diff --git a/.agents/skills/codexbar/SKILL.md b/.agents/skills/codexbar/SKILL.md new file mode 100644 index 0000000000..abcc6e3b2d --- /dev/null +++ b/.agents/skills/codexbar/SKILL.md @@ -0,0 +1,38 @@ +--- +name: codexbar +description: "CodexBar read. Provider usage, limits, credits, config health. JSON. No writes." +--- + +# CodexBar + +Read CodexBar. Never mutate config/auth. + +## Run + +```bash +skill="${CODEX_HOME:-$HOME/.codex}/skills/codexbar" +"$skill/scripts/codexbar" doctor +"$skill/scripts/codexbar" providers +"$skill/scripts/codexbar" usage +"$skill/scripts/codexbar" usage --provider codex +"$skill/scripts/codexbar" usage --all +``` + +All stdout: JSON. Upstream CodexBar shape kept. Less drift, fewer tokens. + +## Rules + +- Start `doctor` when install/config unknown. +- `usage` reads enabled providers. Prefer this. +- `usage --provider ID` reads one provider. +- `usage --all` expensive; use only when needed. +- Identities hidden by default. `--include-identities` only when user explicitly needs them. +- Secrets always hidden. +- Helper read-only: fixed allowlist only. No config writes, auth repair, enable/disable, key storage. +- Timeout means upstream stuck. Narrow provider or raise `CODEXBAR_TIMEOUT` (default 120 seconds). + +## Binary + +Auto-find: `CODEXBAR_BIN`, PATH, app bundle, Homebrew cask. If missing: open CodexBar, Preferences > Advanced > Install CLI; or set `CODEXBAR_BIN`. + +Each stdout/stderr stream capped at 1 MiB while fully drained. Timeout kills process group. diff --git a/.agents/skills/codexbar/scripts/codexbar b/.agents/skills/codexbar/scripts/codexbar new file mode 100755 index 0000000000..d60c8d45d4 --- /dev/null +++ b/.agents/skills/codexbar/scripts/codexbar @@ -0,0 +1,321 @@ +#!/usr/bin/env python3 +"""Read-only, bounded CodexBar CLI bridge.""" + +from __future__ import annotations + +import argparse +import json +import os +import re +import shutil +import signal +import subprocess +import sys +import threading +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Sequence + + +MAX_CAPTURE_BYTES = 1024 * 1024 +DEFAULT_TIMEOUT = 120.0 +BIN_ENV = "CODEXBAR_BIN" +TIMEOUT_ENV = "CODEXBAR_TIMEOUT" +SKIP_DISCOVERY_ENV = "CODEXBAR_SKIP_DISCOVERY" + +SECRET = "" +IDENTITY = "" +EMAIL = "" + +EMAIL_RE = re.compile(r"\b[A-Z0-9._%+\-]+@[A-Z0-9.\-]+\.[A-Z]{2,}\b", re.I) +BEARER_RE = re.compile(r"(?i)\bbearer\s+[A-Z0-9._~+/=\-]+") +JWT_RE = re.compile(r"\beyJ[A-Za-z0-9_\-]+\.[A-Za-z0-9_\-]+\.[A-Za-z0-9_\-]+\b") +LABELED_SECRET_RE = re.compile( + r"(?i)\b(authorization|api[_-]?key|access[_-]?token|refresh[_-]?token|id[_-]?token|" + r"session(?:id)?|secret|password|passwd|cookie|set-cookie)(\s*[:=]\s*)([^\s,;]+)" +) +WORD_SECRET_RE = re.compile( + r"(?i)\b(token|secret|password|passwd|cookie)\s+([A-Z0-9._~+/=\-]{6,})\b" +) +SECRET_KEY_RE = re.compile( + r"(?i)(authorization|api[_-]?key|access[_-]?token|refresh[_-]?token|id[_-]?token|" + r"session(?:id)?|secret|password|passwd|cookie)" +) +IDENTITY_KEYS = { + "accountemail", + "accountorganization", + "accountid", + "accountname", + "email", + "organization", + "userid", + "username", +} +# ProviderIdentitySnapshot.providerID is UsageProvider, not an account identifier. + + +@dataclass(frozen=True) +class Binary: + path: str + source: str + + +@dataclass(frozen=True) +class Result: + returncode: int + stdout: bytes + stderr: bytes + timed_out: bool + + +def normalized_key(value: str) -> str: + return re.sub(r"[^a-z0-9]", "", value.lower()) + + +def timeout_seconds() -> float: + try: + return max(1.0, float(os.environ.get(TIMEOUT_ENV, DEFAULT_TIMEOUT))) + except ValueError: + return DEFAULT_TIMEOUT + + +def safe_path(path: str) -> str: + home = str(Path.home()) + if path == home: + return "~" + if path.startswith(home + os.sep): + return "~" + path[len(home) :] + return re.sub(r"^/Users/[^/]+", "/Users/", path) + + +def candidates() -> list[tuple[str, Path]]: + found: list[tuple[str, Path]] = [] + override = os.environ.get(BIN_ENV) + if override: + found.append(("env", Path(override).expanduser())) + + path_hit = shutil.which("codexbar") + if path_hit: + found.append(("path", Path(path_hit))) + + if os.environ.get(SKIP_DISCOVERY_ENV) == "1": + return found + + home = Path.home() + found.extend( + [ + ("homebrew", Path("/opt/homebrew/bin/codexbar")), + ("homebrew", Path("/usr/local/bin/codexbar")), + ("app", Path("/Applications/CodexBar.app/Contents/Helpers/CodexBarCLI")), + ("app", home / "Applications/CodexBar.app/Contents/Helpers/CodexBarCLI"), + ] + ) + for root in (Path("/opt/homebrew/Caskroom/codexbar"), Path("/usr/local/Caskroom/codexbar")): + found.extend(("cask", app / "Contents/Helpers/CodexBarCLI") for app in sorted(root.glob("*/CodexBar.app"))) + return found + + +def resolve_binary() -> Binary | None: + self_path = Path(__file__).resolve() + seen: set[Path] = set() + for source, candidate in candidates(): + try: + resolved = candidate.resolve() + if resolved in seen or resolved == self_path: + continue + seen.add(resolved) + if resolved.is_file() and os.access(resolved, os.X_OK): + return Binary(str(resolved), source) + except OSError: + continue + return None + + +def drain(pipe: Any, target: bytearray) -> None: + try: + while True: + chunk = pipe.read(65536) + if not chunk: + break + room = MAX_CAPTURE_BYTES - len(target) + if room > 0: + target.extend(chunk[:room]) + finally: + pipe.close() + + +def stop_group(process: subprocess.Popen[bytes], sig: signal.Signals) -> None: + try: + os.killpg(process.pid, sig) + except ProcessLookupError: + pass + + +def run_process(argv: Sequence[str]) -> Result: + process = subprocess.Popen( + list(argv), + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + start_new_session=True, + ) + assert process.stdout is not None + assert process.stderr is not None + stdout = bytearray() + stderr = bytearray() + readers = [ + threading.Thread(target=drain, args=(process.stdout, stdout), daemon=True), + threading.Thread(target=drain, args=(process.stderr, stderr), daemon=True), + ] + for reader in readers: + reader.start() + + timed_out = False + try: + process.wait(timeout=timeout_seconds()) + except subprocess.TimeoutExpired: + timed_out = True + stop_group(process, signal.SIGTERM) + try: + process.wait(timeout=2) + except subprocess.TimeoutExpired: + stop_group(process, signal.SIGKILL) + process.wait() + + for reader in readers: + reader.join(timeout=3) + return Result(124 if timed_out else process.returncode, bytes(stdout), bytes(stderr), timed_out) + + +def decode(value: bytes) -> str: + return value.decode("utf-8", errors="replace") + + +def sanitize_text(value: str, include_identities: bool) -> str: + value = JWT_RE.sub(SECRET, value) + value = BEARER_RE.sub("Bearer " + SECRET, value) + value = LABELED_SECRET_RE.sub(lambda match: match.group(1) + match.group(2) + SECRET, value) + value = WORD_SECRET_RE.sub(lambda match: match.group(1) + " " + SECRET, value) + if not include_identities: + value = EMAIL_RE.sub(EMAIL, value) + return value + + +def sanitize(value: Any, include_identities: bool, key: str | None = None) -> Any: + if key and SECRET_KEY_RE.search(key): + return SECRET + if key and normalized_key(key) in IDENTITY_KEYS and not include_identities and value is not None: + return EMAIL if "email" in normalized_key(key) else IDENTITY + if isinstance(value, dict): + return {str(child_key): sanitize(child, include_identities, str(child_key)) for child_key, child in value.items()} + if isinstance(value, list): + return [sanitize(child, include_identities) for child in value] + if isinstance(value, str): + return sanitize_text(value, include_identities) + return value + + +def print_json(value: Any) -> None: + print(json.dumps(value, indent=2, sort_keys=True)) + + +def print_error(kind: str, message: str, *, binary: Binary | None = None) -> None: + payload: dict[str, Any] = {"error": {"kind": kind, "message": sanitize_text(message, False)}} + if binary: + payload["binary"] = {"path": safe_path(binary.path), "source": binary.source} + print_json(payload) + + +def emit_stderr(result: Result, include_identities: bool) -> None: + text = sanitize_text(decode(result.stderr), include_identities).strip() + if text: + print(text, file=sys.stderr) + + +def read_json(binary: Binary, argv: Sequence[str], include_identities: bool) -> int: + result = run_process([binary.path, *argv]) + emit_stderr(result, include_identities) + if result.timed_out: + print_error("timeout", f"CodexBar exceeded {timeout_seconds():g}s and was stopped.", binary=binary) + return 124 + try: + payload = json.loads(decode(result.stdout)) + except json.JSONDecodeError as error: + preview = sanitize_text(decode(result.stdout[:400]), False) + print_error("invalid_json", f"CodexBar JSON failed: {error.msg}. stdout={preview!r}", binary=binary) + return result.returncode or 1 + print_json(sanitize(payload, include_identities)) + return result.returncode + + +def doctor(binary: Binary, include_identities: bool) -> int: + version = run_process([binary.path, "--version"]) + if version.timed_out: + print_error("timeout", f"CodexBar version exceeded {timeout_seconds():g}s.", binary=binary) + return 124 + validation = run_process([binary.path, "config", "validate", "--format", "json", "--json-only"]) + emit_stderr(version, include_identities) + emit_stderr(validation, include_identities) + if validation.timed_out: + print_error("timeout", f"CodexBar config validation exceeded {timeout_seconds():g}s.", binary=binary) + return 124 + try: + issues = json.loads(decode(validation.stdout)) + except json.JSONDecodeError as error: + print_error("invalid_json", f"CodexBar config validation failed: {error.msg}.", binary=binary) + return validation.returncode or 1 + print_json( + { + "binary": {"path": safe_path(binary.path), "source": binary.source}, + "configIssues": sanitize(issues, include_identities), + "version": sanitize_text((decode(version.stdout) or decode(version.stderr)).strip(), include_identities), + } + ) + return version.returncode or validation.returncode + + +def parser() -> argparse.ArgumentParser: + root = argparse.ArgumentParser(prog="codexbar", description="CodexBar read. JSON out. No writes.") + commands = root.add_subparsers(dest="command", required=True) + for name in ("doctor", "providers"): + command = commands.add_parser(name) + command.add_argument("--include-identities", action="store_true") + usage = commands.add_parser("usage") + scope = usage.add_mutually_exclusive_group() + scope.add_argument("--all", action="store_true") + scope.add_argument("--provider") + usage.add_argument("--include-identities", action="store_true") + return root + + +def main(argv: Sequence[str] | None = None) -> int: + args = parser().parse_args(argv) + binary = resolve_binary() + if not binary: + print_error("missing", "CodexBar CLI not found. Install CLI in CodexBar Advanced settings or set CODEXBAR_BIN.") + return 1 + try: + if args.command == "doctor": + return doctor(binary, args.include_identities) + if args.command == "providers": + return read_json( + binary, + ["config", "providers", "--format", "json", "--json-only"], + args.include_identities, + ) + usage_args = ["usage", "--format", "json", "--json-only"] + if args.all: + usage_args.extend(["--provider", "all"]) + elif args.provider: + usage_args.extend(["--provider", args.provider]) + return read_json(binary, usage_args, args.include_identities) + except OSError as error: + print_error("launch", str(error), binary=binary) + return 1 + + +if __name__ == "__main__": + try: + raise SystemExit(main()) + except KeyboardInterrupt: + print_error("interrupted", "Interrupted.") + raise SystemExit(130) diff --git a/.agents/skills/codexbar/scripts/test_codexbar.py b/.agents/skills/codexbar/scripts/test_codexbar.py new file mode 100644 index 0000000000..ceb9dc7450 --- /dev/null +++ b/.agents/skills/codexbar/scripts/test_codexbar.py @@ -0,0 +1,231 @@ +from __future__ import annotations + +import json +import os +import runpy +import subprocess +import sys +import tempfile +import textwrap +import time +import unittest +from pathlib import Path + + +SCRIPT = Path(__file__).with_name("codexbar") +MODULE = runpy.run_path(str(SCRIPT), run_name="codexbar_skill") +MAX_CAPTURE_BYTES = MODULE["MAX_CAPTURE_BYTES"] +run_process = MODULE["run_process"] + +FAKE = textwrap.dedent( + """\ + #!/usr/bin/env python3 + import json + import os + import subprocess + import sys + import time + + argv = sys.argv[1:] + log = os.environ.get("FAKE_LOG") + if log: + with open(log, "a", encoding="utf-8") as handle: + handle.write(json.dumps(argv) + "\\n") + + if argv == ["--version"]: + key = "VERSION" + elif argv[:2] == ["config", "validate"]: + key = "VALIDATE" + elif argv[:2] == ["config", "providers"]: + key = "PROVIDERS" + elif argv and argv[0] == "usage": + key = "USAGE" + else: + key = "OTHER" + + if os.environ.get("FAKE_SPAWN_CHILD") == "1": + marker = os.environ["FAKE_MARKER"] + subprocess.Popen([ + sys.executable, + "-c", + f"import pathlib,time; time.sleep(2); pathlib.Path({marker!r}).write_text('alive')", + ]) + time.sleep(5) + + delay = os.environ.get(f"FAKE_{key}_DELAY") + if delay: + time.sleep(float(delay)) + stdout = os.environ.get(f"FAKE_{key}_STDOUT", "") + stderr = os.environ.get(f"FAKE_{key}_STDERR", "") + sys.stdout.write(stdout) + sys.stderr.write(stderr) + raise SystemExit(int(os.environ.get(f"FAKE_{key}_EXIT", "0"))) + """ +) + + +def make_env(root: Path, *, install: bool = True) -> tuple[dict[str, str], Path]: + binary = root / "CodexBar.app" / "Contents" / "Helpers" / "CodexBarCLI" + binary.parent.mkdir(parents=True) + binary.write_text(FAKE, encoding="utf-8") + binary.chmod(0o755) + env = os.environ.copy() + env["CODEXBAR_SKIP_DISCOVERY"] = "1" + env["CODEXBAR_TIMEOUT"] = "3" + env["FAKE_LOG"] = str(root / "argv.log") + env["FAKE_VERSION_STDOUT"] = "CodexBar 1.2.3\n" + env["FAKE_VALIDATE_STDOUT"] = "[]" + env["FAKE_PROVIDERS_STDOUT"] = json.dumps( + [{"provider": "codex", "displayName": "Codex", "enabled": True}] + ) + env["FAKE_USAGE_STDOUT"] = json.dumps( + [ + { + "provider": "codex", + "source": "oauth", + "usage": { + "accountEmail": "alice@example.com", + "accountOrganization": "Example Org", + "identity": {"providerID": "codex", "accountID": "acct-123"}, + "primary": {"usedPercent": 42, "windowMinutes": 300}, + }, + } + ] + ) + if install: + env["CODEXBAR_BIN"] = str(binary) + else: + env.pop("CODEXBAR_BIN", None) + env["PATH"] = "/usr/bin:/bin:/usr/sbin:/sbin" + return env, binary + + +def helper(*args: str, env: dict[str, str], cwd: Path | None = None) -> subprocess.CompletedProcess[str]: + return subprocess.run( + [sys.executable, str(SCRIPT), *args], + capture_output=True, + text=True, + check=False, + env=env, + cwd=cwd, + ) + + +class CodexBarSkillTests(unittest.TestCase): + def test_help_is_small_and_read_only(self) -> None: + result = helper("--help", env=os.environ.copy()) + self.assertEqual(result.returncode, 0) + self.assertIn("CodexBar read. JSON out. No writes.", result.stdout) + self.assertNotIn("enable", result.stdout) + self.assertNotIn("set-api-key", result.stdout) + + def test_missing_binary_is_json(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + env, _ = make_env(Path(tmp), install=False) + result = helper("doctor", env=env) + self.assertEqual(result.returncode, 1) + self.assertEqual(json.loads(result.stdout)["error"]["kind"], "missing") + + def test_doctor_reports_version_and_raw_validation_shape(self) -> None: + with tempfile.TemporaryDirectory(dir=Path.home()) as tmp: + env, binary = make_env(Path(tmp)) + result = helper("doctor", env=env) + self.assertEqual(result.returncode, 0) + payload = json.loads(result.stdout) + self.assertEqual(payload["version"], "CodexBar 1.2.3") + self.assertEqual(payload["configIssues"], []) + self.assertEqual(payload["binary"]["path"], "~" + str(binary)[len(str(Path.home())) :]) + + def test_providers_passes_upstream_json_without_second_schema(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + env, _ = make_env(Path(tmp)) + result = helper("providers", env=env) + self.assertEqual(result.returncode, 0) + self.assertEqual( + json.loads(result.stdout), + [{"provider": "codex", "displayName": "Codex", "enabled": True}], + ) + + def test_usage_defaults_to_enabled_and_runs_from_any_cwd(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + env, _ = make_env(root) + elsewhere = root / "elsewhere" + elsewhere.mkdir() + result = helper("usage", env=env, cwd=elsewhere) + calls = [json.loads(line) for line in (root / "argv.log").read_text().splitlines()] + self.assertEqual(result.returncode, 0) + self.assertEqual(calls, [["usage", "--format", "json", "--json-only"]]) + + def test_usage_scope_maps_to_upstream_cli(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + env, _ = make_env(root) + self.assertEqual(helper("usage", "--all", env=env).returncode, 0) + self.assertEqual(helper("usage", "--provider", "zai", env=env).returncode, 0) + calls = [json.loads(line) for line in (root / "argv.log").read_text().splitlines()] + self.assertEqual(calls[0][-2:], ["--provider", "all"]) + self.assertEqual(calls[1][-2:], ["--provider", "zai"]) + + def test_usage_hides_identity_but_preserves_provider_id(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + env, _ = make_env(Path(tmp)) + env["FAKE_USAGE_STDERR"] = ( + "Authorization: Bearer secret-token alice@example.com; token sk-live-prose-secret\n" + ) + result = helper("usage", env=env) + payload = json.loads(result.stdout)[0] + self.assertEqual(payload["provider"], "codex") + self.assertEqual(payload["usage"]["identity"]["providerID"], "codex") + self.assertEqual(payload["usage"]["accountEmail"], "") + self.assertEqual(payload["usage"]["accountOrganization"], "") + self.assertEqual(payload["usage"]["identity"]["accountID"], "") + self.assertNotIn("secret-token", result.stderr) + self.assertNotIn("sk-live-prose-secret", result.stderr) + self.assertIn("", result.stderr) + + def test_include_identities_never_exposes_secret_keys(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + env, _ = make_env(Path(tmp)) + payload = json.loads(env["FAKE_USAGE_STDOUT"]) + payload[0]["usage"]["apiKey"] = "super-secret" + env["FAKE_USAGE_STDOUT"] = json.dumps(payload) + result = helper("usage", "--include-identities", env=env) + usage = json.loads(result.stdout)[0]["usage"] + self.assertEqual(usage["accountEmail"], "alice@example.com") + self.assertEqual(usage["apiKey"], "") + + def test_each_stream_is_capped_while_fully_drained(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + writer = root / "writer" + writer.write_text( + "#!/usr/bin/env python3\n" + "import sys\n" + "sys.stdout.buffer.write(b'o' * 2000000)\n" + "sys.stderr.buffer.write(b'e' * 2000000)\n", + encoding="utf-8", + ) + writer.chmod(0o755) + result = run_process([str(writer)]) + self.assertEqual(result.returncode, 0) + self.assertEqual(len(result.stdout), MAX_CAPTURE_BYTES) + self.assertEqual(len(result.stderr), MAX_CAPTURE_BYTES) + + def test_timeout_kills_process_group(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + env, _ = make_env(root) + marker = root / "child-survived" + env["CODEXBAR_TIMEOUT"] = "1" + env["FAKE_SPAWN_CHILD"] = "1" + env["FAKE_MARKER"] = str(marker) + result = helper("usage", env=env) + time.sleep(2.2) + self.assertEqual(result.returncode, 124) + self.assertEqual(json.loads(result.stdout)["error"]["kind"], "timeout") + self.assertFalse(marker.exists()) + + +if __name__ == "__main__": + unittest.main() diff --git a/.agents/skills/qa-test/SKILL.md b/.agents/skills/qa-test/SKILL.md new file mode 100644 index 0000000000..81c752a165 --- /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 0000000000..3bf7a7b278 --- /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 0000000000..8bb7298c6f --- /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 0000000000..0c8240ce05 --- /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 7407bf2a45..a8ad938e49 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,30 +2,133 @@ name: CI on: push: - branches: ["**"] + branches: [main] pull_request: + types: [opened, synchronize, reopened, ready_for_review, converted_to_draft] concurrency: group: ci-${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true +env: + SWIFT_VERSION: 6.3.3 + SWIFTLY_VERSION: 1.1.3 + SWIFTLY_SIGNING_FINGERPRINT: E813C892820A6FA13755B268F167DF1ACF9CE069 + jobs: - lint-build-test: + changes: + runs-on: ubuntu-24.04 + timeout-minutes: 5 + outputs: + macos-tests: ${{ steps.macos-tests.outputs.macos-tests }} + macos-tests-deferred: ${{ steps.macos-tests.outputs.macos-tests-deferred }} + macos-tests-reason: ${{ steps.macos-tests.outputs.macos-tests-reason }} + changed-path-count: ${{ steps.macos-tests.outputs.changed-path-count }} + linux-musl-build: ${{ steps.linux-musl-build.outputs.linux-musl-build }} + linux-musl-build-reason: ${{ steps.linux-musl-build.outputs.linux-musl-build-reason }} + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + fetch-depth: 0 + + - name: Detect macOS test impact + id: macos-tests + shell: bash + env: + CI_PULL_REQUEST_DRAFT: ${{ github.event.pull_request.draft || false }} + run: | + set -euo pipefail + + if [[ "${{ github.event_name }}" == "pull_request" ]]; then + base_sha="${{ github.event.pull_request.base.sha }}" + else + base_sha="${{ github.event.before }}" + fi + + changed_paths="${RUNNER_TEMP}/changed-paths.txt" + if [[ -z "$base_sha" || "$base_sha" =~ ^0+$ ]]; then + git ls-files | awk '{ print "A\t" $0 }' > "$changed_paths" + elif ! git cat-file -e "${base_sha}^{commit}" 2>/dev/null; then + echo "Base commit ${base_sha} is unavailable; running macOS tests conservatively." + git ls-files | awk '{ print "A\t" $0 }' > "$changed_paths" + else + git diff --name-status "$base_sha" "$GITHUB_SHA" > "$changed_paths" + fi + + printf 'Changed paths:\n' + sed 's/^/- /' "$changed_paths" + ./Scripts/ci_macos_test_gate.sh "$changed_paths" + + - name: Detect Linux musl build impact + id: linux-musl-build + shell: bash + run: ./Scripts/ci_linux_musl_build_gate.sh "$RUNNER_TEMP/changed-paths.txt" + + - name: Summarize CI path gates + if: ${{ always() }} + shell: bash + env: + MACOS_TESTS: ${{ steps.macos-tests.outputs.macos-tests }} + MACOS_TESTS_REASON: ${{ steps.macos-tests.outputs.macos-tests-reason }} + CHANGED_PATH_COUNT: ${{ steps.macos-tests.outputs.changed-path-count }} + LINUX_MUSL_BUILD: ${{ steps.linux-musl-build.outputs.linux-musl-build }} + LINUX_MUSL_BUILD_REASON: ${{ steps.linux-musl-build.outputs.linux-musl-build-reason }} + run: | + set -euo pipefail + reason="${MACOS_TESTS_REASON:-}" + reason="${reason//|/\\|}" + musl_reason="${LINUX_MUSL_BUILD_REASON:-}" + musl_reason="${musl_reason//|/\\|}" + { + printf '### CI path gates\n\n' + printf '| Field | Value |\n' + printf '| --- | --- |\n' + printf '| macOS Swift tests required | `%s` |\n' "${MACOS_TESTS:-}" + printf '| macOS reason | %s |\n' "$reason" + printf '| Linux musl build required | `%s` |\n' "${LINUX_MUSL_BUILD:-}" + printf '| Linux musl reason | %s |\n' "$musl_reason" + printf '| Changed path entries | `%s` |\n' "${CHANGED_PATH_COUNT:-}" + } >> "$GITHUB_STEP_SUMMARY" + + lint: + runs-on: ubuntu-24.04 + timeout-minutes: 10 + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + + - name: Install lint tools + run: ./Scripts/install_lint_tools.sh swiftlint + + - name: Lint + run: ./Scripts/lint.sh lint-linux + + swift-test-macos: + needs: changes + if: ${{ needs.changes.outputs.macos-tests == 'true' && needs.changes.outputs.macos-tests-deferred != 'true' }} runs-on: macos-15-intel - timeout-minutes: 70 + timeout-minutes: 60 + strategy: + fail-fast: false + matrix: + # Two shards amortize SwiftPM's per-shard discovery/cold build. + # Each shard remains within the 50-minute Swift Test timeout. + shard-index: [0, 1] + shard-count: [2] steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - - name: Select Xcode 26.1.1 (if present) or fallback to default + - name: Select Xcode 26.3 or 26.2 run: | set -euo pipefail - for candidate in /Applications/Xcode_26.1.1.app /Applications/Xcode_26.1.app /Applications/Xcode.app; do + # Both versions are part of the official macOS 15 runner image. + for candidate in /Applications/Xcode_26.3.app /Applications/Xcode_26.2.app; do if [[ -d "$candidate" ]]; then sudo xcode-select -s "${candidate}/Contents/Developer" echo "DEVELOPER_DIR=${candidate}/Contents/Developer" >> "$GITHUB_ENV" break fi done + [[ "$(/usr/bin/xcodebuild -version)" == Xcode\ 26.* ]] /usr/bin/xcodebuild -version - name: Swift toolchain version @@ -34,16 +137,272 @@ jobs: swift --version swift package --version - - name: Install lint tools - run: ./Scripts/install_lint_tools.sh - - - name: Lint - run: ./Scripts/lint.sh lint + - name: Check app locales and Swift formatting + if: ${{ matrix.shard-index == 0 }} + run: ./Scripts/lint.sh lint-macos - name: Swift Test - timeout-minutes: 60 + # Keep small batches to reduce SwiftPM process overhead without returning to one aggregate run. + timeout-minutes: 50 + run: | + CODEXBAR_TEST_GROUP_SIZE=4 \ + CODEXBAR_TEST_SUITE_TIMEOUT=120 \ + CODEXBAR_TEST_RETRY_NON_TIMEOUT_FAILURES=0 \ + CODEXBAR_TEST_SHARD_INDEX=${{ matrix.shard-index }} \ + CODEXBAR_TEST_SHARD_COUNT=${{ matrix.shard-count }} \ + ./Scripts/test.sh + + - name: Summarize macOS shard + if: ${{ always() }} + shell: bash + env: + SHARD_INDEX: ${{ matrix.shard-index }} + SHARD_COUNT: ${{ matrix.shard-count }} + RUNS_LINT_MACOS: ${{ matrix.shard-index == 0 }} + run: | + set -euo pipefail + display_shard_index=$((SHARD_INDEX + 1)) + xcode_version="$(/usr/bin/xcodebuild -version 2>/dev/null | tr '\n' ' ' || true)" + { + printf '### macOS Swift shard\n\n' + printf '| Field | Value |\n' + printf '| --- | --- |\n' + printf '| Shard | `%s / %s` |\n' "$display_shard_index" "$SHARD_COUNT" + printf '| Runner | `%s` |\n' "${RUNNER_NAME:-unknown}" + printf '| Xcode | `%s` |\n' "${xcode_version:-unknown}" + printf '| Runs lint-macos | `%s` |\n' "$RUNS_LINT_MACOS" + } >> "$GITHUB_STEP_SUMMARY" + + lint-build-test: + runs-on: ubuntu-24.04 + timeout-minutes: 5 + needs: + - changes + - lint + - swift-test-macos + - build-linux-musl-cli + if: ${{ always() && !cancelled() }} + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + + - name: Verify required CI jobs + run: | + ./Scripts/ci_verify_test_jobs.sh \ + "${{ needs.lint.result }}" \ + "${{ needs.changes.result }}" \ + "${{ needs.changes.outputs.macos-tests }}" \ + "${{ needs.swift-test-macos.result }}" \ + "${{ needs.changes.outputs.macos-tests-deferred }}" \ + "${{ needs.changes.outputs.linux-musl-build }}" \ + "${{ needs.build-linux-musl-cli.result }}" + + - name: Summarize aggregate CI gate + if: ${{ always() }} + shell: bash + env: + LINT_RESULT: ${{ needs.lint.result }} + CHANGES_RESULT: ${{ needs.changes.result }} + MACOS_TESTS: ${{ needs.changes.outputs.macos-tests }} + MACOS_TESTS_DEFERRED: ${{ needs.changes.outputs.macos-tests-deferred }} + MACOS_TESTS_REASON: ${{ needs.changes.outputs.macos-tests-reason }} + MACOS_RESULT: ${{ needs.swift-test-macos.result }} + LINUX_MUSL_BUILD: ${{ needs.changes.outputs.linux-musl-build }} + LINUX_MUSL_BUILD_REASON: ${{ needs.changes.outputs.linux-musl-build-reason }} + LINUX_MUSL_RESULT: ${{ needs.build-linux-musl-cli.result }} + run: | + set -euo pipefail + reason="${MACOS_TESTS_REASON:-}" + reason="${reason//|/\\|}" + musl_reason="${LINUX_MUSL_BUILD_REASON:-}" + musl_reason="${musl_reason//|/\\|}" + { + printf '### Aggregate CI gate\n\n' + printf '| Field | Value |\n' + printf '| --- | --- |\n' + printf '| lint result | `%s` |\n' "$LINT_RESULT" + printf '| changes result | `%s` |\n' "$CHANGES_RESULT" + printf '| macOS Swift tests required | `%s` |\n' "${MACOS_TESTS:-}" + printf '| macOS Swift tests deferred | `%s` |\n' "${MACOS_TESTS_DEFERRED:-}" + printf '| macOS gate reason | %s |\n' "$reason" + printf '| swift-test-macos result | `%s` |\n' "$MACOS_RESULT" + printf '| Linux musl build required | `%s` |\n' "${LINUX_MUSL_BUILD:-}" + printf '| Linux musl gate reason | %s |\n' "$musl_reason" + printf '| build-linux-musl-cli result | `%s` |\n' "$LINUX_MUSL_RESULT" + } >> "$GITHUB_STEP_SUMMARY" + + build-linux-musl-cli: + needs: changes + if: ${{ needs.changes.outputs.linux-musl-build == 'true' }} + runs-on: ubuntu-24.04 + timeout-minutes: 20 + env: + SWIFT_VERSION: 6.2.1 + SWIFT_STATIC_LINUX_SDK: swift-6.2.1-RELEASE_static-linux-0.0.1 + SWIFT_STATIC_LINUX_SDK_TRIPLE: x86_64-swift-linux-musl + SWIFT_STATIC_LINUX_SDK_ARCH: x86_64 + SWIFT_STATIC_LINUX_SDK_URL: https://download.swift.org/swift-6.2.1-release/static-sdk/swift-6.2.1-RELEASE/swift-6.2.1-RELEASE_static-linux-0.0.1.artifactbundle.tar.gz + SWIFT_STATIC_LINUX_SDK_CHECKSUM: 08e1939a504e499ec871b36826569173103e4562769e12b9b8c2a50f098374ad + SQLITE_AMALGAMATION_VERSION: "3530300" + SQLITE_AMALGAMATION_SHA3_256: d45c688a8cb23f68611a894a756a12d7eb6ab6e9e2468ca70adbeab3808b5ab9 + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + + - name: Runner info + run: | + set -euo pipefail + uname -a + uname -m + swift --version + + - name: Install Swift ${{ env.SWIFT_VERSION }} via swiftly + shell: bash + run: | + set -euo pipefail + + if ! command -v gpg >/dev/null 2>&1; then + sudo apt-get update + sudo apt-get install -y ca-certificates gpg + fi + + SWIFTLY_ARCH="$(uname -m)" + SWIFTLY_ARCHIVE="$RUNNER_TEMP/swiftly-${SWIFTLY_VERSION}-${SWIFTLY_ARCH}.tar.gz" + SWIFTLY_SIGNATURE="${SWIFTLY_ARCHIVE}.sig" + SWIFTLY_HOME_DIR="$HOME/.local/share/swiftly" + SWIFTLY_BIN_DIR="$HOME/.local/bin" + SWIFT_GNUPGHOME="$(mktemp -d)" + SWIFT_KEYS="$RUNNER_TEMP/swift-signing-keys.asc" + POST_INSTALL_SCRIPT="$(mktemp)" + + mkdir -p "$SWIFTLY_BIN_DIR" + chmod 700 "$SWIFT_GNUPGHOME" + curl -fsSL "https://download.swift.org/swiftly/linux/swiftly-${SWIFTLY_VERSION}-${SWIFTLY_ARCH}.tar.gz" -o "$SWIFTLY_ARCHIVE" + curl -fsSL "https://download.swift.org/swiftly/linux/swiftly-${SWIFTLY_VERSION}-${SWIFTLY_ARCH}.tar.gz.sig" -o "$SWIFTLY_SIGNATURE" + curl -fsSL --compressed "https://www.swift.org/keys/all-keys.asc" -o "$SWIFT_KEYS" + GNUPGHOME="$SWIFT_GNUPGHOME" gpg --batch --import "$SWIFT_KEYS" + SIGNATURE_STATUS="$(GNUPGHOME="$SWIFT_GNUPGHOME" gpg --batch --status-fd=1 \ + --verify "$SWIFTLY_SIGNATURE" "$SWIFTLY_ARCHIVE" 2>&1)" + printf '%s\n' "$SIGNATURE_STATUS" + grep -Fq "[GNUPG:] VALIDSIG ${SWIFTLY_SIGNING_FINGERPRINT} " <<< "$SIGNATURE_STATUS" + tar -xzf "$SWIFTLY_ARCHIVE" -C /tmp + /tmp/swiftly init --assume-yes --skip-install + + . "$SWIFTLY_HOME_DIR/env.sh" + echo "$SWIFTLY_BIN_DIR" >> "$GITHUB_PATH" + echo "SWIFTLY_HOME_DIR=$SWIFTLY_HOME_DIR" >> "$GITHUB_ENV" + echo "SWIFTLY_BIN_DIR=$SWIFTLY_BIN_DIR" >> "$GITHUB_ENV" + + swiftly install "$SWIFT_VERSION" --use --assume-yes --verify --post-install-file "$POST_INSTALL_SCRIPT" + if [[ -s "$POST_INSTALL_SCRIPT" ]]; then + sudo apt-get update + sudo bash "$POST_INSTALL_SCRIPT" + fi + + hash -r + swift --version + + - name: Install Swift Static Linux SDK + shell: bash run: | - python3 Scripts/ci_swift_test_by_suite.py --group-size 1 --timeout 120 + set -euo pipefail + swift sdk install "$SWIFT_STATIC_LINUX_SDK_URL" --checksum "$SWIFT_STATIC_LINUX_SDK_CHECKSUM" + swift sdk list | grep -Fx "$SWIFT_STATIC_LINUX_SDK" + + sdk_root="$( + find "$HOME" -type d -path "*/${SWIFT_STATIC_LINUX_SDK}.artifactbundle/${SWIFT_STATIC_LINUX_SDK}/swift-linux-musl" | head -n1 + )" + if [[ -z "$sdk_root" ]]; then + echo "Swift SDK root not found." >&2 + exit 1 + fi + + python3 - "$sdk_root/swift-sdk.json" "$SWIFT_STATIC_LINUX_SDK_TRIPLE" <<'PY' + import json + import sys + + sdk_json_path, target_triple = sys.argv[1], sys.argv[2] + with open(sdk_json_path, encoding="utf-8") as handle: + sdk_json = json.load(handle) + + target_triples = sdk_json.get("targetTriples", {}) + if target_triple not in target_triples: + raise SystemExit(f"Swift SDK target triple not found: {target_triple}") + + sdk_json["targetTriples"] = {target_triple: target_triples[target_triple]} + with open(sdk_json_path, "w", encoding="utf-8") as handle: + json.dump(sdk_json, handle, indent=2) + handle.write("\n") + PY + + for sdk_arch in "$sdk_root"/musl-1.2.5.sdk/*; do + if [[ "$(basename "$sdk_arch")" != "$SWIFT_STATIC_LINUX_SDK_ARCH" ]]; then + rm -rf "$sdk_arch" + fi + done + + - name: Build static SQLite for musl SDK + shell: bash + run: | + set -euo pipefail + + missing_packages=() + for tool in clang openssl unzip; do + if ! command -v "$tool" >/dev/null 2>&1; then + missing_packages+=("$tool") + fi + done + if [[ "${#missing_packages[@]}" -gt 0 ]]; then + sudo apt-get update + sudo apt-get install -y "${missing_packages[@]}" + fi + + sqlite_zip="$RUNNER_TEMP/sqlite-amalgamation-${SQLITE_AMALGAMATION_VERSION}.zip" + sqlite_src="$RUNNER_TEMP/sqlite-src" + sqlite_out="$RUNNER_TEMP/sqlite-${SWIFT_STATIC_LINUX_SDK_TRIPLE}" + + curl -fsSL "https://www.sqlite.org/2026/sqlite-amalgamation-${SQLITE_AMALGAMATION_VERSION}.zip" -o "$sqlite_zip" + actual_sha3="$(openssl dgst -sha3-256 "$sqlite_zip" | awk '{print $NF}')" + if [[ "$actual_sha3" != "$SQLITE_AMALGAMATION_SHA3_256" ]]; then + echo "SQLite amalgamation checksum mismatch: $actual_sha3" >&2 + exit 1 + fi + + rm -rf "$sqlite_src" "$sqlite_out" + mkdir -p "$sqlite_src" "$sqlite_out/build" "$sqlite_out/lib" + unzip -q "$sqlite_zip" -d "$sqlite_src" + + sdk_bundle="$( + find "$HOME" -type d -name "${SWIFT_STATIC_LINUX_SDK}.artifactbundle" | head -n1 + )" + if [[ -z "$sdk_bundle" ]]; then + echo "Swift SDK artifact bundle not found." >&2 + exit 1 + fi + sysroot="$sdk_bundle/$SWIFT_STATIC_LINUX_SDK/swift-linux-musl/musl-1.2.5.sdk/$SWIFT_STATIC_LINUX_SDK_ARCH" + if [[ ! -d "$sysroot" ]]; then + echo "Swift SDK sysroot not found: $sysroot" >&2 + exit 1 + fi + + sqlite_amalgamation="$sqlite_src/sqlite-amalgamation-${SQLITE_AMALGAMATION_VERSION}" + mkdir -p "$sysroot/usr/include" + install -m 0644 "$sqlite_amalgamation/sqlite3.h" "$sysroot/usr/include/sqlite3.h" + + clang \ + -target "$SWIFT_STATIC_LINUX_SDK_TRIPLE" \ + --sysroot="$sysroot" \ + -O2 \ + -DSQLITE_OMIT_LOAD_EXTENSION=1 \ + -c "$sqlite_amalgamation/sqlite3.c" \ + -o "$sqlite_out/build/sqlite3.o" + llvm-ar crs "$sqlite_out/lib/libsqlite3.a" "$sqlite_out/build/sqlite3.o" + + echo "CODEXBAR_SQLITE3_LIB_DIR=$sqlite_out/lib" >> "$GITHUB_ENV" + + - name: Build CodexBarCLI (release, Linux musl x86_64) + run: >- + swift build -c release --product CodexBarCLI + --swift-sdk "$SWIFT_STATIC_LINUX_SDK" + --triple "$SWIFT_STATIC_LINUX_SDK_TRIPLE" build-linux-cli: timeout-minutes: 20 @@ -57,7 +416,7 @@ jobs: runs-on: ubuntu-24.04-arm runs-on: ${{ matrix.runs-on }} steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Runner info run: | @@ -65,24 +424,50 @@ jobs: uname -a uname -m - - name: Install Swift 6.2.1 via swiftly + - name: Restore Swift toolchain cache + id: swift-toolchain-cache + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: ~/.local/share/swiftly + key: swift-${{ runner.os }}-${{ runner.arch }}-${{ env.SWIFT_VERSION }}-swiftly-${{ env.SWIFTLY_VERSION }} + + - name: Install Swift ${{ env.SWIFT_VERSION }} via swiftly shell: bash run: | set -euo pipefail - if ! command -v gpg >/dev/null 2>&1; then + missing_packages=() + for package in ca-certificates gpg libcurl4-openssl-dev; do + if ! dpkg-query -W -f='${Status}' "$package" 2>/dev/null | grep -q "install ok installed"; then + missing_packages+=("$package") + fi + done + if [[ "${#missing_packages[@]}" -gt 0 ]]; then sudo apt-get update - sudo apt-get install -y ca-certificates gpg + sudo apt-get install -y "${missing_packages[@]}" fi SWIFTLY_ARCH="$(uname -m)" + SWIFTLY_ARCHIVE="$RUNNER_TEMP/swiftly-${SWIFTLY_VERSION}-${SWIFTLY_ARCH}.tar.gz" + SWIFTLY_SIGNATURE="${SWIFTLY_ARCHIVE}.sig" SWIFTLY_HOME_DIR="$HOME/.local/share/swiftly" SWIFTLY_BIN_DIR="$HOME/.local/bin" + SWIFT_GNUPGHOME="$(mktemp -d)" + SWIFT_KEYS="$RUNNER_TEMP/swift-signing-keys.asc" POST_INSTALL_SCRIPT="$(mktemp)" mkdir -p "$SWIFTLY_BIN_DIR" - curl -fsSL "https://download.swift.org/swiftly/linux/swiftly-${SWIFTLY_ARCH}.tar.gz" -o /tmp/swiftly.tar.gz - tar -xzf /tmp/swiftly.tar.gz -C /tmp + chmod 700 "$SWIFT_GNUPGHOME" + echo "Swift toolchain cache hit: ${{ steps.swift-toolchain-cache.outputs.cache-hit }}" + curl -fsSL "https://download.swift.org/swiftly/linux/swiftly-${SWIFTLY_VERSION}-${SWIFTLY_ARCH}.tar.gz" -o "$SWIFTLY_ARCHIVE" + curl -fsSL "https://download.swift.org/swiftly/linux/swiftly-${SWIFTLY_VERSION}-${SWIFTLY_ARCH}.tar.gz.sig" -o "$SWIFTLY_SIGNATURE" + curl -fsSL --compressed "https://www.swift.org/keys/all-keys.asc" -o "$SWIFT_KEYS" + GNUPGHOME="$SWIFT_GNUPGHOME" gpg --batch --import "$SWIFT_KEYS" + SIGNATURE_STATUS="$(GNUPGHOME="$SWIFT_GNUPGHOME" gpg --batch --status-fd=1 \ + --verify "$SWIFTLY_SIGNATURE" "$SWIFTLY_ARCHIVE" 2>&1)" + printf '%s\n' "$SIGNATURE_STATUS" + grep -Fq "[GNUPG:] VALIDSIG ${SWIFTLY_SIGNING_FINGERPRINT} " <<< "$SIGNATURE_STATUS" + tar -xzf "$SWIFTLY_ARCHIVE" -C /tmp /tmp/swiftly init --assume-yes --skip-install . "$SWIFTLY_HOME_DIR/env.sh" @@ -90,7 +475,7 @@ jobs: echo "SWIFTLY_HOME_DIR=$SWIFTLY_HOME_DIR" >> "$GITHUB_ENV" echo "SWIFTLY_BIN_DIR=$SWIFTLY_BIN_DIR" >> "$GITHUB_ENV" - swiftly install 6.2.1 --use --assume-yes --post-install-file "$POST_INSTALL_SCRIPT" + swiftly install "$SWIFT_VERSION" --use --assume-yes --verify --post-install-file "$POST_INSTALL_SCRIPT" if [[ -s "$POST_INSTALL_SCRIPT" ]]; then sudo apt-get update sudo bash "$POST_INSTALL_SCRIPT" @@ -119,3 +504,22 @@ jobs: fi "$BIN" usage --provider codex --web 2>&1 | tee /tmp/codexbarcli-stderr.txt >/dev/null || true grep -q "macOS" /tmp/codexbarcli-stderr.txt + + - name: Summarize Linux CLI build + if: ${{ always() }} + shell: bash + env: + MATRIX_NAME: ${{ matrix.name }} + MATRIX_RUNS_ON: ${{ matrix.runs-on }} + SWIFT_CACHE_HIT: ${{ steps.swift-toolchain-cache.outputs.cache-hit }} + run: | + set -euo pipefail + { + printf '### Linux CLI build\n\n' + printf '| Field | Value |\n' + printf '| --- | --- |\n' + printf '| Matrix | `%s` |\n' "$MATRIX_NAME" + printf '| Runner label | `%s` |\n' "$MATRIX_RUNS_ON" + printf '| Swift version | `%s` |\n' "$SWIFT_VERSION" + printf '| Swift toolchain cache hit | `%s` |\n' "${SWIFT_CACHE_HIT:-false}" + } >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/release-cli.yml b/.github/workflows/release-cli.yml index b9ad64bdc5..75a020a0c1 100644 --- a/.github/workflows/release-cli.yml +++ b/.github/workflows/release-cli.yml @@ -22,44 +22,95 @@ jobs: - name: linux-x64 runs-on: ubuntu-24.04 platform: linux + asset-platform: linux asset-arch: x86_64 build-arch: "" static-swift-stdlib: true + swift-sdk: "" + swift-sdk-triple: "" + swift-sdk-arch: "" + file-arch-pattern: x86-64 - name: linux-arm64 runs-on: ubuntu-24.04-arm platform: linux + asset-platform: linux asset-arch: aarch64 build-arch: "" static-swift-stdlib: true + swift-sdk: "" + swift-sdk-triple: "" + swift-sdk-arch: "" + file-arch-pattern: aarch64 + - name: linux-musl-x64 + runs-on: ubuntu-24.04 + platform: linux + asset-platform: linux-musl + asset-arch: x86_64 + build-arch: "" + static-swift-stdlib: false + swift-sdk: swift-6.2.1-RELEASE_static-linux-0.0.1 + swift-sdk-triple: x86_64-swift-linux-musl + swift-sdk-arch: x86_64 + file-arch-pattern: x86-64 + - name: linux-musl-arm64 + runs-on: ubuntu-24.04-arm + platform: linux + asset-platform: linux-musl + asset-arch: aarch64 + build-arch: "" + static-swift-stdlib: false + swift-sdk: swift-6.2.1-RELEASE_static-linux-0.0.1 + swift-sdk-triple: aarch64-swift-linux-musl + swift-sdk-arch: aarch64 + file-arch-pattern: aarch64 - name: macos-arm64 runs-on: macos-15 platform: macos + asset-platform: macos asset-arch: arm64 build-arch: arm64 static-swift-stdlib: false + swift-sdk: "" + swift-sdk-triple: "" + swift-sdk-arch: "" + file-arch-pattern: "" - name: macos-x86_64 runs-on: macos-15-intel platform: macos + asset-platform: macos asset-arch: x86_64 build-arch: x86_64 static-swift-stdlib: false + swift-sdk: "" + swift-sdk-triple: "" + swift-sdk-arch: "" + file-arch-pattern: "" runs-on: ${{ matrix.runs-on }} env: RELEASE_TAG: ${{ inputs.tag || github.ref_name }} + SWIFT_VERSION: 6.2.1 + SWIFTLY_VERSION: 1.1.3 + SWIFTLY_SIGNING_FINGERPRINT: E813C892820A6FA13755B268F167DF1ACF9CE069 + SWIFT_STATIC_LINUX_SDK_URL: https://download.swift.org/swift-6.2.1-release/static-sdk/swift-6.2.1-RELEASE/swift-6.2.1-RELEASE_static-linux-0.0.1.artifactbundle.tar.gz + SWIFT_STATIC_LINUX_SDK_CHECKSUM: 08e1939a504e499ec871b36826569173103e4562769e12b9b8c2a50f098374ad + SQLITE_AMALGAMATION_VERSION: "3530300" + SQLITE_AMALGAMATION_SHA3_256: d45c688a8cb23f68611a894a756a12d7eb6ab6e9e2468ca70adbeab3808b5ab9 steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - - name: Select Xcode 26.1.1 (if present) or fallback to default + - name: Select Xcode 26.3 or 26.2 if: matrix.platform == 'macos' run: | set -euo pipefail - for candidate in /Applications/Xcode_26.1.1.app /Applications/Xcode_26.1.app /Applications/Xcode.app; do + # Both versions are part of the official macOS 15 runner image. + for candidate in /Applications/Xcode_26.3.app /Applications/Xcode_26.2.app; do if [[ -d "$candidate" ]]; then sudo xcode-select -s "${candidate}/Contents/Developer" echo "DEVELOPER_DIR=${candidate}/Contents/Developer" >> "$GITHUB_ENV" break fi done + [[ "$(/usr/bin/xcodebuild -version)" == Xcode\ 26.* ]] /usr/bin/xcodebuild -version - name: Runner info @@ -83,7 +134,7 @@ jobs: exit 1 fi - - name: Install Swift 6.2.1 via swiftly + - name: Install Swift ${{ env.SWIFT_VERSION }} via swiftly if: matrix.platform == 'linux' shell: bash run: | @@ -95,13 +146,25 @@ jobs: fi SWIFTLY_ARCH="$(uname -m)" + SWIFTLY_ARCHIVE="$RUNNER_TEMP/swiftly-${SWIFTLY_VERSION}-${SWIFTLY_ARCH}.tar.gz" + SWIFTLY_SIGNATURE="${SWIFTLY_ARCHIVE}.sig" SWIFTLY_HOME_DIR="$HOME/.local/share/swiftly" SWIFTLY_BIN_DIR="$HOME/.local/bin" + SWIFT_GNUPGHOME="$(mktemp -d)" + SWIFT_KEYS="$RUNNER_TEMP/swift-signing-keys.asc" POST_INSTALL_SCRIPT="$(mktemp)" mkdir -p "$SWIFTLY_BIN_DIR" - curl -fsSL "https://download.swift.org/swiftly/linux/swiftly-${SWIFTLY_ARCH}.tar.gz" -o /tmp/swiftly.tar.gz - tar -xzf /tmp/swiftly.tar.gz -C /tmp + chmod 700 "$SWIFT_GNUPGHOME" + curl -fsSL "https://download.swift.org/swiftly/linux/swiftly-${SWIFTLY_VERSION}-${SWIFTLY_ARCH}.tar.gz" -o "$SWIFTLY_ARCHIVE" + curl -fsSL "https://download.swift.org/swiftly/linux/swiftly-${SWIFTLY_VERSION}-${SWIFTLY_ARCH}.tar.gz.sig" -o "$SWIFTLY_SIGNATURE" + curl -fsSL --compressed "https://www.swift.org/keys/all-keys.asc" -o "$SWIFT_KEYS" + GNUPGHOME="$SWIFT_GNUPGHOME" gpg --batch --import "$SWIFT_KEYS" + SIGNATURE_STATUS="$(GNUPGHOME="$SWIFT_GNUPGHOME" gpg --batch --status-fd=1 \ + --verify "$SWIFTLY_SIGNATURE" "$SWIFTLY_ARCHIVE" 2>&1)" + printf '%s\n' "$SIGNATURE_STATUS" + grep -Fq "[GNUPG:] VALIDSIG ${SWIFTLY_SIGNING_FINGERPRINT} " <<< "$SIGNATURE_STATUS" + tar -xzf "$SWIFTLY_ARCHIVE" -C /tmp /tmp/swiftly init --assume-yes --skip-install . "$SWIFTLY_HOME_DIR/env.sh" @@ -109,7 +172,7 @@ jobs: echo "SWIFTLY_HOME_DIR=$SWIFTLY_HOME_DIR" >> "$GITHUB_ENV" echo "SWIFTLY_BIN_DIR=$SWIFTLY_BIN_DIR" >> "$GITHUB_ENV" - swiftly install 6.2.1 --use --assume-yes --post-install-file "$POST_INSTALL_SCRIPT" + swiftly install "$SWIFT_VERSION" --use --assume-yes --verify --post-install-file "$POST_INSTALL_SCRIPT" if [[ -s "$POST_INSTALL_SCRIPT" ]]; then sudo apt-get update sudo bash "$POST_INSTALL_SCRIPT" @@ -118,6 +181,106 @@ jobs: hash -r swift --version + - name: Install Swift Static Linux SDK + if: matrix.swift-sdk != '' + shell: bash + run: | + set -euo pipefail + swift sdk install "$SWIFT_STATIC_LINUX_SDK_URL" --checksum "$SWIFT_STATIC_LINUX_SDK_CHECKSUM" + swift sdk list | grep -Fx "${{ matrix.swift-sdk }}" + + sdk_root="$( + find "$HOME" -type d -path "*/${{ matrix.swift-sdk }}.artifactbundle/${{ matrix.swift-sdk }}/swift-linux-musl" | head -n1 + )" + if [[ -z "$sdk_root" ]]; then + echo "Swift SDK root not found." >&2 + exit 1 + fi + + python3 - "$sdk_root/swift-sdk.json" "${{ matrix.swift-sdk-triple }}" <<'PY' + import json + import sys + + sdk_json_path, target_triple = sys.argv[1], sys.argv[2] + with open(sdk_json_path, encoding="utf-8") as handle: + sdk_json = json.load(handle) + + target_triples = sdk_json.get("targetTriples", {}) + if target_triple not in target_triples: + raise SystemExit(f"Swift SDK target triple not found: {target_triple}") + + sdk_json["targetTriples"] = {target_triple: target_triples[target_triple]} + with open(sdk_json_path, "w", encoding="utf-8") as handle: + json.dump(sdk_json, handle, indent=2) + handle.write("\n") + PY + + for sdk_arch in "$sdk_root"/musl-1.2.5.sdk/*; do + if [[ "$(basename "$sdk_arch")" != "${{ matrix.swift-sdk-arch }}" ]]; then + rm -rf "$sdk_arch" + fi + done + + - name: Build static SQLite for musl SDK + if: matrix.swift-sdk != '' + shell: bash + run: | + set -euo pipefail + + missing_packages=() + for tool in clang openssl unzip; do + if ! command -v "$tool" >/dev/null 2>&1; then + missing_packages+=("$tool") + fi + done + if [[ "${#missing_packages[@]}" -gt 0 ]]; then + sudo apt-get update + sudo apt-get install -y "${missing_packages[@]}" + fi + + sqlite_zip="$RUNNER_TEMP/sqlite-amalgamation-${SQLITE_AMALGAMATION_VERSION}.zip" + sqlite_src="$RUNNER_TEMP/sqlite-src" + sqlite_out="$RUNNER_TEMP/sqlite-${{ matrix.swift-sdk-triple }}" + + curl -fsSL "https://www.sqlite.org/2026/sqlite-amalgamation-${SQLITE_AMALGAMATION_VERSION}.zip" -o "$sqlite_zip" + actual_sha3="$(openssl dgst -sha3-256 "$sqlite_zip" | awk '{print $NF}')" + if [[ "$actual_sha3" != "$SQLITE_AMALGAMATION_SHA3_256" ]]; then + echo "SQLite amalgamation checksum mismatch: $actual_sha3" >&2 + exit 1 + fi + + rm -rf "$sqlite_src" "$sqlite_out" + mkdir -p "$sqlite_src" "$sqlite_out/build" "$sqlite_out/lib" + unzip -q "$sqlite_zip" -d "$sqlite_src" + + sdk_bundle="$( + find "$HOME" -type d -name "${{ matrix.swift-sdk }}.artifactbundle" | head -n1 + )" + if [[ -z "$sdk_bundle" ]]; then + echo "Swift SDK artifact bundle not found." >&2 + exit 1 + fi + sysroot="$sdk_bundle/${{ matrix.swift-sdk }}/swift-linux-musl/musl-1.2.5.sdk/${{ matrix.swift-sdk-arch }}" + if [[ ! -d "$sysroot" ]]; then + echo "Swift SDK sysroot not found: $sysroot" >&2 + exit 1 + fi + + sqlite_amalgamation="$sqlite_src/sqlite-amalgamation-${SQLITE_AMALGAMATION_VERSION}" + mkdir -p "$sysroot/usr/include" + install -m 0644 "$sqlite_amalgamation/sqlite3.h" "$sysroot/usr/include/sqlite3.h" + + clang \ + -target "${{ matrix.swift-sdk-triple }}" \ + --sysroot="$sysroot" \ + -O2 \ + -DSQLITE_OMIT_LOAD_EXTENSION=1 \ + -c "$sqlite_amalgamation/sqlite3.c" \ + -o "$sqlite_out/build/sqlite3.o" + llvm-ar crs "$sqlite_out/lib/libsqlite3.a" "$sqlite_out/build/sqlite3.o" + + echo "CODEXBAR_SQLITE3_LIB_DIR=$sqlite_out/lib" >> "$GITHUB_ENV" + - name: Build CodexBarCLI (release) id: build shell: bash @@ -125,7 +288,9 @@ jobs: set -euo pipefail BUILD_ARGS=(swift build -c release --product CodexBarCLI) - if [[ -n "${{ matrix.build-arch }}" ]]; then + if [[ -n "${{ matrix.swift-sdk }}" ]]; then + BUILD_ARGS+=(--swift-sdk "${{ matrix.swift-sdk }}" --triple "${{ matrix.swift-sdk-triple }}") + elif [[ -n "${{ matrix.build-arch }}" ]]; then BUILD_ARGS+=(--arch "${{ matrix.build-arch }}") fi if [[ "${{ matrix.static-swift-stdlib }}" == "true" ]]; then @@ -134,7 +299,9 @@ jobs: "${BUILD_ARGS[@]}" SHOW_BIN_ARGS=(swift build -c release --product CodexBarCLI --show-bin-path) - if [[ -n "${{ matrix.build-arch }}" ]]; then + if [[ -n "${{ matrix.swift-sdk }}" ]]; then + SHOW_BIN_ARGS+=(--swift-sdk "${{ matrix.swift-sdk }}" --triple "${{ matrix.swift-sdk-triple }}") + elif [[ -n "${{ matrix.build-arch }}" ]]; then SHOW_BIN_ARGS+=(--arch "${{ matrix.build-arch }}") fi if [[ "${{ matrix.static-swift-stdlib }}" == "true" ]]; then @@ -184,9 +351,14 @@ jobs: if [[ "${{ matrix.platform }}" == "macos" ]]; then lipo -archs "$BIN" | tr ' ' '\n' | grep -Fx "${{ matrix.asset-arch }}" run_with_timeout "$RUNNER_TEMP/codexbar-cli-smoke-${{ matrix.name }}.txt" "$BIN" config validate --format json + elif [[ -n "${{ matrix.swift-sdk }}" ]]; then + run_with_timeout "$RUNNER_TEMP/codexbar-cli-help-${{ matrix.name }}.txt" "$BIN" --help + run_with_timeout "$RUNNER_TEMP/codexbar-cli-config-${{ matrix.name }}.json" "$BIN" config validate --format json + file "$BIN" | grep -q "${{ matrix.file-arch-pattern }}" + file "$BIN" | grep -q "statically linked" else run_with_timeout "$RUNNER_TEMP/codexbar-cli-help-${{ matrix.name }}.txt" "$BIN" --help - file "$BIN" | grep -q "${{ matrix.asset-arch }}" + file "$BIN" | grep -q "${{ matrix.file-arch-pattern }}" fi printf '%s\n' "${RELEASE_TAG#v}" > "$BIN_DIR/VERSION" VERSION_OUTPUT="$RUNNER_TEMP/codexbar-cli-version-${{ matrix.name }}.txt" @@ -213,7 +385,7 @@ jobs: ln -s "CodexBarCLI" "$OUT_DIR/codexbar" printf '%s\n' "${SAFE_REF_NAME#v}" > "$OUT_DIR/VERSION" - ASSET="CodexBarCLI-${SAFE_REF_NAME}-${{ matrix.platform }}-${{ matrix.asset-arch }}.tar.gz" + ASSET="CodexBarCLI-${SAFE_REF_NAME}-${{ matrix.asset-platform }}-${{ matrix.asset-arch }}.tar.gz" (cd "$OUT_DIR" && tar czf "$ASSET" CodexBarCLI codexbar VERSION) if command -v sha256sum >/dev/null 2>&1; then sha256sum "$OUT_DIR/$ASSET" > "$OUT_DIR/$ASSET.sha256" @@ -238,7 +410,7 @@ jobs: - name: Upload workflow artifact (manual runs) if: github.event_name == 'workflow_dispatch' - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: codexbar-cli-${{ matrix.name }} path: | @@ -246,7 +418,7 @@ jobs: ${{ steps.pkg.outputs.out_dir }}/${{ steps.pkg.outputs.asset }}.sha256 update-homebrew-tap: - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 needs: build-cli if: github.event_name == 'release' steps: diff --git a/.github/workflows/upstream-monitor.yml b/.github/workflows/upstream-monitor.yml index 3deb7b70d7..63ee3ce725 100644 --- a/.github/workflows/upstream-monitor.yml +++ b/.github/workflows/upstream-monitor.yml @@ -18,14 +18,14 @@ on: jobs: check-upstreams: - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 permissions: issues: write contents: read steps: - name: Checkout repository - uses: actions/checkout@v6 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: fetch-depth: 0 @@ -91,17 +91,24 @@ jobs: - name: Create or update issue if: steps.check.outputs.upstream_commits > 0 || steps.check.outputs.quotio_commits > 0 - uses: actions/github-script@v8 + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + UPSTREAM_COMMITS: ${{ steps.check.outputs.upstream_commits }} + QUOTIO_COMMITS: ${{ steps.check.outputs.quotio_commits }} + UPSTREAM_REF: ${{ steps.check.outputs.upstream_ref }} + QUOTIO_REF: ${{ steps.check.outputs.quotio_ref }} + UPSTREAM_SUMMARY: ${{ steps.check.outputs.upstream_summary }} + QUOTIO_SUMMARY: ${{ steps.check.outputs.quotio_summary }} with: script: | - const upstreamCommits = '${{ steps.check.outputs.upstream_commits }}'; - const quotioCommits = '${{ steps.check.outputs.quotio_commits }}'; - const upstreamRef = '${{ steps.check.outputs.upstream_ref }}'; - const quotioRef = '${{ steps.check.outputs.quotio_ref }}'; + const upstreamCommits = process.env.UPSTREAM_COMMITS; + const quotioCommits = process.env.QUOTIO_COMMITS; + const upstreamRef = process.env.UPSTREAM_REF; + const quotioRef = process.env.QUOTIO_REF; const upstreamBranch = upstreamRef.replace('upstream/', ''); const quotioBranch = quotioRef.replace('quotio/', ''); - const upstreamSummary = `${{ steps.check.outputs.upstream_summary }}`; - const quotioSummary = `${{ steps.check.outputs.quotio_summary }}`; + const upstreamSummary = process.env.UPSTREAM_SUMMARY; + const quotioSummary = process.env.QUOTIO_SUMMARY; const body = `## 🔄 Upstream Changes Detected diff --git a/.gitignore b/.gitignore index 8bb2ec1f8f..462a66f97a 100644 --- a/.gitignore +++ b/.gitignore @@ -18,6 +18,8 @@ Codexbar.app/ # Release artifacts *.ipa *.dSYM* +*.xcarchive/ +*.xcresult/ *.zip *.delta *.dmg @@ -33,9 +35,11 @@ debug_*.swift .codex/environments/ .swiftpm-cache/ .tmp-clang/ +__pycache__/ # Debug/analysis docs docs/*-analysis.md +docs/.viewport-audit/ docs/.astro/ # Swift Package Manager metadata (leave sources tracked) diff --git a/.mac-release.env b/.mac-release.env index 07a027ed35..25afb639b2 100644 --- a/.mac-release.env +++ b/.mac-release.env @@ -5,9 +5,9 @@ MAC_RELEASE_VERSION_FILE=version.env MAC_RELEASE_APPCAST=appcast.xml MAC_RELEASE_SOURCE_FILES='Scripts/release_artifacts.sh' MAC_RELEASE_SUPUBLIC_ED_KEY=AGCY8w5vHirVfGGDGc8Szc5iuOqupZSh9pMj/Qs67XI= -# Older shared AGCY Sparkle key fallback. SPARKLE_PRIVATE_KEY_FILE still wins; -# Keychain is used when this local file is absent. -MAC_RELEASE_SIGNING_KEY_FILE='$HOME/Library/CloudStorage/Dropbox/Backup/Sparkle/sparkle-private-key-OBSOLETE-not-for-BlackBar-publickey-AGCY8w5vHirVfGGDGc8Szc5iuOqupZSh9pMj_Qs67XI-2026-05-21.txt' +# Shared AGCY Sparkle key (matches the embedded SUPublicEDKey above). +# SPARKLE_PRIVATE_KEY_FILE still wins; Keychain is used when this local file is absent. +MAC_RELEASE_SIGNING_KEY_FILE='$HOME/Library/CloudStorage/Dropbox/Backup/Sparkle/sparkle-private-key-Peekaboo-appcast-publickey-AGCY8w5vHirVfGGDGc8Szc5iuOqupZSh9pMj_Qs67XI-2026-05-21.txt' MAC_RELEASE_APP_ZIP='$(codexbar_app_zip_name "$MARKETING_VERSION" "${ARCHES:-arm64 x86_64}")' MAC_RELEASE_DSYM_ZIP='$(codexbar_dsym_zip_name "$MARKETING_VERSION" "${ARCHES:-arm64 x86_64}")' @@ -15,8 +15,18 @@ 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='make check && make test' MAC_RELEASE_PACKAGE_CMD='Scripts/sign-and-notarize.sh' +MAC_RELEASE_OP_ITEM='API Key - App Store Connect - Personal - Release' +MAC_RELEASE_OP_VAULT=Molty +MAC_RELEASE_OP_FIELDS='APP_STORE_CONNECT_KEY_ID APP_STORE_CONNECT_ISSUER_ID APP_STORE_CONNECT_API_KEY_P8' +MAC_RELEASE_OP_USE_SERVICE_ACCOUNT=1 +MAC_RELEASE_CODESIGN_IDENTITY='Developer ID Application: Peter Steinberger (Y5PE65HELJ)' +MAC_RELEASE_CODESIGN_OP_ITEM='Developer ID Release Keychain' +MAC_RELEASE_CODESIGN_OP_VAULT=Molty +MAC_RELEASE_CODESIGN_OP_USE_SERVICE_ACCOUNT=1 +MAC_RELEASE_CODESIGN_KEYCHAIN_MANAGED=1 +MAC_RELEASE_CODESIGN_PASSWORDLESS=1 MAC_RELEASE_TAG_SIGNED=1 MAC_RELEASE_TAG_FORCE=1 MAC_RELEASE_GENERATE_APPCAST_ARGS='--maximum-deltas 0' @@ -29,4 +39,8 @@ MAC_RELEASE_EXTRA_ASSET_PATTERNS='^CodexBarCLI-v${MARKETING_VERSION}-macos-arm64 ^CodexBarCLI-v${MARKETING_VERSION}-linux-aarch64\.tar\.gz$ ^CodexBarCLI-v${MARKETING_VERSION}-linux-aarch64\.tar\.gz\.sha256$ ^CodexBarCLI-v${MARKETING_VERSION}-linux-x86_64\.tar\.gz$ -^CodexBarCLI-v${MARKETING_VERSION}-linux-x86_64\.tar\.gz\.sha256$' +^CodexBarCLI-v${MARKETING_VERSION}-linux-x86_64\.tar\.gz\.sha256$ +^CodexBarCLI-v${MARKETING_VERSION}-linux-musl-aarch64\.tar\.gz$ +^CodexBarCLI-v${MARKETING_VERSION}-linux-musl-aarch64\.tar\.gz\.sha256$ +^CodexBarCLI-v${MARKETING_VERSION}-linux-musl-x86_64\.tar\.gz$ +^CodexBarCLI-v${MARKETING_VERSION}-linux-musl-x86_64\.tar\.gz\.sha256$' diff --git a/.swiftformat b/.swiftformat index 4f3218d9ec..656852f5b8 100644 --- a/.swiftformat +++ b/.swiftformat @@ -1,4 +1,4 @@ -# SwiftFormat configuration for Peekaboo project +# SwiftFormat configuration for CodexBar # Compatible with Swift 6 strict concurrency mode # IMPORTANT: Don't remove self where it's required for Swift 6 concurrency @@ -39,7 +39,8 @@ --enumthreshold 0 # Swift 6 specific ---swiftversion 6.2 +--swiftversion 6.3 +--disable redundantSendable # Keep explicit concurrency contracts visible # Other --stripunusedargs closure-only diff --git a/AGENTS.md b/AGENTS.md index b5f6f49d00..21b2f857cf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -7,8 +7,8 @@ - `docs`: release notes and process (`docs/RELEASING.md`, screenshots). Root-level zips/appcast are generated artifacts—avoid editing except during releases. ## Build, Test, Run -- Dev loop: `./Scripts/compile_and_run.sh` kills old instances, runs `swift build` + `swift test`, packages, relaunches `CodexBar.app`, and confirms it stays running. -- Quick build/test: `swift build` (debug) or `swift build -c release`; `swift test` for the full XCTest suite. +- Dev loop: `./Scripts/compile_and_run.sh` kills old instances, builds, packages, relaunches `CodexBar.app`, and confirms it stays running; add `--test` for the sharded full suite. +- Quick build/test: `swift build` (debug) or `swift build -c release`; `make test` for the sharded full suite. - Package locally: `./Scripts/package_app.sh` to refresh `CodexBar.app`, then restart with `pkill -x CodexBar || pkill -f CodexBar.app || true; cd /Users/steipete/Projects/codexbar && open -n /Users/steipete/Projects/codexbar/CodexBar.app`. - Release flow: `./Scripts/release.sh`; app metadata lives in `.mac-release.env`, repo build/signing stays in `Scripts/sign-and-notarize.sh`, and validation steps live in `docs/RELEASING.md`. @@ -18,7 +18,9 @@ ## Testing Guidelines - Add/extend XCTest cases under `Tests/CodexBarTests/*Tests.swift` (`FeatureNameTests` with `test_caseDescription` methods). -- Always run `swift test` before handoff; add focused filters for parser/provider fixes when possible. +- Swift Testing: prefer backticked sentence names; no camelCase. +- Model names in tests/code: released models or clearly fictitious names only; never expose unreleased names. +- Always run `make test` before handoff; add focused `swift test --filter ...` runs for parser/provider fixes when possible. - After any code change, run `make check` and fix all reported format/lint issues before handoff. - Prefer CLI/focused tests over app-bundle live tests when behavior can be verified without relaunching CodexBar. - 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`. @@ -30,13 +32,14 @@ ## Agent Notes - Use the provided scripts and package manager (SwiftPM); avoid adding dependencies or tooling without confirmation. +- Menu bar automation: capture the target screen first and verify the CodexBar icon is visibly onscreen. Reject `click-extra` success when coordinates fall outside display bounds; hidden menu extras are not click proof. - Validate UI/runtime behavior against the freshly built bundle; restart via the pkill+open command above to avoid running stale binaries. - To guarantee the right bundle is running after a rebuild, use: `pkill -x CodexBar || pkill -f CodexBar.app || true; cd /Users/steipete/Projects/codexbar && open -n /Users/steipete/Projects/codexbar/CodexBar.app`. - For CLI-testable provider/parser/settings behavior, use CLI/focused tests instead of `Scripts/package_app.sh` or `./Scripts/compile_and_run.sh`. - Run `./Scripts/compile_and_run.sh` only when UI/runtime behavior needs bundle-level validation; it builds, tests, packages, relaunches, and verifies the app stays running. - Widget/Tahoe UI issues: use Parallels macOS VM plus screenshots/clicks for autonomous verification. - Release script: keep it in the foreground; do not background it—wait until it finishes. -- Release keys: find in `~/.profile` if missing (Sparkle + App Store Connect). +- Sparkle release key: use `.mac-release.env` `MAC_RELEASE_SIGNING_KEY_FILE`, the legacy `AGCY8w5vHirVfGGDGc8Szc5iuOqupZSh9pMj/Qs67XI=` key. Do not use `sparkle-private-key-KEEP-SECURE.txt`; that is VibeTunnel's mismatched key. - Swift concurrency: treat sibling `async let` tasks as a review red flag when one child is required and another is optional/best-effort. Prefer sequential awaits or a drained `withThrowingTaskGroup` that surfaces required failures and explicitly contains optional failures; crash stacks mentioning `swift_task_dealloc` or `asyncLet_finish_after_task_completion` should trigger an audit of nearby `async let` usage. - Prefer modern SwiftUI/Observation macros: use `@Observable` models with `@State` ownership and `@Bindable` in views; avoid `ObservableObject`, `@ObservedObject`, and `@StateObject`. - Favor modern macOS 15+ APIs over legacy/deprecated counterparts when refactoring (Observation, new display link APIs, updated menu item styling, etc.). diff --git a/CHANGELOG.md b/CHANGELOG.md index ba14e490b6..04ff90fbe3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,784 @@ # Changelog -## 0.31.1 — Unreleased +## 0.46.1 — Unreleased + +### Changed +- About: link the Website entry to codex.bar. + +## 0.46.0 — 2026-07-29 + +### Added +- Qwen Cloud: new provider for Individual Token Plans with 5-hour and weekly rolling windows (#2361). Thanks @umutkeltek, and @Yach0 for the API investigation! +- ZoomMate: new provider with credits, session history, and pacing, using host-scoped cookie routing (#2344). Thanks @weddle! +- Alibaba: Personal/Solo Token Plan variants for mainland (Bailian) and international (Model Studio) accounts (#2487). Thanks @LeoLin990405 and @halilertekin for the investigations! +- Claude: show prepaid credit balance in cost surfaces, using only cached or manually configured web sessions (#2443). Thanks @Zihao-Qi! +- Claude: setting to hide the Daily Routines row (#2358, fixes #2353). Thanks @Zihao-Qi and @tavlean! +- Codex: local Workspaces indexing foundation for per-workspace usage attribution (#2456). Thanks @AmrMohamad! +- Menu: fractional session quota estimates with a condensed weekly forecast row (#2357). Thanks @Zihao-Qi! + +### Changed +- CLI: `config dump` now redacts stored credentials by default; `--show-secrets` restores raw output (#2410, fixes #2400). Thanks @Yuxin-Qiao! + +### Fixed +- Keychain: disabling Keychain access no longer breaks Cursor and Claude refresh — cookie caches fall back to memory only, and background Claude checks cannot prompt (#2426, fixes #2408 and #2425). Thanks @gmkbenjamin! +- Claude: keep the switcher bar on the account Weekly quota instead of exhausted model carve-outs (#2424, fixes #2423). Thanks @gmkbenjamin! +- Claude: profile-scoped credential caching so multiple Claude profiles cannot reuse each other's cached credentials, with safe legacy migration (#2484, part of #2380). Thanks @ProspectOre! +- Codex: bound cost scans on giant session corpora with resumable parsing — huge rollouts no longer pin a CPU core and still count fully toward cost history (#2452). Thanks @D4ilyHub! +- Menu bar: center stacked two-line custom layouts vertically (#2347, fixes #2345). Thanks @kiranmagic7, and @lg for the measured report! +- Menu bar: stale `--hook-event` launches from other CodexBar installations no longer create duplicate menu bar items (#2416). Thanks @uclort! +- Widgets: prevent a WidgetKit reload loop that caused sustained chronod disk writes near quota resets (#2371). Thanks @Yuxin-Qiao and @cskeleton! +- Widgets: remove an unintended dark background overlay (#2354). Thanks @jarvisluk! +- Widgets: Claude enterprise spend-cap accounts now persist their extra-usage row instead of synthetic Session/Weekly rows (#2478). Thanks @ChenZiHong-Gavin! +- Claude: hide the Daily Routines row entirely when Anthropic returns a null routines payload (#2450). Thanks @urda! +- Claude: show model-scoped weekly rows above Daily Routines (#2461, fixes #2460). Thanks @Eimerrrrr! +- Claude: tolerate garbled "all models" captures so duplicated weekly rows no longer appear (#2434). Thanks @guhyun9454! +- Amp: parse subscription plans (Megawatt) into proper percentage windows instead of a misleading cookie error (#2438, fixes #2435). Thanks @tylergibbs1 and @diegomrv! +- Grok: explicit cookie-refresh imports browser cookies and caches validated sessions for background reuse (#2458). Thanks @olddonkey! +- Kimi: reliable weekly and API-derived window durations now feed pace and forecasts (#2433). Thanks @harjothkhara! +- Chutes: render quota counts as detail text instead of misreading them as reset schedules (#2402, fixes #2399). Thanks @kiranmagic7! +- Alibaba/Qwen: allow Token Plan usage on Linux with a manual cookie (#2356). Thanks @OfficialAbhinavSingh! +- LongCat: automatic cookie import falls back to Firefox after Chrome (#2462, fixes #2463). Thanks @akshayprabhu200! +- Hooks: preserve configured hooks across config saves (#2436, fixes #2432). Thanks @kiranmagic7! +- Menu: prioritize exhausted windows for automatic display while preserving the Antigravity preference (#2352). Thanks @Yuxin-Qiao! +- Menu bar: refresh custom Account labels after account changes (#2362). Thanks @kiranmagic7! +- Usage: keep the learned full-session estimate visible while the session window is idle (#2336). Thanks @Zihao-Qi! +- Resets: show the day form at exactly 24 hours in countdowns (#2343). Thanks @OfficialAbhinavSingh! +- z.ai: clamp the raw-percentage fallback to 0–100 (#2342). Thanks @OfficialAbhinavSingh! +- LLMProxy: skip already-elapsed reset times when picking the next reset (#2335). Thanks @OfficialAbhinavSingh! +- Ollama: reuse validated browser sessions across refreshes, and skip inaccessible Safari cookies during automatic + fallback while preserving explicit Safari permission guidance (#2404). Thanks @hxy91819! + +## 0.45.2 — 2026-07-19 + +### Fixed +- Refresh: prevent macOS 14 launch crashes caused by TaskLocal task-allocation corruption (#2341, fixes #2319 and #2326). Thanks @lzylzylzy130 and @jorgesancha! +- Menu bar: render custom-layout provider icons at the native size and tint them for light and dark menu bars (#2334). Thanks @elpinguinofrio! +- Menu: fix switcher “Weekly progress” to prefer weekly quota windows, with provider-specific fallback when unavailable (#2327). Thanks @Anneo22! +- Command Code: improve progress-bar contrast in dark mode (#2333). Thanks @Baksalyar! +- Widgets: keep cost rows on one line with large token counts (#2337). Thanks @zhulijin1991! +- OpenCode/OpenCode Go: preserve computed sub-1% usage percentages instead of rescaling them as direct fractions (#2331). Thanks @OfficialAbhinavSingh! +- OpenCode Go: prefer local usage for unscoped Auto refreshes while keeping account- and workspace-scoped requests web-first (#2316). Thanks @kiranmagic7! + +## 0.45.1 — 2026-07-19 + +### Added +- Claude: show per-model weekly claude-swap usage windows from schema-v1 account listings (#2310). Thanks @AlexGodard! +- Claude: allow an opt-in claude-swap card when only one account is available (#2280). Thanks @possibilities! +- OpenCode Go: add daily local cost and plan-usage history (#2296). Thanks @kentoku24! +- Overview: raise the merged provider limit from three to six (#2314). Thanks @BobbyWang0120! + +### Changed +- Menu bar: remove status-item hover tooltips to match macOS menu extras, keeping VoiceOver titles (#2315). Thanks @BobbyWang0120! +- Codex: simplify cost labels to "Cost" and move the reported-versus-estimated explanation into Cost settings, keeping a short per-value estimate note (#2313). Thanks @Zihao-Qi! + +### Fixed +- StepFun: fix password login web ID derivation so the header and cookie match the anonymous token (#2312). Thanks @Zihao-Qi! +- Menu bar: refresh custom cost tokens when token-cost data changes (#2305). Thanks @Zihao-Qi! +- Menu bar: refresh custom reset tokens at their displayed time boundaries (#2303). Thanks @Zihao-Qi! +- Usage: normalize session-equivalent forecasts against aligned partial-session samples so extrapolated weekly burn is not overstated (#2301). Thanks @Zihao-Qi! +- Usage: align current/latest and historical cost/token metrics by period (#2295). Thanks @RoshanMhatre! +- Codex: exclude parent-copied prefixes from compact subagent usage when the fork boundary matches the parent snapshot (#2285). Thanks @hhh2210! +- Usage & Spend: fix black share-card PNG exports while keeping rendering compatible with Intel Macs (#2292). Thanks @Chipagosfinest! +- Usage & Spend: keep complete model rows visible when another same-currency source has incomplete history (#2308). Thanks @Chipagosfinest! +- ElevenLabs: clamp character and voice-slot usage percentages at 100% during overage (#2293). Thanks @OfficialAbhinavSingh! + +### Internal +- Serialize the Claude CLI platform-gating cases to prevent nondeterministic Linux CI failures (#2311). Thanks @Chipagosfinest! + +## 0.45.0 — 2026-07-18 + +### Added +- Menu bar: add drag-and-drop layouts with customizable identity, usage, reset, cost, spacing, and stacked-line tokens (#2275). +- Usage: estimate weekly quota in full 5-hour windows and show whether it can run out before reset (#2261). Thanks @hdsheena! +- CLI: add quota-aware codexbar guard automation gates with stable exit codes, explicit windows, JSON output, and bounded fetches (#2237). Thanks @OfficialAbhinavSingh! +- CLI: add a gated browser-cookie refresh command for cookie-backed providers (#2262). Thanks @PINKIIILQWQ! +- OpenCode: add safe cookie re-import actions to OpenCode and OpenCode Go settings, preserving cached sessions until refreshed cookies validate (#2264). Thanks @PINKIIILQWQ! +- Refresh: add an opt-in agent-aware Adaptive mode with consent-gated, bounded local activity detection (#2111). Thanks @hhh2210! +- Codex: add opt-in local session cost estimates for organization API-key users (#2172). Thanks @wicolian! +- Cursor: add dashboard token-cost reports with per-model API-rate estimates and Cursor-metered totals (#1745). Thanks @EClinick! +- Cost usage: include OMP session logs alongside pi-compatible sessions without double-counting shared assistant entries (#2269). Thanks @kevcube! +- OpenRouter: support multiple labeled API-key accounts with isolated usage, stacked/segmented menu cards, and CLI account selection (#2271). Thanks @andyylin! +- Agent sessions: add opt-in descriptive Codex thread and subagent labels with safe project fallback (#2273). Thanks @sirwazzles! +- ai&: add 30-day organization spend from request logs with partial-result labeling when pagination is truncated (#2256). Thanks @jethac! +- DeepInfra: add prepaid balance, monthly spend, spending-limit, and suspension tracking via API keys (#2238). Thanks @billerickson! +- Doubao: add arkcli Coding and Agent Plan usage with bounded CLI execution and personal/team quota support (#2221). Thanks @start3015! +- DeepSeek: show Platform cost and token history with Cost summary while preserving optional-usage consent (#2270). Thanks @Zihao-Qi! +- Confetti: use branded provider palettes for reset celebrations (#2177). Thanks @kreitter! + +### Fixed +- Menu bar: fix palette drag-and-drop in the layout editor so dropped and reordered pills stick (#2279). +- Menu Bar settings: remove the Layout editor's container-wide focus ring while preserving keyboard access to its tokens and controls. +- Providers: gate version probes to enabled providers so disabled providers no longer spawn subprocesses or trigger TCC prompts at launch (#2277, #2278, fixes #2267). Thanks @kiranmagic7! +- Settings: restore Settings opening after keepalive window recreation (#2259). Thanks @devYRPauli! +- Linux CLI: close subprocess capture pipes and prevent EMFILE crashes in long-running serve processes (#2258, fixes #2234). Thanks @Yuxin-Qiao! +- Claude: preserve last-good CLI usage across transient parse failures while clearing stale data after authentication loss (#2247, #2241). Thanks @kiranmagic7! +- Claude: reuse the CLI probe session so refreshes no longer create empty account sessions (#2263). Thanks @elpinguinofrio and @devYRPauli! +- Command Code: retry later browser sessions so stale earlier cookies do not mask an active Vivaldi session (#2281). Thanks @cicae! +- Widgets: align token/cost refreshes with the global cadence, with a five-minute WidgetKit safety floor (#2282). Thanks @zhulijin1991! +- Cursor: clamp plan usage at 100% when included usage exceeds the plan limit (#2255). Thanks @OfficialAbhinavSingh! +- Abacus: clamp overage credit usage to 100% (#2265). Thanks @OfficialAbhinavSingh! + +### Internal +- Tests: migrate remaining process-global test overrides to task-local scopes and remove dead seams (#2239, #2240, #2242, #2245). Thanks @anagnorisis2peripeteia! +- Internal: enforce bounded agent-aware Adaptive scans and zero-scan behavior without consent (#2276). + +## 0.44.0 — 2026-07-17 + +### Added +- ZenMux: add Management API usage with five-hour and weekly quotas, subscription expiry, and USD PAYG balance. Thanks @kays0x! +- Settings: add a local Usage & Spend view with honest 7/30-day coverage, native-currency grouping, and exact-home Codex account scans (#2116). Thanks @Chipagosfinest! +- Settings: add a private local share card for Usage & Spend, with native-currency totals and sanitized plan/model labels (#2112). Thanks @Chipagosfinest! +- Hooks: add opt-in external commands for quota and provider state changes with shell-free execution and refresh-storm protection (#2001). Thanks @jychp! +- CLI: add token-gated dashboard snapshots to codexbar serve, keep sensitive responses uncached, and require explicit plain-HTTP opt-in for LAN binds (#2227). Thanks @jethac! +- CLI: show opt-in claude-swap accounts as full and brief usage cards while preserving explicit account and source overrides (#2188). Thanks @possibilities! +- ClinePass: add API-key usage tracking for five-hour, weekly, and monthly quota windows (#2219). Thanks @joeVenner and @derekszen! +- LongCat: add disabled-by-default quota and fuel-pack tracking with manual or browser cookie authentication (#1697). Thanks @LeoLin990405! +- Neuralwatt: add API-key usage tracking for subscription kWh and prepaid credits (#2220). Thanks @jrimmer and @joeVenner! +- Codex: add opt-in local session cost estimates for organization API-key users (#2172). Thanks @wicolian! +- Cursor: add dashboard token-cost reports with per-model API-rate estimates and Cursor-metered totals (#1745). Thanks @EClinick! +- Copilot: add calendar-month pace projections and markers for reset-aware quotas (#2169). Thanks @Zihao-Qi! +- Grok: add guarded weekly pace projections for seven-day quota windows (#2170). Thanks @Zihao-Qi! +- Groq: add console-session spend and token usage with Enterprise Prometheus fallback (#2125). Thanks @3kh0! +- DeepSeek: add detailed Platform usage, profile-scoped browser sessions, and current-month token history (#2135). Thanks @Zihao-Qi! +- Menu bar: add an opt-in high-contrast mode for Icon & percent that keeps icons and metrics readable on inactive displays (#2210). Thanks @zpmdd! +- MiMo: recover session-only Firefox cookies from bounded session restore files (#1565). Thanks @aaronflorey! +- Confetti: use branded provider palettes for reset celebrations (#2177). Thanks @kreitter! + +### Fixed +- Codex: fix copied-prefix subagent accounting so inherited history is not counted as leaf usage (#2228). Thanks @hhh2210! +- Claude: stop automatic refreshes from launching prompt-capable Claude CLI auth checks or delegated refreshes unless Keychain access is explicitly always allowed (#2191). Thanks @Yuxin-Qiao! +- Claude: stop automatic startup refreshes from prompting for Claude Code credentials under the default “Only on user action” Keychain policy (#2195). Thanks @avenoxai! +- Claude: coalesce Keychain prompts within one refresh so concurrent credential reads reuse one result (#2202). Thanks @farzanariel! +- Claude: prevent duplicate weekly reset confetti after stale usage rebounds (#2231). Thanks @Zihao-Qi! +- Claude: confirm identity-less CLI reset samples before showing session or weekly confetti (#2224). Thanks @Yuxin-Qiao! +- Claude: distinguish Team Standard and Team Premium seats in web-account plan labels while preserving legacy Enterprise plan labels (#1965, #2244). Thanks @hegelty! +- Codex: respect configured work days for weekly pace while keeping Automatic historical projections (#2179). Thanks @Zihao-Qi! +- Codex: hide pace details for fully depleted weekly quotas while retaining reset countdowns (#2226). Thanks @Yuxin-Qiao! +- Codex: label USD totals as API-equivalent estimates so subscription users know they are not billed amounts (#2181). Thanks @Yuxin-Qiao! +- CLI: bound CLI and RPC output buffering to prevent runaway memory growth (#2196). Thanks @Yuxin-Qiao! +- Cost usage: retain incomplete JSONL tails so active Codex, Claude, Vertex AI, and Pi sessions do not lose appended usage records (#2168). Thanks @ShiroKSH! +- Browser cookies: block background Chromium Keychain access so Safe Storage prompts occur only after user-initiated refreshes (#2225). Thanks @Yuxin-Qiao! +- StepFun: show credit-plan usage instead of false 0% and combine mixed balances correctly (#2184). Thanks @douxy1994! +- Grok: preserve team identity and report unavailable team usage instead of failing on personal-team billing errors (#2186). Thanks @vincent-peng! +- Ollama: surface Safari Full Disk Access and browser Keychain recovery hints when session cookies cannot be read (#2249). Thanks @fishcharlie! +- Command Code: fix the billing link to open the generic account settings route instead of a contributor-specific path (#2254). +- Workflows: prevent third-party upstream commit text from being interpolated into privileged GitHub Actions scripts (#2185). Thanks @Hinotoi-agent! +- Claude: preserve each account’s last-good OAuth usage during rate limits and isolate retry cooldowns per credential. Thanks @ruushu! +- Claude: recover a missing credentials file from a valid Claude Code Keychain item without showing Keychain UI when Never prompt is selected (#1975). Thanks @OfficialAbhinavSingh! +- Codex cost usage: invalidate cached fork totals when the parent session appears, changes, or resolves to a different file, preventing stale inherited baselines. Thanks @xx205! +- Cursor: bind interactive account login to one readable browser, preserve the active session on cancellation or failure, and prevent background refreshes from replacing the selected account. Thanks @chapati23! +- Menu bar: prevent duplicate provider items when usage updates re-enter initial status-item setup (#2162). Thanks @ss251! +- Codex cost usage: count restarted subagent token counters without subtracting the parent's unrelated cumulative baseline (#2193). Thanks @qiuruiyu and @harjothkhara! +- Antigravity: add an opt-in setting to prioritize exhausted supported quota lanes in automatic menu-bar and Overview ranking while preserving usable-first defaults. Thanks @Yuxin-Qiao! +- Ollama: explain that API-key verification cannot show Cloud quota limits and direct users to browser-cookie mode (#2159). Thanks @kiranmagic7! +- Claude: suppress duplicate all-model scoped quota rows that could appear as “All models only” beside Weekly. +- Copilot: hide quota bars explicitly marked unlimited while preserving finite Premium and Chat quotas. Thanks @Zihao-Qi! + +### Removed +- Kimi K2: remove the unofficial anonymous relay while retaining official Kimi and Moonshot coverage (#2254). +- CrossModel: remove the hosted relay pending identifiable operator and verifiable upstream-authorization details (#2254). Thanks @hujuncheng! + +### Internal +- Tests: isolate cookie importer overrides across concurrent tasks (#2212). Thanks @kiranmagic7! +- CI: defer macOS test shards for draft pull requests (#2161). Thanks @Yuxin-Qiao! +- Localization: complete app locale coverage across all existing catalogs (#2229). Thanks @Yuxin-Qiao! +- Dev: verify packaged Sparkle and app signatures and reject quarantine attributes before reporting a successful development package (#2232). Thanks @Yuxin-Qiao! + +## 0.43.0 — 2026-07-14 + +### Added +- sub2api: add group-key usage with daily, weekly, and monthly quotas, multi-account switching, wallet balance, and expiry details. Thanks @weirdo-adam! +- Kimi: reuse fresh signed-in Kimi Code CLI credentials in Auto mode without refreshing or rewriting CLI-owned authentication state. Thanks @Leechael! +- Community integrations: list codexbar-plasmoid, a KDE Plasma 6 usage widget. Thanks @psimaker! + +### Fixed +- Kiro: clear inherited signal masks in spawned pipe and PTY probes, preventing the CLI from ignoring termination under a blocked parent mask. Thanks @txarly89! +- CLI PTY: preserve deadline timeouts while draining late output and classify child exits by observation time, eliminating scheduler-dependent success/timeout races. Thanks @kiranmagic7! +- Quota warnings: isolate threshold episodes by stable account ownership so one account cannot duplicate or suppress another account's alert. Thanks @vincent-peng! +- Claude: cache successful CLI version probes for 30 minutes while invalidating on executable changes, avoiding repeated PTY launches without retaining failed or stale wrapper results. Thanks @Yuxin-Qiao! +- Linux CLI: bootstrap the configured IANA timezone before Foundation startup on non-FHS systems, preventing SIGILL on NixOS (#2127). Thanks @xikhar! +- Ollama: release temporary dashboard network sessions after each fetch, preventing repeated refreshes from retaining delegates and URL-cache resources. Thanks @astuteprogrammer! +- Amp: release temporary API and dashboard network sessions after every fetch, preventing repeated refreshes from retaining delegates and URL-cache resources. +- Linux CLI: prevent usage rendering from crashing in Foundation bundle discovery when formatting rate windows. Thanks @thanthi-del! +- CLI: defer login-shell PATH probes until Codex RPC launch, preserve login PATH for explicit script overrides, and reap session-escaped helpers without cross-probe descriptor inheritance. Thanks @anagnorisis2peripeteia! +- Menus: keep overview provider-row clicks reliable during live menu rebuilds without stealing nested Copy or plan actions. Thanks @Yuxin-Qiao! +- Startup: load persisted plan-utilization history away from the main thread so mature histories no longer delay app launch. Thanks @Yuxin-Qiao! +- Provider cleanup: prevent in-flight usage, status, token-cost, and cached-hydration work from republishing stale state after a provider is disabled, unavailable, or re-enabled. Thanks @Yuxin-Qiao! +- Agent Sessions: coalesce overlapping unchanged remote refresh requests so menu opens do not repeat Tailscale discovery and SSH passes. Thanks @Yuxin-Qiao! +- Agent Sessions: keep Tailscale discovery headless and fall through across installed CLI variants, preventing repeated Tailscale menu-bar launches. Thanks @willsarg! +- Cost usage: zero the scanner's 60-second refresh debounce on app-driven fetches so non-forced refreshes (hourly timer, post-launch, scope/settings changes) reflect rows appended between fetches instead of serving a stale snapshot that `UsageStore.tokenFetchTTL` then pins for up to an hour (#2089). Thanks @Yuxin-Qiao! +- Codex cost usage: contain interleaved cumulative counters from Ultra-mode fork lineages so repeated lineage switches cannot inflate token and cost history (#2037). Thanks @Zihao-Qi! + +## 0.42.1 — 2026-07-12 + +### Added +- Factory: add API-key usage authentication with API-first Auto mode and recoverable fallback to the existing web session path. Thanks @araa47! +- Developer tooling: add an offline adaptive-refresh replay CLI for comparing policy behavior against caller-supplied JSONL traces, without collecting production data. Thanks @hhh2210! + +### Changed +- Settings: split provider pane "Settings" sections into "Menu bar" and "Connection" so metric pickers and auth/cookie/source controls are grouped by topic. + +### Fixed +- Website: update every public provider count and the social card to 58, with a registry-derived check to prevent future drift. Thanks @kiranmagic7! +- CLI: isolate interactive PATH probes from the caller's terminal so concurrent and redirected-stdin lookups cannot break `watch` or Ctrl+C. Thanks @possibilities! +- Claude login: preserve the selected usage source after OAuth sign-in so Auto mode can still fall back to CLI or web data when OAuth is unavailable. Thanks @Chipagosfinest! +- Kiro: restore usage refresh for current CLIs that stall under PTY by accepting complete pipe output first while retaining a same-deadline PTY fallback for older releases (#1883). Thanks @txarly89! +- Claude quotas: ignore synthetic no-session placeholders when tracking notifications, history, and reset events, preventing false restores and duplicate threshold or pace warnings while weekly usage continues updating. Thanks @vincent-peng! +- Claude: skip doomed background OAuth refreshes when Claude CLI credentials are expired and Keychain contains MCP-only state, allowing Auto mode to fall through. Thanks @janpollak! +- German localization: label manual cookie-source and refresh options as “Manuell” instead of the handbook noun “Handbuch.” Thanks @fbrettnich! +- Amp: parse the current percentage-based daily Amp Free usage output while preserving individual and workspace balances. Thanks @3kh0! +- Codex notifications: suppress false session-restored alerts from transient, stale, or cross-account quota samples while preserving real reset notifications. Thanks @Yuxin-Qiao! +- Codex cost history: keep opening and refreshing the submenu fast as project history grows by comparing only the content it renders. Thanks @Yuxin-Qiao! +- Codex cost history: keep model-less token events explicitly unpriced and unattributed instead of pricing them as GPT-5 while preserving current turn model attribution. Thanks @hhh2210! +- Gemini: recover expired Workspace and education OAuth sessions when current CLI packages omit `oauth2.js`, with explicit credential and install-path discovery fallbacks. Thanks @Yuxin-Qiao! +- Codex accounts: confirm apparent weekly resets before publishing them and isolate reset detection by stable account ownership, preventing transient full gauges and confetti across same-email workspaces (#2054). Thanks @Yuxin-Qiao! +- Settings: render section footer captions (Advanced keychain note, refresh hints, quota-warning and provider subtitles) leading-aligned in footnote size instead of the trailing-aligned body text macOS gives bare form footers. +- Claude OAuth: remember an acknowledged CodexBar Keychain explanation for six hours without suppressing macOS authorization or either Keychain opt-out (#1990). Thanks @harjothkhara! +- Codex accounts: find the Codex CLI bundled with ChatGPT when it is absent from shell PATH, restoring Add Account after the desktop apps merged (#2044). Thanks @sep1107! +- Claude: prevent CodexBar's passive CLI probes from starting background Claude Code updates, avoiding repeated partial downloads when a probe exits before an update completes. Thanks @PG2047! +- Claude CLI: fail fast when usage commands find Claude Code logged out instead of starting its interactive REPL and waiting through probe retries. Thanks @BearHuddleston! +- Codex cost history: bound malformed session-metadata lines and release read chunks promptly, preventing metadata pre-scans from retaining memory in proportion to oversized JSONL records. Thanks @Yuxin-Qiao! +- Widgets: add Cursor to configurable and switcher widgets with accurate legacy Requests and current Total, Auto, and API quota labels (#2040). Thanks @Zihao-Qi! +- Menus: return oversized tracked menus to the provider header after a manual refresh without moving background updates, highlighted rows, open submenus, or newer menu/provider sessions (#2046). Thanks @ss251! + +## 0.42.0 — 2026-07-11 + +### Added +- Agent Sessions: opt in to discover, list, and focus live local or SSH-connected Codex and Claude Code sessions from the menu and CLI; discovery remains off by default. +- Wayfinder: add opt-in local gateway health, routing, savings, and latency usage with configurable loopback URL support. Thanks @tcballard! +- Menu bar: add a "Show reset time when quota runs out" option that replaces exhausted Percent, Pace, and Both values with the countdown until reset, then restores the selected metric afterward (#2028, #2027). Thanks @brahimhamichan! +- Quota warnings: add opt-in predictive pace alerts for Codex and Claude session and weekly limits, with one alert per risk episode. Thanks @vincent-peng! +- Codex: add GPT-5.6 Sol, Terra, and Luna pricing, including long-context, Priority, cache-write, alias, and automatic Pi cache repricing when rates change (#2023). Thanks @0xSMW! +- Codex: show Spark quota rows, with a provider option to hide them without hiding credits or other extra usage (#2013). Thanks @intellectronica! +- Claude CLI: surface model-scoped weekly limits alongside all-model usage without duplicating matching web limits. Thanks @janpollak! +- Kimi K2: add a Usage Dashboard shortcut to the human-facing legacy credits page. Thanks @joeVenner! +- Documentation: add detailed setup and troubleshooting references for Azure OpenAI, Perplexity, Mistral, and Qoder. Thanks @kiranmagic7! + +### Changed +- Settings: reorganize General, Notifications, Menu Bar, Menu, Advanced, and About; consolidate related checkboxes into pickers; standardize labels; and adopt an edge-to-edge Golden Gate sidebar with a hairline separator. + +### Fixed +- Refresh: keep all-provider manual refresh responsive while forced cost, credit, and dashboard enrichment finishes in a serialized background tail, and keep fixed intervals anchored to scheduled ticks. Thanks @Yuxin-Qiao! +- Menus: stop completed provider cards and plan-utilization rows from remaining in “Refreshing…” while unrelated provider or token-cost work is still running. Thanks @Yuxin-Qiao! +- Menu: keep native hover highlights aligned by deferring geometry-changing open-menu rebuilds until the pointer leaves the row. Thanks @Zihao-Qi! +- Settings: keep visual-only preferences and provider reordering on cached UI paths instead of refreshing provider quotas, while preserving refreshes for data-affecting settings. Thanks @Zihao-Qi! +- Display settings: keep display mode, work days, multi-account layout, and cost summary selectors interactive on macOS 27. Thanks @jordanschwartz-js! +- Quota warnings: keep compact per-window threshold editors available whenever notifications or usage-bar markers use them, preserve inherited overrides, and save edits on focus loss, Return, or window close. Thanks @Zihao-Qi! +- CLI server: retain timed-out route and provider work until it actually exits, preventing repeated requests or config changes from stacking background fetches. Thanks @Yuxin-Qiao! +- Widgets: show token-cost rows with their own age when they lag a fresh quota snapshot, and retry fast token-scan failures without waiting out the hourly cache. Thanks @irresi! +- Codex accounts: isolate authenticated OAuth and browser-cookie requests from shared URL caches and cookie stores, preventing one account's cached quota or identity response from appearing under another account and triggering false reset alerts (#1987, #2019). Thanks @harjothkhara! +- Codex: avoid false session-reset celebrations from transient zero-usage samples until the reset boundary advances. Thanks @kiranmagic7! +- Claude OAuth: honor the app's never-prompt policy in the bundled CLI and defer stale cache cleanup without touching Keychain until access is re-enabled. Thanks @Yuxin-Qiao! +- Claude CLI: resolve explicit-year, yearless, and time-only reset timestamps against the exact quota-window calendar occurrence, preserving DST, leap-day, and future reset semantics. Thanks @fanwenlin! +- Token costs: coalesce bounded pricing-catalog refreshes when a newly observed model is still unpriced, preserving its exact usage until pricing arrives. Thanks @iam-brain! +- Cost history: keep model breakdown menus steady while hovering, preserve compact rows, and make overflowing histories scrollable. Thanks @iam-brain! +- Antigravity: recover CLI listening ports from Linux procfs when `lsof` is unavailable, including process network namespaces. Thanks @junmo-kim! +- Gemini: prefer Google's paid-tier plan label over generic Free, Workspace, or Paid fallbacks while preserving acronym casing in the CLI. Thanks @Yuxin-Qiao! +- Ollama: recognize current WorkOS AuthKit sessions, fall back from expired sign-in redirects, and validate API keys against an authenticated endpoint while preserving refresh cancellation. Thanks @joeVenner! +- Kimi K2: report missing, blank, or rejected API keys clearly and trim surrounding whitespace before requests. Thanks @joeVenner! +- MiMo: flag a stale local-fallback cache in the summary (e.g. `stale 34d`) so a tracker that has not been refreshed by `Scripts/mimo-usage.py` is not misread as live usage. Thanks @LeoLin990405! +- Catalan: complete current strings, align instructional voice, and enforce catalog parity. Thanks @pmontp19! + +## 0.41.0 — 2026-07-06 + +### Added +- CLI: add responsive `codexbar cards` and compact `--brief` terminal usage views. Thanks @DonnieFi! +- Antigravity: show pace details for legacy model-family and current session/weekly quota rows without changing compact icon lane semantics. Thanks @Zihao-Qi! +- Widgets: make Kimi available with Weekly, Rate Limit, and Monthly quota rows. Thanks @joeVenner! +- Kimi: show the subscription 7-day Code quota in menus and large widgets. Thanks @skyzer! +- Claude: distinguish Max 5x and Max 20x in the plan label instead of a flat "Max". Thanks @kes02! + +### Fixed +- Ollama: point missing-session recovery to the current `/signin` page instead of the protected settings page. Thanks @joeVenner! +- Alibaba Token Plan: support International Model Studio while preserving China-mainland upgrades and isolating regional cookie caches. Thanks @harshav167! +- Amp: open the current Usage page from the menu dashboard action. Thanks @3kh0! +- Browser cookies: stop automatic Chromium-family probes after the first Safe Storage denial, while keeping an explicit Refresh retry available (#1952). Thanks @CoreyCole! +- Claude: keep yearless reset dates in the upcoming year when a quota crosses New Year's Day. Thanks @devYRPauli! +- Claude web: preserve fractional session and weekly utilization instead of displaying it as zero or unavailable. Thanks @devYRPauli! +- Gemini: detect Google's consumer-tier shutdown response and offer an explicit Antigravity handoff without changing ordinary auth failures or enabling fallback automatically. Thanks @Yuxin-Qiao! +- Gemini: use the real Flash quota in menu-bar metrics when an account has no Pro quota. Thanks @devYRPauli! +- Ollama API: describe rejected keys as invalid or revoked, matching Ollama's current key lifecycle. Thanks @joeVenner! +- Settings: keep Language, Default Terminal, and Refresh cadence selectors interactive on macOS 27. +- Usage formatting: show every positive sub-1% value as `<1%` instead of rounding values above 0.5% up to `1%`. Thanks @devYRPauli! +- Codex menu: hide error-only optional Credits and OpenAI web setup diagnostics while keeping them visible in provider Settings. +- Codex quotas: show the session quota as unavailable while an exhausted weekly limit is still binding, including menu-bar icons and widgets. Thanks @Yuxin-Qiao! +- Codex cost history: reuse cached aggregate pricing and one pricing catalog across daily and project reports, carry fresh cache state across launches, and treat unpriced models as migrated, avoiding repeated row scans, filesystem work, and duplicate background scans on large local histories. +- Devin: keep exact 1% usage from being inflated to 100% while preserving fractional fallback quota semantics. Thanks @Lex-ic-on! +- Kimi K2: reject invalid and out-of-range numeric timestamps while preserving valid second and millisecond values. Thanks @joeVenner! +- Kimi K2: reject non-finite credit and token values before they reach menus, CLI output, or widgets. Thanks @joeVenner! +- Kimi: call the current `GetSubscriptionStats` membership endpoint so the Monthly subscription quota is populated again. Thanks @skyzer! +- Kimi: show the five-hour rate limit before the weekly quota while preserving existing menu-bar metric preferences. Thanks @Zihao-Qi! +- Menu bar: detect Tahoe's blocked no-window state at startup when macOS still records the icon as enabled, so affected users receive recovery guidance instead of a silently missing icon (#1945). Thanks @mmyyfirstb! + +## 0.40.0 — 2026-07-05 + +### Added +- Claude: show opt-in read-only claude-swap accounts as stacked usage cards without delaying ambient refreshes. Thanks @optimiz-r! +- Claude: switch inactive claude-swap accounts directly from their stacked usage cards and refresh usage immediately. +- Codex dashboard: show calendar-correct raw Today and 30-day credit totals without converting credits to billed dollars. Thanks @avenoxai! +- Cost charts: show visible, unit-safe scale labels across detailed history, inline menus, and widgets. Thanks @FNDEVVE! +- Cursor: read the signed-in app token on Linux, with explicit manual-cookie web-source support and XDG config paths. Thanks @DonnieFi! +- Devin: show remaining extra-usage balance in menus, CLI, and widgets while respecting optional-usage visibility. Thanks @FNDEVVE! +- Widgets: make Mistral available in provider selection and switching. Thanks @joeVenner! + +### Changed +- Settings: keep the sidebar fixed and visible while resizing, prevent collapse or over-expansion, and cap detail content width for readability. Thanks @Zihao-Qi! +- Debug builds: add a compact `D` beside menu-bar icons and identify them as CodexBar Debug in tooltips and accessibility. +- Usage bars: distinguish full-height quota-warning thresholds from subtle workday-boundary markers. Thanks @Alekstodo! + +### Fixed +- Codex cost history: reuse one pricing catalog while building project rollups and carry fresh cache state across launches, avoiding repeated filesystem work and duplicate background scans on large local histories. +- Providers: detect Claude Desktop on fresh installs and ignore Gemini CLI installations without usable OAuth credentials. +- Claude cost history: include nested Claude Desktop local-agent logs while preserving current Code/Cowork coverage through the shared `~/.claude/projects` store. Thanks @Zihao-Qi! +- Claude: give multiple claude-swap accounts precedence over token-account cards and segmented switching so adapter rows remain visible. Thanks @optimiz-r! +- Menus: scope manual refresh state to the provider being refreshed, allowing independent provider refreshes without greying unrelated rows. Thanks @hhh2210! +- Claude history: quarantine same-directory account-switch samples until credential ownership is stable, preventing plan-utilization history from crossing accounts. Thanks @ss251! +- Language picker: keep language names readable in their native form and make System follow macOS without removing unrelated overrides. Thanks @Zihao-Qi! +- Reset times: preserve minute precision in long day-scale countdowns when there are no whole hours, while keeping countdowns compact to two units. Thanks @konon4! +- Mistral: reject non-finite and overflowing credit balances before they can reach menu, CLI, or widget formatting. Thanks @joeVenner! + +## 0.39.0 — 2026-07-04 + +### Added +- Codex: show every available reset-credit expiry in menus and provider settings, including non-expiring credits, and summarize credits nearing expiry. Thanks @brahimhamichan! +- Cost history: optionally show shorter 7, 30, and 90-day comparisons from the selected local history window (#1500). Thanks @jtl06! +- Codex cost history: group local usage and costs by project and worktree in menus and CLI output. Thanks @clemenspeters! +- Sakana AI: show best-effort pay-as-you-go credit balance and recent usage without delaying subscription quota refreshes. Thanks @ss251! +- Kimi: show monthly subscription usage alongside weekly and five-hour limits with a short total budget for the optional membership request. Thanks @zhiyue! +- Mistral: show available credit balance from the authenticated billing session while preserving API spend and Monthly Plan usage. Thanks @Zihao-Qi! + +### Changed +- Codex: compact reset-credit expiry inventory into a single scannable timeline instead of one row per credit. +- Repository: reject oversized tracked blobs and generated release/build artifacts during checks. Thanks @joeVenner! + +### Fixed +- Alibaba: keep the browser Safe Storage keychain read non-interactive and honor the "Disable Keychain access" setting, so cookie import can never trigger a Keychain prompt. +- Tests: block real Keychain and `security` CLI access by default so test runs cannot display password prompts. +- Mistral: discard non-finite and overflowing billing costs so malformed price data cannot poison spend totals or charts. Thanks @joeVenner! +- Claude: notify on model-scoped weekly and Daily Routines quota thresholds using independent warning state. Thanks @cleanerzkp! +- Claude CLI: skip the identity probe after terminal usage errors or loading stalls, cutting failed refresh latency and subprocess churn. +- OpenCode web: search Dia after Chrome for automatic cookie import, with Keychain preflight scoped to the candidate browser (fixes #1822). Thanks @zeajose! +- Claude: make the "Avoid Keychain prompts" setting use the no-prompt policy instead of the experimental `security` CLI reader. Thanks @gmkbenjamin! + +## 0.38.1 — 2026-07-04 + +### Added +- Localization: add complete Russian coverage for the app and redesigned website. Thanks @Kirchberg! +- Localization: add Galician app translations and language selection. Thanks @B1NAR10! +- ClawRouter: add API-key tracking for monthly budget, spend, requests, tokens, and routed-provider usage. +- Claude: show model-scoped weekly quota windows, including promotional Fable limits, from OAuth and web usage responses. Thanks @konon4! +- Usage refresh: add an opt-in Adaptive cadence that polls every 2–30 minutes based on recent menu use, Low Power Mode, and thermal state. Thanks @hhh2210! +- Codex: show a conservative 1.5× pace-headroom hint in menus and CLI output when usage is safely ahead of the reset curve. Thanks @astuteprogrammer! + +### Changed +- Branding: replace the app and website icon with a usage-meter prompt mark that matches CodexBar's core UI. +- Website: redesign codexbar.app around faster download, provider discovery, feature, CLI, and widget paths with responsive dark/light and localized layouts. Thanks @vyctorbrzezowski! +- Architecture: accept a bounded opt-in adaptive refresh design with a deterministic 2–30-minute cadence and no behavioral telemetry. Thanks @hhh2210! +- Architecture: define the security and identity boundaries required before custom HTTP JSON providers can be implemented safely. +- Claude: accept a display-only multi-account design based on read-only `claude-swap --list --json`, without account switching or credential storage. +- Notifications: accept a default-off predictive pace warning design that alerts once per risk episode and re-arms only after authoritative recovery. +- OpenCode Go: accept bounded automatic multi-workspace fan-out while preserving the configured workspace as an exact single-workspace override. +- Xiaomi MiMo: require authoritative cadence evidence before showing reserve or deficit projections, avoiding guesses from plan dates or names. + +### Fixed +- Gemini: resolve fnm from the active PATH, stop package-discovery helpers on deadline, and return after the first output line even when descendants keep stdout open. +- Branding: replace the malformed Poe icon and use Poe's official purple consistently across the app, widget, and website. Thanks @garethpaul! +- Monthly quota pace: show reserve, deficit, and run-out estimates for OpenCode Go, Doubao, and Alibaba monthly reset windows using their calendar-cycle length. Thanks @Zihao-Qi and @joeVenner! +- Localization: translate the Default Terminal setting across every supported app language. Thanks @Zihao-Qi! +- Settings: recover collapsed sidebars and undersized saved window frames when reopening Settings. Thanks @ProspectOre! +- z.ai: parse successful BigModel CN quota responses that omit the optional message field, while preserving useful API-code errors. Thanks @joeVenner! +- Claude: block background delegated CLI OAuth refresh when the keychain holds MCP-only state (`mcpOAuth` without `claudeAiOauth`) while preserving explicit Refresh recovery (#1844). Thanks @Yuxin-Qiao! +- OpenAI API: reject non-finite cost values before they can corrupt usage totals or JSON output. Thanks @joeVenner! +- OpenCode: ignore non-finite and out-of-range reset timestamps instead of crashing usage parsing, while preserving valid quota windows. Thanks @joeVenner! + +## 0.38.0 — 2026-07-03 + +### Added +- Doubao: add signed Volcengine AK/SK support for Coding Plan session, weekly, and monthly usage. Thanks @LeoLin990405! +- CrossModel: add API-key wallet balance and UTC daily, weekly, and monthly spend tracking. Thanks @hujuncheng! +- Localization: complete Traditional Chinese provider and menu coverage, and route remaining provider UI copy through localized formatters. Thanks @jack24254029! +- Menu: add an opt-in setting to refresh provider usage whenever the menu opens without changing the periodic refresh clock. Thanks @dstier-git! +- Qoder: add big-model credit usage from qoder.com and qoder.com.cn browser sessions or manual cookies. Thanks @Yuxin-Qiao! +- Quota warnings: add an optional centered on-screen text alert that stays click-through and does not steal focus. Thanks @SAASEmpiree! +- Sakana AI: add manual-cookie usage for five-hour and weekly quota windows. Thanks @LeoLin990405! +- Status pages: show live component submenus for Claude, Codex, and Augment. Thanks @elijahfriedman! +- Cost history: choose inline, submenu, or combined local-cost presentation. Thanks @Zihao-Qi! +- Confetti: optionally celebrate session-limit resets with full-screen confetti, configurable beside the weekly-limit celebration in Advanced settings. Thanks @bystritskiy! +- z.ai: support saved token-account team usage with account-scoped organization and project metadata. Thanks @zqbake! +- CLI: show session pace in text output, expose derived pace data in JSON, and honor the configured weekly work-day baseline. Thanks @kmatsunami! +- Claude: add a combined "Session + Weekly" menu bar metric that shows the 5-hour session and weekly lanes together (paced on the weekly lane), matching Codex, and classify lanes by cadence so a weekly-only account is not mislabeled as a session. Thanks @Shengqiang-Zhang! + +### Changed +- Settings: complete redesign as a System Settings-style window — a sidebar lists app panes plus every provider (search, drag reorder, status dots, enable via context menu), panes use native grouped forms, the window keeps one size instead of resizing per tab, and the last selected pane is remembered across launches. +- Menu: group Plan Usage, Cost, and Storage rows so related account usage is easier to scan. Thanks @Zihao-Qi! + +### Fixed +- Usage refresh: refresh provider data shortly after known quota reset boundaries instead of leaving expired reset times visible until the next normal poll. Thanks @pavbar! +- Settings: align General-pane controls, show compact installed terminal app icons, and enlarge the window to fit more options. +- Sakana AI: parse server-rendered quota reset timestamps as UTC instead of device-local time (#1826). Thanks @ss251! +- Cursor: hide misleading pace and run-out details once a billing-cycle quota is fully depleted. Thanks @Yuxin-Qiao! +- Claude Education: treat subscription-only CLI responses as unavailable quotas, keep local cost data in menus and widgets, and suppress expected refresh cancellations (#1808). +- Claude web usage: bound stale requests so Auto can reach CLI fallback instead of hanging indefinitely. +- Claude history: keep OAuth utilization separate across account switches while preserving continuity through token refreshes. +- Linux CLI: keep Claude OAuth usage subprocess-free, skip version probes, and let Auto bypass unsupported web sources. Thanks @derekszen! +- Usage display: make Usage widgets follow the used-versus-remaining preference already shared by menus and Overview rows (#1738). Thanks @OlegLustenko and @FrancoLan! +- OpenCode Go: keep rolling usage available when the dashboard omits the optional weekly window. Thanks @mohkg1017! +- Menu bar: make Show most-used provider rank only providers selected for Overview. Thanks @dstier-git! +- Codex: show expiring reset-credit availability even when optional credits and extra usage are hidden, while preserving CLI `--no-credits`. Thanks @simon-ami! +- Claude CLI: prevent logged-out background Auto fallbacks from opening browser OAuth during app refresh. Thanks @afarwind! +- Keychain prompts: explain that macOS handles password entry, surface the existing opt-out path, and link to troubleshooting before access begins (fixes #1681). Thanks @someshfengde and @Yuxin-Qiao! +- Claude: use the dedicated Claude Code authentication command for sign-in, report its real exit status, and stop treating a browser URL as completed login (fixes #1715). +- OpenAI API: explain that project service-account keys cannot read organization usage instead of surfacing a generic credit-balance HTTP 401 error (fixes #1792). Thanks @dhruv-anand-aintech! +- Codex cost history: stop double-billing cached input and reprice stale Codex and Pi cache entries. Thanks @dstier-git! +- Overview: render row selection on the GPU to keep trackpad scrolling smooth. Thanks @hhh2210! +- Codex cost history: count cache reads separately, deduplicate active and archived sessions at row level, and preserve cached days across narrow refreshes. Thanks @kiranmagic7! +- Pi cost history: price Codex cache reads once using their true context size. Thanks @kiranmagic7! +- Menu bar: in the combined "Session + Weekly" metric (Codex and Claude), pair the 5-hour session usage with the weekly pace in pace and both display modes instead of showing the busier (most-constrained) lane's usage, which mislabeled the readout as weekly usage + weekly pace. Thanks @Shengqiang-Zhang! +- Menu bar: in the combined "Session + Weekly" metric, ignore Claude web's synthetic 0% five-hour placeholder (emitted for accounts with no live session window but a real weekly lane) so the readout shows the weekly lane instead of a non-existent `5h 0%`/`5h 100%` session. +- Memory pressure: finish isolating utility-queue source reads from main-actor state to prevent the remaining callback crash. Thanks @Zihao-Qi! +- Kiro: run account, usage, and context commands through a PTY so current CLI versions return usage without timing out. Thanks @sf-jin-ku! +- OpenAI web: ignore stale profiles from removed browsers, discover registered installs outside standard app folders, and surface browser-profile access and cookie-load timeout diagnostics. +- PTY probes: preserve Darwin device identifiers without crashing when Intel macOS reports signed values. +- CLI server: collect `/usage` providers concurrently under finite per-provider deadlines so one hung provider degrades to its own error row without discarding healthy results. Thanks @enieuwy! +- Privacy: hide account and team identity values without showing a `Hidden` placeholder or empty account rows. Thanks @Zihao-Qi! +- Mistral: restore Vibe monthly-plan usage by forwarding only required console session cookies. Thanks @lfmundim! +- Codex: show enterprise monthly credit limits across OAuth, CLI, menu, and widget surfaces. Thanks @ChenZiHong-Gavin! +- Codex: avoid launching monthly-credit CLI enrichment during usage-only OAuth refreshes. +- Usage display: keep positive values below one percent visible instead of rounding them to zero. Thanks @Max0633! +- Menu bar: show pace as `0%` instead of a signed `+0%` or `-0%` when the pace delta rounds to zero. Thanks @devYRPauli! +- Menu: align the persistent Refresh row with native actions, keep Settings, About, and Quit keyboard-navigable, and use a narrower Usage Dashboard icon. Thanks @Zihao-Qi! +- Menu: match the persistent Refresh symbol size, weight, and icon column to native action rows across standard and narrow provider menus. Thanks @micnem! +- Claude: stop installed-version checks from invoking a login shell and triggering unwanted Keychain prompts. Thanks @enieuwy! +- Localization: reject blank translated values and restore the affected Vietnamese provider prompts. Thanks @kiranmagic7! +- Usage totals: keep Today tied to the current local calendar day across cost, Admin API, and Poe surfaces instead of showing the latest historical bucket. Thanks @Zihao-Qi! +- Antigravity: align compact icons and automatic highest-usage selection with grouped Gemini and Claude/GPT 5-hour and weekly lanes while ignoring non-renderable cadences. Thanks @Yuxin-Qiao! +- Antigravity CLI: reuse an authenticated user-launched `agy` server for faster, more reliable one-shot usage checks. Thanks @junmo-kim! + +## 0.37.2 — 2026-06-22 + +### Added +- Diagnostics: write redacted provider reports to a file with platform and app-version context. Thanks @Yuxin-Qiao! +- CLI server: report the startup build version from `/health` so clients can detect stale helper processes after updates. Thanks @enieuwy! + +### Fixed +- Claude: pause background CLI usage probes briefly after rate limiting while keeping manual refresh available. Thanks @kiranmagic7! +- Codex OAuth: publish refreshed `auth.json` credentials with private file permissions already applied. Thanks @Hinotoi-agent! +- Provider endpoints: reject unsafe Deepgram, z.ai, and Xiaomi MiMo overrides before attaching credentials. Thanks @Hinotoi-agent! +- Azure OpenAI: reject unsafe endpoint overrides before attaching API keys while keeping invalid configurations visible with an actionable error. Thanks @Hinotoi-agent! + +## 0.37.1 — 2026-06-21 + +### Fixed +- MiniMax: recover detailed token-plan windows from the remains API when the coding-plan page only exposes coarse usage. Thanks @Yuxin-Qiao! +- Cost history: remove the redundant tooltip from submenu-backed Cost rows. Thanks @Zihao-Qi! +- Menu refresh: keep the menu open and show in-place progress when Refresh is clicked. Thanks @elijahfriedman! +- Menu: align provider usage-card spacing with the Overview layout. Thanks @Zihao-Qi! +- Memory pressure: avoid actor-isolation crashes when system callbacks arrive on a utility queue. Thanks @Zihao-Qi! +- Menu: remove extra separators and spacing around Storage, Cost, and Subscription Utilization rows. Thanks @elijahfriedman! +- Antigravity: show limits as unavailable when OAuth identifies the account but quota endpoints deny access. Thanks @Yuxin-Qiao! + +## 0.37.0 — 2026-06-19 + +### Added +- Widgets: add single-window and combined burn-down charts for Codex and Claude session/weekly limits. Thanks @jamesjlopez! +- AWS Bedrock: show optional rolling 14-day Claude token and request totals from CloudWatch. Thanks @zyaiire! +- Codex: optionally show both session-window and weekly percentages in the compact menu bar label. Thanks @thepraggyverse! +- Cursor: show personal on-demand spend alongside the shared team pool. Thanks @yashiels! +- Documentation: link the community KDE Plasma panel integration. Thanks @tylxr59! +- Codex: expose explicitly configured profile homes as switchable accounts without copying their credentials. Thanks @kiranmagic7! +- Codex: show available manual rate-limit reset credits and their next expiry for signed-in OAuth accounts. Thanks @rogdex24! +- Mistral: add Vibe monthly-plan usage and menu bar metric selection. Thanks @lfmundim! +- Storage: show a compact segmented provider breakdown with an expandable Other group. Thanks @elijahfriedman! +- Settings: add an optional enabled-first alphabetical sort for the Providers sidebar without changing custom order. Thanks @elijahfriedman! +- Linux CLI: publish static musl release tarballs for x86_64 and aarch64. Thanks @Yuxin-Qiao! +- Documentation: add safe troubleshooting for browser Keychain prompts that persist after uninstall. Thanks @Yuxin-Qiao! +- Diagnostics: report provider-neutral usage confidence and mark fully decoded Codex OAuth windows exact. Thanks @Yuxin-Qiao! +- Codex agents: add a read-only `codexbar` skill for bounded, redacted provider usage JSON. Thanks @coygeek! +- Display: add a Hide critters option for plain menu bar quota capsules. Thanks @elijahfriedman! + +### Changed +- Packaging: strip local symbols from release executables to reduce the installed app and download size. Thanks @jieshu666! +- Logging: skip message, metadata, and redaction work for filtered or disabled log destinations. Thanks @ProspectOre! +- Cost history: cache date parsers per thread to reduce repeated report-decoding overhead. Thanks @ProspectOre! +- Linux CLI: accept an opt-in static SQLite library directory for musl builds. Thanks @Yuxin-Qiao! +- Linux CLI: add musl source compatibility for static Linux SDK builds. Thanks @Yuxin-Qiao! +- Cost history: resize the chart details to the hovered day's model breakdown instead of reserving the tallest day. Thanks @elijahfriedman! +- Antigravity: use current backend quota labels in menus and widgets while preferring a usable quota lane over an exhausted one. Thanks @Yuxin-Qiao! +- Pi: cache session filename and timestamp parsers to reduce cost-history refresh overhead. Thanks @ProspectOre! +- Menu bar: reuse the icon-observation signature during provider refreshes instead of computing it twice. Thanks @abe238! +- LiteLLM: show personal and team spend amounts directly on budget rows while suppressing duplicate budget sections. Thanks @hololee! + +### Fixed +- Menu: align cost and utilization rows with provider content and use native bottom action items. Thanks @elijahfriedman! +- Charts: keep hover selection on bar widths, preserve single-day details, and remove redundant cost-menu detail lines. Thanks @elijahfriedman! +- Cost history: keep chart date labels aligned with their bars and visible without clipping. Thanks @elijahfriedman! +- Claude settings: dim and disable Avoid Keychain prompts while global Keychain access is disabled. Thanks @Zihao-Qi! +- Linux CLI: read OpenCode Go local SQLite usage in automatic mode and allow Command Code billing with a configured manual cookie. +- MiniMax diagnostics: include safe per-service usage and boosted quota limits for mismatch reports. Thanks @sagelga! +- Xiaomi MiMo: retry another imported browser session when a stale session redirects API requests to login. Thanks @Yuxin-Qiao! +- MiniMax: retry the China API region when the global token endpoint reports a structured invalid-key response. +- Menu refresh: scope manual refreshes to the visible provider, keep Command-R consistent with mouse refresh, and avoid animated refresh-row compositing. Thanks @jangisaac-dev! +- Localization: improve Catalan app and website translations. Thanks @pmontp19! +- Claude web: persist renewed session cookies after successful usage requests so imported sessions stay current. Thanks @ProspectOre! +- Kiro: keep parsed usage available when the optional account probe times out or fails. Thanks @Yuxin-Qiao! +- Cursor: ignore an exhausted Auto or API subquota only when another independent quota remains usable, while preserving the overall cap. Thanks @Yuxin-Qiao! +- Memory: release idle OpenAI WebViews under system pressure without blocking the main thread. Thanks @ProspectOre! +- Memory: trim rebuildable menu and OpenAI debug caches under system pressure. Thanks @ProspectOre! +- Provider plans: keep Claude and Kiro plan matching on one rendered line to avoid bogus labels from adjacent usage hints. Thanks @elijahfriedman! +- Antigravity: use current Gemini 5-hour and weekly quota-summary lanes for the compact menu bar icon and merged highest-usage selection. Thanks @Zihao-Qi! +- Usage bars: render values rounded to 0% or 100% as fully empty or full. Thanks @Zihao-Qi! +- Codex web: keep cookie-import deadlines responsive when browser cookie work blocks the shared worker pool. +- z.ai: open the usage dashboard for the configured global or China API region. Thanks @renbaoshuo! +- Usage dashboards: tint inline history bars with each provider's branding color. Thanks @elijahfriedman! +- Command Code: avoid repeated depleted notifications when subscription lookup intermittently fails. Thanks @LPFchan! +- Codex pace: extrapolate historically exhausted weeks for run-out forecasts and avoid contradictory reset headlines. Thanks @Yuxin-Qiao! +- Localization: correct the German in-progress refresh label. Thanks @ChrisLauinger77! +- Localization: correct misleading literal German UI translations. Thanks @madebyjulz! +- Install docs: describe the official Homebrew cask as universal on Intel and Apple silicon. Thanks @ChrisGVE! +- Settings: switch tabs immediately before animated window resizing and reduce Providers sidebar work. Thanks @elijahfriedman! +- Windsurf: import complete Devin sessions from the current app origin before legacy browser storage. Thanks @kiranmagic7! +- Antigravity: humanize raw model identifiers while preserving server-provided quota labels. Thanks @bcharleson! +- Menu bar: show provider status markers only for the provider rendered in each icon. Thanks @Zihao-Qi! +- Codex CLI: make automatic usage reads prefer OAuth and CLI sources instead of blocking on the optional web dashboard. +- Codex web: apply `--web-timeout` to the full cookie import, account verification, retry, and dashboard fetch path. +- OpenCode Go: allow configured manual cookies in the Linux CLI while keeping browser-cookie import gated to macOS. Thanks @Yuxin-Qiao! +- Provider probes: cap captured subprocess output at 1 MiB per stream without dropping valid text at a truncated UTF-8 boundary. Thanks @ProspectOre! +- Provider switcher: keep Codex quota rows visible when switching away and back during a manual refresh, including menus with usage-history sections. Thanks @Yuxin-Qiao! +- Bedrock: ignore invalid billing dates when selecting the latest usage values. Thanks @ProspectOre! +- Usage history: let opted-in providers persist weekly utilization and keep saved charts visible. Thanks @kiranmagic7! +- Localization: improve Japanese terminology consistency and localize next-day reset times across all 21 app languages. Thanks @tukuyomil032! +- Menu bar: keep visible quota values stable while a manual refresh is in flight without rewinding background-refresh countdowns. Thanks @Zihao-Qi! +- Menu bar: stop informational usage-card rows from highlighting like clickable actions. Thanks @elijahfriedman! +- Localization: validate placeholder integrity across every app language and repair malformed Vietnamese interpolation tokens. Thanks @Yuxin-Qiao! + +## 0.36.1 — 2026-06-16 + +### Added +- Poe: add current point balance and recent points history from a configured API key (#1191). Thanks @Yuxin-Qiao! +- Chutes: add subscription, quota-window, and pay-as-you-go usage tracking from a configured API key (#1496). Thanks @mvanhorn! +- Zed: add plan, edit-prediction quota, billing-cycle, and overdue-invoice tracking from the signed-in editor Keychain session (#1517). Thanks @enesteve0! + +### Changed +- Website: add Poe, Chutes, and Zed to the provider gallery with matching icons and setup documentation. + +### Fixed +- Provider switcher: use a continuous menu background instead of a separate light-mode tinted band. Thanks @Zihao-Qi! + +## 0.36.0 — 2026-06-16 + +- Ollama: replace the bundled provider icon with the cleaner official mark while preserving native template tinting. Thanks @mattab178! +- Menu bar: avoid a one-time visible menu rebuild after first-open background data arrives. +- Settings: use high-contrast selected-content colors for provider sidebar text and icons. +- Localization: align the app and website on the same 21-language catalog, adding Italian (#1248), Indonesian (#1513), Polish (#1253), Arabic, Persian, and Thai as selectable app languages, plus automatic website detection, persistent pickers, and right-to-left layouts for Arabic and Persian. Thanks @Yuxin-Qiao and @StevanusPangau! +- Website: replace the remaining provider letter tiles with the canonical Devin, LiteLLM, and T3 Chat logos. +- Website: keep localized mobile navigation, calls to action, package commands, and right-to-left layouts inside narrow viewports. + +### Added +- LiteLLM: add personal and team budget tracking from a configured virtual key and proxy URL (#1542). Thanks @hololee! + +### Changed +- Antigravity: prefer app and `agy` quota summaries, group usage into Gemini and Claude + GPT session/weekly pools, and preserve IDE and OAuth fallbacks. Thanks @Zihao-Qi! +- Antigravity: show structured quota reset timestamps from the current `resetTime` field (#1553). Thanks @akunzai! +- Configuration: honor absolute `XDG_CONFIG_HOME` paths while rejecting relative paths, preserving existing standard and legacy config precedence (#1562). Thanks @kiranmagic7! + +### Fixed +- Menu bar: preserve native AppKit image-row alignment when returning to cached provider content in the open merged menu (#1560). Thanks @Zihao-Qi! +- Menu bar: defer hosted submenu reconstruction until an active refresh finishes so partial provider data cannot replace the visible menu (#1556). Thanks @Yuxin-Qiao! +- Weekly pace: suppress the “Lasts until reset” label when the projected run-out risk is nonzero (#1561). Thanks @kiranmagic7! +- Antigravity: retry transient `Text file busy` launch failures while the CLI executable is being replaced. +- Antigravity: fall back to loopback HTTP for local CLI and language-server probes on Linux, where self-signed localhost TLS cannot be trusted (fixes #1508). Thanks @zodiacfireworks! +- Codebuff: enforce the optional subscription grace period even when the transport ignores cancellation. +- Copilot: show the shared quota reset date for limited premium and chat usage windows. Thanks @Zihao-Qi! +- Codex: keep managed login timeouts bounded while preserving captured output when detached helpers retain stdout or stderr. +- Claude: keep segmented multi-account menus scoped to the selected account while its refresh is in flight (fixes #1527). +- Command Code: keep showing available credits after the bounded optional subscription grace, including when the transport ignores cancellation (fixes #1131). +- DeepSeek: keep balance refreshes responsive when optional usage-summary work ignores cancellation. +- OpenRouter: keep credit refreshes responsive when optional key-quota enrichment ignores cancellation. +- Provider probes: stop waiting indefinitely for inherited output pipes after subprocesses or CLI version checks exit (fixes #1531). +- Menu bar: update visible usage values in place when a manual refresh completes instead of leaving the open provider card stale until the menu is reopened (fixes #1516). +- Gemini: recognize the current `gemini-api-key` CLI auth setting so API-key sessions show the supported OAuth guidance instead of a misleading not-logged-in error (fixes #1511). +- Kiro: keep usage refreshes bounded and clean up CLI helpers when they retain output pipes, ignore termination, or are cancelled (fixes #1533). Thanks @kiranmagic7! +- Gemini: keep fnm package discovery bounded when helper descendants retain output pipes or ignore termination (fixes #1534). Thanks @kiranmagic7! +- Xiaomi MiMo: cancel optional token-plan requests when the required balance request fails instead of delaying the error for up to 30 seconds. +- Settings: make the cost history window directly editable by keyboard while preserving the existing stepper and 1–365 day bounds (fixes #1499). Thanks @kiranmagic7! +- OpenCode Go: show Zen balances for accounts without subscription usage windows, including when the balance request takes longer than optional enrichment (fixes #1476). Thanks @kiranmagic7! + +## 0.35.0 — 2026-06-14 + +### Added +- Kimi: add usage fetching from the official Code API key flow, with optional compatible HTTPS proxy support (#1424). Thanks @kiranmagic7! +- Xiaomi MiMo: show paid and granted balance components alongside token-plan usage without requiring a duplicate provider (#1309). Thanks @AdrianSimionov! +- Xiaomi MiMo: add an opt-in local session-log fallback for token accounting when browser quota authentication is unavailable (#1284). Thanks @LeoLin990405! +- Weekly pace: use configured work days for standard weekly pace calculations while leaving historical Codex pacing unchanged (#1451, fixes #1356). Thanks @pstanton237! + +### Fixed +- Security: prevent test and infrastructure cookie-import paths from accessing real browser profiles, SQLite stores, or Keychain data unless explicitly enabled (#1491). +- Menu bar: stop the provider-switcher shortcut monitor from killing the menu's event tracking session. Its event-queue peek re-entered the run loop in tracking mode, which could leave a zombie menu on screen that ignored clicks for tens of seconds (beach ball) — most often right after opening the menu or after rapid Cmd-number provider switching, with Settings… the usual victim. Peeks now run in a barren private run-loop mode, start only once the tracking session is pumping, and no longer touch mouse events. Thanks @ProspectOre! +- Menu bar: rebuild merged provider content inside AppKit's active tracking run loop so provider switches no longer wait for the menu to close or the default run loop to resume. +- Menu bar: keep cached provider content visible while switching merged tabs so the open menu no longer flickers through an empty state. +- Menu bar: restore native macOS positioning for merged provider dropdowns while preparing current content before AppKit lays out the menu. +- Menu bar: avoid starting a duplicate background provider refresh when the menu closes while its initial missing-data refresh is still in flight. +- Menu bar: pin the status-item dropdown to the current system appearance so it follows the Light/Dark setting instead of inheriting the menu bar's vibrant appearance, which rendered the menu dark in Light mode whenever a dark or strongly-colored window or wallpaper sat behind the menu bar (#1490). Thanks @npapridonu! +- Menu bar: handle the global open-menu shortcut synchronously so repeated presses close the tracked menu instead of queueing a delayed reopen (#1470). Thanks @Zihao-Qi! +- Menu bar: keep the selected quota percentage visible in Pace mode when pace is temporarily unavailable instead of collapsing to an icon-only status item (fixes #1462). +- Settings: memoize cookie cache lookups behind the "Cached: …" picker labels so opening Settings and switching panes no longer pays a synchronous Keychain read per SwiftUI body evaluation, which froze the Providers pane for seconds (#1471). Thanks @ProspectOre! +- Settings: keep the native tab toolbar in sync when macOS switches appearance while the window is open (#1484). Thanks @hhh2210! +- Launch at Login: remove pending registrations when disabled without re-registering entries awaiting user approval (#1469). Thanks @AmrMohamad! +- Diagnostics: enforce probe timeouts even when an underlying provider operation ignores Swift task cancellation. + +## 0.34.0 — 2026-06-12 + +### Added +- Copilot: optionally import GitHub billing budget windows, bind them to the active account, and expose budget metrics in cards and menu bar icons (#1273). Thanks @Quicksaver! +- Localization: add native Korean language support across the app and language picker (#1460). Thanks @soohanpark! +- Localization: add German as a selectable app language (#1245). Thanks @Yuxin-Qiao! +- Localization: add Turkish as a selectable app language (#1232). Thanks @ykarateke! +- Devin: add daily and weekly quota tracking from the signed-in Chrome session or a manual Bearer token (#1264, fixes #800). Thanks @coygeek! +- Amp: add local `amp usage` support, including account identity and individual and workspace credit balances (fixes #1317). Thanks @3kh0! +- Menu bar: add an optional reset-time display for the selected quota metric, with percent fallback when reset metadata is unavailable (#1223, fixes #1185). Thanks @Yuxin-Qiao! +- Cursor: include application data, extensions, settings, and caches in optional local storage tracking (fixes #1403). Thanks @dhruv-anand-aintech! +- Menu bar: move the highlighted Overview provider with trackpad or mouse-wheel scrolling while preserving native submenu and keyboard behavior (#1436). Thanks @joshuavial! + +### Fixed +- CLI: keep Ollama API credentials scoped to Ollama when deciding whether another provider requires macOS web support (#1466). Thanks @WadydX! +- Provider switcher: keep localized tab titles visible by tightening outer insets only when equal-width segments would otherwise truncate. +- OpenAI API: follow Admin usage pagination for costs and completions so multi-page organization usage totals are not undercounted (#1465). Thanks @rohitjavvadi! +- Settings: slightly increase the window height so standard panes fit without clipping their final controls or helper text. +- Menu bar: show immediate in-place feedback for manual refreshes, keep tracked-menu geometry stable, and coalesce repeated clicks until the active refresh succeeds or fails (#1458). Thanks @hhh2210! +- Grok: recover web billing from status-7 credential failures by combining current browser sessions with non-expired CLI auth, accept raw protobuf responses, and render current zero-use periods (#1452). Thanks @bcharleson! +- Amp: restore usage fetching with access-token authentication for the current balance endpoint and retain browser-cookie settings parsing as a fallback. Thanks @3kh0! +- Antigravity: detect current hyphenated IDE language-server processes inside Antigravity app bundles so local quota refreshes no longer report the IDE as unavailable (#1405). Thanks @lfmundim! +- Menu bar: avoid republishing unchanged provider storage footprints so background scans no longer trigger unnecessary menu observation work (#1416). Thanks @soohanpark! +- Cursor: show capped team Extra usage when no individual cap exists, and honor percent used/remaining menu bar display settings instead of always showing currency spend (#1426). Thanks @lpc-eol! +- Cursor: derive a first-party web session from the signed-in Cursor.app as a final fallback, preserving account precedence and legacy request quotas (#1295). Thanks @Jackie-Qin! +- Claude: explain that an unauthorized Web session requires signing in at claude.ai or refreshing imported cookies (#1287). Thanks @LeoLin990405! +- CLI server: reload provider config for every usage and cost request, invalidate config-dependent cache entries, and prune expired config variants without restarting `codexbar serve`. Thanks @enieuwy! +- Menu bar: reserve quota-bar space consistently across Overview and provider switcher segments so selection no longer changes segment height (#1445). Thanks @Zihao-Qi! +- Cost usage: accept normal models.dev catalog churn while retaining prior model prices as fallbacks, so newly priced models appear without requiring a manual cache reset (#1438). Thanks @tom-rigelblu! +- Menu bar: detect Tahoe Control Center proxy windows parked in the blocked offscreen slot during startup recovery, so hidden icons show the existing guidance without weakening menu-bar-manager safeguards (#1440). +- AWS Bedrock: treat Cost Explorer's temporary data-unavailable response as zero usage instead of an HTTP 400 error (#1324). Thanks @enesteve0! +- Provider switcher: inset quota bars inside fixed-height segments so icons, labels, and selected pills remain vertically centered. +- Doubao: show an unavailable quota state when Ark omits trustworthy request-limit data instead of reporting 100% left. +- Menu bar: anchor merged provider dropdowns to the status item's trailing edge without marking preserved in-flight refresh content fresh, preventing horizontal drift while keeping deferred updates visible (#1288). Thanks @Yuxin-Qiao! +- Antigravity: fall back to the CLI usage server when the desktop app is closed, keep helper sessions owned and bounded without hidden sign-in flows, and show model rows with missing usage as unavailable instead of exhausted (#1313). Thanks @enieuwy! +- Cost usage: replace repeated Foundation metadata/root checks with one portable file-stat pass so expired Codex history refreshes stay responsive on very large session archives (#1392). Thanks @TheAngryPit and @ProspectOre! +- Cursor: show the Safari Full Disk Access recovery hint before the long browser login list so permission guidance remains visible when menu errors truncate (#1419, fixes #1417). Thanks @hhh2210! +- Cursor: present legacy request-based plans as one Requests quota with the raw used/limit count instead of unrelated token-based Auto/API bars (#1420, fixes #1418). Thanks @hhh2210! +- Cost usage: memoize Codex priority-turn trace metadata incrementally so warm refreshes scan only appended rows instead of rescanning large trace databases (#1404). Thanks @ProspectOre! +- Security: reject insecure or malformed MiniMax and Alibaba endpoint overrides while preserving valid custom HTTPS deployments (#1269). Thanks @Hinotoi-agent! +- Security: reject insecure or malformed OpenRouter, Codebuff, Groq, and ElevenLabs endpoint overrides before sending provider credentials (#1256). Thanks @Hinotoi-agent! + +## 0.33.0 — 2026-06-11 + +### Added +- Settings: choose Terminal.app or iTerm for Open Terminal actions, including Vertex AI login commands (#1225, fixes #1147). Thanks @Yuxin-Qiao! +- Localization: add Japanese as a selectable app language (#1385). Thanks @naoterumaker! + +### Fixed +- Menu bar: keep large dynamic Cost totals inside the fixed-width hosted row so switching providers no longer widens the menu or misaligns submenu arrows. +- Cost history: keep all per-day model breakdown rows available in a bounded scrolling detail area instead of hiding models after the first four (#1370). Thanks @MoollaMore! +- Cost usage: run local session-corpus scans and cache decoding on a dedicated serial queue instead of the Swift cooperative thread pool, so multi-minute scans of large archives no longer starve the app's async work or freeze menus (#1387, #1392). Thanks @ProspectOre! +- Copilot: keep explicitly unlimited chat quotas visible instead of dropping their zero-entitlement payload as unavailable (#1320). Thanks @soumikbhatta! +- Security: block credentialed provider redirects that leave the original HTTPS origin while preserving same-origin redirects (#1237). Thanks @Hinotoi-agent! +- Codex: keep local token and cost history visible when remote quota data is unavailable (#1390). Thanks @vaibhavarora14! +- Doubao: confirm zero-remaining HTTP 200 request limits before falling back, preserving genuine exhaustion and avoiding false 100% usage (#1383). Thanks @LeoLin990405 and @foobra! +- Menu bar: defer pasteboard writes and copy feedback outside the `NSMenu` tracking callback so in-menu copy buttons no longer beachball on macOS 26 (#1388). Thanks @LeoLin990405! +- Menu bar: defer merged status-icon redraws until the tracked menu closes while preserving animation lifecycle and quota-warning timing, reducing WindowServer churn during long menu sessions (#1409, fixes #1399). Thanks @kiranmagic7! +- Provider status: decode status feeds on the concurrent executor and reuse ISO8601 formatters, removing a measured main-thread stall during refreshes (#1406). Thanks @ProspectOre! +- Menu bar: keep one stable width across merged provider tabs and resize every hosted card row to AppKit's final menu width so provider switching no longer leaves a widened menu with inset submenu arrows (#1410). +- Menu bar: keep Codex `auth.json` reads, JWT parsing, and fingerprint hashing off the menu-build path by rendering a cached account snapshot and revalidating it asynchronously (#1401). Thanks @ProspectOre! +- Menu bar: defer Overview-row provider transitions out of AppKit's click callback so opening provider detail no longer performs a full synchronous menu rebuild (#1325). +- Menu bar: open cached menus immediately after data-only invalidations, then refresh missing or stale provider data asynchronously without queuing redundant work on close (#1398). Thanks @joshuavial! +- Menu bar: recycle SwiftUI card hosting views across data refreshes and provider switches, and reconcile matching menu rows in place instead of removing and reinserting every row, cutting open-click, switch, and idle rebuild cost (#1394). Thanks @bcssewl! +- Menu bar: gate the provider-switcher shortcut monitor's event-queue peek behind session event counters so hover-driven menu tracking no longer calls `NSApp.nextEvent` on every run-loop pass (#1397). Thanks @bcssewl! +- Development: disable Keychain access for unbundled executables to avoid repeated password prompts while preserving packaged app behavior (#1271). Thanks @Yuxin-Qiao! +- Antigravity: exclude model quotas without a remaining fraction from family summaries so they no longer mask tracked usage in the automatic menu-bar metric (#1369). Thanks @Martin-Hausleitner! +- Claude: add bundled Fable 5 pricing, account for native 1-hour cache-write usage, and refresh Sonnet 4.6 full-context rates (#1368). Thanks @MoollaMore! +- Claude: show a direct claude.ai re-login action when a configured web session expires or becomes invalid (#1377). Thanks @LeoLin990405! +- Menu: reuse unchanged hosted chart submenus and precompute utilization history models to reduce expand and hover stalls (#1379). Thanks @hhh2210! +- Menu bar: defer data-refresh rebuilds until the tracked menu closes, avoiding multi-second WindowServer stalls with slower providers such as Grok (#1376). Thanks @jangisaac-dev! +- OpenAI Web: evict cached dashboard WebViews after their idle timeout even when no later cache activity occurs, releasing hidden WebKit helper processes (#1386). Thanks @naoterumaker! +- Xiaomi MiMo: import automatic session cookies from Safari, Chrome variants, Firefox, and Edge instead of limiting discovery to Chrome (#1304). Thanks @Yuxin-Qiao! + +## 0.32.5 — 2026-06-09 + +### Added +- Localization: add French as a selectable app language (#1241). Thanks @Yuxin-Qiao! +- Localization: add Ukrainian as a selectable app language (#1250). Thanks @Yuxin-Qiao! +- Localization: add Dutch as a selectable app language (#1252). Thanks @Yuxin-Qiao! +- Localization: add Vietnamese as a selectable app language (#1247). Thanks @Yuxin-Qiao! + +### Fixed +- Menu bar: keep provider switching inside AppKit's menu-tracking transaction and defer structural dropdown rebuilds until mouse-up completes, preventing intermittent hangs when moving between providers and Overview. +- Localization: cache resolved localized bundles so repeated menu/status text lookups no longer hit disk on the main thread (#1355, fixes #1347). Thanks @Yuxin-Qiao! +- Menu bar: size hosted chart submenus directly instead of spinning up throwaway SwiftUI hosting controllers during menu layout (#1352). Thanks @Yuxin-Qiao! +- Menu bar: avoid recomputing expensive readiness signatures on closed-menu store ticks while preserving root-open refresh correctness for deferred observations (#1351). Thanks @Yuxin-Qiao! +- Menu bar: defer Quit from the status menu until AppKit menu tracking unwinds so shutdown does not wedge Dock autohide state (#1354, fixes #1353). Thanks @jskoiz! +- Claude: remove transient ClaudeProbe session artifacts after CLI usage polls so background refreshes no longer fill Claude Code project history with CodexBar `/usage` sessions (#1301). Thanks @LPFchan and @matthewod11-stack! +- Menu bar: keep z.ai overview rows with detail submenus in Overview so hovering quota details no longer recurses into a nested provider menu (#1279, fixes #1246). Thanks @RajvardhanPatil07! +- Codex: backfill visible-account reset timestamps and missing 5-hour/weekly window metadata from same-workspace plan history so segmented multi-account JSON keeps machine-readable reset data (#1283). Thanks @callmepopo! +- Antigravity: detect CLI local language-server processes and allow empty CSRF tokens only for explicit CLI matches so Antigravity CLI quota usage renders without weakening IDE CSRF detection (#1341). Thanks @oyaah! +- Menu bar: skip closed attached-menu rebuilds during stale background data-refresh ticks so closed dropdowns are not pre-warmed while the user is not interacting (#1291). Thanks @Nicolas0315! +- Cursor: show deficit and run-out pace details for 30-day Total, Auto, and API billing-cycle usage rows (#1336). Thanks @dhruv-anand-aintech! +- Codex: time out stalled managed `codex login` processes so account switches no longer stay stuck in progress after OAuth completes (#1330). Thanks @dhruv-anand-aintech! +- Codex Spark: show the same deficit and run-out pace details as the core Codex quota lanes for 5-hour and weekly model limits (#1335). Thanks @dhruv-anand-aintech! +- Antigravity: make the automatic menu-bar summary choose the most constrained family quota so an exhausted Gemini lane is no longer hidden by a full Claude lane (#1334). Thanks @dhruv-anand-aintech! +- Performance: memoize models.dev cost catalog load outcomes so large Codex history scans no longer re-read and decode the same cache file per row (#1322, refs #1311). Thanks @turbothad! +- Menu bar: compute Claude pace/reserve from the selected menu-bar metric window so Primary (Session) no longer pairs the session percentage with the weekly reserve (#1302). Thanks @outfoxer! +- Menu bar: defer merged-menu close rebuilds and cache repeated menu-card height measurements so dismissing or rapidly switching the merged dropdown avoids rebuilding SwiftUI-backed cards on the main thread (#1274, #1286, #1314). Thanks @hhh2210! +- Menu bar: keep merged provider tab selection from invalidating broad settings observers so switching providers no longer triggers background refresh and status-icon work. +- Menu bar: observe a compact icon-state signature so merged status icons no longer redraw for provider snapshot changes that cannot affect the visible icon (#1297). Thanks @hhh2210! +- Menu bar: keep provider-switcher quota bars from replacing Auto Layout constraints when the visible ratio is unchanged, making tab switches responsive with many providers enabled (#1303, #1315). Thanks @juanjoseluisgarcia! +- Kiro: retry login-shell PATH capture when CLI discovery races a slow cold shell startup, so `kiro-cli` is no longer stuck as missing for the whole app session (#1316). Thanks @bt-justtrack! + +## 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 @@ -16,6 +794,9 @@ - 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! @@ -277,7 +1058,6 @@ - Manus: add browser-cookie provider support for credit balance, monthly credits, and daily refresh tracking (#700). Thanks @hhh2210! - MiMo: add browser-cookie provider support for Xiaomi token-plan usage, plan labels, balance fallback, CLI, widget, and docs (#651). Thanks @debpramanik! - Qwen and Doubao: add API-key provider support for Alibaba Qwen and Volcengine Ark request-limit tracking (#498). Thanks @LeoLin990405! -- MiniMax: add multi-service quota cards for text, speech, image, video, and music coding-plan usage (#605). Thanks @XWind18! - Antigravity: add OAuth-backed remote usage fetching so quotas can refresh even when the IDE is closed (#635). Thanks @abnormal749! - Venice: add API-key balance provider support with DIEM/USD balance display and token-account CLI wiring (#865). Thanks @clawSean! - Crof: add API-key provider support with request quota and credit balance tracking (#872). Thanks @baanish! @@ -292,8 +1072,6 @@ ### Menu & Settings - Codex: add a stacked multi-account menu layout for account switchers (#869). Thanks @ajmccall! -- Notifications: add opt-in quota warning notifications, warning markers, and provider-level thresholds for session and weekly quota windows (#852). Thanks @Alekstodo! -- Accessibility: add VoiceOver labels for status icons, menu rows, provider switcher buttons, and usage charts (#860, fixes #859). Thanks @WadydX! - Menu bar: keep status items visible on launch by avoiding macOS autosaved hidden menu-extra state from v0.24 (#861). - Menu bar: remove stale split provider status items instead of hiding them, avoiding leftover second-icon slots on macOS 26.4. - Menu: keep the status menu open when manually refreshing usage from the menu (#845). Thanks @OlimjonovOtabek! diff --git a/CLAUDE.md b/CLAUDE.md index 2f38e376d5..d4e9d7e778 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,9 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Overview -CodexBar is a macOS 14+ (Sonoma) menu bar app monitoring AI coding tool usage across 18+ providers. Built with Swift 6.2 and strict concurrency, SwiftPM-only (no Xcode project). Fork of [steipete/CodexBar](https://github.com/steipete/CodexBar) — this fork preserves the Augment provider (removed upstream) and adds color-coded icons, time window selection, and weekly projection. +CodexBar is a macOS 14+ (Sonoma) menu bar app monitoring AI coding tool usage across ~70 providers. Built with Swift 6 and strict concurrency, SwiftPM-only (no Xcode project). Fork of [steipete/CodexBar](https://github.com/steipete/CodexBar), synced to upstream 0.46.0. + +The fork's only functional delta is **color-coded menu bar icons, defaulted on** (upstream ships the same feature off by default) plus a disabled Sparkle feed and fork signing identity. Separator styles and session/weekly window selection were retired in the 0.46 sync — upstream's layout editor and `MenuBarMetricPreference` supersede them. ## Build, Test, Lint @@ -15,8 +17,11 @@ swift test # Full XCTest suite swift test --filter TestClass/testMethod # Single test ./Scripts/compile_and_run.sh # Full dev cycle: kill → build → test → package → relaunch → verify -./Scripts/lint.sh lint # SwiftFormat --lint + SwiftLint --strict (check only) +./Scripts/lint.sh lint # locales + portable checks + SwiftFormat + SwiftLint --strict +./Scripts/lint.sh lint-macos # locales + SwiftFormat (what CI runs on macOS) +./Scripts/lint.sh lint-linux # portable checks + SwiftLint (what CI runs on Linux) ./Scripts/lint.sh format # SwiftFormat auto-fix +node Scripts/check-app-locales.mjs # locale completeness; new L() keys need all 22 catalogs ./Scripts/package_app.sh # Build release binary → create CodexBar.app bundle ./Scripts/sign-and-notarize.sh # Code sign + notarize (arm64 zip) ./Scripts/make_appcast.sh # Generate Sparkle appcast @@ -29,7 +34,7 @@ After code changes, always rebuild and restart via `./Scripts/compile_and_run.sh ### Provider System Each provider lives in `Sources/CodexBarCore/Providers//` with two files: -- **`*ProviderDescriptor`** — Metadata (display name, icon, color, supported source modes). Registered via `@ProviderDescriptorRegistration` and `@ProviderDescriptorDefinition` macros into `ProviderDescriptorRegistry`. +- **`*ProviderDescriptor`** — Metadata (display name, icon, color, supported source modes). Registered by hand in the `ProviderDescriptorRegistry.descriptorsByID` map in `Sources/CodexBarCore/Providers/ProviderDescriptor.swift`. Adding a provider means adding an entry there; a missing one trips a `preconditionFailure` at bootstrap. - **`*StatusProbe`** — Fetch logic implementing one or more `ProviderFetchStrategy` variants (`.oauth`, `.web`, `.cli`, `.api`, `.localProbe`). Strategies declare availability and execute fetches with automatic fallback chaining. ### Data Flow @@ -59,13 +64,6 @@ UsageFetcher (orchestrator) | `RateWindow` | `Sources/CodexBarCore/` | Percentage used, window duration, reset time | | `ConsecutiveFailureGate` | `Sources/CodexBarCore/` | Debounces flaky errors before displaying | -### Macros (`Sources/CodexBarMacros/`) - -- `@ProviderDescriptorRegistration` — Generates registry peer function -- `@ProviderDescriptorDefinition` — Generates `descriptor` computed property -- `@ProviderImplementationRegistration` — Registers provider implementation - -Macro support types live in `Sources/CodexBarMacroSupport/`, implementations use SwiftSyntaxMacros. ### Authentication Chain @@ -100,8 +98,8 @@ Providers authenticate via a fallback chain configured in their descriptor's `su ## Fork Context -- **Upstream:** `steipete/CodexBar` — upstream removed Augment; this fork preserves it +- **Upstream:** `steipete/CodexBar` — Augment is present upstream; nothing provider-related is fork-only - **Secondary upstream:** `nguyenphutrong/quotio` — monitored for feature ideas -- **Fork-specific features:** Color-coded menu bar icons, time window selection, weekly projection, separator styles +- **Fork-specific:** color-coded icons default on; Sparkle feed disabled in `Scripts/package_app.sh` (fork shares upstream's bundle ID *and* Sparkle key, so a live feed would auto-update fork installs into upstream builds); `APP_TEAM_ID`/signing identity - **Upstream sync scripts:** `Scripts/check_upstreams.sh`, `Scripts/review_upstream.sh`, `Scripts/prepare_upstream_pr.sh` - **Version:** Tracked in `version.env` (`MARKETING_VERSION` + `BUILD_NUMBER`) diff --git a/Icon.icns b/Icon.icns index 173ab75eb9..1033b2c4bb 100644 Binary files a/Icon.icns and b/Icon.icns differ diff --git a/Icon.icon/Assets/codexbar.png b/Icon.icon/Assets/codexbar.png index 4bb1ec6e1d..b247ec5f70 100644 Binary files a/Icon.icon/Assets/codexbar.png and b/Icon.icon/Assets/codexbar.png differ diff --git a/Icon.icon/icon.json b/Icon.icon/icon.json index 915293189c..0e27ea6344 100644 --- a/Icon.icon/icon.json +++ b/Icon.icon/icon.json @@ -1,6 +1,6 @@ { "fill" : { - "automatic-gradient" : "extended-srgb:0.00000,0.53333,1.00000,1.00000" + "automatic-gradient" : "extended-srgb:0.00000,0.00000,0.00000,1.00000" }, "groups" : [ { @@ -9,7 +9,7 @@ "image-name" : "codexbar.png", "name" : "codexbar", "position" : { - "scale" : 1.4, + "scale" : 1.0, "translation-in-points" : [ 0, 0 @@ -19,11 +19,11 @@ ], "shadow" : { "kind" : "neutral", - "opacity" : 0.5 + "opacity" : 0.2 }, "translucency" : { - "enabled" : true, - "value" : 0.5 + "enabled" : false, + "value" : 0.0 } } ], @@ -33,4 +33,4 @@ ], "squares" : "shared" } -} \ No newline at end of file +} diff --git a/Makefile b/Makefile index 02f0f14e96..f0563d2b34 100644 --- a/Makefile +++ b/Makefile @@ -3,6 +3,9 @@ # Pull version from version.env for targets that need it include version.env + +# Matches codexbar_release_arch_label in Scripts/release_artifacts.sh. +ARCH_LABEL ?= macos-universal export MARKETING_VERSION export BUILD_NUMBER @@ -15,7 +18,7 @@ export APP_IDENTITY # GitHub repo for fork releases (override: make sign-and-release GH_REPO="org/repo") GH_REPO ?= johnlarkin1/CodexBar -.PHONY: help build build-release test run run-test lint format \ +.PHONY: help build build-release test run run-test lint format check \ sign package release sign-and-release appcast check-release \ validate-changelog check-upstream clean @@ -41,6 +44,8 @@ run-test: ## Build with tests, package, and launch ./Scripts/compile_and_run.sh --test # ── Code Quality ──────────────────────────────────────────────────── +check: lint ## Alias for lint (upstream tooling and AGENTS.md expect `make check`) + lint: ## SwiftFormat --lint + SwiftLint --strict (check only) ./Scripts/lint.sh lint @@ -67,8 +72,8 @@ _guard-no-upstream: ## (internal) Block releases targeting upstream sign-and-release: _guard-no-upstream sign ## Sign, notarize, tag, and create GitHub release on fork @TAG="v$(MARKETING_VERSION)"; \ - ZIP="CodexBar-$(MARKETING_VERSION).zip"; \ - DSYM_ZIP="CodexBar-$(MARKETING_VERSION).dSYM.zip"; \ + ZIP="CodexBar-$(ARCH_LABEL)-$(MARKETING_VERSION).zip"; \ + DSYM_ZIP="CodexBar-$(ARCH_LABEL)-$(MARKETING_VERSION).dSYM.zip"; \ if [ ! -f "$$ZIP" ]; then \ echo "ERROR: $$ZIP not found. Did sign-and-notarize.sh succeed?" >&2; \ exit 1; \ diff --git a/Package.resolved b/Package.resolved index 345845bc60..f3a7e400de 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "9daf4612f2543e308a07be34ab3ccf2f4650427ce8086e5943eaed1fa79b64f4", + "originHash" : "d5ef2ec180d58ea5f869b40e5024f2e23d1ce02e999305b2e78d1b5c3783b83b", "pins" : [ { "identity" : "commander", @@ -24,8 +24,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/sparkle-project/Sparkle", "state" : { - "revision" : "066e75a8b3e99962685d6a90cdd5293ebffd9261", - "version" : "2.9.1" + "revision" : "d46d456107feacc80711b21847b82b07bd9fb46e", + "version" : "2.9.3" } }, { @@ -42,8 +42,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-asn1.git", "state" : { - "revision" : "eb50cbd14606a9161cbc5d452f18797c90ef0bab", - "version" : "1.7.0" + "revision" : "a9a5efd40eaf558a2bcd48d64b1d1646be686008", + "version" : "1.7.1" } }, { @@ -60,17 +60,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-log", "state" : { - "revision" : "5073617dac96330a486245e4c0179cb0a6fd2256", - "version" : "1.12.0" - } - }, - { - "identity" : "swift-syntax", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-syntax", - "state" : { - "revision" : "0687f71944021d616d34d922343dcef086855920", - "version" : "600.0.1" + "revision" : "92448c359f00ebe36ae97d3bd9086f13c7692b5a", + "version" : "1.13.2" } }, { diff --git a/Package.swift b/Package.swift index 6864b99b03..888bee23d5 100644 --- a/Package.swift +++ b/Package.swift @@ -1,5 +1,4 @@ // swift-tools-version: 6.2 -import CompilerPluginSupport import Foundation import PackageDescription @@ -11,6 +10,14 @@ let sweetCookieKitDependency: Package.Dependency = ? .package(path: sweetCookieKitPath) : .package(url: "https://github.com/steipete/SweetCookieKit", from: "0.4.1") +let sqlite3LibDir = ProcessInfo.processInfo.environment["CODEXBAR_SQLITE3_LIB_DIR"]? + .trimmingCharacters(in: .whitespacesAndNewlines) +let sqlite3LinkerSettings: [LinkerSetting] = if let sqlite3LibDir, !sqlite3LibDir.isEmpty { + [.unsafeFlags(["-L\(sqlite3LibDir)"], .when(platforms: [.linux]))] +} else { + [] +} + let package = Package( name: "CodexBar", defaultLocalization: "en", @@ -21,6 +28,8 @@ let package = Package( var products: [Product] = [ .library(name: "CodexBarCore", targets: ["CodexBarCore"]), .executable(name: "CodexBarCLI", targets: ["CodexBarCLI"]), + // Offline adaptive-refresh replay harness. Keep the supporting library package-internal. + .executable(name: "AdaptiveReplayCLI", targets: ["AdaptiveReplayCLI"]), ] #if os(macOS) @@ -35,53 +44,96 @@ let package = Package( return products }(), dependencies: [ - .package(url: "https://github.com/sparkle-project/Sparkle", from: "2.9.1"), + .package(url: "https://github.com/sparkle-project/Sparkle", from: "2.9.3"), .package(url: "https://github.com/steipete/Commander", from: "0.2.1"), .package(url: "https://github.com/apple/swift-crypto.git", from: "3.0.0"), - .package(url: "https://github.com/apple/swift-log", from: "1.12.0"), - .package(url: "https://github.com/apple/swift-syntax", from: "600.0.1"), + .package(url: "https://github.com/apple/swift-log", from: "1.13.2"), .package(url: "https://github.com/sindresorhus/KeyboardShortcuts", from: "2.4.0"), .package(url: "https://github.com/zats/Vortex", revision: "ef5392088d4aeb255c4eee83157dbdafcd31bf07"), sweetCookieKitDependency, ], targets: { var targets: [Target] = [ + // Both glibc and static-musl CLI builds use this target; the module map supplies sqlite3 linkage. + .systemLibrary( + name: "CSQLite3", + providers: [ + .apt(["libsqlite3-dev"]), + .brew(["sqlite3"]), + ]), .target( name: "CodexBarCore", dependencies: [ - "CodexBarMacroSupport", + .target(name: "CSQLite3", condition: .when(platforms: [.linux])), .product(name: "Crypto", package: "swift-crypto"), .product(name: "Logging", package: "swift-log"), .product(name: "SweetCookieKit", package: "SweetCookieKit"), ], swiftSettings: [ .enableUpcomingFeature("StrictConcurrency"), - ]), - .macro( - name: "CodexBarMacros", - dependencies: [ - .product(name: "SwiftCompilerPlugin", package: "swift-syntax"), - .product(name: "SwiftSyntaxBuilder", package: "swift-syntax"), - .product(name: "SwiftSyntaxMacros", package: "swift-syntax"), - ]), - .target( - name: "CodexBarMacroSupport", - dependencies: [ - "CodexBarMacros", - ]), + ], + linkerSettings: sqlite3LinkerSettings), .executableTarget( name: "CodexBarCLI", dependencies: [ "CodexBarCore", .product(name: "Commander", package: "Commander"), + .product(name: "Crypto", package: "swift-crypto"), ], path: "Sources/CodexBarCLI", swiftSettings: [ .enableUpcomingFeature("StrictConcurrency"), + ], + linkerSettings: sqlite3LinkerSettings), + // Sole owner of the adaptive refresh decision table. Package-internal so the app and + // offline replay tool share behavior without publishing another library product. + .target( + name: "AdaptiveRefreshCore", + dependencies: [], + path: "Sources/AdaptiveRefreshCore", + swiftSettings: [ + .enableUpcomingFeature("StrictConcurrency"), + ]), + // Offline adaptive-refresh replay harness: pure Foundation, + // no CodexBar/CodexBarCore dependency, so it builds anywhere CodexBarCore does. + .target( + name: "AdaptiveReplayKit", + dependencies: ["AdaptiveRefreshCore"], + path: "Sources/AdaptiveReplayKit", + exclude: ["README.md"], + swiftSettings: [ + .enableUpcomingFeature("StrictConcurrency"), + ]), + .executableTarget( + name: "AdaptiveReplayCLI", + dependencies: ["AdaptiveReplayKit"], + path: "Sources/AdaptiveReplayCLI", + swiftSettings: [ + .enableUpcomingFeature("StrictConcurrency"), + ]), + .testTarget( + name: "AdaptiveReplayCLITests", + dependencies: ["AdaptiveReplayCLI", "AdaptiveReplayKit"], + path: "Tests/AdaptiveReplayCLITests", + swiftSettings: [ + .enableUpcomingFeature("StrictConcurrency"), + .enableExperimentalFeature("SwiftTesting"), + ]), + .testTarget( + name: "AdaptiveReplayKitTests", + dependencies: ["AdaptiveRefreshCore", "AdaptiveReplayKit"], + path: "Tests/AdaptiveReplayKitTests", + swiftSettings: [ + .enableUpcomingFeature("StrictConcurrency"), + .enableExperimentalFeature("SwiftTesting"), ]), .testTarget( name: "CodexBarLinuxTests", - dependencies: ["CodexBarCore", "CodexBarCLI"], + dependencies: [ + "CodexBarCore", + "CodexBarCLI", + .target(name: "CSQLite3", condition: .when(platforms: [.linux])), + ], path: "TestsLinux", swiftSettings: [ .enableUpcomingFeature("StrictConcurrency"), @@ -104,7 +156,7 @@ let package = Package( .product(name: "Sparkle", package: "Sparkle"), .product(name: "KeyboardShortcuts", package: "KeyboardShortcuts"), .product(name: "Vortex", package: "Vortex"), - "CodexBarMacroSupport", + "AdaptiveRefreshCore", "CodexBarCore", ], path: "Sources/CodexBar", @@ -136,6 +188,7 @@ let package = Package( name: "CodexBarTests", dependencies: ["CodexBar", "CodexBarCore", "CodexBarCLI", "CodexBarWidget"], path: "Tests", + exclude: ["AdaptiveReplayCLITests", "AdaptiveReplayKitTests"], resources: [ .copy("CodexBarTests/Fixtures"), ], diff --git a/README.md b/README.md index 1b3cf6145c..515817a814 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ Tiny macOS 14+ menu bar app that keeps your Codex, Claude, Cursor, Gemini, Antigravity, Droid (Factory), Copilot, z.ai, Kiro, Vertex AI, Augment, Amp, JetBrains AI, and OpenRouter limits visible (session + weekly where available) and shows when each window resets. One status item per provider (or Merge Icons mode with a provider switcher and optional Overview tab); enable what you use from Settings. No Dock icon, minimal UI, dynamic bar icons in the menu bar. -CodexBar menu screenshot +CodexBar menu screenshot > ![IMPORTANT] > This is a FORKED project. I'll tell you why I did that below. @@ -23,6 +23,8 @@ steipete's is probably the most popular because of the wide range of tooling sup That's fine, but the beauty of claudecodeusage is... it just works based on your existing auth tokens from running either `claude` or `codex` in your preferred shell. +> **Status as of the 0.46 sync:** this is a gripe, not a code difference. This fork carries **no** auth-model changes — it never has, in the tree. An earlier attempt to default Claude keychain reads to the `/usr/bin/security` CLI was proposed upstream ([#353](https://github.com/steipete/CodexBar/pull/353), [#532](https://github.com/steipete/CodexBar/pull/532)) and not taken; upstream now actively coerces that strategy back to the Security framework. Upstream's supported way to avoid the prompts is **Settings → Claude → keychain prompt mode = Never**, which this fork uses rather than carrying a patch against code upstream is deliberately retiring. + - [Codex](docs/codex.md) — Local Codex CLI RPC (+ PTY fallback) and optional OpenAI web dashboard extras. - [Claude](docs/claude.md) — OAuth API or browser cookies (+ CLI PTY fallback); session + weekly usage. - [Cursor](docs/cursor.md) — Browser session cookies for plan + usage + billing resets. @@ -41,10 +43,6 @@ That's fine, but the beauty of claudecodeusage is... it just works based on your - [OpenRouter](docs/openrouter.md) — API token for credit-based usage tracking across multiple AI providers. - Open to new providers: [provider authoring guide](docs/provider.md). -## Projected Usage - -I like the notion of pace, but I think it's even more helpful to see the week over week pace. I also want to see it visually , so that's why there is a `Weekly Projection` option. - ## Features - Multi-provider menu bar with per-provider toggles (Settings → Providers). - Session + weekly meters with reset countdowns. @@ -57,9 +55,9 @@ I like the notion of pace, but I think it's even more helpful to see the week ov - WidgetKit widget mirrors the menu card snapshot. - Privacy-first: on-device parsing by default; browser cookies are opt-in and reused (no passwords stored). -## 5 hour Window Pace +## 5 Hour Window Pace -I like the notion of pace, but I think it should also render for the 5 hour windows for Codex and Claude. +Pace indicators for the Codex and Claude 5-hour windows. Contributed upstream and merged in [#355](https://github.com/steipete/CodexBar/pull/355), so this is no longer a fork difference — it ships in upstream CodexBar too. ## Coloring of the Icons @@ -69,16 +67,9 @@ I want to look at my menu bar and immediately know my CC usage or my Codex usage Ya see how it's colored. -## Bar vs Dot dividing Usage vs Pace - -See this: - -![Bar vs Dot dividing Usage vs Pace](Public/bar-usage-pace.png) - -and this new option: - -![Separator Option](Public/separator-option.png) +## Menu Bar Layout +The fork's old separator-style toggle was retired when this synced to upstream 0.46. Upstream's menu bar layout editor covers it and more — you can place, reorder, and remove separator, percent, pace, reset, and cost tokens per provider. ## Better Menu Bar Configurations diff --git a/Scripts/build-site-css.sh b/Scripts/build-site-css.sh new file mode 100755 index 0000000000..e87f7ade42 --- /dev/null +++ b/Scripts/build-site-css.sh @@ -0,0 +1,11 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT_DIR" + +npx --yes tailwindcss@3.4.19 \ + --config Scripts/tailwind.site.config.cjs \ + --input Scripts/site-tailwind.input.css \ + --output docs/site-utilities.css \ + --minify diff --git a/Scripts/check-app-locales.mjs b/Scripts/check-app-locales.mjs new file mode 100644 index 0000000000..ce029e7aad --- /dev/null +++ b/Scripts/check-app-locales.mjs @@ -0,0 +1,210 @@ +#!/usr/bin/env node +import { execFileSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const resources = path.join(repoRoot, "Sources/CodexBar/Resources"); +const english = readCatalog("en"); +const englishKeys = Object.keys(english).sort(); +const strictLocales = ["ar", "ca", "fa", "th"]; +// Catalogs that have reached full English-key coverage. New locales can remain +// warning-only while they are being bootstrapped, then join this list once complete. +const completeLocales = [ + "ar", "ca", "de", "es", "fa", "fr", "gl", "id", "it", "ja", "ko", "nl", "pl", "pt-BR", "ru", "sv", + "th", "tr", "uk", "vi", "zh-Hans", "zh-Hant", +]; +const languageKeys = ["language_arabic", "language_persian", "language_thai"]; +const isTest = process.argv.includes("--test"); + +function readCatalog(locale) { + const file = path.join(resources, `${locale}.lproj/Localizable.strings`); + if (!fs.existsSync(file)) return null; + const output = execFileSync("plutil", ["-convert", "json", "-o", "-", file], { encoding: "utf8" }); + return JSON.parse(output); +} + +function tokenSignature(value) { + // Exclude explicit `%%`, which does not consume an argument. + const withoutEscapedPercents = value.replace(/%%/g, ""); + const printfRaw = withoutEscapedPercents.match(/%(?:\d+\$)?(?:\.\d+)?(?:@|d|f)/g) ?? []; + + const printf = {}; + let implicitIndex = 1; + for (const token of printfRaw) { + const match = token.match(/%(\d+)\$.*?([@df])/); + if (match) { + printf[Number.parseInt(match[1], 10)] = match[2]; + } else { + printf[implicitIndex] = token.at(-1); + implicitIndex += 1; + } + } + + return { printf, swift: swiftInterpolationTokens(value).sort() }; +} + +function formatKeyList(keys, limit = 12) { + const shown = keys.slice(0, limit).join(", "); + const remaining = keys.length - limit; + return remaining > 0 ? `${shown}, ... +${remaining} more` : shown; +} + +function blankKeys(catalog, referenceKeys) { + return referenceKeys.filter((key) => Object.hasOwn(catalog, key) && !catalog[key]?.trim()); +} + +function swiftInterpolationTokens(value) { + const tokens = []; + for (let index = 0; index < value.length - 1; index += 1) { + if (value[index] !== "\\" || value[index + 1] !== "(") continue; + + const start = index; + let depth = 1; + index += 2; + while (index < value.length && depth > 0) { + if (value[index] === "(") depth += 1; + if (value[index] === ")") depth -= 1; + index += 1; + } + tokens.push(value.slice(start, index)); + index -= 1; + } + return tokens; +} + +if (isTest) { + assertEqual(tokenSignature("%1$@ · %2$d"), tokenSignature("%2$d · %1$@"), "positional reorder"); + assertNotEqual(tokenSignature("%1$@ · %2$d"), tokenSignature("%1$d · %2$@"), "positional type swap"); + assertEqual(tokenSignature("%.0f%% used"), tokenSignature("%.0f%% verbraucht"), "escaped percent"); + assertNotEqual(tokenSignature("\\(name): \\(usage)"), tokenSignature("\\(name): \\(value)"), "Swift tokens"); + assertEqual( + tokenSignature("\\(self.store.metadata(for: self.provider).displayName) failed"), + tokenSignature("Fehler: \\(self.store.metadata(for: self.provider).displayName)"), + "nested Swift interpolation"); + assertNotEqual( + tokenSignature("\\(self.store.metadata(for: self.provider).displayName) failed"), + tokenSignature("\\(self.store.metadata(for: self.provider) failed"), + "truncated Swift interpolation"); + assertEqual(formatKeyList(["alpha", "beta"]), "alpha, beta", "short key list"); + assertEqual( + formatKeyList(["alpha", "beta", "gamma", "delta"], 2), + "alpha, beta, ... +2 more", + "truncated key list"); + assertEqual( + blankKeys({ alpha: "", beta: " ", gamma: "ok" }, ["alpha", "beta", "gamma", "delta"]), + ["alpha", "beta"], + "blank keys"); + assertEqual([...new Set(completeLocales)], completeLocales, "unique complete locales"); + assertEqual( + strictLocales.filter((locale) => !completeLocales.includes(locale)), + [], + "strict locales are complete locales"); + console.log("app locale checker tests OK"); + process.exit(0); +} + +let hasErrors = false; +let checkedCount = 0; + +for (const completeLocale of completeLocales) { + const dirPath = path.join(resources, `${completeLocale}.lproj`); + if (!fs.existsSync(dirPath)) { + console.error(`\x1b[31mError: Required complete locale catalog is missing: ${completeLocale}.lproj\x1b[0m`); + hasErrors = true; + } +} + +for (const directory of fs.readdirSync(resources).filter((name) => name.endsWith(".lproj"))) { + const locale = directory.replace(/\.lproj$/, ""); + if (locale === "en" || locale === "Base") continue; + + const catalog = readCatalog(locale); + if (!catalog) continue; + + checkedCount++; + const catalogKeys = Object.keys(catalog); + const emptyKeys = blankKeys(catalog, englishKeys); + + // 1. Missing keys + const missingKeys = englishKeys.filter((key) => !catalogKeys.includes(key)); + if (missingKeys.length > 0) { + const missingKeyList = formatKeyList(missingKeys); + if (completeLocales.includes(locale)) { + console.error( + `\x1b[31m[${locale}] Error: Missing ${missingKeys.length} keys in complete locale: ${missingKeyList}.\x1b[0m`); + hasErrors = true; + } else { + console.warn(`\x1b[33m[${locale}] Warning: Missing ${missingKeys.length} keys: ${missingKeyList}.\x1b[0m`); + } + } + + const extraKeys = catalogKeys.filter((key) => !englishKeys.includes(key)); + if (strictLocales.includes(locale) && extraKeys.length > 0) { + console.error(`\x1b[31m[${locale}] Error: Found ${extraKeys.length} extra keys in strict locale.\x1b[0m`); + hasErrors = true; + } + + // Ensure critical language keys are present in ALL locales + for (const key of languageKeys) { + if (!catalog[key] || !catalog[key].trim()) { + console.error(`\x1b[31m[${locale}] Error: Missing critical language key "${key}".\x1b[0m`); + hasErrors = true; + } + } + + if (emptyKeys.length > 0) { + console.error( + `\x1b[31m[${locale}] Error: Blank values for ${emptyKeys.length} keys: ${formatKeyList(emptyKeys)}.\x1b[0m`); + hasErrors = true; + } + + // 2. Identical values count + let identicalCount = 0; + + for (const key of englishKeys) { + if (!catalog[key]?.trim()) { + continue; + } + + if (catalog[key] === english[key]) { + identicalCount++; + } + + // 3. Format placeholder mismatch + const tEn = tokenSignature(english[key]); + const tLoc = tokenSignature(catalog[key]); + if (JSON.stringify(tEn) !== JSON.stringify(tLoc)) { + console.error(`\x1b[31m[${locale}] Error: Token mismatch for key "${key}"\x1b[0m`); + console.error(` en: ${english[key]} Tokens: ${JSON.stringify(tEn)}`); + console.error(` ${locale}: ${catalog[key]} Tokens: ${JSON.stringify(tLoc)}`); + hasErrors = true; + } + } + + // Warn if identical translation count exceeds 15% of the total keys (approx > 150 out of 1050) + const identicalRatio = identicalCount / englishKeys.length; + if (identicalRatio > 0.15) { + console.warn(`\x1b[33m[${locale}] Warning: High number of identical translations: ${identicalCount}/${englishKeys.length} (${(identicalRatio * 100).toFixed(1)}%)\x1b[0m`); + } +} + +if (hasErrors) { + console.error("\n\x1b[31mApp locale checks failed.\x1b[0m"); + process.exit(1); +} + +console.log(`\n\x1b[32mApp locales OK: Checked ${checkedCount} catalogs against ${englishKeys.length} English keys.\x1b[0m`); + +function assertEqual(actual, expected, label) { + if (JSON.stringify(actual) !== JSON.stringify(expected)) { + throw new Error(`${label}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`); + } +} + +function assertNotEqual(actual, expected, label) { + if (JSON.stringify(actual) === JSON.stringify(expected)) { + throw new Error(`${label}: signatures unexpectedly match`); + } +} diff --git a/Scripts/check-documentation-links.mjs b/Scripts/check-documentation-links.mjs new file mode 100644 index 0000000000..e6faf7a0fc --- /dev/null +++ b/Scripts/check-documentation-links.mjs @@ -0,0 +1,241 @@ +#!/usr/bin/env node +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const approvedRootDocumentation = new Set([ + "README.md", + "CHANGELOG.md", + "LICENSE", + "VISION.md", +].map((relativePath) => path.join(repoRoot, relativePath))); + +const readme = readText("README.md"); +const readmeLinks = [ + ...markdownLinks(readme), + ...markdownImageLinks(readme), + ...htmlLinks(readme), +].filter(isRepositoryDocReference); + +assert(readmeLinks.length > 0, "README.md has no local documentation links"); +for (const link of readmeLinks) validateLocalDocLink(link, repoRoot, "README.md"); + +const providerLinks = inlineCodeDocLinks(readText("docs/providers.md")); +assert(providerLinks.length > 0, "docs/providers.md has no provider detail links"); +for (const link of providerLinks) validateLocalDocLink(link, repoRoot, "docs/providers.md"); + +const docsLinks = markdownFiles("docs").flatMap((relativePath) => { + const markdown = readText(relativePath); + const links = [ + ...markdownLinks(markdown), + ...markdownImageLinks(markdown), + ...htmlLinks(markdown), + ].filter(isLocalDocumentationReference); + + return links.map((link) => ({ link, relativePath })); +}); + +for (const { link, relativePath } of docsLinks) { + validateLocalDocLink(link, path.join(repoRoot, path.dirname(relativePath)), relativePath); +} + +console.log( + `documentation links OK: ${readmeLinks.length + providerLinks.length + docsLinks.length} local links`, +); + +function readText(relativePath) { + return fs.readFileSync(path.join(repoRoot, relativePath), "utf8"); +} + +function markdownLinks(markdown) { + const source = markdownTextOutsideCode(markdown); + const links = []; + const inlinePattern = /(?\n]+)>|([^\s)]+))(?:\s+(?:"[^"\n]*"|'[^'\n]*'|\([^)\n]*\)))?\s*\)/g; + for (const match of source.matchAll(inlinePattern)) { + links.push(encodeSpaces(match[1] ?? match[2])); + } + + const referencePattern = /^\s*\[[^\]\n]+]:\s*(?:<([^>\n]+)>|([^\s]+))/gm; + for (const match of source.matchAll(referencePattern)) { + links.push(encodeSpaces(match[1] ?? match[2])); + } + return links; +} + +function markdownImageLinks(markdown) { + const source = markdownTextOutsideCode(markdown); + const pattern = /!\[(?:\\.|[^\]\\])*\]\(\s*(?:<([^>\n]+)>|([^\s)]+))(?:\s+(?:"[^"\n]*"|'[^'\n]*'|\([^)\n]*\)))?\s*\)/g; + return [...source.matchAll(pattern)].map((match) => match[1] ?? match[2]); +} + +function htmlLinks(markdown) { + const source = markdownTextOutsideCode(markdown); + const pattern = /<\s*(?:a|img)\b[^>]*?\b(?:href|src)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+))/gi; + return [...source.matchAll(pattern)].map((match) => match[1] ?? match[2] ?? match[3]); +} + +function inlineCodeDocLinks(markdown) { + return markdown.split("\n").flatMap((line) => { + const trimmed = line.trim(); + const prefix = "- Details: `"; + if (!trimmed.startsWith(prefix)) return []; + const rest = trimmed.slice(prefix.length); + const end = rest.indexOf("`"); + return end === -1 ? [] : [rest.slice(0, end)]; + }); +} + +function validateLocalDocLink(rawLink, baseDirectory, sourceLabel) { + const sourcePath = path.join(repoRoot, sourceLabel); + const { absolutePath, fragment } = localDocPath(rawLink, baseDirectory, sourcePath); + assert(fs.existsSync(absolutePath), `${sourceLabel}: missing documentation target: ${rawLink}`); + + if (path.extname(absolutePath).toLowerCase() !== ".md" || !fragment) return; + const anchors = markdownHeadingAnchors(readText(path.relative(repoRoot, absolutePath))); + assert(anchors.has(fragment), `${sourceLabel}: missing documentation anchor: ${rawLink}`); +} + +function isRepositoryDocReference(rawLink) { + const parsed = parseRelativeURL(rawLink); + if (!parsed || parsed.protocol || parsed.host) return false; + let pathname = parsed.pathname; + while (pathname.startsWith("./")) pathname = pathname.slice(2); + return pathname === "docs" || pathname.startsWith("docs/"); +} + +function isLocalDocumentationReference(rawLink) { + const parsed = parseRelativeURL(rawLink); + if (!parsed || parsed.protocol || parsed.host) return false; + return Boolean(parsed.pathname || parsed.hash); +} + +function localDocPath(rawLink, baseDirectory, sourcePath) { + const parsed = parseRelativeURL(rawLink); + assert( + parsed && !parsed.protocol && !parsed.host && (parsed.pathname || parsed.hash), + `invalid documentation URL: ${rawLink}`, + ); + + const rawPath = rawLink.split("#", 1)[0].split("?", 1)[0]; + const decodedPath = decodeURIComponent(rawPath); + const absolutePath = decodedPath ? path.resolve(baseDirectory, decodedPath) : sourcePath; + const docsRoot = path.resolve(repoRoot, "docs"); + const isInDocsTree = absolutePath === docsRoot || absolutePath.startsWith(`${docsRoot}${path.sep}`); + assert( + isInDocsTree || approvedRootDocumentation.has(absolutePath), + `documentation link escapes approved documentation roots: ${rawLink}`, + ); + return { absolutePath, fragment: parsed.hash ? decodeURIComponent(parsed.hash.slice(1)) : "" }; +} + +function markdownFiles(relativeDir) { + const dir = path.join(repoRoot, relativeDir); + return fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { + if (entry.name.startsWith(".") || entry.name === "node_modules") return []; + const relativePath = path.join(relativeDir, entry.name); + if (entry.isDirectory()) return markdownFiles(relativePath); + return entry.isFile() && entry.name.endsWith(".md") ? [relativePath] : []; + }).sort((a, b) => a.localeCompare(b)); +} + +function parseRelativeURL(rawLink) { + try { + const parsed = new URL(rawLink, "relative://repo/"); + const isRelative = parsed.protocol === "relative:" && parsed.host === "repo"; + return { + protocol: isRelative ? "" : parsed.protocol, + host: isRelative ? "" : parsed.host, + pathname: isRelative ? parsed.pathname.replace(/^\//, "") : parsed.pathname, + hash: parsed.hash, + }; + } catch { + return null; + } +} + +function markdownHeadingAnchors(markdown) { + const occurrences = new Map(); + const anchors = new Set(); + const source = markdownTextOutsideFencedCode(markdown); + for (const line of source.split("\n")) { + const trimmed = line.replace(/^[ \t]+/, ""); + const match = /^(#{1,6})\s+(.+?)\s*$/.exec(trimmed); + if (!match) continue; + const base = markdownHeadingSlug(match[2]); + if (!base) continue; + const occurrence = occurrences.get(base) ?? 0; + anchors.add(occurrence === 0 ? base : `${base}-${occurrence}`); + occurrences.set(base, occurrence + 1); + } + return anchors; +} + +function markdownHeadingSlug(heading) { + const text = removeMarkdownFormatting(heading).toLowerCase(); + let slug = ""; + for (const char of text) { + if (/[\p{Letter}\p{Number}_-]/u.test(char)) { + slug += char; + } else if (/\s/u.test(char)) { + slug += "-"; + } + } + return slug; +} + +function removeMarkdownFormatting(text) { + return text + .replace(/`([^`]*)`/g, "$1") + .replace(/\[([^\]]+)]\([^)]+\)/g, "$1") + .replace(/[*_~]/g, ""); +} + +function markdownTextOutsideCode(markdown) { + return markdownTextOutsideFencedCode(markdown) + .split("\n") + .map(removeInlineCode) + .join("\n"); +} + +function markdownTextOutsideFencedCode(markdown) { + let fence = null; + return markdown.split("\n").map((line) => { + if (fence) { + if (isClosingFence(line, fence.marker, fence.count)) fence = null; + return ""; + } + const openingFence = parseOpeningFence(line); + if (openingFence) { + fence = openingFence; + return ""; + } + return line; + }).join("\n"); +} + +function parseOpeningFence(line) { + const match = /^( {0,3})([`~]{3,})(.*)$/.exec(line); + if (!match) return null; + const marker = match[2][0]; + if (marker === "`" && match[3].includes("`")) return null; + return { marker, count: match[2].length }; +} + +function isClosingFence(line, marker, minimumCount) { + const escaped = marker === "`" ? "`" : "~"; + const pattern = new RegExp(`^ {0,3}${escaped}{${minimumCount},}\\s*$`); + return pattern.test(line); +} + +function removeInlineCode(line) { + return line.replace(/(? match[1]); +assert(providerIDs.length > 0, "UsageProvider must define at least one provider"); +assertEqual(new Set(providerIDs).size, providerIDs.length, "UsageProvider IDs"); +const providerCount = providerIDs.length; + +const publicCountFiles = [ + ["README.md", `alt="CodexBar — every AI coding limit in your menu bar. ${providerCount} providers."`], + ["docs/providers.md", `CodexBar currently registers ${providerCount} provider IDs.`], + ["docs/social.html", `${providerCount} providers`], + ["docs/llms.txt", `across ${providerCount} providers`], +]; +for (const [relativePath, expectedText] of publicCountFiles) { + const contents = fs.readFileSync(path.join(repoRoot, relativePath), "utf8"); + assert(contents.includes(expectedText), `${relativePath} must advertise ${providerCount} providers`); +} +assert(indexHtml.includes(`across ${providerCount} providers`), `index metadata must advertise ${providerCount} providers`); +assert( + indexHtml.includes(`across ${providerCount} AI coding providers`), + `index social metadata must advertise ${providerCount} providers`, +); +assert( + indexHtml.includes(`>${providerCount} providers,{mobileBreak}one menu bar`), + `index provider heading must advertise ${providerCount} providers`, +); + +assert(!indexHtml.includes("cdn.tailwindcss.com"), "site must not load Tailwind from a runtime CDN"); +for (const match of indexHtml.matchAll(/ locale.code); +const appLanguageSource = fs.readFileSync( + path.join(repoRoot, "Sources/CodexBar/PreferencesGeneralPane.swift"), + "utf8", +); + +assertEqual(catalogCodes, expectedCodes, "locale catalog"); +assertEqual( + localeCatalog.filter((locale) => locale.direction === "rtl").map((locale) => locale.code), + ["ar", "fa"], + "RTL locale catalog"); +const appLanguageEnumBody = appLanguageSource.match( + /enum AppLanguage:[^{]+\{([\s\S]*?)\n\}/, +)?.[1]; +assert(appLanguageEnumBody, "could not locate AppLanguage cases"); +const appCatalogCodes = [...appLanguageEnumBody.matchAll(/case \w+ = "([^"]+)"/g)] + .map((match) => match[1]) + .filter(Boolean) + .map((code) => ({ "zh-Hans": "zh-CN", "zh-Hant": "zh-TW", ja: "ja-JP" })[code] ?? code); +assertEqual(appCatalogCodes, expectedCodes, "app language catalog"); + +const englishKeys = Object.keys(localeMessages.en).sort(); +for (const locale of localeCatalog) { + const messages = localeMessages[locale.code]; + assert(messages, `missing messages for ${locale.code}`); + assertEqual(Object.keys(messages).sort(), englishKeys, `${locale.code} message keys`); + + for (const key of ["meta.description", "meta.ogDescription", "providers.title"]) { + const counts = [...messages[key].matchAll(/\d+/g)].map(Number); + assertEqual(counts[0], providerCount, `${locale.code}.${key} provider count`); + } + + for (const key of englishKeys) { + assert(messages[key].trim(), `${locale.code}.${key} is blank`); + assertEqual(tokens(messages[key]), tokens(localeMessages.en[key]), `${locale.code}.${key} tokens`); + } +} + +const referencedKeys = new Set(); +for (const match of indexHtml.matchAll(/data-i18n(?:-rich|-aria-label|-title|-alt)?="([^"]+)"/g)) { + referencedKeys.add(match[1]); +} +for (const key of referencedKeys) { + assert(englishKeys.includes(key), `index.html references unknown locale key ${key}`); +} + +const siteJs = fs.readFileSync(path.join(repoRoot, 'docs/site.js'), 'utf8'); +const hasLanguagePicker = indexHtml.includes('id="language-picker-list"') + && (indexHtml.includes('localeCatalog') || siteJs.includes('localeCatalog')); +assert(hasLanguagePicker, 'site must include the language picker backed by localeCatalog'); + +for (const code of catalogCodes) { + assert(indexHtml.includes(`href="https://codexbar.app/?lang=${code}"`), `missing hreflang URL for ${code}`); +} + +const providerCards = [...indexHtml.matchAll(/
  • ]*)>([\s\S]*?)<\/li>/g)]; +for (const [, attrs, body] of providerCards) { + if (!attrs.includes('hidden')) { + assert(body.includes('class="provider-card-link"'), 'provider cards must link to provider documentation'); + assert(body.includes('class="provider-logo'), 'provider cards must use logo assets'); + for (const match of body.matchAll(/src="\.\/([^"]+)"/g)) { + assert(fs.existsSync(path.join(repoRoot, 'docs', match[1])), `missing provider logo asset ${match[1]}`); + } + } +} + +console.log(`app/site locales OK: ${catalogCodes.length} locales, ${englishKeys.length} site messages`); + +function tokens(value) { + return [...value.matchAll(/\{([^}]+)\}/g)].map((match) => match[1]).sort(); +} + +function assert(condition, message) { + if (!condition) throw new Error(message); +} + +function assertEqual(actual, expected, label) { + const actualJSON = JSON.stringify(actual); + const expectedJSON = JSON.stringify(expected); + if (actualJSON !== expectedJSON) { + throw new Error(`${label}: expected ${expectedJSON}, got ${actualJSON}`); + } +} diff --git a/Scripts/check_repository_size.sh b/Scripts/check_repository_size.sh new file mode 100755 index 0000000000..b94da55225 --- /dev/null +++ b/Scripts/check_repository_size.sh @@ -0,0 +1,58 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +MAX_BYTES=$((2 * 1024 * 1024)) +failures=0 +tracked_files=0 +declare -a blob_paths=() +declare -a blob_ids=() + +cd "$ROOT_DIR" + +while IFS= read -r -d '' entry; do + metadata=${entry%%$'\t'*} + path=${entry#*$'\t'} + read -r mode object stage <<<"$metadata" + [[ "$stage" == "0" ]] || continue + tracked_files=$((tracked_files + 1)) + + case "$path" in + *.app | *.app/* | *.dSYM | *.dSYM/* | *.xcarchive/* | *.xcresult/* | *.ipa | *.zip | *.delta | *.dmg | \ + *.pkg | *.tar.gz | *.tgz) + printf 'ERROR: generated artifact is tracked: %s\n' "$path" >&2 + failures=$((failures + 1)) + ;; + esac + + # Submodule entries name commits rather than file blobs. + [[ "$mode" == "160000" ]] && continue + blob_paths+=("$path") + blob_ids+=("$object") +done < <(git ls-files --stage -z) + +if ((${#blob_ids[@]} > 0)); then + index=0 + while read -r object type size; do + path=${blob_paths[$index]} + if [[ "$type" != "blob" ]]; then + printf 'ERROR: tracked index entry is not a readable blob: %q (%s)\n' "$path" "$object" >&2 + failures=$((failures + 1)) + index=$((index + 1)) + continue + fi + if ((size > MAX_BYTES)); then + printf 'ERROR: tracked file exceeds %d bytes: %q (%d bytes)\n' "$MAX_BYTES" "$path" "$size" >&2 + failures=$((failures + 1)) + fi + index=$((index + 1)) + done < <(printf '%s\n' "${blob_ids[@]}" | git cat-file --batch-check='%(objectname) %(objecttype) %(objectsize)') +fi + +if ((failures > 0)); then + printf 'Repository size check failed with %d violation(s).\n' "$failures" >&2 + printf 'Publish build/release artifacts outside Git and optimize required source assets.\n' >&2 + exit 1 +fi + +printf 'repository size OK: %d tracked files, maximum %d bytes each\n' "$tracked_files" "$MAX_BYTES" diff --git a/Scripts/ci_linux_musl_build_gate.sh b/Scripts/ci_linux_musl_build_gate.sh new file mode 100755 index 0000000000..d00801d62a --- /dev/null +++ b/Scripts/ci_linux_musl_build_gate.sh @@ -0,0 +1,98 @@ +#!/usr/bin/env bash + +set -euo pipefail + +changed_paths_file="${1:-}" + +if [[ -z "$changed_paths_file" || ! -f "$changed_paths_file" ]]; then + printf 'Usage: %s \n' "$(basename "$0")" >&2 + exit 2 +fi + +linux_musl_build=false +linux_musl_build_reason="" +path_count=0 + +require_linux_musl_build() { + local path="$1" + local reason="$2" + + linux_musl_build=true + if [[ -z "$linux_musl_build_reason" ]]; then + linux_musl_build_reason="${path}: ${reason}" + fi +} + +classify_path() { + local path="$1" + [[ -z "$path" ]] && return + + path_count=$((path_count + 1)) + + case "$path" in + Package.swift) + require_linux_musl_build "$path" "changes the Swift package manifest" + ;; + Sources/*.swift) + require_linux_musl_build "$path" "changes Swift source code" + ;; + esac +} + +invalid_row=false +while IFS=$'\t' read -r status first_path second_path extra_path \ + || [[ -n "${status:-}${first_path:-}${second_path:-}${extra_path:-}" ]] +do + [[ -z "${status}${first_path:-}${second_path:-}${extra_path:-}" ]] && continue + + case "$status" in + R*|C*) + if ! [[ "$status" =~ ^[RC][0-9]{1,3}$ ]] \ + || ((10#${status:1} > 100)) \ + || [[ -z "${first_path:-}" || -z "${second_path:-}" || -n "${extra_path:-}" ]] + then + invalid_row=true + break + fi + classify_path "$first_path" + classify_path "$second_path" + ;; + A|D|M|T|U|X|B) + if [[ -z "${first_path:-}" || -n "${second_path:-}" || -n "${extra_path:-}" ]]; then + invalid_row=true + break + fi + classify_path "$first_path" + ;; + *) + invalid_row=true + break + ;; + esac +done < "$changed_paths_file" + +if [[ "$invalid_row" == true ]]; then + printf 'Invalid git name-status row; refusing to skip the Linux musl build.\n' >&2 + exit 2 +fi + +if [[ "$path_count" -eq 0 ]]; then + require_linux_musl_build '' 'no changed paths were reported' +fi + +if [[ "$linux_musl_build" == true ]]; then + summary_reason="$linux_musl_build_reason" +else + summary_reason="no Swift source or Package.swift changes" +fi + +if [[ -n "${GITHUB_OUTPUT:-}" ]]; then + printf 'linux-musl-build=%s\n' "$linux_musl_build" >> "$GITHUB_OUTPUT" + printf 'linux-musl-build-reason=%s\n' "$summary_reason" >> "$GITHUB_OUTPUT" +fi + +if [[ "$linux_musl_build" == true ]]; then + printf 'Linux musl build required for this change set: %s.\n' "$linux_musl_build_reason" +else + printf 'Skipping Linux musl build: %s.\n' "$summary_reason" +fi diff --git a/Scripts/ci_macos_test_gate.sh b/Scripts/ci_macos_test_gate.sh new file mode 100755 index 0000000000..3350b6e610 --- /dev/null +++ b/Scripts/ci_macos_test_gate.sh @@ -0,0 +1,122 @@ +#!/usr/bin/env bash + +set -euo pipefail + +changed_paths_file="${1:-}" + +if [[ -z "$changed_paths_file" || ! -f "$changed_paths_file" ]]; then + printf 'Usage: %s \n' "$(basename "$0")" >&2 + exit 2 +fi + +macos_tests=false +macos_tests_deferred=false +macos_tests_reason="" +path_count=0 +draft_pull_request="${CI_PULL_REQUEST_DRAFT:-false}" + +case "$draft_pull_request" in + true|false) + ;; + *) + printf 'CI_PULL_REQUEST_DRAFT must be true or false.\n' >&2 + exit 2 + ;; +esac + +require_macos_tests() { + local path="$1" + local reason="$2" + + macos_tests=true + if [[ -z "$macos_tests_reason" ]]; then + macos_tests_reason="${path}: ${reason}" + fi +} + +classify_path() { + local path="$1" + [[ -z "$path" ]] && return + + path_count=$((path_count + 1)) + + case "$path" in + AGENTS.md|docs/configuration.md) + require_macos_tests "$path" "changes contributor or runtime configuration contracts" + ;; + *.md) + ;; + docs/.nojekyll|docs/CNAME|docs/index.html|docs/llms.txt|docs/site-locales.mjs|docs/site.css|docs/site.js|docs/social.html|docs/social.png) + ;; + docs/*.png|docs/*.jpg|docs/*.jpeg|docs/*.webp|docs/*.ico|docs/*.svg) + ;; + *) + require_macos_tests "$path" "not covered by portable docs/site checks" + ;; + esac +} + +invalid_row=false +while IFS=$'\t' read -r status first_path second_path extra_path \ + || [[ -n "${status:-}${first_path:-}${second_path:-}${extra_path:-}" ]] +do + [[ -z "${status}${first_path:-}${second_path:-}${extra_path:-}" ]] && continue + + case "$status" in + R*|C*) + if ! [[ "$status" =~ ^[RC][0-9]{1,3}$ ]] \ + || ((10#${status:1} > 100)) \ + || [[ -z "${first_path:-}" || -z "${second_path:-}" || -n "${extra_path:-}" ]] + then + invalid_row=true + break + fi + classify_path "$first_path" + classify_path "$second_path" + ;; + A|D|M|T|U|X|B) + if [[ -z "${first_path:-}" || -n "${second_path:-}" || -n "${extra_path:-}" ]]; then + invalid_row=true + break + fi + classify_path "$first_path" + ;; + *) + invalid_row=true + break + ;; + esac +done < "$changed_paths_file" + +if [[ "$invalid_row" == true ]]; then + printf 'Invalid git name-status row; refusing to skip macOS tests.\n' >&2 + exit 2 +fi + +if [[ "$path_count" -eq 0 ]]; then + require_macos_tests '' 'no changed paths were reported' +fi + +if [[ "$macos_tests" == true && "$draft_pull_request" == true ]]; then + macos_tests_deferred=true + summary_reason="draft pull request: macOS Swift tests deferred until ready for review" +elif [[ "$macos_tests" == true ]]; then + summary_reason="$macos_tests_reason" +else + summary_reason="docs/site-only changes covered by portable checks" +fi + +if [[ -n "${GITHUB_OUTPUT:-}" ]]; then + printf 'macos-tests=%s\n' "$macos_tests" >> "$GITHUB_OUTPUT" + printf 'macos-tests-deferred=%s\n' "$macos_tests_deferred" >> "$GITHUB_OUTPUT" + printf 'macos-tests-reason=%s\n' "$summary_reason" >> "$GITHUB_OUTPUT" + printf 'changed-path-count=%s\n' "$path_count" >> "$GITHUB_OUTPUT" +fi + +if [[ "$macos_tests_deferred" == true ]]; then + printf 'macOS Swift tests required but deferred until ready for review: %s.\n' "$macos_tests_reason" +elif [[ "$macos_tests" == true ]]; then + printf 'macOS Swift tests required for this change set: %s.\n' "$macos_tests_reason" +else + printf 'Skipping macOS Swift tests: %s.\n' "$summary_reason" +fi diff --git a/Scripts/ci_swift_test_by_suite.py b/Scripts/ci_swift_test_by_suite.py index 55e28ea070..4529d54ceb 100755 --- a/Scripts/ci_swift_test_by_suite.py +++ b/Scripts/ci_swift_test_by_suite.py @@ -9,7 +9,56 @@ import signal import subprocess import sys +import time from collections.abc import Iterable +from dataclasses import dataclass + + +@dataclass(frozen=True) +class TestSelection: + name: str + filter_pattern: str + suite_name: str | None = None + + +@dataclass +class RunStats: + discovered_selections: int = 0 + selected_selections: int = 0 + selected_groups: int = 0 + group_size: int = 0 + shard_index: int | None = None + shard_count: int | None = None + discovery_seconds: float = 0 + execution_seconds: float = 0 + total_seconds: float = 0 + first_pass_successful_groups: int = 0 + first_pass_failed_groups: int = 0 + full_group_retries: int = 0 + timed_out_groups: int = 0 + recovered_groups: int = 0 + isolated_selection_retries: int = 0 + + def summary_rows(self) -> list[tuple[str, str]]: + shard = "none" + if self.shard_index is not None and self.shard_count is not None: + shard = f"{self.shard_index + 1}/{self.shard_count}" + return [ + ("Shard", shard), + ("Group size", str(self.group_size)), + ("Discovered selections", str(self.discovered_selections)), + ("Selected selections", str(self.selected_selections)), + ("Selected groups", str(self.selected_groups)), + ("First-pass successful groups", str(self.first_pass_successful_groups)), + ("First-pass failed groups", str(self.first_pass_failed_groups)), + ("Full-group retries", str(self.full_group_retries)), + ("Recovered groups", str(self.recovered_groups)), + ("Timed out groups", str(self.timed_out_groups)), + ("Isolated selection retries", str(self.isolated_selection_retries)), + ("Discovery seconds", f"{self.discovery_seconds:.1f}"), + ("Execution seconds", f"{self.execution_seconds:.1f}"), + ("Total seconds", f"{self.total_seconds:.1f}"), + ] def parse_args() -> argparse.Namespace: @@ -17,7 +66,17 @@ def parse_args() -> argparse.Namespace: parser.add_argument("--group-size", type=int, default=12) parser.add_argument("--timeout", type=int, default=180) parser.add_argument("--limit-groups", type=int) + parser.add_argument("--shard-index", type=int) + parser.add_argument("--shard-count", type=int) + parser.add_argument( + "--no-retry-non-timeout-failures", + action="store_false", + dest="retry_non_timeout_failures", + help="fail immediately when a group exits without timing out", + ) parser.add_argument("--list-only", action="store_true") + parser.add_argument("--swift-command", default="swift") + parser.add_argument("--swift-command-arg", action="append", default=[]) return parser.parse_args() @@ -37,92 +96,245 @@ def run_command(command: list[str], timeout: int | None = None) -> int: return 124 -def swift_test_list() -> list[str]: - result = subprocess.run(["swift", "test", "list"], check=True, capture_output=True, text=True) - suites: set[str] = set() +def swift_test_list(swift_command: list[str]) -> list[TestSelection]: + command = [*swift_command, "test", "list"] + try: + result = subprocess.run(command, check=True, capture_output=True, text=True) + except subprocess.CalledProcessError as error: + print(f"+ {swift_command[0]} test list", flush=True) + if error.stdout: + print(error.stdout, end="" if error.stdout.endswith("\n") else "\n", flush=True) + if error.stderr: + print(error.stderr, end="" if error.stderr.endswith("\n") else "\n", file=sys.stderr, flush=True) + raise + selections: set[TestSelection] = set() + unknown: list[str] = [] for line in result.stdout.splitlines(): - if "/" not in line: - continue - suite = line.split("/", 1)[0] - if "." not in suite: + top_level = re.fullmatch(r"(?P[^.]+)\.(?:`(?P.+)`|(?P[^()/]+))\(\)", line) + if top_level is not None: + module = top_level.group("module") + test_name = top_level.group("display") or top_level.group("function") + selections.add( + TestSelection( + name=line, + # SwiftPM matches top-level Swift Testing functions by their display name, + # not the backtick-wrapped identifier printed by `swift test list`. + filter_pattern=rf"{re.escape(module)}\..*{re.escape(test_name)}", + ) + ) continue - suites.add(suite) - return sorted(suites) + if "/" in line: + suite = line.split("/", 1)[0] + if "." in suite: + selections.add( + TestSelection( + name=suite, + filter_pattern=rf"^{re.escape(suite)}/", + suite_name=suite, + ) + ) + continue + + unknown.append(line) + + if unknown: + rendered = "\n".join(f"- {line}" for line in unknown) + raise RuntimeError(f"Unrecognized `swift test list` output:\n{rendered}") + return sorted(selections, key=lambda selection: selection.name) -def chunks(items: list[str], size: int) -> Iterable[list[str]]: + +def append_github_summary(stats: RunStats) -> None: + summary_path = os.environ.get("GITHUB_STEP_SUMMARY") + if not summary_path: + return + + with open(summary_path, "a", encoding="utf-8") as summary: + summary.write("### macOS Swift test timing\n\n") + summary.write("| Field | Value |\n") + summary.write("| --- | --- |\n") + for field, value in stats.summary_rows(): + safe_value = value.replace("|", "\\|") + summary.write(f"| {field} | `{safe_value}` |\n") + summary.write("\n") + + +def print_timing_summary(stats: RunStats) -> None: + print("Swift test timing summary:", flush=True) + for field, value in stats.summary_rows(): + print(f"- {field}: {value}", flush=True) + + +def chunks(items: list[TestSelection], size: int) -> Iterable[list[TestSelection]]: for index in range(0, len(items), size): yield items[index : index + size] -def prioritized_suites(suites: list[str]) -> list[str]: +def shard_groups(groups: list[list[TestSelection]], shard_index: int | None, shard_count: int | None) -> list[list[TestSelection]]: + if shard_index is None and shard_count is None: + return groups + if shard_index is None or shard_count is None: + raise ValueError("--shard-index and --shard-count must be passed together") + if shard_count < 1: + raise ValueError("--shard-count must be positive") + if shard_index < 0 or shard_index >= shard_count: + raise ValueError("--shard-index must be in the range [0, --shard-count)") + return [group for index, group in enumerate(groups) if index % shard_count == shard_index] + + +def prioritized_suites(suites: list[TestSelection]) -> list[TestSelection]: priority = ["CodexBarTests.CLIEntryTests"] - ordered = [suite for suite in priority if suite in suites] - ordered.extend(suite for suite in suites if suite not in priority) + ordered = [suite for name in priority for suite in suites if suite.suite_name == name] + ordered.extend(suite for suite in suites if suite.suite_name not in priority) return ordered -def filtered_suites_for_environment(suites: list[str]) -> list[str]: +def filtered_suites_for_environment(suites: list[TestSelection]) -> list[TestSelection]: if os.environ.get("GITHUB_ACTIONS") != "true" or sys.platform != "darwin": return suites # SwiftPM hangs before suite output for this executable-target suite on the Intel macOS runner. # Linux CI still runs it in the full Swift test lane, and local macOS runs it directly. skipped = {"CodexBarTests.CLIEntryTests"} - filtered = [suite for suite in suites if suite not in skipped] + filtered = [suite for suite in suites if suite.suite_name not in skipped] if len(filtered) != len(suites): print(f"Skipping macOS CI-only suites: {', '.join(sorted(skipped))}", flush=True) return filtered -def filter_for(suites: list[str]) -> str: - escaped = [re.escape(suite) for suite in suites] - return rf"^({'|'.join(escaped)})/" +def filter_for(suites: list[TestSelection]) -> str: + return rf"({'|'.join(suite.filter_pattern for suite in suites)})" -def run_group(suites: list[str], timeout: int) -> int: - return run_command(["swift", "test", "--no-parallel", "--filter", filter_for(suites)], timeout=timeout) +def run_group(suites: list[TestSelection], timeout: int, swift_command: list[str]) -> int: + return run_command( + [*swift_command, "test", "--skip-build", "--no-parallel", "--filter", filter_for(suites)], + timeout=timeout, + ) + + +def retry_selections_individually( + suites: list[TestSelection], + timeout: int, + swift_command: list[str], + stats: RunStats, +) -> int: + for suite in suites: + stats.isolated_selection_retries += 1 + print(f"::group::Swift test retry {suite.name}", flush=True) + retry_result = run_group([suite], timeout, swift_command) + print("::endgroup::", flush=True) + if retry_result != 0: + return retry_result + return 0 def main() -> int: + total_started = time.monotonic() args = parse_args() + stats = RunStats( + group_size=args.group_size, + shard_index=args.shard_index, + shard_count=args.shard_count, + ) if args.group_size < 1: print("--group-size must be positive", file=sys.stderr) return 2 - suites = prioritized_suites(filtered_suites_for_environment(swift_test_list())) - print(f"Discovered {len(suites)} test suites", flush=True) - if args.list_only: - for suite in suites: - print(suite) - return 0 + swift_command = [args.swift_command, *args.swift_command_arg] + result = 0 + try: + discovery_started = time.monotonic() + try: + suites = prioritized_suites(filtered_suites_for_environment(swift_test_list(swift_command))) + finally: + stats.discovery_seconds = time.monotonic() - discovery_started + stats.discovered_selections = len(suites) - suite_groups = list(chunks(suites, args.group_size)) - if args.limit_groups is not None: - suite_groups = suite_groups[: args.limit_groups] + suite_groups = list(chunks(suites, args.group_size)) + try: + suite_groups = shard_groups(suite_groups, args.shard_index, args.shard_count) + except ValueError as error: + print(str(error), file=sys.stderr) + result = 2 + return result + if args.limit_groups is not None: + suite_groups = suite_groups[: args.limit_groups] + stats.selected_selections = sum(len(group) for group in suite_groups) + stats.selected_groups = len(suite_groups) - for group_index, group in enumerate(suite_groups, start=1): + shard_suffix = "" + if args.shard_index is not None and args.shard_count is not None: + shard_suffix = f" in shard {args.shard_index + 1}/{args.shard_count}" print( - f"::group::Swift test shard {group_index}/{len(suite_groups)} " - f"({len(group)} suites)", + f"Discovered {len(suites)} test selections; running {stats.selected_selections} selections " + f"in {len(suite_groups)} groups{shard_suffix}", flush=True, ) - result = run_group(group, args.timeout) - print("::endgroup::", flush=True) - if result == 0: - continue - if result != 124 or len(group) == 1: - return result + if args.list_only: + for group in suite_groups: + for suite in group: + print(suite.name) + return 0 - print(f"Shard {group_index} timed out; retrying suites one at a time", flush=True) - for suite in group: - print(f"::group::Swift test retry {suite}", flush=True) - retry_result = run_group([suite], args.timeout) + if not suite_groups: + print("No test groups selected.", flush=True) + return 0 + + execution_started = time.monotonic() + for group_index, group in enumerate(suite_groups, start=1): + print( + f"::group::Swift test group {group_index}/{len(suite_groups)} " + f"({len(group)} selections)", + flush=True, + ) + group_result = run_group(group, args.timeout, swift_command) print("::endgroup::", flush=True) + if group_result == 0: + stats.first_pass_successful_groups += 1 + continue + + stats.first_pass_failed_groups += 1 + group_timed_out = group_result == 124 + if group_timed_out: + stats.timed_out_groups += 1 + if len(group) == 1: + result = group_result + return result + + if group_result != 124: + if not args.retry_non_timeout_failures: + result = group_result + return result + + stats.full_group_retries += 1 + print(f"Group {group_index} failed with exit code {group_result}; retrying group once", flush=True) + retry_result = run_group(group, args.timeout, swift_command) + if retry_result == 0: + stats.recovered_groups += 1 + continue + if retry_result != 124: + result = retry_result + return result + group_timed_out = True + stats.timed_out_groups += 1 + + print(f"Group {group_index} timed out; retrying selections one at a time", flush=True) + retry_result = retry_selections_individually(group, args.timeout, swift_command, stats) if retry_result != 0: - return retry_result + result = retry_result + return result + if group_timed_out: + stats.recovered_groups += 1 - return 0 + return result + finally: + stats.total_seconds = time.monotonic() - total_started + if "execution_started" in locals(): + stats.execution_seconds = time.monotonic() - execution_started + if not args.list_only: + print_timing_summary(stats) + append_github_summary(stats) if __name__ == "__main__": diff --git a/Scripts/ci_verify_test_jobs.sh b/Scripts/ci_verify_test_jobs.sh new file mode 100755 index 0000000000..e49e57398c --- /dev/null +++ b/Scripts/ci_verify_test_jobs.sh @@ -0,0 +1,54 @@ +#!/usr/bin/env bash + +set -euo pipefail + +lint_result="${1:-}" +changes_result="${2:-}" +macos_tests_required="${3:-}" +macos_test_result="${4:-}" +macos_tests_deferred="${5:-}" +linux_musl_build_required="${6:-}" +linux_musl_build_result="${7:-}" + +if [[ "$lint_result" != "success" ]]; then + printf 'lint job finished with %s\n' "${lint_result:-}" >&2 + exit 1 +fi + +if [[ "$changes_result" != "success" ]]; then + printf 'changes job finished with %s\n' "${changes_result:-}" >&2 + exit 1 +fi + +case "${macos_tests_required}:${macos_tests_deferred}:${macos_test_result}" in + true:false:success) + printf 'Lint and macOS Swift test shards passed.\n' + ;; + false:false:skipped) + printf 'Lint passed; macOS Swift tests skipped by the macOS test gate.\n' + ;; + true:true:skipped) + printf 'macOS Swift tests are required but deferred; aggregate CI remains incomplete\n' >&2 + exit 1 + ;; + *) + printf 'macOS test gate/result mismatch: required=%s deferred=%s result=%s\n' \ + "${macos_tests_required:-}" "${macos_tests_deferred:-}" \ + "${macos_test_result:-}" >&2 + exit 1 + ;; +esac + +case "${linux_musl_build_required}:${linux_musl_build_result}" in + true:success) + printf 'Linux musl CLI build passed.\n' + ;; + false:skipped) + printf 'Linux musl CLI build skipped by its path gate.\n' + ;; + *) + printf 'Linux musl build gate/result mismatch: required=%s result=%s\n' \ + "${linux_musl_build_required:-}" "${linux_musl_build_result:-}" >&2 + exit 1 + ;; +esac diff --git a/Scripts/compile_and_run.sh b/Scripts/compile_and_run.sh index d0ecc40787..a0a024de61 100755 --- a/Scripts/compile_and_run.sh +++ b/Scripts/compile_and_run.sh @@ -284,7 +284,7 @@ fi # 3) Package (release build happens inside package_app.sh). if [[ "${RUN_TESTS}" == "1" ]]; then - run_step "swift test" swift test -q + run_step "sharded swift tests" "${ROOT_DIR}/Scripts/test.sh" fi if [[ "${DEBUG_LLDB}" == "1" && -n "${RELEASE_ARCHES}" ]]; then fail "--release-arches is only supported for release packaging" diff --git a/Scripts/generate-llms.mjs b/Scripts/generate-llms.mjs index 16390124f8..947a0d99b3 100755 --- a/Scripts/generate-llms.mjs +++ b/Scripts/generate-llms.mjs @@ -5,11 +5,13 @@ import { fileURLToPath } from "node:url"; const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); const docsDir = path.join(repoRoot, "docs"); +const args = process.argv.slice(2); +const mode = parseMode(args); const cname = fs.readFileSync(path.join(docsDir, "CNAME"), "utf8").trim(); const origin = "https://" + cname; const productName = "CodexBar"; -const productDescription = "CodexBar shows OpenAI Codex and Claude Code usage limits in the macOS menu bar."; const source = "https://github.com/steipete/CodexBar"; +const outputPath = path.join(docsDir, "llms.txt"); const pages = allHtml(docsDir) .map((file) => { @@ -24,6 +26,9 @@ const pages = allHtml(docsDir) }) .filter(Boolean) .sort((a, b) => (a.rel === "index.html" ? -1 : b.rel === "index.html" ? 1 : a.rel.localeCompare(b.rel))); +const productDescription = + pages.find((page) => page.rel === "index.html")?.description || + "CodexBar shows AI coding-provider usage limits in the macOS menu bar."; const lines = [ "# " + productName, @@ -40,9 +45,27 @@ const lines = [ "- Fetch only the pages needed for the current task; this is an index, not a full-site corpus.", "", ]; +const output = lines.join("\n"); -fs.writeFileSync(path.join(docsDir, "llms.txt"), lines.join("\n"), "utf8"); -console.log("wrote " + path.relative(repoRoot, path.join(docsDir, "llms.txt"))); +if (mode === "check") { + const current = fs.existsSync(outputPath) ? fs.readFileSync(outputPath, "utf8") : null; + if (current !== output) { + console.error(`${path.relative(repoRoot, outputPath)} is out of date; run node Scripts/generate-llms.mjs`); + process.exit(1); + } + console.log("llms index OK: " + path.relative(repoRoot, outputPath)); +} else { + fs.writeFileSync(outputPath, output, "utf8"); + console.log("wrote " + path.relative(repoRoot, outputPath)); +} + +function parseMode(values) { + if (values.length === 0) return "write"; + if (values.length === 1 && (values[0] === "write" || values[0] === "--write")) return "write"; + if (values.length === 1 && (values[0] === "check" || values[0] === "--check")) return "check"; + console.error("Usage: node Scripts/generate-llms.mjs [write|--write|check|--check]"); + process.exit(2); +} function allHtml(dir) { return fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { diff --git a/Scripts/install_lint_tools.sh b/Scripts/install_lint_tools.sh index f27779cf75..99f52c902b 100755 --- a/Scripts/install_lint_tools.sh +++ b/Scripts/install_lint_tools.sh @@ -6,15 +6,45 @@ ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" TOOLS_DIR="${ROOT_DIR}/.build/lint-tools" BIN_DIR="${TOOLS_DIR}/bin" -SWIFTFORMAT_VERSION="0.59.1" -SWIFTLINT_VERSION="0.63.2" +SWIFTFORMAT_VERSION="0.61.1" +SWIFTLINT_VERSION="0.65.0" -SWIFTFORMAT_SHA256_DARWIN="8b6289b608a44e73cd3851c3589dbd7c553f32cc805aa54b3a496ce2b90febe7" -SWIFTLINT_SHA256_DARWIN="c59a405c85f95b92ced677a500804e081596a4cae4a6a485af76065557d6ed29" +SWIFTFORMAT_SHA256_DARWIN="b990400779aceb7d7020796eb9ba814d4480543f671d38fc0ff48cb72f04c584" +SWIFTLINT_SHA256_DARWIN="d6cb0aa7a2f5f1ef306fc9e37bcb54dc9a26facc8f7784ac0c3dd3eccf5c6ba6" +SWIFTFORMAT_SHA256_LINUX_X86_64="7bc8706e3fd51963f1f29eb99098ebdf482f3497fa527c68e6cf75cbee29c77a" +SWIFTLINT_SHA256_LINUX_X86_64="79306a34e5c7cc55a220cd108cbb861dcad5f10138dcdf261e2624ae8b0a486b" +SWIFTFORMAT_SHA256_LINUX_ARM64="42a35b557a6d56975fba3a48e78d39ab5388c8faac65d4819f25d3e20c7504c0" +SWIFTLINT_SHA256_LINUX_ARM64="12d3b84bc5b69ae13a99a5a5c79904f9ce25867f099f6368d0037854f9ee6c26" log() { printf '%s\n' "$*"; } fail() { printf 'ERROR: %s\n' "$*" >&2; exit 1; } +INSTALL_SWIFTFORMAT=false +INSTALL_SWIFTLINT=false + +if [[ "$#" -eq 0 ]]; then + INSTALL_SWIFTFORMAT=true + INSTALL_SWIFTLINT=true +else + for tool in "$@"; do + case "$tool" in + all) + INSTALL_SWIFTFORMAT=true + INSTALL_SWIFTLINT=true + ;; + swiftformat) + INSTALL_SWIFTFORMAT=true + ;; + swiftlint) + INSTALL_SWIFTLINT=true + ;; + *) + fail "Unknown lint tool '${tool}'. Usage: $(basename "$0") [all|swiftformat|swiftlint]..." + ;; + esac + done +fi + sha256_value() { local path="$1" if command -v shasum >/dev/null 2>&1; then @@ -39,6 +69,7 @@ install_zip_binary() { local url="$2" local expected_sha="$3" local binary_name="$4" + local installed_name="${5:-$binary_name}" local tmp_zip tmp_zip="$(mktemp -t "${label}.XXXX")" @@ -71,7 +102,7 @@ install_zip_binary() { fail "${label} binary '${binary_name}' not found in archive" fi - install -m 0755 "$extracted_path" "${BIN_DIR}/${binary_name}" + install -m 0755 "$extracted_path" "${BIN_DIR}/${installed_name}" rm -f "$tmp_zip" rm -rf "$tmp_dir" @@ -79,13 +110,21 @@ install_zip_binary() { mkdir -p "$BIN_DIR" -if [[ -x "${BIN_DIR}/swiftformat" && -x "${BIN_DIR}/swiftlint" ]]; then - if [[ "$("${BIN_DIR}/swiftformat" --version 2>/dev/null || true)" == "${SWIFTFORMAT_VERSION}" ]] \ +swiftformat_installed() { + [[ -x "${BIN_DIR}/swiftformat" ]] \ + && [[ "$("${BIN_DIR}/swiftformat" --version 2>/dev/null || true)" == "${SWIFTFORMAT_VERSION}" ]] +} + +swiftlint_installed() { + [[ -x "${BIN_DIR}/swiftlint" ]] \ && [[ "$("${BIN_DIR}/swiftlint" version 2>/dev/null || true)" == "${SWIFTLINT_VERSION}" ]] - then - log "==> Lint tools already installed (${SWIFTFORMAT_VERSION}, ${SWIFTLINT_VERSION})" - exit 0 - fi +} + +if { [[ "$INSTALL_SWIFTFORMAT" != true ]] || swiftformat_installed; } \ + && { [[ "$INSTALL_SWIFTLINT" != true ]] || swiftlint_installed; } +then + log "==> Requested lint tools already installed" + exit 0 fi OS="$(uname -s)" @@ -96,29 +135,45 @@ case "$OS" in SWIFTFORMAT_URL="https://github.com/nicklockwood/SwiftFormat/releases/download/${SWIFTFORMAT_VERSION}/swiftformat.zip" SWIFTLINT_URL="https://github.com/realm/SwiftLint/releases/download/${SWIFTLINT_VERSION}/portable_swiftlint.zip" - install_zip_binary "SwiftFormat ${SWIFTFORMAT_VERSION}" "$SWIFTFORMAT_URL" "$SWIFTFORMAT_SHA256_DARWIN" "swiftformat" - install_zip_binary "SwiftLint ${SWIFTLINT_VERSION}" "$SWIFTLINT_URL" "$SWIFTLINT_SHA256_DARWIN" "swiftlint" + if [[ "$INSTALL_SWIFTFORMAT" == true ]] && ! swiftformat_installed; then + install_zip_binary "SwiftFormat ${SWIFTFORMAT_VERSION}" "$SWIFTFORMAT_URL" "$SWIFTFORMAT_SHA256_DARWIN" "swiftformat" + fi + if [[ "$INSTALL_SWIFTLINT" == true ]] && ! swiftlint_installed; then + install_zip_binary "SwiftLint ${SWIFTLINT_VERSION}" "$SWIFTLINT_URL" "$SWIFTLINT_SHA256_DARWIN" "swiftlint" + fi ;; Linux) case "$ARCH" in x86_64) SWIFTFORMAT_URL="https://github.com/nicklockwood/SwiftFormat/releases/download/${SWIFTFORMAT_VERSION}/swiftformat_linux.zip" SWIFTLINT_URL="https://github.com/realm/SwiftLint/releases/download/${SWIFTLINT_VERSION}/swiftlint_linux_amd64.zip" + SWIFTFORMAT_BINARY="swiftformat_linux" + SWIFTFORMAT_SHA256="$SWIFTFORMAT_SHA256_LINUX_X86_64" + SWIFTLINT_SHA256="$SWIFTLINT_SHA256_LINUX_X86_64" ;; aarch64|arm64) SWIFTFORMAT_URL="https://github.com/nicklockwood/SwiftFormat/releases/download/${SWIFTFORMAT_VERSION}/swiftformat_linux_aarch64.zip" SWIFTLINT_URL="https://github.com/realm/SwiftLint/releases/download/${SWIFTLINT_VERSION}/swiftlint_linux_arm64.zip" + SWIFTFORMAT_BINARY="swiftformat_linux_aarch64" + SWIFTFORMAT_SHA256="$SWIFTFORMAT_SHA256_LINUX_ARM64" + SWIFTLINT_SHA256="$SWIFTLINT_SHA256_LINUX_ARM64" ;; *) fail "Unsupported Linux arch: ${ARCH}" ;; esac - # SHA256 is intentionally only enforced for the macOS CI path. - # If we later run lint on Linux CI, add pinned SHAs here as well. - log "WARN: Linux SHA256 verification not configured for ${ARCH}; installing anyway." - install_zip_binary "SwiftFormat ${SWIFTFORMAT_VERSION}" "$SWIFTFORMAT_URL" "" "swiftformat" - install_zip_binary "SwiftLint ${SWIFTLINT_VERSION}" "$SWIFTLINT_URL" "" "swiftlint" + if { [[ "$INSTALL_SWIFTFORMAT" == true ]] && [[ -z "$SWIFTFORMAT_SHA256" ]]; } \ + || { [[ "$INSTALL_SWIFTLINT" == true ]] && [[ -z "$SWIFTLINT_SHA256" ]]; } + then + log "WARN: Linux SHA256 verification not configured for ${ARCH}; installing anyway." + fi + if [[ "$INSTALL_SWIFTFORMAT" == true ]] && ! swiftformat_installed; then + install_zip_binary "SwiftFormat ${SWIFTFORMAT_VERSION}" "$SWIFTFORMAT_URL" "$SWIFTFORMAT_SHA256" "$SWIFTFORMAT_BINARY" "swiftformat" + fi + if [[ "$INSTALL_SWIFTLINT" == true ]] && ! swiftlint_installed; then + install_zip_binary "SwiftLint ${SWIFTLINT_VERSION}" "$SWIFTLINT_URL" "$SWIFTLINT_SHA256" "swiftlint" + fi ;; *) fail "Unsupported OS: ${OS}" @@ -126,5 +181,9 @@ case "$OS" in esac log "==> Installed lint tools to ${BIN_DIR}" -"${BIN_DIR}/swiftformat" --version -"${BIN_DIR}/swiftlint" version +if [[ "$INSTALL_SWIFTFORMAT" == true ]]; then + "${BIN_DIR}/swiftformat" --version +fi +if [[ "$INSTALL_SWIFTLINT" == true ]]; then + "${BIN_DIR}/swiftlint" version +fi diff --git a/Scripts/lint.sh b/Scripts/lint.sh index e6022264e9..7db993e045 100755 --- a/Scripts/lint.sh +++ b/Scripts/lint.sh @@ -5,31 +5,134 @@ set -euo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" BIN_DIR="${ROOT_DIR}/.build/lint-tools/bin" -ensure_tools() { - # Always delegate to the installer so pinned versions are enforced. - # The installer is idempotent and exits early when the expected versions are already present. - "${ROOT_DIR}/Scripts/install_lint_tools.sh" +ensure_swiftformat() { + "${ROOT_DIR}/Scripts/install_lint_tools.sh" swiftformat +} + +ensure_swiftlint() { + "${ROOT_DIR}/Scripts/install_lint_tools.sh" swiftlint } check_codex_parser_hash() { "${ROOT_DIR}/Scripts/regenerate-codex-parser-hash.sh" --check } +check_package_product_paths() { + "${ROOT_DIR}/Scripts/test_package_product_paths.sh" +} + +check_package_strip() { + "${ROOT_DIR}/Scripts/test_package_strip.sh" +} + +check_package_signing() { + "${ROOT_DIR}/Scripts/test_package_signing.sh" +} + +check_package_info_plist() { + "${ROOT_DIR}/Scripts/test_package_info_plist.sh" +} + +check_release_dsym_paths() { + "${ROOT_DIR}/Scripts/test_release_dsym_paths.sh" +} + +check_sparkle_signing_paths() { + "${ROOT_DIR}/Scripts/test_sparkle_signing_paths.sh" +} + +check_swift_test_sharding() { + "${ROOT_DIR}/Scripts/test_swift_test_sharding.sh" +} + +check_ci_path_gate() { + "${ROOT_DIR}/Scripts/test_ci_path_gate.sh" +} + +check_repository_size() { + "${ROOT_DIR}/Scripts/check_repository_size.sh" + "${ROOT_DIR}/Scripts/test_repository_size.sh" +} + +check_shell_scripts() { + local count=0 + local script + for script in "${ROOT_DIR}"/Scripts/*.sh "${ROOT_DIR}"/Scripts/mac-release; do + [[ -f "$script" ]] || continue + bash -n "$script" + count=$((count + 1)) + done + printf 'shell scripts OK: %d files\n' "$count" +} + +check_app_locales() { + node "${ROOT_DIR}/Scripts/check-app-locales.mjs" --test + node "${ROOT_DIR}/Scripts/check-app-locales.mjs" +} + +check_site_locales() { + node "${ROOT_DIR}/Scripts/check-site-locales.mjs" + node --check "${ROOT_DIR}/docs/site.js" +} + +check_documentation_links() { + node "${ROOT_DIR}/Scripts/check-documentation-links.mjs" +} + +check_llms_index() { + node "${ROOT_DIR}/Scripts/generate-llms.mjs" --check +} + +run_portable_checks() { + check_codex_parser_hash + check_package_product_paths + check_package_strip + check_package_signing + check_package_info_plist + check_release_dsym_paths + check_sparkle_signing_paths + check_swift_test_sharding + check_ci_path_gate + check_repository_size + check_shell_scripts + check_documentation_links + check_llms_index + check_site_locales +} + +run_swiftformat_lint() { + ensure_swiftformat + "${BIN_DIR}/swiftformat" Sources Tests --lint +} + +run_swiftlint() { + ensure_swiftlint + "${BIN_DIR}/swiftlint" --strict +} + cmd="${1:-lint}" case "$cmd" in lint) - check_codex_parser_hash - ensure_tools - "${BIN_DIR}/swiftformat" Sources Tests --lint - "${BIN_DIR}/swiftlint" --strict + check_app_locales + run_portable_checks + run_swiftformat_lint + run_swiftlint + ;; + lint-linux) + run_portable_checks + run_swiftlint + ;; + lint-macos) + check_app_locales + run_swiftformat_lint ;; format) - ensure_tools + ensure_swiftformat "${BIN_DIR}/swiftformat" Sources Tests ;; *) - printf 'Usage: %s [lint|format]\n' "$(basename "$0")" >&2 + printf 'Usage: %s [lint|lint-linux|lint-macos|format]\n' "$(basename "$0")" >&2 exit 2 ;; esac diff --git a/Scripts/mimo-usage.py b/Scripts/mimo-usage.py new file mode 100755 index 0000000000..067bc72aa1 --- /dev/null +++ b/Scripts/mimo-usage.py @@ -0,0 +1,241 @@ +#!/usr/bin/env python3 +""" +mimo-usage — local token usage tracker for cc-mimo + +Scans ~/.claude-envs/mimo/.claude/projects/**/*.jsonl session files, +sums input/output/cache tokens per time window (today/week/all), +writes to ~/.codexbar/mimo-local-usage.json, and prints a human-readable +summary by default. + +Usage: + mimo-usage # show summary (also refreshes cache) + mimo-usage --update # refresh cache only, no output (for LaunchAgent/wrapper) + mimo-usage --json # JSON output + mimo-usage --short # 1-line status (for status line / widget) +""" +import json +import os +import sys +from pathlib import Path +from datetime import datetime, timedelta, timezone + +MIMO_HOME = Path(os.environ.get("MIMO_CLAUDE_HOME", Path.home() / ".claude-envs" / "mimo")).expanduser() +PROJECTS_DIR = MIMO_HOME / ".claude" / "projects" +CACHE_PATH = Path( + os.environ.get("MIMO_LOCAL_USAGE_PATH", Path.home() / ".codexbar" / "mimo-local-usage.json") +).expanduser() + + +def parse_session_usage(jsonl_path: Path): + """Yield (identity, timestamp_iso, usage_dict) for each assistant message with usage.""" + try: + with jsonl_path.open() as f: + for line in f: + try: + d = json.loads(line) + ts = d.get("timestamp") + msg = d.get("message") + if not isinstance(msg, dict): + continue + usage = msg.get("usage") + if not isinstance(usage, dict): + continue + if not ts: + continue + metadata = d.get("metadata") + message_metadata = msg.get("metadata") + session_id = d.get("sessionId") or d.get("session_id") + if not session_id and isinstance(metadata, dict): + session_id = metadata.get("sessionId") + if not session_id and isinstance(message_metadata, dict): + session_id = message_metadata.get("sessionId") + message_id = msg.get("id") + request_id = d.get("requestId") or d.get("request_id") + identity = None + if all(isinstance(value, str) and value for value in (message_id, request_id)): + identity = ("request", message_id, request_id) + elif ( + request_id is None + and isinstance(session_id, str) + and session_id + and isinstance(message_id, str) + and message_id + ): + identity = ("legacy", session_id, message_id) + yield identity, ts, usage + except (json.JSONDecodeError, ValueError): + continue + except (OSError, IOError): + return + + +def aggregate_usage(): + """Scan all mimo session jsonls and return windowed token sums.""" + now = datetime.now(timezone.utc) + today_start = now.replace(hour=0, minute=0, second=0, microsecond=0) + # Week starts on Monday 00:00 UTC + week_start = today_start - timedelta(days=today_start.weekday()) + + windows = { + "today": {"input": 0, "output": 0, "cache_read": 0, "cache_create": 0, "messages": 0}, + "week": {"input": 0, "output": 0, "cache_read": 0, "cache_create": 0, "messages": 0}, + "all_time": {"input": 0, "output": 0, "cache_read": 0, "cache_create": 0, "messages": 0}, + } + sessions_scanned = 0 + last_activity = None + keyed_rows = {} + unkeyed_rows = [] + + if not PROJECTS_DIR.exists(): + return windows, sessions_scanned, last_activity + + for jsonl in PROJECTS_DIR.rglob("*.jsonl"): + sessions_scanned += 1 + for identity, ts_str, usage in parse_session_usage(jsonl): + try: + # Parse ISO timestamp (may end with Z) + ts = datetime.fromisoformat(ts_str.replace("Z", "+00:00")) + if ts.tzinfo is None: + ts = ts.replace(tzinfo=timezone.utc) + except (ValueError, TypeError): + continue + + row = (ts, usage) + if identity is None: + unkeyed_rows.append(row) + else: + previous = keyed_rows.get(identity) + if previous is None or ts >= previous[0]: + keyed_rows[identity] = row + + for ts, usage in [*keyed_rows.values(), *unkeyed_rows]: + input_t = int(usage.get("input_tokens", 0) or 0) + output_t = int(usage.get("output_tokens", 0) or 0) + cache_read_t = int(usage.get("cache_read_input_tokens", 0) or 0) + cache_create_t = int(usage.get("cache_creation_input_tokens", 0) or 0) + + if last_activity is None or ts > last_activity: + last_activity = ts + + # all_time + w = windows["all_time"] + w["input"] += input_t + w["output"] += output_t + w["cache_read"] += cache_read_t + w["cache_create"] += cache_create_t + w["messages"] += 1 + + if ts >= week_start: + w = windows["week"] + w["input"] += input_t + w["output"] += output_t + w["cache_read"] += cache_read_t + w["cache_create"] += cache_create_t + w["messages"] += 1 + + if ts >= today_start: + w = windows["today"] + w["input"] += input_t + w["output"] += output_t + w["cache_read"] += cache_read_t + w["cache_create"] += cache_create_t + w["messages"] += 1 + + return windows, sessions_scanned, last_activity + + +def write_cache(windows, sessions_scanned, last_activity): + CACHE_PATH.parent.mkdir(parents=True, exist_ok=True) + payload = { + "updated_at": datetime.now(timezone.utc).isoformat(), + "last_activity": last_activity.isoformat() if last_activity else None, + "sessions_scanned": sessions_scanned, + "windows": windows, + "source": "local-jsonl-scan", + "note": "Local token accounting from cc-mimo session jsonl. Not a quota; mimo platform.xiaomimimo.com SSO cookie required for real quota.", + } + tmp = CACHE_PATH.with_suffix(".json.tmp") + tmp.write_text(json.dumps(payload, indent=2)) + tmp.replace(CACHE_PATH) + return payload + + +def fmt_tokens(n: int) -> str: + if n >= 1_000_000: + return f"{n / 1_000_000:.1f}M" + if n >= 1_000: + return f"{n / 1_000:.1f}k" + return str(n) + + +def short_status(payload): + """1-line status line.""" + w = payload["windows"]["week"] + total = w["input"] + w["output"] + w["cache_read"] + w["cache_create"] + return f"mimo: {fmt_tokens(total)} tok this week ({w['messages']} msg)" + + +def human_summary(payload): + """Multi-line human-readable summary.""" + last = payload.get("last_activity") + if last: + try: + last_dt = datetime.fromisoformat(last) + ago = datetime.now(timezone.utc) - last_dt + if ago.total_seconds() < 60: + ago_str = "just now" + elif ago.total_seconds() < 3600: + ago_str = f"{int(ago.total_seconds() / 60)}m ago" + elif ago.total_seconds() < 86400: + ago_str = f"{int(ago.total_seconds() / 3600)}h ago" + else: + ago_str = f"{ago.days}d ago" + except (ValueError, TypeError): + ago_str = last + else: + ago_str = "never" + + lines = [ + "== MiMo (local tracker) ==", + f"Sessions scanned: {payload['sessions_scanned']}", + f"Last activity: {ago_str}", + "", + ] + for window_name, label in [("today", "Today"), ("week", "This week"), ("all_time", "All time")]: + w = payload["windows"][window_name] + in_t = fmt_tokens(w["input"]) + out_t = fmt_tokens(w["output"]) + cr_t = fmt_tokens(w["cache_read"]) + cc_t = fmt_tokens(w["cache_create"]) + total = w["input"] + w["output"] + w["cache_read"] + w["cache_create"] + lines.append(f"{label:>10}: {fmt_tokens(total):>8} total | in={in_t} out={out_t} cache_r={cr_t} cache_c={cc_t} | msg={w['messages']}") + lines.append("") + lines.append("Note: this is local accounting from cc-mimo session jsonl.") + lines.append("Real platform quota requires Chrome cookie (cookieSource=manual).") + return "\n".join(lines) + + +def main(): + args = sys.argv[1:] + quiet = "--update" in args + json_out = "--json" in args + short = "--short" in args + + windows, sessions_scanned, last_activity = aggregate_usage() + payload = write_cache(windows, sessions_scanned, last_activity) + + if quiet: + return 0 + if json_out: + print(json.dumps(payload, indent=2)) + return 0 + if short: + print(short_status(payload)) + return 0 + + print(human_summary(payload)) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/Scripts/package_app.sh b/Scripts/package_app.sh index 1e13d6cb73..379721bbc9 100755 --- a/Scripts/package_app.sh +++ b/Scripts/package_app.sh @@ -1,13 +1,56 @@ #!/usr/bin/env bash set -euo pipefail + +resolve_package_signing_mode() { + local requested="${CODEXBAR_SIGNING:-adhoc}" + case "$requested" in + adhoc|identity) ;; + *) + echo "ERROR: Unsupported CODEXBAR_SIGNING: $requested (expected adhoc or identity)" >&2 + return 1 + ;; + esac + SIGNING_MODE="$requested" +} + +verify_no_quarantine_attribute() { + local bundle="$1" + local quarantined + quarantined="$(xattr -r -p com.apple.quarantine "$bundle" 2>/dev/null || true)" + if [[ -n "$quarantined" ]]; then + echo "ERROR: Packaged app still has com.apple.quarantine: ${bundle}" >&2 + return 1 + fi +} + +verify_packaged_app_integrity() { + local bundle="$1" + local sparkle="$bundle/Contents/Frameworks/Sparkle.framework" + + verify_no_quarantine_attribute "$bundle" || return 1 + codesign --verify --deep --strict --verbose=2 "$sparkle" || return 1 + codesign --verify --deep --strict --verbose=2 "$bundle" || return 1 +} + CONF=${1:-release} ALLOW_LLDB=${CODEXBAR_ALLOW_LLDB:-0} -SIGNING_MODE=${CODEXBAR_SIGNING:-} +SIGNING_MODE= +resolve_package_signing_mode ROOT=$(cd "$(dirname "$0")/.." && pwd) cd "$ROOT" +LOWER_CONF=$(printf "%s" "$CONF" | tr '[:upper:]' '[:lower:]') +case "$LOWER_CONF" in + debug|release) ;; + *) + echo "ERROR: Unsupported build configuration: $CONF (expected debug or release)" >&2 + exit 1 + ;; +esac # Load version info source "$ROOT/version.env" +source "$ROOT/Scripts/package_product_paths.sh" +source "$ROOT/Scripts/sparkle_signing_paths.sh" # Clean build only when explicitly requested (slower). if [[ "${CODEXBAR_FORCE_CLEAN:-0}" == "1" ]]; then @@ -104,12 +147,65 @@ if [[ ! -f "$KEYBOARD_SHORTCUTS_UTIL" ]]; then fi patch_keyboard_shortcuts +# Resolve SwiftPM's current output path without relying on a fixed build-system layout. +# The output variable keeps the per-arch cache in this shell instead of losing it to +# command substitution. +swiftpm_bin_path() { + local arch="$1" + local output_var="$2" + local cache_var="SWIFTPM_BIN_PATH_${arch//[^A-Za-z0-9]/_}" + if [[ -z "${!cache_var+set}" ]]; then + local resolved + if ! resolved=$(codexbar_swiftpm_bin_path "$CONF" "$arch"); then + return 1 + fi + printf -v "$cache_var" '%s' "$resolved" + fi + printf -v "$output_var" '%s' "${!cache_var}" +} + +binary_has_arch() { + local binary="$1" + local arch="$2" + [[ -f "$binary" ]] && lipo -archs "$binary" 2>/dev/null | tr ' ' '\n' | grep -qx "$arch" +} + +# SwiftBuild can reuse one output directory for sequential per-arch builds. Snapshot +# each fresh slice before the next build can replace it. +PRODUCT_STAGE_ROOT="$ROOT/.build/package-products/$LOWER_CONF" +rm -rf "$PRODUCT_STAGE_ROOT" + +stage_build_products() { + local arch="$1" + local bin_dir stage_dir name product + swiftpm_bin_path "$arch" bin_dir + + stage_dir="$PRODUCT_STAGE_ROOT/$arch" + mkdir -p "$stage_dir" + for name in CodexBar CodexBarCLI CodexBarClaudeWatchdog; do + if ! product=$(codexbar_require_product_file "$bin_dir" "$name" "$arch"); then + return 1 + fi + if ! binary_has_arch "$product" "$arch"; then + echo "ERROR: ${product} does not contain required architecture: ${arch}" >&2 + return 1 + fi + cp "$product" "$stage_dir/$name" + done + if [[ -d "$bin_dir/CodexBar.dSYM" ]]; then + cp -R "$bin_dir/CodexBar.dSYM" "$stage_dir/" + fi +} + for ARCH in "${ARCH_LIST[@]}"; do swift build -c "$CONF" --arch "$ARCH" + stage_build_products "$ARCH" done -APP="$ROOT/CodexBar.app" -rm -rf "$APP" +APP_FINAL="$ROOT/CodexBar.app" +APP_STAGE="$ROOT/.build/package/CodexBar.app" +rm -rf "$APP_STAGE" +APP="$APP_STAGE" mkdir -p "$APP/Contents/MacOS" "$APP/Contents/Resources" "$APP/Contents/Frameworks" mkdir -p "$APP/Contents/Helpers" "$APP/Contents/PlugIns" @@ -121,14 +217,11 @@ if [[ -f "$ICON_SOURCE" ]]; then fi BUNDLE_ID="com.steipete.codexbar" -# Fork builds intentionally do NOT auto-update. The upstream Sparkle feed -# (steipete/CodexBar) would otherwise replace this fork — color-coded icons, -# separator style, etc. — with steipete's vanilla build, since both share the -# com.steipete.codexbar bundle ID. Update manually from johnlarkin1/CodexBar -# releases. To re-enable, set FEED_URL to the fork's appcast and AUTO_CHECKS=true. +# Fork builds ship no appcast. The bundle ID and Sparkle public key are still upstream's, so a +# non-empty feed here would let an upstream release auto-update a fork install and silently +# remove the fork-only features. Do not restore these values when syncing upstream. FEED_URL="" AUTO_CHECKS=false -LOWER_CONF=$(printf "%s" "$CONF" | tr '[:upper:]' '[:lower:]') if [[ "$LOWER_CONF" == "debug" ]]; then BUNDLE_ID="com.steipete.codexbar.debug" FEED_URL="" @@ -139,9 +232,10 @@ if [[ "$SIGNING_MODE" == "adhoc" ]]; then AUTO_CHECKS=false fi WIDGET_BUNDLE_ID="${BUNDLE_ID}.widget" -APP_GROUP_ID="group.com.steipete.codexbar" +APP_TEAM_ID="${APP_TEAM_ID:-P3Q6VLD666}" +APP_GROUP_ID="${APP_TEAM_ID}.com.steipete.codexbar" if [[ "$BUNDLE_ID" == *".debug"* ]]; then - APP_GROUP_ID="group.com.steipete.codexbar.debug" + APP_GROUP_ID="${APP_TEAM_ID}.com.steipete.codexbar.debug" fi ENTITLEMENTS_DIR="$ROOT/.build/entitlements" APP_ENTITLEMENTS="${ENTITLEMENTS_DIR}/CodexBar.entitlements" @@ -196,38 +290,45 @@ cat > "$APP/Contents/Info.plist" <LSMinimumSystemVersion14.0 LSUIElement CFBundleIconFileIcon - NSHumanReadableCopyright© 2025 Peter Steinberger. MIT License. + NSHumanReadableCopyright© 2026 Peter Steinberger. MIT License. SUFeedURL${FEED_URL} SUPublicEDKeyAGCY8w5vHirVfGGDGc8Szc5iuOqupZSh9pMj/Qs67XI= SUEnableAutomaticChecks<${AUTO_CHECKS}/> CodexBuildTimestamp${BUILD_TIMESTAMP} CodexGitCommit${GIT_COMMIT} + CodexBarTeamID${APP_TEAM_ID} + UTExportedTypeDeclarations + + + UTTypeIdentifiercom.steipete.codexbar.menu-layout-item + UTTypeDescriptionCodexBar menu bar layout token + UTTypeConformsTo + + public.data + + UTTypeTagSpecification + + + PLIST -build_product_path() { - local name="$1" - local arch="$2" - case "$arch" in - arm64|x86_64) echo ".build/${arch}-apple-macosx/$CONF/$name" ;; - *) echo ".build/$CONF/$name" ;; - esac -} - -# Resolve path to built binary; some SwiftPM versions use .build/$CONF/ when building for host only. +# Resolve a built binary from the fresh per-arch snapshot or SwiftPM's reported directory. resolve_binary_path() { local name="$1" local arch="$2" - local candidate - candidate=$(build_product_path "$name" "$arch") - if [[ -f "$candidate" ]]; then - echo "$candidate" - return + local bin_dir candidate + swiftpm_bin_path "$arch" bin_dir + if ! candidate=$(codexbar_resolve_staged_or_reported_file \ + "$PRODUCT_STAGE_ROOT" "$bin_dir" "$name" "$arch"); then + return 1 fi - if [[ "$arch" == "arm64" || "$arch" == "x86_64" ]] && [[ -f ".build/$CONF/$name" ]]; then - echo ".build/$CONF/$name" + if ! binary_has_arch "$candidate" "$arch"; then + echo "ERROR: ${candidate} does not contain required architecture: ${arch}" >&2 + return 1 fi + echo "$candidate" } verify_binary_arches() { @@ -256,9 +357,7 @@ install_binary() { local binaries=() for arch in "${ARCH_LIST[@]}"; do local src - src=$(resolve_binary_path "$name" "$arch") - if [[ -z "$src" || ! -f "$src" ]]; then - echo "ERROR: Missing ${name} build for ${arch} at $(build_product_path "$name" "$arch")" >&2 + if ! src=$(resolve_binary_path "$name" "$arch"); then exit 1 fi binaries+=("$src") @@ -272,48 +371,128 @@ install_binary() { verify_binary_arches "$dest" "${ARCH_LIST[@]}" } +strip_release_binary() { + local binary="$1" + if [[ "$LOWER_CONF" != "release" ]]; then + return 0 + fi + if [[ ! -f "$binary" ]]; then + return 0 + fi + xcrun strip -x "$binary" +} + +ensure_widget_extension_project() { + local spec="$ROOT/WidgetExtension/project.yml" + local project_dir="$ROOT/WidgetExtension/CodexBarWidgetExtension.xcodeproj" + if [[ -f "$project_dir/project.pbxproj" ]]; then + return + fi + if ! command -v xcodegen >/dev/null 2>&1; then + echo "ERROR: Missing ${project_dir}; install xcodegen or restore the generated project." >&2 + exit 1 + fi + + # The tracked project is authoritative. Regenerating it during packaging records the checkout + # directory's spelling in a package file reference and leaves release worktrees dirty. + xcodegen generate --spec "$spec" --project "$ROOT/WidgetExtension" --quiet +} + +build_widget_extension() { + local xcode_conf="Release" + if [[ "$LOWER_CONF" == "debug" ]]; then + xcode_conf="Debug" + fi + + ensure_widget_extension_project + + local derived_dir="$ROOT/.build/xcode-widget-extension-${LOWER_CONF}" + local project_dir="$ROOT/WidgetExtension/CodexBarWidgetExtension.xcodeproj" + local build_log="$derived_dir/xcodebuild.log" + local timeout_seconds="${CODEXBAR_WIDGET_EXTENSION_TIMEOUT_SECONDS:-900}" + local archs="${ARCH_LIST[*]}" + + mkdir -p "$derived_dir" + echo "Building CodexBarWidget Xcode extension (${xcode_conf}, ${archs})." >&2 + xcodebuild \ + -project "$project_dir" \ + -scheme CodexBarWidgetExtension \ + -configuration "$xcode_conf" \ + -destination "generic/platform=macOS" \ + -derivedDataPath "$derived_dir" \ + -skipPackageUpdates \ + -disableAutomaticPackageResolution \ + -skipMacroValidation \ + -skipPackagePluginValidation \ + CODEXBAR_WIDGET_BUNDLE_ID="$WIDGET_BUNDLE_ID" \ + CODEXBAR_TEAM_ID="$APP_TEAM_ID" \ + MARKETING_VERSION="$MARKETING_VERSION" \ + CURRENT_PROJECT_VERSION="$BUILD_NUMBER" \ + CODE_SIGNING_ALLOWED=NO \ + ARCHS="$archs" \ + ONLY_ACTIVE_ARCH=NO \ + build >"$build_log" 2>&1 & + + local xcodebuild_pid=$! + local elapsed=0 + while kill -0 "$xcodebuild_pid" 2>/dev/null; do + if [[ "$elapsed" -ge "$timeout_seconds" ]]; then + kill "$xcodebuild_pid" 2>/dev/null || true + wait "$xcodebuild_pid" 2>/dev/null || true + tail -80 "$build_log" >&2 || true + echo "ERROR: Timed out building CodexBarWidget extension after ${timeout_seconds}s" >&2 + exit 1 + fi + sleep 5 + elapsed=$((elapsed + 5)) + if (( elapsed > 0 && elapsed % 60 == 0 )); then + echo "Still building CodexBarWidget extension (${elapsed}s)..." >&2 + fi + done + if ! wait "$xcodebuild_pid"; then + tail -120 "$build_log" >&2 || true + echo "ERROR: Failed to build CodexBarWidget extension" >&2 + exit 1 + fi + + local appex="$derived_dir/Build/Products/${xcode_conf}/CodexBarWidget.appex" + if [[ ! -f "$appex/Contents/MacOS/CodexBarWidget" ]]; then + echo "ERROR: Missing Xcode-built CodexBarWidget.appex at ${appex}" >&2 + exit 1 + fi + echo "$appex" +} + +install_widget_extension() { + local src_appex + src_appex="$(build_widget_extension)" + local widget_app="$APP/Contents/PlugIns/CodexBarWidget.appex" + rm -rf "$widget_app" + mkdir -p "$APP/Contents/PlugIns" + cp -R "$src_appex" "$widget_app" + verify_binary_arches "$widget_app/Contents/MacOS/CodexBarWidget" "${ARCH_LIST[@]}" +} + install_binary "CodexBar" "$APP/Contents/MacOS/CodexBar" +strip_release_binary "$APP/Contents/MacOS/CodexBar" # Ship CodexBarCLI alongside the app for easy symlinking. -if [[ -n "$(resolve_binary_path "CodexBarCLI" "${ARCH_LIST[0]}")" ]]; then - install_binary "CodexBarCLI" "$APP/Contents/Helpers/CodexBarCLI" -fi +install_binary "CodexBarCLI" "$APP/Contents/Helpers/CodexBarCLI" +strip_release_binary "$APP/Contents/Helpers/CodexBarCLI" # Watchdog helper: ensures `claude` probes die when CodexBar crashes/gets killed. -if [[ -n "$(resolve_binary_path "CodexBarClaudeWatchdog" "${ARCH_LIST[0]}")" ]]; then - install_binary "CodexBarClaudeWatchdog" "$APP/Contents/Helpers/CodexBarClaudeWatchdog" -fi -if [[ -n "$(resolve_binary_path "CodexBarWidget" "${ARCH_LIST[0]}")" ]]; then - WIDGET_APP="$APP/Contents/PlugIns/CodexBarWidget.appex" - mkdir -p "$WIDGET_APP/Contents/MacOS" "$WIDGET_APP/Contents/Resources" - cat > "$WIDGET_APP/Contents/Info.plist" < - - - - CFBundleNameCodexBarWidget - CFBundleDisplayNameCodexBar - CFBundleIdentifier${WIDGET_BUNDLE_ID} - CFBundleExecutableCodexBarWidget - CFBundlePackageTypeXPC! - CFBundleShortVersionString${MARKETING_VERSION} - CFBundleVersion${BUILD_NUMBER} - LSMinimumSystemVersion14.0 - NSExtension - - NSExtensionPointIdentifiercom.apple.widgetkit-extension - NSExtensionPrincipalClassCodexBarWidget.CodexBarWidgetBundle - - - -PLIST - install_binary "CodexBarWidget" "$WIDGET_APP/Contents/MacOS/CodexBarWidget" -fi +install_binary "CodexBarClaudeWatchdog" "$APP/Contents/Helpers/CodexBarClaudeWatchdog" +strip_release_binary "$APP/Contents/Helpers/CodexBarClaudeWatchdog" +install_widget_extension +strip_release_binary "$APP/Contents/PlugIns/CodexBarWidget.appex/Contents/MacOS/CodexBarWidget" + +swiftpm_bin_path "${ARCH_LIST[0]}" PREFERRED_BUILD_DIR + # Embed Sparkle.framework -if [[ -d ".build/$CONF/Sparkle.framework" ]]; then - cp -R ".build/$CONF/Sparkle.framework" "$APP/Contents/Frameworks/" - chmod -R a+rX "$APP/Contents/Frameworks/Sparkle.framework" - install_name_tool -add_rpath "@executable_path/../Frameworks" "$APP/Contents/MacOS/CodexBar" - # Re-sign Sparkle and all nested components with Developer ID + timestamp - SPARKLE="$APP/Contents/Frameworks/Sparkle.framework" +SPARKLE_SOURCE=$(codexbar_require_product_directory "$PREFERRED_BUILD_DIR" Sparkle.framework packaging) +cp -R "$SPARKLE_SOURCE" "$APP/Contents/Frameworks/" +chmod -R a+rX "$APP/Contents/Frameworks/Sparkle.framework" +install_name_tool -add_rpath "@executable_path/../Frameworks" "$APP/Contents/MacOS/CodexBar" +# Re-sign Sparkle and all nested components with the selected package identity. +SPARKLE="$APP/Contents/Frameworks/Sparkle.framework" if [[ "$SIGNING_MODE" == "adhoc" ]]; then CODESIGN_ID="-" CODESIGN_ARGS=(--force --sign "$CODESIGN_ID") @@ -325,19 +504,11 @@ else CODESIGN_ARGS=(--force --timestamp --options runtime --sign "$CODESIGN_ID") fi function resign() { codesign "${CODESIGN_ARGS[@]}" "$1"; } - # Sign innermost binaries first, then the framework root to seal resources - resign "$SPARKLE" - resign "$SPARKLE/Versions/B/Sparkle" - resign "$SPARKLE/Versions/B/Autoupdate" - resign "$SPARKLE/Versions/B/Updater.app" - resign "$SPARKLE/Versions/B/Updater.app/Contents/MacOS/Updater" - resign "$SPARKLE/Versions/B/XPCServices/Downloader.xpc" - resign "$SPARKLE/Versions/B/XPCServices/Downloader.xpc/Contents/MacOS/Downloader" - resign "$SPARKLE/Versions/B/XPCServices/Installer.xpc" - resign "$SPARKLE/Versions/B/XPCServices/Installer.xpc/Contents/MacOS/Installer" - resign "$SPARKLE/Versions/B" - resign "$SPARKLE" -fi +# Validate Sparkle's nested layout before signing so framework layout drift fails clearly. +SPARKLE_SIGNING_TARGETS=$(codexbar_sparkle_signing_targets "$SPARKLE") +while IFS= read -r SPARKLE_TARGET; do + resign "$SPARKLE_TARGET" +done <<<"$SPARKLE_SIGNING_TARGETS" if [[ -f "$ICON_TARGET" ]]; then cp "$ICON_TARGET" "$APP/Contents/Resources/Icon.icns" @@ -354,8 +525,6 @@ if [[ ! -f "$APP/Contents/Resources/Icon-classic.icns" ]]; then fi # SwiftPM resource bundles (e.g. KeyboardShortcuts) are emitted next to the built binary. -CODEXBAR_BINARY="$(resolve_binary_path "CodexBar" "${ARCH_LIST[0]}")" -PREFERRED_BUILD_DIR="$(dirname "${CODEXBAR_BINARY:-$(build_product_path "CodexBar" "${ARCH_LIST[0]}")}")" shopt -s nullglob SWIFTPM_BUNDLES=("${PREFERRED_BUILD_DIR}/"*.bundle) shopt -u nullglob @@ -401,4 +570,8 @@ codesign "${CODESIGN_ARGS[@]}" \ --entitlements "$APP_ENTITLEMENTS" \ "$APP" +rm -rf "$APP_FINAL" +mv "$APP" "$APP_FINAL" +APP="$APP_FINAL" +verify_packaged_app_integrity "$APP" echo "Created $APP" diff --git a/Scripts/package_product_paths.sh b/Scripts/package_product_paths.sh new file mode 100755 index 0000000000..09823537d6 --- /dev/null +++ b/Scripts/package_product_paths.sh @@ -0,0 +1,72 @@ +#!/usr/bin/env bash + +codexbar_swiftpm_bin_path() { + local conf="$1" + shift + local command=(swift build --show-bin-path -c "$conf") + local arch + for arch in "$@"; do + command+=(--arch "$arch") + done + + local path + if ! path=$("${command[@]}"); then + echo "ERROR: SwiftPM failed to report the ${conf} product directory for: $*" >&2 + return 1 + fi + if [[ -z "$path" ]]; then + echo "ERROR: SwiftPM reported an empty ${conf} product directory for: $*" >&2 + return 1 + fi + printf '%s\n' "$path" +} + +codexbar_require_product_file() { + local bin_dir="$1" + local name="$2" + local arch_label="$3" + local product="$bin_dir/$name" + if [[ ! -f "$product" ]]; then + echo "ERROR: Missing ${name} for ${arch_label} at SwiftPM-reported path: ${product}" >&2 + return 1 + fi + printf '%s\n' "$product" +} + +codexbar_require_product_directory() { + local bin_dir="$1" + local name="$2" + local context="$3" + local product="$bin_dir/$name" + if [[ ! -d "$product" ]]; then + echo "ERROR: Missing ${name} for ${context} at SwiftPM-reported path: ${product}" >&2 + return 1 + fi + printf '%s\n' "$product" +} + +codexbar_resolve_staged_or_reported_file() { + local stage_root="$1" + local bin_dir="$2" + local name="$3" + local arch="$4" + local staged="$stage_root/$arch/$name" + if [[ -f "$staged" ]]; then + printf '%s\n' "$staged" + return + fi + codexbar_require_product_file "$bin_dir" "$name" "$arch" +} + +codexbar_resolve_dsym_path() { + local stage_root="$1" + local bin_dir="$2" + local app_name="$3" + local arch="$4" + local staged="$stage_root/$arch/${app_name}.dSYM" + if [[ -d "$staged" ]]; then + printf '%s\n' "$staged" + return + fi + codexbar_require_product_directory "$bin_dir" "${app_name}.dSYM" "$arch" +} diff --git a/Scripts/prepare_upstream_pr.sh b/Scripts/prepare_upstream_pr.sh index b8be56139d..97fdcde151 100755 --- a/Scripts/prepare_upstream_pr.sh +++ b/Scripts/prepare_upstream_pr.sh @@ -63,7 +63,7 @@ echo " ${GREEN}git add ${NC}" echo " ${GREEN}git commit -m 'fix: description'${NC}" echo "" echo "3. Ensure tests pass:" -echo " ${GREEN}swift test${NC}" +echo " ${GREEN}make test${NC}" echo "" echo "4. Review changes:" echo " ${GREEN}git diff upstream/main${NC}" @@ -75,4 +75,3 @@ echo "6. Create PR on GitHub:" echo " ${GREEN}https://github.com/steipete/CodexBar/compare/main...topoffunnel:$BRANCH_NAME${NC}" echo "" echo -e "${YELLOW}Remember: Keep PRs small and focused for better merge chances!${NC}" - diff --git a/Scripts/release_dsym_paths.sh b/Scripts/release_dsym_paths.sh new file mode 100755 index 0000000000..56d88db473 --- /dev/null +++ b/Scripts/release_dsym_paths.sh @@ -0,0 +1,75 @@ +#!/usr/bin/env bash + +codexbar_dsym_dwarf_path() { + local dsym_path="$1" + local app_name="$2" + local dwarf_path="${dsym_path}/Contents/Resources/DWARF/${app_name}" + + if [[ ! -f "$dwarf_path" ]]; then + echo "Missing fresh dSYM for ${app_name} at: ${dwarf_path}" >&2 + return 1 + fi + + printf '%s\n' "$dwarf_path" +} + +codexbar_require_dsym_dwarf_for_arch() { + local dsym_path="$1" + local app_name="$2" + local arch="$3" + local dwarf_path + + if ! dwarf_path=$(codexbar_dsym_dwarf_path "$dsym_path" "$app_name"); then + return 1 + fi + + if ! lipo -archs "$dwarf_path" | tr ' ' '\n' | grep -qx "$arch"; then + echo "dSYM at ${dwarf_path} does not contain required architecture: ${arch}" >&2 + return 1 + fi + + printf '%s\n' "$dwarf_path" +} + +codexbar_dwarf_uuid_for_arch() { + local path="$1" + local arch="$2" + local uuid + + uuid=$(dwarfdump --uuid "$path" | awk -v arch="(${arch})" '$1 == "UUID:" && $3 == arch { print $2; exit }') + if [[ -z "$uuid" ]]; then + echo "Missing UUID for ${arch} in: ${path}" >&2 + return 1 + fi + + printf '%s\n' "$uuid" +} + +codexbar_verify_dsym_matches_binary() { + local app_binary="$1" + local dsym_dwarf="$2" + shift 2 + local arch app_uuid dsym_uuid + + if [[ ! -f "$app_binary" ]]; then + echo "Missing app binary for dSYM UUID verification: ${app_binary}" >&2 + return 1 + fi + if [[ ! -f "$dsym_dwarf" ]]; then + echo "Missing dSYM DWARF file for UUID verification: ${dsym_dwarf}" >&2 + return 1 + fi + + for arch in "$@"; do + if ! app_uuid=$(codexbar_dwarf_uuid_for_arch "$app_binary" "$arch"); then + return 1 + fi + if ! dsym_uuid=$(codexbar_dwarf_uuid_for_arch "$dsym_dwarf" "$arch"); then + return 1 + fi + if [[ "$app_uuid" != "$dsym_uuid" ]]; then + echo "dSYM UUID mismatch for ${arch}: app=${app_uuid}, dSYM=${dsym_uuid}" >&2 + return 1 + fi + done +} diff --git a/Scripts/sign-and-notarize.sh b/Scripts/sign-and-notarize.sh index dcb36275e1..066be86c2f 100755 --- a/Scripts/sign-and-notarize.sh +++ b/Scripts/sign-and-notarize.sh @@ -6,61 +6,43 @@ APP_IDENTITY="${APP_IDENTITY:-Developer ID Application: John Larkin (P3Q6VLD666) APP_BUNDLE="CodexBar.app" ROOT=$(cd "$(dirname "$0")/.." && pwd) source "$ROOT/version.env" -ZIP_NAME="${APP_NAME}-${MARKETING_VERSION}.zip" -DSYM_ZIP="${APP_NAME}-${MARKETING_VERSION}.dSYM.zip" - -# Notarization credentials: support both upstream (APP_STORE_CONNECT_*) and APPLE_* env vars. -NOTARY_KEY_PATH="" -NOTARY_KEY_ID="" -NOTARY_ISSUER="" -CLEANUP_KEY_FILE=false - -if [[ -n "${APPLE_API_KEY_PATH:-}" && -n "${APPLE_API_KEY:-}" && -n "${APPLE_ISSUER_ID:-}" ]]; then - NOTARY_KEY_PATH="$APPLE_API_KEY_PATH" - NOTARY_KEY_ID="$APPLE_API_KEY" - NOTARY_ISSUER="$APPLE_ISSUER_ID" -elif [[ -n "${APP_STORE_CONNECT_API_KEY_P8:-}" && -n "${APP_STORE_CONNECT_KEY_ID:-}" && -n "${APP_STORE_CONNECT_ISSUER_ID:-}" ]]; then - echo "$APP_STORE_CONNECT_API_KEY_P8" | sed 's/\\n/\n/g' > /tmp/codexbar-api-key.p8 - NOTARY_KEY_PATH="/tmp/codexbar-api-key.p8" - NOTARY_KEY_ID="$APP_STORE_CONNECT_KEY_ID" - NOTARY_ISSUER="$APP_STORE_CONNECT_ISSUER_ID" - CLEANUP_KEY_FILE=true -else - echo "Missing notarization credentials. Set either APPLE_API_KEY_PATH/APPLE_API_KEY/APPLE_ISSUER_ID or APP_STORE_CONNECT_* env vars." >&2 - exit 1 -fi - -# Sparkle key is optional for fork builds (no auto-update feed). -SKIP_SPARKLE=false -if [[ -z "${SPARKLE_PRIVATE_KEY_FILE:-}" ]]; then - echo "SPARKLE_PRIVATE_KEY_FILE not set — skipping Sparkle signing (fork build)." - SKIP_SPARKLE=true -elif [[ ! -f "$SPARKLE_PRIVATE_KEY_FILE" ]]; then - echo "Sparkle key file not found: $SPARKLE_PRIVATE_KEY_FILE — skipping Sparkle signing." >&2 - SKIP_SPARKLE=true -else - key_lines=$(grep -v '^[[:space:]]*#' "$SPARKLE_PRIVATE_KEY_FILE" | sed '/^[[:space:]]*$/d') - if [[ $(printf "%s\n" "$key_lines" | wc -l) -ne 1 ]]; then - echo "Sparkle key file must contain exactly one base64 line (no comments/blank lines)." >&2 - exit 1 +source "$ROOT/Scripts/release_artifacts.sh" +source "$ROOT/Scripts/package_product_paths.sh" +source "$ROOT/Scripts/release_dsym_paths.sh" + +verify_distribution_policy() { + local app=$1 + if command -v syspolicy_check >/dev/null 2>&1; then + syspolicy_check distribution "$app" + else + spctl -a -t exec -vv "$app" fi -fi - -cleanup() { - if [[ "$CLEANUP_KEY_FILE" == "true" ]]; then - rm -f /tmp/codexbar-api-key.p8 - fi - rm -f "/tmp/${APP_NAME}Notarize.zip" } -trap cleanup EXIT # Allow building a universal binary if ARCHES is provided; default to universal (arm64 + x86_64). ARCHES_VALUE=${ARCHES:-"arm64 x86_64"} +ZIP_NAME=$(codexbar_app_zip_name "$MARKETING_VERSION" "$ARCHES_VALUE") +DSYM_ZIP=$(codexbar_dsym_zip_name "$MARKETING_VERSION" "$ARCHES_VALUE") + +if [[ -z "${APP_STORE_CONNECT_API_KEY_P8:-}" || -z "${APP_STORE_CONNECT_KEY_ID:-}" || -z "${APP_STORE_CONNECT_ISSUER_ID:-}" ]]; then + echo "Missing APP_STORE_CONNECT_* env vars (API key, key id, issuer id)." >&2 + exit 1 +fi + +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"' EXIT + +( + umask 077 + printf '%s' "$APP_STORE_CONNECT_API_KEY_P8" | sed 's/\\n/\n/g' > "$API_KEY_PATH" +) +chmod 600 "$API_KEY_PATH" + ARCH_LIST=( ${ARCHES_VALUE} ) -for ARCH in "${ARCH_LIST[@]}"; do - swift build -c release --arch "$ARCH" -done -ARCHES="${ARCHES_VALUE}" ./Scripts/package_app.sh release +ARCHES="${ARCHES_VALUE}" CODEXBAR_SIGNING=identity ./Scripts/package_app.sh release ENTITLEMENTS_DIR="$ROOT/.build/entitlements" APP_ENTITLEMENTS="${ENTITLEMENTS_DIR}/CodexBar.entitlements" @@ -88,13 +70,13 @@ 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 "$NOTARY_KEY_PATH" \ - --key-id "$NOTARY_KEY_ID" \ - --issuer "$NOTARY_ISSUER" \ +xcrun notarytool submit "$NOTARIZATION_ZIP" \ + --key "$API_KEY_PATH" \ + --key-id "$APP_STORE_CONNECT_KEY_ID" \ + --issuer "$APP_STORE_CONNECT_ISSUER_ID" \ --wait echo "Stapling ticket" @@ -106,34 +88,50 @@ find "$APP_BUNDLE" -name '._*' -delete "$DITTO_BIN" --norsrc -c -k --keepParent "$APP_BUNDLE" "$ZIP_NAME" -spctl -a -t exec -vv "$APP_BUNDLE" +verify_distribution_policy "$APP_BUNDLE" stapler validate "$APP_BUNDLE" echo "Packaging dSYM" -FIRST_ARCH="${ARCH_LIST[0]}" -PREFERRED_ARCH_DIR=".build/${FIRST_ARCH}-apple-macosx/release" -DSYM_PATH="${PREFERRED_ARCH_DIR}/${APP_NAME}.dSYM" -if [[ ! -d "$DSYM_PATH" ]]; then - echo "Missing dSYM at $DSYM_PATH" >&2 - exit 1 -fi +DSYM_STAGE_ROOT="$ROOT/.build/package-products/release" +DSYM_PATHS=() +for ARCH in "${ARCH_LIST[@]}"; do + STAGED_DSYM="$DSYM_STAGE_ROOT/$ARCH/${APP_NAME}.dSYM" + if [[ -d "$STAGED_DSYM" ]]; then + DSYM_PATHS+=("$STAGED_DSYM") + continue + fi + BIN_DIR=$(codexbar_swiftpm_bin_path release "$ARCH") + DSYM_PATHS+=("$(codexbar_resolve_dsym_path "$DSYM_STAGE_ROOT" "$BIN_DIR" "$APP_NAME" "$ARCH")") +done + +DSYM_PATH="${DSYM_PATHS[0]}" +DSYM_DWARF_PATHS=() +for ((index = 0; index < ${#ARCH_LIST[@]}; index++)); do + ARCH="${ARCH_LIST[$index]}" + if ! ARCH_DSYM=$(codexbar_require_dsym_dwarf_for_arch "${DSYM_PATHS[$index]}" "$APP_NAME" "$ARCH"); then + exit 1 + fi + DSYM_DWARF_PATHS+=("$ARCH_DSYM") +done + if [[ ${#ARCH_LIST[@]} -gt 1 ]]; then - MERGED_DSYM="${PREFERRED_ARCH_DIR}/${APP_NAME}.dSYM-universal" - rm -rf "$MERGED_DSYM" + MERGED_DSYM_ROOT="${DSYM_STAGE_ROOT}/${APP_NAME}.dSYM-universal" + MERGED_DSYM="${MERGED_DSYM_ROOT}/${APP_NAME}.dSYM" + rm -rf "$MERGED_DSYM_ROOT" + mkdir -p "$MERGED_DSYM_ROOT" cp -R "$DSYM_PATH" "$MERGED_DSYM" DWARF_PATH="${MERGED_DSYM}/Contents/Resources/DWARF/${APP_NAME}" - BINARIES=() - for ARCH in "${ARCH_LIST[@]}"; do - ARCH_DSYM=".build/${ARCH}-apple-macosx/release/${APP_NAME}.dSYM/Contents/Resources/DWARF/${APP_NAME}" - if [[ ! -f "$ARCH_DSYM" ]]; then - echo "Missing dSYM for ${ARCH} at $ARCH_DSYM" >&2 - exit 1 - fi - BINARIES+=("$ARCH_DSYM") - done - lipo -create "${BINARIES[@]}" -output "$DWARF_PATH" + lipo -create "${DSYM_DWARF_PATHS[@]}" -output "$DWARF_PATH" DSYM_PATH="$MERGED_DSYM" fi +if [[ ! -d "$DSYM_PATH" ]]; then + echo "Missing dSYM at SwiftPM-reported path: $DSYM_PATH" >&2 + exit 1 +fi +codexbar_verify_dsym_matches_binary \ + "$APP_BUNDLE/Contents/MacOS/$APP_NAME" \ + "$DSYM_PATH/Contents/Resources/DWARF/$APP_NAME" \ + "${ARCH_LIST[@]}" "$DITTO_BIN" --norsrc -c -k --keepParent "$DSYM_PATH" "$DSYM_ZIP" echo "Done: $ZIP_NAME" diff --git a/Scripts/site-tailwind.input.css b/Scripts/site-tailwind.input.css new file mode 100644 index 0000000000..b5c61c9567 --- /dev/null +++ b/Scripts/site-tailwind.input.css @@ -0,0 +1,3 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; diff --git a/Scripts/sparkle_signing_paths.sh b/Scripts/sparkle_signing_paths.sh new file mode 100755 index 0000000000..bb0b6dae9c --- /dev/null +++ b/Scripts/sparkle_signing_paths.sh @@ -0,0 +1,137 @@ +#!/usr/bin/env bash + +codexbar_resolve_sparkle_version_child() { + local versions_dir="$1" + local candidate="$2" + local label="$3" + local versions_root resolved + + versions_root=$(cd "$versions_dir" && pwd -P) + if ! resolved=$(cd "$candidate" 2>/dev/null && pwd -P); then + echo "ERROR: Sparkle ${label} does not resolve: ${candidate}" >&2 + return 1 + fi + if [[ "$(dirname "$resolved")" != "$versions_root" ]]; then + echo "ERROR: Sparkle ${label} resolves outside the framework versions directory: ${candidate}" >&2 + return 1 + fi + + printf '%s\n' "$resolved" +} + +codexbar_sparkle_version_dir() { + local sparkle="$1" + local versions_dir="${sparkle}/Versions" + + if [[ -L "$sparkle" ]]; then + echo "ERROR: Sparkle framework root must not be a symlink: ${sparkle}" >&2 + return 1 + fi + if [[ -L "$versions_dir" ]]; then + echo "ERROR: Sparkle versions directory must not be a symlink: ${versions_dir}" >&2 + return 1 + fi + if [[ ! -d "$versions_dir" ]]; then + echo "ERROR: Missing Sparkle versions directory: ${versions_dir}" >&2 + return 1 + fi + + if [[ -e "$versions_dir/Current" || -L "$versions_dir/Current" ]]; then + local current + if ! current=$(codexbar_resolve_sparkle_version_child "$versions_dir" "$versions_dir/Current" "Versions/Current"); then + return 1 + fi + printf '%s\n' "$current" + return + fi + + local version_dirs=() + local candidate + shopt -s nullglob + for candidate in "$versions_dir"/*; do + if [[ -d "$candidate" ]]; then + version_dirs+=("$candidate") + fi + done + shopt -u nullglob + + case "${#version_dirs[@]}" in + 0) + echo "ERROR: Sparkle framework has no version directory under: ${versions_dir}" >&2 + return 1 + ;; + 1) + local resolved + if ! resolved=$(codexbar_resolve_sparkle_version_child \ + "$versions_dir" "${version_dirs[0]}" "version directory"); then + return 1 + fi + printf '%s\n' "$resolved" + ;; + *) + echo "ERROR: Sparkle framework has multiple version directories and no Versions/Current symlink: ${versions_dir}" >&2 + return 1 + ;; + esac +} + +codexbar_require_sparkle_signing_target() { + local path="$1" + local label="$2" + local trusted_root="$3" + local resolved trusted_root_resolved + + if [[ -L "$path" ]]; then + echo "ERROR: Sparkle signing target must not be a symlink (${label}): ${path}" >&2 + return 1 + fi + + if [[ ! -e "$path" ]]; then + echo "ERROR: Missing Sparkle signing target (${label}): ${path}" >&2 + return 1 + fi + + if ! trusted_root_resolved=$(cd "$trusted_root" 2>/dev/null && pwd -P); then + echo "ERROR: Sparkle signing root does not resolve (${label}): ${trusted_root}" >&2 + return 1 + fi + if [[ -d "$path" ]]; then + resolved=$(cd "$path" && pwd -P) + else + resolved="$(cd "$(dirname "$path")" && pwd -P)/$(basename "$path")" + fi + if [[ "$resolved" != "$trusted_root_resolved" && + "${resolved#"$trusted_root_resolved"/}" == "$resolved" ]]; then + echo "ERROR: Sparkle signing target resolves outside its trusted root (${label}): ${path}" >&2 + return 1 + fi + + printf '%s\n' "$resolved" +} + +codexbar_sparkle_signing_targets() { + local sparkle="$1" + local version_dir + if ! version_dir=$(codexbar_sparkle_version_dir "$sparkle"); then + return 1 + fi + + codexbar_require_sparkle_signing_target "$sparkle" "framework root" "$sparkle" || return 1 + codexbar_require_sparkle_signing_target "$version_dir/Sparkle" "framework binary" "$version_dir" || return 1 + codexbar_require_sparkle_signing_target "$version_dir/Autoupdate" "autoupdate tool" "$version_dir" || return 1 + codexbar_require_sparkle_signing_target "$version_dir/Updater.app" "updater app" "$version_dir" || return 1 + codexbar_require_sparkle_signing_target \ + "$version_dir/Updater.app/Contents/MacOS/Updater" "updater executable" "$version_dir" || return 1 + codexbar_require_sparkle_signing_target \ + "$version_dir/XPCServices/Downloader.xpc" "downloader xpc" "$version_dir" || return 1 + codexbar_require_sparkle_signing_target \ + "$version_dir/XPCServices/Downloader.xpc/Contents/MacOS/Downloader" \ + "downloader executable" "$version_dir" || return 1 + codexbar_require_sparkle_signing_target \ + "$version_dir/XPCServices/Installer.xpc" "installer xpc" "$version_dir" || return 1 + codexbar_require_sparkle_signing_target \ + "$version_dir/XPCServices/Installer.xpc/Contents/MacOS/Installer" \ + "installer executable" "$version_dir" || return 1 + codexbar_require_sparkle_signing_target "$version_dir" "framework version" "$version_dir" || return 1 + codexbar_require_sparkle_signing_target "$sparkle" "framework root" "$sparkle" || return 1 +} diff --git a/Scripts/tailwind.site.config.cjs b/Scripts/tailwind.site.config.cjs new file mode 100644 index 0000000000..afa1342b5f --- /dev/null +++ b/Scripts/tailwind.site.config.cjs @@ -0,0 +1,14 @@ +module.exports = { + content: ["./docs/index.html", "./docs/site.js"], + theme: { + extend: { + screens: { + tablet: "769px", + }, + fontFamily: { + sans: ["Inter", "-apple-system", "BlinkMacSystemFont", "Segoe UI", "sans-serif"], + mono: ["SFMono-Regular", "SF Mono", "Menlo", "monospace"], + }, + }, + }, +}; diff --git a/Scripts/test.sh b/Scripts/test.sh new file mode 100755 index 0000000000..aa56eaed79 --- /dev/null +++ b/Scripts/test.sh @@ -0,0 +1,39 @@ +#!/usr/bin/env bash + +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +GROUP_SIZE="${CODEXBAR_TEST_GROUP_SIZE:-12}" +SUITE_TIMEOUT="${CODEXBAR_TEST_SUITE_TIMEOUT:-180}" +RETRY_NON_TIMEOUT_FAILURES="${CODEXBAR_TEST_RETRY_NON_TIMEOUT_FAILURES:-1}" + +cd "${ROOT_DIR}" + +# Defense in depth: test processes also self-detect, but keep this explicit so runner changes cannot +# expose the user's login Keychain. Deliberate isolated Keychain tests must opt in by setting the allow flag. +if [[ "${CODEXBAR_ALLOW_TEST_KEYCHAIN_ACCESS:-}" != "1" ]]; then + export CODEXBAR_SUPPRESS_TEST_KEYCHAIN_ACCESS=1 +fi + +ARGS=( + --group-size "${GROUP_SIZE}" + --timeout "${SUITE_TIMEOUT}" +) + +case "${RETRY_NON_TIMEOUT_FAILURES}" in + 0) ARGS+=(--no-retry-non-timeout-failures) ;; + 1) ;; + *) + echo "CODEXBAR_TEST_RETRY_NON_TIMEOUT_FAILURES must be 0 or 1" >&2 + exit 2 + ;; +esac + +if [[ -n "${CODEXBAR_TEST_SHARD_INDEX:-}" || -n "${CODEXBAR_TEST_SHARD_COUNT:-}" ]]; then + ARGS+=( + --shard-index "${CODEXBAR_TEST_SHARD_INDEX:?CODEXBAR_TEST_SHARD_COUNT requires CODEXBAR_TEST_SHARD_INDEX}" + --shard-count "${CODEXBAR_TEST_SHARD_COUNT:?CODEXBAR_TEST_SHARD_INDEX requires CODEXBAR_TEST_SHARD_COUNT}" + ) +fi + +exec python3 "${ROOT_DIR}/Scripts/ci_swift_test_by_suite.py" "${ARGS[@]}" "$@" diff --git a/Scripts/test_ci_path_gate.sh b/Scripts/test_ci_path_gate.sh new file mode 100755 index 0000000000..ec5423e2e8 --- /dev/null +++ b/Scripts/test_ci_path_gate.sh @@ -0,0 +1,223 @@ +#!/usr/bin/env bash + +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +tmp_dir="$(mktemp -d)" +trap 'rm -rf "$tmp_dir"' EXIT + +assert_gate() { + local expected="$1" + local name="$2" + local paths_file="${tmp_dir}/${name}.paths" + local output_file="${tmp_dir}/${name}.output" + shift 2 + + printf '%s\n' "$@" > "$paths_file" + GITHUB_OUTPUT="$output_file" "${ROOT_DIR}/Scripts/ci_macos_test_gate.sh" "$paths_file" >/dev/null + local actual + actual="$(sed -n 's/^macos-tests=//p' "$output_file")" + if [[ "$actual" != "$expected" ]]; then + printf '%s: expected macos-tests=%s, got %s\n' "$name" "$expected" "${actual:-}" >&2 + exit 1 + fi + + local reason + reason="$(sed -n 's/^macos-tests-reason=//p' "$output_file")" + if [[ -z "$reason" ]]; then + printf '%s: expected macos-tests-reason output\n' "$name" >&2 + exit 1 + fi + + local deferred + deferred="$(sed -n 's/^macos-tests-deferred=//p' "$output_file")" + if [[ "$deferred" != false ]]; then + printf '%s: expected macos-tests-deferred=false, got %s\n' "$name" "${deferred:-}" >&2 + exit 1 + fi + + local path_count + path_count="$(sed -n 's/^changed-path-count=//p' "$output_file")" + if ! [[ "$path_count" =~ ^[0-9]+$ ]]; then + printf '%s: expected numeric changed-path-count output, got %s\n' \ + "$name" "${path_count:-}" >&2 + exit 1 + fi + + if [[ "$expected" == false && "$reason" != "docs/site-only changes covered by portable checks" ]]; then + printf '%s: expected docs/site skip reason, got %s\n' "$name" "$reason" >&2 + exit 1 + fi +} + +assert_gate false docs-only $'M\tdocs/providers.md' $'M\tREADME.md' +assert_gate true configuration-doc $'M\tdocs/configuration.md' +assert_gate true rename-to-configuration-doc $'R100\tdocs/old.md\tdocs/configuration.md' +assert_gate true rename-from-configuration-doc $'R100\tdocs/configuration.md\tdocs/new.md' +assert_gate true agents-contract $'M\tAGENTS.md' +assert_gate true rename-to-agents-contract $'R100\tdocs/old.md\tAGENTS.md' +assert_gate true rename-from-agents-contract $'R100\tAGENTS.md\tdocs/new.md' +assert_gate true source $'M\tSources/CodexBar/App.swift' +assert_gate false docs-site $'M\tdocs/index.html' $'M\tdocs/site.css' $'M\tdocs/site.js' \ + $'M\tdocs/site-locales.mjs' $'M\tdocs/social.html' $'M\tdocs/social.png' \ + $'M\tdocs/CNAME' $'M\tdocs/.nojekyll' $'M\tdocs/llms.txt' +assert_gate false docs-site-assets $'M\tdocs/icon.png' $'M\tdocs/logos/provider-logo.svg' +assert_gate true docs-unknown-code $'M\tdocs/custom-tool.js' +assert_gate true docs-site-with-config $'M\tdocs/site.css' $'M\tdocs/configuration.md' +assert_gate true empty +assert_gate true source-to-docs $'R100\tSources/CodexBar/App.swift\tdocs/App.md' +assert_gate true docs-to-source $'R100\tdocs/App.md\tSources/CodexBar/App.swift' +assert_gate false docs-to-site $'R100\tdocs/old.md\tdocs/site.css' + +assert_linux_musl_gate() { + local expected="$1" + local name="$2" + local paths_file="${tmp_dir}/linux-musl-${name}.paths" + local output_file="${tmp_dir}/linux-musl-${name}.output" + shift 2 + + printf '%s\n' "$@" > "$paths_file" + GITHUB_OUTPUT="$output_file" "${ROOT_DIR}/Scripts/ci_linux_musl_build_gate.sh" "$paths_file" >/dev/null + local actual + actual="$(sed -n 's/^linux-musl-build=//p' "$output_file")" + if [[ "$actual" != "$expected" ]]; then + printf '%s: expected linux-musl-build=%s, got %s\n' "$name" "$expected" "${actual:-}" >&2 + exit 1 + fi + + local reason + reason="$(sed -n 's/^linux-musl-build-reason=//p' "$output_file")" + if [[ -z "$reason" ]]; then + printf '%s: expected linux-musl-build-reason output\n' "$name" >&2 + exit 1 + fi +} + +assert_linux_musl_gate true package-manifest $'M\tPackage.swift' +assert_linux_musl_gate true swift-source $'M\tSources/CodexBarCore/Process.swift' +assert_linux_musl_gate true nested-swift-source $'M\tSources/CodexBarCore/Host/Process/Process.swift' +assert_linux_musl_gate true rename-from-swift $'R100\tSources/CodexBarCore/Old.swift\tdocs/Old.md' +assert_linux_musl_gate true rename-to-swift $'R100\tdocs/New.md\tSources/CodexBarCore/New.swift' +assert_linux_musl_gate false tests-only $'M\tTests/CodexBarTests/ProcessTests.swift' +assert_linux_musl_gate false workflow-only $'M\t.github/workflows/ci.yml' +assert_linux_musl_gate false script-only $'M\tScripts/ci_verify_test_jobs.sh' +assert_linux_musl_gate false package-resolved $'M\tPackage.resolved' +assert_linux_musl_gate true empty-diff + +draft_paths="${tmp_dir}/draft-source.paths" +draft_output="${tmp_dir}/draft-source.output" +printf '%s\n' $'M\tSources/CodexBar/App.swift' > "$draft_paths" +CI_PULL_REQUEST_DRAFT=true GITHUB_OUTPUT="$draft_output" \ + "${ROOT_DIR}/Scripts/ci_macos_test_gate.sh" "$draft_paths" >/dev/null +if [[ "$(sed -n 's/^macos-tests=//p' "$draft_output")" != true ]]; then + printf 'draft source: expected macOS tests to remain required while deferred\n' >&2 + exit 1 +fi +if [[ "$(sed -n 's/^macos-tests-reason=//p' "$draft_output")" != \ + "draft pull request: macOS Swift tests deferred until ready for review" ]] +then + printf 'draft source: expected draft deferral reason\n' >&2 + exit 1 +fi +if [[ "$(sed -n 's/^macos-tests-deferred=//p' "$draft_output")" != true ]]; then + printf 'draft source: expected macOS tests to be marked deferred\n' >&2 + exit 1 +fi + +draft_docs_output="${tmp_dir}/draft-docs.output" +CI_PULL_REQUEST_DRAFT=true GITHUB_OUTPUT="$draft_docs_output" \ + "${ROOT_DIR}/Scripts/ci_macos_test_gate.sh" "${tmp_dir}/docs-only.paths" >/dev/null +if [[ "$(sed -n 's/^macos-tests=//p' "$draft_docs_output")" != false ]] \ + || [[ "$(sed -n 's/^macos-tests-deferred=//p' "$draft_docs_output")" != false ]] +then + printf 'draft docs: expected required=false and deferred=false\n' >&2 + exit 1 +fi + +assert_gate_fails() { + local name="$1" + local paths_file="${tmp_dir}/${name}.paths" + local output_file="${tmp_dir}/${name}.output" + shift + + printf '%s\n' "$@" > "$paths_file" + if GITHUB_OUTPUT="$output_file" "${ROOT_DIR}/Scripts/ci_macos_test_gate.sh" "$paths_file" >/dev/null 2>&1; then + printf '%s: malformed gate input unexpectedly succeeded\n' "$name" >&2 + exit 1 + fi + if [[ -s "$output_file" ]]; then + printf '%s: malformed gate input emitted an output\n' "$name" >&2 + exit 1 + fi +} + +assert_gate_fails missing-rename-target $'R100\tREADME.md' +assert_gate_fails extra-modified-path $'M\tREADME.md\tdocs/configuration.md' +assert_gate_fails missing-rename-score $'R\tREADME.md\tdocs/README.md' +assert_gate_fails invalid-rename-score $'Rfoo\tREADME.md\tdocs/README.md' +assert_gate_fails out-of-range-rename-score $'R101\tREADME.md\tdocs/README.md' + +for malformed_case in missing-rename-target extra-modified-path missing-rename-score \ + invalid-rename-score out-of-range-rename-score +do + paths_file="${tmp_dir}/${malformed_case}.paths" + output_file="${tmp_dir}/linux-musl-${malformed_case}.output" + if GITHUB_OUTPUT="$output_file" \ + "${ROOT_DIR}/Scripts/ci_linux_musl_build_gate.sh" "$paths_file" >/dev/null 2>&1 + then + printf '%s: malformed Linux musl gate input unexpectedly succeeded\n' "$malformed_case" >&2 + exit 1 + fi + if [[ -s "$output_file" ]]; then + printf '%s: malformed Linux musl gate input emitted an output\n' "$malformed_case" >&2 + exit 1 + fi +done + +if CI_PULL_REQUEST_DRAFT=maybe GITHUB_OUTPUT="${tmp_dir}/invalid-draft.output" \ + "${ROOT_DIR}/Scripts/ci_macos_test_gate.sh" "${tmp_dir}/docs-only.paths" >/dev/null 2>&1 +then + printf 'invalid draft flag unexpectedly succeeded\n' >&2 + exit 1 +fi + +unterminated_paths="${tmp_dir}/unterminated.paths" +unterminated_output="${tmp_dir}/unterminated.output" +printf '%s' $'M\tREADME.md\tdocs/configuration.md' > "$unterminated_paths" +if GITHUB_OUTPUT="$unterminated_output" \ + "${ROOT_DIR}/Scripts/ci_macos_test_gate.sh" "$unterminated_paths" >/dev/null 2>&1 +then + printf 'unterminated malformed gate input unexpectedly succeeded\n' >&2 + exit 1 +fi +if [[ -s "$unterminated_output" ]]; then + printf 'unterminated malformed gate input emitted an output\n' >&2 + exit 1 +fi + +verify="${ROOT_DIR}/Scripts/ci_verify_test_jobs.sh" +"$verify" success success true success false true success >/dev/null +"$verify" success success true success false false skipped >/dev/null +"$verify" success success false skipped false true success >/dev/null +"$verify" success success false skipped false false skipped >/dev/null + +assert_verify_fails() { + if "$verify" "$@" >/dev/null 2>&1; then + printf 'unexpected aggregate success: %s\n' "$*" >&2 + exit 1 + fi +} + +assert_verify_fails success success true skipped false true success +assert_verify_fails success success true skipped true true success +assert_verify_fails success success false skipped true true success +assert_verify_fails success success true success true true success +assert_verify_fails success success false success false true success +assert_verify_fails success success "" skipped false true success +assert_verify_fails failure success true success false true success +assert_verify_fails success failure true success false true success +assert_verify_fails success success true success false true skipped +assert_verify_fails success success true success false false success +assert_verify_fails success success true success false "" skipped + +printf 'CI path gate tests passed.\n' diff --git a/Scripts/test_live_update.sh b/Scripts/test_live_update.sh index db7f9e5a96..76ab2ec326 100755 --- a/Scripts/test_live_update.sh +++ b/Scripts/test_live_update.sh @@ -4,18 +4,27 @@ set -euo pipefail PREV_TAG=${1:?"pass previous release tag (e.g. v0.1.0)"} CUR_TAG=${2:?"pass current release tag (e.g. v0.1.1)"} -ROOT=$(cd "$(dirname "$0")/.." && pwd) PREV_VER=${PREV_TAG#v} +CUR_VER=${CUR_TAG#v} APP_NAME="CodexBar" -ZIP_URL="https://github.com/steipete/CodexBar/releases/download/${PREV_TAG}/${APP_NAME}-${PREV_VER}.zip" +ZIP_URL="https://github.com/steipete/CodexBar/releases/download/${PREV_TAG}/${APP_NAME}-macos-universal-${PREV_VER}.zip" TMP_DIR=$(mktemp -d /tmp/codexbar-live.XXXX) trap 'rm -rf "$TMP_DIR"' EXIT echo "Downloading previous release $PREV_TAG from $ZIP_URL" -curl -L -o "$TMP_DIR/prev.zip" "$ZIP_URL" +curl --fail --location --output "$TMP_DIR/prev.zip" "$ZIP_URL" echo "Installing previous release to /Applications/${APP_NAME}.app" +osascript -e 'tell application "CodexBar" to quit' >/dev/null 2>&1 || true +for _ in {1..20}; do + pgrep -x "$APP_NAME" >/dev/null || break + sleep 0.25 +done +if pgrep -x "$APP_NAME" >/dev/null; then + echo "ERROR: ${APP_NAME} did not quit before replacement." >&2 + exit 1 +fi rm -rf /Applications/${APP_NAME}.app ditto -x -k "$TMP_DIR/prev.zip" "$TMP_DIR" ditto "$TMP_DIR/${APP_NAME}.app" /Applications/${APP_NAME}.app @@ -35,4 +44,11 @@ if [[ ! "$answer" =~ ^[Yy]$ ]]; then exit 1 fi +installed_ver=$(/usr/libexec/PlistBuddy -c 'Print :CFBundleShortVersionString' \ + "/Applications/${APP_NAME}.app/Contents/Info.plist") +if [[ "$installed_ver" != "$CUR_VER" ]]; then + echo "Live update reported success but installed ${installed_ver}; expected ${CUR_VER}." >&2 + exit 1 +fi + echo "Live update test confirmed." diff --git a/Scripts/test_package_info_plist.sh b/Scripts/test_package_info_plist.sh new file mode 100755 index 0000000000..13de6ef7d0 --- /dev/null +++ b/Scripts/test_package_info_plist.sh @@ -0,0 +1,50 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT=$(cd "$(dirname "$0")/.." && pwd) +PACKAGE_SCRIPT="$ROOT/Scripts/package_app.sh" +PLIST_SCRIPT=$(mktemp "${TMPDIR:-/tmp}/codexbar-package-info-plist-script.XXXXXX") +TEMP_DIR=$(mktemp -d "${TMPDIR:-/tmp}/codexbar-package-info-plist.XXXXXX") +trap 'rm -f "$PLIST_SCRIPT"; rm -rf "$TEMP_DIR"' EXIT + +python3 - "$PACKAGE_SCRIPT" "$PLIST_SCRIPT" <<'PY' +import sys +from pathlib import Path + +script = Path(sys.argv[1]).read_text() +start = script.index('cat > "$APP/Contents/Info.plist" </dev/null 2>&1; then + plutil -lint "$APP/Contents/Info.plist" +fi +python3 - "$APP/Contents/Info.plist" <<'PY' +import plistlib +import sys +from pathlib import Path + +plist = plistlib.loads(Path(sys.argv[1]).read_bytes()) +declarations = plist.get("UTExportedTypeDeclarations") +assert declarations == [{ + "UTTypeIdentifier": "com.steipete.codexbar.menu-layout-item", + "UTTypeDescription": "CodexBar menu bar layout token", + "UTTypeConformsTo": ["public.data"], + "UTTypeTagSpecification": {}, +}] +PY + +echo "Package Info.plist tests passed." diff --git a/Scripts/test_package_product_paths.sh b/Scripts/test_package_product_paths.sh new file mode 100755 index 0000000000..1dcfb66f8c --- /dev/null +++ b/Scripts/test_package_product_paths.sh @@ -0,0 +1,91 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT=$(cd "$(dirname "$0")/.." && pwd) +source "$ROOT/Scripts/package_product_paths.sh" + +TEMP_DIR=$(mktemp -d "${TMPDIR:-/tmp}/codexbar-package-paths.XXXXXX") +trap 'rm -rf "$TEMP_DIR"' EXIT + +NATIVE_DIR="$TEMP_DIR/.build/arm64-apple-macosx/release" +SWIFTBUILD_DIR="$TEMP_DIR/.build/out/Products/Release" +STAGE_ROOT="$TEMP_DIR/.build/package-products/release" +mkdir -p "$NATIVE_DIR/CodexBar.dSYM" "$SWIFTBUILD_DIR/Sparkle.framework" "$SWIFTBUILD_DIR/CodexBar.dSYM" +touch "$NATIVE_DIR/CodexBar" "$SWIFTBUILD_DIR/CodexBar" + +native=$(codexbar_require_product_file "$NATIVE_DIR" CodexBar arm64) +[[ "$native" == "$NATIVE_DIR/CodexBar" ]] + +swiftbuild=$(codexbar_require_product_file "$SWIFTBUILD_DIR" CodexBar arm64) +[[ "$swiftbuild" == "$SWIFTBUILD_DIR/CodexBar" ]] + +framework=$(codexbar_require_product_directory "$SWIFTBUILD_DIR" Sparkle.framework packaging) +[[ "$framework" == "$SWIFTBUILD_DIR/Sparkle.framework" ]] + +dsym=$(codexbar_require_product_directory "$SWIFTBUILD_DIR" CodexBar.dSYM release) +[[ "$dsym" == "$SWIFTBUILD_DIR/CodexBar.dSYM" ]] + +resolved=$(codexbar_resolve_staged_or_reported_file "$STAGE_ROOT" "$SWIFTBUILD_DIR" CodexBar arm64) +[[ "$resolved" == "$SWIFTBUILD_DIR/CodexBar" ]] + +resolved_dsym=$(codexbar_resolve_dsym_path "$STAGE_ROOT" "$SWIFTBUILD_DIR" CodexBar arm64) +[[ "$resolved_dsym" == "$SWIFTBUILD_DIR/CodexBar.dSYM" ]] + +mkdir -p "$STAGE_ROOT/arm64/CodexBar.dSYM" +touch "$STAGE_ROOT/arm64/CodexBar" +staged=$(codexbar_resolve_staged_or_reported_file "$STAGE_ROOT" "$SWIFTBUILD_DIR" CodexBar arm64) +[[ "$staged" == "$STAGE_ROOT/arm64/CodexBar" ]] +staged_dsym=$(codexbar_resolve_dsym_path "$STAGE_ROOT" "$SWIFTBUILD_DIR" CodexBar arm64) +[[ "$staged_dsym" == "$STAGE_ROOT/arm64/CodexBar.dSYM" ]] + +rm -rf "$STAGE_ROOT" +rm "$SWIFTBUILD_DIR/CodexBar" +if codexbar_resolve_staged_or_reported_file "$STAGE_ROOT" "$SWIFTBUILD_DIR" CodexBar arm64 \ + 2>"$TEMP_DIR/missing-file.log"; then + echo "ERROR: Missing reported product unexpectedly fell back to legacy output." >&2 + exit 1 +fi +grep -Fq "$SWIFTBUILD_DIR/CodexBar" "$TEMP_DIR/missing-file.log" + +rm -rf "$SWIFTBUILD_DIR/Sparkle.framework" +if codexbar_require_product_directory "$SWIFTBUILD_DIR" Sparkle.framework packaging \ + 2>"$TEMP_DIR/missing-directory.log"; then + echo "ERROR: Missing reported framework was accepted." >&2 + exit 1 +fi +grep -Fq "$SWIFTBUILD_DIR/Sparkle.framework" "$TEMP_DIR/missing-directory.log" + +rm -rf "$SWIFTBUILD_DIR/CodexBar.dSYM" +if codexbar_resolve_dsym_path "$STAGE_ROOT" "$SWIFTBUILD_DIR" CodexBar arm64 \ + 2>"$TEMP_DIR/missing-dsym.log"; then + echo "ERROR: Missing reported dSYM unexpectedly fell back to legacy output." >&2 + exit 1 +fi +grep -Fq "$SWIFTBUILD_DIR/CodexBar.dSYM" "$TEMP_DIR/missing-dsym.log" + +swift() { + [[ "$*" == "build --show-bin-path -c release --arch arm64" ]] + printf '%s\n' "$SWIFTBUILD_DIR" +} +reported=$(codexbar_swiftpm_bin_path release arm64) +[[ "$reported" == "$SWIFTBUILD_DIR" ]] + +swift() { + return 23 +} +if codexbar_swiftpm_bin_path release arm64 2>"$TEMP_DIR/query.log"; then + echo "ERROR: SwiftPM bin-path query failure was ignored." >&2 + exit 1 +fi +grep -Fq "SwiftPM failed to report" "$TEMP_DIR/query.log" + +swift() { + return 0 +} +if codexbar_swiftpm_bin_path release arm64 2>"$TEMP_DIR/empty.log"; then + echo "ERROR: Empty SwiftPM bin path was accepted." >&2 + exit 1 +fi +grep -Fq "SwiftPM reported an empty" "$TEMP_DIR/empty.log" + +echo "Package product path tests passed." diff --git a/Scripts/test_package_signing.sh b/Scripts/test_package_signing.sh new file mode 100755 index 0000000000..507965f929 --- /dev/null +++ b/Scripts/test_package_signing.sh @@ -0,0 +1,79 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT=$(cd "$(dirname "$0")/.." && pwd) +PACKAGE_SCRIPT="$ROOT/Scripts/package_app.sh" +RELEASE_SCRIPT="$ROOT/Scripts/sign-and-notarize.sh" +FUNCTIONS_FILE=$(mktemp "${TMPDIR:-/tmp}/codexbar-package-signing-functions.XXXXXX") +trap 'rm -f "$FUNCTIONS_FILE"' EXIT + +python3 - "$PACKAGE_SCRIPT" "$FUNCTIONS_FILE" <<'PY' +import sys +from pathlib import Path + +script = Path(sys.argv[1]).read_text() +functions = [] +for name in ( + 'resolve_package_signing_mode', + 'verify_no_quarantine_attribute', + 'verify_packaged_app_integrity', +): + start = script.index(f'{name}() {{') + end = script.index('\n}\n', start) + 3 + functions.append(script[start:end]) +Path(sys.argv[2]).write_text('\n\n'.join(functions)) +PY + +source "$FUNCTIONS_FILE" + +unset CODEXBAR_SIGNING +SIGNING_MODE= +resolve_package_signing_mode +[[ "$SIGNING_MODE" == "adhoc" ]] + +CODEXBAR_SIGNING=identity +resolve_package_signing_mode +[[ "$SIGNING_MODE" == "identity" ]] + +CODEXBAR_SIGNING=invalid +if resolve_package_signing_mode 2>/dev/null; then + echo "Invalid package signing mode unexpectedly succeeded" >&2 + exit 1 +fi + +grep -Fq 'CODEXBAR_SIGNING=identity ./Scripts/package_app.sh release' "$RELEASE_SCRIPT" + +TEMP_DIR=$(mktemp -d "${TMPDIR:-/tmp}/codexbar-package-signing.XXXXXX") +trap 'rm -f "$FUNCTIONS_FILE"; rm -rf "$TEMP_DIR"' EXIT +APP="$TEMP_DIR/CodexBar.app" +mkdir -p "$APP/Contents/Frameworks/Sparkle.framework" + +xattr() { + if [[ "${MOCK_QUARANTINE:-0}" == "1" ]]; then + printf '0081;fake;Safari;https://example.invalid\n' + return 0 + fi + return 1 +} + +codesign() { + return "${MOCK_CODESIGN_STATUS:-0}" +} + +verify_packaged_app_integrity "$APP" + +export MOCK_QUARANTINE=1 +if verify_packaged_app_integrity "$APP" 2>/dev/null; then + echo "Quarantined app unexpectedly passed integrity verification" >&2 + exit 1 +fi +unset MOCK_QUARANTINE + +export MOCK_CODESIGN_STATUS=1 +if verify_packaged_app_integrity "$APP" 2>/dev/null; then + echo "App with an invalid signature unexpectedly passed integrity verification" >&2 + exit 1 +fi +unset MOCK_CODESIGN_STATUS + +echo "Package signing tests passed." diff --git a/Scripts/test_package_strip.sh b/Scripts/test_package_strip.sh new file mode 100755 index 0000000000..b8901d1786 --- /dev/null +++ b/Scripts/test_package_strip.sh @@ -0,0 +1,45 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT=$(cd "$(dirname "$0")/.." && pwd) +PACKAGE_SCRIPT="$ROOT/Scripts/package_app.sh" +FUNCTIONS_FILE=$(mktemp "${TMPDIR:-/tmp}/codexbar-package-strip-functions.XXXXXX") +TEMP_DIR=$(mktemp -d "${TMPDIR:-/tmp}/codexbar-package-strip.XXXXXX") +trap 'rm -rf "$FUNCTIONS_FILE" "$TEMP_DIR"' EXIT + +python3 - "$PACKAGE_SCRIPT" "$FUNCTIONS_FILE" <<'PY' +import sys +from pathlib import Path + +script = Path(sys.argv[1]).read_text() +start = script.index('strip_release_binary() {') +end = script.index('\n}\n', start) + 3 +Path(sys.argv[2]).write_text(script[start:end]) +PY + +xcrun() { + [[ "$1" == "strip" && "$2" == "-x" ]] + printf '%s\n' "$3" >> "$STRIP_LOG" +} + +source "$FUNCTIONS_FILE" + +binary="$TEMP_DIR/CodexBar" +touch "$binary" + +STRIP_LOG="$TEMP_DIR/release.log" +LOWER_CONF=release +strip_release_binary "$binary" +grep -Fqx "$binary" "$STRIP_LOG" + +STRIP_LOG="$TEMP_DIR/debug.log" +LOWER_CONF=debug +strip_release_binary "$binary" +[[ ! -e "$STRIP_LOG" ]] + +STRIP_LOG="$TEMP_DIR/missing.log" +LOWER_CONF=release +strip_release_binary "$TEMP_DIR/MissingBinary" +[[ ! -e "$STRIP_LOG" ]] + +echo "Package strip tests passed." diff --git a/Scripts/test_release_dsym_paths.sh b/Scripts/test_release_dsym_paths.sh new file mode 100755 index 0000000000..353fb6df75 --- /dev/null +++ b/Scripts/test_release_dsym_paths.sh @@ -0,0 +1,108 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT=$(cd "$(dirname "$0")/.." && pwd) +source "$ROOT/Scripts/release_dsym_paths.sh" + +TEMP_DIR=$(mktemp -d "${TMPDIR:-/tmp}/codexbar-release-dsym-paths.XXXXXX") +trap 'rm -rf "$TEMP_DIR"' EXIT + +make_dsym() { + local dsym_path="$1" + mkdir -p "$dsym_path/Contents/Resources/DWARF" + touch "$dsym_path/Contents/Resources/DWARF/CodexBar" +} + +ARM_DSYM="$TEMP_DIR/CodexBar arm64.dSYM" +UNIVERSAL_DSYM="$TEMP_DIR/CodexBar universal.dSYM" +WRONG_ARCH_DSYM="$TEMP_DIR/CodexBar stale.dSYM" +MISSING_DWARF_DSYM="$TEMP_DIR/CodexBar missing.dSYM" +APP_BINARY="$TEMP_DIR/CodexBar.app" +MATCHING_DWARF="$TEMP_DIR/CodexBar matching" +MISMATCHED_DWARF="$TEMP_DIR/CodexBar mismatched" +MISSING_UUID_DWARF="$TEMP_DIR/CodexBar missing UUID" +make_dsym "$ARM_DSYM" +make_dsym "$UNIVERSAL_DSYM" +make_dsym "$WRONG_ARCH_DSYM" +mkdir -p "$MISSING_DWARF_DSYM/Contents/Resources/DWARF" +touch "$APP_BINARY" "$MATCHING_DWARF" "$MISMATCHED_DWARF" "$MISSING_UUID_DWARF" + +lipo() { + [[ "$1" == "-archs" ]] + case "$2" in + "$ARM_DSYM/Contents/Resources/DWARF/CodexBar") + printf '%s\n' "arm64" + ;; + "$UNIVERSAL_DSYM/Contents/Resources/DWARF/CodexBar") + printf '%s\n' "arm64 x86_64" + ;; + "$WRONG_ARCH_DSYM/Contents/Resources/DWARF/CodexBar") + printf '%s\n' "x86_64" + ;; + *) + echo "unexpected lipo path: $2" >&2 + return 2 + ;; + esac +} + +dwarfdump() { + [[ "$1" == "--uuid" ]] + case "$2" in + "$APP_BINARY" | "$MATCHING_DWARF") + printf '%s\n' \ + "UUID: AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA (arm64) $2" \ + "UUID: BBBBBBBB-BBBB-BBBB-BBBB-BBBBBBBBBBBB (x86_64) $2" + ;; + "$MISMATCHED_DWARF") + printf '%s\n' \ + "UUID: AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA (arm64) $2" \ + "UUID: CCCCCCCC-CCCC-CCCC-CCCC-CCCCCCCCCCCC (x86_64) $2" + ;; + "$MISSING_UUID_DWARF") + printf '%s\n' "UUID: AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA (arm64) $2" + ;; + *) + echo "unexpected dwarfdump path: $2" >&2 + return 2 + ;; + esac +} + +arm_dwarf=$(codexbar_require_dsym_dwarf_for_arch "$ARM_DSYM" CodexBar arm64) +[[ "$arm_dwarf" == "$ARM_DSYM/Contents/Resources/DWARF/CodexBar" ]] + +x86_dwarf=$(codexbar_require_dsym_dwarf_for_arch "$UNIVERSAL_DSYM" CodexBar x86_64) +[[ "$x86_dwarf" == "$UNIVERSAL_DSYM/Contents/Resources/DWARF/CodexBar" ]] + +if codexbar_require_dsym_dwarf_for_arch "$MISSING_DWARF_DSYM" CodexBar arm64 \ + 2>"$TEMP_DIR/missing-dwarf.log"; then + echo "ERROR: Missing dSYM DWARF file was accepted." >&2 + exit 1 +fi +grep -Fq "$MISSING_DWARF_DSYM/Contents/Resources/DWARF/CodexBar" "$TEMP_DIR/missing-dwarf.log" + +if codexbar_require_dsym_dwarf_for_arch "$WRONG_ARCH_DSYM" CodexBar arm64 \ + 2>"$TEMP_DIR/wrong-arch.log"; then + echo "ERROR: Wrong-architecture dSYM was accepted." >&2 + exit 1 +fi +grep -Fq "required architecture: arm64" "$TEMP_DIR/wrong-arch.log" + +codexbar_verify_dsym_matches_binary "$APP_BINARY" "$MATCHING_DWARF" arm64 x86_64 + +if codexbar_verify_dsym_matches_binary "$APP_BINARY" "$MISMATCHED_DWARF" arm64 x86_64 \ + 2>"$TEMP_DIR/mismatched-uuid.log"; then + echo "ERROR: Mismatched dSYM UUID was accepted." >&2 + exit 1 +fi +grep -Fq "dSYM UUID mismatch for x86_64" "$TEMP_DIR/mismatched-uuid.log" + +if codexbar_verify_dsym_matches_binary "$APP_BINARY" "$MISSING_UUID_DWARF" arm64 x86_64 \ + 2>"$TEMP_DIR/missing-uuid.log"; then + echo "ERROR: Missing dSYM UUID was accepted." >&2 + exit 1 +fi +grep -Fq "Missing UUID for x86_64" "$TEMP_DIR/missing-uuid.log" + +echo "Release dSYM path tests passed." diff --git a/Scripts/test_repository_size.sh b/Scripts/test_repository_size.sh new file mode 100755 index 0000000000..fb760d636d --- /dev/null +++ b/Scripts/test_repository_size.sh @@ -0,0 +1,82 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +TEMP_DIR=$(mktemp -d "${TMPDIR:-/tmp}/codexbar-repository-size.XXXXXX") +trap 'rm -rf "$TEMP_DIR"' EXIT + +mkdir -p "$TEMP_DIR/Scripts" +cp "$ROOT_DIR/Scripts/check_repository_size.sh" "$TEMP_DIR/Scripts/" +git -C "$TEMP_DIR" init --quiet +empty_output=$("$TEMP_DIR/Scripts/check_repository_size.sh") +grep -Fq 'repository size OK: 0 tracked files' <<<"$empty_output" + +printf 'small source file\n' > "$TEMP_DIR/source.txt" +git -C "$TEMP_DIR" add source.txt Scripts/check_repository_size.sh + +"$TEMP_DIR/Scripts/check_repository_size.sh" >/dev/null + +dd if=/dev/zero of="$TEMP_DIR/untracked.bin" bs=1024 count=2049 2>/dev/null +"$TEMP_DIR/Scripts/check_repository_size.sh" >/dev/null + +dd if=/dev/zero of="$TEMP_DIR/boundary.bin" bs=1024 count=2048 2>/dev/null +git -C "$TEMP_DIR" add boundary.bin +"$TEMP_DIR/Scripts/check_repository_size.sh" >/dev/null +printf 'x' >> "$TEMP_DIR/boundary.bin" +git -C "$TEMP_DIR" add boundary.bin +if "$TEMP_DIR/Scripts/check_repository_size.sh" >"$TEMP_DIR/large.log" 2>&1; then + printf 'ERROR: staged blob one byte above the limit was accepted.\n' >&2 + exit 1 +fi +grep -Fq 'tracked file exceeds 2097152 bytes: boundary.bin (2097153 bytes)' "$TEMP_DIR/large.log" + +git -C "$TEMP_DIR" rm --cached --force --quiet boundary.bin +git -C "$TEMP_DIR" add untracked.bin +printf 'working tree is now small\n' > "$TEMP_DIR/untracked.bin" +if "$TEMP_DIR/Scripts/check_repository_size.sh" >"$TEMP_DIR/staged-large.log" 2>&1; then + printf 'ERROR: oversized staged blob was accepted after its working-tree file changed.\n' >&2 + exit 1 +fi +grep -Fq 'tracked file exceeds 2097152 bytes: untracked.bin (2098176 bytes)' "$TEMP_DIR/staged-large.log" + +git -C "$TEMP_DIR" rm --cached --force --quiet untracked.bin +printf 'small staged blob\n' > "$TEMP_DIR/index-is-authoritative.bin" +git -C "$TEMP_DIR" add index-is-authoritative.bin +dd if=/dev/zero of="$TEMP_DIR/index-is-authoritative.bin" bs=1024 count=2049 2>/dev/null +"$TEMP_DIR/Scripts/check_repository_size.sh" >/dev/null + +odd_path=$'odd\nname.txt' +printf 'small source file\n' > "$TEMP_DIR/$odd_path" +git -C "$TEMP_DIR" add "$odd_path" +"$TEMP_DIR/Scripts/check_repository_size.sh" >/dev/null + +artifacts=( + "CodexBar 2.app/Contents/MacOS/CodexBar" + "CodexBar.dSYM/Contents/Info.plist" + "CodexBar.xcarchive/Products/Applications/CodexBar.app/Contents/Info.plist" + "CodexBar.xcresult/Data/data" + "CodexBar.ipa" + "CodexBar.zip" + "CodexBar.delta" + "CodexBar.dmg" + "CodexBar.pkg" + "CodexBar.tar.gz" + "CodexBar.tgz" +) +for artifact in "${artifacts[@]}"; do + mkdir -p "$TEMP_DIR/$(dirname "$artifact")" + printf 'release artifact\n' > "$TEMP_DIR/$artifact" + git -C "$TEMP_DIR" add -f "$artifact" +done +ln -s source.txt "$TEMP_DIR/CodexBar-latest.dmg" +git -C "$TEMP_DIR" add -f CodexBar-latest.dmg +rm "$TEMP_DIR/CodexBar.zip" +if "$TEMP_DIR/Scripts/check_repository_size.sh" >"$TEMP_DIR/artifact.log" 2>&1; then + printf 'ERROR: tracked release artifacts were accepted.\n' >&2 + exit 1 +fi +for artifact in "${artifacts[@]}" CodexBar-latest.dmg; do + grep -Fq "generated artifact is tracked: $artifact" "$TEMP_DIR/artifact.log" +done + +printf 'Repository size tests passed.\n' diff --git a/Scripts/test_sparkle_signing_paths.sh b/Scripts/test_sparkle_signing_paths.sh new file mode 100755 index 0000000000..f450e47288 --- /dev/null +++ b/Scripts/test_sparkle_signing_paths.sh @@ -0,0 +1,129 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT=$(cd "$(dirname "$0")/.." && pwd) +source "$ROOT/Scripts/sparkle_signing_paths.sh" + +TEMP_DIR=$(mktemp -d "${TMPDIR:-/tmp}/codexbar-sparkle-signing.XXXXXX") +TEMP_DIR=$(cd "$TEMP_DIR" && pwd -P) +trap 'rm -rf "$TEMP_DIR"' EXIT + +make_sparkle_version() { + local sparkle="$1" + local version="$2" + local version_dir="$sparkle/Versions/$version" + + mkdir -p \ + "$version_dir/Updater.app/Contents/MacOS" \ + "$version_dir/XPCServices/Downloader.xpc/Contents/MacOS" \ + "$version_dir/XPCServices/Installer.xpc/Contents/MacOS" + touch \ + "$version_dir/Sparkle" \ + "$version_dir/Autoupdate" \ + "$version_dir/Updater.app/Contents/MacOS/Updater" \ + "$version_dir/XPCServices/Downloader.xpc/Contents/MacOS/Downloader" \ + "$version_dir/XPCServices/Installer.xpc/Contents/MacOS/Installer" +} + +SINGLE="$TEMP_DIR/Single Sparkle.framework" +make_sparkle_version "$SINGLE" B +single_version=$(codexbar_sparkle_version_dir "$SINGLE") +[[ "$single_version" == "$SINGLE/Versions/B" ]] + +single_targets=$(codexbar_sparkle_signing_targets "$SINGLE") +grep -Fqx "$SINGLE" <<<"$single_targets" +grep -Fqx "$SINGLE/Versions/B/Sparkle" <<<"$single_targets" +grep -Fqx "$SINGLE/Versions/B/XPCServices/Installer.xpc/Contents/MacOS/Installer" <<<"$single_targets" + +CURRENT="$TEMP_DIR/Current Sparkle.framework" +make_sparkle_version "$CURRENT" A +make_sparkle_version "$CURRENT" C +ln -s C "$CURRENT/Versions/Current" +current_version=$(codexbar_sparkle_version_dir "$CURRENT") +[[ "$current_version" == "$CURRENT/Versions/C" ]] + +rm "$CURRENT/Versions/C/Autoupdate" +if codexbar_sparkle_signing_targets "$CURRENT" >"$TEMP_DIR/missing-target.out" 2>"$TEMP_DIR/missing-target.log"; then + echo "ERROR: Missing Sparkle signing target was accepted." >&2 + exit 1 +fi +grep -Fq "Autoupdate" "$TEMP_DIR/missing-target.log" + +AMBIGUOUS="$TEMP_DIR/Ambiguous Sparkle.framework" +make_sparkle_version "$AMBIGUOUS" A +make_sparkle_version "$AMBIGUOUS" B +if codexbar_sparkle_version_dir "$AMBIGUOUS" 2>"$TEMP_DIR/ambiguous.log"; then + echo "ERROR: Ambiguous Sparkle versions were accepted without Versions/Current." >&2 + exit 1 +fi +grep -Fq "multiple version directories" "$TEMP_DIR/ambiguous.log" + +BROKEN_CURRENT="$TEMP_DIR/Broken Current Sparkle.framework" +make_sparkle_version "$BROKEN_CURRENT" B +ln -s Missing "$BROKEN_CURRENT/Versions/Current" +if codexbar_sparkle_version_dir "$BROKEN_CURRENT" 2>"$TEMP_DIR/broken-current.log"; then + echo "ERROR: Broken Sparkle Versions/Current was accepted." >&2 + exit 1 +fi +grep -Fq "Versions/Current does not resolve" "$TEMP_DIR/broken-current.log" + +ESCAPING_CURRENT="$TEMP_DIR/Escaping Current Sparkle.framework" +OUTSIDE_SPARKLE="$TEMP_DIR/Outside Sparkle.framework" +make_sparkle_version "$ESCAPING_CURRENT" B +make_sparkle_version "$OUTSIDE_SPARKLE" C +ln -s "$OUTSIDE_SPARKLE/Versions/C" "$ESCAPING_CURRENT/Versions/Current" +if codexbar_sparkle_version_dir "$ESCAPING_CURRENT" 2>"$TEMP_DIR/escaping-current.log"; then + echo "ERROR: Escaping Sparkle Versions/Current was accepted." >&2 + exit 1 +fi +grep -Fq "outside the framework versions directory" "$TEMP_DIR/escaping-current.log" + +SYMLINKED_VERSIONS="$TEMP_DIR/Symlinked Versions Sparkle.framework" +mkdir -p "$SYMLINKED_VERSIONS" +ln -s "$OUTSIDE_SPARKLE/Versions" "$SYMLINKED_VERSIONS/Versions" +if codexbar_sparkle_version_dir "$SYMLINKED_VERSIONS" 2>"$TEMP_DIR/symlinked-versions.log"; then + echo "ERROR: Symlinked Sparkle Versions directory was accepted." >&2 + exit 1 +fi +grep -Fq "versions directory must not be a symlink" "$TEMP_DIR/symlinked-versions.log" + +SYMLINKED_FRAMEWORK="$TEMP_DIR/Symlinked Sparkle.framework" +ln -s "$OUTSIDE_SPARKLE" "$SYMLINKED_FRAMEWORK" +if codexbar_sparkle_version_dir "$SYMLINKED_FRAMEWORK" 2>"$TEMP_DIR/symlinked-framework.log"; then + echo "ERROR: Symlinked Sparkle framework root was accepted." >&2 + exit 1 +fi +grep -Fq "framework root must not be a symlink" "$TEMP_DIR/symlinked-framework.log" + +SYMLINKED_TARGET="$TEMP_DIR/Symlinked Target Sparkle.framework" +make_sparkle_version "$SYMLINKED_TARGET" B +rm "$SYMLINKED_TARGET/Versions/B/Autoupdate" +ln -s "$OUTSIDE_SPARKLE/Versions/C/Autoupdate" "$SYMLINKED_TARGET/Versions/B/Autoupdate" +if codexbar_sparkle_signing_targets \ + "$SYMLINKED_TARGET" >"$TEMP_DIR/symlinked-target.out" 2>"$TEMP_DIR/symlinked-target.log"; then + echo "ERROR: Symlinked Sparkle signing target was accepted." >&2 + exit 1 +fi +grep -Fq "signing target must not be a symlink" "$TEMP_DIR/symlinked-target.log" + +ESCAPING_TARGET_PARENT="$TEMP_DIR/Escaping Target Parent Sparkle.framework" +make_sparkle_version "$ESCAPING_TARGET_PARENT" B +mv "$ESCAPING_TARGET_PARENT/Versions/B/XPCServices" "$TEMP_DIR/displaced-xpc-services" +ln -s "$OUTSIDE_SPARKLE/Versions/C/XPCServices" "$ESCAPING_TARGET_PARENT/Versions/B/XPCServices" +if codexbar_sparkle_signing_targets \ + "$ESCAPING_TARGET_PARENT" >"$TEMP_DIR/escaping-target-parent.out" 2>"$TEMP_DIR/escaping-target-parent.log"; then + echo "ERROR: Sparkle signing target with an escaping parent was accepted." >&2 + exit 1 +fi +grep -Fq "signing target resolves outside its trusted root" "$TEMP_DIR/escaping-target-parent.log" + +ESCAPING_SINGLE="$TEMP_DIR/Escaping Single Sparkle.framework" +mkdir -p "$ESCAPING_SINGLE/Versions" +ln -s "$OUTSIDE_SPARKLE/Versions/C" "$ESCAPING_SINGLE/Versions/B" +if codexbar_sparkle_version_dir "$ESCAPING_SINGLE" 2>"$TEMP_DIR/escaping-single.log"; then + echo "ERROR: Escaping single Sparkle version directory was accepted." >&2 + exit 1 +fi +grep -Fq "outside the framework versions directory" "$TEMP_DIR/escaping-single.log" + +echo "Sparkle signing path tests passed." diff --git a/Scripts/test_swift_test_sharding.sh b/Scripts/test_swift_test_sharding.sh new file mode 100755 index 0000000000..768357fd28 --- /dev/null +++ b/Scripts/test_swift_test_sharding.sh @@ -0,0 +1,250 @@ +#!/usr/bin/env bash + +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +TEMP_DIR="$(mktemp -d "${TMPDIR:-/tmp}/codexbar-test-sharding.XXXXXX")" +trap 'rm -rf "${TEMP_DIR}"' EXIT + +IFS= read -r -d '' FAKE_SWIFT_SCRIPT <<'EOF' || true +set -euo pipefail + +printf '%s\n' "$*" >> "${FAKE_SWIFT_LOG}" +if [[ "$*" == "test list" ]]; then + if [[ "${FAKE_SWIFT_MODE:-success}" == "list_fail" ]]; then + sleep 0.25 + printf 'test-list stdout marker\n' + printf 'test-list stderr marker\n' >&2 + exit 42 + fi + printf '%s\n' \ + "CodexBarTests.Alpha/test_one()" \ + "CodexBarTests.Alpha/test_two(argument:)" \ + "CodexBarTests.Beta/test_two" \ + "CodexBarTests.Gamma/test_three" \ + "CodexBarTests.Delta/test_four" \ + "CodexBarTests.Epsilon/test_five" \ + "CodexBarTests.Zeta/test_six" \ + "CodexBarTests.Eta/test_seven" \ + "CodexBarTests.Theta/test_eight" \ + 'CodexBarTests.`top level works`()' \ + 'CodexBarTests.`top/level slash works`()' + exit 0 +fi + +is_group=0 +if [[ "$*" == *"|"* ]]; then + is_group=1 +fi + +next_group_attempt() { + local attempt=0 + if [[ -f "${FAKE_SWIFT_STATE}" ]]; then + read -r attempt < "${FAKE_SWIFT_STATE}" + fi + attempt=$((attempt + 1)) + printf '%s\n' "${attempt}" > "${FAKE_SWIFT_STATE}" + printf '%s\n' "${attempt}" +} + +case "${FAKE_SWIFT_MODE:-success}" in + group_fail_once) + if [[ "${is_group}" == "1" && "$(next_group_attempt)" == "1" ]]; then + exit 1 + fi + ;; + group_always_fail) + if [[ "${is_group}" == "1" ]]; then + exit 1 + fi + ;; + group_timeout) + if [[ "${is_group}" == "1" ]]; then + sleep 2 + fi + ;; + singleton_timeout) + if [[ "${is_group}" == "0" ]]; then + sleep 2 + fi + ;; + group_fail_then_timeout) + if [[ "${is_group}" == "1" ]]; then + attempt="$(next_group_attempt)" + if [[ "${attempt}" == "1" ]]; then + exit 1 + fi + sleep 2 + fi + ;; +esac +EOF + +reset_case() { + local name="$1" + export FAKE_SWIFT_LOG="${TEMP_DIR}/${name}-swift.log" + export FAKE_SWIFT_STATE="${TEMP_DIR}/${name}-state" + export GITHUB_STEP_SUMMARY="${TEMP_DIR}/${name}-summary.md" + rm -f "${FAKE_SWIFT_LOG}" "${FAKE_SWIFT_STATE}" "${GITHUB_STEP_SUMMARY}" +} + +run_harness() { + python3 "${ROOT_DIR}/Scripts/ci_swift_test_by_suite.py" \ + "$@" \ + --swift-command /bin/bash \ + --swift-command-arg=-c \ + --swift-command-arg="${FAKE_SWIFT_SCRIPT}" \ + --swift-command-arg=fake-swift +} + +python3 - "${ROOT_DIR}/.github/workflows/ci.yml" <<'PY' +import pathlib +import re +import sys + +workflow = pathlib.Path(sys.argv[1]).read_text() +if "types: [opened, synchronize, reopened, ready_for_review, converted_to_draft]" not in workflow: + raise SystemExit("CI must rerun when a pull request becomes ready or draft") +if "CI_PULL_REQUEST_DRAFT: ${{ github.event.pull_request.draft || false }}" not in workflow: + raise SystemExit("CI must pass draft state to the macOS test gate") +if "macos-tests-deferred: ${{ steps.macos-tests.outputs.macos-tests-deferred }}" not in workflow: + raise SystemExit("CI must expose whether macOS tests were deferred") +job_match = re.search(r"(?ms)^ swift-test-macos:\n(?P.*?)(?=^ [a-zA-Z0-9_-]+:|\Z)", workflow) +if not job_match: + raise SystemExit("swift-test-macos job not found in CI workflow") + +job = job_match.group("body") +required_not_deferred = ( + "if: ${{ needs.changes.outputs.macos-tests == 'true' && " + "needs.changes.outputs.macos-tests-deferred != 'true' }}" +) +if required_not_deferred not in job: + raise SystemExit("swift-test-macos must skip only required tests explicitly deferred for drafts") +if not re.search(r"(?m)^\s+shard-index:\s+\[0,\s*1\]\s*$", job): + raise SystemExit("swift-test-macos must run exactly two shard indexes: [0, 1]") +if not re.search(r"(?m)^\s+shard-count:\s+\[2\]\s*$", job): + raise SystemExit("swift-test-macos shard-count must be [2]") +if "CODEXBAR_TEST_SHARD_INDEX=${{ matrix.shard-index }}" not in job: + raise SystemExit("swift-test-macos must pass matrix.shard-index to Scripts/test.sh") +if "CODEXBAR_TEST_SHARD_COUNT=${{ matrix.shard-count }}" not in job: + raise SystemExit("swift-test-macos must pass matrix.shard-count to Scripts/test.sh") +PY + +reset_case retry +export FAKE_SWIFT_MODE=group_fail_once +run_harness --group-size 4 --timeout 10 > "${TEMP_DIR}/retry.log" +grep -Fq "failed with exit code 1; retrying group once" "${TEMP_DIR}/retry.log" +grep -Fq "Swift test timing summary:" "${TEMP_DIR}/retry.log" +grep -Fq '| Discovered selections | `10` |' "${GITHUB_STEP_SUMMARY}" +grep -Fq '| Selected selections | `10` |' "${GITHUB_STEP_SUMMARY}" +grep -Fq '| Selected groups | `3` |' "${GITHUB_STEP_SUMMARY}" +grep -Fq '| First-pass successful groups | `2` |' "${GITHUB_STEP_SUMMARY}" +grep -Fq '| First-pass failed groups | `1` |' "${GITHUB_STEP_SUMMARY}" +grep -Fq '| Full-group retries | `1` |' "${GITHUB_STEP_SUMMARY}" +grep -Fq '| Recovered groups | `1` |' "${GITHUB_STEP_SUMMARY}" +[[ "$(grep -c '^test --skip-build --no-parallel' "${FAKE_SWIFT_LOG}")" -eq 4 ]] +grep -Fq "CodexBarTests\\.Alpha" "${FAKE_SWIFT_LOG}" +grep -Fq "CodexBarTests\\.Beta" "${FAKE_SWIFT_LOG}" +grep -Fq "CodexBarTests\\..*top\\ level\\ works" "${FAKE_SWIFT_LOG}" +grep -Fq "CodexBarTests\\..*top/level\\ slash\\ works" "${FAKE_SWIFT_LOG}" +[[ "$(wc -l < "${FAKE_SWIFT_LOG}")" -eq 5 ]] + +reset_case strict +export FAKE_SWIFT_MODE=group_fail_once +set +e +CODEXBAR_TEST_GROUP_SIZE=4 \ + CODEXBAR_TEST_SUITE_TIMEOUT=10 \ + CODEXBAR_TEST_RETRY_NON_TIMEOUT_FAILURES=0 \ + "${ROOT_DIR}/Scripts/test.sh" \ + --limit-groups 1 \ + --swift-command /bin/bash \ + --swift-command-arg=-c \ + --swift-command-arg="${FAKE_SWIFT_SCRIPT}" \ + --swift-command-arg=fake-swift \ + > "${TEMP_DIR}/strict.log" 2>&1 +strict_status=$? +set -e +[[ "${strict_status}" -eq 1 ]] +grep -Fq '| First-pass failed groups | `1` |' "${GITHUB_STEP_SUMMARY}" +grep -Fq '| Full-group retries | `0` |' "${GITHUB_STEP_SUMMARY}" +grep -Fq '| Isolated selection retries | `0` |' "${GITHUB_STEP_SUMMARY}" +[[ "$(wc -l < "${FAKE_SWIFT_LOG}")" -eq 2 ]] + +reset_case shard-0 +export FAKE_SWIFT_MODE=success +run_harness --group-size 4 --timeout 10 --shard-index 0 --shard-count 2 > "${TEMP_DIR}/shard-0.log" +grep -Fq '| Shard | `1/2` |' "${GITHUB_STEP_SUMMARY}" +grep -Fq '| Selected selections | `6` |' "${GITHUB_STEP_SUMMARY}" +grep -Fq '| Selected groups | `2` |' "${GITHUB_STEP_SUMMARY}" + +reset_case shard-1 +run_harness --group-size 4 --timeout 10 --shard-index 1 --shard-count 2 > "${TEMP_DIR}/shard-1.log" +grep -Fq '| Shard | `2/2` |' "${GITHUB_STEP_SUMMARY}" +grep -Fq '| Selected selections | `4` |' "${GITHUB_STEP_SUMMARY}" +grep -Fq '| Selected groups | `1` |' "${GITHUB_STEP_SUMMARY}" + +reset_case shard-list-0 +run_harness --group-size 4 --timeout 10 --shard-index 0 --shard-count 2 --list-only \ + > "${TEMP_DIR}/shard-list-0.log" +reset_case shard-list-1 +run_harness --group-size 4 --timeout 10 --shard-index 1 --shard-count 2 --list-only \ + > "${TEMP_DIR}/shard-list-1.log" +cat "${TEMP_DIR}/shard-list-0.log" "${TEMP_DIR}/shard-list-1.log" \ + | grep -v '^Discovered ' \ + | sort > "${TEMP_DIR}/shards-combined.log" +reset_case shard-list-all +run_harness --group-size 4 --timeout 10 --list-only \ + | grep -v '^Discovered ' \ + | sort > "${TEMP_DIR}/shards-expected.log" +diff -u "${TEMP_DIR}/shards-expected.log" "${TEMP_DIR}/shards-combined.log" + +reset_case group-timeout +export FAKE_SWIFT_MODE=group_timeout +run_harness --group-size 4 --limit-groups 1 --timeout 1 > "${TEMP_DIR}/group-timeout.log" +grep -Fq "timed out; retrying selections one at a time" "${TEMP_DIR}/group-timeout.log" +grep -Fq '| Timed out groups | `1` |' "${GITHUB_STEP_SUMMARY}" +grep -Fq '| Recovered groups | `1` |' "${GITHUB_STEP_SUMMARY}" +grep -Fq '| Isolated selection retries | `4` |' "${GITHUB_STEP_SUMMARY}" + +reset_case singleton-timeout +export FAKE_SWIFT_MODE=singleton_timeout +set +e +run_harness --group-size 1 --limit-groups 1 --timeout 1 > "${TEMP_DIR}/singleton-timeout.log" 2>&1 +singleton_timeout_status=$? +set -e +[[ "${singleton_timeout_status}" -eq 124 ]] +grep -Fq '| Timed out groups | `1` |' "${GITHUB_STEP_SUMMARY}" +grep -Fq '| Recovered groups | `0` |' "${GITHUB_STEP_SUMMARY}" +grep -Fq '| Isolated selection retries | `0` |' "${GITHUB_STEP_SUMMARY}" + +reset_case retry-timeout +export FAKE_SWIFT_MODE=group_fail_then_timeout +run_harness --group-size 4 --limit-groups 1 --timeout 1 > "${TEMP_DIR}/retry-timeout.log" +grep -Fq '| Full-group retries | `1` |' "${GITHUB_STEP_SUMMARY}" +grep -Fq '| Timed out groups | `1` |' "${GITHUB_STEP_SUMMARY}" +grep -Fq '| Recovered groups | `1` |' "${GITHUB_STEP_SUMMARY}" +grep -Fq '| Isolated selection retries | `4` |' "${GITHUB_STEP_SUMMARY}" + +reset_case repeated-failure +export FAKE_SWIFT_MODE=group_always_fail +set +e +run_harness --group-size 4 --limit-groups 1 --timeout 10 > "${TEMP_DIR}/failure.log" 2>&1 +failure_status=$? +set -e +[[ "${failure_status}" -eq 1 ]] +grep -Fq '| Full-group retries | `1` |' "${GITHUB_STEP_SUMMARY}" +grep -Fq '| Recovered groups | `0` |' "${GITHUB_STEP_SUMMARY}" + +reset_case list-failure +export FAKE_SWIFT_MODE=list_fail +set +e +run_harness --group-size 1 --timeout 10 > "${TEMP_DIR}/list-failure.log" 2>&1 +list_failure_status=$? +set -e +[[ "${list_failure_status}" -ne 0 ]] +grep -Fq "test-list stdout marker" "${TEMP_DIR}/list-failure.log" +grep -Fq "test-list stderr marker" "${TEMP_DIR}/list-failure.log" +grep -Eq -- '- Discovery seconds: 0\.[1-9]' "${TEMP_DIR}/list-failure.log" +grep -Fq '| Discovered selections | `0` |' "${GITHUB_STEP_SUMMARY}" + +echo "Swift test sharding tests passed." diff --git a/Scripts/verify_1844_live.sh b/Scripts/verify_1844_live.sh new file mode 100755 index 0000000000..61bd283aaf --- /dev/null +++ b/Scripts/verify_1844_live.sh @@ -0,0 +1,262 @@ +#!/usr/bin/env bash +# Isolated live verification for CodexBar #1844 / PR #1848. +# Uses only synthetic credentials under a disposable HOME and keychain. +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +cd "$ROOT" + +log() { printf '[verify-1844] %s\n' "$*"; } + +ARTIFACT="$(mktemp -d "${TMPDIR:-/tmp}/codexbar-1844-verify.XXXXXX")" +chmod 700 "$ARTIFACT" +HOME_FIXTURE="$ARTIFACT/home" +KEYCHAIN="$ARTIFACT/claude-fixture.keychain-db" +KEYCHAIN_PASSWORD="codexbar-1844-synthetic-fixture" +CONFIG="$ARTIFACT/config.json" +CLI="${CODEXBAR_CLI:-$ROOT/CodexBar.app/Contents/Helpers/CodexBarCLI}" +APP="${CODEXBAR_APP_BINARY:-$ROOT/CodexBar.app/Contents/MacOS/CodexBar}" +MCP_PAYLOAD='{"mcpOAuth":{"plugin:synthetic":{"accessToken":"synthetic-mcp-token"}}}' +EXPIRED_PAYLOAD='{"claudeAiOauth":{"accessToken":"synthetic-expired-token","expiresAt":1000,"scopes":["user:profile"],"refreshToken":"synthetic-refresh-token"}}' + +if [[ ! -x "$CLI" ]]; then + log "Missing packaged CLI: $CLI" + log "Run ./Scripts/package_app.sh, then retry." + exit 2 +fi +if [[ ! -x "$APP" ]]; then + log "Missing packaged app binary: $APP" + exit 2 +fi + +cleanup() { + /usr/bin/security delete-keychain "$KEYCHAIN" >/dev/null 2>&1 || true +} +trap cleanup EXIT + +log "Artifacts: $ARTIFACT" +log "Phase 1: focused integration tests" +{ + swift test --filter ClaudeOAuthTests + swift test --filter ClaudeUsageTests + swift test --filter ClaudeOAuthDelegatedRefreshCoordinatorTests + swift test --filter 'expired claude CLI owner blocks background' + swift test --filter ClaudeOAuthCredentialsStoreSecurityCLITests + swift test --filter ClaudeOAuthCredentialsStoreIsolatedSecurityCLITests + swift test --filter ClaudeOAuthCredentialsStoreMCPOnlyGuardTests +} 2>&1 | tee "$ARTIFACT/integration-tests.log" +log "Phase 1 passed" + +log "Phase 2: disposable HOME, keychain, credentials, config, and Claude CLI canary" +mkdir -p "$HOME_FIXTURE/.claude" "$HOME_FIXTURE/Library/Preferences" "$ARTIFACT/bin" +chmod 700 "$HOME_FIXTURE" "$HOME_FIXTURE/.claude" "$HOME_FIXTURE/Library" \ + "$HOME_FIXTURE/Library/Preferences" "$ARTIFACT/bin" +printf '%s\n' "$EXPIRED_PAYLOAD" >"$HOME_FIXTURE/.claude/.credentials.json" +chmod 600 "$HOME_FIXTURE/.claude/.credentials.json" +printf '%s\n' '{"version":1,"providers":[{"id":"claude","enabled":true,"source":"oauth"}]}' >"$CONFIG" +chmod 600 "$CONFIG" +printf '%s\n' \ + '#!/usr/bin/env bash' \ + 'printf "args:" >>"$CODEXBAR_CLAUDE_INVOCATIONS"' \ + 'printf " %q" "$@" >>"$CODEXBAR_CLAUDE_INVOCATIONS"' \ + 'printf "\\n" >>"$CODEXBAR_CLAUDE_INVOCATIONS"' \ + 'if [[ "$*" == "auth status --json" ]]; then printf "{\"loggedIn\":true}\\n"; exit 0; fi' \ + 'if [[ "$*" == "--version" ]]; then printf "2.1.0\\n"; exit 0; fi' \ + 'if IFS= read -r line; then' \ + ' printf "stdin:%s\\n" "$line" >>"$CODEXBAR_CLAUDE_INVOCATIONS"' \ + ' if [[ "$line" == *"/status"* ]]; then printf touched >"$CODEXBAR_CLAUDE_TOUCH_CANARY"; fi' \ + 'fi' \ + 'exit 99' \ + >"$ARTIFACT/bin/claude" +printf '%s\n' \ + '#!/usr/bin/env bash' \ + 'printf touched >"$CODEXBAR_OPEN_TOUCH_CANARY"' \ + 'exit 99' \ + >"$ARTIFACT/bin/open" +chmod 700 "$ARTIFACT/bin/claude" "$ARTIFACT/bin/open" + +/usr/bin/security list-keychains -d user >"$ARTIFACT/keychains-before.txt" +/usr/bin/security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN" +/usr/bin/security set-keychain-settings -t 3600 "$KEYCHAIN" +/usr/bin/security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN" +/usr/bin/security add-generic-password \ + -a codexbar-verify-1844 \ + -s 'Claude Code-credentials' \ + -w "$MCP_PAYLOAD" \ + -A \ + "$KEYCHAIN" +/usr/bin/security list-keychains -d user >"$ARTIFACT/keychains-after.txt" +if ! cmp -s "$ARTIFACT/keychains-before.txt" "$ARTIFACT/keychains-after.txt"; then + log "Phase 2 failed: creating the disposable keychain changed the user search list" + exit 1 +fi +/usr/bin/security find-generic-password \ + -s 'Claude Code-credentials' \ + -w \ + "$KEYCHAIN" >"$ARTIFACT/keychain-fixture.json" +cmp -s "$ARTIFACT/keychain-fixture.json" <(printf '%s\n' "$MCP_PAYLOAD") + +PROC_LOG="$ARTIFACT/e2e-processes.log" +STDOUT="$ARTIFACT/e2e-stdout.json" +STDERR="$ARTIFACT/e2e-stderr.jsonl" +CANARY="$ARTIFACT/claude-status-canary" +INVOCATIONS="$ARTIFACT/claude-invocations.log" +OPEN_CANARY="$ARTIFACT/open-touch-canary" +: >"$PROC_LOG" +: >"$INVOCATIONS" + +set +e +( + env \ + HOME="$HOME_FIXTURE" \ + CFFIXED_USER_HOME="$HOME_FIXTURE" \ + CODEXBAR_CONFIG="$CONFIG" \ + CODEXBAR_DISABLE_KEYCHAIN_ACCESS=1 \ + CODEXBAR_CLAUDE_SECURITY_CLI_KEYCHAIN="$KEYCHAIN" \ + CODEXBAR_CLAUDE_TOUCH_CANARY="$CANARY" \ + CODEXBAR_CLAUDE_INVOCATIONS="$INVOCATIONS" \ + CODEXBAR_OPEN_TOUCH_CANARY="$OPEN_CANARY" \ + CODEXBAR_DEBUG_CLAUDE_OAUTH_FLOW=1 \ + CLAUDE_CLI_PATH="$ARTIFACT/bin/claude" \ + PATH="$ARTIFACT/bin:/usr/bin:/bin:/usr/sbin:/sbin" \ + "$CLI" usage --provider claude --source oauth --format json --pretty --log-level debug \ + >"$STDOUT" 2>"$STDERR" +) & +PID=$! +while kill -0 "$PID" 2>/dev/null; do + { + date -u +%H:%M:%S + pgrep -P "$PID" -l 2>/dev/null || true + } >>"$PROC_LOG" + sleep 0.02 +done +wait "$PID" +CLI_STATUS=$? +set -e + +{ + echo "# CodexBar #1844 isolated E2E verification" + echo "date: $(date -u +%Y-%m-%dT%H:%M:%SZ)" + echo "candidate: $(git rev-parse HEAD)" + echo "packaged-cli: $CLI" + echo "cli-exit: $CLI_STATUS" + echo "default-keychain-search-list-unchanged: yes" + echo "real-home-referenced: no" + echo "claude-status-canary: $([[ -e "$CANARY" ]] && echo touched || echo untouched)" + echo "open-touch-canary: $([[ -e "$OPEN_CANARY" ]] && echo touched || echo untouched)" + echo + echo "## stdout" + cat "$STDOUT" + echo + echo "## stderr (filtered)" + rg -i 'mcp|delegated|expired|oauth|touch|open|only prompt|user action' "$STDERR" || true + echo + echo "## Claude CLI invocations" + cat "$INVOCATIONS" + echo + echo "## child processes" + cat "$PROC_LOG" +} | tee "$ARTIFACT/E2E-REPORT.md" + +if [[ "$CLI_STATUS" -eq 0 ]]; then + log "Phase 2 failed: the MCP-only fixture unexpectedly produced successful OAuth usage" + exit 1 +fi +if [[ -e "$CANARY" ]]; then + log "Phase 2 failed: delegated Claude CLI /status touch ran" + exit 1 +fi +if [[ -e "$OPEN_CANARY" ]]; then + log "Phase 2 failed: browser/open helper ran" + exit 1 +fi +if rg -q '/usr/bin/open|(^|/)open$|firefox|Google Chrome|Safari' "$PROC_LOG" 2>/dev/null; then + log "Phase 2 failed: an open helper or browser was a probe child" + exit 1 +fi +if ! rg -qi 'MCP OAuth state only|mcpOAuthOnlyKeychain|MCP OAuth' "$STDERR" "$STDOUT"; then + log "Phase 2 failed: expected MCP-only fail-closed message not found" + exit 1 +fi + +log "Phase 2 passed: exact packaged CLI failed closed without delegated /status touch or browser child" + +log "Phase 3: isolated packaged app runtime smoke" +APP_PROC_LOG="$ARTIFACT/app-processes.log" +APP_STDOUT="$ARTIFACT/app-stdout.log" +APP_STDERR="$ARTIFACT/app-stderr.log" +: >"$APP_PROC_LOG" +: >"$INVOCATIONS" +( + env \ + HOME="$HOME_FIXTURE" \ + CFFIXED_USER_HOME="$HOME_FIXTURE" \ + CODEXBAR_CONFIG="$CONFIG" \ + CODEXBAR_DISABLE_KEYCHAIN_ACCESS=1 \ + CODEXBAR_CLAUDE_SECURITY_CLI_KEYCHAIN="$KEYCHAIN" \ + CODEXBAR_CLAUDE_TOUCH_CANARY="$CANARY" \ + CODEXBAR_CLAUDE_INVOCATIONS="$INVOCATIONS" \ + CODEXBAR_OPEN_TOUCH_CANARY="$OPEN_CANARY" \ + CODEXBAR_DEBUG_CLAUDE_OAUTH_FLOW=1 \ + CLAUDE_CLI_PATH="$ARTIFACT/bin/claude" \ + PATH="$ARTIFACT/bin:/usr/bin:/bin:/usr/sbin:/sbin" \ + "$APP" >"$APP_STDOUT" 2>"$APP_STDERR" +) & +APP_PID=$! +APP_OBSERVED_CLI=0 +POST_DISCOVERY_TICKS=0 +for _ in $(seq 1 1000); do + if ! kill -0 "$APP_PID" 2>/dev/null; then + log "Phase 3 failed: packaged app exited before the isolated startup smoke completed" + wait "$APP_PID" || true + exit 1 + fi + { + date -u +%H:%M:%S + pgrep -P "$APP_PID" -l 2>/dev/null || true + } >>"$APP_PROC_LOG" + if rg -q '^args: --version$' "$INVOCATIONS"; then + APP_OBSERVED_CLI=1 + POST_DISCOVERY_TICKS=$((POST_DISCOVERY_TICKS + 1)) + if [[ "$POST_DISCOVERY_TICKS" -ge 250 ]]; then + break + fi + fi + sleep 0.02 +done +kill "$APP_PID" +wait "$APP_PID" 2>/dev/null || true + +if [[ "$APP_OBSERVED_CLI" -ne 1 ]]; then + log "Phase 3 failed: packaged app never exercised the isolated Claude CLI fixture" + exit 1 +fi +if [[ -e "$CANARY" ]]; then + log "Phase 3 failed: packaged app invoked delegated Claude CLI /status touch" + exit 1 +fi +if [[ -e "$OPEN_CANARY" ]]; then + log "Phase 3 failed: packaged app invoked browser/open helper" + exit 1 +fi +if rg -q '/usr/bin/open|(^|/)open$|firefox|Google Chrome|Safari' "$APP_PROC_LOG" 2>/dev/null; then + log "Phase 3 failed: an open helper or browser was an app child" + exit 1 +fi +{ + echo + echo "## packaged app runtime" + echo "app-binary: $APP" + echo "isolated-claude-cli-discovery-observed: yes" + echo "post-discovery-observation-seconds: 5" + echo "app-stayed-running: yes" + echo "claude-status-canary: untouched" + echo "open-touch-canary: untouched" + echo "browser-child: none" + echo + echo "## packaged app Claude CLI invocations" + cat "$INVOCATIONS" +} | tee -a "$ARTIFACT/E2E-REPORT.md" + +log "Phase 3 passed: packaged app exercised CLI discovery without delegated /status touch or browser child" +log "Report: $ARTIFACT/E2E-REPORT.md" diff --git a/Sources/AdaptiveRefreshCore/AdaptiveRefreshPolicyCore.swift b/Sources/AdaptiveRefreshCore/AdaptiveRefreshPolicyCore.swift new file mode 100644 index 0000000000..b95629ca73 --- /dev/null +++ b/Sources/AdaptiveRefreshCore/AdaptiveRefreshPolicyCore.swift @@ -0,0 +1,103 @@ +import Foundation + +/// Canonical adaptive-refresh decision table shared by the app and offline replay tooling. +/// Platform adapters normalize their thermal signals before calling this type; thresholds and +/// delays live here only. +package struct AdaptiveRefreshPolicyCore: Sendable { + package struct Input: Sendable, Equatable { + package let now: Date + package let lastMenuOpenAt: Date? + package let lastCodingActivityAt: Date? + package let lowPowerModeEnabled: Bool + package let thermalPressure: ThermalPressure + + package init( + now: Date, + lastMenuOpenAt: Date?, + lastCodingActivityAt: Date? = nil, + lowPowerModeEnabled: Bool, + thermalPressure: ThermalPressure) + { + self.now = now + self.lastMenuOpenAt = lastMenuOpenAt + self.lastCodingActivityAt = lastCodingActivityAt + self.lowPowerModeEnabled = lowPowerModeEnabled + self.thermalPressure = thermalPressure + } + } + + package enum ThermalPressure: Sendable, Equatable { + case nominal + case constrained + } + + package enum Reason: String, Sendable, Equatable { + case recentInteraction + case codingActivity + case warm + case idle + case longIdle + case constrained + } + + package struct Decision: Sendable, Equatable { + package let delay: Duration + package let reason: Reason + + fileprivate init(delay: Duration, reason: Reason) { + self.delay = delay + self.reason = reason + } + } + + private static let recentInteractionThreshold: TimeInterval = 5 * 60 + private static let warmThreshold: TimeInterval = 60 * 60 + private static let idleThreshold: TimeInterval = 4 * 60 * 60 + private static let codingActivityThreshold: TimeInterval = 5 * 60 + + private static let recentInteractionDelay: Duration = .seconds(2 * 60) + private static let warmDelay: Duration = .seconds(5 * 60) + private static let idleDelay: Duration = .seconds(15 * 60) + private static let longIdleDelay: Duration = .seconds(30 * 60) + private static let constrainedDelay: Duration = .seconds(30 * 60) + private static let codingActivityDelayCap: Duration = .seconds(5 * 60) + + /// Representative cadence for consumers that need one interval but cannot access live state. + package static let nominalIntervalForHeuristics: TimeInterval = 5 * 60 + + package init() {} + + package func nextDelay(for input: Input) -> Decision { + if input.lowPowerModeEnabled || input.thermalPressure == .constrained { + return Decision(delay: Self.constrainedDelay, reason: .constrained) + } + + let baseDecision = self.menuActivityDecision(for: input) + guard let lastCodingActivityAt = input.lastCodingActivityAt, + input.now.timeIntervalSince(lastCodingActivityAt) < Self.codingActivityThreshold, + baseDecision.delay > Self.codingActivityDelayCap + else { return baseDecision } + + return Decision(delay: Self.codingActivityDelayCap, reason: .codingActivity) + } + + private func menuActivityDecision(for input: Input) -> Decision { + guard let lastMenuOpenAt = input.lastMenuOpenAt else { + return Decision(delay: Self.longIdleDelay, reason: .longIdle) + } + + // A future or clock-adjusted timestamp yields a negative age, which reads as recent. + let age = input.now.timeIntervalSince(lastMenuOpenAt) + + if age <= Self.recentInteractionThreshold { + return Decision(delay: Self.recentInteractionDelay, reason: .recentInteraction) + } + if age <= Self.warmThreshold { + return Decision(delay: Self.warmDelay, reason: .warm) + } + if age < Self.idleThreshold { + return Decision(delay: Self.idleDelay, reason: .idle) + } + return Decision(delay: Self.longIdleDelay, reason: .longIdle) + } +} diff --git a/Sources/AdaptiveReplayCLI/CLIArguments.swift b/Sources/AdaptiveReplayCLI/CLIArguments.swift new file mode 100644 index 0000000000..8b10a8d79a --- /dev/null +++ b/Sources/AdaptiveReplayCLI/CLIArguments.swift @@ -0,0 +1,98 @@ +import AdaptiveReplayKit +import Foundation + +enum ReplayPolicyName: String, CaseIterable, Sendable { + case adaptive + case adaptiveActivity = "adaptive-activity" + case fixed2Minutes = "fixed-2m" + case fixed5Minutes = "fixed-5m" + case fixed15Minutes = "fixed-15m" + case fixed30Minutes = "fixed-30m" + case manual + + var policy: any ReplayPolicy { + switch self { + case .adaptive: + AdaptiveReplayPolicy() + case .adaptiveActivity: + AgentAwareAdaptiveReplayPolicy() + case .fixed2Minutes: + FixedIntervalPolicy(minutes: 2) + case .fixed5Minutes: + FixedIntervalPolicy(minutes: 5) + case .fixed15Minutes: + FixedIntervalPolicy(minutes: 15) + case .fixed30Minutes: + FixedIntervalPolicy(minutes: 30) + case .manual: + ManualPolicy() + } + } + + static var expectedValues: String { + allCases.map(\.rawValue).joined(separator: ", ") + } +} + +enum CLIArguments { + case run( + tracePath: String, + policyNames: [ReplayPolicyName], + jsonOutput: Bool, + gapGraceSeconds: TimeInterval?) + case help(exitCode: Int32) + case invalid(message: String) + + static func parse(_ arguments: [String]) -> Self { + if arguments.contains("-h") || arguments.contains("--help") { + return .help(exitCode: EXIT_SUCCESS) + } + + var tracePath: String? + var policyNames: [ReplayPolicyName] = [] + var jsonOutput = false + var gapGraceSeconds: TimeInterval? = ReplayTraceSegmenter.defaultGraceSeconds + var index = 0 + while index < arguments.count { + let argument = arguments[index] + switch argument { + case "--json": + jsonOutput = true + case "--raw-wall-clock": + gapGraceSeconds = nil + case "--gap-grace": + index += 1 + guard index < arguments.count, + let seconds = TimeInterval(arguments[index]), + seconds >= 0, + seconds.isFinite + else { return .invalid(message: "--gap-grace requires non-negative finite seconds") } + gapGraceSeconds = seconds + case "--policy": + index += 1 + guard index < arguments.count else { return .invalid(message: "--policy requires a value") } + let rawPolicyName = arguments[index] + guard let policyName = ReplayPolicyName(rawValue: rawPolicyName) else { + return .invalid( + message: "unknown policy '\(rawPolicyName)' (expected: \(ReplayPolicyName.expectedValues))") + } + policyNames.append(policyName) + default: + guard tracePath == nil else { + return .invalid(message: "unexpected argument '\(argument)'") + } + tracePath = argument + } + index += 1 + } + + guard let tracePath else { + return .help(exitCode: EXIT_FAILURE) + } + return .run( + tracePath: tracePath, + policyNames: policyNames.isEmpty ? ReplayPolicyName.allCases : policyNames, + jsonOutput: jsonOutput, + gapGraceSeconds: gapGraceSeconds) + } +} diff --git a/Sources/AdaptiveReplayCLI/main.swift b/Sources/AdaptiveReplayCLI/main.swift new file mode 100644 index 0000000000..6c96fbb9b9 --- /dev/null +++ b/Sources/AdaptiveReplayCLI/main.swift @@ -0,0 +1,216 @@ +import AdaptiveReplayKit +import Foundation + +/// Thin CLI shell over `AdaptiveReplayKit`: parses a trace path and a policy name, runs the +/// replay, and prints the resulting `ReplayMetrics`. All parsing/replay/metrics logic lives in +/// the library — this file only routes arguments to it and formats the result. +enum AdaptiveReplayCLI { + static func main() { + let arguments = CLIArguments.parse(Array(CommandLine.arguments.dropFirst())) + + switch arguments { + case let .help(exitCode): + print(Self.helpText) + exit(exitCode) + case let .invalid(message): + FileHandle.standardError.write(Data("error: \(message)\n\n\(Self.helpText)\n".utf8)) + exit(EXIT_FAILURE) + case let .run(tracePath, policyNames, jsonOutput, gapGraceSeconds): + Self.run( + tracePath: tracePath, + policyNames: policyNames, + jsonOutput: jsonOutput, + gapGraceSeconds: gapGraceSeconds) + } + } + + private static func run( + tracePath: String, + policyNames: [ReplayPolicyName], + jsonOutput: Bool, + gapGraceSeconds: TimeInterval?) + { + let records: [AdaptiveRefreshTraceRecord] + do { + records = try AdaptiveRefreshTraceParser.parse(contentsOf: URL(fileURLWithPath: tracePath)) + } catch { + FileHandle.standardError.write(Data("error: failed to parse trace: \(error)\n".utf8)) + exit(EXIT_FAILURE) + } + + let policies = policyNames.map(\.policy) + + let results = policies.map { policy in + gapGraceSeconds.map { + ReplayEngine.runSegmented(trace: records, policy: policy, graceSeconds: $0) + } ?? ReplayEngine.run(trace: records, policy: policy) + } + let activityCoverage = ActivityCoverageStats.compute(from: records) + let recordedScheduleAudit = RecordedScheduleAuditor.audit(records) + + if jsonOutput { + print(Self.renderJSON( + results, + activityCoverage: activityCoverage, + recordedScheduleAudit: recordedScheduleAudit, + gapGraceSeconds: gapGraceSeconds)) + } else { + print(Self.renderTable(results)) + print(Self.renderActivityCoverage(activityCoverage)) + print(Self.renderRecordedScheduleAudit(recordedScheduleAudit)) + if let gapGraceSeconds, let first = results.first { + print(String( + format: "segmentation: %d segments, %.2fh excluded (legacy heuristic, %.0fs grace)", + first.segmentCount, + first.excludedGapSeconds / 3600, + gapGraceSeconds)) + } else { + print("segmentation: disabled (raw wall clock)") + } + } + } + + private static func renderRecordedScheduleAudit(_ audit: RecordedScheduleAudit) -> String { + "recorded schedule: \(audit.recordedAdvanceCount) advances, " + + "\(audit.acceptedEvaluationCount)/\(audit.evaluatedCount) evaluations accepted, " + + "payload=\(audit.payloadMismatchCount) decision=\(audit.decisionMismatchCount) " + + "menu-link=\(audit.menuLinkMismatchCount) mismatches, " + + "ambiguous=\(audit.ambiguousComparisonCount)" + } + + /// Reports coverage of optional activity observations already present in the input trace. + private static func renderActivityCoverage(_ stats: ActivityCoverageStats) -> String { + guard stats.decisionCount > 0 else { + return "activity telemetry: no decision events in trace" + } + let sampledSummary = String( + format: "%d/%d decisions sampled (%.0f%%)", + stats.sampledCount, + stats.decisionCount, + stats.sampledFraction * 100) + let activeSummary = String( + format: "%d/%d active coding at decision time (%.0f%%)", + stats.activeCount, + stats.sampledCount, + stats.activeFraction * 100) + return "activity telemetry: \(sampledSummary), \(activeSummary)" + } + + private static func renderTable(_ results: [ReplayMetrics]) -> String { + var lines: [String] = [] + let header = [ + "policy", "refreshes", "per24h", "sim advances", "active >5m", "staleness p50", + "staleness p95", "constrained ok", + ] + lines.append(header.joined(separator: "\t")) + for metrics in results { + let staleness = metrics.stalenessAtMenuOpen + lines.append([ + metrics.policyName, + String(metrics.totalRefreshCount), + String(format: "%.2f", metrics.refreshCountPer24h), + String(metrics.interactionAdvanceCount), + "\(metrics.codingActiveDelayViolationCount)/\(metrics.codingActiveDecisionCount)", + staleness.map { String(format: "%.0fs", $0.median) } ?? "n/a", + staleness.map { String(format: "%.0fs", $0.p95) } ?? "n/a", + metrics.constrainedCompliance + .isCompliant ? "yes" : "NO (\(metrics.constrainedCompliance.violationCount))", + ].joined(separator: "\t")) + } + return lines.joined(separator: "\n") + } + + private static func renderJSON( + _ results: [ReplayMetrics], + activityCoverage: ActivityCoverageStats, + recordedScheduleAudit: RecordedScheduleAudit, + gapGraceSeconds: TimeInterval?) -> String + { + let policies = results.map { metrics -> [String: Any] in + var dict: [String: Any] = [ + "policy": metrics.policyName, + "simulatedSpanSeconds": metrics.simulatedSpanSeconds, + "totalRefreshCount": metrics.totalRefreshCount, + "refreshCountPer24h": metrics.refreshCountPer24h, + "interactionAdvanceCount": metrics.interactionAdvanceCount, + "codingActiveDecisionCount": metrics.codingActiveDecisionCount, + "codingActiveDelayViolationCount": metrics.codingActiveDelayViolationCount, + "segmentCount": metrics.segmentCount, + "excludedGapSeconds": metrics.excludedGapSeconds, + "boundaryCensoredMenuOpenCount": metrics.boundaryCensoredMenuOpenCount, + "constrainedDecisionCount": metrics.constrainedCompliance.constrainedDecisionCount, + "constrainedViolationCount": metrics.constrainedCompliance.violationCount, + "constrainedCompliant": metrics.constrainedCompliance.isCompliant, + ] + if let staleness = metrics.stalenessAtMenuOpen { + dict["stalenessMeanSeconds"] = staleness.mean + dict["stalenessMedianSeconds"] = staleness.median + dict["stalenessP95Seconds"] = staleness.p95 + dict["stalenessSampleCount"] = staleness.sampleCount + } + return dict + } + let segmentation: [String: Any] = [ + "mode": gapGraceSeconds == nil ? "rawWallClock" : "legacyGapHeuristic", + "gapGraceSeconds": gapGraceSeconds.map { $0 as Any } ?? NSNull(), + ] + let payload: [String: Any] = [ + "policies": policies, + "activityCoverage": [ + "decisionCount": activityCoverage.decisionCount, + "sampledCount": activityCoverage.sampledCount, + "activeCount": activityCoverage.activeCount, + "sampledFraction": activityCoverage.sampledFraction, + "activeFraction": activityCoverage.activeFraction, + ], + "recordedScheduleAudit": [ + "recordedAdvanceCount": recordedScheduleAudit.recordedAdvanceCount, + "evaluatedCount": recordedScheduleAudit.evaluatedCount, + "acceptedEvaluationCount": recordedScheduleAudit.acceptedEvaluationCount, + "rejectedEvaluationCount": recordedScheduleAudit.rejectedEvaluationCount, + "payloadMismatchCount": recordedScheduleAudit.payloadMismatchCount, + "decisionMismatchCount": recordedScheduleAudit.decisionMismatchCount, + "menuLinkMismatchCount": recordedScheduleAudit.menuLinkMismatchCount, + "ambiguousComparisonCount": recordedScheduleAudit.ambiguousComparisonCount, + "isValid": recordedScheduleAudit.isValid, + ], + "segmentation": segmentation, + ] + guard let data = try? JSONSerialization.data( + withJSONObject: payload, + options: [.prettyPrinted, .sortedKeys]), + let text = String(data: data, encoding: .utf8) + else { + return "{}" + } + return text + } + + private static let helpText = """ + Usage: AdaptiveReplayCLI [--policy ]... [--gap-grace ] [--raw-wall-clock] [--json] + + Replays a JSONL adaptive-refresh trace against one or more refresh-timing policies and prints + per-policy metrics over automatically segmented observed time. Simulated advances are + counterfactual policy events; recorded live schedule evaluations are audited separately. + + Policies: + adaptive Plain production Adaptive policy. Uses menu opens only. + adaptive-activity Agent-aware Adaptive policy. Also uses local coding-activity fields. + fixed-2m Fixed 2 minute cadence. Unaffected by menu-open interactions. + fixed-5m Fixed 5 minute cadence. + fixed-15m Fixed 15 minute cadence. + fixed-30m Fixed 30 minute cadence. + manual Never refreshes (degenerate floor). + + Defaults to comparing all seven policies when --policy is omitted. + + Options: + --policy Restrict to one listed policy; repeat to compare a specific subset. + --gap-grace Split legacy gaps this many seconds after the last timer deadline (default 300). + --raw-wall-clock Disable gap segmentation; useful only for auditing the old behavior. + --json Print a machine-readable report including replay, activity, and audit data. + -h, --help Print this help text. + """ +} + +AdaptiveReplayCLI.main() diff --git a/Sources/AdaptiveReplayKit/ActivityCoverageStats.swift b/Sources/AdaptiveReplayKit/ActivityCoverageStats.swift new file mode 100644 index 0000000000..327a3eae32 --- /dev/null +++ b/Sources/AdaptiveReplayKit/ActivityCoverageStats.swift @@ -0,0 +1,50 @@ +import Foundation + +/// Informational summary of optional coding-activity observations in a trace's `decision` events. +/// It reports how many decisions carried activity data and how many sampled decisions were below +/// `activeThresholdSeconds` for either CLI. +public struct ActivityCoverageStats: Sendable, Equatable { + public let decisionCount: Int + public let sampledCount: Int + public let activeCount: Int + + public init(decisionCount: Int, sampledCount: Int, activeCount: Int) { + self.decisionCount = decisionCount + self.sampledCount = sampledCount + self.activeCount = activeCount + } + + /// Fraction of `decision` events that carried at least one non-nil activity field. + public var sampledFraction: Double { + self.decisionCount == 0 ? 0 : Double(self.sampledCount) / Double(self.decisionCount) + } + + /// Fraction of the *sampled* decisions (not all decisions) that looked like active coding. + public var activeFraction: Double { + self.sampledCount == 0 ? 0 : Double(self.activeCount) / Double(self.sampledCount) + } + + /// - Parameter activeThresholdSeconds: below this many seconds since the newest transcript + /// write, a CLI counts as "active coding at decision time". Defaults to 5 minutes. + public static func compute( + from records: [AdaptiveRefreshTraceRecord], + activeThresholdSeconds: TimeInterval = 300) -> Self + { + var sampledCount = 0 + var activeCount = 0 + var decisionCount = 0 + for record in records where record.kind == .decision { + decisionCount += 1 + let codexSeconds = record.codexActivitySeconds + let claudeSeconds = record.claudeActivitySeconds + guard codexSeconds != nil || claudeSeconds != nil else { continue } + sampledCount += 1 + let isActive = (codexSeconds ?? .infinity) < activeThresholdSeconds + || (claudeSeconds ?? .infinity) < activeThresholdSeconds + if isActive { + activeCount += 1 + } + } + return Self(decisionCount: decisionCount, sampledCount: sampledCount, activeCount: activeCount) + } +} diff --git a/Sources/AdaptiveReplayKit/AdaptiveRefreshTrace.swift b/Sources/AdaptiveReplayKit/AdaptiveRefreshTrace.swift new file mode 100644 index 0000000000..d6a8cf1b39 --- /dev/null +++ b/Sources/AdaptiveReplayKit/AdaptiveRefreshTrace.swift @@ -0,0 +1,201 @@ +import Foundation + +/// The event kinds a trace records. `decision` events capture a full policy tick (the +/// signals it saw plus what it chose). `menuOpen` and `refreshCompleted` capture the two +/// ground-truth events the replay engine anchors a simulation to, independent of any candidate +/// policy. `timerAdvanced` captures the one place live behavior *isn't* a plain tick loop: when +/// opening the menu makes `UsageStore.noteMenuOpened(at:)` pull the next adaptive refresh forward +/// (see `UsageStore.shouldAdvanceAdaptiveTimer(scheduledAt:candidate:)`). Recording it separately +/// from `decision` lets a trace answer "did an advance happen, and to when" without relying on +/// fragile inference from decision-timestamp gaps. +public enum AdaptiveRefreshTraceEventKind: String, Sendable, Codable { + case decision + case menuOpen + case refreshCompleted + case timerAdvanced + /// Every live advance comparison, including the cases correctly rejected because the current + /// timer was already earlier. This is distinct from counterfactual replay advances. + case timerAdvanceEvaluated +} + +/// One line of a JSONL adaptive-refresh trace. Field presence depends on `kind`: `decision` +/// records populate `menuAgeSeconds`, `lowPowerModeEnabled`, `thermalState`, `reason`, and +/// `delaySeconds`, plus optional activity observations supplied by the input trace +/// (`codexActivitySeconds`/`claudeActivitySeconds`, the seconds-since-newest-transcript fields, +/// and the per-file intensity fields alongside them); timer advance records populate +/// `previousScheduledAt`, `candidateScheduledAt`, `reason`, and `delaySeconds`; `menuOpen` and +/// `refreshCompleted` carry only `kind` and `timestamp`. +public struct AdaptiveRefreshTraceRecord: Sendable, Codable, Equatable { + public let kind: AdaptiveRefreshTraceEventKind + public let timestamp: Date + public let menuAgeSeconds: TimeInterval? + public let lowPowerModeEnabled: Bool? + public let thermalState: ReplayThermalState? + public let reason: String? + public let delaySeconds: TimeInterval? + /// Timer advance records only: the adaptive timer's scheduled refresh time before the + /// comparison, or `nil` when no refresh had been scheduled yet (matches + /// `UsageStore.shouldAdvanceAdaptiveTimer`'s "always advance when nothing is scheduled" rule). + public let previousScheduledAt: Date? + /// Timer advance records only: the candidate refresh time, i.e. the menu-open timestamp plus + /// the freshly computed decision's delay. + public let candidateScheduledAt: Date? + /// `timerAdvanceEvaluated` only: whether the live schedule comparison accepted the candidate. + public let timerAdvanceAccepted: Bool? + /// `timerAdvanceEvaluated` only: `previousScheduledAt - candidateScheduledAt`, captured before + /// whole-second ISO-8601 serialization. Positive means the candidate was earlier. Optional for + /// compatibility with traces recorded before exact comparison deltas were added. + public let scheduleLeadSeconds: TimeInterval? + /// `timerAdvanceEvaluated` only: whether another refresh was in flight at comparison time. + public let refreshInFlight: Bool? + /// `decision` only: seconds since the newest observed Codex session transcript modification, + /// or `nil` when unavailable. Optional so old trace lines without this field keep decoding. + public let codexActivitySeconds: TimeInterval? + /// `decision` only: the Claude Code counterpart of `codexActivitySeconds`. + public let claudeActivitySeconds: TimeInterval? + /// `decision` only: how long the newest Codex transcript has been + /// growing (its mtime minus its creationDate), or `nil` when unavailable. Not a separate + /// session-age field — age is `codexActivitySeconds` + `codexSessionDurationSeconds`. + public let codexSessionDurationSeconds: TimeInterval? + /// `decision` only: the Claude Code counterpart of `codexSessionDurationSeconds`. + public let claudeSessionDurationSeconds: TimeInterval? + /// `decision` only: size in bytes of the newest Codex transcript, as a stateless raw value. + public let codexTranscriptBytes: Int64? + /// `decision` only: the Claude Code counterpart of `codexTranscriptBytes`. + public let claudeTranscriptBytes: Int64? + /// `decision` only: count of Codex `.jsonl` transcripts modified in the observation window. + public let codexActiveTranscriptCount: Int? + /// `decision` only: the Claude Code counterpart of `codexActiveTranscriptCount`. + public let claudeActiveTranscriptCount: Int? + + public init( + kind: AdaptiveRefreshTraceEventKind, + timestamp: Date, + menuAgeSeconds: TimeInterval? = nil, + lowPowerModeEnabled: Bool? = nil, + thermalState: ReplayThermalState? = nil, + reason: String? = nil, + delaySeconds: TimeInterval? = nil, + previousScheduledAt: Date? = nil, + candidateScheduledAt: Date? = nil, + timerAdvanceAccepted: Bool? = nil, + scheduleLeadSeconds: TimeInterval? = nil, + refreshInFlight: Bool? = nil, + codexActivitySeconds: TimeInterval? = nil, + claudeActivitySeconds: TimeInterval? = nil, + codexSessionDurationSeconds: TimeInterval? = nil, + claudeSessionDurationSeconds: TimeInterval? = nil, + codexTranscriptBytes: Int64? = nil, + claudeTranscriptBytes: Int64? = nil, + codexActiveTranscriptCount: Int? = nil, + claudeActiveTranscriptCount: Int? = nil) + { + self.kind = kind + self.timestamp = timestamp + self.menuAgeSeconds = menuAgeSeconds + self.lowPowerModeEnabled = lowPowerModeEnabled + self.thermalState = thermalState + self.reason = reason + self.delaySeconds = delaySeconds + self.previousScheduledAt = previousScheduledAt + self.candidateScheduledAt = candidateScheduledAt + self.timerAdvanceAccepted = timerAdvanceAccepted + self.scheduleLeadSeconds = scheduleLeadSeconds + self.refreshInFlight = refreshInFlight + self.codexActivitySeconds = codexActivitySeconds + self.claudeActivitySeconds = claudeActivitySeconds + self.codexSessionDurationSeconds = codexSessionDurationSeconds + self.claudeSessionDurationSeconds = claudeSessionDurationSeconds + self.codexTranscriptBytes = codexTranscriptBytes + self.claudeTranscriptBytes = claudeTranscriptBytes + self.codexActiveTranscriptCount = codexActiveTranscriptCount + self.claudeActiveTranscriptCount = claudeActiveTranscriptCount + } + + // swiftlint:disable:next function_parameter_count + public static func decision( + timestamp: Date, + menuAgeSeconds: TimeInterval?, + lowPowerModeEnabled: Bool, + thermalState: ReplayThermalState, + reason: String, + delaySeconds: TimeInterval, + codexActivitySeconds: TimeInterval? = nil, + claudeActivitySeconds: TimeInterval? = nil, + codexSessionDurationSeconds: TimeInterval? = nil, + claudeSessionDurationSeconds: TimeInterval? = nil, + codexTranscriptBytes: Int64? = nil, + claudeTranscriptBytes: Int64? = nil, + codexActiveTranscriptCount: Int? = nil, + claudeActiveTranscriptCount: Int? = nil) -> Self + { + Self( + kind: .decision, + timestamp: timestamp, + menuAgeSeconds: menuAgeSeconds, + lowPowerModeEnabled: lowPowerModeEnabled, + thermalState: thermalState, + reason: reason, + delaySeconds: delaySeconds, + codexActivitySeconds: codexActivitySeconds, + claudeActivitySeconds: claudeActivitySeconds, + codexSessionDurationSeconds: codexSessionDurationSeconds, + claudeSessionDurationSeconds: claudeSessionDurationSeconds, + codexTranscriptBytes: codexTranscriptBytes, + claudeTranscriptBytes: claudeTranscriptBytes, + codexActiveTranscriptCount: codexActiveTranscriptCount, + claudeActiveTranscriptCount: claudeActiveTranscriptCount) + } + + public static func menuOpen(timestamp: Date) -> Self { + Self(kind: .menuOpen, timestamp: timestamp) + } + + public static func refreshCompleted(timestamp: Date) -> Self { + Self(kind: .refreshCompleted, timestamp: timestamp) + } + + /// - Parameters: + /// - timestamp: When the menu open that triggered the advance occurred. + /// - previousScheduledAt: The timer's scheduled refresh time immediately before the advance. + /// - candidateScheduledAt: The refresh time the timer advanced to (`timestamp + delaySeconds`). + /// - reason: The freshly computed decision's reason (e.g. `"recentInteraction"`). + /// - delaySeconds: The freshly computed decision's delay. + public static func timerAdvanced( + timestamp: Date, + previousScheduledAt: Date?, + candidateScheduledAt: Date, + reason: String, + delaySeconds: TimeInterval) -> Self + { + Self( + kind: .timerAdvanced, + timestamp: timestamp, + reason: reason, + delaySeconds: delaySeconds, + previousScheduledAt: previousScheduledAt, + candidateScheduledAt: candidateScheduledAt) + } + + // swiftlint:disable:next function_parameter_count + public static func timerAdvanceEvaluated( + timestamp: Date, + previousScheduledAt: Date?, + candidateScheduledAt: Date, + reason: String, + delaySeconds: TimeInterval, + accepted: Bool, + refreshInFlight: Bool) -> Self + { + Self( + kind: .timerAdvanceEvaluated, + timestamp: timestamp, + reason: reason, + delaySeconds: delaySeconds, + previousScheduledAt: previousScheduledAt, + candidateScheduledAt: candidateScheduledAt, + timerAdvanceAccepted: accepted, + scheduleLeadSeconds: previousScheduledAt.map { $0.timeIntervalSince(candidateScheduledAt) }, + refreshInFlight: refreshInFlight) + } +} diff --git a/Sources/AdaptiveReplayKit/AdaptiveRefreshTraceParser.swift b/Sources/AdaptiveReplayKit/AdaptiveRefreshTraceParser.swift new file mode 100644 index 0000000000..f7f40d05ab --- /dev/null +++ b/Sources/AdaptiveReplayKit/AdaptiveRefreshTraceParser.swift @@ -0,0 +1,85 @@ +import Foundation + +/// A malformed trace line, with enough context to find and fix it. +public struct AdaptiveRefreshTraceParseError: Error, Sendable, Equatable, CustomStringConvertible { + public let lineNumber: Int + public let content: String + public let underlyingDescription: String + + public init(lineNumber: Int, content: String, underlyingDescription: String) { + self.lineNumber = lineNumber + self.content = content + self.underlyingDescription = underlyingDescription + } + + public var description: String { + "trace line \(self.lineNumber) is malformed: \(self.underlyingDescription) (content: \(self.content))" + } +} + +/// Parses newline-delimited JSON adaptive-refresh traces. +/// +/// Deliberate choice: a malformed line **fails the whole parse** rather than being silently +/// skipped. A trace is acceptance evidence — if a line is corrupt (truncated write, disk-full +/// mid-append, hand-edited fixture with a typo), the honest answer is "this trace is untrustworthy +/// as a whole", not "here are metrics computed from however much of it happened to parse". A +/// silently-shortened trace would still produce a superficially plausible replay report, which is +/// worse than a loud failure: it hides exactly the kind of gap that would bias staleness/refresh +/// counts. Callers that genuinely want best-effort parsing can catch the error and fall back to +/// `AdaptiveRefreshTraceParser.parseTolerantly`, which skips bad lines and returns what parsed. +public enum AdaptiveRefreshTraceParser { + public static func parse(_ text: String) throws -> [AdaptiveRefreshTraceRecord] { + let decoder = Self.makeDecoder() + var records: [AdaptiveRefreshTraceRecord] = [] + for (index, line) in text.split( + omittingEmptySubsequences: false, + whereSeparator: \.isNewline).enumerated() + { + let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { continue } + guard let data = trimmed.data(using: .utf8) else { + throw AdaptiveRefreshTraceParseError( + lineNumber: index + 1, + content: trimmed, + underlyingDescription: "not valid UTF-8") + } + do { + try records.append(decoder.decode(AdaptiveRefreshTraceRecord.self, from: data)) + } catch { + throw AdaptiveRefreshTraceParseError( + lineNumber: index + 1, + content: trimmed, + underlyingDescription: String(describing: error)) + } + } + return records + } + + public static func parse(contentsOf url: URL) throws -> [AdaptiveRefreshTraceRecord] { + let text = try String(contentsOf: url, encoding: .utf8) + return try self.parse(text) + } + + /// Best-effort variant: skips lines that fail to parse instead of throwing. Not the default — + /// see the type-level documentation for why silent skipping is the wrong default for + /// acceptance-evidence traces. Exists for callers (future exploratory tooling) that explicitly + /// want partial data over none. + public static func parseTolerantly(_ text: String) -> [AdaptiveRefreshTraceRecord] { + let decoder = Self.makeDecoder() + var records: [AdaptiveRefreshTraceRecord] = [] + for line in text.split(omittingEmptySubsequences: false, whereSeparator: \.isNewline) { + let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty, let data = trimmed.data(using: .utf8) else { continue } + if let record = try? decoder.decode(AdaptiveRefreshTraceRecord.self, from: data) { + records.append(record) + } + } + return records + } + + private static func makeDecoder() -> JSONDecoder { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + return decoder + } +} diff --git a/Sources/AdaptiveReplayKit/AgentAwarePolicies.swift b/Sources/AdaptiveReplayKit/AgentAwarePolicies.swift new file mode 100644 index 0000000000..f8d9860814 --- /dev/null +++ b/Sources/AdaptiveReplayKit/AgentAwarePolicies.swift @@ -0,0 +1,23 @@ +import AdaptiveRefreshCore +import Foundation + +/// Agent-aware Adaptive replay policy. Activity remains a distinct opt-in input projection even +/// though both adaptive modes share the canonical decision table. +public struct AgentAwareAdaptiveReplayPolicy: ReplayPolicy, Sendable { + public let name = "adaptive-activity" + public let advancesOnInteraction = true + + public init() {} + + public func decide(_ input: ReplayPolicyInput) -> ReplayPolicyDecision { + let decision = AdaptiveRefreshPolicyCore().nextDelay(for: AdaptiveRefreshPolicyCore.Input( + now: input.now, + lastMenuOpenAt: input.lastMenuOpenAt, + lastCodingActivityAt: input.lastCodingActivityAt, + lowPowerModeEnabled: input.lowPowerModeEnabled, + thermalPressure: input.thermalState.isConstrained ? .constrained : .nominal)) + return ReplayPolicyDecision( + delaySeconds: TimeInterval(decision.delay.components.seconds), + reason: decision.reason.rawValue) + } +} diff --git a/Sources/AdaptiveReplayKit/BaselinePolicies.swift b/Sources/AdaptiveReplayKit/BaselinePolicies.swift new file mode 100644 index 0000000000..4daa811b5d --- /dev/null +++ b/Sources/AdaptiveReplayKit/BaselinePolicies.swift @@ -0,0 +1,58 @@ +import AdaptiveRefreshCore +import Foundation + +/// Replay adapter for the same canonical policy core used by the CodexBar app. +public struct AdaptiveReplayPolicy: ReplayPolicy, Sendable { + public let name = "adaptive" + + /// Matches `UsageStore.noteMenuOpened(at:)`'s adaptive-only advance guard: this is the one + /// baseline that actually models the interaction-advance path, so it is the only one that + /// overrides the protocol's `false` default. + public let advancesOnInteraction = true + + public init() {} + + public func decide(_ input: ReplayPolicyInput) -> ReplayPolicyDecision { + let decision = AdaptiveRefreshPolicyCore().nextDelay(for: AdaptiveRefreshPolicyCore.Input( + now: input.now, + lastMenuOpenAt: input.lastMenuOpenAt, + lastCodingActivityAt: nil, + lowPowerModeEnabled: input.lowPowerModeEnabled, + thermalPressure: input.thermalState.isConstrained ? .constrained : .nominal)) + return ReplayPolicyDecision( + delaySeconds: TimeInterval(decision.delay.components.seconds), + reason: decision.reason.rawValue) + } +} + +/// A fixed-cadence baseline: always waits the same interval, regardless of signals. Used to +/// compare the adaptive policy against the flat refresh frequencies CodexBar also offers +/// (2/5/15/30 minutes). Never advances on interaction (`advancesOnInteraction` stays the protocol +/// default of `false`), matching the real app: fixed-cadence refresh frequencies never wire up +/// `noteMenuOpened`'s advance check. +public struct FixedIntervalPolicy: ReplayPolicy, Sendable { + public let name: String + private let intervalSeconds: TimeInterval + + public init(minutes: Int) { + self.name = "fixed-\(minutes)m" + self.intervalSeconds = TimeInterval(minutes) * 60 + } + + public func decide(_: ReplayPolicyInput) -> ReplayPolicyDecision { + ReplayPolicyDecision(delaySeconds: self.intervalSeconds, reason: "fixed") + } +} + +/// The degenerate floor: never schedules a refresh. A trace replayed against this policy always +/// reports zero refreshes, which is the point — it establishes the worst-case staleness bound the +/// other policies are compared against. +public struct ManualPolicy: ReplayPolicy, Sendable { + public let name = "manual" + + public init() {} + + public func decide(_: ReplayPolicyInput) -> ReplayPolicyDecision { + ReplayPolicyDecision(delaySeconds: nil, reason: "manual") + } +} diff --git a/Sources/AdaptiveReplayKit/README.md b/Sources/AdaptiveReplayKit/README.md new file mode 100644 index 0000000000..1bc09204cf --- /dev/null +++ b/Sources/AdaptiveReplayKit/README.md @@ -0,0 +1,39 @@ +# AdaptiveReplayKit + +`AdaptiveReplayKit` is an offline harness for comparing refresh-timing policies against an +explicit JSONL trace. `AdaptiveReplayCLI` is the command-line wrapper around the library. + +## Scope + +The replay targets do not import `CodexBar` or `CodexBarCore`; they share only the package-internal, +Foundation-only `AdaptiveRefreshCore` target with the app. They do not record app behavior, scan +Codex or Claude transcript directories, write trace files, call providers, or change the production +refresh policy at runtime. Trace capture and lifecycle management are deliberately outside this tool; callers +provide an existing trace path to the CLI. + +Optional activity fields in the trace schema are inputs only. The replay kit never discovers or +collects them. Old records without those fields continue to decode. + +## Components + +- `AdaptiveRefreshTrace.swift` defines the version-tolerant trace schema. +- `AdaptiveRefreshTraceParser.swift` parses JSONL strictly by default. The tolerant entry point is + available for exploratory work that explicitly accepts skipped malformed records. +- `AdaptiveRefreshCore` owns the production decision table. `ReplayPolicy.swift`, + `BaselinePolicies.swift`, and `AgentAwarePolicies.swift` provide the plain and agent-aware production adapters plus + fixed/manual baselines. +- `ReplayEngine.swift` and `ReplayMetrics.swift` calculate simulated refresh cadence, menu-open + staleness, interaction advances, and constrained-state compliance. +- `ReplayTraceSegmentation.swift` excludes legacy deadline-overrun gaps with an explicit heuristic + and reports the excluded duration. +- `RecordedScheduleAudit.swift` audits recorded timer-advance events independently from the replay + clock. +- `Sources/AdaptiveReplayCLI` formats table or JSON reports. + +`interactionAdvanceCount` is counterfactual. Replay assumes a zero-duration refresh, while the +live app waits for provider work and may already have a refresh in flight. Recorded schedule events +therefore have a separate audit instead of a direct count comparison. + +The legacy gap heuristic cannot distinguish sleep or reboot from a long refresh or event-loop +stall. Reports expose the segment count, grace interval, and excluded time rather than assigning a +cause. diff --git a/Sources/AdaptiveReplayKit/RecordedScheduleAudit.swift b/Sources/AdaptiveReplayKit/RecordedScheduleAudit.swift new file mode 100644 index 0000000000..ce522efcc6 --- /dev/null +++ b/Sources/AdaptiveReplayKit/RecordedScheduleAudit.swift @@ -0,0 +1,153 @@ +import Foundation + +public struct RecordedScheduleAudit: Sendable, Equatable { + public let recordedAdvanceCount: Int + public let evaluatedCount: Int + public let acceptedEvaluationCount: Int + public let rejectedEvaluationCount: Int + public let payloadMismatchCount: Int + public let decisionMismatchCount: Int + public let menuLinkMismatchCount: Int + public let ambiguousComparisonCount: Int + + public var isValid: Bool { + self.payloadMismatchCount == 0 + && self.decisionMismatchCount == 0 + && self.menuLinkMismatchCount == 0 + && self.ambiguousComparisonCount == 0 + } +} + +/// Audits the live schedule records without equating them to ReplayEngine's counterfactual clock. +public enum RecordedScheduleAuditor { + public static func audit( + _ records: [AdaptiveRefreshTraceRecord], + timestampTolerance: TimeInterval = 1) -> RecordedScheduleAudit + { + let sorted = records.sorted { $0.timestamp < $1.timestamp } + let menuTimestamps = sorted.filter { $0.kind == .menuOpen }.map(\.timestamp) + let advances = sorted.filter { $0.kind == .timerAdvanced } + let evaluations = sorted.filter { $0.kind == .timerAdvanceEvaluated } + + var payloadMismatchCount = advances.count(where: { !Self.payloadIsValid($0) }) + let evaluationOutcomes = evaluations.map(Self.evaluationOutcome) + let decisionMismatchCount = evaluationOutcomes.count(where: { $0 == .mismatch }) + let ambiguousComparisonCount = evaluationOutcomes.count(where: { $0 == .ambiguous }) + // Every evaluation is caused by one menu open. Before evaluation records existed, an + // accepted advance was the only causal record, so retain those legacy advances as linkage + // events. Modern accepted advances are reconciled against evaluations below instead of + // consuming the same menu open twice. + let legacyAdvances = evaluations.first.map { firstEvaluation in + advances.filter { $0.timestamp < firstEvaluation.timestamp } + } ?? advances + let menuLinkMismatchCount = Self.unmatchedEventCount( + evaluations + legacyAdvances, + menuTimestamps: menuTimestamps, + timestampTolerance: timestampTolerance) + + if let firstEvaluationAt = evaluations.first?.timestamp { + let accepted = evaluations.filter { $0.timerAdvanceAccepted == true } + let auditableAdvances = advances.filter { $0.timestamp >= firstEvaluationAt } + payloadMismatchCount += Self.scheduleMultiplicityDifference(accepted, auditableAdvances) + } + + return RecordedScheduleAudit( + recordedAdvanceCount: advances.count, + evaluatedCount: evaluations.count, + acceptedEvaluationCount: evaluations.count(where: { $0.timerAdvanceAccepted == true }), + rejectedEvaluationCount: evaluations.count(where: { $0.timerAdvanceAccepted == false }), + payloadMismatchCount: payloadMismatchCount, + decisionMismatchCount: decisionMismatchCount, + menuLinkMismatchCount: menuLinkMismatchCount, + ambiguousComparisonCount: ambiguousComparisonCount) + } + + private static func payloadIsValid(_ record: AdaptiveRefreshTraceRecord) -> Bool { + guard let candidate = record.candidateScheduledAt, + let delay = record.delaySeconds, + abs(candidate.timeIntervalSince(record.timestamp) - delay) < 0.001 + else { return false } + // Whole-second legacy timestamps can collapse a sub-second accepted lead to equality. + return record.previousScheduledAt.map { candidate <= $0 } ?? true + } + + private enum EvaluationOutcome: Equatable { + case valid + case mismatch + case ambiguous + } + + private static func evaluationOutcome(_ record: AdaptiveRefreshTraceRecord) -> EvaluationOutcome { + guard let accepted = record.timerAdvanceAccepted, + let candidate = record.candidateScheduledAt, + let delay = record.delaySeconds, + abs(candidate.timeIntervalSince(record.timestamp) - delay) < 0.001 + else { return .mismatch } + guard let previous = record.previousScheduledAt else { return accepted ? .valid : .mismatch } + if candidate != previous { + return accepted == (candidate < previous) ? .valid : .mismatch + } + guard let lead = record.scheduleLeadSeconds else { return .ambiguous } + return accepted == (lead > 0) ? .valid : .mismatch + } + + private struct ScheduleKey: Hashable { + let timestamp: Date + let previousScheduledAt: Date? + let candidateScheduledAt: Date? + let reason: String? + let delaySeconds: TimeInterval? + } + + private static func scheduleMultiplicityDifference( + _ lhs: [AdaptiveRefreshTraceRecord], + _ rhs: [AdaptiveRefreshTraceRecord]) -> Int + { + func counts(_ records: [AdaptiveRefreshTraceRecord]) -> [ScheduleKey: Int] { + Dictionary(grouping: records, by: scheduleKey).mapValues(\.count) + } + let lhsCounts = counts(lhs) + let rhsCounts = counts(rhs) + return Set(lhsCounts.keys).union(rhsCounts.keys).reduce(0) { difference, key in + difference + abs(lhsCounts[key, default: 0] - rhsCounts[key, default: 0]) + } + } + + /// Maximum one-to-one matching for sorted points with a symmetric tolerance window. Extra + /// menu opens are valid because fixed/manual modes do not emit schedule evaluations; only an + /// event without its own causal menu open is a mismatch. + private static func unmatchedEventCount( + _ records: [AdaptiveRefreshTraceRecord], + menuTimestamps: [Date], + timestampTolerance: TimeInterval) -> Int + { + let eventTimestamps = records.map(\.timestamp).sorted() + var eventIndex = 0 + var menuIndex = 0 + var unmatched = 0 + + while eventIndex < eventTimestamps.count, menuIndex < menuTimestamps.count { + let eventTimestamp = eventTimestamps[eventIndex] + let menuTimestamp = menuTimestamps[menuIndex] + if menuTimestamp < eventTimestamp.addingTimeInterval(-timestampTolerance) { + menuIndex += 1 + } else if menuTimestamp > eventTimestamp.addingTimeInterval(timestampTolerance) { + unmatched += 1 + eventIndex += 1 + } else { + eventIndex += 1 + menuIndex += 1 + } + } + return unmatched + eventTimestamps.count - eventIndex + } + + private static func scheduleKey(_ record: AdaptiveRefreshTraceRecord) -> ScheduleKey { + ScheduleKey( + timestamp: record.timestamp, + previousScheduledAt: record.previousScheduledAt, + candidateScheduledAt: record.candidateScheduledAt, + reason: record.reason, + delaySeconds: record.delaySeconds) + } +} diff --git a/Sources/AdaptiveReplayKit/ReplayEngine.swift b/Sources/AdaptiveReplayKit/ReplayEngine.swift new file mode 100644 index 0000000000..8b71a6993c --- /dev/null +++ b/Sources/AdaptiveReplayKit/ReplayEngine.swift @@ -0,0 +1,303 @@ +import Foundation + +/// Simulates the live timer loop (`decide` → sleep → refresh → `decide` → ...) over a trace's +/// observed span for a given `ReplayPolicy`, pure and deterministic: the same trace and policy +/// always produce the same `ReplayMetrics`, since every input the policy sees comes from the +/// trace, never from a live clock. +/// +/// Ground truth vs. reconstructed signal: `menuOpen` events are ground truth — a menu either +/// opened at a timestamp or it didn't, independent of any policy. `lowPowerModeEnabled` and +/// `thermalState`, by contrast, are only *sampled* at the timestamps the trace's original +/// `decision` events happened to occur at (whatever policy produced the trace). When a candidate +/// policy's own tick times fall between those samples, the engine holds the most recent known +/// value (step function). This is the phase-1 approximation: without a continuous power/thermal +/// signal in the trace, "most recent sample" is the best available reconstruction. Before the +/// first known sample, the earliest available sample is used (hold-first). +/// +/// Interaction advances: this is a *counterfactual* replay, not a literal replay of whatever the +/// recording policy happened to do — each candidate policy gets its own tick schedule computed +/// fresh from `policy.decide(_:)`. To reproduce `UsageStore.noteMenuOpened(at:)`'s "pull the timer +/// forward" behavior (see `UsageStore.shouldAdvanceAdaptiveTimer(scheduledAt:candidate:)`) for +/// *any* candidate policy, every `menuOpen` event that falls inside a policy's current tick window +/// is independently re-evaluated: if `policy.advancesOnInteraction` and the decision computed as of +/// that menu open would land earlier than the already-scheduled next tick, the schedule advances to +/// that earlier time, exactly like `startTimer(preservingResetBoundaryRefresh: true)` replacing a +/// pending sleep with a shorter one. Recorded `timerAdvanced` events are audited separately: their +/// count is not expected to equal +/// this counterfactual schedule because live refresh work has non-zero duration and can coalesce. +public enum ReplayEngine { + /// Safety valve against a pathological policy (e.g. a zero-or-negative delay bug) turning a + /// long trace into an unbounded loop. + private static let maxIterations = 2_000_000 + + /// The trace-derived, replay-invariant inputs the simulation loop reads on every tick: + /// menu-open ground truth plus the sampled power/thermal signal, both precomputed and sorted + /// once per `run` so the per-tick lookups stay O(log n). + private struct TraceSignals { + let menuOpenTimestamps: [Date] + let signalSamples: [(timestamp: Date, lowPower: Bool, thermal: ReplayThermalState)] + let signalTimestamps: [Date] + let activitySamples: [ActivityObservation] + let activityTimestamps: [Date] + } + + private struct ActivityObservation { + let timestamp: Date + let lastCodingActivityAt: Date? + } + + public static func run(trace: [AdaptiveRefreshTraceRecord], policy: some ReplayPolicy) -> ReplayMetrics { + self.runDetailed(trace: trace, policy: policy).metrics + } + + static func runDetailed( + trace: [AdaptiveRefreshTraceRecord], + policy: some ReplayPolicy, + stalenessStartAt: Date? = nil) -> ReplayRun + { + guard let start = trace.map(\.timestamp).min(), let end = trace.map(\.timestamp).max() else { + return ReplayRun( + metrics: ReplayMetrics( + policyName: policy.name, + simulatedSpanSeconds: 0, + totalRefreshCount: 0, + refreshCountPer24h: 0, + stalenessAtMenuOpen: nil, + constrainedCompliance: ConstrainedCompliance(constrainedDecisionCount: 0, violationCount: 0)), + stalenessSamples: []) + } + + let menuOpenTimestamps = trace + .filter { $0.kind == .menuOpen } + .map(\.timestamp) + .sorted() + + let signalSamples: [(timestamp: Date, lowPower: Bool, thermal: ReplayThermalState)] = trace + .filter { $0.kind == .decision } + .compactMap { record in + guard let lowPower = record.lowPowerModeEnabled, let thermal = record.thermalState else { + return nil + } + return (timestamp: record.timestamp, lowPower: lowPower, thermal: thermal) + } + .sorted { $0.timestamp < $1.timestamp } + let activitySamples = trace + .filter { $0.kind == .decision } + .map { record in + let activityDates = [record.codexActivitySeconds, record.claudeActivitySeconds] + .compactMap(\.self) + .map { record.timestamp.addingTimeInterval(-max(0, $0)) } + return ActivityObservation( + timestamp: record.timestamp, + lastCodingActivityAt: activityDates.max()) + } + .sorted { $0.timestamp < $1.timestamp } + let signals = TraceSignals( + menuOpenTimestamps: menuOpenTimestamps, + signalSamples: signalSamples, + signalTimestamps: signalSamples.map(\.timestamp), + activitySamples: activitySamples, + activityTimestamps: activitySamples.map(\.timestamp)) + + var cursor = start + var refreshTimestamps: [Date] = [] + var constrainedDecisionCount = 0 + var violationCount = 0 + var interactionAdvanceCount = 0 + var codingActiveDecisionCount = 0 + var codingActiveDelayViolationCount = 0 + var iterations = 0 + // Monotonic pointer into `menuOpenTimestamps`: the scan below considers each menu open for + // an advance at most once, in the single tick window (cursor, next] it falls into. + var menuOpenScanIndex = 0 + + while cursor <= end, iterations < self.maxIterations { + iterations += 1 + let (lowPower, thermal) = self.signal( + signals.signalSamples, + timestamps: signals.signalTimestamps, + at: cursor) + let input = ReplayPolicyInput( + now: cursor, + lastMenuOpenAt: self.lastValue(menuOpenTimestamps, atOrBefore: cursor), + lastCodingActivityAt: self.lastActivity( + signals.activitySamples, + timestamps: signals.activityTimestamps, + at: cursor), + lowPowerModeEnabled: lowPower, + thermalState: thermal) + let decision = policy.decide(input) + + if input.isConstrained { + constrainedDecisionCount += 1 + if let delay = decision.delaySeconds, delay < 1800 { + violationCount += 1 + } + } + + if !input.isConstrained, + let activityAge = input.codingActivityAgeSeconds, + activityAge < 5 * 60 + { + codingActiveDecisionCount += 1 + if decision.delaySeconds.map({ $0 <= 0 || $0 > 5 * 60 }) ?? true { + codingActiveDelayViolationCount += 1 + } + } + + guard let delay = decision.delaySeconds, delay > 0 else { break } + var next = cursor.addingTimeInterval(delay) + + if policy.advancesOnInteraction { + let advanced = self.applyInteractionAdvances( + policy: policy, + signals: signals, + scanIndex: &menuOpenScanIndex, + windowStart: cursor, + scheduledAt: next) + next = advanced.scheduledAt + interactionAdvanceCount += advanced.advanceCount + } + + guard next <= end else { break } + refreshTimestamps.append(next) + cursor = next + } + + let span = end.timeIntervalSince(start) + let refreshCountPer24h = span > 0 ? Double(refreshTimestamps.count) * 86400 / span : 0 + + let stalenessMenuTimestamps = stalenessStartAt.map { start in + menuOpenTimestamps.filter { $0 >= start } + } ?? menuOpenTimestamps + let stalenessSamples = stalenessMenuTimestamps.isEmpty ? [] : self.stalenessSamples( + menuOpenTimestamps: stalenessMenuTimestamps, + refreshTimestamps: refreshTimestamps, + initialFreshAt: stalenessStartAt ?? start) + + return ReplayRun( + metrics: ReplayMetrics( + policyName: policy.name, + simulatedSpanSeconds: span, + totalRefreshCount: refreshTimestamps.count, + refreshCountPer24h: refreshCountPer24h, + stalenessAtMenuOpen: StalenessStats(samples: stalenessSamples), + constrainedCompliance: ConstrainedCompliance( + constrainedDecisionCount: constrainedDecisionCount, + violationCount: violationCount), + interactionAdvanceCount: interactionAdvanceCount, + codingActiveDecisionCount: codingActiveDecisionCount, + codingActiveDelayViolationCount: codingActiveDelayViolationCount), + stalenessSamples: stalenessSamples) + } + + /// Re-evaluates every not-yet-scanned menu open that falls in `(windowStart, scheduledAt]` + /// against `policy`, mirroring `UsageStore.shouldAdvanceAdaptiveTimer(scheduledAt:candidate:)`: + /// a menu open at time `T` computes `policy.decide(now: T, lastMenuOpenAt: T, ...)` (age zero, + /// exactly as `noteMenuOpened(at:)` does with `self.lastMenuOpenAt = date` already applied), and + /// if the resulting candidate (`T + delay`) lands earlier than the currently scheduled refresh, + /// the schedule advances to that candidate. Later menu opens in the same window are then + /// compared against the *advanced* schedule, same as a real second interaction tightening an + /// already-shortened sleep. Returns the (possibly advanced) scheduled time plus how many + /// advances were taken in this window. + private static func applyInteractionAdvances( + policy: some ReplayPolicy, + signals: TraceSignals, + scanIndex: inout Int, + windowStart: Date, + scheduledAt: Date) -> (scheduledAt: Date, advanceCount: Int) + { + var next = scheduledAt + var advanceCount = 0 + while scanIndex < signals.menuOpenTimestamps.count { + let menuOpenAt = signals.menuOpenTimestamps[scanIndex] + guard menuOpenAt > windowStart else { + scanIndex += 1 + continue + } + guard menuOpenAt <= next else { break } + + let (lowPower, thermal) = self.signal( + signals.signalSamples, + timestamps: signals.signalTimestamps, + at: menuOpenAt) + let advanceDecision = policy.decide(ReplayPolicyInput( + now: menuOpenAt, + lastMenuOpenAt: menuOpenAt, + lastCodingActivityAt: self.lastActivity( + signals.activitySamples, + timestamps: signals.activityTimestamps, + at: menuOpenAt), + lowPowerModeEnabled: lowPower, + thermalState: thermal)) + scanIndex += 1 + + guard let advanceDelay = advanceDecision.delaySeconds, advanceDelay > 0 else { continue } + let candidate = menuOpenAt.addingTimeInterval(advanceDelay) + if candidate < next { + next = candidate + advanceCount += 1 + } + } + return (next, advanceCount) + } + + private static func stalenessSamples( + menuOpenTimestamps: [Date], + refreshTimestamps: [Date], + initialFreshAt: Date) -> [Double] + { + menuOpenTimestamps.map { menuOpenAt in + let simulatedRefresh = self.lastValue(refreshTimestamps, atOrBefore: menuOpenAt) + let freshestAt = simulatedRefresh.map { max($0, initialFreshAt) } ?? initialFreshAt + return menuOpenAt.timeIntervalSince(freshestAt) + } + } + + private static func lastActivity( + _ samples: [ActivityObservation], + timestamps: [Date], + at time: Date) -> Date? + { + guard let index = self.lastIndex(timestamps, atOrBefore: time) else { return nil } + return samples[index].lastCodingActivityAt + } + + /// Binds the most recent power/thermal sample at or before `time` (hold-last), falling back + /// to the earliest known sample when `time` precedes every sample (hold-first), and to + /// nominal/not-low-power when no samples exist at all. + private static func signal( + _ samples: [(timestamp: Date, lowPower: Bool, thermal: ReplayThermalState)], + timestamps: [Date], + at time: Date) -> (Bool, ReplayThermalState) + { + guard !samples.isEmpty else { return (false, .nominal) } + if let index = self.lastIndex(timestamps, atOrBefore: time) { + return (samples[index].lowPower, samples[index].thermal) + } + return (samples[0].lowPower, samples[0].thermal) + } + + private static func lastValue(_ timestamps: [Date], atOrBefore time: Date) -> Date? { + guard let index = self.lastIndex(timestamps, atOrBefore: time) else { return nil } + return timestamps[index] + } + + /// Binary search for the last index whose timestamp is `<= time`, assuming `timestamps` is + /// sorted ascending. O(log n) so a long trace (thousands of decisions) stays fast to replay. + private static func lastIndex(_ timestamps: [Date], atOrBefore time: Date) -> Int? { + var low = 0 + var high = timestamps.count - 1 + var result: Int? + while low <= high { + let mid = (low + high) / 2 + if timestamps[mid] <= time { + result = mid + low = mid + 1 + } else { + high = mid - 1 + } + } + return result + } +} diff --git a/Sources/AdaptiveReplayKit/ReplayMetrics.swift b/Sources/AdaptiveReplayKit/ReplayMetrics.swift new file mode 100644 index 0000000000..09db68f770 --- /dev/null +++ b/Sources/AdaptiveReplayKit/ReplayMetrics.swift @@ -0,0 +1,108 @@ +import Foundation + +/// Mean/median/p95 of staleness (seconds since the last simulated refresh) observed at each +/// historical menu-open event. `p95` uses nearest-rank: samples are sorted ascending and index +/// `ceil(0.95 * n) - 1` (clamped to the last index) is reported — the same convention most +/// dashboards use for small-to-medium sample counts, and simple enough to hand-verify in tests. +public struct StalenessStats: Sendable, Equatable { + public let mean: Double + public let median: Double + public let p95: Double + public let sampleCount: Int + + public init(mean: Double, median: Double, p95: Double, sampleCount: Int) { + self.mean = mean + self.median = median + self.p95 = p95 + self.sampleCount = sampleCount + } + + init?(samples: [Double]) { + guard !samples.isEmpty else { return nil } + let sorted = samples.sorted() + self.init( + mean: sorted.reduce(0, +) / Double(sorted.count), + median: Self.percentile(sorted, fraction: 0.5), + p95: Self.percentile(sorted, fraction: 0.95), + sampleCount: sorted.count) + } + + private static func percentile(_ sorted: [Double], fraction: Double) -> Double { + let rank = Int((fraction * Double(sorted.count)).rounded(.up)) + return sorted[max(0, min(sorted.count - 1, rank - 1))] + } +} + +/// Whether a policy honored the "never refresh faster than 30 minutes while constrained (low +/// power or serious/critical thermal)" rule at every simulated decision point where the input was +/// constrained. +public struct ConstrainedCompliance: Sendable, Equatable { + public let constrainedDecisionCount: Int + public let violationCount: Int + + public init(constrainedDecisionCount: Int, violationCount: Int) { + self.constrainedDecisionCount = constrainedDecisionCount + self.violationCount = violationCount + } + + public var isCompliant: Bool { + self.violationCount == 0 + } +} + +public struct ReplayMetrics: Sendable, Equatable { + public let policyName: String + public let simulatedSpanSeconds: TimeInterval + public let totalRefreshCount: Int + public let refreshCountPer24h: Double + public let stalenessAtMenuOpen: StalenessStats? + public let constrainedCompliance: ConstrainedCompliance + /// How many of `totalRefreshCount` were pulled forward by a menu-open interaction rather than + /// firing on the policy's own previously scheduled cadence — i.e. how many times + /// `ReplayEngine.run` took the `advancesOnInteraction` branch for this policy. Always `0` for + /// policies that report `advancesOnInteraction == false` (see `ReplayPolicy`). + public let interactionAdvanceCount: Int + /// Unconstrained replayed decisions with a known transcript-write observation under five minutes old. + public let codingActiveDecisionCount: Int + /// Unconstrained active decisions whose selected delay exceeded the five-minute acceptance cap. + public let codingActiveDelayViolationCount: Int + /// Number of independently simulated awake/run segments contributing to these metrics. + public let segmentCount: Int + /// Wall-clock time excluded after an expected timer deadline because the app was unobserved. + public let excludedGapSeconds: TimeInterval + /// Menu opens before a segment's first recorded refresh, excluded equally for every policy. + public let boundaryCensoredMenuOpenCount: Int + + public init( + policyName: String, + simulatedSpanSeconds: TimeInterval, + totalRefreshCount: Int, + refreshCountPer24h: Double, + stalenessAtMenuOpen: StalenessStats?, + constrainedCompliance: ConstrainedCompliance, + interactionAdvanceCount: Int = 0, + codingActiveDecisionCount: Int = 0, + codingActiveDelayViolationCount: Int = 0, + segmentCount: Int = 1, + excludedGapSeconds: TimeInterval = 0, + boundaryCensoredMenuOpenCount: Int = 0) + { + self.policyName = policyName + self.simulatedSpanSeconds = simulatedSpanSeconds + self.totalRefreshCount = totalRefreshCount + self.refreshCountPer24h = refreshCountPer24h + self.stalenessAtMenuOpen = stalenessAtMenuOpen + self.constrainedCompliance = constrainedCompliance + self.interactionAdvanceCount = interactionAdvanceCount + self.codingActiveDecisionCount = codingActiveDecisionCount + self.codingActiveDelayViolationCount = codingActiveDelayViolationCount + self.segmentCount = segmentCount + self.excludedGapSeconds = excludedGapSeconds + self.boundaryCensoredMenuOpenCount = boundaryCensoredMenuOpenCount + } +} + +struct ReplayRun: Sendable { + let metrics: ReplayMetrics + let stalenessSamples: [Double] +} diff --git a/Sources/AdaptiveReplayKit/ReplayPolicy.swift b/Sources/AdaptiveReplayKit/ReplayPolicy.swift new file mode 100644 index 0000000000..3bf213f221 --- /dev/null +++ b/Sources/AdaptiveReplayKit/ReplayPolicy.swift @@ -0,0 +1,90 @@ +import Foundation + +// Replay harness for the adaptive refresh policy shipped in the `CodexBar` app target. The app +// and replay adapter both call `AdaptiveRefreshPolicyCore`; these types only normalize replay +// inputs and report replay-friendly output. + +/// Coarse thermal-pressure signal matching the two `ProcessInfo.ThermalState` cases the policy +/// distinguishes (`.serious`/`.critical` vs everything else), expressed independently so this +/// library never needs Darwin-only APIs and can build on any platform. +public enum ReplayThermalState: String, Sendable, Codable, CaseIterable { + case nominal + case fair + case serious + case critical + + public var isConstrained: Bool { + self == .serious || self == .critical + } +} + +/// The inputs a refresh-timing policy needs to decide how long to wait before the next refresh. +/// Replay-specific policy input. Platform-independent fields map into the shared policy core. +public struct ReplayPolicyInput: Sendable, Equatable { + public let now: Date + public let lastMenuOpenAt: Date? + /// Most recent transcript write reconstructed from the latest activity observation available + /// at or before `now`. This is nil when that observation could not see either CLI. + public let lastCodingActivityAt: Date? + public let lowPowerModeEnabled: Bool + public let thermalState: ReplayThermalState + + public init( + now: Date, + lastMenuOpenAt: Date?, + lastCodingActivityAt: Date? = nil, + lowPowerModeEnabled: Bool, + thermalState: ReplayThermalState) + { + self.now = now + self.lastMenuOpenAt = lastMenuOpenAt + self.lastCodingActivityAt = lastCodingActivityAt + self.lowPowerModeEnabled = lowPowerModeEnabled + self.thermalState = thermalState + } + + /// Whether this input represents a power/thermal-constrained moment, independent of which + /// policy is deciding. Used by the replay engine to score constrained-tier compliance without + /// depending on any single policy's own notion of "constrained". + public var isConstrained: Bool { + self.lowPowerModeEnabled || self.thermalState.isConstrained + } + + public var codingActivityAgeSeconds: TimeInterval? { + self.lastCodingActivityAt.map { max(0, self.now.timeIntervalSince($0)) } + } +} + +/// A policy's decision: how long to wait, and a short human-readable reason code for reporting. +/// `delaySeconds == nil` means "never schedule another refresh" — the degenerate floor used by +/// `ManualPolicy`. +public struct ReplayPolicyDecision: Sendable, Equatable { + public let delaySeconds: TimeInterval? + public let reason: String + + public init(delaySeconds: TimeInterval?, reason: String) { + self.delaySeconds = delaySeconds + self.reason = reason + } +} + +/// A pure, deterministic function from `ReplayPolicyInput` to `ReplayPolicyDecision`. +public protocol ReplayPolicy: Sendable { + var name: String { get } + + /// Whether opening the menu can pull this policy's next refresh forward, mirroring + /// `UsageStore.noteMenuOpened(at:)`'s guard on `settings.refreshFrequency == .adaptive`: in the + /// real app, only adaptive mode ever advances the timer from an interaction — fixed-cadence and + /// manual modes just record `lastMenuOpenAt` and let the existing schedule run. Defaults to + /// `false` so baseline policies (`FixedIntervalPolicy`, `ManualPolicy`) need no override; only + /// policies that actually model the adaptive table set this to `true`. + var advancesOnInteraction: Bool { get } + + func decide(_ input: ReplayPolicyInput) -> ReplayPolicyDecision +} + +extension ReplayPolicy { + public var advancesOnInteraction: Bool { + false + } +} diff --git a/Sources/AdaptiveReplayKit/ReplayTraceSegmentation.swift b/Sources/AdaptiveReplayKit/ReplayTraceSegmentation.swift new file mode 100644 index 0000000000..72bbede643 --- /dev/null +++ b/Sources/AdaptiveReplayKit/ReplayTraceSegmentation.swift @@ -0,0 +1,123 @@ +import Foundation + +public struct ReplayTraceSegment: Sendable, Equatable { + public let records: [AdaptiveRefreshTraceRecord] + public let start: Date + public let end: Date + + var replayRecords: [AdaptiveRefreshTraceRecord] { + guard self.records.last?.timestamp != self.end else { return self.records } + return self.records + [.refreshCompleted(timestamp: self.end)] + } +} + +public struct ReplaySegmentationReport: Sendable, Equatable { + public let segments: [ReplayTraceSegment] + public let excludedGapSeconds: TimeInterval + public let breakCount: Int + public let graceSeconds: TimeInterval + + public var includedSpanSeconds: TimeInterval { + self.segments.reduce(0) { $0 + max(0, $1.end.timeIntervalSince($1.start)) } + } +} + +/// Splits legacy traces only when observation resumes well after the last timer deadline. The +/// normal scheduled wait remains inside the preceding segment; only overdue wall time is excluded. +public enum ReplayTraceSegmenter { + public static let defaultGraceSeconds: TimeInterval = 5 * 60 + + public static func automatic( + _ records: [AdaptiveRefreshTraceRecord], + graceSeconds: TimeInterval = Self.defaultGraceSeconds) -> ReplaySegmentationReport + { + let sorted = records.sorted { $0.timestamp < $1.timestamp } + guard let first = sorted.first else { + return ReplaySegmentationReport( + segments: [], excludedGapSeconds: 0, breakCount: 0, graceSeconds: graceSeconds) + } + + var segments: [ReplayTraceSegment] = [] + var currentRecords: [AdaptiveRefreshTraceRecord] = [] + var currentStart = first.timestamp + var expectedDeadline: Date? + var excludedGapSeconds: TimeInterval = 0 + + for record in sorted { + if let deadline = expectedDeadline, + record.timestamp.timeIntervalSince(deadline) > graceSeconds, + !currentRecords.isEmpty + { + let end = max(currentRecords.last!.timestamp, deadline) + segments.append(ReplayTraceSegment(records: currentRecords, start: currentStart, end: end)) + excludedGapSeconds += max(0, record.timestamp.timeIntervalSince(end)) + currentRecords = [] + currentStart = record.timestamp + expectedDeadline = nil + } + + currentRecords.append(record) + if record.kind == .decision, let delay = record.delaySeconds, delay > 0 { + expectedDeadline = record.timestamp.addingTimeInterval(delay) + } else if record.kind == .timerAdvanced, let candidate = record.candidateScheduledAt { + expectedDeadline = candidate + } + } + + if let last = currentRecords.last { + segments.append(ReplayTraceSegment(records: currentRecords, start: currentStart, end: last.timestamp)) + } + return ReplaySegmentationReport( + segments: segments, + excludedGapSeconds: excludedGapSeconds, + breakCount: max(0, segments.count - 1), + graceSeconds: graceSeconds) + } +} + +extension ReplayEngine { + public static func runSegmented( + trace: [AdaptiveRefreshTraceRecord], + policy: some ReplayPolicy, + graceSeconds: TimeInterval = ReplayTraceSegmenter.defaultGraceSeconds) -> ReplayMetrics + { + let report = ReplayTraceSegmenter.automatic(trace, graceSeconds: graceSeconds) + let stalenessStarts = report.segments.map { segment in + segment.records.first(where: { $0.kind == .refreshCompleted })?.timestamp + } + let runs = zip(report.segments, stalenessStarts).map { segment, stalenessStart in + self.runDetailed( + trace: segment.replayRecords, + policy: policy, + stalenessStartAt: stalenessStart ?? .distantFuture) + } + let boundaryCensoredMenuOpenCount = zip(report.segments, stalenessStarts).reduce(0) { partial, pair in + let (segment, stalenessStart) = pair + return partial + segment.records.count(where: { record in + record.kind == .menuOpen && (stalenessStart.map { record.timestamp < $0 } ?? true) + }) + } + let span = report.includedSpanSeconds + let refreshCount = runs.reduce(0) { $0 + $1.metrics.totalRefreshCount } + let stalenessSamples = runs.flatMap(\.stalenessSamples) + return ReplayMetrics( + policyName: policy.name, + simulatedSpanSeconds: span, + totalRefreshCount: refreshCount, + refreshCountPer24h: span > 0 ? Double(refreshCount) * 86400 / span : 0, + stalenessAtMenuOpen: StalenessStats(samples: stalenessSamples), + constrainedCompliance: ConstrainedCompliance( + constrainedDecisionCount: runs.reduce(0) { + $0 + $1.metrics.constrainedCompliance.constrainedDecisionCount + }, + violationCount: runs.reduce(0) { $0 + $1.metrics.constrainedCompliance.violationCount }), + interactionAdvanceCount: runs.reduce(0) { $0 + $1.metrics.interactionAdvanceCount }, + codingActiveDecisionCount: runs.reduce(0) { $0 + $1.metrics.codingActiveDecisionCount }, + codingActiveDelayViolationCount: runs.reduce(0) { + $0 + $1.metrics.codingActiveDelayViolationCount + }, + segmentCount: report.segments.count, + excludedGapSeconds: report.excludedGapSeconds, + boundaryCensoredMenuOpenCount: boundaryCensoredMenuOpenCount) + } +} diff --git a/Sources/CSQLite3/module.modulemap b/Sources/CSQLite3/module.modulemap new file mode 100644 index 0000000000..ae14eca222 --- /dev/null +++ b/Sources/CSQLite3/module.modulemap @@ -0,0 +1,5 @@ +module CSQLite3 [system] { + header "shim.h" + link "sqlite3" + export * +} diff --git a/Sources/CSQLite3/shim.h b/Sources/CSQLite3/shim.h new file mode 100644 index 0000000000..f52e1f09e6 --- /dev/null +++ b/Sources/CSQLite3/shim.h @@ -0,0 +1 @@ +#include diff --git a/Sources/CodexBar/About.swift b/Sources/CodexBar/About.swift index 677ea6e5a9..32d10e943b 100644 --- a/Sources/CodexBar/About.swift +++ b/Sources/CodexBar/About.swift @@ -24,7 +24,7 @@ func showAbout() { let credits = NSMutableAttributedString(string: "Peter Steinberger — MIT License\n") credits.append(makeLink("GitHub", urlString: "https://github.com/steipete/CodexBar")) credits.append(separator) - credits.append(makeLink("Website", urlString: "https://codexbar.app")) + credits.append(makeLink("Website", urlString: "https://codex.bar")) credits.append(separator) credits.append(makeLink("Twitter", urlString: "https://twitter.com/steipete")) credits.append(separator) diff --git a/Sources/CodexBar/AdaptiveActivityConsentPresenter.swift b/Sources/CodexBar/AdaptiveActivityConsentPresenter.swift new file mode 100644 index 0000000000..6625190f5d --- /dev/null +++ b/Sources/CodexBar/AdaptiveActivityConsentPresenter.swift @@ -0,0 +1,34 @@ +import AppKit + +@MainActor +enum AdaptiveActivityConsentPresenter { + private static var isPresenting = false + + @discardableResult + static func presentIfNeeded(settings: SettingsStore) -> Bool { + guard !SettingsStore.isRunningTests, + !self.isPresenting, + settings.shouldRequestAdaptiveActivityScanConsent + else { return false } + + self.isPresenting = true + defer { self.isPresenting = false } + + let alert = NSAlert() + alert.alertStyle = .informational + alert.messageText = L("adaptive_activity_consent_title") + alert.informativeText = L("adaptive_activity_consent_message") + alert.addButton(withTitle: L("adaptive_activity_consent_allow")) + let declineButton = alert.addButton(withTitle: L("adaptive_activity_consent_decline")) + declineButton.keyEquivalent = "\u{1B}" + + NSApp.activate(ignoringOtherApps: true) + if alert.runModal() == .alertFirstButtonReturn { + settings.adaptiveActivityScanConsent = .allowed + } else { + settings.adaptiveActivityScanConsent = .declined + settings.refreshFrequency = .adaptive + } + return true + } +} diff --git a/Sources/CodexBar/AdaptiveRefreshPolicy.swift b/Sources/CodexBar/AdaptiveRefreshPolicy.swift new file mode 100644 index 0000000000..fe60617669 --- /dev/null +++ b/Sources/CodexBar/AdaptiveRefreshPolicy.swift @@ -0,0 +1,37 @@ +import AdaptiveRefreshCore +import Foundation + +/// Decides how long to wait before the next automatic usage refresh. +/// Pure by construction: every signal arrives via `Input`, so the same +/// input always yields the same `Decision` with no clock or system reads. +struct AdaptiveRefreshPolicy: Sendable { + struct Input: Sendable, Equatable { + let now: Date + let lastMenuOpenAt: Date? + let lastCodingActivityAt: Date? + let lowPowerModeEnabled: Bool + let thermalState: ProcessInfo.ThermalState + } + + typealias Reason = AdaptiveRefreshPolicyCore.Reason + typealias Decision = AdaptiveRefreshPolicyCore.Decision + + /// Representative cadence for consumers that need a single interval but cannot reach live + /// signals (`ProviderRegistry` builds provider specs before a `UsageStore` exists). Matches + /// `warmDelay`: the steady-state cadence while the user is active, which is when + /// interval-derived heuristics such as the persistent-CLI-session idle window matter most. + static let nominalIntervalForHeuristics = AdaptiveRefreshPolicyCore.nominalIntervalForHeuristics + + func nextDelay(for input: Input) -> Decision { + AdaptiveRefreshPolicyCore().nextDelay(for: AdaptiveRefreshPolicyCore.Input( + now: input.now, + lastMenuOpenAt: input.lastMenuOpenAt, + lastCodingActivityAt: input.lastCodingActivityAt, + lowPowerModeEnabled: input.lowPowerModeEnabled, + thermalPressure: Self.isConstrained(input.thermalState) ? .constrained : .nominal)) + } + + private static func isConstrained(_ state: ProcessInfo.ThermalState) -> Bool { + state == .serious || state == .critical + } +} diff --git a/Sources/CodexBar/AgentSessionsStore.swift b/Sources/CodexBar/AgentSessionsStore.swift new file mode 100644 index 0000000000..7c047ccdb0 --- /dev/null +++ b/Sources/CodexBar/AgentSessionsStore.swift @@ -0,0 +1,215 @@ +import CodexBarCore +import Foundation +import Observation + +struct AgentSessionRemoteRefreshGate { + private(set) var generation = 0 + private(set) var isInFlight = false + private(set) var isPending = false + + mutating func settingsDidChange() { + self.generation += 1 + self.isPending = self.isInFlight + } + + mutating func begin() -> Int? { + guard !self.isInFlight else { + return nil + } + self.isInFlight = true + self.isPending = false + return self.generation + } + + mutating func finish(generation: Int) -> (shouldPublish: Bool, shouldRetry: Bool) { + self.isInFlight = false + let outcome = (generation == self.generation, self.isPending) + self.isPending = false + return outcome + } +} + +@MainActor +@Observable +final class AgentSessionsStore { + typealias LocalScan = @Sendable (_ includeFileOnlySessions: Bool) async -> [AgentSession] + + private let settings: SettingsStore + private let localScan: LocalScan + private let remoteFetcher: RemoteSessionFetcher + @ObservationIgnored private var localRefreshTask: Task? + @ObservationIgnored private var remoteRefreshTask: Task? + @ObservationIgnored private var localRefreshInFlight = false + @ObservationIgnored private var remoteRefreshGate = AgentSessionRemoteRefreshGate() + @ObservationIgnored var onUpdate: (@MainActor () -> Void)? + + private(set) var localSessions: [AgentSession] = [] + private(set) var remoteHosts: [RemoteSessionHostResult] = [] + private(set) var lastUpdatedAt: Date? + private(set) var latestLocalActivityAt: Date? + + init( + settings: SettingsStore, + localScanner: LocalAgentSessionScanner = LocalAgentSessionScanner(), + remoteFetcher: RemoteSessionFetcher = RemoteSessionFetcher()) + { + self.settings = settings + self.localScan = { includeFileOnlySessions in + await localScanner.scan(includeFileOnlySessions: includeFileOnlySessions) + } + self.remoteFetcher = remoteFetcher + } + + init( + settings: SettingsStore, + localScan: @escaping LocalScan, + remoteFetcher: RemoteSessionFetcher = RemoteSessionFetcher()) + { + self.settings = settings + self.localScan = localScan + self.remoteFetcher = remoteFetcher + } + + var totalCount: Int { + self.localSessions.count + self.remoteHosts.reduce(0) { $0 + $1.sessions.count } + } + + /// Adaptive refresh uses local metadata only after explicit consent. Remote sessions remain + /// behind the Agent Sessions setting because they can involve Tailscale discovery and SSH. + var localMonitoringEnabled: Bool { + self.settings.agentSessionsEnabled || self.settings.adaptiveActivityScanningEnabled + } + + nonisolated static func latestActivityAt(in sessions: [AgentSession]) -> Date? { + sessions.compactMap(\.lastActivityAt).max() + } + + nonisolated static func shouldScanLocally( + agentSessionsEnabled: Bool, + adaptiveActivityScanningEnabled: Bool, + lowPowerModeEnabled: Bool, + thermalState: ProcessInfo.ThermalState) -> Bool + { + if agentSessionsEnabled { + return true + } + guard adaptiveActivityScanningEnabled, !lowPowerModeEnabled else { return false } + return thermalState != .serious && thermalState != .critical + } + + func start() { + guard self.localRefreshTask == nil, self.remoteRefreshTask == nil else { return } + self.localRefreshTask = Task { [weak self] in + while !Task.isCancelled { + await self?.refreshLocal() + try? await Task.sleep(for: .seconds(30)) + } + } + self.remoteRefreshTask = Task { [weak self] in + while !Task.isCancelled { + await self?.refreshRemote() + try? await Task.sleep(for: .seconds(60)) + } + } + } + + func stop() { + self.localRefreshTask?.cancel() + self.remoteRefreshTask?.cancel() + self.localRefreshTask = nil + self.remoteRefreshTask = nil + } + + func settingsDidChange(remoteConfigurationChanged: Bool = true) { + if remoteConfigurationChanged { + self.remoteRefreshGate.settingsDidChange() + } + if !self.settings.agentSessionsEnabled { + // Adaptive keeps only the timestamp signal. Retained session paths and identities + // remain scoped to the explicitly enabled Agent Sessions UI. + self.localSessions = [] + self.remoteHosts = [] + } + guard self.localMonitoringEnabled else { + self.latestLocalActivityAt = nil + self.onUpdate?() + return + } + guard !SettingsStore.isRunningTests else { return } + Task { [weak self] in + await self?.refreshLocal() + if remoteConfigurationChanged, self?.settings.agentSessionsEnabled == true { + await self?.refreshRemote() + } + } + } + + func refreshOnMenuOpen() { + guard self.localMonitoringEnabled, !SettingsStore.isRunningTests else { return } + Task { [weak self] in + await self?.refreshLocal() + if self?.settings.agentSessionsEnabled == true { + await self?.refreshRemote() + } + } + } + + func focus(_ session: AgentSession, remoteHost: String?) { + if let remoteHost { + Task { + await self.remoteFetcher.focus(sessionID: session.id, host: remoteHost) + } + } else { + _ = SessionWindowFocuser.focus(session) + } + } + + func refreshLocal() async { + guard self.localMonitoringEnabled, !self.localRefreshInFlight else { return } + let processInfo = ProcessInfo.processInfo + guard Self.shouldScanLocally( + agentSessionsEnabled: self.settings.agentSessionsEnabled, + adaptiveActivityScanningEnabled: self.settings.adaptiveActivityScanningEnabled, + lowPowerModeEnabled: processInfo.isLowPowerModeEnabled, + thermalState: processInfo.thermalState) + else { return } + self.localRefreshInFlight = true + let sessions = await self.localScan(self.settings.agentSessionsEnabled) + self.localRefreshInFlight = false + guard !Task.isCancelled, self.localMonitoringEnabled else { return } + self.applyLocalScanResult(sessions) + } + + func applyLocalScanResult(_ sessions: [AgentSession], updatedAt: Date = Date()) { + self.latestLocalActivityAt = Self.latestActivityAt(in: sessions) + self.localSessions = self.settings.agentSessionsEnabled ? sessions : [] + self.lastUpdatedAt = updatedAt + self.onUpdate?() + } + + private func refreshRemote() async { + guard self.settings.agentSessionsEnabled else { return } + guard var generation = self.remoteRefreshGate.begin() else { return } + while self.settings.agentSessionsEnabled { + var hosts = self.manualHosts + await hosts.append(contentsOf: self.remoteFetcher.discoveredHosts()) + let results = await self.remoteFetcher.fetch(hosts: hosts) + let outcome = self.remoteRefreshGate.finish(generation: generation) + guard !Task.isCancelled, self.settings.agentSessionsEnabled else { return } + if outcome.shouldPublish { + self.remoteHosts = results + self.lastUpdatedAt = Date() + self.onUpdate?() + } + guard outcome.shouldRetry, let nextGeneration = self.remoteRefreshGate.begin() else { return } + generation = nextGeneration + } + } + + private var manualHosts: [String] { + self.settings.agentSessionsManualHosts + .split(separator: ",") + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + } +} diff --git a/Sources/CodexBar/ChartBarHoverSelection.swift b/Sources/CodexBar/ChartBarHoverSelection.swift new file mode 100644 index 0000000000..cc01e74f99 --- /dev/null +++ b/Sources/CodexBar/ChartBarHoverSelection.swift @@ -0,0 +1,11 @@ +import Foundation + +enum ChartBarHoverSelection { + static func accepts(distanceFromBarCenter: CGFloat, barHalfWidth: CGFloat, selectableCount: Int) -> Bool { + selectableCount <= 1 || distanceFromBarCenter <= barHalfWidth + } + + static func nextCalendarDay(after date: Date, calendar: Calendar = .current) -> Date { + calendar.date(byAdding: .day, value: 1, to: date) ?? date.addingTimeInterval(86400) + } +} diff --git a/Sources/CodexBar/ClaudeLoginRunner.swift b/Sources/CodexBar/ClaudeLoginRunner.swift index e9f89934f2..1c10f6e736 100644 --- a/Sources/CodexBar/ClaudeLoginRunner.swift +++ b/Sources/CodexBar/ClaudeLoginRunner.swift @@ -3,6 +3,9 @@ import Darwin import Foundation struct ClaudeLoginRunner { + static let loginArguments = ["auth", "login", "--claudeai"] + private static let successMarkers = ["Successfully logged in", "Login successful", "Logged in successfully"] + enum Phase { case requesting case waitingBrowser @@ -22,22 +25,35 @@ struct ClaudeLoginRunner { let authLink: String? } - static func run(timeout: TimeInterval = 120, onPhaseChange: @escaping @Sendable (Phase) -> Void) async -> Result { + static func run( + timeout: TimeInterval = 120, + binary: String = "claude", + environment: [String: String]? = nil, + onPhaseChange: @escaping @Sendable (Phase) -> Void) async -> Result + { await Task(priority: .userInitiated) { onPhaseChange(.requesting) do { - let runResult = try self.runPTY(timeout: timeout, onPhaseChange: onPhaseChange) + let runResult = try self.runPTY( + timeout: timeout, + binary: binary, + environment: environment, + onPhaseChange: onPhaseChange) let link = self.firstLink(in: runResult.output) - if let link { + switch runResult.completion { + case .processExited(status: 0): + return Result(outcome: .success, output: runResult.output, authLink: link) + case let .processExited(status): + return Result(outcome: .failed(status: status), output: runResult.output, authLink: link) + case .outputCondition where self.successMarkers.contains(where: runResult.output.contains): return Result(outcome: .success, output: runResult.output, authLink: link) + case .outputCondition, .idleTimeout, .deadlineExceeded: + return Result(outcome: .timedOut, output: runResult.output, authLink: link) } - return Result(outcome: .timedOut, output: runResult.output, authLink: nil) } catch LoginError.binaryNotFound { return Result(outcome: .missingBinary, output: "", authLink: nil) } catch let LoginError.timedOut(text) { return Result(outcome: .timedOut, output: text, authLink: self.firstLink(in: text)) - } catch let LoginError.failed(status, text) { - return Result(outcome: .failed(status: status), output: text, authLink: self.firstLink(in: text)) } catch { return Result(outcome: .launchFailed(error.localizedDescription), output: "", authLink: nil) } @@ -49,32 +65,36 @@ struct ClaudeLoginRunner { private enum LoginError: Error { case binaryNotFound case timedOut(text: String) - case failed(status: Int32, text: String) case launchFailed(String) } private struct PTYRunResult { let output: String + let completion: TTYCommandRunner.Result.Completion } private static func runPTY( timeout: TimeInterval, + binary: String, + environment: [String: String]?, onPhaseChange: @escaping @Sendable (Phase) -> Void) throws -> PTYRunResult { let runner = TTYCommandRunner() var options = TTYCommandRunner.Options(rows: 50, cols: 160, timeout: timeout) - options.extraArgs = ["/login"] + options.extraArgs = self.loginArguments + options.baseEnvironment = environment options.stopOnURL = false // keep running until CLI confirms - options.stopOnSubstrings = ["Successfully logged in", "Login successful", "Logged in successfully"] - options.sendEnterEvery = 1.0 + options.stopOnSubstrings = self.successMarkers + options.sendOnSubstrings = ["press ENTER to open in browser": "\r"] options.settleAfterStop = 0.35 + options.returnOnEmptyProcessExit = true do { let result = try runner.run( - binary: "claude", + binary: binary, send: "", options: options, onURLDetected: { onPhaseChange(.waitingBrowser) }) - return PTYRunResult(output: result.text) + return PTYRunResult(output: result.text, completion: result.completion) } catch TTYCommandRunner.Error.binaryNotFound { throw LoginError.binaryNotFound } catch TTYCommandRunner.Error.timedOut { diff --git a/Sources/CodexBar/ClickToCopyOverlay.swift b/Sources/CodexBar/ClickToCopyOverlay.swift index 114602854d..b6ea3ea7d7 100644 --- a/Sources/CodexBar/ClickToCopyOverlay.swift +++ b/Sources/CodexBar/ClickToCopyOverlay.swift @@ -1,6 +1,35 @@ import AppKit import SwiftUI +@MainActor +enum MenuPasteboardCopy { + typealias DeferredAction = @MainActor @Sendable () -> Void + typealias Scheduler = @MainActor @Sendable (@escaping DeferredAction) -> Void + typealias Writer = @MainActor @Sendable (String) -> Void + + static func perform( + _ text: String, + scheduler: Scheduler = Self.schedule, + writer: @escaping Writer = Self.write, + completion: @escaping DeferredAction = {}) + { + scheduler { + writer(text) + completion() + } + } + + private static func schedule(_ action: @escaping DeferredAction) { + DispatchQueue.main.async(execute: action) + } + + private static func write(_ text: String) { + let pasteboard = NSPasteboard.general + pasteboard.clearContents() + pasteboard.setString(text, forType: .string) + } +} + struct ClickToCopyOverlay: NSViewRepresentable { let copyText: String @@ -9,15 +38,25 @@ struct ClickToCopyOverlay: NSViewRepresentable { } func updateNSView(_ nsView: ClickToCopyView, context: Context) { + // Guard against no-op writes to avoid AppKit view invalidation on every + // parent card SwiftUI diff (each MenuCardView body re-eval runs through + // .overlay { ClickToCopyOverlay(...) }, which calls updateNSView even + // when copyText is unchanged). + guard nsView.copyText != self.copyText else { return } nsView.copyText = self.copyText } } final class ClickToCopyView: NSView { var copyText: String + private let copyAction: (String) -> Void - init(copyText: String) { + init( + copyText: String, + copyAction: @escaping (String) -> Void = { MenuPasteboardCopy.perform($0) }) + { self.copyText = copyText + self.copyAction = copyAction super.init(frame: .zero) self.wantsLayer = false } @@ -33,8 +72,6 @@ final class ClickToCopyView: NSView { override func mouseDown(with event: NSEvent) { _ = event - let pb = NSPasteboard.general - pb.clearContents() - pb.setString(self.copyText, forType: .string) + self.copyAction(self.copyText) } } diff --git a/Sources/CodexBar/CodexAccountUsageSnapshotStore.swift b/Sources/CodexBar/CodexAccountUsageSnapshotStore.swift index 207ca5b8a3..2787d44baf 100644 --- a/Sources/CodexBar/CodexAccountUsageSnapshotStore.swift +++ b/Sources/CodexBar/CodexAccountUsageSnapshotStore.swift @@ -14,11 +14,41 @@ struct FileCodexAccountUsageSnapshotStore: CodexAccountUsageSnapshotStoring, @un private struct Record: Codable { let id: String + let accountIdentity: AccountIdentity? let snapshot: UsageSnapshot? let error: String? let sourceLabel: String? } + private struct AccountIdentity: Codable, Equatable { + let normalizedEmail: String? + let workspaceAccountID: String? + let authFingerprint: String? + let storedAccountID: UUID? + let selectionSource: CodexActiveSource? + + init(account: CodexVisibleAccount) { + self.normalizedEmail = CodexIdentityResolver.normalizeEmail(account.email) + self.workspaceAccountID = CodexOpenAIWorkspaceResolver.normalizeWorkspaceAccountID( + account.workspaceAccountID) + self.authFingerprint = CodexAuthFingerprint.normalize(account.authFingerprint) + self.storedAccountID = account.storedAccountID + self.selectionSource = account.selectionSource + } + + func matches(_ account: CodexVisibleAccount) -> Bool { + guard let normalizedEmail = self.normalizedEmail, + normalizedEmail == CodexIdentityResolver.normalizeEmail(account.email), + let workspaceAccountID = self.workspaceAccountID, + workspaceAccountID == CodexOpenAIWorkspaceResolver.normalizeWorkspaceAccountID( + account.workspaceAccountID) + else { + return false + } + return true + } + } + private static let currentVersion = 1 private let fileURL: URL @@ -41,9 +71,10 @@ struct FileCodexAccountUsageSnapshotStore: CodexAccountUsageSnapshotStoring, @un let accountsByID = Dictionary(uniqueKeysWithValues: accounts.map { ($0.id, $0) }) return payload.records.compactMap { record in guard let account = accountsByID[record.id] else { return nil } + guard record.accountIdentity?.matches(account) == true else { return nil } return CodexAccountUsageSnapshot( account: account, - snapshot: record.snapshot, + snapshot: Self.relabelSnapshot(record.snapshot, for: account), error: record.error, sourceLabel: record.sourceLabel) } @@ -52,9 +83,12 @@ struct FileCodexAccountUsageSnapshotStore: CodexAccountUsageSnapshotStoring, @un func store(_ snapshots: [CodexAccountUsageSnapshot]) { let payload = Payload( version: Self.currentVersion, - records: snapshots.map { snapshot in - Record( + records: snapshots.compactMap { snapshot in + let identity = AccountIdentity(account: snapshot.account) + guard identity.normalizedEmail != nil, identity.workspaceAccountID != nil else { return nil } + return Record( id: snapshot.id, + accountIdentity: identity, snapshot: snapshot.snapshot, error: snapshot.error, sourceLabel: snapshot.sourceLabel) @@ -77,6 +111,18 @@ struct FileCodexAccountUsageSnapshotStore: CodexAccountUsageSnapshotStoring, @un } } + private static func relabelSnapshot(_ snapshot: UsageSnapshot?, for account: CodexVisibleAccount) + -> UsageSnapshot? + { + guard let snapshot else { return nil } + let identity = snapshot.identity(for: .codex) + return snapshot.withIdentity(ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: account.email, + accountOrganization: identity?.accountOrganization, + loginMethod: identity?.loginMethod ?? account.workspaceLabel)) + } + static func defaultURL() -> URL { let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first ?? FileManager.default.homeDirectoryForCurrentUser diff --git a/Sources/CodexBar/CodexLoginRunner.swift b/Sources/CodexBar/CodexLoginRunner.swift index f6734588ed..09b941eacb 100644 --- a/Sources/CodexBar/CodexLoginRunner.swift +++ b/Sources/CodexBar/CodexLoginRunner.swift @@ -16,18 +16,24 @@ struct CodexLoginRunner { let output: String } - static func run(homePath: String? = nil, timeout: TimeInterval = 120) async -> Result { + static func run( + homePath: String? = nil, + timeout: TimeInterval = 120, + outputDrainTimeout: TimeInterval = 3, + environment: [String: String] = ProcessInfo.processInfo.environment, + loginPATH: [String]? = LoginShellPathCache.shared.current) async -> Result + { await Task(priority: .userInitiated) { - var env = ProcessInfo.processInfo.environment + var env = environment env["PATH"] = PathBuilder.effectivePATH( purposes: [.rpc, .tty, .nodeTooling], env: env, - loginPATH: LoginShellPathCache.shared.current) + loginPATH: loginPATH) env = CodexHomeScope.scopedEnvironment(base: env, codexHome: homePath) guard let executable = BinaryLocator.resolveCodexBinary( env: env, - loginPATH: LoginShellPathCache.shared.current) + loginPATH: loginPATH) else { return Result(outcome: .missingBinary, output: "") } @@ -41,6 +47,13 @@ struct CodexLoginRunner { let stderr = Pipe() process.standardOutput = stdout process.standardError = stderr + let stdoutCapture = ProcessPipeCapture(pipe: stdout) + let stderrCapture = ProcessPipeCapture(pipe: stderr) + + let termination = ProcessTermination() + process.terminationHandler = { _ in + termination.resolve(timedOut: false) + } var processGroup: pid_t? do { @@ -49,13 +62,18 @@ struct CodexLoginRunner { } catch { return Result(outcome: .launchFailed(error.localizedDescription), output: "") } + stdoutCapture.start() + stderrCapture.start() - let timedOut = await self.wait(for: process, timeout: timeout) + let timedOut = await self.wait(timeout: timeout, termination: termination) if timedOut { self.terminate(process, processGroup: processGroup) } - let output = await self.combinedOutput(stdout: stdout, stderr: stderr) + let output = await self.combinedOutput( + stdout: stdoutCapture, + stderr: stderrCapture, + timeout: outputDrainTimeout) if timedOut { return Result(outcome: .timedOut, output: output) } @@ -68,21 +86,58 @@ struct CodexLoginRunner { }.value } - private static func wait(for process: Process, timeout: TimeInterval) async -> Bool { - await withTaskGroup(of: Bool.self) { group -> Bool in - group.addTask { - process.waitUntilExit() - return false + private final class ProcessTermination: @unchecked Sendable { + private let lock = NSLock() + private var timedOut: Bool? + private var continuation: CheckedContinuation? + + func resolve(timedOut: Bool) { + let continuation: CheckedContinuation? + self.lock.lock() + guard self.timedOut == nil else { + self.lock.unlock() + return + } + self.timedOut = timedOut + continuation = self.continuation + self.continuation = nil + self.lock.unlock() + continuation?.resume(returning: timedOut) + } + + func wait() async -> Bool { + await withCheckedContinuation { continuation in + let timedOut: Bool? + self.lock.lock() + timedOut = self.timedOut + if timedOut == nil { + self.continuation = continuation + } + self.lock.unlock() + + if let timedOut { + continuation.resume(returning: timedOut) + } } - group.addTask { - let nanos = UInt64(max(0, timeout) * 1_000_000_000) - try? await Task.sleep(nanoseconds: nanos) - return true + } + } + + private static func wait(timeout: TimeInterval, termination: ProcessTermination) async -> Bool { + let timeoutTask = Task.detached(priority: .userInitiated) { + try? await Task.sleep(nanoseconds: self.timeoutNanoseconds(timeout)) + if Task.isCancelled == false { + termination.resolve(timedOut: true) } - let result = await group.next() ?? false - group.cancelAll() - return result } + let timedOut = await termination.wait() + timeoutTask.cancel() + return timedOut + } + + private static func timeoutNanoseconds(_ timeout: TimeInterval) -> UInt64 { + guard timeout.isFinite else { return UInt64.max } + let seconds = max(0, min(timeout, Double(UInt64.max) / 1_000_000_000)) + return UInt64(seconds * 1_000_000_000) } private static func terminate(_ process: Process, processGroup: pid_t?) { @@ -111,45 +166,28 @@ struct CodexLoginRunner { return setpgid(pid, pid) == 0 ? pid : nil } - private static func combinedOutput(stdout: Pipe, stderr: Pipe) async -> String { - async let out = self.readToEnd(stdout) - async let err = self.readToEnd(stderr) - let stdoutText = await out - let stderrText = await err - - let merged: String = if !stdoutText.isEmpty, !stderrText.isEmpty { - [stdoutText, stderrText].joined(separator: "\n") + private static func combinedOutput( + stdout: ProcessPipeCapture, + stderr: ProcessPipeCapture, + timeout: TimeInterval) async -> String + { + let drainTimeout = Duration.seconds(max(0, timeout)) + async let outData = stdout.finish(timeout: drainTimeout) + async let errData = stderr.finish(timeout: drainTimeout) + let out = await self.decode(outData) + let err = await self.decode(errData) + + let merged: String = if !out.isEmpty, !err.isEmpty { + [out, err].joined(separator: "\n") } else { - stdoutText + stderrText + out + err } let trimmed = merged.trimmingCharacters(in: .whitespacesAndNewlines) let limited = trimmed.prefix(4000) return limited.isEmpty ? L("No output captured.") : String(limited) } - private static func readToEnd(_ pipe: Pipe, timeout: TimeInterval = 3.0) async -> String { - await withTaskGroup(of: String?.self) { group -> String in - group.addTask { - if #available(macOS 13.0, *) { - if let data = try? pipe.fileHandleForReading.readToEnd() { return self.decode(data) } - } - let data = pipe.fileHandleForReading.readDataToEndOfFile() - return Self.decode(data) - } - group.addTask { - let nanos = UInt64(max(0, timeout) * 1_000_000_000) - try? await Task.sleep(nanoseconds: nanos) - return nil - } - let result = await group.next() - group.cancelAll() - if let result, let text = result { return text } - return "" - } - } - private static func decode(_ data: Data) -> String { - guard let text = String(data: data, encoding: .utf8) else { return "" } - return text + ProcessPipeCapture.decodeUTF8(data) } } diff --git a/Sources/CodexBar/CodexOwnershipContext.swift b/Sources/CodexBar/CodexOwnershipContext.swift index 09326cbfd5..7231ee6308 100644 --- a/Sources/CodexBar/CodexOwnershipContext.swift +++ b/Sources/CodexBar/CodexOwnershipContext.swift @@ -9,6 +9,7 @@ struct CodexOwnershipContext { let planUtilizationLegacyEmailHash: String? let currentWeeklyResetAt: Date? let hasAdjacentMultiAccountVeto: Bool + let hasAdjacentEmailScopeAmbiguity: Bool } extension UsageStore { @@ -65,7 +66,43 @@ extension UsageStore { Self.codexLegacyPlanUtilizationEmailHashKey(for: $0) }, currentWeeklyResetAt: currentWeeklyResetAt, - hasAdjacentMultiAccountVeto: self.codexHasAdjacentMultiAccountVeto()) + hasAdjacentMultiAccountVeto: self.codexHasAdjacentMultiAccountVeto(), + hasAdjacentEmailScopeAmbiguity: normalizedEmail.map { + self.codexHasAdjacentEmailScopeAmbiguity(normalizedEmail: $0) || + self.codexVisibleAccountsHaveAdjacentEmailScopeAmbiguity(normalizedEmail: $0) + } ?? false) + } + + func codexOwnershipContext( + forVisibleAccount account: CodexVisibleAccount, + currentWeeklyResetAt: Date? = nil) -> CodexOwnershipContext + { + let normalizedEmail = CodexIdentityResolver.normalizeEmail(account.email) + let workspaceAccountID = CodexOpenAIWorkspaceResolver.normalizeWorkspaceAccountID(account.workspaceAccountID) + let canonicalIdentity: CodexIdentity = if let workspaceAccountID { + .providerAccount(id: workspaceAccountID) + } else if let normalizedEmail { + .emailOnly(normalizedEmail: normalizedEmail) + } else { + .unresolved + } + + return CodexOwnershipContext( + canonicalKey: CodexHistoryOwnership.canonicalKey(for: canonicalIdentity), + canonicalEmailHashKey: normalizedEmail.map { CodexHistoryOwnership.canonicalEmailHashKey(for: $0) }, + historicalLegacyEmailHash: normalizedEmail.map { + CodexHistoryOwnership.legacyEmailHash(normalizedEmail: $0) + }, + planUtilizationLegacyEmailHash: normalizedEmail.map { + Self.codexLegacyPlanUtilizationEmailHashKey(for: $0) + }, + currentWeeklyResetAt: currentWeeklyResetAt, + hasAdjacentMultiAccountVeto: self.codexHasAdjacentMultiAccountVeto() || + self.codexVisibleAccountsHaveAdjacentMultiAccountVeto(), + hasAdjacentEmailScopeAmbiguity: normalizedEmail.map { + self.codexHasAdjacentEmailScopeAmbiguity(normalizedEmail: $0) || + self.codexVisibleAccountsHaveAdjacentEmailScopeAmbiguity(normalizedEmail: $0) + } ?? false) } func codexHasAdjacentMultiAccountVeto() -> Bool { @@ -87,6 +124,59 @@ extension UsageStore { return distinctAccounts.count > 1 } + private func codexHasAdjacentEmailScopeAmbiguity(normalizedEmail: String) -> Bool { + let snapshot = self.settings.codexAccountReconciliationSnapshot + var distinctAccounts: Set = [] + + if let activeManagedAccount = self.settings.activeManagedCodexAccount, + CodexIdentityResolver.normalizeEmail(snapshot.runtimeEmail(for: activeManagedAccount)) == normalizedEmail + { + distinctAccounts.insert(CodexIdentityMatcher.selectionKey( + for: snapshot.runtimeIdentity(for: activeManagedAccount), + fallbackEmail: snapshot.runtimeEmail(for: activeManagedAccount))) + } + + if let liveSystemAccount = snapshot.liveSystemAccount, + CodexIdentityResolver.normalizeEmail(liveSystemAccount.email) == normalizedEmail + { + distinctAccounts.insert(CodexIdentityMatcher.selectionKey( + for: snapshot.runtimeIdentity(for: liveSystemAccount), + fallbackEmail: liveSystemAccount.email)) + } + + return distinctAccounts.count > 1 + } + + private func codexVisibleAccountsHaveAdjacentMultiAccountVeto() -> Bool { + let accounts = self.settings.codexVisibleAccountProjection.visibleAccounts + var distinctAccounts: Set = [] + for account in accounts { + if let workspaceAccountID = CodexOpenAIWorkspaceResolver.normalizeWorkspaceAccountID( + account.workspaceAccountID) + { + distinctAccounts.insert("provider:\(workspaceAccountID)") + } else if let normalizedEmail = CodexIdentityResolver.normalizeEmail(account.email) { + distinctAccounts.insert("email:\(normalizedEmail)") + } + } + return distinctAccounts.count > 1 + } + + private func codexVisibleAccountsHaveAdjacentEmailScopeAmbiguity(normalizedEmail: String) -> Bool { + let accounts = self.settings.codexVisibleAccountProjection.visibleAccounts + var distinctAccounts: Set = [] + for account in accounts where CodexIdentityResolver.normalizeEmail(account.email) == normalizedEmail { + if let workspaceAccountID = CodexOpenAIWorkspaceResolver.normalizeWorkspaceAccountID( + account.workspaceAccountID) + { + distinctAccounts.insert("provider:\(workspaceAccountID)") + } else { + distinctAccounts.insert("email:\(normalizedEmail)") + } + } + return distinctAccounts.count > 1 + } + nonisolated static func codexLegacyPlanUtilizationEmailHashKey(for normalizedEmail: String) -> String { self.sha256Hex("\(UsageProvider.codex.rawValue):email:\(normalizedEmail)") } diff --git a/Sources/CodexBar/CodexResetCreditExpiryNotifier.swift b/Sources/CodexBar/CodexResetCreditExpiryNotifier.swift new file mode 100644 index 0000000000..b4b3d0b000 --- /dev/null +++ b/Sources/CodexBar/CodexResetCreditExpiryNotifier.swift @@ -0,0 +1,63 @@ +import CodexBarCore +import CryptoKit +import Foundation + +@MainActor +struct CodexResetCreditExpiryNotifier { + static let expiryWindow: TimeInterval = 3 * 24 * 60 * 60 + static let notificationPrefix = "codex-reset-credit-expiry" + static let summaryFingerprintsKey = "codexResetCreditExpirySummaryFingerprints" + static let maximumRememberedSummaries = 64 + + var userDefaults: UserDefaults = .standard + var notificationPoster: (String, String, String) -> Void = { prefix, title, body in + AppNotifications.shared.post(idPrefix: prefix, title: title, body: body) + } + + func postExpiringCreditsIfNeeded( + snapshot: CodexRateLimitResetCreditsSnapshot, + resetStyle: ResetTimeDisplayStyle, + now: Date = Date()) + { + let expiringCredits = snapshot.availableInventory(at: now).credits.filter { credit in + guard let expiresAt = credit.expiresAt else { return false } + return expiresAt.timeIntervalSince(now) <= Self.expiryWindow + } + guard !expiringCredits.isEmpty else { return } + + let fingerprint = Self.summaryFingerprint(expiringCredits) + // Account-scoped refreshes can alternate inventories, so remember more than the latest summary. + var notifiedFingerprints = self.userDefaults.stringArray(forKey: Self.summaryFingerprintsKey) ?? [] + guard !notifiedFingerprints.contains(fingerprint) else { return } + notifiedFingerprints.append(fingerprint) + if notifiedFingerprints.count > Self.maximumRememberedSummaries { + notifiedFingerprints.removeFirst(notifiedFingerprints.count - Self.maximumRememberedSummaries) + } + self.userDefaults.set(notifiedFingerprints, forKey: Self.summaryFingerprintsKey) + + let expiringSnapshot = CodexRateLimitResetCreditsSnapshot( + credits: expiringCredits, + availableCount: expiringCredits.count, + updatedAt: now) + guard let presentation = CodexResetCreditsPresentation.make( + snapshot: expiringSnapshot, + resetStyle: resetStyle, + now: now) + else { + return + } + self.notificationPoster( + Self.notificationPrefix, + L("Limit Reset Credits"), + presentation.helpText) + } + + private static func summaryFingerprint(_ credits: [CodexRateLimitResetCredit]) -> String { + let material = credits.map { credit in + "\(credit.id)\u{1f}\(credit.expiresAt?.timeIntervalSince1970 ?? 0)" + }.joined(separator: "\u{1e}") + return SHA256.hash(data: Data(material.utf8)) + .map { String(format: "%02x", $0) } + .joined() + } +} diff --git a/Sources/CodexBar/CodexbarApp.swift b/Sources/CodexBar/CodexbarApp.swift index 260abc8329..e46362d36e 100644 --- a/Sources/CodexBar/CodexbarApp.swift +++ b/Sources/CodexBar/CodexbarApp.swift @@ -6,7 +6,28 @@ import QuartzCore import Security import SwiftUI +enum CodexBarLaunchMode: Equatable { + case application + case hookEvent + + static func resolve(arguments: [String]) -> Self { + // Other CodexBar installations can leave this app path registered in ~/.codex/hooks.json. + // Treat those invocations as a no-op before AppKit creates a second set of status items. + arguments.dropFirst().contains("--hook-event") ? .hookEvent : .application + } +} + @main +enum CodexBarEntryPoint { + @MainActor + static func main() { + guard CodexBarLaunchMode.resolve(arguments: CommandLine.arguments) == .application else { + return + } + CodexBarApp.main() + } +} + struct CodexBarApp: App { @NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate @State private var settings: SettingsStore @@ -40,6 +61,9 @@ struct CodexBarApp: App { KeychainAccessGate.isDisabled = UserDefaults.standard.bool(forKey: "debugDisableKeychainAccess") KeychainPromptCoordinator.install() + if MainThreadHangWatchdog.isEnabledForCurrentProcess { + MainThreadHangWatchdog.shared.start() + } let preferencesSelection = PreferencesSelection() let settings = SettingsStore() @@ -47,7 +71,7 @@ struct CodexBarApp: App { configureUsageFormatterLocalizationProvider() let managedCodexAccountCoordinator = ManagedCodexAccountCoordinator() managedCodexAccountCoordinator.onManagedAccountsDidChange = { - _ = settings.persistResolvedCodexActiveSourceCorrectionIfNeeded() + _ = settings.refreshCodexAccountReconciliationAfterManagedAccountsDidChange() } _ = settings.persistResolvedCodexActiveSourceCorrectionIfNeeded() let fetcher = UsageFetcher() @@ -96,23 +120,28 @@ struct CodexBarApp: App { await self.appDelegate.runProviderLoginFlow(provider) }) } - .defaultSize(width: PreferencesTab.general.preferredWidth, height: PreferencesTab.general.preferredHeight) - .windowResizability(.contentSize) + .defaultSize(width: SettingsPane.windowWidth, height: SettingsPane.windowHeight) + .windowResizability(.contentMinSize) } - private func openSettings(tab: PreferencesTab) { - self.preferencesSelection.tab = tab + private func openSettings(pane: SettingsPane) { + self.preferencesSelection.pane = pane NSApp.activate(ignoringOtherApps: true) - _ = NSApp.sendAction(Selector(("showPreferencesWindow:")), to: nil, from: nil) + let outcome = SettingsWindowOpener.live().open(preferred: .appKit) + let logger = CodexBarLog.logger(LogCategories.app) + switch outcome { + case .preferred: + break + case .fallback: + logger.warning("Settings AppKit action was not handled; used notification fallback") + case .failed: + logger.error("Failed to open Settings; AppKit action and notification fallback unavailable") + } } private static func applyLanguagePreference(from settings: SettingsStore) { - let language = settings.appLanguage - if language.isEmpty { - UserDefaults.standard.removeObject(forKey: "AppleLanguages") - } else { - UserDefaults.standard.set([language], forKey: "AppleLanguages") - } + AppLanguagePreferenceMigration.clearLegacyOverrideIfOwned(storedAppLanguage: settings.appLanguage) + resetCodexBarLocalizationCache() } } @@ -350,6 +379,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate { let updaterController: UpdaterProviding = makeUpdaterController() private let confettiOverlayController = ScreenConfettiOverlayController() private let confettiLogger = CodexBarLog.logger(LogCategories.confetti) + private lazy var memoryPressureMonitor = MemoryPressureMonitor(trimAppCaches: { [weak self] in + self?.trimRebuildableCachesForMemoryPressure() ?? MemoryPressureCacheTrimSummary() + }) + private var statusController: StatusItemControlling? private var store: UsageStore? private var settings: SettingsStore? @@ -357,7 +390,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate { private var preferencesSelection: PreferencesSelection? private var managedCodexAccountCoordinator: ManagedCodexAccountCoordinator? private var codexAccountPromotionCoordinator: CodexAccountPromotionCoordinator? - private var hasInstalledWeeklyLimitResetObserver = false + private var hasInstalledLimitResetObservers = false + #if DEBUG + private var debugMemoryPressureObserver: NSObjectProtocol? + #endif var terminateActiveProcessesForAppShutdown: () -> Void = { TTYCommandRunner.terminateActiveProcessesForAppShutdown() } @@ -376,24 +412,50 @@ final class AppDelegate: NSObject, NSApplicationDelegate { } func applicationDidFinishLaunching(_ notification: Notification) { - AppNotifications.shared.requestAuthorizationOnStartup() + self.memoryPressureMonitor.start() + #if DEBUG + self.installDebugMemoryPressureObserverIfNeeded() + #endif self.ensureStatusController() + Task { @MainActor [weak self] in + await Task.yield() + guard let settings = self?.settings else { return } + AdaptiveActivityConsentPresenter.presentIfNeeded(settings: settings) + AppNotifications.shared.requestAuthorizationOnStartup() + // A persisted non-USD choice opts into the daily exchange-rate refresh. The service + // returns before networking for the default USD setting and Auto. + guard CurrencyExchange.requiresLiveRates( + preferredCurrencyCode: settings.preferredCurrencyCode) + else { return } + await CurrencyExchange.shared.fetchLatestRatesIfNeeded( + preferredCurrencyCode: settings.preferredCurrencyCode) + } KeyboardShortcuts.onKeyUp(for: .openMenu) { [weak self] in - Task { @MainActor [weak self] in + // KeyboardShortcuts dispatches both normal and menu-tracking hotkeys on the main event loop. + MainActor.assumeIsolated { self?.statusController?.openMenuFromShortcut() } } - if !self.hasInstalledWeeklyLimitResetObserver { + if !self.hasInstalledLimitResetObservers { + NotificationCenter.default.addObserver( + self, + selector: #selector(self.handleSessionLimitResetNotification(_:)), + name: .codexbarSessionLimitReset, + object: nil) NotificationCenter.default.addObserver( self, selector: #selector(self.handleWeeklyLimitResetNotification(_:)), name: .codexbarWeeklyLimitReset, object: nil) - self.hasInstalledWeeklyLimitResetObserver = true + self.hasInstalledLimitResetObservers = true } } func applicationWillTerminate(_ notification: Notification) { + self.memoryPressureMonitor.stop() + #if DEBUG + self.removeDebugMemoryPressureObserver() + #endif self.statusController?.prepareForAppShutdown() self.confettiOverlayController.dismiss() self.dismissAppKitWindowsForShutdown() @@ -406,18 +468,40 @@ final class AppDelegate: NSObject, NSApplicationDelegate { await statusController.runLoginFlowFromSettings(provider: provider) } + @objc private func handleSessionLimitResetNotification(_ notification: Notification) { + guard let event = notification.object as? SessionLimitResetEvent else { return } + guard self.settings?.confettiOnSessionLimitResetsEnabled == true else { return } + self.playLimitResetConfetti( + provider: event.provider, + accountIdentifier: event.accountIdentifier, + resetKind: "session") + } + @objc private func handleWeeklyLimitResetNotification(_ notification: Notification) { guard let event = notification.object as? WeeklyLimitResetEvent else { return } guard self.settings?.confettiOnWeeklyLimitResetsEnabled == true else { return } - let origin = self.statusController?.celebrationOriginPoint(for: event.provider) + self.playLimitResetConfetti( + provider: event.provider, + accountIdentifier: event.accountIdentifier, + resetKind: "weekly") + } + + private func playLimitResetConfetti( + provider: UsageProvider, + accountIdentifier: String, + resetKind: String) + { + let origin = self.statusController?.celebrationOriginPoint(for: provider) + let palette = ProviderDescriptorRegistry.descriptor(for: provider).branding.confettiPalette self.confettiLogger.info( "Triggering confetti", metadata: [ - "provider": event.provider.rawValue, - "accountIdentifier": event.accountIdentifier, + "provider": provider.rawValue, + "accountIdentifier": accountIdentifier, + "resetKind": resetKind, "originKnown": origin == nil ? "0" : "1", ]) - self.confettiOverlayController.play(originInScreen: origin) + self.confettiOverlayController.play(originInScreen: origin, colors: palette) } /// Use the classic (non-Liquid Glass) app icon on macOS versions before 26. @@ -453,7 +537,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate { } private func ensureStatusController() { - if self.statusController != nil { return } + if self.statusController != nil { + return + } if let store, let settings, @@ -497,6 +583,58 @@ final class AppDelegate: NSObject, NSApplicationDelegate { fallbackCodexAccountPromotionCoordinator) } + private func trimRebuildableCachesForMemoryPressure() -> MemoryPressureCacheTrimSummary { + var summary = MemoryPressureCacheTrimSummary() + let statusSummary = self.statusController?.trimRebuildableCachesForMemoryPressure() + ?? MemoryPressureCacheTrimSummary() + let storeSummary = self.store?.trimRebuildableCachesForMemoryPressure() + ?? MemoryPressureCacheTrimSummary() + summary.merge(statusSummary) + summary.merge(storeSummary) + return summary + } + + #if DEBUG + private func installDebugMemoryPressureObserverIfNeeded() { + guard self.debugMemoryPressureObserver == nil else { return } + self.debugMemoryPressureObserver = DistributedNotificationCenter.default().addObserver( + forName: .codexbarDebugSimulateMemoryPressure, + object: nil, + queue: .main) + { [weak self] notification in + let rawLevel = notification.userInfo?["level"] as? String + let shouldSeedCaches = notification.userInfo?["seedCaches"] as? String == "1" + MainActor.assumeIsolated { + self?.handleDebugMemoryPressureNotification( + rawLevel: rawLevel, + shouldSeedCaches: shouldSeedCaches) + } + } + } + + private func removeDebugMemoryPressureObserver() { + guard let observer = self.debugMemoryPressureObserver else { return } + DistributedNotificationCenter.default().removeObserver(observer) + self.debugMemoryPressureObserver = nil + } + + private func handleDebugMemoryPressureNotification(rawLevel: String?, shouldSeedCaches: Bool) { + let isCritical = rawLevel?.caseInsensitiveCompare("critical") == .orderedSame + if shouldSeedCaches { + OpenAIDashboardFetcher.seedCachedWebViewsForMemoryPressureProof() + self.statusController?.seedRebuildableCachesForMemoryPressureProof() + self.store?.seedRebuildableCachesForMemoryPressureProof() + } + CodexBarLog.logger(LogCategories.memoryPressure).info( + "Debug memory pressure notification received", + metadata: [ + "level": isCritical ? "critical" : "warning", + "seedCaches": shouldSeedCaches ? "1" : "0", + ]) + self.memoryPressureMonitor.handleMemoryPressureForTesting(isWarning: !isCritical, isCritical: isCritical) + } + #endif + deinit { NotificationCenter.default.removeObserver(self) } diff --git a/Sources/CodexBar/Config/CodexBarConfigMigrator.swift b/Sources/CodexBar/Config/CodexBarConfigMigrator.swift index 733e4198ee..323d7a4668 100644 --- a/Sources/CodexBar/Config/CodexBarConfigMigrator.swift +++ b/Sources/CodexBar/Config/CodexBarConfigMigrator.swift @@ -13,7 +13,6 @@ struct CodexBarConfigMigrator { let minimaxCookieStore: any MiniMaxCookieStoring let minimaxAPITokenStore: any MiniMaxAPITokenStoring let kimiTokenStore: any KimiTokenStoring - let kimiK2TokenStore: any KimiK2TokenStoring let augmentCookieStore: any CookieHeaderStoring let ampCookieStore: any CookieHeaderStoring let copilotTokenStore: any CopilotTokenStoring @@ -107,7 +106,6 @@ struct CodexBarConfigMigrator { (.zai, stores.zaiTokenStore.loadToken), (.synthetic, stores.syntheticTokenStore.loadToken), (.copilot, stores.copilotTokenStore.loadToken), - (.kimik2, stores.kimiK2TokenStore.loadToken), ], config: &config, state: &state) @@ -308,7 +306,6 @@ struct CodexBarConfigMigrator { try stores.copilotTokenStore.storeToken(nil) try stores.minimaxAPITokenStore.storeToken(nil) try stores.kimiTokenStore.storeToken(nil) - try stores.kimiK2TokenStore.storeToken(nil) try stores.codexCookieStore.storeCookieHeader(nil) try stores.claudeCookieStore.storeCookieHeader(nil) try stores.cursorCookieStore.storeCookieHeader(nil) diff --git a/Sources/CodexBar/CookieHeaderStore.swift b/Sources/CodexBar/CookieHeaderStore.swift index ac3905b037..8fd455ab3a 100644 --- a/Sources/CodexBar/CookieHeaderStore.swift +++ b/Sources/CodexBar/CookieHeaderStore.swift @@ -78,7 +78,7 @@ struct KeychainCookieHeaderStore: CookieHeaderStoring { account: self.account)) } - let status = SecItemCopyMatching(query as CFDictionary, &result) + let status = KeychainSecurity.copyMatching(query as CFDictionary, &result) if status == errSecItemNotFound { // Cache the nil result Self.cacheLock.lock() @@ -140,7 +140,7 @@ struct KeychainCookieHeaderStore: CookieHeaderStoring { kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly, ] - let updateStatus = SecItemUpdate(query as CFDictionary, attributes as CFDictionary) + let updateStatus = KeychainSecurity.update(query as CFDictionary, attributes as CFDictionary) if updateStatus == errSecSuccess { // Update cache Self.cacheLock.lock() @@ -157,7 +157,7 @@ struct KeychainCookieHeaderStore: CookieHeaderStoring { for (key, value) in attributes { addQuery[key] = value } - let addStatus = SecItemAdd(addQuery as CFDictionary, nil) + let addStatus = KeychainSecurity.add(addQuery as CFDictionary, nil) guard addStatus == errSecSuccess else { Self.log.error("Keychain add failed: \(addStatus)") throw CookieHeaderStoreError.keychainStatus(addStatus) @@ -176,7 +176,7 @@ struct KeychainCookieHeaderStore: CookieHeaderStoring { kSecAttrService as String: self.service, kSecAttrAccount as String: self.account, ] - let status = SecItemDelete(query as CFDictionary) + let status = KeychainSecurity.delete(query as CFDictionary) if status == errSecSuccess || status == errSecItemNotFound { // Invalidate cache Self.cacheLock.lock() diff --git a/Sources/CodexBar/CopilotTokenStore.swift b/Sources/CodexBar/CopilotTokenStore.swift index 4fcff0012a..6f852f3dec 100644 --- a/Sources/CodexBar/CopilotTokenStore.swift +++ b/Sources/CodexBar/CopilotTokenStore.swift @@ -50,7 +50,7 @@ struct KeychainCopilotTokenStore: CopilotTokenStoring { account: self.account)) } - let status = SecItemCopyMatching(query as CFDictionary, &result) + let status = KeychainSecurity.copyMatching(query as CFDictionary, &result) if status == errSecItemNotFound { return nil } @@ -91,7 +91,7 @@ struct KeychainCopilotTokenStore: CopilotTokenStoring { kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly, ] - let updateStatus = SecItemUpdate(query as CFDictionary, attributes as CFDictionary) + let updateStatus = KeychainSecurity.update(query as CFDictionary, attributes as CFDictionary) if updateStatus == errSecSuccess { return } @@ -104,7 +104,7 @@ struct KeychainCopilotTokenStore: CopilotTokenStoring { for (key, value) in attributes { addQuery[key] = value } - let addStatus = SecItemAdd(addQuery as CFDictionary, nil) + let addStatus = KeychainSecurity.add(addQuery as CFDictionary, nil) guard addStatus == errSecSuccess else { Self.log.error("Keychain add failed: \(addStatus)") throw CopilotTokenStoreError.keychainStatus(addStatus) @@ -118,7 +118,7 @@ struct KeychainCopilotTokenStore: CopilotTokenStoring { kSecAttrService as String: self.service, kSecAttrAccount as String: self.account, ] - let status = SecItemDelete(query as CFDictionary) + let status = KeychainSecurity.delete(query as CFDictionary) if status == errSecSuccess || status == errSecItemNotFound { return } diff --git a/Sources/CodexBar/CostHistoryChartMenuView.swift b/Sources/CodexBar/CostHistoryChartMenuView.swift index e1a35e28a4..210d0a3f52 100644 --- a/Sources/CodexBar/CostHistoryChartMenuView.swift +++ b/Sources/CodexBar/CostHistoryChartMenuView.swift @@ -6,6 +6,12 @@ import SwiftUI struct CostHistoryChartMenuView: View { typealias DailyEntry = CostUsageDailyReport.Entry + enum AxisLabelPlacement: Equatable { + case hidden + case centered + case edges + } + private struct Point: Identifiable { let id: String let date: Date @@ -41,6 +47,8 @@ struct CostHistoryChartMenuView: View { private let currencyCode: String private let historyDays: Int private let windowLabel: String? + private let projects: [CostUsageProjectBreakdown] + private let sessions: [CostUsageSessionBreakdown] private let width: CGFloat @State private var selectedDateKey: String? @@ -51,6 +59,8 @@ struct CostHistoryChartMenuView: View { currencyCode: String = "USD", historyDays: Int = 30, windowLabel: String? = nil, + projects: [CostUsageProjectBreakdown] = [], + sessions: [CostUsageSessionBreakdown] = [], width: CGFloat) { self.provider = provider @@ -59,12 +69,15 @@ struct CostHistoryChartMenuView: View { self.currencyCode = currencyCode self.historyDays = max(1, min(365, historyDays)) self.windowLabel = windowLabel + self.projects = projects + self.sessions = sessions self.width = width } var body: some View { let model = Self.makeModel(provider: self.provider, daily: self.daily) - VStack(alignment: .leading, spacing: 10) { + let selectedDateKey = self.selectedDateKey ?? Self.defaultSelectedDateKey(model: model) + VStack(alignment: .leading, spacing: Self.outerSpacing) { if model.points.isEmpty { Text(L("No cost history data.")) .font(.footnote) @@ -87,18 +100,35 @@ struct CostHistoryChartMenuView: View { .foregroundStyle(Color(nsColor: .systemYellow)) } } - .chartYAxis(.hidden) + .chartYAxis { + AxisMarks(position: .leading, values: Self.yAxisTickValues(maxCostUSD: model.maxCostUSD)) { value in + AxisGridLine().foregroundStyle(Color.clear) + AxisTick().foregroundStyle(Color.clear) + AxisValueLabel(centered: false) { + if let raw = value.as(Double.self) { + Text(Self.yAxisCostString(raw, currencyCode: self.currencyCode)) + .font(.caption2) + .foregroundStyle(Color(nsColor: .tertiaryLabelColor)) + .padding(.leading, 4) + } + } + } + } .chartXAxis { - AxisMarks(values: model.axisDates) { _ in + AxisMarks(values: model.axisDates) { value in AxisGridLine().foregroundStyle(Color.clear) AxisTick().foregroundStyle(Color.clear) - AxisValueLabel(format: .dateTime.month(.abbreviated).day()) - .font(.caption2) - .foregroundStyle(Color(nsColor: .tertiaryLabelColor)) + if let date = value.as(Date.self) { + AxisValueLabel(anchor: Self.xAxisLabelAnchor(for: date, axisDates: model.axisDates)) { + Text(date, format: .dateTime.month(.abbreviated).day()) + .font(.caption2) + .foregroundStyle(Color(nsColor: .tertiaryLabelColor)) + } + } } } .chartLegend(.hidden) - .frame(height: 130) + .frame(height: Self.chartHeight) .accessibilityLabel(L("Cost history chart")) .accessibilityValue( model.points.isEmpty @@ -123,7 +153,7 @@ struct CostHistoryChartMenuView: View { } } - let detail = self.detailContent(model: model) + let detail = self.detailContent(selectedDateKey: selectedDateKey, model: model) VStack(alignment: .leading, spacing: Self.detailSpacing) { Text(detail.primary) .font(.caption) @@ -131,70 +161,146 @@ struct CostHistoryChartMenuView: View { .lineLimit(1) .truncationMode(.tail) .frame(height: Self.detailPrimaryLineHeight, alignment: .leading) - ForEach(detail.rows) { row in - HStack(alignment: .top, spacing: 8) { - Rectangle() - .fill(row.accentColor) - .frame( - width: 2, - height: Self.accentHeight(for: row)) - .padding(.top, 1) - - VStack(alignment: .leading, spacing: 1) { - Text(row.title) - .font(.caption) - .foregroundStyle(.secondary) - .lineLimit(1) - .truncationMode(.tail) - .frame(height: Self.detailTitleLineHeight, alignment: .leading) - if let subtitle = row.subtitle { - Text(subtitle) - .font(.caption2) - .foregroundStyle(Color(nsColor: .tertiaryLabelColor)) - .lineLimit(1) - .truncationMode(.tail) - .frame(height: Self.detailSubtitleLineHeight, alignment: .leading) - } - if let modeSubtitle = row.modeSubtitle { - Text(modeSubtitle) - .font(.caption2) - .foregroundStyle(Color(nsColor: .tertiaryLabelColor)) - .lineLimit(1) - .truncationMode(.tail) - .frame(height: Self.detailSubtitleLineHeight, alignment: .leading) + if model.detailViewportRowCount > 0 { + ScrollView(.vertical) { + VStack(alignment: .leading, spacing: Self.detailSpacing) { + ForEach(detail.rows) { row in + HStack(alignment: .top, spacing: 8) { + Rectangle() + .fill(row.accentColor) + .frame( + width: 2, + height: Self.accentHeight( + for: row, + rowHeight: model.detailRowHeight)) + .padding(.top, 1) + + VStack(alignment: .leading, spacing: 1) { + Text(row.title) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.tail) + .frame(height: Self.detailTitleLineHeight, alignment: .leading) + if let subtitle = row.subtitle { + Text(subtitle) + .font(.caption2) + .foregroundStyle(Color(nsColor: .tertiaryLabelColor)) + .lineLimit(1) + .truncationMode(.tail) + .frame( + height: Self.detailSubtitleLineHeight, + alignment: .leading) + } + if let modeSubtitle = row.modeSubtitle { + Text(modeSubtitle) + .font(.caption2) + .foregroundStyle(Color(nsColor: .tertiaryLabelColor)) + .lineLimit(1) + .truncationMode(.tail) + .frame( + height: Self.detailSubtitleLineHeight, + alignment: .leading) + } + } + } + .frame(height: model.detailRowHeight, alignment: .leading) } } } - .frame(height: Self.detailRowHeight(for: row), alignment: .leading) - } - ForEach(0.. 0 { + Text("+ \(hiddenSourceCount) more") + .font(.caption2) + .foregroundStyle(Color(nsColor: .tertiaryLabelColor)) + .lineLimit(1) + .padding(.leading, Self.projectSourceIndent) + .frame(height: Self.projectMoreRowHeight, alignment: .leading) + } + } + } + .frame(height: Self.projectEntryHeight(project), alignment: .topLeading) + } + } + .frame(height: Self.projectBlockHeight(projects: self.projects), alignment: .topLeading) + } + + if !self.sessions.isEmpty { + self.sessionsBlock } } .padding(.horizontal, 16) - .padding(.vertical, 10) - .frame(minWidth: self.width, maxWidth: .infinity, alignment: .leading) + .padding(.vertical, Self.verticalPadding) + .frame(minWidth: self.width, maxWidth: .infinity, alignment: .top) + } + + static func estimateDisclaimer(provider: UsageProvider) -> String? { + provider == .codex ? L("codex_api_estimate_hint") : nil } private struct Model { @@ -206,42 +312,145 @@ struct CostHistoryChartMenuView: View { let barColor: Color let peakKey: String? let maxCostUSD: Double - let maxRenderedBreakdownRows: Int - let maxDetailRowsHeight: CGFloat + let detailViewportRowCount: Int + let hasDetailOverflow: Bool + let detailRowHeight: CGFloat } private static let selectionBandColor = Color(nsColor: .labelColor).opacity(0.1) - private static let maxVisibleDetailLines = 4 + static let maxVisibleDetailLines = 4 private static let detailPrimaryLineHeight: CGFloat = 16 private static let detailTitleLineHeight: CGFloat = 16 private static let detailSubtitleLineHeight: CGFloat = 13 private static let compactDetailRowHeight: CGFloat = 36 private static let expandedDetailRowHeight: CGFloat = 44 private static let detailSpacing: CGFloat = 6 + private static let detailHintHeight: CGFloat = 13 + private static let chartHeight: CGFloat = 130 + private static let outerSpacing: CGFloat = 10 + private static let projectRowHeight: CGFloat = 31 + private static let projectRowSpacing: CGFloat = 5 + private static let maxVisibleProjectRows = 5 + private static let projectSourceRowHeight: CGFloat = 29 + private static let projectSourceSpacing: CGFloat = 3 + private static let projectSourceIndent: CGFloat = 10 + private static let projectMoreRowHeight: CGFloat = 16 + private static let maxVisibleProjectSourceRows = 2 + private static let sessionRowHeight: CGFloat = 44 + private static let sessionRowSpacing: CGFloat = 5 + private static let maxVisibleSessionRows = 5 + static let verticalPadding: CGFloat = 10 - static func windowLabel(days: Int) -> String { - if days == 1 { - return L("Today") + private var sessionsBlock: some View { + let visibleCount = min(self.sessions.count, Self.maxVisibleSessionRows) + return VStack(alignment: .leading, spacing: Self.sessionRowSpacing) { + HStack { + Text("Conversations (\(self.windowLabel ?? Self.windowLabel(days: self.historyDays)))") + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + Spacer() + Text("\(self.sessions.count)") + .font(.caption2) + .foregroundStyle(Color(nsColor: .tertiaryLabelColor)) + } + .frame(height: Self.detailPrimaryLineHeight, alignment: .leading) + + ScrollView(.vertical) { + LazyVStack(alignment: .leading, spacing: Self.sessionRowSpacing) { + ForEach(self.sessions) { session in + self.sessionRow(session) + } + } + } + .scrollIndicators(self.sessions.count > visibleCount ? .visible : .hidden) + .frame( + height: CGFloat(visibleCount) * Self.sessionRowHeight + + CGFloat(max(visibleCount - 1, 0)) * Self.sessionRowSpacing, + alignment: .topLeading) } - return String(format: L("Last %d days"), days) + .frame( + height: Self.detailPrimaryLineHeight + Self.sessionRowSpacing + + CGFloat(visibleCount) * Self.sessionRowHeight + + CGFloat(max(visibleCount - 1, 0)) * Self.sessionRowSpacing, + alignment: .topLeading) } - private static func detailRowHeight(for row: DetailRow) -> CGFloat { - self.detailRowHeight(hasModeSubtitle: row.modeSubtitle != nil) + private func sessionRow(_ session: CostUsageSessionBreakdown) -> some View { + HStack(alignment: .top, spacing: 8) { + VStack(alignment: .leading, spacing: 1) { + Text("Session \(Self.shortSessionID(session.sessionID))") + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + Text(Self.sessionUsageLine(session)) + .font(.caption2) + .foregroundStyle(Color(nsColor: .tertiaryLabelColor)) + .lineLimit(1) + .truncationMode(.tail) + Text(session.lastActivity, format: .dateTime.month(.abbreviated).day().hour().minute()) + .font(.caption2) + .foregroundStyle(Color(nsColor: .tertiaryLabelColor)) + .lineLimit(1) + } + Spacer(minLength: 8) + Text(session.costUSD.map(self.costString) ?? "—") + .font(.caption) + .monospacedDigit() + .foregroundStyle(.secondary) + .lineLimit(1) + } + .frame(height: Self.sessionRowHeight, alignment: .topLeading) + .accessibilityElement(children: .combine) + } + + static func shortSessionID(_ sessionID: String) -> String { + let trimmed = sessionID.trimmingCharacters(in: .whitespacesAndNewlines) + guard trimmed.count > 12 else { return trimmed } + return "\(trimmed.prefix(4))...\(trimmed.suffix(8))" + } + + private static func sessionUsageLine(_ session: CostUsageSessionBreakdown) -> String { + let models = session.modelBreakdowns.map(\.modelName) + let modelLabel = if models.isEmpty { + "Unknown model" + } else if models.count == 1 { + models[0] + } else { + "\(models[0]) +\(models.count - 1)" + } + let input = session.inputTokens.map(UsageFormatter.tokenCountString) ?? "—" + let cached = session.cachedInputTokens.map(UsageFormatter.tokenCountString) ?? "—" + let output = session.outputTokens.map(UsageFormatter.tokenCountString) ?? "—" + return "\(modelLabel) · \(input) input · \(cached) cached · \(output) output" } - private static func detailRowHeight(hasModeSubtitle: Bool) -> CGFloat { - hasModeSubtitle ? self.expandedDetailRowHeight : self.compactDetailRowHeight + static func windowLabel(days: Int) -> String { + if days == 1 { + return L("Today") + } + return String(format: L("Last %d days"), days) } - private static func accentHeight(for row: DetailRow) -> CGFloat { - row.subtitle == nil && row.modeSubtitle == nil ? 14 : self.detailRowHeight(for: row) + private static func accentHeight(for row: DetailRow, rowHeight: CGFloat) -> CGFloat { + row.subtitle == nil && row.modeSubtitle == nil ? 14 : rowHeight } private static func capHeight(maxValue: Double) -> Double { maxValue * 0.05 } + /// Y-axis tick values for the cost chart: 0, mid, max when the range is at + /// $1 or more; 0 and max for smaller ranges; empty for flat/no data so the + /// axis renders no labels. + private static func yAxisTickValues(maxCostUSD: Double) -> [Double] { + guard maxCostUSD > 0 else { return [] } + if maxCostUSD < 1.0 { + return [0, maxCostUSD] + } + return [0, maxCostUSD / 2, maxCostUSD] + } + private static func makeModel(provider: UsageProvider, daily: [DailyEntry]) -> Model { let sorted = daily.sorted { lhs, rhs in lhs.date < rhs.date } var points: [Point] = [] @@ -258,11 +467,10 @@ struct CostHistoryChartMenuView: View { var peak: (key: String, costUSD: Double)? var maxCostUSD: Double = 0 - var maxRenderedBreakdownRows = 0 - var detailRowMetrics: [(count: Int, height: CGFloat)] = [] + var maxDetailRows = 0 + var hasModeDetails = false for entry in sorted { - guard let costUSD = entry.costUSD, costUSD >= 0 else { continue } - guard let date = self.dateFromDayKey(entry.date) else { continue } + guard let (costUSD, date) = self.chartPointInput(for: entry) else { continue } let point = Point( date: date, costUSD: costUSD, @@ -272,11 +480,13 @@ struct CostHistoryChartMenuView: View { pointsByKey[entry.date] = point entriesByKey[entry.date] = entry dateKeys.append((entry.date, date)) - let rowMetric = Self.renderedBreakdownRowsMetric(for: entry) - detailRowMetrics.append(rowMetric) - maxRenderedBreakdownRows = max(maxRenderedBreakdownRows, rowMetric.count) + let modelBreakdowns = entry.modelBreakdowns ?? [] + maxDetailRows = max(maxDetailRows, modelBreakdowns.count) + hasModeDetails = hasModeDetails || modelBreakdowns.contains { Self.hasModeSubtitle($0) } if let cur = peak { - if costUSD > cur.costUSD { peak = (entry.date, costUSD) } + if costUSD > cur.costUSD { + peak = (entry.date, costUSD) + } } else { peak = (entry.date, costUSD) } @@ -285,16 +495,13 @@ struct CostHistoryChartMenuView: View { let axisDates: [Date] = { guard let first = dateKeys.first?.date, let last = dateKeys.last?.date else { return [] } - if Calendar.current.isDate(first, inSameDayAs: last) { return [first] } + if Calendar.current.isDate(first, inSameDayAs: last) { + return [first] + } return [first, last] }() let barColor = Self.barColor(for: provider) - let maxDetailRowsHeight = detailRowMetrics.reduce(CGFloat(0)) { currentMax, metric in - let fillerRows = max(maxRenderedBreakdownRows - metric.count, 0) - let filledHeight = metric.height + (CGFloat(fillerRows) * Self.compactDetailRowHeight) - return max(currentMax, filledHeight) - } return Model( points: points, pointsByDateKey: pointsByKey, @@ -304,8 +511,32 @@ struct CostHistoryChartMenuView: View { barColor: barColor, peakKey: maxCostUSD > 0 ? peak?.key : nil, maxCostUSD: maxCostUSD, - maxRenderedBreakdownRows: maxRenderedBreakdownRows, - maxDetailRowsHeight: maxDetailRowsHeight) + detailViewportRowCount: min(maxDetailRows, self.maxVisibleDetailLines), + hasDetailOverflow: maxDetailRows > self.maxVisibleDetailLines, + detailRowHeight: hasModeDetails ? self.expandedDetailRowHeight : self.compactDetailRowHeight) + } + + private static func axisLabelPlacement(for dates: [Date]) -> AxisLabelPlacement { + switch dates.count { + case 0: .hidden + case 1: .centered + default: .edges + } + } + + private static func xAxisLabelAnchor(for date: Date, axisDates: [Date]) -> UnitPoint { + switch self.axisLabelPlacement(for: axisDates) { + case .hidden, .centered: + .top + case .edges: + if let first = axisDates.first, Calendar.current.isDate(date, inSameDayAs: first) { + .topLeading + } else if let last = axisDates.last, Calendar.current.isDate(date, inSameDayAs: last) { + .topTrailing + } else { + .top + } + } } private static func barColor(for provider: UsageProvider) -> Color { @@ -330,31 +561,65 @@ struct CostHistoryChartMenuView: View { return comps.date } + private static func chartPointInput(for entry: DailyEntry) -> (costUSD: Double, date: Date)? { + guard let costUSD = entry.costUSD, costUSD >= 0 else { return nil } + guard let date = self.dateFromDayKey(entry.date) else { return nil } + return (costUSD, date) + } + private static func peakPoint(model: Model) -> Point? { guard let key = model.peakKey else { return nil } return model.pointsByDateKey[key] } - private static func renderedBreakdownRowsMetric(for entry: DailyEntry) -> (count: Int, height: CGFloat) { - guard let breakdown = entry.modelBreakdowns, !breakdown.isEmpty else { return (0, 0) } - let renderedRows = Array( - self.sortedBreakdown(breakdown) - .prefix(self.maxVisibleDetailLines)) - let height = renderedRows.reduce(CGFloat(0)) { total, item in - total + self.detailRowHeight(hasModeSubtitle: Self.hasModeSubtitle(item)) + private static func hasModeSubtitle(_ item: CostUsageDailyReport.ModelBreakdown) -> Bool { + item.standardCostUSD != nil || item.priorityCostUSD != nil + } + + private static func detailRowsViewportHeight(rowCount: Int, rowHeight: CGFloat) -> CGFloat { + guard rowCount > 0 else { return 0 } + return CGFloat(rowCount) * rowHeight + CGFloat(rowCount - 1) * self.detailSpacing + } + + private static func detailBlockHeight(rowCount: Int, hasOverflow: Bool, rowHeight: CGFloat) -> CGFloat { + guard rowCount > 0 else { return self.detailPrimaryLineHeight } + var height = self.detailPrimaryLineHeight + self.detailSpacing + height += self.detailRowsViewportHeight(rowCount: rowCount, rowHeight: rowHeight) + if hasOverflow { + height += self.detailSpacing + self.detailHintHeight } - return (renderedRows.count, height) + return height } - private static func hasModeSubtitle(_ item: CostUsageDailyReport.ModelBreakdown) -> Bool { - item.standardCostUSD != nil || item.priorityCostUSD != nil + private static func projectBlockHeight(projects: [CostUsageProjectBreakdown]) -> CGFloat { + let visibleProjects = Array(projects.prefix(self.maxVisibleProjectRows)) + guard !visibleProjects.isEmpty else { return 0 } + return self.detailPrimaryLineHeight + + self.projectRowSpacing + + visibleProjects.reduce(CGFloat(0)) { $0 + self.projectEntryHeight($1) } + + CGFloat(max(visibleProjects.count - 1, 0)) * self.projectRowSpacing } - private static func detailBlockHeight(maxBreakdownRows: Int, maxRowsHeight: CGFloat) -> CGFloat { - guard maxBreakdownRows > 0 else { return self.detailPrimaryLineHeight } - return self.detailPrimaryLineHeight + - maxRowsHeight + - (CGFloat(maxBreakdownRows) * self.detailSpacing) + private static func projectEntryHeight(_ project: CostUsageProjectBreakdown) -> CGFloat { + let sources = self.visibleProjectSources(project) + guard !sources.isEmpty else { return self.projectRowHeight } + let visibleSources = min(sources.count, self.maxVisibleProjectSourceRows) + let moreRows = sources.count > self.maxVisibleProjectSourceRows ? 1 : 0 + return self.projectRowHeight + + CGFloat(visibleSources) * (self.projectSourceRowHeight + self.projectSourceSpacing) + + CGFloat(moreRows) * (self.projectMoreRowHeight + self.projectSourceSpacing) + } + + static func visibleProjectSources( + _ project: CostUsageProjectBreakdown) -> [CostUsageProjectSourceBreakdown] + { + guard project.sources.count == 1 else { return project.sources } + guard let source = project.sources.first, source.path != project.path else { return [] } + return [source] + } + + private static func defaultSelectedDateKey(model: Model) -> String? { + model.dateKeys.last?.key } private func selectionBandRect(model: Model, proxy: ChartProxy, geo: GeometryProxy) -> CGRect? { @@ -365,32 +630,13 @@ struct CostHistoryChartMenuView: View { let date = model.dateKeys[index].date guard let x = proxy.position(forX: date) else { return nil } - func xForIndex(_ idx: Int) -> CGFloat? { - guard idx >= 0, idx < model.dateKeys.count else { return nil } - return proxy.position(forX: model.dateKeys[idx].date) - } - - let xPrev = xForIndex(index - 1) - let xNext = xForIndex(index + 1) - - let leftInPlot: CGFloat = if let xPrev { - (xPrev + x) / 2 - } else if let xNext { - x - (xNext - x) / 2 - } else { - x - 8 - } - - let rightInPlot: CGFloat = if let xNext { - (xNext + x) / 2 - } else if let xPrev { - x + (x - xPrev) / 2 - } else { - x + 8 - } + // Use the calendar day slot width so the band stays the same size regardless of data gaps. + let nextDayX = proxy.position(forX: ChartBarHoverSelection.nextCalendarDay(after: date)) ?? (x + 20) + let slotWidth = abs(nextDayX - x) + let barHalfWidth = slotWidth * 0.25 + 2 - let left = plotFrame.origin.x + min(leftInPlot, rightInPlot) - let right = plotFrame.origin.x + max(leftInPlot, rightInPlot) + let left = plotFrame.origin.x + x - barHalfWidth + let right = plotFrame.origin.x + x + barHalfWidth return CGRect(x: left, y: plotFrame.origin.y, width: right - left, height: plotFrame.height) } @@ -400,10 +646,9 @@ struct CostHistoryChartMenuView: View { proxy: ChartProxy, geo: GeometryProxy) { - guard let location else { - if self.selectedDateKey != nil { self.selectedDateKey = nil } - return - } + // Keep the last hovered day selected when the pointer leaves the chart so the adjacent + // model-breakdown scroller remains interactive. The selection resets with the menu view. + guard let location else { return } guard let plotAnchor = proxy.plotFrame else { return } let plotFrame = geo[plotAnchor] @@ -413,18 +658,101 @@ struct CostHistoryChartMenuView: View { guard let date: Date = proxy.value(atX: xInPlot) else { return } guard let nearest = self.nearestDateKey(to: date, model: model) else { return } + // Stay on the last selected bar when cursor is in the gap between bars. + if let nearestEntry = model.dateKeys.first(where: { $0.key == nearest }), + let barX = proxy.position(forX: nearestEntry.date) + { + let nextDayX = proxy.position(forX: ChartBarHoverSelection.nextCalendarDay(after: nearestEntry.date)) ?? + (barX + 20) + let slotWidth = abs(nextDayX - barX) + guard ChartBarHoverSelection.accepts( + distanceFromBarCenter: abs(location.x - (plotFrame.origin.x + barX)), + barHalfWidth: slotWidth * 0.25 + 2, + selectableCount: model.dateKeys.count) + else { return } + } + if self.selectedDateKey != nearest { self.selectedDateKey = nearest } } + private func projectSummary(_ project: CostUsageProjectBreakdown) -> String { + let cost = project.totalCostUSD + .map { self.costString($0) } ?? "—" + guard let totalTokens = project.totalTokens else { return cost } + return "\(cost) · \(L("%@ tokens", UsageFormatter.tokenCountString(totalTokens)))" + } + + private func projectParentRow(_ project: CostUsageProjectBreakdown) -> some View { + VStack(alignment: .leading, spacing: 1) { + HStack(spacing: 8) { + Text(project.name) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.tail) + Spacer(minLength: 8) + Text(self.projectSummary(project)) + .font(.caption2) + .foregroundStyle(Color(nsColor: .tertiaryLabelColor)) + .lineLimit(1) + .truncationMode(.head) + } + if let path = project.path { + Text(path) + .font(.caption2) + .foregroundStyle(Color(nsColor: .tertiaryLabelColor)) + .lineLimit(1) + .truncationMode(.middle) + } + } + .frame(height: Self.projectRowHeight, alignment: .leading) + } + + private func projectSourceRow(_ source: CostUsageProjectSourceBreakdown) -> some View { + VStack(alignment: .leading, spacing: 1) { + HStack(spacing: 6) { + Text(source.name) + .font(.caption2) + .foregroundStyle(Color(nsColor: .tertiaryLabelColor)) + .lineLimit(1) + .truncationMode(.tail) + Spacer(minLength: 6) + Text(self.projectSourceSummary(source)) + .font(.caption2) + .foregroundStyle(Color(nsColor: .tertiaryLabelColor)) + .lineLimit(1) + .truncationMode(.head) + } + if let path = source.path { + Text(path) + .font(.caption2) + .foregroundStyle(Color(nsColor: .quaternaryLabelColor)) + .lineLimit(1) + .truncationMode(.middle) + } + } + .padding(.leading, Self.projectSourceIndent) + .frame(height: Self.projectSourceRowHeight, alignment: .leading) + } + + private func projectSourceSummary(_ source: CostUsageProjectSourceBreakdown) -> String { + let cost = source.totalCostUSD + .map { self.costString($0) } ?? "—" + guard let totalTokens = source.totalTokens else { return cost } + return "\(cost) · \(L("%@ tokens", UsageFormatter.tokenCountString(totalTokens)))" + } + private func nearestDateKey(to date: Date, model: Model) -> String? { guard !model.dateKeys.isEmpty else { return nil } var best: (key: String, distance: TimeInterval)? for entry in model.dateKeys { let dist = abs(entry.date.timeIntervalSince(date)) if let cur = best { - if dist < cur.distance { best = (entry.key, dist) } + if dist < cur.distance { + best = (entry.key, dist) + } } else { best = (entry.key, dist) } @@ -432,8 +760,8 @@ struct CostHistoryChartMenuView: View { return best?.key } - private func detailContent(model: Model) -> DetailContent { - guard let key = self.selectedDateKey, + private func detailContent(selectedDateKey: String?, model: Model) -> DetailContent { + guard let key = selectedDateKey, let point = model.pointsByDateKey[key], let date = Self.dateFromDayKey(key) else { @@ -457,8 +785,7 @@ struct CostHistoryChartMenuView: View { guard let entry = model.entriesByDateKey[key] else { return [] } guard let breakdown = entry.modelBreakdowns, !breakdown.isEmpty else { return [] } - return Self.sortedBreakdown(breakdown) - .prefix(Self.maxVisibleDetailLines) + return Self.orderedBreakdownItems(breakdown) .enumerated() .map { index, item in DetailRow( @@ -470,22 +797,38 @@ struct CostHistoryChartMenuView: View { } } - private static func sortedBreakdown( + static func orderedBreakdownItems( _ breakdown: [CostUsageDailyReport.ModelBreakdown]) -> [CostUsageDailyReport.ModelBreakdown] { breakdown.sorted { lhs, rhs in let lCost = lhs.costUSD ?? -1 let rCost = rhs.costUSD ?? -1 - if lCost != rCost { return lCost > rCost } + if lCost != rCost { + return lCost > rCost + } let lTokens = lhs.totalTokens ?? -1 let rTokens = rhs.totalTokens ?? -1 - if lTokens != rTokens { return lTokens > rTokens } + if lTokens != rTokens { + return lTokens > rTokens + } return lhs.modelName > rhs.modelName } } + static func detailViewportRowCount(itemCount: Int) -> Int { + min(max(itemCount, 0), self.maxVisibleDetailLines) + } + + static func detailRowsNeedScrolling(itemCount: Int) -> Bool { + itemCount > self.maxVisibleDetailLines + } + + static func detailOverflowHint(itemCount: Int) -> String? { + self.detailRowsNeedScrolling(itemCount: itemCount) ? L("Scroll to see more models") : nil + } + private func modelBreakdownTotalSubtitle(_ item: CostUsageDailyReport.ModelBreakdown) -> String? { UsageFormatter.modelCostDetail( item.modelName, @@ -515,7 +858,15 @@ struct CostHistoryChartMenuView: View { } private func costString(_ value: Double) -> String { - UsageFormatter.currencyString(value, currencyCode: self.currencyCode) + Self.costString(value, currencyCode: self.currencyCode) + } + + private static func costString(_ value: Double, currencyCode: String) -> String { + UsageFormatter.currencyString(value, currencyCode: currencyCode) + } + + private static func yAxisCostString(_ value: Double, currencyCode: String) -> String { + UsageFormatter.compactCurrencyString(value, currencyCode: currencyCode) } private static func breakdownAccentOpacity(for index: Int) -> Double { @@ -523,3 +874,176 @@ struct CostHistoryChartMenuView: View { return max(0.3, opacity) } } + +extension CostHistoryChartMenuView { + struct RenderFingerprint: Equatable { + let currencyCode: String + let historyDays: Int + let windowLabel: String? + let totalCostBitPattern: UInt64? + let hasDailyEntries: Bool + let daily: [VisibleDailyFingerprint] + let projects: [VisibleProjectFingerprint] + let sessions: [VisibleSessionFingerprint] + } + + struct VisibleDailyFingerprint: Equatable { + let date: String + let totalTokens: Int? + let requestCount: Int? + let costBitPattern: UInt64? + let modelBreakdowns: [VisibleModelBreakdownFingerprint] + } + + struct VisibleModelBreakdownFingerprint: Equatable { + let modelName: String + let costBitPattern: UInt64? + let totalTokens: Int? + let standardCostBitPattern: UInt64? + let priorityCostBitPattern: UInt64? + let standardTokens: Int? + let priorityTokens: Int? + } + + struct VisibleProjectFingerprint: Equatable { + let name: String + let path: String? + let totalTokens: Int? + let totalCostBitPattern: UInt64? + let visibleSourceCount: Int + let sources: [VisibleSourceFingerprint] + } + + struct VisibleSourceFingerprint: Equatable { + let name: String + let path: String? + let totalTokens: Int? + let totalCostBitPattern: UInt64? + } + + struct VisibleSessionFingerprint: Equatable { + let sessionID: String + let lastActivityBitPattern: UInt64 + let inputTokens: Int? + let cachedInputTokens: Int? + let outputTokens: Int? + let totalTokens: Int? + let costBitPattern: UInt64? + let models: [VisibleModelBreakdownFingerprint] + } + + static func renderFingerprint( + from snapshot: CostUsageTokenSnapshot, + provider: UsageProvider) -> RenderFingerprint + { + let projects = provider == .codex ? snapshot.projects : [] + let sessions = provider == .codex ? snapshot.sessions : [] + return RenderFingerprint( + currencyCode: snapshot.currencyCode, + historyDays: snapshot.historyDays, + windowLabel: snapshot.historyLabel, + totalCostBitPattern: snapshot.last30DaysCostUSD.map(\.bitPattern), + hasDailyEntries: !snapshot.daily.isEmpty, + daily: snapshot.daily + .filter { self.chartPointInput(for: $0) != nil } + .sorted { $0.date < $1.date } + .map(self.visibleDailyFingerprint), + projects: Array(projects.prefix(self.maxVisibleProjectRows)).map { project in + let visibleSources = self.visibleProjectSources(project) + return VisibleProjectFingerprint( + name: project.name, + path: project.path, + totalTokens: project.totalTokens, + totalCostBitPattern: project.totalCostUSD.map(\.bitPattern), + visibleSourceCount: visibleSources.count, + sources: Array(visibleSources.prefix(self.maxVisibleProjectSourceRows)).map { source in + VisibleSourceFingerprint( + name: source.name, + path: source.path, + totalTokens: source.totalTokens, + totalCostBitPattern: source.totalCostUSD.map(\.bitPattern)) + }) + }, + sessions: sessions.map { session in + VisibleSessionFingerprint( + sessionID: session.sessionID, + lastActivityBitPattern: session.lastActivity.timeIntervalSince1970.bitPattern, + inputTokens: session.inputTokens, + cachedInputTokens: session.cachedInputTokens, + outputTokens: session.outputTokens, + totalTokens: session.totalTokens, + costBitPattern: session.costUSD.map(\.bitPattern), + models: session.modelBreakdowns.map { item in + VisibleModelBreakdownFingerprint( + modelName: item.modelName, + costBitPattern: item.costUSD.map(\.bitPattern), + totalTokens: item.totalTokens, + standardCostBitPattern: item.standardCostUSD.map(\.bitPattern), + priorityCostBitPattern: item.priorityCostUSD.map(\.bitPattern), + standardTokens: item.standardCostUSD == nil ? nil : item.standardTokens, + priorityTokens: item.priorityCostUSD == nil ? nil : item.priorityTokens) + }) + }) + } + + private static func visibleDailyFingerprint(_ entry: DailyEntry) -> VisibleDailyFingerprint { + VisibleDailyFingerprint( + date: entry.date, + totalTokens: entry.totalTokens, + requestCount: entry.requestCount, + costBitPattern: entry.costUSD.map(\.bitPattern), + modelBreakdowns: self.orderedBreakdownItems(entry.modelBreakdowns ?? []).map { item in + VisibleModelBreakdownFingerprint( + modelName: item.modelName, + costBitPattern: item.costUSD.map(\.bitPattern), + totalTokens: item.totalTokens, + standardCostBitPattern: item.standardCostUSD.map(\.bitPattern), + priorityCostBitPattern: item.priorityCostUSD.map(\.bitPattern), + standardTokens: item.standardCostUSD == nil ? nil : item.standardTokens, + priorityTokens: item.priorityCostUSD == nil ? nil : item.priorityTokens) + }) + } + + static func _defaultSelectedDateKeyForTesting(provider: UsageProvider, daily: [DailyEntry]) -> String? { + self.defaultSelectedDateKey(model: self.makeModel(provider: provider, daily: daily)) + } + + static func _axisDatesForTesting(provider: UsageProvider, daily: [DailyEntry]) -> [Date] { + self.makeModel(provider: provider, daily: daily).axisDates + } + + static func _axisLabelPlacementForTesting( + provider: UsageProvider, + daily: [DailyEntry]) -> AxisLabelPlacement + { + self.axisLabelPlacement(for: self.makeModel(provider: provider, daily: daily).axisDates) + } + + static func _yAxisTickValuesForTesting(maxCostUSD: Double) -> [Double] { + self.yAxisTickValues(maxCostUSD: maxCostUSD) + } + + static func _yAxisCostStringForTesting(_ value: Double, currencyCode: String = "USD") -> String { + self.yAxisCostString(value, currencyCode: currencyCode) + } + + static func _detailViewportConfigurationForTesting( + provider: UsageProvider, + daily: [DailyEntry]) -> (rowCount: Int, hasOverflow: Bool, rowHeight: CGFloat) + { + let model = self.makeModel(provider: provider, daily: daily) + return (model.detailViewportRowCount, model.hasDetailOverflow, model.detailRowHeight) + } +} + +extension CostUsageProjectBreakdown { + fileprivate var projectRowID: String { + self.path ?? "unknown:\(self.name)" + } +} + +extension CostUsageProjectSourceBreakdown { + fileprivate var sourceRowID: String { + self.path ?? "unknown:\(self.name)" + } +} diff --git a/Sources/CodexBar/CreditsHistoryChartMenuView.swift b/Sources/CodexBar/CreditsHistoryChartMenuView.swift index e75521eb96..c7fb3508ad 100644 --- a/Sources/CodexBar/CreditsHistoryChartMenuView.swift +++ b/Sources/CodexBar/CreditsHistoryChartMenuView.swift @@ -227,14 +227,6 @@ struct CreditsHistoryChartMenuView: View { let date = model.dayDates[index].date guard let x = proxy.position(forX: date) else { return nil } - func xForIndex(_ idx: Int) -> CGFloat? { - guard idx >= 0, idx < model.dayDates.count else { return nil } - return proxy.position(forX: model.dayDates[idx].date) - } - - let xPrev = xForIndex(index - 1) - let xNext = xForIndex(index + 1) - if model.dayDates.count <= 1 { return CGRect( x: plotFrame.origin.x, @@ -243,24 +235,14 @@ struct CreditsHistoryChartMenuView: View { height: plotFrame.height) } - let leftInPlot: CGFloat = if let xPrev { - (xPrev + x) / 2 - } else if let xNext { - x - (xNext - x) / 2 - } else { - x - 8 - } - - let rightInPlot: CGFloat = if let xNext { - (xNext + x) / 2 - } else if let xPrev { - x + (x - xPrev) / 2 - } else { - x + 8 - } + // Use the calendar day slot width (always 1 day on the time axis) so the band is the + // same size for every bar regardless of gaps in the data. + let nextDayX = proxy.position(forX: ChartBarHoverSelection.nextCalendarDay(after: date)) ?? (x + 20) + let slotWidth = abs(nextDayX - x) + let barHalfWidth = slotWidth * 0.25 + 2 - let left = plotFrame.origin.x + min(leftInPlot, rightInPlot) - let right = plotFrame.origin.x + max(leftInPlot, rightInPlot) + let left = plotFrame.origin.x + x - barHalfWidth + let right = plotFrame.origin.x + x + barHalfWidth return CGRect(x: left, y: plotFrame.origin.y, width: right - left, height: plotFrame.height) } @@ -283,6 +265,24 @@ struct CreditsHistoryChartMenuView: View { guard let date: Date = proxy.value(atX: xInPlot) else { return } guard let nearest = self.nearestDayKey(to: date, model: model) else { return } + // Stay on the last selected bar when cursor is in the gap between bars; only switch + // selection when the cursor is over the bar's own visual body. + // Skip this gate for single-day charts: no gap exists, and selectionBandRect + // already covers the full plot width in that case. + if model.selectableDayDates.count > 1, + let nearestEntry = model.selectableDayDates.first(where: { $0.dayKey == nearest }), + let barX = proxy.position(forX: nearestEntry.date) + { + let nextDayX = proxy.position(forX: ChartBarHoverSelection.nextCalendarDay(after: nearestEntry.date)) ?? + (barX + 20) + let slotWidth = abs(nextDayX - barX) + guard ChartBarHoverSelection.accepts( + distanceFromBarCenter: abs(location.x - (plotFrame.origin.x + barX)), + barHalfWidth: slotWidth * 0.25 + 2, + selectableCount: model.selectableDayDates.count) + else { return } + } + if self.selectedDayKey != nearest { self.selectedDayKey = nearest } diff --git a/Sources/CodexBar/CursorLoginAccountSelector.swift b/Sources/CodexBar/CursorLoginAccountSelector.swift new file mode 100644 index 0000000000..fc684e4ca0 --- /dev/null +++ b/Sources/CodexBar/CursorLoginAccountSelector.swift @@ -0,0 +1,130 @@ +import AppKit +import Foundation + +enum CursorLoginAccountSelector { + /// Metadata presented to the user. Session cookies and headers must never enter this model. + struct Candidate: Equatable, Sendable { + let selectionID: String + let name: String? + let email: String? + let sourceLabel: String + } + + struct Choice: Equatable, Sendable { + let selectionID: String + let displayLabel: String + } + + typealias Chooser = @MainActor ([Choice]) -> String? + + static func choices(for candidates: [Candidate]) -> [Choice] { + let labeledCandidates = candidates + .map { candidate in + (candidate: candidate, baseLabel: self.baseDisplayLabel(for: candidate)) + } + .sorted { lhs, rhs in + let lhsLabel = lhs.baseLabel.lowercased() + let rhsLabel = rhs.baseLabel.lowercased() + if lhsLabel != rhsLabel { + return lhsLabel < rhsLabel + } + return lhs.candidate.selectionID < rhs.candidate.selectionID + } + let labelCounts = Dictionary(grouping: labeledCandidates, by: { $0.baseLabel }).mapValues(\.count) + var labelOrdinals: [String: Int] = [:] + + return labeledCandidates + .map { labeled in + let displayLabel: String + if labelCounts[labeled.baseLabel, default: 0] > 1 { + let ordinal = labelOrdinals[labeled.baseLabel, default: 0] + 1 + labelOrdinals[labeled.baseLabel] = ordinal + displayLabel = "\(labeled.baseLabel) · \(ordinal)" + } else { + displayLabel = labeled.baseLabel + } + return Choice( + selectionID: labeled.candidate.selectionID, + displayLabel: displayLabel) + } + } + + static func selectedCandidateID( + from choices: [Choice], + selectedIndex: Int?, + confirmed: Bool) -> String? + { + guard confirmed, + let selectedIndex, + choices.indices.contains(selectedIndex) + else { + return nil + } + return choices[selectedIndex].selectionID + } + + @MainActor + static func selectCandidateID( + from candidates: [Candidate], + chooser: Chooser = { choices in + CursorLoginAccountSelector.presentChooser(for: choices) + }) -> String? + { + let choices = self.choices(for: candidates) + guard !choices.isEmpty, + let selectedID = chooser(choices), + choices.contains(where: { $0.selectionID == selectedID }) + else { + return nil + } + return selectedID + } + + private static func baseDisplayLabel(for candidate: Candidate) -> String { + var components: [String] = [] + if let name = self.normalized(candidate.name) { + components.append(name) + } + if let email = self.normalized(candidate.email), !components.contains(email) { + components.append(email) + } + if components.isEmpty { + components.append(L("Account")) + } + components.append(candidate.sourceLabel) + return components.joined(separator: " · ") + } + + private static func normalized(_ value: String?) -> String? { + guard let value = value?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty else { + return nil + } + return value + } + + @MainActor + private static func presentChooser(for choices: [Choice]) -> String? { + let popup = NSPopUpButton( + frame: NSRect(x: 0, y: 0, width: 360, height: 26), + pullsDown: false) + for choice in choices { + popup.addItem(withTitle: choice.displayLabel) + popup.lastItem?.representedObject = choice.selectionID + } + popup.selectItem(at: 0) + + let alert = NSAlert() + alert.messageText = L("Choose Cursor account") + alert.informativeText = L("Choose which Cursor account CodexBar should use.") + alert.alertStyle = .informational + alert.accessoryView = popup + alert.addButton(withTitle: L("Use Account")) + alert.addButton(withTitle: L("Cancel")) + + let confirmed = alert.runModal() == .alertFirstButtonReturn + return self.selectedCandidateID( + from: choices, + selectedIndex: popup.indexOfSelectedItem, + confirmed: confirmed) + } +} diff --git a/Sources/CodexBar/CursorLoginBrowserRouter.swift b/Sources/CodexBar/CursorLoginBrowserRouter.swift new file mode 100644 index 0000000000..2c3277f123 --- /dev/null +++ b/Sources/CodexBar/CursorLoginBrowserRouter.swift @@ -0,0 +1,122 @@ +import AppKit +import CodexBarCore +import Foundation + +@MainActor +enum CursorLoginBrowserRouter { + struct Route: Equatable { + let launchURL: URL + /// The concrete browser that must both open the login URL and supply the polled cookies. + let browserApplicationURL: URL + } + + enum Resolution: Equatable { + case route(Route) + case cancelled + case unavailable + } + + typealias ApplicationURLResolver = @MainActor (URL) -> [URL] + typealias ApplicationChooser = @MainActor ([URL]) -> URL? + typealias BrowserSupportCheck = @MainActor (URL?) -> Bool + + static func resolve( + loginURL: URL, + handlerApplicationURL: URL?, + applicationURLs: ApplicationURLResolver = { + NSWorkspace.shared.urlsForApplications(toOpen: $0) + }, + chooseApplication: ApplicationChooser = { applications in + CursorLoginBrowserRouter.chooseApplication(applications) + }, + supportsBrowser: BrowserSupportCheck) + -> Resolution + { + if let handlerApplicationURL, supportsBrowser(handlerApplicationURL) { + return .route(Route( + launchURL: loginURL, + browserApplicationURL: handlerApplicationURL)) + } + + let candidates = self.supportedApplications( + applicationURLs(loginURL), + supportsBrowser: supportsBrowser) + switch candidates.count { + case 0: + return .unavailable + default: + guard let selection = chooseApplication(candidates) else { return .cancelled } + guard let candidate = candidates.first(where: { self.applicationKey($0) == self.applicationKey(selection) }) + else { + return .unavailable + } + return .route(Route( + launchURL: loginURL, + browserApplicationURL: candidate)) + } + } + + static func supportedApplications( + _ applicationURLs: [URL], + supportsBrowser: BrowserSupportCheck) + -> [URL] + { + var seen = Set() + return applicationURLs + .filter { supportsBrowser($0) } + .filter { seen.insert(self.applicationKey($0)).inserted } + .sorted(by: self.applicationSortsBefore) + } + + static func applicationLabels(_ applicationURLs: [URL]) -> [String] { + let names = applicationURLs.map(self.applicationName) + let counts = Dictionary(grouping: names, by: { $0 }).mapValues(\.count) + return zip(applicationURLs, names).map { applicationURL, name in + guard counts[name, default: 0] > 1 else { return name } + return "\(name) (\(applicationURL.deletingLastPathComponent().path))" + } + } + + static func chooseApplication(_ applicationURLs: [URL]) -> URL? { + guard !applicationURLs.isEmpty else { return nil } + + let popup = NSPopUpButton( + frame: NSRect(x: 0, y: 0, width: 320, height: 26), + pullsDown: false) + popup.addItems(withTitles: self.applicationLabels(applicationURLs)) + popup.selectItem(at: 0) + + let alert = NSAlert() + alert.messageText = L("Open Browser") + alert.informativeText = L("Choose a supported browser so CodexBar can read the matching account.") + alert.accessoryView = popup + alert.addButton(withTitle: L("Open Browser")) + alert.addButton(withTitle: L("Cancel")) + + guard alert.runModal() == .alertFirstButtonReturn else { return nil } + let selectedIndex = popup.indexOfSelectedItem + guard applicationURLs.indices.contains(selectedIndex) else { return nil } + return applicationURLs[selectedIndex] + } + + private static func applicationName(_ applicationURL: URL) -> String { + let bundle = Bundle(url: applicationURL) + return (bundle?.object(forInfoDictionaryKey: "CFBundleDisplayName") as? String) + ?? (bundle?.object(forInfoDictionaryKey: kCFBundleNameKey as String) as? String) + ?? applicationURL.deletingPathExtension().lastPathComponent + } + + private static func applicationKey(_ applicationURL: URL) -> String { + applicationURL.standardizedFileURL.path + } + + private static func applicationSortsBefore(_ lhs: URL, _ rhs: URL) -> Bool { + let lhsName = self.applicationName(lhs) + let rhsName = self.applicationName(rhs) + let nameComparison = lhsName.localizedCaseInsensitiveCompare(rhsName) + if nameComparison != .orderedSame { + return nameComparison == .orderedAscending + } + return self.applicationKey(lhs).localizedCaseInsensitiveCompare(self.applicationKey(rhs)) == .orderedAscending + } +} diff --git a/Sources/CodexBar/CursorLoginRunner.swift b/Sources/CodexBar/CursorLoginRunner.swift index 4e5dbe9f01..fb5d460d45 100644 --- a/Sources/CodexBar/CursorLoginRunner.swift +++ b/Sources/CodexBar/CursorLoginRunner.swift @@ -2,9 +2,61 @@ import AppKit import CodexBarCore import Foundation -/// Opens Cursor in the user's browser and waits until the normal browser-cookie importer can read a session. +private func normalizedCursorAccountID(_ value: String?) -> String? { + guard let value = value?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty else { + return nil + } + return value +} + +private func normalizedCursorAccountEmail(_ value: String?) -> String? { + guard let value = value? + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased(), + !value.isEmpty + else { + return nil + } + return value +} + +/// Opens Cursor in a concrete browser and waits until that browser's cookie store exposes a session. @MainActor final class CursorLoginRunner { + struct AccountIdentity: Equatable, Sendable { + let accountID: String? + let email: String? + + init(accountID: String? = nil, email: String?) { + self.accountID = accountID + self.email = email + } + + fileprivate var hasIdentity: Bool { + normalizedCursorAccountID(self.accountID) != nil || + normalizedCursorAccountEmail(self.email) != nil + } + } + + struct AccountPolicy: Equatable, Sendable { + let priorAccount: AccountIdentity? + let requiresConfirmation: Bool + } + + static func accountPolicy( + configuredSource: ProviderCookieSource, + identity: ProviderIdentitySnapshot?, + hasPriorSnapshot: Bool) -> AccountPolicy + { + guard hasPriorSnapshot else { + return AccountPolicy(priorAccount: nil, requiresConfirmation: false) + } + let account = AccountIdentity(accountID: identity?.accountID, email: identity?.accountEmail) + return AccountPolicy( + priorAccount: configuredSource == .auto ? account : nil, + requiresConfirmation: true) + } + enum Phase { case loading case waitingLogin @@ -23,14 +75,52 @@ final class CursorLoginRunner { let email: String? } + struct SnapshotLoadResult: Sendable { + let snapshot: CursorStatusSnapshot + let session: CursorStatusProbe.BrowserLoginSession? + let sourceLabel: String? + + init( + snapshot: CursorStatusSnapshot, + session: CursorStatusProbe.BrowserLoginSession?, + sourceLabel: String? = nil) + { + self.snapshot = snapshot + self.session = session + self.sourceLabel = sourceLabel + } + } + typealias SnapshotLoader = @Sendable () async throws -> CursorStatusSnapshot + typealias BrowserLoginCandidatesLoader = @Sendable (URL, TimeInterval) async throws + -> [CursorStatusProbe.BrowserLoginResult] typealias Sleeper = @Sendable (UInt64) async throws -> Void - typealias SessionCacheResetter = @Sendable () async -> Void + typealias SessionCacheReplacer = @MainActor @Sendable (CursorStatusProbe.BrowserLoginSession) async -> Bool + typealias RouteLauncher = @MainActor (CursorLoginBrowserRouter.Route) async -> Bool + typealias BrowserApplicationResolver = @MainActor (URL) -> URL? + typealias RouteResolver = @MainActor (URL, URL?) -> CursorLoginBrowserRouter.Resolution + typealias AccountChooser = CursorLoginAccountSelector.Chooser - private let loadSnapshot: SnapshotLoader - private let openURL: @MainActor (URL) -> Bool + private enum CandidateSelection { + case none + case selected(SnapshotLoadResult) + case cancelled + } + + private enum RoutePreparation { + case ready(CursorLoginBrowserRouter.Route) + case terminal(Result) + } + + private let loadBrowserLoginCandidates: @Sendable (URL, TimeInterval) async throws -> [SnapshotLoadResult] + private let launchRoute: RouteLauncher private let sleeper: Sleeper - private let resetSessionCache: SessionCacheResetter + private let replaceSessionCache: SessionCacheReplacer + private let priorAccount: AccountIdentity? + private let requiresAccountConfirmation: Bool + private let browserApplicationResolver: BrowserApplicationResolver + private let routeResolver: RouteResolver + private let accountChooser: AccountChooser? private let timeout: TimeInterval private let pollInterval: TimeInterval private let logger = CodexBarLog.logger(LogCategories.cursorLogin) @@ -39,33 +129,106 @@ final class CursorLoginRunner { init( browserDetection: BrowserDetection, + priorAccount: AccountIdentity? = nil, + requiresAccountConfirmation: Bool? = nil, timeout: TimeInterval = 120, pollInterval: TimeInterval = 2, - openURL: @escaping @MainActor (URL) -> Bool = { NSWorkspace.shared.open($0) }, + launchRoute: @escaping RouteLauncher = { route in await CursorLoginRunner.launch(route) }, loadSnapshot: SnapshotLoader? = nil, + loadBrowserLoginCandidates: BrowserLoginCandidatesLoader? = nil, sleeper: @escaping Sleeper = { try await Task.sleep(nanoseconds: $0) }, - resetSessionCache: @escaping SessionCacheResetter = { - CookieHeaderCache.clear(provider: .cursor) - CursorSessionStore.shared.clearCookies() + browserApplicationResolver: @escaping BrowserApplicationResolver = { + NSWorkspace.shared.urlForApplication(toOpen: $0) + }, + routeResolver: RouteResolver? = nil, + accountChooser: AccountChooser? = nil, + replaceSessionCache: @escaping SessionCacheReplacer = { session in + await CursorLoginRunner.replaceCachedSession(session) }) { + self.priorAccount = priorAccount + self.requiresAccountConfirmation = requiresAccountConfirmation ?? (priorAccount != nil) + self.browserApplicationResolver = browserApplicationResolver + self.routeResolver = routeResolver ?? { loginURL, handlerApplicationURL in + CursorLoginBrowserRouter.resolve( + loginURL: loginURL, + handlerApplicationURL: handlerApplicationURL, + supportsBrowser: { applicationURL in + CursorStatusProbe.supportsInteractiveLoginBrowser( + applicationURL: applicationURL, + browserDetection: browserDetection) + }) + } + self.accountChooser = accountChooser self.timeout = timeout self.pollInterval = pollInterval - self.openURL = openURL + self.launchRoute = launchRoute self.sleeper = sleeper - self.resetSessionCache = resetSessionCache - self.loadSnapshot = loadSnapshot ?? { - let probe = CursorStatusProbe(browserDetection: browserDetection) - return try await probe.fetch(allowCachedSessions: false) + self.replaceSessionCache = replaceSessionCache + if let loadBrowserLoginCandidates { + self.loadBrowserLoginCandidates = { browserApplicationURL, timeout in + try await loadBrowserLoginCandidates(browserApplicationURL, timeout).map { result in + SnapshotLoadResult( + snapshot: result.snapshot, + session: result.session, + sourceLabel: result.sourceLabel) + } + } + } else if let loadSnapshot { + self.loadBrowserLoginCandidates = { _, _ in + let snapshot = try await loadSnapshot() + return [SnapshotLoadResult(snapshot: snapshot, session: nil)] + } + } else { + self.loadBrowserLoginCandidates = { browserApplicationURL, timeout in + let probe = CursorStatusProbe(browserDetection: browserDetection) + return try await probe.fetchBrowserLoginCandidates( + browserApplicationURL: browserApplicationURL, + timeout: timeout).map { result in + SnapshotLoadResult( + snapshot: result.snapshot, + session: result.session, + sourceLabel: result.sourceLabel) + } + } } } func run(onPhaseChange: @escaping @MainActor (Phase) -> Void) async -> Result { + await BrowserCookieAccessGate.withExplicitRetry { + await ProviderInteractionContext.$current.withValue(.userInitiated) { + await self.runUserInitiated(onPhaseChange: onPhaseChange) + } + } + } + + private func runUserInitiated(onPhaseChange: @escaping @MainActor (Phase) -> Void) async -> Result { onPhaseChange(.loading) self.logger.info("Cursor login started") - await self.resetSessionCache() + guard !Task.isCancelled else { + self.logger.info("Cursor login cancelled before cache ownership") + return Result(outcome: .cancelled, email: nil) + } + + let cacheMutationGate = CookieHeaderCache.beginConditionalMutationGate(provider: .cursor) + defer { CookieHeaderCache.endConditionalMutationGate(cacheMutationGate) } + + let route: CursorLoginBrowserRouter.Route + switch self.prepareRoute(onPhaseChange: onPhaseChange) { + case let .ready(preparedRoute): + route = preparedRoute + case let .terminal(result): + return result + } - guard self.openURL(Self.authURL) else { + guard !Task.isCancelled else { + return self.cancelAfterTaskCancellation() + } + let launched = await self.launchRoute(route) + guard !Task.isCancelled else { + return self.cancelAfterTaskCancellation() + } + guard launched else { let message = L("Could not open Cursor login in your browser.") onPhaseChange(.failed(message)) self.logger.error("Cursor login browser launch failed") @@ -77,32 +240,254 @@ final class CursorLoginRunner { var lastError: Error? repeat { - if Task.isCancelled { - self.logger.info("Cursor login cancelled") - return Result(outcome: .cancelled, email: nil) + if let cancellation = self.continuationCancellationResult() { + return cancellation } do { - let snapshot = try await self.loadSnapshot() - onPhaseChange(.success) - self.logger.info("Cursor login completed", metadata: ["outcome": "success"]) - return Result(outcome: .success, email: snapshot.accountEmail) + let remainingTime = deadline.timeIntervalSinceNow + guard remainingTime > 0 else { break } + let loaded = try await self.loadBrowserLoginCandidates( + route.browserApplicationURL, + remainingTime) + if let cancellation = self.continuationCancellationResult() { + return cancellation + } + if let result = await self.completeLoadedCandidates( + loaded, + onPhaseChange: onPhaseChange) + { + return result + } } catch { + if Task.isCancelled { + return self.cancelAfterTaskCancellation() + } lastError = error } - guard Date() < deadline else { break } let delay = UInt64(max(0.1, self.pollInterval) * 1_000_000_000) try? await self.sleeper(delay) } while true - let message = Self.timeoutMessage(lastError: lastError) + if Task.isCancelled { + return self.cancelAfterTaskCancellation() + } + let message = self.timeoutMessage(lastError: lastError) onPhaseChange(.failed(message)) self.logger.warning("Cursor login timed out", metadata: ["error": message]) return Result(outcome: .failed(message), email: nil) } - private static func timeoutMessage(lastError: Error?) -> String { + private func continuationCancellationResult() -> Result? { + guard !Task.isCancelled else { + return self.cancelAfterTaskCancellation() + } + return nil + } + + private func prepareRoute(onPhaseChange: @MainActor (Phase) -> Void) -> RoutePreparation { + let loginURL = Self.authURL + let handlerApplicationURL = self.browserApplicationResolver(loginURL) + let route: CursorLoginBrowserRouter.Route + + switch self.routeResolver(loginURL, handlerApplicationURL) { + case let .route(resolvedRoute): + route = CursorLoginBrowserRouter.Route( + launchURL: loginURL, + browserApplicationURL: resolvedRoute.browserApplicationURL) + case .cancelled: + self.logger.info("Cursor login browser selection cancelled") + return .terminal(Result(outcome: .cancelled, email: nil)) + case .unavailable: + let message = Self.unsupportedBrowserMessage(applicationURL: handlerApplicationURL) + onPhaseChange(.failed(message)) + self.logger.error("Cursor login browser unavailable", metadata: ["error": message]) + return .terminal(Result(outcome: .failed(message), email: nil)) + } + + return .ready(route) + } + + private func completeLoadedCandidates( + _ loaded: [SnapshotLoadResult], + onPhaseChange: @MainActor (Phase) -> Void) async -> Result? + { + switch self.selectCandidate(from: loaded) { + case .none: + return nil + case .cancelled: + self.logger.info("Cursor login account selection cancelled") + return Result(outcome: .cancelled, email: nil) + case let .selected(candidate): + guard !Task.isCancelled else { + return self.cancelAfterTaskCancellation() + } + return await self.completeAcceptedLogin( + candidate, + onPhaseChange: onPhaseChange) + } + } + + private func selectCandidate(from loaded: [SnapshotLoadResult]) -> CandidateSelection { + let candidates = self.deduplicatedCandidates(from: loaded) + + guard !candidates.isEmpty else { return .none } + // A sole Add candidate is unambiguous. + // Switching still needs confirmation because browser profiles can be stale. + guard self.requiresAccountConfirmation || candidates.count > 1 else { + return .selected(candidates[0]) + } + + let presentedCandidates = candidates.enumerated().map { index, candidate in + CursorLoginAccountSelector.Candidate( + selectionID: "cursor-candidate-\(index)", + name: candidate.snapshot.accountName, + email: candidate.snapshot.accountEmail, + sourceLabel: candidate.sourceLabel ?? L("Browser")) + } + let selectedID: String? = if let accountChooser { + CursorLoginAccountSelector.selectCandidateID( + from: presentedCandidates, + chooser: accountChooser) + } else { + CursorLoginAccountSelector.selectCandidateID(from: presentedCandidates) + } + guard let selectedID, + let selectedIndex = presentedCandidates.firstIndex(where: { $0.selectionID == selectedID }) + else { + return .cancelled + } + return .selected(candidates[selectedIndex]) + } + + private func deduplicatedCandidates(from loaded: [SnapshotLoadResult]) -> [SnapshotLoadResult] { + var candidates: [SnapshotLoadResult] = [] + + for candidate in loaded where Self.isAcceptableAccount(candidate.snapshot, priorAccount: self.priorAccount) { + let accountID = normalizedCursorAccountID(candidate.snapshot.accountID) + let email = normalizedCursorAccountEmail(candidate.snapshot.accountEmail) + + if let accountID { + if candidates.contains(where: { + normalizedCursorAccountID($0.snapshot.accountID) == accountID + }) { + continue + } + if let email, + let emailOnlyIndex = candidates.firstIndex(where: { + normalizedCursorAccountID($0.snapshot.accountID) == nil && + normalizedCursorAccountEmail($0.snapshot.accountEmail) == email + }) + { + candidates[emailOnlyIndex] = candidate + } else { + candidates.append(candidate) + } + continue + } + + guard let email else { continue } + if candidates.contains(where: { + normalizedCursorAccountEmail($0.snapshot.accountEmail) == email + }) { + continue + } + candidates.append(candidate) + } + + return candidates + } + + private func completeAcceptedLogin( + _ loaded: SnapshotLoadResult, + onPhaseChange: @MainActor (Phase) -> Void) async -> Result + { + let snapshot = loaded.snapshot + if let session = loaded.session { + guard await self.replaceSessionCache(session) else { + let message = L("Cursor login failed") + onPhaseChange(.failed(message)) + self.logger.error("Cursor login session cache commit failed") + return Result(outcome: .failed(message), email: nil) + } + } + onPhaseChange(.success) + self.logger.info("Cursor login completed", metadata: ["outcome": "success"]) + return Result(outcome: .success, email: snapshot.accountEmail) + } + + private func cancelAfterTaskCancellation() -> Result { + self.logger.info("Cursor login cancelled") + return Result(outcome: .cancelled, email: nil) + } + + @MainActor + static func replaceCachedSession( + _ session: CursorStatusProbe.BrowserLoginSession, + afterCommit: @MainActor () -> Void = {}) async -> Bool + { + // Candidate discovery is cache-independent. Keep both active stores intact until the replacement is durable. + guard CursorStatusProbe.commitBrowserLoginSession(session) else { return false } + afterCommit() + await CursorSessionStore.shared.clearCookies() + return true + } + + private static func launch(_ route: CursorLoginBrowserRouter.Route) async -> Bool { + let configuration = NSWorkspace.OpenConfiguration() + configuration.activates = true + do { + _ = try await NSWorkspace.shared.open( + [route.launchURL], + withApplicationAt: route.browserApplicationURL, + configuration: configuration) + return true + } catch { + return false + } + } + + private nonisolated static func isAcceptableAccount( + _ snapshot: CursorStatusSnapshot, + priorAccount: AccountIdentity?) -> Bool + { + guard let priorAccount else { + return normalizedCursorAccountID(snapshot.accountID) != nil || + normalizedCursorAccountEmail(snapshot.accountEmail) != nil + } + + guard priorAccount.hasIdentity else { + // Preserve Switch intent when the current usage response lacks identity metadata. The candidate still + // requires explicit confirmation because `selectCandidate` sees a non-nil prior account. + return normalizedCursorAccountID(snapshot.accountID) != nil || + normalizedCursorAccountEmail(snapshot.accountEmail) != nil + } + + if let priorAccountID = normalizedCursorAccountID(priorAccount.accountID), + let candidateAccountID = normalizedCursorAccountID(snapshot.accountID) + { + return candidateAccountID != priorAccountID + } + + guard let priorEmail = normalizedCursorAccountEmail(priorAccount.email), + let candidateEmail = normalizedCursorAccountEmail(snapshot.accountEmail) + else { return false } + return candidateEmail != priorEmail + } + + private func timeoutMessage(lastError: Error?) -> String { + if self.priorAccount != nil { + let hint = L("Finish switching to a different Cursor account in your browser, then try again.") + guard let lastError else { + return String(format: L("Timed out waiting for Cursor account switch. %@"), hint) + } + return String( + format: L("Timed out waiting for Cursor account switch. %@ Last error: %@"), + hint, + lastError.localizedDescription) + } + let hint = L("Sign in to cursor.com in your browser, then refresh Cursor in CodexBar.") guard let lastError else { return String(format: L("Timed out waiting for Cursor login. %@"), hint) @@ -112,4 +497,17 @@ final class CursorLoginRunner { hint, lastError.localizedDescription) } + + private static func unsupportedBrowserMessage(applicationURL: URL?) -> String { + let headline = L("Could not open Cursor login in your browser.") + let manualFallback = String( + format: L("Paste a Cookie header from %@."), + "cursor.com") + guard let applicationURL else { + return "\(headline) \(L("Browser cookies")): \(L("Unsupported")). \(manualFallback)" + } + let applicationName = applicationURL.deletingPathExtension().lastPathComponent + let unsupported = String(format: L("%@: unsupported"), applicationName) + return "\(headline) \(unsupported). \(manualFallback)" + } } diff --git a/Sources/CodexBar/HiddenWindowView.swift b/Sources/CodexBar/HiddenWindowView.swift index 689a2f1445..6be1725bcd 100644 --- a/Sources/CodexBar/HiddenWindowView.swift +++ b/Sources/CodexBar/HiddenWindowView.swift @@ -1,12 +1,68 @@ import SwiftUI +final class SettingsOpenRequest { + var wasHandled = false +} + +@MainActor +struct SettingsWindowOpener { + enum Path { + case notification + case appKit + } + + enum Outcome: Equatable { + case preferred + case fallback + case failed + } + + private let notification: @MainActor () -> Bool + private let appKit: @MainActor () -> Bool + + init( + notification: @escaping @MainActor () -> Bool, + appKit: @escaping @MainActor () -> Bool) + { + self.notification = notification + self.appKit = appKit + } + + static func live() -> Self { + Self( + notification: { + let request = SettingsOpenRequest() + NotificationCenter.default.post(name: .codexbarOpenSettings, object: request) + return request.wasHandled + }, + appKit: { + NSApp.sendAction(Selector(("showPreferencesWindow:")), to: nil, from: nil) + }) + } + + func open(preferred: Path) -> Outcome { + let attempts = preferred == .notification + ? [self.notification, self.appKit] + : [self.appKit, self.notification] + if attempts[0]() { + return .preferred + } + if attempts[1]() { + return .fallback + } + return .failed + } +} + struct HiddenWindowView: View { @Environment(\.openSettings) private var openSettings var body: some View { Color.clear .frame(width: 20, height: 20) - .onReceive(NotificationCenter.default.publisher(for: .codexbarOpenSettings)) { _ in + .background(KeepaliveWindowConfigurator()) + .onReceive(NotificationCenter.default.publisher(for: .codexbarOpenSettings)) { notification in + (notification.object as? SettingsOpenRequest)?.wasHandled = true Task { @MainActor in self.openSettings() } @@ -17,22 +73,49 @@ struct HiddenWindowView: View { KeychainMigration.migrateIfNeeded() }.value } - .onAppear { - if let window = NSApp.windows.first(where: { $0.title == "CodexBarLifecycleKeepalive" }) { - // Make the keepalive window truly invisible and non-interactive. - window.styleMask = [.borderless] - window.collectionBehavior = [.auxiliary, .ignoresCycle, .transient, .canJoinAllSpaces] - window.isExcludedFromWindowsMenu = true - window.level = .floating - window.isOpaque = false - window.alphaValue = 0 - window.backgroundColor = .clear - window.hasShadow = false - window.ignoresMouseEvents = true - window.canHide = false - window.setContentSize(NSSize(width: 1, height: 1)) - window.setFrameOrigin(NSPoint(x: -5000, y: -5000)) - } - } + } +} + +@MainActor +struct KeepaliveWindowConfigurator: NSViewRepresentable { + func makeNSView(context: Context) -> KeepaliveWindowConfiguratorView { + KeepaliveWindowConfiguratorView() + } + + func updateNSView(_ nsView: KeepaliveWindowConfiguratorView, context: Context) {} +} + +@MainActor +final class KeepaliveWindowConfiguratorView: NSView { + private let windowProvider: (NSView) -> NSWindow? + + init(windowProvider: @escaping (NSView) -> NSWindow? = { $0.window }) { + self.windowProvider = windowProvider + super.init(frame: .zero) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) is unavailable") + } + + override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + guard let window = self.windowProvider(self) else { return } + + window.identifier = NSUserInterfaceItemIdentifier("CodexBarLifecycleKeepalive") + // Make the keepalive window truly invisible and non-interactive. + window.styleMask = [.borderless] + window.collectionBehavior = [.auxiliary, .ignoresCycle, .transient, .canJoinAllSpaces] + window.isExcludedFromWindowsMenu = true + window.level = .floating + window.isOpaque = false + window.alphaValue = 0 + window.backgroundColor = .clear + window.hasShadow = false + window.ignoresMouseEvents = true + window.canHide = false + window.setContentSize(NSSize(width: 1, height: 1)) + window.setFrameOrigin(NSPoint(x: -5000, y: -5000)) } } diff --git a/Sources/CodexBar/HistoricalUsagePace.swift b/Sources/CodexBar/HistoricalUsagePace.swift index dc8a0ac5cd..70a6bdb233 100644 --- a/Sources/CodexBar/HistoricalUsagePace.swift +++ b/Sources/CodexBar/HistoricalUsagePace.swift @@ -811,10 +811,11 @@ enum CodexHistoricalPaceEvaluator { let weights = weightedWeeks.map(\.weight) let historicalMedian = Self.weightedMedian(values: values, weights: weights) let linearBaseline = 100 * u + // Historical demand can exceed a sustainable quota pace. Never call that excess a reserve. expectedCurve[index] = Self.clamp( (lambda * historicalMedian) + ((1 - lambda) * linearBaseline), lower: 0, - upper: 100) + upper: linearBaseline) } // Expected cumulative usage should be monotone. @@ -831,17 +832,30 @@ enum CodexHistoricalPaceEvaluator { crossingCandidates.reserveCapacity(weightedWeeks.count) for weighted in weightedWeeks { - let week = weighted.week + var extendedCurve = weighted.week.curve + if let capIndex = extendedCurve.firstIndex(where: { $0 >= 100 - Self.epsilon }), + capIndex > 0, capIndex < extendedCurve.count - 1 + { + let gridCount = CodexHistoricalDataset.gridPointCount + let uCap = Double(capIndex) / Double(gridCount - 1) + let valCap = extendedCurve[capIndex] + let slope: Double = valCap / uCap + for i in capIndex..= 100 - Self.epsilon if runOut { weightedRunOutMass += weight if let crossingU = Self.firstCrossing( after: uNow, - curve: week.curve, + curve: extendedCurve, shift: shift, actualAtNow: actual) { @@ -855,12 +869,16 @@ enum CodexHistoricalPaceEvaluator { (weightedRunOutMass + 0.5) / (totalWeight + 1), lower: 0, upper: 1) - let runOutProbability: Double? = scopedWeeks.count >= Self.minimumWeeksForRisk ? smoothedProbability : nil + var runOutProbability: Double? = scopedWeeks.count >= Self.minimumWeeksForRisk ? smoothedProbability : nil var willLastToReset = smoothedProbability < 0.5 var etaSeconds: TimeInterval? - if !willLastToReset { + if actual >= 100 { + willLastToReset = false + etaSeconds = 0 + runOutProbability = 1 + } else if !willLastToReset { let values = crossingCandidates.map(\.etaSeconds) let weights = crossingCandidates.map(\.weight) if values.isEmpty { @@ -875,7 +893,8 @@ enum CodexHistoricalPaceEvaluator { actualUsedPercent: actual, etaSeconds: etaSeconds, willLastToReset: willLastToReset, - runOutProbability: runOutProbability) + runOutProbability: runOutProbability, + projectedRemainingUsage: max(0, (expectedCurve.last ?? expectedNow) - expectedNow)) } private static func firstCrossing( diff --git a/Sources/CodexBar/IconRemainingResolver.swift b/Sources/CodexBar/IconRemainingResolver.swift index 6d2b0e2ca7..49fd27b773 100644 --- a/Sources/CodexBar/IconRemainingResolver.swift +++ b/Sources/CodexBar/IconRemainingResolver.swift @@ -1,7 +1,14 @@ import CodexBarCore +import Foundation enum IconRemainingResolver { - private static func codexProjection(snapshot: UsageSnapshot) -> CodexConsumerProjection { + private static let visibleZeroPercent = 0.0001 + private static let antigravityQuotaSummaryWindowIDPrefix = "antigravity-quota-summary-" + // Antigravity quota summaries expose exact 5-hour session and weekly buckets for the compact icon. + private static let sessionWindowMinutes = 5 * 60 + private static let weeklyWindowMinutes = 7 * 24 * 60 + + private static func codexProjection(snapshot: UsageSnapshot, now: Date) -> CodexConsumerProjection { CodexConsumerProjection.make( surface: .menuBar, context: CodexConsumerProjection.Context( @@ -13,17 +20,56 @@ enum IconRemainingResolver { rawDashboardError: nil, dashboardAttachmentAuthorized: false, dashboardRequiresLogin: false, - now: snapshot.updatedAt)) + now: now)) + } + + private static func codexVisibleWindows(snapshot: UsageSnapshot, now: Date) -> [RateWindow] { + let projection = self.codexProjection(snapshot: snapshot, now: now) + return projection.visibleRateLanes.compactMap { projection.menuBarSelectableRateWindow(for: $0) } + } + + private static func antigravityQuotaSummaryWindows( + snapshot: UsageSnapshot) + -> (primary: RateWindow?, secondary: RateWindow?)? + { + let quotaSummaryWindows = snapshot.extraRateWindows? + .filter { + $0.id.hasPrefix(Self.antigravityQuotaSummaryWindowIDPrefix) + } ?? [] + guard !quotaSummaryWindows.isEmpty else { return nil } + + return self.antigravityQuotaSummaryPair(in: quotaSummaryWindows.filter(\.usageKnown)) } - private static func codexVisibleWindows(snapshot: UsageSnapshot) -> [RateWindow] { - let projection = self.codexProjection(snapshot: snapshot) - return projection.visibleRateLanes.compactMap { projection.rateWindow(for: $0) } + private static func antigravityQuotaSummaryPair( + in windows: [NamedRateWindow]) + -> (primary: RateWindow?, secondary: RateWindow?)? + { + let session = self.mostConstrainedWindow(in: windows, windowMinutes: Self.sessionWindowMinutes) + let weekly = self.mostConstrainedWindow(in: windows, windowMinutes: Self.weeklyWindowMinutes) + guard session != nil || weekly != nil else { return nil } + return (primary: session, secondary: weekly) + } + + /// Returns the highest-usage window for an exact Antigravity compact-icon cadence. + private static func mostConstrainedWindow(in windows: [NamedRateWindow], windowMinutes: Int) -> RateWindow? { + windows + .filter { $0.window.windowMinutes == windowMinutes } + .max { lhs, rhs in + if lhs.window.usedPercent != rhs.window.usedPercent { + return lhs.window.usedPercent < rhs.window.usedPercent + } + // max(by:) keeps the right-hand element when this returns true; use `>` so the smallest id wins ties. + return lhs.id > rhs.id + }? + .window } static func resolvedWindows( snapshot: UsageSnapshot, - style: IconStyle) + style: IconStyle, + secondaryOverrideWindowID: String? = nil, + now: Date = Date()) -> (primary: RateWindow?, secondary: RateWindow?) { if style == .perplexity { @@ -33,17 +79,24 @@ enum IconRemainingResolver { secondary: windows.dropFirst().first) } if style == .antigravity { - let windows = [snapshot.primary, snapshot.secondary, snapshot.tertiary].compactMap(\.self) - return ( - primary: windows.first, - secondary: windows.dropFirst().first) + // Only current quota-summary buckets define the fixed session/weekly icon lanes. + return self.antigravityQuotaSummaryWindows(snapshot: snapshot) + ?? (primary: nil, secondary: nil) } if style == .codex { - let windows = self.codexVisibleWindows(snapshot: snapshot) + let windows = self.codexVisibleWindows(snapshot: snapshot, now: now) return ( primary: windows.first, secondary: windows.dropFirst().first) } + if style == .copilot, + let secondaryOverrideWindowID, + let extraWindow = snapshot.extraRateWindows?.first(where: { $0.id == secondaryOverrideWindowID })?.window + { + return ( + primary: snapshot.primary, + secondary: extraWindow) + } return ( primary: snapshot.primary, secondary: snapshot.secondary) @@ -51,41 +104,49 @@ enum IconRemainingResolver { static func resolvedRemaining( snapshot: UsageSnapshot, - style: IconStyle) + style: IconStyle, + secondaryOverrideWindowID: String? = nil, + now: Date = Date()) -> (primary: Double?, secondary: Double?) { - if style == .perplexity { - let windows = snapshot.orderedPerplexityDisplayWindows() - return ( - primary: windows.first?.remainingPercent, - secondary: windows.dropFirst().first?.remainingPercent) - } - if style == .antigravity { - let windows = [snapshot.primary, snapshot.secondary, snapshot.tertiary].compactMap(\.self) - return ( - primary: windows.first?.remainingPercent, - secondary: windows.dropFirst().first?.remainingPercent) - } - if style == .codex { - let windows = self.codexVisibleWindows(snapshot: snapshot) - return ( - primary: windows.first?.remainingPercent, - secondary: windows.dropFirst().first?.remainingPercent) - } + let windows = self.resolvedWindows( + snapshot: snapshot, + style: style, + secondaryOverrideWindowID: secondaryOverrideWindowID, + now: now) return ( - primary: snapshot.primary?.remainingPercent, - secondary: snapshot.secondary?.remainingPercent) + primary: windows.primary?.remainingPercent, + secondary: windows.secondary?.remainingPercent) } static func resolvedPercents( snapshot: UsageSnapshot, style: IconStyle, - showUsed: Bool) + showUsed: Bool, + renderingStyle: IconStyle? = nil, + secondaryOverrideWindowID: String? = nil, + now: Date = Date()) -> (primary: Double?, secondary: Double?) { - let windows = Self.resolvedWindows(snapshot: snapshot, style: style) - return ( + let windows = Self.resolvedWindows( + snapshot: snapshot, + style: style, + secondaryOverrideWindowID: secondaryOverrideWindowID, + now: now) + var percents = ( primary: showUsed ? windows.primary?.usedPercent : windows.primary?.remainingPercent, secondary: showUsed ? windows.secondary?.usedPercent : windows.secondary?.remainingPercent) + // Provider style chooses the usage lanes; rendering style controls renderer-specific layout sentinels. + // Merged icons still resolve Warp's lanes, but render as `.combined` and must keep the real percentage. + if showUsed, style == .warp, (renderingStyle ?? style) == .warp, let secondary = windows.secondary { + if secondary.remainingPercent <= 0 { + // Preserve Warp's exhausted/no-bonus layout even though used percent is 100. + percents.secondary = 0 + } else if percents.secondary == 0 { + // A zero fill means "lane absent" to IconRenderer; keep an unused bonus lane visible. + percents.secondary = self.visibleZeroPercent + } + } + return percents } } diff --git a/Sources/CodexBar/IconRenderer.swift b/Sources/CodexBar/IconRenderer.swift index 77803c2827..cda3c3e351 100644 --- a/Sources/CodexBar/IconRenderer.swift +++ b/Sources/CodexBar/IconRenderer.swift @@ -35,7 +35,8 @@ enum IconRenderer { let stale: Bool let style: Int let indicator: Int - let tintHash: Int + let hideCritters: Bool + let tint: Int } private final class IconCacheStore: @unchecked Sendable { @@ -120,15 +121,16 @@ enum IconRenderer { wiggle: CGFloat = 0, tilt: CGFloat = 0, statusIndicator: ProviderStatusIndicator = .none, - tintColor: NSColor? = nil) -> NSImage + hideCritters: Bool = false, + tint: NSColor? = nil) -> NSImage { let shouldCache = blink <= 0.0001 && wiggle <= 0.0001 && tilt <= 0.0001 let render = { - self.renderImage(tintColor: tintColor) { - // When a tintColor is provided (macOS 26+ Liquid Glass), draw shapes directly in that - // color so the bitmap has real RGB values. Otherwise use labelColor for template rendering - // tinted via the status button's contentTintColor. - let baseFill = tintColor ?? NSColor.labelColor + self.renderImage(isTemplate: tint == nil) { + // Untinted icons stay monochrome templates; Claude uses subtle shape cues only. A tint is drawn + // as real RGB instead, because a template image keeps only its alpha mask and is recolored by + // AppKit -- which is why tinting one via `contentTintColor` has no effect on macOS 26. + let baseFill = tint ?? NSColor.labelColor let trackFillAlpha: CGFloat = stale ? 0.18 : 0.28 let trackStrokeAlpha: CGFloat = stale ? 0.28 : 0.44 let fillColor = baseFill.withAlphaComponent(stale ? 0.55 : 1.0) @@ -659,17 +661,25 @@ enum IconRenderer { // Warp special case: when no bonus or bonus exhausted, show "top monthly, bottom dimmed" let warpNoBonus = style == .warp && !weeklyAvailable + // "Hide critters" renders plain meter bars: suppress all face/decoration twists. + let twistFace = !hideCritters && style == .codex + let twistNotches = !hideCritters && style == .claude + let twistGemini = !hideCritters && (style == .gemini || style == .antigravity) + let twistAntigravity = !hideCritters && style == .antigravity + let twistFactory = !hideCritters && style == .factory + let twistWarp = !hideCritters && style == .warp + if weeklyAvailable { // Normal: top=primary, bottom=secondary (bonus/weekly). drawBar( rectPx: topRectPx, remaining: topValue, - addNotches: style == .claude, - addFace: style == .codex, - addGeminiTwist: style == .gemini || style == .antigravity, - addAntigravityTwist: style == .antigravity, - addFactoryTwist: style == .factory, - addWarpTwist: style == .warp, + addNotches: twistNotches, + addFace: twistFace, + addGeminiTwist: twistGemini, + addAntigravityTwist: twistAntigravity, + addFactoryTwist: twistFactory, + addWarpTwist: twistWarp, blink: blink) drawBar(rectPx: bottomRectPx, remaining: bottomValue) } else if !hasWeekly || warpNoBonus { @@ -678,7 +688,7 @@ enum IconRenderer { drawBar( rectPx: topRectPx, remaining: topValue, - addWarpTwist: true, + addWarpTwist: twistWarp, blink: blink) drawBar(rectPx: bottomRectPx, remaining: nil, alpha: 0.45) } else { @@ -690,24 +700,24 @@ enum IconRenderer { rectPx: creditsRectPx, remaining: ratio, alpha: creditsAlpha, - addNotches: style == .claude, - addFace: style == .codex, - addGeminiTwist: style == .gemini || style == .antigravity, - addAntigravityTwist: style == .antigravity, - addFactoryTwist: style == .factory, - addWarpTwist: style == .warp, + addNotches: twistNotches, + addFace: twistFace, + addGeminiTwist: twistGemini, + addAntigravityTwist: twistAntigravity, + addFactoryTwist: twistFactory, + addWarpTwist: twistWarp, blink: blink) drawBar(rectPx: creditsBottomRectPx, remaining: nil, alpha: 0.45) } else { drawBar( rectPx: topRectPx, remaining: topValue, - addNotches: style == .claude, - addFace: style == .codex, - addGeminiTwist: style == .gemini || style == .antigravity, - addAntigravityTwist: style == .antigravity, - addFactoryTwist: style == .factory, - addWarpTwist: style == .warp, + addNotches: twistNotches, + addFace: twistFace, + addGeminiTwist: twistGemini, + addAntigravityTwist: twistAntigravity, + addFactoryTwist: twistFactory, + addWarpTwist: twistWarp, blink: blink) drawBar(rectPx: bottomRectPx, remaining: nil, alpha: 0.45) } @@ -719,30 +729,30 @@ enum IconRenderer { rectPx: creditsRectPx, remaining: ratio, alpha: creditsAlpha, - addNotches: style == .claude, - addFace: style == .codex, - addGeminiTwist: style == .gemini || style == .antigravity, - addAntigravityTwist: style == .antigravity, - addFactoryTwist: style == .factory, - addWarpTwist: style == .warp, + addNotches: twistNotches, + addFace: twistFace, + addGeminiTwist: twistGemini, + addAntigravityTwist: twistAntigravity, + addFactoryTwist: twistFactory, + addWarpTwist: twistWarp, blink: blink) } else { // No credits available; fall back to 5h if present. drawBar( rectPx: topRectPx, remaining: topValue, - addNotches: style == .claude, - addFace: style == .codex, - addGeminiTwist: style == .gemini || style == .antigravity, - addAntigravityTwist: style == .antigravity, - addFactoryTwist: style == .factory, - addWarpTwist: style == .warp, + addNotches: twistNotches, + addFace: twistFace, + addGeminiTwist: twistGemini, + addAntigravityTwist: twistAntigravity, + addFactoryTwist: twistFactory, + addWarpTwist: twistWarp, blink: blink) } drawBar(rectPx: creditsBottomRectPx, remaining: bottomValue) } - Self.drawStatusOverlay(indicator: statusIndicator, tintColor: tintColor) + Self.drawStatusOverlay(indicator: statusIndicator, color: baseFill) } } @@ -754,7 +764,8 @@ enum IconRenderer { stale: stale, style: self.styleKey(style), indicator: self.indicatorKey(statusIndicator), - tintHash: self.tintColorHash(tintColor)) + hideCritters: hideCritters, + tint: self.tintKey(tint)) if let cached = self.cachedIcon(for: key) { return cached } @@ -769,14 +780,14 @@ enum IconRenderer { // swiftlint:enable function_body_length /// Morph helper: unbraids a simplified knot into our bar icon. - static func makeMorphIcon(progress: Double, style: IconStyle) -> NSImage { + static func makeMorphIcon(progress: Double, style: IconStyle, hideCritters: Bool = false) -> NSImage { let clamped = max(0, min(progress, 1)) - let key = self.morphCacheKey(progress: clamped, style: style) + let key = self.morphCacheKey(progress: clamped, style: style, hideCritters: hideCritters) if let cached = self.morphCache.image(for: key) { return cached } let image = self.renderImage { - self.drawUnbraidMorph(t: clamped, style: style) + self.drawUnbraidMorph(t: clamped, style: style, hideCritters: hideCritters) } self.morphCache.set(image, for: key) return image @@ -805,21 +816,6 @@ enum IconRenderer { self.styleKeyLookup[style] ?? 0 } - private static func tintColorHash(_ color: NSColor?) -> Int { - guard let color else { return 0 } - // Quantize to 256 buckets per channel to avoid cache explosion while preserving visual fidelity. - var r: CGFloat = 0 - var g: CGFloat = 0 - var b: CGFloat = 0 - var a: CGFloat = 0 - (color.usingColorSpace(.sRGB) ?? color).getRed(&r, green: &g, blue: &b, alpha: &a) - let ri = Int((r * 255).rounded()) - let gi = Int((g * 255).rounded()) - let bi = Int((b * 255).rounded()) - let ai = Int((a * 255).rounded()) - return ri << 24 | gi << 16 | bi << 8 | ai - } - private static func indicatorKey(_ indicator: ProviderStatusIndicator) -> Int { switch indicator { case .none: 0 @@ -831,9 +827,9 @@ enum IconRenderer { } } - private static func morphCacheKey(progress: Double, style: IconStyle) -> NSNumber { + private static func morphCacheKey(progress: Double, style: IconStyle, hideCritters: Bool) -> NSNumber { let bucket = Int((progress * Double(self.morphBucketCount)).rounded()) - let key = self.styleKey(style) * 1000 + bucket + let key = (hideCritters ? 1_000_000 : 0) + self.styleKey(style) * 1000 + bucket return NSNumber(value: key) } @@ -845,7 +841,7 @@ enum IconRenderer { self.iconCacheStore.storeIcon(image, for: key, limit: self.iconCacheLimit) } - private static func drawUnbraidMorph(t: Double, style: IconStyle) { + private static func drawUnbraidMorph(t: Double, style: IconStyle, hideCritters: Bool) { let t = CGFloat(max(0, min(t, 1))) let size = Self.baseSize let center = CGPoint(x: size.width / 2, y: size.height / 2) @@ -923,7 +919,8 @@ enum IconRenderer { weeklyRemaining: 100, creditsRemaining: nil, stale: false, - style: style) + style: style, + hideCritters: hideCritters) bars.draw(in: CGRect(origin: .zero, size: size), from: .zero, operation: .sourceOver, fraction: barT) } } @@ -952,9 +949,11 @@ enum IconRenderer { path.fill() } - private static func drawStatusOverlay(indicator: ProviderStatusIndicator, tintColor: NSColor? = nil) { + private static func drawStatusOverlay( + indicator: ProviderStatusIndicator, + color: NSColor = .labelColor) + { guard indicator.hasIssue else { return } - let color = tintColor ?? NSColor.labelColor switch indicator { case .minor, .maintenance: @@ -964,6 +963,8 @@ enum IconRenderer { y: 2, width: size, height: size) + Self.clearStatusOverlayHalo( + NSBezierPath(ovalIn: rect.insetBy(dx: -1, dy: -1))) let path = NSBezierPath(ovalIn: rect) color.setFill() path.fill() @@ -973,21 +974,35 @@ enum IconRenderer { y: 4, width: 2.0, height: 6) - let linePath = NSBezierPath(roundedRect: lineRect, xRadius: 1, yRadius: 1) - color.setFill() - linePath.fill() - let dotRect = Self.snapRect( x: Self.baseSize.width - 6, y: 2, width: 2.0, height: 2.0) + + let haloRect = lineRect.union(dotRect).insetBy(dx: -1, dy: -1) + Self.clearStatusOverlayHalo( + NSBezierPath(roundedRect: haloRect, xRadius: 2, yRadius: 2)) + + let linePath = NSBezierPath(roundedRect: lineRect, xRadius: 1, yRadius: 1) + color.setFill() + linePath.fill() NSBezierPath(ovalIn: dotRect).fill() case .none: break } } + private static func clearStatusOverlayHalo(_ path: NSBezierPath) { + guard let ctx = NSGraphicsContext.current?.cgContext else { return } + ctx.saveGState() + ctx.setBlendMode(.clear) + // The fill color is ignored by .clear; it only drives the path fill operation. + NSColor.black.setFill() + path.fill() + ctx.restoreGState() + } + private static func withScaledContext(_ draw: () -> Void) { guard let ctx = NSGraphicsContext.current?.cgContext else { draw() @@ -1008,7 +1023,7 @@ enum IconRenderer { CGRect(x: self.snap(x), y: self.snap(y), width: self.snap(width), height: self.snap(height)) } - private static func renderImage(tintColor: NSColor? = nil, _ draw: () -> Void) -> NSImage { + private static func renderImage(isTemplate: Bool = true, _ draw: () -> Void) -> NSImage { let image = NSImage(size: Self.outputSize) if let rep = NSBitmapImageRep( @@ -1019,7 +1034,7 @@ enum IconRenderer { samplesPerPixel: 4, hasAlpha: true, isPlanar: false, - colorSpaceName: .calibratedRGB, + colorSpaceName: .deviceRGB, bytesPerRow: 0, bitsPerPixel: 0) { @@ -1039,11 +1054,19 @@ enum IconRenderer { image.unlockFocus() } - // A colored icon must be non-template so macOS 26 Liquid Glass keeps its RGB pixels - // instead of re-rendering it as a monochrome template. - image.isTemplate = tintColor == nil + image.isTemplate = isTemplate return image } + + /// Packs a tint into the icon cache key. Quantizing to 8 bits per channel keeps two visually identical + /// tints on the same cache entry instead of growing an entry per rendered percentage point. + private static func tintKey(_ tint: NSColor?) -> Int { + guard let srgb = tint?.usingColorSpace(.sRGB) else { return 0 } + let red = Int((srgb.redComponent * 255).rounded()) + let green = Int((srgb.greenComponent * 255).rounded()) + let blue = Int((srgb.blueComponent * 255).rounded()) + return 1 << 24 | red << 16 | green << 8 | blue + } } extension CGPoint { diff --git a/Sources/CodexBar/InlineUsageDashboardContent.swift b/Sources/CodexBar/InlineUsageDashboardContent.swift index 0437288b50..bf7d7cc4e2 100644 --- a/Sources/CodexBar/InlineUsageDashboardContent.swift +++ b/Sources/CodexBar/InlineUsageDashboardContent.swift @@ -19,6 +19,7 @@ struct InlineUsageDashboardModel: Equatable { case currencyUSD case currency(symbol: String) case tokens + case points } let accessibilityLabel: String @@ -26,6 +27,12 @@ struct InlineUsageDashboardModel: Equatable { let kpis: [KPI] let points: [Point] let detailLines: [String] + /// Provider branding color used to fill the mini usage bars. When nil the bars fall back to a + /// neutral palette derived from `valueStyle`. + var barColor: Color? + /// ISO 4217 currency code for cost dashboards. When non-nil, `MiniUsageBars` shows a max-cost scale label. + /// Nil for token/points dashboards. + var currencyCode: String? } extension UsageMenuCardView.Model { @@ -42,6 +49,31 @@ extension UsageMenuCardView.Model { return usage.displayLines } + if input.provider == .clawrouter, + let usage = input.snapshot?.clawRouterUsage + { + var notes = [ + "\(UsageFormatter.tokenCountString(usage.requestCount)) \(L("requests")) · " + + "\(UsageFormatter.tokenCountString(usage.totalTokens)) \(L("tokens"))", + ] + if usage.errorCount > 0 { + notes.append("\(usage.successCount) succeeded · \(usage.errorCount) failed") + } + if !usage.providers.isEmpty { + let mix = usage.providers.prefix(5) + .map { "\($0.provider): \(UsageFormatter.tokenCountString($0.requestCount))" } + .joined(separator: " · ") + notes.append("Routed providers: \(mix)") + } + return notes + } + + if input.provider == .wayfinder, + let usage = input.snapshot?.wayfinderUsage + { + return usage.displayLines + } + if input.provider == .minimax, input.showOptionalCreditsAndExtraUsage, let billing = input.snapshot?.minimaxUsage?.billingSummary @@ -54,10 +86,30 @@ extension UsageMenuCardView.Model { ] } - if input.provider == .deepseek, - input.showOptionalCreditsAndExtraUsage, - let usage = input.snapshot?.deepseekUsage - { + if input.provider == .deepseek { + if input.isRefreshing { + return [] + } + if input.snapshot?.primary == nil { + if input.snapshot?.deepseekDetailedUsageState == .webSessionRequired { + return [L("Sign in to DeepSeek Platform in Chrome for detailed usage.")] + } + if input.snapshot?.deepseekDetailedUsageState == .profileSelectionRequired { + return [L("Select a DeepSeek Chrome profile in Settings.")] + } + } + guard input.tokenCostInlineDashboardEnabled, + input.showOptionalCreditsAndExtraUsage + else { return nil } + guard let usage = input.snapshot?.deepseekUsage else { + if input.snapshot?.deepseekDetailedUsageState == .webSessionRequired { + return [L("Sign in to DeepSeek Platform in Chrome for detailed usage.")] + } + if input.snapshot?.deepseekDetailedUsageState == .profileSelectionRequired { + return [L("Select a DeepSeek Chrome profile in Settings.")] + } + return [L("Detailed usage unavailable.")] + } let symbol = usage.currency == "CNY" ? "¥" : "$" let todayCostStr = usage.todayCost.map { "\(symbol)\(String(format: "%.4f", max(0, $0)))" } ?? "—" return [ @@ -69,17 +121,23 @@ extension UsageMenuCardView.Model { ] } + if input.provider == .poe, + let usage = input.snapshot?.poeUsage + { + return self.poeUsageNotes(usage, now: input.now) + } + if input.provider == .ollama, input.snapshot?.identity?.loginMethod == "API key" { - return [L("API key verified. Ollama does not expose Cloud quota limits through the API.")] + return [L("API key verified. Cloud quotas need browser cookies. Sign in to Ollama.")] } return nil } static func openAIAPIUsageNotes(_ usage: OpenAIAPIUsageSnapshot) -> [String] { - let today = usage.latestDay + let today = usage.currentDay let seven = usage.last7Days let thirty = usage.last30Days let historyLabel = usage.historyWindowLabel @@ -103,22 +161,77 @@ extension UsageMenuCardView.Model { return notes } + static func poeUsageNotes( + _ usage: PoeUsageHistorySnapshot, + now: Date = Date(), + calendar: Calendar = .current) -> [String] + { + let today = usage.currentDay(now: now, calendar: calendar) + let week = usage.last7Days + let month = usage.last30Days + let todayUSD = today.costUSD.map { " · \(UsageFormatter.usdString($0))" } ?? "" + let weekUSD = week.costUSD.map { " · \(UsageFormatter.usdString($0))" } ?? "" + let monthUSD = month.costUSD.map { " · \(UsageFormatter.usdString($0))" } ?? "" + let todayLine = "Today: \(Self.pointsSummary(today.points)) · " + + "\(UsageFormatter.tokenCountString(today.requests)) \(L("requests"))\(todayUSD)" + let weekLine = "7d: \(Self.pointsSummary(week.points)) · " + + "\(UsageFormatter.tokenCountString(week.requests)) \(L("requests"))\(weekUSD)" + let monthLine = "30d: \(Self.pointsSummary(month.points)) · " + + "\(UsageFormatter.tokenCountString(month.requests)) \(L("requests"))\(monthUSD)" + var notes = [ + todayLine, + weekLine, + monthLine, + ] + if let topModel = usage.topModels.first { + notes.append("\(L("Top model")): \(topModel.name) (\(Self.pointsSummary(topModel.points)))") + } + if !usage.topUsageTypes.isEmpty { + let mix = usage.topUsageTypes.prefix(2) + .map { "\($0.name): \(Self.pointsSummary($0.points))" } + .joined(separator: " · ") + notes.append("Usage mix: \(mix)") + } + return notes + } + static func inlineUsageDashboard(input: Input) -> InlineUsageDashboardModel? { + guard var model = self.resolveInlineUsageDashboard(input: input) else { return nil } + model.barColor = Self.inlineDashboardBarColor(for: input.provider) + return model + } + + /// Provider branding color for the inline usage bars, matching the provider's switcher tab and + /// detailed cost-history chart. + static func inlineDashboardBarColor(for provider: UsageProvider) -> Color { + let color = ProviderDescriptorRegistry.descriptor(for: provider).branding.color + return Color(red: color.red, green: color.green, blue: color.blue) + } + + private static func resolveInlineUsageDashboard(input: Input) -> InlineUsageDashboardModel? { if self.usesProviderCostHistoryAsPrimaryDashboard(input.provider), let tokenSnapshot = primaryCostHistorySnapshot(input: input), !tokenSnapshot.daily.isEmpty { - return self.costHistoryInlineDashboard(provider: input.provider, snapshot: tokenSnapshot) + return self.costHistoryInlineDashboard( + provider: input.provider, + snapshot: tokenSnapshot, + comparisonPeriodsEnabled: input.costComparisonPeriodsEnabled, + preferredCurrencyCode: input.preferredCurrencyCode) } if input.provider == .claude, let usage = input.snapshot?.claudeAdminAPIUsage { - return Self.claudeAdminAPIInlineDashboard(usage) + return Self.claudeAdminAPIInlineDashboard( + usage, + preferredCurrencyCode: input.preferredCurrencyCode) } if input.provider == .openrouter, let usage = input.snapshot?.openRouterUsage { - return Self.openRouterInlineDashboard(usage) + return Self.openRouterInlineDashboard( + usage, + preferredCurrencyCode: input.preferredCurrencyCode) } if input.provider == .zai, let modelUsage = input.snapshot?.zaiUsage?.modelUsage @@ -133,24 +246,93 @@ extension UsageMenuCardView.Model { return Self.minimaxInlineDashboard(billing) } if input.provider == .deepseek, + !input.isRefreshing, + input.tokenCostInlineDashboardEnabled, input.showOptionalCreditsAndExtraUsage, let usage = input.snapshot?.deepseekUsage, !usage.daily.isEmpty { return Self.deepseekInlineDashboard(usage) } - if [.codex, .claude, .vertexai, .bedrock].contains(input.provider), - input.tokenCostUsageEnabled, + if input.provider == .poe, + let usage = input.snapshot?.poeUsage, + !usage.daily.isEmpty + { + return Self.poeInlineDashboard(usage, now: input.now) + } + if input.provider == .zoommate, + let history = input.snapshot?.zoommateCreditsHistory, + !history.dailyBreakdown().isEmpty || history.pacingVerdict() != nil + { + return Self.zoommateInlineDashboard(history) + } + if [.codex, .claude, .vertexai, .bedrock, .cursor, .opencodego].contains(input.provider), + input.tokenCostInlineDashboardEnabled, let tokenSnapshot = input.tokenSnapshot, - !tokenSnapshot.daily.isEmpty + !tokenSnapshot.daily.isEmpty || tokenSnapshot.meteredCostUSD != nil { - return Self.costHistoryInlineDashboard(provider: input.provider, snapshot: tokenSnapshot) + return Self.costHistoryInlineDashboard( + provider: input.provider, + snapshot: tokenSnapshot, + comparisonPeriodsEnabled: input.costComparisonPeriodsEnabled, + preferredCurrencyCode: input.preferredCurrencyCode) } return nil } + private static func zoommateInlineDashboard( + _ history: ZoomMateCreditsHistorySnapshot) + -> InlineUsageDashboardModel + { + let breakdown = history.dailyBreakdown() + let today = history.todayCreditsUsed() + let total = breakdown.reduce(0) { $0 + $1.totalCreditsUsed } + let points = breakdown.suffix(30).map { + InlineUsageDashboardModel.Point( + id: $0.day, + label: Self.shortDayLabel($0.day), + value: $0.totalCreditsUsed, + accessibilityValue: "\($0.day): \(Self.creditsSummary($0.totalCreditsUsed))") + } + var details: [String] = [] + if let pace = history.pacingVerdict() { + details.append(Self.zoommatePaceLabel(for: pace)) + } + var model = InlineUsageDashboardModel( + accessibilityLabel: L("ZoomMate 30 day credits usage trend"), + valueStyle: .tokens, + kpis: [ + .init(title: L("Today"), value: Self.creditsSummary(today ?? 0), emphasis: true), + .init(title: L("30d credits"), value: Self.creditsSummary(total), emphasis: false), + ], + points: points, + detailLines: details) + model.barColor = Self.inlineDashboardBarColor(for: .zoommate) + return model + } + + private static func creditsSummary(_ value: Double) -> String { + value.formatted(.number.precision(.fractionLength(0...2))) + } + + private static func zoommatePaceLabel(for pace: UsagePace) -> String { + let deltaValue = Int(abs(pace.deltaPercent).rounded()) + switch pace.stage { + case .onTrack: + return L("Pace: on track") + case .slightlyAhead, .ahead, .farAhead: + return deltaValue == 0 + ? L("Pace: ahead of budget") + : L("Pace: %d%% ahead of budget", deltaValue) + case .slightlyBehind, .behind, .farBehind: + return deltaValue == 0 + ? L("Pace: behind budget") + : L("Pace: %d%% behind budget", deltaValue) + } + } + static func usesProviderCostHistoryAsPrimaryDashboard(_ provider: UsageProvider) -> Bool { - provider == .openai || provider == .mistral + provider == .openai || provider == .mistral || provider == .groq || provider == .xai } static func primaryCostHistorySnapshot(input: Input) -> CostUsageTokenSnapshot? { @@ -165,22 +347,118 @@ extension UsageMenuCardView.Model { return projected } return input.snapshot == nil ? input.tokenSnapshot : nil + case .groq: + if let projected = input.snapshot?.groqConsoleUsage?.toCostUsageTokenSnapshot() { + return projected + } + return input.snapshot == nil ? input.tokenSnapshot : nil + case .xai: + if let projected = input.snapshot?.xaiUsage?.costHistorySnapshot() { + return projected + } + return input.snapshot == nil ? input.tokenSnapshot : nil default: return input.tokenSnapshot } } + static func poeInlineDashboard( + _ usage: PoeUsageHistorySnapshot, + now: Date = Date(), + calendar: Calendar = .current) -> InlineUsageDashboardModel + { + let today = usage.currentDay(now: now, calendar: calendar) + let week = usage.last7Days + let month = usage.last30Days + let points = usage.daily.suffix(30).map { + InlineUsageDashboardModel.Point( + id: $0.day, + label: Self.shortDayLabel($0.day), + value: $0.points, + accessibilityValue: "\($0.day): \(Self.pointsSummary($0.points))") + } + var details = ["30d requests: \(UsageFormatter.tokenCountString(month.requests))"] + if let topModel = usage.topModel { + details.append("\(L("Top model")): \(topModel)") + } + if !usage.topUsageTypes.isEmpty { + let mix = usage.topUsageTypes.prefix(3) + .map { "\($0.name): \(Self.pointsSummary($0.points))" } + .joined(separator: " · ") + details.append("Usage mix: \(mix)") + } + if let usd = today.costUSD, usd > 0 { + details.append("Today USD: \(UsageFormatter.usdString(usd))") + } + if let usd = week.costUSD, usd > 0 { + details.append("7d USD: \(UsageFormatter.usdString(usd))") + } + if let usd = month.costUSD, usd > 0 { + details.append("30d USD: \(UsageFormatter.usdString(usd))") + } + let recent = usage.recentEntries(limit: 2) + if !recent.isEmpty { + let text = recent.map { "\($0.model) \(Self.pointsSummary($0.points))" }.joined(separator: " · ") + details.append("Recent: \(text)") + } + return InlineUsageDashboardModel( + accessibilityLabel: "Poe points usage trend", + valueStyle: .points, + kpis: [ + .init(title: L("Today"), value: Self.pointsSummary(today.points), emphasis: true), + .init(title: "7d", value: Self.pointsSummary(week.points), emphasis: false), + .init(title: "30d", value: Self.pointsSummary(month.points), emphasis: false), + .init(title: L("Requests"), value: UsageFormatter.tokenCountString(month.requests), emphasis: false), + ], + points: points, + detailLines: details) + } + + static func pointsSummary(_ value: Double) -> String { + let clamped = max(0, value) + if clamped.rounded() == clamped { + return "\(UsageFormatter.tokenCountString(Int(clamped))) points" + } + return "\(String(format: "%.1f", clamped)) points" + } + private static func costHistoryInlineDashboard( provider: UsageProvider, - snapshot: CostUsageTokenSnapshot) -> InlineUsageDashboardModel + snapshot: CostUsageTokenSnapshot, + comparisonPeriodsEnabled: Bool, + preferredCurrencyCode: String) -> InlineUsageDashboardModel { + let displayCurrencyCode = UsageFormatter.convertedCost( + 0, + preferredCurrency: preferredCurrencyCode, + providerCurrency: snapshot.currencyCode).currencyCode + func convertedValue(_ value: Double) -> Double { + UsageFormatter.convertedCost( + value, + preferredCurrency: preferredCurrencyCode, + providerCurrency: snapshot.currencyCode).value + } + func convertedString(_ value: Double) -> String { + UsageFormatter.convertedCostString( + value, + preferredCurrency: preferredCurrencyCode, + providerCurrency: snapshot.currencyCode) + } + let historyDays = max(1, min(365, snapshot.historyDays)) - let historyTitle = snapshot.historyLabel + let defaultHistoryTitle = snapshot.historyLabel ?? (historyDays == 1 ? L("Today") : historyDays == 30 ? L("30d cost") : "\(String(format: L("Last %d days"), historyDays)) \(L("Cost"))") + let codexHistoryPeriod = snapshot.historyLabel + ?? (historyDays == 1 + ? L("Today") + : historyDays == 30 + ? "30d" + : String(format: L("Last %d days"), historyDays)) + let historyTitle = provider == .codex ? codexHistoryPeriod : defaultHistoryTitle let tokenHistoryTitle = snapshot.historyLabel.map { "\($0) \(L("tokens"))" } ?? (historyDays == 1 ? L("Today tokens") @@ -193,50 +471,101 @@ extension UsageMenuCardView.Model { : historyDays == 30 ? L("30d requests") : String(format: L("%@ requests"), String(format: L("Last %d days"), historyDays))) - let periodLabel = snapshot.historyLabel?.lowercased() - ?? (historyDays == 1 ? "today" : "\(historyDays) day") + let accessibilityCostLabel: String = if let historyLabel = snapshot.historyLabel { + L("%@ cost", historyLabel) + } else if historyDays == 30 { + L("30d cost") + } else { + L("%@ cost", historyDays == 1 ? L("Today") : String(format: L("Last %d days"), historyDays)) + } let points = snapshot.daily.suffix(historyDays).compactMap { entry -> InlineUsageDashboardModel.Point? in guard let cost = entry.costUSD else { return nil } return InlineUsageDashboardModel.Point( id: entry.date, label: Self.shortDayLabel(entry.date), - value: cost, - accessibilityValue: "\(entry.date): \(Self.costString(cost, currencyCode: snapshot.currencyCode))") + value: convertedValue(cost), + accessibilityValue: "\(entry.date): \(convertedString(cost))") } - let latest = snapshot.daily.max { lhs, rhs in lhs.date < rhs.date } + let latest = CostUsageTokenSnapshot.latestEntry(in: snapshot.daily) + let usesLatestPrimary = provider == .bedrock || provider == .mistral + let primaryCostUSD = usesLatestPrimary ? latest?.costUSD : snapshot.sessionCostUSD var details: [String] = [] + if comparisonPeriodsEnabled { + details.append(contentsOf: snapshot.comparisonSummaries().map { + let label = Self.costHistoryWindowLabel(days: $0.days) + let cost = $0.totalCostUSD.map(convertedString) ?? "—" + guard let totalTokens = $0.totalTokens else { return "\(label): \(cost)" } + return String( + format: L("%@: %@ · %@ tokens"), + label, + cost, + UsageFormatter.tokenCountString(totalTokens)) + }) + } if let topModel = Self.topCostModel(from: snapshot.daily) { details.append("\(L("Top model")): \(Self.shortModelName(topModel))") } - if let requestCount = snapshot.last30DaysRequests { - details.append("\(requestHistoryTitle): \(UsageFormatter.tokenCountString(requestCount)) \(L("requests"))") + if provider == .codex { + details.append(L("codex_api_estimate_hint")) } - if let hint = Self.tokenUsageHint(provider: provider) { - details.append(hint) - } else { - details.append(L("cost_estimate_hint")) + if provider != .groq { + if let requestCount = snapshot.last30DaysRequests { + details + .append("\(requestHistoryTitle): \(UsageFormatter.tokenCountString(requestCount)) \(L("requests"))") + } + if provider != .codex { + let hintLines = Self.tokenUsageHintLines(provider: provider) + if hintLines.isEmpty == false { + details.append(contentsOf: hintLines) + } else { + details.append(L("cost_estimate_hint")) + } + } } let providerName = ProviderDefaults.metadata[provider]?.displayName ?? provider.rawValue - return InlineUsageDashboardModel( - accessibilityLabel: "\(providerName) \(periodLabel) cost trend", - valueStyle: Self.costValueStyle(currencyCode: snapshot.currencyCode), - kpis: [ + let accessibilityLabel = L( + "%@: %@", + providerName, + accessibilityCostLabel) + var kpis = [ + InlineUsageDashboardModel.KPI( + title: usesLatestPrimary ? L("Latest") : L("Today"), + value: primaryCostUSD.map(convertedString) ?? "—", + emphasis: true), + .init( + title: historyTitle, + value: snapshot.last30DaysCostUSD + .map(convertedString) ?? "—", + emphasis: false), + ] + let tokenHistoryKPI = InlineUsageDashboardModel.KPI( + title: tokenHistoryTitle, + value: snapshot.last30DaysTokens.map(UsageFormatter.tokenCountString) ?? "—", + emphasis: false) + let trailingKPIs = Self.costHistoryTrailingKPIs(snapshot: snapshot, latest: latest) + if snapshot.last30DaysRequests == nil { + kpis.append(contentsOf: trailingKPIs) + kpis.append(tokenHistoryKPI) + } else { + kpis.append(tokenHistoryKPI) + kpis.append(contentsOf: trailingKPIs) + } + if provider == .cursor, let meteredCostUSD = snapshot.meteredCostUSD { + kpis.insert( .init( - title: provider == .bedrock || provider == .mistral ? L("Latest") : L("Today"), - value: latest?.costUSD.map { Self.costString($0, currencyCode: snapshot.currencyCode) } ?? "—", + title: "Cursor-metered", + value: convertedString(meteredCostUSD), emphasis: true), - .init( - title: historyTitle, - value: snapshot.last30DaysCostUSD - .map { Self.costString($0, currencyCode: snapshot.currencyCode) } ?? "—", - emphasis: false), - .init( - title: tokenHistoryTitle, - value: snapshot.last30DaysTokens.map(UsageFormatter.tokenCountString) ?? "—", - emphasis: false), - ] + Self.costHistoryTrailingKPIs(snapshot: snapshot, latest: latest), + at: 0) + } + var model = InlineUsageDashboardModel( + accessibilityLabel: accessibilityLabel, + valueStyle: Self.costValueStyle(currencyCode: displayCurrencyCode), + kpis: kpis, points: points, detailLines: details) + model.currencyCode = displayCurrencyCode + return model } private static func costHistoryTrailingKPIs( @@ -260,18 +589,36 @@ extension UsageMenuCardView.Model { ] } - fileprivate static func claudeAdminAPIInlineDashboard(_ usage: ClaudeAdminAPIUsageSnapshot) + fileprivate static func claudeAdminAPIInlineDashboard( + _ usage: ClaudeAdminAPIUsageSnapshot, + preferredCurrencyCode: String = "auto") -> InlineUsageDashboardModel { - let today = usage.latestDay + let displayCurrencyCode = UsageFormatter.convertedCost( + 0, + preferredCurrency: preferredCurrencyCode, + providerCurrency: "USD").currencyCode + func convertedValue(_ value: Double) -> Double { + UsageFormatter.convertedCost( + value, + preferredCurrency: preferredCurrencyCode, + providerCurrency: "USD").value + } + func convertedString(_ value: Double) -> String { + UsageFormatter.convertedCostString( + value, + preferredCurrency: preferredCurrencyCode, + providerCurrency: "USD") + } + let today = usage.currentDay let last7 = usage.last7Days let last30 = usage.last30Days let points = usage.daily.suffix(30).map { InlineUsageDashboardModel.Point( id: $0.day, label: Self.shortDayLabel($0.day), - value: $0.costUSD, - accessibilityValue: "\($0.day): \(UsageFormatter.usdString($0.costUSD))") + value: convertedValue($0.costUSD), + accessibilityValue: "\($0.day): \(convertedString($0.costUSD))") } var details = [ "30d: \(UsageFormatter.tokenCountString(last30.totalTokens)) \(L("tokens"))", @@ -280,15 +627,15 @@ extension UsageMenuCardView.Model { if let topModel = usage.topModels.first { details.append("\(L("Top model")): \(Self.shortModelName(topModel.name))") } - return InlineUsageDashboardModel( + var model = InlineUsageDashboardModel( accessibilityLabel: L("Claude Admin API 30 day spend trend"), - valueStyle: .currencyUSD, + valueStyle: Self.costValueStyle(currencyCode: displayCurrencyCode), kpis: [ - .init(title: L("Today"), value: UsageFormatter.usdString(today.costUSD), emphasis: true), - .init(title: L("7d spend"), value: UsageFormatter.usdString(last7.costUSD), emphasis: false), + .init(title: L("Today"), value: convertedString(today.costUSD), emphasis: true), + .init(title: L("7d spend"), value: convertedString(last7.costUSD), emphasis: false), .init( title: L("30d spend"), - value: UsageFormatter.usdString(last30.costUSD), + value: convertedString(last30.costUSD), emphasis: false), .init( title: L("Today tokens"), @@ -297,9 +644,30 @@ extension UsageMenuCardView.Model { ], points: points, detailLines: details) + model.currencyCode = displayCurrencyCode + return model } - private static func openRouterInlineDashboard(_ usage: OpenRouterUsageSnapshot) -> InlineUsageDashboardModel? { + private static func openRouterInlineDashboard( + _ usage: OpenRouterUsageSnapshot, + preferredCurrencyCode: String) -> InlineUsageDashboardModel? + { + let displayCurrencyCode = UsageFormatter.convertedCost( + 0, + preferredCurrency: preferredCurrencyCode, + providerCurrency: "USD").currencyCode + func convertedValue(_ value: Double) -> Double { + UsageFormatter.convertedCost( + value, + preferredCurrency: preferredCurrencyCode, + providerCurrency: "USD").value + } + func convertedString(_ value: Double) -> String { + UsageFormatter.convertedCostString( + value, + preferredCurrency: preferredCurrencyCode, + providerCurrency: "USD") + } let periodValues: [(String, String, Double?)] = [ ("day", L("Today"), usage.keyUsageDaily), ("week", L("Week"), usage.keyUsageWeekly), @@ -307,11 +675,12 @@ extension UsageMenuCardView.Model { ] let points = periodValues.compactMap { id, label, value -> InlineUsageDashboardModel.Point? in guard let value else { return nil } + let formattedValue = convertedString(value) return InlineUsageDashboardModel.Point( id: id, label: label, - value: value, - accessibilityValue: "\(label): \(Self.openRouterCurrencyString(value))") + value: convertedValue(value), + accessibilityValue: String(format: L("%@: %@"), label, formattedValue)) } guard !points.isEmpty else { return nil } var details: [String] = [] @@ -321,33 +690,38 @@ extension UsageMenuCardView.Model { switch usage.keyQuotaStatus { case .available: if let remaining = usage.keyRemaining { - details.append("\(L("Key remaining")): \(Self.openRouterCurrencyString(remaining))") + details.append(String( + format: L("%@: %@"), + L("Key remaining"), + convertedString(remaining))) } case .noLimitConfigured: details.append(L("No limit set for the API key")) case .unavailable: details.append(L("API key limit unavailable right now")) } - return InlineUsageDashboardModel( + var model = InlineUsageDashboardModel( accessibilityLabel: L("OpenRouter API key spend trend"), - valueStyle: .currencyUSD, + valueStyle: Self.costValueStyle(currencyCode: displayCurrencyCode), kpis: [ - .init(title: L("Balance"), value: Self.openRouterCurrencyString(usage.balance), emphasis: true), + .init(title: L("Balance"), value: convertedString(usage.balance), emphasis: true), .init( title: L("Today"), - value: usage.keyUsageDaily.map(Self.openRouterCurrencyString) ?? "—", + value: usage.keyUsageDaily.map(convertedString) ?? "—", emphasis: false), .init( title: L("Week"), - value: usage.keyUsageWeekly.map(Self.openRouterCurrencyString) ?? "—", + value: usage.keyUsageWeekly.map(convertedString) ?? "—", emphasis: false), .init( title: L("Month"), - value: usage.keyUsageMonthly.map(Self.openRouterCurrencyString) ?? "—", + value: usage.keyUsageMonthly.map(convertedString) ?? "—", emphasis: false), ], points: points, detailLines: details) + model.currencyCode = displayCurrencyCode + return model } private static func zaiInlineDashboard(modelUsage: ZaiModelUsageData, now: Date) -> InlineUsageDashboardModel? { @@ -455,7 +829,7 @@ extension UsageMenuCardView.Model { let monthTokensStr = UsageFormatter.tokenCountString(usage.currentMonthTokens) return InlineUsageDashboardModel( - accessibilityLabel: L("DeepSeek 30 day token usage trend"), + accessibilityLabel: L("DeepSeek this month token usage trend"), valueStyle: .tokens, kpis: [ .init( @@ -487,7 +861,9 @@ extension UsageMenuCardView.Model { } } return tokens.max { - if $0.value == $1.value { return $0.key > $1.key } + if $0.value == $1.value { + return $0.key > $1.key + } return $0.value < $1.value }?.key } @@ -500,15 +876,13 @@ extension UsageMenuCardView.Model { } } return tokens.max { - if $0.value == $1.value { return $0.key > $1.key } + if $0.value == $1.value { + return $0.key > $1.key + } return $0.value < $1.value }?.key } - private static func openRouterCurrencyString(_ value: Double) -> String { - String(format: "$%.2f", value) - } - private static func minimaxCashString(_ value: Double) -> String { String(format: "%.2f", max(0, value)) } @@ -518,7 +892,9 @@ extension UsageMenuCardView.Model { } private static func costValueStyle(currencyCode: String) -> InlineUsageDashboardModel.ValueStyle { - if currencyCode == "USD" { return .currencyUSD } + if currencyCode == "USD" { + return .currencyUSD + } let formatter = NumberFormatter() formatter.numberStyle = .currency formatter.currencyCode = currencyCode @@ -527,7 +903,7 @@ extension UsageMenuCardView.Model { return .currency(symbol: symbol) } - private static func shortDayLabel(_ day: String) -> String { + static func shortDayLabel(_ day: String) -> String { let pieces = day.split(separator: "-") guard pieces.count == 3, let rawDay = Int(pieces[2]) else { return day } return "\(rawDay)" @@ -550,7 +926,9 @@ extension UsageMenuCardView.Model { } } return scores.max { - if $0.value.cost == $1.value.cost { return $0.value.tokens < $1.value.tokens } + if $0.value.cost == $1.value.cost { + return $0.value.tokens < $1.value.tokens + } return $0.value.cost < $1.value.cost }?.key } @@ -567,9 +945,11 @@ struct InlineUsageDashboardContent: View { var body: some View { VStack(alignment: .leading, spacing: 10) { self.kpis - MiniUsageBars(model: self.model) - .frame(height: 58) - .accessibilityLabel(self.model.accessibilityLabel) + if !self.model.points.isEmpty { + MiniUsageBars(model: self.model) + .frame(height: 58) + .accessibilityLabel(self.model.accessibilityLabel) + } self.detailLines } .frame(maxWidth: .infinity, alignment: .leading) @@ -629,40 +1009,65 @@ struct InlineUsageDashboardContent: View { @Environment(\.menuItemHighlighted) private var isHighlighted var body: some View { - let maxValue = max(self.model.points.map(\.value).max() ?? 0, 1) - HStack(alignment: .bottom, spacing: 2) { - ForEach(self.model.points) { point in - RoundedRectangle(cornerRadius: 1.5, style: .continuous) - .fill(self.fill(for: point, maxValue: maxValue)) - .frame(maxWidth: .infinity) - .frame(height: self.height(for: point, maxValue: maxValue)) - .accessibilityLabel(point.accessibilityValue) + let scale = UsageChartScale(values: self.model.points.map(\.value)) + VStack(alignment: .trailing, spacing: 2) { + if let currencyCode = self.model.currencyCode, scale.maximum > 0 { + Text(UsageFormatter.compactCurrencyString(scale.maximum, currencyCode: currencyCode)) + .font(.caption2) + .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted)) + .monospacedDigit() + .lineLimit(1) + .allowsTightening(true) + } + GeometryReader { geometry in + HStack(alignment: .bottom, spacing: 2) { + ForEach(self.model.points) { point in + RoundedRectangle(cornerRadius: 1.5, style: .continuous) + .fill(self.fill(for: point, scale: scale)) + .frame(maxWidth: .infinity) + .frame(height: self.height(for: point, scale: scale, available: geometry.size.height)) + .accessibilityLabel(point.accessibilityValue) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom) + .overlay(alignment: .bottomLeading) { + Rectangle() + .fill(MenuHighlightStyle.secondary(self.isHighlighted).opacity(0.22)) + .frame(height: 1) + } } - } - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom) - .overlay(alignment: .bottomLeading) { - Rectangle() - .fill(MenuHighlightStyle.secondary(self.isHighlighted).opacity(0.22)) - .frame(height: 1) } } - private func height(for point: InlineUsageDashboardModel.Point, maxValue: Double) -> CGFloat { - let ratio = point.value / maxValue + private func height( + for point: InlineUsageDashboardModel.Point, + scale: UsageChartScale, + available: CGFloat) -> CGFloat + { + let ratio = scale.fraction(for: point.value) guard ratio > 0 else { return 1 } - return CGFloat(max(3, min(58, ratio * 58))) + return max(3, CGFloat(ratio) * available) } - private func fill(for point: InlineUsageDashboardModel.Point, maxValue: Double) -> Color { - let ratio = max(0.18, min(1, point.value / maxValue)) + private func fill(for point: InlineUsageDashboardModel.Point, scale: UsageChartScale) -> Color { + let ratio = max(0.18, scale.fraction(for: point.value)) if self.isHighlighted { return Color.white.opacity(0.55 + ratio * 0.35) } + return self.baseColor.opacity(0.42 + ratio * 0.58) + } + + private var baseColor: Color { + if let barColor = self.model.barColor { + return barColor + } switch self.model.valueStyle { case .currencyUSD, .currency: - return Color(red: 0.81, green: 0.56, blue: 0.24).opacity(0.42 + ratio * 0.58) + return Color(red: 0.81, green: 0.56, blue: 0.24) case .tokens: - return Color(red: 0.48, green: 0.41, blue: 0.86).opacity(0.42 + ratio * 0.58) + return Color(red: 0.48, green: 0.41, blue: 0.86) + case .points: + return Color(red: 0.16, green: 0.62, blue: 0.36) } } } diff --git a/Sources/CodexBar/KeychainMigration.swift b/Sources/CodexBar/KeychainMigration.swift index 51bf840ca3..856d565fae 100644 --- a/Sources/CodexBar/KeychainMigration.swift +++ b/Sources/CodexBar/KeychainMigration.swift @@ -82,7 +82,7 @@ enum KeychainMigration { query[kSecAttrAccount as String] = account } - let status = SecItemCopyMatching(query as CFDictionary, &result) + let status = KeychainSecurity.copyMatching(query as CFDictionary, &result) if status == errSecItemNotFound { // Item doesn't exist, nothing to migrate @@ -115,7 +115,7 @@ enum KeychainMigration { deleteQuery[kSecAttrAccount as String] = account } - let deleteStatus = SecItemDelete(deleteQuery as CFDictionary) + let deleteStatus = KeychainSecurity.delete(deleteQuery as CFDictionary) guard deleteStatus == errSecSuccess else { throw KeychainMigrationError.deleteFailed(deleteStatus) } @@ -131,7 +131,7 @@ enum KeychainMigration { addQuery[kSecAttrAccount as String] = account } - let addStatus = SecItemAdd(addQuery as CFDictionary, nil) + let addStatus = KeychainSecurity.add(addQuery as CFDictionary, nil) guard addStatus == errSecSuccess else { throw KeychainMigrationError.addFailed(addStatus) } diff --git a/Sources/CodexBar/KeychainPromptCoordinator.swift b/Sources/CodexBar/KeychainPromptCoordinator.swift index 7a4586514d..69fa3e4bea 100644 --- a/Sources/CodexBar/KeychainPromptCoordinator.swift +++ b/Sources/CodexBar/KeychainPromptCoordinator.swift @@ -37,9 +37,6 @@ private enum KeychainPromptMessage { static let kimiToken = "CodexBar will ask macOS Keychain for your Kimi auth token " + "so it can fetch usage. Click OK to continue." - static let kimiK2Token = - "CodexBar will ask macOS Keychain for your Kimi K2 API key " + - "so it can fetch usage. Click OK to continue." static let minimaxCookie = "CodexBar will ask macOS Keychain for your MiniMax cookie header " + "so it can fetch usage. Click OK to continue." @@ -54,9 +51,33 @@ private enum KeychainPromptMessage { "so it can fetch usage. Click OK to continue." } +struct KeychainPromptAlertModel: Equatable { + let title: String + let message: String + let primaryButtonTitle: String + let learnMoreButtonTitle: String + let documentationURL: String +} + +@MainActor +private final class KeychainPromptLearnMoreTarget: NSObject { + private let documentationURL: String + + init(documentationURL: String) { + self.documentationURL = documentationURL + } + + @objc func openDocumentation() { + guard let url = URL(string: self.documentationURL) else { return } + NSWorkspace.shared.open(url) + } +} + enum KeychainPromptCoordinator { private static let promptLock = NSLock() private static let log = CodexBarLog.logger(LogCategories.keychainPrompt) + private static let documentationURL = + "https://github.com/steipete/CodexBar/blob/main/docs/keychain-prompts.md" static func install() { KeychainPromptHandler.handler = { context in @@ -65,82 +86,132 @@ enum KeychainPromptCoordinator { BrowserCookieKeychainPromptHandler.handler = { context in self.presentBrowserCookiePrompt(context) } + self.disableKeychainForUnbundledExecutableIfNeeded() + } + + private static let unbundledExecutableCheckLock = NSLock() + private nonisolated(unsafe) static var didCheckUnbundledExecutable = false + + static func disableKeychainForUnbundledExecutableIfNeeded() { + self.unbundledExecutableCheckLock.lock() + guard !self.didCheckUnbundledExecutable else { + self.unbundledExecutableCheckLock.unlock() + return + } + self.didCheckUnbundledExecutable = true + self.unbundledExecutableCheckLock.unlock() + + let executablePath = Bundle.main.executableURL?.path ?? "" + guard Self.isUnbundledCodexBarExecutable(executablePath) else { return } + KeychainAccessGate.forceDisabledForProcess(reason: "unbundled-executable") + Self.log.warning( + "Unbundled CodexBar executable detected; disabling keychain access to avoid repeated prompts", + metadata: ["doc": "docs/DEVELOPMENT_SETUP.md"]) + } + + static func isUnbundledCodexBarExecutable(_ executablePath: String) -> Bool { + guard executablePath.hasPrefix("/") else { return false } + let executableURL = URL(fileURLWithPath: executablePath).standardizedFileURL + return executableURL.lastPathComponent == "CodexBar" + && !executableURL.pathComponents.contains(where: { $0.hasSuffix(".app") }) } private static func presentKeychainPrompt(_ context: KeychainPromptContext) { - let (title, message) = self.keychainCopy(for: context) + let model = self.alertModel(for: context) self.log.info("Keychain prompt requested", metadata: ["kind": "\(context.kind)"]) - self.presentAlert(title: title, message: message) + self.presentAlert(model) } private static func presentBrowserCookiePrompt(_ context: BrowserCookieKeychainPromptContext) { - let title = L("Keychain Access Required") - let message = L( - KeychainPromptMessage.browserCookie, - context.label) + let model = self.browserCookieAlertModel(label: context.label) self.log.info("Browser cookie keychain prompt requested", metadata: ["label": context.label]) - self.presentAlert(title: title, message: message) + self.presentAlert(model) } - private static func keychainCopy(for context: KeychainPromptContext) -> (title: String, message: String) { - let title = L("Keychain Access Required") - switch context.kind { + static func alertModel(for context: KeychainPromptContext) -> KeychainPromptAlertModel { + let purpose = switch context.kind { case .claudeOAuth: - return (title, L(KeychainPromptMessage.claudeOAuth)) + L(KeychainPromptMessage.claudeOAuth) case .codexCookie: - return (title, L(KeychainPromptMessage.codexCookie)) + L(KeychainPromptMessage.codexCookie) case .claudeCookie: - return (title, L(KeychainPromptMessage.claudeCookie)) + L(KeychainPromptMessage.claudeCookie) case .cursorCookie: - return (title, L(KeychainPromptMessage.cursorCookie)) + L(KeychainPromptMessage.cursorCookie) case .opencodeCookie: - return (title, L(KeychainPromptMessage.openCodeCookie)) + L(KeychainPromptMessage.openCodeCookie) case .factoryCookie: - return (title, L(KeychainPromptMessage.factoryCookie)) + L(KeychainPromptMessage.factoryCookie) case .zaiToken: - return (title, L(KeychainPromptMessage.zaiToken)) + L(KeychainPromptMessage.zaiToken) case .syntheticToken: - return (title, L(KeychainPromptMessage.syntheticToken)) + L(KeychainPromptMessage.syntheticToken) case .copilotToken: - return (title, L(KeychainPromptMessage.copilotToken)) + L(KeychainPromptMessage.copilotToken) case .kimiToken: - return (title, L(KeychainPromptMessage.kimiToken)) - case .kimiK2Token: - return (title, L(KeychainPromptMessage.kimiK2Token)) + L(KeychainPromptMessage.kimiToken) case .minimaxCookie: - return (title, L(KeychainPromptMessage.minimaxCookie)) + L(KeychainPromptMessage.minimaxCookie) case .minimaxToken: - return (title, L(KeychainPromptMessage.minimaxToken)) + L(KeychainPromptMessage.minimaxToken) case .augmentCookie: - return (title, L(KeychainPromptMessage.augmentCookie)) + L(KeychainPromptMessage.augmentCookie) case .ampCookie: - return (title, L(KeychainPromptMessage.ampCookie)) + L(KeychainPromptMessage.ampCookie) } + return self.alertModel(purpose: purpose) + } + + static func browserCookieAlertModel(label: String) -> KeychainPromptAlertModel { + self.alertModel(purpose: L(KeychainPromptMessage.browserCookie, label)) + } + + private static func alertModel(purpose: String) -> KeychainPromptAlertModel { + KeychainPromptAlertModel( + title: L("Keychain Access Required"), + message: "\(purpose)\n\n\(L("keychain_prompt_privacy_note"))", + primaryButtonTitle: L("OK"), + learnMoreButtonTitle: L("keychain_prompt_learn_more"), + documentationURL: self.documentationURL) } - private static func presentAlert(title: String, message: String) { + private static func presentAlert(_ model: KeychainPromptAlertModel) { self.promptLock.lock() defer { self.promptLock.unlock() } if Thread.isMainThread { MainActor.assumeIsolated { - self.showAlert(title: title, message: message) + self.showAlert(model) } return } DispatchQueue.main.sync { MainActor.assumeIsolated { - self.showAlert(title: title, message: message) + self.showAlert(model) } } } @MainActor - private static func showAlert(title: String, message: String) { + private static func showAlert(_ model: KeychainPromptAlertModel) { let alert = NSAlert() - alert.messageText = L(title) - alert.informativeText = L(message) - alert.addButton(withTitle: L("OK")) - _ = alert.runModal() + alert.messageText = model.title + alert.informativeText = model.message + alert.addButton(withTitle: model.primaryButtonTitle) + + let learnMoreTarget = KeychainPromptLearnMoreTarget(documentationURL: model.documentationURL) + let learnMoreButton = NSButton( + title: model.learnMoreButtonTitle, + target: learnMoreTarget, + action: #selector(KeychainPromptLearnMoreTarget.openDocumentation)) + learnMoreButton.isBordered = false + learnMoreButton.contentTintColor = .linkColor + learnMoreButton.font = .systemFont(ofSize: NSFont.smallSystemFontSize) + learnMoreButton.sizeToFit() + alert.accessoryView = learnMoreButton + + withExtendedLifetime(learnMoreTarget) { + _ = alert.runModal() + } } } diff --git a/Sources/CodexBar/KimiK2TokenStore.swift b/Sources/CodexBar/KimiK2TokenStore.swift deleted file mode 100644 index ed3cf55aa3..0000000000 --- a/Sources/CodexBar/KimiK2TokenStore.swift +++ /dev/null @@ -1,128 +0,0 @@ -import CodexBarCore -import Foundation -import Security - -protocol KimiK2TokenStoring: Sendable { - func loadToken() throws -> String? - func storeToken(_ token: String?) throws -} - -enum KimiK2TokenStoreError: LocalizedError { - case keychainStatus(OSStatus) - case invalidData - - var errorDescription: String? { - switch self { - case let .keychainStatus(status): - "Keychain error: \(status)" - case .invalidData: - "Keychain returned invalid data." - } - } -} - -struct KeychainKimiK2TokenStore: KimiK2TokenStoring { - private static let log = CodexBarLog.logger(LogCategories.kimiK2TokenStore) - - private let service = "com.steipete.CodexBar" - private let account = "kimi-k2-api-token" - - func loadToken() throws -> String? { - guard !KeychainAccessGate.isDisabled else { - Self.log.debug("Keychain access disabled; skipping token load") - return nil - } - var result: CFTypeRef? - let query: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: self.service, - kSecAttrAccount as String: self.account, - kSecMatchLimit as String: kSecMatchLimitOne, - kSecReturnData as String: true, - ] - - if case .interactionRequired = KeychainAccessPreflight - .checkGenericPassword(service: self.service, account: self.account) - { - KeychainPromptHandler.handler?(KeychainPromptContext( - kind: .kimiK2Token, - service: self.service, - account: self.account)) - } - - let status = SecItemCopyMatching(query as CFDictionary, &result) - if status == errSecItemNotFound { - return nil - } - guard status == errSecSuccess else { - Self.log.error("Keychain read failed: \(status)") - throw KimiK2TokenStoreError.keychainStatus(status) - } - - guard let data = result as? Data else { - throw KimiK2TokenStoreError.invalidData - } - let token = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) - if let token, !token.isEmpty { - return token - } - return nil - } - - func storeToken(_ token: String?) throws { - guard !KeychainAccessGate.isDisabled else { - Self.log.debug("Keychain access disabled; skipping token store") - return - } - let cleaned = token?.trimmingCharacters(in: .whitespacesAndNewlines) - if cleaned == nil || cleaned?.isEmpty == true { - try self.deleteTokenIfPresent() - return - } - - let data = cleaned!.data(using: .utf8)! - let query: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: self.service, - kSecAttrAccount as String: self.account, - ] - let attributes: [String: Any] = [ - kSecValueData as String: data, - kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly, - ] - - let updateStatus = SecItemUpdate(query as CFDictionary, attributes as CFDictionary) - if updateStatus == errSecSuccess { - return - } - if updateStatus != errSecItemNotFound { - Self.log.error("Keychain update failed: \(updateStatus)") - throw KimiK2TokenStoreError.keychainStatus(updateStatus) - } - - var addQuery = query - for (key, value) in attributes { - addQuery[key] = value - } - let addStatus = SecItemAdd(addQuery as CFDictionary, nil) - guard addStatus == errSecSuccess else { - Self.log.error("Keychain add failed: \(addStatus)") - throw KimiK2TokenStoreError.keychainStatus(addStatus) - } - } - - private func deleteTokenIfPresent() throws { - guard !KeychainAccessGate.isDisabled else { return } - let query: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: self.service, - kSecAttrAccount as String: self.account, - ] - let status = SecItemDelete(query as CFDictionary) - if status == errSecSuccess || status == errSecItemNotFound { - return - } - Self.log.error("Keychain delete failed: \(status)") - throw KimiK2TokenStoreError.keychainStatus(status) - } -} diff --git a/Sources/CodexBar/KimiTokenStore.swift b/Sources/CodexBar/KimiTokenStore.swift index dddcb15986..50bb9553a3 100644 --- a/Sources/CodexBar/KimiTokenStore.swift +++ b/Sources/CodexBar/KimiTokenStore.swift @@ -50,7 +50,7 @@ struct KeychainKimiTokenStore: KimiTokenStoring { account: self.account)) } - let status = SecItemCopyMatching(query as CFDictionary, &result) + let status = KeychainSecurity.copyMatching(query as CFDictionary, &result) if status == errSecItemNotFound { return nil } @@ -91,7 +91,7 @@ struct KeychainKimiTokenStore: KimiTokenStoring { kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly, ] - let updateStatus = SecItemUpdate(query as CFDictionary, attributes as CFDictionary) + let updateStatus = KeychainSecurity.update(query as CFDictionary, attributes as CFDictionary) if updateStatus == errSecSuccess { return } @@ -104,7 +104,7 @@ struct KeychainKimiTokenStore: KimiTokenStoring { for (key, value) in attributes { addQuery[key] = value } - let addStatus = SecItemAdd(addQuery as CFDictionary, nil) + let addStatus = KeychainSecurity.add(addQuery as CFDictionary, nil) guard addStatus == errSecSuccess else { Self.log.error("Keychain add failed: \(addStatus)") throw KimiTokenStoreError.keychainStatus(addStatus) @@ -118,7 +118,7 @@ struct KeychainKimiTokenStore: KimiTokenStoring { kSecAttrService as String: self.service, kSecAttrAccount as String: self.account, ] - let status = SecItemDelete(query as CFDictionary) + let status = KeychainSecurity.delete(query as CFDictionary) if status == errSecSuccess || status == errSecItemNotFound { return } diff --git a/Sources/CodexBar/LaunchAtLoginManager.swift b/Sources/CodexBar/LaunchAtLoginManager.swift index e282efab4d..29d143f0a9 100644 --- a/Sources/CodexBar/LaunchAtLoginManager.swift +++ b/Sources/CodexBar/LaunchAtLoginManager.swift @@ -2,6 +2,9 @@ import CodexBarCore import ServiceManagement enum LaunchAtLoginManager { + typealias StatusProvider = () -> SMAppService.Status + typealias RegistrationAction = () throws -> Void + private static let isRunningTests: Bool = { let env = ProcessInfo.processInfo.environment if env["XCTestConfigurationFilePath"] != nil { return true } @@ -13,11 +16,38 @@ enum LaunchAtLoginManager { static func setEnabled(_ enabled: Bool) { if self.isRunningTests { return } let service = SMAppService.mainApp + self.setEnabled( + enabled, + status: { service.status }, + register: { try service.register() }, + unregister: { try service.unregister() }) + } + + static func setEnabled( + _ enabled: Bool, + status: StatusProvider, + register: RegistrationAction, + unregister: RegistrationAction) + { do { if enabled { - try service.register() + switch status() { + case .enabled, .requiresApproval: + return + case .notRegistered, .notFound: + try register() + @unknown default: + try register() + } } else { - try service.unregister() + switch status() { + case .enabled, .requiresApproval: + try unregister() + case .notRegistered, .notFound: + return + @unknown default: + try unregister() + } } } catch { CodexBarLog.logger(LogCategories.launchAtLogin).error("Failed to update login item: \(error)") diff --git a/Sources/CodexBar/Localization.swift b/Sources/CodexBar/Localization.swift index 8fbdf32170..d9fc9a3352 100644 --- a/Sources/CodexBar/Localization.swift +++ b/Sources/CodexBar/Localization.swift @@ -5,6 +5,22 @@ enum CodexBarLocalizationOverride { @TaskLocal static var appLanguage: String? } +enum AppLanguagePreferenceMigration { + private static let appleLanguagesKey = "AppleLanguages" + + static func clearLegacyOverrideIfOwned( + storedAppLanguage: String, + defaults: UserDefaults = .standard) + { + let language = storedAppLanguage.trimmingCharacters(in: .whitespacesAndNewlines) + guard !language.isEmpty, + defaults.stringArray(forKey: self.appleLanguagesKey) == [language] + else { return } + + defaults.removeObject(forKey: self.appleLanguagesKey) + } +} + private func appLanguageDefaults() -> UserDefaults { if Bundle.main.bundleIdentifier != nil { return .standard @@ -16,23 +32,30 @@ private func appLanguageDefaults() -> UserDefaults { return UserDefaults(suiteName: "CodexBar") ?? .standard } -private func isRunningTestsProcess() -> Bool { +private let isRunningTestsProcessAtStartup: Bool = { let env = ProcessInfo.processInfo.environment - if env["XCTestConfigurationFilePath"] != nil { return true } - if env["TESTING_LIBRARY_VERSION"] != nil { return true } - if env["SWIFT_TESTING"] != nil { return true } + if env["XCTestConfigurationFilePath"] != nil { + return true + } + if env["TESTING_LIBRARY_VERSION"] != nil { + return true + } + if env["SWIFT_TESTING"] != nil { + return true + } return NSClassFromString("XCTestCase") != nil -} +}() -private let standardAppLanguageAtProcessStart = UserDefaults.standard.string(forKey: "appLanguage") +private func isRunningTestsProcess() -> Bool { + isRunningTestsProcessAtStartup +} private func resolvedAppLanguage() -> String { if let override = CodexBarLocalizationOverride.appLanguage { return override } if isRunningTestsProcess() { - let current = UserDefaults.standard.string(forKey: "appLanguage") - return current == standardAppLanguageAtProcessStart ? "en" : current ?? "" + return "en" } return appLanguageDefaults().string(forKey: "appLanguage") ?? "" } @@ -41,10 +64,69 @@ func codexBarLocalizationSignature() -> String { resolvedAppLanguage() } +/// Resolving the `.lproj`/resource bundles repeats `Bundle(url:)`/`Bundle(path:)` filesystem lookups, +/// which are surprisingly hot: every `L(…)` and `codexBarLocalizationSignature()` call runs them, and +/// menu row bodies (`MetricRow`, `ProviderCostContent`, `UsageMenuCardView.Model`) re-evaluate them on +/// every closed-menu rebuild tick on the main thread (#1347). The resolved bundles never change unless +/// the language changes, so cache them. A single lock with compute-happening-outside-the-lock keeps the +/// disk work off the critical section and avoids re-entrant deadlock when the localized-bundle compute +/// closure calls back into the resource-bundle accessor. +private enum LocalizationBundleCache { + private static let lock = NSLock() + private nonisolated(unsafe) static var resourceBundle: Bundle? + private nonisolated(unsafe) static var localizedBundlesByLanguage: [String: Bundle] = [:] + + static func defaultResourceBundle(_ compute: () -> Bundle) -> Bundle { + self.lock.lock() + if let resourceBundle { + self.lock.unlock() + return resourceBundle + } + self.lock.unlock() + let computed = compute() + self.lock.lock() + resourceBundle = computed + self.lock.unlock() + return computed + } + + static func localizedBundle(forLanguage language: String, _ compute: () -> Bundle) -> Bundle { + self.lock.lock() + if let cachedLocalizedBundle = self.localizedBundlesByLanguage[language] { + self.lock.unlock() + return cachedLocalizedBundle + } + self.lock.unlock() + let computed = compute() + self.lock.lock() + self.localizedBundlesByLanguage[language] = computed + self.lock.unlock() + return computed + } + + static func reset() { + self.lock.lock() + self.resourceBundle = nil + self.localizedBundlesByLanguage = [:] + self.lock.unlock() + } +} + func codexBarLocalizationResourceBundle( mainBundle: Bundle = .main, bundleName: String = "CodexBar_CodexBar") -> Bundle { + // Only the default (process `.main`) resolution is cached: it is constant for the lifetime of the + // process. Custom arguments (tests) keep resolving directly so they stay isolated from the cache. + guard mainBundle === Bundle.main, bundleName == "CodexBar_CodexBar" else { + return resolveLocalizationResourceBundle(mainBundle: mainBundle, bundleName: bundleName) + } + return LocalizationBundleCache.defaultResourceBundle { + resolveLocalizationResourceBundle(mainBundle: mainBundle, bundleName: bundleName) + } +} + +private func resolveLocalizationResourceBundle(mainBundle: Bundle, bundleName: String) -> Bundle { guard mainBundle.bundleURL.pathExtension == "app" else { return Bundle.module } @@ -65,15 +147,31 @@ func codexBarLocalizationResourceBundle( } private func localizedBundle() -> Bundle { - let resourceBundle = codexBarLocalizationResourceBundle() + // Keyed on the resolved language so a language switch (settings change or test override) transparently + // re-resolves; otherwise the cached bundle is returned without touching the filesystem. let language = resolvedAppLanguage() + return localizedBundle(forLanguage: language) +} + +private func localizedBundle(forLanguage language: String) -> Bundle { + LocalizationBundleCache.localizedBundle(forLanguage: language) { + resolveLocalizedBundle(forLanguage: language) + } +} + +private func resolveLocalizedBundle(forLanguage language: String) -> Bundle { + let resourceBundle = codexBarLocalizationResourceBundle() if !language.isEmpty { if let bundle = lprojBundle(named: language, in: resourceBundle) { return bundle } } else { // System mode: follow macOS language preferences - if let preferred = resourceBundle.preferredLocalizations.first, + let localizations = resourceBundle.localizations.filter { $0 != "Base" } + let preferred = Bundle.preferredLocalizations( + from: localizations, + forPreferences: Locale.preferredLanguages).first + if let preferred, let bundle = lprojBundle(named: preferred, in: resourceBundle) { return bundle @@ -109,10 +207,35 @@ func L(_ key: String, _ arguments: CVarArg...) -> String { String(format: L(key), arguments: arguments) } +func L(_ key: String, language: String) -> String { + let resourceBundle = codexBarLocalizationResourceBundle() + let bundle = localizedBundle(forLanguage: language) + return codexBarLocalizedString(key, bundle: bundle, resourceBundle: resourceBundle) +} + func codexBarLocalizedLocale() -> Locale { - let language = resolvedAppLanguage() + codexBarLocale(forLanguage: resolvedAppLanguage()) +} + +/// Returns the locale of the resource bundle currently selected by `L`. +/// +/// This can differ from `Locale.current` when the app falls back to a supported language. Plural +/// formatting must use this locale so it follows the same language as the resolved strings. +func codexBarLocalizedResourceLocale() -> Locale { + let bundleURL = localizedBundle().bundleURL + guard bundleURL.pathExtension == "lproj" else { + return codexBarLocalizedLocale() + } + return codexBarLocale(forLanguage: bundleURL.deletingPathExtension().lastPathComponent) +} + +private func codexBarLocale(forLanguage language: String) -> Locale { guard !language.isEmpty else { return .current } - switch language.lowercased() { + let normalized = language.lowercased() + if normalized == "ar" || normalized.hasPrefix("ar-") { + return Locale(identifier: "\(language)@numbers=arab") + } + switch normalized { case "zh-hans": return Locale(identifier: "zh-Hans") case "zh-hant": @@ -124,6 +247,10 @@ func codexBarLocalizedLocale() -> Locale { } } +func codexBarLocalizedInteger(_ value: Int) -> String { + value.formatted(.number.locale(codexBarLocalizedLocale())) +} + func codexBarLocalizedString(_ key: String, bundle: Bundle, resourceBundle: Bundle) -> String { let value = bundle.localizedString(forKey: key, value: nil, table: nil) let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) @@ -141,6 +268,20 @@ func codexBarLocalizedString(_ key: String, bundle: Bundle, resourceBundle: Bund return fallback.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? key : fallback } +func resetCodexBarLocalizationCache() { + LocalizationBundleCache.reset() +} + +#if DEBUG +func codexBarLocalizedBundleForTesting() -> Bundle { + localizedBundle() +} + +func resetCodexBarLocalizationCacheForTesting() { + resetCodexBarLocalizationCache() +} +#endif + func configureUsageFormatterLocalizationProvider() { UsageFormatter.setLocalizationProvider { key in let resourceBundle = codexBarLocalizationResourceBundle() diff --git a/Sources/CodexBar/MainThreadHangWatchdog.swift b/Sources/CodexBar/MainThreadHangWatchdog.swift new file mode 100644 index 0000000000..6df99e6cb7 --- /dev/null +++ b/Sources/CodexBar/MainThreadHangWatchdog.swift @@ -0,0 +1,311 @@ +import CodexBarCore +import Foundation + +/// Tracks what the main thread is currently doing so hang reports can name the +/// operation even when the stall happens in uninstrumented code. +enum MainThreadActivityBreadcrumb { + private final class State: @unchecked Sendable { + let lock = NSLock() + var stack: [String] = [] + } + + private static let state = State() + + static var current: String? { + guard MainThreadHangWatchdog.isEnabledForCurrentProcess else { return nil } + return self.state.lock.withLock { self.state.stack.last } + } + + static func push(_ label: String) { + guard MainThreadHangWatchdog.isEnabledForCurrentProcess else { return } + self.state.lock.withLock { + self.state.stack.append(label) + } + } + + static func pop() { + guard MainThreadHangWatchdog.isEnabledForCurrentProcess else { return } + self.state.lock.withLock { + _ = self.state.stack.popLast() + } + } +} + +/// Detects main-queue response delays and records breadcrumbs for the work that +/// occupied the main thread. Long hangs launch `/usr/bin/sample` asynchronously +/// so sampling cannot delay recovery detection or inflate the reported duration. +final class MainThreadHangWatchdog: @unchecked Sendable { + static let shared = MainThreadHangWatchdog() + static let isEnabledForCurrentProcess: Bool = { + #if DEBUG + true + #else + let environment = ProcessInfo.processInfo.environment + return environment["CODEXBAR_MAIN_THREAD_HANG_WATCHDOG"] == "1" || + UserDefaults.standard.bool(forKey: "debugMainThreadHangWatchdog") + #endif + }() + + private let logger = CodexBarLog.logger(LogCategories.app) + private let pingInterval: TimeInterval + private let hangThreshold: TimeInterval + private let sampleThreshold: TimeInterval + private let sampleCooldown: TimeInterval + private let sampleCaptureOverride: (@Sendable () -> String?)? + private let schedulePing: @Sendable (@escaping @Sendable () -> Void) -> Void + private let lock = NSLock() + private var isRunning = false + private var lastSampleAt: Date? + private var activeSampleProcesses: [ObjectIdentifier: Process] = [:] + var onHangForTesting: ((TimeInterval, [String]) -> Void)? + #if DEBUG + var onHangDetectionForTesting: (() -> Void)? + private var onSampleAttemptForTesting: (() -> Void)? + #endif + + private enum SampleCaptureResult { + case coolingDown + case attempted(String?) + } + + init( + pingInterval: TimeInterval = 0.025, + hangThreshold: TimeInterval = 0.15, + sampleThreshold: TimeInterval = 2.0, + sampleCooldown: TimeInterval = 300, + sampleCaptureOverride: (@Sendable () -> String?)? = nil, + schedulePing: @escaping @Sendable (@escaping @Sendable () -> Void) -> Void = { response in + DispatchQueue.main.async(execute: response) + }) + { + self.pingInterval = pingInterval + self.hangThreshold = hangThreshold + self.sampleThreshold = sampleThreshold + self.sampleCooldown = sampleCooldown + self.sampleCaptureOverride = sampleCaptureOverride + self.schedulePing = schedulePing + } + + func start() { + self.lock.lock() + defer { self.lock.unlock() } + guard !self.isRunning else { return } + self.isRunning = true + let thread = Thread { [weak self] in self?.run() } + thread.name = "CodexBar.MainThreadHangWatchdog" + thread.qualityOfService = .utility + thread.start() + } + + func stop() { + self.lock.withLock { + self.isRunning = false + } + } + + private var shouldRun: Bool { + self.lock.withLock { self.isRunning } + } + + private final class PingBox: @unchecked Sendable { + private let lock = NSLock() + private let semaphore = DispatchSemaphore(value: 0) + private var _respondedAt: DispatchTime? + + func markResponded() { + self.lock.withLock { + self._respondedAt = .now() + } + self.semaphore.signal() + } + + var respondedAt: DispatchTime? { + self.lock.withLock { self._respondedAt } + } + + func waitForResponse(timeout: TimeInterval) -> Bool { + self.semaphore.wait(timeout: .now() + timeout) == .success + } + } + + private func run() { + while self.shouldRun { + let box = PingBox() + let pingSentAt = DispatchTime.now() + self.schedulePing { box.markResponded() } + if !box.waitForResponse(timeout: self.hangThreshold) { + guard self.shouldRun else { return } + #if DEBUG + self.onHangDetectionForTesting?() + #endif + self.traceHang(box: box, pingSentAt: pingSentAt) + } + guard self.shouldRun else { return } + Thread.sleep(forTimeInterval: self.pingInterval) + } + } + + private func traceHang(box: PingBox, pingSentAt: DispatchTime) { + // One delayed ping can span several main-thread operations, so retain each + // distinct breadcrumb observed until the queued ping finally executes. + var activities: [String] = [] + func recordActivity() { + guard activities.count < 8, + let activity = MainThreadActivityBreadcrumb.current, + !activities.contains(activity) + else { return } + activities.append(activity) + } + + recordActivity() + var sampleFile: String? + var didAttemptSample = false + while box.respondedAt == nil, self.shouldRun { + recordActivity() + if !didAttemptSample, self.elapsedSeconds(since: pingSentAt) >= self.sampleThreshold { + #if DEBUG + self.onSampleAttemptForTesting?() + #endif + switch self.captureSampleIfAllowed() { + case .coolingDown: + break + case let .attempted(file): + didAttemptSample = true + sampleFile = file + } + } + Thread.sleep(forTimeInterval: 0.025) + } + guard let respondedAt = box.respondedAt else { return } + let duration = self.elapsedSeconds(from: pingSentAt, to: respondedAt) + var metadata: [String: String] = [ + "durationMs": String(format: "%.0f", duration * 1000), + "activity": activities.isEmpty ? "unknown" : activities.joined(separator: ","), + ] + if let sampleFile { + metadata["sampleRequested"] = sampleFile + } + self.logger.warning("main thread hang", metadata: metadata) + self.onHangForTesting?(duration, activities) + } + + private func elapsedSeconds(since start: DispatchTime) -> TimeInterval { + self.elapsedSeconds(from: start, to: .now()) + } + + private func elapsedSeconds(from start: DispatchTime, to end: DispatchTime) -> TimeInterval { + TimeInterval(end.uptimeNanoseconds - start.uptimeNanoseconds) / 1_000_000_000 + } + + private func captureSampleIfAllowed() -> SampleCaptureResult { + let now = Date() + let shouldCapture = self.lock.withLock { + if self.lastSampleAt.map({ now.timeIntervalSince($0) < self.sampleCooldown }) ?? false { + return false + } + self.lastSampleAt = now + return true + } + guard shouldCapture else { return .coolingDown } + + let file = if let sampleCaptureOverride { + sampleCaptureOverride() + } else { + self.launchSample() + } + return .attempted(file) + } + + private func launchSample() -> String? { + let directory = FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent("Library/Logs/CodexBar", isDirectory: true) + do { + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + } catch { + self.logger.warning( + "main thread hang sample failed", + metadata: ["error": "\(error)"]) + return nil + } + + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withFullDate, .withTime] + let stamp = formatter.string(from: Date()).replacingOccurrences(of: ":", with: "-") + let file = directory.appendingPathComponent("hang-sample-\(stamp).txt") + + let process = Process() + let processID = ObjectIdentifier(process) + process.executableURL = URL(fileURLWithPath: "/usr/bin/sample") + process.arguments = ["\(ProcessInfo.processInfo.processIdentifier)", "3", "-file", file.path] + process.terminationHandler = { [weak self] completedProcess in + self?.sampleDidFinish(completedProcess, processID: processID, file: file) + } + self.lock.withLock { + self.activeSampleProcesses[processID] = process + } + do { + try process.run() + } catch { + _ = self.lock.withLock { + self.activeSampleProcesses.removeValue(forKey: processID) + } + self.logger.warning( + "main thread hang sample failed", + metadata: ["error": "\(error)"]) + return nil + } + return file.path + } + + private func sampleDidFinish(_ process: Process, processID: ObjectIdentifier, file: URL) { + _ = self.lock.withLock { + self.activeSampleProcesses.removeValue(forKey: processID) + } + guard process.terminationStatus == 0, + FileManager.default.fileExists(atPath: file.path) + else { + self.logger.warning( + "main thread hang sample failed", + metadata: ["status": "\(process.terminationStatus)"]) + return + } + self.logger.info( + "main thread hang sample captured", + metadata: ["sample": file.path]) + } + + #if DEBUG + func traceHangForTesting( + responseDelay: TimeInterval, + waitForSampleAttempt: Bool = false, + responseBeforeTrace: Bool = false) + { + self.lock.withLock { + self.isRunning = true + } + defer { + self.lock.withLock { + self.isRunning = false + } + self.onSampleAttemptForTesting = nil + } + + let box = PingBox() + let pingSentAt = DispatchTime.now() + let scheduleResponse = { + DispatchQueue.global(qos: .userInitiated).asyncAfter(deadline: .now() + responseDelay) { + box.markResponded() + } + } + if responseBeforeTrace { + Thread.sleep(forTimeInterval: responseDelay) + box.markResponded() + } else if waitForSampleAttempt { + self.onSampleAttemptForTesting = scheduleResponse + } else { + scheduleResponse() + } + self.traceHang(box: box, pingSentAt: pingSentAt) + } + #endif +} diff --git a/Sources/CodexBar/MemoryPressureMonitor.swift b/Sources/CodexBar/MemoryPressureMonitor.swift new file mode 100644 index 0000000000..9af67a3986 --- /dev/null +++ b/Sources/CodexBar/MemoryPressureMonitor.swift @@ -0,0 +1,149 @@ +import CodexBarCore +import Dispatch +import Foundation + +@MainActor +struct MemoryPressureCacheTrimSummary: Equatable { + var menuCardHeights = 0 + var menuWidths = 0 + var mergedSwitcherSelections = 0 + var recycledMenuCardViews = 0 + var openAIWebDebugLines = 0 + + var total: Int { + self.menuCardHeights + + self.menuWidths + + self.mergedSwitcherSelections + + self.recycledMenuCardViews + + self.openAIWebDebugLines + } + + var metadata: [String: String] { + [ + "menuCardHeights": "\(self.menuCardHeights)", + "menuWidths": "\(self.menuWidths)", + "mergedSwitcherSelections": "\(self.mergedSwitcherSelections)", + "recycledMenuCardViews": "\(self.recycledMenuCardViews)", + "openAIWebDebugLines": "\(self.openAIWebDebugLines)", + "total": "\(self.total)", + ] + } + + mutating func merge(_ other: MemoryPressureCacheTrimSummary) { + self.menuCardHeights += other.menuCardHeights + self.menuWidths += other.menuWidths + self.mergedSwitcherSelections += other.mergedSwitcherSelections + self.recycledMenuCardViews += other.recycledMenuCardViews + self.openAIWebDebugLines += other.openAIWebDebugLines + } +} + +@MainActor +final class MemoryPressureMonitor { + typealias CacheTrimHandler = @MainActor () -> MemoryPressureCacheTrimSummary + + private let logger = CodexBarLog.logger(LogCategories.memoryPressure) + private let releaseFreeMallocPages: @Sendable () -> Void + private let trimAppCaches: CacheTrimHandler + private var source: DispatchSourceMemoryPressure? + + init( + trimAppCaches: @escaping CacheTrimHandler = { MemoryPressureCacheTrimSummary() }, + releaseFreeMallocPages: @escaping @Sendable () -> Void = { + MemoryPressureRelief.releaseFreeMallocPages() + }) + { + self.trimAppCaches = trimAppCaches + self.releaseFreeMallocPages = releaseFreeMallocPages + } + + func start() { + guard self.source == nil else { return } + + let source = DispatchSource.makeMemoryPressureSource( + eventMask: [.warning, .critical], + queue: .global(qos: .utility)) + source.setEventHandler(handler: Self.makeEventHandler( + source: source, + handle: { [weak self] isWarning, isCritical in + self?.handleMemoryPressure(isWarning: isWarning, isCritical: isCritical) + })) + self.source = source + source.resume() + } + + nonisolated static func makeEventHandler( + source: DispatchSourceMemoryPressure, + handle: @escaping @MainActor @Sendable (_ isWarning: Bool, _ isCritical: Bool) -> Void) + -> @Sendable () -> Void + { + self.makeEventHandler( + eventReader: { [weak source] in source?.data ?? [] }, + handle: handle) + } + + nonisolated static func makeEventHandler( + eventReader: @escaping @Sendable () -> DispatchSource.MemoryPressureEvent, + handle: @escaping @MainActor @Sendable (_ isWarning: Bool, _ isCritical: Bool) -> Void) + -> @Sendable () -> Void + { + // DispatchSource invokes this on the utility queue. Keep the handler + // nonisolated, then hop to MainActor for app-state cleanup. + { @Sendable in + let event = eventReader() + let isWarning = event.contains(.warning) + let isCritical = event.contains(.critical) + Task { @MainActor in + handle(isWarning, isCritical) + } + } + } + + func stop() { + self.source?.cancel() + self.source = nil + } + + deinit { + self.source?.cancel() + } + + #if DEBUG + func handleMemoryPressureForTesting(isWarning: Bool, isCritical: Bool) { + self.handleMemoryPressure(isWarning: isWarning, isCritical: isCritical) + } + #endif + + private func handleMemoryPressure(isWarning: Bool, isCritical: Bool) { + let level = if isCritical { + "critical" + } else if isWarning { + "warning" + } else { + "normal" + } + self.logger.warning("System memory pressure", metadata: ["level": level]) + #if DEBUG + let cachedWebViewsBefore = OpenAIDashboardFetcher.cachedWebViewCountForTesting() + #endif + OpenAIDashboardFetcher.evictIdleCachedWebViews() + #if DEBUG + let cachedWebViewsAfter = OpenAIDashboardFetcher.cachedWebViewCountForTesting() + self.logger.info( + "Memory pressure OpenAI webview cache", + metadata: [ + "before": "\(cachedWebViewsBefore)", + "after": "\(cachedWebViewsAfter)", + "evicted": "\(max(0, cachedWebViewsBefore - cachedWebViewsAfter))", + ]) + #endif + let trimSummary = self.trimAppCaches() + if trimSummary.total > 0 { + self.logger.info("Trimmed app caches for memory pressure", metadata: trimSummary.metadata) + } + let releaseFreeMallocPages = self.releaseFreeMallocPages + Task.detached(priority: .utility) { + releaseFreeMallocPages() + } + } +} diff --git a/Sources/CodexBar/MemoryPressureRelief.swift b/Sources/CodexBar/MemoryPressureRelief.swift new file mode 100644 index 0000000000..7a2162e348 --- /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/MenuBarDisplayMode.swift b/Sources/CodexBar/MenuBarDisplayMode.swift index 658b18fa4b..24c6d25249 100644 --- a/Sources/CodexBar/MenuBarDisplayMode.swift +++ b/Sources/CodexBar/MenuBarDisplayMode.swift @@ -5,6 +5,7 @@ enum MenuBarDisplayMode: String, CaseIterable, Identifiable { case percent case pace case both + case resetTime var id: String { self.rawValue @@ -15,6 +16,7 @@ enum MenuBarDisplayMode: String, CaseIterable, Identifiable { case .percent: L("display_mode_percent") case .pace: L("display_mode_pace") case .both: L("display_mode_both") + case .resetTime: L("display_mode_reset_time") } } @@ -23,23 +25,7 @@ enum MenuBarDisplayMode: String, CaseIterable, Identifiable { case .percent: L("display_mode_percent_desc") case .pace: L("display_mode_pace_desc") case .both: L("display_mode_both_desc") - } - } -} - -/// Controls which time window drives the percent and pace values in the menu bar. -enum MenuBarTimeWindow: String, CaseIterable, Identifiable { - case session - case weekly - - var id: String { - self.rawValue - } - - var label: String { - switch self { - case .session: "Session" - case .weekly: "Weekly" + case .resetTime: L("display_mode_reset_time_desc") } } } diff --git a/Sources/CodexBar/MenuBarDisplayText.swift b/Sources/CodexBar/MenuBarDisplayText.swift index 8e8c8f7729..be99308684 100644 --- a/Sources/CodexBar/MenuBarDisplayText.swift +++ b/Sources/CodexBar/MenuBarDisplayText.swift @@ -5,34 +5,178 @@ enum MenuBarDisplayText { static func percentText(window: RateWindow?, showUsed: Bool) -> String? { guard let window else { return nil } let percent = showUsed ? window.usedPercent : window.remainingPercent - let clamped = min(100, max(0, percent)) - return String(format: "%.0f%%", clamped) + return UsageFormatter.percentString(percent) } static func paceText(pace: UsagePace?) -> String? { guard let pace else { return nil } let deltaValue = Int(abs(pace.deltaPercent).rounded()) + if deltaValue == 0 { return "0%" } let sign = pace.deltaPercent >= 0 ? "+" : "-" return "\(sign)\(deltaValue)%" } + /// Combined "session · weekly" menu-bar text shared by providers that expose both a + /// session (5h) and weekly (7d) lane, e.g. Codex and Claude. + static func combinedSessionWeeklyPercentText( + sessionWindow: RateWindow?, + weeklyWindow: RateWindow?, + showUsed: Bool, + resetTimeDisplayStyle: ResetTimeDisplayStyle = .countdown, + showsResetTimeWhenExhausted: Bool = false, + now: Date = .init()) + -> String? + { + var parts: [String] = [] + if let sessionWindow, + let session = self.laneValueText( + window: sessionWindow, + showUsed: showUsed, + resetTimeDisplayStyle: resetTimeDisplayStyle, + showsResetTimeWhenExhausted: showsResetTimeWhenExhausted, + now: now) + { + parts.append("\(self.sessionWindowLabel(window: sessionWindow)) \(session)") + } + if let weeklyWindow, + let weekly = self.laneValueText( + window: weeklyWindow, + showUsed: showUsed, + resetTimeDisplayStyle: resetTimeDisplayStyle, + showsResetTimeWhenExhausted: showsResetTimeWhenExhausted, + now: now) + { + parts.append("W \(weekly)") + } + return parts.isEmpty ? nil : parts.joined(separator: " · ") + } + + private static func laneValueText( + window: RateWindow, + showUsed: Bool, + resetTimeDisplayStyle: ResetTimeDisplayStyle, + showsResetTimeWhenExhausted: Bool, + now: Date) -> String? + { + if let resetText = self.exhaustedResetText( + window: window, + enabled: showsResetTimeWhenExhausted, + style: resetTimeDisplayStyle, + now: now) + { + return resetText + } + return self.percentText(window: window, showUsed: showUsed) + } + + private static func sessionWindowLabel(window: RateWindow) -> String { + guard let minutes = window.windowMinutes, minutes > 0 else { return "S" } + guard minutes.isMultiple(of: 60) else { return "\(minutes)m" } + return "\(minutes / 60)h" + } + static func displayText( mode: MenuBarDisplayMode, percentWindow: RateWindow?, pace: UsagePace? = nil, showUsed: Bool, - separatorStyle: MenuBarSeparatorStyle = .dot) -> String? + resetTimeDisplayStyle: ResetTimeDisplayStyle = .countdown, + showsResetTimeWhenExhausted: Bool = false, + now: Date = .init()) -> String? { + if mode != .resetTime, + showsResetTimeWhenExhausted, + let percentWindow, + percentWindow.remainingPercent <= 0 + { + if let resetText = self.exhaustedResetText( + window: percentWindow, + enabled: true, + style: resetTimeDisplayStyle, + now: now) + { + return resetText + } + // Smart mode cannot replace an exhausted percentage unless the reset is concrete, future, + // and schedulable. Preserve the quota signal in pace/both modes too; a pace from another + // combined lane must not hide that this displayed lane is already exhausted. + return self.percentText(window: percentWindow, showUsed: showUsed) + } switch mode { case .percent: return self.percentText(window: percentWindow, showUsed: showUsed) case .pace: + // Pace can be temporarily unavailable near a reset or when a provider omits window metadata. + // Keep the selected quota visible instead of collapsing the status item to an icon-only state. return self.paceText(pace: pace) + ?? self.percentText(window: percentWindow, showUsed: showUsed) case .both: guard let percent = percentText(window: percentWindow, showUsed: showUsed) else { return nil } // Fall back to percent-only when pace is unavailable (e.g. Copilot) guard let paceText = Self.paceText(pace: pace) else { return percent } - return "\(percent)\(separatorStyle.separator)\(paceText)" + return "\(percent) · \(paceText)" + case .resetTime: + guard let percentWindow else { return nil } + return self.resetTimeText(window: percentWindow, style: resetTimeDisplayStyle, now: now) + ?? self.percentText(window: percentWindow, showUsed: showUsed) + } + } + + /// "↻ …" reset text for a window, or nil when it carries no usable reset metadata. + static func resetTimeText( + window: RateWindow, + style: ResetTimeDisplayStyle, + now: Date) -> String? + { + if let resetsAt = window.resetsAt { + let description = switch style { + case .countdown: + UsageFormatter.resetCountdownDescription(from: resetsAt, now: now) + case .absolute: + UsageFormatter.resetDescription(from: resetsAt, now: now) + } + return "↻ \(description)" + } + if let resetDescription = self.resetMetadataText(window.resetDescription) { + return "↻ \(resetDescription)" } + return nil + } + + /// Smart-mode replacement: when enabled and the quota is exhausted (0% remaining, regardless of + /// whether the display shows used or remaining), surface the reset time instead of a dead percent. + /// + /// Requires a concrete, still-future `resetsAt`. The smart option only replaces the percent when it + /// has a reset time it can both render as a live countdown/clock AND hand to the refresh scheduler, + /// so the lane keeps ticking and flips back to the percentage once the reset passes. Windows with + /// only textual reset metadata (`resetDescription`, no `resetsAt`) or an already-elapsed reset can't + /// be scheduled, so they keep showing the percent instead of freezing on stale reset text. + private static func exhaustedResetText( + window: RateWindow?, + enabled: Bool, + style: ResetTimeDisplayStyle, + now: Date) -> String? + { + guard enabled, let window, window.remainingPercent <= 0 else { return nil } + guard let resetsAt = window.resetsAt, resetsAt > now else { return nil } + return self.resetTimeText(window: window, style: style, now: now) + } + + private static func resetMetadataText(_ description: String?) -> String? { + guard let description else { return nil } + let trimmed = description.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + + // RateWindow.resetDescription predates provider-specific detail fields and is also used for + // request/token summaries. Only trust phrases that explicitly describe reset timing. + let normalized = trimmed.lowercased() + let resetPrefixes = [ + "reset ", "resets ", "in ", "today ", "today,", "tomorrow ", "tomorrow,", "next ", + "expire ", "expires ", "refill ", "refills ", + ] + let exactResetDescriptions = ["today", "tomorrow", "expired", "now", "soon"] + return exactResetDescriptions.contains(normalized) || resetPrefixes.contains(where: normalized.hasPrefix) + ? trimmed + : nil } } diff --git a/Sources/CodexBar/MenuBarLayout.swift b/Sources/CodexBar/MenuBarLayout.swift new file mode 100644 index 0000000000..ea9444b292 --- /dev/null +++ b/Sources/CodexBar/MenuBarLayout.swift @@ -0,0 +1,253 @@ +import CodexBarCore +import Foundation + +enum PercentWindow: String, CaseIterable, Codable, Hashable, Sendable { + case session + case weekly + case automatic +} + +enum MenuBarLayoutToken: Codable, Hashable, Sendable { + case icon + case providerName + case accountLabel + case percent(window: PercentWindow) + case usageBar + case resetCountdown + case resetAbsolute + case runsOut + case costToday + case cost30d + case separatorDot + case space +} + +enum MenuBarLayoutSemanticWindowResolver { + static func windows( + provider: UsageProvider, + snapshot: UsageSnapshot?) + -> (session: RateWindow?, weekly: RateWindow?) + { + guard let snapshot else { return (nil, nil) } + let candidates = [ + snapshot.primary, + snapshot.secondary, + snapshot.tertiary, + ] + (snapshot.extraRateWindows ?? []).map(\.window) + let usable = candidates.compactMap { window -> RateWindow? in + guard let window, !window.isSyntheticPlaceholder else { return nil } + return window + } + let session = usable.first { window in + guard let minutes = window.windowMinutes else { return false } + return (60...(12 * 60)).contains(minutes) + } + let cadenceWeekly = usable.first { $0.windowMinutes == 7 * 24 * 60 } + let kimiWeekly = snapshot.primary.flatMap { $0.isSyntheticPlaceholder ? nil : $0 } + let weekly = provider == .kimi ? kimiWeekly ?? cadenceWeekly : cadenceWeekly + return (session, weekly) + } +} + +enum MenuBarLayoutCostResolver { + static func todayCostUSD( + snapshot: CostUsageTokenSnapshot?, + now: Date, + calendar: Calendar = .current) + -> Double? + { + guard let snapshot else { return nil } + return CostUsageTokenSnapshot.entry( + in: snapshot.daily, + forLocalDayContaining: now, + calendar: calendar)?.costUSD + } +} + +struct MenuBarLayout: Codable, Hashable, Sendable { + static let defaultLayout = MenuBarLayout(lines: [[.icon, .percent(window: .automatic)]]) + + let lines: [[MenuBarLayoutToken]] + + init(lines: [[MenuBarLayoutToken]]) { + self.lines = Self.normalizedLines(lines) + } + + private enum CodingKeys: String, CodingKey { + case lines + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + try self.init(lines: container.decode([[MenuBarLayoutToken]].self, forKey: .lines)) + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(self.lines, forKey: .lines) + } + + private static func normalizedLines(_ lines: [[MenuBarLayoutToken]]) -> [[MenuBarLayoutToken]] { + guard let firstContentLine = lines.firstIndex(where: { !$0.isEmpty }) else { + return self.defaultLayout.lines + } + return Array(lines[firstContentLine...].prefix(2)) + } +} + +enum MenuBarLayoutPreset: String, CaseIterable, Identifiable, Sendable { + case iconAndPercent + case iconOnly + case percentAndReset + case compactStacked + case custom + + var id: String { + self.rawValue + } + + var layout: MenuBarLayout? { + switch self { + case .iconAndPercent: + MenuBarLayout(lines: [[.icon, .percent(window: .automatic)]]) + case .iconOnly: + MenuBarLayout(lines: [[.icon]]) + case .percentAndReset: + MenuBarLayout(lines: [[ + .icon, + .percent(window: .automatic), + .separatorDot, + .resetCountdown, + ]]) + case .compactStacked: + MenuBarLayout(lines: [ + [.percent(window: .session)], + [.percent(window: .weekly)], + ]) + case .custom: + nil + } + } + + static func matching(_ layout: MenuBarLayout) -> Self { + allCases.first { $0.layout == layout } ?? .custom + } +} + +enum MenuBarLayoutSize: String, CaseIterable, Identifiable, Sendable { + case small + case regular + + var id: String { + self.rawValue + } +} + +enum MenuBarLayoutGap: String, CaseIterable, Identifiable, Sendable { + case tight + case regular + + var id: String { + self.rawValue + } +} + +struct MenuBarLayoutResolution: Equatable { + struct LegacySettings: Equatable { + let iconStyle: MenuBarIconStyle + let displayMode: MenuBarDisplayMode + let metricPreference: MenuBarMetricPreference + let resetTimeDisplayStyle: ResetTimeDisplayStyle + } + + let layout: MenuBarLayout + let legacySettings: LegacySettings? + + var usesLegacyRendering: Bool { + self.legacySettings != nil + } + + static func stored(_ layout: MenuBarLayout) -> Self { + Self(layout: layout, legacySettings: nil) + } + + static func legacy( + iconStyle: MenuBarIconStyle, + displayMode: MenuBarDisplayMode, + metricPreference: MenuBarMetricPreference, + resetTimeDisplayStyle: ResetTimeDisplayStyle, + provider: UsageProvider? = nil) + -> Self + { + Self( + layout: MenuBarLayout.migrated( + iconStyle: iconStyle, + displayMode: displayMode, + metricPreference: metricPreference, + resetTimeDisplayStyle: resetTimeDisplayStyle, + provider: provider), + legacySettings: LegacySettings( + iconStyle: iconStyle, + displayMode: displayMode, + metricPreference: metricPreference, + resetTimeDisplayStyle: resetTimeDisplayStyle)) + } +} + +extension MenuBarLayout { + static func migrated( + iconStyle: MenuBarIconStyle, + displayMode: MenuBarDisplayMode, + metricPreference: MenuBarMetricPreference, + resetTimeDisplayStyle: ResetTimeDisplayStyle, + provider: UsageProvider? = nil) + -> MenuBarLayout + { + _ = iconStyle // Critters and bars keep rendering through their unchanged legacy path. + let icon: MenuBarLayoutToken = .icon + switch displayMode { + case .percent: + if metricPreference == .primaryAndSecondary { + return MenuBarLayout(lines: [[ + icon, + .percent(window: Self.percentWindow(for: .primary, provider: provider)), + .separatorDot, + .percent(window: Self.percentWindow(for: .secondary, provider: provider)), + ]]) + } + return MenuBarLayout(lines: [[ + icon, + .percent(window: Self.percentWindow(for: metricPreference, provider: provider)), + ]]) + case .pace: + return MenuBarLayout(lines: [[icon, .runsOut]]) + case .both: + return MenuBarLayout(lines: [[ + icon, + .percent(window: Self.percentWindow(for: metricPreference, provider: provider)), + .separatorDot, + .runsOut, + ]]) + case .resetTime: + let resetItem = resetTimeDisplayStyle == .absolute + ? MenuBarLayoutToken.resetAbsolute + : MenuBarLayoutToken.resetCountdown + return MenuBarLayout(lines: [[icon, resetItem]]) + } + } + + private static func percentWindow( + for preference: MenuBarMetricPreference, + provider: UsageProvider?) + -> PercentWindow + { + switch preference { + case .primary: + provider == .kimi ? .weekly : .session + case .secondary: + provider == .kimi ? .session : .weekly + case .automatic, .primaryAndSecondary, .tertiary, .extraUsage, .average, .monthlyPlan: + .automatic + } + } +} diff --git a/Sources/CodexBar/MenuBarLayoutEditor.swift b/Sources/CodexBar/MenuBarLayoutEditor.swift new file mode 100644 index 0000000000..316cdd1768 --- /dev/null +++ b/Sources/CodexBar/MenuBarLayoutEditor.swift @@ -0,0 +1,815 @@ +import AppKit +import CodexBarCore +import CoreTransferable +import SwiftUI +import UniformTypeIdentifiers + +extension UTType { + static let codexBarMenuLayoutItem = UTType(exportedAs: "com.steipete.codexbar.menu-layout-item") +} + +struct MenuBarLayoutPosition: Codable, Hashable, Sendable { + let line: Int + let index: Int +} + +struct MenuBarLayoutDragItem: Codable, Hashable, Transferable, Sendable { + enum Content: Codable, Hashable, Sendable { + case token(MenuBarLayoutToken) + case lineBreak + } + + let content: Content + let source: MenuBarLayoutPosition? + let sourceLayout: MenuBarLayout? + + static var transferRepresentation: some TransferRepresentation { + CodableRepresentation(contentType: .codexBarMenuLayoutItem) + } + + static func palette(_ component: MenuBarLayoutToken) -> Self { + Self(content: .token(component), source: nil, sourceLayout: nil) + } + + static func placed( + _ component: MenuBarLayoutToken, + at source: MenuBarLayoutPosition, + in layout: MenuBarLayout) + -> Self + { + Self(content: .token(component), source: source, sourceLayout: layout) + } + + static let lineBreak = Self(content: .lineBreak, source: nil, sourceLayout: nil) +} + +enum MenuBarLayoutEditorMutations { + static func append(_ component: MenuBarLayoutToken, to layout: MenuBarLayout) -> MenuBarLayout { + var lines = layout.lines + let line = max(0, lines.count - 1) + lines[line].append(component) + return MenuBarLayout(lines: lines) + } + + static func insert( + _ item: MenuBarLayoutDragItem, + at target: MenuBarLayoutPosition, + in layout: MenuBarLayout) + -> MenuBarLayout + { + if case .lineBreak = item.content { + return self.addLineBreak(to: layout, at: target.index) + } + + guard case let .token(token) = item.content else { return layout } + var lines = layout.lines + guard !lines.isEmpty else { return MenuBarLayout(lines: [[token]]) } + var targetLine = min(max(target.line, 0), lines.count - 1) + var targetIndex = min(max(target.index, 0), lines[targetLine].count) + + if let source = item.source { + guard item.sourceLayout == layout else { return layout } + guard lines.indices.contains(source.line), + lines[source.line].indices.contains(source.index), + lines[source.line][source.index] == token + else { return layout } + lines[source.line].remove(at: source.index) + if source.line == targetLine, source.index < targetIndex { + targetIndex -= 1 + } + } + + targetLine = min(max(targetLine, 0), lines.count - 1) + targetIndex = min(max(targetIndex, 0), lines[targetLine].count) + lines[targetLine].insert(token, at: targetIndex) + return MenuBarLayout(lines: lines) + } + + static func remove(at position: MenuBarLayoutPosition, from layout: MenuBarLayout) -> MenuBarLayout { + guard layout.lines.indices.contains(position.line), + layout.lines[position.line].indices.contains(position.index), + layout.lines.reduce(0, { $0 + $1.count }) > 1 + else { return layout } + var lines = layout.lines + lines[position.line].remove(at: position.index) + guard lines.joined().contains(where: { $0 != .space }) else { return layout } + return MenuBarLayout(lines: lines) + } + + static func remove(_ item: MenuBarLayoutDragItem, from layout: MenuBarLayout) -> MenuBarLayout { + guard let source = item.source, + item.sourceLayout == layout, + case let .token(component) = item.content, + layout.lines.indices.contains(source.line), + layout.lines[source.line].indices.contains(source.index), + layout.lines[source.line][source.index] == component + else { return layout } + return self.remove(at: source, from: layout) + } + + static func addLineBreak(to layout: MenuBarLayout, at proposedIndex: Int? = nil) -> MenuBarLayout { + guard layout.lines.count == 1 else { return layout } + let line = layout.lines[0] + guard !line.isEmpty else { return layout } + if line.count == 1 { + return MenuBarLayout(lines: [line, []]) + } + let index = min(max(proposedIndex ?? line.count / 2, 1), line.count - 1) + return MenuBarLayout(lines: [Array(line[.. MenuBarLayout { + guard layout.lines.count == 2 else { return layout } + return MenuBarLayout(lines: [layout.lines[0] + layout.lines[1]]) + } +} + +private enum MenuBarLayoutEditorScope: Hashable { + case all + case provider(UsageProvider) +} + +@MainActor +enum MenuBarLayoutEditorPersistence { + static func activate( + _ layout: MenuBarLayout, + for provider: UsageProvider?, + settings: SettingsStore) + { + settings.menuBarIconStyle = .iconAndPercent + settings.setMenuBarLayout(layout, for: provider) + } + + static func setSize( + _ size: MenuBarLayoutSize, + activating layout: MenuBarLayout, + for provider: UsageProvider?, + settings: SettingsStore) + { + settings.menuBarLayoutSize = size + self.activate(layout, for: provider, settings: settings) + } + + static func setGap( + _ gap: MenuBarLayoutGap, + activating layout: MenuBarLayout, + for provider: UsageProvider?, + settings: SettingsStore) + { + settings.menuBarLayoutGap = gap + self.activate(layout, for: provider, settings: settings) + } +} + +private struct MenuBarLayoutPaletteGroup: Identifiable { + let id: String + let title: String + let tokens: [MenuBarLayoutToken] + let includesLineBreak: Bool +} + +@MainActor +struct MenuBarLayoutEditor: View { + @Bindable var settings: SettingsStore + @Bindable var store: UsageStore + + @State private var scope: MenuBarLayoutEditorScope = .all + @State private var selectedPosition: MenuBarLayoutPosition? + + private var layout: MenuBarLayout { + switch self.scope { + case .all: + self.settings.menuBarLayoutForGlobalEditing(representativeProvider: self.scopedProvider) + case let .provider(provider): + self.settings.menuBarLayout(for: provider) + } + } + + private var preset: MenuBarLayoutPreset { + MenuBarLayoutPreset.matching(self.layout) + } + + private var providers: [UsageProvider] { + self.store.enabledProvidersForDisplay() + } + + private var scopedProvider: UsageProvider? { + switch self.scope { + case .all: self.providers.first + case let .provider(provider): provider + } + } + + private var persistenceProvider: UsageProvider? { + switch self.scope { + case .all: nil + case let .provider(provider): provider + } + } + + private var sizeBinding: Binding { + Binding( + get: { self.settings.menuBarLayoutSize }, + set: { size in + MenuBarLayoutEditorPersistence.setSize( + size, + activating: self.layout, + for: self.persistenceProvider, + settings: self.settings) + }) + } + + private var gapBinding: Binding { + Binding( + get: { self.settings.menuBarLayoutGap }, + set: { gap in + MenuBarLayoutEditorPersistence.setGap( + gap, + activating: self.layout, + for: self.persistenceProvider, + settings: self.settings) + }) + } + + private var paletteGroups: [MenuBarLayoutPaletteGroup] { + [ + MenuBarLayoutPaletteGroup( + id: "identity", + title: L("menu_bar_layout_group_identity"), + tokens: [.icon, .providerName, .accountLabel], + includesLineBreak: false), + MenuBarLayoutPaletteGroup( + id: "usage", + title: L("menu_bar_layout_group_usage"), + tokens: [ + .percent(window: .session), + .percent(window: .weekly), + .percent(window: .automatic), + .usageBar, + ], + includesLineBreak: false), + MenuBarLayoutPaletteGroup( + id: "time", + title: L("menu_bar_layout_group_time"), + tokens: [.resetCountdown, .resetAbsolute, .runsOut], + includesLineBreak: false), + MenuBarLayoutPaletteGroup( + id: "money", + title: L("menu_bar_layout_group_money"), + tokens: [.costToday, .cost30d], + includesLineBreak: false), + MenuBarLayoutPaletteGroup( + id: "structure", + title: L("menu_bar_layout_group_structure"), + tokens: [.separatorDot, .space], + includesLineBreak: true), + ] + } + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + self.header + self.preview + self.layoutStrip + self.removeDropTarget + + Divider() + + ForEach(self.paletteGroups) { group in + self.palette(group) + } + + Divider() + + self.displayOptions + } + .padding(.vertical, 4) + .onDeleteCommand { + self.removeSelectedToken() + } + .onChange(of: self.scope) { _, _ in + self.selectedPosition = nil + } + } + + private var header: some View { + HStack(alignment: .center, spacing: 12) { + Menu { + Button(L("menu_bar_layout_scope_all")) { + self.scope = .all + } + if !self.providers.isEmpty { + Divider() + } + ForEach(self.providers, id: \.self) { provider in + Button(L(self.store.metadata(for: provider).displayName)) { + self.scope = .provider(provider) + } + } + } label: { + Label(self.scopeLabel, systemImage: "scope") + } + .menuStyle(.button) + .help(L("menu_bar_layout_scope_help")) + + if case let .provider(provider) = self.scope, + self.settings.menuBarLayoutOverrides[provider] != nil + { + Button(L("menu_bar_layout_use_all")) { + self.settings.removeMenuBarLayoutOverride(for: provider) + self.selectedPosition = nil + } + .buttonStyle(.link) + } + + Spacer(minLength: 8) + + Menu { + ForEach(MenuBarLayoutPreset.allCases) { preset in + Button(preset.label) { + self.applyPreset(preset) + } + .disabled(preset == .custom) + } + } label: { + HStack(spacing: 5) { + Text(self.preset.label) + Image(systemName: "chevron.up.chevron.down") + .font(.caption2) + } + } + .menuStyle(.button) + .accessibilityLabel(L("menu_bar_layout_preset")) + } + } + + private var scopeLabel: String { + switch self.scope { + case .all: + L("menu_bar_layout_scope_all") + case let .provider(provider): + L(self.store.metadata(for: provider).displayName) + } + } + + private var preview: some View { + VStack(alignment: .leading, spacing: 5) { + Text(L("menu_bar_layout_live_preview")) + .font(.caption) + .foregroundStyle(.secondary) + MenuBarLayoutPreview( + layout: self.layout, + provider: self.scopedProvider, + settings: self.settings, + store: self.store) + .frame(maxWidth: .infinity, minHeight: 30) + .background( + RoundedRectangle(cornerRadius: 7, style: .continuous) + .fill(.background.opacity(0.75))) + .overlay( + RoundedRectangle(cornerRadius: 7, style: .continuous) + .stroke(.separator.opacity(0.65), lineWidth: 1)) + } + } + + private var layoutStrip: some View { + VStack(alignment: .leading, spacing: 6) { + HStack { + Text(L("menu_bar_layout_strip")) + .font(.caption) + .foregroundStyle(.secondary) + Spacer() + if self.layout.lines.count == 2 { + Button(L("menu_bar_layout_remove_line_break")) { + self.write(MenuBarLayoutEditorMutations.removeLineBreak(from: self.layout)) + } + .buttonStyle(.link) + } + } + + VStack(alignment: .leading, spacing: 4) { + ForEach(Array(self.layout.lines.enumerated()), id: \.offset) { lineIndex, _ in + self.layoutLine(lineIndex) + } + } + } + } + + private func layoutLine(_ lineIndex: Int) -> some View { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 5) { + let line = self.layout.lines[lineIndex] + ForEach(Array(line.enumerated()), id: \.offset) { index, token in + let position = MenuBarLayoutPosition(line: lineIndex, index: index) + Button { + self.selectedPosition = position + } label: { + MenuBarLayoutChipLabel( + title: token.editorLabel, + systemImage: token.editorSystemImage, + isSelected: self.selectedPosition == position) + } + .buttonStyle(.plain) + .focusable() + .onKeyPress(keys: [.space, .return], phases: [.down]) { _ in + self.selectedPosition = position + return .handled + } + .draggable(MenuBarLayoutDragItem.placed(token, at: position, in: self.layout)) + .dropDestination(for: MenuBarLayoutDragItem.self) { items, _ in + self.insert(items.first, at: position) + } + .accessibilityLabel(token.editorAccessibilityLabel) + .accessibilityHint(L("menu_bar_layout_chip_hint")) + .accessibilityAction(named: L("Remove")) { + self.remove(at: position) + } + } + if line.isEmpty { + Text(L("menu_bar_layout_empty_line")) + .font(.caption) + .foregroundStyle(.tertiary) + .padding(.horizontal, 8) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 7) + .padding(.vertical, 5) + } + .frame(minHeight: 34) + .background( + RoundedRectangle(cornerRadius: 7, style: .continuous) + .fill(Color.accentColor.opacity(0.04))) + .overlay( + RoundedRectangle(cornerRadius: 7, style: .continuous) + .strokeBorder(style: StrokeStyle(lineWidth: 1, dash: [4, 3])) + .foregroundStyle(Color.secondary.opacity(0.35))) + .dropDestination(for: MenuBarLayoutDragItem.self) { items, _ in + self.insert( + items.first, + at: MenuBarLayoutPosition(line: lineIndex, index: self.layout.lines[lineIndex].count)) + } + .accessibilityLabel(L("menu_bar_layout_line", lineIndex + 1)) + } + + private var removeDropTarget: some View { + HStack(spacing: 6) { + Image(systemName: "trash") + Text(L("menu_bar_layout_drag_remove")) + } + .font(.caption) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity) + .padding(.vertical, 5) + .background( + RoundedRectangle(cornerRadius: 6, style: .continuous) + .fill(.secondary.opacity(0.06))) + .dropDestination(for: MenuBarLayoutDragItem.self) { items, _ in + guard let item = items.first, item.source != nil else { return false } + let updated = MenuBarLayoutEditorMutations.remove(item, from: self.layout) + guard updated != self.layout else { return false } + self.write(updated) + self.selectedPosition = nil + return true + } + .accessibilityLabel(L("menu_bar_layout_drag_remove")) + } + + private func palette(_ group: MenuBarLayoutPaletteGroup) -> some View { + VStack(alignment: .leading, spacing: 5) { + Text(group.title) + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + LazyVGrid( + columns: [GridItem(.adaptive(minimum: 88), spacing: 6)], + alignment: .leading, + spacing: 6) + { + ForEach(group.tokens, id: \.self) { token in + Button { + self.write(MenuBarLayoutEditorMutations.append(token, to: self.layout)) + } label: { + MenuBarLayoutChipLabel( + title: token.editorLabel, + systemImage: token.editorSystemImage, + isSelected: false) + } + .buttonStyle(.plain) + .focusable() + .onKeyPress(keys: [.space, .return], phases: [.down]) { _ in + self.write(MenuBarLayoutEditorMutations.append(token, to: self.layout)) + return .handled + } + .draggable(MenuBarLayoutDragItem.palette(token)) + .accessibilityLabel(token.editorAccessibilityLabel) + .accessibilityHint(L("menu_bar_layout_palette_hint")) + } + if group.includesLineBreak { + Button { + self.write(MenuBarLayoutEditorMutations.addLineBreak(to: self.layout)) + } label: { + MenuBarLayoutChipLabel( + title: L("menu_bar_layout_token_line_break"), + systemImage: "arrow.turn.down.right", + isSelected: false) + } + .buttonStyle(.plain) + .focusable() + .onKeyPress(keys: [.space, .return], phases: [.down]) { _ in + self.write(MenuBarLayoutEditorMutations.addLineBreak(to: self.layout)) + return .handled + } + .draggable(MenuBarLayoutDragItem.lineBreak) + .disabled(self.layout.lines.count == 2) + .accessibilityLabel(L("menu_bar_layout_token_line_break")) + .accessibilityHint(L("menu_bar_layout_palette_hint")) + } + } + } + } + + private var displayOptions: some View { + HStack(spacing: 18) { + Picker(L("menu_bar_layout_size"), selection: self.sizeBinding) { + ForEach(MenuBarLayoutSize.allCases) { size in + Text(size.label).tag(size) + } + } + .pickerStyle(.menu) + + Picker(L("menu_bar_layout_gap"), selection: self.gapBinding) { + ForEach(MenuBarLayoutGap.allCases) { gap in + Text(gap.label).tag(gap) + } + } + .pickerStyle(.menu) + + Spacer() + + Text(L("menu_bar_layout_keyboard_hint")) + .font(.caption) + .foregroundStyle(.tertiary) + } + } + + private func applyPreset(_ preset: MenuBarLayoutPreset) { + guard let layout = preset.layout else { return } + self.selectedPosition = nil + self.write(layout) + } + + private func insert(_ item: MenuBarLayoutDragItem?, at position: MenuBarLayoutPosition) -> Bool { + guard let item else { return false } + let updated = MenuBarLayoutEditorMutations.insert(item, at: position, in: self.layout) + guard updated != self.layout else { return false } + self.write(updated) + self.selectedPosition = nil + return true + } + + private func removeSelectedToken() { + guard let selectedPosition else { return } + self.remove(at: selectedPosition) + } + + private func remove(at position: MenuBarLayoutPosition) { + let updated = MenuBarLayoutEditorMutations.remove(at: position, from: self.layout) + guard updated != self.layout else { return } + self.write(updated) + self.selectedPosition = nil + } + + private func write(_ layout: MenuBarLayout) { + MenuBarLayoutEditorPersistence.activate( + layout, + for: self.persistenceProvider, + settings: self.settings) + } +} + +private struct MenuBarLayoutChipLabel: View { + let title: String + let systemImage: String + let isSelected: Bool + + var body: some View { + HStack(spacing: 5) { + Image(systemName: self.systemImage) + .font(.caption.weight(.medium)) + Text(self.title) + .font(.caption) + .lineLimit(1) + } + .padding(.horizontal, 8) + .padding(.vertical, 5) + .foregroundStyle(self.isSelected ? Color.white : Color.primary) + .background( + Capsule(style: .continuous) + .fill(self.isSelected ? Color.accentColor : Color.secondary.opacity(0.12))) + .overlay( + Capsule(style: .continuous) + .stroke(self.isSelected ? Color.clear : Color.secondary.opacity(0.2), lineWidth: 1)) + } +} + +@MainActor +private struct MenuBarLayoutPreview: View { + let layout: MenuBarLayout + let provider: UsageProvider? + @Bindable var settings: SettingsStore + @Bindable var store: UsageStore + + private let renderer = MenuBarLayoutRenderer() + + var body: some View { + let provider = self.provider ?? .codex + let snapshot = self.store.snapshot(for: provider) + let data = snapshot.map { self.liveData(provider: provider, snapshot: $0) } + ?? self.representativeData(provider: provider) + let icon = ProviderBrandIcon.image(for: provider) + let minute = Date(timeIntervalSince1970: floor(Date().timeIntervalSince1970 / 60) * 60) + let rendered = self.renderer.render( + layout: self.layout, + data: data, + icon: icon, + options: MenuBarLayoutRenderOptions( + size: self.settings.menuBarLayoutSize, + highContrast: self.settings.menuBarHighContrastOnInactiveDisplays, + showUsed: self.settings.usageBarsShowUsed, + appearanceName: "preview", + isDebugApp: false, + now: minute)) + MenuBarLayoutPreviewText(rendered: rendered) + } + + private func liveData(provider: UsageProvider, snapshot: UsageSnapshot) -> MenuBarLayoutRenderData { + let now = Date() + let session: RateWindow? + let weekly: RateWindow? + let automatic: RateWindow? + if provider == .codex, + let projection = self.store.codexConsumerProjectionIfNeeded( + for: provider, + surface: .menuBar, + snapshotOverride: snapshot, + now: now) + { + session = projection.menuBarSelectableRateWindow(for: .session) + weekly = projection.menuBarSelectableRateWindow(for: .weekly) + automatic = projection.visibleRateLanes.lazy + .compactMap { projection.menuBarSelectableRateWindow(for: $0) } + .first + } else { + let semanticWindows = MenuBarLayoutSemanticWindowResolver.windows( + provider: provider, + snapshot: snapshot) + session = semanticWindows.session + weekly = semanticWindows.weekly + automatic = MenuBarMetricWindowResolver.rateWindow( + preference: .automatic, + provider: provider, + snapshot: snapshot, + supportsAverage: self.settings.menuBarMetricSupportsAverage(for: provider), + antigravityPrioritizeExhaustedQuotas: self.settings.antigravityPrioritizeExhaustedQuotas, + now: now) + } + let paceWindow = weekly ?? automatic + let runsOut = paceWindow + .flatMap { self.store.weeklyPace(provider: provider, window: $0, now: now) } + .flatMap { UsagePaceText.weeklyDetail(provider: provider, pace: $0, now: now).rightLabel } + let cost = self.store.tokenSnapshotForCurrentProviderConfig(for: provider)?.snapshot + let costToday = MenuBarLayoutCostResolver.todayCostUSD(snapshot: cost, now: now) + return MenuBarLayoutRenderData( + iconKey: provider.rawValue, + providerName: L(self.store.metadata(for: provider).displayName), + accountLabel: self.settings.hidePersonalInfo ? nil : snapshot.accountEmail(for: provider), + session: MenuBarLayoutRenderWindow(session), + weekly: MenuBarLayoutRenderWindow(weekly), + automatic: MenuBarLayoutRenderWindow(automatic), + runsOut: runsOut, + costToday: costToday.map { + UsageFormatter.currencyString($0, currencyCode: cost?.currencyCode ?? "USD") + }, + cost30d: cost?.last30DaysCostUSD.map { + UsageFormatter.currencyString($0, currencyCode: cost?.currencyCode ?? "USD") + }) + } + + private func representativeData(provider: UsageProvider) -> MenuBarLayoutRenderData { + let now = Date() + let session = RateWindow( + usedPercent: 37, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(2 * 60 * 60), + resetDescription: nil) + let weekly = RateWindow( + usedPercent: 62, + windowMinutes: 10080, + resetsAt: now.addingTimeInterval(3 * 24 * 60 * 60), + resetDescription: nil) + return MenuBarLayoutRenderData( + iconKey: "\(provider.rawValue)-representative", + providerName: L(self.store.metadata(for: provider).displayName), + accountLabel: self.settings.hidePersonalInfo ? nil : L("menu_bar_layout_sample_account"), + session: MenuBarLayoutRenderWindow(session), + weekly: MenuBarLayoutRenderWindow(weekly), + automatic: MenuBarLayoutRenderWindow(session), + runsOut: L("menu_bar_layout_sample_runs_out"), + costToday: "$1.25", + cost30d: "$20.00") + } +} + +@MainActor +private struct MenuBarLayoutPreviewText: NSViewRepresentable { + let rendered: MenuBarLayoutRenderedTitle + + func makeNSView(context: Context) -> NSTextField { + let field = NSTextField(labelWithAttributedString: self.rendered.attributedTitle) + field.alignment = .center + field.lineBreakMode = .byClipping + field.maximumNumberOfLines = 2 + field.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + return field + } + + func updateNSView(_ field: NSTextField, context: Context) { + field.attributedStringValue = self.rendered.attributedTitle + field.setAccessibilityLabel(self.rendered.accessibilityLabel) + } +} + +extension MenuBarLayoutPreset { + var label: String { + switch self { + case .iconAndPercent: L("menu_bar_layout_preset_icon_percent") + case .iconOnly: L("menu_bar_layout_preset_icon_only") + case .percentAndReset: L("menu_bar_layout_preset_percent_reset") + case .compactStacked: L("menu_bar_layout_preset_compact_stacked") + case .custom: L("menu_bar_layout_preset_custom") + } + } +} + +extension MenuBarLayoutSize { + var label: String { + switch self { + case .small: L("menu_bar_layout_size_small") + case .regular: L("menu_bar_layout_size_regular") + } + } +} + +extension MenuBarLayoutGap { + var label: String { + switch self { + case .tight: L("menu_bar_layout_gap_tight") + case .regular: L("menu_bar_layout_gap_regular") + } + } +} + +extension MenuBarLayoutToken { + var editorLabel: String { + switch self { + case .icon: L("menu_bar_layout_token_icon") + case .providerName: L("menu_bar_layout_token_provider") + case .accountLabel: L("menu_bar_layout_token_account") + case .percent(window: .session): L("menu_bar_layout_token_session") + case .percent(window: .weekly): L("menu_bar_layout_token_weekly") + case .percent(window: .automatic): L("menu_bar_layout_token_auto") + case .usageBar: L("menu_bar_layout_token_bar") + case .resetCountdown: L("menu_bar_layout_token_resets_in") + case .resetAbsolute: L("menu_bar_layout_token_reset_at") + case .runsOut: L("menu_bar_layout_token_runs_out") + case .costToday: L("menu_bar_layout_token_cost_today") + case .cost30d: L("menu_bar_layout_token_cost_30d") + case .separatorDot: "·" + case .space: L("menu_bar_layout_token_space") + } + } + + var editorAccessibilityLabel: String { + switch self { + case .separatorDot: L("menu_bar_layout_token_separator_accessibility") + default: self.editorLabel + } + } + + var editorSystemImage: String { + switch self { + case .icon: "app.dashed" + case .providerName: "textformat" + case .accountLabel: "person.crop.circle" + case .percent: "percent" + case .usageBar: "chart.bar.fill" + case .resetCountdown: "timer" + case .resetAbsolute: "clock" + case .runsOut: "hourglass.bottomhalf.filled" + case .costToday: "dollarsign.circle" + case .cost30d: "calendar.badge.clock" + case .separatorDot: "smallcircle.filled.circle" + case .space: "space" + } + } +} diff --git a/Sources/CodexBar/MenuBarLayoutRenderer.swift b/Sources/CodexBar/MenuBarLayoutRenderer.swift new file mode 100644 index 0000000000..8d5b5455c7 --- /dev/null +++ b/Sources/CodexBar/MenuBarLayoutRenderer.swift @@ -0,0 +1,383 @@ +import AppKit +import CodexBarCore +import Foundation + +struct MenuBarLayoutRenderWindow: Hashable { + let usedPercent: Double + let windowMinutes: Int? + let resetsAt: Date? + let resetDescription: String? + + init?(_ window: RateWindow?) { + guard let window, !window.isSyntheticPlaceholder else { return nil } + self.usedPercent = window.usedPercent + self.windowMinutes = window.windowMinutes + self.resetsAt = window.resetsAt + self.resetDescription = window.resetDescription + } + + var remainingPercent: Double { + max(0, 100 - self.usedPercent) + } +} + +struct MenuBarLayoutRenderData: Hashable { + let iconKey: String + let providerName: String? + let accountLabel: String? + let session: MenuBarLayoutRenderWindow? + let weekly: MenuBarLayoutRenderWindow? + let automatic: MenuBarLayoutRenderWindow? + let runsOut: String? + let costToday: String? + let cost30d: String? +} + +struct MenuBarLayoutRenderOptions: Hashable { + let size: MenuBarLayoutSize + let highContrast: Bool + let showUsed: Bool + let appearanceName: String + let isDebugApp: Bool + /// Minute-granularity clock. Countdown tokens refresh without invalidating cached titles every tick. + let now: Date +} + +struct MenuBarLayoutRenderKey: Hashable { + let layout: MenuBarLayout + let data: MenuBarLayoutRenderData + let options: MenuBarLayoutRenderOptions +} + +struct MenuBarLayoutRenderedTitle { + let attributedTitle: NSAttributedString + let accessibilityLabel: String +} + +@MainActor +final class MenuBarLayoutTitleCache { + private let capacity: Int + private var storage: [MenuBarLayoutRenderKey: MenuBarLayoutRenderedTitle] = [:] + + init(capacity: Int = 64) { + self.capacity = max(1, capacity) + } + + func value( + for key: MenuBarLayoutRenderKey, + make: () -> MenuBarLayoutRenderedTitle) + -> MenuBarLayoutRenderedTitle + { + if let cached = self.storage[key] { + return cached + } + let value = make() + if self.storage.count >= self.capacity, let oldest = self.storage.keys.first { + self.storage.removeValue(forKey: oldest) + } + self.storage[key] = value + return value + } + + func removeAll() { + self.storage.removeAll(keepingCapacity: true) + } + + var count: Int { + self.storage.count + } +} + +@MainActor +final class MenuBarLayoutRenderer { + private static let missingValue = "–" + private static let stackedBaselineOffset: CGFloat = -3 // Center multi-line NSStatusBarButton titles. + + private struct TokenStyle { + let font: NSFont + let foregroundColor: NSColor + let iconHeight: CGFloat + let attributes: [NSAttributedString.Key: Any] + } + + private let cache: MenuBarLayoutTitleCache + + init(cache: MenuBarLayoutTitleCache = MenuBarLayoutTitleCache()) { + self.cache = cache + } + + func render( + layout: MenuBarLayout, + data: MenuBarLayoutRenderData, + icon: NSImage?, + options: MenuBarLayoutRenderOptions) + -> MenuBarLayoutRenderedTitle + { + let key = MenuBarLayoutRenderKey(layout: layout, data: data, options: options) + return self.cache.value(for: key) { + Self.renderUncached(layout: layout, data: data, icon: icon, options: options) + } + } + + func removeAll() { + self.cache.removeAll() + } + + private static func renderUncached( + layout: MenuBarLayout, + data: MenuBarLayoutRenderData, + icon: NSImage?, + options: MenuBarLayoutRenderOptions) + -> MenuBarLayoutRenderedTitle + { + let isStacked = layout.lines.count == 2 + let font = NSFont.systemFont(ofSize: Self.fontSize(size: options.size, isStacked: isStacked)) + let foregroundColor = options.highContrast ? NSColor.labelColor : NSColor.controlTextColor + let paragraphStyle = NSMutableParagraphStyle() + if isStacked { + paragraphStyle.minimumLineHeight = 9.5 + paragraphStyle.maximumLineHeight = 9.5 + paragraphStyle.lineSpacing = -1 + } + var attributes: [NSAttributedString.Key: Any] = [ + .font: font, + .foregroundColor: foregroundColor, + .paragraphStyle: paragraphStyle, + ] + if isStacked { + attributes[.baselineOffset] = Self.stackedBaselineOffset + } + let result = NSMutableAttributedString() + var accessibilityLines: [String] = [] + + for (lineIndex, line) in layout.lines.enumerated() { + if lineIndex > 0 { + result.append(NSAttributedString(string: "\n", attributes: attributes)) + } + var accessibilityParts: [String] = [] + for (tokenIndex, token) in line.enumerated() { + if tokenIndex > 0, token != .space, line[tokenIndex - 1] != .space { + result.append(NSAttributedString(string: "\u{2009}", attributes: attributes)) + } + let renderedItem = Self.renderItem( + token, + data: data, + icon: icon, + style: TokenStyle( + font: font, + foregroundColor: foregroundColor, + iconHeight: Self.iconHeight(size: options.size, isStacked: isStacked), + attributes: attributes), + options: options) + result.append(renderedItem.value) + if let accessibilityText = renderedItem.accessibilityText { + accessibilityParts.append(accessibilityText) + } + } + accessibilityLines.append(accessibilityParts.joined(separator: ", ")) + } + + if options.isDebugApp { + result.append(NSAttributedString(string: " D", attributes: attributes)) + accessibilityLines[accessibilityLines.count - 1].append(", \(L("Debug"))") + } + let accessibilityLabel = accessibilityLines.enumerated().map { index, line in + index == 0 ? line : "\(L("menu_bar_layout_line", index + 1)), \(line)" + }.joined(separator: ", ") + return MenuBarLayoutRenderedTitle( + attributedTitle: result, + accessibilityLabel: accessibilityLabel) + } + + private static func renderItem( + _ item: MenuBarLayoutToken, + data: MenuBarLayoutRenderData, + icon: NSImage?, + style: TokenStyle, + options: MenuBarLayoutRenderOptions) + -> (value: NSAttributedString, accessibilityText: String?) + { + switch item { + case .icon: + guard let icon else { + return self.textToken( + self.missingValue, + accessibilityText: L("Icon unavailable"), + attributes: style.attributes) + } + let attachment = NSTextAttachment() + attachment.image = Self.attachmentImage(icon, tint: style.foregroundColor) + let height = style.iconHeight + let width = icon.size.height > 0 ? icon.size.width * height / icon.size.height : height + attachment.bounds = NSRect( + x: 0, + y: ((style.font.capHeight - height) / 2).rounded(), + width: width, + height: height) + let value = NSMutableAttributedString(attachment: attachment) + value.addAttributes(style.attributes, range: NSRange(location: 0, length: value.length)) + return (value, L("%@ icon", data.providerName ?? L("Provider"))) + case .providerName: + return self.optionalTextToken( + data.providerName, + unavailableLabel: L("Provider name unavailable"), + attributes: style.attributes) + case .accountLabel: + return self.optionalTextToken( + data.accountLabel, + unavailableLabel: L("Account unavailable"), + attributes: style.attributes) + case let .percent(window): + let rateWindow = Self.window(window, data: data) + let percent = rateWindow.map { options.showUsed ? $0.usedPercent : $0.remainingPercent } + let value = percent.map(UsageFormatter.percentString) ?? Self.missingValue + let prefix: String + let accessibilityPrefix: String + switch window { + case .session: + prefix = Self.sessionPrefix(rateWindow) + accessibilityPrefix = L("Session") + case .weekly: + prefix = "W" + accessibilityPrefix = L("Weekly") + case .automatic: + prefix = "" + accessibilityPrefix = L("Usage") + } + let display = prefix.isEmpty ? value : "\(prefix) \(value)" + let accessibility = percent == nil + ? L("%@ unavailable", accessibilityPrefix) + : L("%@ %@", accessibilityPrefix, value) + return self.textToken(display, accessibilityText: accessibility, attributes: style.attributes) + case .usageBar: + guard let window = data.automatic else { + return self.textToken( + self.missingValue, + accessibilityText: L("Usage bar unavailable"), + attributes: style.attributes) + } + let displayedPercent = options.showUsed ? window.usedPercent : window.remainingPercent + let filled = Int((displayedPercent.clamped(to: 0...100) / 100 * 3).rounded()) + let value = String(repeating: "▮", count: filled) + String(repeating: "▯", count: 3 - filled) + return self.textToken( + value, + accessibilityText: L("Usage bar, %d of 3 filled", filled), + attributes: style.attributes) + case .resetCountdown: + return self.resetToken( + data.automatic?.resetsAt.map { UsageFormatter.resetCountdownDescription(from: $0, now: options.now) } + ?? data.automatic?.resetDescription, + unavailableLabel: L("Reset countdown unavailable"), + attributes: style.attributes) + case .resetAbsolute: + return self.resetToken( + data.automatic?.resetsAt.map { UsageFormatter.resetDescription(from: $0, now: options.now) } + ?? data.automatic?.resetDescription, + unavailableLabel: L("Reset time unavailable"), + attributes: style.attributes) + case .runsOut: + return self.optionalTextToken( + data.runsOut, + unavailableLabel: L("Run-out estimate unavailable"), + attributes: style.attributes) + case .costToday: + return self.optionalTextToken( + data.costToday, + unavailableLabel: L("Cost today unavailable"), + attributes: style.attributes) + case .cost30d: + return self.optionalTextToken( + data.cost30d, + unavailableLabel: L("30-day cost unavailable"), + attributes: style.attributes) + case .separatorDot: + return self.textToken("·", accessibilityText: nil, attributes: style.attributes) + case .space: + return self.textToken(" ", accessibilityText: nil, attributes: style.attributes) + } + } + + private static func attachmentImage(_ image: NSImage, tint: NSColor) -> NSImage { + guard image.isTemplate else { return image } + + // NSTextAttachment draws an NSImage directly instead of through an image cell, so AppKit does not + // apply template tinting here. Keep a template image for status-item semantics while drawing its mask + // with the same dynamic foreground color as the surrounding title. + let tintedImage = NSImage(size: image.size, flipped: false) { rect in + image.draw(in: rect) + tint.setFill() + rect.fill(using: .sourceAtop) + return true + } + tintedImage.isTemplate = true + return tintedImage + } + + private static func resetToken( + _ value: String?, + unavailableLabel: String, + attributes: [NSAttributedString.Key: Any]) + -> (value: NSAttributedString, accessibilityText: String?) + { + self.optionalTextToken( + value, + unavailableLabel: unavailableLabel, + accessibilityPrefix: L("Resets"), + attributes: attributes) + } + + private static func optionalTextToken( + _ value: String?, + unavailableLabel: String, + accessibilityPrefix: String? = nil, + attributes: [NSAttributedString.Key: Any]) + -> (value: NSAttributedString, accessibilityText: String?) + { + guard let value, !value.isEmpty else { + return self.textToken(self.missingValue, accessibilityText: unavailableLabel, attributes: attributes) + } + let accessibilityText = accessibilityPrefix.map { "\($0) \(value)" } ?? value + return self.textToken(value, accessibilityText: accessibilityText, attributes: attributes) + } + + private static func textToken( + _ value: String, + accessibilityText: String?, + attributes: [NSAttributedString.Key: Any]) + -> (value: NSAttributedString, accessibilityText: String?) + { + (NSAttributedString(string: value, attributes: attributes), accessibilityText) + } + + private static func window( + _ percentWindow: PercentWindow, + data: MenuBarLayoutRenderData) + -> MenuBarLayoutRenderWindow? + { + switch percentWindow { + case .session: data.session + case .weekly: data.weekly + case .automatic: data.automatic + } + } + + private static func sessionPrefix(_ window: MenuBarLayoutRenderWindow?) -> String { + guard let minutes = window?.windowMinutes, minutes > 0 else { return "S" } + guard minutes.isMultiple(of: 60) else { return "\(minutes)m" } + return "\(minutes / 60)h" + } + + private static func fontSize(size: MenuBarLayoutSize, isStacked: Bool) -> CGFloat { + if isStacked { + return size == .small ? 8 : 9 + } + return size == .small ? 11 : NSFont.systemFontSize + } + + private static func iconHeight(size: MenuBarLayoutSize, isStacked: Bool) -> CGFloat { + if isStacked { + return size == .small ? 8 : 9 + } + return size == .small ? 14 : 16 + } +} diff --git a/Sources/CodexBar/MenuBarMetricWindowResolver.swift b/Sources/CodexBar/MenuBarMetricWindowResolver.swift index 520878d4d7..e99cbd1b36 100644 --- a/Sources/CodexBar/MenuBarMetricWindowResolver.swift +++ b/Sources/CodexBar/MenuBarMetricWindowResolver.swift @@ -12,23 +12,60 @@ enum MenuBarMetricWindowResolver { preference: MenuBarMetricPreference, provider: UsageProvider, snapshot: UsageSnapshot?, - supportsAverage: Bool) + supportsAverage: Bool, + antigravityPrioritizeExhaustedQuotas: Bool = false, + now: Date = Date()) -> RateWindow? { guard let snapshot else { return nil } switch preference { + case .monthlyPlan: + return snapshot.extraRateWindows?.first { $0.id == "mistral-monthly-plan" }?.window case .extraUsage: return Self.extraUsageWindow(snapshot: snapshot) case .tertiary: - return Self.window(in: snapshot, following: Self.tertiaryOrder(for: provider)) + return Self.requestedWindow( + provider: provider, + snapshot: snapshot, + lanes: Self.tertiaryOrder(for: provider)) case .primary: - return Self.window(in: snapshot, following: Self.primaryOrder(for: provider)) + return Self.requestedWindow( + provider: provider, + snapshot: snapshot, + lanes: Self.primaryOrder(for: provider)) case .secondary: - return Self.window(in: snapshot, following: Self.secondaryOrder(for: provider)) + return Self.requestedWindow( + provider: provider, + snapshot: snapshot, + lanes: Self.secondaryOrder(for: provider)) + case .primaryAndSecondary: + // Claude accounts that only expose an enterprise/extra-usage spend limit have no real + // session/weekly lanes; surface the spend limit (as `.automatic` does) instead of an empty + // or 0% placeholder lane. + if provider == .claude, let spendLimit = Self.claudeSpendLimitWindow(snapshot: snapshot) { + return spendLimit + } + return Self.mostConstrainedWindow( + primary: snapshot.primary, + secondary: snapshot.secondary, + tertiary: nil) case .average: return Self.averageWindow(provider: provider, snapshot: snapshot, supportsAverage: supportsAverage) case .automatic: - return Self.automaticWindow(provider: provider, snapshot: snapshot) + return Self.automaticWindow( + provider: provider, + snapshot: snapshot, + antigravityPrioritizeExhaustedQuotas: antigravityPrioritizeExhaustedQuotas, + now: now) + } + } + + static func automaticSelectionPrioritizesExhaustedWindow(for provider: UsageProvider) -> Bool { + switch provider { + case .antigravity, .perplexity, .zai, .copilot, .cursor, .minimax, .claude, .codex: + false + default: + true } } @@ -82,9 +119,27 @@ enum MenuBarMetricWindowResolver { return RateWindow(usedPercent: usedPercent, windowMinutes: nil, resetsAt: nil, resetDescription: nil) } - private static func automaticWindow(provider: UsageProvider, snapshot: UsageSnapshot) -> RateWindow? { + private static func automaticWindow( + provider: UsageProvider, + snapshot: UsageSnapshot, + antigravityPrioritizeExhaustedQuotas: Bool, + now: Date) + -> RateWindow? + { if provider == .antigravity { - return self.window(in: snapshot, following: [.primary, .secondary, .tertiary]) + if antigravityPrioritizeExhaustedQuotas, + let window = antigravityQuotaSummaryRankingWindow(snapshot: snapshot, now: now) + { + return window + } + if let window = mostConstrainedAntigravityQuotaSummaryWindow(snapshot: snapshot) { + return window + } + return self.mostConstrainedWindow( + primary: snapshot.primary, + secondary: snapshot.secondary, + tertiary: snapshot.tertiary) + ?? self.mostConstrainedAntigravityLegacyExtraWindow(snapshot: snapshot) } if provider == .perplexity { return snapshot.automaticPerplexityWindow() @@ -95,7 +150,14 @@ enum MenuBarMetricWindowResolver { secondary: snapshot.tertiary, tertiary: nil) ?? snapshot.secondary } - if provider == .factory || provider == .kimi { + if provider == .factory || provider == .kimi || provider == .litellm { + if let exhausted = exhaustedWindow( + primary: snapshot.primary, + secondary: snapshot.secondary, + tertiary: nil) + { + return exhausted + } return snapshot.secondary ?? snapshot.primary } if provider == .copilot, @@ -105,20 +167,171 @@ enum MenuBarMetricWindowResolver { return primary.usedPercent >= secondary.usedPercent ? primary : secondary } if provider == .cursor { + return Self.mostConstrainedCursorWindow( + total: snapshot.primary, + auto: snapshot.secondary, + api: snapshot.tertiary) + } + if provider == .minimax { return Self.mostConstrainedWindow( primary: snapshot.primary, secondary: snapshot.secondary, tertiary: snapshot.tertiary) } - if provider == .claude, - Self.shouldUseClaudeSpendLimit(providerCost: snapshot.providerCost, snapshot: snapshot), - let extraUsage = Self.extraUsageWindow(snapshot: snapshot) + if provider == .claude, let spendLimit = Self.claudeSpendLimitWindow(snapshot: snapshot) { + return spendLimit + } + if Self.automaticSelectionPrioritizesExhaustedWindow(for: provider), + let exhausted = Self.exhaustedWindow( + primary: snapshot.primary, + secondary: snapshot.secondary, + tertiary: snapshot.tertiary) { - return extraUsage + return exhausted } return snapshot.primary ?? snapshot.secondary } + private static let antigravityQuotaSummaryWindowIDPrefix = "antigravity-quota-summary-" + private static let antigravityCompactFallbackWindowIDPrefix = "antigravity-compact-fallback-" + + private static func mostConstrainedAntigravityQuotaSummaryWindow(snapshot: UsageSnapshot) -> RateWindow? { + let windows = snapshot.extraRateWindows? + .filter { $0.usageKnown && $0.id.hasPrefix(Self.antigravityQuotaSummaryWindowIDPrefix) } + .map(\.window) ?? [] + guard !windows.isEmpty else { return nil } + + let usableWindows = windows.filter { $0.usedPercent < 100 } + if let maxUsable = usableWindows.max(by: { $0.usedPercent < $1.usedPercent }) { + return maxUsable + } + return windows.max(by: { $0.usedPercent < $1.usedPercent }) + } + + /// Picks the binding supported quota-summary lane for the exhausted-first opt-in. + static func antigravityQuotaSummaryRankingWindow( + snapshot: UsageSnapshot, + now: Date) + -> RateWindow? + { + let candidates = Self.antigravityQuotaSummaryRows(snapshot: snapshot) + .filter { + $0.usageKnown && + $0.window.usedPercent.isFinite && + Self.isSupportedAntigravityQuotaCadence($0.window.windowMinutes) + } + return candidates.max { lhs, rhs in + if lhs.window.usedPercent != rhs.window.usedPercent { + return lhs.window.usedPercent < rhs.window.usedPercent + } + + let lhsFutureReset = lhs.window.resetsAt.flatMap { $0 > now ? $0 : nil } + let rhsFutureReset = rhs.window.resetsAt.flatMap { $0 > now ? $0 : nil } + if (lhsFutureReset != nil) != (rhsFutureReset != nil) { + return lhsFutureReset == nil + } + if let lhsFutureReset, let rhsFutureReset, lhsFutureReset != rhsFutureReset { + return lhsFutureReset > rhsFutureReset + } + return lhs.id < rhs.id + }?.window + } + + /// True only when every fully understood quota family has an exhausted binding lane. + /// Any incomplete or unfamiliar summary row fails open so automatic provider rotation + /// does not hide quota that CodexBar cannot classify safely. + static func antigravityQuotaSummaryFamiliesAreAllBlocked(snapshot: UsageSnapshot) -> Bool { + let rows = Self.antigravityQuotaSummaryRows(snapshot: snapshot) + guard !rows.isEmpty else { return false } + + var familyBlocked: [String: Bool] = [:] + for row in rows { + guard row.usageKnown, + row.window.usedPercent.isFinite, + Self.isSupportedAntigravityQuotaCadence(row.window.windowMinutes), + let family = Self.antigravityQuotaFamily(for: row) + else { + return false + } + familyBlocked[family, default: false] = + familyBlocked[family, default: false] || row.window.usedPercent >= 100 + } + return !familyBlocked.isEmpty && familyBlocked.values.allSatisfy(\.self) + } + + private static let antigravitySupportedQuotaCadences: Set = [300, 10080] + + private static func isSupportedAntigravityQuotaCadence(_ windowMinutes: Int?) -> Bool { + guard let windowMinutes else { return false } + return Self.antigravitySupportedQuotaCadences.contains(windowMinutes) + } + + private static func antigravityQuotaSummaryRows(snapshot: UsageSnapshot) -> [NamedRateWindow] { + snapshot.extraRateWindows?.filter { + $0.id.hasPrefix(Self.antigravityQuotaSummaryWindowIDPrefix) + } ?? [] + } + + private static func antigravityQuotaFamily(for row: NamedRateWindow) -> String? { + let suffix = row.id.dropFirst(Self.antigravityQuotaSummaryWindowIDPrefix.count) + var normalizedSuffix = suffix + .lowercased() + .replacingOccurrences(of: "_", with: "-") + if normalizedSuffix.hasSuffix(" limit") { + normalizedSuffix.removeLast(" limit".count) + } + let cadenceSuffixes: [String] + switch row.window.windowMinutes { + case 300: + cadenceSuffixes = ["-session", "-5h", "-5-hour", "-five hour", "-five-hour"] + case 10080: + cadenceSuffixes = ["-weekly"] + default: + return nil + } + + guard let cadenceSuffix = cadenceSuffixes.first(where: normalizedSuffix.hasSuffix) else { + return nil + } + let family = normalizedSuffix + .dropLast(cadenceSuffix.count) + .trimmingCharacters(in: .whitespacesAndNewlines) + guard !family.isEmpty, + family.first != "-", + family.last != "-", + family.allSatisfy({ $0.isLetter || $0.isNumber || $0 == "-" || $0 == "_" || $0 == "." }) + else { + return nil + } + return family + } + + private static func mostConstrainedAntigravityLegacyExtraWindow(snapshot: UsageSnapshot) -> RateWindow? { + let windows = snapshot.extraRateWindows? + .filter { + $0.usageKnown && $0.id.hasPrefix(Self.antigravityCompactFallbackWindowIDPrefix) + } + .map(\.window) ?? [] + guard !windows.isEmpty else { return nil } + + let usableWindows = windows.filter { $0.usedPercent < 100 } + if let maxUsable = usableWindows.max(by: { $0.usedPercent < $1.usedPercent }) { + return maxUsable + } + return windows.max(by: { $0.usedPercent < $1.usedPercent }) + } + + private static func requestedWindow( + provider: UsageProvider, + snapshot: UsageSnapshot, + lanes: [Lane]) -> RateWindow? + { + self.window(in: snapshot, following: lanes) + ?? (provider == .antigravity + ? self.mostConstrainedAntigravityLegacyExtraWindow(snapshot: snapshot) + : nil) + } + private static func window(in snapshot: UsageSnapshot, following lanes: [Lane]) -> RateWindow? { for lane in lanes { if let window = self.window(in: snapshot, lane: lane) { @@ -150,6 +363,48 @@ enum MenuBarMetricWindowResolver { return windows.max(by: { $0.usedPercent < $1.usedPercent }) } + private static func exhaustedWindow( + primary: RateWindow?, + secondary: RateWindow?, + tertiary: RateWindow?) + -> RateWindow? + { + [primary, secondary, tertiary] + .compactMap(\.self) + .first { $0.usedPercent >= 100 } + } + + private static func mostConstrainedCursorWindow( + total: RateWindow?, + auto: RateWindow?, + api: RateWindow?) + -> RateWindow? + { + if let total, total.usedPercent >= 100 { + return total + } + + let subquotaWindows = [auto, api].compactMap(\.self) + let usableSubquotaWindows = subquotaWindows.filter { $0.usedPercent < 100 } + if !subquotaWindows.isEmpty, usableSubquotaWindows.isEmpty { + return subquotaWindows.max(by: { $0.usedPercent < $1.usedPercent }) + } + + return ([total].compactMap(\.self) + usableSubquotaWindows) + .max(by: { $0.usedPercent < $1.usedPercent }) + } + + /// The Claude spend-limit window when the account only exposes an enterprise/extra-usage spend limit + /// and has no real session/weekly quota lanes (`primary` nil, a `.spendLimit` window, or an explicitly + /// marked placeholder). Lets the automatic and combined metrics surface the spend limit instead of an empty + /// or 0% placeholder lane. Returns nil for accounts that expose genuine quota lanes. + static func claudeSpendLimitWindow(snapshot: UsageSnapshot) -> RateWindow? { + guard self.shouldUseClaudeSpendLimit(providerCost: snapshot.providerCost, snapshot: snapshot) else { + return nil + } + return self.extraUsageWindow(snapshot: snapshot) + } + private static func shouldUseClaudeSpendLimit( providerCost: ProviderCostSnapshot?, snapshot: UsageSnapshot) @@ -160,10 +415,7 @@ enum MenuBarMetricWindowResolver { snapshot.tertiary == nil else { return false } guard let primary = snapshot.primary else { return true } - return primary.usedPercent == 0 - && primary.windowMinutes == 5 * 60 - && primary.resetsAt == nil - && primary.resetDescription == nil + return primary.isSyntheticPlaceholder } private static func extraUsageWindow(snapshot: UsageSnapshot?) -> RateWindow? { diff --git a/Sources/CodexBar/MenuBarSeparatorStyle.swift b/Sources/CodexBar/MenuBarSeparatorStyle.swift deleted file mode 100644 index 456c3a6aab..0000000000 --- a/Sources/CodexBar/MenuBarSeparatorStyle.swift +++ /dev/null @@ -1,25 +0,0 @@ -import Foundation - -/// Controls the separator character between percent and pace in the menu bar. -enum MenuBarSeparatorStyle: String, CaseIterable, Identifiable { - case dot - case pipe - - var id: String { - self.rawValue - } - - var separator: String { - switch self { - case .dot: " · " - case .pipe: " | " - } - } - - var label: String { - switch self { - case .dot: "Dot (·)" - case .pipe: "Pipe (|)" - } - } -} diff --git a/Sources/CodexBar/MenuBarStatusItemDefaultsRepair.swift b/Sources/CodexBar/MenuBarStatusItemDefaultsRepair.swift index 6e7c6cafd6..6109e558b7 100644 --- a/Sources/CodexBar/MenuBarStatusItemDefaultsRepair.swift +++ b/Sources/CodexBar/MenuBarStatusItemDefaultsRepair.swift @@ -27,19 +27,28 @@ enum MenuBarStatusItemDefaultsRepair { return itemName.hasPrefix(self.legacyAutosavePrefix) || self.isDefaultStatusItemName(itemName) } + static func visibilityDefault(defaults: UserDefaults, autosaveName: String) -> Bool? { + guard !autosaveName.isEmpty else { return nil } + return self.boolValue(defaults.object(forKey: self.visibilityPrefix + autosaveName)) + } + private static func isDefaultStatusItemName(_ itemName: String) -> Bool { guard itemName.hasPrefix("Item-") else { return false } return itemName.dropFirst("Item-".count).allSatisfy(\.isNumber) } private static func isFalse(_ value: Any?) -> Bool { + self.boolValue(value) == false + } + + private static func boolValue(_ value: Any?) -> Bool? { switch value { case let number as NSNumber: - !number.boolValue + number.boolValue case let bool as Bool: - !bool + bool default: - false + nil } } } diff --git a/Sources/CodexBar/MenuBarStatusItemPlacementPreflight.swift b/Sources/CodexBar/MenuBarStatusItemPlacementPreflight.swift new file mode 100644 index 0000000000..473fabd7eb --- /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 0000000000..51ee404e26 --- /dev/null +++ b/Sources/CodexBar/MenuBarStatusItemWindowProbe.swift @@ -0,0 +1,110 @@ +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 isTahoeBlockedProxy: Bool { + self.ownerName == "Control Center" + && self.isOnscreen + && abs(self.bounds.minX) <= 1 + && self.bounds.maxY <= 0 + && self.bounds.width > 0 + && self.bounds.height > 0 + && !self.isWithinDisplayBounds + } + + 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/MenuBarUsageTint.swift b/Sources/CodexBar/MenuBarUsageTint.swift new file mode 100644 index 0000000000..e46ca4f5ad --- /dev/null +++ b/Sources/CodexBar/MenuBarUsageTint.swift @@ -0,0 +1,41 @@ +import AppKit +import Foundation + +/// Maps how much of a quota is used to a menu bar icon tint. +/// +/// The colors are fixed sRGB rather than the dynamic `NSColor.system*` palette. A tinted icon is baked into a +/// non-template bitmap (see `IconRenderer.makeIcon(tint:)`), so any dynamic color would be frozen at render time +/// and go stale on a light/dark switch. Fixed components keep the render a pure function of its inputs, which is +/// also what lets the icon cache key hash the tint safely. +/// +/// The values are mid-luminance and high-chroma so they stay legible against a light menu bar, a dark menu bar, +/// and a translucent one over an arbitrary wallpaper. `NSColor.systemGreen` in particular is far too light on +/// white. Color is never the only signal: the bar fill length already encodes the same value. +enum MenuBarUsageTint { + /// Comfortably inside the quota. + private static let low = NSColor(srgbRed: 0.14, green: 0.60, blue: 0.25, alpha: 1) + /// Approaching the limit. + private static let medium = NSColor(srgbRed: 0.85, green: 0.48, blue: 0.02, alpha: 1) + /// At or past the point where the window is likely to run out. + private static let high = NSColor(srgbRed: 0.80, green: 0.13, blue: 0.13, alpha: 1) + + private static let mediumThreshold: Double = 70 + private static let highThreshold: Double = 90 + + /// - Parameter usedPercent: Percentage of the window consumed, or `nil` when usage is unknown. + /// - Returns: The tint to draw the icon in, or `nil` to leave the icon as an untinted template. + static func color(forUsedPercent usedPercent: Double?) -> NSColor? { + guard let usedPercent else { return nil } + let clamped = min(max(usedPercent, 0), 100) + + if clamped < Self.mediumThreshold { + let fraction = clamped / Self.mediumThreshold + return Self.low.blended(withFraction: fraction, of: Self.medium) ?? Self.low + } + if clamped < Self.highThreshold { + let fraction = (clamped - Self.mediumThreshold) / (Self.highThreshold - Self.mediumThreshold) + return Self.medium.blended(withFraction: fraction, of: Self.high) ?? Self.medium + } + return Self.high + } +} diff --git a/Sources/CodexBar/MenuBarVisibilityWatcher.swift b/Sources/CodexBar/MenuBarVisibilityWatcher.swift index ec4055e295..767f3e092e 100644 --- a/Sources/CodexBar/MenuBarVisibilityWatcher.swift +++ b/Sources/CodexBar/MenuBarVisibilityWatcher.swift @@ -34,6 +34,18 @@ extension StatusItemVisibilitySnapshot: CustomStringConvertible { } } +struct StatusItemStartupVisibilityEvidence: Equatable, CustomStringConvertible { + let autosaveName: String + let expectsVisibility: Bool + let visibilityDefault: Bool? + let snapshot: StatusItemVisibilitySnapshot + + var description: String { + "name=\(self.autosaveName),expected=\(self.expectsVisibility)," + + "default=\(self.visibilityDefault.map(String.init) ?? "unset"),\(self.snapshot)" + } +} + @MainActor func isStatusItemBlocked(_ item: NSStatusItem) -> Bool { MenuBarVisibilityWatcher.isBlockedSnapshot(snapshot: MenuBarVisibilityWatcher.visibilitySnapshot(item)) @@ -111,6 +123,48 @@ enum MenuBarVisibilityWatcher { } } + static func hasAnyStartupRecoveryCandidate( + snapshots: [StatusItemVisibilitySnapshot], + evidence: [StatusItemStartupVisibilityEvidence] = [], + windowSnapshots: [MenuBarStatusItemWindowSnapshot] = [], + detectTahoeBlockedStatusItem: Bool = false) + -> Bool + { + if self.hasAnyBlockedVisibleSnapshot(snapshots) { + return true + } + if detectTahoeBlockedStatusItem, + self.hasAnyTahoeHiddenNoProxyCandidate(evidence: evidence, windowSnapshots: windowSnapshots) + { + return true + } + guard detectTahoeBlockedStatusItem, + self.hasAnyDisplacedVisibleSnapshot(snapshots), + windowSnapshots.contains(where: \.isTahoeBlockedProxy) + else { + return false + } + return true + } + + static func hasAnyTahoeHiddenNoProxyCandidate( + evidence: [StatusItemStartupVisibilityEvidence], + windowSnapshots: [MenuBarStatusItemWindowSnapshot]) + -> Bool + { + evidence.contains { item in + // Tahoe can destroy the Control Center scene while leaving its enabled default behind. + // Requiring both app intent and that default avoids treating ordinary hidden items as blocked. + item.expectsVisibility + && item.visibilityDefault == true + && !item.snapshot.isVisible + && !item.snapshot.hasWindow + && !windowSnapshots.contains { + $0.name == item.autosaveName && $0.isOnscreen && $0.isWithinDisplayBounds + } + } + } + @MainActor static func visibilitySnapshots(_ items: [NSStatusItem]) -> [StatusItemVisibilitySnapshot] { items.map { item in @@ -126,11 +180,18 @@ enum MenuBarVisibilityWatcher { static func shouldAttemptStartupRecovery( appLaunchedAt: Date, now: Date = Date(), - snapshots: [StatusItemVisibilitySnapshot]) + snapshots: [StatusItemVisibilitySnapshot], + evidence: [StatusItemStartupVisibilityEvidence] = [], + windowSnapshots: [MenuBarStatusItemWindowSnapshot] = [], + detectTahoeBlockedStatusItem: Bool = false) -> Bool { guard now.timeIntervalSince(appLaunchedAt) <= self.startupFreshnessInterval else { return false } - return self.hasAnyBlockedVisibleSnapshot(snapshots) + return self.hasAnyStartupRecoveryCandidate( + snapshots: snapshots, + evidence: evidence, + windowSnapshots: windowSnapshots, + detectTahoeBlockedStatusItem: detectTahoeBlockedStatusItem) } static func shouldRefreshScreenChangePlacement( @@ -192,25 +253,39 @@ extension StatusItemController { } private func checkStartupStatusItemVisibility(appLaunchedAt: Date, now: Date = Date()) { - let snapshots = MenuBarVisibilityWatcher.visibilitySnapshots(self.startupVisibilityStatusItems) + let evidence = self.startupStatusItemVisibilityEvidence() + let snapshots = evidence.map(\.snapshot) + let windowSnapshots = self.statusItemWindowSnapshots() guard MenuBarVisibilityWatcher.shouldAttemptStartupRecovery( appLaunchedAt: appLaunchedAt, now: now, - snapshots: snapshots) + snapshots: snapshots, + evidence: evidence, + windowSnapshots: windowSnapshots, + detectTahoeBlockedStatusItem: self.canDetectTahoeBlockedStatusItem) else { return } self.menuLogger.error( - "Status item failed to materialize; recreating status items", - metadata: ["snapshots": snapshots.map(\.description).joined(separator: " | ")]) + "Status item failed to materialize or remained detached; recreating status items", + metadata: [ + "snapshots": snapshots.map(\.description).joined(separator: " | "), + "evidence": evidence.map(\.description).joined(separator: " | "), + "windows": self.statusItemWindowDiagnosticsDescription(windowSnapshots), + ]) self.recreateStatusItemsForVisibilityRecovery() - let recoveredSnapshots = MenuBarVisibilityWatcher.visibilitySnapshots(self.startupVisibilityStatusItems) + let recoveredEvidence = self.startupStatusItemVisibilityEvidence() + let recoveredSnapshots = recoveredEvidence.map(\.snapshot) + let recoveredWindowSnapshots = self.statusItemWindowSnapshots() guard MenuBarVisibilityWatcher.shouldAttemptStartupRecovery( appLaunchedAt: appLaunchedAt, now: now, - snapshots: recoveredSnapshots) + snapshots: recoveredSnapshots, + evidence: recoveredEvidence, + windowSnapshots: recoveredWindowSnapshots, + detectTahoeBlockedStatusItem: self.canDetectTahoeBlockedStatusItem) else { self.menuLogger.info( "Status item materialized after recreation", @@ -219,8 +294,12 @@ extension StatusItemController { } self.menuLogger.error( - "Status item still failed to materialize after recreation", - metadata: ["snapshots": recoveredSnapshots.map(\.description).joined(separator: " | ")]) + "Status item still unavailable after recreation", + metadata: [ + "snapshots": recoveredSnapshots.map(\.description).joined(separator: " | "), + "evidence": recoveredEvidence.map(\.description).joined(separator: " | "), + "windows": self.statusItemWindowDiagnosticsDescription(recoveredWindowSnapshots), + ]) guard #available(macOS 26.0, *), MenuBarVisibilityWatcher.shouldShowGuidance(defaults: self.settings.userDefaults, now: now) else { @@ -272,6 +351,7 @@ extension StatusItemController { "currentScreenCount": "\(settledCurrentScreenCount)", "capturedScreenCount": "\(currentScreenCount)", "snapshots": snapshots.map(\.description).joined(separator: " | "), + "windows": self.statusItemWindowDiagnosticsDescription(), ]) self.recreateStatusItemsForVisibilityRecovery() self.schedulePostScreenChangeRecoveryVerification(attempt: 1) @@ -322,6 +402,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 +412,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 +425,40 @@ extension StatusItemController { private var startupVisibilityStatusItems: [NSStatusItem] { [self.statusItem] + Array(self.statusItems.values) } + + private func startupStatusItemVisibilityEvidence() -> [StatusItemStartupVisibilityEvidence] { + self.startupVisibilityStatusItems.map { item in + let autosaveName = item.autosaveName ?? "" + return StatusItemStartupVisibilityEvidence( + autosaveName: autosaveName, + expectsVisibility: self.expectedVisibleStatusItemAutosaveNames.contains(autosaveName), + visibilityDefault: MenuBarStatusItemDefaultsRepair.visibilityDefault( + defaults: self.settings.userDefaults, + autosaveName: autosaveName), + snapshot: MenuBarVisibilityWatcher.visibilitySnapshot(item)) + } + } + + private var canDetectTahoeBlockedStatusItem: Bool { + if #available(macOS 26.0, *) { + return true + } + return false + } + + private func statusItemWindowSnapshots() -> [MenuBarStatusItemWindowSnapshot] { + let names = Set(self.startupVisibilityStatusItems.compactMap { item in + item.autosaveName.isEmpty ? nil : item.autosaveName + }) + return MenuBarStatusItemWindowProbe.snapshots(matching: names) + } + + private func statusItemWindowDiagnosticsDescription( + _ snapshots: [MenuBarStatusItemWindowSnapshot]? = nil) + -> String + { + let snapshots = snapshots ?? self.statusItemWindowSnapshots() + guard !snapshots.isEmpty else { return "none" } + return snapshots.map(\.description).joined(separator: " | ") + } } diff --git a/Sources/CodexBar/MenuCardGPUSelectionView.swift b/Sources/CodexBar/MenuCardGPUSelectionView.swift new file mode 100644 index 0000000000..e021993deb --- /dev/null +++ b/Sources/CodexBar/MenuCardGPUSelectionView.swift @@ -0,0 +1,310 @@ +import AppKit +import SwiftUI + +/// Hosts a menu-card SwiftUI row whose selection highlight is rendered entirely by AppKit/Core +/// Animation instead of SwiftUI, so moving the highlight while scrolling costs no SwiftUI body +/// re-evaluation or content re-rasterization. +/// +/// The reported Overview scroll stutter comes from driving the native selection look through SwiftUI: +/// each scroll step flips `menuItemHighlighted`, which re-renders the entire rich row subtree +/// (header, usage bars, storage line). A headless benchmark measured ~3–10 ms per toggle with +/// spikes past one 120 Hz frame, matching the dropped frames in the bug report. +/// +/// This view keeps the SwiftUI content pinned to its normal (unselected) appearance and recreates +/// the selected look in two GPU-composited steps that never touch the SwiftUI graph: +/// 1. an `NSVisualEffectView` with the native `.selection` material drawn behind the content, and +/// 2. a `CIColorMatrix` content filter that maps the row's pixels to the selected text color — +/// this matches the existing design, where every element already becomes +/// `selectedMenuItemTextColor` when highlighted. +/// Toggling selection then costs a layer property change (~0.05 ms) rather than a SwiftUI pass. +@MainActor +final class GPUSelectionHostingView: NSView, MenuCardHighlighting, MenuCardMeasuring { + private let hosting: NSHostingView> + private let selectionView = NSVisualEffectView() + private var tintFilter: CIFilter? + private var isRowHighlighted = false + private var onClick: (() -> Void)? + private let containsInteractiveControls: Bool + private let interactiveRegionStore: MenuCardInteractiveRegionStore? + + private(set) var allowsMenuHighlight: Bool + + /// Selection inset/radius mirror the SwiftUI `MenuCardSectionContainerView` highlight + /// (`.padding(.horizontal, 6).padding(.vertical, 2)` with a 6 pt corner radius) so the AppKit + /// background lands in the same place the SwiftUI one used to. + private static var selectionHorizontalInset: CGFloat { + 6 + } + + private static var selectionVerticalInset: CGFloat { + 2 + } + + private static var selectionCornerRadius: CGFloat { + 6 + } + + /// Short enough that a fast flick still looks crisp, long enough to read as a glide rather than + /// a hard cut. Tunable from real-device recordings. + private static var selectionFadeDuration: CFTimeInterval { + 0.06 + } + + init( + rootView: MenuCardSectionContainerView, + allowsMenuHighlight: Bool, + containsInteractiveControls: Bool = false, + interactiveRegionStore: MenuCardInteractiveRegionStore? = nil, + onClick: (() -> Void)?) + { + self.hosting = NSHostingView(rootView: rootView) + self.allowsMenuHighlight = allowsMenuHighlight + self.containsInteractiveControls = containsInteractiveControls + self.interactiveRegionStore = interactiveRegionStore + self.onClick = onClick + self.tintFilter = nil + super.init(frame: .zero) + self.wantsLayer = true + self.refreshTintFilter() + self.setupSelectionView() + self.setupHosting() + } + + @available(*, unavailable) + required init?(coder _: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override var allowsVibrancy: Bool { + true + } + + override var intrinsicContentSize: NSSize { + NSSize(width: self.frame.width, height: self.hosting.intrinsicContentSize.height) + } + + override func acceptsFirstMouse(for _: NSEvent?) -> Bool { + true + } + + override func viewDidChangeEffectiveAppearance() { + super.viewDidChangeEffectiveAppearance() + self.refreshTintFilter() + } + + /// Forward accessibility activation to the click handler, mirroring `MenuCardItemHostingView`. + override func accessibilityRole() -> NSAccessibility.Role? { + self.onClick == nil ? super.accessibilityRole() : .button + } + + override func accessibilityPerformPress() -> Bool { + guard let onClick = self.onClick else { + return super.accessibilityPerformPress() + } + onClick() + return true + } + + override func hitTest(_ point: NSPoint) -> NSView? { + let descendant = super.hitTest(point) + if let descendant { + var current: NSView? = descendant + while let view = current, view !== self { + if view is NSButton || view is NSControl { + return descendant + } + current = view.superview + } + if self.hitsHostedInteractiveControl(at: point) { + return descendant + } + if descendant !== self, self.onClick != nil { + return self + } + } + return descendant + } + + private func hitsHostedInteractiveControl(at point: NSPoint) -> Bool { + guard self.containsInteractiveControls else { return false } + let hostedPoint = self.hosting.convert(point, from: self) + return self.interactiveRegionStore?.contains( + hostedPoint, + hostingBounds: self.hosting.bounds, + fittedSize: self.hosting.fittingSize) == true + } + + private func locationInView(for event: NSEvent) -> NSPoint { + guard self.window != nil else { + return event.locationInWindow + } + return self.convert(event.locationInWindow, from: nil) + } + + override func mouseDown(with event: NSEvent) { + guard event.type == .leftMouseDown, self.onClick != nil else { + super.mouseDown(with: event) + return + } + guard self.bounds.contains(self.locationInView(for: event)), let window = self.window else { return } + + // A submenu-backed NSMenuItem consumes mouseUp in its nested tracking loop before a custom + // view receives it. Track the drag/up sequence directly so release-inside cancellation stays + // native while the menu never gets a chance to close before the row action runs. + var shouldInvoke = false + window.trackEvents( + matching: [.leftMouseDragged, .leftMouseUp], + timeout: NSEvent.foreverDuration, + mode: .eventTracking) + { [weak self] trackedEvent, stop in + guard let self, let trackedEvent else { + stop.pointee = true + return + } + if self.primaryPressShouldYieldToMenu(for: trackedEvent) { + // We dequeued this drag from the window; put it back so NSMenu's tracking loop can + // continue native drag-to-submenu selection from the same event. + window.postEvent(trackedEvent, atStart: true) + stop.pointee = true + return + } + guard let decision = self.primaryPressDecision(for: trackedEvent) else { return } + shouldInvoke = decision + stop.pointee = true + } + if shouldInvoke { + self.onClick?() + } + } + + private func primaryPressDecision(for event: NSEvent) -> Bool? { + guard event.type == .leftMouseUp else { return nil } + return self.bounds.contains(self.locationInView(for: event)) + } + + private func primaryPressShouldYieldToMenu(for event: NSEvent) -> Bool { + event.type == .leftMouseDragged && !self.bounds.contains(self.locationInView(for: event)) + } + + override func layout() { + super.layout() + self.selectionView.frame = self.bounds.insetBy( + dx: Self.selectionHorizontalInset, + dy: Self.selectionVerticalInset) + self.selectionView.layer?.cornerRadius = Self.selectionCornerRadius + self.hosting.frame = self.bounds + } + + func setHighlighted(_ highlighted: Bool) { + guard self.isRowHighlighted != highlighted else { return } + self.isRowHighlighted = highlighted + // Tint the content to the selected text color via a GPU color matrix; clearing the + // filter returns it to its normal palette. No SwiftUI invalidation happens here. + if let tintFilter { + self.hosting.layer?.filters = highlighted ? [tintFilter] : [] + } + // Crossfade the selection background instead of hard-cutting it. As the wheel moves the + // highlight, the leaving row fades out while the arriving row fades in, which reads as the + // selection gliding between rows rather than teleporting. The fade is short so fast flicks + // still resolve crisply. Runs entirely on the GPU via Core Animation. + let layer = self.selectionView.layer + let fade = CABasicAnimation(keyPath: "opacity") + fade.fromValue = layer?.presentation()?.opacity ?? (highlighted ? 0 : 1) + fade.toValue = highlighted ? 1 : 0 + fade.duration = Self.selectionFadeDuration + fade.timingFunction = CAMediaTimingFunction(name: .easeOut) + layer?.add(fade, forKey: "selectionFade") + layer?.opacity = highlighted ? 1 : 0 + } + + func measuredHeight(width: CGFloat) -> CGFloat { + self.hosting.frame = NSRect(origin: self.hosting.frame.origin, size: NSSize(width: width, height: 1)) + self.hosting.layoutSubtreeIfNeeded() + return self.hosting.fittingSize.height + } + + #if DEBUG + /// True once the menu marks this row highlighted via `setHighlighted`. + var isHighlightedForTesting: Bool { + self.isRowHighlighted + } + + /// The hosted SwiftUI highlight state, which must stay `false` for GPU-selected rows — proving + /// selection never re-invalidates the SwiftUI graph while scrolling. + var swiftUIHighlightStateIsHighlightedForTesting: Bool { + self.hosting.rootView.highlightState.isHighlighted + } + #endif + + private func setupSelectionView() { + self.selectionView.material = .selection + self.selectionView.blendingMode = .withinWindow + self.selectionView.state = .active + self.selectionView.isEmphasized = true + self.selectionView.wantsLayer = true + self.selectionView.layer?.masksToBounds = true + // Visibility is driven by layer opacity (crossfaded in `setHighlighted`) rather than + // `isHidden`, so the selection can glide in and out instead of hard-cutting. + self.selectionView.layer?.opacity = 0 + self.selectionView.autoresizingMask = [.width, .height] + self.addSubview(self.selectionView) + } + + private func setupHosting() { + self.hosting.wantsLayer = true + self.hosting.autoresizingMask = [.width, .height] + self.addSubview(self.hosting) + } + + /// Maps every pixel's RGB to the system selected-menu-item text color while preserving alpha, + /// reproducing the appearance the SwiftUI rows already adopt when highlighted. The bias is read + /// from `NSColor.selectedMenuItemTextColor` rather than hard-coded to white so graphite/ + /// high-contrast/accessibility appearances tint correctly. Core Image runs this on the GPU + /// (Metal), so it composites for free per frame. + private func refreshTintFilter() { + self.tintFilter = Self.makeSelectedTextTintFilter(appearance: self.effectiveAppearance) + if self.isRowHighlighted { + self.hosting.layer?.filters = self.tintFilter.map { [$0] } ?? [] + } + } + + private static func makeSelectedTextTintFilter(appearance: NSAppearance) -> CIFilter? { + guard let filter = CIFilter(name: "CIColorMatrix") else { return nil } + var tint: NSColor = .white + appearance.performAsCurrentDrawingAppearance { + tint = NSColor.selectedMenuItemTextColor.usingColorSpace(.deviceRGB) ?? .white + } + filter.setValue(CIVector(x: 0, y: 0, z: 0, w: 0), forKey: "inputRVector") + filter.setValue(CIVector(x: 0, y: 0, z: 0, w: 0), forKey: "inputGVector") + filter.setValue(CIVector(x: 0, y: 0, z: 0, w: 0), forKey: "inputBVector") + filter.setValue(CIVector(x: 0, y: 0, z: 0, w: 1), forKey: "inputAVector") + filter.setValue( + CIVector(x: tint.redComponent, y: tint.greenComponent, z: tint.blueComponent, w: 0), + forKey: "inputBiasVector") + return filter + } +} + +#if DEBUG +extension GPUSelectionHostingView { + func _test_hitsHostedInteractiveControl(at point: NSPoint) -> Bool { + self.hitsHostedInteractiveControl(at: point) + } + + func _test_simulateRuntimeClick(at point: NSPoint? = nil) -> Bool { + let clickPoint = point ?? NSPoint(x: self.bounds.midX, y: self.bounds.midY) + guard let onClick = self.onClick, self.hitTest(clickPoint) === self else { return false } + guard self.bounds.contains(clickPoint) else { return false } + onClick() + return true + } + + func _test_primaryPressDecision(for event: NSEvent) -> Bool? { + self.primaryPressDecision(for: event) + } + + func _test_primaryPressShouldYieldToMenu(for event: NSEvent) -> Bool { + self.primaryPressShouldYieldToMenu(for: event) + } +} +#endif diff --git a/Sources/CodexBar/MenuCardHeightFingerprint.swift b/Sources/CodexBar/MenuCardHeightFingerprint.swift new file mode 100644 index 0000000000..f2d938887a --- /dev/null +++ b/Sources/CodexBar/MenuCardHeightFingerprint.swift @@ -0,0 +1,184 @@ +import Foundation + +extension UsageMenuCardView.Model { + func heightFingerprint(section: String, additional: [String] = []) -> String { + let notesFingerprint = MenuCardHeightFingerprint.join(self.usageNotes.map { + MenuCardHeightFingerprint.field("note", $0) + }) + return MenuCardHeightFingerprint.join([ + "section=\(section)", + "provider=\(self.provider.rawValue)", + "localization=\(codexBarLocalizationSignature())", + MenuCardHeightFingerprint.field("name", self.providerName), + MenuCardHeightFingerprint.field("email", self.email), + MenuCardHeightFingerprint.field("subtitle", self.subtitleText), + "subtitleStyle=\(self.subtitleStyle.heightFingerprint)", + MenuCardHeightFingerprint.field("plan", self.planText), + MenuCardHeightFingerprint.field("placeholder", self.placeholder), + MenuCardHeightFingerprint.field("credits", self.creditsText), + "creditsRemaining=\(self.creditsRemaining.map(String.init(describing:)) ?? "nil")", + MenuCardHeightFingerprint.field("creditsHint", self.creditsHintText), + MenuCardHeightFingerprint.field("creditsCopy", self.creditsHintCopyText), + "codexResetCredits=\(self.codexResetCredits?.heightFingerprint ?? "")", + "metrics=\(MenuCardHeightFingerprint.join(self.metrics.map(\.heightFingerprint)))", + "notes=\(notesFingerprint)", + "dashboard=\(self.inlineUsageDashboard?.heightFingerprint ?? "")", + "providerCost=\(self.providerCost?.heightFingerprint ?? "")", + "tokenUsage=\(self.tokenUsage?.heightFingerprint ?? "")", + "openaiAPI=\(self.openAIAPIUsage == nil ? "0" : "1")", + ] + additional) + } + + static func heightFingerprintField(_ name: String, _ value: String?) -> String { + MenuCardHeightFingerprint.field(name, value) + } +} + +private enum MenuCardHeightFingerprint { + private static let hashSalt = UUID() + + static func join(_ values: [String]) -> String { + values.map { "\($0.count):\($0)" }.joined(separator: "|") + } + + static func field(_ name: String, _ value: String?) -> String { + guard let value else { + return "\(name)=nil" + } + return "\(name)=\(Self.stringShape(value))" + } + + private static func stringShape(_ value: String) -> String { + var hasher = Hasher() + hasher.combine(Self.hashSalt) + hasher.combine(value) + let digest = String(UInt(bitPattern: hasher.finalize()), radix: 16) + return "chars:\(value.count),utf8:\(value.utf8.count),lines:\(Self.lineCount(value)),hash:\(digest)" + } + + private static func lineCount(_ value: String) -> Int { + guard !value.isEmpty else { return 0 } + return value.utf8.reduce(1) { count, byte in + byte == 10 ? count + 1 : count + } + } +} + +extension UsageMenuCardView.Model.SubtitleStyle { + fileprivate var heightFingerprint: String { + switch self { + case .info: "info" + case .loading: "loading" + case .error: "error" + } + } +} + +extension UsageMenuCardView.Model.Metric { + fileprivate var heightFingerprint: String { + MenuCardHeightFingerprint.join([ + self.id, + MenuCardHeightFingerprint.field("title", self.title), + "percent=\(Int(self.percent.rounded()))", + "percentStyle=\(self.percentStyle.rawValue)", + MenuCardHeightFingerprint.field("status", self.statusText), + MenuCardHeightFingerprint.field("reset", self.resetText), + MenuCardHeightFingerprint.field("detail", self.detailText), + MenuCardHeightFingerprint.field("detailLeft", self.detailLeftText), + MenuCardHeightFingerprint.field("detailRight", self.detailRightText), + MenuCardHeightFingerprint.field( + "sessionEquivalentLeft", + self.sessionEquivalentDetail?.leftText), + MenuCardHeightFingerprint.field( + "sessionEquivalentRight", + self.sessionEquivalentDetail?.rightText), + self.pacePercent == nil ? "pace=0" : "pace=1", + self.paceOnTop ? "paceTop=1" : "paceTop=0", + self.cardStyle ? "card=1" : "card=0", + "warningMarkers=\(self.warningMarkerPercents.count)", + "workdayMarkers=\(self.workdayMarkerPercents.count)", + ]) + } +} + +extension UsageMenuCardView.Model.ProviderCostSection { + fileprivate var heightFingerprint: String { + MenuCardHeightFingerprint.join([ + MenuCardHeightFingerprint.field("title", self.title), + MenuCardHeightFingerprint.field("spend", self.spendLine), + MenuCardHeightFingerprint.field("percentLine", self.percentLine), + MenuCardHeightFingerprint.field("personalSpend", self.personalSpendLine), + self.percentUsed == nil ? "percent=0" : "percent=1", + ]) + } +} + +extension UsageMenuCardView.Model.TokenUsageSection { + fileprivate var heightFingerprint: String { + MenuCardHeightFingerprint.join([ + MenuCardHeightFingerprint.field("session", self.sessionLine), + MenuCardHeightFingerprint.field("month", self.monthLine), + MenuCardHeightFingerprint.field("metered", self.meteredLine), + MenuCardHeightFingerprint.field("comparisons", self.comparisonLines.joined(separator: "|")), + MenuCardHeightFingerprint.field("hint", self.hintLine), + MenuCardHeightFingerprint.field("error", self.errorLine), + MenuCardHeightFingerprint.field("errorCopy", self.errorCopyText), + ]) + } +} + +extension CodexResetCreditsPresentation { + fileprivate var heightFingerprint: String { + MenuCardHeightFingerprint.join([ + MenuCardHeightFingerprint.field("text", self.text), + MenuCardHeightFingerprint.field("expirySummary", self.expirySummaryText), + ]) + } +} + +extension InlineUsageDashboardModel { + fileprivate var heightFingerprint: String { + MenuCardHeightFingerprint.join([ + MenuCardHeightFingerprint.field("accessibility", self.accessibilityLabel), + self.valueStyle.heightFingerprint, + MenuCardHeightFingerprint.join(self.kpis.map(\.heightFingerprint)), + MenuCardHeightFingerprint.join(self.points.map(\.heightFingerprint)), + MenuCardHeightFingerprint.join(self.detailLines.map { MenuCardHeightFingerprint.field("detail", $0) }), + ]) + } +} + +extension InlineUsageDashboardModel.KPI { + fileprivate var heightFingerprint: String { + MenuCardHeightFingerprint.join([ + MenuCardHeightFingerprint.field("title", self.title), + MenuCardHeightFingerprint.field("value", self.value), + self.emphasis ? "1" : "0", + ]) + } +} + +extension InlineUsageDashboardModel.Point { + fileprivate var heightFingerprint: String { + MenuCardHeightFingerprint.join([ + self.id, + MenuCardHeightFingerprint.field("label", self.label), + MenuCardHeightFingerprint.field("accessibilityValue", self.accessibilityValue), + ]) + } +} + +extension InlineUsageDashboardModel.ValueStyle { + fileprivate var heightFingerprint: String { + switch self { + case .currencyUSD: + "currencyUSD" + case let .currency(symbol): + "currency:\(symbol)" + case .tokens: + "tokens" + case .points: + "points" + } + } +} diff --git a/Sources/CodexBar/MenuCardQuotaWarningMarkers.swift b/Sources/CodexBar/MenuCardQuotaWarningMarkers.swift index abe15e2879..c5f0c18529 100644 --- a/Sources/CodexBar/MenuCardQuotaWarningMarkers.swift +++ b/Sources/CodexBar/MenuCardQuotaWarningMarkers.swift @@ -18,56 +18,6 @@ extension UsageMenuCardView.Model { .map { showUsed ? 100 - Double($0) : Double($0) } .filter { $0 > 0 && $0 < 100 } } - - /// Merges quota warning markers with optional work-day boundary markers. - /// Preserves original warning-marker ordering when workdayMarkers is empty, - /// sorts the combined set when workday markers are present. - static func mergedMarkerPercents( - warningMarkers: [Double], - workdayMarkers: [Double]) -> [Double] - { - let combined = warningMarkers + workdayMarkers - return workdayMarkers.isEmpty ? combined : combined.sorted() - } - - /// Combines quota warning markers with optional work-day boundary markers - /// into a single sorted array. Workday markers are only applied when - /// includeWorkdayMarkers is true and windowMinutes == 10080. - static func markerPercents( - thresholds: [Int]?, - showUsed: Bool, - workDays: Int?, - windowMinutes: Int?, - includeWorkdayMarkers: Bool) -> [Double] - { - let warningMarkers = Self.warningMarkerPercents(thresholds: thresholds, showUsed: showUsed) - let workdayMarkers = includeWorkdayMarkers - ? workDayMarkerPercents(workDays: workDays, windowMinutes: windowMinutes) - : [] - return Self.mergedMarkerPercents(warningMarkers: warningMarkers, workdayMarkers: workdayMarkers) - } - - static func weeklyMarkerPercents(input: Input, windowMinutes: Int?) -> [Double] { - UsageMenuCardView.Model.markerPercents( - thresholds: input.quotaWarningThresholds[.weekly], - showUsed: input.usageBarsShowUsed, - workDays: input.workDaysPerWeek, - windowMinutes: windowMinutes, - includeWorkdayMarkers: true) - } - - static func codexLaneMarkerPercents( - input: Input, - lane: CodexConsumerProjection.RateLane, - windowMinutes: Int?) -> [Double] - { - UsageMenuCardView.Model.markerPercents( - thresholds: input.quotaWarningThresholds[lane.quotaWarningWindow], - showUsed: input.usageBarsShowUsed, - workDays: input.workDaysPerWeek, - windowMinutes: windowMinutes, - includeWorkdayMarkers: lane == .weekly) - } } /// Returns boundary percentages for work day markers on a weekly progress bar. diff --git a/Sources/CodexBar/MenuCardRefreshMonitor.swift b/Sources/CodexBar/MenuCardRefreshMonitor.swift new file mode 100644 index 0000000000..ec5a3811c8 --- /dev/null +++ b/Sources/CodexBar/MenuCardRefreshMonitor.swift @@ -0,0 +1,111 @@ +import CodexBarCore +import Observation + +struct MenuCardLiveSubtitle { + let text: String + let style: UsageMenuCardView.Model.SubtitleStyle +} + +/// Updates values in an already-hosted card without rebuilding its tracked NSMenu. +@MainActor +@Observable +final class MenuCardRefreshMonitor { + typealias ModelResolver = @MainActor (UsageProvider) -> UsageMenuCardView.Model? + typealias ProviderRefreshStateResolver = @MainActor (UsageProvider) -> Bool + + private let resolveModel: ModelResolver + private let isProviderRefreshActive: ProviderRefreshStateResolver + /// Set while an all-providers refresh is running; individual cards freeze only while their + /// provider has active refresh work. + private var globalManualRefreshInFlight = false + /// Providers with an individual manual refresh in flight. Concurrent entries are allowed so + /// refreshing one provider does not stall or unfreeze another. + private var manualRefreshProviders: Set = [] + private var frozenManualRefreshModels: [UsageProvider: UsageMenuCardView.Model] = [:] + + /// True while any manual refresh (global or per-provider) is running. + var isManualRefreshInFlight: Bool { + self.globalManualRefreshInFlight || !self.manualRefreshProviders.isEmpty + } + + init( + resolveModel: @escaping ModelResolver, + isProviderRefreshActive: @escaping ProviderRefreshStateResolver) + { + self.resolveModel = resolveModel + self.isProviderRefreshActive = isProviderRefreshActive + } + + func beginManualRefresh( + frozenModels: [UsageProvider: UsageMenuCardView.Model], + provider: UsageProvider? = nil) + { + if let provider { + self.frozenManualRefreshModels[provider] = frozenModels[provider] + self.manualRefreshProviders.insert(provider) + } else { + self.frozenManualRefreshModels = frozenModels + self.globalManualRefreshInFlight = true + } + } + + /// Balances a `beginManualRefresh` with the same `provider` argument (nil ends the global refresh). + func endManualRefresh(for provider: UsageProvider? = nil) { + if let provider { + self.manualRefreshProviders.remove(provider) + self.frozenManualRefreshModels[provider] = nil + } else { + self.globalManualRefreshInFlight = false + self.frozenManualRefreshModels.removeAll(keepingCapacity: true) + } + } + + func resetManualRefresh() { + self.globalManualRefreshInFlight = false + self.manualRefreshProviders.removeAll(keepingCapacity: true) + self.frozenManualRefreshModels.removeAll(keepingCapacity: true) + } + + func isManualRefreshInFlight(for provider: UsageProvider) -> Bool { + self.manualRefreshProviders.contains(provider) || + (self.globalManualRefreshInFlight && self.isProviderRefreshActive(provider)) + } + + func model( + for provider: UsageProvider, + fallback: UsageMenuCardView.Model) -> UsageMenuCardView.Model + { + guard !self.isManualRefreshInFlight(for: provider) else { + guard let frozen = self.frozenManualRefreshModels[provider] else { + return fallback + } + if fallback.hasCompatibleTrackedLayout(with: frozen) { + return frozen + } + // A rebuilding menu may temporarily lose some metric rows, but retained rows and other sections + // must still match the frozen layout. + if fallback.hasCompatibleTrackedMetricSubset(of: frozen) { + return frozen + } + return fallback + } + + guard let resolved = self.resolveModel(provider), + fallback.hasCompatibleTrackedLayout(with: resolved) + else { + return fallback + } + return resolved + } + + func subtitle( + for provider: UsageProvider, + fallback: MenuCardLiveSubtitle) -> MenuCardLiveSubtitle + { + if self.isManualRefreshInFlight(for: provider) { + return MenuCardLiveSubtitle(text: "\(L("Refreshing"))…", style: .loading) + } + guard let model = self.resolveModel(provider) else { return fallback } + return MenuCardLiveSubtitle(text: model.subtitleText, style: model.subtitleStyle) + } +} diff --git a/Sources/CodexBar/MenuCardView+CodexResetCredits.swift b/Sources/CodexBar/MenuCardView+CodexResetCredits.swift new file mode 100644 index 0000000000..fa6b2d4da3 --- /dev/null +++ b/Sources/CodexBar/MenuCardView+CodexResetCredits.swift @@ -0,0 +1,132 @@ +import CodexBarCore +import SwiftUI + +struct CodexResetCreditPresentationItem: Equatable { + let expiryText: String + let compactExpiryText: String +} + +struct CodexResetCreditsPresentation: Equatable { + let text: String + let items: [CodexResetCreditPresentationItem] + + var expirySummaryText: String { + let visibleItems = self.items.prefix(4).map(\.compactExpiryText) + let hiddenCount = self.items.count - visibleItems.count + let suffix = hiddenCount > 0 ? ["+\(hiddenCount)"] : [] + return (visibleItems + suffix).joined(separator: " · ") + } + + var helpText: String { + self.items.enumerated().map { index, item in + "\(index + 1). \(item.expiryText)" + }.joined(separator: "\n") + } + + var accessibilityLabel: String { + [L("Limit Reset Credits"), self.text, self.helpText] + .filter { !$0.isEmpty } + .joined(separator: ", ") + } + + static func make( + snapshot: CodexRateLimitResetCreditsSnapshot, + resetStyle: ResetTimeDisplayStyle, + now: Date) -> CodexResetCreditsPresentation? + { + let inventory = snapshot.availableInventory(at: now) + guard !inventory.credits.isEmpty else { return nil } + let items = inventory.credits.map { credit in + Self.presentationItem(for: credit, resetStyle: resetStyle, now: now) + } + return CodexResetCreditsPresentation( + text: Self.availableText(count: inventory.count), + items: items) + } + + private static func availableText(count: Int) -> String { + count == 1 ? L("1 available") : String(format: L("%d available"), count) + } + + private static func presentationItem( + for credit: CodexRateLimitResetCredit, + resetStyle: ResetTimeDisplayStyle, + now: Date) -> CodexResetCreditPresentationItem + { + guard let expiresAt = credit.expiresAt else { + return CodexResetCreditPresentationItem(expiryText: L("No expiry"), compactExpiryText: L("No expiry")) + } + let formattedTime = Self.formattedTime(expiresAt, resetStyle: resetStyle, now: now) + let compactExpiryText = resetStyle == .countdown && formattedTime.hasPrefix("in ") + ? String(formattedTime.dropFirst(3)) + : formattedTime + return CodexResetCreditPresentationItem( + expiryText: String(format: L("Expires %@"), formattedTime), + compactExpiryText: compactExpiryText) + } + + private static func formattedTime( + _ expiresAt: Date, + resetStyle: ResetTimeDisplayStyle, + now: Date) -> String + { + switch resetStyle { + case .absolute: + return UsageFormatter.resetDescription(from: expiresAt, now: now) + case .countdown: + let countdown = UsageFormatter.resetCountdownDescription(from: expiresAt, now: now) + return countdown == "now" ? L("now") : countdown + } + } +} + +struct CodexResetCreditsContent: View { + let presentation: CodexResetCreditsPresentation + @Environment(\.menuItemHighlighted) private var isHighlighted + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + Text(L("Limit Reset Credits")) + .font(.body) + .fontWeight(.medium) + .lineLimit(1) + HStack(alignment: .firstTextBaseline, spacing: 8) { + Text(self.presentation.text) + .font(.footnote.weight(.semibold)) + .foregroundStyle(MenuHighlightStyle.primary(self.isHighlighted)) + .lineLimit(1) + .layoutPriority(1) + Spacer(minLength: 8) + HStack(alignment: .firstTextBaseline, spacing: 4) { + Image(systemName: "clock") + .font(.caption2) + Text(self.presentation.expirySummaryText) + .font(.caption) + .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted)) + .lineLimit(1) + .minimumScaleFactor(0.8) + } + .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted)) + .accessibilityHidden(true) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .help(self.presentation.helpText) + .accessibilityElement(children: .combine) + .accessibilityLabel(self.presentation.accessibilityLabel) + } +} + +extension UsageMenuCardView.Model { + static func codexResetCredits(input: Input) -> CodexResetCreditsPresentation? { + guard input.provider == .codex, + let resetCredits = input.snapshot?.codexResetCredits + else { + return nil + } + return CodexResetCreditsPresentation.make( + snapshot: resetCredits, + resetStyle: input.resetTimeDisplayStyle, + now: input.now) + } +} diff --git a/Sources/CodexBar/MenuCardView+Costs.swift b/Sources/CodexBar/MenuCardView+Costs.swift index 37973a99ab..33921e7856 100644 --- a/Sources/CodexBar/MenuCardView+Costs.swift +++ b/Sources/CodexBar/MenuCardView+Costs.swift @@ -1,7 +1,117 @@ import CodexBarCore import Foundation +import SwiftUI + +struct ProviderCostContent: View { + let section: UsageMenuCardView.Model.ProviderCostSection + let progressColor: Color + @Environment(\.menuItemHighlighted) private var isHighlighted + + var body: some View { + if self.section.presentation == .inlineValue { + HStack(alignment: .firstTextBaseline) { + Text(self.section.title) + .font(.body) + .fontWeight(.medium) + Spacer() + Text(self.section.spendLine) + .font(.footnote) + .monospacedDigit() + .lineLimit(1) + } + } else { + VStack(alignment: .leading, spacing: 6) { + HStack(alignment: .firstTextBaseline) { + Text(self.section.title) + .font(.body) + .fontWeight(.medium) + .lineLimit(1) + .truncationMode(.tail) + if let balanceLine = self.section.balanceLine { + Spacer(minLength: 8) + Text(balanceLine) + .font(.footnote) + .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted)) + .monospacedDigit() + .lineLimit(1) + .layoutPriority(1) + } + } + if let percentUsed = self.section.percentUsed { + UsageProgressBar( + percent: percentUsed, + tint: self.progressColor, + accessibilityLabel: L("Extra usage spent")) + } + HStack(alignment: .firstTextBaseline) { + Text(self.section.spendLine).font(.footnote).lineLimit(1) + Spacer() + if let percentLine = self.section.percentLine { + Text(percentLine) + .font(.footnote) + .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted)) + .lineLimit(1) + } + } + if let personalSpendLine = self.section.personalSpendLine { + Text(personalSpendLine) + .font(.footnote).foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted)).lineLimit(1) + } + } + } + } +} + +extension UsageMenuCardView.Model.ProviderCostSection { + enum Presentation: Equatable { + case detail + case inlineValue + } + + init( + title: String, + percentUsed: Double?, + spendLine: String, + percentLine: String?, + balanceLine: String? = nil, + presentation: Presentation = .detail, + showsInProviderDetails: Bool = true) + { + self.init( + title: title, + percentUsed: percentUsed, + spendLine: spendLine, + percentLine: percentLine, + balanceLine: balanceLine, + personalSpendLine: nil, + presentation: presentation, + showsInProviderDetails: showsInProviderDetails) + } +} extension UsageMenuCardView.Model { + static func sakanaPayAsYouGoSection( + _ usage: SakanaPayAsYouGoSnapshot?, + preferredCurrencyCode: String = "auto") -> ProviderCostSection? + { + guard let usage else { return nil } + return ProviderCostSection( + title: L("Extra usage"), + percentUsed: nil, + spendLine: "\(L("Balance")): \(usage.balanceDetail)", + percentLine: usage.periodUsageTotal.map { value in + let cost = UsageFormatter.convertedCostString( + value, preferredCurrency: preferredCurrencyCode, providerCurrency: "USD") + return "\(L("Usage")): \(cost)" + }) + } + + static func isRequiredOpenCodeZenBalance(_ snapshot: UsageSnapshot?) -> Bool { + snapshot?.primary == nil && + snapshot?.secondary == nil && + snapshot?.providerCost?.period == "Zen balance" + } + static func tokenUsageSnapshot(input: Input) -> CostUsageTokenSnapshot? { if usesProviderCostHistoryAsPrimaryDashboard(input.provider), input.snapshot != nil { return primaryCostHistorySnapshot(input: input) @@ -11,11 +121,23 @@ extension UsageMenuCardView.Model { static func creditsLine( metadata: ProviderMetadata, + snapshot: UsageSnapshot?, credits: CreditsSnapshot?, - error: String?) -> String? + error: String?, + preferredCurrencyCode: String = "auto") -> String? { guard metadata.supportsCredits else { return nil } + if metadata.id == .codex, credits == nil, error == nil { return nil } + if metadata.id == .amp, + let ampUsage = snapshot?.ampUsage, + let ampCredits = self.ampCreditsLine(ampUsage, preferredCurrencyCode: preferredCurrencyCode) + { + return ampCredits + } if let credits { + if let creditLimit = credits.codexCreditLimit { + return UsageFormatter.creditsString(from: creditLimit.remaining) + } return UsageFormatter.creditsString(from: credits.remaining) } if let error, !error.isEmpty { @@ -24,11 +146,51 @@ extension UsageMenuCardView.Model { return L(metadata.creditsHint) } + static func creditsProgressPercent(credits: CreditsSnapshot?) -> Double? { + credits?.codexCreditLimit?.remainingPercent + } + + static func creditsScaleText(credits: CreditsSnapshot?) -> String? { + guard let limit = credits?.codexCreditLimit else { return nil } + return L("of %@", UsageFormatter.creditsNumberString(from: limit.limit)) + } + + static func codexCreditLimitDetail(credits: CreditsSnapshot?, now: Date) -> String? { + guard let limit = credits?.codexCreditLimit else { return nil } + var parts = [ + L("%@ used", UsageFormatter.creditsNumberString(from: limit.used)), + ] + if let resetsAt = limit.resetsAt { + parts.append(L("resets %@", UsageFormatter.resetDescription(from: resetsAt, now: now))) + } + return parts.joined(separator: " · ") + } + + private static func ampCreditsLine( + _ usage: AmpUsageDetails, + preferredCurrencyCode: String = "auto") -> String? + { + var lines: [String] = [] + if let individualCredits = usage.individualCredits { + let cost = UsageFormatter.convertedCostString( + individualCredits, preferredCurrency: preferredCurrencyCode, providerCurrency: "USD") + lines.append("\(L("Individual credits")): \(cost)") + } + lines.append(contentsOf: usage.workspaceBalances.map { workspace in + "\(L("Workspace")) \(workspace.name): " + + UsageFormatter.convertedCostString( + workspace.remaining, preferredCurrency: preferredCurrencyCode, providerCurrency: "USD") + }) + return lines.isEmpty ? nil : lines.joined(separator: "\n") + } + static func tokenUsageSection( provider: UsageProvider, enabled: Bool, + comparisonPeriodsEnabled: Bool, snapshot: CostUsageTokenSnapshot?, - error: String?) -> TokenUsageSection? + error: String?, + preferredCurrencyCode: String = "auto") -> TokenUsageSection? { guard ProviderDescriptorRegistry.descriptor(for: provider).tokenCost.supportsTokenCost else { return nil @@ -37,7 +199,10 @@ extension UsageMenuCardView.Model { guard let snapshot else { return nil } let sessionCost = snapshot.sessionCostUSD.map { - UsageFormatter.currencyString($0, currencyCode: snapshot.currencyCode) + UsageFormatter.convertedCostString( + $0, + preferredCurrency: preferredCurrencyCode, + providerCurrency: snapshot.currencyCode) } ?? "—" let sessionTokens = snapshot.sessionTokens.map { UsageFormatter.tokenCountString($0) } let sessionLabel = if provider == .bedrock || provider == .mistral { @@ -53,43 +218,104 @@ extension UsageMenuCardView.Model { }() let monthCost = snapshot.last30DaysCostUSD.map { - UsageFormatter.currencyString($0, currencyCode: snapshot.currencyCode) + UsageFormatter.convertedCostString( + $0, + preferredCurrency: preferredCurrencyCode, + providerCurrency: snapshot.currencyCode) } ?? "—" let fallbackTokens = snapshot.daily.compactMap(\.totalTokens).reduce(0, +) let monthTokensValue = snapshot.last30DaysTokens ?? (fallbackTokens > 0 ? fallbackTokens : nil) let monthTokens = monthTokensValue.map { UsageFormatter.tokenCountString($0) } - let windowLabel = snapshot.historyLabel ?? Self.costHistoryWindowLabel(days: snapshot.historyDays) + let windowLabel = if let historyLabel = snapshot.historyLabel { + historyLabel + } else if provider == .mistral, + snapshot.historyDays == 1, + Self.bedrockLatestBillingDay(from: snapshot.daily) != nil + { + L("Latest billing day") + } else { + Self.costHistoryWindowLabel(days: snapshot.historyDays) + } let monthLine: String = { if let monthTokens { return String(format: L("%@: %@ · %@ tokens"), windowLabel, monthCost, monthTokens) } return "\(windowLabel): \(monthCost)" }() + // Plan-metered spend over the same window (what the provider actually deducts); + // only providers that report it (currently Cursor) populate `meteredCostUSD`. + let meteredLine: String? = snapshot.meteredCostUSD.map { + let amount = UsageFormatter.convertedCostString( + $0, + preferredCurrency: preferredCurrencyCode, + providerCurrency: snapshot.currencyCode) + return String(format: L("Cursor-metered: %@ (%@)"), amount, windowLabel.lowercased()) + } let err = (error?.isEmpty ?? true) ? nil : error return TokenUsageSection( sessionLine: sessionLine, monthLine: monthLine, + meteredLine: meteredLine, + comparisonLines: comparisonPeriodsEnabled + ? snapshot.comparisonSummaries().map { + Self.costWindowLine( + summary: $0, + currencyCode: UsageFormatter.effectiveCurrencyCode( + preferred: preferredCurrencyCode, + providerCurrency: snapshot.currencyCode), + sourceCurrencyCode: snapshot.currencyCode) + } + : [], hintLine: Self.tokenUsageHint(provider: provider), errorLine: err, errorCopyText: (error?.isEmpty ?? true) ? nil : error) } + static func costWindowLine( + summary: CostUsageWindowSummary, + currencyCode: String, + sourceCurrencyCode: String? = nil) -> String + { + let label = Self.costHistoryWindowLabel(days: summary.days) + let cost = summary.totalCostUSD.map { + UsageFormatter.convertedCostString( + $0, + preferredCurrency: currencyCode, + providerCurrency: sourceCurrencyCode ?? currencyCode) + } ?? "—" + guard let totalTokens = summary.totalTokens else { return "\(label): \(cost)" } + return String( + format: L("%@: %@ · %@ tokens"), + label, + cost, + UsageFormatter.tokenCountString(totalTokens)) + } + static func tokenUsageHint(provider: UsageProvider) -> String? { + let lines = Self.tokenUsageHintLines(provider: provider) + return lines.isEmpty ? nil : lines.joined(separator: "\n") + } + + static func tokenUsageHeader(provider _: UsageProvider) -> String { + L("Cost") + } + + static func tokenUsageHintLines(provider: UsageProvider) -> [String] { switch provider { case .codex: - L("Estimated from local Codex logs for the selected account.") - case .claude: - UsageFormatter.costEstimateHint(provider: provider) + [L("codex_api_estimate_hint")] + case .claude, .cursor: + [UsageFormatter.costEstimateHint(provider: provider)] case .vertexai: - L("cost_estimate_hint") + [L("cost_estimate_hint")] case .bedrock: - L("AWS Cost Explorer billing can lag.") + [L("AWS Cost Explorer billing can lag.")] case .openai: - L("Reported by OpenAI Admin API organization usage.") + [L("Reported by OpenAI Admin API organization usage.")] case .mistral: - L("Reported by Mistral billing usage.") + [L("Reported by Mistral billing usage.")] default: - nil + [] } } @@ -107,40 +333,84 @@ extension UsageMenuCardView.Model { private static func bedrockLatestBillingDay(from entries: [CostUsageDailyReport.Entry]) -> CostUsageDailyReport.Entry? { - entries.max { lhs, rhs in - let lDate = Self.bedrockBillingDate(from: lhs.date) ?? .distantPast - let rDate = Self.bedrockBillingDate(from: rhs.date) ?? .distantPast - if lDate != rDate { return lDate < rDate } - let lCost = lhs.costUSD ?? -1 - let rCost = rhs.costUSD ?? -1 + entries.compactMap { entry -> (entry: CostUsageDailyReport.Entry, dayKey: String)? in + guard let dayKey = bedrockBillingDayKey(from: entry.date) else { return nil } + return (entry, dayKey) + } + .max { lhs, rhs in + if lhs.dayKey != rhs.dayKey { return lhs.dayKey < rhs.dayKey } + let lCost = lhs.entry.costUSD ?? -1 + let rCost = rhs.entry.costUSD ?? -1 if lCost != rCost { return lCost < rCost } - let lTokens = lhs.totalTokens ?? -1 - let rTokens = rhs.totalTokens ?? -1 + let lTokens = lhs.entry.totalTokens ?? -1 + let rTokens = rhs.entry.totalTokens ?? -1 if lTokens != rTokens { return lTokens < rTokens } - return lhs.date < rhs.date - } + return lhs.entry.date < rhs.entry.date + }?.entry } private static func bedrockDisplayDate(from text: String) -> String? { - guard let date = bedrockBillingDate(from: text) else { return nil } - let formatter = DateFormatter() - formatter.locale = Locale(identifier: "en_US_POSIX") - formatter.timeZone = TimeZone(secondsFromGMT: 0) - formatter.dateFormat = "MMM d" - return formatter.string(from: date) + guard let dayKey = bedrockBillingDayKey(from: text) else { return nil } + let monthStart = dayKey.index(dayKey.startIndex, offsetBy: 5) + let monthEnd = dayKey.index(monthStart, offsetBy: 2) + let dayStart = dayKey.index(dayKey.startIndex, offsetBy: 8) + guard + let month = Int(dayKey[monthStart.. Date? { - let formatter = DateFormatter() - formatter.locale = Locale(identifier: "en_US_POSIX") - formatter.timeZone = TimeZone(secondsFromGMT: 0) - formatter.dateFormat = "yyyy-MM-dd" - return formatter.date(from: text.trimmingCharacters(in: .whitespacesAndNewlines)) + private static let bedrockMonthAbbreviations = [ + "Jan", "Feb", "Mar", "Apr", "May", "Jun", + "Jul", "Aug", "Sep", "Oct", "Nov", "Dec", + ] + + private static func bedrockBillingDayKey(from text: String) -> String? { + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + guard trimmed.count == 10 else { return nil } + for (offset, character) in trimmed.enumerated() { + switch offset { + case 4, 7: + guard character == "-" else { return nil } + default: + guard character.isNumber else { return nil } + } + } + let monthStart = trimmed.index(trimmed.startIndex, offsetBy: 5) + let monthEnd = trimmed.index(monthStart, offsetBy: 2) + let dayStart = trimmed.index(trimmed.startIndex, offsetBy: 8) + let yearEnd = trimmed.index(trimmed.startIndex, offsetBy: 4) + guard + let year = Int(trimmed[.. Int { + switch month { + case 2: + if year.isMultiple(of: 400) { return 29 } + if year.isMultiple(of: 100) { return 28 } + return year.isMultiple(of: 4) ? 29 : 28 + case 4, 6, 9, 11: + return 30 + default: + return 31 + } } static func providerCostSection( provider: UsageProvider, - cost: ProviderCostSnapshot?) -> ProviderCostSection? + cost: ProviderCostSnapshot?, + isClaudeAdminAPI: Bool = false, + preferredCurrencyCode: String = "auto") -> ProviderCostSection? { if provider == .manus { return nil @@ -148,8 +418,16 @@ extension UsageMenuCardView.Model { guard let cost else { return nil } guard provider != .synthetic else { return nil } - if provider == .factory, cost.period == "Extra usage balance" { - let balance = UsageFormatter.currencyString(cost.used, currencyCode: cost.currencyCode) + /// Formats a cost value using the user's currency preference. + func formatCost(_ value: Double, providerCurrency: String? = nil) -> String { + UsageFormatter.convertedCostString( + value, + preferredCurrency: preferredCurrencyCode, + providerCurrency: providerCurrency ?? cost.currencyCode) + } + + if provider == .factory || provider == .devin, cost.period == "Extra usage balance" { + let balance = formatCost(cost.used) return ProviderCostSection( title: L("Extra usage"), percentUsed: nil, @@ -158,7 +436,7 @@ extension UsageMenuCardView.Model { } if provider == .opencodego, cost.period == "Zen balance" { - let balance = UsageFormatter.currencyString(cost.used, currencyCode: cost.currencyCode) + let balance = formatCost(cost.used) return ProviderCostSection( title: L("Zen balance"), percentUsed: nil, @@ -166,8 +444,76 @@ extension UsageMenuCardView.Model { percentLine: nil) } - if provider == .openai || provider == .claude, cost.limit <= 0 { - let spend = UsageFormatter.currencyString(cost.used, currencyCode: cost.currencyCode) + if provider == .minimax, cost.period == "MiniMax points balance" { + let balance = String(format: "%.0f", cost.used) + return ProviderCostSection( + title: L("Credits"), + percentUsed: nil, + spendLine: "\(L("Balance")): \(balance)", + percentLine: nil) + } + + if provider == .xai, cost.period == "Prepaid credits" { + let balance = UsageFormatter.currencyString(cost.used, currencyCode: cost.currencyCode) + return ProviderCostSection( + title: L("Credits"), + percentUsed: nil, + spendLine: "\(L("Balance")): \(balance)", + percentLine: nil) + } + + if provider == .zenmux || provider == .neuralwatt { + let balance = formatCost(cost.used) + return ProviderCostSection( + title: L("metric_mistral_payg"), + percentUsed: nil, + spendLine: "\(L("Balance")): \(balance)", + percentLine: nil) + } + + if provider == .claude { + if isClaudeAdminAPI { + let spend = formatCost(cost.used) + let periodLabel = Self.localizedPeriodLabel(cost.period ?? "Last 30 days") + return ProviderCostSection( + title: L("API spend"), + percentUsed: nil, + spendLine: "\(periodLabel): \(spend)", + percentLine: nil) + } + + if cost.limit <= 0 { + guard let balance = cost.balance else { return nil } + let value = formatCost(balance) + return ProviderCostSection( + title: L("Credits"), + percentUsed: nil, + spendLine: value, + percentLine: nil, + presentation: .inlineValue, + showsInProviderDetails: false) + } + + let used = formatCost(cost.used) + let limit = formatCost(cost.limit) + let percentUsed = Self.clamped((cost.used / cost.limit) * 100) + let periodLabel = Self.localizedPeriodLabel(cost.period ?? "This month") + let balanceLine = cost.balance.map { + "\(L("Balance")): \(formatCost($0))" + } + return ProviderCostSection( + title: L("Extra usage"), + percentUsed: percentUsed, + spendLine: "\(periodLabel): \(used) / \(limit)", + percentLine: String(format: L("%.0f%% used"), min(100, max(0, percentUsed))), + balanceLine: balanceLine, + showsInProviderDetails: false) + } + + if provider == .openai || provider == .litellm || provider == .aiand, + cost.limit <= 0 + { + let spend = formatCost(cost.used) let periodLabel = Self.localizedPeriodLabel(cost.period ?? "Last 30 days") return ProviderCostSection( title: L("API spend"), @@ -176,30 +522,57 @@ extension UsageMenuCardView.Model { percentLine: nil) } + if provider == .litellm { + return nil + } + + if provider == .clawrouter, cost.limit <= 0 { + let spend = formatCost(cost.used) + return ProviderCostSection( + title: "ClawRouter spend", + percentUsed: nil, + spendLine: "\(L("This month")): \(spend)", + percentLine: nil) + } + guard cost.limit > 0 else { return nil } let used: String let limit: String let title: String - if cost.currencyCode == "Quota" { + if provider == .clawrouter { + title = "Monthly budget" + used = formatCost(cost.used) + limit = formatCost(cost.limit) + } else if cost.currencyCode == "Quota" { title = L("Quota usage") used = String(format: "%.0f", cost.used) limit = String(format: "%.0f", cost.limit) } else { title = L("Extra usage") - used = UsageFormatter.currencyString(cost.used, currencyCode: cost.currencyCode) - limit = UsageFormatter.currencyString(cost.limit, currencyCode: cost.currencyCode) + used = formatCost(cost.used) + limit = formatCost(cost.limit) } let percentUsed = Self.clamped((cost.used / cost.limit) * 100) let periodLabel = Self.localizedPeriodLabel(cost.period ?? "This month") + // When the headline budget is a shared pool (e.g. Cursor team on-demand), show the + // account's own contribution underneath it. + let personalSpendLine: String? = cost.personalUsed.flatMap { personal in + personal > 0 + ? "\(L("Your spend")): \(formatCost(personal))" + : nil + } + return ProviderCostSection( title: title, percentUsed: percentUsed, spendLine: "\(periodLabel): \(used) / \(limit)", - percentLine: String(format: L("%.0f%% used"), min(100, max(0, percentUsed)))) + percentLine: String(format: L("%.0f%% used"), min(100, max(0, percentUsed))), + balanceLine: nil, + personalSpendLine: personalSpendLine) } private static func localizedPeriodLabel(_ label: String) -> String { diff --git a/Sources/CodexBar/MenuCardView+Kiro.swift b/Sources/CodexBar/MenuCardView+Kiro.swift index f9f61a9c03..c9262caa30 100644 --- a/Sources/CodexBar/MenuCardView+Kiro.swift +++ b/Sources/CodexBar/MenuCardView+Kiro.swift @@ -29,7 +29,11 @@ extension UsageMenuCardView.Model { if overagesEnabled, let estimatedOverageCostUSD = input.snapshot?.kiroUsage?.estimatedOverageCostUSD { - notes.append("\(L("Overage cost")): \(UsageFormatter.usdString(estimatedOverageCostUSD))") + let costStr = UsageFormatter.convertedCostString( + estimatedOverageCostUSD, + preferredCurrency: input.preferredCurrencyCode, + providerCurrency: "USD") + notes.append("\(L("Overage cost")): \(costStr)") } return notes } diff --git a/Sources/CodexBar/MenuCardView+MiniMax.swift b/Sources/CodexBar/MenuCardView+MiniMax.swift index dda62d32fe..22e6882289 100644 --- a/Sources/CodexBar/MenuCardView+MiniMax.swift +++ b/Sources/CodexBar/MenuCardView+MiniMax.swift @@ -4,21 +4,22 @@ import Foundation extension UsageMenuCardView.Model { static func minimaxMetrics(services: [MiniMaxServiceUsage], input: Input) -> [Metric] { let percentStyle: PercentStyle = .used - let textGenerationCount = services.count { $0.displayName == "Text Generation" } + let displayNameCounts = Dictionary(grouping: services.map(\.displayName), by: { $0 }).mapValues(\.count) return services.enumerated().map { index, service in let used = service.usage let displayPercent = min(100, max(0, service.percent)) - let usageLabel = String( - format: L("minimax_usage_amount_format"), - used.formatted(), - service.limit.formatted()) - let usedLabel = String( - format: L("minimax_used_percent_format"), - String(format: "%.0f%%", displayPercent)) + let usageLabel = if service.isUnlimited { + nil as String? + } else { + String( + format: L("minimax_usage_amount_format"), + used.formatted(), + service.limit.formatted()) + } let localizedName = Self.localizedMiniMaxServiceName(service.displayName) - let title = if localizedName == L("minimax_service_text_generation"), textGenerationCount > 1 { - "\(L("minimax_service_text_generation")) · \(Self.displayWindowBadge(for: service.windowType))" + let title = if (displayNameCounts[service.displayName] ?? 0) > 1 { + "\(localizedName) · \(Self.displayWindowBadge(for: service.windowType))" } else { localizedName } @@ -28,13 +29,67 @@ extension UsageMenuCardView.Model { title: title, percent: displayPercent, percentStyle: percentStyle, + statusText: service.isUnlimited ? "∞ Unlimited" : nil, resetText: Self.localizedMiniMaxResetDescription(service.resetDescription), - detailText: service.timeRange, + detailText: nil, detailLeftText: usageLabel, - detailRightText: usedLabel, + detailRightText: nil, pacePercent: nil, paceOnTop: true, - cardStyle: true) + warningMarkerPercents: service.isUnlimited + ? [] + : Self.miniMaxWarningMarkerPercents(service: service, input: input), + workdayMarkerPercents: service.isUnlimited + ? [] + : Self.miniMaxWorkdayMarkerPercents(service: service, input: input), + cardStyle: false) + } + } + + private static func miniMaxWarningMarkerPercents(service: MiniMaxServiceUsage, input: Input) -> [Double] { + switch self.miniMaxQuotaWarningWindow(for: service) { + case .session: + warningMarkerPercents( + thresholds: input.quotaWarningThresholds[.session], + showUsed: true) + case .weekly: + warningMarkerPercents( + thresholds: input.quotaWarningThresholds[.weekly], + showUsed: true) + } + } + + private static func miniMaxWorkdayMarkerPercents(service: MiniMaxServiceUsage, input: Input) -> [Double] { + guard self.miniMaxQuotaWarningWindow(for: service) == .weekly else { return [] } + return workDayMarkerPercents( + workDays: input.workDaysPerWeek, + windowMinutes: self.miniMaxWindowMinutes(for: service.windowType)) + } + + private static func miniMaxQuotaWarningWindow(for service: MiniMaxServiceUsage) -> QuotaWarningWindow { + service.windowType.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == "weekly" ? .weekly : .session + } + + private static func miniMaxWindowMinutes(for windowType: String) -> Int? { + let normalized = windowType.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + if normalized == "weekly" { + return 7 * 24 * 60 + } + if normalized == "today" || normalized == "daily" { + return 24 * 60 + } + if normalized == "5h" { + return 5 * 60 + } + let pieces = normalized.split(separator: " ") + guard pieces.count >= 2, let value = Int(pieces[0]) else { return nil } + switch pieces[1] { + case "hour", "hours", "hr", "hrs": + return value * 60 + case "minute", "minutes", "min", "mins": + return value + default: + return nil } } diff --git a/Sources/CodexBar/MenuCardView+ModelHelpers.swift b/Sources/CodexBar/MenuCardView+ModelHelpers.swift index 973ef05c1f..91a611a2d3 100644 --- a/Sources/CodexBar/MenuCardView+ModelHelpers.swift +++ b/Sources/CodexBar/MenuCardView+ModelHelpers.swift @@ -9,6 +9,272 @@ extension UsageMenuCardView.Model { let paceOnTop: Bool } + struct PrimaryMetricPresentation { + var statusText: String? + var resetText: String? + var detailText: String? + var detailLeft: String? + var detailRight: String? + var pacePercent: Double? + var paceOnTop = true + } + + static func applyPrimaryQuotaPresentation( + _ presentation: inout PrimaryMetricPresentation, + input: Input, + primary: RateWindow, + openRouterQuotaDetail: String?) + { + if input.provider == .openrouter, let openRouterQuotaDetail { + presentation.resetText = openRouterQuotaDetail + } + if [.copilot, .zenmux].contains(input.provider), + let detail = Self.trimmedResetDescription(primary) + { + presentation.detailLeft = detail + } + guard input.provider == .crof, + let detail = Self.trimmedResetDescription(primary) + else { return } + if input.snapshot?.secondary != nil { + presentation.detailRight = detail + } else { + presentation.detailText = detail + } + } + + static func applyPrimaryBalancePresentation( + _ presentation: inout PrimaryMetricPresentation, + input: Input, + primary: RateWindow) + { + if [.warp, .kilo, .mimo, .deepseek, .deepinfra, .qoder, .mistral, .neuralwatt, .litellm, .chutes] + .contains(input.provider), + let detail = nonEmptyResetDescription(primary) + { + presentation.detailText = detail + } + if let balance = Self.poeBalanceDetailText(input: input) { + presentation.detailText = balance + } + if input.provider == .kiro, + let kiroUsage = input.snapshot?.kiroUsage, + kiroUsage.creditsTotal > 0 + { + let remaining = UsageFormatter.kiroCreditNumber(kiroUsage.creditsRemaining) + let total = UsageFormatter.kiroCreditNumber(kiroUsage.creditsTotal) + presentation.detailLeft = String(format: L("%@ of %@ credits left"), remaining, total) + } + if input.provider == .alibaba || input.provider == .alibabatokenplan || input.provider == .manus, + let detail = Self.nonEmptyResetDescription(primary) + { + presentation.detailText = detail + if input.provider == .manus { + presentation.resetText = nil + } + } + } + + static func applyPrimaryResetPresentation( + _ presentation: inout PrimaryMetricPresentation, + input: Input, + primary: RateWindow) + { + if input.provider == .sub2api { + presentation.resetText = primary.resetDescription + } + if [.warp, .kilo, .mimo, .deepseek, .deepinfra, .qoder, .mistral, .neuralwatt, .litellm, .zenmux, .chutes] + .contains(input.provider), + primary.resetsAt == nil + { + presentation.resetText = nil + } + if input.provider == .crof, input.snapshot?.secondary == nil { + presentation.resetText = nil + } + } + + static func applyPrimaryPacePresentation( + _ presentation: inout PrimaryMetricPresentation, + input: Input, + primary: RateWindow) + { + if let paceDetail = sessionPaceDetail( + provider: input.provider, + window: primary, + now: input.now, + showUsed: input.usageBarsShowUsed) + { + self.apply(paceDetail, to: &presentation) + } + if input.provider == .abacus { + if let detail = Self.nonEmptyResetDescription(primary) { + presentation.detailText = detail + } + if primary.resetsAt == nil { + presentation.resetText = nil + } + if let pace = input.weeklyPace, + let paceDetail = Self.weeklyPaceDetail( + provider: input.provider, + window: primary, + now: input.now, + pace: pace, + showUsed: input.usageBarsShowUsed) + { + Self.apply(paceDetail, to: &presentation) + } + } else if let paceDetail = Self.resetWindowPaceDetail( + window: primary, + input: input, + pace: input.provider == .kimi ? input.weeklyPace : nil) + { + Self.apply(paceDetail, to: &presentation) + } + } + + static func applyPrimaryFinalOverrides( + _ presentation: inout PrimaryMetricPresentation, + input: Input, + primary: RateWindow) + { + // Legacy request-based Cursor plans surface the raw used/limit quota on its own line. + if input.provider == .cursor, let requests = input.snapshot?.cursorRequests { + presentation.detailText = String( + format: L("Request quota: %@ / %@"), + "\(requests.used)", + "\(requests.limit)") + } + if input.provider == .synthetic, + let regen = Self.syntheticRollingRegenDetail( + window: primary, + now: input.now, + showUsed: input.usageBarsShowUsed) + { + presentation.resetText = regen.resetText + Self.apply(regen.pace, to: &presentation) + } + let usesBalanceStatusText = input.provider == .deepseek || input.provider == .deepinfra || + (input.provider == .crof && input.snapshot?.secondary == nil) + if usesBalanceStatusText { + presentation.statusText = presentation.detailText + presentation.detailText = nil + } + } + + private static func apply(_ paceDetail: PaceDetail, to presentation: inout PrimaryMetricPresentation) { + presentation.detailLeft = paceDetail.leftLabel + presentation.detailRight = paceDetail.rightLabel + presentation.pacePercent = paceDetail.pacePercent + presentation.paceOnTop = paceDetail.paceOnTop + } + + private static func nonEmptyResetDescription(_ window: RateWindow) -> String? { + guard let detail = window.resetDescription, + !detail.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + else { return nil } + return detail + } + + private static func trimmedResetDescription(_ window: RateWindow) -> String? { + guard let detail = window.resetDescription?.trimmingCharacters(in: .whitespacesAndNewlines), + !detail.isEmpty + else { return nil } + return detail + } + + static func redactedMetricDetail(_ detail: String?, provider: UsageProvider, metricID: String) -> String? { + guard let detail else { return nil } + guard provider == .litellm, + metricID == "secondary", + detail.hasPrefix("Team "), + let separator = detail.range(of: ": ", options: .backwards) + else { + return PersonalInfoRedactor.redactEmails(in: detail, isEnabled: true) + } + return PersonalInfoRedactor.redactEmails(in: "Team\(detail[separator.lowerBound...])", isEnabled: true) + } + + static func redactedMetrics( + _ metrics: [Metric], + provider: UsageProvider, + hidePersonalInfo: Bool) -> [Metric] + { + guard hidePersonalInfo else { return metrics } + return metrics.map { metric in + Metric( + id: metric.id, + title: PersonalInfoRedactor.redactEmails(in: metric.title, isEnabled: true) ?? metric.title, + percent: metric.percent, + percentStyle: metric.percentStyle, + statusText: PersonalInfoRedactor.redactEmails(in: metric.statusText, isEnabled: true), + resetText: PersonalInfoRedactor.redactEmails(in: metric.resetText, isEnabled: true), + detailText: Self.redactedMetricDetail( + metric.detailText, + provider: provider, + metricID: metric.id), + detailLeftText: PersonalInfoRedactor.redactEmails(in: metric.detailLeftText, isEnabled: true), + detailRightText: PersonalInfoRedactor.redactEmails(in: metric.detailRightText, isEnabled: true), + pacePercent: metric.pacePercent, + paceOnTop: metric.paceOnTop, + warningMarkerPercents: metric.warningMarkerPercents, + workdayMarkerPercents: metric.workdayMarkerPercents, + cardStyle: metric.cardStyle, + sessionEquivalentDetail: metric.sessionEquivalentDetail) + } + } + + static func usageNotes(input: Input) -> [String] { + let subscriptionNotes = self.subscriptionMetadataNotes(snapshot: input.snapshot, provider: input.provider) + + if input.provider == .sub2api { + return self.sub2APIUsageNotes(input.snapshot?.sub2APIUsage) + subscriptionNotes + } + + if input.provider == .kiro { + return self.kiroUsageNotes(input: input) + subscriptionNotes + } + + if input.provider == .kilo { + var notes = Self.kiloLoginDetails(snapshot: input.snapshot) + let resolvedSource = input.sourceLabel? + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + if input.kiloAutoMode, + resolvedSource == "cli", + !notes.contains(where: { $0.caseInsensitiveCompare("Using CLI fallback") == .orderedSame }) + { + notes.append(L("Using CLI fallback")) + } + return notes + subscriptionNotes + } + + if input.provider == .mimo, input.snapshot != nil { + return Self.mimoUsageNotes(input: input, subscriptionNotes: subscriptionNotes) + } + + if let notes = self.apiProviderUsageNotes(input: input) { + return notes + subscriptionNotes + } + + guard input.provider == .openrouter, + let openRouter = input.snapshot?.openRouterUsage + else { + return subscriptionNotes + } + + var notes = Self.openRouterSpendNotes(openRouter) + switch openRouter.keyQuotaStatus { + case .available: + break + case .noLimitConfigured: + notes.append(L("No limit set for the API key")) + case .unavailable: + notes.append(L("API key limit unavailable right now")) + } + return notes + subscriptionNotes + } + var isOverviewErrorOnly: Bool { self.subtitleStyle == .error && self.metrics.isEmpty && @@ -26,9 +292,159 @@ extension UsageMenuCardView.Model { !self.usageNotes.isEmpty || self.openAIAPIUsage != nil || self.inlineUsageDashboard != nil || + self.codexResetCredits != nil || self.placeholder != nil } + var creditsOnlyInlineUsageDashboard: Bool { + self.creditsText != nil && + self.inlineUsageDashboard != nil && + self.metrics.isEmpty && + self.usageNotes.isEmpty && + self.openAIAPIUsage == nil && + self.codexResetCredits == nil && + self.placeholder == nil + } + + var usesStackedDetailLayout: Bool { + !self.metrics.isEmpty || + self.creditsText != nil || + self.codexResetCredits != nil || + self.providerCost != nil || + self.tokenUsage != nil + } + + func hasCompatibleTrackedLayout(with candidate: Self) -> Bool { + self.hasCompatibleTrackedLayout(with: candidate, includeMetrics: true) + } + + func hasCompatibleTrackedLayoutIgnoringMetrics(with candidate: Self) -> Bool { + self.hasCompatibleTrackedLayout(with: candidate, includeMetrics: false) + } + + func hasCompatibleTrackedMetricSubset(of candidate: Self) -> Bool { + guard self.metrics.count < candidate.metrics.count, + self.hasCompatibleTrackedLayoutIgnoringMetrics(with: candidate) + else { + return false + } + return self.metrics.allSatisfy { metric in + candidate.metrics.contains { Self.hasCompatibleMetricLayout(metric, $0) } + } + } + + private func hasCompatibleTrackedLayout(with candidate: Self, includeMetrics: Bool) -> Bool { + guard self.provider == candidate.provider, + !includeMetrics || self.metrics.count == candidate.metrics.count, + self.usageNotes == candidate.usageNotes, + (self.openAIAPIUsage == nil) == (candidate.openAIAPIUsage == nil), + Self.hasCompatibleCreditsLayout( + currentText: self.creditsText, + currentRemaining: self.creditsRemaining, + candidateText: candidate.creditsText, + candidateRemaining: candidate.creditsRemaining), + self.creditsHintText == candidate.creditsHintText, + self.codexResetCredits == candidate.codexResetCredits, + self.placeholder == candidate.placeholder, + Self.hasCompatibleDashboardLayout(self.inlineUsageDashboard, candidate.inlineUsageDashboard), + Self.hasCompatibleProviderCostLayout(self.providerCost, candidate.providerCost), + Self.hasCompatibleTokenUsageLayout(self.tokenUsage, candidate.tokenUsage) + else { + return false + } + + guard includeMetrics else { return true } + return zip(self.metrics, candidate.metrics).allSatisfy(Self.hasCompatibleMetricLayout) + } + + private static func hasCompatibleMetricLayout(_ current: Metric, _ candidate: Metric) -> Bool { + current.id == candidate.id && + current.title == candidate.title && + current.percentStyle == candidate.percentStyle && + (current.statusText == nil) == (candidate.statusText == nil) && + (current.resetText == nil) == (candidate.resetText == nil) && + (current.detailText == nil) == (candidate.detailText == nil) && + (current.detailLeftText == nil) == (candidate.detailLeftText == nil) && + (current.detailRightText == nil) == (candidate.detailRightText == nil) && + current.cardStyle == candidate.cardStyle + } + + private static func hasCompatibleCreditsLayout( + currentText: String?, + currentRemaining: Double?, + candidateText: String?, + candidateRemaining: Double?) -> Bool + { + switch (currentText, candidateText) { + case (nil, nil): + return true + case let (currentText?, candidateText?): + guard (currentRemaining == nil) == (candidateRemaining == nil) else { return false } + // Numeric balances render as a fixed single line beside the full-scale label. + // Multiline workspace balances retain their measured text until the menu reopens. + return currentRemaining != nil || currentText == candidateText + default: + return false + } + } + + private static func hasCompatibleDashboardLayout( + _ current: InlineUsageDashboardModel?, + _ candidate: InlineUsageDashboardModel?) -> Bool + { + switch (current, candidate) { + case (nil, nil): + true + case let (current?, candidate?): + current.valueStyle == candidate.valueStyle && + current.kpis.count == candidate.kpis.count && + current.points.count == candidate.points.count && + current.detailLines.count == candidate.detailLines.count && + zip(current.kpis, candidate.kpis).allSatisfy { + $0.title == $1.title && $0.emphasis == $1.emphasis + } && + zip(current.points, candidate.points).allSatisfy { + $0.id == $1.id && $0.label == $1.label + } + default: + false + } + } + + private static func hasCompatibleProviderCostLayout( + _ current: ProviderCostSection?, + _ candidate: ProviderCostSection?) -> Bool + { + switch (current, candidate) { + case (nil, nil): + true + case let (current?, candidate?): + current.title == candidate.title && + (current.percentUsed == nil) == (candidate.percentUsed == nil) && + (current.percentLine == nil) == (candidate.percentLine == nil) && + (current.personalSpendLine == nil) == (candidate.personalSpendLine == nil) + default: + false + } + } + + private static func hasCompatibleTokenUsageLayout( + _ current: TokenUsageSection?, + _ candidate: TokenUsageSection?) -> Bool + { + switch (current, candidate) { + case (nil, nil): + true + case let (current?, candidate?): + current.hintLine == candidate.hintLine && + current.errorLine == candidate.errorLine && + (current.meteredLine == nil) == (candidate.meteredLine == nil) && + current.comparisonLines.count == candidate.comparisonLines.count + default: + false + } + } + static func progressColor(for provider: UsageProvider) -> Color { if provider == .elevenlabs { return Color(nsColor: .labelColor) @@ -38,6 +454,66 @@ extension UsageMenuCardView.Model { return Color(red: color.red, green: color.green, blue: color.blue) } + static func rateWindowLabels( + input: Input, + snapshot: UsageSnapshot) -> (primary: String, secondary: String, tertiary: String, showsTertiary: Bool) + { + if input.provider == .factory, snapshot.tertiary != nil { + return ("5-hour", L("Weekly"), L("Monthly"), true) + } + // Legacy request-based Cursor plans track a request quota, not the token-based "Total" pool. + let primaryLabel = if input.provider == .cursor, snapshot.cursorRequests != nil { + "Requests" + } else if input.provider == .crof { + CrofProviderDescriptor.primaryLabel(snapshot: snapshot) + } else if input.provider == .grok { + GrokProviderDescriptor.primaryLabel(window: snapshot.primary, now: input.now) ?? input.metadata.sessionLabel + } else if input.provider == .doubao { + DoubaoProviderDescriptor.primaryLabel(window: snapshot.primary) ?? input.metadata.sessionLabel + } else if input.provider == .sub2api { + Sub2APIProviderDescriptor.primaryLabel(details: snapshot.sub2APIUsage) ?? input.metadata.sessionLabel + } else if input.provider == .amp { + AmpProviderDescriptor.primaryLabel(details: snapshot.ampUsage) ?? input.metadata.sessionLabel + } else if input.provider == .alibabatokenplan { + AlibabaTokenPlanProviderDescriptor.primaryLabel(window: snapshot.primary) ?? input.metadata.sessionLabel + } else { + input.metadata.sessionLabel + } + let secondaryLabel = if input.provider == .amp { + AmpProviderDescriptor.secondaryLabel(details: snapshot.ampUsage) ?? input.metadata.weeklyLabel + } else if input.provider == .alibabatokenplan { + AlibabaTokenPlanProviderDescriptor.secondaryLabel(window: snapshot.secondary) ?? input.metadata.weeklyLabel + } else { + input.metadata.weeklyLabel + } + return ( + L(primaryLabel), + L(secondaryLabel), + input.metadata.opusLabel.map(L) ?? L("Sonnet"), + input.metadata.supportsOpus) + } + + static func sub2APIUsageNotes(_ usage: Sub2APIUsageDetails?) -> [String] { + guard let usage else { return [] } + var notes: [String] = [] + if let balance = usage.balance { + notes.append("\(L("Balance")): \(UsageFormatter.currencyString(balance, currencyCode: usage.unit))") + } + if let today = usage.today { + notes.append("\(L("Today")): \(self.sub2APITotalsText(today, unit: usage.unit))") + } + if let total = usage.total { + notes.append("\(L("Total")): \(self.sub2APITotalsText(total, unit: usage.unit))") + } + return notes + } + + private static func sub2APITotalsText(_ totals: Sub2APIUsageDetails.Totals, unit: String) -> String { + "\(UsageFormatter.tokenCountString(totals.requests)) \(L("requests")) · " + + "\(UsageFormatter.tokenCountString(totals.totalTokens)) \(L("tokens")) · " + + UsageFormatter.currencyString(totals.actualCostUSD, currencyCode: unit) + } + static func resetText( for window: RateWindow, style: ResetTimeDisplayStyle, @@ -52,7 +528,7 @@ extension UsageMenuCardView.Model { } if input.snapshot == nil, !input.isRefreshing, input.lastError == nil { - return L("No usage yet") + return self.hasLocalCodexTokenUsage(input) ? nil : L("No usage yet") } return nil @@ -64,20 +540,92 @@ extension UsageMenuCardView.Model { else { return nil } + // Local Codex session costs are independent from OAuth, CLI quota, and OpenAI web + // dashboard access. Do not present a failed account-level quota fetch as a failure of + // a valid local API-key ledger. + if input.codexLocalSessionCostLedgerEnabled, + self.hasLocalCodexTokenUsage(input), + self.isRemoteCodexQuotaFetchError(lastError) + { + return nil + } if self.shouldShowRateLimitsUnavailablePlaceholder(input: input, lastError: lastError) { return nil } return lastError } + static func dashboardHint(error: String?) -> String? { + guard let error, !error.isEmpty else { return nil } + return error + } + + static func mimoUsageNotes(input: Input, subscriptionNotes: [String]) -> [String] { + let source = input.sourceLabel? + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + guard source != "local" else { return [] } + return [ + L("Balance updates in near-real time (up to 5 min lag)"), + L("Daily billing data finalizes at 07:00 UTC"), + ] + subscriptionNotes + } + + static func subscriptionMetadataNotes(snapshot: UsageSnapshot?, provider: UsageProvider) -> [String] { + guard let snapshot else { return [] } + if let renewsAt = snapshot.subscriptionRenewsAt { + return [String(format: L("Renews: %@"), self.subscriptionDateString(renewsAt, provider: provider))] + } + if let expiresAt = snapshot.subscriptionExpiresAt { + return [String(format: L("Plan expires: %@"), self.subscriptionDateString(expiresAt, provider: provider))] + } + return [] + } + + private static func subscriptionDateString(_ date: Date, provider: UsageProvider) -> String { + let formatter = DateFormatter() + formatter.locale = Locale.current + formatter.timeZone = self.subscriptionDateTimeZone(provider: provider) + formatter.setLocalizedDateFormatFromTemplate("MMM d, yyyy") + return formatter.string(from: date) + } + + private static func subscriptionDateTimeZone(provider: UsageProvider) -> TimeZone { + switch provider { + case .minimax: + TimeZone(identifier: "Asia/Shanghai") ?? .current + default: + .current + } + } + + static func poeBalanceDetailText(input: Input) -> String? { + guard input.provider == .poe else { return nil } + return StatusItemController.poeBalanceDisplayText(snapshot: input.snapshot) + } + + private static func hasLocalCodexTokenUsage(_ input: Input) -> Bool { + input.provider == .codex && + input.tokenCostUsageEnabled && + self.tokenUsageSnapshot(input: input) != nil + } + + private static func isRemoteCodexQuotaFetchError(_ error: String) -> Bool { + error.localizedCaseInsensitiveContains("Codex usage is temporarily unavailable") + } + private static func shouldShowRateLimitsUnavailablePlaceholder(input: Input, lastError: String? = nil) -> Bool { let currentError = lastError ?? input.lastError if let currentError = currentError?.trimmingCharacters(in: .whitespacesAndNewlines), !currentError.isEmpty, - !UsageError.isNoRateLimitsFoundDescription(currentError) + !UsageError.isNoRateLimitsFoundDescription(currentError), + !ClaudeStatusProbe.isSubscriptionQuotaUnavailableDescription(currentError) { return false } + if input.limitsAvailability?.isUnavailable == true { + return true + } return self.rateLimitsUnavailable(input: input, lastError: currentError) } @@ -101,9 +649,15 @@ extension UsageMenuCardView.Model { let actualUsed = window.usedPercent let expectedPercent = showUsed ? expectedUsed : (100 - expectedUsed) let actualPercent = showUsed ? actualUsed : (100 - actualUsed) - if expectedPercent.isFinite == false || actualPercent.isFinite == false { return nil } + if expectedPercent.isFinite == false || actualPercent.isFinite == false { + return nil + } let paceOnTop = actualUsed <= expectedUsed - let pacePercent: Double? = if detail.stage == .onTrack { nil } else { expectedPercent } + let pacePercent: Double? = if detail.stage == .onTrack { + nil + } else { + expectedPercent + } return PaceDetail( leftLabel: detail.leftLabel, rightLabel: detail.rightLabel, @@ -112,20 +666,27 @@ extension UsageMenuCardView.Model { } static func weeklyPaceDetail( + provider: UsageProvider, window: RateWindow, now: Date, pace: UsagePace?, showUsed: Bool) -> PaceDetail? { - guard let pace else { return nil } - let detail = UsagePaceText.weeklyDetail(pace: pace, now: now) + guard let pace, window.remainingPercent > 0 else { return nil } + let detail = UsagePaceText.weeklyDetail(provider: provider, pace: pace, now: now) let expectedUsed = detail.expectedUsedPercent let actualUsed = window.usedPercent let expectedPercent = showUsed ? expectedUsed : (100 - expectedUsed) let actualPercent = showUsed ? actualUsed : (100 - actualUsed) - if expectedPercent.isFinite == false || actualPercent.isFinite == false { return nil } + if expectedPercent.isFinite == false || actualPercent.isFinite == false { + return nil + } let paceOnTop = actualUsed <= expectedUsed - let pacePercent: Double? = if detail.stage == .onTrack { nil } else { expectedPercent } + let pacePercent: Double? = if detail.stage == .onTrack { + nil + } else { + expectedPercent + } return PaceDetail( leftLabel: detail.leftLabel, rightLabel: detail.rightLabel, @@ -133,28 +694,105 @@ extension UsageMenuCardView.Model { paceOnTop: paceOnTop) } + static func standardWeeklyPace(input: Input, window: RateWindow) -> UsagePace? { + if let weeklyPace = input.weeklyPace { + return weeklyPace + } + return Self.displayableWeeklyPace(UsagePace.weekly( + window: window, + now: input.now, + defaultWindowMinutes: 10080, + workDays: input.workDaysPerWeek)) + } + + private static func displayableWeeklyPace(_ pace: UsagePace?) -> UsagePace? { + guard let pace else { return nil } + return pace.expectedUsedPercent >= 3 || pace.etaSeconds == 0 ? pace : nil + } + + static func resetWindowPaceDetail( + window: RateWindow, + input: Input, + pace: UsagePace? = nil) -> PaceDetail? + { + let capability = ProviderDescriptorRegistry.descriptor(for: input.provider).pace + guard capability.supportsResetWindowPace(window: window, now: input.now), + window.remainingPercent > 0 + else { return nil } + let paceWindow = Self.resetWindowForPace(provider: input.provider, window: window) + let resolved = pace ?? UsagePace.weekly( + window: paceWindow, + now: input.now, + defaultWindowMinutes: 10080, + workDays: input.workDaysPerWeek) + guard let resolved = Self.displayableWeeklyPace(resolved) else { return nil } + return Self.weeklyPaceDetail( + provider: input.provider, + window: paceWindow, + now: input.now, + pace: resolved, + showUsed: input.usageBarsShowUsed) + } + + private static func resetWindowForPace(provider: UsageProvider, window: RateWindow) -> RateWindow { + // Provider snapshots use 30 days as a monthly sentinel; use the reset date for the real calendar-cycle length. + let pace = ProviderDescriptorRegistry.descriptor(for: provider).pace + guard pace.usesInferredMonthlyDuration(window: window), + let resetsAt = window.resetsAt, + let minutes = self.inferredMonthlyWindowMinutes(endingAt: resetsAt) + else { return window } + return RateWindow( + usedPercent: window.usedPercent, + windowMinutes: minutes, + resetsAt: window.resetsAt, + resetDescription: window.resetDescription, + nextRegenPercent: window.nextRegenPercent, + isSyntheticPlaceholder: window.isSyntheticPlaceholder) + } + + private static func inferredMonthlyWindowMinutes(endingAt resetsAt: Date) -> Int? { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(secondsFromGMT: 0) ?? calendar.timeZone + guard let startsAt = calendar.date(byAdding: .month, value: -1, to: resetsAt) else { return nil } + let minutes = resetsAt.timeIntervalSince(startsAt) / 60 + guard minutes.isFinite, minutes > 0 else { return nil } + return Int(minutes.rounded()) + } + static func antigravityMetrics(input: Input, snapshot: UsageSnapshot) -> [Metric] { let percentStyle: PercentStyle = input.usageBarsShowUsed ? .used : .left - var metrics = [ - Self.antigravityMetric( + if Self.hasAntigravityQuotaSummaryWindows(snapshot) { + return Self.extraRateWindowMetrics( + snapshot: snapshot, + input: input, + percentStyle: percentStyle) + } + + var metrics: [Metric] = [] + if let primary = snapshot.primary { + metrics.append(Self.antigravityMetric( id: "primary", title: L(input.metadata.sessionLabel), - window: snapshot.primary, + window: primary, input: input, - percentStyle: percentStyle), - Self.antigravityMetric( + percentStyle: percentStyle)) + } + if let secondary = snapshot.secondary { + metrics.append(Self.antigravityMetric( id: "secondary", title: L(input.metadata.weeklyLabel), - window: snapshot.secondary, + window: secondary, input: input, - percentStyle: percentStyle), - Self.antigravityMetric( + percentStyle: percentStyle)) + } + if input.metadata.supportsOpus, let tertiary = snapshot.tertiary { + metrics.append(Self.antigravityMetric( id: "tertiary", title: input.metadata.opusLabel.map(L) ?? L("Gemini Flash"), - window: snapshot.tertiary, + window: tertiary, input: input, - percentStyle: percentStyle), - ] + percentStyle: percentStyle)) + } metrics.append(contentsOf: Self.extraRateWindowMetrics( snapshot: snapshot, input: input, @@ -174,24 +812,183 @@ extension UsageMenuCardView.Model { if input.provider == .codex, !input.showOptionalCreditsAndExtraUsage { return [] } - return extraRateWindows.map { namedWindow in - Metric( + if input.provider == .copilot, !input.copilotBudgetExtrasEnabled { + return [] + } + var visibleRateWindows = if input.provider == .codex, !input.codexSparkUsageVisible { + extraRateWindows.filter { !Self.isCodexSparkRateWindow($0) } + } else { + extraRateWindows + } + if input.provider == .claude, + !input.showOptionalCreditsAndExtraUsage || !input.claudeDailyRoutinesUsageVisible + { + visibleRateWindows.removeAll(where: Self.isClaudeDailyRoutinesRateWindow) + } + return visibleRateWindows.map { namedWindow in + let paceDetail = Self.extraRateWindowPaceDetail( + provider: input.provider, + window: namedWindow.window, + input: input) + let usageKnown = namedWindow.usageKnown + let resolvedResetText = Self.extraRateWindowResetText( + namedWindow: namedWindow, + input: input) + let resetText = input.provider == .sub2api && namedWindow.window.resetsAt == nil + ? nil + : resolvedResetText + let detailText = input.provider == .sub2api + ? namedWindow.window.resetDescription + : nil + let statusText: String? = if usageKnown { + nil + } else if let resetText { + "\(L("Unavailable")) - \(resetText)" + } else { + L("Unavailable") + } + let title = input.provider == .doubao && namedWindow.id.contains("-team-") + ? "\(L(namedWindow.title)) (\(L("Team")))" + : L(namedWindow.title) + return Metric( id: namedWindow.id, - title: namedWindow.title, + title: title, percent: Self.clamped( input.usageBarsShowUsed ? namedWindow.window.usedPercent : namedWindow.window.remainingPercent), percentStyle: percentStyle, - resetText: Self.resetText( - for: namedWindow.window, - style: input.resetTimeDisplayStyle, - now: input.now), - detailText: nil, - detailLeftText: nil, - detailRightText: nil, - pacePercent: nil, - paceOnTop: true) + statusText: statusText, + resetText: usageKnown ? resetText : nil, + detailText: usageKnown ? detailText : nil, + detailLeftText: usageKnown ? paceDetail?.leftLabel : nil, + detailRightText: usageKnown ? paceDetail?.rightLabel : nil, + pacePercent: usageKnown ? paceDetail?.pacePercent : nil, + paceOnTop: paceDetail?.paceOnTop ?? true, + sessionEquivalentDetail: usageKnown + ? Self.sessionEquivalentDetail( + input: input, + weeklyWindow: namedWindow.window, + weeklyWindowID: namedWindow.id) + : nil) + } + } + + private static func isCodexSparkRateWindow(_ namedWindow: NamedRateWindow) -> Bool { + namedWindow.id == CodexAdditionalRateLimitMapper.sparkWindowID || + namedWindow.id == CodexAdditionalRateLimitMapper.sparkWeeklyWindowID + } + + private static func isClaudeDailyRoutinesRateWindow(_ namedWindow: NamedRateWindow) -> Bool { + namedWindow.id == "claude-routines" + } + + private static let antigravityQuotaSummaryWindowIDPrefix = "antigravity-quota-summary-" + + private static func hasAntigravityQuotaSummaryWindows(_ snapshot: UsageSnapshot) -> Bool { + snapshot.extraRateWindows?.contains(where: self.isAntigravityQuotaSummaryWindow) == true + } + + private static func isAntigravityQuotaSummaryWindow(_ namedWindow: NamedRateWindow) -> Bool { + namedWindow.id.hasPrefix(self.antigravityQuotaSummaryWindowIDPrefix) + } + + private static func extraRateWindowResetText( + namedWindow: NamedRateWindow, + input: Input) -> String? + { + if namedWindow.window.resetsAt != nil { + return self.resetText( + for: namedWindow.window, + style: input.resetTimeDisplayStyle, + now: input.now) + } + if input.provider == .antigravity, + self.isAntigravityQuotaSummaryWindow(namedWindow) + { + return self.antigravityQuotaSummaryResetText(namedWindow.window.resetDescription) + } + return self.resetText( + for: namedWindow.window, + style: input.resetTimeDisplayStyle, + now: input.now) + } + + private static func antigravityQuotaSummaryResetText(_ description: String?) -> String? { + guard let description = description?.trimmingCharacters(in: .whitespacesAndNewlines), + !description.isEmpty + else { return nil } + + if let range = description.range(of: "fully refresh in ", options: .caseInsensitive) { + var suffix = String(description[range.upperBound...]) + .trimmingCharacters(in: .whitespacesAndNewlines) + while suffix.last == "." { + suffix.removeLast() + } + guard !suffix.isEmpty else { return description } + return String(format: L("Resets in %@"), suffix) + } + + return description + } + + private static func extraRateWindowPaceDetail( + provider: UsageProvider, + window: RateWindow, + input: Input) -> PaceDetail? + { + if provider == .claude, window.windowMinutes != 10080 { return nil } + guard provider == .codex || provider == .claude || provider == .antigravity else { return nil } + switch window.windowMinutes { + case 300: + return self.sessionPaceDetail( + provider: provider, + window: window, + now: input.now, + showUsed: input.usageBarsShowUsed) + case 10080: + let pace = Self.displayableWeeklyPace(UsagePace.weekly( + window: window, + now: input.now, + defaultWindowMinutes: 10080, + workDays: input.workDaysPerWeek)) + return Self.weeklyPaceDetail( + provider: provider, + window: window, + now: input.now, + pace: pace, + showUsed: input.usageBarsShowUsed) + default: + return nil + } + } + + private static func antigravityMetricPaceDetail( + window: RateWindow, + input: Input) -> PaceDetail? + { + guard input.provider == .antigravity else { return nil } + switch window.windowMinutes { + case nil, 300: + return self.sessionPaceDetail( + provider: input.provider, + window: window, + now: input.now, + showUsed: input.usageBarsShowUsed) + case 10080: + let pace = Self.displayableWeeklyPace(UsagePace.weekly( + window: window, + now: input.now, + defaultWindowMinutes: 10080, + workDays: input.workDaysPerWeek)) + return Self.weeklyPaceDetail( + provider: input.provider, + window: window, + now: input.now, + pace: pace, + showUsed: input.usageBarsShowUsed) + default: + return nil } } @@ -218,6 +1015,7 @@ extension UsageMenuCardView.Model { paceOnTop: true) } let percent = input.usageBarsShowUsed ? window.usedPercent : window.remainingPercent + let paceDetail = Self.antigravityMetricPaceDetail(window: window, input: input) return Metric( id: id, title: title, @@ -225,10 +1023,10 @@ extension UsageMenuCardView.Model { percentStyle: percentStyle, resetText: Self.resetText(for: window, style: input.resetTimeDisplayStyle, now: input.now), detailText: nil, - detailLeftText: nil, - detailRightText: nil, - pacePercent: nil, - paceOnTop: true) + detailLeftText: paceDetail?.leftLabel, + detailRightText: paceDetail?.rightLabel, + pacePercent: paceDetail?.pacePercent, + paceOnTop: paceDetail?.paceOnTop ?? true) } static func zaiLimitDetailText(limit: ZaiLimitEntry?) -> String? { @@ -247,7 +1045,11 @@ extension UsageMenuCardView.Model { return nil } - static func openRouterQuotaDetail(provider: UsageProvider, snapshot: UsageSnapshot) -> String? { + static func openRouterQuotaDetail( + provider: UsageProvider, + snapshot: UsageSnapshot, + preferredCurrencyCode: String = "auto") -> String? + { guard provider == .openrouter, let usage = snapshot.openRouterUsage, usage.hasValidKeyQuota, @@ -257,8 +1059,10 @@ extension UsageMenuCardView.Model { return nil } - let remaining = UsageFormatter.usdString(keyRemaining) - let limit = UsageFormatter.usdString(keyLimit) + let remaining = UsageFormatter.convertedCostString( + keyRemaining, preferredCurrency: preferredCurrencyCode, providerCurrency: "USD") + let limit = UsageFormatter.convertedCostString( + keyLimit, preferredCurrency: preferredCurrencyCode, providerCurrency: "USD") return String(format: L("%@/%@ left"), remaining, limit) } diff --git a/Sources/CodexBar/MenuCardView+ModelInput.swift b/Sources/CodexBar/MenuCardView+ModelInput.swift new file mode 100644 index 0000000000..27b80a908a --- /dev/null +++ b/Sources/CodexBar/MenuCardView+ModelInput.swift @@ -0,0 +1,122 @@ +import CodexBarCore +import Foundation + +extension UsageMenuCardView.Model { + struct Input { + let provider: UsageProvider + let metadata: ProviderMetadata + let snapshot: UsageSnapshot? + let codexProjection: CodexConsumerProjection? + let credits: CreditsSnapshot? + let creditsError: String? + let dashboard: OpenAIDashboardSnapshot? + let dashboardError: String? + let tokenSnapshot: CostUsageTokenSnapshot? + let tokenError: String? + let account: AccountInfo + let accountIsAuthoritative: Bool + let planOverride: String? + let isRefreshing: Bool + let lastError: String? + let limitsAvailability: UsageLimitsAvailability? + let usageBarsShowUsed: Bool + let resetTimeDisplayStyle: ResetTimeDisplayStyle + let tokenCostUsageEnabled: Bool + let codexLocalSessionCostLedgerEnabled: Bool + let tokenCostInlineDashboardEnabled: Bool + let tokenCostMenuSectionEnabled: Bool + let costComparisonPeriodsEnabled: Bool + let showOptionalCreditsAndExtraUsage: Bool + let claudeDailyRoutinesUsageVisible: Bool + let codexSparkUsageVisible: Bool + let copilotBudgetExtrasEnabled: Bool + let sourceLabel: String? + let kiloAutoMode: Bool + let hidePersonalInfo: Bool + let weeklyPace: UsagePace? + let sessionEquivalentForecast: SessionEquivalentForecast? + let quotaWarningThresholds: [QuotaWarningWindow: [Int]] + let workDaysPerWeek: Int? + let usesLiveSubtitle: Bool + let preferredCurrencyCode: String + let now: Date + + init( + provider: UsageProvider, + metadata: ProviderMetadata, + snapshot: UsageSnapshot?, + codexProjection: CodexConsumerProjection? = nil, + credits: CreditsSnapshot?, + creditsError: String?, + dashboard: OpenAIDashboardSnapshot?, + dashboardError: String?, + tokenSnapshot: CostUsageTokenSnapshot?, + tokenError: String?, + account: AccountInfo, + accountIsAuthoritative: Bool = false, + planOverride: String? = nil, + isRefreshing: Bool, + lastError: String?, + limitsAvailability: UsageLimitsAvailability? = nil, + usageBarsShowUsed: Bool, + resetTimeDisplayStyle: ResetTimeDisplayStyle, + tokenCostUsageEnabled: Bool, + codexLocalSessionCostLedgerEnabled: Bool = false, + tokenCostInlineDashboardEnabled: Bool? = nil, + tokenCostMenuSectionEnabled: Bool? = nil, + costComparisonPeriodsEnabled: Bool = false, + showOptionalCreditsAndExtraUsage: Bool, + claudeDailyRoutinesUsageVisible: Bool = true, + codexSparkUsageVisible: Bool = true, + copilotBudgetExtrasEnabled: Bool = false, + sourceLabel: String? = nil, + kiloAutoMode: Bool = false, + hidePersonalInfo: Bool, + weeklyPace: UsagePace? = nil, + sessionEquivalentForecast: SessionEquivalentForecast? = nil, + quotaWarningThresholds: [QuotaWarningWindow: [Int]] = [:], + workDaysPerWeek: Int? = nil, + usesLiveSubtitle: Bool = false, + preferredCurrencyCode: String = "auto", + now: Date) + { + self.provider = provider + self.metadata = metadata + self.snapshot = snapshot + self.codexProjection = codexProjection + self.credits = credits + self.creditsError = creditsError + self.dashboard = dashboard + self.dashboardError = dashboardError + self.tokenSnapshot = tokenSnapshot + self.tokenError = tokenError + self.account = account + self.accountIsAuthoritative = accountIsAuthoritative + self.planOverride = planOverride + self.isRefreshing = isRefreshing + self.lastError = lastError + self.limitsAvailability = limitsAvailability + self.usageBarsShowUsed = usageBarsShowUsed + self.resetTimeDisplayStyle = resetTimeDisplayStyle + self.tokenCostUsageEnabled = tokenCostUsageEnabled + self.codexLocalSessionCostLedgerEnabled = codexLocalSessionCostLedgerEnabled + self.tokenCostInlineDashboardEnabled = tokenCostInlineDashboardEnabled ?? tokenCostUsageEnabled + self.tokenCostMenuSectionEnabled = tokenCostMenuSectionEnabled ?? tokenCostUsageEnabled + self.costComparisonPeriodsEnabled = costComparisonPeriodsEnabled + self.showOptionalCreditsAndExtraUsage = showOptionalCreditsAndExtraUsage + self.claudeDailyRoutinesUsageVisible = claudeDailyRoutinesUsageVisible + self.codexSparkUsageVisible = codexSparkUsageVisible + self.copilotBudgetExtrasEnabled = copilotBudgetExtrasEnabled + self.sourceLabel = sourceLabel + self.kiloAutoMode = kiloAutoMode + self.hidePersonalInfo = hidePersonalInfo + self.weeklyPace = weeklyPace + self.sessionEquivalentForecast = sessionEquivalentForecast + self.quotaWarningThresholds = quotaWarningThresholds + self.workDaysPerWeek = workDaysPerWeek + self.usesLiveSubtitle = usesLiveSubtitle + self.preferredCurrencyCode = preferredCurrencyCode + self.now = now + } + } +} diff --git a/Sources/CodexBar/MenuCardView+SessionEquivalent.swift b/Sources/CodexBar/MenuCardView+SessionEquivalent.swift new file mode 100644 index 0000000000..18a57c3e6b --- /dev/null +++ b/Sources/CodexBar/MenuCardView+SessionEquivalent.swift @@ -0,0 +1,72 @@ +import CodexBarCore + +extension UsageMenuCardView.Model { + static func sessionEquivalentDetail( + input: Input, + weeklyWindow: RateWindow, + weeklyWindowID: String?) -> UsagePaceText.SessionEquivalentDetail? + { + guard let forecast = input.sessionEquivalentForecast, + forecast.applies(to: weeklyWindow, windowID: weeklyWindowID) + else { + return nil + } + return UsagePaceText.sessionEquivalentDetail(forecast: forecast) + } + + static func codexRateMetrics( + input: Input, + projection: CodexConsumerProjection, + percentStyle: PercentStyle) -> [Metric] + { + projection.visibleRateLanes.compactMap { lane in + guard let window = projection.rateWindow(for: lane) else { return nil } + + let title: String + let id: String + let paceDetail: PaceDetail? + switch lane { + case .session: + title = L(input.metadata.sessionLabel) + id = "primary" + paceDetail = Self.sessionPaceDetail( + provider: input.provider, + window: window, + now: input.now, + showUsed: input.usageBarsShowUsed) + case .weekly: + title = L(input.metadata.weeklyLabel) + id = "secondary" + paceDetail = Self.weeklyPaceDetail( + provider: input.provider, + window: window, + now: input.now, + pace: Self.standardWeeklyPace(input: input, window: window), + showUsed: input.usageBarsShowUsed) + } + + return Metric( + id: id, + title: title, + percent: Self.clamped(input.usageBarsShowUsed ? window.usedPercent : window.remainingPercent), + percentStyle: percentStyle, + resetText: Self.resetText(for: window, style: input.resetTimeDisplayStyle, now: input.now), + detailText: nil, + detailLeftText: paceDetail?.leftLabel, + detailRightText: paceDetail?.rightLabel, + pacePercent: paceDetail?.pacePercent, + paceOnTop: paceDetail?.paceOnTop ?? true, + warningMarkerPercents: Self.warningMarkerPercents( + thresholds: input.quotaWarningThresholds[lane.quotaWarningWindow], + showUsed: input.usageBarsShowUsed), + workdayMarkerPercents: lane == .weekly + ? workDayMarkerPercents( + workDays: input.workDaysPerWeek, + windowMinutes: window.windowMinutes) + : [], + sessionEquivalentDetail: lane == .weekly + ? Self.sessionEquivalentDetail(input: input, weeklyWindow: window, weeklyWindowID: nil) + : nil) + } + } +} diff --git a/Sources/CodexBar/MenuCardView.swift b/Sources/CodexBar/MenuCardView.swift index 3c16bb854f..e18e4fa523 100644 --- a/Sources/CodexBar/MenuCardView.swift +++ b/Sources/CodexBar/MenuCardView.swift @@ -37,7 +37,9 @@ struct UsageMenuCardView: View { let pacePercent: Double? let paceOnTop: Bool let warningMarkerPercents: [Double] + let workdayMarkerPercents: [Double] let cardStyle: Bool + let sessionEquivalentDetail: UsagePaceText.SessionEquivalentDetail? init( id: String, @@ -52,7 +54,9 @@ struct UsageMenuCardView: View { pacePercent: Double?, paceOnTop: Bool, warningMarkerPercents: [Double] = [], - cardStyle: Bool = false) + workdayMarkerPercents: [Double] = [], + cardStyle: Bool = false, + sessionEquivalentDetail: UsagePaceText.SessionEquivalentDetail? = nil) { self.id = id self.title = title @@ -66,11 +70,13 @@ struct UsageMenuCardView: View { self.pacePercent = pacePercent self.paceOnTop = paceOnTop self.warningMarkerPercents = warningMarkerPercents + self.workdayMarkerPercents = workdayMarkerPercents self.cardStyle = cardStyle + self.sessionEquivalentDetail = sessionEquivalentDetail } var percentLabel: String { - String(format: "%.0f%% %@", self.percent, self.percentStyle.labelSuffix) + UsageFormatter.percentText(self.percent, suffix: self.percentStyle.labelSuffix) } } @@ -83,9 +89,31 @@ struct UsageMenuCardView: View { struct TokenUsageSection { let sessionLine: String let monthLine: String + let meteredLine: String? + let comparisonLines: [String] let hintLine: String? let errorLine: String? let errorCopyText: String? + + /// Explicit initializer so `meteredLine`/`comparisonLines` default to empty: callers + /// that predate them (and providers that never report them) keep their call sites. + init( + sessionLine: String, + monthLine: String, + meteredLine: String? = nil, + comparisonLines: [String] = [], + hintLine: String?, + errorLine: String?, + errorCopyText: String?) + { + self.sessionLine = sessionLine + self.monthLine = monthLine + self.meteredLine = meteredLine + self.comparisonLines = comparisonLines + self.hintLine = hintLine + self.errorLine = errorLine + self.errorCopyText = errorCopyText + } } struct ProviderCostSection { @@ -93,6 +121,10 @@ struct UsageMenuCardView: View { let percentUsed: Double? let spendLine: String let percentLine: String? + var balanceLine: String? + var personalSpendLine: String? + var presentation: Presentation = .detail + var showsInProviderDetails = true } let provider: UsageProvider @@ -100,6 +132,7 @@ struct UsageMenuCardView: View { let email: String let subtitleText: String let subtitleStyle: SubtitleStyle + var usesLiveSubtitle: Bool = false let planText: String? let metrics: [Metric] let usageNotes: [String] @@ -107,8 +140,10 @@ struct UsageMenuCardView: View { let inlineUsageDashboard: InlineUsageDashboardModel? let creditsText: String? let creditsRemaining: Double? + var creditsProgressPercent: Double?, creditsScaleText: String? let creditsHintText: String? let creditsHintCopyText: String? + var codexResetCredits: CodexResetCreditsPresentation? let providerCost: ProviderCostSection? let tokenUsage: TokenUsageSection? let placeholder: String? @@ -116,8 +151,11 @@ struct UsageMenuCardView: View { } let model: Model + var layoutModel: Model? let width: CGFloat + var planAction: (() -> Void)? @Environment(\.menuItemHighlighted) private var isHighlighted + @Environment(\.menuCardRefreshMonitor) private var refreshMonitor static func popupMetricTitle(provider: UsageProvider, metric: Model.Metric) -> String { if provider == .openrouter, metric.id == "primary" { @@ -127,119 +165,117 @@ struct UsageMenuCardView: View { } var body: some View { - VStack(alignment: .leading, spacing: 6) { - UsageMenuCardHeaderView(model: self.model) + let liveModel = self.liveModel + VStack(alignment: .leading, spacing: 0) { + UsageMenuCardHeaderView( + model: self.layoutModel ?? self.model, + planAction: self.planAction) - if self.hasDetails { + if Self.hasDetails(for: liveModel) { Divider() + .padding(.top, UsageMenuCardLayout.headerContentSpacing) + .padding(.bottom, Self.dividerBottomPadding(for: liveModel)) } - if self.model.metrics.isEmpty { - if let dashboard = self.model.inlineUsageDashboard { + if !liveModel.usesStackedDetailLayout { + if let dashboard = liveModel.inlineUsageDashboard { InlineUsageDashboardContent(model: dashboard) - } else if !self.model.usageNotes.isEmpty { - UsageNotesContent(notes: self.model.usageNotes) - } else if let placeholder = self.model.placeholder { + } else if !liveModel.usageNotes.isEmpty { + UsageNotesContent(notes: liveModel.usageNotes) + } else if let placeholder = liveModel.placeholder { + // Non-stacked placeholders are standalone detail rows; stacked usage placeholders are gated below. Text(placeholder) .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted)) .font(.subheadline) } } else { - let hasUsage = self.model.hasUsageContent - let hasCredits = self.model.creditsText != nil - let hasProviderCost = self.model.providerCost != nil - let hasCost = self.model.tokenUsage != nil || hasProviderCost + let hasUsage = liveModel.hasUsageContent + let hasCredits = liveModel.creditsText != nil + let hasProviderCost = liveModel.providerCost != nil + let hasCost = liveModel.tokenUsage != nil || hasProviderCost VStack(alignment: .leading, spacing: 12) { - if hasUsage { - VStack(alignment: .leading, spacing: 12) { - ForEach(self.model.metrics, id: \.id) { metric in - MetricRow( - metric: metric, - title: Self.popupMetricTitle(provider: self.model.provider, metric: metric), - progressColor: self.model.progressColor) - } - if let dashboard = self.model.inlineUsageDashboard { - InlineUsageDashboardContent(model: dashboard) - } else if !self.model.usageNotes.isEmpty { - UsageNotesContent(notes: self.model.usageNotes) - } - } + if hasUsage, !liveModel.creditsOnlyInlineUsageDashboard { + UsageMenuCardUsageContentView(model: liveModel, showBottomDivider: false) } - if hasUsage, hasCredits || hasCost { + if hasUsage, !liveModel.creditsOnlyInlineUsageDashboard, hasCredits || hasCost { Divider() } - if let credits = self.model.creditsText { + if let credits = liveModel.creditsText { CreditsBarContent( creditsText: credits, - creditsRemaining: self.model.creditsRemaining, - hintText: self.model.creditsHintText, - hintCopyText: self.model.creditsHintCopyText, - progressColor: self.model.progressColor) + creditsRemaining: liveModel.creditsRemaining, + progressPercent: liveModel.creditsProgressPercent, + scaleText: liveModel.creditsScaleText, + hintText: liveModel.creditsHintText, + hintCopyText: liveModel.creditsHintCopyText, + progressColor: liveModel.progressColor) + } + if liveModel.creditsOnlyInlineUsageDashboard, let dashboard = liveModel.inlineUsageDashboard { + InlineUsageDashboardContent(model: dashboard) } if hasCredits, hasCost { Divider() } - if let providerCost = self.model.providerCost { + if let providerCost = liveModel.providerCost { ProviderCostContent( section: providerCost, - progressColor: self.model.progressColor) + progressColor: liveModel.progressColor) } - if hasProviderCost, self.model.tokenUsage != nil { + if hasProviderCost, liveModel.tokenUsage != nil { Divider() } - if let tokenUsage = self.model.tokenUsage { - VStack(alignment: .leading, spacing: 6) { - Text(L("cost_header_estimated")) - .font(.body) - .fontWeight(.medium) - Text(tokenUsage.sessionLine) - .font(.footnote) - Text(tokenUsage.monthLine) - .font(.footnote) - if let hint = tokenUsage.hintLine, !hint.isEmpty { - Text(hint) - .font(.footnote) - .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted)) - .lineLimit(4) - .fixedSize(horizontal: false, vertical: true) - } - if let error = tokenUsage.errorLine, !error.isEmpty { - Text(error) - .font(.footnote) - .foregroundStyle(MenuHighlightStyle.error(self.isHighlighted)) - .lineLimit(4) - .fixedSize(horizontal: false, vertical: true) - .overlay { - ClickToCopyOverlay(copyText: tokenUsage.errorCopyText ?? error) - } - } - } + if let tokenUsage = liveModel.tokenUsage { + TokenUsageSectionContent( + provider: liveModel.provider, + tokenUsage: tokenUsage, + showsCodexHint: liveModel.inlineUsageDashboard == nil, + lineFont: .footnote) } } - .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(for: liveModel) + ? UsageMenuCardLayout.sectionTopPadding + : UsageMenuCardLayout.headerOnlyVerticalPadding) + // AppKit's following separator row adds visual bottom space, so detail cards keep this inset tight. + .padding( + .bottom, + Self.hasDetails(for: liveModel) + ? UsageMenuCardLayout.sectionBottomPadding + : UsageMenuCardLayout.headerOnlyVerticalPadding) .frame(width: self.width, alignment: .leading) } - private var hasDetails: Bool { - self.model.hasUsageContent || - self.model.tokenUsage != nil || - self.model.providerCost != nil + private var liveModel: Model { + guard self.model.usesLiveSubtitle else { return self.model } + return self.refreshMonitor?.model(for: self.model.provider, fallback: self.model) ?? self.model + } + + private static func hasDetails(for model: Model) -> Bool { + model.hasUsageContent || model.usesStackedDetailLayout + } + + static func dividerBottomPadding(for model: Model) -> CGFloat { + if model.usesStackedDetailLayout, model.hasUsageContent { + return UsageMenuCardLayout.postHeaderDividerContentSpacing + } + return UsageMenuCardLayout.sectionBottomPadding } } private struct UsageMenuCardHeaderView: View { let model: UsageMenuCardView.Model + var planAction: (() -> Void)? @Environment(\.menuItemHighlighted) private var isHighlighted + @Environment(\.menuCardRefreshMonitor) private var refreshMonitor 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) @@ -248,32 +284,79 @@ private struct UsageMenuCardHeaderView: View { .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted)) .lineLimit(1).truncationMode(.middle) } - let subtitleAlignment: VerticalAlignment = self.model.subtitleStyle == .error ? .top : .firstTextBaseline - HStack(alignment: subtitleAlignment) { - Text(self.model.subtitleText) - .font(.footnote) - .foregroundStyle(self.subtitleColor) - .lineLimit(self.model.subtitleStyle == .error ? 4 : 1) - .multilineTextAlignment(.leading) - .fixedSize(horizontal: false, vertical: true) - .layoutPriority(1) - .padding(.bottom, self.model.subtitleStyle == .error ? 4 : 0) + let liveSubtitle = self.liveSubtitle + // Keep the geometry AppKit measured for this hosted row. A new error stays one line + // until the next rebuild; a recovered error keeps its reserved height until then. + let usesErrorLayout = self.model.subtitleStyle == .error + let subtitleAlignment: VerticalAlignment = usesErrorLayout ? .top : .firstTextBaseline + HStack(alignment: subtitleAlignment, spacing: UsageMenuCardLayout.headerColumnSpacing) { + if usesErrorLayout { + Text(self.model.subtitleText) + .font(.footnote) + .lineLimit(4) + .multilineTextAlignment(.leading) + .fixedSize(horizontal: false, vertical: true) + .padding(.bottom, 4) + .hidden() + .overlay(alignment: .topLeading) { + Text(liveSubtitle.text) + .font(.footnote) + .foregroundStyle(self.subtitleColor(for: liveSubtitle.style)) + .lineLimit(4) + .multilineTextAlignment(.leading) + .fixedSize(horizontal: false, vertical: true) + } + .clipped() + .layoutPriority(1) + } else { + Text(liveSubtitle.text) + .font(.footnote) + .foregroundStyle(self.subtitleColor(for: liveSubtitle.style)) + .lineLimit(1) + .multilineTextAlignment(.leading) + .fixedSize(horizontal: false, vertical: true) + .layoutPriority(1) + } Spacer() - if self.model.subtitleStyle == .error, !self.model.subtitleText.isEmpty { - CopyIconButton(copyText: self.model.subtitleText, isHighlighted: self.isHighlighted) + if usesErrorLayout { + let showsCopyButton = liveSubtitle.style == .error && !liveSubtitle.text.isEmpty + CopyIconButton( + copyText: liveSubtitle.text, + isHighlighted: self.isHighlighted, + isInteractive: showsCopyButton) + .opacity(showsCopyButton ? 1 : 0) + .allowsHitTesting(showsCopyButton) + .accessibilityHidden(!showsCopyButton) } if let plan = self.model.planText { - Text(plan) - .font(.footnote) - .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted)) - .lineLimit(1) + Group { + if let planAction { + Button(action: planAction) { + Text(plan) + } + .buttonStyle(.plain) + .menuCardInteractiveControl() + .accessibilityLabel(plan) + } else { + Text(plan) + } + } + .font(.footnote) + .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted)) + .lineLimit(1) } } } } - private var subtitleColor: Color { - switch self.model.subtitleStyle { + private var liveSubtitle: MenuCardLiveSubtitle { + let fallback = MenuCardLiveSubtitle(text: self.model.subtitleText, style: self.model.subtitleStyle) + guard self.model.usesLiveSubtitle else { return fallback } + return self.refreshMonitor?.subtitle(for: self.model.provider, fallback: fallback) ?? fallback + } + + private func subtitleColor(for style: UsageMenuCardView.Model.SubtitleStyle) -> Color { + switch style { case .info: MenuHighlightStyle.secondary(self.isHighlighted) case .loading: MenuHighlightStyle.secondary(self.isHighlighted) case .error: MenuHighlightStyle.error(self.isHighlighted) @@ -299,23 +382,14 @@ private struct CopyIconButtonStyle: ButtonStyle { private struct CopyIconButton: View { let copyText: String let isHighlighted: Bool + let isInteractive: Bool @State private var didCopy = false @State private var resetTask: Task? var body: some View { Button { - self.copyToPasteboard() - withAnimation(.easeOut(duration: 0.12)) { - self.didCopy = true - } - self.resetTask?.cancel() - self.resetTask = Task { @MainActor in - try? await Task.sleep(for: .seconds(0.9)) - withAnimation(.easeOut(duration: 0.2)) { - self.didCopy = false - } - } + self.handleCopy() } label: { Image(systemName: self.didCopy ? "checkmark" : "doc.on.doc") .font(.caption2.weight(.semibold)) @@ -323,41 +397,72 @@ private struct CopyIconButton: View { .frame(width: 18, height: 18) } .buttonStyle(CopyIconButtonStyle(isHighlighted: self.isHighlighted)) + .menuCardInteractiveControl(isEnabled: self.isInteractive) .accessibilityLabel(self.didCopy ? L("Copied") : L("Copy error")) } - private func copyToPasteboard() { - let pb = NSPasteboard.general - pb.clearContents() - pb.setString(self.copyText, forType: .string) + private func handleCopy() { + let text = self.copyText + self.resetTask?.cancel() + MenuPasteboardCopy.perform(text, completion: { + self.didCopy = true + self.resetTask = Task { @MainActor in + try? await Task.sleep(for: .seconds(0.9)) + self.didCopy = false + } + }) } } -private struct ProviderCostContent: View { - let section: UsageMenuCardView.Model.ProviderCostSection - let progressColor: Color +/// Shared token-cost block (header, Today/window/metered/comparison lines, hint, error) used by +/// both the inline card body and the standalone cost section; only the value-line font differs. +private struct TokenUsageSectionContent: View { + let provider: UsageProvider + let tokenUsage: UsageMenuCardView.Model.TokenUsageSection + let showsCodexHint: Bool + let lineFont: Font @Environment(\.menuItemHighlighted) private var isHighlighted var body: some View { VStack(alignment: .leading, spacing: 6) { - Text(self.section.title) + Text(UsageMenuCardView.Model.tokenUsageHeader(provider: self.provider)) .font(.body) .fontWeight(.medium) - if let percentUsed = self.section.percentUsed { - UsageProgressBar( - percent: percentUsed, - tint: self.progressColor, - accessibilityLabel: L("Extra usage spent")) + Text(self.tokenUsage.sessionLine) + .font(self.lineFont) + .lineLimit(1) + Text(self.tokenUsage.monthLine) + .font(self.lineFont) + .lineLimit(1) + if let metered = self.tokenUsage.meteredLine, !metered.isEmpty { + Text(metered) + .font(self.lineFont) + .lineLimit(1) } - HStack(alignment: .firstTextBaseline) { - Text(self.section.spendLine) + ForEach(self.tokenUsage.comparisonLines, id: \.self) { line in + Text(line) + .font(self.lineFont) + .lineLimit(1) + } + if self.provider != .codex || self.showsCodexHint, + let hint = self.tokenUsage.hintLine, + !hint.isEmpty + { + Text(hint) .font(.footnote) - Spacer() - if let percentLine = self.section.percentLine { - Text(percentLine) - .font(.footnote) - .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted)) - } + .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted)) + .lineLimit(4) + .fixedSize(horizontal: false, vertical: true) + } + if let error = self.tokenUsage.errorLine, !error.isEmpty { + Text(error) + .font(.footnote) + .foregroundStyle(MenuHighlightStyle.error(self.isHighlighted)) + .lineLimit(4) + .fixedSize(horizontal: false, vertical: true) + .overlay { + ClickToCopyOverlay(copyText: self.tokenUsage.errorCopyText ?? error) + } } } } @@ -386,7 +491,8 @@ private struct MetricRow: View { accessibilityLabel: self.metric.percentStyle.accessibilityLabel, pacePercent: self.metric.pacePercent, paceOnTop: self.metric.paceOnTop, - warningMarkerPercents: self.metric.warningMarkerPercents) + warningMarkerPercents: self.metric.warningMarkerPercents, + workdayMarkerPercents: self.metric.workdayMarkerPercents) VStack(alignment: .leading, spacing: 2) { HStack(alignment: .firstTextBaseline) { Text(self.metric.percentLabel) @@ -417,6 +523,21 @@ private struct MetricRow: View { } } } + if let sessionEquivalentDetail = self.metric.sessionEquivalentDetail { + HStack(alignment: .firstTextBaseline) { + Text(sessionEquivalentDetail.leftText) + .font(.footnote) + .foregroundStyle(MenuHighlightStyle.primary(self.isHighlighted)) + .lineLimit(1) + Spacer() + Text(sessionEquivalentDetail.rightText) + .font(.footnote) + .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted)) + .lineLimit(1) + } + .accessibilityElement(children: .ignore) + .accessibilityLabel(sessionEquivalentDetail.accessibilityLabel) + } } .frame(maxWidth: .infinity, alignment: .leading) if let detail = self.metric.detailText { @@ -458,60 +579,127 @@ struct UsageMenuCardHeaderSectionView: View { let width: CGFloat var body: some View { - VStack(alignment: .leading, spacing: 6) { - UsageMenuCardHeaderView(model: self.model) + VStack(alignment: .leading, spacing: UsageMenuCardLayout.headerContentSpacing) { + UsageMenuCardHeaderView(model: self.model, planAction: nil) if self.showDivider { 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 { +private struct UsageMenuCardUsageContentView: View { let model: UsageMenuCardView.Model let showBottomDivider: Bool - let bottomPadding: CGFloat - let width: CGFloat + var showsSectionDividers = true @Environment(\.menuItemHighlighted) private var isHighlighted + /// Doubao ships Coding Plan and Agent Plan subscriptions, each with personal + /// and team editions whose windows share period labels. Split the two plan + /// families here; team rows keep distinct ids and disclose their edition. + private var doubaoSplitMetrics: ( + coding: [UsageMenuCardView.Model.Metric], + agent: [UsageMenuCardView.Model.Metric])? + { + guard self.model.provider == .doubao else { return nil } + let agent = self.model.metrics.filter { $0.id.hasPrefix("doubao-agent-") } + guard !agent.isEmpty else { return nil } + let coding = self.model.metrics.filter { !$0.id.hasPrefix("doubao-agent-") } + return (coding, agent) + } + + private func groupHeader(_ title: String) -> some View { + Text(L(title)) + .font(.caption.weight(.semibold)) + .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted)) + .textCase(.uppercase) + } + + private func metricRows(_ metrics: [UsageMenuCardView.Model.Metric]) -> some View { + ForEach(metrics, id: \.id) { metric in + MetricRow( + metric: metric, + title: UsageMenuCardView.popupMetricTitle(provider: self.model.provider, metric: metric), + progressColor: self.model.progressColor) + } + } + var body: some View { VStack(alignment: .leading, spacing: 12) { - if self.model.metrics.isEmpty { - if let dashboard = self.model.inlineUsageDashboard { - InlineUsageDashboardContent(model: dashboard) - } else if !self.model.usageNotes.isEmpty { - UsageNotesContent(notes: self.model.usageNotes) - } else if let placeholder = self.model.placeholder { - Text(placeholder) - .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted)) - .font(.subheadline) + if let split = self.doubaoSplitMetrics { + if !split.coding.isEmpty { + self.groupHeader("Coding Plan") + self.metricRows(split.coding) } - } else { - ForEach(self.model.metrics, id: \.id) { metric in - MetricRow( - metric: metric, - title: UsageMenuCardView.popupMetricTitle(provider: self.model.provider, metric: metric), - progressColor: self.model.progressColor) + if !split.coding.isEmpty, self.showsSectionDividers { + Divider() } - if let dashboard = self.model.inlineUsageDashboard { - InlineUsageDashboardContent(model: dashboard) - } else if !self.model.usageNotes.isEmpty { - UsageNotesContent(notes: self.model.usageNotes) + self.groupHeader("Agent Plan") + self.metricRows(split.agent) + } else { + self.metricRows(self.model.metrics) + } + if let resetCredits = self.model.codexResetCredits { + if !self.model.metrics.isEmpty, self.showsSectionDividers { + Divider() } + CodexResetCreditsContent(presentation: resetCredits) + } + if let dashboard = self.model.inlineUsageDashboard { + InlineUsageDashboardContent(model: dashboard) + } else if !self.model.usageNotes.isEmpty { + UsageNotesContent(notes: self.model.usageNotes) + } else if let placeholder = self.model.placeholder, self.model.metrics.isEmpty, + self.model.codexResetCredits == nil + { + Text(placeholder) + .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted)) + .font(.subheadline) } if self.showBottomDivider { Divider() } } - .padding(.horizontal, 16) - .padding(.top, 10) - .padding(.bottom, self.bottomPadding) - .frame(width: self.width, alignment: .leading) + } +} + +struct UsageMenuCardUsageSectionView: View { + let model: UsageMenuCardView.Model + let showBottomDivider: Bool + let bottomPadding: CGFloat + let width: CGFloat + var showsSectionDividers = true + @Environment(\.menuCardRefreshMonitor) private var refreshMonitor + + var body: some View { + let liveModel = self.liveModel + UsageMenuCardUsageContentView( + model: liveModel, + showBottomDivider: self.showBottomDivider, + showsSectionDividers: self.showsSectionDividers) + .padding(.horizontal, UsageMenuCardLayout.horizontalPadding) + .padding(.top, UsageMenuCardLayout.usageSectionTopPadding) + .padding(.bottom, self.bottomPadding) + .frame(width: self.width, alignment: .leading) + } + + private var liveModel: UsageMenuCardView.Model { + guard self.model.usesLiveSubtitle else { return self.model } + return self.refreshMonitor?.model(for: self.model.provider, fallback: self.model) ?? self.model } } @@ -521,26 +709,35 @@ struct UsageMenuCardCreditsSectionView: View { let topPadding: CGFloat let bottomPadding: CGFloat let width: CGFloat + @Environment(\.menuCardRefreshMonitor) private var refreshMonitor var body: some View { - if let credits = self.model.creditsText { + let liveModel = self.liveModel + if let credits = liveModel.creditsText { VStack(alignment: .leading, spacing: 6) { CreditsBarContent( creditsText: credits, - creditsRemaining: self.model.creditsRemaining, - hintText: self.model.creditsHintText, - hintCopyText: self.model.creditsHintCopyText, - progressColor: self.model.progressColor) + creditsRemaining: liveModel.creditsRemaining, + progressPercent: liveModel.creditsProgressPercent, + scaleText: liveModel.creditsScaleText, + hintText: liveModel.creditsHintText, + hintCopyText: liveModel.creditsHintCopyText, + progressColor: liveModel.progressColor) if self.showBottomDivider { Divider() } } - .padding(.horizontal, 16) + .padding(.horizontal, UsageMenuCardLayout.horizontalPadding) .padding(.top, self.topPadding) .padding(.bottom, self.bottomPadding) .frame(width: self.width, alignment: .leading) } } + + private var liveModel: UsageMenuCardView.Model { + guard self.model.usesLiveSubtitle else { return self.model } + return self.refreshMonitor?.model(for: self.model.provider, fallback: self.model) ?? self.model + } } private struct CreditsBarContent: View { @@ -548,18 +745,25 @@ private struct CreditsBarContent: View { let creditsText: String let creditsRemaining: Double? + var progressPercent: Double?, scaleText: String? let hintText: String? let hintCopyText: String? let progressColor: Color @Environment(\.menuItemHighlighted) private var isHighlighted private var percentLeft: Double? { + if let progressPercent { + return min(100, max(0, progressPercent)) + } guard let creditsRemaining else { return nil } let percent = (creditsRemaining / Self.fullScaleTokens) * 100 return min(100, max(0, percent)) } - private var scaleText: String { + private var effectiveScaleText: String { + if let scaleText { + return scaleText + } let scale = UsageFormatter.tokenCountString(Int(Self.fullScaleTokens)) return "\(scale) \(L("tokens"))" } @@ -577,8 +781,9 @@ private struct CreditsBarContent: View { HStack(alignment: .firstTextBaseline) { Text(self.creditsText) .font(.caption) + .lineLimit(1) Spacer() - Text(self.scaleText) + Text(self.effectiveScaleText) .font(.caption) .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted)) } @@ -606,48 +811,34 @@ struct UsageMenuCardCostSectionView: View { let bottomPadding: CGFloat let width: CGFloat @Environment(\.menuItemHighlighted) private var isHighlighted + @Environment(\.menuCardRefreshMonitor) private var refreshMonitor var body: some View { - let hasTokenCost = self.model.tokenUsage != nil + let liveModel = self.liveModel + let hasTokenCost = liveModel.tokenUsage != nil return Group { if hasTokenCost { VStack(alignment: .leading, spacing: 10) { - if let tokenUsage = self.model.tokenUsage { - VStack(alignment: .leading, spacing: 6) { - Text(L("cost_header_estimated")) - .font(.body) - .fontWeight(.medium) - Text(tokenUsage.sessionLine) - .font(.caption) - Text(tokenUsage.monthLine) - .font(.caption) - if let hint = tokenUsage.hintLine, !hint.isEmpty { - Text(hint) - .font(.footnote) - .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted)) - .lineLimit(4) - .fixedSize(horizontal: false, vertical: true) - } - if let error = tokenUsage.errorLine, !error.isEmpty { - Text(error) - .font(.footnote) - .foregroundStyle(MenuHighlightStyle.error(self.isHighlighted)) - .lineLimit(4) - .fixedSize(horizontal: false, vertical: true) - .overlay { - ClickToCopyOverlay(copyText: tokenUsage.errorCopyText ?? error) - } - } - } + if let tokenUsage = liveModel.tokenUsage { + TokenUsageSectionContent( + provider: liveModel.provider, + tokenUsage: tokenUsage, + showsCodexHint: true, + lineFont: .caption) } } - .padding(.horizontal, 16) + .padding(.horizontal, UsageMenuCardLayout.horizontalPadding) .padding(.top, self.topPadding) .padding(.bottom, self.bottomPadding) .frame(width: self.width, alignment: .leading) } } } + + private var liveModel: UsageMenuCardView.Model { + guard self.model.usesLiveSubtitle else { return self.model } + return self.refreshMonitor?.model(for: self.model.provider, fallback: self.model) ?? self.model + } } struct UsageMenuCardExtraUsageSectionView: View { @@ -655,140 +846,95 @@ struct UsageMenuCardExtraUsageSectionView: View { let topPadding: CGFloat let bottomPadding: CGFloat let width: CGFloat + @Environment(\.menuCardRefreshMonitor) private var refreshMonitor var body: some View { + let liveModel = self.liveModel Group { - if let providerCost = self.model.providerCost { + if let providerCost = liveModel.providerCost { ProviderCostContent( section: providerCost, - progressColor: self.model.progressColor) - .padding(.horizontal, 16) + progressColor: liveModel.progressColor) + .padding(.horizontal, UsageMenuCardLayout.horizontalPadding) .padding(.top, self.topPadding) .padding(.bottom, self.bottomPadding) .frame(width: self.width, alignment: .leading) } } } + + private var liveModel: UsageMenuCardView.Model { + guard self.model.usesLiveSubtitle else { return self.model } + return self.refreshMonitor?.model(for: self.model.provider, fallback: self.model) ?? self.model + } } // MARK: - Model factory extension UsageMenuCardView.Model { - struct Input { - let provider: UsageProvider - let metadata: ProviderMetadata - let snapshot: UsageSnapshot? - let codexProjection: CodexConsumerProjection? - let credits: CreditsSnapshot? - let creditsError: String? - let dashboard: OpenAIDashboardSnapshot? - let dashboardError: String? - let tokenSnapshot: CostUsageTokenSnapshot? - let tokenError: String? - let account: AccountInfo - let isRefreshing: Bool - let lastError: String? - let usageBarsShowUsed: Bool - let resetTimeDisplayStyle: ResetTimeDisplayStyle - let tokenCostUsageEnabled: Bool - let showOptionalCreditsAndExtraUsage: Bool - let sourceLabel: String? - let kiloAutoMode: Bool - let hidePersonalInfo: Bool - let weeklyPace: UsagePace? - let quotaWarningThresholds: [QuotaWarningWindow: [Int]] - let workDaysPerWeek: Int? - let now: Date - - init( - provider: UsageProvider, - metadata: ProviderMetadata, - snapshot: UsageSnapshot?, - codexProjection: CodexConsumerProjection? = nil, - credits: CreditsSnapshot?, - creditsError: String?, - dashboard: OpenAIDashboardSnapshot?, - dashboardError: String?, - tokenSnapshot: CostUsageTokenSnapshot?, - tokenError: String?, - account: AccountInfo, - isRefreshing: Bool, - lastError: String?, - usageBarsShowUsed: Bool, - resetTimeDisplayStyle: ResetTimeDisplayStyle, - tokenCostUsageEnabled: Bool, - showOptionalCreditsAndExtraUsage: Bool, - sourceLabel: String? = nil, - kiloAutoMode: Bool = false, - hidePersonalInfo: Bool, - weeklyPace: UsagePace? = nil, - quotaWarningThresholds: [QuotaWarningWindow: [Int]] = [:], - workDaysPerWeek: Int? = nil, - now: Date) - { - self.provider = provider - self.metadata = metadata - self.snapshot = snapshot - self.codexProjection = codexProjection - self.credits = credits - self.creditsError = creditsError - self.dashboard = dashboard - self.dashboardError = dashboardError - self.tokenSnapshot = tokenSnapshot - self.tokenError = tokenError - self.account = account - self.isRefreshing = isRefreshing - self.lastError = lastError - self.usageBarsShowUsed = usageBarsShowUsed - self.resetTimeDisplayStyle = resetTimeDisplayStyle - self.tokenCostUsageEnabled = tokenCostUsageEnabled - self.showOptionalCreditsAndExtraUsage = showOptionalCreditsAndExtraUsage - self.sourceLabel = sourceLabel - self.kiloAutoMode = kiloAutoMode - self.hidePersonalInfo = hidePersonalInfo - self.weeklyPace = weeklyPace - self.quotaWarningThresholds = quotaWarningThresholds - self.workDaysPerWeek = workDaysPerWeek - self.now = now - } - } - static func make(_ input: Input) -> UsageMenuCardView.Model { let planText = Self.plan( for: input.provider, snapshot: input.snapshot, account: input.account, + override: input.planOverride, metadata: input.metadata) - let metrics = Self.metrics(input: input) + let metrics = Self.redactedMetrics( + Self.metrics(input: input), + provider: input.provider, + hidePersonalInfo: input.hidePersonalInfo) let openAIAPIUsage = input.snapshot?.openAIAPIUsage let inlineUsageDashboard = Self.inlineUsageDashboard(input: input) let usageNotes = Self.usageNotes(input: input) - let creditsText: String? = if input.provider == .openrouter { + let rawCreditsText: String? = if input.provider == .openrouter { nil } else if input.codexProjection != nil, !input.showOptionalCreditsAndExtraUsage { nil } else { - Self.creditsLine(metadata: input.metadata, credits: input.credits, error: input.creditsError) - } + Self.creditsLine( + metadata: input.metadata, + snapshot: input.snapshot, + credits: input.credits, + error: input.creditsError, + preferredCurrencyCode: input.preferredCurrencyCode) + } + let creditsText = PersonalInfoRedactor.redactEmails(in: rawCreditsText, isEnabled: input.hidePersonalInfo) + let creditsProgressPercent = Self.creditsProgressPercent(credits: input.credits) + let creditsScaleText = Self.creditsScaleText(credits: input.credits) + let codexCreditLimitDetail = Self.codexCreditLimitDetail(credits: input.credits, now: input.now) let isClaudeAdminAPI = input.provider == .claude && - input.snapshot?.identity?.loginMethod == "Admin API" + input.snapshot?.claudeAdminAPIUsage != nil + let isRequiredOpenCodeZenBalance = Self.isRequiredOpenCodeZenBalance(input.snapshot) let hidesOptionalProviderCost = ((input.provider == .claude && !isClaudeAdminAPI) || input.provider == .factory || - input.provider == .opencodego) && + input.provider == .devin || + (input.provider == .opencodego && !isRequiredOpenCodeZenBalance)) && !input.showOptionalCreditsAndExtraUsage - let providerCost: ProviderCostSection? = if hidesOptionalProviderCost || + let providerCost: ProviderCostSection? = if input.provider == .sakana { + input.showOptionalCreditsAndExtraUsage + ? Self.sakanaPayAsYouGoSection( + input.snapshot?.sakanaPayAsYouGo, + preferredCurrencyCode: input.preferredCurrencyCode) + : nil + } else if hidesOptionalProviderCost || (input.provider == .openai && openAIAPIUsage != nil) { nil } else { - Self.providerCostSection(provider: input.provider, cost: input.snapshot?.providerCost) + Self.providerCostSection( + provider: input.provider, + cost: input.snapshot?.providerCost, + isClaudeAdminAPI: isClaudeAdminAPI, + preferredCurrencyCode: input.preferredCurrencyCode) } let tokenUsageSnapshot = Self.tokenUsageSnapshot(input: input) let tokenUsage = Self.tokenUsageSection( provider: input.provider, - enabled: input.tokenCostUsageEnabled, + enabled: input.tokenCostMenuSectionEnabled, + comparisonPeriodsEnabled: input.costComparisonPeriodsEnabled, snapshot: tokenUsageSnapshot, - error: input.tokenError) + error: input.tokenError, + preferredCurrencyCode: input.preferredCurrencyCode) let subtitle = Self.subtitle( snapshot: input.snapshot, isRefreshing: input.isRefreshing, @@ -803,70 +949,26 @@ extension UsageMenuCardView.Model { email: redacted.email, subtitleText: redacted.subtitleText, subtitleStyle: subtitle.style, + usesLiveSubtitle: input.usesLiveSubtitle, planText: planText, metrics: metrics, usageNotes: usageNotes, openAIAPIUsage: openAIAPIUsage, inlineUsageDashboard: inlineUsageDashboard, creditsText: creditsText, - creditsRemaining: input.credits?.remaining, - creditsHintText: redacted.creditsHintText, - creditsHintCopyText: redacted.creditsHintCopyText, + creditsRemaining: input.credits?.codexCreditLimit?.remaining ?? input.credits?.remaining, + creditsProgressPercent: creditsProgressPercent, + creditsScaleText: creditsScaleText, + creditsHintText: codexCreditLimitDetail ?? redacted.creditsHintText, + creditsHintCopyText: codexCreditLimitDetail ?? redacted.creditsHintCopyText, + codexResetCredits: Self.codexResetCredits(input: input), providerCost: providerCost, tokenUsage: tokenUsage, placeholder: placeholder, progressColor: Self.progressColor(for: input.provider)) } - private static func usageNotes(input: Input) -> [String] { - if input.provider == .kiro { - return kiroUsageNotes(input: input) - } - - if input.provider == .kilo { - var notes = Self.kiloLoginDetails(snapshot: input.snapshot) - let resolvedSource = input.sourceLabel? - .trimmingCharacters(in: .whitespacesAndNewlines) - .lowercased() - if input.kiloAutoMode, - resolvedSource == "cli", - !notes.contains(where: { $0.caseInsensitiveCompare("Using CLI fallback") == .orderedSame }) - { - notes.append(L("Using CLI fallback")) - } - return notes - } - - if input.provider == .mimo, input.snapshot != nil { - return [ - L("Balance updates in near-real time (up to 5 min lag)"), - L("Daily billing data finalizes at 07:00 UTC"), - ] - } - - if let notes = apiProviderUsageNotes(input: input) { - return notes - } - - guard input.provider == .openrouter, - let openRouter = input.snapshot?.openRouterUsage - else { - return [] - } - - var notes = Self.openRouterSpendNotes(openRouter) - switch openRouter.keyQuotaStatus { - case .available: - break - case .noLimitConfigured: - notes.append(L("No limit set for the API key")) - case .unavailable: - notes.append(L("API key limit unavailable right now")) - } - return notes - } - - private static func openRouterSpendNotes(_ usage: OpenRouterUsageSnapshot) -> [String] { + static func openRouterSpendNotes(_ usage: OpenRouterUsageSnapshot) -> [String] { var parts: [String] = [] if let daily = usage.keyUsageDaily { parts.append("\(L("Today")): \(Self.openRouterCurrencyString(daily))") @@ -886,10 +988,13 @@ extension UsageMenuCardView.Model { for provider: UsageProvider, snapshot: UsageSnapshot?, account: AccountInfo, - metadata: ProviderMetadata) -> String + metadata: ProviderMetadata, + accountIsAuthoritative: Bool) -> String { - if let email = snapshot?.accountEmail(for: provider), !email.isEmpty { return email } - if metadata.usesAccountFallback, + if let email = snapshot?.accountEmail(for: provider), !email.isEmpty { + return email + } + if metadata.usesAccountFallback || accountIsAuthoritative, let email = account.email, !email.isEmpty { return email @@ -901,8 +1006,12 @@ extension UsageMenuCardView.Model { for provider: UsageProvider, snapshot: UsageSnapshot?, account: AccountInfo, + override: String?, metadata: ProviderMetadata) -> String? { + if let override, !override.isEmpty { + return override + } if provider == .kiro, let plan = kiroPlan(snapshot: snapshot) { @@ -914,6 +1023,12 @@ extension UsageMenuCardView.Model { } return self.planDisplay(pass, for: provider) } + if provider == .amp, + let plan = snapshot?.ampUsage?.subscriptionPlan, + !plan.isEmpty + { + return self.planDisplay(plan, for: provider) + } if let plan = snapshot?.loginMethod(for: provider), !plan.isEmpty { return self.planDisplay(plan, for: provider) } @@ -926,6 +1041,9 @@ extension UsageMenuCardView.Model { } private static func planDisplay(_ text: String, for provider: UsageProvider) -> String { + if provider == .minimax { + return self.miniMaxPlanDisplay(text) + } let cleaned = if provider == .codex { CodexPlanFormatting.displayName(text) ?? UsageFormatter.cleanPlanName(text) } else { @@ -934,11 +1052,26 @@ extension UsageMenuCardView.Model { return cleaned.isEmpty ? text : cleaned } + private static func miniMaxPlanDisplay(_ text: String) -> String { + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + let normalized = trimmed.lowercased() + if normalized.contains("tokenplanplus") || normalized.contains("token plan plus") { + return "Plus" + } + if normalized.contains("tokenplanmax") || normalized.contains("token plan max") { + return "Max" + } + if normalized.contains("tokenplanultra") || normalized.contains("token plan ultra") { + return "Ultra" + } + return trimmed + } + private static func kiloLoginPass(snapshot: UsageSnapshot?) -> String? { self.kiloLoginParts(snapshot: snapshot).pass } - private static func kiloLoginDetails(snapshot: UsageSnapshot?) -> [String] { + static func kiloLoginDetails(snapshot: UsageSnapshot?) -> [String] { self.kiloLoginParts(snapshot: snapshot).details } @@ -975,7 +1108,7 @@ extension UsageMenuCardView.Model { return (lastError.trimmingCharacters(in: .whitespacesAndNewlines), .error) } - if isRefreshing, snapshot == nil { + if isRefreshing { return ("\(L("Refreshing"))…", .loading) } @@ -1002,7 +1135,8 @@ extension UsageMenuCardView.Model { for: input.provider, snapshot: input.snapshot, account: input.account, - metadata: input.metadata), + metadata: input.metadata, + accountIsAuthoritative: input.accountIsAuthoritative), isEnabled: input.hidePersonalInfo) let subtitleText = PersonalInfoRedactor.redactEmails(in: subtitle.text, isEnabled: input.hidePersonalInfo) ?? subtitle.text @@ -1031,7 +1165,8 @@ extension UsageMenuCardView.Model { } if input.provider == .minimax { if let minimaxUsage = snapshot.minimaxUsage { - if let services = minimaxUsage.services, !services.isEmpty { + let services = minimaxUsage.orderedQuotaServices + if !services.isEmpty { return Self.minimaxMetrics(services: services, input: input) } } @@ -1042,8 +1177,25 @@ extension UsageMenuCardView.Model { let zaiTokenDetail = Self.zaiLimitDetailText(limit: zaiUsage?.tokenLimit) let zaiTimeDetail = Self.zaiLimitDetailText(limit: zaiUsage?.timeLimit) let zaiSessionDetail = Self.zaiLimitDetailText(limit: zaiUsage?.sessionTokenLimit) - let openRouterQuotaDetail = Self.openRouterQuotaDetail(provider: input.provider, snapshot: snapshot) + let openRouterQuotaDetail = Self.openRouterQuotaDetail( + provider: input.provider, + snapshot: snapshot, + preferredCurrencyCode: input.preferredCurrencyCode) let labels = Self.rateWindowLabels(input: input, snapshot: snapshot) + if input.provider == .mistral, let credits = snapshot.mistralUsage?.credits { + metrics.append(Metric( + id: "mistral-balance", + title: L("Balance"), + percent: 0, + percentStyle: percentStyle, + statusText: credits.formattedAvailableAmount, + resetText: nil, + detailText: nil, + detailLeftText: nil, + detailRightText: nil, + pacePercent: nil, + paceOnTop: true)) + } if input.provider == .codex, let codexProjection = input.codexProjection { metrics.append(contentsOf: Self.codexRateMetrics( input: input, @@ -1066,6 +1218,20 @@ extension UsageMenuCardView.Model { title: labels.secondary, zaiTimeDetail: zaiTimeDetail)) } + if input.provider == .mimo, let mimoUsage = snapshot.mimoUsage { + metrics.append(Metric( + id: "mimo-balance", + title: L("Balance"), + percent: 0, + percentStyle: percentStyle, + statusText: mimoUsage.balanceDetail, + resetText: nil, + detailText: nil, + detailLeftText: nil, + detailRightText: nil, + pacePercent: nil, + paceOnTop: true)) + } if labels.showsTertiary, let opus = snapshot.tertiary { var tertiaryDetailText: String? if input.provider == .alibaba || input.provider == .alibabatokenplan, @@ -1078,9 +1244,10 @@ extension UsageMenuCardView.Model { tertiaryDetailText = detail } // Perplexity purchased credits don't reset; show balance without "Resets" prefix. - let opusResetText: String? = input.provider == .perplexity + let opusResetText: String? = input.provider == .perplexity || input.provider == .sub2api ? opus.resetDescription?.trimmingCharacters(in: .whitespacesAndNewlines) : Self.resetText(for: opus, style: input.resetTimeDisplayStyle, now: input.now) + let tertiaryPaceDetail = Self.resetWindowPaceDetail(window: opus, input: input) metrics.append(Metric( id: "tertiary", title: labels.tertiary, @@ -1088,10 +1255,10 @@ extension UsageMenuCardView.Model { percentStyle: percentStyle, resetText: opusResetText, detailText: tertiaryDetailText, - detailLeftText: nil, - detailRightText: nil, - pacePercent: nil, - paceOnTop: true, + detailLeftText: tertiaryPaceDetail?.leftLabel, + detailRightText: tertiaryPaceDetail?.rightLabel, + pacePercent: tertiaryPaceDetail?.pacePercent, + paceOnTop: tertiaryPaceDetail?.paceOnTop ?? true, warningMarkerPercents: Self.warningMarkerPercents( thresholds: input.quotaWarningThresholds[.weekly], showUsed: input.usageBarsShowUsed))) @@ -1100,16 +1267,16 @@ extension UsageMenuCardView.Model { snapshot: snapshot, input: input, percentStyle: percentStyle)) - if input.provider == .kilo, + if input.provider == .kilo || input.provider == .kimi, metrics.contains(where: { $0.id == "primary" }), metrics.contains(where: { $0.id == "secondary" }) { metrics.sort { lhs, rhs in - let kiloOrder: [String: Int] = [ + let primarySecondaryOrder: [String: Int] = [ "secondary": 0, "primary": 1, ] - return (kiloOrder[lhs.id] ?? Int.max) < (kiloOrder[rhs.id] ?? Int.max) + return (primarySecondaryOrder[lhs.id] ?? Int.max) < (primarySecondaryOrder[rhs.id] ?? Int.max) } } @@ -1136,23 +1303,6 @@ extension UsageMenuCardView.Model { return metrics } - private static func rateWindowLabels( - input: Input, - snapshot: UsageSnapshot) -> (primary: String, secondary: String, tertiary: String, showsTertiary: Bool) - { - if input.provider == .factory, snapshot.tertiary != nil { - return ("5-hour", L("Weekly"), L("Monthly"), true) - } - let primaryLabel = input.provider == .grok - ? GrokProviderDescriptor.primaryLabel(window: snapshot.primary) ?? input.metadata.sessionLabel - : input.metadata.sessionLabel - return ( - L(primaryLabel), - L(input.metadata.weeklyLabel), - input.metadata.opusLabel.map(L) ?? L("Sonnet"), - input.metadata.supportsOpus) - } - private static func primaryMetric( input: Input, primary: RateWindow, @@ -1161,121 +1311,38 @@ extension UsageMenuCardView.Model { zaiTokenDetail: String?, openRouterQuotaDetail: String?) -> Metric { - var primaryDetailText: String? = input.provider == .zai ? zaiTokenDetail : nil - var primaryResetText = Self.resetText(for: primary, style: input.resetTimeDisplayStyle, now: input.now) - var primaryDetailLeft: String? - var primaryDetailRight: String? - if input.provider == .crof, - let detail = primary.resetDescription?.trimmingCharacters(in: .whitespacesAndNewlines), - !detail.isEmpty - { - primaryDetailRight = detail - } - if input.provider == .openrouter, - let openRouterQuotaDetail - { - primaryResetText = openRouterQuotaDetail - } - if input.provider == .copilot, - let detail = primary.resetDescription?.trimmingCharacters(in: .whitespacesAndNewlines), - !detail.isEmpty - { - primaryDetailLeft = detail - } - if input.provider == .warp || input.provider == .kilo || input.provider == .mimo || input.provider == .deepseek, - let detail = primary.resetDescription, - !detail.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty - { - primaryDetailText = detail - } - if input.provider == .kiro, - let kiroUsage = input.snapshot?.kiroUsage, - kiroUsage.creditsTotal > 0 - { - let remaining = UsageFormatter.kiroCreditNumber(kiroUsage.creditsRemaining) - let total = UsageFormatter.kiroCreditNumber(kiroUsage.creditsTotal) - primaryDetailLeft = String(format: L("%@ of %@ credits left"), remaining, total) - } - if input.provider == .alibaba || input.provider == .alibabatokenplan || input.provider == .mistral || input - .provider == .manus, - let detail = primary.resetDescription, - !detail.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty - { - primaryDetailText = detail - if input.provider == .manus { primaryResetText = nil } - } - if [.warp, .kilo, .mimo, .deepseek].contains(input.provider), primary.resetsAt == nil { - primaryResetText = nil - } - // Abacus: show credits as detail, compute pace on the primary monthly window - var primaryPacePercent: Double? - var primaryPaceOnTop = true - if let paceDetail = Self.sessionPaceDetail( - provider: input.provider, - window: primary, - now: input.now, - showUsed: input.usageBarsShowUsed) - { - primaryDetailLeft = paceDetail.leftLabel - primaryDetailRight = paceDetail.rightLabel - primaryPacePercent = paceDetail.pacePercent - primaryPaceOnTop = paceDetail.paceOnTop - } - if input.provider == .abacus { - if let detail = primary.resetDescription, - !detail.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty - { - primaryDetailText = detail - } - if primary.resetsAt == nil { - primaryResetText = nil - } - if let pace = input.weeklyPace { - let paceDetail = Self.weeklyPaceDetail( - window: primary, - now: input.now, - pace: pace, - showUsed: input.usageBarsShowUsed) - if let paceDetail { - primaryDetailLeft = paceDetail.leftLabel - primaryDetailRight = paceDetail.rightLabel - primaryPacePercent = paceDetail.pacePercent - primaryPaceOnTop = paceDetail.paceOnTop - } - } - } - if input.provider == .synthetic, - let regen = Self.syntheticRollingRegenDetail( - window: primary, - now: input.now, - showUsed: input.usageBarsShowUsed) - { - primaryResetText = regen.resetText - primaryDetailLeft = regen.pace.leftLabel - primaryDetailRight = regen.pace.rightLabel - primaryPacePercent = regen.pace.pacePercent - primaryPaceOnTop = regen.pace.paceOnTop - } - let primaryStatusText = input.provider == .deepseek ? primaryDetailText : nil - if input.provider == .deepseek { - primaryDetailText = nil - } + var presentation = PrimaryMetricPresentation( + resetText: Self.resetText(for: primary, style: input.resetTimeDisplayStyle, now: input.now), + detailText: input.provider == .zai ? zaiTokenDetail : nil) + Self.applyPrimaryQuotaPresentation( + &presentation, + input: input, + primary: primary, + openRouterQuotaDetail: openRouterQuotaDetail) + Self.applyPrimaryBalancePresentation(&presentation, input: input, primary: primary) + Self.applyPrimaryResetPresentation(&presentation, input: input, primary: primary) + Self.applyPrimaryPacePresentation(&presentation, input: input, primary: primary) + Self.applyPrimaryFinalOverrides(&presentation, input: input, primary: primary) return Metric( id: "primary", title: title ?? L(input.metadata.sessionLabel), percent: Self.clamped( input.usageBarsShowUsed ? primary.usedPercent : primary.remainingPercent), percentStyle: percentStyle, - statusText: primaryStatusText, - resetText: primaryResetText, - detailText: primaryDetailText, - detailLeftText: primaryDetailLeft, - detailRightText: primaryDetailRight, - pacePercent: primaryPacePercent, - paceOnTop: primaryPaceOnTop, + statusText: presentation.statusText, + resetText: presentation.resetText, + detailText: presentation.detailText, + detailLeftText: presentation.detailLeft, + detailRightText: presentation.detailRight, + pacePercent: presentation.pacePercent, + paceOnTop: presentation.paceOnTop, warningMarkerPercents: Self.warningMarkerPercents( thresholds: input.quotaWarningThresholds[.session], - showUsed: input.usageBarsShowUsed)) + showUsed: input.usageBarsShowUsed), + sessionEquivalentDetail: Self.sessionEquivalentDetail( + input: input, + weeklyWindow: primary, + weeklyWindowID: nil)) } private static func secondaryMetric( @@ -1285,11 +1352,21 @@ extension UsageMenuCardView.Model { title: String? = nil, zaiTimeDetail: String?) -> Metric { - var paceDetail = Self.weeklyPaceDetail( - window: weekly, - now: input.now, - pace: input.weeklyPace, - showUsed: input.usageBarsShowUsed) + // Kimi's secondary slot is its 5-hour rate limit rather than a weekly window. + var paceDetail = if input.provider == .kimi { + Self.sessionPaceDetail( + provider: input.provider, + window: weekly, + now: input.now, + showUsed: input.usageBarsShowUsed) + } else { + Self.weeklyPaceDetail( + provider: input.provider, + window: weekly, + now: input.now, + pace: input.weeklyPace, + showUsed: input.usageBarsShowUsed) + } var weeklyResetText = Self.resetText(for: weekly, style: input.resetTimeDisplayStyle, now: input.now) var weeklyDetailText: String? = input.provider == .zai ? zaiTimeDetail : nil if input.provider == .warp, @@ -1299,7 +1376,7 @@ extension UsageMenuCardView.Model { weeklyResetText = nil weeklyDetailText = detail } - if input.provider == .kilo, + if [.kilo, .litellm, .chutes].contains(input.provider), let detail = weekly.resetDescription, !detail.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { @@ -1308,6 +1385,9 @@ extension UsageMenuCardView.Model { weeklyResetText = nil } } + if input.provider == .sub2api { + weeklyResetText = weekly.resetDescription + } if input.provider == .kiro, let kiroUsage = input.snapshot?.kiroUsage, let remaining = kiroUsage.bonusCreditsRemaining, @@ -1339,12 +1419,22 @@ extension UsageMenuCardView.Model { { weeklyResetText = detail } - if input.provider == .copilot, + if [.copilot, .zenmux].contains(input.provider), let detail = weekly.resetDescription?.trimmingCharacters(in: .whitespacesAndNewlines), !detail.isEmpty { paceDetail = PaceDetail(leftLabel: detail, rightLabel: nil, pacePercent: nil, paceOnTop: true) } + if input.provider == .zenmux, weekly.resetsAt == nil { + weeklyResetText = nil + } + if let cursorPaceDetail = Self.resetWindowPaceDetail( + window: weekly, + input: input, + pace: input.weeklyPace) + { + paceDetail = cursorPaceDetail + } // Perplexity bonus credits don't reset; show balance without "Resets" prefix. if input.provider == .perplexity, let detail = weekly.resetDescription?.trimmingCharacters(in: .whitespacesAndNewlines), @@ -1367,67 +1457,22 @@ extension UsageMenuCardView.Model { title: title ?? L(input.metadata.weeklyLabel), percent: Self.clamped(input.usageBarsShowUsed ? weekly.usedPercent : weekly.remainingPercent), percentStyle: percentStyle, + statusText: nil, resetText: weeklyResetText, detailText: weeklyDetailText, detailLeftText: paceDetail?.leftLabel, detailRightText: paceDetail?.rightLabel, pacePercent: paceDetail?.pacePercent, paceOnTop: paceDetail?.paceOnTop ?? true, - warningMarkerPercents: Self.weeklyMarkerPercents(input: input, windowMinutes: weekly.windowMinutes)) - } - - private static func codexRateMetrics( - input: Input, - projection: CodexConsumerProjection, - percentStyle: PercentStyle) -> [Metric] - { - projection.visibleRateLanes.compactMap { lane in - guard let window = projection.rateWindow(for: lane) else { return nil } - - let title: String - let id: String - let paceDetail: PaceDetail? - switch lane { - case .session: - title = L(input.metadata.sessionLabel) - id = "primary" - paceDetail = Self.sessionPaceDetail( - provider: input.provider, - window: window, - now: input.now, - showUsed: input.usageBarsShowUsed) - case .weekly: - title = L(input.metadata.weeklyLabel) - id = "secondary" - paceDetail = Self.weeklyPaceDetail( - window: window, - now: input.now, - pace: input.weeklyPace - ?? UsagePace.weekly(window: window, now: input.now, defaultWindowMinutes: 10080) - .flatMap { $0.expectedUsedPercent >= 3 ? $0 : nil }, - showUsed: input.usageBarsShowUsed) - } - - return Metric( - id: id, - title: title, - percent: Self.clamped(input.usageBarsShowUsed ? window.usedPercent : window.remainingPercent), - percentStyle: percentStyle, - resetText: Self.resetText(for: window, style: input.resetTimeDisplayStyle, now: input.now), - detailText: nil, - detailLeftText: paceDetail?.leftLabel, - detailRightText: paceDetail?.rightLabel, - pacePercent: paceDetail?.pacePercent, - paceOnTop: paceDetail?.paceOnTop ?? true, - warningMarkerPercents: Self.codexLaneMarkerPercents( - input: input, - lane: lane, - windowMinutes: window.windowMinutes)) - } - } - - private static func dashboardHint(error: String?) -> String? { - guard let error, !error.isEmpty else { return nil } - return error + warningMarkerPercents: Self.warningMarkerPercents( + thresholds: input.quotaWarningThresholds[.weekly], + showUsed: input.usageBarsShowUsed), + workdayMarkerPercents: workDayMarkerPercents( + workDays: input.workDaysPerWeek, + windowMinutes: weekly.windowMinutes), + sessionEquivalentDetail: Self.sessionEquivalentDetail( + input: input, + weeklyWindow: weekly, + weeklyWindowID: nil)) } } diff --git a/Sources/CodexBar/MenuContent.swift b/Sources/CodexBar/MenuContent.swift index 264eeb1e39..2a1c33fb46 100644 --- a/Sources/CodexBar/MenuContent.swift +++ b/Sources/CodexBar/MenuContent.swift @@ -71,6 +71,10 @@ struct MenuContent: View { } } .buttonStyle(.plain) + case let .unavailable(title, tooltip): + Text(title) + .foregroundStyle(.secondary) + .help(tooltip ?? "") case let .submenu(title, systemImageName, submenuItems): VStack(alignment: .leading, spacing: 4) { HStack(spacing: 8) { @@ -142,6 +146,8 @@ struct MenuContent: View { self.actions.quit() case let .copyError(message): self.actions.copyError(message) + case .focusAgentSession: + return } } } @@ -162,6 +168,38 @@ struct MenuActions { let copyError: (String) -> Void } +struct PersistentRefreshRowMetrics: Equatable { + static let defaults = Self( + rowHeight: 24, + selectionHorizontalInset: 5, + selectionVerticalInset: 0, + selectionCornerRadius: 7, + // Align the custom row's image/title frames with native NSMenuItem columns. + leadingPadding: 15, + trailingPadding: 8, + iconWidth: 16, + iconSymbolPointSize: 16, + iconSymbolWeight: .regular, + iconTitleSpacing: 4.5, + shortcutFontSize: 13, + shortcutXOffset: -9.5, + shortcutYOffset: 0) + + let rowHeight: CGFloat + let selectionHorizontalInset: CGFloat + let selectionVerticalInset: CGFloat + let selectionCornerRadius: CGFloat + let leadingPadding: CGFloat + let trailingPadding: CGFloat + let iconWidth: CGFloat + let iconSymbolPointSize: CGFloat + let iconSymbolWeight: NSFont.Weight + let iconTitleSpacing: CGFloat + let shortcutFontSize: CGFloat + let shortcutXOffset: CGFloat + let shortcutYOffset: CGFloat +} + @MainActor struct StatusIconView: View { @Bindable var store: UsageStore @@ -189,25 +227,33 @@ struct StatusIconView: View { snapshot: snap, style: self.store.style(for: self.provider)) let primary = remaining.primary - let percent = primary.map { String(format: L("%d percent remaining"), Int($0 * 100)) } ?? L("Unknown") + let percent = primary.map(Self.accessibilityPercentRemaining) ?? L("Unknown") let stale = self.store.isStale(provider: self.provider) return stale ? "\(percent), \(L("stale data"))" : percent } + static func accessibilityPercentRemaining(_ remaining: Double) -> String { + String(format: L("%d percent remaining"), Int(remaining.rounded())) + } + private var icon: NSImage { + let now = Date() let snapshot = self.store.snapshot(for: self.provider) let remaining = snapshot.map { - IconRemainingResolver.resolvedRemaining(snapshot: $0, style: self.store.style(for: self.provider)) + IconRemainingResolver.resolvedRemaining( + snapshot: $0, + style: self.store.style(for: self.provider), + now: now) } let creditsProjection = self.store.codexConsumerProjectionIfNeeded( for: self.provider, surface: .menuBar, snapshotOverride: snapshot, - now: snapshot?.updatedAt ?? Date()) + now: now) let creditsRemaining = creditsProjection?.menuBarFallback == .creditsBalance ? self.store.codexMenuBarCreditsRemaining( snapshotOverride: snapshot, - now: snapshot?.updatedAt ?? Date()) + now: now) : nil return IconRenderer.makeIcon( primaryRemaining: remaining?.primary, @@ -215,6 +261,7 @@ struct StatusIconView: View { creditsRemaining: creditsRemaining, stale: self.store.isStale(provider: self.provider), style: self.store.style(for: self.provider), - statusIndicator: self.store.statusIndicator(for: self.provider)) + statusIndicator: self.store.statusIndicator(for: self.provider), + hideCritters: self.store.settings.menuBarHidesCritters) } } diff --git a/Sources/CodexBar/MenuDescriptor+ProviderUsage.swift b/Sources/CodexBar/MenuDescriptor+ProviderUsage.swift new file mode 100644 index 0000000000..413e7d5217 --- /dev/null +++ b/Sources/CodexBar/MenuDescriptor+ProviderUsage.swift @@ -0,0 +1,212 @@ +import CodexBarCore +import Foundation + +extension MenuDescriptor { + static func appendOpenAIAPIUsageSummary( + entries: inout [Entry], + usage: OpenAIAPIUsageSnapshot, + preferredCurrencyCode: String = "auto") + { + let today = usage.currentDay + let last7 = usage.last7Days + let last30 = usage.last30Days + let historyLabel = usage.historyWindowLabel + let todayCost = UsageFormatter.convertedCostString( + today.costUSD, preferredCurrency: preferredCurrencyCode, providerCurrency: "USD") + let last7Cost = UsageFormatter.convertedCostString( + last7.costUSD, preferredCurrency: preferredCurrencyCode, providerCurrency: "USD") + let last30Cost = UsageFormatter.convertedCostString( + last30.costUSD, preferredCurrency: preferredCurrencyCode, providerCurrency: "USD") + + entries.append(.text( + "\(L("Today")): \(todayCost) · " + + "\(UsageFormatter.tokenCountString(today.totalTokens)) \(L("tokens"))", + .secondary)) + entries.append(.text( + "7d: \(last7Cost) · " + + "\(UsageFormatter.tokenCountString(last7.requests)) \(L("requests"))", + .secondary)) + entries.append(.text( + "\(historyLabel): \(last30Cost) · " + + "\(UsageFormatter.tokenCountString(last30.requests)) \(L("requests"))", + .secondary)) + if let topModel = usage.topModels.first?.name { + entries.append(.text("\(L("Top model")): \(topModel)", .secondary)) + } + } + + static func appendClaudeAdminAPIUsageSummary( + entries: inout [Entry], + usage: ClaudeAdminAPIUsageSnapshot, + preferredCurrencyCode: String = "auto") + { + let today = usage.currentDay + let last7 = usage.last7Days + let last30 = usage.last30Days + let todayCost = UsageFormatter.convertedCostString( + today.costUSD, preferredCurrency: preferredCurrencyCode, providerCurrency: "USD") + let last7Cost = UsageFormatter.convertedCostString( + last7.costUSD, preferredCurrency: preferredCurrencyCode, providerCurrency: "USD") + let last30Cost = UsageFormatter.convertedCostString( + last30.costUSD, preferredCurrency: preferredCurrencyCode, providerCurrency: "USD") + + entries.append(.text( + "\(L("Today")): \(todayCost) · " + + "\(UsageFormatter.tokenCountString(today.totalTokens)) \(L("tokens"))", + .secondary)) + entries.append(.text( + "7d: \(last7Cost) · " + + "\(UsageFormatter.tokenCountString(last7.totalTokens)) \(L("tokens"))", + .secondary)) + entries.append(.text( + "30d: \(last30Cost) · " + + "\(UsageFormatter.tokenCountString(last30.totalTokens)) \(L("tokens"))", + .secondary)) + if let topModel = usage.topModels.first?.name { + entries.append(.text("\(L("Top model")): \(topModel)", .secondary)) + } + } + + static func appendOpenRouterUsageSummary( + entries: inout [Entry], + usage: OpenRouterUsageSnapshot, + preferredCurrencyCode: String = "auto") + { + if let daily = usage.keyUsageDaily { + let cost = UsageFormatter.convertedCostString( + daily, preferredCurrency: preferredCurrencyCode, providerCurrency: "USD") + entries.append(.text("\(L("Today")): \(cost)", .secondary)) + } + if let weekly = usage.keyUsageWeekly { + let cost = UsageFormatter.convertedCostString( + weekly, preferredCurrency: preferredCurrencyCode, providerCurrency: "USD") + entries.append(.text("\(L("Week")): \(cost)", .secondary)) + } + if let monthly = usage.keyUsageMonthly { + let cost = UsageFormatter.convertedCostString( + monthly, preferredCurrency: preferredCurrencyCode, providerCurrency: "USD") + entries.append(.text("\(L("Month")): \(cost)", .secondary)) + } + } + + static func appendMistralUsageSummary( + entries: inout [Entry], + usage: MistralUsageSnapshot, + preferredCurrencyCode: String = "auto") + { + let latest = usage.daily.last + if let latest { + let cost = UsageFormatter.convertedCostString( + latest.cost, + preferredCurrency: preferredCurrencyCode, + providerCurrency: usage.currency) + entries.append(.text( + "\(L("Latest")): \(cost) · " + + "\(UsageFormatter.tokenCountString(latest.totalTokens)) \(L("tokens"))", + .secondary)) + } + let totalTokens = usage.totalInputTokens + usage.totalCachedTokens + usage.totalOutputTokens + let totalCost = UsageFormatter.convertedCostString( + usage.totalCost, + preferredCurrency: preferredCurrencyCode, + providerCurrency: usage.currency) + entries.append(.text( + "\(L("Month")): \(totalCost) · " + + "\(UsageFormatter.tokenCountString(totalTokens)) \(L("tokens"))", + .secondary)) + if let top = Self.topMistralModel(from: usage.daily) { + entries.append(.text("\(L("Top model")): \(top)", .secondary)) + } + } + + static func appendPoeUsageSummary( + entries: inout [Entry], + usage: PoeUsageHistorySnapshot, + preferredCurrencyCode: String = "auto") + { + let today = usage.currentDay() + let week = usage.last7Days + let month = usage.last30Days + let todayCostSuffix = today.costUSD.map { value in + let cost = UsageFormatter.convertedCostString( + value, preferredCurrency: preferredCurrencyCode, providerCurrency: "USD") + return " · \(cost)" + } ?? "" + let weekCostSuffix = week.costUSD.map { value in + let cost = UsageFormatter.convertedCostString( + value, preferredCurrency: preferredCurrencyCode, providerCurrency: "USD") + return " · \(cost)" + } ?? "" + let monthCostSuffix = month.costUSD.map { value in + let cost = UsageFormatter.convertedCostString( + value, preferredCurrency: preferredCurrencyCode, providerCurrency: "USD") + return " · \(cost)" + } ?? "" + entries.append(.text( + "\(L("Today")): \(Self.pointsString(today.points)) · " + + "\(UsageFormatter.tokenCountString(today.requests)) \(L("requests"))\(todayCostSuffix)", + .secondary)) + entries.append(.text( + "7d: \(Self.pointsString(week.points)) · " + + "\(UsageFormatter.tokenCountString(week.requests)) \(L("requests"))\(weekCostSuffix)", + .secondary)) + entries.append(.text( + "30d: \(Self.pointsString(month.points)) · " + + "\(UsageFormatter.tokenCountString(month.requests)) \(L("requests"))\(monthCostSuffix)", + .secondary)) + if let topModel = usage.topModels.first { + entries.append( + .text( + "\(L("Top model")): \(topModel.name) (\(Self.pointsString(topModel.points)))", + .secondary)) + } + if !usage.topUsageTypes.isEmpty { + let summary = usage.topUsageTypes + .prefix(2) + .map { "\($0.name): \(Self.pointsString($0.points))" } + .joined(separator: " · ") + entries.append(.text("Usage mix: \(summary)", .secondary)) + } + let recent = usage.recentEntries(limit: 3) + if !recent.isEmpty { + entries.append(.text("Recent activity:", .secondary)) + for entry in recent { + let stamp = Self.poeTimeString(entry.createdAt) + entries.append(.text( + "\(stamp) · \(entry.model) · \(Self.pointsString(entry.points))", + .secondary)) + } + } + } + + private static func topMistralModel(from entries: [MistralDailyUsageBucket]) -> String? { + var tokens: [String: Int] = [:] + for entry in entries { + for model in entry.models { + tokens[model.name, default: 0] += model.totalTokens + } + } + return tokens.max { + if $0.value == $1.value { + return $0.key > $1.key + } + return $0.value < $1.value + }?.key + } + + private static func pointsString(_ points: Double) -> String { + let value = max(0, points) + if value.rounded() == value { + return "\(UsageFormatter.tokenCountString(Int(value))) points" + } + return "\(String(format: "%.1f", value)) points" + } + + private static func poeTimeString(_ date: Date) -> String { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.timeZone = .current + formatter.dateFormat = "MM-dd HH:mm" + return formatter.string(from: date) + } +} diff --git a/Sources/CodexBar/MenuDescriptor+Wayfinder.swift b/Sources/CodexBar/MenuDescriptor+Wayfinder.swift new file mode 100644 index 0000000000..ed21a56019 --- /dev/null +++ b/Sources/CodexBar/MenuDescriptor+Wayfinder.swift @@ -0,0 +1,12 @@ +import CodexBarCore + +extension MenuDescriptor { + static func appendWayfinderUsageSummary( + entries: inout [Entry], + usage: WayfinderUsageSnapshot) + { + for line in usage.displayLines { + entries.append(.text(line, .secondary)) + } + } +} diff --git a/Sources/CodexBar/MenuDescriptor.swift b/Sources/CodexBar/MenuDescriptor.swift index 20788c2cb8..5c8a5652b0 100644 --- a/Sources/CodexBar/MenuDescriptor.swift +++ b/Sources/CodexBar/MenuDescriptor.swift @@ -24,13 +24,22 @@ struct MenuDescriptor { enum Entry { case text(String, TextStyle) case action(String, MenuAction) + case unavailable(String, String?) case submenu(String, String?, [SubmenuItem]) case divider + + var isActionable: Bool { + switch self { + case .action, .submenu, .unavailable: true + case .text, .divider: false + } + } } enum MenuActionSystemImage: String { + case installUpdate = "arrow.down.circle" case refresh = "arrow.clockwise" - case dashboard = "chart.bar" + case dashboard = "chart.xyaxis.line" case statusPage = "waveform.path.ecg" case changelog = "list.bullet.rectangle" case addAccount = "plus" @@ -67,6 +76,7 @@ struct MenuDescriptor { case about case quit case copyError(String) + case focusAgentSession(AgentSession, remoteHost: String?) } var sections: [Section] @@ -79,7 +89,12 @@ struct MenuDescriptor { managedCodexAccountCoordinator: ManagedCodexAccountCoordinator? = nil, codexAccountPromotionCoordinator: CodexAccountPromotionCoordinator? = nil, updateReady: Bool, - includeContextualActions: Bool = true) -> MenuDescriptor + includeContextualActions: Bool = true, + agentSessionsEnabled: Bool = false, + agentSessionLabelStyle: AgentSessionLabelStyle = .project, + localAgentSessions: [AgentSession] = [], + remoteAgentHosts: [RemoteSessionHostResult] = [], + now: Date = Date()) -> MenuDescriptor { var sections: [Section] = [] @@ -128,11 +143,77 @@ struct MenuDescriptor { sections.append(actions) } } + if agentSessionsEnabled { + sections.append(Self.agentSessionsSection( + localSessions: localAgentSessions, + remoteHosts: remoteAgentHosts, + labelStyle: agentSessionLabelStyle, + now: now)) + } sections.append(Self.metaSection(updateReady: updateReady)) return MenuDescriptor(sections: sections) } + static func agentSessionsSection( + localSessions: [AgentSession], + remoteHosts: [RemoteSessionHostResult], + labelStyle: AgentSessionLabelStyle = .project, + now: Date = Date()) -> Section + { + let totalCount = localSessions.count + remoteHosts.reduce(0) { $0 + $1.sessions.count } + var entries: [Entry] = [.text("Agent Sessions (\(totalCount))", .headline)] + + for session in localSessions { + entries.append(.action( + self.agentSessionRowTitle(session, labelStyle: labelStyle, now: now), + .focusAgentSession(session, remoteHost: nil))) + } + for remoteHost in remoteHosts { + if let error = remoteHost.error { + entries.append(.unavailable("\(remoteHost.host) — unreachable", error)) + continue + } + entries.append(.text("\(remoteHost.host) — \(remoteHost.sessions.count)", .secondary)) + for session in remoteHost.sessions { + entries.append(.action( + self.agentSessionRowTitle(session, labelStyle: labelStyle, now: now), + .focusAgentSession(session, remoteHost: remoteHost.host))) + } + } + if totalCount == 0 { + entries.append(.unavailable("No agent sessions found", nil)) + } + return Section(entries: entries) + } + + private static func agentSessionRowTitle( + _ session: AgentSession, + labelStyle: AgentSessionLabelStyle, + now: Date) -> String + { + let state = session.state == .active ? "●" : "○" + let providerGlyph = session.provider == .codex ? "⌘" : "✦" + let label = labelStyle.label(for: session) + return "\(state) \(providerGlyph) \(label) — \(session.provider.rawValue) · " + + "\(session.source.rawValue) · \(self.agentSessionAge(session, now: now))" + } + + private static func agentSessionAge(_ session: AgentSession, now: Date) -> String { + guard let activity = session.lastActivityAt ?? session.startedAt else { return "now" } + let seconds = max(0, Int(now.timeIntervalSince(activity))) + if seconds < 60 { + return "\(seconds)s" + } + if seconds < 3600 { + return "\(seconds / 60)m" + } + if seconds < 86400 { + return "\(seconds / 3600)h" + } + return "\(seconds / 86400)d" + } + private static func usageSection( for provider: UsageProvider, store: UsageStore, @@ -141,7 +222,9 @@ struct MenuDescriptor { let meta = store.metadata(for: provider) var entries: [Entry] = [] let headlineText: String = { - if let ver = Self.versionNumber(for: provider, store: store) { return "\(meta.displayName) \(ver)" } + if let ver = Self.versionNumber(for: provider, store: store) { + return "\(meta.displayName) \(ver)" + } return meta.displayName }() entries.append(.text(headlineText, .headline)) @@ -149,11 +232,14 @@ struct MenuDescriptor { if let snap = store.snapshot(for: provider) { let resetStyle = settings.resetTimeDisplayStyle let labels = Self.rateWindowLabels(provider: provider, metadata: meta, snapshot: snap) + let crofShowsCreditsOnly = provider == .crof && snap.secondary == nil if let primary = snap.primary { - let primaryWindow = if provider == .warp || provider == .kilo || provider == .mimo || provider == - .abacus || - provider == .deepseek || provider == .azureopenai - { + let primaryDetail = primary.resetDescription?.trimmingCharacters(in: .whitespacesAndNewlines) + let primaryDescriptionIsDetail = provider == .warp || provider == .kilo || provider == .abacus || + provider == .deepseek || provider == .deepinfra || provider == .neuralwatt || + provider == .azureopenai || provider == .mimo || provider == .qoder || provider == .sub2api || + crofShowsCreditsOnly || provider == .chutes + let primaryWindow = if primaryDescriptionIsDetail { // Some providers use resetDescription for non-reset detail // (e.g., "Unlimited", "X/Y credits"). Avoid rendering it as a "Resets ..." line. RateWindow( @@ -170,24 +256,23 @@ struct MenuDescriptor { window: primaryWindow, resetStyle: resetStyle, showUsed: settings.usageBarsShowUsed) - if provider == .warp || provider == .kilo || provider == .mimo || provider == .abacus || provider == - .deepseek || provider == .azureopenai, - let detail = primary.resetDescription?.trimmingCharacters(in: .whitespacesAndNewlines), - !detail.isEmpty + if primaryDescriptionIsDetail, + let primaryDetail, + !primaryDetail.isEmpty { - entries.append(.text(detail, .secondary)) + entries.append(.text(primaryDetail, .secondary)) } if provider == .crof, primary.resetsAt != nil, - let detail = primary.resetDescription?.trimmingCharacters(in: .whitespacesAndNewlines), - !detail.isEmpty + let primaryDetail, + !primaryDetail.isEmpty { - entries.append(.text(detail, .secondary)) + entries.append(.text(primaryDetail, .secondary)) } if provider == .abacus, let pace = store.weeklyPace(provider: provider, window: primary) { - let paceSummary = UsagePaceText.weeklySummary(pace: pace) + let paceSummary = UsagePaceText.weeklySummary(provider: provider, pace: pace) entries.append(.text(paceSummary, .secondary)) } if let paceSummary = UsagePaceText.sessionSummary(provider: provider, window: primary) { @@ -196,11 +281,12 @@ struct MenuDescriptor { } if let weekly = snap.secondary { let weeklyResetOverride: String? = { - guard provider == .warp || provider == .kilo || provider == .perplexity || provider == .crof + guard provider == .warp || provider == .kilo || provider == .perplexity || provider == .crof || + provider == .sub2api || provider == .chutes else { return nil } let detail = weekly.resetDescription?.trimmingCharacters(in: .whitespacesAndNewlines) guard let detail, !detail.isEmpty else { return nil } - if provider == .kilo, weekly.resetsAt != nil { + if [.kilo, .chutes].contains(provider), weekly.resetsAt != nil { return nil } return detail @@ -212,7 +298,7 @@ struct MenuDescriptor { resetStyle: resetStyle, showUsed: settings.usageBarsShowUsed, resetOverride: weeklyResetOverride) - if provider == .kilo, + if [.kilo, .chutes].contains(provider), weekly.resetsAt != nil, let detail = weekly.resetDescription?.trimmingCharacters(in: .whitespacesAndNewlines), !detail.isEmpty @@ -220,13 +306,13 @@ struct MenuDescriptor { entries.append(.text(detail, .secondary)) } if let pace = store.weeklyPace(provider: provider, window: weekly) { - let paceSummary = UsagePaceText.weeklySummary(pace: pace) + let paceSummary = UsagePaceText.weeklySummary(provider: provider, pace: pace) entries.append(.text(paceSummary, .secondary)) } } if labels.showsTertiary, let opus = snap.tertiary { // Perplexity purchased credits don't reset; show the balance as plain text. - let opusResetOverride: String? = provider == .perplexity + let opusResetOverride: String? = provider == .perplexity || provider == .sub2api ? opus.resetDescription?.trimmingCharacters(in: .whitespacesAndNewlines) : nil Self.appendRateWindow( @@ -238,7 +324,18 @@ struct MenuDescriptor { resetOverride: opusResetOverride) } - Self.appendProviderUsageSummaries(entries: &entries, snapshot: snap) + Self.appendProviderUsageSummaries( + entries: &entries, + snapshot: snap, + showOptionalUsage: settings.showOptionalCreditsAndExtraUsage, + preferredCurrencyCode: settings.preferredCurrencyCode) + if snap.rateLimitsUnavailable(for: provider) { + entries.append(.text(L("Limits not available"), .secondary)) + } + } else if !store.isStale(provider: provider), + store.knownLimitsAvailability(for: provider)?.isUnavailable == true + { + entries.append(.text(L("Limits not available"), .secondary)) } else { entries.append(.text(L("No usage yet"), .secondary)) } @@ -257,7 +354,9 @@ struct MenuDescriptor { private static func appendProviderUsageSummaries( entries: inout [Entry], - snapshot: UsageSnapshot) + snapshot: UsageSnapshot, + showOptionalUsage: Bool, + preferredCurrencyCode: String = "auto") { if let cost = snapshot.providerCost { if cost.currencyCode == "Quota" { @@ -267,117 +366,77 @@ struct MenuDescriptor { } } if let openAIAPIUsage = snapshot.openAIAPIUsage { - Self.appendOpenAIAPIUsageSummary(entries: &entries, usage: openAIAPIUsage) + Self.appendOpenAIAPIUsageSummary( + entries: &entries, + usage: openAIAPIUsage, + preferredCurrencyCode: preferredCurrencyCode) } if let claudeAdminAPIUsage = snapshot.claudeAdminAPIUsage { - Self.appendClaudeAdminAPIUsageSummary(entries: &entries, usage: claudeAdminAPIUsage) + Self.appendClaudeAdminAPIUsageSummary( + entries: &entries, + usage: claudeAdminAPIUsage, + preferredCurrencyCode: preferredCurrencyCode) } if let openRouterUsage = snapshot.openRouterUsage { - Self.appendOpenRouterUsageSummary(entries: &entries, usage: openRouterUsage) - } - if let mistralUsage = snapshot.mistralUsage, !mistralUsage.daily.isEmpty { - Self.appendMistralUsageSummary(entries: &entries, usage: mistralUsage) - } - } - - private static func appendOpenAIAPIUsageSummary( - entries: inout [Entry], - usage: OpenAIAPIUsageSnapshot) - { - let today = usage.latestDay - let last7 = usage.last7Days - let last30 = usage.last30Days - let historyLabel = usage.historyWindowLabel - - entries.append(.text( - "\(L("Today")): \(UsageFormatter.usdString(today.costUSD)) · " + - "\(UsageFormatter.tokenCountString(today.totalTokens)) \(L("tokens"))", - .secondary)) - entries.append(.text( - "7d: \(UsageFormatter.usdString(last7.costUSD)) · " + - "\(UsageFormatter.tokenCountString(last7.requests)) \(L("requests"))", - .secondary)) - entries.append(.text( - "\(historyLabel): \(UsageFormatter.usdString(last30.costUSD)) · " + - "\(UsageFormatter.tokenCountString(last30.requests)) \(L("requests"))", - .secondary)) - if let topModel = usage.topModels.first?.name { - entries.append(.text("\(L("Top model")): \(topModel)", .secondary)) - } - } - - private static func appendClaudeAdminAPIUsageSummary( - entries: inout [Entry], - usage: ClaudeAdminAPIUsageSnapshot) - { - let today = usage.latestDay - let last7 = usage.last7Days - let last30 = usage.last30Days - - entries.append(.text( - "\(L("Today")): \(UsageFormatter.usdString(today.costUSD)) · " + - "\(UsageFormatter.tokenCountString(today.totalTokens)) \(L("tokens"))", - .secondary)) - entries.append(.text( - "7d: \(UsageFormatter.usdString(last7.costUSD)) · " + - "\(UsageFormatter.tokenCountString(last7.totalTokens)) \(L("tokens"))", - .secondary)) - entries.append(.text( - "30d: \(UsageFormatter.usdString(last30.costUSD)) · " + - "\(UsageFormatter.tokenCountString(last30.totalTokens)) \(L("tokens"))", - .secondary)) - if let topModel = usage.topModels.first?.name { - entries.append(.text("\(L("Top model")): \(topModel)", .secondary)) - } - } - - private static func appendOpenRouterUsageSummary( - entries: inout [Entry], - usage: OpenRouterUsageSnapshot) - { - if let daily = usage.keyUsageDaily { - entries.append(.text("\(L("Today")): \(UsageFormatter.usdString(daily))", .secondary)) - } - if let weekly = usage.keyUsageWeekly { - entries.append(.text("\(L("Week")): \(UsageFormatter.usdString(weekly))", .secondary)) + Self.appendOpenRouterUsageSummary( + entries: &entries, + usage: openRouterUsage, + preferredCurrencyCode: preferredCurrencyCode) } - if let monthly = usage.keyUsageMonthly { - entries.append(.text("\(L("Month")): \(UsageFormatter.usdString(monthly))", .secondary)) - } - } - - private static func appendMistralUsageSummary( - entries: inout [Entry], - usage: MistralUsageSnapshot) - { - let latest = usage.daily.last - if let latest { + if let clawRouterUsage = snapshot.clawRouterUsage { entries.append(.text( - "\(L("Latest")): \(usage.currencySymbol)\(String(format: "%.4f", max(0, latest.cost))) · " + - "\(UsageFormatter.tokenCountString(latest.totalTokens)) \(L("tokens"))", + "\(UsageFormatter.tokenCountString(clawRouterUsage.requestCount)) \(L("requests")) · " + + "\(UsageFormatter.tokenCountString(clawRouterUsage.totalTokens)) \(L("tokens"))", .secondary)) + if !clawRouterUsage.providers.isEmpty { + let mix = clawRouterUsage.providers.prefix(5) + .map { "\($0.provider): \(UsageFormatter.tokenCountString($0.requestCount))" } + .joined(separator: " · ") + entries.append(.text("Routed providers: \(mix)", .secondary)) + } } - let totalTokens = usage.totalInputTokens + usage.totalCachedTokens + usage.totalOutputTokens - entries.append(.text( - "\(L("Month")): \(usage.currencySymbol)\(String(format: "%.4f", max(0, usage.totalCost))) · " + - "\(UsageFormatter.tokenCountString(totalTokens)) \(L("tokens"))", - .secondary)) - if let top = Self.topMistralModel(from: usage.daily) { - entries.append(.text("\(L("Top model")): \(top)", .secondary)) + if let wayfinderUsage = snapshot.wayfinderUsage { + Self.appendWayfinderUsageSummary(entries: &entries, usage: wayfinderUsage) } - } - - private static func topMistralModel(from entries: [MistralDailyUsageBucket]) -> String? { - var tokens: [String: Int] = [:] - for entry in entries { - for model in entry.models { - tokens[model.name, default: 0] += model.totalTokens + if let poeUsage = snapshot.poeUsage, !poeUsage.daily.isEmpty { + Self.appendPoeUsageSummary( + entries: &entries, + usage: poeUsage, + preferredCurrencyCode: preferredCurrencyCode) + } + if let mistralUsage = snapshot.mistralUsage, !mistralUsage.daily.isEmpty { + Self.appendMistralUsageSummary( + entries: &entries, + usage: mistralUsage, + preferredCurrencyCode: preferredCurrencyCode) + } + if let mimoUsage = snapshot.mimoUsage { + entries.append(.text("\(L("Balance")): \(mimoUsage.balanceDetail)", .primary)) + } + if let xaiUsage = snapshot.xaiUsage { + entries.append(.text("\(L("Balance")): \(UsageFormatter.usdString(xaiUsage.balanceUSD))", .primary)) + if !xaiUsage.daily.isEmpty { + entries.append(.text( + "\(xaiUsage.historyWindowPeriodLabel): \(UsageFormatter.usdString(xaiUsage.windowCostUSD))", + .secondary)) + } + } + // Sakana pay-as-you-go is optional data gated by "Show optional credits and extra usage". + // Gate the render on the setting too, not just the fetch: toggling the setting off only + // rebuilds the menu, it does not immediately refetch, so a previously-populated + // sakanaPayAsYouGo would otherwise linger in the cached snapshot until the next refresh. + if showOptionalUsage, let sakanaPayAsYouGo = snapshot.sakanaPayAsYouGo { + entries.append(.text("\(L("Balance")): \(sakanaPayAsYouGo.balanceDetail)", .primary)) + if let periodUsageTotal = sakanaPayAsYouGo.periodUsageTotal { + let cost = UsageFormatter.convertedCostString( + periodUsageTotal, + preferredCurrency: preferredCurrencyCode, + providerCurrency: "USD") + entries.append(.text( + "\(L("Usage")): \(cost)", + .secondary)) } } - return tokens.max { - if $0.value == $1.value { return $0.key > $1.key } - return $0.value < $1.value - }?.key } private static func accountSection( @@ -412,7 +471,7 @@ struct MenuDescriptor { .trimmingCharacters(in: .whitespacesAndNewlines) let redactedEmail = PersonalInfoRedactor.redactEmail(emailText, isEnabled: hidePersonalInfo) - if let emailText, !emailText.isEmpty { + if let emailText, !emailText.isEmpty, !redactedEmail.isEmpty { entries.append(.text("\(L("Account")): \(redactedEmail)", .secondary)) } if provider == .kiro { @@ -438,7 +497,7 @@ struct MenuDescriptor { entries.append(.text("\(L("Activity")): \(detail)", .secondary)) } } else if let loginMethodText, !loginMethodText.isEmpty { - if provider == .openrouter || provider == .mimo, + if provider == .openrouter || provider == .mimo || provider == .poe, loginMethodText.localizedCaseInsensitiveContains("balance:") { let balanceValue = loginMethodText @@ -461,7 +520,9 @@ struct MenuDescriptor { if metadata.usesAccountFallback { if emailText?.isEmpty ?? true, let fallbackEmail = fallback.email, !fallbackEmail.isEmpty { let redacted = PersonalInfoRedactor.redactEmail(fallbackEmail, isEnabled: hidePersonalInfo) - entries.append(.text("\(L("Account")): \(redacted)", .secondary)) + if !redacted.isEmpty { + entries.append(.text("\(L("Account")): \(redacted)", .secondary)) + } } if loginMethodText?.isEmpty ?? true, let fallbackPlan = fallback.plan, !fallbackPlan.isEmpty { entries.append( @@ -521,12 +582,14 @@ struct MenuDescriptor { let targetProvider = provider ?? store.enabledProviders().first let metadata = targetProvider.map { store.metadata(for: $0) } let fallbackAccount = targetProvider.map { store.accountInfo(for: $0) } ?? account + let hasAccount = self.hasAccount(for: targetProvider, store: store, account: fallbackAccount) let loginContext = targetProvider.map { ProviderMenuLoginContext( provider: $0, store: store, settings: store.settings, - account: fallbackAccount) + account: fallbackAccount, + hasAccount: hasAccount) } // Show "Add Account" if no account, "Switch Account" if logged in @@ -540,7 +603,6 @@ struct MenuDescriptor { entries.append(.action(override.label, override.action)) } else { let loginAction = self.switchAccountTarget(for: provider, store: store) - let hasAccount = self.hasAccount(for: provider, store: store, account: fallbackAccount) let accountLabel = hasAccount ? L("Switch Account...") : L("Add Account...") entries.append(.action(accountLabel, loginAction)) } @@ -605,18 +667,29 @@ struct MenuDescriptor { } private static func switchAccountTarget(for provider: UsageProvider?, store: UsageStore) -> MenuAction { - if let provider { return .switchAccount(provider) } - if let enabled = store.enabledProviders().first { return .switchAccount(enabled) } + if let provider { + return .switchAccount(provider) + } + if let enabled = store.enabledProviders().first { + return .switchAccount(enabled) + } return .switchAccount(.codex) } private static func hasAccount(for provider: UsageProvider?, store: UsageStore, account: AccountInfo) -> Bool { let target = provider ?? store.enabledProviders().first ?? .codex - if let email = store.snapshot(for: target)?.accountEmail(for: target), + let snapshot = store.snapshot(for: target) + if let email = snapshot?.accountEmail(for: target), !email.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { return true } + if target == .claude, + snapshot?.identity(for: .claude) != nil, + snapshot?.hasRateLimitWindows == true + { + return true + } let metadata = store.metadata(for: target) if metadata.usesAccountFallback, let fallback = account.email?.trimmingCharacters(in: .whitespacesAndNewlines), @@ -635,12 +708,31 @@ struct MenuDescriptor { if provider == .factory, snapshot.tertiary != nil { return ("5-hour", L("Weekly"), L("Monthly"), true) } - let primaryLabel = provider == .grok - ? GrokProviderDescriptor.primaryLabel(window: snapshot.primary) ?? metadata.sessionLabel - : metadata.sessionLabel + let primaryLabel = if provider == .grok { + GrokProviderDescriptor.primaryLabel(window: snapshot.primary) ?? metadata.sessionLabel + } else if provider == .crof { + CrofProviderDescriptor.primaryLabel(snapshot: snapshot) + } else if provider == .doubao { + DoubaoProviderDescriptor.primaryLabel(window: snapshot.primary) ?? metadata.sessionLabel + } else if provider == .sub2api { + Sub2APIProviderDescriptor.primaryLabel(details: snapshot.sub2APIUsage) ?? metadata.sessionLabel + } else if provider == .amp { + AmpProviderDescriptor.primaryLabel(details: snapshot.ampUsage) ?? metadata.sessionLabel + } else if provider == .alibabatokenplan { + AlibabaTokenPlanProviderDescriptor.primaryLabel(window: snapshot.primary) ?? metadata.sessionLabel + } else { + metadata.sessionLabel + } + let secondaryLabel = if provider == .amp { + AmpProviderDescriptor.secondaryLabel(details: snapshot.ampUsage) ?? metadata.weeklyLabel + } else if provider == .alibabatokenplan { + AlibabaTokenPlanProviderDescriptor.secondaryLabel(window: snapshot.secondary) ?? metadata.weeklyLabel + } else { + metadata.weeklyLabel + } return ( L(primaryLabel), - L(metadata.weeklyLabel), + L(secondaryLabel), metadata.opusLabel.map(L) ?? L("Sonnet"), metadata.supportsOpus) } @@ -692,8 +784,10 @@ private enum AccountFormatter { extension MenuDescriptor.MenuAction { var systemImageName: String? { switch self { - case .installUpdate, .settings, .about, .quit: - nil + case .installUpdate: MenuDescriptor.MenuActionSystemImage.installUpdate.rawValue + case .settings: MenuDescriptor.MenuActionSystemImage.settings.rawValue + case .about: MenuDescriptor.MenuActionSystemImage.about.rawValue + case .quit: MenuDescriptor.MenuActionSystemImage.quit.rawValue case .refresh: MenuDescriptor.MenuActionSystemImage.refresh.rawValue case .refreshAugmentSession: MenuDescriptor.MenuActionSystemImage.refresh.rawValue case .dashboard: MenuDescriptor.MenuActionSystemImage.dashboard.rawValue @@ -706,6 +800,8 @@ extension MenuDescriptor.MenuAction { case .openTerminal: MenuDescriptor.MenuActionSystemImage.openTerminal.rawValue case .loginToProvider: MenuDescriptor.MenuActionSystemImage.loginToProvider.rawValue case .copyError: MenuDescriptor.MenuActionSystemImage.copyError.rawValue + case .focusAgentSession: + nil } } } diff --git a/Sources/CodexBar/MenuHighlightStyle.swift b/Sources/CodexBar/MenuHighlightStyle.swift index be76fe04a7..bb493b5026 100644 --- a/Sources/CodexBar/MenuHighlightStyle.swift +++ b/Sources/CodexBar/MenuHighlightStyle.swift @@ -2,6 +2,10 @@ import SwiftUI extension EnvironmentValues { @Entry var menuItemHighlighted: Bool = false + /// Optional live-refresh monitor injected into menu card views so the provider card + /// subtitle can reflect the in-flight "Refreshing…" state in place while the NSMenu + /// stays open, without rebuilding the menu during AppKit tracking. + @Entry var menuCardRefreshMonitor: MenuCardRefreshMonitor? } enum MenuHighlightStyle { diff --git a/Sources/CodexBar/MenuOpenRefreshPlan.swift b/Sources/CodexBar/MenuOpenRefreshPlan.swift new file mode 100644 index 0000000000..87fe8d9cb5 --- /dev/null +++ b/Sources/CodexBar/MenuOpenRefreshPlan.swift @@ -0,0 +1,41 @@ +import CodexBarCore + +struct MenuOpenRefreshPlan: Equatable { + struct Inputs { + let refreshAllOnOpen: Bool + let enabledProviders: [UsageProvider] + let visibleProviders: [UsageProvider] + let refreshingProviders: Set + let staleProviders: Set + let missingProviders: Set + } + + enum Scheduling: Equatable { + case sequential + case concurrent + } + + let providers: [UsageProvider] + let scheduling: Scheduling + let refreshCodexDashboard: Bool + + static func resolve(_ inputs: Inputs) -> Self { + if inputs.refreshAllOnOpen { + return Self( + providers: inputs.enabledProviders, + scheduling: .concurrent, + refreshCodexDashboard: inputs.enabledProviders.contains(.codex)) + } + + let enabled = Set(inputs.enabledProviders) + let providers = inputs.visibleProviders.filter { + enabled.contains($0) && + (inputs.refreshingProviders.contains($0) || inputs.staleProviders.contains($0) || + inputs.missingProviders.contains($0)) + } + return Self( + providers: providers, + scheduling: .sequential, + refreshCodexDashboard: false) + } +} diff --git a/Sources/CodexBar/MenuSessionCoordinator.swift b/Sources/CodexBar/MenuSessionCoordinator.swift new file mode 100644 index 0000000000..0b78842a81 --- /dev/null +++ b/Sources/CodexBar/MenuSessionCoordinator.swift @@ -0,0 +1,211 @@ +struct MenuSessionCoordinator { + enum ClosedPreparationPlan: Equatable { + case none + case nonDeferred + case required(version: Int) + } + + private(set) var contentVersion = 0 + private(set) var latestRequiredRebuildVersion = 0 + private(set) var latestDataOnlyContentVersion = 0 + private(set) var latestStructuralContentVersion = 0 + private(set) var renderedVersions: [MenuID: Int] = [:] + private(set) var deferredUntilNextOpen: Set = [] + private(set) var parentRebuildsDeferredDuringTracking: Set = [] + private var nextMenuInteractionGeneration = 0 + private(set) var menuInteractionGenerations: [MenuID: Int] = [:] + private var nextViewportRestoreGeneration = 0 + private(set) var pendingViewportRestores: [MenuID: Int] = [:] + + @discardableResult + mutating func invalidate( + allowsStaleContent: Bool, + requiresRebuild: Bool) + -> Int + { + self.contentVersion &+= 1 + if allowsStaleContent { + self.latestDataOnlyContentVersion = self.contentVersion + } else { + self.latestStructuralContentVersion = self.contentVersion + if requiresRebuild { + self.latestRequiredRebuildVersion = self.contentVersion + } + } + return self.contentVersion + } + + func needsRefresh(_ menuID: MenuID) -> Bool { + self.renderedVersions[menuID] != self.contentVersion + } + + mutating func markFresh(_ menuID: MenuID) { + self.renderedVersions[menuID] = self.contentVersion + } + + func renderedVersion(for menuID: MenuID) -> Int? { + self.renderedVersions[menuID] + } + + func canPreserveStaleContent(for menuID: MenuID) -> Bool { + guard let renderedVersion = self.renderedVersions[menuID] else { return false } + return self.contentVersion == self.latestDataOnlyContentVersion && + renderedVersion >= self.latestStructuralContentVersion + } + + func hasRequiredClosedPreparation(for menuIDs: some Sequence) -> Bool { + guard self.latestRequiredRebuildVersion > 0 else { return false } + return menuIDs.contains { self.isRenderedVersion($0, olderThan: self.latestRequiredRebuildVersion) } + } + + func closedPreparationPlan(for menuIDs: some Sequence) -> ClosedPreparationPlan { + if self.hasRequiredClosedPreparation(for: menuIDs) { + return .required(version: self.latestRequiredRebuildVersion) + } + if self.contentVersion > self.latestRequiredRebuildVersion { + return .none + } + return .nonDeferred + } + + func isRenderedVersion(_ menuID: MenuID, olderThan version: Int) -> Bool { + (self.renderedVersions[menuID] ?? -1) < version + } + + mutating func deferUntilNextOpen(_ menuID: MenuID) { + self.deferredUntilNextOpen.insert(menuID) + } + + mutating func clearNextOpenDeferral(_ menuID: MenuID) { + self.deferredUntilNextOpen.remove(menuID) + } + + func isDeferredUntilNextOpen(_ menuID: MenuID) -> Bool { + self.deferredUntilNextOpen.contains(menuID) + } + + mutating func deferParentRebuild(_ menuID: MenuID) { + self.parentRebuildsDeferredDuringTracking.insert(menuID) + } + + mutating func clearParentRebuildDeferral(_ menuID: MenuID) { + self.parentRebuildsDeferredDuringTracking.remove(menuID) + } + + func isParentRebuildDeferred(_ menuID: MenuID) -> Bool { + self.parentRebuildsDeferredDuringTracking.contains(menuID) + } + + /// Identifies one concrete open/close lifetime even when AppKit reuses the same menu object. + @discardableResult + mutating func beginTrackingSession(_ menuID: MenuID) -> Int { + self.replaceMenuInteractionGeneration(for: menuID) + } + + func menuInteractionGeneration(for menuID: MenuID) -> Int? { + self.menuInteractionGenerations[menuID] + } + + func isCurrentMenuInteraction(_ generation: Int, for menuID: MenuID) -> Bool { + self.menuInteractionGenerations[menuID] == generation + } + + @discardableResult + mutating func advanceMenuInteraction(for menuID: MenuID) -> Int? { + guard self.menuInteractionGenerations[menuID] != nil else { return nil } + return self.replaceMenuInteractionGeneration(for: menuID) + } + + private mutating func replaceMenuInteractionGeneration(for menuID: MenuID) -> Int { + self.nextMenuInteractionGeneration &+= 1 + self.menuInteractionGenerations[menuID] = self.nextMenuInteractionGeneration + return self.nextMenuInteractionGeneration + } + + mutating func endTrackingSession(_ menuID: MenuID) { + self.menuInteractionGenerations.removeValue(forKey: menuID) + } + + /// One-shot viewport restore tied to the menu-tracking session that started a manual refresh. + @discardableResult + mutating func armViewportRestore(_ menuID: MenuID) -> Int { + self.nextViewportRestoreGeneration &+= 1 + self.pendingViewportRestores[menuID] = self.nextViewportRestoreGeneration + return self.nextViewportRestoreGeneration + } + + func isCurrentViewportRestore(_ generation: Int, for menuID: MenuID) -> Bool { + self.pendingViewportRestores[menuID] == generation + } + + @discardableResult + mutating func consumeViewportRestore(_ menuID: MenuID, generation: Int) -> Bool { + guard self.isCurrentViewportRestore(generation, for: menuID) else { return false } + self.pendingViewportRestores.removeValue(forKey: menuID) + return true + } + + mutating func cancelViewportRestore(_ menuID: MenuID) { + self.pendingViewportRestores.removeValue(forKey: menuID) + } + + mutating func removeMenu(_ menuID: MenuID) { + self.renderedVersions.removeValue(forKey: menuID) + self.deferredUntilNextOpen.remove(menuID) + self.parentRebuildsDeferredDuringTracking.remove(menuID) + self.endTrackingSession(menuID) + self.cancelViewportRestore(menuID) + } + + mutating func clearMenuTracking() { + self.renderedVersions.removeAll(keepingCapacity: false) + self.deferredUntilNextOpen.removeAll(keepingCapacity: false) + self.parentRebuildsDeferredDuringTracking.removeAll(keepingCapacity: false) + self.menuInteractionGenerations.removeAll(keepingCapacity: false) + self.pendingViewportRestores.removeAll(keepingCapacity: false) + } + + #if DEBUG + mutating func replaceContentVersionForTesting(_ version: Int) { + self.contentVersion = version + } + + mutating func replaceRenderedVersionsForTesting(_ versions: [MenuID: Int]) { + self.renderedVersions = versions + } + + mutating func replaceDeferredMenusForTesting(_ menuIDs: Set) { + self.deferredUntilNextOpen = menuIDs + } + #endif +} + +struct MenuRebuildRequestRegistry { + private var nextToken = 0 + private(set) var tokens: [MenuID: Int] = [:] + + mutating func replaceRequest(for menuID: MenuID) -> Int { + self.nextToken &+= 1 + self.tokens[menuID] = self.nextToken + return self.nextToken + } + + func isCurrent(_ token: Int, for menuID: MenuID) -> Bool { + self.tokens[menuID] == token + } + + @discardableResult + mutating func finish(_ token: Int, for menuID: MenuID) -> Bool { + guard self.isCurrent(token, for: menuID) else { return false } + self.tokens.removeValue(forKey: menuID) + return true + } + + mutating func cancel(for menuID: MenuID) { + self.tokens.removeValue(forKey: menuID) + } + + mutating func cancelAll() { + self.tokens.removeAll(keepingCapacity: false) + } +} diff --git a/Sources/CodexBar/MiniMaxAPITokenStore.swift b/Sources/CodexBar/MiniMaxAPITokenStore.swift index e4d281b925..af079bbaf7 100644 --- a/Sources/CodexBar/MiniMaxAPITokenStore.swift +++ b/Sources/CodexBar/MiniMaxAPITokenStore.swift @@ -50,7 +50,7 @@ struct KeychainMiniMaxAPITokenStore: MiniMaxAPITokenStoring { account: self.account)) } - let status = SecItemCopyMatching(query as CFDictionary, &result) + let status = KeychainSecurity.copyMatching(query as CFDictionary, &result) if status == errSecItemNotFound { return nil } @@ -91,7 +91,7 @@ struct KeychainMiniMaxAPITokenStore: MiniMaxAPITokenStoring { kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly, ] - let updateStatus = SecItemUpdate(query as CFDictionary, attributes as CFDictionary) + let updateStatus = KeychainSecurity.update(query as CFDictionary, attributes as CFDictionary) if updateStatus == errSecSuccess { return } @@ -104,7 +104,7 @@ struct KeychainMiniMaxAPITokenStore: MiniMaxAPITokenStoring { for (key, value) in attributes { addQuery[key] = value } - let addStatus = SecItemAdd(addQuery as CFDictionary, nil) + let addStatus = KeychainSecurity.add(addQuery as CFDictionary, nil) guard addStatus == errSecSuccess else { Self.log.error("Keychain add failed: \(addStatus)") throw MiniMaxAPITokenStoreError.keychainStatus(addStatus) @@ -118,7 +118,7 @@ struct KeychainMiniMaxAPITokenStore: MiniMaxAPITokenStoring { kSecAttrService as String: self.service, kSecAttrAccount as String: self.account, ] - let status = SecItemDelete(query as CFDictionary) + let status = KeychainSecurity.delete(query as CFDictionary) if status == errSecSuccess || status == errSecItemNotFound { return } diff --git a/Sources/CodexBar/MiniMaxCookieStore.swift b/Sources/CodexBar/MiniMaxCookieStore.swift index e36bbe71ad..49e714b993 100644 --- a/Sources/CodexBar/MiniMaxCookieStore.swift +++ b/Sources/CodexBar/MiniMaxCookieStore.swift @@ -50,7 +50,7 @@ struct KeychainMiniMaxCookieStore: MiniMaxCookieStoring { account: self.account)) } - let status = SecItemCopyMatching(query as CFDictionary, &result) + let status = KeychainSecurity.copyMatching(query as CFDictionary, &result) if status == errSecItemNotFound { return nil } @@ -96,7 +96,7 @@ struct KeychainMiniMaxCookieStore: MiniMaxCookieStoring { kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly, ] - let updateStatus = SecItemUpdate(query as CFDictionary, attributes as CFDictionary) + let updateStatus = KeychainSecurity.update(query as CFDictionary, attributes as CFDictionary) if updateStatus == errSecSuccess { return } @@ -109,7 +109,7 @@ struct KeychainMiniMaxCookieStore: MiniMaxCookieStoring { for (key, value) in attributes { addQuery[key] = value } - let addStatus = SecItemAdd(addQuery as CFDictionary, nil) + let addStatus = KeychainSecurity.add(addQuery as CFDictionary, nil) guard addStatus == errSecSuccess else { Self.log.error("Keychain add failed: \(addStatus)") throw MiniMaxCookieStoreError.keychainStatus(addStatus) @@ -123,7 +123,7 @@ struct KeychainMiniMaxCookieStore: MiniMaxCookieStoring { kSecAttrService as String: self.service, kSecAttrAccount as String: self.account, ] - let status = SecItemDelete(query as CFDictionary) + let status = KeychainSecurity.delete(query as CFDictionary) if status == errSecSuccess || status == errSecItemNotFound { return } diff --git a/Sources/CodexBar/Notifications+CodexBar.swift b/Sources/CodexBar/Notifications+CodexBar.swift index 4d8e4b2f9a..301a5362f4 100644 --- a/Sources/CodexBar/Notifications+CodexBar.swift +++ b/Sources/CodexBar/Notifications+CodexBar.swift @@ -4,11 +4,31 @@ import Foundation extension Notification.Name { static let codexbarOpenSettings = Notification.Name("codexbarOpenSettings") static let codexbarDebugBlinkNow = Notification.Name("codexbarDebugBlinkNow") + #if DEBUG + static let codexbarDebugSimulateMemoryPressure = + Notification.Name("com.steipete.codexbar.debug.simulateMemoryPressure") + #endif + static let codexbarSessionLimitReset = Notification.Name("codexbarSessionLimitReset") static let codexbarWeeklyLimitReset = Notification.Name("codexbarWeeklyLimitReset") static let codexbarProviderConfigDidChange = Notification.Name("codexbarProviderConfigDidChange") static let codexbarQuotaWarningDidPost = Notification.Name("codexbarQuotaWarningDidPost") } +@MainActor +final class SessionLimitResetEvent: NSObject { + let provider: UsageProvider + let accountIdentifier: String + let accountLabel: String? + let usedPercent: Double + + init(provider: UsageProvider, accountIdentifier: String, accountLabel: String?, usedPercent: Double) { + self.provider = provider + self.accountIdentifier = accountIdentifier + self.accountLabel = accountLabel + self.usedPercent = usedPercent + } +} + @MainActor final class WeeklyLimitResetEvent: NSObject { let provider: UsageProvider diff --git a/Sources/CodexBar/OpenAICreditsPurchaseWindowController.swift b/Sources/CodexBar/OpenAICreditsPurchaseWindowController.swift index 99ec8eef69..8455c222b7 100644 --- a/Sources/CodexBar/OpenAICreditsPurchaseWindowController.swift +++ b/Sources/CodexBar/OpenAICreditsPurchaseWindowController.swift @@ -361,6 +361,7 @@ final class OpenAICreditsPurchaseWindowController: NSWindowController, WKNavigat private let logger = CodexBarLog.logger(LogCategories.creditsPurchase) private var webView: WKWebView? private var accountEmail: String? + private var cacheScope: CookieHeaderCache.Scope? private var pendingAutoStart = false private let logHandler = WeakScriptMessageHandler() @@ -374,10 +375,23 @@ final class OpenAICreditsPurchaseWindowController: NSWindowController, WKNavigat fatalError("init(coder:) has not been implemented") } - func show(purchaseURL: URL, accountEmail: String?, autoStartPurchase: Bool) { + func show( + purchaseURL: URL, + accountEmail: String?, + cacheScope: CookieHeaderCache.Scope?, + autoStartPurchase: Bool) + { + guard Self.canOpenPurchaseWindow(accountEmail: accountEmail, cacheScope: cacheScope) else { + self.close() + self.accountEmail = nil + self.cacheScope = nil + self.logger.error("Buy credits blocked: scoped account email unavailable") + return + } let normalizedEmail = Self.normalizeEmail(accountEmail) - if self.window == nil || normalizedEmail != self.accountEmail { + if self.window == nil || normalizedEmail != self.accountEmail || cacheScope != self.cacheScope { self.accountEmail = normalizedEmail + self.cacheScope = cacheScope self.buildWindow() } Self.resetDebugLog() @@ -399,7 +413,9 @@ final class OpenAICreditsPurchaseWindowController: NSWindowController, WKNavigat private func buildWindow() { let config = WKWebViewConfiguration() config.userContentController.add(self.logHandler, name: Self.logHandlerName) - config.websiteDataStore = OpenAIDashboardWebsiteDataStore.store(forAccountEmail: self.accountEmail) + config.websiteDataStore = OpenAIDashboardWebsiteDataStore.store( + forAccountEmail: self.accountEmail, + scope: self.cacheScope) let webView = WKWebView(frame: .zero, configuration: config) webView.navigationDelegate = self @@ -468,6 +484,10 @@ final class OpenAICreditsPurchaseWindowController: NSWindowController, WKNavigat return raw.lowercased() } + static func canOpenPurchaseWindow(accountEmail: String?, cacheScope: CookieHeaderCache.Scope?) -> Bool { + cacheScope == nil || self.normalizeEmail(accountEmail) != nil + } + private static func defaultFrame() -> NSRect { let visible = NSScreen.main?.visibleFrame ?? NSRect(x: 0, y: 0, width: 1200, height: 900) let width = min(Self.defaultSize.width, visible.width * 0.92) diff --git a/Sources/CodexBar/PersonalInfoRedactor.swift b/Sources/CodexBar/PersonalInfoRedactor.swift index 306e981fee..8815a86c5f 100644 --- a/Sources/CodexBar/PersonalInfoRedactor.swift +++ b/Sources/CodexBar/PersonalInfoRedactor.swift @@ -1,7 +1,7 @@ import Foundation enum PersonalInfoRedactor { - static let emailPlaceholder = "Hidden" + static let emailPlaceholder = "" private static let emailRegex: NSRegularExpression? = { let pattern = #"[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}"# @@ -19,10 +19,15 @@ enum PersonalInfoRedactor { guard isEnabled else { return text } guard let regex = Self.emailRegex else { return text } let range = NSRange(text.startIndex.. 1 { + if self.visibleSeries.count > 1 { Picker(selection: Binding( get: { effectiveSelectedSeries?.id ?? "" }, set: { newValue in self.selectedSeriesID = newValue self.selectedPointID = nil })) { - ForEach(visibleSeries) { series in + ForEach(self.visibleSeries) { series in Text(series.title).tag(series.id) } } label: { @@ -172,9 +174,9 @@ struct PlanUtilizationHistoryChartMenuView: View { .padding(.horizontal, 16) .padding(.vertical, 10) .frame(minWidth: self.width, maxWidth: .infinity, alignment: .topLeading) - .task(id: visibleSeries.map(\.id).joined(separator: ",")) { - guard let firstVisibleSeries = visibleSeries.first else { return } - guard !visibleSeries.contains(where: { $0.id == self.selectedSeriesID }) else { return } + .task(id: self.visibleSeries.map(\.id).joined(separator: ",")) { + guard let firstVisibleSeries = self.visibleSeries.first else { return } + guard !self.visibleSeries.contains(where: { $0.id == self.selectedSeriesID }) else { return } self.selectedSeriesID = firstVisibleSeries.id self.selectedPointID = nil } @@ -228,12 +230,12 @@ struct PlanUtilizationHistoryChartMenuView: View { } } - private nonisolated static func mergedEntries( + nonisolated static func mergedEntries( _ entries: [PlanUtilizationHistoryEntry]) -> [PlanUtilizationHistoryEntry] { - entries.reduce(into: []) { result, entry in - guard !result.contains(entry) else { return } - result.append(entry) + var seen: Set = [] + return entries.filter { entry in + seen.insert(entry).inserted } } @@ -244,20 +246,29 @@ struct PlanUtilizationHistoryChartMenuView: View { guard let snapshot else { return nil } var names: Set = [] - if snapshot.primary != nil { - names.insert(.session) - } - if snapshot.secondary != nil { + switch provider { + case .codex: + if snapshot.primary != nil { names.insert(.session) } + if snapshot.secondary != nil { names.insert(.weekly) } + case .claude: + if snapshot.primary != nil { names.insert(.session) } + if snapshot.secondary != nil { names.insert(.weekly) } + if snapshot.tertiary != nil, + ProviderDescriptorRegistry.metadata[provider]?.supportsOpus == true + { + names.insert(.opus) + } + case .opencodego: + if snapshot.primary != nil { names.insert(.session) } + if snapshot.secondary != nil { names.insert(.weekly) } + if snapshot.tertiary != nil { names.insert(.monthly) } + default: + let windows = [snapshot.primary, snapshot.secondary, snapshot.tertiary].compactMap(\.self) + + (snapshot.extraRateWindows?.filter(\.usageKnown).map(\.window) ?? []) + guard windows.contains(where: { $0.windowMinutes == 7 * 24 * 60 }) else { return nil } names.insert(.weekly) } - if provider == .claude, - snapshot.tertiary != nil, - ProviderDescriptorRegistry.metadata[provider]?.supportsOpus == true - { - names.insert(.opus) - } - return names } @@ -614,6 +625,8 @@ struct PlanUtilizationHistoryChartMenuView: View { L(metadata?.sessionLabel ?? "Session") case .weekly: L(metadata?.weeklyLabel ?? "Weekly") + case .monthly: + metadata?.opusLabel ?? "Monthly" case .opus: metadata?.opusLabel ?? "Opus" default: @@ -634,6 +647,8 @@ struct PlanUtilizationHistoryChartMenuView: View { 0 case .weekly: 1 + case .monthly: + 2 case .opus: 2 default: @@ -793,6 +808,18 @@ struct PlanUtilizationHistoryChartMenuView: View { } } + // Stay on the last selected bar when cursor is in the gap between bars; only switch + // selection when the cursor is over the bar's own visual body. + if let best, let bestPoint = model.pointsByID[best.id], + let barX = proxy.position(forX: Double(bestPoint.index)) + { + guard ChartBarHoverSelection.accepts( + distanceFromBarCenter: abs(location.x - (plotFrame.origin.x + barX)), + barHalfWidth: Layout.barWidth / 2, + selectableCount: model.points.count) + else { return } + } + if self.selectedPointID != best?.id { self.selectedPointID = best?.id } diff --git a/Sources/CodexBar/PlanUtilizationHistoryStore.swift b/Sources/CodexBar/PlanUtilizationHistoryStore.swift index 13b6b4d970..28dc6dff4c 100644 --- a/Sources/CodexBar/PlanUtilizationHistoryStore.swift +++ b/Sources/CodexBar/PlanUtilizationHistoryStore.swift @@ -1,7 +1,7 @@ import CodexBarCore import Foundation -struct PlanUtilizationSeriesName: RawRepresentable, Hashable, Codable, ExpressibleByStringLiteral { +struct PlanUtilizationSeriesName: RawRepresentable, Hashable, Codable, ExpressibleByStringLiteral, Sendable { let rawValue: String init(rawValue: String) { @@ -14,6 +14,7 @@ struct PlanUtilizationSeriesName: RawRepresentable, Hashable, Codable, Expressib static let session: Self = "session" static let weekly: Self = "weekly" + static let monthly: Self = "monthly" static let opus: Self = "opus" func canonicalWindowMinutes(_ windowMinutes: Int) -> Int { @@ -28,13 +29,13 @@ struct PlanUtilizationSeriesName: RawRepresentable, Hashable, Codable, Expressib } } -struct PlanUtilizationHistoryEntry: Codable, Equatable { +struct PlanUtilizationHistoryEntry: Codable, Equatable, Hashable, Sendable { let capturedAt: Date let usedPercent: Double let resetsAt: Date? } -struct PlanUtilizationSeriesHistory: Codable, Equatable { +struct PlanUtilizationSeriesHistory: Codable, Equatable, Sendable { let name: PlanUtilizationSeriesName let windowMinutes: Int let entries: [PlanUtilizationHistoryEntry] @@ -60,10 +61,34 @@ struct PlanUtilizationSeriesHistory: Codable, Equatable { } } -struct PlanUtilizationHistoryBuckets: Equatable { +struct PlanUtilizationHistorySelection { + let accountKey: String? + let histories: [PlanUtilizationSeriesHistory] + let cacheIdentity: String + + init(accountKey: String?, histories: [PlanUtilizationSeriesHistory]) { + self.accountKey = accountKey + self.histories = histories + self.cacheIdentity = "account:\(accountKey ?? UsageStore.planUtilizationUnscopedPreferredKey)" + } + + private init(accountKey: String?, histories: [PlanUtilizationSeriesHistory], cacheIdentity: String) { + self.accountKey = accountKey + self.histories = histories + self.cacheIdentity = cacheIdentity + } + + static let unavailable = Self(accountKey: nil, histories: [], cacheIdentity: "unavailable") +} + +struct PlanUtilizationHistoryBuckets: Equatable, Sendable { var preferredAccountKey: String? var unscoped: [PlanUtilizationSeriesHistory] = [] var accounts: [String: [PlanUtilizationSeriesHistory]] = [:] + var sessionEquivalentWindowPairIdentities: [String: String] = [:] + + private static let unscopedIdentityKey = "__codexbar_unscoped__" + private static let invalidatedIdentity = "__codexbar_invalidated__" func histories(for accountKey: String?) -> [PlanUtilizationSeriesHistory] { guard let accountKey, !accountKey.isEmpty else { return self.unscoped } @@ -83,6 +108,45 @@ struct PlanUtilizationHistoryBuckets: Equatable { } } + func sessionEquivalentWindowPairIdentity(for accountKey: String?) -> String? { + self.sessionEquivalentWindowPairIdentities[Self.identityKey(for: accountKey)] + } + + mutating func setSessionEquivalentWindowPairIdentity(_ identity: String?, for accountKey: String?) { + let key = Self.identityKey(for: accountKey) + if let identity { + self.sessionEquivalentWindowPairIdentities[key] = identity + } else { + self.sessionEquivalentWindowPairIdentities.removeValue(forKey: key) + } + } + + mutating func invalidateSessionEquivalentWindowPairIdentity(for accountKey: String?) { + self.sessionEquivalentWindowPairIdentities[Self.identityKey(for: accountKey)] = Self.invalidatedIdentity + } + + mutating func moveSessionEquivalentWindowPairIdentity( + from sourceAccountKey: String?, + to targetAccountKey: String?) + { + let sourceKey = Self.identityKey(for: sourceAccountKey) + let targetKey = Self.identityKey(for: targetAccountKey) + guard sourceKey != targetKey, + let sourceIdentity = self.sessionEquivalentWindowPairIdentities[sourceKey] + else { + return + } + + if let targetIdentity = self.sessionEquivalentWindowPairIdentities[targetKey], + targetIdentity != sourceIdentity + { + self.sessionEquivalentWindowPairIdentities[targetKey] = Self.invalidatedIdentity + } else { + self.sessionEquivalentWindowPairIdentities[targetKey] = sourceIdentity + } + self.sessionEquivalentWindowPairIdentities.removeValue(forKey: sourceKey) + } + var isEmpty: Bool { self.unscoped.isEmpty && self.accounts.values.allSatisfy(\.isEmpty) } @@ -95,22 +159,41 @@ struct PlanUtilizationHistoryBuckets: Equatable { return lhs.name.rawValue < rhs.name.rawValue } } + + private static func identityKey(for accountKey: String?) -> String { + guard let accountKey, !accountKey.isEmpty else { return self.unscopedIdentityKey } + return accountKey + } } -private struct ProviderHistoryFile: Codable { +private struct ProviderHistoryFile: Codable, Sendable { let preferredAccountKey: String? let unscoped: [PlanUtilizationSeriesHistory] let accounts: [String: [PlanUtilizationSeriesHistory]] + let sessionEquivalentWindowPairIdentities: [String: String] } -private struct ProviderHistoryDocument: Codable { +private struct ProviderHistoryDocument: Codable, Sendable { let version: Int let preferredAccountKey: String? let unscoped: [PlanUtilizationSeriesHistory] let accounts: [String: [PlanUtilizationSeriesHistory]] + let sessionEquivalentWindowPairIdentities: [String: String] } -struct PlanUtilizationHistoryStore { +extension ProviderHistoryFile { + init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.preferredAccountKey = try container.decodeIfPresent(String.self, forKey: .preferredAccountKey) + self.unscoped = try container.decode([PlanUtilizationSeriesHistory].self, forKey: .unscoped) + self.accounts = try container.decode([String: [PlanUtilizationSeriesHistory]].self, forKey: .accounts) + self.sessionEquivalentWindowPairIdentities = try container.decodeIfPresent( + [String: String].self, + forKey: .sessionEquivalentWindowPairIdentities) ?? [:] + } +} + +struct PlanUtilizationHistoryStore: Sendable { fileprivate static let providerSchemaVersion = 1 let directoryURL: URL? @@ -127,6 +210,16 @@ struct PlanUtilizationHistoryStore { self.loadProviderFiles() } + /// Loads the persisted histories on a utility-priority detached task. + /// + /// The on-disk decode is synchronous I/O + JSON parsing that can take + /// ~150 ms for mature two-year histories and must not run on the app + /// startup main thread. The returned dictionary is safe to apply on the + /// main actor once decoding completes. + func loadAsync() async -> [UsageProvider: PlanUtilizationHistoryBuckets] { + await Task.detached(priority: .utility) { self.load() }.value + } + func save(_ providers: [UsageProvider: PlanUtilizationHistoryBuckets]) { guard let directoryURL = self.directoryURL else { return } do { @@ -142,7 +235,8 @@ struct PlanUtilizationHistoryStore { let buckets = providers[provider] ?? PlanUtilizationHistoryBuckets() let unscoped = Self.sortedHistories(buckets.unscoped) let accounts = Self.sortedAccounts(buckets.accounts) - guard !unscoped.isEmpty || !accounts.isEmpty else { + guard !unscoped.isEmpty || !accounts.isEmpty || !buckets.sessionEquivalentWindowPairIdentities.isEmpty + else { try? FileManager.default.removeItem(at: fileURL) continue } @@ -151,7 +245,8 @@ struct PlanUtilizationHistoryStore { version: Self.providerSchemaVersion, preferredAccountKey: buckets.preferredAccountKey, unscoped: unscoped, - accounts: accounts) + accounts: accounts, + sessionEquivalentWindowPairIdentities: buckets.sessionEquivalentWindowPairIdentities) let data = try encoder.encode(payload) try data.write(to: fileURL, options: Data.WritingOptions.atomic) } @@ -180,7 +275,8 @@ struct PlanUtilizationHistoryStore { let history = ProviderHistoryFile( preferredAccountKey: decoded.preferredAccountKey, unscoped: decoded.unscoped, - accounts: decoded.accounts) + accounts: decoded.accounts, + sessionEquivalentWindowPairIdentities: decoded.sessionEquivalentWindowPairIdentities) output[provider] = Self.decodeProvider(history) } @@ -207,7 +303,8 @@ struct PlanUtilizationHistoryStore { let sorted = Self.sortedHistories(histories) guard !sorted.isEmpty else { return nil } return (accountKey, sorted) - })) + }), + sessionEquivalentWindowPairIdentities: providerHistory.sessionEquivalentWindowPairIdentities) } private static func sortedAccounts( @@ -265,5 +362,90 @@ extension ProviderHistoryDocument { self.preferredAccountKey = try container.decodeIfPresent(String.self, forKey: .preferredAccountKey) self.unscoped = try container.decode([PlanUtilizationSeriesHistory].self, forKey: .unscoped) self.accounts = try container.decode([String: [PlanUtilizationSeriesHistory]].self, forKey: .accounts) + self.sessionEquivalentWindowPairIdentities = try container.decodeIfPresent( + [String: String].self, + forKey: .sessionEquivalentWindowPairIdentities) ?? [:] + } +} + +/// One-shot synchronization primitive used by `UsageStore.init` to defer the +/// utility-priority plan-utilization history load until a test chooses to +/// release it. The default `nil` gate is open and the load proceeds immediately. +/// +/// Used to verify that `UsageStore.init` returns before disk I/O completes and +/// that the history is applied exactly once after the gate opens. +final class PlanUtilizationHistoryLoadGate: @unchecked Sendable { + private enum State { + case closed + case open + case cancelled + } + + private let lock = NSLock() + private var continuations: [CheckedContinuation] = [] + private var state: State = .closed + + init() {} + + var isOpen: Bool { + self.lock.lock() + defer { self.lock.unlock() } + return self.state == .open + } + + var isCancelled: Bool { + self.lock.lock() + defer { self.lock.unlock() } + return self.state == .cancelled + } + + func wait() async -> Bool { + await withCheckedContinuation { continuation in + self.lock.lock() + switch self.state { + case .open: + self.lock.unlock() + continuation.resume(returning: true) + case .cancelled: + self.lock.unlock() + continuation.resume(returning: false) + case .closed: + self.continuations.append(continuation) + self.lock.unlock() + } + } + } + + func open() { + self.lock.lock() + guard self.state == .closed else { + self.lock.unlock() + return + } + self.state = .open + let pending = self.continuations + self.continuations.removeAll() + self.lock.unlock() + for continuation in pending { + continuation.resume(returning: true) + } + } + + /// Cancels this one-shot gate and resumes pending or future waiters with + /// `false`. Cancellation is sticky so it cannot race ahead of `wait()` and + /// lose the wakeup that drains the load task. + func cancel() { + self.lock.lock() + guard self.state == .closed else { + self.lock.unlock() + return + } + self.state = .cancelled + let pending = self.continuations + self.continuations.removeAll() + self.lock.unlock() + for continuation in pending { + continuation.resume(returning: false) + } } } diff --git a/Sources/CodexBar/PredictivePaceWarnings.swift b/Sources/CodexBar/PredictivePaceWarnings.swift new file mode 100644 index 0000000000..8ee2eb3b45 --- /dev/null +++ b/Sources/CodexBar/PredictivePaceWarnings.swift @@ -0,0 +1,284 @@ +import CodexBarCore +import Foundation + +struct PredictivePaceWarningStateKey: Hashable { + let provider: UsageProvider + let accountDiscriminator: String + let window: QuotaWarningWindow + let resetWindow: PredictivePaceWarningResetWindow +} + +struct PredictivePaceWarningResetWindow: Hashable { + let windowMinutes: Int? + let resetsAt: Date + + func belongsToSameCycle(as other: Self) -> Bool { + guard self.windowMinutes == other.windowMinutes else { return false } + let tolerance = self.windowMinutes.map { max(TimeInterval($0 * 60) / 2, 300) } ?? 300 + return abs(self.resetsAt.timeIntervalSince(other.resetsAt)) < tolerance + } +} + +struct PredictivePaceWarningEvent: Equatable { + let window: QuotaWarningWindow + let etaSeconds: TimeInterval + let accountDisplayName: String? +} + +enum PredictivePaceWarningNotificationLogic { + static func notificationIDPrefix(provider: UsageProvider, event: PredictivePaceWarningEvent) -> String { + "predictive-pace-warning-\(provider.rawValue)-\(event.window.rawValue)" + } + + static func notificationCopy( + providerName: String, + event: PredictivePaceWarningEvent, + now: Date = .init()) -> (title: String, body: String) + { + let windowLabel = event.window.localizedNotificationDisplayName + let title = L("predictive_pace_warning_notification_title", providerName, windowLabel) + let durationText = Self.durationText(seconds: event.etaSeconds, now: now) + let body = if let accountDisplayName = event.accountDisplayName { + L("predictive_pace_warning_notification_body_with_account", accountDisplayName, durationText) + } else { + L("predictive_pace_warning_notification_body", durationText) + } + return (title, body) + } + + static func shouldNotify(pace: UsagePace) -> Bool { + guard !pace.willLastToReset else { return false } + guard let etaSeconds = pace.etaSeconds, etaSeconds > 0 else { return false } + guard (pace.runOutProbability ?? 1) >= 0.5 else { return false } + return true + } + + static func recordObservation( + key: PredictivePaceWarningStateKey, + pace: UsagePace, + notifiedKeys: inout Set) -> Bool + { + if pace.willLastToReset { + notifiedKeys.remove(key) + return false + } + + guard self.shouldNotify(pace: pace) else { return false } + guard !notifiedKeys.contains(key) else { return false } + notifiedKeys.insert(key) + return true + } + + static func reconcileSiblingWindowKeys( + activeKey: PredictivePaceWarningStateKey, + notifiedKeys: inout Set) + { + let siblingKeys = notifiedKeys.filter { key in + key.provider == activeKey.provider && + key.accountDiscriminator == activeKey.accountDiscriminator && + key.window == activeKey.window + } + guard !siblingKeys.isEmpty else { return } + + let alreadyWarnedThisCycle = siblingKeys.contains { key in + key.resetWindow.belongsToSameCycle(as: activeKey.resetWindow) + } + notifiedKeys.subtract(siblingKeys) + if alreadyWarnedThisCycle { + // Follow small provider reset-time corrections without re-alerting. Replacing the key + // lets successive relative-TTL observations move together instead of accumulating drift. + notifiedKeys.insert(activeKey) + } + } + + private static func durationText(seconds: TimeInterval, now: Date) -> String { + let countdown = UsageFormatter.resetCountdownDescription(from: now.addingTimeInterval(seconds), now: now) + if countdown.hasPrefix("in ") { + return String(countdown.dropFirst(3)) + } + return countdown + } +} + +@MainActor +extension UsageStore { + func handlePredictivePaceWarningTransitions( + provider: UsageProvider, + snapshot: UsageSnapshot, + accountDiscriminatorOverride: String? = nil) + { + guard self.settings.predictivePaceWarningNotificationsEnabled else { + self.predictivePaceWarningNotifiedKeys = Set( + self.predictivePaceWarningNotifiedKeys.filter { $0.provider != provider }) + return + } + guard provider == .codex || provider == .claude else { return } + guard let accountDiscriminator = self.predictivePaceWarningAccountDiscriminator( + provider: provider, + snapshot: snapshot, + accountDiscriminatorOverride: accountDiscriminatorOverride) + else { return } + + let candidates = self.predictivePaceWarningCandidates(provider: provider, snapshot: snapshot) + for candidate in candidates { + guard let resetWindow = Self.predictivePaceWarningResetWindow(for: candidate.rateWindow) else { + continue + } + let key = PredictivePaceWarningStateKey( + provider: provider, + accountDiscriminator: accountDiscriminator, + window: candidate.window, + resetWindow: resetWindow) + PredictivePaceWarningNotificationLogic.reconcileSiblingWindowKeys( + activeKey: key, + notifiedKeys: &self.predictivePaceWarningNotifiedKeys) + + guard PredictivePaceWarningNotificationLogic.recordObservation( + key: key, + pace: candidate.pace, + notifiedKeys: &self.predictivePaceWarningNotifiedKeys) + else { continue } + + self.postPredictivePaceWarning( + PredictivePaceWarningEvent( + window: candidate.window, + etaSeconds: candidate.pace.etaSeconds ?? 0, + accountDisplayName: self.predictivePaceWarningAccountDisplayName( + provider: provider, + snapshot: snapshot)), + provider: provider, + now: snapshot.updatedAt) + } + } + + private func predictivePaceWarningCandidates( + provider: UsageProvider, + snapshot: UsageSnapshot) -> [(window: QuotaWarningWindow, rateWindow: RateWindow, pace: UsagePace)] + { + var candidates: [(window: QuotaWarningWindow, rateWindow: RateWindow, pace: UsagePace)] = [] + let now = snapshot.updatedAt + + if let sessionWindow = self.predictivePaceWarningSessionWindow(provider: provider, snapshot: snapshot), + !sessionWindow.isSyntheticPlaceholder, + let sessionPace = UsagePaceText.sessionPace(provider: provider, window: sessionWindow, now: now) + { + candidates.append((window: .session, rateWindow: sessionWindow, pace: sessionPace)) + } + + if let weeklyWindow = self.predictivePaceWarningWeeklyWindow(provider: provider, snapshot: snapshot), + let weeklyPace = self.weeklyPace(provider: provider, window: weeklyWindow, now: now) + { + candidates.append((window: .weekly, rateWindow: weeklyWindow, pace: weeklyPace)) + } + + return candidates + } + + private func predictivePaceWarningSessionWindow(provider: UsageProvider, snapshot: UsageSnapshot) -> RateWindow? { + if provider == .codex { + return self.codexConsumerProjection( + surface: .liveCard, + snapshotOverride: snapshot, + now: snapshot.updatedAt) + .sourceRateWindow(for: .session) + } + return self.sessionQuotaWindow(provider: provider, snapshot: snapshot)?.window + } + + private func predictivePaceWarningWeeklyWindow(provider: UsageProvider, snapshot: UsageSnapshot) -> RateWindow? { + if provider == .codex { + return self.codexConsumerProjection( + surface: .liveCard, + snapshotOverride: snapshot, + now: snapshot.updatedAt) + .sourceRateWindow(for: .weekly) + } + return snapshot.secondary + } + + private func predictivePaceWarningAccountDiscriminator( + provider: UsageProvider, + snapshot: UsageSnapshot, + accountDiscriminatorOverride: String? = nil) -> String? + { + if provider == .codex { + return self.codexOwnershipContext( + preferredEmail: snapshot.accountEmail(for: .codex), + snapshot: snapshot) + .canonicalKey + } + + if let accountDiscriminatorOverride = accountDiscriminatorOverride? + .trimmingCharacters(in: .whitespacesAndNewlines), + !accountDiscriminatorOverride.isEmpty + { + return accountDiscriminatorOverride + } + + guard let account = snapshot.accountEmail(for: provider)? + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased(), + !account.isEmpty + else { return nil } + return "email:\(account)" + } + + static func warningClaudeAccountDiscriminator( + strategyKind: ProviderFetchKind, + observation: ClaudeOAuthActiveAccountObservation, + oauthHistoryOwnerIdentifier: String? = nil) -> String? + { + switch strategyKind { + case .cli: + return self.warningClaudeActiveAccountDiscriminator(observation: observation) + case .oauth: + if let activeAccount = self.warningClaudeActiveAccountDiscriminator( + observation: observation) + { + return activeAccount + } + guard let owner = oauthHistoryOwnerIdentifier? + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased(), + !owner.isEmpty + else { return nil } + // OAuth usage has no email. Keep a credential-scoped fallback so warning episodes remain + // account-scoped when Claude's active-account metadata is unavailable. + return "claude-oauth-owner:\(owner)" + case .apiToken, .localProbe, .web, .webDashboard: + return nil + } + } + + private static func warningClaudeActiveAccountDiscriminator( + observation: ClaudeOAuthActiveAccountObservation) -> String? + { + guard case let .stable(identity) = observation, + let identity = identity?.trimmingCharacters(in: .whitespacesAndNewlines), + !identity.isEmpty + else { return nil } + return "claude-account:\(identity)" + } + + static func warningTokenAccountDiscriminator(_ account: ProviderTokenAccount?) -> String? { + guard let account else { return nil } + return "token-account:\(account.id.uuidString.lowercased())" + } + + private func predictivePaceWarningAccountDisplayName(provider: UsageProvider, snapshot: UsageSnapshot) -> String? { + guard !self.settings.hidePersonalInfo else { return nil } + let account = snapshot.accountEmail(for: provider)? + .trimmingCharacters(in: .whitespacesAndNewlines) + guard let account, !account.isEmpty else { return nil } + return account + } + + private static func predictivePaceWarningResetWindow(for window: RateWindow) + -> PredictivePaceWarningResetWindow? + { + guard let resetsAt = window.resetsAt else { return nil } + return PredictivePaceWarningResetWindow( + windowMinutes: window.windowMinutes, + resetsAt: resetsAt) + } +} diff --git a/Sources/CodexBar/PreferencesAboutPane.swift b/Sources/CodexBar/PreferencesAboutPane.swift index 31c3bbe438..90d6301e06 100644 --- a/Sources/CodexBar/PreferencesAboutPane.swift +++ b/Sources/CodexBar/PreferencesAboutPane.swift @@ -30,7 +30,72 @@ struct AboutPane: View { } var body: some View { - VStack(spacing: 12) { + Form { + Section { + self.hero + .frame(maxWidth: .infinity) + .listRowBackground(Color.clear) + } + + if self.updater.isAvailable { + Section { + Toggle(L("check_updates_auto"), isOn: self.$autoUpdateEnabled) + + Picker(selection: self.updateChannelBinding) { + ForEach(UpdateChannel.allCases) { channel in + Text(channel.displayName).tag(channel) + } + } label: { + SettingsRowLabel(L("update_channel"), subtitle: self.updateChannel.description) + } + + LabeledContent(String(format: L("version_format"), self.versionString)) { + Button(L("check_for_updates")) { self.updater.checkForUpdates(nil) } + } + } header: { + Text(L("section_updates")) + } + } else { + Section { + Text(self.updater.unavailableReason ?? L("updates_unavailable")) + .foregroundStyle(.secondary) + } + } + + Section { + AboutLinkRow( + icon: "chevron.left.slash.chevron.right", + title: L("link_github"), + url: "https://github.com/steipete/CodexBar") + AboutLinkRow(icon: "globe", title: L("link_website"), url: "https://steipete.me") + AboutLinkRow(icon: "bird", title: L("link_twitter"), url: "https://twitter.com/steipete") + AboutLinkRow(icon: "envelope", title: L("link_email"), url: "mailto:peter@steipete.me") + } header: { + Text(L("section_links")) + } footer: { + Text(L("copyright")) + .frame(maxWidth: .infinity) + .multilineTextAlignment(.center) + } + } + .formStyle(.grouped) + .toggleStyle(.switch) + .scrollContentBackground(.hidden) + .onAppear { + guard !self.didLoadUpdaterState else { return } + // Align Sparkle's flag with the persisted preference on first load. + self.updater.automaticallyChecksForUpdates = self.autoUpdateEnabled + self.updater.automaticallyDownloadsUpdates = self.autoUpdateEnabled + self.didLoadUpdaterState = true + } + .onChange(of: self.autoUpdateEnabled) { _, newValue in + self.updater.automaticallyChecksForUpdates = newValue + self.updater.automaticallyDownloadsUpdates = newValue + } + } + + private var hero: some View { + VStack(spacing: 10) { if let image = NSApplication.shared.applicationIconImage { Button(action: self.openProjectHome) { Image(nsImage: image) @@ -41,6 +106,7 @@ struct AboutPane: View { .shadow(color: self.iconHover ? .accentColor.opacity(0.25) : .clear, radius: 6) } .buttonStyle(.plain) + .focusEffectDisabled() .onHover { hovering in withAnimation(.spring(response: 0.32, dampingFraction: 0.78)) { self.iconHover = hovering @@ -62,75 +128,8 @@ struct AboutPane: View { .font(.footnote) .foregroundStyle(.secondary) } - - VStack(alignment: .center, spacing: 10) { - AboutLinkRow( - icon: "chevron.left.slash.chevron.right", - title: L("link_github"), - url: "https://github.com/steipete/CodexBar") - AboutLinkRow(icon: "globe", title: L("link_website"), url: "https://steipete.me") - AboutLinkRow(icon: "bird", title: L("link_twitter"), url: "https://twitter.com/steipete") - AboutLinkRow(icon: "envelope", title: L("link_email"), url: "mailto:peter@steipete.me") - } - .padding(.top, 8) - .frame(maxWidth: .infinity) - .multilineTextAlignment(.center) - - Divider() - - if self.updater.isAvailable { - VStack(spacing: 10) { - Toggle(L("check_updates_auto"), isOn: self.$autoUpdateEnabled) - .toggleStyle(.checkbox) - .frame(maxWidth: .infinity, alignment: .center) - VStack(spacing: 6) { - HStack(spacing: 12) { - Text(L("update_channel")) - Spacer() - Picker("", selection: self.updateChannelBinding) { - ForEach(UpdateChannel.allCases) { channel in - Text(channel.displayName).tag(channel) - } - } - .pickerStyle(.menu) - .labelsHidden() - } - .frame(maxWidth: 280) - Text(self.updateChannel.description) - .font(.footnote) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) - .frame(maxWidth: 280) - } - Button(L("check_for_updates")) { self.updater.checkForUpdates(nil) } - } - } else { - Text(self.updater.unavailableReason ?? L("updates_unavailable")) - .foregroundStyle(.secondary) - } - - Text(L("copyright")) - .font(.footnote) - .foregroundStyle(.secondary) - .padding(.top, 4) - - Spacer(minLength: 0) - } - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) - .padding(.top, 4) - .padding(.horizontal, 24) - .padding(.bottom, 24) - .onAppear { - guard !self.didLoadUpdaterState else { return } - // Align Sparkle's flag with the persisted preference on first load. - self.updater.automaticallyChecksForUpdates = self.autoUpdateEnabled - self.updater.automaticallyDownloadsUpdates = self.autoUpdateEnabled - self.didLoadUpdaterState = true - } - .onChange(of: self.autoUpdateEnabled) { _, newValue in - self.updater.automaticallyChecksForUpdates = newValue - self.updater.automaticallyDownloadsUpdates = newValue } + .padding(.vertical, 6) } private var updateChannel: UpdateChannel { @@ -151,3 +150,32 @@ struct AboutPane: View { NSWorkspace.shared.open(url) } } + +@MainActor +struct AboutLinkRow: View { + let icon: String + let title: String + let url: String + @State private var hovering = false + + var body: some View { + Button { + if let url = URL(string: self.url) { NSWorkspace.shared.open(url) } + } label: { + HStack(spacing: 8) { + Image(systemName: self.icon) + .frame(width: 18) + .foregroundStyle(.secondary) + Text(self.title) + .foregroundStyle(.primary) + Spacer() + Image(systemName: "arrow.up.right") + .font(.caption) + .foregroundStyle(self.hovering ? Color.accentColor : Color.secondary) + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .onHover { self.hovering = $0 } + } +} diff --git a/Sources/CodexBar/PreferencesAdvancedPane.swift b/Sources/CodexBar/PreferencesAdvancedPane.swift index e2f0b36577..ab21733bdf 100644 --- a/Sources/CodexBar/PreferencesAdvancedPane.swift +++ b/Sources/CodexBar/PreferencesAdvancedPane.swift @@ -1,104 +1,71 @@ -import KeyboardShortcuts +import CodexBarCore import SwiftUI @MainActor struct AdvancedPane: View { @Bindable var settings: SettingsStore + @Bindable var store: UsageStore @State private var isInstallingCLI = false @State private var cliStatus: String? var body: some View { - ScrollView(.vertical, showsIndicators: true) { - VStack(alignment: .leading, spacing: 16) { - SettingsSection(contentSpacing: 8) { - Text(L("section_keyboard_shortcut")) - .font(.caption) - .foregroundStyle(.secondary) - .textCase(.uppercase) - HStack(alignment: .center, spacing: 12) { - Text(L("open_menu_shortcut_title")) - .font(.body) - Spacer() - KeyboardShortcuts.Recorder(for: .openMenu) - } - Text(L("open_menu_shortcut_subtitle")) - .font(.footnote) - .foregroundStyle(.tertiary) - } - - Divider() - - SettingsSection(contentSpacing: 10) { - HStack(spacing: 12) { - Button { - Task { await self.installCLI() } - } label: { - if self.isInstallingCLI { - ProgressView().controlSize(.small) - } else { - Text(L("install_cli")) - } - } - .disabled(self.isInstallingCLI) - - if let status = self.cliStatus { - Text(status) - .font(.footnote) - .foregroundStyle(.tertiary) - .lineLimit(2) + Form { + Section { + LabeledContent { + Button { + Task { await self.installCLI() } + } label: { + if self.isInstallingCLI { + ProgressView().controlSize(.small) + } else { + Text(L("install_cli")) } } - Text(L("install_cli_subtitle")) - .font(.footnote) - .foregroundStyle(.tertiary) + .disabled(self.isInstallingCLI) + } label: { + SettingsRowLabel(L("install_cli"), subtitle: L("install_cli_subtitle")) } - - Divider() - - SettingsSection(contentSpacing: 10) { - PreferenceToggleRow( - title: L("show_debug_settings_title"), - subtitle: L("show_debug_settings_subtitle"), - binding: self.$settings.debugMenuEnabled) - PreferenceToggleRow( - title: L("surprise_me_title"), - subtitle: L("surprise_me_subtitle"), - binding: self.$settings.randomBlinkEnabled) - PreferenceToggleRow( - title: L("weekly_limit_confetti_title"), - subtitle: L("weekly_limit_confetti_subtitle"), - binding: self.$settings.confettiOnWeeklyLimitResetsEnabled) + } header: { + Text(L("section_command_line")) + } footer: { + if let status = self.cliStatus { + SettingsSectionFooter(status) } + } - Divider() + Section { + Toggle(isOn: self.$settings.hidePersonalInfo) { + SettingsRowLabel(L("hide_personal_info_title"), subtitle: L("hide_personal_info_subtitle")) + } - SettingsSection(contentSpacing: 10) { - PreferenceToggleRow( - title: L("hide_personal_info_title"), - subtitle: L("hide_personal_info_subtitle"), - binding: self.$settings.hidePersonalInfo) - PreferenceToggleRow( - title: L("show_provider_storage_usage_title"), - subtitle: L("show_provider_storage_usage_subtitle"), - binding: self.$settings.providerStorageFootprintsEnabled) + Toggle(isOn: self.$settings.debugDisableKeychainAccess) { + SettingsRowLabel( + L("disable_keychain_access_title"), + subtitle: L("disable_keychain_access_subtitle")) } + } header: { + Text(L("section_privacy")) + } footer: { + SettingsSectionFooter(L("keychain_access_caption")) + } - Divider() + Section { + Toggle(isOn: self.$settings.providerStorageFootprintsEnabled) { + SettingsRowLabel( + L("show_provider_storage_usage_title"), + subtitle: L("show_provider_storage_usage_subtitle")) + } - SettingsSection( - title: L("section_keychain_access"), - caption: L("keychain_access_caption")) - { - PreferenceToggleRow( - title: L("disable_keychain_access_title"), - subtitle: L("disable_keychain_access_subtitle"), - binding: self.$settings.debugDisableKeychainAccess) + Toggle(isOn: self.$settings.debugMenuEnabled) { + SettingsRowLabel(L("show_debug_settings_title"), subtitle: L("show_debug_settings_subtitle")) } + } header: { + Text(L("section_diagnostics")) } - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.horizontal, 20) - .padding(.vertical, 12) } + .formStyle(.grouped) + .toggleStyle(.switch) + .scrollContentBackground(.hidden) } } diff --git a/Sources/CodexBar/PreferencesCodexAccountsSection.swift b/Sources/CodexBar/PreferencesCodexAccountsSection.swift index f42be52718..19f5bbc786 100644 --- a/Sources/CodexBar/PreferencesCodexAccountsSection.swift +++ b/Sources/CodexBar/PreferencesCodexAccountsSection.swift @@ -132,7 +132,7 @@ struct CodexAccountsSectionView: View { let addAccount: () -> Void var body: some View { - ProviderSettingsSection(title: L("Accounts")) { + Section { if let selection = self.activeSelectionBinding { VStack(alignment: .leading, spacing: 6) { HStack(alignment: .firstTextBaseline, spacing: 10) { @@ -212,6 +212,8 @@ struct CodexAccountsSectionView: View { .buttonStyle(.bordered) .controlSize(.small) .disabled(self.state.canAddAccount == false) + } header: { + Text(L("Accounts")) } } diff --git a/Sources/CodexBar/PreferencesComponents.swift b/Sources/CodexBar/PreferencesComponents.swift index d0fb56a0df..aba32757a9 100644 --- a/Sources/CodexBar/PreferencesComponents.swift +++ b/Sources/CodexBar/PreferencesComponents.swift @@ -1,6 +1,107 @@ import AppKit +import KeyboardShortcuts import SwiftUI +/// Colored rounded-square symbol used for app panes in the settings sidebar, +/// mirroring the System Settings sidebar style. +struct SettingsIconChip: View { + static let side: CGFloat = 20 + + let systemImage: String + let color: Color + + var body: some View { + Image(systemName: self.systemImage) + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(.white) + .frame(width: Self.side, height: Self.side) + .background( + RoundedRectangle(cornerRadius: 5, style: .continuous) + .fill(LinearGradient( + colors: [self.color.opacity(0.85), self.color], + startPoint: .top, + endPoint: .bottom))) + .accessibilityHidden(true) + } +} + +/// Two-line label for grouped-form rows that genuinely need a supporting sentence. +struct SettingsRowLabel: View { + let title: String + let subtitle: String? + + init(_ title: String, subtitle: String? = nil) { + self.title = title + self.subtitle = subtitle + } + + var body: some View { + VStack(alignment: .leading, spacing: 2) { + Text(self.title) + if let subtitle, !subtitle.isEmpty { + Text(subtitle) + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + } + } +} + +/// Section footer for grouped forms. macOS renders bare footer text trailing-aligned +/// at body size, which reads badly for long captions; this pins it leading at footnote +/// size in secondary color, matching System Settings captions. +struct SettingsSectionFooter: View { + private let content: Content + + init(@ViewBuilder content: () -> Content) { + self.content = content() + } + + var body: some View { + self.content + .font(.footnote) + .foregroundStyle(.secondary) + .multilineTextAlignment(.leading) + .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: .infinity, alignment: .leading) + } +} + +extension SettingsSectionFooter where Content == Text { + init(_ text: String) { + self.init { Text(text) } + } +} + +@MainActor +struct OpenMenuShortcutRecorder: NSViewRepresentable { + static let preferredWidth: CGFloat = 170 + + func makeNSView(context: Context) -> KeyboardShortcuts.RecorderCocoa { + KeyboardShortcuts.RecorderCocoa(for: .openMenu) + } + + func updateNSView(_ nsView: KeyboardShortcuts.RecorderCocoa, context: Context) { + nsView.shortcutName = .openMenu + } + + func sizeThatFits( + _: ProposedViewSize, + nsView: KeyboardShortcuts.RecorderCocoa, + context: Context) + -> CGSize? + { + Self.fittedSize(intrinsicHeight: nsView.intrinsicContentSize.height) + } + + static func fittedSize(intrinsicHeight: CGFloat) -> CGSize { + CGSize(width: self.preferredWidth, height: intrinsicHeight) + } +} + +// MARK: - Legacy building blocks (Debug pane) + @MainActor struct PreferenceToggleRow: View { let title: String @@ -61,31 +162,6 @@ struct SettingsSection: View { } .frame(maxWidth: .infinity, alignment: .leading) } - } -} - -@MainActor -struct AboutLinkRow: View { - let icon: String - let title: String - let url: String - @State private var hovering = false - - var body: some View { - Button { - if let url = URL(string: self.url) { NSWorkspace.shared.open(url) } - } label: { - HStack(spacing: 8) { - Image(systemName: self.icon) - Text(self.title) - .underline(self.hovering, color: .accentColor) - } - .frame(maxWidth: .infinity) - .padding(.vertical, 4) - .foregroundColor(.accentColor) - } - .buttonStyle(.plain) - .contentShape(Rectangle()) - .onHover { self.hovering = $0 } + .frame(maxWidth: .infinity, alignment: .leading) } } diff --git a/Sources/CodexBar/PreferencesDebugPane.swift b/Sources/CodexBar/PreferencesDebugPane.swift index e5de4da915..d868a64485 100644 --- a/Sources/CodexBar/PreferencesDebugPane.swift +++ b/Sources/CodexBar/PreferencesDebugPane.swift @@ -316,6 +316,7 @@ struct DebugPane: View { Text("Augment").tag(UsageProvider.augment) Text("Amp").tag(UsageProvider.amp) Text("T3 Chat").tag(UsageProvider.t3chat) + Text("ZoomMate").tag(UsageProvider.zoommate) Text("Ollama").tag(UsageProvider.ollama) } .pickerStyle(.segmented) @@ -524,9 +525,13 @@ struct DebugPane: View { } private func clearCookieCache() { - let cleared = CookieHeaderCache.clearAll() - if cleared > 0 { - self.cookieCacheStatus = "Cleared \(cleared) provider\(cleared == 1 ? "" : "s")." + let summary = CookieHeaderCache.clearAllDetailed() + if summary.failedCount > 0 { + self.cookieCacheStatus = "Cookie cache cleanup failed for \(summary.failedCount) " + + "operation\(summary.failedCount == 1 ? "" : "s")." + } else if summary.clearedCount > 0 { + self.cookieCacheStatus = "Cleared \(summary.clearedCount) " + + "provider\(summary.clearedCount == 1 ? "" : "s")." } else { self.cookieCacheStatus = "No cached cookies found." } diff --git a/Sources/CodexBar/PreferencesDisplayPane.swift b/Sources/CodexBar/PreferencesDisplayPane.swift deleted file mode 100644 index 7466986b35..0000000000 --- a/Sources/CodexBar/PreferencesDisplayPane.swift +++ /dev/null @@ -1,331 +0,0 @@ -import CodexBarCore -import SwiftUI - -@MainActor -struct DisplayPane: View { - private static let maxOverviewProviders = SettingsStore.mergedOverviewProviderLimit - - static func overviewProviderLimitText(limit: Int = Self.maxOverviewProviders) -> String { - L("overview_choose_providers", String(limit)) - } - - @State private var isOverviewProviderPopoverPresented = false - @Bindable var settings: SettingsStore - @Bindable var store: UsageStore - - var body: some View { - ScrollView(.vertical, showsIndicators: true) { - VStack(alignment: .leading, spacing: 16) { - SettingsSection(contentSpacing: 12) { - Text(L("section_menu_bar")) - .font(.caption) - .foregroundStyle(.secondary) - .textCase(.uppercase) - PreferenceToggleRow( - title: L("merge_icons_title"), - subtitle: L("merge_icons_subtitle"), - binding: self.$settings.mergeIcons) - PreferenceToggleRow( - title: L("switcher_shows_icons_title"), - subtitle: L("switcher_shows_icons_subtitle"), - binding: self.$settings.switcherShowsIcons) - .disabled(!self.settings.mergeIcons) - .opacity(self.settings.mergeIcons ? 1 : 0.5) - PreferenceToggleRow( - title: L("show_most_used_provider_title"), - subtitle: L("show_most_used_provider_subtitle"), - binding: self.$settings.menuBarShowsHighestUsage) - .disabled(!self.settings.mergeIcons) - .opacity(self.settings.mergeIcons ? 1 : 0.5) - PreferenceToggleRow( - title: "Color-coded icons", - subtitle: "Tint menu bar icons green, yellow, or red based on session usage.", - binding: self.$settings.colorCodedIcons) - PreferenceToggleRow( - title: L("menu_bar_shows_percent_title"), - subtitle: L("menu_bar_shows_percent_subtitle"), - binding: self.$settings.menuBarShowsBrandIconWithPercent) - HStack(alignment: .top, spacing: 12) { - VStack(alignment: .leading, spacing: 4) { - Text(L("display_mode_title")) - .font(.body) - Text(L("display_mode_subtitle")) - .font(.footnote) - .foregroundStyle(.tertiary) - } - Spacer() - Picker(L("Display mode"), selection: self.$settings.menuBarDisplayMode) { - ForEach(MenuBarDisplayMode.allCases) { mode in - Text(mode.label).tag(mode) - } - } - .labelsHidden() - .pickerStyle(.menu) - .frame(maxWidth: 200) - } - .disabled(!self.settings.menuBarShowsBrandIconWithPercent) - .opacity(self.settings.menuBarShowsBrandIconWithPercent ? 1 : 0.5) - HStack(alignment: .top, spacing: 12) { - VStack(alignment: .leading, spacing: 4) { - Text("Separator") - .font(.body) - Text("Character between percent and pace (e.g. 45% | +5%).") - .font(.footnote) - .foregroundStyle(.tertiary) - } - Spacer() - Picker("Separator", selection: self.$settings.menuBarSeparatorStyle) { - ForEach(MenuBarSeparatorStyle.allCases) { style in - Text(style.label).tag(style) - } - } - .labelsHidden() - .pickerStyle(.menu) - .frame(maxWidth: 200) - } - .disabled(!self.settings.menuBarShowsBrandIconWithPercent || - self.settings.menuBarDisplayMode != .both) - .opacity(self.settings.menuBarShowsBrandIconWithPercent && - self.settings.menuBarDisplayMode == .both ? 1 : 0.5) - VStack(alignment: .leading, spacing: 4) { - Text("Time windows") - .font(.body) - Text("Choose which time window drives the percent and pace values.") - .font(.footnote) - .foregroundStyle(.tertiary) - Grid(alignment: .leading, horizontalSpacing: 8, verticalSpacing: 6) { - GridRow { - Text("Percent:") - .font(.callout) - Picker( - "Percent time window", - selection: self.$settings.menuBarPercentTimeWindow) - { - ForEach(MenuBarTimeWindow.allCases) { window in - Text(window.label).tag(window) - } - } - .labelsHidden() - .pickerStyle(.segmented) - .frame(maxWidth: 160) - } - .disabled(self.settings.menuBarDisplayMode == .pace) - .opacity(self.settings.menuBarDisplayMode == .pace ? 0.5 : 1) - GridRow { - Text("Pace:") - .font(.callout) - Picker( - "Pace time window", - selection: self.$settings.menuBarPaceTimeWindow) - { - ForEach(MenuBarTimeWindow.allCases) { window in - Text(window.label).tag(window) - } - } - .labelsHidden() - .pickerStyle(.segmented) - .frame(maxWidth: 160) - } - .disabled(self.settings.menuBarDisplayMode == .percent) - .opacity(self.settings.menuBarDisplayMode == .percent ? 0.5 : 1) - } - } - .disabled(!self.settings.menuBarShowsBrandIconWithPercent) - .opacity(self.settings.menuBarShowsBrandIconWithPercent ? 1 : 0.5) - } - - Divider() - - SettingsSection(contentSpacing: 12) { - Text(L("section_menu_content")) - .font(.caption) - .foregroundStyle(.secondary) - .textCase(.uppercase) - PreferenceToggleRow( - title: L("show_usage_as_used_title"), - subtitle: L("show_usage_as_used_subtitle"), - binding: self.$settings.usageBarsShowUsed) - PreferenceToggleRow( - title: L("show_quota_warning_markers_title"), - subtitle: L("show_quota_warning_markers_subtitle"), - binding: self.$settings.quotaWarningMarkersVisible) - HStack(alignment: .top, spacing: 12) { - VStack(alignment: .leading, spacing: 4) { - Text(L("weekly_progress_work_days_title")) - .font(.body) - Text(L("weekly_progress_work_days_subtitle")) - .font(.footnote) - .foregroundStyle(.tertiary) - } - Spacer() - Picker(L("weekly_progress_work_days_title"), selection: self.$settings.weeklyProgressWorkDays) { - Text(L("Off")).tag(nil as Int?) - Text(L("4 days")).tag(4 as Int?) - Text(L("5 days")).tag(5 as Int?) - Text(L("7 days")).tag(7 as Int?) - } - .labelsHidden() - .pickerStyle(.menu) - .frame(maxWidth: 100) - } - PreferenceToggleRow( - title: L("show_reset_time_as_clock_title"), - subtitle: L("show_reset_time_as_clock_subtitle"), - binding: self.$settings.resetTimesShowAbsolute) - PreferenceToggleRow( - title: L("show_provider_changelog_links_title"), - subtitle: L("show_provider_changelog_links_subtitle"), - binding: self.$settings.providerChangelogLinksEnabled) - PreferenceToggleRow( - title: L("show_credits_extra_usage_title"), - subtitle: L("show_credits_extra_usage_subtitle"), - binding: self.$settings.showOptionalCreditsAndExtraUsage) - HStack(alignment: .top, spacing: 12) { - VStack(alignment: .leading, spacing: 4) { - Text(L("multi_account_layout_title")) - .font(.body) - Text(L("multi_account_layout_subtitle")) - .font(.footnote) - .foregroundStyle(.tertiary) - } - Spacer() - Picker(L("multi_account_layout_title"), selection: self.$settings.multiAccountMenuLayout) { - ForEach(MultiAccountMenuLayout.allCases) { layout in - Text(layout.label).tag(layout) - } - } - .labelsHidden() - .pickerStyle(.menu) - .frame(maxWidth: 200) - } - self.overviewProviderSelector - } - } - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.horizontal, 20) - .padding(.vertical, 12) - .onAppear { - self.reconcileOverviewSelection() - } - .onChange(of: self.settings.mergeIcons) { _, isEnabled in - guard isEnabled else { - self.isOverviewProviderPopoverPresented = false - return - } - self.reconcileOverviewSelection() - } - .onChange(of: self.activeProvidersInOrder) { _, _ in - if self.activeProvidersInOrder.isEmpty { - self.isOverviewProviderPopoverPresented = false - } - self.reconcileOverviewSelection() - } - } - } - - private var overviewProviderSelector: some View { - VStack(alignment: .leading, spacing: 6) { - HStack(alignment: .center, spacing: 12) { - Text(L("overview_tab_providers_title")) - .font(.body) - Spacer(minLength: 0) - if self.showsOverviewConfigureButton { - Button(L("configure")) { - self.isOverviewProviderPopoverPresented = true - } - .offset(y: 1) - .popover(isPresented: self.$isOverviewProviderPopoverPresented, arrowEdge: .bottom) { - self.overviewProviderPopover - } - } - } - - if !self.settings.mergeIcons { - Text(L("overview_enable_merge_icons_hint")) - .font(.footnote) - .foregroundStyle(.tertiary) - } else if self.activeProvidersInOrder.isEmpty { - Text(L("overview_no_providers_hint")) - .font(.footnote) - .foregroundStyle(.tertiary) - } else { - Text(self.overviewProviderSelectionSummary) - .font(.footnote) - .foregroundStyle(.tertiary) - .lineLimit(2) - .truncationMode(.tail) - } - } - } - - private var overviewProviderPopover: some View { - VStack(alignment: .leading, spacing: 10) { - Text(Self.overviewProviderLimitText()) - .font(.headline) - Text(L("overview_rows_follow_order")) - .font(.footnote) - .foregroundStyle(.tertiary) - - ScrollView(.vertical, showsIndicators: true) { - VStack(alignment: .leading, spacing: 6) { - ForEach(self.activeProvidersInOrder, id: \.self) { provider in - Toggle( - isOn: Binding( - get: { self.overviewSelectedProviders.contains(provider) }, - set: { shouldSelect in - self.setOverviewProviderSelection(provider: provider, isSelected: shouldSelect) - })) { - Text(self.providerDisplayName(provider)) - .font(.body) - } - .toggleStyle(.checkbox) - .disabled( - !self.overviewSelectedProviders.contains(provider) && - self.overviewSelectedProviders.count >= Self.maxOverviewProviders) - } - } - } - .frame(maxHeight: 220) - } - .padding(12) - .frame(width: 280) - } - - private var activeProvidersInOrder: [UsageProvider] { - self.store.enabledProviders() - } - - private var overviewSelectedProviders: [UsageProvider] { - self.settings.resolvedMergedOverviewProviders( - activeProviders: self.activeProvidersInOrder, - maxVisibleProviders: Self.maxOverviewProviders) - } - - private var showsOverviewConfigureButton: Bool { - self.settings.mergeIcons && !self.activeProvidersInOrder.isEmpty - } - - private var overviewProviderSelectionSummary: String { - let selectedNames = self.overviewSelectedProviders.map(self.providerDisplayName) - guard !selectedNames.isEmpty else { return L("overview_no_providers_selected") } - return selectedNames.joined(separator: ", ") - } - - private func providerDisplayName(_ provider: UsageProvider) -> String { - ProviderDescriptorRegistry.descriptor(for: provider).metadata.displayName - } - - private func setOverviewProviderSelection(provider: UsageProvider, isSelected: Bool) { - _ = self.settings.setMergedOverviewProviderSelection( - provider: provider, - isSelected: isSelected, - activeProviders: self.activeProvidersInOrder, - maxVisibleProviders: Self.maxOverviewProviders) - } - - private func reconcileOverviewSelection() { - _ = self.settings.reconcileMergedOverviewSelectedProviders( - activeProviders: self.activeProvidersInOrder, - maxVisibleProviders: Self.maxOverviewProviders) - } -} diff --git a/Sources/CodexBar/PreferencesGeneralPane.swift b/Sources/CodexBar/PreferencesGeneralPane.swift index 181b496335..4b2b0e750d 100644 --- a/Sources/CodexBar/PreferencesGeneralPane.swift +++ b/Sources/CodexBar/PreferencesGeneralPane.swift @@ -5,27 +5,108 @@ import SwiftUI enum AppLanguage: String, CaseIterable, Identifiable { case system = "" case english = "en" - case spanish = "es" - case catalan = "ca" case chineseSimplified = "zh-Hans" case chineseTraditional = "zh-Hant" + case japanese = "ja" + case spanish = "es" case portugueseBrazilian = "pt-BR" + case korean = "ko" + case german = "de" + case french = "fr" + case arabic = "ar" + case italian = "it" + case vietnamese = "vi" + case dutch = "nl" + case turkish = "tr" + case ukrainian = "uk" + case russian = "ru" + case indonesian = "id" + case polish = "pl" + case persian = "fa" + case thai = "th" + case galician = "gl" + case catalan = "ca" case swedish = "sv" var id: String { self.rawValue } + var label: String { + L(self.labelKey, language: self.labelLanguage) + } + + private var labelLanguage: String { + switch self { + case .system, .english: + "en" + default: + self.rawValue + } + } + + private var labelKey: String { + switch self { + case .system: "language_system" + case .english: "language_english" + case .chineseSimplified: "language_chinese_simplified" + case .chineseTraditional: "language_chinese_traditional" + case .japanese: "language_japanese" + case .spanish: "language_spanish" + case .portugueseBrazilian: "language_portuguese_brazilian" + case .korean: "language_korean" + case .german: "language_german" + case .french: "language_french" + case .arabic: "language_arabic" + case .italian: "language_italian" + case .vietnamese: "language_vietnamese" + case .dutch: "language_dutch" + case .turkish: "language_turkish" + case .ukrainian: "language_ukrainian" + case .russian: "language_russian" + case .indonesian: "language_indonesian" + case .polish: "language_polish" + case .persian: "language_persian" + case .thai: "language_thai" + case .galician: "language_galician" + case .catalan: "language_catalan" + case .swedish: "language_swedish" + } + } +} + +enum PreferredCurrencyOption: String, CaseIterable, Identifiable { + case auto + case usd = "USD" + case gbp = "GBP" + case eur = "EUR" + case cny = "CNY" + case jpy = "JPY" + case cad = "CAD" + case aud = "AUD" + case hkd = "HKD" + case twd = "TWD" + case sgd = "SGD" + case inr = "INR" + + var id: String { + self.rawValue + } + var label: String { switch self { - case .system: L("language_system") - case .english: L("language_english") - case .spanish: L("language_spanish") - case .catalan: L("language_catalan") - case .chineseSimplified: L("language_chinese_simplified") - case .chineseTraditional: L("language_chinese_traditional") - case .portugueseBrazilian: L("language_portuguese_brazilian") - case .swedish: L("language_swedish") + case .auto: L("currency_auto") + case .usd: "USD ($)" + case .gbp: "GBP (£)" + case .eur: "EUR (€)" + case .cny: "CNY (¥)" + case .jpy: "JPY (¥)" + case .cad: "CAD ($)" + case .aud: "AUD ($)" + case .hkd: "HKD ($)" + case .twd: "TWD (NT$)" + case .sgd: "SGD ($)" + case .inr: "INR (₹)" } } } @@ -33,203 +114,97 @@ enum AppLanguage: String, CaseIterable, Identifiable { @MainActor struct GeneralPane: View { @Bindable var settings: SettingsStore - @Bindable var store: UsageStore var body: some View { - ScrollView(.vertical, showsIndicators: true) { - VStack(alignment: .leading, spacing: 16) { - SettingsSection(contentSpacing: 12) { - Text(L("section_system")) - .font(.caption) - .foregroundStyle(.secondary) - .textCase(.uppercase) - - VStack(alignment: .leading, spacing: 6) { - HStack(alignment: .top, spacing: 12) { - VStack(alignment: .leading, spacing: 4) { - Text(L("language_title")) - .font(.body) - Text(L("language_subtitle")) - .font(.footnote) - .foregroundStyle(.tertiary) - .fixedSize(horizontal: false, vertical: true) - } - Spacer() - Picker(L("language_title"), selection: self.$settings.appLanguage) { - ForEach(AppLanguage.allCases) { option in - Text(option.label).tag(option.rawValue) - } - } - .labelsHidden() - .pickerStyle(.menu) - .frame(maxWidth: 200) + Form { + Section { + SettingsMenuPicker( + selection: self.$settings.appLanguage, + options: GeneralSettingsMenuOptions.languages, + label: { + SettingsRowLabel(L("language_title"), subtitle: L("language_subtitle")) + }, + optionLabel: { rawValue in + Text(verbatim: AppLanguage(rawValue: rawValue)?.label ?? rawValue) + }) + + SettingsMenuPicker( + selection: self.$settings.preferredCurrencyCode, + options: PreferredCurrencyOption.allCases.map(\.rawValue), + label: { + SettingsRowLabel(L("currency_title"), subtitle: L("currency_subtitle")) + }, + optionLabel: { rawValue in + Text(verbatim: PreferredCurrencyOption(rawValue: rawValue)?.label ?? rawValue) + }) + .onChange(of: self.settings.preferredCurrencyCode) { _, newValue in + guard CurrencyExchange.requiresLiveRates(preferredCurrencyCode: newValue) else { return } + Task { + await CurrencyExchange.shared.fetchLatestRatesIfNeeded( + preferredCurrencyCode: newValue) } } - PreferenceToggleRow( - title: L("start_at_login_title"), - subtitle: L("start_at_login_subtitle"), - binding: self.$settings.launchAtLogin) - } - - Divider() - - SettingsSection(contentSpacing: 12) { - Text(L("section_usage")) - .font(.caption) - .foregroundStyle(.secondary) - .textCase(.uppercase) - - VStack(alignment: .leading, spacing: 10) { - VStack(alignment: .leading, spacing: 4) { - Toggle(isOn: self.$settings.costUsageEnabled) { - Text(L("show_cost_summary")) - .font(.body) - } - .toggleStyle(.checkbox) - - Text(L("show_cost_summary_subtitle")) - .font(.footnote) - .foregroundStyle(.tertiary) - .fixedSize(horizontal: false, vertical: true) - - if self.settings.costUsageEnabled { - Stepper( - value: self.$settings.costUsageHistoryDays, - in: 1...365, - step: 1) - { - Text(String( - format: L("cost_history_days_title"), - self.settings.costUsageHistoryDays)) - .font(.footnote) - } - - Text(L("cost_auto_refresh_info")) - .font(.footnote) - .foregroundStyle(.tertiary) - - self.costStatusLine(provider: .claude) - self.costStatusLine(provider: .codex) + SettingsMenuPicker( + selection: self.$settings.terminalApp, + options: GeneralSettingsMenuOptions.terminalApps(selected: self.settings.terminalApp), + label: { + SettingsRowLabel(L("terminal_app_title"), subtitle: L("terminal_app_subtitle")) + }, + optionLabel: { option in + HStack(spacing: 6) { + if let icon = option.pickerIcon { + Image(nsImage: icon) } + Text(option.label) } - } - } + }) - Divider() - - SettingsSection(contentSpacing: 12) { - Text(L("section_automation")) - .font(.caption) - .foregroundStyle(.secondary) - .textCase(.uppercase) - VStack(alignment: .leading, spacing: 6) { - HStack(alignment: .top, spacing: 12) { - VStack(alignment: .leading, spacing: 4) { - Text(L("refresh_cadence_title")) - .font(.body) - Text(L("refresh_cadence_subtitle")) - .font(.footnote) - .foregroundStyle(.tertiary) - } - Spacer() - Picker(L("Refresh cadence"), selection: self.$settings.refreshFrequency) { - ForEach(RefreshFrequency.allCases) { option in - Text(option.label).tag(option) - } - } - .labelsHidden() - .pickerStyle(.menu) - .frame(maxWidth: 200) - } - if self.settings.refreshFrequency == .manual { - Text(L("manual_refresh_hint")) - .font(.footnote) - .foregroundStyle(.secondary) - } - } - PreferenceToggleRow( - title: L("check_provider_status_title"), - subtitle: L("check_provider_status_subtitle"), - binding: self.$settings.statusChecksEnabled) - PreferenceToggleRow( - title: L("session_quota_notifications_title"), - subtitle: L("session_quota_notifications_subtitle"), - binding: self.$settings.sessionQuotaNotificationsEnabled) - PreferenceToggleRow( - title: L("quota_warning_notifications_title"), - subtitle: L("quota_warning_notifications_subtitle"), - binding: self.$settings.quotaWarningNotificationsEnabled) - if self.settings.quotaWarningNotificationsEnabled { - GlobalQuotaWarningSettingsView(settings: self.settings) - } - } + Toggle(L("start_at_login_title"), isOn: self.$settings.launchAtLogin) + } header: { + Text(L("section_system")) + } - Divider() + Section { + SettingsMenuPicker( + selection: self.$settings.refreshFrequency, + options: GeneralSettingsMenuOptions.refreshFrequencies, + label: { Text(L("refresh_interval_title")) }, + optionLabel: { option in Text(option.label) }) - SettingsSection(contentSpacing: 12) { - HStack { - Spacer() - Button(L("quit_app")) { NSApp.terminate(nil) } - .buttonStyle(.borderedProminent) - .controlSize(.large) - } + Toggle(L("refresh_on_open_title"), isOn: self.$settings.refreshAllProvidersOnMenuOpen) + + Toggle(isOn: self.$settings.statusChecksEnabled) { + SettingsRowLabel( + L("check_provider_status_title"), + subtitle: L("check_provider_status_subtitle")) + } + } header: { + Text(L("section_refreshing")) + } footer: { + if self.settings.refreshFrequency == .manual { + SettingsSectionFooter(L("manual_refresh_hint")) } } - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.horizontal, 20) - .padding(.vertical, 12) - } - } - private func costStatusLine(provider: UsageProvider) -> some View { - let name = ProviderDescriptorRegistry.descriptor(for: provider).metadata.displayName - - guard provider == .claude || provider == .codex else { - return Text(String(format: L("cost_status_unsupported"), name)) - .font(.footnote) - .foregroundStyle(.tertiary) - } + Section { + LabeledContent(L("open_menu_shortcut_title")) { + OpenMenuShortcutRecorder() + } + } header: { + Text(L("section_keyboard_shortcut")) + } - if self.store.isTokenRefreshInFlight(for: provider) { - let elapsed: String = { - guard let startedAt = self.store.tokenLastAttemptAt(for: provider) else { return "" } - let seconds = max(0, Date().timeIntervalSince(startedAt)) - let formatter = DateComponentsFormatter() - formatter.allowedUnits = seconds < 60 ? [.second] : [.minute, .second] - formatter.unitsStyle = .abbreviated - return formatter.string(from: seconds).map { " (\($0))" } ?? "" - }() - return Text(String(format: L("cost_status_fetching"), name, elapsed)) - .font(.footnote) - .foregroundStyle(.tertiary) - } - if let snapshot = self.store.tokenSnapshot(for: provider) { - let updated = UsageFormatter.updatedString(from: snapshot.updatedAt) - let cost = snapshot.last30DaysCostUSD - .map { UsageFormatter.currencyString($0, currencyCode: snapshot.currencyCode) } ?? "—" - let window = snapshot.historyLabel ?? (snapshot.historyDays == 1 ? "today" : "\(snapshot.historyDays)d") - return Text(String(format: L("cost_status_snapshot"), name, updated, window, cost)) - .font(.footnote) - .foregroundStyle(.tertiary) - } - if let error = self.store.tokenError(for: provider), !error.isEmpty { - let truncated = UsageFormatter.truncatedSingleLine(error, max: 120) - return Text(String(format: L("cost_status_error"), name, truncated)) - .font(.footnote) - .foregroundStyle(.tertiary) - } - if let lastAttempt = self.store.tokenLastAttemptAt(for: provider) { - let rel = RelativeDateTimeFormatter() - rel.locale = Locale(identifier: "en_US") - rel.unitsStyle = .abbreviated - let when = rel.localizedString(for: lastAttempt, relativeTo: Date()) - return Text(String(format: L("cost_status_last_attempt"), name, when)) - .font(.footnote) - .foregroundStyle(.tertiary) + Section { + HStack { + Spacer() + Button(L("quit_app")) { NSApp.terminate(nil) } + } + } } - return Text(String(format: L("cost_status_no_data"), name)) - .font(.footnote) - .foregroundStyle(.tertiary) + .formStyle(.grouped) + .toggleStyle(.switch) + .scrollContentBackground(.hidden) + .background(FocusResigningBackground()) } } diff --git a/Sources/CodexBar/PreferencesHooksPane.swift b/Sources/CodexBar/PreferencesHooksPane.swift new file mode 100644 index 0000000000..25519240ee --- /dev/null +++ b/Sources/CodexBar/PreferencesHooksPane.swift @@ -0,0 +1,196 @@ +import CodexBarCore +import SwiftUI + +@MainActor +struct HooksPane: View { + @Bindable var settings: SettingsStore + + var body: some View { + Form { + Section { + Toggle(isOn: self.enabledBinding) { + SettingsRowLabel(L("hooks_enable_title"), subtitle: L("hooks_enable_subtitle")) + } + Label(L("hooks_trust_warning"), systemImage: "exclamationmark.triangle.fill") + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } header: { + Text(L("tab_hooks")) + } + + Section { + if self.settings.hookRules.isEmpty { + Text(L("hooks_empty")) + .font(.caption) + .foregroundStyle(.secondary) + } else { + ForEach(self.settings.hookRules) { rule in + HookRuleRow( + rule: self.binding(for: rule), + onDelete: { self.settings.removeHookRule(id: rule.id) }) + } + } + + Button { + self.settings.addHookRule(HookRule(event: .quotaReached, executable: "")) + } label: { + Label(L("hooks_add_rule"), systemImage: "plus") + } + .disabled(!HookEditorValidation.canAddRule(count: self.settings.hookRules.count)) + } header: { + Text(L("hooks_rules_header")) + } + } + .formStyle(.grouped) + } + + private var enabledBinding: Binding { + Binding( + get: { self.settings.hooksEnabled }, + set: { self.settings.setHooksEnabled($0) }) + } + + private func binding(for rule: HookRule) -> Binding { + Binding( + get: { self.settings.hookRules.first(where: { $0.id == rule.id }) ?? rule }, + set: { self.settings.updateHookRule($0) }) + } +} + +@MainActor +private struct HookRuleRow: View { + @Binding var rule: HookRule + let onDelete: () -> Void + @State private var argumentRows: [ArgumentRow] + + init(rule: Binding, onDelete: @escaping () -> Void) { + self._rule = rule + self.onDelete = onDelete + self._argumentRows = State(initialValue: rule.wrappedValue.arguments.map(ArgumentRow.init(value:))) + } + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + HStack { + Toggle(L("hooks_rule_enabled"), isOn: self.$rule.enabled) + .labelsHidden() + .toggleStyle(.switch) + .controlSize(.mini) + + Picker(L("hooks_event"), selection: self.$rule.event) { + ForEach(HookEventType.allCases, id: \.self) { event in + Text(event.rawValue).tag(event) + } + } + .labelsHidden() + + Picker(L("hooks_provider"), selection: self.providerBinding) { + Text(L("hooks_any_provider")).tag(String?.none) + ForEach(UsageProvider.allCases, id: \.self) { provider in + Text(ProviderDescriptorRegistry.descriptor(for: provider).metadata.displayName) + .tag(String?.some(provider.rawValue)) + } + } + .labelsHidden() + + Spacer() + + Button(role: .destructive, action: self.onDelete) { + Image(systemName: "trash") + } + .buttonStyle(.borderless) + .accessibilityLabel(L("hooks_delete_rule")) + } + + if self.rule.event == .quotaLow { + HStack { + Text(L("hooks_threshold")) + .foregroundStyle(.secondary) + TextField(L("hooks_threshold_placeholder"), value: self.thresholdPercentBinding, format: .number) + .frame(width: 60) + Text(verbatim: "%") + .foregroundStyle(.secondary) + } + .font(.caption) + } + + TextField(L("hooks_executable_placeholder"), text: self.$rule.executable) + .textFieldStyle(.roundedBorder) + .font(.system(.caption, design: .monospaced)) + + VStack(alignment: .leading, spacing: 4) { + HStack { + Text(L("hooks_arguments_placeholder")) + .font(.caption) + .foregroundStyle(.secondary) + Spacer() + Button { + self.argumentRows.append(ArgumentRow(value: "")) + } label: { + Label(L("hooks_add_argument"), systemImage: "plus") + } + .buttonStyle(.borderless) + .controlSize(.small) + .disabled(!HookEditorValidation.canAddArgument(count: self.argumentRows.count)) + } + + ForEach(self.$argumentRows) { $argument in + HStack { + TextField(L("hooks_argument_placeholder"), text: $argument.value) + .textFieldStyle(.roundedBorder) + .font(.system(.caption, design: .monospaced)) + Button { + self.argumentRows.removeAll(where: { $0.id == argument.id }) + } label: { + Image(systemName: "minus.circle") + } + .buttonStyle(.borderless) + .accessibilityLabel(L("hooks_delete_argument")) + } + } + } + } + .padding(.vertical, 4) + .onChange(of: self.argumentRows.map(\.value)) { _, arguments in + if self.rule.arguments != arguments { + self.rule.arguments = arguments + } + } + .onChange(of: self.rule.arguments) { _, arguments in + if self.argumentRows.map(\.value) != arguments { + self.argumentRows = arguments.map(ArgumentRow.init(value:)) + } + } + } + + private var providerBinding: Binding { + Binding(get: { self.rule.provider }, set: { self.rule.provider = $0 }) + } + + /// Threshold stored as a 0...1 fraction, edited as a 0...100 percentage. + private var thresholdPercentBinding: Binding { + Binding( + get: { self.rule.threshold.map { $0 * 100 } }, + set: { self.rule.threshold = HookEditorValidation.thresholdFraction(percent: $0) }) + } + + private struct ArgumentRow: Identifiable { + let id = UUID() + var value: String + } +} + +enum HookEditorValidation { + static func canAddRule(count: Int) -> Bool { + count < HooksConfig.maximumRuleCount + } + + static func canAddArgument(count: Int) -> Bool { + count < HookRule.maximumArgumentCount + } + + static func thresholdFraction(percent: Double?) -> Double? { + percent.map { min(max($0, 1), 100) / 100 } + } +} diff --git a/Sources/CodexBar/PreferencesMenuBarPane.swift b/Sources/CodexBar/PreferencesMenuBarPane.swift new file mode 100644 index 0000000000..cb90f958d1 --- /dev/null +++ b/Sources/CodexBar/PreferencesMenuBarPane.swift @@ -0,0 +1,215 @@ +import CodexBarCore +import SwiftUI + +@MainActor +struct MenuBarPane: View { + private static let maxOverviewProviders = SettingsStore.mergedOverviewProviderLimit + + @State private var isOverviewProviderPopoverPresented = false + @Bindable var settings: SettingsStore + @Bindable var store: UsageStore + + static func overviewProviderLimitText(limit: Int = Self.maxOverviewProviders) -> String { + L("overview_choose_providers", String(limit)) + } + + static func inactiveDisplayContrastAvailable(for style: MenuBarIconStyle) -> Bool { + style == .iconAndPercent + } + + var body: some View { + Form { + Section { + SettingsMenuPicker( + selection: self.$settings.menuBarIconStyle, + options: MenuBarSettingsMenuOptions.iconStyles, + label: { + SettingsRowLabel( + L("menu_bar_style_title"), + subtitle: L("menu_bar_style_subtitle")) + }, + optionLabel: { style in + Text(style.label) + }) + + Toggle(isOn: self.$settings.menuBarHighContrastOnInactiveDisplays) { + SettingsRowLabel( + L("menu_bar_inactive_display_contrast_title"), + subtitle: "\(MenuBarIconStyle.iconAndPercent.label): " + + L("menu_bar_inactive_display_contrast_subtitle")) + } + .disabled(!Self.inactiveDisplayContrastAvailable(for: self.settings.menuBarIconStyle)) + + Toggle(isOn: self.$settings.menuBarUsageColorsEnabled) { + SettingsRowLabel( + L("menu_bar_usage_colors_title"), + subtitle: L("menu_bar_usage_colors_subtitle")) + } + // The meter icon is what carries the tint; Icon + Percent renders the provider brand logo + // through MenuBarLayoutRenderer, which this setting does not touch. + .disabled(self.settings.menuBarIconStyle == .iconAndPercent) + } header: { + Text(L("section_icon")) + } + + Section { + MenuBarLayoutEditor(settings: self.settings, store: self.store) + .disabled(self.settings.menuBarIconStyle != .iconAndPercent) + } header: { + Text(L("menu_bar_layout_title")) + } footer: { + SettingsSectionFooter(L("menu_bar_layout_footer")) + } + + Section { + Toggle(isOn: self.$settings.mergeIcons) { + SettingsRowLabel(L("merge_icons_title"), subtitle: L("merge_icons_subtitle")) + } + + SettingsMenuPicker( + selection: self.$settings.switcherRowsOption, + options: MenuBarSettingsMenuOptions.switcherRows, + label: { Text(L("switcher_rows_title")) }, + optionLabel: { option in + Text(option.label) + }) + .disabled(!self.settings.mergeIcons) + + Toggle(isOn: self.$settings.menuBarShowsHighestUsage) { + SettingsRowLabel( + L("show_most_used_provider_title"), + subtitle: L("show_most_used_provider_subtitle")) + } + .disabled(!self.settings.mergeIcons) + + self.overviewProviderRow + .disabled(!self.settings.mergeIcons) + } header: { + Text(L("section_combined_icon")) + } + + Section { + Toggle(isOn: self.$settings.randomBlinkEnabled) { + SettingsRowLabel(L("surprise_me_title"), subtitle: L("surprise_me_subtitle")) + } + } header: { + Text(L("section_animation")) + } + } + .formStyle(.grouped) + .toggleStyle(.switch) + .scrollContentBackground(.hidden) + .onAppear { + self.reconcileOverviewSelection() + } + .onChange(of: self.settings.mergeIcons) { _, isEnabled in + guard isEnabled else { + self.isOverviewProviderPopoverPresented = false + return + } + self.reconcileOverviewSelection() + } + .onChange(of: self.activeProvidersInOrder) { _, _ in + if self.activeProvidersInOrder.isEmpty { + self.isOverviewProviderPopoverPresented = false + } + self.reconcileOverviewSelection() + } + } + + private var overviewProviderRow: some View { + LabeledContent { + if self.showsOverviewConfigureButton { + Button(L("configure")) { + self.isOverviewProviderPopoverPresented = true + } + .popover(isPresented: self.$isOverviewProviderPopoverPresented, arrowEdge: .bottom) { + self.overviewProviderPopover + } + } + } label: { + SettingsRowLabel(L("overview_tab_providers_title"), subtitle: self.overviewProviderSubtitle) + } + } + + private var overviewProviderSubtitle: String { + if !self.settings.mergeIcons { + L("overview_enable_merge_icons_hint") + } else if self.activeProvidersInOrder.isEmpty { + L("overview_no_providers_hint") + } else { + self.overviewProviderSelectionSummary + } + } + + private var overviewProviderPopover: some View { + VStack(alignment: .leading, spacing: 10) { + Text(Self.overviewProviderLimitText()) + .font(.headline) + Text(L("overview_rows_follow_order")) + .font(.footnote) + .foregroundStyle(.tertiary) + + ScrollView(.vertical, showsIndicators: true) { + VStack(alignment: .leading, spacing: 6) { + ForEach(self.activeProvidersInOrder, id: \.self) { provider in + Toggle( + isOn: Binding( + get: { self.overviewSelectedProviders.contains(provider) }, + set: { shouldSelect in + self.setOverviewProviderSelection(provider: provider, isSelected: shouldSelect) + })) { + Text(self.providerDisplayName(provider)) + .font(.body) + } + .toggleStyle(.checkbox) + .disabled( + !self.overviewSelectedProviders.contains(provider) && + self.overviewSelectedProviders.count >= Self.maxOverviewProviders) + } + } + } + .frame(maxHeight: 220) + } + .padding(12) + .frame(width: 280) + } + + private var activeProvidersInOrder: [UsageProvider] { + self.store.enabledProviders() + } + + private var overviewSelectedProviders: [UsageProvider] { + self.settings.resolvedMergedOverviewProviders( + activeProviders: self.activeProvidersInOrder, + maxVisibleProviders: Self.maxOverviewProviders) + } + + private var showsOverviewConfigureButton: Bool { + self.settings.mergeIcons && !self.activeProvidersInOrder.isEmpty + } + + private var overviewProviderSelectionSummary: String { + let selectedNames = self.overviewSelectedProviders.map(self.providerDisplayName) + guard !selectedNames.isEmpty else { return L("overview_no_providers_selected") } + return selectedNames.joined(separator: ", ") + } + + private func providerDisplayName(_ provider: UsageProvider) -> String { + ProviderDescriptorRegistry.descriptor(for: provider).metadata.displayName + } + + private func setOverviewProviderSelection(provider: UsageProvider, isSelected: Bool) { + _ = self.settings.setMergedOverviewProviderSelection( + provider: provider, + isSelected: isSelected, + activeProviders: self.activeProvidersInOrder, + maxVisibleProviders: Self.maxOverviewProviders) + } + + private func reconcileOverviewSelection() { + _ = self.settings.reconcileMergedOverviewSelectedProviders( + activeProviders: self.activeProvidersInOrder, + maxVisibleProviders: Self.maxOverviewProviders) + } +} diff --git a/Sources/CodexBar/PreferencesMenuPane.swift b/Sources/CodexBar/PreferencesMenuPane.swift new file mode 100644 index 0000000000..b0624477ad --- /dev/null +++ b/Sources/CodexBar/PreferencesMenuPane.swift @@ -0,0 +1,222 @@ +import CodexBarCore +import SwiftUI + +@MainActor +struct MenuPane: View { + @Bindable var settings: SettingsStore + @Bindable var store: UsageStore + + var body: some View { + Form { + Section { + SettingsMenuPicker( + selection: self.$settings.usageBarsFillOption, + options: MenuSettingsMenuOptions.usageBarsFill, + label: { Text(L("usage_bars_fill_title")) }, + optionLabel: { option in + Text(option.label) + }) + + Toggle(isOn: self.$settings.quotaWarningMarkersVisible) { + SettingsRowLabel( + L("show_quota_warning_markers_title"), + subtitle: L("show_quota_warning_markers_subtitle")) + } + + SettingsMenuPicker( + selection: self.$settings.weeklyProgressWorkDays, + options: MenuSettingsMenuOptions.weeklyProgressWorkDays, + label: { + Text(L("weekly_progress_work_days_title")) + }, + optionLabel: { workDays in + Text(MenuSettingsMenuOptions.weeklyProgressWorkDaysLabel(workDays)) + }) + + SettingsMenuPicker( + selection: self.$settings.resetTimesOption, + options: MenuSettingsMenuOptions.resetTimes, + label: { Text(L("reset_times_title")) }, + optionLabel: { option in + Text(option.label) + }) + } header: { + Text(L("section_usage")) + } + + Section { + Toggle(L("show_provider_changelog_links_title"), isOn: self.$settings.providerChangelogLinksEnabled) + + Toggle(isOn: self.$settings.showOptionalCreditsAndExtraUsage) { + SettingsRowLabel( + L("show_credits_extra_usage_title"), + subtitle: L("show_credits_extra_usage_subtitle")) + } + + SettingsMenuPicker( + selection: self.$settings.multiAccountMenuLayout, + options: MenuSettingsMenuOptions.multiAccountLayouts, + label: { + Text(L("multi_account_layout_title")) + }, + optionLabel: { layout in + Text(layout.label) + }) + } header: { + Text(L("section_content")) + } + + CostSummarySettingsSection(settings: self.settings, store: self.store) + + Section { + Toggle(isOn: self.$settings.agentSessionsEnabled) { + SettingsRowLabel( + L("agent_sessions_title"), + subtitle: L("agent_sessions_subtitle")) + } + + SettingsMenuPicker( + selection: self.$settings.agentSessionLabelStyle, + options: MenuSettingsMenuOptions.agentSessionLabelStyles, + label: { + SettingsRowLabel( + L("agent_session_labels_title"), + subtitle: L("agent_session_labels_subtitle")) + }, + optionLabel: { style in + Text(style.label) + }) + .disabled(!self.settings.agentSessionsEnabled) + + TextField(L("agent_sessions_hosts_title"), text: self.$settings.agentSessionsManualHosts) + .disabled(!self.settings.agentSessionsEnabled) + } header: { + Text(L("section_agent_sessions")) + } footer: { + SettingsSectionFooter(L("agent_sessions_footer")) + } + } + .formStyle(.grouped) + .toggleStyle(.switch) + .scrollContentBackground(.hidden) + .background(FocusResigningBackground()) + } +} + +/// Cost summary settings grouped-form section, including per-provider fetch status in the footer. +@MainActor +struct CostSummarySettingsSection: View { + @Bindable var settings: SettingsStore + @Bindable var store: UsageStore + + var body: some View { + Section { + SettingsMenuPicker( + selection: self.$settings.costSummaryOption, + options: MenuSettingsMenuOptions.costSummaries, + label: { + SettingsRowLabel(L("cost_summary_title"), subtitle: L("show_cost_summary_subtitle")) + }, + optionLabel: { option in + Text(option.label) + }) + + if self.settings.costUsageEnabled { + CostHistoryDaysEditor(settings: self.settings) + + Toggle(isOn: self.$settings.costComparisonPeriodsEnabled) { + SettingsRowLabel( + L("cost_comparison_periods_title"), + subtitle: L("cost_comparison_periods_subtitle")) + } + } + } header: { + Text(L("section_cost_summary")) + } footer: { + if self.settings.costUsageEnabled { + SettingsSectionFooter { + VStack(alignment: .leading, spacing: 3) { + Text(L("cost_auto_refresh_info")) + self.costStatusLine(provider: .claude) + self.costStatusLine(provider: .codex) + self.costStatusLine(provider: .cursor) + Text(Self.costDataExplanation()) + } + } + } + } + } + + static func costDataExplanation() -> String { + L("cost_data_explanation") + } + + private func costStatusLine(provider: UsageProvider) -> Text { + let name = ProviderDescriptorRegistry.descriptor(for: provider).metadata.displayName + + guard ProviderDescriptorRegistry.descriptor(for: provider).tokenCost.supportsTokenCost else { + return Text(String(format: L("cost_status_unsupported"), name)) + } + + if self.store.isTokenRefreshInFlight(for: provider) { + let elapsed: String = { + guard let startedAt = self.store.tokenLastAttemptAt(for: provider) else { return "" } + let seconds = max(0, Date().timeIntervalSince(startedAt)) + let formatter = DateComponentsFormatter() + formatter.allowedUnits = seconds < 60 ? [.second] : [.minute, .second] + formatter.unitsStyle = .abbreviated + return formatter.string(from: seconds).map { " (\($0))" } ?? "" + }() + return Text(String(format: L("cost_status_fetching"), name, elapsed)) + } + if let snapshot = self.store.tokenSnapshot(for: provider) { + let updated = UsageFormatter.updatedString(from: snapshot.updatedAt) + let cost = snapshot.last30DaysCostUSD + .map { UsageFormatter.currencyString($0, currencyCode: snapshot.currencyCode) } ?? "—" + let window = snapshot.historyLabel ?? (snapshot.historyDays == 1 ? "today" : "\(snapshot.historyDays)d") + return Text(String(format: L("cost_status_snapshot"), name, updated, window, cost)) + } + if let error = self.store.tokenError(for: provider), !error.isEmpty { + let truncated = UsageFormatter.truncatedSingleLine(error, max: 120) + return Text(String(format: L("cost_status_error"), name, truncated)) + } + if let lastAttempt = self.store.tokenLastAttemptAt(for: provider) { + let rel = RelativeDateTimeFormatter() + rel.locale = Locale(identifier: "en_US") + rel.unitsStyle = .abbreviated + let when = rel.localizedString(for: lastAttempt, relativeTo: Date()) + return Text(String(format: L("cost_status_last_attempt"), name, when)) + } + return Text(String(format: L("cost_status_no_data"), name)) + } +} + +@MainActor +struct CostHistoryDaysEditor: View { + @Bindable var settings: SettingsStore + + static func title(days: Int) -> String { + String(format: L("cost_history_days_title"), days) + } + + var body: some View { + LabeledContent(Self.title(days: self.settings.costUsageHistoryDays)) { + HStack(spacing: 8) { + TextField( + Self.title(days: self.settings.costUsageHistoryDays), + value: self.$settings.costUsageHistoryDays, + format: .number) + .labelsHidden() + .textFieldStyle(.roundedBorder) + .multilineTextAlignment(.trailing) + .monospacedDigit() + .frame(width: 64) + + Stepper(value: self.$settings.costUsageHistoryDays, in: 1...365, step: 1) { + EmptyView() + } + .labelsHidden() + } + } + } +} diff --git a/Sources/CodexBar/PreferencesMenuPicker.swift b/Sources/CodexBar/PreferencesMenuPicker.swift new file mode 100644 index 0000000000..e072bc0d2d --- /dev/null +++ b/Sources/CodexBar/PreferencesMenuPicker.swift @@ -0,0 +1,93 @@ +import SwiftUI + +/// Menu-backed settings selector that avoids disabled `Picker` items on macOS 27 when built with the macOS 26 SDK. +struct SettingsMenuPicker: View { + @Binding private var selection: Value + private let options: [Value] + private let label: () -> Label + private let optionLabel: (Value) -> OptionLabel + + init( + selection: Binding, + options: [Value], + @ViewBuilder label: @escaping () -> Label, + @ViewBuilder optionLabel: @escaping (Value) -> OptionLabel) + { + self._selection = selection + self.options = options + self.label = label + self.optionLabel = optionLabel + } + + var body: some View { + LabeledContent { + Menu { + ForEach(self.options, id: \.self) { option in + Button { + self.selection = option + } label: { + HStack { + if self.selection == option { + Image(systemName: "checkmark") + } + self.optionLabel(option) + } + } + } + } label: { + self.optionLabel(self.selection) + .foregroundStyle(.primary) + } + .menuStyle(.button) + .buttonStyle(.borderless) + .fixedSize() + } label: { + self.label() + } + } +} + +enum GeneralSettingsMenuOptions { + static let languages = AppLanguage.allCases.map(\.rawValue) + static let refreshFrequencies = RefreshFrequency.allCases + + static func terminalApps(selected: TerminalApp) -> [TerminalApp] { + TerminalApp.pickerOptions(selected: selected) + } + + static func terminalApps( + selected: TerminalApp, + applicationURL: (String) -> URL?) -> [TerminalApp] + { + TerminalApp.pickerOptions(selected: selected, applicationURL: applicationURL) + } +} + +enum MenuBarSettingsMenuOptions { + static let displayModes = MenuBarDisplayMode.allCases + static let iconStyles = MenuBarIconStyle.allCases + static let switcherRows = SwitcherRowsOption.allCases +} + +enum MenuSettingsMenuOptions { + static let weeklyProgressWorkDays: [Int?] = [nil, 4, 5, 7] + static let multiAccountLayouts = MultiAccountMenuLayout.allCases + static let usageBarsFill = UsageBarsFillOption.allCases + static let resetTimes = ResetTimesOption.allCases + static let costSummaries = CostSummaryOption.allCases + static let agentSessionLabelStyles = AgentSessionLabelStyle.allCases + + static func weeklyProgressWorkDaysLabel(_ workDays: Int?) -> String { + switch workDays { + case nil: L("Automatic") + case 4: L("4 days") + case 5: L("5 days") + case 7: L("7 days") + case let workDays?: L("%d days", workDays) + } + } +} + +enum NotificationsSettingsMenuOptions { + static let confettiCelebrations = ConfettiCelebrationOption.allCases +} diff --git a/Sources/CodexBar/PreferencesNotificationsPane.swift b/Sources/CodexBar/PreferencesNotificationsPane.swift new file mode 100644 index 0000000000..e806c723f9 --- /dev/null +++ b/Sources/CodexBar/PreferencesNotificationsPane.swift @@ -0,0 +1,61 @@ +import SwiftUI + +@MainActor +struct NotificationsPane: View { + @Bindable var settings: SettingsStore + + var body: some View { + Form { + Section { + Toggle(isOn: self.$settings.sessionQuotaNotificationsEnabled) { + SettingsRowLabel( + L("quota_depleted_title"), + subtitle: L("session_quota_notifications_subtitle")) + } + + Toggle(isOn: self.$settings.quotaWarningNotificationsEnabled) { + SettingsRowLabel( + L("threshold_warnings_title"), + subtitle: L("quota_warning_notifications_subtitle")) + } + + Toggle(isOn: self.$settings.predictivePaceWarningNotificationsEnabled) { + SettingsRowLabel( + L("predictive_pace_warnings_title"), + subtitle: L("predictive_pace_warnings_subtitle")) + } + + let warningSettingsVisibility = QuotaWarningSettingsVisibility( + thresholdWarningsEnabled: self.settings.quotaWarningNotificationsEnabled, + predictiveWarningsEnabled: self.settings.predictivePaceWarningNotificationsEnabled) + if warningSettingsVisibility.showsDeliveryControls { + GlobalQuotaWarningSettingsView( + settings: self.settings, + showsThresholdControls: warningSettingsVisibility.showsThresholdControls) + } + } header: { + Text(L("section_alerts")) + } + + Section { + SettingsMenuPicker( + selection: self.$settings.confettiCelebrationOption, + options: NotificationsSettingsMenuOptions.confettiCelebrations, + label: { + SettingsRowLabel( + L("confetti_on_reset_title"), + subtitle: L("confetti_on_reset_subtitle")) + }, + optionLabel: { option in + Text(option.label) + }) + } header: { + Text(L("section_celebrations")) + } + } + .formStyle(.grouped) + .toggleStyle(.switch) + .scrollContentBackground(.hidden) + .background(FocusResigningBackground()) + } +} diff --git a/Sources/CodexBar/PreferencesProviderDetailView.swift b/Sources/CodexBar/PreferencesProviderDetailView.swift index 36dd0ce6dc..492279dbf0 100644 --- a/Sources/CodexBar/PreferencesProviderDetailView.swift +++ b/Sources/CodexBar/PreferencesProviderDetailView.swift @@ -1,6 +1,11 @@ import CodexBarCore import SwiftUI +enum ProviderMetricInlinePresentation: Equatable { + case progress + case status(String) +} + @MainActor struct ProviderDetailView: View { let provider: UsageProvider @@ -8,6 +13,7 @@ struct ProviderDetailView: View { @Binding var isEnabled: Bool let subtitle: String let model: UsageMenuCardView.Model + let openAIWebDiagnostic: String? let settingsPickers: [ProviderSettingsPickerDescriptor] let settingsToggles: [ProviderSettingsToggleDescriptor] let settingsFields: [ProviderSettingsFieldDescriptor] @@ -27,6 +33,7 @@ struct ProviderDetailView: View { isEnabled: Binding, subtitle: String, model: UsageMenuCardView.Model, + openAIWebDiagnostic: String?, settingsPickers: [ProviderSettingsPickerDescriptor], settingsToggles: [ProviderSettingsToggleDescriptor], settingsFields: [ProviderSettingsFieldDescriptor], @@ -45,6 +52,7 @@ struct ProviderDetailView: View { self._isEnabled = isEnabled self.subtitle = subtitle self.model = model + self.openAIWebDiagnostic = openAIWebDiagnostic self.settingsPickers = settingsPickers self.settingsToggles = settingsToggles self.settingsFields = settingsFields @@ -63,13 +71,22 @@ struct ProviderDetailView: View { L(UsageMenuCardView.popupMetricTitle(provider: provider, metric: metric)) } + static func metricInlinePresentation( + _ metric: UsageMenuCardView.Model.Metric) -> ProviderMetricInlinePresentation + { + if let statusText = metric.statusText { + return .status(statusText) + } + return .progress + } + static func planRow(provider: UsageProvider, planText: String?) -> (label: String, value: String)? { guard let rawPlan = planText?.trimmingCharacters(in: .whitespacesAndNewlines), !rawPlan.isEmpty else { return nil } - guard provider == .openrouter || provider == .mimo || provider == .moonshot else { + guard provider == .openrouter || provider == .mimo || provider == .moonshot || provider == .poe else { return (label: L("Plan"), value: rawPlan) } @@ -81,29 +98,50 @@ struct ProviderDetailView: View { return (label: L("Balance"), value: trimmedValue) } } + if provider == .mimo { + return (label: L("Plan"), value: rawPlan) + } return (label: L("Balance"), value: rawPlan) } + private var menuBarSettingsPickers: [ProviderSettingsPickerDescriptor] { + self.settingsPickers.filter { $0.placement == .menuBar } + } + + private var connectionSettingsPickers: [ProviderSettingsPickerDescriptor] { + self.settingsPickers.filter { $0.placement == .connection } + } + var body: some View { - ScrollView { - VStack(alignment: .leading, spacing: 16) { - let labelWidth = self.detailLabelWidth - ProviderDetailHeaderView( + Form { + Section { + ProviderDetailHeaderRow( provider: self.provider, store: self.store, isEnabled: self.$isEnabled, subtitle: self.subtitle, - model: self.model, - labelWidth: labelWidth, onRefresh: self.onRefresh) + ProviderDetailInfoRows( + provider: self.provider, + store: self.store, + isEnabled: self.isEnabled, + model: self.model) + } + + Section { ProviderMetricsInlineView( provider: self.provider, model: self.model, + openAIWebDiagnostic: self.openAIWebDiagnostic, isEnabled: self.isEnabled, - labelWidth: labelWidth) + isRefreshing: self.store.refreshingProviders.contains(self.provider)) + } header: { + Text(L("Usage")) + } - if let errorDisplay { + if let errorDisplay { + Section { ProviderErrorView( title: String( format: L("last_fetch_failed_with_provider"), @@ -112,146 +150,103 @@ struct ProviderDetailView: View { isExpanded: self.$isErrorExpanded, onCopy: { self.onCopyError(errorDisplay.full) }) } + } - if self.hasSettings { - ProviderSettingsSection(title: L("Settings")) { - ForEach(self.settingsPickers) { picker in - ProviderSettingsPickerRowView(picker: picker) - } - if let tokenAccounts = self.settingsTokenAccounts, - tokenAccounts.isVisible?() ?? true - { - ProviderSettingsTokenAccountsRowView(descriptor: tokenAccounts) - } - ForEach(self.settingsFields) { field in - ProviderSettingsFieldRowView(field: field) - } - ForEach(self.settingsActions) { descriptor in - ProviderSettingsActionsRowView(descriptor: descriptor) - } - if let organizations = self.settingsOrganizations { - ProviderSettingsOrganizationsRowView(descriptor: organizations) - } + if !self.menuBarSettingsPickers.isEmpty { + Section { + ForEach(self.menuBarSettingsPickers) { picker in + ProviderSettingsPickerRowView(picker: picker) } + } header: { + Text(L("provider_section_menu_bar")) } + } - if self.showsSupplementarySettingsContent { - self.supplementarySettingsContent + if !self.connectionSettingsPickers.isEmpty || !self.settingsActions.isEmpty { + Section { + ForEach(self.connectionSettingsPickers) { picker in + ProviderSettingsPickerRowView(picker: picker) + } + ForEach(self.settingsActions) { descriptor in + ProviderSettingsActionsRowView(descriptor: descriptor) + } + } header: { + Text(L("provider_section_connection")) } + } - ProviderQuotaWarningSettingsView(provider: self.provider, settings: self.store.settings) + if let tokenAccounts = self.settingsTokenAccounts, + tokenAccounts.isVisible?() ?? true + { + ProviderSettingsTokenAccountsRowView(descriptor: tokenAccounts) + } - if !self.settingsToggles.isEmpty { - ProviderSettingsSection(title: L("Options")) { - ForEach(self.settingsToggles) { toggle in - ProviderSettingsToggleRowView(toggle: toggle) - } - } - } + ForEach(self.settingsFields) { field in + ProviderSettingsFieldRowView(field: field) } - .frame(maxWidth: ProviderSettingsMetrics.detailMaxWidth, alignment: .leading) - .padding(.vertical, 12) - .padding(.horizontal, 8) - } - .frame(maxWidth: .infinity, alignment: .leading) - } - private var hasSettings: Bool { - !self.settingsPickers.isEmpty || - !self.settingsFields.isEmpty || - !self.settingsActions.isEmpty || - self.settingsTokenAccounts != nil || - self.settingsOrganizations != nil - } + if let organizations = self.settingsOrganizations { + ProviderSettingsOrganizationsRowView(descriptor: organizations) + } - private var detailLabelWidth: CGFloat { - var infoLabels = [L("State"), L("Source"), L("Version"), L("Updated")] - if self.store.status(for: self.provider) != nil { - infoLabels.append(L("Status")) - } - if !self.model.email.isEmpty { - infoLabels.append(L("Account")) - } - if self.provider == .kiro, - self.model.metrics.isEmpty == false - { - infoLabels.append(L("Auth")) - } - if let planRow = Self.planRow(provider: self.provider, planText: self.model.planText) { - infoLabels.append(planRow.label) - } + if self.showsSupplementarySettingsContent { + self.supplementarySettingsContent + } - var metricLabels = self.model.metrics.map { metric in - Self.metricTitle(provider: self.provider, metric: metric) - } - if self.model.creditsText != nil { - metricLabels.append(L("Credits")) - } - if let providerCost = self.model.providerCost { - metricLabels.append(providerCost.title) - } - if self.model.tokenUsage != nil { - metricLabels.append(L("Cost")) - } + ProviderQuotaWarningSettingsView(provider: self.provider, settings: self.store.settings) - let infoWidth = ProviderSettingsMetrics.labelWidth( - for: infoLabels, - font: ProviderSettingsMetrics.infoLabelFont()) - let metricWidth = ProviderSettingsMetrics.labelWidth( - for: metricLabels, - font: ProviderSettingsMetrics.metricLabelFont()) - return max(infoWidth, metricWidth) + if !self.settingsToggles.isEmpty { + Section { + ForEach(self.settingsToggles) { toggle in + ProviderSettingsToggleRowView(toggle: toggle) + } + } header: { + Text(L("Options")) + } + } + } + .formStyle(.grouped) + .scrollContentBackground(.hidden) } } @MainActor -private struct ProviderDetailHeaderView: View { +private struct ProviderDetailHeaderRow: View { let provider: UsageProvider @Bindable var store: UsageStore @Binding var isEnabled: Bool let subtitle: String - let model: UsageMenuCardView.Model - let labelWidth: CGFloat let onRefresh: () -> Void var body: some View { - VStack(alignment: .leading, spacing: 12) { - HStack(alignment: .center, spacing: 12) { - ProviderDetailBrandIcon(provider: self.provider) - - VStack(alignment: .leading, spacing: 4) { - Text(self.store.metadata(for: self.provider).displayName) - .font(.title3.weight(.semibold)) + HStack(alignment: .center, spacing: 12) { + ProviderDetailBrandIcon(provider: self.provider) - Text(self.detailSubtitle) - .font(.footnote) - .foregroundStyle(.secondary) - } + VStack(alignment: .leading, spacing: 2) { + Text(self.store.metadata(for: self.provider).displayName) + .font(.title3.weight(.semibold)) - Spacer(minLength: 12) + Text(self.detailSubtitle) + .font(.footnote) + .foregroundStyle(.secondary) + } - Button { - self.onRefresh() - } label: { - Image(systemName: "arrow.clockwise") - } - .buttonStyle(.bordered) - .controlSize(.small) - .help(L("Refresh")) + Spacer(minLength: 12) - Toggle("", isOn: self.$isEnabled) - .labelsHidden() - .toggleStyle(.switch) - .controlSize(.small) + Button { + self.onRefresh() + } label: { + Image(systemName: "arrow.clockwise") } + .buttonStyle(.borderless) + .help(L("Refresh")) - ProviderDetailInfoGrid( - provider: self.provider, - store: self.store, - isEnabled: self.isEnabled, - model: self.model, - labelWidth: self.labelWidth) + Toggle(L("Enabled"), isOn: self.$isEnabled) + .labelsHidden() + .toggleStyle(.switch) + .controlSize(.small) } + .padding(.vertical, 2) } private var detailSubtitle: String { @@ -287,54 +282,38 @@ private struct ProviderDetailBrandIcon: View { } @MainActor -private struct ProviderDetailInfoGrid: View { +private struct ProviderDetailInfoRows: View { let provider: UsageProvider @Bindable var store: UsageStore let isEnabled: Bool let model: UsageMenuCardView.Model - let labelWidth: CGFloat var body: some View { - let status = self.store.status(for: self.provider) - let source = self.store.sourceLabel(for: self.provider) - let version = self.store.version(for: self.provider) ?? L("not detected") - let updated = self.updatedText - let email = self.model.email - let enabledText = self.isEnabled ? L("Enabled") : L("Disabled") - - Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 6) { - ProviderDetailInfoRow(label: L("State"), value: enabledText, labelWidth: self.labelWidth) - ProviderDetailInfoRow(label: L("Source"), value: source, labelWidth: self.labelWidth) - ProviderDetailInfoRow(label: L("Version"), value: version, labelWidth: self.labelWidth) - ProviderDetailInfoRow(label: L("Updated"), value: updated, labelWidth: self.labelWidth) - - if let status { - ProviderDetailInfoRow( - label: L("Status"), - value: status.description ?? status.indicator.label, - labelWidth: self.labelWidth) - } + ProviderDetailInfoRow(label: L("Source"), value: self.store.sourceLabel(for: self.provider)) + ProviderDetailInfoRow(label: L("Version"), value: self.store.version(for: self.provider) ?? L("not detected")) + ProviderDetailInfoRow(label: L("Updated"), value: self.updatedText) - if !email.isEmpty { - ProviderDetailInfoRow(label: L("Account"), value: email, labelWidth: self.labelWidth) - } + if let status = self.store.status(for: self.provider) { + ProviderDetailInfoRow(label: L("Status"), value: status.description ?? status.indicator.label) + } - if self.provider == .kiro, - let authMethod = self.store.snapshot(for: self.provider)?.loginMethod(for: .kiro), - !authMethod.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty - { - ProviderDetailInfoRow(label: L("Auth"), value: authMethod, labelWidth: self.labelWidth) - } + if !self.model.email.isEmpty { + ProviderDetailInfoRow(label: L("Account"), value: self.model.email) + } - if let planRow = ProviderDetailView.planRow( - provider: self.provider, - planText: self.model.planText) - { - ProviderDetailInfoRow(label: planRow.label, value: planRow.value, labelWidth: self.labelWidth) - } + if self.provider == .kiro, + let authMethod = self.store.snapshot(for: self.provider)?.loginMethod(for: .kiro), + !authMethod.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + { + ProviderDetailInfoRow(label: L("Auth"), value: authMethod) + } + + if let planRow = ProviderDetailView.planRow( + provider: self.provider, + planText: self.model.planText) + { + ProviderDetailInfoRow(label: planRow.label, value: planRow.value) } - .font(.footnote) - .foregroundStyle(.secondary) } private var updatedText: String { @@ -354,14 +333,14 @@ private struct ProviderDetailInfoGrid: View { private struct ProviderDetailInfoRow: View { let label: String let value: String - let labelWidth: CGFloat var body: some View { - GridRow { - Text(self.label) - .frame(width: self.labelWidth, alignment: .leading) + LabeledContent(self.label) { Text(self.value) + .foregroundStyle(.secondary) + .multilineTextAlignment(.trailing) .lineLimit(2) + .textSelection(.enabled) } } } @@ -370,74 +349,112 @@ private struct ProviderDetailInfoRow: View { struct ProviderMetricsInlineView: View { let provider: UsageProvider let model: UsageMenuCardView.Model + let openAIWebDiagnostic: String? let isEnabled: Bool - let labelWidth: CGFloat + let isRefreshing: Bool + + struct InfoRow: Identifiable, Equatable { + enum ID: Hashable { + case credits + case openAIWeb + } + + let id: ID + let label: String + let value: String + } + + static func infoRows( + for model: UsageMenuCardView.Model, + openAIWebDiagnostic: String?) -> [InfoRow] + { + var rows: [InfoRow] = [] + if let credits = model.creditsText { + rows.append(InfoRow(id: .credits, label: L("Credits"), value: credits)) + } + if let diagnostic = openAIWebDiagnostic { + rows.append(InfoRow(id: .openAIWeb, label: L("OpenAI web extras"), value: diagnostic)) + } + return rows + } var body: some View { let hasMetrics = !self.model.metrics.isEmpty let hasUsageNotes = !self.model.usageNotes.isEmpty - let hasCredits = self.model.creditsText != nil - let hasProviderCost = self.model.providerCost != nil + let infoRows = Self.infoRows(for: self.model, openAIWebDiagnostic: self.openAIWebDiagnostic) + let hasProviderCost = self.model.providerCost?.showsInProviderDetails == true let hasTokenUsage = self.model.tokenUsage != nil - ProviderSettingsSection( - title: L("Usage"), - spacing: 8, - verticalPadding: 6, - horizontalPadding: 0) - { - if !hasMetrics, !hasUsageNotes, !hasProviderCost, !hasCredits, !hasTokenUsage { - Text(self.placeholderText) - .font(.footnote) - .foregroundStyle(.secondary) - } else { - ForEach(self.model.metrics, id: \.id) { metric in - ProviderMetricInlineRow( - metric: metric, - title: ProviderDetailView.metricTitle(provider: self.provider, metric: metric), - progressColor: self.model.progressColor, - labelWidth: self.labelWidth) - } + let hasResetCredits = self.model.codexResetCredits != nil - if hasUsageNotes { - ProviderUsageNotesInlineView( - notes: self.model.usageNotes, - labelWidth: self.labelWidth, - alignsWithMetricContent: hasMetrics) - } + if !hasMetrics, !hasUsageNotes, !hasProviderCost, infoRows.isEmpty, !hasTokenUsage, !hasResetCredits { + Text(self.placeholderText) + .font(.footnote) + .foregroundStyle(.secondary) + } else { + ForEach(self.model.metrics, id: \.id) { metric in + ProviderMetricInlineRow( + metric: metric, + title: ProviderDetailView.metricTitle(provider: self.provider, metric: metric), + progressColor: self.model.progressColor) + } - if let credits = self.model.creditsText { - ProviderMetricInlineTextRow( - title: L("Credits"), - value: credits, - labelWidth: self.labelWidth) + if hasUsageNotes { + VStack(alignment: .leading, spacing: 4) { + ForEach(Array(self.model.usageNotes.enumerated()), id: \.offset) { _, note in + Text(note) + .font(.footnote) + .foregroundStyle(.secondary) + .lineLimit(2) + .fixedSize(horizontal: false, vertical: true) + } } + } - if let providerCost = self.model.providerCost { - ProviderMetricInlineCostRow( - section: providerCost, - progressColor: self.model.progressColor, - labelWidth: self.labelWidth) - } + ForEach(infoRows) { row in + ProviderDetailInfoRow(label: row.label, value: row.value) + } + + if let resetCredits = self.model.codexResetCredits { + ProviderCodexResetCreditsInlineRow(presentation: resetCredits) + } - if let tokenUsage = self.model.tokenUsage { - ProviderMetricInlineTextRow( - title: L("Cost"), - value: tokenUsage.sessionLine, - labelWidth: self.labelWidth) - ProviderMetricInlineTextRow( - title: "", - value: tokenUsage.monthLine, - labelWidth: self.labelWidth) + if let providerCost = self.model.providerCost, providerCost.showsInProviderDetails { + ProviderMetricInlineCostRow( + section: providerCost, + progressColor: self.model.progressColor) + } + + if let tokenUsage = self.model.tokenUsage { + ProviderMetricInlineTextRow( + title: L("Cost"), + value: tokenUsage.sessionLine) + ProviderMetricInlineTextRow(title: "", value: tokenUsage.monthLine) + if self.model.provider == .codex, let hint = tokenUsage.hintLine, !hint.isEmpty { + ProviderMetricInlineTextRow(title: "", value: hint) } } } } private var placeholderText: String { - if !self.isEnabled { + Self.placeholderText( + isEnabled: self.isEnabled, + isRefreshing: self.isRefreshing, + modelPlaceholder: self.model.placeholder) + } + + static func placeholderText( + isEnabled: Bool, + isRefreshing: Bool, + modelPlaceholder: String?) -> String + { + if !isEnabled { return L("Disabled — no recent data") } - return self.model.placeholder.map(L) ?? L("No usage yet") + if isRefreshing { + return L("Refreshing") + } + return modelPlaceholder.map(L) ?? L("No usage yet") } } @@ -445,41 +462,46 @@ private struct ProviderMetricInlineRow: View { let metric: UsageMenuCardView.Model.Metric let title: String let progressColor: Color - let labelWidth: CGFloat var body: some View { - HStack(alignment: .top, spacing: 10) { - Text(self.title) - .font(.subheadline.weight(.semibold)) - .lineLimit(1) - .frame(width: self.labelWidth, alignment: .leading) + VStack(alignment: .leading, spacing: 4) { + switch ProviderDetailView.metricInlinePresentation(self.metric) { + case let .status(statusText): + HStack(alignment: .firstTextBaseline, spacing: 8) { + Text(self.title) + .font(.subheadline.weight(.semibold)) + .lineLimit(1) + Spacer(minLength: 8) + Text(statusText) + .font(.footnote) + .foregroundStyle(.secondary) + } + case .progress: + HStack(alignment: .firstTextBaseline, spacing: 8) { + Text(self.title) + .font(.subheadline.weight(.semibold)) + .lineLimit(1) + Spacer(minLength: 8) + Text(self.metric.percentLabel) + .font(.footnote) + .foregroundStyle(.secondary) + .monospacedDigit() + } - VStack(alignment: .leading, spacing: 4) { UsageProgressBar( percent: self.metric.percent, tint: self.progressColor, accessibilityLabel: self.metric.percentStyle.accessibilityLabel, pacePercent: self.metric.pacePercent, paceOnTop: self.metric.paceOnTop, - warningMarkerPercents: self.metric.warningMarkerPercents) - .frame(minWidth: ProviderSettingsMetrics.metricBarWidth, maxWidth: .infinity) - - HStack(alignment: .firstTextBaseline, spacing: 8) { - Text(self.metric.percentLabel) - .font(.footnote) - .foregroundStyle(.secondary) - .monospacedDigit() - Spacer(minLength: 8) - if let resetText = self.metric.resetText, !resetText.isEmpty { - Text(resetText) - .font(.footnote) - .foregroundStyle(.secondary) - } - } + warningMarkerPercents: self.metric.warningMarkerPercents, + workdayMarkerPercents: self.metric.workdayMarkerPercents) + .frame(maxWidth: .infinity) let hasLeftDetail = self.metric.detailLeftText?.isEmpty == false let hasRightDetail = self.metric.detailRightText?.isEmpty == false - if hasLeftDetail || hasRightDetail { + let resetText = self.metric.resetText ?? "" + if hasLeftDetail || hasRightDetail || !resetText.isEmpty { HStack(alignment: .firstTextBaseline, spacing: 8) { if let leftDetail = self.metric.detailLeftText, !leftDetail.isEmpty { Text(leftDetail) @@ -491,109 +513,112 @@ private struct ProviderMetricInlineRow: View { Text(rightDetail) .font(.footnote) .foregroundStyle(.secondary) + } else if !resetText.isEmpty { + Text(resetText) + .font(.footnote) + .foregroundStyle(.secondary) } } } - if let detail = self.detailText, !detail.isEmpty { + if hasRightDetail, !resetText.isEmpty { + Text(resetText) + .font(.footnote) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .trailing) + } + + if let detail = self.metric.detailText, !detail.isEmpty { Text(detail) .font(.footnote) .foregroundStyle(.tertiary) } } - .frame(maxWidth: .infinity, alignment: .leading) } .padding(.vertical, 2) } - - private var detailText: String? { - guard let detailText = self.metric.detailText, !detailText.isEmpty else { return nil } - return detailText - } } -private struct ProviderUsageNotesInlineView: View { - let notes: [String] - let labelWidth: CGFloat - let alignsWithMetricContent: Bool +private struct ProviderCodexResetCreditsInlineRow: View { + let presentation: CodexResetCreditsPresentation var body: some View { - HStack(alignment: .top, spacing: 10) { - if self.alignsWithMetricContent { - Spacer() - .frame(width: self.labelWidth) + VStack(alignment: .leading, spacing: 4) { + HStack(alignment: .firstTextBaseline, spacing: 8) { + Text(L("Limit Reset Credits")) + .font(.subheadline.weight(.semibold)) + Spacer(minLength: 8) + Text(self.presentation.text) + .font(.footnote) + .foregroundStyle(.secondary) } - VStack(alignment: .leading, spacing: 4) { - ForEach(Array(self.notes.enumerated()), id: \.offset) { _, note in - Text(note) - .font(.footnote) - .foregroundStyle(.secondary) - .lineLimit(2) - .fixedSize(horizontal: false, vertical: true) - } + HStack(alignment: .firstTextBaseline, spacing: 4) { + Image(systemName: "clock") + .font(.caption2) + Text(self.presentation.expirySummaryText) + .font(.caption) + .foregroundStyle(.tertiary) + .lineLimit(1) + .minimumScaleFactor(0.8) } - .frame(maxWidth: .infinity, alignment: .leading) + .frame(maxWidth: .infinity, alignment: .trailing) + .accessibilityHidden(true) } .padding(.vertical, 2) + .accessibilityElement(children: .combine) + .accessibilityLabel(self.presentation.accessibilityLabel) } } private struct ProviderMetricInlineTextRow: View { let title: String let value: String - let labelWidth: CGFloat var body: some View { HStack(alignment: .firstTextBaseline, spacing: 12) { - Text(self.title) - .font(.subheadline.weight(.semibold)) - .frame(width: self.labelWidth, alignment: .leading) - + if !self.title.isEmpty { + Text(self.title) + .font(.subheadline.weight(.semibold)) + } + Spacer(minLength: 8) Text(self.value) .font(.footnote) .foregroundStyle(.secondary) - - Spacer(minLength: 0) + .multilineTextAlignment(.trailing) } - .padding(.vertical, 1) } } private struct ProviderMetricInlineCostRow: View { let section: UsageMenuCardView.Model.ProviderCostSection let progressColor: Color - let labelWidth: CGFloat var body: some View { - HStack(alignment: .top, spacing: 10) { - Text(self.section.title) - .font(.subheadline.weight(.semibold)) - .frame(width: self.labelWidth, alignment: .leading) - - VStack(alignment: .leading, spacing: 4) { - if let percentUsed = self.section.percentUsed { - UsageProgressBar( - percent: percentUsed, - tint: self.progressColor, - accessibilityLabel: L("Usage used")) - .frame(minWidth: ProviderSettingsMetrics.metricBarWidth, maxWidth: .infinity) - } - - HStack(alignment: .firstTextBaseline, spacing: 8) { - if let percentLine = self.section.percentLine { - Text(percentLine) - .font(.footnote) - .foregroundStyle(.secondary) - .monospacedDigit() - } - Spacer(minLength: 8) - Text(self.section.spendLine) + VStack(alignment: .leading, spacing: 4) { + HStack(alignment: .firstTextBaseline, spacing: 8) { + Text(self.section.title) + .font(.subheadline.weight(.semibold)) + Spacer(minLength: 8) + if let percentLine = self.section.percentLine { + Text(percentLine) .font(.footnote) .foregroundStyle(.secondary) + .monospacedDigit() } } - Spacer(minLength: 0) + if let percentUsed = self.section.percentUsed { + UsageProgressBar( + percent: percentUsed, + tint: self.progressColor, + accessibilityLabel: L("Usage used")) + .frame(maxWidth: .infinity) + } + + Text(self.section.spendLine) + .font(.footnote) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .trailing) } .padding(.vertical, 2) } diff --git a/Sources/CodexBar/PreferencesProviderSettingsMetrics.swift b/Sources/CodexBar/PreferencesProviderSettingsMetrics.swift index af25f4e4d4..4bc7304cd1 100644 --- a/Sources/CodexBar/PreferencesProviderSettingsMetrics.swift +++ b/Sources/CodexBar/PreferencesProviderSettingsMetrics.swift @@ -2,42 +2,7 @@ import AppKit import SwiftUI enum ProviderSettingsMetrics { - static let rowSpacing: CGFloat = 12 - static let rowInsets = EdgeInsets(top: 6, leading: 0, bottom: 6, trailing: 0) - static let dividerBottomInset: CGFloat = 8 - static let listTopPadding: CGFloat = 12 static let checkboxSize: CGFloat = 18 static let iconSize: CGFloat = 18 - static let reorderHandleSize: CGFloat = 12 - static let reorderDotSize: CGFloat = 2 - static let reorderDotSpacing: CGFloat = 3 static let pickerLabelWidth: CGFloat = 92 - static let sidebarWidth: CGFloat = 240 - static let sidebarCornerRadius: CGFloat = 12 - static let sidebarSubtitleHeight: CGFloat = { - let font = NSFont.systemFont(ofSize: NSFont.smallSystemFontSize) - let layout = NSLayoutManager() - return ceil(layout.defaultLineHeight(for: font) * 2) - }() - - static let detailMaxWidth: CGFloat = 640 - static let metricLabelWidth: CGFloat = 120 - static let metricBarWidth: CGFloat = 220 - - static func labelWidth(for labels: [String], font: NSFont, minimum: CGFloat = 0) -> CGFloat { - let maxWidth = labels - .filter { !$0.isEmpty } - .map { ($0 as NSString).size(withAttributes: [.font: font]).width } - .max() ?? 0 - return max(minimum, ceil(maxWidth)) - } - - static func metricLabelFont() -> NSFont { - let baseSize = NSFont.preferredFont(forTextStyle: .subheadline).pointSize - return NSFont.systemFont(ofSize: baseSize, weight: .semibold) - } - - static func infoLabelFont() -> NSFont { - NSFont.preferredFont(forTextStyle: .footnote) - } } diff --git a/Sources/CodexBar/PreferencesProviderSettingsRows.swift b/Sources/CodexBar/PreferencesProviderSettingsRows.swift index 5d19dc140f..8d5ff265ca 100644 --- a/Sources/CodexBar/PreferencesProviderSettingsRows.swift +++ b/Sources/CodexBar/PreferencesProviderSettingsRows.swift @@ -1,51 +1,21 @@ +import CodexBarCore import SwiftUI -struct ProviderSettingsSection: View { - let title: String - let spacing: CGFloat - let verticalPadding: CGFloat - let horizontalPadding: CGFloat - @ViewBuilder let content: () -> Content - - init( - title: String, - spacing: CGFloat = 12, - verticalPadding: CGFloat = 10, - horizontalPadding: CGFloat = 4, - @ViewBuilder content: @escaping () -> Content) - { - self.title = title - self.spacing = spacing - self.verticalPadding = verticalPadding - self.horizontalPadding = horizontalPadding - self.content = content - } - - var body: some View { - VStack(alignment: .leading, spacing: self.spacing) { - Text(L(self.title)) - .font(.headline) - self.content() - } - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.vertical, self.verticalPadding) - .padding(.horizontal, self.horizontalPadding) - } -} - @MainActor struct ProviderSettingsToggleRowView: View { let toggle: ProviderSettingsToggleDescriptor var body: some View { + let isEnabled = self.toggle.isEnabled?() ?? true VStack(alignment: .leading, spacing: 8) { HStack(alignment: .firstTextBaseline, spacing: 12) { VStack(alignment: .leading, spacing: 4) { Text(L(self.toggle.title)) .font(.subheadline.weight(.semibold)) + .foregroundStyle(isEnabled ? .primary : .tertiary) Text(L(self.toggle.subtitle)) .font(.footnote) - .foregroundStyle(.secondary) + .foregroundStyle(isEnabled ? .secondary : .tertiary) .fixedSize(horizontal: false, vertical: true) } Spacer(minLength: 8) @@ -79,6 +49,7 @@ struct ProviderSettingsToggleRowView: View { } } } + .disabled(!isEnabled) .onChange(of: self.toggle.binding.wrappedValue) { _, enabled in guard let onChange = self.toggle.onChange else { return } Task { @MainActor in @@ -99,40 +70,42 @@ struct ProviderSettingsPickerRowView: View { var body: some View { let isEnabled = self.picker.isEnabled?() ?? true - VStack(alignment: .leading, spacing: 6) { - HStack(alignment: .firstTextBaseline, spacing: 10) { - Text(L(self.picker.title)) - .font(.subheadline.weight(.semibold)) - .frame(width: ProviderSettingsMetrics.pickerLabelWidth, alignment: .leading) - - Picker("", selection: self.picker.binding) { - ForEach(self.picker.options) { option in - Text(L(option.title)).tag(option.id) - } - } - .labelsHidden() - .pickerStyle(.menu) - .controlSize(.small) - + let subtitle = self.picker.dynamicSubtitle?() ?? self.picker.subtitle + let trimmedSubtitle = subtitle.trimmingCharacters(in: .whitespacesAndNewlines) + LabeledContent { + HStack(spacing: 8) { if let trailingText = self.picker.trailingText?(), !trailingText.isEmpty { Text(trailingText) .font(.footnote) .foregroundStyle(.secondary) .lineLimit(1) .truncationMode(.tail) - .padding(.leading, 4) } - Spacer(minLength: 0) - } + let visibleActions = self.picker.trailingActions.filter { $0.isVisible?() ?? true } + ForEach(visibleActions) { action in + Button(L(action.title)) { + Task { @MainActor in + await action.perform() + } + } + .applyProviderSettingsButtonStyle(action.style) + .controlSize(.small) + } - let subtitle = self.picker.dynamicSubtitle?() ?? self.picker.subtitle - if !subtitle.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - Text(L(subtitle)) - .font(.footnote) - .foregroundStyle(.secondary) - .fixedSize(horizontal: false, vertical: true) + Picker("", selection: self.picker.binding) { + ForEach(self.picker.options) { option in + Text(L(option.title)).tag(option.id) + } + } + .labelsHidden() + .pickerStyle(.menu) + .fixedSize() } + } label: { + SettingsRowLabel( + L(self.picker.title), + subtitle: trimmedSubtitle.isEmpty ? nil : L(trimmedSubtitle)) } .disabled(!isEnabled) .onChange(of: self.picker.binding.wrappedValue) { _, selection in @@ -144,43 +117,17 @@ struct ProviderSettingsPickerRowView: View { } } +/// Renders a provider settings field descriptor as its own grouped-form section: +/// title becomes the header, subtitle/footer text become the footer, and the +/// placeholder stays inside the field. @MainActor struct ProviderSettingsFieldRowView: View { let field: ProviderSettingsFieldDescriptor var body: some View { - VStack(alignment: .leading, spacing: 8) { - let trimmedTitle = self.field.title.trimmingCharacters(in: .whitespacesAndNewlines) - let trimmedSubtitle = self.field.subtitle.trimmingCharacters(in: .whitespacesAndNewlines) - let hasHeader = !trimmedTitle.isEmpty || !trimmedSubtitle.isEmpty - - if hasHeader { - VStack(alignment: .leading, spacing: 4) { - if !trimmedTitle.isEmpty { - Text(L(trimmedTitle)) - .font(.subheadline.weight(.semibold)) - } - if !trimmedSubtitle.isEmpty { - Text(L(trimmedSubtitle)) - .font(.footnote) - .foregroundStyle(.secondary) - .fixedSize(horizontal: false, vertical: true) - } - } - } - - switch self.field.kind { - case .plain: - TextField(L(self.field.placeholder ?? ""), text: self.field.binding) - .textFieldStyle(.roundedBorder) - .font(.footnote) - .onTapGesture { self.field.onActivate?() } - case .secure: - SecureField(L(self.field.placeholder ?? ""), text: self.field.binding) - .textFieldStyle(.roundedBorder) - .font(.footnote) - .onTapGesture { self.field.onActivate?() } - } + let trimmedTitle = self.field.title.trimmingCharacters(in: .whitespacesAndNewlines) + Section { + self.fieldView let actions = self.field.actions.filter { $0.isVisible?() ?? true } if !actions.isEmpty { @@ -196,12 +143,48 @@ struct ProviderSettingsFieldRowView: View { } } } + } header: { + if !trimmedTitle.isEmpty { + Text(L(trimmedTitle)) + } + } footer: { + self.footerView + } + } - if let footer = self.field.footerText, !footer.isEmpty { - Text(L(footer)) - .font(.footnote) - .foregroundStyle(.secondary) - .fixedSize(horizontal: false, vertical: true) + private var fieldView: some View { + let prompt = (self.field.placeholder?.isEmpty == false) ? Text(L(self.field.placeholder ?? "")) : nil + return Group { + switch self.field.kind { + case .plain: + TextField(text: self.field.binding, prompt: prompt) { + EmptyView() + } + case .secure: + SecureField(text: self.field.binding, prompt: prompt) { + EmptyView() + } + } + } + .labelsHidden() + .textFieldStyle(.plain) + .onTapGesture { self.field.onActivate?() } + } + + @ViewBuilder + private var footerView: some View { + let trimmedSubtitle = self.field.subtitle.trimmingCharacters(in: .whitespacesAndNewlines) + let footer = (self.field.footerText?.isEmpty == false) ? self.field.footerText : nil + if !trimmedSubtitle.isEmpty || footer != nil { + SettingsSectionFooter { + VStack(alignment: .leading, spacing: 3) { + if !trimmedSubtitle.isEmpty { + Text(L(trimmedSubtitle)) + } + if let footer { + Text(L(footer)) + } + } } } } @@ -243,45 +226,40 @@ struct ProviderSettingsActionsRowView: View { @MainActor struct ProviderSettingsTokenAccountsRowView: View { + struct TeamAccountDraft: Equatable { + var teamMode: Bool + var organizationID: String + var projectID: String + + func normalizedForPersistence() -> Self { + guard self.teamMode else { + return Self(teamMode: false, organizationID: "", projectID: "") + } + return Self( + teamMode: true, + organizationID: self.organizationID.trimmingCharacters(in: .whitespacesAndNewlines), + projectID: self.projectID.trimmingCharacters(in: .whitespacesAndNewlines)) + } + } + let descriptor: ProviderSettingsTokenAccountsDescriptor @State private var newLabel: String = "" @State private var newToken: String = "" @State private var newOrgID: String = "" + @State private var newProjectID: String = "" + @State private var newTeamMode = false + @State private var teamDrafts: [UUID: TeamAccountDraft] = [:] var body: some View { - VStack(alignment: .leading, spacing: 10) { - HStack(alignment: .center, spacing: 12) { - Text(L(self.descriptor.title)) - .font(.subheadline.weight(.semibold)) - Spacer(minLength: 8) - if let title = self.descriptor.primaryAddActionTitle, - let action = self.descriptor.primaryAddAction - { - Button(L(title)) { - Task { @MainActor in - await action() - } - } - .buttonStyle(.bordered) - .controlSize(.small) - } - } - - if !self.descriptor.subtitle.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - Text(L(self.descriptor.subtitle)) - .font(.footnote) - .foregroundStyle(.secondary) - .fixedSize(horizontal: false, vertical: true) - } - + Section { let accounts = self.descriptor.accounts() if accounts.isEmpty { Text(L("No token accounts yet.")) .font(.footnote) .foregroundStyle(.secondary) } else { - VStack(alignment: .leading, spacing: 8) { - ForEach(Array(accounts.enumerated()), id: \.element.id) { index, account in + ForEach(Array(accounts.enumerated()), id: \.element.id) { index, account in + VStack(alignment: .leading, spacing: 8) { HStack(alignment: .center, spacing: 10) { Button { self.descriptor.setActiveIndex(index) @@ -310,22 +288,29 @@ struct ProviderSettingsTokenAccountsRowView: View { .buttonStyle(.bordered) .controlSize(.small) } - if index < accounts.count - 1 { - Divider() + if self.descriptor.showsTeamModeControls { + self.teamModeEditor(account: account) } } } } if self.descriptor.primaryAddAction == nil { - VStack(alignment: .leading, spacing: 6) { + VStack(alignment: .leading, spacing: 8) { HStack(spacing: 8) { - TextField(L("Label"), text: self.$newLabel) - .textFieldStyle(.roundedBorder) - .font(.footnote) - SecureField(L(self.descriptor.placeholder), text: self.$newToken) - .textFieldStyle(.roundedBorder) - .font(.footnote) + TextField(text: self.$newLabel, prompt: Text(L("Label"))) { + EmptyView() + } + .labelsHidden() + .textFieldStyle(.roundedBorder) + .font(.footnote) + .frame(maxWidth: 160) + SecureField(text: self.$newToken, prompt: Text(L(self.descriptor.placeholder))) { + EmptyView() + } + .labelsHidden() + .textFieldStyle(.roundedBorder) + .font(.footnote) Button(L("Add")) { let label = self.newLabel.trimmingCharacters(in: .whitespacesAndNewlines) let token = self.newToken.trimmingCharacters(in: .whitespacesAndNewlines) @@ -333,22 +318,68 @@ struct ProviderSettingsTokenAccountsRowView: View { let orgID = self.descriptor.showsOrganizationField ? self.newOrgID.trimmingCharacters(in: .whitespacesAndNewlines) : "" - self.descriptor.addAccount(label, token, orgID.isEmpty ? nil : orgID) + let teamOrgID = self.newOrgID.trimmingCharacters(in: .whitespacesAndNewlines) + let projectID = self.newProjectID.trimmingCharacters(in: .whitespacesAndNewlines) + let usageScope = self.descriptor.showsTeamModeControls + ? (self.newTeamMode ? "team" : "personal") + : nil + let accountOrganizationID = if self.newTeamMode { + teamOrgID.isEmpty ? nil : teamOrgID + } else { + orgID.isEmpty ? nil : orgID + } + let accountWorkspaceID = self.newTeamMode && !projectID.isEmpty ? projectID : nil + self.descriptor.addAccount( + label, + token, + usageScope, + accountOrganizationID, + accountWorkspaceID) self.newLabel = "" self.newToken = "" self.newOrgID = "" + self.newProjectID = "" + self.newTeamMode = false } .buttonStyle(.bordered) .controlSize(.small) - .disabled(self.newLabel.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || - self.newToken.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + .disabled(Self.isAddDisabled( + label: self.newLabel, + token: self.newToken, + showsTeamModeControls: self.descriptor.showsTeamModeControls, + teamMode: self.newTeamMode, + teamContext: (organizationID: self.newOrgID, projectID: self.newProjectID))) } if self.descriptor.showsOrganizationField { - TextField(L("Org ID (optional)"), text: self.$newOrgID) - .textFieldStyle(.roundedBorder) + TextField(text: self.$newOrgID, prompt: Text(L("Org ID (optional)"))) { + EmptyView() + } + .labelsHidden() + .textFieldStyle(.roundedBorder) + .font(.footnote) + .help( + L("Optional organization ID for accounts linked to multiple Anthropic organizations.")) + } + if self.descriptor.showsTeamModeControls { + Toggle(L("Team mode"), isOn: self.$newTeamMode) + .toggleStyle(.checkbox) .font(.footnote) - .help( - L("Optional organization ID for accounts linked to multiple Anthropic organizations.")) + if self.newTeamMode { + HStack(spacing: 8) { + TextField(text: self.$newOrgID, prompt: Text(L("Organization ID"))) { + EmptyView() + } + .labelsHidden() + .textFieldStyle(.roundedBorder) + .font(.footnote) + TextField(text: self.$newProjectID, prompt: Text(L("Project ID"))) { + EmptyView() + } + .labelsHidden() + .textFieldStyle(.roundedBorder) + .font(.footnote) + } + } } } } @@ -364,6 +395,26 @@ struct ProviderSettingsTokenAccountsRowView: View { } .buttonStyle(.link) .controlSize(.small) + + Spacer(minLength: 0) + + if let title = self.descriptor.primaryAddActionTitle, + let action = self.descriptor.primaryAddAction + { + Button(L(title)) { + Task { @MainActor in + await action() + } + } + .buttonStyle(.bordered) + .controlSize(.small) + } + } + } header: { + Text(L(self.descriptor.title)) + } footer: { + if !self.descriptor.subtitle.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + SettingsSectionFooter(L(self.descriptor.subtitle)) } } } @@ -373,6 +424,132 @@ struct ProviderSettingsTokenAccountsRowView: View { let selectedIndex = min(self.descriptor.activeIndex(), max(0, accountCount - 1)) return selectedIndex == index } + + private func teamModeEditor(account: ProviderTokenAccount) -> some View { + let draft = self.teamDraft(for: account) + let original = Self.teamAccountDraft(for: account) + return VStack(alignment: .leading, spacing: 6) { + Toggle(L("Team mode"), isOn: self.teamModeDraftBinding(account: account)) + .toggleStyle(.checkbox) + .font(.footnote) + if draft.teamMode { + HStack(spacing: 8) { + TextField( + text: self.organizationIDDraftBinding(account: account), + prompt: Text(L("Organization ID"))) + { + EmptyView() + } + .labelsHidden() + .textFieldStyle(.roundedBorder) + .font(.footnote) + TextField( + text: self.projectIDDraftBinding(account: account), + prompt: Text(L("Project ID"))) + { + EmptyView() + } + .labelsHidden() + .textFieldStyle(.roundedBorder) + .font(.footnote) + } + } + Button(L("apply")) { + self.applyTeamDraft(account: account) + } + .buttonStyle(.bordered) + .controlSize(.small) + .disabled(Self.isTeamDraftApplyDisabled(draft: draft, original: original)) + } + .padding(.leading, 24) + } + + private func teamModeDraftBinding(account: ProviderTokenAccount) -> Binding { + Binding( + get: { self.teamDraft(for: account).teamMode }, + set: { enabled in + self.updateTeamDraft(account: account) { draft in + draft.teamMode = enabled + } + }) + } + + private func organizationIDDraftBinding(account: ProviderTokenAccount) -> Binding { + Binding( + get: { self.teamDraft(for: account).organizationID }, + set: { value in + self.updateTeamDraft(account: account) { draft in + draft.organizationID = value + } + }) + } + + private func projectIDDraftBinding(account: ProviderTokenAccount) -> Binding { + Binding( + get: { self.teamDraft(for: account).projectID }, + set: { value in + self.updateTeamDraft(account: account) { draft in + draft.projectID = value + } + }) + } + + private func teamDraft(for account: ProviderTokenAccount) -> TeamAccountDraft { + self.teamDrafts[account.id] ?? Self.teamAccountDraft(for: account) + } + + private func updateTeamDraft( + account: ProviderTokenAccount, + mutate: (inout TeamAccountDraft) -> Void) + { + var draft = self.teamDraft(for: account) + mutate(&draft) + self.teamDrafts[account.id] = draft + } + + private func applyTeamDraft(account: ProviderTokenAccount) { + let draft = self.teamDraft(for: account) + let original = Self.teamAccountDraft(for: account) + guard !Self.isTeamDraftApplyDisabled(draft: draft, original: original) else { return } + let normalized = draft.normalizedForPersistence() + self.descriptor.updateAccount( + account.id, + normalized.teamMode ? "team" : "personal", + normalized.teamMode ? normalized.organizationID : nil, + normalized.teamMode ? normalized.projectID : nil) + self.teamDrafts[account.id] = nil + } + + static func teamAccountDraft(for account: ProviderTokenAccount) -> TeamAccountDraft { + let teamMode = account.sanitizedUsageScope?.lowercased() == "team" + return TeamAccountDraft( + teamMode: teamMode, + organizationID: teamMode ? (account.sanitizedOrganizationID ?? "") : "", + projectID: teamMode ? (account.sanitizedWorkspaceID ?? "") : "") + } + + static func isTeamDraftApplyDisabled(draft: TeamAccountDraft, original: TeamAccountDraft) -> Bool { + let draft = draft.normalizedForPersistence() + let original = original.normalizedForPersistence() + guard draft != original else { return true } + guard draft.teamMode else { return false } + return draft.organizationID.isEmpty || draft.projectID.isEmpty + } + + static func isAddDisabled( + label: String, + token: String, + showsTeamModeControls: Bool, + teamMode: Bool, + teamContext: (organizationID: String, projectID: String)) -> Bool + { + let label = label.trimmingCharacters(in: .whitespacesAndNewlines) + let token = token.trimmingCharacters(in: .whitespacesAndNewlines) + guard !label.isEmpty, !token.isEmpty else { return true } + guard showsTeamModeControls, teamMode else { return false } + return teamContext.organizationID.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || + teamContext.projectID.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } } extension View { @@ -394,50 +571,33 @@ struct ProviderSettingsOrganizationsRowView: View { @State private var isRefreshing = false var body: some View { - VStack(alignment: .leading, spacing: 10) { - HStack(alignment: .center, spacing: 12) { - Text(L(self.descriptor.title)) - .font(.subheadline.weight(.semibold)) - Spacer(minLength: 8) - } - - if let subtitle = self.descriptor.subtitle, - !subtitle.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty - { - Text(L(subtitle)) - .font(.footnote) - .foregroundStyle(.secondary) - .fixedSize(horizontal: false, vertical: true) - } - + Section { let entries = self.descriptor.entries() if entries.allSatisfy(\.isLocked) { Text(L("No organizations loaded. Click Refresh after setting your API key.")) .font(.footnote) .foregroundStyle(.secondary) } else { - VStack(alignment: .leading, spacing: 6) { - ForEach(entries) { entry in - Toggle(isOn: Binding( - get: { entry.isEnabled }, - set: { newValue in - self.descriptor.onToggle(entry.id, newValue) - })) { - VStack(alignment: .leading, spacing: 1) { - Text(entry.localizesTitle ? L(entry.title) : entry.title) - .font(.footnote) - if let subtitle = entry.subtitle, - !subtitle.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty - { - Text(entry.localizesSubtitle ? L(subtitle) : subtitle) - .font(.caption) - .foregroundStyle(.secondary) - } + ForEach(entries) { entry in + Toggle(isOn: Binding( + get: { entry.isEnabled }, + set: { newValue in + self.descriptor.onToggle(entry.id, newValue) + })) { + VStack(alignment: .leading, spacing: 1) { + Text(entry.localizesTitle ? L(entry.title) : entry.title) + if let subtitle = entry.subtitle, + !subtitle.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + { + Text(entry.localizesSubtitle ? L(subtitle) : subtitle) + .font(.caption) + .foregroundStyle(.secondary) } } - .toggleStyle(.checkbox) - .disabled(entry.isLocked) - } + } + .toggleStyle(.switch) + .controlSize(.small) + .disabled(entry.isLocked) } } @@ -459,6 +619,14 @@ struct ProviderSettingsOrganizationsRowView: View { .foregroundStyle(.red) } } + } header: { + Text(L(self.descriptor.title)) + } footer: { + if let subtitle = self.descriptor.subtitle, + !subtitle.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + { + SettingsSectionFooter(L(subtitle)) + } } } } diff --git a/Sources/CodexBar/PreferencesProviderSidebarView.swift b/Sources/CodexBar/PreferencesProviderSidebarView.swift deleted file mode 100644 index e3f21cf0f9..0000000000 --- a/Sources/CodexBar/PreferencesProviderSidebarView.swift +++ /dev/null @@ -1,221 +0,0 @@ -import CodexBarCore -import SwiftUI -import UniformTypeIdentifiers - -@MainActor -struct ProviderSidebarListView: View { - let providers: [UsageProvider] - @Bindable var store: UsageStore - let isEnabled: (UsageProvider) -> Binding - let subtitle: (UsageProvider) -> 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)) - } - } - .padding(.vertical, 4) - } - .background( - RoundedRectangle(cornerRadius: ProviderSettingsMetrics.sidebarCornerRadius, style: .continuous) - .fill(Color(nsColor: .controlBackgroundColor).opacity(0.8))) - .overlay( - RoundedRectangle(cornerRadius: ProviderSettingsMetrics.sidebarCornerRadius, style: .continuous) - .stroke(Color(nsColor: .separatorColor).opacity(0.7), lineWidth: 1)) - .clipShape(RoundedRectangle(cornerRadius: ProviderSettingsMetrics.sidebarCornerRadius, style: .continuous)) - .frame(minWidth: ProviderSettingsMetrics.sidebarWidth, maxWidth: ProviderSettingsMetrics.sidebarWidth) - } -} - -@MainActor -private struct ProviderSidebarRowView: View { - let provider: UsageProvider - @Bindable var store: UsageStore - @Binding var isEnabled: Bool - let subtitle: String - @Binding var draggingProvider: UsageProvider? - - var body: some View { - let isRefreshing = self.store.refreshingProviders.contains(self.provider) - let showStatus = self.store.statusChecksEnabled - let statusText = self.statusText - - HStack(alignment: .center, spacing: 10) { - ProviderSidebarReorderHandle() - .contentShape(Rectangle()) - .padding(.vertical, 4) - .padding(.horizontal, 2) - .help(L("Drag to reorder")) - .onDrag { - self.draggingProvider = self.provider - return NSItemProvider(object: self.provider.rawValue as NSString) - } - - ProviderSidebarBrandIcon(provider: self.provider) - - VStack(alignment: .leading, spacing: 2) { - HStack(spacing: 6) { - Text(self.store.metadata(for: self.provider).displayName) - .font(.subheadline.weight(.semibold)) - .foregroundStyle(.primary) - - if showStatus { - ProviderStatusDot(indicator: self.store.statusIndicator(for: self.provider)) - } - - if isRefreshing { - ProgressView() - .controlSize(.mini) - } - } - Text(statusText) - .font(.caption) - .foregroundStyle(.secondary) - .lineLimit(2) - .frame(height: ProviderSettingsMetrics.sidebarSubtitleHeight, alignment: .topLeading) - } - - Spacer(minLength: 8) - - Toggle("", isOn: self.$isEnabled) - .labelsHidden() - .toggleStyle(.checkbox) - .controlSize(.small) - } - .padding(.trailing, 6) - .contentShape(Rectangle()) - .padding(.vertical, 2) - } - - private var statusText: String { - guard !self.isEnabled else { return self.subtitle } - let lines = self.subtitle.split(separator: "\n", omittingEmptySubsequences: false) - if lines.count >= 2 { - let first = lines[0] - let rest = lines.dropFirst().joined(separator: "\n") - return "\(L("Disabled")) — \(first)\n\(rest)" - } - return "\(L("Disabled")) — \(self.subtitle)" - } -} - -private struct ProviderSidebarReorderHandle: View { - var body: some View { - VStack(spacing: ProviderSettingsMetrics.reorderDotSpacing) { - ForEach(0..<3, id: \.self) { _ in - HStack(spacing: ProviderSettingsMetrics.reorderDotSpacing) { - Circle() - .frame( - width: ProviderSettingsMetrics.reorderDotSize, - height: ProviderSettingsMetrics.reorderDotSize) - Circle() - .frame( - width: ProviderSettingsMetrics.reorderDotSize, - height: ProviderSettingsMetrics.reorderDotSize) - } - } - } - .frame( - width: ProviderSettingsMetrics.reorderHandleSize, - height: ProviderSettingsMetrics.reorderHandleSize) - .foregroundStyle(.tertiary) - .accessibilityLabel(L("Reorder")) - } -} - -@MainActor -private struct ProviderSidebarBrandIcon: View { - let provider: UsageProvider - - var body: some View { - if let brand = ProviderBrandIcon.image(for: self.provider) { - Image(nsImage: brand) - .resizable() - .scaledToFit() - .frame(width: ProviderSettingsMetrics.iconSize, height: ProviderSettingsMetrics.iconSize) - .foregroundStyle(.secondary) - .accessibilityHidden(true) - } else { - Image(systemName: "circle.dotted") - .font(.system(size: ProviderSettingsMetrics.iconSize, weight: .regular)) - .foregroundStyle(.secondary) - .accessibilityHidden(true) - } - } -} - -private struct ProviderSidebarDropDelegate: DropDelegate { - let item: UsageProvider - let providers: [UsageProvider] - @Binding var dragging: UsageProvider? - let moveProviders: (IndexSet, Int) -> Void - - func dropEntered(info _: DropInfo) { - guard let dragging, dragging != self.item else { return } - guard let fromIndex = self.providers.firstIndex(of: dragging), - let toIndex = self.providers.firstIndex(of: self.item) - else { return } - - if fromIndex == toIndex { return } - let adjustedIndex = toIndex > fromIndex ? toIndex + 1 : toIndex - self.moveProviders(IndexSet(integer: fromIndex), adjustedIndex) - } - - func dropUpdated(info _: DropInfo) -> DropProposal? { - DropProposal(operation: .move) - } - - func performDrop(info _: DropInfo) -> Bool { - self.dragging = nil - return true - } -} - -private struct ProviderStatusDot: View { - let indicator: ProviderStatusIndicator - - var body: some View { - Circle() - .fill(self.statusColor) - .frame(width: 6, height: 6) - .accessibilityHidden(true) - } - - private var statusColor: Color { - switch self.indicator { - case .none: .green - case .minor: .yellow - case .major: .orange - case .critical: .red - case .maintenance: .gray - case .unknown: .gray - } - } -} diff --git a/Sources/CodexBar/PreferencesProvidersPane+Testing.swift b/Sources/CodexBar/PreferencesProvidersPane+Testing.swift index 585690c089..8b3190365c 100644 --- a/Sources/CodexBar/PreferencesProvidersPane+Testing.swift +++ b/Sources/CodexBar/PreferencesProvidersPane+Testing.swift @@ -11,8 +11,12 @@ extension ProvidersPane { self.providerSubtitle(provider) } - func _test_menuBarMetricPicker(for provider: UsageProvider) -> ProviderSettingsPickerDescriptor? { - self.menuBarMetricPicker(for: provider) + func _test_providerSidebarSubtitle(_ provider: UsageProvider) -> String { + self.providerSidebarSubtitle(provider) + } + + func _test_moveProviders(fromOffsets: IndexSet, toOffset: Int) { + self.moveProviders(fromOffsets: fromOffsets, toOffset: toOffset) } func _test_settingsPickers(for provider: UsageProvider) -> [ProviderSettingsPickerDescriptor] { @@ -67,6 +71,10 @@ extension ProvidersPane { self.menuCardModel(for: provider) } + func _test_openAIWebDiagnostic(for provider: UsageProvider) -> String? { + self.openAIWebDiagnostic(for: provider) + } + func _test_providerErrorDisplay(for provider: UsageProvider) -> ProviderErrorDisplay? { self.providerErrorDisplay(provider) } @@ -139,10 +147,6 @@ enum ProvidersPaneTestHarness { _ = pane._test_providerSubtitle(.kimi) _ = pane._test_providerSubtitle(.gemini) - _ = pane._test_menuBarMetricPicker(for: .codex) - _ = pane._test_menuBarMetricPicker(for: .gemini) - _ = pane._test_menuBarMetricPicker(for: .zai) - if let descriptor = pane._test_tokenAccountDescriptor(for: .claude) { _ = descriptor.isVisible?() _ = descriptor.accounts() @@ -163,6 +167,7 @@ enum ProvidersPaneTestHarness { isEnabled: enabledBinding, subtitle: "Subtitle", model: model, + openAIWebDiagnostic: pane._test_openAIWebDiagnostic(for: .codex), settingsPickers: [descriptors.picker], settingsToggles: [descriptors.toggle], settingsFields: [descriptors.fieldPlain, descriptors.fieldSecure], @@ -173,7 +178,7 @@ enum ProvidersPaneTestHarness { onRefresh: {}, showsSupplementarySettingsContent: true, supplementarySettingsContent: { - ProviderSettingsSection(title: "Accounts") { + Section("Accounts") { Text("Supplementary") } }).body @@ -248,7 +253,9 @@ enum ProvidersPaneTestHarness { activeIndex: { 0 }, setActiveIndex: { _ in }, showsOrganizationField: false, - addAccount: { _, _, _ in }, + showsTeamModeControls: false, + addAccount: { _, _, _, _, _ in }, + updateAccount: { _, _, _, _ in }, removeAccount: { _ in }, primaryAddActionTitle: nil, primaryAddAction: nil, diff --git a/Sources/CodexBar/PreferencesProvidersPane.swift b/Sources/CodexBar/PreferencesProvidersPane.swift index 22423d90a9..dd677fbc0a 100644 --- a/Sources/CodexBar/PreferencesProvidersPane.swift +++ b/Sources/CodexBar/PreferencesProvidersPane.swift @@ -2,8 +2,20 @@ import AppKit import CodexBarCore import SwiftUI +@MainActor +enum ProviderSettingsRefreshInteraction { + static func perform(operation: () async -> Void) async { + await BrowserCookieAccessGate.withExplicitRetry { + await ProviderInteractionContext.$current.withValue(.userInitiated) { + await operation() + } + } + } +} + @MainActor struct ProvidersPane: View { + let provider: UsageProvider @Bindable var settings: SettingsStore @Bindable var store: UsageStore let managedCodexAccountCoordinator: ManagedCodexAccountCoordinator @@ -16,13 +28,9 @@ struct ProvidersPane: View { @State private var activeConfirmation: ProviderSettingsConfirmationState? @State private var codexAccountsNotice: CodexAccountsSectionNotice? @State private var isAuthenticatingLiveCodexAccount = false - @State private var selectedProvider: UsageProvider? - - private var providers: [UsageProvider] { - self.settings.orderedProviders() - } init( + provider: UsageProvider = .codex, settings: SettingsStore, store: UsageStore, managedCodexAccountCoordinator: ManagedCodexAccountCoordinator = ManagedCodexAccountCoordinator(), @@ -30,6 +38,7 @@ struct ProvidersPane: View { codexAmbientLoginRunner: any CodexAmbientLoginRunning = DefaultCodexAmbientLoginRunner(), runProviderLoginFlow: @escaping @MainActor (UsageProvider) async -> Void = { _ in }) { + self.provider = provider self.settings = settings self.store = store self.managedCodexAccountCoordinator = managedCodexAccountCoordinator @@ -43,119 +52,105 @@ struct ProvidersPane: View { } var body: some View { - HStack(alignment: .top, spacing: 16) { - ProviderSidebarListView( - providers: self.providers, - store: self.store, - isEnabled: { provider in self.binding(for: provider) }, - subtitle: { provider in self.providerSubtitle(provider) }, - selection: self.$selectedProvider, - moveProviders: { fromOffsets, toOffset in - self.settings.moveProvider(fromOffsets: fromOffsets, toOffset: toOffset) - }) - - if let provider = self.selectedProvider ?? self.providers.first { - ProviderDetailView( - provider: provider, - store: self.store, - isEnabled: self.binding(for: provider), - subtitle: self.providerSubtitle(provider), - model: self.menuCardModel(for: provider), - settingsPickers: self.extraSettingsPickers(for: provider), - settingsToggles: self.extraSettingsToggles(for: provider), - settingsFields: self.extraSettingsFields(for: provider), - settingsActions: self.extraSettingsActions(for: provider), - settingsTokenAccounts: self.tokenAccountDescriptor(for: provider), - settingsOrganizations: self.extraSettingsOrganizations(for: provider), - errorDisplay: self.providerErrorDisplay(provider), - isErrorExpanded: self.expandedBinding(for: provider), - onCopyError: { text in self.copyToPasteboard(text) }, - onRefresh: { - self.triggerRefresh(for: provider) - }, - showsSupplementarySettingsContent: self.codexAccountsSectionState(for: provider) != nil, - supplementarySettingsContent: { - if let state = self.codexAccountsSectionState(for: provider) { - CodexAccountsSectionView( - state: state, - setActiveVisibleAccount: { visibleAccountID in - Task { @MainActor in - await self.selectCodexVisibleAccount(id: visibleAccountID) - } - }, - reauthenticateAccount: { account in - Task { @MainActor in - await self.reauthenticateCodexAccount(account) - } - }, - removeAccount: { account in - self.requestManagedCodexAccountRemoval(account) - }, - requestSystemVisibleAccount: { visibleAccountID in - Task { @MainActor in - await self.requestCodexSystemVisibleAccount(id: visibleAccountID) - } - }, - addAccount: { - Task { @MainActor in - await self.addManagedCodexAccount() - } - }) - } - }) - } else { - Text(L("select_a_provider")) - .foregroundStyle(.secondary) - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) - } - } - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading) - .onAppear { - self.ensureSelection() - } - .onChange(of: self.providers) { _, _ in - self.ensureSelection() - } - .onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in - self.runSettingsDidBecomeActiveHooks() - } - .alert( - self.activeConfirmation?.title ?? "", - isPresented: Binding( - get: { self.activeConfirmation != nil }, - set: { isPresented in - if !isPresented { self.activeConfirmation = nil } - }), - actions: { - if let active = self.activeConfirmation { - Button(active.confirmTitle) { - active.onConfirm() - self.activeConfirmation = nil - } - Button(L("cancel"), role: .cancel) { self.activeConfirmation = nil } - } + ProviderDetailView( + provider: self.provider, + store: self.store, + isEnabled: self.binding(for: self.provider), + subtitle: self.providerSubtitle(self.provider), + model: self.menuCardModel(for: self.provider), + openAIWebDiagnostic: self.openAIWebDiagnostic(for: self.provider), + settingsPickers: self.extraSettingsPickers(for: self.provider), + settingsToggles: self.extraSettingsToggles(for: self.provider), + settingsFields: self.extraSettingsFields(for: self.provider), + settingsActions: self.extraSettingsActions(for: self.provider), + settingsTokenAccounts: self.tokenAccountDescriptor(for: self.provider), + settingsOrganizations: self.extraSettingsOrganizations(for: self.provider), + errorDisplay: self.providerErrorDisplay(self.provider), + isErrorExpanded: self.expandedBinding(for: self.provider), + onCopyError: { text in self.copyToPasteboard(text) }, + onRefresh: { + self.triggerRefresh(for: self.provider) }, - message: { - if let active = self.activeConfirmation { - Text(active.message) + showsSupplementarySettingsContent: self.codexAccountsSectionState(for: self.provider) != nil, + supplementarySettingsContent: { + if let state = self.codexAccountsSectionState(for: self.provider) { + CodexAccountsSectionView( + state: state, + setActiveVisibleAccount: { visibleAccountID in + Task { @MainActor in + await self.selectCodexVisibleAccount(id: visibleAccountID) + } + }, + reauthenticateAccount: { account in + Task { @MainActor in + await self.reauthenticateCodexAccount(account) + } + }, + removeAccount: { account in + self.requestManagedCodexAccountRemoval(account) + }, + requestSystemVisibleAccount: { visibleAccountID in + Task { @MainActor in + await self.requestCodexSystemVisibleAccount(id: visibleAccountID) + } + }, + addAccount: { + Task { @MainActor in + await self.addManagedCodexAccount() + } + }) } }) + .onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in + self.runSettingsDidBecomeActiveHooks() + } + .alert( + self.activeConfirmation?.title ?? "", + isPresented: Binding( + get: { self.activeConfirmation != nil }, + set: { isPresented in + if !isPresented { + self.activeConfirmation = nil + } + }), + actions: { + if let active = self.activeConfirmation { + Button(active.confirmTitle) { + active.onConfirm() + self.activeConfirmation = nil + } + Button(L("cancel"), role: .cancel) { self.activeConfirmation = nil } + } + }, + message: { + if let active = self.activeConfirmation { + Text(active.message) + } + }) } - private func ensureSelection() { - guard !self.providers.isEmpty else { - self.selectedProvider = nil - return - } - if let selected = self.selectedProvider, self.providers.contains(selected) { - return + 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) } - self.selectedProvider = self.providers.first + } + + func moveProviders(fromOffsets: IndexSet, toOffset: Int) { + guard !self.settings.providersSortedAlphabetically else { return } + self.settings.moveProvider(fromOffsets: fromOffsets, toOffset: toOffset) } private func triggerRefresh(for provider: UsageProvider) { Task { @MainActor in - await ProviderInteractionContext.$current.withValue(.userInitiated) { + await ProviderSettingsRefreshInteraction.perform { if provider == .codex { await self.store.refreshCodexAccountScopedState(allowDisabled: true) } else { @@ -177,11 +172,13 @@ struct ProvidersPane: View { func providerSubtitle(_ provider: UsageProvider) -> String { let meta = self.store.metadata(for: provider) let usageText: String - if let snapshot = self.store.snapshot(for: provider) { + if self.store.isStale(provider: provider) { + usageText = L("last_fetch_failed") + } else if self.store.knownLimitsAvailability(for: provider)?.isUnavailable == true { + usageText = L("Limits not available") + } else if let snapshot = self.store.presentationSnapshot(for: provider) { let relative = snapshot.updatedAt.relativeDescription() usageText = relative - } else if self.store.isStale(provider: provider) { - usageText = L("last_fetch_failed") } else { usageText = L("usage_not_fetched_yet") } @@ -199,6 +196,29 @@ struct ProvidersPane: View { return "\(detailLine)\n\(usageText)" } + func providerSidebarSubtitle(_ provider: UsageProvider) -> String { + let meta = self.store.metadata(for: provider) + let usageText: String = if self.store.isStale(provider: provider) { + L("last_fetch_failed") + } else if self.store.knownLimitsAvailability(for: provider)?.isUnavailable == true { + L("Limits not available") + } else if let snapshot = self.store.presentationSnapshot(for: provider) { + snapshot.updatedAt.relativeDescription() + } else { + L("usage_not_fetched_yet") + } + + let detailLine: String = if let sourceLabel = self.store.lastSourceLabels[provider], !sourceLabel.isEmpty { + sourceLabel + } else if let version = self.store.version(for: provider), !version.isEmpty { + "\(meta.cliName) \(version)" + } else { + meta.cliName + } + + return "\(detailLine)\n\(usageText)" + } + func codexAccountsSectionState(for provider: UsageProvider) -> CodexAccountsSectionState? { guard provider == .codex else { return nil } let projection = self.settings.codexVisibleAccountProjection @@ -318,7 +338,9 @@ struct ProvidersPane: View { } func providerErrorDisplay(_ provider: UsageProvider) -> ProviderErrorDisplay? { - guard let full = self.store.error(for: provider), !full.isEmpty else { return nil } + guard let full = self.store.error(for: provider) ?? self.store.diagnostic(for: provider), + !full.isEmpty + else { return nil } let preview = self.store.userFacingError(for: provider) ?? full return ProviderErrorDisplay( preview: self.truncated(preview, prefix: ""), @@ -335,12 +357,10 @@ struct ProvidersPane: View { private func extraSettingsPickers(for provider: UsageProvider) -> [ProviderSettingsPickerDescriptor] { guard let impl = ProviderCatalog.implementation(for: provider) else { return [] } let context = self.makeSettingsContext(provider: provider) - let providerPickers = impl.settingsPickers(context: context) + // The token layout editor is the only text-style menu bar UI. Legacy metric keys remain persisted solely for + // migration and downgrade safety, so provider settings no longer append their former menu bar metric picker. + return impl.settingsPickers(context: context) .filter { $0.isVisible?() ?? true } - if let menuBarPicker = self.menuBarMetricPicker(for: provider) { - return [menuBarPicker] + providerPickers - } - return providerPickers } private func extraSettingsFields(for provider: UsageProvider) -> [ProviderSettingsFieldDescriptor] { @@ -394,12 +414,28 @@ struct ProvidersPane: View { } }, showsOrganizationField: provider == .claude, - addAccount: { label, token, organizationID in + showsTeamModeControls: provider == .zai, + addAccount: { label, token, usageScope, organizationID, workspaceID in self.settings.addTokenAccount( provider: provider, label: label, token: token, - organizationID: organizationID) + usageScope: usageScope, + organizationID: organizationID, + workspaceID: workspaceID) + Task { @MainActor in + await ProviderInteractionContext.$current.withValue(.userInitiated) { + await self.store.refreshProvider(provider, allowDisabled: true) + } + } + }, + updateAccount: { accountID, usageScope, organizationID, workspaceID in + self.settings.updateTokenAccount( + provider: provider, + accountID: accountID, + usageScope: usageScope, + organizationID: organizationID, + workspaceID: workspaceID) Task { @MainActor in await ProviderInteractionContext.$current.withValue(.userInitiated) { await self.store.refreshProvider(provider, allowDisabled: true) @@ -477,97 +513,9 @@ struct ProvidersPane: View { }) } - func menuBarMetricPicker(for provider: UsageProvider) -> ProviderSettingsPickerDescriptor? { - let options: [ProviderSettingsPickerOption] - if provider == .openrouter { - options = [ - ProviderSettingsPickerOption(id: MenuBarMetricPreference.automatic.rawValue, title: L("automatic")), - ProviderSettingsPickerOption( - id: MenuBarMetricPreference.primary.rawValue, - title: L("primary_api_key_limit")), - ] - } else if SettingsStore.isBalanceOnlyProvider(provider) { - options = [ - ProviderSettingsPickerOption(id: MenuBarMetricPreference.automatic.rawValue, title: L("Automatic")), - ] - } else if provider == .abacus { - let metadata = self.store.metadata(for: provider) - options = [ - ProviderSettingsPickerOption(id: MenuBarMetricPreference.automatic.rawValue, title: L("automatic")), - ProviderSettingsPickerOption( - id: MenuBarMetricPreference.primary.rawValue, - title: String(format: L("metric_primary"), metadata.sessionLabel)), - ] - } else { - let metadata = self.store.metadata(for: provider) - let snapshot = self.store.snapshot(for: provider) - let supportsAverage = self.settings.menuBarMetricSupportsAverage(for: provider) - let supportsTertiary = self.settings.menuBarMetricSupportsTertiary(for: provider, snapshot: snapshot) - let supportsExtraUsage = self.settings.menuBarMetricSupportsExtraUsage(for: provider, snapshot: snapshot) - var metricOptions: [ProviderSettingsPickerOption] = [ - ProviderSettingsPickerOption(id: MenuBarMetricPreference.automatic.rawValue, title: L("automatic")), - ProviderSettingsPickerOption( - id: MenuBarMetricPreference.primary.rawValue, - title: String(format: L("metric_primary"), metadata.sessionLabel)), - ProviderSettingsPickerOption( - id: MenuBarMetricPreference.secondary.rawValue, - title: String(format: L("metric_secondary"), metadata.weeklyLabel)), - ] - if supportsTertiary { - let tertiaryTitle = metadata.opusLabel ?? MenuBarMetricPreference.tertiary.label - metricOptions.append(ProviderSettingsPickerOption( - id: MenuBarMetricPreference.tertiary.rawValue, - title: String(format: L("metric_tertiary"), tertiaryTitle))) - } - if supportsExtraUsage { - metricOptions.append(ProviderSettingsPickerOption( - id: MenuBarMetricPreference.extraUsage.rawValue, - title: MenuBarMetricPreference.extraUsage.label)) - } - if supportsAverage { - metricOptions.append(ProviderSettingsPickerOption( - id: MenuBarMetricPreference.average.rawValue, - title: String(format: L("metric_average"), metadata.sessionLabel, metadata.weeklyLabel))) - } - options = metricOptions - } - return ProviderSettingsPickerDescriptor( - id: "menuBarMetric", - title: L("menu_bar_metric_title"), - subtitle: Self.menuBarMetricPickerSubtitle(for: provider), - binding: Binding( - get: { - self.settings - .menuBarMetricPreference(for: provider, snapshot: self.store.snapshot(for: provider)) - .rawValue - }, - set: { rawValue in - guard let preference = MenuBarMetricPreference(rawValue: rawValue) else { return } - self.settings.setMenuBarMetricPreference(preference, for: provider) - }), - options: options, - isVisible: { true }, - onChange: nil) - } - - private static func menuBarMetricPickerSubtitle(for provider: UsageProvider) -> String { - switch provider { - case .deepseek: - L("menu_bar_metric_subtitle_deepseek") - case .moonshot: - L("menu_bar_metric_subtitle_moonshot") - case .mistral: - L("menu_bar_metric_subtitle_mistral") - case .kimik2: - L("menu_bar_metric_subtitle_kimik2") - default: - L("menu_bar_metric_subtitle") - } - } - func menuCardModel(for provider: UsageProvider) -> UsageMenuCardView.Model { let metadata = self.store.metadata(for: provider) - let snapshot = self.store.snapshot(for: provider) + let snapshot = self.store.presentationSnapshot(for: provider) let now = Date() let codexProjection = self.store.codexConsumerProjectionIfNeeded( for: provider, @@ -602,8 +550,8 @@ struct ProvidersPane: View { tokenError = nil } - // Abacus uses primary for monthly credits (no secondary window) - let paceWindow = provider == .abacus ? snapshot?.primary : snapshot?.secondary + // Abacus and Kimi carry their long-cadence window in primary rather than secondary. + let paceWindow = provider == .abacus || provider == .kimi ? snapshot?.primary : snapshot?.secondary let weeklyPace = if let codexProjection, let weekly = codexProjection.rateWindow(for: .weekly) { @@ -627,10 +575,19 @@ struct ProvidersPane: View { account: self.store.accountInfo(for: provider), isRefreshing: self.store.refreshingProviders.contains(provider), lastError: codexProjection?.userFacingErrors.usage ?? self.store.userFacingError(for: provider), + limitsAvailability: self.store.knownLimitsAvailability(for: provider), usageBarsShowUsed: self.settings.usageBarsShowUsed, resetTimeDisplayStyle: self.settings.resetTimeDisplayStyle, tokenCostUsageEnabled: self.settings.isCostUsageEffectivelyEnabled(for: provider), + codexLocalSessionCostLedgerEnabled: self.settings.codexLocalSessionCostLedgerEnabled, + tokenCostInlineDashboardEnabled: self.settings.costSummaryShowsInlineDashboard(for: provider), + // Display style only controls the main menu. Provider details always expose + // available cost data in their Usage section. + tokenCostMenuSectionEnabled: self.settings.isCostUsageEffectivelyEnabled(for: provider), showOptionalCreditsAndExtraUsage: self.settings.showOptionalCreditsAndExtraUsage, + claudeDailyRoutinesUsageVisible: self.settings.claudeDailyRoutinesUsageVisible, + codexSparkUsageVisible: self.settings.codexSparkUsageVisible, + copilotBudgetExtrasEnabled: self.settings.copilotBudgetExtrasEnabled, hidePersonalInfo: self.settings.hidePersonalInfo, weeklyPace: weeklyPace, quotaWarningThresholds: [ @@ -642,6 +599,14 @@ struct ProvidersPane: View { return UsageMenuCardView.Model.make(input) } + func openAIWebDiagnostic(for provider: UsageProvider) -> String? { + guard provider == .codex else { return nil } + let diagnostic = self.store.codexConsumerProjectionIfNeeded( + for: provider, + surface: .liveCard)?.userFacingErrors.dashboard + return PersonalInfoRedactor.redactEmails(in: diagnostic, isEnabled: self.settings.hidePersonalInfo) + } + private func quotaWarningMarkerThresholds(provider: UsageProvider, window: QuotaWarningWindow) -> [Int] { guard self.settings.quotaWarningMarkersVisible else { return [] } guard self.settings.quotaWarningEnabled(provider: provider, window: window) else { return [] } diff --git a/Sources/CodexBar/PreferencesSelection.swift b/Sources/CodexBar/PreferencesSelection.swift index a5db5855cb..371ca9eec1 100644 --- a/Sources/CodexBar/PreferencesSelection.swift +++ b/Sources/CodexBar/PreferencesSelection.swift @@ -1,8 +1,65 @@ +import CodexBarCore import Foundation import Observation +extension SettingsPane { + /// Stable token used to remember the selected pane across launches. + var persistenceToken: String { + switch self { + case .general: "general" + case .usageSpend: "usageSpend" + case .notifications: "notifications" + case .menuBar: "menuBar" + case .menu: "menu" + case .advanced: "advanced" + case .hooks: "hooks" + case .about: "about" + case .debug: "debug" + case let .provider(provider): "provider:\(provider.rawValue)" + } + } + + init?(persistenceToken: String) { + switch persistenceToken { + case "general": self = .general + case "usageSpend": self = .usageSpend + case "notifications": self = .notifications + case "menuBar": self = .menuBar + // Pre-0.41.1 releases persisted the retired Display pane; its contents moved to Menu Bar. + case "display": self = .menuBar + case "menu": self = .menu + case "advanced": self = .advanced + case "hooks": self = .hooks + case "about": self = .about + case "debug": self = .debug + default: + let providerPrefix = "provider:" + guard persistenceToken.hasPrefix(providerPrefix), + let provider = UsageProvider(rawValue: String(persistenceToken.dropFirst(providerPrefix.count))) + else { + return nil + } + self = .provider(provider) + } + } +} + @MainActor @Observable final class PreferencesSelection { - var tab: PreferencesTab = .general + static let paneDefaultsKey = "settingsSelectedPane" + + private let userDefaults: UserDefaults + + var pane: SettingsPane { + didSet { + self.userDefaults.set(self.pane.persistenceToken, forKey: Self.paneDefaultsKey) + } + } + + init(userDefaults: UserDefaults = .standard) { + self.userDefaults = userDefaults + let token = userDefaults.string(forKey: Self.paneDefaultsKey) ?? "" + self.pane = SettingsPane(persistenceToken: token) ?? .general + } } diff --git a/Sources/CodexBar/PreferencesSidebar.swift b/Sources/CodexBar/PreferencesSidebar.swift new file mode 100644 index 0000000000..d5d8d01a0f --- /dev/null +++ b/Sources/CodexBar/PreferencesSidebar.swift @@ -0,0 +1,307 @@ +import AppKit +import CodexBarCore +import SwiftUI + +/// System Settings-style sidebar: fixed app panes on top, one row per provider below. +@MainActor +struct SettingsSidebarView: View { + @Bindable var settings: SettingsStore + @Bindable var store: UsageStore + @Binding var selection: SettingsPane + @State private var searchText = "" + + var body: some View { + VStack(spacing: 0) { + HStack(spacing: 6) { + SettingsSidebarSearchField(searchText: self.$searchText) + SettingsSidebarSortToggle(isOn: self.sortAlphabeticallyBinding) + } + .padding(.horizontal, 8) + .padding(.top, 16) + .padding(.bottom, 8) + + List(selection: self.selectionBinding) { + self.appPanesSection + self.providersSection + } + .listStyle(.sidebar) + .scrollContentBackground(.hidden) + } + .padding(.horizontal, 8) + } + + private var appPanesSection: some View { + Section { + SettingsSidebarPaneRow(pane: .general, systemImage: "gearshape.fill", color: .gray) + SettingsSidebarPaneRow(pane: .usageSpend, systemImage: "chart.bar.fill", color: .green) + SettingsSidebarPaneRow(pane: .notifications, systemImage: "bell.badge.fill", color: .red) + SettingsSidebarPaneRow(pane: .menuBar, systemImage: "menubar.rectangle", color: .blue) + SettingsSidebarPaneRow(pane: .menu, systemImage: "filemenu.and.selection", color: .teal) + SettingsSidebarPaneRow(pane: .advanced, systemImage: "slider.horizontal.3", color: .purple) + SettingsSidebarPaneRow(pane: .hooks, systemImage: "bolt.horizontal.circle.fill", color: .orange) + SettingsSidebarAboutRow() + if self.settings.debugMenuEnabled { + SettingsSidebarPaneRow(pane: .debug, systemImage: "ladybug.fill", color: .red) + } + } + } + + private var providersSection: some View { + Section { + ForEach(self.filteredProviders, id: \.self) { provider in + SettingsSidebarProviderRow( + provider: provider, + store: self.store, + isEnabled: self.enabledBinding(for: provider)) + .tag(SettingsPane.provider(provider)) + .moveDisabled(!self.canReorderProviders) + } + .onMove { fromOffsets, toOffset in + guard self.canReorderProviders else { return } + self.settings.moveProvider(fromOffsets: fromOffsets, toOffset: toOffset) + } + + if self.filteredProviders.isEmpty { + Text(L("No matching providers")) + .font(.caption) + .foregroundStyle(.secondary) + } + } header: { + HStack(spacing: 4) { + Text(L("tab_providers")) + Spacer() + Text(String(format: L("providers_on_count"), self.enabledProviderCount)) + .foregroundStyle(.tertiary) + .monospacedDigit() + .padding(.trailing, 10) + } + } + } + + private var selectionBinding: Binding { + Binding( + get: { self.selection }, + set: { newValue in + if let newValue { + self.selection = newValue + } + }) + } + + private var sortAlphabeticallyBinding: Binding { + Binding( + get: { self.settings.providersSortedAlphabetically }, + set: { self.settings.providersSortedAlphabetically = $0 }) + } + + private var orderedProviders: [UsageProvider] { + guard self.settings.providersSortedAlphabetically else { + return self.settings.orderedProviders() + } + return CodexBarConfig.alphabeticalProviderOrder(enablement: { provider in + self.settings.isProviderEnabled(provider: provider, metadata: self.store.metadata(for: provider)) + }) + } + + private var filteredProviders: [UsageProvider] { + ProvidersPane.filteredProviders( + self.orderedProviders, + query: self.searchText, + displayName: { provider in self.store.metadata(for: provider).displayName }) + } + + private var enabledProviderCount: Int { + self.orderedProviders.count(where: { provider in + self.settings.isProviderEnabled(provider: provider, metadata: self.store.metadata(for: provider)) + }) + } + + private var canReorderProviders: Bool { + !self.settings.providersSortedAlphabetically + && self.searchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + + private func enabledBinding(for provider: UsageProvider) -> Binding { + let meta = self.store.metadata(for: provider) + return Binding( + get: { self.settings.isProviderEnabled(provider: provider, metadata: meta) }, + set: { newValue in + self.settings.setProviderEnabled(provider: provider, metadata: meta, enabled: newValue) + }) + } +} + +@MainActor +private struct SettingsSidebarPaneRow: View { + let pane: SettingsPane + let systemImage: String + let color: Color + + var body: some View { + HStack(spacing: 8) { + SettingsIconChip(systemImage: self.systemImage, color: self.color) + Text(self.pane.title) + } + .tag(self.pane) + } +} + +@MainActor +private struct SettingsSidebarAboutRow: View { + var body: some View { + HStack(spacing: 8) { + if let icon = NSApplication.shared.applicationIconImage { + Image(nsImage: icon) + .resizable() + .scaledToFit() + .frame(width: SettingsIconChip.side, height: SettingsIconChip.side) + .accessibilityHidden(true) + } else { + SettingsIconChip(systemImage: "info.circle.fill", color: .green) + } + Text(SettingsPane.about.title) + } + .tag(SettingsPane.about) + } +} + +@MainActor +private struct SettingsSidebarProviderRow: View { + let provider: UsageProvider + @Bindable var store: UsageStore + @Binding var isEnabled: Bool + + var body: some View { + HStack(spacing: 8) { + SettingsSidebarBrandIcon(provider: self.provider, isEnabled: self.isEnabled) + + Text(self.store.metadata(for: self.provider).displayName) + .foregroundStyle(self.isEnabled ? .primary : .secondary) + + Spacer(minLength: 4) + + if self.store.refreshingProviders.contains(self.provider) { + ProgressView() + .controlSize(.mini) + } + + if self.isEnabled, self.store.statusChecksEnabled { + SettingsSidebarStatusDot(indicator: self.store.statusIndicator(for: self.provider)) + } + } + .opacity(self.isEnabled ? 1 : 0.62) + .contextMenu { + Button(self.isEnabled ? L("Disable") : L("Enable")) { + self.isEnabled.toggle() + } + } + .accessibilityLabel(self.accessibilityLabel) + } + + private var accessibilityLabel: String { + let name = self.store.metadata(for: self.provider).displayName + return self.isEnabled ? name : "\(name) — \(L("Disabled"))" + } +} + +@MainActor +private struct SettingsSidebarBrandIcon: View { + let provider: UsageProvider + let isEnabled: Bool + + var body: some View { + Group { + if let brand = ProviderBrandIcon.image(for: self.provider) { + Image(nsImage: brand) + .resizable() + .scaledToFit() + } else { + Image(systemName: "circle.dotted") + .resizable() + .scaledToFit() + } + } + .frame(width: 16, height: 16) + .foregroundStyle(self.isEnabled ? .primary : .secondary) + .accessibilityHidden(true) + } +} + +private struct SettingsSidebarStatusDot: View { + let indicator: ProviderStatusIndicator + + var body: some View { + Circle() + .fill(self.statusColor) + .frame(width: 6, height: 6) + .accessibilityHidden(true) + } + + private var statusColor: Color { + switch self.indicator { + case .none: .green + case .minor: .yellow + case .major: .orange + case .critical: .red + case .maintenance: .gray + case .unknown: .gray + } + } +} + +private struct SettingsSidebarSearchField: View { + @Binding var searchText: String + + var body: some View { + HStack(spacing: 5) { + 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, 7) + .padding(.vertical, 4) + .background( + RoundedRectangle(cornerRadius: 6, style: .continuous) + .fill(Color(nsColor: .textBackgroundColor).opacity(0.6))) + .overlay( + RoundedRectangle(cornerRadius: 6, style: .continuous) + .stroke(Color(nsColor: .separatorColor).opacity(0.6), lineWidth: 1)) + } +} + +private struct SettingsSidebarSortToggle: View { + @Binding var isOn: Bool + + var body: some View { + Button { + self.isOn.toggle() + } label: { + Image(systemName: "arrow.up.arrow.down") + .font(.callout) + .foregroundStyle(self.isOn ? Color.accentColor : Color.secondary) + .frame(width: 24, height: 24) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .help(self.isOn + ? L("Sorted alphabetically (enabled first) — click to use your custom order") + : L("Sort providers alphabetically (enabled first)")) + .accessibilityLabel(L("Sort providers alphabetically")) + .accessibilityAddTraits(self.isOn ? .isSelected : []) + } +} diff --git a/Sources/CodexBar/PreferencesSpendDashboardPane.swift b/Sources/CodexBar/PreferencesSpendDashboardPane.swift new file mode 100644 index 0000000000..83e7f31038 --- /dev/null +++ b/Sources/CodexBar/PreferencesSpendDashboardPane.swift @@ -0,0 +1,507 @@ +import AppKit +import Charts +import CodexBarCore +import SwiftUI + +func spendDashboardDayRangeText(_ days: Int) -> String { + let template: String + switch days { + case 7: template = L("7d") + case 30: template = L("30d") + default: return codexBarLocalizedInteger(days) + } + return template.replacingOccurrences( + of: String(days), + with: codexBarLocalizedInteger(days)) +} + +func spendDashboardRankText(_ rank: Int) -> String { + "#\(codexBarLocalizedInteger(rank))" +} + +func spendDashboardRefreshFailureText(_ count: Int) -> String { + "\(L("Refresh failures")): \(codexBarLocalizedInteger(count))" +} + +func spendDashboardCoverageText(covered: Int, requested: Int) -> String { + "\(L("Coverage")): \(codexBarLocalizedInteger(covered)) / \(codexBarLocalizedInteger(requested))" +} + +enum SpendDashboardModelHistoryPresentation: Equatable { + case unavailable + case empty + case partial + case complete +} + +func spendDashboardModelHistoryPresentation( + _ group: SpendDashboardModel.CurrencyGroup) -> SpendDashboardModelHistoryPresentation +{ + if group.models.isEmpty { + return group.modelHistoryCompleteness == .incomplete ? .unavailable : .empty + } + return group.modelHistoryCompleteness == .incomplete ? .partial : .complete +} + +@MainActor +struct SpendDashboardPane: View { + @Bindable var settings: SettingsStore + @Bindable var store: UsageStore + @State private var controller: SpendDashboardController + + init(settings: SettingsStore, store: UsageStore) { + self.settings = settings + self.store = store + self._controller = State(initialValue: SpendDashboardController(requestBuilder: { mode in + await SpendDashboardSource.makeRequest(settings: settings, store: store, mode: mode) + })) + } + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 18) { + self.header + self.content + self.provenance + self.shareAction + } + .padding(24) + } + .background(FocusResigningBackground()) + .onAppear { + self.controller.refreshDateWindow() + self.controller.update(configuration: self.configuration) + } + .onChange(of: self.configuration) { _, configuration in + self.controller.update(configuration: configuration) + } + .onDisappear { + self.controller.stop() + } + .onReceive(NotificationCenter.default.publisher(for: .NSCalendarDayChanged)) { _ in + self.controller.refreshDateWindow() + } + .onReceive(NotificationCenter.default.publisher(for: .NSSystemTimeZoneDidChange)) { _ in + self.controller.refreshDateWindow() + } + .onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in + self.controller.refreshDateWindow() + } + } + + private var configuration: SpendDashboardConfiguration { + SpendDashboardSource.configuration(settings: self.settings, store: self.store) + } + + private var header: some View { + HStack(alignment: .top, spacing: 16) { + VStack(alignment: .leading, spacing: 4) { + Text(L("Usage & Spend")) + .font(.title2.weight(.semibold)) + Text(L("Local estimated cost history across supported providers.")) + .font(.subheadline) + .foregroundStyle(.secondary) + } + Spacer() + Picker(L("Time range"), selection: self.daysBinding) { + Text(spendDashboardDayRangeText(7)).tag(7) + Text(spendDashboardDayRangeText(30)).tag(30) + } + .labelsHidden() + .pickerStyle(.segmented) + .frame(width: 116) + + Button { + self.controller.refresh() + } label: { + if self.controller.isRefreshing { + ProgressView().controlSize(.small) + } else { + Label(L("Refresh"), systemImage: "arrow.clockwise") + } + } + .disabled(self.controller.isRefreshing || !self.settings.costUsageEnabled) + } + } + + @ViewBuilder + private var content: some View { + if !self.settings.costUsageEnabled { + SpendDashboardPanel { + ContentUnavailableView { + Label(L("Cost tracking is off"), systemImage: "chart.bar.xaxis") + } description: { + Text(L("Turn on Track costs to build local estimates.")) + } + .frame(maxWidth: .infinity, minHeight: 220) + } + } else if self.controller.model.groups.isEmpty { + SpendDashboardPanel { + ContentUnavailableView { + Label(L("No local cost history yet"), systemImage: "chart.bar.xaxis") + } description: { + Text(L("Turn on cost tracking or refresh after using a supported provider.")) + } + .frame(maxWidth: .infinity, minHeight: 220) + } + } else { + ForEach(self.controller.model.groups) { group in + SpendCurrencySection(group: group, requestedDays: self.controller.model.requestedDays) + } + } + + if self.controller.failedSourceCount > 0 { + Label( + spendDashboardRefreshFailureText(self.controller.failedSourceCount), + systemImage: "exclamationmark.triangle.fill") + .font(.caption) + .foregroundStyle(.secondary) + } + } + + private var provenance: some View { + HStack(alignment: .top, spacing: 10) { + Image(systemName: "lock.shield.fill") + .foregroundStyle(.secondary) + Text(L("Native currencies stay separate; Codex account rows exclude Pi session history.")) + .font(.caption) + .foregroundStyle(.secondary) + Spacer() + Toggle(L("Track costs"), isOn: self.$settings.costUsageEnabled) + .toggleStyle(.switch) + .controlSize(.small) + } + } + + private var shareAction: some View { + HStack { + Spacer() + Button { + guard let payload = self.sharePayload else { return } + ShareStatsPresenter.shared.present(payload: payload) + } label: { + Label(L("Share Stats…"), systemImage: "square.and.arrow.up") + } + .disabled(self.sharePayload == nil) + } + } + + private var sharePayload: ShareStatsPayload? { + ShareStatsBuilder.make( + model: self.controller.model, + subscriptionNames: self.subscriptionNames) + } + + private var subscriptionNames: [String: ShareStatsSubscriptionName] { + var names: [String: ShareStatsSubscriptionName] = [:] + let codexRowCount = self.controller.model.groups + .flatMap(\.providers) + .count { $0.provider == .codex } + for group in self.controller.model.groups { + for row in group.providers { + let snapshots: [UsageSnapshot?] = if row.provider == .codex, + row.id.hasPrefix("codex:") + { + [ + self.store.codexAccountSnapshots.first { + row.id == "codex:\($0.id)" + }?.snapshot, + codexRowCount == 1 ? self.store.snapshot(for: .codex) : nil, + ] + } else { + [self.store.snapshot(for: row.provider)] + } + if let name = ShareStatsSubscriptionName.first(from: snapshots, provider: row.provider) { + names[row.id] = name + } + } + } + return names + } + + private var daysBinding: Binding { + Binding( + get: { self.controller.selectedDays }, + set: { self.controller.selectDays($0) }) + } +} + +private struct SpendCurrencySection: View { + let group: SpendDashboardModel.CurrencyGroup + let requestedDays: Int + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + HStack(alignment: .firstTextBaseline) { + Text(self.group.currencyCode) + .font(.headline) + Spacer() + Text(self.group.totalCost.map { + UsageFormatter.currencyString($0, currencyCode: self.group.currencyCode) + } ?? L("Spend unavailable")) + .font(.title3.weight(.semibold)) + .monospacedDigit() + } + + Text( + "\(L("Local estimated history")) · " + + spendDashboardCoverageText( + covered: self.group.coveredDayCount, + requested: self.requestedDays)) + .font(.caption) + .foregroundStyle(.secondary) + + SpendDashboardPanel { + HStack(spacing: 24) { + SpendSummaryValue( + title: L("Estimated spend"), + value: self.group.totalCost.map { + UsageFormatter.currencyString($0, currencyCode: self.group.currencyCode) + } ?? "—") + SpendSummaryValue( + title: L("Tracked tokens"), + value: self.group.totalTokens.map(UsageFormatter.tokenCountString) ?? "—") + SpendSummaryValue( + title: L("Subscriptions"), + value: codexBarLocalizedInteger(self.group.providers.count)) + Spacer() + } + } + + SpendProviderPanel(group: self.group) + SpendModelPanel(group: self.group) + SpendDailyChart(group: self.group) + } + } +} + +private struct SpendSummaryValue: View { + let title: String + let value: String + + var body: some View { + VStack(alignment: .leading, spacing: 5) { + Text(self.title) + .font(.caption) + .foregroundStyle(.secondary) + Text(self.value) + .font(.system(.title2, design: .rounded, weight: .semibold)) + .monospacedDigit() + } + } +} + +private struct SpendProviderPanel: View { + let group: SpendDashboardModel.CurrencyGroup + + var body: some View { + SpendDashboardPanel { + VStack(alignment: .leading, spacing: 0) { + Text(L("By subscription")).font(.headline).padding(.bottom, 8) + ForEach(self.group.providers) { row in + if row.rank > 1 { + Divider() + } + HStack(spacing: 10) { + Text(spendDashboardRankText(row.rank)) + .font(.caption.monospacedDigit()) + .foregroundStyle(.tertiary) + .frame(width: 26, alignment: .leading) + SpendProviderIcon(provider: row.provider) + Text(row.displayName).lineLimit(1) + Spacer() + Text(row.totalCost.map { + UsageFormatter.currencyString($0, currencyCode: self.group.currencyCode) + } ?? L("Spend unavailable")) + .foregroundStyle(row.totalCost == nil ? .secondary : .primary) + .monospacedDigit() + } + .padding(.vertical, 9) + } + } + } + } +} + +private struct SpendModelPanel: View { + let group: SpendDashboardModel.CurrencyGroup + + var body: some View { + SpendDashboardPanel { + VStack(alignment: .leading, spacing: 0) { + Text(L("Models")).font(.headline).padding(.bottom, 8) + let presentation = spendDashboardModelHistoryPresentation(self.group) + switch presentation { + case .unavailable: + Text(L("Model breakdown unavailable")) + .foregroundStyle(.secondary) + .padding(.vertical, 10) + case .empty: + Text(L("No model-level history")) + .foregroundStyle(.secondary) + .padding(.vertical, 10) + case .partial, .complete: + if presentation == .partial { + Label(L("Model breakdown unavailable"), systemImage: "exclamationmark.triangle") + .font(.caption) + .foregroundStyle(.secondary) + .padding(.bottom, 6) + } + ForEach(self.group.models.prefix(8)) { row in + if row.rank > 1 { + Divider() + } + HStack(spacing: 10) { + if presentation == .complete { + Text(spendDashboardRankText(row.rank)) + .font(.caption.monospacedDigit()) + .foregroundStyle(.tertiary) + .frame(width: 26, alignment: .leading) + } else { + Image(systemName: "circle.dashed") + .font(.caption) + .foregroundStyle(.tertiary) + .frame(width: 26, alignment: .leading) + } + SpendProviderIcon(provider: row.provider) + VStack(alignment: .leading, spacing: 2) { + Text(row.modelName).lineLimit(1) + Text(row.providerName).font(.caption).foregroundStyle(.secondary) + } + Spacer() + Text(row.totalCost.map { + UsageFormatter.currencyString($0, currencyCode: self.group.currencyCode) + } ?? "—") + .monospacedDigit() + } + .padding(.vertical, 9) + } + } + } + } + } +} + +struct SpendDailyChartPresentation: Equatable { + enum Content: Equatable { + case chart + case unavailable + } + + struct Series: Equatable { + let name: String + let provider: UsageProvider + } + + let content: Content + let series: [Series] + let dayCount: Int + + init(dailyPoints: [SpendDashboardModel.DailyPoint], aggregateTotal: Double?) { + self.content = dailyPoints.isEmpty && aggregateTotal == nil ? .unavailable : .chart + self.dayCount = Set(dailyPoints.map(\.day)).count + + var seenNames: Set = [] + self.series = dailyPoints.compactMap { point in + guard seenNames.insert(point.providerName).inserted else { return nil } + return Series(name: point.providerName, provider: point.provider) + } + } + + var accessibilityValue: String { + L("%d days of usage data across %d services", self.dayCount, self.series.count) + } +} + +private struct SpendDailyChart: View { + let group: SpendDashboardModel.CurrencyGroup + + var body: some View { + let presentation = SpendDailyChartPresentation( + dailyPoints: self.group.dailyPoints, + aggregateTotal: self.group.totalCost) + SpendDashboardPanel { + VStack(alignment: .leading, spacing: 12) { + Text(L("Daily estimated spend")).font(.headline) + if presentation.content == .unavailable { + ContentUnavailableView(L("Spend unavailable"), systemImage: "chart.bar.xaxis") + .frame(maxWidth: .infinity, minHeight: 170) + } else { + Chart(self.group.dailyPoints) { point in + BarMark( + x: .value(L("Day"), point.day, unit: .day), + yStart: .value(L("Estimated spend"), point.stackStart), + yEnd: .value(L("Estimated spend"), point.stackEnd), + width: .ratio(0.72)) + .foregroundStyle(by: .value(L("Provider"), point.providerName)) + .accessibilityLabel(Text(self.pointAccessibilityLabel(point))) + .accessibilityValue(Text(UsageFormatter.currencyString( + point.cost, + currencyCode: self.group.currencyCode))) + } + .chartXScale(domain: self.group.chartDomain) + .chartForegroundStyleScale( + domain: presentation.series.map(\.name), + range: presentation.series.map { self.providerColor($0.provider) }) + .chartLegend(position: .bottom, alignment: .leading, spacing: 8) + .chartYAxis { + AxisMarks(position: .leading) { value in + AxisGridLine() + AxisValueLabel { + if let amount = value.as(Double.self) { + Text(UsageFormatter.compactCurrencyString( + amount, + currencyCode: self.group.currencyCode)) + } + } + } + } + .frame(height: 170) + .accessibilityLabel(L("Daily estimated spend")) + .accessibilityValue(presentation.accessibilityValue) + } + } + } + } + + private func pointAccessibilityLabel(_ point: SpendDashboardModel.DailyPoint) -> String { + let day = point.day.formatted( + .dateTime.month(.abbreviated).day().locale(codexBarLocalizedLocale())) + return "\(point.providerName), \(day)" + } + + private func providerColor(_ provider: UsageProvider) -> Color { + let color = ProviderDescriptorRegistry.descriptor(for: provider).branding.color + return Color(red: color.red, green: color.green, blue: color.blue) + } +} + +private struct SpendProviderIcon: View { + let provider: UsageProvider + + var body: some View { + Group { + if let icon = ProviderBrandIcon.image(for: self.provider) { + Image(nsImage: icon).resizable().scaledToFit() + } else { + Image(systemName: "circle.dotted") + } + } + .frame(width: 20, height: 20) + .accessibilityHidden(true) + } +} + +private struct SpendDashboardPanel: View { + @ViewBuilder let content: Content + + var body: some View { + self.content + .padding(16) + .background(.quaternary.opacity(0.55), in: RoundedRectangle(cornerRadius: 14, style: .continuous)) + .overlay { + RoundedRectangle(cornerRadius: 14, style: .continuous) + .strokeBorder(Color(nsColor: .separatorColor).opacity(0.35)) + } + } +} diff --git a/Sources/CodexBar/PreferencesView.swift b/Sources/CodexBar/PreferencesView.swift index 961b1b9b87..3a398bf1ee 100644 --- a/Sources/CodexBar/PreferencesView.swift +++ b/Sources/CodexBar/PreferencesView.swift @@ -2,36 +2,41 @@ import AppKit import CodexBarCore import SwiftUI -enum PreferencesTab: String, CaseIterable, Hashable { +/// Sidebar destinations of the settings window: fixed app panes plus one entry per provider. +enum SettingsPane: Hashable { case general - case providers - case display + case usageSpend + case notifications + case menuBar + case menu case advanced + case hooks case about case debug + case provider(UsageProvider) - static let defaultWidth: CGFloat = 546 - static let providersWidth: CGFloat = 792 - static let windowHeight: CGFloat = 638 + static let windowWidth: CGFloat = 880 + static let windowHeight: CGFloat = 620 + static let windowMinWidth: CGFloat = 800 + static let windowMinHeight: CGFloat = 540 + static let sidebarWidth: CGFloat = 260 + static let detailMaxWidth: CGFloat = 780 var title: String { switch self { case .general: L("tab_general") - case .providers: L("tab_providers") - case .display: L("tab_display") + case .usageSpend: L("tab_usage_spend") + case .notifications: L("tab_notifications") + case .menuBar: L("tab_menu_bar") + case .menu: L("tab_menu") case .advanced: L("tab_advanced") + case .hooks: L("tab_hooks") case .about: L("tab_about") case .debug: L("tab_debug") + case let .provider(provider): + ProviderDescriptorRegistry.descriptor(for: provider).metadata.displayName } } - - var preferredWidth: CGFloat { - self == .providers ? PreferencesTab.providersWidth : PreferencesTab.defaultWidth - } - - var preferredHeight: CGFloat { - PreferencesTab.windowHeight - } } @MainActor @@ -43,8 +48,7 @@ struct PreferencesView: View { let managedCodexAccountCoordinator: ManagedCodexAccountCoordinator let codexAccountPromotionCoordinator: CodexAccountPromotionCoordinator let runProviderLoginFlow: @MainActor (UsageProvider) async -> Void - @State private var contentWidth: CGFloat = PreferencesTab.general.preferredWidth - @State private var contentHeight: CGFloat = PreferencesTab.general.preferredHeight + @Environment(\.colorScheme) private var colorScheme init( settings: SettingsStore, @@ -69,88 +73,268 @@ struct PreferencesView: View { } var body: some View { - TabView(selection: self.$selection.tab) { - GeneralPane(settings: self.settings, store: self.store) - .tabItem { Label(L("tab_general"), systemImage: "gearshape") } - .tag(PreferencesTab.general) + HStack(spacing: 0) { + // Golden Gate-style sidebar: edge-to-edge material with a hairline separator, + // no floating card chrome. The material ignores the safe area so it runs up + // behind the transparent titlebar. + SettingsSidebarView(settings: self.settings, store: self.store, selection: self.$selection.pane) + .frame(width: SettingsPane.sidebarWidth) + .background { + SettingsSidebarMaterial() + .ignoresSafeArea() + } + + Divider() + .ignoresSafeArea() + + self.detailView + .frame( + maxWidth: SettingsPane.detailMaxWidth, + maxHeight: .infinity, + alignment: .topLeading) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + } + .frame( + minWidth: SettingsPane.windowMinWidth, + idealWidth: SettingsPane.windowWidth, + maxWidth: .infinity, + minHeight: SettingsPane.windowMinHeight, + idealHeight: SettingsPane.windowHeight, + maxHeight: .infinity) + .id(self.settings.appLanguage) + .background { + SettingsWindowAppearanceBridge(colorScheme: self.colorScheme, windowTitle: self.selection.pane.title) + .allowsHitTesting(false) + } + .onAppear { + self.ensureValidSelection() + } + .onChange(of: self.settings.debugMenuEnabled) { _, _ in + self.ensureValidSelection() + } + .onChange(of: self.settings.shouldRequestAdaptiveActivityScanConsent) { _, shouldRequest in + guard shouldRequest else { return } + AdaptiveActivityConsentPresenter.presentIfNeeded(settings: self.settings) + } + } + @ViewBuilder + private var detailView: some View { + switch self.selection.pane { + case .general: + GeneralPane(settings: self.settings) + case .usageSpend: + SpendDashboardPane(settings: self.settings, store: self.store) + case .notifications: + NotificationsPane(settings: self.settings) + case .menuBar: + MenuBarPane(settings: self.settings, store: self.store) + case .menu: + MenuPane(settings: self.settings, store: self.store) + case .advanced: + AdvancedPane(settings: self.settings, store: self.store) + case .hooks: + HooksPane(settings: self.settings) + case .about: + AboutPane(updater: self.updater) + case .debug: + DebugPane(settings: self.settings, store: self.store) + case let .provider(provider): ProvidersPane( + provider: provider, settings: self.settings, store: self.store, managedCodexAccountCoordinator: self.managedCodexAccountCoordinator, codexAccountPromotionCoordinator: self.codexAccountPromotionCoordinator, runProviderLoginFlow: self.runProviderLoginFlow) - .tabItem { Label(L("tab_providers"), systemImage: "square.grid.2x2") } - .tag(PreferencesTab.providers) + .id(provider) + } + } - DisplayPane(settings: self.settings, store: self.store) - .tabItem { Label(L("tab_display"), systemImage: "eye") } - .tag(PreferencesTab.display) + private func ensureValidSelection() { + if !self.settings.debugMenuEnabled, self.selection.pane == .debug { + self.selection.pane = .general + } + } +} - AdvancedPane(settings: self.settings) - .tabItem { Label(L("tab_advanced"), systemImage: "slider.horizontal.3") } - .tag(PreferencesTab.advanced) +@MainActor +enum SettingsWindowSizing { + static func enforceMinimumSize(_ window: NSWindow) { + let toolbarHeight = max(0, window.frame.height - window.contentLayoutRect.height) + let minimumSize = NSSize( + width: SettingsPane.windowMinWidth, + height: SettingsPane.windowMinHeight + toolbarHeight) + window.minSize = minimumSize - AboutPane(updater: self.updater) - .tabItem { Label(L("tab_about"), systemImage: "info.circle") } - .tag(PreferencesTab.about) + if window.frame.width < minimumSize.width || window.frame.height < minimumSize.height { + var frame = window.frame + let repairedSize = NSSize( + width: max(frame.width, minimumSize.width), + height: max(frame.height, minimumSize.height)) + frame.origin.y += frame.height - repairedSize.height + frame.size = repairedSize + window.setFrame(frame, display: true) + } + } +} + +@MainActor +enum SettingsWindowAppearance { + typealias ResetAction = @MainActor @Sendable () -> Void + typealias ResetScheduler = @MainActor @Sendable (@escaping ResetAction) -> Void - if self.settings.debugMenuEnabled { - DebugPane(settings: self.settings, store: self.store) - .tabItem { Label(L("tab_debug"), systemImage: "ladybug") } - .tag(PreferencesTab.debug) + static func refresh( + _ window: NSWindow, + application: NSApplication = NSApp, + scheduleReset: ResetScheduler = Self.scheduleReset) + { + SettingsWindowSizing.enforceMinimumSize(window) + window.appearanceSource = application + // Pulse the exact effective appearance so the native toolbar redraws without + // dropping inherited accessibility attributes, then restore KVO inheritance. + window.appearance = application.effectiveAppearance + scheduleReset { [weak window] in + if let window { + SettingsWindowSizing.enforceMinimumSize(window) } + window?.appearance = nil + window?.viewsNeedDisplay = true } - .id(self.settings.appLanguage) - .padding(.horizontal, 24) - .padding(.vertical, 16) - .frame(width: self.contentWidth, height: self.contentHeight) - .onAppear { - self.updateLayout(for: self.selection.tab, animate: false) - self.ensureValidTabSelection() - } - .onChange(of: self.selection.tab) { _, newValue in - self.updateLayout(for: newValue, animate: true) + } + + static func scheduleReset(_ action: @escaping ResetAction) { + Task { @MainActor in + await Task.yield() + action() } - .onChange(of: self.settings.debugMenuEnabled) { _, _ in - self.ensureValidTabSelection() + } +} + +@MainActor +struct SettingsWindowAppearanceBridge: NSViewRepresentable { + let colorScheme: ColorScheme + let windowTitle: String + + func makeNSView(context: Context) -> SettingsWindowAppearanceView { + SettingsWindowAppearanceView() + } + + func updateNSView(_ nsView: SettingsWindowAppearanceView, context: Context) { + nsView.refreshWindowAppearance(for: self.colorScheme, windowTitle: self.windowTitle) + } +} + +@MainActor +final class SettingsWindowAppearanceView: NSView { + private let scheduleReset: SettingsWindowAppearance.ResetScheduler + private var colorScheme: ColorScheme? + private var windowTitle: String? + + init(scheduleReset: @escaping SettingsWindowAppearance.ResetScheduler = SettingsWindowAppearance.scheduleReset) { + self.scheduleReset = scheduleReset + super.init(frame: .zero) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + deinit { + NotificationCenter.default.removeObserver(self) + } + + override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + NotificationCenter.default.removeObserver(self, name: NSWindow.didUpdateNotification, object: nil) + if let window { + NotificationCenter.default.addObserver( + self, + selector: #selector(self.windowDidUpdate(_:)), + name: NSWindow.didUpdateNotification, + object: window) } + self.configureWindowStyle() + self.refreshWindowAppearance() } - private func updateLayout(for tab: PreferencesTab, animate: Bool) { - let change = { - self.contentWidth = tab.preferredWidth - self.contentHeight = tab.preferredHeight + @objc private func windowDidUpdate(_ notification: Notification) { + self.configureWindowStyle() + } + + func refreshWindowAppearance(for colorScheme: ColorScheme, windowTitle: String? = nil) { + let colorSchemeChanged = self.colorScheme != colorScheme + let windowTitleChanged = self.windowTitle != windowTitle + guard colorSchemeChanged || windowTitleChanged else { return } + self.colorScheme = colorScheme + self.windowTitle = windowTitle + + guard let window else { return } + self.configureWindowStyle() + if windowTitleChanged, let windowTitle { + window.title = windowTitle } - if animate { - withAnimation(.spring(response: 0.32, dampingFraction: 0.85)) { change() } - } else { - change() + if colorSchemeChanged { + SettingsWindowAppearance.refresh(window, scheduleReset: self.scheduleReset) } - Self.resizeSettingsWindow(width: tab.preferredWidth, height: tab.preferredHeight, animate: animate) } - private static let settingsWindowIdentifier = "com_apple_SwiftUI_Settings_window" - private static let knownTabTitles = Set(PreferencesTab.allCases.map(\.title)) + private func refreshWindowAppearance() { + guard let window else { return } + self.configureWindowStyle() + if let windowTitle { + window.title = windowTitle + } + SettingsWindowAppearance.refresh(window, scheduleReset: self.scheduleReset) + } - private static func resizeSettingsWindow(width: CGFloat, height: CGFloat, animate: Bool) { - guard let window = NSApp.windows.first(where: { - $0.identifier?.rawValue == settingsWindowIdentifier - || knownTabTitles.contains($0.title) - }) else { return } - let toolbarHeight = window.frame.height - window.contentLayoutRect.height - guard toolbarHeight > 0 else { return } - let newSize = NSSize(width: width, height: height + toolbarHeight) - var frame = window.frame - frame.origin.y += frame.size.height - newSize.height - frame.size = newSize - window.setFrame(frame, display: true, animate: animate) + override func layout() { + super.layout() + self.configureWindowStyle() } - private func ensureValidTabSelection() { - if !self.settings.debugMenuEnabled, self.selection.tab == .debug { - self.selection.tab = .general - self.updateLayout(for: .general, animate: true) + private func configureWindowStyle() { + guard let window else { return } + if !window.styleMask.contains(.resizable) { + window.styleMask.insert(.resizable) + } + if !window.titlebarAppearsTransparent { + window.titlebarAppearsTransparent = true + } + if window.titleVisibility != .visible { + window.titleVisibility = .visible + } + if window.titlebarSeparatorStyle != .none { + window.titlebarSeparatorStyle = .none + } + if window.toolbar != nil { + window.toolbar = nil + } + // Full-size content lets the sidebar material extend behind the titlebar so the + // edge-to-edge sidebar reaches the top of the window; content stays below the + // titlebar via the safe area. + if !window.styleMask.contains(.fullSizeContentView) { + window.styleMask.insert(.fullSizeContentView) } } } + +@MainActor +private struct SettingsSidebarMaterial: NSViewRepresentable { + func makeNSView(context: Context) -> NSVisualEffectView { + let view = NSVisualEffectView() + self.configure(view) + return view + } + + func updateNSView(_ nsView: NSVisualEffectView, context: Context) { + self.configure(nsView) + } + + private func configure(_ view: NSVisualEffectView) { + view.material = .sidebar + view.blendingMode = .behindWindow + view.state = .followsWindowActiveState + } +} diff --git a/Sources/CodexBar/ProviderBrandIcon.swift b/Sources/CodexBar/ProviderBrandIcon.swift index cec160ad97..844e46770f 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/ProviderRefreshCoordinator.swift b/Sources/CodexBar/ProviderRefreshCoordinator.swift new file mode 100644 index 0000000000..e05edb31cd --- /dev/null +++ b/Sources/CodexBar/ProviderRefreshCoordinator.swift @@ -0,0 +1,199 @@ +import Foundation + +@MainActor +final class ProviderRefreshCoordinator { + enum WaitResult: Equatable { + case completed + case retryRequired + case cancelled + } + + struct Request { + let generation: UInt64 + let state: ProviderRefreshTaskState + let predecessorStates: [ProviderRefreshTaskState] + } + + private var states: [Key: [ProviderRefreshTaskState]] = [:] + private var latestGenerations: [Key: UInt64] = [:] + private var activeCounts: [Key: Int] = [:] + private var nextGeneration: UInt64 = 0 + private var nextWaiterID: UInt64 = 0 + + func coalescingState(for key: Key) -> ProviderRefreshTaskState? { + guard let latestGeneration = self.latestGenerations[key] else { return nil } + return self.states[key]?.last { state in + state.generation == latestGeneration && !state.isCompleted + } + } + + func beginReplacingRequest(for key: Key) -> Request { + self.nextGeneration &+= 1 + let generation = self.nextGeneration + let predecessorStates = self.states[key] ?? [] + for predecessorState in predecessorStates { + predecessorState.cancelTask() + } + self.latestGenerations[key] = generation + let state = ProviderRefreshTaskState(generation: generation) + self.states[key, default: []].append(state) + return Request( + generation: generation, + state: state, + predecessorStates: predecessorStates) + } + + /// Invalidates in-flight work without creating a replacement request. Existing states stay + /// registered until their tasks and waiters drain, but their generations can no longer publish. + func invalidateRequests(for key: Key) { + self.nextGeneration &+= 1 + self.latestGenerations[key] = self.nextGeneration + for state in self.states[key] ?? [] { + state.cancelTask() + } + } + + func wait(for key: Key, state: ProviderRefreshTaskState) async -> WaitResult { + self.nextWaiterID &+= 1 + let waiterID = self.nextWaiterID + guard let task = state.addWaiter(waiterID) else { return .completed } + await withTaskCancellationHandler { + await task.value + } onCancel: { + state.cancelWaiter(waiterID) + } + state.finishWaiter(waiterID) + let result: WaitResult = if Task.isCancelled { + .cancelled + } else if state.shouldRetry { + .retryRequired + } else { + .completed + } + if state.canRemove { + self.scheduleRemoval(for: key, state: state) + } + return result + } + + func complete(_ state: ProviderRefreshTaskState, for key: Key, retryRequired: Bool) { + state.markCompleted(retryRequired: retryRequired) + self.scheduleRemoval(for: key, state: state) + } + + func remove(_ state: ProviderRefreshTaskState, for key: Key) { + guard var keyStates = self.states[key] else { return } + keyStates.removeAll { $0 === state } + if keyStates.isEmpty { + self.states.removeValue(forKey: key) + } else { + self.states[key] = keyStates + } + } + + func isCurrent(_ generation: UInt64, for key: Key) -> Bool { + self.latestGenerations[key] == generation + } + + @discardableResult + func beginActivity(for key: Key) -> Bool { + self.activeCounts[key, default: 0] += 1 + return self.activeCounts[key] == 1 + } + + @discardableResult + func endActivity(for key: Key) -> Bool { + let remaining = max(0, self.activeCounts[key, default: 1] - 1) + if remaining == 0 { + self.activeCounts.removeValue(forKey: key) + return true + } + self.activeCounts[key] = remaining + return false + } + + private func scheduleRemoval(for key: Key, state: ProviderRefreshTaskState) { + Task { @MainActor [weak self] in + await Task.yield() + guard let self, + self.states[key]?.contains(where: { $0 === state }) == true, + state.canRemove + else { + return + } + self.remove(state, for: key) + } + } +} + +final class ProviderRefreshTaskState: @unchecked Sendable { + let generation: UInt64 + + private let lock = NSLock() + private var task: Task? + private var waiterIDs: Set = [] + private var completed = false + private var retryRequired = false + + init(generation: UInt64) { + self.generation = generation + } + + func install(task: Task) { + self.lock.withLock { + self.task = task + } + } + + func addWaiter(_ waiterID: UInt64) -> Task? { + self.lock.withLock { + self.waiterIDs.insert(waiterID) + return self.task + } + } + + func cancelWaiter(_ waiterID: UInt64) { + let taskToCancel = self.lock.withLock { + guard self.waiterIDs.remove(waiterID) != nil else { return nil as Task? } + return self.waiterIDs.isEmpty && !self.completed ? self.task : nil + } + taskToCancel?.cancel() + } + + func finishWaiter(_ waiterID: UInt64) { + _ = self.lock.withLock { + self.waiterIDs.remove(waiterID) + } + } + + func markCompleted(retryRequired: Bool) { + self.lock.withLock { + self.completed = true + self.retryRequired = retryRequired + } + } + + func cancelTask() { + let task = self.lock.withLock { + self.completed ? nil : self.task + } + task?.cancel() + } + + func waitForTaskCompletion() async { + let task = self.lock.withLock { self.task } + await task?.value + } + + fileprivate var shouldRetry: Bool { + self.lock.withLock { self.retryRequired } + } + + fileprivate var isCompleted: Bool { + self.lock.withLock { self.completed } + } + + var canRemove: Bool { + self.lock.withLock { self.completed && self.waiterIDs.isEmpty } + } +} diff --git a/Sources/CodexBar/ProviderRegistry.swift b/Sources/CodexBar/ProviderRegistry.swift index eeeb541b17..56593f7cfb 100644 --- a/Sources/CodexBar/ProviderRegistry.swift +++ b/Sources/CodexBar/ProviderRegistry.swift @@ -56,7 +56,10 @@ struct ProviderRegistry { runtime: .app, sourceMode: sourceMode, includeCredits: false, - includeOptionalUsage: settings.showOptionalCreditsAndExtraUsage, + includeOptionalUsage: ProviderTokenAccountSelection.shouldIncludeOptionalUsage( + provider: provider, + settings: settings, + override: nil), webTimeout: 60, webDebugDumpHTML: false, verbose: verbose, @@ -81,7 +84,10 @@ struct ProviderRegistry { } } }, - costUsageHistoryDays: settings.costUsageHistoryDays) + costUsageHistoryDays: settings.costUsageHistoryDays, + persistsCLISessions: true, + persistentCLISessionIdleWindow: Self.persistentCLISessionIdleWindow( + refreshInterval: Self.nominalRefreshInterval(for: settings.refreshFrequency))) }) specs[provider] = spec } @@ -89,6 +95,18 @@ struct ProviderRegistry { return specs } + static func persistentCLISessionIdleWindow(refreshInterval: TimeInterval?) -> TimeInterval { + max(180, (refreshInterval ?? 120) + 60) + } + + /// `RefreshFrequency.seconds` is nil for `.adaptive`, which would collapse the idle window to + /// its floor and churn persistent CLI sessions between adaptive ticks. No `UsageStore` exists + /// when specs are built, so `.adaptive` maps to the policy's nominal interval instead of a + /// live decision; `.manual` stays nil. + static func nominalRefreshInterval(for frequency: RefreshFrequency) -> TimeInterval? { + frequency.usesAdaptivePolicy ? AdaptiveRefreshPolicy.nominalIntervalForHeuristics : frequency.seconds + } + @MainActor static func makeSettingsSnapshot( settings: SettingsStore, @@ -123,38 +141,23 @@ struct ProviderRegistry { provider: provider, settings: settings, override: tokenOverride) - var env = ProviderConfigEnvironment.applyProviderConfigOverrides( + var env = ProviderEnvironmentResolver.resolve( base: base, provider: provider, - config: settings.providerConfig(for: provider)) - // If token account is selected, use its token instead of config's apiKey - if let account { - TokenAccountSupportCatalog.scrubEnvironmentForSelectedAccount( - &env, - provider: provider, - token: account.token) - if let override = TokenAccountSupportCatalog.envOverride( - for: provider, - token: account.token) - { - for (key, value) in override { - env[key] = value - } - } - } - // Managed Codex routing only scopes remote account fetches such as identity, plan, - // quotas, and dashboard data, and only when the active source is a managed account. - // Token-cost/session history is intentionally not routed through the managed home - // because that data is currently treated as provider-level local telemetry from this - // Mac's Codex sessions, not as account-owned remote state. If we later want - // account-scoped token history in the UI, that needs an explicit product decision and - // presentation change so the two concepts are not conflated. + config: settings.providerConfig(for: provider), + selectedAccount: account) + // Codex account routing scopes remote account fetches such as identity, plan, + // quotas, and dashboard data. Token-cost/session history is intentionally handled + // separately because it is provider-level local telemetry from this Mac's Codex sessions, + // not account-owned remote state. if provider == .codex { let codexActiveSource = codexActiveSourceOverride ?? settings.codexResolvedActiveSource - if case .managedAccount = codexActiveSource, - let managedHomePath = settings.managedCodexRemoteHomePath(forActiveSource: codexActiveSource) - { + if let managedHomePath = settings.managedCodexRemoteHomePath(forActiveSource: codexActiveSource) { env = CodexHomeScope.scopedEnvironment(base: env, codexHome: managedHomePath) + } else if let liveHomePath = settings.liveSystemCodexHomePath(forActiveSource: codexActiveSource) { + env = CodexHomeScope.scopedEnvironment(base: env, codexHome: liveHomePath) + } else if let profileHomePath = settings.profileCodexHomePath(forActiveSource: codexActiveSource) { + env = CodexHomeScope.scopedEnvironment(base: env, codexHome: profileHomePath) } } return env diff --git a/Sources/CodexBar/ProviderSwitcherButtons.swift b/Sources/CodexBar/ProviderSwitcherButtons.swift index 0832e2bb3c..1779621933 100644 --- a/Sources/CodexBar/ProviderSwitcherButtons.swift +++ b/Sources/CodexBar/ProviderSwitcherButtons.swift @@ -1,8 +1,6 @@ import AppKit final class PaddedToggleButton: NSButton { - private var quotaBarReservedHeight: CGFloat = 0 - var contentPadding = NSEdgeInsets(top: 4, left: 7, bottom: 4, right: 7) { didSet { if oldValue.top != self.contentPadding.top || @@ -19,30 +17,16 @@ final class PaddedToggleButton: NSButton { let size = super.intrinsicContentSize return NSSize( width: size.width + self.contentPadding.left + self.contentPadding.right, - height: size.height + self.contentPadding.top + self.contentPadding.bottom + self.quotaBarReservedHeight) - } - - func setQuotaBarReservedHeight(_ height: CGFloat) { - guard self.quotaBarReservedHeight != height else { return } - self.quotaBarReservedHeight = height - self.invalidateIntrinsicContentSize() + height: size.height + self.contentPadding.top + self.contentPadding.bottom) } } -@MainActor -protocol ProviderSwitcherToggleButton: AnyObject { - func setQuotaBarReservedHeight(_ height: CGFloat) -} - -extension PaddedToggleButton: ProviderSwitcherToggleButton {} - final class InlineIconToggleButton: NSButton { private let iconView = NSImageView() private let titleField = NSTextField(labelWithString: "") private let stack = NSStackView() private var paddingConstraints: [NSLayoutConstraint] = [] private var iconSizeConstraints: [NSLayoutConstraint] = [] - private var quotaBarReservedHeight: CGFloat = 0 private var isConfiguring = false // Batch invalidation during setup var contentPadding = NSEdgeInsets(top: 4, left: 7, bottom: 4, right: 7) { @@ -50,8 +34,7 @@ final class InlineIconToggleButton: NSButton { self.paddingConstraints.first { $0.firstAttribute == .top }?.constant = self.contentPadding.top self.paddingConstraints.first { $0.firstAttribute == .leading }?.constant = self.contentPadding.left self.paddingConstraints.first { $0.firstAttribute == .trailing }?.constant = -self.contentPadding.right - self.paddingConstraints.first { $0.firstAttribute == .bottom }?.constant = - -(self.contentPadding.bottom + self.quotaBarReservedHeight) + self.paddingConstraints.first { $0.firstAttribute == .bottom }?.constant = -self.contentPadding.bottom if !self.isConfiguring { self.invalidateIntrinsicContentSize() } } } @@ -88,14 +71,6 @@ final class InlineIconToggleButton: NSButton { self.titleField.font = NSFont.systemFont(ofSize: size) } - func setQuotaBarReservedHeight(_ height: CGFloat) { - guard self.quotaBarReservedHeight != height else { return } - self.quotaBarReservedHeight = height - self.paddingConstraints.first { $0.firstAttribute == .bottom }?.constant = - -(self.contentPadding.bottom + height) - if !self.isConfiguring { self.invalidateIntrinsicContentSize() } - } - func setAllowsTwoLineTitle(_ allow: Bool) { let hasWhitespace = self.titleField.stringValue.rangeOfCharacter(from: .whitespacesAndNewlines) != nil let shouldWrap = allow && hasWhitespace @@ -108,7 +83,7 @@ final class InlineIconToggleButton: NSButton { let size = self.stack.fittingSize return NSSize( width: size.width + self.contentPadding.left + self.contentPadding.right, - height: size.height + self.contentPadding.top + self.contentPadding.bottom + self.quotaBarReservedHeight) + height: size.height + self.contentPadding.top + self.contentPadding.bottom) } init(title: String, image: NSImage, target: AnyObject?, action: Selector?) { @@ -158,7 +133,7 @@ final class InlineIconToggleButton: NSButton { self.iconSizeConstraints = [iconWidth, iconHeight] let top = self.stack.topAnchor.constraint( - equalTo: self.topAnchor, + greaterThanOrEqualTo: self.topAnchor, constant: self.contentPadding.top) let leading = self.stack.leadingAnchor.constraint( greaterThanOrEqualTo: self.leadingAnchor, @@ -168,24 +143,22 @@ final class InlineIconToggleButton: NSButton { constant: -self.contentPadding.right) let centerX = self.stack.centerXAnchor.constraint(equalTo: self.centerXAnchor) centerX.priority = .defaultHigh + let centerY = self.stack.centerYAnchor.constraint(equalTo: self.centerYAnchor) let bottom = self.stack.bottomAnchor.constraint( lessThanOrEqualTo: self.bottomAnchor, constant: -self.contentPadding.bottom) - self.paddingConstraints = [top, leading, trailing, bottom, centerX] + self.paddingConstraints = [top, leading, trailing, bottom, centerX, centerY] NSLayoutConstraint.activate(self.paddingConstraints + self.iconSizeConstraints) } } -extension InlineIconToggleButton: ProviderSwitcherToggleButton {} - final class StackedToggleButton: NSButton { private let iconView = NSImageView() private let titleField = NSTextField(labelWithString: "") private let stack = NSStackView() private var paddingConstraints: [NSLayoutConstraint] = [] private var iconSizeConstraints: [NSLayoutConstraint] = [] - private var quotaBarReservedHeight: CGFloat = 0 private var isConfiguring = false // Batch invalidation during setup var contentPadding = NSEdgeInsets(top: 2, left: 4, bottom: 2, right: 4) { @@ -193,8 +166,7 @@ final class StackedToggleButton: NSButton { self.paddingConstraints.first { $0.firstAttribute == .top }?.constant = self.contentPadding.top self.paddingConstraints.first { $0.firstAttribute == .leading }?.constant = self.contentPadding.left self.paddingConstraints.first { $0.firstAttribute == .trailing }?.constant = -self.contentPadding.right - self.paddingConstraints.first { $0.firstAttribute == .bottom }?.constant = - -(self.contentPadding.bottom + self.quotaBarReservedHeight) + self.paddingConstraints.first { $0.firstAttribute == .bottom }?.constant = -self.contentPadding.bottom if !self.isConfiguring { self.invalidateIntrinsicContentSize() } } } @@ -231,14 +203,6 @@ final class StackedToggleButton: NSButton { self.titleField.font = NSFont.systemFont(ofSize: size) } - func setQuotaBarReservedHeight(_ height: CGFloat) { - guard self.quotaBarReservedHeight != height else { return } - self.quotaBarReservedHeight = height - self.paddingConstraints.first { $0.firstAttribute == .bottom }?.constant = - -(self.contentPadding.bottom + height) - if !self.isConfiguring { self.invalidateIntrinsicContentSize() } - } - func setAllowsTwoLineTitle(_ allow: Bool) { let hasWhitespace = self.titleField.stringValue.rangeOfCharacter(from: .whitespacesAndNewlines) != nil let shouldWrap = allow && hasWhitespace @@ -251,7 +215,7 @@ final class StackedToggleButton: NSButton { let size = self.stack.fittingSize return NSSize( width: size.width + self.contentPadding.left + self.contentPadding.right, - height: size.height + self.contentPadding.top + self.contentPadding.bottom + self.quotaBarReservedHeight) + height: size.height + self.contentPadding.top + self.contentPadding.bottom) } init(title: String, image: NSImage, target: AnyObject?, action: Selector?) { @@ -300,10 +264,9 @@ final class StackedToggleButton: NSButton { let iconHeight = self.iconView.heightAnchor.constraint(equalToConstant: 16) self.iconSizeConstraints = [iconWidth, iconHeight] - // Avoid subpixel centering: pin from the top so the icon sits on whole-point coordinates. // Force an even layout width (button width minus padding) so the icon doesn't land on 0.5pt centers. let top = self.stack.topAnchor.constraint( - equalTo: self.topAnchor, + greaterThanOrEqualTo: self.topAnchor, constant: self.contentPadding.top) let leading = self.stack.leadingAnchor.constraint( equalTo: self.leadingAnchor, @@ -314,10 +277,9 @@ final class StackedToggleButton: NSButton { let bottom = self.stack.bottomAnchor.constraint( lessThanOrEqualTo: self.bottomAnchor, constant: -self.contentPadding.bottom) - self.paddingConstraints = [top, leading, trailing, bottom] + let centerY = self.stack.centerYAnchor.constraint(equalTo: self.centerYAnchor) + self.paddingConstraints = [top, leading, trailing, bottom, centerY] NSLayoutConstraint.activate(self.paddingConstraints + self.iconSizeConstraints) } } - -extension StackedToggleButton: ProviderSwitcherToggleButton {} diff --git a/Sources/CodexBar/Providers/Abacus/AbacusProviderImplementation.swift b/Sources/CodexBar/Providers/Abacus/AbacusProviderImplementation.swift index 1e0cdeb9f4..186f5d9490 100644 --- a/Sources/CodexBar/Providers/Abacus/AbacusProviderImplementation.swift +++ b/Sources/CodexBar/Providers/Abacus/AbacusProviderImplementation.swift @@ -1,10 +1,8 @@ import AppKit import CodexBarCore -import CodexBarMacroSupport import Foundation import SwiftUI -@ProviderImplementationRegistration struct AbacusProviderImplementation: ProviderImplementation { let id: UsageProvider = .abacus @@ -64,9 +62,7 @@ struct AbacusProviderImplementation: ProviderImplementation { isVisible: nil, onChange: nil, trailingText: { - guard let entry = CookieHeaderCache.load(provider: .abacus) else { return nil } - let when = entry.storedAt.relativeDescription() - return "Cached: \(entry.sourceLabel) • \(when)" + ProviderCookieSourceUI.cachedTrailingText(provider: .abacus) }), ] } diff --git a/Sources/CodexBar/Providers/Abacus/AbacusSettingsStore.swift b/Sources/CodexBar/Providers/Abacus/AbacusSettingsStore.swift index d5e5c3e30c..aa33089acc 100644 --- a/Sources/CodexBar/Providers/Abacus/AbacusSettingsStore.swift +++ b/Sources/CodexBar/Providers/Abacus/AbacusSettingsStore.swift @@ -26,36 +26,10 @@ extension SettingsStore { extension SettingsStore { func abacusSettingsSnapshot(tokenOverride: TokenAccountOverride?) -> ProviderSettingsSnapshot .AbacusProviderSettings { - ProviderSettingsSnapshot.AbacusProviderSettings( - cookieSource: self.abacusSnapshotCookieSource(tokenOverride: tokenOverride), - manualCookieHeader: self.abacusSnapshotCookieHeader(tokenOverride: tokenOverride)) - } - - private func abacusSnapshotCookieHeader(tokenOverride: TokenAccountOverride?) -> String { - let fallback = self.abacusCookieHeader - guard let support = TokenAccountSupportCatalog.support(for: .abacus), - case .cookieHeader = support.injection - else { - return fallback - } - guard let account = ProviderTokenAccountSelection.selectedAccount( + self.resolvedCookieSettings( provider: .abacus, - settings: self, - override: tokenOverride) - else { - return fallback - } - return TokenAccountSupportCatalog.normalizedCookieHeader(account.token, support: support) - } - - private func abacusSnapshotCookieSource(tokenOverride _: TokenAccountOverride?) -> ProviderCookieSource { - let fallback = self.abacusCookieSource - guard let support = TokenAccountSupportCatalog.support(for: .abacus), - support.requiresManualCookieSource - else { - return fallback - } - if self.tokenAccounts(for: .abacus).isEmpty { return fallback } - return .manual + configuredSource: self.abacusCookieSource, + configuredHeader: self.abacusCookieHeader, + tokenOverride: tokenOverride) } } diff --git a/Sources/CodexBar/Providers/AiAnd/AiAndProviderImplementation.swift b/Sources/CodexBar/Providers/AiAnd/AiAndProviderImplementation.swift new file mode 100644 index 0000000000..34baab3658 --- /dev/null +++ b/Sources/CodexBar/Providers/AiAnd/AiAndProviderImplementation.swift @@ -0,0 +1,52 @@ +import AppKit +import CodexBarCore +import Foundation + +struct AiAndProviderImplementation: ProviderImplementation { + let id: UsageProvider = .aiand + + @MainActor + func presentation(context _: ProviderPresentationContext) -> ProviderPresentation { + ProviderPresentation { _ in "api" } + } + + @MainActor + func observeSettings(_ settings: SettingsStore) { + _ = settings.aiAndAPIKey + } + + @MainActor + func isAvailable(context: ProviderAvailabilityContext) -> Bool { + if AiAndSettingsReader.apiKey(environment: context.environment) != nil { + return true + } + return !context.settings.aiAndAPIKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + + @MainActor + func settingsFields(context: ProviderSettingsContext) -> [ProviderSettingsFieldDescriptor] { + [ + ProviderSettingsFieldDescriptor( + id: "aiand-api-key", + title: "API key", + subtitle: "Stored in CodexBar's config file. Create a key in the ai& console (shown once).", + kind: .secure, + placeholder: "sk-…", + binding: context.stringBinding(\.aiAndAPIKey), + actions: [ + ProviderSettingsActionDescriptor( + id: "aiand-open-console", + title: "Open ai& Console", + style: .link, + isVisible: nil, + perform: { + if let url = URL(string: "https://console.aiand.com") { + NSWorkspace.shared.open(url) + } + }), + ], + isVisible: nil, + onActivate: nil), + ] + } +} diff --git a/Sources/CodexBar/Providers/AiAnd/AiAndSettingsStore.swift b/Sources/CodexBar/Providers/AiAnd/AiAndSettingsStore.swift new file mode 100644 index 0000000000..73e5b26b24 --- /dev/null +++ b/Sources/CodexBar/Providers/AiAnd/AiAndSettingsStore.swift @@ -0,0 +1,14 @@ +import CodexBarCore +import Foundation + +extension SettingsStore { + var aiAndAPIKey: String { + get { self.configSnapshot.providerConfig(for: .aiand)?.sanitizedAPIKey ?? "" } + set { + self.updateProviderConfig(provider: .aiand) { entry in + entry.apiKey = self.normalizedConfigValue(newValue) + } + self.logSecretUpdate(provider: .aiand, field: "apiKey", value: newValue) + } + } +} diff --git a/Sources/CodexBar/Providers/Alibaba/AlibabaCodingPlanProviderImplementation.swift b/Sources/CodexBar/Providers/Alibaba/AlibabaCodingPlanProviderImplementation.swift index eb198478eb..0f58957db3 100644 --- a/Sources/CodexBar/Providers/Alibaba/AlibabaCodingPlanProviderImplementation.swift +++ b/Sources/CodexBar/Providers/Alibaba/AlibabaCodingPlanProviderImplementation.swift @@ -1,10 +1,8 @@ import AppKit import CodexBarCore -import CodexBarMacroSupport import Foundation import SwiftUI -@ProviderImplementationRegistration struct AlibabaCodingPlanProviderImplementation: ProviderImplementation { let id: UsageProvider = .alibaba @@ -69,9 +67,7 @@ struct AlibabaCodingPlanProviderImplementation: ProviderImplementation { isVisible: nil, onChange: nil, trailingText: { - guard let entry = CookieHeaderCache.load(provider: .alibaba) else { return nil } - let when = entry.storedAt.relativeDescription() - return "Cached: \(entry.sourceLabel) • \(when)" + ProviderCookieSourceUI.cachedTrailingText(provider: .alibaba) }), ProviderSettingsPickerDescriptor( id: "alibaba-coding-plan-region", diff --git a/Sources/CodexBar/Providers/Alibaba/AlibabaTokenPlanProviderImplementation.swift b/Sources/CodexBar/Providers/Alibaba/AlibabaTokenPlanProviderImplementation.swift index ad62bee57a..0d1b7cb705 100644 --- a/Sources/CodexBar/Providers/Alibaba/AlibabaTokenPlanProviderImplementation.swift +++ b/Sources/CodexBar/Providers/Alibaba/AlibabaTokenPlanProviderImplementation.swift @@ -1,10 +1,8 @@ import AppKit import CodexBarCore -import CodexBarMacroSupport import Foundation import SwiftUI -@ProviderImplementationRegistration struct AlibabaTokenPlanProviderImplementation: ProviderImplementation { let id: UsageProvider = .alibabatokenplan @@ -19,6 +17,7 @@ struct AlibabaTokenPlanProviderImplementation: ProviderImplementation { func observeSettings(_ settings: SettingsStore) { _ = settings.alibabaTokenPlanCookieSource _ = settings.alibabaTokenPlanCookieHeader + _ = settings.alibabaTokenPlanAPIRegion } @MainActor @@ -38,29 +37,50 @@ struct AlibabaTokenPlanProviderImplementation: ProviderImplementation { allowsOff: false, keychainDisabled: context.settings.debugDisableKeychainAccess) let cookieSubtitle: () -> String? = { - ProviderCookieSourceUI.subtitle( + let region = context.settings.alibabaTokenPlanAPIRegion + let host = region.usesPersonalTokenPlanAPI + ? URL(string: region.quotaBaseURLString)?.host + : region.dashboardURL.host + return ProviderCookieSourceUI.subtitle( source: context.settings.alibabaTokenPlanCookieSource, keychainDisabled: context.settings.debugDisableKeychainAccess, - auto: "Automatic imports browser cookies from Bailian.", - manual: "Paste a Cookie header from bailian.console.aliyun.com.", + auto: "Automatic imports browser cookies from Model Studio/Bailian.", + manual: "Paste a Cookie header from \(host ?? "the selected console").", off: "Alibaba Token Plan cookies are disabled.") } + let regionBinding = Binding( + get: { context.settings.alibabaTokenPlanAPIRegion.rawValue }, + set: { raw in + context.settings.alibabaTokenPlanAPIRegion = AlibabaTokenPlanAPIRegion(rawValue: raw) ?? .international + }) + let regionOptions = AlibabaTokenPlanAPIRegion.allCases.map { + ProviderSettingsPickerOption(id: $0.rawValue, title: $0.displayName) + } + return [ ProviderSettingsPickerDescriptor( id: "alibaba-token-plan-cookie-source", title: "Cookie source", - subtitle: "Automatic imports browser cookies from Bailian.", + subtitle: "Automatic imports browser cookies from Model Studio/Bailian.", dynamicSubtitle: cookieSubtitle, binding: cookieBinding, options: cookieOptions, isVisible: nil, onChange: nil, trailingText: { - guard let entry = CookieHeaderCache.load(provider: .alibabatokenplan) else { return nil } - let when = entry.storedAt.relativeDescription() - return "Cached: \(entry.sourceLabel) • \(when)" + ProviderCookieSourceUI.cachedTrailingText( + provider: .alibabatokenplan, + scope: context.settings.alibabaTokenPlanAPIRegion.cookieCacheScope) }), + ProviderSettingsPickerDescriptor( + id: "alibaba-token-plan-region", + title: "Gateway region", + subtitle: "Use international or China mainland console gateways for quota fetches.", + binding: regionBinding, + options: regionOptions, + isVisible: nil, + onChange: nil), ] } @@ -81,7 +101,9 @@ struct AlibabaTokenPlanProviderImplementation: ProviderImplementation { style: .link, isVisible: nil, perform: { - NSWorkspace.shared.open(AlibabaTokenPlanUsageFetcher.dashboardURL) + NSWorkspace.shared.open( + AlibabaTokenPlanUsageFetcher.dashboardURL( + region: context.settings.alibabaTokenPlanAPIRegion)) }), ], isVisible: { diff --git a/Sources/CodexBar/Providers/Alibaba/AlibabaTokenPlanSettingsStore.swift b/Sources/CodexBar/Providers/Alibaba/AlibabaTokenPlanSettingsStore.swift index 6f2ed187b3..b0d067c576 100644 --- a/Sources/CodexBar/Providers/Alibaba/AlibabaTokenPlanSettingsStore.swift +++ b/Sources/CodexBar/Providers/Alibaba/AlibabaTokenPlanSettingsStore.swift @@ -22,9 +22,22 @@ extension SettingsStore { } } + var alibabaTokenPlanAPIRegion: AlibabaTokenPlanAPIRegion { + get { + let raw = self.configSnapshot.providerConfig(for: .alibabatokenplan)?.sanitizedRegion + return AlibabaTokenPlanAPIRegion(rawValue: raw ?? "") ?? .chinaMainland + } + set { + self.updateProviderConfig(provider: .alibabatokenplan) { entry in + entry.region = newValue.rawValue + } + } + } + func alibabaTokenPlanSettingsSnapshot() -> ProviderSettingsSnapshot.AlibabaTokenPlanProviderSettings { ProviderSettingsSnapshot.AlibabaTokenPlanProviderSettings( cookieSource: self.alibabaTokenPlanCookieSource, - manualCookieHeader: self.alibabaTokenPlanCookieHeader) + manualCookieHeader: self.alibabaTokenPlanCookieHeader, + apiRegion: self.alibabaTokenPlanAPIRegion) } } diff --git a/Sources/CodexBar/Providers/Amp/AmpProviderImplementation.swift b/Sources/CodexBar/Providers/Amp/AmpProviderImplementation.swift index 25ca6c9326..7359c304c8 100644 --- a/Sources/CodexBar/Providers/Amp/AmpProviderImplementation.swift +++ b/Sources/CodexBar/Providers/Amp/AmpProviderImplementation.swift @@ -1,19 +1,24 @@ import AppKit import CodexBarCore -import CodexBarMacroSupport import Foundation import SwiftUI -@ProviderImplementationRegistration struct AmpProviderImplementation: ProviderImplementation { let id: UsageProvider = .amp @MainActor func observeSettings(_ settings: SettingsStore) { + _ = settings.ampUsageDataSource + _ = settings.ampAPIToken _ = settings.ampCookieSource _ = settings.ampCookieHeader } + @MainActor + func sourceMode(context: ProviderSourceModeContext) -> ProviderSourceMode { + context.settings.ampUsageDataSource + } + @MainActor func settingsSnapshot(context: ProviderSettingsSnapshotContext) -> ProviderSettingsSnapshotContribution? { .amp(context.settings.ampSettingsSnapshot(tokenOverride: context.tokenOverride)) @@ -21,6 +26,17 @@ struct AmpProviderImplementation: ProviderImplementation { @MainActor func settingsPickers(context: ProviderSettingsContext) -> [ProviderSettingsPickerDescriptor] { + let sourceBinding = Binding( + get: { context.settings.ampUsageDataSource.rawValue }, + set: { raw in + context.settings.ampUsageDataSource = ProviderSourceMode(rawValue: raw) ?? .auto + }) + let sourceOptions: [ProviderSettingsPickerOption] = [ + ProviderSettingsPickerOption(id: ProviderSourceMode.auto.rawValue, title: "Auto"), + ProviderSettingsPickerOption(id: ProviderSourceMode.cli.rawValue, title: "Amp CLI"), + ProviderSettingsPickerOption(id: ProviderSourceMode.api.rawValue, title: "Access token"), + ProviderSettingsPickerOption(id: ProviderSourceMode.web.rawValue, title: "Browser cookies"), + ] let cookieBinding = Binding( get: { context.settings.ampCookieSource.rawValue }, set: { raw in @@ -40,6 +56,14 @@ struct AmpProviderImplementation: ProviderImplementation { } return [ + ProviderSettingsPickerDescriptor( + id: "amp-usage-source", + title: "Usage source", + subtitle: "Auto tries the Amp CLI, access token, then browser cookies.", + binding: sourceBinding, + options: sourceOptions, + isVisible: nil, + onChange: nil), ProviderSettingsPickerDescriptor( id: "amp-cookie-source", title: "Cookie source", @@ -47,7 +71,10 @@ struct AmpProviderImplementation: ProviderImplementation { dynamicSubtitle: cookieSubtitle, binding: cookieBinding, options: cookieOptions, - isVisible: nil, + isVisible: { + context.settings.ampUsageDataSource == .auto || + context.settings.ampUsageDataSource == .web + }, onChange: nil), ] } @@ -55,6 +82,30 @@ struct AmpProviderImplementation: ProviderImplementation { @MainActor func settingsFields(context: ProviderSettingsContext) -> [ProviderSettingsFieldDescriptor] { [ + ProviderSettingsFieldDescriptor( + id: "amp-api-token", + title: "Access token", + subtitle: "Stored in ~/.codexbar/config.json. You can also set AMP_API_KEY.", + kind: .secure, + placeholder: "sgamp_...", + binding: context.stringBinding(\.ampAPIToken), + actions: [ + ProviderSettingsActionDescriptor( + id: "amp-open-access-tokens", + title: "Open Amp Access Tokens", + style: .link, + isVisible: nil, + perform: { + if let url = URL(string: "https://ampcode.com/settings") { + NSWorkspace.shared.open(url) + } + }), + ], + isVisible: { + context.settings.ampUsageDataSource == .auto || + context.settings.ampUsageDataSource == .api + }, + onActivate: { context.settings.ensureAmpAPITokenLoaded() }), ProviderSettingsFieldDescriptor( id: "amp-cookie", title: "", @@ -74,7 +125,11 @@ struct AmpProviderImplementation: ProviderImplementation { } }), ], - isVisible: { context.settings.ampCookieSource == .manual }, + isVisible: { + (context.settings.ampUsageDataSource == .auto || + context.settings.ampUsageDataSource == .web) && + context.settings.ampCookieSource == .manual + }, onActivate: { context.settings.ensureAmpCookieLoaded() }), ] } diff --git a/Sources/CodexBar/Providers/Amp/AmpSettingsStore.swift b/Sources/CodexBar/Providers/Amp/AmpSettingsStore.swift index a7b237ba18..e6fbd01192 100644 --- a/Sources/CodexBar/Providers/Amp/AmpSettingsStore.swift +++ b/Sources/CodexBar/Providers/Amp/AmpSettingsStore.swift @@ -2,6 +2,26 @@ import CodexBarCore import Foundation extension SettingsStore { + var ampUsageDataSource: ProviderSourceMode { + get { self.configSnapshot.providerConfig(for: .amp)?.source ?? .auto } + set { + self.updateProviderConfig(provider: .amp) { entry in + entry.source = newValue == .auto ? nil : newValue + } + self.logProviderModeChange(provider: .amp, field: "source", value: newValue.rawValue) + } + } + + var ampAPIToken: String { + get { self.configSnapshot.providerConfig(for: .amp)?.sanitizedAPIKey ?? "" } + set { + self.updateProviderConfig(provider: .amp) { entry in + entry.apiKey = self.normalizedConfigValue(newValue) + } + self.logSecretUpdate(provider: .amp, field: "apiKey", value: newValue) + } + } + var ampCookieHeader: String { get { self.configSnapshot.providerConfig(for: .amp)?.sanitizedCookieHeader ?? "" } set { @@ -22,41 +42,17 @@ extension SettingsStore { } } + func ensureAmpAPITokenLoaded() {} + func ensureAmpCookieLoaded() {} } extension SettingsStore { func ampSettingsSnapshot(tokenOverride: TokenAccountOverride?) -> ProviderSettingsSnapshot.AmpProviderSettings { - ProviderSettingsSnapshot.AmpProviderSettings( - cookieSource: self.ampSnapshotCookieSource(tokenOverride: tokenOverride), - manualCookieHeader: self.ampSnapshotCookieHeader(tokenOverride: tokenOverride)) - } - - private func ampSnapshotCookieHeader(tokenOverride: TokenAccountOverride?) -> String { - let fallback = self.ampCookieHeader - guard let support = TokenAccountSupportCatalog.support(for: .amp), - case .cookieHeader = support.injection - else { - return fallback - } - guard let account = ProviderTokenAccountSelection.selectedAccount( + self.resolvedCookieSettings( provider: .amp, - settings: self, - override: tokenOverride) - else { - return fallback - } - return TokenAccountSupportCatalog.normalizedCookieHeader(account.token, support: support) - } - - private func ampSnapshotCookieSource(tokenOverride: TokenAccountOverride?) -> ProviderCookieSource { - let fallback = self.ampCookieSource - guard let support = TokenAccountSupportCatalog.support(for: .amp), - support.requiresManualCookieSource - else { - return fallback - } - if self.tokenAccounts(for: .amp).isEmpty { return fallback } - return .manual + configuredSource: self.ampCookieSource, + configuredHeader: self.ampCookieHeader, + tokenOverride: tokenOverride) } } diff --git a/Sources/CodexBar/Providers/Antigravity/AntigravityLoginRunner.swift b/Sources/CodexBar/Providers/Antigravity/AntigravityLoginRunner.swift index 0ac446c876..9072f96ee6 100644 --- a/Sources/CodexBar/Providers/Antigravity/AntigravityLoginRunner.swift +++ b/Sources/CodexBar/Providers/Antigravity/AntigravityLoginRunner.swift @@ -376,10 +376,10 @@ private final class AntigravityLoopbackServer: @unchecked Sendable { private func httpResponse(for callback: AntigravityOAuthCallback) -> Data { let success = callback.error == nil && callback.code?.isEmpty == false let status = success ? "200 OK" : "400 Bad Request" - let title = success ? "Login Successful" : "Login Failed" + let title = success ? L("Login Successful") : L("Login Failed") let detail = success - ? "You can close this window and return to CodexBar." - : "You can close this window and try again." + ? L("You can close this window and return to CodexBar.") + : L("You can close this window and try again.") let html = """ diff --git a/Sources/CodexBar/Providers/Antigravity/AntigravityProviderImplementation.swift b/Sources/CodexBar/Providers/Antigravity/AntigravityProviderImplementation.swift index bb3b010708..35333c815a 100644 --- a/Sources/CodexBar/Providers/Antigravity/AntigravityProviderImplementation.swift +++ b/Sources/CodexBar/Providers/Antigravity/AntigravityProviderImplementation.swift @@ -1,9 +1,7 @@ import CodexBarCore -import CodexBarMacroSupport import Foundation import SwiftUI -@ProviderImplementationRegistration struct AntigravityProviderImplementation: ProviderImplementation { let id: UsageProvider = .antigravity let supportsLoginFlow: Bool = true @@ -11,6 +9,7 @@ struct AntigravityProviderImplementation: ProviderImplementation { @MainActor func observeSettings(_ settings: SettingsStore) { _ = settings.antigravityUsageDataSource + _ = settings.antigravityPrioritizeExhaustedQuotas _ = settings.tokenAccountsData(for: .antigravity) } @@ -28,6 +27,24 @@ struct AntigravityProviderImplementation: ProviderImplementation { } } + @MainActor + func settingsToggles(context: ProviderSettingsContext) -> [ProviderSettingsToggleDescriptor] { + [ + ProviderSettingsToggleDescriptor( + id: "antigravity-prioritize-exhausted-quotas", + title: "Prioritize exhausted quotas", + subtitle: "Optional. In Automatic mode, let exhausted five-hour or weekly lanes outrank " + + "still-usable model families. Applies to the menu bar and Overview ranking.", + binding: context.boolBinding(\.antigravityPrioritizeExhaustedQuotas), + statusText: nil, + actions: [], + isVisible: nil, + onChange: nil, + onAppDidBecomeActive: nil, + onAppearWhenEnabled: nil), + ] + } + @MainActor func settingsPickers(context: ProviderSettingsContext) -> [ProviderSettingsPickerDescriptor] { let usageBinding = Binding( @@ -42,7 +59,8 @@ struct AntigravityProviderImplementation: ProviderImplementation { ProviderSettingsPickerDescriptor( id: "antigravity-usage-source", title: "Usage source", - subtitle: "Auto uses the local IDE API first, then Google OAuth when the IDE is closed.", + subtitle: "Auto tries Antigravity app, agy CLI, then IDE; " + + "OAuth follows for selected or signed-in accounts.", binding: usageBinding, options: usageOptions, isVisible: nil, diff --git a/Sources/CodexBar/Providers/Antigravity/AntigravitySettingsStore.swift b/Sources/CodexBar/Providers/Antigravity/AntigravitySettingsStore.swift index f32a9443de..ad301c40b3 100644 --- a/Sources/CodexBar/Providers/Antigravity/AntigravitySettingsStore.swift +++ b/Sources/CodexBar/Providers/Antigravity/AntigravitySettingsStore.swift @@ -2,6 +2,21 @@ import CodexBarCore import Foundation extension SettingsStore { + var antigravityPrioritizeExhaustedQuotas: Bool { + get { + self.configSnapshot.providerConfig(for: .antigravity)?.antigravityPrioritizeExhaustedQuotas ?? false + } + set { + self.updateProviderConfig(provider: .antigravity) { entry in + entry.antigravityPrioritizeExhaustedQuotas = newValue + } + self.logProviderModeChange( + provider: .antigravity, + field: "prioritizeExhaustedQuotas", + value: "\(newValue)") + } + } + var antigravityUsageDataSource: AntigravityUsageDataSource { get { let source = self.configSnapshot.providerConfig(for: .antigravity)?.source diff --git a/Sources/CodexBar/Providers/Augment/AugmentProviderImplementation.swift b/Sources/CodexBar/Providers/Augment/AugmentProviderImplementation.swift index bad4a58a00..982c42944b 100644 --- a/Sources/CodexBar/Providers/Augment/AugmentProviderImplementation.swift +++ b/Sources/CodexBar/Providers/Augment/AugmentProviderImplementation.swift @@ -1,10 +1,8 @@ import AppKit import CodexBarCore -import CodexBarMacroSupport import Foundation import SwiftUI -@ProviderImplementationRegistration struct AugmentProviderImplementation: ProviderImplementation { let id: UsageProvider = .augment @@ -68,9 +66,7 @@ struct AugmentProviderImplementation: ProviderImplementation { isVisible: nil, onChange: nil, trailingText: { - guard let entry = CookieHeaderCache.load(provider: .augment) else { return nil } - let when = entry.storedAt.relativeDescription() - return "Cached: \(entry.sourceLabel) • \(when)" + ProviderCookieSourceUI.cachedTrailingText(provider: .augment) }), ] } diff --git a/Sources/CodexBar/Providers/Augment/AugmentProviderRuntime.swift b/Sources/CodexBar/Providers/Augment/AugmentProviderRuntime.swift index b2593c546d..173822d1f2 100644 --- a/Sources/CodexBar/Providers/Augment/AugmentProviderRuntime.swift +++ b/Sources/CodexBar/Providers/Augment/AugmentProviderRuntime.swift @@ -5,6 +5,12 @@ import Foundation final class AugmentProviderRuntime: ProviderRuntime { let id: UsageProvider = .augment private var keepalive: AugmentSessionKeepalive? + #if DEBUG + private(set) var _test_keepaliveStopCount = 0 + var _test_isKeepaliveRunning: Bool { + self.keepalive != nil + } + #endif func start(context: ProviderRuntimeContext) { self.updateKeepalive(context: context) @@ -83,8 +89,12 @@ final class AugmentProviderRuntime: ProviderRuntime { private func stopKeepalive(context: ProviderRuntimeContext, reason: String) { #if os(macOS) - self.keepalive?.stop() + guard let keepalive = self.keepalive else { return } + keepalive.stop() self.keepalive = nil + #if DEBUG + self._test_keepaliveStopCount += 1 + #endif context.store.augmentLogger.info("Augment keepalive stopped (\(reason))") #endif } @@ -92,6 +102,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 +116,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/Augment/AugmentSettingsStore.swift b/Sources/CodexBar/Providers/Augment/AugmentSettingsStore.swift index c0b3bc4617..4e500616ce 100644 --- a/Sources/CodexBar/Providers/Augment/AugmentSettingsStore.swift +++ b/Sources/CodexBar/Providers/Augment/AugmentSettingsStore.swift @@ -28,36 +28,10 @@ extension SettingsStore { extension SettingsStore { func augmentSettingsSnapshot(tokenOverride: TokenAccountOverride?) -> ProviderSettingsSnapshot .AugmentProviderSettings { - ProviderSettingsSnapshot.AugmentProviderSettings( - cookieSource: self.augmentSnapshotCookieSource(tokenOverride: tokenOverride), - manualCookieHeader: self.augmentSnapshotCookieHeader(tokenOverride: tokenOverride)) - } - - private func augmentSnapshotCookieHeader(tokenOverride: TokenAccountOverride?) -> String { - let fallback = self.augmentCookieHeader - guard let support = TokenAccountSupportCatalog.support(for: .augment), - case .cookieHeader = support.injection - else { - return fallback - } - guard let account = ProviderTokenAccountSelection.selectedAccount( + self.resolvedCookieSettings( provider: .augment, - settings: self, - override: tokenOverride) - else { - return fallback - } - return TokenAccountSupportCatalog.normalizedCookieHeader(account.token, support: support) - } - - private func augmentSnapshotCookieSource(tokenOverride: TokenAccountOverride?) -> ProviderCookieSource { - let fallback = self.augmentCookieSource - guard let support = TokenAccountSupportCatalog.support(for: .augment), - support.requiresManualCookieSource - else { - return fallback - } - if self.tokenAccounts(for: .augment).isEmpty { return fallback } - return .manual + configuredSource: self.augmentCookieSource, + configuredHeader: self.augmentCookieHeader, + tokenOverride: tokenOverride) } } diff --git a/Sources/CodexBar/Providers/AzureOpenAI/AzureOpenAIProviderImplementation.swift b/Sources/CodexBar/Providers/AzureOpenAI/AzureOpenAIProviderImplementation.swift index dce3a24f90..9b8c6f3e0a 100644 --- a/Sources/CodexBar/Providers/AzureOpenAI/AzureOpenAIProviderImplementation.swift +++ b/Sources/CodexBar/Providers/AzureOpenAI/AzureOpenAIProviderImplementation.swift @@ -1,8 +1,6 @@ import CodexBarCore -import CodexBarMacroSupport import Foundation -@ProviderImplementationRegistration struct AzureOpenAIProviderImplementation: ProviderImplementation { let id: UsageProvider = .azureopenai @@ -22,12 +20,12 @@ struct AzureOpenAIProviderImplementation: ProviderImplementation { func isAvailable(context: ProviderAvailabilityContext) -> Bool { let environment = context.environment let hasEnvironmentConfig = AzureOpenAISettingsReader.apiKey(environment: environment) != nil && - AzureOpenAISettingsReader.endpoint(environment: environment) != nil && + AzureOpenAISettingsReader.rawEndpoint(environment: environment) != nil && AzureOpenAISettingsReader.deploymentName(environment: environment) != nil if hasEnvironmentConfig { return true } return !context.settings.azureOpenAIAPIKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && - AzureOpenAISettingsReader.endpointURL(from: context.settings.azureOpenAIEndpoint) != nil && + !context.settings.azureOpenAIEndpoint.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && !context.settings.azureOpenAIDeploymentName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } diff --git a/Sources/CodexBar/Providers/Bedrock/BedrockProviderImplementation.swift b/Sources/CodexBar/Providers/Bedrock/BedrockProviderImplementation.swift index ecca9e1102..8cdc396fee 100644 --- a/Sources/CodexBar/Providers/Bedrock/BedrockProviderImplementation.swift +++ b/Sources/CodexBar/Providers/Bedrock/BedrockProviderImplementation.swift @@ -1,10 +1,8 @@ import AppKit import CodexBarCore -import CodexBarMacroSupport import Foundation import SwiftUI -@ProviderImplementationRegistration struct BedrockProviderImplementation: ProviderImplementation { let id: UsageProvider = .bedrock diff --git a/Sources/CodexBar/Providers/Chutes/ChutesProviderImplementation.swift b/Sources/CodexBar/Providers/Chutes/ChutesProviderImplementation.swift new file mode 100644 index 0000000000..485392785c --- /dev/null +++ b/Sources/CodexBar/Providers/Chutes/ChutesProviderImplementation.swift @@ -0,0 +1,40 @@ +import CodexBarCore +import Foundation + +struct ChutesProviderImplementation: ProviderImplementation { + let id: UsageProvider = .chutes + + @MainActor + func presentation(context _: ProviderPresentationContext) -> ProviderPresentation { + ProviderPresentation { _ in "api" } + } + + @MainActor + func observeSettings(_ settings: SettingsStore) { + _ = settings.chutesAPIKey + } + + @MainActor + func isAvailable(context: ProviderAvailabilityContext) -> Bool { + if ChutesSettingsReader.apiKey(environment: context.environment) != nil { + return true + } + return !context.settings.chutesAPIKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + + @MainActor + func settingsFields(context: ProviderSettingsContext) -> [ProviderSettingsFieldDescriptor] { + [ + ProviderSettingsFieldDescriptor( + id: "chutes-api-key", + title: "API key", + subtitle: "Stored in ~/.codexbar/config.json. Paste a Chutes API key.", + kind: .secure, + placeholder: "chutes key...", + binding: context.stringBinding(\.chutesAPIKey), + actions: [], + isVisible: nil, + onActivate: nil), + ] + } +} diff --git a/Sources/CodexBar/Providers/Chutes/ChutesSettingsStore.swift b/Sources/CodexBar/Providers/Chutes/ChutesSettingsStore.swift new file mode 100644 index 0000000000..7c09486298 --- /dev/null +++ b/Sources/CodexBar/Providers/Chutes/ChutesSettingsStore.swift @@ -0,0 +1,16 @@ +import CodexBarCore +import Foundation + +extension SettingsStore { + var chutesAPIKey: String { + get { self.configSnapshot.providerConfig(for: .chutes)?.sanitizedAPIKey ?? "" } + set { + self.updateProviderConfig(provider: .chutes) { entry in + entry.apiKey = self.normalizedConfigValue(newValue) + } + self.logSecretUpdate(provider: .chutes, field: "apiKey", value: newValue) + } + } + + func ensureChutesAPIKeyLoaded() {} +} diff --git a/Sources/CodexBar/Providers/Claude/ClaudeLoginFlow.swift b/Sources/CodexBar/Providers/Claude/ClaudeLoginFlow.swift index 76002520ef..29241390d0 100644 --- a/Sources/CodexBar/Providers/Claude/ClaudeLoginFlow.swift +++ b/Sources/CodexBar/Providers/Claude/ClaudeLoginFlow.swift @@ -1,8 +1,20 @@ import CodexBarCore +import Foundation + +typealias ClaudeLoginFlowRunner = ( + _ timeout: TimeInterval, + _ onPhaseChange: @escaping @Sendable (ClaudeLoginRunner.Phase) -> Void) async -> ClaudeLoginRunner.Result @MainActor extension StatusItemController { func runClaudeLoginFlow() async -> Bool { + await self.runClaudeLoginFlow( + loginRunner: { timeout, onPhaseChange in + await ClaudeLoginRunner.run(timeout: timeout, onPhaseChange: onPhaseChange) + }) + } + + func runClaudeLoginFlow(loginRunner: ClaudeLoginFlowRunner) async -> Bool { let phaseHandler: @Sendable (ClaudeLoginRunner.Phase) -> Void = { [weak self] phase in Task { @MainActor in switch phase { @@ -11,7 +23,7 @@ extension StatusItemController { } } } - let result = await ClaudeLoginRunner.run(timeout: 120, onPhaseChange: phaseHandler) + let result = await loginRunner(120, phaseHandler) guard !Task.isCancelled else { return false } self.loginPhase = .idle self.presentClaudeLoginResult(result) @@ -21,7 +33,6 @@ extension StatusItemController { if case .success = result.outcome { let metadata = self.store.metadata(for: .claude) self.settings.setProviderEnabled(provider: .claude, metadata: metadata, enabled: true) - self.settings.claudeUsageDataSource = .oauth self.postLoginNotification(for: .claude) return true } diff --git a/Sources/CodexBar/Providers/Claude/ClaudeProviderImplementation.swift b/Sources/CodexBar/Providers/Claude/ClaudeProviderImplementation.swift index 9f7032edbe..94f75c5210 100644 --- a/Sources/CodexBar/Providers/Claude/ClaudeProviderImplementation.swift +++ b/Sources/CodexBar/Providers/Claude/ClaudeProviderImplementation.swift @@ -1,8 +1,6 @@ import CodexBarCore -import CodexBarMacroSupport import SwiftUI -@ProviderImplementationRegistration struct ClaudeProviderImplementation: ProviderImplementation { let id: UsageProvider = .claude let supportsLoginFlow: Bool = true @@ -27,6 +25,9 @@ struct ClaudeProviderImplementation: ProviderImplementation { _ = settings.claudeOAuthKeychainPromptMode _ = settings.claudeOAuthKeychainReadStrategy _ = settings.claudeWebExtrasEnabled + _ = settings.claudeSwapEnabled + _ = settings.claudeSwapShowSingleAccount + _ = settings.claudeSwapExecutablePath } @MainActor @@ -48,6 +49,10 @@ struct ClaudeProviderImplementation: ProviderImplementation { } } + func makeRuntime() -> (any ProviderRuntime)? { + ClaudeProviderRuntime() + } + @MainActor func defaultSourceLabel(context: ProviderSourceLabelContext) -> String? { context.settings.claudeUsageDataSource.rawValue @@ -69,7 +74,7 @@ struct ClaudeProviderImplementation: ProviderImplementation { let subtitle = if context.settings.debugDisableKeychainAccess { "Inactive while \"Disable Keychain access\" is enabled in Advanced." } else { - "Use /usr/bin/security to read Claude credentials and avoid CodexBar keychain prompts." + "Never allow Claude OAuth credential reads to show macOS Keychain prompts." } let promptFreeBinding = Binding( @@ -79,7 +84,29 @@ struct ClaudeProviderImplementation: ProviderImplementation { context.settings.claudeOAuthPromptFreeCredentialsEnabled = enabled }) + let claudeSwapBinding = Binding( + get: { context.settings.claudeSwapEnabled }, + set: { context.settings.claudeSwapEnabled = $0 }) + let claudeSwapShowSingleAccountBinding = Binding( + get: { context.settings.claudeSwapShowSingleAccount }, + set: { context.settings.claudeSwapShowSingleAccount = $0 }) + return [ + ProviderSettingsToggleDescriptor( + id: "claude-daily-routines-usage-visible", + title: "Show Daily Routines usage", + subtitle: [ + "Shows the Daily Routines quota row in the menu and provider preview.", + "Requires optional credits and extra usage in Display settings.", + ].joined(separator: " "), + binding: context.boolBinding(\.claudeDailyRoutinesUsageVisible), + statusText: nil, + actions: [], + isVisible: nil, + isEnabled: { context.settings.showOptionalCreditsAndExtraUsage }, + onChange: nil, + onAppDidBecomeActive: nil, + onAppearWhenEnabled: nil), ProviderSettingsToggleDescriptor( id: "claude-oauth-prompt-free-credentials", title: "Avoid Keychain prompts", @@ -88,12 +115,58 @@ struct ClaudeProviderImplementation: ProviderImplementation { statusText: nil, actions: [], isVisible: nil, + isEnabled: { !context.settings.debugDisableKeychainAccess }, + onChange: nil, + onAppDidBecomeActive: nil, + onAppearWhenEnabled: nil), + ProviderSettingsToggleDescriptor( + id: "claude-swap-accounts", + title: "Read accounts from claude-swap", + subtitle: "Shows usage and lets you switch accounts through `cswap`. " + + "Credentials stay managed by claude-swap; CodexBar never reads them.", + binding: claudeSwapBinding, + statusText: { Self.claudeSwapStatusText(store: context.store, settings: context.settings) }, + actions: [], + isVisible: nil, + isEnabled: nil, + onChange: nil, + onAppDidBecomeActive: nil, + onAppearWhenEnabled: nil), + ProviderSettingsToggleDescriptor( + id: "claude-swap-show-single-account", + title: "Show account card when only one account is available", + subtitle: "Prefer claude-swap over the ambient Claude account presentation.", + binding: claudeSwapShowSingleAccountBinding, + statusText: nil, + actions: [], + isVisible: { context.settings.claudeSwapEnabled }, + isEnabled: nil, onChange: nil, onAppDidBecomeActive: nil, onAppearWhenEnabled: nil), ] } + @MainActor + private static func claudeSwapStatusText(store: UsageStore, settings: SettingsStore) -> String? { + guard settings.claudeSwapEnabled else { return nil } + if settings.claudeSwapExecutablePath.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + return "Set the cswap executable path below." + } + var parts: [String] = [] + if let version = store.claudeSwapDetectedVersion { + parts.append("claude-swap \(version)") + } + if let error = store.claudeSwapLastError { + parts.append(error) + } else if let refreshedAt = store.claudeSwapLastRefreshAt { + let accounts = store.claudeSwapAccountSnapshots.count + let accountsText = accounts == 1 ? "1 account" : "\(accounts) accounts" + parts.append("\(accountsText), updated \(refreshedAt.relativeDescription())") + } + return parts.isEmpty ? nil : parts.joined(separator: " — ") + } + @MainActor func settingsPickers(context: ProviderSettingsContext) -> [ProviderSettingsPickerDescriptor] { let usageBinding = Binding( @@ -142,8 +215,7 @@ struct ClaudeProviderImplementation: ProviderImplementation { if context.settings.debugDisableKeychainAccess { return "Global Keychain access is disabled in Advanced, so this setting is currently inactive." } - return "Controls Claude OAuth Keychain prompts when the standard reader is active. Choosing " + - "\"Never prompt\" can make OAuth unavailable; use Web/CLI when needed." + return "Choosing \"Never prompt\" can make OAuth unavailable; use Web/CLI when needed." } return [ @@ -163,11 +235,11 @@ struct ClaudeProviderImplementation: ProviderImplementation { ProviderSettingsPickerDescriptor( id: "claude-keychain-prompt-policy", title: "Keychain prompt policy", - subtitle: "Applies only to the Security.framework OAuth keychain reader.", + subtitle: "Controls when Claude OAuth may ask macOS for Keychain access.", dynamicSubtitle: keychainPromptPolicySubtitle, binding: keychainPromptPolicyBinding, options: keychainPromptPolicyOptions, - isVisible: { context.settings.claudeOAuthKeychainReadStrategy == .securityFramework }, + isVisible: nil, isEnabled: { !context.settings.debugDisableKeychainAccess }, onChange: nil), ProviderSettingsPickerDescriptor( @@ -180,9 +252,7 @@ struct ClaudeProviderImplementation: ProviderImplementation { isVisible: nil, onChange: nil, trailingText: { - guard let entry = CookieHeaderCache.load(provider: .claude) else { return nil } - let when = entry.storedAt.relativeDescription() - return "Cached: \(entry.sourceLabel) • \(when)" + ProviderCookieSourceUI.cachedTrailingText(provider: .claude) }), ] } @@ -200,6 +270,16 @@ struct ClaudeProviderImplementation: ProviderImplementation { actions: [], isVisible: nil, onActivate: nil), + ProviderSettingsFieldDescriptor( + id: "claude-swap-executable-path", + title: "claude-swap executable", + subtitle: "Path to the cswap executable (github.com/realiti4/claude-swap).", + kind: .plain, + placeholder: "~/.local/bin/cswap", + binding: context.stringBinding(\.claudeSwapExecutablePath), + actions: [], + isVisible: { context.settings.claudeSwapEnabled }, + onActivate: nil), ] } @@ -218,9 +298,22 @@ struct ClaudeProviderImplementation: ProviderImplementation { context.settings.showOptionalCreditsAndExtraUsage, cost.currencyCode != "Quota" { - let used = UsageFormatter.currencyString(cost.used, currencyCode: cost.currencyCode) - let limit = UsageFormatter.currencyString(cost.limit, currencyCode: cost.currencyCode) - entries.append(.text(String(format: L("extra_usage_format"), used, limit), .primary)) + func formatCost(_ value: Double) -> String { + UsageFormatter.convertedCostString( + value, + preferredCurrency: context.settings.preferredCurrencyCode, + providerCurrency: cost.currencyCode) + } + if cost.limit > 0 { + let used = formatCost(cost.used) + let limit = formatCost(cost.limit) + entries.append(.text(String(format: L("extra_usage_format"), used, limit), .primary)) + } + if let balance = cost.balance { + let value = formatCost(balance) + let label = cost.limit > 0 ? L("Balance") : L("Credits") + entries.append(.text("\(label): \(value)", .primary)) + } } } @@ -228,8 +321,38 @@ struct ClaudeProviderImplementation: ProviderImplementation { func loginMenuAction(context: ProviderMenuLoginContext) -> (label: String, action: MenuDescriptor.MenuAction)? { - guard self.shouldOpenTerminalForOAuthError(store: context.store) else { return nil } - return ("Open Terminal", .openTerminal(command: "claude")) + if self.shouldOpenBrowserForWebSessionError(context: context) { + return ("Re-login at claude.ai", .loginToProvider(url: "https://claude.ai/")) + } + if self.shouldOpenTerminalForOAuthError(store: context.store) { + return ("Open Terminal", .openTerminal(command: "claude")) + } + guard !context.hasAccount else { return nil } + return (L("Sign in with Claude Code..."), .switchAccount(.claude)) + } + + @MainActor + private func shouldOpenBrowserForWebSessionError(context: ProviderMenuLoginContext) -> Bool { + let settings = context.settings.claudeSettingsSnapshot(tokenOverride: nil) + let source = settings.usageDataSource + guard source == .auto || source == .web, + settings.cookieSource == .auto, + let error = context.store.error(for: .claude) + else { return false } + + let sessionErrors = [ + ClaudeWebAPIFetcher.FetchError.unauthorized.localizedDescription, + ClaudeWebAPIFetcher.FetchError.noSessionKeyFound.localizedDescription, + ClaudeWebAPIFetcher.FetchError.invalidSessionKey.localizedDescription, + ] + if sessionErrors.contains(error) { + return true + } + + guard error == ProviderFetchError.noAvailableStrategy(.claude).localizedDescription else { return false } + return context.store.fetchAttempts(for: .claude).contains { + $0.strategyID == "claude.web" && !$0.wasAvailable + } } @MainActor diff --git a/Sources/CodexBar/Providers/Claude/ClaudeProviderRuntime.swift b/Sources/CodexBar/Providers/Claude/ClaudeProviderRuntime.swift new file mode 100644 index 0000000000..bddad8ac38 --- /dev/null +++ b/Sources/CodexBar/Providers/Claude/ClaudeProviderRuntime.swift @@ -0,0 +1,42 @@ +import CodexBarCore + +@MainActor +final class ClaudeProviderRuntime: ProviderRuntime { + let id: UsageProvider = .claude + private var lastSwapConfiguration: Configuration? + + func start(context: ProviderRuntimeContext) { + self.reconcileSwapConfiguration(context: context) + } + + func stop(context: ProviderRuntimeContext) { + self.lastSwapConfiguration = nil + context.store.clearClaudeSwapAccountState() + } + + func settingsDidChange(context: ProviderRuntimeContext) { + self.reconcileSwapConfiguration(context: context) + } + + private func reconcileSwapConfiguration(context: ProviderRuntimeContext) { + let configuration = Configuration( + providerEnabled: context.store.isEnabled(.claude), + enabled: context.settings.claudeSwapEnabled, + executablePath: context.settings.claudeSwapExecutablePath) + guard configuration != self.lastSwapConfiguration else { return } + self.lastSwapConfiguration = configuration + + // Cancel before clearing so an old executable can never repopulate the menu. + context.store.clearClaudeSwapAccountState() + guard configuration.providerEnabled, configuration.enabled, !configuration.executablePath.isEmpty else { + return + } + context.store.scheduleClaudeSwapAccountRefresh() + } + + private struct Configuration: Equatable { + let providerEnabled: Bool + let enabled: Bool + let executablePath: String + } +} diff --git a/Sources/CodexBar/Providers/Claude/ClaudeSettingsStore.swift b/Sources/CodexBar/Providers/Claude/ClaudeSettingsStore.swift index 0819b75033..3d67f07d20 100644 --- a/Sources/CodexBar/Providers/Claude/ClaudeSettingsStore.swift +++ b/Sources/CodexBar/Providers/Claude/ClaudeSettingsStore.swift @@ -56,6 +56,42 @@ extension SettingsStore { self.logSecretUpdate(provider: .claude, field: "apiKey", value: newValue) } } + + var claudeSwapEnabled: Bool { + get { self.configSnapshot.providerConfig(for: .claude)?.claudeSwapEnabled ?? false } + set { + self.updateProviderConfig(provider: .claude) { entry in + entry.claudeSwapEnabled = newValue + } + self.logProviderModeChange(provider: .claude, field: "claudeSwapEnabled", value: String(newValue)) + } + } + + var claudeSwapShowSingleAccount: Bool { + get { self.configSnapshot.providerConfig(for: .claude)?.claudeSwapShowSingleAccount ?? false } + set { + self.updateProviderConfig(provider: .claude) { entry in + entry.claudeSwapShowSingleAccount = newValue + } + self.logProviderModeChange( + provider: .claude, + field: "claudeSwapShowSingleAccount", + value: String(newValue)) + } + } + + var claudeSwapExecutablePath: String { + get { self.configSnapshot.providerConfig(for: .claude)?.sanitizedClaudeSwapExecutablePath ?? "" } + set { + self.updateProviderConfig(provider: .claude) { entry in + entry.claudeSwapExecutablePath = self.normalizedConfigValue(newValue) + } + self.logProviderModeChange( + provider: .claude, + field: "claudeSwapExecutablePath", + value: newValue.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? "cleared" : "set") + } + } } extension SettingsStore { diff --git a/Sources/CodexBar/Providers/Claude/UsageStore+ClaudeSwapRefresh.swift b/Sources/CodexBar/Providers/Claude/UsageStore+ClaudeSwapRefresh.swift new file mode 100644 index 0000000000..a2a827dc56 --- /dev/null +++ b/Sources/CodexBar/Providers/Claude/UsageStore+ClaudeSwapRefresh.swift @@ -0,0 +1,153 @@ +import CodexBarCore +import Foundation + +/// External credential transactions must run to completion; configuration changes hide their state but do not +/// cancel the subprocess halfway through a claude-swap transaction. +struct ClaudeSwapTransientState { + var lastError: String? + var lastErrorAccountID: ProviderAccountIdentity? + var switchingAccountID: ProviderAccountIdentity? + var task: Task? + var versionProbedPath: String? +} + +extension UsageStore { + /// True when the opt-in claude-swap adapter should run alongside the + /// ambient Claude refresh. Listing is read-only; explicit account activation + /// stays external-process-owned and never exposes credentials to CodexBar. + func shouldFetchClaudeSwapAccounts() -> Bool { + self.isEnabled(.claude) && self.settings.claudeSwapEnabled && + !self.settings.claudeSwapExecutablePath.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + + func clearClaudeSwapAccountState() { + let hadState = !self.claudeSwapAccountSnapshots.isEmpty || + self.claudeSwapLastRefreshAt != nil || self.claudeSwapLastError != nil || + self.claudeSwapTransientState.lastError != nil || + self.claudeSwapTransientState.lastErrorAccountID != nil || + self.claudeSwapTransientState.switchingAccountID != nil + self.claudeSwapRefreshTask?.cancel() + self.claudeSwapRefreshTask = nil + self.claudeSwapAccountSnapshots = [] + self.claudeSwapLastRefreshAt = nil + self.claudeSwapLastError = nil + self.claudeSwapTransientState.lastError = nil + self.claudeSwapTransientState.lastErrorAccountID = nil + self.claudeSwapTransientState.switchingAccountID = nil + if hadState { + self.claudeSwapRevision &+= 1 + } + } + + /// Runs the optional adapter independently so it cannot delay the ambient Claude card. + func scheduleClaudeSwapAccountRefresh(generation: UInt64? = nil) { + self.claudeSwapRefreshTask?.cancel() + guard self.shouldFetchClaudeSwapAccounts() else { + self.clearClaudeSwapAccountState() + return + } + + self.claudeSwapRefreshTask = Task { @MainActor [weak self] in + guard let self else { return } + await self.refreshClaudeSwapAccounts(generation: generation) + } + } + + func refreshClaudeSwapAccounts(generation: UInt64? = nil) async { + let executablePath = self.settings.claudeSwapExecutablePath + await self.probeClaudeSwapVersionIfNeeded(executablePath: executablePath) + + do { + let list = try await ClaudeSwapAccountReader.readAccountList(executablePath: executablePath) + let snapshots = ClaudeSwapAccountProjection.accountSnapshots(from: list) + guard self.isCurrentClaudeSwapRefresh(executablePath: executablePath, generation: generation) else { + return + } + self.claudeSwapAccountSnapshots = snapshots + self.claudeSwapLastRefreshAt = Date() + self.claudeSwapLastError = nil + self.claudeSwapRevision &+= 1 + } catch is CancellationError { + return + } catch { + guard self.isCurrentClaudeSwapRefresh(executablePath: executablePath, generation: generation) else { + return + } + // Retain the last successful snapshots as stale data; the settings + // pane surfaces the adapter error and last refresh time. + let message = (error as? LocalizedError)?.errorDescription + ?? error.localizedDescription + if self.claudeSwapLastError != message { + self.claudeSwapLastError = message + self.claudeSwapRevision &+= 1 + } + } + } + + /// Activates one account through the configured claude-swap executable. + /// The numeric slot comes from the already validated list payload; requests + /// are serialized so two credential transactions can never overlap. + func switchClaudeSwapAccount(_ accountID: ProviderAccountIdentity) { + guard self.claudeSwapTransientState.task == nil, + self.shouldFetchClaudeSwapAccounts(), + accountID.source == ClaudeSwapAccountProjection.sourceName, + let account = self.claudeSwapAccountSnapshots.first(where: { $0.id == accountID }), + account.canActivate, + let accountNumber = Int(accountID.opaqueID), + accountNumber > 0 + else { + return + } + + let executablePath = self.settings.claudeSwapExecutablePath + self.claudeSwapTransientState.switchingAccountID = accountID + self.claudeSwapTransientState.lastError = nil + self.claudeSwapTransientState.lastErrorAccountID = nil + self.claudeSwapRevision &+= 1 + + self.claudeSwapTransientState.task = Task { @MainActor [weak self] in + var switchError: String? + do { + _ = try await ClaudeSwapAccountReader.switchAccount( + executablePath: executablePath, + accountNumber: accountNumber) + } catch { + switchError = (error as? LocalizedError)?.errorDescription + ?? error.localizedDescription + } + + guard let self else { return } + if self.isCurrentClaudeSwapConfiguration(executablePath: executablePath) { + // Claude Code owns the ambient credential, so reconcile both + // the provider snapshot and the adapter's active-row marker. + await self.refreshProvider(.claude) + } + let configurationIsCurrent = self.isCurrentClaudeSwapConfiguration(executablePath: executablePath) + self.claudeSwapTransientState.task = nil + self.claudeSwapTransientState.switchingAccountID = nil + if configurationIsCurrent { + self.claudeSwapTransientState.lastError = switchError + self.claudeSwapTransientState.lastErrorAccountID = switchError == nil ? nil : accountID + } + self.claudeSwapRevision &+= 1 + } + } + + private func probeClaudeSwapVersionIfNeeded(executablePath: String) async { + guard self.claudeSwapTransientState.versionProbedPath != executablePath else { return } + let version = await ClaudeSwapAccountReader.readVersion(executablePath: executablePath) + guard self.isCurrentClaudeSwapConfiguration(executablePath: executablePath) else { return } + self.claudeSwapTransientState.versionProbedPath = executablePath + self.claudeSwapDetectedVersion = version + } + + private func isCurrentClaudeSwapRefresh(executablePath: String, generation: UInt64?) -> Bool { + self.isCurrentProviderRefreshGeneration(.claude, generation: generation) && + self.isCurrentClaudeSwapConfiguration(executablePath: executablePath) + } + + private func isCurrentClaudeSwapConfiguration(executablePath: String) -> Bool { + self.isEnabled(.claude) && self.settings.claudeSwapEnabled && + self.settings.claudeSwapExecutablePath == executablePath + } +} diff --git a/Sources/CodexBar/Providers/ClawRouter/ClawRouterProviderImplementation.swift b/Sources/CodexBar/Providers/ClawRouter/ClawRouterProviderImplementation.swift new file mode 100644 index 0000000000..bd164220a2 --- /dev/null +++ b/Sources/CodexBar/Providers/ClawRouter/ClawRouterProviderImplementation.swift @@ -0,0 +1,48 @@ +import CodexBarCore +import Foundation + +struct ClawRouterProviderImplementation: ProviderImplementation { + let id: UsageProvider = .clawrouter + + @MainActor + func presentation(context _: ProviderPresentationContext) -> ProviderPresentation { + ProviderPresentation { _ in "api" } + } + + @MainActor + func observeSettings(_ settings: SettingsStore) { + _ = settings.clawRouterAPIKey + _ = settings.clawRouterBaseURL + } + + @MainActor + func isAvailable(context: ProviderAvailabilityContext) -> Bool { + ProviderTokenResolver.clawRouterToken(environment: context.environment) != nil + } + + @MainActor + func settingsFields(context: ProviderSettingsContext) -> [ProviderSettingsFieldDescriptor] { + [ + ProviderSettingsFieldDescriptor( + id: "clawrouter-api-key", + title: "API key", + subtitle: "Stored in the CodexBar config file. Reads monthly budget and routed usage from /v1/usage.", + kind: .secure, + placeholder: "ClawRouter key…", + binding: context.stringBinding(\.clawRouterAPIKey), + actions: [], + isVisible: nil, + onActivate: nil), + ProviderSettingsFieldDescriptor( + id: "clawrouter-base-url", + title: "Base URL", + subtitle: "Optional. Defaults to the hosted ClawRouter service.", + kind: .plain, + placeholder: ClawRouterSettingsReader.defaultBaseURL.absoluteString, + binding: context.stringBinding(\.clawRouterBaseURL), + actions: [], + isVisible: nil, + onActivate: nil), + ] + } +} diff --git a/Sources/CodexBar/Providers/ClawRouter/ClawRouterSettingsStore.swift b/Sources/CodexBar/Providers/ClawRouter/ClawRouterSettingsStore.swift new file mode 100644 index 0000000000..bfbe412447 --- /dev/null +++ b/Sources/CodexBar/Providers/ClawRouter/ClawRouterSettingsStore.swift @@ -0,0 +1,23 @@ +import CodexBarCore +import Foundation + +extension SettingsStore { + var clawRouterAPIKey: String { + get { self.configSnapshot.providerConfig(for: .clawrouter)?.sanitizedAPIKey ?? "" } + set { + self.updateProviderConfig(provider: .clawrouter) { entry in + entry.apiKey = self.normalizedConfigValue(newValue) + } + self.logSecretUpdate(provider: .clawrouter, field: "apiKey", value: newValue) + } + } + + var clawRouterBaseURL: String { + get { self.configSnapshot.providerConfig(for: .clawrouter)?.sanitizedEnterpriseHost ?? "" } + set { + self.updateProviderConfig(provider: .clawrouter) { entry in + entry.enterpriseHost = self.normalizedConfigValue(newValue) + } + } + } +} diff --git a/Sources/CodexBar/Providers/ClinePass/ClinePassProviderImplementation.swift b/Sources/CodexBar/Providers/ClinePass/ClinePassProviderImplementation.swift new file mode 100644 index 0000000000..0abdd11345 --- /dev/null +++ b/Sources/CodexBar/Providers/ClinePass/ClinePassProviderImplementation.swift @@ -0,0 +1,40 @@ +import CodexBarCore +import Foundation + +struct ClinePassProviderImplementation: ProviderImplementation { + let id: UsageProvider = .clinepass + + @MainActor + func presentation(context _: ProviderPresentationContext) -> ProviderPresentation { + ProviderPresentation { _ in "api" } + } + + @MainActor + func observeSettings(_ settings: SettingsStore) { + _ = settings.clinePassAPIKey + } + + @MainActor + func isAvailable(context: ProviderAvailabilityContext) -> Bool { + if ClinePassSettingsReader.apiKey(environment: context.environment) != nil { + return true + } + return !context.settings.clinePassAPIKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + + @MainActor + func settingsFields(context: ProviderSettingsContext) -> [ProviderSettingsFieldDescriptor] { + [ + ProviderSettingsFieldDescriptor( + id: "clinepass-api-key", + title: "API key", + subtitle: "Stored in ~/.codexbar/config.json. Paste a ClinePass API key.", + kind: .secure, + placeholder: "ClinePass API key...", + binding: context.stringBinding(\.clinePassAPIKey), + actions: [], + isVisible: nil, + onActivate: nil), + ] + } +} diff --git a/Sources/CodexBar/Providers/ClinePass/ClinePassSettingsStore.swift b/Sources/CodexBar/Providers/ClinePass/ClinePassSettingsStore.swift new file mode 100644 index 0000000000..786b2cee99 --- /dev/null +++ b/Sources/CodexBar/Providers/ClinePass/ClinePassSettingsStore.swift @@ -0,0 +1,14 @@ +import CodexBarCore +import Foundation + +extension SettingsStore { + var clinePassAPIKey: String { + get { self.configSnapshot.providerConfig(for: .clinepass)?.sanitizedAPIKey ?? "" } + set { + self.updateProviderConfig(provider: .clinepass) { entry in + entry.apiKey = self.normalizedConfigValue(newValue) + } + self.logSecretUpdate(provider: .clinepass, field: "apiKey", value: newValue) + } + } +} diff --git a/Sources/CodexBar/Providers/Codebuff/CodebuffProviderImplementation.swift b/Sources/CodexBar/Providers/Codebuff/CodebuffProviderImplementation.swift index 6ca91443c9..be32da459e 100644 --- a/Sources/CodexBar/Providers/Codebuff/CodebuffProviderImplementation.swift +++ b/Sources/CodexBar/Providers/Codebuff/CodebuffProviderImplementation.swift @@ -1,9 +1,7 @@ import AppKit import CodexBarCore -import CodexBarMacroSupport import Foundation -@ProviderImplementationRegistration struct CodebuffProviderImplementation: ProviderImplementation { let id: UsageProvider = .codebuff diff --git a/Sources/CodexBar/Providers/Codex/CodexConsumerProjection.swift b/Sources/CodexBar/Providers/Codex/CodexConsumerProjection.swift index 287db61f4c..ea54df5c79 100644 --- a/Sources/CodexBar/Providers/Codex/CodexConsumerProjection.swift +++ b/Sources/CodexBar/Providers/Codex/CodexConsumerProjection.swift @@ -2,8 +2,9 @@ import CodexBarCore import Foundation struct CodexUIErrorMapper { - private static let codexCLINotSignedInMessage = - "Codex CLI is not signed in. Run `codex login --device-auth`, then refresh." + private static var codexCLINotSignedInMessage: String { + L("Codex CLI is not signed in. Run `codex login --device-auth`, then refresh.") + } static func userFacingMessage(_ raw: String?) -> String? { guard let raw, !raw.isEmpty else { return nil } @@ -20,7 +21,7 @@ struct CodexUIErrorMapper { } if self.looksCodexCLIMissing(lower: lower) { - return CodexStatusProbeError.codexNotInstalled.localizedDescription + return L("Codex CLI missing. Install via `npm i -g @openai/codex` (or bun install) and restart.") } if self.looksCodexCLILoginRequired(lower: lower) { @@ -28,24 +29,25 @@ struct CodexUIErrorMapper { } if self.looksExpired(lower: lower) { - return "Codex session expired. Sign in again." + return L("Codex session expired. Sign in again.") } if lower.contains("frame load interrupted") { - return "OpenAI web refresh was interrupted. Refresh OpenAI cookies and try again." + return L("OpenAI web refresh was interrupted. Refresh OpenAI cookies and try again.") } if self.looksOpenAIWebTimeout(lower: lower) { - return "OpenAI web refresh timed out. Refresh OpenAI cookies and try again." + return L("OpenAI web refresh timed out. Refresh OpenAI cookies and try again.") } if self.looksOpenAIWebNetworkError(lower: lower) { - return "OpenAI web refresh hit a network error. " - + "Check your connection, then refresh OpenAI cookies and try again." + return L( + "OpenAI web refresh hit a network error. " + + "Check your connection, then refresh OpenAI cookies and try again.") } if self.looksInternalTransport(lower: lower) { - return "Codex usage is temporarily unavailable. Try refreshing." + return L("Codex usage is temporarily unavailable. Try refreshing.") } return trimmed @@ -55,22 +57,49 @@ struct CodexUIErrorMapper { let cachedMarker = " Cached values from " guard let suffixRange = raw.range(of: cachedMarker) else { return nil } - let suffix = String(raw[suffixRange.lowerBound...]).trimmingCharacters(in: .whitespacesAndNewlines) + let rawPrefix = String(raw[.. String { + let droppedPrefix = if rawPrefix.lowercased().hasPrefix(prefix.lowercased()) { + String(rawPrefix.dropFirst(prefix.count)) + } else { + rawPrefix + } + var message = droppedPrefix.trimmingCharacters(in: .whitespacesAndNewlines) + if message.hasSuffix(".") { + message.removeLast() + } + return message + } + + private static func cachedStamp(raw: String, suffixRange: Range, marker: String) -> String { + let start = raw.index(suffixRange.lowerBound, offsetBy: marker.count) + var stamp = String(raw[start...]).trimmingCharacters(in: .whitespacesAndNewlines) + if stamp.hasSuffix(".") { + stamp.removeLast() + } + return stamp + } + private static func isAlreadyUserFacing(lower: String) -> Bool { lower.contains("openai cookies are for") || lower.contains("sign in to chatgpt.com") @@ -168,7 +197,7 @@ struct CodexConsumerProjection { let userFacingError: String? var remaining: Double? { - self.snapshot?.remaining + self.snapshot?.codexCreditLimit?.remaining ?? self.snapshot?.remaining } } @@ -209,6 +238,7 @@ struct CodexConsumerProjection { private let rateWindowsByLane: [RateLane: RateWindow] private let codeReviewRemainingPercent: Double? private let codeReviewLimit: RateWindow? + private let evaluationTime: Date static func make(surface: Surface, context: Context) -> CodexConsumerProjection { let allowsLiveAdjuncts = surface != .overrideCard @@ -261,20 +291,68 @@ struct CodexConsumerProjection { credits: creditsProjection, menuBarFallback: self.menuBarFallback( creditsRemaining: creditsProjection?.remaining, - rateWindowsByLane: rateWindowsByLane), + rateWindowsByLane: rateWindowsByLane, + evaluationTime: context.now), userFacingErrors: userFacingErrors, canShowBuyCredits: canShowBuyCredits, hasUsageBreakdown: hasUsageBreakdown, hasCreditsHistory: hasCreditsHistory, rateWindowsByLane: rateWindowsByLane, codeReviewRemainingPercent: dashboardVisibility == .attached ? dashboard?.codeReviewRemainingPercent : nil, - codeReviewLimit: dashboardVisibility == .attached ? dashboard?.codeReviewLimit : nil) + codeReviewLimit: dashboardVisibility == .attached ? dashboard?.codeReviewLimit : nil, + evaluationTime: context.now) } func rateWindow(for lane: RateLane) -> RateWindow? { + guard let window = self.rateWindowsByLane[lane] else { return nil } + switch lane { + case .session: + return Self.sessionDisplayWindow( + session: window, + weekly: self.rateWindowsByLane[.weekly], + evaluationTime: self.evaluationTime) + case .weekly: + return window + } + } + + func sourceRateWindow(for lane: RateLane) -> RateWindow? { self.rateWindowsByLane[lane] } + static func sourceRateWindow(for lane: RateLane, snapshot: UsageSnapshot?) -> RateWindow? { + self.rateWindowsByLane(snapshot: snapshot)[lane] + } + + func menuBarSelectableRateWindow(for lane: RateLane) -> RateWindow? { + guard let window = self.rateWindow(for: lane) else { return nil } + guard window.remainingPercent <= 0, + let resetAt = window.resetsAt, + resetAt <= self.evaluationTime + else { + return window + } + return nil + } + + var nextMenuBarStateChangeAt: Date? { + self.rateWindowsByLane.values.compactMap { window in + guard window.remainingPercent <= 0, + let resetAt = window.resetsAt, + resetAt > self.evaluationTime + else { + return nil + } + return resetAt + }.min() + } + + var hasBindingWeeklyCap: Bool { + Self.weeklyCapsSession( + weekly: self.rateWindowsByLane[.weekly], + evaluationTime: self.evaluationTime) + } + func remainingPercent(for metric: SupplementalMetric) -> Double? { switch metric { case .codeReview: @@ -370,18 +448,72 @@ struct CodexConsumerProjection { return (lane, window) } + /// When Codex's weekly lane is exhausted, it is the binding cap: session quota cannot be used until + /// the weekly window resets, even if the API still reports room in the 5-hour bucket. + private static func weeklyCapsSession(weekly: RateWindow?, evaluationTime: Date) -> Bool { + guard let weekly else { return false } + guard weekly.remainingPercent <= 0 else { return false } + return weekly.resetsAt.map { $0 > evaluationTime } ?? true + } + + private static func sessionDisplayWindow( + session: RateWindow, + weekly: RateWindow?, + evaluationTime: Date) -> RateWindow + { + guard self.weeklyCapsSession(weekly: weekly, evaluationTime: evaluationTime) else { + return session + } + let reset = self.bindingReset( + session: session, + weekly: weekly, + evaluationTime: evaluationTime) + return RateWindow( + usedPercent: max(session.usedPercent, 100), + windowMinutes: session.windowMinutes, + resetsAt: reset.date, + resetDescription: reset.description, + nextRegenPercent: session.nextRegenPercent, + isSyntheticPlaceholder: session.isSyntheticPlaceholder) + } + + private static func bindingReset( + session: RateWindow, + weekly: RateWindow?, + evaluationTime: Date) -> (date: Date?, description: String?) + { + guard let weekly else { return (nil, nil) } + let sessionIsExhausted = session.remainingPercent <= 0 && + (session.resetsAt.map { $0 > evaluationTime } ?? true) + guard sessionIsExhausted else { + return (weekly.resetsAt, weekly.resetDescription) + } + guard let sessionReset = session.resetsAt, let weeklyReset = weekly.resetsAt else { + return (nil, nil) + } + if sessionReset > weeklyReset { + return (sessionReset, session.resetDescription) + } + return (weeklyReset, weekly.resetDescription) + } + private static func menuBarFallback( creditsRemaining: Double?, - rateWindowsByLane: [RateLane: RateWindow]) -> MenuBarFallback + rateWindowsByLane: [RateLane: RateWindow], + evaluationTime: Date) -> MenuBarFallback { guard let creditsRemaining, creditsRemaining > 0 else { return .none } - let hasExhaustedLane = rateWindowsByLane.values.contains { $0.remainingPercent <= 0 } + let hasExhaustedLane = rateWindowsByLane.values.contains { + $0.remainingPercent <= 0 && ($0.resetsAt.map { $0 > evaluationTime } ?? true) + } let hasNoRateWindows = rateWindowsByLane.isEmpty return (hasExhaustedLane || hasNoRateWindows) ? .creditsBalance : .none } var hasExhaustedRateLane: Bool { - self.rateWindowsByLane.values.contains { $0.remainingPercent <= 0 } + self.rateWindowsByLane.values.contains { + $0.remainingPercent <= 0 && ($0.resetsAt.map { $0 > self.evaluationTime } ?? true) + } } } @@ -430,4 +562,37 @@ extension UsageStore { guard projection.menuBarFallback == .creditsBalance else { return nil } return projection.credits?.remaining } + + func codexMenuBarMetricWindow(snapshot: UsageSnapshot, now: Date = Date()) -> RateWindow? { + let projection = self.codexConsumerProjection( + surface: .menuBar, + snapshotOverride: snapshot, + now: now) + let windows = projection.visibleRateLanes.compactMap { + projection.menuBarSelectableRateWindow(for: $0) + } + let first = windows.first + let second = windows.dropFirst().first + + switch self.settings.menuBarMetricPreference(for: .codex, snapshot: snapshot) { + case .secondary, .tertiary: + return second ?? first + case .extraUsage: + return first + case .average: + guard self.settings.menuBarMetricSupportsAverage(for: .codex), + let primary = first, + let secondary = second + else { + return first + } + let usedPercent = (primary.usedPercent + secondary.usedPercent) / 2 + return RateWindow( + usedPercent: usedPercent, windowMinutes: nil, resetsAt: nil, resetDescription: nil) + case .primaryAndSecondary: + return windows.prefix(2).max(by: { $0.usedPercent < $1.usedPercent }) + case .automatic, .primary, .monthlyPlan: + return first + } + } } diff --git a/Sources/CodexBar/Providers/Codex/CodexLimitResetOwnerKey.swift b/Sources/CodexBar/Providers/Codex/CodexLimitResetOwnerKey.swift new file mode 100644 index 0000000000..25a2357209 --- /dev/null +++ b/Sources/CodexBar/Providers/Codex/CodexLimitResetOwnerKey.swift @@ -0,0 +1,84 @@ +import CodexBarCore +import CryptoKit +import Foundation + +struct CodexLimitResetOwnerKey: Equatable, Hashable, Sendable { + let rawValue: String + + init?(identity: CodexIdentity, accountEmail: String?) { + guard case let .providerAccount(id) = identity, + let normalizedID = CodexOpenAIWorkspaceResolver.normalizeWorkspaceAccountID(id), + let normalizedEmail = CodexIdentityResolver.normalizeEmail(accountEmail) + else { + return nil + } + let input = "codex-limit-reset-owner:v2\0\(normalizedID)\0\(normalizedEmail)" + let digest = SHA256.hash(data: Data(input.utf8)) + self.rawValue = digest.map { String(format: "%02x", $0) }.joined() + } +} + +struct CodexSessionQuotaOwnerKey: Equatable, Sendable { + let rawValue: String + + init?(refreshGuard: CodexAccountScopedRefreshGuard) { + let input: String + switch refreshGuard.identity { + case let .providerAccount(id): + guard let normalizedID = CodexOpenAIWorkspaceResolver.normalizeWorkspaceAccountID(id), + let normalizedEmail = CodexIdentityResolver.normalizeEmail(refreshGuard.accountKey) + else { + return nil + } + input = "codex-session-quota-owner:v1\0provider\0\(normalizedID)\0\(normalizedEmail)" + case let .emailOnly(normalizedEmail): + guard let email = CodexIdentityResolver.normalizeEmail(normalizedEmail) else { return nil } + if let accountKey = CodexIdentityResolver.normalizeEmail(refreshGuard.accountKey), accountKey != email { + return nil + } + guard let sourceKey = Self.sourceKey(refreshGuard.source) else { return nil } + // Email-only auth cannot distinguish same-email workspaces. Include the credential fingerprint + // and deliberately establish a new baseline after rotation rather than risk a cross-account alert. + guard let fingerprint = CodexAuthFingerprint.normalize(refreshGuard.authFingerprint) else { return nil } + input = "codex-session-quota-owner:v1\0email\0\(sourceKey)\0\(email)\0\(fingerprint)" + case .unresolved: + return nil + } + let digest = SHA256.hash(data: Data(input.utf8)) + self.rawValue = digest.map { String(format: "%02x", $0) }.joined() + } + + private static func sourceKey(_ source: CodexActiveSource) -> String? { + switch source { + case .liveSystem: + "live-system" + case let .managedAccount(id): + "managed:\(id.uuidString.lowercased())" + case let .profileHome(path): + CodexHomeScope.normalizedHomePath(path).map { "profile:\($0)" } + } + } +} + +extension UsageStore { + func codexLimitResetOwnerKey( + expectedGuard: CodexAccountScopedRefreshGuard, + visibleAccounts _: [CodexVisibleAccount]) -> CodexLimitResetOwnerKey? + { + CodexLimitResetOwnerKey( + identity: expectedGuard.identity, + accountEmail: expectedGuard.accountKey) + } + + func codexLimitResetOwnerKey( + forVisibleAccount account: CodexVisibleAccount, + visibleAccounts _: [CodexVisibleAccount]) -> CodexLimitResetOwnerKey? + { + guard let workspaceAccountID = CodexOpenAIWorkspaceResolver.normalizeWorkspaceAccountID( + account.workspaceAccountID) + else { return nil } + return CodexLimitResetOwnerKey( + identity: .providerAccount(id: workspaceAccountID), + accountEmail: account.email) + } +} diff --git a/Sources/CodexBar/Providers/Codex/CodexProviderImplementation.swift b/Sources/CodexBar/Providers/Codex/CodexProviderImplementation.swift index 79e3548efa..5744287c09 100644 --- a/Sources/CodexBar/Providers/Codex/CodexProviderImplementation.swift +++ b/Sources/CodexBar/Providers/Codex/CodexProviderImplementation.swift @@ -1,9 +1,7 @@ import CodexBarCore -import CodexBarMacroSupport import Foundation import SwiftUI -@ProviderImplementationRegistration struct CodexProviderImplementation: ProviderImplementation { let id: UsageProvider = .codex let supportsLoginFlow: Bool = true @@ -72,12 +70,32 @@ struct CodexProviderImplementation: ProviderImplementation { } }) let batterySaverBinding = context.boolBinding(\.openAIWebBatterySaverEnabled) + let historicalTrackingSubtitle = [ + L("Stores local Codex usage history (8 weeks) to personalize Pace predictions."), + "[\(L("weekly_progress_work_days_title")) = \(L("Automatic"))]", + ].joined(separator: " ") return [ + ProviderSettingsToggleDescriptor( + id: "codex-local-session-cost-ledger", + title: "Local session cost estimates", + subtitle: [ + "Uses this Mac's Codex sessions instead of the selected managed account's session history.", + "Works with organization API keys and does not require OpenAI billing or administrator access.", + "Uses locally cached or bundled model prices without making a network request.", + "This provider-specific toggle does not enable cost summaries for other providers.", + ].joined(separator: " "), + binding: context.boolBinding(\.codexLocalSessionCostLedgerEnabled), + statusText: nil, + actions: [], + isVisible: nil, + onChange: nil, + onAppDidBecomeActive: nil, + onAppearWhenEnabled: nil), ProviderSettingsToggleDescriptor( id: "codex-historical-tracking", title: "Historical tracking", - subtitle: "Stores local Codex usage history (8 weeks) to personalize Pace predictions.", + subtitle: historicalTrackingSubtitle, binding: context.boolBinding(\.historicalTrackingEnabled), statusText: nil, actions: [], @@ -85,6 +103,21 @@ struct CodexProviderImplementation: ProviderImplementation { onChange: nil, onAppDidBecomeActive: nil, onAppearWhenEnabled: nil), + ProviderSettingsToggleDescriptor( + id: "codex-spark-usage-visible", + title: "Show Codex Spark usage", + subtitle: [ + "Shows Codex Spark quota rows in the menu and provider preview.", + "Requires optional credits and extra usage in Display settings.", + ].joined(separator: " "), + binding: context.boolBinding(\.codexSparkUsageVisible), + statusText: nil, + actions: [], + isVisible: nil, + isEnabled: { context.settings.showOptionalCreditsAndExtraUsage }, + onChange: nil, + onAppDidBecomeActive: nil, + onAppearWhenEnabled: nil), ProviderSettingsToggleDescriptor( id: "codex-openai-web-extras", title: "OpenAI web extras", @@ -148,8 +181,11 @@ struct CodexProviderImplementation: ProviderImplementation { return [ ProviderSettingsPickerDescriptor( id: "codex-usage-source", - title: "Usage source", - subtitle: "Auto falls back to the next source if the preferred one fails.", + title: "Quota usage source", + subtitle: [ + "Controls live session and weekly quota fetching only.", + "Local session cost estimates work independently.", + ].joined(separator: " "), binding: usageBinding, options: usageOptions, isVisible: nil, @@ -169,9 +205,7 @@ struct CodexProviderImplementation: ProviderImplementation { isVisible: { context.settings.openAIWebAccessEnabled }, onChange: nil, trailingText: { - guard let entry = CookieHeaderCache.load(provider: .codex) else { return nil } - let when = entry.storedAt.relativeDescription() - return "Cached: \(entry.sourceLabel) • \(when)" + ProviderCookieSourceUI.cachedTrailingText(provider: .codex) }), ] } @@ -201,9 +235,19 @@ struct CodexProviderImplementation: ProviderImplementation { else { return } if let credits = context.store.credits { + let remaining = credits.codexCreditLimit?.remaining ?? credits.remaining entries.append(.text( - String(format: L("credits_remaining"), UsageFormatter.creditsString(from: credits.remaining)), + String(format: L("credits_remaining"), UsageFormatter.creditsString(from: remaining)), .primary)) + if let limit = credits.codexCreditLimit { + var parts = [ + L("%@ used", UsageFormatter.creditsNumberString(from: limit.used)), + ] + if let resetsAt = limit.resetsAt { + parts.append(L("resets %@", UsageFormatter.resetDescription(from: resetsAt))) + } + entries.append(.text(parts.joined(separator: " · "), .secondary)) + } if let latest = credits.events.first { entries.append(.text( String(format: L("last_spend"), UsageFormatter.creditEventSummary(latest)), diff --git a/Sources/CodexBar/Providers/Codex/CodexSettingsStore.swift b/Sources/CodexBar/Providers/Codex/CodexSettingsStore.swift index 8bfb331fe4..79ffbf1a95 100644 --- a/Sources/CodexBar/Providers/Codex/CodexSettingsStore.swift +++ b/Sources/CodexBar/Providers/Codex/CodexSettingsStore.swift @@ -18,6 +18,16 @@ extension SettingsStore { .path } + private static func normalizedCodexProfileHomePaths(_ paths: [String]?) -> [String] { + var seen: Set = [] + var result: [String] = [] + for path in (paths ?? []).compactMap({ CodexHomeScope.normalizedHomePath($0) }) { + guard seen.insert(path).inserted else { continue } + result.append(path) + } + return result + } + private func loadManagedCodexAccounts() throws -> ManagedCodexAccountSet { #if DEBUG if CodexManagedRemoteHomeTestingOverride.isUnreadable(for: self) { @@ -70,6 +80,35 @@ extension SettingsStore { self.managedCodexRemoteHomePath(forActiveSource: self.codexResolvedActiveSource) } + func liveSystemCodexHomePath(forActiveSource source: CodexActiveSource) -> String? { + guard source == .liveSystem else { + return nil + } + let path = self.codexAccountReconciliationSnapshot(activeSourceOverride: source) + .liveSystemAccount?.codexHomePath.trimmingCharacters(in: .whitespacesAndNewlines) + guard let path, !path.isEmpty else { + return nil + } + return path + } + + var codexProfileHomePaths: [String] { + Self.normalizedCodexProfileHomePaths( + self.configSnapshot.providerConfig(for: .codex)?.codexProfileHomePaths) + } + + func profileCodexHomePath(forActiveSource source: CodexActiveSource) -> String? { + guard case let .profileHome(path) = source else { + return nil + } + guard let normalizedPath = CodexHomeScope.normalizedHomePath(path), + self.codexProfileHomePaths.contains(normalizedPath) + else { + return nil + } + return normalizedPath + } + func managedCodexRemoteHomePath(forActiveSource source: CodexActiveSource) -> String? { guard case let .managedAccount(id) = source else { return nil @@ -128,6 +167,8 @@ extension SettingsStore { self.codexPersistedActiveSource } set { + self.invalidateCodexAccountReconciliationSnapshotCache() + self.cachedCodexAccountMenuProjection = nil self.updateProviderConfig(provider: .codex) { entry in entry.codexActiveSource = newValue } @@ -150,6 +191,13 @@ extension SettingsStore { return true } + @discardableResult + func refreshCodexAccountReconciliationAfterManagedAccountsDidChange() -> Bool { + self.invalidateCodexAccountReconciliationSnapshotCache() + self.cachedCodexAccountMenuProjection = nil + return self.persistResolvedCodexActiveSourceCorrectionIfNeeded() + } + var codexCookieHeader: String { get { self.configSnapshot.providerConfig(for: .codex)?.sanitizedCookieHeader ?? "" } set { @@ -180,6 +228,20 @@ 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 + self.codexAccountReconciliationGeneration &+= 1 + } + var codexAccountReconciliationSnapshot: CodexAccountReconciliationSnapshot { self.codexAccountReconciliationSnapshot(activeSourceOverride: nil) } @@ -187,9 +249,93 @@ 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.codexAccountSnapshotLoader(activeSource: activeSource)() + let loadedAt = Date() + if cacheInterval > 0 { + self.cachedCodexAccountReconciliationSnapshot = CachedCodexAccountReconciliationSnapshot( + activeSource: activeSource, + loadedAt: loadedAt, + snapshot: snapshot) + } + if activeSource == self.codexPersistedActiveSource { + self.cachedCodexAccountMenuProjection = CachedCodexAccountMenuProjection( + activeSource: activeSource, + loadedAt: loadedAt, + projection: CodexVisibleAccountProjection.make(from: snapshot)) + } + return snapshot + } + + /// Menu rendering must stay side-effect free: no `auth.json` reads, JWT parsing, or fingerprint hashing. + var codexVisibleAccountProjectionForMenuDisplay: CodexVisibleAccountProjection? { + let activeSource = self.codexPersistedActiveSource + guard let cached = self.cachedCodexAccountMenuProjection, + cached.activeSource == activeSource + else { + return nil + } + return cached.projection + } + + var codexAccountMenuProjectionNeedsRevalidation: Bool { + let activeSource = self.codexPersistedActiveSource + guard let cached = self.cachedCodexAccountMenuProjection, + cached.activeSource == activeSource + else { + return true + } + return Date().timeIntervalSince(cached.loadedAt) >= Self.codexAccountReconciliationSnapshotCacheInterval + } + + func revalidateCodexAccountMenuProjection() async -> CodexAccountMenuProjectionRevalidationResult { + guard self.codexAccountMenuProjectionNeedsRevalidation else { return .skipped } + + let activeSource = self.codexPersistedActiveSource + let generation = self.codexAccountReconciliationGeneration + let loader = self.codexAccountSnapshotLoader(activeSource: activeSource) + let snapshot = await Self.loadCodexAccountSnapshot(loader) + + guard generation == self.codexAccountReconciliationGeneration, + activeSource == self.codexPersistedActiveSource + else { + return .discarded + } + + let now = Date() + let projection = CodexVisibleAccountProjection.make(from: snapshot) + let previousProjection = self.cachedCodexAccountMenuProjection.flatMap { cached in + cached.activeSource == activeSource ? cached.projection : nil + } + self.cachedCodexAccountMenuProjection = CachedCodexAccountMenuProjection( + activeSource: activeSource, + loadedAt: now, + projection: projection) + if Self.codexAccountReconciliationSnapshotCacheInterval > 0 { + self.cachedCodexAccountReconciliationSnapshot = CachedCodexAccountReconciliationSnapshot( + activeSource: activeSource, + loadedAt: now, + snapshot: snapshot) + } + return previousProjection == projection ? .unchanged : .updated + } + + @concurrent + private nonisolated static func loadCodexAccountSnapshot( + _ loader: @escaping @Sendable () -> CodexAccountReconciliationSnapshot) + async -> CodexAccountReconciliationSnapshot + { + loader() } var codexVisibleAccountProjection: CodexVisibleAccountProjection { @@ -203,15 +349,14 @@ 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 } func selectDisplayedCodexVisibleAccount(_ account: CodexVisibleAccount) { - if self.selectCodexVisibleAccount(id: account.id) { - return - } - // An open menu can preserve a previously rendered account row while the live projection is briefly incomplete. + // The row already carries the exact source it represented. Re-resolving its ID would synchronously + // reload auth state from the menu click callback and can also fail after a stale snapshot is rendered. self.codexActiveSource = account.selectionSource } @@ -224,6 +369,7 @@ extension SettingsStore { return } + self.invalidateCodexAccountReconciliationSnapshotCache() self.codexActiveSource = .managedAccount(id: account.id) _ = self.persistResolvedCodexActiveSourceCorrectionIfNeeded() } @@ -232,6 +378,18 @@ extension SettingsStore { self.codexVisibleAccountProjection.source(forVisibleAccountID: id) } + private func codexAccountSnapshotLoader( + activeSource: CodexActiveSource) -> @Sendable () -> CodexAccountReconciliationSnapshot + { + #if DEBUG + if let loader = self._test_codexAccountSnapshotLoader { + return { loader(activeSource) } + } + #endif + let reconciler = self.codexAccountReconciler(activeSource: activeSource) + return { reconciler.loadSnapshot() } + } + private func codexAccountReconciler(activeSource: CodexActiveSource) -> DefaultCodexAccountReconciler { let baseEnvironment = self.codexReconciliationEnvironment() #if DEBUG @@ -245,6 +403,7 @@ extension SettingsStore { return DefaultCodexAccountReconciler( activeSource: activeSource, baseEnvironment: baseEnvironment, + profileHomePaths: self.codexProfileHomePaths, managedEnvironmentBuilder: { environment, account in CodexHomeScope.scopedEnvironment(base: environment, codexHome: account.managedHomePath) }) @@ -275,6 +434,7 @@ extension SettingsStore { usesInjectedEnvironment: reconciliationEnvironmentOverride != nil), activeSource: activeSource, baseEnvironment: baseEnvironment, + profileHomePaths: self.codexProfileHomePaths, managedEnvironmentBuilder: { environment, account in CodexHomeScope.scopedEnvironment(base: environment, codexHome: account.managedHomePath) }) @@ -282,6 +442,7 @@ extension SettingsStore { return DefaultCodexAccountReconciler( activeSource: activeSource, baseEnvironment: baseEnvironment, + profileHomePaths: self.codexProfileHomePaths, managedEnvironmentBuilder: { environment, account in CodexHomeScope.scopedEnvironment(base: environment, codexHome: account.managedHomePath) }) @@ -467,34 +628,57 @@ private struct CodexManagedRemoteHomeTestingSystemObserver: CodexSystemAccountOb } extension SettingsStore { + private func invalidateCodexAccountReconciliationCachesForTesting() { + self.invalidateCodexAccountReconciliationSnapshotCache() + self.cachedCodexAccountMenuProjection = nil + } + var _test_activeManagedCodexRemoteHomePath: String? { get { CodexManagedRemoteHomeTestingOverride.homePath(for: self) } - set { CodexManagedRemoteHomeTestingOverride.setHomePath(newValue, for: self) } + set { + self.invalidateCodexAccountReconciliationCachesForTesting() + CodexManagedRemoteHomeTestingOverride.setHomePath(newValue, for: self) + } } var _test_activeManagedCodexAccount: ManagedCodexAccount? { get { CodexManagedRemoteHomeTestingOverride.account(for: self) } - set { CodexManagedRemoteHomeTestingOverride.setAccount(newValue, for: self) } + set { + self.invalidateCodexAccountReconciliationCachesForTesting() + CodexManagedRemoteHomeTestingOverride.setAccount(newValue, for: self) + } } var _test_unreadableManagedCodexAccountStore: Bool { get { CodexManagedRemoteHomeTestingOverride.isUnreadable(for: self) } - set { CodexManagedRemoteHomeTestingOverride.setUnreadable(newValue, for: self) } + set { + self.invalidateCodexAccountReconciliationCachesForTesting() + CodexManagedRemoteHomeTestingOverride.setUnreadable(newValue, for: self) + } } var _test_managedCodexAccountStoreURL: URL? { get { CodexManagedRemoteHomeTestingOverride.managedStoreURL(for: self) } - set { CodexManagedRemoteHomeTestingOverride.setManagedStoreURL(newValue, for: self) } + set { + self.invalidateCodexAccountReconciliationCachesForTesting() + CodexManagedRemoteHomeTestingOverride.setManagedStoreURL(newValue, for: self) + } } var _test_liveSystemCodexAccount: ObservedSystemCodexAccount? { get { CodexManagedRemoteHomeTestingOverride.liveSystemAccount(for: self) } - set { CodexManagedRemoteHomeTestingOverride.setLiveSystemAccount(newValue, for: self) } + set { + self.invalidateCodexAccountReconciliationCachesForTesting() + CodexManagedRemoteHomeTestingOverride.setLiveSystemAccount(newValue, for: self) + } } var _test_codexReconciliationEnvironment: [String: String]? { get { CodexManagedRemoteHomeTestingOverride.reconciliationEnvironment(for: self) } - set { CodexManagedRemoteHomeTestingOverride.setReconciliationEnvironment(newValue, for: self) } + set { + self.invalidateCodexAccountReconciliationCachesForTesting() + CodexManagedRemoteHomeTestingOverride.setReconciliationEnvironment(newValue, for: self) + } } } #endif diff --git a/Sources/CodexBar/Providers/Codex/CodexWeeklyResetConfirmation.swift b/Sources/CodexBar/Providers/Codex/CodexWeeklyResetConfirmation.swift new file mode 100644 index 0000000000..3fe3000f0d --- /dev/null +++ b/Sources/CodexBar/Providers/Codex/CodexWeeklyResetConfirmation.swift @@ -0,0 +1,184 @@ +import CodexBarCore +import Foundation + +struct CodexWeeklyResetConfirmation: Sendable { + enum InitialDecision: Equatable, Sendable { + case publishInitial + case requiresConfirmation + case preservePrevious + } + + enum ConfirmationDecision: Equatable, Sendable { + case publishConfirmation + case preservePrevious + } + + private static let resetEquivalenceToleranceSeconds: TimeInterval = 2 * 60 + private static let resetThreshold = 1.0 + + static func initialDecision( + previous: UsageSnapshot?, + initial: UsageSnapshot) -> InitialDecision + { + guard self.isFinite(initial.updatedAt) else { return .preservePrevious } + guard let previous else { + guard let initialWeekly = CodexConsumerProjection.sourceRateWindow( + for: .weekly, + snapshot: initial) + else { + return .publishInitial + } + return self.initialDecisionWithoutWeeklyBaseline( + initialWeekly: initialWeekly, + capturedAt: initial.updatedAt) + } + guard Self.isFinite(previous.updatedAt), initial.updatedAt > previous.updatedAt else { + return .preservePrevious + } + + guard let previousWeekly = CodexConsumerProjection.sourceRateWindow( + for: .weekly, + snapshot: previous) + else { + guard let initialWeekly = CodexConsumerProjection.sourceRateWindow( + for: .weekly, + snapshot: initial) + else { + return .publishInitial + } + return self.initialDecisionWithoutWeeklyBaseline( + initialWeekly: initialWeekly, + capturedAt: initial.updatedAt) + } + guard previousWeekly.usedPercent.isFinite else { + return .preservePrevious + } + // A source can legitimately omit the weekly lane and rely on the existing + // reset-window backfill path. Only gate an explicit weekly observation. + guard let initialWeekly = CodexConsumerProjection.sourceRateWindow( + for: .weekly, + snapshot: initial) + else { + return .preservePrevious + } + guard initialWeekly.usedPercent.isFinite else { return .preservePrevious } + let previousBoundary = Self.finiteResetBoundary(previousWeekly) + let initialBoundary = Self.finiteResetBoundary(initialWeekly) + if initialWeekly.resetsAt != nil, + Self.validResetBoundary(initialWeekly, capturedAt: initial.updatedAt) == nil + { + return .preservePrevious + } + if let previousBoundary, let initialBoundary, + initialBoundary.timeIntervalSince(previousBoundary) < -Self.resetEquivalenceToleranceSeconds + { + return .preservePrevious + } + + guard previousWeekly.usedPercent > Self.resetThreshold, + initialWeekly.usedPercent <= Self.resetThreshold + else { + return .publishInitial + } + guard Self.validResetBoundary(initialWeekly, capturedAt: initial.updatedAt) != nil else { + return .preservePrevious + } + return .requiresConfirmation + } + + static func confirmationDecision( + previous: UsageSnapshot?, + initial: UsageSnapshot, + confirmation: UsageSnapshot) -> ConfirmationDecision + { + guard previous.map({ self.isFinite($0.updatedAt) }) ?? true, + self.isFinite(initial.updatedAt), + self.isFinite(confirmation.updatedAt), + confirmation.updatedAt > initial.updatedAt, + let initialWeekly = CodexConsumerProjection.sourceRateWindow( + for: .weekly, + snapshot: initial), + let confirmationWeekly = CodexConsumerProjection.sourceRateWindow( + for: .weekly, + snapshot: confirmation), + initialWeekly.usedPercent.isFinite, + confirmationWeekly.usedPercent.isFinite + else { + return .preservePrevious + } + let previousWeekly = CodexConsumerProjection.sourceRateWindow( + for: .weekly, + snapshot: previous) + guard previousWeekly?.usedPercent.isFinite ?? true else { return .preservePrevious } + let previousBoundary = previousWeekly.flatMap(Self.finiteResetBoundary) + let confirmationBoundary = Self.finiteResetBoundary(confirmationWeekly) + if confirmationWeekly.resetsAt != nil, + Self.validResetBoundary(confirmationWeekly, capturedAt: confirmation.updatedAt) == nil + { + return .preservePrevious + } + if let previousBoundary, let confirmationBoundary, + confirmationBoundary.timeIntervalSince(previousBoundary) < -Self.resetEquivalenceToleranceSeconds + { + return .preservePrevious + } + + if confirmationWeekly.usedPercent > Self.resetThreshold { + return .publishConfirmation + } + + guard initialWeekly.usedPercent <= Self.resetThreshold, + let initialBoundary = Self.validResetBoundary(initialWeekly, capturedAt: initial.updatedAt), + let confirmationBoundary = Self.validResetBoundary( + confirmationWeekly, + capturedAt: confirmation.updatedAt), + abs(initialBoundary.timeIntervalSince(confirmationBoundary)) + < Self.resetEquivalenceToleranceSeconds + else { + return .preservePrevious + } + if let previous, + let previousWeekly, + let previousBoundary = Self.validResetBoundary( + previousWeekly, + capturedAt: previous.updatedAt) + { + guard initialBoundary.timeIntervalSince(previousBoundary) >= Self.resetEquivalenceToleranceSeconds, + confirmationBoundary.timeIntervalSince(previousBoundary) >= Self.resetEquivalenceToleranceSeconds + else { + return .preservePrevious + } + } + return .publishConfirmation + } + + private static func initialDecisionWithoutWeeklyBaseline( + initialWeekly: RateWindow, + capturedAt: Date) -> InitialDecision + { + guard initialWeekly.usedPercent.isFinite else { return .preservePrevious } + if initialWeekly.resetsAt != nil, + self.validResetBoundary(initialWeekly, capturedAt: capturedAt) == nil + { + return .preservePrevious + } + guard initialWeekly.usedPercent <= self.resetThreshold else { return .publishInitial } + return self.validResetBoundary(initialWeekly, capturedAt: capturedAt) == nil + ? .preservePrevious + : .requiresConfirmation + } + + private static func finiteResetBoundary(_ window: RateWindow) -> Date? { + guard let boundary = window.resetsAt, isFinite(boundary) else { return nil } + return boundary + } + + private static func validResetBoundary(_ window: RateWindow, capturedAt: Date) -> Date? { + guard let boundary = self.finiteResetBoundary(window), boundary > capturedAt else { return nil } + return boundary + } + + private static func isFinite(_ date: Date) -> Bool { + date.timeIntervalSinceReferenceDate.isFinite + } +} diff --git a/Sources/CodexBar/Providers/Codex/UsageStore+CodexAccountState.swift b/Sources/CodexBar/Providers/Codex/UsageStore+CodexAccountState.swift index 0952a47ae9..fc6c23c66c 100644 --- a/Sources/CodexBar/Providers/Codex/UsageStore+CodexAccountState.swift +++ b/Sources/CodexBar/Providers/Codex/UsageStore+CodexAccountState.swift @@ -13,6 +13,19 @@ struct CodexAccountScopedRefreshGuard: Equatable { let source: CodexActiveSource let identity: CodexIdentity let accountKey: String? + let authFingerprint: String? + + init( + source: CodexActiveSource, + identity: CodexIdentity, + accountKey: String?, + authFingerprint: String? = nil) + { + self.source = source + self.identity = identity + self.accountKey = CodexIdentityResolver.normalizeEmail(accountKey) + self.authFingerprint = CodexAuthFingerprint.normalize(authFingerprint) + } } @MainActor @@ -34,10 +47,11 @@ extension UsageStore { phaseDidChange?(.credits) if self.settings.codexCookieSource.isEnabled { - let expectedGuard = self.currentCodexOpenAIWebRefreshGuard() + let expectedGuard = self.freshCodexOpenAIWebRefreshGuard() await self.refreshOpenAIDashboardIfNeeded( force: true, expectedGuard: expectedGuard, + bypassCoalescing: true, allowCodexUsageBackfill: true) phaseDidChange?(.dashboard) } @@ -54,24 +68,25 @@ extension UsageStore { } @discardableResult - func prepareCodexAccountScopedRefreshIfNeeded() -> Bool { - let currentGuard = self.currentCodexAccountScopedRefreshGuard( + func prepareCodexAccountScopedRefreshIfNeeded( + forceInvalidation: Bool = false, + currentGuardOverride: CodexAccountScopedRefreshGuard? = nil) -> Bool + { + let currentGuard = currentGuardOverride ?? self.freshCodexAccountScopedRefreshGuard( preferCurrentSnapshot: false, allowLastKnownLiveFallback: false) let previousGuard = self.lastCodexAccountScopedRefreshGuard self.lastCodexAccountScopedRefreshGuard = currentGuard - guard previousGuard != nil, previousGuard != currentGuard else { return false } + let accountChanged = previousGuard.map { + !Self.codexScopedRefreshGuardsMatchAccount($0, currentGuard) + } ?? false + guard forceInvalidation || accountChanged else { return false } - self.snapshots.removeValue(forKey: .codex) - self.errors[.codex] = nil - self.lastSourceLabels.removeValue(forKey: .codex) - self.lastFetchAttempts.removeValue(forKey: .codex) - self.accountSnapshots.removeValue(forKey: .codex) - self.codexAccountSnapshots = [] - self.failureGates[.codex]?.reset() - self.lastKnownSessionRemaining.removeValue(forKey: .codex) - self.lastKnownSessionWindowSource.removeValue(forKey: .codex) + let preserveSessionQuotaTransitionState = !forceInvalidation && + Self.codexSessionQuotaOwnersMatch(previousGuard, currentGuard) + self.clearCodexPublishedUsageState( + preserveSessionQuotaTransitionState: preserveSessionQuotaTransitionState) self.credits = nil self.lastCreditsError = nil @@ -86,6 +101,61 @@ extension UsageStore { return true } + func clearCodexPublishedUsageState(preserveSessionQuotaTransitionState: Bool = false) { + self.snapshots.removeValue(forKey: .codex) + self.errors[.codex] = nil + self.lastSourceLabels.removeValue(forKey: .codex) + self.lastFetchAttempts.removeValue(forKey: .codex) + self.accountSnapshots.removeValue(forKey: .codex) + // Visible-account rows carry their own owner and are reconciled against the current projection. + // Clearing selected-account state must not discard valid sibling rows. + self.failureGates[.codex]?.reset() + if !preserveSessionQuotaTransitionState { + self.requireFreshCodexSessionQuotaBaseline() + } + self.lastKnownResetSnapshots.removeValue(forKey: .codex) + self.lastCodexUsagePublicationGuard = nil + } + + @discardableResult + func reconcileCodexPublishedUsageOwner( + with currentGuard: CodexAccountScopedRefreshGuard, + persistWidgetSnapshot: Bool = true) -> Bool + { + let hasPublishedUsageState = self.snapshots[.codex] != nil || + self.lastKnownResetSnapshots[.codex] != nil || + self.errors[.codex] != nil || + self.lastSourceLabels[.codex] != nil || + self.lastFetchAttempts[.codex] != nil + guard hasPublishedUsageState else { return false } + guard self.lastCodexUsagePublicationGuard.map({ + Self.codexScopedRefreshGuardsMatchAccount($0, currentGuard) + }) == true + else { + let preserveSessionQuotaTransitionState = Self.codexSessionQuotaOwnersMatch( + self.lastCodexUsagePublicationGuard, + currentGuard) + self.clearCodexPublishedUsageState( + preserveSessionQuotaTransitionState: preserveSessionQuotaTransitionState) + if persistWidgetSnapshot { + self.persistWidgetSnapshot(reason: "codex-account-invalidate") + } + return true + } + return false + } + + func reconcileCodexAccountStateForUsageOwner(_ currentGuard: CodexAccountScopedRefreshGuard) { + let clearedUsage = self.reconcileCodexPublishedUsageOwner( + with: currentGuard, + persistWidgetSnapshot: false) + let invalidatedAccountState = self.prepareCodexAccountScopedRefreshIfNeeded( + currentGuardOverride: currentGuard) + if clearedUsage, !invalidatedAccountState { + self.persistWidgetSnapshot(reason: "codex-account-invalidate") + } + } + func seedCodexAccountScopedRefreshGuard( source: CodexActiveSource? = nil, accountEmail: String?) @@ -105,7 +175,8 @@ extension UsageStore { self.lastCodexAccountScopedRefreshGuard = CodexAccountScopedRefreshGuard( source: resolvedSource, identity: resolvedIdentity, - accountKey: accountKey) + accountKey: accountKey, + authFingerprint: self.currentCodexAuthFingerprint(source: resolvedSource)) } func currentCodexAccountScopedRefreshGuard( @@ -120,7 +191,8 @@ extension UsageStore { allowLastKnownLiveFallback: allowLastKnownLiveFallback), accountKey: self.codexAccountScopedRefreshKey( preferCurrentSnapshot: preferCurrentSnapshot, - allowLastKnownLiveFallback: allowLastKnownLiveFallback)) + allowLastKnownLiveFallback: allowLastKnownLiveFallback), + authFingerprint: self.currentCodexAuthFingerprint(source: self.settings.codexResolvedActiveSource)) } func currentCodexOpenAIWebRefreshGuard() -> CodexAccountScopedRefreshGuard { @@ -132,53 +204,115 @@ extension UsageStore { .email) case .managedAccount: Self.normalizeCodexAccountScopedKey(self.currentManagedCodexRuntimeEmail()) + case let .profileHome(path): + Self.normalizeCodexAccountScopedKey(self.currentProfileCodexRuntimeEmail(path: path)) } return CodexAccountScopedRefreshGuard( source: source, identity: self.currentCodexOpenAIWebIdentity(source: source), - accountKey: accountKey) + accountKey: accountKey, + authFingerprint: self.currentCodexAuthFingerprint(source: source)) + } + + func freshCodexAccountScopedRefreshGuard( + preferCurrentSnapshot: Bool = true, + allowLastKnownLiveFallback: Bool = true) -> CodexAccountScopedRefreshGuard + { + self.settings.invalidateCodexAccountReconciliationSnapshotCache() + return self.currentCodexAccountScopedRefreshGuard( + preferCurrentSnapshot: preferCurrentSnapshot, + allowLastKnownLiveFallback: allowLastKnownLiveFallback) + } + + func freshCodexOpenAIWebRefreshGuard() -> CodexAccountScopedRefreshGuard { + self.settings.invalidateCodexAccountReconciliationSnapshotCache() + return self.currentCodexOpenAIWebRefreshGuard() } func shouldApplyCodexUsageResult( expectedGuard: CodexAccountScopedRefreshGuard, usage: UsageSnapshot) -> Bool { - let currentGuard = self.currentCodexAccountScopedRefreshGuard() + let currentGuard = self.freshCodexAccountScopedRefreshGuard() guard currentGuard.source == expectedGuard.source else { return false } + let fingerprintsAllowApply = Self.codexGuardAuthFingerprintAllowsUsageApply( + currentGuard, + expectedGuard) + let expectedAuthFingerprint = CodexAuthFingerprint.normalize(expectedGuard.authFingerprint) + let currentAuthFingerprint = CodexAuthFingerprint.normalize(currentGuard.authFingerprint) + let canProveNilToCurrentAuth = expectedAuthFingerprint == nil && currentAuthFingerprint != nil + let resultIdentity = CodexIdentityResolver.resolve(accountId: nil, email: usage.accountEmail(for: .codex)) + let resultAccountKey = Self.normalizeCodexAccountScopedKey(usage.accountEmail(for: .codex)) + let resultMatchesCurrentAccountKey = Self.codexUsageResultAccountKeyMatchesCurrentGuard( + resultAccountKey, + expectedGuard: expectedGuard, + currentGuard: currentGuard) if expectedGuard.identity != .unresolved { - return currentGuard.identity == expectedGuard.identity + guard Self.codexGuardIdentityAndEmailMatch(currentGuard, expectedGuard) else { return false } + guard resultMatchesCurrentAccountKey else { return false } + if fingerprintsAllowApply { + return true + } + guard canProveNilToCurrentAuth else { return false } + return resultIdentity == currentGuard.identity || + (resultAccountKey != nil && resultAccountKey == currentGuard.accountKey) } - let resultIdentity = CodexIdentityResolver.resolve(accountId: nil, email: usage.accountEmail(for: .codex)) if currentGuard.identity != .unresolved { - return resultIdentity == currentGuard.identity + guard resultIdentity == currentGuard.identity else { return false } + return fingerprintsAllowApply || canProveNilToCurrentAuth } switch currentGuard.source { case .liveSystem: - return resultIdentity != .unresolved + guard resultIdentity != .unresolved else { return false } + if fingerprintsAllowApply { + return true + } + guard canProveNilToCurrentAuth else { return false } + guard let currentAccountKey = currentGuard.accountKey else { return true } + return resultAccountKey == currentAccountKey case .managedAccount: return false + case .profileHome: + return false } } func shouldApplyCodexScopedFailure(expectedGuard: CodexAccountScopedRefreshGuard) -> Bool { - let currentGuard = self.currentCodexAccountScopedRefreshGuard() + let currentGuard = self.freshCodexAccountScopedRefreshGuard() guard currentGuard.source == expectedGuard.source else { return false } + guard Self.codexGuardAuthFingerprintMatches(currentGuard, expectedGuard) else { return false } if expectedGuard.identity != .unresolved { - return currentGuard.identity == expectedGuard.identity + return Self.codexGuardIdentityAndEmailMatch(currentGuard, expectedGuard) } return currentGuard.identity == .unresolved } + func codexScopedNonUsageSuccessApplyGuard( + expectedGuard: CodexAccountScopedRefreshGuard) -> CodexAccountScopedRefreshGuard? + { + let currentGuard = self.freshCodexAccountScopedRefreshGuard() + guard currentGuard.source == expectedGuard.source else { return nil } + guard Self.codexGuardAuthFingerprintAllowsUsageApply(currentGuard, expectedGuard) else { return nil } + guard expectedGuard.identity != .unresolved else { return nil } + guard Self.codexGuardIdentityAndEmailMatch(currentGuard, expectedGuard) else { return nil } + return currentGuard + } + func shouldApplyCodexScopedNonUsageResult(expectedGuard: CodexAccountScopedRefreshGuard) -> Bool { - let currentGuard = self.currentCodexAccountScopedRefreshGuard() + self.codexScopedNonUsageSuccessApplyGuard(expectedGuard: expectedGuard) != nil + } + + func shouldApplyCodexScopedNonUsageFailure(expectedGuard: CodexAccountScopedRefreshGuard) -> Bool { + let currentGuard = self.freshCodexAccountScopedRefreshGuard() guard currentGuard.source == expectedGuard.source else { return false } + guard Self.codexGuardAuthFingerprintMatches(currentGuard, expectedGuard) else { return false } guard expectedGuard.identity != .unresolved else { return false } - return currentGuard.identity == expectedGuard.identity + return Self.codexGuardIdentityAndEmailMatch(currentGuard, expectedGuard) } func shouldApplyOpenAIDashboardRefreshGuard( @@ -186,11 +320,12 @@ extension UsageStore { routingTargetEmail: String?) -> Bool { let normalizedRoutingTargetEmail = CodexIdentityResolver.normalizeEmail(routingTargetEmail) - let currentGuard = self.currentCodexOpenAIWebRefreshGuard() + let currentGuard = self.freshCodexOpenAIWebRefreshGuard() guard currentGuard.source == expectedGuard.source else { return false } + guard Self.codexGuardAuthFingerprintAllowsUsageApply(currentGuard, expectedGuard) else { return false } if expectedGuard.identity != .unresolved { - return currentGuard.identity == expectedGuard.identity + return Self.codexGuardIdentityAndEmailMatch(currentGuard, expectedGuard) } guard case .liveSystem = expectedGuard.source else { return false } @@ -205,9 +340,46 @@ extension UsageStore { expectedGuard: CodexAccountScopedRefreshGuard, routingTargetEmail: String?) -> Bool { - self.shouldApplyOpenAIDashboardRefreshGuard( - expectedGuard: expectedGuard, - routingTargetEmail: routingTargetEmail) + let normalizedRoutingTargetEmail = CodexIdentityResolver.normalizeEmail(routingTargetEmail) + let currentGuard = self.freshCodexOpenAIWebRefreshGuard() + guard currentGuard.source == expectedGuard.source else { return false } + guard Self.codexGuardAuthFingerprintMatches(currentGuard, expectedGuard) else { return false } + + if expectedGuard.identity != .unresolved { + return Self.codexGuardIdentityAndEmailMatch(currentGuard, expectedGuard) + } + + guard case .liveSystem = expectedGuard.source else { return false } + guard currentGuard.identity == .unresolved else { return false } + return CodexIdentityResolver.normalizeEmail( + self.currentCodexOpenAIWebTargetEmail( + allowCurrentSnapshotFallback: true, + allowLastKnownLiveFallback: false)) == normalizedRoutingTargetEmail + } + + func shouldApplyOpenAIDashboardPolicyResult( + expectedGuard: CodexAccountScopedRefreshGuard, + routingTargetEmail: String?) -> Bool + { + let normalizedRoutingTargetEmail = CodexIdentityResolver.normalizeEmail(routingTargetEmail) + let currentGuard = self.freshCodexOpenAIWebRefreshGuard() + guard currentGuard.source == expectedGuard.source else { return false } + + if expectedGuard.identity != .unresolved { + if Self.codexGuardIdentityAndEmailMatch(currentGuard, expectedGuard) { + return Self.codexGuardAuthFingerprintMatches(currentGuard, expectedGuard) || + Self.codexGuardAuthFingerprintAllowsUsageApply(currentGuard, expectedGuard) + } + return Self.codexGuardAuthFingerprintAllowsProviderTransitionCleanup(currentGuard, expectedGuard) + } + + guard case .liveSystem = expectedGuard.source else { return false } + guard currentGuard.identity == .unresolved else { return false } + guard Self.codexGuardAuthFingerprintMatches(currentGuard, expectedGuard) else { return false } + return CodexIdentityResolver.normalizeEmail( + self.currentCodexOpenAIWebTargetEmail( + allowCurrentSnapshotFallback: true, + allowLastKnownLiveFallback: false)) == normalizedRoutingTargetEmail } func codexDashboardKnownOwnerCandidates() -> [CodexDashboardKnownOwnerCandidate] { @@ -228,6 +400,8 @@ extension UsageStore { self.settings.codexAccountReconciliationSnapshot.liveSystemAccount?.email) case .managedAccount: CodexIdentityResolver.normalizeEmail(self.currentManagedCodexRuntimeEmail()) + case let .profileHome(path): + CodexIdentityResolver.normalizeEmail(self.currentProfileCodexRuntimeEmail(path: path)) } } @@ -276,6 +450,110 @@ extension UsageStore { self.lastKnownLiveSystemCodexEmail = normalized } + nonisolated static func codexGuardAuthFingerprintMatches( + _ lhs: CodexAccountScopedRefreshGuard, + _ rhs: CodexAccountScopedRefreshGuard) -> Bool + { + let lhsFingerprint = CodexAuthFingerprint.normalize(lhs.authFingerprint) + let rhsFingerprint = CodexAuthFingerprint.normalize(rhs.authFingerprint) + if lhsFingerprint != nil || rhsFingerprint != nil { + return lhsFingerprint == rhsFingerprint + } + return true + } + + nonisolated static func codexGuardAuthFingerprintAllowsUsageApply( + _ lhs: CodexAccountScopedRefreshGuard, + _ rhs: CodexAccountScopedRefreshGuard) -> Bool + { + if self.codexGuardAuthFingerprintMatches(lhs, rhs) { + return true + } + let lhsFingerprint = CodexAuthFingerprint.normalize(lhs.authFingerprint) + let rhsFingerprint = CodexAuthFingerprint.normalize(rhs.authFingerprint) + guard lhsFingerprint != nil, rhsFingerprint != nil else { return false } + guard case .providerAccount = rhs.identity, + self.codexGuardIdentityAndEmailMatch(lhs, rhs) + else { return false } + guard case .liveSystem = lhs.source else { return true } + return true + } + + private nonisolated static func codexGuardAuthFingerprintAllowsProviderTransitionCleanup( + _ lhs: CodexAccountScopedRefreshGuard, + _ rhs: CodexAccountScopedRefreshGuard) -> Bool + { + let lhsFingerprint = CodexAuthFingerprint.normalize(lhs.authFingerprint) + let rhsFingerprint = CodexAuthFingerprint.normalize(rhs.authFingerprint) + guard let lhsFingerprint, let rhsFingerprint, lhsFingerprint != rhsFingerprint else { return false } + guard case .providerAccount = rhs.identity else { return false } + guard lhs.identity == rhs.identity else { return false } + guard let lhsEmail = CodexIdentityResolver.normalizeEmail(lhs.accountKey), + let rhsEmail = CodexIdentityResolver.normalizeEmail(rhs.accountKey) + else { return false } + return lhsEmail != rhsEmail + } + + nonisolated static func codexScopedRefreshGuardsMatchAccount( + _ lhs: CodexAccountScopedRefreshGuard, + _ rhs: CodexAccountScopedRefreshGuard) -> Bool + { + guard lhs.source == rhs.source else { return false } + if lhs == rhs { + guard case .providerAccount = lhs.identity else { return true } + return self.codexGuardIdentityAndEmailMatch(lhs, rhs) + } + guard lhs.identity != .unresolved, + self.codexGuardIdentityAndEmailMatch(lhs, rhs), + lhs.accountKey == rhs.accountKey + else { + return false + } + return self.codexGuardAuthFingerprintAllowsUsageApply(lhs, rhs) + } + + private nonisolated static func codexGuardIdentityAndEmailMatch( + _ lhs: CodexAccountScopedRefreshGuard, + _ rhs: CodexAccountScopedRefreshGuard) -> Bool + { + guard lhs.identity == rhs.identity else { return false } + guard case .providerAccount = lhs.identity else { return true } + guard let lhsEmail = CodexIdentityResolver.normalizeEmail(lhs.accountKey), + let rhsEmail = CodexIdentityResolver.normalizeEmail(rhs.accountKey) + else { return false } + return lhsEmail == rhsEmail + } + + private nonisolated static func codexUsageResultAccountKeyMatchesCurrentGuard( + _ resultAccountKey: String?, + expectedGuard: CodexAccountScopedRefreshGuard, + currentGuard: CodexAccountScopedRefreshGuard) -> Bool + { + guard let currentAccountKey = currentGuard.accountKey else { return true } + guard let resultAccountKey else { + guard let expectedAccountKey = expectedGuard.accountKey else { return true } + return expectedAccountKey == currentAccountKey + } + return resultAccountKey == currentAccountKey + } + + func currentCodexAuthFingerprint(source: CodexActiveSource) -> String? { + let snapshot = self.settings.codexAccountReconciliationSnapshot + switch source { + case .liveSystem: + return CodexAuthFingerprint.normalize(snapshot.liveSystemAccount?.authFingerprint) + case let .managedAccount(id): + guard let account = snapshot.storedAccounts.first(where: { $0.id == id }) else { return nil } + return CodexAuthFingerprint.fingerprint(homePath: account.managedHomePath) + case let .profileHome(path): + guard let profileAccount = snapshot.profileHomeAccount(path: path) else { + guard let normalizedPath = CodexHomeScope.normalizedHomePath(path) else { return nil } + return CodexAuthFingerprint.fingerprint(homePath: normalizedPath) + } + return CodexAuthFingerprint.normalize(profileAccount.authFingerprint) + } + } + func codexAccountScopedRefreshKey( preferCurrentSnapshot: Bool = true, allowLastKnownLiveFallback: Bool = true) -> String? @@ -319,6 +597,8 @@ extension UsageStore { return nil } return self.currentManagedCodexRuntimeEmail() + case let .profileHome(path): + return self.currentProfileCodexRuntimeEmail(path: path) } } @@ -356,6 +636,12 @@ extension UsageStore { return .unresolved } return self.settings.codexAccountReconciliationSnapshot.runtimeIdentity(for: activeStoredAccount) + case let .profileHome(path): + guard let profileAccount = self.settings.codexAccountReconciliationSnapshot.profileHomeAccount(path: path) + else { + return .unresolved + } + return self.settings.codexAccountReconciliationSnapshot.runtimeIdentity(for: profileAccount) } } @@ -374,6 +660,12 @@ extension UsageStore { return .unresolved } return self.settings.codexAccountReconciliationSnapshot.runtimeIdentity(for: activeStoredAccount) + case let .profileHome(path): + guard let profileAccount = self.settings.codexAccountReconciliationSnapshot.profileHomeAccount(path: path) + else { + return .unresolved + } + return self.settings.codexAccountReconciliationSnapshot.runtimeIdentity(for: profileAccount) } } @@ -388,22 +680,34 @@ extension UsageStore { self.settings.codexAccountReconciliationSnapshot.runtimeEmail(for: activeStoredAccount)) } + func currentProfileCodexRuntimeEmail(path: String) -> String? { + guard let profileAccount = self.settings.codexAccountReconciliationSnapshot.profileHomeAccount(path: path) + else { + return nil + } + return Self.normalizeCodexAccountScopedEmail(profileAccount.email) + } + private func clearCodexOpenAIWebStateForAccountTransition(targetEmail: String?) { self.invalidateOpenAIDashboardRefreshTask() if self.settings.codexCookieSource.isEnabled, let normalizedTarget = Self.normalizeCodexAccountScopedEmail(targetEmail) { - let previous = self.lastOpenAIDashboardTargetEmail + let scope = self.codexCookieCacheScopeForOpenAIWeb() + let isolationKey = Self.openAIWebTargetIsolationKey(email: normalizedTarget, scope: scope) + let previousIsolationKey = self.lastOpenAIDashboardTargetIsolationKey self.lastOpenAIDashboardTargetEmail = normalizedTarget - if let previous, !previous.isEmpty, previous != normalizedTarget { + self.lastOpenAIDashboardTargetIsolationKey = isolationKey + if let previousIsolationKey, previousIsolationKey != isolationKey { self.openAIWebAccountDidChange = true - self.openAIDashboardCookieImportStatus = "Codex account changed; importing browser cookies…" + self.openAIDashboardCookieImportStatus = L("Codex account changed; importing browser cookies…") } else { self.openAIDashboardCookieImportStatus = nil } self.openAIDashboardRequiresLogin = true } else { self.lastOpenAIDashboardTargetEmail = Self.normalizeCodexAccountScopedEmail(targetEmail) + self.lastOpenAIDashboardTargetIsolationKey = nil self.openAIWebAccountDidChange = false self.openAIDashboardRequiresLogin = false self.openAIDashboardCookieImportStatus = nil diff --git a/Sources/CodexBar/Providers/Codex/UsageStore+CodexRefresh.swift b/Sources/CodexBar/Providers/Codex/UsageStore+CodexRefresh.swift index dc06080172..e6b7d06682 100644 --- a/Sources/CodexBar/Providers/Codex/UsageStore+CodexRefresh.swift +++ b/Sources/CodexBar/Providers/Codex/UsageStore+CodexRefresh.swift @@ -9,13 +9,13 @@ extension UsageStore { func codexCreditsFetcher() -> UsageFetcher { // Credits are remote Codex account state, so they need the same managed-home routing as the - // primary Codex usage fetch. Local token-cost scanning intentionally stays ambient-system scoped. + // primary Codex usage fetch. Token-cost scanning owns its selected managed or ambient scope separately. self.makeFetchContext(provider: .codex, override: nil).fetcher } func scheduleCreditsRefreshIfNeeded(minimumSnapshotUpdatedAt: Date? = nil) { let refreshKey = self.codexCreditsRefreshKey( - expectedGuard: self.currentCodexAccountScopedRefreshGuard()) + expectedGuard: self.freshCodexAccountScopedRefreshGuard()) if let existing = self.creditsRefreshTask, !existing.isCancelled, self.creditsRefreshTaskKey == refreshKey @@ -56,6 +56,8 @@ extension UsageStore { "live" case let .managedAccount(id): "managed:\(id.uuidString)" + case let .profileHome(path): + "profile:\(path)" } let identityKey = switch expectedGuard.identity { @@ -71,18 +73,19 @@ extension UsageStore { sourceKey, identityKey, expectedGuard.accountKey ?? "account:nil", + "auth:\(expectedGuard.authFingerprint ?? "nil")", ].joined(separator: "|") } func refreshCreditsIfNeeded(minimumSnapshotUpdatedAt: Date? = nil) async { guard self.isEnabled(.codex) else { return } - var expectedGuard = self.currentCodexAccountScopedRefreshGuard() + var expectedGuard = self.freshCodexAccountScopedRefreshGuard() if expectedGuard.identity == .unresolved, let minimumSnapshotUpdatedAt, case .liveSystem = expectedGuard.source { _ = await self.waitForCodexSnapshotOrRefreshCompletion(minimumUpdatedAt: minimumSnapshotUpdatedAt) - expectedGuard = self.currentCodexAccountScopedRefreshGuard() + expectedGuard = self.freshCodexAccountScopedRefreshGuard() } guard expectedGuard.identity != .unresolved, expectedGuard.accountKey != nil @@ -92,15 +95,17 @@ extension UsageStore { do { let credits = try await self.loadLatestCodexCredits() guard !Task.isCancelled else { return } - guard self.shouldApplyCodexScopedNonUsageResult(expectedGuard: expectedGuard) else { return } + guard let applyGuard = self.codexScopedNonUsageSuccessApplyGuard( + expectedGuard: expectedGuard) else { return } + self.reconcileCodexPublishedUsageOwner(with: applyGuard) await MainActor.run { self.credits = credits self.lastCreditsError = nil self.lastCreditsSnapshot = credits - self.lastCreditsSnapshotAccountKey = expectedGuard.accountKey + self.lastCreditsSnapshotAccountKey = applyGuard.accountKey self.lastCreditsSource = .api self.creditsFailureStreak = 0 - self.lastCodexAccountScopedRefreshGuard = expectedGuard + self.lastCodexAccountScopedRefreshGuard = applyGuard } let codexSnapshot = await MainActor.run { self.snapshots[.codex] @@ -123,7 +128,8 @@ extension UsageStore { guard !Task.isCancelled else { return } let message = error.localizedDescription if message.localizedCaseInsensitiveContains("data not available yet") { - guard self.shouldApplyCodexScopedNonUsageResult(expectedGuard: expectedGuard) else { return } + guard self.shouldApplyCodexScopedNonUsageFailure(expectedGuard: expectedGuard) else { return } + self.reconcileCodexPublishedUsageOwner(with: expectedGuard) await MainActor.run { if let cached = self.lastCreditsSnapshot, self.lastCreditsSnapshotAccountKey == expectedGuard.accountKey @@ -134,13 +140,14 @@ extension UsageStore { } else { self.credits = nil self.lastCreditsSource = .none - self.lastCreditsError = "Codex credits are still loading; will retry shortly." + self.lastCreditsError = L("Codex credits are still loading; will retry shortly.") } } return } - guard self.shouldApplyCodexScopedNonUsageResult(expectedGuard: expectedGuard) else { return } + guard self.shouldApplyCodexScopedNonUsageFailure(expectedGuard: expectedGuard) else { return } + self.reconcileCodexPublishedUsageOwner(with: expectedGuard) await MainActor.run { self.creditsFailureStreak += 1 if let cached = self.lastCreditsSnapshot, @@ -190,7 +197,9 @@ extension UsageStore { let deadline = Date().addingTimeInterval(Self.codexSnapshotWaitTimeoutSeconds) while Date() < deadline { - if Task.isCancelled { return nil } + if Task.isCancelled { + return nil + } if let snapshot = await MainActor.run(body: { self.snapshots[.codex] }), snapshot.updatedAt >= minimumUpdatedAt { @@ -207,7 +216,9 @@ extension UsageStore { let refreshStartDeadline = Date().addingTimeInterval(Self.codexRefreshStartGraceSeconds) while Date() < deadline { - if Task.isCancelled { return nil } + if Task.isCancelled { + return nil + } let state = await MainActor.run { ( snapshot: self.snapshots[.codex], diff --git a/Sources/CodexBar/Providers/Codex/UsageStore+CodexWeeklyResetConfirmation.swift b/Sources/CodexBar/Providers/Codex/UsageStore+CodexWeeklyResetConfirmation.swift new file mode 100644 index 0000000000..98e83bce25 --- /dev/null +++ b/Sources/CodexBar/Providers/Codex/UsageStore+CodexWeeklyResetConfirmation.swift @@ -0,0 +1,90 @@ +import CodexBarCore + +extension UsageStore { + typealias CodexWeeklyConfirmationFetch = @Sendable () async -> ProviderFetchOutcome + + nonisolated static func codexOutcomeAdmittedForPublication( + initialOutcome: ProviderFetchOutcome, + previousSnapshot: UsageSnapshot?, + missingWindowBackfillSnapshot: UsageSnapshot?, + fetchConfirmation: @escaping CodexWeeklyConfirmationFetch) async -> ProviderFetchOutcome? + { + guard case let .success(rawInitialResult) = initialOutcome.result else { return initialOutcome } + let rawInitialSnapshot = rawInitialResult.usage.scoped(to: .codex) + let publicationBaseline = [previousSnapshot, missingWindowBackfillSnapshot] + .compactMap(\.self) + .max { $0.updatedAt < $1.updatedAt } + let publicationInitialOutcome = if let missingWindowBackfillSnapshot { + initialOutcome.replacingUsage(Self.codexBackfillingResetWindows( + rawInitialSnapshot, + from: missingWindowBackfillSnapshot)) + } else { + initialOutcome + } + + if CodexConsumerProjection.sourceRateWindow(for: .weekly, snapshot: rawInitialSnapshot) == nil { + guard rawInitialSnapshot.updatedAt.timeIntervalSinceReferenceDate.isFinite, + previousSnapshot.map({ + $0.updatedAt.timeIntervalSinceReferenceDate.isFinite && + rawInitialSnapshot.updatedAt > $0.updatedAt + }) ?? true, + missingWindowBackfillSnapshot.map({ + $0.updatedAt.timeIntervalSinceReferenceDate.isFinite && + rawInitialSnapshot.updatedAt >= $0.updatedAt + }) ?? true + else { + return nil + } + if CodexConsumerProjection.sourceRateWindow(for: .weekly, snapshot: publicationBaseline) != nil, + case let .success(publicationResult) = publicationInitialOutcome.result, + CodexConsumerProjection.sourceRateWindow( + for: .weekly, + snapshot: publicationResult.usage.scoped(to: .codex)) == nil + { + return nil + } + return publicationInitialOutcome + } + + switch CodexWeeklyResetConfirmation.initialDecision( + previous: publicationBaseline, + initial: rawInitialSnapshot) + { + case .publishInitial: + return publicationInitialOutcome + case .preservePrevious: + return nil + case .requiresConfirmation: + break + } + + guard !Task.isCancelled else { return nil } + let confirmationOutcome = await fetchConfirmation() + guard !Task.isCancelled, + case let .success(confirmationResult) = confirmationOutcome.result + else { + return nil + } + let confirmationSnapshot = confirmationResult.usage.scoped(to: .codex) + guard CodexIdentityResolver.normalizeEmail(rawInitialSnapshot.accountEmail(for: .codex)) == + CodexIdentityResolver.normalizeEmail(confirmationSnapshot.accountEmail(for: .codex)) + else { + return nil + } + switch CodexWeeklyResetConfirmation.confirmationDecision( + previous: publicationBaseline, + initial: rawInitialSnapshot, + confirmation: confirmationSnapshot) + { + case .publishConfirmation: + if let missingWindowBackfillSnapshot { + return confirmationOutcome.replacingUsage(Self.codexBackfillingResetWindows( + confirmationSnapshot, + from: missingWindowBackfillSnapshot)) + } + return confirmationOutcome + case .preservePrevious: + return nil + } + } +} diff --git a/Sources/CodexBar/Providers/CommandCode/CommandCodeProviderImplementation.swift b/Sources/CodexBar/Providers/CommandCode/CommandCodeProviderImplementation.swift index 4d2e0438a4..bc71670418 100644 --- a/Sources/CodexBar/Providers/CommandCode/CommandCodeProviderImplementation.swift +++ b/Sources/CodexBar/Providers/CommandCode/CommandCodeProviderImplementation.swift @@ -1,10 +1,8 @@ import AppKit import CodexBarCore -import CodexBarMacroSupport import Foundation import SwiftUI -@ProviderImplementationRegistration struct CommandCodeProviderImplementation: ProviderImplementation { let id: UsageProvider = .commandcode diff --git a/Sources/CodexBar/Providers/CommandCode/CommandCodeSettingsStore.swift b/Sources/CodexBar/Providers/CommandCode/CommandCodeSettingsStore.swift index 592dc077da..bf4a4c5a12 100644 --- a/Sources/CodexBar/Providers/CommandCode/CommandCodeSettingsStore.swift +++ b/Sources/CodexBar/Providers/CommandCode/CommandCodeSettingsStore.swift @@ -28,36 +28,10 @@ extension SettingsStore { extension SettingsStore { func commandcodeSettingsSnapshot(tokenOverride: TokenAccountOverride?) -> ProviderSettingsSnapshot .CommandCodeProviderSettings { - ProviderSettingsSnapshot.CommandCodeProviderSettings( - cookieSource: self.commandcodeSnapshotCookieSource(tokenOverride: tokenOverride), - manualCookieHeader: self.commandcodeSnapshotCookieHeader(tokenOverride: tokenOverride)) - } - - private func commandcodeSnapshotCookieHeader(tokenOverride: TokenAccountOverride?) -> String { - let fallback = self.commandcodeCookieHeader - guard let support = TokenAccountSupportCatalog.support(for: .commandcode), - case .cookieHeader = support.injection - else { - return fallback - } - guard let account = ProviderTokenAccountSelection.selectedAccount( + self.resolvedCookieSettings( provider: .commandcode, - settings: self, - override: tokenOverride) - else { - return fallback - } - return TokenAccountSupportCatalog.normalizedCookieHeader(account.token, support: support) - } - - private func commandcodeSnapshotCookieSource(tokenOverride: TokenAccountOverride?) -> ProviderCookieSource { - let fallback = self.commandcodeCookieSource - guard let support = TokenAccountSupportCatalog.support(for: .commandcode), - support.requiresManualCookieSource - else { - return fallback - } - if self.tokenAccounts(for: .commandcode).isEmpty { return fallback } - return .manual + configuredSource: self.commandcodeCookieSource, + configuredHeader: self.commandcodeCookieHeader, + tokenOverride: tokenOverride) } } diff --git a/Sources/CodexBar/Providers/Copilot/CopilotLoginFlow.swift b/Sources/CodexBar/Providers/Copilot/CopilotLoginFlow.swift index 14374c5f27..b6f7b4e25d 100644 --- a/Sources/CodexBar/Providers/Copilot/CopilotLoginFlow.swift +++ b/Sources/CodexBar/Providers/Copilot/CopilotLoginFlow.swift @@ -99,9 +99,10 @@ struct CopilotLoginFlow { } catch { guard existingAccounts.isEmpty else { let err = NSAlert() - err.messageText = "Could Not Identify GitHub Account" - err.informativeText = "GitHub login succeeded, but CodexBar could not verify which " + - "account it belongs to. Please try again." + err.messageText = L("Could Not Identify GitHub Account") + err.informativeText = L( + "GitHub login succeeded, but CodexBar could not verify which " + + "account it belongs to. Please try again.") err.runModal() return } @@ -138,20 +139,20 @@ struct CopilotLoginFlow { enabled: true) let success = NSAlert() - success.messageText = wasRefresh ? "Token Refreshed" : "Account Added" + success.messageText = wasRefresh ? L("Token Refreshed") : L("Account Added") success.informativeText = label success.runModal() case let .failure(error): guard !(error is CancellationError) else { return } let err = NSAlert() - err.messageText = "Login Failed" + err.messageText = L("Login Failed") err.informativeText = error.localizedDescription err.runModal() } } catch { let err = NSAlert() - err.messageText = "Login Failed" + err.messageText = L("Login Failed") err.informativeText = error.localizedDescription err.runModal() } diff --git a/Sources/CodexBar/Providers/Copilot/CopilotProviderImplementation.swift b/Sources/CodexBar/Providers/Copilot/CopilotProviderImplementation.swift index 660a2d2303..6b26386ecf 100644 --- a/Sources/CodexBar/Providers/Copilot/CopilotProviderImplementation.swift +++ b/Sources/CodexBar/Providers/Copilot/CopilotProviderImplementation.swift @@ -1,9 +1,7 @@ import AppKit import CodexBarCore -import CodexBarMacroSupport import SwiftUI -@ProviderImplementationRegistration struct CopilotProviderImplementation: ProviderImplementation { let id: UsageProvider = .copilot let supportsLoginFlow: Bool = true @@ -17,6 +15,9 @@ struct CopilotProviderImplementation: ProviderImplementation { func observeSettings(_ settings: SettingsStore) { _ = settings.copilotAPIToken _ = settings.copilotEnterpriseHost + _ = settings.copilotBudgetExtrasEnabled + _ = settings.copilotBudgetCookieSource + _ = settings.copilotBudgetCookieHeader } @MainActor @@ -31,13 +32,157 @@ struct CopilotProviderImplementation: ProviderImplementation { ("Add Account...", .addProviderAccount(.copilot)) } + @MainActor + func settingsToggles(context: ProviderSettingsContext) -> [ProviderSettingsToggleDescriptor] { + let budgetExtrasBinding = Binding( + get: { context.settings.copilotBudgetExtrasEnabled }, + set: { enabled in + context.settings.copilotBudgetExtrasEnabled = enabled + }) + let budgetExtrasStatus: () -> String? = { + if context.store.snapshot(for: .copilot)?.extraRateWindows?.isEmpty == false { + return nil + } + if context.settings.copilotBudgetCookieSource == .manual, + context.settings.copilotBudgetCookieHeader.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + { + return [ + "Paste a github.com Cookie header, then refresh Copilot.", + "Copilot reauth does not provide the GitHub web cookie used for budgets.", + ].joined(separator: " ") + } + return [ + "Refresh Copilot to load budget bars.", + "Budget extras require a logged-in github.com browser session or a manual Cookie header.", + ].joined(separator: " ") + } + + return [ + ProviderSettingsToggleDescriptor( + id: "copilot-budget-extras", + title: "Budget extras", + subtitle: [ + "Optional.", + "Turn this on to fetch configured GitHub Copilot budget limits and show them as extra bars.", + ].joined(separator: " "), + binding: budgetExtrasBinding, + statusText: budgetExtrasStatus, + actions: [], + isVisible: nil, + onChange: { enabled in + if enabled { + await context.store.refreshProvider(.copilot, allowDisabled: true) + } else { + context.store.clearCopilotBudgetExtras() + } + }, + onAppDidBecomeActive: nil, + onAppearWhenEnabled: nil), + ] + } + + @MainActor + func settingsPickers(context: ProviderSettingsContext) -> [ProviderSettingsPickerDescriptor] { + let extraWindows = context.store.snapshot(for: .copilot)?.extraRateWindows ?? [] + let cookieBinding = Binding( + get: { context.settings.copilotBudgetCookieSource.rawValue }, + set: { raw in + context.settings.copilotBudgetCookieSource = ProviderCookieSource(rawValue: raw) ?? .auto + }) + let cookieOptions = ProviderCookieSourceUI.options( + allowsOff: false, + keychainDisabled: context.settings.debugDisableKeychainAccess) + let cookieSubtitle: () -> String? = { + ProviderCookieSourceUI.subtitle( + source: context.settings.copilotBudgetCookieSource, + keychainDisabled: context.settings.debugDisableKeychainAccess, + auto: "Automatically imports browser cookies for github.com budget extras.", + manual: "Paste a Cookie header from github.com.", + off: "GitHub cookies are disabled.") + } + let options = [ + ProviderSettingsPickerOption( + id: CopilotIconSecondaryWindowSelection.chat, + title: "Chat"), + ] + extraWindows.map { window in + ProviderSettingsPickerOption(id: window.id, title: window.title) + } + + return [ + ProviderSettingsPickerDescriptor( + id: "copilot-icon-secondary-window", + title: "Menu bar secondary metric", + subtitle: "Choose the second meter shown in the menu bar icon.", + placement: .menuBar, + dynamicSubtitle: { + extraWindows.isEmpty + ? "Budget options appear after a refresh finds configured Copilot budgets." + : nil + }, + binding: Binding( + get: { + let selected = context.settings.copilotIconSecondaryWindowID + if selected == CopilotIconSecondaryWindowSelection.chat { + return selected + } + return extraWindows.contains(where: { $0.id == selected }) + ? selected + : CopilotIconSecondaryWindowSelection.chat + }, + set: { selection in + context.settings.copilotIconSecondaryWindowID = selection + }), + options: options, + isVisible: { context.settings.copilotBudgetExtrasEnabled }, + onChange: nil), + ProviderSettingsPickerDescriptor( + id: "copilot-budget-cookie-source", + title: "GitHub cookies", + subtitle: "Automatically imports browser cookies for budget extras.", + dynamicSubtitle: cookieSubtitle, + binding: cookieBinding, + options: cookieOptions, + isVisible: { context.settings.copilotBudgetExtrasEnabled }, + onChange: { _ in + await context.store.refreshProvider(.copilot, allowDisabled: true) + }, + trailingText: { + guard context.settings.copilotBudgetCookieSource != .manual else { return nil } + return ProviderCookieSourceUI.cachedTrailingText(provider: .copilot) + }), + ] + } + @MainActor func settingsFields(context: ProviderSettingsContext) -> [ProviderSettingsFieldDescriptor] { [ + ProviderSettingsFieldDescriptor( + id: "copilot-budget-cookie-header", + title: "Manual GitHub Cookie header", + subtitle: "Paste a github.com Cookie header. Treat this value like a password.", + kind: .secure, + placeholder: "Cookie: ...", + binding: context.stringBinding(\.copilotBudgetCookieHeader), + actions: [ + ProviderSettingsActionDescriptor( + id: "refresh-copilot-budget-cookie", + title: "Refresh budgets", + style: .bordered, + isVisible: nil, + perform: { + await context.store.refreshProvider(.copilot, allowDisabled: true) + }), + ], + isVisible: { + context.settings.copilotBudgetExtrasEnabled && + context.settings.copilotBudgetCookieSource == .manual + }, + onActivate: nil), ProviderSettingsFieldDescriptor( id: "copilot-enterprise-host", title: "Enterprise host", - subtitle: "Optional. Enter your GitHub Enterprise host, for example octocorp.ghe.com. Leave blank for github.com.", + subtitle: "Optional. Enter your GitHub Enterprise host, for example octocorp.ghe.com. " + + "Leave blank for github.com.", kind: .plain, placeholder: "github.com", binding: context.stringBinding(\.copilotEnterpriseHost), diff --git a/Sources/CodexBar/Providers/Copilot/CopilotSettingsStore.swift b/Sources/CodexBar/Providers/Copilot/CopilotSettingsStore.swift index 38c875e8b9..4fdf677eef 100644 --- a/Sources/CodexBar/Providers/Copilot/CopilotSettingsStore.swift +++ b/Sources/CodexBar/Providers/Copilot/CopilotSettingsStore.swift @@ -1,6 +1,10 @@ import CodexBarCore import Foundation +enum CopilotIconSecondaryWindowSelection { + static let chat = "chat" +} + extension SettingsStore { var copilotAPIToken: String { get { self.configSnapshot.providerConfig(for: .copilot)?.sanitizedAPIKey ?? "" } @@ -21,7 +25,48 @@ extension SettingsStore { } } + var copilotBudgetCookieHeader: String { + get { self.configSnapshot.providerConfig(for: .copilot)?.sanitizedCookieHeader ?? "" } + set { + self.updateProviderConfig(provider: .copilot) { entry in + entry.cookieHeader = self.normalizedConfigValue(newValue) + } + self.logSecretUpdate(provider: .copilot, field: "cookieHeader", value: newValue) + } + } + + var copilotBudgetCookieSource: ProviderCookieSource { + get { self.resolvedCookieSource(provider: .copilot, fallback: .auto) } + set { + self.updateProviderConfig(provider: .copilot) { entry in + entry.cookieSource = newValue + } + self.logProviderModeChange(provider: .copilot, field: "cookieSource", value: newValue.rawValue) + } + } + func ensureCopilotAPITokenLoaded() {} + + var copilotIconSecondaryWindowID: String { + get { + let raw = self.copilotIconSecondaryWindowIDRaw.trimmingCharacters(in: .whitespacesAndNewlines) + return raw.isEmpty ? CopilotIconSecondaryWindowSelection.chat : raw + } + set { + let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines) + self.copilotIconSecondaryWindowIDRaw = trimmed.isEmpty + ? CopilotIconSecondaryWindowSelection.chat + : trimmed + } + } + + func copilotIconSecondaryWindowOverrideID(snapshot: UsageSnapshot?) -> String? { + guard self.copilotBudgetExtrasEnabled else { return nil } + let selected = self.copilotIconSecondaryWindowID + guard selected != CopilotIconSecondaryWindowSelection.chat else { return nil } + guard snapshot?.extraRateWindows?.contains(where: { $0.id == selected }) == true else { return nil } + return selected + } } extension SettingsStore { @@ -36,6 +81,10 @@ extension SettingsStore { let host = CopilotDeviceFlow.normalizedHost(self.copilotEnterpriseHost) return ProviderSettingsSnapshot.CopilotProviderSettings( apiToken: self.normalizedConfigValue(token), - enterpriseHost: host == CopilotDeviceFlow.defaultHost ? nil : host) + enterpriseHost: host == CopilotDeviceFlow.defaultHost ? nil : host, + selectedAccountExternalIdentifier: account?.externalIdentifier.flatMap(self.normalizedConfigValue), + budgetExtrasEnabled: self.copilotBudgetExtrasEnabled, + budgetCookieSource: self.copilotBudgetCookieSource, + manualBudgetCookieHeader: self.normalizedConfigValue(self.copilotBudgetCookieHeader)) } } diff --git a/Sources/CodexBar/Providers/Copilot/UsageStore+CopilotBudgets.swift b/Sources/CodexBar/Providers/Copilot/UsageStore+CopilotBudgets.swift new file mode 100644 index 0000000000..0d6dc4075c --- /dev/null +++ b/Sources/CodexBar/Providers/Copilot/UsageStore+CopilotBudgets.swift @@ -0,0 +1,19 @@ +import CodexBarCore +import Foundation + +@MainActor +extension UsageStore { + func clearCopilotBudgetExtras() { + if let snapshot = self.snapshots[.copilot], + snapshot.extraRateWindows?.isEmpty == false + { + let updated = snapshot.with(extraRateWindows: nil) + self.snapshots[.copilot] = updated + self.lastKnownResetSnapshots[.copilot] = updated + } else if let resetSnapshot = self.lastKnownResetSnapshots[.copilot], + resetSnapshot.extraRateWindows?.isEmpty == false + { + self.lastKnownResetSnapshots[.copilot] = resetSnapshot.with(extraRateWindows: nil) + } + } +} diff --git a/Sources/CodexBar/Providers/Crof/CrofProviderImplementation.swift b/Sources/CodexBar/Providers/Crof/CrofProviderImplementation.swift index 3e71650801..f53257e24a 100644 --- a/Sources/CodexBar/Providers/Crof/CrofProviderImplementation.swift +++ b/Sources/CodexBar/Providers/Crof/CrofProviderImplementation.swift @@ -1,9 +1,7 @@ import AppKit import CodexBarCore -import CodexBarMacroSupport import Foundation -@ProviderImplementationRegistration struct CrofProviderImplementation: ProviderImplementation { let id: UsageProvider = .crof diff --git a/Sources/CodexBar/Providers/Cursor/CursorLoginFlow.swift b/Sources/CodexBar/Providers/Cursor/CursorLoginFlow.swift index 04f75022d2..090bc1a6ee 100644 --- a/Sources/CodexBar/Providers/Cursor/CursorLoginFlow.swift +++ b/Sources/CodexBar/Providers/Cursor/CursorLoginFlow.swift @@ -2,8 +2,33 @@ import CodexBarCore @MainActor extension StatusItemController { - func runCursorLoginFlow() async { - let cursorRunner = CursorLoginRunner(browserDetection: self.store.browserDetection) + func runCursorLoginFlow() async -> Bool { + // Acquire cache ownership before retiring refreshes so a cancellation-ignoring refresh cannot write in the + // gap. CursorLoginRunner also holds a nested gate for standalone callers and tests. + let cacheMutationGate = CookieHeaderCache.beginConditionalMutationGate(provider: .cursor) + defer { CookieHeaderCache.endConditionalMutationGate(cacheMutationGate) } + + let currentSnapshot = self.store.snapshot(for: .cursor) + let currentIdentity = currentSnapshot?.identity(for: .cursor) + let accountPolicy = CursorLoginRunner.accountPolicy( + configuredSource: self.settings.cursorCookieSource, + identity: currentIdentity, + hasPriorSnapshot: currentSnapshot != nil) + + // Stop older refreshes from publishing while the interactive login replaces the session. + self.store.invalidateProviderRefreshRequests(.cursor) + let cursorRunner = CursorLoginRunner( + browserDetection: self.store.browserDetection, + priorAccount: accountPolicy.priorAccount, + requiresAccountConfirmation: accountPolicy.requiresConfirmation, + replaceSessionCache: { session in + await CursorLoginRunner.replaceCachedSession(session) { + // Finalize without suspending: future refreshes use the chosen cached browser session, + // while any refresh that started during the interactive flow loses publication ownership. + self.settings.cursorCookieSource = .auto + self.store.invalidateProviderRefreshRequests(.cursor) + } + }) let phaseHandler: @MainActor (CursorLoginRunner.Phase) -> Void = { [weak self] phase in switch phase { case .loading, .waitingLogin: @@ -13,13 +38,25 @@ extension StatusItemController { } } let result = await cursorRunner.run(onPhaseChange: phaseHandler) - guard !Task.isCancelled else { return } + guard Self.shouldFinalizeCursorLoginResult(result, taskIsCancelled: Task.isCancelled) else { return false } self.loginPhase = .idle self.presentCursorLoginResult(result) let outcome = self.describe(result.outcome) self.loginLogger.info("Cursor login", metadata: ["outcome": outcome]) if case .success = result.outcome { self.postLoginNotification(for: .cursor) + return true + } + return false + } + + nonisolated static func shouldFinalizeCursorLoginResult( + _ result: CursorLoginRunner.Result, + taskIsCancelled: Bool) -> Bool + { + if case .success = result.outcome { + return true } + return !taskIsCancelled } } diff --git a/Sources/CodexBar/Providers/Cursor/CursorProviderImplementation.swift b/Sources/CodexBar/Providers/Cursor/CursorProviderImplementation.swift index a8638af891..b0304510e1 100644 --- a/Sources/CodexBar/Providers/Cursor/CursorProviderImplementation.swift +++ b/Sources/CodexBar/Providers/Cursor/CursorProviderImplementation.swift @@ -1,9 +1,7 @@ import CodexBarCore -import CodexBarMacroSupport import Foundation import SwiftUI -@ProviderImplementationRegistration struct CursorProviderImplementation: ProviderImplementation { let id: UsageProvider = .cursor let supportsLoginFlow: Bool = true @@ -69,9 +67,7 @@ struct CursorProviderImplementation: ProviderImplementation { isVisible: nil, onChange: nil, trailingText: { - guard let entry = CookieHeaderCache.load(provider: .cursor) else { return nil } - let when = entry.storedAt.relativeDescription() - return "Cached: \(entry.sourceLabel) • \(when)" + ProviderCookieSourceUI.cachedTrailingText(provider: .cursor) }), ] } @@ -85,15 +81,20 @@ struct CursorProviderImplementation: ProviderImplementation { @MainActor func runLoginFlow(context: ProviderLoginContext) async -> Bool { await context.controller.runCursorLoginFlow() - return true } @MainActor func appendUsageMenuEntries(context: ProviderMenuUsageContext, entries: inout [ProviderMenuEntry]) { guard let cost = context.snapshot?.providerCost, cost.currencyCode != "Quota" else { return } - let used = UsageFormatter.currencyString(cost.used, currencyCode: cost.currencyCode) + let used = UsageFormatter.convertedCostString( + cost.used, + preferredCurrency: context.settings.preferredCurrencyCode, + providerCurrency: cost.currencyCode) if cost.limit > 0 { - let limitStr = UsageFormatter.currencyString(cost.limit, currencyCode: cost.currencyCode) + let limitStr = UsageFormatter.convertedCostString( + cost.limit, + preferredCurrency: context.settings.preferredCurrencyCode, + providerCurrency: cost.currencyCode) entries.append(.text(String(format: L("cursor_on_demand_with_limit"), used, limitStr), .primary)) } else { entries.append(.text(String(format: L("cursor_on_demand"), used), .primary)) diff --git a/Sources/CodexBar/Providers/Cursor/CursorSettingsStore.swift b/Sources/CodexBar/Providers/Cursor/CursorSettingsStore.swift index 92bc131c75..0de2438feb 100644 --- a/Sources/CodexBar/Providers/Cursor/CursorSettingsStore.swift +++ b/Sources/CodexBar/Providers/Cursor/CursorSettingsStore.swift @@ -28,36 +28,10 @@ extension SettingsStore { extension SettingsStore { func cursorSettingsSnapshot(tokenOverride: TokenAccountOverride?) -> ProviderSettingsSnapshot .CursorProviderSettings { - ProviderSettingsSnapshot.CursorProviderSettings( - cookieSource: self.cursorSnapshotCookieSource(tokenOverride: tokenOverride), - manualCookieHeader: self.cursorSnapshotCookieHeader(tokenOverride: tokenOverride)) - } - - private func cursorSnapshotCookieHeader(tokenOverride: TokenAccountOverride?) -> String { - let fallback = self.cursorCookieHeader - guard let support = TokenAccountSupportCatalog.support(for: .cursor), - case .cookieHeader = support.injection - else { - return fallback - } - guard let account = ProviderTokenAccountSelection.selectedAccount( + self.resolvedCookieSettings( provider: .cursor, - settings: self, - override: tokenOverride) - else { - return fallback - } - return TokenAccountSupportCatalog.normalizedCookieHeader(account.token, support: support) - } - - private func cursorSnapshotCookieSource(tokenOverride: TokenAccountOverride?) -> ProviderCookieSource { - let fallback = self.cursorCookieSource - guard let support = TokenAccountSupportCatalog.support(for: .cursor), - support.requiresManualCookieSource - else { - return fallback - } - if self.tokenAccounts(for: .cursor).isEmpty { return fallback } - return .manual + configuredSource: self.cursorCookieSource, + configuredHeader: self.cursorCookieHeader, + tokenOverride: tokenOverride) } } diff --git a/Sources/CodexBar/Providers/DeepInfra/DeepInfraProviderImplementation.swift b/Sources/CodexBar/Providers/DeepInfra/DeepInfraProviderImplementation.swift new file mode 100644 index 0000000000..b8271868c5 --- /dev/null +++ b/Sources/CodexBar/Providers/DeepInfra/DeepInfraProviderImplementation.swift @@ -0,0 +1,27 @@ +import CodexBarCore +import Foundation + +struct DeepInfraProviderImplementation: ProviderImplementation { + let id: UsageProvider = .deepinfra + + @MainActor + func presentation(context _: ProviderPresentationContext) -> ProviderPresentation { + ProviderPresentation { _ in "api" } + } + + @MainActor + func observeSettings(_: SettingsStore) {} + + @MainActor + func isAvailable(context: ProviderAvailabilityContext) -> Bool { + if DeepInfraSettingsReader.apiKey(environment: context.environment) != nil { + return true + } + return !context.settings.tokenAccounts(for: .deepinfra).isEmpty + } + + @MainActor + func settingsFields(context _: ProviderSettingsContext) -> [ProviderSettingsFieldDescriptor] { + [] + } +} diff --git a/Sources/CodexBar/Providers/DeepSeek/DeepSeekProviderImplementation.swift b/Sources/CodexBar/Providers/DeepSeek/DeepSeekProviderImplementation.swift index e72ec76f08..ad6f352698 100644 --- a/Sources/CodexBar/Providers/DeepSeek/DeepSeekProviderImplementation.swift +++ b/Sources/CodexBar/Providers/DeepSeek/DeepSeekProviderImplementation.swift @@ -1,25 +1,72 @@ import CodexBarCore -import CodexBarMacroSupport import Foundation +import SwiftUI -@ProviderImplementationRegistration struct DeepSeekProviderImplementation: ProviderImplementation { let id: UsageProvider = .deepseek @MainActor func presentation(context _: ProviderPresentationContext) -> ProviderPresentation { - ProviderPresentation { _ in "api" } + ProviderPresentation { context in + context.store.sourceLabel(for: context.provider) + } } @MainActor func observeSettings(_: SettingsStore) {} @MainActor - func isAvailable(context: ProviderAvailabilityContext) -> Bool { - if DeepSeekSettingsReader.apiKey(environment: context.environment) != nil { - return true - } - return !context.settings.tokenAccounts(for: .deepseek).isEmpty + func settingsPickers(context: ProviderSettingsContext) -> [ProviderSettingsPickerDescriptor] { + let presentationSnapshot = context.store.presentationSnapshot(for: .deepseek) + ?? context.store.lastKnownResetSnapshots[.deepseek] + let profiles = presentationSnapshot?.deepseekPlatformProfiles ?? [] + guard profiles.count > 1 || presentationSnapshot?.deepseekDetailedUsageState == .profileSelectionRequired + else { return [] } + let apiKey = context.settings.selectedTokenAccount(for: .deepseek)?.token + ?? DeepSeekSettingsReader.apiKey(environment: context.store.environmentBase) + let source = context.settings.providerConfig(for: .deepseek)?.source ?? .auto + let selectedProfileID = context.settings.deepseekProfileID(apiKey: apiKey) + let hasValidSelection = profiles.contains { $0.id == selectedProfileID } + let profileBinding = Binding( + get: { + let profileID = context.settings.deepseekProfileID(apiKey: apiKey) + return profiles.contains { $0.id == profileID } ? profileID : "" + }, + set: { profileID in + guard !profileID.isEmpty else { return } + context.store.beginDeepSeekProfileTransition(preservingBalance: apiKey != nil && source != .web) + context.settings.setDeepSeekProfileID(profileID, apiKey: apiKey) + }) + let options = (hasValidSelection + ? [] + : [ProviderSettingsPickerOption(id: "", title: "Select profile…")]) + + profiles.map { ProviderSettingsPickerOption(id: $0.id, title: $0.name) } + + return [ + ProviderSettingsPickerDescriptor( + id: "deepseek-chrome-profile", + title: "Chrome profile", + subtitle: "Choose which signed-in DeepSeek Platform session supplies detailed usage.", + dynamicSubtitle: { + context.store.refreshingProviders.contains(.deepseek) + ? "Refreshing" + : nil + }, + binding: profileBinding, + options: options, + isVisible: nil, + isEnabled: { !context.store.refreshingProviders.contains(.deepseek) }, + onChange: { _ in + await ProviderInteractionContext.$current.withValue(.userInitiated) { + await context.store.refreshProvider(.deepseek, allowDisabled: true) + } + }), + ] + } + + @MainActor + func isAvailable(context _: ProviderAvailabilityContext) -> Bool { + true } @MainActor diff --git a/Sources/CodexBar/Providers/DeepSeek/DeepSeekSettingsStore.swift b/Sources/CodexBar/Providers/DeepSeek/DeepSeekSettingsStore.swift new file mode 100644 index 0000000000..bc24e63341 --- /dev/null +++ b/Sources/CodexBar/Providers/DeepSeek/DeepSeekSettingsStore.swift @@ -0,0 +1,28 @@ +import CodexBarCore +import Foundation + +extension SettingsStore { + func deepseekProfileID(apiKey: String?) -> String { + _ = self.configRevision + _ = self.providerDetailSettingsRevision + guard let config = self.config.providerConfig(for: .deepseek), + let profileID = config.sanitizedDeepSeekProfileID + else { return "" } + let accountID = self.selectedTokenAccount(for: .deepseek)?.id + let expectedScope = DeepSeekSettingsReader.profileScope(selectedTokenAccountID: accountID, apiKey: apiKey) + guard let expectedScope, config.sanitizedDeepSeekProfileScope == expectedScope else { return "" } + return profileID + } + + func setDeepSeekProfileID(_ newValue: String, apiKey: String?) { + let profileID = self.normalizedConfigValue(newValue) + let profileScope = DeepSeekSettingsReader.profileScope( + selectedTokenAccountID: self.selectedTokenAccount(for: .deepseek)?.id, + apiKey: apiKey) + guard profileID == nil || profileScope != nil else { return } + self.updateProviderDetailConfig(provider: .deepseek) { entry in + entry.deepseekProfileID = profileID + entry.deepseekProfileScope = profileID == nil ? nil : profileScope + } + } +} diff --git a/Sources/CodexBar/Providers/Deepgram/DeepgramProviderImplementation.swift b/Sources/CodexBar/Providers/Deepgram/DeepgramProviderImplementation.swift index 54616537c3..a230196345 100644 --- a/Sources/CodexBar/Providers/Deepgram/DeepgramProviderImplementation.swift +++ b/Sources/CodexBar/Providers/Deepgram/DeepgramProviderImplementation.swift @@ -1,8 +1,6 @@ import CodexBarCore -import CodexBarMacroSupport import Foundation -@ProviderImplementationRegistration struct DeepgramProviderImplementation: ProviderImplementation { let id: UsageProvider = .deepgram diff --git a/Sources/CodexBar/Providers/Devin/DevinProviderImplementation.swift b/Sources/CodexBar/Providers/Devin/DevinProviderImplementation.swift new file mode 100644 index 0000000000..f69c529710 --- /dev/null +++ b/Sources/CodexBar/Providers/Devin/DevinProviderImplementation.swift @@ -0,0 +1,135 @@ +import AppKit +import CodexBarCore +import Foundation +import SwiftUI + +struct DevinProviderImplementation: ProviderImplementation { + let id: UsageProvider = .devin + let supportsLoginFlow: Bool = true + + @MainActor + func presentation(context _: ProviderPresentationContext) -> ProviderPresentation { + ProviderPresentation { context in + context.store.sourceLabel(for: context.provider) + } + } + + @MainActor + func observeSettings(_ settings: SettingsStore) { + _ = settings.devinCookieSource + _ = settings.devinBearerToken + _ = settings.devinOrganization + } + + @MainActor + func settingsSnapshot(context: ProviderSettingsSnapshotContext) -> ProviderSettingsSnapshotContribution? { + .devin(context.settings.devinSettingsSnapshot(tokenOverride: context.tokenOverride)) + } + + @MainActor + func settingsPickers(context: ProviderSettingsContext) -> [ProviderSettingsPickerDescriptor] { + let cookieBinding = Binding( + get: { context.settings.devinCookieSource.rawValue }, + set: { raw in + context.settings.devinCookieSource = ProviderCookieSource(rawValue: raw) ?? .auto + }) + let cookieOptions = ProviderCookieSourceUI.options( + allowsOff: false, + keychainDisabled: context.settings.debugDisableKeychainAccess) + let subtitle: () -> String? = { + ProviderCookieSourceUI.subtitle( + source: context.settings.devinCookieSource, + keychainDisabled: context.settings.debugDisableKeychainAccess, + auto: "Automatically imports the app.devin.ai session from Chrome.", + manual: "Paste an Authorization Bearer token from app.devin.ai.", + off: "Paste an Authorization Bearer token from app.devin.ai.") + } + + return [ + ProviderSettingsPickerDescriptor( + id: "devin-cookie-source", + title: "Auth source", + subtitle: "Automatically imports the app.devin.ai session from Chrome.", + dynamicSubtitle: subtitle, + binding: cookieBinding, + options: cookieOptions, + isVisible: nil, + onChange: nil), + ] + } + + @MainActor + func settingsFields(context: ProviderSettingsContext) -> [ProviderSettingsFieldDescriptor] { + [ + ProviderSettingsFieldDescriptor( + id: "devin-organization", + title: "Organization", + subtitle: "Optional. Use the slug from app.devin.ai/org/, or paste the full Devin org URL.", + kind: .plain, + placeholder: "org/example-org", + binding: context.stringBinding(\.devinOrganization), + actions: [ + ProviderSettingsActionDescriptor( + id: "devin-open-usage", + title: "Open Devin Usage", + style: .link, + isVisible: nil, + perform: { + NSWorkspace.shared.open(Self.usageURL(organization: context.settings.devinOrganization)) + }), + ], + isVisible: nil, + onActivate: nil), + ProviderSettingsFieldDescriptor( + id: "devin-bearer-token", + title: "Bearer token", + subtitle: "Paste the Authorization header value from app.devin.ai.", + kind: .secure, + placeholder: "Bearer eyJ...", + binding: context.stringBinding(\.devinBearerToken), + actions: [], + isVisible: { context.settings.devinCookieSource == .manual }, + onActivate: nil), + ] + } + + @MainActor + func loginMenuAction(context _: ProviderMenuLoginContext) + -> (label: String, action: MenuDescriptor.MenuAction)? + { + ("Open Devin...", .loginToProvider(url: Self.usageURL(organization: nil).absoluteString)) + } + + @MainActor + func runLoginFlow(context: ProviderLoginContext) async -> Bool { + let organization = context.controller.settings.devinOrganization + NSWorkspace.shared.open(Self.usageURL(organization: organization)) + return false + } + + private static func usageURL(organization: String?) -> URL { + let normalized = DevinUsageFetcher.normalizedOrganization(organization) + let urlString: String + if let normalized, normalized.hasPrefix("org/") { + let slug = String(normalized.dropFirst(4)) + urlString = "https://app.devin.ai/org/\(slug)/settings/usage" + } else { + urlString = "https://app.devin.ai/settings/usage" + } + return URL(string: urlString) ?? URL(string: "https://app.devin.ai")! + } + + @MainActor + func appendUsageMenuEntries(context: ProviderMenuUsageContext, entries: inout [ProviderMenuEntry]) { + guard context.settings.showOptionalCreditsAndExtraUsage, + let cost = context.snapshot?.providerCost, + cost.period == "Extra usage balance" + else { return } + + let balance = UsageFormatter.convertedCostString( + cost.used, + preferredCurrency: context.settings.preferredCurrencyCode, + providerCurrency: cost.currencyCode) + entries.append(.text(L("Extra usage balance: %@", balance), .primary)) + } +} diff --git a/Sources/CodexBar/Providers/Devin/DevinSettingsStore.swift b/Sources/CodexBar/Providers/Devin/DevinSettingsStore.swift new file mode 100644 index 0000000000..430f440439 --- /dev/null +++ b/Sources/CodexBar/Providers/Devin/DevinSettingsStore.swift @@ -0,0 +1,43 @@ +import CodexBarCore +import Foundation + +extension SettingsStore { + var devinBearerToken: String { + get { self.configSnapshot.providerConfig(for: .devin)?.sanitizedCookieHeader ?? "" } + set { + self.updateProviderConfig(provider: .devin) { entry in + entry.cookieHeader = self.normalizedConfigValue(newValue) + } + self.logSecretUpdate(provider: .devin, field: "cookieHeader", value: newValue) + } + } + + var devinCookieSource: ProviderCookieSource { + get { self.resolvedCookieSource(provider: .devin, fallback: .auto) } + set { + self.updateProviderConfig(provider: .devin) { entry in + entry.cookieSource = newValue + } + self.logProviderModeChange(provider: .devin, field: "cookieSource", value: newValue.rawValue) + } + } + + var devinOrganization: String { + get { self.configSnapshot.providerConfig(for: .devin)?.sanitizedWorkspaceID ?? "" } + set { + self.updateProviderConfig(provider: .devin) { entry in + entry.workspaceID = self.normalizedConfigValue(newValue) + } + } + } +} + +extension SettingsStore { + func devinSettingsSnapshot(tokenOverride _: TokenAccountOverride?) -> ProviderSettingsSnapshot + .DevinProviderSettings { + ProviderSettingsSnapshot.DevinProviderSettings( + cookieSource: self.devinCookieSource, + manualBearerToken: self.devinBearerToken, + organization: self.devinOrganization) + } +} diff --git a/Sources/CodexBar/Providers/Doubao/DoubaoProviderImplementation.swift b/Sources/CodexBar/Providers/Doubao/DoubaoProviderImplementation.swift index fe974bed89..81f1fde4f7 100644 --- a/Sources/CodexBar/Providers/Doubao/DoubaoProviderImplementation.swift +++ b/Sources/CodexBar/Providers/Doubao/DoubaoProviderImplementation.swift @@ -1,15 +1,15 @@ import AppKit import CodexBarCore -import CodexBarMacroSupport import Foundation -@ProviderImplementationRegistration struct DoubaoProviderImplementation: ProviderImplementation { let id: UsageProvider = .doubao @MainActor func observeSettings(_ settings: SettingsStore) { _ = settings.doubaoAPIToken + _ = settings.doubaoSecretAccessKey + _ = settings.doubaoRegion } @MainActor @@ -17,11 +17,11 @@ struct DoubaoProviderImplementation: ProviderImplementation { [ ProviderSettingsFieldDescriptor( id: "doubao-api-token", - title: "API key", - subtitle: "Stored in ~/.codexbar/config.json. Get your API key from the Volcengine " - + "Ark console.", + title: "API key / Access key ID", + subtitle: "Without configured API credentials, install and authenticate 'arkcli' for " + + "Coding/Agent Plan usage. Existing API credentials remain authoritative.", kind: .secure, - placeholder: "ark-...", + placeholder: "ark-... or AKLT...", binding: context.stringBinding(\.doubaoAPIToken), actions: [ ProviderSettingsActionDescriptor( @@ -37,6 +37,26 @@ struct DoubaoProviderImplementation: ProviderImplementation { ], isVisible: nil, onActivate: nil), + ProviderSettingsFieldDescriptor( + id: "doubao-secret-access-key", + title: "Secret access key", + subtitle: "Optional. Only needed if arkcli is unavailable and you use Volcengine AK/SK signing.", + kind: .secure, + placeholder: "", + binding: context.stringBinding(\.doubaoSecretAccessKey), + actions: [], + isVisible: nil, + onActivate: nil), + ProviderSettingsFieldDescriptor( + id: "doubao-region", + title: "Region", + subtitle: "Volcengine Ark region. Defaults to cn-beijing.", + kind: .plain, + placeholder: DoubaoSettingsReader.defaultRegion, + binding: context.stringBinding(\.doubaoRegion), + actions: [], + isVisible: nil, + onActivate: nil), ] } } diff --git a/Sources/CodexBar/Providers/Doubao/DoubaoSettingsStore.swift b/Sources/CodexBar/Providers/Doubao/DoubaoSettingsStore.swift index 4d69a273f1..7313926b24 100644 --- a/Sources/CodexBar/Providers/Doubao/DoubaoSettingsStore.swift +++ b/Sources/CodexBar/Providers/Doubao/DoubaoSettingsStore.swift @@ -11,4 +11,24 @@ extension SettingsStore { self.logSecretUpdate(provider: .doubao, field: "apiKey", value: newValue) } } + + var doubaoSecretAccessKey: String { + get { self.configSnapshot.providerConfig(for: .doubao)?.sanitizedSecretKey ?? "" } + set { + self.updateProviderConfig(provider: .doubao) { entry in + entry.secretKey = self.normalizedConfigValue(newValue) + } + self.logSecretUpdate(provider: .doubao, field: "secretAccessKey", value: newValue) + } + } + + var doubaoRegion: String { + get { self.configSnapshot.providerConfig(for: .doubao)?.sanitizedRegion ?? "" } + set { + self.updateProviderConfig(provider: .doubao) { entry in + entry.region = self.normalizedConfigValue(newValue) + } + self.logProviderModeChange(provider: .doubao, field: "region", value: newValue) + } + } } diff --git a/Sources/CodexBar/Providers/ElevenLabs/ElevenLabsProviderImplementation.swift b/Sources/CodexBar/Providers/ElevenLabs/ElevenLabsProviderImplementation.swift index d6b4c285e7..cf3d314022 100644 --- a/Sources/CodexBar/Providers/ElevenLabs/ElevenLabsProviderImplementation.swift +++ b/Sources/CodexBar/Providers/ElevenLabs/ElevenLabsProviderImplementation.swift @@ -1,8 +1,6 @@ import CodexBarCore -import CodexBarMacroSupport import Foundation -@ProviderImplementationRegistration struct ElevenLabsProviderImplementation: ProviderImplementation { let id: UsageProvider = .elevenlabs diff --git a/Sources/CodexBar/Providers/Factory/FactoryProviderImplementation.swift b/Sources/CodexBar/Providers/Factory/FactoryProviderImplementation.swift index 3df11a9418..751cb3d44f 100644 --- a/Sources/CodexBar/Providers/Factory/FactoryProviderImplementation.swift +++ b/Sources/CodexBar/Providers/Factory/FactoryProviderImplementation.swift @@ -1,15 +1,16 @@ +import AppKit import CodexBarCore -import CodexBarMacroSupport import Foundation import SwiftUI -@ProviderImplementationRegistration struct FactoryProviderImplementation: ProviderImplementation { let id: UsageProvider = .factory let supportsLoginFlow: Bool = true @MainActor func observeSettings(_ settings: SettingsStore) { + _ = settings.factoryUsageDataSource + _ = settings.factoryAPIKey _ = settings.factoryCookieSource _ = settings.factoryCookieHeader } @@ -19,6 +20,20 @@ struct FactoryProviderImplementation: ProviderImplementation { .factory(context.settings.factorySettingsSnapshot(tokenOverride: context.tokenOverride)) } + @MainActor + func defaultSourceLabel(context: ProviderSourceLabelContext) -> String? { + context.settings.factoryUsageDataSource.rawValue + } + + @MainActor + func sourceMode(context: ProviderSourceModeContext) -> ProviderSourceMode { + switch context.settings.factoryUsageDataSource { + case .api: .api + case .web: .web + case .auto, .cli, .oauth: .auto + } + } + @MainActor func tokenAccountsVisibility(context: ProviderSettingsContext, support: TokenAccountSupport) -> Bool { guard support.requiresManualCookieSource else { return true } @@ -35,6 +50,17 @@ struct FactoryProviderImplementation: ProviderImplementation { @MainActor func settingsPickers(context: ProviderSettingsContext) -> [ProviderSettingsPickerDescriptor] { + let usageBinding = Binding( + get: { context.settings.factoryUsageDataSource.rawValue }, + set: { raw in + context.settings.factoryUsageDataSource = ProviderSourceMode(rawValue: raw) ?? .auto + }) + let usageOptions = [ + ProviderSettingsPickerOption(id: ProviderSourceMode.auto.rawValue, title: "Auto"), + ProviderSettingsPickerOption(id: ProviderSourceMode.api.rawValue, title: "API key"), + ProviderSettingsPickerOption(id: ProviderSourceMode.web.rawValue, title: "Browser cookies"), + ] + let cookieBinding = Binding( get: { context.settings.factoryCookieSource.rawValue }, set: { raw in @@ -54,6 +80,20 @@ struct FactoryProviderImplementation: ProviderImplementation { } return [ + ProviderSettingsPickerDescriptor( + id: "factory-usage-source", + title: "Usage source", + subtitle: "Auto tries a Factory API key first, then falls back to cookies/WorkOS on " + + "auth or recoverable API failures.", + binding: usageBinding, + options: usageOptions, + isVisible: nil, + onChange: nil, + trailingText: { + guard context.settings.factoryUsageDataSource == .auto else { return nil } + let label = context.store.sourceLabel(for: .factory) + return label == "auto" ? nil : label + }), ProviderSettingsPickerDescriptor( id: "factory-cookie-source", title: "Cookie source", @@ -64,17 +104,37 @@ struct FactoryProviderImplementation: ProviderImplementation { isVisible: nil, onChange: nil, trailingText: { - guard let entry = CookieHeaderCache.load(provider: .factory) else { return nil } - let when = entry.storedAt.relativeDescription() - return "Cached: \(entry.sourceLabel) • \(when)" + ProviderCookieSourceUI.cachedTrailingText(provider: .factory) }), ] } @MainActor func settingsFields(context: ProviderSettingsContext) -> [ProviderSettingsFieldDescriptor] { - _ = context - return [] + [ + ProviderSettingsFieldDescriptor( + id: "factory-api-key", + title: "API key", + subtitle: "Stored in ~/.codexbar/config.json. You can also provide FACTORY_API_KEY or " + + "~/.factory/.env.", + kind: .secure, + placeholder: "fk-...", + binding: context.stringBinding(\.factoryAPIKey), + actions: [ + ProviderSettingsActionDescriptor( + id: "factory-open-api-keys", + title: "Open API keys", + style: .link, + isVisible: nil, + perform: { + if let url = URL(string: "https://app.factory.ai/settings/api-keys") { + NSWorkspace.shared.open(url) + } + }), + ], + isVisible: nil, + onActivate: nil), + ] } @MainActor @@ -97,7 +157,10 @@ struct FactoryProviderImplementation: ProviderImplementation { cost.period == "Extra usage balance" else { return } - let balance = UsageFormatter.currencyString(cost.used, currencyCode: cost.currencyCode) + let balance = UsageFormatter.convertedCostString( + cost.used, + preferredCurrency: context.settings.preferredCurrencyCode, + providerCurrency: cost.currencyCode) entries.append(.text(L("Extra usage balance: %@", balance), .primary)) } } diff --git a/Sources/CodexBar/Providers/Factory/FactorySettingsStore.swift b/Sources/CodexBar/Providers/Factory/FactorySettingsStore.swift index 968132998d..bb9172869a 100644 --- a/Sources/CodexBar/Providers/Factory/FactorySettingsStore.swift +++ b/Sources/CodexBar/Providers/Factory/FactorySettingsStore.swift @@ -2,6 +2,38 @@ import CodexBarCore import Foundation extension SettingsStore { + var factoryUsageDataSource: ProviderSourceMode { + get { + switch self.configSnapshot.providerConfig(for: .factory)?.source { + case .api: .api + case .web: .web + case .auto, .cli, .oauth, .none: .auto + } + } + set { + let source: ProviderSourceMode? = switch newValue { + case .auto: .auto + case .api: .api + case .web: .web + case .cli, .oauth: .auto + } + self.updateProviderConfig(provider: .factory) { entry in + entry.source = source + } + self.logProviderModeChange(provider: .factory, field: "usageSource", value: newValue.rawValue) + } + } + + var factoryAPIKey: String { + get { self.configSnapshot.providerConfig(for: .factory)?.sanitizedAPIKey ?? "" } + set { + self.updateProviderConfig(provider: .factory) { entry in + entry.apiKey = self.normalizedConfigValue(newValue) + } + self.logSecretUpdate(provider: .factory, field: "apiKey", value: newValue) + } + } + var factoryCookieHeader: String { get { self.configSnapshot.providerConfig(for: .factory)?.sanitizedCookieHeader ?? "" } set { @@ -28,36 +60,10 @@ extension SettingsStore { extension SettingsStore { func factorySettingsSnapshot(tokenOverride: TokenAccountOverride?) -> ProviderSettingsSnapshot .FactoryProviderSettings { - ProviderSettingsSnapshot.FactoryProviderSettings( - cookieSource: self.factorySnapshotCookieSource(tokenOverride: tokenOverride), - manualCookieHeader: self.factorySnapshotCookieHeader(tokenOverride: tokenOverride)) - } - - private func factorySnapshotCookieHeader(tokenOverride: TokenAccountOverride?) -> String { - let fallback = self.factoryCookieHeader - guard let support = TokenAccountSupportCatalog.support(for: .factory), - case .cookieHeader = support.injection - else { - return fallback - } - guard let account = ProviderTokenAccountSelection.selectedAccount( + self.resolvedCookieSettings( provider: .factory, - settings: self, - override: tokenOverride) - else { - return fallback - } - return TokenAccountSupportCatalog.normalizedCookieHeader(account.token, support: support) - } - - private func factorySnapshotCookieSource(tokenOverride: TokenAccountOverride?) -> ProviderCookieSource { - let fallback = self.factoryCookieSource - guard let support = TokenAccountSupportCatalog.support(for: .factory), - support.requiresManualCookieSource - else { - return fallback - } - if self.tokenAccounts(for: .factory).isEmpty { return fallback } - return .manual + configuredSource: self.factoryCookieSource, + configuredHeader: self.factoryCookieHeader, + tokenOverride: tokenOverride) } } diff --git a/Sources/CodexBar/Providers/Gemini/GeminiProviderImplementation.swift b/Sources/CodexBar/Providers/Gemini/GeminiProviderImplementation.swift index 12b6e7c917..ecbfb94d40 100644 --- a/Sources/CodexBar/Providers/Gemini/GeminiProviderImplementation.swift +++ b/Sources/CodexBar/Providers/Gemini/GeminiProviderImplementation.swift @@ -1,15 +1,45 @@ import CodexBarCore -import CodexBarMacroSupport import Foundation +import SwiftUI -@ProviderImplementationRegistration struct GeminiProviderImplementation: ProviderImplementation { let id: UsageProvider = .gemini let supportsLoginFlow: Bool = true + @MainActor + func settingsActions(context: ProviderSettingsContext) -> [ProviderSettingsActionsDescriptor] { + guard Self.showsAntigravityMigrationAction(context: context) else { return [] } + return [ + ProviderSettingsActionsDescriptor( + id: "gemini-antigravity-migration", + title: "Gemini CLI migration", + subtitle: GeminiConsumerTierMigration.deprecationError, + actions: [ + ProviderSettingsActionDescriptor( + id: "gemini-enable-antigravity", + title: "Enable Antigravity provider", + style: .bordered, + isVisible: nil, + perform: { + context.settings.setProviderEnabled( + provider: .antigravity, + metadata: ProviderDescriptorRegistry.descriptor(for: .antigravity).metadata, + enabled: true) + await context.store.refreshProvider(.antigravity, allowDisabled: true) + }), + ], + isVisible: nil), + ] + } + @MainActor func runLoginFlow(context: ProviderLoginContext) async -> Bool { await context.controller.runGeminiLoginFlow() return false } + + @MainActor + private static func showsAntigravityMigrationAction(context: ProviderSettingsContext) -> Bool { + context.store.geminiObservedConsumerTierDeprecation + } } diff --git a/Sources/CodexBar/Providers/Grok/GrokProviderImplementation.swift b/Sources/CodexBar/Providers/Grok/GrokProviderImplementation.swift index e37cd9bb05..1d53f20a08 100644 --- a/Sources/CodexBar/Providers/Grok/GrokProviderImplementation.swift +++ b/Sources/CodexBar/Providers/Grok/GrokProviderImplementation.swift @@ -1,8 +1,6 @@ import CodexBarCore -import CodexBarMacroSupport import Foundation -@ProviderImplementationRegistration struct GrokProviderImplementation: ProviderImplementation { let id: UsageProvider = .grok } diff --git a/Sources/CodexBar/Providers/Groq/GroqProviderImplementation.swift b/Sources/CodexBar/Providers/Groq/GroqProviderImplementation.swift index e07a5aa3a2..7c9f386ca1 100644 --- a/Sources/CodexBar/Providers/Groq/GroqProviderImplementation.swift +++ b/Sources/CodexBar/Providers/Groq/GroqProviderImplementation.swift @@ -1,8 +1,6 @@ import CodexBarCore -import CodexBarMacroSupport import Foundation -@ProviderImplementationRegistration struct GroqProviderImplementation: ProviderImplementation { let id: UsageProvider = .groq @@ -16,11 +14,9 @@ struct GroqProviderImplementation: ProviderImplementation { _ = settings.groqAPIKey } - @MainActor - func isAvailable(context: ProviderAvailabilityContext) -> Bool { - ProviderTokenResolver.groqToken(environment: context.environment) != nil || - !context.settings.groqAPIKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty - } + // No `isAvailable` override: when Groq is enabled, the fetch pipeline resolves + // the console browser session (primary) or the optional API key (Enterprise + // Prometheus fallback). Matches the MiMo cookie provider. @MainActor func settingsFields(context: ProviderSettingsContext) -> [ProviderSettingsFieldDescriptor] { @@ -28,7 +24,8 @@ struct GroqProviderImplementation: ProviderImplementation { ProviderSettingsFieldDescriptor( id: "groq-api-key", title: "API key", - subtitle: "Stored in ~/.codexbar/config.json. Metrics require Groq Enterprise Prometheus access.", + subtitle: "Usage & spend come from your console.groq.com browser session automatically. " + + "An API key is optional and only adds Enterprise Prometheus metrics.", kind: .secure, placeholder: "gsk_...", binding: context.stringBinding(\.groqAPIKey), diff --git a/Sources/CodexBar/Providers/JetBrains/JetBrainsLoginFlow.swift b/Sources/CodexBar/Providers/JetBrains/JetBrainsLoginFlow.swift index e188c04bbb..c8a2d109b6 100644 --- a/Sources/CodexBar/Providers/JetBrains/JetBrainsLoginFlow.swift +++ b/Sources/CodexBar/Providers/JetBrains/JetBrainsLoginFlow.swift @@ -8,11 +8,11 @@ extension StatusItemController { let detectedIDEs = JetBrainsIDEDetector.detectInstalledIDEs(includeMissingQuota: true) if detectedIDEs.isEmpty { let message = [ - "Install a JetBrains IDE with AI Assistant enabled, then refresh CodexBar.", - "Alternatively, set a custom path in Settings.", + L("Install a JetBrains IDE with AI Assistant enabled, then refresh CodexBar."), + L("Alternatively, set a custom path in Settings."), ].joined(separator: " ") self.presentLoginAlert( - title: "No JetBrains IDE detected", + title: L("No JetBrains IDE detected"), message: message) } else { let ideNames = detectedIDEs.prefix(3).map(\.displayName).joined(separator: ", ") diff --git a/Sources/CodexBar/Providers/JetBrains/JetBrainsProviderImplementation.swift b/Sources/CodexBar/Providers/JetBrains/JetBrainsProviderImplementation.swift index 7e7096f50f..beaf36b780 100644 --- a/Sources/CodexBar/Providers/JetBrains/JetBrainsProviderImplementation.swift +++ b/Sources/CodexBar/Providers/JetBrains/JetBrainsProviderImplementation.swift @@ -1,9 +1,7 @@ import CodexBarCore -import CodexBarMacroSupport import Foundation import SwiftUI -@ProviderImplementationRegistration struct JetBrainsProviderImplementation: ProviderImplementation { let id: UsageProvider = .jetbrains diff --git a/Sources/CodexBar/Providers/Kilo/KiloProviderImplementation.swift b/Sources/CodexBar/Providers/Kilo/KiloProviderImplementation.swift index 792830fbd9..86538932ef 100644 --- a/Sources/CodexBar/Providers/Kilo/KiloProviderImplementation.swift +++ b/Sources/CodexBar/Providers/Kilo/KiloProviderImplementation.swift @@ -1,10 +1,8 @@ import AppKit import CodexBarCore -import CodexBarMacroSupport import Foundation import SwiftUI -@ProviderImplementationRegistration struct KiloProviderImplementation: ProviderImplementation { let id: UsageProvider = .kilo @@ -128,7 +126,7 @@ struct KiloProviderImplementation: ProviderImplementation { }, onRefresh: { [weak settings] in guard let settings else { - return .init(success: false, errorMessage: "Settings unavailable.") + return .init(success: false, errorMessage: L("Settings unavailable.")) } let resolved: KiloResolvedBearerToken do { @@ -138,7 +136,7 @@ struct KiloProviderImplementation: ProviderImplementation { } catch let error as LocalizedError { return .init( success: false, - errorMessage: error.errorDescription ?? "Failed to resolve Kilo credentials.") + errorMessage: error.errorDescription ?? L("Failed to resolve Kilo credentials.")) } catch { return .init(success: false, errorMessage: error.localizedDescription) } @@ -151,7 +149,7 @@ struct KiloProviderImplementation: ProviderImplementation { } catch let error as LocalizedError { return .init( success: false, - errorMessage: error.errorDescription ?? "Failed to load organizations.") + errorMessage: error.errorDescription ?? L("Failed to load organizations.")) } catch { return .init(success: false, errorMessage: error.localizedDescription) } diff --git a/Sources/CodexBar/Providers/Kilo/UsageStore+KiloOrgRefresh.swift b/Sources/CodexBar/Providers/Kilo/UsageStore+KiloOrgRefresh.swift index ed1f7129e2..2d601f5cfe 100644 --- a/Sources/CodexBar/Providers/Kilo/UsageStore+KiloOrgRefresh.swift +++ b/Sources/CodexBar/Providers/Kilo/UsageStore+KiloOrgRefresh.swift @@ -35,7 +35,7 @@ extension UsageStore { self.kiloEnabledScopes.count > 1 } - func refreshKiloScopes() async { + func refreshKiloScopes(generation: UInt64? = nil) async { let scopes = self.kiloEnabledScopes guard scopes.count > 1 else { await MainActor.run { self.kiloScopeSnapshots = [] } @@ -102,6 +102,7 @@ extension UsageStore { let ordered = scopes.compactMap { resultByID[$0.scopeIdentifier] } await MainActor.run { + guard self.isCurrentProviderRefreshGeneration(.kilo, generation: generation) else { return } self.kiloScopeSnapshots = ordered } } @@ -115,17 +116,6 @@ extension UsageSnapshot { accountEmail: baseIdentity?.accountEmail, accountOrganization: org, loginMethod: baseIdentity?.loginMethod) - return UsageSnapshot( - primary: self.primary, - secondary: self.secondary, - tertiary: self.tertiary, - extraRateWindows: self.extraRateWindows, - providerCost: self.providerCost, - zaiUsage: self.zaiUsage, - minimaxUsage: self.minimaxUsage, - openRouterUsage: self.openRouterUsage, - cursorRequests: self.cursorRequests, - updatedAt: self.updatedAt, - identity: newIdentity) + return self.withIdentity(newIdentity) } } diff --git a/Sources/CodexBar/Providers/Kimi/KimiProviderImplementation.swift b/Sources/CodexBar/Providers/Kimi/KimiProviderImplementation.swift index d48511963d..ca6be08aeb 100644 --- a/Sources/CodexBar/Providers/Kimi/KimiProviderImplementation.swift +++ b/Sources/CodexBar/Providers/Kimi/KimiProviderImplementation.swift @@ -1,20 +1,22 @@ import AppKit import CodexBarCore -import CodexBarMacroSupport import Foundation import SwiftUI -@ProviderImplementationRegistration struct KimiProviderImplementation: ProviderImplementation { let id: UsageProvider = .kimi @MainActor func presentation(context _: ProviderPresentationContext) -> ProviderPresentation { - ProviderPresentation { _ in "web" } + ProviderPresentation { context in + context.store.sourceLabel(for: context.provider) + } } @MainActor func observeSettings(_ settings: SettingsStore) { + _ = settings.kimiUsageDataSource + _ = settings.kimiAPIKey _ = settings.kimiCookieSource _ = settings.kimiManualCookieHeader } @@ -24,8 +26,33 @@ struct KimiProviderImplementation: ProviderImplementation { .kimi(context.settings.kimiSettingsSnapshot(tokenOverride: context.tokenOverride)) } + @MainActor + func defaultSourceLabel(context: ProviderSourceLabelContext) -> String? { + context.settings.kimiUsageDataSource.rawValue + } + + @MainActor + func sourceMode(context: ProviderSourceModeContext) -> ProviderSourceMode { + switch context.settings.kimiUsageDataSource { + case .api: .api + case .web: .web + case .auto, .cli, .oauth: .auto + } + } + @MainActor func settingsPickers(context: ProviderSettingsContext) -> [ProviderSettingsPickerDescriptor] { + let usageBinding = Binding( + get: { context.settings.kimiUsageDataSource.rawValue }, + set: { raw in + context.settings.kimiUsageDataSource = ProviderSourceMode(rawValue: raw) ?? .auto + }) + let usageOptions = [ + ProviderSettingsPickerOption(id: ProviderSourceMode.auto.rawValue, title: "Auto"), + ProviderSettingsPickerOption(id: ProviderSourceMode.api.rawValue, title: "API key"), + ProviderSettingsPickerOption(id: ProviderSourceMode.web.rawValue, title: "Browser cookies"), + ] + let cookieBinding = Binding( get: { context.settings.kimiCookieSource.rawValue }, set: { raw in @@ -45,6 +72,20 @@ struct KimiProviderImplementation: ProviderImplementation { } return [ + ProviderSettingsPickerDescriptor( + id: "kimi-usage-source", + title: "Usage source", + subtitle: "Auto tries your configured API key, then a signed-in Kimi Code CLI credential, " + + "then browser cookies.", + binding: usageBinding, + options: usageOptions, + isVisible: nil, + onChange: nil, + trailingText: { + guard context.settings.kimiUsageDataSource == .auto else { return nil } + let label = context.store.sourceLabel(for: .kimi) + return label == "auto" ? nil : label + }), ProviderSettingsPickerDescriptor( id: "kimi-cookie-source", title: "Cookie source", @@ -60,6 +101,27 @@ struct KimiProviderImplementation: ProviderImplementation { @MainActor func settingsFields(context: ProviderSettingsContext) -> [ProviderSettingsFieldDescriptor] { [ + ProviderSettingsFieldDescriptor( + id: "kimi-api-key", + title: "API key", + subtitle: "Stored in ~/.codexbar/config.json. You can also provide KIMI_CODE_API_KEY.", + kind: .secure, + placeholder: "Paste Kimi Code API key...", + binding: context.stringBinding(\.kimiAPIKey), + actions: [ + ProviderSettingsActionDescriptor( + id: "kimi-open-api-docs", + title: "Open API docs", + style: .link, + isVisible: nil, + perform: { + if let url = URL(string: "https://www.kimi.com/code/docs/en/") { + NSWorkspace.shared.open(url) + } + }), + ], + isVisible: nil, + onActivate: nil), ProviderSettingsFieldDescriptor( id: "kimi-cookie", title: "", diff --git a/Sources/CodexBar/Providers/Kimi/KimiSettingsStore.swift b/Sources/CodexBar/Providers/Kimi/KimiSettingsStore.swift index a79241d4b7..6d5adf2c14 100644 --- a/Sources/CodexBar/Providers/Kimi/KimiSettingsStore.swift +++ b/Sources/CodexBar/Providers/Kimi/KimiSettingsStore.swift @@ -2,6 +2,32 @@ import CodexBarCore import Foundation extension SettingsStore { + var kimiUsageDataSource: ProviderSourceMode { + get { self.configSnapshot.providerConfig(for: .kimi)?.source ?? .auto } + set { + let source: ProviderSourceMode? = switch newValue { + case .auto: .auto + case .api: .api + case .web: .web + case .cli, .oauth: .auto + } + self.updateProviderConfig(provider: .kimi) { entry in + entry.source = source + } + self.logProviderModeChange(provider: .kimi, field: "usageSource", value: newValue.rawValue) + } + } + + var kimiAPIKey: String { + get { self.configSnapshot.providerConfig(for: .kimi)?.sanitizedAPIKey ?? "" } + set { + self.updateProviderConfig(provider: .kimi) { entry in + entry.apiKey = self.normalizedConfigValue(newValue) + } + self.logSecretUpdate(provider: .kimi, field: "apiKey", value: newValue) + } + } + var kimiManualCookieHeader: String { get { self.configSnapshot.providerConfig(for: .kimi)?.sanitizedCookieHeader ?? "" } set { @@ -27,10 +53,11 @@ extension SettingsStore { extension SettingsStore { func kimiSettingsSnapshot(tokenOverride: TokenAccountOverride?) -> ProviderSettingsSnapshot.KimiProviderSettings { - _ = tokenOverride self.ensureKimiAuthTokenLoaded() - return ProviderSettingsSnapshot.KimiProviderSettings( - cookieSource: self.kimiCookieSource, - manualCookieHeader: self.kimiManualCookieHeader) + return self.resolvedCookieSettings( + provider: .kimi, + configuredSource: self.kimiCookieSource, + configuredHeader: self.kimiManualCookieHeader, + tokenOverride: tokenOverride) } } diff --git a/Sources/CodexBar/Providers/KimiK2/KimiK2ProviderImplementation.swift b/Sources/CodexBar/Providers/KimiK2/KimiK2ProviderImplementation.swift deleted file mode 100644 index 07c643c230..0000000000 --- a/Sources/CodexBar/Providers/KimiK2/KimiK2ProviderImplementation.swift +++ /dev/null @@ -1,41 +0,0 @@ -import AppKit -import CodexBarCore -import CodexBarMacroSupport -import Foundation - -@ProviderImplementationRegistration -struct KimiK2ProviderImplementation: ProviderImplementation { - let id: UsageProvider = .kimik2 - - @MainActor - func observeSettings(_ settings: SettingsStore) { - _ = settings.kimiK2APIToken - } - - @MainActor - func settingsFields(context: ProviderSettingsContext) -> [ProviderSettingsFieldDescriptor] { - [ - ProviderSettingsFieldDescriptor( - id: "kimi-k2-api-token", - title: "API key", - subtitle: "Stored in ~/.codexbar/config.json. For the official Kimi API, use Moonshot / Kimi API.", - kind: .secure, - placeholder: "Paste API key…", - binding: context.stringBinding(\.kimiK2APIToken), - actions: [ - ProviderSettingsActionDescriptor( - id: "kimi-k2-open-api-keys", - title: "Open legacy provider docs", - style: .link, - isVisible: nil, - perform: { - if let url = URL(string: "https://github.com/steipete/CodexBar/blob/main/docs/kimi-k2.md") { - NSWorkspace.shared.open(url) - } - }), - ], - isVisible: nil, - onActivate: { context.settings.ensureKimiK2APITokenLoaded() }), - ] - } -} diff --git a/Sources/CodexBar/Providers/KimiK2/KimiK2SettingsStore.swift b/Sources/CodexBar/Providers/KimiK2/KimiK2SettingsStore.swift deleted file mode 100644 index 0415d18f34..0000000000 --- a/Sources/CodexBar/Providers/KimiK2/KimiK2SettingsStore.swift +++ /dev/null @@ -1,16 +0,0 @@ -import CodexBarCore -import Foundation - -extension SettingsStore { - var kimiK2APIToken: String { - get { self.configSnapshot.providerConfig(for: .kimik2)?.sanitizedAPIKey ?? "" } - set { - self.updateProviderConfig(provider: .kimik2) { entry in - entry.apiKey = self.normalizedConfigValue(newValue) - } - self.logSecretUpdate(provider: .kimik2, field: "apiKey", value: newValue) - } - } - - func ensureKimiK2APITokenLoaded() {} -} diff --git a/Sources/CodexBar/Providers/Kiro/KiroProviderImplementation.swift b/Sources/CodexBar/Providers/Kiro/KiroProviderImplementation.swift index 170adeeebe..383c4b1a59 100644 --- a/Sources/CodexBar/Providers/Kiro/KiroProviderImplementation.swift +++ b/Sources/CodexBar/Providers/Kiro/KiroProviderImplementation.swift @@ -1,9 +1,7 @@ import CodexBarCore -import CodexBarMacroSupport import Foundation import SwiftUI -@ProviderImplementationRegistration struct KiroProviderImplementation: ProviderImplementation { let id: UsageProvider = .kiro @@ -11,8 +9,9 @@ struct KiroProviderImplementation: ProviderImplementation { [ ProviderSettingsPickerDescriptor( id: "kiroMenuBarDisplay", - title: "Kiro menu bar value", - subtitle: "Show or hide Kiro credits, percent, or both next to the menu bar icon.", + title: L("Kiro menu bar value"), + subtitle: L("Show or hide Kiro credits, percent, or both next to the menu bar icon."), + placement: .menuBar, binding: Binding( get: { context.settings.kiroMenuBarDisplayMode.rawValue }, set: { rawValue in diff --git a/Sources/CodexBar/Providers/LLMProxy/LLMProxyProviderImplementation.swift b/Sources/CodexBar/Providers/LLMProxy/LLMProxyProviderImplementation.swift index 3d9f386cdd..f27260c866 100644 --- a/Sources/CodexBar/Providers/LLMProxy/LLMProxyProviderImplementation.swift +++ b/Sources/CodexBar/Providers/LLMProxy/LLMProxyProviderImplementation.swift @@ -1,8 +1,6 @@ import CodexBarCore -import CodexBarMacroSupport import Foundation -@ProviderImplementationRegistration struct LLMProxyProviderImplementation: ProviderImplementation { let id: UsageProvider = .llmproxy @@ -20,7 +18,7 @@ struct LLMProxyProviderImplementation: ProviderImplementation { @MainActor func isAvailable(context: ProviderAvailabilityContext) -> Bool { ProviderTokenResolver.llmProxyToken(environment: context.environment) != nil && - LLMProxySettingsReader.baseURL(environment: context.environment) != nil + LLMProxySettingsReader.hasBaseURLOverride(environment: context.environment) } @MainActor diff --git a/Sources/CodexBar/Providers/LiteLLM/LiteLLMProviderImplementation.swift b/Sources/CodexBar/Providers/LiteLLM/LiteLLMProviderImplementation.swift new file mode 100644 index 0000000000..5bbc1e526a --- /dev/null +++ b/Sources/CodexBar/Providers/LiteLLM/LiteLLMProviderImplementation.swift @@ -0,0 +1,49 @@ +import CodexBarCore +import Foundation + +struct LiteLLMProviderImplementation: ProviderImplementation { + let id: UsageProvider = .litellm + + @MainActor + func presentation(context _: ProviderPresentationContext) -> ProviderPresentation { + ProviderPresentation { _ in "api" } + } + + @MainActor + func observeSettings(_ settings: SettingsStore) { + _ = settings.liteLLMAPIKey + _ = settings.liteLLMBaseURL + } + + @MainActor + func isAvailable(context: ProviderAvailabilityContext) -> Bool { + ProviderTokenResolver.liteLLMToken(environment: context.environment) != nil && + LiteLLMSettingsReader.hasBaseURLOverride(environment: context.environment) + } + + @MainActor + func settingsFields(context: ProviderSettingsContext) -> [ProviderSettingsFieldDescriptor] { + [ + ProviderSettingsFieldDescriptor( + id: "litellm-api-key", + title: "API key", + subtitle: "LiteLLM virtual key used to read its own spend and budget.", + kind: .secure, + placeholder: "sk-…", + binding: context.stringBinding(\.liteLLMAPIKey), + actions: [], + isVisible: nil, + onActivate: nil), + ProviderSettingsFieldDescriptor( + id: "litellm-base-url", + title: "Base URL", + subtitle: "LiteLLM proxy base URL. /v1 suffixes are accepted and stripped for management endpoints.", + kind: .plain, + placeholder: "https://litellm.example.com", + binding: context.stringBinding(\.liteLLMBaseURL), + actions: [], + isVisible: nil, + onActivate: nil), + ] + } +} diff --git a/Sources/CodexBar/Providers/LiteLLM/LiteLLMSettingsStore.swift b/Sources/CodexBar/Providers/LiteLLM/LiteLLMSettingsStore.swift new file mode 100644 index 0000000000..2f0c28a79a --- /dev/null +++ b/Sources/CodexBar/Providers/LiteLLM/LiteLLMSettingsStore.swift @@ -0,0 +1,27 @@ +import CodexBarCore +import Foundation + +extension SettingsStore { + var liteLLMAPIKey: String { + get { + self.configSnapshot.providerConfig(for: .litellm)?.sanitizedAPIKey ?? "" + } + set { + self.updateProviderConfig(provider: .litellm) { entry in + entry.apiKey = self.normalizedConfigValue(newValue) + } + self.logSecretUpdate(provider: .litellm, field: "apiKey", value: newValue) + } + } + + var liteLLMBaseURL: String { + get { + self.configSnapshot.providerConfig(for: .litellm)?.sanitizedEnterpriseHost ?? "" + } + set { + self.updateProviderConfig(provider: .litellm) { entry in + entry.enterpriseHost = self.normalizedConfigValue(newValue) + } + } + } +} diff --git a/Sources/CodexBar/Providers/LongCat/LongCatProviderImplementation.swift b/Sources/CodexBar/Providers/LongCat/LongCatProviderImplementation.swift new file mode 100644 index 0000000000..d4b1cec73f --- /dev/null +++ b/Sources/CodexBar/Providers/LongCat/LongCatProviderImplementation.swift @@ -0,0 +1,100 @@ +import AppKit +import CodexBarCore +import Foundation +import SwiftUI + +struct LongCatProviderImplementation: ProviderImplementation { + let id: UsageProvider = .longcat + + @MainActor + func presentation(context _: ProviderPresentationContext) -> ProviderPresentation { + ProviderPresentation { context in + context.store.sourceLabel(for: context.provider) + } + } + + @MainActor + func observeSettings(_ settings: SettingsStore) { + _ = settings.longcatUsageDataSource + _ = settings.longcatCookieSource + _ = settings.longcatManualCookieHeader + } + + @MainActor + func settingsSnapshot(context: ProviderSettingsSnapshotContext) -> ProviderSettingsSnapshotContribution? { + .longcat(context.settings.longcatSettingsSnapshot(tokenOverride: context.tokenOverride)) + } + + @MainActor + func defaultSourceLabel(context: ProviderSourceLabelContext) -> String? { + context.settings.longcatUsageDataSource.rawValue + } + + @MainActor + func sourceMode(context: ProviderSourceModeContext) -> ProviderSourceMode { + switch context.settings.longcatUsageDataSource { + case .web: .web + case .auto, .api, .cli, .oauth: .auto + } + } + + @MainActor + func settingsPickers(context: ProviderSettingsContext) -> [ProviderSettingsPickerDescriptor] { + let cookieBinding = Binding( + get: { context.settings.longcatCookieSource.rawValue }, + set: { raw in + context.settings.longcatCookieSource = ProviderCookieSource(rawValue: raw) ?? .auto + }) + let options = ProviderCookieSourceUI.options( + allowsOff: true, + keychainDisabled: context.settings.debugDisableKeychainAccess) + + let subtitle: () -> String? = { + ProviderCookieSourceUI.subtitle( + source: context.settings.longcatCookieSource, + keychainDisabled: context.settings.debugDisableKeychainAccess, + auto: "Automatic imports longcat.chat cookies from your browser.", + manual: "Paste a Cookie header copied from longcat.chat.", + off: "LongCat cookies are disabled.") + } + + return [ + ProviderSettingsPickerDescriptor( + id: "longcat-cookie-source", + title: "Cookie source", + subtitle: "Automatic imports longcat.chat cookies from your browser.", + dynamicSubtitle: subtitle, + binding: cookieBinding, + options: options, + isVisible: nil, + onChange: nil), + ] + } + + @MainActor + func settingsFields(context: ProviderSettingsContext) -> [ProviderSettingsFieldDescriptor] { + [ + ProviderSettingsFieldDescriptor( + id: "longcat-cookie", + title: "", + subtitle: "", + kind: .secure, + placeholder: "Cookie: \u{2026}", + binding: context.stringBinding(\.longcatManualCookieHeader), + actions: [ + ProviderSettingsActionDescriptor( + id: "longcat-open-console", + title: "Open Console", + style: .link, + isVisible: nil, + perform: { + if let url = URL(string: "https://longcat.chat/platform/") { + NSWorkspace.shared.open(url) + } + }), + ], + isVisible: { context.settings.longcatCookieSource == .manual }, + onActivate: { context.settings.ensureLongCatCookieLoaded() }), + ] + } +} diff --git a/Sources/CodexBar/Providers/LongCat/LongCatSettingsStore.swift b/Sources/CodexBar/Providers/LongCat/LongCatSettingsStore.swift new file mode 100644 index 0000000000..f0747b19df --- /dev/null +++ b/Sources/CodexBar/Providers/LongCat/LongCatSettingsStore.swift @@ -0,0 +1,54 @@ +import CodexBarCore +import Foundation + +extension SettingsStore { + var longcatUsageDataSource: ProviderSourceMode { + get { self.configSnapshot.providerConfig(for: .longcat)?.source ?? .auto } + set { + let source: ProviderSourceMode? = switch newValue { + case .auto: .auto + case .web: .web + case .api, .cli, .oauth: .auto + } + self.updateProviderConfig(provider: .longcat) { entry in + entry.source = source + } + self.logProviderModeChange(provider: .longcat, field: "usageSource", value: newValue.rawValue) + } + } + + var longcatManualCookieHeader: String { + get { self.configSnapshot.providerConfig(for: .longcat)?.sanitizedCookieHeader ?? "" } + set { + self.updateProviderConfig(provider: .longcat) { entry in + entry.cookieHeader = self.normalizedConfigValue(newValue) + } + self.logSecretUpdate(provider: .longcat, field: "cookieHeader", value: newValue) + } + } + + var longcatCookieSource: ProviderCookieSource { + get { self.resolvedCookieSource(provider: .longcat, fallback: .auto) } + set { + self.updateProviderConfig(provider: .longcat) { entry in + entry.cookieSource = newValue + } + self.logProviderModeChange(provider: .longcat, field: "cookieSource", value: newValue.rawValue) + } + } + + func ensureLongCatCookieLoaded() {} +} + +extension SettingsStore { + func longcatSettingsSnapshot(tokenOverride: TokenAccountOverride?) + -> ProviderSettingsSnapshot.LongCatProviderSettings + { + self.ensureLongCatCookieLoaded() + return self.resolvedCookieSettings( + provider: .longcat, + configuredSource: self.longcatCookieSource, + configuredHeader: self.longcatManualCookieHeader, + tokenOverride: tokenOverride) + } +} diff --git a/Sources/CodexBar/Providers/Manus/ManusProviderImplementation.swift b/Sources/CodexBar/Providers/Manus/ManusProviderImplementation.swift index 873ffca566..7578b78cb6 100644 --- a/Sources/CodexBar/Providers/Manus/ManusProviderImplementation.swift +++ b/Sources/CodexBar/Providers/Manus/ManusProviderImplementation.swift @@ -1,10 +1,8 @@ import AppKit import CodexBarCore -import CodexBarMacroSupport import Foundation import SwiftUI -@ProviderImplementationRegistration struct ManusProviderImplementation: ProviderImplementation { let id: UsageProvider = .manus let supportsLoginFlow: Bool = true diff --git a/Sources/CodexBar/Providers/Manus/ManusSettingsStore.swift b/Sources/CodexBar/Providers/Manus/ManusSettingsStore.swift index 1b4573a1b2..4a11d3d46e 100644 --- a/Sources/CodexBar/Providers/Manus/ManusSettingsStore.swift +++ b/Sources/CodexBar/Providers/Manus/ManusSettingsStore.swift @@ -25,36 +25,10 @@ extension SettingsStore { extension SettingsStore { func manusSettingsSnapshot(tokenOverride: TokenAccountOverride?) -> ProviderSettingsSnapshot.ManusProviderSettings { - ProviderSettingsSnapshot.ManusProviderSettings( - cookieSource: self.manusSnapshotCookieSource(tokenOverride: tokenOverride), - manualCookieHeader: self.manusSnapshotCookieHeader(tokenOverride: tokenOverride)) - } - - private func manusSnapshotCookieHeader(tokenOverride: TokenAccountOverride?) -> String { - let fallback = self.manusManualCookieHeader - guard let support = TokenAccountSupportCatalog.support(for: .manus), - case .cookieHeader = support.injection - else { - return fallback - } - guard let account = ProviderTokenAccountSelection.selectedAccount( + self.resolvedCookieSettings( provider: .manus, - settings: self, - override: tokenOverride) - else { - return fallback - } - return TokenAccountSupportCatalog.normalizedCookieHeader(account.token, support: support) - } - - private func manusSnapshotCookieSource(tokenOverride: TokenAccountOverride?) -> ProviderCookieSource { - let fallback = self.manusCookieSource - guard let support = TokenAccountSupportCatalog.support(for: .manus), - support.requiresManualCookieSource - else { - return fallback - } - if self.tokenAccounts(for: .manus).isEmpty { return fallback } - return .manual + configuredSource: self.manusCookieSource, + configuredHeader: self.manusManualCookieHeader, + tokenOverride: tokenOverride) } } diff --git a/Sources/CodexBar/Providers/MiMo/MiMoProviderImplementation.swift b/Sources/CodexBar/Providers/MiMo/MiMoProviderImplementation.swift index bcb9b68cfb..e0f15e6167 100644 --- a/Sources/CodexBar/Providers/MiMo/MiMoProviderImplementation.swift +++ b/Sources/CodexBar/Providers/MiMo/MiMoProviderImplementation.swift @@ -1,10 +1,8 @@ import AppKit import CodexBarCore -import CodexBarMacroSupport import Foundation import SwiftUI -@ProviderImplementationRegistration struct MiMoProviderImplementation: ProviderImplementation { let id: UsageProvider = .mimo let supportsLoginFlow: Bool = true @@ -39,7 +37,7 @@ struct MiMoProviderImplementation: ProviderImplementation { ProviderCookieSourceUI.subtitle( source: context.settings.miMoCookieSource, keychainDisabled: context.settings.debugDisableKeychainAccess, - auto: "Automatic imports Chrome browser cookies from Xiaomi MiMo.", + auto: "Automatic imports browser cookies from Xiaomi MiMo.", manual: "Paste a Cookie header from platform.xiaomimimo.com.", off: "Xiaomi MiMo cookies are disabled.") } @@ -48,16 +46,14 @@ struct MiMoProviderImplementation: ProviderImplementation { ProviderSettingsPickerDescriptor( id: "mimo-cookie-source", title: "Cookie source", - subtitle: "Automatic imports Chrome browser cookies from Xiaomi MiMo.", + subtitle: "Automatic imports browser cookies from Xiaomi MiMo.", dynamicSubtitle: cookieSubtitle, binding: cookieBinding, options: cookieOptions, isVisible: nil, onChange: nil, trailingText: { - guard let entry = CookieHeaderCache.load(provider: .mimo) else { return nil } - let when = entry.storedAt.relativeDescription() - return "Cached: \(entry.sourceLabel) • \(when)" + ProviderCookieSourceUI.cachedTrailingText(provider: .mimo) }), ] } diff --git a/Sources/CodexBar/Providers/MiMo/MiMoSettingsStore.swift b/Sources/CodexBar/Providers/MiMo/MiMoSettingsStore.swift index 3285a20b30..0bbda51759 100644 --- a/Sources/CodexBar/Providers/MiMo/MiMoSettingsStore.swift +++ b/Sources/CodexBar/Providers/MiMo/MiMoSettingsStore.swift @@ -27,9 +27,10 @@ extension SettingsStore { extension SettingsStore { func miMoSettingsSnapshot(tokenOverride: TokenAccountOverride?) -> ProviderSettingsSnapshot.MiMoProviderSettings { - _ = tokenOverride - return ProviderSettingsSnapshot.MiMoProviderSettings( - cookieSource: self.miMoCookieSource, - manualCookieHeader: self.miMoCookieHeader) + self.resolvedCookieSettings( + provider: .mimo, + configuredSource: self.miMoCookieSource, + configuredHeader: self.miMoCookieHeader, + tokenOverride: tokenOverride) } } diff --git a/Sources/CodexBar/Providers/MiniMax/MiniMaxProviderImplementation.swift b/Sources/CodexBar/Providers/MiniMax/MiniMaxProviderImplementation.swift index 661441e99a..68f9c8851f 100644 --- a/Sources/CodexBar/Providers/MiniMax/MiniMaxProviderImplementation.swift +++ b/Sources/CodexBar/Providers/MiniMax/MiniMaxProviderImplementation.swift @@ -1,10 +1,8 @@ import AppKit import CodexBarCore -import CodexBarMacroSupport import Foundation import SwiftUI -@ProviderImplementationRegistration struct MiniMaxProviderImplementation: ProviderImplementation { let id: UsageProvider = .minimax @@ -89,9 +87,7 @@ struct MiniMaxProviderImplementation: ProviderImplementation { isVisible: { authMode().allowsCookies }, onChange: nil, trailingText: { - guard let entry = CookieHeaderCache.load(provider: .minimax) else { return nil } - let when = entry.storedAt.relativeDescription() - return "Cached: \(entry.sourceLabel) • \(when)" + ProviderCookieSourceUI.cachedTrailingText(provider: .minimax) }), ProviderSettingsPickerDescriptor( id: "minimax-region", diff --git a/Sources/CodexBar/Providers/MiniMax/MiniMaxSettingsStore.swift b/Sources/CodexBar/Providers/MiniMax/MiniMaxSettingsStore.swift index e10b621a5a..75fd496307 100644 --- a/Sources/CodexBar/Providers/MiniMax/MiniMaxSettingsStore.swift +++ b/Sources/CodexBar/Providers/MiniMax/MiniMaxSettingsStore.swift @@ -60,37 +60,14 @@ extension SettingsStore { extension SettingsStore { func minimaxSettingsSnapshot(tokenOverride: TokenAccountOverride?) -> ProviderSettingsSnapshot .MiniMaxProviderSettings { - ProviderSettingsSnapshot.MiniMaxProviderSettings( - cookieSource: self.minimaxSnapshotCookieSource(tokenOverride: tokenOverride), - manualCookieHeader: self.minimaxSnapshotCookieHeader(tokenOverride: tokenOverride), - apiRegion: self.minimaxAPIRegion) - } - - private func minimaxSnapshotCookieHeader(tokenOverride: TokenAccountOverride?) -> String { - let fallback = self.minimaxCookieHeader - guard let support = TokenAccountSupportCatalog.support(for: .minimax), - case .cookieHeader = support.injection - else { - return fallback - } - guard let account = ProviderTokenAccountSelection.selectedAccount( + let cookieSettings: ProviderSettingsSnapshot.CookieProviderSettings = self.resolvedCookieSettings( provider: .minimax, - settings: self, - override: tokenOverride) - else { - return fallback - } - return TokenAccountSupportCatalog.normalizedCookieHeader(account.token, support: support) - } - - private func minimaxSnapshotCookieSource(tokenOverride: TokenAccountOverride?) -> ProviderCookieSource { - let fallback = self.minimaxCookieSource - guard let support = TokenAccountSupportCatalog.support(for: .minimax), - support.requiresManualCookieSource - else { - return fallback - } - if self.tokenAccounts(for: .minimax).isEmpty { return fallback } - return .manual + configuredSource: self.minimaxCookieSource, + configuredHeader: self.minimaxCookieHeader, + tokenOverride: tokenOverride) + return ProviderSettingsSnapshot.MiniMaxProviderSettings( + cookieSource: cookieSettings.cookieSource, + manualCookieHeader: cookieSettings.manualCookieHeader, + apiRegion: self.minimaxAPIRegion) } } diff --git a/Sources/CodexBar/Providers/Mistral/MistralProviderImplementation.swift b/Sources/CodexBar/Providers/Mistral/MistralProviderImplementation.swift index 1949d2ed0f..2562111504 100644 --- a/Sources/CodexBar/Providers/Mistral/MistralProviderImplementation.swift +++ b/Sources/CodexBar/Providers/Mistral/MistralProviderImplementation.swift @@ -1,10 +1,8 @@ import AppKit import CodexBarCore -import CodexBarMacroSupport import Foundation import SwiftUI -@ProviderImplementationRegistration struct MistralProviderImplementation: ProviderImplementation { let id: UsageProvider = .mistral @@ -69,9 +67,7 @@ struct MistralProviderImplementation: ProviderImplementation { isVisible: nil, onChange: nil, trailingText: { - guard let entry = CookieHeaderCache.load(provider: .mistral) else { return nil } - let when = entry.storedAt.relativeDescription() - return "Cached: \(entry.sourceLabel) • \(when)" + ProviderCookieSourceUI.cachedTrailingText(provider: .mistral) }), ] } diff --git a/Sources/CodexBar/Providers/Mistral/MistralSettingsStore.swift b/Sources/CodexBar/Providers/Mistral/MistralSettingsStore.swift index e994855170..3332ef46b9 100644 --- a/Sources/CodexBar/Providers/Mistral/MistralSettingsStore.swift +++ b/Sources/CodexBar/Providers/Mistral/MistralSettingsStore.swift @@ -29,36 +29,10 @@ extension SettingsStore { func mistralSettingsSnapshot(tokenOverride: TokenAccountOverride?) -> ProviderSettingsSnapshot .MistralProviderSettings { - ProviderSettingsSnapshot.MistralProviderSettings( - cookieSource: self.mistralSnapshotCookieSource(tokenOverride: tokenOverride), - manualCookieHeader: self.mistralSnapshotCookieHeader(tokenOverride: tokenOverride)) - } - - private func mistralSnapshotCookieHeader(tokenOverride: TokenAccountOverride?) -> String { - let fallback = self.mistralCookieHeader - guard let support = TokenAccountSupportCatalog.support(for: .mistral), - case .cookieHeader = support.injection - else { - return fallback - } - guard let account = ProviderTokenAccountSelection.selectedAccount( + self.resolvedCookieSettings( provider: .mistral, - settings: self, - override: tokenOverride) - else { - return fallback - } - return TokenAccountSupportCatalog.normalizedCookieHeader(account.token, support: support) - } - - private func mistralSnapshotCookieSource(tokenOverride: TokenAccountOverride?) -> ProviderCookieSource { - let fallback = self.mistralCookieSource - guard let support = TokenAccountSupportCatalog.support(for: .mistral), - support.requiresManualCookieSource - else { - return fallback - } - if self.tokenAccounts(for: .mistral).isEmpty { return fallback } - return .manual + configuredSource: self.mistralCookieSource, + configuredHeader: self.mistralCookieHeader, + tokenOverride: tokenOverride) } } diff --git a/Sources/CodexBar/Providers/Moonshot/MoonshotProviderImplementation.swift b/Sources/CodexBar/Providers/Moonshot/MoonshotProviderImplementation.swift index 67e862c40f..c96088fd24 100644 --- a/Sources/CodexBar/Providers/Moonshot/MoonshotProviderImplementation.swift +++ b/Sources/CodexBar/Providers/Moonshot/MoonshotProviderImplementation.swift @@ -1,10 +1,8 @@ import AppKit import CodexBarCore -import CodexBarMacroSupport import Foundation import SwiftUI -@ProviderImplementationRegistration struct MoonshotProviderImplementation: ProviderImplementation { let id: UsageProvider = .moonshot diff --git a/Sources/CodexBar/Providers/NeuralWatt/NeuralWattProviderImplementation.swift b/Sources/CodexBar/Providers/NeuralWatt/NeuralWattProviderImplementation.swift new file mode 100644 index 0000000000..c284281602 --- /dev/null +++ b/Sources/CodexBar/Providers/NeuralWatt/NeuralWattProviderImplementation.swift @@ -0,0 +1,44 @@ +import CodexBarCore +import Foundation + +struct NeuralWattProviderImplementation: ProviderImplementation { + let id: UsageProvider = .neuralwatt + + @MainActor + func presentation(context _: ProviderPresentationContext) -> ProviderPresentation { + ProviderPresentation { _ in "api" } + } + + @MainActor + func observeSettings(_ settings: SettingsStore) { + _ = settings.neuralWattAPIKey + _ = settings.tokenAccountsData(for: .neuralwatt) + } + + @MainActor + func isAvailable(context: ProviderAvailabilityContext) -> Bool { + if NeuralWattSettingsReader.apiKey(environment: context.environment) != nil { + return true + } + if !context.settings.neuralWattAPIKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + return true + } + return !context.settings.tokenAccounts(for: .neuralwatt).isEmpty + } + + @MainActor + func settingsFields(context: ProviderSettingsContext) -> [ProviderSettingsFieldDescriptor] { + [ + ProviderSettingsFieldDescriptor( + id: "neuralwatt-api-key", + title: "API key", + subtitle: "Stored in the CodexBar config file. Manage keys from the Neuralwatt dashboard.", + kind: .secure, + placeholder: "sk-...", + binding: context.stringBinding(\.neuralWattAPIKey), + actions: [], + isVisible: nil, + onActivate: nil), + ] + } +} diff --git a/Sources/CodexBar/Providers/NeuralWatt/NeuralWattSettingsStore.swift b/Sources/CodexBar/Providers/NeuralWatt/NeuralWattSettingsStore.swift new file mode 100644 index 0000000000..c5aa7a1625 --- /dev/null +++ b/Sources/CodexBar/Providers/NeuralWatt/NeuralWattSettingsStore.swift @@ -0,0 +1,14 @@ +import CodexBarCore +import Foundation + +extension SettingsStore { + var neuralWattAPIKey: String { + get { self.configSnapshot.providerConfig(for: .neuralwatt)?.sanitizedAPIKey ?? "" } + set { + self.updateProviderConfig(provider: .neuralwatt) { entry in + entry.apiKey = self.normalizedConfigValue(newValue) + } + self.logSecretUpdate(provider: .neuralwatt, field: "apiKey", value: newValue) + } + } +} diff --git a/Sources/CodexBar/Providers/Ollama/OllamaProviderImplementation.swift b/Sources/CodexBar/Providers/Ollama/OllamaProviderImplementation.swift index d2b64c3626..6bf3d3f5e8 100644 --- a/Sources/CodexBar/Providers/Ollama/OllamaProviderImplementation.swift +++ b/Sources/CodexBar/Providers/Ollama/OllamaProviderImplementation.swift @@ -1,10 +1,8 @@ import AppKit import CodexBarCore -import CodexBarMacroSupport import Foundation import SwiftUI -@ProviderImplementationRegistration struct OllamaProviderImplementation: ProviderImplementation { let id: UsageProvider = .ollama @@ -99,7 +97,21 @@ struct OllamaProviderImplementation: ProviderImplementation { binding: cookieBinding, options: cookieOptions, isVisible: nil, - onChange: nil), + onChange: nil, + trailingText: { + guard context.settings.ollamaUsageDataSource != .api else { return nil } + return ProviderCookieRefreshAction.trailingText( + provider: .ollama, + cookieSource: context.settings.ollamaCookieSource, + context: context) + }, + trailingActions: [ + ProviderCookieRefreshAction.descriptor( + provider: .ollama, + cookieSource: { context.settings.ollamaCookieSource }, + additionalVisibility: { context.settings.ollamaUsageDataSource != .api }, + context: context), + ]), ] } diff --git a/Sources/CodexBar/Providers/Ollama/OllamaSettingsStore.swift b/Sources/CodexBar/Providers/Ollama/OllamaSettingsStore.swift index 628d7b7f2b..2c464b9c98 100644 --- a/Sources/CodexBar/Providers/Ollama/OllamaSettingsStore.swift +++ b/Sources/CodexBar/Providers/Ollama/OllamaSettingsStore.swift @@ -53,36 +53,10 @@ extension SettingsStore { extension SettingsStore { func ollamaSettingsSnapshot(tokenOverride: TokenAccountOverride?) -> ProviderSettingsSnapshot .OllamaProviderSettings { - ProviderSettingsSnapshot.OllamaProviderSettings( - cookieSource: self.ollamaSnapshotCookieSource(tokenOverride: tokenOverride), - manualCookieHeader: self.ollamaSnapshotCookieHeader(tokenOverride: tokenOverride)) - } - - private func ollamaSnapshotCookieHeader(tokenOverride: TokenAccountOverride?) -> String { - let fallback = self.ollamaCookieHeader - guard let support = TokenAccountSupportCatalog.support(for: .ollama), - case .cookieHeader = support.injection - else { - return fallback - } - guard let account = ProviderTokenAccountSelection.selectedAccount( + self.resolvedCookieSettings( provider: .ollama, - settings: self, - override: tokenOverride) - else { - return fallback - } - return TokenAccountSupportCatalog.normalizedCookieHeader(account.token, support: support) - } - - private func ollamaSnapshotCookieSource(tokenOverride: TokenAccountOverride?) -> ProviderCookieSource { - let fallback = self.ollamaCookieSource - guard let support = TokenAccountSupportCatalog.support(for: .ollama), - support.requiresManualCookieSource - else { - return fallback - } - if self.tokenAccounts(for: .ollama).isEmpty { return fallback } - return .manual + configuredSource: self.ollamaCookieSource, + configuredHeader: self.ollamaCookieHeader, + tokenOverride: tokenOverride) } } diff --git a/Sources/CodexBar/Providers/Ollama/OllamaUIErrorMapper.swift b/Sources/CodexBar/Providers/Ollama/OllamaUIErrorMapper.swift new file mode 100644 index 0000000000..702cc9bd59 --- /dev/null +++ b/Sources/CodexBar/Providers/Ollama/OllamaUIErrorMapper.swift @@ -0,0 +1,36 @@ +import CodexBarCore +import Foundation + +struct OllamaUIErrorMapper { + static func userFacingMessage( + _ raw: String?, + localize: (String) -> String = L) -> String? + { + guard let raw else { return nil } + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + + if trimmed == OllamaUsageError.safariCookieAccessDenied.localizedDescription { + return localize("ollama_safari_cookie_access_hint") + } + if let browserName = self.browserName( + in: trimmed, + suffix: " cookie decryption was declined in Keychain; retry with a manual refresh.") + { + return String(format: localize("ollama_browser_cookie_decryption_denied"), browserName) + } + if let browserName = self.browserName( + in: trimmed, + suffix: " cookie decryption is disabled in CodexBar; enable Keychain access and refresh.") + { + return String(format: localize("ollama_browser_cookie_decryption_disabled"), browserName) + } + return trimmed + } + + private static func browserName(in message: String, suffix: String) -> String? { + guard message.hasSuffix(suffix) else { return nil } + let name = String(message.dropLast(suffix.count)).trimmingCharacters(in: .whitespacesAndNewlines) + return name.isEmpty ? nil : name + } +} diff --git a/Sources/CodexBar/Providers/OpenAI/OpenAIAPIProviderImplementation.swift b/Sources/CodexBar/Providers/OpenAI/OpenAIAPIProviderImplementation.swift index 4d13b22be6..c0b2d93e45 100644 --- a/Sources/CodexBar/Providers/OpenAI/OpenAIAPIProviderImplementation.swift +++ b/Sources/CodexBar/Providers/OpenAI/OpenAIAPIProviderImplementation.swift @@ -1,9 +1,7 @@ import AppKit import CodexBarCore -import CodexBarMacroSupport import Foundation -@ProviderImplementationRegistration struct OpenAIAPIProviderImplementation: ProviderImplementation { let id: UsageProvider = .openai @@ -32,8 +30,8 @@ struct OpenAIAPIProviderImplementation: ProviderImplementation { ProviderSettingsFieldDescriptor( id: "openai-api-key", title: "Admin API key", - subtitle: "Stored in ~/.codexbar/config.json. OPENAI_ADMIN_KEY is preferred; " + - "OPENAI_API_KEY still works.", + subtitle: "Stored in ~/.codexbar/config.json. OPENAI_ADMIN_KEY is required for organization usage; " + + "legacy/user keys only get a best-effort balance fallback.", kind: .secure, placeholder: "sk-admin-...", binding: context.stringBinding(\.openAIAPIKey), diff --git a/Sources/CodexBar/Providers/OpenCode/OpenCodeProviderImplementation.swift b/Sources/CodexBar/Providers/OpenCode/OpenCodeProviderImplementation.swift index 5ef1459074..2fd1a7b8a2 100644 --- a/Sources/CodexBar/Providers/OpenCode/OpenCodeProviderImplementation.swift +++ b/Sources/CodexBar/Providers/OpenCode/OpenCodeProviderImplementation.swift @@ -1,10 +1,8 @@ import AppKit import CodexBarCore -import CodexBarMacroSupport import Foundation import SwiftUI -@ProviderImplementationRegistration struct OpenCodeProviderImplementation: ProviderImplementation { let id: UsageProvider = .opencode @@ -28,7 +26,9 @@ struct OpenCodeProviderImplementation: ProviderImplementation { @MainActor func tokenAccountsVisibility(context: ProviderSettingsContext, support: TokenAccountSupport) -> Bool { guard support.requiresManualCookieSource else { return true } - if !context.settings.tokenAccounts(for: context.provider).isEmpty { return true } + if !context.settings.tokenAccounts(for: context.provider).isEmpty { + return true + } return context.settings.opencodeCookieSource == .manual } @@ -70,10 +70,17 @@ struct OpenCodeProviderImplementation: ProviderImplementation { isVisible: nil, onChange: nil, trailingText: { - OpenCodeProviderUI.cachedCookieTrailingText( + ProviderCookieRefreshAction.trailingText( + provider: .opencode, + cookieSource: context.settings.opencodeCookieSource, + context: context) + }, + trailingActions: [ + ProviderCookieRefreshAction.descriptor( provider: .opencode, - cookieSource: context.settings.opencodeCookieSource) - }), + cookieSource: { context.settings.opencodeCookieSource }, + context: context), + ]), ] } diff --git a/Sources/CodexBar/Providers/OpenCode/OpenCodeProviderUI.swift b/Sources/CodexBar/Providers/OpenCode/OpenCodeProviderUI.swift index e6646e6c26..103d31c18a 100644 --- a/Sources/CodexBar/Providers/OpenCode/OpenCodeProviderUI.swift +++ b/Sources/CodexBar/Providers/OpenCode/OpenCodeProviderUI.swift @@ -5,8 +5,6 @@ enum OpenCodeProviderUI { @MainActor static func cachedCookieTrailingText(provider: UsageProvider, cookieSource: ProviderCookieSource) -> String? { guard cookieSource != .manual else { return nil } - guard let entry = CookieHeaderCache.load(provider: provider) else { return nil } - let when = entry.storedAt.relativeDescription() - return "Cached: \(entry.sourceLabel) • \(when)" + return ProviderCookieSourceUI.cachedTrailingText(provider: provider) } } diff --git a/Sources/CodexBar/Providers/OpenCode/OpenCodeSettingsStore.swift b/Sources/CodexBar/Providers/OpenCode/OpenCodeSettingsStore.swift index b33abcfd13..b718e8638c 100644 --- a/Sources/CodexBar/Providers/OpenCode/OpenCodeSettingsStore.swift +++ b/Sources/CodexBar/Providers/OpenCode/OpenCodeSettingsStore.swift @@ -39,37 +39,14 @@ extension SettingsStore { extension SettingsStore { func opencodeSettingsSnapshot(tokenOverride: TokenAccountOverride?) -> ProviderSettingsSnapshot .OpenCodeProviderSettings { - ProviderSettingsSnapshot.OpenCodeProviderSettings( - cookieSource: self.opencodeSnapshotCookieSource(tokenOverride: tokenOverride), - manualCookieHeader: self.opencodeSnapshotCookieHeader(tokenOverride: tokenOverride), - workspaceID: self.opencodeWorkspaceID) - } - - private func opencodeSnapshotCookieHeader(tokenOverride: TokenAccountOverride?) -> String { - let fallback = self.opencodeCookieHeader - guard let support = TokenAccountSupportCatalog.support(for: .opencode), - case .cookieHeader = support.injection - else { - return fallback - } - guard let account = ProviderTokenAccountSelection.selectedAccount( + let cookieSettings: ProviderSettingsSnapshot.CookieProviderSettings = self.resolvedCookieSettings( provider: .opencode, - settings: self, - override: tokenOverride) - else { - return fallback - } - return TokenAccountSupportCatalog.normalizedCookieHeader(account.token, support: support) - } - - private func opencodeSnapshotCookieSource(tokenOverride: TokenAccountOverride?) -> ProviderCookieSource { - let fallback = self.opencodeCookieSource - guard let support = TokenAccountSupportCatalog.support(for: .opencode), - support.requiresManualCookieSource - else { - return fallback - } - if self.tokenAccounts(for: .opencode).isEmpty { return fallback } - return .manual + configuredSource: self.opencodeCookieSource, + configuredHeader: self.opencodeCookieHeader, + tokenOverride: tokenOverride) + return ProviderSettingsSnapshot.OpenCodeProviderSettings( + cookieSource: cookieSettings.cookieSource, + manualCookieHeader: cookieSettings.manualCookieHeader, + workspaceID: self.opencodeWorkspaceID) } } diff --git a/Sources/CodexBar/Providers/OpenCodeGo/OpenCodeGoProviderImplementation.swift b/Sources/CodexBar/Providers/OpenCodeGo/OpenCodeGoProviderImplementation.swift index 0c5b1ee08e..6fbc8a57a0 100644 --- a/Sources/CodexBar/Providers/OpenCodeGo/OpenCodeGoProviderImplementation.swift +++ b/Sources/CodexBar/Providers/OpenCodeGo/OpenCodeGoProviderImplementation.swift @@ -1,10 +1,8 @@ import AppKit import CodexBarCore -import CodexBarMacroSupport import Foundation import SwiftUI -@ProviderImplementationRegistration struct OpenCodeGoProviderImplementation: ProviderImplementation { let id: UsageProvider = .opencodego @@ -28,7 +26,9 @@ struct OpenCodeGoProviderImplementation: ProviderImplementation { @MainActor func tokenAccountsVisibility(context: ProviderSettingsContext, support: TokenAccountSupport) -> Bool { guard support.requiresManualCookieSource else { return true } - if !context.settings.tokenAccounts(for: context.provider).isEmpty { return true } + if !context.settings.tokenAccounts(for: context.provider).isEmpty { + return true + } return context.settings.opencodegoCookieSource == .manual } @@ -70,10 +70,17 @@ struct OpenCodeGoProviderImplementation: ProviderImplementation { isVisible: nil, onChange: nil, trailingText: { - OpenCodeProviderUI.cachedCookieTrailingText( + ProviderCookieRefreshAction.trailingText( + provider: .opencodego, + cookieSource: context.settings.opencodegoCookieSource, + context: context) + }, + trailingActions: [ + ProviderCookieRefreshAction.descriptor( provider: .opencodego, - cookieSource: context.settings.opencodegoCookieSource) - }), + cookieSource: { context.settings.opencodegoCookieSource }, + context: context), + ]), ] } diff --git a/Sources/CodexBar/Providers/OpenCodeGo/OpenCodeGoSettingsStore.swift b/Sources/CodexBar/Providers/OpenCodeGo/OpenCodeGoSettingsStore.swift index 3f77ff2b20..3e1780f4b8 100644 --- a/Sources/CodexBar/Providers/OpenCodeGo/OpenCodeGoSettingsStore.swift +++ b/Sources/CodexBar/Providers/OpenCodeGo/OpenCodeGoSettingsStore.swift @@ -44,38 +44,15 @@ extension SettingsStore { func opencodegoSettingsSnapshot(tokenOverride: TokenAccountOverride?) -> ProviderSettingsSnapshot .OpenCodeProviderSettings { - ProviderSettingsSnapshot.OpenCodeProviderSettings( - cookieSource: self.opencodegoSnapshotCookieSource(tokenOverride: tokenOverride), - manualCookieHeader: self.opencodegoSnapshotCookieHeader(tokenOverride: tokenOverride), - workspaceID: self.opencodegoSnapshotWorkspaceID) - } - - private func opencodegoSnapshotCookieHeader(tokenOverride: TokenAccountOverride?) -> String { - let fallback = self.opencodegoCookieHeader - guard let support = TokenAccountSupportCatalog.support(for: .opencodego), - case .cookieHeader = support.injection - else { - return fallback - } - guard let account = ProviderTokenAccountSelection.selectedAccount( + let cookieSettings: ProviderSettingsSnapshot.CookieProviderSettings = self.resolvedCookieSettings( provider: .opencodego, - settings: self, - override: tokenOverride) - else { - return fallback - } - return TokenAccountSupportCatalog.normalizedCookieHeader(account.token, support: support) - } - - private func opencodegoSnapshotCookieSource(tokenOverride: TokenAccountOverride?) -> ProviderCookieSource { - let fallback = self.opencodegoCookieSource - guard let support = TokenAccountSupportCatalog.support(for: .opencodego), - support.requiresManualCookieSource - else { - return fallback - } - if self.tokenAccounts(for: .opencodego).isEmpty { return fallback } - return .manual + configuredSource: self.opencodegoCookieSource, + configuredHeader: self.opencodegoCookieHeader, + tokenOverride: tokenOverride) + return ProviderSettingsSnapshot.OpenCodeProviderSettings( + cookieSource: cookieSettings.cookieSource, + manualCookieHeader: cookieSettings.manualCookieHeader, + workspaceID: self.opencodegoSnapshotWorkspaceID) } private var opencodegoSnapshotWorkspaceID: String? { diff --git a/Sources/CodexBar/Providers/OpenRouter/OpenRouterProviderImplementation.swift b/Sources/CodexBar/Providers/OpenRouter/OpenRouterProviderImplementation.swift index d584a24306..e91337548c 100644 --- a/Sources/CodexBar/Providers/OpenRouter/OpenRouterProviderImplementation.swift +++ b/Sources/CodexBar/Providers/OpenRouter/OpenRouterProviderImplementation.swift @@ -1,10 +1,8 @@ import AppKit import CodexBarCore -import CodexBarMacroSupport import Foundation import SwiftUI -@ProviderImplementationRegistration struct OpenRouterProviderImplementation: ProviderImplementation { let id: UsageProvider = .openrouter diff --git a/Sources/CodexBar/Providers/Perplexity/PerplexityProviderImplementation.swift b/Sources/CodexBar/Providers/Perplexity/PerplexityProviderImplementation.swift index 770e18c2e3..00887bdb9c 100644 --- a/Sources/CodexBar/Providers/Perplexity/PerplexityProviderImplementation.swift +++ b/Sources/CodexBar/Providers/Perplexity/PerplexityProviderImplementation.swift @@ -1,10 +1,8 @@ import AppKit import CodexBarCore -import CodexBarMacroSupport import Foundation import SwiftUI -@ProviderImplementationRegistration struct PerplexityProviderImplementation: ProviderImplementation { let id: UsageProvider = .perplexity let supportsLoginFlow: Bool = true diff --git a/Sources/CodexBar/Providers/Perplexity/PerplexitySettingsStore.swift b/Sources/CodexBar/Providers/Perplexity/PerplexitySettingsStore.swift index 6d2d44dfcc..e5d430d425 100644 --- a/Sources/CodexBar/Providers/Perplexity/PerplexitySettingsStore.swift +++ b/Sources/CodexBar/Providers/Perplexity/PerplexitySettingsStore.swift @@ -26,11 +26,10 @@ extension SettingsStore { extension SettingsStore { func perplexitySettingsSnapshot(tokenOverride: TokenAccountOverride?) -> ProviderSettingsSnapshot .PerplexityProviderSettings { - // tokenOverride is not used: Perplexity auth is cookie-based, not token-account-based. - // Manual cookies are handled via perplexityManualCookieHeader in the settings snapshot below. - _ = tokenOverride - return ProviderSettingsSnapshot.PerplexityProviderSettings( - cookieSource: self.perplexityCookieSource, - manualCookieHeader: self.perplexityManualCookieHeader) + self.resolvedCookieSettings( + provider: .perplexity, + configuredSource: self.perplexityCookieSource, + configuredHeader: self.perplexityManualCookieHeader, + tokenOverride: tokenOverride) } } diff --git a/Sources/CodexBar/Providers/Poe/PoeProviderImplementation.swift b/Sources/CodexBar/Providers/Poe/PoeProviderImplementation.swift new file mode 100644 index 0000000000..57023af8b9 --- /dev/null +++ b/Sources/CodexBar/Providers/Poe/PoeProviderImplementation.swift @@ -0,0 +1,38 @@ +import CodexBarCore +import Foundation + +struct PoeProviderImplementation: ProviderImplementation { + let id: UsageProvider = .poe + + @MainActor + func presentation(context _: ProviderPresentationContext) -> ProviderPresentation { + ProviderPresentation { _ in "api" } + } + + @MainActor + func observeSettings(_ settings: SettingsStore) { + _ = settings.poeAPIKey + } + + @MainActor + func settingsFields(context: ProviderSettingsContext) -> [ProviderSettingsFieldDescriptor] { + [ + ProviderSettingsFieldDescriptor( + id: "poe-api-key", + title: "API key", + subtitle: "Stored in ~/.codexbar/config.json. Get your key from poe.com/api/keys.", + kind: .secure, + placeholder: nil, + binding: context.stringBinding(\.poeAPIKey), + actions: [], + isVisible: nil, + onActivate: nil), + ] + } + + @MainActor + func isAvailable(context: ProviderAvailabilityContext) -> Bool { + ProviderTokenResolver.poeToken(environment: context.environment) != nil || + !context.settings.poeAPIKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } +} diff --git a/Sources/CodexBar/Providers/Poe/PoeSettingsStore.swift b/Sources/CodexBar/Providers/Poe/PoeSettingsStore.swift new file mode 100644 index 0000000000..150bdac160 --- /dev/null +++ b/Sources/CodexBar/Providers/Poe/PoeSettingsStore.swift @@ -0,0 +1,16 @@ +import CodexBarCore +import Foundation + +extension SettingsStore { + var poeAPIKey: String { + get { + self.configSnapshot.providerConfig(for: .poe)?.sanitizedAPIKey ?? "" + } + set { + self.updateProviderConfig(provider: .poe) { entry in + entry.apiKey = self.normalizedConfigValue(newValue) + } + self.logSecretUpdate(provider: .poe, field: "apiKey", value: newValue) + } + } +} diff --git a/Sources/CodexBar/Providers/Qoder/QoderProviderImplementation.swift b/Sources/CodexBar/Providers/Qoder/QoderProviderImplementation.swift new file mode 100644 index 0000000000..b21ddd4194 --- /dev/null +++ b/Sources/CodexBar/Providers/Qoder/QoderProviderImplementation.swift @@ -0,0 +1,103 @@ +import AppKit +import CodexBarCore +import Foundation +import SwiftUI + +struct QoderProviderImplementation: ProviderImplementation { + let id: UsageProvider = .qoder + + @MainActor + static func usageDashboardURL(settings: SettingsStore) -> URL { + QoderProviderDescriptor.dashboardURL( + settings: settings.qoderSettingsSnapshot(tokenOverride: nil), + sourceLabel: nil) + } + + @MainActor + func observeSettings(_ settings: SettingsStore) { + _ = settings.qoderCookieSource + _ = settings.qoderCookieHeader + } + + @MainActor + func settingsSnapshot(context: ProviderSettingsSnapshotContext) -> ProviderSettingsSnapshotContribution? { + .qoder(context.settings.qoderSettingsSnapshot(tokenOverride: context.tokenOverride)) + } + + @MainActor + func tokenAccountsVisibility(context: ProviderSettingsContext, support: TokenAccountSupport) -> Bool { + guard support.requiresManualCookieSource else { return true } + if !context.settings.tokenAccounts(for: context.provider).isEmpty { return true } + return context.settings.qoderCookieSource == .manual + } + + @MainActor + func applyTokenAccountCookieSource(settings: SettingsStore) { + if settings.qoderCookieSource != .manual { + settings.qoderCookieSource = .manual + } + } + + @MainActor + func settingsPickers(context: ProviderSettingsContext) -> [ProviderSettingsPickerDescriptor] { + let cookieBinding = Binding( + get: { context.settings.qoderCookieSource.rawValue }, + set: { raw in + context.settings.qoderCookieSource = ProviderCookieSource(rawValue: raw) ?? .auto + }) + let cookieOptions = ProviderCookieSourceUI.options( + allowsOff: false, + keychainDisabled: context.settings.debugDisableKeychainAccess) + + let cookieSubtitle: () -> String? = { + ProviderCookieSourceUI.subtitle( + source: context.settings.qoderCookieSource, + keychainDisabled: context.settings.debugDisableKeychainAccess, + auto: "Automatic imports browser cookies.", + manual: "Paste a Cookie header or cURL capture from Qoder usage.", + off: "Qoder cookies are disabled.") + } + + return [ + ProviderSettingsPickerDescriptor( + id: "qoder-cookie-source", + title: "Cookie source", + subtitle: "Automatic imports browser cookies.", + dynamicSubtitle: cookieSubtitle, + binding: cookieBinding, + options: cookieOptions, + isVisible: nil, + onChange: nil, + trailingText: { + guard let entry = CookieHeaderCache.loadForDisplay(provider: .qoder) else { return nil } + let when = entry.storedAt.relativeDescription() + return "Cached: \(entry.sourceLabel) • \(when)" + }), + ] + } + + @MainActor + func settingsFields(context: ProviderSettingsContext) -> [ProviderSettingsFieldDescriptor] { + [ + ProviderSettingsFieldDescriptor( + id: "qoder-cookie", + title: "", + subtitle: "", + kind: .secure, + placeholder: "Cookie: \u{2026}\n\nor paste a cURL capture from the Qoder usage page", + binding: context.stringBinding(\.qoderCookieHeader), + actions: [ + ProviderSettingsActionDescriptor( + id: "qoder-open-usage", + title: "Open Qoder Usage", + style: .link, + isVisible: nil, + perform: { + NSWorkspace.shared.open(Self.usageDashboardURL(settings: context.settings)) + }), + ], + isVisible: { context.settings.qoderCookieSource == .manual }, + onActivate: { context.settings.ensureQoderCookieLoaded() }), + ] + } +} diff --git a/Sources/CodexBar/Providers/Qoder/QoderSettingsStore.swift b/Sources/CodexBar/Providers/Qoder/QoderSettingsStore.swift new file mode 100644 index 0000000000..0e1bddbf56 --- /dev/null +++ b/Sources/CodexBar/Providers/Qoder/QoderSettingsStore.swift @@ -0,0 +1,38 @@ +import CodexBarCore +import Foundation + +extension SettingsStore { + var qoderCookieHeader: String { + get { self.configSnapshot.providerConfig(for: .qoder)?.sanitizedCookieHeader ?? "" } + set { + self.updateProviderConfig(provider: .qoder) { entry in + entry.cookieHeader = self.normalizedConfigValue(newValue) + } + self.logSecretUpdate(provider: .qoder, field: "cookieHeader", value: newValue) + } + } + + var qoderCookieSource: ProviderCookieSource { + get { self.resolvedCookieSource(provider: .qoder, fallback: .auto) } + set { + self.updateProviderConfig(provider: .qoder) { entry in + entry.cookieSource = newValue + } + self.logProviderModeChange(provider: .qoder, field: "cookieSource", value: newValue.rawValue) + } + } + + func ensureQoderCookieLoaded() {} +} + +extension SettingsStore { + func qoderSettingsSnapshot(tokenOverride: TokenAccountOverride?) -> ProviderSettingsSnapshot + .QoderProviderSettings + { + self.resolvedCookieSettings( + provider: .qoder, + configuredSource: self.qoderCookieSource, + configuredHeader: self.qoderCookieHeader, + tokenOverride: tokenOverride) + } +} diff --git a/Sources/CodexBar/Providers/QwenCloud/QwenCloudProviderImplementation.swift b/Sources/CodexBar/Providers/QwenCloud/QwenCloudProviderImplementation.swift new file mode 100644 index 0000000000..213eb6bf3c --- /dev/null +++ b/Sources/CodexBar/Providers/QwenCloud/QwenCloudProviderImplementation.swift @@ -0,0 +1,91 @@ +import AppKit +import CodexBarCore +import Foundation +import SwiftUI + +struct QwenCloudProviderImplementation: ProviderImplementation { + let id: UsageProvider = .qwencloud + + @MainActor + func presentation(context _: ProviderPresentationContext) -> ProviderPresentation { + ProviderPresentation { context in + context.store.sourceLabel(for: context.provider) + } + } + + @MainActor + func observeSettings(_ settings: SettingsStore) { + _ = settings.qwenCloudCookieSource + _ = settings.qwenCloudCookieHeader + } + + @MainActor + func settingsSnapshot(context: ProviderSettingsSnapshotContext) -> ProviderSettingsSnapshotContribution? { + _ = context + return .qwenCloud(context.settings.qwenCloudSettingsSnapshot()) + } + + @MainActor + func settingsPickers(context: ProviderSettingsContext) -> [ProviderSettingsPickerDescriptor] { + let cookieBinding = Binding( + get: { context.settings.qwenCloudCookieSource.rawValue }, + set: { raw in + context.settings.qwenCloudCookieSource = ProviderCookieSource(rawValue: raw) ?? .auto + }) + let cookieOptions = ProviderCookieSourceUI.options( + allowsOff: false, + keychainDisabled: context.settings.debugDisableKeychainAccess) + let cookieSubtitle: () -> String? = { + ProviderCookieSourceUI.subtitle( + source: context.settings.qwenCloudCookieSource, + keychainDisabled: context.settings.debugDisableKeychainAccess, + auto: "Automatic imports browser cookies from Qwen Cloud.", + manual: "Paste a Cookie header from home.qwencloud.com.", + off: "Qwen Cloud cookies are disabled.") + } + + return [ + ProviderSettingsPickerDescriptor( + id: "qwen-cloud-cookie-source", + title: "Cookie source", + subtitle: "Automatic imports browser cookies from Qwen Cloud.", + dynamicSubtitle: cookieSubtitle, + binding: cookieBinding, + options: cookieOptions, + isVisible: nil, + onChange: nil, + trailingText: { + guard let entry = CookieHeaderCache.loadForDisplay(provider: .qwencloud) else { return nil } + let when = entry.storedAt.relativeDescription() + return "Cached: \(entry.sourceLabel) • \(when)" + }), + ] + } + + @MainActor + func settingsFields(context: ProviderSettingsContext) -> [ProviderSettingsFieldDescriptor] { + [ + ProviderSettingsFieldDescriptor( + id: "qwen-cloud-cookie", + title: "Cookie header", + subtitle: "", + kind: .secure, + placeholder: "Cookie: ...", + binding: context.stringBinding(\.qwenCloudCookieHeader), + actions: [ + ProviderSettingsActionDescriptor( + id: "qwen-cloud-open-dashboard", + title: "Open Token Plan", + style: .link, + isVisible: nil, + perform: { + NSWorkspace.shared.open(QwenCloudUsageFetcher.dashboardURL) + }), + ], + isVisible: { + context.settings.qwenCloudCookieSource == .manual + }, + onActivate: nil), + ] + } +} diff --git a/Sources/CodexBar/Providers/QwenCloud/QwenCloudSettingsStore.swift b/Sources/CodexBar/Providers/QwenCloud/QwenCloudSettingsStore.swift new file mode 100644 index 0000000000..ddd79395be --- /dev/null +++ b/Sources/CodexBar/Providers/QwenCloud/QwenCloudSettingsStore.swift @@ -0,0 +1,30 @@ +import CodexBarCore +import Foundation + +extension SettingsStore { + var qwenCloudCookieHeader: String { + get { self.configSnapshot.providerConfig(for: .qwencloud)?.sanitizedCookieHeader ?? "" } + set { + self.updateProviderConfig(provider: .qwencloud) { entry in + entry.cookieHeader = self.normalizedConfigValue(newValue) + } + self.logSecretUpdate(provider: .qwencloud, field: "cookieHeader", value: newValue) + } + } + + var qwenCloudCookieSource: ProviderCookieSource { + get { self.resolvedCookieSource(provider: .qwencloud, fallback: .auto) } + set { + self.updateProviderConfig(provider: .qwencloud) { entry in + entry.cookieSource = newValue + } + self.logProviderModeChange(provider: .qwencloud, field: "cookieSource", value: newValue.rawValue) + } + } + + func qwenCloudSettingsSnapshot() -> ProviderSettingsSnapshot.QwenCloudProviderSettings { + ProviderSettingsSnapshot.QwenCloudProviderSettings( + cookieSource: self.qwenCloudCookieSource, + manualCookieHeader: self.qwenCloudCookieHeader) + } +} diff --git a/Sources/CodexBar/Providers/Sakana/SakanaProviderImplementation.swift b/Sources/CodexBar/Providers/Sakana/SakanaProviderImplementation.swift new file mode 100644 index 0000000000..d58600dec9 --- /dev/null +++ b/Sources/CodexBar/Providers/Sakana/SakanaProviderImplementation.swift @@ -0,0 +1,51 @@ +import AppKit +import CodexBarCore +import Foundation + +struct SakanaProviderImplementation: ProviderImplementation { + let id: UsageProvider = .sakana + + @MainActor + func presentation(context _: ProviderPresentationContext) -> ProviderPresentation { + ProviderPresentation { _ in "web" } + } + + @MainActor + func observeSettings(_ settings: SettingsStore) { + _ = settings.sakanaCookieHeader + } + + @MainActor + func isAvailable(context: ProviderAvailabilityContext) -> Bool { + SakanaSettingsReader.cookieHeader(environment: context.environment) != nil + } + + @MainActor + func settingsFields(context: ProviderSettingsContext) -> [ProviderSettingsFieldDescriptor] { + let subtitle = "Stored in ~/.codexbar/config.json. Copy the Sakana AI console Cookie request header." + + return [ + ProviderSettingsFieldDescriptor( + id: "sakana-cookie", + title: "Cookie header", + subtitle: subtitle, + kind: .secure, + placeholder: "Cookie: ...", + binding: context.stringBinding(\.sakanaCookieHeader), + actions: [ + ProviderSettingsActionDescriptor( + id: "sakana-open-dashboard", + title: "Open Sakana AI Console", + style: .link, + isVisible: nil, + perform: { + if let url = URL(string: "https://console.sakana.ai/billing") { + NSWorkspace.shared.open(url) + } + }), + ], + isVisible: nil, + onActivate: nil), + ] + } +} diff --git a/Sources/CodexBar/Providers/Sakana/SakanaSettingsStore.swift b/Sources/CodexBar/Providers/Sakana/SakanaSettingsStore.swift new file mode 100644 index 0000000000..805c76ced9 --- /dev/null +++ b/Sources/CodexBar/Providers/Sakana/SakanaSettingsStore.swift @@ -0,0 +1,14 @@ +import CodexBarCore +import Foundation + +extension SettingsStore { + var sakanaCookieHeader: String { + get { self.configSnapshot.providerConfig(for: .sakana)?.sanitizedCookieHeader ?? "" } + set { + self.updateProviderConfig(provider: .sakana) { entry in + entry.cookieHeader = self.normalizedConfigValue(newValue) + } + self.logSecretUpdate(provider: .sakana, field: "cookieHeader", value: newValue) + } + } +} diff --git a/Sources/CodexBar/Providers/Shared/ProviderCookieRefreshAction.swift b/Sources/CodexBar/Providers/Shared/ProviderCookieRefreshAction.swift new file mode 100644 index 0000000000..2ceb1320ec --- /dev/null +++ b/Sources/CodexBar/Providers/Shared/ProviderCookieRefreshAction.swift @@ -0,0 +1,76 @@ +import CodexBarCore +import Foundation + +@MainActor +enum ProviderCookieRefreshAction { + enum Outcome: Equatable { + case refreshed + case failed + } + + static func descriptor( + provider: UsageProvider, + cookieSource: @escaping () -> ProviderCookieSource, + additionalVisibility: @escaping () -> Bool = { true }, + context: ProviderSettingsContext) -> ProviderSettingsActionDescriptor + { + ProviderSettingsActionDescriptor( + id: "\(provider.rawValue)-reimport-cookie", + title: "Refresh", + style: .bordered, + isVisible: { cookieSource() == .auto && additionalVisibility() }, + perform: { + await self.perform(provider: provider, context: context) + }) + } + + static func trailingText( + provider: UsageProvider, + cookieSource: ProviderCookieSource, + context: ProviderSettingsContext) -> String? + { + guard cookieSource != .manual else { return nil } + return context.statusText(self.statusID(provider)) ?? ProviderCookieSourceUI + .cachedTrailingText(provider: provider) + } + + static func refresh( + provider: UsageProvider, + operation: () async -> Bool) async -> Outcome + { + await ProviderInteractionContext.$current.withValue(.userInitiated) { + guard let gate = CookieHeaderCache.beginRefreshReadSuppression(provider: provider) else { + return .failed + } + defer { CookieHeaderCache.endRefreshReadSuppression(gate) } + + let validated = await operation() + guard validated, !Task.isCancelled else { return .failed } + + let commit = CookieHeaderCache.commitRefreshReadSuppression(gate) + guard commit.stagedCount > 0, + commit.committedCount == commit.stagedCount, + commit.failedCount == 0 + else { return .failed } + return .refreshed + } + } + + private static func perform(provider: UsageProvider, context: ProviderSettingsContext) async { + context.setStatusText(self.statusID(provider), L("Refreshing")) + let previousUpdatedAt = context.store.snapshot(for: provider)?.updatedAt + let outcome = await self.refresh(provider: provider) { + await context.store.refreshProvider(provider, allowDisabled: true) + guard context.store.error(for: provider) == nil, + context.store.lastSourceLabels[provider] == "web", + let updatedAt = context.store.snapshot(for: provider)?.updatedAt + else { return false } + return previousUpdatedAt.map { updatedAt != $0 } ?? true + } + context.setStatusText(self.statusID(provider), outcome == .refreshed ? nil : L("Failed")) + } + + private static func statusID(_ provider: UsageProvider) -> String { + "\(provider.rawValue)-cookie-refresh-status" + } +} diff --git a/Sources/CodexBar/Providers/Shared/ProviderCookieSettingsResolution.swift b/Sources/CodexBar/Providers/Shared/ProviderCookieSettingsResolution.swift new file mode 100644 index 0000000000..e6804793fc --- /dev/null +++ b/Sources/CodexBar/Providers/Shared/ProviderCookieSettingsResolution.swift @@ -0,0 +1,22 @@ +import CodexBarCore + +extension SettingsStore { + func resolvedCookieSettings( + provider: UsageProvider, + configuredSource: ProviderCookieSource, + configuredHeader: String?, + tokenOverride: TokenAccountOverride?) -> Settings + { + let resolved = ProviderCookieSettingsResolver.resolve( + provider: provider, + configuredSource: configuredSource, + configuredHeader: configuredHeader, + selectedAccount: ProviderTokenAccountSelection.selectedAccount( + provider: provider, + settings: self, + override: tokenOverride)) + return Settings( + cookieSource: resolved.cookieSource, + manualCookieHeader: resolved.manualCookieHeader) + } +} diff --git a/Sources/CodexBar/Providers/Shared/ProviderCookieSourceUI.swift b/Sources/CodexBar/Providers/Shared/ProviderCookieSourceUI.swift index 7c3fccc720..f86c260b1c 100644 --- a/Sources/CodexBar/Providers/Shared/ProviderCookieSourceUI.swift +++ b/Sources/CodexBar/Providers/Shared/ProviderCookieSourceUI.swift @@ -4,6 +4,18 @@ enum ProviderCookieSourceUI { static let keychainDisabledPrefixKey = "Keychain access is disabled in Advanced, so browser cookie import is unavailable." + @MainActor + static func cachedTrailingText(provider: UsageProvider, scope: CookieHeaderCache.Scope? = nil) -> String? { + guard let entry = CookieHeaderCache.loadForDisplay(provider: provider, scope: scope) else { return nil } + return self.cachedTrailingText(entry: entry) + } + + @MainActor + static func cachedTrailingText(entry: CookieHeaderCache.Entry) -> String { + let when = entry.storedAt.relativeDescription() + return L("Cached: %1$@ • %2$@", entry.sourceLabel, when) + } + static func options(allowsOff: Bool, keychainDisabled: Bool) -> [ProviderSettingsPickerOption] { var options: [ProviderSettingsPickerOption] = [] if !keychainDisabled { diff --git a/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift b/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift index ef97d3f062..c811590992 100644 --- a/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift +++ b/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift @@ -17,15 +17,18 @@ enum ProviderImplementationRegistry { case .openai: OpenAIAPIProviderImplementation() case .azureopenai: AzureOpenAIProviderImplementation() case .claude: ClaudeProviderImplementation() + case .clinepass: ClinePassProviderImplementation() case .cursor: CursorProviderImplementation() case .opencode: OpenCodeProviderImplementation() case .opencodego: OpenCodeGoProviderImplementation() case .alibaba: AlibabaCodingPlanProviderImplementation() case .alibabatokenplan: AlibabaTokenPlanProviderImplementation() + case .qwencloud: QwenCloudProviderImplementation() case .factory: FactoryProviderImplementation() case .gemini: GeminiProviderImplementation() case .antigravity: AntigravityProviderImplementation() case .copilot: CopilotProviderImplementation() + case .devin: DevinProviderImplementation() case .zai: ZaiProviderImplementation() case .minimax: MiniMaxProviderImplementation() case .manus: ManusProviderImplementation() @@ -35,7 +38,6 @@ enum ProviderImplementationRegistry { case .vertexai: VertexAIProviderImplementation() case .augment: AugmentProviderImplementation() case .jetbrains: JetBrainsProviderImplementation() - case .kimik2: KimiK2ProviderImplementation() case .moonshot: MoonshotProviderImplementation() case .amp: AmpProviderImplementation() case .t3chat: T3ChatProviderImplementation() @@ -45,22 +47,38 @@ enum ProviderImplementationRegistry { case .elevenlabs: ElevenLabsProviderImplementation() case .warp: WarpProviderImplementation() case .windsurf: WindsurfProviderImplementation() + case .zed: ZedProviderImplementation() case .perplexity: PerplexityProviderImplementation() case .mimo: MiMoProviderImplementation() case .doubao: DoubaoProviderImplementation() + case .sakana: SakanaProviderImplementation() case .abacus: AbacusProviderImplementation() case .mistral: MistralProviderImplementation() case .deepseek: DeepSeekProviderImplementation() + case .deepinfra: DeepInfraProviderImplementation() case .codebuff: CodebuffProviderImplementation() case .crof: CrofProviderImplementation() case .venice: VeniceProviderImplementation() case .commandcode: CommandCodeProviderImplementation() + case .qoder: QoderProviderImplementation() case .stepfun: StepFunProviderImplementation() case .bedrock: BedrockProviderImplementation() case .grok: GrokProviderImplementation() case .groq: GroqProviderImplementation() case .llmproxy: LLMProxyProviderImplementation() + case .litellm: LiteLLMProviderImplementation() case .deepgram: DeepgramProviderImplementation() + case .poe: PoeProviderImplementation() + case .chutes: ChutesProviderImplementation() + case .neuralwatt: NeuralWattProviderImplementation() + case .clawrouter: ClawRouterProviderImplementation() + case .longcat: LongCatProviderImplementation() + case .sub2api: Sub2APIProviderImplementation() + case .wayfinder: WayfinderProviderImplementation() + case .zenmux: ZenMuxProviderImplementation() + case .aiand: AiAndProviderImplementation() + case .zoommate: ZoomMateProviderImplementation() + case .xai: XAIProviderImplementation() } } diff --git a/Sources/CodexBar/Providers/Shared/ProviderMenuContext.swift b/Sources/CodexBar/Providers/Shared/ProviderMenuContext.swift index ac350b05e6..d40df25d4e 100644 --- a/Sources/CodexBar/Providers/Shared/ProviderMenuContext.swift +++ b/Sources/CodexBar/Providers/Shared/ProviderMenuContext.swift @@ -25,4 +25,5 @@ struct ProviderMenuLoginContext { let store: UsageStore let settings: SettingsStore let account: AccountInfo + let hasAccount: Bool } diff --git a/Sources/CodexBar/Providers/Shared/ProviderSettingsDescriptors.swift b/Sources/CodexBar/Providers/Shared/ProviderSettingsDescriptors.swift index 5dcefde146..0d30aed34c 100644 --- a/Sources/CodexBar/Providers/Shared/ProviderSettingsDescriptors.swift +++ b/Sources/CodexBar/Providers/Shared/ProviderSettingsDescriptors.swift @@ -82,6 +82,9 @@ struct ProviderSettingsToggleDescriptor: Identifiable { /// Optional runtime visibility gate. let isVisible: (() -> Bool)? + /// Optional runtime enabled gate. + let isEnabled: (() -> Bool)? + /// Called whenever the toggle changes. let onChange: ((_ enabled: Bool) async -> Void)? @@ -90,6 +93,32 @@ struct ProviderSettingsToggleDescriptor: Identifiable { /// Called when the view appears while the toggle is enabled. let onAppearWhenEnabled: (() async -> Void)? + + init( + id: String, + title: String, + subtitle: String, + binding: Binding, + statusText: (() -> String?)?, + actions: [ProviderSettingsActionDescriptor], + isVisible: (() -> Bool)?, + isEnabled: (() -> Bool)? = nil, + onChange: ((_ enabled: Bool) async -> Void)?, + onAppDidBecomeActive: (() async -> Void)?, + onAppearWhenEnabled: (() async -> Void)?) + { + self.id = id + self.title = title + self.subtitle = subtitle + self.binding = binding + self.statusText = statusText + self.actions = actions + self.isVisible = isVisible + self.isEnabled = isEnabled + self.onChange = onChange + self.onAppDidBecomeActive = onAppDidBecomeActive + self.onAppearWhenEnabled = onAppearWhenEnabled + } } /// Shared text field descriptor rendered in the Providers settings pane. @@ -135,7 +164,18 @@ struct ProviderSettingsTokenAccountsDescriptor: Identifiable { let activeIndex: () -> Int let setActiveIndex: (Int) -> Void let showsOrganizationField: Bool - let addAccount: (_ label: String, _ token: String, _ organizationID: String?) -> Void + let showsTeamModeControls: Bool + let addAccount: ( + _ label: String, + _ token: String, + _ usageScope: String?, + _ organizationID: String?, + _ workspaceID: String?) -> Void + let updateAccount: ( + _ accountID: UUID, + _ usageScope: String?, + _ organizationID: String?, + _ workspaceID: String?) -> Void let removeAccount: (_ accountID: UUID) -> Void let primaryAddActionTitle: String? let primaryAddAction: (() async -> Void)? @@ -192,11 +232,17 @@ struct ProviderSettingsOrganizationsDescriptor: Identifiable { } /// Shared picker descriptor rendered in the Providers settings pane. +enum ProviderSettingsPickerPlacement: Equatable { + case menuBar + case connection +} + @MainActor struct ProviderSettingsPickerDescriptor: Identifiable { let id: String let title: String let subtitle: String + let placement: ProviderSettingsPickerPlacement let dynamicSubtitle: (() -> String?)? let binding: Binding let options: [ProviderSettingsPickerOption] @@ -204,22 +250,26 @@ struct ProviderSettingsPickerDescriptor: Identifiable { let isEnabled: (() -> Bool)? let onChange: ((_ selection: String) async -> Void)? let trailingText: (() -> String?)? + let trailingActions: [ProviderSettingsActionDescriptor] init( id: String, title: String, subtitle: String, + placement: ProviderSettingsPickerPlacement = .connection, dynamicSubtitle: (() -> String?)? = nil, binding: Binding, options: [ProviderSettingsPickerOption], isVisible: (() -> Bool)?, isEnabled: (() -> Bool)? = nil, onChange: ((_ selection: String) async -> Void)?, - trailingText: (() -> String?)? = nil) + trailingText: (() -> String?)? = nil, + trailingActions: [ProviderSettingsActionDescriptor] = []) { self.id = id self.title = title self.subtitle = subtitle + self.placement = placement self.dynamicSubtitle = dynamicSubtitle self.binding = binding self.options = options @@ -227,6 +277,7 @@ struct ProviderSettingsPickerDescriptor: Identifiable { self.isEnabled = isEnabled self.onChange = onChange self.trailingText = trailingText + self.trailingActions = trailingActions } } diff --git a/Sources/CodexBar/Providers/Shared/ProviderTokenAccountSelection.swift b/Sources/CodexBar/Providers/Shared/ProviderTokenAccountSelection.swift index 86c2618ceb..c8f56173b0 100644 --- a/Sources/CodexBar/Providers/Shared/ProviderTokenAccountSelection.swift +++ b/Sources/CodexBar/Providers/Shared/ProviderTokenAccountSelection.swift @@ -13,7 +13,21 @@ enum ProviderTokenAccountSelection { settings: SettingsStore, override: TokenAccountOverride?) -> ProviderTokenAccount? { - if let override, override.provider == provider { return override.account } - return settings.selectedTokenAccount(for: provider) + if let override, override.provider == provider { + return override.account + } + return settings.effectiveSelectedTokenAccount(for: provider) + } + + @MainActor + static func shouldIncludeOptionalUsage( + provider: UsageProvider, + settings: SettingsStore, + override: TokenAccountOverride?) -> Bool + { + guard provider == .deepseek else { return settings.showOptionalCreditsAndExtraUsage } + guard settings.costUsageEnabled, settings.showOptionalCreditsAndExtraUsage else { return false } + guard let override, override.provider == provider else { return true } + return settings.selectedTokenAccount(for: provider)?.id == override.account.id } } diff --git a/Sources/CodexBar/Providers/StepFun/StepFunProviderImplementation.swift b/Sources/CodexBar/Providers/StepFun/StepFunProviderImplementation.swift index dec72a8c88..7871faa726 100644 --- a/Sources/CodexBar/Providers/StepFun/StepFunProviderImplementation.swift +++ b/Sources/CodexBar/Providers/StepFun/StepFunProviderImplementation.swift @@ -1,10 +1,8 @@ import AppKit import CodexBarCore -import CodexBarMacroSupport import Foundation import SwiftUI -@ProviderImplementationRegistration struct StepFunProviderImplementation: ProviderImplementation { let id: UsageProvider = .stepfun @@ -96,9 +94,7 @@ struct StepFunProviderImplementation: ProviderImplementation { isVisible: nil, onChange: nil, trailingText: { - guard let entry = CookieHeaderCache.load(provider: .stepfun) else { return nil } - let when = entry.storedAt.relativeDescription() - return "Cached: \(entry.sourceLabel) • \(when)" + ProviderCookieSourceUI.cachedTrailingText(provider: .stepfun) }), ] } diff --git a/Sources/CodexBar/Providers/StepFun/StepFunSettingsStore.swift b/Sources/CodexBar/Providers/StepFun/StepFunSettingsStore.swift index d1a533e90d..27fa571f8e 100644 --- a/Sources/CodexBar/Providers/StepFun/StepFunSettingsStore.swift +++ b/Sources/CodexBar/Providers/StepFun/StepFunSettingsStore.swift @@ -51,38 +51,15 @@ extension SettingsStore { func stepfunSettingsSnapshot(tokenOverride: TokenAccountOverride?) -> ProviderSettingsSnapshot .StepFunProviderSettings { - ProviderSettingsSnapshot.StepFunProviderSettings( - cookieSource: self.stepfunSnapshotCookieSource(tokenOverride: tokenOverride), - manualToken: self.stepfunSnapshotToken(tokenOverride: tokenOverride), + let cookieSettings: ProviderSettingsSnapshot.CookieProviderSettings = self.resolvedCookieSettings( + provider: .stepfun, + configuredSource: self.stepfunCookieSource, + configuredHeader: self.stepfunToken, + tokenOverride: tokenOverride) + return ProviderSettingsSnapshot.StepFunProviderSettings( + cookieSource: cookieSettings.cookieSource, + manualToken: cookieSettings.manualCookieHeader ?? "", username: self.stepfunUsername, password: self.stepfunPassword) } - - private func stepfunSnapshotToken(tokenOverride: TokenAccountOverride?) -> String { - let fallback = self.stepfunToken - guard let support = TokenAccountSupportCatalog.support(for: .stepfun), - case .cookieHeader = support.injection - else { - return fallback - } - guard let account = ProviderTokenAccountSelection.selectedAccount( - provider: .stepfun, - settings: self, - override: tokenOverride) - else { - return fallback - } - return TokenAccountSupportCatalog.normalizedCookieHeader(account.token, support: support) - } - - private func stepfunSnapshotCookieSource(tokenOverride: TokenAccountOverride?) -> ProviderCookieSource { - let fallback = self.stepfunCookieSource - guard let support = TokenAccountSupportCatalog.support(for: .stepfun), - support.requiresManualCookieSource - else { - return fallback - } - if self.tokenAccounts(for: .stepfun).isEmpty { return fallback } - return .manual - } } diff --git a/Sources/CodexBar/Providers/Sub2API/Sub2APIProviderImplementation.swift b/Sources/CodexBar/Providers/Sub2API/Sub2APIProviderImplementation.swift new file mode 100644 index 0000000000..4fa483cc7d --- /dev/null +++ b/Sources/CodexBar/Providers/Sub2API/Sub2APIProviderImplementation.swift @@ -0,0 +1,89 @@ +import CodexBarCore +import Foundation + +struct Sub2APIProviderImplementation: ProviderImplementation { + let id: UsageProvider = .sub2api + + @MainActor + func presentation(context _: ProviderPresentationContext) -> ProviderPresentation { + ProviderPresentation { _ in "api" } + } + + @MainActor + func observeSettings(_ settings: SettingsStore) { + _ = settings.sub2APIAPIKey + _ = settings.sub2APIBaseURL + _ = settings.tokenAccountsData(for: .sub2api) + } + + @MainActor + func isAvailable(context: ProviderAvailabilityContext) -> Bool { + Sub2APISettingsReader.apiKey(environment: context.environment) != nil && + Sub2APISettingsReader.baseURL(environment: context.environment) != nil + } + + @MainActor + func settingsFields(context: ProviderSettingsContext) -> [ProviderSettingsFieldDescriptor] { + [ + ProviderSettingsFieldDescriptor( + id: "sub2api-api-key", + title: "Fallback API key", + subtitle: "Used when no group API key account is selected.", + kind: .secure, + placeholder: "sk-…", + binding: context.stringBinding(\.sub2APIAPIKey), + actions: [], + isVisible: nil, + onActivate: nil), + ProviderSettingsFieldDescriptor( + id: "sub2api-base-url", + title: "Base URL", + subtitle: "Base URL of your sub2api instance. HTTPS is required except for local loopback testing.", + kind: .plain, + placeholder: "https://sub2api.example.com", + binding: context.stringBinding(\.sub2APIBaseURL), + actions: [], + isVisible: nil, + onActivate: nil), + ] + } + + @MainActor + func appendUsageMenuEntries(context: ProviderMenuUsageContext, entries: inout [ProviderMenuEntry]) { + guard let usage = context.snapshot?.sub2APIUsage else { return } + if let balance = usage.balance { + let balanceText = UsageFormatter.convertedCostString( + balance, + preferredCurrency: context.settings.preferredCurrencyCode, + providerCurrency: usage.unit) + entries.append(.text("\(L("Balance")): \(balanceText)", .primary)) + } + if let today = usage.today { + let totals = self.totalsText( + today, + unit: usage.unit, + preferredCurrencyCode: context.settings.preferredCurrencyCode) + entries.append(.text("\(L("Today")): \(totals)", .secondary)) + } + if let total = usage.total { + let totals = self.totalsText( + total, + unit: usage.unit, + preferredCurrencyCode: context.settings.preferredCurrencyCode) + entries.append(.text("\(L("Total")): \(totals)", .secondary)) + } + } + + private func totalsText( + _ totals: Sub2APIUsageDetails.Totals, + unit: String, + preferredCurrencyCode: String) -> String + { + "\(UsageFormatter.tokenCountString(totals.requests)) \(L("requests")) · " + + "\(UsageFormatter.tokenCountString(totals.totalTokens)) \(L("tokens")) · " + + UsageFormatter.convertedCostString( + totals.actualCostUSD, + preferredCurrency: preferredCurrencyCode, + providerCurrency: unit) + } +} diff --git a/Sources/CodexBar/Providers/Sub2API/Sub2APISettingsStore.swift b/Sources/CodexBar/Providers/Sub2API/Sub2APISettingsStore.swift new file mode 100644 index 0000000000..0ca6e94702 --- /dev/null +++ b/Sources/CodexBar/Providers/Sub2API/Sub2APISettingsStore.swift @@ -0,0 +1,23 @@ +import CodexBarCore +import Foundation + +extension SettingsStore { + var sub2APIAPIKey: String { + get { self.configSnapshot.providerConfig(for: .sub2api)?.sanitizedAPIKey ?? "" } + set { + self.updateProviderConfig(provider: .sub2api) { entry in + entry.apiKey = self.normalizedConfigValue(newValue) + } + self.logSecretUpdate(provider: .sub2api, field: "apiKey", value: newValue) + } + } + + var sub2APIBaseURL: String { + get { self.configSnapshot.providerConfig(for: .sub2api)?.sanitizedEnterpriseHost ?? "" } + set { + self.updateProviderConfig(provider: .sub2api) { entry in + entry.enterpriseHost = self.normalizedConfigValue(newValue) + } + } + } +} diff --git a/Sources/CodexBar/Providers/Synthetic/SyntheticProviderImplementation.swift b/Sources/CodexBar/Providers/Synthetic/SyntheticProviderImplementation.swift index dcef3eb675..b8aa9b0634 100644 --- a/Sources/CodexBar/Providers/Synthetic/SyntheticProviderImplementation.swift +++ b/Sources/CodexBar/Providers/Synthetic/SyntheticProviderImplementation.swift @@ -1,9 +1,7 @@ import AppKit import CodexBarCore -import CodexBarMacroSupport import Foundation -@ProviderImplementationRegistration struct SyntheticProviderImplementation: ProviderImplementation { let id: UsageProvider = .synthetic diff --git a/Sources/CodexBar/Providers/T3Chat/T3ChatProviderImplementation.swift b/Sources/CodexBar/Providers/T3Chat/T3ChatProviderImplementation.swift index 6a1c364636..abb6d53220 100644 --- a/Sources/CodexBar/Providers/T3Chat/T3ChatProviderImplementation.swift +++ b/Sources/CodexBar/Providers/T3Chat/T3ChatProviderImplementation.swift @@ -1,10 +1,8 @@ import AppKit import CodexBarCore -import CodexBarMacroSupport import Foundation import SwiftUI -@ProviderImplementationRegistration struct T3ChatProviderImplementation: ProviderImplementation { let id: UsageProvider = .t3chat diff --git a/Sources/CodexBar/Providers/T3Chat/T3ChatSettingsStore.swift b/Sources/CodexBar/Providers/T3Chat/T3ChatSettingsStore.swift index 8f092953c9..4ea59eb182 100644 --- a/Sources/CodexBar/Providers/T3Chat/T3ChatSettingsStore.swift +++ b/Sources/CodexBar/Providers/T3Chat/T3ChatSettingsStore.swift @@ -27,36 +27,10 @@ extension SettingsStore { func t3ChatSettingsSnapshot( tokenOverride: TokenAccountOverride?) -> ProviderSettingsSnapshot.T3ChatProviderSettings { - ProviderSettingsSnapshot.T3ChatProviderSettings( - cookieSource: self.t3ChatSnapshotCookieSource(tokenOverride: tokenOverride), - manualCookieHeader: self.t3ChatSnapshotCookieHeader(tokenOverride: tokenOverride)) - } - - private func t3ChatSnapshotCookieHeader(tokenOverride: TokenAccountOverride?) -> String { - let fallback = self.t3ChatCookieHeader - guard let support = TokenAccountSupportCatalog.support(for: .t3chat), - case .cookieHeader = support.injection - else { - return fallback - } - guard let account = ProviderTokenAccountSelection.selectedAccount( + self.resolvedCookieSettings( provider: .t3chat, - settings: self, - override: tokenOverride) - else { - return fallback - } - return TokenAccountSupportCatalog.normalizedCookieHeader(account.token, support: support) - } - - private func t3ChatSnapshotCookieSource(tokenOverride: TokenAccountOverride?) -> ProviderCookieSource { - let fallback = self.t3ChatCookieSource - guard let support = TokenAccountSupportCatalog.support(for: .t3chat), - support.requiresManualCookieSource - else { - return fallback - } - if self.tokenAccounts(for: .t3chat).isEmpty { return fallback } - return .manual + configuredSource: self.t3ChatCookieSource, + configuredHeader: self.t3ChatCookieHeader, + tokenOverride: tokenOverride) } } diff --git a/Sources/CodexBar/Providers/Venice/VeniceProviderImplementation.swift b/Sources/CodexBar/Providers/Venice/VeniceProviderImplementation.swift index d16b84b744..2886d30dd6 100644 --- a/Sources/CodexBar/Providers/Venice/VeniceProviderImplementation.swift +++ b/Sources/CodexBar/Providers/Venice/VeniceProviderImplementation.swift @@ -1,8 +1,6 @@ import CodexBarCore -import CodexBarMacroSupport import Foundation -@ProviderImplementationRegistration struct VeniceProviderImplementation: ProviderImplementation { let id: UsageProvider = .venice diff --git a/Sources/CodexBar/Providers/VertexAI/VertexAILoginFlow.swift b/Sources/CodexBar/Providers/VertexAI/VertexAILoginFlow.swift index 2741f091dd..8e8e0ed9f1 100644 --- a/Sources/CodexBar/Providers/VertexAI/VertexAILoginFlow.swift +++ b/Sources/CodexBar/Providers/VertexAI/VertexAILoginFlow.swift @@ -16,7 +16,8 @@ extension StatusItemController { let response = alert.runModal() if response == .alertFirstButtonReturn { - Self.openTerminalWithGcloudCommand() + self.openTerminal( + command: "gcloud auth application-default login --scopes=openid,https://www.googleapis.com/auth/userinfo.email,https://www.googleapis.com/auth/cloud-platform") } // Refresh after user may have logged in @@ -26,23 +27,4 @@ extension StatusItemController { await self.store.refresh() } } - - private static func openTerminalWithGcloudCommand() { - let script = """ - tell application "Terminal" - activate - do script "gcloud auth application-default login --scopes=openid,https://www.googleapis.com/auth/userinfo.email,https://www.googleapis.com/auth/cloud-platform" - end tell - """ - - if let appleScript = NSAppleScript(source: script) { - var error: NSDictionary? - appleScript.executeAndReturnError(&error) - if let error { - CodexBarLog.logger(LogCategories.terminal).error( - "Failed to open Terminal", - metadata: ["error": String(describing: error)]) - } - } - } } diff --git a/Sources/CodexBar/Providers/VertexAI/VertexAIProviderImplementation.swift b/Sources/CodexBar/Providers/VertexAI/VertexAIProviderImplementation.swift index 0f3b6f82c5..7399d55868 100644 --- a/Sources/CodexBar/Providers/VertexAI/VertexAIProviderImplementation.swift +++ b/Sources/CodexBar/Providers/VertexAI/VertexAIProviderImplementation.swift @@ -1,8 +1,6 @@ import CodexBarCore -import CodexBarMacroSupport import Foundation -@ProviderImplementationRegistration struct VertexAIProviderImplementation: ProviderImplementation { let id: UsageProvider = .vertexai let supportsLoginFlow: Bool = true diff --git a/Sources/CodexBar/Providers/Warp/WarpProviderImplementation.swift b/Sources/CodexBar/Providers/Warp/WarpProviderImplementation.swift index e9cb82de9b..97bfcc8680 100644 --- a/Sources/CodexBar/Providers/Warp/WarpProviderImplementation.swift +++ b/Sources/CodexBar/Providers/Warp/WarpProviderImplementation.swift @@ -1,9 +1,7 @@ import AppKit import CodexBarCore -import CodexBarMacroSupport import Foundation -@ProviderImplementationRegistration struct WarpProviderImplementation: ProviderImplementation { let id: UsageProvider = .warp diff --git a/Sources/CodexBar/Providers/Wayfinder/WayfinderProviderImplementation.swift b/Sources/CodexBar/Providers/Wayfinder/WayfinderProviderImplementation.swift new file mode 100644 index 0000000000..8aeddd4242 --- /dev/null +++ b/Sources/CodexBar/Providers/Wayfinder/WayfinderProviderImplementation.swift @@ -0,0 +1,51 @@ +import CodexBarCore +import Foundation + +struct WayfinderProviderImplementation: ProviderImplementation { + let id: UsageProvider = .wayfinder + + @MainActor + static func dashboardURL( + settings: SettingsStore, + environment: [String: String] = ProcessInfo.processInfo.environment) -> URL + { + let effectiveEnvironment = ProviderConfigEnvironment.applyProviderConfigOverrides( + base: environment, + provider: .wayfinder, + config: settings.providerConfig(for: .wayfinder)) + return WayfinderSettingsReader.dashboardURL(environment: effectiveEnvironment) + } + + @MainActor + func presentation(context _: ProviderPresentationContext) -> ProviderPresentation { + ProviderPresentation { _ in "api" } + } + + @MainActor + func observeSettings(_ settings: SettingsStore) { + _ = settings.wayfinderGatewayURL + } + + @MainActor + func isAvailable(context _: ProviderAvailabilityContext) -> Bool { + // The gateway's read-only API needs no credentials; enabling the provider is the opt-in. + true + } + + @MainActor + func settingsFields(context: ProviderSettingsContext) -> [ProviderSettingsFieldDescriptor] { + [ + ProviderSettingsFieldDescriptor( + id: "wayfinder-gateway-url", + title: "Gateway URL", + subtitle: "Local Wayfinder gateway. Read-only polling of health, routing split, and " + + "savings — prompts are never read or sent.", + kind: .plain, + placeholder: WayfinderSettingsReader.defaultBaseURL.absoluteString, + binding: context.stringBinding(\.wayfinderGatewayURL), + actions: [], + isVisible: nil, + onActivate: nil), + ] + } +} diff --git a/Sources/CodexBar/Providers/Wayfinder/WayfinderSettingsStore.swift b/Sources/CodexBar/Providers/Wayfinder/WayfinderSettingsStore.swift new file mode 100644 index 0000000000..4d8a993136 --- /dev/null +++ b/Sources/CodexBar/Providers/Wayfinder/WayfinderSettingsStore.swift @@ -0,0 +1,13 @@ +import CodexBarCore +import Foundation + +extension SettingsStore { + var wayfinderGatewayURL: String { + get { self.configSnapshot.providerConfig(for: .wayfinder)?.sanitizedEnterpriseHost ?? "" } + set { + self.updateProviderConfig(provider: .wayfinder) { entry in + entry.enterpriseHost = self.normalizedConfigValue(newValue) + } + } + } +} diff --git a/Sources/CodexBar/Providers/Windsurf/WindsurfProviderImplementation.swift b/Sources/CodexBar/Providers/Windsurf/WindsurfProviderImplementation.swift index 38d290663a..66ff4fbb3c 100644 --- a/Sources/CodexBar/Providers/Windsurf/WindsurfProviderImplementation.swift +++ b/Sources/CodexBar/Providers/Windsurf/WindsurfProviderImplementation.swift @@ -1,9 +1,7 @@ import CodexBarCore -import CodexBarMacroSupport import Foundation import SwiftUI -@ProviderImplementationRegistration struct WindsurfProviderImplementation: ProviderImplementation { let id: UsageProvider = .windsurf diff --git a/Sources/CodexBar/Providers/Windsurf/WindsurfSettingsStore.swift b/Sources/CodexBar/Providers/Windsurf/WindsurfSettingsStore.swift index 683e97c7ed..60357ca7af 100644 --- a/Sources/CodexBar/Providers/Windsurf/WindsurfSettingsStore.swift +++ b/Sources/CodexBar/Providers/Windsurf/WindsurfSettingsStore.swift @@ -45,10 +45,15 @@ extension SettingsStore { func windsurfSettingsSnapshot( tokenOverride: TokenAccountOverride?) -> ProviderSettingsSnapshot.WindsurfProviderSettings { - ProviderSettingsSnapshot.WindsurfProviderSettings( + let cookieSettings: ProviderSettingsSnapshot.CookieProviderSettings = self.resolvedCookieSettings( + provider: .windsurf, + configuredSource: self.windsurfCookieSource, + configuredHeader: self.windsurfCookieHeader, + tokenOverride: tokenOverride) + return ProviderSettingsSnapshot.WindsurfProviderSettings( usageDataSource: self.windsurfUsageDataSource, - cookieSource: self.windsurfSnapshotCookieSource(tokenOverride: tokenOverride), - manualCookieHeader: self.windsurfSnapshotCookieHeader(tokenOverride: tokenOverride)) + cookieSource: cookieSettings.cookieSource, + manualCookieHeader: cookieSettings.manualCookieHeader) } private static func windsurfUsageDataSource(from source: ProviderSourceMode?) -> WindsurfUsageDataSource { @@ -62,32 +67,4 @@ extension SettingsStore { return .cli } } - - private func windsurfSnapshotCookieHeader(tokenOverride: TokenAccountOverride?) -> String { - let fallback = self.windsurfCookieHeader - guard let support = TokenAccountSupportCatalog.support(for: .windsurf), - case .cookieHeader = support.injection - else { - return fallback - } - guard let account = ProviderTokenAccountSelection.selectedAccount( - provider: .windsurf, - settings: self, - override: tokenOverride) - else { - return fallback - } - return TokenAccountSupportCatalog.normalizedCookieHeader(account.token, support: support) - } - - private func windsurfSnapshotCookieSource(tokenOverride: TokenAccountOverride?) -> ProviderCookieSource { - let fallback = self.windsurfCookieSource - guard let support = TokenAccountSupportCatalog.support(for: .windsurf), - support.requiresManualCookieSource - else { - return fallback - } - if self.tokenAccounts(for: .windsurf).isEmpty { return fallback } - return .manual - } } diff --git a/Sources/CodexBar/Providers/XAI/XAIProviderImplementation.swift b/Sources/CodexBar/Providers/XAI/XAIProviderImplementation.swift new file mode 100644 index 0000000000..80bf4b9f7c --- /dev/null +++ b/Sources/CodexBar/Providers/XAI/XAIProviderImplementation.swift @@ -0,0 +1,52 @@ +import CodexBarCore +import Foundation + +struct XAIProviderImplementation: ProviderImplementation { + let id: UsageProvider = .xai + + @MainActor + func presentation(context _: ProviderPresentationContext) -> ProviderPresentation { + ProviderPresentation { _ in "api" } + } + + @MainActor + func observeSettings(_ settings: SettingsStore) { + _ = settings.xaiManagementAPIKey + _ = settings.xaiTeamID + } + + @MainActor + func isAvailable(context: ProviderAvailabilityContext) -> Bool { + if XAISettingsReader.apiKey(environment: context.environment) != nil { + return true + } + return !context.settings.xaiManagementAPIKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + + @MainActor + func settingsFields(context: ProviderSettingsContext) -> [ProviderSettingsFieldDescriptor] { + [ + ProviderSettingsFieldDescriptor( + id: "xai-management-api-key", + title: "Management API key", + subtitle: "Stored in ~/.codexbar/config.json. Create one at console.x.ai under " + + "Settings > Management Keys; inference API keys are not accepted.", + kind: .secure, + placeholder: "xai-...", + binding: context.stringBinding(\.xaiManagementAPIKey), + actions: [], + isVisible: nil, + onActivate: nil), + ProviderSettingsFieldDescriptor( + id: "xai-team-id", + title: "Team ID", + subtitle: "Required. Shown in the xAI Console URL and team settings.", + kind: .plain, + placeholder: "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", + binding: context.stringBinding(\.xaiTeamID), + actions: [], + isVisible: nil, + onActivate: nil), + ] + } +} diff --git a/Sources/CodexBar/Providers/XAI/XAISettingsStore.swift b/Sources/CodexBar/Providers/XAI/XAISettingsStore.swift new file mode 100644 index 0000000000..2c75bee099 --- /dev/null +++ b/Sources/CodexBar/Providers/XAI/XAISettingsStore.swift @@ -0,0 +1,27 @@ +import CodexBarCore +import Foundation + +extension SettingsStore { + var xaiManagementAPIKey: String { + get { + self.configSnapshot.providerConfig(for: .xai)?.sanitizedAPIKey ?? "" + } + set { + self.updateProviderConfig(provider: .xai) { entry in + entry.apiKey = self.normalizedConfigValue(newValue) + } + self.logSecretUpdate(provider: .xai, field: "apiKey", value: newValue) + } + } + + var xaiTeamID: String { + get { + self.configSnapshot.providerConfig(for: .xai)?.sanitizedWorkspaceID ?? "" + } + set { + self.updateProviderConfig(provider: .xai) { entry in + entry.workspaceID = self.normalizedConfigValue(newValue) + } + } + } +} diff --git a/Sources/CodexBar/Providers/Zai/ZaiProviderImplementation.swift b/Sources/CodexBar/Providers/Zai/ZaiProviderImplementation.swift index d4bc64a9f5..adeece16f7 100644 --- a/Sources/CodexBar/Providers/Zai/ZaiProviderImplementation.swift +++ b/Sources/CodexBar/Providers/Zai/ZaiProviderImplementation.swift @@ -1,10 +1,8 @@ import AppKit import CodexBarCore -import CodexBarMacroSupport import Foundation import SwiftUI -@ProviderImplementationRegistration struct ZaiProviderImplementation: ProviderImplementation { let id: UsageProvider = .zai @@ -21,8 +19,7 @@ struct ZaiProviderImplementation: ProviderImplementation { @MainActor func settingsSnapshot(context: ProviderSettingsSnapshotContext) -> ProviderSettingsSnapshotContribution? { - _ = context - return .zai(context.settings.zaiSettingsSnapshot()) + .zai(context.settings.zaiSettingsSnapshot(tokenOverride: context.tokenOverride)) } @MainActor @@ -44,7 +41,6 @@ struct ZaiProviderImplementation: ProviderImplementation { let options = ZaiAPIRegion.allCases.map { ProviderSettingsPickerOption(id: $0.rawValue, title: $0.displayName) } - return [ ProviderSettingsPickerDescriptor( id: "zai-api-region", @@ -58,8 +54,7 @@ struct ZaiProviderImplementation: ProviderImplementation { } @MainActor - func settingsFields(context: ProviderSettingsContext) -> [ProviderSettingsFieldDescriptor] { - _ = context - return [] + func settingsFields(context _: ProviderSettingsContext) -> [ProviderSettingsFieldDescriptor] { + [] } } diff --git a/Sources/CodexBar/Providers/Zai/ZaiSettingsStore.swift b/Sources/CodexBar/Providers/Zai/ZaiSettingsStore.swift index 5d6a6fa51b..a5c1779a67 100644 --- a/Sources/CodexBar/Providers/Zai/ZaiSettingsStore.swift +++ b/Sources/CodexBar/Providers/Zai/ZaiSettingsStore.swift @@ -28,7 +28,37 @@ extension SettingsStore { } extension SettingsStore { - func zaiSettingsSnapshot() -> ProviderSettingsSnapshot.ZaiProviderSettings { - ProviderSettingsSnapshot.ZaiProviderSettings(apiRegion: self.zaiAPIRegion) + func zaiSettingsSnapshot( + tokenOverride: TokenAccountOverride? = nil) -> ProviderSettingsSnapshot.ZaiProviderSettings + { + let usageScope = self.zaiEffectiveUsageScope(tokenOverride: tokenOverride) + let account = ProviderTokenAccountSelection.selectedAccount( + provider: .zai, + settings: self, + override: tokenOverride) + let teamContext: ZaiBigModelTeamContext? = if usageScope == .team { + ZaiBigModelTeamContext( + organizationID: account?.sanitizedOrganizationID, + projectID: account?.sanitizedWorkspaceID) + } else { + nil + } + return ProviderSettingsSnapshot.ZaiProviderSettings( + apiRegion: self.zaiAPIRegion, + usageScope: usageScope, + teamContext: teamContext) + } + + func zaiEffectiveUsageScope(tokenOverride: TokenAccountOverride? = nil) -> ZaiUsageScope { + let account = ProviderTokenAccountSelection.selectedAccount( + provider: .zai, + settings: self, + override: tokenOverride) + return Self.zaiUsageScope(from: account) ?? .personal + } + + private static func zaiUsageScope(from account: ProviderTokenAccount?) -> ZaiUsageScope? { + guard let raw = account?.sanitizedUsageScope?.lowercased() else { return nil } + return ZaiUsageScope(rawValue: raw) } } diff --git a/Sources/CodexBar/Providers/Zed/ZedProviderImplementation.swift b/Sources/CodexBar/Providers/Zed/ZedProviderImplementation.swift new file mode 100644 index 0000000000..d7287ea3b7 --- /dev/null +++ b/Sources/CodexBar/Providers/Zed/ZedProviderImplementation.swift @@ -0,0 +1,5 @@ +import CodexBarCore + +struct ZedProviderImplementation: ProviderImplementation { + let id: UsageProvider = .zed +} diff --git a/Sources/CodexBar/Providers/ZenMux/ZenMuxProviderImplementation.swift b/Sources/CodexBar/Providers/ZenMux/ZenMuxProviderImplementation.swift new file mode 100644 index 0000000000..41966c5208 --- /dev/null +++ b/Sources/CodexBar/Providers/ZenMux/ZenMuxProviderImplementation.swift @@ -0,0 +1,52 @@ +import AppKit +import CodexBarCore +import Foundation + +struct ZenMuxProviderImplementation: ProviderImplementation { + let id: UsageProvider = .zenmux + + @MainActor + func presentation(context _: ProviderPresentationContext) -> ProviderPresentation { + ProviderPresentation { _ in "api" } + } + + @MainActor + func observeSettings(_ settings: SettingsStore) { + _ = settings.zenMuxManagementAPIKey + } + + @MainActor + func isAvailable(context: ProviderAvailabilityContext) -> Bool { + if ZenMuxSettingsReader.managementAPIKey(environment: context.environment) != nil { + return true + } + return !context.settings.zenMuxManagementAPIKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + + @MainActor + func settingsFields(context: ProviderSettingsContext) -> [ProviderSettingsFieldDescriptor] { + [ + ProviderSettingsFieldDescriptor( + id: "zenmux-management-api-key", + title: "Management API key", + subtitle: "Stored in ~/.codexbar/config.json. Standard ZenMux inference API keys are not supported.", + kind: .secure, + placeholder: "ZenMux management key…", + binding: context.stringBinding(\.zenMuxManagementAPIKey), + actions: [ + ProviderSettingsActionDescriptor( + id: "zenmux-open-management", + title: "Open ZenMux Management", + style: .link, + isVisible: nil, + perform: { + if let url = URL(string: "https://zenmux.ai/platform/management") { + NSWorkspace.shared.open(url) + } + }), + ], + isVisible: nil, + onActivate: nil), + ] + } +} diff --git a/Sources/CodexBar/Providers/ZenMux/ZenMuxSettingsStore.swift b/Sources/CodexBar/Providers/ZenMux/ZenMuxSettingsStore.swift new file mode 100644 index 0000000000..6774bd98e5 --- /dev/null +++ b/Sources/CodexBar/Providers/ZenMux/ZenMuxSettingsStore.swift @@ -0,0 +1,14 @@ +import CodexBarCore +import Foundation + +extension SettingsStore { + var zenMuxManagementAPIKey: String { + get { self.configSnapshot.providerConfig(for: .zenmux)?.sanitizedAPIKey ?? "" } + set { + self.updateProviderConfig(provider: .zenmux) { entry in + entry.apiKey = self.normalizedConfigValue(newValue) + } + self.logSecretUpdate(provider: .zenmux, field: "apiKey", value: newValue) + } + } +} diff --git a/Sources/CodexBar/Providers/ZoomMate/ZoomMateProviderImplementation.swift b/Sources/CodexBar/Providers/ZoomMate/ZoomMateProviderImplementation.swift new file mode 100644 index 0000000000..d83e1fb96c --- /dev/null +++ b/Sources/CodexBar/Providers/ZoomMate/ZoomMateProviderImplementation.swift @@ -0,0 +1,100 @@ +import AppKit +import CodexBarCore +import Foundation +import SwiftUI + +struct ZoomMateProviderImplementation: ProviderImplementation { + let id: UsageProvider = .zoommate + + /// ZoomMate is a web-cookie provider with no CLI/version detector, so the default detail line + /// ("zoommate not detected") would misleadingly read as "provider not found". Match the other + /// web-cookie providers (Cursor, Perplexity, Manus, …) and surface the source instead. + @MainActor + func presentation(context _: ProviderPresentationContext) -> ProviderPresentation { + ProviderPresentation { _ in "web" } + } + + @MainActor + func observeSettings(_ settings: SettingsStore) { + _ = settings.zoomMateCookieSource + _ = settings.zoomMateCookieHeader + } + + @MainActor + func settingsSnapshot(context: ProviderSettingsSnapshotContext) -> ProviderSettingsSnapshotContribution? { + .zoommate(context.settings.zoomMateSettingsSnapshot(tokenOverride: context.tokenOverride)) + } + + @MainActor + func settingsPickers(context: ProviderSettingsContext) -> [ProviderSettingsPickerDescriptor] { + let cookieBinding = Binding( + get: { context.settings.zoomMateCookieSource.rawValue }, + set: { raw in + context.settings.zoomMateCookieSource = ProviderCookieSource(rawValue: raw) ?? .auto + }) + let cookieOptions = ProviderCookieSourceUI.options( + allowsOff: false, + keychainDisabled: context.settings.debugDisableKeychainAccess) + + let cookieSubtitle: () -> String? = { + ProviderCookieSourceUI.subtitle( + source: context.settings.zoomMateCookieSource, + keychainDisabled: context.settings.debugDisableKeychainAccess, + auto: "Automatically signs in using your ZoomMate session cookies from Chrome.", + manual: "Paste a cURL capture from the ZoomMate AI credit usage page.", + off: "Paste a cURL capture from the ZoomMate AI credit usage page.") + } + + return [ + ProviderSettingsPickerDescriptor( + id: "zoommate-cookie-source", + title: "Cookie source", + subtitle: "Automatically signs in using your ZoomMate session cookies from Chrome.", + dynamicSubtitle: cookieSubtitle, + binding: cookieBinding, + options: cookieOptions, + isVisible: nil, + onChange: nil, + trailingText: { + ProviderCookieRefreshAction.trailingText( + provider: .zoommate, + cookieSource: context.settings.zoomMateCookieSource, + context: context) + }, + trailingActions: [ + ProviderCookieRefreshAction.descriptor( + provider: .zoommate, + cookieSource: { context.settings.zoomMateCookieSource }, + context: context), + ]), + ] + } + + @MainActor + func settingsFields(context: ProviderSettingsContext) -> [ProviderSettingsFieldDescriptor] { + [ + ProviderSettingsFieldDescriptor( + id: "zoommate-cookie", + title: "ZoomMate capture", + subtitle: "Paste a full cURL capture from the ZoomMate AI credit usage page. " + + "The token expires approximately hourly, so you may need to re-paste periodically.", + kind: .secure, + placeholder: "curl 'https://ai.zoom.us/ai-computer/api/v1/credits/status' -H 'authorization: ...'", + binding: context.stringBinding(\.zoomMateCookieHeader), + actions: [ + ProviderSettingsActionDescriptor( + id: "zoommate-open-app", + title: "Open ZoomMate", + style: .link, + isVisible: nil, + perform: { + if let url = URL(string: "https://zoommate.zoom.us/#/?settings=credit-usage") { + NSWorkspace.shared.open(url) + } + }), + ], + isVisible: { context.settings.zoomMateCookieSource == .manual }, + onActivate: nil), + ] + } +} diff --git a/Sources/CodexBar/Providers/ZoomMate/ZoomMateSettingsStore.swift b/Sources/CodexBar/Providers/ZoomMate/ZoomMateSettingsStore.swift new file mode 100644 index 0000000000..f44e01ad45 --- /dev/null +++ b/Sources/CodexBar/Providers/ZoomMate/ZoomMateSettingsStore.swift @@ -0,0 +1,36 @@ +import CodexBarCore +import Foundation + +extension SettingsStore { + var zoomMateCookieHeader: String { + get { self.configSnapshot.providerConfig(for: .zoommate)?.sanitizedCookieHeader ?? "" } + set { + self.updateProviderConfig(provider: .zoommate) { entry in + entry.cookieHeader = self.normalizedConfigValue(newValue) + } + self.logSecretUpdate(provider: .zoommate, field: "cookieHeader", value: newValue) + } + } + + var zoomMateCookieSource: ProviderCookieSource { + get { self.resolvedCookieSource(provider: .zoommate, fallback: .auto) } + set { + self.updateProviderConfig(provider: .zoommate) { entry in + entry.cookieSource = newValue + } + self.logProviderModeChange(provider: .zoommate, field: "cookieSource", value: newValue.rawValue) + } + } +} + +extension SettingsStore { + func zoomMateSettingsSnapshot( + tokenOverride: TokenAccountOverride?) -> ProviderSettingsSnapshot.ZoomMateProviderSettings + { + self.resolvedCookieSettings( + provider: .zoommate, + configuredSource: self.zoomMateCookieSource, + configuredHeader: self.zoomMateCookieHeader, + tokenOverride: tokenOverride) + } +} diff --git a/Sources/CodexBar/QuotaWarningAlertOverlayController.swift b/Sources/CodexBar/QuotaWarningAlertOverlayController.swift new file mode 100644 index 0000000000..564fb09df0 --- /dev/null +++ b/Sources/CodexBar/QuotaWarningAlertOverlayController.swift @@ -0,0 +1,178 @@ +import AppKit +import CodexBarCore +import SwiftUI + +struct QuotaWarningAlertPresentationState { + struct Presentation: Equatable { + let generation: UInt + let title: String + let message: String + } + + private(set) var current: Presentation? + private var nextGeneration: UInt = 0 + + mutating func present(title: String, message: String) -> Presentation { + self.nextGeneration &+= 1 + let presentation = Presentation( + generation: self.nextGeneration, + title: title, + message: message) + self.current = presentation + return presentation + } + + mutating func dismiss(generation: UInt) -> Bool { + guard self.current?.generation == generation else { return false } + self.current = nil + return true + } + + mutating func dismiss() { + self.current = nil + } +} + +/// Presents a transient, centered text alert when a quota warning threshold is crossed. +/// +/// Modeled after ``ScreenConfettiOverlayController``: it shows a borderless, click-through +/// panel above all spaces and auto-dismisses after a short lifetime, so it never steals focus +/// or blocks the user's work. +@MainActor +final class QuotaWarningAlertOverlayController { + private static let overlayLifetime: TimeInterval = 4.5 + + private let logger = CodexBarLog.logger(LogCategories.sessionQuotaNotifications) + private var presentationState = QuotaWarningAlertPresentationState() + private var window: NSWindow? + private var dismissalTask: Task? + + func show(title: String, message: String) { + self.dismiss() + + guard let screen = NSScreen.main ?? NSScreen.screens.first else { + self.logger.error("Cannot present quota warning overlay because no screens were found") + return + } + + let presentation = self.presentationState.present(title: title, message: message) + + let frame = screen.frame + let contentView = QuotaWarningAlertOverlayView(title: title, message: message) + .allowsHitTesting(false) + let hostingView = NSHostingView(rootView: contentView) + hostingView.wantsLayer = true + hostingView.layer?.backgroundColor = NSColor.clear.cgColor + + let window = ClickThroughAlertPanel( + contentRect: frame, + styleMask: [.borderless, .nonactivatingPanel], + backing: .buffered, + defer: false, + screen: screen) + window.contentView = hostingView + window.level = .statusBar + window.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .ignoresCycle, .stationary] + window.backgroundColor = .clear + window.isOpaque = false + window.hasShadow = false + window.ignoresMouseEvents = true + window.acceptsMouseMovedEvents = false + window.isMovable = false + window.isReleasedWhenClosed = false + window.canHide = false + window.hidesOnDeactivate = false + window.becomesKeyOnlyIfNeeded = false + window.isExcludedFromWindowsMenu = true + window.setFrame(frame, display: false) + window.orderFrontRegardless() + self.window = window + + self.logger.info("Presenting quota warning overlay") + + self.dismissalTask = Task { @MainActor [weak self] in + try? await Task.sleep(for: .seconds(Self.overlayLifetime)) + guard !Task.isCancelled else { return } + guard let self, self.presentationState.dismiss(generation: presentation.generation) else { return } + self.closeWindow() + } + } + + func dismiss() { + self.dismissalTask?.cancel() + self.dismissalTask = nil + self.presentationState.dismiss() + self.closeWindow() + } + + private func closeWindow() { + guard let window = self.window else { return } + window.orderOut(nil) + window.close() + self.window = nil + } +} + +private final class ClickThroughAlertPanel: NSPanel { + override var canBecomeKey: Bool { + false + } + + override var canBecomeMain: Bool { + false + } + + override var acceptsFirstResponder: Bool { + false + } +} + +private struct QuotaWarningAlertOverlayView: View { + let title: String + let message: String + + @Environment(\.accessibilityReduceMotion) private var reduceMotion + @State private var appeared = false + + var body: some View { + HStack(alignment: .top, spacing: 12) { + Image(systemName: "exclamationmark.triangle.fill") + .font(.title2) + .foregroundStyle(.orange) + + VStack(alignment: .leading, spacing: 4) { + Text(self.title) + .font(.headline) + Text(self.message) + .font(.subheadline) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + } + .padding(.vertical, 16) + .padding(.horizontal, 20) + .frame(maxWidth: 420, alignment: .leading) + .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 16, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 16, style: .continuous) + .strokeBorder(Color.primary.opacity(0.08))) + .shadow(color: .black.opacity(0.25), radius: 24, y: 8) + .scaleEffect(self.reduceMotion || self.appeared ? 1 : 0.92) + .opacity(self.reduceMotion || self.appeared ? 1 : 0) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) + .padding(40) + .allowsHitTesting(false) + .accessibilityElement(children: .combine) + .accessibilityLabel(self.title) + .accessibilityValue(self.message) + .task { + guard !self.reduceMotion else { + self.appeared = true + return + } + withAnimation(.spring(response: 0.4, dampingFraction: 0.8)) { + self.appeared = true + } + } + } +} diff --git a/Sources/CodexBar/QuotaWarningSettingsViews.swift b/Sources/CodexBar/QuotaWarningSettingsViews.swift index ce37b40f1b..2261128f8d 100644 --- a/Sources/CodexBar/QuotaWarningSettingsViews.swift +++ b/Sources/CodexBar/QuotaWarningSettingsViews.swift @@ -1,160 +1,244 @@ import CodexBarCore +#if os(macOS) +import AppKit +#endif import SwiftUI +struct QuotaWarningSettingsVisibility: Equatable { + let showsThresholdControls: Bool + let showsDeliveryControls: Bool + + init(thresholdWarningsEnabled: Bool, predictiveWarningsEnabled: Bool) { + self.showsThresholdControls = thresholdWarningsEnabled + self.showsDeliveryControls = thresholdWarningsEnabled || predictiveWarningsEnabled + } +} + @MainActor struct GlobalQuotaWarningSettingsView: View { @Bindable var settings: SettingsStore + let showsThresholdControls: Bool + + init(settings: SettingsStore, showsThresholdControls: Bool = true) { + self.settings = settings + self.showsThresholdControls = showsThresholdControls + } var body: some View { VStack(alignment: .leading, spacing: 10) { - HStack(spacing: 16) { - Toggle(isOn: Binding( - get: { self.settings.quotaWarningWindowEnabled(.session) }, - set: { self.settings.setQuotaWarningWindowEnabled(.session, enabled: $0) })) - { - Text(L("quota_warning_session_capitalized")) - .font(.footnote) - } - .toggleStyle(.checkbox) - - Toggle(isOn: Binding( - get: { self.settings.quotaWarningWindowEnabled(.weekly) }, - set: { self.settings.setQuotaWarningWindowEnabled(.weekly, enabled: $0) })) - { - Text(L("quota_warning_weekly_capitalized")) - .font(.footnote) - } - .toggleStyle(.checkbox) - } + if self.showsThresholdControls { + QuotaWarningWindowThresholdRows(settings: self.settings) - self.windowThresholdField(.session) - self.windowThresholdField(.weekly) + Text(L("quota_warning_global_threshold_subtitle")) + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } Toggle(isOn: self.$settings.quotaWarningSoundEnabled) { Text(L("quota_warning_sound")) .font(.footnote) } .toggleStyle(.checkbox) - } - .padding(.leading, 20) - } - private func windowThresholdField(_ window: QuotaWarningWindow) -> some View { - QuotaWarningThresholdField( - title: String(format: L("quota_warning_window_warn_at"), window.localizedCapitalizedDisplayName), - subtitle: L("quota_warning_global_threshold_subtitle"), - thresholds: { self.settings.quotaWarningThresholds(window) }, - setThresholds: { self.settings.setQuotaWarningThresholds(window, thresholds: $0) }) - .disabled(!self.settings.quotaWarningWindowEnabled(window)) - .opacity(!self.settings.quotaWarningWindowEnabled(window) ? 0.55 : 1) + Toggle(isOn: self.$settings.quotaWarningOnScreenAlertEnabled) { + Text(L("quota_warning_onscreen_alert")) + .font(.footnote) + } + .toggleStyle(.checkbox) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.leading, 22) + .background(FocusResigningBackground()) + .listRowSeparator(.hidden) } } @MainActor struct ProviderQuotaWarningSettingsView: View { + private static let windowRowMinHeight: CGFloat = 26 + private static let thresholdFieldWidth: CGFloat = 40 + let provider: UsageProvider @Bindable var settings: SettingsStore var body: some View { - ProviderSettingsSection(title: L("quota_warnings_title")) { - Text(L("quota_warning_provider_inherits")) - .font(.footnote) - .foregroundStyle(.secondary) - .fixedSize(horizontal: false, vertical: true) + Section { + Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 8) { + self.windowRow(.session) + self.windowRow(.weekly) + } + .frame(maxWidth: .infinity, alignment: .leading) + .listRowSeparator(.hidden) + .disabled(!self.controlsEnabled) + .opacity(self.controlsEnabled ? 1 : 0.45) + } header: { + Text(L("quota_warnings_title")) + } footer: { + SettingsSectionFooter(self.footerText) + } + .background(FocusResigningBackground()) + } - self.windowRow(.session) - self.windowRow(.weekly) + var controlsEnabled: Bool { + self.settings.quotaWarningNotificationsEnabled || self.settings.quotaWarningMarkersVisible + } + + var footerText: String { + if self.settings.quotaWarningNotificationsEnabled { + return L("quota_warning_provider_inherits") + } + if self.settings.quotaWarningMarkersVisible { + return L("quota_warning_provider_markers_only") } + return L("quota_warning_provider_disabled") } private func windowRow(_ window: QuotaWarningWindow) -> some View { - VStack(alignment: .leading, spacing: 8) { - Toggle(isOn: Binding( - get: { self.settings.hasQuotaWarningOverride(provider: self.provider, window: window) }, - set: { isOn in - if isOn { - self.settings.setQuotaWarningOverride( - provider: self.provider, - window: window, - thresholds: self.settings.quotaWarningThresholds(window), - enabled: self.settings.quotaWarningWindowEnabled(window)) - } else { - self.settings.setQuotaWarningOverride( - provider: self.provider, - window: window, - thresholds: nil, - enabled: nil) - } - })) { - Text(String(format: L("quota_warning_customize_thresholds"), window.localizedDisplayName)) - .font(.subheadline.weight(.semibold)) - } - .toggleStyle(.checkbox) + GridRow(alignment: .firstTextBaseline) { + Text(window.localizedCapitalizedDisplayName) + .font(.subheadline.weight(.semibold)) + .fixedSize(horizontal: true, vertical: false) + .frame(minHeight: Self.windowRowMinHeight, alignment: .center) + .gridColumnAlignment(.leading) - if self.settings.hasQuotaWarningOverride(provider: self.provider, window: window) { - Toggle(isOn: Binding( - get: { self.settings.quotaWarningEnabled(provider: self.provider, window: window) }, - set: { - self.settings.setQuotaWarningWindowEnabled( + Picker(window.localizedCapitalizedDisplayName, selection: self.overrideModeBinding(for: window)) { + Text(L("quota_warning_global")).tag(ProviderQuotaWarningOverrideMode.global) + Text(L("Custom")).tag(ProviderQuotaWarningOverrideMode.custom) + Text(L("quota_warning_off")).tag(ProviderQuotaWarningOverrideMode.off) + } + .labelsHidden() + .pickerStyle(.segmented) + .controlSize(.small) + .fixedSize() + .frame(minHeight: Self.windowRowMinHeight, alignment: .center) + .gridColumnAlignment(.leading) + + self.windowDetail(window) + .frame(minHeight: Self.windowRowMinHeight, alignment: .leading) + .gridColumnAlignment(.leading) + } + } + + @ViewBuilder + private func windowDetail(_ window: QuotaWarningWindow) -> some View { + switch self.overrideMode(for: window) { + case .custom: + QuotaWarningThresholdField( + title: "", + subtitle: "", + accessibilityContext: window.localizedCapitalizedDisplayName, + shouldCommitOnDisappear: { + self.shouldCommitThresholdEditorOnDisappear(for: window) + }, + thresholds: { + self.settings.resolvedQuotaWarningThresholds(provider: self.provider, window: window) + }, + setThresholds: { + self.settings.setQuotaWarningThresholdsIfOverridden( + provider: self.provider, + window: window, + thresholds: $0) + }, + fieldWidth: Self.thresholdFieldWidth, + controlFont: .subheadline) + .fixedSize(horizontal: true, vertical: false) + case .off: + EmptyView() + case .global: + Text(String(format: L("quota_warning_inherited"), Self.thresholdText( + self.settings.quotaWarningThresholds(window), + enabled: self.settings.quotaWarningWindowEnabled(window)))) + .font(.subheadline) + .foregroundStyle(.secondary) + } + } + + func overrideModeBinding(for window: QuotaWarningWindow) -> Binding { + Binding( + get: { self.overrideMode(for: window) }, + set: { mode in + let currentMode = self.overrideMode(for: window) + guard mode != currentMode else { return } + + switch mode { + case .custom: + self.settings.setQuotaWarningOverride( + provider: self.provider, + window: window, + thresholds: self.settings.explicitQuotaWarningThresholds( provider: self.provider, - window: window, - enabled: $0) - })) { - Text(String(format: L("quota_warning_enable_warnings"), window.localizedDisplayName)) - .font(.footnote) - } - .toggleStyle(.checkbox) - .padding(.leading, 20) - - if self.settings.quotaWarningEnabled(provider: self.provider, window: window) { - QuotaWarningThresholdField( - title: String( - format: L("quota_warning_window_warn_at"), - window.localizedCapitalizedDisplayName), - subtitle: "", - thresholds: { - self.settings.resolvedQuotaWarningThresholds(provider: self.provider, window: window) - }, - setThresholds: { - self.settings.setQuotaWarningThresholds( + window: window), + enabled: true) + case .off: + self.settings.setQuotaWarningOverride( + provider: self.provider, + window: window, + thresholds: currentMode == .custom + ? self.settings.explicitQuotaWarningThresholds( provider: self.provider, - window: window, - thresholds: $0) - }) - .padding(.leading, 20) - } else { - Text(L("quota_warning_off")) - .font(.footnote) - .foregroundStyle(.secondary) - .padding(.leading, 20) + window: window) + : nil, + enabled: false) + case .global: + self.settings.setQuotaWarningOverride( + provider: self.provider, + window: window, + thresholds: nil, + enabled: nil) } - } else { - Text(String(format: L("quota_warning_inherited"), Self.thresholdText( - self.settings.quotaWarningThresholds(window), - enabled: self.settings.quotaWarningWindowEnabled(window)))) - .font(.footnote) - .foregroundStyle(.secondary) - .padding(.leading, 20) - } + }) + } + + func overrideMode(for window: QuotaWarningWindow) -> ProviderQuotaWarningOverrideMode { + guard self.settings.hasQuotaWarningOverride(provider: self.provider, window: window) else { + return .global } + return self.settings.quotaWarningEnabled(provider: self.provider, window: window) ? .custom : .off + } + + func shouldCommitThresholdEditorOnDisappear(for window: QuotaWarningWindow) -> Bool { + let mode = self.overrideMode(for: window) + return mode == .custom || mode == .off } - private static func thresholdText(_ thresholds: [Int], enabled: Bool) -> String { + static func thresholdText(_ thresholds: [Int], enabled: Bool) -> String { guard enabled else { return L("quota_warning_off") } - let text = QuotaWarningThresholds.active(thresholds).map { "\($0)%" }.joined(separator: ", ") - return text.isEmpty ? L("quota_warning_depleted_only") : text + let activeThresholds = QuotaWarningThresholds.active(thresholds) + guard let upperThreshold = activeThresholds.first else { + return L("quota_warning_depleted_only") + } + + var parts: [String] = [] + parts.append("\(L("quota_warning_warning")) \(upperThreshold)%") + if let lowerThreshold = activeThresholds.dropFirst().first { + parts.append("\(L("quota_warning_critical")) \(lowerThreshold)%") + } + parts.append(contentsOf: activeThresholds.dropFirst(2).map { "\($0)%" }) + return parts.joined(separator: ", ") } } -extension QuotaWarningWindow { - fileprivate var localizedDisplayName: String { - switch self { - case .session: L("quota_warning_session") - case .weekly: L("quota_warning_weekly") - } +enum ProviderQuotaWarningOverrideMode: Hashable { + case global + case custom + case off +} + +struct FocusResigningBackground: View { + var body: some View { + Color.clear + .contentShape(Rectangle()) + .onTapGesture { + #if os(macOS) + NSApplication.shared.keyWindow?.makeFirstResponder(nil) + #endif + } } +} - fileprivate var localizedCapitalizedDisplayName: String { +extension QuotaWarningWindow { + var localizedCapitalizedDisplayName: String { switch self { case .session: L("quota_warning_session_capitalized") case .weekly: L("quota_warning_weekly_capitalized") @@ -162,52 +246,64 @@ extension QuotaWarningWindow { } } +@MainActor +private struct QuotaWarningWindowThresholdRows: View { + @Bindable var settings: SettingsStore + + var body: some View { + Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 8) { + self.windowThresholdRow(.session) + self.windowThresholdRow(.weekly) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + + private func windowThresholdRow(_ window: QuotaWarningWindow) -> some View { + GridRow(alignment: .firstTextBaseline) { + Toggle(isOn: Binding( + get: { self.settings.quotaWarningWindowEnabled(window) }, + set: { self.settings.setQuotaWarningWindowEnabled(window, enabled: $0) })) + { + Text(window.localizedCapitalizedDisplayName) + .font(.footnote.weight(.semibold)) + .fixedSize(horizontal: true, vertical: false) + } + .toggleStyle(.checkbox) + .gridColumnAlignment(.leading) + + QuotaWarningThresholdField( + title: "", + subtitle: "", + accessibilityContext: window.localizedCapitalizedDisplayName, + thresholds: { self.settings.quotaWarningThresholds(window) }, + setThresholds: { self.settings.setQuotaWarningThresholds(window, thresholds: $0) }) + .disabled(!self.settings.quotaWarningWindowEnabled(window)) + .opacity(self.settings.quotaWarningWindowEnabled(window) ? 1 : 0.45) + .gridColumnAlignment(.leading) + } + } +} + @MainActor private struct QuotaWarningThresholdField: View { + private static let defaultFieldWidth: CGFloat = 44 + let title: String let subtitle: String + var accessibilityContext: String = "" + var shouldCommitOnDisappear: () -> Bool = { true } let thresholds: () -> [Int] let setThresholds: ([Int]) -> Void + var fieldWidth: CGFloat = Self.defaultFieldWidth + var controlFont: Font = .footnote + var titleFont: Font = .footnote.weight(.semibold) - @State private var upperText: String = "" - @State private var lowerText: String = "" + @State private var draft = QuotaWarningThresholdEditorText.Draft() + @FocusState private var focusedField: QuotaWarningThresholdEditorText.Field? var body: some View { - VStack(alignment: .leading, spacing: 6) { - HStack(alignment: .firstTextBaseline, spacing: 10) { - Text(self.title) - .font(.footnote.weight(.semibold)) - .frame(width: 110, alignment: .leading) - - Text(L("quota_warning_upper")) - .font(.footnote) - .foregroundStyle(.secondary) - - TextField("50", text: self.$upperText) - .textFieldStyle(.roundedBorder) - .font(.footnote) - .frame(width: 56) - .onChange(of: self.upperText) { _, value in - self.upperText = Self.filteredIntegerText(value) - } - .onSubmit { self.commit() } - - Text(L("quota_warning_lower")) - .font(.footnote) - .foregroundStyle(.secondary) - - TextField("20", text: self.$lowerText) - .textFieldStyle(.roundedBorder) - .font(.footnote) - .frame(width: 56) - .onChange(of: self.lowerText) { _, value in - self.lowerText = Self.filteredIntegerText(value) - } - .onSubmit { self.commit() } - - Button(L("apply")) { self.commit() } - .controlSize(.small) - } + VStack(alignment: .leading, spacing: 7) { + self.horizontalEditor if !self.subtitle.isEmpty { Text(self.subtitle) @@ -217,36 +313,300 @@ private struct QuotaWarningThresholdField: View { } } .onAppear { self.updateText(from: self.thresholds()) } + .onChange(of: self.focusedField) { previous, current in + if previous != nil, current == nil { + self.commit() + } + } .onChange(of: self.thresholds()) { _, value in - self.updateText(from: value) + if self.focusedField == nil { + self.updateText(from: value) + } } + .onDisappear { + if self.shouldCommitOnDisappear() { + self.commit() + } + } + .background(self.focusMonitor) + } + + private var horizontalEditor: some View { + HStack(alignment: .firstTextBaseline, spacing: 10) { + self.titleView + + self.upperField + self.lowerField + } + .fixedSize(horizontal: true, vertical: false) + } + + @ViewBuilder + private var titleView: some View { + if !self.title.isEmpty { + Text(self.title) + .font(self.titleFont) + .frame(width: 110, alignment: .leading) + } + } + + private var upperField: some View { + self.thresholdInput( + label: L("quota_warning_warning"), + placeholder: "50", + text: self.thresholdTextBinding(.upper), + field: .upper) + } + + private var lowerField: some View { + self.thresholdInput( + label: L("quota_warning_critical"), + placeholder: "20", + text: self.thresholdTextBinding(.lower), + field: .lower) + } + + private func thresholdInput( + label: String, + placeholder: String, + text: Binding, + field: QuotaWarningThresholdEditorText.Field) -> some View + { + HStack(alignment: .firstTextBaseline, spacing: 5) { + Text(label) + .font(self.controlFont) + .foregroundStyle(.secondary) + + TextField(label, text: text, prompt: Text(verbatim: placeholder)) + .labelsHidden() + .textFieldStyle(.roundedBorder) + .font(self.controlFont) + .multilineTextAlignment(.trailing) + .frame(width: self.fieldWidth) + .focused(self.$focusedField, equals: field) + .onSubmit { + self.commit() + self.focusedField = nil + } + .accessibilityLabel(Text(self.accessibilityLabel(for: label))) + + Text(verbatim: "%") + .font(self.controlFont) + .foregroundStyle(.secondary) + } + } + + private func thresholdTextBinding(_ field: QuotaWarningThresholdEditorText.Field) -> Binding { + Binding( + get: { self.draft.text(for: field) }, + set: { self.draft.setText($0, for: field) }) } private func commit() { - let sanitized = QuotaWarningThresholds.resolved( - upper: Self.integer(from: self.upperText), - lower: Self.integer(from: self.lowerText)) - self.updateText(from: sanitized) + guard let sanitized = self.draft.takeResolvedThresholds() else { return } self.setThresholds(sanitized) + self.updateText(from: sanitized) } private func updateText(from thresholds: [Int]) { - let pair = Self.pair(from: thresholds) - self.upperText = pair.upper.map(String.init) ?? "" - self.lowerText = pair.lower.map(String.init) ?? "" + self.draft.update(from: thresholds) + } + + private func accessibilityLabel(for label: String) -> String { + let context = self.title.isEmpty ? self.accessibilityContext : self.title + guard !context.isEmpty else { return label } + return "\(context), \(label)" } - private static func pair(from thresholds: [Int]) -> (upper: Int?, lower: Int?) { + @ViewBuilder + private var focusMonitor: some View { + #if os(macOS) + QuotaWarningFocusMonitor(isActive: self.focusedField != nil) { + NSApplication.shared.keyWindow?.makeFirstResponder(nil) + self.focusedField = nil + } + #else + EmptyView() + #endif + } +} + +#if os(macOS) +private struct QuotaWarningFocusMonitor: NSViewRepresentable { + let isActive: Bool + let onOutsideClick: () -> Void + + func makeNSView(context: Context) -> QuotaWarningFocusMonitorView { + let view = QuotaWarningFocusMonitorView() + view.isActive = self.isActive + view.onOutsideClick = self.onOutsideClick + return view + } + + func updateNSView(_ nsView: QuotaWarningFocusMonitorView, context: Context) { + nsView.isActive = self.isActive + nsView.onOutsideClick = self.onOutsideClick + } + + static func dismantleNSView(_ nsView: QuotaWarningFocusMonitorView, coordinator: ()) { + nsView.invalidate() + } +} + +private final class QuotaWarningFocusMonitorView: NSView { + var onOutsideClick: (() -> Void)? + var isActive: Bool = false { + didSet { self.updateMonitor() } + } + + private var monitor: Any? + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + self.wantsLayer = false + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func invalidate() { + self.isActive = false + self.onOutsideClick = nil + } + + private func updateMonitor() { + if self.isActive { + self.installMonitor() + } else { + self.removeMonitor() + } + } + + private func installMonitor() { + guard self.monitor == nil else { return } + self.monitor = NSEvent.addLocalMonitorForEvents(matching: .leftMouseDown) { [weak self] event in + self?.handle(event) + return event + } + } + + private func removeMonitor() { + if let monitor { + NSEvent.removeMonitor(monitor) + self.monitor = nil + } + } + + private func handle(_ event: NSEvent) { + guard self.isActive else { return } + guard let window = self.window, event.window === window else { return } + + let location = self.convert(event.locationInWindow, from: nil) + guard !self.bounds.contains(location) else { return } + guard !Self.eventHitsTextInput(event) else { return } + + DispatchQueue.main.async { [weak self] in + self?.onOutsideClick?() + } + } + + private static func eventHitsTextInput(_ event: NSEvent) -> Bool { + guard let contentView = event.window?.contentView else { return false } + let location = contentView.convert(event.locationInWindow, from: nil) + guard let hitView = contentView.hitTest(location) else { return false } + return hitView.hasAncestor(of: NSTextField.self) || hitView.hasAncestor(of: NSTextView.self) + } +} + +extension NSView { + fileprivate func hasAncestor(of type: T.Type) -> Bool { + var view: NSView? = self + while let current = view { + if current is T { + return true + } + view = current.superview + } + return false + } +} +#endif + +enum QuotaWarningThresholdEditorText { + enum Field: Hashable { + case upper + case lower + } + + struct Draft { + private(set) var upperText: String + private(set) var lowerText: String + private let initialUpperText: String + private let initialLowerText: String + + var isDirty: Bool { + self.upperText != self.initialUpperText || self.lowerText != self.initialLowerText + } + + init(thresholds: [Int] = QuotaWarningThresholds.defaults) { + let pair = QuotaWarningThresholdEditorText.displayText(from: thresholds) + let upperText = pair.upper.map(String.init) ?? "" + let lowerText = pair.lower.map(String.init) ?? "" + self.upperText = upperText + self.lowerText = lowerText + self.initialUpperText = upperText + self.initialLowerText = lowerText + } + + func text(for field: Field) -> String { + switch field { + case .upper: self.upperText + case .lower: self.lowerText + } + } + + mutating func setText(_ value: String, for field: Field) { + let filtered = QuotaWarningThresholdEditorText.filteredIntegerText(value) + guard self.text(for: field) != filtered else { return } + switch field { + case .upper: self.upperText = filtered + case .lower: self.lowerText = filtered + } + } + + mutating func update(from thresholds: [Int]) { + self = Draft(thresholds: thresholds) + } + + mutating func takeResolvedThresholds() -> [Int]? { + guard self.isDirty else { return nil } + let thresholds = QuotaWarningThresholdEditorText.resolvedThresholds( + upperText: self.upperText, + lowerText: self.lowerText) + self.update(from: thresholds) + return thresholds + } + } + + static func displayText(from thresholds: [Int]) -> (upper: Int?, lower: Int?) { let sanitized = QuotaWarningThresholds.sanitized(thresholds) return (sanitized.first, sanitized.dropFirst().first) } - private static func integer(from text: String) -> Int? { - guard !text.isEmpty else { return nil } - return Int(text) + static func resolvedThresholds(upperText: String, lowerText: String) -> [Int] { + QuotaWarningThresholds.resolved( + upper: self.integer(from: upperText), + lower: self.integer(from: lowerText)) } - private static func filteredIntegerText(_ text: String) -> String { + static func filteredIntegerText(_ text: String) -> String { String(text.filter(\.isNumber).prefix(2)) } + + private static func integer(from text: String) -> Int? { + guard !text.isEmpty else { return nil } + return Int(text) + } } diff --git a/Sources/CodexBar/Resources/Icon-classic.icns b/Sources/CodexBar/Resources/Icon-classic.icns index 6ec346785f..1033b2c4bb 100644 Binary files a/Sources/CodexBar/Resources/Icon-classic.icns and b/Sources/CodexBar/Resources/Icon-classic.icns differ diff --git a/Sources/CodexBar/Resources/ProviderIcon-aiand.svg b/Sources/CodexBar/Resources/ProviderIcon-aiand.svg new file mode 100644 index 0000000000..68cba8283f --- /dev/null +++ b/Sources/CodexBar/Resources/ProviderIcon-aiand.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/Sources/CodexBar/Resources/ProviderIcon-chutes.svg b/Sources/CodexBar/Resources/ProviderIcon-chutes.svg new file mode 100644 index 0000000000..f30860b1a4 --- /dev/null +++ b/Sources/CodexBar/Resources/ProviderIcon-chutes.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/Sources/CodexBar/Resources/ProviderIcon-clawrouter.svg b/Sources/CodexBar/Resources/ProviderIcon-clawrouter.svg new file mode 100644 index 0000000000..f8718f87e9 --- /dev/null +++ b/Sources/CodexBar/Resources/ProviderIcon-clawrouter.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/Sources/CodexBar/Resources/ProviderIcon-clinepass.svg b/Sources/CodexBar/Resources/ProviderIcon-clinepass.svg new file mode 100644 index 0000000000..1ce7fe20be --- /dev/null +++ b/Sources/CodexBar/Resources/ProviderIcon-clinepass.svg @@ -0,0 +1,4 @@ + + + + diff --git a/Sources/CodexBar/Resources/ProviderIcon-deepinfra.svg b/Sources/CodexBar/Resources/ProviderIcon-deepinfra.svg new file mode 100644 index 0000000000..b181a117d8 --- /dev/null +++ b/Sources/CodexBar/Resources/ProviderIcon-deepinfra.svg @@ -0,0 +1,4 @@ + + DeepInfra + + diff --git a/Sources/CodexBar/Resources/ProviderIcon-devin.svg b/Sources/CodexBar/Resources/ProviderIcon-devin.svg new file mode 100644 index 0000000000..e2b1cd5f67 --- /dev/null +++ b/Sources/CodexBar/Resources/ProviderIcon-devin.svg @@ -0,0 +1,3 @@ + + + diff --git a/Sources/CodexBar/Resources/ProviderIcon-doubao.svg b/Sources/CodexBar/Resources/ProviderIcon-doubao.svg index 9c20430a1c..c5205ce6ff 100644 --- a/Sources/CodexBar/Resources/ProviderIcon-doubao.svg +++ b/Sources/CodexBar/Resources/ProviderIcon-doubao.svg @@ -1 +1,7 @@ -Doubao + + Doubao + + + + + diff --git a/Sources/CodexBar/Resources/ProviderIcon-litellm.svg b/Sources/CodexBar/Resources/ProviderIcon-litellm.svg new file mode 100644 index 0000000000..3a6c20b880 --- /dev/null +++ b/Sources/CodexBar/Resources/ProviderIcon-litellm.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/Sources/CodexBar/Resources/ProviderIcon-longcat.svg b/Sources/CodexBar/Resources/ProviderIcon-longcat.svg new file mode 100644 index 0000000000..dd1201c95e --- /dev/null +++ b/Sources/CodexBar/Resources/ProviderIcon-longcat.svg @@ -0,0 +1,4 @@ + + + + diff --git a/Sources/CodexBar/Resources/ProviderIcon-neuralwatt.svg b/Sources/CodexBar/Resources/ProviderIcon-neuralwatt.svg new file mode 100644 index 0000000000..cf43777aca --- /dev/null +++ b/Sources/CodexBar/Resources/ProviderIcon-neuralwatt.svg @@ -0,0 +1,3 @@ + + + diff --git a/Sources/CodexBar/Resources/ProviderIcon-ollama.svg b/Sources/CodexBar/Resources/ProviderIcon-ollama.svg index 23b80bc53d..92efd117e8 100644 --- a/Sources/CodexBar/Resources/ProviderIcon-ollama.svg +++ b/Sources/CodexBar/Resources/ProviderIcon-ollama.svg @@ -1,3 +1,7 @@ - - + + + + + + diff --git a/Sources/CodexBar/Resources/ProviderIcon-poe.svg b/Sources/CodexBar/Resources/ProviderIcon-poe.svg new file mode 100644 index 0000000000..5e654565f3 --- /dev/null +++ b/Sources/CodexBar/Resources/ProviderIcon-poe.svg @@ -0,0 +1 @@ +Poe diff --git a/Sources/CodexBar/Resources/ProviderIcon-qoder.svg b/Sources/CodexBar/Resources/ProviderIcon-qoder.svg new file mode 100644 index 0000000000..69c14e4268 --- /dev/null +++ b/Sources/CodexBar/Resources/ProviderIcon-qoder.svg @@ -0,0 +1,3 @@ + + + diff --git a/Sources/CodexBar/Resources/ProviderIcon-qwencloud.svg b/Sources/CodexBar/Resources/ProviderIcon-qwencloud.svg new file mode 100644 index 0000000000..2e8609e5de --- /dev/null +++ b/Sources/CodexBar/Resources/ProviderIcon-qwencloud.svg @@ -0,0 +1,4 @@ + + + + diff --git a/Sources/CodexBar/Resources/ProviderIcon-sakana.svg b/Sources/CodexBar/Resources/ProviderIcon-sakana.svg new file mode 100644 index 0000000000..5e199bb74f --- /dev/null +++ b/Sources/CodexBar/Resources/ProviderIcon-sakana.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/Sources/CodexBar/Resources/ProviderIcon-sub2api.svg b/Sources/CodexBar/Resources/ProviderIcon-sub2api.svg new file mode 100644 index 0000000000..c1f61af468 --- /dev/null +++ b/Sources/CodexBar/Resources/ProviderIcon-sub2api.svg @@ -0,0 +1,4 @@ + + + + diff --git a/Sources/CodexBar/Resources/ProviderIcon-wayfinder.svg b/Sources/CodexBar/Resources/ProviderIcon-wayfinder.svg new file mode 100644 index 0000000000..2d913546cd --- /dev/null +++ b/Sources/CodexBar/Resources/ProviderIcon-wayfinder.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/Sources/CodexBar/Resources/ProviderIcon-xai.svg b/Sources/CodexBar/Resources/ProviderIcon-xai.svg new file mode 100644 index 0000000000..f0a69128c4 --- /dev/null +++ b/Sources/CodexBar/Resources/ProviderIcon-xai.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/Sources/CodexBar/Resources/ProviderIcon-zed.svg b/Sources/CodexBar/Resources/ProviderIcon-zed.svg new file mode 100644 index 0000000000..fdb37112bc --- /dev/null +++ b/Sources/CodexBar/Resources/ProviderIcon-zed.svg @@ -0,0 +1,3 @@ + + + diff --git a/Sources/CodexBar/Resources/ProviderIcon-zenmux.svg b/Sources/CodexBar/Resources/ProviderIcon-zenmux.svg new file mode 100644 index 0000000000..3dc2a97c65 --- /dev/null +++ b/Sources/CodexBar/Resources/ProviderIcon-zenmux.svg @@ -0,0 +1,3 @@ + + + diff --git a/Sources/CodexBar/Resources/ProviderIcon-zoommate.svg b/Sources/CodexBar/Resources/ProviderIcon-zoommate.svg new file mode 100644 index 0000000000..03b027dd42 --- /dev/null +++ b/Sources/CodexBar/Resources/ProviderIcon-zoommate.svg @@ -0,0 +1 @@ + diff --git a/Sources/CodexBar/Resources/ar.lproj/Localizable.strings b/Sources/CodexBar/Resources/ar.lproj/Localizable.strings new file mode 100644 index 0000000000..4159afba30 --- /dev/null +++ b/Sources/CodexBar/Resources/ar.lproj/Localizable.strings @@ -0,0 +1,1357 @@ +/* Arabic localization for CodexBar */ + +"ollama_safari_cookie_access_hint" = "تحتاج ملفات تعريف ارتباط Safari إلى وصول كامل إلى القرص لتطبيق CodexBar (إعدادات النظام > الخصوصية والأمان)."; +"ollama_browser_cookie_decryption_denied" = "تم رفض فك تشفير ملفات تعريف ارتباط %@ في سلسلة المفاتيح؛ أعد المحاولة بتحديث يدوي."; +"ollama_browser_cookie_decryption_disabled" = "فك تشفير ملفات تعريف ارتباط %@ معطل في CodexBar؛ فعّل الوصول إلى سلسلة المفاتيح ثم حدّث."; + +" providers" = " providers"; +"(System)" = "(النظام)"; +"30d" = "30 يومًا"; +"7d" = "7 أيام"; +"A managed Codex login is already running. Wait for it to finish before adding " = "تسجيل دخول Codex المدار يعمل بالفعل. انتظر حتى ينتهي قبل إضافة "; +"API key" = "مفتاح API"; +"API region" = "منطقة API"; +"API token" = "API رمز"; +"API tokens" = "رموز API"; +"About" = "حول"; +"Account" = "الحساب"; +"Accounts" = "الحسابات"; +"Accounts subtitle" = "العنوان الفرعي للحسابات"; +"Active" = "نشط"; +"Add" = "إضافة"; +"Add Workspace" = "إضافة مساحة العمل"; +"Advanced" = "متقدمة"; +"All" = "الجميع"; +"Always allow prompts" = "دائما اسمح بالمحفزات"; +"Animation pattern" = "نمط الرسوم المتحركة"; +"Antigravity login is managed in the app" = "يتم إدارة Antigravity تسجيل الدخول في التطبيق"; +"Applies only to the Security.framework OAuth keychain reader." = "ينطبق فقط على قارئ سلسلة مفاتيح Security.framework OAuth."; +"Alternatively, set a custom path in Settings." = "بدلاً من ذلك، عيّن مسارًا مخصصًا في الإعدادات."; +"Auto falls back to the next source if the preferred one fails." = "التلقائي يعود إلى المصدر التالي إذا فشل المصدر المفضل."; +"Auto uses API first, then falls back to CLI on auth failures." = "يستخدم التلقائي API أولا، ثم يعود إلى CLI عند فشل التصديق."; +"Auto-detect" = "الكشف التلقائي"; +"Auto-refresh is off; use the menu's Refresh command." = "التحديث التلقائي مغلق؛ استخدم أمر التحديث في القائمة."; +"Auto-refresh: hourly · Timeout: 10m" = "التحديث التلقائي: كل ساعة · المهلة: 10m"; +"Automatic" = "أوتوماتيكي"; +"Automatic imports browser cookies and WorkOS tokens." = "يقوم الاستيراد التلقائي بملفات تعريف الارتباط ورموز WorkOS في المصفح."; +"Automatic imports browser cookies and local storage tokens." = "يقوم باستيراد ملفات تعريف الارتباط في المتصفح ورموز التخزين المحلية تلقائيا."; +"Automatic imports browser cookies for dashboard extras." = "يقوم باستيراد ملفات تعريف الارتباط تلقائيا للمتصفح للحصول على إضافات في لوحة التحكم."; +"Automatic imports browser cookies for the web API." = "يقوم الاستيراد التلقائي بملفات تعريف الارتباط للمتصفح لخدمة الويب API."; +"Automatic imports browser cookies from Model Studio/Bailian." = "يقوم الاستيراد التلقائي بملفات تعريف الارتباط في المتصفح من Model Studio/Bailian."; +"Automatic imports browser cookies from admin.mistral.ai." = "يقوم الاستيراد التلقائي بملفات تعريف الارتباط من admin.mistral.ai."; +"Automatic imports browser cookies from opencode.ai." = "يقوم الكوكيز تلقائيا باستيراد ملفات تعريف الارتباط من opencode.ai."; +"Automatic imports browser cookies or stored sessions." = "يقوم الاستيراد التلقائي بملفات تعريف الارتباط أو الجلسات المخزنة في المتصفح تلقائيا."; +"Automatic imports browser cookies." = "يقوم الاستيراد التلقائي بملفات تعريف الارتباط من المتصفح."; +"Automatically imports browser session cookie." = "يقوم تلقائيا باستيراد كوكي جلسة المتصفح."; +"Automatically opens CodexBar when you start your Mac." = "يفتح تلقائيا CodexBar عند تشغيل جهاز الماك."; +"Automation" = "الأتمتة"; +"Average (\\(label1) + \\(label2))" = "المتوسط (\\(label1) + \\(label2))"; +"Average (\\(metadata.sessionLabel) + \\(metadata.weeklyLabel))" = "المتوسط (\\(metadata.sessionLabel) + \\(metadata.weeklyLabel))"; +"Avoid Keychain prompts" = "تجنب Keychain الأسئلة"; +"Balance" = "التوازن"; +"Battery Saver" = "منقذ البطارية"; +"Bordered" = "الحدود"; +"Build" = "البناء"; +"Built \\(buildTimestamp)" = "بنيت \\(buildTimestamp)"; +"Buy Credits..." = "اشتر الاعتمادات..."; +"Buy Credits…" = "اشتر الاعتمادات..."; +"CLI paths" = "CLI المسارات"; +"CLI sessions" = "جلسات CLI"; +"Caches" = "التخزين المؤقت"; +"Cancel" = "إلغاء"; +"Check for Updates…" = "تحقق من التحديثات..."; +"Check for updates automatically" = "تحقق تلقائيا من التحديثات"; +"Check if you like your agents having some fun up there." = "تحقق إذا كنت تحب وكلائنك يستمتعون هناك."; +"Check provider status" = "تحقق من حالة المزود"; +"Choose a supported browser so CodexBar can read the matching account." = "اختر متصفحًا مدعومًا حتى يتمكن CodexBar من قراءة الحساب المطابق."; +"Choose Codex workspace" = "اختر Codex مساحة العمل"; +"Choose Cursor account" = "اختر حساب Cursor"; +"Choose the MiniMax host (global .io or China mainland .com)." = "اختر المضيف MiniMax (.io العالمي أو .com البر الرئيسي الصيني)."; +"Choose up to " = "اختر حتى "; +"Choose up to \\(Self.maxOverviewProviders) providers" = "اختر حتى \\(Self.maxOverviewProviders) المزودين"; +"Choose up to \\(count) providers" = "اختر حتى \\(count) المزودين"; +"Choose what to show in the menu bar (Pace shows usage vs. expected)." = "اختر ما تريد عرضه في شريط القائمة (Pace يظهر الاستخدام مقابل المتوقع)."; +"Choose which Codex account CodexBar should follow." = "اختر أي حساب Codex يجب CodexBar اتباعه."; +"Choose which Cursor account CodexBar should use." = "اختر حساب Cursor الذي يجب أن يستخدمه CodexBar."; +"Choose which window drives the menu bar percent." = "اختر أي نافذة تحدد نسبة شريط القوائم."; +"Chrome" = "Chrome"; +"Claude CLI not found" = "Claude CLI لم يعثر عليه"; +"Claude binary" = "Claude الثنائية"; +"Claude cookies" = "Claude كوكيز"; +"Claude login failed" = "فشل تسجيل الدخول Claude"; +"Claude login timed out" = "انتهى وقت تسجيل الدخول Claude"; +"Close" = "إغلاق"; +"Code review" = "مراجعة الكود"; +"Codex CLI not found" = "Codex CLI لم يعثر عليه"; +"Codex account login already running" = "Codex تسجيل الدخول للحساب يعمل بالفعل"; +"Codex binary" = "Codex الثنائية"; +"Codex login failed" = "فشل تسجيل الدخول Codex"; +"Codex login timed out" = "انتهى وقت تسجيل الدخول Codex"; +"CodexBar Lifecycle Keepalive" = "CodexBar دورة الحياة في العيش"; +"CodexBar can't show its menu bar icon" = "لا يمكن CodexBar عرض أيقونة شريط القائمة"; +"CodexBar could not read managed account storage. " = "لم يكن CodexBar قادرا على قراءة تخزين الحساب المدار. "; +"Configure…" = "تكوين..."; +"Connected" = "متصل"; +"Controls how much detail is logged." = "يتحكم في كمية التفاصيل المسجلة."; +"Cookie header" = "رأس الكوكي"; +"Cookie source" = "مصدر الكوكيز"; +"Cookie: ..." = "كوكي: ..."; +"Cookie: \\u{2026}\\\n\\\nor paste a cURL capture from the Abacus AI dashboard" = "كوكي: \\u{2026}\\\n\\\nأو لصق التقاط cURL من لوحة Abacus AI"; +"Cookie: \\u{2026}\\\n\\\nor paste the __Secure-next-auth.session-token value" = "كوكي: \\u{2026}\\\n\\\nأو لصق قيمة __Secure-next-auth.session-token"; +"Cookie: \\u{2026}\\\n\\\nor paste the kimi-auth token value" = "كوكي: \\u{2026}\\\n\\\n أو لصق قيمة رمز kimi-authentic"; +"Cookie: …" = "كوكي: ..."; +"CopilotDeviceFlow" = "CopilotDeviceFlow"; +"Cost" = "التكلفة"; +"Could not add Codex account" = "لم أتمكن من إضافة Codex الحساب"; +"Could not open Terminal for Gemini" = "لم أتمكن من فتح الطرفية من Gemini"; +"Could not start claude /login" = "لم تستطع بدء كلود /login"; +"Could not start codex login" = "لم أتمكن من بدء تسجيل الدخول إلى الكودكس"; +"Could not switch system account" = "لم أتمكن من تغيير حساب النظام"; +"Credits" = "الاعتمادات"; +"Individual credits" = "الاعتمادات الفردية"; +"Workspace" = "مساحة العمل"; +"Credits history" = "تاريخ الاعتمادات"; +"Cursor login failed" = "فشل تسجيل الدخول Cursor"; +"Custom" = "العرف"; +"Custom Path" = "المسار المخصص"; +"Daily Routines" = "الروتين اليومي"; +"Debug" = "تصحيح الأخطاء"; +"Default" = "الافتراضي"; +"Disable Keychain access" = "تعطيل Keychain الوصول"; +"Disabled" = "معاق"; +"Dismiss" = "انصرف"; +"Disconnected" = "مفصل"; +"Display" = "العرض"; +"Display mode" = "وضع العرض"; +"Display reset times as absolute clock values instead of countdowns." = "عرض أوقات إعادة الضبط كقيم ساعة مطلقة بدلا من العد التنازلي."; +"Done" = "تم"; +"Effective PATH" = "PATH فعالة"; +"Email" = "البريد الإلكتروني"; +"Enable Merge Icons to configure Overview tab providers." = "تفعيل أيقونات الدمج لتكوين مزودي تبويب النظرة العامة."; +"Enable file logging" = "تمكين تسجيل الملفات"; +"Enabled" = "مفعل"; +"Error" = "خطأ"; +"Error simulation" = "محاكاة الخطأ"; +"Expose troubleshooting tools in the Debug tab." = "اعرض أدوات استكشاف الأخطاء في تبويب التصحيح."; +"Failed" = "فشل"; +"False" = "خطأ"; +"Fetch strategy attempts" = "محاولات استراتيجية الجلب"; +"Fetching" = "الجلب"; +"Field" = "الميدان"; +"Field subtitle" = "عنوان فرعي للميدان"; +"Finish the current managed account change before switching the system account." = "أكمل تغيير الحساب المدار الحالي قبل تغيير حساب النظام."; +"Force animation on next refresh" = "الرسوم المتحركة بالقوة في التحديث القادم"; +"Gateway region" = "منطقة البوابة"; +"Gemini CLI not found" = "Gemini CLI لم يعثر عليه"; +"Gemini/Antigravity, surfacing incidents in the icon and menu." = "Gemini/Antigravity، تظهر الحوادث في الأيقونة والقائمة."; +"General" = "عام"; +"GitHub" = "GitHub"; +"GitHub Copilot Login" = "GitHub Copilot تسجيل الدخول"; +"GitHub Login" = "GitHub تسجيل الدخول"; +"Hide details" = "إخفاء التفاصيل"; +"Hide personal information" = "إخفاء المعلومات الشخصية"; +"Historical tracking" = "التتبع التاريخي"; +"How often CodexBar polls providers in the background." = "كم مرة CodexBar استطلاعات في الخلفية."; +"Inactive" = "غير نشط"; +"Install CLI" = "تثبيت CLI"; +"Install the Claude CLI (npm i -g @anthropic-ai/claude-code) and try again." = "ثبت Claude CLI (npm i -g @anthropic-ai/claude-code) وجرب مرة أخرى."; +"Install the Codex CLI (npm i -g @openai/codex) and try again." = "ثبت Codex CLI (npm i -g @openai/codex) وجرب مرة أخرى."; +"Install the Gemini CLI (npm i -g @google/gemini-cli) and try again." = "ثبت Gemini CLI (npm i -g @google/gemini-cli) وجرب مرة أخرى."; +"Install a JetBrains IDE with AI Assistant enabled, then refresh CodexBar." = "ثبّت بيئة JetBrains IDE مع تفعيل AI Assistant، ثم حدّث CodexBar."; +"JetBrains AI is ready" = "JetBrains الذكاء الاصطناعي جاهز"; +"JetBrains IDE" = "JetBrains IDE"; +"Keep CLI sessions alive" = "حافظ على الجلسات CLI حية"; +"Keyboard shortcut" = "اختصار لوحة المفاتيح"; +"Keychain access" = "Keychain الوصول"; +"Keychain prompt policy" = "سياسة Keychain السريع."; +"Last \\(name) fetch failed:" = "آخر \\(name) فشل في الجلب:"; +"Last \\(self.store.metadata(for: self.provider).displayName) fetch failed:" = "فشل جلب Last \\(self.store.metadata(for: self.provider).displayName):"; +"Last attempt" = "المحاولة الأخيرة"; +"Link" = "رابط"; +"Loading animations" = "رسوم التحميل"; +"Loading…" = "جار التحميل..."; +"Local" = "محلي"; +"Logging" = "قطع الأشجار"; +"Login failed" = "فشل تسجيل الدخول"; +"Login shell PATH (startup capture)" = "PATH shell تسجيل الدخول (التقاط بدء التشغيل)"; +"Login timed out" = "انتهى وقت تسجيل الدخول"; +"MCP details" = "MCP التفاصيل"; +"Managed Codex accounts unavailable" = "الحسابات Codex المدارة غير متاحة"; +"Managed account storage is unreadable. Live account access is still available, " = "تخزين الحساب المدار غير مقروء. الوصول إلى الحساب الحي لا يزال متاحا "; +"Manual" = "الدليل"; +"May your tokens never run out—keep agent limits in view." = "عسى أن لا تنفد رموزك أبدا—حافظ على حدود الوكلاء في مرآه."; +"Menu bar" = "شريط القوائم"; +"Menu bar auto-shows the provider closest to its rate limit." = "شريط القوائم يعرض تلقائيا مزود الخدمة الأقرب إلى حد السعر."; +"Menu bar metric" = "مقياس شريط القائمة"; +"Menu bar shows percent" = "شريط القائمة يعرض النسبة المئوية"; +"Menu content" = "محتوى القائمة"; +"Merge Icons" = "أيقونات الدمج"; +"Never prompt" = "لم يكن هناك طلب أبدا"; +"No" = "لا"; +"No Codex accounts detected yet." = "لم يتم اكتشاف حسابات Codex حتى الآن."; +"No JetBrains IDE detected" = "لم يتم اكتشاف JetBrains IDE"; +"No cost history data." = "بيانات تاريخ مجانية."; +"No data available" = "لا توجد بيانات متاحة"; +"No data yet" = "لا توجد بيانات حتى الآن"; +"No enabled providers available for Overview." = "لا يوجد مزودون مفعلون متاحون للنظرة العامة."; +"No providers selected" = "لم يتم اختيار أي مقدمي خدمة"; +"No token accounts yet." = "لا توجد حسابات رمزية حتى الآن."; +"No usage breakdown data." = "لا توجد بيانات تفصيلية للاستخدام."; +"None" = "لا شيء"; +"Notifications" = "الإشعارات"; +"Notifies when the 5-hour session quota hits 0% and when it becomes " = "يرسل إشعارًا عندما تصل حصة جلسة الخمس ساعات إلى 0% وعندما تصبح "; +"OK" = "حسنا"; +"Obscure email addresses in the menu bar and menu UI." = "عناوين بريد إلكتروني غامضة في شريط القائمة وواجهة القائمة."; +"Off" = "انطلق"; +"Offline" = "غير متصل"; +"On" = "شغلوا"; +"Online" = "عبر الإنترنت"; +"Only on user action" = "فقط عند إجراء المستخدم"; +"Open" = "مفتوح"; +"Open API Keys" = "مفاتيح API المفتوحة"; +"Open Amp Settings" = "افتح إعدادات Amp"; +"Open Antigravity to sign in, then refresh CodexBar." = "افتح Antigravity لتسجيل الدخول، ثم قم بتحديث CodexBar."; +"Open Browser" = "المتصفح المفتوح"; +"Open Coding Plan" = "خطة الترميز المفتوحة"; +"Open Console" = "وحدة التحكم المفتوحة"; +"Open Dashboard" = "لوحة التحكم المفتوحة"; +"Open Mistral Admin" = "Open Mistral Admin"; +"Open Menu Bar Settings" = "إعدادات شريط القائمة المفتوح"; +"Open Ollama Settings" = "افتح إعدادات Ollama"; +"Open Terminal" = "المحطة المفتوحة"; +"Open Usage Page" = "صفحة الاستخدام المفتوحة"; +"Open Warp API Key Guide" = "دليل مفتاح Warp API المفتوح"; +"Open menu" = "قائمة مفتوحة"; +"Open token file" = "ملف الرمز المفتوح"; +"OpenAI cookies" = "OpenAI كوكيز"; +"OpenAI web extras" = "OpenAI إضافات الويب"; +"Option A" = "الخيار أ"; +"Option B" = "الخيار ب"; +"Optional override if workspace lookup fails." = "تجاوز اختياري إذا فشل البحث في مساحة العمل."; +"Options" = "الخيارات"; +"Override auto-detection with a custom IDE base path" = "تجاوز الكشف التلقائي باستخدام مسار IDE الأساسي المخصص"; +"Overview" = "نظرة عامة"; +"Overview rows always follow provider order." = "الصفوف العامة دائما تتبع ترتيب مقدم الخدمة."; +"Overview tab providers" = "نظرة عامة على مزودي تبويب"; +"Paste API key…" = "الصق API المفتاح..."; +"Paste API token…" = "الصق API الرمز..."; +"Paste key…" = "مفتاح لصق..."; +"Paste sessionKey or OAuth token…" = "الصق مفتاح الجلسة أو رمز OAuth..."; +"Paste the Cookie header from a request to admin.mistral.ai. " = "الصق رأس الكوكي من طلب إلى admin.mistral.ai. "; +"Paste token…" = "اللصق الرمز..."; +"Personal" = "شخصي"; +"Picker" = "بيكر"; +"Picker subtitle" = "عنوان فرعي لاختيار"; +"Placeholder" = "العنصر المؤقت"; +"Plan" = "الخطة"; +"Plan Usage" = "استخدام الخطة"; +"Play full-screen confetti when weekly usage resets." = "شغل ورق الورق الورقية بملء الشاشة عند إعادة ضبط الاستخدام الأسبوعي."; +"Polls OpenAI/Claude status pages and Google Workspace for " = "استطلاعات OpenAI/Claude صفحات الحالة ومساحة العمل Google ل "; +"Prevents any Keychain access while enabled." = "يمنع أي وصول Keychain أثناء التفعيل."; +"Primary (API key limit)" = "الحد الأساسي (API المفاتيح)"; +"Primary (\\(label))" = "الابتدائي (\\(label))"; +"Primary (\\(metadata.sessionLabel))" = "الابتدائي (\\(metadata.sessionLabel))"; +"Probe logs" = "سجلات المسبار"; +"Progress bars fill as you consume quota (instead of showing remaining)." = "تمتلئ أشرطة التقدم كلما استهلكت الحصة (بدلا من إظهار الحصة المتبقية)."; +"Provider" = "المزود"; +"Providers" = "مقدمو الخدمات"; +"Quit CodexBar" = "اترك CodexBar"; +"Random (default)" = "عشوائي (افتراضي)"; +"Reads local usage logs. Shows today + last 30 days cost in the menu." = "يقرأ سجلات الاستخدام المحلية. يعرض اليوم + نافذة التاريخ المحددة في القائمة."; +"Refresh" = "تحديث"; +"Refresh cadence" = "وتيرة التحديث"; +"Remote" = "البعيد"; +"Remove" = "إزالة"; +"Remove Codex account?" = "هل تحذف Codex الحساب؟"; +"Remove \\(account.email) from CodexBar? Its managed Codex home will be deleted." = "إزالة \\(account.email) من CodexBar؟ سيتم حذف Codex المنزل الذي يديره."; +"Remove \\(email) from CodexBar? Its managed Codex home will be deleted." = "إزالة \\(email) من CodexBar؟ سيتم حذف Codex المنزل الذي يديره."; +"Remove selected account" = "إزالة الحساب المحدد"; +"Replace critter bars with provider branding icons and a percentage." = "استبدل ألواح الحيوانات بأيقونات علامة مزودة ونسبة مئوية."; +"Replay selected animation" = "إعادة تشغيل الرسوم المتحركة المختارة"; +"Requires authentication via GitHub Device Flow." = "يتطلب المصادقة عبر تدفق الجهاز GitHub."; +"Resets: \\(reset)" = "إعادة الضبط: \\(reset)"; +"Rolling five-hour limit" = "الحد الأقصى المتجدد لخمس ساعات"; +"Search hourly" = "البحث بالساعة"; +"Secondary (\\(label))" = "الثانوية (\\(label))"; +"Secondary (\\(metadata.weeklyLabel))" = "الثانوية (\\(metadata.weeklyLabel))"; +"Select a provider" = "اختر مزودا"; +"Select the IDE to monitor" = "اختر IDE للمراقبة"; +"Session quota notifications" = "إشعارات حصص الجلسة"; +"Session tokens" = "رموز الجلسة"; +"provider_section_connection" = "الاتصال"; +"provider_section_menu_bar" = "شريط القوائم"; +"Show Codex Credits and Claude Extra usage sections in the menu." = "عرض أقسام Codex الاعتمادات و Claude الاستخدام الإضافي في القائمة."; +"Show Debug Settings" = "عرض إعدادات التصحيح"; +"Show all token accounts" = "عرض جميع حسابات الرموز"; +"Show cost summary" = "ملخص تكلفة العرض"; +"Show credits + extra usage" = "اعتمادات العرض + الاستخدام الإضافي"; +"Show details" = "تفاصيل العرض"; +"Show most-used provider" = "عرض المزود الأكثر استخداما"; +"Show provider icons in the switcher (otherwise show a weekly progress line)." = "عرض أيقونات المزودين في جهاز التبديل (وإلا اعرض خط تقدم أسبوعي)."; +"Show reset time as clock" = "عرض وقت إعادة التعيين كساعة"; +"Show usage as used" = "استخدام العرض كما هو مستخدم"; +"Sign in with Claude Code..." = "تسجيل الدخول باستخدام Claude Code..."; +"Sign in via button below" = "سجل الدخول عبر الزر أدناه"; +"Skip teardown between probes (debug-only)." = "تخطي التفكيك بين المجسات (تصحيح فقط)."; +"Stack token accounts in the menu (otherwise show an account switcher bar)." = "قم بتكديس حسابات الرموز في القائمة (وإلا اعرض شريط تبديل الحسابات)."; +"Start at Login" = "ابدأ عند تسجيل الدخول"; +"Status" = "الحالة"; +"Store Claude sessionKey cookies or OAuth access tokens." = "تخزين ملفات تعريف الارتباط الخاصة Claude sessionKey أو رموز الوصول OAuth."; +"Store multiple Abacus AI Cookie headers." = "احتفظ بعدة رؤوس Abacus AI كوكي."; +"Store multiple Augment Cookie headers." = "احتفظ بعدة رؤوس Augment كوكي."; +"Store multiple Cursor Cookie headers." = "احتفظ بعدة رؤوس Cursor كوكي."; +"Store multiple Factory Cookie headers." = "احتفظ بعدة رؤوس Factory كوكي."; +"Store multiple MiniMax Cookie headers." = "احتفظ بعدة رؤوس MiniMax كوكي."; +"Store multiple Mistral Cookie headers." = "احتفظ بعدة رؤوس Mistral كوكي."; +"Store multiple Ollama Cookie headers." = "احتفظ بعدة رؤوس Ollama كوكي."; +"Store multiple OpenCode Cookie headers." = "احتفظ بعدة رؤوس OpenCode كوكي."; +"Store multiple OpenCode Go Cookie headers." = "تخزين عدة رؤوس OpenCode Go Cookie."; +"Stored in the CodexBar config file." = "مخزنة في ملف الإعدادات CodexBar."; +"Stored in ~/.codexbar/config.json. " = "مخزنة في ~/.codexbar/config.json. "; +"Stored in ~/.codexbar/config.json. Paste the key from the Synthetic dashboard." = "مخزنة في ~/.codexbar/config.json. الصق المفتاح من لوحة Synthetic."; +"Stored in ~/.codexbar/config.json. Paste your Coding Plan API key from Model Studio." = "مخزن في ~/.codexbar/config.json. الصق مفتاح خطة البرمجة الخاصة بك API من Model Studio."; +"Stored in ~/.codexbar/config.json. Paste your MiniMax API key." = "مخزن في ~/.codexbar/config.json. الصق مفتاح MiniMax API الخاص بك."; +"Stored in ~/.codexbar/config.json. You can also provide KILO_API_KEY or " = "مخزنة في ~/.codexbar/config.json. يمكنك أيضا توفير KILO_API_KEY or "; +"Stores local Codex usage history (8 weeks) to personalize Pace predictions." = "يخزن تاريخ استخدام Codex محليا (8 أسابيع) لتخصيص توقعات Pac."; +"Surprise me" = "فاجئني"; +"Switcher shows icons" = "المحول يعرض الأيقونات"; +"Symlink CodexBarCLI to /usr/local/bin and /opt/homebrew/bin as codexbar." = "Symlink CodexBarCLI إلى /usr/local/bin و /opt/homebrew/bin ك codexbar."; +"System" = "النظام"; +"Temporarily shows the loading animation after the next refresh." = "يعرض مؤقتا حركة التحميل بعد التحديث التالي."; +"terminal_app_subtitle" = "الطرفية المستخدمة في إجراء الطرفية المفتوحة"; +"terminal_app_title" = "المحطة الافتراضية"; +"Tertiary (\\(label))" = "الثالثية (\\(label))"; +"Tertiary (\\(tertiaryTitle))" = "الثالثية (\\(tertiaryTitle))"; +"The default Codex account on this Mac." = "الحساب الافتراضي Codex على هذا الجهاز."; +"Toggle" = "التبديل"; +"Toggle subtitle" = "تبديل العنوان الفرعي"; +"Token" = "الرمز"; +"Trigger the menu bar menu from anywhere." = "فعل قائمة شريط القوائم من أي مكان."; +"True" = "صحيح"; +"Twitter" = "تويتر"; +"Unsupported" = "غير مدعوم"; +"Update Channel" = "قناة التحديث"; +"Updated" = "تحديث"; +"Updates unavailable in this build." = "التحديثات غير متوفرة في هذا الإصدار."; +"Usage" = "الاستخدام"; +"Usage breakdown" = "تفصيل الاستخدام"; +"Usage history (30 days)" = "تاريخ الاستخدام"; +"Usage source" = "مصدر الاستخدام"; +"Use Account" = "استخدام الحساب"; +"Use BigModel for the China mainland endpoints (open.bigmodel.cn)." = "استخدم BigModel لنقاط نهاية البر الرئيسي للصين (open.bigmodel.cn)."; +"Use a single menu bar icon with a provider switcher." = "استخدم أيقونة شريط قائمة واحدة مع محول المزود."; +"Use international or China mainland console gateways for quota fetches." = "استخدم بوابات الكونسول الدولية أو الصينية لجلب الحصص."; +"Version" = "النسخة"; +"Version \\(self.versionString)" = "النسخة \\(self.versionString)"; +"Version \\(version)" = "النسخة \\(version)"; +"Version \\(versionString)" = "النسخة \\(versionString)"; +"Vertex AI Login" = "Vertex AI تسجيل الدخول"; +"Wait for the current managed Codex login to finish before adding another account." = "انتظر حتى ينتهي تسجيل الدخول Codex المدار الحالي قبل إضافة حساب آخر."; +"Waiting for Authentication..." = "في انتظار المصادقة..."; +"Website" = "الموقع الإلكتروني"; +"Weekly limit confetti" = "قصاصات كونفيتي أسبوعية محدودة"; +"Weekly token limit" = "الحد الأسبوعي للرموز"; +"Weekly usage" = "الاستخدام الأسبوعي"; +"Weekly usage unavailable for this account." = "الاستخدام الأسبوعي غير متاح لهذا الحساب."; +"Window: \\(window)" = "النافذة: \\(window)"; +"Write logs to \\(self.fileLogPath) for debugging." = "اكتب السجلات إلى \\(self.fileLogPath) للتصحيح."; +"Yes" = "نعم"; +"\\(detail.modelCode): \\(usage)" = "\\(detail.modelCode): \\(usage)"; +"\\(name): \\(truncated)" = "\\(name): \\(truncated)"; +"\\(name): \\(updated) · 30d \\(cost)" = "\\(name): \\(updated) · 30d \\(cost)"; +"\\(name): fetching…\\(elapsed)" = "\\(name): رائع... \\(elapsed)"; +"\\(name): last attempt \\(when)" = "\\(name): المحاولة الأخيرة \\(when)"; +"\\(name): no data yet" = "\\(name): لا توجد بيانات حتى الآن"; +"\\(name): unsupported" = "\\(name): غير مدعوم"; +"all browsers" = "جميع المتصفحات"; +"available again." = "متاح مرة أخرى."; +"built_format" = "بنيت %@"; +"copilot_complete_in_browser" = "تسجيل الدخول الكامل في متصفحك."; +"copilot_device_code" = "رمز الجهاز المنسوخ إلى الحافظة: %1$@\n\nVerify على: %2$@"; +"copilot_device_code_copied" = "تم نسخ رمز الجهاز."; +"copilot_verify_at" = "تحقق على %@"; +"copilot_waiting_text" = "تسجيل الدخول الكامل في متصفحك. \nتغلق هذه النافذة تلقائيا عند اكتمال تسجيل الدخول."; +"copilot_window_closes_auto" = "تغلق هذه النافذة تلقائيا عند اكتمال تسجيل الدخول."; +"cost_status_error" = "%1$@: %2$@"; +"cost_status_fetching" = "%1$@: رائع... %2$@"; +"cost_status_last_attempt" = "%1$@: المحاولة الأخيرة %2$@"; +"cost_status_no_data" = "%@: لا توجد بيانات حتى الآن"; +"cost_status_snapshot" = "%1$@: %2$@ · %3$@ %4$@"; +"cost_status_unsupported" = "%@: غير مدعوم"; +"credits_remaining" = "الاعتمادات: %@"; +"cursor_on_demand" = "عند الطلب: %@"; +"cursor_on_demand_with_limit" = "عند الطلب: %1$@ / %2$@"; +"extra_usage_format" = "الاستخدام الإضافي: %1$@ / %2$@"; +"jetbrains_detected_generate" = "تم اكتشاف: %@. استخدم مساعد الذكاء الاصطناعي مرة واحدة لتوليد بيانات الحصص، ثم قم بتحديث CodexBar."; +"jetbrains_detected_select" = "تم اكتشاف: %@. اختر IDE المفضل لديك في الإعدادات، ثم قم بتحديث CodexBar."; +"last_fetch_failed_with_provider" = "آخر %@ فشل في الجلب:"; +"last_spend" = "آخر إنفاق: %@"; +"mcp_model_usage" = "%1$@: %2$@"; +"mcp_resets" = "إعادة الضبط: %@"; +"mcp_window" = "النافذة: %@"; +"metric_average" = "المتوسط (%1$@ + %2$@)"; +"metric_primary" = "الابتدائي (%@)"; +"metric_secondary" = "الثانوية (%@)"; +"metric_tertiary" = "الثالثية (%@)"; +"multiple_workspaces_found" = "CodexBar وجدت عدة مساحات عمل ل %@. يرجى اختيار مساحة العمل التي ستضيفها."; +"ory_session_…=…; csrftoken=…" = "ory_session_...=...; csrftoken=..."; +"overview_choose_providers" = "اختر حتى %@ المزودين"; +"remove_account_message" = "إزالة %@ من CodexBar؟ سيتم حذف Codex المنزل الذي يديره."; +"version_format" = "النسخة %@"; +"vertex_ai_login_instructions" = "لتتبع Vertex AI الاستخدام، قم بالتحقق باستخدام Google Cloud.\n\n1. افتح الطرفية\n2. تشغيل: gcloud مصادقة التطبيق - login\n3. اتبع تعليمات المتصفح لتوقيع in\n4. هل تضبط مشروعك: gcloud config set project PROJECT_ID\n\nOpen الآن؟"; +"workspaceID is set but only opencode, opencodego, and deepgram support workspaceID." = "يتم تعيين WorkspaceID لكن فقط opencode وopencodego وdeepgram يدعمون WorkspaceID."; +"© 2026 Peter Steinberger. MIT License." = "© 2026 بيتر ستاينبرجر. MIT الرخصة."; + +/* General Pane */ +"section_system" = "النظام"; +"section_usage" = "الاستخدام"; +"section_refreshing" = "التحديث"; +"section_alerts" = "التنبيهات"; +"section_celebrations" = "الاحتفالات"; +"section_icon" = "الأيقونة"; +"section_combined_icon" = "الأيقونة المدمجة"; +"section_animation" = "الحركة"; +"section_content" = "المحتوى"; +"section_agent_sessions" = "جلسات الوكلاء"; +"language_title" = "اللغة"; +"language_subtitle" = "غير لغة العرض. يتطلب إعادة تشغيل التطبيق ليكون مفعوله بالكامل."; +"currency_title" = "العملة المفضلة"; +"currency_subtitle" = "عملة تقديرات التكلفة ومقاييس الإنفاق. تستخدم أسعار صرف تُحدّث يوميًا."; +"currency_auto" = "تلقائي (حسب المزوّد / USD)"; +"language_system" = "النظام"; +"language_english" = "الإنجليزية"; +"language_spanish" = "الإسبانيول"; +"language_catalan" = "كاتالا"; +"language_chinese_simplified" = "简体中文"; +"language_chinese_traditional" = "繁體中文"; +"language_portuguese_brazilian" = "البرتغاليون (البرازيل)"; +"language_dutch" = "هولندا"; +"language_german" = "دويتش"; +"language_swedish" = "السويديا"; +"language_french" = "الفرنسية"; +"language_ukrainian" = "Українська"; +"language_russian" = "Русский"; +"language_japanese" = "ياباني"; +"language_korean" = "الكورية"; +"language_turkish" = "تركجه"; +"language_italian" = "إيطاليانو"; +"language_polish" = "بولسكي"; +"start_at_login_title" = "ابدأ عند تسجيل الدخول"; +"start_at_login_subtitle" = "يفتح تلقائيا CodexBar عند تشغيل جهاز الماك."; +"show_cost_summary_subtitle" = "يقرأ سجلات الاستخدام المحلية. يعرض اليوم + نافذة التاريخ المحددة في القائمة."; +"cost_summary_style_title" = "نمط العرض"; +"cost_summary_style_inline" = "مضمن فقط"; +"cost_summary_style_submenu" = "قائمة فرعية فقط"; +"cost_summary_style_both" = "كلاهما"; +"cost_summary_style_inline_help" = "يعرض ملخص التكلفة مباشرة في القائمة الرئيسية."; +"cost_summary_style_submenu_help" = "يعرض بدلاً من ذلك قائمة التكلفة الفرعية التفصيلية."; +"cost_summary_style_both_help" = "يعرض ملخص القائمة الرئيسية وقائمة التكلفة الفرعية التفصيلية."; +"cost_history_window_title" = "نافذة التاريخ"; +"cost_history_window_help" = "يحدد عدد أيام سجلات الاستخدام المحلية التي تظهر في القائمة."; +"cost_history_days_title" = "نافذة التاريخ: %d أيام"; +"cost_comparison_periods_title" = "إظهار فترات مقارنة أقصر"; +"cost_comparison_periods_subtitle" = "أضف إجماليات 7 و30 و90 يومًا عندما تقع ضمن نافذة التاريخ المحددة. تعيد هذه الإجماليات استخدام الفحص المحلي نفسه."; +"cost_auto_refresh_info" = "تحديث تلقائي: الفاصل الزمني العام (الحد الأدنى 5 دقائق) · المهلة: 10 دقائق"; +"refresh_interval_title" = "فاصل التحديث"; +"manual_refresh_hint" = "التحديث التلقائي مغلق؛ استخدم أمر التحديث في القائمة."; +"refresh_on_open_title" = "التحديث عند فتح القائمة"; +"refresh_on_open_subtitle" = "جلب أحدث بيانات الاستخدام لكل مزوّد في كل مرة تفتح فيها القائمة."; +"check_provider_status_title" = "تحقق من حالة المزود"; +"check_provider_status_subtitle" = "استطلاعات OpenAI/Claude صفحات الحالة و Google مساحة العمل Gemini/Antigravity، وتظهر الحوادث في الأيقونة والقائمة."; +"session_quota_notifications_subtitle" = "يرسل إشعارًا عندما تصل حصة جلسة الخمس ساعات إلى 0% وعندما تصبح متاحة مجددًا."; +"quota_depleted_title" = "نفاد الحصة واستعادتها"; +"quota_warning_notifications_subtitle" = "يحذر عندما يتجاوز الحصة المتبقية من الجلسة أو الحصة الأسبوعية العتبات المكونة."; +"threshold_warnings_title" = "تحذيرات العتبة"; +"quota_warnings_title" = "تحذيرات الحصص"; +"quota_warning_session" = "الجلسة"; +"quota_warning_session_capitalized" = "الجلسة"; +"quota_warning_weekly" = "أسبوعيا"; +"quota_warning_weekly_capitalized" = "الأسبوعي"; +"quota_warning_notification_title" = "انخفاض حصة %2$@ لدى %1$@"; +"quota_warning_notification_body" = "%1$@ متبقٍ. تم بلوغ حد التحذير البالغ %2$d%% لحصة %3$@."; +"quota_warning_notification_body_with_account" = "الحساب %1$@. متبقٍ %2$@. تم بلوغ حد التحذير البالغ %3$d%% لحصة %4$@."; +"predictive_pace_warnings_title" = "تحذيرات تنبؤية للوتيرة"; +"predictive_pace_warnings_subtitle" = "يُحذّر لـ Codex وClaude عندما قد تؤدي وتيرة الجلسة أو الأسبوع إلى نفاد الحصة قبل إعادة التعيين."; +"confetti_on_reset_title" = "قصاصات ورقية عند إعادة التعيين"; +"confetti_on_reset_subtitle" = "تشغيل قصاصات ورقية بملء الشاشة عند إعادة تعيين الاستخدام."; +"confetti_option_off" = "إيقاف"; +"confetti_option_session" = "إعادة تعيين الجلسة"; +"confetti_option_weekly" = "إعادة التعيين الأسبوعي"; +"confetti_option_both" = "كلاهما"; +"predictive_pace_warning_notification_title" = "%1$@: تحذير وتيرة %2$@"; +"predictive_pace_warning_notification_body" = "بالوتيرة الحالية، قد تنفد هذه الحصة خلال %1$@ قبل إعادة تعيينها."; +"predictive_pace_warning_notification_body_with_account" = "الحساب %1$@. بالوتيرة الحالية، قد تنفد هذه الحصة خلال %2$@ قبل إعادة تعيينها."; +"session_depleted_notification_title" = "جلسة %@ استنزفت"; +"session_depleted_notification_body" = "المتبقي 0%. سنبلغك عندما تصبح الحصة متاحة مجددًا."; +"session_restored_notification_title" = "%@ الجلسة التي استعادت"; +"session_restored_notification_body" = "حصة الجلسة متاحة مرة أخرى."; +"quota_warning_warn_at" = "التحذير في"; +"quota_warning_global_threshold_subtitle" = "النسب المتبقية للجلسات والفترات الأسبوعية ما لم يتجاوزها مقدم الخدمة."; +"quota_warning_sound" = "تشغيل صوت الإشعار"; +"quota_warning_onscreen_alert" = "إظهار تنبيه نصي على الشاشة"; +"quota_warning_provider_inherits" = "يستخدم إعدادات تحذير الحصص العامة إلا إذا تم تخصيص نافذة هنا."; +"quota_warning_provider_disabled" = "إشعارات تحذير الحصة وعلامات أشرطة الاستخدام معطّلة. فعّل أيًا منهما لتعديل هذه الإعدادات المحفوظة."; +"quota_warning_provider_markers_only" = "تم تعطيل إشعارات تحذير الحصة على مستوى التطبيق. لا تزال هذه الإعدادات تتحكم في علامات أشرطة الاستخدام."; +"quota_warning_global" = "عام"; +"quota_warning_customize_thresholds" = "تخصيص عتبات %@"; +"quota_warning_enable_warnings" = "تفعيل تحذيرات %@"; +"quota_warning_window_warn_at" = "%@ التحذير في"; +"quota_warning_off" = "انطلق"; +"quota_warning_inherited" = "الموروث: %@"; +"quota_warning_depleted_only" = "مستنزف فقط"; +"quota_warning_upper" = "أعلى"; +"quota_warning_lower" = "الأسفل"; +"quota_warning_warning" = "تحذير"; +"quota_warning_critical" = "حرج"; +"apply" = "قدم"; +"quit_app" = "إنهاء CodexBar"; + +/* Tab titles */ +"tab_general" = "عام"; +"tab_providers" = "مقدمو الخدمات"; +"tab_notifications" = "الإشعارات"; +"tab_menu_bar" = "شريط القوائم"; +"tab_menu" = "القائمة"; +"tab_advanced" = "متقدمة"; +"tab_hooks" = "الخطافات"; +"tab_about" = "حول"; + +/* Hooks Pane */ +"hooks_enable_title" = "تفعيل الخطافات"; +"hooks_enable_subtitle" = "تشغيل أوامر خارجية عند وقوع أحداث الحصة أو المزود."; +"hooks_trust_warning" = "يمكن للخطافات تنفيذ أوامر محلية على جهاز Mac. اضبط فقط الأوامر التي تثق بها."; +"hooks_rules_header" = "القواعد"; +"hooks_empty" = "لا توجد خطافات مُهيأة."; +"hooks_add_rule" = "إضافة قاعدة"; +"hooks_delete_rule" = "حذف القاعدة"; +"hooks_rule_enabled" = "مُفعّل"; +"hooks_event" = "الحدث"; +"hooks_provider" = "المزود"; +"hooks_any_provider" = "أي مزود"; +"hooks_threshold" = "التشغيل عند الاستخدام ≥"; +"hooks_threshold_placeholder" = "90"; +"hooks_executable_placeholder" = "/usr/local/bin/my-command"; +"hooks_arguments_placeholder" = "الوسائط"; +"hooks_argument_placeholder" = "الوسيطة"; +"hooks_add_argument" = "إضافة وسيطة"; +"hooks_delete_argument" = "حذف الوسيطة"; +"tab_debug" = "تصحيح الأخطاء"; + +/* Providers Pane */ +"select_a_provider" = "اختر مزودا"; +"cancel" = "إلغاء"; +"last_fetch_failed" = "فشل آخر جلب"; +"usage_not_fetched_yet" = "الاستخدام لم يتم استحضاره بعد"; +"managed_account_storage_unreadable" = "تخزين الحساب المدار غير مقروء. لا يزال الوصول إلى الحساب المباشر متاحا، لكن إجراءات الإضافة المدارة، وإعادة المصادقة، والإزالة يتم تعطيلها حتى يصبح المتجر قابلا للاسترداد."; +"remove_codex_account_title" = "هل تحذف Codex الحساب؟"; +"remove" = "إزالة"; +"managed_login_already_running" = "تسجيل دخول Codex المدار يعمل بالفعل. انتظر حتى ينتهي قبل إضافة أو إعادة المصادقة على حساب آخر."; +"managed_login_failed" = "لم يكتمل تسجيل الدخول Codex تدخله. تحقق من أن `codex --version` يعمل في الطرفية. إذا تم حظر macOS أو تم نقلها `codex` إلى سلة المهملات، قم بإزالة التثبيت المكررة القديمة، وشغل `npm install -g --include=optional @openai/codex@latest`، ثم جرب مرة أخرى."; +"codex_login_output" = "مخرج تسجيل الدخول إلى الكودكس:"; +"managed_login_missing_email" = "تم تسجيل Codex الدخول، لكن لم يكن هناك بريد إلكتروني للحساب متاح. حاول مرة أخرى بعد التأكد من تسجيل الدخول بالكامل."; +"login_success_notification_title" = "%@ تسجيل الدخول ناجحا"; +"login_success_notification_body" = "يمكنك العودة إلى التطبيق؛ انتهى التوثيق."; +"workspace_selection_cancelled" = "CodexBar وجدت عدة مساحات عمل، لكن لم يتم اختيار مساحة عمل."; +"unsafe_managed_home" = "رفض CodexBar تعديل مسار منزلي مدار غير متوقع: %@"; +"menu_bar_metric_title" = "مقياس شريط القائمة"; +"menu_bar_metric_subtitle" = "اختر أي نافذة تحدد نسبة شريط القوائم."; +"menu_bar_metric_subtitle_deepseek" = "يظهر توازن DeepSeek في شريط القوائم."; +"menu_bar_metric_subtitle_moonshot" = "يظهر توازن Moonshot / Kimi API في شريط القوائم."; +"menu_bar_metric_subtitle_mistral" = "يعرض الإنفاق Mistral API الشهري الحالي في شريط القوائم."; +"automatic" = "أوتوماتيكي"; +"primary_api_key_limit" = "الحد الأساسي (API المفاتيح)"; + +/* Display Pane */ +"menu_bar_style_title" = "نمط شريط القوائم"; +"menu_bar_style_subtitle" = "كيفية رسم عنصر شريط القوائم."; +"menu_bar_inactive_display_contrast_title" = "تحسين الوضوح على الشاشات غير النشطة"; +"menu_bar_usage_colors_title" = "استخدام ملوّن"; +"menu_bar_usage_colors_subtitle" = "تلوين أيقونة شريط القوائم من الأخضر إلى الأحمر مع ارتفاع الاستخدام."; +"menu_bar_inactive_display_contrast_subtitle" = "استخدم عرضًا عالي التباين لإبقاء الأيقونة والمقياس قابلين للقراءة على الشاشات الأخرى."; +"menu_bar_style_critters" = "الكائنات الصغيرة"; +"menu_bar_style_bars" = "أشرطة القياس"; +"menu_bar_style_icon_percent" = "الأيقونة والنسبة المئوية"; +"switcher_rows_title" = "صفوف المحوّل"; +"switcher_rows_icons" = "أيقونات المزودين"; +"switcher_rows_progress" = "التقدم الأسبوعي"; +"usage_bars_fill_title" = "تعبئة أشرطة الاستخدام"; +"usage_bars_fill_remaining" = "حسب المتبقي"; +"usage_bars_fill_used" = "حسب المستهلك"; +"reset_times_title" = "أوقات إعادة التعيين"; +"reset_times_countdown" = "العد التنازلي"; +"reset_times_clock" = "وقت الساعة"; +"cost_summary_title" = "ملخص التكلفة"; +"cost_summary_off" = "إيقاف"; +"merge_icons_title" = "أيقونات الدمج"; +"merge_icons_subtitle" = "استخدم أيقونة شريط قائمة واحدة مع محول المزود."; +"show_most_used_provider_title" = "عرض المزود الأكثر استخداما"; +"show_most_used_provider_subtitle" = "شريط القوائم يعرض تلقائيا مزود الخدمة الأقرب إلى حد السعر."; +"display_mode_title" = "وضع العرض"; +"display_mode_subtitle" = "اختر ما تريد عرضه في شريط القائمة (Pace يظهر الاستخدام مقابل المتوقع)."; +"show_quota_warning_markers_title" = "عرض علامات التحذير من الحصص"; +"show_quota_warning_markers_subtitle" = "ارسم علامات عتبة على أشرطة الاستخدام عند تكوين تحذيرات الحصص."; +"weekly_progress_work_days_title" = "أيام العمل الأسبوعية للتقدم"; +"weekly_progress_work_days_subtitle" = "حدد أيام عمل لمؤشرات شريط الاستخدام الأسبوعي وحسابات السرعة."; +"show_provider_changelog_links_title" = "اعرض روابط سجل التغييرات لمزود الخدمة"; +"show_provider_changelog_links_subtitle" = "يضيف روابط ملاحظات الإصدار لمزودي الخدمة المدعومين بدعم CLI إلى القائمة."; +"show_credits_extra_usage_title" = "اعتمادات العرض + الاستخدام الإضافي"; +"show_credits_extra_usage_subtitle" = "عرض أقسام Codex الاعتمادات و Claude الاستخدام الإضافي في القائمة."; +"multi_account_layout_title" = "تخطيط الحسابات المتعددة"; +"multi_account_layout_subtitle" = "اختر تبديل الحسابات المجزأة أو بطاقات الحساب المكدسة."; +"multi_account_layout_segmented" = "مقسم"; +"multi_account_layout_stacked" = "مكدس"; +"overview_tab_providers_title" = "نظرة عامة على مزودي تبويب"; +"configure" = "تكوين..."; +"overview_enable_merge_icons_hint" = "تفعيل أيقونات الدمج لتكوين مزودي تبويب النظرة العامة."; +"overview_no_providers_hint" = "لا يوجد مزودون مفعلون متاحون للنظرة العامة."; +"overview_rows_follow_order" = "الصفوف العامة دائما تتبع ترتيب مقدم الخدمة."; +"overview_no_providers_selected" = "لم يتم اختيار أي مقدمي خدمة"; +"agent_sessions_title" = "جلسات الوكلاء"; +"agent_sessions_subtitle" = "إظهار جلسات Codex وClaude Code المحلية والمكتشفة عبر SSH في القائمة."; +"agent_sessions_hosts_title" = "مضيفو SSH إضافيون"; +"agent_sessions_footer" = "يتم اكتشاف أجهزة Mac على شبكة tailnet تلقائيًا. يتم تحديث الجلسات المحلية كل 30 ثانية؛ والمضيفون البعيدون كل 60 ثانية وعند فتح القائمة."; +"agent_session_labels_title" = "تسميات الجلسات"; +"agent_session_labels_subtitle" = "اختر كيفية تسمية جلسات الوكلاء."; +"agent_session_label_project" = "المشروع"; +"agent_session_label_descriptive" = "وصفي"; +"agent_session_label_descriptive_and_project" = "وصفي + المشروع"; +"agent_session_unknown_project" = "مشروع غير معروف"; + +/* Advanced Pane */ +"section_keyboard_shortcut" = "اختصار لوحة المفاتيح"; +"open_menu_shortcut_title" = "قائمة مفتوحة"; +"open_menu_shortcut_subtitle" = "فعل قائمة شريط القوائم من أي مكان."; +"install_cli" = "تثبيت CLI"; +"install_cli_subtitle" = "Symlink CodexBarCLI إلى /usr/local/bin و /opt/homebrew/bin ك codexbar."; +"cli_not_found" = "CodexBarCLI غير موجود في حزمة التطبيقات."; +"no_writable_bin_dirs" = "لم يتم العثور على أي سجلات قابلة للكتابة."; +"show_debug_settings_title" = "عرض إعدادات التصحيح"; +"show_debug_settings_subtitle" = "اعرض أدوات استكشاف الأخطاء في تبويب التصحيح."; +"surprise_me_title" = "فاجئني"; +"surprise_me_subtitle" = "تحقق إذا كنت تحب وكلائنك يستمتعون هناك."; +"hide_personal_info_title" = "إخفاء المعلومات الشخصية"; +"hide_personal_info_subtitle" = "عناوين بريد إلكتروني غامضة في شريط القائمة وواجهة القائمة."; +"show_provider_storage_usage_title" = "عرض استخدام التخزين لمزود الخدمة"; +"show_provider_storage_usage_subtitle" = "عرض استخدام القرص المحلي في القوائم. يمسح المسارات المعروفة المملوكة لمزود الخدمة في الخلفية."; +"section_keychain_access" = "Keychain الوصول"; +"keychain_access_caption" = "قم بتعطيل جميع Keychain القراءة والكتابة. استخدم هذا إذا استمر macOS في طلب 'Chrome/Brave/Edge التخزين الآمن' حتى بعد الضغط على 'دائما السماح'. استيراد ملفات تعريف الارتباط من المتصفح غير متاح أثناء تفعيله؛ الصق رؤوس الكوكيز يدويا في المزودين. Claude/Codex OAuth عبر CLI لا يزال يعمل."; +"disable_keychain_access_title" = "تعطيل Keychain الوصول"; +"disable_keychain_access_subtitle" = "يمنع أي وصول Keychain أثناء التفعيل."; + +/* About Pane */ +"about_tagline" = "عسى أن لا تنفد رموزك أبدا—حافظ على حدود الوكلاء في مرآه."; +"link_github" = "GitHub"; +"link_website" = "الموقع الإلكتروني"; +"link_twitter" = "تويتر"; +"link_email" = "البريد الإلكتروني"; +"check_updates_auto" = "تحقق تلقائيا من التحديثات"; +"update_channel" = "قناة التحديث"; +"check_for_updates" = "تحقق من التحديثات..."; +"updates_unavailable" = "التحديثات غير متوفرة في هذا الإصدار."; +"copyright" = "© 2026 بيتر ستاينبرجر. MIT الرخصة."; + +/* Debug Pane */ +"section_logging" = "قطع الأشجار"; +"enable_file_logging" = "تمكين تسجيل الملفات"; +"enable_file_logging_subtitle" = "اكتب السجلات إلى %@ للتصحيح."; +"verbosity_title" = "التكرار"; +"verbosity_subtitle" = "يتحكم في كمية التفاصيل المسجلة."; +"open_log_file" = "ملف سجل مفتوح"; +"force_animation_next_refresh" = "الرسوم المتحركة بالقوة في التحديث القادم"; +"force_animation_next_refresh_subtitle" = "يعرض مؤقتا حركة التحميل بعد التحديث التالي."; +"section_loading_animations" = "رسوم التحميل"; +"loading_animations_caption" = "اختر نمطا وأعد تشغيله في شريط القوائم. \"عشوائي\" يحافظ على السلوك القائم."; +"animation_random_default" = "عشوائي (افتراضي)"; +"replay_selected_animation" = "إعادة تشغيل الرسوم المتحركة المختارة"; +"blink_now" = "ارمش الآن"; +"section_probe_logs" = "سجلات المسبار"; +"probe_logs_caption" = "جلب أحدث مخرجات المسبار للتصحيح؛ النسخة تحتفظ بالنص الكامل."; +"fetch_log" = "سجل الجلب"; +"copy" = "نسخة"; +"save_to_file" = "احفظ في الملف"; +"load_parse_dump" = "تحميل تحليل التفريغ"; +"rerun_provider_autodetect" = "إعادة تشغيل الكشف التلقائي عن مزود الخدمة"; +"loading" = "جار التحميل..."; +"no_log_yet_fetch" = "لا يوجد سجل بعد. أحضر للتحميل."; +"section_fetch_strategy" = "محاولات استراتيجية الجلب"; +"fetch_strategy_caption" = "قرارات وأخطاء خط الأنابيب الأخير للجلب للمزود."; +"section_openai_cookies" = "OpenAI كوكيز"; +"openai_cookies_caption" = "استيراد ملفات تعريف الارتباط + WebKit لجمع سجلات الكوكيز من آخر محاولة OpenAI للكوكيز."; +"no_log_yet" = "لا يوجد سجل بعد. قم بتحديث ملفات تعريف الارتباط OpenAI في → Codex المزودين لتشغيل استيراد."; +"section_caches" = "التخزين المؤقت"; +"caches_caption" = "امسح نتائج مسح التكلفة المخبأة أو ملفات تعريف الارتباط في المتصفح."; +"clear_cookie_cache" = "مسح ذاكرة الكوكيز"; +"clear_cost_cache" = "مسح ذاكرة التكلفة"; +"section_notifications" = "الإشعارات"; +"notifications_caption" = "تفعيل إشعارات الاختبار لنافذة الجلسة التي مدتها 5 ساعات (/restored مستنفد)."; +"post_depleted" = "استنزاف العمود"; +"post_restored" = "تم ترميم العمود"; +"section_cli_sessions" = "جلسات CLI"; +"cli_sessions_caption" = "حافظ على الجلسات Codex/Claude CLI بعد الاستعلام. الخروج الافتراضي بمجرد التقاط البيانات."; +"keep_cli_sessions_alive" = "حافظ على الجلسات CLI حية"; +"keep_cli_sessions_alive_subtitle" = "تخطي التفكيك بين المجسات (تصحيح فقط)."; +"reset_cli_sessions" = "إعادة تعيين CLI الجلسات"; +"section_error_simulation" = "محاكاة الخطأ"; +"error_simulation_caption" = "قم بإدخال رسالة خطأ مزيفة في بطاقة القائمة لاختبار التخطيط."; +"set_menu_error" = "خطأ في قائمة التعيين"; +"clear_menu_error" = "خطأ في إزالة القائمة"; +"set_cost_error" = "خطأ تكلفة التعيين"; +"clear_cost_error" = "خطأ واضح في التكلفة"; +"section_cli_paths" = "CLI المسارات"; +"cli_paths_caption" = "تم حل Codex الطبقتين الثنائية و PATH؛ تسجيل الدخول PATH الالتقاط لبدء التشغيل (مهلة قصيرة)."; +"codex_binary" = "Codex الثنائية"; +"claude_binary" = "Claude الثنائية"; +"effective_path" = "PATH فعالة"; +"unavailable" = "غير متوفر"; +"login_shell_path" = "PATH shell تسجيل الدخول (التقاط بدء التشغيل)"; +"cleared" = "تم الموافقة."; +"no_fetch_attempts" = "لم تحاول الجلب حتى الآن."; +"macOS Tahoe can block menu bar apps in System Settings → Menu Bar → Allow in the Menu Bar. CodexBar is running, but macOS may be hiding its icon. Open Menu Bar settings and turn CodexBar on." = "macOS Tahoe يمكنها حظر تطبيقات شريط القوائم في إعدادات النظام → شريط القوائم → السماح في شريط القوائم. CodexBar قيد التشغيل، لكن قد يكون macOS يخفي أيقونته. افتح إعدادات شريط القوائم وفعل CodexBar."; + +/* Metric preferences */ +"metric_pref_automatic" = "أوتوماتيكي"; +"metric_pref_primary" = "الابتدائي"; +"metric_pref_secondary" = "الثانوية"; +"metric_pref_tertiary" = "الدرجة الثالثة"; +"metric_pref_extra_usage" = "الاستخدام الإضافي"; +"metric_pref_average" = "المتوسط"; +"metric_mistral_payg" = "Pay-as-you-go"; +"metric_mistral_monthly_plan" = "Monthly Plan"; + +/* Display modes */ +"display_mode_percent" = "النسبة المئوية"; +"display_mode_pace" = "السرعة"; +"display_mode_both" = "كلاهما"; +"display_mode_reset_time" = "وقت إعادة الضبط"; +"display_mode_percent_desc" = "أظهر النسبة المتبقية /used (مثلا 45%)"; +"display_mode_pace_desc" = "مؤشر السرعة (مثلا +5%)"; +"display_mode_both_desc" = "أظهر كل من النسبة المئوية والسرعة (مثلا 45% · +5%)"; +"display_mode_reset_time_desc" = "عرض وقت إعادة الضبط للمقياس المحدد (مثلا ↻ 3:56 مساء)"; +"menu_bar_reset_when_exhausted_title" = "عرض وقت إعادة التعيين عند نفاد الحصة"; +"menu_bar_reset_when_exhausted_subtitle" = "عند تبقّي 0%، اعرض الوقت حتى إعادة التعيين بدلًا من النسبة المئوية"; + +/* Provider status */ +"status_operational" = "التشغيل"; +"status_degraded" = "أداء متدهور"; +"status_partial_outage" = "انقطاع جزئي"; +"status_major_outage" = "انقطاع كبير"; +"status_critical_issue" = "القضية الحرجة"; +"status_maintenance" = "الصيانة"; +"status_unknown" = "الحالة غير معروفة"; + +/* Refresh frequency */ +"refresh_manual" = "الدليل"; +"refresh_1min" = "دقيقة واحدة"; +"refresh_2min" = "دقيقتان"; +"refresh_5min" = "5 دقائق"; +"refresh_15min" = "15 دقيقة"; +"refresh_30min" = "30 دقيقة"; +"refresh_adaptive" = "تكيفي"; +"refresh_adaptive_agent_aware" = "تكيفي (مدرك لنشاط الوكيل)"; +"adaptive_activity_consent_title" = "السماح بالتحديث المستجيب للنشاط؟"; +"adaptive_activity_consent_message" = "يمكن لوضع التحديث التكيفي المدرك لنشاط الوكيل فحص قائمة العمليات المحلية قيد التشغيل، بما في ذلك أسطر الأوامر، للتعرّف على Codex وClaude، ثم قراءة بيانات تعريف الجلسات المعروفة كل 30 ثانية أثناء البرمجة. عند إيقاف Agent Sessions، لا يستخدم CodexBar سوى وقت أحدث نشاط في الذاكرة ويتجاهل مسارات الجلسات وهوياتها. لا تُرسل بيانات النشاط هذه إلى أي مكان، ويظل الاكتشاف عن بُعد وSSH متوقفين. إذا رفضت، فسيعود CodexBar إلى الوضع التكيفي العادي دون عمليات فحص النشاط المحلي."; +"adaptive_activity_consent_allow" = "السماح بالنشاط المحلي"; +"adaptive_activity_consent_decline" = "استخدام التحديث التكيفي العادي"; + +/* Additional keys */ +"not_found" = "لم يعثر عليه"; + +/* Cost estimation */ +"cost_estimate_hint" = "تقديرات من سجلات محلية · قد تختلف عن فاتورتك"; +"codex_api_estimate_hint" = "تقدير استنادًا إلى استخدام الرموز · ليست فاتورة اشتراك"; +"cost_data_explanation" = "قد تكون التكاليف واردة من المزوّد أو مقدّرة من استخدام الرموز وفق أسعار API العامة. التقديرات ليست رسوم اشتراك."; +"No JetBrains IDE with AI Assistant detected. Install a JetBrains IDE and enable AI Assistant." = "لم يتم اكتشاف أي JetBrains IDE مع مساعدة الذكاء الاصطناعي. قم بتثبيت JetBrains IDE وفعل AI Assistant."; +"OpenRouter API token not configured. Set OPENROUTER_API_KEY environment variable or configure in Settings." = "OpenRouter API الرمز غير مكون. حدد OPENROUTER_API_KEY متغير البيئة أو قم بالتكوين في الإعدادات."; +"z.ai API token not found. Set apiKey in ~/.codexbar/config.json or Z_AI_API_KEY." = "z.ai API الرمز لم يعثر عليه. اضبط apiKey في ~/.codexbar/config.json أو Z_AI_API_KEY."; +"Missing DeepSeek API key." = "مفتاح DeepSeek API مفقود."; +"%@ is unavailable in the current environment." = "%@ غير متاح في البيئة الحالية."; +"All Systems Operational" = "جميع الأنظمة تعمل"; +"Last 30 days" = "آخر 30 يوما"; +"Last 30 days:" = "آخر 30 يوما:"; +"This month" = "هذا الشهر"; +"Store multiple OpenAI API keys." = "خزن عدة مفاتيح OpenAI API."; +"Admin API key" = "مفتاح API الإدارة"; +"Open billing" = "الفوترة المفتوحة"; +"Google accounts" = "Google الروايات"; +"Store multiple Antigravity Google OAuth accounts for quick switching." = "تخزين عدة حسابات Antigravity Google OAuth للتبديل السريع."; +"Add Google Account" = "أضف Google الحساب"; +"Open Token Plan" = "خطة التوكن المفتوحة"; +"Text Generation" = "توليد النصوص"; +"Text to Speech" = "التحويل من النص إلى كلام"; +"Music Generation" = "توليد الموسيقى"; +"Image Generation" = "توليد الصور"; +"No local data found" = "لم يتم العثور على بيانات محلية"; +"Credits unavailable; keep Codex running to refresh." = "الاعتمادات غير متوفرة؛ استمر في Codex لتجديد النشاط."; +"No available fetch strategy for minimax." = "لا توجد استراتيجية جلب متاحة للمينيماكس."; +"No Cursor session found. Please log in to cursor.com in Safari, Chrome, Microsoft Edge, Brave, Arc, Dia, ChatGPT Atlas, Chromium, Helium, Vivaldi, Yandex Browser, Firefox, Zen, Colibri, Sidekick, Opera, Opera GX, or Edge Canary. If you use Safari, grant CodexBar Full Disk Access in System Settings ▸ Privacy & Security. You can also sign in to Cursor from the CodexBar menu (Add / switch account)." = "لم يتم العثور على جلسة Cursor. يرجى تسجيل الدخول إلى cursor.com في Safari، Chrome، مايكروسوفت إيدج، بريف، آرك، دييا، شات جي بي تي أطلس، كروميوم، هيليوم، فيفالدي، متصفح ياندكس، فايرفوكس، زين، كوليبري، سايدكيك، أوبرا، أوبرا GX، أو إيدج كاناري. إذا كنت تستخدم Safari، امنح CodexBar الوصول الكامل للقرص في إعدادات النظام ▸ الخصوصية والأمان. يمكنك أيضا تسجيل الدخول إلى Cursor من قائمة CodexBar (إضافة / تغيير الحساب)."; +"No OpenCode session cookies found in browsers." = "لا توجد ملفات تعريف الارتباط للجلسة OpenCode في المتصفحات."; +"No available fetch strategy for %@." = "لا توجد استراتيجية جلب متاحة %@."; +"Today" = "اليوم"; +"Today tokens" = "الرموز اليوم"; +"30d cost" = "تكلفة 30d"; +"%@ cost" = "تكلفة %@"; +"30d tokens" = "رموز 30d"; +"Latest tokens" = "أحدث الرموز"; +"Top model" = "أفضل موديل"; +"Storage" = "التخزين"; +"Add Account..." = "أضف حساب..."; +"Usage Dashboard" = "لوحة تحكم الاستخدام"; +"Status Page" = "صفحة الحالة"; +"Open Status Page" = "فتح صفحة الحالة"; +"Settings..." = "الإعدادات..."; +"About CodexBar" = "حول CodexBar"; +"Quit" = "استقال"; +"Last %d day" = "آخر %d يوم"; +"Last %d days" = "آخر %d أيام"; +"%@ tokens" = "رموز %@"; +"Latest billing day" = "آخر يوم فوترة"; +"Latest billing day (%@)" = "آخر يوم فوترة (%@)"; +"%@ left" = "%@ متبقٍ"; +"Resets %@" = "إعادة التعيين %@"; +"Resets in %@" = "إعادة التعيين في %@"; +"Resets now" = "إعادة التعيين الآن"; +"reset_tomorrow_format" = "غدًا، %@"; +"Lasts until reset" = "يستمر حتى إعادة التعيين"; +"1.5× headroom" = "هامش 1.5×"; +"Updated %@" = "تحديث %@"; +"Updated relative %@" = "تحديث %@"; +"Updated absolute %@" = "تحديث %@"; +"Updated %@h ago" = "تم التحديث %@h قبل"; +"Updated %@m ago" = "تم التحديث %@m قبل"; +"Updated just now" = "تم التحديث للتو"; +"Projected empty in %@" = "إسقاط فارغ في %@"; +"Runs out in %@" = "ينفد في %@"; +"Pace: %@" = "الوتيرة: %@"; +"Pace: %@ · %@" = "الوتيرة: %@ · %@"; +"%@ · %@" = "%@ · %@"; +"≈ %d%% run-out risk" = "≈ %d%% خطر الانتهاء"; +"%d%% in deficit" = "%d%% في العجز"; +"%d%% in reserve" = "%d%% في الاحتياط"; +"usage_percent_suffix_left" = "متبقٍ"; +"usage_percent_suffix_used" = "مستخدم"; +"Store multiple DeepSeek API keys." = "خزن عدة مفاتيح DeepSeek API."; +"This week" = "هذا الأسبوع"; +"Week" = "الأسبوع"; +"Month" = "الشهر"; +"Models" = "النماذج"; +"24h tokens" = "رموز 24h"; +"Latest hour" = "آخر ساعة"; +"Peak hour" = "ساعة الذروة"; +"Top method" = "الطريقة العليا"; +"30d cash" = "30d نقدا"; +"30d billing history from MiniMax web session" = "30d تاريخ الفوترة من جلسة الويب MiniMax"; +"AWS Cost Explorer billing can lag." = "AWS قد تتأخر فوترة Cost Explorer."; +"Rate limit: %d / %@" = "الحد الأقصى للسعر: %d / %@"; +"Key remaining" = "المفتاح المتبقي"; +"No limit set for the API key" = "لا يوجد حد محدد لمفتاح API"; +"API key limit unavailable right now" = "API حد المفتاح غير متوفر حاليا"; +"This month: %@ tokens" = "هذا الشهر: %@ الرموز"; +"No utilization data yet." = "لا توجد بيانات استخدام حتى الآن."; +"No %@ utilization data yet." = "لا توجد بيانات استخدام %@ حتى الآن."; +"%@: %@%% used" = "%@: %@%% مستخدمة"; +"%dd" = "%d يوم"; +"today" = "اليوم"; +"just now" = "الآن فقط"; +"On pace" = "على الوتيرة"; +"Runs out now" = "ينتهي العدد الآن"; +"Projected empty now" = "متوقعة فارغة الآن"; +"Switch Account..." = "تحويل الحساب..."; +"Update ready, restart now?" = "هل التحديث جاهز، هل أعد التشغيل الآن؟"; +"Daily" = "يوميا"; +"Hourly Tokens" = "الرموز بالساعة"; +"No data" = "لا توجد بيانات"; +"No usage breakdown data available." = "لا توجد بيانات تفصيلية للاستخدام متاحة."; + +"Today: %@ · %@ tokens" = "اليوم: %@ · رموز %@"; +"Today: %@" = "اليوم: %@"; +"Today: %@ tokens" = "اليوم: %@ الرموز"; +"Last 30 days: %@ · %@ tokens" = "آخر 30 يوما: %@ · رموز %@"; +"Last 30 days: %@" = "آخر 30 يوما: %@"; +"Est. total (30d): %@" = "التقدير الكلي (30d): %@"; +"Est. total (%@): %@" = "التقدير الكلي (%@): %@"; +"Hover a bar for details" = "مرر المؤشر على الشريط لمزيد من التفاصيل"; +"%@: %@ · %@ tokens" = "%@: %@ · رموز %@"; +"%@: %@" = "%@: %@"; +"No providers selected for Overview." = "لم يتم اختيار أي مقدمي خدمة للنظرة العامة."; +"No overview data available." = "لا توجد بيانات عامة متوفرة."; +"Auto uses the local IDE API first, then Google OAuth when the IDE is closed." = "يستخدم الأوتوماتيكي IDE API المحلي أولا، ثم Google OAuth عندما يكون IDE مغلقا."; +"Login with Google" = "تسجيل الدخول عبر Google"; + +/* Popup panels */ +"No usage configured." = "لا يوجد استخدام محدد."; +"Quota" = "الحصة"; +"Daily quota" = "الحصة اليومية"; +"Total" = "الإجمالي"; +"tokens" = "الرموز"; +"requests" = "الطلبات"; +"Latest" = "أحدث الإصدارات"; +"Monthly" = "شهريا"; +"Sonnet" = "السوناتة"; +"Overages" = "الزيادات"; +"Activity" = "النشاط"; +"Copied" = "تم النسخ"; +"Copy error" = "خطأ في النسخ."; +"Copy path" = "مسار النسخ"; +"Extra usage spent" = "الاستخدام الإضافي المخصص"; +"Credits remaining" = "الاعتمادات المتبقية"; +"Using CLI fallback" = "استخدام CLI الخطة الاحتياطية"; +"Balance updates in near-real time (up to 5 min lag)" = "تحديثات التوازن في الوقت شبه الحقيقي (حتى تأخير 5 دقائق)"; +"Daily billing data finalizes at 07:00 UTC" = "يتم الانتهاء من بيانات الفوترة اليومية في الساعة 07:00 UTC"; +"%@ of %@ credits left" = "%@ من %@ اعتمادات متبقية"; +"%@ of %@ bonus credits left" = "%@ من %@ رصيد إضافي متبقي"; +"%@ / %@ (%@ remaining)" = "%@ / %@ (%@ متبقية)"; +"%@/%@ left" = "%@/%@ متبقٍ"; +"Gemini Flash" = "Gemini فلاش"; +"Regenerates %@" = "يجدد %@"; +"used after next regen" = "تم استخدامه بعد التجديد التالي"; +"after next regen" = "بعد التجديد التالي"; +"Near full" = "شبه مكتمل"; +"Full in ~1 regen" = "تجدد كامل ~1"; +"Full in ~%.0f regens" = "جميع التحديثات ~%.0f"; +"Overage usage" = "الاستخدام الزائد"; +"Overage cost" = "تكلفة التجاوز"; +"credits" = "الاعتمادات"; +"Zen balance" = "توازن الزن"; +"API spend" = "API تنفق"; +"Extra usage" = "الاستخدام الإضافي"; +"Quota usage" = "استخدام الحصص"; +"Your spend" = "إنفاقك"; +"%.0f%% used" = "%.0f%% مستخدمة"; +"Usage history (today)" = "تاريخ الاستخدام (اليوم)"; +"Usage history (%d days)" = "تاريخ الاستخدام (%d أيام)"; +"%d percent remaining" = "%d بالمئة المتبقية"; +"Unknown" = "غير معروف"; +"stale data" = "بيانات قديمة"; +"No credits history data." = "لا توجد بيانات عن تاريخ الاعتمادات."; +"No credits history data available." = "لا توجد بيانات تاريخ الاعتمادات المتاحة."; +"Credits history chart" = "قائمة تاريخ الاعتمادات"; +"%d days of credits data" = "بيانات %d أيام الاعتمادات"; +"Usage breakdown chart" = "مخطط تحليل الاستخدام"; +"%d days of usage data across %d services" = "%d بيانات الاستخدام عبر خدمات %d"; +"Cost history chart" = "مخطط تاريخ التكاليف"; +"%d days of cost data" = "%d أيام بيانات التكلفة"; +"Plan utilization chart" = "مخطط استخدام الخطة"; +"%d utilization samples" = "%d عينات الاستخدام"; +"Hourly Usage" = "الاستخدام بالساعة"; +"Usage remaining" = "الاستخدام المتبقي"; +"Usage used" = "الاستخدام المستخدم"; +"API key verified. Cloud quotas need browser cookies. Sign in to Ollama." = "تم التحقق من مفتاح API. تتطلب حصص Cloud ملفات تعريف ارتباط المتصفح. سجّل الدخول إلى Ollama."; +"Last 30 days: %@ tokens" = "آخر 30 يوما: %@ الرموز"; +"7d spend" = "7d تنفق"; +"30d spend" = "30d تنفق"; +"Cache read" = "قراءة الذاكرة المؤقتة"; +"Claude Admin API 30 day spend trend" = "Claude API المسؤول اتجاه الإنفاق لمدة 30 يوما"; +"OpenRouter API key spend trend" = "OpenRouter API الاتجاه الرئيسي للإنفاق"; +"z.ai hourly token trend" = "z.ai اتجاه الرموز بالساعة"; +"MiniMax 30 day token usage trend" = "MiniMax اتجاه استخدام الرموز خلال 30 يوما"; +"Today cash" = "اليوم نقدا"; +"DeepSeek 30 day token usage trend" = "DeepSeek اتجاه استخدام الرموز خلال 30 يوما"; +"Detailed usage unavailable." = "الاستخدام التفصيلي غير متاح."; +"Sign in to DeepSeek Platform in Chrome for detailed usage." = "سجّل الدخول إلى منصة DeepSeek في Chrome لعرض الاستخدام التفصيلي."; +"Select a DeepSeek Chrome profile in Settings." = "حدد ملف تعريف Chrome لـ DeepSeek في الإعدادات."; +"DeepSeek this month token usage trend" = "اتجاه استخدام رموز DeepSeek لهذا الشهر"; +"Chrome profile" = "ملف تعريف Chrome"; +"Choose which signed-in DeepSeek Platform session supplies detailed usage." = "اختر جلسة DeepSeek Platform المسجّل دخولها التي توفّر تفاصيل الاستخدام."; +"Select profile…" = "اختر ملفًا شخصيًا…"; +"cache-hit input" = "إدخال الضربات المؤقتة"; +"cache-miss input" = "إدخال ذاكرة تخزين مؤقت (CACHE-miss)"; +"output" = "الإنتاج"; +"Requests" = "الطلبات"; +"Reported by OpenAI Admin API organization usage." = "تم الإبلاغ عنه من قبل OpenAI الإدارة API استخدام المنظمة."; +"Reported by Mistral billing usage." = "تم الإبلاغ عنه حسب استخدام Mistral الفوترة."; +"Google OAuth" = "Google OAuth"; +"Add accounts via GitHub OAuth Device Flow on the selected host." = "أضف حسابات عبر تدفق الجهاز GitHub OAuth على المضيف المحدد."; +"Stores each signed-in Google account for quick Antigravity switching. Uses Antigravity.app OAuth when available, or ANTIGRAVITY_OAUTH_CLIENT_ID and ANTIGRAVITY_OAUTH_CLIENT_SECRET as an override." = "يخزن كل حساب Google مسجلا للتبديل السريع Antigravity. يستخدم Antigravity.app OAuth عندما يتوفر البرنامج، أو ANTIGRAVITY_OAUTH_CLIENT_ID و ANTIGRAVITY_OAUTH_CLIENT_SECRET كوسيلة تجاوز."; +"Manual cleanup: past sessions" = "التنظيف اليدوي: الجلسات السابقة"; +"Clearing removes past resume, continue, and rewind history." = "إزالة الفحص القديم يحذف السيرة الذاتية السابقة، استمر، وإعادة التاريخ إلى الوراء."; +"Manual cleanup: file checkpoints" = "التنظيف اليدوي: نقاط تفتيش الملفات"; +"Clearing removes checkpoint restore data for previous edits." = "مسح البيانات يزيل بيانات استعادة نقاط التحقق من التعديلات السابقة."; +"Manual cleanup: saved plans" = "التنظيف اليدوي: الخطط المحفوظة"; +"Clearing removes old plan-mode files." = "إزالة الملفات القديمة في وضع التخطيط."; +"Manual cleanup: debug logs" = "التنظيف اليدوي: تصحيح السجلات"; +"Clearing removes past debug logs." = "المسح يزيل سجلات التصحيح السابقة."; +"Manual cleanup: attachment cache" = "التنظيف اليدوي: ذاكرة التخزين المؤقت للملحقات"; +"Clearing removes cached large pastes or attached images." = "إزالة المسح إزالة المعجون الكبيرة المخزنة أو الصور المرفقة."; +"Manual cleanup: session metadata" = "التنظيف اليدوي: بيانات الجلسة الوصفية"; +"Clearing removes per-session environment metadata." = "إزالة المسح يزيل بيانات وصفية البيئة لكل جلسة."; +"Manual cleanup: shell snapshots" = "التنظيف اليدوي: لقطات الغلاف"; +"Clearing removes leftover runtime shell snapshot files." = "إزالة الملفات المتبقية من ملفات لقطات الصدفة التشغيلية."; +"Manual cleanup: legacy todos" = "تنظيف يدوي: المهام القديمة"; +"Clearing removes legacy per-session task lists." = "إزالة المهمة تزيل قوائم المهام القديمة لكل جلسة."; +"Manual cleanup: sessions" = "التنظيف اليدوي: الجلسات"; +"Clearing removes past Codex session history." = "إزالة الفحص تحذف سجل الجلسة Codex السابق."; +"Manual cleanup: archived sessions" = "التنظيف اليدوي: الجلسات المؤرشفة"; +"Clearing removes archived Codex session history." = "المسح يزيل تاريخ الجلسة Codex المؤرشف."; +"Manual cleanup: cache" = "التنظيف اليدوي: ذاكرة تخزين مؤقت"; +"Clearing removes provider-owned cached data." = "إزالة المسح تزيل البيانات المخزنة مؤقتا المملوكة لمزود الخدمة."; +"Manual cleanup: logs" = "التنظيف اليدوي: السجلات"; +"Clearing removes local diagnostic logs." = "إزالة السجلات المحلية للتشخيص."; +"Manual cleanup: file history" = "تنظيف الدليل اليدوي: سجل الملفات"; +"Clearing removes local edit checkpoint history." = "المسح يزيل سجل نقاط التحرير المحلية."; +"Manual cleanup: temporary data" = "التنظيف اليدوي: بيانات مؤقتة"; +"Clearing removes local temporary provider data." = "المسح يزيل بيانات مقدم الخدمة المؤقت المحلي."; +"Total: %@" = "المجموع: %@"; +"%d more items" = "%d المزيد من العناصر"; +"Other (%d items)" = "أخرى (%d عناصر)"; +"Expand" = "توسيع"; +"Collapse" = "طيّ"; +"Cleanup ideas" = "أفكار التنظيف"; +"%d unreadable item(s) skipped" = "%d العناصر غير القابلة للقراءة تم تخطيها"; + +"API key limit" = "حد API المفتاح"; +"Auth" = "المصادقة"; +"Auto" = "أوتو"; +"Disabled — no recent data" = "معطلة — لا توجد بيانات حديثة"; +"Limits not available" = "الحدود غير المتاحة"; +"No usage yet" = "لم يستخدم حتى الآن"; +"Not fetched yet" = "لم يتم إحضاره بعد"; +"Refreshing" = "منعش"; +"Session" = "الجلسة"; +"Source" = "المصدر"; +"State" = "الدولة"; +"Unavailable" = "غير متوفر"; +"Weekly" = "الأسبوعي"; +"not detected" = "لم يتم اكتشافه"; +"Estimated from local Codex logs for the selected account." = "تم التقدير من سجلات Codex المحلية للحساب المختار."; +"minimax_usage_amount_format" = "الاستخدام: %@ / %@"; +"minimax_used_percent_format" = "المستخدم %@"; +"minimax_service_text_generation" = "توليد النصوص"; +"minimax_service_text_to_speech" = "التحويل من النص إلى كلام"; +"minimax_service_music_generation" = "توليد الموسيقى"; +"minimax_service_image_generation" = "توليد الصور"; +"minimax_service_lyrics_generation" = "توليد الكلمات"; +"minimax_service_coding_plan_vlm" = "خطة البرمجة VLM"; +"minimax_service_coding_plan_search" = "البحث عن خطة الترميز"; + +/* Additional provider settings and alerts */ +"%@ is waiting for permission" = "%@ ينتظر الإذن"; +"%@ requests" = "طلبات %@"; +"%@: %@ credits" = "%@: %@ اعتمادات"; +"30d requests" = "طلبات 30d"; +"4 days" = "4 أيام"; +"5 days" = "5 أيام"; +"7 days" = "7 أيام"; +"API key verifies Ollama Cloud access; cookies still expose quota limits." = "API المفتاح يتحقق Ollama الوصول إلى السحابة؛ لا تزال ملفات تعريف الارتباط تكشف عن حدود الحصص."; +"AWS access key ID. Can also be set with AWS_ACCESS_KEY_ID." = "AWS معرف مفتاح الوصول. يمكن أيضا ضبطها باستخدام AWS_ACCESS_KEY_ID."; +"AWS region. Can also be set with AWS_REGION." = "AWS المنطقة. يمكن أيضا ضبطها باستخدام AWS_REGION."; +"AWS secret access key. Can also be set with AWS_SECRET_ACCESS_KEY." = "AWS مفتاح وصول سري. يمكن أيضا ضبطها باستخدام AWS_SECRET_ACCESS_KEY."; +"Access key ID" = "معرف مفتاح الوصول"; +"Add Account" = "إضافة حساب"; +"Adding Account…" = "إضافة حساب..."; +"Antigravity login failed" = "فشل تسجيل الدخول Antigravity"; +"Antigravity login timed out" = "انتهى وقت تسجيل الدخول Antigravity"; +"Auth source" = "المصدر المعتمد"; +"Automatic imports browser cookies from Xiaomi MiMo." = "يقوم الكوكيز تلقائيا باستيراد ملفات تعريف الارتباط من Xiaomi MiMo."; +"Automatic imports Windsurf session data from Chromium browser localStorage." = "يقوم الاستيراد التلقائي Windsurf بيانات الجلسة من متصفح Chromium localStorage."; +"Automatic imports browser cookies from Bailian." = "يقوم الاستيراد التلقائي بملفات تعريف الارتباط من المتصفح من Bailian."; +"Automatically imports browser cookies." = "يقوم باستيراد ملفات تعريف الارتباط تلقائيا في المتصفح."; +"Automatically imports browser session cookies." = "يقوم تلقائيا باستيراد ملفات تعريف الارتباط لجلسات المتصفح."; +"Azure OpenAI deployment name. AZURE_OPENAI_DEPLOYMENT_NAME is also supported." = "اسم Azure OpenAI الانتشار. AZURE_OPENAI_DEPLOYMENT_NAME مدعوم أيضا."; +"Azure OpenAI key" = "مفتاح Azure OpenAI"; +"Azure OpenAI resource endpoint. AZURE_OPENAI_ENDPOINT is also supported." = "Azure OpenAI نقطة نهاية الموارد. AZURE_OPENAI_ENDPOINT مدعوم أيضا."; +"Base URL" = "URL القاعدة"; +"Base URL for the LLM-API-Key-Proxy instance." = "URL أساسي لنسخة LLM-API-Key-Proxy (الوكيل الرئيسي)."; +"Browser cookies" = "ملفات تعريف الارتباط في المتصفح"; +"Cap end" = "نهاية الغطاء"; +"Cap start" = "بداية الكابر"; +"Capacity End" = "نهاية السعة"; +"Capacity Start" = "بدء السعة"; +"Changelog" = "سجل التغييرات"; +"Choose the Moonshot/Kimi API host for international or China mainland accounts." = "اختر المضيف Moonshot/Kimi API للحسابات الدولية أو القارية الصينية."; +"CodexBar can't replace a system account that is signed in with an API key only setup." = "لا CodexBar استبدال حساب نظام مسجل الدخول بإعداد API فقط على المفتاح."; +"CodexBar could not find saved auth for that account. Re-authenticate it and try again." = "لم CodexBar العثور على مصحة محفوظة لهذا الحساب. أعد التحقق من صحتك وحاول مرة أخرى."; +"CodexBar could not read managed account storage. Recover the store before adding another account." = "لم يكن CodexBar قادرا على قراءة تخزين الحساب المدار. استرجع المتجر قبل إضافة حساب آخر."; +"CodexBar could not read saved auth for that account. Re-authenticate it and try again." = "لم CodexBar أستطيع قراءة المصادقة المحفوظة لهذا الحساب. أعد التحقق من صحتك وحاول مرة أخرى."; +"CodexBar could not read the current system account on this Mac." = "لم CodexBar أستطيع قراءة حساب النظام الحالي على هذا الجهاز."; +"CodexBar could not replace the live Codex auth on this Mac." = "لم CodexBar استطعت استبدال المصادقة الحية Codex على هذا الجهاز."; +"CodexBar could not safely preserve the current system account before switching." = "لم يكن بإمكان CodexBar الحفاظ على حساب النظام الحالي بأمان قبل التحويل."; +"CodexBar could not save the current system account before switching." = "لم يكن بإمكان CodexBar حفظ حساب النظام الحالي قبل التحويل."; +"CodexBar could not update managed account storage." = "لم يتمكن CodexBar تحديث تخزين الحساب المدار."; +"CodexBar found another managed account that already uses the current system account. Resolve the duplicate account before switching." = "CodexBar وجدت حسابا مديرا آخر يستخدم حساب النظام الحالي بالفعل. قم بحل مشكلة الحساب المكرر قبل التغيير."; +"CodexBar will ask macOS Keychain for “%@” so it can decrypt browser cookies and authenticate your account. Click OK to continue." = "سيطلب CodexBar macOS Keychain \"%@\" حتى يتمكن من فك تشفير ملفات تعريف الارتباط في المتصفح وتوثيق حسابك. انقر موافقا للمتابعة."; +"CodexBar will ask macOS Keychain for the Claude Code OAuth token so it can fetch your Claude usage. Click OK to continue." = "سيطلب CodexBar macOS Keychain رمز Claude OAuth ليتمكن من جلب استخدامك Claude. انقر موافقا للمتابعة."; +"CodexBar will ask macOS Keychain for your Amp cookie header so it can fetch usage. Click OK to continue." = "CodexBar سيطلب macOS Keychain رأس الكوكيز Amp الخاص بك حتى يتمكن من استخدام الاستخدام. انقر موافقا للمتابعة."; +"CodexBar will ask macOS Keychain for your Augment cookie header so it can fetch usage. Click OK to continue." = "CodexBar سيطلب macOS Keychain رأس الكوكيز Augment الخاص بك حتى يتمكن من استخدام الاستخدام. انقر موافقا للمتابعة."; +"CodexBar will ask macOS Keychain for your Claude cookie header so it can fetch Claude web usage. Click OK to continue." = "CodexBar ستطلب macOS Keychain رأس الكوكيز Claude الخاص بك حتى يتمكن من جلب Claude استخدام الويب. انقر موافقا للمتابعة."; +"CodexBar will ask macOS Keychain for your Cursor cookie header so it can fetch usage. Click OK to continue." = "CodexBar سيطلب macOS Keychain رأس الكوكيز Cursor الخاص بك حتى يتمكن من استخدام الاستخدام. انقر موافقا للمتابعة."; +"CodexBar will ask macOS Keychain for your Factory cookie header so it can fetch usage. Click OK to continue." = "CodexBar سيطلب macOS Keychain رأس الكوكيز Factory الخاص بك حتى يتمكن من استخدام الاستخدام. انقر موافقا للمتابعة."; +"CodexBar will ask macOS Keychain for your GitHub Copilot token so it can fetch usage. Click OK to continue." = "CodexBar سيطلب macOS Keychain رمز GitHub Copilot الخاص بك حتى يتمكن من استدعاء الاستخدام. انقر موافقا للمتابعة."; +"CodexBar will ask macOS Keychain for your Kimi auth token so it can fetch usage. Click OK to continue." = "CodexBar سيطلب macOS Keychain رمز التوثيق Kimi الخاص بك حتى يتمكن من استدعاء الاستخدام. انقر موافقا للمتابعة."; +"CodexBar will ask macOS Keychain for your MiniMax API token so it can fetch usage. Click OK to continue." = "CodexBar سيطلب macOS Keychain رمز MiniMax API الخاص بك حتى يتمكن من استدعاء الاستخدام. انقر موافقا للمتابعة."; +"CodexBar will ask macOS Keychain for your MiniMax cookie header so it can fetch usage. Click OK to continue." = "CodexBar سيطلب macOS Keychain رأس الكوكيز MiniMax الخاص بك حتى يتمكن من استخدام الاستخدام. انقر موافقا للمتابعة."; +"CodexBar will ask macOS Keychain for your OpenAI cookie header so it can fetch Codex dashboard extras. Click OK to continue." = "سيطلب CodexBar macOS Keychain رأس الكوكيز OpenAI الخاص بك حتى يتمكن من جلب إضافات Codex لوحة التحكم. انقر موافقا للمتابعة."; +"CodexBar will ask macOS Keychain for your OpenCode cookie header so it can fetch usage. Click OK to continue." = "CodexBar سيطلب macOS Keychain رأس الكوكيز OpenCode الخاص بك حتى يتمكن من استخدام الاستخدام. انقر موافقا للمتابعة."; +"CodexBar will ask macOS Keychain for your Synthetic API key so it can fetch usage. Click OK to continue." = "CodexBar ستطلب macOS Keychain مفتاح Synthetic API الخاص بك حتى يتمكن من جلب الاستخدام. انقر موافقا للمتابعة."; +"CodexBar will ask macOS Keychain for your z.ai API token so it can fetch usage. Click OK to continue." = "CodexBar سيطلب macOS Keychain رمز z.ai API الخاص بك حتى يتمكن من استدعاء الاستخدام. انقر موافقا للمتابعة."; +"Could not open Cursor login in your browser." = "لم أتمكن من فتح Cursor تسجيل الدخول في متصفحك."; +"Could not open browser for Antigravity" = "لم أتمكن من فتح المتصفح من Antigravity"; +"Credits used" = "الاعتمادات المستخدمة"; +"Day" = "اليوم"; +"Deployment" = "النشر"; +"Drag to reorder" = "سحب لإعادة ترتيب"; +"Sort providers alphabetically" = "فرز المزوّدين أبجديًا"; +"Sort providers alphabetically (enabled first)" = "فرز المزوّدين أبجديًا (المفعّلون أولاً)"; +"Sorted alphabetically (enabled first) — click to use your custom order" = "مرتّبة أبجديًا (المفعّلون أولاً) — انقر لاستخدام ترتيبك المخصّص"; +"Endpoint" = "نقطة النهاية"; +"Enterprise host" = "مضيف مؤسسي"; +"Extra usage balance: %@" = "توازن الاستخدام الإضافي: %@"; +"Keychain Access Required" = "Keychain الوصول المطلوب"; +"keychain_prompt_learn_more" = "معرفة المزيد…"; +"keychain_prompt_privacy_note" = "يتولى macOS، وليس CodexBar، إدخال كلمة سر تسجيل الدخول إلى Mac. يمكنك تعطيل وصول سلسلة المفاتيح في أي وقت من الإعدادات ← متقدم."; +"Kiro menu bar value" = "Kiro قيمة شريط القائمة"; +"Label" = "العلامة التجارية"; +"No organizations loaded. Click Refresh after setting your API key." = "لا توجد منظمات محملة. انقر على تحديث بعد تعيين مفتاح API."; +"No output captured." = "لم يتم التقاط أي مخرجات."; +"No system account" = "لا يوجد حساب نظام"; +"Oasis-Token" = "رمز الواحة"; +"Open Augment (Log Out & Back In)" = "فتح Augment (تسجيل الخروج والعودة للدخول)"; +"Open Codebuff Dashboard" = "لوحة تحكم مفتوحة Codebuff"; +"Open Command Code Settings" = "افتح إعدادات Command Code"; +"Open Crof dashboard" = "لوحة تحكم Open Crof"; +"Open Manus" = "فتح Manus"; +"Open MiMo Balance" = "توازن MiMo مفتوح"; +"Open Moonshot Console" = "وحدة التحكم المفتوحة Moonshot"; +"Open Ollama API Keys" = "مفاتيح Ollama API المفتوحة"; +"Open StepFun Platform" = "منصة StepFun المفتوحة"; +"Open T3 Chat Settings" = "افتح إعدادات T3 Chat"; +"Open Volcengine Ark Console" = "وحدة تحكم فولكموتور المفتوحة"; +"Open legacy provider docs" = "وثائق مزود الوراثة المفتوحة"; +"Open projects" = "المشاريع المفتوحة"; +"Open this URL manually to continue login:\n\n%@" = "افتح هذا URL يدويا لمتابعة تسجيل الدخول:\n\n%@"; +"Optional organization ID for accounts linked to multiple Anthropic organizations." = "معرف منظمة اختياري للحسابات المرتبطة بعدة منظمات أنثروبية."; +"Optional. Applies to the configured Admin API key; selected token accounts do not inherit OPENAI_PROJECT_ID." = "اختياري. ينطبق على مفتاح API المسؤول المكون؛ بعض الحسابات الرمزية لا ترث OPENAI_PROJECT_ID."; +"Optional. Enter your GitHub Enterprise host, for example octocorp.ghe.com. Leave blank for github.com." = "اختياري. هنا يأتي مضيف GitHub Enterprise، على سبيل المثال octocorp.ghe.com. اترك الفراغ github.com."; +"Optional. Leave blank to discover and aggregate projects visible to the API key." = "اختياري. اترك فارغا لاكتشاف وتجميع المشاريع المرئية لمفتاح API."; +"Org ID (optional)" = "معرف المنظمة (اختياري)"; +"Organizations" = "المنظمات"; +"Organization ID" = "معرف المنظمة"; +"Password" = "كلمة المرور"; +"%@ authentication is disabled." = "%@ المصادقة معطلة."; +"%@ cookies are disabled." = "%@ ملفات تعريف الارتباط معطلة."; +"%@ web API access is disabled." = "%@ الوصول إلى API الويب معطل."; +"Disable %@ dashboard cookie usage." = "قم بتعطيل استخدام ملفات تعريف الارتباط %@ لوحة التحكم."; +"Keychain access is disabled in Advanced, so browser cookie import is unavailable." = "Keychain الوصول معطل في القسم المتقدم، لذا فإن استيراد ملفات تعريف الارتباط من المتصفح غير متاح."; +"Manually paste an %@ from a browser session." = "قم بلصق %@ يدويا من جلسة متصفح."; +"Paste a Cookie header captured from %@." = "الصق رأس كوكي تم التقاطه من %@."; +"Paste a Cookie header from %@." = "لصق رأس كوكي من %@."; +"Paste a Cookie header or cURL capture from %@." = "الصق رأس كوكي أو التقاط رابط cURL من %@."; +"Paste a Cookie header or full cURL capture from %@." = "الصق رأس كوكي أو التقاط CURL بالكامل من %@."; +"Paste a Cookie or Authorization header from %@." = "الصق رأس كوكيز أو تفويض من %@."; +"Paste a full cookie header or the %@ value." = "الصق رأس كوكيز كامل أو قيمة %@."; +"Paste a Cookie header or full cURL capture from T3 Chat settings." = "الصق رأس كوكي أو التقاط CURL بالكامل من T3 Chat الإعدادات."; +"Paste the Cookie header from a request to admin.mistral.ai. Must contain an ory_session_* cookie." = "الصق رأس الكوكي من طلب إلى admin.mistral.ai. يجب أن يحتوي على كوكيز ory_session_*."; +"Paste the Oasis-Token from a logged-in browser session on platform.stepfun.com." = "الصق رمز الواحة من جلسة متصفح مسجلة الدخول على platform.stepfun.com."; +"Paste the %@ JSON bundle from %@." = "الصق حزمة %@ JSON من %@."; +"Paste the %@ value or a full Cookie header." = "الصق قيمة %@ أو رأس كوكي كامل."; +"Personal account" = "الحساب الشخصي"; +"Project ID" = "معرف المشروع"; +"Re-auth" = "إعادة التصديق"; +"Re-login at claude.ai" = "إعادة تسجيل الدخول في claude.ai"; +"Re-authenticating…" = "إعادة التوثيق..."; +"Refresh Session" = "جلسة التحديث"; +"Refresh organizations" = "تحديث المنظمات"; +"Region" = "المنطقة"; +"Reload" = "إعادة التعبئة"; +"Reorder" = "إعادة ترتيب"; +"Secret access key" = "مفتاح الوصول السري"; +"Series" = "السلسلة"; +"Service" = "الخدمة"; +"Show or hide Kiro credits, percent, or both next to the menu bar icon." = "اعرض أو أخف Kiro الاعتمادات، النسبة المئوية، أو كلاهما بجانب أيقونة شريط القوائم."; +"Show usage for organizations you belong to. Personal account is always shown." = "اعرض الاستخدام للمنظمات التي تنتمي إليها. الحساب الشخصي يعرض دائما."; +"Sign in to cursor.com in your browser, then refresh Cursor in CodexBar." = "سجل الدخول إلى cursor.com في متصفحك، ثم قم بتحديث Cursor في CodexBar."; +"Simulated error text" = "نص خطأ محاكى"; +"StepFun platform account (phone number or email)." = "StepFun حساب المنصة (رقم الهاتف أو البريد الإلكتروني)."; +"Stored in ~/.codexbar/config.json." = "مخزنة في ~/.codexbar/config.json."; +"Stored in ~/.codexbar/config.json. AZURE_OPENAI_API_KEY is also supported." = "كما يدعم التخزين في ~/.codexbar/config.json. AZURE_OPENAI_API_KEY."; +"Stored in ~/.codexbar/config.json. For the official Kimi API, use Moonshot / Kimi API." = "مخزن في ~/.codexbar/config.json. بالنسبة Kimi API الرسمي، استخدم Moonshot / Kimi API."; +"Stored in ~/.codexbar/config.json. Get your API key from the Volcengine Ark console." = "مخزن في ~/.codexbar/config.json. احصل على مفتاح API من وحدة التحكم Volcengine Ark."; +"Stored in ~/.codexbar/config.json. Get your key from Ollama settings." = "مخزن في ~/.codexbar/config.json. احصل على مفتاحك من Ollama الإعدادات."; +"Stored in ~/.codexbar/config.json. Get your key from console.deepgram.com." = "مخزن في ~/.codexbar/config.json. احصل على مفتاحك من console.deepgram.com."; +"Stored in ~/.codexbar/config.json. Get your key from elevenlabs.io/app/settings/api-keys." = "مخزن في ~/.codexbar/config.json. احصل على مفتاحك من elevenlabs.io/app/settings/api-keys."; +"Stored in ~/.codexbar/config.json. Get your key from openrouter.ai/settings/keys and set a key spending limit there to enable API key quota tracking." = "مخزن في ~/.codexbar/config.json. احصل على مفتاحك من openrouter.ai/settings/keys وحدد حدا لإنفاق المفاتيح هناك لتمكين تتبع حصص API المفاتيح."; +"Stored in ~/.codexbar/config.json. In Warp, open Settings > Platform > API Keys, then create one." = "مخزن في ~/.codexbar/config.json. في Warp، افتح الإعدادات > مفاتيح > API المنصة، ثم أنشئ واحدا."; +"Stored in ~/.codexbar/config.json. Metrics require Groq Enterprise Prometheus access." = "التخزين في ~/.codexbar/config.json. Metrics يتطلب الوصول Groq Prometheus Enterprise."; +"Stored in ~/.codexbar/config.json. OPENAI_ADMIN_KEY is preferred; OPENAI_API_KEY still works." = "يفضل التخزين في ~/.codexbar/config.json. OPENAI_ADMIN_KEY؛ OPENAI_API_KEY لا يزال يعمل."; +"Stored in ~/.codexbar/config.json. Requires an Anthropic Admin API key." = "التخزين في ~/.codexbar/config.json. يتطلب مفتاح API إداري بشري."; +"Stored in ~/.codexbar/config.json. Used for /v1/quota-stats." = "مخزنة في ~/.codexbar/config.json. تستخدم /v1/quota-stats."; +"Stored in ~/.codexbar/config.json. You can also provide CODEBUFF_API_KEY or let CodexBar read ~/.config/manicode/credentials.json (created by `codebuff login`)." = "مخزن في ~/.codexbar/config.json. يمكنك أيضا توفير CODEBUFF_API_KEY أو السماح CodexBar بقراءة ~/.config/manicode/credentials.json (تم إنشاؤه بواسطة `codebuff login`)."; +"Stored in ~/.codexbar/config.json. You can also provide CROF_API_KEY." = "مخزن في ~/.codexbar/config.json. يمكنك أيضا توفير CROF_API_KEY."; +"Stored in ~/.codexbar/config.json. You can also provide KILO_API_KEY or ~/.local/share/kilo/auth.json (kilo.access)." = "مخزنة في ~/.codexbar/config.json. يمكنك أيضا توفير KILO_API_KEY أو ~/.local/share/kilo/auth.json (كيلو.أكس)."; +"T3 Chat cookie" = "T3 Chat كوكي"; +"Team mode" = "وضع الفريق"; +"That account is no longer available in CodexBar. Refresh the account list and try again." = "هذا الحساب لم يعد متاحا في عام CodexBar. قم بتحديث قائمة الحسابات وحاول مرة أخرى."; +"The browser login did not complete in time. Try Antigravity login again." = "لم يكتمل تسجيل الدخول في المتصفح في الوقت المناسب. حاول تسجيل الدخول Antigravity مرة أخرى."; +"Timed out waiting for Cursor login. %@" = "انتهى الوقت في انتظار تسجيل الدخول Cursor. %@"; +"Timed out waiting for Cursor login. %@ Last error: %@" = "انتهى الوقت في انتظار تسجيل الدخول Cursor. %@ آخر خطأ: %@"; +"Today requests" = "طلبات اليوم"; +"Total (30d): %@ credits" = "الإجمالي (30d): %@ ساعات معتمدة"; +"Username" = "اسم المستخدم"; +"Uses username + password to login and obtain an Oasis-Token automatically." = "يستخدم اسم المستخدم + كلمة المرور لتسجيل الدخول والحصول تلقائيا على رمز الواحة."; +"Uses username + password to login and obtain an %@ automatically." = "يستخدم اسم المستخدم + كلمة المرور لتسجيل الدخول والحصول على %@ تلقائيا."; +"Utilization End" = "نهاية الاستخدام"; +"Utilization Start" = "بداية الاستخدام"; +"Verbosity" = "التكرار"; +"Windsurf session JSON bundle" = "حزمة Windsurf JSON الجلسة"; +"Workspace ID" = "معرف مساحة العمل"; +"Your StepFun platform password. Used to login and obtain a session token." = "كلمة مرور المنصة StepFun الخاصة بك. يستخدم لتسجيل الدخول والحصول على رمز الجلسة."; +"claude /login exited with status %d." = "/login كلود غادر بوضعية %d."; +"codex login exited with status %d." = "تم الخروج من تسجيل الدخول إلى الكودكس مع الحالة %d."; +"Cookie: …\n\nor paste a cURL capture from the Abacus AI dashboard" = "كوكي: ... \n\n أو لصق التقاط cURL من لوحة Abacus AI"; +"Cookie: …\n\nor paste the __Secure-next-auth.session-token value" = "كوكي: ... \n\n أو لصق قيمة __Secure-next-auth.session-token"; +"Cookie: …\n\nor paste the kimi-auth token value" = "كوكي: ... \n\n أو لصق قيمة رمز kimi-authentic"; +"session_id=...\n\nor paste just the session_id value" = "session_id=... \n\n أو لصق القيمة session_id فقط"; +"Clear" = "واضح"; +"No matching providers" = "لا يوجد مزودون مطابقون"; +"Search providers" = "مزودو البحث"; + +"language_vietnamese" = "الفيتناميون"; +"language_indonesian" = "بهاسا إندونيسيا"; + +"Request quota: %@ / %@" = "طلب الحصة: %@ / %@"; +"language_arabic" = "العربية"; +"language_persian" = "فارسی"; +"language_thai" = "ไทย"; +"language_galician" = "Galego"; +"Limit Reset Credits" = "أرصدة إعادة تعيين الحد"; +"1 available" = "1 متاح"; +"%d available" = "%d متاح"; +"Next expires %@" = "تنتهي صلاحية التالية %@"; +"Expires %@" = "تنتهي الصلاحية %@"; +"No expiry" = "لا انتهاء صلاحية"; +"byte_unit_byte" = "بايت"; +"byte_unit_bytes" = "بايتات"; +"byte_unit_kilobyte" = "كيلوبايت"; +"byte_unit_kilobytes" = "كيلوبايتات"; +"byte_unit_megabyte" = "ميغابايت"; +"byte_unit_megabytes" = "ميغابايتات"; +"byte_unit_gigabyte" = "غيغابايت"; +"byte_unit_gigabytes" = "غيغابايتات"; + +/* Settings sidebar redesign */ +"Enable" = "تفعيل"; +"Disable" = "تعطيل"; +"providers_on_count" = "%d مفعّل"; +"section_cost_summary" = "ملخص التكلفة"; +"section_command_line" = "سطر الأوامر"; +"section_privacy" = "الخصوصية"; +"section_diagnostics" = "التشخيصات"; +"section_updates" = "التحديثات"; +"section_links" = "روابط"; +"Show Codex Spark usage" = "عرض استخدام Codex Spark"; +"Shows Codex Spark quota rows in the menu and provider preview. Requires optional credits and extra usage in Display settings." = "يعرض صفوف حصة Codex Spark في القائمة ومعاينة المزوّد. يتطلب تفعيل «عرض الاعتمادات + الاستخدام الإضافي» في إعدادات العرض."; +"Show Daily Routines usage" = "عرض استخدام الروتين اليومي"; +"Shows the Daily Routines quota row in the menu and provider preview. Requires optional credits and extra usage in Display settings." = "يعرض صف حصة الروتين اليومي في القائمة ومعاينة المزوّد. يتطلب تفعيل «عرض الاعتمادات + الاستخدام الإضافي» في إعدادات العرض."; +"Scroll to see more models" = "مرر لرؤية المزيد من النماذج"; +/* Shareable usage card */ +"Copy Image" = "نسخ الصورة"; +"Copy Stats" = "نسخ الإحصاءات"; +"Could not copy image" = "تعذر نسخ الصورة"; +"Image copied" = "تم نسخ الصورة"; +"Image saved" = "تم حفظ الصورة"; +"Nothing is uploaded. This image is created on your Mac." = "لا يتم رفع أي شيء. تُنشأ هذه الصورة على جهاز Mac."; +"Save..." = "حفظ..."; +"Share AI Usage" = "مشاركة استخدام الذكاء الاصطناعي"; +"Share Stats…" = "مشاركة الإحصاءات…"; +"Stats copied" = "تم نسخ الإحصاءات"; +"Finish switching to a different Cursor account in your browser, then try again." = "أكمل التبديل إلى حساب Cursor مختلف في متصفحك، ثم حاول مرة أخرى."; +"Timed out waiting for Cursor account switch. %@" = "انتهت مهلة انتظار تبديل حساب Cursor. %@"; +"Timed out waiting for Cursor account switch. %@ Last error: %@" = "انتهت مهلة انتظار تبديل حساب Cursor. %@ آخر خطأ: %@"; + +/* Spend dashboard */ +"tab_usage_spend" = "الاستخدام والإنفاق"; +"Usage & Spend" = "الاستخدام والإنفاق"; +"Local estimated cost history across supported providers." = "سجل التكاليف التقديري المحلي عبر المزوّدين المدعومين."; +"Time range" = "النطاق الزمني"; +"Track costs" = "تتبّع التكاليف"; +"Cost tracking is off" = "تتبّع التكاليف متوقف"; +"Turn on Track costs to build local estimates." = "فعّل «تتبّع التكاليف» لإنشاء تقديرات محلية."; +"No local cost history yet" = "لا يوجد سجل تكاليف محلي بعد"; +"Turn on cost tracking or refresh after using a supported provider." = "فعّل تتبّع التكاليف أو حدّث بعد استخدام مزوّد مدعوم."; +"Refresh failures" = "حالات فشل التحديث"; +"Native currencies stay separate; Codex account rows exclude Pi session history." = "تبقى العملات الأصلية منفصلة؛ تستبعد صفوف حساب Codex سجل جلسات Pi."; +"Spend unavailable" = "الإنفاق غير متاح"; +"Model breakdown unavailable" = "تفصيل الإنفاق حسب النموذج غير متاح"; +"Local estimated history" = "السجل التقديري المحلي"; +"Coverage" = "التغطية"; +"Estimated spend" = "الإنفاق التقديري"; +"Tracked tokens" = "الرموز المتتبعة"; +"Subscriptions" = "الاشتراكات"; +"By subscription" = "حسب الاشتراك"; +"No model-level history" = "لا يوجد سجل على مستوى النموذج"; +"Daily estimated spend" = "الإنفاق اليومي التقديري"; +"≈%d full 5h windows of weekly left · %d windows until reset" = "≈%d نافذة كاملة مدتها 5 ساعات متبقية من الأسبوعي · %d نافذة حتى إعادة التعيين"; +"Weekly cannot run out before reset at this pace" = "لا يمكن أن ينفد الحد الأسبوعي قبل إعادة التعيين بهذه الوتيرة"; +"Weekly can run out ≈%d windows early" = "قد ينفد الحد الأسبوعي مبكرًا بنحو %d نافذة"; +"Estimated: %@" = "تقديري: %@"; +"session_quota_estimate_value_format" = "%1$@ %2$@"; +"session quota" = "حصة الجلسة"; +"session quotas" = "حصص الجلسات"; +"Coding Plan" = "خطة البرمجة"; +"Agent Plan" = "خطة الوكيل"; +"Team" = "فريق"; + +/* Menu bar layout editor */ +"menu_bar_layout_title" = "التخطيط"; +"menu_bar_layout_footer" = "اسحب العناصر لترتيب شريط القوائم. انقر على عنصر لإضافته؛ حدّد عنصراً موضوعاً واضغط Delete لإزالته."; +"menu_bar_layout_group_identity" = "الهوية"; +"menu_bar_layout_group_usage" = "الاستخدام"; +"menu_bar_layout_group_time" = "الوقت"; +"menu_bar_layout_group_money" = "التكلفة"; +"menu_bar_layout_group_structure" = "البنية"; +"menu_bar_layout_scope_all" = "كل المزوّدين"; +"menu_bar_layout_scope_help" = "عدّل التخطيط الافتراضي أو خصّص مزوّداً واحداً."; +"menu_bar_layout_use_all" = "استخدام تخطيط كل المزوّدين"; +"menu_bar_layout_preset" = "إعداد تخطيط مسبق"; +"menu_bar_layout_preset_icon_percent" = "الأيقونة والنسبة"; +"menu_bar_layout_preset_icon_only" = "الأيقونة فقط"; +"menu_bar_layout_preset_percent_reset" = "النسبة وإعادة الضبط"; +"menu_bar_layout_preset_compact_stacked" = "مكدّس مضغوط"; +"menu_bar_layout_preset_custom" = "العرف"; +"menu_bar_layout_live_preview" = "معاينة مباشرة"; +"menu_bar_layout_strip" = "شريط القوائم"; +"menu_bar_layout_remove_line_break" = "إزالة فاصل السطر"; +"menu_bar_layout_chip_hint" = "حدّد أو اسحب لإعادة الترتيب أو استخدم إجراء الإزالة."; +"menu_bar_layout_palette_hint" = "انقر للإضافة أو اسحب إلى التخطيط."; +"menu_bar_layout_empty_line" = "أفلت عنصراً هنا"; +"menu_bar_layout_line" = "السطر %d"; +"menu_bar_layout_drag_remove" = "اسحب هنا للإزالة"; +"menu_bar_layout_size" = "الحجم"; +"menu_bar_layout_size_small" = "صغير"; +"menu_bar_layout_size_regular" = "عادي"; +"menu_bar_layout_gap" = "المسافة"; +"menu_bar_layout_gap_tight" = "ضيّق"; +"menu_bar_layout_gap_regular" = "عادي"; +"menu_bar_layout_keyboard_hint" = "يحذف Delete العنصر المحدد"; +"menu_bar_layout_sample_account" = "حساب"; +"menu_bar_layout_sample_runs_out" = "ينفد الجمعة"; +"menu_bar_layout_token_icon" = "الأيقونة"; +"menu_bar_layout_token_provider" = "اسم المزوّد"; +"menu_bar_layout_token_account" = "الحساب"; +"menu_bar_layout_token_session" = "الجلسة %"; +"menu_bar_layout_token_weekly" = "الأسبوعي %"; +"menu_bar_layout_token_auto" = "نسبة تلقائية"; +"menu_bar_layout_token_bar" = "شريط الاستخدام"; +"menu_bar_layout_token_resets_in" = "إعادة الضبط خلال"; +"menu_bar_layout_token_reset_at" = "إعادة الضبط عند"; +"menu_bar_layout_token_runs_out" = "ينفد"; +"menu_bar_layout_token_cost_today" = "تكلفة اليوم"; +"menu_bar_layout_token_cost_30d" = "تكلفة 30 يوماً"; +"menu_bar_layout_token_space" = "مسافة"; +"menu_bar_layout_token_line_break" = "فاصل سطر"; +"menu_bar_layout_token_separator_accessibility" = "نقطة فاصلة"; + +/* Menu bar layout accessibility */ +"Icon unavailable" = "الأيقونة: غير متوفر"; +"%@ icon" = "%@: الأيقونة"; +"Provider name unavailable" = "اسم المزوّد: غير متوفر"; +"Account unavailable" = "الحساب: غير متوفر"; +"%@ unavailable" = "%@: غير متوفر"; +"%@ %@" = "%@ %@"; +"Usage bar unavailable" = "شريط الاستخدام: غير متوفر"; +"Usage bar, %d of 3 filled" = "شريط الاستخدام: %d/3 ممتلئ"; +"Reset countdown unavailable" = "إعادة الضبط خلال: غير متوفر"; +"Reset time unavailable" = "إعادة الضبط عند: غير متوفر"; +"Run-out estimate unavailable" = "ينفد: غير متوفر"; +"Cost today unavailable" = "تكلفة اليوم: غير متوفر"; +"30-day cost unavailable" = "تكلفة 30 يوماً: غير متوفر"; +"Resets" = "إعادات الضبط"; diff --git a/Sources/CodexBar/Resources/ar.lproj/Localizable.stringsdict b/Sources/CodexBar/Resources/ar.lproj/Localizable.stringsdict new file mode 100644 index 0000000000..c7e4cf1d77 --- /dev/null +++ b/Sources/CodexBar/Resources/ar.lproj/Localizable.stringsdict @@ -0,0 +1,73 @@ + + + + + ≈%d full 5h windows of weekly left · %d windows until reset + + NSStringLocalizedFormatKey + %#@left@ · %#@until@ + left + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + d + zero + ≈%d نوافذ كاملة مدتها 5 ساعات متبقية من الأسبوعي + one + ≈%d نافذة كاملة مدتها 5 ساعات متبقية من الأسبوعي + two + ≈%d نافذتان كاملتان مدة كل منهما 5 ساعات متبقيتان من الأسبوعي + few + ≈%d نوافذ كاملة مدة كل منها 5 ساعات متبقية من الأسبوعي + many + ≈%d نافذة كاملة مدتها 5 ساعات متبقية من الأسبوعي + other + ≈%d نافذة كاملة مدتها 5 ساعات متبقية من الأسبوعي + + until + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + d + zero + %d نوافذ حتى إعادة التعيين + one + %d نافذة حتى إعادة التعيين + two + %d نافذتان حتى إعادة التعيين + few + %d نوافذ حتى إعادة التعيين + many + %d نافذة حتى إعادة التعيين + other + %d نافذة حتى إعادة التعيين + + + Weekly can run out ≈%d windows early + + NSStringLocalizedFormatKey + %#@early@ + early + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + d + zero + قد ينفد الحد الأسبوعي مبكرًا بنحو %d نوافذ + one + قد ينفد الحد الأسبوعي مبكرًا بنحو %d نافذة + two + قد ينفد الحد الأسبوعي مبكرًا بنحو %d نافذتين + few + قد ينفد الحد الأسبوعي مبكرًا بنحو %d نوافذ + many + قد ينفد الحد الأسبوعي مبكرًا بنحو %d نافذة + other + قد ينفد الحد الأسبوعي مبكرًا بنحو %d نافذة + + + + diff --git a/Sources/CodexBar/Resources/ca.lproj/Localizable.strings b/Sources/CodexBar/Resources/ca.lproj/Localizable.strings index 9fc46ba24d..25524dc47a 100644 --- a/Sources/CodexBar/Resources/ca.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ca.lproj/Localizable.strings @@ -1,34 +1,40 @@ /* Catalan localization for CodexBar */ +"ollama_safari_cookie_access_hint" = "Les galetes de Safari necessiten accés complet al disc per a CodexBar (Configuració del Sistema > Privadesa i seguretat)."; +"ollama_browser_cookie_decryption_denied" = "S'ha denegat el desxifratge de les galetes de %@ al Clauer; torneu-ho a provar amb una actualització manual."; +"ollama_browser_cookie_decryption_disabled" = "El desxifratge de les galetes de %@ està desactivat a CodexBar; activeu l'accés al Clauer i actualitzeu."; + " providers" = " proveïdors"; "(System)" = "(Sistema)"; "30d" = "30 d"; -"A managed Codex login is already running. Wait for it to finish before adding " = "Ja hi ha un inici de sessió gestionat de Codex en curs. Espera que acabi abans d'afegir "; +"7d" = "7 d"; +"A managed Codex login is already running. Wait for it to finish before adding " = "Ja hi ha un inici de sessió gestionat de Codex en curs. Espereu que acabi abans d'afegir "; "API key" = "Clau d'API"; "API region" = "Regió de l'API"; -"API token" = "Testimoni d'API"; -"API tokens" = "Testimonis d'API"; +"API token" = "Token d'API"; +"API tokens" = "Tokens d'API"; "About" = "Quant a"; "Account" = "Compte"; "Accounts" = "Comptes"; "Accounts subtitle" = "Subtítol de comptes"; "Active" = "Actiu"; "Add" = "Afegeix"; -"Add Workspace" = "Afegeix espai de treball"; +"Add Workspace" = "Afegiu espai de treball"; "Advanced" = "Avançat"; "All" = "Tot"; -"Always allow prompts" = "Permet sempre les sol·licituds"; +"Always allow prompts" = "Permeteu sempre les sol·licituds"; "Animation pattern" = "Patró d'animació"; "Antigravity login is managed in the app" = "L'inici de sessió d'Antigravity es gestiona a l'app"; "Applies only to the Security.framework OAuth keychain reader." = "Només s'aplica al lector de Clauer OAuth de Security.framework."; +"Alternatively, set a custom path in Settings." = "Alternativament, definiu un camí personalitzat a Configuració."; "Auto falls back to the next source if the preferred one fails." = "Auto recorre a la font següent si la preferida falla."; "Auto uses API first, then falls back to CLI on auth failures." = "Auto fa servir primer l'API i recorre a la CLI si falla l'autenticació."; "Auto-detect" = "Detecció automàtica"; -"Auto-refresh is off; use the menu's Refresh command." = "L'actualització automàtica està desactivada; fes servir l'ordre Actualitza del menú."; -"Auto-refresh: hourly · Timeout: 10m" = "Actualització automàtica: cada hora · Temps d'espera: 10 m"; +"Auto-refresh is off; use the menu's Refresh command." = "L'actualització automàtica està desactivada; feu servir l'ordre Actualitza del menú."; +"Auto-refresh: hourly · Timeout: 10m" = "Actualització automàtica: cada hora · Temps d'espera: 10 min"; "Automatic" = "Automàtic"; -"Automatic imports browser cookies and WorkOS tokens." = "El mode automàtic importa galetes del navegador i testimonis de WorkOS."; -"Automatic imports browser cookies and local storage tokens." = "El mode automàtic importa galetes del navegador i testimonis de l'emmagatzematge local."; +"Automatic imports browser cookies and WorkOS tokens." = "El mode automàtic importa galetes del navegador i tokens de WorkOS."; +"Automatic imports browser cookies and local storage tokens." = "El mode automàtic importa galetes del navegador i tokens de l'emmagatzematge local."; "Automatic imports browser cookies for dashboard extras." = "El mode automàtic importa galetes del navegador per als extres del tauler."; "Automatic imports browser cookies for the web API." = "El mode automàtic importa galetes del navegador per a l'API web."; "Automatic imports browser cookies from Model Studio/Bailian." = "El mode automàtic importa galetes del navegador des de Model Studio/Bailian."; @@ -47,24 +53,27 @@ "Bordered" = "Amb vora"; "Build" = "Compilació"; "Built \\(buildTimestamp)" = "Compilat \\(buildTimestamp)"; -"Buy Credits..." = "Compra crèdits..."; -"Buy Credits…" = "Compra crèdits…"; +"Buy Credits..." = "Compreu crèdits..."; +"Buy Credits…" = "Compreu crèdits…"; "CLI paths" = "Camins de la CLI"; "CLI sessions" = "Sessions de la CLI"; "Caches" = "Memòries cau"; "Cancel" = "Cancel·la"; "Check for Updates…" = "Cerca actualitzacions…"; "Check for updates automatically" = "Cerca actualitzacions automàticament"; -"Check if you like your agents having some fun up there." = "Activa-ho si t'agrada que els teus agents es diverteixin allà dalt."; -"Check provider status" = "Comprova l'estat del proveïdor"; -"Choose Codex workspace" = "Tria l'espai de treball de Codex"; -"Choose the MiniMax host (global .io or China mainland .com)." = "Tria l'amfitrió de MiniMax (global .io o la Xina continental .com)."; -"Choose up to " = "Tria fins a "; -"Choose up to \\(Self.maxOverviewProviders) providers" = "Tria fins a \\(Self.maxOverviewProviders) proveïdors"; -"Choose up to \\(count) providers" = "Tria fins a \\(count) proveïdors"; -"Choose what to show in the menu bar (Pace shows usage vs. expected)." = "Tria què es mostra a la barra de menús (Ritme mostra l'ús respecte al previst)."; -"Choose which Codex account CodexBar should follow." = "Tria quin compte de Codex ha de seguir el CodexBar."; -"Choose which window drives the menu bar percent." = "Tria quina finestra determina el percentatge de la barra de menús."; +"Check if you like your agents having some fun up there." = "Activeu-ho si us agrada que els vostres agents es diverteixin allà dalt."; +"Check provider status" = "Comproveu l'estat del proveïdor"; +"Choose a supported browser so CodexBar can read the matching account." = "Trieu un navegador compatible perquè CodexBar pugui llegir el compte corresponent."; +"Choose Codex workspace" = "Trieu l'espai de treball de Codex"; +"Choose Cursor account" = "Trieu el compte de Cursor"; +"Choose the MiniMax host (global .io or China mainland .com)." = "Trieu l'amfitrió de MiniMax (global .io o la Xina continental .com)."; +"Choose up to " = "Trieu fins a "; +"Choose up to \\(Self.maxOverviewProviders) providers" = "Trieu fins a \\(Self.maxOverviewProviders) proveïdors"; +"Choose up to \\(count) providers" = "Trieu fins a \\(count) proveïdors"; +"Choose what to show in the menu bar (Pace shows usage vs. expected)." = "Trieu què es mostra a la barra de menús (Ritme mostra l'ús respecte al previst)."; +"Choose which Codex account CodexBar should follow." = "Trieu quin compte de Codex ha de seguir el CodexBar."; +"Choose which Cursor account CodexBar should use." = "Trieu quin compte de Cursor ha d'utilitzar CodexBar."; +"Choose which window drives the menu bar percent." = "Trieu quina finestra determina el percentatge de la barra de menús."; "Chrome" = "Chrome"; "Claude CLI not found" = "No s'ha trobat la CLI de Claude"; "Claude binary" = "Binari de Claude"; @@ -80,15 +89,15 @@ "CodexBar Lifecycle Keepalive" = "Manteniment del cicle de vida del CodexBar"; "CodexBar can't show its menu bar icon" = "El CodexBar no pot mostrar la seva icona a la barra de menús"; "CodexBar could not read managed account storage. " = "El CodexBar no ha pogut llegir l'emmagatzematge de comptes gestionats. "; -"Configure…" = "Configura…"; +"Configure…" = "Configureu…"; "Connected" = "Connectat"; "Controls how much detail is logged." = "Controla quant detall es registra."; "Cookie header" = "Capçalera de galeta"; "Cookie source" = "Origen de la galeta"; "Cookie: ..." = "Cookie: ..."; -"Cookie: \\u{2026}\\\n\\\nor paste a cURL capture from the Abacus AI dashboard" = "Cookie: \\u{2026}\\\n\\\no enganxa una captura cURL del tauler d'Abacus AI"; -"Cookie: \\u{2026}\\\n\\\nor paste the __Secure-next-auth.session-token value" = "Cookie: \\u{2026}\\\n\\\no enganxa el valor de __Secure-next-auth.session-token"; -"Cookie: \\u{2026}\\\n\\\nor paste the kimi-auth token value" = "Cookie: \\u{2026}\\\n\\\no enganxa el valor del testimoni kimi-auth"; +"Cookie: \\u{2026}\\\n\\\nor paste a cURL capture from the Abacus AI dashboard" = "Cookie: \\u{2026}\\\n\\\no enganxeu una captura cURL del tauler d'Abacus AI"; +"Cookie: \\u{2026}\\\n\\\nor paste the __Secure-next-auth.session-token value" = "Cookie: \\u{2026}\\\n\\\no enganxeu el valor de __Secure-next-auth.session-token"; +"Cookie: \\u{2026}\\\n\\\nor paste the kimi-auth token value" = "Cookie: \\u{2026}\\\n\\\no enganxeu el valor del token kimi-auth"; "Cookie: …" = "Cookie: …"; "CopilotDeviceFlow" = "CopilotDeviceFlow"; "Cost" = "Cost"; @@ -98,6 +107,8 @@ "Could not start codex login" = "No s'ha pogut iniciar codex login"; "Could not switch system account" = "No s'ha pogut canviar el compte del sistema"; "Credits" = "Crèdits"; +"Individual credits" = "Crèdits individuals"; +"Workspace" = "Espai de treball"; "Credits history" = "Historial de crèdits"; "Cursor login failed" = "L'inici de sessió de Cursor ha fallat"; "Custom" = "Personalitzat"; @@ -105,30 +116,30 @@ "Daily Routines" = "Rutines diàries"; "Debug" = "Depuració"; "Default" = "Per defecte"; -"Disable Keychain access" = "Desactiva l'accés al Clauer"; +"Disable Keychain access" = "Desactiveu l'accés al Clauer"; "Disabled" = "Desactivat"; -"Dismiss" = "Descarta"; +"Dismiss" = "Descarteu"; "Disconnected" = "Desconnectat"; "Display" = "Pantalla"; "Display mode" = "Mode de visualització"; -"Display reset times as absolute clock values instead of countdowns." = "Mostra les hores de reinici com a valors de rellotge absoluts en comptes de comptes enrere."; +"Display reset times as absolute clock values instead of countdowns." = "Mostreu les hores de reinici com a valors de rellotge absoluts en comptes de comptes enrere."; "Done" = "Fet"; "Effective PATH" = "PATH efectiu"; "Email" = "Correu electrònic"; -"Enable Merge Icons to configure Overview tab providers." = "Activa Combina les icones per configurar els proveïdors de la pestanya Resum."; -"Enable file logging" = "Activa el registre en fitxer"; +"Enable Merge Icons to configure Overview tab providers." = "Activeu Combina les icones per configurar els proveïdors de la pestanya Resum."; +"Enable file logging" = "Activeu el registre en fitxer"; "Enabled" = "Activat"; "Error" = "Error"; "Error simulation" = "Simulació d'errors"; -"Expose troubleshooting tools in the Debug tab." = "Mostra eines de diagnòstic a la pestanya Depuració."; +"Expose troubleshooting tools in the Debug tab." = "Mostreu eines de diagnòstic a la pestanya Depuració."; "Failed" = "Ha fallat"; "False" = "Fals"; "Fetch strategy attempts" = "Intents d'estratègia d'obtenció"; "Fetching" = "S'està obtenint"; "Field" = "Camp"; "Field subtitle" = "Subtítol del camp"; -"Finish the current managed account change before switching the system account." = "Acaba el canvi de compte gestionat actual abans de canviar el compte del sistema."; -"Force animation on next refresh" = "Força l'animació a la propera actualització"; +"Finish the current managed account change before switching the system account." = "Acabeu el canvi de compte gestionat actual abans de canviar el compte del sistema."; +"Force animation on next refresh" = "Forceu l'animació a la pròxima actualització"; "Gateway region" = "Regió de la passarel·la"; "Gemini CLI not found" = "No s'ha trobat la CLI de Gemini"; "Gemini/Antigravity, surfacing incidents in the icon and menu." = "Gemini/Antigravity, mostrant incidències a la icona i al menú."; @@ -136,18 +147,19 @@ "GitHub" = "GitHub"; "GitHub Copilot Login" = "Inici de sessió de GitHub Copilot"; "GitHub Login" = "Inici de sessió de GitHub"; -"Hide details" = "Amaga els detalls"; -"Hide personal information" = "Amaga la informació personal"; +"Hide details" = "Amagueu els detalls"; +"Hide personal information" = "Amagueu la informació personal"; "Historical tracking" = "Seguiment històric"; "How often CodexBar polls providers in the background." = "Amb quina freqüència el CodexBar consulta els proveïdors en segon pla."; "Inactive" = "Inactiu"; "Install CLI" = "Instal·la la CLI"; -"Install the Claude CLI (npm i -g @anthropic-ai/claude-code) and try again." = "Instal·la la CLI de Claude (npm i -g @anthropic-ai/claude-code) i torna-ho a provar."; -"Install the Codex CLI (npm i -g @openai/codex) and try again." = "Instal·la la CLI de Codex (npm i -g @openai/codex) i torna-ho a provar."; -"Install the Gemini CLI (npm i -g @google/gemini-cli) and try again." = "Instal·la la CLI de Gemini (npm i -g @google/gemini-cli) i torna-ho a provar."; +"Install the Claude CLI (npm i -g @anthropic-ai/claude-code) and try again." = "Instal·leu la CLI de Claude (npm i -g @anthropic-ai/claude-code) i torneu-ho a provar."; +"Install the Codex CLI (npm i -g @openai/codex) and try again." = "Instal·leu la CLI de Codex (npm i -g @openai/codex) i torneu-ho a provar."; +"Install the Gemini CLI (npm i -g @google/gemini-cli) and try again." = "Instal·leu la CLI de Gemini (npm i -g @google/gemini-cli) i torneu-ho a provar."; +"Install a JetBrains IDE with AI Assistant enabled, then refresh CodexBar." = "Instal·leu un IDE de JetBrains amb l'AI Assistant activat i després actualitzeu el CodexBar."; "JetBrains AI is ready" = "JetBrains AI està a punt"; "JetBrains IDE" = "IDE de JetBrains"; -"Keep CLI sessions alive" = "Mantén actives les sessions de la CLI"; +"Keep CLI sessions alive" = "Mantingueu actives les sessions de la CLI"; "Keyboard shortcut" = "Drecera de teclat"; "Keychain access" = "Accés al Clauer"; "Keychain prompt policy" = "Política de sol·licituds del Clauer"; @@ -166,7 +178,7 @@ "Managed Codex accounts unavailable" = "Comptes gestionats de Codex no disponibles"; "Managed account storage is unreadable. Live account access is still available, " = "L'emmagatzematge de comptes gestionats no es pot llegir. L'accés a comptes en directe encara està disponible, "; "Manual" = "Manual"; -"May your tokens never run out—keep agent limits in view." = "Que els teus testimonis no s'esgotin mai: mantén els límits dels teus agents a la vista."; +"May your tokens never run out—keep agent limits in view." = "Que els vostres tokens no s'esgotin mai: mantingueu els límits dels vostres agents a la vista."; "Menu bar" = "Barra de menús"; "Menu bar auto-shows the provider closest to its rate limit." = "La barra de menús mostra automàticament el proveïdor més a prop del seu límit."; "Menu bar metric" = "Mètrica de la barra de menús"; @@ -183,135 +195,137 @@ "No data yet" = "Encara no hi ha dades"; "No enabled providers available for Overview." = "No hi ha proveïdors activats disponibles per al Resum."; "No providers selected" = "No hi ha cap proveïdor seleccionat"; -"No token accounts yet." = "Encara no hi ha comptes amb testimoni."; +"No token accounts yet." = "Encara no hi ha comptes amb token."; "No usage breakdown data." = "No hi ha dades de desglossament d'ús."; "None" = "Cap"; "Notifications" = "Notificacions"; -"Notifies when the 5-hour session quota hits 0% and when it becomes " = "Avisa quan la quota de sessió de 5 hores arriba al 0 % i quan torna a estar "; +"Notifies when the 5-hour session quota hits 0% and when it becomes " = "Avisa quan la quota de sessió de 5 hores arriba al 0% i quan torna a estar "; "OK" = "D'acord"; -"Obscure email addresses in the menu bar and menu UI." = "Amaga les adreces de correu a la barra de menús i a la interfície del menú."; +"Obscure email addresses in the menu bar and menu UI." = "Amagueu les adreces de correu a la barra de menús i a la interfície del menú."; "Off" = "Desactivat"; "Offline" = "Sense connexió"; "On" = "Activat"; "Online" = "En línia"; "Only on user action" = "Només en accions de l'usuari"; -"Open" = "Obre"; -"Open API Keys" = "Obre les claus d'API"; -"Open Amp Settings" = "Obre la configuració d'Amp"; -"Open Antigravity to sign in, then refresh CodexBar." = "Obre Antigravity per iniciar la sessió i després actualitza el CodexBar."; -"Open Browser" = "Obre el navegador"; -"Open Coding Plan" = "Obre el pla de programació"; -"Open Console" = "Obre la Consola"; -"Open Dashboard" = "Obre el tauler"; -"Open Mistral Admin" = "Obre l'administració de Mistral"; -"Open Menu Bar Settings" = "Obre la configuració de la barra de menús"; -"Open Ollama Settings" = "Obre la configuració d'Ollama"; -"Open Terminal" = "Obre el Terminal"; -"Open Usage Page" = "Obre la pàgina d'ús"; -"Open Warp API Key Guide" = "Obre la guia de la clau d'API de Warp"; -"Open menu" = "Obre el menú"; -"Open token file" = "Obre el fitxer de testimoni"; +"Open" = "Obriu"; +"Open API Keys" = "Obriu les claus d'API"; +"Open Amp Settings" = "Obriu la configuració d'Amp"; +"Open Antigravity to sign in, then refresh CodexBar." = "Obriu Antigravity per iniciar la sessió i després actualitzeu el CodexBar."; +"Open Browser" = "Obriu el navegador"; +"Open Coding Plan" = "Obriu el pla de programació"; +"Open Console" = "Obriu la Consola"; +"Open Dashboard" = "Obriu el tauler"; +"Open Mistral Admin" = "Obriu l'administració de Mistral"; +"Open Menu Bar Settings" = "Obriu la configuració de la barra de menús"; +"Open Ollama Settings" = "Obriu la configuració d'Ollama"; +"Open Terminal" = "Obriu el Terminal"; +"Open Usage Page" = "Obriu la pàgina d'ús"; +"Open Warp API Key Guide" = "Obriu la guia de la clau d'API de Warp"; +"Open menu" = "Obriu el menú"; +"Open token file" = "Obriu el fitxer de token"; "OpenAI cookies" = "Galetes d'OpenAI"; "OpenAI web extras" = "Extres web d'OpenAI"; "Option A" = "Opció A"; "Option B" = "Opció B"; "Optional override if workspace lookup fails." = "Substitució opcional si falla la cerca de l'espai de treball."; "Options" = "Opcions"; -"Override auto-detection with a custom IDE base path" = "Substitueix la detecció automàtica amb un camí base d'IDE personalitzat"; +"Override auto-detection with a custom IDE base path" = "Substituïu la detecció automàtica amb un camí base d'IDE personalitzat"; "Overview" = "Resum"; "Overview rows always follow provider order." = "Les files del Resum sempre segueixen l'ordre dels proveïdors."; "Overview tab providers" = "Proveïdors de la pestanya Resum"; -"Paste API key…" = "Enganxa la clau d'API…"; -"Paste API token…" = "Enganxa el testimoni d'API…"; -"Paste key…" = "Enganxa la clau…"; -"Paste sessionKey or OAuth token…" = "Enganxa la sessionKey o el testimoni OAuth…"; -"Paste the Cookie header from a request to admin.mistral.ai. " = "Enganxa la capçalera Cookie d'una petició a admin.mistral.ai. "; -"Paste token…" = "Enganxa el testimoni…"; +"Paste API key…" = "Enganxeu la clau d'API…"; +"Paste API token…" = "Enganxeu el token d'API…"; +"Paste key…" = "Enganxeu la clau…"; +"Paste sessionKey or OAuth token…" = "Enganxeu la sessionKey o el token OAuth…"; +"Paste the Cookie header from a request to admin.mistral.ai. " = "Enganxeu la capçalera Cookie d'una petició a admin.mistral.ai. "; +"Paste token…" = "Enganxeu el token…"; "Personal" = "Personal"; "Picker" = "Selector"; "Picker subtitle" = "Subtítol del selector"; "Placeholder" = "Text de marcador"; "Plan" = "Pla"; -"Play full-screen confetti when weekly usage resets." = "Mostra confeti a pantalla completa quan es reinicia l'ús setmanal."; +"Plan Usage" = "Ús del pla"; +"Play full-screen confetti when weekly usage resets." = "Mostreu confeti a pantalla completa quan es reinicia l'ús setmanal."; "Polls OpenAI/Claude status pages and Google Workspace for " = "Consulta les pàgines d'estat d'OpenAI/Claude i Google Workspace per a "; "Prevents any Keychain access while enabled." = "Impedeix qualsevol accés al Clauer mentre estigui activat."; "Primary (API key limit)" = "Principal (límit de la clau d'API)"; "Primary (\\(label))" = "Principal (\\(label))"; "Primary (\\(metadata.sessionLabel))" = "Principal (\\(metadata.sessionLabel))"; "Probe logs" = "Registres de sondeig"; -"Progress bars fill as you consume quota (instead of showing remaining)." = "Les barres de progrés s'omplen a mesura que consumeixes la quota (en comptes de mostrar el que queda)."; +"Progress bars fill as you consume quota (instead of showing remaining)." = "Les barres de progrés s'omplen a mesura que consumiu la quota (en comptes de mostrar el que queda)."; "Provider" = "Proveïdor"; "Providers" = "Proveïdors"; -"Quit CodexBar" = "Surt del CodexBar"; +"Quit CodexBar" = "Sortiu del CodexBar"; "Random (default)" = "Aleatori (per defecte)"; "Reads local usage logs. Shows today + last 30 days cost in the menu." = "Llegeix els registres d'ús locals. Mostra el cost d'avui + la finestra d'historial seleccionada al menú."; "Refresh" = "Actualitza"; "Refresh cadence" = "Freqüència d'actualització"; "Remote" = "Remot"; -"Remove" = "Elimina"; -"Remove Codex account?" = "Vols eliminar el compte de Codex?"; -"Remove \\(account.email) from CodexBar? Its managed Codex home will be deleted." = "Vols eliminar \\(account.email) del CodexBar? El seu directori Codex gestionat s'esborrarà."; -"Remove \\(email) from CodexBar? Its managed Codex home will be deleted." = "Vols eliminar \\(email) del CodexBar? El seu directori Codex gestionat s'esborrarà."; -"Remove selected account" = "Elimina el compte seleccionat"; -"Replace critter bars with provider branding icons and a percentage." = "Substitueix les barres de bestioles per icones de marca del proveïdor i un percentatge."; +"Remove" = "Elimineu"; +"Remove Codex account?" = "Voleu eliminar el compte de Codex?"; +"Remove \\(account.email) from CodexBar? Its managed Codex home will be deleted." = "Voleu eliminar \\(account.email) del CodexBar? El seu directori Codex gestionat s'esborrarà."; +"Remove \\(email) from CodexBar? Its managed Codex home will be deleted." = "Voleu eliminar \\(email) del CodexBar? El seu directori Codex gestionat s'esborrarà."; +"Remove selected account" = "Elimineu el compte seleccionat"; +"Replace critter bars with provider branding icons and a percentage." = "Substituïu les barres de bestioles per icones de marca del proveïdor i un percentatge."; "Replay selected animation" = "Reprodueix l'animació seleccionada"; "Requires authentication via GitHub Device Flow." = "Requereix autenticació mitjançant el flux de dispositiu de GitHub."; "Resets: \\(reset)" = "Es reinicia: \\(reset)"; +"reset_tomorrow_format" = "demà, %@"; "Rolling five-hour limit" = "Límit mòbil de cinc hores"; "Search hourly" = "Cerques per hora"; "Secondary (\\(label))" = "Secundari (\\(label))"; "Secondary (\\(metadata.weeklyLabel))" = "Secundari (\\(metadata.weeklyLabel))"; -"Select a provider" = "Selecciona un proveïdor"; -"Select the IDE to monitor" = "Selecciona l'IDE que cal monitorar"; +"Select a provider" = "Seleccioneu un proveïdor"; +"Select the IDE to monitor" = "Seleccioneu l'IDE que cal monitorar"; "Session quota notifications" = "Notificacions de quota de sessió"; -"Session tokens" = "Testimonis de sessió"; -"Settings" = "Configuració"; -"Show Codex Credits and Claude Extra usage sections in the menu." = "Mostra les seccions de Crèdits de Codex i Ús addicional de Claude al menú."; -"Show Debug Settings" = "Mostra la configuració de depuració"; -"Show all token accounts" = "Mostra tots els comptes amb testimoni"; -"Show cost summary" = "Mostra el resum de cost"; -"Show credits + extra usage" = "Mostra crèdits + ús addicional"; -"Show details" = "Mostra els detalls"; -"Show most-used provider" = "Mostra el proveïdor més utilitzat"; -"Show provider icons in the switcher (otherwise show a weekly progress line)." = "Mostra les icones de proveïdor al selector (si no, mostra una línia de progrés setmanal)."; -"Show reset time as clock" = "Mostra l'hora de reinici com a rellotge"; -"Show usage as used" = "Mostra l'ús com a consumit"; -"Sign in via button below" = "Inicia la sessió amb el botó de sota"; -"Skip teardown between probes (debug-only)." = "Omet el tancament entre sondeigs (només depuració)."; -"Stack token accounts in the menu (otherwise show an account switcher bar)." = "Apila els comptes amb testimoni al menú (si no, mostra una barra de canvi de compte)."; +"Session tokens" = "Tokens de sessió"; +"provider_section_connection" = "Connexió"; +"provider_section_menu_bar" = "Barra de menús"; +"Show Codex Credits and Claude Extra usage sections in the menu." = "Mostreu les seccions de Crèdits de Codex i Ús addicional de Claude al menú."; +"Show Debug Settings" = "Mostreu la configuració de depuració"; +"Show all token accounts" = "Mostreu tots els comptes amb token"; +"Show cost summary" = "Mostreu el resum de cost"; +"Show credits + extra usage" = "Mostreu crèdits + ús addicional"; +"Show details" = "Mostreu els detalls"; +"Show most-used provider" = "Mostreu el proveïdor més utilitzat"; +"Show provider icons in the switcher (otherwise show a weekly progress line)." = "Mostreu les icones de proveïdor al selector (si no, mostreu una línia de progrés setmanal)."; +"Show reset time as clock" = "Mostreu l'hora de reinici com a rellotge"; +"Show usage as used" = "Mostreu l'ús com a consumit"; +"Sign in with Claude Code..." = "Inicia sessió amb Claude Code..."; +"Sign in via button below" = "Inicieu la sessió amb el botó de sota"; +"Skip teardown between probes (debug-only)." = "Ometeu el tancament entre sondeigs (només depuració)."; +"Stack token accounts in the menu (otherwise show an account switcher bar)." = "Apileu els comptes amb token al menú (si no, mostreu una barra de canvi de compte)."; "Start at Login" = "Obrir en iniciar la sessió"; "Status" = "Estat"; -"Store Claude sessionKey cookies or OAuth access tokens." = "Desa galetes sessionKey de Claude o testimonis d'accés OAuth."; -"Store multiple Abacus AI Cookie headers." = "Desa diverses capçaleres Cookie d'Abacus AI."; -"Store multiple Augment Cookie headers." = "Desa diverses capçaleres Cookie d'Augment."; -"Store multiple Cursor Cookie headers." = "Desa diverses capçaleres Cookie de Cursor."; -"Store multiple Factory Cookie headers." = "Desa diverses capçaleres Cookie de Factory."; -"Store multiple MiniMax Cookie headers." = "Desa diverses capçaleres Cookie de MiniMax."; -"Store multiple Mistral Cookie headers." = "Desa diverses capçaleres Cookie de Mistral."; -"Store multiple Ollama Cookie headers." = "Desa diverses capçaleres Cookie d'Ollama."; -"Store multiple OpenCode Cookie headers." = "Desa diverses capçaleres Cookie d'OpenCode."; -"Store multiple OpenCode Go Cookie headers." = "Desa diverses capçaleres Cookie d'OpenCode Go."; +"Store Claude sessionKey cookies or OAuth access tokens." = "Deseu galetes sessionKey de Claude o tokens d'accés OAuth."; +"Store multiple Abacus AI Cookie headers." = "Deseu diverses capçaleres Cookie d'Abacus AI."; +"Store multiple Augment Cookie headers." = "Deseu diverses capçaleres Cookie d'Augment."; +"Store multiple Cursor Cookie headers." = "Deseu diverses capçaleres Cookie de Cursor."; +"Store multiple Factory Cookie headers." = "Deseu diverses capçaleres Cookie de Factory."; +"Store multiple MiniMax Cookie headers." = "Deseu diverses capçaleres Cookie de MiniMax."; +"Store multiple Mistral Cookie headers." = "Deseu diverses capçaleres Cookie de Mistral."; +"Store multiple Ollama Cookie headers." = "Deseu diverses capçaleres Cookie d'Ollama."; +"Store multiple OpenCode Cookie headers." = "Deseu diverses capçaleres Cookie d'OpenCode."; +"Store multiple OpenCode Go Cookie headers." = "Deseu diverses capçaleres Cookie d'OpenCode Go."; "Stored in the CodexBar config file." = "Desat al fitxer de configuració del CodexBar."; "Stored in ~/.codexbar/config.json. " = "Desat a ~/.codexbar/config.json. "; -"Stored in ~/.codexbar/config.json. Generate one at kimi-k2.ai." = "Desat a ~/.codexbar/config.json. Genera'n una a kimi-k2.ai."; -"Stored in ~/.codexbar/config.json. Paste the key from the Synthetic dashboard." = "Desat a ~/.codexbar/config.json. Enganxa la clau del tauler de Synthetic."; -"Stored in ~/.codexbar/config.json. Paste your Coding Plan API key from Model Studio." = "Desat a ~/.codexbar/config.json. Enganxa la clau d'API del teu pla de programació des de Model Studio."; -"Stored in ~/.codexbar/config.json. Paste your MiniMax API key." = "Desat a ~/.codexbar/config.json. Enganxa la teva clau d'API de MiniMax."; -"Stored in ~/.codexbar/config.json. You can also provide KILO_API_KEY or " = "Desat a ~/.codexbar/config.json. També pots proporcionar KILO_API_KEY o "; +"Stored in ~/.codexbar/config.json. Paste the key from the Synthetic dashboard." = "Desat a ~/.codexbar/config.json. Enganxeu la clau del tauler de Synthetic."; +"Stored in ~/.codexbar/config.json. Paste your Coding Plan API key from Model Studio." = "Desat a ~/.codexbar/config.json. Enganxeu la clau d'API del vostre pla de programació des de Model Studio."; +"Stored in ~/.codexbar/config.json. Paste your MiniMax API key." = "Desat a ~/.codexbar/config.json. Enganxeu la vostra clau d'API de MiniMax."; +"Stored in ~/.codexbar/config.json. You can also provide KILO_API_KEY or " = "Desat a ~/.codexbar/config.json. També podeu proporcionar KILO_API_KEY o "; "Stores local Codex usage history (8 weeks) to personalize Pace predictions." = "Desa l'historial d'ús local de Codex (8 setmanes) per personalitzar les prediccions de Ritme."; -"Subscription Utilization" = "Ús de la subscripció"; "Surprise me" = "Sorprèn-me"; "Switcher shows icons" = "El selector mostra icones"; -"Symlink CodexBarCLI to /usr/local/bin and /opt/homebrew/bin as codexbar." = "Crea un enllaç simbòlic de CodexBarCLI a /usr/local/bin i /opt/homebrew/bin com a codexbar."; +"Symlink CodexBarCLI to /usr/local/bin and /opt/homebrew/bin as codexbar." = "Creeu un enllaç simbòlic de CodexBarCLI a /usr/local/bin i /opt/homebrew/bin com a codexbar."; "System" = "Sistema"; -"Temporarily shows the loading animation after the next refresh." = "Mostra temporalment l'animació de càrrega després de la propera actualització."; +"Temporarily shows the loading animation after the next refresh." = "Mostra temporalment l'animació de càrrega després de la pròxima actualització."; "Tertiary (\\(label))" = "Terciari (\\(label))"; "Tertiary (\\(tertiaryTitle))" = "Terciari (\\(tertiaryTitle))"; "The default Codex account on this Mac." = "El compte de Codex per defecte en aquest Mac."; "Toggle" = "Commutador"; "Toggle subtitle" = "Subtítol del commutador"; -"Token" = "Testimoni"; -"Trigger the menu bar menu from anywhere." = "Obre el menú de la barra de menús des de qualsevol lloc."; +"Token" = "Token"; +"Trigger the menu bar menu from anywhere." = "Obriu el menú de la barra de menús des de qualsevol lloc."; "True" = "Cert"; "Twitter" = "Twitter"; "Unsupported" = "No compatible"; @@ -322,19 +336,20 @@ "Usage breakdown" = "Desglossament d'ús"; "Usage history (30 days)" = "Historial d'ús"; "Usage source" = "Origen de l'ús"; -"Use BigModel for the China mainland endpoints (open.bigmodel.cn)." = "Fes servir BigModel per als endpoints de la Xina continental (open.bigmodel.cn)."; -"Use a single menu bar icon with a provider switcher." = "Fes servir una sola icona a la barra de menús amb un selector de proveïdor."; -"Use international or China mainland console gateways for quota fetches." = "Fes servir les passarel·les de consola internacionals o de la Xina continental per obtenir la quota."; +"Use Account" = "Utilitza el compte"; +"Use BigModel for the China mainland endpoints (open.bigmodel.cn)." = "Feu servir BigModel per als endpoints de la Xina continental (open.bigmodel.cn)."; +"Use a single menu bar icon with a provider switcher." = "Feu servir una sola icona a la barra de menús amb un selector de proveïdor."; +"Use international or China mainland console gateways for quota fetches." = "Feu servir les passarel·les de consola internacionals o de la Xina continental per obtenir la quota."; "Version" = "Versió"; "Version \\(self.versionString)" = "Versió \\(self.versionString)"; "Version \\(version)" = "Versió \\(version)"; "Version \\(versionString)" = "Versió \\(versionString)"; "Vertex AI Login" = "Inici de sessió de Vertex AI"; -"Wait for the current managed Codex login to finish before adding another account." = "Espera que acabi l'inici de sessió gestionat de Codex actual abans d'afegir un altre compte."; +"Wait for the current managed Codex login to finish before adding another account." = "Espereu que acabi l'inici de sessió gestionat de Codex actual abans d'afegir un altre compte."; "Waiting for Authentication..." = "S'està esperant l'autenticació..."; "Website" = "Lloc web"; "Weekly limit confetti" = "Confeti del límit setmanal"; -"Weekly token limit" = "Límit setmanal de testimonis"; +"Weekly token limit" = "Límit setmanal de tokens"; "Weekly usage" = "Ús setmanal"; "Weekly usage unavailable for this account." = "Ús setmanal no disponible per a aquest compte."; "Window: \\(window)" = "Finestra: \\(window)"; @@ -350,11 +365,11 @@ "all browsers" = "tots els navegadors"; "available again." = "disponible de nou."; "built_format" = "Compilació %@"; -"copilot_complete_in_browser" = "Completa l'inici de sessió al teu navegador."; -"copilot_device_code" = "Codi de dispositiu copiat al porta-retalls: %1$@\n\nVerifica'l a: %2$@"; +"copilot_complete_in_browser" = "Completeu l'inici de sessió al vostre navegador."; +"copilot_device_code" = "Codi de dispositiu copiat al porta-retalls: %1$@\n\nVerifiqueu-lo a: %2$@"; "copilot_device_code_copied" = "Codi de dispositiu copiat."; -"copilot_verify_at" = "Verifica'l a %@"; -"copilot_waiting_text" = "Completa l'inici de sessió al teu navegador.\nAquesta finestra es tanca automàticament quan finalitza l'inici de sessió."; +"copilot_verify_at" = "Verifiqueu-lo a %@"; +"copilot_waiting_text" = "Completeu l'inici de sessió al vostre navegador.\nAquesta finestra es tanca automàticament quan finalitza l'inici de sessió."; "copilot_window_closes_auto" = "Aquesta finestra es tanca automàticament quan finalitza l'inici de sessió."; "cost_status_error" = "%1$@: %2$@"; "cost_status_fetching" = "%1$@: s'està obtenint… %2$@"; @@ -366,8 +381,8 @@ "cursor_on_demand" = "Sota demanda: %@"; "cursor_on_demand_with_limit" = "Sota demanda: %1$@ / %2$@"; "extra_usage_format" = "Ús addicional: %1$@ / %2$@"; -"jetbrains_detected_generate" = "Detectat: %@. Fes servir l'assistent d'IA una vegada per generar dades de quota i després actualitza el CodexBar."; -"jetbrains_detected_select" = "Detectat: %@. Selecciona el teu IDE preferit a la configuració i després actualitza el CodexBar."; +"jetbrains_detected_generate" = "Detectat: %@. Feu servir l'assistent d'IA una vegada per generar dades de quota i després actualitzeu el CodexBar."; +"jetbrains_detected_select" = "Detectat: %@. Seleccioneu el vostre IDE preferit a la configuració i després actualitzeu el CodexBar."; "last_fetch_failed_with_provider" = "L'última obtenció de %@ ha fallat:"; "last_spend" = "Última despesa: %@"; "mcp_model_usage" = "%1$@: %2$@"; @@ -377,21 +392,31 @@ "metric_primary" = "Principal (%@)"; "metric_secondary" = "Secundari (%@)"; "metric_tertiary" = "Terciari (%@)"; -"multiple_workspaces_found" = "El CodexBar ha trobat diversos espais de treball per a %@. Tria l'espai de treball que vols afegir."; +"multiple_workspaces_found" = "El CodexBar ha trobat diversos espais de treball per a %@. Trieu l'espai de treball que voleu afegir."; "ory_session_…=…; csrftoken=…" = "ory_session_…=…; csrftoken=…"; -"overview_choose_providers" = "Tria fins a %@ proveïdors"; -"remove_account_message" = "Vols eliminar %@ del CodexBar? El seu directori Codex gestionat s'esborrarà."; +"overview_choose_providers" = "Trieu fins a %@ proveïdors"; +"remove_account_message" = "Voleu eliminar %@ del CodexBar? El seu directori Codex gestionat s'esborrarà."; "version_format" = "Versió %@"; -"vertex_ai_login_instructions" = "Per fer un seguiment de l'ús de Vertex AI, autentica't amb Google Cloud.\n\n1. Obre el Terminal\n2. Executa: gcloud auth application-default login\n3. Segueix les indicacions del navegador per iniciar la sessió\n4. Defineix el teu projecte: gcloud config set project PROJECT_ID\n\nVols obrir el Terminal ara?"; +"vertex_ai_login_instructions" = "Per fer un seguiment de l'ús de Vertex AI, autentiqueu-vos amb Google Cloud.\n\n1. Obriu el Terminal\n2. Executeu: gcloud auth application-default login\n3. Seguiu les indicacions del navegador per iniciar la sessió\n4. Definiu el vostre projecte: gcloud config set project PROJECT_ID\n\nVoleu obrir el Terminal ara?"; "workspaceID is set but only opencode, opencodego, and deepgram support workspaceID." = "workspaceID està definit, però només opencode, opencodego i deepgram admeten workspaceID."; "© 2026 Peter Steinberger. MIT License." = "© 2026 Peter Steinberger. Llicència MIT."; /* General Pane */ "section_system" = "Sistema"; "section_usage" = "Ús"; -"section_automation" = "Automatització"; +"section_refreshing" = "Actualització"; +"section_alerts" = "Avisos"; +"section_celebrations" = "Celebracions"; +"section_icon" = "Icona"; +"section_combined_icon" = "Icona combinada"; +"section_animation" = "Animació"; +"section_content" = "Contingut"; +"section_agent_sessions" = "Sessions d'agents"; "language_title" = "Idioma"; "language_subtitle" = "Canvia l'idioma de la interfície. Cal reiniciar l'app perquè s'apliqui completament."; +"currency_title" = "Moneda preferida"; +"currency_subtitle" = "Moneda per a estimacions de cost i despeses. Utilitza tipus de canvi actualitzats diàriament."; +"currency_auto" = "Automàtic (segons el proveïdor / USD)"; "language_system" = "Sistema"; "language_english" = "English"; "language_spanish" = "Español"; @@ -399,133 +424,200 @@ "language_chinese_simplified" = "简体中文"; "language_chinese_traditional" = "繁體中文"; "language_portuguese_brazilian" = "Português (Brasil)"; +"language_swedish" = "Suec"; +"language_dutch" = "Nederlands"; +"language_german" = "Deutsch"; +"language_french" = "Francès"; +"language_ukrainian" = "Ucraïnès"; +"language_russian" = "Русский"; +"language_japanese" = "Japonès"; +"language_korean" = "Coreà"; +"language_italian" = "Italiano"; +"language_polish" = "Polski"; "start_at_login_title" = "Obrir en iniciar la sessió"; "start_at_login_subtitle" = "Obre el CodexBar automàticament en iniciar el Mac."; -"show_cost_summary" = "Mostra el resum de cost"; "show_cost_summary_subtitle" = "Llegeix els registres d'ús locals. Mostra el cost d'avui + la finestra d'historial seleccionada al menú."; +"cost_summary_style_title" = "Estil de visualització"; +"cost_summary_style_inline" = "Només integrat"; +"cost_summary_style_submenu" = "Només submenú"; +"cost_summary_style_both" = "Tots dos"; +"cost_summary_style_inline_help" = "Mostra el resum de cost directament al menú principal."; +"cost_summary_style_submenu_help" = "Mostra el submenú Cost detallat en lloc d'això."; +"cost_summary_style_both_help" = "Mostra el resum del menú principal i el submenú Cost detallat."; +"cost_history_window_title" = "Finestra d'historial"; +"cost_history_window_help" = "Defineix quants dies de registres d'ús locals apareixen al menú."; "cost_history_days_title" = "Finestra d'historial: %d dies"; -"cost_auto_refresh_info" = "Actualització automàtica: cada hora · Temps d'espera: 10 m"; -"refresh_cadence_title" = "Freqüència d'actualització"; -"refresh_cadence_subtitle" = "Amb quina freqüència el CodexBar consulta els proveïdors en segon pla."; -"manual_refresh_hint" = "L'actualització automàtica està desactivada; fes servir l'ordre Actualitza del menú."; -"check_provider_status_title" = "Comprova l'estat del proveïdor"; +"cost_auto_refresh_info" = "Actualització automàtica: interval global (mínim 5 min) · Temps d'espera: 10 min"; +"cost_comparison_periods_title" = "Mostra períodes de comparació més curts"; +"cost_comparison_periods_subtitle" = "Afegeix totals de 7, 30 i 90 dies quan càpiguen dins l'interval d'historial seleccionat. Aquests totals reutilitzen la mateixa exploració local."; +"refresh_interval_title" = "Interval d'actualització"; +"manual_refresh_hint" = "L'actualització automàtica està desactivada; feu servir l'ordre Actualitza del menú."; +"refresh_on_open_title" = "Actualitza en obrir el menú"; +"refresh_on_open_subtitle" = "Obté l'ús més recent de cada proveïdor cada vegada que obriu el menú."; +"check_provider_status_title" = "Comproveu l'estat del proveïdor"; "check_provider_status_subtitle" = "Consulta les pàgines d'estat d'OpenAI/Claude i Google Workspace per a Gemini/Antigravity, mostrant incidències a la icona i al menú."; -"session_quota_notifications_title" = "Notificacions de quota de sessió"; -"session_quota_notifications_subtitle" = "Avisa quan la quota de sessió de 5 hores arriba al 0 % i quan torna a estar disponible."; -"quota_warning_notifications_title" = "Notificacions d'avís de quota"; -"quota_warning_notifications_subtitle" = "Avisa quan la quota restant de sessió o setmanal supera els llindars configurats."; +"session_quota_notifications_subtitle" = "Avisa quan la quota de sessió de 5 hores arriba al 0% i quan torna a estar disponible."; +"quota_depleted_title" = "Quota esgotada i restablerta"; +"quota_warning_notifications_subtitle" = "Avisa quan la quota restant de sessió o setmanal baixa per sota dels llindars configurats."; +"threshold_warnings_title" = "Avisos de llindar"; "quota_warnings_title" = "Avisos de quota"; "quota_warning_session" = "sessió"; "quota_warning_session_capitalized" = "Sessió"; "quota_warning_weekly" = "setmanal"; "quota_warning_weekly_capitalized" = "Setmanal"; -"quota_warning_warn_at" = "Avisa al"; +"quota_warning_warn_at" = "Aviseu al"; "quota_warning_global_threshold_subtitle" = "Percentatges restants per a les finestres de sessió i setmanal, llevat que un proveïdor els substitueixi."; "quota_warning_sound" = "Reprodueix el so de notificació"; +"quota_warning_onscreen_alert" = "Mostra una alerta de text a la pantalla"; "quota_warning_provider_inherits" = "Fa servir la configuració global d'avís de quota llevat que es personalitzi una finestra aquí."; +"quota_warning_provider_disabled" = "Les notificacions d'avís de quota i els marcadors de les barres d'ús estan desactivats. Activeu una de les dues opcions per editar aquesta configuració desada."; +"quota_warning_provider_markers_only" = "Les notificacions d'avís de quota estan desactivades globalment. Aquesta configuració encara controla els marcadors de les barres d'ús."; +"quota_warning_global" = "Global"; "quota_warning_customize_thresholds" = "Personalitza els llindars de %@"; -"quota_warning_enable_warnings" = "Activa els avisos de %@"; +"quota_warning_enable_warnings" = "Activeu els avisos de %@"; "quota_warning_window_warn_at" = "%@ avisa al"; "quota_warning_off" = "Desactivat"; "quota_warning_inherited" = "Heretat: %@"; "quota_warning_depleted_only" = "només esgotat"; -"quota_warning_upper" = "Superior"; +"quota_warning_upper" = "Més alt"; "quota_warning_lower" = "Inferior"; -"apply" = "Aplica"; -"quit_app" = "Surt del CodexBar"; +"quota_warning_warning" = "Avís"; +"quota_warning_critical" = "Crític"; +"apply" = "Apliqueu"; +"quit_app" = "Sortiu del CodexBar"; /* Tab titles */ "tab_general" = "General"; "tab_providers" = "Proveïdors"; -"tab_display" = "Pantalla"; +"tab_notifications" = "Notificacions"; +"tab_menu_bar" = "Barra de menús"; +"tab_menu" = "Menú"; "tab_advanced" = "Avançat"; +"tab_hooks" = "Hooks"; "tab_about" = "Quant a"; + +/* Hooks Pane */ +"hooks_enable_title" = "Activa els hooks"; +"hooks_enable_subtitle" = "Executa ordres externes quan es produeixen esdeveniments de quota o de proveïdor."; +"hooks_trust_warning" = "Els hooks poden executar ordres locals al teu Mac. Configura només ordres en què confiïs."; +"hooks_rules_header" = "Regles"; +"hooks_empty" = "No hi ha cap hook configurat."; +"hooks_add_rule" = "Afegeix una regla"; +"hooks_delete_rule" = "Elimina la regla"; +"hooks_rule_enabled" = "Activat"; +"hooks_event" = "Esdeveniment"; +"hooks_provider" = "Proveïdor"; +"hooks_any_provider" = "Qualsevol proveïdor"; +"hooks_threshold" = "Activa amb ús ≥"; +"hooks_threshold_placeholder" = "90"; +"hooks_executable_placeholder" = "/usr/local/bin/my-command"; +"hooks_arguments_placeholder" = "Arguments"; +"hooks_argument_placeholder" = "Argument"; +"hooks_add_argument" = "Afegeix un argument"; +"hooks_delete_argument" = "Elimina l'argument"; "tab_debug" = "Depuració"; /* Providers Pane */ -"select_a_provider" = "Selecciona un proveïdor"; -"cancel" = "Cancel·la"; +"select_a_provider" = "Seleccioneu un proveïdor"; +"cancel" = "Cancel·leu"; "last_fetch_failed" = "l'última obtenció ha fallat"; "usage_not_fetched_yet" = "encara no s'ha obtingut l'ús"; -"managed_account_storage_unreadable" = "L'emmagatzematge de comptes gestionats no es pot llegir. L'accés a comptes en directe encara està disponible, però les accions d'afegir, reautenticar i eliminar comptes gestionats estan desactivades fins que el magatzem es pugui recuperar."; -"remove_codex_account_title" = "Vols eliminar el compte de Codex?"; -"remove" = "Elimina"; -"managed_login_already_running" = "Ja hi ha un inici de sessió gestionat de Codex en curs. Espera que acabi abans d'afegir o reautenticar un altre compte."; -"managed_login_failed" = "L'inici de sessió gestionat de Codex no s'ha completat. Comprova que `codex --version` funciona al Terminal. Si macOS ha bloquejat o ha mogut `codex` a la Paperera, elimina les instal·lacions duplicades obsoletes, executa `npm install -g --include=optional @openai/codex@latest` i torna-ho a provar."; +"managed_account_storage_unreadable" = "L'emmagatzematge de comptes gestionats no es pot llegir. L'accés a comptes en directe encara està disponible, però les accions d'afegir, reautenticar i eliminar comptes gestionats estan desactivades fins que l'emmagatzematge es pugui recuperar."; +"remove_codex_account_title" = "Voleu eliminar el compte de Codex?"; +"remove" = "Elimineu"; +"managed_login_already_running" = "Ja hi ha un inici de sessió gestionat de Codex en curs. Espereu que acabi abans d'afegir o reautenticar un altre compte."; +"managed_login_failed" = "L'inici de sessió gestionat de Codex no s'ha completat. Comproveu que `codex --version` funciona al Terminal. Si macOS ha bloquejat o ha mogut `codex` a la Paperera, elimineu les instal·lacions duplicades obsoletes, executeu `npm install -g --include=optional @openai/codex@latest` i torneu-ho a provar."; "codex_login_output" = "Sortida de codex login:"; -"managed_login_missing_email" = "L'inici de sessió de Codex s'ha completat, però no hi havia cap correu de compte disponible. Torna-ho a provar després de confirmar que el compte té la sessió totalment iniciada."; +"managed_login_missing_email" = "L'inici de sessió de Codex s'ha completat, però no hi havia cap correu de compte disponible. Torneu-ho a provar després de confirmar que el compte té la sessió totalment iniciada."; "workspace_selection_cancelled" = "El CodexBar ha trobat diversos espais de treball, però no se n'ha seleccionat cap."; "unsafe_managed_home" = "El CodexBar s'ha negat a modificar un camí de directori gestionat inesperat: %@"; "menu_bar_metric_title" = "Mètrica de la barra de menús"; -"menu_bar_metric_subtitle" = "Tria quina finestra determina el percentatge de la barra de menús."; +"menu_bar_metric_subtitle" = "Trieu quina finestra determina el percentatge de la barra de menús."; "menu_bar_metric_subtitle_deepseek" = "Mostra el saldo de DeepSeek a la barra de menús."; "menu_bar_metric_subtitle_moonshot" = "Mostra el saldo de l'API de Moonshot / Kimi a la barra de menús."; -"menu_bar_metric_subtitle_mistral" = "Mostra la despesa de l'API de Mistral del mes actual a la barra de menús."; -"menu_bar_metric_subtitle_kimik2" = "Mostra els crèdits de la clau d'API de Kimi K2 a la barra de menús."; +"menu_bar_metric_subtitle_mistral" = "Trieu entre la despesa de l'API de Mistral i l'ús del Monthly Plan per a la barra de menús."; "automatic" = "Automàtic"; "primary_api_key_limit" = "Principal (límit de la clau d'API)"; /* Display Pane */ -"section_menu_bar" = "Barra de menús"; +"menu_bar_style_title" = "Estil de la barra de menús"; +"menu_bar_style_subtitle" = "Com es dibuixa l'element de la barra de menús."; +"menu_bar_inactive_display_contrast_title" = "Millora la visibilitat a les pantalles inactives"; +"menu_bar_usage_colors_title" = "Ús amb codi de colors"; +"menu_bar_usage_colors_subtitle" = "Acoloreix la icona de la barra de menús de verd a vermell a mesura que augmenta l'ús."; +"menu_bar_inactive_display_contrast_subtitle" = "Utilitza una representació d'alt contrast perquè la icona i la mètrica siguin llegibles a les altres pantalles."; +"menu_bar_style_critters" = "Bestioles"; +"menu_bar_style_bars" = "Barres de mesura"; +"menu_bar_style_icon_percent" = "Icona i percentatge"; +"switcher_rows_title" = "Files del selector"; +"switcher_rows_icons" = "Icones de proveïdor"; +"switcher_rows_progress" = "Progrés setmanal"; +"usage_bars_fill_title" = "Ompliment de les barres d'ús"; +"usage_bars_fill_remaining" = "Com a restant"; +"usage_bars_fill_used" = "Com a consumit"; +"reset_times_title" = "Hores de reinici"; +"reset_times_countdown" = "Compte enrere"; +"reset_times_clock" = "Hora del rellotge"; +"cost_summary_title" = "Resum de cost"; +"cost_summary_off" = "Desactivat"; "merge_icons_title" = "Combina les icones"; -"merge_icons_subtitle" = "Fes servir una sola icona a la barra de menús amb un selector de proveïdor."; -"switcher_shows_icons_title" = "El selector mostra icones"; -"switcher_shows_icons_subtitle" = "Mostra les icones de proveïdor al selector (si no, mostra una línia de progrés setmanal)."; -"show_most_used_provider_title" = "Mostra el proveïdor més utilitzat"; +"merge_icons_subtitle" = "Feu servir una sola icona a la barra de menús amb un selector de proveïdor."; +"show_most_used_provider_title" = "Mostreu el proveïdor més utilitzat"; "show_most_used_provider_subtitle" = "La barra de menús mostra automàticament el proveïdor més a prop del seu límit."; -"menu_bar_shows_percent_title" = "La barra de menús mostra el percentatge"; -"menu_bar_shows_percent_subtitle" = "Substitueix les barres de bestioles per icones de marca del proveïdor i un percentatge."; "display_mode_title" = "Mode de visualització"; -"display_mode_subtitle" = "Tria què es mostra a la barra de menús (Ritme mostra l'ús respecte al previst)."; -"section_menu_content" = "Contingut del menú"; -"show_usage_as_used_title" = "Mostra l'ús com a consumit"; -"show_usage_as_used_subtitle" = "Les barres de progrés s'omplen a mesura que consumeixes la quota (en comptes de mostrar el que queda)."; -"show_quota_warning_markers_title" = "Mostra els marcadors d'avís de quota"; +"display_mode_subtitle" = "Trieu què es mostra a la barra de menús (Ritme mostra l'ús respecte al previst)."; +"show_quota_warning_markers_title" = "Mostreu els marcadors d'avís de quota"; "show_quota_warning_markers_subtitle" = "Dibuixa marques de llindar a les barres d'ús quan hi ha avisos de quota configurats."; -"show_reset_time_as_clock_title" = "Mostra l'hora de reinici com a rellotge"; -"show_reset_time_as_clock_subtitle" = "Mostra les hores de reinici com a valors de rellotge absoluts en comptes de comptes enrere."; -"show_provider_changelog_links_title" = "Mostra els enllaços al registre de canvis del proveïdor"; -"show_provider_changelog_links_subtitle" = "Afegeix al menú enllaços a les notes de versió dels proveïdors compatibles basats en CLI."; -"show_credits_extra_usage_title" = "Mostra crèdits + ús addicional"; -"show_credits_extra_usage_subtitle" = "Mostra les seccions de Crèdits de Codex i Ús addicional de Claude al menú."; -"show_all_token_accounts_title" = "Mostra tots els comptes amb testimoni"; -"show_all_token_accounts_subtitle" = "Apila els comptes amb testimoni al menú (si no, mostra una barra de canvi de compte)."; +"weekly_progress_work_days_title" = "Dies laborables del progrés setmanal"; +"weekly_progress_work_days_subtitle" = "Definiu els dies laborables per als marcadors de les barres d'ús setmanal i els càlculs de ritme."; +"show_provider_changelog_links_title" = "Mostreu els enllaços al registre de canvis del proveïdor"; +"show_provider_changelog_links_subtitle" = "Afegiu al menú enllaços a les notes de versió dels proveïdors compatibles basats en CLI."; +"show_credits_extra_usage_title" = "Mostreu crèdits + ús addicional"; +"show_credits_extra_usage_subtitle" = "Mostreu les seccions de Crèdits de Codex i Ús addicional de Claude al menú."; "multi_account_layout_title" = "Disposició multicompte"; -"multi_account_layout_subtitle" = "Tria el canvi de compte segmentat o targetes de compte apilades."; +"multi_account_layout_subtitle" = "Trieu el canvi de compte segmentat o targetes de compte apilades."; "multi_account_layout_segmented" = "Segmentat"; "multi_account_layout_stacked" = "Apilat"; "overview_tab_providers_title" = "Proveïdors de la pestanya Resum"; -"configure" = "Configura…"; -"overview_enable_merge_icons_hint" = "Activa Combina les icones per configurar els proveïdors de la pestanya Resum."; +"configure" = "Configureu…"; +"overview_enable_merge_icons_hint" = "Activeu Combina les icones per configurar els proveïdors de la pestanya Resum."; "overview_no_providers_hint" = "No hi ha proveïdors activats disponibles per al Resum."; "overview_rows_follow_order" = "Les files del Resum sempre segueixen l'ordre dels proveïdors."; "overview_no_providers_selected" = "No hi ha cap proveïdor seleccionat"; +"agent_sessions_title" = "Sessions d'agents"; +"agent_sessions_subtitle" = "Mostreu al menú les sessions locals i descobertes per SSH de Codex i Claude Code."; +"agent_sessions_hosts_title" = "Amfitrions SSH addicionals"; +"agent_sessions_footer" = "Els Mac de la vostra tailnet es descobreixen automàticament. Les sessions locals s'actualitzen cada 30 segons; els amfitrions remots, cada 60 segons i quan s'obre el menú."; +"agent_session_labels_title" = "Etiquetes de sessió"; +"agent_session_labels_subtitle" = "Trieu com s'anomenen les sessions d'agents."; +"agent_session_label_project" = "Projecte"; +"agent_session_label_descriptive" = "Descriptiva"; +"agent_session_label_descriptive_and_project" = "Descriptiva + projecte"; +"agent_session_unknown_project" = "Projecte desconegut"; /* Advanced Pane */ "section_keyboard_shortcut" = "Drecera de teclat"; -"open_menu_shortcut_title" = "Obre el menú"; -"open_menu_shortcut_subtitle" = "Obre el menú de la barra de menús des de qualsevol lloc."; +"open_menu_shortcut_title" = "Obriu el menú"; +"open_menu_shortcut_subtitle" = "Obriu el menú de la barra de menús des de qualsevol lloc."; "install_cli" = "Instal·la la CLI"; -"install_cli_subtitle" = "Crea un enllaç simbòlic de CodexBarCLI a /usr/local/bin i /opt/homebrew/bin com a codexbar."; +"install_cli_subtitle" = "Creeu un enllaç simbòlic de CodexBarCLI a /usr/local/bin i /opt/homebrew/bin com a codexbar."; "cli_not_found" = "No s'ha trobat CodexBarCLI al paquet de l'app."; "no_writable_bin_dirs" = "No s'han trobat directoris bin amb permís d'escriptura."; -"show_debug_settings_title" = "Mostra la configuració de depuració"; -"show_debug_settings_subtitle" = "Mostra eines de diagnòstic a la pestanya Depuració."; +"show_debug_settings_title" = "Mostreu la configuració de depuració"; +"show_debug_settings_subtitle" = "Mostreu eines de diagnòstic a la pestanya Depuració."; "surprise_me_title" = "Sorprèn-me"; -"surprise_me_subtitle" = "Activa-ho si t'agrada que els teus agents es diverteixin allà dalt."; -"weekly_limit_confetti_title" = "Confeti del límit setmanal"; -"weekly_limit_confetti_subtitle" = "Mostra confeti a pantalla completa quan es reinicia l'ús setmanal."; -"hide_personal_info_title" = "Amaga la informació personal"; -"hide_personal_info_subtitle" = "Amaga les adreces de correu a la barra de menús i a la interfície del menú."; -"show_provider_storage_usage_title" = "Mostra l'ús d'emmagatzematge del proveïdor"; -"show_provider_storage_usage_subtitle" = "Mostra l'ús de disc local als menús. Analitza en segon pla els camins coneguts del proveïdor."; +"surprise_me_subtitle" = "Activeu-ho si us agrada que els vostres agents es diverteixin allà dalt."; +"hide_personal_info_title" = "Amagueu la informació personal"; +"hide_personal_info_subtitle" = "Amagueu les adreces de correu a la barra de menús i a la interfície del menú."; +"show_provider_storage_usage_title" = "Mostreu l'ús d'emmagatzematge del proveïdor"; +"show_provider_storage_usage_subtitle" = "Mostreu l'ús de disc local als menús. Analitza en segon pla els camins coneguts del proveïdor."; "section_keychain_access" = "Accés al Clauer"; -"keychain_access_caption" = "Desactiva totes les lectures i escriptures del Clauer. La importació de galetes del navegador no estarà disponible; enganxa les capçaleres Cookie manualment a Proveïdors."; -"disable_keychain_access_title" = "Desactiva l'accés al Clauer"; +"keychain_access_caption" = "Desactiveu totes les lectures i escriptures del Clauer. Feu-ho si macOS continua mostrant sol·licituds de «Chrome/Brave/Edge Safe Storage» fins i tot després de triar «Permet sempre». La importació de galetes del navegador no estarà disponible mentre aquesta opció estigui activada; enganxeu manualment les capçaleres Cookie a Proveïdors. L'OAuth de Claude/Codex mitjançant la CLI continuarà funcionant."; +"disable_keychain_access_title" = "Desactiveu l'accés al Clauer"; "disable_keychain_access_subtitle" = "Impedeix qualsevol accés al Clauer mentre estigui activat."; /* About Pane */ -"about_tagline" = "Que els teus testimonis no s'esgotin mai: mantén els límits dels teus agents a la vista."; +"about_tagline" = "Que els vostres tokens no s'esgotin mai: mantingueu els límits dels vostres agents a la vista."; "link_github" = "GitHub"; "link_website" = "Lloc web"; "link_twitter" = "Twitter"; @@ -538,51 +630,51 @@ /* Debug Pane */ "section_logging" = "Registre"; -"enable_file_logging" = "Activa el registre en fitxer"; +"enable_file_logging" = "Activeu el registre en fitxer"; "enable_file_logging_subtitle" = "Escriu els registres a %@ per a la depuració."; "verbosity_title" = "Nivell de detall"; "verbosity_subtitle" = "Controla quant detall es registra."; -"open_log_file" = "Obre el fitxer de registre"; -"force_animation_next_refresh" = "Força l'animació a la propera actualització"; -"force_animation_next_refresh_subtitle" = "Mostra temporalment l'animació de càrrega després de la propera actualització."; +"open_log_file" = "Obriu el fitxer de registre"; +"force_animation_next_refresh" = "Forceu l'animació a la pròxima actualització"; +"force_animation_next_refresh_subtitle" = "Mostra temporalment l'animació de càrrega després de la pròxima actualització."; "section_loading_animations" = "Animacions de càrrega"; -"loading_animations_caption" = "Tria un patró i reprodueix-lo a la barra de menús. «Aleatori» manté el comportament actual."; +"loading_animations_caption" = "Trieu un patró i reproduïu-lo a la barra de menús. «Aleatori» manté el comportament actual."; "animation_random_default" = "Aleatori (per defecte)"; "replay_selected_animation" = "Reprodueix l'animació seleccionada"; "blink_now" = "Parpelleja ara"; "section_probe_logs" = "Registres de sondeig"; -"probe_logs_caption" = "Obté la sortida de sondeig més recent per a la depuració; Copia conserva el text complet."; -"fetch_log" = "Obtén el registre"; +"probe_logs_caption" = "Obté la sortida de sondeig més recent per a la depuració; l'opció Copia conserva el text complet."; +"fetch_log" = "Obtingueu el registre"; "copy" = "Copia"; -"save_to_file" = "Desa en un fitxer"; -"load_parse_dump" = "Carrega l'abocament d'anàlisi"; -"rerun_provider_autodetect" = "Torna a executar l'autodetecció de proveïdors"; +"save_to_file" = "Deseu en un fitxer"; +"load_parse_dump" = "Carregueu l'abocament d'anàlisi"; +"rerun_provider_autodetect" = "Torneu a executar l'autodetecció de proveïdors"; "loading" = "S'està carregant…"; -"no_log_yet_fetch" = "Encara no hi ha registre. Obtén per carregar-lo."; +"no_log_yet_fetch" = "Encara no hi ha registre. Obtingueu per carregar-lo."; "section_fetch_strategy" = "Intents d'estratègia d'obtenció"; "fetch_strategy_caption" = "Últimes decisions i errors del flux d'obtenció d'un proveïdor."; "section_openai_cookies" = "Galetes d'OpenAI"; "openai_cookies_caption" = "Registres d'importació de galetes i extracció amb WebKit de l'últim intent de galetes d'OpenAI."; -"no_log_yet" = "Encara no hi ha registre. Actualitza les galetes d'OpenAI a Proveïdors → Codex per executar una importació."; +"no_log_yet" = "Encara no hi ha registre. Actualitzeu les galetes d'OpenAI a Proveïdors → Codex per executar una importació."; "section_caches" = "Memòries cau"; -"caches_caption" = "Esborra els resultats d'anàlisi de cost a la memòria cau o les memòries cau de galetes del navegador."; -"clear_cookie_cache" = "Esborra la memòria cau de galetes"; -"clear_cost_cache" = "Esborra la memòria cau de cost"; +"caches_caption" = "Esborreu els resultats d'anàlisi de cost a la memòria cau o les memòries cau de galetes del navegador."; +"clear_cookie_cache" = "Esborreu la memòria cau de galetes"; +"clear_cost_cache" = "Esborreu la memòria cau de cost"; "section_notifications" = "Notificacions"; -"notifications_caption" = "Llança notificacions de prova per a la finestra de sessió de 5 hores (esgotada/restaurada)."; +"notifications_caption" = "Llanceu notificacions de prova per a la finestra de sessió de 5 hores (esgotada/restaurada)."; "post_depleted" = "Envia esgotada"; "post_restored" = "Envia restaurada"; "section_cli_sessions" = "Sessions de la CLI"; -"cli_sessions_caption" = "Mantén actives les sessions de la CLI de Codex/Claude després d'un sondeig. Per defecte es tanquen quan es capturen les dades."; -"keep_cli_sessions_alive" = "Mantén actives les sessions de la CLI"; -"keep_cli_sessions_alive_subtitle" = "Omet el tancament entre sondeigs (només depuració)."; -"reset_cli_sessions" = "Reinicia les sessions de la CLI"; +"cli_sessions_caption" = "Mantingueu actives les sessions de la CLI de Codex/Claude després d'un sondeig. Per defecte es tanquen quan es capturen les dades."; +"keep_cli_sessions_alive" = "Mantingueu actives les sessions de la CLI"; +"keep_cli_sessions_alive_subtitle" = "Ometeu el tancament entre sondeigs (només depuració)."; +"reset_cli_sessions" = "Reinicieu les sessions de la CLI"; "section_error_simulation" = "Simulació d'errors"; "error_simulation_caption" = "Injecta un missatge d'error fals a la targeta del menú per provar la disposició."; "set_menu_error" = "Estableix l'error de menú"; -"clear_menu_error" = "Esborra l'error de menú"; +"clear_menu_error" = "Esborreu l'error de menú"; "set_cost_error" = "Estableix l'error de cost"; -"clear_cost_error" = "Esborra l'error de cost"; +"clear_cost_error" = "Esborreu l'error de cost"; "section_cli_paths" = "Camins de la CLI"; "cli_paths_caption" = "Binari de Codex resolt i capes de PATH; captura del PATH d'inici de sessió a l'arrencada (temps d'espera curt)."; "codex_binary" = "Binari de Codex"; @@ -592,7 +684,7 @@ "login_shell_path" = "PATH del shell d'inici de sessió (captura a l'arrencada)"; "cleared" = "Esborrat."; "no_fetch_attempts" = "Encara no hi ha intents d'obtenció."; -"macOS Tahoe can block menu bar apps in System Settings → Menu Bar → Allow in the Menu Bar. CodexBar is running, but macOS may be hiding its icon. Open Menu Bar settings and turn CodexBar on." = "macOS Tahoe pot bloquejar les apps de la barra de menús a Configuració del Sistema → Barra de menús → Permet a la barra de menús. El CodexBar s'està executant, però macOS podria estar amagant-ne la icona. Obre la configuració de la barra de menús i activa el CodexBar."; +"macOS Tahoe can block menu bar apps in System Settings → Menu Bar → Allow in the Menu Bar. CodexBar is running, but macOS may be hiding its icon. Open Menu Bar settings and turn CodexBar on." = "macOS Tahoe pot bloquejar les apps de la barra de menús a Configuració del Sistema → Barra de menús → Permet a la barra de menús. El CodexBar s'està executant, però macOS podria estar amagant-ne la icona. Obriu la configuració de la barra de menús i activeu el CodexBar."; /* Metric preferences */ "metric_pref_automatic" = "Automàtic"; @@ -601,17 +693,24 @@ "metric_pref_tertiary" = "Terciari"; "metric_pref_extra_usage" = "Ús addicional"; "metric_pref_average" = "Mitjana"; +"metric_mistral_payg" = "Pay-as-you-go"; +"metric_mistral_monthly_plan" = "Monthly Plan"; /* Display modes */ "display_mode_percent" = "Percentatge"; "display_mode_pace" = "Ritme"; "display_mode_both" = "Tots dos"; -"display_mode_percent_desc" = "Mostra el percentatge restant/usat (p. ex. 45 %)"; -"display_mode_pace_desc" = "Mostra l'indicador de ritme (p. ex. +5 %)"; -"display_mode_both_desc" = "Mostra el percentatge i el ritme (p. ex. 45 % · +5 %)"; +"display_mode_reset_time" = "Temps de reinici"; +"display_mode_percent_desc" = "Mostreu el percentatge restant/usat (p. ex. 45%)"; +"display_mode_pace_desc" = "Mostreu l'indicador de ritme (p. ex. +5%)"; +"display_mode_both_desc" = "Mostreu el percentatge i el ritme (p. ex. 45% · +5%)"; +"display_mode_reset_time_desc" = "Mostreu l'hora de reinici de la mètrica seleccionada (p. ex. ↻ 3:56 PM)"; +"menu_bar_reset_when_exhausted_title" = "Mostra l'hora de restabliment quan s'esgoti la quota"; +"menu_bar_reset_when_exhausted_subtitle" = "Amb un 0% restant, mostra el temps fins al restabliment en lloc del percentatge"; /* Provider status */ "status_operational" = "Operatiu"; +"status_degraded" = "Rendiment degradat"; "status_partial_outage" = "Interrupció parcial"; "status_major_outage" = "Interrupció greu"; "status_critical_issue" = "Problema crític"; @@ -625,17 +724,26 @@ "refresh_5min" = "5 min"; "refresh_15min" = "15 min"; "refresh_30min" = "30 min"; +"refresh_adaptive" = "Adaptatiu"; +"refresh_adaptive_agent_aware" = "Adaptatiu (activitat dels agents)"; +"adaptive_activity_consent_title" = "Voleu permetre l’actualització segons l’activitat?"; +"adaptive_activity_consent_message" = "El mode Adaptatiu segons l’activitat dels agents pot inspeccionar la llista de processos locals en execució, incloses les línies d’ordres, per identificar Codex i Claude, i llegir les metadades de sessions conegudes cada 30 segons mentre programeu. Amb Agent Sessions desactivat, CodexBar només conserva a la memòria l’hora de l’activitat més recent i descarta les rutes i identitats de les sessions. Aquestes dades no s’envien enlloc, i la detecció remota i SSH continuen desactivats. Si ho rebutgeu, CodexBar tornarà al mode Adaptatiu normal sense exploracions d’activitat local."; +"adaptive_activity_consent_allow" = "Permeteu l’activitat local"; +"adaptive_activity_consent_decline" = "Utilitzeu l’Adaptatiu normal"; /* Additional keys */ "not_found" = "No trobat"; /* Cost estimation */ -"cost_header_estimated" = "Cost (estimat)"; -"cost_estimate_hint" = "Estimat a partir de registres locals · pot diferir de la teva factura"; +"cost_estimate_hint" = "Estimat a partir de registres locals · pot diferir de la vostra factura"; +"codex_api_estimate_hint" = "Estimat a partir de l’ús de tokens · no és una factura de subscripció"; +"cost_data_explanation" = "Els costos poden ser comunicats pel proveïdor o estimats a partir de l’ús de tokens amb preus públics de l’API. Les estimacions no són càrrecs de subscripció."; /* Popup panels */ "No usage configured." = "No hi ha cap ús configurat."; "Quota" = "Quota"; +"Daily quota" = "Quota diària"; +"Total" = "Total"; "tokens" = "tokens"; "requests" = "sol·licituds"; "Latest" = "Més recent"; @@ -649,7 +757,7 @@ "Copy path" = "Copia el camí"; "Extra usage spent" = "Despesa d'ús addicional"; "Credits remaining" = "Crèdits restants"; -"Using CLI fallback" = "S'està utilitzant l'alternativa de la CLI"; +"Using CLI fallback" = "S'utilitza l'alternativa de la CLI"; "Balance updates in near-real time (up to 5 min lag)" = "El saldo s'actualitza gairebé en temps real (fins a 5 min de retard)"; "Daily billing data finalizes at 07:00 UTC" = "Les dades diàries de facturació es tanquen a les 07:00 UTC"; "%@ of %@ credits left" = "Queden %@ de %@ crèdits"; @@ -658,8 +766,8 @@ "%@/%@ left" = "%@/%@ restant"; "Gemini Flash" = "Gemini Flash"; "Regenerates %@" = "Es regenera %@"; -"used after next regen" = "usat després de la propera regeneració"; -"after next regen" = "després de la propera regeneració"; +"used after next regen" = "usat després de la pròxima regeneració"; +"after next regen" = "després de la pròxima regeneració"; "Near full" = "Gairebé ple"; "Full in ~1 regen" = "Ple en ~1 regeneració"; "Full in ~%.0f regens" = "Ple en ~%.0f regeneracions"; @@ -670,6 +778,7 @@ "API spend" = "Despesa d'API"; "Extra usage" = "Ús addicional"; "Quota usage" = "Ús de quota"; +"Your spend" = "La vostra despesa"; "%.0f%% used" = "%.0f%% usat"; "Usage history (today)" = "Historial d'ús (avui)"; "Usage history (%d days)" = "Historial d'ús (%d dies)"; @@ -687,8 +796,8 @@ "%d utilization samples" = "%d mostres d'utilització"; "Hourly Usage" = "Ús per hora"; "Usage remaining" = "Ús restant"; -"Usage used" = "Ús utilitzat"; -"API key verified. Ollama does not expose Cloud quota limits through the API." = "Clau d'API verificada. Ollama no exposa els límits de quota de Cloud a través de l'API."; +"Usage used" = "Ús consumit"; +"API key verified. Cloud quotas need browser cookies. Sign in to Ollama." = "Clau d'API verificada. Les quotes de Cloud necessiten galetes del navegador. Inicieu sessió a Ollama."; "Last 30 days: %@ tokens" = "Últims 30 dies: %@ tokens"; "7d spend" = "Despesa 7 d"; "30d spend" = "Despesa 30 d"; @@ -699,6 +808,13 @@ "MiniMax 30 day token usage trend" = "Tendència d'ús de tokens de 30 dies de MiniMax"; "Today cash" = "Efectiu d'avui"; "DeepSeek 30 day token usage trend" = "Tendència d'ús de tokens de 30 dies de DeepSeek"; +"Detailed usage unavailable." = "L'ús detallat no està disponible."; +"Sign in to DeepSeek Platform in Chrome for detailed usage." = "Inicia la sessió a DeepSeek Platform al Chrome per veure l'ús detallat."; +"Select a DeepSeek Chrome profile in Settings." = "Selecciona un perfil de Chrome de DeepSeek a Configuració."; +"DeepSeek this month token usage trend" = "Tendència d'ús de tokens de DeepSeek aquest mes"; +"Chrome profile" = "Perfil de Chrome"; +"Choose which signed-in DeepSeek Platform session supplies detailed usage." = "Tria quina sessió iniciada de DeepSeek Platform proporciona l'ús detallat."; +"Select profile…" = "Selecciona un perfil…"; "cache-hit input" = "entrada amb encert de memòria cau"; "cache-miss input" = "entrada sense encert de memòria cau"; "output" = "sortida"; @@ -708,6 +824,7 @@ "Today" = "Avui"; "Today tokens" = "Tokens d'avui"; "30d cost" = "Cost 30 d"; +"%@ cost" = "Cost %@"; "30d tokens" = "Tokens 30 d"; "Latest tokens" = "Tokens recents"; "Top model" = "Model principal"; @@ -756,12 +873,12 @@ "AWS region. Can also be set with AWS_REGION." = "Regió d'AWS. També es pot definir amb AWS_REGION."; "AWS secret access key. Can also be set with AWS_SECRET_ACCESS_KEY." = "Clau secreta d'accés d'AWS. També es pot definir amb AWS_SECRET_ACCESS_KEY."; "Access key ID" = "ID de clau d'accés"; -"Add Account" = "Afegeix compte"; +"Add Account" = "Afegiu compte"; "Adding Account…" = "S'està afegint el compte…"; "Antigravity login failed" = "L'inici de sessió d'Antigravity ha fallat"; "Antigravity login timed out" = "L'inici de sessió d'Antigravity ha esgotat el temps"; "Auth source" = "Font d'autenticació"; -"Automatic imports Chrome browser cookies from Xiaomi MiMo." = "Importa automàticament les galetes de Chrome de Xiaomi MiMo."; +"Automatic imports browser cookies from Xiaomi MiMo." = "Importa automàticament les galetes del navegador de Xiaomi MiMo."; "Automatic imports Windsurf session data from Chromium browser localStorage." = "Importa automàticament dades de sessió de Windsurf del localStorage de Chromium."; "Automatic imports browser cookies from Bailian." = "Importa automàticament galetes del navegador de Bailian."; "Automatically imports browser cookies." = "Importa automàticament galetes del navegador."; @@ -777,130 +894,137 @@ "Capacity End" = "Final de capacitat"; "Capacity Start" = "Inici de capacitat"; "Changelog" = "Registre de canvis"; -"Choose the Moonshot/Kimi API host for international or China mainland accounts." = "Tria el host de l'API Moonshot/Kimi per a comptes internacionals o de la Xina continental."; +"Choose the Moonshot/Kimi API host for international or China mainland accounts." = "Trieu el host de l'API Moonshot/Kimi per a comptes internacionals o de la Xina continental."; "CodexBar can't replace a system account that is signed in with an API key only setup." = "CodexBar no pot substituir un compte del sistema iniciat només amb una clau API."; -"CodexBar could not find saved auth for that account. Re-authenticate it and try again." = "CodexBar no ha trobat autenticació desada per a aquest compte. Torna'l a autenticar i prova-ho de nou."; -"CodexBar could not read managed account storage. Recover the store before adding another account." = "CodexBar no ha pogut llegir l'emmagatzematge de comptes gestionats. Recupera'l abans d'afegir un altre compte."; -"CodexBar could not read saved auth for that account. Re-authenticate it and try again." = "CodexBar no ha pogut llegir l'autenticació desada per a aquest compte. Torna'l a autenticar i prova-ho de nou."; +"CodexBar could not find saved auth for that account. Re-authenticate it and try again." = "CodexBar no ha trobat autenticació desada per a aquest compte. Torneu-lo a autenticar i torneu-ho a provar."; +"CodexBar could not read managed account storage. Recover the store before adding another account." = "CodexBar no ha pogut llegir l'emmagatzematge de comptes gestionats. Recupereu-lo abans d'afegir un altre compte."; +"CodexBar could not read saved auth for that account. Re-authenticate it and try again." = "CodexBar no ha pogut llegir l'autenticació desada per a aquest compte. Torneu-lo a autenticar i torneu-ho a provar."; "CodexBar could not read the current system account on this Mac." = "CodexBar no ha pogut llegir el compte del sistema actual en aquest Mac."; "CodexBar could not replace the live Codex auth on this Mac." = "CodexBar no ha pogut substituir l'autenticació activa de Codex en aquest Mac."; "CodexBar could not safely preserve the current system account before switching." = "CodexBar no ha pogut preservar de manera segura el compte del sistema actual abans de canviar."; "CodexBar could not save the current system account before switching." = "CodexBar no ha pogut desar el compte del sistema actual abans de canviar."; "CodexBar could not update managed account storage." = "CodexBar no ha pogut actualitzar l'emmagatzematge de comptes gestionats."; -"CodexBar found another managed account that already uses the current system account. Resolve the duplicate account before switching." = "CodexBar ha trobat un altre compte gestionat que ja utilitza el compte del sistema actual. Resol el compte duplicat abans de canviar."; -"CodexBar will ask macOS Keychain for “%@” so it can decrypt browser cookies and authenticate your account. Click OK to continue." = "CodexBar demanarà a Clauers de macOS “%@” per desxifrar galetes del navegador i autenticar el compte. Fes clic a OK per continuar."; -"CodexBar will ask macOS Keychain for the Claude Code OAuth token so it can fetch your Claude usage. Click OK to continue." = "CodexBar demanarà a Clauers de macOS el token OAuth de Claude Code per obtenir l'ús de Claude. Fes clic a OK per continuar."; -"CodexBar will ask macOS Keychain for your Amp cookie header so it can fetch usage. Click OK to continue." = "CodexBar demanarà a Clauers de macOS la capçalera Cookie d'Amp per obtenir l'ús. Fes clic a OK per continuar."; -"CodexBar will ask macOS Keychain for your Augment cookie header so it can fetch usage. Click OK to continue." = "CodexBar demanarà a Clauers de macOS la capçalera Cookie d'Augment per obtenir l'ús. Fes clic a OK per continuar."; -"CodexBar will ask macOS Keychain for your Claude cookie header so it can fetch Claude web usage. Click OK to continue." = "CodexBar demanarà a Clauers de macOS la capçalera Cookie de Claude per obtenir l'ús web de Claude. Fes clic a OK per continuar."; -"CodexBar will ask macOS Keychain for your Cursor cookie header so it can fetch usage. Click OK to continue." = "CodexBar demanarà a Clauers de macOS la capçalera Cookie de Cursor per obtenir l'ús. Fes clic a OK per continuar."; -"CodexBar will ask macOS Keychain for your Factory cookie header so it can fetch usage. Click OK to continue." = "CodexBar demanarà a Clauers de macOS la capçalera Cookie de Factory per obtenir l'ús. Fes clic a OK per continuar."; -"CodexBar will ask macOS Keychain for your GitHub Copilot token so it can fetch usage. Click OK to continue." = "CodexBar demanarà a Clauers de macOS el token de GitHub Copilot per obtenir l'ús. Fes clic a OK per continuar."; -"CodexBar will ask macOS Keychain for your Kimi K2 API key so it can fetch usage. Click OK to continue." = "CodexBar demanarà a Clauers de macOS la clau API de Kimi K2 per obtenir l'ús. Fes clic a OK per continuar."; -"CodexBar will ask macOS Keychain for your Kimi auth token so it can fetch usage. Click OK to continue." = "CodexBar demanarà a Clauers de macOS el token d'autenticació de Kimi per obtenir l'ús. Fes clic a OK per continuar."; -"CodexBar will ask macOS Keychain for your MiniMax API token so it can fetch usage. Click OK to continue." = "CodexBar demanarà a Clauers de macOS el token API de MiniMax per obtenir l'ús. Fes clic a OK per continuar."; -"CodexBar will ask macOS Keychain for your MiniMax cookie header so it can fetch usage. Click OK to continue." = "CodexBar demanarà a Clauers de macOS la capçalera Cookie de MiniMax per obtenir l'ús. Fes clic a OK per continuar."; -"CodexBar will ask macOS Keychain for your OpenAI cookie header so it can fetch Codex dashboard extras. Click OK to continue." = "CodexBar demanarà a Clauers de macOS la capçalera Cookie d'OpenAI per obtenir extres del tauler de Codex. Fes clic a OK per continuar."; -"CodexBar will ask macOS Keychain for your OpenCode cookie header so it can fetch usage. Click OK to continue." = "CodexBar demanarà a Clauers de macOS la capçalera Cookie d'OpenCode per obtenir l'ús. Fes clic a OK per continuar."; -"CodexBar will ask macOS Keychain for your Synthetic API key so it can fetch usage. Click OK to continue." = "CodexBar demanarà a Clauers de macOS la clau API de Synthetic per obtenir l'ús. Fes clic a OK per continuar."; -"CodexBar will ask macOS Keychain for your z.ai API token so it can fetch usage. Click OK to continue." = "CodexBar demanarà a Clauers de macOS el token API de z.ai per obtenir l'ús. Fes clic a OK per continuar."; +"CodexBar found another managed account that already uses the current system account. Resolve the duplicate account before switching." = "CodexBar ha trobat un altre compte gestionat que ja utilitza el compte del sistema actual. Resoleu el compte duplicat abans de canviar."; +"CodexBar will ask macOS Keychain for “%@” so it can decrypt browser cookies and authenticate your account. Click OK to continue." = "CodexBar demanarà a Clauer de macOS “%@” per desxifrar galetes del navegador i autenticar el compte. Premeu D'acord per continuar."; +"CodexBar will ask macOS Keychain for the Claude Code OAuth token so it can fetch your Claude usage. Click OK to continue." = "CodexBar demanarà a Clauer de macOS el token OAuth de Claude Code per obtenir l'ús de Claude. Premeu D'acord per continuar."; +"CodexBar will ask macOS Keychain for your Amp cookie header so it can fetch usage. Click OK to continue." = "CodexBar demanarà a Clauer de macOS la capçalera Cookie d'Amp per obtenir l'ús. Premeu D'acord per continuar."; +"CodexBar will ask macOS Keychain for your Augment cookie header so it can fetch usage. Click OK to continue." = "CodexBar demanarà a Clauer de macOS la capçalera Cookie d'Augment per obtenir l'ús. Premeu D'acord per continuar."; +"CodexBar will ask macOS Keychain for your Claude cookie header so it can fetch Claude web usage. Click OK to continue." = "CodexBar demanarà a Clauer de macOS la capçalera Cookie de Claude per obtenir l'ús web de Claude. Premeu D'acord per continuar."; +"CodexBar will ask macOS Keychain for your Cursor cookie header so it can fetch usage. Click OK to continue." = "CodexBar demanarà a Clauer de macOS la capçalera Cookie de Cursor per obtenir l'ús. Premeu D'acord per continuar."; +"CodexBar will ask macOS Keychain for your Factory cookie header so it can fetch usage. Click OK to continue." = "CodexBar demanarà a Clauer de macOS la capçalera Cookie de Factory per obtenir l'ús. Premeu D'acord per continuar."; +"CodexBar will ask macOS Keychain for your GitHub Copilot token so it can fetch usage. Click OK to continue." = "CodexBar demanarà a Clauer de macOS el token de GitHub Copilot per obtenir l'ús. Premeu D'acord per continuar."; +"CodexBar will ask macOS Keychain for your Kimi auth token so it can fetch usage. Click OK to continue." = "CodexBar demanarà a Clauer de macOS el token d'autenticació de Kimi per obtenir l'ús. Premeu D'acord per continuar."; +"CodexBar will ask macOS Keychain for your MiniMax API token so it can fetch usage. Click OK to continue." = "CodexBar demanarà a Clauer de macOS el token API de MiniMax per obtenir l'ús. Premeu D'acord per continuar."; +"CodexBar will ask macOS Keychain for your MiniMax cookie header so it can fetch usage. Click OK to continue." = "CodexBar demanarà a Clauer de macOS la capçalera Cookie de MiniMax per obtenir l'ús. Premeu D'acord per continuar."; +"CodexBar will ask macOS Keychain for your OpenAI cookie header so it can fetch Codex dashboard extras. Click OK to continue." = "CodexBar demanarà a Clauer de macOS la capçalera Cookie d'OpenAI per obtenir extres del tauler de Codex. Premeu D'acord per continuar."; +"CodexBar will ask macOS Keychain for your OpenCode cookie header so it can fetch usage. Click OK to continue." = "CodexBar demanarà a Clauer de macOS la capçalera Cookie d'OpenCode per obtenir l'ús. Premeu D'acord per continuar."; +"CodexBar will ask macOS Keychain for your Synthetic API key so it can fetch usage. Click OK to continue." = "CodexBar demanarà a Clauer de macOS la clau API de Synthetic per obtenir l'ús. Premeu D'acord per continuar."; +"CodexBar will ask macOS Keychain for your z.ai API token so it can fetch usage. Click OK to continue." = "CodexBar demanarà a Clauer de macOS el token API de z.ai per obtenir l'ús. Premeu D'acord per continuar."; "Could not open Cursor login in your browser." = "No s'ha pogut obrir l'inici de sessió de Cursor al navegador."; "Could not open browser for Antigravity" = "No s'ha pogut obrir el navegador per a Antigravity"; "Credits used" = "Crèdits usats"; "Day" = "Dia"; "Deployment" = "Desplegament"; -"Drag to reorder" = "Arrossega per reordenar"; +"Drag to reorder" = "Arrossegueu per reordenar"; +"Sort providers alphabetically" = "Ordena els proveïdors alfabèticament"; +"Sort providers alphabetically (enabled first)" = "Ordena els proveïdors alfabèticament (els activats primer)"; +"Sorted alphabetically (enabled first) — click to use your custom order" = "Ordenats alfabèticament (els activats primer) — feu clic per utilitzar l'ordre personalitzat"; "Endpoint" = "Endpoint"; "Enterprise host" = "Host Enterprise"; "Extra usage balance: %@" = "Saldo d'ús extra: %@"; -"Keychain Access Required" = "Cal accés a Clauers"; +"Keychain Access Required" = "Cal accés al Clauer"; +"keychain_prompt_learn_more" = "Més informació…"; +"keychain_prompt_privacy_note" = "macOS —no CodexBar— gestiona la introducció de la contrasenya d'inici de sessió del Mac. Podeu desactivar l'accés al Clauer en qualsevol moment a Configuració → Avançat."; "Kiro menu bar value" = "Valor de Kiro a la barra de menús"; "Label" = "Etiqueta"; -"No organizations loaded. Click Refresh after setting your API key." = "No hi ha organitzacions carregades. Fes clic a Actualitza després de configurar la clau API."; +"No organizations loaded. Click Refresh after setting your API key." = "No hi ha organitzacions carregades. Feu clic a Actualitza després de configurar la clau API."; "No output captured." = "No s'ha capturat cap sortida."; "No system account" = "Sense compte del sistema"; "Oasis-Token" = "Oasis-Token"; -"Open Augment (Log Out & Back In)" = "Obre Augment (tanca sessió i torna a entrar)"; -"Open Codebuff Dashboard" = "Obre el tauler de Codebuff"; -"Open Command Code Settings" = "Obre la configuració de Command Code"; -"Open Crof dashboard" = "Obre el tauler de Crof"; -"Open Manus" = "Obre Manus"; -"Open MiMo Balance" = "Obre el saldo de MiMo"; -"Open Moonshot Console" = "Obre la consola de Moonshot"; -"Open Ollama API Keys" = "Obre les claus API d'Ollama"; -"Open StepFun Platform" = "Obre la plataforma StepFun"; -"Open T3 Chat Settings" = "Obre la configuració de T3 Chat"; -"Open Volcengine Ark Console" = "Obre la consola Volcengine Ark"; -"Open legacy provider docs" = "Obre la documentació del proveïdor heretat"; -"Open projects" = "Obre projectes"; -"Open this URL manually to continue login:\n\n%@" = "Obre aquesta URL manualment per continuar l'inici de sessió:\n\n%@"; +"Open Augment (Log Out & Back In)" = "Obriu Augment (tanqueu la sessió i torneu a iniciar-la)"; +"Open Codebuff Dashboard" = "Obriu el tauler de Codebuff"; +"Open Command Code Settings" = "Obriu la configuració de Command Code"; +"Open Crof dashboard" = "Obriu el tauler de Crof"; +"Open Manus" = "Obriu Manus"; +"Open MiMo Balance" = "Obriu el saldo de MiMo"; +"Open Moonshot Console" = "Obriu la consola de Moonshot"; +"Open Ollama API Keys" = "Obriu les claus API d'Ollama"; +"Open StepFun Platform" = "Obriu la plataforma StepFun"; +"Open T3 Chat Settings" = "Obriu la configuració de T3 Chat"; +"Open Volcengine Ark Console" = "Obriu la consola Volcengine Ark"; +"Open legacy provider docs" = "Obriu la documentació del proveïdor heretat"; +"Open projects" = "Obriu projectes"; +"Open this URL manually to continue login:\n\n%@" = "Obriu aquesta URL manualment per continuar l'inici de sessió:\n\n%@"; "Optional organization ID for accounts linked to multiple Anthropic organizations." = "ID d'organització opcional per a comptes vinculats a diverses organitzacions d'Anthropic."; "Optional. Applies to the configured Admin API key; selected token accounts do not inherit OPENAI_PROJECT_ID." = "Opcional. S'aplica a la clau Admin API configurada; els comptes de token seleccionats no hereten OPENAI_PROJECT_ID."; -"Optional. Enter your GitHub Enterprise host, for example octocorp.ghe.com. Leave blank for github.com." = "Opcional. Introdueix el host de GitHub Enterprise, per exemple octocorp.ghe.com. Deixa-ho en blanc per a github.com."; -"Optional. Leave blank to discover and aggregate projects visible to the API key." = "Opcional. Deixa-ho en blanc per descobrir i agregar projectes visibles per a la clau API."; +"Optional. Enter your GitHub Enterprise host, for example octocorp.ghe.com. Leave blank for github.com." = "Opcional. Introduïu el host de GitHub Enterprise, per exemple octocorp.ghe.com. Deixeu-ho en blanc per a github.com."; +"Optional. Leave blank to discover and aggregate projects visible to the API key." = "Opcional. Deixeu-ho en blanc per descobrir i agregar projectes visibles per a la clau API."; "Org ID (optional)" = "ID d'org. (opcional)"; "Organizations" = "Organitzacions"; +"Organization ID" = "ID d'organització"; "Password" = "Contrasenya"; "%@ authentication is disabled." = "L'autenticació de %@ està desactivada."; "%@ cookies are disabled." = "Les galetes de %@ estan desactivades."; "%@ web API access is disabled." = "L'accés a l'API web de %@ està desactivat."; -"Disable %@ dashboard cookie usage." = "Desactiva l'ús de galetes del tauler de %@."; -"Keychain access is disabled in Advanced, so browser cookie import is unavailable." = "L'accés al clauer està desactivat a Avançat, així que la importació de galetes del navegador no està disponible."; -"Manually paste an %@ from a browser session." = "Enganxa manualment un %@ d'una sessió del navegador."; -"Paste a Cookie header captured from %@." = "Enganxa una capçalera Cookie capturada de %@."; -"Paste a Cookie header from %@." = "Enganxa una capçalera Cookie de %@."; -"Paste a Cookie header or cURL capture from %@." = "Enganxa una capçalera Cookie o una captura cURL de %@."; -"Paste a Cookie header or full cURL capture from %@." = "Enganxa una capçalera Cookie o una captura cURL completa de %@."; -"Paste a Cookie or Authorization header from %@." = "Enganxa una capçalera Cookie o Authorization de %@."; -"Paste a full cookie header or the %@ value." = "Enganxa una capçalera de galetes completa o el valor %@."; -"Paste a Cookie header or full cURL capture from T3 Chat settings." = "Enganxa una capçalera Cookie o una captura cURL completa de la configuració de T3 Chat."; -"Paste the Cookie header from a request to admin.mistral.ai. Must contain an ory_session_* cookie." = "Enganxa la capçalera Cookie d'una sol·licitud a admin.mistral.ai. Ha de contenir una galeta ory_session_*."; -"Paste the Oasis-Token from a logged-in browser session on platform.stepfun.com." = "Enganxa l'Oasis-Token d'una sessió iniciada a platform.stepfun.com."; -"Paste the %@ JSON bundle from %@." = "Enganxa el paquet JSON %@ de %@."; -"Paste the %@ value or a full Cookie header." = "Enganxa el valor %@ o una capçalera Cookie completa."; +"Disable %@ dashboard cookie usage." = "Desactiveu l'ús de galetes del tauler de %@."; +"Keychain access is disabled in Advanced, so browser cookie import is unavailable." = "L'accés al Clauer està desactivat a Avançat, així que la importació de galetes del navegador no està disponible."; +"Manually paste an %@ from a browser session." = "Enganxeu manualment un %@ d'una sessió del navegador."; +"Paste a Cookie header captured from %@." = "Enganxeu una capçalera Cookie capturada de %@."; +"Paste a Cookie header from %@." = "Enganxeu una capçalera Cookie de %@."; +"Paste a Cookie header or cURL capture from %@." = "Enganxeu una capçalera Cookie o una captura cURL de %@."; +"Paste a Cookie header or full cURL capture from %@." = "Enganxeu una capçalera Cookie o una captura cURL completa de %@."; +"Paste a Cookie or Authorization header from %@." = "Enganxeu una capçalera Cookie o Authorization de %@."; +"Paste a full cookie header or the %@ value." = "Enganxeu una capçalera de galetes completa o el valor %@."; +"Paste a Cookie header or full cURL capture from T3 Chat settings." = "Enganxeu una capçalera Cookie o una captura cURL completa de la configuració de T3 Chat."; +"Paste the Cookie header from a request to admin.mistral.ai. Must contain an ory_session_* cookie." = "Enganxeu la capçalera Cookie d'una sol·licitud a admin.mistral.ai. Ha de contenir una galeta ory_session_*."; +"Paste the Oasis-Token from a logged-in browser session on platform.stepfun.com." = "Enganxeu l'Oasis-Token d'una sessió iniciada a platform.stepfun.com."; +"Paste the %@ JSON bundle from %@." = "Enganxeu el paquet JSON %@ de %@."; +"Paste the %@ value or a full Cookie header." = "Enganxeu el valor %@ o una capçalera Cookie completa."; "Personal account" = "Compte personal"; "Project ID" = "ID de projecte"; -"Re-auth" = "Reautentica"; +"Re-auth" = "Reautentiqueu"; +"Re-login at claude.ai" = "Torneu a iniciar sessió a claude.ai"; "Re-authenticating…" = "S'està reautenticant…"; -"Refresh Session" = "Actualitza la sessió"; -"Refresh organizations" = "Actualitza organitzacions"; +"Refresh Session" = "Actualitzeu la sessió"; +"Refresh organizations" = "Actualitzeu organitzacions"; "Region" = "Regió"; -"Reload" = "Recarrega"; -"Reorder" = "Reordena"; +"Reload" = "Recarregueu"; +"Reorder" = "Reordeneu"; "Secret access key" = "Clau secreta d'accés"; "Series" = "Sèrie"; "Service" = "Servei"; -"Show or hide Kiro credits, percent, or both next to the menu bar icon." = "Mostra o amaga crèdits de Kiro, percentatge o tots dos al costat de la icona de la barra de menús."; -"Show usage for organizations you belong to. Personal account is always shown." = "Mostra l'ús de les organitzacions a què pertanys. El compte personal sempre es mostra."; -"Sign in to cursor.com in your browser, then refresh Cursor in CodexBar." = "Inicia sessió a cursor.com al navegador i després actualitza Cursor a CodexBar."; +"Show or hide Kiro credits, percent, or both next to the menu bar icon." = "Mostreu o amagueu crèdits de Kiro, percentatge o tots dos al costat de la icona de la barra de menús."; +"Show usage for organizations you belong to. Personal account is always shown." = "Mostreu l'ús de les organitzacions a què pertanyeu. El compte personal sempre es mostra."; +"Sign in to cursor.com in your browser, then refresh Cursor in CodexBar." = "Inicieu sessió a cursor.com al navegador i després actualitzeu Cursor a CodexBar."; "Simulated error text" = "Text d'error simulat"; "StepFun platform account (phone number or email)." = "Compte de la plataforma StepFun (telèfon o correu)."; "Stored in ~/.codexbar/config.json." = "Desat a ~/.codexbar/config.json."; "Stored in ~/.codexbar/config.json. AZURE_OPENAI_API_KEY is also supported." = "Desat a ~/.codexbar/config.json. També s'admet AZURE_OPENAI_API_KEY."; -"Stored in ~/.codexbar/config.json. For the official Kimi API, use Moonshot / Kimi API." = "Desat a ~/.codexbar/config.json. Per a l'API oficial de Kimi, usa Moonshot / Kimi API."; -"Stored in ~/.codexbar/config.json. Get your API key from the Volcengine Ark console." = "Desat a ~/.codexbar/config.json. Obtén la clau API a la consola Volcengine Ark."; -"Stored in ~/.codexbar/config.json. Get your key from Ollama settings." = "Desat a ~/.codexbar/config.json. Obtén la clau a la configuració d'Ollama."; -"Stored in ~/.codexbar/config.json. Get your key from console.deepgram.com." = "Desat a ~/.codexbar/config.json. Obtén la clau a console.deepgram.com."; -"Stored in ~/.codexbar/config.json. Get your key from elevenlabs.io/app/settings/api-keys." = "Desat a ~/.codexbar/config.json. Obtén la clau a elevenlabs.io/app/settings/api-keys."; -"Stored in ~/.codexbar/config.json. Get your key from openrouter.ai/settings/keys and set a key spending limit there to enable API key quota tracking." = "Desat a ~/.codexbar/config.json. Obtén la clau a openrouter.ai/settings/keys i defineix-hi un límit de despesa per activar el seguiment de quota."; -"Stored in ~/.codexbar/config.json. In Warp, open Settings > Platform > API Keys, then create one." = "Desat a ~/.codexbar/config.json. A Warp, obre Settings > Platform > API Keys i crea'n una."; +"Stored in ~/.codexbar/config.json. For the official Kimi API, use Moonshot / Kimi API." = "Desat a ~/.codexbar/config.json. Per a l'API oficial de Kimi, useu Moonshot / Kimi API."; +"Stored in ~/.codexbar/config.json. Get your API key from the Volcengine Ark console." = "Desat a ~/.codexbar/config.json. Obteniu la clau API a la consola Volcengine Ark."; +"Stored in ~/.codexbar/config.json. Get your key from Ollama settings." = "Desat a ~/.codexbar/config.json. Obteniu la clau a la configuració d'Ollama."; +"Stored in ~/.codexbar/config.json. Get your key from console.deepgram.com." = "Desat a ~/.codexbar/config.json. Obteniu la clau a console.deepgram.com."; +"Stored in ~/.codexbar/config.json. Get your key from elevenlabs.io/app/settings/api-keys." = "Desat a ~/.codexbar/config.json. Obteniu la clau a elevenlabs.io/app/settings/api-keys."; +"Stored in ~/.codexbar/config.json. Get your key from openrouter.ai/settings/keys and set a key spending limit there to enable API key quota tracking." = "Desat a ~/.codexbar/config.json. Obteniu la clau a openrouter.ai/settings/keys i definiu-hi un límit de despesa per activar el seguiment de quota."; +"Stored in ~/.codexbar/config.json. In Warp, open Settings > Platform > API Keys, then create one." = "Desat a ~/.codexbar/config.json. A Warp, obriu Settings > Platform > API Keys i creeu-ne una."; "Stored in ~/.codexbar/config.json. Metrics require Groq Enterprise Prometheus access." = "Desat a ~/.codexbar/config.json. Les mètriques requereixen accés a Groq Enterprise Prometheus."; "Stored in ~/.codexbar/config.json. OPENAI_ADMIN_KEY is preferred; OPENAI_API_KEY still works." = "Desat a ~/.codexbar/config.json. Es prefereix OPENAI_ADMIN_KEY; OPENAI_API_KEY encara funciona."; "Stored in ~/.codexbar/config.json. Requires an Anthropic Admin API key." = "Desat a ~/.codexbar/config.json. Requereix una clau Anthropic Admin API."; "Stored in ~/.codexbar/config.json. Used for /v1/quota-stats." = "Desat a ~/.codexbar/config.json. S'usa per a /v1/quota-stats."; -"Stored in ~/.codexbar/config.json. You can also provide CODEBUFF_API_KEY or let CodexBar read ~/.config/manicode/credentials.json (created by `codebuff login`)." = "Desat a ~/.codexbar/config.json. També pots proporcionar CODEBUFF_API_KEY o deixar que CodexBar llegeixi ~/.config/manicode/credentials.json (creat per `codebuff login`)."; -"Stored in ~/.codexbar/config.json. You can also provide CROF_API_KEY." = "Desat a ~/.codexbar/config.json. També pots proporcionar CROF_API_KEY."; -"Stored in ~/.codexbar/config.json. You can also provide KILO_API_KEY or ~/.local/share/kilo/auth.json (kilo.access)." = "Desat a ~/.codexbar/config.json. També pots proporcionar KILO_API_KEY o ~/.local/share/kilo/auth.json (kilo.access)."; +"Stored in ~/.codexbar/config.json. You can also provide CODEBUFF_API_KEY or let CodexBar read ~/.config/manicode/credentials.json (created by `codebuff login`)." = "Desat a ~/.codexbar/config.json. També podeu proporcionar CODEBUFF_API_KEY o deixar que CodexBar llegeixi ~/.config/manicode/credentials.json (creat per `codebuff login`)."; +"Stored in ~/.codexbar/config.json. You can also provide CROF_API_KEY." = "Desat a ~/.codexbar/config.json. També podeu proporcionar CROF_API_KEY."; +"Stored in ~/.codexbar/config.json. You can also provide KILO_API_KEY or ~/.local/share/kilo/auth.json (kilo.access)." = "Desat a ~/.codexbar/config.json. També podeu proporcionar KILO_API_KEY o ~/.local/share/kilo/auth.json (kilo.access)."; "T3 Chat cookie" = "Galeta de T3 Chat"; -"That account is no longer available in CodexBar. Refresh the account list and try again." = "Aquest compte ja no està disponible a CodexBar. Actualitza la llista de comptes i torna-ho a provar."; -"The browser login did not complete in time. Try Antigravity login again." = "L'inici de sessió del navegador no s'ha completat a temps. Torna a provar l'inici de sessió d'Antigravity."; +"Team mode" = "Mode d'equip"; +"That account is no longer available in CodexBar. Refresh the account list and try again." = "Aquest compte ja no està disponible a CodexBar. Actualitzeu la llista de comptes i torneu-ho a provar."; +"The browser login did not complete in time. Try Antigravity login again." = "L'inici de sessió del navegador no s'ha completat a temps. Torneu a provar l'inici de sessió d'Antigravity."; "Timed out waiting for Cursor login. %@" = "S'ha esgotat el temps esperant l'inici de sessió de Cursor. %@"; "Timed out waiting for Cursor login. %@ Last error: %@" = "S'ha esgotat el temps esperant l'inici de sessió de Cursor. %@ Últim error: %@"; "Today requests" = "Sol·licituds d'avui"; "Total (30d): %@ credits" = "Total (30 d): %@ crèdits"; "Username" = "Nom d'usuari"; -"Uses username + password to login and obtain an Oasis-Token automatically." = "Usa nom d'usuari i contrasenya per iniciar sessió i obtenir un Oasis-Token automàticament."; -"Uses username + password to login and obtain an %@ automatically." = "Usa nom d'usuari i contrasenya per iniciar sessió i obtenir un %@ automàticament."; +"Uses username + password to login and obtain an Oasis-Token automatically." = "Fa servir el nom d'usuari i la contrasenya per iniciar sessió i obtenir automàticament un Oasis-Token."; +"Uses username + password to login and obtain an %@ automatically." = "Fa servir el nom d'usuari i la contrasenya per iniciar sessió i obtenir automàticament un %@."; "Utilization End" = "Final d'utilització"; "Utilization Start" = "Inici d'utilització"; "Verbosity" = "Detall"; @@ -909,7 +1033,324 @@ "Your StepFun platform password. Used to login and obtain a session token." = "La contrasenya de la plataforma StepFun. S'usa per iniciar sessió i obtenir un token de sessió."; "claude /login exited with status %d." = "claude /login ha sortit amb estat %d."; "codex login exited with status %d." = "codex login ha sortit amb estat %d."; -"Cookie: …\n\nor paste a cURL capture from the Abacus AI dashboard" = "Cookie: …\n\no enganxa una captura cURL del tauler d'Abacus AI"; -"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"; +"Cookie: …\n\nor paste a cURL capture from the Abacus AI dashboard" = "Cookie: …\n\no enganxeu una captura cURL del tauler d'Abacus AI"; +"Cookie: …\n\nor paste the __Secure-next-auth.session-token value" = "Cookie: …\n\no enganxeu el valor de __Secure-next-auth.session-token"; +"Cookie: …\n\nor paste the kimi-auth token value" = "Cookie: …\n\no enganxeu el valor del token kimi-auth"; +"session_id=...\n\nor paste just the session_id value" = "session_id=...\n\no enganxeu només el valor de session_id"; +"Clear" = "Esborreu"; +"No matching providers" = "No hi ha proveïdors coincidents"; +"Search providers" = "Cerca proveïdors"; + +"language_vietnamese" = "Vietnamita"; +"language_turkish" = "Türkçe"; +"language_indonesian" = "Indonesi"; +"language_arabic" = "العربية"; +"language_persian" = "فارسی"; +"language_thai" = "ไทย"; +"language_galician" = "Galego"; +"Limit Reset Credits" = "Crèdits de restabliment del límit"; +"1 available" = "1 disponible"; +"%d available" = "%d disponibles"; +"Next expires %@" = "El següent caduca %@"; +"Expires %@" = "Caduca %@"; +"No expiry" = "Sense caducitat"; +"Other (%d items)" = "Altres (%d elements)"; +"Expand" = "Amplia"; +"Collapse" = "Redueix"; +"byte_unit_byte" = "byte"; +"byte_unit_bytes" = "bytes"; +"byte_unit_kilobyte" = "quilobyte"; +"byte_unit_kilobytes" = "quilobytes"; +"byte_unit_megabyte" = "megabyte"; +"byte_unit_megabytes" = "megabytes"; +"byte_unit_gigabyte" = "gigabyte"; +"byte_unit_gigabytes" = "gigabytes"; + +/* Noves traduccions */ +"%@ is unavailable in the current environment." = "%@ no està disponible en l'entorn actual."; +"%@ left" = "Queden %@"; +"%@ · %@" = "%@ · %@"; +"%@: %@ · %@ tokens" = "%@: %@ · %@ tokens"; +"%@: %@" = "%@: %@"; +"%@: %@%% used" = "%@: %@%% usat"; +"%d more items" = "%d elements més"; +"%d unreadable item(s) skipped" = "%d elements no llegibles omesos"; +"%d%% in deficit" = "%d%% en dèficit"; +"%d%% in reserve" = "%d%% en reserva"; +"%dd" = "%dd"; +"About CodexBar" = "Quant al CodexBar"; +"Add Account..." = "Afegiu un compte..."; +"Add accounts via GitHub OAuth Device Flow on the selected host." = "Afegiu comptes mitjançant el flux de dispositiu de GitHub OAuth a l'amfitrió seleccionat."; +"Add Google Account" = "Afegiu un compte de Google"; +"Admin API key" = "Clau d'Admin API"; +"All Systems Operational" = "Tots els sistemes estan operatius"; +"Auto" = "Automàtic"; +"Auto uses the local IDE API first, then Google OAuth when the IDE is closed." = "El mode automàtic fa servir primer l'API local de l'IDE i després Google OAuth quan l'IDE està tancat."; +"Cleanup ideas" = "Idees de neteja"; +"Clearing removes archived Codex session history." = "En esborrar, s'elimina l'historial arxivat de sessions de Codex."; +"Clearing removes cached large pastes or attached images." = "En esborrar, s'eliminen de la memòria cau els continguts voluminosos enganxats o les imatges adjuntes."; +"Clearing removes checkpoint restore data for previous edits." = "En esborrar, s'eliminen les dades de restauració dels punts de control d'edicions anteriors."; +"Clearing removes leftover runtime shell snapshot files." = "En esborrar, s'eliminen els fitxers residuals d'instantànies de shell en execució."; +"Clearing removes legacy per-session task lists." = "En esborrar, s'eliminen les llistes de tasques antigues per sessió."; +"Clearing removes local diagnostic logs." = "En esborrar, s'eliminen els registres de diagnòstic locals."; +"Clearing removes local edit checkpoint history." = "En esborrar, s'elimina l'historial local de punts de control d'edició."; +"Clearing removes local temporary provider data." = "En esborrar, s'eliminen les dades temporals locals del proveïdor."; +"Clearing removes old plan-mode files." = "En esborrar, s'eliminen els fitxers antics del mode plan."; +"Clearing removes past Codex session history." = "En esborrar, s'elimina l'historial anterior de sessions de Codex."; +"Clearing removes past debug logs." = "En esborrar, s'eliminen els registres de depuració anteriors."; +"Clearing removes past resume, continue, and rewind history." = "En esborrar, s'elimina l'historial anterior de represa, continuació i rebobinat."; +"Clearing removes per-session environment metadata." = "En esborrar, s'eliminen les metadades d'entorn de cada sessió."; +"Clearing removes provider-owned cached data." = "En esborrar, s'eliminen les dades de la memòria cau pertanyents al proveïdor."; +"Credits unavailable; keep Codex running to refresh." = "Crèdits no disponibles; mantingueu el Codex en execució per actualitzar."; +"Daily" = "Diari"; +"Disabled — no recent data" = "Desactivat — sense dades recents"; +"Est. total (%@): %@" = "Total estimat (%@): %@"; +"Est. total (30d): %@" = "Total estimat (30 d): %@"; +"Estimated from local Codex logs for the selected account." = "Estimat a partir dels registres locals de Codex per al compte seleccionat."; +"Google accounts" = "Comptes de Google"; +"Google OAuth" = "Google OAuth"; +"Hourly Tokens" = "Tokens per hora"; +"Hover a bar for details" = "Passeu el cursor per una barra per veure'n els detalls"; +"Image Generation" = "Generació d'imatges"; +"just now" = "ara mateix"; +"Last %d day" = "Últim %d dia"; +"Last 30 days" = "Últims 30 dies"; +"Last 30 days:" = "Últims 30 dies:"; +"Last 30 days: %@" = "Últims 30 dies: %@"; +"Last 30 days: %@ · %@ tokens" = "Últims 30 dies: %@ · %@ tokens"; +"Lasts until reset" = "Dura fins al reinici"; +"1.5× headroom" = "marge d’1,5×"; +"Login with Google" = "Inicieu sessió amb Google"; +"login_success_notification_body" = "Podeu tornar a l'app; l'autenticació ha finalitzat."; +"login_success_notification_title" = "Inici de sessió de %@ correcte"; +"Manual cleanup: archived sessions" = "Neteja manual: sessions arxivades"; +"Manual cleanup: attachment cache" = "Neteja manual: memòria cau d'adjuncions"; +"Manual cleanup: cache" = "Neteja manual: memòria cau"; +"Manual cleanup: debug logs" = "Neteja manual: registres de depuració"; +"Manual cleanup: file checkpoints" = "Neteja manual: punts de control de fitxers"; +"Manual cleanup: file history" = "Neteja manual: historial de fitxers"; +"Manual cleanup: legacy todos" = "Neteja manual: tasques antigues"; +"Manual cleanup: logs" = "Neteja manual: registres"; +"Manual cleanup: past sessions" = "Neteja manual: sessions anteriors"; +"Manual cleanup: saved plans" = "Neteja manual: plans desats"; +"Manual cleanup: session metadata" = "Neteja manual: metadades de sessió"; +"Manual cleanup: sessions" = "Neteja manual: sessions"; +"Manual cleanup: shell snapshots" = "Neteja manual: instantànies de shell"; +"Manual cleanup: temporary data" = "Neteja manual: dades temporals"; +"minimax_service_coding_plan_search" = "Cerca (pla de programació)"; +"minimax_service_coding_plan_vlm" = "Model de visió (pla de programació)"; +"minimax_service_image_generation" = "Generació d'imatges"; +"minimax_service_lyrics_generation" = "Generació de lletres"; +"minimax_service_music_generation" = "Generació de música"; +"minimax_service_text_generation" = "Generació de text"; +"minimax_service_text_to_speech" = "Síntesi de veu"; +"minimax_usage_amount_format" = "Ús: %@ / %@"; +"minimax_used_percent_format" = "%@ usat"; +"Missing DeepSeek API key." = "Falta la clau d'API de DeepSeek."; +"Music Generation" = "Generació de música"; +"No %@ utilization data yet." = "Encara no hi ha dades d'utilització de %@."; +"No available fetch strategy for %@." = "No hi ha cap estratègia d'obtenció disponible per a %@."; +"No available fetch strategy for minimax." = "No hi ha cap estratègia d'obtenció disponible per a MiniMax."; +"No Cursor session found. Please log in to cursor.com in Safari, Chrome, Microsoft Edge, Brave, Arc, Dia, ChatGPT Atlas, Chromium, Helium, Vivaldi, Yandex Browser, Firefox, Zen, Colibri, Sidekick, Opera, Opera GX, or Edge Canary. If you use Safari, grant CodexBar Full Disk Access in System Settings ▸ Privacy & Security. You can also sign in to Cursor from the CodexBar menu (Add / switch account)." = "No s'ha trobat cap sessió de Cursor. Inicieu la sessió a cursor.com amb Safari, Chrome, Microsoft Edge, Brave, Arc, Dia, ChatGPT Atlas, Chromium, Helium, Vivaldi, Yandex Browser, Firefox, Zen, Colibri, Sidekick, Opera, Opera GX o Edge Canary. Si feu servir Safari, atorgueu al CodexBar Accés complet al disc a Configuració del Sistema ▸ Privadesa i Seguretat. També podeu iniciar la sessió a Cursor des del menú del CodexBar (Afegiu / canvieu de compte)."; +"No JetBrains IDE with AI Assistant detected. Install a JetBrains IDE and enable AI Assistant." = "No s'ha detectat cap IDE de JetBrains amb AI Assistant. Instal·leu un IDE de JetBrains i activeu l'AI Assistant."; +"No local data found" = "No s'han trobat dades locals"; +"No OpenCode session cookies found in browsers." = "No s'han trobat galetes de sessió d'OpenCode als navegadors."; +"No overview data available." = "No hi ha dades de resum disponibles."; +"No providers selected for Overview." = "No hi ha cap proveïdor seleccionat per al Resum."; +"No usage breakdown data available." = "No hi ha dades de desglossament d'ús disponibles."; +"No utilization data yet." = "Encara no hi ha dades d'utilització."; +"not detected" = "no detectat"; +"On pace" = "Al ritme"; +"Open billing" = "Obriu la facturació"; +"Open Token Plan" = "Obriu el pla de tokens"; +"OpenRouter API token not configured. Set OPENROUTER_API_KEY environment variable or configure in Settings." = "Token d'API d'OpenRouter no configurat. Definiu la variable d'entorn OPENROUTER_API_KEY o configureu-lo a Configuració."; +"Pace: %@" = "Ritme: %@"; +"Pace: %@ · %@" = "Ritme: %@ · %@"; +"Projected empty in %@" = "Es preveu que s'esgotarà en %@"; +"Projected empty now" = "Es preveu que s'esgotarà ara"; +"Quit" = "Sortiu"; +"quota_warning_notification_body" = "Queda un %1$@. Heu assolit el llindar d'avís del %2$d%% (%3$@)."; +"quota_warning_notification_body_with_account" = "Compte %1$@. Queda un %2$@. Heu assolit el llindar d'avís del %3$d%% (%4$@)."; +"predictive_pace_warnings_title" = "Alertes predictives de ritme"; +"predictive_pace_warnings_subtitle" = "Avisa per Codex i Claude quan el ritme de sessió o setmanal pot esgotar la quota abans del reinici."; +"confetti_on_reset_title" = "Confeti en reiniciar"; +"confetti_on_reset_subtitle" = "Mostra confeti a pantalla completa quan es reinicia l'ús."; +"confetti_option_off" = "Desactivat"; +"confetti_option_session" = "Reinicis de sessió"; +"confetti_option_weekly" = "Reinicis setmanals"; +"confetti_option_both" = "Tots dos"; +"predictive_pace_warning_notification_title" = "%1$@: alerta de ritme %2$@"; +"predictive_pace_warning_notification_body" = "Al ritme actual, aquesta quota podria esgotar-se en %1$@, abans de reiniciar-se."; +"predictive_pace_warning_notification_body_with_account" = "Compte %1$@. Al ritme actual, aquesta quota podria esgotar-se en %2$@, abans de reiniciar-se."; +"quota_warning_notification_title" = "Quota baixa de %1$@ (%2$@)"; +"Refreshing" = "S'està actualitzant"; +"Request quota: %@ / %@" = "Quota de sol·licituds: %@ / %@"; +"Resets %@" = "Es reinicia %@"; +"Resets in %@" = "Es reinicia en %@"; +"Resets now" = "Es reinicia ara"; +"Runs out in %@" = "S'esgota en %@"; +"Runs out now" = "S'esgota ara"; +"Session" = "Sessió"; +"session_depleted_notification_body" = "Queda un 0%. Es notificarà quan torni a estar disponible."; +"session_depleted_notification_title" = "Quota de sessió de %@ esgotada"; +"session_restored_notification_body" = "La quota de sessió torna a estar disponible."; +"session_restored_notification_title" = "Quota de sessió de %@ restablerta"; +"Settings..." = "Configuració..."; +"Source" = "Origen"; +"State" = "Estat"; +"Status Page" = "Pàgina d'estat"; +"Open Status Page" = "Obre la pàgina d'estat"; +"Store multiple Antigravity Google OAuth accounts for quick switching." = "Deseu diversos comptes OAuth de Google d'Antigravity per canviar ràpidament."; +"Store multiple DeepSeek API keys." = "Deseu diverses claus d'API de DeepSeek."; +"Store multiple OpenAI API keys." = "Deseu diverses claus d'API d'OpenAI."; +"Stores each signed-in Google account for quick Antigravity switching. Uses Antigravity.app OAuth when available, or ANTIGRAVITY_OAUTH_CLIENT_ID and ANTIGRAVITY_OAUTH_CLIENT_SECRET as an override." = "Desa cada compte de Google amb la sessió iniciada per canviar ràpidament a Antigravity. Fa servir OAuth d'Antigravity.app quan està disponible, o ANTIGRAVITY_OAUTH_CLIENT_ID i ANTIGRAVITY_OAUTH_CLIENT_SECRET com a substitució."; +"Switch Account..." = "Canvieu de compte..."; +"terminal_app_subtitle" = "Terminal que fa servir l'acció Obre el Terminal"; +"terminal_app_title" = "Terminal per defecte"; +"Text Generation" = "Generació de text"; +"Text to Speech" = "Síntesi de veu"; +"today" = "avui"; +"Total: %@" = "Total: %@"; +"Unavailable" = "No disponible"; +"Update ready, restart now?" = "Actualització a punt, voleu reiniciar ara?"; +"Updated %@" = "Actualitzat %@"; +"Updated relative %@" = "Actualitzat %@"; +"Updated absolute %@" = "Actualitzat %@"; +"Updated %@h ago" = "Actualitzat fa %@h"; +"Updated %@m ago" = "Actualitzat fa %@m"; +"Updated just now" = "Actualitzat ara mateix"; +"Usage Dashboard" = "Tauler d'ús"; +"usage_percent_suffix_left" = "restant"; +"usage_percent_suffix_used" = "usat"; +"Weekly" = "Setmanal"; +"z.ai API token not found. Set apiKey in ~/.codexbar/config.json or Z_AI_API_KEY." = "No s'ha trobat el token d'API de z.ai. Definiu apiKey a ~/.codexbar/config.json o Z_AI_API_KEY."; +"≈ %d%% run-out risk" = "≈ %d%% risc d'esgotament"; + +/* Settings sidebar redesign */ +"Enable" = "Activa"; +"Disable" = "Desactiva"; +"providers_on_count" = "%d actius"; +"section_cost_summary" = "Resum de costos"; +"section_command_line" = "Línia d'ordres"; +"section_privacy" = "Privadesa"; +"section_diagnostics" = "Diagnòstics"; +"section_updates" = "Actualitzacions"; +"section_links" = "Enllaços"; +"Show Codex Spark usage" = "Mostreu l'ús de Codex Spark"; +"Shows Codex Spark quota rows in the menu and provider preview. Requires optional credits and extra usage in Display settings." = "Mostreu les files de quota de Codex Spark al menú i a la previsualització del proveïdor. Cal activar «Mostreu crèdits + ús addicional» a la configuració de Pantalla."; +"Show Daily Routines usage" = "Mostreu l'ús de Rutines diàries"; +"Shows the Daily Routines quota row in the menu and provider preview. Requires optional credits and extra usage in Display settings." = "Mostreu la fila de quota de Rutines diàries al menú i a la previsualització del proveïdor. Cal activar «Mostreu crèdits + ús addicional» a la configuració de Pantalla."; +"Scroll to see more models" = "Desplaceu-vos per veure més models"; +/* Shareable usage card */ +"Copy Image" = "Copia la imatge"; +"Copy Stats" = "Copia les estadístiques"; +"Could not copy image" = "No s’ha pogut copiar la imatge"; +"Image copied" = "Imatge copiada"; +"Image saved" = "Imatge desada"; +"Nothing is uploaded. This image is created on your Mac." = "No es puja res. Aquesta imatge es crea al Mac."; +"Save..." = "Desa..."; +"Share AI Usage" = "Comparteix l’ús de la IA"; +"Share Stats…" = "Comparteix les estadístiques…"; +"Stats copied" = "Estadístiques copiades"; +"Finish switching to a different Cursor account in your browser, then try again." = "Acabeu de canviar a un compte de Cursor diferent al navegador i torneu-ho a provar."; +"Timed out waiting for Cursor account switch. %@" = "S'ha esgotat el temps d'espera del canvi de compte de Cursor. %@"; +"Timed out waiting for Cursor account switch. %@ Last error: %@" = "S'ha esgotat el temps d'espera del canvi de compte de Cursor. %@ Últim error: %@"; + +/* Spend dashboard */ +"tab_usage_spend" = "Ús i despesa"; +"Usage & Spend" = "Ús i despesa"; +"Local estimated cost history across supported providers." = "Historial local de costos estimats dels proveïdors compatibles."; +"Time range" = "Interval de temps"; +"Track costs" = "Fes seguiment dels costos"; +"Cost tracking is off" = "El seguiment de costos està desactivat"; +"Turn on Track costs to build local estimates." = "Activa «Fes seguiment dels costos» per crear estimacions locals."; +"No local cost history yet" = "Encara no hi ha historial local de costos"; +"Turn on cost tracking or refresh after using a supported provider." = "Activa el seguiment de costos o actualitza després d’utilitzar un proveïdor compatible."; +"Refresh failures" = "Actualitzacions fallides"; +"Native currencies stay separate; Codex account rows exclude Pi session history." = "Les monedes originals es mantenen separades; les files de comptes de Codex exclouen l’historial de sessions de Pi."; +"Spend unavailable" = "Despesa no disponible"; +"Model breakdown unavailable" = "Desglossament per model no disponible"; +"Local estimated history" = "Historial local estimat"; +"Coverage" = "Cobertura"; +"Estimated spend" = "Despesa estimada"; +"Tracked tokens" = "Tokens registrats"; +"Subscriptions" = "Subscripcions"; +"By subscription" = "Per subscripció"; +"No model-level history" = "Sense historial per model"; +"Daily estimated spend" = "Despesa diària estimada"; +"≈%d full 5h windows of weekly left · %d windows until reset" = "≈%d finestres completes de 5 h de quota setmanal · %d finestres fins al reinici"; +"Weekly cannot run out before reset at this pace" = "La quota setmanal no es pot esgotar abans del reinici a aquest ritme"; +"Weekly can run out ≈%d windows early" = "La quota setmanal es pot esgotar ≈%d finestres abans"; +"Estimated: %@" = "Estimació: %@"; +"session_quota_estimate_value_format" = "%1$@ %2$@"; +"session quota" = "quota de sessió"; +"session quotas" = "quotes de sessió"; +"Coding Plan" = "Pla de programació"; +"Agent Plan" = "Pla d'agent"; +"Team" = "Equip"; + +/* Menu bar layout editor */ +"menu_bar_layout_title" = "Disposició"; +"menu_bar_layout_footer" = "Arrossega les fitxes per ordenar la barra de menús. Fes clic en una fitxa per afegir-la; selecciona una fitxa col·locada i prem Suprimir per eliminar-la."; +"menu_bar_layout_group_identity" = "Identitat"; +"menu_bar_layout_group_usage" = "Ús"; +"menu_bar_layout_group_time" = "Temps"; +"menu_bar_layout_group_money" = "Cost"; +"menu_bar_layout_group_structure" = "Estructura"; +"menu_bar_layout_scope_all" = "Tots els proveïdors"; +"menu_bar_layout_scope_help" = "Edita la disposició predeterminada o substitueix-la per a un proveïdor."; +"menu_bar_layout_use_all" = "Usa la disposició de tots els proveïdors"; +"menu_bar_layout_preset" = "Predefinit de disposició"; +"menu_bar_layout_preset_icon_percent" = "Icona i percentatge"; +"menu_bar_layout_preset_icon_only" = "Només icona"; +"menu_bar_layout_preset_percent_reset" = "Percentatge i reinici"; +"menu_bar_layout_preset_compact_stacked" = "Apilat compacte"; +"menu_bar_layout_preset_custom" = "Personalitzat"; +"menu_bar_layout_live_preview" = "Previsualització en directe"; +"menu_bar_layout_strip" = "Franja de la barra de menús"; +"menu_bar_layout_remove_line_break" = "Elimina el salt de línia"; +"menu_bar_layout_chip_hint" = "Selecciona, arrossega per reordenar o usa l’acció Elimina."; +"menu_bar_layout_palette_hint" = "Fes clic per afegir o arrossega a la disposició."; +"menu_bar_layout_empty_line" = "Deixa una fitxa aquí"; +"menu_bar_layout_line" = "Línia %d"; +"menu_bar_layout_drag_remove" = "Arrossega aquí per eliminar"; +"menu_bar_layout_size" = "Mida"; +"menu_bar_layout_size_small" = "Petita"; +"menu_bar_layout_size_regular" = "Normal"; +"menu_bar_layout_gap" = "Espaiat"; +"menu_bar_layout_gap_tight" = "Estret"; +"menu_bar_layout_gap_regular" = "Normal"; +"menu_bar_layout_keyboard_hint" = "Suprimir elimina la fitxa seleccionada"; +"menu_bar_layout_sample_account" = "compte"; +"menu_bar_layout_sample_runs_out" = "s’esgota div."; +"menu_bar_layout_token_icon" = "Icona"; +"menu_bar_layout_token_provider" = "Nom del proveïdor"; +"menu_bar_layout_token_account" = "Compte"; +"menu_bar_layout_token_session" = "Sessió %"; +"menu_bar_layout_token_weekly" = "Setmanal %"; +"menu_bar_layout_token_auto" = "% automàtic"; +"menu_bar_layout_token_bar" = "Barra d’ús"; +"menu_bar_layout_token_resets_in" = "Es reinicia d’aquí a"; +"menu_bar_layout_token_reset_at" = "Reinici a"; +"menu_bar_layout_token_runs_out" = "S’esgota"; +"menu_bar_layout_token_cost_today" = "Cost d’avui"; +"menu_bar_layout_token_cost_30d" = "Cost de 30 dies"; +"menu_bar_layout_token_space" = "Espai"; +"menu_bar_layout_token_line_break" = "Salt de línia"; +"menu_bar_layout_token_separator_accessibility" = "Punt separador"; + +/* Menu bar layout accessibility */ +"Icon unavailable" = "Icona: No disponible"; +"%@ icon" = "%@: Icona"; +"Provider name unavailable" = "Nom del proveïdor: No disponible"; +"Account unavailable" = "Compte: No disponible"; +"%@ unavailable" = "%@: No disponible"; +"%@ %@" = "%@ %@"; +"Usage bar unavailable" = "Barra d’ús: No disponible"; +"Usage bar, %d of 3 filled" = "Barra d’ús: %d/3 plens"; +"Reset countdown unavailable" = "Es reinicia d’aquí a: No disponible"; +"Reset time unavailable" = "Reinici a: No disponible"; +"Run-out estimate unavailable" = "S’esgota: No disponible"; +"Cost today unavailable" = "Cost d’avui: No disponible"; +"30-day cost unavailable" = "Cost de 30 dies: No disponible"; +"Resets" = "Reinicis"; diff --git a/Sources/CodexBar/Resources/ca.lproj/Localizable.stringsdict b/Sources/CodexBar/Resources/ca.lproj/Localizable.stringsdict new file mode 100644 index 0000000000..3f1ee7a138 --- /dev/null +++ b/Sources/CodexBar/Resources/ca.lproj/Localizable.stringsdict @@ -0,0 +1,49 @@ + + + + + ≈%d full 5h windows of weekly left · %d windows until reset + + NSStringLocalizedFormatKey + %#@left@ · %#@until@ + left + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + d + one + ≈%d finestra completa de 5 h de quota setmanal + other + ≈%d finestres completes de 5 h de quota setmanal + + until + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + d + one + %d finestra fins al reinici + other + %d finestres fins al reinici + + + Weekly can run out ≈%d windows early + + NSStringLocalizedFormatKey + %#@early@ + early + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + d + one + La quota setmanal es pot esgotar ≈%d finestra abans + other + La quota setmanal es pot esgotar ≈%d finestres abans + + + + diff --git a/Sources/CodexBar/Resources/de.lproj/Localizable.strings b/Sources/CodexBar/Resources/de.lproj/Localizable.strings new file mode 100644 index 0000000000..2657bcb2f4 --- /dev/null +++ b/Sources/CodexBar/Resources/de.lproj/Localizable.strings @@ -0,0 +1,1354 @@ +/* English localization for CodexBar (base/fallback) */ + +"tab_hooks" = "Hooks"; +"hooks_enable_title" = "Hooks aktivieren"; +"hooks_enable_subtitle" = "Externe Befehle bei Kontingent- oder Anbieterereignissen ausführen."; +"hooks_trust_warning" = "Hooks können lokale Befehle auf deinem Mac ausführen. Konfiguriere nur vertrauenswürdige Befehle."; +"hooks_rules_header" = "Regeln"; +"hooks_empty" = "Keine Hooks konfiguriert."; +"hooks_add_rule" = "Regel hinzufügen"; +"hooks_delete_rule" = "Regel löschen"; +"hooks_rule_enabled" = "Aktiviert"; +"hooks_event" = "Ereignis"; +"hooks_provider" = "Anbieter"; +"hooks_any_provider" = "Beliebiger Anbieter"; +"hooks_threshold" = "Auslösen bei Nutzung ≥"; +"hooks_threshold_placeholder" = "90"; +"hooks_executable_placeholder" = "/usr/local/bin/my-command"; +"hooks_arguments_placeholder" = "Argumente"; +"hooks_argument_placeholder" = "Argument"; +"hooks_add_argument" = "Argument hinzufügen"; +"hooks_delete_argument" = "Argument löschen"; + +"ollama_safari_cookie_access_hint" = "Safari-Cookies benötigen vollen Festplattenzugriff für CodexBar (Systemeinstellungen > Datenschutz & Sicherheit)."; +"ollama_browser_cookie_decryption_denied" = "Die Entschlüsselung der %@-Cookies wurde im Schlüsselbund abgelehnt; versuchen Sie es mit einer manuellen Aktualisierung erneut."; +"ollama_browser_cookie_decryption_disabled" = "Die Entschlüsselung der %@-Cookies ist in CodexBar deaktiviert; aktivieren Sie den Schlüsselbundzugriff und aktualisieren Sie."; + +" providers" = "Anbieter"; +"(System)" = "(System)"; +"30d" = "30d"; +"7d" = "7d"; +"A managed Codex login is already running. Wait for it to finish before adding " = "Eine verwaltete Codex-Anmeldung läuft bereits. Warten Sie, bis der Vorgang abgeschlossen ist, bevor Sie ihn hinzufügen"; +"API key" = "API-Schlüssel"; +"API region" = "API-Region"; +"API token" = "API-Token"; +"API tokens" = "API-Tokens"; +"About" = "Über"; +"Account" = "Konto"; +"Accounts" = "Konten"; +"Accounts subtitle" = "Untertitel \"Konten\"."; +"Active" = "Aktiv"; +"Add" = "Hinzufügen"; +"Add Workspace" = "Arbeitsbereich hinzufügen"; +"Advanced" = "Erweitert"; +"All" = "Alle"; +"Always allow prompts" = "Erlauben Sie immer Aufforderungen"; +"Animation pattern" = "Animationsmuster"; +"Antigravity login is managed in the app" = "Der Antigravity-Login wird in der App verwaltet"; +"Applies only to the Security.framework OAuth keychain reader." = "Gilt nur für den Security.framework OAuth-Schlüsselbundleser."; +"Auto falls back to the next source if the preferred one fails." = "Wenn die bevorzugte Quelle ausfällt, wird automatisch auf die nächste Quelle zurückgegriffen."; +"Auto uses API first, then falls back to CLI on auth failures." = "Auto verwendet zuerst die API und greift dann bei Authentifizierungsfehlern auf die CLI zurück."; +"Auto-detect" = "Automatische Erkennung"; +"Auto-refresh is off; use the menu's Refresh command." = "Die automatische Aktualisierung ist deaktiviert. Verwenden Sie den Befehl \"Aktualisieren\" des Menüs."; +"Auto-refresh: hourly · Timeout: 10m" = "Automatische Aktualisierung: stündlich · Zeitüberschreitung: 10 Minuten"; +"Automatic" = "Automatisch"; +"Automatic imports browser cookies and WorkOS tokens." = "Automatischer Import von Browser-Cookies und WorkOS-Tokens."; +"Automatic imports browser cookies and local storage tokens." = "Automatischer Import von Browser-Cookies und lokalen Speichertokens."; +"Automatic imports browser cookies for dashboard extras." = "Automatischer Import von Browser-Cookies für Dashboard-Extras."; +"Automatic imports browser cookies for the web API." = "Automatischer Import von Browser-Cookies für die Web-API."; +"Automatic imports browser cookies from Model Studio/Bailian." = "Importiert automatisch Browser-Cookies von Model Studio/Bailian."; +"Automatic imports browser cookies from admin.mistral.ai." = "Importiert automatisch Browser-Cookies von admin.mistral.ai."; +"Automatic imports browser cookies from opencode.ai." = "Importiert automatisch Browser-Cookies von opencode.ai."; +"Automatic imports browser cookies or stored sessions." = "Automatischer Import von Browser-Cookies oder gespeicherten Sitzungen."; +"Automatic imports browser cookies." = "Automatischer Import von Browser-Cookies."; +"Automatically imports browser session cookie." = "Importiert automatisch Browser-Sitzungscookies."; +"Automatically opens CodexBar when you start your Mac." = "CodexBar wird automatisch geöffnet, wenn Sie Ihren Mac starten."; +"Automation" = "Automatisierung"; +"Average (\\(label1) + \\(label2))" = "Durchschnitt (\\\\(label1) + \\\\(label2))"; +"Average (\\(metadata.sessionLabel) + \\(metadata.weeklyLabel))" = "Durchschnitt (\\\\(metadata.sessionLabel) + \\\\(metadata.weeklyLabel))"; +"Avoid Keychain prompts" = "Vermeiden Sie Aufforderungen zum Schlüsselbund"; +"Balance" = "Gleichgewicht"; +"Battery Saver" = "Batteriesparmodus"; +"Bordered" = "Umrandet"; +"Build" = "Build"; +"Built \\(buildTimestamp)" = "Gebaut \\\\(buildTimestamp)"; +"Buy Credits..." = "Credits kaufen..."; +"Buy Credits…" = "Credits kaufen…"; +"CLI paths" = "CLI-Pfade"; +"CLI sessions" = "CLI-Sitzungen"; +"Caches" = "Caches"; +"Cancel" = "Stornieren"; +"Check for Updates…" = "Nach Updates suchen…"; +"Check for updates automatically" = "Suchen Sie automatisch nach Updates"; +"Check if you like your agents having some fun up there." = "Prüfen Sie, ob Sie möchten, dass Ihre Agenten dort oben Spaß haben."; +"Check provider status" = "Überprüfen Sie den Anbieterstatus"; +"Choose Codex workspace" = "Wählen Sie Codex-Arbeitsbereich"; +"Choose the MiniMax host (global .io or China mainland .com)." = "Wählen Sie den MiniMax-Host (global .io oder China Mainland .com)."; +"Choose up to " = "Wählen Sie bis zu"; +"Choose up to \\(Self.maxOverviewProviders) providers" = "Wählen Sie bis zu \\\\(Self.maxOverviewProviders) Anbieter aus"; +"Choose up to \\(count) providers" = "Wählen Sie bis zu \\\\(count) Anbieter aus"; +"Choose what to show in the menu bar (Pace shows usage vs. expected)." = "Wählen Sie aus, was in der Menüleiste angezeigt werden soll (Pace zeigt die Nutzung im Vergleich zur erwarteten)."; +"Choose which Codex account CodexBar should follow." = "Wählen Sie aus, welchem ​​Codex-Konto CodexBar folgen soll."; +"Choose which window drives the menu bar percent." = "Wählen Sie aus, welches Fenster den Prozentwert der Menüleiste steuert."; +"Chrome" = "Chrome"; +"Claude CLI not found" = "Claude CLI nicht gefunden"; +"Claude binary" = "Claude binär"; +"Claude cookies" = "Claude-Cookies"; +"Claude login failed" = "Die Anmeldung von Claude ist fehlgeschlagen"; +"Claude login timed out" = "Zeitüberschreitung beim Claude-Login"; +"Close" = "Schließen"; +"Code review" = "Codeüberprüfung"; +"Codex CLI not found" = "Codex-CLI nicht gefunden"; +"Codex account login already running" = "Die Codex-Kontoanmeldung läuft bereits"; +"Codex binary" = "Codex-Binärdatei"; +"Codex login failed" = "Codex-Anmeldung fehlgeschlagen"; +"Codex login timed out" = "Zeitüberschreitung beim Codex-Login"; +"CodexBar Lifecycle Keepalive" = "CodexBar Lifecycle Keepalive"; +"CodexBar can't show its menu bar icon" = "CodexBar kann sein Menüleistensymbol nicht anzeigen"; +"CodexBar could not read managed account storage. " = "CodexBar konnte den verwalteten Kontospeicher nicht lesen."; +"Configure…" = "Konfigurieren…"; +"Connected" = "Verbunden"; +"Controls how much detail is logged." = "Steuert, wie viele Details protokolliert werden."; +"Cookie header" = "Cookie-Header"; +"Cookie source" = "Cookie-Quelle"; +"Cookie: ..." = "Cookie: ..."; +"Cookie: \\u{2026}\\\n\\\nor paste a cURL capture from the Abacus AI dashboard" = "Cookie: \\u{2026}\\\n\\\noder fügen Sie eine cURL-Erfassung aus dem Abacus AI-Dashboard ein"; +"Cookie: \\u{2026}\\\n\\\nor paste the __Secure-next-auth.session-token value" = "Cookie: \\u{2026}\\\n\\\noder fügen Sie den __Secure-next-auth.session-token-Wert ein"; +"Cookie: \\u{2026}\\\n\\\nor paste the kimi-auth token value" = "Cookie: \\u{2026}\\\n\\\noder fügen Sie den Kimi-Auth-Token-Wert ein"; +"Cookie: …" = "Cookie: …"; +"CopilotDeviceFlow" = "CopilotDeviceFlow"; +"Cost" = "Kosten"; +"Could not add Codex account" = "Codex-Konto konnte nicht hinzugefügt werden"; +"Could not open Terminal for Gemini" = "Terminal für Gemini konnte nicht geöffnet werden"; +"Could not start claude /login" = "Claude /login konnte nicht gestartet werden"; +"Could not start codex login" = "Die Codex-Anmeldung konnte nicht gestartet werden"; +"Could not switch system account" = "Das Systemkonto konnte nicht gewechselt werden"; +"Credits" = "Credits"; +"Credits history" = "Credits-Geschichte"; +"Cursor login failed" = "Die Cursor-Anmeldung ist fehlgeschlagen"; +"Custom" = "Benutzerdefiniert"; +"Custom Path" = "Benutzerdefinierter Pfad"; +"Daily Routines" = "Tägliche Routinen"; +"Debug" = "Debuggen"; +"Default" = "Standard"; +"Disable Keychain access" = "Deaktivieren Sie den Schlüsselbundzugriff"; +"Disabled" = "Deaktiviert"; +"Dismiss" = "Zurückweisen"; +"Disconnected" = "Getrennt"; +"Display" = "Anzeige"; +"Display mode" = "Anzeigemodus"; +"Display reset times as absolute clock values instead of countdowns." = "Anzeige der Rücksetzzeiten als absolute Uhrwerte statt als Countdown."; +"Done" = "Erledigt"; +"Effective PATH" = "Effektiver WEG"; +"Email" = "E-Mail"; +"Enable Merge Icons to configure Overview tab providers." = "Aktivieren Sie \"Symbole zusammenführen\", um Anbieter für die Registerkarte \"Übersicht\" zu konfigurieren."; +"Enable file logging" = "Aktivieren Sie die Dateiprotokollierung"; +"Enabled" = "Ermöglicht"; +"Error" = "Fehler"; +"Error simulation" = "Fehlersimulation"; +"Expose troubleshooting tools in the Debug tab." = "Stellen Sie Tools zur Fehlerbehebung auf der Registerkarte \"Debug\" bereit."; +"Failed" = "Fehlgeschlagen"; +"False" = "FALSCH"; +"Fetch strategy attempts" = "Strategieversuche abrufen"; +"Fetching" = "Holen"; +"Field" = "Feld"; +"Field subtitle" = "Felduntertitel"; +"Finish the current managed account change before switching the system account." = "Schließen Sie die Änderung des aktuellen verwalteten Kontos ab, bevor Sie das Systemkonto wechseln."; +"Force animation on next refresh" = "Animation bei der nächsten Aktualisierung erzwingen"; +"Gateway region" = "Gateway-Region"; +"Gemini CLI not found" = "Gemini-CLI nicht gefunden"; +"Gemini/Antigravity, surfacing incidents in the icon and menu." = "Zwillinge/Antigravitation, auftauchende Ereignisse im Symbol und Menü."; +"General" = "Allgemein"; +"GitHub" = "GitHub"; +"GitHub Copilot Login" = "GitHub Copilot-Anmeldung"; +"GitHub Login" = "GitHub-Anmeldung"; +"Hide details" = "Details ausblenden"; +"Hide personal information" = "Persönliche Informationen ausblenden"; +"Historical tracking" = "Historische Verfolgung"; +"How often CodexBar polls providers in the background." = "Wie oft fragt CodexBar Anbieter im Hintergrund ab?"; +"Inactive" = "Inaktiv"; +"Install CLI" = "CLI installieren"; +"Install the Claude CLI (npm i -g @anthropic-ai/claude-code) and try again." = "Installieren Sie die Claude-CLI (npm i -g @anthropic-ai/claude-code) und versuchen Sie es erneut."; +"Install the Codex CLI (npm i -g @openai/codex) and try again." = "Installieren Sie die Codex-CLI (npm i -g @openai/codex) und versuchen Sie es erneut."; +"Install the Gemini CLI (npm i -g @google/gemini-cli) and try again." = "Installieren Sie die Gemini-CLI (npm i -g @google/gemini-cli) und versuchen Sie es erneut."; +"JetBrains AI is ready" = "JetBrains AI ist bereit"; +"JetBrains IDE" = "JetBrains-IDE"; +"Keep CLI sessions alive" = "Halten Sie CLI-Sitzungen am Leben"; +"Keyboard shortcut" = "Tastenkombination"; +"Keychain access" = "Schlüsselbundzugriff"; +"Keychain prompt policy" = "Richtlinie für Schlüsselbund-Eingabeaufforderungen"; +"Last \\(name) fetch failed:" = "Der letzte Abruf von \\\\(name) ist fehlgeschlagen:"; +"Last \\(self.store.metadata(for: self.provider).displayName) fetch failed:" = "Letzter Abruf von \\\\(self.store.metadata(for: self.provider).displayName) ist fehlgeschlagen:"; +"Last attempt" = "Letzter Versuch"; +"Link" = "Link"; +"Loading animations" = "Animationen werden geladen"; +"Loading…" = "Laden…"; +"Local" = "Lokal"; +"Logging" = "Protokollierung"; +"Login failed" = "Fehler bei der Anmeldung"; +"Login shell PATH (startup capture)" = "Login-Shell-PATH (Starterfassung)"; +"Login timed out" = "Zeitüberschreitung bei der Anmeldung"; +"MCP details" = "MCP-Details"; +"Managed Codex accounts unavailable" = "Verwaltete Codex-Konten nicht verfügbar"; +"Managed account storage is unreadable. Live account access is still available, " = "Der verwaltete Kontospeicher ist nicht lesbar. Der Live-Kontozugriff ist weiterhin verfügbar."; +"Manual" = "Manuell"; +"May your tokens never run out—keep agent limits in view." = "Mögen Ihre Token nie ausgehen – behalten Sie die Agentenlimits im Blick."; +"Menu bar" = "Menüleiste"; +"Menu bar auto-shows the provider closest to its rate limit." = "In der Menüleiste wird automatisch der Anbieter angezeigt, der seinem Tariflimit am nächsten kommt."; +"Menu bar metric" = "Menüleistenmetrik"; +"Menu bar shows percent" = "Die Menüleiste zeigt Prozent an"; +"Menu content" = "Menüinhalt"; +"Merge Icons" = "Symbole zusammenführen"; +"Never prompt" = "Niemals auffordern"; +"No" = "NEIN"; +"No Codex accounts detected yet." = "Es wurden noch keine Codex-Konten erkannt."; +"No JetBrains IDE detected" = "Keine JetBrains-IDE erkannt"; +"No cost history data." = "Keine Daten zur Kostenhistorie."; +"No data available" = "Keine Daten verfügbar"; +"No data yet" = "Noch keine Daten"; +"No enabled providers available for Overview." = "Für die Übersicht sind keine aktivierten Anbieter verfügbar."; +"No providers selected" = "Keine Anbieter ausgewählt"; +"No token accounts yet." = "Noch keine Token-Konten."; +"No usage breakdown data." = "Keine Nutzungsaufschlüsselungsdaten."; +"None" = "Keiner"; +"Notifications" = "Benachrichtigungen"; +"Notifies when the 5-hour session quota hits 0% and when it becomes " = "Benachrichtigt, wenn das 5-Stunden-Sitzungskontingent 0 % erreicht und wenn dies der Fall ist"; +"OK" = "OK"; +"Obscure email addresses in the menu bar and menu UI." = "Verdecken Sie E-Mail-Adressen in der Menüleiste und der Menü-Benutzeroberfläche."; +"Off" = "Aus"; +"Offline" = "Offline"; +"On" = "An"; +"Online" = "Online"; +"Only on user action" = "Nur bei Benutzeraktion"; +"Open" = "Offen"; +"Open API Keys" = "Offene API-Schlüssel"; +"Open Amp Settings" = "Öffnen Sie die Verstärkereinstellungen"; +"Open Antigravity to sign in, then refresh CodexBar." = "Öffnen Sie Antigravity, um sich anzumelden, und aktualisieren Sie dann CodexBar."; +"Open Browser" = "Öffnen Sie den Browser"; +"Open Coding Plan" = "Codierungsplan öffnen"; +"Open Console" = "Öffnen Sie die Konsole"; +"Open Dashboard" = "Öffnen Sie das Dashboard"; +"Open Mistral Admin" = "Öffnen Sie Mistral Admin"; +"Open Menu Bar Settings" = "Öffnen Sie die Menüleisteneinstellungen"; +"Open Ollama Settings" = "Öffnen Sie die Ollama-Einstellungen"; +"Open Terminal" = "Öffnen Sie das Terminal"; +"Open Usage Page" = "Öffnen Sie die Nutzungsseite"; +"Open Warp API Key Guide" = "Öffnen Sie den Warp-API-Schlüsselleitfaden"; +"Open menu" = "Menü öffnen"; +"Open token file" = "Tokendatei öffnen"; +"OpenAI cookies" = "OpenAI-Cookies"; +"OpenAI web extras" = "OpenAI-Web-Extras"; +"Option A" = "Option A"; +"Option B" = "Option B"; +"Optional override if workspace lookup fails." = "Optionale Überschreibung, wenn die Arbeitsbereichssuche fehlschlägt."; +"Options" = "Optionen"; +"Override auto-detection with a custom IDE base path" = "Überschreiben Sie die automatische Erkennung mit einem benutzerdefinierten IDE-Basispfad"; +"Overview" = "Überblick"; +"Overview rows always follow provider order." = "Übersichtszeilen folgen immer der Anbieterreihenfolge."; +"Overview tab providers" = "Anbieter von Übersichtsregisterkarten"; +"Paste API key…" = "API-Schlüssel einfügen…"; +"Paste API token…" = "API-Token einfügen…"; +"Paste key…" = "Schlüssel einfügen…"; +"Paste sessionKey or OAuth token…" = "SessionKey oder OAuth-Token einfügen…"; +"Paste the Cookie header from a request to admin.mistral.ai. " = "Fügen Sie den Cookie-Header aus einer Anfrage in admin.mistral.ai ein."; +"Paste token…" = "Token einfügen…"; +"Personal" = "Persönlich"; +"Picker" = "Auswahl"; +"Picker subtitle" = "Picker-Untertitel"; +"Placeholder" = "Platzhalter"; +"Plan" = "Planen"; +"Plan Usage" = "Plannutzung"; +"Play full-screen confetti when weekly usage resets." = "Spielen Sie Konfetti im Vollbildmodus ab, wenn die wöchentliche Nutzung zurückgesetzt wird."; +"Polls OpenAI/Claude status pages and Google Workspace for " = "Fragt OpenAI/Claude-Statusseiten und Google Workspace ab"; +"Prevents any Keychain access while enabled." = "Verhindert jeglichen Zugriff auf den Schlüsselbund, solange diese Option aktiviert ist."; +"Primary (API key limit)" = "Primär (API-Schlüssellimit)"; +"Primary (\\(label))" = "Primär (\\\\(label))"; +"Primary (\\(metadata.sessionLabel))" = "Primär (\\\\(metadata.sessionLabel))"; +"Probe logs" = "Sondenprotokolle"; +"Progress bars fill as you consume quota (instead of showing remaining)." = "Fortschrittsbalken füllen sich, wenn Sie das Kontingent verbrauchen (anstatt die verbleibende Menge anzuzeigen)."; +"Provider" = "Anbieter"; +"Providers" = "Anbieter"; +"Quit CodexBar" = "CodexBar beenden"; +"Random (default)" = "Zufällig (Standard)"; +"Reads local usage logs. Shows today + last 30 days cost in the menu." = "Liest lokale Nutzungsprotokolle. Zeigt heute + das ausgewählte Verlaufsfenster im Menü an."; +"Refresh" = "Aktualisieren"; +"Refresh cadence" = "Trittfrequenz aktualisieren"; +"Remote" = "Remote"; +"Remove" = "Entfernen"; +"Remove Codex account?" = "Codex-Konto entfernen?"; +"Remove \\(account.email) from CodexBar? Its managed Codex home will be deleted." = "\\\\(account.email) aus CodexBar entfernen? Das verwaltete Codex-Heim wird gelöscht."; +"Remove \\(email) from CodexBar? Its managed Codex home will be deleted." = "\\\\(email) aus CodexBar entfernen? Das verwaltete Codex-Heim wird gelöscht."; +"Remove selected account" = "Ausgewähltes Konto entfernen"; +"Replace critter bars with provider branding icons and a percentage." = "Ersetzen Sie die Leisten durch Anbieter-Branding-Symbole und einen Prozentsatz."; +"Replay selected animation" = "Ausgewählte Animation erneut abspielen"; +"Requires authentication via GitHub Device Flow." = "Erfordert Authentifizierung über GitHub Device Flow."; +"Resets: \\(reset)" = "Zurückgesetzt: \\\\(reset)"; +"Rolling five-hour limit" = "Rollierendes Fünf-Stunden-Limit"; +"Search hourly" = "Stündlich suchen"; +"Secondary (\\(label))" = "Sekundär (\\\\(label))"; +"Secondary (\\(metadata.weeklyLabel))" = "Sekundär (\\\\(metadata.weeklyLabel))"; +"Select a provider" = "Wählen Sie einen Anbieter aus"; +"Select the IDE to monitor" = "Wählen Sie die zu überwachende IDE aus"; +"Session quota notifications" = "Benachrichtigungen über Sitzungskontingente"; +"Session tokens" = "Sitzungstoken"; +"provider_section_connection" = "Verbindung"; +"provider_section_menu_bar" = "Menüleiste"; +"Show Codex Credits and Claude Extra usage sections in the menu." = "Zeigen Sie die Nutzungsabschnitte \"Codex Credits\" und \"Claude Extra\" im Menü an."; +"Show Debug Settings" = "Debug-Einstellungen anzeigen"; +"Show all token accounts" = "Alle Token-Konten anzeigen"; +"Show cost summary" = "Kostenübersicht anzeigen"; +"Show credits + extra usage" = "Credits + zusätzliche Nutzung anzeigen"; +"Show details" = "Details anzeigen"; +"Show most-used provider" = "Meistgenutzten Anbieter anzeigen"; +"Show provider icons in the switcher (otherwise show a weekly progress line)." = "Anbietersymbole im Umschalter anzeigen (andernfalls eine wöchentliche Fortschrittslinie anzeigen)."; +"Show reset time as clock" = "Reset-Zeit als Uhr anzeigen"; +"Show usage as used" = "Nutzung als verbraucht anzeigen"; +"Sign in via button below" = "Melden Sie sich über die Schaltfläche unten an"; +"Skip teardown between probes (debug-only)." = "Teardown zwischen Probes überspringen (nur Debug)."; +"Stack token accounts in the menu (otherwise show an account switcher bar)." = "Stapeln Sie Token-Konten im Menü (andernfalls wird eine Kontowechselleiste angezeigt)."; +"Start at Login" = "Beginnen Sie mit der Anmeldung"; +"Status" = "Status"; +"Store Claude sessionKey cookies or OAuth access tokens." = "Speichern Sie Claude-SessionKey-Cookies oder OAuth-Zugriffstoken."; +"Store multiple Abacus AI Cookie headers." = "Speichern Sie mehrere Abacus AI Cookie-Header."; +"Store multiple Augment Cookie headers." = "Speichern Sie mehrere Augment-Cookie-Header."; +"Store multiple Cursor Cookie headers." = "Speichern Sie mehrere Cursor-Cookie-Header."; +"Store multiple Factory Cookie headers." = "Speichern Sie mehrere Factory-Cookie-Header."; +"Store multiple MiniMax Cookie headers." = "Speichern Sie mehrere MiniMax-Cookie-Header."; +"Store multiple Mistral Cookie headers." = "Speichern Sie mehrere Mistral-Cookie-Header."; +"Store multiple Ollama Cookie headers." = "Speichern Sie mehrere Ollama-Cookie-Header."; +"Store multiple OpenCode Cookie headers." = "Speichern Sie mehrere OpenCode-Cookie-Header."; +"Store multiple OpenCode Go Cookie headers." = "Speichern Sie mehrere OpenCode Go-Cookie-Header."; +"Stored in the CodexBar config file." = "Wird in der CodexBar-Konfigurationsdatei gespeichert."; +"Stored in ~/.codexbar/config.json. " = "Gespeichert in ~/.codexbar/config.json."; +"Stored in ~/.codexbar/config.json. Paste the key from the Synthetic dashboard." = "Gespeichert in ~/.codexbar/config.json. Fügen Sie den Schlüssel aus dem Synthetic-Dashboard ein."; +"Stored in ~/.codexbar/config.json. Paste your Coding Plan API key from Model Studio." = "Gespeichert in ~/.codexbar/config.json. Fügen Sie Ihren Coding Plan API-Schlüssel aus Model Studio ein."; +"Stored in ~/.codexbar/config.json. Paste your MiniMax API key." = "Gespeichert in ~/.codexbar/config.json. Fügen Sie Ihren MiniMax-API-Schlüssel ein."; +"Stored in ~/.codexbar/config.json. You can also provide KILO_API_KEY or " = "Gespeichert in ~/.codexbar/config.json. Sie können auch KILO_API_KEY oder angeben"; +"Stores local Codex usage history (8 weeks) to personalize Pace predictions." = "Speichert den lokalen Codex-Nutzungsverlauf (8 Wochen), um Pace-Vorhersagen zu personalisieren."; +"Surprise me" = "Überrasche mich"; +"Switcher shows icons" = "Switcher zeigt Symbole an"; +"Symlink CodexBarCLI to /usr/local/bin and /opt/homebrew/bin as codexbar." = "Verknüpfen Sie CodexBarCLI mit /usr/local/bin und /opt/homebrew/bin als Codexbar."; +"System" = "System"; +"terminal_app_subtitle" = "Terminal für die Aktion „Terminal öffnen“"; +"terminal_app_title" = "Standardterminal"; +"Temporarily shows the loading animation after the next refresh." = "Zeigt nach der nächsten Aktualisierung vorübergehend die Ladeanimation an."; +"Tertiary (\\(label))" = "Tertiär (\\\\(label))"; +"Tertiary (\\(tertiaryTitle))" = "Tertiär (\\\\(tertiaryTitle))"; +"The default Codex account on this Mac." = "Das Standard-Codex-Konto auf diesem Mac."; +"Toggle" = "Umschalten"; +"Toggle subtitle" = "Untertitel umschalten"; +"Token" = "Token"; +"Trigger the menu bar menu from anywhere." = "Lösen Sie das Menü der Menüleiste von überall aus aus."; +"True" = "WAHR"; +"Twitter" = "Twitter"; +"Unsupported" = "Nicht unterstützt"; +"Update Channel" = "Kanal aktualisieren"; +"Updated" = "Aktualisiert"; +"Updates unavailable in this build." = "Updates sind in diesem Build nicht verfügbar."; +"Usage" = "Verwendung"; +"Usage breakdown" = "Aufschlüsselung der Nutzung"; +"Usage history (30 days)" = "Nutzungsverlauf"; +"Usage source" = "Nutzungsquelle"; +"Use BigModel for the China mainland endpoints (open.bigmodel.cn)." = "Verwenden Sie BigModel für die Endpunkte auf dem chinesischen Festland (open.bigmodel.cn)."; +"Use a single menu bar icon with a provider switcher." = "Verwenden Sie ein einzelnes Menüleistensymbol mit einem Anbieter-Umschalter."; +"Use international or China mainland console gateways for quota fetches." = "Verwenden Sie für Kontingentabrufe internationale Konsolen-Gateways oder Konsolen-Gateways auf dem chinesischen Festland."; +"Version" = "Version"; +"Version \\(self.versionString)" = "Version \\(self.versionString)"; +"Version \\(version)" = "Version \\(version)"; +"Version \\(versionString)" = "Version \\(versionString)"; +"Vertex AI Login" = "Vertex AI Login"; +"Wait for the current managed Codex login to finish before adding another account." = "Warten Sie, bis die aktuell verwaltete Codex-Anmeldung abgeschlossen ist, bevor Sie ein weiteres Konto hinzufügen."; +"Waiting for Authentication..." = "Warten auf Authentifizierung..."; +"Website" = "Webseite"; +"Weekly limit confetti" = "Wöchentliches Konfetti-Limit"; +"Weekly token limit" = "Wöchentliches Token-Limit"; +"Weekly usage" = "Wöchentliche Nutzung"; +"Weekly usage unavailable for this account." = "Die wöchentliche Nutzung ist für dieses Konto nicht verfügbar."; +"Window: \\(window)" = "Fenster: \\\\(window)"; +"Write logs to \\(self.fileLogPath) for debugging." = "Schreiben Sie Protokolle zum Debuggen nach \\\\(self.fileLogPath)."; +"Yes" = "Ja"; +"\\(detail.modelCode): \\(usage)" = "\\(detail.modelCode): \\(usage)"; +"\\(name): \\(truncated)" = "\\(name): \\(truncated)"; +"\\(name): \\(updated) · 30d \\(cost)" = "\\(name): \\(updated) · 30d \\(cost)"; +"\\(name): fetching…\\(elapsed)" = "\\\\(name): Abrufen…\\\\(elapsed)"; +"\\(name): last attempt \\(when)" = "\\\\(name): letzter Versuch \\\\(when)"; +"\\(name): no data yet" = "\\\\(name): noch keine Daten"; +"\\(name): unsupported" = "\\\\(name): nicht unterstützt"; +"all browsers" = "alle Browser"; +"available again." = "wieder verfügbar."; +"built_format" = "Gebaut %@"; +"copilot_complete_in_browser" = "Melden Sie sich vollständig in Ihrem Browser an."; +"copilot_device_code" = "Gerätecode in die Zwischenablage kopiert: %1$@\n\nÜberprüfen unter: %2$@"; +"copilot_device_code_copied" = "Gerätecode kopiert."; +"copilot_verify_at" = "Überprüfen Sie um %@"; +"copilot_waiting_text" = "Schließen Sie die Anmeldung in Ihrem Browser ab.\nDieses Fenster wird automatisch geschlossen, wenn die Anmeldung abgeschlossen ist."; +"copilot_window_closes_auto" = "Dieses Fenster wird automatisch geschlossen, wenn die Anmeldung abgeschlossen ist."; +"cost_status_error" = "%1$@: %2$@"; +"cost_status_fetching" = "%1$@: Abrufen… %2$@"; +"cost_status_last_attempt" = "%1$@: letzter Versuch %2$@"; +"cost_status_no_data" = "%@: noch keine Daten"; +"cost_status_snapshot" = "%1$@: %2$@ · %3$@ %4$@"; +"cost_status_unsupported" = "%@: nicht unterstützt"; +"credits_remaining" = "Credits: %@"; +"cursor_on_demand" = "Auf Anfrage: %@"; +"cursor_on_demand_with_limit" = "Auf Anfrage: %1$@ / %2$@"; +"extra_usage_format" = "Zusätzliche Nutzung: %1$@ / %2$@"; +"jetbrains_detected_generate" = "Erkannt: %@. Verwenden Sie den KI-Assistenten einmal, um Quotendaten zu generieren, und aktualisieren Sie dann CodexBar."; +"jetbrains_detected_select" = "Erkannt: %@. Wählen Sie in den Einstellungen Ihre bevorzugte IDE aus und aktualisieren Sie dann CodexBar."; +"last_fetch_failed_with_provider" = "Der letzte Abruf von %@ ist fehlgeschlagen:"; +"last_spend" = "Letzte Ausgabe: %@"; +"mcp_model_usage" = "%1$@: %2$@"; +"mcp_resets" = "Zurückgesetzt: %@"; +"mcp_window" = "Fenster: %@"; +"metric_average" = "Durchschnitt (%1$@ + %2$@)"; +"metric_primary" = "Primär (%@)"; +"metric_secondary" = "Sekundär (%@)"; +"metric_tertiary" = "Tertiärbereich (%@)"; +"multiple_workspaces_found" = "CodexBar hat mehrere Arbeitsbereiche für %@ gefunden. Bitte wählen Sie den Arbeitsbereich aus, den Sie hinzufügen möchten."; +"ory_session_…=…; csrftoken=…" = "ory_session_…=…; csrftoken=…"; +"overview_choose_providers" = "Wählen Sie bis zu %@ Anbieter aus"; +"remove_account_message" = "%@ aus CodexBar entfernen? Das verwaltete Codex-Heim wird gelöscht."; +"version_format" = "Version %@"; +"vertex_ai_login_instructions" = "Um die Vertex AI-Nutzung zu verfolgen, authentifizieren Sie sich bei Google Cloud.\n\n1. Öffnen Sie Terminal\n2. Führen Sie Folgendes aus: gcloud auth application-default login\n3. Befolgen Sie die Anweisungen des Browsers, um sich anzumelden\n4. Legen Sie Ihr Projekt fest: gcloud config set project PROJECT_ID\n\nTerminal jetzt öffnen?"; +"workspaceID is set but only opencode, opencodego, and deepgram support workspaceID." = "Die Arbeitsbereichs-ID ist festgelegt, aber nur Opencode, Opencodego und Deepgram unterstützen die Arbeitsbereichs-ID."; +"© 2026 Peter Steinberger. MIT License." = "© 2026 Peter Steinberger. MIT-Lizenz."; + +/* General Pane */ +"section_system" = "System"; +"section_usage" = "Nutzung"; +"section_refreshing" = "Aktualisierung"; +"section_alerts" = "Warnungen"; +"section_celebrations" = "Feiern"; +"section_icon" = "Symbol"; +"section_combined_icon" = "Kombiniertes Symbol"; +"section_animation" = "Animation"; +"section_content" = "Inhalt"; +"section_agent_sessions" = "Agenten-Sitzungen"; +"language_title" = "Sprache"; +"language_subtitle" = "Anzeigesprache wechseln. Ein App-Neustart wird empfohlen."; +"currency_title" = "Bevorzugte Währung"; +"currency_subtitle" = "Währung für Kostenschätzungen und Ausgaben. Verwendet täglich aktualisierte Wechselkurse."; +"currency_auto" = "Automatisch (Anbieter / USD)"; +"language_system" = "System"; +"language_english" = "Englisch"; +"language_spanish" = "Español"; +"language_catalan" = "Català"; +"language_chinese_simplified" = "简体中文"; +"language_chinese_traditional" = "繁體中文"; +"language_portuguese_brazilian" = "Português (Brasil)"; +"language_german" = "Deutsch"; +"language_japanese" = "Japanisch"; +"language_swedish" = "Svenska"; +"language_dutch" = "Niederländisch"; +"language_french" = "Französisch"; +"language_ukrainian" = "Ukrainisch"; +"language_russian" = "Русский"; +"language_vietnamese" = "Vietnamesisch"; +"language_korean" = "Koreanisch"; +"language_turkish" = "Türkçe"; +"language_italian" = "Italiano"; +"language_indonesian" = "Indonesisch"; +"language_polish" = "Polnisch"; +"start_at_login_title" = "Beim Login starten"; +"start_at_login_subtitle" = "Startet CodexBar automatisch, wenn dein Mac hochfährt."; +"show_cost_summary_subtitle" = "Liest lokale Nutzungsprotokolle. Zeigt heute + das gewählte Verlaufsfenster im Menü."; +"cost_summary_style_title" = "Anzeigestil"; +"cost_summary_style_inline" = "Nur Inline"; +"cost_summary_style_submenu" = "Nur Untermenü"; +"cost_summary_style_both" = "Beides"; +"cost_summary_style_inline_help" = "Zeigt die Kostenübersicht direkt im Hauptmenü."; +"cost_summary_style_submenu_help" = "Zeigt stattdessen das detaillierte Kosten-Untermenü."; +"cost_summary_style_both_help" = "Zeigt die Hauptmenü-Übersicht und das detaillierte Kosten-Untermenü."; +"cost_history_window_title" = "Verlaufsfenster"; +"cost_history_window_help" = "Legt fest, wie viele Tage lokaler Nutzungsprotokolle im Menü erscheinen."; +"cost_history_days_title" = "Verlaufsfenster: %d Tage"; +"cost_auto_refresh_info" = "Auto-Aktualisierung: globales Intervall (mindestens 5 Min.) · Timeout: 10 Min."; +"cost_comparison_periods_title" = "Kürzere Vergleichszeiträume anzeigen"; +"cost_comparison_periods_subtitle" = "Fügt Summen für 7, 30 und 90 Tage hinzu, wenn sie in den ausgewählten Verlaufszeitraum passen. Diese Summen verwenden denselben lokalen Scan."; +"refresh_interval_title" = "Aktualisierungsintervall"; +"manual_refresh_hint" = "Auto-Aktualisierung ist aus; nutze im Menü den Befehl „Aktualisieren“."; +"refresh_on_open_title" = "Beim Öffnen des Menüs aktualisieren"; +"refresh_on_open_subtitle" = "Bei jedem Öffnen des Menüs die aktuelle Nutzung aller Anbieter abrufen."; +"check_provider_status_title" = "Anbieterstatus prüfen"; +"check_provider_status_subtitle" = "Prüft OpenAI/Claude-Statusseiten und Google Workspace für Gemini/Antigravity und zeigt Vorfälle in Icon und Menü."; +"session_quota_notifications_subtitle" = "Benachrichtigt, wenn das 5-Stunden-Sitzungslimit 0 % erreicht und wenn es wieder verfügbar ist."; +"quota_depleted_title" = "Kontingent erschöpft und wiederhergestellt"; +"quota_warning_notifications_subtitle" = "Warnt, wenn verbleibende Sitzungs- oder Wochenquote die konfigurierten Schwellenwerte unterschreitet."; +"threshold_warnings_title" = "Schwellenwertwarnungen"; +"quota_warnings_title" = "Kontingentwarnungen"; +"quota_warning_session" = "Sitzung"; +"quota_warning_session_capitalized" = "Sitzung"; +"quota_warning_weekly" = "wöchentlich"; +"quota_warning_weekly_capitalized" = "Wöchentlich"; +"quota_warning_notification_title" = "%1$@ %2$@ Kontingent niedrig"; +"quota_warning_notification_body" = "%1$@ übrig. Reached your %2$d%% %3$@ warning threshold."; +"quota_warning_notification_body_with_account" = "Konto %1$@. %2$@ übrig. Ihr Warnschwellenwert von %3$d%% %4$@ wurde erreicht."; +"predictive_pace_warnings_title" = "Vorausschauende Tempo-Warnungen"; +"predictive_pace_warnings_subtitle" = "Warnt für Codex und Claude, wenn die Sitzungs- oder Wochenquote beim aktuellen Tempo vor dem Zurücksetzen aufgebraucht sein könnte."; +"confetti_on_reset_title" = "Konfetti beim Zurücksetzen"; +"confetti_on_reset_subtitle" = "Spielt Vollbild-Konfetti ab, wenn die Nutzung zurückgesetzt wird."; +"confetti_option_off" = "Aus"; +"confetti_option_session" = "Sitzungs-Resets"; +"confetti_option_weekly" = "Wöchentliche Resets"; +"confetti_option_both" = "Beide"; +"predictive_pace_warning_notification_title" = "%1$@: Tempo-Warnung (%2$@)"; +"predictive_pace_warning_notification_body" = "Beim aktuellen Tempo könnte diese Quote in %1$@ aufgebraucht sein, bevor sie zurückgesetzt wird."; +"predictive_pace_warning_notification_body_with_account" = "Konto %1$@. Beim aktuellen Tempo könnte diese Quote in %2$@ aufgebraucht sein, bevor sie zurückgesetzt wird."; +"session_depleted_notification_title" = "%@ Sitzung erschöpft"; +"session_depleted_notification_body" = "0 % übrig. Werde benachrichtigen, wenn es wieder verfügbar ist."; +"session_restored_notification_title" = "%@ Sitzung wiederhergestellt"; +"session_restored_notification_body" = "Das Sitzungskontingent ist wieder verfügbar."; +"quota_warning_warn_at" = "Warnen Sie vor"; +"quota_warning_global_threshold_subtitle" = "Verbleibende Prozentsätze für Sitzungs- und Wochenfenster, es sei denn, ein Anbieter überschreibt sie."; +"quota_warning_sound" = "Benachrichtigungston abspielen"; +"quota_warning_onscreen_alert" = "Bildschirm-Textwarnung anzeigen"; +"quota_warning_provider_inherits" = "Verwendet die globalen Einstellungen für Kontingentwarnungen, es sei denn, hier wird ein Fenster angepasst."; +"quota_warning_provider_disabled" = "Benachrichtigungen für Kontingentwarnungen und Markierungen in den Nutzungsleisten sind deaktiviert. Aktivieren Sie eine der beiden Optionen, um diese gespeicherten Einstellungen zu bearbeiten."; +"quota_warning_provider_markers_only" = "Kontingentwarnungsmitteilungen sind global deaktiviert. Diese Einstellungen steuern weiterhin die Markierungen in den Nutzungsleisten."; +"quota_warning_global" = "Global"; +"quota_warning_customize_thresholds" = "Passen Sie die Schwellenwerte für %@ an"; +"quota_warning_enable_warnings" = "Aktivieren Sie %@-Warnungen"; +"quota_warning_window_warn_at" = "%@ warnen bei"; +"quota_warning_off" = "Aus"; +"quota_warning_inherited" = "Geerbt: %@"; +"quota_warning_depleted_only" = "nur erschöpft"; +"quota_warning_upper" = "Höher"; +"quota_warning_lower" = "Untere"; +"quota_warning_warning" = "Warnung"; +"quota_warning_critical" = "Kritisch"; +"apply" = "Anwenden"; +"quit_app" = "CodexBar beenden"; + +/* Tab titles */ +"tab_general" = "Allgemein"; +"tab_providers" = "Anbieter"; +"tab_notifications" = "Benachrichtigungen"; +"tab_menu_bar" = "Menüleiste"; +"tab_menu" = "Menü"; +"tab_advanced" = "Fortschrittlich"; +"tab_about" = "Um"; +"tab_debug" = "Debuggen"; + +/* Providers Pane */ +"select_a_provider" = "Wählen Sie einen Anbieter aus"; +"cancel" = "Stornieren"; +"last_fetch_failed" = "Der letzte Abruf ist fehlgeschlagen"; +"usage_not_fetched_yet" = "Nutzung noch nicht abgerufen"; +"managed_account_storage_unreadable" = "Der verwaltete Kontospeicher ist nicht lesbar. Der Live-Kontozugriff ist weiterhin verfügbar, die verwalteten Aktionen \"Hinzufügen\", \"Erneute Authentifizierung\" und \"Entfernen\" sind jedoch deaktiviert, bis der Store wiederhergestellt werden kann."; +"remove_codex_account_title" = "Codex-Konto entfernen?"; +"remove" = "Entfernen"; +"managed_login_already_running" = "Eine verwaltete Codex-Anmeldung läuft bereits. Warten Sie, bis der Vorgang abgeschlossen ist, bevor Sie ein weiteres Konto hinzufügen oder erneut authentifizieren."; +"managed_login_failed" = "Die Anmeldung bei verwaltetem Codex wurde nicht abgeschlossen. Stellen Sie sicher, dass \"codex --version\" im Terminal funktioniert. Wenn macOS blockiert oder \"Codex\" in den Papierkorb verschoben hat, entfernen Sie veraltete doppelte Installationen, führen Sie \"npm install -g --include=optional @openai/codex@latest\" aus und versuchen Sie es dann erneut."; +"codex_login_output" = "Codex-Login-Ausgabe:"; +"managed_login_missing_email" = "Codex-Anmeldung abgeschlossen, aber keine Konto-E-Mail-Adresse verfügbar. Versuchen Sie es erneut, nachdem Sie sich vergewissert haben, dass das Konto vollständig angemeldet ist."; +"login_success_notification_title" = "%@ Anmeldung erfolgreich"; +"login_success_notification_body" = "Sie können zur App zurückkehren; Authentifizierung abgeschlossen."; +"workspace_selection_cancelled" = "CodexBar hat mehrere Arbeitsbereiche gefunden, es wurde jedoch kein Arbeitsbereich ausgewählt."; +"unsafe_managed_home" = "CodexBar weigerte sich, einen unerwarteten verwalteten Home-Pfad zu ändern: %@"; +"menu_bar_metric_title" = "Menüleistenmetrik"; +"menu_bar_metric_subtitle" = "Wählen Sie aus, welches Fenster den Prozentwert der Menüleiste steuert."; +"menu_bar_metric_subtitle_deepseek" = "Zeigt das DeepSeek-Guthaben in der Menüleiste an."; +"menu_bar_metric_subtitle_moonshot" = "Zeigt das Moonshot-/Kimi-API-Guthaben in der Menüleiste an."; +"menu_bar_metric_subtitle_mistral" = "Zeigt die Mistral-API-Ausgaben des aktuellen Monats in der Menüleiste an."; +"automatic" = "Automatisch"; +"primary_api_key_limit" = "Primär (API-Schlüssellimit)"; + +/* Display Pane */ +"menu_bar_style_title" = "Menüleistenstil"; +"menu_bar_style_subtitle" = "Legt fest, wie das Menüleistenelement dargestellt wird."; +"menu_bar_inactive_display_contrast_title" = "Sichtbarkeit auf inaktiven Displays verbessern"; +"menu_bar_usage_colors_title" = "Farbcodierte Auslastung"; +"menu_bar_usage_colors_subtitle" = "Färbt das Menüleistensymbol von Grün nach Rot, wenn die Auslastung steigt."; +"menu_bar_inactive_display_contrast_subtitle" = "Verwendet eine kontrastreiche Darstellung, damit Symbol und Messwert auf anderen Displays lesbar bleiben."; +"menu_bar_style_critters" = "Kreaturen"; +"menu_bar_style_bars" = "Messleisten"; +"menu_bar_style_icon_percent" = "Symbol und Prozent"; +"switcher_rows_title" = "Umschalterzeilen"; +"switcher_rows_icons" = "Anbietersymbole"; +"switcher_rows_progress" = "Wöchentlicher Fortschritt"; +"usage_bars_fill_title" = "Füllung der Nutzungsbalken"; +"usage_bars_fill_remaining" = "Verbleibend"; +"usage_bars_fill_used" = "Verbraucht"; +"reset_times_title" = "Zurücksetzzeiten"; +"reset_times_countdown" = "Countdown"; +"reset_times_clock" = "Uhrzeit"; +"cost_summary_title" = "Kostenübersicht"; +"cost_summary_off" = "Aus"; +"merge_icons_title" = "Symbole zusammenführen"; +"merge_icons_subtitle" = "Verwenden Sie ein einzelnes Menüleistensymbol mit einem Anbieter-Umschalter."; +"show_most_used_provider_title" = "Meistgenutzten Anbieter anzeigen"; +"show_most_used_provider_subtitle" = "In der Menüleiste wird automatisch der Anbieter angezeigt, der seinem Tariflimit am nächsten kommt."; +"display_mode_title" = "Anzeigemodus"; +"display_mode_subtitle" = "Wählen Sie aus, was in der Menüleiste angezeigt werden soll (Pace zeigt die Nutzung im Vergleich zur erwarteten)."; +"show_quota_warning_markers_title" = "Quotenwarnmarkierungen anzeigen"; +"show_quota_warning_markers_subtitle" = "Zeichnen Sie Schwellenwertmarkierungen auf Nutzungsbalken, wenn Kontingentwarnungen konfiguriert sind."; +"weekly_progress_work_days_title" = "Wöchentliche Fortschrittsarbeitstage"; +"weekly_progress_work_days_subtitle" = "Legt Arbeitstage für Markierungen in wöchentlichen Nutzungsbalken und Tempo-Berechnungen fest."; +"show_provider_changelog_links_title" = "Links zum Änderungsprotokoll des Anbieters anzeigen"; +"show_provider_changelog_links_subtitle" = "Fügt dem Menü Versionshinweise-Links für unterstützte CLI-gestützte Anbieter hinzu."; +"show_credits_extra_usage_title" = "Credits + zusätzliche Nutzung anzeigen"; +"show_credits_extra_usage_subtitle" = "Zeigen Sie die Nutzungsabschnitte \"Codex Credits\" und \"Claude Extra\" im Menü an."; +"multi_account_layout_title" = "Layout für mehrere Konten"; +"multi_account_layout_subtitle" = "Wählen Sie segmentierte Kontoumschaltung oder gestapelte Kontokarten."; +"multi_account_layout_segmented" = "Segmentiert"; +"multi_account_layout_stacked" = "Gestapelt"; +"overview_tab_providers_title" = "Anbieter von Übersichtsregisterkarten"; +"configure" = "Konfigurieren…"; +"overview_enable_merge_icons_hint" = "Aktivieren Sie \"Symbole zusammenführen\", um Anbieter für die Registerkarte \"Übersicht\" zu konfigurieren."; +"overview_no_providers_hint" = "Für die Übersicht sind keine aktivierten Anbieter verfügbar."; +"overview_rows_follow_order" = "Übersichtszeilen folgen immer der Anbieterreihenfolge."; +"overview_no_providers_selected" = "Keine Anbieter ausgewählt"; +"agent_sessions_title" = "Agenten-Sitzungen"; +"agent_sessions_subtitle" = "Lokale und über SSH erkannte Codex- und Claude-Code-Sitzungen im Menü anzeigen."; +"agent_sessions_hosts_title" = "Zusätzliche SSH-Hosts"; +"agent_sessions_footer" = "Macs in deinem Tailnet werden automatisch erkannt. Lokale Sitzungen werden alle 30 Sekunden aktualisiert; Remote-Hosts alle 60 Sekunden und beim Öffnen des Menüs."; +"agent_session_labels_title" = "Sitzungsbezeichnungen"; +"agent_session_labels_subtitle" = "Wähle aus, wie Agenten-Sitzungen benannt werden."; +"agent_session_label_project" = "Projekt"; +"agent_session_label_descriptive" = "Beschreibend"; +"agent_session_label_descriptive_and_project" = "Beschreibend + Projekt"; +"agent_session_unknown_project" = "Unbekanntes Projekt"; + +/* Advanced Pane */ +"section_keyboard_shortcut" = "Tastenkombination"; +"open_menu_shortcut_title" = "Menü öffnen"; +"open_menu_shortcut_subtitle" = "Lösen Sie das Menü der Menüleiste von überall aus aus."; +"install_cli" = "CLI installieren"; +"install_cli_subtitle" = "Verknüpfen Sie CodexBarCLI mit /usr/local/bin und /opt/homebrew/bin als Codexbar."; +"cli_not_found" = "CodexBarCLI wurde im App-Bundle nicht gefunden."; +"no_writable_bin_dirs" = "Keine beschreibbaren Bin-Verzeichnisse gefunden."; +"show_debug_settings_title" = "Debug-Einstellungen anzeigen"; +"show_debug_settings_subtitle" = "Stellen Sie Tools zur Fehlerbehebung auf der Registerkarte \"Debug\" bereit."; +"surprise_me_title" = "Überrasche mich"; +"surprise_me_subtitle" = "Prüfen Sie, ob Sie möchten, dass Ihre Agenten dort oben Spaß haben."; +"hide_personal_info_title" = "Persönliche Informationen ausblenden"; +"hide_personal_info_subtitle" = "Verdecken Sie E-Mail-Adressen in der Menüleiste und der Menü-Benutzeroberfläche."; +"show_provider_storage_usage_title" = "Speichernutzung des Anbieters anzeigen"; +"show_provider_storage_usage_subtitle" = "Zeigen Sie die lokale Festplattennutzung in Menüs an. Scannt bekannte anbietereigene Pfade im Hintergrund."; +"section_keychain_access" = "Schlüsselbundzugriff"; +"keychain_access_caption" = "Deaktivieren Sie alle Lese- und Schreibvorgänge im Schlüsselbund. Verwenden Sie diese Option, wenn macOS weiterhin nach \"Chrome/Brave/Edge Safe Storage\" fragt, auch nachdem Sie auf \"Immer zulassen\" geklickt haben. Der Browser-Cookie-Import ist nicht verfügbar, solange er aktiviert ist. Fügen Sie Cookie-Header manuell in Provider ein. Claude/Codex OAuth über die CLI funktioniert weiterhin."; +"disable_keychain_access_title" = "Deaktivieren Sie den Schlüsselbundzugriff"; +"disable_keychain_access_subtitle" = "Verhindert jeglichen Zugriff auf den Schlüsselbund, solange diese Option aktiviert ist."; + +/* About Pane */ +"about_tagline" = "Mögen Ihre Token nie ausgehen – behalten Sie die Agentenlimits im Blick."; +"link_github" = "GitHub"; +"link_website" = "Webseite"; +"link_twitter" = "Twitter"; +"link_email" = "E-Mail"; +"check_updates_auto" = "Suchen Sie automatisch nach Updates"; +"update_channel" = "Kanal aktualisieren"; +"check_for_updates" = "Nach Updates suchen…"; +"updates_unavailable" = "Updates sind in diesem Build nicht verfügbar."; +"copyright" = "© 2026 Peter Steinberger. MIT-Lizenz."; + +/* Debug Pane */ +"section_logging" = "Protokollierung"; +"enable_file_logging" = "Aktivieren Sie die Dateiprotokollierung"; +"enable_file_logging_subtitle" = "Schreiben Sie Protokolle zum Debuggen in %@."; +"verbosity_title" = "Ausführlichkeit"; +"verbosity_subtitle" = "Steuert, wie viele Details protokolliert werden."; +"open_log_file" = "Protokolldatei öffnen"; +"force_animation_next_refresh" = "Animation bei der nächsten Aktualisierung erzwingen"; +"force_animation_next_refresh_subtitle" = "Zeigt nach der nächsten Aktualisierung vorübergehend die Ladeanimation an."; +"section_loading_animations" = "Animationen werden geladen"; +"loading_animations_caption" = "Wählen Sie ein Muster aus und spielen Sie es in der Menüleiste ab. \\\"Random\\\" behält das bestehende Verhalten bei."; +"animation_random_default" = "Zufällig (Standard)"; +"replay_selected_animation" = "Ausgewählte Animation erneut abspielen"; +"blink_now" = "Blinzeln Sie jetzt"; +"section_probe_logs" = "Sondenprotokolle"; +"probe_logs_caption" = "Rufen Sie die neueste Probe-Ausgabe zum Debuggen ab. Beim Kopieren bleibt der vollständige Text erhalten."; +"fetch_log" = "Protokoll abrufen"; +"copy" = "Kopie"; +"save_to_file" = "In Datei speichern"; +"load_parse_dump" = "Parse-Dump laden"; +"rerun_provider_autodetect" = "Führen Sie die automatische Anbietererkennung erneut aus"; +"loading" = "Laden…"; +"no_log_yet_fetch" = "Noch kein Protokoll. Zum Laden abrufen."; +"section_fetch_strategy" = "Strategieversuche abrufen"; +"fetch_strategy_caption" = "Entscheidungen und Fehler der letzten Abrufpipeline für einen Anbieter."; +"section_openai_cookies" = "OpenAI-Cookies"; +"openai_cookies_caption" = "Cookie-Import + WebKit-Scrape-Protokolle vom letzten OpenAI-Cookie-Versuch."; +"no_log_yet" = "Noch kein Protokoll. Aktualisieren Sie OpenAI-Cookies unter Anbieter → Codex, um einen Import auszuführen."; +"section_caches" = "Caches"; +"caches_caption" = "Löschen Sie zwischengespeicherte Kosten-Scan-Ergebnisse oder Browser-Cookie-Caches."; +"clear_cookie_cache" = "Cookie-Cache leeren"; +"clear_cost_cache" = "Kostencache löschen"; +"section_notifications" = "Benachrichtigungen"; +"notifications_caption" = "Testbenachrichtigungen für das 5-Stunden-Sitzungsfenster auslösen (erschöpft/wiederhergestellt)."; +"post_depleted" = "Beitrag erschöpft"; +"post_restored" = "Beitrag wiederhergestellt"; +"section_cli_sessions" = "CLI-Sitzungen"; +"cli_sessions_caption" = "Halten Sie Codex/Claude-CLI-Sitzungen nach einer Untersuchung am Leben. Die Standardeinstellung wird beendet, sobald Daten erfasst wurden."; +"keep_cli_sessions_alive" = "Halten Sie CLI-Sitzungen am Leben"; +"keep_cli_sessions_alive_subtitle" = "Teardown zwischen Probes überspringen (nur Debug)."; +"reset_cli_sessions" = "CLI-Sitzungen zurücksetzen"; +"section_error_simulation" = "Fehlersimulation"; +"error_simulation_caption" = "Fügen Sie zum Testen des Layouts eine gefälschte Fehlermeldung in die Menükarte ein."; +"set_menu_error" = "Menüfehler einstellen"; +"clear_menu_error" = "Menüfehler löschen"; +"set_cost_error" = "Kostenfehler festlegen"; +"clear_cost_error" = "Klarer Kostenfehler"; +"section_cli_paths" = "CLI-Pfade"; +"cli_paths_caption" = "Codex-Binär- und PATH-Ebenen behoben; Start-Login-PATH-Erfassung (kurze Zeitüberschreitung)."; +"codex_binary" = "Codex-Binärdatei"; +"claude_binary" = "Claude binär"; +"effective_path" = "Effektiver WEG"; +"unavailable" = "Nicht verfügbar"; +"login_shell_path" = "Login-Shell-PATH (Starterfassung)"; +"cleared" = "Gelöscht."; +"no_fetch_attempts" = "Noch keine Abrufversuche."; +"macOS Tahoe can block menu bar apps in System Settings → Menu Bar → Allow in the Menu Bar. CodexBar is running, but macOS may be hiding its icon. Open Menu Bar settings and turn CodexBar on." = "macOS Tahoe kann Menüleisten-Apps unter Systemeinstellungen → Menüleiste → Zulassen in der Menüleiste blockieren. CodexBar wird ausgeführt, aber macOS verbirgt möglicherweise sein Symbol. Öffnen Sie die Menüleisteneinstellungen und aktivieren Sie CodexBar."; + +/* Metric preferences */ +"metric_pref_automatic" = "Automatisch"; +"metric_pref_primary" = "Primär"; +"metric_pref_secondary" = "Sekundär"; +"metric_pref_tertiary" = "Tertiär"; +"metric_pref_extra_usage" = "Zusätzliche Nutzung"; +"metric_pref_average" = "Durchschnitt"; +"metric_mistral_payg" = "Pay-as-you-go"; +"metric_mistral_monthly_plan" = "Monthly Plan"; + +/* Display modes */ +"display_mode_percent" = "Prozent"; +"display_mode_pace" = "Tempo"; +"display_mode_both" = "Beide"; +"display_mode_reset_time" = "Zurücksetzungszeit"; +"display_mode_percent_desc" = "Verbleibenden/verwendeten Prozentsatz anzeigen (z. B. 45 %)"; +"display_mode_pace_desc" = "Tempoanzeige anzeigen (z. B. +5%)"; +"display_mode_both_desc" = "Zeigen Sie sowohl Prozentsatz als auch Tempo an (z. B. 45 % · +5 %)"; +"display_mode_reset_time_desc" = "Zurücksetzungszeit der ausgewählten Metrik anzeigen (z. B. ↻ 15:56)"; +"menu_bar_reset_when_exhausted_title" = "Zurücksetzungszeit anzeigen, wenn das Kontingent aufgebraucht ist"; +"menu_bar_reset_when_exhausted_subtitle" = "Bei 0 % Rest die Zeit bis zur Zurücksetzung statt des Prozentwerts anzeigen"; + +/* Provider status */ +"status_operational" = "Betriebsbereit"; +"status_degraded" = "Eingeschränkte Leistung"; +"status_partial_outage" = "Teilweiser Ausfall"; +"status_major_outage" = "Schwerwiegender Ausfall"; +"status_critical_issue" = "Kritisches Problem"; +"status_maintenance" = "Wartung"; +"status_unknown" = "Status unbekannt"; + +/* Refresh frequency */ +"refresh_manual" = "Manuell"; +"refresh_1min" = "1 Minute"; +"refresh_2min" = "2 Min"; +"refresh_5min" = "5 Min"; +"refresh_15min" = "15 Min"; +"refresh_30min" = "30 Min"; +"refresh_adaptive" = "Adaptiv"; +"refresh_adaptive_agent_aware" = "Adaptiv (Agentenaktivität)"; +"adaptive_activity_consent_title" = "Aktivitätsabhängige Aktualisierung erlauben?"; +"adaptive_activity_consent_message" = "Der Modus „Adaptiv (Agentenaktivität)“ kann die Liste der lokal laufenden Prozesse einschließlich ihrer Befehlszeilen prüfen, um Codex und Claude zu erkennen, und anschließend beim Programmieren alle 30 Sekunden bekannte Sitzungsmetadaten lesen. Wenn Agent Sessions deaktiviert ist, verwendet CodexBar nur den Zeitpunkt der letzten Aktivität im Arbeitsspeicher und verwirft Sitzungspfade und Identitäten. Diese Aktivitätsdaten werden nirgendwohin gesendet; Remote-Erkennung und SSH bleiben deaktiviert. Bei Ablehnung kehrt CodexBar ohne lokale Aktivitätsscans zum normalen adaptiven Modus zurück."; +"adaptive_activity_consent_allow" = "Lokale Aktivität erlauben"; +"adaptive_activity_consent_decline" = "Normales Adaptiv verwenden"; + +/* Additional keys */ +"not_found" = "Nicht gefunden"; + +/* Cost estimation */ +"cost_estimate_hint" = "Schätzung aus lokalen Protokollen · kann von Ihrer Rechnung abweichen"; +"codex_api_estimate_hint" = "Aus Token-Nutzung geschätzt · keine Abonnementrechnung"; +"cost_data_explanation" = "Kosten können vom Anbieter gemeldet oder anhand der Token-Nutzung zu öffentlichen API-Preisen geschätzt werden. Schätzungen sind keine Abonnementgebühren."; +"No JetBrains IDE with AI Assistant detected. Install a JetBrains IDE and enable AI Assistant." = "Keine JetBrains-IDE mit AI Assistant erkannt. Installieren Sie eine JetBrains-IDE und aktivieren Sie AI Assistant."; +"OpenRouter API token not configured. Set OPENROUTER_API_KEY environment variable or configure in Settings." = "OpenRouter-API-Token nicht konfiguriert. Legen Sie die Umgebungsvariable OPENROUTER_API_KEY fest oder konfigurieren Sie sie in den Einstellungen."; +"z.ai API token not found. Set apiKey in ~/.codexbar/config.json or Z_AI_API_KEY." = "z.ai-API-Token nicht gefunden. Legen Sie apiKey in ~/.codexbar/config.json oder Z_AI_API_KEY fest."; +"Missing DeepSeek API key." = "Fehlender DeepSeek-API-Schlüssel."; +"%@ is unavailable in the current environment." = "%@ ist in der aktuellen Umgebung nicht verfügbar."; +"All Systems Operational" = "Alle Systeme betriebsbereit"; +"Last 30 days" = "Letzte 30 Tage"; +"Last 30 days:" = "Letzte 30 Tage:"; +"This month" = "Diesen Monat"; +"Store multiple OpenAI API keys." = "Speichern Sie mehrere OpenAI-API-Schlüssel."; +"Admin API key" = "Admin-API-Schlüssel"; +"Open billing" = "Abrechnung öffnen"; +"Google accounts" = "Google-Konten"; +"Store multiple Antigravity Google OAuth accounts for quick switching." = "Speichern Sie mehrere Antigravity Google OAuth-Konten für einen schnellen Wechsel."; +"Add Google Account" = "Google-Konto hinzufügen"; +"Open Token Plan" = "Offener Token-Plan"; +"Text Generation" = "Textgenerierung"; +"Text to Speech" = "Text-to-Speech"; +"Music Generation" = "Musikgeneration"; +"Image Generation" = "Bilderzeugung"; +"No local data found" = "Keine lokalen Daten gefunden"; +"Credits unavailable; keep Codex running to refresh." = "Credits nicht verfügbar; Lassen Sie Codex zum Aktualisieren laufen."; +"No available fetch strategy for minimax." = "Für Minimax ist keine Abrufstrategie verfügbar."; +"No Cursor session found. Please log in to cursor.com in Safari, Chrome, Microsoft Edge, Brave, Arc, Dia, ChatGPT Atlas, Chromium, Helium, Vivaldi, Yandex Browser, Firefox, Zen, Colibri, Sidekick, Opera, Opera GX, or Edge Canary. If you use Safari, grant CodexBar Full Disk Access in System Settings ▸ Privacy & Security. You can also sign in to Cursor from the CodexBar menu (Add / switch account)." = "Keine Cursor-Sitzung gefunden. Bitte melden Sie sich bei Cursor.com in Safari, Chrome, Microsoft Edge, Brave, Arc, Dia, ChatGPT Atlas, Chromium, Helium, Vivaldi, Yandex Browser, Firefox, Zen, Colibri, Sidekick, Opera, Opera GX oder Edge Canary an. Wenn Sie Safari verwenden, gewähren Sie CodexBar vollständigen Festplattenzugriff unter Systemeinstellungen ▸ Datenschutz und Sicherheit. Sie können sich auch über das CodexBar-Menü (Konto hinzufügen/wechseln) bei Cursor anmelden."; +"No OpenCode session cookies found in browsers." = "In Browsern wurden keine OpenCode-Sitzungscookies gefunden."; +"No available fetch strategy for %@." = "Für %@ ist keine Abrufstrategie verfügbar."; +"Today" = "Heute"; +"Today tokens" = "Heute Token"; +"30d cost" = "30 Tage Kosten"; +"%@ cost" = "%@ Kosten"; +"30d tokens" = "30d-Token"; +"Latest tokens" = "Neueste Token"; +"Top model" = "Topmodell"; +"Storage" = "Speicher"; +"Add Account..." = "Konto hinzufügen..."; +"Usage Dashboard" = "Nutzungs-Dashboard"; +"Status Page" = "Statusseite"; +"Open Status Page" = "Statusseite öffnen"; +"Settings..." = "Einstellungen..."; +"About CodexBar" = "About CodexBar"; +"Quit" = "Beenden"; +"Last %d day" = "Letzter %d Tag"; +"Last %d days" = "Letzte %d Tage"; +"%@ tokens" = "%@ Token"; +"Latest billing day" = "Letzter Abrechnungstag"; +"Latest billing day (%@)" = "Letzter Abrechnungstag (%@)"; +"%@ left" = "%@ übrig"; +"Resets %@" = "Setzt %@ zurück"; +"Resets in %@" = "Zurückgesetzt in %@"; +"Resets now" = "Wird jetzt zurückgesetzt"; +"reset_tomorrow_format" = "morgen, %@"; +"Lasts until reset" = "Hält bis zum Zurücksetzen an"; +"1.5× headroom" = "1,5× Spielraum"; +"Updated %@" = "Aktualisiert %@"; +"Updated relative %@" = "Aktualisiert %@"; +"Updated absolute %@" = "Aktualisiert %@"; +"Updated %@h ago" = "Vor %@h aktualisiert"; +"Updated %@m ago" = "Vor %@m aktualisiert"; +"Updated just now" = "Gerade erst aktualisiert"; +"Projected empty in %@" = "Voraussichtlich leer in %@"; +"Runs out in %@" = "Läuft in %@ aus"; +"Pace: %@" = "Tempo: %@"; +"Pace: %@ · %@" = "Tempo: %@ · %@"; +"%@ · %@" = "%@ · %@"; +"≈ %d%% run-out risk" = "≈ %d%% Auslaufrisiko"; +"%d%% in deficit" = "%d%% im Defizit"; +"%d%% in reserve" = "%d%% in Reserve"; +"usage_percent_suffix_left" = "übrig"; +"usage_percent_suffix_used" = "verbraucht"; +"Store multiple DeepSeek API keys." = "Speichern Sie mehrere DeepSeek-API-Schlüssel."; +"This week" = "Diese Woche"; +"Week" = "Woche"; +"Month" = "Monat"; +"Models" = "Modelle"; +"24h tokens" = "24-Stunden-Token"; +"Latest hour" = "Letzte Stunde"; +"Peak hour" = "Spitzenstunde"; +"Top method" = "Top-Methode"; +"30d cash" = "30 Tage Bargeld"; +"30d billing history from MiniMax web session" = "30-tägiger Abrechnungsverlauf aus der MiniMax-Websitzung"; +"AWS Cost Explorer billing can lag." = "Die Abrechnung mit AWS Cost Explorer kann verzögert sein."; +"Rate limit: %d / %@" = "Ratenlimit: %d / %@"; +"Key remaining" = "Schlüssel übrig"; +"No limit set for the API key" = "Für den API-Schlüssel ist kein Limit festgelegt"; +"API key limit unavailable right now" = "Das API-Schlüssellimit ist derzeit nicht verfügbar"; +"This month: %@ tokens" = "Diesen Monat: %@ Token"; +"No utilization data yet." = "Noch keine Nutzungsdaten."; +"No %@ utilization data yet." = "Noch keine %@-Nutzungsdaten."; +"%@: %@%% used" = "%@: %@%% verwendet"; +"%dd" = "%dd"; +"today" = "Heute"; +"just now" = "soeben"; +"On pace" = "Auf Tempo"; +"Runs out now" = "Ist jetzt ausverkauft"; +"Projected empty now" = "Voraussichtlich jetzt leer"; +"Switch Account..." = "Konto wechseln..."; +"Update ready, restart now?" = "Update bereit, jetzt neu starten?"; +"Daily" = "Täglich"; +"Hourly Tokens" = "Stündliche Token"; +"No data" = "Keine Daten"; +"No usage breakdown data available." = "Es sind keine Nutzungsaufschlüsselungsdaten verfügbar."; + +"Today: %@ · %@ tokens" = "Heute: %@ · %@ Token"; +"Today: %@" = "Heute: %@"; +"Today: %@ tokens" = "Heute: %@ Token"; +"Last 30 days: %@ · %@ tokens" = "Letzte 30 Tage: %@ · %@ Token"; +"Last 30 days: %@" = "Letzte 30 Tage: %@"; +"Est. total (30d): %@" = "Schätzung: Gesamt (30 Tage): %@"; +"Est. total (%@): %@" = "Schätzung: Gesamt (%@): %@"; +"Hover a bar for details" = "Bewegen Sie den Mauszeiger über eine Leiste, um Einzelheiten anzuzeigen"; +"%@: %@ · %@ tokens" = "%@: %@ · %@ Token"; +"No providers selected for Overview." = "Für die Übersicht wurden keine Anbieter ausgewählt."; +"No overview data available." = "Keine Übersichtsdaten verfügbar."; +"Auto uses the local IDE API first, then Google OAuth when the IDE is closed." = "Auto verwendet zuerst die lokale IDE-API und dann Google OAuth, wenn die IDE geschlossen wird."; +"Login with Google" = "Melden Sie sich mit Google an"; + +/* Popup panels */ +"No usage configured." = "Keine Nutzung konfiguriert."; +"Quota" = "Kontingent"; +"Daily quota" = "Tageskontingent"; +"Total" = "Gesamt"; +"tokens" = "Token"; +"requests" = "Anfragen"; +"Latest" = "Letzte"; +"Monthly" = "Monatlich"; +"Sonnet" = "Sonett"; +"Overages" = "Überschreitungen"; +"Activity" = "Aktivität"; +"Copied" = "Kopiert"; +"Copy error" = "Kopierfehler"; +"Copy path" = "Pfad kopieren"; +"Extra usage spent" = "Zusätzliche Nutzung aufgewendet"; +"Credits remaining" = "Verbleibende Credits"; +"Using CLI fallback" = "CLI-Fallback verwenden"; +"Balance updates in near-real time (up to 5 min lag)" = "Guthabenaktualisierungen nahezu in Echtzeit (bis zu 5 Minuten Verzögerung)"; +"Daily billing data finalizes at 07:00 UTC" = "Die täglichen Abrechnungsdaten werden um 07:00 UTC finalisiert"; +"%@ of %@ credits left" = "%@ von %@ Credits übrig"; +"%@ of %@ bonus credits left" = "%@ von %@ Bonusguthaben übrig"; +"%@ / %@ (%@ remaining)" = "%@ / %@ (%@ verbleibend)"; +"%@/%@ left" = "%@/%@ übrig"; +"Gemini Flash" = "Gemini Flash"; +"Regenerates %@" = "Regeneriert %@"; +"used after next regen" = "Wird nach der nächsten Regenerierung verwendet"; +"after next regen" = "nach der nächsten Regeneration"; +"Near full" = "Fast voll"; +"Full in ~1 regen" = "Voll in ~1 Regeneration"; +"Full in ~%.0f regens" = "Voll in ~%.0f Regenerationen"; +"Overage usage" = "Übermäßige Nutzung"; +"Overage cost" = "Überschreitungskosten"; +"credits" = "Credits"; +"Zen balance" = "Zen-Balance"; +"API spend" = "API-Ausgaben"; +"Extra usage" = "Zusätzliche Nutzung"; +"Quota usage" = "Kontingentnutzung"; +"Your spend" = "Deine Ausgaben"; +"%.0f%% used" = "%.0f%% verwendet"; +"Usage history (today)" = "Nutzungshistorie (heute)"; +"Usage history (%d days)" = "Nutzungsverlauf (%d Tage)"; +"%d percent remaining" = "%d Prozent verbleibend"; +"Unknown" = "Unbekannt"; +"stale data" = "veraltete Daten"; +"No credits history data." = "Keine Credits-Verlaufsdaten."; +"No credits history data available." = "Es sind keine Daten zum Kreditverlauf verfügbar."; +"Credits history chart" = "Diagramm zum Verlauf der Credits"; +"%d days of credits data" = "%d Tage Credits-Daten"; +"Usage breakdown chart" = "Aufschlüsselungsdiagramm zur Nutzung"; +"%d days of usage data across %d services" = "%d Tage Nutzungsdaten für %d Dienste"; +"Cost history chart" = "Kostenverlaufsdiagramm"; +"%d days of cost data" = "%d Tage Kostendaten"; +"Plan utilization chart" = "Planauslastungsdiagramm"; +"%d utilization samples" = "%d Nutzungsbeispiele"; +"Hourly Usage" = "Stündliche Nutzung"; +"Usage remaining" = "Verbleibende Nutzung"; +"Usage used" = "Verbraucht"; +"API key verified. Cloud quotas need browser cookies. Sign in to Ollama." = "API-Schlüssel bestätigt. Cloud-Kontingente erfordern Browser-Cookies. Melde dich bei Ollama an."; +"Last 30 days: %@ tokens" = "Letzte 30 Tage: %@ Token"; +"7d spend" = "7d ausgeben"; +"30d spend" = "30 Tage ausgeben"; +"Cache read" = "Cache gelesen"; +"Claude Admin API 30 day spend trend" = "30-Tage-Ausgabentrend der Claude Admin API"; +"OpenRouter API key spend trend" = "Trend zu Ausgaben für OpenRouter-API-Schlüssel"; +"z.ai hourly token trend" = "z.ai stündlicher Token-Trend"; +"MiniMax 30 day token usage trend" = "MiniMax 30-Tage-Token-Nutzungstrend"; +"Today cash" = "Heutige Kosten"; +"DeepSeek 30 day token usage trend" = "Trend zur 30-Tage-Token-Nutzung von DeepSeek"; +"cache-hit input" = "Cache-Hit-Eingabe"; +"cache-miss input" = "Cache-Miss-Eingabe"; +"output" = "Ausgabe"; +"Requests" = "Anfragen"; +"Reported by OpenAI Admin API organization usage." = "Gemeldet durch die Nutzung der OpenAI Admin API-Organisation."; +"Reported by Mistral billing usage." = "Gemeldet durch Mistral-Abrechnungsnutzung."; +"Google OAuth" = "Google OAuth"; +"Add accounts via GitHub OAuth Device Flow on the selected host." = "Fügen Sie Konten über GitHub OAuth Device Flow auf dem ausgewählten Host hinzu."; +"Stores each signed-in Google account for quick Antigravity switching. Uses Antigravity.app OAuth when available, or ANTIGRAVITY_OAUTH_CLIENT_ID and ANTIGRAVITY_OAUTH_CLIENT_SECRET as an override." = "Speichert jedes angemeldete Google-Konto für einen schnellen Antigravity-Wechsel. Verwendet Antigravity.app OAuth, sofern verfügbar, oder ANTIGRAVITY_OAUTH_CLIENT_ID und ANTIGRAVITY_OAUTH_CLIENT_SECRET als Überschreibung."; +"Manual cleanup: past sessions" = "Manuelle Bereinigung: vergangene Sitzungen"; +"Clearing removes past resume, continue, and rewind history." = "Durch das Löschen werden vergangene Fortsetzungs-, Fortsetzungs- und Rückspulverläufe entfernt."; +"Manual cleanup: file checkpoints" = "Manuelle Bereinigung: Dateiprüfpunkte"; +"Clearing removes checkpoint restore data for previous edits." = "Durch das Löschen werden Prüfpunkt-Wiederherstellungsdaten für frühere Bearbeitungen entfernt."; +"Manual cleanup: saved plans" = "Manuelle Bereinigung: gespeicherte Pläne"; +"Clearing removes old plan-mode files." = "Durch das Löschen werden alte Planmodusdateien entfernt."; +"Manual cleanup: debug logs" = "Manuelle Bereinigung: Debug-Protokolle"; +"Clearing removes past debug logs." = "Durch das Löschen werden frühere Debugprotokolle entfernt."; +"Manual cleanup: attachment cache" = "Manuelle Bereinigung: Anhang-Cache"; +"Clearing removes cached large pastes or attached images." = "Durch das Löschen werden zwischengespeicherte große Einfügungen oder angehängte Bilder entfernt."; +"Manual cleanup: session metadata" = "Manuelle Bereinigung: Sitzungsmetadaten"; +"Clearing removes per-session environment metadata." = "Durch das Löschen werden Umgebungsmetadaten pro Sitzung entfernt."; +"Manual cleanup: shell snapshots" = "Manuelle Bereinigung: Shell-Snapshots"; +"Clearing removes leftover runtime shell snapshot files." = "Durch das Löschen werden übrig gebliebene Runtime-Shell-Snapshot-Dateien entfernt."; +"Manual cleanup: legacy todos" = "Manuelle Bereinigung: Legacy-Aufgaben"; +"Clearing removes legacy per-session task lists." = "Durch das Löschen werden alte Aufgabenlisten pro Sitzung entfernt."; +"Manual cleanup: sessions" = "Manuelle Bereinigung: Sitzungen"; +"Clearing removes past Codex session history." = "Durch das Löschen wird der Verlauf vergangener Codex-Sitzungen entfernt."; +"Manual cleanup: archived sessions" = "Manuelle Bereinigung: archivierte Sitzungen"; +"Clearing removes archived Codex session history." = "Beim Löschen wird der archivierte Codex-Sitzungsverlauf entfernt."; +"Manual cleanup: cache" = "Manuelle Bereinigung: Cache"; +"Clearing removes provider-owned cached data." = "Durch das Löschen werden zwischengespeicherte Daten des Anbieters entfernt."; +"Manual cleanup: logs" = "Manuelle Bereinigung: Protokolle"; +"Clearing removes local diagnostic logs." = "Durch das Löschen werden lokale Diagnoseprotokolle entfernt."; +"Manual cleanup: file history" = "Manuelle Bereinigung: Dateiverlauf"; +"Clearing removes local edit checkpoint history." = "Durch das Löschen wird der lokale Bearbeitungsprüfpunktverlauf entfernt."; +"Manual cleanup: temporary data" = "Manuelle Bereinigung: temporäre Daten"; +"Clearing removes local temporary provider data." = "Durch das Löschen werden lokale temporäre Anbieterdaten entfernt."; +"Total: %@" = "Gesamt: %@"; +"%d more items" = "%d weitere Artikel"; +"Cleanup ideas" = "Aufräumideen"; +"%d unreadable item(s) skipped" = "%d unlesbare Elemente wurden übersprungen"; + +"API key limit" = "API-Schlüssellimit"; +"Auth" = "Auth"; +"Auto" = "Auto"; +"Disabled — no recent data" = "Deaktiviert – keine aktuellen Daten"; +"Limits not available" = "Limits nicht verfügbar"; +"No usage yet" = "Noch keine Nutzung"; +"Not fetched yet" = "Noch nicht abgerufen"; +"Refreshing" = "Aktualisiere"; +"Session" = "Sitzung"; +"Source" = "Quelle"; +"State" = "Zustand"; +"Unavailable" = "Nicht verfügbar"; +"Weekly" = "Wöchentlich"; +"not detected" = "nicht erkannt"; +"Estimated from local Codex logs for the selected account." = "Geschätzt aus lokalen Codex-Protokollen für das ausgewählte Konto."; +"minimax_usage_amount_format" = "Verwendung: %@ / %@"; +"minimax_used_percent_format" = "Verbraucht %@"; +"minimax_service_text_generation" = "Textgenerierung"; +"minimax_service_text_to_speech" = "Text-to-Speech"; +"minimax_service_music_generation" = "Musikgeneration"; +"minimax_service_image_generation" = "Bilderzeugung"; +"minimax_service_lyrics_generation" = "Songtextgenerierung"; +"minimax_service_coding_plan_vlm" = "Codierungsplan VLM"; +"minimax_service_coding_plan_search" = "Suche nach Kodierungsplänen"; + +/* Additional provider settings and alerts */ +"%@ is waiting for permission" = "%@ wartet auf Erlaubnis"; +"%@ requests" = "%@ Anfragen"; +"%@: %@ credits" = "%@: %@ Credits"; +"30d requests" = "30 Tage Anfragen"; +"4 days" = "4 Tage"; +"5 days" = "5 Tage"; +"7 days" = "7 Tage"; +"API key verifies Ollama Cloud access; cookies still expose quota limits." = "Der API-Schlüssel überprüft den Ollama-Cloud-Zugriff. Cookies unterliegen weiterhin Kontingentgrenzen."; +"AWS access key ID. Can also be set with AWS_ACCESS_KEY_ID." = "AWS-Zugriffsschlüssel-ID. Kann auch mit AWS_ACCESS_KEY_ID festgelegt werden."; +"AWS region. Can also be set with AWS_REGION." = "AWS-Region. Kann auch mit AWS_REGION festgelegt werden."; +"AWS secret access key. Can also be set with AWS_SECRET_ACCESS_KEY." = "Geheimer AWS-Zugriffsschlüssel. Kann auch mit AWS_SECRET_ACCESS_KEY festgelegt werden."; +"Access key ID" = "Zugriffsschlüssel-ID"; +"Add Account" = "Konto hinzufügen"; +"Adding Account…" = "Konto wird hinzugefügt…"; +"Antigravity login failed" = "Antigravity-Anmeldung fehlgeschlagen"; +"Antigravity login timed out" = "Zeitüberschreitung beim Antigravity-Login"; +"Auth source" = "Authentifizierungsquelle"; +"Automatic imports browser cookies from Xiaomi MiMo." = "Importiert automatisch Browser-Cookies von Xiaomi MiMo."; +"Automatic imports Windsurf session data from Chromium browser localStorage." = "Importiert Windsurf-Sitzungsdaten automatisch aus dem lokalen Speicher des Chromium-Browsers."; +"Automatic imports browser cookies from Bailian." = "Automatischer Import von Browser-Cookies von Bailian."; +"Automatically imports browser cookies." = "Importiert automatisch Browser-Cookies."; +"Automatically imports browser session cookies." = "Importiert automatisch Browser-Sitzungscookies."; +"Azure OpenAI deployment name. AZURE_OPENAI_DEPLOYMENT_NAME is also supported." = "Name der Azure OpenAI-Bereitstellung. AZURE_OPENAI_DEPLOYMENT_NAME wird ebenfalls unterstützt."; +"Azure OpenAI key" = "Azure OpenAI-Schlüssel"; +"Azure OpenAI resource endpoint. AZURE_OPENAI_ENDPOINT is also supported." = "Azure OpenAI-Ressourcenendpunkt. AZURE_OPENAI_ENDPOINT wird ebenfalls unterstützt."; +"Base URL" = "Basis-URL"; +"Base URL for the LLM-API-Key-Proxy instance." = "Basis-URL für die LLM-API-Key-Proxy-Instanz."; +"Browser cookies" = "Browser-Cookies"; +"Cap end" = "Kappenende"; +"Cap start" = "Kappenanfang"; +"Capacity End" = "Kapazitätsende"; +"Capacity Start" = "Kapazitätsanfang"; +"Changelog" = "Änderungsprotokoll"; +"Choose the Moonshot/Kimi API host for international or China mainland accounts." = "Wählen Sie den Moonshot/Kimi-API-Host für internationale Konten oder Konten auf dem chinesischen Festland."; +"CodexBar can't replace a system account that is signed in with an API key only setup." = "CodexBar kann kein Systemkonto ersetzen, das nur mit einem API-Schlüssel angemeldet ist."; +"CodexBar could not find saved auth for that account. Re-authenticate it and try again." = "CodexBar konnte die gespeicherte Authentifizierung für dieses Konto nicht finden. Authentifizieren Sie es erneut und versuchen Sie es erneut."; +"CodexBar could not read managed account storage. Recover the store before adding another account." = "CodexBar konnte den verwalteten Kontospeicher nicht lesen. Stellen Sie den Store wieder her, bevor Sie ein weiteres Konto hinzufügen."; +"CodexBar could not read saved auth for that account. Re-authenticate it and try again." = "CodexBar konnte die gespeicherte Authentifizierung für dieses Konto nicht lesen. Authentifizieren Sie es erneut und versuchen Sie es erneut."; +"CodexBar could not read the current system account on this Mac." = "CodexBar konnte das aktuelle Systemkonto auf diesem Mac nicht lesen."; +"CodexBar could not replace the live Codex auth on this Mac." = "CodexBar konnte die Live-Codex-Authentifizierung auf diesem Mac nicht ersetzen."; +"CodexBar could not safely preserve the current system account before switching." = "CodexBar konnte das aktuelle Systemkonto vor dem Wechsel nicht sicher beibehalten."; +"CodexBar could not save the current system account before switching." = "CodexBar konnte das aktuelle Systemkonto vor dem Wechsel nicht speichern."; +"CodexBar could not update managed account storage." = "CodexBar konnte den verwalteten Kontospeicher nicht aktualisieren."; +"CodexBar found another managed account that already uses the current system account. Resolve the duplicate account before switching." = "CodexBar hat ein anderes verwaltetes Konto gefunden, das bereits das aktuelle Systemkonto verwendet. Lösen Sie das doppelte Konto auf, bevor Sie wechseln."; +"CodexBar will ask macOS Keychain for “%@” so it can decrypt browser cookies and authenticate your account. Click OK to continue." = "CodexBar fragt den macOS-Schlüsselbund nach \"%@\", damit es Browser-Cookies entschlüsseln und Ihr Konto authentifizieren kann. Klicken Sie auf OK, um fortzufahren."; +"CodexBar will ask macOS Keychain for the Claude Code OAuth token so it can fetch your Claude usage. Click OK to continue." = "CodexBar fragt den macOS-Schlüsselbund nach dem Claude Code OAuth-Token, damit es Ihre Claude-Nutzung abrufen kann. Klicken Sie auf OK, um fortzufahren."; +"CodexBar will ask macOS Keychain for your Amp cookie header so it can fetch usage. Click OK to continue." = "CodexBar fragt den macOS-Schlüsselbund nach Ihrem Amp-Cookie-Header, damit er die Nutzung abrufen kann. Klicken Sie auf OK, um fortzufahren."; +"CodexBar will ask macOS Keychain for your Augment cookie header so it can fetch usage. Click OK to continue." = "CodexBar fragt den macOS-Schlüsselbund nach Ihrem Augment-Cookie-Header, damit er die Nutzung abrufen kann. Klicken Sie auf OK, um fortzufahren."; +"CodexBar will ask macOS Keychain for your Claude cookie header so it can fetch Claude web usage. Click OK to continue." = "CodexBar fragt den macOS-Schlüsselbund nach Ihrem Claude-Cookie-Header, damit die Claude-Webnutzung abgerufen werden kann. Klicken Sie auf OK, um fortzufahren."; +"CodexBar will ask macOS Keychain for your Cursor cookie header so it can fetch usage. Click OK to continue." = "CodexBar fragt den macOS-Schlüsselbund nach Ihrem Cursor-Cookie-Header, damit er die Nutzung abrufen kann. Klicken Sie auf OK, um fortzufahren."; +"CodexBar will ask macOS Keychain for your Factory cookie header so it can fetch usage. Click OK to continue." = "CodexBar fragt den macOS-Schlüsselbund nach Ihrem Factory-Cookie-Header, damit er die Nutzung abrufen kann. Klicken Sie auf OK, um fortzufahren."; +"CodexBar will ask macOS Keychain for your GitHub Copilot token so it can fetch usage. Click OK to continue." = "CodexBar fragt den macOS-Schlüsselbund nach Ihrem GitHub-Copilot-Token, damit es die Nutzung abrufen kann. Klicken Sie auf OK, um fortzufahren."; +"CodexBar will ask macOS Keychain for your Kimi auth token so it can fetch usage. Click OK to continue." = "CodexBar fragt den macOS-Schlüsselbund nach Ihrem Kimi-Authentifizierungstoken, damit es die Nutzung abrufen kann. Klicken Sie auf OK, um fortzufahren."; +"CodexBar will ask macOS Keychain for your MiniMax API token so it can fetch usage. Click OK to continue." = "CodexBar fragt den macOS-Schlüsselbund nach Ihrem MiniMax-API-Token, damit die Nutzung abgerufen werden kann. Klicken Sie auf OK, um fortzufahren."; +"CodexBar will ask macOS Keychain for your MiniMax cookie header so it can fetch usage. Click OK to continue." = "CodexBar fragt den macOS-Schlüsselbund nach Ihrem MiniMax-Cookie-Header, damit er die Nutzung abrufen kann. Klicken Sie auf OK, um fortzufahren."; +"CodexBar will ask macOS Keychain for your OpenAI cookie header so it can fetch Codex dashboard extras. Click OK to continue." = "CodexBar fragt den macOS-Schlüsselbund nach Ihrem OpenAI-Cookie-Header, damit Codex-Dashboard-Extras abgerufen werden können. Klicken Sie auf OK, um fortzufahren."; +"CodexBar will ask macOS Keychain for your OpenCode cookie header so it can fetch usage. Click OK to continue." = "CodexBar fragt den macOS-Schlüsselbund nach Ihrem OpenCode-Cookie-Header, damit er die Nutzung abrufen kann. Klicken Sie auf OK, um fortzufahren."; +"CodexBar will ask macOS Keychain for your Synthetic API key so it can fetch usage. Click OK to continue." = "CodexBar fragt den macOS-Schlüsselbund nach Ihrem synthetischen API-Schlüssel, damit er die Nutzung abrufen kann. Klicken Sie auf OK, um fortzufahren."; +"CodexBar will ask macOS Keychain for your z.ai API token so it can fetch usage. Click OK to continue." = "CodexBar fragt den macOS-Schlüsselbund nach Ihrem z.ai-API-Token, damit es die Nutzung abrufen kann. Klicken Sie auf OK, um fortzufahren."; +"Could not open Cursor login in your browser." = "Die Cursor-Anmeldung konnte in Ihrem Browser nicht geöffnet werden."; +"Could not open browser for Antigravity" = "Der Browser für Antigravity konnte nicht geöffnet werden"; +"Credits used" = "Verwendete Credits"; +"Day" = "Tag"; +"Deployment" = "Einsatz"; +"Drag to reorder" = "Zum Neuanordnen ziehen"; +"Sort providers alphabetically" = "Anbieter alphabetisch sortieren"; +"Sort providers alphabetically (enabled first)" = "Anbieter alphabetisch sortieren (aktivierte zuerst)"; +"Sorted alphabetically (enabled first) — click to use your custom order" = "Alphabetisch sortiert (aktivierte zuerst) — klicken, um die eigene Reihenfolge zu verwenden"; +"Endpoint" = "Endpunkt"; +"Enterprise host" = "Unternehmenshost"; +"Extra usage balance: %@" = "Zusätzlicher Nutzungssaldo: %@"; +"Keychain Access Required" = "Schlüsselbundzugriff erforderlich"; +"keychain_prompt_learn_more" = "Weitere Informationen…"; +"keychain_prompt_privacy_note" = "Die Eingabe des Mac-Anmeldepassworts wird von macOS verarbeitet, nicht von CodexBar. Du kannst den Schlüsselbundzugriff jederzeit unter Einstellungen → Erweitert deaktivieren."; +"Kiro menu bar value" = "Wert der Kiro-Menüleiste"; +"Label" = "Etikett"; +"No organizations loaded. Click Refresh after setting your API key." = "Keine Organisationen geladen. Klicken Sie auf Aktualisieren, nachdem Sie Ihren API-Schlüssel festgelegt haben."; +"No output captured." = "Keine Ausgabe erfasst."; +"No system account" = "Kein Systemkonto"; +"Oasis-Token" = "Oasis-Token"; +"Open Augment (Log Out & Back In)" = "Augment öffnen (abmelden und wieder anmelden)"; +"Open Codebuff Dashboard" = "Öffnen Sie das Codebuff-Dashboard"; +"Open Command Code Settings" = "Öffnen Sie die Befehlscode-Einstellungen"; +"Open Crof dashboard" = "Öffnen Sie das Crof-Dashboard"; +"Open Manus" = "Öffne Manus"; +"Open MiMo Balance" = "Öffnen Sie MiMo Balance"; +"Open Moonshot Console" = "Öffnen Sie die Moonshot-Konsole"; +"Open Ollama API Keys" = "Öffnen Sie die Ollama-API-Schlüssel"; +"Open StepFun Platform" = "Öffnen Sie die StepFun-Plattform"; +"Open T3 Chat Settings" = "Öffnen Sie die T3-Chat-Einstellungen"; +"Open Volcengine Ark Console" = "Öffnen Sie die Volcengine Ark-Konsole"; +"Open legacy provider docs" = "Öffnen Sie die Dokumente älterer Anbieter"; +"Open projects" = "Offene Projekte"; +"Open this URL manually to continue login:\n\n%@" = "Öffnen Sie diese URL manuell, um mit der Anmeldung fortzufahren:\n\n%@"; +"Optional organization ID for accounts linked to multiple Anthropic organizations." = "Optionale Organisations-ID für Konten, die mit mehreren Anthropic-Organisationen verknüpft sind."; +"Optional. Applies to the configured Admin API key; selected token accounts do not inherit OPENAI_PROJECT_ID." = "Optional. Gilt für den konfigurierten Admin-API-Schlüssel; Ausgewählte Token-Konten erben OPENAI_PROJECT_ID nicht."; +"Optional. Enter your GitHub Enterprise host, for example octocorp.ghe.com. Leave blank for github.com." = "Optional. Geben Sie Ihren GitHub Enterprise-Host ein, zum Beispiel octocorp.ghe.com. Für github.com leer lassen."; +"Optional. Leave blank to discover and aggregate projects visible to the API key." = "Optional. Lassen Sie das Feld leer, um für den API-Schlüssel sichtbare Projekte zu ermitteln und zu aggregieren."; +"Org ID (optional)" = "Organisations-ID (optional)"; +"Organizations" = "Organisationen"; +"Organization ID" = "Organisations-ID"; +"Password" = "Passwort"; +"%@ authentication is disabled." = "Die %@-Authentifizierung ist deaktiviert."; +"%@ cookies are disabled." = "%@ Cookies sind deaktiviert."; +"%@ web API access is disabled." = "%@ Web-API-Zugriff ist deaktiviert."; +"Disable %@ dashboard cookie usage." = "Deaktivieren Sie die Verwendung von Dashboard-Cookies für %@."; +"Keychain access is disabled in Advanced, so browser cookie import is unavailable." = "Der Schlüsselbundzugriff ist in \"Erweitert\" deaktiviert, daher ist der Browser-Cookie-Import nicht verfügbar."; +"Manually paste an %@ from a browser session." = "Fügen Sie manuell einen %@ aus einer Browsersitzung ein."; +"Paste a Cookie header captured from %@." = "Fügen Sie einen von %@ erfassten Cookie-Header ein."; +"Paste a Cookie header from %@." = "Fügen Sie einen Cookie-Header von %@ ein."; +"Paste a Cookie header or cURL capture from %@." = "Fügen Sie einen Cookie-Header oder eine cURL-Erfassung aus %@ ein."; +"Paste a Cookie header or full cURL capture from %@." = "Fügen Sie einen Cookie-Header oder eine vollständige cURL-Erfassung aus %@ ein."; +"Paste a Cookie or Authorization header from %@." = "Fügen Sie einen Cookie- oder Autorisierungsheader von %@ ein."; +"Paste a full cookie header or the %@ value." = "Fügen Sie einen vollständigen Cookie-Header oder den Wert %@ ein."; +"Paste a Cookie header or full cURL capture from T3 Chat settings." = "Fügen Sie einen Cookie-Header oder eine vollständige cURL-Erfassung aus den T3-Chat-Einstellungen ein."; +"Paste the Cookie header from a request to admin.mistral.ai. Must contain an ory_session_* cookie." = "Fügen Sie den Cookie-Header aus einer Anfrage in admin.mistral.ai ein. Muss ein ory_session_*-Cookie enthalten."; +"Paste the Oasis-Token from a logged-in browser session on platform.stepfun.com." = "Fügen Sie den Oasis-Token aus einer angemeldeten Browsersitzung auf platform.stepfun.com ein."; +"Paste the %@ JSON bundle from %@." = "Fügen Sie das JSON-Bundle %@ aus %@ ein."; +"Paste the %@ value or a full Cookie header." = "Fügen Sie den Wert %@ oder einen vollständigen Cookie-Header ein."; +"Personal account" = "Persönliches Konto"; +"Project ID" = "Projekt-ID"; +"Re-auth" = "Erneut authentifizieren"; +"Re-login at claude.ai" = "Erneut bei claude.ai anmelden"; +"Re-authenticating…" = "Erneute Authentifizierung…"; +"Refresh Session" = "Sitzung aktualisieren"; +"Refresh organizations" = "Organisationen aktualisieren"; +"Region" = "Region"; +"Reload" = "Neu laden"; +"Reorder" = "Neu anordnen"; +"Secret access key" = "Geheimer Zugangsschlüssel"; +"Series" = "Serie"; +"Service" = "Service"; +"Show or hide Kiro credits, percent, or both next to the menu bar icon." = "Kiro-Credits, Prozent oder beides neben dem Menüleistensymbol ein- oder ausblenden."; +"Show usage for organizations you belong to. Personal account is always shown." = "Zeigen Sie die Nutzung für Organisationen an, denen Sie angehören. Persönliches Konto wird immer angezeigt."; +"Sign in to cursor.com in your browser, then refresh Cursor in CodexBar." = "Melden Sie sich in Ihrem Browser bei Cursor.com an und aktualisieren Sie dann Cursor in CodexBar."; +"Simulated error text" = "Simulierter Fehlertext"; +"StepFun platform account (phone number or email)." = "StepFun-Plattformkonto (Telefonnummer oder E-Mail)."; +"Stored in ~/.codexbar/config.json." = "Gespeichert in ~/.codexbar/config.json."; +"Stored in ~/.codexbar/config.json. AZURE_OPENAI_API_KEY is also supported." = "Gespeichert in ~/.codexbar/config.json. AZURE_OPENAI_API_KEY wird ebenfalls unterstützt."; +"Stored in ~/.codexbar/config.json. For the official Kimi API, use Moonshot / Kimi API." = "Gespeichert in ~/.codexbar/config.json. Verwenden Sie für die offizielle Kimi-API die Moonshot/Kimi-API."; +"Stored in ~/.codexbar/config.json. Get your API key from the Volcengine Ark console." = "Gespeichert in ~/.codexbar/config.json. Holen Sie sich Ihren API-Schlüssel von der Volcengine Ark-Konsole."; +"Stored in ~/.codexbar/config.json. Get your key from Ollama settings." = "Gespeichert in ~/.codexbar/config.json. Holen Sie sich Ihren Schlüssel aus den Ollama-Einstellungen."; +"Stored in ~/.codexbar/config.json. Get your key from console.deepgram.com." = "Gespeichert in ~/.codexbar/config.json. Holen Sie sich Ihren Schlüssel von console.deepgram.com."; +"Stored in ~/.codexbar/config.json. Get your key from elevenlabs.io/app/settings/api-keys." = "Gespeichert in ~/.codexbar/config.json. Holen Sie sich Ihren Schlüssel von elevenlabs.io/app/settings/api-keys."; +"Stored in ~/.codexbar/config.json. Get your key from openrouter.ai/settings/keys and set a key spending limit there to enable API key quota tracking." = "Gespeichert in ~/.codexbar/config.json. Holen Sie sich Ihren Schlüssel von openrouter.ai/settings/keys und legen Sie dort ein Schlüsselausgabelimit fest, um die API-Schlüsselkontingentverfolgung zu ermöglichen."; +"Stored in ~/.codexbar/config.json. In Warp, open Settings > Platform > API Keys, then create one." = "Gespeichert in ~/.codexbar/config.json. Öffnen Sie in Warp Einstellungen > Plattform > API-Schlüssel und erstellen Sie einen."; +"Stored in ~/.codexbar/config.json. Metrics require Groq Enterprise Prometheus access." = "Gespeichert in ~/.codexbar/config.json. Für Metriken ist Groq Enterprise Prometheus-Zugriff erforderlich."; +"Stored in ~/.codexbar/config.json. OPENAI_ADMIN_KEY is preferred; OPENAI_API_KEY still works." = "Gespeichert in ~/.codexbar/config.json. OPENAI_ADMIN_KEY wird bevorzugt; OPENAI_API_KEY funktioniert immer noch."; +"Stored in ~/.codexbar/config.json. Requires an Anthropic Admin API key." = "Gespeichert in ~/.codexbar/config.json. Erfordert einen Anthropic Admin API-Schlüssel."; +"Stored in ~/.codexbar/config.json. Used for /v1/quota-stats." = "Gespeichert in ~/.codexbar/config.json. Wird für /v1/quota-stats verwendet."; +"Stored in ~/.codexbar/config.json. You can also provide CODEBUFF_API_KEY or let CodexBar read ~/.config/manicode/credentials.json (created by `codebuff login`)." = "Gespeichert in ~/.codexbar/config.json. Sie können auch CODEBUFF_API_KEY bereitstellen oder CodexBar ~/.config/manicode/credentials.json lesen lassen (erstellt durch \"codebuff login\")."; +"Stored in ~/.codexbar/config.json. You can also provide CROF_API_KEY." = "Gespeichert in ~/.codexbar/config.json. Sie können auch CROF_API_KEY angeben."; +"Stored in ~/.codexbar/config.json. You can also provide KILO_API_KEY or ~/.local/share/kilo/auth.json (kilo.access)." = "Gespeichert in ~/.codexbar/config.json. Sie können auch KILO_API_KEY oder ~/.local/share/kilo/auth.json (kilo.access) angeben."; +"T3 Chat cookie" = "T3-Chat-Cookie"; +"Team mode" = "Teammodus"; +"That account is no longer available in CodexBar. Refresh the account list and try again." = "Dieses Konto ist in CodexBar nicht mehr verfügbar. Aktualisieren Sie die Kontoliste und versuchen Sie es erneut."; +"The browser login did not complete in time. Try Antigravity login again." = "Die Browseranmeldung wurde nicht rechtzeitig abgeschlossen. Versuchen Sie erneut, sich bei Antigravity anzumelden."; +"Timed out waiting for Cursor login. %@" = "Zeitüberschreitung beim Warten auf die Cursor-Anmeldung. %@"; +"Timed out waiting for Cursor login. %@ Last error: %@" = "Zeitüberschreitung beim Warten auf die Cursor-Anmeldung. %@ Letzter Fehler: %@"; +"Today requests" = "Heute Anfragen"; +"Total (30d): %@ credits" = "Gesamt (30 Tage): %@ Credits"; +"Username" = "Benutzername"; +"Uses username + password to login and obtain an Oasis-Token automatically." = "Verwendet Benutzername + Passwort, um sich anzumelden und automatisch ein Oasis-Token zu erhalten."; +"Uses username + password to login and obtain an %@ automatically." = "Verwendet Benutzername + Passwort, um sich anzumelden und automatisch einen %@ zu erhalten."; +"Utilization End" = "Nutzungsende"; +"Utilization Start" = "Nutzungsbeginn"; +"Verbosity" = "Ausführlichkeit"; +"Windsurf session JSON bundle" = "JSON-Paket für Windsurf-Sitzungen"; +"Workspace ID" = "Arbeitsbereichs-ID"; +"Your StepFun platform password. Used to login and obtain a session token." = "Ihr Passwort für die StepFun-Plattform. Wird verwendet, um sich anzumelden und ein Sitzungstoken zu erhalten."; +"claude /login exited with status %d." = "Claude /login wurde mit dem Status %d beendet."; +"codex login exited with status %d." = "Codex-Anmeldung mit Status %d beendet."; +"Cookie: …\n\nor paste a cURL capture from the Abacus AI dashboard" = "Cookie: …\n\noder fügen Sie eine cURL-Erfassung aus dem Abacus AI-Dashboard ein"; +"Cookie: …\n\nor paste the __Secure-next-auth.session-token value" = "Cookie: …\n\noder fügen Sie den Wert __Secure-next-auth.session-token ein"; +"Cookie: …\n\nor paste the kimi-auth token value" = "Cookie: …\n\noder fügen Sie den Kimi-Auth-Token-Wert ein"; +"session_id=...\n\nor paste just the session_id value" = "session_id=...\n\noder fügen Sie nur den session_id-Wert ein"; +"Clear" = "Klar"; +"No matching providers" = "Keine passenden Anbieter"; +"Search providers" = "Suchanbieter"; + +"Request quota: %@ / %@" = "Anfragelimit: %@ / %@"; +"language_arabic" = "العربية"; +"language_persian" = "فارسی"; +"language_thai" = "ไทย"; +"language_galician" = "Galego"; +"Limit Reset Credits" = "Credits zum Zurücksetzen des Limits"; +"1 available" = "1 verfügbar"; +"%d available" = "%d verfügbar"; +"Next expires %@" = "Nächster Ablauf %@"; +"Expires %@" = "Läuft %@ ab"; +"No expiry" = "Kein Ablaufdatum"; +"Other (%d items)" = "Andere (%d Elemente)"; +"Expand" = "Aufklappen"; +"Collapse" = "Zuklappen"; +"byte_unit_byte" = "Byte"; +"byte_unit_bytes" = "Byte"; +"byte_unit_kilobyte" = "Kilobyte"; +"byte_unit_kilobytes" = "Kilobyte"; +"byte_unit_megabyte" = "Megabyte"; +"byte_unit_megabytes" = "Megabyte"; +"byte_unit_gigabyte" = "Gigabyte"; +"byte_unit_gigabytes" = "Gigabyte"; + +/* Settings sidebar redesign */ +"Enable" = "Aktivieren"; +"Disable" = "Deaktivieren"; +"providers_on_count" = "%d aktiv"; +"section_cost_summary" = "Kostenübersicht"; +"section_command_line" = "Befehlszeile"; +"section_privacy" = "Datenschutz"; +"section_diagnostics" = "Diagnose"; +"section_updates" = "Updates"; +"section_links" = "Links"; +"Show Codex Spark usage" = "Codex-Spark-Nutzung anzeigen"; +"Shows Codex Spark quota rows in the menu and provider preview. Requires optional credits and extra usage in Display settings." = "Zeigt die Codex-Spark-Kontingentzeilen im Menü und in der Anbietervorschau an. Erfordert, dass „Credits + zusätzliche Nutzung anzeigen“ in den Anzeigeeinstellungen aktiviert ist."; +"Show Daily Routines usage" = "Nutzung von „Tägliche Routinen“ anzeigen"; +"Shows the Daily Routines quota row in the menu and provider preview. Requires optional credits and extra usage in Display settings." = "Zeigt die Kontingentzeile „Tägliche Routinen“ im Menü und in der Anbietervorschau an. Erfordert, dass „Credits + zusätzliche Nutzung anzeigen“ in den Anzeigeeinstellungen aktiviert ist."; +"Scroll to see more models" = "Scrollen, um weitere Modelle zu sehen"; +"Copy Image" = "Bild kopieren"; +"Copy Stats" = "Statistiken kopieren"; +"Could not copy image" = "Bild konnte nicht kopiert werden"; +"Image copied" = "Bild kopiert"; +"Image saved" = "Bild gespeichert"; +"Nothing is uploaded. This image is created on your Mac." = "Es wird nichts hochgeladen. Dieses Bild wird auf deinem Mac erstellt."; +"Save..." = "Speichern..."; +"Share AI Usage" = "KI-Nutzung teilen"; +"Share Stats…" = "Statistiken teilen…"; +"Stats copied" = "Statistiken kopiert"; +"DeepSeek this month token usage trend" = "Trend der DeepSeek-Token-Nutzung in diesem Monat"; +"Chrome profile" = "Chrome-Profil"; +"Choose which signed-in DeepSeek Platform session supplies detailed usage." = "Wähle aus, welche angemeldete DeepSeek-Platform-Sitzung detaillierte Nutzungsdaten liefert."; +"Detailed usage unavailable." = "Detaillierte Nutzung nicht verfügbar."; +"Sign in to DeepSeek Platform in Chrome for detailed usage." = "Melde dich für detaillierte Nutzungsdaten in Chrome bei DeepSeek Platform an."; +"Select a DeepSeek Chrome profile in Settings." = "Wähle in den Einstellungen ein DeepSeek-Chrome-Profil aus."; +"Select profile…" = "Profil auswählen…"; + +"%@: %@" = "%@: %@"; +"Alternatively, set a custom path in Settings." = "Alternativ können Sie in den Einstellungen einen benutzerdefinierten Pfad festlegen."; +"Choose a supported browser so CodexBar can read the matching account." = "Wählen Sie einen unterstützten Browser, damit CodexBar das passende Konto lesen kann."; +"Choose Cursor account" = "Cursor-Konto auswählen"; +"Choose which Cursor account CodexBar should use." = "Wählen Sie aus, welches Cursor-Konto CodexBar verwenden soll."; +"Finish switching to a different Cursor account in your browser, then try again." = "Schließen Sie den Wechsel zu einem anderen Cursor-Konto in Ihrem Browser ab und versuchen Sie es dann erneut."; +"Individual credits" = "Individuelle Credits"; +"Install a JetBrains IDE with AI Assistant enabled, then refresh CodexBar." = "Installieren Sie eine JetBrains-IDE mit aktiviertem AI Assistant und aktualisieren Sie dann CodexBar."; +"Sign in with Claude Code..." = "Mit Claude Code anmelden..."; +"Timed out waiting for Cursor account switch. %@" = "Zeitüberschreitung beim Warten auf den Wechsel des Cursor-Kontos. %@"; +"Timed out waiting for Cursor account switch. %@ Last error: %@" = "Zeitüberschreitung beim Warten auf den Wechsel des Cursor-Kontos. %@ Letzter Fehler: %@"; +"Use Account" = "Konto verwenden"; +"Workspace" = "Arbeitsbereich"; +/* Spend dashboard */ +"tab_usage_spend" = "Nutzung & Ausgaben"; +"Usage & Spend" = "Nutzung & Ausgaben"; +"Local estimated cost history across supported providers." = "Lokaler Verlauf der geschätzten Kosten bei unterstützten Anbietern."; +"Time range" = "Zeitraum"; +"Track costs" = "Kosten verfolgen"; +"Cost tracking is off" = "Kostenverfolgung ist deaktiviert"; +"Turn on Track costs to build local estimates." = "Aktivieren Sie „Kosten verfolgen“, um lokale Schätzungen zu erstellen."; +"No local cost history yet" = "Noch kein lokaler Kostenverlauf"; +"Turn on cost tracking or refresh after using a supported provider." = "Aktivieren Sie die Kostenverfolgung oder aktualisieren Sie nach der Nutzung eines unterstützten Anbieters."; +"Refresh failures" = "Fehlgeschlagene Aktualisierungen"; +"Native currencies stay separate; Codex account rows exclude Pi session history." = "Originalwährungen bleiben getrennt; Codex-Kontozeilen schließen den Pi-Sitzungsverlauf aus."; +"Spend unavailable" = "Ausgaben nicht verfügbar"; +"Model breakdown unavailable" = "Modellaufschlüsselung nicht verfügbar"; +"Local estimated history" = "Lokaler Schätzverlauf"; +"Coverage" = "Abdeckung"; +"Estimated spend" = "Geschätzte Ausgaben"; +"Tracked tokens" = "Erfasste Token"; +"Subscriptions" = "Abonnements"; +"By subscription" = "Nach Abonnement"; +"No model-level history" = "Kein Verlauf auf Modellebene"; +"Daily estimated spend" = "Geschätzte tägliche Ausgaben"; +"≈%d full 5h windows of weekly left · %d windows until reset" = "≈%d volle 5-Std.-Fenster des Wochenlimits übrig · %d Fenster bis zum Reset"; +"Weekly cannot run out before reset at this pace" = "Das Wochenlimit kann bei diesem Tempo nicht vor dem Reset aufgebraucht sein"; +"Weekly can run out ≈%d windows early" = "Das Wochenlimit kann ≈%d Fenster früher aufgebraucht sein"; +"Estimated: %@" = "Geschätzt: %@"; +"session_quota_estimate_value_format" = "%1$@ %2$@"; +"session quota" = "Sitzungskontingent"; +"session quotas" = "Sitzungskontingente"; +"Coding Plan" = "Coding-Plan"; +"Agent Plan" = "Agentenplan"; +"Team" = "Team"; + +/* Menu bar layout editor */ +"menu_bar_layout_title" = "Layout"; +"menu_bar_layout_footer" = "Ziehe Bausteine, um die Menüleiste anzuordnen. Klicke einen Baustein zum Anhängen an; wähle einen platzierten Baustein und drücke die Löschtaste, um ihn zu entfernen."; +"menu_bar_layout_group_identity" = "Identität"; +"menu_bar_layout_group_usage" = "Verwendung"; +"menu_bar_layout_group_time" = "Zeit"; +"menu_bar_layout_group_money" = "Kosten"; +"menu_bar_layout_group_structure" = "Struktur"; +"menu_bar_layout_scope_all" = "Alle Anbieter"; +"menu_bar_layout_scope_help" = "Bearbeite das Standardlayout oder überschreibe einen Anbieter."; +"menu_bar_layout_use_all" = "Layout für alle Anbieter verwenden"; +"menu_bar_layout_preset" = "Layoutvorlage"; +"menu_bar_layout_preset_icon_percent" = "Symbol und Prozent"; +"menu_bar_layout_preset_icon_only" = "Nur Symbol"; +"menu_bar_layout_preset_percent_reset" = "Prozent und Zurücksetzung"; +"menu_bar_layout_preset_compact_stacked" = "Kompakt gestapelt"; +"menu_bar_layout_preset_custom" = "Benutzerdefiniert"; +"menu_bar_layout_live_preview" = "Live-Vorschau"; +"menu_bar_layout_strip" = "Menüleistenstreifen"; +"menu_bar_layout_remove_line_break" = "Zeilenumbruch entfernen"; +"menu_bar_layout_chip_hint" = "Auswählen, zum Sortieren ziehen oder die Entfernen-Aktion verwenden."; +"menu_bar_layout_palette_hint" = "Zum Anhängen klicken oder in das Layout ziehen."; +"menu_bar_layout_empty_line" = "Baustein hier ablegen"; +"menu_bar_layout_line" = "Zeile %d"; +"menu_bar_layout_drag_remove" = "Zum Entfernen hierher ziehen"; +"menu_bar_layout_size" = "Größe"; +"menu_bar_layout_size_small" = "Klein"; +"menu_bar_layout_size_regular" = "Normal"; +"menu_bar_layout_gap" = "Abstand"; +"menu_bar_layout_gap_tight" = "Eng"; +"menu_bar_layout_gap_regular" = "Normal"; +"menu_bar_layout_keyboard_hint" = "Die Löschtaste entfernt den ausgewählten Baustein"; +"menu_bar_layout_sample_account" = "Konto"; +"menu_bar_layout_sample_runs_out" = "reicht bis Fr."; +"menu_bar_layout_token_icon" = "Symbol"; +"menu_bar_layout_token_provider" = "Anbietername"; +"menu_bar_layout_token_account" = "Konto"; +"menu_bar_layout_token_session" = "Sitzung %"; +"menu_bar_layout_token_weekly" = "Wöchentlich %"; +"menu_bar_layout_token_auto" = "Automatisch %"; +"menu_bar_layout_token_bar" = "Nutzungsleiste"; +"menu_bar_layout_token_resets_in" = "Zurücksetzung in"; +"menu_bar_layout_token_reset_at" = "Zurücksetzung um"; +"menu_bar_layout_token_runs_out" = "Reicht bis"; +"menu_bar_layout_token_cost_today" = "Kosten heute"; +"menu_bar_layout_token_cost_30d" = "Kosten 30 Tage"; +"menu_bar_layout_token_space" = "Leerraum"; +"menu_bar_layout_token_line_break" = "Zeilenumbruch"; +"menu_bar_layout_token_separator_accessibility" = "Trennpunkt"; + +/* Menu bar layout accessibility */ +"Icon unavailable" = "Symbol: Nicht verfügbar"; +"%@ icon" = "%@: Symbol"; +"Provider name unavailable" = "Anbietername: Nicht verfügbar"; +"Account unavailable" = "Konto: Nicht verfügbar"; +"%@ unavailable" = "%@: Nicht verfügbar"; +"%@ %@" = "%@ %@"; +"Usage bar unavailable" = "Nutzungsleiste: Nicht verfügbar"; +"Usage bar, %d of 3 filled" = "Nutzungsleiste: %d/3 gefüllt"; +"Reset countdown unavailable" = "Zurücksetzung in: Nicht verfügbar"; +"Reset time unavailable" = "Zurücksetzung um: Nicht verfügbar"; +"Run-out estimate unavailable" = "Reicht bis: Nicht verfügbar"; +"Cost today unavailable" = "Kosten heute: Nicht verfügbar"; +"30-day cost unavailable" = "Kosten 30 Tage: Nicht verfügbar"; +"Resets" = "Zurücksetzungen"; diff --git a/Sources/CodexBar/Resources/de.lproj/Localizable.stringsdict b/Sources/CodexBar/Resources/de.lproj/Localizable.stringsdict new file mode 100644 index 0000000000..91c39af1a3 --- /dev/null +++ b/Sources/CodexBar/Resources/de.lproj/Localizable.stringsdict @@ -0,0 +1,49 @@ + + + + + ≈%d full 5h windows of weekly left · %d windows until reset + + NSStringLocalizedFormatKey + %#@left@ · %#@until@ + left + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + d + one + ≈%d volles 5-Std.-Fenster des Wochenlimits übrig + other + ≈%d volle 5-Std.-Fenster des Wochenlimits übrig + + until + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + d + one + %d Fenster bis zum Reset + other + %d Fenster bis zum Reset + + + Weekly can run out ≈%d windows early + + NSStringLocalizedFormatKey + %#@early@ + early + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + d + one + Das Wochenlimit kann ≈%d Fenster früher aufgebraucht sein + other + Das Wochenlimit kann ≈%d Fenster früher aufgebraucht sein + + + + diff --git a/Sources/CodexBar/Resources/en.lproj/Localizable.strings b/Sources/CodexBar/Resources/en.lproj/Localizable.strings index 324589f44a..22e4948fae 100644 --- a/Sources/CodexBar/Resources/en.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/en.lproj/Localizable.strings @@ -1,8 +1,13 @@ /* English localization for CodexBar (base/fallback) */ +"ollama_safari_cookie_access_hint" = "Safari cookies need Full Disk Access for CodexBar (System Settings > Privacy & Security)."; +"ollama_browser_cookie_decryption_denied" = "%@ cookie decryption was declined in Keychain; retry with a manual refresh."; +"ollama_browser_cookie_decryption_disabled" = "%@ cookie decryption is disabled in CodexBar; enable Keychain access and refresh."; + " providers" = " providers"; "(System)" = "(System)"; "30d" = "30d"; +"7d" = "7d"; "A managed Codex login is already running. Wait for it to finish before adding " = "A managed Codex login is already running. Wait for it to finish before adding "; "API key" = "API key"; "API region" = "API region"; @@ -21,6 +26,7 @@ "Animation pattern" = "Animation pattern"; "Antigravity login is managed in the app" = "Antigravity login is managed in the app"; "Applies only to the Security.framework OAuth keychain reader." = "Applies only to the Security.framework OAuth keychain reader."; +"Alternatively, set a custom path in Settings." = "Alternatively, set a custom path in Settings."; "Auto falls back to the next source if the preferred one fails." = "Auto falls back to the next source if the preferred one fails."; "Auto uses API first, then falls back to CLI on auth failures." = "Auto uses API first, then falls back to CLI on auth failures."; "Auto-detect" = "Auto-detect"; @@ -57,13 +63,16 @@ "Check for updates automatically" = "Check for updates automatically"; "Check if you like your agents having some fun up there." = "Check if you like your agents having some fun up there."; "Check provider status" = "Check provider status"; +"Choose a supported browser so CodexBar can read the matching account." = "Choose a supported browser so CodexBar can read the matching account."; "Choose Codex workspace" = "Choose Codex workspace"; +"Choose Cursor account" = "Choose Cursor account"; "Choose the MiniMax host (global .io or China mainland .com)." = "Choose the MiniMax host (global .io or China mainland .com)."; "Choose up to " = "Choose up to "; "Choose up to \\(Self.maxOverviewProviders) providers" = "Choose up to \\(Self.maxOverviewProviders) providers"; "Choose up to \\(count) providers" = "Choose up to \\(count) providers"; "Choose what to show in the menu bar (Pace shows usage vs. expected)." = "Choose what to show in the menu bar (Pace shows usage vs. expected)."; "Choose which Codex account CodexBar should follow." = "Choose which Codex account CodexBar should follow."; +"Choose which Cursor account CodexBar should use." = "Choose which Cursor account CodexBar should use."; "Choose which window drives the menu bar percent." = "Choose which window drives the menu bar percent."; "Chrome" = "Chrome"; "Claude CLI not found" = "Claude CLI not found"; @@ -99,6 +108,8 @@ "Could not start codex login" = "Could not start codex login"; "Could not switch system account" = "Could not switch system account"; "Credits" = "Credits"; +"Individual credits" = "Individual credits"; +"Workspace" = "Workspace"; "Credits history" = "Credits history"; "Cursor login failed" = "Cursor login failed"; "Custom" = "Custom"; @@ -146,6 +157,7 @@ "Install the Claude CLI (npm i -g @anthropic-ai/claude-code) and try again." = "Install the Claude CLI (npm i -g @anthropic-ai/claude-code) and try again."; "Install the Codex CLI (npm i -g @openai/codex) and try again." = "Install the Codex CLI (npm i -g @openai/codex) and try again."; "Install the Gemini CLI (npm i -g @google/gemini-cli) and try again." = "Install the Gemini CLI (npm i -g @google/gemini-cli) and try again."; +"Install a JetBrains IDE with AI Assistant enabled, then refresh CodexBar." = "Install a JetBrains IDE with AI Assistant enabled, then refresh CodexBar."; "JetBrains AI is ready" = "JetBrains AI is ready"; "JetBrains IDE" = "JetBrains IDE"; "Keep CLI sessions alive" = "Keep CLI sessions alive"; @@ -232,6 +244,7 @@ "Picker subtitle" = "Picker subtitle"; "Placeholder" = "Placeholder"; "Plan" = "Plan"; +"Plan Usage" = "Plan Usage"; "Play full-screen confetti when weekly usage resets." = "Play full-screen confetti when weekly usage resets."; "Polls OpenAI/Claude status pages and Google Workspace for " = "Polls OpenAI/Claude status pages and Google Workspace for "; "Prevents any Keychain access while enabled." = "Prevents any Keychain access while enabled."; @@ -265,7 +278,8 @@ "Select the IDE to monitor" = "Select the IDE to monitor"; "Session quota notifications" = "Session quota notifications"; "Session tokens" = "Session tokens"; -"Settings" = "Settings"; +"provider_section_connection" = "Connection"; +"provider_section_menu_bar" = "Menu bar"; "Show Codex Credits and Claude Extra usage sections in the menu." = "Show Codex Credits and Claude Extra usage sections in the menu."; "Show Debug Settings" = "Show Debug Settings"; "Show all token accounts" = "Show all token accounts"; @@ -276,6 +290,7 @@ "Show provider icons in the switcher (otherwise show a weekly progress line)." = "Show provider icons in the switcher (otherwise show a weekly progress line)."; "Show reset time as clock" = "Show reset time as clock"; "Show usage as used" = "Show usage as used"; +"Sign in with Claude Code..." = "Sign in with Claude Code..."; "Sign in via button below" = "Sign in via button below"; "Skip teardown between probes (debug-only)." = "Skip teardown between probes (debug-only)."; "Stack token accounts in the menu (otherwise show an account switcher bar)." = "Stack token accounts in the menu (otherwise show an account switcher bar)."; @@ -293,18 +308,18 @@ "Store multiple OpenCode Go Cookie headers." = "Store multiple OpenCode Go Cookie headers."; "Stored in the CodexBar config file." = "Stored in the CodexBar config file."; "Stored in ~/.codexbar/config.json. " = "Stored in ~/.codexbar/config.json. "; -"Stored in ~/.codexbar/config.json. Generate one at kimi-k2.ai." = "Stored in ~/.codexbar/config.json. Generate one at kimi-k2.ai."; "Stored in ~/.codexbar/config.json. Paste the key from the Synthetic dashboard." = "Stored in ~/.codexbar/config.json. Paste the key from the Synthetic dashboard."; "Stored in ~/.codexbar/config.json. Paste your Coding Plan API key from Model Studio." = "Stored in ~/.codexbar/config.json. Paste your Coding Plan API key from Model Studio."; "Stored in ~/.codexbar/config.json. Paste your MiniMax API key." = "Stored in ~/.codexbar/config.json. Paste your MiniMax API key."; "Stored in ~/.codexbar/config.json. You can also provide KILO_API_KEY or " = "Stored in ~/.codexbar/config.json. You can also provide KILO_API_KEY or "; "Stores local Codex usage history (8 weeks) to personalize Pace predictions." = "Stores local Codex usage history (8 weeks) to personalize Pace predictions."; -"Subscription Utilization" = "Subscription Utilization"; "Surprise me" = "Surprise me"; "Switcher shows icons" = "Switcher shows icons"; "Symlink CodexBarCLI to /usr/local/bin and /opt/homebrew/bin as codexbar." = "Symlink CodexBarCLI to /usr/local/bin and /opt/homebrew/bin as codexbar."; "System" = "System"; "Temporarily shows the loading animation after the next refresh." = "Temporarily shows the loading animation after the next refresh."; +"terminal_app_subtitle" = "Terminal used by the Open Terminal action"; +"terminal_app_title" = "Default terminal"; "Tertiary (\\(label))" = "Tertiary (\\(label))"; "Tertiary (\\(tertiaryTitle))" = "Tertiary (\\(tertiaryTitle))"; "The default Codex account on this Mac." = "The default Codex account on this Mac."; @@ -322,6 +337,7 @@ "Usage breakdown" = "Usage breakdown"; "Usage history (30 days)" = "Usage history"; "Usage source" = "Usage source"; +"Use Account" = "Use Account"; "Use BigModel for the China mainland endpoints (open.bigmodel.cn)." = "Use BigModel for the China mainland endpoints (open.bigmodel.cn)."; "Use a single menu bar icon with a provider switcher." = "Use a single menu bar icon with a provider switcher."; "Use international or China mainland console gateways for quota fetches." = "Use international or China mainland console gateways for quota fetches."; @@ -389,9 +405,19 @@ /* General Pane */ "section_system" = "System"; "section_usage" = "Usage"; -"section_automation" = "Automation"; +"section_refreshing" = "Refreshing"; +"section_alerts" = "Alerts"; +"section_celebrations" = "Celebrations"; +"section_icon" = "Icon"; +"section_combined_icon" = "Combined icon"; +"section_animation" = "Animation"; +"section_content" = "Content"; +"section_agent_sessions" = "Agent sessions"; "language_title" = "Language"; "language_subtitle" = "Change the display language. Requires app restart to take full effect."; +"currency_title" = "Preferred Currency"; +"currency_subtitle" = "Currency for cost estimates and spend metrics. Uses live exchange rates updated daily."; +"currency_auto" = "Auto (Follow Provider / USD)"; "language_system" = "System"; "language_english" = "English"; "language_spanish" = "Español"; @@ -399,22 +425,43 @@ "language_chinese_simplified" = "简体中文"; "language_chinese_traditional" = "繁體中文"; "language_portuguese_brazilian" = "Português (Brasil)"; +"language_dutch" = "Nederlands"; +"language_german" = "Deutsch"; "language_swedish" = "Svenska"; -"start_at_login_title" = "Start at Login"; +"language_french" = "French"; +"language_ukrainian" = "Українська"; +"language_russian" = "Русский"; +"language_japanese" = "Japanese"; +"language_korean" = "Korean"; +"language_turkish" = "Türkçe"; +"language_italian" = "Italiano"; +"language_polish" = "Polski"; +"start_at_login_title" = "Start at login"; "start_at_login_subtitle" = "Automatically opens CodexBar when you start your Mac."; -"show_cost_summary" = "Show cost summary"; "show_cost_summary_subtitle" = "Reads local usage logs. Shows today + the selected history window in the menu."; +"cost_summary_style_title" = "Display style"; +"cost_summary_style_inline" = "Inline only"; +"cost_summary_style_submenu" = "Submenu only"; +"cost_summary_style_both" = "Inline + submenu"; +"cost_summary_style_inline_help" = "Shows the cost summary directly in the main menu."; +"cost_summary_style_submenu_help" = "Shows the detailed Cost submenu instead."; +"cost_summary_style_both_help" = "Shows both the main menu summary and detailed Cost submenu."; +"cost_history_window_title" = "History window"; +"cost_history_window_help" = "Sets how many days of local usage logs appear in the menu."; "cost_history_days_title" = "History window: %d days"; -"cost_auto_refresh_info" = "Auto-refresh: hourly · Timeout: 10m"; -"refresh_cadence_title" = "Refresh cadence"; -"refresh_cadence_subtitle" = "How often CodexBar polls providers in the background."; +"cost_comparison_periods_title" = "Show shorter comparison periods"; +"cost_comparison_periods_subtitle" = "Add 7, 30, and 90-day totals when they fit inside the selected history window. These totals reuse the same local scan."; +"cost_auto_refresh_info" = "Auto-refresh: global interval (minimum 5m) · Timeout: 10m"; +"refresh_interval_title" = "Refresh interval"; "manual_refresh_hint" = "Auto-refresh is off; use the menu's Refresh command."; +"refresh_on_open_title" = "Refresh when the menu opens"; +"refresh_on_open_subtitle" = "Fetch the latest usage for every provider each time you open the menu."; "check_provider_status_title" = "Check provider status"; "check_provider_status_subtitle" = "Polls OpenAI/Claude status pages and Google Workspace for Gemini/Antigravity, surfacing incidents in the icon and menu."; -"session_quota_notifications_title" = "Session quota notifications"; "session_quota_notifications_subtitle" = "Notifies when the 5-hour session quota hits 0% and when it becomes available again."; -"quota_warning_notifications_title" = "Quota warning notifications"; +"quota_depleted_title" = "Quota depleted & restored"; "quota_warning_notifications_subtitle" = "Warns when session or weekly quota remaining crosses configured thresholds."; +"threshold_warnings_title" = "Threshold warnings"; "quota_warnings_title" = "Quota warnings"; "quota_warning_session" = "session"; "quota_warning_session_capitalized" = "Session"; @@ -423,6 +470,17 @@ "quota_warning_notification_title" = "%1$@ %2$@ quota low"; "quota_warning_notification_body" = "%1$@ left. Reached your %2$d%% %3$@ warning threshold."; "quota_warning_notification_body_with_account" = "Account %1$@. %2$@ left. Reached your %3$d%% %4$@ warning threshold."; +"predictive_pace_warnings_title" = "Pace warnings"; +"predictive_pace_warnings_subtitle" = "Warns for Codex and Claude when session or weekly pace may run out before reset."; +"confetti_on_reset_title" = "Confetti on reset"; +"confetti_on_reset_subtitle" = "Play full-screen confetti when usage resets."; +"confetti_option_off" = "Off"; +"confetti_option_session" = "Session resets"; +"confetti_option_weekly" = "Weekly resets"; +"confetti_option_both" = "Both"; +"predictive_pace_warning_notification_title" = "%1$@ %2$@ pace warning"; +"predictive_pace_warning_notification_body" = "At the current pace, this quota may run out in %1$@, before it resets."; +"predictive_pace_warning_notification_body_with_account" = "Account %1$@. At the current pace, this quota may run out in %2$@, before it resets."; "session_depleted_notification_title" = "%@ session depleted"; "session_depleted_notification_body" = "0% left. Will notify when it's available again."; "session_restored_notification_title" = "%@ session restored"; @@ -430,26 +488,55 @@ "quota_warning_warn_at" = "Warn at"; "quota_warning_global_threshold_subtitle" = "Remaining percentages for session and weekly windows unless a provider overrides them."; "quota_warning_sound" = "Play notification sound"; +"quota_warning_onscreen_alert" = "Show on-screen text alert"; "quota_warning_provider_inherits" = "Uses the global quota warning settings unless a window is customized here."; +"quota_warning_provider_disabled" = "Quota warning notifications and usage-bar markers are disabled. Enable either to edit these saved settings."; +"quota_warning_provider_markers_only" = "Quota warning notifications are disabled globally. These settings still control usage-bar markers."; +"quota_warning_global" = "Global"; "quota_warning_customize_thresholds" = "Customize %@ thresholds"; "quota_warning_enable_warnings" = "Enable %@ warnings"; "quota_warning_window_warn_at" = "%@ warn at"; "quota_warning_off" = "Off"; "quota_warning_inherited" = "Inherited: %@"; "quota_warning_depleted_only" = "depleted only"; -"quota_warning_upper" = "Upper"; +"quota_warning_upper" = "Higher"; "quota_warning_lower" = "Lower"; +"quota_warning_warning" = "Warning"; +"quota_warning_critical" = "Critical"; "apply" = "Apply"; "quit_app" = "Quit CodexBar"; /* Tab titles */ "tab_general" = "General"; "tab_providers" = "Providers"; -"tab_display" = "Display"; +"tab_notifications" = "Notifications"; +"tab_menu_bar" = "Menu Bar"; +"tab_menu" = "Menu"; "tab_advanced" = "Advanced"; +"tab_hooks" = "Hooks"; "tab_about" = "About"; "tab_debug" = "Debug"; +/* Hooks Pane */ +"hooks_enable_title" = "Enable hooks"; +"hooks_enable_subtitle" = "Run external commands when quota or provider events occur."; +"hooks_trust_warning" = "Hooks can execute local commands on your Mac. Only configure commands you trust."; +"hooks_rules_header" = "Rules"; +"hooks_empty" = "No hooks configured."; +"hooks_add_rule" = "Add rule"; +"hooks_delete_rule" = "Delete rule"; +"hooks_rule_enabled" = "Enabled"; +"hooks_event" = "Event"; +"hooks_provider" = "Provider"; +"hooks_any_provider" = "Any provider"; +"hooks_threshold" = "Fire at usage ≥"; +"hooks_threshold_placeholder" = "90"; +"hooks_executable_placeholder" = "/usr/local/bin/my-command"; +"hooks_arguments_placeholder" = "Arguments"; +"hooks_argument_placeholder" = "Argument"; +"hooks_add_argument" = "Add argument"; +"hooks_delete_argument" = "Delete argument"; + /* Providers Pane */ "select_a_provider" = "Select a provider"; "cancel" = "Cancel"; @@ -470,48 +557,65 @@ "menu_bar_metric_subtitle" = "Choose which window drives the menu bar percent."; "menu_bar_metric_subtitle_deepseek" = "Shows the DeepSeek balance in the menu bar."; "menu_bar_metric_subtitle_moonshot" = "Shows the Moonshot / Kimi API balance in the menu bar."; -"menu_bar_metric_subtitle_mistral" = "Shows current-month Mistral API spend in the menu bar."; -"menu_bar_metric_subtitle_kimik2" = "Shows Kimi K2 API-key credits in the menu bar."; +"menu_bar_metric_subtitle_mistral" = "Choose Mistral API spend or Monthly Plan usage for the menu bar."; "automatic" = "Automatic"; "primary_api_key_limit" = "Primary (API key limit)"; /* Display Pane */ -"section_menu_bar" = "Menu bar"; -"merge_icons_title" = "Merge Icons"; +"menu_bar_style_title" = "Menu bar style"; +"menu_bar_style_subtitle" = "How the menu bar item is drawn."; +"menu_bar_inactive_display_contrast_title" = "Improve visibility on inactive displays"; +"menu_bar_usage_colors_title" = "Color-coded usage"; +"menu_bar_usage_colors_subtitle" = "Tint the menu bar icon from green to red as usage rises."; +"menu_bar_inactive_display_contrast_subtitle" = "Use high-contrast rendering to keep the icon and metric readable on other displays."; +"menu_bar_style_critters" = "Critters"; +"menu_bar_style_bars" = "Meter bars"; +"menu_bar_style_icon_percent" = "Icon & percent"; +"switcher_rows_title" = "Switcher rows"; +"switcher_rows_icons" = "Provider icons"; +"switcher_rows_progress" = "Weekly progress"; +"usage_bars_fill_title" = "Usage bars fill"; +"usage_bars_fill_remaining" = "As remaining"; +"usage_bars_fill_used" = "As used"; +"reset_times_title" = "Reset times"; +"reset_times_countdown" = "Countdown"; +"reset_times_clock" = "Clock time"; +"cost_summary_title" = "Cost summary"; +"cost_summary_off" = "Off"; +"merge_icons_title" = "Merge icons"; "merge_icons_subtitle" = "Use a single menu bar icon with a provider switcher."; -"switcher_shows_icons_title" = "Switcher shows icons"; -"switcher_shows_icons_subtitle" = "Show provider icons in the switcher (otherwise show a weekly progress line)."; "show_most_used_provider_title" = "Show most-used provider"; "show_most_used_provider_subtitle" = "Menu bar auto-shows the provider closest to its rate limit."; -"menu_bar_shows_percent_title" = "Menu bar shows percent"; -"menu_bar_shows_percent_subtitle" = "Replace critter bars with provider branding icons and a percentage."; "display_mode_title" = "Display mode"; "display_mode_subtitle" = "Choose what to show in the menu bar (Pace shows usage vs. expected)."; -"section_menu_content" = "Menu content"; -"show_usage_as_used_title" = "Show usage as used"; -"show_usage_as_used_subtitle" = "Progress bars fill as you consume quota (instead of showing remaining)."; "show_quota_warning_markers_title" = "Show quota warning markers"; "show_quota_warning_markers_subtitle" = "Draw threshold tick marks on usage bars when quota warnings are configured."; -"weekly_progress_work_days_title" = "Weekly progress work days"; -"weekly_progress_work_days_subtitle" = "Draw day-boundary tick marks on weekly usage bars."; -"show_reset_time_as_clock_title" = "Show reset time as clock"; -"show_reset_time_as_clock_subtitle" = "Display reset times as absolute clock values instead of countdowns."; +"weekly_progress_work_days_title" = "Work days"; +"weekly_progress_work_days_subtitle" = "Set work days for weekly usage-bar markers and pace calculations."; "show_provider_changelog_links_title" = "Show provider changelog links"; "show_provider_changelog_links_subtitle" = "Adds release-notes links for supported CLI-backed providers to the menu."; -"show_credits_extra_usage_title" = "Show credits + extra usage"; +"show_credits_extra_usage_title" = "Show credits & extra usage"; "show_credits_extra_usage_subtitle" = "Show Codex Credits and Claude Extra usage sections in the menu."; -"show_all_token_accounts_title" = "Show all token accounts"; -"show_all_token_accounts_subtitle" = "Stack token accounts in the menu (otherwise show an account switcher bar)."; "multi_account_layout_title" = "Multi-account layout"; "multi_account_layout_subtitle" = "Choose segmented account switching or stacked account cards."; "multi_account_layout_segmented" = "Segmented"; "multi_account_layout_stacked" = "Stacked"; -"overview_tab_providers_title" = "Overview tab providers"; +"overview_tab_providers_title" = "Overview providers"; "configure" = "Configure…"; -"overview_enable_merge_icons_hint" = "Enable Merge Icons to configure Overview tab providers."; +"overview_enable_merge_icons_hint" = "Turn on Merge icons to configure Overview providers."; "overview_no_providers_hint" = "No enabled providers available for Overview."; "overview_rows_follow_order" = "Overview rows always follow provider order."; "overview_no_providers_selected" = "No providers selected"; +"agent_sessions_title" = "Agent sessions"; +"agent_sessions_subtitle" = "Show local and SSH-discovered Codex and Claude Code sessions in the menu."; +"agent_sessions_hosts_title" = "Additional SSH hosts"; +"agent_sessions_footer" = "Macs on your tailnet are discovered automatically. Local sessions refresh every 30 seconds; remote hosts every 60 seconds and when the menu opens."; +"agent_session_labels_title" = "Session labels"; +"agent_session_labels_subtitle" = "Choose how agent sessions are named."; +"agent_session_label_project" = "Project"; +"agent_session_label_descriptive" = "Descriptive"; +"agent_session_label_descriptive_and_project" = "Descriptive + project"; +"agent_session_unknown_project" = "Unknown project"; /* Advanced Pane */ "section_keyboard_shortcut" = "Keyboard shortcut"; @@ -521,12 +625,10 @@ "install_cli_subtitle" = "Symlink CodexBarCLI to /usr/local/bin and /opt/homebrew/bin as codexbar."; "cli_not_found" = "CodexBarCLI not found in app bundle."; "no_writable_bin_dirs" = "No writable bin dirs found."; -"show_debug_settings_title" = "Show Debug Settings"; +"show_debug_settings_title" = "Show debug settings"; "show_debug_settings_subtitle" = "Expose troubleshooting tools in the Debug tab."; "surprise_me_title" = "Surprise me"; "surprise_me_subtitle" = "Check if you like your agents having some fun up there."; -"weekly_limit_confetti_title" = "Weekly limit confetti"; -"weekly_limit_confetti_subtitle" = "Play full-screen confetti when weekly usage resets."; "hide_personal_info_title" = "Hide personal information"; "hide_personal_info_subtitle" = "Obscure email addresses in the menu bar and menu UI."; "show_provider_storage_usage_title" = "Show provider storage usage"; @@ -543,7 +645,7 @@ "link_twitter" = "Twitter"; "link_email" = "Email"; "check_updates_auto" = "Check for updates automatically"; -"update_channel" = "Update Channel"; +"update_channel" = "Update channel"; "check_for_updates" = "Check for Updates…"; "updates_unavailable" = "Updates unavailable in this build."; "copyright" = "© 2026 Peter Steinberger. MIT License."; @@ -613,17 +715,24 @@ "metric_pref_tertiary" = "Tertiary"; "metric_pref_extra_usage" = "Extra usage"; "metric_pref_average" = "Average"; +"metric_mistral_payg" = "Pay-as-you-go"; +"metric_mistral_monthly_plan" = "Monthly Plan"; /* Display modes */ "display_mode_percent" = "Percent"; "display_mode_pace" = "Pace"; "display_mode_both" = "Both"; +"display_mode_reset_time" = "Reset time"; "display_mode_percent_desc" = "Show remaining/used percentage (e.g. 45%)"; "display_mode_pace_desc" = "Show pace indicator (e.g. +5%)"; "display_mode_both_desc" = "Show both percentage and pace (e.g. 45% · +5%)"; +"display_mode_reset_time_desc" = "Show the reset time for the selected metric (e.g. ↻ 3:56 PM)"; +"menu_bar_reset_when_exhausted_title" = "Show reset time when quota runs out"; +"menu_bar_reset_when_exhausted_subtitle" = "At 0% remaining, show the time until reset instead of the percentage"; /* Provider status */ "status_operational" = "Operational"; +"status_degraded" = "Degraded performance"; "status_partial_outage" = "Partial outage"; "status_major_outage" = "Major outage"; "status_critical_issue" = "Critical issue"; @@ -637,13 +746,20 @@ "refresh_5min" = "5 min"; "refresh_15min" = "15 min"; "refresh_30min" = "30 min"; +"refresh_adaptive" = "Adaptive"; +"refresh_adaptive_agent_aware" = "Adaptive (agent-aware)"; +"adaptive_activity_consent_title" = "Allow agent-aware refresh?"; +"adaptive_activity_consent_message" = "Adaptive (agent-aware) can inspect the local running-process list, including command lines, to identify Codex and Claude, then read known session metadata every 30 seconds while you are coding. With Agent Sessions off, CodexBar uses only the latest activity time in memory and discards session paths and identities. This activity data is not sent anywhere, and remote discovery and SSH stay off. If you decline, CodexBar returns to plain Adaptive without local activity scans."; +"adaptive_activity_consent_allow" = "Allow Local Activity"; +"adaptive_activity_consent_decline" = "Use Plain Adaptive"; /* Additional keys */ "not_found" = "Not found"; /* Cost estimation */ -"cost_header_estimated" = "Cost (estimated)"; "cost_estimate_hint" = "Estimated from local logs · may differ from your bill"; +"codex_api_estimate_hint" = "Estimated from token usage · not a subscription bill"; +"cost_data_explanation" = "Costs may be provider-reported or estimated from token usage at public API prices. Estimates are not subscription charges."; "No JetBrains IDE with AI Assistant detected. Install a JetBrains IDE and enable AI Assistant." = "No JetBrains IDE with AI Assistant detected. Install a JetBrains IDE and enable AI Assistant."; "OpenRouter API token not configured. Set OPENROUTER_API_KEY environment variable or configure in Settings." = "OpenRouter API token not configured. Set OPENROUTER_API_KEY environment variable or configure in Settings."; "z.ai API token not found. Set apiKey in ~/.codexbar/config.json or Z_AI_API_KEY." = "z.ai API token not found. Set apiKey in ~/.codexbar/config.json or Z_AI_API_KEY."; @@ -673,6 +789,7 @@ "Today" = "Today"; "Today tokens" = "Today tokens"; "30d cost" = "30d cost"; +"%@ cost" = "%@ cost"; "30d tokens" = "30d tokens"; "Latest tokens" = "Latest tokens"; "Top model" = "Top model"; @@ -680,6 +797,7 @@ "Add Account..." = "Add Account..."; "Usage Dashboard" = "Usage Dashboard"; "Status Page" = "Status Page"; +"Open Status Page" = "Open Status Page"; "Settings..." = "Settings..."; "About CodexBar" = "About CodexBar"; "Quit" = "Quit"; @@ -692,8 +810,12 @@ "Resets %@" = "Resets %@"; "Resets in %@" = "Resets in %@"; "Resets now" = "Resets now"; +"reset_tomorrow_format" = "tomorrow, %@"; "Lasts until reset" = "Lasts until reset"; +"1.5× headroom" = "1.5× headroom"; "Updated %@" = "Updated %@"; +"Updated relative %@" = "Updated %@"; +"Updated absolute %@" = "Updated %@"; "Updated %@h ago" = "Updated %@h ago"; "Updated %@m ago" = "Updated %@m ago"; "Updated just now" = "Updated just now"; @@ -749,6 +871,7 @@ "Est. total (%@): %@" = "Est. total (%@): %@"; "Hover a bar for details" = "Hover a bar for details"; "%@: %@ · %@ tokens" = "%@: %@ · %@ tokens"; +"%@: %@" = "%@: %@"; "No providers selected for Overview." = "No providers selected for Overview."; "No overview data available." = "No overview data available."; "Auto uses the local IDE API first, then Google OAuth when the IDE is closed." = "Auto uses the local IDE API first, then Google OAuth when the IDE is closed."; @@ -757,6 +880,8 @@ /* Popup panels */ "No usage configured." = "No usage configured."; "Quota" = "Quota"; +"Daily quota" = "Daily quota"; +"Total" = "Total"; "tokens" = "tokens"; "requests" = "requests"; "Latest" = "Latest"; @@ -790,6 +915,7 @@ "API spend" = "API spend"; "Extra usage" = "Extra usage"; "Quota usage" = "Quota usage"; +"Your spend" = "Your spend"; "%.0f%% used" = "%.0f%% used"; "Usage history (today)" = "Usage history (today)"; "Usage history (%d days)" = "Usage history (%d days)"; @@ -809,7 +935,7 @@ "Hourly Usage" = "Hourly Usage"; "Usage remaining" = "Usage remaining"; "Usage used" = "Usage used"; -"API key verified. Ollama does not expose Cloud quota limits through the API." = "API key verified. Ollama does not expose Cloud quota limits through the API."; +"API key verified. Cloud quotas need browser cookies. Sign in to Ollama." = "API key verified. Cloud quotas need browser cookies. Sign in to Ollama."; "Last 30 days: %@ tokens" = "Last 30 days: %@ tokens"; "7d spend" = "7d spend"; "30d spend" = "30d spend"; @@ -820,6 +946,13 @@ "MiniMax 30 day token usage trend" = "MiniMax 30 day token usage trend"; "Today cash" = "Today cash"; "DeepSeek 30 day token usage trend" = "DeepSeek 30 day token usage trend"; +"DeepSeek this month token usage trend" = "DeepSeek this month token usage trend"; +"Chrome profile" = "Chrome profile"; +"Choose which signed-in DeepSeek Platform session supplies detailed usage." = "Choose which signed-in DeepSeek Platform session supplies detailed usage."; +"Detailed usage unavailable." = "Detailed usage unavailable."; +"Sign in to DeepSeek Platform in Chrome for detailed usage." = "Sign in to DeepSeek Platform in Chrome for detailed usage."; +"Select a DeepSeek Chrome profile in Settings." = "Select a DeepSeek Chrome profile in Settings."; +"Select profile…" = "Select profile…"; "cache-hit input" = "cache-hit input"; "cache-miss input" = "cache-miss input"; "output" = "output"; @@ -859,6 +992,9 @@ "Clearing removes local temporary provider data." = "Clearing removes local temporary provider data."; "Total: %@" = "Total: %@"; "%d more items" = "%d more items"; +"Other (%d items)" = "Other (%d items)"; +"Expand" = "Expand"; +"Collapse" = "Collapse"; "Cleanup ideas" = "Cleanup ideas"; "%d unreadable item(s) skipped" = "%d unreadable item(s) skipped"; @@ -905,7 +1041,7 @@ "Antigravity login failed" = "Antigravity login failed"; "Antigravity login timed out" = "Antigravity login timed out"; "Auth source" = "Auth source"; -"Automatic imports Chrome browser cookies from Xiaomi MiMo." = "Automatic imports Chrome browser cookies from Xiaomi MiMo."; +"Automatic imports browser cookies from Xiaomi MiMo." = "Automatic imports browser cookies from Xiaomi MiMo."; "Automatic imports Windsurf session data from Chromium browser localStorage." = "Automatic imports Windsurf session data from Chromium browser localStorage."; "Automatic imports browser cookies from Bailian." = "Automatic imports browser cookies from Bailian."; "Automatically imports browser cookies." = "Automatically imports browser cookies."; @@ -940,7 +1076,6 @@ "CodexBar will ask macOS Keychain for your Cursor cookie header so it can fetch usage. Click OK to continue." = "CodexBar will ask macOS Keychain for your Cursor cookie header so it can fetch usage. Click OK to continue."; "CodexBar will ask macOS Keychain for your Factory cookie header so it can fetch usage. Click OK to continue." = "CodexBar will ask macOS Keychain for your Factory cookie header so it can fetch usage. Click OK to continue."; "CodexBar will ask macOS Keychain for your GitHub Copilot token so it can fetch usage. Click OK to continue." = "CodexBar will ask macOS Keychain for your GitHub Copilot token so it can fetch usage. Click OK to continue."; -"CodexBar will ask macOS Keychain for your Kimi K2 API key so it can fetch usage. Click OK to continue." = "CodexBar will ask macOS Keychain for your Kimi K2 API key so it can fetch usage. Click OK to continue."; "CodexBar will ask macOS Keychain for your Kimi auth token so it can fetch usage. Click OK to continue." = "CodexBar will ask macOS Keychain for your Kimi auth token so it can fetch usage. Click OK to continue."; "CodexBar will ask macOS Keychain for your MiniMax API token so it can fetch usage. Click OK to continue." = "CodexBar will ask macOS Keychain for your MiniMax API token so it can fetch usage. Click OK to continue."; "CodexBar will ask macOS Keychain for your MiniMax cookie header so it can fetch usage. Click OK to continue." = "CodexBar will ask macOS Keychain for your MiniMax cookie header so it can fetch usage. Click OK to continue."; @@ -954,10 +1089,15 @@ "Day" = "Day"; "Deployment" = "Deployment"; "Drag to reorder" = "Drag to reorder"; +"Sort providers alphabetically" = "Sort providers alphabetically"; +"Sort providers alphabetically (enabled first)" = "Sort providers alphabetically (enabled first)"; +"Sorted alphabetically (enabled first) — click to use your custom order" = "Sorted alphabetically (enabled first) — click to use your custom order"; "Endpoint" = "Endpoint"; "Enterprise host" = "Enterprise host"; "Extra usage balance: %@" = "Extra usage balance: %@"; "Keychain Access Required" = "Keychain Access Required"; +"keychain_prompt_learn_more" = "Learn More…"; +"keychain_prompt_privacy_note" = "macOS—not CodexBar—handles any Mac login password entry. You can disable all Keychain access at any time in Settings → Advanced."; "Kiro menu bar value" = "Kiro menu bar value"; "Label" = "Label"; "No organizations loaded. Click Refresh after setting your API key." = "No organizations loaded. Click Refresh after setting your API key."; @@ -984,6 +1124,7 @@ "Optional. Leave blank to discover and aggregate projects visible to the API key." = "Optional. Leave blank to discover and aggregate projects visible to the API key."; "Org ID (optional)" = "Org ID (optional)"; "Organizations" = "Organizations"; +"Organization ID" = "Organization ID"; "Password" = "Password"; "%@ authentication is disabled." = "%@ authentication is disabled."; "%@ cookies are disabled." = "%@ cookies are disabled."; @@ -1005,6 +1146,7 @@ "Personal account" = "Personal account"; "Project ID" = "Project ID"; "Re-auth" = "Re-auth"; +"Re-login at claude.ai" = "Re-login at claude.ai"; "Re-authenticating…" = "Re-authenticating…"; "Refresh Session" = "Refresh Session"; "Refresh organizations" = "Refresh organizations"; @@ -1036,6 +1178,7 @@ "Stored in ~/.codexbar/config.json. You can also provide CROF_API_KEY." = "Stored in ~/.codexbar/config.json. You can also provide CROF_API_KEY."; "Stored in ~/.codexbar/config.json. You can also provide KILO_API_KEY or ~/.local/share/kilo/auth.json (kilo.access)." = "Stored in ~/.codexbar/config.json. You can also provide KILO_API_KEY or ~/.local/share/kilo/auth.json (kilo.access)."; "T3 Chat cookie" = "T3 Chat cookie"; +"Team mode" = "Team mode"; "That account is no longer available in CodexBar. Refresh the account list and try again." = "That account is no longer available in CodexBar. Refresh the account list and try again."; "The browser login did not complete in time. Try Antigravity login again." = "The browser login did not complete in time. Try Antigravity login again."; "Timed out waiting for Cursor login. %@" = "Timed out waiting for Cursor login. %@"; @@ -1057,3 +1200,159 @@ "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"; + +"language_vietnamese" = "Vietnamese"; +"language_indonesian" = "Bahasa Indonesia"; + +"Request quota: %@ / %@" = "Request quota: %@ / %@"; +"language_arabic" = "العربية"; +"language_persian" = "فارسی"; +"language_thai" = "ไทย"; +"language_galician" = "Galego"; +"Limit Reset Credits" = "Limit Reset Credits"; +"1 available" = "1 available"; +"%d available" = "%d available"; +"Next expires %@" = "Next expires %@"; +"Expires %@" = "Expires %@"; +"No expiry" = "No expiry"; +"byte_unit_byte" = "byte"; +"byte_unit_bytes" = "bytes"; +"byte_unit_kilobyte" = "kilobyte"; +"byte_unit_kilobytes" = "kilobytes"; +"byte_unit_megabyte" = "megabyte"; +"byte_unit_megabytes" = "megabytes"; +"byte_unit_gigabyte" = "gigabyte"; +"byte_unit_gigabytes" = "gigabytes"; + +/* Settings sidebar redesign */ +"Enable" = "Enable"; +"Disable" = "Disable"; +"providers_on_count" = "%d on"; +"section_cost_summary" = "Cost summary"; +"section_command_line" = "Command line"; +"section_privacy" = "Privacy"; +"section_diagnostics" = "Diagnostics"; +"section_updates" = "Updates"; +"section_links" = "Links"; +"Show Codex Spark usage" = "Show Codex Spark usage"; +"Shows Codex Spark quota rows in the menu and provider preview. Requires optional credits and extra usage in Display settings." = "Shows Codex Spark quota rows in the menu and provider preview. Requires optional credits and extra usage in Display settings."; +"Show Daily Routines usage" = "Show Daily Routines usage"; +"Shows the Daily Routines quota row in the menu and provider preview. Requires optional credits and extra usage in Display settings." = "Shows the Daily Routines quota row in the menu and provider preview. Requires optional credits and extra usage in Display settings."; +"Scroll to see more models" = "Scroll to see more models"; + +/* Shareable usage card */ +"Copy Image" = "Copy Image"; +"Copy Stats" = "Copy Stats"; +"Could not copy image" = "Could not copy image"; +"Image copied" = "Image copied"; +"Image saved" = "Image saved"; +"Nothing is uploaded. This image is created on your Mac." = "Nothing is uploaded. This image is created on your Mac."; +"Save..." = "Save..."; +"Share AI Usage" = "Share AI Usage"; +"Share Stats…" = "Share Stats…"; +"Stats copied" = "Stats copied"; +"Finish switching to a different Cursor account in your browser, then try again." = "Finish switching to a different Cursor account in your browser, then try again."; +"Timed out waiting for Cursor account switch. %@" = "Timed out waiting for Cursor account switch. %@"; +"Timed out waiting for Cursor account switch. %@ Last error: %@" = "Timed out waiting for Cursor account switch. %@ Last error: %@"; + +/* Spend dashboard */ +"tab_usage_spend" = "Usage & Spend"; +"Usage & Spend" = "Usage & Spend"; +"Local estimated cost history across supported providers." = "Local estimated cost history across supported providers."; +"Time range" = "Time range"; +"Track costs" = "Track costs"; +"Cost tracking is off" = "Cost tracking is off"; +"Turn on Track costs to build local estimates." = "Turn on Track costs to build local estimates."; +"No local cost history yet" = "No local cost history yet"; +"Turn on cost tracking or refresh after using a supported provider." = "Turn on cost tracking or refresh after using a supported provider."; +"Refresh failures" = "Refresh failures"; +"Native currencies stay separate; Codex account rows exclude Pi session history." = "Native currencies stay separate; Codex account rows exclude Pi session history."; +"Spend unavailable" = "Spend unavailable"; +"Model breakdown unavailable" = "Model breakdown unavailable"; +"Local estimated history" = "Local estimated history"; +"Coverage" = "Coverage"; +"Estimated spend" = "Estimated spend"; +"Tracked tokens" = "Tracked tokens"; +"Subscriptions" = "Subscriptions"; +"By subscription" = "By subscription"; +"No model-level history" = "No model-level history"; +"Daily estimated spend" = "Daily estimated spend"; +"≈%d full 5h windows of weekly left · %d windows until reset" = "≈%d full 5h windows of weekly left · %d windows until reset"; +"Weekly cannot run out before reset at this pace" = "Weekly cannot run out before reset at this pace"; +"Weekly can run out ≈%d windows early" = "Weekly can run out ≈%d windows early"; +"Estimated: %@" = "Est. %@"; +"session_quota_estimate_value_format" = "%1$@ %2$@"; +"session quota" = "session quota"; +"session quotas" = "session quotas"; +"Coding Plan" = "Coding Plan"; +"Agent Plan" = "Agent Plan"; +"Team" = "Team"; + +/* Menu bar layout editor */ +"menu_bar_layout_title" = "Layout"; +"menu_bar_layout_footer" = "Drag tokens to arrange the menu bar. Click a token to append it; select a placed token and press Delete to remove it."; +"menu_bar_layout_group_identity" = "Identity"; +"menu_bar_layout_group_usage" = "Usage"; +"menu_bar_layout_group_time" = "Time"; +"menu_bar_layout_group_money" = "Money"; +"menu_bar_layout_group_structure" = "Structure"; +"menu_bar_layout_scope_all" = "All providers"; +"menu_bar_layout_scope_help" = "Edit the default layout or override one provider."; +"menu_bar_layout_use_all" = "Use all-providers layout"; +"menu_bar_layout_preset" = "Layout preset"; +"menu_bar_layout_preset_icon_percent" = "Icon & percent"; +"menu_bar_layout_preset_icon_only" = "Icon only"; +"menu_bar_layout_preset_percent_reset" = "Percent + reset"; +"menu_bar_layout_preset_compact_stacked" = "Compact stacked"; +"menu_bar_layout_preset_custom" = "Custom"; +"menu_bar_layout_live_preview" = "Live preview"; +"menu_bar_layout_strip" = "Menu bar strip"; +"menu_bar_layout_remove_line_break" = "Remove line break"; +"menu_bar_layout_chip_hint" = "Select, drag to reorder, or use the Remove action."; +"menu_bar_layout_palette_hint" = "Click to append or drag into the layout."; +"menu_bar_layout_empty_line" = "Drop a token here"; +"menu_bar_layout_line" = "Line %d"; +"menu_bar_layout_drag_remove" = "Drag here to remove"; +"menu_bar_layout_size" = "Size"; +"menu_bar_layout_size_small" = "Small"; +"menu_bar_layout_size_regular" = "Regular"; +"menu_bar_layout_gap" = "Gap"; +"menu_bar_layout_gap_tight" = "Tight"; +"menu_bar_layout_gap_regular" = "Regular"; +"menu_bar_layout_keyboard_hint" = "Delete removes the selected token"; +"menu_bar_layout_sample_account" = "account"; +"menu_bar_layout_sample_runs_out" = "runs out Fri"; +"menu_bar_layout_token_icon" = "Icon"; +"menu_bar_layout_token_provider" = "Provider name"; +"menu_bar_layout_token_account" = "Account"; +"menu_bar_layout_token_session" = "Session %"; +"menu_bar_layout_token_weekly" = "Weekly %"; +"menu_bar_layout_token_auto" = "Auto %"; +"menu_bar_layout_token_bar" = "Usage bar"; +"menu_bar_layout_token_resets_in" = "Resets in"; +"menu_bar_layout_token_reset_at" = "Reset at"; +"menu_bar_layout_token_runs_out" = "Runs out"; +"menu_bar_layout_token_cost_today" = "Cost today"; +"menu_bar_layout_token_cost_30d" = "Cost 30d"; +"menu_bar_layout_token_space" = "Space"; +"menu_bar_layout_token_line_break" = "Line break"; +"menu_bar_layout_token_separator_accessibility" = "Separator dot"; + +/* Menu bar layout accessibility */ +"Icon unavailable" = "Icon unavailable"; +"%@ icon" = "%@ icon"; +"Provider name unavailable" = "Provider name unavailable"; +"Account unavailable" = "Account unavailable"; +"%@ unavailable" = "%@ unavailable"; +"%@ %@" = "%@ %@"; +"Usage bar unavailable" = "Usage bar unavailable"; +"Usage bar, %d of 3 filled" = "Usage bar, %d of 3 filled"; +"Reset countdown unavailable" = "Reset countdown unavailable"; +"Reset time unavailable" = "Reset time unavailable"; +"Run-out estimate unavailable" = "Run-out estimate unavailable"; +"Cost today unavailable" = "Cost today unavailable"; +"30-day cost unavailable" = "30-day cost unavailable"; +"Resets" = "Resets"; diff --git a/Sources/CodexBar/Resources/en.lproj/Localizable.stringsdict b/Sources/CodexBar/Resources/en.lproj/Localizable.stringsdict new file mode 100644 index 0000000000..f4090374dd --- /dev/null +++ b/Sources/CodexBar/Resources/en.lproj/Localizable.stringsdict @@ -0,0 +1,49 @@ + + + + + ≈%d full 5h windows of weekly left · %d windows until reset + + NSStringLocalizedFormatKey + %#@left@ · %#@until@ + left + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + d + one + ≈%d full 5h window of weekly left + other + ≈%d full 5h windows of weekly left + + until + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + d + one + %d window until reset + other + %d windows until reset + + + Weekly can run out ≈%d windows early + + NSStringLocalizedFormatKey + %#@early@ + early + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + d + one + Weekly can run out ≈%d window early + other + Weekly can run out ≈%d windows early + + + + diff --git a/Sources/CodexBar/Resources/es.lproj/Localizable.strings b/Sources/CodexBar/Resources/es.lproj/Localizable.strings index 958bd89db7..77197cf10f 100644 --- a/Sources/CodexBar/Resources/es.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/es.lproj/Localizable.strings @@ -1,8 +1,33 @@ /* Spanish localization for CodexBar */ +"tab_hooks" = "Hooks"; +"hooks_enable_title" = "Activar hooks"; +"hooks_enable_subtitle" = "Ejecuta comandos externos cuando ocurran eventos de cuota o proveedor."; +"hooks_trust_warning" = "Los hooks pueden ejecutar comandos locales en tu Mac. Configura solo comandos de confianza."; +"hooks_rules_header" = "Reglas"; +"hooks_empty" = "No hay hooks configurados."; +"hooks_add_rule" = "Añadir regla"; +"hooks_delete_rule" = "Eliminar regla"; +"hooks_rule_enabled" = "Activado"; +"hooks_event" = "Evento"; +"hooks_provider" = "Proveedor"; +"hooks_any_provider" = "Cualquier proveedor"; +"hooks_threshold" = "Ejecutar con uso ≥"; +"hooks_threshold_placeholder" = "90"; +"hooks_executable_placeholder" = "/usr/local/bin/my-command"; +"hooks_arguments_placeholder" = "Argumentos"; +"hooks_argument_placeholder" = "Argumento"; +"hooks_add_argument" = "Añadir argumento"; +"hooks_delete_argument" = "Eliminar argumento"; + +"ollama_safari_cookie_access_hint" = "Las cookies de Safari necesitan acceso total al disco para CodexBar (Ajustes del Sistema > Privacidad y seguridad)."; +"ollama_browser_cookie_decryption_denied" = "Se rechazó en el Llavero el descifrado de las cookies de %@; vuelve a intentarlo con una actualización manual."; +"ollama_browser_cookie_decryption_disabled" = "El descifrado de las cookies de %@ está desactivado en CodexBar; activa el acceso al Llavero y actualiza."; + " providers" = " proveedores"; "(System)" = "(Sistema)"; "30d" = "30 d"; +"7d" = "7 d"; "A managed Codex login is already running. Wait for it to finish before adding " = "Ya hay un inicio de sesión gestionado de Codex en curso. Espera a que termine antes de añadir "; "API key" = "Clave de API"; "API region" = "Región de API"; @@ -98,6 +123,8 @@ "Could not start codex login" = "No se pudo iniciar codex login"; "Could not switch system account" = "No se pudo cambiar la cuenta del sistema"; "Credits" = "Créditos"; +"Individual credits" = "Créditos individuales"; +"Workspace" = "Espacio de trabajo"; "Credits history" = "Historial de créditos"; "Cursor login failed" = "El inicio de sesión de Cursor falló"; "Custom" = "Personalizado"; @@ -232,6 +259,7 @@ "Picker subtitle" = "Subtítulo del selector"; "Placeholder" = "Marcador de posición"; "Plan" = "Plan"; +"Plan Usage" = "Uso del plan"; "Play full-screen confetti when weekly usage resets." = "Mostrar confeti a pantalla completa cuando se reinicia el uso semanal."; "Polls OpenAI/Claude status pages and Google Workspace for " = "Consulta las páginas de estado de OpenAI/Claude y Google Workspace para "; "Prevents any Keychain access while enabled." = "Impide cualquier acceso al Llavero mientras esté activado."; @@ -257,6 +285,7 @@ "Replay selected animation" = "Reproducir la animación seleccionada"; "Requires authentication via GitHub Device Flow." = "Requiere autenticación mediante el flujo de dispositivo de GitHub."; "Resets: \\(reset)" = "Se reinicia: \\(reset)"; +"reset_tomorrow_format" = "mañana, %@"; "Rolling five-hour limit" = "Límite móvil de cinco horas"; "Search hourly" = "Búsquedas por hora"; "Secondary (\\(label))" = "Secundario (\\(label))"; @@ -265,7 +294,8 @@ "Select the IDE to monitor" = "Selecciona el IDE a monitorizar"; "Session quota notifications" = "Notificaciones de cuota de sesión"; "Session tokens" = "Tokens de sesión"; -"Settings" = "Ajustes"; +"provider_section_connection" = "Conexión"; +"provider_section_menu_bar" = "Barra de menús"; "Show Codex Credits and Claude Extra usage sections in the menu." = "Mostrar las secciones de Créditos de Codex y Uso adicional de Claude en el menú."; "Show Debug Settings" = "Mostrar ajustes de depuración"; "Show all token accounts" = "Mostrar todas las cuentas con token"; @@ -293,18 +323,18 @@ "Store multiple OpenCode Go Cookie headers." = "Almacena varias cabeceras Cookie de OpenCode Go."; "Stored in the CodexBar config file." = "Almacenado en el archivo de configuración de CodexBar."; "Stored in ~/.codexbar/config.json. " = "Almacenado en ~/.codexbar/config.json. "; -"Stored in ~/.codexbar/config.json. Generate one at kimi-k2.ai." = "Almacenado en ~/.codexbar/config.json. Genera una en kimi-k2.ai."; "Stored in ~/.codexbar/config.json. Paste the key from the Synthetic dashboard." = "Almacenado en ~/.codexbar/config.json. Pega la clave del panel de Synthetic."; "Stored in ~/.codexbar/config.json. Paste your Coding Plan API key from Model Studio." = "Almacenado en ~/.codexbar/config.json. Pega tu clave de API del plan de programación desde Model Studio."; "Stored in ~/.codexbar/config.json. Paste your MiniMax API key." = "Almacenado en ~/.codexbar/config.json. Pega tu clave de API de MiniMax."; "Stored in ~/.codexbar/config.json. You can also provide KILO_API_KEY or " = "Almacenado en ~/.codexbar/config.json. También puedes proporcionar KILO_API_KEY o "; "Stores local Codex usage history (8 weeks) to personalize Pace predictions." = "Almacena el historial de uso local de Codex (8 semanas) para personalizar las predicciones de Ritmo."; -"Subscription Utilization" = "Uso de la suscripción"; "Surprise me" = "Sorpréndeme"; "Switcher shows icons" = "El selector muestra iconos"; "Symlink CodexBarCLI to /usr/local/bin and /opt/homebrew/bin as codexbar." = "Crear un enlace simbólico de CodexBarCLI en /usr/local/bin y /opt/homebrew/bin como codexbar."; "System" = "Sistema"; "Temporarily shows the loading animation after the next refresh." = "Muestra temporalmente la animación de carga tras la próxima actualización."; +"terminal_app_subtitle" = "Terminal usado por la acción Abrir Terminal"; +"terminal_app_title" = "Terminal predeterminado"; "Tertiary (\\(label))" = "Terciario (\\(label))"; "Tertiary (\\(tertiaryTitle))" = "Terciario (\\(tertiaryTitle))"; "The default Codex account on this Mac." = "La cuenta de Codex predeterminada en este Mac."; @@ -317,6 +347,9 @@ "Unsupported" = "No compatible"; "Update Channel" = "Canal de actualizaciones"; "Updated" = "Actualizado"; +"Updated %@" = "Actualizado %@"; +"Updated relative %@" = "Actualizado %@"; +"Updated absolute %@" = "Actualizado %@"; "Updates unavailable in this build." = "Actualizaciones no disponibles en esta compilación."; "Usage" = "Uso"; "Usage breakdown" = "Desglose de uso"; @@ -389,9 +422,19 @@ /* General Pane */ "section_system" = "Sistema"; "section_usage" = "Uso"; -"section_automation" = "Automatización"; +"section_refreshing" = "Actualización"; +"section_alerts" = "Alertas"; +"section_celebrations" = "Celebraciones"; +"section_icon" = "Icono"; +"section_combined_icon" = "Icono combinado"; +"section_animation" = "Animación"; +"section_content" = "Contenido"; +"section_agent_sessions" = "Sesiones de agentes"; "language_title" = "Idioma"; "language_subtitle" = "Cambia el idioma de la interfaz. Requiere reiniciar la app para aplicarse por completo."; +"currency_title" = "Moneda preferida"; +"currency_subtitle" = "Moneda para estimaciones de coste y gastos. Usa tipos de cambio actualizados diariamente."; +"currency_auto" = "Automático (según proveedor / USD)"; "language_system" = "Sistema"; "language_english" = "English"; "language_spanish" = "Español"; @@ -399,45 +442,85 @@ "language_chinese_simplified" = "简体中文"; "language_chinese_traditional" = "繁體中文"; "language_portuguese_brazilian" = "Português (Brasil)"; +"language_swedish" = "Sueco"; +"language_dutch" = "Nederlands"; +"language_german" = "Deutsch"; +"language_french" = "Francés"; +"language_ukrainian" = "Ucraniano"; +"language_russian" = "Русский"; +"language_japanese" = "Japonés"; +"language_korean" = "Coreano"; +"language_italian" = "Italiano"; +"language_polish" = "Polski"; "start_at_login_title" = "Abrir al iniciar sesión"; "start_at_login_subtitle" = "Abre CodexBar automáticamente al iniciar tu Mac."; -"show_cost_summary" = "Mostrar resumen de coste"; "show_cost_summary_subtitle" = "Lee los registros de uso locales. Muestra el coste de hoy + la ventana de historial seleccionada en el menú."; +"cost_summary_style_title" = "Estilo de visualización"; +"cost_summary_style_inline" = "Solo integrado"; +"cost_summary_style_submenu" = "Solo submenú"; +"cost_summary_style_both" = "Ambos"; +"cost_summary_style_inline_help" = "Muestra el resumen de coste directamente en el menú principal."; +"cost_summary_style_submenu_help" = "Muestra en su lugar el submenú Coste detallado."; +"cost_summary_style_both_help" = "Muestra el resumen del menú principal y el submenú Coste detallado."; +"cost_history_window_title" = "Ventana de historial"; +"cost_history_window_help" = "Define cuántos días de registros de uso locales aparecen en el menú."; "cost_history_days_title" = "Ventana de historial: %d días"; -"cost_auto_refresh_info" = "Actualización automática: cada hora · Tiempo de espera: 10 m"; -"refresh_cadence_title" = "Frecuencia de actualización"; -"refresh_cadence_subtitle" = "Con qué frecuencia CodexBar consulta a los proveedores en segundo plano."; +"cost_auto_refresh_info" = "Actualización automática: intervalo global (mínimo 5 min) · Tiempo de espera: 10 min"; +"cost_comparison_periods_title" = "Mostrar períodos de comparación más cortos"; +"cost_comparison_periods_subtitle" = "Añade totales de 7, 30 y 90 días cuando quepan en el intervalo de historial seleccionado. Estos totales reutilizan el mismo análisis local."; +"refresh_interval_title" = "Intervalo de actualización"; "manual_refresh_hint" = "La actualización automática está desactivada; usa el comando Actualizar del menú."; +"refresh_on_open_title" = "Actualizar al abrir el menú"; +"refresh_on_open_subtitle" = "Obtén el uso más reciente de cada proveedor cada vez que abres el menú."; "check_provider_status_title" = "Comprobar estado del proveedor"; "check_provider_status_subtitle" = "Consulta las páginas de estado de OpenAI/Claude y Google Workspace para Gemini/Antigravity, mostrando incidencias en el icono y el menú."; -"session_quota_notifications_title" = "Notificaciones de cuota de sesión"; "session_quota_notifications_subtitle" = "Avisa cuando la cuota de sesión de 5 horas llega al 0 % y cuando vuelve a estar disponible."; -"quota_warning_notifications_title" = "Notificaciones de aviso de cuota"; +"quota_depleted_title" = "Cuota agotada y restaurada"; "quota_warning_notifications_subtitle" = "Avisa cuando la cuota restante de sesión o semanal cruza los umbrales configurados."; +"threshold_warnings_title" = "Avisos de umbral"; "quota_warnings_title" = "Avisos de cuota"; "quota_warning_session" = "sesión"; "quota_warning_session_capitalized" = "Sesión"; "quota_warning_weekly" = "semanal"; "quota_warning_weekly_capitalized" = "Semanal"; +"predictive_pace_warnings_title" = "Avisos predictivos de ritmo"; +"predictive_pace_warnings_subtitle" = "Avisa para Codex y Claude cuando el ritmo de sesión o semanal puede agotar la cuota antes del reinicio."; +"confetti_on_reset_title" = "Confeti al reiniciar"; +"confetti_on_reset_subtitle" = "Reproduce confeti a pantalla completa cuando se reinicia el uso."; +"confetti_option_off" = "Desactivado"; +"confetti_option_session" = "Reinicios de sesión"; +"confetti_option_weekly" = "Reinicios semanales"; +"confetti_option_both" = "Ambos"; +"predictive_pace_warning_notification_title" = "%1$@: aviso de ritmo %2$@"; +"predictive_pace_warning_notification_body" = "Al ritmo actual, esta cuota podría agotarse en %1$@, antes de reiniciarse."; +"predictive_pace_warning_notification_body_with_account" = "Cuenta %1$@. Al ritmo actual, esta cuota podría agotarse en %2$@, antes de reiniciarse."; "quota_warning_warn_at" = "Avisar al"; "quota_warning_global_threshold_subtitle" = "Porcentajes restantes para las ventanas de sesión y semanal, salvo que un proveedor los anule."; "quota_warning_sound" = "Reproducir sonido de notificación"; +"quota_warning_onscreen_alert" = "Mostrar alerta de texto en pantalla"; "quota_warning_provider_inherits" = "Usa los ajustes globales de aviso de cuota salvo que se personalice una ventana aquí."; +"quota_warning_provider_disabled" = "Las notificaciones de aviso de cuota y los marcadores de las barras de uso están desactivados. Activa una de las dos opciones para editar estos ajustes guardados."; +"quota_warning_provider_markers_only" = "Las notificaciones de aviso de cuota están desactivadas globalmente. Estos ajustes siguen controlando los marcadores de las barras de uso."; +"quota_warning_global" = "Global"; "quota_warning_customize_thresholds" = "Personalizar umbrales de %@"; "quota_warning_enable_warnings" = "Activar avisos de %@"; "quota_warning_window_warn_at" = "%@ avisar al"; "quota_warning_off" = "Desactivado"; "quota_warning_inherited" = "Heredado: %@"; "quota_warning_depleted_only" = "solo agotado"; -"quota_warning_upper" = "Superior"; +"quota_warning_upper" = "Más alto"; "quota_warning_lower" = "Inferior"; +"quota_warning_warning" = "Advertencia"; +"quota_warning_critical" = "Crítico"; "apply" = "Aplicar"; "quit_app" = "Salir de CodexBar"; /* Tab titles */ "tab_general" = "General"; "tab_providers" = "Proveedores"; -"tab_display" = "Pantalla"; +"tab_notifications" = "Notificaciones"; +"tab_menu_bar" = "Barra de menús"; +"tab_menu" = "Menú"; "tab_advanced" = "Avanzado"; "tab_about" = "Acerca de"; "tab_debug" = "Depuración"; @@ -461,35 +544,44 @@ "menu_bar_metric_subtitle_deepseek" = "Muestra el saldo de DeepSeek en la barra de menús."; "menu_bar_metric_subtitle_moonshot" = "Muestra el saldo de la API de Moonshot / Kimi en la barra de menús."; "menu_bar_metric_subtitle_mistral" = "Muestra el gasto de la API de Mistral del mes actual en la barra de menús."; -"menu_bar_metric_subtitle_kimik2" = "Muestra los créditos de la clave de API de Kimi K2 en la barra de menús."; "automatic" = "Automático"; "primary_api_key_limit" = "Principal (límite de la clave de API)"; /* Display Pane */ -"section_menu_bar" = "Barra de menús"; +"menu_bar_style_title" = "Estilo de la barra de menús"; +"menu_bar_style_subtitle" = "Cómo se dibuja el elemento de la barra de menús."; +"menu_bar_inactive_display_contrast_title" = "Mejorar la visibilidad en pantallas inactivas"; +"menu_bar_usage_colors_title" = "Uso codificado por colores"; +"menu_bar_usage_colors_subtitle" = "Colorea el icono de la barra de menús de verde a rojo a medida que aumenta el uso."; +"menu_bar_inactive_display_contrast_subtitle" = "Usa un renderizado de alto contraste para mantener legibles el icono y la métrica en otras pantallas."; +"menu_bar_style_critters" = "Bichitos"; +"menu_bar_style_bars" = "Barras de medición"; +"menu_bar_style_icon_percent" = "Icono y porcentaje"; +"switcher_rows_title" = "Filas del selector"; +"switcher_rows_icons" = "Iconos de proveedor"; +"switcher_rows_progress" = "Progreso semanal"; +"usage_bars_fill_title" = "Relleno de las barras de uso"; +"usage_bars_fill_remaining" = "Según lo restante"; +"usage_bars_fill_used" = "Según lo usado"; +"reset_times_title" = "Horas de reinicio"; +"reset_times_countdown" = "Cuenta atrás"; +"reset_times_clock" = "Hora"; +"cost_summary_title" = "Resumen de coste"; +"cost_summary_off" = "Desactivado"; "merge_icons_title" = "Combinar iconos"; "merge_icons_subtitle" = "Usar un único icono en la barra de menús con un selector de proveedor."; -"switcher_shows_icons_title" = "El selector muestra iconos"; -"switcher_shows_icons_subtitle" = "Mostrar los iconos de proveedor en el selector (de lo contrario, mostrar una línea de progreso semanal)."; "show_most_used_provider_title" = "Mostrar el proveedor más usado"; "show_most_used_provider_subtitle" = "La barra de menús muestra automáticamente el proveedor más cercano a su límite."; -"menu_bar_shows_percent_title" = "La barra de menús muestra el porcentaje"; -"menu_bar_shows_percent_subtitle" = "Sustituir las barras de bichitos por iconos de marca del proveedor y un porcentaje."; "display_mode_title" = "Modo de visualización"; "display_mode_subtitle" = "Elige qué mostrar en la barra de menús (Ritmo muestra el uso frente al previsto)."; -"section_menu_content" = "Contenido del menú"; -"show_usage_as_used_title" = "Mostrar el uso como consumido"; -"show_usage_as_used_subtitle" = "Las barras de progreso se llenan a medida que consumes la cuota (en lugar de mostrar lo restante)."; "show_quota_warning_markers_title" = "Mostrar marcadores de aviso de cuota"; "show_quota_warning_markers_subtitle" = "Dibuja marcas de umbral en las barras de uso cuando hay avisos de cuota configurados."; -"show_reset_time_as_clock_title" = "Mostrar la hora de reinicio como reloj"; -"show_reset_time_as_clock_subtitle" = "Mostrar las horas de reinicio como valores de reloj absolutos en lugar de cuentas atrás."; +"weekly_progress_work_days_title" = "Días laborables del progreso semanal"; +"weekly_progress_work_days_subtitle" = "Define los días laborables para los marcadores de las barras de uso semanal y los cálculos de ritmo."; "show_provider_changelog_links_title" = "Mostrar enlaces al registro de cambios del proveedor"; "show_provider_changelog_links_subtitle" = "Añade al menú enlaces a las notas de versión de los proveedores compatibles basados en CLI."; "show_credits_extra_usage_title" = "Mostrar créditos + uso adicional"; "show_credits_extra_usage_subtitle" = "Mostrar las secciones de Créditos de Codex y Uso adicional de Claude en el menú."; -"show_all_token_accounts_title" = "Mostrar todas las cuentas con token"; -"show_all_token_accounts_subtitle" = "Apilar las cuentas con token en el menú (de lo contrario, mostrar una barra de cambio de cuenta)."; "multi_account_layout_title" = "Diseño multicuenta"; "multi_account_layout_subtitle" = "Elige cambio de cuenta segmentado o tarjetas de cuenta apiladas."; "multi_account_layout_segmented" = "Segmentado"; @@ -500,6 +592,16 @@ "overview_no_providers_hint" = "No hay proveedores activados disponibles para Resumen."; "overview_rows_follow_order" = "Las filas de Resumen siempre siguen el orden de los proveedores."; "overview_no_providers_selected" = "No hay proveedores seleccionados"; +"agent_sessions_title" = "Sesiones de agentes"; +"agent_sessions_subtitle" = "Muestra en el menú las sesiones de Codex y Claude Code locales y detectadas mediante SSH."; +"agent_sessions_hosts_title" = "Hosts SSH adicionales"; +"agent_sessions_footer" = "Los Mac de tu tailnet se detectan automáticamente. Las sesiones locales se actualizan cada 30 segundos; los hosts remotos, cada 60 segundos y al abrir el menú."; +"agent_session_labels_title" = "Etiquetas de sesión"; +"agent_session_labels_subtitle" = "Elige cómo se nombran las sesiones de agentes."; +"agent_session_label_project" = "Proyecto"; +"agent_session_label_descriptive" = "Descriptiva"; +"agent_session_label_descriptive_and_project" = "Descriptiva + proyecto"; +"agent_session_unknown_project" = "Proyecto desconocido"; /* Advanced Pane */ "section_keyboard_shortcut" = "Atajo de teclado"; @@ -511,10 +613,9 @@ "no_writable_bin_dirs" = "No se encontraron directorios bin con permiso de escritura."; "show_debug_settings_title" = "Mostrar ajustes de depuración"; "show_debug_settings_subtitle" = "Muestra herramientas de diagnóstico en la pestaña Depuración."; +"1.5× headroom" = "margen de 1,5×"; "surprise_me_title" = "Sorpréndeme"; "surprise_me_subtitle" = "Actívalo si te gusta que tus agentes se diviertan ahí arriba."; -"weekly_limit_confetti_title" = "Confeti del límite semanal"; -"weekly_limit_confetti_subtitle" = "Mostrar confeti a pantalla completa cuando se reinicia el uso semanal."; "hide_personal_info_title" = "Ocultar información personal"; "hide_personal_info_subtitle" = "Oculta las direcciones de correo en la barra de menús y la interfaz del menú."; "show_provider_storage_usage_title" = "Mostrar uso de almacenamiento del proveedor"; @@ -601,17 +702,24 @@ "metric_pref_tertiary" = "Terciario"; "metric_pref_extra_usage" = "Uso adicional"; "metric_pref_average" = "Promedio"; +"metric_mistral_payg" = "Pay-as-you-go"; +"metric_mistral_monthly_plan" = "Monthly Plan"; /* Display modes */ "display_mode_percent" = "Porcentaje"; "display_mode_pace" = "Ritmo"; "display_mode_both" = "Ambos"; +"display_mode_reset_time" = "Tiempo de restablecimiento"; "display_mode_percent_desc" = "Mostrar el porcentaje restante/usado (p. ej. 45 %)"; "display_mode_pace_desc" = "Mostrar el indicador de ritmo (p. ej. +5 %)"; "display_mode_both_desc" = "Mostrar porcentaje y ritmo (p. ej. 45 % · +5 %)"; +"display_mode_reset_time_desc" = "Mostrar el tiempo de restablecimiento de la métrica seleccionada (p. ej. ↻ 3:56 PM)"; +"menu_bar_reset_when_exhausted_title" = "Mostrar la hora de restablecimiento cuando se agote la cuota"; +"menu_bar_reset_when_exhausted_subtitle" = "Con un 0% restante, muestra el tiempo hasta el restablecimiento en lugar del porcentaje"; /* Provider status */ "status_operational" = "Operativo"; +"status_degraded" = "Rendimiento degradado"; "status_partial_outage" = "Interrupción parcial"; "status_major_outage" = "Interrupción grave"; "status_critical_issue" = "Problema crítico"; @@ -625,17 +733,26 @@ "refresh_5min" = "5 min"; "refresh_15min" = "15 min"; "refresh_30min" = "30 min"; +"refresh_adaptive" = "Adaptativo"; +"refresh_adaptive_agent_aware" = "Adaptativo (actividad de agentes)"; +"adaptive_activity_consent_title" = "¿Permitir la actualización según la actividad?"; +"adaptive_activity_consent_message" = "El modo Adaptativo según la actividad de agentes puede inspeccionar la lista de procesos locales en ejecución, incluidas las líneas de comandos, para identificar Codex y Claude, y leer los metadatos de sesiones conocidas cada 30 segundos mientras programas. Con Agent Sessions desactivado, CodexBar solo conserva en memoria la hora de la actividad más reciente y descarta las rutas e identidades de las sesiones. Estos datos no se envían a ningún sitio, y la detección remota y SSH permanecen desactivados. Si rechazas, CodexBar volverá al modo Adaptativo normal sin análisis de actividad local."; +"adaptive_activity_consent_allow" = "Permitir actividad local"; +"adaptive_activity_consent_decline" = "Usar Adaptativo normal"; /* Additional keys */ "not_found" = "No encontrado"; /* Cost estimation */ -"cost_header_estimated" = "Coste (estimado)"; "cost_estimate_hint" = "Estimado a partir de registros locales · puede diferir de tu factura"; +"codex_api_estimate_hint" = "Estimado a partir del uso de tokens · no es una factura de suscripción"; +"cost_data_explanation" = "Los costes pueden ser informados por el proveedor o estimados a partir del uso de tokens con precios públicos de la API. Las estimaciones no son cargos de suscripción."; /* Popup panels */ "No usage configured." = "No hay uso configurado."; "Quota" = "Cuota"; +"Daily quota" = "Cuota diaria"; +"Total" = "Total"; "tokens" = "tokens"; "requests" = "solicitudes"; "Latest" = "Último"; @@ -670,6 +787,7 @@ "API spend" = "Gasto de API"; "Extra usage" = "Uso adicional"; "Quota usage" = "Uso de cuota"; +"Your spend" = "Tu gasto"; "%.0f%% used" = "%.0f%% usado"; "Usage history (today)" = "Historial de uso (hoy)"; "Usage history (%d days)" = "Historial de uso (%d días)"; @@ -688,7 +806,7 @@ "Hourly Usage" = "Uso por hora"; "Usage remaining" = "Uso restante"; "Usage used" = "Uso utilizado"; -"API key verified. Ollama does not expose Cloud quota limits through the API." = "Clave de API verificada. Ollama no expone los límites de cuota de Cloud mediante la API."; +"API key verified. Cloud quotas need browser cookies. Sign in to Ollama." = "Clave de API verificada. Las cuotas de Cloud requieren cookies del navegador. Inicia sesión en Ollama."; "Last 30 days: %@ tokens" = "Últimos 30 días: %@ tokens"; "7d spend" = "Gasto 7 d"; "30d spend" = "Gasto 30 d"; @@ -708,6 +826,7 @@ "Today" = "Hoy"; "Today tokens" = "Tokens de hoy"; "30d cost" = "Coste 30 d"; +"%@ cost" = "Coste %@"; "30d tokens" = "Tokens 30 d"; "Latest tokens" = "Tokens recientes"; "Top model" = "Modelo principal"; @@ -761,7 +880,7 @@ "Antigravity login failed" = "Error al iniciar sesión en Antigravity"; "Antigravity login timed out" = "El inicio de sesión en Antigravity agotó el tiempo"; "Auth source" = "Fuente de autenticación"; -"Automatic imports Chrome browser cookies from Xiaomi MiMo." = "Importa automáticamente las cookies de Chrome desde Xiaomi MiMo."; +"Automatic imports browser cookies from Xiaomi MiMo." = "Importa automáticamente las cookies del navegador desde Xiaomi MiMo."; "Automatic imports Windsurf session data from Chromium browser localStorage." = "Importa automáticamente datos de sesión de Windsurf desde localStorage de Chromium."; "Automatic imports browser cookies from Bailian." = "Importa automáticamente cookies del navegador desde Bailian."; "Automatically imports browser cookies." = "Importa automáticamente cookies del navegador."; @@ -796,7 +915,6 @@ "CodexBar will ask macOS Keychain for your Cursor cookie header so it can fetch usage. Click OK to continue." = "CodexBar pedirá a Llaveros de macOS tu cabecera Cookie de Cursor para obtener el uso. Haz clic en OK para continuar."; "CodexBar will ask macOS Keychain for your Factory cookie header so it can fetch usage. Click OK to continue." = "CodexBar pedirá a Llaveros de macOS tu cabecera Cookie de Factory para obtener el uso. Haz clic en OK para continuar."; "CodexBar will ask macOS Keychain for your GitHub Copilot token so it can fetch usage. Click OK to continue." = "CodexBar pedirá a Llaveros de macOS tu token de GitHub Copilot para obtener el uso. Haz clic en OK para continuar."; -"CodexBar will ask macOS Keychain for your Kimi K2 API key so it can fetch usage. Click OK to continue." = "CodexBar pedirá a Llaveros de macOS tu clave API de Kimi K2 para obtener el uso. Haz clic en OK para continuar."; "CodexBar will ask macOS Keychain for your Kimi auth token so it can fetch usage. Click OK to continue." = "CodexBar pedirá a Llaveros de macOS tu token de autenticación de Kimi para obtener el uso. Haz clic en OK para continuar."; "CodexBar will ask macOS Keychain for your MiniMax API token so it can fetch usage. Click OK to continue." = "CodexBar pedirá a Llaveros de macOS tu token API de MiniMax para obtener el uso. Haz clic en OK para continuar."; "CodexBar will ask macOS Keychain for your MiniMax cookie header so it can fetch usage. Click OK to continue." = "CodexBar pedirá a Llaveros de macOS tu cabecera Cookie de MiniMax para obtener el uso. Haz clic en OK para continuar."; @@ -810,10 +928,15 @@ "Day" = "Día"; "Deployment" = "Despliegue"; "Drag to reorder" = "Arrastra para reordenar"; +"Sort providers alphabetically" = "Ordenar proveedores alfabéticamente"; +"Sort providers alphabetically (enabled first)" = "Ordenar proveedores alfabéticamente (activados primero)"; +"Sorted alphabetically (enabled first) — click to use your custom order" = "Ordenados alfabéticamente (activados primero) — haz clic para usar tu orden personalizado"; "Endpoint" = "Endpoint"; "Enterprise host" = "Host Enterprise"; "Extra usage balance: %@" = "Saldo de uso extra: %@"; "Keychain Access Required" = "Se requiere acceso a Llaveros"; +"keychain_prompt_learn_more" = "Más información…"; +"keychain_prompt_privacy_note" = "macOS, no CodexBar, gestiona la introducción de la contraseña de inicio de sesión del Mac. Puedes desactivar el acceso a Llaveros en cualquier momento en Ajustes → Avanzado."; "Kiro menu bar value" = "Valor de Kiro en la barra de menús"; "Label" = "Etiqueta"; "No organizations loaded. Click Refresh after setting your API key." = "No hay organizaciones cargadas. Haz clic en Actualizar después de configurar tu clave API."; @@ -840,6 +963,7 @@ "Optional. Leave blank to discover and aggregate projects visible to the API key." = "Opcional. Déjalo vacío para descubrir y agregar proyectos visibles para la clave API."; "Org ID (optional)" = "ID de org. (opcional)"; "Organizations" = "Organizaciones"; +"Organization ID" = "ID de organización"; "Password" = "Contraseña"; "%@ authentication is disabled." = "La autenticación de %@ está desactivada."; "%@ cookies are disabled." = "Las cookies de %@ están desactivadas."; @@ -861,6 +985,7 @@ "Personal account" = "Cuenta personal"; "Project ID" = "ID de proyecto"; "Re-auth" = "Reautenticar"; +"Re-login at claude.ai" = "Volver a iniciar sesión en claude.ai"; "Re-authenticating…" = "Reautenticando…"; "Refresh Session" = "Actualizar sesión"; "Refresh organizations" = "Actualizar organizaciones"; @@ -892,6 +1017,7 @@ "Stored in ~/.codexbar/config.json. You can also provide CROF_API_KEY." = "Guardado en ~/.codexbar/config.json. También puedes proporcionar CROF_API_KEY."; "Stored in ~/.codexbar/config.json. You can also provide KILO_API_KEY or ~/.local/share/kilo/auth.json (kilo.access)." = "Guardado en ~/.codexbar/config.json. También puedes proporcionar KILO_API_KEY o ~/.local/share/kilo/auth.json (kilo.access)."; "T3 Chat cookie" = "Cookie de T3 Chat"; +"Team mode" = "Modo de equipo"; "That account is no longer available in CodexBar. Refresh the account list and try again." = "Esa cuenta ya no está disponible en CodexBar. Actualiza la lista de cuentas e inténtalo de nuevo."; "The browser login did not complete in time. Try Antigravity login again." = "El inicio de sesión del navegador no terminó a tiempo. Intenta iniciar sesión en Antigravity de nuevo."; "Timed out waiting for Cursor login. %@" = "Se agotó el tiempo esperando el inicio de sesión de Cursor. %@"; @@ -913,3 +1039,314 @@ "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"; + +"language_vietnamese" = "Vietnamita"; +"language_turkish" = "Türkçe"; +"language_indonesian" = "Indonesio"; +"language_arabic" = "العربية"; +"language_persian" = "فارسی"; +"language_thai" = "ไทย"; +"language_galician" = "Galego"; +"Limit Reset Credits" = "Créditos para restablecer límites"; +"1 available" = "1 disponible"; +"%d available" = "%d disponibles"; +"Next expires %@" = "El siguiente caduca %@"; +"Expires %@" = "Caduca %@"; +"No expiry" = "Sin caducidad"; +"Other (%d items)" = "Otros (%d elementos)"; +"Expand" = "Expandir"; +"Collapse" = "Contraer"; +"byte_unit_byte" = "byte"; +"byte_unit_bytes" = "bytes"; +"byte_unit_kilobyte" = "kilobyte"; +"byte_unit_kilobytes" = "kilobytes"; +"byte_unit_megabyte" = "megabyte"; +"byte_unit_megabytes" = "megabytes"; +"byte_unit_gigabyte" = "gigabyte"; +"byte_unit_gigabytes" = "gigabytes"; +"Open Status Page" = "Abrir página de estado"; + +/* Settings sidebar redesign */ +"Enable" = "Activar"; +"Disable" = "Desactivar"; +"providers_on_count" = "%d activados"; +"section_cost_summary" = "Resumen de costos"; +"section_command_line" = "Línea de comandos"; +"section_privacy" = "Privacidad"; +"section_diagnostics" = "Diagnósticos"; +"section_updates" = "Actualizaciones"; +"section_links" = "Enlaces"; +"Show Codex Spark usage" = "Mostrar el uso de Codex Spark"; +"Shows Codex Spark quota rows in the menu and provider preview. Requires optional credits and extra usage in Display settings." = "Muestra las filas de cuota de Codex Spark en el menú y en la vista previa del proveedor. Requiere activar «Mostrar créditos + uso adicional» en los ajustes de Pantalla."; +"Show Daily Routines usage" = "Mostrar el uso de Rutinas diarias"; +"Shows the Daily Routines quota row in the menu and provider preview. Requires optional credits and extra usage in Display settings." = "Muestra la fila de cuota de Rutinas diarias en el menú y en la vista previa del proveedor. Requiere activar «Mostrar créditos + uso adicional» en los ajustes de Pantalla."; +"Scroll to see more models" = "Desplázate para ver más modelos"; +"Copy Image" = "Copiar imagen"; +"Copy Stats" = "Copiar estadísticas"; +"Could not copy image" = "No se pudo copiar la imagen"; +"Image copied" = "Imagen copiada"; +"Image saved" = "Imagen guardada"; +"Nothing is uploaded. This image is created on your Mac." = "No se sube nada. Esta imagen se crea en tu Mac."; +"Save..." = "Guardar..."; +"Share AI Usage" = "Compartir uso de IA"; +"Share Stats…" = "Compartir estadísticas…"; +"Stats copied" = "Estadísticas copiadas"; +"DeepSeek this month token usage trend" = "Tendencia de uso de tokens de DeepSeek este mes"; +"Chrome profile" = "Perfil de Chrome"; +"Choose which signed-in DeepSeek Platform session supplies detailed usage." = "Elige qué sesión iniciada de DeepSeek Platform proporciona el uso detallado."; +"Detailed usage unavailable." = "El uso detallado no está disponible."; +"Sign in to DeepSeek Platform in Chrome for detailed usage." = "Inicia sesión en DeepSeek Platform en Chrome para ver el uso detallado."; +"Select a DeepSeek Chrome profile in Settings." = "Selecciona un perfil de Chrome de DeepSeek en Ajustes."; +"Select profile…" = "Seleccionar perfil…"; + +"%@ · %@" = "%@ · %@"; +"%@ is unavailable in the current environment." = "%@ no está disponible en el entorno actual."; +"%@ left" = "Queda %@"; +"%@: %@" = "%@: %@"; +"%@: %@ · %@ tokens" = "%@: %@ · %@ tokens"; +"%@: %@%% used" = "%@: %@%% usado"; +"%d more items" = "%d elementos más"; +"%d unreadable item(s) skipped" = "%d elementos ilegibles omitidos"; +"%d%% in deficit" = "%d%% de déficit"; +"%d%% in reserve" = "%d%% de reserva"; +"%dd" = "%d d"; +"≈ %d%% run-out risk" = "≈ %d%% de riesgo de agotamiento"; +"About CodexBar" = "Acerca de CodexBar"; +"Add Account..." = "Añadir cuenta..."; +"Add accounts via GitHub OAuth Device Flow on the selected host." = "Añade cuentas mediante el flujo de dispositivo OAuth de GitHub en el host seleccionado."; +"Add Google Account" = "Añadir cuenta de Google"; +"Admin API key" = "Clave de API de administrador"; +"All Systems Operational" = "Todos los sistemas funcionan correctamente"; +"Alternatively, set a custom path in Settings." = "También puedes establecer una ruta personalizada en Ajustes."; +"Auto" = "Automático"; +"Auto uses the local IDE API first, then Google OAuth when the IDE is closed." = "El modo automático usa primero la API del IDE local y después Google OAuth cuando el IDE está cerrado."; +"Choose a supported browser so CodexBar can read the matching account." = "Elige un navegador compatible para que CodexBar pueda leer la cuenta correspondiente."; +"Choose Cursor account" = "Elegir cuenta de Cursor"; +"Choose which Cursor account CodexBar should use." = "Elige qué cuenta de Cursor debe usar CodexBar."; +"Cleanup ideas" = "Sugerencias de limpieza"; +"Clearing removes archived Codex session history." = "Al limpiar se elimina el historial de sesiones archivadas de Codex."; +"Clearing removes cached large pastes or attached images." = "Al limpiar se eliminan los textos extensos pegados y las imágenes adjuntas almacenados en caché."; +"Clearing removes checkpoint restore data for previous edits." = "Al limpiar se eliminan los datos de restauración de puntos de control de ediciones anteriores."; +"Clearing removes leftover runtime shell snapshot files." = "Al limpiar se eliminan los archivos restantes de instantáneas del shell de ejecución."; +"Clearing removes legacy per-session task lists." = "Al limpiar se eliminan las listas de tareas heredadas de cada sesión."; +"Clearing removes local diagnostic logs." = "Al limpiar se eliminan los registros de diagnóstico locales."; +"Clearing removes local edit checkpoint history." = "Al limpiar se elimina el historial local de puntos de control de edición."; +"Clearing removes local temporary provider data." = "Al limpiar se eliminan los datos temporales locales del proveedor."; +"Clearing removes old plan-mode files." = "Al limpiar se eliminan los archivos antiguos del modo de planificación."; +"Clearing removes past Codex session history." = "Al limpiar se elimina el historial de sesiones anteriores de Codex."; +"Clearing removes past debug logs." = "Al limpiar se eliminan los registros de depuración anteriores."; +"Clearing removes past resume, continue, and rewind history." = "Al limpiar se elimina el historial anterior de reanudación, continuación y retroceso."; +"Clearing removes per-session environment metadata." = "Al limpiar se eliminan los metadatos de entorno de cada sesión."; +"Clearing removes provider-owned cached data." = "Al limpiar se eliminan los datos en caché administrados por el proveedor."; +"Credits unavailable; keep Codex running to refresh." = "Créditos no disponibles; mantén Codex en ejecución para actualizarlos."; +"Daily" = "Diario"; +"Disabled — no recent data" = "Desactivado — sin datos recientes"; +"Est. total (%@): %@" = "Total estimado (%@): %@"; +"Est. total (30d): %@" = "Total estimado (30 d): %@"; +"Estimated from local Codex logs for the selected account." = "Estimado a partir de los registros locales de Codex para la cuenta seleccionada."; +"Finish switching to a different Cursor account in your browser, then try again." = "Termina de cambiar a otra cuenta de Cursor en el navegador y vuelve a intentarlo."; +"Google accounts" = "Cuentas de Google"; +"Google OAuth" = "OAuth de Google"; +"Hourly Tokens" = "Tokens por hora"; +"Hover a bar for details" = "Pasa el puntero sobre una barra para ver los detalles"; +"Image Generation" = "Generación de imágenes"; +"Install a JetBrains IDE with AI Assistant enabled, then refresh CodexBar." = "Instala un IDE de JetBrains con AI Assistant activado y después actualiza CodexBar."; +"just now" = "ahora mismo"; +"Last %d day" = "Último %d día"; +"Last 30 days" = "Últimos 30 días"; +"Last 30 days:" = "Últimos 30 días:"; +"Last 30 days: %@" = "Últimos 30 días: %@"; +"Last 30 days: %@ · %@ tokens" = "Últimos 30 días: %@ · %@ tokens"; +"Lasts until reset" = "Dura hasta el restablecimiento"; +"Login with Google" = "Iniciar sesión con Google"; +"login_success_notification_body" = "Puedes volver a la app; la autenticación ha finalizado."; +"login_success_notification_title" = "Inicio de sesión en %@ correcto"; +"Manual cleanup: archived sessions" = "Limpieza manual: sesiones archivadas"; +"Manual cleanup: attachment cache" = "Limpieza manual: caché de archivos adjuntos"; +"Manual cleanup: cache" = "Limpieza manual: caché"; +"Manual cleanup: debug logs" = "Limpieza manual: registros de depuración"; +"Manual cleanup: file checkpoints" = "Limpieza manual: puntos de control de archivos"; +"Manual cleanup: file history" = "Limpieza manual: historial de archivos"; +"Manual cleanup: legacy todos" = "Limpieza manual: tareas heredadas"; +"Manual cleanup: logs" = "Limpieza manual: registros"; +"Manual cleanup: past sessions" = "Limpieza manual: sesiones anteriores"; +"Manual cleanup: saved plans" = "Limpieza manual: planes guardados"; +"Manual cleanup: session metadata" = "Limpieza manual: metadatos de sesión"; +"Manual cleanup: sessions" = "Limpieza manual: sesiones"; +"Manual cleanup: shell snapshots" = "Limpieza manual: instantáneas del shell"; +"Manual cleanup: temporary data" = "Limpieza manual: datos temporales"; +"minimax_service_coding_plan_search" = "Búsqueda del plan de programación"; +"minimax_service_coding_plan_vlm" = "VLM del plan de programación"; +"minimax_service_image_generation" = "Generación de imágenes"; +"minimax_service_lyrics_generation" = "Generación de letras"; +"minimax_service_music_generation" = "Generación de música"; +"minimax_service_text_generation" = "Generación de texto"; +"minimax_service_text_to_speech" = "Texto a voz"; +"minimax_usage_amount_format" = "Uso: %@ / %@"; +"minimax_used_percent_format" = "Usado: %@"; +"Missing DeepSeek API key." = "Falta la clave de API de DeepSeek."; +"Music Generation" = "Generación de música"; +"No %@ utilization data yet." = "Aún no hay datos de utilización de %@."; +"No available fetch strategy for %@." = "No hay ninguna estrategia de obtención disponible para %@."; +"No available fetch strategy for minimax." = "No hay ninguna estrategia de obtención disponible para MiniMax."; +"No Cursor session found. Please log in to cursor.com in Safari, Chrome, Microsoft Edge, Brave, Arc, Dia, ChatGPT Atlas, Chromium, Helium, Vivaldi, Yandex Browser, Firefox, Zen, Colibri, Sidekick, Opera, Opera GX, or Edge Canary. If you use Safari, grant CodexBar Full Disk Access in System Settings ▸ Privacy & Security. You can also sign in to Cursor from the CodexBar menu (Add / switch account)." = "No se encontró ninguna sesión de Cursor. Inicia sesión en cursor.com desde Safari, Chrome, Microsoft Edge, Brave, Arc, Dia, ChatGPT Atlas, Chromium, Helium, Vivaldi, Yandex Browser, Firefox, Zen, Colibri, Sidekick, Opera, Opera GX o Edge Canary. Si usas Safari, concede a CodexBar acceso total al disco en Ajustes del Sistema ▸ Privacidad y seguridad. También puedes iniciar sesión en Cursor desde el menú de CodexBar (Añadir/cambiar cuenta)."; +"No JetBrains IDE with AI Assistant detected. Install a JetBrains IDE and enable AI Assistant." = "No se detectó ningún IDE de JetBrains con AI Assistant. Instala un IDE de JetBrains y activa AI Assistant."; +"No local data found" = "No se encontraron datos locales"; +"No OpenCode session cookies found in browsers." = "No se encontraron cookies de sesión de OpenCode en los navegadores."; +"No overview data available." = "No hay datos de resumen disponibles."; +"No providers selected for Overview." = "No hay proveedores seleccionados para Resumen."; +"No usage breakdown data available." = "No hay datos de desglose de uso disponibles."; +"No utilization data yet." = "Aún no hay datos de utilización."; +"not detected" = "no detectado"; +"On pace" = "Al ritmo previsto"; +"Open billing" = "Abrir facturación"; +"Open Token Plan" = "Abrir plan de tokens"; +"OpenRouter API token not configured. Set OPENROUTER_API_KEY environment variable or configure in Settings." = "El token de API de OpenRouter no está configurado. Define la variable de entorno OPENROUTER_API_KEY o configúralo en Ajustes."; +"Pace: %@" = "Ritmo: %@"; +"Pace: %@ · %@" = "Ritmo: %@ · %@"; +"Projected empty in %@" = "Agotamiento previsto en %@"; +"Projected empty now" = "Agotamiento previsto ahora"; +"Quit" = "Salir"; +"quota_warning_notification_body" = "Queda %1$@. Se alcanzó el umbral de aviso del %2$d%% para %3$@."; +"quota_warning_notification_body_with_account" = "Cuenta %1$@. Queda %2$@. Se alcanzó el umbral de aviso del %3$d%% para %4$@."; +"quota_warning_notification_title" = "%1$@: cuota de %2$@ baja"; +"Refreshing" = "Actualizando"; +"Request quota: %@ / %@" = "Cuota de solicitudes: %@ / %@"; +"Resets %@" = "Se restablece %@"; +"Resets in %@" = "Se restablece en %@"; +"Resets now" = "Se restablece ahora"; +"Runs out in %@" = "Se agota en %@"; +"Runs out now" = "Se agota ahora"; +"Session" = "Sesión"; +"session_depleted_notification_body" = "Queda un 0%. Se te avisará cuando vuelva a estar disponible."; +"session_depleted_notification_title" = "Cuota de sesión de %@ agotada"; +"session_restored_notification_body" = "La cuota de sesión vuelve a estar disponible."; +"session_restored_notification_title" = "Cuota de sesión de %@ restablecida"; +"Settings..." = "Ajustes..."; +"Sign in with Claude Code..." = "Iniciar sesión con Claude Code..."; +"Source" = "Fuente"; +"State" = "Estado"; +"Status Page" = "Página de estado"; +"Store multiple Antigravity Google OAuth accounts for quick switching." = "Guarda varias cuentas OAuth de Google de Antigravity para cambiar rápidamente entre ellas."; +"Store multiple DeepSeek API keys." = "Guarda varias claves de API de DeepSeek."; +"Store multiple OpenAI API keys." = "Guarda varias claves de API de OpenAI."; +"Stores each signed-in Google account for quick Antigravity switching. Uses Antigravity.app OAuth when available, or ANTIGRAVITY_OAUTH_CLIENT_ID and ANTIGRAVITY_OAUTH_CLIENT_SECRET as an override." = "Guarda cada cuenta de Google con sesión iniciada para cambiar rápidamente en Antigravity. Usa OAuth de Antigravity.app cuando está disponible, o ANTIGRAVITY_OAUTH_CLIENT_ID y ANTIGRAVITY_OAUTH_CLIENT_SECRET como valores alternativos."; +"Switch Account..." = "Cambiar cuenta..."; +"Text Generation" = "Generación de texto"; +"Text to Speech" = "Texto a voz"; +"Timed out waiting for Cursor account switch. %@" = "Se agotó el tiempo de espera para cambiar de cuenta de Cursor. %@"; +"Timed out waiting for Cursor account switch. %@ Last error: %@" = "Se agotó el tiempo de espera para cambiar de cuenta de Cursor. %@ Último error: %@"; +"today" = "hoy"; +"Total: %@" = "Total: %@"; +"Unavailable" = "No disponible"; +"Update ready, restart now?" = "La actualización está lista. ¿Reiniciar ahora?"; +"Updated %@h ago" = "Actualizado hace %@ h"; +"Updated %@m ago" = "Actualizado hace %@ min"; +"Updated just now" = "Actualizado ahora mismo"; +"Usage Dashboard" = "Panel de uso"; +"usage_percent_suffix_left" = "restante"; +"usage_percent_suffix_used" = "usado"; +"Use Account" = "Usar cuenta"; +"Weekly" = "Semanal"; +"z.ai API token not found. Set apiKey in ~/.codexbar/config.json or Z_AI_API_KEY." = "No se encontró el token de API de z.ai. Define apiKey en ~/.codexbar/config.json o Z_AI_API_KEY."; +/* Spend dashboard */ +"tab_usage_spend" = "Uso y gasto"; +"Usage & Spend" = "Uso y gasto"; +"Local estimated cost history across supported providers." = "Historial local de costes estimados de proveedores compatibles."; +"Time range" = "Intervalo de tiempo"; +"Track costs" = "Registrar costes"; +"Cost tracking is off" = "El seguimiento de costes está desactivado"; +"Turn on Track costs to build local estimates." = "Activa «Registrar costes» para crear estimaciones locales."; +"No local cost history yet" = "Aún no hay historial local de costes"; +"Turn on cost tracking or refresh after using a supported provider." = "Activa el seguimiento local de costes o actualiza después de usar un proveedor compatible."; +"Refresh failures" = "Actualizaciones fallidas"; +"Native currencies stay separate; Codex account rows exclude Pi session history." = "Las divisas originales se mantienen separadas; las filas de cuentas de Codex excluyen el historial de sesiones de Pi."; +"Spend unavailable" = "Gasto no disponible"; +"Model breakdown unavailable" = "Desglose por modelo no disponible"; +"Local estimated history" = "Historial local estimado"; +"Coverage" = "Cobertura"; +"Estimated spend" = "Gasto estimado"; +"Tracked tokens" = "Tokens registrados"; +"Subscriptions" = "Suscripciones"; +"By subscription" = "Por suscripción"; +"No model-level history" = "No hay historial por modelo"; +"Daily estimated spend" = "Gasto diario estimado"; +"≈%d full 5h windows of weekly left · %d windows until reset" = "≈%d ventanas completas de 5 h de cuota semanal · %d ventanas hasta el reinicio"; +"Weekly cannot run out before reset at this pace" = "La cuota semanal no puede agotarse antes del reinicio a este ritmo"; +"Weekly can run out ≈%d windows early" = "La cuota semanal puede agotarse ≈%d ventanas antes"; +"Estimated: %@" = "Estimación: %@"; +"session_quota_estimate_value_format" = "%1$@ %2$@"; +"session quota" = "cuota de sesión"; +"session quotas" = "cuotas de sesión"; +"Coding Plan" = "Plan de programación"; +"Agent Plan" = "Plan de agente"; +"Team" = "Equipo"; + +/* Menu bar layout editor */ +"menu_bar_layout_title" = "Disposición"; +"menu_bar_layout_footer" = "Arrastra fichas para ordenar la barra de menús. Haz clic en una ficha para añadirla; selecciona una ficha colocada y pulsa Suprimir para quitarla."; +"menu_bar_layout_group_identity" = "Identidad"; +"menu_bar_layout_group_usage" = "Uso"; +"menu_bar_layout_group_time" = "Tiempo"; +"menu_bar_layout_group_money" = "Coste"; +"menu_bar_layout_group_structure" = "Estructura"; +"menu_bar_layout_scope_all" = "Todos los proveedores"; +"menu_bar_layout_scope_help" = "Edita la disposición predeterminada o reemplázala para un proveedor."; +"menu_bar_layout_use_all" = "Usar la disposición de todos los proveedores"; +"menu_bar_layout_preset" = "Preajuste de disposición"; +"menu_bar_layout_preset_icon_percent" = "Icono y porcentaje"; +"menu_bar_layout_preset_icon_only" = "Solo icono"; +"menu_bar_layout_preset_percent_reset" = "Porcentaje y reinicio"; +"menu_bar_layout_preset_compact_stacked" = "Apilado compacto"; +"menu_bar_layout_preset_custom" = "Personalizado"; +"menu_bar_layout_live_preview" = "Vista previa en directo"; +"menu_bar_layout_strip" = "Franja de la barra de menús"; +"menu_bar_layout_remove_line_break" = "Quitar salto de línea"; +"menu_bar_layout_chip_hint" = "Selecciona, arrastra para reordenar o usa la acción Quitar."; +"menu_bar_layout_palette_hint" = "Haz clic para añadir o arrastra a la disposición."; +"menu_bar_layout_empty_line" = "Suelta una ficha aquí"; +"menu_bar_layout_line" = "Línea %d"; +"menu_bar_layout_drag_remove" = "Arrastra aquí para quitar"; +"menu_bar_layout_size" = "Tamaño"; +"menu_bar_layout_size_small" = "Pequeño"; +"menu_bar_layout_size_regular" = "Normal"; +"menu_bar_layout_gap" = "Separación"; +"menu_bar_layout_gap_tight" = "Estrecha"; +"menu_bar_layout_gap_regular" = "Normal"; +"menu_bar_layout_keyboard_hint" = "Suprimir quita la ficha seleccionada"; +"menu_bar_layout_sample_account" = "cuenta"; +"menu_bar_layout_sample_runs_out" = "se agota vie."; +"menu_bar_layout_token_icon" = "Icono"; +"menu_bar_layout_token_provider" = "Nombre del proveedor"; +"menu_bar_layout_token_account" = "Cuenta"; +"menu_bar_layout_token_session" = "Sesión %"; +"menu_bar_layout_token_weekly" = "Semanal %"; +"menu_bar_layout_token_auto" = "% automático"; +"menu_bar_layout_token_bar" = "Barra de uso"; +"menu_bar_layout_token_resets_in" = "Se reinicia en"; +"menu_bar_layout_token_reset_at" = "Reinicio a las"; +"menu_bar_layout_token_runs_out" = "Se agota"; +"menu_bar_layout_token_cost_today" = "Coste de hoy"; +"menu_bar_layout_token_cost_30d" = "Coste de 30 días"; +"menu_bar_layout_token_space" = "Espacio"; +"menu_bar_layout_token_line_break" = "Salto de línea"; +"menu_bar_layout_token_separator_accessibility" = "Punto separador"; + +/* Menu bar layout accessibility */ +"Icon unavailable" = "Icono: No disponible"; +"%@ icon" = "%@: Icono"; +"Provider name unavailable" = "Nombre del proveedor: No disponible"; +"Account unavailable" = "Cuenta: No disponible"; +"%@ unavailable" = "%@: No disponible"; +"%@ %@" = "%@ %@"; +"Usage bar unavailable" = "Barra de uso: No disponible"; +"Usage bar, %d of 3 filled" = "Barra de uso: %d/3 llenos"; +"Reset countdown unavailable" = "Se reinicia en: No disponible"; +"Reset time unavailable" = "Reinicio a las: No disponible"; +"Run-out estimate unavailable" = "Se agota: No disponible"; +"Cost today unavailable" = "Coste de hoy: No disponible"; +"30-day cost unavailable" = "Coste de 30 días: No disponible"; +"Resets" = "Reinicios"; diff --git a/Sources/CodexBar/Resources/es.lproj/Localizable.stringsdict b/Sources/CodexBar/Resources/es.lproj/Localizable.stringsdict new file mode 100644 index 0000000000..03921fc07e --- /dev/null +++ b/Sources/CodexBar/Resources/es.lproj/Localizable.stringsdict @@ -0,0 +1,49 @@ + + + + + ≈%d full 5h windows of weekly left · %d windows until reset + + NSStringLocalizedFormatKey + %#@left@ · %#@until@ + left + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + d + one + ≈%d ventana completa de 5 h de cuota semanal + other + ≈%d ventanas completas de 5 h de cuota semanal + + until + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + d + one + %d ventana hasta el reinicio + other + %d ventanas hasta el reinicio + + + Weekly can run out ≈%d windows early + + NSStringLocalizedFormatKey + %#@early@ + early + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + d + one + La cuota semanal puede agotarse ≈%d ventana antes + other + La cuota semanal puede agotarse ≈%d ventanas antes + + + + diff --git a/Sources/CodexBar/Resources/fa.lproj/Localizable.strings b/Sources/CodexBar/Resources/fa.lproj/Localizable.strings new file mode 100644 index 0000000000..7be0cf39c9 --- /dev/null +++ b/Sources/CodexBar/Resources/fa.lproj/Localizable.strings @@ -0,0 +1,1357 @@ +/* Persian localization for CodexBar */ + +"ollama_safari_cookie_access_hint" = "کوکی‌های Safari برای CodexBar به دسترسی کامل دیسک نیاز دارند (تنظیمات سیستم > حریم خصوصی و امنیت)."; +"ollama_browser_cookie_decryption_denied" = "رمزگشایی کوکی‌های %@ در Keychain رد شد؛ با یک تازه‌سازی دستی دوباره تلاش کنید."; +"ollama_browser_cookie_decryption_disabled" = "رمزگشایی کوکی‌های %@ در CodexBar غیرفعال است؛ دسترسی Keychain را فعال و تازه‌سازی کنید."; + +" providers" = " providers"; +"(System)" = "(سیستم)"; +"30d" = "30 روز"; +"7d" = "7 روز"; +"A managed Codex login is already running. Wait for it to finish before adding " = "یک ورود Codex مدیریت شده هم اکنون در حال اجرا است. صبر کنید تا تمام شود و بعد را اضافه کنید"; +"API key" = "کلید API"; +"API region" = "منطقه API"; +"API token" = "API توکن"; +"API tokens" = "توکن های API"; +"About" = "درباره"; +"Account" = "حساب"; +"Accounts" = "گزارش ها"; +"Accounts subtitle" = "زیرنویس حساب ها"; +"Active" = "فعال"; +"Add" = "افزودن"; +"Add Workspace" = "افزودن فضای کاری"; +"Advanced" = "پیشرفته"; +"All" = "همه"; +"Always allow prompts" = "همیشه اجازه دهید پرامپت ها"; +"Animation pattern" = "الگوی انیمیشن"; +"Antigravity login is managed in the app" = "ورود Antigravity در اپلیکیشن مدیریت می شود"; +"Applies only to the Security.framework OAuth keychain reader." = "فقط برای Security.framework OAuth خواننده جاکلیدی کاربرد دارد."; +"Alternatively, set a custom path in Settings." = "یا در تنظیمات یک مسیر سفارشی تعیین کنید."; +"Auto falls back to the next source if the preferred one fails." = "اگر منبع ترجیحی خراب شود، خودکار به منبع بعدی برمی گردد."; +"Auto uses API first, then falls back to CLI on auth failures." = "خودکار اول از API استفاده می کند، سپس در صورت خطاهای احراز هویت به CLI برمی گردد."; +"Auto-detect" = "تشخیص خودکار"; +"Auto-refresh is off; use the menu's Refresh command." = "تازه سازی خودکار خاموش است؛ از فرمان تازه سازی منو استفاده کنید."; +"Auto-refresh: hourly · Timeout: 10m" = "بازخوانی خودکار: ساعتی · مهلت: 10m"; +"Automatic" = "اتوماتیک"; +"Automatic imports browser cookies and WorkOS tokens." = "به طور خودکار کوکی های مرورگر و توکن های WorkOS را وارد می کند."; +"Automatic imports browser cookies and local storage tokens." = "به طور خودکار کوکی های مرورگر و توکن های ذخیره سازی محلی را وارد می کند."; +"Automatic imports browser cookies for dashboard extras." = "کوکی های مرورگر را به طور خودکار برای اضافه کردن داشبورد وارد می کند."; +"Automatic imports browser cookies for the web API." = "کوکی های مرورگر را به صورت خودکار برای API وب وارد می کند."; +"Automatic imports browser cookies from Model Studio/Bailian." = "کوکی های مرورگر را به صورت خودکار از Model Studio/Bailian. وارد می کند"; +"Automatic imports browser cookies from admin.mistral.ai." = "کوکی های مرورگر را به طور خودکار از admin.mistral.ai وارد می کند."; +"Automatic imports browser cookies from opencode.ai." = "کوکی های مرورگر را به طور خودکار از opencode.ai وارد می کند."; +"Automatic imports browser cookies or stored sessions." = "به طور خودکار کوکی های مرورگر یا جلسات ذخیره شده را وارد می کند."; +"Automatic imports browser cookies." = "کوکی های مرورگر را به صورت خودکار وارد می کند."; +"Automatically imports browser session cookie." = "به طور خودکار کوکی نشست مرورگر را وارد می کند."; +"Automatically opens CodexBar when you start your Mac." = "وقتی مک را روشن می کنید، CodexBar به طور خودکار باز می شود."; +"Automation" = "اتوماسیون"; +"Average (\\(label1) + \\(label2))" = "میانگین (\\(label1) + \\(label2))"; +"Average (\\(metadata.sessionLabel) + \\(metadata.weeklyLabel))" = "میانگین (\\(metadata.sessionLabel) + \\(metadata.weeklyLabel))"; +"Avoid Keychain prompts" = "از درخواست های Keychain اجتناب کنید"; +"Balance" = "تعادل"; +"Battery Saver" = "باتری سیور"; +"Bordered" = "مرز"; +"Build" = "ساخت"; +"Built \\(buildTimestamp)" = "ساخته شده \\(buildTimestamp)"; +"Buy Credits..." = "خرید اعتبار..."; +"Buy Credits…" = "خرید اعتبار..."; +"CLI paths" = "مسیرهای CLI"; +"CLI sessions" = "جلسات CLI"; +"Caches" = "کش ها"; +"Cancel" = "لغو"; +"Check for Updates…" = "به روزرسانی ها را بررسی کنید..."; +"Check for updates automatically" = "به طور خودکار به روزرسانی ها را بررسی کنید"; +"Check if you like your agents having some fun up there." = "بررسی کن که آیا دوست داری مأمورانت آنجا خوش بگذرانند یا نه."; +"Check provider status" = "وضعیت ارائه دهنده را بررسی کنید"; +"Choose a supported browser so CodexBar can read the matching account." = "یک مرورگر پشتیبانی‌شده انتخاب کنید تا CodexBar بتواند حساب منطبق را بخواند."; +"Choose Codex workspace" = "فضای کاری Codex را انتخاب کنید"; +"Choose Cursor account" = "حساب Cursor را انتخاب کنید"; +"Choose the MiniMax host (global .io or China mainland .com)." = "میزبان MiniMax را انتخاب کنید (جهانی .io یا .com سرزمین اصلی چین)."; +"Choose up to " = "تا انتخاب کنید"; +"Choose up to \\(Self.maxOverviewProviders) providers" = "تا \\(Self.maxOverviewProviders) ارائه دهنده را انتخاب کنید"; +"Choose up to \\(count) providers" = "تا \\(count) ارائه دهنده را انتخاب کنید"; +"Choose what to show in the menu bar (Pace shows usage vs. expected)." = "انتخاب کنید که در نوار منو چه چیزی نمایش داده شود (Pace میزان مصرف در مقابل مورد انتظار را نشان می دهد)."; +"Choose which Codex account CodexBar should follow." = "انتخاب کنید که کدام حساب Codex را دنبال CodexBar."; +"Choose which Cursor account CodexBar should use." = "انتخاب کنید CodexBar از کدام حساب Cursor استفاده کند."; +"Choose which window drives the menu bar percent." = "انتخاب کنید کدام پنجره درصد نوار منو را تنظیم کند."; +"Chrome" = "Chrome"; +"Claude CLI not found" = "Claude CLI پیدا نشد"; +"Claude binary" = "Claude دودویی"; +"Claude cookies" = "Claude کوکی"; +"Claude login failed" = "ورود Claude ناموفق"; +"Claude login timed out" = "ورود Claude به پایان رسید"; +"Close" = "بسته شدن"; +"Code review" = "بررسی کد"; +"Codex CLI not found" = "Codex CLI پیدا نشد"; +"Codex account login already running" = "ورود Codex حساب کاربری در حال اجرا است"; +"Codex binary" = "Codex دودویی"; +"Codex login failed" = "ورود Codex ناموفق"; +"Codex login timed out" = "ورود Codex به پایان رسید"; +"CodexBar Lifecycle Keepalive" = "CodexBar چرخه زندگی زنده نگه داشتن"; +"CodexBar can't show its menu bar icon" = "CodexBar نمی تواند آیکون نوار منویش را نشان دهد"; +"CodexBar could not read managed account storage. " = "CodexBar نمی توانست ذخیره سازی حساب مدیریت شده را بخواند. "; +"Configure…" = "پیکربندی کن..."; +"Connected" = "متصل"; +"Controls how much detail is logged." = "میزان جزئیات ثبت شده را کنترل می کند."; +"Cookie header" = "هدر کوکی"; +"Cookie source" = "منبع کوکی"; +"Cookie: ..." = "کوکی: ..."; +"Cookie: \\u{2026}\\\n\\\nor paste a cURL capture from the Abacus AI dashboard" = "کوکی: \\u{2026}\\\n\\\n یا یک cURL capture از داشبورد Abacus AI پیست کنید"; +"Cookie: \\u{2026}\\\n\\\nor paste the __Secure-next-auth.session-token value" = "کوکی: \\u{2026}\\\n\\\nیا مقدار توکن __Secure-next-auth.session-token را بچسبانید"; +"Cookie: \\u{2026}\\\n\\\nor paste the kimi-auth token value" = "کوکی: \\u{2026}\\\n\\\nیا مقدار توکن kimi-authentic را بچسبانید"; +"Cookie: …" = "کوکی: ..."; +"CopilotDeviceFlow" = "CopilotDeviceFlow"; +"Cost" = "هزینه"; +"Could not add Codex account" = "نتوانستم حساب Codex اضافه کنم"; +"Could not open Terminal for Gemini" = "نتوانستم ترمینال را برای Gemini باز کنم"; +"Could not start claude /login" = "نتوانستم کلود را شروع /login"; +"Could not start codex login" = "ورود به کدکس فعال نشد"; +"Could not switch system account" = "نتوانستم حساب سیستم را تغییر دهم"; +"Credits" = "اعتبارات"; +"Individual credits" = "اعتبارات فردی"; +"Workspace" = "فضای کاری"; +"Credits history" = "تاریخچه اعتبارها"; +"Cursor login failed" = "ورود Cursor ناموفق"; +"Custom" = "عرف"; +"Custom Path" = "مسیر سفارشی"; +"Daily Routines" = "روال روزانه"; +"Debug" = "اشکال زدایی"; +"Default" = "پیش فرض"; +"Disable Keychain access" = "غیرفعال کردن دسترسی Keychain"; +"Disabled" = "معلول"; +"Dismiss" = "اخراج"; +"Disconnected" = "قطع ارتباط"; +"Display" = "نمایش"; +"Display mode" = "حالت نمایش"; +"Display reset times as absolute clock values instead of countdowns." = "زمان بازنشانی را به جای شمارش معکوس، به صورت مقادیر مطلق ساعت نمایش دهید."; +"Done" = "انجام شد"; +"Effective PATH" = "PATH مؤثر"; +"Email" = "ایمیل"; +"Enable Merge Icons to configure Overview tab providers." = "فعال سازی Merge Icons برای پیکربندی ارائه دهندگان تب نمای کلی."; +"Enable file logging" = "فعال سازی ثبت فایل"; +"Enabled" = "فعال"; +"Error" = "خطا"; +"Error simulation" = "شبیه سازی خطا"; +"Expose troubleshooting tools in the Debug tab." = "ابزارهای عیب یابی را در تب اشکال زدایی آشکار کنید."; +"Failed" = "شکست خورد"; +"False" = "نادرست"; +"Fetch strategy attempts" = "تلاش های استراتژی جمع آوری"; +"Fetching" = "جمع آوری"; +"Field" = "میدان"; +"Field subtitle" = "زیرنویس فیلد"; +"Finish the current managed account change before switching the system account." = "قبل از تغییر حساب سیستم، تغییر حساب مدیریت شده فعلی را تکمیل کنید."; +"Force animation on next refresh" = "انیمیشن فورس در رفرش بعدی"; +"Gateway region" = "منطقه گیت وی"; +"Gemini CLI not found" = "Gemini CLI پیدا نشد"; +"Gemini/Antigravity, surfacing incidents in the icon and menu." = "Gemini/Antigravity، نمایش حوادث در آیکون و منو."; +"General" = "عمومی"; +"GitHub" = "GitHub"; +"GitHub Copilot Login" = "GitHub Copilot ورود"; +"GitHub Login" = "GitHub ورود"; +"Hide details" = "مخفی کردن جزئیات"; +"Hide personal information" = "مخفی کردن اطلاعات شخصی"; +"Historical tracking" = "ردیابی تاریخی"; +"How often CodexBar polls providers in the background." = "چند وقت یکبار CodexBar ارائه دهندگان نظرسنجی در پس زمینه انجام می دهند."; +"Inactive" = "غیرفعال"; +"Install CLI" = "نصب CLI"; +"Install the Claude CLI (npm i -g @anthropic-ai/claude-code) and try again." = "Claude CLI (npm i -g @anthropic-ai/claude-code) را نصب کنید و دوباره امتحان کنید."; +"Install the Codex CLI (npm i -g @openai/codex) and try again." = "Codex CLI (npm i -g @openai/codex) را نصب کنید و دوباره امتحان کنید."; +"Install the Gemini CLI (npm i -g @google/gemini-cli) and try again." = "Gemini CLI (npm i -g @google/gemini-cli) را نصب کنید و دوباره امتحان کنید."; +"Install a JetBrains IDE with AI Assistant enabled, then refresh CodexBar." = "یک IDE از JetBrains با AI Assistant فعال نصب کنید، سپس CodexBar را تازه‌سازی کنید."; +"JetBrains AI is ready" = "JetBrains هوش مصنوعی آماده است"; +"JetBrains IDE" = "JetBrains IDE"; +"Keep CLI sessions alive" = "جلسات CLI را زنده نگه دارید"; +"Keyboard shortcut" = "میانبر کیبورد"; +"Keychain access" = "دسترسی Keychain"; +"Keychain prompt policy" = "سیاست Keychain سرعت"; +"Last \\(name) fetch failed:" = "آخرین \\(name) آوردن ناموفق بود:"; +"Last \\(self.store.metadata(for: self.provider).displayName) fetch failed:" = "آخرین \\(self.store.metadata(for: self.provider).displayName) واکشی ناموفق بود:"; +"Last attempt" = "آخرین تلاش"; +"Link" = "لینک"; +"Loading animations" = "انیمیشن های بارگذاری"; +"Loading…" = "در حال بارگذاری..."; +"Local" = "محلی"; +"Logging" = "چوب بری"; +"Login failed" = "ورود ناموفق"; +"Login shell PATH (startup capture)" = "PATH پوسته ورود (ضبط راه انداز)"; +"Login timed out" = "زمان ورود تمام شد"; +"MCP details" = "جزئیات MCP"; +"Managed Codex accounts unavailable" = "حساب های مدیریت شده Codex در دسترس نیستند"; +"Managed account storage is unreadable. Live account access is still available, " = "ذخیره سازی حساب مدیریت شده قابل خواندن نیست. دسترسی به حساب زنده هنوز در دسترس است "; +"Manual" = "دفترچه راهنما"; +"May your tokens never run out—keep agent limits in view." = "امیدوارم توکن های شما هرگز تمام نشوند—محدودیت های عامل را در نظر داشته باشید."; +"Menu bar" = "نوار منو"; +"Menu bar auto-shows the provider closest to its rate limit." = "نوار منو به طور خودکار ارائه دهنده ای را نشان می دهد که به محدودیت نرخ خود نزدیک تر است."; +"Menu bar metric" = "معیار نوار منو"; +"Menu bar shows percent" = "نوار منو درصد را نشان می دهد"; +"Menu content" = "محتوای منو"; +"Merge Icons" = "آیکون های ادغام"; +"Never prompt" = "هرگز پرامپت نکنید"; +"No" = "نه"; +"No Codex accounts detected yet." = "هنوز حساب Codex شناسایی نشده است."; +"No JetBrains IDE detected" = "هیچ JetBrains IDE ای شناسایی نشد."; +"No cost history data." = "داده های تاریخچه رایگان."; +"No data available" = "داده ای در دسترس نیست"; +"No data yet" = "هنوز داده ای وجود ندارد"; +"No enabled providers available for Overview." = "هیچ ارائه دهنده فعالی برای مرور کلی در دسترس نیست."; +"No providers selected" = "هیچ ارائه دهنده ای انتخاب نشده است"; +"No token accounts yet." = "هنوز حساب توکنی ندارم."; +"No usage breakdown data." = "هیچ داده ای درباره تقسیم بندی مصرف وجود ندارد."; +"None" = "هیچ کدام"; +"Notifications" = "اعلان ها"; +"Notifies when the 5-hour session quota hits 0% and when it becomes " = "هنگامی که سهمیه جلسه پنج‌ساعته به 0% می‌رسد و دوباره "; +"OK" = "باشه"; +"Obscure email addresses in the menu bar and menu UI." = "آدرس های ایمیل مبهم در نوار منو و رابط کاربری منو."; +"Off" = "خاموش"; +"Offline" = "آفلاین"; +"On" = "روشن است"; +"Online" = "آنلاین"; +"Only on user action" = "فقط با اقدام کاربر"; +"Open" = "باز"; +"Open API Keys" = "کلیدهای API باز"; +"Open Amp Settings" = "تنظیمات باز Amp"; +"Open Antigravity to sign in, then refresh CodexBar." = "Antigravity را باز کنید تا وارد شوید، سپس CodexBar را تازه کنید."; +"Open Browser" = "مرورگر باز"; +"Open Coding Plan" = "طرح کدگذاری باز"; +"Open Console" = "کنسول باز"; +"Open Dashboard" = "داشبورد باز"; +"Open Mistral Admin" = "Open Mistral Admin"; +"Open Menu Bar Settings" = "تنظیمات نوار منو را باز کنید"; +"Open Ollama Settings" = "تنظیمات باز Ollama"; +"Open Terminal" = "ترمینال باز"; +"Open Usage Page" = "صفحه استفاده باز"; +"Open Warp API Key Guide" = "راهنمای کلید باز Warp API"; +"Open menu" = "منوی باز"; +"Open token file" = "فایل توکن باز"; +"OpenAI cookies" = "OpenAI کوکی"; +"OpenAI web extras" = "OpenAI افزونه های وب"; +"Option A" = "گزینه الف"; +"Option B" = "گزینه B"; +"Optional override if workspace lookup fails." = "اگر جستجوی workspace شکست بخورد، جایگزین اختیاری است."; +"Options" = "گزینه ها"; +"Override auto-detection with a custom IDE base path" = "لغو تشخیص خودکار با یک مسیر پایه سفارشی IDE"; +"Overview" = "بررسی اجمالی"; +"Overview rows always follow provider order." = "ردیف های نمای کلی همیشه مطابق با ترتیب ارائه دهنده انجام می شوند."; +"Overview tab providers" = "ارائه دهندگان تب مرور کلی"; +"Paste API key…" = "کلید API بچسبان..."; +"Paste API token…" = "توکن API بچسبان..."; +"Paste key…" = "کلید چسباندن..."; +"Paste sessionKey or OAuth token…" = "sessionKey یا توکن OAuth را پیست کنید..."; +"Paste the Cookie header from a request to admin.mistral.ai. " = "هدر کوکی را از درخواست به admin.mistral.ai. "; +"Paste token…" = "توکن چسباندن..."; +"Personal" = "زندگی شخصی"; +"Picker" = "پیکر"; +"Picker subtitle" = "زیرعنوان پیکر"; +"Placeholder" = "جایگزین جایگزین"; +"Plan" = "طرح"; +"Plan Usage" = "استفاده از طرح"; +"Play full-screen confetti when weekly usage resets." = "وقتی استفاده هفتگی ریست می شود، کنفتی تمام صفحه را پخش کنید."; +"Polls OpenAI/Claude status pages and Google Workspace for " = "نظرسنجی ها OpenAI/Claude صفحات وضعیت و Google Workspace for "; +"Prevents any Keychain access while enabled." = "در حالت فعال بودن از هرگونه دسترسی Keychain جلوگیری می کند."; +"Primary (API key limit)" = "کلید اصلی (API حد کلید)"; +"Primary (\\(label))" = "ابتدایی (\\(label))"; +"Primary (\\(metadata.sessionLabel))" = "ابتدایی (\\(metadata.sessionLabel))"; +"Probe logs" = "گزارش های کاوشگر"; +"Progress bars fill as you consume quota (instead of showing remaining)." = "نوارهای پیشرفت هنگام مصرف سهمیه پر می شوند (به جای اینکه باقی مانده را نشان دهند)."; +"Provider" = "ارائه دهنده"; +"Providers" = "ارائه دهندگان"; +"Quit CodexBar" = "ترک CodexBar"; +"Random (default)" = "تصادفی (پیش فرض)"; +"Reads local usage logs. Shows today + last 30 days cost in the menu." = "گزارش های استفاده محلی را می خواند. امروز نمایش داده می شود + پنجره تاریخچه انتخاب شده در منو."; +"Refresh" = "تازه سازی"; +"Refresh cadence" = "کادانس تازه سازی"; +"Remote" = "دورافتاده"; +"Remove" = "حذف"; +"Remove Codex account?" = "حساب Codex حذف کنم؟"; +"Remove \\(account.email) from CodexBar? Its managed Codex home will be deleted." = "حذف \\(account.email) از CodexBar؟ مدیریت Codex خانه آن حذف خواهد شد."; +"Remove \\(email) from CodexBar? Its managed Codex home will be deleted." = "حذف \\(email) از CodexBar؟ مدیریت Codex خانه آن حذف خواهد شد."; +"Remove selected account" = "حذف حساب انتخاب شده"; +"Replace critter bars with provider branding icons and a percentage." = "بارهای کریتر را با آیکون های برندینگ ارائه دهنده و درصدی جایگزین کنید."; +"Replay selected animation" = "بازپخش انیمیشن انتخاب شده"; +"Requires authentication via GitHub Device Flow." = "نیاز به احراز هویت از طریق جریان دستگاه GitHub دارد."; +"Resets: \\(reset)" = "بازنشانی: \\(reset)"; +"Rolling five-hour limit" = "محدودیت پنج ساعته متحرک"; +"Search hourly" = "جستجو ساعتی"; +"Secondary (\\(label))" = "ثانویه (\\(label))"; +"Secondary (\\(metadata.weeklyLabel))" = "ثانویه (\\(metadata.weeklyLabel))"; +"Select a provider" = "یک ارائه دهنده انتخاب کنید"; +"Select the IDE to monitor" = "IDE را برای مانیتور انتخاب کنید"; +"Session quota notifications" = "اعلان های سهمیه نشست"; +"Session tokens" = "توکن های جلسه"; +"provider_section_connection" = "اتصال"; +"provider_section_menu_bar" = "نوار منو"; +"Show Codex Credits and Claude Extra usage sections in the menu." = "بخش های Codex اعتبارها و Claude استفاده اضافی را در منو نمایش دهید."; +"Show Debug Settings" = "نمایش تنظیمات اشکال زدایی"; +"Show all token accounts" = "نمایش همه حساب های توکن"; +"Show cost summary" = "خلاصه هزینه نمایش"; +"Show credits + extra usage" = "نمایش اعتبارها + استفاده اضافی"; +"Show details" = "جزئیات برنامه"; +"Show most-used provider" = "نمایش ارائه دهنده پرکاربرد"; +"Show provider icons in the switcher (otherwise show a weekly progress line)." = "آیکون های ارائه دهنده را در سوئیچر نشان دهید (در غیر این صورت یک خط پیشرفت هفتگی نمایش دهید)."; +"Show reset time as clock" = "نمایش زمان بازنشانی به صورت ساعت"; +"Show usage as used" = "کاربرد نمایش همان طور که استفاده می شود"; +"Sign in with Claude Code..." = "ورود با Claude Code..."; +"Sign in via button below" = "از طریق دکمه زیر وارد شوید"; +"Skip teardown between probes (debug-only)." = "رد کردن بین پروب ها (فقط برای اشکال زدایی)."; +"Stack token accounts in the menu (otherwise show an account switcher bar)." = "حساب های توکن را در منو انباشته کنید (در غیر این صورت نوار تعویض حساب نمایش داده شود)."; +"Start at Login" = "شروع از ورود"; +"Status" = "وضعیت"; +"Store Claude sessionKey cookies or OAuth access tokens." = "کوکی های sessionKey Claude یا توکن های دسترسی OAuth را ذخیره کنید."; +"Store multiple Abacus AI Cookie headers." = "چندین هدر Abacus AI کوکی را ذخیره کنید."; +"Store multiple Augment Cookie headers." = "چندین هدر Augment کوکی را ذخیره کنید."; +"Store multiple Cursor Cookie headers." = "چندین هدر Cursor کوکی را ذخیره کنید."; +"Store multiple Factory Cookie headers." = "چندین هدر Factory کوکی را ذخیره کنید."; +"Store multiple MiniMax Cookie headers." = "چندین هدر MiniMax کوکی را ذخیره کنید."; +"Store multiple Mistral Cookie headers." = "چندین هدر Mistral کوکی را ذخیره کنید."; +"Store multiple Ollama Cookie headers." = "چندین هدر Ollama کوکی را ذخیره کنید."; +"Store multiple OpenCode Cookie headers." = "چندین هدر OpenCode کوکی را ذخیره کنید."; +"Store multiple OpenCode Go Cookie headers." = "چندین هدر OpenCode Go Cookie را ذخیره کنید."; +"Stored in the CodexBar config file." = "در فایل پیکربندی CodexBar ذخیره شده است."; +"Stored in ~/.codexbar/config.json. " = "ذخیره شده در ~/.codexbar/config.json. "; +"Stored in ~/.codexbar/config.json. Paste the key from the Synthetic dashboard." = "ذخیره شده در ~/.codexbar/config.json. کلید را از داشبورد Synthetic پیست کنید."; +"Stored in ~/.codexbar/config.json. Paste your Coding Plan API key from Model Studio." = "ذخیره شده در ~/.codexbar/config.json. کلید API برنامه کدنویسی خود را از مدل استودیو چسبانده کنید."; +"Stored in ~/.codexbar/config.json. Paste your MiniMax API key." = "ذخیره شده در ~/.codexbar/config.json. کلید MiniMax API خود را بچسبانید."; +"Stored in ~/.codexbar/config.json. You can also provide KILO_API_KEY or " = "ذخیره شده در ~/.codexbar/config.json. همچنین می توانید KILO_API_KEY or ارائه دهید"; +"Stores local Codex usage history (8 weeks) to personalize Pace predictions." = "تاریخچه استفاده Codex محلی (۸ هفته) را برای شخصی سازی پیش بینی های سرعت ذخیره می کند."; +"Surprise me" = "سورپرایزم کن"; +"Switcher shows icons" = "سوئیچر آیکون ها را نشان می دهد"; +"Symlink CodexBarCLI to /usr/local/bin and /opt/homebrew/bin as codexbar." = "Symlink CodexBarCLI به /usr/local/bin و /opt/homebrew/bin به عنوان codexbar."; +"System" = "سیستم"; +"Temporarily shows the loading animation after the next refresh." = "انیمیشن بارگذاری پس از تازه سازی بعدی به طور موقت نمایش داده می شود."; +"terminal_app_subtitle" = "ترمینال مورد استفاده در عملکرد ترمینال باز"; +"terminal_app_title" = "ترمینال پیش فرض"; +"Tertiary (\\(label))" = "دوره سوم (\\(label))"; +"Tertiary (\\(tertiaryTitle))" = "دوره سوم (\\(tertiaryTitle))"; +"The default Codex account on this Mac." = "حساب پیش فرض Codex این مک."; +"Toggle" = "تغییر وضعیت"; +"Toggle subtitle" = "تغییر زیرنویس"; +"Token" = "توکن"; +"Trigger the menu bar menu from anywhere." = "منوی نوار منو را از هر جایی فعال کنید."; +"True" = "درسته"; +"Twitter" = "توییتر"; +"Unsupported" = "بدون پشتیبانی"; +"Update Channel" = "به روزرسانی کانال"; +"Updated" = "به روزرسانی شده"; +"Updates unavailable in this build." = "به روزرسانی ها در این نسخه در دسترس نیستند."; +"Usage" = "کاربرد"; +"Usage breakdown" = "تفکیک استفاده"; +"Usage history (30 days)" = "تاریخچه استفاده"; +"Usage source" = "منبع استفاده"; +"Use Account" = "استفاده از حساب"; +"Use BigModel for the China mainland endpoints (open.bigmodel.cn)." = "برای نقاط پایانی سرزمین اصلی چین از BigModel استفاده کنید (open.bigmodel.cn)."; +"Use a single menu bar icon with a provider switcher." = "از یک آیکون نوار منو با یک تغییر دهنده ارائه دهنده استفاده کنید."; +"Use international or China mainland console gateways for quota fetches." = "برای دریافت سهمیه از دروازه های کنسول بین المللی یا چین استفاده کنید."; +"Version" = "نسخه"; +"Version \\(self.versionString)" = "نسخه \\(self.versionString)"; +"Version \\(version)" = "نسخه \\(version)"; +"Version \\(versionString)" = "نسخه \\(versionString)"; +"Vertex AI Login" = "Vertex AI ورود"; +"Wait for the current managed Codex login to finish before adding another account." = "صبر کنید تا ورود مدیریت شده فعلی Codex کامل شود و بعد حساب جدیدی اضافه کنید."; +"Waiting for Authentication..." = "منتظر احراز هویت..."; +"Website" = "وب سایت"; +"Weekly limit confetti" = "کنفتی محدود هفتگی"; +"Weekly token limit" = "محدودیت هفتگی توکن"; +"Weekly usage" = "استفاده هفتگی"; +"Weekly usage unavailable for this account." = "استفاده هفتگی برای این حساب در دسترس نیست."; +"Window: \\(window)" = "پنجره: \\(window)"; +"Write logs to \\(self.fileLogPath) for debugging." = "لاگ ها را برای \\(self.fileLogPath) برای اشکال زدایی بنویسید."; +"Yes" = "بله"; +"\\(detail.modelCode): \\(usage)" = "\\(detail.modelCode): \\(usage)"; +"\\(name): \\(truncated)" = "\\(name): \\(truncated)"; +"\\(name): \\(updated) · 30d \\(cost)" = "\\(name): \\(updated) · 30d \\(cost)"; +"\\(name): fetching…\\(elapsed)" = "\\(name): جالب است... \\(elapsed)"; +"\\(name): last attempt \\(when)" = "\\(name): آخرین تلاش \\(when)"; +"\\(name): no data yet" = "\\(name): هنوز داده ای وجود ندارد"; +"\\(name): unsupported" = "\\(name): بدون پشتیبانی"; +"all browsers" = "تمام مرورگرها"; +"available again." = "دوباره در دسترس است."; +"built_format" = "ساخته شده %@"; +"copilot_complete_in_browser" = "ورود کامل به مرورگر خود را انجام دهید."; +"copilot_device_code" = "کد دستگاه کپی شده به کلیپ بورد: %1$@\n\nVerify در: %2$@"; +"copilot_device_code_copied" = "کد دستگاه کپی شد."; +"copilot_verify_at" = "تأیید کنید در %@"; +"copilot_waiting_text" = "ورود کامل به مرورگر خود را انجام دهید. \nاین پنجره به طور خودکار پس از تکمیل ورود بسته می شود."; +"copilot_window_closes_auto" = "این پنجره به طور خودکار هنگام تکمیل ورود بسته می شود."; +"cost_status_error" = "%1$@: %2$@"; +"cost_status_fetching" = "%1$@: جالب است... %2$@"; +"cost_status_last_attempt" = "%1$@: آخرین تلاش %2$@"; +"cost_status_no_data" = "%@: هنوز داده ای وجود ندارد"; +"cost_status_snapshot" = "%1$@: %2$@ · %3$@ %4$@"; +"cost_status_unsupported" = "%@: بدون پشتیبانی"; +"credits_remaining" = "اعتبارات: %@"; +"cursor_on_demand" = "درخواست: %@"; +"cursor_on_demand_with_limit" = "درخواست: %1$@ / %2$@"; +"extra_usage_format" = "استفاده اضافی: %1$@ / %2$@"; +"jetbrains_detected_generate" = "شناسایی شد: %@. یک بار از دستیار هوش مصنوعی برای تولید داده های سهمیه استفاده کنید، سپس CodexBar را تازه کنید."; +"jetbrains_detected_select" = "شناسایی شد: %@. IDE مورد علاقه تان را در تنظیمات انتخاب کنید، سپس CodexBar را تازه کنید."; +"last_fetch_failed_with_provider" = "آخرین %@ آوردن ناموفق بود:"; +"last_spend" = "آخرین هزینه ها: %@"; +"mcp_model_usage" = "%1$@: %2$@"; +"mcp_resets" = "بازنشانی: %@"; +"mcp_window" = "پنجره: %@"; +"metric_average" = "میانگین (%1$@ + %2$@)"; +"metric_primary" = "ابتدایی (%@)"; +"metric_secondary" = "ثانویه (%@)"; +"metric_tertiary" = "دوره سوم (%@)"; +"multiple_workspaces_found" = "CodexBar چندین فضای کاری برای %@ پیدا کردم. لطفا فضای کاری را برای افزودن انتخاب کنید."; +"ory_session_…=…; csrftoken=…" = "ory_session_...=...; csrftoken=..."; +"overview_choose_providers" = "تا %@ ارائه دهنده را انتخاب کنید"; +"remove_account_message" = "حذف %@ از CodexBar؟ مدیریت Codex خانه آن حذف خواهد شد."; +"version_format" = "نسخه %@"; +"vertex_ai_login_instructions" = "برای پیگیری Vertex AI استفاده، با Google Cloud.\n\n1 احراز هویت کنید. ترمینال باز\n2. اجرا: gcloud auth application-default-login\n3. دستورالعمل های مرورگر را دنبال کنید تا in\n4 را امضا کنید. پروژه ات را تنظیم کن: gcloud config set project PROJECT_ID\n\nOpen Terminal را همین حالا؟"; +"workspaceID is set but only opencode, opencodego, and deepgram support workspaceID." = "workspaceID فعال است اما فقط opencode، opencodego و deepgram از workspaceID پشتیبانی می کنند."; +"© 2026 Peter Steinberger. MIT License." = "© ۲۰۲۶ پیتر استاینبرگر. MIT مجوز."; + +/* General Pane */ +"section_system" = "سیستم"; +"section_usage" = "کاربرد"; +"section_refreshing" = "تازه‌سازی"; +"section_alerts" = "هشدارها"; +"section_celebrations" = "جشن‌ها"; +"section_icon" = "آیکون"; +"section_combined_icon" = "آیکون ترکیبی"; +"section_animation" = "پویانمایی"; +"section_content" = "محتوا"; +"section_agent_sessions" = "جلسات عامل‌ها"; +"language_title" = "زبان"; +"language_subtitle" = "زبان نمایش را تغییر دهید. برای اجرایی شدن کامل برنامه نیاز به ریستارت دارد."; +"currency_title" = "ارز ترجیحی"; +"currency_subtitle" = "ارز برآورد هزینه و مصرف. از نرخ‌های تبدیل روزانه استفاده می‌کند."; +"currency_auto" = "خودکار (بر اساس ارائه‌دهنده / USD)"; +"language_system" = "سیستم"; +"language_english" = "انگلیسی"; +"language_spanish" = "اسپانیایی"; +"language_catalan" = "کاتالا"; +"language_chinese_simplified" = "简体中文"; +"language_chinese_traditional" = "繁體中文"; +"language_portuguese_brazilian" = "پرتغالی ها (برزیل)"; +"language_dutch" = "هلند ها"; +"language_german" = "دویچ"; +"language_swedish" = "سوئنسکا"; +"language_french" = "فرانسوی"; +"language_ukrainian" = "Українська"; +"language_russian" = "Русский"; +"language_japanese" = "ژاپنی"; +"language_korean" = "کره ای"; +"language_turkish" = "ترک چه"; +"language_italian" = "ایتالیانو"; +"language_polish" = "پولسکی"; +"start_at_login_title" = "شروع از ورود"; +"start_at_login_subtitle" = "وقتی مک را روشن می کنید، CodexBar به طور خودکار باز می شود."; +"show_cost_summary_subtitle" = "گزارش های استفاده محلی را می خواند. امروز نمایش داده می شود + پنجره تاریخچه انتخاب شده در منو."; +"cost_summary_style_title" = "سبک نمایش"; +"cost_summary_style_inline" = "فقط درون‌خطی"; +"cost_summary_style_submenu" = "فقط زیرمنو"; +"cost_summary_style_both" = "هر دو"; +"cost_summary_style_inline_help" = "خلاصه هزینه را مستقیماً در منوی اصلی نشان می‌دهد."; +"cost_summary_style_submenu_help" = "در عوض زیرمنوی جزئیات هزینه را نشان می‌دهد."; +"cost_summary_style_both_help" = "هم خلاصه منوی اصلی و هم زیرمنوی جزئیات هزینه را نشان می‌دهد."; +"cost_history_window_title" = "پنجره تاریخچه"; +"cost_history_window_help" = "تعیین می‌کند چند روز از گزارش‌های استفاده محلی در منو نشان داده شود."; +"cost_history_days_title" = "پنجره تاریخچه: %d روز"; +"cost_comparison_periods_title" = "نمایش دوره‌های مقایسه کوتاه‌تر"; +"cost_comparison_periods_subtitle" = "وقتی در پنجره تاریخچه انتخاب‌شده جا می‌گیرند، مجموع‌های ۷، ۳۰ و ۹۰ روزه را اضافه کنید. این مجموع‌ها از همان اسکن محلی استفاده می‌کنند."; +"cost_auto_refresh_info" = "تازه‌سازی خودکار: بازه سراسری (حداقل ۵ دقیقه) · مهلت: ۱۰ دقیقه"; +"refresh_interval_title" = "فاصله تازه‌سازی"; +"manual_refresh_hint" = "تازه سازی خودکار خاموش است؛ از فرمان تازه سازی منو استفاده کنید."; +"refresh_on_open_title" = "بازخوانی هنگام باز کردن منو"; +"refresh_on_open_subtitle" = "هر بار که منو را باز می‌کنید، آخرین میزان مصرف هر ارائه‌دهنده دریافت می‌شود."; +"check_provider_status_title" = "وضعیت ارائه دهنده را بررسی کنید"; +"check_provider_status_subtitle" = "نظرسنجی ها OpenAI/Claude صفحات وضعیت و Google Workspace برای Gemini/Antigravity که حوادث را در آیکون و منو نمایش می دهد."; +"session_quota_notifications_subtitle" = "هنگامی که سهمیه جلسه پنج‌ساعته به 0% می‌رسد و دوباره در دسترس قرار می‌گیرد، اطلاع می‌دهد."; +"quota_depleted_title" = "اتمام و بازیابی سهمیه"; +"quota_warning_notifications_subtitle" = "هشدار می دهد وقتی سهمیه نشست یا هفتگی باقی مانده از آستانه های پیکربندی شده عبور کند."; +"threshold_warnings_title" = "هشدارهای آستانه"; +"quota_warnings_title" = "هشدارهای سهمیه"; +"quota_warning_session" = "جلسه"; +"quota_warning_session_capitalized" = "جلسه"; +"quota_warning_weekly" = "هفتگی"; +"quota_warning_weekly_capitalized" = "هفتگی"; +"quota_warning_notification_title" = "سهمیه %2$@ در %1$@ رو به اتمام است"; +"quota_warning_notification_body" = "%1$@ باقی مانده است. آستانه هشدار %2$d%% برای سهمیه %3$@ شما فعال شد."; +"quota_warning_notification_body_with_account" = "حساب %1$@. %2$@ باقی مانده است. آستانه هشدار %3$d%% برای سهمیه %4$@ شما فعال شد."; +"predictive_pace_warnings_title" = "هشدارهای پیش‌بینی روند مصرف"; +"predictive_pace_warnings_subtitle" = "برای Codex و Claude هشدار می‌دهد وقتی روند مصرف جلسه یا هفتگی ممکن است پیش از بازنشانی سهمیه را تمام کند."; +"confetti_on_reset_title" = "کنفتی هنگام بازنشانی"; +"confetti_on_reset_subtitle" = "هنگام بازنشانی استفاده، کنفتی تمام‌صفحه پخش کنید."; +"confetti_option_off" = "خاموش"; +"confetti_option_session" = "بازنشانی‌های جلسه"; +"confetti_option_weekly" = "بازنشانی‌های هفتگی"; +"confetti_option_both" = "هر دو"; +"predictive_pace_warning_notification_title" = "%1$@ هشدار روند مصرف %2$@"; +"predictive_pace_warning_notification_body" = "با روند فعلی، این سهمیه ممکن است تا %1$@ دیگر، پیش از بازنشانی، تمام شود."; +"predictive_pace_warning_notification_body_with_account" = "حساب %1$@. با روند فعلی، این سهمیه ممکن است تا %2$@ دیگر، پیش از بازنشانی، تمام شود."; +"session_depleted_notification_title" = "%@ جلسه تخلیه شد"; +"session_depleted_notification_body" = "0% باقی مانده است. وقتی دوباره در دسترس قرار گیرد اطلاع می‌دهیم."; +"session_restored_notification_title" = "جلسه %@ بازیابی شد"; +"session_restored_notification_body" = "سهمیه جلسه دوباره در دسترس است."; +"quota_warning_warn_at" = "هشدار در"; +"quota_warning_global_threshold_subtitle" = "درصدهای باقی مانده برای جلسات و بازه های هفتگی مگر اینکه ارائه دهنده آن ها را لغو کند."; +"quota_warning_sound" = "صدای اعلان پخش کن"; +"quota_warning_onscreen_alert" = "نمایش هشدار متنی روی صفحه"; +"quota_warning_provider_inherits" = "از تنظیمات هشدار سهمیه جهانی استفاده می کند مگر اینکه پنجره ای اینجا سفارشی شده باشد."; +"quota_warning_provider_disabled" = "اعلان‌های هشدار سهمیه و نشانگرهای نوار استفاده غیرفعال هستند. برای ویرایش این تنظیمات ذخیره‌شده، یکی از آن‌ها را فعال کنید."; +"quota_warning_provider_markers_only" = "اعلان‌های هشدار سهمیه در سطح سراسری غیرفعال هستند. این تنظیمات همچنان نشانگرهای نوار استفاده را کنترل می‌کنند."; +"quota_warning_global" = "سراسری"; +"quota_warning_customize_thresholds" = "آستانه های %@ شخصی سازی کنید"; +"quota_warning_enable_warnings" = "فعال کردن هشدارهای %@"; +"quota_warning_window_warn_at" = "%@ هشدار می دهد"; +"quota_warning_off" = "خاموش"; +"quota_warning_inherited" = "به ارث رسیده: %@"; +"quota_warning_depleted_only" = "فقط کاهش یافته"; +"quota_warning_upper" = "بالاتر"; +"quota_warning_lower" = "پایین تر"; +"quota_warning_warning" = "هشدار"; +"quota_warning_critical" = "بحرانی"; +"apply" = "درخواست بده"; +"quit_app" = "خروج از CodexBar"; + +/* Tab titles */ +"tab_general" = "عمومی"; +"tab_providers" = "ارائه دهندگان"; +"tab_notifications" = "اعلان‌ها"; +"tab_menu_bar" = "نوار منو"; +"tab_menu" = "منو"; +"tab_advanced" = "پیشرفته"; +"tab_hooks" = "قلاب‌ها"; +"tab_about" = "درباره"; + +/* Hooks Pane */ +"hooks_enable_title" = "فعال‌سازی قلاب‌ها"; +"hooks_enable_subtitle" = "اجرای دستورهای خارجی هنگام رخ‌دادن رویدادهای سهمیه یا ارائه‌دهنده."; +"hooks_trust_warning" = "قلاب‌ها می‌توانند دستورهای محلی را روی مک شما اجرا کنند. فقط دستورهایی را تنظیم کنید که به آن‌ها اعتماد دارید."; +"hooks_rules_header" = "قوانین"; +"hooks_empty" = "هیچ قلابی پیکربندی نشده است."; +"hooks_add_rule" = "افزودن قانون"; +"hooks_delete_rule" = "حذف قانون"; +"hooks_rule_enabled" = "فعال"; +"hooks_event" = "رویداد"; +"hooks_provider" = "ارائه‌دهنده"; +"hooks_any_provider" = "هر ارائه‌دهنده"; +"hooks_threshold" = "اجرا در مصرف ≥"; +"hooks_threshold_placeholder" = "90"; +"hooks_executable_placeholder" = "/usr/local/bin/my-command"; +"hooks_arguments_placeholder" = "آرگومان‌ها"; +"hooks_argument_placeholder" = "آرگومان"; +"hooks_add_argument" = "افزودن آرگومان"; +"hooks_delete_argument" = "حذف آرگومان"; +"tab_debug" = "اشکال زدایی"; + +/* Providers Pane */ +"select_a_provider" = "یک ارائه دهنده انتخاب کنید"; +"cancel" = "لغو"; +"last_fetch_failed" = "آخرین واکشی شکست خورد"; +"usage_not_fetched_yet" = "هنوز استفاده را دریافت نکرده ایم"; +"managed_account_storage_unreadable" = "ذخیره سازی حساب مدیریت شده قابل خواندن نیست. دسترسی به حساب زنده هنوز در دسترس است، اما اقدامات مدیریت شده افزودن، احراز هویت مجدد و حذف تا زمانی که فروشگاه قابل بازیابی شود، غیرفعال می شوند."; +"remove_codex_account_title" = "حساب Codex حذف کنم؟"; +"remove" = "حذف"; +"managed_login_already_running" = "یک ورود Codex مدیریت شده هم اکنون در حال اجرا است. صبر کنید تا تمام شود قبل از اینکه حساب دیگری را اضافه یا دوباره احراز هویت کنید."; +"managed_login_failed" = "ورود Codex کامل نشد. بررسی کنید که `codex --version` در ترمینال کار می کند. اگر مسدود macOS یا `codex` به سطل زباله منتقل شد، نصب های تکراری و قدیمی را حذف کنید، `npm install -g --include=optional @openai/codex@latest` را اجرا کنید و دوباره امتحان کنید."; +"codex_login_output" = "خروجی ورود به کدکس:"; +"managed_login_missing_email" = "ورود Codex تکمیل شد، اما هیچ ایمیل حسابی در دسترس نبود. پس از اطمینان از اینکه حساب کاملا وارد شده است، دوباره تلاش کنید."; +"login_success_notification_title" = "ورود %@ موفقیت آمیز بود"; +"login_success_notification_body" = "می توانید به اپلیکیشن بازگردید؛ احراز هویت تمام شد."; +"workspace_selection_cancelled" = "CodexBar چندین فضای کاری پیدا کردم، اما هیچ فضای کاری انتخاب نشده بود."; +"unsafe_managed_home" = "CodexBar از تغییر مسیر خانه مدیریت شده غیرمنتظره خودداری کرد: %@"; +"menu_bar_metric_title" = "معیار نوار منو"; +"menu_bar_metric_subtitle" = "انتخاب کنید کدام پنجره درصد نوار منو را تنظیم کند."; +"menu_bar_metric_subtitle_deepseek" = "تعادل DeepSeek را در نوار منو نشان می دهد."; +"menu_bar_metric_subtitle_moonshot" = "تعادل Moonshot / Kimi API را در نوار منو نشان می دهد."; +"menu_bar_metric_subtitle_mistral" = "هزینه های Mistral API ماه جاری را در نوار منو نشان می دهد."; +"automatic" = "اتوماتیک"; +"primary_api_key_limit" = "کلید اصلی (API حد کلید)"; + +/* Display Pane */ +"menu_bar_style_title" = "سبک نوار منو"; +"menu_bar_style_subtitle" = "نحوه نمایش آیتم نوار منو."; +"menu_bar_inactive_display_contrast_title" = "بهبود خوانایی در نمایشگرهای غیرفعال"; +"menu_bar_usage_colors_title" = "مصرف رنگی"; +"menu_bar_usage_colors_subtitle" = "نماد نوار منو را با افزایش مصرف از سبز به قرمز رنگ می‌کند."; +"menu_bar_inactive_display_contrast_subtitle" = "از نمایش با کنتراست بالا استفاده می‌کند تا نماد و معیار در نمایشگرهای دیگر خوانا بمانند."; +"menu_bar_style_critters" = "موجودات"; +"menu_bar_style_bars" = "نوارهای اندازه‌گیری"; +"menu_bar_style_icon_percent" = "آیکون و درصد"; +"switcher_rows_title" = "ردیف‌های سوئیچر"; +"switcher_rows_icons" = "آیکون‌های ارائه‌دهنده"; +"switcher_rows_progress" = "پیشرفت هفتگی"; +"usage_bars_fill_title" = "پرشدن نوارهای استفاده"; +"usage_bars_fill_remaining" = "بر اساس باقی‌مانده"; +"usage_bars_fill_used" = "بر اساس استفاده‌شده"; +"reset_times_title" = "زمان‌های بازنشانی"; +"reset_times_countdown" = "شمارش معکوس"; +"reset_times_clock" = "ساعت"; +"cost_summary_title" = "خلاصه هزینه"; +"cost_summary_off" = "خاموش"; +"merge_icons_title" = "آیکون های ادغام"; +"merge_icons_subtitle" = "از یک آیکون نوار منو با یک تغییر دهنده ارائه دهنده استفاده کنید."; +"show_most_used_provider_title" = "نمایش ارائه دهنده پرکاربرد"; +"show_most_used_provider_subtitle" = "نوار منو به طور خودکار ارائه دهنده ای را نشان می دهد که به محدودیت نرخ خود نزدیک تر است."; +"display_mode_title" = "حالت نمایش"; +"display_mode_subtitle" = "انتخاب کنید که در نوار منو چه چیزی نمایش داده شود (Pace میزان مصرف در مقابل مورد انتظار را نشان می دهد)."; +"show_quota_warning_markers_title" = "نشانگرهای هشدار سهمیه را نشان دهید"; +"show_quota_warning_markers_subtitle" = "علامت تیک آستانه را روی نوارهای استفاده هنگام پیکربندی هشدارهای سهمیه رسم کنید."; +"weekly_progress_work_days_title" = "روزهای کاری پیشرفت هفتگی"; +"weekly_progress_work_days_subtitle" = "روزهای کاری را برای نشانگرهای نوار مصرف هفتگی و محاسبات سرعت تعیین کنید."; +"show_provider_changelog_links_title" = "لینک های تغییرات ارائه دهنده را نمایش دهید"; +"show_provider_changelog_links_subtitle" = "لینک های یادداشت های انتشار برای ارائه دهندگان پشتیبانی شده با پشتیبانی CLI به منو اضافه می شود."; +"show_credits_extra_usage_title" = "نمایش اعتبارها + استفاده اضافی"; +"show_credits_extra_usage_subtitle" = "بخش های Codex اعتبارها و Claude استفاده اضافی را در منو نمایش دهید."; +"multi_account_layout_title" = "چیدمان چندحسابی"; +"multi_account_layout_subtitle" = "کارت های حساب سوئیچینگ تقسیم شده یا کارت های حساب انباشته را انتخاب کنید."; +"multi_account_layout_segmented" = "بخش بندی شده"; +"multi_account_layout_stacked" = "انباشته شده"; +"overview_tab_providers_title" = "ارائه دهندگان تب مرور کلی"; +"configure" = "پیکربندی کن..."; +"overview_enable_merge_icons_hint" = "فعال سازی Merge Icons برای پیکربندی ارائه دهندگان تب نمای کلی."; +"overview_no_providers_hint" = "هیچ ارائه دهنده فعالی برای مرور کلی در دسترس نیست."; +"overview_rows_follow_order" = "ردیف های نمای کلی همیشه مطابق با ترتیب ارائه دهنده انجام می شوند."; +"overview_no_providers_selected" = "هیچ ارائه دهنده ای انتخاب نشده است"; +"agent_sessions_title" = "جلسات عامل‌ها"; +"agent_sessions_subtitle" = "جلسات محلی و جلسات Codex و Claude Code یافته‌شده از طریق SSH را در منو نمایش دهید."; +"agent_sessions_hosts_title" = "میزبان‌های SSH اضافی"; +"agent_sessions_footer" = "مک‌های موجود در tailnet شما به‌طور خودکار شناسایی می‌شوند. جلسات محلی هر ۳۰ ثانیه و میزبان‌های راه دور هر ۶۰ ثانیه و هنگام باز شدن منو تازه‌سازی می‌شوند."; +"agent_session_labels_title" = "برچسب‌های جلسه"; +"agent_session_labels_subtitle" = "نحوه نام‌گذاری جلسات عامل را انتخاب کنید."; +"agent_session_label_project" = "پروژه"; +"agent_session_label_descriptive" = "توصیفی"; +"agent_session_label_descriptive_and_project" = "توصیفی + پروژه"; +"agent_session_unknown_project" = "پروژه ناشناخته"; + +/* Advanced Pane */ +"section_keyboard_shortcut" = "میانبر کیبورد"; +"open_menu_shortcut_title" = "منوی باز"; +"open_menu_shortcut_subtitle" = "منوی نوار منو را از هر جایی فعال کنید."; +"install_cli" = "نصب CLI"; +"install_cli_subtitle" = "Symlink CodexBarCLI به /usr/local/bin و /opt/homebrew/bin به عنوان codexbar."; +"cli_not_found" = "CodexBarCLI در بسته برنامه ها یافت نمی شود."; +"no_writable_bin_dirs" = "هیچ سطل قابل نوشتاری پیدا نشد."; +"show_debug_settings_title" = "نمایش تنظیمات اشکال زدایی"; +"show_debug_settings_subtitle" = "ابزارهای عیب یابی را در تب اشکال زدایی آشکار کنید."; +"surprise_me_title" = "سورپرایزم کن"; +"surprise_me_subtitle" = "بررسی کن که آیا دوست داری مأمورانت آنجا خوش بگذرانند یا نه."; +"hide_personal_info_title" = "مخفی کردن اطلاعات شخصی"; +"hide_personal_info_subtitle" = "آدرس های ایمیل مبهم در نوار منو و رابط کاربری منو."; +"show_provider_storage_usage_title" = "استفاده از ذخیره سازی ارائه دهنده را نمایش دهید"; +"show_provider_storage_usage_subtitle" = "استفاده محلی از دیسک را در منوها نمایش دهید. مسیرهای شناخته شده متعلق به ارائه دهندگان را در پس زمینه اسکن می کند."; +"section_keychain_access" = "دسترسی Keychain"; +"keychain_access_caption" = "تمام قابلیت های خواندن و نوشتن Keychain را غیرفعال کنید. اگر macOS حتی بعد از کلیک روی همیشه اجازه مدام درخواست «ذخیره سازی امن» Chrome/Brave/Edge کند، از این گزینه استفاده کنید. وارد کردن کوکی مرورگر در حالت فعال بودن در دسترس نیست؛ هدرهای کوکی را به صورت دستی در Providers پیست کنید. Claude/Codex OAuth از طریق CLI هنوز کار می کند."; +"disable_keychain_access_title" = "غیرفعال کردن دسترسی Keychain"; +"disable_keychain_access_subtitle" = "در حالت فعال بودن از هرگونه دسترسی Keychain جلوگیری می کند."; + +/* About Pane */ +"about_tagline" = "امیدوارم توکن های شما هرگز تمام نشوند—محدودیت های عامل را در نظر داشته باشید."; +"link_github" = "GitHub"; +"link_website" = "وب سایت"; +"link_twitter" = "توییتر"; +"link_email" = "ایمیل"; +"check_updates_auto" = "به طور خودکار به روزرسانی ها را بررسی کنید"; +"update_channel" = "به روزرسانی کانال"; +"check_for_updates" = "به روزرسانی ها را بررسی کنید..."; +"updates_unavailable" = "به روزرسانی ها در این نسخه در دسترس نیستند."; +"copyright" = "© ۲۰۲۶ پیتر استاینبرگر. MIT مجوز."; + +/* Debug Pane */ +"section_logging" = "چوب بری"; +"enable_file_logging" = "فعال سازی ثبت فایل"; +"enable_file_logging_subtitle" = "لاگ ها را برای %@ برای اشکال زدایی بنویسید."; +"verbosity_title" = "پرسۆزی"; +"verbosity_subtitle" = "میزان جزئیات ثبت شده را کنترل می کند."; +"open_log_file" = "فایل لاگ باز"; +"force_animation_next_refresh" = "انیمیشن فورس در رفرش بعدی"; +"force_animation_next_refresh_subtitle" = "انیمیشن بارگذاری پس از تازه سازی بعدی به طور موقت نمایش داده می شود."; +"section_loading_animations" = "انیمیشن های بارگذاری"; +"loading_animations_caption" = "یک الگو انتخاب کنید و دوباره در نوار منو پخش کنید. «تصادفی» رفتار موجود را حفظ می کند."; +"animation_random_default" = "تصادفی (پیش فرض)"; +"replay_selected_animation" = "بازپخش انیمیشن انتخاب شده"; +"blink_now" = "الان پلک بزن"; +"section_probe_logs" = "گزارش های کاوشگر"; +"probe_logs_caption" = "آخرین خروجی پروب برای اشکال زدایی را دریافت کنید؛ کپی متن کامل را نگه می دارد."; +"fetch_log" = "لاگ جمع آوری"; +"copy" = "کپی"; +"save_to_file" = "ذخیره در فایل"; +"load_parse_dump" = "بارگذاری تحلیل dump"; +"rerun_provider_autodetect" = "Re-run provider autodetect"; +"loading" = "در حال بارگذاری..."; +"no_log_yet_fetch" = "هنوز گزارش نشده. برای بارگذاری بیاور."; +"section_fetch_strategy" = "تلاش های استراتژی جمع آوری"; +"fetch_strategy_caption" = "تصمیمات و خطاهای خط لوله آخرین واکشی برای یک ارائه دهنده."; +"section_openai_cookies" = "OpenAI کوکی"; +"openai_cookies_caption" = "وارد کردن کوکی + WebKit لاگ های آخرین تلاش OpenAI کوکی را استخراج می کند."; +"no_log_yet" = "هنوز گزارش نشده. به روزرسانی کوکی های OpenAI در → Codex ارائه دهندگان برای اجرای واردات."; +"section_caches" = "کش ها"; +"caches_caption" = "نتایج اسکن هزینه کش شده یا کش های کوکی مرورگر را پاک کنید."; +"clear_cookie_cache" = "پاک کردن کش کوکی"; +"clear_cost_cache" = "پاک سازی کش هزینه"; +"section_notifications" = "اعلان ها"; +"notifications_caption" = "اعلان های تست را برای پنجره نشست ۵ ساعته فعال کنید (تخلیه /restored)."; +"post_depleted" = "پست تخلیه شده"; +"post_restored" = "پست بازسازی شد"; +"section_cli_sessions" = "جلسات CLI"; +"cli_sessions_caption" = "جلسات Codex/Claude CLI را بعد از پروب زنده نگه دارید. پس از جمع آوری داده ها، پیش فرض خارج می شود."; +"keep_cli_sessions_alive" = "جلسات CLI را زنده نگه دارید"; +"keep_cli_sessions_alive_subtitle" = "رد کردن بین پروب ها (فقط برای اشکال زدایی)."; +"reset_cli_sessions" = "بازنشانی جلسات CLI"; +"section_error_simulation" = "شبیه سازی خطا"; +"error_simulation_caption" = "یک پیام خطای جعلی را در کارت منو برای تست چیدمان وارد کنید."; +"set_menu_error" = "خطای تنظیم منوی"; +"clear_menu_error" = "خطای پاک کردن منو"; +"set_cost_error" = "خطای هزینه تنظیم"; +"clear_cost_error" = "خطای هزینه ای واضح"; +"section_cli_paths" = "مسیرهای CLI"; +"cli_paths_caption" = "Codex لایه های دودویی و PATH حل شد؛ ورود به راه اندازی PATH ضبط (تایم اوت کوتاه)."; +"codex_binary" = "Codex دودویی"; +"claude_binary" = "Claude دودویی"; +"effective_path" = "PATH مؤثر"; +"unavailable" = "در دسترس نیست"; +"login_shell_path" = "PATH پوسته ورود (ضبط راه انداز)"; +"cleared" = "تأیید شد."; +"no_fetch_attempts" = "هنوز هیچ تلاشی برای آوردن توپ انجام نشده."; +"macOS Tahoe can block menu bar apps in System Settings → Menu Bar → Allow in the Menu Bar. CodexBar is running, but macOS may be hiding its icon. Open Menu Bar settings and turn CodexBar on." = "macOS تاهو می تواند برنامه های نوار منو را در تنظیمات سیستم → نوار منو → اجازه را در نوار منو مسدود کند. CodexBar در حال اجرا است، اما ممکن است آیکون macOS مخفی شده باشد. تنظیمات نوار منو را باز کنید و CodexBar را روشن کنید."; + +/* Metric preferences */ +"metric_pref_automatic" = "اتوماتیک"; +"metric_pref_primary" = "انتخابات مقدماتی"; +"metric_pref_secondary" = "دبیرستان"; +"metric_pref_tertiary" = "دوره سوم"; +"metric_pref_extra_usage" = "استفاده اضافی"; +"metric_pref_average" = "میانگین"; +"metric_mistral_payg" = "Pay-as-you-go"; +"metric_mistral_monthly_plan" = "Monthly Plan"; + +/* Display modes */ +"display_mode_percent" = "درصد"; +"display_mode_pace" = "سرعت"; +"display_mode_both" = "هر دو"; +"display_mode_reset_time" = "زمان بازنشانی"; +"display_mode_percent_desc" = "درصد باقی مانده /used را نشان دهید (مثلا 45%)"; +"display_mode_pace_desc" = "نمایش شاخص سرعت (مثلا +5%)"; +"display_mode_both_desc" = "هم درصد و هم سرعت را نشان دهید (مثلا 45% · +5%)"; +"display_mode_reset_time_desc" = "زمان بازنشانی متریک انتخاب شده را نشان دهید (مثلا ↻ ساعت ۳:۵۶ بعدازظهر)"; +"menu_bar_reset_when_exhausted_title" = "نمایش زمان بازنشانی هنگام اتمام سهمیه"; +"menu_bar_reset_when_exhausted_subtitle" = "در ۰٪ باقی‌مانده، به‌جای درصد، زمان تا بازنشانی نمایش داده می‌شود"; + +/* Provider status */ +"status_operational" = "عملیاتی"; +"status_degraded" = "کارایی کاهش‌یافته"; +"status_partial_outage" = "قطعی جزئی"; +"status_major_outage" = "قطعی عمده"; +"status_critical_issue" = "مسئله بحرانی"; +"status_maintenance" = "نگهداری"; +"status_unknown" = "وضعیت نامشخص"; + +/* Refresh frequency */ +"refresh_manual" = "دفترچه راهنما"; +"refresh_1min" = "۱ دقیقه"; +"refresh_2min" = "۲ دقیقه"; +"refresh_5min" = "۵ دقیقه"; +"refresh_15min" = "۱۵ دقیقه"; +"refresh_30min" = "۳۰ دقیقه"; +"refresh_adaptive" = "تطبیقی"; +"refresh_adaptive_agent_aware" = "تطبیقی (آگاه از عامل)"; +"adaptive_activity_consent_title" = "اجازه به تازه‌سازی آگاه از فعالیت؟"; +"adaptive_activity_consent_message" = "حالت تطبیقی آگاه از عامل می‌تواند برای شناسایی Codex و Claude فهرست فرایندهای محلی در حال اجرا، از جمله خط‌های فرمان، را بررسی کند و هنگام کدنویسی هر ۳۰ ثانیه فرادادهٔ نشست‌های شناخته‌شده را بخواند. وقتی Agent Sessions خاموش است، CodexBar فقط زمان آخرین فعالیت را در حافظه نگه می‌دارد و مسیرها و هویت‌های نشست را دور می‌ریزد. این داده به هیچ‌جا ارسال نمی‌شود و شناسایی راه‌دور و SSH خاموش می‌مانند. اگر رد کنید، CodexBar بدون پویش فعالیت محلی به حالت تطبیقی عادی بازمی‌گردد."; +"adaptive_activity_consent_allow" = "اجازه به فعالیت محلی"; +"adaptive_activity_consent_decline" = "استفاده از حالت تطبیقی عادی"; + +/* Additional keys */ +"not_found" = "پیدا نشد"; + +/* Cost estimation */ +"cost_estimate_hint" = "برآورد شده از چوب های محلی · ممکن است با صورتحساب شما متفاوت باشد"; +"codex_api_estimate_hint" = "برآوردشده از مصرف توکن · صورتحساب اشتراک نیست"; +"cost_data_explanation" = "هزینه‌ها ممکن است توسط ارائه‌دهنده گزارش شوند یا بر اساس مصرف توکن و قیمت‌های عمومی API برآورد شوند. برآوردها هزینه اشتراک نیستند."; +"No JetBrains IDE with AI Assistant detected. Install a JetBrains IDE and enable AI Assistant." = "هیچ JetBrains IDE ای با AI Assistant شناسایی نشد. یک JetBrains IDE نصب کنید و AI Assistant را فعال کنید."; +"OpenRouter API token not configured. Set OPENROUTER_API_KEY environment variable or configure in Settings." = "OpenRouter API توکن پیکربندی نشده است. متغیر محیطی OPENROUTER_API_KEY تنظیم کنید یا در تنظیمات پیکربندی کنید."; +"z.ai API token not found. Set apiKey in ~/.codexbar/config.json or Z_AI_API_KEY." = "z.ai API توکن پیدا نشد. apiKey را در ~/.codexbar/config.json یا Z_AI_API_KEY تنظیم کنید."; +"Missing DeepSeek API key." = "کلید DeepSeek API گم شده."; +"%@ is unavailable in the current environment." = "%@ در شرایط فعلی در دسترس نیست."; +"All Systems Operational" = "تمام سیستم ها عملیاتی هستند"; +"Last 30 days" = "۳۰ روز آخر"; +"Last 30 days:" = "۳۰ روز آخر:"; +"This month" = "این ماه"; +"Store multiple OpenAI API keys." = "چند کلید OpenAI API را ذخیره کنید."; +"Admin API key" = "کلید API مدیریت"; +"Open billing" = "صورتحساب باز"; +"Google accounts" = "Google حساب ها"; +"Store multiple Antigravity Google OAuth accounts for quick switching." = "چندین حساب Antigravity Google OAuth را برای تعویض سریع ذخیره کنید."; +"Add Google Account" = "افزودن Google حساب کاربری"; +"Open Token Plan" = "طرح توکن باز"; +"Text Generation" = "تولید متن"; +"Text to Speech" = "تبدیل متن به گفتار"; +"Music Generation" = "تولید موسیقی"; +"Image Generation" = "تولید تصویر"; +"No local data found" = "داده محلی یافت نشد"; +"Credits unavailable; keep Codex running to refresh." = "اعتبارها در دسترس نیست؛ Codex را روشن نگه دارید تا تازه شوید."; +"No available fetch strategy for minimax." = "هیچ استراتژی جمع آوری برای مینیمکس در دسترس نیست."; +"No Cursor session found. Please log in to cursor.com in Safari, Chrome, Microsoft Edge, Brave, Arc, Dia, ChatGPT Atlas, Chromium, Helium, Vivaldi, Yandex Browser, Firefox, Zen, Colibri, Sidekick, Opera, Opera GX, or Edge Canary. If you use Safari, grant CodexBar Full Disk Access in System Settings ▸ Privacy & Security. You can also sign in to Cursor from the CodexBar menu (Add / switch account)." = "جلسه Cursor پیدا نشد. لطفا در Safari، Chrome، مایکروسافت اج، بریو، آرک، دیا، چت جی پی تی اطلس، کرومیوم، هلیوم، ویوالدی، مرورگر یاندکس، فایرفاکس، زن، کولیبری، سایدکیک، اپرا، اپرا جی ایکس یا اج کنری وارد cursor.com شوید. اگر از Safari استفاده می کنید، به CodexBar دسترسی کامل دیسک در تنظیمات سیستم ▸ حریم خصوصی و امنیت اعطا کنید. همچنین می توانید از منوی CodexBar (افزودن / تغییر حساب) وارد Cursor شوید."; +"No OpenCode session cookies found in browsers." = "هیچ کوکی نشست OpenCode در مرورگرها یافت نمی شود."; +"No available fetch strategy for %@." = "استراتژی جمع آوری برای %@ موجود نیست."; +"Today" = "امروز"; +"Today tokens" = "توکن های امروزی"; +"30d cost" = "هزینه 30d"; +"%@ cost" = "هزینه %@"; +"30d tokens" = "توکن های 30d"; +"Latest tokens" = "جدیدترین توکن ها"; +"Top model" = "مدل برتر"; +"Storage" = "ذخیره سازی"; +"Add Account..." = "افزودن حساب..."; +"Usage Dashboard" = "داشبورد استفاده"; +"Status Page" = "صفحه وضعیت"; +"Open Status Page" = "باز کردن صفحه وضعیت"; +"Settings..." = "محیط ها..."; +"About CodexBar" = "درباره CodexBar"; +"Quit" = "ترک کن"; +"Last %d day" = "روز %d گذشته"; +"Last %d days" = "%d روز آخر"; +"%@ tokens" = "توکن های %@"; +"Latest billing day" = "آخرین روز صورتحساب"; +"Latest billing day (%@)" = "آخرین روز صورتحساب (%@)"; +"%@ left" = "%@ باقی مانده"; +"Resets %@" = "ریست %@"; +"Resets in %@" = "ریست ها در %@"; +"Resets now" = "اکنون بازنشانی می شود"; +"reset_tomorrow_format" = "فردا، %@"; +"Lasts until reset" = "تا زمان ریست ادامه دارد"; +"1.5× headroom" = "حاشیه ۱٫۵×"; +"Updated %@" = "به روزرسانی %@"; +"Updated relative %@" = "به روزرسانی %@"; +"Updated absolute %@" = "به روزرسانی %@"; +"Updated %@h ago" = "%@h پیش به روزرسانی شده است"; +"Updated %@m ago" = "%@m پیش به روزرسانی شده"; +"Updated just now" = "همین الان به روزرسانی شد"; +"Projected empty in %@" = "خالی در %@"; +"Runs out in %@" = "در %@ تمام می شود"; +"Pace: %@" = "سرعت: %@"; +"Pace: %@ · %@" = "سرعت: %@ · %@"; +"%@ · %@" = "%@ · %@"; +"≈ %d%% run-out risk" = "≈ %d%% خطر تمام شدن"; +"%d%% in deficit" = "%d%% در کسری بودجه"; +"%d%% in reserve" = "%d%% در ذخیره"; +"usage_percent_suffix_left" = "باقی مانده"; +"usage_percent_suffix_used" = "استفاده شده"; +"Store multiple DeepSeek API keys." = "چند کلید DeepSeek API را ذخیره کنید."; +"This week" = "این هفته"; +"Week" = "هفته"; +"Month" = "ماه"; +"Models" = "مدل ها"; +"24h tokens" = "توکن های 24h"; +"Latest hour" = "آخرین ساعت"; +"Peak hour" = "ساعت اوج"; +"Top method" = "روش تاپ"; +"30d cash" = "30d پول نقد"; +"30d billing history from MiniMax web session" = "تاریخچه صورتحساب 30d از جلسه وب MiniMax"; +"AWS Cost Explorer billing can lag." = "AWS صورتحساب اکسپلورر هزینه ممکن است کند شود."; +"Rate limit: %d / %@" = "محدودیت نرخ: %d / %@"; +"Key remaining" = "کلید باقی مانده"; +"No limit set for the API key" = "هیچ محدودیتی برای کلید API تعیین نشده است"; +"API key limit unavailable right now" = "API محدودیت کلید در حال حاضر در دسترس نیست"; +"This month: %@ tokens" = "این ماه: توکن های %@"; +"No utilization data yet." = "هنوز داده ای برای استفاده وجود ندارد."; +"No %@ utilization data yet." = "هنوز داده های استفاده %@ نشده است."; +"%@: %@%% used" = "%@: استفاده %@%%"; +"%dd" = "%d روز"; +"today" = "امروز"; +"just now" = "همین الان"; +"On pace" = "روی سرعت"; +"Runs out now" = "الان تمام می شود"; +"Projected empty now" = "اکنون خالی پیش بینی شده است"; +"Switch Account..." = "حساب را عوض کن..."; +"Update ready, restart now?" = "به روزرسانی آماده ای؟ الان ریستارت می کنی؟"; +"Daily" = "روزانه"; +"Hourly Tokens" = "توکن های ساعتی"; +"No data" = "داده ای وجود ندارد"; +"No usage breakdown data available." = "هیچ داده ای درباره تقسیم بندی استفاده در دسترس نیست."; + +"Today: %@ · %@ tokens" = "امروز: %@ · توکن های %@"; +"Today: %@" = "امروز: %@"; +"Today: %@ tokens" = "امروز: توکن های %@"; +"Last 30 days: %@ · %@ tokens" = "۳۰ روز آخر: %@ · توکن های %@"; +"Last 30 days: %@" = "۳۰ روز آخر: %@"; +"Est. total (30d): %@" = "برآورد کل (30d): %@"; +"Est. total (%@): %@" = "برآورد کل (%@): %@"; +"Hover a bar for details" = "برای جزئیات بیشتر روی یک نوار نگه دارید"; +"%@: %@ · %@ tokens" = "%@: %@ · توکن های %@"; +"%@: %@" = "%@: %@"; +"No providers selected for Overview." = "هیچ ارائه دهنده ای برای مرور کلی انتخاب نشده است."; +"No overview data available." = "داده های کلی در دسترس نیست."; +"Auto uses the local IDE API first, then Google OAuth when the IDE is closed." = "Auto ابتدا از IDE API محلی استفاده می کند، سپس وقتی IDE بسته است Google OAuth می کند."; +"Login with Google" = "با Google وارد شوید"; + +/* Popup panels */ +"No usage configured." = "هیچ استفاده ای تنظیم نشده است."; +"Quota" = "سهمیه"; +"Daily quota" = "سهمیه روزانه"; +"Total" = "مجموع"; +"tokens" = "توکن ها"; +"requests" = "درخواست ها"; +"Latest" = "جدیدترین ها"; +"Monthly" = "ماهانه"; +"Sonnet" = "سونت"; +"Overages" = "اضافه هزینه ها"; +"Activity" = "فعالیت ها"; +"Copied" = "کپی شده"; +"Copy error" = "خطای کپی"; +"Copy path" = "مسیر کپی"; +"Extra usage spent" = "استفاده اضافی صرف شده"; +"Credits remaining" = "اعتبار باقی‌مانده"; +"Using CLI fallback" = "استفاده از CLI پشتیبان"; +"Balance updates in near-real time (up to 5 min lag)" = "به روزرسانی تعادل تقریبا در زمان واقعی (تا ۵ دقیقه تأخیر)"; +"Daily billing data finalizes at 07:00 UTC" = "داده های صورتحساب روزانه در ساعت ۰۷:۰۰ نهایی می شود UTC"; +"%@ of %@ credits left" = "%@ از %@ اعتبار باقی مانده"; +"%@ of %@ bonus credits left" = "%@ از %@ اعتبار اضافی باقی مانده"; +"%@ / %@ (%@ remaining)" = "%@ / %@ (%@ باقی مانده)"; +"%@/%@ left" = "%@/%@ باقی مانده"; +"Gemini Flash" = "Gemini فلش"; +"Regenerates %@" = "بازسازی %@"; +"used after next regen" = "استفاده شده بعد از بازسازی بعدی"; +"after next regen" = "پس از بازسازی بعدی"; +"Near full" = "تقریبا پر"; +"Full in ~1 regen" = "بازیابی کامل ~۱"; +"Full in ~%.0f regens" = "بازسازی های کامل ~%.0f"; +"Overage usage" = "استفاده بیش از حد"; +"Overage cost" = "هزینه اضافی"; +"credits" = "اعتبارات"; +"Zen balance" = "تعادل ذن"; +"API spend" = "API خرج می کنم"; +"Extra usage" = "استفاده اضافی"; +"Quota usage" = "استفاده از سهمیه"; +"Your spend" = "هزینه شما"; +"%.0f%% used" = "%.0f%% استفاده می شود"; +"Usage history (today)" = "تاریخچه استفاده (امروز)"; +"Usage history (%d days)" = "تاریخچه استفاده (%d روز)"; +"%d percent remaining" = "%d درصد باقی مانده"; +"Unknown" = "نامشخص"; +"stale data" = "داده های کهنه"; +"No credits history data." = "هیچ داده ای درباره سابقه اعتباری وجود ندارد."; +"No credits history data available." = "هیچ داده ای درباره تاریخچه اعتبارها در دسترس نیست."; +"Credits history chart" = "جدول تاریخچه اعتبارها"; +"%d days of credits data" = "%d روز داده های اعتباری"; +"Usage breakdown chart" = "جدول تقسیم بندی مصرف"; +"%d days of usage data across %d services" = "%d روز داده های استفاده در سرویس های %d"; +"Cost history chart" = "نمودار تاریخچه هزینه"; +"%d days of cost data" = "%d روز داده های هزینه"; +"Plan utilization chart" = "نمودار استفاده از برنامه"; +"%d utilization samples" = "نمونه های %d استفاده"; +"Hourly Usage" = "استفاده ساعتی"; +"Usage remaining" = "کاربرد باقی مانده"; +"Usage used" = "کاربرد مورد استفاده"; +"API key verified. Cloud quotas need browser cookies. Sign in to Ollama." = "کلید API تأیید شد. سهمیه‌های Cloud به کوکی‌های مرورگر نیاز دارند. وارد Ollama شوید."; +"Last 30 days: %@ tokens" = "۳۰ روز آخر: توکن های %@"; +"7d spend" = "7d خرج می کنم"; +"30d spend" = "30d خرج می کنم"; +"Cache read" = "خواندن کش"; +"Claude Admin API 30 day spend trend" = "Claude روند مدیریت API هزینه ۳۰ روزه"; +"OpenRouter API key spend trend" = "OpenRouter API روند کلیدی هزینه کرد"; +"z.ai hourly token trend" = "z.ai روند توکن ساعتی"; +"MiniMax 30 day token usage trend" = "MiniMax روند استفاده ۳۰ روزه از توکن"; +"Today cash" = "امروزه پول نقد"; +"DeepSeek 30 day token usage trend" = "DeepSeek روند استفاده ۳۰ روزه از توکن"; +"Detailed usage unavailable." = "جزئیات استفاده در دسترس نیست."; +"Sign in to DeepSeek Platform in Chrome for detailed usage." = "برای مشاهده جزئیات استفاده، در Chrome وارد DeepSeek Platform شوید."; +"Select a DeepSeek Chrome profile in Settings." = "یک نمایه Chrome دیپ‌سیک را در تنظیمات انتخاب کنید."; +"DeepSeek this month token usage trend" = "روند استفاده از توکن DeepSeek در این ماه"; +"Chrome profile" = "نمایه Chrome"; +"Choose which signed-in DeepSeek Platform session supplies detailed usage." = "انتخاب کنید کدام نشست واردشدهٔ DeepSeek Platform جزئیات استفاده را ارائه دهد."; +"Select profile…" = "انتخاب نمایه…"; +"cache-hit input" = "ورودی کش و ضربه"; +"cache-miss input" = "ورودی کش-خطا"; +"output" = "خروجی"; +"Requests" = "درخواست ها"; +"Reported by OpenAI Admin API organization usage." = "گزارش شده توسط مدیر OpenAI API استفاده سازمانی."; +"Reported by Mistral billing usage." = "بر اساس Mistral استفاده از صورتحساب گزارش شده است."; +"Google OAuth" = "Google OAuth"; +"Add accounts via GitHub OAuth Device Flow on the selected host." = "حساب ها را از طریق GitHub OAuth Device Flow روی میزبان انتخاب شده اضافه کنید."; +"Stores each signed-in Google account for quick Antigravity switching. Uses Antigravity.app OAuth when available, or ANTIGRAVITY_OAUTH_CLIENT_ID and ANTIGRAVITY_OAUTH_CLIENT_SECRET as an override." = "هر حساب کاربری وارد شده Google را برای تعویض سریع Antigravity ذخیره می کند. در صورت امکان از Antigravity.app OAuth استفاده می کند، یا ANTIGRAVITY_OAUTH_CLIENT_ID و ANTIGRAVITY_OAUTH_CLIENT_SECRET را به عنوان یک اورراید استفاده می کند."; +"Manual cleanup: past sessions" = "تمیزکاری دستی: جلسات قبلی"; +"Clearing removes past resume, continue, and rewind history." = "پاک سازی گذشته را حذف می کند، ادامه می دهد و تاریخ را به عقب برمی گرداند."; +"Manual cleanup: file checkpoints" = "پاک سازی دستی: نقاط کنترل فایل"; +"Clearing removes checkpoint restore data for previous edits." = "پاک کردن داده های بازیابی چک پوینت برای ویرایش های قبلی حذف می شود."; +"Manual cleanup: saved plans" = "پاک سازی دستی: نقشه های ذخیره شده"; +"Clearing removes old plan-mode files." = "پاک کردن فایل های قدیمی حالت طرح را حذف می کند."; +"Manual cleanup: debug logs" = "پاک سازی دستی: لاگ های اشکال زدایی"; +"Clearing removes past debug logs." = "پاک کردن لاگ های دیباگ گذشته را حذف می کند."; +"Manual cleanup: attachment cache" = "پاک سازی دستی: کش ضمیمه"; +"Clearing removes cached large pastes or attached images." = "پاک کردن پیست های بزرگ کش شده یا تصاویر پیوست شده را حذف می کند."; +"Manual cleanup: session metadata" = "پاک سازی دستی: فراداده نشست"; +"Clearing removes per-session environment metadata." = "پاک سازی متادیتای محیط به ازای هر جلسه را حذف می کند."; +"Manual cleanup: shell snapshots" = "پاک سازی دستی: عکس های فوری گلوله"; +"Clearing removes leftover runtime shell snapshot files." = "پاک کردن فایل های اسنپ شات شل زمان اجرا باقی مانده را حذف می کند."; +"Manual cleanup: legacy todos" = "پاک سازی دستی: کارهای میراثی"; +"Clearing removes legacy per-session task lists." = "پاک سازی فهرست وظایف قدیمی هر جلسه را حذف می کند."; +"Manual cleanup: sessions" = "پاک سازی دستی: جلسات"; +"Clearing removes past Codex session history." = "پاک سازی تاریخچه جلسه Codex را حذف می کند."; +"Manual cleanup: archived sessions" = "پاک سازی دستی: جلسات بایگانی شده"; +"Clearing removes archived Codex session history." = "پاک سازی تاریخچه نشست آرشیو شده Codex را حذف می کند."; +"Manual cleanup: cache" = "پاک سازی دستی: کش"; +"Clearing removes provider-owned cached data." = "پاک سازی داده های کش شده متعلق به ارائه دهنده را حذف می کند."; +"Manual cleanup: logs" = "پاک سازی دستی: لاگ ها"; +"Clearing removes local diagnostic logs." = "پاک سازی لاگ های تشخیصی محلی را حذف می کند."; +"Manual cleanup: file history" = "پاک سازی دستی: تاریخچه فایل"; +"Clearing removes local edit checkpoint history." = "پاک سازی تاریخچه چک پوینت ویرایش محلی را حذف می کند."; +"Manual cleanup: temporary data" = "پاک سازی دستی: داده های موقتی"; +"Clearing removes local temporary provider data." = "پاک سازی داده های ارائه دهنده موقت محلی را حذف می کند."; +"Total: %@" = "کل: %@"; +"%d more items" = "%d آیتم های بیشتر"; +"Other (%d items)" = "موارد دیگر (%d مورد)"; +"Expand" = "گسترش"; +"Collapse" = "جمع کردن"; +"Cleanup ideas" = "ایده های پاکسازی"; +"%d unreadable item(s) skipped" = "%d آیتم(های) غیرقابل خواندن رد شده اند"; + +"API key limit" = "محدودیت کلید API"; +"Auth" = "احراز هویت"; +"Auto" = "اتو"; +"Disabled — no recent data" = "غیرفعال — داده های اخیر وجود ندارد"; +"Limits not available" = "محدودیت ها در دسترس نیستند"; +"No usage yet" = "هنوز استفاده نشده"; +"Not fetched yet" = "هنوز نیامده"; +"Refreshing" = "تازه کننده"; +"Session" = "جلسه"; +"Source" = "منبع"; +"State" = "ایالت"; +"Unavailable" = "در دسترس نیست"; +"Weekly" = "هفتگی"; +"not detected" = "شناسایی نشد"; +"Estimated from local Codex logs for the selected account." = "برآورد شده از لاگ های محلی Codex حساب انتخاب شده."; +"minimax_usage_amount_format" = "استفاده: %@ / %@"; +"minimax_used_percent_format" = "%@ استفاده شده"; +"minimax_service_text_generation" = "تولید متن"; +"minimax_service_text_to_speech" = "تبدیل متن به گفتار"; +"minimax_service_music_generation" = "تولید موسیقی"; +"minimax_service_image_generation" = "تولید تصویر"; +"minimax_service_lyrics_generation" = "تولید اشعار"; +"minimax_service_coding_plan_vlm" = "برنامه کدنویسی VLM"; +"minimax_service_coding_plan_search" = "جستجوی برنامه کدنویسی"; + +/* Additional provider settings and alerts */ +"%@ is waiting for permission" = "%@ منتظر اجازه است"; +"%@ requests" = "درخواست های %@"; +"%@: %@ credits" = "%@: %@ اعتبار"; +"30d requests" = "درخواست های 30d"; +"4 days" = "۴ روز"; +"5 days" = "۵ روز"; +"7 days" = "۷ روز"; +"API key verifies Ollama Cloud access; cookies still expose quota limits." = "API کلید دسترسی Ollama ابری را تأیید می کند؛ کوکی ها هنوز محدودیت سهمیه را نشان می دهند."; +"AWS access key ID. Can also be set with AWS_ACCESS_KEY_ID." = "AWS شناسه کلید دسترسی داشته باشید. همچنین می توان آن را با AWS_ACCESS_KEY_ID تنظیم کرد."; +"AWS region. Can also be set with AWS_REGION." = "AWS منطقه. همچنین می توان آن را با AWS_REGION تنظیم کرد."; +"AWS secret access key. Can also be set with AWS_SECRET_ACCESS_KEY." = "AWS کلید دسترسی مخفی. همچنین می توان آن را با AWS_SECRET_ACCESS_KEY تنظیم کرد."; +"Access key ID" = "شناسه کلید دسترسی"; +"Add Account" = "افزودن حساب کاربری"; +"Adding Account…" = "اضافه کردن حساب..."; +"Antigravity login failed" = "ورود Antigravity ناموفق"; +"Antigravity login timed out" = "ورود Antigravity به پایان رسید"; +"Auth source" = "منبع احراز هویت"; +"Automatic imports browser cookies from Xiaomi MiMo." = "کوکی های مرورگر را به طور خودکار از شیائومی MiMo وارد می کند."; +"Automatic imports Windsurf session data from Chromium browser localStorage." = "وارد کردن خودکار داده های نشست Windsurf از مرورگر Chromium localStorage."; +"Automatic imports browser cookies from Bailian." = "کوکی های مرورگر را به طور خودکار از Bailian وارد می کند."; +"Automatically imports browser cookies." = "کوکی های مرورگر را به طور خودکار وارد می کند."; +"Automatically imports browser session cookies." = "کوکی های نشست مرورگر را به طور خودکار وارد می کند."; +"Azure OpenAI deployment name. AZURE_OPENAI_DEPLOYMENT_NAME is also supported." = "Azure OpenAI نام اعزام. AZURE_OPENAI_DEPLOYMENT_NAME نیز پشتیبانی می شود."; +"Azure OpenAI key" = "کلید Azure OpenAI"; +"Azure OpenAI resource endpoint. AZURE_OPENAI_ENDPOINT is also supported." = "Azure OpenAI نقطه پایانی منابع. AZURE_OPENAI_ENDPOINT نیز پشتیبانی می شود."; +"Base URL" = "پایگاه URL"; +"Base URL for the LLM-API-Key-Proxy instance." = "URL پایه برای نمونه LLM-API-Key-Proxy."; +"Browser cookies" = "کوکی های مرورگر"; +"Cap end" = "انتهای نهایی"; +"Cap start" = "شروع کپ"; +"Capacity End" = "پایان ظرفیت"; +"Capacity Start" = "شروع ظرفیت"; +"Changelog" = "فهرست تغییرات"; +"Choose the Moonshot/Kimi API host for international or China mainland accounts." = "میزبان Moonshot/Kimi API را برای حساب های بین المللی یا حساب های سرزمین اصلی چین انتخاب کنید."; +"CodexBar can't replace a system account that is signed in with an API key only setup." = "نمی CodexBar حساب سیستمی که فقط با کلید API وارد شده جایگزین شود."; +"CodexBar could not find saved auth for that account. Re-authenticate it and try again." = "CodexBar نتوانستم احراز هویت ذخیره شده آن حساب را پیدا کنم. دوباره احراز هویت کنید و دوباره تلاش کنید."; +"CodexBar could not read managed account storage. Recover the store before adding another account." = "CodexBar نمی توانست ذخیره سازی حساب مدیریت شده را بخواند. قبل از اضافه کردن حساب جدید، فروشگاه را بازیابی کنید."; +"CodexBar could not read saved auth for that account. Re-authenticate it and try again." = "CodexBar نمی توانستم احراز هویت ذخیره شده آن حساب را بخوانم. دوباره احراز هویت کنید و دوباره تلاش کنید."; +"CodexBar could not read the current system account on this Mac." = "CodexBar نمی توانستم حساب فعلی سیستم را روی این مک بخوانم."; +"CodexBar could not replace the live Codex auth on this Mac." = "CodexBar نتوانستم احراز هویت زنده Codex این مک را جایگزین کنم."; +"CodexBar could not safely preserve the current system account before switching." = "CodexBar نتوانستم حساب فعلی سیستم را قبل از تغییر به طور ایمن حفظ کنم."; +"CodexBar could not save the current system account before switching." = "CodexBar نتوانستم حساب فعلی سیستم را قبل از تغییر ذخیره کنم."; +"CodexBar could not update managed account storage." = "CodexBar نتوانست ذخیره سازی حساب مدیریت شده را به روزرسانی کند."; +"CodexBar found another managed account that already uses the current system account. Resolve the duplicate account before switching." = "CodexBar یک حساب مدیریت شده دیگر پیدا کردم که قبلا از حساب فعلی سیستم استفاده می کند. قبل از تغییر حساب، حساب تکراری را حل کنید."; +"CodexBar will ask macOS Keychain for “%@” so it can decrypt browser cookies and authenticate your account. Click OK to continue." = "CodexBar از macOS Keychain درخواست «%@» می کند تا بتواند کوکی های مرورگر را رمزگشایی کرده و حساب شما را احراز هویت کند. برای ادامه روی OK کلیک کنید."; +"CodexBar will ask macOS Keychain for the Claude Code OAuth token so it can fetch your Claude usage. Click OK to continue." = "CodexBar از macOS Keychain کد Claude توکن OAuth را درخواست می کند تا بتواند استفاده Claude شما را دریافت کند. برای ادامه روی OK کلیک کنید."; +"CodexBar will ask macOS Keychain for your Amp cookie header so it can fetch usage. Click OK to continue." = "CodexBar از macOS Keychain Amp هدر کوکی تان را می خواهید تا بتواند استفاده را دریافت کند. برای ادامه روی OK کلیک کنید."; +"CodexBar will ask macOS Keychain for your Augment cookie header so it can fetch usage. Click OK to continue." = "CodexBar از macOS Keychain Augment هدر کوکی تان را می خواهید تا بتواند استفاده را دریافت کند. برای ادامه روی OK کلیک کنید."; +"CodexBar will ask macOS Keychain for your Claude cookie header so it can fetch Claude web usage. Click OK to continue." = "CodexBar از macOS Keychain هدر کوکی Claude شما را می خواهید تا بتواند استفاده Claude وب را دریافت کند. برای ادامه روی OK کلیک کنید."; +"CodexBar will ask macOS Keychain for your Cursor cookie header so it can fetch usage. Click OK to continue." = "CodexBar از macOS Keychain Cursor هدر کوکی تان را می خواهید تا بتواند استفاده را دریافت کند. برای ادامه روی OK کلیک کنید."; +"CodexBar will ask macOS Keychain for your Factory cookie header so it can fetch usage. Click OK to continue." = "CodexBar از macOS Keychain Factory هدر کوکی تان را می خواهید تا بتواند استفاده را دریافت کند. برای ادامه روی OK کلیک کنید."; +"CodexBar will ask macOS Keychain for your GitHub Copilot token so it can fetch usage. Click OK to continue." = "CodexBar از macOS Keychain توکن GitHub Copilot شما را درخواست می کند تا بتواند استفاده را دریافت کند. برای ادامه روی OK کلیک کنید."; +"CodexBar will ask macOS Keychain for your Kimi auth token so it can fetch usage. Click OK to continue." = "CodexBar از macOS Keychain توکن احراز هویت Kimi می خواهید تا بتواند استفاده را دریافت کند. برای ادامه روی OK کلیک کنید."; +"CodexBar will ask macOS Keychain for your MiniMax API token so it can fetch usage. Click OK to continue." = "CodexBar از macOS Keychain توکن MiniMax API شما را درخواست می کند تا بتواند استفاده را دریافت کند. برای ادامه روی OK کلیک کنید."; +"CodexBar will ask macOS Keychain for your MiniMax cookie header so it can fetch usage. Click OK to continue." = "CodexBar از macOS Keychain MiniMax هدر کوکی تان را می خواهید تا بتواند استفاده را دریافت کند. برای ادامه روی OK کلیک کنید."; +"CodexBar will ask macOS Keychain for your OpenAI cookie header so it can fetch Codex dashboard extras. Click OK to continue." = "CodexBar از macOS Keychain OpenAI هدر کوکی تان را می خواهید تا اضافه های داشبورد Codex را دریافت کند. برای ادامه روی OK کلیک کنید."; +"CodexBar will ask macOS Keychain for your OpenCode cookie header so it can fetch usage. Click OK to continue." = "CodexBar از macOS Keychain OpenCode هدر کوکی تان را می خواهید تا بتواند استفاده را دریافت کند. برای ادامه روی OK کلیک کنید."; +"CodexBar will ask macOS Keychain for your Synthetic API key so it can fetch usage. Click OK to continue." = "CodexBar از macOS Keychain کلید Synthetic API می خواهید تا بتواند استفاده را دریافت کند. برای ادامه روی OK کلیک کنید."; +"CodexBar will ask macOS Keychain for your z.ai API token so it can fetch usage. Click OK to continue." = "CodexBar از macOS Keychain توکن z.ai API شما را درخواست می کند تا بتواند استفاده را دریافت کند. برای ادامه روی OK کلیک کنید."; +"Could not open Cursor login in your browser." = "نتوانستم مرورگر Cursor ورود را باز کنم."; +"Could not open browser for Antigravity" = "برای مدت Antigravity نتوانستم مرورگر را باز کنم"; +"Credits used" = "اعتبارات استفاده شده"; +"Day" = "روز"; +"Deployment" = "استقرار"; +"Drag to reorder" = "درگ برای بازآرایی"; +"Sort providers alphabetically" = "مرتب‌سازی ارائه‌دهندگان بر اساس حروف الفبا"; +"Sort providers alphabetically (enabled first)" = "مرتب‌سازی الفبایی ارائه‌دهندگان (فعال‌ها ابتدا)"; +"Sorted alphabetically (enabled first) — click to use your custom order" = "مرتب‌شده بر اساس حروف الفبا (فعال‌ها ابتدا) — برای استفاده از ترتیب سفارشی کلیک کنید"; +"Endpoint" = "نقطه پایان"; +"Enterprise host" = "میزبان سازمانی"; +"Extra usage balance: %@" = "تعادل استفاده اضافی: %@"; +"Keychain Access Required" = "دسترسی Keychain مورد نیاز است"; +"keychain_prompt_learn_more" = "بیشتر بدانید…"; +"keychain_prompt_privacy_note" = "macOS، نه CodexBar، ورود گذرواژهٔ Mac را مدیریت می‌کند. می‌توانید دسترسی به Keychain را هر زمان از تنظیمات ← پیشرفته غیرفعال کنید."; +"Kiro menu bar value" = "Kiro مقدار نوار منو"; +"Label" = "برچسب"; +"No organizations loaded. Click Refresh after setting your API key." = "هیچ سازمانی بارگذاری نشده بود. پس از تنظیم کلید API روی «تازه سازی» کلیک کنید."; +"No output captured." = "هیچ خروجی ای ضبط نشد."; +"No system account" = "بدون حساب سیستمی"; +"Oasis-Token" = "اوسیس-توکن"; +"Open Augment (Log Out & Back In)" = "Augment باز (خروج و ورود دوباره)"; +"Open Codebuff Dashboard" = "داشبورد Open Codebuff"; +"Open Command Code Settings" = "تنظیمات باز Command Code"; +"Open Crof dashboard" = "داشبورد Open Crof"; +"Open Manus" = "Manus باز"; +"Open MiMo Balance" = "تعادل باز MiMo"; +"Open Moonshot Console" = "کنسول Moonshot باز"; +"Open Ollama API Keys" = "کلیدهای باز Ollama API"; +"Open StepFun Platform" = "پلتفرم StepFun باز"; +"Open T3 Chat Settings" = "تنظیمات باز T3 Chat"; +"Open Volcengine Ark Console" = "کنسول Open Volcengine Ark"; +"Open legacy provider docs" = "مستندات ارائه دهنده قدیمی باز"; +"Open projects" = "پروژه های باز"; +"Open this URL manually to continue login:\n\n%@" = "این URL را به صورت دستی باز کنید تا ورود ادامه یابد \n\n%@"; +"Optional organization ID for accounts linked to multiple Anthropic organizations." = "شناسه سازمان اختیاری برای حساب هایی که به چندین سازمان انسان شناس متصل هستند."; +"Optional. Applies to the configured Admin API key; selected token accounts do not inherit OPENAI_PROJECT_ID." = "اختیاری. روی کلید API مدیر پیکربندی شده اعمال می شود؛ حساب های توکن منتخب OPENAI_PROJECT_ID را به ارث نمی برند."; +"Optional. Enter your GitHub Enterprise host, for example octocorp.ghe.com. Leave blank for github.com." = "اختیاری. برای مثال، میزبان GitHub Enterprise شما وارد octocorp.ghe.com. برای github.com خالی بگذارید."; +"Optional. Leave blank to discover and aggregate projects visible to the API key." = "اختیاری. برای کشف و تجمیع پروژه هایی که با کلید API قابل مشاهده هستند، صفحه خالی بگذارید."; +"Org ID (optional)" = "شناسه سازمان (اختیاری)"; +"Organizations" = "سازمان ها"; +"Organization ID" = "شناسه سازمان"; +"Password" = "رمز عبور"; +"%@ authentication is disabled." = "%@ احراز هویت غیرفعال است."; +"%@ cookies are disabled." = "%@ کوکی ها غیرفعال هستند."; +"%@ web API access is disabled." = "دسترسی %@ وب API غیرفعال است."; +"Disable %@ dashboard cookie usage." = "استفاده %@ از کوکی داشبورد را غیرفعال کنید."; +"Keychain access is disabled in Advanced, so browser cookie import is unavailable." = "دسترسی Keychain در حالت پیشرفته غیرفعال است، بنابراین وارد کردن کوکی مرورگر در دسترس نیست."; +"Manually paste an %@ from a browser session." = "یک %@ را به صورت دستی از یک جلسه مرورگر پیست کنید."; +"Paste a Cookie header captured from %@." = "یک هدر کوکی که از %@ گرفته شده بچسبان."; +"Paste a Cookie header from %@." = "یک هدر کوکی از %@ بچسبان."; +"Paste a Cookie header or cURL capture from %@." = "یک هدر کوکی یا cURL را از %@ کپچر بچسبانید."; +"Paste a Cookie header or full cURL capture from %@." = "یک هدر کوکی یا ضبط کامل cURL از %@ را بچسبانید."; +"Paste a Cookie or Authorization header from %@." = "یک هدر کوکی یا مجوز از %@ بچسبانید."; +"Paste a full cookie header or the %@ value." = "یک هدر کامل کوکی یا مقدار %@ را بچسبانید."; +"Paste a Cookie header or full cURL capture from T3 Chat settings." = "یک هدر کوکی یا ضبط کامل CURL از تنظیمات T3 Chat بچسبان."; +"Paste the Cookie header from a request to admin.mistral.ai. Must contain an ory_session_* cookie." = "هدر کوکی را از درخواست به admin.mistral.ai بچسبانید. باید یک کوکی ory_session_* داشته باشد."; +"Paste the Oasis-Token from a logged-in browser session on platform.stepfun.com." = "Oasis-Token را از یک نشست مرورگر وارد شده در platform.stepfun.com بچسبانید."; +"Paste the %@ JSON bundle from %@." = "بسته %@ JSON را از %@ پیست کنید."; +"Paste the %@ value or a full Cookie header." = "مقدار %@ یا یک هدر کامل کوکی را بچسبانید."; +"Personal account" = "حساب شخصی"; +"Project ID" = "شناسه پروژه"; +"Re-auth" = "تجدید احراز هویت"; +"Re-login at claude.ai" = "ورود مجدد در claude.ai"; +"Re-authenticating…" = "احراز هویت مجدد..."; +"Refresh Session" = "جلسه تازه سازی"; +"Refresh organizations" = "سازمان های تازه سازی"; +"Region" = "منطقه"; +"Reload" = "بارگذاری مجدد"; +"Reorder" = "بازآرایی"; +"Secret access key" = "کلید دسترسی مخفی"; +"Series" = "سری ها"; +"Service" = "خدمت"; +"Show or hide Kiro credits, percent, or both next to the menu bar icon." = "Kiro اعتبار، درصد یا هر دو را کنار آیکون نوار منو نمایش یا پنهان کنید."; +"Show usage for organizations you belong to. Personal account is always shown." = "استفاده از سازمان هایی که عضو آن هستید را نشان دهید. حساب شخصی همیشه نمایش داده می شود."; +"Sign in to cursor.com in your browser, then refresh Cursor in CodexBar." = "در مرورگر خود وارد cursor.com شوید، سپس Cursor را در CodexBar تازه سازی کنید."; +"Simulated error text" = "متن خطای شبیه سازی شده"; +"StepFun platform account (phone number or email)." = "StepFun حساب پلتفرم (شماره تلفن یا ایمیل)."; +"Stored in ~/.codexbar/config.json." = "ذخیره شده در ~/.codexbar/config.json."; +"Stored in ~/.codexbar/config.json. AZURE_OPENAI_API_KEY is also supported." = "ذخیره سازی در ~/.codexbar/config.json. AZURE_OPENAI_API_KEY نیز پشتیبانی می شود."; +"Stored in ~/.codexbar/config.json. For the official Kimi API, use Moonshot / Kimi API." = "ذخیره شده در ~/.codexbar/config.json. برای Kimi API رسمی، از Moonshot / Kimi API استفاده کنید."; +"Stored in ~/.codexbar/config.json. Get your API key from the Volcengine Ark console." = "ذخیره شده در ~/.codexbar/config.json. کلید API خود را از کنسول Volcengine Ark دریافت کنید."; +"Stored in ~/.codexbar/config.json. Get your key from Ollama settings." = "ذخیره شده در ~/.codexbar/config.json. کلید خود را از تنظیمات Ollama بگیرید."; +"Stored in ~/.codexbar/config.json. Get your key from console.deepgram.com." = "ذخیره شده در ~/.codexbar/config.json. کلیدت را از console.deepgram.com بگیر."; +"Stored in ~/.codexbar/config.json. Get your key from elevenlabs.io/app/settings/api-keys." = "ذخیره شده در ~/.codexbar/config.json. کلید خود را از elevenlabs.io/app/settings/api-keys. دریافت کنید"; +"Stored in ~/.codexbar/config.json. Get your key from openrouter.ai/settings/keys and set a key spending limit there to enable API key quota tracking." = "ذخیره شده در ~/.codexbar/config.json. کلید خود را از openrouter.ai/settings/keys بگیرید و یک محدودیت هزینه کلید آنجا تعیین کنید تا ردیابی سهمیه کلید فعال API."; +"Stored in ~/.codexbar/config.json. In Warp, open Settings > Platform > API Keys, then create one." = "ذخیره شده در ~/.codexbar/config.json. در Warp، تنظیمات را > Platform > API Keys باز کنید و سپس یکی بسازید."; +"Stored in ~/.codexbar/config.json. Metrics require Groq Enterprise Prometheus access." = "ذخیره سازی در معیارهای ~/.codexbar/config.json. نیازمند دسترسی Groq پرومتئوس سازمانی است."; +"Stored in ~/.codexbar/config.json. OPENAI_ADMIN_KEY is preferred; OPENAI_API_KEY still works." = "نگهداری در ~/.codexbar/config.json. OPENAI_ADMIN_KEY ترجیح داده می شود؛ OPENAI_API_KEY هنوز کار می کند."; +"Stored in ~/.codexbar/config.json. Requires an Anthropic Admin API key." = "ذخیره سازی در ~/.codexbar/config.json. نیاز به کلید API مدیریت انسان شناسی دارد."; +"Stored in ~/.codexbar/config.json. Used for /v1/quota-stats." = "ذخیره شده در ~/.codexbar/config.json. برای /v1/quota-stats."; +"Stored in ~/.codexbar/config.json. You can also provide CODEBUFF_API_KEY or let CodexBar read ~/.config/manicode/credentials.json (created by `codebuff login`)." = "ذخیره شده در ~/.codexbar/config.json. همچنین می توانید CODEBUFF_API_KEY ارائه دهید یا اجازه دهید CodexBar ~/.config/manicode/credentials.json (ایجاد شده توسط `codebuff login`) را بخوانند."; +"Stored in ~/.codexbar/config.json. You can also provide CROF_API_KEY." = "ذخیره شده در ~/.codexbar/config.json. همچنین می توانید CROF_API_KEY ارائه دهید."; +"Stored in ~/.codexbar/config.json. You can also provide KILO_API_KEY or ~/.local/share/kilo/auth.json (kilo.access)." = "ذخیره شده در ~/.codexbar/config.json. همچنین می توانید KILO_API_KEY یا ~/.local/share/kilo/auth.json (kilo.access) ارائه دهید."; +"T3 Chat cookie" = "T3 Chat کوکی"; +"Team mode" = "حالت تیم"; +"That account is no longer available in CodexBar. Refresh the account list and try again." = "آن حساب دیگر در CodexBar در دسترس نیست. لیست حساب ها را تازه کنید و دوباره تلاش کنید."; +"The browser login did not complete in time. Try Antigravity login again." = "ورود به مرورگر به موقع کامل نشد. دوباره Antigravity حساب کاربری امتحان کن."; +"Timed out waiting for Cursor login. %@" = "زمان خروج منتظر ورود Cursor هستم. %@"; +"Timed out waiting for Cursor login. %@ Last error: %@" = "زمان خروج منتظر ورود Cursor هستم. %@ آخرین خطا: %@"; +"Today requests" = "درخواست های امروز"; +"Total (30d): %@ credits" = "مجموع (30d): %@ اعتبار"; +"Username" = "نام کاربری"; +"Uses username + password to login and obtain an Oasis-Token automatically." = "برای ورود و دریافت خودکار توکن اوسیس از نام کاربری + رمز عبور استفاده می کند."; +"Uses username + password to login and obtain an %@ automatically." = "از نام کاربری + رمز عبور برای ورود و دریافت خودکار %@ استفاده می کند."; +"Utilization End" = "پایان استفاده"; +"Utilization Start" = "شروع استفاده"; +"Verbosity" = "پرسۆزی"; +"Windsurf session JSON bundle" = "Windsurf جلسه JSON بسته"; +"Workspace ID" = "شناسه فضای کاری"; +"Your StepFun platform password. Used to login and obtain a session token." = "رمز عبور پلتفرم StepFun شما. برای ورود و دریافت توکن نشست استفاده می شود."; +"claude /login exited with status %d." = "کلود با %d جایگاه /login خارج شد."; +"codex login exited with status %d." = "ورود به کدکس با وضعیت %d خارج شد."; +"Cookie: …\n\nor paste a cURL capture from the Abacus AI dashboard" = "کوکی: ... \n\n یا کپچر cURL را از داشبورد Abacus AI پیست کنید"; +"Cookie: …\n\nor paste the __Secure-next-auth.session-token value" = "کوکی: ... \n\n یا مقدار توکن __Secure-next-auth.session-token را جای گذاری کنید"; +"Cookie: …\n\nor paste the kimi-auth token value" = "کوکی: ... \n\n یا مقدار توکن kimi-authentic را پیست کنید"; +"session_id=...\n\nor paste just the session_id value" = "session_id=... \n\n یا فقط مقدار session_id را بچسباند"; +"Clear" = "پاک است"; +"No matching providers" = "هیچ ارائه دهنده تطبیقی وجود ندارد"; +"Search providers" = "ارائه دهندگان جستجو"; + +"language_vietnamese" = "ویتنامی ها"; +"language_indonesian" = "زبان اندونزی"; + +"Request quota: %@ / %@" = "سهمیه درخواستی: %@ / %@"; +"language_arabic" = "العربية"; +"language_persian" = "فارسی"; +"language_thai" = "ไทย"; +"language_galician" = "Galego"; +"Limit Reset Credits" = "اعتبارهای بازنشانی محدودیت"; +"1 available" = "۱ مورد موجود"; +"%d available" = "%d مورد موجود"; +"Next expires %@" = "مورد بعدی در %@ منقضی می‌شود"; +"Expires %@" = "انقضا %@"; +"No expiry" = "بدون انقضا"; +"byte_unit_byte" = "بایت"; +"byte_unit_bytes" = "بایت"; +"byte_unit_kilobyte" = "کیلوبایت"; +"byte_unit_kilobytes" = "کیلوبایت"; +"byte_unit_megabyte" = "مگابایت"; +"byte_unit_megabytes" = "مگابایت"; +"byte_unit_gigabyte" = "گیگابایت"; +"byte_unit_gigabytes" = "گیگابایت"; + +/* Settings sidebar redesign */ +"Enable" = "فعال‌سازی"; +"Disable" = "غیرفعال‌سازی"; +"providers_on_count" = "%d فعال"; +"section_cost_summary" = "خلاصه هزینه"; +"section_command_line" = "خط فرمان"; +"section_privacy" = "حریم خصوصی"; +"section_diagnostics" = "عیب‌یابی"; +"section_updates" = "به‌روزرسانی‌ها"; +"section_links" = "پیوندها"; +"Show Codex Spark usage" = "نمایش استفاده از Codex Spark"; +"Shows Codex Spark quota rows in the menu and provider preview. Requires optional credits and extra usage in Display settings." = "ردیف‌های سهمیه Codex Spark را در منو و پیش‌نمایش ارائه‌دهنده نمایش می‌دهد. لازم است «نمایش اعتبارها + استفاده اضافی» در تنظیمات نمایش فعال باشد."; +"Show Daily Routines usage" = "نمایش استفاده از روال روزانه"; +"Shows the Daily Routines quota row in the menu and provider preview. Requires optional credits and extra usage in Display settings." = "ردیف سهمیه روال روزانه را در منو و پیش‌نمایش ارائه‌دهنده نمایش می‌دهد. لازم است «نمایش اعتبارها + استفاده اضافی» در تنظیمات نمایش فعال باشد."; +"Scroll to see more models" = "برای دیدن مدل‌های بیشتر پیمایش کنید"; +/* Shareable usage card */ +"Copy Image" = "کپی تصویر"; +"Copy Stats" = "کپی آمار"; +"Could not copy image" = "تصویر کپی نشد"; +"Image copied" = "تصویر کپی شد"; +"Image saved" = "تصویر ذخیره شد"; +"Nothing is uploaded. This image is created on your Mac." = "چیزی بارگذاری نمی‌شود. این تصویر روی Mac شما ساخته می‌شود."; +"Save..." = "ذخیره..."; +"Share AI Usage" = "اشتراک‌گذاری مصرف هوش مصنوعی"; +"Share Stats…" = "اشتراک‌گذاری آمار…"; +"Stats copied" = "آمار کپی شد"; +"Finish switching to a different Cursor account in your browser, then try again." = "تغییر به یک حساب Cursor دیگر را در مرورگر کامل کنید، سپس دوباره تلاش کنید."; +"Timed out waiting for Cursor account switch. %@" = "مهلت انتظار برای تغییر حساب Cursor به پایان رسید. %@"; +"Timed out waiting for Cursor account switch. %@ Last error: %@" = "مهلت انتظار برای تغییر حساب Cursor به پایان رسید. %@ آخرین خطا: %@"; + +/* Spend dashboard */ +"tab_usage_spend" = "مصرف و هزینه"; +"Usage & Spend" = "مصرف و هزینه"; +"Local estimated cost history across supported providers." = "تاریخچه برآورد هزینه محلی در ارائه‌دهندگان پشتیبانی‌شده."; +"Time range" = "بازه زمانی"; +"Track costs" = "پیگیری هزینه‌ها"; +"Cost tracking is off" = "پیگیری هزینه خاموش است"; +"Turn on Track costs to build local estimates." = "برای ایجاد برآوردهای محلی، «پیگیری هزینه‌ها» را روشن کنید."; +"No local cost history yet" = "هنوز تاریخچه هزینه محلی وجود ندارد"; +"Turn on cost tracking or refresh after using a supported provider." = "پیگیری هزینه را روشن کنید یا پس از استفاده از یک ارائه‌دهنده پشتیبانی‌شده تازه‌سازی کنید."; +"Refresh failures" = "خطاهای تازه‌سازی"; +"Native currencies stay separate; Codex account rows exclude Pi session history." = "ارزهای اصلی جدا نگه داشته می‌شوند؛ ردیف‌های حساب Codex تاریخچه نشست‌های Pi را دربر نمی‌گیرند."; +"Spend unavailable" = "هزینه در دسترس نیست"; +"Model breakdown unavailable" = "تفکیک مدل در دسترس نیست"; +"Local estimated history" = "تاریخچه برآورد محلی"; +"Coverage" = "پوشش"; +"Estimated spend" = "برآورد هزینه"; +"Tracked tokens" = "توکن‌های پیگیری‌شده"; +"Subscriptions" = "اشتراک‌ها"; +"By subscription" = "بر اساس اشتراک"; +"No model-level history" = "تاریخچه‌ای در سطح مدل وجود ندارد"; +"Daily estimated spend" = "برآورد هزینه روزانه"; +"≈%d full 5h windows of weekly left · %d windows until reset" = "حدود %d بازه کامل ۵ ساعته از سهم هفتگی مانده · %d بازه تا بازنشانی"; +"Weekly cannot run out before reset at this pace" = "با این روند، سهم هفتگی پیش از بازنشانی تمام نمی‌شود"; +"Weekly can run out ≈%d windows early" = "سهم هفتگی ممکن است حدود %d بازه زودتر تمام شود"; +"Estimated: %@" = "برآوردی: %@"; +"session_quota_estimate_value_format" = "%1$@ %2$@"; +"session quota" = "سهمیه جلسه"; +"session quotas" = "سهمیه‌های جلسه"; +"Coding Plan" = "طرح کدنویسی"; +"Agent Plan" = "طرح عامل"; +"Team" = "تیم"; + +/* Menu bar layout editor */ +"menu_bar_layout_title" = "چیدمان"; +"menu_bar_layout_footer" = "نشانه‌ها را برای چیدمان نوار منو بکشید. برای افزودن روی نشانه کلیک کنید؛ نشانهٔ قرارگرفته را انتخاب کنید و برای حذف Delete را بزنید."; +"menu_bar_layout_group_identity" = "هویت"; +"menu_bar_layout_group_usage" = "کاربرد"; +"menu_bar_layout_group_time" = "زمان"; +"menu_bar_layout_group_money" = "هزینه"; +"menu_bar_layout_group_structure" = "ساختار"; +"menu_bar_layout_scope_all" = "همهٔ ارائه‌دهندگان"; +"menu_bar_layout_scope_help" = "چیدمان پیش‌فرض را ویرایش کنید یا برای یک ارائه‌دهنده بازنویسی کنید."; +"menu_bar_layout_use_all" = "استفاده از چیدمان همهٔ ارائه‌دهندگان"; +"menu_bar_layout_preset" = "پیش‌تنظیم چیدمان"; +"menu_bar_layout_preset_icon_percent" = "نماد و درصد"; +"menu_bar_layout_preset_icon_only" = "فقط نماد"; +"menu_bar_layout_preset_percent_reset" = "درصد و بازنشانی"; +"menu_bar_layout_preset_compact_stacked" = "پشتهٔ فشرده"; +"menu_bar_layout_preset_custom" = "عرف"; +"menu_bar_layout_live_preview" = "پیش‌نمایش زنده"; +"menu_bar_layout_strip" = "نوار منو"; +"menu_bar_layout_remove_line_break" = "حذف شکست خط"; +"menu_bar_layout_chip_hint" = "انتخاب کنید، برای مرتب‌سازی بکشید یا از عمل حذف استفاده کنید."; +"menu_bar_layout_palette_hint" = "برای افزودن کلیک کنید یا به چیدمان بکشید."; +"menu_bar_layout_empty_line" = "نشانه را اینجا رها کنید"; +"menu_bar_layout_line" = "خط %d"; +"menu_bar_layout_drag_remove" = "برای حذف به اینجا بکشید"; +"menu_bar_layout_size" = "اندازه"; +"menu_bar_layout_size_small" = "کوچک"; +"menu_bar_layout_size_regular" = "معمولی"; +"menu_bar_layout_gap" = "فاصله"; +"menu_bar_layout_gap_tight" = "فشرده"; +"menu_bar_layout_gap_regular" = "معمولی"; +"menu_bar_layout_keyboard_hint" = "Delete نشانهٔ انتخاب‌شده را حذف می‌کند"; +"menu_bar_layout_sample_account" = "حساب"; +"menu_bar_layout_sample_runs_out" = "جمعه تمام می‌شود"; +"menu_bar_layout_token_icon" = "آیکون"; +"menu_bar_layout_token_provider" = "نام ارائه‌دهنده"; +"menu_bar_layout_token_account" = "حساب"; +"menu_bar_layout_token_session" = "جلسه %"; +"menu_bar_layout_token_weekly" = "هفتگی %"; +"menu_bar_layout_token_auto" = "درصد خودکار"; +"menu_bar_layout_token_bar" = "نوار مصرف"; +"menu_bar_layout_token_resets_in" = "بازنشانی در"; +"menu_bar_layout_token_reset_at" = "بازنشانی در ساعت"; +"menu_bar_layout_token_runs_out" = "تمام می‌شود"; +"menu_bar_layout_token_cost_today" = "هزینهٔ امروز"; +"menu_bar_layout_token_cost_30d" = "هزینهٔ ۳۰ روز"; +"menu_bar_layout_token_space" = "فاصله"; +"menu_bar_layout_token_line_break" = "شکست خط"; +"menu_bar_layout_token_separator_accessibility" = "نقطهٔ جداکننده"; + +/* Menu bar layout accessibility */ +"Icon unavailable" = "آیکون: در دسترس نیست"; +"%@ icon" = "%@: آیکون"; +"Provider name unavailable" = "نام ارائه‌دهنده: در دسترس نیست"; +"Account unavailable" = "حساب: در دسترس نیست"; +"%@ unavailable" = "%@: در دسترس نیست"; +"%@ %@" = "%@ %@"; +"Usage bar unavailable" = "نوار مصرف: در دسترس نیست"; +"Usage bar, %d of 3 filled" = "نوار مصرف: %d/3 پر"; +"Reset countdown unavailable" = "بازنشانی در: در دسترس نیست"; +"Reset time unavailable" = "بازنشانی در ساعت: در دسترس نیست"; +"Run-out estimate unavailable" = "تمام می‌شود: در دسترس نیست"; +"Cost today unavailable" = "هزینهٔ امروز: در دسترس نیست"; +"30-day cost unavailable" = "هزینهٔ ۳۰ روز: در دسترس نیست"; +"Resets" = "بازنشانی‌ها"; diff --git a/Sources/CodexBar/Resources/fa.lproj/Localizable.stringsdict b/Sources/CodexBar/Resources/fa.lproj/Localizable.stringsdict new file mode 100644 index 0000000000..73d56131bf --- /dev/null +++ b/Sources/CodexBar/Resources/fa.lproj/Localizable.stringsdict @@ -0,0 +1,49 @@ + + + + + ≈%d full 5h windows of weekly left · %d windows until reset + + NSStringLocalizedFormatKey + %#@left@ · %#@until@ + left + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + d + one + حدود %d بازه کامل ۵ ساعته از سهم هفتگی مانده + other + حدود %d بازه کامل ۵ ساعته از سهم هفتگی مانده + + until + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + d + one + %d بازه تا بازنشانی + other + %d بازه تا بازنشانی + + + Weekly can run out ≈%d windows early + + NSStringLocalizedFormatKey + %#@early@ + early + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + d + one + سهم هفتگی ممکن است حدود %d بازه زودتر تمام شود + other + سهم هفتگی ممکن است حدود %d بازه زودتر تمام شود + + + + diff --git a/Sources/CodexBar/Resources/fr.lproj/Localizable.strings b/Sources/CodexBar/Resources/fr.lproj/Localizable.strings new file mode 100644 index 0000000000..cd53bef01e --- /dev/null +++ b/Sources/CodexBar/Resources/fr.lproj/Localizable.strings @@ -0,0 +1,1353 @@ +/* English localization for CodexBar (base/fallback) */ + +"tab_hooks" = "Hooks"; +"hooks_enable_title" = "Activer les hooks"; +"hooks_enable_subtitle" = "Exécute des commandes externes lors d’événements de quota ou de fournisseur."; +"hooks_trust_warning" = "Les hooks peuvent exécuter des commandes locales sur votre Mac. Ne configurez que des commandes fiables."; +"hooks_rules_header" = "Règles"; +"hooks_empty" = "Aucun hook configuré."; +"hooks_add_rule" = "Ajouter une règle"; +"hooks_delete_rule" = "Supprimer la règle"; +"hooks_rule_enabled" = "Activé"; +"hooks_event" = "Événement"; +"hooks_provider" = "Fournisseur"; +"hooks_any_provider" = "Tout fournisseur"; +"hooks_threshold" = "Déclencher à l’utilisation ≥"; +"hooks_threshold_placeholder" = "90"; +"hooks_executable_placeholder" = "/usr/local/bin/my-command"; +"hooks_arguments_placeholder" = "Arguments"; +"hooks_argument_placeholder" = "Argument"; +"hooks_add_argument" = "Ajouter un argument"; +"hooks_delete_argument" = "Supprimer l’argument"; + +"ollama_safari_cookie_access_hint" = "Les cookies Safari nécessitent l’accès complet au disque pour CodexBar (Réglages Système > Confidentialité et sécurité)."; +"ollama_browser_cookie_decryption_denied" = "Le déchiffrement des cookies %@ a été refusé dans le Trousseau ; réessayez avec une actualisation manuelle."; +"ollama_browser_cookie_decryption_disabled" = "Le déchiffrement des cookies %@ est désactivé dans CodexBar ; activez l’accès au Trousseau et actualisez."; + +" providers" = " fournisseurs"; +"(System)" = "(System)"; +"30d" = "30d"; +"7d" = "7d"; +"A managed Codex login is already running. Wait for it to finish before adding " = "Une connexion Codex gérée est déjà en cours d'exécution. Attendez qu'il soit terminé avant d'ajouter"; +"API key" = "Clé API"; +"API region" = "Région API"; +"API token" = "Jeton API"; +"API tokens" = "Jetons API"; +"About" = "À propos"; +"Account" = "Compte"; +"Accounts" = "Comptes"; +"Accounts subtitle" = "Sous-titre des comptes"; +"Active" = "Actif"; +"Add" = "Ajouter"; +"Add Workspace" = "Ajouter un espace de travail"; +"Advanced" = "Avancé"; +"All" = "Tout"; +"Always allow prompts" = "Toujours autoriser les invites"; +"Animation pattern" = "Modèle d'animation"; +"Antigravity login is managed in the app" = "La connexion antigravité est gérée dans l’app"; +"Applies only to the Security.framework OAuth keychain reader." = "S’applique uniquement au lecteur de Trousseau OAuth Security.framework."; +"Auto falls back to the next source if the preferred one fails." = "Revient automatiquement à la source suivante si la source préférée échoue."; +"Auto uses API first, then falls back to CLI on auth failures." = "Auto utilise d'abord l'API, puis revient à la CLI en cas d'échec d'authentification."; +"Auto-detect" = "Auto-detect"; +"Auto-refresh is off; use the menu's Refresh command." = "L'actualisation automatique est désactivée ; utilisez la commande Actualiser du menu."; +"Auto-refresh: hourly · Timeout: 10m" = "Actualisation automatique : toutes les heures · Délai d'expiration : 10 min"; +"Automatic" = "Automatique"; +"Automatic imports browser cookies and WorkOS tokens." = "Importe automatiquement les cookies du navigateur et les jetons WorkOS."; +"Automatic imports browser cookies and local storage tokens." = "Importe automatiquement les cookies du navigateur et les jetons de stockage local."; +"Automatic imports browser cookies for dashboard extras." = "Importe automatiquement les cookies du navigateur pour les extras du tableau de bord."; +"Automatic imports browser cookies for the web API." = "Importe automatiquement les cookies du navigateur pour l'API Web."; +"Automatic imports browser cookies from Model Studio/Bailian." = "Importe automatiquement les cookies du navigateur depuis Model Studio/Bailian."; +"Automatic imports browser cookies from admin.mistral.ai." = "Importe automatiquement les cookies du navigateur depuis admin.mistral.ai."; +"Automatic imports browser cookies from opencode.ai." = "Importe automatiquement les cookies du navigateur depuis opencode.ai."; +"Automatic imports browser cookies or stored sessions." = "Importe automatiquement les cookies du navigateur ou les sessions stockées."; +"Automatic imports browser cookies." = "Importe automatiquement les cookies du navigateur."; +"Automatically imports browser session cookie." = "Importe automatiquement le cookie de session du navigateur."; +"Automatically opens CodexBar when you start your Mac." = "Ouvre automatiquement CodexBar lorsque vous démarrez votre Mac."; +"Automation" = "Automatisation"; +"Average (\\(label1) + \\(label2))" = "Moyenne (\\(label1) + \\(label2))"; +"Average (\\(metadata.sessionLabel) + \\(metadata.weeklyLabel))" = "Moyenne (\\(metadata.sessionLabel) + \\(metadata.weeklyLabel))"; +"Avoid Keychain prompts" = "Éviter les invites du Trousseau"; +"Balance" = "Balance"; +"Battery Saver" = "Économiseur de batterie"; +"Bordered" = "Bordé"; +"Build" = "Version"; +"Built \\(buildTimestamp)" = "Construit \\(buildTimestamp)"; +"Buy Credits..." = "Acheter des crédits..."; +"Buy Credits…" = "Acheter des crédits…"; +"CLI paths" = "Chemins CLI"; +"CLI sessions" = "Sessions CLI"; +"Caches" = "Caches"; +"Cancel" = "Annuler"; +"Check for Updates…" = "Rechercher les mises à jour…"; +"Check for updates automatically" = "Rechercher automatiquement les mises à jour"; +"Check if you like your agents having some fun up there." = "Vérifiez si vous aimez que vos agents s'amusent là-haut."; +"Check provider status" = "Vérifier le statut du fournisseur"; +"Choose Codex workspace" = "Choisissez l'espace de travail Codex"; +"Choose the MiniMax host (global .io or China mainland .com)." = "Choisissez l'hôte MiniMax (global .io ou Chine continentale .com)."; +"Choose up to " = "Choisissez jusqu'à "; +"Choose up to \\(Self.maxOverviewProviders) providers" = "Choisissez jusqu'à \\(Self.maxOverviewProviders) fournisseurs"; +"Choose up to \\(count) providers" = "Choisissez jusqu'à \\(count) fournisseurs"; +"Choose what to show in the menu bar (Pace shows usage vs. expected)." = "Choisissez ce que vous voulez afficher dans la barre de menu (Pace affiche l'utilisation par rapport à celle attendue)."; +"Choose which Codex account CodexBar should follow." = "Choisissez quel compte Codex CodexBar doit suivre."; +"Choose which window drives the menu bar percent." = "Choisissez quelle fenêtre gère le pourcentage de la barre de menus."; +"Chrome" = "Chrome"; +"Claude CLI not found" = "Claude CLI introuvable"; +"Claude binary" = "Binaire Claude"; +"Claude cookies" = "cookies Claude"; +"Claude login failed" = "La connexion de Claude a échoué"; +"Claude login timed out" = "La connexion de Claude a expiré"; +"Close" = "Fermer"; +"Code review" = "Revue de code"; +"Codex CLI not found" = "Codex CLI introuvable"; +"Codex account login already running" = "La connexion au compte Codex est déjà en cours"; +"Codex binary" = "Binaire Codex"; +"Codex login failed" = "Échec de la connexion au Codex"; +"Codex login timed out" = "La connexion au Codex a expiré"; +"CodexBar Lifecycle Keepalive" = "Cycle de vie de CodexBar Keepalive"; +"CodexBar can't show its menu bar icon" = "CodexBar ne peut pas afficher l'icône de sa barre de menus"; +"CodexBar could not read managed account storage. " = "CodexBar n'a pas pu lire le stockage du compte géré."; +"Configure…" = "Configurer…"; +"Connected" = "Connecté"; +"Controls how much detail is logged." = "Contrôle la quantité de détails enregistrés."; +"Cookie header" = "En-tête du cookie"; +"Cookie source" = "Source des cookies"; +"Cookie: ..." = "Cookie : …"; +"Cookie: \\u{2026}\\\n\\\nor paste a cURL capture from the Abacus AI dashboard" = "Cookie : \\u{2026}\\\n\\\nou collez une capture cURL à partir du tableau de bord Abacus AI"; +"Cookie: \\u{2026}\\\n\\\nor paste the __Secure-next-auth.session-token value" = "Cookie : \\u{2026}\\\n\\\nou collez la valeur __Secure-next-auth.session-token"; +"Cookie: \\u{2026}\\\n\\\nor paste the kimi-auth token value" = "Cookie : \\u{2026}\\\n\\\nou collez la valeur du jeton kimi-auth"; +"Cookie: …" = "Cookie : …"; +"CopilotDeviceFlow" = "CopilotDeviceFlow"; +"Cost" = "Coût"; +"Could not add Codex account" = "Impossible d'ajouter un compte Codex"; +"Could not open Terminal for Gemini" = "Impossible d'ouvrir le terminal pour Gemini"; +"Could not start claude /login" = "Impossible de démarrer Claude /connexion"; +"Could not start codex login" = "Impossible de démarrer la connexion à Codex"; +"Could not switch system account" = "Impossible de changer de compte système"; +"Credits" = "Crédits"; +"Individual credits" = "Crédits individuels"; +"Workspace" = "Espace de travail"; +"Credits history" = "Historique des crédits"; +"Cursor login failed" = "La connexion au curseur a échoué"; +"Custom" = "Personnalisé"; +"Custom Path" = "Chemin personnalisé"; +"Daily Routines" = "Routines quotidiennes"; +"Debug" = "Débogage"; +"Default" = "Par défaut"; +"Disable Keychain access" = "Désactiver l'accès au Trousseau"; +"Disabled" = "Désactivé"; +"Dismiss" = "Ignorer"; +"Disconnected" = "Déconnecté"; +"Display" = "Affichage"; +"Display mode" = "Mode d'affichage"; +"Display reset times as absolute clock values instead of countdowns." = "Affichez les temps de réinitialisation sous forme de valeurs d'horloge absolues au lieu de comptes à rebours."; +"Done" = "Terminé"; +"Effective PATH" = "CHEMIN efficace"; +"Email" = "E-mail"; +"Enable Merge Icons to configure Overview tab providers." = "Activez Fusionner les icônes pour configurer les fournisseurs d'onglets Présentation."; +"Enable file logging" = "Activer la journalisation des fichiers"; +"Enabled" = "Activé"; +"Error" = "Erreur"; +"Error simulation" = "Simulation d'erreur"; +"Expose troubleshooting tools in the Debug tab." = "Exposez les outils de dépannage dans l’onglet Débogage."; +"Failed" = "Échec"; +"False" = "False"; +"Fetch strategy attempts" = "Récupérer les tentatives de stratégie"; +"Fetching" = "Récupération"; +"Field" = "Champ"; +"Field subtitle" = "Sous-titre du champ"; +"Finish the current managed account change before switching the system account." = "Terminez la modification du compte géré actuel avant de changer de compte système."; +"Force animation on next refresh" = "Forcer l'animation au prochain rafraîchissement"; +"Gateway region" = "Région passerelle"; +"Gemini CLI not found" = "Gemini CLI introuvable"; +"Gemini/Antigravity, surfacing incidents in the icon and menu." = "Gemini/Antigravity, signale les incidents dans l'icône et le menu."; +"General" = "Général"; +"GitHub" = "GitHub"; +"GitHub Copilot Login" = "Connexion à GitHub Copilot"; +"GitHub Login" = "Connexion à GitHub"; +"Hide details" = "Masquer les détails"; +"Hide personal information" = "Masquer les informations personnelles"; +"Historical tracking" = "Suivi historique"; +"How often CodexBar polls providers in the background." = "À quelle fréquence CodexBar interroge les fournisseurs en arrière-plan."; +"Inactive" = "Inactif"; +"Install CLI" = "Installer la CLI"; +"Install the Claude CLI (npm i -g @anthropic-ai/claude-code) and try again." = "Installez la CLI Claude (npm i -g @anthropic-ai/claude-code) et réessayez."; +"Install the Codex CLI (npm i -g @openai/codex) and try again." = "Installez la CLI Codex (npm i -g @openai/codex) et réessayez."; +"Install the Gemini CLI (npm i -g @google/gemini-cli) and try again." = "Installez la CLI Gemini (npm i -g @google/gemini-cli) et réessayez."; +"JetBrains AI is ready" = "L'IA JetBrains est prête"; +"JetBrains IDE" = "EDI JetBrains"; +"Keep CLI sessions alive" = "Maintenir les sessions CLI en vie"; +"Keyboard shortcut" = "Raccourci clavier"; +"Keychain access" = "Accès au Trousseau"; +"Keychain prompt policy" = "Politique d'invite du Trousseau"; +"Last \\(name) fetch failed:" = "La dernière récupération de \\(name) a échoué :"; +"Last \\(self.store.metadata(for: self.provider).displayName) fetch failed:" = "La dernière récupération de \\(self.store.metadata(for: self.provider).displayName) a échoué :"; +"Last attempt" = "Dernière tentative"; +"Link" = "Lien"; +"Loading animations" = "Chargement des animations"; +"Loading…" = "Chargement…"; +"Local" = "Local"; +"Logging" = "Journalisation"; +"Login failed" = "La connexion a échoué"; +"Login shell PATH (startup capture)" = "CHEMIN du shell de connexion (capture de démarrage)"; +"Login timed out" = "La connexion a expiré"; +"MCP details" = "Détails du MCP"; +"Managed Codex accounts unavailable" = "Comptes Codex gérés indisponibles"; +"Managed account storage is unreadable. Live account access is still available, " = "Le stockage du compte géré est illisible. L'accès au compte en direct est toujours disponible,"; +"Manual" = "Manuel"; +"May your tokens never run out—keep agent limits in view." = "Que vos jetons ne soient jamais épuisés : gardez un œil sur les limites des agents."; +"Menu bar" = "Barre de menus"; +"Menu bar auto-shows the provider closest to its rate limit." = "La barre de menu affiche automatiquement le fournisseur le plus proche de sa limite de débit."; +"Menu bar metric" = "Métrique de la barre de menus"; +"Menu bar shows percent" = "La barre de menu affiche le pourcentage"; +"Menu content" = "Contenu des menus"; +"Merge Icons" = "Fusionner les icônes"; +"Never prompt" = "Ne jamais demander"; +"No" = "Non"; +"No Codex accounts detected yet." = "Aucun compte Codex détecté pour l'instant."; +"No JetBrains IDE detected" = "Aucun IDE JetBrains détecté"; +"No cost history data." = "Aucune donnée historique des coûts."; +"No data available" = "Aucune donnée disponible"; +"No data yet" = "Aucune donnée pour l'instant"; +"No enabled providers available for Overview." = "Aucun fournisseur activé disponible pour la présentation."; +"No providers selected" = "Aucun fournisseur sélectionné"; +"No token accounts yet." = "Aucun compte symbolique pour l'instant."; +"No usage breakdown data." = "Aucune donnée de répartition d'utilisation."; +"None" = "Aucun"; +"Notifications" = "Notifications"; +"Notifies when the 5-hour session quota hits 0% and when it becomes " = "Avertit lorsque le quota de session de 5 heures atteint 0 % et lorsqu'il devient"; +"OK" = "OK"; +"Obscure email addresses in the menu bar and menu UI." = "Adresses e-mail obscures dans la barre de menus et l'interface utilisateur du menu."; +"Off" = "Désactivé"; +"Offline" = "Hors ligne"; +"On" = "Activé"; +"Online" = "En ligne"; +"Only on user action" = "Uniquement sur l'action de l'utilisateur"; +"Open" = "Ouvrir"; +"Open API Keys" = "Clés API ouvertes"; +"Open Amp Settings" = "Ouvrir les paramètres de l'ampli"; +"Open Antigravity to sign in, then refresh CodexBar." = "Ouvrez Antigravity pour vous connecter, puis actualisez CodexBar."; +"Open Browser" = "Ouvrir le navigateur"; +"Open Coding Plan" = "Plan de codage ouvert"; +"Open Console" = "Ouvrir la console"; +"Open Dashboard" = "Ouvrir le tableau de bord"; +"Open Mistral Admin" = "Ouvrir l'administrateur Mistral"; +"Open Menu Bar Settings" = "Ouvrir les paramètres de la barre de menu"; +"Open Ollama Settings" = "Ouvrir les paramètres Ollama"; +"Open Terminal" = "Terminal ouvert"; +"Open Usage Page" = "Ouvrir la page d'utilisation"; +"Open Warp API Key Guide" = "Guide des clés de l'API Open Warp"; +"Open menu" = "Ouvrir le menu"; +"Open token file" = "Ouvrir le fichier de jeton"; +"OpenAI cookies" = "Cookies OpenAI"; +"OpenAI web extras" = "Extras Web OpenAI"; +"Option A" = "Option A"; +"Option B" = "Option B"; +"Optional override if workspace lookup fails." = "Remplacement facultatif si la recherche d’espace de travail échoue."; +"Options" = "Options"; +"Override auto-detection with a custom IDE base path" = "Remplacer la détection automatique par un chemin de base IDE personnalisé"; +"Overview" = "Aperçu"; +"Overview rows always follow provider order." = "Les lignes de présentation suivent toujours l’ordre des fournisseurs."; +"Overview tab providers" = "Fournisseurs d'onglets de présentation"; +"Paste API key…" = "Coller la clé API…"; +"Paste API token…" = "Coller le jeton API…"; +"Paste key…" = "Coller la clé…"; +"Paste sessionKey or OAuth token…" = "Collez sessionKey ou le jeton OAuth…"; +"Paste the Cookie header from a request to admin.mistral.ai. " = "Collez l'en-tête Cookie d'une requête vers admin.mistral.ai."; +"Paste token…" = "Coller le jeton…"; +"Personal" = "Personnel"; +"Picker" = "Sélecteur"; +"Picker subtitle" = "Sous-titre du sélecteur"; +"Placeholder" = "Espace réservé"; +"Plan" = "Forfait"; +"Plan Usage" = "Utilisation du forfait"; +"Play full-screen confetti when weekly usage resets." = "Jouez des confettis en plein écran lorsque l'utilisation hebdomadaire est réinitialisée."; +"Polls OpenAI/Claude status pages and Google Workspace for " = "Sonde les pages d'état OpenAI/Claude et Google Workspace pour"; +"Prevents any Keychain access while enabled." = "Empêche tout accès au Trousseau lorsqu'il est activé."; +"Primary (API key limit)" = "Primaire (limite de clé API)"; +"Primary (\\(label))" = "Primaire (\\(label))"; +"Primary (\\(metadata.sessionLabel))" = "Primaire (\\(metadata.sessionLabel))"; +"Probe logs" = "Journaux de sonde"; +"Progress bars fill as you consume quota (instead of showing remaining)." = "Les barres de progression se remplissent à mesure que vous consommez le quota (au lieu d'afficher le reste)."; +"Provider" = "Fournisseur"; +"Providers" = "Fournisseurs"; +"Quit CodexBar" = "Quitter CodexBar"; +"Random (default)" = "Aléatoire (par défaut)"; +"Reads local usage logs. Shows today + last 30 days cost in the menu." = "Lit les journaux d'utilisation locaux. Affiche aujourd'hui + la fenêtre d'historique sélectionnée dans le menu."; +"Refresh" = "Actualiser"; +"Refresh cadence" = "Cadence de rafraîchissement"; +"Remote" = "Distant"; +"Remove" = "Supprimer"; +"Remove Codex account?" = "Supprimer le compte Codex ?"; +"Remove \\(account.email) from CodexBar? Its managed Codex home will be deleted." = "Supprimer \\(account.email) de CodexBar ? Sa maison Codex gérée sera supprimée."; +"Remove \\(email) from CodexBar? Its managed Codex home will be deleted." = "Supprimer \\(email) de CodexBar ? Sa maison Codex gérée sera supprimée."; +"Remove selected account" = "Supprimer le compte sélectionné"; +"Replace critter bars with provider branding icons and a percentage." = "Remplacez les barres de créatures par des icônes de marque du fournisseur et un pourcentage."; +"Replay selected animation" = "Rejouer l'animation sélectionnée"; +"Requires authentication via GitHub Device Flow." = "Nécessite une authentification via GitHub Device Flow."; +"Resets: \\(reset)" = "Réinitialisation : \\(reset)"; +"Rolling five-hour limit" = "Limite mobile de cinq heures"; +"Search hourly" = "Recherche horaire"; +"Secondary (\\(label))" = "Secondaire (\\(label))"; +"Secondary (\\(metadata.weeklyLabel))" = "Secondaire (\\(metadata.weeklyLabel))"; +"Select a provider" = "Sélectionnez un fournisseur"; +"Select the IDE to monitor" = "Sélectionnez l'IDE à surveiller"; +"Session quota notifications" = "Notifications de quota de session"; +"Session tokens" = "Jetons de session"; +"provider_section_connection" = "Connexion"; +"provider_section_menu_bar" = "Barre de menus"; +"Show Codex Credits and Claude Extra usage sections in the menu." = "Afficher les sections d'utilisation des crédits Codex et de Claude Extra dans le menu."; +"Show Debug Settings" = "Afficher les paramètres de débogage"; +"Show all token accounts" = "Afficher tous les comptes de jetons"; +"Show cost summary" = "Afficher le récapitulatif des coûts"; +"Show credits + extra usage" = "Afficher les crédits + utilisation supplémentaire"; +"Show details" = "Afficher les détails"; +"Show most-used provider" = "Afficher le fournisseur le plus utilisé"; +"Show provider icons in the switcher (otherwise show a weekly progress line)." = "Afficher les icônes des fournisseurs dans le sélecteur (sinon, afficher une ligne de progression hebdomadaire)."; +"Show reset time as clock" = "Afficher l'heure de réinitialisation sous forme d'horloge"; +"Show usage as used" = "Afficher l'utilisation telle qu'utilisée"; +"Sign in via button below" = "Connectez-vous via le bouton ci-dessous"; +"Skip teardown between probes (debug-only)." = "Ignorer le démontage entre les sondes (débogage uniquement)."; +"Stack token accounts in the menu (otherwise show an account switcher bar)." = "Empilez les comptes de jetons dans le menu (sinon, affichez une barre de changement de compte)."; +"Start at Login" = "Commencez par la connexion"; +"Status" = "Statut"; +"Store Claude sessionKey cookies or OAuth access tokens." = "Stockez les cookies sessionKey de Claude ou les jetons d'accès OAuth."; +"Store multiple Abacus AI Cookie headers." = "Stockez plusieurs en-têtes Abacus AI Cookie."; +"Store multiple Augment Cookie headers." = "Stockez plusieurs en-têtes de cookies d’augmentation."; +"Store multiple Cursor Cookie headers." = "Stockez plusieurs en-têtes de cookies de curseur."; +"Store multiple Factory Cookie headers." = "Stockez plusieurs en-têtes Factory Cookie."; +"Store multiple MiniMax Cookie headers." = "Stockez plusieurs en-têtes MiniMax Cookie."; +"Store multiple Mistral Cookie headers." = "Stockez plusieurs en-têtes Mistral Cookie."; +"Store multiple Ollama Cookie headers." = "Stockez plusieurs en-têtes Ollama Cookie."; +"Store multiple OpenCode Cookie headers." = "Stockez plusieurs en-têtes de cookies OpenCode."; +"Store multiple OpenCode Go Cookie headers." = "Stockez plusieurs en-têtes OpenCode Go Cookie."; +"Stored in the CodexBar config file." = "Stocké dans le fichier de configuration CodexBar."; +"Stored in ~/.codexbar/config.json. " = "Stocké dans ~/.codexbar/config.json."; +"Stored in ~/.codexbar/config.json. Paste the key from the Synthetic dashboard." = "Stocké dans ~/.codexbar/config.json. Collez la clé du tableau de bord synthétique."; +"Stored in ~/.codexbar/config.json. Paste your Coding Plan API key from Model Studio." = "Stocké dans ~/.codexbar/config.json. Collez la clé API de votre plan de codage depuis Model Studio."; +"Stored in ~/.codexbar/config.json. Paste your MiniMax API key." = "Stocké dans ~/.codexbar/config.json. Collez votre clé API MiniMax."; +"Stored in ~/.codexbar/config.json. You can also provide KILO_API_KEY or " = "Stocké dans ~/.codexbar/config.json. Vous pouvez également fournir KILO_API_KEY ou"; +"Stores local Codex usage history (8 weeks) to personalize Pace predictions." = "Stocke l’historique d’utilisation local du Codex (8 semaines) pour personnaliser les prédictions Pace."; +"Surprise me" = "Surprenez-moi"; +"Switcher shows icons" = "Le commutateur affiche des icônes"; +"Symlink CodexBarCLI to /usr/local/bin and /opt/homebrew/bin as codexbar." = "Lien symbolique CodexBarCLI vers /usr/local/bin et /opt/homebrew/bin en tant que codexbar."; +"System" = "Système"; +"Temporarily shows the loading animation after the next refresh." = "Affiche temporairement l'animation de chargement après la prochaine actualisation."; +"terminal_app_subtitle" = "Terminal utilisé par l'action Ouvrir le terminal"; +"terminal_app_title" = "Terminal par défaut"; +"Tertiary (\\(label))" = "Tertiaire (\\(label))"; +"Tertiary (\\(tertiaryTitle))" = "Tertiaire (\\(tertiaryTitle))"; +"The default Codex account on this Mac." = "Le compte Codex par défaut sur ce Mac."; +"Toggle" = "Basculer"; +"Toggle subtitle" = "Basculer le sous-titre"; +"Token" = "Jeton"; +"Trigger the menu bar menu from anywhere." = "Déclenchez le menu de la barre de menus depuis n'importe où."; +"True" = "Vrai"; +"Twitter" = "Twitter"; +"Unsupported" = "Non pris en charge"; +"Update Channel" = "Mettre à jour la chaîne"; +"Updated" = "Mis à jour"; +"Updates unavailable in this build." = "Mises à jour non disponibles dans cette version."; +"Usage" = "Utilisation"; +"Usage breakdown" = "Répartition de l'utilisation"; +"Usage history (30 days)" = "Historique d'utilisation"; +"Usage source" = "Source d'utilisation"; +"Use BigModel for the China mainland endpoints (open.bigmodel.cn)." = "Utilisez BigModel pour les points de terminaison de la Chine continentale (open.bigmodel.cn)."; +"Use a single menu bar icon with a provider switcher." = "Utilisez une seule icône de barre de menu avec un sélecteur de fournisseur."; +"Use international or China mainland console gateways for quota fetches." = "Utilisez les passerelles de console internationales ou chinoises pour les récupérations de quotas."; +"Version" = "Version"; +"Version \\(self.versionString)" = "Version \\(self.versionString)"; +"Version \\(version)" = "Version \\(version)"; +"Version \\(versionString)" = "Version \\(versionString)"; +"Vertex AI Login" = "Connexion à Vertex AI"; +"Wait for the current managed Codex login to finish before adding another account." = "Attendez la fin de la connexion Codex gérée actuelle avant d'ajouter un autre compte."; +"Waiting for Authentication..." = "En attente d'authentification..."; +"Website" = "Site web"; +"Weekly limit confetti" = "Confettis de limite hebdomadaire"; +"Weekly token limit" = "Limite hebdomadaire de jetons"; +"Weekly usage" = "Utilisation hebdomadaire"; +"Weekly usage unavailable for this account." = "Utilisation hebdomadaire indisponible pour ce compte."; +"Window: \\(window)" = "Fenêtre : \\(window)"; +"Write logs to \\(self.fileLogPath) for debugging." = "Écrivez les journaux dans \\(self.fileLogPath) pour le débogage."; +"Yes" = "Oui"; +"\\(detail.modelCode): \\(usage)" = "\\(detail.modelCode) : \\(usage)"; +"\\(name): \\(truncated)" = "\\(name) : \\(truncated)"; +"\\(name): \\(updated) · 30d \\(cost)" = "\\(name) : \\(updated) · 30j \\(cost)"; +"\\(name): fetching…\\(elapsed)" = "\\(name) : récupération de…\\(elapsed)"; +"\\(name): last attempt \\(when)" = "\\(name) : dernière tentative \\(when)"; +"\\(name): no data yet" = "\\(name) : aucune donnée pour l'instant"; +"\\(name): unsupported" = "\\(name) : non pris en charge"; +"all browsers" = "tous les navigateurs"; +"available again." = "à nouveau disponible."; +"built_format" = "Construit %@"; +"copilot_complete_in_browser" = "Connectez-vous complètement dans votre navigateur."; +"copilot_device_code" = "Code de l'appareil copié dans le presse-papier : %1$@\n\nVérifiez à : %2$@"; +"copilot_device_code_copied" = "Code de l'appareil copié."; +"copilot_verify_at" = "Vérifiez à %@"; +"copilot_waiting_text" = "Terminez la connexion dans votre navigateur.\nCette fenêtre se ferme automatiquement une fois la connexion terminée."; +"copilot_window_closes_auto" = "Cette fenêtre se ferme automatiquement une fois la connexion terminée."; +"cost_status_error" = "%1$@ : %2$@"; +"cost_status_fetching" = "%1$@ : récupération de … %2$@"; +"cost_status_last_attempt" = "%1$@ : dernière tentative %2$@"; +"cost_status_no_data" = "%@ : aucune donnée pour l'instant"; +"cost_status_snapshot" = "%1$@ : %2$@ · %3$@ %4$@"; +"cost_status_unsupported" = "%@ : non pris en charge"; +"credits_remaining" = "Crédits : %@"; +"cursor_on_demand" = "À la demande : %@"; +"cursor_on_demand_with_limit" = "À la demande : %1$@ / %2$@"; +"extra_usage_format" = "Utilisation supplémentaire : %1$@ / %2$@"; +"jetbrains_detected_generate" = "Détecté : %@. Utilisez l'assistant IA une fois pour générer des données de quota, puis actualisez CodexBar."; +"jetbrains_detected_select" = "Détecté : %@. Sélectionnez votre IDE préféré dans Paramètres, puis actualisez CodexBar."; +"last_fetch_failed_with_provider" = "La dernière récupération de %@ a échoué :"; +"last_spend" = "Dernière dépense : %@"; +"mcp_model_usage" = "%1$@ : %2$@"; +"mcp_resets" = "Réinitialisation : %@"; +"mcp_window" = "Fenêtre : %@"; +"metric_average" = "Moyenne (%1$@ + %2$@)"; +"metric_primary" = "Primaire (%@)"; +"metric_secondary" = "Secondaire (%@)"; +"metric_tertiary" = "Tertiaire (%@)"; +"multiple_workspaces_found" = "CodexBar a trouvé plusieurs espaces de travail pour %@. Veuillez choisir l'espace de travail à ajouter."; +"ory_session_…=…; csrftoken=…" = "ory_session_…=…; csrftoken=…"; +"overview_choose_providers" = "Choisissez jusqu'à %@ fournisseurs"; +"remove_account_message" = "Supprimer %@ de CodexBar ? Sa maison Codex gérée sera supprimée."; +"version_format" = "Version %@"; +"vertex_ai_login_instructions" = "Pour suivre l'utilisation de Vertex AI, authentifiez-vous auprès de Google Cloud.\n\n1. Ouvrez le terminal\n2. Exécutez : gcloud auth application-default login\n3. Suivez les invites du navigateur pour vous connecter\n4. Définissez votre projet : gcloud config set project PROJECT_ID\n\nOuvrir le terminal maintenant ?"; +"workspaceID is set but only opencode, opencodego, and deepgram support workspaceID." = "workspaceID est défini mais seuls opencode, opencodego et deepgram prennent en charge workspaceID."; +"© 2026 Peter Steinberger. MIT License." = "© 2026 Peter Steinberger. Licence MIT."; + +/* General Pane */ +"section_system" = "Système"; +"section_usage" = "Utilisation"; +"section_refreshing" = "Actualisation"; +"section_alerts" = "Alertes"; +"section_celebrations" = "Célébrations"; +"section_icon" = "Icône"; +"section_combined_icon" = "Icône combinée"; +"section_animation" = "Animation"; +"section_content" = "Contenu"; +"section_agent_sessions" = "Sessions d’agents"; +"language_title" = "Langue"; +"language_subtitle" = "Change la langue d'affichage. Nécessite de redémarrer l'app pour une prise en compte complète."; +"currency_title" = "Devise préférée"; +"currency_subtitle" = "Devise des estimations de coût et des dépenses. Utilise des taux de change actualisés chaque jour."; +"currency_auto" = "Automatique (selon le fournisseur / USD)"; +"language_system" = "Système"; +"language_english" = "Anglais"; +"language_spanish" = "Espagnol"; +"language_catalan" = "Catalan"; +"language_chinese_simplified" = "Chinois simplifié"; +"language_chinese_traditional" = "Chinois traditionnel"; +"language_portuguese_brazilian" = "Portugais (Brésil)"; +"language_german" = "Allemand"; +"language_swedish" = "Suédois"; +"language_french" = "Français"; +"language_dutch" = "Néerlandais"; +"language_ukrainian" = "Ukrainien"; +"language_russian" = "Русский"; +"language_japanese" = "Japonais"; +"language_korean" = "Coréen"; +"language_italian" = "Italiano"; +"language_vietnamese" = "Vietnamien"; +"language_turkish" = "Türkçe"; +"language_indonesian" = "Indonésien"; +"language_polish" = "Polonais"; +"start_at_login_title" = "Lancer à l'ouverture de session"; +"start_at_login_subtitle" = "Ouvre automatiquement CodexBar au démarrage de votre Mac."; +"show_cost_summary_subtitle" = "Lit les journaux d'utilisation locaux. Affiche le coût d'aujourd'hui et de la période sélectionnée dans le menu."; +"cost_summary_style_title" = "Style d’affichage"; +"cost_summary_style_inline" = "Intégré seulement"; +"cost_summary_style_submenu" = "Sous-menu seulement"; +"cost_summary_style_both" = "Les deux"; +"cost_summary_style_inline_help" = "Affiche le résumé des coûts directement dans le menu principal."; +"cost_summary_style_submenu_help" = "Affiche plutôt le sous-menu Coût détaillé."; +"cost_summary_style_both_help" = "Affiche le résumé du menu principal et le sous-menu Coût détaillé."; +"cost_history_window_title" = "Fenêtre d'historique"; +"cost_history_window_help" = "Définit le nombre de jours de journaux d'utilisation locaux affichés dans le menu."; +"cost_history_days_title" = "Fenêtre d'historique : %d jours"; +"cost_auto_refresh_info" = "Actualisation automatique : intervalle global (minimum 5 min) · Délai d'expiration : 10 min"; +"cost_comparison_periods_title" = "Afficher des périodes de comparaison plus courtes"; +"cost_comparison_periods_subtitle" = "Ajoute les totaux sur 7, 30 et 90 jours lorsqu'ils tiennent dans la période d'historique sélectionnée. Ces totaux réutilisent la même analyse locale."; +"refresh_interval_title" = "Intervalle d’actualisation"; +"manual_refresh_hint" = "L'actualisation automatique est désactivée ; utilisez la commande Actualiser du menu."; +"refresh_on_open_title" = "Actualiser à l'ouverture du menu"; +"refresh_on_open_subtitle" = "Récupère l'utilisation la plus récente de chaque fournisseur à chaque ouverture du menu."; +"check_provider_status_title" = "Vérifier l'état des fournisseurs"; +"check_provider_status_subtitle" = "Interroge les pages d'état OpenAI/Claude et Google Workspace pour Gemini/Antigravity, et affiche les incidents dans l'icône et le menu."; +"session_quota_notifications_subtitle" = "Vous avertit lorsque le quota de session sur 5 heures atteint 0 % puis lorsqu'il redevient disponible."; +"quota_depleted_title" = "Quota épuisé et rétabli"; +"quota_warning_notifications_subtitle" = "Vous avertit lorsque le quota restant (session ou hebdomadaire) franchit les seuils configurés."; +"threshold_warnings_title" = "Alertes de seuil"; +"quota_warnings_title" = "Alertes de quota"; +"quota_warning_session" = "session"; +"quota_warning_session_capitalized" = "Session"; +"quota_warning_weekly" = "hebdomadaire"; +"quota_warning_weekly_capitalized" = "Hebdomadaire"; +"quota_warning_notification_title" = "%1$@ %2$@ : quota faible"; +"quota_warning_notification_body" = "Il reste %1$@. Seuil d'alerte %2$d %% (%3$@) atteint."; +"quota_warning_notification_body_with_account" = "Compte %1$@. Il reste %2$@. Seuil d'alerte %3$d %% (%4$@) atteint."; +"predictive_pace_warnings_title" = "Avertissements prédictifs de rythme"; +"predictive_pace_warnings_subtitle" = "Avertit pour Codex et Claude lorsque le rythme de session ou hebdomadaire risque d'épuiser le quota avant la réinitialisation."; +"confetti_on_reset_title" = "Confettis à la réinitialisation"; +"confetti_on_reset_subtitle" = "Afficher des confettis en plein écran lorsque l’utilisation est réinitialisée."; +"confetti_option_off" = "Désactivé"; +"confetti_option_session" = "Réinitialisations de session"; +"confetti_option_weekly" = "Réinitialisations hebdomadaires"; +"confetti_option_both" = "Les deux"; +"predictive_pace_warning_notification_title" = "%1$@ : avertissement de rythme %2$@"; +"predictive_pace_warning_notification_body" = "Au rythme actuel, ce quota pourrait être épuisé dans %1$@, avant sa réinitialisation."; +"predictive_pace_warning_notification_body_with_account" = "Compte %1$@. Au rythme actuel, ce quota pourrait être épuisé dans %2$@, avant sa réinitialisation."; +"session_depleted_notification_title" = "Session %@ épuisée"; +"session_depleted_notification_body" = "0 % restant. Vous serez notifié quand elle redeviendra disponible."; +"session_restored_notification_title" = "Session %@ rétablie"; +"session_restored_notification_body" = "Le quota de session est à nouveau disponible."; +"quota_warning_warn_at" = "Avertir à"; +"quota_warning_global_threshold_subtitle" = "Pourcentages restants pour les fenêtres de session et hebdomadaires, sauf si un fournisseur les remplace."; +"quota_warning_sound" = "Lire un son de notification"; +"quota_warning_onscreen_alert" = "Afficher une alerte textuelle à l’écran"; +"quota_warning_provider_inherits" = "Utilise les réglages globaux d'alerte de quota, sauf personnalisation de cette fenêtre."; +"quota_warning_provider_disabled" = "Les notifications d’alerte de quota et les marqueurs des barres d’utilisation sont désactivés. Activez l’une des deux options pour modifier ces réglages enregistrés."; +"quota_warning_provider_markers_only" = "Les notifications d’alerte de quota sont désactivées globalement. Ces réglages contrôlent toujours les marqueurs des barres d’utilisation."; +"quota_warning_global" = "Global"; +"quota_warning_customize_thresholds" = "Personnaliser les seuils %@"; +"quota_warning_enable_warnings" = "Activer les alertes %@"; +"quota_warning_window_warn_at" = "Alerte %@"; +"quota_warning_off" = "Désactivé"; +"quota_warning_inherited" = "Hérité : %@"; +"quota_warning_depleted_only" = "uniquement à l'épuisement"; +"quota_warning_upper" = "Plus haut"; +"quota_warning_lower" = "Seuil bas"; +"quota_warning_warning" = "Avertissement"; +"quota_warning_critical" = "Critique"; +"apply" = "Appliquer"; +"quit_app" = "Quitter CodexBar"; + +/* Tab titles */ +"tab_general" = "Général"; +"tab_providers" = "Fournisseurs"; +"tab_notifications" = "Notifications"; +"tab_menu_bar" = "Barre de menus"; +"tab_menu" = "Menu"; +"tab_advanced" = "Avancé"; +"tab_about" = "À propos"; +"tab_debug" = "Débogage"; + +/* Providers Pane */ +"select_a_provider" = "Sélectionner un fournisseur"; +"cancel" = "Annuler"; +"last_fetch_failed" = "dernière récupération échouée"; +"usage_not_fetched_yet" = "utilisation pas encore récupérée"; +"managed_account_storage_unreadable" = "Le stockage du compte géré est illisible. L'accès au compte réel est toujours disponible, mais les actions gérées d'ajout, de réauthentification et de suppression sont désactivées jusqu'à ce que le magasin soit récupérable."; +"remove_codex_account_title" = "Supprimer le compte Codex ?"; +"remove" = "Supprimer"; +"managed_login_already_running" = "Une connexion Codex gérée est déjà en cours d'exécution. Attendez la fin avant d'ajouter ou de ré-authentifier un autre compte."; +"managed_login_failed" = "La connexion au Codex géré n'a pas abouti. Vérifiez que `codex --version` fonctionne dans Terminal. Si macOS a bloqué ou déplacé « codex » vers la corbeille, supprimez les installations en double obsolètes, exécutez « npm install -g --include=optional @openai/codex@latest », puis réessayez."; +"codex_login_output" = "Résultat de connexion à Codex :"; +"managed_login_missing_email" = "Connexion au Codex terminée, mais aucune adresse e-mail du compte n'était disponible. Réessayez après avoir confirmé que le compte est entièrement connecté."; +"login_success_notification_title" = "%@ connexion réussie"; +"login_success_notification_body" = "Vous pouvez revenir à l’app ; authentification terminée."; +"workspace_selection_cancelled" = "CodexBar a trouvé plusieurs espaces de travail, mais aucun espace de travail n'a été sélectionné."; +"unsafe_managed_home" = "CodexBar a refusé de modifier un chemin d'accès à la maison géré inattendu : %@"; +"menu_bar_metric_title" = "Métrique de la barre de menus"; +"menu_bar_metric_subtitle" = "Choisissez quelle fenêtre gère le pourcentage de la barre de menus."; +"menu_bar_metric_subtitle_deepseek" = "Affiche le solde DeepSeek dans la barre de menu."; +"menu_bar_metric_subtitle_moonshot" = "Affiche le solde de l'API Moonshot / Kimi dans la barre de menu."; +"menu_bar_metric_subtitle_mistral" = "Affiche les dépenses de l'API Mistral du mois en cours dans la barre de menu."; +"automatic" = "Automatique"; +"primary_api_key_limit" = "Primaire (limite de clé API)"; + +/* Display Pane */ +"menu_bar_style_title" = "Style de la barre de menus"; +"menu_bar_style_subtitle" = "Définit l’apparence de l’élément dans la barre de menus."; +"menu_bar_inactive_display_contrast_title" = "Améliorer la visibilité sur les écrans inactifs"; +"menu_bar_usage_colors_title" = "Utilisation en couleurs"; +"menu_bar_usage_colors_subtitle" = "Colore l'icône de la barre des menus du vert au rouge à mesure que l'utilisation augmente."; +"menu_bar_inactive_display_contrast_subtitle" = "Utilise un rendu à contraste élevé pour que l’icône et la mesure restent lisibles sur les autres écrans."; +"menu_bar_style_critters" = "Créatures"; +"menu_bar_style_bars" = "Barres de mesure"; +"menu_bar_style_icon_percent" = "Icône et pourcentage"; +"switcher_rows_title" = "Lignes du sélecteur"; +"switcher_rows_icons" = "Icônes des fournisseurs"; +"switcher_rows_progress" = "Progression hebdomadaire"; +"usage_bars_fill_title" = "Remplissage des barres d’utilisation"; +"usage_bars_fill_remaining" = "Selon le quota restant"; +"usage_bars_fill_used" = "Selon le quota utilisé"; +"reset_times_title" = "Heures de réinitialisation"; +"reset_times_countdown" = "Compte à rebours"; +"reset_times_clock" = "Heure"; +"cost_summary_title" = "Récapitulatif des coûts"; +"cost_summary_off" = "Désactivé"; +"merge_icons_title" = "Fusionner les icônes"; +"merge_icons_subtitle" = "Utilisez une seule icône de barre de menu avec un sélecteur de fournisseur."; +"show_most_used_provider_title" = "Afficher le fournisseur le plus utilisé"; +"show_most_used_provider_subtitle" = "La barre de menu affiche automatiquement le fournisseur le plus proche de sa limite de débit."; +"display_mode_title" = "Mode d'affichage"; +"display_mode_subtitle" = "Choisissez ce que vous voulez afficher dans la barre de menu (Pace affiche l'utilisation par rapport à celle attendue)."; +"show_quota_warning_markers_title" = "Afficher les marqueurs d'avertissement de quota"; +"show_quota_warning_markers_subtitle" = "Dessinez des coches de seuil sur les barres d’utilisation lorsque des avertissements de quota sont configurés."; +"weekly_progress_work_days_title" = "Jours de travail hebdomadaires"; +"weekly_progress_work_days_subtitle" = "Définit les jours ouvrés pour les repères des barres d’utilisation hebdomadaire et le calcul du rythme."; +"show_provider_changelog_links_title" = "Afficher les liens du journal des modifications du fournisseur"; +"show_provider_changelog_links_subtitle" = "Ajoute au menu des liens de notes de version pour les fournisseurs pris en charge par CLI."; +"show_credits_extra_usage_title" = "Afficher les crédits + utilisation supplémentaire"; +"show_credits_extra_usage_subtitle" = "Afficher les sections d'utilisation des crédits Codex et de Claude Extra dans le menu."; +"multi_account_layout_title" = "Disposition multi-comptes"; +"multi_account_layout_subtitle" = "Choisissez un changement de compte segmenté ou des cartes de compte empilées."; +"multi_account_layout_segmented" = "Segmenté"; +"multi_account_layout_stacked" = "Empilé"; +"overview_tab_providers_title" = "Fournisseurs d'onglets de présentation"; +"configure" = "Configurer…"; +"overview_enable_merge_icons_hint" = "Activez Fusionner les icônes pour configurer les fournisseurs d'onglets Présentation."; +"overview_no_providers_hint" = "Aucun fournisseur activé disponible pour la présentation."; +"overview_rows_follow_order" = "Les lignes de présentation suivent toujours l’ordre des fournisseurs."; +"overview_no_providers_selected" = "Aucun fournisseur sélectionné"; +"agent_sessions_title" = "Sessions d’agents"; +"agent_sessions_subtitle" = "Afficher dans le menu les sessions Codex et Claude Code locales et découvertes via SSH."; +"agent_sessions_hosts_title" = "Hôtes SSH supplémentaires"; +"agent_sessions_footer" = "Les Mac de votre tailnet sont découverts automatiquement. Les sessions locales s’actualisent toutes les 30 secondes ; les hôtes distants toutes les 60 secondes et à l’ouverture du menu."; +"agent_session_labels_title" = "Libellés des sessions"; +"agent_session_labels_subtitle" = "Choisissez comment nommer les sessions d’agents."; +"agent_session_label_project" = "Projet"; +"agent_session_label_descriptive" = "Descriptif"; +"agent_session_label_descriptive_and_project" = "Descriptif + projet"; +"agent_session_unknown_project" = "Projet inconnu"; + +/* Advanced Pane */ +"section_keyboard_shortcut" = "Raccourci clavier"; +"open_menu_shortcut_title" = "Ouvrir le menu"; +"open_menu_shortcut_subtitle" = "Déclenchez le menu de la barre de menus depuis n'importe où."; +"install_cli" = "Installer la CLI"; +"install_cli_subtitle" = "Lien symbolique CodexBarCLI vers /usr/local/bin et /opt/homebrew/bin en tant que codexbar."; +"cli_not_found" = "CodexBarCLI introuvable dans l'ensemble d'applications."; +"no_writable_bin_dirs" = "Aucun répertoire bin inscriptible trouvé."; +"show_debug_settings_title" = "Afficher les paramètres de débogage"; +"show_debug_settings_subtitle" = "Exposez les outils de dépannage dans l’onglet Débogage."; +"surprise_me_title" = "Surprenez-moi"; +"surprise_me_subtitle" = "Vérifiez si vous aimez que vos agents s'amusent là-haut."; +"hide_personal_info_title" = "Masquer les informations personnelles"; +"hide_personal_info_subtitle" = "Adresses e-mail obscures dans la barre de menus et l'interface utilisateur du menu."; +"show_provider_storage_usage_title" = "Afficher l'utilisation du stockage du fournisseur"; +"show_provider_storage_usage_subtitle" = "Afficher l'utilisation du disque local dans les menus. Analyse les chemins connus appartenant au fournisseur en arrière-plan."; +"section_keychain_access" = "Accès au Trousseau"; +"keychain_access_caption" = "Désactivez toutes les lectures et écritures du Trousseau. Utilisez-le si macOS continue de demander « Chrome/Brave/Edge Safe Storage » même après avoir cliqué sur Toujours autoriser. L'importation des cookies du navigateur n'est pas disponible lorsqu'elle est activée ; collez manuellement les en-têtes de cookies dans les fournisseurs. Claude/Codex OAuth via la CLI fonctionne toujours."; +"disable_keychain_access_title" = "Désactiver l'accès au Trousseau"; +"disable_keychain_access_subtitle" = "Empêche tout accès au Trousseau lorsqu'il est activé."; + +/* About Pane */ +"about_tagline" = "Que vos jetons ne soient jamais épuisés : gardez un œil sur les limites des agents."; +"link_github" = "GitHub"; +"link_website" = "Site web"; +"link_twitter" = "X/Twitter"; +"link_email" = "E-mail"; +"check_updates_auto" = "Rechercher automatiquement les mises à jour"; +"update_channel" = "Mettre à jour la chaîne"; +"check_for_updates" = "Rechercher les mises à jour…"; +"updates_unavailable" = "Mises à jour non disponibles dans cette version."; +"copyright" = "© 2026 Peter Steinberger. Licence MIT."; + +/* Debug Pane */ +"section_logging" = "Journalisation"; +"enable_file_logging" = "Activer la journalisation des fichiers"; +"enable_file_logging_subtitle" = "Écrivez les journaux dans %@ pour le débogage."; +"verbosity_title" = "Niveau de verbosité"; +"verbosity_subtitle" = "Contrôle la quantité de détails enregistrés."; +"open_log_file" = "Ouvrir le fichier journal"; +"force_animation_next_refresh" = "Forcer l'animation au prochain rafraîchissement"; +"force_animation_next_refresh_subtitle" = "Affiche temporairement l'animation de chargement après la prochaine actualisation."; +"section_loading_animations" = "Chargement des animations"; +"loading_animations_caption" = "Choisissez un motif et rejouez-le dans la barre de menu. \"Aléatoire\" conserve le comportement existant."; +"animation_random_default" = "Aléatoire (par défaut)"; +"replay_selected_animation" = "Rejouer l'animation sélectionnée"; +"blink_now" = "Cligne des yeux maintenant"; +"section_probe_logs" = "Journaux de sonde"; +"probe_logs_caption" = "Récupère la dernière sortie de la sonde pour le débogage ; La copie conserve le texte intégral."; +"fetch_log" = "Récupérer le journal"; +"copy" = "Copier"; +"save_to_file" = "Enregistrer dans un fichier"; +"load_parse_dump" = "Charger le vidage d'analyse"; +"rerun_provider_autodetect" = "Réexécuter la détection automatique du fournisseur"; +"loading" = "Chargement…"; +"no_log_yet_fetch" = "Pas de journal pour l'instant. Récupérer pour charger."; +"section_fetch_strategy" = "Récupérer les tentatives de stratégie"; +"fetch_strategy_caption" = "Dernières décisions et erreurs du pipeline de récupération pour un fournisseur."; +"section_openai_cookies" = "Cookies OpenAI"; +"openai_cookies_caption" = "Importation de cookies + journaux de récupération WebKit de la dernière tentative de cookies OpenAI."; +"no_log_yet" = "Pas de journal pour l'instant. Mettez à jour les cookies OpenAI dans Fournisseurs → Codex pour exécuter une importation."; +"section_caches" = "Caches"; +"caches_caption" = "Effacez les résultats de l’analyse des coûts mis en cache ou les caches des cookies du navigateur."; +"clear_cookie_cache" = "Vider le cache des cookies"; +"clear_cost_cache" = "Vider le cache des coûts"; +"section_notifications" = "Notifications"; +"notifications_caption" = "Déclenchez des notifications de test pour la fenêtre de session de 5 heures (épuisée/restaurée)."; +"post_depleted" = "Post épuisé"; +"post_restored" = "Message restauré"; +"section_cli_sessions" = "Sessions CLI"; +"cli_sessions_caption" = "Gardez les sessions Codex/Claude CLI actives après une sonde. La valeur par défaut se ferme une fois les données capturées."; +"keep_cli_sessions_alive" = "Maintenir les sessions CLI en vie"; +"keep_cli_sessions_alive_subtitle" = "Ignorer le démontage entre les sondes (débogage uniquement)."; +"reset_cli_sessions" = "Réinitialiser les sessions CLI"; +"section_error_simulation" = "Simulation d'erreur"; +"error_simulation_caption" = "Injectez un faux message d'erreur dans la carte de menu pour tester la mise en page."; +"set_menu_error" = "Erreur de menu de définition"; +"clear_menu_error" = "Effacer l'erreur de menu"; +"set_cost_error" = "Erreur de définition du coût"; +"clear_cost_error" = "Effacer l'erreur de coût"; +"section_cli_paths" = "Chemins CLI"; +"cli_paths_caption" = "Couches binaires et PATH du Codex résolues ; Capture du chemin de connexion au démarrage (délai d'attente court)."; +"codex_binary" = "Binaire Codex"; +"claude_binary" = "Binaire Claude"; +"effective_path" = "CHEMIN efficace"; +"unavailable" = "Indisponible"; +"login_shell_path" = "CHEMIN du shell de connexion (capture de démarrage)"; +"cleared" = "Effacé"; +"no_fetch_attempts" = "Aucune tentative de récupération pour l'instant."; +"macOS Tahoe can block menu bar apps in System Settings → Menu Bar → Allow in the Menu Bar. CodexBar is running, but macOS may be hiding its icon. Open Menu Bar settings and turn CodexBar on." = "macOS Tahoe peut bloquer les applications de la barre de menus dans Paramètres système → Barre de menus → Autoriser dans la barre de menus. CodexBar est en cours d'exécution, mais macOS cache peut-être son icône. Ouvrez les paramètres de la barre de menu et activez CodexBar."; + +/* Metric preferences */ +"metric_pref_automatic" = "Automatique"; +"metric_pref_primary" = "Principal"; +"metric_pref_secondary" = "Secondaire"; +"metric_pref_tertiary" = "Tertiaire"; +"metric_pref_extra_usage" = "Utilisation supplémentaire"; +"metric_pref_average" = "Moyenne"; +"metric_mistral_payg" = "Pay-as-you-go"; +"metric_mistral_monthly_plan" = "Monthly Plan"; + +/* Display modes */ +"display_mode_percent" = "Pourcentage"; +"display_mode_pace" = "Rythme"; +"display_mode_both" = "Les deux"; +"display_mode_reset_time" = "Heure de réinitialisation"; +"display_mode_percent_desc" = "Afficher le pourcentage restant/utilisé (par exemple 45 %)"; +"display_mode_pace_desc" = "Afficher l'indicateur d'allure (par exemple +5 %)"; +"display_mode_both_desc" = "Afficher à la fois le pourcentage et le rythme (par exemple 45 % · +5 %)"; +"display_mode_reset_time_desc" = "Afficher l'heure de réinitialisation de la métrique sélectionnée (par exemple ↻ 15:56)"; +"menu_bar_reset_when_exhausted_title" = "Afficher l'heure de réinitialisation quand le quota est épuisé"; +"menu_bar_reset_when_exhausted_subtitle" = "À 0 % restant, affiche le temps avant réinitialisation au lieu du pourcentage"; + +/* Provider status */ +"status_operational" = "Opérationnel"; +"status_degraded" = "Performances dégradées"; +"status_partial_outage" = "Dégradation partielle"; +"status_major_outage" = "Panne majeure"; +"status_critical_issue" = "Problème critique"; +"status_maintenance" = "Maintenance"; +"status_unknown" = "Statut inconnu"; + +/* Refresh frequency */ +"refresh_manual" = "Manuel"; +"refresh_1min" = "1 minute"; +"refresh_2min" = "2 minutes"; +"refresh_5min" = "5 minutes"; +"refresh_15min" = "15 minutes"; +"refresh_30min" = "30 minutes"; +"refresh_adaptive" = "Adaptatif"; +"refresh_adaptive_agent_aware" = "Adaptatif (activité des agents)"; +"adaptive_activity_consent_title" = "Autoriser l’actualisation selon l’activité ?"; +"adaptive_activity_consent_message" = "Le mode Adaptatif selon l’activité des agents peut examiner la liste des processus locaux en cours, y compris leurs lignes de commande, pour identifier Codex et Claude, puis lire toutes les 30 secondes les métadonnées des sessions connues pendant que vous codez. Lorsque Agent Sessions est désactivé, CodexBar ne conserve en mémoire que l’heure de la dernière activité et ignore les chemins et identités des sessions. Ces données ne sont envoyées nulle part, et la détection à distance ainsi que SSH restent désactivés. Si vous refusez, CodexBar revient au mode Adaptatif normal sans analyse de l’activité locale."; +"adaptive_activity_consent_allow" = "Autoriser l’activité locale"; +"adaptive_activity_consent_decline" = "Utiliser le mode Adaptatif normal"; + +/* Additional keys */ +"not_found" = "Pas trouvé"; + +/* Cost estimation */ +"cost_estimate_hint" = "Estimé à partir des journaux locaux · peut différer de votre facture"; +"codex_api_estimate_hint" = "Estimé à partir de l’utilisation des jetons · pas une facture d’abonnement"; +"cost_data_explanation" = "Les coûts peuvent être déclarés par le fournisseur ou estimés à partir de l’utilisation des jetons aux tarifs publics de l’API. Les estimations ne sont pas des frais d’abonnement."; +"No JetBrains IDE with AI Assistant detected. Install a JetBrains IDE and enable AI Assistant." = "Aucun IDE JetBrains avec AI Assistant détecté. Installez un IDE JetBrains et activez AI Assistant."; +"OpenRouter API token not configured. Set OPENROUTER_API_KEY environment variable or configure in Settings." = "Jeton API OpenRouter non configuré. Définissez la variable d'environnement OPENROUTER_API_KEY ou configurez-la dans Paramètres."; +"z.ai API token not found. Set apiKey in ~/.codexbar/config.json or Z_AI_API_KEY." = "Jeton API z.ai introuvable. Définissez apiKey dans ~/.codexbar/config.json ou Z_AI_API_KEY."; +"Missing DeepSeek API key." = "Clé API DeepSeek manquante."; +"%@ is unavailable in the current environment." = "%@ n'est pas disponible dans l'environnement actuel."; +"All Systems Operational" = "Tous les systèmes opérationnels"; +"Last 30 days" = "30 derniers jours"; +"Last 30 days:" = "30 derniers jours :"; +"This month" = "Ce mois-ci"; +"Store multiple OpenAI API keys." = "Stockez plusieurs clés API OpenAI."; +"Admin API key" = "Clé API d'administration"; +"Open billing" = "Facturation ouverte"; +"Google accounts" = "Comptes Google"; +"Store multiple Antigravity Google OAuth accounts for quick switching." = "Stockez plusieurs comptes Google OAuth Antigravity pour une commutation rapide."; +"Add Google Account" = "Ajouter un compte Google"; +"Open Token Plan" = "Plan de jetons ouverts"; +"Text Generation" = "Génération de texte"; +"Text to Speech" = "Synthèse vocale"; +"Music Generation" = "Génération de musique"; +"Image Generation" = "Génération d'images"; +"No local data found" = "Aucune donnée locale trouvée"; +"Credits unavailable; keep Codex running to refresh." = "Crédits indisponibles ; laissez le Codex fonctionner pour l'actualiser."; +"No available fetch strategy for minimax." = "Aucune stratégie de récupération disponible pour minimax."; +"No Cursor session found. Please log in to cursor.com in Safari, Chrome, Microsoft Edge, Brave, Arc, Dia, ChatGPT Atlas, Chromium, Helium, Vivaldi, Yandex Browser, Firefox, Zen, Colibri, Sidekick, Opera, Opera GX, or Edge Canary. If you use Safari, grant CodexBar Full Disk Access in System Settings ▸ Privacy & Security. You can also sign in to Cursor from the CodexBar menu (Add / switch account)." = "Aucune session de Cursor trouvée. Veuillez vous connecter à cursor.com dans Safari, Chrome, Microsoft Edge, Brave, Arc, Dia, ChatGPT Atlas, Chromium, Helium, Vivaldi, Yandex Browser, Firefox, Zen, Colibri, Sidekick, Opera, Opera GX ou Edge Canary. Si vous utilisez Safari, accordez l'accès complet au disque à CodexBar dans Paramètres système ▸ Confidentialité et sécurité. Vous pouvez également vous connecter à Cursor à partir du menu CodexBar (Ajouter/changer de compte)."; +"No OpenCode session cookies found in browsers." = "Aucun cookie de session OpenCode trouvé dans les navigateurs."; +"No available fetch strategy for %@." = "Aucune stratégie de récupération disponible pour %@."; +"Today" = "Aujourd’hui"; +"Today tokens" = "Jetons d'aujourd'hui"; +"30d cost" = "coût 30 jours"; +"%@ cost" = "coût %@"; +"30d tokens" = "jetons 30d"; +"Latest tokens" = "Derniers jetons"; +"Top model" = "Top modèle"; +"Storage" = "Stockage"; +"Add Account..." = "Ajouter un compte..."; +"Usage Dashboard" = "Tableau de bord d'utilisation"; +"Status Page" = "Page d'état"; +"Open Status Page" = "Ouvrir la page d'état"; +"Settings..." = "Réglages…"; +"About CodexBar" = "À propos de CodexBar"; +"Quit" = "Quitter"; +"Last %d day" = "Dernier %d jour"; +"Last %d days" = "%d derniers jours"; +"%@ tokens" = "Jetons %@"; +"Latest billing day" = "Dernier jour de facturation"; +"Latest billing day (%@)" = "Dernier jour de facturation (%@)"; +"%@ left" = "%@ restant"; +"Resets %@" = "Réinitialise %@"; +"Resets in %@" = "Réinitialisé dans %@"; +"Resets now" = "Réinitialise maintenant"; +"reset_tomorrow_format" = "demain, %@"; +"Lasts until reset" = "Dure jusqu'à la réinitialisation"; +"1.5× headroom" = "marge de 1,5×"; +"Updated %@" = "%@ mis à jour"; +"Updated relative %@" = "%@ mis à jour"; +"Updated absolute %@" = "%@ mis à jour"; +"Updated %@h ago" = "Mis à jour il y a %@h"; +"Updated %@m ago" = "Mis à jour il y a %@m"; +"Updated just now" = "Mis à jour tout à l'heure"; +"Projected empty in %@" = "Projeté vide dans %@"; +"Runs out in %@" = "S'épuise dans %@"; +"Pace: %@" = "Rythme : %@"; +"Pace: %@ · %@" = "Rythme : %@ · %@"; +"%@ · %@" = "%@ · %@"; +"≈ %d%% run-out risk" = "≈ %d%% risque d'épuisement"; +"%d%% in deficit" = "%d%% en déficit"; +"%d%% in reserve" = "%d%% en réserve"; +"usage_percent_suffix_left" = "restant"; +"usage_percent_suffix_used" = "utilisé"; +"Store multiple DeepSeek API keys." = "Stockez plusieurs clés API DeepSeek."; +"This week" = "Cette semaine"; +"Week" = "Semaine"; +"Month" = "Mois"; +"Models" = "Modèles"; +"24h tokens" = "jetons 24h"; +"Latest hour" = "Dernière heure"; +"Peak hour" = "Heure de pointe"; +"Top method" = "Méthode supérieure"; +"30d cash" = "30 jours en espèces"; +"30d billing history from MiniMax web session" = "Historique de facturation 30 jours à partir de la session Web MiniMax"; +"AWS Cost Explorer billing can lag." = "La facturation d'AWS Cost Explorer peut prendre du retard."; +"Rate limit: %d / %@" = "Limite de débit : %d / %@"; +"Key remaining" = "Clé restante"; +"No limit set for the API key" = "Aucune limite définie pour la clé API"; +"API key limit unavailable right now" = "Limite de clé API indisponible pour le moment"; +"This month: %@ tokens" = "Ce mois-ci : %@ jetons"; +"No utilization data yet." = "Aucune donnée d'utilisation pour l'instant."; +"No %@ utilization data yet." = "Aucune donnée d'utilisation de %@ pour l'instant."; +"%@: %@%% used" = "%@ : %@%% utilisé"; +"%dd" = "%dd"; +"today" = "aujourd'hui"; +"just now" = "tout à l' heure"; +"On pace" = "Au rythme"; +"Runs out now" = "S'épuise maintenant"; +"Projected empty now" = "Projeté vide maintenant"; +"Switch Account..." = "Changer de compte..."; +"Update ready, restart now?" = "La mise à jour est prête, redémarrer maintenant ?"; +"Daily" = "Quotidien"; +"Hourly Tokens" = "Jetons horaires"; +"No data" = "Aucune donnée"; +"No usage breakdown data available." = "Aucune donnée de répartition d'utilisation disponible."; + +"Today: %@ · %@ tokens" = "Aujourd'hui : %@ · %@ jetons"; +"Today: %@" = "Aujourd'hui : %@"; +"Today: %@ tokens" = "Aujourd'hui : %@ jetons"; +"Last 30 days: %@ · %@ tokens" = "30 derniers jours : %@ · %@ jetons"; +"Last 30 days: %@" = "30 derniers jours : %@"; +"Est. total (30d): %@" = "HNE. total (30j) : %@"; +"Est. total (%@): %@" = "HNE. total (%@) : %@"; +"Hover a bar for details" = "Passez la souris sur une barre pour plus de détails"; +"%@: %@ · %@ tokens" = "%@ : %@ · %@ jetons"; +"No providers selected for Overview." = "Aucun fournisseur sélectionné pour la vue d'ensemble."; +"No overview data available." = "Aucune donnée globale disponible."; +"Auto uses the local IDE API first, then Google OAuth when the IDE is closed." = "Auto utilise d'abord l'API IDE locale, puis Google OAuth lorsque l'EDI est fermé."; +"Login with Google" = "Connectez-vous avec Google"; + +/* Popup panels */ +"No usage configured." = "Aucune utilisation configurée."; +"Quota" = "Quota"; +"Daily quota" = "Quota quotidien"; +"Total" = "Total"; +"tokens" = "jetons"; +"requests" = "requêtes"; +"Latest" = "Dernier"; +"Monthly" = "Mensuel"; +"Sonnet" = "Sonnet"; +"Overages" = "Dépassements"; +"Activity" = "Activité"; +"Copied" = "Copié"; +"Copy error" = "Erreur de copie"; +"Copy path" = "Copier le chemin"; +"Extra usage spent" = "Utilisation supplémentaire dépensée"; +"Credits remaining" = "Crédits restants"; +"Using CLI fallback" = "Utilisation de la solution de secours CLI"; +"Balance updates in near-real time (up to 5 min lag)" = "Mises à jour du solde en temps quasi réel (jusqu'à 5 minutes de décalage)"; +"Daily billing data finalizes at 07:00 UTC" = "Les données de facturation quotidiennes se terminent à 07h00 UTC"; +"%@ of %@ credits left" = "%@ sur %@ crédits restants"; +"%@ of %@ bonus credits left" = "%@ de %@ crédits bonus restants"; +"%@ / %@ (%@ remaining)" = "%@ / %@ (%@ restant)"; +"%@/%@ left" = "%@/%@ gauche"; +"Gemini Flash" = "Flash Gémeaux"; +"Regenerates %@" = "Régénère %@"; +"used after next regen" = "utilisé après la prochaine régénération"; +"after next regen" = "après la prochaine régénération"; +"Near full" = "Presque plein"; +"Full in ~1 regen" = "Complet en ~1 régénération"; +"Full in ~%.0f regens" = "Plein en ~%.0f régénérations"; +"Overage usage" = "Utilisation excédentaire"; +"Overage cost" = "Coût excédentaire"; +"credits" = "crédits"; +"Zen balance" = "L'équilibre zen"; +"API spend" = "Dépenses API"; +"Extra usage" = "Utilisation supplémentaire"; +"Quota usage" = "Utilisation des quotas"; +"Your spend" = "Votre dépense"; +"%.0f%% used" = "%.0f%% utilisé"; +"Usage history (today)" = "Historique d'utilisation (aujourd'hui)"; +"Usage history (%d days)" = "Historique d'utilisation (%d jours)"; +"%d percent remaining" = "%d pour cent restant"; +"Unknown" = "Inconnu"; +"stale data" = "données obsolètes"; +"No credits history data." = "Aucune donnée d'historique de crédits."; +"No credits history data available." = "Aucune donnée d'historique de crédits disponible."; +"Credits history chart" = "Graphique de l'historique des crédits"; +"%d days of credits data" = "%d jours de données de crédits"; +"Usage breakdown chart" = "Tableau de répartition de l'utilisation"; +"%d days of usage data across %d services" = "%d jours de données d'utilisation sur %d services"; +"Cost history chart" = "Graphique de l'historique des coûts"; +"%d days of cost data" = "%d jours de données sur les coûts"; +"Plan utilization chart" = "Tableau d'utilisation du plan"; +"%d utilization samples" = "Exemples d'utilisation de %d"; +"Hourly Usage" = "Utilisation horaire"; +"Usage remaining" = "Utilisation restante"; +"Usage used" = "Utilisation utilisée"; +"API key verified. Cloud quotas need browser cookies. Sign in to Ollama." = "Clé API vérifiée. Les quotas Cloud nécessitent les cookies du navigateur. Connectez-vous à Ollama."; +"Last 30 days: %@ tokens" = "30 derniers jours : jetons %@"; +"7d spend" = "7j dépensés"; +"30d spend" = "30 jours de dépenses"; +"Cache read" = "Lecture du cache"; +"Claude Admin API 30 day spend trend" = "Tendance des dépenses de l'API Claude Admin sur 30 jours"; +"OpenRouter API key spend trend" = "Tendance des dépenses liées aux clés API OpenRouter"; +"z.ai hourly token trend" = "tendance des jetons horaires z.ai"; +"MiniMax 30 day token usage trend" = "Tendance d'utilisation des jetons MiniMax sur 30 jours"; +"Today cash" = "Aujourd'hui en espèces"; +"DeepSeek 30 day token usage trend" = "Tendance d'utilisation des jetons DeepSeek sur 30 jours"; +"cache-hit input" = "entrée d'accès au cache"; +"cache-miss input" = "entrée manquante dans le cache"; +"output" = "sortie"; +"Requests" = "Requêtes"; +"Reported by OpenAI Admin API organization usage." = "Signalé par l’utilisation de l’organisation de l’API OpenAI Admin."; +"Reported by Mistral billing usage." = "Rapporté par l'utilisation de la facturation Mistral."; +"Google OAuth" = "Google OAuth"; +"Add accounts via GitHub OAuth Device Flow on the selected host." = "Ajoutez des comptes via GitHub OAuth Device Flow sur l'hôte sélectionné."; +"Stores each signed-in Google account for quick Antigravity switching. Uses Antigravity.app OAuth when available, or ANTIGRAVITY_OAUTH_CLIENT_ID and ANTIGRAVITY_OAUTH_CLIENT_SECRET as an override." = "Stocke chaque compte Google connecté pour un changement rapide d'antigravité. Utilise Antigravity.app OAuth lorsqu'il est disponible, ou ANTIGRAVITY_OAUTH_CLIENT_ID et ANTIGRAVITY_OAUTH_CLIENT_SECRET comme remplacement."; +"Manual cleanup: past sessions" = "Nettoyage manuel : sessions précédentes"; +"Clearing removes past resume, continue, and rewind history." = "L'effacement supprime l'historique de reprise, de continuation et de rembobinage passé."; +"Manual cleanup: file checkpoints" = "Nettoyage manuel : points de contrôle des fichiers"; +"Clearing removes checkpoint restore data for previous edits." = "La suppression supprime les données de restauration du point de contrôle pour les modifications précédentes."; +"Manual cleanup: saved plans" = "Nettoyage manuel : plans enregistrés"; +"Clearing removes old plan-mode files." = "La suppression supprime les anciens fichiers en mode plan."; +"Manual cleanup: debug logs" = "Nettoyage manuel : journaux de débogage"; +"Clearing removes past debug logs." = "La suppression supprime les anciens journaux de débogage."; +"Manual cleanup: attachment cache" = "Nettoyage manuel : cache des pièces jointes"; +"Clearing removes cached large pastes or attached images." = "La suppression supprime les gros collages mis en cache ou les images jointes."; +"Manual cleanup: session metadata" = "Nettoyage manuel : métadonnées de session"; +"Clearing removes per-session environment metadata." = "La suppression supprime les métadonnées de l'environnement par session."; +"Manual cleanup: shell snapshots" = "Nettoyage manuel : instantanés du shell"; +"Clearing removes leftover runtime shell snapshot files." = "La suppression supprime les fichiers instantanés du shell d'exécution restants."; +"Manual cleanup: legacy todos" = "Nettoyage manuel : tâches héritées"; +"Clearing removes legacy per-session task lists." = "La suppression supprime les anciennes listes de tâches par session."; +"Manual cleanup: sessions" = "Nettoyage manuel : sessions"; +"Clearing removes past Codex session history." = "La suppression supprime l'historique des sessions Codex passées."; +"Manual cleanup: archived sessions" = "Nettoyage manuel : sessions archivées"; +"Clearing removes archived Codex session history." = "La suppression supprime l'historique des sessions Codex archivé."; +"Manual cleanup: cache" = "Nettoyage manuel : cache"; +"Clearing removes provider-owned cached data." = "La suppression supprime les données mises en cache appartenant au fournisseur."; +"Manual cleanup: logs" = "Nettoyage manuel : journaux"; +"Clearing removes local diagnostic logs." = "La suppression supprime les journaux de diagnostic locaux."; +"Manual cleanup: file history" = "Nettoyage manuel : historique des fichiers"; +"Clearing removes local edit checkpoint history." = "La suppression supprime l’historique des points de contrôle des modifications locales."; +"Manual cleanup: temporary data" = "Nettoyage manuel : données temporaires"; +"Clearing removes local temporary provider data." = "La suppression supprime les données du fournisseur temporaire local."; +"Total: %@" = "Total : %@"; +"%d more items" = "%d plus d'articles"; +"Cleanup ideas" = "Idées de nettoyage"; +"%d unreadable item(s) skipped" = "%d élément(s) illisible(s) ignoré(s)"; + +"API key limit" = "Limite de clé API"; +"Auth" = "Authentification"; +"Auto" = "Auto"; +"Disabled — no recent data" = "Désactivé – aucune donnée récente"; +"Limits not available" = "Limites non disponibles"; +"No usage yet" = "Pas encore d'utilisation"; +"Not fetched yet" = "Pas encore récupéré"; +"Refreshing" = "Actualisation"; +"Session" = "Session"; +"Source" = "Source"; +"State" = "État"; +"Unavailable" = "Indisponible"; +"Weekly" = "Hebdomadaire"; +"not detected" = "non détecté"; +"Estimated from local Codex logs for the selected account." = "Estimé à partir des journaux Codex locaux pour le compte sélectionné."; +"minimax_usage_amount_format" = "Utilisation : %@ / %@"; +"minimax_used_percent_format" = "Utilisé %@"; +"minimax_service_text_generation" = "Génération de texte"; +"minimax_service_text_to_speech" = "Synthèse vocale"; +"minimax_service_music_generation" = "Génération de musique"; +"minimax_service_image_generation" = "Génération d'images"; +"minimax_service_lyrics_generation" = "Génération de paroles"; +"minimax_service_coding_plan_vlm" = "Plan de codage VLM"; +"minimax_service_coding_plan_search" = "Recherche de plan de codage"; + +/* Additional provider settings and alerts */ +"%@ is waiting for permission" = "%@ attend l'autorisation"; +"%@ requests" = "%@ requêtes"; +"%@: %@ credits" = "%@ : %@ crédits"; +"30d requests" = "demandes 30j"; +"4 days" = "4 jours"; +"5 days" = "5 jours"; +"7 days" = "7 jours"; +"API key verifies Ollama Cloud access; cookies still expose quota limits." = "La clé API vérifie l'accès à Ollama Cloud ; les cookies exposent toujours des limites de quota."; +"AWS access key ID. Can also be set with AWS_ACCESS_KEY_ID." = "ID de clé d'accès AWS. Peut également être défini avec AWS_ACCESS_KEY_ID."; +"AWS region. Can also be set with AWS_REGION." = "Région AWS. Peut également être défini avec AWS_REGION."; +"AWS secret access key. Can also be set with AWS_SECRET_ACCESS_KEY." = "Clé d'accès secrète AWS. Peut également être défini avec AWS_SECRET_ACCESS_KEY."; +"Access key ID" = "ID de la clé d'accès"; +"Add Account" = "Ajouter un compte"; +"Adding Account…" = "Ajout d'un compte…"; +"Antigravity login failed" = "La connexion antigravité a échoué"; +"Antigravity login timed out" = "La connexion antigravité a expiré"; +"Auth source" = "Source d'authentification"; +"Automatic imports browser cookies from Xiaomi MiMo." = "Importe automatiquement les cookies du navigateur de Xiaomi MiMo."; +"Automatic imports Windsurf session data from Chromium browser localStorage." = "Importe automatiquement les données de session Windsurf à partir du navigateur Chromium localStorage."; +"Automatic imports browser cookies from Bailian." = "Importe automatiquement les cookies du navigateur depuis Bailian."; +"Automatically imports browser cookies." = "Importe automatiquement les cookies du navigateur."; +"Automatically imports browser session cookies." = "Importe automatiquement les cookies de session du navigateur."; +"Azure OpenAI deployment name. AZURE_OPENAI_DEPLOYMENT_NAME is also supported." = "Nom du déploiement Azure OpenAI. AZURE_OPENAI_DEPLOYMENT_NAME est également pris en charge."; +"Azure OpenAI key" = "Clé Azure OpenAI"; +"Azure OpenAI resource endpoint. AZURE_OPENAI_ENDPOINT is also supported." = "Point de terminaison de ressource Azure OpenAI. AZURE_OPENAI_ENDPOINT est également pris en charge."; +"Base URL" = "URL de base"; +"Base URL for the LLM-API-Key-Proxy instance." = "URL de base pour l'instance LLM-API-Key-Proxy."; +"Browser cookies" = "Cookies du navigateur"; +"Cap end" = "Fin du capuchon"; +"Cap start" = "Début du plafond"; +"Capacity End" = "Fin de capacité"; +"Capacity Start" = "Capacité Début"; +"Changelog" = "Journal des modifications"; +"Choose the Moonshot/Kimi API host for international or China mainland accounts." = "Choisissez l'hôte API Moonshot/Kimi pour les comptes internationaux ou en Chine continentale."; +"CodexBar can't replace a system account that is signed in with an API key only setup." = "CodexBar ne peut pas remplacer un compte système connecté par une configuration de clé API uniquement."; +"CodexBar could not find saved auth for that account. Re-authenticate it and try again." = "CodexBar n'a pas pu trouver l'authentification enregistrée pour ce compte. Ré-authentifiez-le et réessayez."; +"CodexBar could not read managed account storage. Recover the store before adding another account." = "CodexBar n'a pas pu lire le stockage du compte géré. Récupérez la boutique avant d'ajouter un autre compte."; +"CodexBar could not read saved auth for that account. Re-authenticate it and try again." = "CodexBar n'a pas pu lire l'authentification enregistrée pour ce compte. Ré-authentifiez-le et réessayez."; +"CodexBar could not read the current system account on this Mac." = "CodexBar n'a pas pu lire le compte système actuel sur ce Mac."; +"CodexBar could not replace the live Codex auth on this Mac." = "CodexBar n'a pas pu remplacer l'authentification Codex en direct sur ce Mac."; +"CodexBar could not safely preserve the current system account before switching." = "CodexBar n'a pas pu conserver en toute sécurité le compte système actuel avant le changement."; +"CodexBar could not save the current system account before switching." = "CodexBar n'a pas pu enregistrer le compte système actuel avant de changer."; +"CodexBar could not update managed account storage." = "CodexBar n'a pas pu mettre à jour le stockage du compte géré."; +"CodexBar found another managed account that already uses the current system account. Resolve the duplicate account before switching." = "CodexBar a trouvé un autre compte géré qui utilise déjà le compte système actuel. Résolvez le compte en double avant de changer."; +"CodexBar will ask macOS Keychain for “%@” so it can decrypt browser cookies and authenticate your account. Click OK to continue." = "CodexBar demandera au Trousseau macOS « %@ » afin de pouvoir décrypter les cookies du navigateur et authentifier votre compte. Cliquez sur OK pour continuer."; +"CodexBar will ask macOS Keychain for the Claude Code OAuth token so it can fetch your Claude usage. Click OK to continue." = "CodexBar demandera au Trousseau macOS le jeton Claude Code OAuth afin de pouvoir récupérer votre utilisation de Claude. Cliquez sur OK pour continuer."; +"CodexBar will ask macOS Keychain for your Amp cookie header so it can fetch usage. Click OK to continue." = "CodexBar demandera au Trousseau macOS l'en-tête de votre cookie Amp afin de pouvoir récupérer son utilisation. Cliquez sur OK pour continuer."; +"CodexBar will ask macOS Keychain for your Augment cookie header so it can fetch usage. Click OK to continue." = "CodexBar demandera au Trousseau macOS votre en-tête de cookie Augment afin de pouvoir récupérer son utilisation. Cliquez sur OK pour continuer."; +"CodexBar will ask macOS Keychain for your Claude cookie header so it can fetch Claude web usage. Click OK to continue." = "CodexBar demandera au Trousseau macOS votre en-tête de cookie Claude afin de pouvoir récupérer l'utilisation du Web de Claude. Cliquez sur OK pour continuer."; +"CodexBar will ask macOS Keychain for your Cursor cookie header so it can fetch usage. Click OK to continue." = "CodexBar demandera au Trousseau macOS l'en-tête de votre cookie Cursor afin de pouvoir récupérer son utilisation. Cliquez sur OK pour continuer."; +"CodexBar will ask macOS Keychain for your Factory cookie header so it can fetch usage. Click OK to continue." = "CodexBar demandera au Trousseau macOS l'en-tête de votre cookie d'usine afin de pouvoir récupérer son utilisation. Cliquez sur OK pour continuer."; +"CodexBar will ask macOS Keychain for your GitHub Copilot token so it can fetch usage. Click OK to continue." = "CodexBar demandera au Trousseau macOS votre jeton GitHub Copilot afin de pouvoir récupérer son utilisation. Cliquez sur OK pour continuer."; +"CodexBar will ask macOS Keychain for your Kimi auth token so it can fetch usage. Click OK to continue." = "CodexBar demandera au Trousseau macOS votre jeton d'authentification Kimi afin de pouvoir récupérer son utilisation. Cliquez sur OK pour continuer."; +"CodexBar will ask macOS Keychain for your MiniMax API token so it can fetch usage. Click OK to continue." = "CodexBar demandera au Trousseau macOS votre jeton API MiniMax afin de pouvoir récupérer son utilisation. Cliquez sur OK pour continuer."; +"CodexBar will ask macOS Keychain for your MiniMax cookie header so it can fetch usage. Click OK to continue." = "CodexBar demandera au Trousseau macOS votre en-tête de cookie MiniMax afin de pouvoir récupérer son utilisation. Cliquez sur OK pour continuer."; +"CodexBar will ask macOS Keychain for your OpenAI cookie header so it can fetch Codex dashboard extras. Click OK to continue." = "CodexBar demandera au Trousseau macOS votre en-tête de cookie OpenAI afin de pouvoir récupérer les extras du tableau de bord Codex. Cliquez sur OK pour continuer."; +"CodexBar will ask macOS Keychain for your OpenCode cookie header so it can fetch usage. Click OK to continue." = "CodexBar demandera au Trousseau macOS votre en-tête de cookie OpenCode afin de pouvoir récupérer son utilisation. Cliquez sur OK pour continuer."; +"CodexBar will ask macOS Keychain for your Synthetic API key so it can fetch usage. Click OK to continue." = "CodexBar demandera au Trousseau macOS votre clé API synthétique afin de pouvoir récupérer son utilisation. Cliquez sur OK pour continuer."; +"CodexBar will ask macOS Keychain for your z.ai API token so it can fetch usage. Click OK to continue." = "CodexBar demandera au Trousseau macOS votre jeton API z.ai afin de pouvoir récupérer son utilisation. Cliquez sur OK pour continuer."; +"Could not open Cursor login in your browser." = "Impossible d'ouvrir la connexion par curseur dans votre navigateur."; +"Could not open browser for Antigravity" = "Impossible d'ouvrir le navigateur pour Antigravity"; +"Credits used" = "Crédits utilisés"; +"Day" = "Jour"; +"Deployment" = "Déploiement"; +"Drag to reorder" = "Faites glisser pour réorganiser"; +"Sort providers alphabetically" = "Trier les fournisseurs par ordre alphabétique"; +"Sort providers alphabetically (enabled first)" = "Trier les fournisseurs par ordre alphabétique (activés en premier)"; +"Sorted alphabetically (enabled first) — click to use your custom order" = "Triés par ordre alphabétique (activés en premier) — cliquez pour utiliser votre ordre personnalisé"; +"Endpoint" = "Point de terminaison"; +"Enterprise host" = "Hôte d'entreprise"; +"Extra usage balance: %@" = "Solde d'utilisation supplémentaire : %@"; +"Keychain Access Required" = "Accès au Trousseau requis"; +"keychain_prompt_learn_more" = "En savoir plus…"; +"keychain_prompt_privacy_note" = "La saisie du mot de passe de connexion au Mac est gérée par macOS, pas par CodexBar. Vous pouvez désactiver l'accès au Trousseau à tout moment dans Réglages → Avancé."; +"Kiro menu bar value" = "Valeur de la barre de menu Kiro"; +"Label" = "Libellé"; +"No organizations loaded. Click Refresh after setting your API key." = "Aucune organisation chargée. Cliquez sur Actualiser après avoir défini votre clé API."; +"No output captured." = "Aucune sortie capturée."; +"No system account" = "Aucun compte système"; +"Oasis-Token" = "Oasis-Token"; +"Open Augment (Log Out & Back In)" = "Ouvrir l'augmentation (déconnexion et reconnexion)"; +"Open Codebuff Dashboard" = "Ouvrir le tableau de bord Codebuff"; +"Open Command Code Settings" = "Ouvrir les paramètres du code de commande"; +"Open Crof dashboard" = "Ouvrir le tableau de bord Crof"; +"Open Manus" = "Ouvrir Manus"; +"Open MiMo Balance" = "Ouvrir la balance MiMo"; +"Open Moonshot Console" = "Ouvrir la console Moonshot"; +"Open Ollama API Keys" = "Ouvrir les clés API Ollama"; +"Open StepFun Platform" = "Ouvrir la plateforme StepFun"; +"Open T3 Chat Settings" = "Ouvrir les paramètres de discussion T3"; +"Open Volcengine Ark Console" = "Ouvrir la console Volcengine Ark"; +"Open legacy provider docs" = "Ouvrir les documents du fournisseur existant"; +"Open projects" = "Projets ouverts"; +"Open this URL manually to continue login:\n\n%@" = "Ouvrez cette URL manuellement pour continuer la connexion :\n\n%@"; +"Optional organization ID for accounts linked to multiple Anthropic organizations." = "ID d'organisation facultatif pour les comptes liés à plusieurs organisations Anthropic."; +"Optional. Applies to the configured Admin API key; selected token accounts do not inherit OPENAI_PROJECT_ID." = "Facultatif. S'applique à la clé API Admin configurée ; Les comptes de jetons sélectionnés n'héritent pas d'OPENAI_PROJECT_ID."; +"Optional. Enter your GitHub Enterprise host, for example octocorp.ghe.com. Leave blank for github.com." = "Facultatif. Entrez votre hôte GitHub Enterprise, par exemple octocorp.ghe.com. Laissez vide pour github.com."; +"Optional. Leave blank to discover and aggregate projects visible to the API key." = "Facultatif. Laissez vide pour découvrir et regrouper les projets visibles par la clé API."; +"Org ID (optional)" = "ID de l'organisation (facultatif)"; +"Organizations" = "Organisations"; +"Organization ID" = "ID de l'organisation"; +"Password" = "Mot de passe"; +"%@ authentication is disabled." = "L'authentification %@ est désactivée."; +"%@ cookies are disabled." = "Les cookies %@ sont désactivés."; +"%@ web API access is disabled." = "L'accès à l'API Web %@ est désactivé."; +"Disable %@ dashboard cookie usage." = "Désactivez l'utilisation des cookies du tableau de bord %@."; +"Keychain access is disabled in Advanced, so browser cookie import is unavailable." = "L'accès au Trousseau est désactivé dans Advanced, donc l'importation des cookies du navigateur n'est pas disponible."; +"Manually paste an %@ from a browser session." = "Collez manuellement un %@ à partir d'une session de navigateur."; +"Paste a Cookie header captured from %@." = "Collez un en-tête de cookie capturé à partir de %@."; +"Paste a Cookie header from %@." = "Collez un en-tête de cookie à partir de %@."; +"Paste a Cookie header or cURL capture from %@." = "Collez un en-tête de cookie ou une capture cURL à partir de %@."; +"Paste a Cookie header or full cURL capture from %@." = "Collez un en-tête de cookie ou une capture cURL complète à partir de %@."; +"Paste a Cookie or Authorization header from %@." = "Collez un en-tête de cookie ou d'autorisation à partir de %@."; +"Paste a full cookie header or the %@ value." = "Collez un en-tête de cookie complet ou la valeur %@."; +"Paste a Cookie header or full cURL capture from T3 Chat settings." = "Collez un en-tête de cookie ou une capture cURL complète à partir des paramètres de T3 Chat."; +"Paste the Cookie header from a request to admin.mistral.ai. Must contain an ory_session_* cookie." = "Collez l'en-tête Cookie d'une requête vers admin.mistral.ai. Doit contenir un cookie ory_session_*."; +"Paste the Oasis-Token from a logged-in browser session on platform.stepfun.com." = "Collez le jeton Oasis à partir d'une session de navigateur connectée sur platform.stepfun.com."; +"Paste the %@ JSON bundle from %@." = "Collez le bundle JSON %@ de %@."; +"Paste the %@ value or a full Cookie header." = "Collez la valeur %@ ou un en-tête de cookie complet."; +"Personal account" = "Compte personnel"; +"Project ID" = "ID du projet"; +"Re-auth" = "Se reconnecter"; +"Re-login at claude.ai" = "Se reconnecter à claude.ai"; +"Re-authenticating…" = "Réauthentification…"; +"Refresh Session" = "Session de rafraîchissement"; +"Refresh organizations" = "Actualiser les organisations"; +"Region" = "Région"; +"Reload" = "Recharger"; +"Reorder" = "Réorganiser"; +"Secret access key" = "Clé d'accès secrète"; +"Series" = "Séries"; +"Service" = "Service"; +"Show or hide Kiro credits, percent, or both next to the menu bar icon." = "Affichez ou masquez les crédits Kiro, le pourcentage ou les deux à côté de l'icône de la barre de menu."; +"Show usage for organizations you belong to. Personal account is always shown." = "Afficher l'utilisation des organisations auxquelles vous appartenez. Le compte personnel est toujours affiché."; +"Sign in to cursor.com in your browser, then refresh Cursor in CodexBar." = "Connectez-vous à cursor.com dans votre navigateur, puis actualisez Cursor dans CodexBar."; +"Simulated error text" = "Texte d'erreur simulé"; +"StepFun platform account (phone number or email)." = "Compte de la plateforme StepFun (numéro de téléphone ou email)."; +"Stored in ~/.codexbar/config.json." = "Stocké dans ~/.codexbar/config.json."; +"Stored in ~/.codexbar/config.json. AZURE_OPENAI_API_KEY is also supported." = "Stocké dans ~/.codexbar/config.json. AZURE_OPENAI_API_KEY est également pris en charge."; +"Stored in ~/.codexbar/config.json. For the official Kimi API, use Moonshot / Kimi API." = "Stocké dans ~/.codexbar/config.json. Pour l'API Kimi officielle, utilisez l'API Moonshot / Kimi."; +"Stored in ~/.codexbar/config.json. Get your API key from the Volcengine Ark console." = "Stocké dans ~/.codexbar/config.json. Obtenez votre clé API depuis la console Volcengine Ark."; +"Stored in ~/.codexbar/config.json. Get your key from Ollama settings." = "Stocké dans ~/.codexbar/config.json. Obtenez votre clé dans les paramètres d'Ollama."; +"Stored in ~/.codexbar/config.json. Get your key from console.deepgram.com." = "Stocké dans ~/.codexbar/config.json. Obtenez votre clé sur console.deepgram.com."; +"Stored in ~/.codexbar/config.json. Get your key from elevenlabs.io/app/settings/api-keys." = "Stocké dans ~/.codexbar/config.json. Obtenez votre clé sur Elevenlabs.io/app/settings/api-keys."; +"Stored in ~/.codexbar/config.json. Get your key from openrouter.ai/settings/keys and set a key spending limit there to enable API key quota tracking." = "Stocké dans ~/.codexbar/config.json. Obtenez votre clé sur openrouter.ai/settings/keys et définissez-y une limite de dépenses pour activer le suivi des quotas de clés API."; +"Stored in ~/.codexbar/config.json. In Warp, open Settings > Platform > API Keys, then create one." = "Stocké dans ~/.codexbar/config.json. Dans Warp, ouvrez Paramètres > Plateforme > Clés API, puis créez-en une."; +"Stored in ~/.codexbar/config.json. Metrics require Groq Enterprise Prometheus access." = "Stocké dans ~/.codexbar/config.json. Les métriques nécessitent un accès à Groq Enterprise Prometheus."; +"Stored in ~/.codexbar/config.json. OPENAI_ADMIN_KEY is preferred; OPENAI_API_KEY still works." = "Stocké dans ~/.codexbar/config.json. OPENAI_ADMIN_KEY est préféré ; OPENAI_API_KEY fonctionne toujours."; +"Stored in ~/.codexbar/config.json. Requires an Anthropic Admin API key." = "Stocké dans ~/.codexbar/config.json. Nécessite une clé API Anthropic Admin."; +"Stored in ~/.codexbar/config.json. Used for /v1/quota-stats." = "Stocké dans ~/.codexbar/config.json. Utilisé pour /v1/quota-stats."; +"Stored in ~/.codexbar/config.json. You can also provide CODEBUFF_API_KEY or let CodexBar read ~/.config/manicode/credentials.json (created by `codebuff login`)." = "Stocké dans ~/.codexbar/config.json. Vous pouvez également fournir CODEBUFF_API_KEY ou laisser CodexBar lire ~/.config/manicode/credentials.json (créé par `codebuff login`)."; +"Stored in ~/.codexbar/config.json. You can also provide CROF_API_KEY." = "Stocké dans ~/.codexbar/config.json. Vous pouvez également fournir CROF_API_KEY."; +"Stored in ~/.codexbar/config.json. You can also provide KILO_API_KEY or ~/.local/share/kilo/auth.json (kilo.access)." = "Stocké dans ~/.codexbar/config.json. Vous pouvez également fournir KILO_API_KEY ou ~/.local/share/kilo/auth.json (kilo.access)."; +"T3 Chat cookie" = "Cookie de discussion T3"; +"Team mode" = "Mode équipe"; +"That account is no longer available in CodexBar. Refresh the account list and try again." = "Ce compte n'est plus disponible dans CodexBar. Actualisez la liste des comptes et réessayez."; +"The browser login did not complete in time. Try Antigravity login again." = "La connexion au navigateur ne s'est pas terminée à temps. Essayez à nouveau de vous connecter à Antigravity."; +"Timed out waiting for Cursor login. %@" = "Le délai d'attente pour la connexion au curseur a expiré. %@"; +"Timed out waiting for Cursor login. %@ Last error: %@" = "Le délai d'attente pour la connexion au curseur a expiré. %@ Dernière erreur : %@"; +"Today requests" = "Demandes d'aujourd'hui"; +"Total (30d): %@ credits" = "Total (30j) : %@ crédits"; +"Username" = "Nom d’utilisateur"; +"Uses username + password to login and obtain an Oasis-Token automatically." = "Utilise le nom d'utilisateur + le mot de passe pour se connecter et obtenir automatiquement un jeton Oasis."; +"Uses username + password to login and obtain an %@ automatically." = "Utilise le nom d'utilisateur + le mot de passe pour se connecter et obtenir automatiquement un %@."; +"Utilization End" = "Fin d'utilisation"; +"Utilization Start" = "Début de l'utilisation"; +"Verbosity" = "Niveau de verbosité"; +"Windsurf session JSON bundle" = "Pack JSON de session de planche à voile"; +"Workspace ID" = "ID de l'espace de travail"; +"Your StepFun platform password. Used to login and obtain a session token." = "Votre mot de passe de la plateforme StepFun. Utilisé pour se connecter et obtenir un jeton de session."; +"claude /login exited with status %d." = "claude /login est sorti avec le statut %d."; +"codex login exited with status %d." = "La connexion à Codex s'est terminée avec le statut %d."; +"Cookie: …\n\nor paste a cURL capture from the Abacus AI dashboard" = "Cookie : …\n\nou collez une capture cURL à partir du tableau de bord Abacus AI"; +"Cookie: …\n\nor paste the __Secure-next-auth.session-token value" = "Cookie : …\n\nou collez la valeur __Secure-next-auth.session-token"; +"Cookie: …\n\nor paste the kimi-auth token value" = "Cookie : …\n\nou collez la valeur du jeton kimi-auth"; +"session_id=...\n\nor paste just the session_id value" = "session_id=...\n\nou collez uniquement la valeur session_id"; +"Clear" = "Effacer"; +"No matching providers" = "Aucun fournisseur correspondant"; +"Search providers" = "Fournisseurs de recherche"; +"language_arabic" = "العربية"; +"language_persian" = "فارسی"; +"language_thai" = "ไทย"; +"language_galician" = "Galego"; +"Limit Reset Credits" = "Crédits de réinitialisation de limite"; +"1 available" = "1 disponible"; +"%d available" = "%d disponibles"; +"Next expires %@" = "Prochaine expiration %@"; +"Expires %@" = "Expire %@"; +"No expiry" = "Sans expiration"; +"Other (%d items)" = "Autres (%d éléments)"; +"Expand" = "Développer"; +"Collapse" = "Réduire"; +"byte_unit_byte" = "octet"; +"byte_unit_bytes" = "octets"; +"byte_unit_kilobyte" = "kilooctet"; +"byte_unit_kilobytes" = "kilooctets"; +"byte_unit_megabyte" = "mégaoctet"; +"byte_unit_megabytes" = "mégaoctets"; +"byte_unit_gigabyte" = "gigaoctet"; +"byte_unit_gigabytes" = "gigaoctets"; + +/* Settings sidebar redesign */ +"Enable" = "Activer"; +"Disable" = "Désactiver"; +"providers_on_count" = "%d activés"; +"section_cost_summary" = "Résumé des coûts"; +"section_command_line" = "Ligne de commande"; +"section_privacy" = "Confidentialité"; +"section_diagnostics" = "Diagnostics"; +"section_updates" = "Mises à jour"; +"section_links" = "Liens"; +"Show Codex Spark usage" = "Afficher l’utilisation de Codex Spark"; +"Shows Codex Spark quota rows in the menu and provider preview. Requires optional credits and extra usage in Display settings." = "Affiche les lignes de quota Codex Spark dans le menu et l’aperçu du fournisseur. Nécessite d’activer « Afficher les crédits + utilisation supplémentaire » dans les réglages Affichage."; +"Show Daily Routines usage" = "Afficher l’utilisation de Routines quotidiennes"; +"Shows the Daily Routines quota row in the menu and provider preview. Requires optional credits and extra usage in Display settings." = "Affiche la ligne de quota Routines quotidiennes dans le menu et l’aperçu du fournisseur. Nécessite d’activer « Afficher les crédits + utilisation supplémentaire » dans les réglages Affichage."; +"Scroll to see more models" = "Faites défiler pour voir plus de modèles"; +"Copy Image" = "Copier l’image"; +"Copy Stats" = "Copier les statistiques"; +"Could not copy image" = "Impossible de copier l’image"; +"Image copied" = "Image copiée"; +"Image saved" = "Image enregistrée"; +"Nothing is uploaded. This image is created on your Mac." = "Rien n’est téléversé. Cette image est créée sur votre Mac."; +"Save..." = "Enregistrer..."; +"Share AI Usage" = "Partager l’utilisation de l’IA"; +"Share Stats…" = "Partager les statistiques…"; +"Stats copied" = "Statistiques copiées"; +"DeepSeek this month token usage trend" = "Tendance d’utilisation des jetons DeepSeek ce mois-ci"; +"Chrome profile" = "Profil Chrome"; +"Choose which signed-in DeepSeek Platform session supplies detailed usage." = "Choisissez la session DeepSeek Platform connectée qui fournit l’utilisation détaillée."; +"Detailed usage unavailable." = "L’utilisation détaillée n’est pas disponible."; +"Sign in to DeepSeek Platform in Chrome for detailed usage." = "Connectez-vous à DeepSeek Platform dans Chrome pour afficher l’utilisation détaillée."; +"Select a DeepSeek Chrome profile in Settings." = "Sélectionnez un profil Chrome DeepSeek dans Réglages."; +"Select profile…" = "Sélectionner un profil…"; + +"%@: %@" = "%@ : %@"; +"Alternatively, set a custom path in Settings." = "Vous pouvez également définir un chemin personnalisé dans Paramètres."; +"Choose a supported browser so CodexBar can read the matching account." = "Choisissez un navigateur pris en charge pour que CodexBar puisse lire le compte correspondant."; +"Choose Cursor account" = "Choisissez le compte Cursor"; +"Choose which Cursor account CodexBar should use." = "Choisissez le compte Cursor que CodexBar doit utiliser."; +"Finish switching to a different Cursor account in your browser, then try again." = "Terminez de passer à un autre compte Cursor dans votre navigateur, puis réessayez."; +"Install a JetBrains IDE with AI Assistant enabled, then refresh CodexBar." = "Installez un IDE JetBrains avec AI Assistant activé, puis actualisez CodexBar."; +"Request quota: %@ / %@" = "Quota de requêtes : %@ / %@"; +"Sign in with Claude Code..." = "Connectez-vous avec Claude Code..."; +"Timed out waiting for Cursor account switch. %@" = "Le délai d'attente pour le changement de compte Cursor a expiré. %@"; +"Timed out waiting for Cursor account switch. %@ Last error: %@" = "Le délai d'attente pour le changement de compte Cursor a expiré. %@ Dernière erreur : %@"; +"Use Account" = "Utiliser le compte"; +/* Spend dashboard */ +"tab_usage_spend" = "Utilisation et dépenses"; +"Usage & Spend" = "Utilisation et dépenses"; +"Local estimated cost history across supported providers." = "Historique local des coûts estimés pour les fournisseurs pris en charge."; +"Time range" = "Période"; +"Track costs" = "Suivre les coûts"; +"Cost tracking is off" = "Le suivi des coûts est désactivé"; +"Turn on Track costs to build local estimates." = "Activez « Suivre les coûts » pour créer des estimations locales."; +"No local cost history yet" = "Aucun historique local des coûts pour l’instant"; +"Turn on cost tracking or refresh after using a supported provider." = "Activez le suivi local des coûts ou actualisez après avoir utilisé un fournisseur pris en charge."; +"Refresh failures" = "Échecs d’actualisation"; +"Native currencies stay separate; Codex account rows exclude Pi session history." = "Les devises d’origine restent séparées ; les lignes de compte Codex excluent l’historique des sessions Pi."; +"Spend unavailable" = "Dépenses indisponibles"; +"Model breakdown unavailable" = "Répartition par modèle indisponible"; +"Local estimated history" = "Historique local estimé"; +"Coverage" = "Couverture"; +"Estimated spend" = "Dépenses estimées"; +"Tracked tokens" = "Jetons suivis"; +"Subscriptions" = "Abonnements"; +"By subscription" = "Par abonnement"; +"No model-level history" = "Aucun historique au niveau des modèles"; +"Daily estimated spend" = "Dépenses quotidiennes estimées"; +"≈%d full 5h windows of weekly left · %d windows until reset" = "≈%d fenêtres complètes de 5 h de quota hebdomadaire · %d fenêtres avant réinitialisation"; +"Weekly cannot run out before reset at this pace" = "Le quota hebdomadaire ne peut pas être épuisé avant la réinitialisation à ce rythme"; +"Weekly can run out ≈%d windows early" = "Le quota hebdomadaire peut être épuisé ≈%d fenêtres plus tôt"; +"Estimated: %@" = "Estimation : %@"; +"session_quota_estimate_value_format" = "%1$@ %2$@"; +"session quota" = "quota de session"; +"session quotas" = "quotas de session"; +"Coding Plan" = "Plan de codage"; +"Agent Plan" = "Plan d'agent"; +"Team" = "Équipe"; + +/* Menu bar layout editor */ +"menu_bar_layout_title" = "Disposition"; +"menu_bar_layout_footer" = "Faites glisser les jetons pour organiser la barre des menus. Cliquez sur un jeton pour l’ajouter ; sélectionnez un jeton placé et appuyez sur Supprimer pour le retirer."; +"menu_bar_layout_group_identity" = "Identité"; +"menu_bar_layout_group_usage" = "Utilisation"; +"menu_bar_layout_group_time" = "Temps"; +"menu_bar_layout_group_money" = "Coût"; +"menu_bar_layout_group_structure" = "Structure"; +"menu_bar_layout_scope_all" = "Tous les fournisseurs"; +"menu_bar_layout_scope_help" = "Modifiez la disposition par défaut ou remplacez-la pour un fournisseur."; +"menu_bar_layout_use_all" = "Utiliser la disposition de tous les fournisseurs"; +"menu_bar_layout_preset" = "Préréglage de disposition"; +"menu_bar_layout_preset_icon_percent" = "Icône et pourcentage"; +"menu_bar_layout_preset_icon_only" = "Icône uniquement"; +"menu_bar_layout_preset_percent_reset" = "Pourcentage et réinitialisation"; +"menu_bar_layout_preset_compact_stacked" = "Empilement compact"; +"menu_bar_layout_preset_custom" = "Personnalisé"; +"menu_bar_layout_live_preview" = "Aperçu en direct"; +"menu_bar_layout_strip" = "Bande de la barre des menus"; +"menu_bar_layout_remove_line_break" = "Supprimer le saut de ligne"; +"menu_bar_layout_chip_hint" = "Sélectionnez, faites glisser pour réorganiser ou utilisez l’action Supprimer."; +"menu_bar_layout_palette_hint" = "Cliquez pour ajouter ou faites glisser dans la disposition."; +"menu_bar_layout_empty_line" = "Déposez un jeton ici"; +"menu_bar_layout_line" = "Ligne %d"; +"menu_bar_layout_drag_remove" = "Faites glisser ici pour supprimer"; +"menu_bar_layout_size" = "Taille"; +"menu_bar_layout_size_small" = "Petite"; +"menu_bar_layout_size_regular" = "Normale"; +"menu_bar_layout_gap" = "Espacement"; +"menu_bar_layout_gap_tight" = "Serré"; +"menu_bar_layout_gap_regular" = "Normale"; +"menu_bar_layout_keyboard_hint" = "Supprimer retire le jeton sélectionné"; +"menu_bar_layout_sample_account" = "compte"; +"menu_bar_layout_sample_runs_out" = "épuisé ven."; +"menu_bar_layout_token_icon" = "Icône"; +"menu_bar_layout_token_provider" = "Nom du fournisseur"; +"menu_bar_layout_token_account" = "Compte"; +"menu_bar_layout_token_session" = "Session %"; +"menu_bar_layout_token_weekly" = "Hebdomadaire %"; +"menu_bar_layout_token_auto" = "% auto"; +"menu_bar_layout_token_bar" = "Barre d’utilisation"; +"menu_bar_layout_token_resets_in" = "Réinitialisation dans"; +"menu_bar_layout_token_reset_at" = "Réinitialisation à"; +"menu_bar_layout_token_runs_out" = "Épuisé"; +"menu_bar_layout_token_cost_today" = "Coût aujourd’hui"; +"menu_bar_layout_token_cost_30d" = "Coût sur 30 j"; +"menu_bar_layout_token_space" = "Espace"; +"menu_bar_layout_token_line_break" = "Saut de ligne"; +"menu_bar_layout_token_separator_accessibility" = "Point séparateur"; + +/* Menu bar layout accessibility */ +"Icon unavailable" = "Icône: Indisponible"; +"%@ icon" = "%@: Icône"; +"Provider name unavailable" = "Nom du fournisseur: Indisponible"; +"Account unavailable" = "Compte: Indisponible"; +"%@ unavailable" = "%@: Indisponible"; +"%@ %@" = "%@ %@"; +"Usage bar unavailable" = "Barre d’utilisation: Indisponible"; +"Usage bar, %d of 3 filled" = "Barre d’utilisation: %d/3 remplis"; +"Reset countdown unavailable" = "Réinitialisation dans: Indisponible"; +"Reset time unavailable" = "Réinitialisation à: Indisponible"; +"Run-out estimate unavailable" = "Épuisé: Indisponible"; +"Cost today unavailable" = "Coût aujourd’hui: Indisponible"; +"30-day cost unavailable" = "Coût sur 30 j: Indisponible"; +"Resets" = "Réinitialisations"; diff --git a/Sources/CodexBar/Resources/fr.lproj/Localizable.stringsdict b/Sources/CodexBar/Resources/fr.lproj/Localizable.stringsdict new file mode 100644 index 0000000000..49d71e756c --- /dev/null +++ b/Sources/CodexBar/Resources/fr.lproj/Localizable.stringsdict @@ -0,0 +1,49 @@ + + + + + ≈%d full 5h windows of weekly left · %d windows until reset + + NSStringLocalizedFormatKey + %#@left@ · %#@until@ + left + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + d + one + ≈%d fenêtre complète de 5 h de quota hebdomadaire + other + ≈%d fenêtres complètes de 5 h de quota hebdomadaire + + until + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + d + one + %d fenêtre avant réinitialisation + other + %d fenêtres avant réinitialisation + + + Weekly can run out ≈%d windows early + + NSStringLocalizedFormatKey + %#@early@ + early + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + d + one + Le quota hebdomadaire peut être épuisé ≈%d fenêtre plus tôt + other + Le quota hebdomadaire peut être épuisé ≈%d fenêtres plus tôt + + + + diff --git a/Sources/CodexBar/Resources/gl.lproj/Localizable.strings b/Sources/CodexBar/Resources/gl.lproj/Localizable.strings new file mode 100644 index 0000000000..504f9adc5a --- /dev/null +++ b/Sources/CodexBar/Resources/gl.lproj/Localizable.strings @@ -0,0 +1,1353 @@ +/* Galician localization for CodexBar */ + +"ollama_safari_cookie_access_hint" = "As cookies de Safari precisan acceso total ao disco para CodexBar (Axustes do Sistema > Privacidade e seguridade)."; +"ollama_browser_cookie_decryption_denied" = "Rexeitouse no Chaveiro o descifrado das cookies de %@; téntao de novo cunha actualización manual."; +"ollama_browser_cookie_decryption_disabled" = "O descifrado das cookies de %@ está desactivado en CodexBar; activa o acceso ao Chaveiro e actualiza."; + +" providers" = " provedores"; +"(System)" = "(Sistema)"; +"30d" = "30d"; +"7d" = "7d"; +"A managed Codex login is already running. Wait for it to finish before adding " = "Xa hai un inicio de sesión xestionado de Codex en curso. Agarda a que remate antes de engadir "; +"API key" = "Chave de API"; +"API region" = "Rexión da API"; +"API token" = "Token de API"; +"API tokens" = "Tokens de API"; +"About" = "Acerca de"; +"Account" = "Conta"; +"Accounts" = "Contas"; +"Accounts subtitle" = "Subtítulo de contas"; +"Active" = "Activo"; +"Add" = "Engadir"; +"Add Workspace" = "Engadir espazo de traballo"; +"Advanced" = "Avanzado"; +"All" = "Todo"; +"Always allow prompts" = "Permitir sempre as solicitudes"; +"Animation pattern" = "Patrón de animación"; +"Antigravity login is managed in the app" = "O inicio de sesión de Antigravity xestiónase na aplicación"; +"Applies only to the Security.framework OAuth keychain reader." = "Só se aplica ao lector de Chaveiro OAuth de Security.framework."; +"Auto falls back to the next source if the preferred one fails." = "Recorre automaticamente á seguinte fonte se a preferida falla."; +"Auto uses API first, then falls back to CLI on auth failures." = "Usa automaticamente a API primeiro e recorre á CLI se falla a autenticación."; +"Auto-detect" = "Detección automática"; +"Auto-refresh is off; use the menu's Refresh command." = "A actualización automática está desactivada; usa a orde Actualizar do menú."; +"Auto-refresh: hourly · Timeout: 10m" = "Actualización automática: cada hora · Tempo de espera: 10 m"; +"Automatic" = "Automático"; +"Automatic imports browser cookies and WorkOS tokens." = "O modo automático importa cookies do navegador e tokens de WorkOS."; +"Automatic imports browser cookies and local storage tokens." = "O modo automático importa cookies do navegador e tokens de almacenamento local."; +"Automatic imports browser cookies for dashboard extras." = "O modo automático importa cookies do navegador para os extras do panel."; +"Automatic imports browser cookies for the web API." = "O modo automático importa cookies do navegador para a API web."; +"Automatic imports browser cookies from Model Studio/Bailian." = "O modo automático importa cookies do navegador desde Model Studio/Bailian."; +"Automatic imports browser cookies from admin.mistral.ai." = "O modo automático importa cookies do navegador desde admin.mistral.ai."; +"Automatic imports browser cookies from opencode.ai." = "O modo automático importa cookies do navegador desde opencode.ai."; +"Automatic imports browser cookies or stored sessions." = "O modo automático importa cookies do navegador ou sesións gardadas."; +"Automatic imports browser cookies." = "O modo automático importa cookies do navegador."; +"Automatically imports browser session cookie." = "Importa automaticamente a cookie de sesión do navegador."; +"Automatically opens CodexBar when you start your Mac." = "Abre CodexBar automaticamente ao iniciar o teu Mac."; +"Automation" = "Automatización"; +"Average (\\(label1) + \\(label2))" = "Media (\\(label1) + \\(label2))"; +"Average (\\(metadata.sessionLabel) + \\(metadata.weeklyLabel))" = "Media (\\(metadata.sessionLabel) + \\(metadata.weeklyLabel))"; +"Avoid Keychain prompts" = "Evitar as solicitudes do Chaveiro"; +"Balance" = "Saldo"; +"Battery Saver" = "Aforro de batería"; +"Bordered" = "Con bordo"; +"Build" = "Compilación"; +"Built \\(buildTimestamp)" = "Compilado o \\(buildTimestamp)"; +"Buy Credits..." = "Mercar créditos..."; +"Buy Credits…" = "Mercar créditos…"; +"CLI paths" = "Rutas da CLI"; +"CLI sessions" = "Sesións da CLI"; +"Caches" = "Cachés"; +"Cancel" = "Cancelar"; +"Check for Updates…" = "Buscar actualizacións…"; +"Check for updates automatically" = "Buscar actualizacións automaticamente"; +"Check if you like your agents having some fun up there." = "Actívao se che gusta que os teus axentes se divirtan aí arriba."; +"Check provider status" = "Comprobar o estado do provedor"; +"Choose a supported browser so CodexBar can read the matching account." = "Escolle un navegador compatible para que CodexBar poida ler a conta correspondente."; +"Choose Codex workspace" = "Escoller espazo de traballo de Codex"; +"Choose Cursor account" = "Escoller conta de Cursor"; +"Choose the MiniMax host (global .io or China mainland .com)." = "Escolle o servidor de MiniMax (global .io ou China continental .com)."; +"Choose up to " = "Escolle ata "; +"Choose up to \\(Self.maxOverviewProviders) providers" = "Escolle ata \\(Self.maxOverviewProviders) provedores"; +"Choose up to \\(count) providers" = "Escolle ata \\(count) provedores"; +"Choose what to show in the menu bar (Pace shows usage vs. expected)." = "Escolle que amosar na barra de menús (Ritmo amosa o uso fronte ao previsto)."; +"Choose which Codex account CodexBar should follow." = "Escolle que conta de Codex debe seguir CodexBar."; +"Choose which Cursor account CodexBar should use." = "Escolle que conta de Cursor debe usar CodexBar."; +"Choose which window drives the menu bar percent." = "Escolle que xanela determina a porcentaxe da barra de menús."; +"Chrome" = "Chrome"; +"Claude CLI not found" = "Non se atopou a CLI de Claude"; +"Claude binary" = "Binario de Claude"; +"Claude cookies" = "Cookies de Claude"; +"Claude login failed" = "O inicio de sesión de Claude fallou"; +"Claude login timed out" = "O inicio de sesión de Claude esgotou o tempo de espera"; +"Close" = "Pechar"; +"Codex CLI not found" = "Non se atopou a CLI de Codex"; +"Codex account login already running" = "O inicio de sesión da conta de Codex xa está en curso"; +"Codex binary" = "Binario de Codex"; +"Codex login failed" = "O inicio de sesión de Codex fallou"; +"Codex login timed out" = "O inicio de sesión de Codex esgotou o tempo de espera"; +"CodexBar Lifecycle Keepalive" = "Mantemento do ciclo de vida de CodexBar"; +"CodexBar can't show its menu bar icon" = "CodexBar de menús non pode amosar a súa icona"; +"CodexBar could not read managed account storage. " = "CodexBar de menús non puido ler o almacenamento de contas xestionadas. "; +"Configure…" = "Configurar…"; +"Connected" = "Conectado"; +"Controls how much detail is logged." = "Controla canto detalle se rexistra."; +"Cookie header" = "Cabeceira de cookie"; +"Cookie source" = "Orixe da cookie"; +"Cookie: ..." = "Cookie: ..."; +"Cookie: \\u{2026}\\\n\\\nor paste a cURL capture from the Abacus AI dashboard" = "Cookie: \\u{2026}\\\n\\\nou pega unha captura de cURL do panel de Abacus AI"; +"Cookie: \\u{2026}\\\n\\\nor paste the __Secure-next-auth.session-token value" = "Cookie: \\u{2026}\\\n\\\nou pega o valor de __Secure-next-auth.session-token"; +"Cookie: \\u{2026}\\\n\\\nor paste the kimi-auth token value" = "Cookie: \\u{2026}\\\n\\\nou pega o valor do token kimi-auth"; +"Cookie: …" = "Cookie: …"; +"CopilotDeviceFlow" = "CopilotDeviceFlow"; +"Cost" = "Custo"; +"Could not add Codex account" = "Non se puido engadir a conta de Codex"; +"Could not open Terminal for Gemini" = "Non se puido abrir o Terminal para Gemini"; +"Could not start claude /login" = "Non se puido iniciar claude /login"; +"Could not start codex login" = "Non se puido iniciar codex login"; +"Could not switch system account" = "Non se puido cambiar a conta do sistema"; +"Credits" = "Créditos"; +"Individual credits" = "Créditos individuais"; +"Workspace" = "Espazo de traballo"; +"Credits history" = "Historial de créditos"; +"Cursor login failed" = "O inicio de sesión de Cursor fallou"; +"Custom" = "Personalizado"; +"Custom Path" = "Ruta personalizada"; +"Daily Routines" = "Rutinas diarias"; +"Debug" = "Depuración"; +"Default" = "Por defecto"; +"Disable Keychain access" = "Desactivar o acceso ao Chaveiro"; +"Disabled" = "Desactivado"; +"Dismiss" = "Descartar"; +"Disconnected" = "Desconectado"; +"Display" = "Pantalla"; +"Display mode" = "Modo de visualización"; +"Display reset times as absolute clock values instead of countdowns." = "Amosa as horas de reinicio como valores de reloxo absolutos en vez de contas atrás."; +"Done" = "Feito"; +"Effective PATH" = "PATH efectivo"; +"Email" = "Correo electrónico"; +"Enable Merge Icons to configure Overview tab providers." = "Activa Combinar as iconas para configurar os provedores da lapela Resumo."; +"Enable file logging" = "Activar o rexistro en ficheiro"; +"Enabled" = "Activado"; +"Error" = "Erro"; +"Error simulation" = "Simulación de erros"; +"Expose troubleshooting tools in the Debug tab." = "Amosar ferramentas de diagnose na lapela Depuración."; +"Failed" = "Fallou"; +"False" = "Falso"; +"Fetch strategy attempts" = "Intentos de estratexia de obtención"; +"Fetching" = "Obtendo"; +"Field" = "Campo"; +"Field subtitle" = "Subtítulo do campo"; +"Finish the current managed account change before switching the system account." = "Remata o cambio de conta xestionada actual antes de cambiar a conta do sistema."; +"Force animation on next refresh" = "Forzar a animación na seguinte actualización"; +"Gateway region" = "Rexión da pasarela"; +"Gemini CLI not found" = "Non se atopou a CLI de Gemini"; +"Gemini/Antigravity, surfacing incidents in the icon and menu." = "Gemini/Antigravity, amosando incidencias na icona e no menú."; +"General" = "Xeral"; +"GitHub" = "GitHub"; +"GitHub Copilot Login" = "Inicio de sesión de GitHub Copilot"; +"GitHub Login" = "Inicio de sesión de GitHub"; +"Hide details" = "Ocultar os detalles"; +"Hide personal information" = "Ocultar a información persoal"; +"Historical tracking" = "Seguimento histórico"; +"How often CodexBar polls providers in the background." = "Con que frecuencia CodexBar consulta os provedores en segundo plano."; +"Inactive" = "Inactivo"; +"Install CLI" = "Instalar a CLI"; +"Install the Claude CLI (npm i -g @anthropic-ai/claude-code) and try again." = "Instala a CLI de Claude (npm i -g @anthropic-ai/claude-code) e téntao de novo."; +"Install the Codex CLI (npm i -g @openai/codex) and try again." = "Instala a CLI de Codex (npm i -g @openai/codex) e téntao de novo."; +"Install the Gemini CLI (npm i -g @google/gemini-cli) and try again." = "Instala a CLI de Gemini (npm i -g @google/gemini-cli) e téntao de novo."; +"JetBrains AI is ready" = "JetBrains AI está listo"; +"JetBrains IDE" = "IDE de JetBrains"; +"Keep CLI sessions alive" = "Manter activas as sesións de CLI"; +"Keyboard shortcut" = "Atallo de teclado"; +"Keychain access" = "Acceso ao Chaveiro"; +"Keychain prompt policy" = "Política de solicitudes do Chaveiro"; +"Last \\(name) fetch failed:" = "A última obtención de \\(name) fallou:"; +"Last \\(self.store.metadata(for: self.provider).displayName) fetch failed:" = "A última obtención de \\(self.store.metadata(for: self.provider).displayName) fallou:"; +"Last attempt" = "Último intento"; +"Link" = "Enlace"; +"Loading animations" = "Animacións de carga"; +"Loading…" = "Cargando…"; +"Local" = "Local"; +"Logging" = "Rexistro"; +"Login failed" = "O inicio de sesión fallou"; +"Login shell PATH (startup capture)" = "PATH do shell de inicio de sesión (captura no arranque)"; +"Login timed out" = "O inicio de sesión esgotou o tempo de espera"; +"MCP details" = "Detalles do MCP"; +"Managed Codex accounts unavailable" = "Contas xestionadas de Codex non dispoñibles"; +"Managed account storage is unreadable. Live account access is still available, " = "O almacenamento de contas xestionadas non se pode ler. O acceso ás contas activas aínda está dispoñible, "; +"Manual" = "Manual"; +"May your tokens never run out—keep agent limits in view." = "Que os teus tokens nunca se esgoten: mantén á vista os límites dos teus axentes."; +"Menu bar" = "Barra de menús"; +"Menu bar auto-shows the provider closest to its rate limit." = "A barra de menús amosa automaticamente o provedor máis próximo ao seu límite."; +"Menu bar metric" = "Métrica da barra de menús"; +"Menu bar shows percent" = "A barra de menús amosa a porcentaxe"; +"Menu content" = "Contido do menú"; +"Merge Icons" = "Combinar iconas"; +"Never prompt" = "Non preguntar nunca"; +"No" = "Non"; +"No Codex accounts detected yet." = "Aínda non se detectaron contas de Codex."; +"No JetBrains IDE detected" = "Non se detectou ningún IDE de JetBrains"; +"No cost history data." = "Non hai datos de historial de custo."; +"No credits history data." = "Non hai datos de historial de créditos."; +"No data available" = "Non hai datos dispoñibles"; +"No data yet" = "Aínda non hai datos"; +"No enabled providers available for Overview." = "Non hai provedores activados dispoñibles para o Resumo."; +"No providers selected" = "Non se seleccionou ningún provedor"; +"No token accounts yet." = "Aínda non hai contas con token."; +"No usage breakdown data." = "Non hai datos de desglose de uso."; +"None" = "Ningún"; +"Notifications" = "Notificacións"; +"Notifies when the 5-hour session quota hits 0% and when it becomes " = "Avisa cando a cota de sesión de 5 horas chega ao 0% e cando volve estar "; +"OK" = "Aceptar"; +"Obscure email addresses in the menu bar and menu UI." = "Ocultar os enderezos de correo na barra de menús e na interface do menú."; +"Off" = "Desactivado"; +"Offline" = "Sen conexión"; +"On" = "Activado"; +"Online" = "En liña"; +"Only on user action" = "Só en accións do usuario"; +"Open" = "Abrir"; +"Open API Keys" = "Abrir as chaves de API"; +"Open Amp Settings" = "Abrir os axustes de Amp"; +"Open Antigravity to sign in, then refresh CodexBar." = "Abre Antigravity para iniciar sesión e logo actualiza CodexBar."; +"Open Browser" = "Abrir o navegador"; +"Open Coding Plan" = "Abrir o plan de programación"; +"Open Console" = "Abrir a Consola"; +"Open Dashboard" = "Abrir o panel"; +"Open Mistral Admin" = "Abrir a administración de Mistral"; +"Open Menu Bar Settings" = "Abrir os axustes da barra de menús"; +"Open Ollama Settings" = "Abrir os axustes de Ollama"; +"Open Terminal" = "Abrir o Terminal"; +"Open Usage Page" = "Abrir a páxina de uso"; +"Open Warp API Key Guide" = "Abrir a guía da chave de API de Warp"; +"Open menu" = "Abrir o menú"; +"Open token file" = "Abrir o ficheiro de token"; +"OpenAI cookies" = "Cookies de OpenAI"; +"OpenAI web extras" = "Extras web de OpenAI"; +"Option A" = "Opción A"; +"Option B" = "Opción B"; +"Optional override if workspace lookup fails." = "Substitución opcional se falla a busca do espazo de traballo."; +"Options" = "Opcións"; +"Override auto-detection with a custom IDE base path" = "Substituír a detección automática por unha ruta base de IDE personalizada"; +"Overview" = "Resumo"; +"Overview rows always follow provider order." = "As filas de Resumo sempre seguen a orde dos provedores."; +"Overview tab providers" = "Provedores da lapela Resumo"; +"Paste API key…" = "Pegar chave de API…"; +"Paste API token…" = "Pegar token de API…"; +"Paste key…" = "Pegar chave…"; +"Paste sessionKey or OAuth token…" = "Pegar sessionKey ou token de OAuth…"; +"Paste the Cookie header from a request to admin.mistral.ai. " = "Pega a cabeceira Cookie dunha solicitude a admin.mistral.ai. "; +"Paste token…" = "Pegar token…"; +"Personal" = "Persoal"; +"Picker" = "Selector"; +"Picker subtitle" = "Subtítulo do selector"; +"Placeholder" = "Texto de marcador"; +"Plan" = "Plan"; +"Play full-screen confetti when weekly usage resets." = "Amosar confeti a pantalla completa cando se reinicie o uso semanal."; +"Polls OpenAI/Claude status pages and Google Workspace for " = "Consulta as páxinas de estado de OpenAI/Claude e Google Workspace para "; +"Prevents any Keychain access while enabled." = "Evita calquera acceso ao Chaveiro mentres estea activado."; +"Primary (API key limit)" = "Principal (límite da chave de API)"; +"Primary (\\(label))" = "Principal (\\(label))"; +"Primary (\\(metadata.sessionLabel))" = "Principal (\\(metadata.sessionLabel))"; +"Probe logs" = "Rexistros de sondaxe"; +"Progress bars fill as you consume quota (instead of showing remaining)." = "As barras de progreso énchense a medida que consomes a cota (en vez de amosar o que queda)."; +"Provider" = "Provedor"; +"Providers" = "Provedores"; +"Quit CodexBar" = "Saír de CodexBar"; +"Random (default)" = "Aleatorio (por defecto)"; +"Reads local usage logs. Shows today + last 30 days cost in the menu." = "Le os rexistros de uso locais. Amosa o custo de hoxe + a xanela de historial seleccionada no menú."; +"Refresh" = "Actualizar"; +"Refresh cadence" = "Frecuencia de actualización"; +"Remote" = "Remoto"; +"Remove" = "Eliminar"; +"Remove Codex account?" = "Queres eliminar a conta de Codex?"; +"Remove \\(account.email) from CodexBar? Its managed Codex home will be deleted." = "Queres eliminar \\(account.email) de CodexBar? O seu directorio de Codex xestionado eliminarase."; +"Remove \\(email) from CodexBar? Its managed Codex home will be deleted." = "Queres eliminar \\(email) de CodexBar? O seu directorio de Codex xestionado eliminarase."; +"Remove selected account" = "Eliminar a conta seleccionada"; +"Replace critter bars with provider branding icons and a percentage." = "Substitúe as barras de progreso por iconas de marca do provedor e unha porcentaxe."; +"Replay selected animation" = "Reproducir a animación seleccionada"; +"Requires authentication via GitHub Device Flow." = "Require autenticación mediante o fluxo de dispositivo de GitHub."; +"Resets: \\(reset)" = "Reiníciase: \\(reset)"; +"reset_tomorrow_format" = "mañá, %@"; +"Rolling five-hour limit" = "Límite móbil de cinco horas"; +"Search hourly" = "Buscas por hora"; +"Secondary (\\(label))" = "Secundario (\\(label))"; +"Secondary (\\(metadata.weeklyLabel))" = "Secundario (\\(metadata.weeklyLabel))"; +"Select a provider" = "Selecciona un provedor"; +"Select the IDE to monitor" = "Selecciona o IDE que desexas monitorizar"; +"Session quota notifications" = "Notificacións de cota de sesión"; +"Session tokens" = "Tokens de sesión"; +"provider_section_connection" = "Conexión"; +"provider_section_menu_bar" = "Barra de menús"; +"Show Codex Credits and Claude Extra usage sections in the menu." = "Amosa as seccións de Créditos de Codex e Uso adicional de Claude no menú."; +"Show Debug Settings" = "Amosar os axustes de depuración"; +"Show all token accounts" = "Amosar todas as contas con token"; +"Show cost summary" = "Amosar o resumo de custos"; +"Show credits + extra usage" = "Amosar créditos + uso adicional"; +"Show details" = "Amosar detalles"; +"Show most-used provider" = "Amosar o provedor máis usado"; +"Show provider icons in the switcher (otherwise show a weekly progress line)." = "Amosa as iconas de provedor no selector (se non, amosa unha liña de progreso semanal)."; +"Show reset time as clock" = "Amosar a hora de reinicio como reloxo"; +"Show usage as used" = "Amosar o uso como consumido"; +"Sign in via button below" = "Inicia sesión co botón de abaixo"; +"Skip teardown between probes (debug-only)." = "Omitir o peche entre sondaxes (só depuración)."; +"Stack token accounts in the menu (otherwise show an account switcher bar)." = "Apilar as contas con token no menú (se non, amosar unha barra de cambio de conta)."; +"Start at Login" = "Abrir ao iniciar a sesión"; +"Status" = "Estado"; +"Store Claude sessionKey cookies or OAuth access tokens." = "Garda as cookies sessionKey de Claude ou tokens de acceso OAuth."; +"Store multiple Abacus AI Cookie headers." = "Garda varias cabeceiras de Cookie de Abacus AI."; +"Store multiple Augment Cookie headers." = "Garda varias cabeceiras de Cookie de Augment."; +"Store multiple Cursor Cookie headers." = "Garda varias cabeceiras de Cookie de Cursor."; +"Store multiple Factory Cookie headers." = "Garda varias cabeceiras de Cookie de Factory."; +"Store multiple MiniMax Cookie headers." = "Garda varias cabeceiras de Cookie de MiniMax."; +"Store multiple Mistral Cookie headers." = "Garda varias cabeceiras de Cookie de Mistral."; +"Store multiple Ollama Cookie headers." = "Garda varias cabeceiras de Cookie de Ollama."; +"Store multiple OpenCode Cookie headers." = "Garda varias cabeceiras de Cookie de OpenCode."; +"Store multiple OpenCode Go Cookie headers." = "Garda varias cabeceiras de Cookie de OpenCode Go."; +"Stored in the CodexBar config file." = "Gardado no ficheiro de configuración de CodexBar."; +"Stored in ~/.codexbar/config.json. " = "Gardado en ~/.codexbar/config.json. "; +"Stored in ~/.codexbar/config.json. Paste the key from the Synthetic dashboard." = "Gardado en ~/.codexbar/config.json. Pega a chave do panel de Synthetic."; +"Stored in ~/.codexbar/config.json. Paste your Coding Plan API key from Model Studio." = "Gardado en ~/.codexbar/config.json. Pega a túa chave de API do plan de programación desde Model Studio."; +"Stored in ~/.codexbar/config.json. Paste your MiniMax API key." = "Gardado en ~/.codexbar/config.json. Pega a túa chave de API de MiniMax."; +"Stored in ~/.codexbar/config.json. You can also provide KILO_API_KEY or " = "Gardado en ~/.codexbar/config.json. Tamén podes proporcionar KILO_API_KEY ou "; +"Stores local Codex usage history (8 weeks) to personalize Pace predictions." = "Garda o historial de uso local de Codex (8 semanas) para personalizar as predicións de Ritmo."; +"Surprise me" = "Sorpréndeme"; +"Switcher shows icons" = "O selector amosa iconas"; +"Symlink CodexBarCLI to /usr/local/bin and /opt/homebrew/bin as codexbar." = "Crea un enlace simbólico de CodexBarCLI a /usr/local/bin e /opt/homebrew/bin como codexbar."; +"System" = "Sistema"; +"Temporarily shows the loading animation after the next refresh." = "Amosa temporalmente a animación de carga despois da seguinte actualización."; +"Tertiary (\\(label))" = "Terciario (\\(label))"; +"Tertiary (\\(tertiaryTitle))" = "Terciario (\\(tertiaryTitle))"; +"The default Codex account on this Mac." = "A conta de Codex por defecto neste Mac."; +"Toggle" = "Alternar"; +"Toggle subtitle" = "Subtítulo do alternador"; +"Token" = "Token"; +"Trigger the menu bar menu from anywhere." = "Abre o menú da barra de menús desde calquera lugar."; +"True" = "Verdadeiro"; +"Twitter" = "Twitter"; +"Unsupported" = "Non soportado"; +"Update Channel" = "Canle de actualizacións"; +"Updated" = "Actualizado"; +"Updates unavailable in this build." = "Actualizacións non dispoñibles nesta compilación."; +"Usage" = "Uso"; +"Usage breakdown" = "Desglose de uso"; +"Usage history (30 days)" = "Historial de uso (30 días)"; +"Usage source" = "Orixe de uso"; +"Use Account" = "Usar conta"; +"Use BigModel for the China mainland endpoints (open.bigmodel.cn)." = "Usa BigModel para os endpoints de China continental (open.bigmodel.cn)."; +"Use a single menu bar icon with a provider switcher." = "Usa unha única icona na barra de menús cun selector de provedor."; +"Use international or China mainland console gateways for quota fetches." = "Usa pasarelas de consola internacionais ou de China continental para a obtención de cotas."; +"Version" = "Versión"; +"Version \\(self.versionString)" = "Versión \\(self.versionString)"; +"Version \\(version)" = "Versión \\(version)"; +"Version \\(versionString)" = "Versión \\(versionString)"; +"Vertex AI Login" = "Inicio de sesión de Vertex AI"; +"Wait for the current managed Codex login to finish before adding another account." = "Agarda a que remate o inicio de sesión xestionado de Codex actual antes de engadir outra conta."; +"Waiting for Authentication..." = "Agardando pola autenticación..."; +"Website" = "Sitio web"; +"Weekly limit confetti" = "Confeti do límite semanal"; +"Weekly token limit" = "Límite semanal de tokens"; +"Weekly usage" = "Uso semanal"; +"Weekly usage unavailable for this account." = "O uso semanal non está dispoñible para esta conta."; +"Window: \\(window)" = "Xanela: \\(window)"; +"Write logs to \\(self.fileLogPath) for debugging." = "Escribe os rexistros en \\(self.fileLogPath) para depuración."; +"Yes" = "Si"; +"\\(detail.modelCode): \\(usage)" = "\\(detail.modelCode): \\(usage)"; +"\\(name): \\(truncated)" = "\\(name): \\(truncated)"; +"\\(name): \\(updated) · 30d \\(cost)" = "\\(name): \\(updated) · 30d \\(cost)"; +"\\(name): fetching…\\(elapsed)" = "\\(name): obtendo…\\(elapsed)"; +"\\(name): last attempt \\(when)" = "\\(name): último intento \\(when)"; +"\\(name): no data yet" = "\\(name): aínda sen datos"; +"\\(name): unsupported" = "\\(name): non soportado"; +"all browsers" = "todos os navegadores"; +"available again." = "dispoñible de novo."; +"built_format" = "Compilación %@"; +"copilot_complete_in_browser" = "Completa o inicio de sesión no teu navegador."; +"copilot_device_code" = "Código de dispositivo copiado ao portapapeis: %1$@\n\nVerifícao en: %2$@"; +"copilot_device_code_copied" = "Código de dispositivo copiado."; +"copilot_verify_at" = "Verifícao en %@"; +"copilot_waiting_text" = "Completa o inicio de sesión no teu navegador.\nEsta xanela pecharase automaticamente cando remate o inicio de sesión."; +"copilot_window_closes_auto" = "Esta xanela pecharase automaticamente cando remate o inicio de sesión."; +"cost_status_error" = "%1$@: %2$@"; +"cost_status_fetching" = "%1$@: obtendo… %2$@"; +"cost_status_last_attempt" = "%1$@: último intento %2$@"; +"cost_status_no_data" = "%@: aínda sen datos"; +"cost_status_snapshot" = "%1$@: %2$@ · %3$@ %4$@"; +"cost_status_unsupported" = "%@: non soportado"; +"credits_remaining" = "Créditos restantes: %@"; +"cursor_on_demand" = "Baixo demanda: %@"; +"cursor_on_demand_with_limit" = "Baixo demanda: %1$@ / %2$@"; +"extra_usage_format" = "Uso adicional: %1$@ / %2$@"; +"jetbrains_detected_generate" = "Detectado: %@. Usa o asistente de IA unha vez para xerar os datos de cota e logo actualiza CodexBar."; +"jetbrains_detected_select" = "Detectado: %@. Selecciona o teu IDE preferido na configuración e logo actualiza CodexBar."; +"last_fetch_failed_with_provider" = "A última obtención de %@ fallou:"; +"last_spend" = "Último gasto: %@"; +"mcp_model_usage" = "%1$@: %2$@"; +"mcp_resets" = "Reiníciase: %@"; +"mcp_window" = "Xanela: %@"; +"metric_average" = "Media (%1$@ + %2$@)"; +"metric_primary" = "Principal (%@)"; +"metric_secondary" = "Secundario (%@)"; +"metric_tertiary" = "Terciario (%@)"; +"multiple_workspaces_found" = "CodexBar atopou varios espazos de traballo para %@. Escolle o que queiras engadir."; +"ory_session_…=…; csrftoken=…" = "ory_session_…=…; csrftoken=…"; +"overview_choose_providers" = "Escolle ata %@ provedores"; +"remove_account_message" = "Queres eliminar %@ de CodexBar? O seu directorio de Codex xestionado eliminarase."; +"version_format" = "Versión %@"; +"vertex_ai_login_instructions" = "Para facer un seguimento do uso de Vertex AI, autentícate con Google Cloud.\n\n1. Abre o Terminal\n2. Executa: gcloud auth application-default login\n3. Segue as indicacións do navegador para iniciar sesión\n4. Define o teu proxecto: gcloud config set project ID_PROXECTO\n\nQueres abrir o Terminal agora?"; +"workspaceID is set but only opencode, opencodego, and deepgram support workspaceID." = "workspaceID está definido, pero só opencode, opencodego e deepgram admiten workspaceID."; +"© 2026 Peter Steinberger. MIT License." = "© 2026 Peter Steinberger. Licenza MIT."; + +/* General Pane */ +"section_system" = "Sistema"; +"section_usage" = "Uso"; +"section_refreshing" = "Actualización"; +"section_alerts" = "Alertas"; +"section_celebrations" = "Celebracións"; +"section_icon" = "Icona"; +"section_combined_icon" = "Icona combinada"; +"section_animation" = "Animación"; +"section_content" = "Contido"; +"section_agent_sessions" = "Sesións de axentes"; +"language_title" = "Idioma"; +"language_subtitle" = "Cambia o idioma da interface. Cómpre reiniciar a aplicación para que se aplique por completo."; +"currency_title" = "Moeda preferida"; +"currency_subtitle" = "Moeda para estimacións de custo e gasto. Usa tipos de cambio actualizados diariamente."; +"currency_auto" = "Automático (segundo o provedor / USD)"; +"language_system" = "Sistema"; +"language_english" = "Inglés"; +"language_spanish" = "Castelán"; +"language_catalan" = "Catalán"; +"language_chinese_simplified" = "简体中文"; +"language_chinese_traditional" = "繁體中文"; +"language_portuguese_brazilian" = "Portugués (Brasil)"; +"language_swedish" = "Sueco"; +"language_dutch" = "Neerlandés"; +"language_german" = "Alemán"; +"language_french" = "Francés"; +"language_ukrainian" = "Ucraíno"; +"language_russian" = "Русский"; +"language_japanese" = "Xaponés"; +"language_korean" = "Coreano"; +"language_italian" = "Italiano"; +"language_polish" = "Polaco"; +"terminal_app_title" = "Terminal predeterminado"; +"terminal_app_subtitle" = "Terminal usado pola acción Abrir terminal"; +"start_at_login_title" = "Abrir ao iniciar a sesión"; +"start_at_login_subtitle" = "Abre CodexBar automaticamente ao iniciar o teu Mac."; +"show_cost_summary_subtitle" = "Le os rexistros de uso locais. Amosa o custo de hoxe + a xanela de historial seleccionada no menú."; +"cost_summary_style_title" = "Estilo de visualización"; +"cost_summary_style_inline" = "Só integrado"; +"cost_summary_style_submenu" = "Só submenú"; +"cost_summary_style_both" = "Ambos"; +"cost_summary_style_inline_help" = "Amosa o resumo de custos directamente no menú principal."; +"cost_summary_style_submenu_help" = "Amosa no seu lugar o submenú Custo detallado."; +"cost_summary_style_both_help" = "Amosa o resumo do menú principal e o submenú Custo detallado."; +"cost_history_window_title" = "Xanela do historial"; +"cost_history_window_help" = "Define cantos días de rexistros de uso locais aparecen no menú."; +"cost_history_days_title" = "Xanela de historial: %d días"; +"cost_auto_refresh_info" = "Actualización automática: intervalo global (mínimo 5 min) · Tempo de espera: 10 min"; +"cost_comparison_periods_title" = "Mostrar períodos de comparación máis curtos"; +"cost_comparison_periods_subtitle" = "Engade totais de 7, 30 e 90 días cando caiban na xanela de historial seleccionada. Estes totais reutilizan a mesma análise local."; +"refresh_interval_title" = "Intervalo de actualización"; +"manual_refresh_hint" = "A actualización automática está desactivada; usa a orde Actualizar do menú."; +"check_provider_status_title" = "Comprobar o estado do provedor"; +"check_provider_status_subtitle" = "Consulta as páxinas de estado de OpenAI/Claude e Google Workspace para Gemini/Antigravity, amosando incidencias na icona e no menú."; +"session_quota_notifications_subtitle" = "Avisa cando a cota de sesión de 5 horas chega ao 0% e cando volve estar dispoñible."; +"quota_depleted_title" = "Cota esgotada e restaurada"; +"quota_warning_notifications_subtitle" = "Avisa cando a cota restante de sesión ou semanal supera os limiares configurados."; +"threshold_warnings_title" = "Avisos de limiar"; +"quota_warnings_title" = "Avisos de cota"; +"quota_warning_session" = "sesión"; +"quota_warning_session_capitalized" = "Sesión"; +"quota_warning_weekly" = "semanal"; +"quota_warning_weekly_capitalized" = "Semanal"; +"quota_warning_warn_at" = "Avisar ao"; +"quota_warning_global_threshold_subtitle" = "Porcentaxes restantes para as xanelas de sesión e semanal, a menos que un provedor as substitúa."; +"quota_warning_sound" = "Reproducir o son de notificación"; +"quota_warning_provider_inherits" = "Usa a configuración global de aviso de cota a menos que se personalice unha xanela aquí."; +"quota_warning_provider_disabled" = "As notificacións de aviso de cota e os marcadores das barras de uso están desactivados. Activa unha das dúas opcións para editar estes axustes gardados."; +"quota_warning_provider_markers_only" = "As notificacións de aviso de cota están desactivadas globalmente. Esta configuración segue controlando os marcadores das barras de uso."; +"quota_warning_global" = "Global"; +"quota_warning_customize_thresholds" = "Personalizar os limiares de %@"; +"quota_warning_enable_warnings" = "Activar os avisos de %@"; +"quota_warning_window_warn_at" = "%@ avisa ao"; +"quota_warning_off" = "Desactivado"; +"quota_warning_inherited" = "Herdado: %@"; +"quota_warning_depleted_only" = "só esgotado"; +"quota_warning_upper" = "Máis alto"; +"quota_warning_lower" = "Inferior"; +"quota_warning_warning" = "Aviso"; +"quota_warning_critical" = "Crítico"; +"apply" = "Aplicar"; +"quit_app" = "Saír de CodexBar"; + +/* Tab titles */ +"tab_general" = "Xeral"; +"tab_providers" = "Provedores"; +"tab_notifications" = "Notificacións"; +"tab_menu_bar" = "Barra de menús"; +"tab_menu" = "Menú"; +"tab_advanced" = "Avanzado"; +"tab_hooks" = "Ganchos"; + +/* Hooks Pane */ +"hooks_enable_title" = "Activar ganchos"; +"hooks_enable_subtitle" = "Executa comandos externos cando se producen eventos de cota ou provedor."; +"hooks_trust_warning" = "Os ganchos poden executar comandos locais no teu Mac. Configura só comandos nos que confíes."; +"hooks_rules_header" = "Regras"; +"hooks_empty" = "Non hai ganchos configurados."; +"hooks_add_rule" = "Engadir regra"; +"hooks_delete_rule" = "Eliminar regra"; +"hooks_rule_enabled" = "Activado"; +"hooks_event" = "Evento"; +"hooks_provider" = "Provedor"; +"hooks_any_provider" = "Calquera provedor"; +"hooks_threshold" = "Activar cun uso ≥"; +"hooks_threshold_placeholder" = "90"; +"hooks_executable_placeholder" = "/usr/local/bin/my-command"; +"hooks_arguments_placeholder" = "Argumentos"; +"hooks_argument_placeholder" = "Argumento"; +"hooks_add_argument" = "Engadir argumento"; +"hooks_delete_argument" = "Eliminar argumento"; +"tab_about" = "Acerca de"; +"tab_debug" = "Depuración"; + +/* Providers Pane */ +"select_a_provider" = "Selecciona un provedor"; +"cancel" = "Cancelar"; +"last_fetch_failed" = "a última obtención fallou"; +"usage_not_fetched_yet" = "aínda non se obtivo o uso"; +"managed_account_storage_unreadable" = "O almacenamento de contas xestionadas non se pode ler. O acceso ás contas activas aínda está dispoñible, pero as accións de engadir, reautenticar e eliminar contas xestionadas están desactivadas ata que se poida recuperar o almacén."; +"remove_codex_account_title" = "Queres eliminar a conta de Codex?"; +"remove" = "Eliminar"; +"managed_login_already_running" = "Xa hai un inicio de sesión xestionado de Codex en curso. Agarda a que remate antes de engadir ou reautenticar outra conta."; +"managed_login_failed" = "O inicio de sesión xestionado de Codex non se completou. Comproba que `codex --version` funciona no Terminal. Se macOS bloqueou ou moveu `codex` ao Lixo, elimina as instalacións duplicadas obsoletas, executa `npm install -g --include=optional @openai/codex@latest` e téntao de novo."; +"codex_login_output" = "Saída de codex login:"; +"managed_login_missing_email" = "O inicio de sesión de Codex completouse, pero non había ningún correo electrónico de conta dispoñible. Téntao de novo despois de confirmar que iniciaches sesión por completo na conta."; +"workspace_selection_cancelled" = "CodexBar atopou varios espazos de traballo, pero non se seleccionou ningún."; +"unsafe_managed_home" = "CodexBar rexeitou modificar unha ruta de directorio xestionada inesperada: %@"; +"menu_bar_metric_title" = "Métrica da barra de menús"; +"menu_bar_metric_subtitle" = "Escolle que xanela determina a porcentaxe da barra de menús."; +"menu_bar_metric_subtitle_deepseek" = "Amosa o saldo de DeepSeek na barra de menús."; +"menu_bar_metric_subtitle_moonshot" = "Amosa o saldo da API de Moonshot / Kimi na barra de menús."; +"menu_bar_metric_subtitle_mistral" = "Amosa o gasto da API de Mistral do mes actual na barra de menús."; +"automatic" = "Automático"; +"primary_api_key_limit" = "Principal (límite da chave de API)"; + +/* Display Pane */ +"menu_bar_style_title" = "Estilo da barra de menús"; +"menu_bar_style_subtitle" = "Como se debuxa o elemento da barra de menús."; +"menu_bar_inactive_display_contrast_title" = "Mellorar a visibilidade nas pantallas inactivas"; +"menu_bar_usage_colors_title" = "Uso con código de cores"; +"menu_bar_usage_colors_subtitle" = "Colorea a icona da barra de menús de verde a vermello a medida que aumenta o uso."; +"menu_bar_inactive_display_contrast_subtitle" = "Usa unha representación de alto contraste para manter lexibles a icona e a métrica nas outras pantallas."; +"menu_bar_style_critters" = "Animaliños"; +"menu_bar_style_bars" = "Barras de medición"; +"menu_bar_style_icon_percent" = "Icona e porcentaxe"; +"switcher_rows_title" = "Filas do selector"; +"switcher_rows_icons" = "Iconas de provedores"; +"switcher_rows_progress" = "Progreso semanal"; +"usage_bars_fill_title" = "Recheo das barras de uso"; +"usage_bars_fill_remaining" = "Como restante"; +"usage_bars_fill_used" = "Como consumido"; +"reset_times_title" = "Horas de reinicio"; +"reset_times_countdown" = "Conta atrás"; +"reset_times_clock" = "Hora do reloxo"; +"cost_summary_title" = "Resumo de custos"; +"cost_summary_off" = "Desactivado"; +"merge_icons_title" = "Combinar iconas"; +"merge_icons_subtitle" = "Usa unha única icona na barra de menús cun selector de provedor."; +"show_most_used_provider_title" = "Amosar o provedor máis usado"; +"show_most_used_provider_subtitle" = "A barra de menús amosa automaticamente o provedor máis próximo ao seu límite."; +"display_mode_title" = "Modo de visualización"; +"display_mode_subtitle" = "Escolle que amosar na barra de menús (Ritmo amosa o uso fronte ao previsto)."; +"show_quota_warning_markers_title" = "Amosar os marcadores de aviso de cota"; +"show_quota_warning_markers_subtitle" = "Debuxa marcas de limiar nas barras de uso cando hai avisos de cota configurados."; +"weekly_progress_work_days_title" = "Días laborables do progreso semanal"; +"weekly_progress_work_days_subtitle" = "Define os días laborables para os marcadores das barras de uso semanal e os cálculos de ritmo."; +"show_provider_changelog_links_title" = "Amosar as ligazóns ao rexistro de cambios do provedor"; +"show_provider_changelog_links_subtitle" = "Engade ao menú enlaces ás notas de versión dos provedores compatibles baseados en CLI."; +"show_credits_extra_usage_title" = "Amosar créditos + uso adicional"; +"show_credits_extra_usage_subtitle" = "Amosa as seccións de Créditos de Codex e Uso adicional de Claude no menú."; +"multi_account_layout_title" = "Disposición multiconta"; +"multi_account_layout_subtitle" = "Escolle o cambio de conta segmentado ou tarxetas de conta apiladas."; +"multi_account_layout_segmented" = "Segmentado"; +"multi_account_layout_stacked" = "Apilado"; +"overview_tab_providers_title" = "Provedores da lapela Resumo"; +"configure" = "Configurar…"; +"overview_enable_merge_icons_hint" = "Activa Combinar as iconas para configurar os provedores da lapela Resumo."; +"overview_no_providers_hint" = "Non hai provedores activados dispoñibles para o Resumo."; +"overview_rows_follow_order" = "As filas de Resumo sempre seguen a orde dos provedores."; +"overview_no_providers_selected" = "Non se seleccionou ningún provedor"; +"agent_sessions_title" = "Sesións de axentes"; +"agent_sessions_subtitle" = "Amosa no menú as sesións de Codex e Claude Code locais e descubertas mediante SSH."; +"agent_sessions_hosts_title" = "Hosts SSH adicionais"; +"agent_sessions_footer" = "Os Mac da túa tailnet descóbrense automaticamente. As sesións locais actualízanse cada 30 segundos; os hosts remotos cada 60 segundos e cando se abre o menú."; +"agent_session_labels_title" = "Etiquetas de sesión"; +"agent_session_labels_subtitle" = "Escolle como se nomean as sesións de axentes."; +"agent_session_label_project" = "Proxecto"; +"agent_session_label_descriptive" = "Descritiva"; +"agent_session_label_descriptive_and_project" = "Descritiva + proxecto"; +"agent_session_unknown_project" = "Proxecto descoñecido"; + +/* Advanced Pane */ +"section_keyboard_shortcut" = "Atallo de teclado"; +"open_menu_shortcut_title" = "Abrir o menú"; +"open_menu_shortcut_subtitle" = "Abre o menú da barra de menús desde calquera lugar."; +"install_cli" = "Instalar a CLI"; +"install_cli_subtitle" = "Crea un enlace simbólico de CodexBarCLI a /usr/local/bin e /opt/homebrew/bin como codexbar."; +"cli_not_found" = "Non se atopou CodexBarCLI no paquete da aplicación."; +"no_writable_bin_dirs" = "Non se atoparon directorios bin con permisos de escritura."; +"show_debug_settings_title" = "Amosar os axustes de depuración"; +"show_debug_settings_subtitle" = "Amosa ferramentas de diagnose na lapela Depuración."; +"surprise_me_title" = "Sorpréndeme"; +"surprise_me_subtitle" = "Actívao se che gusta que os teus axentes se divirtan aí arriba."; +"hide_personal_info_title" = "Ocultar información persoal"; +"hide_personal_info_subtitle" = "Oculta os enderezos de correo na barra de menús e na interface do menú."; +"show_provider_storage_usage_title" = "Amosar o uso de almacenamento do provedor"; +"show_provider_storage_usage_subtitle" = "Amosa o uso do disco local nos menús. Analiza en segundo plano as rutas coñecidas do provedor."; +"section_keychain_access" = "Acceso ao Chaveiro"; +"keychain_access_caption" = "Desactiva todas as lecturas e escrituras do Chaveiro. A importación de cookies do navegador non estará dispoñible; pega as cabeceiras de Cookie manualmente en Provedores."; +"disable_keychain_access_title" = "Desactivar o acceso ao Chaveiro"; +"disable_keychain_access_subtitle" = "Evita calquera acceso ao Chaveiro mentres estea activado."; + +/* About Pane */ +"about_tagline" = "Que os teus tokens nunca se esgoten: mantén á vista os límites dos teus axentes."; +"link_github" = "GitHub"; +"link_website" = "Sitio web"; +"link_twitter" = "Twitter"; +"link_email" = "Correo electrónico"; +"check_updates_auto" = "Buscar actualizacións automaticamente"; +"update_channel" = "Canle de actualizacións"; +"check_for_updates" = "Buscar actualizacións…"; +"updates_unavailable" = "Actualizacións non dispoñibles nesta compilación."; +"copyright" = "© 2026 Peter Steinberger. Licenza MIT."; + +/* Debug Pane */ +"section_logging" = "Rexistro"; +"enable_file_logging" = "Activar o rexistro en ficheiro"; +"enable_file_logging_subtitle" = "Escribe os rexistros en %@ para depuración."; +"verbosity_title" = "Nivel de detalle"; +"verbosity_subtitle" = "Controla canto detalle se rexistra."; +"open_log_file" = "Abrir o ficheiro de rexistro"; +"force_animation_next_refresh" = "Forzar a animación na seguinte actualización"; +"force_animation_next_refresh_subtitle" = "Amosa temporalmente a animación de carga despois da seguinte actualización."; +"section_loading_animations" = "Animacións de carga"; +"loading_animations_caption" = "Escolle un patrón e reprodúceo na barra de menús. «Aleatorio» mantén o comportamento actual."; +"animation_random_default" = "Aleatorio (por defecto)"; +"replay_selected_animation" = "Reproducir a animación seleccionada"; +"blink_now" = "Parpadear agora"; +"section_probe_logs" = "Rexistros de sondaxe"; +"probe_logs_caption" = "Obtén a última saída de sondaxe para a depuración; Copiar conserva o texto completo."; +"fetch_log" = "Obter o rexistro"; +"copy" = "Copiar"; +"save_to_file" = "Gardar nun ficheiro"; +"load_parse_dump" = "Cargar o volcado de análise"; +"rerun_provider_autodetect" = "Volver a executar a autodetección de provedores"; +"loading" = "Cargando…"; +"no_log_yet_fetch" = "Aínda non hai rexistro. Obtén para cargalo."; +"section_fetch_strategy" = "Intentos de estratexia de obtención"; +"fetch_strategy_caption" = "Últimas decisións e erros do fluxo de obtención dun provedor."; +"section_openai_cookies" = "Cookies de OpenAI"; +"openai_cookies_caption" = "Rexistros de importación de cookies e extracción con WebKit do último intento de cookies de OpenAI."; +"no_log_yet" = "Aínda non hai rexistro. Actualiza as cookies de OpenAI en Provedores → Codex para executar unha importación."; +"section_caches" = "Cachés"; +"caches_caption" = "Borra os resultados de análise de custos na caché ou as cachés de cookies do navegador."; +"clear_cookie_cache" = "Borrar a caché de cookies"; +"clear_cost_cache" = "Borrar a caché de custos"; +"section_notifications" = "Notificacións"; +"notifications_caption" = "Lanza notificacións de proba para a xanela de sesión de 5 horas (esgotada/restaurada)."; +"post_depleted" = "Enviar esgotada"; +"post_restored" = "Enviar restaurada"; +"section_cli_sessions" = "Sesións da CLI"; +"cli_sessions_caption" = "Mantén activas as sesións da CLI de Codex/Claude despois dunha sondaxe. Por defecto péchanse cando se capturan os datos."; +"keep_cli_sessions_alive" = "Manter activas as sesións de CLI"; +"keep_cli_sessions_alive_subtitle" = "Omitir o peche entre sondaxes (só depuración)."; +"reset_cli_sessions" = "Reiniciar as sesións de CLI"; +"section_error_simulation" = "Simulación de erros"; +"error_simulation_caption" = "Inxecta unha mensaxe de erro falsa na tarxeta do menú para probar a disposición."; +"set_menu_error" = "Establecer o erro de menú"; +"clear_menu_error" = "Borrar o erro de menú"; +"set_cost_error" = "Establecer o erro de custo"; +"clear_cost_error" = "Borrar o erro de custo"; +"section_cli_paths" = "Rutas de CLI"; +"cli_paths_caption" = "Binario de Codex resolto e capas de PATH; captura do PATH de inicio de sesión no arranque (tempo de espera curto)."; +"codex_binary" = "Binario de Codex"; +"claude_binary" = "Binario de Claude"; +"effective_path" = "PATH efectivo"; +"unavailable" = "Non dispoñible"; +"login_shell_path" = "PATH do shell de inicio de sesión (captura no arranque)"; +"cleared" = "Borrado."; +"no_fetch_attempts" = "Aínda non hai intentos de obtención."; +"macOS Tahoe can block menu bar apps in System Settings → Menu Bar → Allow in the Menu Bar. CodexBar is running, but macOS may be hiding its icon. Open Menu Bar settings and turn CodexBar on." = "macOS Tahoe pode bloquear as aplicacións da barra de menús en Axustes do Sistema → Barra de menús → Permitir na barra de menús. CodexBar está a executarse, pero macOS pode estar ocultando a súa icona. Abre os axustes da barra de menús e activa CodexBar."; + +/* Metric preferences */ +"metric_pref_automatic" = "Automático"; +"metric_pref_primary" = "Principal"; +"metric_pref_secondary" = "Secundario"; +"metric_pref_tertiary" = "Terciario"; +"metric_pref_extra_usage" = "Uso adicional"; +"metric_pref_average" = "Media"; +"metric_mistral_payg" = "Pago por uso"; +"metric_mistral_monthly_plan" = "Plan mensual"; + +/* Display modes */ +"display_mode_percent" = "Porcentaxe"; +"display_mode_pace" = "Ritmo"; +"display_mode_both" = "Ambos"; +"display_mode_reset_time" = "Tempo de restablecemento"; +"display_mode_percent_desc" = "Amosa a porcentaxe restante/usada (ex. 45 %)"; +"display_mode_pace_desc" = "Amosa o indicador de ritmo (ex. +5 %)"; +"display_mode_both_desc" = "Amosa a porcentaxe e o ritmo (ex. 45 % · +5 %)"; +"display_mode_reset_time_desc" = "Amosa o tempo de restablecemento da métrica seleccionada (p. ex. ↻ 15:56)"; +"menu_bar_reset_when_exhausted_title" = "Mostrar a hora de restablecemento cando se esgote a cota"; +"menu_bar_reset_when_exhausted_subtitle" = "Cun 0% restante, mostra o tempo ata o restablecemento en lugar da porcentaxe"; + +/* Provider status */ +"status_operational" = "Operativo"; +"status_degraded" = "Rendemento degradado"; +"status_partial_outage" = "Interrupción parcial"; +"status_major_outage" = "Interrupción grave"; +"status_critical_issue" = "Problema crítico"; +"status_maintenance" = "Mantemento"; +"status_unknown" = "Estado descoñecido"; + +/* Refresh frequency */ +"refresh_manual" = "Manual"; +"refresh_1min" = "1 min"; +"refresh_2min" = "2 min"; +"refresh_5min" = "5 min"; +"refresh_15min" = "15 min"; +"refresh_30min" = "30 min"; + +/* Additional keys */ +"not_found" = "Non atopado"; + +/* Cost estimation */ +"cost_estimate_hint" = "Estimado a partir de rexistros locais · pode diferir da túa factura"; +"codex_api_estimate_hint" = "Estimado a partir do uso de tokens · non é unha factura de subscrición"; +"cost_data_explanation" = "Os custos poden ser comunicados polo provedor ou estimados a partir do uso de tokens cos prezos públicos da API. As estimacións non son cargos de subscrición."; + +/* Popup panels */ +"No usage configured." = "Non hai ningún uso configurado."; +"Quota" = "Cota"; +"Daily quota" = "Cota diaria"; +"Total" = "Total"; +"tokens" = "tokens"; +"requests" = "solicitudes"; +"Latest" = "Máis recente"; +"Monthly" = "Mensual"; +"Sonnet" = "Sonnet"; +"Auth" = "Autenticación"; +"Overages" = "Excesos"; +"Activity" = "Actividade"; +"Copied" = "Copiado"; +"Copy error" = "Erro ao copiar"; +"Copy path" = "Copiar camiño"; +"Extra usage spent" = "Uso extra gastado"; +"Credits remaining" = "Créditos restantes"; +"Using CLI fallback" = "Usando a alternativa da CLI"; +"Balance updates in near-real time (up to 5 min lag)" = "O saldo actualízase case en tempo real (cun atraso de ata 5 min)"; +"Daily billing data finalizes at 07:00 UTC" = "Os datos diarios de facturación péchanse ás 07:00 UTC"; +"%@ of %@ credits left" = "Quedan %@ de %@ créditos"; +"%@ of %@ bonus credits left" = "Quedan %@ de %@ créditos extra"; +"%@ / %@ (%@ remaining)" = "%@ / %@ (%@ restantes)"; +"%@/%@ left" = "Quedan %@/%@"; +"Gemini Flash" = "Gemini Flash"; +"Regenerates %@" = "Rexenérase %@"; +"used after next regen" = "usado tras a próxima rexeneración"; +"after next regen" = "tras a próxima rexeneración"; +"Near full" = "Case cheo"; +"Full in ~1 regen" = "Cheo en ~1 rexeneración"; +"Full in ~%.0f regens" = "Cheo en ~%.0f rexeneracións"; +"Overage usage" = "Uso excesivo"; +"Overage cost" = "Custo excedente"; +"credits" = "créditos"; +"Zen balance" = "Saldo Zen"; +"API spend" = "Gasto da API"; +"Extra usage" = "Uso extra"; +"Quota usage" = "Uso da cota"; +"Your spend" = "O teu gasto"; +"%.0f%% used" = "%.0f%% usado"; +"Usage history (today)" = "Historial de uso (hoxe)"; +"Usage history (%d days)" = "Historial de uso (%d días)"; +"%d percent remaining" = "%d por cento restante"; +"Unknown" = "Descoñecido"; +"stale data" = "datos obsoletos"; +"No credits history data available." = "Non hai datos dispoñibles do historial de créditos."; +"Credits history chart" = "Gráfica do historial de créditos"; +"%d days of credits data" = "%d días de datos de créditos"; +"Usage breakdown chart" = "Gráfica da desagregación do uso"; +"%d days of usage data across %d services" = "%d días de datos de uso en %d servizos"; +"Cost history chart" = "Gráfica do historial de custos"; +"%d days of cost data" = "%d días de datos de custos"; +"Plan utilization chart" = "Gráfica de uso do plan"; +"%d utilization samples" = "%d mostras de uso"; +"Hourly Usage" = "Uso horario"; +"Usage remaining" = "Uso restante"; +"Usage used" = "Uso consumido"; +"API key verified. Cloud quotas need browser cookies. Sign in to Ollama." = "Chave de API verificada. As cotas de Cloud requiren cookies do navegador. Inicie sesión en Ollama."; +"Last 30 days: %@ tokens" = "Últimos 30 días: %@ tokens"; +"7d spend" = "Gasto en 7 d"; +"30d spend" = "Gasto en 30 d"; +"Cache read" = "Lectura da caché"; +"Claude Admin API 30 day spend trend" = "Tendencia de gasto de 30 días da API de administración de Claude"; +"OpenRouter API key spend trend" = "Tendencia de gasto da chave de API de OpenRouter"; +"z.ai hourly token trend" = "Tendencia horaria de tokens de z.ai"; +"MiniMax 30 day token usage trend" = "Tendencia de uso de tokens de MiniMax en 30 días"; +"Today cash" = "Gasto de hoxe"; +"DeepSeek 30 day token usage trend" = "Tendencia de uso de tokens de DeepSeek en 30 días"; +"DeepSeek this month token usage trend" = "Tendencia de uso de tokens de DeepSeek este mes"; +"Chrome profile" = "Perfil de Chrome"; +"Choose which signed-in DeepSeek Platform session supplies detailed usage." = "Escolle que sesión iniciada de DeepSeek Platform fornece o uso detallado."; +"Detailed usage unavailable." = "O uso detallado non está dispoñible."; +"Sign in to DeepSeek Platform in Chrome for detailed usage." = "Inicia sesión en DeepSeek Platform en Chrome para ver o uso detallado."; +"Select a DeepSeek Chrome profile in Settings." = "Selecciona un perfil de Chrome de DeepSeek en Axustes."; +"Select profile…" = "Seleccionar perfil…"; +"cache-hit input" = "entrada atopada na caché"; +"cache-miss input" = "entrada non atopada na caché"; +"output" = "saída"; +"Requests" = "Solicitudes"; +"Reported by OpenAI Admin API organization usage." = "Datos do uso da organización fornecidos pola API de administración de OpenAI."; +"Reported by Mistral billing usage." = "Datos fornecidos polo uso de facturación de Mistral."; +"Today" = "Hoxe"; +"Today tokens" = "Tokens de hoxe"; +"30d cost" = "Custo en 30 d"; +"%@ cost" = "Custo en %@"; +"30d tokens" = "Tokens en 30 d"; +"Latest tokens" = "Tokens máis recentes"; +"Top model" = "Modelo principal"; +"Storage" = "Almacenamento"; +"No data" = "Sen datos"; +"Last %d days" = "Últimos %d días"; +"%@ tokens" = "%@ tokens"; +"Latest billing day" = "Último día de facturación"; +"Latest billing day (%@)" = "Último día de facturación (%@)"; +"This week" = "Esta semana"; +"This month" = "Este mes"; +"Week" = "Semana"; +"Month" = "Mes"; +"Models" = "Modelos"; +"24h tokens" = "Tokens en 24 h"; +"Latest hour" = "Última hora"; +"Peak hour" = "Hora punta"; +"Top method" = "Método principal"; +"30d cash" = "Gasto en 30 d"; +"30d billing history from MiniMax web session" = "Historial de facturación de 30 días da sesión web de MiniMax"; +"AWS Cost Explorer billing can lag." = "A facturación de AWS Cost Explorer pode levar atraso."; +"Rate limit: %d / %@" = "Límite de frecuencia: %d / %@"; +"Key remaining" = "Saldo restante da chave"; +"No limit set for the API key" = "Non hai ningún límite configurado para a chave de API"; +"API key limit unavailable right now" = "O límite da chave de API non está dispoñible neste momento"; +"Today: %@ · %@ tokens" = "Hoxe: %@ · %@ tokens"; +"Today: %@" = "Hoxe: %@"; +"Today: %@ tokens" = "Hoxe: %@ tokens"; +"This month: %@ tokens" = "Este mes: %@ tokens"; +"API key limit" = "Límite da chave de API"; +"Limits not available" = "Límites non dispoñibles"; +"No usage yet" = "Aínda non hai uso"; +"Not fetched yet" = "Aínda non se obtivo"; +"Code review" = "Revisión de código"; + +/* Additional provider settings and alerts */ +"%@ is waiting for permission" = "%@ agarda permiso"; +"%@ requests" = "%@ solicitudes"; +"%@: %@ credits" = "%@: %@ créditos"; +"30d requests" = "Solicitudes en 30 d"; +"4 days" = "4 días"; +"5 days" = "5 días"; +"7 days" = "7 días"; +"API key verifies Ollama Cloud access; cookies still expose quota limits." = "A chave de API verifica o acceso a Ollama Cloud; as cookies seguen amosando os límites da cota."; +"AWS access key ID. Can also be set with AWS_ACCESS_KEY_ID." = "ID da chave de acceso de AWS. Tamén se pode definir con AWS_ACCESS_KEY_ID."; +"AWS region. Can also be set with AWS_REGION." = "Rexión de AWS. Tamén se pode definir con AWS_REGION."; +"AWS secret access key. Can also be set with AWS_SECRET_ACCESS_KEY." = "Chave de acceso secreta de AWS. Tamén se pode definir con AWS_SECRET_ACCESS_KEY."; +"Access key ID" = "ID da chave de acceso"; +"Add Account" = "Engadir conta"; +"Adding Account…" = "Engadindo conta…"; +"Antigravity login failed" = "Erro ao iniciar sesión en Antigravity"; +"Antigravity login timed out" = "O inicio de sesión en Antigravity esgotou o tempo"; +"Auth source" = "Fonte de autenticación"; +"Automatic imports browser cookies from Xiaomi MiMo." = "Importa automaticamente as cookies do navegador desde Xiaomi MiMo."; +"Automatic imports Windsurf session data from Chromium browser localStorage." = "Importa automaticamente datos da sesión de Windsurf desde o localStorage de Chromium."; +"Automatic imports browser cookies from Bailian." = "Importa automaticamente cookies do navegador desde Bailian."; +"Automatically imports browser cookies." = "Importa automaticamente cookies do navegador."; +"Automatically imports browser session cookies." = "Importa automaticamente cookies da sesión do navegador."; +"Azure OpenAI deployment name. AZURE_OPENAI_DEPLOYMENT_NAME is also supported." = "Nome da implantación de Azure OpenAI. Tamén se admite AZURE_OPENAI_DEPLOYMENT_NAME."; +"Azure OpenAI key" = "Chave de Azure OpenAI"; +"Azure OpenAI resource endpoint. AZURE_OPENAI_ENDPOINT is also supported." = "Punto de conexión do recurso de Azure OpenAI. Tamén se admite AZURE_OPENAI_ENDPOINT."; +"Base URL" = "URL base"; +"Base URL for the LLM-API-Key-Proxy instance." = "URL base da instancia de LLM-API-Key-Proxy."; +"Browser cookies" = "Cookies do navegador"; +"Cap end" = "Fin do límite"; +"Cap start" = "Inicio do límite"; +"Capacity End" = "Fin da capacidade"; +"Capacity Start" = "Inicio da capacidade"; +"Changelog" = "Rexistro de cambios"; +"Choose the Moonshot/Kimi API host for international or China mainland accounts." = "Escolle o servidor da API de Moonshot/Kimi para contas internacionais ou da China continental."; +"CodexBar can't replace a system account that is signed in with an API key only setup." = "CodexBar non pode substituír unha conta do sistema que iniciou sesión usando só unha chave de API."; +"CodexBar could not find saved auth for that account. Re-authenticate it and try again." = "CodexBar non atopou a autenticación gardada desa conta. Volve autenticala e téntao de novo."; +"CodexBar could not read managed account storage. Recover the store before adding another account." = "CodexBar non puido ler o almacenamento das contas xestionadas. Recupera o almacén antes de engadir outra conta."; +"CodexBar could not read saved auth for that account. Re-authenticate it and try again." = "CodexBar non puido ler a autenticación gardada desa conta. Volve autenticala e téntao de novo."; +"CodexBar could not read the current system account on this Mac." = "CodexBar non puido ler a conta actual do sistema neste Mac."; +"CodexBar could not replace the live Codex auth on this Mac." = "CodexBar non puido substituír a autenticación activa de Codex neste Mac."; +"CodexBar could not safely preserve the current system account before switching." = "CodexBar non puido conservar con seguridade a conta actual do sistema antes de cambiala."; +"CodexBar could not save the current system account before switching." = "CodexBar non puido gardar a conta actual do sistema antes de cambiala."; +"CodexBar could not update managed account storage." = "CodexBar non puido actualizar o almacenamento das contas xestionadas."; +"CodexBar found another managed account that already uses the current system account. Resolve the duplicate account before switching." = "CodexBar atopou outra conta xestionada que xa usa a conta actual do sistema. Resolve a conta duplicada antes de cambiar."; +"CodexBar will ask macOS Keychain for “%@” so it can decrypt browser cookies and authenticate your account. Click OK to continue." = "CodexBar pediralle ao Chaveiro de macOS “%@” para descifrar as cookies do navegador e autenticar a túa conta. Preme Aceptar para continuar."; +"CodexBar will ask macOS Keychain for the Claude Code OAuth token so it can fetch your Claude usage. Click OK to continue." = "CodexBar pediralle ao Chaveiro de macOS o token OAuth de Claude Code para obter o teu uso de Claude. Preme Aceptar para continuar."; +"CodexBar will ask macOS Keychain for your Amp cookie header so it can fetch usage. Click OK to continue." = "CodexBar pediralle ao Chaveiro de macOS a túa cabeceira Cookie de Amp para obter o uso. Preme Aceptar para continuar."; +"CodexBar will ask macOS Keychain for your Augment cookie header so it can fetch usage. Click OK to continue." = "CodexBar pediralle ao Chaveiro de macOS a túa cabeceira Cookie de Augment para obter o uso. Preme Aceptar para continuar."; +"CodexBar will ask macOS Keychain for your Claude cookie header so it can fetch Claude web usage. Click OK to continue." = "CodexBar pediralle ao Chaveiro de macOS a túa cabeceira Cookie de Claude para obter o uso web de Claude. Preme Aceptar para continuar."; +"CodexBar will ask macOS Keychain for your Cursor cookie header so it can fetch usage. Click OK to continue." = "CodexBar pediralle ao Chaveiro de macOS a túa cabeceira Cookie de Cursor para obter o uso. Preme Aceptar para continuar."; +"CodexBar will ask macOS Keychain for your Factory cookie header so it can fetch usage. Click OK to continue." = "CodexBar pediralle ao Chaveiro de macOS a túa cabeceira Cookie de Factory para obter o uso. Preme Aceptar para continuar."; +"CodexBar will ask macOS Keychain for your GitHub Copilot token so it can fetch usage. Click OK to continue." = "CodexBar pediralle ao Chaveiro de macOS o teu token de GitHub Copilot para obter o uso. Preme Aceptar para continuar."; +"CodexBar will ask macOS Keychain for your Kimi auth token so it can fetch usage. Click OK to continue." = "CodexBar pediralle ao Chaveiro de macOS o teu token de autenticación de Kimi para obter o uso. Preme Aceptar para continuar."; +"CodexBar will ask macOS Keychain for your MiniMax API token so it can fetch usage. Click OK to continue." = "CodexBar pediralle ao Chaveiro de macOS o teu token de API de MiniMax para obter o uso. Preme Aceptar para continuar."; +"CodexBar will ask macOS Keychain for your MiniMax cookie header so it can fetch usage. Click OK to continue." = "CodexBar pediralle ao Chaveiro de macOS a túa cabeceira Cookie de MiniMax para obter o uso. Preme Aceptar para continuar."; +"CodexBar will ask macOS Keychain for your OpenAI cookie header so it can fetch Codex dashboard extras. Click OK to continue." = "CodexBar pediralle ao Chaveiro de macOS a túa cabeceira Cookie de OpenAI para obter os extras do panel de Codex. Preme Aceptar para continuar."; +"CodexBar will ask macOS Keychain for your OpenCode cookie header so it can fetch usage. Click OK to continue." = "CodexBar pediralle ao Chaveiro de macOS a túa cabeceira Cookie de OpenCode para obter o uso. Preme Aceptar para continuar."; +"CodexBar will ask macOS Keychain for your Synthetic API key so it can fetch usage. Click OK to continue." = "CodexBar pediralle ao Chaveiro de macOS a túa chave de API de Synthetic para obter o uso. Preme Aceptar para continuar."; +"CodexBar will ask macOS Keychain for your z.ai API token so it can fetch usage. Click OK to continue." = "CodexBar pediralle ao Chaveiro de macOS o teu token de API de z.ai para obter o uso. Preme Aceptar para continuar."; +"Could not open Cursor login in your browser." = "Non se puido abrir o inicio de sesión de Cursor no teu navegador."; +"Could not open browser for Antigravity" = "Non se puido abrir o navegador para Antigravity"; +"Credits used" = "Créditos utilizados"; +"Day" = "Día"; +"Deployment" = "Despregamento"; +"Drag to reorder" = "Arrastra para reordenar"; +"Sort providers alphabetically" = "Ordenar os provedores alfabeticamente"; +"Sort providers alphabetically (enabled first)" = "Ordenar os provedores alfabeticamente (activado primeiro)"; +"Sorted alphabetically (enabled first) — click to use your custom order" = "Ordenados alfabeticamente (os activados primeiro): preme para usar a túa orde personalizada"; +"Endpoint" = "Punto de conexión"; +"Enterprise host" = "Servidor empresarial"; +"Extra usage balance: %@" = "Saldo de uso adicional: %@"; +"Keychain Access Required" = "Requírese acceso ao Chaveiro"; +"keychain_prompt_learn_more" = "Máis información…"; +"keychain_prompt_privacy_note" = "macOS, non CodexBar, xestiona a introdución do contrasinal de inicio de sesión do Mac. Podes desactivar o acceso ao Chaveiro en calquera momento en Axustes → Avanzado."; +"Kiro menu bar value" = "Valor de Kiro na barra de menús"; +"Label" = "Etiqueta"; +"No organizations loaded. Click Refresh after setting your API key." = "Non se cargou ningunha organización. Preme Actualizar despois de configurar a túa chave de API."; +"No output captured." = "Non se capturou ningunha saída."; +"No system account" = "Sen conta do sistema"; +"Oasis-Token" = "Oasis-Token"; +"Open Augment (Log Out & Back In)" = "Abrir Augment (pechar sesión e volver entrar)"; +"Open Codebuff Dashboard" = "Abrir o panel de Codebuff"; +"Open Command Code Settings" = "Abrir os axustes de Command Code"; +"Open Crof dashboard" = "Abrir o panel de Crof"; +"Open Manus" = "Abrir Manus"; +"Open MiMo Balance" = "Abrir o saldo de MiMo"; +"Open Moonshot Console" = "Abrir a consola de Moonshot"; +"Open Ollama API Keys" = "Abrir as chaves de API de Ollama"; +"Open StepFun Platform" = "Abrir a plataforma StepFun"; +"Open T3 Chat Settings" = "Abrir os axustes de T3 Chat"; +"Open Volcengine Ark Console" = "Abrir a consola de Volcengine Ark"; +"Open legacy provider docs" = "Abrir a documentación do provedor antigo"; +"Open projects" = "Abrir proxectos"; +"Open this URL manually to continue login:\n\n%@" = "Abre este URL manualmente para continuar o inicio de sesión:\n\n%@"; +"Optional organization ID for accounts linked to multiple Anthropic organizations." = "ID de organización opcional para contas vinculadas a varias organizacións de Anthropic."; +"Optional. Applies to the configured Admin API key; selected token accounts do not inherit OPENAI_PROJECT_ID." = "Opcional. Aplícase á chave configurada da API de administración; as contas de token seleccionadas non herdan OPENAI_PROJECT_ID."; +"Optional. Enter your GitHub Enterprise host, for example octocorp.ghe.com. Leave blank for github.com." = "Opcional. Introduce o teu servidor de GitHub Enterprise, por exemplo octocorp.ghe.com. Deixa en branco para github.com."; +"Optional. Leave blank to discover and aggregate projects visible to the API key." = "Opcional. Déixao en branco para descubrir e agregar os proxectos visibles para a chave de API."; +"Org ID (optional)" = "ID da organización (opcional)"; +"Organizations" = "Organizacións"; +"Organization ID" = "ID da organización"; +"Password" = "Contrasinal"; +"%@ authentication is disabled." = "A autenticación %@ está desactivada."; +"%@ cookies are disabled." = "As cookies de %@ están desactivadas."; +"%@ web API access is disabled." = "O acceso á API web de %@ está desactivado."; +"Disable %@ dashboard cookie usage." = "Desactiva o uso das cookies do panel de %@."; +"Keychain access is disabled in Advanced, so browser cookie import is unavailable." = "O acceso ao chaveiro está desactivado en Avanzado, polo que a importación de cookies do navegador non está dispoñible."; +"Manually paste an %@ from a browser session." = "Pega manualmente un %@ dunha sesión do navegador."; +"Paste a Cookie header captured from %@." = "Pega unha cabeceira de cookie capturada de %@."; +"Paste a Cookie header from %@." = "Pega unha cabeceira de cookie de %@."; +"Paste a Cookie header or cURL capture from %@." = "Pega unha cabeceira de cookies ou unha captura cURL de %@."; +"Paste a Cookie header or full cURL capture from %@." = "Pega unha cabeceira de cookie ou unha captura de cURL completa de %@."; +"Paste a Cookie or Authorization header from %@." = "Pega unha cabeceira de cookie ou autorización de %@."; +"Paste a full cookie header or the %@ value." = "Pega unha cabeceira de cookie completa ou o valor %@."; +"Paste a Cookie header or full cURL capture from T3 Chat settings." = "Pega unha cabeceira Cookie ou unha captura cURL completa desde os axustes de T3 Chat."; +"Paste the Cookie header from a request to admin.mistral.ai. Must contain an ory_session_* cookie." = "Pega a cabeceira Cookie dunha solicitude a admin.mistral.ai. Debe conter unha cookie ory_session_*."; +"Paste the Oasis-Token from a logged-in browser session on platform.stepfun.com." = "Pega o Oasis-Token desde unha sesión de navegador iniciada en platform.stepfun.com."; +"Paste the %@ JSON bundle from %@." = "Pega o paquete %@ JSON de %@."; +"Paste the %@ value or a full Cookie header." = "Pega o valor %@ ou unha cabeceira completa da cookie."; +"Personal account" = "Conta persoal"; +"Project ID" = "ID do proxecto"; +"Re-auth" = "Volver autenticar"; +"Re-login at claude.ai" = "Volve iniciar sesión en claude.ai"; +"Re-authenticating…" = "Reautenticando..."; +"Refresh Session" = "Actualizar a sesión"; +"Refresh organizations" = "Actualizar as organizacións"; +"Region" = "Rexión"; +"Reload" = "Recargar"; +"Reorder" = "Reordenar"; +"Secret access key" = "Chave de acceso secreta"; +"Series" = "Serie"; +"Service" = "Servizo"; +"Show or hide Kiro credits, percent, or both next to the menu bar icon." = "Amosa ou oculta os créditos de Kiro, a porcentaxe ou ambos xunto á icona da barra de menús."; +"Show usage for organizations you belong to. Personal account is always shown." = "Amosa o uso das organizacións ás que pertences. A conta persoal amósase sempre."; +"Sign in to cursor.com in your browser, then refresh Cursor in CodexBar." = "Inicia sesión en cursor.com no navegador e despois actualiza Cursor en CodexBar."; +"Simulated error text" = "Texto de erro simulado"; +"StepFun platform account (phone number or email)." = "Conta da plataforma StepFun (número de teléfono ou correo electrónico)."; +"Stored in ~/.codexbar/config.json." = "Gardado en ~/.codexbar/config.json."; +"Stored in ~/.codexbar/config.json. AZURE_OPENAI_API_KEY is also supported." = "Gardado en ~/.codexbar/config.json. Tamén se admite AZURE_OPENAI_API_KEY."; +"Stored in ~/.codexbar/config.json. For the official Kimi API, use Moonshot / Kimi API." = "Gardado en ~/.codexbar/config.json. Para a API oficial de Kimi, usa Moonshot / Kimi API."; +"Stored in ~/.codexbar/config.json. Get your API key from the Volcengine Ark console." = "Gardado en ~/.codexbar/config.json. Obtén a túa chave de API na consola de Volcengine Ark."; +"Stored in ~/.codexbar/config.json. Get your key from Ollama settings." = "Gardado en ~/.codexbar/config.json. Obtén a túa chave nos axustes de Ollama."; +"Stored in ~/.codexbar/config.json. Get your key from console.deepgram.com." = "Gardado en ~/.codexbar/config.json. Obtén a túa chave en console.deepgram.com."; +"Stored in ~/.codexbar/config.json. Get your key from elevenlabs.io/app/settings/api-keys." = "Gardado en ~/.codexbar/config.json. Obtén a túa chave en elevenlabs.io/app/settings/api-keys."; +"Stored in ~/.codexbar/config.json. Get your key from openrouter.ai/settings/keys and set a key spending limit there to enable API key quota tracking." = "Gardado en ~/.codexbar/config.json. Obtén a túa chave en openrouter.ai/settings/keys e define alí un límite de gasto para activar o seguimento da cota da chave de API."; +"Stored in ~/.codexbar/config.json. In Warp, open Settings > Platform > API Keys, then create one." = "Gardado en ~/.codexbar/config.json. En Warp, abre Settings > Platform > API Keys e crea unha chave."; +"Stored in ~/.codexbar/config.json. Metrics require Groq Enterprise Prometheus access." = "Gardado en ~/.codexbar/config.json. As métricas requiren acceso a Groq Enterprise Prometheus."; +"Stored in ~/.codexbar/config.json. OPENAI_ADMIN_KEY is preferred; OPENAI_API_KEY still works." = "Gardado en ~/.codexbar/config.json. Prefírese OPENAI_ADMIN_KEY; OPENAI_API_KEY tamén funciona."; +"Stored in ~/.codexbar/config.json. Requires an Anthropic Admin API key." = "Gardado en ~/.codexbar/config.json. Require unha chave da API de administración de Anthropic."; +"Stored in ~/.codexbar/config.json. Used for /v1/quota-stats." = "Gardado en ~/.codexbar/config.json. Úsase para /v1/quota-stats."; +"Stored in ~/.codexbar/config.json. You can also provide CODEBUFF_API_KEY or let CodexBar read ~/.config/manicode/credentials.json (created by `codebuff login`)." = "Gardado en ~/.codexbar/config.json. Tamén podes fornecer CODEBUFF_API_KEY ou deixar que CodexBar lea ~/.config/manicode/credentials.json (creado por `codebuff login`)."; +"Stored in ~/.codexbar/config.json. You can also provide CROF_API_KEY." = "Gardado en ~/.codexbar/config.json. Tamén podes fornecer CROF_API_KEY."; +"Stored in ~/.codexbar/config.json. You can also provide KILO_API_KEY or ~/.local/share/kilo/auth.json (kilo.access)." = "Gardado en ~/.codexbar/config.json. Tamén podes fornecer KILO_API_KEY ou ~/.local/share/kilo/auth.json (kilo.access)."; +"T3 Chat cookie" = "Cookie de T3 Chat"; +"Team mode" = "Modo de equipo"; +"That account is no longer available in CodexBar. Refresh the account list and try again." = "Esa conta xa non está dispoñible en CodexBar. Actualiza a lista de contas e téntao de novo."; +"The browser login did not complete in time. Try Antigravity login again." = "O inicio de sesión do navegador non rematou a tempo. Tenta iniciar sesión en Antigravity de novo."; +"Timed out waiting for Cursor login. %@" = "Esgotouse o tempo de espera polo inicio de sesión de Cursor. %@"; +"Timed out waiting for Cursor login. %@ Last error: %@" = "Esgotouse o tempo de espera polo inicio de sesión de Cursor. %@ Último erro: %@"; +"Today requests" = "Solicitudes de hoxe"; +"Total (30d): %@ credits" = "Total (30d): %@ créditos"; +"Username" = "Nome de usuario"; +"Uses username + password to login and obtain an Oasis-Token automatically." = "Usa nome de usuario + contrasinal para iniciar sesión e obter un Oasis-Token automaticamente."; +"Uses username + password to login and obtain an %@ automatically." = "Usa nome de usuario + contrasinal para iniciar sesión e obter un %@ automaticamente."; +"Utilization End" = "Fin de utilización"; +"Utilization Start" = "Inicio de utilización"; +"Verbosity" = "Nivel de detalle"; +"Windsurf session JSON bundle" = "Paquete JSON da sesión de Windsurf"; +"Workspace ID" = "ID do espazo de traballo"; +"Your StepFun platform password. Used to login and obtain a session token." = "O teu contrasinal da plataforma StepFun. Usado para iniciar sesión e obter un token de sesión."; +"claude /login exited with status %d." = "claude /login saíu co estado %d."; +"codex login exited with status %d." = "codex login rematou co estado %d."; +"Cookie: …\n\nor paste a cURL capture from the Abacus AI dashboard" = "Cookie:…\n\nou pega unha captura de cURL desde o panel de control de Abacus AI"; +"Cookie: …\n\nor paste the __Secure-next-auth.session-token value" = "Cookie:…\n\nou pega o valor __Secure-next-auth.session-token"; +"Cookie: …\n\nor paste the kimi-auth token value" = "Cookie:…\n\nou pega o valor do token kimi-auth"; +"session_id=...\n\nor paste just the session_id value" = "session_id=...\n\nou pega só o valor session_id"; +"Clear" = "Limpar"; +"No matching providers" = "Non hai provedores coincidentes"; +"Search providers" = "Buscar provedores"; + +"language_vietnamese" = "Vietnamita"; +"language_turkish" = "Türkçe"; +"language_indonesian" = "Bahasa Indonesia"; +"language_arabic" = "العربية"; +"language_persian" = "فارسی"; +"language_thai" = "ไทย"; +"language_galician" = "Galego"; +"Limit Reset Credits" = "Créditos para restablecer límites"; +"1 available" = "1 dispoñible"; +"%d available" = "%d dispoñibles"; +"Next expires %@" = "O seguinte caduca %@"; +"Expires %@" = "Caduca %@"; +"No expiry" = "Sen caducidade"; +"Other (%d items)" = "Outros (%d elementos)"; +"Expand" = "Expandir"; +"Collapse" = "Contraer"; +"byte_unit_byte" = "byte"; +"byte_unit_bytes" = "bytes"; +"byte_unit_kilobyte" = "kilobyte"; +"byte_unit_kilobytes" = "kilobytes"; +"byte_unit_megabyte" = "megabyte"; +"byte_unit_megabytes" = "megabytes"; +"byte_unit_gigabyte" = "gigabyte"; +"byte_unit_gigabytes" = "gigabytes"; +"Open Status Page" = "Abrir a páxina de estado"; +"%@ is unavailable in the current environment." = "%@ non está dispoñible no contorno actual."; +"%@ left" = "%@ restante"; +"%@ · %@" = "%@ · %@"; +"%@: %@" = "%@: %@"; +"%@: %@ · %@ tokens" = "%@: %@ · %@ tokens"; +"%@: %@%% used" = "%@: %@%% usado"; +"%d more items" = "%d elementos máis"; +"%d unreadable item(s) skipped" = "Omitíronse %d elementos ilexibles"; +"%d%% in deficit" = "%d%% en déficit"; +"%d%% in reserve" = "%d%% en reserva"; +"%dd" = "%d d"; +"1.5× headroom" = "1,5× de marxe"; +"About CodexBar" = "Acerca de CodexBar"; +"Add Account..." = "Engadir conta..."; +"Add Google Account" = "Engadir conta de Google"; +"Add accounts via GitHub OAuth Device Flow on the selected host." = "Engade contas mediante o fluxo de dispositivo OAuth de GitHub no servidor seleccionado."; +"Admin API key" = "Chave de API de administración"; +"All Systems Operational" = "Todos os sistemas operativos"; +"Alternatively, set a custom path in Settings." = "Tamén podes definir unha ruta personalizada en Axustes."; +"Auto" = "Automático"; +"Auto uses the local IDE API first, then Google OAuth when the IDE is closed." = "O modo automático usa primeiro a API local do IDE e despois Google OAuth cando o IDE está pechado."; +"Cleanup ideas" = "Suxestións de limpeza"; +"Clearing removes archived Codex session history." = "Ao limpar elimínase o historial de sesións arquivadas de Codex."; +"Clearing removes cached large pastes or attached images." = "Ao limpar elimínanse as pegadas grandes e as imaxes anexas da caché."; +"Clearing removes checkpoint restore data for previous edits." = "Ao limpar elimínanse os datos de restauración dos puntos de control de edicións anteriores."; +"Clearing removes leftover runtime shell snapshot files." = "Ao limpar elimínanse os ficheiros de instantáneas de shell restantes do tempo de execución."; +"Clearing removes legacy per-session task lists." = "Ao limpar elimínanse as listas de tarefas antigas de cada sesión."; +"Clearing removes local diagnostic logs." = "Ao limpar elimínanse os rexistros de diagnóstico locais."; +"Clearing removes local edit checkpoint history." = "Ao limpar elimínase o historial local de puntos de control de edición."; +"Clearing removes local temporary provider data." = "Ao limpar elimínanse os datos temporais locais do provedor."; +"Clearing removes old plan-mode files." = "Ao limpar elimínanse os ficheiros antigos do modo de planificación."; +"Clearing removes past Codex session history." = "Ao limpar elimínase o historial de sesións anteriores de Codex."; +"Clearing removes past debug logs." = "Ao limpar elimínanse os rexistros de depuración anteriores."; +"Clearing removes past resume, continue, and rewind history." = "Ao limpar elimínase o historial anterior de retomar, continuar e rebobinar."; +"Clearing removes per-session environment metadata." = "Ao limpar elimínanse os metadatos de contorno de cada sesión."; +"Clearing removes provider-owned cached data." = "Ao limpar elimínanse os datos da caché propiedade do provedor."; +"Credits unavailable; keep Codex running to refresh." = "Os créditos non están dispoñibles; mantén Codex en execución para actualizalos."; +"Daily" = "Diario"; +"Disable" = "Desactivar"; +"Disabled — no recent data" = "Desactivado — sen datos recentes"; +"Enable" = "Activar"; +"Est. total (%@): %@" = "Total estimado (%@): %@"; +"Est. total (30d): %@" = "Total estimado (30 d): %@"; +"Estimated from local Codex logs for the selected account." = "Estimado a partir dos rexistros locais de Codex para a conta seleccionada."; +"Google OAuth" = "Google OAuth"; +"Google accounts" = "Contas de Google"; +"Hourly Tokens" = "Tokens por hora"; +"Hover a bar for details" = "Pasa o cursor sobre unha barra para ver os detalles"; +"Image Generation" = "Xeración de imaxes"; +"Install a JetBrains IDE with AI Assistant enabled, then refresh CodexBar." = "Instala un IDE de JetBrains co Asistente de IA activado e despois actualiza CodexBar."; +"Last %d day" = "Último %d día"; +"Last 30 days" = "Últimos 30 días"; +"Last 30 days:" = "Últimos 30 días:"; +"Last 30 days: %@" = "Últimos 30 días: %@"; +"Last 30 days: %@ · %@ tokens" = "Últimos 30 días: %@ · %@ tokens"; +"Lasts until reset" = "Dura ata o restablecemento"; +"Login with Google" = "Iniciar sesión con Google"; +"Manual cleanup: archived sessions" = "Limpeza manual: sesións arquivadas"; +"Manual cleanup: attachment cache" = "Limpeza manual: caché de anexos"; +"Manual cleanup: cache" = "Limpeza manual: caché"; +"Manual cleanup: debug logs" = "Limpeza manual: rexistros de depuración"; +"Manual cleanup: file checkpoints" = "Limpeza manual: puntos de control de ficheiros"; +"Manual cleanup: file history" = "Limpeza manual: historial de ficheiros"; +"Manual cleanup: legacy todos" = "Limpeza manual: tarefas antigas"; +"Manual cleanup: logs" = "Limpeza manual: rexistros"; +"Manual cleanup: past sessions" = "Limpeza manual: sesións anteriores"; +"Manual cleanup: saved plans" = "Limpeza manual: plans gardados"; +"Manual cleanup: session metadata" = "Limpeza manual: metadatos das sesións"; +"Manual cleanup: sessions" = "Limpeza manual: sesións"; +"Manual cleanup: shell snapshots" = "Limpeza manual: instantáneas de shell"; +"Manual cleanup: temporary data" = "Limpeza manual: datos temporais"; +"Missing DeepSeek API key." = "Falta a chave de API de DeepSeek."; +"Music Generation" = "Xeración de música"; +"No %@ utilization data yet." = "Aínda non hai datos de utilización de %@."; +"No Cursor session found. Please log in to cursor.com in Safari, Chrome, Microsoft Edge, Brave, Arc, Dia, ChatGPT Atlas, Chromium, Helium, Vivaldi, Yandex Browser, Firefox, Zen, Colibri, Sidekick, Opera, Opera GX, or Edge Canary. If you use Safari, grant CodexBar Full Disk Access in System Settings ▸ Privacy & Security. You can also sign in to Cursor from the CodexBar menu (Add / switch account)." = "Non se atopou ningunha sesión de Cursor. Inicia sesión en cursor.com desde Safari, Chrome, Microsoft Edge, Brave, Arc, Dia, ChatGPT Atlas, Chromium, Helium, Vivaldi, Yandex Browser, Firefox, Zen, Colibri, Sidekick, Opera, Opera GX ou Edge Canary. Se usas Safari, concede a CodexBar acceso completo ao disco en Axustes do Sistema ▸ Privacidade e seguridade. Tamén podes iniciar sesión en Cursor desde o menú de CodexBar (Engadir / cambiar conta)."; +"No JetBrains IDE with AI Assistant detected. Install a JetBrains IDE and enable AI Assistant." = "Non se detectou ningún IDE de JetBrains co Asistente de IA. Instala un IDE de JetBrains e activa o Asistente de IA."; +"No OpenCode session cookies found in browsers." = "Non se atoparon cookies de sesión de OpenCode nos navegadores."; +"No available fetch strategy for %@." = "Non hai ningunha estratexia de obtención dispoñible para %@."; +"No available fetch strategy for minimax." = "Non hai ningunha estratexia de obtención dispoñible para MiniMax."; +"No local data found" = "Non se atoparon datos locais"; +"No overview data available." = "Non hai datos de resumo dispoñibles."; +"No providers selected for Overview." = "Non hai provedores seleccionados para o Resumo."; +"No usage breakdown data available." = "Non hai datos de desglose de uso dispoñibles."; +"No utilization data yet." = "Aínda non hai datos de utilización."; +"On pace" = "Ao ritmo previsto"; +"Open Token Plan" = "Abrir o plan de tokens"; +"Open billing" = "Abrir a facturación"; +"OpenRouter API token not configured. Set OPENROUTER_API_KEY environment variable or configure in Settings." = "O token da API de OpenRouter non está configurado. Define a variable de contorno OPENROUTER_API_KEY ou configúrao en Axustes."; +"Pace: %@" = "Ritmo: %@"; +"Pace: %@ · %@" = "Ritmo: %@ · %@"; +"Plan Usage" = "Uso do plan"; +"Projected empty in %@" = "Prevese que se esgote en %@"; +"Projected empty now" = "Prevese que se esgote agora"; +"Quit" = "Saír"; +"Refreshing" = "Actualizando"; +"Request quota: %@ / %@" = "Cota de solicitudes: %@ / %@"; +"Resets %@" = "Restablécese %@"; +"Resets in %@" = "Restablécese en %@"; +"Resets now" = "Restablécese agora"; +"Runs out in %@" = "Esgótase en %@"; +"Runs out now" = "Esgótase agora"; +"Session" = "Sesión"; +"Settings..." = "Axustes..."; +"Sign in with Claude Code..." = "Iniciar sesión con Claude Code..."; +"Source" = "Orixe"; +"State" = "Estado"; +"Status Page" = "Páxina de estado"; +"Store multiple Antigravity Google OAuth accounts for quick switching." = "Garda varias contas de Google OAuth de Antigravity para cambiar rapidamente."; +"Store multiple DeepSeek API keys." = "Garda varias chaves de API de DeepSeek."; +"Store multiple OpenAI API keys." = "Garda varias chaves de API de OpenAI."; +"Stores each signed-in Google account for quick Antigravity switching. Uses Antigravity.app OAuth when available, or ANTIGRAVITY_OAUTH_CLIENT_ID and ANTIGRAVITY_OAUTH_CLIENT_SECRET as an override." = "Garda cada conta de Google coa sesión iniciada para cambiar rapidamente en Antigravity. Usa o OAuth de Antigravity.app cando está dispoñible ou ANTIGRAVITY_OAUTH_CLIENT_ID e ANTIGRAVITY_OAUTH_CLIENT_SECRET como substitución."; +"Switch Account..." = "Cambiar conta..."; +"Text Generation" = "Xeración de texto"; +"Text to Speech" = "Texto a voz"; +"Total: %@" = "Total: %@"; +"Unavailable" = "Non dispoñible"; +"Update ready, restart now?" = "A actualización está lista. Queres reiniciar agora?"; +"Updated %@" = "Actualizado %@"; +"Updated %@h ago" = "Actualizado hai %@ h"; +"Updated %@m ago" = "Actualizado hai %@ min"; +"Updated absolute %@" = "Actualizado %@"; +"Updated just now" = "Actualizado agora mesmo"; +"Updated relative %@" = "Actualizado %@"; +"Usage Dashboard" = "Panel de uso"; +"Weekly" = "Semanal"; +"just now" = "agora mesmo"; +"login_success_notification_body" = "Podes volver á aplicación; a autenticación rematou."; +"login_success_notification_title" = "Inicio de sesión en %@ correcto"; +"minimax_service_coding_plan_search" = "Busca do plan de programación"; +"minimax_service_coding_plan_vlm" = "VLM do plan de programación"; +"minimax_service_image_generation" = "Xeración de imaxes"; +"minimax_service_lyrics_generation" = "Xeración de letras"; +"minimax_service_music_generation" = "Xeración de música"; +"minimax_service_text_generation" = "Xeración de texto"; +"minimax_service_text_to_speech" = "Texto a voz"; +"minimax_usage_amount_format" = "Uso: %@ / %@"; +"minimax_used_percent_format" = "Usado %@"; +"not detected" = "non detectado"; +"providers_on_count" = "%d activados"; +"quota_warning_notification_body" = "Queda %1$@. Acadaches o limiar de aviso do %2$d%% para a cota %3$@."; +"quota_warning_notification_body_with_account" = "Conta %1$@. Queda %2$@. Acadaches o limiar de aviso do %3$d%% para a cota %4$@."; +"predictive_pace_warnings_title" = "Avisos preditivos de ritmo"; +"predictive_pace_warnings_subtitle" = "Avisa para Codex e Claude cando o ritmo de sesión ou semanal pode esgotar a cota antes do reinicio."; +"confetti_on_reset_title" = "Confeti ao reiniciar"; +"confetti_on_reset_subtitle" = "Amosa confeti a pantalla completa cando se reinicie o uso."; +"confetti_option_off" = "Desactivado"; +"confetti_option_session" = "Reinicios da sesión"; +"confetti_option_weekly" = "Reinicios semanais"; +"confetti_option_both" = "Ambos"; +"predictive_pace_warning_notification_title" = "%1$@: aviso de ritmo %2$@"; +"predictive_pace_warning_notification_body" = "Ao ritmo actual, esta cota podería esgotarse en %1$@, antes de reiniciarse."; +"predictive_pace_warning_notification_body_with_account" = "Conta %1$@. Ao ritmo actual, esta cota podería esgotarse en %2$@, antes de reiniciarse."; +"quota_warning_notification_title" = "Cota %2$@ de %1$@ baixa"; +"quota_warning_onscreen_alert" = "Amosar unha alerta de texto en pantalla"; +"refresh_adaptive" = "Adaptativa"; +"refresh_adaptive_agent_aware" = "Adaptativa (actividade de axentes)"; +"adaptive_activity_consent_title" = "Permitir a actualización segundo a actividade?"; +"adaptive_activity_consent_message" = "O modo Adaptativo segundo a actividade de axentes pode inspeccionar a lista de procesos locais en execución, incluídas as liñas de comandos, para identificar Codex e Claude, e ler os metadatos de sesións coñecidas cada 30 segundos mentres programas. Con Agent Sessions desactivado, CodexBar só conserva na memoria a hora da actividade máis recente e descarta as rutas e identidades das sesións. Estes datos non se envían a ningún sitio, e a detección remota e SSH seguen desactivados. Se rexeitas, CodexBar volverá ao modo Adaptativo normal sen exploracións de actividade local."; +"adaptive_activity_consent_allow" = "Permitir actividade local"; +"adaptive_activity_consent_decline" = "Usar o Adaptativo normal"; +"refresh_on_open_subtitle" = "Obtén o uso máis recente de cada provedor cada vez que abras o menú."; +"refresh_on_open_title" = "Actualizar ao abrir o menú"; +"section_command_line" = "Liña de ordes"; +"section_cost_summary" = "Resumo de custos"; +"section_diagnostics" = "Diagnóstico"; +"section_links" = "Ligazóns"; +"section_privacy" = "Privacidade"; +"section_updates" = "Actualizacións"; +"session_depleted_notification_body" = "Queda un 0%. Avisarémoste cando volva estar dispoñible."; +"session_depleted_notification_title" = "Sesión de %@ esgotada"; +"session_restored_notification_body" = "A cota da sesión volve estar dispoñible."; +"session_restored_notification_title" = "Sesión de %@ restaurada"; +"today" = "hoxe"; +"usage_percent_suffix_left" = "restante"; +"usage_percent_suffix_used" = "usado"; +"z.ai API token not found. Set apiKey in ~/.codexbar/config.json or Z_AI_API_KEY." = "Non se atopou o token da API de z.ai. Define apiKey en ~/.codexbar/config.json ou Z_AI_API_KEY."; +"≈ %d%% run-out risk" = "≈ %d%% de risco de esgotamento"; +"Show Codex Spark usage" = "Amosar o uso de Codex Spark"; +"Shows Codex Spark quota rows in the menu and provider preview. Requires optional credits and extra usage in Display settings." = "Amosa as filas de cota de Codex Spark no menú e na previsualización do provedor. Require activar «Amosar créditos + uso adicional» nos axustes de Pantalla."; +"Show Daily Routines usage" = "Amosar o uso de Rutinas diarias"; +"Shows the Daily Routines quota row in the menu and provider preview. Requires optional credits and extra usage in Display settings." = "Amosa a fila de cota de Rutinas diarias no menú e na previsualización do provedor. Require activar «Amosar créditos + uso adicional» nos axustes de Pantalla."; +"Scroll to see more models" = "Desprázate para ver máis modelos"; + +/* Shareable usage card */ +"Copy Image" = "Copiar imaxe"; +"Copy Stats" = "Copiar estatísticas"; +"Could not copy image" = "Non se puido copiar a imaxe"; +"Image copied" = "Imaxe copiada"; +"Image saved" = "Imaxe gardada"; +"Nothing is uploaded. This image is created on your Mac." = "Non se carga nada. Esta imaxe créase no teu Mac."; +"Save..." = "Gardar..."; +"Share AI Usage" = "Compartir uso da IA"; +"Share Stats…" = "Compartir estatísticas…"; +"Stats copied" = "Estatísticas copiadas"; +"Finish switching to a different Cursor account in your browser, then try again." = "Completa o cambio a outra conta de Cursor no navegador e téntao de novo."; +"Timed out waiting for Cursor account switch. %@" = "Esgotouse o tempo de espera para cambiar de conta de Cursor. %@"; +"Timed out waiting for Cursor account switch. %@ Last error: %@" = "Esgotouse o tempo de espera para cambiar de conta de Cursor. %@ Último erro: %@"; + +/* Spend dashboard */ +"tab_usage_spend" = "Uso e gasto"; +"Usage & Spend" = "Uso e gasto"; +"Local estimated cost history across supported providers." = "Historial local de custos estimados dos provedores compatibles."; +"Time range" = "Intervalo de tempo"; +"Track costs" = "Rastrexar custos"; +"Cost tracking is off" = "O seguimento de custos está desactivado"; +"Turn on Track costs to build local estimates." = "Activa «Rastrexar custos» para crear estimacións locais."; +"No local cost history yet" = "Aínda non hai historial local de custos"; +"Turn on cost tracking or refresh after using a supported provider." = "Activa o seguimento de custos ou actualiza despois de usar un provedor compatible."; +"Refresh failures" = "Actualizacións falladas"; +"Native currencies stay separate; Codex account rows exclude Pi session history." = "As moedas orixinais mantéñense separadas; as filas das contas de Codex exclúen o historial de sesións de Pi."; +"Spend unavailable" = "Gasto non dispoñible"; +"Model breakdown unavailable" = "Desglose por modelo non dispoñible"; +"Local estimated history" = "Historial local estimado"; +"Coverage" = "Cobertura"; +"Estimated spend" = "Gasto estimado"; +"Tracked tokens" = "Tokens rexistrados"; +"Subscriptions" = "Subscricións"; +"By subscription" = "Por subscrición"; +"No model-level history" = "Sen historial por modelo"; +"Daily estimated spend" = "Gasto diario estimado"; +"≈%d full 5h windows of weekly left · %d windows until reset" = "≈%d xanelas completas de 5 h de cota semanal · %d xanelas ata o restablecemento"; +"Weekly cannot run out before reset at this pace" = "A cota semanal non pode esgotarse antes do restablecemento a este ritmo"; +"Weekly can run out ≈%d windows early" = "A cota semanal pode esgotarse ≈%d xanelas antes"; +"Estimated: %@" = "Estimación: %@"; +"session_quota_estimate_value_format" = "%1$@ %2$@"; +"session quota" = "cota de sesión"; +"session quotas" = "cotas de sesión"; +"Coding Plan" = "Plan de programación"; +"Agent Plan" = "Plan de axente"; +"Team" = "Equipo"; + +/* Menu bar layout editor */ +"menu_bar_layout_title" = "Disposición"; +"menu_bar_layout_footer" = "Arrastra fichas para ordenar a barra de menús. Preme nunha ficha para engadila; selecciona unha ficha colocada e preme Suprimir para retirala."; +"menu_bar_layout_group_identity" = "Identidade"; +"menu_bar_layout_group_usage" = "Uso"; +"menu_bar_layout_group_time" = "Tempo"; +"menu_bar_layout_group_money" = "Custo"; +"menu_bar_layout_group_structure" = "Estrutura"; +"menu_bar_layout_scope_all" = "Todos os provedores"; +"menu_bar_layout_scope_help" = "Edita a disposición predeterminada ou substitúea para un provedor."; +"menu_bar_layout_use_all" = "Usar a disposición de todos os provedores"; +"menu_bar_layout_preset" = "Predefinición de disposición"; +"menu_bar_layout_preset_icon_percent" = "Icona e porcentaxe"; +"menu_bar_layout_preset_icon_only" = "Só icona"; +"menu_bar_layout_preset_percent_reset" = "Porcentaxe e reinicio"; +"menu_bar_layout_preset_compact_stacked" = "Amontoado compacto"; +"menu_bar_layout_preset_custom" = "Personalizado"; +"menu_bar_layout_live_preview" = "Vista previa en directo"; +"menu_bar_layout_strip" = "Franxa da barra de menús"; +"menu_bar_layout_remove_line_break" = "Retirar salto de liña"; +"menu_bar_layout_chip_hint" = "Selecciona, arrastra para reordenar ou usa a acción Retirar."; +"menu_bar_layout_palette_hint" = "Preme para engadir ou arrastra á disposición."; +"menu_bar_layout_empty_line" = "Solta unha ficha aquí"; +"menu_bar_layout_line" = "Liña %d"; +"menu_bar_layout_drag_remove" = "Arrastra aquí para retirar"; +"menu_bar_layout_size" = "Tamaño"; +"menu_bar_layout_size_small" = "Pequeno"; +"menu_bar_layout_size_regular" = "Normal"; +"menu_bar_layout_gap" = "Separación"; +"menu_bar_layout_gap_tight" = "Estreita"; +"menu_bar_layout_gap_regular" = "Normal"; +"menu_bar_layout_keyboard_hint" = "Suprimir retira a ficha seleccionada"; +"menu_bar_layout_sample_account" = "conta"; +"menu_bar_layout_sample_runs_out" = "esgótase ven."; +"menu_bar_layout_token_icon" = "Icona"; +"menu_bar_layout_token_provider" = "Nome do provedor"; +"menu_bar_layout_token_account" = "Conta"; +"menu_bar_layout_token_session" = "Sesión %"; +"menu_bar_layout_token_weekly" = "Semanal %"; +"menu_bar_layout_token_auto" = "% automática"; +"menu_bar_layout_token_bar" = "Barra de uso"; +"menu_bar_layout_token_resets_in" = "Reiníciase en"; +"menu_bar_layout_token_reset_at" = "Reinicio ás"; +"menu_bar_layout_token_runs_out" = "Esgótase"; +"menu_bar_layout_token_cost_today" = "Custo de hoxe"; +"menu_bar_layout_token_cost_30d" = "Custo de 30 días"; +"menu_bar_layout_token_space" = "Espazo"; +"menu_bar_layout_token_line_break" = "Salto de liña"; +"menu_bar_layout_token_separator_accessibility" = "Punto separador"; + +/* Menu bar layout accessibility */ +"Icon unavailable" = "Icona: Non dispoñible"; +"%@ icon" = "%@: Icona"; +"Provider name unavailable" = "Nome do provedor: Non dispoñible"; +"Account unavailable" = "Conta: Non dispoñible"; +"%@ unavailable" = "%@: Non dispoñible"; +"%@ %@" = "%@ %@"; +"Usage bar unavailable" = "Barra de uso: Non dispoñible"; +"Usage bar, %d of 3 filled" = "Barra de uso: %d/3 cheos"; +"Reset countdown unavailable" = "Reiníciase en: Non dispoñible"; +"Reset time unavailable" = "Reinicio ás: Non dispoñible"; +"Run-out estimate unavailable" = "Esgótase: Non dispoñible"; +"Cost today unavailable" = "Custo de hoxe: Non dispoñible"; +"30-day cost unavailable" = "Custo de 30 días: Non dispoñible"; +"Resets" = "Reinicios"; diff --git a/Sources/CodexBar/Resources/gl.lproj/Localizable.stringsdict b/Sources/CodexBar/Resources/gl.lproj/Localizable.stringsdict new file mode 100644 index 0000000000..cf8c929de2 --- /dev/null +++ b/Sources/CodexBar/Resources/gl.lproj/Localizable.stringsdict @@ -0,0 +1,49 @@ + + + + + ≈%d full 5h windows of weekly left · %d windows until reset + + NSStringLocalizedFormatKey + %#@left@ · %#@until@ + left + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + d + one + ≈%d xanela completa de 5 h de cota semanal + other + ≈%d xanelas completas de 5 h de cota semanal + + until + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + d + one + %d xanela ata o restablecemento + other + %d xanelas ata o restablecemento + + + Weekly can run out ≈%d windows early + + NSStringLocalizedFormatKey + %#@early@ + early + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + d + one + A cota semanal pode esgotarse ≈%d xanela antes + other + A cota semanal pode esgotarse ≈%d xanelas antes + + + + diff --git a/Sources/CodexBar/Resources/id.lproj/Localizable.strings b/Sources/CodexBar/Resources/id.lproj/Localizable.strings new file mode 100644 index 0000000000..932abafcb3 --- /dev/null +++ b/Sources/CodexBar/Resources/id.lproj/Localizable.strings @@ -0,0 +1,1357 @@ +/* Indonesian localization for CodexBar */ + +"ollama_safari_cookie_access_hint" = "Cookie Safari memerlukan Akses Disk Penuh untuk CodexBar (Pengaturan Sistem > Privasi & Keamanan)."; +"ollama_browser_cookie_decryption_denied" = "Dekripsi cookie %@ ditolak di Rantai Kunci; coba lagi dengan penyegaran manual."; +"ollama_browser_cookie_decryption_disabled" = "Dekripsi cookie %@ dinonaktifkan di CodexBar; aktifkan akses Rantai Kunci lalu segarkan."; + +" providers" = " penyedia"; +"(System)" = "(Sistem)"; +"30d" = "30 hari"; +"7d" = "7 hari"; +"A managed Codex login is already running. Wait for it to finish before adding " = "Login Codex terkelola sudah berjalan. Tunggu hingga selesai sebelum menambahkan "; +"API key" = "Kunci API"; +"API region" = "Wilayah API"; +"API token" = "Token API"; +"API tokens" = "Token API"; +"About" = "Tentang"; +"Account" = "Akun"; +"Accounts" = "Akun"; +"Accounts subtitle" = "Subjudul akun"; +"Active" = "Aktif"; +"Add" = "Tambah"; +"Add Workspace" = "Tambah Workspace"; +"Advanced" = "Lanjutan"; +"All" = "Semua"; +"Always allow prompts" = "Selalu izinkan permintaan"; +"Animation pattern" = "Pola animasi"; +"Antigravity login is managed in the app" = "Login Antigravity dikelola di dalam aplikasi"; +"Applies only to the Security.framework OAuth keychain reader." = "Hanya berlaku untuk pembaca keychain OAuth Security.framework."; +"Alternatively, set a custom path in Settings." = "Atau, atur jalur kustom di Pengaturan."; +"Auto falls back to the next source if the preferred one fails." = "Otomatis beralih ke sumber berikutnya jika yang dipilih gagal."; +"Auto uses API first, then falls back to CLI on auth failures." = "Otomatis menggunakan API terlebih dahulu, lalu beralih ke CLI saat autentikasi gagal."; +"Auto-detect" = "Deteksi otomatis"; +"Auto-refresh is off; use the menu's Refresh command." = "Penyegaran otomatis nonaktif; gunakan perintah Segarkan di menu."; +"Auto-refresh: hourly · Timeout: 10m" = "Penyegaran otomatis: per jam · Batas waktu: 10m"; +"Automatic" = "Otomatis"; +"Automatic imports browser cookies and WorkOS tokens." = "Otomatis mengimpor cookie browser dan token WorkOS."; +"Automatic imports browser cookies and local storage tokens." = "Otomatis mengimpor cookie browser dan token penyimpanan lokal."; +"Automatic imports browser cookies for dashboard extras." = "Otomatis mengimpor cookie browser untuk fitur tambahan dasbor."; +"Automatic imports browser cookies for the web API." = "Otomatis mengimpor cookie browser untuk web API."; +"Automatic imports browser cookies from Model Studio/Bailian." = "Otomatis mengimpor cookie browser dari Model Studio/Bailian."; +"Automatic imports browser cookies from admin.mistral.ai." = "Otomatis mengimpor cookie browser dari admin.mistral.ai."; +"Automatic imports browser cookies from opencode.ai." = "Otomatis mengimpor cookie browser dari opencode.ai."; +"Automatic imports browser cookies or stored sessions." = "Otomatis mengimpor cookie browser atau sesi tersimpan."; +"Automatic imports browser cookies." = "Otomatis mengimpor cookie browser."; +"Automatically imports browser session cookie." = "Otomatis mengimpor cookie sesi browser."; +"Automatically opens CodexBar when you start your Mac." = "Otomatis membuka CodexBar saat Anda menyalakan Mac."; +"Automation" = "Otomatisasi"; +"Average (\\(label1) + \\(label2))" = "Rata-rata (\\(label1) + \\(label2))"; +"Average (\\(metadata.sessionLabel) + \\(metadata.weeklyLabel))" = "Rata-rata (\\(metadata.sessionLabel) + \\(metadata.weeklyLabel))"; +"Avoid Keychain prompts" = "Hindari permintaan Keychain"; +"Balance" = "Saldo"; +"Battery Saver" = "Penghemat Baterai"; +"Bordered" = "Berbingkai"; +"Build" = "Build"; +"Built \\(buildTimestamp)" = "Dibangun \\(buildTimestamp)"; +"Buy Credits..." = "Beli Kredit..."; +"Buy Credits…" = "Beli Kredit…"; +"CLI paths" = "Path CLI"; +"CLI sessions" = "Sesi CLI"; +"Caches" = "Cache"; +"Cancel" = "Batal"; +"Check for Updates…" = "Periksa Pembaruan…"; +"Check for updates automatically" = "Periksa pembaruan secara otomatis"; +"Check if you like your agents having some fun up there." = "Centang jika Anda suka agen bersenang-senang di atas sana."; +"Check provider status" = "Periksa status penyedia"; +"Choose a supported browser so CodexBar can read the matching account." = "Pilih browser yang didukung agar CodexBar dapat membaca akun yang sesuai."; +"Choose Codex workspace" = "Pilih workspace Codex"; +"Choose Cursor account" = "Pilih akun Cursor"; +"Choose the MiniMax host (global .io or China mainland .com)." = "Pilih host MiniMax (global .io atau Tiongkok daratan .com)."; +"Choose up to " = "Pilih hingga "; +"Choose up to \\(Self.maxOverviewProviders) providers" = "Pilih hingga \\(Self.maxOverviewProviders) penyedia"; +"Choose up to \\(count) providers" = "Pilih hingga \\(count) penyedia"; +"Choose what to show in the menu bar (Pace shows usage vs. expected)." = "Pilih apa yang ditampilkan di menu bar (Pace menampilkan penggunaan vs. perkiraan)."; +"Choose which Codex account CodexBar should follow." = "Pilih akun Codex mana yang harus diikuti CodexBar."; +"Choose which Cursor account CodexBar should use." = "Pilih akun Cursor yang harus digunakan CodexBar."; +"Choose which window drives the menu bar percent." = "Pilih jendela mana yang menggerakkan persentase menu bar."; +"Chrome" = "Chrome"; +"Claude CLI not found" = "Claude CLI tidak ditemukan"; +"Claude binary" = "Biner Claude"; +"Claude cookies" = "Cookie Claude"; +"Claude login failed" = "Login Claude gagal"; +"Claude login timed out" = "Login Claude kehabisan waktu"; +"Close" = "Tutup"; +"Code review" = "Tinjauan kode"; +"Codex CLI not found" = "Codex CLI tidak ditemukan"; +"Codex account login already running" = "Login akun Codex sudah berjalan"; +"Codex binary" = "Biner Codex"; +"Codex login failed" = "Login Codex gagal"; +"Codex login timed out" = "Login Codex kehabisan waktu"; +"CodexBar Lifecycle Keepalive" = "CodexBar Lifecycle Keepalive"; +"CodexBar can't show its menu bar icon" = "CodexBar tidak dapat menampilkan ikon menu bar"; +"CodexBar could not read managed account storage. " = "CodexBar tidak dapat membaca penyimpanan akun terkelola. "; +"Configure…" = "Konfigurasi…"; +"Connected" = "Terhubung"; +"Controls how much detail is logged." = "Mengontrol seberapa banyak detail yang dicatat."; +"Cookie header" = "Header Cookie"; +"Cookie source" = "Sumber cookie"; +"Cookie: ..." = "Cookie: ..."; +"Cookie: \\u{2026}\\\n\\\nor paste a cURL capture from the Abacus AI dashboard" = "Cookie: \\u{2026}\\\n\\\natau tempel tangkapan cURL dari dasbor Abacus AI"; +"Cookie: \\u{2026}\\\n\\\nor paste the __Secure-next-auth.session-token value" = "Cookie: \\u{2026}\\\n\\\natau tempel nilai __Secure-next-auth.session-token"; +"Cookie: \\u{2026}\\\n\\\nor paste the kimi-auth token value" = "Cookie: \\u{2026}\\\n\\\natau tempel nilai token kimi-auth"; +"Cookie: …" = "Cookie: …"; +"CopilotDeviceFlow" = "CopilotDeviceFlow"; +"Cost" = "Biaya"; +"Could not add Codex account" = "Tidak dapat menambahkan akun Codex"; +"Could not open Terminal for Gemini" = "Tidak dapat membuka Terminal untuk Gemini"; +"Could not start claude /login" = "Tidak dapat memulai claude /login"; +"Could not start codex login" = "Tidak dapat memulai codex login"; +"Could not switch system account" = "Tidak dapat beralih akun sistem"; +"Credits" = "Kredit"; +"Individual credits" = "Kredit individual"; +"Workspace" = "Workspace"; +"Credits history" = "Riwayat kredit"; +"Cursor login failed" = "Login Cursor gagal"; +"Custom" = "Kustom"; +"Custom Path" = "Path Kustom"; +"Daily Routines" = "Rutinitas Harian"; +"Debug" = "Debug"; +"Default" = "Bawaan"; +"Disable Keychain access" = "Nonaktifkan akses Keychain"; +"Disabled" = "Nonaktif"; +"Dismiss" = "Tutup"; +"Disconnected" = "Terputus"; +"Display" = "Tampilan"; +"Display mode" = "Mode tampilan"; +"Display reset times as absolute clock values instead of countdowns." = "Tampilkan waktu reset sebagai jam absolut alih-alih hitung mundur."; +"Done" = "Selesai"; +"Effective PATH" = "PATH Efektif"; +"Email" = "Email"; +"Enable Merge Icons to configure Overview tab providers." = "Aktifkan Gabung Ikon untuk mengonfigurasi penyedia tab Ikhtisar."; +"Enable file logging" = "Aktifkan pencatatan file"; +"Enabled" = "Aktif"; +"Error" = "Error"; +"Error simulation" = "Simulasi error"; +"Expose troubleshooting tools in the Debug tab." = "Tampilkan alat pemecahan masalah di tab Debug."; +"Failed" = "Gagal"; +"False" = "Salah"; +"Fetch strategy attempts" = "Percobaan strategi pengambilan"; +"Fetching" = "Mengambil"; +"Field" = "Bidang"; +"Field subtitle" = "Subjudul bidang"; +"Finish the current managed account change before switching the system account." = "Selesaikan perubahan akun terkelola saat ini sebelum beralih akun sistem."; +"Force animation on next refresh" = "Paksa animasi pada penyegaran berikutnya"; +"Gateway region" = "Wilayah gateway"; +"Gemini CLI not found" = "Gemini CLI tidak ditemukan"; +"Gemini/Antigravity, surfacing incidents in the icon and menu." = "Gemini/Antigravity, menampilkan insiden di ikon dan menu."; +"General" = "Umum"; +"GitHub" = "GitHub"; +"GitHub Copilot Login" = "Login GitHub Copilot"; +"GitHub Login" = "Login GitHub"; +"Hide details" = "Sembunyikan detail"; +"Hide personal information" = "Sembunyikan informasi pribadi"; +"Historical tracking" = "Pelacakan riwayat"; +"How often CodexBar polls providers in the background." = "Seberapa sering CodexBar memeriksa penyedia di latar belakang."; +"Inactive" = "Tidak aktif"; +"Install CLI" = "Pasang CLI"; +"Install the Claude CLI (npm i -g @anthropic-ai/claude-code) and try again." = "Pasang Claude CLI (npm i -g @anthropic-ai/claude-code) dan coba lagi."; +"Install the Codex CLI (npm i -g @openai/codex) and try again." = "Pasang Codex CLI (npm i -g @openai/codex) dan coba lagi."; +"Install the Gemini CLI (npm i -g @google/gemini-cli) and try again." = "Pasang Gemini CLI (npm i -g @google/gemini-cli) dan coba lagi."; +"Install a JetBrains IDE with AI Assistant enabled, then refresh CodexBar." = "Pasang JetBrains IDE dengan AI Assistant aktif, lalu segarkan CodexBar."; +"JetBrains AI is ready" = "JetBrains AI siap"; +"JetBrains IDE" = "JetBrains IDE"; +"Keep CLI sessions alive" = "Pertahankan sesi CLI"; +"Keyboard shortcut" = "Pintasan keyboard"; +"Keychain access" = "Akses Keychain"; +"Keychain prompt policy" = "Kebijakan permintaan Keychain"; +"Last \\(name) fetch failed:" = "Pengambilan \\(name) terakhir gagal:"; +"Last \\(self.store.metadata(for: self.provider).displayName) fetch failed:" = "Pengambilan \\(self.store.metadata(for: self.provider).displayName) terakhir gagal:"; +"Last attempt" = "Percobaan terakhir"; +"Link" = "Tautan"; +"Loading animations" = "Animasi pemuatan"; +"Loading…" = "Memuat…"; +"Local" = "Lokal"; +"Logging" = "Pencatatan"; +"Login failed" = "Login gagal"; +"Login shell PATH (startup capture)" = "PATH shell login (tangkapan startup)"; +"Login timed out" = "Login kehabisan waktu"; +"MCP details" = "Detail MCP"; +"Managed Codex accounts unavailable" = "Akun Codex terkelola tidak tersedia"; +"Managed account storage is unreadable. Live account access is still available, " = "Penyimpanan akun terkelola tidak dapat dibaca. Akses akun langsung masih tersedia, "; +"Manual" = "Manual"; +"May your tokens never run out—keep agent limits in view." = "Semoga token Anda tidak pernah habis—pantau batas agen Anda."; +"Menu bar" = "Menu bar"; +"Menu bar auto-shows the provider closest to its rate limit." = "Menu bar otomatis menampilkan penyedia yang paling dekat batas penggunaannya."; +"Menu bar metric" = "Metrik menu bar"; +"Menu bar shows percent" = "Menu bar tampilkan persen"; +"Menu content" = "Konten menu"; +"Merge Icons" = "Gabung Ikon"; +"Never prompt" = "Jangan pernah minta"; +"No" = "Tidak"; +"No Codex accounts detected yet." = "Belum ada akun Codex yang terdeteksi."; +"No JetBrains IDE detected" = "Tidak ada JetBrains IDE terdeteksi"; +"No cost history data." = "Tidak ada data riwayat biaya."; +"No data available" = "Tidak ada data tersedia"; +"No data yet" = "Belum ada data"; +"No enabled providers available for Overview." = "Tidak ada penyedia aktif untuk Ikhtisar."; +"No providers selected" = "Tidak ada penyedia dipilih"; +"No token accounts yet." = "Belum ada akun token."; +"No usage breakdown data." = "Tidak ada data rincian penggunaan."; +"None" = "Tidak ada"; +"Notifications" = "Notifikasi"; +"Notifies when the 5-hour session quota hits 0% and when it becomes " = "Memberi tahu saat kuota sesi 5 jam mencapai 0% dan saat menjadi "; +"OK" = "OK"; +"Obscure email addresses in the menu bar and menu UI." = "Samarkan alamat email di menu bar dan antarmuka menu."; +"Off" = "Nonaktif"; +"Offline" = "Luring"; +"On" = "Aktif"; +"Online" = "Daring"; +"Only on user action" = "Hanya saat tindakan pengguna"; +"Open" = "Buka"; +"Open API Keys" = "Buka Kunci API"; +"Open Amp Settings" = "Buka Pengaturan Amp"; +"Open Antigravity to sign in, then refresh CodexBar." = "Buka Antigravity untuk masuk, lalu segarkan CodexBar."; +"Open Browser" = "Buka Browser"; +"Open Coding Plan" = "Buka Coding Plan"; +"Open Console" = "Buka Konsol"; +"Open Dashboard" = "Buka Dasbor"; +"Open Mistral Admin" = "Buka Mistral Admin"; +"Open Menu Bar Settings" = "Buka Pengaturan Menu Bar"; +"Open Ollama Settings" = "Buka Pengaturan Ollama"; +"Open Terminal" = "Buka Terminal"; +"Open Usage Page" = "Buka Halaman Penggunaan"; +"Open Warp API Key Guide" = "Buka Panduan Kunci API Warp"; +"Open menu" = "Buka menu"; +"Open token file" = "Buka file token"; +"OpenAI cookies" = "Cookie OpenAI"; +"OpenAI web extras" = "Ekstra web OpenAI"; +"Option A" = "Opsi A"; +"Option B" = "Opsi B"; +"Optional override if workspace lookup fails." = "Penggantian opsional jika pencarian workspace gagal."; +"Options" = "Opsi"; +"Override auto-detection with a custom IDE base path" = "Timpa deteksi otomatis dengan path dasar IDE kustom"; +"Overview" = "Ikhtisar"; +"Overview rows always follow provider order." = "Baris ikhtisar selalu mengikuti urutan penyedia."; +"Overview tab providers" = "Penyedia tab ikhtisar"; +"Paste API key…" = "Tempel kunci API…"; +"Paste API token…" = "Tempel token API…"; +"Paste key…" = "Tempel kunci…"; +"Paste sessionKey or OAuth token…" = "Tempel sessionKey atau token OAuth…"; +"Paste the Cookie header from a request to admin.mistral.ai. " = "Tempel header Cookie dari permintaan ke admin.mistral.ai. "; +"Paste token…" = "Tempel token…"; +"Personal" = "Pribadi"; +"Picker" = "Pemilih"; +"Picker subtitle" = "Subjudul pemilih"; +"Placeholder" = "Placeholder"; +"Plan" = "Paket"; +"Plan Usage" = "Penggunaan Paket"; +"Play full-screen confetti when weekly usage resets." = "Mainkan confetti layar penuh saat penggunaan mingguan direset."; +"Polls OpenAI/Claude status pages and Google Workspace for " = "Memeriksa halaman status OpenAI/Claude dan Google Workspace untuk "; +"Prevents any Keychain access while enabled." = "Mencegah semua akses Keychain saat diaktifkan."; +"Primary (API key limit)" = "Utama (batas kunci API)"; +"Primary (\\(label))" = "Utama (\\(label))"; +"Primary (\\(metadata.sessionLabel))" = "Utama (\\(metadata.sessionLabel))"; +"Probe logs" = "Log probe"; +"Progress bars fill as you consume quota (instead of showing remaining)." = "Bilah progres terisi saat Anda menggunakan kuota (alih-alih menampilkan sisa)."; +"Provider" = "Penyedia"; +"Providers" = "Penyedia"; +"Quit CodexBar" = "Keluar CodexBar"; +"Random (default)" = "Acak (bawaan)"; +"Reads local usage logs. Shows today + last 30 days cost in the menu." = "Membaca log penggunaan lokal. Menampilkan hari ini + jendela riwayat yang dipilih di menu."; +"Refresh" = "Segarkan"; +"Refresh cadence" = "Frekuensi penyegaran"; +"Remote" = "Jarak jauh"; +"Remove" = "Hapus"; +"Remove Codex account?" = "Hapus akun Codex?"; +"Remove \\(account.email) from CodexBar? Its managed Codex home will be deleted." = "Hapus \\(account.email) dari CodexBar? Home Codex terkelolanya akan dihapus."; +"Remove \\(email) from CodexBar? Its managed Codex home will be deleted." = "Hapus \\(email) dari CodexBar? Home Codex terkelolanya akan dihapus."; +"Remove selected account" = "Hapus akun terpilih"; +"Replace critter bars with provider branding icons and a percentage." = "Ganti bilah critter dengan ikon merek penyedia dan persentase."; +"Replay selected animation" = "Putar ulang animasi terpilih"; +"Requires authentication via GitHub Device Flow." = "Memerlukan autentikasi via GitHub Device Flow."; +"Resets: \\(reset)" = "Reset: \\(reset)"; +"Rolling five-hour limit" = "Batas bergulir lima jam"; +"Search hourly" = "Cari per jam"; +"Secondary (\\(label))" = "Sekunder (\\(label))"; +"Secondary (\\(metadata.weeklyLabel))" = "Sekunder (\\(metadata.weeklyLabel))"; +"Select a provider" = "Pilih penyedia"; +"Select the IDE to monitor" = "Pilih IDE yang dipantau"; +"Session quota notifications" = "Notifikasi kuota sesi"; +"Session tokens" = "Token sesi"; +"provider_section_connection" = "Koneksi"; +"provider_section_menu_bar" = "Menu bar"; +"Show Codex Credits and Claude Extra usage sections in the menu." = "Tampilkan bagian Kredit Codex dan penggunaan Ekstra Claude di menu."; +"Show Debug Settings" = "Tampilkan Pengaturan Debug"; +"Show all token accounts" = "Tampilkan semua akun token"; +"Show cost summary" = "Tampilkan ringkasan biaya"; +"Show credits + extra usage" = "Tampilkan kredit + penggunaan ekstra"; +"Show details" = "Tampilkan detail"; +"Show most-used provider" = "Tampilkan penyedia paling sering digunakan"; +"Show provider icons in the switcher (otherwise show a weekly progress line)." = "Tampilkan ikon penyedia di pengalih (jika tidak, tampilkan garis progres mingguan)."; +"Show reset time as clock" = "Tampilkan waktu reset sebagai jam"; +"Show usage as used" = "Tampilkan penggunaan sebagai terpakai"; +"Sign in with Claude Code..." = "Masuk dengan Claude Code..."; +"Sign in via button below" = "Masuk via tombol di bawah"; +"Skip teardown between probes (debug-only)." = "Lewati pembersihan antar probe (hanya debug)."; +"Stack token accounts in the menu (otherwise show an account switcher bar)." = "Tumpuk akun token di menu (jika tidak, tampilkan bilah pengalih akun)."; +"Start at Login" = "Mulai saat Login"; +"Status" = "Status"; +"Store Claude sessionKey cookies or OAuth access tokens." = "Simpan cookie sessionKey Claude atau token akses OAuth."; +"Store multiple Abacus AI Cookie headers." = "Simpan beberapa header Cookie Abacus AI."; +"Store multiple Augment Cookie headers." = "Simpan beberapa header Cookie Augment."; +"Store multiple Cursor Cookie headers." = "Simpan beberapa header Cookie Cursor."; +"Store multiple Factory Cookie headers." = "Simpan beberapa header Cookie Factory."; +"Store multiple MiniMax Cookie headers." = "Simpan beberapa header Cookie MiniMax."; +"Store multiple Mistral Cookie headers." = "Simpan beberapa header Cookie Mistral."; +"Store multiple Ollama Cookie headers." = "Simpan beberapa header Cookie Ollama."; +"Store multiple OpenCode Cookie headers." = "Simpan beberapa header Cookie OpenCode."; +"Store multiple OpenCode Go Cookie headers." = "Simpan beberapa header Cookie OpenCode Go."; +"Stored in the CodexBar config file." = "Disimpan dalam file konfigurasi CodexBar."; +"Stored in ~/.codexbar/config.json. " = "Disimpan di ~/.codexbar/config.json. "; +"Stored in ~/.codexbar/config.json. Paste the key from the Synthetic dashboard." = "Disimpan di ~/.codexbar/config.json. Tempel kunci dari dasbor Synthetic."; +"Stored in ~/.codexbar/config.json. Paste your Coding Plan API key from Model Studio." = "Disimpan di ~/.codexbar/config.json. Tempel kunci API Coding Plan Anda dari Model Studio."; +"Stored in ~/.codexbar/config.json. Paste your MiniMax API key." = "Disimpan di ~/.codexbar/config.json. Tempel kunci API MiniMax Anda."; +"Stored in ~/.codexbar/config.json. You can also provide KILO_API_KEY or " = "Disimpan di ~/.codexbar/config.json. Anda juga dapat menyediakan KILO_API_KEY atau "; +"Stores local Codex usage history (8 weeks) to personalize Pace predictions." = "Menyimpan riwayat penggunaan Codex lokal (8 minggu) untuk personalisasi prediksi Pace."; +"Surprise me" = "Kejutkan saya"; +"Switcher shows icons" = "Pengalih tampilkan ikon"; +"Symlink CodexBarCLI to /usr/local/bin and /opt/homebrew/bin as codexbar." = "Symlink CodexBarCLI ke /usr/local/bin dan /opt/homebrew/bin sebagai codexbar."; +"System" = "Sistem"; +"Temporarily shows the loading animation after the next refresh." = "Sementara menampilkan animasi pemuatan setelah penyegaran berikutnya."; +"terminal_app_subtitle" = "Terminal yang digunakan oleh tindakan Buka Terminal"; +"terminal_app_title" = "Terminal Bawaan"; +"Tertiary (\\(label))" = "Tersier (\\(label))"; +"Tertiary (\\(tertiaryTitle))" = "Tersier (\\(tertiaryTitle))"; +"The default Codex account on this Mac." = "Akun Codex bawaan di Mac ini."; +"Toggle" = "Alihkan"; +"Toggle subtitle" = "Subjudul alihkan"; +"Token" = "Token"; +"Trigger the menu bar menu from anywhere." = "Picu menu bar dari mana saja."; +"True" = "Benar"; +"Twitter" = "Twitter"; +"Unsupported" = "Tidak didukung"; +"Update Channel" = "Saluran Pembaruan"; +"Updated" = "Diperbarui"; +"Updates unavailable in this build." = "Pembaruan tidak tersedia di build ini."; +"Usage" = "Penggunaan"; +"Usage breakdown" = "Rincian penggunaan"; +"Usage history (30 days)" = "Riwayat penggunaan"; +"Usage source" = "Sumber penggunaan"; +"Use Account" = "Gunakan Akun"; +"Use BigModel for the China mainland endpoints (open.bigmodel.cn)." = "Gunakan BigModel untuk endpoint Tiongkok daratan (open.bigmodel.cn)."; +"Use a single menu bar icon with a provider switcher." = "Gunakan satu ikon menu bar dengan pengalih penyedia."; +"Use international or China mainland console gateways for quota fetches." = "Gunakan gateway konsol internasional atau Tiongkok daratan untuk pengambilan kuota."; +"Version" = "Versi"; +"Version \\(self.versionString)" = "Versi \\(self.versionString)"; +"Version \\(version)" = "Versi \\(version)"; +"Version \\(versionString)" = "Versi \\(versionString)"; +"Vertex AI Login" = "Login Vertex AI"; +"Wait for the current managed Codex login to finish before adding another account." = "Tunggu login Codex terkelola saat ini selesai sebelum menambahkan akun lain."; +"Waiting for Authentication..." = "Menunggu Autentikasi..."; +"Website" = "Situs Web"; +"Weekly limit confetti" = "Confetti batas mingguan"; +"Weekly token limit" = "Batas token mingguan"; +"Weekly usage" = "Penggunaan mingguan"; +"Weekly usage unavailable for this account." = "Penggunaan mingguan tidak tersedia untuk akun ini."; +"Window: \\(window)" = "Jendela: \\(window)"; +"Write logs to \\(self.fileLogPath) for debugging." = "Tulis log ke \\(self.fileLogPath) untuk debugging."; +"Yes" = "Ya"; +"\\(detail.modelCode): \\(usage)" = "\\(detail.modelCode): \\(usage)"; +"\\(name): \\(truncated)" = "\\(name): \\(truncated)"; +"\\(name): \\(updated) · 30d \\(cost)" = "\\(name): \\(updated) · 30 hari \\(cost)"; +"\\(name): fetching…\\(elapsed)" = "\\(name): mengambil…\\(elapsed)"; +"\\(name): last attempt \\(when)" = "\\(name): percobaan terakhir \\(when)"; +"\\(name): no data yet" = "\\(name): belum ada data"; +"\\(name): unsupported" = "\\(name): tidak didukung"; +"all browsers" = "semua browser"; +"available again." = "tersedia kembali."; +"built_format" = "Dibangun %@"; +"copilot_complete_in_browser" = "Selesaikan login di browser Anda."; +"copilot_device_code" = "Kode perangkat disalin ke clipboard: %1$@\n\nVerifikasi di: %2$@"; +"copilot_device_code_copied" = "Kode perangkat disalin."; +"copilot_verify_at" = "Verifikasi di %@"; +"copilot_waiting_text" = "Selesaikan login di browser Anda.\nJendela ini tertutup otomatis saat login selesai."; +"copilot_window_closes_auto" = "Jendela ini tertutup otomatis saat login selesai."; +"cost_status_error" = "%1$@: %2$@"; +"cost_status_fetching" = "%1$@: mengambil… %2$@"; +"cost_status_last_attempt" = "%1$@: percobaan terakhir %2$@"; +"cost_status_no_data" = "%@: belum ada data"; +"cost_status_snapshot" = "%1$@: %2$@ · %3$@ %4$@"; +"cost_status_unsupported" = "%@: tidak didukung"; +"credits_remaining" = "Kredit: %@"; +"cursor_on_demand" = "On-demand: %@"; +"cursor_on_demand_with_limit" = "On-demand: %1$@ / %2$@"; +"extra_usage_format" = "Penggunaan ekstra: %1$@ / %2$@"; +"jetbrains_detected_generate" = "Terdeteksi: %@. Gunakan asisten AI sekali untuk menghasilkan data kuota, lalu segarkan CodexBar."; +"jetbrains_detected_select" = "Terdeteksi: %@. Pilih IDE pilihan Anda di Pengaturan, lalu segarkan CodexBar."; +"last_fetch_failed_with_provider" = "Pengambilan %@ terakhir gagal:"; +"last_spend" = "Pengeluaran terakhir: %@"; +"mcp_model_usage" = "%1$@: %2$@"; +"mcp_resets" = "Reset: %@"; +"mcp_window" = "Jendela: %@"; +"metric_average" = "Rata-rata (%1$@ + %2$@)"; +"metric_primary" = "Utama (%@)"; +"metric_secondary" = "Sekunder (%@)"; +"metric_tertiary" = "Tersier (%@)"; +"multiple_workspaces_found" = "CodexBar menemukan beberapa workspace untuk %@. Silakan pilih workspace yang akan ditambahkan."; +"ory_session_…=…; csrftoken=…" = "ory_session_…=…; csrftoken=…"; +"overview_choose_providers" = "Pilih hingga %@ penyedia"; +"remove_account_message" = "Hapus %@ dari CodexBar? Home Codex terkelolanya akan dihapus."; +"version_format" = "Versi %@"; +"vertex_ai_login_instructions" = "Untuk melacak penggunaan Vertex AI, autentikasi dengan Google Cloud.\n\n1. Buka Terminal\n2. Jalankan: gcloud auth application-default login\n3. Ikuti petunjuk browser untuk masuk\n4. Atur proyek Anda: gcloud config set project PROJECT_ID\n\nBuka Terminal sekarang?"; +"workspaceID is set but only opencode, opencodego, and deepgram support workspaceID." = "workspaceID diatur tetapi hanya opencode, opencodego, dan deepgram yang mendukung workspaceID."; +"© 2026 Peter Steinberger. MIT License." = "© 2026 Peter Steinberger. Lisensi MIT."; + +/* General Pane */ +"section_system" = "Sistem"; +"section_usage" = "Penggunaan"; +"section_refreshing" = "Penyegaran"; +"section_alerts" = "Peringatan"; +"section_celebrations" = "Perayaan"; +"section_icon" = "Ikon"; +"section_combined_icon" = "Ikon gabungan"; +"section_animation" = "Animasi"; +"section_content" = "Konten"; +"section_agent_sessions" = "Sesi agen"; +"language_title" = "Bahasa"; +"language_subtitle" = "Ubah bahasa tampilan. Memerlukan restart aplikasi agar berlaku penuh."; +"currency_title" = "Mata uang pilihan"; +"currency_subtitle" = "Mata uang untuk estimasi biaya dan pengeluaran. Menggunakan kurs yang diperbarui setiap hari."; +"currency_auto" = "Otomatis (ikuti penyedia / USD)"; +"language_system" = "Sistem"; +"language_english" = "English"; +"language_spanish" = "Español"; +"language_catalan" = "Català"; +"language_chinese_simplified" = "简体中文"; +"language_chinese_traditional" = "繁體中文"; +"language_portuguese_brazilian" = "Português (Brasil)"; +"language_dutch" = "Nederlands"; +"language_german" = "Deutsch"; +"language_swedish" = "Svenska"; +"language_french" = "Français"; +"language_ukrainian" = "Українська"; +"language_russian" = "Русский"; +"language_japanese" = "日本語"; +"language_korean" = "한국어"; +"language_turkish" = "Türkçe"; +"language_vietnamese" = "Tiếng Việt"; +"language_italian" = "Italiano"; +"language_indonesian" = "Bahasa Indonesia"; +"language_polish" = "Bahasa Polandia"; +"start_at_login_title" = "Mulai saat Login"; +"start_at_login_subtitle" = "Otomatis membuka CodexBar saat Anda menyalakan Mac."; +"show_cost_summary_subtitle" = "Membaca log penggunaan lokal. Menampilkan hari ini + jendela riwayat yang dipilih di menu."; +"cost_summary_style_title" = "Gaya tampilan"; +"cost_summary_style_inline" = "Hanya sebaris"; +"cost_summary_style_submenu" = "Hanya submenu"; +"cost_summary_style_both" = "Keduanya"; +"cost_summary_style_inline_help" = "Menampilkan ringkasan biaya langsung di menu utama."; +"cost_summary_style_submenu_help" = "Menampilkan submenu Biaya terperinci sebagai gantinya."; +"cost_summary_style_both_help" = "Menampilkan ringkasan menu utama dan submenu Biaya terperinci."; +"cost_history_window_title" = "Jendela riwayat"; +"cost_history_window_help" = "Menentukan berapa hari log penggunaan lokal yang ditampilkan di menu."; +"cost_history_days_title" = "Jendela riwayat: %d hari"; +"cost_comparison_periods_title" = "Tampilkan periode perbandingan yang lebih singkat"; +"cost_comparison_periods_subtitle" = "Tambahkan total 7, 30, dan 90 hari jika termasuk dalam rentang riwayat yang dipilih. Total ini menggunakan kembali pemindaian lokal yang sama."; +"cost_auto_refresh_info" = "Penyegaran otomatis: interval global (minimum 5 menit) · Batas waktu: 10 menit"; +"refresh_interval_title" = "Interval penyegaran"; +"manual_refresh_hint" = "Penyegaran otomatis nonaktif; gunakan perintah Segarkan di menu."; +"refresh_on_open_title" = "Segarkan saat menu dibuka"; +"refresh_on_open_subtitle" = "Ambil penggunaan terbaru untuk setiap penyedia setiap kali Anda membuka menu."; +"check_provider_status_title" = "Periksa status penyedia"; +"check_provider_status_subtitle" = "Memeriksa halaman status OpenAI/Claude dan Google Workspace untuk Gemini/Antigravity, menampilkan insiden di ikon dan menu."; +"session_quota_notifications_subtitle" = "Memberi tahu saat kuota sesi 5 jam mencapai 0% dan saat tersedia kembali."; +"quota_depleted_title" = "Kuota habis & tersedia kembali"; +"quota_warning_notifications_subtitle" = "Memperingatkan saat sisa kuota sesi atau mingguan melewati ambang batas yang dikonfigurasi."; +"threshold_warnings_title" = "Peringatan ambang batas"; +"quota_warnings_title" = "Peringatan kuota"; +"quota_warning_session" = "sesi"; +"quota_warning_session_capitalized" = "Sesi"; +"quota_warning_weekly" = "mingguan"; +"quota_warning_weekly_capitalized" = "Mingguan"; +"quota_warning_notification_title" = "%1$@ kuota %2$@ rendah"; +"quota_warning_notification_body" = "%1$@ tersisa. Mencapai ambang batas peringatan %3$@ %2$d%% Anda."; +"quota_warning_notification_body_with_account" = "Akun %1$@. %2$@ tersisa. Mencapai ambang batas peringatan %4$@ %3$d%% Anda."; +"predictive_pace_warnings_title" = "Peringatan prediktif laju pemakaian"; +"predictive_pace_warnings_subtitle" = "Memperingatkan untuk Codex dan Claude saat laju sesi atau mingguan dapat menghabiskan kuota sebelum reset."; +"confetti_on_reset_title" = "Konfeti saat reset"; +"confetti_on_reset_subtitle" = "Mainkan konfeti layar penuh saat penggunaan direset."; +"confetti_option_off" = "Mati"; +"confetti_option_session" = "Reset sesi"; +"confetti_option_weekly" = "Reset mingguan"; +"confetti_option_both" = "Keduanya"; +"predictive_pace_warning_notification_title" = "%1$@ peringatan laju %2$@"; +"predictive_pace_warning_notification_body" = "Dengan laju saat ini, kuota ini dapat habis dalam %1$@, sebelum direset."; +"predictive_pace_warning_notification_body_with_account" = "Akun %1$@. Dengan laju saat ini, kuota ini dapat habis dalam %2$@, sebelum direset."; +"session_depleted_notification_title" = "Sesi %@ habis"; +"session_depleted_notification_body" = "0% tersisa. Akan memberi tahu saat tersedia kembali."; +"session_restored_notification_title" = "Sesi %@ pulih"; +"session_restored_notification_body" = "Kuota sesi tersedia kembali."; +"quota_warning_warn_at" = "Peringatkan pada"; +"quota_warning_global_threshold_subtitle" = "Persentase sisa untuk jendela sesi dan mingguan kecuali penyedia menimpanya."; +"quota_warning_sound" = "Mainkan suara notifikasi"; +"quota_warning_onscreen_alert" = "Tampilkan peringatan teks di layar"; +"quota_warning_provider_inherits" = "Menggunakan pengaturan peringatan kuota global kecuali jendela dikustomisasi di sini."; +"quota_warning_provider_disabled" = "Notifikasi peringatan kuota dan penanda bilah penggunaan dinonaktifkan. Aktifkan salah satunya untuk mengedit pengaturan yang tersimpan ini."; +"quota_warning_provider_markers_only" = "Notifikasi peringatan kuota dinonaktifkan secara global. Pengaturan ini tetap mengontrol penanda bilah penggunaan."; +"quota_warning_global" = "Global"; +"quota_warning_customize_thresholds" = "Kustomisasi ambang batas %@"; +"quota_warning_enable_warnings" = "Aktifkan peringatan %@"; +"quota_warning_window_warn_at" = "Peringatan %@ pada"; +"quota_warning_off" = "Mati"; +"quota_warning_inherited" = "Diwariskan: %@"; +"quota_warning_depleted_only" = "hanya habis"; +"quota_warning_upper" = "Lebih tinggi"; +"quota_warning_lower" = "Bawah"; +"quota_warning_warning" = "Peringatan"; +"quota_warning_critical" = "Kritis"; +"apply" = "Terapkan"; +"quit_app" = "Keluar CodexBar"; + +/* Tab titles */ +"tab_general" = "Umum"; +"tab_providers" = "Penyedia"; +"tab_notifications" = "Notifikasi"; +"tab_menu_bar" = "Menu bar"; +"tab_menu" = "Menu"; +"tab_advanced" = "Lanjutan"; +"tab_hooks" = "Hook"; + +/* Hooks Pane */ +"hooks_enable_title" = "Aktifkan hook"; +"hooks_enable_subtitle" = "Jalankan perintah eksternal saat terjadi peristiwa kuota atau penyedia."; +"hooks_trust_warning" = "Hook dapat menjalankan perintah lokal di Mac Anda. Hanya konfigurasikan perintah yang Anda percayai."; +"hooks_rules_header" = "Aturan"; +"hooks_empty" = "Belum ada hook yang dikonfigurasi."; +"hooks_add_rule" = "Tambah aturan"; +"hooks_delete_rule" = "Hapus aturan"; +"hooks_rule_enabled" = "Aktif"; +"hooks_event" = "Peristiwa"; +"hooks_provider" = "Penyedia"; +"hooks_any_provider" = "Penyedia apa pun"; +"hooks_threshold" = "Picu saat penggunaan ≥"; +"hooks_threshold_placeholder" = "90"; +"hooks_executable_placeholder" = "/usr/local/bin/my-command"; +"hooks_arguments_placeholder" = "Argumen"; +"hooks_argument_placeholder" = "Argumen"; +"hooks_add_argument" = "Tambah argumen"; +"hooks_delete_argument" = "Hapus argumen"; +"tab_about" = "Tentang"; +"tab_debug" = "Debug"; + +/* Providers Pane */ +"select_a_provider" = "Pilih penyedia"; +"cancel" = "Batal"; +"last_fetch_failed" = "pengambilan terakhir gagal"; +"usage_not_fetched_yet" = "penggunaan belum diambil"; +"managed_account_storage_unreadable" = "Penyimpanan akun terkelola tidak dapat dibaca. Akses akun langsung masih tersedia, tetapi tindakan tambah, autentikasi ulang, dan hapus akun terkelola dinonaktifkan hingga penyimpanan dapat dipulihkan."; +"remove_codex_account_title" = "Hapus akun Codex?"; +"remove" = "Hapus"; +"managed_login_already_running" = "Login Codex terkelola sudah berjalan. Tunggu hingga selesai sebelum menambahkan atau mengautentikasi ulang akun lain."; +"managed_login_failed" = "Login Codex terkelola tidak selesai. Verifikasi bahwa `codex --version` berfungsi di Terminal. Jika macOS memblokir atau memindahkan `codex` ke Tempat Sampah, hapus instalasi duplikat yang kedaluwarsa, jalankan `npm install -g --include=optional @openai/codex@latest`, lalu coba lagi."; +"codex_login_output" = "Output login codex:"; +"managed_login_missing_email" = "Login Codex selesai, tetapi email akun tidak tersedia. Coba lagi setelah mengonfirmasi akun sudah sepenuhnya masuk."; +"login_success_notification_title" = "Login %@ berhasil"; +"login_success_notification_body" = "Anda dapat kembali ke aplikasi; autentikasi selesai."; +"workspace_selection_cancelled" = "CodexBar menemukan beberapa workspace, tetapi tidak ada workspace yang dipilih."; +"unsafe_managed_home" = "CodexBar menolak memodifikasi path home terkelola yang tidak terduga: %@"; +"menu_bar_metric_title" = "Metrik menu bar"; +"menu_bar_metric_subtitle" = "Pilih jendela mana yang menggerakkan persentase menu bar."; +"menu_bar_metric_subtitle_deepseek" = "Menampilkan saldo DeepSeek di menu bar."; +"menu_bar_metric_subtitle_moonshot" = "Menampilkan saldo API Moonshot / Kimi di menu bar."; +"menu_bar_metric_subtitle_mistral" = "Menampilkan pengeluaran API Mistral bulan ini di menu bar."; +"automatic" = "Otomatis"; +"primary_api_key_limit" = "Utama (batas kunci API)"; + +/* Display Pane */ +"menu_bar_style_title" = "Gaya menu bar"; +"menu_bar_style_subtitle" = "Cara item menu bar digambar."; +"menu_bar_inactive_display_contrast_title" = "Tingkatkan visibilitas di layar tidak aktif"; +"menu_bar_usage_colors_title" = "Penggunaan berwarna"; +"menu_bar_usage_colors_subtitle" = "Mewarnai ikon bilah menu dari hijau ke merah seiring meningkatnya penggunaan."; +"menu_bar_inactive_display_contrast_subtitle" = "Gunakan rendering kontras tinggi agar ikon dan metrik tetap terbaca di layar lain."; +"menu_bar_style_critters" = "Critter"; +"menu_bar_style_bars" = "Bilah meter"; +"menu_bar_style_icon_percent" = "Ikon & persentase"; +"switcher_rows_title" = "Baris pengalih"; +"switcher_rows_icons" = "Ikon penyedia"; +"switcher_rows_progress" = "Progres mingguan"; +"usage_bars_fill_title" = "Pengisian bilah penggunaan"; +"usage_bars_fill_remaining" = "Berdasarkan sisa kuota"; +"usage_bars_fill_used" = "Berdasarkan kuota terpakai"; +"reset_times_title" = "Waktu reset"; +"reset_times_countdown" = "Hitung mundur"; +"reset_times_clock" = "Waktu absolut"; +"cost_summary_title" = "Ringkasan biaya"; +"cost_summary_off" = "Mati"; +"merge_icons_title" = "Gabung Ikon"; +"merge_icons_subtitle" = "Gunakan satu ikon menu bar dengan pengalih penyedia."; +"show_most_used_provider_title" = "Tampilkan penyedia paling sering digunakan"; +"show_most_used_provider_subtitle" = "Menu bar otomatis menampilkan penyedia yang paling dekat batas penggunaannya."; +"display_mode_title" = "Mode tampilan"; +"display_mode_subtitle" = "Pilih apa yang ditampilkan di menu bar (Pace menampilkan penggunaan vs. perkiraan)."; +"show_quota_warning_markers_title" = "Tampilkan penanda peringatan kuota"; +"show_quota_warning_markers_subtitle" = "Gambar tanda centang ambang batas pada bilah penggunaan saat peringatan kuota dikonfigurasi."; +"weekly_progress_work_days_title" = "Hari kerja progres mingguan"; +"weekly_progress_work_days_subtitle" = "Atur hari kerja untuk penanda bilah penggunaan mingguan dan perhitungan pace."; +"show_provider_changelog_links_title" = "Tampilkan tautan changelog penyedia"; +"show_provider_changelog_links_subtitle" = "Menambahkan tautan catatan rilis untuk penyedia berbasis CLI yang didukung ke menu."; +"show_credits_extra_usage_title" = "Tampilkan kredit + penggunaan ekstra"; +"show_credits_extra_usage_subtitle" = "Tampilkan bagian Kredit Codex dan penggunaan Ekstra Claude di menu."; +"multi_account_layout_title" = "Tata letak multi-akun"; +"multi_account_layout_subtitle" = "Pilih pengalihan akun tersegmentasi atau kartu akun bertumpuk."; +"multi_account_layout_segmented" = "Tersegmentasi"; +"multi_account_layout_stacked" = "Bertumpuk"; +"overview_tab_providers_title" = "Penyedia tab ikhtisar"; +"configure" = "Konfigurasi…"; +"overview_enable_merge_icons_hint" = "Aktifkan Gabung Ikon untuk mengonfigurasi penyedia tab Ikhtisar."; +"overview_no_providers_hint" = "Tidak ada penyedia aktif untuk Ikhtisar."; +"overview_rows_follow_order" = "Baris ikhtisar selalu mengikuti urutan penyedia."; +"overview_no_providers_selected" = "Tidak ada penyedia dipilih"; +"agent_sessions_title" = "Sesi agen"; +"agent_sessions_subtitle" = "Tampilkan sesi Codex dan Claude Code lokal serta yang ditemukan melalui SSH di menu."; +"agent_sessions_hosts_title" = "Host SSH tambahan"; +"agent_sessions_footer" = "Mac di tailnet Anda ditemukan secara otomatis. Sesi lokal disegarkan setiap 30 detik; host jarak jauh setiap 60 detik dan saat menu dibuka."; +"agent_session_labels_title" = "Label sesi"; +"agent_session_labels_subtitle" = "Pilih cara penamaan sesi agen."; +"agent_session_label_project" = "Proyek"; +"agent_session_label_descriptive" = "Deskriptif"; +"agent_session_label_descriptive_and_project" = "Deskriptif + proyek"; +"agent_session_unknown_project" = "Proyek tidak dikenal"; + +/* Advanced Pane */ +"section_keyboard_shortcut" = "Pintasan keyboard"; +"open_menu_shortcut_title" = "Buka menu"; +"open_menu_shortcut_subtitle" = "Picu menu bar dari mana saja."; +"install_cli" = "Pasang CLI"; +"install_cli_subtitle" = "Symlink CodexBarCLI ke /usr/local/bin dan /opt/homebrew/bin sebagai codexbar."; +"cli_not_found" = "CodexBarCLI tidak ditemukan di bundel aplikasi."; +"no_writable_bin_dirs" = "Tidak ada direktori bin yang dapat ditulis."; +"show_debug_settings_title" = "Tampilkan Pengaturan Debug"; +"show_debug_settings_subtitle" = "Tampilkan alat pemecahan masalah di tab Debug."; +"surprise_me_title" = "Kejutkan saya"; +"surprise_me_subtitle" = "Centang jika Anda suka agen bersenang-senang di atas sana."; +"hide_personal_info_title" = "Sembunyikan informasi pribadi"; +"hide_personal_info_subtitle" = "Samarkan alamat email di menu bar dan antarmuka menu."; +"show_provider_storage_usage_title" = "Tampilkan penggunaan penyimpanan penyedia"; +"show_provider_storage_usage_subtitle" = "Tampilkan penggunaan disk lokal di menu. Memindai path milik penyedia di latar belakang."; +"section_keychain_access" = "Akses Keychain"; +"keychain_access_caption" = "Nonaktifkan semua pembacaan dan penulisan Keychain. Gunakan ini jika macOS terus meminta 'Chrome/Brave/Edge Safe Storage' meskipun sudah mengklik Always Allow. Impor cookie browser tidak tersedia saat diaktifkan; tempel header Cookie secara manual di Penyedia. OAuth Claude/Codex via CLI masih berfungsi."; +"disable_keychain_access_title" = "Nonaktifkan akses Keychain"; +"disable_keychain_access_subtitle" = "Mencegah semua akses Keychain saat diaktifkan."; + +/* About Pane */ +"about_tagline" = "Semoga token Anda tidak pernah habis—pantau batas agen Anda."; +"link_github" = "GitHub"; +"link_website" = "Situs Web"; +"link_twitter" = "Twitter"; +"link_email" = "Email"; +"check_updates_auto" = "Periksa pembaruan secara otomatis"; +"update_channel" = "Saluran Pembaruan"; +"check_for_updates" = "Periksa Pembaruan…"; +"updates_unavailable" = "Pembaruan tidak tersedia di build ini."; +"copyright" = "© 2026 Peter Steinberger. Lisensi MIT."; + +/* Debug Pane */ +"section_logging" = "Pencatatan"; +"enable_file_logging" = "Aktifkan pencatatan file"; +"enable_file_logging_subtitle" = "Tulis log ke %@ untuk debugging."; +"verbosity_title" = "Verbositas"; +"verbosity_subtitle" = "Mengontrol seberapa banyak detail yang dicatat."; +"open_log_file" = "Buka file log"; +"force_animation_next_refresh" = "Paksa animasi pada penyegaran berikutnya"; +"force_animation_next_refresh_subtitle" = "Sementara menampilkan animasi pemuatan setelah penyegaran berikutnya."; +"section_loading_animations" = "Animasi pemuatan"; +"loading_animations_caption" = "Pilih pola dan putar ulang di menu bar. \"Acak\" mempertahankan perilaku yang ada."; +"animation_random_default" = "Acak (bawaan)"; +"replay_selected_animation" = "Putar ulang animasi terpilih"; +"blink_now" = "Kedipkan sekarang"; +"section_probe_logs" = "Log probe"; +"probe_logs_caption" = "Ambil output probe terbaru untuk debugging; Salin menyimpan teks lengkap."; +"fetch_log" = "Ambil log"; +"copy" = "Salin"; +"save_to_file" = "Simpan ke file"; +"load_parse_dump" = "Muat parse dump"; +"rerun_provider_autodetect" = "Jalankan ulang deteksi otomatis penyedia"; +"loading" = "Memuat…"; +"no_log_yet_fetch" = "Belum ada log. Ambil untuk memuat."; +"section_fetch_strategy" = "Percobaan strategi pengambilan"; +"fetch_strategy_caption" = "Keputusan dan error pipeline pengambilan terakhir untuk penyedia."; +"section_openai_cookies" = "Cookie OpenAI"; +"openai_cookies_caption" = "Log impor cookie + scrape WebKit dari percobaan cookie OpenAI terakhir."; +"no_log_yet" = "Belum ada log. Perbarui cookie OpenAI di Penyedia → Codex untuk menjalankan impor."; +"section_caches" = "Cache"; +"caches_caption" = "Hapus hasil pemindaian biaya yang di-cache atau cache cookie browser."; +"clear_cookie_cache" = "Hapus cache cookie"; +"clear_cost_cache" = "Hapus cache biaya"; +"section_notifications" = "Notifikasi"; +"notifications_caption" = "Picu notifikasi uji untuk jendela sesi 5 jam (habis/pulih)."; +"post_depleted" = "Kirim habis"; +"post_restored" = "Kirim pulih"; +"section_cli_sessions" = "Sesi CLI"; +"cli_sessions_caption" = "Pertahankan sesi CLI Codex/Claude setelah probe. Bawaan keluar setelah data diambil."; +"keep_cli_sessions_alive" = "Pertahankan sesi CLI"; +"keep_cli_sessions_alive_subtitle" = "Lewati pembersihan antar probe (hanya debug)."; +"reset_cli_sessions" = "Reset sesi CLI"; +"section_error_simulation" = "Simulasi error"; +"error_simulation_caption" = "Suntikkan pesan error palsu ke kartu menu untuk pengujian tata letak."; +"set_menu_error" = "Atur error menu"; +"clear_menu_error" = "Hapus error menu"; +"set_cost_error" = "Atur error biaya"; +"clear_cost_error" = "Hapus error biaya"; +"section_cli_paths" = "Path CLI"; +"cli_paths_caption" = "Biner Codex yang terselesaikan dan lapisan PATH; tangkapan PATH shell login startup (batas waktu singkat)."; +"codex_binary" = "Biner Codex"; +"claude_binary" = "Biner Claude"; +"effective_path" = "PATH Efektif"; +"unavailable" = "Tidak tersedia"; +"login_shell_path" = "PATH shell login (tangkapan startup)"; +"cleared" = "Dihapus."; +"no_fetch_attempts" = "Belum ada percobaan pengambilan."; +"macOS Tahoe can block menu bar apps in System Settings → Menu Bar → Allow in the Menu Bar. CodexBar is running, but macOS may be hiding its icon. Open Menu Bar settings and turn CodexBar on." = "macOS Tahoe dapat memblokir aplikasi menu bar di Pengaturan Sistem → Menu Bar → Izinkan di Menu Bar. CodexBar berjalan, tetapi macOS mungkin menyembunyikan ikonnya. Buka pengaturan Menu Bar dan aktifkan CodexBar."; + +/* Metric preferences */ +"metric_pref_automatic" = "Otomatis"; +"metric_pref_primary" = "Utama"; +"metric_pref_secondary" = "Sekunder"; +"metric_pref_tertiary" = "Tersier"; +"metric_pref_extra_usage" = "Penggunaan ekstra"; +"metric_pref_average" = "Rata-rata"; +"metric_mistral_payg" = "Pay-as-you-go"; +"metric_mistral_monthly_plan" = "Monthly Plan"; + +/* Display modes */ +"display_mode_percent" = "Persen"; +"display_mode_pace" = "Pace"; +"display_mode_both" = "Keduanya"; +"display_mode_reset_time" = "Waktu reset"; +"display_mode_percent_desc" = "Tampilkan persentase sisa/terpakai (mis. 45%)"; +"display_mode_pace_desc" = "Tampilkan indikator pace (mis. +5%)"; +"display_mode_both_desc" = "Tampilkan persentase dan pace (mis. 45% · +5%)"; +"display_mode_reset_time_desc" = "Tampilkan waktu reset untuk metrik yang dipilih (mis. ↻ 3:56 PM)"; +"menu_bar_reset_when_exhausted_title" = "Tampilkan waktu reset saat kuota habis"; +"menu_bar_reset_when_exhausted_subtitle" = "Saat tersisa 0%, tampilkan waktu hingga reset alih-alih persentase"; + +/* Provider status */ +"status_operational" = "Operasional"; +"status_degraded" = "Performa menurun"; +"status_partial_outage" = "Gangguan sebagian"; +"status_major_outage" = "Gangguan besar"; +"status_critical_issue" = "Masalah kritis"; +"status_maintenance" = "Pemeliharaan"; +"status_unknown" = "Status tidak diketahui"; + +/* Refresh frequency */ +"refresh_manual" = "Manual"; +"refresh_1min" = "1 mnt"; +"refresh_2min" = "2 mnt"; +"refresh_5min" = "5 mnt"; +"refresh_15min" = "15 mnt"; +"refresh_30min" = "30 mnt"; +"refresh_adaptive" = "Adaptif"; +"refresh_adaptive_agent_aware" = "Adaptif (peka agen)"; +"adaptive_activity_consent_title" = "Izinkan penyegaran yang peka terhadap aktivitas?"; +"adaptive_activity_consent_message" = "Mode Adaptif yang peka terhadap agen dapat memeriksa daftar proses lokal yang sedang berjalan, termasuk baris perintah, untuk mengenali Codex dan Claude, lalu membaca metadata sesi yang dikenal setiap 30 detik saat Anda menulis kode. Saat Agent Sessions dinonaktifkan, CodexBar hanya menggunakan waktu aktivitas terbaru di memori serta membuang jalur dan identitas sesi. Data ini tidak dikirim ke mana pun, dan penemuan jarak jauh serta SSH tetap nonaktif. Jika Anda menolak, CodexBar kembali ke mode Adaptif biasa tanpa pemindaian aktivitas lokal."; +"adaptive_activity_consent_allow" = "Izinkan Aktivitas Lokal"; +"adaptive_activity_consent_decline" = "Gunakan Adaptif Biasa"; + +/* Additional keys */ +"not_found" = "Tidak ditemukan"; + +/* Cost estimation */ +"cost_estimate_hint" = "Diperkirakan dari log lokal · mungkin berbeda dari tagihan Anda"; +"codex_api_estimate_hint" = "Diperkirakan dari penggunaan token · bukan tagihan langganan"; +"cost_data_explanation" = "Biaya dapat dilaporkan oleh penyedia atau diperkirakan dari penggunaan token dengan harga API publik. Estimasi bukan biaya langganan."; +"No JetBrains IDE with AI Assistant detected. Install a JetBrains IDE and enable AI Assistant." = "Tidak ada JetBrains IDE dengan AI Assistant terdeteksi. Pasang JetBrains IDE dan aktifkan AI Assistant."; +"OpenRouter API token not configured. Set OPENROUTER_API_KEY environment variable or configure in Settings." = "Token API OpenRouter belum dikonfigurasi. Atur variabel lingkungan OPENROUTER_API_KEY atau konfigurasi di Pengaturan."; +"z.ai API token not found. Set apiKey in ~/.codexbar/config.json or Z_AI_API_KEY." = "Token API z.ai tidak ditemukan. Atur apiKey di ~/.codexbar/config.json atau Z_AI_API_KEY."; +"Missing DeepSeek API key." = "Kunci API DeepSeek tidak ada."; +"%@ is unavailable in the current environment." = "%@ tidak tersedia di lingkungan saat ini."; +"All Systems Operational" = "Semua Sistem Operasional"; +"Last 30 days" = "30 hari terakhir"; +"Last 30 days:" = "30 hari terakhir:"; +"This month" = "Bulan ini"; +"Store multiple OpenAI API keys." = "Simpan beberapa kunci API OpenAI."; +"Admin API key" = "Kunci API Admin"; +"Open billing" = "Buka tagihan"; +"Google accounts" = "Akun Google"; +"Store multiple Antigravity Google OAuth accounts for quick switching." = "Simpan beberapa akun Google OAuth Antigravity untuk beralih cepat."; +"Add Google Account" = "Tambah Akun Google"; +"Open Token Plan" = "Buka Token Plan"; +"Text Generation" = "Pembuatan Teks"; +"Text to Speech" = "Teks ke Suara"; +"Music Generation" = "Pembuatan Musik"; +"Image Generation" = "Pembuatan Gambar"; +"No local data found" = "Tidak ada data lokal ditemukan"; +"Credits unavailable; keep Codex running to refresh." = "Kredit tidak tersedia; biarkan Codex berjalan untuk menyegarkan."; +"No available fetch strategy for minimax." = "Tidak ada strategi pengambilan tersedia untuk minimax."; +"No Cursor session found. Please log in to cursor.com in Safari, Chrome, Microsoft Edge, Brave, Arc, Dia, ChatGPT Atlas, Chromium, Helium, Vivaldi, Yandex Browser, Firefox, Zen, Colibri, Sidekick, Opera, Opera GX, or Edge Canary. If you use Safari, grant CodexBar Full Disk Access in System Settings ▸ Privacy & Security. You can also sign in to Cursor from the CodexBar menu (Add / switch account)." = "Sesi Cursor tidak ditemukan. Silakan masuk ke cursor.com di Safari, Chrome, Microsoft Edge, Brave, Arc, Dia, ChatGPT Atlas, Chromium, Helium, Vivaldi, Yandex Browser, Firefox, Zen, Colibri, Sidekick, Opera, Opera GX, atau Edge Canary. Jika Anda menggunakan Safari, berikan CodexBar Akses Disk Penuh di Pengaturan Sistem ▸ Privasi & Keamanan. Anda juga dapat masuk ke Cursor dari menu CodexBar (Tambah / beralih akun)."; +"No OpenCode session cookies found in browsers." = "Cookie sesi OpenCode tidak ditemukan di browser."; +"No available fetch strategy for %@." = "Tidak ada strategi pengambilan tersedia untuk %@."; +"Today" = "Hari ini"; +"Today tokens" = "Token hari ini"; +"30d cost" = "Biaya 30 hari"; +"%@ cost" = "Biaya %@"; +"30d tokens" = "Token 30 hari"; +"Latest tokens" = "Token terbaru"; +"Top model" = "Model teratas"; +"Storage" = "Penyimpanan"; +"Add Account..." = "Tambah Akun..."; +"Usage Dashboard" = "Dasbor Penggunaan"; +"Status Page" = "Halaman Status"; +"Open Status Page" = "Buka Halaman Status"; +"Settings..." = "Pengaturan..."; +"About CodexBar" = "Tentang CodexBar"; +"Quit" = "Keluar"; +"Last %d day" = "%d hari terakhir"; +"Last %d days" = "%d hari terakhir"; +"%@ tokens" = "%@ token"; +"Latest billing day" = "Hari tagihan terbaru"; +"Latest billing day (%@)" = "Hari tagihan terbaru (%@)"; +"%@ left" = "%@ tersisa"; +"Resets %@" = "Reset %@"; +"Resets in %@" = "Reset dalam %@"; +"Resets now" = "Reset sekarang"; +"reset_tomorrow_format" = "besok, %@"; +"Lasts until reset" = "Bertahan hingga reset"; +"1.5× headroom" = "ruang 1,5×"; +"Updated %@" = "Diperbarui %@"; +"Updated relative %@" = "Diperbarui %@"; +"Updated absolute %@" = "Diperbarui %@"; +"Updated %@h ago" = "Diperbarui %@ jam lalu"; +"Updated %@m ago" = "Diperbarui %@ menit lalu"; +"Updated just now" = "Baru saja diperbarui"; +"Projected empty in %@" = "Diproyeksikan habis dalam %@"; +"Runs out in %@" = "Habis dalam %@"; +"Pace: %@" = "Pace: %@"; +"Pace: %@ · %@" = "Pace: %@ · %@"; +"%@ · %@" = "%@ · %@"; +"≈ %d%% run-out risk" = "≈ %d%% risiko habis"; +"%d%% in deficit" = "%d%% defisit"; +"%d%% in reserve" = "%d%% cadangan"; +"usage_percent_suffix_left" = "tersisa"; +"usage_percent_suffix_used" = "terpakai"; +"Store multiple DeepSeek API keys." = "Simpan beberapa kunci API DeepSeek."; +"This week" = "Minggu ini"; +"Week" = "Minggu"; +"Month" = "Bulan"; +"Models" = "Model"; +"24h tokens" = "Token 24j"; +"Latest hour" = "Jam terbaru"; +"Peak hour" = "Jam puncak"; +"Top method" = "Metode teratas"; +"30d cash" = "Kas 30 hari"; +"30d billing history from MiniMax web session" = "Riwayat tagihan 30 hari dari sesi web MiniMax"; +"AWS Cost Explorer billing can lag." = "Tagihan AWS Cost Explorer dapat tertunda."; +"Rate limit: %d / %@" = "Batas rate: %d / %@"; +"Key remaining" = "Sisa kunci"; +"No limit set for the API key" = "Tidak ada batas yang ditetapkan untuk kunci API"; +"API key limit unavailable right now" = "Batas kunci API tidak tersedia saat ini"; +"This month: %@ tokens" = "Bulan ini: %@ token"; +"No utilization data yet." = "Belum ada data pemanfaatan."; +"No %@ utilization data yet." = "Belum ada data pemanfaatan %@."; +"%@: %@%% used" = "%@: %@%% terpakai"; +"%dd" = "%d hari"; +"today" = "hari ini"; +"just now" = "baru saja"; +"On pace" = "Sesuai pace"; +"Runs out now" = "Habis sekarang"; +"Projected empty now" = "Diproyeksikan habis sekarang"; +"Switch Account..." = "Beralih Akun..."; +"Update ready, restart now?" = "Pembaruan siap, mulai ulang sekarang?"; +"Daily" = "Harian"; +"Hourly Tokens" = "Token Per Jam"; +"No data" = "Tidak ada data"; +"No usage breakdown data available." = "Tidak ada data rincian penggunaan tersedia."; + +"Today: %@ · %@ tokens" = "Hari ini: %@ · %@ token"; +"Today: %@" = "Hari ini: %@"; +"Today: %@ tokens" = "Hari ini: %@ token"; +"Last 30 days: %@ · %@ tokens" = "30 hari terakhir: %@ · %@ token"; +"Last 30 days: %@" = "30 hari terakhir: %@"; +"Est. total (30d): %@" = "Perkiraan total (30 hari): %@"; +"Est. total (%@): %@" = "Perkiraan total (%@): %@"; +"Hover a bar for details" = "Arahkan kursor ke bilah untuk detail"; +"%@: %@ · %@ tokens" = "%@: %@ · %@ token"; +"%@: %@" = "%@: %@"; +"No providers selected for Overview." = "Tidak ada penyedia dipilih untuk Ikhtisar."; +"No overview data available." = "Tidak ada data ikhtisar tersedia."; +"Auto uses the local IDE API first, then Google OAuth when the IDE is closed." = "Otomatis menggunakan API IDE lokal terlebih dahulu, lalu Google OAuth saat IDE ditutup."; +"Login with Google" = "Masuk dengan Google"; + +/* Popup panels */ +"No usage configured." = "Tidak ada penggunaan dikonfigurasi."; +"Quota" = "Kuota"; +"Daily quota" = "Kuota harian"; +"Total" = "Total"; +"tokens" = "token"; +"requests" = "permintaan"; +"Latest" = "Terbaru"; +"Monthly" = "Bulanan"; +"Sonnet" = "Sonnet"; +"Overages" = "Kelebihan"; +"Activity" = "Aktivitas"; +"Copied" = "Disalin"; +"Copy error" = "Salin error"; +"Copy path" = "Salin path"; +"Extra usage spent" = "Penggunaan ekstra dibelanjakan"; +"Credits remaining" = "Kredit tersisa"; +"Using CLI fallback" = "Menggunakan fallback CLI"; +"Balance updates in near-real time (up to 5 min lag)" = "Saldo diperbarui hampir real-time (hingga 5 menit keterlambatan)"; +"Daily billing data finalizes at 07:00 UTC" = "Data tagihan harian final pada 07:00 UTC"; +"%@ of %@ credits left" = "%@ dari %@ kredit tersisa"; +"%@ of %@ bonus credits left" = "%@ dari %@ kredit bonus tersisa"; +"%@ / %@ (%@ remaining)" = "%@ / %@ (%@ tersisa)"; +"%@/%@ left" = "%@/%@ tersisa"; +"Gemini Flash" = "Gemini Flash"; +"Regenerates %@" = "Regenerasi %@"; +"used after next regen" = "terpakai setelah regenerasi berikutnya"; +"after next regen" = "setelah regenerasi berikutnya"; +"Near full" = "Hampir penuh"; +"Full in ~1 regen" = "Penuh dalam ~1 regenerasi"; +"Full in ~%.0f regens" = "Penuh dalam ~%.0f regenerasi"; +"Overage usage" = "Penggunaan kelebihan"; +"Overage cost" = "Biaya kelebihan"; +"credits" = "kredit"; +"Zen balance" = "Saldo Zen"; +"API spend" = "Pengeluaran API"; +"Extra usage" = "Penggunaan ekstra"; +"Quota usage" = "Penggunaan kuota"; +"Your spend" = "Pengeluaran Anda"; +"%.0f%% used" = "%.0f%% terpakai"; +"Usage history (today)" = "Riwayat penggunaan (hari ini)"; +"Usage history (%d days)" = "Riwayat penggunaan (%d hari)"; +"%d percent remaining" = "%d persen tersisa"; +"Unknown" = "Tidak diketahui"; +"stale data" = "data kedaluwarsa"; +"No credits history data." = "Tidak ada data riwayat kredit."; +"No credits history data available." = "Tidak ada data riwayat kredit tersedia."; +"Credits history chart" = "Grafik riwayat kredit"; +"%d days of credits data" = "%d hari data kredit"; +"Usage breakdown chart" = "Grafik rincian penggunaan"; +"%d days of usage data across %d services" = "%d hari data penggunaan di %d layanan"; +"Cost history chart" = "Grafik riwayat biaya"; +"%d days of cost data" = "%d hari data biaya"; +"Plan utilization chart" = "Grafik pemanfaatan paket"; +"%d utilization samples" = "%d sampel pemanfaatan"; +"Hourly Usage" = "Penggunaan Per Jam"; +"Usage remaining" = "Penggunaan tersisa"; +"Usage used" = "Penggunaan terpakai"; +"API key verified. Cloud quotas need browser cookies. Sign in to Ollama." = "Kunci API terverifikasi. Kuota Cloud memerlukan cookie browser. Masuk ke Ollama."; +"Last 30 days: %@ tokens" = "30 hari terakhir: %@ token"; +"7d spend" = "Pengeluaran 7 hari"; +"30d spend" = "Pengeluaran 30 hari"; +"Cache read" = "Baca cache"; +"Claude Admin API 30 day spend trend" = "Tren pengeluaran 30 hari API Admin Claude"; +"OpenRouter API key spend trend" = "Tren pengeluaran kunci API OpenRouter"; +"z.ai hourly token trend" = "Tren token per jam z.ai"; +"MiniMax 30 day token usage trend" = "Tren penggunaan token 30 hari MiniMax"; +"Today cash" = "Kas hari ini"; +"DeepSeek 30 day token usage trend" = "Tren penggunaan token 30 hari DeepSeek"; +"DeepSeek this month token usage trend" = "Tren penggunaan token DeepSeek bulan ini"; +"Chrome profile" = "Profil Chrome"; +"Choose which signed-in DeepSeek Platform session supplies detailed usage." = "Pilih sesi DeepSeek Platform yang telah masuk untuk menyediakan rincian penggunaan."; +"Detailed usage unavailable." = "Rincian penggunaan tidak tersedia."; +"Sign in to DeepSeek Platform in Chrome for detailed usage." = "Masuk ke DeepSeek Platform di Chrome untuk melihat rincian penggunaan."; +"Select a DeepSeek Chrome profile in Settings." = "Pilih profil Chrome DeepSeek di Pengaturan."; +"Select profile…" = "Pilih profil…"; +"cache-hit input" = "input cache-hit"; +"cache-miss input" = "input cache-miss"; +"output" = "output"; +"Requests" = "Permintaan"; +"Reported by OpenAI Admin API organization usage." = "Dilaporkan oleh penggunaan organisasi API Admin OpenAI."; +"Reported by Mistral billing usage." = "Dilaporkan oleh penggunaan tagihan Mistral."; +"Google OAuth" = "Google OAuth"; +"Add accounts via GitHub OAuth Device Flow on the selected host." = "Tambah akun via GitHub OAuth Device Flow pada host yang dipilih."; +"Stores each signed-in Google account for quick Antigravity switching. Uses Antigravity.app OAuth when available, or ANTIGRAVITY_OAUTH_CLIENT_ID and ANTIGRAVITY_OAUTH_CLIENT_SECRET as an override." = "Menyimpan setiap akun Google yang masuk untuk beralih Antigravity cepat. Menggunakan OAuth Antigravity.app jika tersedia, atau ANTIGRAVITY_OAUTH_CLIENT_ID dan ANTIGRAVITY_OAUTH_CLIENT_SECRET sebagai pengganti."; +"Manual cleanup: past sessions" = "Pembersihan manual: sesi sebelumnya"; +"Clearing removes past resume, continue, and rewind history." = "Menghapus riwayat lanjutkan, teruskan, dan mundur sebelumnya."; +"Manual cleanup: file checkpoints" = "Pembersihan manual: checkpoint file"; +"Clearing removes checkpoint restore data for previous edits." = "Menghapus data pemulihan checkpoint untuk pengeditan sebelumnya."; +"Manual cleanup: saved plans" = "Pembersihan manual: rencana tersimpan"; +"Clearing removes old plan-mode files." = "Menghapus file mode rencana lama."; +"Manual cleanup: debug logs" = "Pembersihan manual: log debug"; +"Clearing removes past debug logs." = "Menghapus log debug sebelumnya."; +"Manual cleanup: attachment cache" = "Pembersihan manual: cache lampiran"; +"Clearing removes cached large pastes or attached images." = "Menghapus tempel besar atau gambar terlampir yang di-cache."; +"Manual cleanup: session metadata" = "Pembersihan manual: metadata sesi"; +"Clearing removes per-session environment metadata." = "Menghapus metadata lingkungan per-sesi."; +"Manual cleanup: shell snapshots" = "Pembersihan manual: snapshot shell"; +"Clearing removes leftover runtime shell snapshot files." = "Menghapus file snapshot shell runtime yang tersisa."; +"Manual cleanup: legacy todos" = "Pembersihan manual: todo lama"; +"Clearing removes legacy per-session task lists." = "Menghapus daftar tugas per-sesi lama."; +"Manual cleanup: sessions" = "Pembersihan manual: sesi"; +"Clearing removes past Codex session history." = "Menghapus riwayat sesi Codex sebelumnya."; +"Manual cleanup: archived sessions" = "Pembersihan manual: sesi terarsip"; +"Clearing removes archived Codex session history." = "Menghapus riwayat sesi Codex terarsip."; +"Manual cleanup: cache" = "Pembersihan manual: cache"; +"Clearing removes provider-owned cached data." = "Menghapus data cache milik penyedia."; +"Manual cleanup: logs" = "Pembersihan manual: log"; +"Clearing removes local diagnostic logs." = "Menghapus log diagnostik lokal."; +"Manual cleanup: file history" = "Pembersihan manual: riwayat file"; +"Clearing removes local edit checkpoint history." = "Menghapus riwayat checkpoint edit lokal."; +"Manual cleanup: temporary data" = "Pembersihan manual: data sementara"; +"Clearing removes local temporary provider data." = "Menghapus data sementara penyedia lokal."; +"Total: %@" = "Total: %@"; +"%d more items" = "%d item lagi"; +"Other (%d items)" = "Lainnya (%d item)"; +"Expand" = "Perluas"; +"Collapse" = "Ciutkan"; +"Cleanup ideas" = "Ide pembersihan"; +"%d unreadable item(s) skipped" = "%d item tidak terbaca dilewati"; + +"API key limit" = "Batas kunci API"; +"Auth" = "Auth"; +"Auto" = "Otomatis"; +"Disabled — no recent data" = "Nonaktif — tidak ada data terbaru"; +"Limits not available" = "Batas tidak tersedia"; +"No usage yet" = "Belum ada penggunaan"; +"Not fetched yet" = "Belum diambil"; +"Refreshing" = "Menyegarkan"; +"Session" = "Sesi"; +"Source" = "Sumber"; +"State" = "Status"; +"Unavailable" = "Tidak tersedia"; +"Weekly" = "Mingguan"; +"not detected" = "tidak terdeteksi"; +"Estimated from local Codex logs for the selected account." = "Diperkirakan dari log Codex lokal untuk akun yang dipilih."; +"minimax_usage_amount_format" = "Penggunaan: %@ / %@"; +"minimax_used_percent_format" = "Terpakai %@"; +"minimax_service_text_generation" = "Pembuatan Teks"; +"minimax_service_text_to_speech" = "Teks ke Suara"; +"minimax_service_music_generation" = "Pembuatan Musik"; +"minimax_service_image_generation" = "Pembuatan Gambar"; +"minimax_service_lyrics_generation" = "Pembuatan Lirik"; +"minimax_service_coding_plan_vlm" = "VLM Coding Plan"; +"minimax_service_coding_plan_search" = "Pencarian Coding Plan"; + +/* Additional provider settings and alerts */ +"%@ is waiting for permission" = "%@ menunggu izin"; +"%@ requests" = "%@ permintaan"; +"%@: %@ credits" = "%@: %@ kredit"; +"30d requests" = "Permintaan 30 hari"; +"4 days" = "4 hari"; +"5 days" = "5 hari"; +"7 days" = "7 hari"; +"API key verifies Ollama Cloud access; cookies still expose quota limits." = "Kunci API memverifikasi akses Ollama Cloud; cookie masih menampilkan batas kuota."; +"AWS access key ID. Can also be set with AWS_ACCESS_KEY_ID." = "AWS access key ID. Dapat juga diatur dengan AWS_ACCESS_KEY_ID."; +"AWS region. Can also be set with AWS_REGION." = "Wilayah AWS. Dapat juga diatur dengan AWS_REGION."; +"AWS secret access key. Can also be set with AWS_SECRET_ACCESS_KEY." = "AWS secret access key. Dapat juga diatur dengan AWS_SECRET_ACCESS_KEY."; +"Access key ID" = "Access key ID"; +"Add Account" = "Tambah Akun"; +"Adding Account…" = "Menambahkan Akun…"; +"Antigravity login failed" = "Login Antigravity gagal"; +"Antigravity login timed out" = "Login Antigravity kehabisan waktu"; +"Auth source" = "Sumber auth"; +"Automatic imports browser cookies from Xiaomi MiMo." = "Otomatis mengimpor cookie browser dari Xiaomi MiMo."; +"Automatic imports Windsurf session data from Chromium browser localStorage." = "Otomatis mengimpor data sesi Windsurf dari localStorage browser Chromium."; +"Automatic imports browser cookies from Bailian." = "Otomatis mengimpor cookie browser dari Bailian."; +"Automatically imports browser cookies." = "Otomatis mengimpor cookie browser."; +"Automatically imports browser session cookies." = "Otomatis mengimpor cookie sesi browser."; +"Azure OpenAI deployment name. AZURE_OPENAI_DEPLOYMENT_NAME is also supported." = "Nama deployment Azure OpenAI. AZURE_OPENAI_DEPLOYMENT_NAME juga didukung."; +"Azure OpenAI key" = "Kunci Azure OpenAI"; +"Azure OpenAI resource endpoint. AZURE_OPENAI_ENDPOINT is also supported." = "Endpoint resource Azure OpenAI. AZURE_OPENAI_ENDPOINT juga didukung."; +"Base URL" = "URL Dasar"; +"Base URL for the LLM-API-Key-Proxy instance." = "URL dasar untuk instance LLM-API-Key-Proxy."; +"Browser cookies" = "Cookie browser"; +"Cap end" = "Akhir batas"; +"Cap start" = "Awal batas"; +"Capacity End" = "Akhir Kapasitas"; +"Capacity Start" = "Awal Kapasitas"; +"Changelog" = "Log perubahan"; +"Choose the Moonshot/Kimi API host for international or China mainland accounts." = "Pilih host API Moonshot/Kimi untuk akun internasional atau Tiongkok daratan."; +"CodexBar can't replace a system account that is signed in with an API key only setup." = "CodexBar tidak dapat mengganti akun sistem yang masuk dengan pengaturan hanya kunci API."; +"CodexBar could not find saved auth for that account. Re-authenticate it and try again." = "CodexBar tidak dapat menemukan auth tersimpan untuk akun tersebut. Autentikasi ulang dan coba lagi."; +"CodexBar could not read managed account storage. Recover the store before adding another account." = "CodexBar tidak dapat membaca penyimpanan akun terkelola. Pulihkan penyimpanan sebelum menambahkan akun lain."; +"CodexBar could not read saved auth for that account. Re-authenticate it and try again." = "CodexBar tidak dapat membaca auth tersimpan untuk akun tersebut. Autentikasi ulang dan coba lagi."; +"CodexBar could not read the current system account on this Mac." = "CodexBar tidak dapat membaca akun sistem saat ini di Mac ini."; +"CodexBar could not replace the live Codex auth on this Mac." = "CodexBar tidak dapat mengganti auth Codex aktif di Mac ini."; +"CodexBar could not safely preserve the current system account before switching." = "CodexBar tidak dapat menyimpan akun sistem saat ini dengan aman sebelum beralih."; +"CodexBar could not save the current system account before switching." = "CodexBar tidak dapat menyimpan akun sistem saat ini sebelum beralih."; +"CodexBar could not update managed account storage." = "CodexBar tidak dapat memperbarui penyimpanan akun terkelola."; +"CodexBar found another managed account that already uses the current system account. Resolve the duplicate account before switching." = "CodexBar menemukan akun terkelola lain yang sudah menggunakan akun sistem saat ini. Selesaikan akun duplikat sebelum beralih."; +"CodexBar will ask macOS Keychain for \U201c%@\U201d so it can decrypt browser cookies and authenticate your account. Click OK to continue." = "CodexBar akan meminta Keychain macOS untuk \U201c%@\U201d agar dapat mendekripsi cookie browser dan mengautentikasi akun Anda. Klik OK untuk melanjutkan."; +"CodexBar will ask macOS Keychain for the Claude Code OAuth token so it can fetch your Claude usage. Click OK to continue." = "CodexBar akan meminta Keychain macOS untuk token OAuth Claude Code agar dapat mengambil penggunaan Claude Anda. Klik OK untuk melanjutkan."; +"CodexBar will ask macOS Keychain for your Amp cookie header so it can fetch usage. Click OK to continue." = "CodexBar akan meminta Keychain macOS untuk header cookie Amp Anda agar dapat mengambil penggunaan. Klik OK untuk melanjutkan."; +"CodexBar will ask macOS Keychain for your Augment cookie header so it can fetch usage. Click OK to continue." = "CodexBar akan meminta Keychain macOS untuk header cookie Augment Anda agar dapat mengambil penggunaan. Klik OK untuk melanjutkan."; +"CodexBar will ask macOS Keychain for your Claude cookie header so it can fetch Claude web usage. Click OK to continue." = "CodexBar akan meminta Keychain macOS untuk header cookie Claude Anda agar dapat mengambil penggunaan web Claude. Klik OK untuk melanjutkan."; +"CodexBar will ask macOS Keychain for your Cursor cookie header so it can fetch usage. Click OK to continue." = "CodexBar akan meminta Keychain macOS untuk header cookie Cursor Anda agar dapat mengambil penggunaan. Klik OK untuk melanjutkan."; +"CodexBar will ask macOS Keychain for your Factory cookie header so it can fetch usage. Click OK to continue." = "CodexBar akan meminta Keychain macOS untuk header cookie Factory Anda agar dapat mengambil penggunaan. Klik OK untuk melanjutkan."; +"CodexBar will ask macOS Keychain for your GitHub Copilot token so it can fetch usage. Click OK to continue." = "CodexBar akan meminta Keychain macOS untuk token GitHub Copilot Anda agar dapat mengambil penggunaan. Klik OK untuk melanjutkan."; +"CodexBar will ask macOS Keychain for your Kimi auth token so it can fetch usage. Click OK to continue." = "CodexBar akan meminta Keychain macOS untuk token auth Kimi Anda agar dapat mengambil penggunaan. Klik OK untuk melanjutkan."; +"CodexBar will ask macOS Keychain for your MiniMax API token so it can fetch usage. Click OK to continue." = "CodexBar akan meminta Keychain macOS untuk token API MiniMax Anda agar dapat mengambil penggunaan. Klik OK untuk melanjutkan."; +"CodexBar will ask macOS Keychain for your MiniMax cookie header so it can fetch usage. Click OK to continue." = "CodexBar akan meminta Keychain macOS untuk header cookie MiniMax Anda agar dapat mengambil penggunaan. Klik OK untuk melanjutkan."; +"CodexBar will ask macOS Keychain for your OpenAI cookie header so it can fetch Codex dashboard extras. Click OK to continue." = "CodexBar akan meminta Keychain macOS untuk header cookie OpenAI Anda agar dapat mengambil ekstra dasbor Codex. Klik OK untuk melanjutkan."; +"CodexBar will ask macOS Keychain for your OpenCode cookie header so it can fetch usage. Click OK to continue." = "CodexBar akan meminta Keychain macOS untuk header cookie OpenCode Anda agar dapat mengambil penggunaan. Klik OK untuk melanjutkan."; +"CodexBar will ask macOS Keychain for your Synthetic API key so it can fetch usage. Click OK to continue." = "CodexBar akan meminta Keychain macOS untuk kunci API Synthetic Anda agar dapat mengambil penggunaan. Klik OK untuk melanjutkan."; +"CodexBar will ask macOS Keychain for your z.ai API token so it can fetch usage. Click OK to continue." = "CodexBar akan meminta Keychain macOS untuk token API z.ai Anda agar dapat mengambil penggunaan. Klik OK untuk melanjutkan."; +"Could not open Cursor login in your browser." = "Tidak dapat membuka login Cursor di browser Anda."; +"Could not open browser for Antigravity" = "Tidak dapat membuka browser untuk Antigravity"; +"Credits used" = "Kredit terpakai"; +"Day" = "Hari"; +"Deployment" = "Deployment"; +"Drag to reorder" = "Seret untuk mengatur ulang"; +"Sort providers alphabetically" = "Urutkan penyedia menurut abjad"; +"Sort providers alphabetically (enabled first)" = "Urutkan penyedia menurut abjad (yang aktif lebih dulu)"; +"Sorted alphabetically (enabled first) — click to use your custom order" = "Diurutkan menurut abjad (yang aktif lebih dulu) — klik untuk memakai urutan khusus"; +"Endpoint" = "Endpoint"; +"Enterprise host" = "Host enterprise"; +"Extra usage balance: %@" = "Saldo penggunaan ekstra: %@"; +"Keychain Access Required" = "Akses Keychain Diperlukan"; +"keychain_prompt_learn_more" = "Pelajari Lebih Lanjut…"; +"keychain_prompt_privacy_note" = "macOS—bukan CodexBar—menangani pemasukan kata sandi masuk Mac. Anda dapat menonaktifkan semua akses Keychain kapan saja di Pengaturan → Lanjutan."; +"Kiro menu bar value" = "Nilai menu bar Kiro"; +"Label" = "Label"; +"No organizations loaded. Click Refresh after setting your API key." = "Tidak ada organisasi dimuat. Klik Segarkan setelah mengatur kunci API Anda."; +"No output captured." = "Tidak ada output tertangkap."; +"No system account" = "Tidak ada akun sistem"; +"Oasis-Token" = "Oasis-Token"; +"Open Augment (Log Out & Back In)" = "Buka Augment (Keluar & Masuk Kembali)"; +"Open Codebuff Dashboard" = "Buka Dasbor Codebuff"; +"Open Command Code Settings" = "Buka Pengaturan Command Code"; +"Open Crof dashboard" = "Buka dasbor Crof"; +"Open Manus" = "Buka Manus"; +"Open MiMo Balance" = "Buka Saldo MiMo"; +"Open Moonshot Console" = "Buka Konsol Moonshot"; +"Open Ollama API Keys" = "Buka Kunci API Ollama"; +"Open StepFun Platform" = "Buka Platform StepFun"; +"Open T3 Chat Settings" = "Buka Pengaturan T3 Chat"; +"Open Volcengine Ark Console" = "Buka Konsol Volcengine Ark"; +"Open legacy provider docs" = "Buka dokumentasi penyedia lama"; +"Open projects" = "Buka proyek"; +"Open this URL manually to continue login:\n\n%@" = "Buka URL ini secara manual untuk melanjutkan login:\n\n%@"; +"Optional organization ID for accounts linked to multiple Anthropic organizations." = "ID organisasi opsional untuk akun yang terhubung ke beberapa organisasi Anthropic."; +"Optional. Applies to the configured Admin API key; selected token accounts do not inherit OPENAI_PROJECT_ID." = "Opsional. Berlaku untuk kunci API Admin yang dikonfigurasi; akun token yang dipilih tidak mewarisi OPENAI_PROJECT_ID."; +"Optional. Enter your GitHub Enterprise host, for example octocorp.ghe.com. Leave blank for github.com." = "Opsional. Masukkan host GitHub Enterprise Anda, misalnya octocorp.ghe.com. Biarkan kosong untuk github.com."; +"Optional. Leave blank to discover and aggregate projects visible to the API key." = "Opsional. Biarkan kosong untuk menemukan dan menggabungkan proyek yang terlihat oleh kunci API."; +"Org ID (optional)" = "ID Org (opsional)"; +"Organizations" = "Organisasi"; +"Organization ID" = "ID Organisasi"; +"Password" = "Kata sandi"; +"%@ authentication is disabled." = "Autentikasi %@ dinonaktifkan."; +"%@ cookies are disabled." = "Cookie %@ dinonaktifkan."; +"%@ web API access is disabled." = "Akses web API %@ dinonaktifkan."; +"Disable %@ dashboard cookie usage." = "Nonaktifkan penggunaan cookie dasbor %@."; +"Keychain access is disabled in Advanced, so browser cookie import is unavailable." = "Akses Keychain dinonaktifkan di Lanjutan, jadi impor cookie browser tidak tersedia."; +"Manually paste an %@ from a browser session." = "Tempel %@ secara manual dari sesi browser."; +"Paste a Cookie header captured from %@." = "Tempel header Cookie yang ditangkap dari %@."; +"Paste a Cookie header from %@." = "Tempel header Cookie dari %@."; +"Paste a Cookie header or cURL capture from %@." = "Tempel header Cookie atau tangkapan cURL dari %@."; +"Paste a Cookie header or full cURL capture from %@." = "Tempel header Cookie atau tangkapan cURL lengkap dari %@."; +"Paste a Cookie or Authorization header from %@." = "Tempel header Cookie atau Authorization dari %@."; +"Paste a full cookie header or the %@ value." = "Tempel header cookie lengkap atau nilai %@."; +"Paste a Cookie header or full cURL capture from T3 Chat settings." = "Tempel header Cookie atau tangkapan cURL lengkap dari pengaturan T3 Chat."; +"Paste the Cookie header from a request to admin.mistral.ai. Must contain an ory_session_* cookie." = "Tempel header Cookie dari permintaan ke admin.mistral.ai. Harus berisi cookie ory_session_*."; +"Paste the Oasis-Token from a logged-in browser session on platform.stepfun.com." = "Tempel Oasis-Token dari sesi browser yang sudah masuk di platform.stepfun.com."; +"Paste the %@ JSON bundle from %@." = "Tempel bundel JSON %@ dari %@."; +"Paste the %@ value or a full Cookie header." = "Tempel nilai %@ atau header Cookie lengkap."; +"Personal account" = "Akun pribadi"; +"Project ID" = "ID Proyek"; +"Re-auth" = "Autentikasi ulang"; +"Re-login at claude.ai" = "Login ulang di claude.ai"; +"Re-authenticating…" = "Mengautentikasi ulang…"; +"Refresh Session" = "Segarkan Sesi"; +"Refresh organizations" = "Segarkan organisasi"; +"Region" = "Wilayah"; +"Reload" = "Muat ulang"; +"Reorder" = "Atur ulang"; +"Secret access key" = "Secret access key"; +"Series" = "Seri"; +"Service" = "Layanan"; +"Show or hide Kiro credits, percent, or both next to the menu bar icon." = "Tampilkan atau sembunyikan kredit Kiro, persen, atau keduanya di sebelah ikon menu bar."; +"Show usage for organizations you belong to. Personal account is always shown." = "Tampilkan penggunaan untuk organisasi yang Anda ikuti. Akun pribadi selalu ditampilkan."; +"Sign in to cursor.com in your browser, then refresh Cursor in CodexBar." = "Masuk ke cursor.com di browser Anda, lalu segarkan Cursor di CodexBar."; +"Simulated error text" = "Teks error simulasi"; +"StepFun platform account (phone number or email)." = "Akun platform StepFun (nomor telepon atau email)."; +"Stored in ~/.codexbar/config.json." = "Disimpan di ~/.codexbar/config.json."; +"Stored in ~/.codexbar/config.json. AZURE_OPENAI_API_KEY is also supported." = "Disimpan di ~/.codexbar/config.json. AZURE_OPENAI_API_KEY juga didukung."; +"Stored in ~/.codexbar/config.json. For the official Kimi API, use Moonshot / Kimi API." = "Disimpan di ~/.codexbar/config.json. Untuk API Kimi resmi, gunakan Moonshot / Kimi API."; +"Stored in ~/.codexbar/config.json. Get your API key from the Volcengine Ark console." = "Disimpan di ~/.codexbar/config.json. Dapatkan kunci API Anda dari konsol Volcengine Ark."; +"Stored in ~/.codexbar/config.json. Get your key from Ollama settings." = "Disimpan di ~/.codexbar/config.json. Dapatkan kunci Anda dari pengaturan Ollama."; +"Stored in ~/.codexbar/config.json. Get your key from console.deepgram.com." = "Disimpan di ~/.codexbar/config.json. Dapatkan kunci Anda dari console.deepgram.com."; +"Stored in ~/.codexbar/config.json. Get your key from elevenlabs.io/app/settings/api-keys." = "Disimpan di ~/.codexbar/config.json. Dapatkan kunci Anda dari elevenlabs.io/app/settings/api-keys."; +"Stored in ~/.codexbar/config.json. Get your key from openrouter.ai/settings/keys and set a key spending limit there to enable API key quota tracking." = "Disimpan di ~/.codexbar/config.json. Dapatkan kunci Anda dari openrouter.ai/settings/keys dan atur batas pengeluaran kunci di sana untuk mengaktifkan pelacakan kuota kunci API."; +"Stored in ~/.codexbar/config.json. In Warp, open Settings > Platform > API Keys, then create one." = "Disimpan di ~/.codexbar/config.json. Di Warp, buka Settings > Platform > API Keys, lalu buat satu."; +"Stored in ~/.codexbar/config.json. Metrics require Groq Enterprise Prometheus access." = "Disimpan di ~/.codexbar/config.json. Metrik memerlukan akses Groq Enterprise Prometheus."; +"Stored in ~/.codexbar/config.json. OPENAI_ADMIN_KEY is preferred; OPENAI_API_KEY still works." = "Disimpan di ~/.codexbar/config.json. OPENAI_ADMIN_KEY lebih diutamakan; OPENAI_API_KEY masih berfungsi."; +"Stored in ~/.codexbar/config.json. Requires an Anthropic Admin API key." = "Disimpan di ~/.codexbar/config.json. Memerlukan kunci API Admin Anthropic."; +"Stored in ~/.codexbar/config.json. Used for /v1/quota-stats." = "Disimpan di ~/.codexbar/config.json. Digunakan untuk /v1/quota-stats."; +"Stored in ~/.codexbar/config.json. You can also provide CODEBUFF_API_KEY or let CodexBar read ~/.config/manicode/credentials.json (created by `codebuff login`)." = "Disimpan di ~/.codexbar/config.json. Anda juga dapat menyediakan CODEBUFF_API_KEY atau biarkan CodexBar membaca ~/.config/manicode/credentials.json (dibuat oleh `codebuff login`)."; +"Stored in ~/.codexbar/config.json. You can also provide CROF_API_KEY." = "Disimpan di ~/.codexbar/config.json. Anda juga dapat menyediakan CROF_API_KEY."; +"Stored in ~/.codexbar/config.json. You can also provide KILO_API_KEY or ~/.local/share/kilo/auth.json (kilo.access)." = "Disimpan di ~/.codexbar/config.json. Anda juga dapat menyediakan KILO_API_KEY atau ~/.local/share/kilo/auth.json (kilo.access)."; +"T3 Chat cookie" = "Cookie T3 Chat"; +"Team mode" = "Mode tim"; +"That account is no longer available in CodexBar. Refresh the account list and try again." = "Akun tersebut tidak lagi tersedia di CodexBar. Segarkan daftar akun dan coba lagi."; +"The browser login did not complete in time. Try Antigravity login again." = "Login browser tidak selesai tepat waktu. Coba login Antigravity lagi."; +"Timed out waiting for Cursor login. %@" = "Kehabisan waktu menunggu login Cursor. %@"; +"Timed out waiting for Cursor login. %@ Last error: %@" = "Kehabisan waktu menunggu login Cursor. %@ Error terakhir: %@"; +"Today requests" = "Permintaan hari ini"; +"Total (30d): %@ credits" = "Total (30 hari): %@ kredit"; +"Username" = "Nama pengguna"; +"Uses username + password to login and obtain an Oasis-Token automatically." = "Menggunakan nama pengguna + kata sandi untuk login dan mendapatkan Oasis-Token secara otomatis."; +"Uses username + password to login and obtain an %@ automatically." = "Menggunakan nama pengguna + kata sandi untuk login dan mendapatkan %@ secara otomatis."; +"Utilization End" = "Akhir Pemanfaatan"; +"Utilization Start" = "Awal Pemanfaatan"; +"Verbosity" = "Verbositas"; +"Windsurf session JSON bundle" = "Bundel JSON sesi Windsurf"; +"Workspace ID" = "ID Workspace"; +"Your StepFun platform password. Used to login and obtain a session token." = "Kata sandi platform StepFun Anda. Digunakan untuk login dan mendapatkan token sesi."; +"claude /login exited with status %d." = "claude /login keluar dengan status %d."; +"codex login exited with status %d." = "codex login keluar dengan status %d."; +"Cookie: …\n\nor paste a cURL capture from the Abacus AI dashboard" = "Cookie: …\n\natau tempel tangkapan cURL dari dasbor Abacus AI"; +"Cookie: …\n\nor paste the __Secure-next-auth.session-token value" = "Cookie: …\n\natau tempel nilai __Secure-next-auth.session-token"; +"Cookie: …\n\nor paste the kimi-auth token value" = "Cookie: …\n\natau tempel nilai token kimi-auth"; +"session_id=...\n\nor paste just the session_id value" = "session_id=...\n\natau tempel hanya nilai session_id"; +"Clear" = "Hapus"; +"No matching providers" = "Tidak ada penyedia cocok"; +"Search providers" = "Cari penyedia"; + +"Request quota: %@ / %@" = "Kuota permintaan: %@ / %@"; +"language_arabic" = "العربية"; +"language_persian" = "فارسی"; +"language_thai" = "ไทย"; +"language_galician" = "Galego"; +"Limit Reset Credits" = "Kredit pengaturan ulang batas"; +"1 available" = "1 tersedia"; +"%d available" = "%d tersedia"; +"Next expires %@" = "Berikutnya kedaluwarsa %@"; +"Expires %@" = "Kedaluwarsa %@"; +"No expiry" = "Tidak ada kedaluwarsa"; +"byte_unit_byte" = "byte"; +"byte_unit_bytes" = "byte"; +"byte_unit_kilobyte" = "kilobyte"; +"byte_unit_kilobytes" = "kilobyte"; +"byte_unit_megabyte" = "megabyte"; +"byte_unit_megabytes" = "megabyte"; +"byte_unit_gigabyte" = "gigabyte"; +"byte_unit_gigabytes" = "gigabyte"; + +/* Settings sidebar redesign */ +"Enable" = "Aktifkan"; +"Disable" = "Nonaktifkan"; +"providers_on_count" = "%d aktif"; +"section_cost_summary" = "Ringkasan biaya"; +"section_command_line" = "Baris perintah"; +"section_privacy" = "Privasi"; +"section_diagnostics" = "Diagnostik"; +"section_updates" = "Pembaruan"; +"section_links" = "Tautan"; +"Show Codex Spark usage" = "Tampilkan penggunaan Codex Spark"; +"Shows Codex Spark quota rows in the menu and provider preview. Requires optional credits and extra usage in Display settings." = "Menampilkan baris kuota Codex Spark di menu dan pratinjau penyedia. Mengharuskan “Tampilkan kredit + penggunaan ekstra” diaktifkan di pengaturan Tampilan."; +"Show Daily Routines usage" = "Tampilkan penggunaan Rutinitas Harian"; +"Shows the Daily Routines quota row in the menu and provider preview. Requires optional credits and extra usage in Display settings." = "Menampilkan baris kuota Rutinitas Harian di menu dan pratinjau penyedia. Mengharuskan “Tampilkan kredit + penggunaan ekstra” diaktifkan di pengaturan Tampilan."; +"Scroll to see more models" = "Gulir untuk melihat model lainnya"; + +/* Shareable usage card */ +"Copy Image" = "Salin Gambar"; +"Copy Stats" = "Salin Statistik"; +"Could not copy image" = "Gambar tidak dapat disalin"; +"Image copied" = "Gambar disalin"; +"Image saved" = "Gambar disimpan"; +"Nothing is uploaded. This image is created on your Mac." = "Tidak ada data yang diunggah. Gambar ini dibuat di Mac Anda."; +"Save..." = "Simpan..."; +"Share AI Usage" = "Bagikan Penggunaan AI"; +"Share Stats…" = "Bagikan Statistik…"; +"Stats copied" = "Statistik disalin"; +"Finish switching to a different Cursor account in your browser, then try again." = "Selesaikan peralihan ke akun Cursor lain di browser Anda, lalu coba lagi."; +"Timed out waiting for Cursor account switch. %@" = "Waktu tunggu untuk beralih akun Cursor habis. %@"; +"Timed out waiting for Cursor account switch. %@ Last error: %@" = "Waktu tunggu untuk beralih akun Cursor habis. %@ Kesalahan terakhir: %@"; + +/* Spend dashboard */ +"tab_usage_spend" = "Penggunaan & Pengeluaran"; +"Usage & Spend" = "Penggunaan & Pengeluaran"; +"Local estimated cost history across supported providers." = "Riwayat perkiraan biaya lokal di seluruh penyedia yang didukung."; +"Time range" = "Rentang waktu"; +"Track costs" = "Lacak biaya"; +"Cost tracking is off" = "Pelacakan biaya dinonaktifkan"; +"Turn on Track costs to build local estimates." = "Aktifkan “Lacak biaya” untuk membuat perkiraan lokal."; +"No local cost history yet" = "Belum ada riwayat biaya lokal"; +"Turn on cost tracking or refresh after using a supported provider." = "Aktifkan pelacakan biaya atau segarkan setelah menggunakan penyedia yang didukung."; +"Refresh failures" = "Kegagalan penyegaran"; +"Native currencies stay separate; Codex account rows exclude Pi session history." = "Mata uang asli tetap dipisahkan; baris akun Codex tidak menyertakan riwayat sesi Pi."; +"Spend unavailable" = "Data pengeluaran tidak tersedia"; +"Model breakdown unavailable" = "Rincian per model tidak tersedia"; +"Local estimated history" = "Riwayat perkiraan lokal"; +"Coverage" = "Cakupan"; +"Estimated spend" = "Perkiraan pengeluaran"; +"Tracked tokens" = "Token yang dilacak"; +"Subscriptions" = "Langganan"; +"By subscription" = "Berdasarkan langganan"; +"No model-level history" = "Tidak ada riwayat tingkat model"; +"Daily estimated spend" = "Perkiraan pengeluaran harian"; +"≈%d full 5h windows of weekly left · %d windows until reset" = "≈%d jendela 5 jam penuh dari kuota mingguan tersisa · %d jendela hingga reset"; +"Weekly cannot run out before reset at this pace" = "Kuota mingguan tidak dapat habis sebelum reset dengan laju ini"; +"Weekly can run out ≈%d windows early" = "Kuota mingguan dapat habis ≈%d jendela lebih awal"; +"Estimated: %@" = "Perkiraan: %@"; +"session_quota_estimate_value_format" = "%1$@ %2$@"; +"session quota" = "kuota sesi"; +"session quotas" = "kuota sesi"; +"Coding Plan" = "Paket Coding"; +"Agent Plan" = "Paket Agen"; +"Team" = "Tim"; + +/* Menu bar layout editor */ +"menu_bar_layout_title" = "Tata letak"; +"menu_bar_layout_footer" = "Seret token untuk mengatur bar menu. Klik token untuk menambahkannya; pilih token yang sudah ditempatkan lalu tekan Delete untuk menghapusnya."; +"menu_bar_layout_group_identity" = "Identitas"; +"menu_bar_layout_group_usage" = "Penggunaan"; +"menu_bar_layout_group_time" = "Waktu"; +"menu_bar_layout_group_money" = "Biaya"; +"menu_bar_layout_group_structure" = "Struktur"; +"menu_bar_layout_scope_all" = "Semua penyedia"; +"menu_bar_layout_scope_help" = "Edit tata letak bawaan atau timpa untuk satu penyedia."; +"menu_bar_layout_use_all" = "Gunakan tata letak semua penyedia"; +"menu_bar_layout_preset" = "Prasetel tata letak"; +"menu_bar_layout_preset_icon_percent" = "Ikon & persentase"; +"menu_bar_layout_preset_icon_only" = "Ikon saja"; +"menu_bar_layout_preset_percent_reset" = "Persentase + reset"; +"menu_bar_layout_preset_compact_stacked" = "Tumpukan ringkas"; +"menu_bar_layout_preset_custom" = "Kustom"; +"menu_bar_layout_live_preview" = "Pratinjau langsung"; +"menu_bar_layout_strip" = "Strip bar menu"; +"menu_bar_layout_remove_line_break" = "Hapus pemisah baris"; +"menu_bar_layout_chip_hint" = "Pilih, seret untuk mengurutkan ulang, atau gunakan tindakan Hapus."; +"menu_bar_layout_palette_hint" = "Klik untuk menambahkan atau seret ke tata letak."; +"menu_bar_layout_empty_line" = "Letakkan token di sini"; +"menu_bar_layout_line" = "Baris %d"; +"menu_bar_layout_drag_remove" = "Seret ke sini untuk menghapus"; +"menu_bar_layout_size" = "Ukuran"; +"menu_bar_layout_size_small" = "Kecil"; +"menu_bar_layout_size_regular" = "Reguler"; +"menu_bar_layout_gap" = "Jarak"; +"menu_bar_layout_gap_tight" = "Rapat"; +"menu_bar_layout_gap_regular" = "Reguler"; +"menu_bar_layout_keyboard_hint" = "Delete menghapus token yang dipilih"; +"menu_bar_layout_sample_account" = "akun"; +"menu_bar_layout_sample_runs_out" = "habis Jum."; +"menu_bar_layout_token_icon" = "Ikon"; +"menu_bar_layout_token_provider" = "Nama penyedia"; +"menu_bar_layout_token_account" = "Akun"; +"menu_bar_layout_token_session" = "Sesi %"; +"menu_bar_layout_token_weekly" = "Mingguan %"; +"menu_bar_layout_token_auto" = "% otomatis"; +"menu_bar_layout_token_bar" = "Bar penggunaan"; +"menu_bar_layout_token_resets_in" = "Reset dalam"; +"menu_bar_layout_token_reset_at" = "Reset pukul"; +"menu_bar_layout_token_runs_out" = "Habis"; +"menu_bar_layout_token_cost_today" = "Biaya hari ini"; +"menu_bar_layout_token_cost_30d" = "Biaya 30 hari"; +"menu_bar_layout_token_space" = "Spasi"; +"menu_bar_layout_token_line_break" = "Pemisah baris"; +"menu_bar_layout_token_separator_accessibility" = "Titik pemisah"; + +/* Menu bar layout accessibility */ +"Icon unavailable" = "Ikon: Tidak tersedia"; +"%@ icon" = "%@: Ikon"; +"Provider name unavailable" = "Nama penyedia: Tidak tersedia"; +"Account unavailable" = "Akun: Tidak tersedia"; +"%@ unavailable" = "%@: Tidak tersedia"; +"%@ %@" = "%@ %@"; +"Usage bar unavailable" = "Bar penggunaan: Tidak tersedia"; +"Usage bar, %d of 3 filled" = "Bar penggunaan: %d/3 terisi"; +"Reset countdown unavailable" = "Reset dalam: Tidak tersedia"; +"Reset time unavailable" = "Reset pukul: Tidak tersedia"; +"Run-out estimate unavailable" = "Habis: Tidak tersedia"; +"Cost today unavailable" = "Biaya hari ini: Tidak tersedia"; +"30-day cost unavailable" = "Biaya 30 hari: Tidak tersedia"; +"Resets" = "Reset"; diff --git a/Sources/CodexBar/Resources/id.lproj/Localizable.stringsdict b/Sources/CodexBar/Resources/id.lproj/Localizable.stringsdict new file mode 100644 index 0000000000..145f76ebfe --- /dev/null +++ b/Sources/CodexBar/Resources/id.lproj/Localizable.stringsdict @@ -0,0 +1,49 @@ + + + + + ≈%d full 5h windows of weekly left · %d windows until reset + + NSStringLocalizedFormatKey + %#@left@ · %#@until@ + left + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + d + one + ≈%d jendela 5 jam penuh dari kuota mingguan tersisa + other + ≈%d jendela 5 jam penuh dari kuota mingguan tersisa + + until + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + d + one + %d jendela hingga reset + other + %d jendela hingga reset + + + Weekly can run out ≈%d windows early + + NSStringLocalizedFormatKey + %#@early@ + early + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + d + one + Kuota mingguan dapat habis ≈%d jendela lebih awal + other + Kuota mingguan dapat habis ≈%d jendela lebih awal + + + + diff --git a/Sources/CodexBar/Resources/it.lproj/Localizable.strings b/Sources/CodexBar/Resources/it.lproj/Localizable.strings new file mode 100644 index 0000000000..92e21df2ad --- /dev/null +++ b/Sources/CodexBar/Resources/it.lproj/Localizable.strings @@ -0,0 +1,1357 @@ +/* Italian localization for CodexBar */ + +"ollama_safari_cookie_access_hint" = "I cookie di Safari richiedono l’accesso completo al disco per CodexBar (Impostazioni di Sistema > Privacy e sicurezza)."; +"ollama_browser_cookie_decryption_denied" = "La decrittografia dei cookie di %@ è stata rifiutata nel Portachiavi; riprova con un aggiornamento manuale."; +"ollama_browser_cookie_decryption_disabled" = "La decrittografia dei cookie di %@ è disabilitata in CodexBar; abilita l’accesso al Portachiavi e aggiorna."; + +" providers" = " provider"; +"(System)" = "(Sistema)"; +"30d" = "30 g"; +"7d" = "7 g"; +"A managed Codex login is already running. Wait for it to finish before adding " = "È già in corso un accesso gestito a Codex. Attendi che termini prima di aggiungere "; +"API key" = "Chiave API"; +"API region" = "Regione API"; +"API token" = "Token API"; +"API tokens" = "Token API"; +"About" = "Informazioni"; +"Account" = "Account"; +"Accounts" = "Account"; +"Accounts subtitle" = "Scegli e gestisci gli account monitorati da CodexBar."; +"Active" = "Attivo"; +"Add" = "Aggiungi"; +"Add Workspace" = "Aggiungi workspace"; +"Advanced" = "Avanzate"; +"All" = "Tutti"; +"Always allow prompts" = "Consenti sempre i prompt"; +"Animation pattern" = "Schema animazione"; +"Antigravity login is managed in the app" = "L'accesso ad Antigravity è gestito nell'app"; +"Applies only to the Security.framework OAuth keychain reader." = "Si applica solo al lettore OAuth del portachiavi di Security.framework."; +"Alternatively, set a custom path in Settings." = "In alternativa, imposta un percorso personalizzato in Impostazioni."; +"Auto falls back to the next source if the preferred one fails." = "In modalità Auto passa alla fonte successiva se quella preferita fallisce."; +"Auto uses API first, then falls back to CLI on auth failures." = "Auto usa prima l'API, poi passa alla CLI se l'autenticazione fallisce."; +"Auto-detect" = "Rilevamento automatico"; +"Auto-refresh is off; use the menu's Refresh command." = "L'aggiornamento automatico è disattivato; usa il comando Aggiorna del menu."; +"Auto-refresh: hourly · Timeout: 10m" = "Aggiornamento automatico: ogni ora · Timeout: 10 min"; +"Automatic" = "Automatico"; +"Automatic imports browser cookies and WorkOS tokens." = "Importa automaticamente i cookie del browser e i token WorkOS."; +"Automatic imports browser cookies and local storage tokens." = "Importa automaticamente i cookie del browser e i token del local storage."; +"Automatic imports browser cookies for dashboard extras." = "Importa automaticamente i cookie del browser per gli extra della dashboard."; +"Automatic imports browser cookies for the web API." = "Importa automaticamente i cookie del browser per la web API."; +"Automatic imports browser cookies from Model Studio/Bailian." = "Importa automaticamente i cookie del browser da Model Studio/Bailian."; +"Automatic imports browser cookies from admin.mistral.ai." = "Importa automaticamente i cookie del browser da admin.mistral.ai."; +"Automatic imports browser cookies from opencode.ai." = "Importa automaticamente i cookie del browser da opencode.ai."; +"Automatic imports browser cookies or stored sessions." = "Importa automaticamente i cookie del browser o le sessioni salvate."; +"Automatic imports browser cookies." = "Importa automaticamente i cookie del browser."; +"Automatically imports browser session cookie." = "Importa automaticamente il cookie di sessione del browser."; +"Automatically opens CodexBar when you start your Mac." = "Apre automaticamente CodexBar quando avvii il Mac."; +"Automation" = "Automazione"; +"Average (\\(label1) + \\(label2))" = "Media (\\(label1) + \\(label2))"; +"Average (\\(metadata.sessionLabel) + \\(metadata.weeklyLabel))" = "Media (\\(metadata.sessionLabel) + \\(metadata.weeklyLabel))"; +"Avoid Keychain prompts" = "Evita i prompt del portachiavi"; +"Balance" = "Saldo"; +"Battery Saver" = "Risparmio batteria"; +"Bordered" = "Con bordo"; +"Build" = "Build"; +"Built \\(buildTimestamp)" = "Build \\(buildTimestamp)"; +"Buy Credits..." = "Acquista crediti..."; +"Buy Credits…" = "Acquista crediti…"; +"CLI paths" = "Percorsi CLI"; +"CLI sessions" = "Sessioni CLI"; +"Caches" = "Cache"; +"Cancel" = "Annulla"; +"Check for Updates…" = "Controlla aggiornamenti…"; +"Check for updates automatically" = "Controlla automaticamente gli aggiornamenti"; +"Check if you like your agents having some fun up there." = "Attivalo se vuoi che i tuoi agenti si divertano un po' lassù."; +"Check provider status" = "Controlla stato provider"; +"Choose a supported browser so CodexBar can read the matching account." = "Scegli un browser supportato affinché CodexBar possa leggere l'account corrispondente."; +"Choose Codex workspace" = "Scegli il workspace Codex"; +"Choose Cursor account" = "Scegli account Cursor"; +"Choose the MiniMax host (global .io or China mainland .com)." = "Scegli l'host MiniMax (.io globale o .com per la Cina continentale)."; +"Choose up to " = "Scegli fino a "; +"Choose up to \\(Self.maxOverviewProviders) providers" = "Scegli fino a \\(Self.maxOverviewProviders) provider"; +"Choose up to \\(count) providers" = "Scegli fino a \\(count) provider"; +"Choose what to show in the menu bar (Pace shows usage vs. expected)." = "Scegli cosa mostrare nella barra menu (Andamento mostra l'uso rispetto al previsto)."; +"Choose which Codex account CodexBar should follow." = "Scegli quale account Codex deve seguire CodexBar."; +"Choose which Cursor account CodexBar should use." = "Scegli quale account Cursor deve usare CodexBar."; +"Choose which window drives the menu bar percent." = "Scegli quale finestra determina la percentuale nella barra menu."; +"Chrome" = "Chrome"; +"Claude CLI not found" = "Claude CLI non trovata"; +"Claude binary" = "Binario Claude"; +"Claude cookies" = "Cookie Claude"; +"Claude login failed" = "Accesso Claude non riuscito"; +"Claude login timed out" = "Timeout accesso Claude"; +"Close" = "Chiudi"; +"Code review" = "Revisione codice"; +"Codex CLI not found" = "Codex CLI non trovata"; +"Codex account login already running" = "Accesso account Codex già in corso"; +"Codex binary" = "Binario Codex"; +"Codex login failed" = "Accesso Codex non riuscito"; +"Codex login timed out" = "Timeout accesso Codex"; +"CodexBar Lifecycle Keepalive" = "Keepalive ciclo di vita CodexBar"; +"CodexBar can't show its menu bar icon" = "CodexBar non può mostrare la sua icona nella barra menu"; +"CodexBar could not read managed account storage. " = "CodexBar non è riuscito a leggere l'archivio degli account gestiti. "; +"Configure…" = "Configura…"; +"Connected" = "Connesso"; +"Controls how much detail is logged." = "Controlla il livello di dettaglio registrato nei log."; +"Cookie header" = "Header Cookie"; +"Cookie source" = "Fonte cookie"; +"Cookie: ..." = "Cookie: ..."; +"Cookie: \\u{2026}\\\n\\\nor paste a cURL capture from the Abacus AI dashboard" = "Cookie: \\u{2026}\\\n\\\noppure incolla una cattura cURL dalla dashboard di Abacus AI"; +"Cookie: \\u{2026}\\\n\\\nor paste the __Secure-next-auth.session-token value" = "Cookie: \\u{2026}\\\n\\\noppure incolla il valore di __Secure-next-auth.session-token"; +"Cookie: \\u{2026}\\\n\\\nor paste the kimi-auth token value" = "Cookie: \\u{2026}\\\n\\\noppure incolla il valore del token kimi-auth"; +"Cookie: …" = "Cookie: …"; +"CopilotDeviceFlow" = "Copilot Device Flow"; +"Cost" = "Costo"; +"Could not add Codex account" = "Impossibile aggiungere l'account Codex"; +"Could not open Terminal for Gemini" = "Impossibile aprire Terminale per Gemini"; +"Could not start claude /login" = "Impossibile avviare claude /login"; +"Could not start codex login" = "Impossibile avviare codex login"; +"Could not switch system account" = "Impossibile cambiare account di sistema"; +"Credits" = "Crediti"; +"Individual credits" = "Crediti individuali"; +"Workspace" = "Spazio di lavoro"; +"Credits history" = "Storico crediti"; +"Cursor login failed" = "Accesso Cursor non riuscito"; +"Custom" = "Personalizzato"; +"Custom Path" = "Percorso personalizzato"; +"Daily Routines" = "Routine quotidiane"; +"Debug" = "Diagnostica"; +"Default" = "Predefinito"; +"Disable Keychain access" = "Disabilita accesso al portachiavi"; +"Disabled" = "Disattivato"; +"Dismiss" = "Chiudi"; +"Disconnected" = "Disconnesso"; +"Display" = "Aspetto"; +"Display mode" = "Modalità di visualizzazione"; +"Display reset times as absolute clock values instead of countdowns." = "Mostra gli orari di reset come orari assoluti invece che come conto alla rovescia."; +"Done" = "Fine"; +"Effective PATH" = "PATH effettivo"; +"Email" = "Email"; +"Enable Merge Icons to configure Overview tab providers." = "Abilita Unisci icone per configurare i provider della scheda Panoramica."; +"Enable file logging" = "Abilita logging su file"; +"Enabled" = "Attivo"; +"Error" = "Errore"; +"Error simulation" = "Simulazione errore"; +"Expose troubleshooting tools in the Debug tab." = "Espone gli strumenti di risoluzione problemi nella scheda Debug."; +"Failed" = "Fallito"; +"False" = "Falso"; +"Fetch strategy attempts" = "Tentativi strategia di recupero"; +"Fetching" = "Recupero in corso"; +"Field" = "Campo"; +"Field subtitle" = "Sottotitolo campo"; +"Finish the current managed account change before switching the system account." = "Completa l'attuale cambio di account gestito prima di cambiare l'account di sistema."; +"Force animation on next refresh" = "Forza animazione al prossimo aggiornamento"; +"Gateway region" = "Regione gateway"; +"Gemini CLI not found" = "Gemini CLI non trovata"; +"Gemini/Antigravity, surfacing incidents in the icon and menu." = "Gemini/Antigravity, con incidenti mostrati nell'icona e nel menu."; +"General" = "Generale"; +"GitHub" = "GitHub"; +"GitHub Copilot Login" = "Accesso GitHub Copilot"; +"GitHub Login" = "Accesso GitHub"; +"Hide details" = "Nascondi dettagli"; +"Hide personal information" = "Nascondi informazioni personali"; +"Historical tracking" = "Tracciamento storico"; +"How often CodexBar polls providers in the background." = "Quanto spesso CodexBar interroga i provider in background."; +"Inactive" = "Inattivo"; +"Install CLI" = "Installa CLI"; +"Install the Claude CLI (npm i -g @anthropic-ai/claude-code) and try again." = "Installa la Claude CLI (npm i -g @anthropic-ai/claude-code) e riprova."; +"Install the Codex CLI (npm i -g @openai/codex) and try again." = "Installa la Codex CLI (npm i -g @openai/codex) e riprova."; +"Install the Gemini CLI (npm i -g @google/gemini-cli) and try again." = "Installa la Gemini CLI (npm i -g @google/gemini-cli) e riprova."; +"Install a JetBrains IDE with AI Assistant enabled, then refresh CodexBar." = "Installa un IDE JetBrains con AI Assistant abilitato, poi aggiorna CodexBar."; +"JetBrains AI is ready" = "JetBrains AI è pronto"; +"JetBrains IDE" = "IDE JetBrains"; +"Keep CLI sessions alive" = "Mantieni attive le sessioni CLI"; +"Keyboard shortcut" = "Scorciatoia da tastiera"; +"Keychain access" = "Accesso al portachiavi"; +"Keychain prompt policy" = "Politica prompt portachiavi"; +"Last \\(name) fetch failed:" = "Ultimo recupero di \\(name) non riuscito:"; +"Last \\(self.store.metadata(for: self.provider).displayName) fetch failed:" = "Ultimo recupero di \\(self.store.metadata(for: self.provider).displayName) non riuscito:"; +"Last attempt" = "Ultimo tentativo"; +"Link" = "Collegamento"; +"Loading animations" = "Animazioni di caricamento"; +"Loading…" = "Caricamento…"; +"Local" = "Locale"; +"Logging" = "Log"; +"Login failed" = "Accesso non riuscito"; +"Login shell PATH (startup capture)" = "PATH della shell di login (cattura all'avvio)"; +"Login timed out" = "Timeout accesso"; +"MCP details" = "Dettagli MCP"; +"Managed Codex accounts unavailable" = "Account Codex gestiti non disponibili"; +"Managed account storage is unreadable. Live account access is still available, " = "L'archivio degli account gestiti non è leggibile. L'accesso agli account live è ancora disponibile, "; +"Manual" = "Manuale"; +"May your tokens never run out—keep agent limits in view." = "Che i tuoi token non finiscano mai: tieni d'occhio i limiti degli agenti."; +"Menu bar" = "Barra menu"; +"Menu bar auto-shows the provider closest to its rate limit." = "La barra menu mostra automaticamente il provider più vicino al proprio limite."; +"Menu bar metric" = "Metrica barra menu"; +"Menu bar shows percent" = "La barra menu mostra la percentuale"; +"Menu content" = "Contenuto menu"; +"Merge Icons" = "Unisci icone"; +"Never prompt" = "Non chiedere mai"; +"No" = "No"; +"No Codex accounts detected yet." = "Nessun account Codex rilevato finora."; +"No JetBrains IDE detected" = "Nessun IDE JetBrains rilevato"; +"No cost history data." = "Nessun dato storico costi."; +"No data available" = "Nessun dato disponibile"; +"No data yet" = "Nessun dato"; +"No enabled providers available for Overview." = "Nessun provider abilitato disponibile per la Panoramica."; +"No providers selected" = "Nessun provider selezionato"; +"No token accounts yet." = "Nessun account token al momento."; +"No usage breakdown data." = "Nessun dato di dettaglio utilizzo."; +"None" = "Nessuno"; +"Notifications" = "Notifiche"; +"Notifies when the 5-hour session quota hits 0% and when it becomes " = "Notifica quando la quota di sessione di 5 ore raggiunge lo 0% e quando torna "; +"OK" = "Va bene"; +"Obscure email addresses in the menu bar and menu UI." = "Oscura gli indirizzi email nella barra menu e nell'interfaccia del menu."; +"Off" = "Disattivato"; +"Offline" = "Non in linea"; +"On" = "Attivo"; +"Online" = "In linea"; +"Only on user action" = "Solo su azione dell'utente"; +"Open" = "Apri"; +"Open API Keys" = "Apri chiavi API"; +"Open Amp Settings" = "Apri impostazioni Amp"; +"Open Antigravity to sign in, then refresh CodexBar." = "Apri Antigravity per accedere, poi aggiorna CodexBar."; +"Open Browser" = "Apri browser"; +"Open Coding Plan" = "Apri Coding Plan"; +"Open Console" = "Apri console"; +"Open Dashboard" = "Apri dashboard"; +"Open Mistral Admin" = "Apri Mistral Admin"; +"Open Menu Bar Settings" = "Apri impostazioni barra menu"; +"Open Ollama Settings" = "Apri impostazioni Ollama"; +"Open Terminal" = "Apri Terminale"; +"Open Usage Page" = "Apri pagina utilizzo"; +"Open Warp API Key Guide" = "Apri guida chiave API Warp"; +"Open menu" = "Apri menu"; +"Open token file" = "Apri file token"; +"OpenAI cookies" = "Cookie OpenAI"; +"OpenAI web extras" = "Extra web OpenAI"; +"Option A" = "Opzione A"; +"Option B" = "Opzione B"; +"Optional override if workspace lookup fails." = "Override opzionale se la ricerca del workspace fallisce."; +"Options" = "Opzioni"; +"Override auto-detection with a custom IDE base path" = "Sostituisci il rilevamento automatico con un percorso base IDE personalizzato"; +"Overview" = "Panoramica"; +"Overview rows always follow provider order." = "Le righe della Panoramica seguono sempre l'ordine dei provider."; +"Overview tab providers" = "Provider scheda Panoramica"; +"Paste API key…" = "Incolla chiave API…"; +"Paste API token…" = "Incolla token API…"; +"Paste key…" = "Incolla chiave…"; +"Paste sessionKey or OAuth token…" = "Incolla sessionKey o token OAuth…"; +"Paste the Cookie header from a request to admin.mistral.ai. " = "Incolla l'header Cookie da una richiesta a admin.mistral.ai. "; +"Paste token…" = "Incolla token…"; +"Personal" = "Personale"; +"Picker" = "Selettore"; +"Picker subtitle" = "Sottotitolo selettore"; +"Placeholder" = "Segnaposto"; +"Plan" = "Piano"; +"Plan Usage" = "Utilizzo piano"; +"Play full-screen confetti when weekly usage resets." = "Mostra coriandoli a schermo intero quando l'utilizzo settimanale si resetta."; +"Polls OpenAI/Claude status pages and Google Workspace for " = "Interroga le pagine di stato di OpenAI/Claude e Google Workspace per "; +"Prevents any Keychain access while enabled." = "Impedisce qualsiasi accesso al portachiavi quando è attivo."; +"Primary (API key limit)" = "Primario (limite chiave API)"; +"Primary (\\(label))" = "Primario (\\(label))"; +"Primary (\\(metadata.sessionLabel))" = "Primario (\\(metadata.sessionLabel))"; +"Probe logs" = "Log probe"; +"Progress bars fill as you consume quota (instead of showing remaining)." = "Le barre di avanzamento si riempiono man mano che consumi la quota (invece di mostrare il rimanente)."; +"Provider" = "Provider"; +"Providers" = "Provider"; +"Quit CodexBar" = "Esci da CodexBar"; +"Random (default)" = "Casuale (predefinito)"; +"Reads local usage logs. Shows today + last 30 days cost in the menu." = "Legge i log di utilizzo locali. Mostra oggi + la finestra storica selezionata nel menu."; +"Refresh" = "Aggiorna"; +"Refresh cadence" = "Frequenza aggiornamento"; +"Remote" = "Remoto"; +"Remove" = "Rimuovi"; +"Remove Codex account?" = "Rimuovere l'account Codex?"; +"Remove \\(account.email) from CodexBar? Its managed Codex home will be deleted." = "Rimuovere \\(account.email) da CodexBar? La sua home Codex gestita verrà eliminata."; +"Remove \\(email) from CodexBar? Its managed Codex home will be deleted." = "Rimuovere \\(email) da CodexBar? La sua home Codex gestita verrà eliminata."; +"Remove selected account" = "Rimuovi account selezionato"; +"Replace critter bars with provider branding icons and a percentage." = "Sostituisce le barre con icone del brand del provider e una percentuale."; +"Replay selected animation" = "Riproduci di nuovo l'animazione selezionata"; +"Requires authentication via GitHub Device Flow." = "Richiede autenticazione tramite GitHub Device Flow."; +"Resets: \\(reset)" = "Si resetta: \\(reset)"; +"Rolling five-hour limit" = "Limite mobile di cinque ore"; +"Search hourly" = "Ricerca oraria"; +"Secondary (\\(label))" = "Secondario (\\(label))"; +"Secondary (\\(metadata.weeklyLabel))" = "Secondario (\\(metadata.weeklyLabel))"; +"Select a provider" = "Seleziona un provider"; +"Select the IDE to monitor" = "Seleziona l'IDE da monitorare"; +"Session quota notifications" = "Notifiche quota sessione"; +"Session tokens" = "Token di sessione"; +"provider_section_connection" = "Connessione"; +"provider_section_menu_bar" = "Barra menu"; +"Show Codex Credits and Claude Extra usage sections in the menu." = "Mostra nel menu le sezioni Crediti Codex e Uso extra Claude."; +"Show Debug Settings" = "Mostra impostazioni debug"; +"Show all token accounts" = "Mostra tutti gli account token"; +"Show cost summary" = "Mostra riepilogo costi"; +"Show credits + extra usage" = "Mostra crediti + uso extra"; +"Show details" = "Mostra dettagli"; +"Show most-used provider" = "Mostra provider più usato"; +"Show provider icons in the switcher (otherwise show a weekly progress line)." = "Mostra le icone dei provider nel selettore (altrimenti mostra una linea di progresso settimanale)."; +"Show reset time as clock" = "Mostra l'ora di reset come orario"; +"Show usage as used" = "Mostra l'utilizzo come consumato"; +"Sign in with Claude Code..." = "Accedi con Claude Code..."; +"Sign in via button below" = "Accedi con il pulsante qui sotto"; +"Skip teardown between probes (debug-only)." = "Salta il teardown tra i probe (solo debug)."; +"Stack token accounts in the menu (otherwise show an account switcher bar)." = "Impila gli account token nel menu (altrimenti mostra una barra per cambiare account)."; +"Start at Login" = "Avvia all'accesso"; +"Status" = "Stato"; +"Store Claude sessionKey cookies or OAuth access tokens." = "Memorizza i cookie sessionKey di Claude o i token di accesso OAuth."; +"Store multiple Abacus AI Cookie headers." = "Memorizza più header Cookie di Abacus AI."; +"Store multiple Augment Cookie headers." = "Memorizza più header Cookie di Augment."; +"Store multiple Cursor Cookie headers." = "Memorizza più header Cookie di Cursor."; +"Store multiple Factory Cookie headers." = "Memorizza più header Cookie di Factory."; +"Store multiple MiniMax Cookie headers." = "Memorizza più header Cookie di MiniMax."; +"Store multiple Mistral Cookie headers." = "Memorizza più header Cookie di Mistral."; +"Store multiple Ollama Cookie headers." = "Memorizza più header Cookie di Ollama."; +"Store multiple OpenCode Cookie headers." = "Memorizza più header Cookie di OpenCode."; +"Store multiple OpenCode Go Cookie headers." = "Memorizza più header Cookie di OpenCode Go."; +"Stored in the CodexBar config file." = "Memorizzato nel file di configurazione di CodexBar."; +"Stored in ~/.codexbar/config.json. " = "Memorizzato in ~/.codexbar/config.json. "; +"Stored in ~/.codexbar/config.json. Paste the key from the Synthetic dashboard." = "Memorizzato in ~/.codexbar/config.json. Incolla la chiave dalla dashboard di Synthetic."; +"Stored in ~/.codexbar/config.json. Paste your Coding Plan API key from Model Studio." = "Memorizzato in ~/.codexbar/config.json. Incolla la tua chiave API Coding Plan da Model Studio."; +"Stored in ~/.codexbar/config.json. Paste your MiniMax API key." = "Memorizzato in ~/.codexbar/config.json. Incolla la tua chiave API MiniMax."; +"Stored in ~/.codexbar/config.json. You can also provide KILO_API_KEY or " = "Memorizzato in ~/.codexbar/config.json. Puoi anche fornire KILO_API_KEY o "; +"Stores local Codex usage history (8 weeks) to personalize Pace predictions." = "Memorizza la cronologia di utilizzo locale di Codex (8 settimane) per personalizzare le previsioni di andamento."; +"Surprise me" = "Sorprendimi"; +"Switcher shows icons" = "Il selettore mostra icone"; +"Symlink CodexBarCLI to /usr/local/bin and /opt/homebrew/bin as codexbar." = "Crea il symlink di CodexBarCLI in /usr/local/bin e /opt/homebrew/bin come codexbar."; +"System" = "Sistema"; +"terminal_app_subtitle" = "Terminale usato dall'azione Apri terminale"; +"terminal_app_title" = "Terminale predefinito"; +"Temporarily shows the loading animation after the next refresh." = "Mostra temporaneamente l'animazione di caricamento dopo il prossimo aggiornamento."; +"Tertiary (\\(label))" = "Terziario (\\(label))"; +"Tertiary (\\(tertiaryTitle))" = "Terziario (\\(tertiaryTitle))"; +"The default Codex account on this Mac." = "L'account Codex predefinito su questo Mac."; +"Toggle" = "Interruttore"; +"Toggle subtitle" = "Sottotitolo interruttore"; +"Token" = "Token"; +"Trigger the menu bar menu from anywhere." = "Attiva il menu della barra da qualsiasi punto."; +"True" = "Vero"; +"Twitter" = "X"; +"Unsupported" = "Non supportato"; +"Update Channel" = "Canale aggiornamenti"; +"Updated" = "Aggiornato"; +"Updates unavailable in this build." = "Aggiornamenti non disponibili in questa build."; +"Usage" = "Utilizzo"; +"Usage breakdown" = "Dettaglio utilizzo"; +"Usage history (30 days)" = "Storico utilizzo (30 giorni)"; +"Usage source" = "Fonte utilizzo"; +"Use Account" = "Usa account"; +"Use BigModel for the China mainland endpoints (open.bigmodel.cn)." = "Usa BigModel per gli endpoint della Cina continentale (open.bigmodel.cn)."; +"Use a single menu bar icon with a provider switcher." = "Usa una singola icona nella barra menu con selettore provider."; +"Use international or China mainland console gateways for quota fetches." = "Usa i gateway console internazionali o della Cina continentale per recuperare le quote."; +"Version" = "Versione"; +"Version \\(self.versionString)" = "Versione \\(self.versionString)"; +"Version \\(version)" = "Versione \\(version)"; +"Version \\(versionString)" = "Versione \\(versionString)"; +"Vertex AI Login" = "Accesso Vertex AI"; +"Wait for the current managed Codex login to finish before adding another account." = "Attendi che l'accesso gestito di Codex in corso finisca prima di aggiungere un altro account."; +"Waiting for Authentication..." = "In attesa di autenticazione..."; +"Website" = "Sito web"; +"Weekly limit confetti" = "Coriandoli limite settimanale"; +"Weekly token limit" = "Limite token settimanale"; +"Weekly usage" = "Utilizzo settimanale"; +"Weekly usage unavailable for this account." = "Utilizzo settimanale non disponibile per questo account."; +"Window: \\(window)" = "Finestra: \\(window)"; +"Write logs to \\(self.fileLogPath) for debugging." = "Scrive i log in \\(self.fileLogPath) per il debug."; +"Yes" = "Sì"; +"\\(detail.modelCode): \\(usage)" = "Modello \\(detail.modelCode): \\(usage)"; +"\\(name): \\(truncated)" = "\\(name): \\(truncated) (ridotto)"; +"\\(name): \\(updated) · 30d \\(cost)" = "\\(name): \\(updated) · 30 g \\(cost)"; +"\\(name): fetching…\\(elapsed)" = "\\(name): recupero…\\(elapsed)"; +"\\(name): last attempt \\(when)" = "\\(name): ultimo tentativo \\(when)"; +"\\(name): no data yet" = "\\(name): nessun dato ancora"; +"\\(name): unsupported" = "\\(name): non supportato"; +"all browsers" = "tutti i browser"; +"available again." = "nuovamente disponibile."; +"built_format" = "Build %@"; +"copilot_complete_in_browser" = "Completa l'accesso nel browser."; +"copilot_device_code" = "Codice dispositivo copiato negli appunti: %1$@\n\nVerifica su: %2$@"; +"copilot_device_code_copied" = "Codice dispositivo copiato."; +"copilot_verify_at" = "Verifica su %@"; +"copilot_waiting_text" = "Completa l'accesso nel browser.\nQuesta finestra si chiuderà automaticamente quando l'accesso sarà completato."; +"copilot_window_closes_auto" = "Questa finestra si chiuderà automaticamente quando l'accesso sarà completato."; +"cost_status_error" = "%1$@: errore %2$@"; +"cost_status_fetching" = "%1$@: recupero in corso… %2$@"; +"cost_status_last_attempt" = "%1$@: ultimo tentativo %2$@"; +"cost_status_no_data" = "%@: nessun dato ancora"; +"cost_status_snapshot" = "%1$@: %2$@ · %3$@: %4$@"; +"cost_status_unsupported" = "%@: non supportato"; +"credits_remaining" = "Crediti: %@"; +"cursor_on_demand" = "Su richiesta: %@"; +"cursor_on_demand_with_limit" = "Su richiesta: %1$@ / %2$@"; +"extra_usage_format" = "Uso extra: %1$@ / %2$@"; +"jetbrains_detected_generate" = "Rilevato: %@. Usa l'assistente AI una volta per generare i dati di quota, poi aggiorna CodexBar."; +"jetbrains_detected_select" = "Rilevato: %@. Seleziona l'IDE preferito nelle Impostazioni, poi aggiorna CodexBar."; +"last_fetch_failed_with_provider" = "Ultimo recupero %@ non riuscito:"; +"last_spend" = "Ultima spesa: %@"; +"mcp_model_usage" = "Modello %1$@: %2$@"; +"mcp_resets" = "Si resetta: %@"; +"mcp_window" = "Finestra: %@"; +"metric_average" = "Media (%1$@ + %2$@)"; +"metric_primary" = "Primario (%@)"; +"metric_secondary" = "Secondario (%@)"; +"metric_tertiary" = "Terziario (%@)"; +"multiple_workspaces_found" = "CodexBar ha trovato più workspace per %@. Scegli il workspace da aggiungere."; +"ory_session_…=…; csrftoken=…" = "ory_session_…=…; csrftoken=…"; +"overview_choose_providers" = "Scegli fino a %@ provider"; +"remove_account_message" = "Rimuovere %@ da CodexBar? La sua home Codex gestita verrà eliminata."; +"version_format" = "Versione %@"; +"vertex_ai_login_instructions" = "Per monitorare l'utilizzo di Vertex AI, autenticati con Google Cloud.\n\n1. Apri Terminale\n2. Esegui: gcloud auth application-default login\n3. Segui le istruzioni nel browser per accedere\n4. Imposta il progetto: gcloud config set project PROJECT_ID\n\nAprire Terminale ora?"; +"workspaceID is set but only opencode, opencodego, and deepgram support workspaceID." = "workspaceID è impostato ma solo opencode, opencodego e deepgram supportano workspaceID."; +"© 2026 Peter Steinberger. MIT License." = "© 2026 Peter Steinberger. Licenza MIT."; + +/* General Pane */ +"section_system" = "Sistema"; +"section_usage" = "Utilizzo"; +"section_refreshing" = "Aggiornamento"; +"section_alerts" = "Avvisi"; +"section_celebrations" = "Celebrazioni"; +"section_icon" = "Icona"; +"section_combined_icon" = "Icona combinata"; +"section_animation" = "Animazione"; +"section_content" = "Contenuto"; +"section_agent_sessions" = "Sessioni degli agenti"; +"language_title" = "Lingua"; +"language_subtitle" = "Cambia la lingua dell'interfaccia. Richiede il riavvio dell'app per applicare completamente la modifica."; +"currency_title" = "Valuta preferita"; +"currency_subtitle" = "Valuta per stime dei costi e spese. Usa tassi di cambio aggiornati ogni giorno."; +"currency_auto" = "Automatica (in base al provider / USD)"; +"language_system" = "Sistema"; +"language_english" = "Inglese"; +"language_spanish" = "Spagnolo"; +"language_catalan" = "Catalano"; +"language_chinese_simplified" = "Cinese semplificato"; +"language_chinese_traditional" = "Cinese tradizionale"; +"language_portuguese_brazilian" = "Portoghese (Brasile)"; +"language_german" = "Tedesco"; +"language_swedish" = "Svedese"; +"language_italian" = "Italiano"; +"language_polish" = "Polacco"; +"language_japanese" = "Giapponese"; +"language_korean" = "Coreano"; +"language_turkish" = "Turco"; +"language_french" = "Francese"; +"language_dutch" = "Olandese"; +"language_ukrainian" = "Ucraino"; +"language_russian" = "Русский"; +"language_vietnamese" = "Vietnamita"; +"language_indonesian" = "Indonesiano"; +"start_at_login_title" = "Avvia all'accesso"; +"start_at_login_subtitle" = "Apre automaticamente CodexBar quando avvii il Mac."; +"show_cost_summary_subtitle" = "Legge i log di utilizzo locali. Mostra oggi + la finestra storica selezionata nel menu."; +"cost_summary_style_title" = "Stile di visualizzazione"; +"cost_summary_style_inline" = "Solo in linea"; +"cost_summary_style_submenu" = "Solo sottomenu"; +"cost_summary_style_both" = "Entrambi"; +"cost_summary_style_inline_help" = "Mostra il riepilogo dei costi direttamente nel menu principale."; +"cost_summary_style_submenu_help" = "Mostra invece il sottomenu Costo dettagliato."; +"cost_summary_style_both_help" = "Mostra sia il riepilogo nel menu principale sia il sottomenu Costo dettagliato."; +"cost_history_window_title" = "Finestra storica"; +"cost_history_window_help" = "Imposta quanti giorni di log di utilizzo locali mostrare nel menu."; +"cost_history_days_title" = "Finestra storica: %d giorni"; +"cost_comparison_periods_title" = "Mostra periodi di confronto più brevi"; +"cost_comparison_periods_subtitle" = "Aggiungi i totali di 7, 30 e 90 giorni quando rientrano nell'intervallo di cronologia selezionato. Questi totali riutilizzano la stessa scansione locale."; +"cost_auto_refresh_info" = "Aggiornamento automatico: intervallo globale (minimo 5 min) · Timeout: 10 min"; +"refresh_interval_title" = "Intervallo di aggiornamento"; +"manual_refresh_hint" = "Aggiornamento automatico disattivato; usa il comando Aggiorna nel menu."; +"refresh_on_open_title" = "Aggiorna all'apertura del menu"; +"refresh_on_open_subtitle" = "Recupera l'utilizzo più recente di ogni provider ogni volta che apri il menu."; +"check_provider_status_title" = "Controlla stato provider"; +"check_provider_status_subtitle" = "Controlla le pagine di stato OpenAI/Claude e Google Workspace per Gemini/Antigravity, mostrando incidenti in icona e menu."; +"session_quota_notifications_subtitle" = "Notifica quando la quota sessione di 5 ore arriva allo 0% e quando torna disponibile."; +"quota_depleted_title" = "Quota esaurita e ripristinata"; +"quota_warning_notifications_subtitle" = "Avvisa quando la quota residua di sessione o settimanale scende sotto le soglie configurate."; +"threshold_warnings_title" = "Avvisi di soglia"; +"quota_warnings_title" = "Avvisi quota"; +"quota_warning_session" = "sessione"; +"quota_warning_session_capitalized" = "Sessione"; +"quota_warning_weekly" = "settimanale"; +"quota_warning_weekly_capitalized" = "Settimanale"; +"quota_warning_notification_title" = "Quota %2$@ di %1$@ quasi esaurita"; +"quota_warning_notification_body" = "Rimane %1$@. Hai raggiunto la soglia di avviso del %2$d%% per la quota %3$@."; +"quota_warning_notification_body_with_account" = "Account %1$@. Rimane %2$@. Hai raggiunto la soglia di avviso del %3$d%% per la quota %4$@."; +"predictive_pace_warnings_title" = "Avvisi predittivi sul ritmo"; +"predictive_pace_warnings_subtitle" = "Avvisa per Codex e Claude quando il ritmo della sessione o della settimana potrebbe esaurire la quota prima del reset."; +"confetti_on_reset_title" = "Coriandoli al reset"; +"confetti_on_reset_subtitle" = "Mostra coriandoli a schermo intero quando l'utilizzo si resetta."; +"confetti_option_off" = "Disattivato"; +"confetti_option_session" = "Reset della sessione"; +"confetti_option_weekly" = "Reset settimanali"; +"confetti_option_both" = "Entrambi"; +"predictive_pace_warning_notification_title" = "%1$@: avviso ritmo %2$@"; +"predictive_pace_warning_notification_body" = "Al ritmo attuale, questa quota potrebbe esaurirsi tra %1$@, prima del reset."; +"predictive_pace_warning_notification_body_with_account" = "Account %1$@. Al ritmo attuale, questa quota potrebbe esaurirsi tra %2$@, prima del reset."; +"session_depleted_notification_title" = "Sessione %@ esaurita"; +"session_depleted_notification_body" = "Rimane lo 0%. Ti avviseremo quando tornerà disponibile."; +"session_restored_notification_title" = "Sessione %@ ripristinata"; +"session_restored_notification_body" = "La quota di sessione è di nuovo disponibile."; +"quota_warning_warn_at" = "Avvisa a"; +"quota_warning_global_threshold_subtitle" = "Percentuali residue per le finestre di sessione e settimanale, salvo override del provider."; +"quota_warning_sound" = "Riproduci suono di notifica"; +"quota_warning_onscreen_alert" = "Mostra avviso di testo sullo schermo"; +"quota_warning_provider_inherits" = "Usa le impostazioni globali di avviso quota, salvo personalizzazione di una finestra qui."; +"quota_warning_provider_disabled" = "Le notifiche di avviso quota e gli indicatori nelle barre di utilizzo sono disattivati. Abilita una delle due opzioni per modificare queste impostazioni salvate."; +"quota_warning_provider_markers_only" = "Le notifiche di avviso quota sono disattivate globalmente. Queste impostazioni controllano ancora gli indicatori nelle barre di utilizzo."; +"quota_warning_global" = "Globale"; +"quota_warning_customize_thresholds" = "Personalizza soglie %@"; +"quota_warning_enable_warnings" = "Abilita avvisi %@"; +"quota_warning_window_warn_at" = "Avvisa %@ a"; +"quota_warning_off" = "Disattivato"; +"quota_warning_inherited" = "Ereditato: %@"; +"quota_warning_depleted_only" = "solo esaurita"; +"quota_warning_upper" = "Più alto"; +"quota_warning_lower" = "Inferiore"; +"quota_warning_warning" = "Avviso"; +"quota_warning_critical" = "Critico"; +"apply" = "Applica"; +"quit_app" = "Esci da CodexBar"; + +/* Tab titles */ +"tab_general" = "Generale"; +"tab_providers" = "Provider"; +"tab_notifications" = "Notifiche"; +"tab_menu_bar" = "Barra menu"; +"tab_menu" = "Menu"; +"tab_advanced" = "Avanzate"; +"tab_hooks" = "Hook"; + +/* Hooks Pane */ +"hooks_enable_title" = "Abilita hook"; +"hooks_enable_subtitle" = "Esegui comandi esterni al verificarsi di eventi di quota o provider."; +"hooks_trust_warning" = "Gli hook possono eseguire comandi locali sul tuo Mac. Configura solo comandi di cui ti fidi."; +"hooks_rules_header" = "Regole"; +"hooks_empty" = "Nessun hook configurato."; +"hooks_add_rule" = "Aggiungi regola"; +"hooks_delete_rule" = "Elimina regola"; +"hooks_rule_enabled" = "Abilitato"; +"hooks_event" = "Evento"; +"hooks_provider" = "Provider"; +"hooks_any_provider" = "Qualsiasi provider"; +"hooks_threshold" = "Attiva a utilizzo ≥"; +"hooks_threshold_placeholder" = "90"; +"hooks_executable_placeholder" = "/usr/local/bin/my-command"; +"hooks_arguments_placeholder" = "Argomenti"; +"hooks_argument_placeholder" = "Argomento"; +"hooks_add_argument" = "Aggiungi argomento"; +"hooks_delete_argument" = "Elimina argomento"; +"tab_about" = "Informazioni"; +"tab_debug" = "Diagnostica"; + +/* Providers Pane */ +"select_a_provider" = "Seleziona un provider"; +"cancel" = "Annulla"; +"last_fetch_failed" = "ultimo recupero fallito"; +"usage_not_fetched_yet" = "utilizzo non ancora recuperato"; +"managed_account_storage_unreadable" = "L'archivio degli account gestiti non è leggibile. L'accesso account live è ancora disponibile, ma aggiunta, re-autenticazione e rimozione degli account gestiti sono disabilitate finché l'archivio non viene ripristinato."; +"remove_codex_account_title" = "Rimuovere account Codex?"; +"remove" = "Rimuovi"; +"managed_login_already_running" = "È già in corso un accesso gestito a Codex. Attendi che termini prima di aggiungere o riautenticare un altro account."; +"managed_login_failed" = "L'accesso gestito a Codex non è stato completato. Verifica che `codex --version` funzioni in Terminale. Se macOS ha bloccato `codex` o lo ha spostato nel Cestino, rimuovi le installazioni duplicate obsolete, esegui `npm install -g --include=optional @openai/codex@latest`, poi riprova."; +"codex_login_output" = "output di codex login:"; +"managed_login_missing_email" = "L'accesso Codex è stato completato, ma nessuna email account era disponibile. Riprova dopo aver verificato che l'account sia completamente autenticato."; +"login_success_notification_title" = "Accesso %@ riuscito"; +"login_success_notification_body" = "Puoi tornare all'app; l'autenticazione è terminata."; +"workspace_selection_cancelled" = "CodexBar ha trovato più workspace, ma non ne è stato selezionato nessuno."; +"unsafe_managed_home" = "CodexBar ha rifiutato di modificare un percorso home gestito inatteso: %@"; +"menu_bar_metric_title" = "Metrica barra menu"; +"menu_bar_metric_subtitle" = "Scegli quale finestra guida la percentuale nella barra menu."; +"menu_bar_metric_subtitle_deepseek" = "Mostra il saldo DeepSeek nella barra menu."; +"menu_bar_metric_subtitle_moonshot" = "Mostra il saldo API Moonshot / Kimi nella barra menu."; +"menu_bar_metric_subtitle_mistral" = "Mostra la spesa API Mistral del mese corrente nella barra menu."; +"automatic" = "Automatico"; +"primary_api_key_limit" = "Principale (limite chiave API)"; + +/* Display Pane */ +"menu_bar_style_title" = "Stile barra menu"; +"menu_bar_style_subtitle" = "Come viene rappresentato l'elemento della barra menu."; +"menu_bar_inactive_display_contrast_title" = "Migliora la visibilità sugli schermi inattivi"; +"menu_bar_usage_colors_title" = "Utilizzo a colori"; +"menu_bar_usage_colors_subtitle" = "Colora l'icona nella barra dei menu dal verde al rosso man mano che l'utilizzo aumenta."; +"menu_bar_inactive_display_contrast_subtitle" = "Usa un rendering ad alto contrasto per mantenere leggibili icona e metrica sugli altri schermi."; +"menu_bar_style_critters" = "Creature"; +"menu_bar_style_bars" = "Barre di livello"; +"menu_bar_style_icon_percent" = "Icona + percentuale"; +"switcher_rows_title" = "Righe selettore"; +"switcher_rows_icons" = "Icone provider"; +"switcher_rows_progress" = "Progresso settimanale"; +"usage_bars_fill_title" = "Riempimento delle barre di utilizzo"; +"usage_bars_fill_remaining" = "In base al residuo"; +"usage_bars_fill_used" = "In base all'utilizzo"; +"reset_times_title" = "Orari di reset"; +"reset_times_countdown" = "Conto alla rovescia"; +"reset_times_clock" = "Orario"; +"cost_summary_title" = "Riepilogo costi"; +"cost_summary_off" = "Disattivato"; +"merge_icons_title" = "Unisci icone"; +"merge_icons_subtitle" = "Usa un'unica icona nella barra menu con selettore provider."; +"show_most_used_provider_title" = "Mostra provider più usato"; +"show_most_used_provider_subtitle" = "La barra menu mostra automaticamente il provider più vicino al limite."; +"display_mode_title" = "Modalità visualizzazione"; +"display_mode_subtitle" = "Scegli cosa mostrare nella barra menu (Andamento confronta utilizzo e atteso)."; +"show_quota_warning_markers_title" = "Mostra indicatori avviso quota"; +"show_quota_warning_markers_subtitle" = "Disegna tacche soglia sulle barre quando gli avvisi quota sono configurati."; +"weekly_progress_work_days_title" = "Giorni lavorativi progresso settimanale"; +"weekly_progress_work_days_subtitle" = "Disegna i confini giornalieri sulle barre settimanali."; +"show_provider_changelog_links_title" = "Mostra link changelog provider"; +"show_provider_changelog_links_subtitle" = "Aggiunge link alle note di rilascio per i provider CLI supportati."; +"show_credits_extra_usage_title" = "Mostra crediti + uso extra"; +"show_credits_extra_usage_subtitle" = "Mostra nel menu le sezioni Crediti Codex e Uso extra Claude."; +"multi_account_layout_title" = "Layout multi-account"; +"multi_account_layout_subtitle" = "Scegli tra selezione segmentata o schede account impilate."; +"multi_account_layout_segmented" = "Segmentato"; +"multi_account_layout_stacked" = "Impilato"; +"overview_tab_providers_title" = "Provider della scheda Panoramica"; +"configure" = "Configura…"; +"overview_enable_merge_icons_hint" = "Abilita Unisci icone per configurare i provider della scheda Panoramica."; +"overview_no_providers_hint" = "Nessun provider attivo disponibile per Panoramica."; +"overview_rows_follow_order" = "Le righe Panoramica seguono sempre l'ordine dei provider."; +"overview_no_providers_selected" = "Nessun provider selezionato"; +"agent_sessions_title" = "Sessioni degli agenti"; +"agent_sessions_subtitle" = "Mostra nel menu le sessioni Codex e Claude Code locali e rilevate tramite SSH."; +"agent_sessions_hosts_title" = "Host SSH aggiuntivi"; +"agent_sessions_footer" = "I Mac sulla tua tailnet vengono rilevati automaticamente. Le sessioni locali si aggiornano ogni 30 secondi; gli host remoti ogni 60 secondi e all'apertura del menu."; +"agent_session_labels_title" = "Etichette delle sessioni"; +"agent_session_labels_subtitle" = "Scegli come denominare le sessioni degli agenti."; +"agent_session_label_project" = "Progetto"; +"agent_session_label_descriptive" = "Descrittiva"; +"agent_session_label_descriptive_and_project" = "Descrittiva + progetto"; +"agent_session_unknown_project" = "Progetto sconosciuto"; + +/* Advanced Pane */ +"section_keyboard_shortcut" = "Scorciatoia tastiera"; +"open_menu_shortcut_title" = "Apri menu"; +"open_menu_shortcut_subtitle" = "Attiva il menu della barra da qualsiasi punto."; +"install_cli" = "Installa CLI"; +"install_cli_subtitle" = "Crea il symlink di CodexBarCLI in /usr/local/bin e /opt/homebrew/bin come codexbar."; +"cli_not_found" = "CodexBarCLI non trovato nel bundle dell'app."; +"no_writable_bin_dirs" = "Nessuna directory bin scrivibile trovata."; +"show_debug_settings_title" = "Mostra impostazioni debug"; +"show_debug_settings_subtitle" = "Espone strumenti di diagnosi nella scheda Debug."; +"surprise_me_title" = "Sorprendimi"; +"surprise_me_subtitle" = "Per chi apprezza un po' di personalità dagli agenti."; +"hide_personal_info_title" = "Nascondi informazioni personali"; +"hide_personal_info_subtitle" = "Oscura gli indirizzi email nella barra menu e nell'interfaccia menu."; +"show_provider_storage_usage_title" = "Mostra spazio usato dai provider"; +"show_provider_storage_usage_subtitle" = "Mostra l'uso disco locale nei menu. Scansiona in background i percorsi dei provider."; +"section_keychain_access" = "Accesso Portachiavi"; +"keychain_access_caption" = "Disattiva tutte le letture e scritture del Portachiavi. Usalo se macOS continua a chiedere accesso a 'Chrome/Brave/Edge Safe Storage' anche dopo aver scelto Consenti sempre. Quando è attivo, l'importazione dei cookie dal browser non è disponibile; incolla manualmente gli header Cookie in Provider. L'OAuth di Claude/Codex tramite CLI continua a funzionare."; +"disable_keychain_access_title" = "Disattiva accesso Portachiavi"; +"disable_keychain_access_subtitle" = "Impedisce qualsiasi accesso al Portachiavi quando attivo."; + +/* About Pane */ +"about_tagline" = "Che i tuoi token non finiscano mai: tieni sempre sotto controllo i limiti degli agenti."; +"link_github" = "GitHub"; +"link_website" = "Sito web"; +"link_twitter" = "X"; +"link_email" = "Email"; +"check_updates_auto" = "Controlla automaticamente gli aggiornamenti"; +"update_channel" = "Canale aggiornamenti"; +"check_for_updates" = "Controlla aggiornamenti…"; +"updates_unavailable" = "Aggiornamenti non disponibili in questa build."; +"copyright" = "© 2026 Peter Steinberger. Licenza MIT."; + +/* Debug Pane */ +"section_logging" = "Log"; +"enable_file_logging" = "Abilita log su file"; +"enable_file_logging_subtitle" = "Scrive i log in %@ per il debug."; +"verbosity_title" = "Verbosità"; +"verbosity_subtitle" = "Controlla quanto dettaglio viene registrato nei log."; +"open_log_file" = "Apri file di log"; +"force_animation_next_refresh" = "Forza animazione al prossimo aggiornamento"; +"force_animation_next_refresh_subtitle" = "Mostra temporaneamente l'animazione di caricamento dopo il prossimo aggiornamento."; +"section_loading_animations" = "Animazioni di caricamento"; +"loading_animations_caption" = "Scegli un pattern e riproducilo nella barra menu. \"Casuale\" mantiene il comportamento esistente."; +"animation_random_default" = "Casuale (predefinito)"; +"replay_selected_animation" = "Riproduci animazione selezionata"; +"blink_now" = "Lampeggia ora"; +"section_probe_logs" = "Log probe"; +"probe_logs_caption" = "Recupera l'ultimo output del probe per il debug; Copia mantiene il testo completo."; +"fetch_log" = "Recupera log"; +"copy" = "Copia"; +"save_to_file" = "Salva su file"; +"load_parse_dump" = "Carica dump di parsing"; +"rerun_provider_autodetect" = "Riesegui rilevamento automatico provider"; +"loading" = "Caricamento…"; +"no_log_yet_fetch" = "Nessun log ancora. Recupera per caricare."; +"section_fetch_strategy" = "Tentativi strategia di recupero"; +"fetch_strategy_caption" = "Ultime decisioni ed errori della pipeline di recupero per un provider."; +"section_openai_cookies" = "Cookie OpenAI"; +"openai_cookies_caption" = "Importazione cookie + log di scraping WebKit dall'ultimo tentativo cookie OpenAI."; +"no_log_yet" = "Nessun log ancora. Aggiorna i cookie OpenAI in Provider → Codex per avviare un'importazione."; +"section_caches" = "Cache"; +"caches_caption" = "Cancella i risultati memorizzati delle scansioni costi o le cache dei cookie del browser."; +"clear_cookie_cache" = "Svuota cache cookie"; +"clear_cost_cache" = "Svuota cache costi"; +"section_notifications" = "Notifiche"; +"notifications_caption" = "Attiva notifiche di test per la finestra di sessione di 5 ore (esaurita/ripristinata)."; +"post_depleted" = "Invia notifica esaurita"; +"post_restored" = "Invia notifica ripristinata"; +"section_cli_sessions" = "Sessioni CLI"; +"cli_sessions_caption" = "Mantieni attive le sessioni CLI di Codex/Claude dopo un probe. Per impostazione predefinita si chiudono dopo aver raccolto i dati."; +"keep_cli_sessions_alive" = "Mantieni attive le sessioni CLI"; +"keep_cli_sessions_alive_subtitle" = "Salta il teardown tra i probe (solo debug)."; +"reset_cli_sessions" = "Reimposta sessioni CLI"; +"section_error_simulation" = "Simulazione errori"; +"error_simulation_caption" = "Inserisce un messaggio di errore fittizio nella scheda menu per testare il layout."; +"set_menu_error" = "Imposta errore del menu"; +"clear_menu_error" = "Cancella errore del menu"; +"set_cost_error" = "Imposta errore dei costi"; +"clear_cost_error" = "Cancella errore dei costi"; +"section_cli_paths" = "Percorsi CLI"; +"cli_paths_caption" = "Binario Codex e livelli PATH risolti; acquisizione del PATH della shell di login all'avvio (timeout breve)."; +"codex_binary" = "Binario Codex"; +"claude_binary" = "Binario Claude"; +"effective_path" = "PATH effettivo"; +"unavailable" = "Non disponibile"; +"login_shell_path" = "PATH della shell di login (cattura all'avvio)"; +"cleared" = "Cancellato."; +"no_fetch_attempts" = "Nessun tentativo di recupero ancora."; +"macOS Tahoe can block menu bar apps in System Settings → Menu Bar → Allow in the Menu Bar. CodexBar is running, but macOS may be hiding its icon. Open Menu Bar settings and turn CodexBar on." = "macOS Tahoe può bloccare le app della barra menu in Impostazioni di Sistema → Barra menu → Consenti nella barra menu. CodexBar è in esecuzione, ma macOS potrebbe nasconderne l'icona. Apri le impostazioni della barra menu e attiva CodexBar."; + +/* Metric preferences */ +"metric_pref_automatic" = "Automatico"; +"metric_pref_primary" = "Principale"; +"metric_pref_secondary" = "Secondario"; +"metric_pref_tertiary" = "Terziario"; +"metric_pref_extra_usage" = "Uso extra"; +"metric_pref_average" = "Media"; +"metric_mistral_payg" = "A consumo"; +"metric_mistral_monthly_plan" = "Piano mensile"; + +/* Display modes */ +"display_mode_percent" = "Percentuale"; +"display_mode_pace" = "Andamento"; +"display_mode_both" = "Entrambi"; +"display_mode_reset_time" = "Ora di reimpostazione"; +"display_mode_percent_desc" = "Mostra percentuale residua/consumata (es. 45%)"; +"display_mode_pace_desc" = "Mostra indicatore andamento (es. +5%)"; +"display_mode_both_desc" = "Mostra percentuale e andamento (es. 45% · +5%)"; +"display_mode_reset_time_desc" = "Mostra l'ora di reimpostazione per la metrica selezionata (es. ↻ 15:56)"; +"menu_bar_reset_when_exhausted_title" = "Mostra l'ora di ripristino quando la quota si esaurisce"; +"menu_bar_reset_when_exhausted_subtitle" = "Allo 0% rimanente, mostra il tempo al ripristino invece della percentuale"; + +/* Provider status */ +"status_operational" = "Operativo"; +"status_degraded" = "Prestazioni ridotte"; +"status_partial_outage" = "Interruzione parziale"; +"status_major_outage" = "Interruzione grave"; +"status_critical_issue" = "Problema critico"; +"status_maintenance" = "Manutenzione"; +"status_unknown" = "Stato sconosciuto"; + +/* Refresh frequency */ +"refresh_manual" = "Manuale"; +"refresh_1min" = "1 min."; +"refresh_2min" = "2 min."; +"refresh_5min" = "5 min."; +"refresh_15min" = "15 min."; +"refresh_30min" = "30 min."; +"refresh_adaptive" = "Adattivo"; +"refresh_adaptive_agent_aware" = "Adattivo (attività degli agenti)"; +"adaptive_activity_consent_title" = "Consentire l’aggiornamento basato sull’attività?"; +"adaptive_activity_consent_message" = "La modalità Adattiva basata sull’attività degli agenti può esaminare l’elenco dei processi locali in esecuzione, incluse le righe di comando, per identificare Codex e Claude, quindi leggere ogni 30 secondi i metadati delle sessioni note mentre programmi. Quando Agent Sessions è disattivato, CodexBar conserva in memoria solo l’ora dell’attività più recente e scarta percorsi e identità delle sessioni. Questi dati non vengono inviati da nessuna parte; il rilevamento remoto e SSH restano disattivati. Se rifiuti, CodexBar torna alla modalità Adattiva normale senza scansioni dell’attività locale."; +"adaptive_activity_consent_allow" = "Consenti attività locale"; +"adaptive_activity_consent_decline" = "Usa Adattivo normale"; + +/* Additional keys */ +"not_found" = "Non trovato"; + +/* Cost estimation */ +"cost_estimate_hint" = "Stimato dai log locali · può differire dalla fattura"; +"codex_api_estimate_hint" = "Stimato dall’utilizzo dei token · non è una fattura di abbonamento"; +"cost_data_explanation" = "I costi possono essere comunicati dal fornitore o stimati dall’utilizzo dei token ai prezzi API pubblici. Le stime non sono addebiti di abbonamento."; +"No JetBrains IDE with AI Assistant detected. Install a JetBrains IDE and enable AI Assistant." = "Nessun IDE JetBrains con AI Assistant rilevato. Installa un IDE JetBrains e abilita AI Assistant."; +"OpenRouter API token not configured. Set OPENROUTER_API_KEY environment variable or configure in Settings." = "Token API OpenRouter non configurato. Imposta la variabile d'ambiente OPENROUTER_API_KEY oppure configuralo nelle Impostazioni."; +"z.ai API token not found. Set apiKey in ~/.codexbar/config.json or Z_AI_API_KEY." = "Token API z.ai non trovato. Imposta apiKey in ~/.codexbar/config.json o Z_AI_API_KEY."; +"Missing DeepSeek API key." = "Chiave API DeepSeek mancante."; +"%@ is unavailable in the current environment." = "%@ non è disponibile nell'ambiente corrente."; +"All Systems Operational" = "Tutti i sistemi sono operativi"; +"Last 30 days" = "Ultimi 30 giorni"; +"Last 30 days:" = "Ultimi 30 giorni:"; +"This month" = "Questo mese"; +"Store multiple OpenAI API keys." = "Memorizza più chiavi API OpenAI."; +"Admin API key" = "Chiave API admin"; +"Open billing" = "Apri fatturazione"; +"Google accounts" = "Account Google"; +"Store multiple Antigravity Google OAuth accounts for quick switching." = "Memorizza più account Google OAuth di Antigravity per un cambio rapido."; +"Add Google Account" = "Aggiungi account Google"; +"Open Token Plan" = "Apri Token Plan"; +"Text Generation" = "Generazione testo"; +"Text to Speech" = "Sintesi vocale"; +"Music Generation" = "Generazione musica"; +"Image Generation" = "Generazione immagini"; +"No local data found" = "Nessun dato locale trovato"; +"Credits unavailable; keep Codex running to refresh." = "Crediti non disponibili; lascia Codex in esecuzione per aggiornare."; +"No available fetch strategy for minimax." = "Nessuna strategia di recupero disponibile per minimax."; +"No Cursor session found. Please log in to cursor.com in Safari, Chrome, Microsoft Edge, Brave, Arc, Dia, ChatGPT Atlas, Chromium, Helium, Vivaldi, Yandex Browser, Firefox, Zen, Colibri, Sidekick, Opera, Opera GX, or Edge Canary. If you use Safari, grant CodexBar Full Disk Access in System Settings ▸ Privacy & Security. You can also sign in to Cursor from the CodexBar menu (Add / switch account)." = "Nessuna sessione Cursor trovata. Accedi a cursor.com in Safari, Chrome, Microsoft Edge, Brave, Arc, Dia, ChatGPT Atlas, Chromium, Helium, Vivaldi, Yandex Browser, Firefox, Zen, Colibri, Sidekick, Opera, Opera GX o Edge Canary. Se usi Safari, concedi a CodexBar l'accesso completo al disco in Impostazioni di Sistema ▸ Privacy e sicurezza. Puoi anche accedere a Cursor dal menu di CodexBar (Aggiungi / cambia account)."; +"No OpenCode session cookies found in browsers." = "Nessun cookie di sessione OpenCode trovato nei browser."; +"No available fetch strategy for %@." = "Nessuna strategia di recupero disponibile per %@."; +"Today" = "Oggi"; +"Today tokens" = "Token di oggi"; +"30d cost" = "Costo 30 g"; +"%@ cost" = "Costo %@"; +"30d tokens" = "Token 30 g"; +"Latest tokens" = "Token recenti"; +"Top model" = "Modello principale"; +"Storage" = "Archiviazione"; +"Add Account..." = "Aggiungi account..."; +"Usage Dashboard" = "Dashboard utilizzo"; +"Status Page" = "Pagina di stato"; +"Open Status Page" = "Apri pagina di stato"; +"Settings..." = "Impostazioni..."; +"About CodexBar" = "Informazioni su CodexBar"; +"Quit" = "Esci"; +"Last %d day" = "Ultimo %d giorno"; +"Last %d days" = "Ultimi %d giorni"; +"%@ tokens" = "%@ token"; +"Latest billing day" = "Ultimo giorno di fatturazione"; +"Latest billing day (%@)" = "Ultimo giorno di fatturazione (%@)"; +"%@ left" = "%@ rimasto"; +"Resets %@" = "Si resetta %@"; +"Resets in %@" = "Si reimposta tra %@"; +"Resets now" = "Si resetta ora"; +"reset_tomorrow_format" = "domani, %@"; +"Lasts until reset" = "Valido fino al reset"; +"1.5× headroom" = "margine 1,5×"; +"Updated %@" = "Aggiornato %@"; +"Updated relative %@" = "Aggiornato %@"; +"Updated absolute %@" = "Aggiornato %@"; +"Updated %@h ago" = "Aggiornato %@ h fa"; +"Updated %@m ago" = "Aggiornato %@ min fa"; +"Updated just now" = "Aggiornato ora"; +"Projected empty in %@" = "Esaurimento previsto tra %@"; +"Runs out in %@" = "Si esaurisce tra %@"; +"Pace: %@" = "Andamento: %@"; +"Pace: %@ · %@" = "Andamento: %@ · %@"; +"%@ · %@" = "%@ • %@"; +"≈ %d%% run-out risk" = "≈ %d%% rischio di esaurimento"; +"%d%% in deficit" = "deficit del %d%%"; +"%d%% in reserve" = "%d%% di riserva"; +"usage_percent_suffix_left" = "rimasto"; +"usage_percent_suffix_used" = "usato"; +"Store multiple DeepSeek API keys." = "Memorizza più chiavi API DeepSeek."; +"This week" = "Questa settimana"; +"Week" = "Settimana"; +"Month" = "Mese"; +"Models" = "Modelli"; +"24h tokens" = "Token 24h"; +"Latest hour" = "Ultima ora"; +"Peak hour" = "Ora di picco"; +"Top method" = "Metodo principale"; +"30d cash" = "Spesa 30 g"; +"30d billing history from MiniMax web session" = "Cronologia di fatturazione degli ultimi 30 giorni dalla sessione web MiniMax"; +"AWS Cost Explorer billing can lag." = "La fatturazione di AWS Cost Explorer può avere ritardi."; +"Rate limit: %d / %@" = "Limite di richiesta: %d / %@"; +"Key remaining" = "Residuo chiave"; +"No limit set for the API key" = "Nessun limite impostato per la chiave API"; +"API key limit unavailable right now" = "Limite della chiave API non disponibile al momento"; +"This month: %@ tokens" = "Questo mese: %@ token"; +"No utilization data yet." = "Nessun dato di utilizzo ancora disponibile."; +"No %@ utilization data yet." = "Nessun dato di utilizzo %@ ancora disponibile."; +"%@: %@%% used" = "%@: %@%% usato"; +"%dd" = "%d g"; +"today" = "oggi"; +"just now" = "proprio ora"; +"On pace" = "In linea"; +"Runs out now" = "Si esaurisce ora"; +"Projected empty now" = "Esaurimento previsto ora"; +"Switch Account..." = "Cambia account..."; +"Update ready, restart now?" = "Aggiornamento pronto, riavviare ora?"; +"Daily" = "Giornaliero"; +"Hourly Tokens" = "Token orari"; +"No data" = "Nessun dato"; +"No usage breakdown data available." = "Nessun dato di dettaglio utilizzo disponibile."; + +"Today: %@ · %@ tokens" = "Oggi: %@ · %@ token"; +"Today: %@" = "Oggi: %@"; +"Today: %@ tokens" = "Oggi: %@ token"; +"Last 30 days: %@ · %@ tokens" = "Ultimi 30 giorni: %@ · %@ token"; +"Last 30 days: %@" = "Ultimi 30 giorni: %@"; +"Est. total (30d): %@" = "Totale stimato (30 g): %@"; +"Est. total (%@): %@" = "Totale stimato (%@): %@"; +"Hover a bar for details" = "Passa il mouse su una barra per i dettagli"; +"%@: %@ · %@ tokens" = "%@: %@ • %@ token"; +"%@: %@" = "%@: %@"; +"No providers selected for Overview." = "Nessun provider selezionato per Panoramica."; +"No overview data available." = "Nessun dato di panoramica disponibile."; +"Auto uses the local IDE API first, then Google OAuth when the IDE is closed." = "Auto usa prima l'API IDE locale, poi Google OAuth quando l'IDE è chiuso."; +"Login with Google" = "Accedi con Google"; + +/* Popup panels */ +"No usage configured." = "Nessun utilizzo configurato."; +"Quota" = "Limite"; +"Daily quota" = "Quota giornaliera"; +"Total" = "Totale"; +"tokens" = "token"; +"requests" = "richieste"; +"Latest" = "Più recente"; +"Monthly" = "Mensile"; +"Sonnet" = "Claude Sonnet"; +"Overages" = "Eccedenze"; +"Activity" = "Attività"; +"Copied" = "Copiato"; +"Copy error" = "Errore di copia"; +"Copy path" = "Copia percorso"; +"Extra usage spent" = "Uso extra speso"; +"Credits remaining" = "Crediti rimanenti"; +"Using CLI fallback" = "Uso del fallback CLI"; +"Balance updates in near-real time (up to 5 min lag)" = "Il saldo si aggiorna quasi in tempo reale (ritardo fino a 5 min)"; +"Daily billing data finalizes at 07:00 UTC" = "I dati giornalieri di fatturazione si consolidano alle 07:00 UTC"; +"%@ of %@ credits left" = "%@ di %@ crediti rimasti"; +"%@ of %@ bonus credits left" = "%@ di %@ crediti bonus rimasti"; +"%@ / %@ (%@ remaining)" = "%@ / %@ (%@ rimanenti)"; +"%@/%@ left" = "%@/%@ rimasti"; +"Gemini Flash" = "Gemini Flash"; +"Regenerates %@" = "Si rigenera %@"; +"used after next regen" = "usato dopo la prossima rigenerazione"; +"after next regen" = "dopo la prossima rigenerazione"; +"Near full" = "Quasi pieno"; +"Full in ~1 regen" = "Pieno tra ~1 rigenerazione"; +"Full in ~%.0f regens" = "Pieno tra ~%.0f rigenerazioni"; +"Overage usage" = "Utilizzo in eccedenza"; +"Overage cost" = "Costo eccedenza"; +"credits" = "crediti"; +"Zen balance" = "Saldo Zen"; +"API spend" = "Spesa API"; +"Extra usage" = "Utilizzo extra"; +"Quota usage" = "Utilizzo quota"; +"Your spend" = "La tua spesa"; +"%.0f%% used" = "%.0f%% usato"; +"Usage history (today)" = "Cronologia utilizzo (oggi)"; +"Usage history (%d days)" = "Cronologia utilizzo (%d giorni)"; +"%d percent remaining" = "%d percento rimanente"; +"Unknown" = "Sconosciuto"; +"stale data" = "dati obsoleti"; +"No credits history data." = "Nessun dato storico crediti."; +"No credits history data available." = "Nessun dato storico crediti disponibile."; +"Credits history chart" = "Grafico storico crediti"; +"%d days of credits data" = "%d giorni di dati crediti"; +"Usage breakdown chart" = "Grafico dettaglio utilizzo"; +"%d days of usage data across %d services" = "%d giorni di dati di utilizzo su %d servizi"; +"Cost history chart" = "Grafico storico costi"; +"%d days of cost data" = "%d giorni di dati costi"; +"Plan utilization chart" = "Grafico utilizzo piano"; +"%d utilization samples" = "%d campioni di utilizzo"; +"Hourly Usage" = "Utilizzo orario"; +"Usage remaining" = "Utilizzo rimanente"; +"Usage used" = "Utilizzo consumato"; +"API key verified. Cloud quotas need browser cookies. Sign in to Ollama." = "Chiave API verificata. Le quote Cloud richiedono i cookie del browser. Accedi a Ollama."; +"Last 30 days: %@ tokens" = "Ultimi 30 giorni: %@ token"; +"7d spend" = "Spesa 7 g"; +"30d spend" = "Spesa 30 g"; +"Cache read" = "Lettura cache"; +"Claude Admin API 30 day spend trend" = "Trend spesa 30 giorni Claude Admin API"; +"OpenRouter API key spend trend" = "Trend spesa chiave API OpenRouter"; +"z.ai hourly token trend" = "Trend orario token z.ai"; +"MiniMax 30 day token usage trend" = "Trend utilizzo token 30 giorni MiniMax"; +"Today cash" = "Spesa di oggi"; +"DeepSeek 30 day token usage trend" = "Trend utilizzo token 30 giorni DeepSeek"; +"DeepSeek this month token usage trend" = "Trend utilizzo token DeepSeek di questo mese"; +"Chrome profile" = "Profilo Chrome"; +"Choose which signed-in DeepSeek Platform session supplies detailed usage." = "Scegli quale sessione di DeepSeek Platform con accesso effettuato fornisce l'utilizzo dettagliato."; +"Detailed usage unavailable." = "Utilizzo dettagliato non disponibile."; +"Sign in to DeepSeek Platform in Chrome for detailed usage." = "Accedi a DeepSeek Platform in Chrome per l'utilizzo dettagliato."; +"Select a DeepSeek Chrome profile in Settings." = "Seleziona un profilo Chrome di DeepSeek nelle Impostazioni."; +"Select profile…" = "Seleziona profilo…"; +"cache-hit input" = "input cache-hit"; +"cache-miss input" = "input cache-miss"; +"output" = "uscita"; +"Requests" = "Richieste"; +"Reported by OpenAI Admin API organization usage." = "Segnalato dall'utilizzo dell'organizzazione OpenAI Admin API."; +"Reported by Mistral billing usage." = "Segnalato dall'utilizzo di fatturazione Mistral."; +"Google OAuth" = "Google OAuth"; +"Add accounts via GitHub OAuth Device Flow on the selected host." = "Aggiungi account tramite GitHub OAuth Device Flow sull'host selezionato."; +"Stores each signed-in Google account for quick Antigravity switching. Uses Antigravity.app OAuth when available, or ANTIGRAVITY_OAUTH_CLIENT_ID and ANTIGRAVITY_OAUTH_CLIENT_SECRET as an override." = "Memorizza ogni account Google connesso per un rapido cambio Antigravity. Usa l'OAuth di Antigravity.app quando disponibile, oppure ANTIGRAVITY_OAUTH_CLIENT_ID e ANTIGRAVITY_OAUTH_CLIENT_SECRET come override."; +"Manual cleanup: past sessions" = "Pulizia manuale: sessioni passate"; +"Clearing removes past resume, continue, and rewind history." = "La cancellazione rimuove la cronologia passata di riprendi, continua e rewind."; +"Manual cleanup: file checkpoints" = "Pulizia manuale: checkpoint dei file"; +"Clearing removes checkpoint restore data for previous edits." = "La cancellazione rimuove i dati di ripristino dei checkpoint per le modifiche precedenti."; +"Manual cleanup: saved plans" = "Pulizia manuale: piani salvati"; +"Clearing removes old plan-mode files." = "La cancellazione rimuove i vecchi file della modalità piano."; +"Manual cleanup: debug logs" = "Pulizia manuale: log di debug"; +"Clearing removes past debug logs." = "La cancellazione rimuove i log di debug passati."; +"Manual cleanup: attachment cache" = "Pulizia manuale: cache allegati"; +"Clearing removes cached large pastes or attached images." = "La cancellazione rimuove grandi incolli memorizzati o immagini allegate."; +"Manual cleanup: session metadata" = "Pulizia manuale: metadati sessione"; +"Clearing removes per-session environment metadata." = "La cancellazione rimuove i metadati dell'ambiente per sessione."; +"Manual cleanup: shell snapshots" = "Pulizia manuale: snapshot della shell"; +"Clearing removes leftover runtime shell snapshot files." = "La cancellazione rimuove i file snapshot della shell rimasti in esecuzione."; +"Manual cleanup: legacy todos" = "Pulizia manuale: todo legacy"; +"Clearing removes legacy per-session task lists." = "La cancellazione rimuove i vecchi elenchi attività per sessione."; +"Manual cleanup: sessions" = "Pulizia manuale: sessioni"; +"Clearing removes past Codex session history." = "La cancellazione rimuove la cronologia delle sessioni Codex passate."; +"Manual cleanup: archived sessions" = "Pulizia manuale: sessioni archiviate"; +"Clearing removes archived Codex session history." = "La cancellazione rimuove la cronologia delle sessioni Codex archiviate."; +"Manual cleanup: cache" = "Pulizia manuale: cache"; +"Clearing removes provider-owned cached data." = "La cancellazione rimuove i dati memorizzati appartenenti ai provider."; +"Manual cleanup: logs" = "Pulizia manuale: log"; +"Clearing removes local diagnostic logs." = "La cancellazione rimuove i log diagnostici locali."; +"Manual cleanup: file history" = "Pulizia manuale: cronologia file"; +"Clearing removes local edit checkpoint history." = "La cancellazione rimuove la cronologia locale dei checkpoint di modifica."; +"Manual cleanup: temporary data" = "Pulizia manuale: dati temporanei"; +"Clearing removes local temporary provider data." = "La cancellazione rimuove i dati temporanei locali dei provider."; +"Total: %@" = "Totale: %@"; +"%d more items" = "%d elementi in più"; +"Other (%d items)" = "Altro (%d elementi)"; +"Expand" = "Espandi"; +"Collapse" = "Comprimi"; +"Cleanup ideas" = "Idee di pulizia"; +"%d unreadable item(s) skipped" = "Saltati %d elemento/i illeggibili"; + +"API key limit" = "Limite chiave API"; +"Auth" = "Autenticazione"; +"Auto" = "Automatico"; +"Disabled — no recent data" = "Disattivato — nessun dato recente"; +"Limits not available" = "Limiti non disponibili"; +"No usage yet" = "Nessun utilizzo"; +"Not fetched yet" = "Non ancora recuperato"; +"Refreshing" = "Aggiornamento in corso"; +"Session" = "Sessione"; +"Source" = "Origine"; +"State" = "Stato"; +"Unavailable" = "Non disponibile"; +"Weekly" = "Settimanale"; +"not detected" = "non rilevato"; +"Estimated from local Codex logs for the selected account." = "Stima basata sui log locali di Codex per l'account selezionato."; +"minimax_usage_amount_format" = "Utilizzo: %@ / %@"; +"minimax_used_percent_format" = "Usato %@"; +"minimax_service_text_generation" = "Generazione testo"; +"minimax_service_text_to_speech" = "Sintesi vocale"; +"minimax_service_music_generation" = "Generazione musica"; +"minimax_service_image_generation" = "Generazione immagini"; +"minimax_service_lyrics_generation" = "Generazione testi"; +"minimax_service_coding_plan_vlm" = "Piano di coding VLM"; +"minimax_service_coding_plan_search" = "Ricerca coding plan"; + +/* Additional provider settings and alerts */ +"%@ is waiting for permission" = "%@ è in attesa di autorizzazione"; +"%@ requests" = "%@ richieste"; +"%@: %@ credits" = "%@: %@ crediti"; +"30d requests" = "Richieste 30 g"; +"4 days" = "4 giorni"; +"5 days" = "5 giorni"; +"7 days" = "7 giorni"; +"API key verifies Ollama Cloud access; cookies still expose quota limits." = "La chiave API verifica l'accesso a Ollama Cloud; i cookie espongono comunque i limiti di quota."; +"AWS access key ID. Can also be set with AWS_ACCESS_KEY_ID." = "ID chiave di accesso AWS. Può anche essere impostato con AWS_ACCESS_KEY_ID."; +"AWS region. Can also be set with AWS_REGION." = "Regione AWS. Può anche essere impostata con AWS_REGION."; +"AWS secret access key. Can also be set with AWS_SECRET_ACCESS_KEY." = "Chiave segreta di accesso AWS. Può anche essere impostata con AWS_SECRET_ACCESS_KEY."; +"Access key ID" = "ID chiave di accesso"; +"Add Account" = "Aggiungi account"; +"Adding Account…" = "Aggiunta account…"; +"Antigravity login failed" = "Accesso Antigravity non riuscito"; +"Antigravity login timed out" = "Timeout durante l'accesso Antigravity"; +"Auth source" = "Fonte autenticazione"; +"Automatic imports browser cookies from Xiaomi MiMo." = "Importa automaticamente i cookie del browser da Xiaomi MiMo."; +"Automatic imports Windsurf session data from Chromium browser localStorage." = "Importa automaticamente i dati di sessione Windsurf dal localStorage del browser Chromium."; +"Automatic imports browser cookies from Bailian." = "Importa automaticamente i cookie del browser da Bailian."; +"Automatically imports browser cookies." = "Importa automaticamente i cookie del browser."; +"Automatically imports browser session cookies." = "Importa automaticamente i cookie di sessione del browser."; +"Azure OpenAI deployment name. AZURE_OPENAI_DEPLOYMENT_NAME is also supported." = "Nome del deployment Azure OpenAI. È supportato anche AZURE_OPENAI_DEPLOYMENT_NAME."; +"Azure OpenAI key" = "Chiave Azure OpenAI"; +"Azure OpenAI resource endpoint. AZURE_OPENAI_ENDPOINT is also supported." = "Endpoint della risorsa Azure OpenAI. È supportato anche AZURE_OPENAI_ENDPOINT."; +"Base URL" = "URL di base"; +"Base URL for the LLM-API-Key-Proxy instance." = "URL di base dell'istanza LLM-API-Key-Proxy."; +"Browser cookies" = "Cookie browser"; +"Cap end" = "Fine tetto"; +"Cap start" = "Inizio tetto"; +"Capacity End" = "Fine capacità"; +"Capacity Start" = "Inizio capacità"; +"Changelog" = "Registro modifiche"; +"Choose the Moonshot/Kimi API host for international or China mainland accounts." = "Scegli l'host API Moonshot/Kimi per account internazionali o della Cina continentale."; +"CodexBar can't replace a system account that is signed in with an API key only setup." = "CodexBar non può sostituire un account di sistema autenticato solo tramite configurazione con chiave API."; +"CodexBar could not find saved auth for that account. Re-authenticate it and try again." = "CodexBar non ha trovato l'autenticazione salvata per quell'account. Ri-autenticati e riprova."; +"CodexBar could not read managed account storage. Recover the store before adding another account." = "CodexBar non è riuscito a leggere l'archivio degli account gestiti. Ripristinalo prima di aggiungere un altro account."; +"CodexBar could not read saved auth for that account. Re-authenticate it and try again." = "CodexBar non è riuscito a leggere l'autenticazione salvata per quell'account. Ri-autenticati e riprova."; +"CodexBar could not read the current system account on this Mac." = "CodexBar non è riuscito a leggere l'account di sistema corrente su questo Mac."; +"CodexBar could not replace the live Codex auth on this Mac." = "CodexBar non è riuscito a sostituire l'autenticazione Codex attiva su questo Mac."; +"CodexBar could not safely preserve the current system account before switching." = "CodexBar non è riuscito a conservare in sicurezza l'account di sistema corrente prima del cambio."; +"CodexBar could not save the current system account before switching." = "CodexBar non è riuscito a salvare l'account di sistema corrente prima del cambio."; +"CodexBar could not update managed account storage." = "CodexBar non è riuscito ad aggiornare l'archivio degli account gestiti."; +"CodexBar found another managed account that already uses the current system account. Resolve the duplicate account before switching." = "CodexBar ha trovato un altro account gestito che usa già l'account di sistema corrente. Risolvi il duplicato prima di cambiare."; +"CodexBar will ask macOS Keychain for “%@” so it can decrypt browser cookies and authenticate your account. Click OK to continue." = "CodexBar chiederà al Portachiavi di macOS “%@” per poter decifrare i cookie del browser e autenticare il tuo account. Fai clic su OK per continuare."; +"CodexBar will ask macOS Keychain for the Claude Code OAuth token so it can fetch your Claude usage. Click OK to continue." = "CodexBar chiederà al Portachiavi di macOS il token OAuth di Claude Code per recuperare il tuo utilizzo Claude. Fai clic su OK per continuare."; +"CodexBar will ask macOS Keychain for your Amp cookie header so it can fetch usage. Click OK to continue." = "CodexBar chiederà al Portachiavi di macOS il tuo header Cookie di Amp per recuperare l'utilizzo. Fai clic su OK per continuare."; +"CodexBar will ask macOS Keychain for your Augment cookie header so it can fetch usage. Click OK to continue." = "CodexBar chiederà al Portachiavi di macOS il tuo header Cookie di Augment per recuperare l'utilizzo. Fai clic su OK per continuare."; +"CodexBar will ask macOS Keychain for your Claude cookie header so it can fetch Claude web usage. Click OK to continue." = "CodexBar chiederà al Portachiavi di macOS il tuo header Cookie di Claude per recuperare l'utilizzo web di Claude. Fai clic su OK per continuare."; +"CodexBar will ask macOS Keychain for your Cursor cookie header so it can fetch usage. Click OK to continue." = "CodexBar chiederà al Portachiavi di macOS il tuo header Cookie di Cursor per recuperare l'utilizzo. Fai clic su OK per continuare."; +"CodexBar will ask macOS Keychain for your Factory cookie header so it can fetch usage. Click OK to continue." = "CodexBar chiederà al Portachiavi di macOS il tuo header Cookie di Factory per recuperare l'utilizzo. Fai clic su OK per continuare."; +"CodexBar will ask macOS Keychain for your GitHub Copilot token so it can fetch usage. Click OK to continue." = "CodexBar chiederà al Portachiavi di macOS il tuo token GitHub Copilot per recuperare l'utilizzo. Fai clic su OK per continuare."; +"CodexBar will ask macOS Keychain for your Kimi auth token so it can fetch usage. Click OK to continue." = "CodexBar chiederà al Portachiavi di macOS il tuo token di autenticazione Kimi per recuperare l'utilizzo. Fai clic su OK per continuare."; +"CodexBar will ask macOS Keychain for your MiniMax API token so it can fetch usage. Click OK to continue." = "CodexBar chiederà al Portachiavi di macOS il tuo token API MiniMax per recuperare l'utilizzo. Fai clic su OK per continuare."; +"CodexBar will ask macOS Keychain for your MiniMax cookie header so it can fetch usage. Click OK to continue." = "CodexBar chiederà al Portachiavi di macOS il tuo header Cookie di MiniMax per recuperare l'utilizzo. Fai clic su OK per continuare."; +"CodexBar will ask macOS Keychain for your OpenAI cookie header so it can fetch Codex dashboard extras. Click OK to continue." = "CodexBar chiederà al Portachiavi di macOS il tuo header Cookie OpenAI per recuperare gli extra della dashboard Codex. Fai clic su OK per continuare."; +"CodexBar will ask macOS Keychain for your OpenCode cookie header so it can fetch usage. Click OK to continue." = "CodexBar chiederà al Portachiavi di macOS il tuo header Cookie OpenCode per recuperare l'utilizzo. Fai clic su OK per continuare."; +"CodexBar will ask macOS Keychain for your Synthetic API key so it can fetch usage. Click OK to continue." = "CodexBar chiederà al Portachiavi di macOS la tua chiave API Synthetic per recuperare l'utilizzo. Fai clic su OK per continuare."; +"CodexBar will ask macOS Keychain for your z.ai API token so it can fetch usage. Click OK to continue." = "CodexBar chiederà al Portachiavi di macOS il tuo token API z.ai per recuperare l'utilizzo. Fai clic su OK per continuare."; +"Could not open Cursor login in your browser." = "Impossibile aprire l'accesso Cursor nel browser."; +"Could not open browser for Antigravity" = "Impossibile aprire il browser per Antigravity"; +"Credits used" = "Crediti usati"; +"Day" = "Giorno"; +"Deployment" = "Deployment"; +"Drag to reorder" = "Trascina per riordinare"; +"Sort providers alphabetically" = "Ordina i provider alfabeticamente"; +"Sort providers alphabetically (enabled first)" = "Ordina i provider alfabeticamente (prima quelli attivi)"; +"Sorted alphabetically (enabled first) — click to use your custom order" = "Ordinati alfabeticamente (prima quelli attivi) — fai clic per usare l’ordine personalizzato"; +"Endpoint" = "Endpoint"; +"Enterprise host" = "Host Enterprise"; +"Extra usage balance: %@" = "Saldo uso extra: %@"; +"Keychain Access Required" = "Accesso Portachiavi richiesto"; +"keychain_prompt_learn_more" = "Ulteriori informazioni…"; +"keychain_prompt_privacy_note" = "L'inserimento della password di accesso al Mac è gestito da macOS, non da CodexBar. Puoi disattivare l'accesso al Portachiavi in qualsiasi momento in Impostazioni → Avanzate."; +"Kiro menu bar value" = "Valore barra menu Kiro"; +"Label" = "Etichetta"; +"No organizations loaded. Click Refresh after setting your API key." = "Nessuna organizzazione caricata. Fai clic su Aggiorna dopo aver impostato la chiave API."; +"No output captured." = "Nessun output acquisito."; +"No system account" = "Nessun account di sistema"; +"Oasis-Token" = "Oasis-Token"; +"Open Augment (Log Out & Back In)" = "Apri Augment (Esci e rientra)"; +"Open Codebuff Dashboard" = "Apri dashboard Codebuff"; +"Open Command Code Settings" = "Apri impostazioni Command Code"; +"Open Crof dashboard" = "Apri dashboard Crof"; +"Open Manus" = "Apri Manus"; +"Open MiMo Balance" = "Apri saldo MiMo"; +"Open Moonshot Console" = "Apri console Moonshot"; +"Open Ollama API Keys" = "Apri chiavi API Ollama"; +"Open StepFun Platform" = "Apri piattaforma StepFun"; +"Open T3 Chat Settings" = "Apri impostazioni T3 Chat"; +"Open Volcengine Ark Console" = "Apri console Volcengine Ark"; +"Open legacy provider docs" = "Apri documentazione provider legacy"; +"Open projects" = "Apri progetti"; +"Open this URL manually to continue login:\n\n%@" = "Apri manualmente questo URL per continuare l'accesso:\n\n%@"; +"Optional organization ID for accounts linked to multiple Anthropic organizations." = "ID organizzazione opzionale per account collegati a più organizzazioni Anthropic."; +"Optional. Applies to the configured Admin API key; selected token accounts do not inherit OPENAI_PROJECT_ID." = "Opzionale. Si applica alla chiave API admin configurata; gli account token selezionati non ereditano OPENAI_PROJECT_ID."; +"Optional. Enter your GitHub Enterprise host, for example octocorp.ghe.com. Leave blank for github.com." = "Opzionale. Inserisci il tuo host GitHub Enterprise, ad esempio octocorp.ghe.com. Lascia vuoto per github.com."; +"Optional. Leave blank to discover and aggregate projects visible to the API key." = "Opzionale. Lascia vuoto per rilevare e aggregare i progetti visibili alla chiave API."; +"Org ID (optional)" = "ID org (opzionale)"; +"Organizations" = "Organizzazioni"; +"Organization ID" = "ID organizzazione"; +"Password" = "Password"; +"%@ authentication is disabled." = "L'autenticazione %@ è disabilitata."; +"%@ cookies are disabled." = "I cookie %@ sono disabilitati."; +"%@ web API access is disabled." = "L'accesso web API %@ è disabilitato."; +"Disable %@ dashboard cookie usage." = "Disabilita l'uso dei cookie dashboard %@."; +"Keychain access is disabled in Advanced, so browser cookie import is unavailable." = "L'accesso al portachiavi è disabilitato in Avanzate, quindi l'importazione dei cookie dal browser non è disponibile."; +"Manually paste an %@ from a browser session." = "Incolla manualmente un %@ da una sessione del browser."; +"Paste a Cookie header captured from %@." = "Incolla un header Cookie catturato da %@."; +"Paste a Cookie header from %@." = "Incolla un header Cookie da %@."; +"Paste a Cookie header or cURL capture from %@." = "Incolla un header Cookie o una cattura cURL da %@."; +"Paste a Cookie header or full cURL capture from %@." = "Incolla un header Cookie o una cattura cURL completa da %@."; +"Paste a Cookie or Authorization header from %@." = "Incolla un header Cookie o Authorization da %@."; +"Paste a full cookie header or the %@ value." = "Incolla un header Cookie completo o il valore %@."; +"Paste a Cookie header or full cURL capture from T3 Chat settings." = "Incolla un header Cookie o una cattura cURL completa dalle impostazioni di T3 Chat."; +"Paste the Cookie header from a request to admin.mistral.ai. Must contain an ory_session_* cookie." = "Incolla l'header Cookie da una richiesta a admin.mistral.ai. Deve contenere un cookie ory_session_*."; +"Paste the Oasis-Token from a logged-in browser session on platform.stepfun.com." = "Incolla l'Oasis-Token da una sessione browser autenticata su platform.stepfun.com."; +"Paste the %@ JSON bundle from %@." = "Incolla il bundle JSON %@ da %@."; +"Paste the %@ value or a full Cookie header." = "Incolla il valore %@ o un header Cookie completo."; +"Personal account" = "Account personale"; +"Project ID" = "ID progetto"; +"Re-auth" = "Riautentica"; +"Re-login at claude.ai" = "Accedi di nuovo su claude.ai"; +"Re-authenticating…" = "Riautenticazione…"; +"Refresh Session" = "Aggiorna sessione"; +"Refresh organizations" = "Aggiorna organizzazioni"; +"Region" = "Regione"; +"Reload" = "Ricarica"; +"Reorder" = "Riordina"; +"Secret access key" = "Chiave di accesso segreta"; +"Series" = "Serie"; +"Service" = "Servizio"; +"Show or hide Kiro credits, percent, or both next to the menu bar icon." = "Mostra o nascondi i crediti Kiro, la percentuale o entrambi accanto all'icona della barra menu."; +"Show usage for organizations you belong to. Personal account is always shown." = "Mostra l'utilizzo per le organizzazioni di cui fai parte. L'account personale è sempre mostrato."; +"Sign in to cursor.com in your browser, then refresh Cursor in CodexBar." = "Accedi a cursor.com nel browser, poi aggiorna Cursor in CodexBar."; +"Simulated error text" = "Testo errore simulato"; +"StepFun platform account (phone number or email)." = "Account piattaforma StepFun (numero di telefono o email)."; +"Stored in ~/.codexbar/config.json." = "Memorizzato in ~/.codexbar/config.json."; +"Stored in ~/.codexbar/config.json. AZURE_OPENAI_API_KEY is also supported." = "Memorizzato in ~/.codexbar/config.json. È supportato anche AZURE_OPENAI_API_KEY."; +"Stored in ~/.codexbar/config.json. For the official Kimi API, use Moonshot / Kimi API." = "Memorizzato in ~/.codexbar/config.json. Per l'API ufficiale Kimi, usa Moonshot / Kimi API."; +"Stored in ~/.codexbar/config.json. Get your API key from the Volcengine Ark console." = "Memorizzato in ~/.codexbar/config.json. Ottieni la tua chiave API dalla console Volcengine Ark."; +"Stored in ~/.codexbar/config.json. Get your key from Ollama settings." = "Memorizzato in ~/.codexbar/config.json. Ottieni la tua chiave dalle impostazioni di Ollama."; +"Stored in ~/.codexbar/config.json. Get your key from console.deepgram.com." = "Memorizzato in ~/.codexbar/config.json. Ottieni la tua chiave da console.deepgram.com."; +"Stored in ~/.codexbar/config.json. Get your key from elevenlabs.io/app/settings/api-keys." = "Memorizzato in ~/.codexbar/config.json. Ottieni la tua chiave da elevenlabs.io/app/settings/api-keys."; +"Stored in ~/.codexbar/config.json. Get your key from openrouter.ai/settings/keys and set a key spending limit there to enable API key quota tracking." = "Memorizzato in ~/.codexbar/config.json. Ottieni la tua chiave da openrouter.ai/settings/keys e imposta lì un limite di spesa per abilitare il monitoraggio della quota della chiave API."; +"Stored in ~/.codexbar/config.json. In Warp, open Settings > Platform > API Keys, then create one." = "Memorizzato in ~/.codexbar/config.json. In Warp, apri Impostazioni > Piattaforma > Chiavi API, poi creane una."; +"Stored in ~/.codexbar/config.json. Metrics require Groq Enterprise Prometheus access." = "Memorizzato in ~/.codexbar/config.json. Le metriche richiedono accesso a Groq Enterprise Prometheus."; +"Stored in ~/.codexbar/config.json. OPENAI_ADMIN_KEY is preferred; OPENAI_API_KEY still works." = "Memorizzato in ~/.codexbar/config.json. È preferibile OPENAI_ADMIN_KEY; OPENAI_API_KEY continua a funzionare."; +"Stored in ~/.codexbar/config.json. Requires an Anthropic Admin API key." = "Memorizzato in ~/.codexbar/config.json. Richiede una chiave Anthropic Admin API."; +"Stored in ~/.codexbar/config.json. Used for /v1/quota-stats." = "Memorizzato in ~/.codexbar/config.json. Usato per /v1/quota-stats."; +"Stored in ~/.codexbar/config.json. You can also provide CODEBUFF_API_KEY or let CodexBar read ~/.config/manicode/credentials.json (created by `codebuff login`)." = "Memorizzato in ~/.codexbar/config.json. Puoi anche fornire CODEBUFF_API_KEY o lasciare che CodexBar legga ~/.config/manicode/credentials.json (creato da `codebuff login`)."; +"Stored in ~/.codexbar/config.json. You can also provide CROF_API_KEY." = "Memorizzato in ~/.codexbar/config.json. Puoi anche fornire CROF_API_KEY."; +"Stored in ~/.codexbar/config.json. You can also provide KILO_API_KEY or ~/.local/share/kilo/auth.json (kilo.access)." = "Memorizzato in ~/.codexbar/config.json. Puoi anche fornire KILO_API_KEY o ~/.local/share/kilo/auth.json (kilo.access)."; +"T3 Chat cookie" = "Cookie T3 Chat"; +"Team mode" = "Modalità team"; +"That account is no longer available in CodexBar. Refresh the account list and try again." = "Quell'account non è più disponibile in CodexBar. Aggiorna l'elenco account e riprova."; +"The browser login did not complete in time. Try Antigravity login again." = "L'accesso nel browser non è stato completato in tempo. Riprova l'accesso ad Antigravity."; +"Timed out waiting for Cursor login. %@" = "Timeout in attesa dell'accesso Cursor. %@"; +"Timed out waiting for Cursor login. %@ Last error: %@" = "Timeout in attesa dell'accesso Cursor. %@ Ultimo errore: %@"; +"Today requests" = "Richieste di oggi"; +"Total (30d): %@ credits" = "Totale (30 g): %@ crediti"; +"Username" = "Nome utente"; +"Uses username + password to login and obtain an Oasis-Token automatically." = "Usa nome utente e password per accedere e ottenere automaticamente un Oasis-Token."; +"Uses username + password to login and obtain an %@ automatically." = "Usa nome utente e password per accedere e ottenere automaticamente un %@."; +"Utilization End" = "Fine utilizzo"; +"Utilization Start" = "Inizio utilizzo"; +"Verbosity" = "Verbosità"; +"Windsurf session JSON bundle" = "Bundle JSON sessione Windsurf"; +"Workspace ID" = "ID workspace"; +"Your StepFun platform password. Used to login and obtain a session token." = "La tua password della piattaforma StepFun. Usata per accedere e ottenere un token di sessione."; +"claude /login exited with status %d." = "claude /login è terminato con stato %d."; +"codex login exited with status %d." = "codex login è terminato con stato %d."; +"Cookie: …\n\nor paste a cURL capture from the Abacus AI dashboard" = "Cookie: …\n\noppure incolla una cattura cURL dalla dashboard di Abacus AI"; +"Cookie: …\n\nor paste the __Secure-next-auth.session-token value" = "Cookie: …\n\noppure incolla il valore di __Secure-next-auth.session-token"; +"Cookie: …\n\nor paste the kimi-auth token value" = "Cookie: …\n\noppure incolla il valore del token kimi-auth"; +"session_id=...\n\nor paste just the session_id value" = "session_id=...\n\noppure incolla solo il valore di session_id"; +"Clear" = "Cancella"; +"No matching providers" = "Nessun provider corrispondente"; +"Search providers" = "Cerca provider"; + +"Request quota: %@ / %@" = "Quota richieste: %@ / %@"; +"language_arabic" = "العربية"; +"language_persian" = "فارسی"; +"language_thai" = "ไทย"; +"language_galician" = "Galego"; +"Limit Reset Credits" = "Crediti di reimpostazione limite"; +"1 available" = "1 disponibile"; +"%d available" = "%d disponibili"; +"Next expires %@" = "La prossima scade %@"; +"Expires %@" = "Scade %@"; +"No expiry" = "Nessuna scadenza"; +"byte_unit_byte" = "byte"; +"byte_unit_bytes" = "byte"; +"byte_unit_kilobyte" = "kilobyte"; +"byte_unit_kilobytes" = "kilobyte"; +"byte_unit_megabyte" = "megabyte"; +"byte_unit_megabytes" = "megabyte"; +"byte_unit_gigabyte" = "gigabyte"; +"byte_unit_gigabytes" = "gigabyte"; + +/* Settings sidebar redesign */ +"Enable" = "Attiva"; +"Disable" = "Disattiva"; +"providers_on_count" = "%d attivi"; +"section_cost_summary" = "Riepilogo costi"; +"section_command_line" = "Riga di comando"; +"section_privacy" = "Privacy"; +"section_diagnostics" = "Diagnostica"; +"section_updates" = "Aggiornamenti"; +"section_links" = "Link"; +"Show Codex Spark usage" = "Mostra l’utilizzo di Codex Spark"; +"Shows Codex Spark quota rows in the menu and provider preview. Requires optional credits and extra usage in Display settings." = "Mostra le righe della quota Codex Spark nel menu e nell’anteprima del provider. Richiede di attivare «Mostra crediti + uso extra» nelle impostazioni Aspetto."; +"Show Daily Routines usage" = "Mostra l’utilizzo di Routine quotidiane"; +"Shows the Daily Routines quota row in the menu and provider preview. Requires optional credits and extra usage in Display settings." = "Mostra la riga della quota Routine quotidiane nel menu e nell’anteprima del provider. Richiede di attivare «Mostra crediti + uso extra» nelle impostazioni Aspetto."; +"Scroll to see more models" = "Scorri per vedere altri modelli"; + +/* Shareable usage card */ +"Copy Image" = "Copia immagine"; +"Copy Stats" = "Copia statistiche"; +"Could not copy image" = "Impossibile copiare l'immagine"; +"Image copied" = "Immagine copiata"; +"Image saved" = "Immagine salvata"; +"Nothing is uploaded. This image is created on your Mac." = "Nessun dato viene caricato. Questa immagine viene creata sul tuo Mac."; +"Save..." = "Salva..."; +"Share AI Usage" = "Condividi utilizzo IA"; +"Share Stats…" = "Condividi statistiche…"; +"Stats copied" = "Statistiche copiate"; +"Finish switching to a different Cursor account in your browser, then try again." = "Completa il passaggio a un altro account Cursor nel browser, quindi riprova."; +"Timed out waiting for Cursor account switch. %@" = "Tempo scaduto in attesa del cambio di account Cursor. %@"; +"Timed out waiting for Cursor account switch. %@ Last error: %@" = "Tempo scaduto in attesa del cambio di account Cursor. %@ Ultimo errore: %@"; + +/* Spend dashboard */ +"tab_usage_spend" = "Utilizzo e spesa"; +"Usage & Spend" = "Utilizzo e spesa"; +"Local estimated cost history across supported providers." = "Cronologia locale dei costi stimati per i provider supportati."; +"Time range" = "Intervallo di tempo"; +"Track costs" = "Tieni traccia dei costi"; +"Cost tracking is off" = "Il monitoraggio dei costi è disattivato"; +"Turn on Track costs to build local estimates." = "Attiva «Tieni traccia dei costi» per creare stime locali."; +"No local cost history yet" = "Ancora nessuna cronologia locale dei costi"; +"Turn on cost tracking or refresh after using a supported provider." = "Attiva il monitoraggio dei costi o aggiorna dopo aver usato un provider supportato."; +"Refresh failures" = "Aggiornamenti non riusciti"; +"Native currencies stay separate; Codex account rows exclude Pi session history." = "Le valute originali rimangono separate; le righe degli account Codex escludono la cronologia delle sessioni Pi."; +"Spend unavailable" = "Spesa non disponibile"; +"Model breakdown unavailable" = "Ripartizione per modello non disponibile"; +"Local estimated history" = "Cronologia locale stimata"; +"Coverage" = "Copertura"; +"Estimated spend" = "Spesa stimata"; +"Tracked tokens" = "Token tracciati"; +"Subscriptions" = "Abbonamenti"; +"By subscription" = "Per abbonamento"; +"No model-level history" = "Nessuna cronologia a livello di modello"; +"Daily estimated spend" = "Spesa giornaliera stimata"; +"≈%d full 5h windows of weekly left · %d windows until reset" = "≈%d finestre complete da 5 h di quota settimanale · %d finestre al reset"; +"Weekly cannot run out before reset at this pace" = "La quota settimanale non può esaurirsi prima del reset a questo ritmo"; +"Weekly can run out ≈%d windows early" = "La quota settimanale può esaurirsi ≈%d finestre prima"; +"Estimated: %@" = "Stima: %@"; +"session_quota_estimate_value_format" = "%1$@ %2$@"; +"session quota" = "quota di sessione"; +"session quotas" = "quote di sessione"; +"Coding Plan" = "Piano di codifica"; +"Agent Plan" = "Piano agente"; +"Team" = "Squadra"; + +/* Menu bar layout editor */ +"menu_bar_layout_title" = "Disposizione"; +"menu_bar_layout_footer" = "Trascina i token per disporre la barra dei menu. Fai clic su un token per aggiungerlo; seleziona un token posizionato e premi Canc per rimuoverlo."; +"menu_bar_layout_group_identity" = "Identità"; +"menu_bar_layout_group_usage" = "Utilizzo"; +"menu_bar_layout_group_time" = "Tempo"; +"menu_bar_layout_group_money" = "Costo"; +"menu_bar_layout_group_structure" = "Struttura"; +"menu_bar_layout_scope_all" = "Tutti i provider"; +"menu_bar_layout_scope_help" = "Modifica la disposizione predefinita o sostituiscila per un provider."; +"menu_bar_layout_use_all" = "Usa la disposizione di tutti i provider"; +"menu_bar_layout_preset" = "Preset disposizione"; +"menu_bar_layout_preset_icon_percent" = "Icona e percentuale"; +"menu_bar_layout_preset_icon_only" = "Solo icona"; +"menu_bar_layout_preset_percent_reset" = "Percentuale e ripristino"; +"menu_bar_layout_preset_compact_stacked" = "Compatto su due righe"; +"menu_bar_layout_preset_custom" = "Personalizzato"; +"menu_bar_layout_live_preview" = "Anteprima dal vivo"; +"menu_bar_layout_strip" = "Striscia della barra dei menu"; +"menu_bar_layout_remove_line_break" = "Rimuovi interruzione di riga"; +"menu_bar_layout_chip_hint" = "Seleziona, trascina per riordinare o usa l’azione Rimuovi."; +"menu_bar_layout_palette_hint" = "Fai clic per aggiungere o trascina nella disposizione."; +"menu_bar_layout_empty_line" = "Rilascia qui un token"; +"menu_bar_layout_line" = "Riga %d"; +"menu_bar_layout_drag_remove" = "Trascina qui per rimuovere"; +"menu_bar_layout_size" = "Dimensione"; +"menu_bar_layout_size_small" = "Piccola"; +"menu_bar_layout_size_regular" = "Normale"; +"menu_bar_layout_gap" = "Spaziatura"; +"menu_bar_layout_gap_tight" = "Stretta"; +"menu_bar_layout_gap_regular" = "Normale"; +"menu_bar_layout_keyboard_hint" = "Canc rimuove il token selezionato"; +"menu_bar_layout_sample_account" = "account"; +"menu_bar_layout_sample_runs_out" = "termina ven."; +"menu_bar_layout_token_icon" = "Icona"; +"menu_bar_layout_token_provider" = "Nome provider"; +"menu_bar_layout_token_account" = "Account"; +"menu_bar_layout_token_session" = "Sessione %"; +"menu_bar_layout_token_weekly" = "Settimanale %"; +"menu_bar_layout_token_auto" = "% automatica"; +"menu_bar_layout_token_bar" = "Barra utilizzo"; +"menu_bar_layout_token_resets_in" = "Ripristino tra"; +"menu_bar_layout_token_reset_at" = "Ripristino alle"; +"menu_bar_layout_token_runs_out" = "Termina"; +"menu_bar_layout_token_cost_today" = "Costo oggi"; +"menu_bar_layout_token_cost_30d" = "Costo 30 gg"; +"menu_bar_layout_token_space" = "Spazio"; +"menu_bar_layout_token_line_break" = "Interruzione di riga"; +"menu_bar_layout_token_separator_accessibility" = "Punto separatore"; + +/* Menu bar layout accessibility */ +"Icon unavailable" = "Icona: Non disponibile"; +"%@ icon" = "%@: Icona"; +"Provider name unavailable" = "Nome provider: Non disponibile"; +"Account unavailable" = "Account: Non disponibile"; +"%@ unavailable" = "%@: Non disponibile"; +"%@ %@" = "%@ %@"; +"Usage bar unavailable" = "Barra utilizzo: Non disponibile"; +"Usage bar, %d of 3 filled" = "Barra utilizzo: %d/3 pieni"; +"Reset countdown unavailable" = "Ripristino tra: Non disponibile"; +"Reset time unavailable" = "Ripristino alle: Non disponibile"; +"Run-out estimate unavailable" = "Termina: Non disponibile"; +"Cost today unavailable" = "Costo oggi: Non disponibile"; +"30-day cost unavailable" = "Costo 30 gg: Non disponibile"; +"Resets" = "Ripristini"; diff --git a/Sources/CodexBar/Resources/it.lproj/Localizable.stringsdict b/Sources/CodexBar/Resources/it.lproj/Localizable.stringsdict new file mode 100644 index 0000000000..c68dc50f95 --- /dev/null +++ b/Sources/CodexBar/Resources/it.lproj/Localizable.stringsdict @@ -0,0 +1,49 @@ + + + + + ≈%d full 5h windows of weekly left · %d windows until reset + + NSStringLocalizedFormatKey + %#@left@ · %#@until@ + left + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + d + one + ≈%d finestra completa da 5 h di quota settimanale + other + ≈%d finestre complete da 5 h di quota settimanale + + until + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + d + one + %d finestra al reset + other + %d finestre al reset + + + Weekly can run out ≈%d windows early + + NSStringLocalizedFormatKey + %#@early@ + early + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + d + one + La quota settimanale può esaurirsi ≈%d finestra prima + other + La quota settimanale può esaurirsi ≈%d finestre prima + + + + diff --git a/Sources/CodexBar/Resources/ja.lproj/Localizable.strings b/Sources/CodexBar/Resources/ja.lproj/Localizable.strings new file mode 100644 index 0000000000..67a0a56f38 --- /dev/null +++ b/Sources/CodexBar/Resources/ja.lproj/Localizable.strings @@ -0,0 +1,1354 @@ +/* Japanese localization for CodexBar */ + +"tab_hooks" = "フック"; +"hooks_enable_title" = "フックを有効にする"; +"hooks_enable_subtitle" = "クォータまたはプロバイダーのイベント発生時に外部コマンドを実行します。"; +"hooks_trust_warning" = "フックはMac上でローカルコマンドを実行できます。信頼できるコマンドのみ設定してください。"; +"hooks_rules_header" = "ルール"; +"hooks_empty" = "フックは設定されていません。"; +"hooks_add_rule" = "ルールを追加"; +"hooks_delete_rule" = "ルールを削除"; +"hooks_rule_enabled" = "有効"; +"hooks_event" = "イベント"; +"hooks_provider" = "プロバイダー"; +"hooks_any_provider" = "すべてのプロバイダー"; +"hooks_threshold" = "使用率 ≥ で実行"; +"hooks_threshold_placeholder" = "90"; +"hooks_executable_placeholder" = "/usr/local/bin/my-command"; +"hooks_arguments_placeholder" = "引数"; +"hooks_argument_placeholder" = "引数"; +"hooks_add_argument" = "引数を追加"; +"hooks_delete_argument" = "引数を削除"; + +"ollama_safari_cookie_access_hint" = "Safari Cookie を読み込むには、CodexBar にフルディスクアクセスが必要です(システム設定 > プライバシーとセキュリティ)。"; +"ollama_browser_cookie_decryption_denied" = "%@ Cookie の復号がキーチェーンで拒否されました。手動で更新して再試行してください。"; +"ollama_browser_cookie_decryption_disabled" = "%@ Cookie の復号が CodexBar で無効です。キーチェーンへのアクセスを有効にして更新してください。"; + +" providers" = " 件のプロバイダ"; +"(System)" = "(システム)"; +"30d" = "30日"; +"7d" = "7日"; +"A managed Codex login is already running. Wait for it to finish before adding " = "管理対象の Codex ログインがすでに実行中です。完了を待ってから追加してください "; +"API key" = "API キー"; +"API region" = "API リージョン"; +"API token" = "API トークン"; +"API tokens" = "API トークン"; +"About" = "このアプリについて"; +"Account" = "アカウント"; +"Accounts" = "アカウント"; +"Accounts subtitle" = "アカウントのサブタイトル"; +"Active" = "アクティブ"; +"Add" = "追加"; +"Add Workspace" = "ワークスペースを追加"; +"Advanced" = "詳細"; +"All" = "すべて"; +"Always allow prompts" = "常にプロンプトを許可"; +"Animation pattern" = "アニメーションパターン"; +"Antigravity login is managed in the app" = "Antigravity のログインはアプリ内で管理されます"; +"Applies only to the Security.framework OAuth keychain reader." = "Security.framework の OAuth キーチェーンリーダーにのみ適用されます。"; +"Auto falls back to the next source if the preferred one fails." = "自動では、優先ソースが失敗した場合に次のソースへフォールバックします。"; +"Auto uses API first, then falls back to CLI on auth failures." = "自動では、まず API を使用し、認証に失敗した場合は CLI にフォールバックします。"; +"Auto-detect" = "自動検出"; +"Auto-refresh is off; use the menu's Refresh command." = "自動更新はオフです。メニューの「更新」コマンドを使用してください。"; +"Auto-refresh: hourly · Timeout: 10m" = "自動更新: 1時間ごと · タイムアウト: 10分"; +"Automatic" = "自動"; +"Automatic imports browser cookies and WorkOS tokens." = "自動では、ブラウザの Cookie と WorkOS トークンを読み込みます。"; +"Automatic imports browser cookies and local storage tokens." = "自動では、ブラウザの Cookie とローカルストレージのトークンを読み込みます。"; +"Automatic imports browser cookies for dashboard extras." = "自動では、ダッシュボードの追加情報用にブラウザの Cookie を読み込みます。"; +"Automatic imports browser cookies for the web API." = "自動では、Web API 用にブラウザの Cookie を読み込みます。"; +"Automatic imports browser cookies from Model Studio/Bailian." = "自動では、Model Studio/Bailian からブラウザの Cookie を読み込みます。"; +"Automatic imports browser cookies from admin.mistral.ai." = "自動では、admin.mistral.ai からブラウザの Cookie を読み込みます。"; +"Automatic imports browser cookies from opencode.ai." = "自動では、opencode.ai からブラウザの Cookie を読み込みます。"; +"Automatic imports browser cookies or stored sessions." = "自動では、ブラウザの Cookie または保存済みセッションを読み込みます。"; +"Automatic imports browser cookies." = "自動では、ブラウザの Cookie を読み込みます。"; +"Automatically imports browser session cookie." = "ブラウザのセッション Cookie を自動的に読み込みます。"; +"Automatically opens CodexBar when you start your Mac." = "Mac の起動時に CodexBar を自動的に開きます。"; +"Automation" = "オートメーション"; +"Average (\\(label1) + \\(label2))" = "平均 (\\(label1) + \\(label2))"; +"Average (\\(metadata.sessionLabel) + \\(metadata.weeklyLabel))" = "平均 (\\(metadata.sessionLabel) + \\(metadata.weeklyLabel))"; +"Avoid Keychain prompts" = "キーチェーンのプロンプトを回避"; +"Balance" = "残高"; +"Battery Saver" = "バッテリーセーバー"; +"Bordered" = "枠線あり"; +"Build" = "ビルド"; +"Built \\(buildTimestamp)" = "ビルド日時 \\(buildTimestamp)"; +"Buy Credits..." = "クレジットを購入..."; +"Buy Credits…" = "クレジットを購入…"; +"CLI paths" = "CLI パス"; +"CLI sessions" = "CLI セッション"; +"Caches" = "キャッシュ"; +"Cancel" = "キャンセル"; +"Check for Updates…" = "アップデートを確認…"; +"Check for updates automatically" = "アップデートを自動的に確認"; +"Check if you like your agents having some fun up there." = "エージェントがメニューバーで楽しく動き回るのがお好みならチェックしてください。"; +"Check provider status" = "プロバイダの状態を確認"; +"Choose Codex workspace" = "Codex ワークスペースを選択"; +"Choose the MiniMax host (global .io or China mainland .com)." = "MiniMax のホストを選択します(グローバルの .io または中国本土の .com)。"; +"Choose up to " = "選択可能数: 最大 "; +"Choose up to \\(Self.maxOverviewProviders) providers" = "最大 \\(Self.maxOverviewProviders) 件のプロバイダを選択"; +"Choose up to \\(count) providers" = "最大 \\(count) 件のプロバイダを選択"; +"Choose what to show in the menu bar (Pace shows usage vs. expected)." = "メニューバーに表示する内容を選択します(ペースは想定に対する使用量を表示します)。"; +"Choose which Codex account CodexBar should follow." = "CodexBar が追跡する Codex アカウントを選択します。"; +"Choose which window drives the menu bar percent." = "メニューバーのパーセント表示に使用するウインドウを選択します。"; +"Chrome" = "Chrome"; +"Claude CLI not found" = "Claude CLI が見つかりません"; +"Claude binary" = "Claude バイナリ"; +"Claude cookies" = "Claude の Cookie"; +"Claude login failed" = "Claude のログインに失敗しました"; +"Claude login timed out" = "Claude のログインがタイムアウトしました"; +"Close" = "閉じる"; +"Code review" = "コードレビュー"; +"Codex CLI not found" = "Codex CLI が見つかりません"; +"Codex account login already running" = "Codex アカウントのログインがすでに実行中です"; +"Codex binary" = "Codex バイナリ"; +"Codex login failed" = "Codex のログインに失敗しました"; +"Codex login timed out" = "Codex のログインがタイムアウトしました"; +"CodexBar Lifecycle Keepalive" = "CodexBar ライフサイクルキープアライブ"; +"CodexBar can't show its menu bar icon" = "CodexBar はメニューバーアイコンを表示できません"; +"CodexBar could not read managed account storage. " = "CodexBar は管理対象アカウントのストレージを読み取れませんでした。"; +"Configure…" = "設定…"; +"Connected" = "接続済み"; +"Controls how much detail is logged." = "記録するログの詳細度を制御します。"; +"Cookie header" = "Cookie ヘッダー"; +"Cookie source" = "Cookie ソース"; +"Cookie: ..." = "Cookie: ..."; +"Cookie: \\u{2026}\\\n\\\nor paste a cURL capture from the Abacus AI dashboard" = "Cookie: \\u{2026}\\\n\\\nまたは Abacus AI ダッシュボードからの cURL キャプチャを貼り付けてください"; +"Cookie: \\u{2026}\\\n\\\nor paste the __Secure-next-auth.session-token value" = "Cookie: \\u{2026}\\\n\\\nまたは __Secure-next-auth.session-token の値を貼り付けてください"; +"Cookie: \\u{2026}\\\n\\\nor paste the kimi-auth token value" = "Cookie: \\u{2026}\\\n\\\nまたは kimi-auth トークンの値を貼り付けてください"; +"Cookie: …" = "Cookie: …"; +"CopilotDeviceFlow" = "CopilotDeviceFlow"; +"Cost" = "コスト"; +"Could not add Codex account" = "Codex アカウントを追加できませんでした"; +"Could not open Terminal for Gemini" = "Gemini 用のターミナルを開けませんでした"; +"Could not start claude /login" = "claude /login を開始できませんでした"; +"Could not start codex login" = "codex login を開始できませんでした"; +"Could not switch system account" = "システムアカウントを切り替えられませんでした"; +"Credits" = "クレジット"; +"Individual credits" = "個人クレジット"; +"Workspace" = "ワークスペース"; +"Credits history" = "クレジット履歴"; +"Cursor login failed" = "Cursor のログインに失敗しました"; +"Custom" = "カスタム"; +"Custom Path" = "カスタムパス"; +"Daily Routines" = "デイリールーティン"; +"Debug" = "デバッグ"; +"Default" = "デフォルト"; +"Disable Keychain access" = "キーチェーンへのアクセスを無効にする"; +"Disabled" = "無効"; +"Dismiss" = "閉じる"; +"Disconnected" = "未接続"; +"Display" = "表示"; +"Display mode" = "表示モード"; +"Display reset times as absolute clock values instead of countdowns." = "リセット時刻をカウントダウンではなく絶対時刻で表示します。"; +"Done" = "完了"; +"Effective PATH" = "有効な PATH"; +"Email" = "メールアドレス"; +"Enable Merge Icons to configure Overview tab providers." = "「アイコンを統合」を有効にすると、概要タブのプロバイダを設定できます。"; +"Enable file logging" = "ファイルへのログ記録を有効にする"; +"Enabled" = "有効"; +"Error" = "エラー"; +"Error simulation" = "エラーシミュレーション"; +"Expose troubleshooting tools in the Debug tab." = "デバッグタブにトラブルシューティングツールを表示します。"; +"Failed" = "失敗"; +"False" = "False"; +"Fetch strategy attempts" = "取得戦略の試行"; +"Fetching" = "取得中"; +"Field" = "フィールド"; +"Field subtitle" = "フィールドのサブタイトル"; +"Finish the current managed account change before switching the system account." = "システムアカウントを切り替える前に、現在の管理対象アカウントの変更を完了してください。"; +"Force animation on next refresh" = "次回の更新時にアニメーションを強制実行"; +"Gateway region" = "ゲートウェイリージョン"; +"Gemini CLI not found" = "Gemini CLI が見つかりません"; +"Gemini/Antigravity, surfacing incidents in the icon and menu." = "Gemini/Antigravity の障害情報をアイコンとメニューに表示します。"; +"General" = "一般"; +"GitHub" = "GitHub"; +"GitHub Copilot Login" = "GitHub Copilot ログイン"; +"GitHub Login" = "GitHub ログイン"; +"Hide details" = "詳細を非表示"; +"Hide personal information" = "個人情報を非表示"; +"Historical tracking" = "履歴トラッキング"; +"How often CodexBar polls providers in the background." = "CodexBar がバックグラウンドでプロバイダをポーリングする頻度です。"; +"Inactive" = "非アクティブ"; +"Install CLI" = "CLI をインストール"; +"Install the Claude CLI (npm i -g @anthropic-ai/claude-code) and try again." = "Claude CLI をインストールして(npm i -g @anthropic-ai/claude-code)、もう一度お試しください。"; +"Install the Codex CLI (npm i -g @openai/codex) and try again." = "Codex CLI をインストールして(npm i -g @openai/codex)、もう一度お試しください。"; +"Install the Gemini CLI (npm i -g @google/gemini-cli) and try again." = "Gemini CLI をインストールして(npm i -g @google/gemini-cli)、もう一度お試しください。"; +"JetBrains AI is ready" = "JetBrains AI の準備ができました"; +"JetBrains IDE" = "JetBrains IDE"; +"Keep CLI sessions alive" = "CLI セッションを維持する"; +"Keyboard shortcut" = "キーボードショートカット"; +"Keychain access" = "キーチェーンへのアクセス"; +"Keychain prompt policy" = "キーチェーンのプロンプトポリシー"; +"Last \\(name) fetch failed:" = "前回の \\(name) の取得に失敗しました:"; +"Last \\(self.store.metadata(for: self.provider).displayName) fetch failed:" = "前回の \\(self.store.metadata(for: self.provider).displayName) の取得に失敗しました:"; +"Last attempt" = "最終試行"; +"Link" = "リンク"; +"Loading animations" = "読み込み中アニメーション"; +"Loading…" = "読み込み中…"; +"Local" = "ローカル"; +"Logging" = "ログ"; +"Login failed" = "ログインに失敗しました"; +"Login shell PATH (startup capture)" = "ログインシェルの PATH(起動時に取得)"; +"Login timed out" = "ログインがタイムアウトしました"; +"MCP details" = "MCP の詳細"; +"Managed Codex accounts unavailable" = "管理対象の Codex アカウントを利用できません"; +"Managed account storage is unreadable. Live account access is still available, " = "管理対象アカウントのストレージを読み取れません。ライブアカウントへのアクセスは引き続き利用できます。"; +"Manual" = "手動"; +"May your tokens never run out—keep agent limits in view." = "トークンが尽きませんように — エージェントの上限を常に見守りましょう。"; +"Menu bar" = "メニューバー"; +"Menu bar auto-shows the provider closest to its rate limit." = "メニューバーには、レート制限に最も近いプロバイダが自動的に表示されます。"; +"Menu bar metric" = "メニューバーの指標"; +"Menu bar shows percent" = "メニューバーにパーセントを表示"; +"Menu content" = "メニューの内容"; +"Merge Icons" = "アイコンを統合"; +"Never prompt" = "プロンプトを表示しない"; +"No" = "いいえ"; +"No Codex accounts detected yet." = "Codex アカウントはまだ検出されていません。"; +"No JetBrains IDE detected" = "JetBrains IDE が検出されません"; +"No cost history data." = "コスト履歴データがありません。"; +"No data available" = "データがありません"; +"No data yet" = "まだデータがありません"; +"No enabled providers available for Overview." = "概要に表示できる有効なプロバイダがありません。"; +"No providers selected" = "プロバイダが選択されていません"; +"No token accounts yet." = "トークンアカウントはまだありません。"; +"No usage breakdown data." = "使用量の内訳データがありません。"; +"None" = "なし"; +"Notifications" = "通知"; +"Notifies when the 5-hour session quota hits 0% and when it becomes " = "5時間セッションのクォータが 0% になったとき、および再び利用可能になったときに通知します "; +"OK" = "OK"; +"Obscure email addresses in the menu bar and menu UI." = "メニューバーとメニュー UI でメールアドレスを伏せ字にします。"; +"Off" = "オフ"; +"Offline" = "オフライン"; +"On" = "オン"; +"Online" = "オンライン"; +"Only on user action" = "ユーザー操作時のみ"; +"Open" = "開く"; +"Open API Keys" = "API キーを開く"; +"Open Amp Settings" = "Amp の設定を開く"; +"Open Antigravity to sign in, then refresh CodexBar." = "Antigravity を開いてサインインしてから、CodexBar を更新してください。"; +"Open Browser" = "ブラウザを開く"; +"Open Coding Plan" = "コーディングプランを開く"; +"Open Console" = "コンソールを開く"; +"Open Dashboard" = "ダッシュボードを開く"; +"Open Mistral Admin" = "Mistral 管理画面を開く"; +"Open Menu Bar Settings" = "メニューバー設定を開く"; +"Open Ollama Settings" = "Ollama の設定を開く"; +"Open Terminal" = "ターミナルを開く"; +"Open Usage Page" = "使用状況ページを開く"; +"Open Warp API Key Guide" = "Warp API キーガイドを開く"; +"Open menu" = "メニューを開く"; +"Open token file" = "トークンファイルを開く"; +"OpenAI cookies" = "OpenAI の Cookie"; +"OpenAI web extras" = "OpenAI Web 追加情報"; +"Option A" = "オプション A"; +"Option B" = "オプション B"; +"Optional override if workspace lookup fails." = "ワークスペースの検索に失敗した場合の任意の上書き設定です。"; +"Options" = "オプション"; +"Override auto-detection with a custom IDE base path" = "カスタムの IDE ベースパスで自動検出を上書き"; +"Overview" = "概要"; +"Overview rows always follow provider order." = "概要の行は常にプロバイダの順序に従います。"; +"Overview tab providers" = "概要タブのプロバイダ"; +"Paste API key…" = "API キーを貼り付け…"; +"Paste API token…" = "API トークンを貼り付け…"; +"Paste key…" = "キーを貼り付け…"; +"Paste sessionKey or OAuth token…" = "sessionKey または OAuth トークンを貼り付け…"; +"Paste the Cookie header from a request to admin.mistral.ai. " = "admin.mistral.ai へのリクエストの Cookie ヘッダーを貼り付けてください。"; +"Paste token…" = "トークンを貼り付け…"; +"Personal" = "個人"; +"Picker" = "ピッカー"; +"Picker subtitle" = "ピッカーのサブタイトル"; +"Placeholder" = "プレースホルダ"; +"Plan" = "プラン"; +"Plan Usage" = "プラン使用状況"; +"Play full-screen confetti when weekly usage resets." = "週間使用量がリセットされたときに全画面の紙吹雪を表示します。"; +"Polls OpenAI/Claude status pages and Google Workspace for " = "OpenAI/Claude のステータスページと Google Workspace をポーリングして、"; +"Prevents any Keychain access while enabled." = "有効にすると、キーチェーンへのアクセスをすべて防ぎます。"; +"Primary (API key limit)" = "プライマリ(API キー上限)"; +"Primary (\\(label))" = "プライマリ (\\(label))"; +"Primary (\\(metadata.sessionLabel))" = "プライマリ (\\(metadata.sessionLabel))"; +"Probe logs" = "プローブログ"; +"Progress bars fill as you consume quota (instead of showing remaining)." = "プログレスバーは(残量表示ではなく)クォータの消費に応じて満たされていきます。"; +"Provider" = "プロバイダ"; +"Providers" = "プロバイダ"; +"Quit CodexBar" = "CodexBar を終了"; +"Random (default)" = "ランダム(デフォルト)"; +"Reads local usage logs. Shows today + last 30 days cost in the menu." = "ローカルの使用ログを読み取り、今日のコストと選択した履歴期間のコストをメニューに表示します。"; +"Refresh" = "更新"; +"Refresh cadence" = "更新間隔"; +"Remote" = "リモート"; +"Remove" = "削除"; +"Remove Codex account?" = "Codex アカウントを削除しますか?"; +"Remove \\(account.email) from CodexBar? Its managed Codex home will be deleted." = "\\(account.email) を CodexBar から削除しますか?管理対象の Codex ホームは削除されます。"; +"Remove \\(email) from CodexBar? Its managed Codex home will be deleted." = "\\(email) を CodexBar から削除しますか?管理対象の Codex ホームは削除されます。"; +"Remove selected account" = "選択したアカウントを削除"; +"Replace critter bars with provider branding icons and a percentage." = "クリッターバーをプロバイダのブランドアイコンとパーセント表示に置き換えます。"; +"Replay selected animation" = "選択したアニメーションを再生"; +"Requires authentication via GitHub Device Flow." = "GitHub Device Flow による認証が必要です。"; +"Resets: \\(reset)" = "リセット: \\(reset)"; +"Rolling five-hour limit" = "5時間のローリング上限"; +"Search hourly" = "1時間ごとに検索"; +"Secondary (\\(label))" = "セカンダリ (\\(label))"; +"Secondary (\\(metadata.weeklyLabel))" = "セカンダリ (\\(metadata.weeklyLabel))"; +"Select a provider" = "プロバイダを選択"; +"Select the IDE to monitor" = "監視する IDE を選択"; +"Session quota notifications" = "セッションクォータ通知"; +"Session tokens" = "セッショントークン"; +"provider_section_connection" = "接続"; +"provider_section_menu_bar" = "メニューバー"; +"Show Codex Credits and Claude Extra usage sections in the menu." = "Codex クレジットと Claude 追加使用量のセクションをメニューに表示します。"; +"Show Debug Settings" = "デバッグ設定を表示"; +"Show all token accounts" = "すべてのトークンアカウントを表示"; +"Show cost summary" = "コスト概要を表示"; +"Show credits + extra usage" = "クレジットと追加使用量を表示"; +"Show details" = "詳細を表示"; +"Show most-used provider" = "最も使用中のプロバイダを表示"; +"Show provider icons in the switcher (otherwise show a weekly progress line)." = "切替バーにプロバイダのアイコンを表示します(オフの場合は週間進捗ラインを表示します)。"; +"Show reset time as clock" = "リセット時刻を時計表示"; +"Show usage as used" = "使用量を消費分で表示"; +"Sign in via button below" = "下のボタンからサインイン"; +"Skip teardown between probes (debug-only)." = "プローブ間のティアダウンをスキップします(デバッグ専用)。"; +"Stack token accounts in the menu (otherwise show an account switcher bar)." = "メニューにトークンアカウントを積み重ねて表示します(オフの場合はアカウント切替バーを表示します)。"; +"Start at Login" = "ログイン時に起動"; +"Status" = "ステータス"; +"Store Claude sessionKey cookies or OAuth access tokens." = "Claude の sessionKey Cookie または OAuth アクセストークンを保存します。"; +"Store multiple Abacus AI Cookie headers." = "複数の Abacus AI Cookie ヘッダーを保存します。"; +"Store multiple Augment Cookie headers." = "複数の Augment Cookie ヘッダーを保存します。"; +"Store multiple Cursor Cookie headers." = "複数の Cursor Cookie ヘッダーを保存します。"; +"Store multiple Factory Cookie headers." = "複数の Factory Cookie ヘッダーを保存します。"; +"Store multiple MiniMax Cookie headers." = "複数の MiniMax Cookie ヘッダーを保存します。"; +"Store multiple Mistral Cookie headers." = "複数の Mistral Cookie ヘッダーを保存します。"; +"Store multiple Ollama Cookie headers." = "複数の Ollama Cookie ヘッダーを保存します。"; +"Store multiple OpenCode Cookie headers." = "複数の OpenCode Cookie ヘッダーを保存します。"; +"Store multiple OpenCode Go Cookie headers." = "複数の OpenCode Go Cookie ヘッダーを保存します。"; +"Stored in the CodexBar config file." = "CodexBar の設定ファイルに保存されます。"; +"Stored in ~/.codexbar/config.json. " = "~/.codexbar/config.json に保存されます。 "; +"Stored in ~/.codexbar/config.json. Paste the key from the Synthetic dashboard." = "~/.codexbar/config.json に保存されます。Synthetic ダッシュボードのキーを貼り付けてください。"; +"Stored in ~/.codexbar/config.json. Paste your Coding Plan API key from Model Studio." = "~/.codexbar/config.json に保存されます。Model Studio の Coding Plan API キーを貼り付けてください。"; +"Stored in ~/.codexbar/config.json. Paste your MiniMax API key." = "~/.codexbar/config.json に保存されます。MiniMax API キーを貼り付けてください。"; +"Stored in ~/.codexbar/config.json. You can also provide KILO_API_KEY or " = "~/.codexbar/config.json に保存されます。KILO_API_KEY を指定することもできます。または "; +"Stores local Codex usage history (8 weeks) to personalize Pace predictions." = "ローカルの Codex 使用履歴(8 週間分)を保存して、ペース予測をパーソナライズします。"; +"Surprise me" = "サプライズ"; +"Switcher shows icons" = "切替バーにアイコンを表示"; +"Symlink CodexBarCLI to /usr/local/bin and /opt/homebrew/bin as codexbar." = "CodexBarCLI を codexbar として /usr/local/bin と /opt/homebrew/bin にシンボリックリンクします。"; +"System" = "システム"; +"Temporarily shows the loading animation after the next refresh." = "次回の更新後に読み込みアニメーションを一時的に表示します。"; +"terminal_app_subtitle" = "「ターミナルを開く」アクションで使用するターミナル"; +"terminal_app_title" = "デフォルトのターミナル"; +"Tertiary (\\(label))" = "第3(\\(label))"; +"Tertiary (\\(tertiaryTitle))" = "第3(\\(tertiaryTitle))"; +"The default Codex account on this Mac." = "この Mac のデフォルトの Codex アカウントです。"; +"Toggle" = "切り替え"; +"Toggle subtitle" = "サブタイトルを切り替え"; +"Token" = "トークン"; +"Trigger the menu bar menu from anywhere." = "どこからでもメニューバーのメニューを開きます。"; +"True" = "True"; +"Twitter" = "Twitter"; +"Unsupported" = "未対応"; +"Update Channel" = "アップデートチャンネル"; +"Updated" = "更新済み"; +"Updates unavailable in this build." = "このビルドではアップデートを利用できません。"; +"Usage" = "使用量"; +"Usage breakdown" = "使用量の内訳"; +"Usage history (30 days)" = "使用履歴"; +"Usage source" = "使用量の取得元"; +"Use BigModel for the China mainland endpoints (open.bigmodel.cn)." = "中国本土向けエンドポイント(open.bigmodel.cn)には BigModel を使用します。"; +"Use a single menu bar icon with a provider switcher." = "プロバイダ切替付きの単一のメニューバーアイコンを使用します。"; +"Use international or China mainland console gateways for quota fetches." = "クォータ取得に国際版または中国本土版のコンソールゲートウェイを使用します。"; +"Version" = "バージョン"; +"Version \\(self.versionString)" = "バージョン \\(self.versionString)"; +"Version \\(version)" = "バージョン \\(version)"; +"Version \\(versionString)" = "バージョン \\(versionString)"; +"Vertex AI Login" = "Vertex AI ログイン"; +"Wait for the current managed Codex login to finish before adding another account." = "別のアカウントを追加する前に、現在のマネージド Codex ログインが完了するまでお待ちください。"; +"Waiting for Authentication..." = "認証を待機中..."; +"Website" = "Web サイト"; +"Weekly limit confetti" = "週間上限の紙吹雪"; +"Weekly token limit" = "週間トークン上限"; +"Weekly usage" = "週間使用量"; +"Weekly usage unavailable for this account." = "このアカウントでは週間使用量を取得できません。"; +"Window: \\(window)" = "ウインドウ: \\(window)"; +"Write logs to \\(self.fileLogPath) for debugging." = "デバッグ用にログを \\(self.fileLogPath) に書き込みます。"; +"Yes" = "はい"; +"\\(detail.modelCode): \\(usage)" = "\\(detail.modelCode): \\(usage)"; +"\\(name): \\(truncated)" = "\\(name): \\(truncated)"; +"\\(name): \\(updated) · 30d \\(cost)" = "\\(name): \\(updated) · 30日 \\(cost)"; +"\\(name): fetching…\\(elapsed)" = "\\(name): 取得中…\\(elapsed)"; +"\\(name): last attempt \\(when)" = "\\(name): 最終試行 \\(when)"; +"\\(name): no data yet" = "\\(name): データなし"; +"\\(name): unsupported" = "\\(name): 未対応"; +"all browsers" = "すべてのブラウザ"; +"available again." = "再び利用可能になりました。"; +"built_format" = "ビルド: %@"; +"copilot_complete_in_browser" = "ブラウザでサインインを完了してください。"; +"copilot_device_code" = "デバイスコードをクリップボードにコピーしました: %1$@\n\n確認先: %2$@"; +"copilot_device_code_copied" = "デバイスコードをコピーしました。"; +"copilot_verify_at" = "%@ で確認してください"; +"copilot_waiting_text" = "ブラウザでサインインを完了してください。\nサインインが完了すると、このウインドウは自動的に閉じます。"; +"copilot_window_closes_auto" = "サインインが完了すると、このウインドウは自動的に閉じます。"; +"cost_status_error" = "%1$@: %2$@"; +"cost_status_fetching" = "%1$@: 取得中… %2$@"; +"cost_status_last_attempt" = "%1$@: 最終試行 %2$@"; +"cost_status_no_data" = "%@: データなし"; +"cost_status_snapshot" = "%1$@: %2$@ · %3$@ %4$@"; +"cost_status_unsupported" = "%@: 未対応"; +"credits_remaining" = "クレジット: %@"; +"cursor_on_demand" = "オンデマンド: %@"; +"cursor_on_demand_with_limit" = "オンデマンド: %1$@ / %2$@"; +"extra_usage_format" = "追加使用量: %1$@ / %2$@"; +"jetbrains_detected_generate" = "検出: %@。AI アシスタントを一度使用してクォータデータを生成してから、CodexBar を更新してください。"; +"jetbrains_detected_select" = "検出: %@。設定でお使いの IDE を選択してから、CodexBar を更新してください。"; +"last_fetch_failed_with_provider" = "前回の %@ の取得に失敗しました:"; +"last_spend" = "直近の支出: %@"; +"mcp_model_usage" = "%1$@: %2$@"; +"mcp_resets" = "リセット: %@"; +"mcp_window" = "ウインドウ: %@"; +"metric_average" = "平均(%1$@ + %2$@)"; +"metric_primary" = "プライマリ(%@)"; +"metric_secondary" = "セカンダリ(%@)"; +"metric_tertiary" = "第3(%@)"; +"multiple_workspaces_found" = "CodexBar は %@ の複数のワークスペースを見つけました。追加するワークスペースを選択してください。"; +"ory_session_…=…; csrftoken=…" = "ory_session_…=…; csrftoken=…"; +"overview_choose_providers" = "最大 %@ 個のプロバイダを選択"; +"remove_account_message" = "%@ を CodexBar から削除しますか?マネージド Codex ホームも削除されます。"; +"version_format" = "バージョン %@"; +"vertex_ai_login_instructions" = "Vertex AI の使用状況を追跡するには、Google Cloud で認証してください。\n\n1. ターミナルを開く\n2. 実行: gcloud auth application-default login\n3. ブラウザの指示に従ってサインイン\n4. プロジェクトを設定: gcloud config set project PROJECT_ID\n\n今すぐターミナルを開きますか?"; +"workspaceID is set but only opencode, opencodego, and deepgram support workspaceID." = "workspaceID が設定されていますが、workspaceID に対応しているのは opencode、opencodego、deepgram のみです。"; +"© 2026 Peter Steinberger. MIT License." = "© 2026 Peter Steinberger. MIT License."; + +/* General Pane */ +"section_system" = "システム"; +"section_usage" = "使用量"; +"section_refreshing" = "更新"; +"section_alerts" = "アラート"; +"section_celebrations" = "お祝い"; +"section_icon" = "アイコン"; +"section_combined_icon" = "統合アイコン"; +"section_animation" = "アニメーション"; +"section_content" = "メニューの内容"; +"section_agent_sessions" = "エージェントセッション"; +"language_title" = "言語"; +"language_subtitle" = "表示言語を変更します。完全に反映するにはアプリの再起動が必要です。"; +"currency_title" = "優先通貨"; +"currency_subtitle" = "費用見積もりと支出指標に使う通貨です。毎日更新される為替レートを使用します。"; +"currency_auto" = "自動(プロバイダー / USD に従う)"; +"language_system" = "システム"; +"language_english" = "英語"; +"language_spanish" = "スペイン語"; +"language_catalan" = "カタロニア語"; +"language_chinese_simplified" = "簡体字中国語"; +"language_chinese_traditional" = "繁体字中国語"; +"language_portuguese_brazilian" = "ポルトガル語(ブラジル)"; +"language_dutch" = "オランダ語"; +"language_swedish" = "スウェーデン語"; +"language_french" = "フランス語"; +"language_german" = "ドイツ語"; +"language_ukrainian" = "ウクライナ語"; +"language_russian" = "Русский"; +"language_japanese" = "日本語"; +"language_korean" = "韓国語"; +"language_italian" = "イタリア語"; +"language_polish" = "ポーランド語"; +"start_at_login_title" = "ログイン時に起動"; +"start_at_login_subtitle" = "Mac の起動時に CodexBar を自動的に開きます。"; +"show_cost_summary_subtitle" = "ローカルの使用ログを読み取り、今日分と選択した履歴期間をメニューに表示します。"; +"cost_summary_style_title" = "表示スタイル"; +"cost_summary_style_inline" = "インラインのみ"; +"cost_summary_style_submenu" = "サブメニューのみ"; +"cost_summary_style_both" = "両方"; +"cost_summary_style_inline_help" = "コスト概要をメインメニューに直接表示します。"; +"cost_summary_style_submenu_help" = "代わりに詳細なコストサブメニューを表示します。"; +"cost_summary_style_both_help" = "メインメニューの概要と詳細なコストサブメニューの両方を表示します。"; +"cost_history_window_title" = "履歴期間"; +"cost_history_window_help" = "メニューに表示するローカル使用ログの日数を設定します。"; +"cost_history_days_title" = "履歴期間: %d 日"; +"cost_auto_refresh_info" = "自動更新: グローバル間隔(最短 5 分)· タイムアウト: 10 分"; +"cost_comparison_periods_title" = "短い比較期間を表示"; +"cost_comparison_periods_subtitle" = "選択した履歴期間に収まる場合、7日、30日、90日の合計を追加します。これらの合計には同じローカルスキャンを再利用します。"; +"refresh_interval_title" = "更新間隔"; +"manual_refresh_hint" = "自動更新はオフです。メニューの「更新」コマンドを使用してください。"; +"refresh_on_open_title" = "メニューを開いたときに更新"; +"refresh_on_open_subtitle" = "メニューを開くたびに、すべてのプロバイダーの最新の使用状況を取得します。"; +"check_provider_status_title" = "プロバイダのステータスを確認"; +"check_provider_status_subtitle" = "OpenAI/Claude のステータスページと Gemini/Antigravity 用の Google Workspace をポーリングし、障害情報をアイコンとメニューに表示します。"; +"session_quota_notifications_subtitle" = "5 時間のセッションクォータが 0% になったとき、および再び利用可能になったときに通知します。"; +"quota_depleted_title" = "クォータの枯渇と回復"; +"quota_warning_notifications_subtitle" = "セッションまたは週間クォータの残量が設定したしきい値を下回ったときに警告します。"; +"threshold_warnings_title" = "しきい値警告"; +"quota_warnings_title" = "クォータ警告"; +"quota_warning_session" = "セッション"; +"quota_warning_session_capitalized" = "セッション"; +"quota_warning_weekly" = "週間"; +"quota_warning_weekly_capitalized" = "週間"; +"quota_warning_notification_title" = "%1$@ の%2$@クォータが残りわずか"; +"quota_warning_notification_body" = "残り %1$@。設定した %2$d%% の%3$@警告しきい値に達しました。"; +"quota_warning_notification_body_with_account" = "アカウント %1$@。残り %2$@。設定した %3$d%% の%4$@警告しきい値に達しました。"; +"predictive_pace_warnings_title" = "予測ペース警告"; +"predictive_pace_warnings_subtitle" = "Codex と Claude で、セッションまたは週間のペースではリセット前にクォータが尽きる可能性がある場合に警告します。"; +"confetti_on_reset_title" = "リセット時の紙吹雪"; +"confetti_on_reset_subtitle" = "使用量がリセットされたときに全画面の紙吹雪を再生します。"; +"confetti_option_off" = "オフ"; +"confetti_option_session" = "セッションのリセット"; +"confetti_option_weekly" = "週間リセット"; +"confetti_option_both" = "両方"; +"predictive_pace_warning_notification_title" = "%1$@ の%2$@ペース警告"; +"predictive_pace_warning_notification_body" = "現在のペースでは、リセット前にこのクォータがあと %1$@ で尽きる可能性があります。"; +"predictive_pace_warning_notification_body_with_account" = "アカウント %1$@。現在のペースでは、リセット前にこのクォータがあと %2$@ で尽きる可能性があります。"; +"session_depleted_notification_title" = "%@ のセッションを使い切りました"; +"session_depleted_notification_body" = "残り 0% です。再び利用可能になったら通知します。"; +"session_restored_notification_title" = "%@ のセッションが回復しました"; +"session_restored_notification_body" = "セッションクォータが再び利用可能になりました。"; +"quota_warning_warn_at" = "警告する残量"; +"quota_warning_global_threshold_subtitle" = "プロバイダ側で上書きされない限り、セッションおよび週間ウインドウの残量パーセントに適用されます。"; +"quota_warning_sound" = "通知音を再生"; +"quota_warning_onscreen_alert" = "画面上にテキストアラートを表示"; +"quota_warning_provider_inherits" = "ここでウインドウをカスタマイズしない限り、グローバルのクォータ警告設定を使用します。"; +"quota_warning_provider_disabled" = "クォータ警告通知と使用量バーのマーカーはオフです。保存済みの設定を編集するには、どちらかをオンにしてください。"; +"quota_warning_provider_markers_only" = "クォータ警告通知はグローバルでオフです。これらの設定は引き続き使用量バーのマーカーを制御します。"; +"quota_warning_global" = "グローバル"; +"quota_warning_customize_thresholds" = "%@ のしきい値をカスタマイズ"; +"quota_warning_enable_warnings" = "%@ の警告を有効にする"; +"quota_warning_window_warn_at" = "%@ の警告残量"; +"quota_warning_off" = "オフ"; +"quota_warning_inherited" = "継承: %@"; +"quota_warning_depleted_only" = "枯渇時のみ"; +"quota_warning_upper" = "高め"; +"quota_warning_lower" = "下限"; +"quota_warning_warning" = "警告"; +"quota_warning_critical" = "重大"; +"apply" = "適用"; +"quit_app" = "CodexBar を終了"; + +/* Tab titles */ +"tab_general" = "一般"; +"tab_providers" = "プロバイダ"; +"tab_notifications" = "通知"; +"tab_menu_bar" = "メニューバー"; +"tab_menu" = "メニュー"; +"tab_advanced" = "詳細"; +"tab_about" = "情報"; +"tab_debug" = "デバッグ"; + +/* Providers Pane */ +"select_a_provider" = "プロバイダを選択"; +"cancel" = "キャンセル"; +"last_fetch_failed" = "前回の取得に失敗"; +"usage_not_fetched_yet" = "使用量は未取得"; +"managed_account_storage_unreadable" = "マネージドアカウントのストレージを読み取れません。ライブアカウントへのアクセスは引き続き可能ですが、ストアが復旧するまで、マネージドアカウントの追加・再認証・削除操作は無効になります。"; +"remove_codex_account_title" = "Codex アカウントを削除しますか?"; +"remove" = "削除"; +"managed_login_already_running" = "マネージド Codex ログインがすでに実行中です。別のアカウントを追加または再認証する前に、完了するまでお待ちください。"; +"managed_login_failed" = "マネージド Codex ログインが完了しませんでした。ターミナルで `codex --version` が動作することを確認してください。macOS が `codex` をブロックした、またはゴミ箱に移動した場合は、古い重複インストールを削除し、`npm install -g --include=optional @openai/codex@latest` を実行してから再試行してください。"; +"codex_login_output" = "codex login の出力:"; +"managed_login_missing_email" = "Codex ログインは完了しましたが、アカウントのメールアドレスを取得できませんでした。アカウントが完全にサインインしていることを確認してから、再試行してください。"; +"login_success_notification_title" = "%@ のログインに成功しました"; +"login_success_notification_body" = "アプリに戻れます。認証が完了しました。"; +"workspace_selection_cancelled" = "CodexBar は複数のワークスペースを見つけましたが、ワークスペースが選択されませんでした。"; +"unsafe_managed_home" = "CodexBar は想定外のマネージドホームパスの変更を拒否しました: %@"; +"menu_bar_metric_title" = "メニューバーの指標"; +"menu_bar_metric_subtitle" = "メニューバーのパーセント表示に使用するウインドウを選択します。"; +"menu_bar_metric_subtitle_deepseek" = "DeepSeek の残高をメニューバーに表示します。"; +"menu_bar_metric_subtitle_moonshot" = "Moonshot / Kimi API の残高をメニューバーに表示します。"; +"menu_bar_metric_subtitle_mistral" = "今月の Mistral API 支出をメニューバーに表示します。"; +"automatic" = "自動"; +"primary_api_key_limit" = "プライマリ(API キー上限)"; + +/* Display Pane */ +"menu_bar_style_title" = "メニューバーのスタイル"; +"menu_bar_style_subtitle" = "メニューバー項目の表示方法です。"; +"menu_bar_inactive_display_contrast_title" = "非アクティブなディスプレイでの視認性を向上"; +"menu_bar_usage_colors_title" = "使用量の色分け"; +"menu_bar_usage_colors_subtitle" = "使用量が増えるとメニューバーアイコンを緑から赤へ色付けします。"; +"menu_bar_inactive_display_contrast_subtitle" = "高コントラスト表示を使用し、ほかのディスプレイでもアイコンと指標を読みやすくします。"; +"menu_bar_style_critters" = "クリッター"; +"menu_bar_style_bars" = "メーターバー"; +"menu_bar_style_icon_percent" = "アイコンとパーセント"; +"switcher_rows_title" = "切替バーの表示"; +"switcher_rows_icons" = "プロバイダアイコン"; +"switcher_rows_progress" = "週間進捗"; +"usage_bars_fill_title" = "使用量バーの表示"; +"usage_bars_fill_remaining" = "残量として"; +"usage_bars_fill_used" = "消費量として"; +"reset_times_title" = "リセット時刻"; +"reset_times_countdown" = "カウントダウン"; +"reset_times_clock" = "時計表示"; +"cost_summary_title" = "コスト概要"; +"cost_summary_off" = "オフ"; +"merge_icons_title" = "アイコンを統合"; +"merge_icons_subtitle" = "プロバイダ切替付きの単一のメニューバーアイコンを使用します。"; +"show_most_used_provider_title" = "最も使用中のプロバイダを表示"; +"show_most_used_provider_subtitle" = "レート制限に最も近いプロバイダをメニューバーに自動表示します。"; +"display_mode_title" = "表示モード"; +"display_mode_subtitle" = "メニューバーに表示する内容を選択します(ペースは使用量と想定値の比較を表示します)。"; +"show_quota_warning_markers_title" = "クォータ警告マーカーを表示"; +"show_quota_warning_markers_subtitle" = "クォータ警告が設定されている場合、使用量バーにしきい値の目盛りを描画します。"; +"weekly_progress_work_days_title" = "週間進捗の作業日"; +"weekly_progress_work_days_subtitle" = "週間使用量バーの目盛りとペース計算に使用する作業日を設定します。"; +"show_provider_changelog_links_title" = "プロバイダの変更履歴リンクを表示"; +"show_provider_changelog_links_subtitle" = "対応する CLI ベースのプロバイダのリリースノートへのリンクをメニューに追加します。"; +"show_credits_extra_usage_title" = "クレジットと追加使用量を表示"; +"show_credits_extra_usage_subtitle" = "Codex クレジットと Claude 追加使用量のセクションをメニューに表示します。"; +"multi_account_layout_title" = "複数アカウントのレイアウト"; +"multi_account_layout_subtitle" = "セグメント式のアカウント切替か、積み重ね式のアカウントカードを選択します。"; +"multi_account_layout_segmented" = "セグメント"; +"multi_account_layout_stacked" = "スタック"; +"overview_tab_providers_title" = "概要タブのプロバイダ"; +"configure" = "設定…"; +"overview_enable_merge_icons_hint" = "概要タブのプロバイダを設定するには「アイコンを統合」を有効にしてください。"; +"overview_no_providers_hint" = "概要に使用できる有効なプロバイダがありません。"; +"overview_rows_follow_order" = "概要の行は常にプロバイダの並び順に従います。"; +"overview_no_providers_selected" = "プロバイダが選択されていません"; +"agent_sessions_title" = "エージェントセッション"; +"agent_sessions_subtitle" = "ローカルおよび SSH で検出された Codex と Claude Code のセッションをメニューに表示します。"; +"agent_sessions_hosts_title" = "追加の SSH ホスト"; +"agent_sessions_footer" = "tailnet 上の Mac は自動的に検出されます。ローカルセッションは 30 秒ごと、リモートホストは 60 秒ごと、およびメニューを開いたときに更新されます。"; +"agent_session_labels_title" = "セッションラベル"; +"agent_session_labels_subtitle" = "エージェントセッションの名前の付け方を選択します。"; +"agent_session_label_project" = "プロジェクト"; +"agent_session_label_descriptive" = "説明"; +"agent_session_label_descriptive_and_project" = "説明 + プロジェクト"; +"agent_session_unknown_project" = "不明なプロジェクト"; + +/* Advanced Pane */ +"section_keyboard_shortcut" = "キーボードショートカット"; +"open_menu_shortcut_title" = "メニューを開く"; +"open_menu_shortcut_subtitle" = "どこからでもメニューバーのメニューを開きます。"; +"install_cli" = "CLI をインストール"; +"install_cli_subtitle" = "CodexBarCLI を codexbar として /usr/local/bin と /opt/homebrew/bin にシンボリックリンクします。"; +"cli_not_found" = "アプリバンドル内に CodexBarCLI が見つかりません。"; +"no_writable_bin_dirs" = "書き込み可能な bin ディレクトリが見つかりません。"; +"show_debug_settings_title" = "デバッグ設定を表示"; +"show_debug_settings_subtitle" = "デバッグタブにトラブルシューティングツールを表示します。"; +"surprise_me_title" = "サプライズ"; +"surprise_me_subtitle" = "エージェントたちがメニューバーで少し遊ぶのが好きか試してみてください。"; +"hide_personal_info_title" = "個人情報を隠す"; +"hide_personal_info_subtitle" = "メニューバーとメニュー UI のメールアドレスを伏せ字にします。"; +"show_provider_storage_usage_title" = "プロバイダのストレージ使用量を表示"; +"show_provider_storage_usage_subtitle" = "ローカルディスクの使用量をメニューに表示します。既知のプロバイダ所有パスをバックグラウンドでスキャンします。"; +"section_keychain_access" = "キーチェーンアクセス"; +"keychain_access_caption" = "キーチェーンの読み書きをすべて無効にします。「常に許可」をクリックしても macOS が「Chrome/Brave/Edge Safe Storage」のプロンプトを表示し続ける場合に使用してください。有効中はブラウザの Cookie 読み込みが利用できないため、プロバイダで Cookie ヘッダーを手動で貼り付けてください。CLI 経由の Claude/Codex OAuth は引き続き動作します。"; +"disable_keychain_access_title" = "キーチェーンアクセスを無効にする"; +"disable_keychain_access_subtitle" = "有効中はキーチェーンへのアクセスを一切行いません。"; + +/* About Pane */ +"about_tagline" = "トークンが尽きませんように—エージェントの上限を常に見守りましょう。"; +"link_github" = "GitHub"; +"link_website" = "ウェブサイト"; +"link_twitter" = "Twitter"; +"link_email" = "メール"; +"check_updates_auto" = "アップデートを自動的に確認"; +"update_channel" = "アップデートチャンネル"; +"check_for_updates" = "アップデートを確認…"; +"updates_unavailable" = "このビルドではアップデートを利用できません。"; +"copyright" = "© 2026 Peter Steinberger. MIT License."; + +/* Debug Pane */ +"section_logging" = "ログ"; +"enable_file_logging" = "ファイルログを有効にする"; +"enable_file_logging_subtitle" = "デバッグ用に %@ へログを書き込みます。"; +"verbosity_title" = "詳細度"; +"verbosity_subtitle" = "ログに記録する詳細の量を制御します。"; +"open_log_file" = "ログファイルを開く"; +"force_animation_next_refresh" = "次回の更新時にアニメーションを強制する"; +"force_animation_next_refresh_subtitle" = "次回の更新後に読み込みアニメーションを一時的に表示します。"; +"section_loading_animations" = "読み込みアニメーション"; +"loading_animations_caption" = "パターンを選んでメニューバーで再生できます。\"ランダム\"は既存の動作を維持します。"; +"animation_random_default" = "ランダム(デフォルト)"; +"replay_selected_animation" = "選択したアニメーションを再生"; +"blink_now" = "今すぐ点滅"; +"section_probe_logs" = "プローブログ"; +"probe_logs_caption" = "デバッグ用に最新のプローブ出力を取得します。コピーでは全文が保持されます。"; +"fetch_log" = "ログを取得"; +"copy" = "コピー"; +"save_to_file" = "ファイルに保存"; +"load_parse_dump" = "解析ダンプを読み込む"; +"rerun_provider_autodetect" = "プロバイダの自動検出を再実行"; +"loading" = "読み込み中…"; +"no_log_yet_fetch" = "ログはまだありません。取得して読み込んでください。"; +"section_fetch_strategy" = "取得戦略の試行"; +"fetch_strategy_caption" = "プロバイダに対する直近の取得パイプラインの判断とエラーです。"; +"section_openai_cookies" = "OpenAI Cookie"; +"openai_cookies_caption" = "前回の OpenAI Cookie 試行における Cookie インポートと WebKit スクレイピングのログです。"; +"no_log_yet" = "ログはまだありません。プロバイダ → Codex で OpenAI Cookie を更新するとインポートが実行されます。"; +"section_caches" = "キャッシュ"; +"caches_caption" = "キャッシュされたコストスキャン結果またはブラウザの Cookie キャッシュを消去します。"; +"clear_cookie_cache" = "Cookie キャッシュを消去"; +"clear_cost_cache" = "コストキャッシュを消去"; +"section_notifications" = "通知"; +"notifications_caption" = "5時間セッション枠(枯渇/回復)のテスト通知を発行します。"; +"post_depleted" = "枯渇通知を送信"; +"post_restored" = "回復通知を送信"; +"section_cli_sessions" = "CLI セッション"; +"cli_sessions_caption" = "プローブ後も Codex/Claude の CLI セッションを維持します。デフォルトではデータ取得後に終了します。"; +"keep_cli_sessions_alive" = "CLI セッションを維持する"; +"keep_cli_sessions_alive_subtitle" = "プローブ間のクリーンアップをスキップします(デバッグ専用)。"; +"reset_cli_sessions" = "CLI セッションをリセット"; +"section_error_simulation" = "エラーシミュレーション"; +"error_simulation_caption" = "レイアウトテスト用に、メニューカードへ偽のエラーメッセージを挿入します。"; +"set_menu_error" = "メニューエラーを設定"; +"clear_menu_error" = "メニューエラーを消去"; +"set_cost_error" = "コストエラーを設定"; +"clear_cost_error" = "コストエラーを消去"; +"section_cli_paths" = "CLI パス"; +"cli_paths_caption" = "解決された Codex バイナリと PATH レイヤー、起動時のログインシェル PATH 取得(短いタイムアウト)です。"; +"codex_binary" = "Codex バイナリ"; +"claude_binary" = "Claude バイナリ"; +"effective_path" = "有効な PATH"; +"unavailable" = "利用不可"; +"login_shell_path" = "ログインシェル PATH(起動時に取得)"; +"cleared" = "消去しました。"; +"no_fetch_attempts" = "取得の試行はまだありません。"; +"macOS Tahoe can block menu bar apps in System Settings → Menu Bar → Allow in the Menu Bar. CodexBar is running, but macOS may be hiding its icon. Open Menu Bar settings and turn CodexBar on." = "macOS Tahoe では、システム設定 → メニューバー → メニューバーに表示を許可 でメニューバーアプリがブロックされることがあります。CodexBar は実行中ですが、macOS がアイコンを非表示にしている可能性があります。メニューバー設定を開き、CodexBar をオンにしてください。"; + +/* Metric preferences */ +"metric_pref_automatic" = "自動"; +"metric_pref_primary" = "プライマリ"; +"metric_pref_secondary" = "セカンダリ"; +"metric_pref_tertiary" = "第3"; +"metric_pref_extra_usage" = "追加使用量"; +"metric_pref_average" = "平均"; +"metric_mistral_payg" = "Pay-as-you-go"; +"metric_mistral_monthly_plan" = "Monthly Plan"; + +/* Display modes */ +"display_mode_percent" = "パーセント"; +"display_mode_pace" = "ペース"; +"display_mode_both" = "両方"; +"display_mode_reset_time" = "リセット時刻"; +"display_mode_percent_desc" = "残り/使用済みのパーセンテージを表示(例: 45%)"; +"display_mode_pace_desc" = "ペースインジケータを表示(例: +5%)"; +"display_mode_both_desc" = "パーセンテージとペースの両方を表示(例: 45% · +5%)"; +"display_mode_reset_time_desc" = "選択した指標のリセット時刻を表示(例: ↻ 15:56)"; +"menu_bar_reset_when_exhausted_title" = "クォータを使い切ったらリセット時刻を表示"; +"menu_bar_reset_when_exhausted_subtitle" = "残り0%のとき、パーセントの代わりにリセットまでの時間を表示します"; + +/* Provider status */ +"status_operational" = "正常稼働中"; +"status_degraded" = "パフォーマンス低下"; +"status_partial_outage" = "一部障害"; +"status_major_outage" = "重大な障害"; +"status_critical_issue" = "致命的な問題"; +"status_maintenance" = "メンテナンス中"; +"status_unknown" = "ステータス不明"; + +/* Refresh frequency */ +"refresh_manual" = "手動"; +"refresh_1min" = "1分"; +"refresh_2min" = "2分"; +"refresh_5min" = "5分"; +"refresh_15min" = "15分"; +"refresh_30min" = "30分"; +"refresh_adaptive" = "アダプティブ"; +"refresh_adaptive_agent_aware" = "アダプティブ(エージェント対応)"; +"adaptive_activity_consent_title" = "アクティビティ対応の更新を許可しますか?"; +"adaptive_activity_consent_message" = "エージェント対応のアダプティブ更新では、Codex と Claude を識別するために、コマンドラインを含むローカルの実行中プロセス一覧を調べ、コーディング中は既知のセッションメタデータを 30 秒ごとに読み取ることができます。Agent Sessions がオフの場合、CodexBar は最新のアクティビティ時刻だけをメモリで使用し、セッションのパスと識別情報を破棄します。このデータが外部に送信されることはなく、リモート検出と SSH はオフのままです。許可しない場合は、ローカルスキャンを行わない通常のアダプティブに戻ります。"; +"adaptive_activity_consent_allow" = "ローカルアクティビティを許可"; +"adaptive_activity_consent_decline" = "通常のアダプティブを使用"; + +/* Additional keys */ +"not_found" = "見つかりません"; + +/* Cost estimation */ +"cost_estimate_hint" = "ローカルログからの推定値 · 請求額と異なる場合があります"; +"codex_api_estimate_hint" = "トークン使用量からの見積もり · サブスクリプションの請求額ではありません"; +"cost_data_explanation" = "コストはプロバイダーから報告される場合と、トークン使用量を公開 API 価格で換算して見積もられる場合があります。見積もりはサブスクリプション料金ではありません。"; +"No JetBrains IDE with AI Assistant detected. Install a JetBrains IDE and enable AI Assistant." = "AI Assistant 対応の JetBrains IDE が検出されませんでした。JetBrains IDE をインストールし、AI Assistant を有効にしてください。"; +"OpenRouter API token not configured. Set OPENROUTER_API_KEY environment variable or configure in Settings." = "OpenRouter API トークンが設定されていません。環境変数 OPENROUTER_API_KEY を設定するか、設定で構成してください。"; +"z.ai API token not found. Set apiKey in ~/.codexbar/config.json or Z_AI_API_KEY." = "z.ai API トークンが見つかりません。~/.codexbar/config.json の apiKey または Z_AI_API_KEY を設定してください。"; +"Missing DeepSeek API key." = "DeepSeek API キーがありません。"; +"%@ is unavailable in the current environment." = "%@ は現在の環境では利用できません。"; +"All Systems Operational" = "全システム正常稼働中"; +"Last 30 days" = "過去30日間"; +"Last 30 days:" = "過去30日間:"; +"This month" = "今月"; +"Store multiple OpenAI API keys." = "複数の OpenAI API キーを保存します。"; +"Admin API key" = "管理者 API キー"; +"Open billing" = "請求情報を開く"; +"Google accounts" = "Google アカウント"; +"Store multiple Antigravity Google OAuth accounts for quick switching." = "複数の Antigravity Google OAuth アカウントを保存して素早く切り替えられます。"; +"Add Google Account" = "Google アカウントを追加"; +"Open Token Plan" = "トークンプランを開く"; +"Text Generation" = "テキスト生成"; +"Text to Speech" = "音声合成"; +"Music Generation" = "音楽生成"; +"Image Generation" = "画像生成"; +"No local data found" = "ローカルデータが見つかりません"; +"Credits unavailable; keep Codex running to refresh." = "クレジット情報を取得できません。更新するには Codex を実行したままにしてください。"; +"No available fetch strategy for minimax." = "minimax に利用可能な取得戦略がありません。"; +"No Cursor session found. Please log in to cursor.com in Safari, Chrome, Microsoft Edge, Brave, Arc, Dia, ChatGPT Atlas, Chromium, Helium, Vivaldi, Yandex Browser, Firefox, Zen, Colibri, Sidekick, Opera, Opera GX, or Edge Canary. If you use Safari, grant CodexBar Full Disk Access in System Settings ▸ Privacy & Security. You can also sign in to Cursor from the CodexBar menu (Add / switch account)." = "Cursor のセッションが見つかりません。Safari、Chrome、Microsoft Edge、Brave、Arc、Dia、ChatGPT Atlas、Chromium、Helium、Vivaldi、Yandex Browser、Firefox、Zen、Colibri、Sidekick、Opera、Opera GX、または Edge Canary で cursor.com にログインしてください。Safari をお使いの場合は、システム設定 ▸ プライバシーとセキュリティ で CodexBar にフルディスクアクセスを許可してください。CodexBar のメニューから Cursor にサインインすることもできます(アカウントを追加/切り替え)。"; +"No OpenCode session cookies found in browsers." = "ブラウザに OpenCode のセッション Cookie が見つかりません。"; +"No available fetch strategy for %@." = "%@ に利用可能な取得戦略がありません。"; +"Today" = "今日"; +"Today tokens" = "今日のトークン"; +"30d cost" = "過去30日間のコスト"; +"%@ cost" = "%@のコスト"; +"30d tokens" = "過去30日間のトークン"; +"Latest tokens" = "最新のトークン"; +"Top model" = "最多使用モデル"; +"Storage" = "ストレージ"; +"Add Account..." = "アカウントを追加..."; +"Usage Dashboard" = "使用状況ダッシュボード"; +"Status Page" = "ステータスページ"; +"Open Status Page" = "ステータスページを開く"; +"Settings..." = "設定..."; +"About CodexBar" = "CodexBar について"; +"Quit" = "終了"; +"Last %d day" = "過去%d日間"; +"Last %d days" = "過去%d日間"; +"%@ tokens" = "%@ トークン"; +"Latest billing day" = "直近の請求日"; +"Latest billing day (%@)" = "直近の請求日(%@)"; +"%@ left" = "残り %@"; +"Resets %@" = "%@ にリセット"; +"Resets in %@" = "%@ 後にリセット"; +"Resets now" = "まもなくリセット"; +"reset_tomorrow_format" = "明日 %@"; +"Lasts until reset" = "リセットまで持続"; +"1.5× headroom" = "1.5倍の余裕"; +"Updated %@" = "%@ に更新"; +"Updated relative %@" = "%@ に更新"; +"Updated absolute %@" = "%@ に更新"; +"Updated %@h ago" = "%@時間前に更新"; +"Updated %@m ago" = "%@分前に更新"; +"Updated just now" = "たった今更新"; +"Projected empty in %@" = "%@ 後に枯渇する見込み"; +"Runs out in %@" = "%@ 後に使い切る見込み"; +"Pace: %@" = "ペース: %@"; +"Pace: %@ · %@" = "ペース: %@ · %@"; +"%@ · %@" = "%@ · %@"; +"≈ %d%% run-out risk" = "枯渇リスク ≈ %d%%"; +"%d%% in deficit" = "%d%% 不足"; +"%d%% in reserve" = "%d%% 余裕"; +"usage_percent_suffix_left" = "残り"; +"usage_percent_suffix_used" = "使用済み"; +"Store multiple DeepSeek API keys." = "複数の DeepSeek API キーを保存します。"; +"This week" = "今週"; +"Week" = "週"; +"Month" = "月"; +"Models" = "モデル"; +"24h tokens" = "24時間のトークン"; +"Latest hour" = "直近1時間"; +"Peak hour" = "ピーク時間帯"; +"Top method" = "最多使用メソッド"; +"30d cash" = "過去30日間の支出"; +"30d billing history from MiniMax web session" = "MiniMax Web セッションからの30日間の請求履歴"; +"AWS Cost Explorer billing can lag." = "AWS Cost Explorer の請求情報は反映が遅れることがあります。"; +"Rate limit: %d / %@" = "レート制限: %d / %@"; +"Key remaining" = "キーの残量"; +"No limit set for the API key" = "API キーに上限が設定されていません"; +"API key limit unavailable right now" = "API キーの上限は現在取得できません"; +"This month: %@ tokens" = "今月: %@ トークン"; +"No utilization data yet." = "使用率データはまだありません。"; +"No %@ utilization data yet." = "%@ の使用率データはまだありません。"; +"%@: %@%% used" = "%@: %@%% 使用済み"; +"%dd" = "%d日"; +"today" = "今日"; +"just now" = "たった今"; +"On pace" = "想定ペース"; +"Runs out now" = "まもなく使い切ります"; +"Projected empty now" = "まもなく枯渇する見込み"; +"Switch Account..." = "アカウントを切り替え..."; +"Update ready, restart now?" = "アップデートの準備ができました。今すぐ再起動しますか?"; +"Daily" = "日別"; +"Hourly Tokens" = "時間別トークン"; +"No data" = "データなし"; +"No usage breakdown data available." = "使用状況の内訳データがありません。"; + +"Today: %@ · %@ tokens" = "今日: %@ · %@ トークン"; +"Today: %@" = "今日: %@"; +"Today: %@ tokens" = "今日: %@ トークン"; +"Last 30 days: %@ · %@ tokens" = "過去30日間: %@ · %@ トークン"; +"Last 30 days: %@" = "過去30日間: %@"; +"Est. total (30d): %@" = "推定合計(30日間): %@"; +"Est. total (%@): %@" = "推定合計(%@): %@"; +"Hover a bar for details" = "バーにポインタを合わせると詳細が表示されます"; +"%@: %@ · %@ tokens" = "%@: %@ · %@ トークン"; +"No providers selected for Overview." = "概要に表示するプロバイダが選択されていません。"; +"No overview data available." = "概要データがありません。"; +"Auto uses the local IDE API first, then Google OAuth when the IDE is closed." = "自動では、まずローカルの IDE API を使用し、IDE が閉じている場合は Google OAuth を使用します。"; +"Login with Google" = "Google でログイン"; + +/* Popup panels */ +"No usage configured." = "使用状況が設定されていません。"; +"Quota" = "クォータ"; +"Daily quota" = "日次クォータ"; +"Total" = "合計"; +"tokens" = "トークン"; +"requests" = "リクエスト"; +"Latest" = "最新"; +"Monthly" = "月間"; +"Sonnet" = "Sonnet"; +"Overages" = "超過分"; +"Activity" = "アクティビティ"; +"Copied" = "コピーしました"; +"Copy error" = "エラーをコピー"; +"Copy path" = "パスをコピー"; +"Extra usage spent" = "追加使用分の支出"; +"Credits remaining" = "残りクレジット"; +"Using CLI fallback" = "CLI フォールバックを使用中"; +"Balance updates in near-real time (up to 5 min lag)" = "残高はほぼリアルタイムで更新されます(最大5分の遅延)"; +"Daily billing data finalizes at 07:00 UTC" = "日次請求データは 07:00 UTC に確定します"; +"%@ of %@ credits left" = "クレジット残り %@ / %@"; +"%@ of %@ bonus credits left" = "ボーナスクレジット残り %@ / %@"; +"%@ / %@ (%@ remaining)" = "%@ / %@(残り %@)"; +"%@/%@ left" = "残り %@/%@"; +"Gemini Flash" = "Gemini Flash"; +"Regenerates %@" = "%@ に再生成"; +"used after next regen" = "次回再生成後の使用率"; +"after next regen" = "次回再生成後"; +"Near full" = "ほぼ満タン"; +"Full in ~1 regen" = "約1回の再生成で満タン"; +"Full in ~%.0f regens" = "約%.0f回の再生成で満タン"; +"Overage usage" = "超過使用量"; +"Overage cost" = "超過コスト"; +"credits" = "クレジット"; +"Zen balance" = "Zen 残高"; +"API spend" = "API 支出"; +"Extra usage" = "追加使用量"; +"Quota usage" = "クォータ使用量"; +"Your spend" = "あなたの支出"; +"%.0f%% used" = "%.0f%% 使用済み"; +"Usage history (today)" = "使用履歴(今日)"; +"Usage history (%d days)" = "使用履歴(%d日間)"; +"%d percent remaining" = "残り %d パーセント"; +"Unknown" = "不明"; +"stale data" = "古いデータ"; +"No credits history data." = "クレジット履歴データがありません。"; +"No credits history data available." = "利用可能なクレジット履歴データがありません。"; +"Credits history chart" = "クレジット履歴チャート"; +"%d days of credits data" = "%d日間のクレジットデータ"; +"Usage breakdown chart" = "使用状況の内訳チャート"; +"%d days of usage data across %d services" = "%2$dサービスにわたる%1$d日間の使用状況データ"; +"Cost history chart" = "コスト履歴チャート"; +"%d days of cost data" = "%d日間のコストデータ"; +"Plan utilization chart" = "プラン使用率チャート"; +"%d utilization samples" = "%d 件の使用率サンプル"; +"Hourly Usage" = "時間別使用量"; +"Usage remaining" = "残りの使用量"; +"Usage used" = "使用済みの使用量"; +"API key verified. Cloud quotas need browser cookies. Sign in to Ollama." = "APIキーを確認しました。CloudクォータにはブラウザCookieが必要です。Ollamaにサインインしてください。"; +"Last 30 days: %@ tokens" = "過去30日間: %@ トークン"; +"7d spend" = "7日間の支出"; +"30d spend" = "過去30日間の支出"; +"Cache read" = "キャッシュ読み取り"; +"Claude Admin API 30 day spend trend" = "Claude Admin API の30日間支出推移"; +"OpenRouter API key spend trend" = "OpenRouter API キーの支出推移"; +"z.ai hourly token trend" = "z.ai の時間別トークン推移"; +"MiniMax 30 day token usage trend" = "MiniMax の30日間トークン使用量推移"; +"Today cash" = "本日の現金"; +"DeepSeek 30 day token usage trend" = "DeepSeek の30日間トークン使用量推移"; +"cache-hit input" = "キャッシュヒット入力"; +"cache-miss input" = "キャッシュミス入力"; +"output" = "出力"; +"Requests" = "リクエスト"; +"Reported by OpenAI Admin API organization usage." = "OpenAI Admin API の組織使用量から報告されています。"; +"Reported by Mistral billing usage." = "Mistral の請求使用量から報告されています。"; +"Google OAuth" = "Google OAuth"; +"Add accounts via GitHub OAuth Device Flow on the selected host." = "選択したホスト上で GitHub OAuth Device Flow を使ってアカウントを追加します。"; +"Stores each signed-in Google account for quick Antigravity switching. Uses Antigravity.app OAuth when available, or ANTIGRAVITY_OAUTH_CLIENT_ID and ANTIGRAVITY_OAUTH_CLIENT_SECRET as an override." = "サインイン済みの各 Google アカウントを保存し、Antigravity をすばやく切り替えられるようにします。利用可能な場合は Antigravity.app の OAuth を使用し、上書き設定として ANTIGRAVITY_OAUTH_CLIENT_ID と ANTIGRAVITY_OAUTH_CLIENT_SECRET を使用できます。"; +"Manual cleanup: past sessions" = "手動クリーンアップ: 過去のセッション"; +"Clearing removes past resume, continue, and rewind history." = "消去すると、過去の再開・継続・巻き戻しの履歴が削除されます。"; +"Manual cleanup: file checkpoints" = "手動クリーンアップ: ファイルチェックポイント"; +"Clearing removes checkpoint restore data for previous edits." = "消去すると、過去の編集のチェックポイント復元データが削除されます。"; +"Manual cleanup: saved plans" = "手動クリーンアップ: 保存済みプラン"; +"Clearing removes old plan-mode files." = "消去すると、古いプランモードのファイルが削除されます。"; +"Manual cleanup: debug logs" = "手動クリーンアップ: デバッグログ"; +"Clearing removes past debug logs." = "消去すると、過去のデバッグログが削除されます。"; +"Manual cleanup: attachment cache" = "手動クリーンアップ: 添付ファイルキャッシュ"; +"Clearing removes cached large pastes or attached images." = "消去すると、キャッシュされた大きなペースト内容や添付画像が削除されます。"; +"Manual cleanup: session metadata" = "手動クリーンアップ: セッションメタデータ"; +"Clearing removes per-session environment metadata." = "消去すると、セッションごとの環境メタデータが削除されます。"; +"Manual cleanup: shell snapshots" = "手動クリーンアップ: シェルスナップショット"; +"Clearing removes leftover runtime shell snapshot files." = "消去すると、残存しているランタイムシェルのスナップショットファイルが削除されます。"; +"Manual cleanup: legacy todos" = "手動クリーンアップ: レガシー ToDo"; +"Clearing removes legacy per-session task lists." = "消去すると、セッションごとのレガシータスクリストが削除されます。"; +"Manual cleanup: sessions" = "手動クリーンアップ: セッション"; +"Clearing removes past Codex session history." = "消去すると、過去の Codex セッション履歴が削除されます。"; +"Manual cleanup: archived sessions" = "手動クリーンアップ: アーカイブ済みセッション"; +"Clearing removes archived Codex session history." = "消去すると、アーカイブされた Codex セッション履歴が削除されます。"; +"Manual cleanup: cache" = "手動クリーンアップ: キャッシュ"; +"Clearing removes provider-owned cached data." = "消去すると、プロバイダが保持するキャッシュデータが削除されます。"; +"Manual cleanup: logs" = "手動クリーンアップ: ログ"; +"Clearing removes local diagnostic logs." = "消去すると、ローカルの診断ログが削除されます。"; +"Manual cleanup: file history" = "手動クリーンアップ: ファイル履歴"; +"Clearing removes local edit checkpoint history." = "消去すると、ローカルの編集チェックポイント履歴が削除されます。"; +"Manual cleanup: temporary data" = "手動クリーンアップ: 一時データ"; +"Clearing removes local temporary provider data." = "消去すると、ローカルのプロバイダ一時データが削除されます。"; +"Total: %@" = "合計: %@"; +"%d more items" = "他 %d 件の項目"; +"Cleanup ideas" = "クリーンアップの候補"; +"%d unreadable item(s) skipped" = "読み取れない項目 %d 件をスキップしました"; + +"API key limit" = "API キー上限"; +"Auth" = "認証"; +"Auto" = "自動"; +"Disabled — no recent data" = "無効 — 最近のデータなし"; +"Limits not available" = "上限情報なし"; +"No usage yet" = "まだ使用量がありません"; +"Not fetched yet" = "未取得"; +"Refreshing" = "更新中"; +"Session" = "セッション"; +"Source" = "ソース"; +"State" = "状態"; +"Unavailable" = "利用不可"; +"Weekly" = "週間"; +"not detected" = "未検出"; +"Estimated from local Codex logs for the selected account." = "選択したアカウントのローカル Codex ログから推定しています。"; +"minimax_usage_amount_format" = "使用量: %@ / %@"; +"minimax_used_percent_format" = "使用済み %@"; +"minimax_service_text_generation" = "テキスト生成"; +"minimax_service_text_to_speech" = "音声合成"; +"minimax_service_music_generation" = "音楽生成"; +"minimax_service_image_generation" = "画像生成"; +"minimax_service_lyrics_generation" = "歌詞生成"; +"minimax_service_coding_plan_vlm" = "コーディングプラン VLM"; +"minimax_service_coding_plan_search" = "コーディングプラン検索"; + +/* Additional provider settings and alerts */ +"%@ is waiting for permission" = "%@ は許可を待っています"; +"%@ requests" = "%@ リクエスト"; +"%@: %@ credits" = "%@: %@ クレジット"; +"30d requests" = "過去30日間のリクエスト"; +"4 days" = "4日間"; +"5 days" = "5日間"; +"7 days" = "7日間"; +"API key verifies Ollama Cloud access; cookies still expose quota limits." = "API キーで Ollama Cloud へのアクセスを確認できますが、クォータ上限の取得には引き続き Cookie が必要です。"; +"AWS access key ID. Can also be set with AWS_ACCESS_KEY_ID." = "AWS アクセスキー ID。AWS_ACCESS_KEY_ID でも設定できます。"; +"AWS region. Can also be set with AWS_REGION." = "AWS リージョン。AWS_REGION でも設定できます。"; +"AWS secret access key. Can also be set with AWS_SECRET_ACCESS_KEY." = "AWS シークレットアクセスキー。AWS_SECRET_ACCESS_KEY でも設定できます。"; +"Access key ID" = "アクセスキー ID"; +"Add Account" = "アカウントを追加"; +"Adding Account…" = "アカウントを追加中…"; +"Antigravity login failed" = "Antigravity のログインに失敗しました"; +"Antigravity login timed out" = "Antigravity のログインがタイムアウトしました"; +"Auth source" = "認証ソース"; +"Automatic imports browser cookies from Xiaomi MiMo." = "自動では Xiaomi MiMo のブラウザ Cookie を読み込みます。"; +"Automatic imports Windsurf session data from Chromium browser localStorage." = "自動では Chromium ブラウザの localStorage から Windsurf セッションデータを読み込みます。"; +"Automatic imports browser cookies from Bailian." = "自動では Bailian のブラウザ Cookie を読み込みます。"; +"Automatically imports browser cookies." = "ブラウザの Cookie を自動的に読み込みます。"; +"Automatically imports browser session cookies." = "ブラウザのセッション Cookie を自動的に読み込みます。"; +"Azure OpenAI deployment name. AZURE_OPENAI_DEPLOYMENT_NAME is also supported." = "Azure OpenAI のデプロイメント名。AZURE_OPENAI_DEPLOYMENT_NAME もサポートされています。"; +"Azure OpenAI key" = "Azure OpenAI キー"; +"Azure OpenAI resource endpoint. AZURE_OPENAI_ENDPOINT is also supported." = "Azure OpenAI リソースのエンドポイント。AZURE_OPENAI_ENDPOINT もサポートされています。"; +"Base URL" = "ベース URL"; +"Base URL for the LLM-API-Key-Proxy instance." = "LLM-API-Key-Proxy インスタンスのベース URL。"; +"Browser cookies" = "ブラウザ Cookie"; +"Cap end" = "上限終了"; +"Cap start" = "上限開始"; +"Capacity End" = "キャパシティ終了"; +"Capacity Start" = "キャパシティ開始"; +"Changelog" = "変更履歴"; +"Choose the Moonshot/Kimi API host for international or China mainland accounts." = "国際アカウントまたは中国本土アカウント用の Moonshot/Kimi API ホストを選択します。"; +"CodexBar can't replace a system account that is signed in with an API key only setup." = "CodexBar は、API キーのみの構成でサインインしているシステムアカウントを置き換えることはできません。"; +"CodexBar could not find saved auth for that account. Re-authenticate it and try again." = "CodexBar はそのアカウントの保存済み認証情報を見つけられませんでした。再認証してからやり直してください。"; +"CodexBar could not read managed account storage. Recover the store before adding another account." = "CodexBar は管理アカウントのストレージを読み取れませんでした。別のアカウントを追加する前にストアを復旧してください。"; +"CodexBar could not read saved auth for that account. Re-authenticate it and try again." = "CodexBar はそのアカウントの保存済み認証情報を読み取れませんでした。再認証してからやり直してください。"; +"CodexBar could not read the current system account on this Mac." = "CodexBar はこの Mac の現在のシステムアカウントを読み取れませんでした。"; +"CodexBar could not replace the live Codex auth on this Mac." = "CodexBar はこの Mac の現在使用中の Codex 認証情報を置き換えられませんでした。"; +"CodexBar could not safely preserve the current system account before switching." = "CodexBar は切り替え前に現在のシステムアカウントを安全に保全できませんでした。"; +"CodexBar could not save the current system account before switching." = "CodexBar は切り替え前に現在のシステムアカウントを保存できませんでした。"; +"CodexBar could not update managed account storage." = "CodexBar は管理アカウントのストレージを更新できませんでした。"; +"CodexBar found another managed account that already uses the current system account. Resolve the duplicate account before switching." = "CodexBar は、現在のシステムアカウントをすでに使用している別の管理アカウントを検出しました。切り替える前に重複アカウントを解消してください。"; +"CodexBar will ask macOS Keychain for “%@” so it can decrypt browser cookies and authenticate your account. Click OK to continue." = "CodexBar はブラウザの Cookie を復号してアカウントを認証するために、macOS キーチェーンに「%@」へのアクセスを求めます。続けるには OK をクリックしてください。"; +"CodexBar will ask macOS Keychain for the Claude Code OAuth token so it can fetch your Claude usage. Click OK to continue." = "CodexBar は Claude の使用量を取得するために、macOS キーチェーンに Claude Code の OAuth トークンへのアクセスを求めます。続けるには OK をクリックしてください。"; +"CodexBar will ask macOS Keychain for your Amp cookie header so it can fetch usage. Click OK to continue." = "CodexBar は使用量を取得するために、macOS キーチェーンに Amp の Cookie ヘッダーへのアクセスを求めます。続けるには OK をクリックしてください。"; +"CodexBar will ask macOS Keychain for your Augment cookie header so it can fetch usage. Click OK to continue." = "CodexBar は使用量を取得するために、macOS キーチェーンに Augment の Cookie ヘッダーへのアクセスを求めます。続けるには OK をクリックしてください。"; +"CodexBar will ask macOS Keychain for your Claude cookie header so it can fetch Claude web usage. Click OK to continue." = "CodexBar は Claude ウェブの使用量を取得するために、macOS キーチェーンに Claude の Cookie ヘッダーへのアクセスを求めます。続けるには OK をクリックしてください。"; +"CodexBar will ask macOS Keychain for your Cursor cookie header so it can fetch usage. Click OK to continue." = "CodexBar は使用量を取得するために、macOS キーチェーンに Cursor の Cookie ヘッダーへのアクセスを求めます。続けるには OK をクリックしてください。"; +"CodexBar will ask macOS Keychain for your Factory cookie header so it can fetch usage. Click OK to continue." = "CodexBar は使用量を取得するために、macOS キーチェーンに Factory の Cookie ヘッダーへのアクセスを求めます。続けるには OK をクリックしてください。"; +"CodexBar will ask macOS Keychain for your GitHub Copilot token so it can fetch usage. Click OK to continue." = "CodexBar は使用量を取得するために、macOS キーチェーンに GitHub Copilot のトークンへのアクセスを求めます。続けるには OK をクリックしてください。"; +"CodexBar will ask macOS Keychain for your Kimi auth token so it can fetch usage. Click OK to continue." = "CodexBar は使用量を取得するために、macOS キーチェーンに Kimi の認証トークンへのアクセスを求めます。続けるには OK をクリックしてください。"; +"CodexBar will ask macOS Keychain for your MiniMax API token so it can fetch usage. Click OK to continue." = "CodexBar は使用量を取得するために、macOS キーチェーンに MiniMax の API トークンへのアクセスを求めます。続けるには OK をクリックしてください。"; +"CodexBar will ask macOS Keychain for your MiniMax cookie header so it can fetch usage. Click OK to continue." = "CodexBar は使用量を取得するために、macOS キーチェーンに MiniMax の Cookie ヘッダーへのアクセスを求めます。続けるには OK をクリックしてください。"; +"CodexBar will ask macOS Keychain for your OpenAI cookie header so it can fetch Codex dashboard extras. Click OK to continue." = "CodexBar は Codex ダッシュボードの追加情報を取得するために、macOS キーチェーンに OpenAI の Cookie ヘッダーへのアクセスを求めます。続けるには OK をクリックしてください。"; +"CodexBar will ask macOS Keychain for your OpenCode cookie header so it can fetch usage. Click OK to continue." = "CodexBar は使用量を取得するために、macOS キーチェーンに OpenCode の Cookie ヘッダーへのアクセスを求めます。続けるには OK をクリックしてください。"; +"CodexBar will ask macOS Keychain for your Synthetic API key so it can fetch usage. Click OK to continue." = "CodexBar は使用量を取得するために、macOS キーチェーンに Synthetic の API キーへのアクセスを求めます。続けるには OK をクリックしてください。"; +"CodexBar will ask macOS Keychain for your z.ai API token so it can fetch usage. Click OK to continue." = "CodexBar は使用量を取得するために、macOS キーチェーンに z.ai の API トークンへのアクセスを求めます。続けるには OK をクリックしてください。"; +"Could not open Cursor login in your browser." = "ブラウザで Cursor のログイン画面を開けませんでした。"; +"Could not open browser for Antigravity" = "Antigravity 用のブラウザを開けませんでした"; +"Credits used" = "使用済みクレジット"; +"Day" = "日"; +"Deployment" = "デプロイメント"; +"Drag to reorder" = "ドラッグして並べ替え"; +"Sort providers alphabetically" = "プロバイダーをアルファベット順に並べ替え"; +"Sort providers alphabetically (enabled first)" = "プロバイダーをアルファベット順に並べ替え(有効なものを先頭)"; +"Sorted alphabetically (enabled first) — click to use your custom order" = "アルファベット順(有効なものを先頭)— クリックしてカスタム順に戻す"; +"Endpoint" = "エンドポイント"; +"Enterprise host" = "Enterprise ホスト"; +"Extra usage balance: %@" = "追加使用量の残高: %@"; +"Keychain Access Required" = "キーチェーンへのアクセスが必要です"; +"keychain_prompt_learn_more" = "詳しく見る…"; +"keychain_prompt_privacy_note" = "Macのログインパスワード入力を処理するのはCodexBarではなくmacOSです。キーチェーンへのアクセスは「設定」→「詳細」でいつでも無効にできます。"; +"Kiro menu bar value" = "Kiro メニューバー表示値"; +"Label" = "ラベル"; +"No organizations loaded. Click Refresh after setting your API key." = "組織が読み込まれていません。API キーを設定してから「更新」をクリックしてください。"; +"No output captured." = "出力は取得されませんでした。"; +"No system account" = "システムアカウントなし"; +"Oasis-Token" = "Oasis-Token"; +"Open Augment (Log Out & Back In)" = "Augment を開く(ログアウトして再ログイン)"; +"Open Codebuff Dashboard" = "Codebuff ダッシュボードを開く"; +"Open Command Code Settings" = "Command Code 設定を開く"; +"Open Crof dashboard" = "Crof ダッシュボードを開く"; +"Open Manus" = "Manus を開く"; +"Open MiMo Balance" = "MiMo 残高を開く"; +"Open Moonshot Console" = "Moonshot コンソールを開く"; +"Open Ollama API Keys" = "Ollama API キーを開く"; +"Open StepFun Platform" = "StepFun プラットフォームを開く"; +"Open T3 Chat Settings" = "T3 Chat 設定を開く"; +"Open Volcengine Ark Console" = "Volcengine Ark コンソールを開く"; +"Open legacy provider docs" = "レガシープロバイダのドキュメントを開く"; +"Open projects" = "プロジェクトを開く"; +"Open this URL manually to continue login:\n\n%@" = "ログインを続けるには、この URL を手動で開いてください:\n\n%@"; +"Optional organization ID for accounts linked to multiple Anthropic organizations." = "複数の Anthropic 組織にリンクされたアカウント用のオプションの組織 ID。"; +"Optional. Applies to the configured Admin API key; selected token accounts do not inherit OPENAI_PROJECT_ID." = "オプション。設定済みの Admin API キーに適用されます。選択したトークンアカウントには OPENAI_PROJECT_ID は引き継がれません。"; +"Optional. Enter your GitHub Enterprise host, for example octocorp.ghe.com. Leave blank for github.com." = "オプション。GitHub Enterprise のホストを入力してください(例: octocorp.ghe.com)。github.com の場合は空欄のままにしてください。"; +"Optional. Leave blank to discover and aggregate projects visible to the API key." = "オプション。空欄のままにすると、API キーから参照できるプロジェクトを検出して集計します。"; +"Org ID (optional)" = "組織 ID(オプション)"; +"Organizations" = "組織"; +"Organization ID" = "組織 ID"; +"Password" = "パスワード"; +"%@ authentication is disabled." = "%@ の認証は無効になっています。"; +"%@ cookies are disabled." = "%@ の Cookie は無効になっています。"; +"%@ web API access is disabled." = "%@ のウェブ API アクセスは無効になっています。"; +"Disable %@ dashboard cookie usage." = "%@ ダッシュボードの Cookie 使用を無効にします。"; +"Keychain access is disabled in Advanced, so browser cookie import is unavailable." = "詳細設定でキーチェーンへのアクセスが無効になっているため、ブラウザ Cookie の読み込みは利用できません。"; +"Manually paste an %@ from a browser session." = "ブラウザセッションから %@ を手動で貼り付けてください。"; +"Paste a Cookie header captured from %@." = "%@ から取得した Cookie ヘッダーを貼り付けてください。"; +"Paste a Cookie header from %@." = "%@ の Cookie ヘッダーを貼り付けてください。"; +"Paste a Cookie header or cURL capture from %@." = "%@ の Cookie ヘッダーまたは cURL キャプチャを貼り付けてください。"; +"Paste a Cookie header or full cURL capture from %@." = "%@ の Cookie ヘッダーまたは完全な cURL キャプチャを貼り付けてください。"; +"Paste a Cookie or Authorization header from %@." = "%@ の Cookie または Authorization ヘッダーを貼り付けてください。"; +"Paste a full cookie header or the %@ value." = "完全な Cookie ヘッダーまたは %@ の値を貼り付けてください。"; +"Paste a Cookie header or full cURL capture from T3 Chat settings." = "T3 Chat 設定から取得した Cookie ヘッダーまたは完全な cURL キャプチャを貼り付けてください。"; +"Paste the Cookie header from a request to admin.mistral.ai. Must contain an ory_session_* cookie." = "admin.mistral.ai へのリクエストの Cookie ヘッダーを貼り付けてください。ory_session_* Cookie が含まれている必要があります。"; +"Paste the Oasis-Token from a logged-in browser session on platform.stepfun.com." = "platform.stepfun.com にログイン中のブラウザセッションから Oasis-Token を貼り付けてください。"; +"Paste the %@ JSON bundle from %@." = "%@ の JSON バンドルを %@ から貼り付けてください。"; +"Paste the %@ value or a full Cookie header." = "%@ の値または完全な Cookie ヘッダーを貼り付けてください。"; +"Personal account" = "個人アカウント"; +"Project ID" = "プロジェクト ID"; +"Re-auth" = "再認証"; +"Re-login at claude.ai" = "claude.ai で再ログイン"; +"Re-authenticating…" = "再認証中…"; +"Refresh Session" = "セッションを更新"; +"Refresh organizations" = "組織を更新"; +"Region" = "リージョン"; +"Reload" = "再読み込み"; +"Reorder" = "並べ替え"; +"Secret access key" = "シークレットアクセスキー"; +"Series" = "シリーズ"; +"Service" = "サービス"; +"Show or hide Kiro credits, percent, or both next to the menu bar icon." = "メニューバーアイコンの横に Kiro のクレジット、パーセント、またはその両方を表示/非表示にします。"; +"Show usage for organizations you belong to. Personal account is always shown." = "所属している組織の使用量を表示します。個人アカウントは常に表示されます。"; +"Sign in to cursor.com in your browser, then refresh Cursor in CodexBar." = "ブラウザで cursor.com にサインインしてから、CodexBar で Cursor を更新してください。"; +"Simulated error text" = "シミュレートされたエラーテキスト"; +"StepFun platform account (phone number or email)." = "StepFun プラットフォームのアカウント(電話番号またはメールアドレス)。"; +"Stored in ~/.codexbar/config.json." = "~/.codexbar/config.json に保存されます。"; +"Stored in ~/.codexbar/config.json. AZURE_OPENAI_API_KEY is also supported." = "~/.codexbar/config.json に保存されます。AZURE_OPENAI_API_KEY もサポートされています。"; +"Stored in ~/.codexbar/config.json. For the official Kimi API, use Moonshot / Kimi API." = "~/.codexbar/config.json に保存されます。公式の Kimi API には Moonshot / Kimi API を使用してください。"; +"Stored in ~/.codexbar/config.json. Get your API key from the Volcengine Ark console." = "~/.codexbar/config.json に保存されます。API キーは Volcengine Ark コンソールから取得してください。"; +"Stored in ~/.codexbar/config.json. Get your key from Ollama settings." = "~/.codexbar/config.json に保存されます。キーは Ollama の設定から取得してください。"; +"Stored in ~/.codexbar/config.json. Get your key from console.deepgram.com." = "~/.codexbar/config.json に保存されます。キーは console.deepgram.com から取得してください。"; +"Stored in ~/.codexbar/config.json. Get your key from elevenlabs.io/app/settings/api-keys." = "~/.codexbar/config.json に保存されます。キーは elevenlabs.io/app/settings/api-keys から取得してください。"; +"Stored in ~/.codexbar/config.json. Get your key from openrouter.ai/settings/keys and set a key spending limit there to enable API key quota tracking." = "~/.codexbar/config.json に保存されます。キーは openrouter.ai/settings/keys から取得し、そこでキーの支出上限を設定すると API キーのクォータ追跡が有効になります。"; +"Stored in ~/.codexbar/config.json. In Warp, open Settings > Platform > API Keys, then create one." = "~/.codexbar/config.json に保存されます。Warp で「Settings」>「Platform」>「API Keys」を開いて作成してください。"; +"Stored in ~/.codexbar/config.json. Metrics require Groq Enterprise Prometheus access." = "~/.codexbar/config.json に保存されます。メトリクスには Groq Enterprise の Prometheus アクセスが必要です。"; +"Stored in ~/.codexbar/config.json. OPENAI_ADMIN_KEY is preferred; OPENAI_API_KEY still works." = "~/.codexbar/config.json に保存されます。OPENAI_ADMIN_KEY が推奨されますが、OPENAI_API_KEY も引き続き使用できます。"; +"Stored in ~/.codexbar/config.json. Requires an Anthropic Admin API key." = "~/.codexbar/config.json に保存されます。Anthropic の Admin API キーが必要です。"; +"Stored in ~/.codexbar/config.json. Used for /v1/quota-stats." = "~/.codexbar/config.json に保存されます。/v1/quota-stats に使用されます。"; +"Stored in ~/.codexbar/config.json. You can also provide CODEBUFF_API_KEY or let CodexBar read ~/.config/manicode/credentials.json (created by `codebuff login`)." = "~/.codexbar/config.json に保存されます。CODEBUFF_API_KEY を指定するか、CodexBar に ~/.config/manicode/credentials.json(`codebuff login` で作成)を読み込ませることもできます。"; +"Stored in ~/.codexbar/config.json. You can also provide CROF_API_KEY." = "~/.codexbar/config.json に保存されます。CROF_API_KEY を指定することもできます。"; +"Stored in ~/.codexbar/config.json. You can also provide KILO_API_KEY or ~/.local/share/kilo/auth.json (kilo.access)." = "~/.codexbar/config.json に保存されます。KILO_API_KEY または ~/.local/share/kilo/auth.json(kilo.access)を指定することもできます。"; +"T3 Chat cookie" = "T3 Chat Cookie"; +"Team mode" = "チームモード"; +"That account is no longer available in CodexBar. Refresh the account list and try again." = "そのアカウントは CodexBar で利用できなくなっています。アカウントリストを更新してからやり直してください。"; +"The browser login did not complete in time. Try Antigravity login again." = "ブラウザでのログインが時間内に完了しませんでした。Antigravity のログインをもう一度お試しください。"; +"Timed out waiting for Cursor login. %@" = "Cursor のログイン待機がタイムアウトしました。%@"; +"Timed out waiting for Cursor login. %@ Last error: %@" = "Cursor のログイン待機がタイムアウトしました。%@ 最後のエラー: %@"; +"Today requests" = "本日のリクエスト"; +"Total (30d): %@ credits" = "合計(30日間): %@ クレジット"; +"Username" = "ユーザ名"; +"Uses username + password to login and obtain an Oasis-Token automatically." = "ユーザ名とパスワードでログインし、Oasis-Token を自動的に取得します。"; +"Uses username + password to login and obtain an %@ automatically." = "ユーザ名とパスワードでログインし、%@ を自動的に取得します。"; +"Utilization End" = "使用率終了"; +"Utilization Start" = "使用率開始"; +"Verbosity" = "詳細度"; +"Windsurf session JSON bundle" = "Windsurf セッション JSON バンドル"; +"Workspace ID" = "ワークスペース ID"; +"Your StepFun platform password. Used to login and obtain a session token." = "StepFun プラットフォームのパスワード。ログインしてセッショントークンを取得するために使用されます。"; +"claude /login exited with status %d." = "claude /login がステータス %d で終了しました。"; +"codex login exited with status %d." = "codex login がステータス %d で終了しました。"; +"Cookie: …\n\nor paste a cURL capture from the Abacus AI dashboard" = "Cookie: …\n\nまたは Abacus AI ダッシュボードからの cURL キャプチャを貼り付けてください"; +"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 トークンの値を貼り付けてください"; +"session_id=...\n\nor paste just the session_id value" = "session_id=...\n\nまたは session_id の値のみを貼り付けてください"; +"Clear" = "消去"; +"No matching providers" = "一致するプロバイダがありません"; +"Search providers" = "プロバイダを検索"; + +"language_vietnamese" = "ベトナム語"; +"language_turkish" = "トルコ語"; +"language_indonesian" = "インドネシア語"; +"language_arabic" = "العربية"; +"language_persian" = "فارسی"; +"language_thai" = "ไทย"; +"language_galician" = "Galego"; +"Limit Reset Credits" = "上限リセットクレジット"; +"1 available" = "1件利用可能"; +"%d available" = "%d件利用可能"; +"Next expires %@" = "次回の有効期限:%@"; +"Expires %@" = "%@ に期限切れ"; +"No expiry" = "有効期限なし"; +"Other (%d items)" = "その他(%d項目)"; +"Expand" = "展開"; +"Collapse" = "折りたたむ"; +"byte_unit_byte" = "バイト"; +"byte_unit_bytes" = "バイト"; +"byte_unit_kilobyte" = "キロバイト"; +"byte_unit_kilobytes" = "キロバイト"; +"byte_unit_megabyte" = "メガバイト"; +"byte_unit_megabytes" = "メガバイト"; +"byte_unit_gigabyte" = "ギガバイト"; +"byte_unit_gigabytes" = "ギガバイト"; + +/* Settings sidebar redesign */ +"Enable" = "有効にする"; +"Disable" = "無効にする"; +"providers_on_count" = "%d 件オン"; +"section_cost_summary" = "コスト概要"; +"section_command_line" = "コマンドライン"; +"section_privacy" = "プライバシー"; +"section_diagnostics" = "診断"; +"section_updates" = "アップデート"; +"section_links" = "リンク"; +"Show Codex Spark usage" = "Codex Spark の使用量を表示"; +"Shows Codex Spark quota rows in the menu and provider preview. Requires optional credits and extra usage in Display settings." = "メニューとプロバイダのプレビューに Codex Spark のクォータ行を表示します。表示設定で「クレジットと追加使用量を表示」を有効にする必要があります。"; +"Show Daily Routines usage" = "デイリールーティンの使用量を表示"; +"Shows the Daily Routines quota row in the menu and provider preview. Requires optional credits and extra usage in Display settings." = "メニューとプロバイダのプレビューにデイリールーティンのクォータ行を表示します。表示設定で「クレジットと追加使用量を表示」を有効にする必要があります。"; +"Scroll to see more models" = "スクロールして他のモデルを表示"; +"Copy Image" = "画像をコピー"; +"Copy Stats" = "統計をコピー"; +"Could not copy image" = "画像をコピーできませんでした"; +"Image copied" = "画像をコピーしました"; +"Image saved" = "画像を保存しました"; +"Nothing is uploaded. This image is created on your Mac." = "アップロードは行われません。この画像はMac上で作成されます。"; +"Save..." = "保存..."; +"Share AI Usage" = "AI使用状況を共有"; +"Share Stats…" = "統計を共有…"; +"Stats copied" = "統計をコピーしました"; +"DeepSeek this month token usage trend" = "今月の DeepSeek トークン使用量の推移"; +"Chrome profile" = "Chrome プロファイル"; +"Choose which signed-in DeepSeek Platform session supplies detailed usage." = "詳細な使用状況を取得する、ログイン済みの DeepSeek Platform セッションを選択します。"; +"Detailed usage unavailable." = "詳細な使用状況を取得できません。"; +"Sign in to DeepSeek Platform in Chrome for detailed usage." = "詳細な使用状況を確認するには、Chrome で DeepSeek Platform にログインしてください。"; +"Select a DeepSeek Chrome profile in Settings." = "設定で DeepSeek の Chrome プロファイルを選択してください。"; +"Select profile…" = "プロファイルを選択…"; + +"%@: %@" = "%@: %@"; +"Alternatively, set a custom path in Settings." = "または、設定でカスタムパスを指定します。"; +"Choose a supported browser so CodexBar can read the matching account." = "CodexBar が対応するアカウントを読み取れるよう、サポート対象のブラウザを選択してください。"; +"Choose Cursor account" = "Cursor アカウントを選択"; +"Choose which Cursor account CodexBar should use." = "CodexBar で使用する Cursor アカウントを選択してください。"; +"Finish switching to a different Cursor account in your browser, then try again." = "ブラウザで別の Cursor アカウントへの切り替えを完了してから、もう一度試してください。"; +"Install a JetBrains IDE with AI Assistant enabled, then refresh CodexBar." = "AI Assistant を有効にした JetBrains IDE をインストールしてから、CodexBar を更新してください。"; +"Request quota: %@ / %@" = "リクエスト上限: %@ / %@"; +"Sign in with Claude Code..." = "Claude Code でサインイン..."; +"Timed out waiting for Cursor account switch. %@" = "Cursor アカウントの切り替え待機中にタイムアウトしました。%@"; +"Timed out waiting for Cursor account switch. %@ Last error: %@" = "Cursor アカウントの切り替え待機中にタイムアウトしました。%@ 最後のエラー: %@"; +"Use Account" = "アカウントを使用する"; +/* Spend dashboard */ +"tab_usage_spend" = "使用量と支出"; +"Usage & Spend" = "使用量と支出"; +"Local estimated cost history across supported providers." = "対応プロバイダ全体のローカル推定コスト履歴。"; +"Time range" = "期間"; +"Track costs" = "コストを追跡"; +"Cost tracking is off" = "コスト追跡はオフです"; +"Turn on Track costs to build local estimates." = "「コストを追跡」をオンにしてローカル推定を作成してください。"; +"No local cost history yet" = "ローカルのコスト履歴はまだありません"; +"Turn on cost tracking or refresh after using a supported provider." = "コスト追跡をオンにするか、対応プロバイダの使用後に更新してください。"; +"Refresh failures" = "更新失敗"; +"Native currencies stay separate; Codex account rows exclude Pi session history." = "各通貨は別々に扱われ、Codex アカウント行には Pi セッション履歴を含めません。"; +"Spend unavailable" = "支出を取得できません"; +"Model breakdown unavailable" = "モデル別の内訳を取得できません"; +"Local estimated history" = "ローカル推定履歴"; +"Coverage" = "対象範囲"; +"Estimated spend" = "推定支出"; +"Tracked tokens" = "追跡対象トークン"; +"Subscriptions" = "サブスクリプション"; +"By subscription" = "サブスクリプション別"; +"No model-level history" = "モデル別の履歴はありません"; +"Daily estimated spend" = "日別推定支出"; +"≈%d full 5h windows of weekly left · %d windows until reset" = "週間枠は約%d回分の完全な5時間ウィンドウ · リセットまで%d回"; +"Weekly cannot run out before reset at this pace" = "このペースではリセット前に週間枠を使い切れません"; +"Weekly can run out ≈%d windows early" = "週間枠は約%dウィンドウ早く使い切る可能性があります"; +"Estimated: %@" = "推定:%@"; +"session_quota_estimate_value_format" = "%1$@%2$@"; +"session quota" = "セッション枠"; +"session quotas" = "セッション枠"; +"Coding Plan" = "コーディングプラン"; +"Agent Plan" = "エージェントプラン"; +"Team" = "チーム"; + +/* Menu bar layout editor */ +"menu_bar_layout_title" = "レイアウト"; +"menu_bar_layout_footer" = "トークンをドラッグしてメニューバーを並べます。クリックすると追加でき、配置済みのトークンを選択して Delete キーを押すと削除できます。"; +"menu_bar_layout_group_identity" = "識別情報"; +"menu_bar_layout_group_usage" = "使用量"; +"menu_bar_layout_group_time" = "時間"; +"menu_bar_layout_group_money" = "コスト"; +"menu_bar_layout_group_structure" = "構造"; +"menu_bar_layout_scope_all" = "すべてのプロバイダー"; +"menu_bar_layout_scope_help" = "既定のレイアウトを編集するか、プロバイダーごとに上書きします。"; +"menu_bar_layout_use_all" = "全プロバイダーのレイアウトを使用"; +"menu_bar_layout_preset" = "レイアウトプリセット"; +"menu_bar_layout_preset_icon_percent" = "アイコンと割合"; +"menu_bar_layout_preset_icon_only" = "アイコンのみ"; +"menu_bar_layout_preset_percent_reset" = "割合とリセット"; +"menu_bar_layout_preset_compact_stacked" = "コンパクトな2段表示"; +"menu_bar_layout_preset_custom" = "カスタム"; +"menu_bar_layout_live_preview" = "ライブプレビュー"; +"menu_bar_layout_strip" = "メニューバー表示"; +"menu_bar_layout_remove_line_break" = "改行を削除"; +"menu_bar_layout_chip_hint" = "選択、ドラッグで並べ替え、または削除アクションを使用します。"; +"menu_bar_layout_palette_hint" = "クリックで追加するか、レイアウトへドラッグします。"; +"menu_bar_layout_empty_line" = "ここにトークンをドロップ"; +"menu_bar_layout_line" = "%d 行目"; +"menu_bar_layout_drag_remove" = "ここへドラッグして削除"; +"menu_bar_layout_size" = "サイズ"; +"menu_bar_layout_size_small" = "小"; +"menu_bar_layout_size_regular" = "標準"; +"menu_bar_layout_gap" = "間隔"; +"menu_bar_layout_gap_tight" = "狭い"; +"menu_bar_layout_gap_regular" = "標準"; +"menu_bar_layout_keyboard_hint" = "Delete キーで選択したトークンを削除"; +"menu_bar_layout_sample_account" = "アカウント"; +"menu_bar_layout_sample_runs_out" = "金曜に使い切る"; +"menu_bar_layout_token_icon" = "アイコン"; +"menu_bar_layout_token_provider" = "プロバイダー名"; +"menu_bar_layout_token_account" = "アカウント"; +"menu_bar_layout_token_session" = "セッション %"; +"menu_bar_layout_token_weekly" = "週間 %"; +"menu_bar_layout_token_auto" = "自動 %"; +"menu_bar_layout_token_bar" = "使用量バー"; +"menu_bar_layout_token_resets_in" = "リセットまで"; +"menu_bar_layout_token_reset_at" = "リセット時刻"; +"menu_bar_layout_token_runs_out" = "使い切り"; +"menu_bar_layout_token_cost_today" = "今日のコスト"; +"menu_bar_layout_token_cost_30d" = "30日間のコスト"; +"menu_bar_layout_token_space" = "空白"; +"menu_bar_layout_token_line_break" = "改行"; +"menu_bar_layout_token_separator_accessibility" = "区切り点"; + +/* Menu bar layout accessibility */ +"Icon unavailable" = "アイコン: 利用不可"; +"%@ icon" = "%@: アイコン"; +"Provider name unavailable" = "プロバイダー名: 利用不可"; +"Account unavailable" = "アカウント: 利用不可"; +"%@ unavailable" = "%@: 利用不可"; +"%@ %@" = "%@ %@"; +"Usage bar unavailable" = "使用量バー: 利用不可"; +"Usage bar, %d of 3 filled" = "使用量バー: %d/3 点灯"; +"Reset countdown unavailable" = "リセットまで: 利用不可"; +"Reset time unavailable" = "リセット時刻: 利用不可"; +"Run-out estimate unavailable" = "使い切り: 利用不可"; +"Cost today unavailable" = "今日のコスト: 利用不可"; +"30-day cost unavailable" = "30日間のコスト: 利用不可"; +"Resets" = "リセット"; diff --git a/Sources/CodexBar/Resources/ja.lproj/Localizable.stringsdict b/Sources/CodexBar/Resources/ja.lproj/Localizable.stringsdict new file mode 100644 index 0000000000..e72992462d --- /dev/null +++ b/Sources/CodexBar/Resources/ja.lproj/Localizable.stringsdict @@ -0,0 +1,49 @@ + + + + + ≈%d full 5h windows of weekly left · %d windows until reset + + NSStringLocalizedFormatKey + %#@left@ · %#@until@ + left + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + d + one + 週間枠は約%d回分の完全な5時間ウィンドウ + other + 週間枠は約%d回分の完全な5時間ウィンドウ + + until + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + d + one + リセットまで%d回 + other + リセットまで%d回 + + + Weekly can run out ≈%d windows early + + NSStringLocalizedFormatKey + %#@early@ + early + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + d + one + 週間枠は約%dウィンドウ早く使い切る可能性があります + other + 週間枠は約%dウィンドウ早く使い切る可能性があります + + + + diff --git a/Sources/CodexBar/Resources/ko.lproj/Localizable.strings b/Sources/CodexBar/Resources/ko.lproj/Localizable.strings new file mode 100644 index 0000000000..fc68cc6ecc --- /dev/null +++ b/Sources/CodexBar/Resources/ko.lproj/Localizable.strings @@ -0,0 +1,1321 @@ +/* Korean (한국어) localization for CodexBar */ + +"tab_hooks" = "훅"; +"hooks_enable_title" = "훅 활성화"; +"hooks_enable_subtitle" = "할당량 또는 공급자 이벤트가 발생하면 외부 명령을 실행합니다."; +"hooks_trust_warning" = "훅은 Mac에서 로컬 명령을 실행할 수 있습니다. 신뢰하는 명령만 구성하세요."; +"hooks_rules_header" = "규칙"; +"hooks_empty" = "구성된 훅이 없습니다."; +"hooks_add_rule" = "규칙 추가"; +"hooks_delete_rule" = "규칙 삭제"; +"hooks_rule_enabled" = "활성화됨"; +"hooks_event" = "이벤트"; +"hooks_provider" = "공급자"; +"hooks_any_provider" = "모든 공급자"; +"hooks_threshold" = "사용량 ≥ 에서 실행"; +"hooks_threshold_placeholder" = "90"; +"hooks_executable_placeholder" = "/usr/local/bin/my-command"; +"hooks_arguments_placeholder" = "인수"; +"hooks_argument_placeholder" = "인수"; +"hooks_add_argument" = "인수 추가"; +"hooks_delete_argument" = "인수 삭제"; + +"ollama_safari_cookie_access_hint" = "Safari 쿠키를 읽으려면 CodexBar에 전체 디스크 접근 권한이 필요합니다(시스템 설정 > 개인정보 보호 및 보안)."; +"ollama_browser_cookie_decryption_denied" = "%@ 쿠키 복호화가 키체인에서 거부되었습니다. 수동 새로 고침으로 다시 시도하세요."; +"ollama_browser_cookie_decryption_disabled" = "%@ 쿠키 복호화가 CodexBar에서 비활성화되어 있습니다. 키체인 접근을 활성화하고 새로 고침하세요."; + +" providers" = " 공급자"; +"(System)" = "(시스템)"; +"30d" = "30일"; +"7d" = "7일"; +"A managed Codex login is already running. Wait for it to finish before adding " = "관리되는 Codex 로그인이 이미 실행 중입니다. 추가하기 전에 완료될 때까지 기다리세요. "; +"API key" = "API 키"; +"API region" = "API 지역"; +"API token" = "API 토큰"; +"API tokens" = "API 토큰"; +"About" = "정보"; +"Account" = "계정"; +"Accounts" = "계정"; +"Accounts subtitle" = "계정 부제"; +"Active" = "활성"; +"Add" = "추가"; +"Add Workspace" = "작업 공간 추가"; +"Advanced" = "고급"; +"All" = "전체"; +"Always allow prompts" = "항상 프롬프트 허용"; +"Animation pattern" = "애니메이션 패턴"; +"Antigravity login is managed in the app" = "Antigravity 로그인은 앱에서 관리됩니다"; +"Applies only to the Security.framework OAuth keychain reader." = "Security.framework OAuth 키체인 리더에만 적용됩니다."; +"Auto falls back to the next source if the preferred one fails." = "자동은 선호하는 소스가 실패하면 다음 소스로 대체합니다."; +"Auto uses API first, then falls back to CLI on auth failures." = "자동은 API를 먼저 사용하고, 인증 실패 시 CLI로 대체합니다."; +"Auto-detect" = "자동 감지"; +"Auto-refresh is off; use the menu's Refresh command." = "자동 새로 고침이 꺼져 있습니다. 메뉴의 새로 고침 명령을 사용하세요."; +"Auto-refresh: hourly · Timeout: 10m" = "자동 새로 고침: 매시간 · 시간 초과: 10분"; +"Automatic" = "자동"; +"Automatic imports browser cookies and WorkOS tokens." = "자동은 브라우저 쿠키와 WorkOS 토큰을 가져옵니다."; +"Automatic imports browser cookies and local storage tokens." = "자동은 브라우저 쿠키와 로컬 저장소 토큰을 가져옵니다."; +"Automatic imports browser cookies for dashboard extras." = "자동은 대시보드 추가 항목을 위한 브라우저 쿠키를 가져옵니다."; +"Automatic imports browser cookies for the web API." = "자동은 웹 API를 위한 브라우저 쿠키를 가져옵니다."; +"Automatic imports browser cookies from Model Studio/Bailian." = "자동은 Model Studio/Bailian에서 브라우저 쿠키를 가져옵니다."; +"Automatic imports browser cookies from admin.mistral.ai." = "자동은 admin.mistral.ai에서 브라우저 쿠키를 가져옵니다."; +"Automatic imports browser cookies from opencode.ai." = "자동은 opencode.ai에서 브라우저 쿠키를 가져옵니다."; +"Automatic imports browser cookies or stored sessions." = "자동은 브라우저 쿠키 또는 저장된 세션을 가져옵니다."; +"Automatic imports browser cookies." = "자동은 브라우저 쿠키를 가져옵니다."; +"Automatically imports browser session cookie." = "브라우저 세션 쿠키를 자동으로 가져옵니다."; +"Automatically opens CodexBar when you start your Mac." = "Mac을 시작할 때 CodexBar를 자동으로 엽니다."; +"Automation" = "자동화"; +"Average (\\(label1) + \\(label2))" = "평균 (\\(label1) + \\(label2))"; +"Average (\\(metadata.sessionLabel) + \\(metadata.weeklyLabel))" = "평균 (\\(metadata.sessionLabel) + \\(metadata.weeklyLabel))"; +"Avoid Keychain prompts" = "키체인 프롬프트 방지"; +"Balance" = "잔액"; +"Battery Saver" = "배터리 절약"; +"Bordered" = "테두리 있음"; +"Build" = "빌드"; +"Built \\(buildTimestamp)" = "빌드 \\(buildTimestamp)"; +"Buy Credits..." = "크레딧 구매..."; +"Buy Credits…" = "크레딧 구매…"; +"CLI paths" = "CLI 경로"; +"CLI sessions" = "CLI 세션"; +"Caches" = "캐시"; +"Cancel" = "취소"; +"Check for Updates…" = "업데이트 확인…"; +"Check for updates automatically" = "자동으로 업데이트 확인"; +"Check if you like your agents having some fun up there." = "에이전트가 위에서 즐기는 모습을 보고 싶다면 선택하세요."; +"Check provider status" = "공급자 상태 확인"; +"Choose Codex workspace" = "Codex 작업 공간 선택"; +"Choose the MiniMax host (global .io or China mainland .com)." = "MiniMax 호스트를 선택하세요 (글로벌 .io 또는 중국 본토 .com)."; +"Choose up to " = "최대 선택 "; +"Choose up to \\(Self.maxOverviewProviders) providers" = "최대 \\(Self.maxOverviewProviders)개 공급자 선택"; +"Choose up to \\(count) providers" = "최대 \\(count)개 공급자 선택"; +"Choose what to show in the menu bar (Pace shows usage vs. expected)." = "메뉴 막대에 표시할 항목을 선택하세요 (사용 속도는 예상 대비 사용량을 표시)."; +"Choose which Codex account CodexBar should follow." = "CodexBar가 따를 Codex 계정을 선택하세요."; +"Choose which window drives the menu bar percent." = "메뉴 막대 백분율을 결정하는 기간을 선택하세요."; +"Chrome" = "Chrome"; +"Claude CLI not found" = "Claude CLI를 찾을 수 없음"; +"Claude binary" = "Claude 바이너리"; +"Claude cookies" = "Claude 쿠키"; +"Claude login failed" = "Claude 로그인 실패"; +"Claude login timed out" = "Claude 로그인 시간 초과"; +"Close" = "닫기"; +"Code review" = "코드 검토"; +"Codex CLI not found" = "Codex CLI를 찾을 수 없음"; +"Codex account login already running" = "Codex 계정 로그인이 이미 진행 중입니다"; +"Codex binary" = "Codex 바이너리"; +"Codex login failed" = "Codex 로그인 실패"; +"Codex login timed out" = "Codex 로그인 시간 초과"; +"CodexBar Lifecycle Keepalive" = "CodexBar 수명 주기 유지"; +"CodexBar can't show its menu bar icon" = "CodexBar가 메뉴 막대 아이콘을 표시할 수 없습니다"; +"CodexBar could not read managed account storage. " = "CodexBar가 관리 계정 저장소를 읽을 수 없습니다. "; +"Configure…" = "구성…"; +"Connected" = "연결됨"; +"Controls how much detail is logged." = "기록되는 세부 정보의 양을 제어합니다."; +"Cookie header" = "쿠키 헤더"; +"Cookie source" = "쿠키 소스"; +"Cookie: ..." = "쿠키: ..."; +"Cookie: \\u{2026}\\\n\\\nor paste a cURL capture from the Abacus AI dashboard" = "쿠키: \\u{2026}\\\n\\\n또는 Abacus AI 대시보드에서 캡처한 cURL을 붙여넣으세요"; +"Cookie: \\u{2026}\\\n\\\nor paste the __Secure-next-auth.session-token value" = "쿠키: \\u{2026}\\\n\\\n또는 __Secure-next-auth.session-token 값을 붙여넣으세요"; +"Cookie: \\u{2026}\\\n\\\nor paste the kimi-auth token value" = "쿠키: \\u{2026}\\\n\\\n또는 kimi-auth 토큰 값을 붙여넣으세요"; +"Cookie: …" = "쿠키: …"; +"CopilotDeviceFlow" = "CopilotDeviceFlow"; +"Cost" = "비용"; +"Could not add Codex account" = "Codex 계정을 추가할 수 없음"; +"Could not open Terminal for Gemini" = "Gemini용 터미널을 열 수 없음"; +"Could not start claude /login" = "claude /login을 시작할 수 없음"; +"Could not start codex login" = "codex login을 시작할 수 없음"; +"Could not switch system account" = "시스템 계정을 전환할 수 없음"; +"Credits" = "크레딧"; +"Individual credits" = "개인 크레딧"; +"Workspace" = "작업 공간"; +"Credits history" = "크레딧 내역"; +"Cursor login failed" = "Cursor 로그인 실패"; +"Custom" = "사용자 설정"; +"Custom Path" = "사용자 설정 경로"; +"Daily Routines" = "일일 루틴"; +"Debug" = "디버그"; +"Default" = "기본값"; +"Disable Keychain access" = "키체인 접근 사용 안 함"; +"Disabled" = "사용 안 함"; +"Dismiss" = "무시"; +"Disconnected" = "연결 끊김"; +"Display" = "표시"; +"Display mode" = "표시 모드"; +"Display reset times as absolute clock values instead of countdowns." = "재설정 시간을 카운트다운 대신 절대 시각으로 표시합니다."; +"Done" = "완료"; +"Effective PATH" = "유효 PATH"; +"Email" = "이메일"; +"Enable Merge Icons to configure Overview tab providers." = "개요 탭 공급자를 구성하려면 아이콘 병합을 사용하세요."; +"Enable file logging" = "파일 로깅 사용"; +"Enabled" = "사용"; +"Error" = "오류"; +"Error simulation" = "오류 시뮬레이션"; +"Expose troubleshooting tools in the Debug tab." = "디버그 탭에 문제 해결 도구를 표시합니다."; +"Failed" = "실패"; +"False" = "False"; +"Fetch strategy attempts" = "가져오기 전략 시도"; +"Fetching" = "가져오는 중"; +"Field" = "필드"; +"Field subtitle" = "필드 부제목"; +"Finish the current managed account change before switching the system account." = "시스템 계정을 전환하기 전에 현재 관리 계정 변경을 완료하세요."; +"Force animation on next refresh" = "다음 새로 고침 시 애니메이션 강제 적용"; +"Gateway region" = "게이트웨이 리전"; +"Gemini CLI not found" = "Gemini CLI를 찾을 수 없음"; +"Gemini/Antigravity, surfacing incidents in the icon and menu." = "Gemini/Antigravity의 장애를 아이콘과 메뉴에 표시합니다."; +"General" = "일반"; +"GitHub" = "GitHub"; +"GitHub Copilot Login" = "GitHub Copilot 로그인"; +"GitHub Login" = "GitHub 로그인"; +"Hide details" = "세부 정보 가리기"; +"Hide personal information" = "개인 정보 가리기"; +"Historical tracking" = "기록 추적"; +"How often CodexBar polls providers in the background." = "CodexBar가 백그라운드에서 공급자를 폴링하는 빈도입니다."; +"Inactive" = "비활성"; +"Install CLI" = "CLI 설치"; +"Install the Claude CLI (npm i -g @anthropic-ai/claude-code) and try again." = "Claude CLI(npm i -g @anthropic-ai/claude-code)를 설치한 후 다시 시도하세요."; +"Install the Codex CLI (npm i -g @openai/codex) and try again." = "Codex CLI(npm i -g @openai/codex)를 설치한 후 다시 시도하세요."; +"Install the Gemini CLI (npm i -g @google/gemini-cli) and try again." = "Gemini CLI(npm i -g @google/gemini-cli)를 설치한 후 다시 시도하세요."; +"JetBrains AI is ready" = "JetBrains AI 준비 완료"; +"JetBrains IDE" = "JetBrains IDE"; +"Keep CLI sessions alive" = "CLI 세션 유지"; +"Keyboard shortcut" = "키보드 단축키"; +"Keychain access" = "키체인 접근"; +"Keychain prompt policy" = "키체인 프롬프트 정책"; +"Last \\(name) fetch failed:" = "마지막 \\(name) 가져오기 실패:"; +"Last \\(self.store.metadata(for: self.provider).displayName) fetch failed:" = "마지막 \\(self.store.metadata(for: self.provider).displayName) 가져오기 실패:"; +"Last attempt" = "마지막 시도"; +"Link" = "링크"; +"Loading animations" = "로딩 애니메이션"; +"Loading…" = "불러오는 중…"; +"Local" = "로컬"; +"Logging" = "로깅"; +"Login failed" = "로그인 실패"; +"Login shell PATH (startup capture)" = "로그인 셸 PATH(시작 시 캡처)"; +"Login timed out" = "로그인 시간 초과됨"; +"MCP details" = "MCP 세부 정보"; +"Managed Codex accounts unavailable" = "관리되는 Codex 계정을 사용할 수 없음"; +"Managed account storage is unreadable. Live account access is still available, " = "관리되는 계정 저장소를 읽을 수 없습니다. 실시간 계정 접근은 여전히 가능하며, "; +"Manual" = "수동"; +"May your tokens never run out—keep agent limits in view." = "토큰이 결코 바닥나지 않기를—에이전트 한도를 한눈에 확인하세요."; +"Menu bar" = "메뉴 막대"; +"Menu bar auto-shows the provider closest to its rate limit." = "메뉴 막대에 사용 한도에 가장 가까운 공급자를 자동으로 표시합니다."; +"Menu bar metric" = "메뉴 막대 지표"; +"Menu bar shows percent" = "메뉴 막대에 백분율 표시"; +"Menu content" = "메뉴 내용"; +"Merge Icons" = "아이콘 병합"; +"Never prompt" = "표시 안 함"; +"No" = "아니요"; +"No Codex accounts detected yet." = "아직 감지된 Codex 계정이 없습니다."; +"No JetBrains IDE detected" = "감지된 JetBrains IDE 없음"; +"No cost history data." = "비용 기록 데이터가 없습니다."; +"No data available" = "사용 가능한 데이터 없음"; +"No data yet" = "아직 데이터 없음"; +"No enabled providers available for Overview." = "개요에 사용할 수 있는 공급자가 없습니다."; +"No providers selected" = "선택된 공급자 없음"; +"No token accounts yet." = "아직 토큰 계정이 없습니다."; +"No usage breakdown data." = "사용량 분석 데이터가 없습니다."; +"None" = "없음"; +"Notifications" = "알림"; +"Notifies when the 5-hour session quota hits 0% and when it becomes " = "5시간 세션 할당량이 0%에 도달하거나 다시 사용 가능해질 때 알립니다 "; +"OK" = "확인"; +"Obscure email addresses in the menu bar and menu UI." = "메뉴 막대와 메뉴 UI에서 이메일 주소를 가립니다."; +"Off" = "끔"; +"Offline" = "오프라인"; +"On" = "켬"; +"Online" = "온라인"; +"Only on user action" = "사용자 작업 시에만"; +"Open" = "열기"; +"Open API Keys" = "API 키 열기"; +"Open Amp Settings" = "Amp 설정 열기"; +"Open Antigravity to sign in, then refresh CodexBar." = "Antigravity를 열어 로그인한 다음 CodexBar를 새로 고침하세요."; +"Open Browser" = "브라우저 열기"; +"Open Coding Plan" = "코딩 요금제 열기"; +"Open Console" = "콘솔 열기"; +"Open Dashboard" = "대시보드 열기"; +"Open Mistral Admin" = "Mistral 관리 열기"; +"Open Menu Bar Settings" = "메뉴 막대 설정 열기"; +"Open Ollama Settings" = "Ollama 설정 열기"; +"Open Terminal" = "터미널 열기"; +"Open Usage Page" = "사용량 페이지 열기"; +"Open Warp API Key Guide" = "Warp API 키 가이드 열기"; +"Open menu" = "메뉴 열기"; +"Open token file" = "토큰 파일 열기"; +"OpenAI cookies" = "OpenAI 쿠키"; +"OpenAI web extras" = "OpenAI 웹 추가 항목"; +"Option A" = "옵션 A"; +"Option B" = "옵션 B"; +"Optional override if workspace lookup fails." = "작업 공간 조회에 실패할 경우의 선택적 재정의 값입니다."; +"Options" = "옵션"; +"Override auto-detection with a custom IDE base path" = "사용자 설정 IDE 기본 경로로 자동 감지 재정의"; +"Overview" = "개요"; +"Overview rows always follow provider order." = "개요 행은 항상 공급자 순서를 따릅니다."; +"Overview tab providers" = "개요 탭 공급자"; +"Paste API key…" = "API 키 붙여넣기…"; +"Paste API token…" = "API 토큰 붙여넣기…"; +"Paste key…" = "키 붙여넣기…"; +"Paste sessionKey or OAuth token…" = "sessionKey 또는 OAuth 토큰 붙여넣기…"; +"Paste the Cookie header from a request to admin.mistral.ai. " = "admin.mistral.ai 요청의 Cookie 헤더를 붙여넣으세요. "; +"Paste token…" = "토큰 붙여넣기…"; +"Personal" = "개인"; +"Picker" = "선택기"; +"Picker subtitle" = "선택기 부제목"; +"Placeholder" = "플레이스홀더"; +"Plan" = "요금제"; +"Plan Usage" = "요금제 사용량"; +"Play full-screen confetti when weekly usage resets." = "주간 사용량이 재설정되면 전체 화면 색종이를 재생합니다."; +"Polls OpenAI/Claude status pages and Google Workspace for " = "OpenAI/Claude 상태 페이지와 Google Workspace를 폴링하여 "; +"Prevents any Keychain access while enabled." = "사용 중에는 모든 키체인 접근을 차단합니다."; +"Primary (API key limit)" = "기본 (API 키 한도)"; +"Primary (\\(label))" = "기본 (\\(label))"; +"Primary (\\(metadata.sessionLabel))" = "기본 (\\(metadata.sessionLabel))"; +"Probe logs" = "프로브 로그"; +"Progress bars fill as you consume quota (instead of showing remaining)." = "남은 양을 표시하는 대신 할당량을 소비할수록 진행 막대가 채워집니다."; +"Provider" = "공급자"; +"Providers" = "공급자"; +"Quit CodexBar" = "CodexBar 종료"; +"Random (default)" = "무작위 (기본값)"; +"Reads local usage logs. Shows today + last 30 days cost in the menu." = "로컬 사용량 로그를 읽습니다. 메뉴에 오늘과 선택한 기록 기간의 비용을 표시합니다."; +"Refresh" = "새로 고침"; +"Refresh cadence" = "새로 고침 주기"; +"Remote" = "원격"; +"Remove" = "제거"; +"Remove Codex account?" = "Codex 계정을 제거하시겠습니까?"; +"Remove \\(account.email) from CodexBar? Its managed Codex home will be deleted." = "CodexBar에서 \\(account.email)을(를) 제거하시겠습니까? 관리되는 Codex 홈이 삭제됩니다."; +"Remove \\(email) from CodexBar? Its managed Codex home will be deleted." = "CodexBar에서 \\(email)을(를) 제거하시겠습니까? 관리되는 Codex 홈이 삭제됩니다."; +"Remove selected account" = "선택한 계정 제거"; +"Replace critter bars with provider branding icons and a percentage." = "크리터 막대를 공급자 브랜딩 아이콘과 백분율로 대체합니다."; +"Replay selected animation" = "선택한 애니메이션 다시 재생"; +"Requires authentication via GitHub Device Flow." = "GitHub Device Flow를 통한 인증이 필요합니다."; +"Resets: \\(reset)" = "재설정: \\(reset)"; +"Rolling five-hour limit" = "5시간 롤링 한도"; +"Search hourly" = "시간당 검색"; +"Secondary (\\(label))" = "보조 (\\(label))"; +"Secondary (\\(metadata.weeklyLabel))" = "보조 (\\(metadata.weeklyLabel))"; +"Select a provider" = "공급자 선택"; +"Select the IDE to monitor" = "모니터링할 IDE 선택"; +"Session quota notifications" = "세션 할당량 알림"; +"Session tokens" = "세션 토큰"; +"provider_section_connection" = "연결"; +"provider_section_menu_bar" = "메뉴 막대"; +"Show Codex Credits and Claude Extra usage sections in the menu." = "메뉴에 Codex 크레딧 및 Claude 추가 사용량 섹션을 표시합니다."; +"Show Debug Settings" = "디버그 설정 표시"; +"Show all token accounts" = "모든 토큰 계정 표시"; +"Show cost summary" = "비용 요약 표시"; +"Show credits + extra usage" = "크레딧 + 추가 사용량 표시"; +"Show details" = "세부 정보 표시"; +"Show most-used provider" = "가장 많이 사용한 공급자 표시"; +"Show provider icons in the switcher (otherwise show a weekly progress line)." = "전환기에 공급자 아이콘을 표시합니다(그렇지 않으면 주간 진행률 막대를 표시)."; +"Show reset time as clock" = "재설정 시간을 시계로 표시"; +"Show usage as used" = "사용량을 사용한 양으로 표시"; +"Sign in via button below" = "아래 버튼으로 로그인"; +"Skip teardown between probes (debug-only)." = "프로브 간 정리를 건너뜁니다(디버그 전용)."; +"Stack token accounts in the menu (otherwise show an account switcher bar)." = "메뉴에 토큰 계정을 쌓아서 표시합니다(그렇지 않으면 계정 전환기 막대를 표시)."; +"Start at Login" = "로그인 시 시작"; +"Status" = "상태"; +"Store Claude sessionKey cookies or OAuth access tokens." = "Claude sessionKey 쿠키 또는 OAuth 액세스 토큰을 저장합니다."; +"Store multiple Abacus AI Cookie headers." = "여러 Abacus AI 쿠키 헤더를 저장합니다."; +"Store multiple Augment Cookie headers." = "여러 Augment 쿠키 헤더를 저장합니다."; +"Store multiple Cursor Cookie headers." = "여러 Cursor 쿠키 헤더를 저장합니다."; +"Store multiple Factory Cookie headers." = "여러 Factory 쿠키 헤더를 저장합니다."; +"Store multiple MiniMax Cookie headers." = "여러 MiniMax 쿠키 헤더를 저장합니다."; +"Store multiple Mistral Cookie headers." = "여러 Mistral 쿠키 헤더를 저장합니다."; +"Store multiple Ollama Cookie headers." = "여러 Ollama 쿠키 헤더를 저장합니다."; +"Store multiple OpenCode Cookie headers." = "여러 OpenCode 쿠키 헤더를 저장합니다."; +"Store multiple OpenCode Go Cookie headers." = "여러 OpenCode Go 쿠키 헤더를 저장합니다."; +"Stored in the CodexBar config file." = "CodexBar 설정 파일에 저장됩니다."; +"Stored in ~/.codexbar/config.json. " = "~/.codexbar/config.json에 저장됩니다. "; +"Stored in ~/.codexbar/config.json. Paste the key from the Synthetic dashboard." = "~/.codexbar/config.json에 저장됩니다. Synthetic 대시보드에서 키를 붙여넣으세요."; +"Stored in ~/.codexbar/config.json. Paste your Coding Plan API key from Model Studio." = "~/.codexbar/config.json에 저장됩니다. Model Studio에서 Coding Plan API 키를 붙여넣으세요."; +"Stored in ~/.codexbar/config.json. Paste your MiniMax API key." = "~/.codexbar/config.json에 저장됩니다. MiniMax API 키를 붙여넣으세요."; +"Stored in ~/.codexbar/config.json. You can also provide KILO_API_KEY or " = "~/.codexbar/config.json에 저장됩니다. KILO_API_KEY를 제공할 수도 있습니다. "; +"Stores local Codex usage history (8 weeks) to personalize Pace predictions." = "사용 속도 예측을 개인화하기 위해 로컬 Codex 사용 기록(8주)을 저장합니다."; +"Surprise me" = "랜덤으로 선택"; +"Switcher shows icons" = "전환기에 아이콘 표시"; +"Symlink CodexBarCLI to /usr/local/bin and /opt/homebrew/bin as codexbar." = "CodexBarCLI를 codexbar로 /usr/local/bin 및 /opt/homebrew/bin에 심볼릭 링크합니다."; +"System" = "시스템"; +"Temporarily shows the loading animation after the next refresh." = "다음 새로 고침 후 불러오는 중 애니메이션을 일시적으로 표시합니다."; +"terminal_app_subtitle" = "터미널 열기 동작에서 사용하는 터미널"; +"terminal_app_title" = "기본 터미널"; +"Tertiary (\\(label))" = "3차 (\\(label))"; +"Tertiary (\\(tertiaryTitle))" = "3차 (\\(tertiaryTitle))"; +"The default Codex account on this Mac." = "이 Mac의 기본 Codex 계정입니다."; +"Toggle" = "전환"; +"Toggle subtitle" = "부제목 전환"; +"Token" = "토큰"; +"Trigger the menu bar menu from anywhere." = "어디서나 메뉴 막대 메뉴를 실행합니다."; +"True" = "True"; +"Twitter" = "Twitter"; +"Unsupported" = "지원되지 않음"; +"Update Channel" = "업데이트 채널"; +"Updated" = "업데이트됨"; +"Updates unavailable in this build." = "이 빌드에서는 업데이트를 사용할 수 없습니다."; +"Usage" = "사용량"; +"Usage breakdown" = "사용량 내역"; +"Usage history (30 days)" = "사용 기록"; +"Usage source" = "사용량 소스"; +"Use BigModel for the China mainland endpoints (open.bigmodel.cn)." = "중국 본토 엔드포인트에 BigModel을 사용합니다(open.bigmodel.cn)."; +"Use a single menu bar icon with a provider switcher." = "공급자 전환기와 함께 단일 메뉴 막대 아이콘 사용"; +"Use international or China mainland console gateways for quota fetches." = "할당량 가져오기에 국제 또는 중국 본토 콘솔 게이트웨이 사용"; +"Version" = "버전"; +"Version \\(self.versionString)" = "버전 \\(self.versionString)"; +"Version \\(version)" = "버전 \\(version)"; +"Version \\(versionString)" = "버전 \\(versionString)"; +"Vertex AI Login" = "Vertex AI 로그인"; +"Wait for the current managed Codex login to finish before adding another account." = "다른 계정을 추가하기 전에 현재 진행 중인 관리형 Codex 로그인이 끝날 때까지 기다리세요."; +"Waiting for Authentication..." = "인증 대기 중..."; +"Website" = "웹사이트"; +"Weekly limit confetti" = "주간 한도 콘페티"; +"Weekly token limit" = "주간 토큰 한도"; +"Weekly usage" = "주간 사용량"; +"Weekly usage unavailable for this account." = "이 계정에서는 주간 사용량을 사용할 수 없습니다."; +"Window: \\(window)" = "기간: \\(window)"; +"Write logs to \\(self.fileLogPath) for debugging." = "디버깅을 위해 \\(self.fileLogPath)에 로그 기록"; +"Yes" = "예"; +"\\(detail.modelCode): \\(usage)" = "\\(detail.modelCode): \\(usage)"; +"\\(name): \\(truncated)" = "\\(name): \\(truncated)"; +"\\(name): \\(updated) · 30d \\(cost)" = "\\(name): \\(updated) · 30일 \\(cost)"; +"\\(name): fetching…\\(elapsed)" = "\\(name): 가져오는 중…\\(elapsed)"; +"\\(name): last attempt \\(when)" = "\\(name): 마지막 시도 \\(when)"; +"\\(name): no data yet" = "\\(name): 아직 데이터 없음"; +"\\(name): unsupported" = "\\(name): 지원 안 함"; +"all browsers" = "모든 브라우저"; +"available again." = "다시 사용 가능합니다."; +"built_format" = "빌드 %@"; +"copilot_complete_in_browser" = "브라우저에서 로그인을 완료하세요."; +"copilot_device_code" = "기기 코드가 클립보드에 복사됨: %1$@\n\n확인 위치: %2$@"; +"copilot_device_code_copied" = "기기 코드가 복사되었습니다."; +"copilot_verify_at" = "%@에서 확인"; +"copilot_waiting_text" = "브라우저에서 로그인을 완료하세요.\n로그인이 완료되면 이 창은 자동으로 닫힙니다."; +"copilot_window_closes_auto" = "로그인이 완료되면 이 창은 자동으로 닫힙니다."; +"cost_status_error" = "%1$@: %2$@"; +"cost_status_fetching" = "%1$@: 가져오는 중… %2$@"; +"cost_status_last_attempt" = "%1$@: 마지막 시도 %2$@"; +"cost_status_no_data" = "%@: 아직 데이터 없음"; +"cost_status_snapshot" = "%1$@: %2$@ · %3$@ %4$@"; +"cost_status_unsupported" = "%@: 지원 안 함"; +"credits_remaining" = "크레딧: %@"; +"cursor_on_demand" = "온디맨드: %@"; +"cursor_on_demand_with_limit" = "온디맨드: %1$@ / %2$@"; +"extra_usage_format" = "추가 사용량: %1$@ / %2$@"; +"jetbrains_detected_generate" = "감지됨: %@. AI 어시스턴트를 한 번 사용하여 할당량 데이터를 생성한 다음 CodexBar를 새로 고치세요."; +"jetbrains_detected_select" = "감지됨: %@. 설정에서 선호하는 IDE를 선택한 다음 CodexBar를 새로 고치세요."; +"last_fetch_failed_with_provider" = "마지막 %@ 가져오기 실패:"; +"last_spend" = "마지막 지출: %@"; +"mcp_model_usage" = "%1$@: %2$@"; +"mcp_resets" = "재설정: %@"; +"mcp_window" = "기간: %@"; +"metric_average" = "평균 (%1$@ + %2$@)"; +"metric_primary" = "1차 (%@)"; +"metric_secondary" = "2차 (%@)"; +"metric_tertiary" = "3차 (%@)"; +"multiple_workspaces_found" = "CodexBar가 %@에 대한 여러 작업 공간을 찾았습니다. 추가할 작업 공간을 선택하세요."; +"ory_session_…=…; csrftoken=…" = "ory_session_…=…; csrftoken=…"; +"overview_choose_providers" = "최대 %@개의 공급자 선택"; +"remove_account_message" = "CodexBar에서 %@을(를) 제거하시겠습니까? 관리형 Codex 홈이 삭제됩니다."; +"version_format" = "버전 %@"; +"vertex_ai_login_instructions" = "Vertex AI 사용량을 추적하려면 Google Cloud로 인증하세요.\n\n1. 터미널 열기\n2. 실행: gcloud auth application-default login\n3. 브라우저 안내에 따라 로그인\n4. 프로젝트 설정: gcloud config set project PROJECT_ID\n\n지금 터미널을 여시겠습니까?"; +"workspaceID is set but only opencode, opencodego, and deepgram support workspaceID." = "workspaceID가 설정되었지만 opencode, opencodego, deepgram만 workspaceID를 지원합니다."; +"© 2026 Peter Steinberger. MIT License." = "© 2026 Peter Steinberger. MIT License."; +"section_system" = "시스템"; +"section_usage" = "사용량"; +"section_refreshing" = "새로 고침"; +"section_alerts" = "알림"; +"section_celebrations" = "축하"; +"section_icon" = "아이콘"; +"section_combined_icon" = "통합 아이콘"; +"section_animation" = "애니메이션"; +"section_content" = "콘텐츠"; +"section_agent_sessions" = "에이전트 세션"; +"language_title" = "언어"; +"language_subtitle" = "표시 언어를 변경합니다. 적용하려면 앱을 다시 시작해야 합니다."; +"currency_title" = "기본 통화"; +"currency_subtitle" = "비용 추정 및 지출 지표에 사용할 통화입니다. 매일 갱신되는 환율을 사용합니다."; +"currency_auto" = "자동(제공업체 / USD 따름)"; +"language_system" = "시스템"; +"language_english" = "영어"; +"language_spanish" = "스페인어"; +"language_catalan" = "카탈루냐어"; +"language_chinese_simplified" = "중국어(간체)"; +"language_chinese_traditional" = "중국어(번체)"; +"language_portuguese_brazilian" = "포르투갈어(브라질)"; +"language_german" = "독일어"; +"language_dutch" = "네덜란드어"; +"language_swedish" = "스웨덴어"; +"language_french" = "프랑스어"; +"language_ukrainian" = "우크라이나어"; +"language_russian" = "Русский"; +"language_japanese" = "일본어"; +"language_korean" = "한국어"; +"language_italian" = "Italiano"; +"language_polish" = "폴란드어"; +"start_at_login_title" = "로그인 시 시작"; +"start_at_login_subtitle" = "Mac을 시작할 때 CodexBar를 자동으로 엽니다."; +"show_cost_summary_subtitle" = "로컬 사용량 로그를 읽습니다. 메뉴에 오늘 및 선택한 기록 범위를 표시합니다."; +"cost_summary_style_title" = "표시 스타일"; +"cost_summary_style_inline" = "인라인만"; +"cost_summary_style_submenu" = "하위 메뉴만"; +"cost_summary_style_both" = "둘 다"; +"cost_summary_style_inline_help" = "비용 요약을 기본 메뉴에 직접 표시합니다."; +"cost_summary_style_submenu_help" = "대신 자세한 비용 하위 메뉴를 표시합니다."; +"cost_summary_style_both_help" = "기본 메뉴 요약과 자세한 비용 하위 메뉴를 모두 표시합니다."; +"cost_history_window_title" = "기록 범위"; +"cost_history_window_help" = "메뉴에 표시할 로컬 사용량 로그 일수를 설정합니다."; +"cost_history_days_title" = "기록 범위: %d일"; +"cost_auto_refresh_info" = "자동 새로 고침: 전역 간격(최소 5분) · 시간 초과: 10분"; +"cost_comparison_periods_title" = "더 짧은 비교 기간 표시"; +"cost_comparison_periods_subtitle" = "선택한 기록 범위에 포함되는 경우 7일, 30일, 90일 합계를 추가합니다. 이 합계에는 동일한 로컬 스캔을 재사용합니다."; +"refresh_interval_title" = "새로 고침 주기"; +"manual_refresh_hint" = "자동 새로 고침이 꺼져 있습니다. 메뉴의 새로 고침 명령을 사용하세요."; +"refresh_on_open_title" = "메뉴를 열 때 새로 고침"; +"refresh_on_open_subtitle" = "메뉴를 열 때마다 모든 공급자의 최신 사용량을 가져옵니다."; +"check_provider_status_title" = "공급자 상태 확인"; +"check_provider_status_subtitle" = "OpenAI/Claude 상태 페이지와 Gemini/Antigravity용 Google Workspace를 폴링하여 아이콘과 메뉴에 문제를 표시합니다."; +"session_quota_notifications_subtitle" = "5시간 세션 할당량이 0%에 도달할 때와 다시 사용할 수 있게 될 때 알립니다."; +"quota_depleted_title" = "할당량 소진 및 복원"; +"quota_warning_notifications_subtitle" = "세션 또는 주간 할당량 잔여량이 설정된 임곗값을 넘으면 경고합니다."; +"threshold_warnings_title" = "임곗값 경고"; +"quota_warnings_title" = "할당량 경고"; +"quota_warning_session" = "세션"; +"quota_warning_session_capitalized" = "세션"; +"quota_warning_weekly" = "주간"; +"quota_warning_weekly_capitalized" = "주간"; +"quota_warning_notification_title" = "%1$@ %2$@ 할당량 부족"; +"quota_warning_notification_body" = "%1$@ 남음. %2$d%% %3$@ 경고 임곗값에 도달했습니다."; +"quota_warning_notification_body_with_account" = "계정 %1$@. %2$@ 남음. %3$d%% %4$@ 경고 임곗값에 도달했습니다."; +"predictive_pace_warnings_title" = "예측 속도 경고"; +"predictive_pace_warnings_subtitle" = "Codex 및 Claude에서 세션 또는 주간 사용 속도로 인해 재설정 전에 할당량이 소진될 수 있으면 경고합니다."; +"confetti_on_reset_title" = "재설정 시 색종이"; +"confetti_on_reset_subtitle" = "사용량이 재설정될 때 전체 화면 색종이를 재생합니다."; +"confetti_option_off" = "끔"; +"confetti_option_session" = "세션 재설정"; +"confetti_option_weekly" = "주간 재설정"; +"confetti_option_both" = "둘 다"; +"predictive_pace_warning_notification_title" = "%1$@ %2$@ 사용 속도 경고"; +"predictive_pace_warning_notification_body" = "현재 속도라면 이 할당량이 재설정 전에 %1$@ 후 소진될 수 있습니다."; +"predictive_pace_warning_notification_body_with_account" = "계정 %1$@. 현재 속도라면 이 할당량이 재설정 전에 %2$@ 후 소진될 수 있습니다."; +"session_depleted_notification_title" = "%@ 세션 소진됨"; +"session_depleted_notification_body" = "0% 남음. 다시 사용할 수 있게 되면 알립니다."; +"session_restored_notification_title" = "%@ 세션 복원됨"; +"session_restored_notification_body" = "세션 할당량을 다시 사용할 수 있습니다."; +"quota_warning_warn_at" = "경고 기준"; +"quota_warning_global_threshold_subtitle" = "공급자가 재정의하지 않는 한 세션 및 주간 범위의 잔여 백분율입니다."; +"quota_warning_sound" = "알림 소리 재생"; +"quota_warning_onscreen_alert" = "화면에 텍스트 알림 표시"; +"quota_warning_provider_inherits" = "여기서 범위를 사용자 설정하지 않는 한 전역 할당량 경고 설정을 사용합니다."; +"quota_warning_provider_disabled" = "할당량 경고 알림과 사용량 막대 표시기가 꺼져 있습니다. 저장된 설정을 편집하려면 둘 중 하나를 켜세요."; +"quota_warning_provider_markers_only" = "할당량 경고 알림이 전역에서 꺼져 있습니다. 이 설정은 계속 사용량 막대 표시기를 제어합니다."; +"quota_warning_global" = "전역"; +"quota_warning_customize_thresholds" = "%@ 임곗값 사용자 설정"; +"quota_warning_enable_warnings" = "%@ 경고 사용"; +"quota_warning_window_warn_at" = "%@ 경고 기준"; +"quota_warning_off" = "사용 안 함"; +"quota_warning_inherited" = "상속됨: %@"; +"quota_warning_depleted_only" = "소진 시에만"; +"quota_warning_upper" = "더 높음"; +"quota_warning_lower" = "하한"; +"quota_warning_warning" = "경고"; +"quota_warning_critical" = "심각"; +"apply" = "적용"; +"quit_app" = "CodexBar 종료"; +"tab_general" = "일반"; +"tab_providers" = "공급자"; +"tab_notifications" = "알림"; +"tab_menu_bar" = "메뉴 막대"; +"tab_menu" = "메뉴"; +"tab_advanced" = "고급"; +"tab_about" = "정보"; +"tab_debug" = "디버그"; +"select_a_provider" = "공급자 선택"; +"cancel" = "취소"; +"last_fetch_failed" = "마지막 가져오기 실패"; +"usage_not_fetched_yet" = "사용량을 아직 가져오지 않음"; +"managed_account_storage_unreadable" = "관리 계정 저장소를 읽을 수 없습니다. 실시간 계정 접근은 계속 사용할 수 있지만, 저장소를 복구할 수 있을 때까지 관리 추가, 재인증, 제거 작업은 사용할 수 없습니다."; +"remove_codex_account_title" = "Codex 계정을 제거하시겠습니까?"; +"remove" = "제거"; +"managed_login_already_running" = "관리 Codex 로그인이 이미 실행 중입니다. 다른 계정을 추가하거나 재인증하기 전에 완료될 때까지 기다리세요."; +"managed_login_failed" = "관리 Codex 로그인이 완료되지 않았습니다. 터미널에서 `codex --version`이 작동하는지 확인하세요. macOS가 `codex`를 차단했거나 휴지통으로 옮긴 경우, 오래된 중복 설치를 제거하고 `npm install -g --include=optional @openai/codex@latest`를 실행한 다음 다시 시도하세요."; +"codex_login_output" = "codex 로그인 출력:"; +"managed_login_missing_email" = "Codex 로그인은 완료되었지만 계정 이메일을 가져올 수 없습니다. 계정이 완전히 로그인되었는지 확인한 후 다시 시도하세요."; +"login_success_notification_title" = "%@ 로그인 성공"; +"login_success_notification_body" = "앱으로 돌아가셔도 됩니다. 인증이 완료되었습니다."; +"workspace_selection_cancelled" = "CodexBar에서 여러 작업 공간을 찾았지만 선택된 작업 공간이 없습니다."; +"unsafe_managed_home" = "CodexBar가 예기치 않은 관리 홈 경로 수정을 거부했습니다: %@"; +"menu_bar_metric_title" = "메뉴 막대 지표"; +"menu_bar_metric_subtitle" = "메뉴 막대 백분율을 결정할 창을 선택하세요."; +"menu_bar_metric_subtitle_deepseek" = "메뉴 막대에 DeepSeek 잔액을 표시합니다."; +"menu_bar_metric_subtitle_moonshot" = "메뉴 막대에 Moonshot / Kimi API 잔액을 표시합니다."; +"menu_bar_metric_subtitle_mistral" = "메뉴 막대에 이번 달 Mistral API 지출을 표시합니다."; +"automatic" = "자동"; +"primary_api_key_limit" = "기본 (API 키 한도)"; +"menu_bar_style_title" = "메뉴 막대 스타일"; +"menu_bar_style_subtitle" = "메뉴 막대 항목의 표시 방식을 선택합니다."; +"menu_bar_inactive_display_contrast_title" = "비활성 디스플레이에서 가시성 향상"; +"menu_bar_usage_colors_title" = "사용량 색상 표시"; +"menu_bar_usage_colors_subtitle" = "사용량이 늘어남에 따라 메뉴 막대 아이콘을 초록색에서 빨간색으로 표시합니다."; +"menu_bar_inactive_display_contrast_subtitle" = "고대비 렌더링을 사용하여 다른 디스플레이에서도 아이콘과 지표를 읽기 쉽게 유지합니다."; +"menu_bar_style_critters" = "크리터"; +"menu_bar_style_bars" = "미터 막대"; +"menu_bar_style_icon_percent" = "아이콘 및 백분율"; +"switcher_rows_title" = "전환기 행"; +"switcher_rows_icons" = "공급자 아이콘"; +"switcher_rows_progress" = "주간 진행률"; +"usage_bars_fill_title" = "사용량 막대 채우기"; +"usage_bars_fill_remaining" = "남은 양 기준"; +"usage_bars_fill_used" = "사용량 기준"; +"reset_times_title" = "재설정 시간"; +"reset_times_countdown" = "카운트다운"; +"reset_times_clock" = "시각"; +"cost_summary_title" = "비용 요약"; +"cost_summary_off" = "끔"; +"merge_icons_title" = "아이콘 병합"; +"merge_icons_subtitle" = "공급자 전환기가 있는 단일 메뉴 막대 아이콘을 사용합니다."; +"show_most_used_provider_title" = "가장 많이 사용한 공급자 표시"; +"show_most_used_provider_subtitle" = "메뉴 막대에 사용 한도에 가장 가까운 공급자를 자동으로 표시합니다."; +"display_mode_title" = "표시 모드"; +"display_mode_subtitle" = "메뉴 막대에 표시할 내용을 선택하세요(사용 속도는 사용량 대 예상치를 표시)."; +"show_quota_warning_markers_title" = "할당량 경고 표시기 표시"; +"show_quota_warning_markers_subtitle" = "할당량 경고가 구성된 경우 사용량 막대에 임계값 눈금 표시를 그립니다."; +"weekly_progress_work_days_title" = "주간 진행률 근무일"; +"weekly_progress_work_days_subtitle" = "주간 사용량 막대 눈금과 페이스 계산에 사용할 근무일을 설정합니다."; +"show_provider_changelog_links_title" = "공급자 변경 로그 링크 표시"; +"show_provider_changelog_links_subtitle" = "지원되는 CLI 기반 공급자의 릴리스 노트 링크를 메뉴에 추가합니다."; +"show_credits_extra_usage_title" = "크레딧 + 추가 사용량 표시"; +"show_credits_extra_usage_subtitle" = "메뉴에 Codex 크레딧 및 Claude 추가 사용량 섹션을 표시합니다."; +"multi_account_layout_title" = "다중 계정 레이아웃"; +"multi_account_layout_subtitle" = "분할된 계정 전환 또는 쌓인 계정 카드를 선택하세요."; +"multi_account_layout_segmented" = "분할"; +"multi_account_layout_stacked" = "쌓기"; +"overview_tab_providers_title" = "개요 탭 공급자"; +"configure" = "구성…"; +"overview_enable_merge_icons_hint" = "개요 탭 공급자를 구성하려면 아이콘 병합을 사용하세요."; +"overview_no_providers_hint" = "개요에 사용할 수 있는 활성화된 공급자가 없습니다."; +"overview_rows_follow_order" = "개요 행은 항상 공급자 순서를 따릅니다."; +"overview_no_providers_selected" = "선택된 공급자 없음"; +"agent_sessions_title" = "에이전트 세션"; +"agent_sessions_subtitle" = "메뉴에 로컬 및 SSH로 검색된 Codex 및 Claude Code 세션을 표시합니다."; +"agent_sessions_hosts_title" = "추가 SSH 호스트"; +"agent_sessions_footer" = "tailnet의 Mac은 자동으로 검색됩니다. 로컬 세션은 30초마다, 원격 호스트는 60초마다 그리고 메뉴를 열 때 새로 고칩니다."; +"agent_session_labels_title" = "세션 레이블"; +"agent_session_labels_subtitle" = "에이전트 세션 이름 지정 방법을 선택합니다."; +"agent_session_label_project" = "프로젝트"; +"agent_session_label_descriptive" = "설명형"; +"agent_session_label_descriptive_and_project" = "설명형 + 프로젝트"; +"agent_session_unknown_project" = "알 수 없는 프로젝트"; +"section_keyboard_shortcut" = "키보드 단축키"; +"open_menu_shortcut_title" = "메뉴 열기"; +"open_menu_shortcut_subtitle" = "어디서나 메뉴 막대 메뉴를 실행합니다."; +"install_cli" = "CLI 설치"; +"install_cli_subtitle" = "CodexBarCLI를 codexbar로 /usr/local/bin 및 /opt/homebrew/bin에 심볼릭 링크합니다."; +"cli_not_found" = "앱 번들에서 CodexBarCLI를 찾을 수 없습니다."; +"no_writable_bin_dirs" = "쓰기 가능한 bin 디렉터리를 찾을 수 없습니다."; +"show_debug_settings_title" = "디버그 설정 표시"; +"show_debug_settings_subtitle" = "디버그 탭에 문제 해결 도구를 표시합니다."; +"surprise_me_title" = "깜짝 놀래주기"; +"surprise_me_subtitle" = "에이전트가 저 위에서 조금 즐기는 모습이 마음에 드는지 확인해 보세요."; +"hide_personal_info_title" = "개인 정보 가리기"; +"hide_personal_info_subtitle" = "메뉴 막대와 메뉴 UI에서 이메일 주소를 가립니다."; +"show_provider_storage_usage_title" = "공급자 저장 공간 사용량 표시"; +"show_provider_storage_usage_subtitle" = "메뉴에 로컬 디스크 사용량을 표시합니다. 알려진 공급자 소유 경로를 백그라운드에서 스캔합니다."; +"section_keychain_access" = "키체인 접근"; +"keychain_access_caption" = "모든 키체인 읽기 및 쓰기를 사용 안 함으로 설정합니다. 항상 허용을 클릭한 후에도 macOS가 'Chrome/Brave/Edge Safe Storage'를 계속 요청하는 경우 사용하세요. 사용 시 브라우저 쿠키 가져오기를 사용할 수 없으며, 공급자에서 Cookie 헤더를 직접 붙여넣으세요. CLI를 통한 Claude/Codex OAuth는 계속 작동합니다."; +"disable_keychain_access_title" = "키체인 접근 사용 안 함"; +"disable_keychain_access_subtitle" = "사용 시 모든 키체인 접근을 차단합니다."; +"about_tagline" = "토큰이 결코 바닥나지 않기를—에이전트 한도를 늘 확인하세요."; +"link_github" = "GitHub"; +"link_website" = "웹사이트"; +"link_twitter" = "Twitter"; +"link_email" = "이메일"; +"check_updates_auto" = "자동으로 업데이트 확인"; +"update_channel" = "업데이트 채널"; +"check_for_updates" = "업데이트 확인…"; +"updates_unavailable" = "이 빌드에서는 업데이트를 사용할 수 없습니다."; +"copyright" = "© 2026 Peter Steinberger. MIT License."; +"section_logging" = "로깅"; +"enable_file_logging" = "파일 로깅 사용"; +"enable_file_logging_subtitle" = "디버깅을 위해 %@에 로그를 기록합니다."; +"verbosity_title" = "상세 수준"; +"verbosity_subtitle" = "로그에 기록되는 세부 정보의 양을 제어합니다."; +"open_log_file" = "로그 파일 열기"; +"force_animation_next_refresh" = "다음 새로 고침 시 애니메이션 강제 실행"; +"force_animation_next_refresh_subtitle" = "다음 새로 고침 후 불러오는 중 애니메이션을 일시적으로 표시합니다."; +"section_loading_animations" = "불러오는 중 애니메이션"; +"loading_animations_caption" = "패턴을 선택하여 메뉴 막대에서 다시 재생하세요. \"무작위\"는 기존 동작을 유지합니다."; +"animation_random_default" = "무작위(기본값)"; +"replay_selected_animation" = "선택한 애니메이션 다시 재생"; +"blink_now" = "지금 깜박이기"; +"section_probe_logs" = "프로브 로그"; +"probe_logs_caption" = "디버깅을 위해 최신 프로브 출력을 가져옵니다. 복사하면 전체 텍스트가 유지됩니다."; +"fetch_log" = "로그 가져오기"; +"copy" = "복사"; +"save_to_file" = "파일로 저장"; +"load_parse_dump" = "파싱 덤프 불러오기"; +"rerun_provider_autodetect" = "공급자 자동 감지 다시 실행"; +"loading" = "불러오는 중…"; +"no_log_yet_fetch" = "아직 로그가 없습니다. 가져오기를 눌러 불러오세요."; +"section_fetch_strategy" = "가져오기 전략 시도"; +"fetch_strategy_caption" = "공급자에 대한 마지막 가져오기 파이프라인 결정과 오류입니다."; +"section_openai_cookies" = "OpenAI 쿠키"; +"openai_cookies_caption" = "마지막 OpenAI 쿠키 시도의 쿠키 가져오기 및 WebKit 스크레이프 로그입니다."; +"no_log_yet" = "아직 로그가 없습니다. 공급자 → Codex에서 OpenAI 쿠키를 업데이트하여 가져오기를 실행하세요."; +"section_caches" = "캐시"; +"caches_caption" = "캐시된 비용 스캔 결과 또는 브라우저 쿠키 캐시를 지웁니다."; +"clear_cookie_cache" = "쿠키 캐시 지우기"; +"clear_cost_cache" = "비용 캐시 지우기"; +"section_notifications" = "알림"; +"notifications_caption" = "5시간 세션 기간에 대한 테스트 알림을 트리거합니다(소진/복원)."; +"post_depleted" = "소진 알림 보내기"; +"post_restored" = "복원 알림 보내기"; +"section_cli_sessions" = "CLI 세션"; +"cli_sessions_caption" = "프로브 후에도 Codex/Claude CLI 세션을 유지합니다. 기본값은 데이터가 캡처되면 종료합니다."; +"keep_cli_sessions_alive" = "CLI 세션 유지"; +"keep_cli_sessions_alive_subtitle" = "프로브 간 종료를 건너뜁니다(디버그 전용)."; +"reset_cli_sessions" = "CLI 세션 재설정"; +"section_error_simulation" = "오류 시뮬레이션"; +"error_simulation_caption" = "레이아웃 테스트를 위해 메뉴 카드에 가짜 오류 메시지를 삽입합니다."; +"set_menu_error" = "메뉴 오류 설정"; +"clear_menu_error" = "메뉴 오류 지우기"; +"set_cost_error" = "비용 오류 설정"; +"clear_cost_error" = "비용 오류 지우기"; +"section_cli_paths" = "CLI 경로"; +"cli_paths_caption" = "확인된 Codex 바이너리와 PATH 계층, 시작 시 로그인 PATH 캡처(짧은 시간 초과)."; +"codex_binary" = "Codex 바이너리"; +"claude_binary" = "Claude 바이너리"; +"effective_path" = "유효 PATH"; +"unavailable" = "사용할 수 없음"; +"login_shell_path" = "로그인 셸 PATH(시작 시 캡처)"; +"cleared" = "지웠습니다."; +"no_fetch_attempts" = "아직 가져오기 시도가 없습니다."; +"macOS Tahoe can block menu bar apps in System Settings → Menu Bar → Allow in the Menu Bar. CodexBar is running, but macOS may be hiding its icon. Open Menu Bar settings and turn CodexBar on." = "macOS Tahoe는 시스템 설정 → 메뉴 막대 → 메뉴 막대에서 허용 설정에서 메뉴 막대 앱을 차단할 수 있습니다. CodexBar는 실행 중이지만 macOS가 아이콘을 가리고 있을 수 있습니다. 메뉴 막대 설정을 열고 CodexBar를 켜세요."; +"metric_pref_automatic" = "자동"; +"metric_pref_primary" = "1차"; +"metric_pref_secondary" = "2차"; +"metric_pref_tertiary" = "3차"; +"metric_pref_extra_usage" = "추가 사용량"; +"metric_pref_average" = "평균"; +"metric_mistral_payg" = "Pay-as-you-go"; +"metric_mistral_monthly_plan" = "Monthly Plan"; +"display_mode_percent" = "백분율"; +"display_mode_pace" = "사용 속도"; +"display_mode_both" = "둘 다"; +"display_mode_reset_time" = "재설정 시간"; +"display_mode_percent_desc" = "남은/사용한 백분율 표시(예: 45%)"; +"display_mode_pace_desc" = "사용 속도 표시기 표시(예: +5%)"; +"display_mode_both_desc" = "백분율과 사용 속도 모두 표시(예: 45% · +5%)"; +"display_mode_reset_time_desc" = "선택한 지표의 재설정 시간 표시(예: ↻ 오후 3:56)"; +"menu_bar_reset_when_exhausted_title" = "할당량 소진 시 재설정 시간 표시"; +"menu_bar_reset_when_exhausted_subtitle" = "남은 양이 0%일 때 백분율 대신 재설정까지의 시간을 표시합니다"; +"status_operational" = "정상 작동"; +"status_degraded" = "성능 저하"; +"status_partial_outage" = "부분 장애"; +"status_major_outage" = "주요 장애"; +"status_critical_issue" = "심각한 문제"; +"status_maintenance" = "유지 보수"; +"status_unknown" = "상태 알 수 없음"; +"refresh_manual" = "수동"; +"refresh_1min" = "1분"; +"refresh_2min" = "2분"; +"refresh_5min" = "5분"; +"refresh_15min" = "15분"; +"refresh_30min" = "30분"; +"refresh_adaptive" = "적응형"; +"refresh_adaptive_agent_aware" = "적응형(에이전트 활동 인식)"; +"adaptive_activity_consent_title" = "활동 인식 새로 고침을 허용할까요?"; +"adaptive_activity_consent_message" = "에이전트 활동 인식 적응형 모드는 Codex와 Claude를 식별하기 위해 명령줄을 포함한 로컬 실행 중 프로세스 목록을 검사한 다음, 코딩하는 동안 30초마다 알려진 세션 메타데이터를 읽을 수 있습니다. Agent Sessions를 끄면 CodexBar는 메모리에서 가장 최근 활동 시간만 사용하고 세션 경로와 ID는 폐기합니다. 이 데이터는 어디에도 전송되지 않으며 원격 검색 및 SSH는 꺼진 상태로 유지됩니다. 거부하면 로컬 활동 검사 없이 일반 적응형 모드로 돌아갑니다."; +"adaptive_activity_consent_allow" = "로컬 활동 허용"; +"adaptive_activity_consent_decline" = "일반 적응형 사용"; +"not_found" = "찾을 수 없음"; +"cost_estimate_hint" = "로컬 로그에서 추정 · 실제 청구액과 다를 수 있음"; +"codex_api_estimate_hint" = "토큰 사용량 기반 추정 · 구독 청구서가 아님"; +"cost_data_explanation" = "비용은 제공업체가 보고하거나 토큰 사용량과 공개 API 가격을 기준으로 추정할 수 있습니다. 추정치는 구독 요금이 아닙니다."; +"No JetBrains IDE with AI Assistant detected. Install a JetBrains IDE and enable AI Assistant." = "AI Assistant가 있는 JetBrains IDE를 찾지 못했습니다. JetBrains IDE를 설치하고 AI Assistant를 사용하세요."; +"OpenRouter API token not configured. Set OPENROUTER_API_KEY environment variable or configure in Settings." = "OpenRouter API 토큰이 구성되지 않았습니다. OPENROUTER_API_KEY 환경 변수를 설정하거나 설정에서 구성하세요."; +"z.ai API token not found. Set apiKey in ~/.codexbar/config.json or Z_AI_API_KEY." = "z.ai API 토큰을 찾을 수 없습니다. ~/.codexbar/config.json에 apiKey를 설정하거나 Z_AI_API_KEY를 설정하세요."; +"Missing DeepSeek API key." = "DeepSeek API 키가 없습니다."; +"%@ is unavailable in the current environment." = "%@은(는) 현재 환경에서 사용할 수 없습니다."; +"All Systems Operational" = "모든 시스템 정상 작동"; +"Last 30 days" = "지난 30일"; +"Last 30 days:" = "지난 30일:"; +"This month" = "이번 달"; +"Store multiple OpenAI API keys." = "여러 OpenAI API 키를 저장합니다."; +"Admin API key" = "관리자 API 키"; +"Open billing" = "결제 열기"; +"Google accounts" = "Google 계정"; +"Store multiple Antigravity Google OAuth accounts for quick switching." = "빠른 전환을 위해 여러 Antigravity Google OAuth 계정을 저장합니다."; +"Add Google Account" = "Google 계정 추가"; +"Open Token Plan" = "Token Plan 열기"; +"Text Generation" = "텍스트 생성"; +"Text to Speech" = "텍스트 음성 변환"; +"Music Generation" = "음악 생성"; +"Image Generation" = "이미지 생성"; +"No local data found" = "로컬 데이터를 찾을 수 없음"; +"Credits unavailable; keep Codex running to refresh." = "크레딧을 사용할 수 없습니다. 새로 고치려면 Codex를 계속 실행하세요."; +"No available fetch strategy for minimax." = "minimax에 사용할 수 있는 가져오기 전략이 없습니다."; +"No Cursor session found. Please log in to cursor.com in Safari, Chrome, Microsoft Edge, Brave, Arc, Dia, ChatGPT Atlas, Chromium, Helium, Vivaldi, Yandex Browser, Firefox, Zen, Colibri, Sidekick, Opera, Opera GX, or Edge Canary. If you use Safari, grant CodexBar Full Disk Access in System Settings ▸ Privacy & Security. You can also sign in to Cursor from the CodexBar menu (Add / switch account)." = "Cursor 세션을 찾을 수 없습니다. Safari, Chrome, Microsoft Edge, Brave, Arc, Dia, ChatGPT Atlas, Chromium, Helium, Vivaldi, Yandex Browser, Firefox, Zen, Colibri, Sidekick, Opera, Opera GX 또는 Edge Canary에서 cursor.com에 로그인하세요. Safari를 사용하는 경우 시스템 설정 ▸ 개인정보 보호 및 보안에서 CodexBar에 전체 디스크 접근 권한을 부여하세요. CodexBar 메뉴(계정 추가/전환)에서 Cursor에 로그인할 수도 있습니다."; +"No OpenCode session cookies found in browsers." = "브라우저에서 OpenCode 세션 쿠키를 찾을 수 없습니다."; +"No available fetch strategy for %@." = "%@에 사용할 수 있는 가져오기 전략이 없습니다."; +"Today" = "오늘"; +"Today tokens" = "오늘 토큰"; +"30d cost" = "30일 비용"; +"%@ cost" = "%@ 비용"; +"30d tokens" = "30일 토큰"; +"Latest tokens" = "최근 토큰"; +"Top model" = "상위 모델"; +"Storage" = "저장 공간"; +"Add Account..." = "계정 추가..."; +"Usage Dashboard" = "사용량 대시보드"; +"Status Page" = "상태 페이지"; +"Open Status Page" = "상태 페이지 열기"; +"Settings..." = "설정..."; +"About CodexBar" = "CodexBar 정보"; +"Quit" = "종료"; +"Last %d day" = "최근 %d일"; +"Last %d days" = "최근 %d일"; +"%@ tokens" = "%@ 토큰"; +"Latest billing day" = "최근 청구일"; +"Latest billing day (%@)" = "최근 청구일(%@)"; +"%@ left" = "%@ 남음"; +"Resets %@" = "%@에 재설정"; +"Resets in %@" = "%@ 후 재설정"; +"Resets now" = "지금 재설정"; +"reset_tomorrow_format" = "내일 %@"; +"Lasts until reset" = "재설정까지 유지"; +"1.5× headroom" = "1.5배 여유"; +"Updated %@" = "%@에 업데이트됨"; +"Updated relative %@" = "%@에 업데이트됨"; +"Updated absolute %@" = "%@에 업데이트됨"; +"Updated %@h ago" = "%@시간 전 업데이트됨"; +"Updated %@m ago" = "%@분 전 업데이트됨"; +"Updated just now" = "방금 업데이트됨"; +"Projected empty in %@" = "%@ 후 소진 예상"; +"Runs out in %@" = "%@ 후 소진"; +"Pace: %@" = "사용 속도: %@"; +"Pace: %@ · %@" = "사용 속도: %@ · %@"; +"%@ · %@" = "%@ · %@"; +"≈ %d%% run-out risk" = "≈ %d%% 소진 위험"; +"%d%% in deficit" = "%d%% 부족"; +"%d%% in reserve" = "%d%% 여유"; +"usage_percent_suffix_left" = "남음"; +"usage_percent_suffix_used" = "사용"; +"Store multiple DeepSeek API keys." = "여러 DeepSeek API 키를 저장합니다."; +"This week" = "이번 주"; +"Week" = "주"; +"Month" = "월"; +"Models" = "모델"; +"24h tokens" = "24시간 토큰"; +"Latest hour" = "최근 1시간"; +"Peak hour" = "최대 사용 시간"; +"Top method" = "상위 메서드"; +"30d cash" = "30일 현금"; +"30d billing history from MiniMax web session" = "MiniMax 웹 세션의 30일 청구 내역"; +"AWS Cost Explorer billing can lag." = "AWS Cost Explorer 청구는 지연될 수 있습니다."; +"Rate limit: %d / %@" = "속도 제한: %d / %@"; +"Key remaining" = "키 잔여량"; +"No limit set for the API key" = "API 키에 설정된 한도가 없습니다"; +"API key limit unavailable right now" = "지금은 API 키 한도를 사용할 수 없습니다"; +"This month: %@ tokens" = "이번 달: %@ 토큰"; +"No utilization data yet." = "아직 사용률 데이터가 없습니다."; +"No %@ utilization data yet." = "아직 %@ 사용률 데이터가 없습니다."; +"%@: %@%% used" = "%@: %@%% 사용"; +"%dd" = "%d일"; +"today" = "오늘"; +"just now" = "방금"; +"On pace" = "정상 속도"; +"Runs out now" = "지금 소진"; +"Projected empty now" = "지금 소진 예상"; +"Switch Account..." = "계정 전환..."; +"Update ready, restart now?" = "업데이트 준비 완료, 지금 다시 시작할까요?"; +"Daily" = "일간"; +"Hourly Tokens" = "시간별 토큰"; +"No data" = "데이터 없음"; +"No usage breakdown data available." = "사용량 분석 데이터가 없습니다."; +"Today: %@ · %@ tokens" = "오늘: %@ · 토큰 %@개"; +"Today: %@" = "오늘: %@"; +"Today: %@ tokens" = "오늘: 토큰 %@개"; +"Last 30 days: %@ · %@ tokens" = "최근 30일: %@ · 토큰 %@개"; +"Last 30 days: %@" = "최근 30일: %@"; +"Est. total (30d): %@" = "예상 합계(30일): %@"; +"Est. total (%@): %@" = "예상 합계(%@): %@"; +"Hover a bar for details" = "막대에 마우스를 올리면 세부 정보 표시"; +"%@: %@ · %@ tokens" = "%@: %@ · 토큰 %@개"; +"No providers selected for Overview." = "개요에 선택된 공급자가 없습니다."; +"No overview data available." = "개요 데이터가 없습니다."; +"Auto uses the local IDE API first, then Google OAuth when the IDE is closed." = "자동 모드는 로컬 IDE API를 먼저 사용하고, IDE가 닫혀 있으면 Google OAuth를 사용합니다."; +"Login with Google" = "Google로 로그인"; +"No usage configured." = "구성된 사용량이 없습니다."; +"Quota" = "할당량"; +"Daily quota" = "일일 할당량"; +"Total" = "합계"; +"tokens" = "토큰"; +"requests" = "요청"; +"Latest" = "최신"; +"Monthly" = "월간"; +"Sonnet" = "Sonnet"; +"Overages" = "초과분"; +"Activity" = "활동"; +"Copied" = "복사됨"; +"Copy error" = "오류 복사"; +"Copy path" = "경로 복사"; +"Extra usage spent" = "추가 사용 지출액"; +"Credits remaining" = "남은 크레딧"; +"Using CLI fallback" = "CLI 대체 사용 중"; +"Balance updates in near-real time (up to 5 min lag)" = "잔액은 거의 실시간으로 업데이트됩니다(최대 5분 지연)"; +"Daily billing data finalizes at 07:00 UTC" = "일간 청구 데이터는 07:00 UTC에 확정됩니다"; +"%@ of %@ credits left" = "크레딧 %2$@개 중 %1$@개 남음"; +"%@ of %@ bonus credits left" = "보너스 크레딧 %2$@개 중 %1$@개 남음"; +"%@ / %@ (%@ remaining)" = "%@ / %@ (%@ 남음)"; +"%@/%@ left" = "%@/%@ 남음"; +"Gemini Flash" = "Gemini Flash"; +"Regenerates %@" = "%@에 재생성"; +"used after next regen" = "다음 재생성 후 사용됨"; +"after next regen" = "다음 재생성 후"; +"Near full" = "거의 가득 참"; +"Full in ~1 regen" = "약 1회 재생성 후 가득 참"; +"Full in ~%.0f regens" = "약 %.0f회 재생성 후 가득 참"; +"Overage usage" = "초과 사용량"; +"Overage cost" = "초과 비용"; +"credits" = "크레딧"; +"Zen balance" = "Zen 잔액"; +"API spend" = "API 지출"; +"Extra usage" = "추가 사용량"; +"Quota usage" = "할당량 사용량"; +"Your spend" = "개인 지출"; +"%.0f%% used" = "%.0f%% 사용됨"; +"Usage history (today)" = "사용 기록(오늘)"; +"Usage history (%d days)" = "사용 기록(%d일)"; +"%d percent remaining" = "%d퍼센트 남음"; +"Unknown" = "알 수 없음"; +"stale data" = "오래된 데이터"; +"No credits history data." = "크레딧 기록 데이터가 없습니다."; +"No credits history data available." = "크레딧 기록 데이터가 없습니다."; +"Credits history chart" = "크레딧 기록 차트"; +"%d days of credits data" = "크레딧 데이터 %d일"; +"Usage breakdown chart" = "사용량 분석 차트"; +"%d days of usage data across %d services" = "%2$d개 서비스의 %1$d일간 사용량 데이터"; +"Cost history chart" = "비용 내역 차트"; +"%d days of cost data" = "%d일간 비용 데이터"; +"Plan utilization chart" = "요금제 사용률 차트"; +"%d utilization samples" = "사용률 샘플 %d개"; +"Hourly Usage" = "시간별 사용량"; +"Usage remaining" = "남은 사용량"; +"Usage used" = "사용한 사용량"; +"API key verified. Cloud quotas need browser cookies. Sign in to Ollama." = "API 키가 확인되었습니다. Cloud 할당량에는 브라우저 쿠키가 필요합니다. Ollama에 로그인하세요."; +"Last 30 days: %@ tokens" = "지난 30일: %@ 토큰"; +"7d spend" = "7일 지출"; +"30d spend" = "30일 지출"; +"Cache read" = "캐시 읽기"; +"Claude Admin API 30 day spend trend" = "Claude Admin API 30일 지출 추세"; +"OpenRouter API key spend trend" = "OpenRouter API 키 지출 추세"; +"z.ai hourly token trend" = "z.ai 시간별 토큰 추세"; +"MiniMax 30 day token usage trend" = "MiniMax 30일 토큰 사용량 추세"; +"Today cash" = "오늘 현금"; +"DeepSeek 30 day token usage trend" = "DeepSeek 30일 토큰 사용량 추세"; +"cache-hit input" = "캐시 적중 입력"; +"cache-miss input" = "캐시 미스 입력"; +"output" = "출력"; +"Requests" = "요청"; +"Reported by OpenAI Admin API organization usage." = "OpenAI Admin API 조직 사용량 기준으로 보고됨"; +"Reported by Mistral billing usage." = "Mistral 청구 사용량 기준으로 보고됨"; +"Google OAuth" = "Google OAuth"; +"Add accounts via GitHub OAuth Device Flow on the selected host." = "선택한 호스트에서 GitHub OAuth 장치 흐름으로 계정을 추가합니다."; +"Stores each signed-in Google account for quick Antigravity switching. Uses Antigravity.app OAuth when available, or ANTIGRAVITY_OAUTH_CLIENT_ID and ANTIGRAVITY_OAUTH_CLIENT_SECRET as an override." = "빠른 Antigravity 전환을 위해 로그인한 각 Google 계정을 저장합니다. 가능한 경우 Antigravity.app OAuth를 사용하며, 재정의하려면 ANTIGRAVITY_OAUTH_CLIENT_ID와 ANTIGRAVITY_OAUTH_CLIENT_SECRET을 사용합니다."; +"Manual cleanup: past sessions" = "수동 정리: 지난 세션"; +"Clearing removes past resume, continue, and rewind history." = "지우면 지난 재개, 계속, 되감기 기록이 제거됩니다."; +"Manual cleanup: file checkpoints" = "수동 정리: 파일 체크포인트"; +"Clearing removes checkpoint restore data for previous edits." = "지우면 이전 편집의 체크포인트 복원 데이터가 제거됩니다."; +"Manual cleanup: saved plans" = "수동 정리: 저장된 계획"; +"Clearing removes old plan-mode files." = "지우면 오래된 계획 모드 파일이 제거됩니다."; +"Manual cleanup: debug logs" = "수동 정리: 디버그 로그"; +"Clearing removes past debug logs." = "지우면 지난 디버그 로그가 제거됩니다."; +"Manual cleanup: attachment cache" = "수동 정리: 첨부 파일 캐시"; +"Clearing removes cached large pastes or attached images." = "지우면 캐시된 대용량 붙여넣기 또는 첨부 이미지가 제거됩니다."; +"Manual cleanup: session metadata" = "수동 정리: 세션 메타데이터"; +"Clearing removes per-session environment metadata." = "지우면 세션별 환경 메타데이터가 제거됩니다."; +"Manual cleanup: shell snapshots" = "수동 정리: 셸 스냅샷"; +"Clearing removes leftover runtime shell snapshot files." = "지우면 남아 있는 런타임 셸 스냅샷 파일이 제거됩니다."; +"Manual cleanup: legacy todos" = "수동 정리: 레거시 할 일"; +"Clearing removes legacy per-session task lists." = "지우면 레거시 세션별 작업 목록이 제거됩니다."; +"Manual cleanup: sessions" = "수동 정리: 세션"; +"Clearing removes past Codex session history." = "지우면 지난 Codex 세션 기록이 제거됩니다."; +"Manual cleanup: archived sessions" = "수동 정리: 보관된 세션"; +"Clearing removes archived Codex session history." = "지우면 보관된 Codex 세션 기록이 제거됩니다."; +"Manual cleanup: cache" = "수동 정리: 캐시"; +"Clearing removes provider-owned cached data." = "지우면 공급자 소유의 캐시 데이터가 제거됩니다."; +"Manual cleanup: logs" = "수동 정리: 로그"; +"Clearing removes local diagnostic logs." = "지우면 로컬 진단 로그가 제거됩니다."; +"Manual cleanup: file history" = "수동 정리: 파일 기록"; +"Clearing removes local edit checkpoint history." = "지우면 로컬 편집 체크포인트 기록이 제거됩니다."; +"Manual cleanup: temporary data" = "수동 정리: 임시 데이터"; +"Clearing removes local temporary provider data." = "지우면 로컬 임시 공급자 데이터가 제거됩니다."; +"Total: %@" = "총계: %@"; +"%d more items" = "항목 %d개 더"; +"Cleanup ideas" = "정리 아이디어"; +"%d unreadable item(s) skipped" = "읽을 수 없는 항목 %d개 건너뜀"; +"API key limit" = "API 키 한도"; +"Auth" = "인증"; +"Auto" = "자동"; +"Disabled — no recent data" = "사용 안 함 — 최근 데이터 없음"; +"Limits not available" = "한도를 사용할 수 없음"; +"No usage yet" = "아직 사용량 없음"; +"Not fetched yet" = "아직 가져오지 않음"; +"Refreshing" = "새로 고치는 중"; +"Session" = "세션"; +"Source" = "소스"; +"State" = "상태"; +"Unavailable" = "사용할 수 없음"; +"Weekly" = "주간"; +"not detected" = "감지되지 않음"; +"Estimated from local Codex logs for the selected account." = "선택한 계정의 로컬 Codex 로그를 기반으로 추정됨."; +"minimax_usage_amount_format" = "사용량: %@ / %@"; +"minimax_used_percent_format" = "%@ 사용됨"; +"minimax_service_text_generation" = "텍스트 생성"; +"minimax_service_text_to_speech" = "텍스트 음성 변환"; +"minimax_service_music_generation" = "음악 생성"; +"minimax_service_image_generation" = "이미지 생성"; +"minimax_service_lyrics_generation" = "가사 생성"; +"minimax_service_coding_plan_vlm" = "코딩 요금제 VLM"; +"minimax_service_coding_plan_search" = "코딩 요금제 검색"; +"%@ is waiting for permission" = "%@이(가) 권한을 기다리는 중"; +"%@ requests" = "%@ 요청"; +"%@: %@ credits" = "%@: %@ 크레딧"; +"30d requests" = "30일 요청"; +"4 days" = "4일"; +"5 days" = "5일"; +"7 days" = "7일"; +"API key verifies Ollama Cloud access; cookies still expose quota limits." = "API 키는 Ollama Cloud 접근을 확인하며, 쿠키는 여전히 할당량 한도를 노출합니다."; +"AWS access key ID. Can also be set with AWS_ACCESS_KEY_ID." = "AWS 액세스 키 ID. AWS_ACCESS_KEY_ID로도 설정할 수 있습니다."; +"AWS region. Can also be set with AWS_REGION." = "AWS 리전. AWS_REGION으로도 설정할 수 있습니다."; +"AWS secret access key. Can also be set with AWS_SECRET_ACCESS_KEY." = "AWS 시크릿 액세스 키. AWS_SECRET_ACCESS_KEY로도 설정할 수 있습니다."; +"Access key ID" = "액세스 키 ID"; +"Add Account" = "계정 추가"; +"Adding Account…" = "계정 추가 중…"; +"Antigravity login failed" = "Antigravity 로그인 실패"; +"Antigravity login timed out" = "Antigravity 로그인 시간 초과"; +"Auth source" = "인증 소스"; +"Automatic imports browser cookies from Xiaomi MiMo." = "Xiaomi MiMo에서 브라우저 쿠키를 자동으로 가져옵니다."; +"Automatic imports Windsurf session data from Chromium browser localStorage." = "Chromium 브라우저 localStorage에서 Windsurf 세션 데이터를 자동으로 가져옵니다."; +"Automatic imports browser cookies from Bailian." = "Bailian에서 브라우저 쿠키를 자동으로 가져옵니다."; +"Automatically imports browser cookies." = "브라우저 쿠키를 자동으로 가져옵니다."; +"Automatically imports browser session cookies." = "브라우저 세션 쿠키를 자동으로 가져옵니다."; +"Azure OpenAI deployment name. AZURE_OPENAI_DEPLOYMENT_NAME is also supported." = "Azure OpenAI 배포 이름. AZURE_OPENAI_DEPLOYMENT_NAME도 지원됩니다."; +"Azure OpenAI key" = "Azure OpenAI 키"; +"Azure OpenAI resource endpoint. AZURE_OPENAI_ENDPOINT is also supported." = "Azure OpenAI 리소스 엔드포인트. AZURE_OPENAI_ENDPOINT도 지원됩니다."; +"Base URL" = "기본 URL"; +"Base URL for the LLM-API-Key-Proxy instance." = "LLM-API-Key-Proxy 인스턴스의 기본 URL."; +"Browser cookies" = "브라우저 쿠키"; +"Cap end" = "한도 종료"; +"Cap start" = "한도 시작"; +"Capacity End" = "용량 종료"; +"Capacity Start" = "용량 시작"; +"Changelog" = "변경 사항"; +"Choose the Moonshot/Kimi API host for international or China mainland accounts." = "해외 또는 중국 본토 계정에 맞는 Moonshot/Kimi API 호스트를 선택하세요."; +"CodexBar can't replace a system account that is signed in with an API key only setup." = "CodexBar는 API 키 전용 설정으로 로그인된 시스템 계정을 교체할 수 없습니다."; +"CodexBar could not find saved auth for that account. Re-authenticate it and try again." = "CodexBar가 해당 계정의 저장된 인증을 찾을 수 없습니다. 다시 인증한 후 시도하세요."; +"CodexBar could not read managed account storage. Recover the store before adding another account." = "CodexBar가 관리되는 계정 저장소를 읽을 수 없습니다. 다른 계정을 추가하기 전에 저장소를 복구하세요."; +"CodexBar could not read saved auth for that account. Re-authenticate it and try again." = "CodexBar가 해당 계정의 저장된 인증을 읽을 수 없습니다. 다시 인증한 후 시도하세요."; +"CodexBar could not read the current system account on this Mac." = "CodexBar가 이 Mac의 현재 시스템 계정을 읽을 수 없습니다."; +"CodexBar could not replace the live Codex auth on this Mac." = "CodexBar가 이 Mac의 활성 Codex 인증을 교체할 수 없습니다."; +"CodexBar could not safely preserve the current system account before switching." = "CodexBar가 전환하기 전에 현재 시스템 계정을 안전하게 보존할 수 없습니다."; +"CodexBar could not save the current system account before switching." = "CodexBar가 전환하기 전에 현재 시스템 계정을 저장할 수 없습니다."; +"CodexBar could not update managed account storage." = "CodexBar가 관리되는 계정 저장소를 업데이트할 수 없습니다."; +"CodexBar found another managed account that already uses the current system account. Resolve the duplicate account before switching." = "CodexBar가 현재 시스템 계정을 이미 사용 중인 다른 관리 계정을 발견했습니다. 전환하기 전에 중복된 계정을 해결하세요."; +"CodexBar will ask macOS Keychain for “%@” so it can decrypt browser cookies and authenticate your account. Click OK to continue." = "CodexBar가 브라우저 쿠키를 복호화하고 계정을 인증할 수 있도록 macOS 키체인에 “%@”을(를) 요청합니다. 계속하려면 확인을 클릭하세요."; +"CodexBar will ask macOS Keychain for the Claude Code OAuth token so it can fetch your Claude usage. Click OK to continue." = "CodexBar가 Claude 사용량을 가져올 수 있도록 macOS 키체인에 Claude Code OAuth 토큰을 요청합니다. 계속하려면 확인을 클릭하세요."; +"CodexBar will ask macOS Keychain for your Amp cookie header so it can fetch usage. Click OK to continue." = "CodexBar가 사용량을 가져올 수 있도록 macOS 키체인에 Amp 쿠키 헤더를 요청합니다. 계속하려면 확인을 클릭하세요."; +"CodexBar will ask macOS Keychain for your Augment cookie header so it can fetch usage. Click OK to continue." = "CodexBar가 사용량을 가져올 수 있도록 macOS 키체인에 Augment 쿠키 헤더를 요청합니다. 계속하려면 확인을 클릭하세요."; +"CodexBar will ask macOS Keychain for your Claude cookie header so it can fetch Claude web usage. Click OK to continue." = "CodexBar가 Claude 웹 사용량을 가져올 수 있도록 macOS 키체인에 Claude 쿠키 헤더를 요청합니다. 계속하려면 확인을 클릭하세요."; +"CodexBar will ask macOS Keychain for your Cursor cookie header so it can fetch usage. Click OK to continue." = "CodexBar가 사용량을 가져올 수 있도록 macOS 키체인에 Cursor 쿠키 헤더를 요청합니다. 계속하려면 확인을 클릭하세요."; +"CodexBar will ask macOS Keychain for your Factory cookie header so it can fetch usage. Click OK to continue." = "CodexBar가 사용량을 가져올 수 있도록 macOS 키체인에 Factory 쿠키 헤더를 요청합니다. 계속하려면 확인을 클릭하세요."; +"CodexBar will ask macOS Keychain for your GitHub Copilot token so it can fetch usage. Click OK to continue." = "CodexBar가 사용량을 가져올 수 있도록 macOS 키체인에 GitHub Copilot 토큰을 요청합니다. 계속하려면 확인을 클릭하세요."; +"CodexBar will ask macOS Keychain for your Kimi auth token so it can fetch usage. Click OK to continue." = "CodexBar가 사용량을 가져올 수 있도록 macOS 키체인에 Kimi 인증 토큰을 요청합니다. 계속하려면 확인을 클릭하세요."; +"CodexBar will ask macOS Keychain for your MiniMax API token so it can fetch usage. Click OK to continue." = "CodexBar가 사용량을 가져올 수 있도록 macOS 키체인에 MiniMax API 토큰을 요청합니다. 계속하려면 확인을 클릭하세요."; +"CodexBar will ask macOS Keychain for your MiniMax cookie header so it can fetch usage. Click OK to continue." = "CodexBar가 사용량을 가져올 수 있도록 macOS 키체인에 MiniMax 쿠키 헤더를 요청합니다. 계속하려면 확인을 클릭하세요."; +"CodexBar will ask macOS Keychain for your OpenAI cookie header so it can fetch Codex dashboard extras. Click OK to continue." = "CodexBar가 Codex 대시보드 추가 정보를 가져올 수 있도록 macOS 키체인에 OpenAI 쿠키 헤더를 요청합니다. 계속하려면 확인을 클릭하세요."; +"CodexBar will ask macOS Keychain for your OpenCode cookie header so it can fetch usage. Click OK to continue." = "CodexBar가 사용량을 가져올 수 있도록 macOS 키체인에 OpenCode 쿠키 헤더를 요청합니다. 계속하려면 확인을 클릭하세요."; +"CodexBar will ask macOS Keychain for your Synthetic API key so it can fetch usage. Click OK to continue." = "CodexBar가 사용량을 가져올 수 있도록 macOS 키체인에 Synthetic API 키를 요청합니다. 계속하려면 확인을 클릭하세요."; +"CodexBar will ask macOS Keychain for your z.ai API token so it can fetch usage. Click OK to continue." = "CodexBar가 사용량을 가져올 수 있도록 macOS 키체인에 z.ai API 토큰을 요청합니다. 계속하려면 확인을 클릭하세요."; +"Could not open Cursor login in your browser." = "브라우저에서 Cursor 로그인을 열 수 없습니다."; +"Could not open browser for Antigravity" = "Antigravity용 브라우저를 열 수 없습니다"; +"Credits used" = "사용한 크레딧"; +"Day" = "일간"; +"Deployment" = "배포"; +"Drag to reorder" = "드래그하여 순서 변경"; +"Sort providers alphabetically" = "공급자를 알파벳순으로 정렬"; +"Sort providers alphabetically (enabled first)" = "공급자를 알파벳순으로 정렬(활성화 항목 우선)"; +"Sorted alphabetically (enabled first) — click to use your custom order" = "알파벳순으로 정렬됨(활성화 항목 우선) — 사용자 지정 순서를 사용하려면 클릭"; +"Endpoint" = "엔드포인트"; +"Enterprise host" = "엔터프라이즈 호스트"; +"Extra usage balance: %@" = "추가 사용량 잔액: %@"; +"Keychain Access Required" = "키체인 접근 필요"; +"keychain_prompt_learn_more" = "더 알아보기…"; +"keychain_prompt_privacy_note" = "Mac 로그인 암호 입력은 CodexBar가 아닌 macOS에서 처리합니다. 설정 → 고급에서 언제든지 키체인 접근을 비활성화할 수 있습니다."; +"Kiro menu bar value" = "Kiro 메뉴 막대 값"; +"Label" = "레이블"; +"No organizations loaded. Click Refresh after setting your API key." = "불러온 조직이 없습니다. API 키를 설정한 후 새로 고침을 클릭하세요."; +"No output captured." = "캡처된 출력이 없습니다."; +"No system account" = "시스템 계정 없음"; +"Oasis-Token" = "Oasis-Token"; +"Open Augment (Log Out & Back In)" = "Augment 열기(로그아웃 후 다시 로그인)"; +"Open Codebuff Dashboard" = "Codebuff 대시보드 열기"; +"Open Command Code Settings" = "Command Code 설정 열기"; +"Open Crof dashboard" = "Crof 대시보드 열기"; +"Open Manus" = "Manus 열기"; +"Open MiMo Balance" = "MiMo 잔액 열기"; +"Open Moonshot Console" = "Moonshot 콘솔 열기"; +"Open Ollama API Keys" = "Ollama API 키 열기"; +"Open StepFun Platform" = "StepFun 플랫폼 열기"; +"Open T3 Chat Settings" = "T3 Chat 설정 열기"; +"Open Volcengine Ark Console" = "Volcengine Ark 콘솔 열기"; +"Open legacy provider docs" = "레거시 공급자 문서 열기"; +"Open projects" = "프로젝트 열기"; +"Open this URL manually to continue login:\n\n%@" = "로그인을 계속하려면 이 URL을 수동으로 여세요:\n\n%@"; +"Optional organization ID for accounts linked to multiple Anthropic organizations." = "여러 Anthropic 조직에 연결된 계정을 위한 선택적 조직 ID입니다."; +"Optional. Applies to the configured Admin API key; selected token accounts do not inherit OPENAI_PROJECT_ID." = "선택 사항입니다. 구성된 Admin API 키에 적용되며, 선택한 토큰 계정은 OPENAI_PROJECT_ID를 상속하지 않습니다."; +"Optional. Enter your GitHub Enterprise host, for example octocorp.ghe.com. Leave blank for github.com." = "선택 사항입니다. GitHub Enterprise 호스트를 입력하세요. 예: octocorp.ghe.com. github.com을 사용하려면 비워 두세요."; +"Optional. Leave blank to discover and aggregate projects visible to the API key." = "선택 사항입니다. API 키에 표시되는 프로젝트를 검색하고 집계하려면 비워 두세요."; +"Org ID (optional)" = "조직 ID(선택 사항)"; +"Organizations" = "조직"; +"Organization ID" = "조직 ID"; +"Password" = "암호"; +"%@ authentication is disabled." = "%@ 인증이 사용 안 함으로 설정되어 있습니다."; +"%@ cookies are disabled." = "%@ 쿠키가 사용 안 함으로 설정되어 있습니다."; +"%@ web API access is disabled." = "%@ 웹 API 접근이 사용 안 함으로 설정되어 있습니다."; +"Disable %@ dashboard cookie usage." = "%@ 대시보드 쿠키 사용을 사용 안 함으로 설정합니다."; +"Keychain access is disabled in Advanced, so browser cookie import is unavailable." = "고급에서 키체인 접근이 사용 안 함으로 설정되어 있어 브라우저 쿠키 가져오기를 사용할 수 없습니다."; +"Manually paste an %@ from a browser session." = "브라우저 세션에서 %@을(를) 수동으로 붙여넣으세요."; +"Paste a Cookie header captured from %@." = "%@에서 캡처한 쿠키 헤더를 붙여넣으세요."; +"Paste a Cookie header from %@." = "%@의 쿠키 헤더를 붙여넣으세요."; +"Paste a Cookie header or cURL capture from %@." = "%@의 쿠키 헤더 또는 cURL 캡처를 붙여넣으세요."; +"Paste a Cookie header or full cURL capture from %@." = "%@의 쿠키 헤더 또는 전체 cURL 캡처를 붙여넣으세요."; +"Paste a Cookie or Authorization header from %@." = "%@의 쿠키 또는 Authorization 헤더를 붙여넣으세요."; +"Paste a full cookie header or the %@ value." = "전체 쿠키 헤더 또는 %@ 값을 붙여넣으세요."; +"Paste a Cookie header or full cURL capture from T3 Chat settings." = "T3 Chat 설정에서 쿠키 헤더 또는 전체 cURL 캡처를 붙여넣으세요."; +"Paste the Cookie header from a request to admin.mistral.ai. Must contain an ory_session_* cookie." = "admin.mistral.ai에 대한 요청의 Cookie 헤더를 붙여넣으세요. ory_session_* 쿠키가 포함되어야 합니다."; +"Paste the Oasis-Token from a logged-in browser session on platform.stepfun.com." = "platform.stepfun.com에 로그인된 브라우저 세션의 Oasis-Token을 붙여넣으세요."; +"Paste the %@ JSON bundle from %@." = "%2$@의 %1$@ JSON 번들을 붙여넣으세요."; +"Paste the %@ value or a full Cookie header." = "%@ 값 또는 전체 Cookie 헤더를 붙여넣으세요."; +"Personal account" = "개인 계정"; +"Project ID" = "프로젝트 ID"; +"Re-auth" = "재인증"; +"Re-login at claude.ai" = "claude.ai에서 다시 로그인"; +"Re-authenticating…" = "다시 인증하는 중…"; +"Refresh Session" = "세션 새로 고침"; +"Refresh organizations" = "조직 새로 고침"; +"Region" = "지역"; +"Reload" = "다시 불러오기"; +"Reorder" = "순서 변경"; +"Secret access key" = "보안 액세스 키"; +"Series" = "시리즈"; +"Service" = "서비스"; +"Show or hide Kiro credits, percent, or both next to the menu bar icon." = "메뉴 막대 아이콘 옆에 Kiro 크레딧, 백분율 또는 둘 다를 표시하거나 가립니다."; +"Show usage for organizations you belong to. Personal account is always shown." = "사용자가 속한 조직의 사용량을 표시합니다. 개인 계정은 항상 표시됩니다."; +"Sign in to cursor.com in your browser, then refresh Cursor in CodexBar." = "브라우저에서 cursor.com에 로그인한 다음 CodexBar에서 Cursor를 새로 고침하세요."; +"Simulated error text" = "시뮬레이션된 오류 텍스트"; +"StepFun platform account (phone number or email)." = "StepFun 플랫폼 계정(전화번호 또는 이메일)."; +"Stored in ~/.codexbar/config.json." = "~/.codexbar/config.json에 저장됩니다."; +"Stored in ~/.codexbar/config.json. AZURE_OPENAI_API_KEY is also supported." = "~/.codexbar/config.json에 저장됩니다. AZURE_OPENAI_API_KEY도 지원됩니다."; +"Stored in ~/.codexbar/config.json. For the official Kimi API, use Moonshot / Kimi API." = "~/.codexbar/config.json에 저장됩니다. 공식 Kimi API의 경우 Moonshot / Kimi API를 사용하세요."; +"Stored in ~/.codexbar/config.json. Get your API key from the Volcengine Ark console." = "~/.codexbar/config.json에 저장됩니다. Volcengine Ark 콘솔에서 API 키를 받으세요."; +"Stored in ~/.codexbar/config.json. Get your key from Ollama settings." = "~/.codexbar/config.json에 저장됩니다. Ollama 설정에서 키를 받으세요."; +"Stored in ~/.codexbar/config.json. Get your key from console.deepgram.com." = "~/.codexbar/config.json에 저장됩니다. console.deepgram.com에서 키를 받으세요."; +"Stored in ~/.codexbar/config.json. Get your key from elevenlabs.io/app/settings/api-keys." = "~/.codexbar/config.json에 저장됩니다. elevenlabs.io/app/settings/api-keys에서 키를 받으세요."; +"Stored in ~/.codexbar/config.json. Get your key from openrouter.ai/settings/keys and set a key spending limit there to enable API key quota tracking." = "~/.codexbar/config.json에 저장됩니다. openrouter.ai/settings/keys에서 키를 받고, API 키 할당량 추적을 사용하려면 그곳에서 키 지출 한도를 설정하세요."; +"Stored in ~/.codexbar/config.json. In Warp, open Settings > Platform > API Keys, then create one." = "~/.codexbar/config.json에 저장됩니다. Warp에서 Settings > Platform > API Keys를 연 다음 하나를 생성하세요."; +"Stored in ~/.codexbar/config.json. Metrics require Groq Enterprise Prometheus access." = "~/.codexbar/config.json에 저장됩니다. 지표를 보려면 Groq Enterprise Prometheus 액세스가 필요합니다."; +"Stored in ~/.codexbar/config.json. OPENAI_ADMIN_KEY is preferred; OPENAI_API_KEY still works." = "~/.codexbar/config.json에 저장됩니다. OPENAI_ADMIN_KEY를 권장하지만 OPENAI_API_KEY도 여전히 작동합니다."; +"Stored in ~/.codexbar/config.json. Requires an Anthropic Admin API key." = "~/.codexbar/config.json에 저장됩니다. Anthropic Admin API 키가 필요합니다."; +"Stored in ~/.codexbar/config.json. Used for /v1/quota-stats." = "~/.codexbar/config.json에 저장됩니다. /v1/quota-stats에 사용됩니다."; +"Stored in ~/.codexbar/config.json. You can also provide CODEBUFF_API_KEY or let CodexBar read ~/.config/manicode/credentials.json (created by `codebuff login`)." = "~/.codexbar/config.json에 저장됩니다. CODEBUFF_API_KEY를 제공하거나 CodexBar가 ~/.config/manicode/credentials.json(`codebuff login`으로 생성됨)을 읽도록 할 수도 있습니다."; +"Stored in ~/.codexbar/config.json. You can also provide CROF_API_KEY." = "~/.codexbar/config.json에 저장됩니다. CROF_API_KEY를 제공할 수도 있습니다."; +"Stored in ~/.codexbar/config.json. You can also provide KILO_API_KEY or ~/.local/share/kilo/auth.json (kilo.access)." = "~/.codexbar/config.json에 저장됩니다. KILO_API_KEY 또는 ~/.local/share/kilo/auth.json(kilo.access)을 제공할 수도 있습니다."; +"T3 Chat cookie" = "T3 Chat 쿠키"; +"Team mode" = "팀 모드"; +"That account is no longer available in CodexBar. Refresh the account list and try again." = "해당 계정은 더 이상 CodexBar에서 사용할 수 없습니다. 계정 목록을 새로 고침한 후 다시 시도하세요."; +"The browser login did not complete in time. Try Antigravity login again." = "브라우저 로그인이 제때 완료되지 않았습니다. Antigravity 로그인을 다시 시도하세요."; +"Timed out waiting for Cursor login. %@" = "Cursor 로그인을 기다리는 중 시간이 초과되었습니다. %@"; +"Timed out waiting for Cursor login. %@ Last error: %@" = "Cursor 로그인을 기다리는 중 시간이 초과되었습니다. %@ 마지막 오류: %@"; +"Today requests" = "오늘 요청"; +"Total (30d): %@ credits" = "총계(30일): %@ 크레딧"; +"Username" = "사용자 이름"; +"Uses username + password to login and obtain an Oasis-Token automatically." = "사용자 이름 + 비밀번호로 로그인하여 Oasis-Token을 자동으로 가져옵니다."; +"Uses username + password to login and obtain an %@ automatically." = "사용자 이름 + 비밀번호로 로그인하여 %@을(를) 자동으로 가져옵니다."; +"Utilization End" = "사용률 종료"; +"Utilization Start" = "사용률 시작"; +"Verbosity" = "상세도"; +"Windsurf session JSON bundle" = "Windsurf 세션 JSON 번들"; +"Workspace ID" = "작업 공간 ID"; +"Your StepFun platform password. Used to login and obtain a session token." = "StepFun 플랫폼 비밀번호입니다. 로그인하여 세션 토큰을 가져오는 데 사용됩니다."; +"claude /login exited with status %d." = "claude /login이 상태 %d(으)로 종료되었습니다."; +"codex login exited with status %d." = "codex login이 상태 %d(으)로 종료되었습니다."; +"Cookie: …\n\nor paste a cURL capture from the Abacus AI dashboard" = "Cookie: …\n\n또는 Abacus AI 대시보드에서 캡처한 cURL을 붙여넣으세요"; +"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 토큰 값을 붙여넣으세요"; +"session_id=...\n\nor paste just the session_id value" = "session_id=...\n\n또는 session_id 값만 붙여넣으세요"; +"Clear" = "지우기"; +"No matching providers" = "일치하는 공급자 없음"; +"Search providers" = "공급자 검색"; +"language_vietnamese" = "베트남어"; +"language_turkish" = "Türkçe"; +"language_indonesian" = "인도네시아어"; +"Request quota: %@ / %@" = "요청 할당량: %@ / %@"; +"language_arabic" = "العربية"; +"language_persian" = "فارسی"; +"language_thai" = "ไทย"; +"language_galician" = "Galego"; +"Limit Reset Credits" = "한도 재설정 크레딧"; +"1 available" = "1회 사용 가능"; +"%d available" = "%d회 사용 가능"; +"Next expires %@" = "다음 만료: %@"; +"Expires %@" = "%@에 만료"; +"No expiry" = "만료 없음"; +"Other (%d items)" = "기타(%d개 항목)"; +"Expand" = "펼치기"; +"Collapse" = "접기"; +"byte_unit_byte" = "바이트"; +"byte_unit_bytes" = "바이트"; +"byte_unit_kilobyte" = "킬로바이트"; +"byte_unit_kilobytes" = "킬로바이트"; +"byte_unit_megabyte" = "메가바이트"; +"byte_unit_megabytes" = "메가바이트"; +"byte_unit_gigabyte" = "기가바이트"; +"byte_unit_gigabytes" = "기가바이트"; + +/* Settings sidebar redesign */ +"Enable" = "활성화"; +"Disable" = "비활성화"; +"providers_on_count" = "%d개 켜짐"; +"section_cost_summary" = "비용 요약"; +"section_command_line" = "명령줄"; +"section_privacy" = "개인정보 보호"; +"section_diagnostics" = "진단"; +"section_updates" = "업데이트"; +"section_links" = "링크"; +"Show Codex Spark usage" = "Codex Spark 사용량 표시"; +"Shows Codex Spark quota rows in the menu and provider preview. Requires optional credits and extra usage in Display settings." = "메뉴와 공급자 미리보기에 Codex Spark 할당량 행을 표시합니다. 표시 설정에서 ‘크레딧 + 추가 사용량 표시’를 활성화해야 합니다."; +"Show Daily Routines usage" = "일일 루틴 사용량 표시"; +"Shows the Daily Routines quota row in the menu and provider preview. Requires optional credits and extra usage in Display settings." = "메뉴와 공급자 미리보기에 일일 루틴 할당량 행을 표시합니다. 표시 설정에서 ‘크레딧 + 추가 사용량 표시’를 활성화해야 합니다."; +"Scroll to see more models" = "스크롤하여 더 많은 모델 보기"; +"Copy Image" = "이미지 복사"; +"Copy Stats" = "통계 복사"; +"Could not copy image" = "이미지를 복사할 수 없습니다"; +"Image copied" = "이미지가 복사되었습니다"; +"Image saved" = "이미지가 저장되었습니다"; +"Nothing is uploaded. This image is created on your Mac." = "업로드되는 항목이 없습니다. 이 이미지는 Mac에서 생성됩니다."; +"Save..." = "저장..."; +"Share AI Usage" = "AI 사용량 공유"; +"Share Stats…" = "통계 공유…"; +"Stats copied" = "통계가 복사되었습니다"; +"DeepSeek this month token usage trend" = "이번 달 DeepSeek 토큰 사용량 추이"; +"Chrome profile" = "Chrome 프로필"; +"Choose which signed-in DeepSeek Platform session supplies detailed usage." = "상세 사용량을 제공할 로그인된 DeepSeek Platform 세션을 선택하세요."; +"Detailed usage unavailable." = "상세 사용량을 확인할 수 없습니다."; +"Sign in to DeepSeek Platform in Chrome for detailed usage." = "상세 사용량을 보려면 Chrome에서 DeepSeek Platform에 로그인하세요."; +"Select a DeepSeek Chrome profile in Settings." = "설정에서 DeepSeek Chrome 프로필을 선택하세요."; +"Select profile…" = "프로필 선택…"; + +"%@: %@" = "%@: %@"; +"Alternatively, set a custom path in Settings." = "또는 설정에서 사용자 정의 경로를 설정하세요."; +"Choose a supported browser so CodexBar can read the matching account." = "CodexBar가 일치하는 계정을 읽을 수 있도록 지원되는 브라우저를 선택하세요."; +"Choose Cursor account" = "Cursor 계정 선택"; +"Choose which Cursor account CodexBar should use." = "CodexBar에서 사용할 Cursor 계정을 선택하세요."; +"Finish switching to a different Cursor account in your browser, then try again." = "브라우저에서 다른 Cursor 계정으로 전환을 완료한 후 다시 시도하세요."; +"Install a JetBrains IDE with AI Assistant enabled, then refresh CodexBar." = "AI Assistant가 활성화된 JetBrains IDE를 설치한 다음 CodexBar를 새로 고치세요."; +"Sign in with Claude Code..." = "Claude Code로 로그인하세요..."; +"Timed out waiting for Cursor account switch. %@" = "Cursor 계정 전환을 기다리는 동안 시간이 초과되었습니다. %@"; +"Timed out waiting for Cursor account switch. %@ Last error: %@" = "Cursor 계정 전환을 기다리는 동안 시간이 초과되었습니다. %@ 마지막 오류: %@"; +"Use Account" = "계정 사용"; +/* Spend dashboard */ +"tab_usage_spend" = "사용량 및 지출"; +"Usage & Spend" = "사용량 및 지출"; +"Local estimated cost history across supported providers." = "지원되는 공급자의 로컬 예상 비용 내역입니다."; +"Time range" = "기간"; +"Track costs" = "비용 추적"; +"Cost tracking is off" = "비용 추적이 꺼져 있습니다"; +"Turn on Track costs to build local estimates." = "로컬 예상치를 만들려면 ‘비용 추적’을 켜세요."; +"No local cost history yet" = "아직 로컬 비용 내역이 없습니다"; +"Turn on cost tracking or refresh after using a supported provider." = "비용 추적을 켜거나 지원되는 공급자를 사용한 후 새로 고치세요."; +"Refresh failures" = "새로 고침 실패"; +"Native currencies stay separate; Codex account rows exclude Pi session history." = "각 통화는 별도로 유지되며 Codex 계정 행에서는 Pi 세션 기록이 제외됩니다."; +"Spend unavailable" = "지출 정보 없음"; +"Model breakdown unavailable" = "모델별 내역을 사용할 수 없습니다"; +"Local estimated history" = "로컬 예상 내역"; +"Coverage" = "포함 범위"; +"Estimated spend" = "예상 지출"; +"Tracked tokens" = "추적된 토큰"; +"Subscriptions" = "구독"; +"By subscription" = "구독별"; +"No model-level history" = "모델별 내역이 없습니다"; +"Daily estimated spend" = "일별 예상 지출"; +"≈%d full 5h windows of weekly left · %d windows until reset" = "주간 한도 약 %d개의 전체 5시간 창 남음 · 재설정까지 %d개 창"; +"Weekly cannot run out before reset at this pace" = "이 속도라면 재설정 전에 주간 한도를 소진할 수 없습니다"; +"Weekly can run out ≈%d windows early" = "주간 한도가 약 %d개 창 일찍 소진될 수 있습니다"; +"Estimated: %@" = "예상: %@"; +"session_quota_estimate_value_format" = "%1$@ %2$@"; +"session quota" = "세션 할당량"; +"session quotas" = "세션 할당량"; +"Coding Plan" = "코딩 요금제"; +"Agent Plan" = "에이전트 요금제"; +"Team" = "팀"; + +/* Menu bar layout editor */ +"menu_bar_layout_title" = "레이아웃"; +"menu_bar_layout_footer" = "토큰을 드래그해 메뉴 막대를 배치하세요. 토큰을 클릭하면 추가되고, 배치된 토큰을 선택한 뒤 Delete 키를 누르면 제거됩니다."; +"menu_bar_layout_group_identity" = "식별 정보"; +"menu_bar_layout_group_usage" = "사용량"; +"menu_bar_layout_group_time" = "시간"; +"menu_bar_layout_group_money" = "비용"; +"menu_bar_layout_group_structure" = "구조"; +"menu_bar_layout_scope_all" = "모든 제공자"; +"menu_bar_layout_scope_help" = "기본 레이아웃을 편집하거나 제공자별로 재정의합니다."; +"menu_bar_layout_use_all" = "모든 제공자 레이아웃 사용"; +"menu_bar_layout_preset" = "레이아웃 프리셋"; +"menu_bar_layout_preset_icon_percent" = "아이콘 및 백분율"; +"menu_bar_layout_preset_icon_only" = "아이콘만"; +"menu_bar_layout_preset_percent_reset" = "백분율 + 재설정"; +"menu_bar_layout_preset_compact_stacked" = "압축 스택"; +"menu_bar_layout_preset_custom" = "사용자 설정"; +"menu_bar_layout_live_preview" = "실시간 미리보기"; +"menu_bar_layout_strip" = "메뉴 막대 스트립"; +"menu_bar_layout_remove_line_break" = "줄 바꿈 제거"; +"menu_bar_layout_chip_hint" = "선택하거나 드래그해 순서를 바꾸거나 제거 동작을 사용하세요."; +"menu_bar_layout_palette_hint" = "클릭해 추가하거나 레이아웃으로 드래그하세요."; +"menu_bar_layout_empty_line" = "여기에 토큰 놓기"; +"menu_bar_layout_line" = "%d번째 줄"; +"menu_bar_layout_drag_remove" = "여기로 드래그해 제거"; +"menu_bar_layout_size" = "크기"; +"menu_bar_layout_size_small" = "작게"; +"menu_bar_layout_size_regular" = "보통"; +"menu_bar_layout_gap" = "간격"; +"menu_bar_layout_gap_tight" = "좁게"; +"menu_bar_layout_gap_regular" = "보통"; +"menu_bar_layout_keyboard_hint" = "Delete 키로 선택한 토큰 제거"; +"menu_bar_layout_sample_account" = "계정"; +"menu_bar_layout_sample_runs_out" = "금요일 소진"; +"menu_bar_layout_token_icon" = "아이콘"; +"menu_bar_layout_token_provider" = "제공자 이름"; +"menu_bar_layout_token_account" = "계정"; +"menu_bar_layout_token_session" = "세션 %"; +"menu_bar_layout_token_weekly" = "주간 %"; +"menu_bar_layout_token_auto" = "자동 %"; +"menu_bar_layout_token_bar" = "사용량 막대"; +"menu_bar_layout_token_resets_in" = "재설정까지"; +"menu_bar_layout_token_reset_at" = "재설정 시각"; +"menu_bar_layout_token_runs_out" = "소진"; +"menu_bar_layout_token_cost_today" = "오늘 비용"; +"menu_bar_layout_token_cost_30d" = "30일 비용"; +"menu_bar_layout_token_space" = "공백"; +"menu_bar_layout_token_line_break" = "줄 바꿈"; +"menu_bar_layout_token_separator_accessibility" = "구분점"; + +/* Menu bar layout accessibility */ +"Icon unavailable" = "아이콘: 사용할 수 없음"; +"%@ icon" = "%@: 아이콘"; +"Provider name unavailable" = "제공자 이름: 사용할 수 없음"; +"Account unavailable" = "계정: 사용할 수 없음"; +"%@ unavailable" = "%@: 사용할 수 없음"; +"%@ %@" = "%@ %@"; +"Usage bar unavailable" = "사용량 막대: 사용할 수 없음"; +"Usage bar, %d of 3 filled" = "사용량 막대: %d/3 채움"; +"Reset countdown unavailable" = "재설정까지: 사용할 수 없음"; +"Reset time unavailable" = "재설정 시각: 사용할 수 없음"; +"Run-out estimate unavailable" = "소진: 사용할 수 없음"; +"Cost today unavailable" = "오늘 비용: 사용할 수 없음"; +"30-day cost unavailable" = "30일 비용: 사용할 수 없음"; +"Resets" = "재설정"; diff --git a/Sources/CodexBar/Resources/ko.lproj/Localizable.stringsdict b/Sources/CodexBar/Resources/ko.lproj/Localizable.stringsdict new file mode 100644 index 0000000000..d2ece2fd7c --- /dev/null +++ b/Sources/CodexBar/Resources/ko.lproj/Localizable.stringsdict @@ -0,0 +1,49 @@ + + + + + ≈%d full 5h windows of weekly left · %d windows until reset + + NSStringLocalizedFormatKey + %#@left@ · %#@until@ + left + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + d + one + 주간 한도 약 %d개의 전체 5시간 창 남음 + other + 주간 한도 약 %d개의 전체 5시간 창 남음 + + until + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + d + one + 재설정까지 %d개 창 + other + 재설정까지 %d개 창 + + + Weekly can run out ≈%d windows early + + NSStringLocalizedFormatKey + %#@early@ + early + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + d + one + 주간 한도가 약 %d개 창 일찍 소진될 수 있습니다 + other + 주간 한도가 약 %d개 창 일찍 소진될 수 있습니다 + + + + diff --git a/Sources/CodexBar/Resources/nl.lproj/Localizable.strings b/Sources/CodexBar/Resources/nl.lproj/Localizable.strings new file mode 100644 index 0000000000..7275279eae --- /dev/null +++ b/Sources/CodexBar/Resources/nl.lproj/Localizable.strings @@ -0,0 +1,1353 @@ +/* Dutch localization for CodexBar */ + +"tab_hooks" = "Hooks"; +"hooks_enable_title" = "Hooks inschakelen"; +"hooks_enable_subtitle" = "Voer externe opdrachten uit bij quota- of providergebeurtenissen."; +"hooks_trust_warning" = "Hooks kunnen lokale opdrachten op je Mac uitvoeren. Configureer alleen opdrachten die je vertrouwt."; +"hooks_rules_header" = "Regels"; +"hooks_empty" = "Geen hooks geconfigureerd."; +"hooks_add_rule" = "Regel toevoegen"; +"hooks_delete_rule" = "Regel verwijderen"; +"hooks_rule_enabled" = "Ingeschakeld"; +"hooks_event" = "Gebeurtenis"; +"hooks_provider" = "Provider"; +"hooks_any_provider" = "Elke provider"; +"hooks_threshold" = "Uitvoeren bij gebruik ≥"; +"hooks_threshold_placeholder" = "90"; +"hooks_executable_placeholder" = "/usr/local/bin/my-command"; +"hooks_arguments_placeholder" = "Argumenten"; +"hooks_argument_placeholder" = "Argument"; +"hooks_add_argument" = "Argument toevoegen"; +"hooks_delete_argument" = "Argument verwijderen"; + +"ollama_safari_cookie_access_hint" = "Safari-cookies vereisen volledige schijftoegang voor CodexBar (Systeeminstellingen > Privacy en beveiliging)."; +"ollama_browser_cookie_decryption_denied" = "Het ontsleutelen van %@-cookies is geweigerd in Sleutelhanger; probeer opnieuw met handmatig vernieuwen."; +"ollama_browser_cookie_decryption_disabled" = "Het ontsleutelen van %@-cookies is uitgeschakeld in CodexBar; schakel Sleutelhangertoegang in en vernieuw."; + +" providers" = " providers"; +"(System)" = "(Systeem)"; +"30d" = "30d"; +"7d" = "7d"; +"A managed Codex login is already running. Wait for it to finish before adding " = "Er is al een beheerde Codex-aanmelding actief. Wacht tot het klaar is voordat je het toevoegt"; +"API key" = "API-sleutel"; +"API region" = "API-regio"; +"API token" = "API-token"; +"API tokens" = "API-tokens"; +"About" = "Over"; +"Account" = "Account"; +"Accounts" = "Accounts"; +"Accounts subtitle" = "Accounts"; +"Active" = "Actief"; +"Add" = "Toevoegen"; +"Add Workspace" = "Werkruimte toevoegen"; +"Advanced" = "Geavanceerd"; +"All" = "Alle"; +"Always allow prompts" = "Sta altijd aanwijzingen toe"; +"Animation pattern" = "Animatie patroon"; +"Antigravity login is managed in the app" = "Antigravity-login wordt beheerd in de app"; +"Applies only to the Security.framework OAuth keychain reader." = "Geldt alleen voor de Security.framework OAuth-sleutelhangerlezer."; +"Auto falls back to the next source if the preferred one fails." = "Auto valt terug naar de volgende bron als de voorkeursbron uitvalt."; +"Auto uses API first, then falls back to CLI on auth failures." = "Auto gebruikt eerst de API en valt vervolgens terug op CLI bij auth-mislukkingen."; +"Auto-detect" = "Automatische detectie"; +"Auto-refresh is off; use the menu's Refresh command." = "Automatisch vernieuwen is uitgeschakeld; gebruik de opdracht Vernieuwen van het menu."; +"Auto-refresh: hourly · Timeout: 10m" = "Automatisch vernieuwen: elk uur · Time-out: 10m"; +"Automatic" = "Automatisch"; +"Automatic imports browser cookies and WorkOS tokens." = "Importeert automatisch browsercookies en WorkOS-tokens."; +"Automatic imports browser cookies and local storage tokens." = "Importeert automatisch browsercookies en lokale opslagtokens."; +"Automatic imports browser cookies for dashboard extras." = "Importeert automatisch browsercookies voor dashboardextra's."; +"Automatic imports browser cookies for the web API." = "Importeert automatisch browsercookies voor de web-API."; +"Automatic imports browser cookies from Model Studio/Bailian." = "Importeert automatisch browsercookies van Model Studio/Bailian."; +"Automatic imports browser cookies from admin.mistral.ai." = "Importeert automatisch browsercookies van admin.mistral.ai."; +"Automatic imports browser cookies from opencode.ai." = "Importeert automatisch browsercookies van opencode.ai."; +"Automatic imports browser cookies or stored sessions." = "Importeert automatisch browsercookies of opgeslagen sessies."; +"Automatic imports browser cookies." = "Automatische import van browsercookies."; +"Automatically imports browser session cookie." = "Importeert automatisch een browsersessiecookie."; +"Automatically opens CodexBar when you start your Mac." = "Opent automatisch CodexBar wanneer u uw Mac start."; +"Automation" = "Automatisering"; +"Average (\\(label1) + \\(label2))" = "Gemiddeld (\\(label1) + \\(label2))"; +"Average (\\(metadata.sessionLabel) + \\(metadata.weeklyLabel))" = "Gemiddeld (\\(metadata.sessionLabel) + \\(metadata.weeklyLabel))"; +"Avoid Keychain prompts" = "Vermijd sleutelhangerprompts"; +"Balance" = "Saldo"; +"Battery Saver" = "Batterijbesparing"; +"Bordered" = "Omzoomd"; +"Build" = "Bouwen"; +"Built \\(buildTimestamp)" = "Gebouwd \\(buildTimestamp)"; +"Buy Credits..." = "Koop tegoeden..."; +"Buy Credits…" = "Koop tegoeden…"; +"CLI paths" = "CLI-paden"; +"CLI sessions" = "CLI-sessies"; +"Caches" = "Caches"; +"Cancel" = "Annuleren"; +"Check for Updates…" = "Controleer op updates…"; +"Check for updates automatically" = "Automatisch controleren op updates"; +"Check if you like your agents having some fun up there." = "Controleer of je het leuk vindt dat je agenten daar plezier hebben."; +"Check provider status" = "Controleer de status van de provider"; +"Choose Codex workspace" = "Kies Codex-werkruimte"; +"Choose the MiniMax host (global .io or China mainland .com)." = "Kies de MiniMax-host (global .io of China vasteland .com)."; +"Choose up to " = "Kies tot"; +"Choose up to \\(Self.maxOverviewProviders) providers" = "Kies maximaal \\(Self.maxOverviewProviders) providers"; +"Choose up to \\(count) providers" = "Kies maximaal \\(count) providers"; +"Choose what to show in the menu bar (Pace shows usage vs. expected)." = "Kies wat u wilt weergeven in de menubalk (Tempo toont gebruik vs. verwacht)."; +"Choose which Codex account CodexBar should follow." = "Kies welk Codex-account CodexBar moet volgen."; +"Choose which window drives the menu bar percent." = "Kies welk venster het menubalkpercentage aanstuurt."; +"Chrome" = "Chrome"; +"Claude CLI not found" = "Claude CLI niet gevonden"; +"Claude binary" = "Claude binair"; +"Claude cookies" = "Claude-koekjes"; +"Claude login failed" = "Inloggen bij Claude is mislukt"; +"Claude login timed out" = "Er is een time-out opgetreden bij het inloggen bij Claude"; +"Close" = "Sluiten"; +"Code review" = "Code review"; +"Codex CLI not found" = "Codex-CLI niet gevonden"; +"Codex account login already running" = "Inloggen op Codex-account is al actief"; +"Codex binary" = "Codex binair"; +"Codex login failed" = "Codex-aanmelding mislukt"; +"Codex login timed out" = "Er is een time-out opgetreden bij het inloggen op de Codex"; +"CodexBar Lifecycle Keepalive" = "CodexBar-levenscyclus Keepalive"; +"CodexBar can't show its menu bar icon" = "CodexBar kan het menubalkpictogram niet weergeven"; +"CodexBar could not read managed account storage. " = "CodexBar kan de beheerde accountopslag niet lezen."; +"Configure…" = "Configureer…"; +"Connected" = "Aangesloten"; +"Controls how much detail is logged." = "Bepaalt hoeveel details worden geregistreerd."; +"Cookie header" = "Cookie-header"; +"Cookie source" = "Cookie-bron"; +"Cookie: ..." = "Koekje: ..."; +"Cookie: \\u{2026}\\\n\\\nor paste a cURL capture from the Abacus AI dashboard" = "Cookie: \\u{2026}\\\n\\\nof plak een cURL-opname vanuit het Abacus AI-dashboard"; +"Cookie: \\u{2026}\\\n\\\nor paste the __Secure-next-auth.session-token value" = "Cookie: \\u{2026}\\\n\\\nof plak de __Secure-next-auth.session-token-waarde"; +"Cookie: \\u{2026}\\\n\\\nor paste the kimi-auth token value" = "Cookie: \\u{2026}\\\n\\\nof plak de kimi-auth tokenwaarde"; +"Cookie: …" = "Cookie: …"; +"CopilotDeviceFlow" = "CopilotDeviceFlow"; +"Cost" = "Kosten"; +"Could not add Codex account" = "Kan Codex-account niet toevoegen"; +"Could not open Terminal for Gemini" = "Kan Terminal voor Gemini niet openen"; +"Could not start claude /login" = "Kan claude /login niet starten"; +"Could not start codex login" = "Kan codex-aanmelding niet starten"; +"Could not switch system account" = "Kan van systeemaccount niet wisselen"; +"Credits" = "Kredieten"; +"Individual credits" = "Individuele kredieten"; +"Workspace" = "Werkruimte"; +"Credits history" = "Creditgeschiedenis"; +"Cursor login failed" = "Cursoraanmelding mislukt"; +"Custom" = "Aangepast"; +"Custom Path" = "Aangepast pad"; +"Daily Routines" = "Dagelijkse routines"; +"Debug" = "Foutopsporing"; +"Default" = "Standaard"; +"Disable Keychain access" = "Schakel sleutelhangertoegang uit"; +"Disabled" = "Uitgeschakeld"; +"Dismiss" = "Afwijzen"; +"Disconnected" = "Verbinding verbroken"; +"Display" = "Weergave"; +"Display mode" = "Weergavemodus"; +"Display reset times as absolute clock values instead of countdowns." = "Geef resettijden weer als absolute klokwaarden in plaats van aftellingen."; +"Done" = "Klaar"; +"Effective PATH" = "Effectief PAD"; +"Email" = "E-mail"; +"Enable Merge Icons to configure Overview tab providers." = "Schakel Pictogrammen samenvoegen in om de providers van tabbladen Overzicht te configureren."; +"Enable file logging" = "Bestandsregistratie inschakelen"; +"Enabled" = "Ingeschakeld"; +"Error" = "Fout"; +"Error simulation" = "Foutsimulatie"; +"Expose troubleshooting tools in the Debug tab." = "Geef hulpprogramma's voor probleemoplossing weer op het tabblad Foutopsporing."; +"Failed" = "Mislukt"; +"False" = "Onwaar"; +"Fetch strategy attempts" = "Strategiepogingen ophalen"; +"Fetching" = "Ophalen"; +"Field" = "Veld"; +"Field subtitle" = "Ondertitel van veld"; +"Finish the current managed account change before switching the system account." = "Voltooi de huidige beheerde accountwijziging voordat u van systeemaccount wisselt."; +"Force animation on next refresh" = "Animatie forceren bij volgende vernieuwing"; +"Gateway region" = "Gateway-regio"; +"Gemini CLI not found" = "Gemini-CLI niet gevonden"; +"Gemini/Antigravity, surfacing incidents in the icon and menu." = "Gemini/Antigravity, incidenten verschijnen in het pictogram en het menu."; +"General" = "Algemeen"; +"GitHub" = "GitHub"; +"GitHub Copilot Login" = "GitHub Copilot-aanmelding"; +"GitHub Login" = "GitHub-aanmelding"; +"Hide details" = "Details verbergen"; +"Hide personal information" = "Verberg persoonlijke informatie"; +"Historical tracking" = "Historische tracking"; +"How often CodexBar polls providers in the background." = "Hoe vaak CodexBar providers op de achtergrond ondervraagt."; +"Inactive" = "Inactief"; +"Install CLI" = "Installeer CLI"; +"Install the Claude CLI (npm i -g @anthropic-ai/claude-code) and try again." = "Installeer de Claude CLI (npm i -g @anthropic-ai/claude-code) en probeer het opnieuw."; +"Install the Codex CLI (npm i -g @openai/codex) and try again." = "Installeer de Codex CLI (npm i -g @openai/codex) en probeer het opnieuw."; +"Install the Gemini CLI (npm i -g @google/gemini-cli) and try again." = "Installeer de Gemini CLI (npm i -g @google/gemini-cli) en probeer het opnieuw."; +"JetBrains AI is ready" = "JetBrains AI is klaar"; +"JetBrains IDE" = "JetBrains IDE"; +"Keep CLI sessions alive" = "Houd CLI-sessies levend"; +"Keyboard shortcut" = "Sneltoets"; +"Keychain access" = "Toegang via sleutelhanger"; +"Keychain prompt policy" = "Sleutelhangerpromptbeleid"; +"Last \\(name) fetch failed:" = "Laatste \\(name) ophalen mislukt:"; +"Last \\(self.store.metadata(for: self.provider).displayName) fetch failed:" = "Laatste ophalen van \\(self.store.metadata(for: self.provider).displayName) mislukt:"; +"Last attempt" = "Laatste poging"; +"Link" = "Link"; +"Loading animations" = "Animaties laden"; +"Loading…" = "Laden…"; +"Local" = "Lokaal"; +"Logging" = "Loggen"; +"Login failed" = "Inloggen mislukt"; +"Login shell PATH (startup capture)" = "Login shell PATH (opstartopname)"; +"Login timed out" = "Er is een time-out opgetreden voor het inloggen"; +"MCP details" = "MCP-details"; +"Managed Codex accounts unavailable" = "Beheerde Codex-accounts zijn niet beschikbaar"; +"Managed account storage is unreadable. Live account access is still available, " = "Beheerde accountopslag is onleesbaar. Live accounttoegang is nog steeds beschikbaar,"; +"Manual" = "Handmatig"; +"May your tokens never run out—keep agent limits in view." = "Moge uw tokens nooit opraken: houd de limieten van agenten in het oog."; +"Menu bar" = "Menubalk"; +"Menu bar auto-shows the provider closest to its rate limit." = "De menubalk toont automatisch de aanbieder die het dichtst bij de tarieflimiet zit."; +"Menu bar metric" = "Menubalkstatistiek"; +"Menu bar shows percent" = "Menubalk toont percentage"; +"Menu content" = "Menu-inhoud"; +"Merge Icons" = "Pictogrammen samenvoegen"; +"Never prompt" = "Nooit vragen"; +"No" = "Nee"; +"No Codex accounts detected yet." = "Er zijn nog geen Codex-accounts gedetecteerd."; +"No JetBrains IDE detected" = "Geen JetBrains IDE gedetecteerd"; +"No cost history data." = "Geen kostengeschiedenisgegevens."; +"No data available" = "Geen gegevens beschikbaar"; +"No data yet" = "Nog geen gegevens"; +"No enabled providers available for Overview." = "Er zijn geen ingeschakelde providers beschikbaar voor Overzicht."; +"No providers selected" = "Geen aanbieders geselecteerd"; +"No token accounts yet." = "Nog geen tokenaccounts."; +"No usage breakdown data." = "Geen gebruiksgegevens."; +"None" = "Geen"; +"Notifications" = "Meldingen"; +"Notifies when the 5-hour session quota hits 0% and when it becomes " = "Geeft een melding wanneer het sessiequotum van 5 uur 0% bereikt en wanneer dit wordt bereikt"; +"OK" = "OK"; +"Obscure email addresses in the menu bar and menu UI." = "Onduidelijke e-mailadressen in de menubalk en menu-UI."; +"Off" = "Uit"; +"Offline" = "Offline"; +"On" = "Op"; +"Online" = "Online"; +"Only on user action" = "Alleen bij gebruikersactie"; +"Open" = "Open"; +"Open API Keys" = "Open API-sleutels"; +"Open Amp Settings" = "Open Versterkerinstellingen"; +"Open Antigravity to sign in, then refresh CodexBar." = "Open Antigravity om in te loggen en vernieuw vervolgens CodexBar."; +"Open Browser" = "Browser openen"; +"Open Coding Plan" = "Coderingsplan openen"; +"Open Console" = "Console openen"; +"Open Dashboard" = "Dashboard openen"; +"Open Mistral Admin" = "Open Mistral-beheer"; +"Open Menu Bar Settings" = "Open Menubalkinstellingen"; +"Open Ollama Settings" = "Open Ollama-instellingen"; +"Open Terminal" = "Terminal openen"; +"Open Usage Page" = "Gebruikspagina openen"; +"Open Warp API Key Guide" = "Open Warp API-sleutelgids"; +"Open menu" = "Menu openen"; +"Open token file" = "Tokenbestand openen"; +"OpenAI cookies" = "OpenAI-cookies"; +"OpenAI web extras" = "OpenAI-webextra's"; +"Option A" = "Optie A"; +"Option B" = "Optie B"; +"Optional override if workspace lookup fails." = "Optioneel overschrijven als het opzoeken van de werkruimte mislukt."; +"Options" = "Opties"; +"Override auto-detection with a custom IDE base path" = "Overschrijf automatische detectie met een aangepast IDE-basispad"; +"Overview" = "Overzicht"; +"Overview rows always follow provider order." = "Overzichtsrijen volgen altijd de volgorde van de provider."; +"Overview tab providers" = "Overzicht tabblad aanbieders"; +"Paste API key…" = "API-sleutel plakken…"; +"Paste API token…" = "API-token plakken…"; +"Paste key…" = "Sleutel plakken…"; +"Paste sessionKey or OAuth token…" = "SessionKey of OAuth-token plakken..."; +"Paste the Cookie header from a request to admin.mistral.ai. " = "Plak de Cookie-header uit een verzoek naar admin.mistral.ai."; +"Paste token…" = "Token plakken..."; +"Personal" = "Persoonlijk"; +"Picker" = "Kikker"; +"Picker subtitle" = "Ondertitel kiezen"; +"Placeholder" = "Tijdelijke aanduiding"; +"Plan" = "Plan"; +"Plan Usage" = "Plangebruik"; +"Play full-screen confetti when weekly usage resets." = "Speel confetti op volledig scherm af wanneer het wekelijkse gebruik wordt gereset."; +"Polls OpenAI/Claude status pages and Google Workspace for " = "Polls OpenAI/Claude-statuspagina's en Google Workspace voor"; +"Prevents any Keychain access while enabled." = "Voorkomt elke sleutelhangertoegang indien ingeschakeld."; +"Primary (API key limit)" = "Primair (API-sleutellimiet)"; +"Primary (\\(label))" = "Primair (\\(label))"; +"Primary (\\(metadata.sessionLabel))" = "Primair (\\(metadata.sessionLabel))"; +"Probe logs" = "Sondelogboeken"; +"Progress bars fill as you consume quota (instead of showing remaining)." = "Voortgangsbalken worden gevuld naarmate u uw quotum verbruikt (in plaats van de resterende hoeveelheid weer te geven)."; +"Provider" = "Aanbieder"; +"Providers" = "Providers"; +"Quit CodexBar" = "Sluit CodexBar af"; +"Random (default)" = "Willekeurig (standaard)"; +"Reads local usage logs. Shows today + last 30 days cost in the menu." = "Leest lokale gebruikslogboeken. Toont vandaag + het geselecteerde geschiedenisvenster in het menu."; +"Refresh" = "Vernieuwen"; +"Refresh cadence" = "Cadans vernieuwen"; +"Remote" = "Op afstand"; +"Remove" = "Verwijderen"; +"Remove Codex account?" = "Codex-account verwijderen?"; +"Remove \\(account.email) from CodexBar? Its managed Codex home will be deleted." = "\\(account.email) verwijderen uit CodexBar? Het beheerde Codex-huis wordt verwijderd."; +"Remove \\(email) from CodexBar? Its managed Codex home will be deleted." = "\\(email) verwijderen uit CodexBar? Het beheerde Codex-huis wordt verwijderd."; +"Remove selected account" = "Geselecteerd account verwijderen"; +"Replace critter bars with provider branding icons and a percentage." = "Vervang critterbalken door brandingpictogrammen van de provider en een percentage."; +"Replay selected animation" = "Speel de geselecteerde animatie opnieuw af"; +"Requires authentication via GitHub Device Flow." = "Vereist authenticatie via GitHub Device Flow."; +"Resets: \\(reset)" = "Resetten: \\(reset)"; +"Rolling five-hour limit" = "Doorlopende limiet van vijf uur"; +"Search hourly" = "Zoek per uur"; +"Secondary (\\(label))" = "Secundair (\\(label))"; +"Secondary (\\(metadata.weeklyLabel))" = "Secundair (\\(metadata.weeklyLabel))"; +"Select a provider" = "Selecteer een aanbieder"; +"Select the IDE to monitor" = "Selecteer de IDE die u wilt monitoren"; +"Session quota notifications" = "Meldingen over sessiequota"; +"Session tokens" = "Sessietokens"; +"provider_section_connection" = "Verbinding"; +"provider_section_menu_bar" = "Menubalk"; +"Show Codex Credits and Claude Extra usage sections in the menu." = "Toon Codex Credits en Claude Extra gebruikssecties in het menu."; +"Show Debug Settings" = "Toon foutopsporingsinstellingen"; +"Show all token accounts" = "Toon alle tokenaccounts"; +"Show cost summary" = "Kostenoverzicht weergeven"; +"Show credits + extra usage" = "Toon credits + extra gebruik"; +"Show details" = "Details weergeven"; +"Show most-used provider" = "Toon meest gebruikte provider"; +"Show provider icons in the switcher (otherwise show a weekly progress line)." = "Toon providerpictogrammen in de switcher (toon anders een wekelijkse voortgangslijn)."; +"Show reset time as clock" = "Toon resettijd als klok"; +"Show usage as used" = "Toon gebruik zoals gebruikt"; +"Sign in via button below" = "Meld u aan via onderstaande knop"; +"Skip teardown between probes (debug-only)." = "Sla demontage tussen tests over (alleen voor foutopsporing)."; +"Stack token accounts in the menu (otherwise show an account switcher bar)." = "Stapel token-accounts in het menu (laat anders een accountwisselbalk zien)."; +"Start at Login" = "Begin bij Inloggen"; +"Status" = "Status"; +"Store Claude sessionKey cookies or OAuth access tokens." = "Bewaar Claude sessionKey-cookies of OAuth-toegangstokens."; +"Store multiple Abacus AI Cookie headers." = "Bewaar meerdere Abacus AI Cookie-headers."; +"Store multiple Augment Cookie headers." = "Bewaar meerdere Augment Cookie-headers."; +"Store multiple Cursor Cookie headers." = "Bewaar meerdere Cursor Cookie-headers."; +"Store multiple Factory Cookie headers." = "Bewaar meerdere Factory Cookie-headers."; +"Store multiple MiniMax Cookie headers." = "Bewaar meerdere MiniMax Cookie-headers."; +"Store multiple Mistral Cookie headers." = "Bewaar meerdere Mistral Cookie-headers."; +"Store multiple Ollama Cookie headers." = "Bewaar meerdere Ollama Cookie-headers."; +"Store multiple OpenCode Cookie headers." = "Bewaar meerdere OpenCode Cookie-headers."; +"Store multiple OpenCode Go Cookie headers." = "Bewaar meerdere OpenCode Go Cookie-headers."; +"Stored in the CodexBar config file." = "Opgeslagen in het CodexBar-configuratiebestand."; +"Stored in ~/.codexbar/config.json. " = "Opgeslagen in ~/.codexbar/config.json."; +"Stored in ~/.codexbar/config.json. Paste the key from the Synthetic dashboard." = "Opgeslagen in ~/.codexbar/config.json. Plak de sleutel uit het synthetische dashboard."; +"Stored in ~/.codexbar/config.json. Paste your Coding Plan API key from Model Studio." = "Opgeslagen in ~/.codexbar/config.json. Plak de API-sleutel van uw codeerplan uit Model Studio."; +"Stored in ~/.codexbar/config.json. Paste your MiniMax API key." = "Opgeslagen in ~/.codexbar/config.json. Plak uw MiniMax API-sleutel."; +"Stored in ~/.codexbar/config.json. You can also provide KILO_API_KEY or " = "Opgeslagen in ~/.codexbar/config.json. U kunt ook KILO_API_KEY of"; +"Stores local Codex usage history (8 weeks) to personalize Pace predictions." = "Slaat de lokale Codex-gebruiksgeschiedenis op (8 weken) om tempo-voorspellingen te personaliseren."; +"Surprise me" = "Verras mij"; +"Switcher shows icons" = "Switcher toont pictogrammen"; +"Symlink CodexBarCLI to /usr/local/bin and /opt/homebrew/bin as codexbar." = "Symlink CodexBarCLI naar /usr/local/bin en /opt/homebrew/bin als codexbar."; +"System" = "Systeem"; +"Temporarily shows the loading animation after the next refresh." = "Toont tijdelijk de laadanimatie na de volgende vernieuwing."; +"terminal_app_subtitle" = "Terminal gebruikt door de actie Terminal openen"; +"terminal_app_title" = "Standaardterminal"; +"Tertiary (\\(label))" = "Tertiair (\\(label))"; +"Tertiary (\\(tertiaryTitle))" = "Tertiair (\\(tertiaryTitle))"; +"The default Codex account on this Mac." = "Het standaard Codex-account op deze Mac."; +"Toggle" = "Schakelaar"; +"Toggle subtitle" = "Schakel ondertiteling in"; +"Token" = "Token"; +"Trigger the menu bar menu from anywhere." = "Activeer het menubalkmenu vanaf elke locatie."; +"True" = "WAAR"; +"Twitter" = "Twitteren"; +"Unsupported" = "Niet ondersteund"; +"Update Channel" = "Kanaal bijwerken"; +"Updated" = "Bijgewerkt"; +"Updates unavailable in this build." = "Updates zijn niet beschikbaar in deze build."; +"Usage" = "Gebruik"; +"Usage breakdown" = "Uitsplitsing van gebruik"; +"Usage history (30 days)" = "Gebruiksgeschiedenis"; +"Usage source" = "Gebruiksbron"; +"Use BigModel for the China mainland endpoints (open.bigmodel.cn)." = "Gebruik BigModel voor de eindpunten op het vasteland van China (open.bigmodel.cn)."; +"Use a single menu bar icon with a provider switcher." = "Gebruik één menubalkpictogram met een providerwisselaar."; +"Use international or China mainland console gateways for quota fetches." = "Gebruik internationale of Chinese consolegateways voor het ophalen van quota."; +"Version" = "Versie"; +"Version \\(self.versionString)" = "Versie \\(self.versionString)"; +"Version \\(version)" = "Versie \\(version)"; +"Version \\(versionString)" = "Versie \\(versionString)"; +"Vertex AI Login" = "Vertex AI-login"; +"Wait for the current managed Codex login to finish before adding another account." = "Wacht tot de huidige beheerde Codex-aanmelding is voltooid voordat u een ander account toevoegt."; +"Waiting for Authentication..." = "Wachten op authenticatie..."; +"Website" = "Website"; +"Weekly limit confetti" = "Wekelijkse limiet confetti"; +"Weekly token limit" = "Wekelijkse tokenlimiet"; +"Weekly usage" = "Wekelijks gebruik"; +"Weekly usage unavailable for this account." = "Wekelijks gebruik is niet beschikbaar voor dit account."; +"Window: \\(window)" = "Venster: \\(window)"; +"Write logs to \\(self.fileLogPath) for debugging." = "Schrijf logboeken naar \\(self.fileLogPath) voor foutopsporing."; +"Yes" = "Ja"; +"\\(detail.modelCode): \\(usage)" = "\\(detail.modelCode): \\(usage)"; +"\\(name): \\(truncated)" = "\\(name): \\(truncated)"; +"\\(name): \\(updated) · 30d \\(cost)" = "\\(name): \\(updated) · 30d \\(cost)"; +"\\(name): fetching…\\(elapsed)" = "\\(name): ophalen…\\(elapsed)"; +"\\(name): last attempt \\(when)" = "\\(name): laatste poging \\(when)"; +"\\(name): no data yet" = "\\(name): nog geen gegevens"; +"\\(name): unsupported" = "\\(name): niet ondersteund"; +"all browsers" = "alle browsers"; +"available again." = "weer beschikbaar."; +"built_format" = "Gebouwd %@"; +"copilot_complete_in_browser" = "Voltooi het inloggen in uw browser."; +"copilot_device_code" = "Apparaatcode gekopieerd naar klembord: %1$@\n\nVerifiëren op: %2$@"; +"copilot_device_code_copied" = "Apparaatcode gekopieerd."; +"copilot_verify_at" = "Verifiëren op %@"; +"copilot_waiting_text" = "Voltooi het inloggen in uw browser.\nDit venster wordt automatisch gesloten wanneer het inloggen is voltooid."; +"copilot_window_closes_auto" = "Dit venster wordt automatisch gesloten wanneer het inloggen is voltooid."; +"cost_status_error" = "%1$@: %2$@"; +"cost_status_fetching" = "%1$@: ophalen… %2$@"; +"cost_status_last_attempt" = "%1$@: laatste poging %2$@"; +"cost_status_no_data" = "%@: nog geen gegevens"; +"cost_status_snapshot" = "%1$@: %2$@ · %3$@ %4$@"; +"cost_status_unsupported" = "%@: niet ondersteund"; +"credits_remaining" = "Tegoeden: %@"; +"cursor_on_demand" = "Op aanvraag: %@"; +"cursor_on_demand_with_limit" = "Op aanvraag: %1$@ / %2$@"; +"extra_usage_format" = "Extra verbruik: %1$@ / %2$@"; +"jetbrains_detected_generate" = "Gedetecteerd: %@. Gebruik de AI-assistent één keer om quotagegevens te genereren en vernieuw vervolgens CodexBar."; +"jetbrains_detected_select" = "Gedetecteerd: %@. Selecteer uw favoriete IDE in Instellingen en vernieuw vervolgens CodexBar."; +"last_fetch_failed_with_provider" = "Laatste %@ ophaalactie mislukt:"; +"last_spend" = "Laatste uitgave: %@"; +"mcp_model_usage" = "%1$@: %2$@"; +"mcp_resets" = "Resetten: %@"; +"mcp_window" = "Venster: %@"; +"metric_average" = "Gemiddeld (%1$@ + %2$@)"; +"metric_primary" = "Primair (%@)"; +"metric_secondary" = "Secundair (%@)"; +"metric_tertiary" = "Tertiair (%@)"; +"multiple_workspaces_found" = "CodexBar heeft meerdere werkruimten gevonden voor %@. Kies de werkruimte die u wilt toevoegen."; +"ory_session_…=…; csrftoken=…" = "ory_sessie_…=…; csrftoken=…"; +"overview_choose_providers" = "Kies maximaal %@ providers"; +"remove_account_message" = "%@ verwijderen uit CodexBar? Het beheerde Codex-huis wordt verwijderd."; +"version_format" = "Versie %@"; +"vertex_ai_login_instructions" = "Om het gebruik van Vertex AI bij te houden, authenticeert u zich met Google Cloud.\n\n1. Open Terminal\n2. Uitvoeren: gcloud auth applicatie-standaard login\n3. Volg de browserprompts om in te loggen\n4. Stel uw project in: gcloud config set project PROJECT_ID\n\nTerminal nu openen?"; +"workspaceID is set but only opencode, opencodego, and deepgram support workspaceID." = "workspaceID is ingesteld, maar alleen opencode, opencodego en deepgram ondersteunen workspaceID."; +"© 2026 Peter Steinberger. MIT License." = "© 2026 Peter Steinberger. MIT-licentie."; + +/* General Pane */ +"section_system" = "Systeem"; +"section_usage" = "Gebruik"; +"section_refreshing" = "Vernieuwen"; +"section_alerts" = "Waarschuwingen"; +"section_celebrations" = "Vieringen"; +"section_icon" = "Pictogram"; +"section_combined_icon" = "Gecombineerd pictogram"; +"section_animation" = "Animatie"; +"section_content" = "Inhoud"; +"section_agent_sessions" = "Agentsessies"; +"language_title" = "Taal"; +"language_subtitle" = "Wijzig de weergavetaal. Vereist een herstart van de app om volledig effect te krijgen."; +"currency_title" = "Voorkeursvaluta"; +"currency_subtitle" = "Valuta voor kostenramingen en uitgaven. Gebruikt dagelijks bijgewerkte wisselkoersen."; +"currency_auto" = "Automatisch (provider / USD volgen)"; +"language_system" = "Systeem"; +"language_english" = "Engels"; +"language_spanish" = "Spaans"; +"language_catalan" = "Català"; +"language_chinese_simplified" = "简体中文"; +"language_chinese_traditional" = "繁體中文"; +"language_portuguese_brazilian" = "Portugees (Brazilië)"; +"language_dutch" = "Nederlands"; +"language_german" = "Duits"; +"language_french" = "Frans"; +"language_ukrainian" = "Oekraïens"; +"language_russian" = "Русский"; +"language_japanese" = "Japans"; +"language_korean" = "Koreaans"; +"language_italian" = "Italiano"; +"language_swedish" = "Zweeds"; +"language_vietnamese" = "Vietnamees"; +"language_turkish" = "Türkçe"; +"language_indonesian" = "Indonesisch"; +"language_polish" = "Pools"; +"start_at_login_title" = "Begin bij Inloggen"; +"start_at_login_subtitle" = "Opent automatisch CodexBar wanneer u uw Mac start."; +"show_cost_summary_subtitle" = "Leest lokale gebruikslogboeken. Toont vandaag + het geselecteerde geschiedenisvenster in het menu."; +"cost_summary_style_title" = "Weergavestijl"; +"cost_summary_style_inline" = "Alleen inline"; +"cost_summary_style_submenu" = "Alleen submenu"; +"cost_summary_style_both" = "Beide"; +"cost_summary_style_inline_help" = "Toont het kostenoverzicht rechtstreeks in het hoofdmenu."; +"cost_summary_style_submenu_help" = "Toont in plaats daarvan het gedetailleerde Kosten-submenu."; +"cost_summary_style_both_help" = "Toont zowel het hoofdmenu-overzicht als het gedetailleerde Kosten-submenu."; +"cost_history_window_title" = "Geschiedenisvenster"; +"cost_history_window_help" = "Stelt in hoeveel dagen lokale gebruikslogboeken in het menu verschijnen."; +"cost_history_days_title" = "Geschiedenisvenster: %d dagen"; +"cost_auto_refresh_info" = "Automatisch vernieuwen: algemeen interval (minimaal 5 min) · Time-out: 10 min"; +"cost_comparison_periods_title" = "Kortere vergelijkingsperioden tonen"; +"cost_comparison_periods_subtitle" = "Voegt totalen voor 7, 30 en 90 dagen toe wanneer ze binnen het geselecteerde geschiedenisvenster vallen. Deze totalen gebruiken dezelfde lokale scan."; +"refresh_interval_title" = "Vernieuwingsinterval"; +"manual_refresh_hint" = "Automatisch vernieuwen is uitgeschakeld; gebruik de opdracht Vernieuwen van het menu."; +"refresh_on_open_title" = "Vernieuwen bij openen van menu"; +"refresh_on_open_subtitle" = "Haalt bij elke keer dat je het menu opent het meest recente gebruik van elke provider op."; +"check_provider_status_title" = "Controleer de status van de provider"; +"check_provider_status_subtitle" = "Polls van OpenAI/Claude-statuspagina's en Google Workspace voor Gemini/Antigravity, waarbij incidenten in het pictogram en het menu worden weergegeven."; +"session_quota_notifications_subtitle" = "Geeft een melding wanneer het sessiequotum van 5 uur 0% bereikt en wanneer het weer beschikbaar komt."; +"quota_depleted_title" = "Quotum uitgeput en hersteld"; +"quota_warning_notifications_subtitle" = "Waarschuwt wanneer het resterende sessie- of wekelijkse quotum de geconfigureerde drempels overschrijdt."; +"threshold_warnings_title" = "Drempelwaarschuwingen"; +"quota_warnings_title" = "Quotumwaarschuwingen"; +"quota_warning_session" = "sessie"; +"quota_warning_session_capitalized" = "Sessie"; +"quota_warning_weekly" = "wekelijks"; +"quota_warning_weekly_capitalized" = "Wekelijks"; +"quota_warning_notification_title" = "%1$@ %2$@ quotum laag"; +"quota_warning_notification_body" = "%1$@ over. Je waarschuwingsdrempel van %2$d%% %3$@ is bereikt."; +"quota_warning_notification_body_with_account" = "Rekening %1$@. %2$@ over. Je waarschuwingsdrempel van %3$d%% %4$@ is bereikt."; +"predictive_pace_warnings_title" = "Voorspellende tempowaarschuwingen"; +"predictive_pace_warnings_subtitle" = "Waarschuwt voor Codex en Claude wanneer het sessie- of wekelijkse tempo het quotum vóór de reset kan opgebruiken."; +"confetti_on_reset_title" = "Confetti bij reset"; +"confetti_on_reset_subtitle" = "Speel confetti op volledig scherm af wanneer het gebruik wordt gereset."; +"confetti_option_off" = "Uit"; +"confetti_option_session" = "Sessieresets"; +"confetti_option_weekly" = "Wekelijkse resets"; +"confetti_option_both" = "Beide"; +"predictive_pace_warning_notification_title" = "%1$@ %2$@-tempowaarschuwing"; +"predictive_pace_warning_notification_body" = "Bij het huidige tempo kan dit quotum over %1$@ opraken, voordat het wordt gereset."; +"predictive_pace_warning_notification_body_with_account" = "Account %1$@. Bij het huidige tempo kan dit quotum over %2$@ opraken, voordat het wordt gereset."; +"session_depleted_notification_title" = "%@ sessie uitgeput"; +"session_depleted_notification_body" = "0% over. Zal op de hoogte stellen wanneer het weer beschikbaar is."; +"session_restored_notification_title" = "%@ sessie hersteld"; +"session_restored_notification_body" = "Sessiequota zijn weer beschikbaar."; +"quota_warning_warn_at" = "Waarschuw bij"; +"quota_warning_global_threshold_subtitle" = "Resterende percentages voor sessie- en wekelijkse vensters, tenzij een provider deze overschrijft."; +"quota_warning_sound" = "Meldingsgeluid afspelen"; +"quota_warning_onscreen_alert" = "Tekstwaarschuwing op het scherm tonen"; +"quota_warning_provider_inherits" = "Gebruikt de algemene instellingen voor quotawaarschuwingen, tenzij hier een venster wordt aangepast."; +"quota_warning_provider_disabled" = "Meldingen voor quotawaarschuwingen en markeringen op gebruiksbalken zijn uitgeschakeld. Schakel een van beide in om deze opgeslagen instellingen te bewerken."; +"quota_warning_provider_markers_only" = "Meldingen voor quotawaarschuwingen zijn globaal uitgeschakeld. Deze instellingen bepalen nog steeds de markeringen op gebruiksbalken."; +"quota_warning_global" = "Globaal"; +"quota_warning_customize_thresholds" = "Pas %@ drempels aan"; +"quota_warning_enable_warnings" = "Schakel %@ waarschuwingen in"; +"quota_warning_window_warn_at" = "%@ waarschuwen om"; +"quota_warning_off" = "Uit"; +"quota_warning_inherited" = "Geërfd: %@"; +"quota_warning_depleted_only" = "alleen maar uitgeput"; +"quota_warning_upper" = "Hoger"; +"quota_warning_lower" = "Lager"; +"quota_warning_warning" = "Waarschuwing"; +"quota_warning_critical" = "Kritiek"; +"apply" = "Toepassen"; +"quit_app" = "Sluit CodexBar af"; + +/* Tab titles */ +"tab_general" = "Algemeen"; +"tab_providers" = "Aanbieders"; +"tab_notifications" = "Meldingen"; +"tab_menu_bar" = "Menubalk"; +"tab_menu" = "Menu"; +"tab_advanced" = "Geavanceerd"; +"tab_about" = "Over"; +"tab_debug" = "Foutopsporing"; + +/* Providers Pane */ +"select_a_provider" = "Selecteer een aanbieder"; +"cancel" = "Annuleren"; +"last_fetch_failed" = "laatste ophaalactie mislukt"; +"usage_not_fetched_yet" = "gebruik nog niet opgehaald"; +"managed_account_storage_unreadable" = "Beheerde accountopslag is onleesbaar. Live accounttoegang is nog steeds beschikbaar, maar beheerde acties voor toevoegen, opnieuw verifiëren en verwijderen zijn uitgeschakeld totdat de winkel kan worden hersteld."; +"remove_codex_account_title" = "Codex-account verwijderen?"; +"remove" = "Verwijderen"; +"managed_login_already_running" = "Er is al een beheerde Codex-aanmelding actief. Wacht tot het klaar is voordat u een ander account toevoegt of opnieuw verifieert."; +"managed_login_failed" = "Beheerde Codex-aanmelding is niet voltooid. Controleer of `codex --version` werkt in Terminal. Als macOS `codex` naar de prullenbak heeft geblokkeerd of verplaatst, verwijdert u verouderde dubbele installaties, voert u `npm install -g --include=optioneel @openai/codex@latest` uit en probeert u het vervolgens opnieuw."; +"codex_login_output" = "codex login-uitvoer:"; +"managed_login_missing_email" = "Codex-aanmelding voltooid, maar er was geen account-e-mailadres beschikbaar. Probeer het opnieuw nadat u heeft bevestigd dat het account volledig is aangemeld."; +"login_success_notification_title" = "%@ inloggen succesvol"; +"login_success_notification_body" = "U kunt terugkeren naar de app; authenticatie voltooid."; +"workspace_selection_cancelled" = "CodexBar heeft meerdere werkruimten gevonden, maar er is geen werkruimte geselecteerd."; +"unsafe_managed_home" = "CodexBar weigerde een onverwacht beheerd thuispad te wijzigen: %@"; +"menu_bar_metric_title" = "Menubalkstatistiek"; +"menu_bar_metric_subtitle" = "Kies welk venster het menubalkpercentage aanstuurt."; +"menu_bar_metric_subtitle_deepseek" = "Toont het DeepSeek-saldo in de menubalk."; +"menu_bar_metric_subtitle_moonshot" = "Toont het Moonshot / Kimi API-saldo in de menubalk."; +"menu_bar_metric_subtitle_mistral" = "Toont de Mistral API-uitgaven van de huidige maand in de menubalk."; +"automatic" = "Automatisch"; +"primary_api_key_limit" = "Primair (API-sleutellimiet)"; + +/* Display Pane */ +"menu_bar_style_title" = "Menubalkstijl"; +"menu_bar_style_subtitle" = "Hoe het menubalkitem wordt weergegeven."; +"menu_bar_inactive_display_contrast_title" = "Zichtbaarheid op inactieve beeldschermen verbeteren"; +"menu_bar_usage_colors_title" = "Gebruik met kleurcodering"; +"menu_bar_usage_colors_subtitle" = "Kleurt het menubalksymbool van groen naar rood naarmate het gebruik stijgt."; +"menu_bar_inactive_display_contrast_subtitle" = "Gebruikt weergave met hoog contrast zodat het pictogram en de metriek leesbaar blijven op andere beeldschermen."; +"menu_bar_style_critters" = "Critters"; +"menu_bar_style_bars" = "Meterbalken"; +"menu_bar_style_icon_percent" = "Pictogram en percentage"; +"switcher_rows_title" = "Switcher-rijen"; +"switcher_rows_icons" = "Providerpictogrammen"; +"switcher_rows_progress" = "Wekelijkse voortgang"; +"usage_bars_fill_title" = "Vulling gebruiksbalken"; +"usage_bars_fill_remaining" = "Als resterend"; +"usage_bars_fill_used" = "Als gebruikt"; +"reset_times_title" = "Resettijden"; +"reset_times_countdown" = "Aftellen"; +"reset_times_clock" = "Kloktijd"; +"cost_summary_title" = "Kostenoverzicht"; +"cost_summary_off" = "Uit"; +"merge_icons_title" = "Pictogrammen samenvoegen"; +"merge_icons_subtitle" = "Gebruik één menubalkpictogram met een providerwisselaar."; +"show_most_used_provider_title" = "Toon meest gebruikte provider"; +"show_most_used_provider_subtitle" = "De menubalk toont automatisch de aanbieder die het dichtst bij de tarieflimiet zit."; +"display_mode_title" = "Weergavemodus"; +"display_mode_subtitle" = "Kies wat u wilt weergeven in de menubalk (Tempo toont gebruik vs. verwacht)."; +"show_quota_warning_markers_title" = "Toon waarschuwingsmarkeringen voor quota"; +"show_quota_warning_markers_subtitle" = "Teken drempelmarkeringen op gebruiksbalken wanneer quotawaarschuwingen zijn geconfigureerd."; +"weekly_progress_work_days_title" = "Wekelijkse voortgang werkdagen"; +"weekly_progress_work_days_subtitle" = "Stel werkdagen in voor markeringen op wekelijkse gebruiksbalken en tempoberekeningen."; +"show_provider_changelog_links_title" = "Toon provider changelog-links"; +"show_provider_changelog_links_subtitle" = "Voegt koppelingen naar release-opmerkingen voor ondersteunde CLI-ondersteunde providers toe aan het menu."; +"show_credits_extra_usage_title" = "Toon credits + extra gebruik"; +"show_credits_extra_usage_subtitle" = "Toon Codex Credits en Claude Extra gebruikssecties in het menu."; +"multi_account_layout_title" = "Indeling voor meerdere accounts"; +"multi_account_layout_subtitle" = "Kies voor gesegmenteerd wisselen tussen accounts of gestapelde accountkaarten."; +"multi_account_layout_segmented" = "Gesegmenteerd"; +"multi_account_layout_stacked" = "Gestapeld"; +"overview_tab_providers_title" = "Overzicht tabblad aanbieders"; +"configure" = "Configureer…"; +"overview_enable_merge_icons_hint" = "Schakel Pictogrammen samenvoegen in om de providers van tabbladen Overzicht te configureren."; +"overview_no_providers_hint" = "Er zijn geen ingeschakelde providers beschikbaar voor Overzicht."; +"overview_rows_follow_order" = "Overzichtsrijen volgen altijd de volgorde van de provider."; +"overview_no_providers_selected" = "Geen aanbieders geselecteerd"; +"agent_sessions_title" = "Agentsessies"; +"agent_sessions_subtitle" = "Toon lokale en via SSH gevonden Codex- en Claude Code-sessies in het menu."; +"agent_sessions_hosts_title" = "Aanvullende SSH-hosts"; +"agent_sessions_footer" = "Macs op uw tailnet worden automatisch gevonden. Lokale sessies worden elke 30 seconden vernieuwd; externe hosts elke 60 seconden en wanneer het menu wordt geopend."; +"agent_session_labels_title" = "Sessielabels"; +"agent_session_labels_subtitle" = "Kies hoe agentsessies worden benoemd."; +"agent_session_label_project" = "Project"; +"agent_session_label_descriptive" = "Beschrijvend"; +"agent_session_label_descriptive_and_project" = "Beschrijvend + project"; +"agent_session_unknown_project" = "Onbekend project"; + +/* Advanced Pane */ +"section_keyboard_shortcut" = "Sneltoets"; +"open_menu_shortcut_title" = "Menu openen"; +"open_menu_shortcut_subtitle" = "Activeer het menubalkmenu vanaf elke locatie."; +"install_cli" = "Installeer CLI"; +"install_cli_subtitle" = "Symlink CodexBarCLI naar /usr/local/bin en /opt/homebrew/bin als codexbar."; +"cli_not_found" = "CodexBarCLI niet gevonden in appbundel."; +"no_writable_bin_dirs" = "Geen beschrijfbare mapmap gevonden."; +"show_debug_settings_title" = "Toon foutopsporingsinstellingen"; +"show_debug_settings_subtitle" = "Geef hulpprogramma's voor probleemoplossing weer op het tabblad Foutopsporing."; +"surprise_me_title" = "Verras mij"; +"surprise_me_subtitle" = "Controleer of je het leuk vindt dat je agenten daar plezier hebben."; +"hide_personal_info_title" = "Verberg persoonlijke informatie"; +"hide_personal_info_subtitle" = "Onduidelijke e-mailadressen in de menubalk en menu-UI."; +"show_provider_storage_usage_title" = "Toon het opslaggebruik van de provider"; +"show_provider_storage_usage_subtitle" = "Toon lokaal schijfgebruik in menu's. Scant bekende paden van de provider op de achtergrond."; +"section_keychain_access" = "Toegang via sleutelhanger"; +"keychain_access_caption" = "Schakel alle lees- en schrijfbewerkingen van de sleutelhanger uit. Gebruik dit als macOS blijft vragen om 'Chrome/Brave/Edge Safe Storage', zelfs nadat u op Altijd toestaan ​​hebt geklikt. Browsercookie-import is niet beschikbaar als deze is ingeschakeld; plak Cookie-headers handmatig in Providers. Claude/Codex OAuth via de CLI werkt nog steeds."; +"disable_keychain_access_title" = "Schakel sleutelhangertoegang uit"; +"disable_keychain_access_subtitle" = "Voorkomt elke sleutelhangertoegang indien ingeschakeld."; + +/* About Pane */ +"about_tagline" = "Moge uw tokens nooit opraken: houd de limieten van agenten in het oog."; +"link_github" = "GitHub"; +"link_website" = "Website"; +"link_twitter" = "Twitteren"; +"link_email" = "E-mail"; +"check_updates_auto" = "Automatisch controleren op updates"; +"update_channel" = "Kanaal bijwerken"; +"check_for_updates" = "Controleer op updates…"; +"updates_unavailable" = "Updates zijn niet beschikbaar in deze build."; +"copyright" = "© 2026 Peter Steinberger. MIT-licentie."; + +/* Debug Pane */ +"section_logging" = "Loggen"; +"enable_file_logging" = "Bestandsregistratie inschakelen"; +"enable_file_logging_subtitle" = "Schrijf logboeken naar %@ voor foutopsporing."; +"verbosity_title" = "Breedsprakigheid"; +"verbosity_subtitle" = "Bepaalt hoeveel details worden geregistreerd."; +"open_log_file" = "Logbestand openen"; +"force_animation_next_refresh" = "Animatie forceren bij volgende vernieuwing"; +"force_animation_next_refresh_subtitle" = "Toont tijdelijk de laadanimatie na de volgende vernieuwing."; +"section_loading_animations" = "Animaties laden"; +"loading_animations_caption" = "Kies een patroon en speel het opnieuw af in de menubalk. \"Random\" behoudt het bestaande gedrag."; +"animation_random_default" = "Willekeurig (standaard)"; +"replay_selected_animation" = "Speel de geselecteerde animatie opnieuw af"; +"blink_now" = "Knipper nu"; +"section_probe_logs" = "Sondelogboeken"; +"probe_logs_caption" = "Haal de nieuwste testuitvoer op voor foutopsporing; Bij kopiëren blijft de volledige tekst behouden."; +"fetch_log" = "Logboek ophalen"; +"copy" = "Kopiëren"; +"save_to_file" = "Opslaan in bestand"; +"load_parse_dump" = "Parseerdump laden"; +"rerun_provider_autodetect" = "Voer de automatische detectie van de provider opnieuw uit"; +"loading" = "Laden..."; +"no_log_yet_fetch" = "Nog geen logboek. Ophalen om te laden."; +"section_fetch_strategy" = "Strategiepogingen ophalen"; +"fetch_strategy_caption" = "Laatste ophaalpijplijnbeslissingen en fouten voor een provider."; +"section_openai_cookies" = "OpenAI-cookies"; +"openai_cookies_caption" = "Cookie-import + WebKit-scraping-logboeken van de laatste OpenAI-cookiepoging."; +"no_log_yet" = "Nog geen logboek. Update OpenAI-cookies in Providers → Codex om een ​​import uit te voeren."; +"section_caches" = "Caches"; +"caches_caption" = "Wis in het cachegeheugen opgeslagen kostenscanresultaten of caches van browsercookies."; +"clear_cookie_cache" = "Cookie-cache wissen"; +"clear_cost_cache" = "Wis de kostencache"; +"section_notifications" = "Meldingen"; +"notifications_caption" = "Activeer testmeldingen voor het sessievenster van 5 uur (opgebruikt/hersteld)."; +"post_depleted" = "Post uitgeput"; +"post_restored" = "Bericht hersteld"; +"section_cli_sessions" = "CLI-sessies"; +"cli_sessions_caption" = "Houd Codex/Claude CLI-sessies levend na een onderzoek. Standaard wordt afgesloten zodra gegevens zijn vastgelegd."; +"keep_cli_sessions_alive" = "Houd CLI-sessies levend"; +"keep_cli_sessions_alive_subtitle" = "Sla demontage tussen tests over (alleen voor foutopsporing)."; +"reset_cli_sessions" = "CLI-sessies opnieuw instellen"; +"section_error_simulation" = "Foutsimulatie"; +"error_simulation_caption" = "Injecteer een valse foutmelding in de menukaart voor het testen van de lay-out."; +"set_menu_error" = "Menufout instellen"; +"clear_menu_error" = "Menufout wissen"; +"set_cost_error" = "Fout bij instellen van kosten"; +"clear_cost_error" = "Duidelijke kostenfout"; +"section_cli_paths" = "CLI-paden"; +"cli_paths_caption" = "Opgelost Codex binaire en PATH-lagen; opstarten login PATH vastleggen (korte time-out)."; +"codex_binary" = "Codex binair"; +"claude_binary" = "Claude binair"; +"effective_path" = "Effectief PAD"; +"unavailable" = "Niet beschikbaar"; +"login_shell_path" = "Login shell PATH (opstartopname)"; +"cleared" = "Gewist."; +"no_fetch_attempts" = "Nog geen ophaalpogingen."; +"macOS Tahoe can block menu bar apps in System Settings → Menu Bar → Allow in the Menu Bar. CodexBar is running, but macOS may be hiding its icon. Open Menu Bar settings and turn CodexBar on." = "macOS Tahoe kan menubalk-apps blokkeren in Systeeminstellingen → Menubalk → Toestaan ​​in de menubalk. CodexBar is actief, maar macOS verbergt mogelijk het pictogram ervan. Open de Menubalkinstellingen en schakel CodexBar in."; + +/* Metric preferences */ +"metric_pref_automatic" = "Automatisch"; +"metric_pref_primary" = "Primair"; +"metric_pref_secondary" = "Secundair"; +"metric_pref_tertiary" = "Tertiair"; +"metric_pref_extra_usage" = "Extra gebruik"; +"metric_pref_average" = "Gemiddeld"; +"metric_mistral_payg" = "Pay-as-you-go"; +"metric_mistral_monthly_plan" = "Monthly Plan"; + +/* Display modes */ +"display_mode_percent" = "Procent"; +"display_mode_pace" = "Tempo"; +"display_mode_both" = "Beide"; +"display_mode_reset_time" = "Resettijd"; +"display_mode_percent_desc" = "Toon resterend/gebruikt percentage (bijvoorbeeld 45%)"; +"display_mode_pace_desc" = "Toon tempo-indicator (bijv. +5%)"; +"display_mode_both_desc" = "Toon zowel percentage als tempo (bijvoorbeeld 45% · +5%)"; +"display_mode_reset_time_desc" = "Toon de resettijd voor de geselecteerde metriek (bijvoorbeeld ↻ 15:56)"; +"menu_bar_reset_when_exhausted_title" = "Toon resettijd wanneer het quotum op is"; +"menu_bar_reset_when_exhausted_subtitle" = "Bij 0% resterend wordt de tijd tot de reset getoond in plaats van het percentage"; + +/* Provider status */ +"status_operational" = "Operationeel"; +"status_degraded" = "Verminderde prestaties"; +"status_partial_outage" = "Gedeeltelijke uitval"; +"status_major_outage" = "Grote storing"; +"status_critical_issue" = "Kritieke kwestie"; +"status_maintenance" = "Onderhoud"; +"status_unknown" = "Status onbekend"; + +/* Refresh frequency */ +"refresh_manual" = "Handmatig"; +"refresh_1min" = "1 min"; +"refresh_2min" = "2 minuten"; +"refresh_5min" = "5 min"; +"refresh_15min" = "15 minuten"; +"refresh_30min" = "30 min"; +"refresh_adaptive" = "Adaptief"; +"refresh_adaptive_agent_aware" = "Adaptief (agentactiviteit)"; +"adaptive_activity_consent_title" = "Activiteitsgestuurd vernieuwen toestaan?"; +"adaptive_activity_consent_message" = "De agentbewuste adaptieve modus kan de lijst met lokaal actieve processen, inclusief opdrachtregels, controleren om Codex en Claude te herkennen en vervolgens tijdens het programmeren elke 30 seconden bekende sessiemetadata lezen. Als Agent Sessions uitstaat, gebruikt CodexBar in het geheugen alleen het tijdstip van de meest recente activiteit en verwijdert het sessiepaden en identiteiten. Deze gegevens worden nergens naartoe gestuurd en detectie op afstand en SSH blijven uitgeschakeld. Als je weigert, keert CodexBar terug naar de gewone adaptieve modus zonder lokale activiteitsscans."; +"adaptive_activity_consent_allow" = "Lokale activiteit toestaan"; +"adaptive_activity_consent_decline" = "Gewoon Adaptief gebruiken"; + +/* Additional keys */ +"not_found" = "Niet gevonden"; + +/* Cost estimation */ +"cost_estimate_hint" = "Geschat op basis van lokale logboeken · kan afwijken van uw factuur"; +"codex_api_estimate_hint" = "Geschat op basis van tokengebruik · geen abonnementsfactuur"; +"cost_data_explanation" = "Kosten kunnen door de provider worden gerapporteerd of worden geschat op basis van tokengebruik tegen openbare API-prijzen. Schattingen zijn geen abonnementskosten."; +"No JetBrains IDE with AI Assistant detected. Install a JetBrains IDE and enable AI Assistant." = "Geen JetBrains IDE met AI Assistant gedetecteerd. Installeer een JetBrains IDE en schakel AI Assistant in."; +"OpenRouter API token not configured. Set OPENROUTER_API_KEY environment variable or configure in Settings." = "OpenRouter API-token niet geconfigureerd. Stel de omgevingsvariabele OPENROUTER_API_KEY in of configureer deze in Instellingen."; +"z.ai API token not found. Set apiKey in ~/.codexbar/config.json or Z_AI_API_KEY." = "z.ai API-token niet gevonden. Stel apiKey in ~/.codexbar/config.json of Z_AI_API_KEY."; +"Missing DeepSeek API key." = "Ontbrekende DeepSeek API-sleutel."; +"%@ is unavailable in the current environment." = "%@ is niet beschikbaar in de huidige omgeving."; +"All Systems Operational" = "Alle systemen operationeel"; +"Last 30 days" = "Laatste 30 dagen"; +"Last 30 days:" = "Afgelopen 30 dagen:"; +"This month" = "Deze maand"; +"Store multiple OpenAI API keys." = "Bewaar meerdere OpenAI API-sleutels."; +"Admin API key" = "Beheerder API-sleutel"; +"Open billing" = "Facturering openen"; +"Google accounts" = "Google-accounts"; +"Store multiple Antigravity Google OAuth accounts for quick switching." = "Bewaar meerdere Antigravity Google OAuth-accounts voor snel schakelen."; +"Add Google Account" = "Google-account toevoegen"; +"Open Token Plan" = "Tokenplan openen"; +"Text Generation" = "Tekst genereren"; +"Text to Speech" = "Tekst naar spraak"; +"Music Generation" = "Muziek generatie"; +"Image Generation" = "Beeldgeneratie"; +"No local data found" = "Geen lokale gegevens gevonden"; +"Credits unavailable; keep Codex running to refresh." = "Tegoeden niet beschikbaar; laat Codex draaien om te vernieuwen."; +"No available fetch strategy for minimax." = "Geen beschikbare ophaalstrategie voor minimax."; +"No Cursor session found. Please log in to cursor.com in Safari, Chrome, Microsoft Edge, Brave, Arc, Dia, ChatGPT Atlas, Chromium, Helium, Vivaldi, Yandex Browser, Firefox, Zen, Colibri, Sidekick, Opera, Opera GX, or Edge Canary. If you use Safari, grant CodexBar Full Disk Access in System Settings ▸ Privacy & Security. You can also sign in to Cursor from the CodexBar menu (Add / switch account)." = "Geen Cursorsessie gevonden. Meld u aan bij cursor.com in Safari, Chrome, Microsoft Edge, Brave, Arc, Dia, ChatGPT Atlas, Chromium, Helium, Vivaldi, Yandex Browser, Firefox, Zen, Colibri, Sidekick, Opera, Opera GX of Edge Canary. Als u Safari gebruikt, verleen CodexBar volledige schijftoegang in Systeeminstellingen ▸ Privacy en beveiliging. U kunt zich ook aanmelden bij Cursor via het CodexBar-menu (Account toevoegen/wisselen)."; +"No OpenCode session cookies found in browsers." = "Er zijn geen OpenCode-sessiecookies gevonden in browsers."; +"No available fetch strategy for %@." = "Geen beschikbare ophaalstrategie voor %@."; +"Today" = "Vandaag"; +"Today tokens" = "Vandaag tokens"; +"30d cost" = "30d kosten"; +"%@ cost" = "%@ kosten"; +"30d tokens" = "30d-tokens"; +"Latest tokens" = "Nieuwste tokens"; +"Top model" = "Topmodel"; +"Storage" = "Opslag"; +"Add Account..." = "Account toevoegen..."; +"Usage Dashboard" = "Gebruiksdashboard"; +"Status Page" = "Statuspagina"; +"Open Status Page" = "Statuspagina openen"; +"Settings..." = "Instellingen..."; +"About CodexBar" = "Over CodexBar"; +"Quit" = "Stoppen"; +"Last %d day" = "Afgelopen %d dag"; +"Last %d days" = "Afgelopen %d dagen"; +"%@ tokens" = "%@ tokens"; +"Latest billing day" = "Laatste factuurdag"; +"Latest billing day (%@)" = "Laatste factuurdag (%@)"; +"%@ left" = "%@ over"; +"Resets %@" = "Reset %@"; +"Resets in %@" = "Resetten over %@"; +"Resets now" = "Wordt nu gereset"; +"reset_tomorrow_format" = "morgen, %@"; +"Lasts until reset" = "Gaat mee tot reset"; +"1.5× headroom" = "1,5× speelruimte"; +"Updated %@" = "Bijgewerkt %@"; +"Updated relative %@" = "Bijgewerkt %@"; +"Updated absolute %@" = "Bijgewerkt %@"; +"Updated %@h ago" = "%@u geleden bijgewerkt"; +"Updated %@m ago" = "%@m geleden bijgewerkt"; +"Updated just now" = "Zojuist bijgewerkt"; +"Projected empty in %@" = "Geprojecteerd leeg in %@"; +"Runs out in %@" = "Loopt af over %@"; +"Pace: %@" = "Tempo: %@"; +"Pace: %@ · %@" = "Tempo: %@ · %@"; +"%@ · %@" = "%@ · %@"; +"≈ %d%% run-out risk" = "≈ %d%% uitlooprisico"; +"%d%% in deficit" = "%d%% tekort"; +"%d%% in reserve" = "%d%% in reserve"; +"usage_percent_suffix_left" = "over"; +"usage_percent_suffix_used" = "gebruikt"; +"Store multiple DeepSeek API keys." = "Bewaar meerdere DeepSeek API-sleutels."; +"This week" = "Deze week"; +"Week" = "Week"; +"Month" = "Maand"; +"Models" = "Modellen"; +"24h tokens" = "24-uurs tokens"; +"Latest hour" = "Laatste uur"; +"Peak hour" = "Piekuur"; +"Top method" = "Topmethode"; +"30d cash" = "30d contant"; +"30d billing history from MiniMax web session" = "30d factuurgeschiedenis van MiniMax-websessie"; +"AWS Cost Explorer billing can lag." = "De facturering van AWS Cost Explorer kan vertraging oplopen."; +"Rate limit: %d / %@" = "Tarieflimiet: %d / %@"; +"Key remaining" = "Sleutel resterend"; +"No limit set for the API key" = "Er is geen limiet ingesteld voor de API-sleutel"; +"API key limit unavailable right now" = "API-sleutellimiet momenteel niet beschikbaar"; +"This month: %@ tokens" = "Deze maand: %@ tokens"; +"No utilization data yet." = "Nog geen gebruiksgegevens."; +"No %@ utilization data yet." = "Nog geen %@ gebruiksgegevens."; +"%@: %@%% used" = "%@: %@%% gebruikt"; +"%dd" = "%dd"; +"today" = "Vandaag"; +"just now" = "zojuist"; +"On pace" = "Op tempo"; +"Runs out now" = "Is nu op"; +"Projected empty now" = "Nu leeg geprojecteerd"; +"Switch Account..." = "Account wisselen..."; +"Update ready, restart now?" = "Update klaar, nu opnieuw opstarten?"; +"Daily" = "Dagelijks"; +"Hourly Tokens" = "Tokens per uur"; +"No data" = "Geen gegevens"; +"No usage breakdown data available." = "Er zijn geen gebruiksgegevens beschikbaar."; + +"Today: %@ · %@ tokens" = "Vandaag: %@ · %@ tokens"; +"Today: %@" = "Vandaag: %@"; +"Today: %@ tokens" = "Vandaag: %@ tokens"; +"Last 30 days: %@ · %@ tokens" = "Afgelopen 30 dagen: %@ · %@ tokens"; +"Last 30 days: %@" = "Afgelopen 30 dagen: %@"; +"Est. total (30d): %@" = "Geschat. totaal (30d): %@"; +"Est. total (%@): %@" = "Geschat. totaal (%@): %@"; +"Hover a bar for details" = "Beweeg een balk voor details"; +"%@: %@ · %@ tokens" = "%@: %@ · %@ tokens"; +"No providers selected for Overview." = "Geen aanbieders geselecteerd voor Overzicht."; +"No overview data available." = "Geen overzichtsgegevens beschikbaar."; +"Auto uses the local IDE API first, then Google OAuth when the IDE is closed." = "Auto gebruikt eerst de lokale IDE API en vervolgens Google OAuth wanneer de IDE wordt gesloten."; +"Login with Google" = "Inloggen met Google"; + +/* Popup panels */ +"No usage configured." = "Geen gebruik geconfigureerd."; +"Quota" = "Quotum"; +"Daily quota" = "Dagquotum"; +"Total" = "Totaal"; +"tokens" = "tokens"; +"requests" = "verzoeken"; +"Latest" = "Nieuwste"; +"Monthly" = "Maandelijks"; +"Sonnet" = "Sonnet"; +"Overages" = "Overschotten"; +"Activity" = "Activiteit"; +"Copied" = "Gekopieerd"; +"Copy error" = "Kopieerfout"; +"Copy path" = "Kopieer pad"; +"Extra usage spent" = "Extra gebruik besteed"; +"Credits remaining" = "Resterende tegoeden"; +"Using CLI fallback" = "CLI-fallback gebruiken"; +"Balance updates in near-real time (up to 5 min lag)" = "Saldo-updates in bijna realtime (tot 5 minuten vertraging)"; +"Daily billing data finalizes at 07:00 UTC" = "De dagelijkse factureringsgegevens worden afgerond om 07:00 UTC"; +"%@ of %@ credits left" = "%@ van %@ credits over"; +"%@ of %@ bonus credits left" = "Er zijn nog %@ van %@ bonuscredits over"; +"%@ / %@ (%@ remaining)" = "%@ / %@ (%@ resterend)"; +"%@/%@ left" = "%@/%@ over"; +"Gemini Flash" = "Tweeling flits"; +"Regenerates %@" = "Regenereert %@"; +"used after next regen" = "gebruikt na de volgende regen"; +"after next regen" = "na de volgende regen"; +"Near full" = "Bijna vol"; +"Full in ~1 regen" = "Volledig in ~1 regeneratie"; +"Full in ~%.0f regens" = "Volledig in ~%.0f regens"; +"Overage usage" = "Overmatig gebruik"; +"Overage cost" = "Overschrijdingskosten"; +"credits" = "tegoeden"; +"Zen balance" = "Zen-balans"; +"API spend" = "API-uitgaven"; +"Extra usage" = "Extra gebruik"; +"Quota usage" = "Quotumgebruik"; +"Your spend" = "Jouw uitgaven"; +"%.0f%% used" = "%.0f%% gebruikt"; +"Usage history (today)" = "Gebruiksgeschiedenis (vandaag)"; +"Usage history (%d days)" = "Gebruiksgeschiedenis (%d dagen)"; +"%d percent remaining" = "%d procent resterend"; +"Unknown" = "Onbekend"; +"stale data" = "verouderde gegevens"; +"No credits history data." = "Geen kredietgeschiedenisgegevens."; +"No credits history data available." = "Er zijn geen kredietgeschiedenisgegevens beschikbaar."; +"Credits history chart" = "Creditgeschiedenisgrafiek"; +"%d days of credits data" = "%d dagen aan kredietgegevens"; +"Usage breakdown chart" = "Uitsplitsingsschema voor gebruik"; +"%d days of usage data across %d services" = "%d dagen aan gebruiksgegevens voor %d services"; +"Cost history chart" = "Kostengeschiedenisgrafiek"; +"%d days of cost data" = "%d dagen aan kostengegevens"; +"Plan utilization chart" = "Plan gebruiksgrafiek"; +"%d utilization samples" = "%d gebruiksvoorbeelden"; +"Hourly Usage" = "Uurgebruik"; +"Usage remaining" = "Resterend gebruik"; +"Usage used" = "Gebruik gebruikt"; +"API key verified. Cloud quotas need browser cookies. Sign in to Ollama." = "API-sleutel geverifieerd. Cloud-quota vereisen browsercookies. Meld je aan bij Ollama."; +"Last 30 days: %@ tokens" = "Afgelopen 30 dagen: %@ tokens"; +"7d spend" = "7d uitgaven"; +"30d spend" = "30d uitgaven"; +"Cache read" = "Cache lezen"; +"Claude Admin API 30 day spend trend" = "Claude Admin API bestedingstrend van 30 dagen"; +"OpenRouter API key spend trend" = "Trend van uitgaven voor OpenRouter API-sleutels"; +"z.ai hourly token trend" = "z.ai tokentrend per uur"; +"MiniMax 30 day token usage trend" = "MiniMax 30 dagen tokengebruikstrend"; +"Today cash" = "Vandaag contant"; +"DeepSeek 30 day token usage trend" = "DeepSeek 30 dagen tokengebruikstrend"; +"cache-hit input" = "cache-hit-invoer"; +"cache-miss input" = "cache-miss invoer"; +"output" = "uitgang"; +"Requests" = "Verzoeken"; +"Reported by OpenAI Admin API organization usage." = "Gerapporteerd door het gebruik van de OpenAI Admin API-organisatie."; +"Reported by Mistral billing usage." = "Gerapporteerd door Mistral-factureringsgebruik."; +"Google OAuth" = "Google OAuth"; +"Add accounts via GitHub OAuth Device Flow on the selected host." = "Voeg accounts toe via GitHub OAuth Device Flow op de geselecteerde host."; +"Stores each signed-in Google account for quick Antigravity switching. Uses Antigravity.app OAuth when available, or ANTIGRAVITY_OAUTH_CLIENT_ID and ANTIGRAVITY_OAUTH_CLIENT_SECRET as an override." = "Slaat elk ingelogd Google-account op voor snel schakelen tussen anti-zwaartekracht. Gebruikt Antigravity.app OAuth indien beschikbaar, of ANTIGRAVITY_OAUTH_CLIENT_ID en ANTIGRAVITY_OAUTH_CLIENT_SECRET als overschrijving."; +"Manual cleanup: past sessions" = "Handmatig opschonen: afgelopen sessies"; +"Clearing removes past resume, continue, and rewind history." = "Door te wissen wordt de geschiedenis van het hervatten, doorgaan en terugspoelen uit het verleden verwijderd."; +"Manual cleanup: file checkpoints" = "Handmatig opschonen: bestandscontrolepunten"; +"Clearing removes checkpoint restore data for previous edits." = "Door het wissen worden de controlepuntherstelgegevens van eerdere bewerkingen verwijderd."; +"Manual cleanup: saved plans" = "Handmatig opschonen: opgeslagen plannen"; +"Clearing removes old plan-mode files." = "Door te wissen worden oude bestanden in de planmodus verwijderd."; +"Manual cleanup: debug logs" = "Handmatig opschonen: foutopsporingslogboeken"; +"Clearing removes past debug logs." = "Door te wissen worden eerdere foutopsporingslogboeken verwijderd."; +"Manual cleanup: attachment cache" = "Handmatig opschonen: bijlagecache"; +"Clearing removes cached large pastes or attached images." = "Door te wissen worden in de cache opgeslagen grote pasta's of bijgevoegde afbeeldingen verwijderd."; +"Manual cleanup: session metadata" = "Handmatig opschonen: sessiemetagegevens"; +"Clearing removes per-session environment metadata." = "Door te wissen worden de metagegevens van de omgeving per sessie verwijderd."; +"Manual cleanup: shell snapshots" = "Handmatig opschonen: shell-snapshots"; +"Clearing removes leftover runtime shell snapshot files." = "Door het wissen worden de overgebleven runtime shell-snapshotbestanden verwijderd."; +"Manual cleanup: legacy todos" = "Handmatig opschonen: oude taken"; +"Clearing removes legacy per-session task lists." = "Door het wissen worden verouderde takenlijsten per sessie verwijderd."; +"Manual cleanup: sessions" = "Handmatig opschonen: sessies"; +"Clearing removes past Codex session history." = "Door te wissen wordt de geschiedenis van de Codex-sessie verwijderd."; +"Manual cleanup: archived sessions" = "Handmatig opschonen: gearchiveerde sessies"; +"Clearing removes archived Codex session history." = "Door te wissen wordt de gearchiveerde Codex-sessiegeschiedenis verwijderd."; +"Manual cleanup: cache" = "Handmatig opschonen: cache"; +"Clearing removes provider-owned cached data." = "Door te wissen worden gegevens in de cache van de provider verwijderd."; +"Manual cleanup: logs" = "Handmatig opschonen: logboeken"; +"Clearing removes local diagnostic logs." = "Door te wissen worden lokale diagnostische logboeken verwijderd."; +"Manual cleanup: file history" = "Handmatig opschonen: bestandsgeschiedenis"; +"Clearing removes local edit checkpoint history." = "Door te wissen wordt de geschiedenis van de lokale bewerkingscontrolepunten verwijderd."; +"Manual cleanup: temporary data" = "Handmatig opschonen: tijdelijke gegevens"; +"Clearing removes local temporary provider data." = "Door te wissen worden lokale tijdelijke providergegevens verwijderd."; +"Total: %@" = "Totaal: %@"; +"%d more items" = "%d meer artikelen"; +"Cleanup ideas" = "Opruimideeën"; +"%d unreadable item(s) skipped" = "%d onleesbare item(s) overgeslagen"; + +"API key limit" = "API-sleutellimiet"; +"Auth" = "Aut"; +"Auto" = "Auto"; +"Disabled — no recent data" = "Uitgeschakeld — geen recente gegevens"; +"Limits not available" = "Limieten niet beschikbaar"; +"No usage yet" = "Nog geen gebruik"; +"Not fetched yet" = "Nog niet opgehaald"; +"Refreshing" = "Verfrissend"; +"Session" = "Sessie"; +"Source" = "Bron"; +"State" = "Staat"; +"Unavailable" = "Niet beschikbaar"; +"Weekly" = "Wekelijks"; +"not detected" = "niet gedetecteerd"; +"Estimated from local Codex logs for the selected account." = "Geschat op basis van lokale Codex-logboeken voor het geselecteerde account."; +"minimax_usage_amount_format" = "Gebruik: %@ / %@"; +"minimax_used_percent_format" = "Gebruikt %@"; +"minimax_service_text_generation" = "Tekst genereren"; +"minimax_service_text_to_speech" = "Tekst naar spraak"; +"minimax_service_music_generation" = "Muziek generatie"; +"minimax_service_image_generation" = "Beeldgeneratie"; +"minimax_service_lyrics_generation" = "Songtekst generatie"; +"minimax_service_coding_plan_vlm" = "Codeerplan VLM"; +"minimax_service_coding_plan_search" = "Coderingsplan zoeken"; + +/* Additional provider settings and alerts */ +"%@ is waiting for permission" = "%@ wacht op toestemming"; +"%@ requests" = "%@ verzoeken"; +"%@: %@ credits" = "%@: %@ tegoeden"; +"30d requests" = "30d verzoeken"; +"4 days" = "4 dagen"; +"5 days" = "5 dagen"; +"7 days" = "7 dagen"; +"API key verifies Ollama Cloud access; cookies still expose quota limits." = "API-sleutel verifieert Ollama Cloud-toegang; cookies stellen nog steeds quotumlimieten bloot."; +"AWS access key ID. Can also be set with AWS_ACCESS_KEY_ID." = "AWS-toegangssleutel-ID. Kan ook worden ingesteld met AWS_ACCESS_KEY_ID."; +"AWS region. Can also be set with AWS_REGION." = "AWS-regio. Kan ook worden ingesteld met AWS_REGION."; +"AWS secret access key. Can also be set with AWS_SECRET_ACCESS_KEY." = "Geheime toegangssleutel van AWS. Kan ook worden ingesteld met AWS_SECRET_ACCESS_KEY."; +"Access key ID" = "Toegangssleutel-ID"; +"Add Account" = "Account toevoegen"; +"Adding Account…" = "Account toevoegen…"; +"Antigravity login failed" = "Antigravity-aanmelding mislukt"; +"Antigravity login timed out" = "Er is een time-out opgetreden bij het inloggen op anti-zwaartekracht"; +"Auth source" = "Authenticatiebron"; +"Automatic imports browser cookies from Xiaomi MiMo." = "Importeert automatisch browsercookies van Xiaomi MiMo."; +"Automatic imports Windsurf session data from Chromium browser localStorage." = "Automatische import van windsurfsessiegegevens uit de Chromium-browser localStorage."; +"Automatic imports browser cookies from Bailian." = "Importeert automatisch browsercookies van Bailian."; +"Automatically imports browser cookies." = "Importeert automatisch browsercookies."; +"Automatically imports browser session cookies." = "Importeert automatisch browsersessiecookies."; +"Azure OpenAI deployment name. AZURE_OPENAI_DEPLOYMENT_NAME is also supported." = "Azure OpenAI-implementatienaam. AZURE_OPENAI_DEPLOYMENT_NAME wordt ook ondersteund."; +"Azure OpenAI key" = "Azure OpenAI-sleutel"; +"Azure OpenAI resource endpoint. AZURE_OPENAI_ENDPOINT is also supported." = "Azure OpenAI-resource-eindpunt. AZURE_OPENAI_ENDPOINT wordt ook ondersteund."; +"Base URL" = "Basis-URL"; +"Base URL for the LLM-API-Key-Proxy instance." = "Basis-URL voor de LLM-API-Key-Proxy-instantie."; +"Browser cookies" = "Browser-cookies"; +"Cap end" = "Dop uiteinde"; +"Cap start" = "Kap begin"; +"Capacity End" = "Einde capaciteit"; +"Capacity Start" = "Capaciteit begin"; +"Changelog" = "Wijzigingslog"; +"Choose the Moonshot/Kimi API host for international or China mainland accounts." = "Kies de Moonshot/Kimi API-host voor internationale accounts of accounts op het vasteland van China."; +"CodexBar can't replace a system account that is signed in with an API key only setup." = "CodexBar kan een systeemaccount dat is aangemeld met alleen een API-sleutelconfiguratie niet vervangen."; +"CodexBar could not find saved auth for that account. Re-authenticate it and try again." = "CodexBar kon de opgeslagen verificatie voor dat account niet vinden. Authenticeer het opnieuw en probeer het opnieuw."; +"CodexBar could not read managed account storage. Recover the store before adding another account." = "CodexBar kan de beheerde accountopslag niet lezen. Herstel de winkel voordat u een ander account toevoegt."; +"CodexBar could not read saved auth for that account. Re-authenticate it and try again." = "CodexBar kan de opgeslagen verificatie voor dat account niet lezen. Authenticeer het opnieuw en probeer het opnieuw."; +"CodexBar could not read the current system account on this Mac." = "CodexBar kon het huidige systeemaccount op deze Mac niet lezen."; +"CodexBar could not replace the live Codex auth on this Mac." = "CodexBar kon de live Codex-authenticatie op deze Mac niet vervangen."; +"CodexBar could not safely preserve the current system account before switching." = "CodexBar kon het huidige systeemaccount niet veilig behouden voordat hij overschakelde."; +"CodexBar could not save the current system account before switching." = "CodexBar kon het huidige systeemaccount niet opslaan voordat er werd overgeschakeld."; +"CodexBar could not update managed account storage." = "CodexBar kan de beheerde accountopslag niet updaten."; +"CodexBar found another managed account that already uses the current system account. Resolve the duplicate account before switching." = "CodexBar heeft een ander beheerd account gevonden dat al gebruikmaakt van het huidige systeemaccount. Los het dubbele account op voordat u overstapt."; +"CodexBar will ask macOS Keychain for “%@” so it can decrypt browser cookies and authenticate your account. Click OK to continue." = "CodexBar vraagt ​​macOS-sleutelhanger om “%@”, zodat browsercookies kunnen worden gedecodeerd en uw account kan worden geverifieerd. Klik op OK om door te gaan."; +"CodexBar will ask macOS Keychain for the Claude Code OAuth token so it can fetch your Claude usage. Click OK to continue." = "CodexBar zal macOS Keychain om het Claude Code OAuth-token vragen, zodat het uw Claude-gebruik kan ophalen. Klik op OK om door te gaan."; +"CodexBar will ask macOS Keychain for your Amp cookie header so it can fetch usage. Click OK to continue." = "CodexBar zal macOS Keychain om je Amp-cookieheader vragen, zodat deze het gebruik kan ophalen. Klik op OK om door te gaan."; +"CodexBar will ask macOS Keychain for your Augment cookie header so it can fetch usage. Click OK to continue." = "CodexBar zal macOS Keychain om uw Augment-cookie-header vragen, zodat deze het gebruik kan ophalen. Klik op OK om door te gaan."; +"CodexBar will ask macOS Keychain for your Claude cookie header so it can fetch Claude web usage. Click OK to continue." = "CodexBar zal macOS Keychain om uw Claude-cookieheader vragen, zodat deze het webgebruik van Claude kan ophalen. Klik op OK om door te gaan."; +"CodexBar will ask macOS Keychain for your Cursor cookie header so it can fetch usage. Click OK to continue." = "CodexBar zal macOS Keychain om uw Cursor-cookieheader vragen, zodat deze het gebruik kan ophalen. Klik op OK om door te gaan."; +"CodexBar will ask macOS Keychain for your Factory cookie header so it can fetch usage. Click OK to continue." = "CodexBar zal macOS Keychain om uw Factory-cookieheader vragen, zodat deze het gebruik kan ophalen. Klik op OK om door te gaan."; +"CodexBar will ask macOS Keychain for your GitHub Copilot token so it can fetch usage. Click OK to continue." = "CodexBar zal macOS Keychain om uw GitHub Copilot-token vragen, zodat deze het gebruik kan ophalen. Klik op OK om door te gaan."; +"CodexBar will ask macOS Keychain for your Kimi auth token so it can fetch usage. Click OK to continue." = "CodexBar vraagt ​​macOS Keychain om je Kimi-authenticatietoken, zodat het gebruik kan worden opgehaald. Klik op OK om door te gaan."; +"CodexBar will ask macOS Keychain for your MiniMax API token so it can fetch usage. Click OK to continue." = "CodexBar zal macOS Keychain om uw MiniMax API-token vragen, zodat deze het gebruik kan ophalen. Klik op OK om door te gaan."; +"CodexBar will ask macOS Keychain for your MiniMax cookie header so it can fetch usage. Click OK to continue." = "CodexBar zal macOS Keychain om uw MiniMax-cookieheader vragen, zodat deze het gebruik kan ophalen. Klik op OK om door te gaan."; +"CodexBar will ask macOS Keychain for your OpenAI cookie header so it can fetch Codex dashboard extras. Click OK to continue." = "CodexBar zal macOS Keychain om uw OpenAI-cookieheader vragen, zodat deze extra's op het Codex-dashboard kan ophalen. Klik op OK om door te gaan."; +"CodexBar will ask macOS Keychain for your OpenCode cookie header so it can fetch usage. Click OK to continue." = "CodexBar zal macOS Keychain om uw OpenCode-cookieheader vragen, zodat deze het gebruik kan ophalen. Klik op OK om door te gaan."; +"CodexBar will ask macOS Keychain for your Synthetic API key so it can fetch usage. Click OK to continue." = "CodexBar vraagt ​​macOS-sleutelhanger om uw synthetische API-sleutel, zodat deze het gebruik kan ophalen. Klik op OK om door te gaan."; +"CodexBar will ask macOS Keychain for your z.ai API token so it can fetch usage. Click OK to continue." = "CodexBar zal macOS Keychain om uw z.ai API-token vragen, zodat deze het gebruik kan ophalen. Klik op OK om door te gaan."; +"Could not open Cursor login in your browser." = "Kan Cursor-login niet openen in uw browser."; +"Could not open browser for Antigravity" = "Kan browser voor Antigravity niet openen"; +"Credits used" = "Gebruikte tegoeden"; +"Day" = "Dag"; +"Deployment" = "Inzet"; +"Drag to reorder" = "Sleep om de volgorde te wijzigen"; +"Sort providers alphabetically" = "Providers alfabetisch sorteren"; +"Sort providers alphabetically (enabled first)" = "Providers alfabetisch sorteren (ingeschakelde eerst)"; +"Sorted alphabetically (enabled first) — click to use your custom order" = "Alfabetisch gesorteerd (ingeschakelde eerst) — klik om je aangepaste volgorde te gebruiken"; +"Endpoint" = "Eindpunt"; +"Enterprise host" = "Enterprise-host"; +"Extra usage balance: %@" = "Extra gebruikssaldo: %@"; +"Keychain Access Required" = "Toegang tot sleutelhanger vereist"; +"keychain_prompt_learn_more" = "Meer informatie…"; +"keychain_prompt_privacy_note" = "De invoer van het Mac-inlogwachtwoord wordt verwerkt door macOS, niet door CodexBar. Je kunt sleutelhanger toegang op elk moment uitschakelen via Instellingen → Geavanceerd."; +"Kiro menu bar value" = "Waarde van de Kiro-menubalk"; +"Label" = "Label"; +"No organizations loaded. Click Refresh after setting your API key." = "Er zijn geen organisaties geladen. Klik op Vernieuwen nadat u uw API-sleutel hebt ingesteld."; +"No output captured." = "Geen uitvoer vastgelegd."; +"No system account" = "Geen systeemaccount"; +"Oasis-Token" = "Oasis-token"; +"Open Augment (Log Out & Back In)" = "Augment openen (uitloggen en weer inloggen)"; +"Open Codebuff Dashboard" = "Open het Codebuff-dashboard"; +"Open Command Code Settings" = "Open de opdrachtcode-instellingen"; +"Open Crof dashboard" = "Open het Crof-dashboard"; +"Open Manus" = "Manus openen"; +"Open MiMo Balance" = "Open MiMo-saldo"; +"Open Moonshot Console" = "Open de Moonshot-console"; +"Open Ollama API Keys" = "Open Ollama API-sleutels"; +"Open StepFun Platform" = "Open het StepFun-platform"; +"Open T3 Chat Settings" = "Open T3 Chat-instellingen"; +"Open Volcengine Ark Console" = "Open de Volcengine Ark-console"; +"Open legacy provider docs" = "Open oude providerdocumenten"; +"Open projects" = "Openstaande projecten"; +"Open this URL manually to continue login:\n\n%@" = "Open deze URL handmatig om door te gaan met inloggen:\n\n%@"; +"Optional organization ID for accounts linked to multiple Anthropic organizations." = "Optionele organisatie-ID voor accounts die zijn gekoppeld aan meerdere Anthropic-organisaties."; +"Optional. Applies to the configured Admin API key; selected token accounts do not inherit OPENAI_PROJECT_ID." = "Optioneel. Is van toepassing op de geconfigureerde Admin API-sleutel; geselecteerde tokenaccounts nemen OPENAI_PROJECT_ID niet over."; +"Optional. Enter your GitHub Enterprise host, for example octocorp.ghe.com. Leave blank for github.com." = "Optioneel. Voer uw GitHub Enterprise-host in, bijvoorbeeld octocorp.ghe.com. Laat leeg voor github.com."; +"Optional. Leave blank to discover and aggregate projects visible to the API key." = "Optioneel. Laat dit veld leeg om projecten te ontdekken en samen te voegen die zichtbaar zijn voor de API-sleutel."; +"Org ID (optional)" = "Organisatie-ID (optioneel)"; +"Organizations" = "Organisaties"; +"Organization ID" = "Organisatie-ID"; +"Password" = "Wachtwoord"; +"%@ authentication is disabled." = "%@-authenticatie is uitgeschakeld."; +"%@ cookies are disabled." = "%@ cookies zijn uitgeschakeld."; +"%@ web API access is disabled." = "%@ web-API-toegang is uitgeschakeld."; +"Disable %@ dashboard cookie usage." = "Schakel het gebruik van %@ dashboardcookies uit."; +"Keychain access is disabled in Advanced, so browser cookie import is unavailable." = "De sleutelhangertoegang is uitgeschakeld in Geavanceerd, dus het importeren van browsercookies is niet beschikbaar."; +"Manually paste an %@ from a browser session." = "Plak handmatig een %@ uit een browsersessie."; +"Paste a Cookie header captured from %@." = "Plak een Cookie-header vastgelegd van %@."; +"Paste a Cookie header from %@." = "Plak een Cookie-header van %@."; +"Paste a Cookie header or cURL capture from %@." = "Plak een cookie-header of cURL-opname uit %@."; +"Paste a Cookie header or full cURL capture from %@." = "Plak een cookiekoptekst of volledige krulopname uit %@."; +"Paste a Cookie or Authorization header from %@." = "Plak een cookie- of autorisatiekop van %@."; +"Paste a full cookie header or the %@ value." = "Plak een volledige cookiekop of de waarde %@."; +"Paste a Cookie header or full cURL capture from T3 Chat settings." = "Plak een Cookie-header of volledige cURL-opname uit de T3 Chat-instellingen."; +"Paste the Cookie header from a request to admin.mistral.ai. Must contain an ory_session_* cookie." = "Plak de Cookie-header uit een verzoek naar admin.mistral.ai. Moet een ory_session_* cookie bevatten."; +"Paste the Oasis-Token from a logged-in browser session on platform.stepfun.com." = "Plak de Oasis-Token uit een ingelogde browsersessie op platform.stepfun.com."; +"Paste the %@ JSON bundle from %@." = "Plak de %@ JSON-bundel uit %@."; +"Paste the %@ value or a full Cookie header." = "Plak de waarde %@ of een volledige Cookie-header."; +"Personal account" = "Persoonlijk account"; +"Project ID" = "Project-ID"; +"Re-auth" = "Opnieuw verifiëren"; +"Re-login at claude.ai" = "Opnieuw aanmelden bij claude.ai"; +"Re-authenticating…" = "Opnieuw authenticeren…"; +"Refresh Session" = "Sessie vernieuwen"; +"Refresh organizations" = "Vernieuw organisaties"; +"Region" = "Regio"; +"Reload" = "Herladen"; +"Reorder" = "Opnieuw ordenen"; +"Secret access key" = "Geheime toegangssleutel"; +"Series" = "Serie"; +"Service" = "Dienst"; +"Show or hide Kiro credits, percent, or both next to the menu bar icon." = "Toon of verberg Kiro-credits, percentages of beide naast het menubalkpictogram."; +"Show usage for organizations you belong to. Personal account is always shown." = "Toon gebruik voor organisaties waartoe u behoort. Persoonlijk account wordt altijd getoond."; +"Sign in to cursor.com in your browser, then refresh Cursor in CodexBar." = "Meld u aan bij cursor.com in uw browser en vernieuw vervolgens Cursor in CodexBar."; +"Simulated error text" = "Gesimuleerde fouttekst"; +"StepFun platform account (phone number or email)." = "StepFun-platformaccount (telefoonnummer of e-mailadres)."; +"Stored in ~/.codexbar/config.json." = "Opgeslagen in ~/.codexbar/config.json."; +"Stored in ~/.codexbar/config.json. AZURE_OPENAI_API_KEY is also supported." = "Opgeslagen in ~/.codexbar/config.json. AZURE_OPENAI_API_KEY wordt ook ondersteund."; +"Stored in ~/.codexbar/config.json. For the official Kimi API, use Moonshot / Kimi API." = "Opgeslagen in ~/.codexbar/config.json. Gebruik Moonshot / Kimi API voor de officiële Kimi API."; +"Stored in ~/.codexbar/config.json. Get your API key from the Volcengine Ark console." = "Opgeslagen in ~/.codexbar/config.json. Haal uw API-sleutel op via de Volcengine Ark-console."; +"Stored in ~/.codexbar/config.json. Get your key from Ollama settings." = "Opgeslagen in ~/.codexbar/config.json. Haal uw sleutel op via Ollama-instellingen."; +"Stored in ~/.codexbar/config.json. Get your key from console.deepgram.com." = "Opgeslagen in ~/.codexbar/config.json. Haal uw sleutel op via console.deepgram.com."; +"Stored in ~/.codexbar/config.json. Get your key from elevenlabs.io/app/settings/api-keys." = "Opgeslagen in ~/.codexbar/config.json. Haal uw sleutel op via elevenlabs.io/app/settings/api-keys."; +"Stored in ~/.codexbar/config.json. Get your key from openrouter.ai/settings/keys and set a key spending limit there to enable API key quota tracking." = "Opgeslagen in ~/.codexbar/config.json. Haal uw sleutel op via openrouter.ai/settings/keys en stel daar een sleutelbestedingslimiet in om het bijhouden van API-sleutelquota in te schakelen."; +"Stored in ~/.codexbar/config.json. In Warp, open Settings > Platform > API Keys, then create one." = "Opgeslagen in ~/.codexbar/config.json. Open in Warp Instellingen > Platform > API-sleutels en maak er vervolgens een."; +"Stored in ~/.codexbar/config.json. Metrics require Groq Enterprise Prometheus access." = "Opgeslagen in ~/.codexbar/config.json. Voor statistieken is toegang tot Groq Enterprise Prometheus vereist."; +"Stored in ~/.codexbar/config.json. OPENAI_ADMIN_KEY is preferred; OPENAI_API_KEY still works." = "Opgeslagen in ~/.codexbar/config.json. OPENAI_ADMIN_KEY heeft de voorkeur; OPENAI_API_KEY werkt nog steeds."; +"Stored in ~/.codexbar/config.json. Requires an Anthropic Admin API key." = "Opgeslagen in ~/.codexbar/config.json. Vereist een Anthropic Admin API-sleutel."; +"Stored in ~/.codexbar/config.json. Used for /v1/quota-stats." = "Opgeslagen in ~/.codexbar/config.json. Gebruikt voor /v1/quota-stats."; +"Stored in ~/.codexbar/config.json. You can also provide CODEBUFF_API_KEY or let CodexBar read ~/.config/manicode/credentials.json (created by `codebuff login`)." = "Opgeslagen in ~/.codexbar/config.json. Je kunt ook CODEBUFF_API_KEY opgeven of CodexBar ~/.config/manicode/credentials.json laten lezen (gemaakt door `codebuff login`)."; +"Stored in ~/.codexbar/config.json. You can also provide CROF_API_KEY." = "Opgeslagen in ~/.codexbar/config.json. U kunt ook CROF_API_KEY opgeven."; +"Stored in ~/.codexbar/config.json. You can also provide KILO_API_KEY or ~/.local/share/kilo/auth.json (kilo.access)." = "Opgeslagen in ~/.codexbar/config.json. U kunt ook KILO_API_KEY of ~/.local/share/kilo/auth.json (kilo.access) opgeven."; +"T3 Chat cookie" = "T3 Chat-cookie"; +"Team mode" = "Teammodus"; +"That account is no longer available in CodexBar. Refresh the account list and try again." = "Dat account is niet langer beschikbaar in CodexBar. Vernieuw de accountlijst en probeer het opnieuw."; +"The browser login did not complete in time. Try Antigravity login again." = "De browseraanmelding is niet op tijd voltooid. Probeer Antigravity-login opnieuw."; +"Timed out waiting for Cursor login. %@" = "Er is een time-out opgetreden tijdens het wachten op cursoraanmelding. %@"; +"Timed out waiting for Cursor login. %@ Last error: %@" = "Er is een time-out opgetreden tijdens het wachten op cursoraanmelding. %@ Laatste fout: %@"; +"Today requests" = "Vandaag verzoeken"; +"Total (30d): %@ credits" = "Totaal (30d): %@ credits"; +"Username" = "Gebruikersnaam"; +"Uses username + password to login and obtain an Oasis-Token automatically." = "Gebruikt gebruikersnaam + wachtwoord om in te loggen en automatisch een Oasis-Token te verkrijgen."; +"Uses username + password to login and obtain an %@ automatically." = "Gebruikt gebruikersnaam + wachtwoord om in te loggen en automatisch een %@ te verkrijgen."; +"Utilization End" = "Gebruik einde"; +"Utilization Start" = "Gebruik starten"; +"Verbosity" = "Breedsprakigheid"; +"Windsurf session JSON bundle" = "Windsurfsessie JSON-bundel"; +"Workspace ID" = "Werkruimte-ID"; +"Your StepFun platform password. Used to login and obtain a session token." = "Uw StepFun-platformwachtwoord. Wordt gebruikt om in te loggen en een sessietoken te verkrijgen."; +"claude /login exited with status %d." = "claude /login afgesloten met status %d."; +"codex login exited with status %d." = "codex login afgesloten met status %d."; +"Cookie: …\n\nor paste a cURL capture from the Abacus AI dashboard" = "Cookie: …\n\nof plak een cURL-opname vanuit het Abacus AI-dashboard"; +"Cookie: …\n\nor paste the __Secure-next-auth.session-token value" = "Cookie: …\n\nof plak de __Secure-next-auth.session-token-waarde"; +"Cookie: …\n\nor paste the kimi-auth token value" = "Cookie: …\n\nof plak de kimi-auth-tokenwaarde"; +"session_id=...\n\nor paste just the session_id value" = "session_id=...\n\nof plak alleen de session_id-waarde"; +"Clear" = "Duidelijk"; +"No matching providers" = "Geen overeenkomende aanbieders"; +"Search providers" = "Zoekaanbieders"; +"language_arabic" = "العربية"; +"language_persian" = "فارسی"; +"language_thai" = "ไทย"; +"language_galician" = "Galego"; +"Limit Reset Credits" = "Limietresetcredits"; +"1 available" = "1 beschikbaar"; +"%d available" = "%d beschikbaar"; +"Next expires %@" = "Volgende verloopt %@"; +"Expires %@" = "Verloopt %@"; +"No expiry" = "Geen vervaldatum"; +"Other (%d items)" = "Overig (%d onderdelen)"; +"Expand" = "Uitvouwen"; +"Collapse" = "Invouwen"; +"byte_unit_byte" = "byte"; +"byte_unit_bytes" = "bytes"; +"byte_unit_kilobyte" = "kilobyte"; +"byte_unit_kilobytes" = "kilobytes"; +"byte_unit_megabyte" = "megabyte"; +"byte_unit_megabytes" = "megabytes"; +"byte_unit_gigabyte" = "gigabyte"; +"byte_unit_gigabytes" = "gigabytes"; + +/* Settings sidebar redesign */ +"Enable" = "Inschakelen"; +"Disable" = "Uitschakelen"; +"providers_on_count" = "%d aan"; +"section_cost_summary" = "Kostenoverzicht"; +"section_command_line" = "Opdrachtregel"; +"section_privacy" = "Privacy"; +"section_diagnostics" = "Diagnostiek"; +"section_updates" = "Updates"; +"section_links" = "Links"; +"Show Codex Spark usage" = "Codex Spark-gebruik tonen"; +"Shows Codex Spark quota rows in the menu and provider preview. Requires optional credits and extra usage in Display settings." = "Toont Codex Spark-quotumregels in het menu en de voorvertoning van de provider. Vereist dat ‘Toon credits + extra gebruik’ is ingeschakeld in de instellingen voor Weergave."; +"Show Daily Routines usage" = "Gebruik van Dagelijkse routines tonen"; +"Shows the Daily Routines quota row in the menu and provider preview. Requires optional credits and extra usage in Display settings." = "Toont de quotumregel voor Dagelijkse routines in het menu en de voorvertoning van de provider. Vereist dat ‘Toon credits + extra gebruik’ is ingeschakeld in de instellingen voor Weergave."; +"Scroll to see more models" = "Scroll om meer modellen te bekijken"; +"Copy Image" = "Afbeelding kopiëren"; +"Copy Stats" = "Statistieken kopiëren"; +"Could not copy image" = "Afbeelding kon niet worden gekopieerd"; +"Image copied" = "Afbeelding gekopieerd"; +"Image saved" = "Afbeelding bewaard"; +"Nothing is uploaded. This image is created on your Mac." = "Er wordt niets geüpload. Deze afbeelding wordt op je Mac gemaakt."; +"Save..." = "Bewaar..."; +"Share AI Usage" = "AI-gebruik delen"; +"Share Stats…" = "Statistieken delen…"; +"Stats copied" = "Statistieken gekopieerd"; +"DeepSeek this month token usage trend" = "Trend van DeepSeek-tokengebruik deze maand"; +"Chrome profile" = "Chrome-profiel"; +"Choose which signed-in DeepSeek Platform session supplies detailed usage." = "Kies welke aangemelde DeepSeek Platform-sessie gedetailleerd gebruik levert."; +"Detailed usage unavailable." = "Gedetailleerd gebruik niet beschikbaar."; +"Sign in to DeepSeek Platform in Chrome for detailed usage." = "Meld je in Chrome aan bij DeepSeek Platform voor gedetailleerd gebruik."; +"Select a DeepSeek Chrome profile in Settings." = "Selecteer een DeepSeek Chrome-profiel in Instellingen."; +"Select profile…" = "Profiel selecteren…"; + +"%@: %@" = "%@: %@"; +"Alternatively, set a custom path in Settings." = "U kunt ook een aangepast pad instellen in Instellingen."; +"Choose a supported browser so CodexBar can read the matching account." = "Kies een ondersteunde browser zodat CodexBar het overeenkomende account kan lezen."; +"Choose Cursor account" = "Kies Cursoraccount"; +"Choose which Cursor account CodexBar should use." = "Kies welk Cursor-account CodexBar moet gebruiken."; +"Finish switching to a different Cursor account in your browser, then try again." = "Voltooi het overschakelen naar een ander Cursor-account in uw browser en probeer het vervolgens opnieuw."; +"Install a JetBrains IDE with AI Assistant enabled, then refresh CodexBar." = "Installeer een JetBrains IDE met AI Assistant ingeschakeld en vernieuw vervolgens CodexBar."; +"Request quota: %@ / %@" = "Aanvraagquotum: %@ / %@"; +"Sign in with Claude Code..." = "Aanmelden met Claude Code..."; +"Timed out waiting for Cursor account switch. %@" = "Er is een time-out opgetreden tijdens het wachten op het wisselen van Cursor-account. %@"; +"Timed out waiting for Cursor account switch. %@ Last error: %@" = "Er is een time-out opgetreden tijdens het wachten op het wisselen van Cursor-account. %@ Laatste fout: %@"; +"Use Account" = "Gebruik account"; +/* Spend dashboard */ +"tab_usage_spend" = "Gebruik en uitgaven"; +"Usage & Spend" = "Gebruik en uitgaven"; +"Local estimated cost history across supported providers." = "Lokale geschiedenis met geschatte kosten voor ondersteunde aanbieders."; +"Time range" = "Tijdsbereik"; +"Track costs" = "Kosten bijhouden"; +"Cost tracking is off" = "Kostenregistratie is uitgeschakeld"; +"Turn on Track costs to build local estimates." = "Schakel ‘Kosten bijhouden’ in om lokale schattingen op te bouwen."; +"No local cost history yet" = "Nog geen lokale kostengeschiedenis"; +"Turn on cost tracking or refresh after using a supported provider." = "Schakel kostenregistratie in of vernieuw nadat je een ondersteunde aanbieder hebt gebruikt."; +"Refresh failures" = "Mislukte vernieuwingen"; +"Native currencies stay separate; Codex account rows exclude Pi session history." = "Oorspronkelijke valuta’s blijven gescheiden; rijen met Codex-accounts sluiten Pi-sessiegeschiedenis uit."; +"Spend unavailable" = "Uitgaven niet beschikbaar"; +"Model breakdown unavailable" = "Uitsplitsing per model niet beschikbaar"; +"Local estimated history" = "Lokaal geschatte geschiedenis"; +"Coverage" = "Dekking"; +"Estimated spend" = "Geschatte uitgaven"; +"Tracked tokens" = "Bijgehouden tokens"; +"Subscriptions" = "Abonnementen"; +"By subscription" = "Per abonnement"; +"No model-level history" = "Geen geschiedenis op modelniveau"; +"Daily estimated spend" = "Geschatte dagelijkse uitgaven"; +"≈%d full 5h windows of weekly left · %d windows until reset" = "≈%d volledige vensters van 5 uur aan weeklimiet over · %d vensters tot reset"; +"Weekly cannot run out before reset at this pace" = "Het weeklimiet kan bij dit tempo niet vóór de reset opraken"; +"Weekly can run out ≈%d windows early" = "Het weeklimiet kan ≈%d vensters eerder opraken"; +"Estimated: %@" = "Schatting: %@"; +"session_quota_estimate_value_format" = "%1$@ %2$@"; +"session quota" = "sessiequotum"; +"session quotas" = "sessiequota"; +"Coding Plan" = "Codeerplan"; +"Agent Plan" = "Agentplan"; +"Team" = "Team"; + +/* Menu bar layout editor */ +"menu_bar_layout_title" = "Indeling"; +"menu_bar_layout_footer" = "Sleep tokens om de menubalk in te delen. Klik op een token om deze toe te voegen; selecteer een geplaatst token en druk op Delete om het te verwijderen."; +"menu_bar_layout_group_identity" = "Identiteit"; +"menu_bar_layout_group_usage" = "Gebruik"; +"menu_bar_layout_group_time" = "Tijd"; +"menu_bar_layout_group_money" = "Kosten"; +"menu_bar_layout_group_structure" = "Structuur"; +"menu_bar_layout_scope_all" = "Alle providers"; +"menu_bar_layout_scope_help" = "Bewerk de standaardindeling of overschrijf deze voor één provider."; +"menu_bar_layout_use_all" = "Indeling voor alle providers gebruiken"; +"menu_bar_layout_preset" = "Indelingsvoorinstelling"; +"menu_bar_layout_preset_icon_percent" = "Pictogram en percentage"; +"menu_bar_layout_preset_icon_only" = "Alleen pictogram"; +"menu_bar_layout_preset_percent_reset" = "Percentage en reset"; +"menu_bar_layout_preset_compact_stacked" = "Compact gestapeld"; +"menu_bar_layout_preset_custom" = "Aangepast"; +"menu_bar_layout_live_preview" = "Livevoorvertoning"; +"menu_bar_layout_strip" = "Menubalkstrook"; +"menu_bar_layout_remove_line_break" = "Regeleinde verwijderen"; +"menu_bar_layout_chip_hint" = "Selecteer, sleep om te herschikken of gebruik de actie Verwijderen."; +"menu_bar_layout_palette_hint" = "Klik om toe te voegen of sleep naar de indeling."; +"menu_bar_layout_empty_line" = "Zet hier een token neer"; +"menu_bar_layout_line" = "Regel %d"; +"menu_bar_layout_drag_remove" = "Sleep hierheen om te verwijderen"; +"menu_bar_layout_size" = "Grootte"; +"menu_bar_layout_size_small" = "Klein"; +"menu_bar_layout_size_regular" = "Normaal"; +"menu_bar_layout_gap" = "Tussenruimte"; +"menu_bar_layout_gap_tight" = "Krap"; +"menu_bar_layout_gap_regular" = "Normaal"; +"menu_bar_layout_keyboard_hint" = "Delete verwijdert het geselecteerde token"; +"menu_bar_layout_sample_account" = "account"; +"menu_bar_layout_sample_runs_out" = "op vr."; +"menu_bar_layout_token_icon" = "Pictogram"; +"menu_bar_layout_token_provider" = "Providernaam"; +"menu_bar_layout_token_account" = "Account"; +"menu_bar_layout_token_session" = "Sessie %"; +"menu_bar_layout_token_weekly" = "Wekelijks %"; +"menu_bar_layout_token_auto" = "Automatisch %"; +"menu_bar_layout_token_bar" = "Gebruiksbalk"; +"menu_bar_layout_token_resets_in" = "Reset over"; +"menu_bar_layout_token_reset_at" = "Reset om"; +"menu_bar_layout_token_runs_out" = "Op"; +"menu_bar_layout_token_cost_today" = "Kosten vandaag"; +"menu_bar_layout_token_cost_30d" = "Kosten 30 dagen"; +"menu_bar_layout_token_space" = "Spatie"; +"menu_bar_layout_token_line_break" = "Regeleinde"; +"menu_bar_layout_token_separator_accessibility" = "Scheidingspunt"; + +/* Menu bar layout accessibility */ +"Icon unavailable" = "Pictogram: Niet beschikbaar"; +"%@ icon" = "%@: Pictogram"; +"Provider name unavailable" = "Providernaam: Niet beschikbaar"; +"Account unavailable" = "Account: Niet beschikbaar"; +"%@ unavailable" = "%@: Niet beschikbaar"; +"%@ %@" = "%@ %@"; +"Usage bar unavailable" = "Gebruiksbalk: Niet beschikbaar"; +"Usage bar, %d of 3 filled" = "Gebruiksbalk: %d/3 gevuld"; +"Reset countdown unavailable" = "Reset over: Niet beschikbaar"; +"Reset time unavailable" = "Reset om: Niet beschikbaar"; +"Run-out estimate unavailable" = "Op: Niet beschikbaar"; +"Cost today unavailable" = "Kosten vandaag: Niet beschikbaar"; +"30-day cost unavailable" = "Kosten 30 dagen: Niet beschikbaar"; +"Resets" = "Resets"; diff --git a/Sources/CodexBar/Resources/nl.lproj/Localizable.stringsdict b/Sources/CodexBar/Resources/nl.lproj/Localizable.stringsdict new file mode 100644 index 0000000000..173b27fcf8 --- /dev/null +++ b/Sources/CodexBar/Resources/nl.lproj/Localizable.stringsdict @@ -0,0 +1,49 @@ + + + + + ≈%d full 5h windows of weekly left · %d windows until reset + + NSStringLocalizedFormatKey + %#@left@ · %#@until@ + left + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + d + one + ≈%d volledig venster van 5 uur aan weeklimiet over + other + ≈%d volledige vensters van 5 uur aan weeklimiet over + + until + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + d + one + %d venster tot reset + other + %d vensters tot reset + + + Weekly can run out ≈%d windows early + + NSStringLocalizedFormatKey + %#@early@ + early + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + d + one + Het weeklimiet kan ≈%d venster eerder opraken + other + Het weeklimiet kan ≈%d vensters eerder opraken + + + + diff --git a/Sources/CodexBar/Resources/pl.lproj/Localizable.strings b/Sources/CodexBar/Resources/pl.lproj/Localizable.strings new file mode 100644 index 0000000000..a64d4961d6 --- /dev/null +++ b/Sources/CodexBar/Resources/pl.lproj/Localizable.strings @@ -0,0 +1,1357 @@ +/* English localization for CodexBar (base/fallback) */ + +"ollama_safari_cookie_access_hint" = "Pliki cookie Safari wymagają pełnego dostępu do dysku dla CodexBar (Ustawienia systemowe > Prywatność i ochrona)."; +"ollama_browser_cookie_decryption_denied" = "Odszyfrowanie plików cookie %@ zostało odrzucone w Pęku kluczy; spróbuj ponownie przez ręczne odświeżenie."; +"ollama_browser_cookie_decryption_disabled" = "Odszyfrowanie plików cookie %@ jest wyłączone w CodexBar; włącz dostęp do Pęku kluczy i odśwież."; + +" providers" = " providers"; +"(System)" = "(System)"; +"30d" = "30d"; +"7d" = "7d"; +"A managed Codex login is already running. Wait for it to finish before adding " = "Trwa już zarządzane logowanie Codex. Poczekaj na jego zakończenie, zanim dodasz "; +"API key" = "Klucz API"; +"API region" = "Region API"; +"API token" = "Token API"; +"API tokens" = "Tokeny API"; +"About" = "O aplikacji"; +"Account" = "Konto"; +"Accounts" = "Konta"; +"Accounts subtitle" = "Podtytuł kont"; +"Active" = "Aktywne"; +"Add" = "Dodaj"; +"Add Workspace" = "Dodaj workspace"; +"Advanced" = "Zaawansowane"; +"All" = "Wszystko"; +"Always allow prompts" = "Zawsze zezwalaj na monity"; +"Animation pattern" = "Wzór animacji"; +"Antigravity login is managed in the app" = "Logowanie do Antigravity jest zarządzane w aplikacji"; +"Applies only to the Security.framework OAuth keychain reader." = "Dotyczy wyłącznie czytnika pęku kluczy OAuth Security.framework."; +"Alternatively, set a custom path in Settings." = "Ewentualnie ustaw niestandardową ścieżkę w Ustawieniach."; +"Auto falls back to the next source if the preferred one fails." = "Automatycznie przełącza na kolejne źródło, jeśli preferowane zawiedzie."; +"Auto uses API first, then falls back to CLI on auth failures." = "Tryb Auto najpierw używa API, a przy błędach uwierzytelniania przełącza się na CLI."; +"Auto-detect" = "Automatyczne wykrywanie"; +"Auto-refresh is off; use the menu's Refresh command." = "Automatyczne odświeżanie jest wyłączone; użyj polecenia Odśwież w menu."; +"Auto-refresh: hourly · Timeout: 10m" = "Automatyczne odświeżanie: co godzinę · Limit czasu: 10 min"; +"Automatic" = "Automatycznie"; +"Automatic imports browser cookies and WorkOS tokens." = "Automatycznie importuje pliki cookie przeglądarki i tokeny WorkOS."; +"Automatic imports browser cookies and local storage tokens." = "Automatycznie importuje pliki cookie przeglądarki i tokeny z localStorage."; +"Automatic imports browser cookies for dashboard extras." = "Automatycznie importuje pliki cookie przeglądarki dla dodatków panelu."; +"Automatic imports browser cookies for the web API." = "Automatycznie importuje pliki cookie przeglądarki dla web API."; +"Automatic imports browser cookies from Model Studio/Bailian." = "Automatycznie importuje pliki cookie przeglądarki z Model Studio/Bailian."; +"Automatic imports browser cookies from admin.mistral.ai." = "Automatycznie importuje pliki cookie przeglądarki z admin.mistral.ai."; +"Automatic imports browser cookies from opencode.ai." = "Automatycznie importuje pliki cookie przeglądarki z opencode.ai."; +"Automatic imports browser cookies or stored sessions." = "Automatycznie importuje pliki cookie przeglądarki lub zapisane sesje."; +"Automatic imports browser cookies." = "Automatycznie importuje pliki cookie przeglądarki."; +"Automatically imports browser session cookie." = "Automatycznie importuje cookie sesji przeglądarki."; +"Automatically opens CodexBar when you start your Mac." = "Automatycznie otwiera CodexBar przy uruchamianiu Maca."; +"Automation" = "Automatyzacja"; +"Average (\\(label1) + \\(label2))" = "Średnia (\\(label1) + \\(label2))"; +"Average (\\(metadata.sessionLabel) + \\(metadata.weeklyLabel))" = "Średnia (\\(metadata.sessionLabel) + \\(metadata.weeklyLabel))"; +"Avoid Keychain prompts" = "Unikaj monitów pęku kluczy"; +"Balance" = "Saldo"; +"Battery Saver" = "Oszczędzanie baterii"; +"Bordered" = "Z obramowaniem"; +"Build" = "Wersja kompilacji"; +"Built \\(buildTimestamp)" = "Zbudowano \\(buildTimestamp)"; +"Buy Credits..." = "Kup kredyty..."; +"Buy Credits…" = "Kup kredyty…"; +"CLI paths" = "Ścieżki CLI"; +"CLI sessions" = "Sesje CLI"; +"Caches" = "Pamięci podręczne"; +"Cancel" = "Anuluj"; +"Check for Updates…" = "Sprawdź aktualizacje…"; +"Check for updates automatically" = "Sprawdzaj aktualizacje automatycznie"; +"Check if you like your agents having some fun up there." = "Sprawdź, czy lubisz, gdy twoi agenci trochę się tam bawią."; +"Check provider status" = "Sprawdź status dostawcy"; +"Choose a supported browser so CodexBar can read the matching account." = "Wybierz obsługiwaną przeglądarkę, aby CodexBar mógł odczytać odpowiednie konto."; +"Choose Codex workspace" = "Wybierz workspace Codex"; +"Choose Cursor account" = "Wybierz konto Cursor"; +"Choose the MiniMax host (global .io or China mainland .com)." = "Wybierz host MiniMax (.io globalny lub .com dla Chin kontynentalnych)."; +"Choose up to " = "Wybierz maksymalnie "; +"Choose up to \\(Self.maxOverviewProviders) providers" = "Wybierz maksymalnie \\(Self.maxOverviewProviders) dostawców"; +"Choose up to \\(count) providers" = "Wybierz maksymalnie \\(count) dostawców"; +"Choose what to show in the menu bar (Pace shows usage vs. expected)." = "Wybierz, co pokazywać na pasku menu (Tempo pokazuje użycie względem oczekiwanego)."; +"Choose which Codex account CodexBar should follow." = "Wybierz, które konto Codex ma śledzić CodexBar."; +"Choose which Cursor account CodexBar should use." = "Wybierz konto Cursor, którego CodexBar powinien używać."; +"Choose which window drives the menu bar percent." = "Wybierz, które okno steruje procentem na pasku menu."; +"Chrome" = "Chrome"; +"Claude CLI not found" = "Nie znaleziono Claude CLI"; +"Claude binary" = "Plik binarny Claude"; +"Claude cookies" = "Pliki cookie Claude"; +"Claude login failed" = "Logowanie do Claude nie powiodło się"; +"Claude login timed out" = "Przekroczono limit czasu logowania do Claude"; +"Close" = "Zamknij"; +"Code review" = "Przegląd kodu"; +"Codex CLI not found" = "Nie znaleziono Codex CLI"; +"Codex account login already running" = "Logowanie do konta Codex już trwa"; +"Codex binary" = "Plik binarny Codex"; +"Codex login failed" = "Logowanie do Codex nie powiodło się"; +"Codex login timed out" = "Przekroczono limit czasu logowania do Codex"; +"CodexBar Lifecycle Keepalive" = "Podtrzymanie cyklu życia CodexBar"; +"CodexBar can't show its menu bar icon" = "CodexBar nie może pokazać swojej ikony na pasku menu"; +"CodexBar could not read managed account storage. " = "CodexBar nie mógł odczytać magazynu zarządzanych kont. "; +"Configure…" = "Skonfiguruj…"; +"Connected" = "Połączono"; +"Controls how much detail is logged." = "Określa poziom szczegółowości logowania."; +"Cookie header" = "Nagłówek Cookie"; +"Cookie source" = "Źródło Cookie"; +"Cookie: ..." = "Cookie: ..."; +"Cookie: \\u{2026}\\\n\\\nor paste a cURL capture from the Abacus AI dashboard" = "Cookie: \\u{2026}\\\n\\\nalbo wklej przechwycony cURL z panelu Abacus AI"; +"Cookie: \\u{2026}\\\n\\\nor paste the __Secure-next-auth.session-token value" = "Cookie: \\u{2026}\\\n\\\nalbo wklej wartość __Secure-next-auth.session-token"; +"Cookie: \\u{2026}\\\n\\\nor paste the kimi-auth token value" = "Cookie: \\u{2026}\\\n\\\nalbo wklej wartość tokenu kimi-auth"; +"Cookie: …" = "Cookie: …"; +"CopilotDeviceFlow" = "Przepływ urządzenia Copilot"; +"Cost" = "Koszt"; +"Could not add Codex account" = "Nie udało się dodać konta Codex"; +"Could not open Terminal for Gemini" = "Nie udało się otworzyć Terminala dla Gemini"; +"Could not start claude /login" = "Nie udało się uruchomić `claude /login`"; +"Could not start codex login" = "Nie udało się uruchomić logowania Codex"; +"Could not switch system account" = "Nie udało się przełączyć konta systemowego"; +"Credits" = "Kredyty"; +"Individual credits" = "Kredyty indywidualne"; +"Workspace" = "Obszar roboczy"; +"Credits history" = "Historia kredytów"; +"Cursor login failed" = "Logowanie do Cursor nie powiodło się"; +"Custom" = "Niestandardowe"; +"Custom Path" = "Ścieżka niestandardowa"; +"Daily Routines" = "Codzienne rutyny"; +"Debug" = "Debugowanie"; +"Default" = "Domyślne"; +"Disable Keychain access" = "Wyłącz dostęp do pęku kluczy"; +"Disabled" = "Wyłączone"; +"Dismiss" = "Odrzuć"; +"Disconnected" = "Rozłączono"; +"Display" = "Wyświetlanie"; +"Display mode" = "Tryb wyświetlania"; +"Display reset times as absolute clock values instead of countdowns." = "Pokazuj czasy resetu jako wartości zegarowe zamiast odliczania."; +"Done" = "Gotowe"; +"Effective PATH" = "Efektywny PATH"; +"Email" = "E-mail"; +"Enable Merge Icons to configure Overview tab providers." = "Włącz Scal ikony, aby skonfigurować dostawców zakładki Przegląd."; +"Enable file logging" = "Włącz logowanie do pliku"; +"Enabled" = "Włączone"; +"Error" = "Błąd"; +"Error simulation" = "Symulacja błędu"; +"Expose troubleshooting tools in the Debug tab." = "Udostępnia narzędzia diagnostyczne na karcie Debug."; +"Failed" = "Niepowodzenie"; +"False" = "Fałsz"; +"Fetch strategy attempts" = "Próby strategii pobierania"; +"Fetching" = "Pobieranie"; +"Field" = "Pole"; +"Field subtitle" = "Podtytuł pola"; +"Finish the current managed account change before switching the system account." = "Zakończ bieżącą zmianę zarządzanego konta przed przełączeniem konta systemowego."; +"Force animation on next refresh" = "Wymuś animację przy następnym odświeżeniu"; +"Gateway region" = "Region bramy"; +"Gemini CLI not found" = "Nie znaleziono Gemini CLI"; +"Gemini/Antigravity, surfacing incidents in the icon and menu." = "Gemini/Antigravity, pokazując incydenty na ikonie i w menu."; +"General" = "Ogólne"; +"GitHub" = "GitHub"; +"GitHub Copilot Login" = "Logowanie GitHub Copilot"; +"GitHub Login" = "Logowanie GitHub"; +"Hide details" = "Ukryj szczegóły"; +"Hide personal information" = "Ukryj dane osobowe"; +"Historical tracking" = "Śledzenie historyczne"; +"How often CodexBar polls providers in the background." = "Jak często CodexBar odpyta dostawców w tle."; +"Inactive" = "Nieaktywne"; +"Install CLI" = "Zainstaluj CLI"; +"Install the Claude CLI (npm i -g @anthropic-ai/claude-code) and try again." = "Zainstaluj Claude CLI (`npm i -g @anthropic-ai/claude-code`) i spróbuj ponownie."; +"Install the Codex CLI (npm i -g @openai/codex) and try again." = "Zainstaluj Codex CLI (`npm i -g @openai/codex`) i spróbuj ponownie."; +"Install the Gemini CLI (npm i -g @google/gemini-cli) and try again." = "Zainstaluj Gemini CLI (`npm i -g @google/gemini-cli`) i spróbuj ponownie."; +"Install a JetBrains IDE with AI Assistant enabled, then refresh CodexBar." = "Zainstaluj IDE JetBrains z włączonym AI Assistant, a następnie odśwież CodexBar."; +"JetBrains AI is ready" = "JetBrains AI jest gotowe"; +"JetBrains IDE" = "JetBrains IDE"; +"Keep CLI sessions alive" = "Utrzymuj sesje CLI aktywne"; +"Keyboard shortcut" = "Skrót klawiaturowy"; +"Keychain access" = "Dostęp do pęku kluczy"; +"Keychain prompt policy" = "Zasada monitów pęku kluczy"; +"Last \\(name) fetch failed:" = "Ostatnie pobranie \\(name) nie powiodło się:"; +"Last \\(self.store.metadata(for: self.provider).displayName) fetch failed:" = "Ostatnie pobranie \\(self.store.metadata(for: self.provider).displayName) nie powiodło się:"; +"Last attempt" = "Ostatnia próba"; +"Link" = "Link"; +"Loading animations" = "Animacje ładowania"; +"Loading…" = "Ładowanie…"; +"Local" = "Lokalne"; +"Logging" = "Logowanie"; +"Login failed" = "Logowanie nie powiodło się"; +"Login shell PATH (startup capture)" = "PATH powłoki logowania (przechwycony przy starcie)"; +"Login timed out" = "Przekroczono limit czasu logowania"; +"MCP details" = "Szczegóły MCP"; +"Managed Codex accounts unavailable" = "Zarządzane konta Codex są niedostępne"; +"Managed account storage is unreadable. Live account access is still available, " = "Magazyn zarządzanych kont jest nieczytelny. Dostęp do aktywnego konta nadal działa, "; +"Manual" = "Ręcznie"; +"May your tokens never run out—keep agent limits in view." = "Niech twoje tokeny nigdy się nie skończą — miej limity agentów zawsze w zasięgu wzroku."; +"Menu bar" = "Pasek menu"; +"Menu bar auto-shows the provider closest to its rate limit." = "Pasek menu automatycznie pokazuje dostawcę najbliżej jego limitu."; +"Menu bar metric" = "Metryka paska menu"; +"Menu bar shows percent" = "Pasek menu pokazuje procent"; +"Menu content" = "Zawartość menu"; +"Merge Icons" = "Scal ikony"; +"Never prompt" = "Nigdy nie pytaj"; +"No" = "Nie"; +"No Codex accounts detected yet." = "Nie wykryto jeszcze żadnych kont Codex."; +"No JetBrains IDE detected" = "Nie wykryto żadnego IDE JetBrains"; +"No cost history data." = "Brak danych historii kosztów."; +"No data available" = "Brak dostępnych danych"; +"No data yet" = "Brak danych"; +"No enabled providers available for Overview." = "Brak włączonych dostawców dostępnych dla Przeglądu."; +"No providers selected" = "Nie wybrano dostawców"; +"No token accounts yet." = "Brak jeszcze kont tokenów."; +"No usage breakdown data." = "Brak danych podziału użycia."; +"None" = "None"; +"Notifications" = "Powiadomienia"; +"Notifies when the 5-hour session quota hits 0% and when it becomes " = "Powiadamia, gdy limit 5-godzinnej sesji spadnie do 0% i gdy znów stanie się "; +"OK" = "OK"; +"Obscure email addresses in the menu bar and menu UI." = "Ukrywa adresy e-mail na pasku menu i w interfejsie menu."; +"Off" = "Wyłączone"; +"Offline" = "Offline"; +"On" = "On"; +"Online" = "Online"; +"Only on user action" = "Tylko po działaniu użytkownika"; +"Open" = "Otwórz"; +"Open API Keys" = "Otwórz klucze API"; +"Open Amp Settings" = "Otwórz ustawienia Amp"; +"Open Antigravity to sign in, then refresh CodexBar." = "Otwórz Antigravity, aby się zalogować, a następnie odśwież CodexBar."; +"Open Browser" = "Otwórz przeglądarkę"; +"Open Coding Plan" = "Otwórz Coding Plan"; +"Open Console" = "Otwórz konsolę"; +"Open Dashboard" = "Otwórz panel"; +"Open Mistral Admin" = "Otwórz Mistral Admin"; +"Open Menu Bar Settings" = "Otwórz ustawienia paska menu"; +"Open Ollama Settings" = "Otwórz ustawienia Ollama"; +"Open Terminal" = "Otwórz Terminal"; +"Open Usage Page" = "Otwórz stronę użycia"; +"Open Warp API Key Guide" = "Otwórz przewodnik po kluczu API Warp"; +"Open menu" = "Otwórz menu"; +"Open token file" = "Otwórz plik tokenu"; +"OpenAI cookies" = "Pliki cookie OpenAI"; +"OpenAI web extras" = "Dodatki webowe OpenAI"; +"Option A" = "Option A"; +"Option B" = "Option B"; +"Optional override if workspace lookup fails." = "Opcjonalne nadpisanie, jeśli wyszukiwanie workspace się nie powiedzie."; +"Options" = "Opcje"; +"Override auto-detection with a custom IDE base path" = "Nadpisz automatyczne wykrywanie niestandardową ścieżką bazową IDE"; +"Overview" = "Przegląd"; +"Overview rows always follow provider order." = "Wiersze Przeglądu zawsze podążają za kolejnością dostawców."; +"Overview tab providers" = "Dostawcy zakładki Przegląd"; +"Paste API key…" = "Wklej klucz API…"; +"Paste API token…" = "Wklej token API…"; +"Paste key…" = "Wklej klucz…"; +"Paste sessionKey or OAuth token…" = "Wklej sessionKey lub token OAuth…"; +"Paste the Cookie header from a request to admin.mistral.ai. " = "Wklej nagłówek Cookie z żądania do admin.mistral.ai. "; +"Paste token…" = "Wklej token…"; +"Personal" = "Osobiste"; +"Picker" = "Selektor"; +"Picker subtitle" = "Podtytuł selektora"; +"Placeholder" = "Tekst zastępczy"; +"Plan" = "Plan"; +"Plan Usage" = "Wykorzystanie planu"; +"Play full-screen confetti when weekly usage resets." = "Odtwórz pełnoekranowe konfetti, gdy tygodniowe użycie się resetuje."; +"Polls OpenAI/Claude status pages and Google Workspace for " = "Sprawdza strony statusu OpenAI/Claude oraz Google Workspace dla "; +"Prevents any Keychain access while enabled." = "Blokuje wszelki dostęp do pęku kluczy, gdy opcja jest włączona."; +"Primary (API key limit)" = "Główny (limit klucza API)"; +"Primary (\\(label))" = "Główny (\\(label))"; +"Primary (\\(metadata.sessionLabel))" = "Główny (\\(metadata.sessionLabel))"; +"Probe logs" = "Logi probe"; +"Progress bars fill as you consume quota (instead of showing remaining)." = "Paski postępu wypełniają się wraz ze zużyciem limitu (zamiast pokazywać pozostałą część)."; +"Provider" = "Dostawca"; +"Providers" = "Dostawcy"; +"Quit CodexBar" = "Zakończ CodexBar"; +"Random (default)" = "Losowo (domyślnie)"; +"Reads local usage logs. Shows today + last 30 days cost in the menu." = "Odczytuje lokalne logi użycia. Pokazuje dziś + koszty z ostatnich 30 dni w menu."; +"Refresh" = "Odśwież"; +"Refresh cadence" = "Częstotliwość odświeżania"; +"Remote" = "Zdalne"; +"Remove" = "Usuń"; +"Remove Codex account?" = "Usunąć konto Codex?"; +"Remove \\(account.email) from CodexBar? Its managed Codex home will be deleted." = "Usunąć \\(account.email) z CodexBar? Jego zarządzony katalog domowy Codex zostanie usunięty."; +"Remove \\(email) from CodexBar? Its managed Codex home will be deleted." = "Usunąć \\(email) z CodexBar? Jego zarządzony katalog domowy Codex zostanie usunięty."; +"Remove selected account" = "Usuń wybrane konto"; +"Replace critter bars with provider branding icons and a percentage." = "Zastąp paski stworzonkami ikonami marek dostawców i wartością procentową."; +"Replay selected animation" = "Odtwórz wybraną animację ponownie"; +"Requires authentication via GitHub Device Flow." = "Wymaga uwierzytelnienia przez GitHub Device Flow."; +"Resets: \\(reset)" = "Reset: \\(reset)"; +"Rolling five-hour limit" = "Ruchomy limit pięciogodzinny"; +"Search hourly" = "Szukaj co godzinę"; +"Secondary (\\(label))" = "Wtórny (\\(label))"; +"Secondary (\\(metadata.weeklyLabel))" = "Wtórny (\\(metadata.weeklyLabel))"; +"Select a provider" = "Wybierz dostawcę"; +"Select the IDE to monitor" = "Wybierz IDE do monitorowania"; +"Session quota notifications" = "Powiadomienia o limicie sesji"; +"Session tokens" = "Tokeny sesji"; +"provider_section_connection" = "Połączenie"; +"provider_section_menu_bar" = "Pasek menu"; +"Show Codex Credits and Claude Extra usage sections in the menu." = "Pokazuj w menu sekcje Codex Credits i Claude Extra usage."; +"Show Debug Settings" = "Pokaż ustawienia debugowania"; +"Show all token accounts" = "Pokaż wszystkie konta tokenów"; +"Show cost summary" = "Pokaż podsumowanie kosztów"; +"Show credits + extra usage" = "Pokaż kredyty + dodatkowe użycie"; +"Show details" = "Pokaż szczegóły"; +"Show most-used provider" = "Pokaż najczęściej używanego dostawcę"; +"Show provider icons in the switcher (otherwise show a weekly progress line)." = "Pokazuj ikony dostawców w przełączniku (w przeciwnym razie linię postępu tygodniowego)."; +"Show reset time as clock" = "Pokaż czas resetu jako godzinę"; +"Show usage as used" = "Pokazuj użycie jako wykorzystane"; +"Sign in with Claude Code..." = "Zaloguj się przez Claude Code..."; +"Sign in via button below" = "Zaloguj się przyciskiem poniżej"; +"Skip teardown between probes (debug-only)." = "Pomiń sprzątanie między probe'ami (tylko do debugowania)."; +"Stack token accounts in the menu (otherwise show an account switcher bar)." = "Układa konta tokenów w stos w menu (w przeciwnym razie pokazuje pasek przełączania kont)."; +"Start at Login" = "Uruchamiaj przy logowaniu"; +"Status" = "Status"; +"Store Claude sessionKey cookies or OAuth access tokens." = "Przechowuj pliki cookie sessionKey Claude lub tokeny dostępu OAuth."; +"Store multiple Abacus AI Cookie headers." = "Przechowuj wiele nagłówków Cookie dla Abacus AI."; +"Store multiple Augment Cookie headers." = "Przechowuj wiele nagłówków Cookie dla Augment."; +"Store multiple Cursor Cookie headers." = "Przechowuj wiele nagłówków Cookie dla Cursor."; +"Store multiple Factory Cookie headers." = "Przechowuj wiele nagłówków Cookie dla Factory."; +"Store multiple MiniMax Cookie headers." = "Przechowuj wiele nagłówków Cookie dla MiniMax."; +"Store multiple Mistral Cookie headers." = "Przechowuj wiele nagłówków Cookie dla Mistral."; +"Store multiple Ollama Cookie headers." = "Przechowuj wiele nagłówków Cookie dla Ollama."; +"Store multiple OpenCode Cookie headers." = "Przechowuj wiele nagłówków Cookie dla OpenCode."; +"Store multiple OpenCode Go Cookie headers." = "Przechowuj wiele nagłówków Cookie dla OpenCode Go."; +"Stored in the CodexBar config file." = "Przechowywane w pliku konfiguracyjnym CodexBar."; +"Stored in ~/.codexbar/config.json. " = "Przechowywane w ~/.codexbar/config.json. "; +"Stored in ~/.codexbar/config.json. Paste the key from the Synthetic dashboard." = "Przechowywane w ~/.codexbar/config.json. Wklej klucz z panelu Synthetic."; +"Stored in ~/.codexbar/config.json. Paste your Coding Plan API key from Model Studio." = "Przechowywane w ~/.codexbar/config.json. Wklej klucz API Coding Plan z Model Studio."; +"Stored in ~/.codexbar/config.json. Paste your MiniMax API key." = "Przechowywane w ~/.codexbar/config.json. Wklej swój klucz API MiniMax."; +"Stored in ~/.codexbar/config.json. You can also provide KILO_API_KEY or " = "Przechowywane w ~/.codexbar/config.json. Możesz też podać KILO_API_KEY lub "; +"Stores local Codex usage history (8 weeks) to personalize Pace predictions." = "Przechowuje lokalną historię użycia Codex (8 tygodni), aby personalizować prognozy Tempo."; +"Surprise me" = "Zaskocz mnie"; +"Switcher shows icons" = "Przełącznik pokazuje ikony"; +"Symlink CodexBarCLI to /usr/local/bin and /opt/homebrew/bin as codexbar." = "Dowiąż symbolicznie CodexBarCLI do /usr/local/bin i /opt/homebrew/bin jako codexbar."; +"System" = "System"; +"Temporarily shows the loading animation after the next refresh." = "Tymczasowo pokazuje animację ładowania po następnym odświeżeniu."; +"terminal_app_subtitle" = "Terminal używany przez akcję Otwórz terminal"; +"terminal_app_title" = "Domyślny terminal"; +"Tertiary (\\(label))" = "Trzeciorzędny (\\(label))"; +"Tertiary (\\(tertiaryTitle))" = "Trzeciorzędny (\\(tertiaryTitle))"; +"The default Codex account on this Mac." = "Domyślne konto Codex na tym Macu."; +"Toggle" = "Przełącznik"; +"Toggle subtitle" = "Podtytuł przełącznika"; +"Token" = "Token"; +"Trigger the menu bar menu from anywhere." = "Wywołaj menu paska menu z dowolnego miejsca."; +"True" = "Prawda"; +"Twitter" = "Twitter"; +"Unsupported" = "Nieobsługiwane"; +"Update Channel" = "Kanał aktualizacji"; +"Updated" = "Zaktualizowano"; +"Updates unavailable in this build." = "Aktualizacje niedostępne w tej wersji."; +"Usage" = "Zużycie"; +"Usage breakdown" = "Podział użycia"; +"Usage history (30 days)" = "Historia użycia (30 dni)"; +"Usage source" = "Źródło użycia"; +"Use Account" = "Użyj konta"; +"Use BigModel for the China mainland endpoints (open.bigmodel.cn)." = "Użyj BigModel dla punktów końcowych Chin kontynentalnych (open.bigmodel.cn)."; +"Use a single menu bar icon with a provider switcher." = "Używaj jednej ikony paska menu z przełącznikiem dostawcy."; +"Use international or China mainland console gateways for quota fetches." = "Używaj międzynarodowych lub chińskich bram konsoli do pobierania limitów."; +"Version" = "Wersja"; +"Version \\(self.versionString)" = "Wersja \\(self.versionString)"; +"Version \\(version)" = "Wersja \\(version)"; +"Version \\(versionString)" = "Wersja \\(versionString)"; +"Vertex AI Login" = "Logowanie Vertex AI"; +"Wait for the current managed Codex login to finish before adding another account." = "Poczekaj, aż bieżące zarządzane logowanie Codex się zakończy, zanim dodasz kolejne konto."; +"Waiting for Authentication..." = "Oczekiwanie na uwierzytelnienie..."; +"Website" = "Strona internetowa"; +"Weekly limit confetti" = "Konfetti limitu tygodniowego"; +"Weekly token limit" = "Tygodniowy limit tokenów"; +"Weekly usage" = "Tygodniowe użycie"; +"Weekly usage unavailable for this account." = "Dane tygodniowego użycia są niedostępne dla tego konta."; +"Window: \\(window)" = "Okno: \\(window)"; +"Write logs to \\(self.fileLogPath) for debugging." = "Zapisuj logi do \\(self.fileLogPath) na potrzeby debugowania."; +"Yes" = "Tak"; +"\\(detail.modelCode): \\(usage)" = "\\(detail.modelCode): \\(usage)"; +"\\(name): \\(truncated)" = "\\(name): \\(truncated)"; +"\\(name): \\(updated) · 30d \\(cost)" = "\\(name): \\(updated) · 30d \\(cost)"; +"\\(name): fetching…\\(elapsed)" = "\\(name): pobieranie…\\(elapsed)"; +"\\(name): last attempt \\(when)" = "\\(name): ostatnia próba \\(when)"; +"\\(name): no data yet" = "\\(name): brak danych"; +"\\(name): unsupported" = "\\(name): nieobsługiwane"; +"all browsers" = "wszystkie przeglądarki"; +"available again." = "znów dostępny."; +"built_format" = "Zbudowano %@"; +"copilot_complete_in_browser" = "Dokończ logowanie w przeglądarce."; +"copilot_device_code" = "Kod urządzenia skopiowano do schowka: %1$@\n\nZweryfikuj na: %2$@"; +"copilot_device_code_copied" = "Kod urządzenia skopiowano."; +"copilot_verify_at" = "Zweryfikuj na %@"; +"copilot_waiting_text" = "Dokończ logowanie w przeglądarce.\nTo okno zamknie się automatycznie po zakończeniu logowania."; +"copilot_window_closes_auto" = "To okno zamknie się automatycznie po zakończeniu logowania."; +"cost_status_error" = "%1$@: %2$@"; +"cost_status_fetching" = "%1$@: pobieranie… %2$@"; +"cost_status_last_attempt" = "%1$@: ostatnia próba %2$@"; +"cost_status_no_data" = "%@: brak danych"; +"cost_status_snapshot" = "%1$@: %2$@ · %3$@ %4$@"; +"cost_status_unsupported" = "%@: nieobsługiwane"; +"credits_remaining" = "Kredyty: %@"; +"cursor_on_demand" = "Na żądanie: %@"; +"cursor_on_demand_with_limit" = "Na żądanie: %1$@ / %2$@"; +"extra_usage_format" = "Dodatkowe użycie: %1$@ / %2$@"; +"jetbrains_detected_generate" = "Wykryto: %@. Użyj raz asystenta AI, aby wygenerować dane limitu, a następnie odśwież CodexBar."; +"jetbrains_detected_select" = "Wykryto: %@. Wybierz preferowane IDE w Ustawieniach, a następnie odśwież CodexBar."; +"last_fetch_failed_with_provider" = "Ostatnie pobranie %@ nie powiodło się:"; +"last_spend" = "Ostatni wydatek: %@"; +"mcp_model_usage" = "%1$@: %2$@"; +"mcp_resets" = "Reset: %@"; +"mcp_window" = "Okno: %@"; +"metric_average" = "Średnia (%1$@ + %2$@)"; +"metric_primary" = "Główny (%@)"; +"metric_secondary" = "Wtórny (%@)"; +"metric_tertiary" = "Trzeciorzędny (%@)"; +"multiple_workspaces_found" = "CodexBar znalazł wiele workspace dla %@. Wybierz workspace do dodania."; +"ory_session_…=…; csrftoken=…" = "ory_session_…=…; csrftoken=…"; +"overview_choose_providers" = "Wybierz maksymalnie %@ dostawców"; +"remove_account_message" = "Usunąć %@ z CodexBar? Jego zarządzony katalog domowy Codex zostanie usunięty."; +"version_format" = "Wersja %@"; +"vertex_ai_login_instructions" = "Aby śledzić użycie Vertex AI, uwierzytelnij się w Google Cloud.\n\n1. Otwórz Terminal\n2. Uruchom: gcloud auth application-default login\n3. Postępuj zgodnie z instrukcjami w przeglądarce, aby się zalogować\n4. Ustaw projekt: gcloud config set project PROJECT_ID\n\nOtworzyć teraz Terminal?"; +"workspaceID is set but only opencode, opencodego, and deepgram support workspaceID." = "Ustawiono `workspaceID`, ale obsługują go tylko opencode, opencodego i deepgram."; +"© 2026 Peter Steinberger. MIT License." = "© 2026 Peter Steinberger. MIT License."; + +/* General Pane */ +"section_system" = "System"; +"section_usage" = "Zużycie"; +"section_refreshing" = "Odświeżanie"; +"section_alerts" = "Alerty"; +"section_celebrations" = "Świętowanie"; +"section_icon" = "Ikona"; +"section_combined_icon" = "Połączona ikona"; +"section_animation" = "Animacja"; +"section_content" = "Zawartość"; +"section_agent_sessions" = "Sesje agentów"; +"language_title" = "Język"; +"language_subtitle" = "Zmień język interfejsu. Aby zmiana zaczęła w pełni obowiązywać, uruchom aplikację ponownie."; +"currency_title" = "Preferowana waluta"; +"currency_subtitle" = "Waluta szacowanych kosztów i wydatków. Używa kursów aktualizowanych codziennie."; +"currency_auto" = "Automatycznie (według dostawcy / USD)"; +"language_system" = "System"; +"language_english" = "English"; +"language_spanish" = "Español"; +"language_catalan" = "Català"; +"language_chinese_simplified" = "简体中文"; +"language_chinese_traditional" = "繁體中文"; +"language_portuguese_brazilian" = "Português (Brasil)"; +"language_german" = "Niemiecki"; +"language_swedish" = "Svenska"; +"language_french" = "Francuski"; +"language_dutch" = "Niderlandzki"; +"language_ukrainian" = "Ukraiński"; +"language_russian" = "Русский"; +"language_vietnamese" = "Wietnamski"; +"language_italian" = "Włoski"; +"language_indonesian" = "Indonezyjski"; +"language_polish" = "Polski"; +"language_japanese" = "Japoński"; +"language_korean" = "Koreański"; +"language_turkish" = "Turecki"; +"start_at_login_title" = "Uruchamiaj przy logowaniu"; +"start_at_login_subtitle" = "Automatycznie uruchamia CodexBar podczas startu Maca."; +"show_cost_summary_subtitle" = "Odczytuje lokalne logi użycia. Pokazuje dziś + wybrany zakres historii w menu."; +"cost_summary_style_title" = "Styl wyświetlania"; +"cost_summary_style_inline" = "Tylko wbudowane"; +"cost_summary_style_submenu" = "Tylko podmenu"; +"cost_summary_style_both" = "Oba"; +"cost_summary_style_inline_help" = "Pokazuje podsumowanie kosztów bezpośrednio w menu głównym."; +"cost_summary_style_submenu_help" = "Zamiast tego pokazuje szczegółowe podmenu Koszt."; +"cost_summary_style_both_help" = "Pokazuje podsumowanie w menu głównym i szczegółowe podmenu Koszt."; +"cost_history_window_title" = "Zakres historii"; +"cost_history_window_help" = "Ustawia, ile dni lokalnych dzienników użycia pokazać w menu."; +"cost_history_days_title" = "Zakres historii: %d dni"; +"cost_comparison_periods_title" = "Pokaż krótsze okresy porównawcze"; +"cost_comparison_periods_subtitle" = "Dodaj sumy z 7, 30 i 90 dni, gdy mieszczą się w wybranym zakresie historii. Sumy te wykorzystują to samo skanowanie lokalne."; +"cost_auto_refresh_info" = "Auto-odświeżanie: interwał globalny (minimum 5 min) · Limit czasu: 10 min"; +"refresh_interval_title" = "Częstotliwość odświeżania"; +"manual_refresh_hint" = "Auto-odświeżanie jest wyłączone; użyj polecenia Odśwież w menu."; +"refresh_on_open_title" = "Odśwież po otwarciu menu"; +"refresh_on_open_subtitle" = "Pobiera najnowsze zużycie każdego dostawcy przy każdym otwarciu menu."; +"check_provider_status_title" = "Sprawdzaj status dostawców"; +"check_provider_status_subtitle" = "Sprawdza status OpenAI/Claude oraz Google Workspace dla Gemini/Antigravity i pokazuje incydenty na ikonie i w menu."; +"session_quota_notifications_subtitle" = "Powiadamia, gdy limit 5-godzinnej sesji spadnie do 0% i gdy znów będzie dostępny."; +"quota_depleted_title" = "Wyczerpanie i przywrócenie limitu"; +"quota_warning_notifications_subtitle" = "Ostrzega, gdy pozostały limit sesji lub tygodnia przekroczy skonfigurowane progi."; +"threshold_warnings_title" = "Ostrzeżenia progowe"; +"quota_warnings_title" = "Ostrzeżenia limitu"; +"quota_warning_session" = "sesja"; +"quota_warning_session_capitalized" = "Sesja"; +"quota_warning_weekly" = "tydzień"; +"quota_warning_weekly_capitalized" = "Tydzień"; +"quota_warning_notification_title" = "Niski limit %1$@ (%2$@)"; +"quota_warning_notification_body" = "Pozostało %1$@. Osiągnięto próg ostrzeżenia %2$d%% dla %3$@."; +"quota_warning_notification_body_with_account" = "Konto %1$@. Pozostało %2$@. Osiągnięto próg ostrzeżenia %3$d%% dla %4$@."; +"predictive_pace_warnings_title" = "Predykcyjne ostrzeżenia tempa"; +"predictive_pace_warnings_subtitle" = "Ostrzega dla Codex i Claude, gdy tempo sesji lub tygodnia może wyczerpać limit przed resetem."; +"confetti_on_reset_title" = "Konfetti po resecie"; +"confetti_on_reset_subtitle" = "Wyświetlaj pełnoekranowe konfetti po zresetowaniu użycia."; +"confetti_option_off" = "Wyłączone"; +"confetti_option_session" = "Resety sesji"; +"confetti_option_weekly" = "Resety tygodniowe"; +"confetti_option_both" = "Oba"; +"predictive_pace_warning_notification_title" = "%1$@ ostrzeżenie tempa %2$@"; +"predictive_pace_warning_notification_body" = "Przy obecnym tempie ten limit może wyczerpać się za %1$@, przed resetem."; +"predictive_pace_warning_notification_body_with_account" = "Konto %1$@. Przy obecnym tempie ten limit może wyczerpać się za %2$@, przed resetem."; +"session_depleted_notification_title" = "Wyczerpano limit sesji (%@)"; +"session_depleted_notification_body" = "Pozostało 0%. Powiadomimy, gdy limit będzie ponownie dostępny."; +"session_restored_notification_title" = "Przywrócono limit sesji (%@)"; +"session_restored_notification_body" = "Limit sesji jest ponownie dostępny."; +"quota_warning_warn_at" = "Ostrzegaj przy"; +"quota_warning_global_threshold_subtitle" = "Procent pozostałego limitu dla okien sesji i tygodnia, chyba że dostawca ma nadpisanie."; +"quota_warning_sound" = "Odtwarzaj dźwięk powiadomienia"; +"quota_warning_onscreen_alert" = "Pokaż alert tekstowy na ekranie"; +"quota_warning_provider_inherits" = "Używa globalnych ustawień ostrzeżeń limitu, chyba że to okno jest tutaj dostosowane."; +"quota_warning_provider_disabled" = "Powiadomienia o ostrzeżeniach limitu i znaczniki na paskach użycia są wyłączone. Włącz dowolną z tych funkcji, aby edytować zapisane ustawienia."; +"quota_warning_provider_markers_only" = "Powiadomienia o ostrzeżeniach limitu są globalnie wyłączone. Te ustawienia nadal sterują znacznikami na paskach użycia."; +"quota_warning_global" = "Globalne"; +"quota_warning_customize_thresholds" = "Dostosuj progi dla %@"; +"quota_warning_enable_warnings" = "Włącz ostrzeżenia dla %@"; +"quota_warning_window_warn_at" = "%@ — ostrzegaj przy"; +"quota_warning_off" = "Wyłączone"; +"quota_warning_inherited" = "Dziedziczone: %@"; +"quota_warning_depleted_only" = "tylko wyczerpanie"; +"quota_warning_upper" = "Wyższy"; +"quota_warning_lower" = "Dolny"; +"quota_warning_warning" = "Ostrzeżenie"; +"quota_warning_critical" = "Krytyczny"; +"apply" = "Zastosuj"; +"quit_app" = "Zakończ aplikację"; + +/* Tab titles */ +"tab_general" = "Ogólne"; +"tab_providers" = "Dostawcy"; +"tab_notifications" = "Powiadomienia"; +"tab_menu_bar" = "Pasek menu"; +"tab_menu" = "Menu"; +"tab_advanced" = "Zaawansowane"; +"tab_hooks" = "Haki"; + +/* Hooks Pane */ +"hooks_enable_title" = "Włącz haki"; +"hooks_enable_subtitle" = "Uruchamiaj polecenia zewnętrzne, gdy wystąpią zdarzenia limitu lub dostawcy."; +"hooks_trust_warning" = "Haki mogą uruchamiać lokalne polecenia na Twoim Macu. Konfiguruj tylko polecenia, którym ufasz."; +"hooks_rules_header" = "Reguły"; +"hooks_empty" = "Nie skonfigurowano haków."; +"hooks_add_rule" = "Dodaj regułę"; +"hooks_delete_rule" = "Usuń regułę"; +"hooks_rule_enabled" = "Włączone"; +"hooks_event" = "Zdarzenie"; +"hooks_provider" = "Dostawca"; +"hooks_any_provider" = "Dowolny dostawca"; +"hooks_threshold" = "Uruchom przy użyciu ≥"; +"hooks_threshold_placeholder" = "90"; +"hooks_executable_placeholder" = "/usr/local/bin/my-command"; +"hooks_arguments_placeholder" = "Argumenty"; +"hooks_argument_placeholder" = "Argument"; +"hooks_add_argument" = "Dodaj argument"; +"hooks_delete_argument" = "Usuń argument"; +"tab_about" = "O aplikacji"; +"tab_debug" = "Debugowanie"; + +/* Providers Pane */ +"select_a_provider" = "Wybierz dostawcę"; +"cancel" = "Anuluj"; +"last_fetch_failed" = "ostatnie pobranie nieudane"; +"usage_not_fetched_yet" = "dane użycia nie zostały jeszcze pobrane"; +"managed_account_storage_unreadable" = "Magazyn zarządzanych kont jest nieczytelny. Dostęp do aktywnego konta nadal działa, ale dodawanie, ponowne uwierzytelnianie i usuwanie zarządzanych kont są wyłączone do czasu naprawy magazynu."; +"remove_codex_account_title" = "Usunąć konto Codex?"; +"remove" = "Usuń"; +"managed_login_already_running" = "Trwa już zarządzane logowanie Codex. Poczekaj na zakończenie przed dodaniem lub ponowną autoryzacją kolejnego konta."; +"managed_login_failed" = "Zarządzane logowanie Codex nie zostało ukończone. Sprawdź, czy `codex --version` działa w Terminalu. Jeśli macOS zablokował `codex` lub przeniósł go do Kosza, usuń stare duplikaty instalacji, uruchom `npm install -g --include=optional @openai/codex@latest`, a potem spróbuj ponownie."; +"codex_login_output" = "wynik logowania codex:"; +"managed_login_missing_email" = "Logowanie Codex zakończone, ale brak adresu e-mail konta. Spróbuj ponownie po potwierdzeniu pełnego zalogowania."; +"login_success_notification_title" = "Logowanie %@ zakończone powodzeniem"; +"login_success_notification_body" = "Możesz wrócić do aplikacji; uwierzytelnianie zostało zakończone."; +"workspace_selection_cancelled" = "CodexBar wykrył wiele workspace, ale nie wybrano żadnego."; +"unsafe_managed_home" = "CodexBar odmówił zmiany nieoczekiwanej ścieżki zarządzanego katalogu domowego: %@"; +"menu_bar_metric_title" = "Metryka paska menu"; +"menu_bar_metric_subtitle" = "Wybierz metrykę pokazywaną obok ikony na pasku menu."; +"menu_bar_metric_subtitle_deepseek" = "Pokazuje saldo DeepSeek na pasku menu."; +"menu_bar_metric_subtitle_moonshot" = "Pokazuje saldo API Moonshot / Kimi na pasku menu."; +"menu_bar_metric_subtitle_mistral" = "Pokazuje wydatki Mistral API z bieżącego miesiąca na pasku menu."; +"automatic" = "Automatycznie"; +"primary_api_key_limit" = "Główny (limit klucza API)"; + +/* Display Pane */ +"menu_bar_style_title" = "Styl paska menu"; +"menu_bar_style_subtitle" = "Sposób rysowania elementu paska menu."; +"menu_bar_inactive_display_contrast_title" = "Popraw widoczność na nieaktywnych ekranach"; +"menu_bar_usage_colors_title" = "Zużycie oznaczone kolorami"; +"menu_bar_usage_colors_subtitle" = "Zabarwia ikonę paska menu od zieleni do czerwieni wraz ze wzrostem zużycia."; +"menu_bar_inactive_display_contrast_subtitle" = "Używa renderowania o wysokim kontraście, aby ikona i wskaźnik pozostały czytelne na innych ekranach."; +"menu_bar_style_critters" = "Stworki"; +"menu_bar_style_bars" = "Paski miernika"; +"menu_bar_style_icon_percent" = "Ikona i procent"; +"switcher_rows_title" = "Wiersze przełącznika"; +"switcher_rows_icons" = "Ikony dostawców"; +"switcher_rows_progress" = "Postęp tygodniowy"; +"usage_bars_fill_title" = "Wypełnienie pasków użycia"; +"usage_bars_fill_remaining" = "Pozostały limit"; +"usage_bars_fill_used" = "Wykorzystany limit"; +"reset_times_title" = "Czasy resetu"; +"reset_times_countdown" = "Odliczanie"; +"reset_times_clock" = "Godzina"; +"cost_summary_title" = "Podsumowanie kosztów"; +"cost_summary_off" = "Wyłączone"; +"merge_icons_title" = "Scal ikony"; +"merge_icons_subtitle" = "Używaj jednej ikony paska menu z przełącznikiem dostawcy."; +"show_most_used_provider_title" = "Pokaż najczęściej używanego dostawcę"; +"show_most_used_provider_subtitle" = "Pasek menu automatycznie pokazuje dostawcę najbliżej limitu."; +"display_mode_title" = "Tryb wyświetlania"; +"display_mode_subtitle" = "Wybierz, co pokazywać na pasku menu (Tempo pokazuje użycie względem oczekiwanego)."; +"show_quota_warning_markers_title" = "Pokaż znaczniki ostrzeżeń limitu"; +"show_quota_warning_markers_subtitle" = "Rysuje znaczniki progów na paskach użycia, gdy skonfigurowano ostrzeżenia limitu."; +"weekly_progress_work_days_title" = "Dni robocze postępu tygodniowego"; +"weekly_progress_work_days_subtitle" = "Rysuje znaczniki granic dni na tygodniowych paskach użycia."; +"show_provider_changelog_links_title" = "Pokaż linki do changelogów dostawców"; +"show_provider_changelog_links_subtitle" = "Dodaje linki do informacji o wydaniach dla obsługiwanych dostawców CLI w menu."; +"show_credits_extra_usage_title" = "Pokaż kredyty + dodatkowe użycie"; +"show_credits_extra_usage_subtitle" = "Pokazuje sekcje Codex Credits i Claude Extra usage w menu."; +"multi_account_layout_title" = "Układ wielu kont"; +"multi_account_layout_subtitle" = "Wybierz przełączanie segmentowe kont lub ułożone karty kont."; +"multi_account_layout_segmented" = "Segmentowy"; +"multi_account_layout_stacked" = "Ułożony"; +"overview_tab_providers_title" = "Dostawcy zakładki Przegląd"; +"configure" = "Skonfiguruj…"; +"overview_enable_merge_icons_hint" = "Włącz Scal ikony, aby skonfigurować dostawców zakładki Przegląd."; +"overview_no_providers_hint" = "Brak włączonych dostawców dla Przeglądu."; +"overview_rows_follow_order" = "Wiersze Przeglądu zawsze podążają za kolejnością dostawców."; +"overview_no_providers_selected" = "Nie wybrano dostawców"; +"agent_sessions_title" = "Sesje agentów"; +"agent_sessions_subtitle" = "Pokazuj w menu lokalne oraz wykryte przez SSH sesje Codex i Claude Code."; +"agent_sessions_hosts_title" = "Dodatkowe hosty SSH"; +"agent_sessions_footer" = "Komputery Mac w twojej sieci tailnet są wykrywane automatycznie. Sesje lokalne są odświeżane co 30 sekund; hosty zdalne co 60 sekund i po otwarciu menu."; +"agent_session_labels_title" = "Etykiety sesji"; +"agent_session_labels_subtitle" = "Wybierz sposób nazywania sesji agentów."; +"agent_session_label_project" = "Projekt"; +"agent_session_label_descriptive" = "Opisowa"; +"agent_session_label_descriptive_and_project" = "Opisowa + projekt"; +"agent_session_unknown_project" = "Nieznany projekt"; + +/* Advanced Pane */ +"section_keyboard_shortcut" = "Skrót klawiaturowy"; +"open_menu_shortcut_title" = "Skrót otwierania menu"; +"open_menu_shortcut_subtitle" = "Skrót klawiaturowy do otwierania menu CodexBar."; +"install_cli" = "Zainstaluj CLI"; +"install_cli_subtitle" = "Utwórz dowiązanie symboliczne CodexBarCLI do /usr/local/bin i /opt/homebrew/bin jako codexbar."; +"cli_not_found" = "Nie znaleziono CodexBarCLI w pakiecie aplikacji."; +"no_writable_bin_dirs" = "Nie znaleziono zapisywalnych katalogów bin."; +"show_debug_settings_title" = "Pokaż ustawienia debugowania"; +"show_debug_settings_subtitle" = "Pokazuje dodatkowe ustawienia debugowania i diagnostyki."; +"surprise_me_title" = "Zaskocz mnie"; +"surprise_me_subtitle" = "Sprawdź, czy lubisz, gdy twoi agenci trochę się tam bawią."; +"hide_personal_info_title" = "Ukryj dane osobowe"; +"hide_personal_info_subtitle" = "Ukrywa adresy e-mail na pasku menu i w interfejsie menu."; +"show_provider_storage_usage_title" = "Pokaż użycie dysku dostawców"; +"show_provider_storage_usage_subtitle" = "Pokazuje lokalne zużycie dysku w menu. Skanuje znane ścieżki dostawców w tle."; +"section_keychain_access" = "Dostęp do pęku kluczy"; +"keychain_access_caption" = "Wyłącz wszystkie odczyty i zapisy pęku kluczy. Użyj tego, jeśli macOS nadal pyta o „Chrome/Brave/Edge Safe Storage” nawet po kliknięciu Zawsze zezwalaj. Import plików cookie przeglądarki będzie niedostępny, gdy opcja jest włączona; wklejaj nagłówki Cookie ręcznie w Dostawcach. OAuth Claude/Codex przez CLI nadal działa."; +"disable_keychain_access_title" = "Wyłącz dostęp do pęku kluczy"; +"disable_keychain_access_subtitle" = "Blokuje wszelki dostęp do pęku kluczy, gdy opcja jest włączona."; + +/* About Pane */ +"about_tagline" = "Niech twoje tokeny nigdy się nie skończą — miej limity agentów zawsze na oku."; +"link_github" = "GitHub"; +"link_website" = "Strona internetowa"; +"link_twitter" = "Twitter"; +"link_email" = "E-mail"; +"check_updates_auto" = "Sprawdzaj aktualizacje automatycznie"; +"update_channel" = "Kanał aktualizacji"; +"check_for_updates" = "Sprawdź aktualizacje…"; +"updates_unavailable" = "Aktualizacje niedostępne w tej wersji."; +"copyright" = "© 2026 Peter Steinberger. Licencja MIT."; + +/* Debug Pane */ +"section_logging" = "Logowanie"; +"enable_file_logging" = "Włącz logowanie do pliku"; +"enable_file_logging_subtitle" = "Zapisuj logi do %@ na potrzeby debugowania."; +"verbosity_title" = "Szczegółowość"; +"verbosity_subtitle" = "Określa poziom szczegółowości logowania."; +"open_log_file" = "Otwórz plik logu"; +"force_animation_next_refresh" = "Wymuś animację przy następnym odświeżeniu"; +"force_animation_next_refresh_subtitle" = "Tymczasowo pokazuje animację ładowania po następnym odświeżeniu."; +"section_loading_animations" = "Animacje ładowania"; +"loading_animations_caption" = "Wybierz wzór i odtwórz go ponownie na pasku menu. „Losowo” zachowuje obecne działanie."; +"animation_random_default" = "Losowo (domyślnie)"; +"replay_selected_animation" = "Odtwórz wybraną animację ponownie"; +"blink_now" = "Mignij teraz"; +"section_probe_logs" = "Logi probe"; +"probe_logs_caption" = "Pobiera najnowszy wynik probe do debugowania; Kopiuj zachowuje pełną treść."; +"fetch_log" = "Pobierz log"; +"copy" = "Kopiuj"; +"save_to_file" = "Zapisz do pliku"; +"load_parse_dump" = "Wczytaj zrzut parsowania"; +"rerun_provider_autodetect" = "Uruchom ponownie automatyczne wykrywanie dostawcy"; +"loading" = "Ładowanie…"; +"no_log_yet_fetch" = "Brak logu. Pobierz, aby wczytać."; +"section_fetch_strategy" = "Strategia pobierania"; +"fetch_strategy_caption" = "Ostatnie decyzje i błędy potoku pobierania dla dostawcy."; +"section_openai_cookies" = "Ciasteczka OpenAI"; +"openai_cookies_caption" = "Logi importu ciasteczek + zrzutu WebKit z ostatniej próby odczytu ciasteczek OpenAI."; +"no_log_yet" = "Brak logu. Zaktualizuj ciasteczka OpenAI w Dostawcy → Codex, aby uruchomić import."; +"section_caches" = "Pamięci podręczne"; +"caches_caption" = "Wyczyść zapisane wyniki skanowania kosztów lub pamięci podręczne plików cookie przeglądarki."; +"clear_cookie_cache" = "Wyczyść pamięć podręczną cookie"; +"clear_cost_cache" = "Wyczyść pamięć podręczną kosztów"; +"section_notifications" = "Powiadomienia"; +"notifications_caption" = "Wyzwala testowe powiadomienia dla 5-godzinnego okna sesji (wyczerpanie/przywrócenie)."; +"post_depleted" = "Wyślij wyczerpanie"; +"post_restored" = "Wyślij przywrócenie"; +"section_cli_sessions" = "Sesje CLI"; +"cli_sessions_caption" = "Utrzymuj sesje CLI Codex/Claude aktywne po probe. Domyślnie kończą się po przechwyceniu danych."; +"keep_cli_sessions_alive" = "Utrzymuj sesje CLI aktywne"; +"keep_cli_sessions_alive_subtitle" = "Pomiń sprzątanie między probe'ami (tylko do debugowania)."; +"reset_cli_sessions" = "Zresetuj sesje CLI"; +"section_error_simulation" = "Symulacja błędów"; +"error_simulation_caption" = "Wstrzykuje fałszywy komunikat błędu do karty menu na potrzeby testów układu."; +"set_menu_error" = "Ustaw błąd menu"; +"clear_menu_error" = "Wyczyść błąd menu"; +"set_cost_error" = "Ustaw błąd kosztów"; +"clear_cost_error" = "Wyczyść błąd kosztów"; +"section_cli_paths" = "Ścieżki CLI"; +"cli_paths_caption" = "Rozpoznany plik binarny Codex i warstwy PATH; przechwycony PATH logowania przy starcie (krótki limit czasu)."; +"codex_binary" = "Plik binarny Codex"; +"claude_binary" = "Plik binarny Claude"; +"effective_path" = "Efektywny PATH"; +"unavailable" = "Niedostępne"; +"login_shell_path" = "PATH powłoki logowania (przechwycony przy starcie)"; +"cleared" = "Wyczyszczono."; +"no_fetch_attempts" = "Brak prób pobrania."; +"macOS Tahoe can block menu bar apps in System Settings → Menu Bar → Allow in the Menu Bar. CodexBar is running, but macOS may be hiding its icon. Open Menu Bar settings and turn CodexBar on." = "macOS Tahoe can block menu bar apps in System Settings → Menu Bar → Allow in the Menu Bar. CodexBar is running, but macOS may be hiding its icon. Open Menu Bar settings and turn CodexBar on."; + +/* Metric preferences */ +"metric_pref_automatic" = "Automatycznie"; +"metric_pref_primary" = "Główny"; +"metric_pref_secondary" = "Wtórny"; +"metric_pref_tertiary" = "Trzeciorzędny"; +"metric_pref_extra_usage" = "Dodatkowe użycie"; +"metric_pref_average" = "Średnia"; +"metric_mistral_payg" = "Pay-as-you-go"; +"metric_mistral_monthly_plan" = "Monthly Plan"; + +/* Display modes */ +"display_mode_percent" = "Procent"; +"display_mode_pace" = "Tempo"; +"display_mode_both" = "Oba"; +"display_mode_reset_time" = "Godzina resetu"; +"display_mode_percent_desc" = "Pokaż procent pozostałego/wykorzystanego limitu (np. 45%)"; +"display_mode_pace_desc" = "Pokaż wskaźnik tempa (np. +5%)"; +"display_mode_both_desc" = "Pokaż jednocześnie procent i tempo (np. 45% · +5%)"; +"display_mode_reset_time_desc" = "Pokaż godzinę resetu dla wybranej metryki (np. ↻ 15:56)"; +"menu_bar_reset_when_exhausted_title" = "Pokaż czas resetu, gdy limit się wyczerpie"; +"menu_bar_reset_when_exhausted_subtitle" = "Przy 0% pozostałych pokazuje czas do resetu zamiast wartości procentowej"; + +/* Provider status */ +"status_operational" = "Operacyjne"; +"status_degraded" = "Obniżona wydajność"; +"status_partial_outage" = "Częściowa awaria"; +"status_major_outage" = "Poważna awaria"; +"status_critical_issue" = "Krytyczny problem"; +"status_maintenance" = "Prace serwisowe"; +"status_unknown" = "Nieznany status"; + +/* Refresh frequency */ +"refresh_manual" = "Ręcznie"; +"refresh_1min" = "Co 1 min"; +"refresh_2min" = "Co 2 min"; +"refresh_5min" = "Co 5 min"; +"refresh_15min" = "Co 15 min"; +"refresh_30min" = "Co 30 min"; +"refresh_adaptive" = "Adaptacyjny"; +"refresh_adaptive_agent_aware" = "Adaptacyjny (aktywność agentów)"; +"adaptive_activity_consent_title" = "Zezwolić na odświeżanie uwzględniające aktywność?"; +"adaptive_activity_consent_message" = "Tryb adaptacyjny uwzględniający aktywność agentów może sprawdzać listę uruchomionych procesów lokalnych, w tym wiersze poleceń, aby rozpoznać Codex i Claude, a następnie podczas programowania co 30 sekund odczytywać metadane znanych sesji. Gdy Agent Sessions jest wyłączone, CodexBar używa w pamięci tylko czasu ostatniej aktywności i odrzuca ścieżki oraz tożsamości sesji. Te dane nie są nigdzie wysyłane, a wykrywanie zdalne i SSH pozostają wyłączone. Jeśli odmówisz, CodexBar wróci do zwykłego trybu adaptacyjnego bez skanowania aktywności lokalnej."; +"adaptive_activity_consent_allow" = "Zezwól na lokalną aktywność"; +"adaptive_activity_consent_decline" = "Używaj zwykłego Adaptacyjnego"; + +/* Additional keys */ +"not_found" = "Nie znaleziono"; + +/* Cost estimation */ +"cost_estimate_hint" = "Oszacowano na podstawie lokalnych logów · może różnić się od rachunku"; +"codex_api_estimate_hint" = "Oszacowano na podstawie użycia tokenów · to nie jest rachunek za subskrypcję"; +"cost_data_explanation" = "Koszty mogą być raportowane przez dostawcę lub szacowane na podstawie użycia tokenów według publicznych cen API. Szacunki nie są opłatami za subskrypcję."; +"No JetBrains IDE with AI Assistant detected. Install a JetBrains IDE and enable AI Assistant." = "Nie wykryto IDE JetBrains z AI Assistant. Zainstaluj IDE JetBrains i włącz AI Assistant."; +"OpenRouter API token not configured. Set OPENROUTER_API_KEY environment variable or configure in Settings." = "Token API OpenRouter nie jest skonfigurowany. Ustaw zmienną środowiskową OPENROUTER_API_KEY albo skonfiguruj go w Ustawieniach."; +"z.ai API token not found. Set apiKey in ~/.codexbar/config.json or Z_AI_API_KEY." = "Nie znaleziono tokenu API z.ai. Ustaw `apiKey` w ~/.codexbar/config.json albo Z_AI_API_KEY."; +"Missing DeepSeek API key." = "Brakuje klucza API DeepSeek."; +"%@ is unavailable in the current environment." = "%@ jest niedostępne w bieżącym środowisku."; +"All Systems Operational" = "Wszystkie systemy działają"; +"Last 30 days" = "Ostatnie 30 dni"; +"Last 30 days:" = "Ostatnie 30 dni:"; +"This month" = "Ten miesiąc"; +"Store multiple OpenAI API keys." = "Przechowuj wiele kluczy API OpenAI."; +"Admin API key" = "Klucz API administratora"; +"Open billing" = "Otwórz rozliczenia"; +"Google accounts" = "Konta Google"; +"Store multiple Antigravity Google OAuth accounts for quick switching." = "Przechowuj wiele kont Google OAuth Antigravity, aby szybko je przełączać."; +"Add Google Account" = "Dodaj konto Google"; +"Open Token Plan" = "Otwórz Token Plan"; +"Text Generation" = "Generowanie tekstu"; +"Text to Speech" = "Synteza mowy"; +"Music Generation" = "Generowanie muzyki"; +"Image Generation" = "Generowanie obrazów"; +"No local data found" = "Nie znaleziono danych lokalnych"; +"Credits unavailable; keep Codex running to refresh." = "Kredyty są niedostępne; pozostaw Codex uruchomiony, aby je odświeżyć."; +"No available fetch strategy for minimax." = "Brak dostępnej strategii pobierania dla minimax."; +"No Cursor session found. Please log in to cursor.com in Safari, Chrome, Microsoft Edge, Brave, Arc, Dia, ChatGPT Atlas, Chromium, Helium, Vivaldi, Yandex Browser, Firefox, Zen, Colibri, Sidekick, Opera, Opera GX, or Edge Canary. If you use Safari, grant CodexBar Full Disk Access in System Settings ▸ Privacy & Security. You can also sign in to Cursor from the CodexBar menu (Add / switch account)." = "Nie znaleziono sesji Cursor. Zaloguj się do cursor.com w Safari, Chrome, Microsoft Edge, Brave, Arc, Dia, ChatGPT Atlas, Chromium, Helium, Vivaldi, Yandex Browser, Firefox, Zen, Colibri, Sidekick, Opera, Opera GX lub Edge Canary. Jeśli używasz Safari, przyznaj CodexBar pełny dostęp do dysku w Ustawieniach systemowych ▸ Prywatność i bezpieczeństwo. Możesz też zalogować się do Cursor z menu CodexBar (Dodaj / przełącz konto)."; +"No OpenCode session cookies found in browsers." = "Nie znaleziono w przeglądarkach plików cookie sesji OpenCode."; +"No available fetch strategy for %@." = "Brak dostępnej strategii pobierania dla %@."; +"Today" = "Dziś"; +"Today tokens" = "Dzisiejsze tokeny"; +"30d cost" = "Koszt 30 dni"; +"%@ cost" = "Koszt %@"; +"30d tokens" = "Tokeny 30 dni"; +"Latest tokens" = "Najnowsze tokeny"; +"Top model" = "Najlepszy model"; +"Storage" = "Pamięć"; +"Add Account..." = "Dodaj konto..."; +"Usage Dashboard" = "Panel użycia"; +"Status Page" = "Strona statusu"; +"Open Status Page" = "Otwórz stronę stanu"; +"Settings..." = "Ustawienia..."; +"About CodexBar" = "O CodexBar"; +"Quit" = "Zakończ"; +"Last %d day" = "Ostatni %d dzień"; +"Last %d days" = "Ostatnie %d dni"; +"%@ tokens" = "%@ tokenów"; +"Latest billing day" = "Najnowszy dzień rozliczeniowy"; +"Latest billing day (%@)" = "Najnowszy dzień rozliczeniowy (%@)"; +"%@ left" = "%@ pozostało"; +"Resets %@" = "Reset %@"; +"Resets in %@" = "Reset za %@"; +"Resets now" = "Reset teraz"; +"reset_tomorrow_format" = "jutro, %@"; +"Lasts until reset" = "Wystarcza do resetu"; +"1.5× headroom" = "zapas 1,5×"; +"Updated %@" = "Zaktualizowano %@"; +"Updated relative %@" = "Zaktualizowano %@"; +"Updated absolute %@" = "Zaktualizowano %@"; +"Updated %@h ago" = "Zaktualizowano %@ godz. temu"; +"Updated %@m ago" = "Zaktualizowano %@ min temu"; +"Updated just now" = "Zaktualizowano przed chwilą"; +"Projected empty in %@" = "Szacowane wyczerpanie za %@"; +"Runs out in %@" = "Skończy się za %@"; +"Pace: %@" = "Tempo: %@"; +"Pace: %@ · %@" = "Tempo: %@ · %@"; +"%@ · %@" = "%@ · %@"; +"≈ %d%% run-out risk" = "≈ %d%% ryzyka wyczerpania"; +"%d%% in deficit" = "%d%% deficytu"; +"%d%% in reserve" = "%d%% rezerwy"; +"usage_percent_suffix_left" = "pozostało"; +"usage_percent_suffix_used" = "wykorzystano"; +"Store multiple DeepSeek API keys." = "Przechowuj wiele kluczy API DeepSeek."; +"This week" = "Ten tydzień"; +"Week" = "Tydzień"; +"Month" = "Miesiąc"; +"Models" = "Modele"; +"24h tokens" = "Tokeny 24h"; +"Latest hour" = "Ostatnia godzina"; +"Peak hour" = "Szczytowa godzina"; +"Top method" = "Najlepsza metoda"; +"30d cash" = "Gotówka 30 dni"; +"30d billing history from MiniMax web session" = "30-dniowa historia rozliczeń z sesji webowej MiniMax"; +"AWS Cost Explorer billing can lag." = "Dane rozliczeń AWS Cost Explorer mogą być opóźnione."; +"Rate limit: %d / %@" = "Limit żądań: %d / %@"; +"Key remaining" = "Pozostało klucza"; +"No limit set for the API key" = "Nie ustawiono limitu dla klucza API"; +"API key limit unavailable right now" = "Limit klucza API jest teraz niedostępny"; +"This month: %@ tokens" = "Ten miesiąc: %@ tokenów"; +"No utilization data yet." = "Brak jeszcze danych wykorzystania."; +"No %@ utilization data yet." = "Brak jeszcze danych wykorzystania dla %@."; +"%@: %@%% used" = "%@: wykorzystano %@%%"; +"%dd" = "%d d"; +"today" = "dzisiaj"; +"just now" = "przed chwilą"; +"On pace" = "Zgodnie z tempem"; +"Runs out now" = "Kończy się teraz"; +"Projected empty now" = "Szacowane wyczerpanie teraz"; +"Switch Account..." = "Przełącz konto..."; +"Update ready, restart now?" = "Aktualizacja gotowa, uruchomić ponownie teraz?"; +"Daily" = "Dziennie"; +"Hourly Tokens" = "Tokeny godzinowe"; +"No data" = "Brak danych"; +"No usage breakdown data available." = "Brak dostępnych danych podziału użycia."; + +"Today: %@ · %@ tokens" = "Dziś: %@ · %@ tokenów"; +"Today: %@" = "Dziś: %@"; +"Today: %@ tokens" = "Dziś: %@ tokenów"; +"Last 30 days: %@ · %@ tokens" = "Ostatnie 30 dni: %@ · %@ tokenów"; +"Last 30 days: %@" = "Ostatnie 30 dni: %@"; +"Est. total (30d): %@" = "Szac. łączna wartość (30 dni): %@"; +"Est. total (%@): %@" = "Szac. łączna wartość (%@): %@"; +"Hover a bar for details" = "Najedź na słupek, aby zobaczyć szczegóły"; +"%@: %@ · %@ tokens" = "%@: %@ · %@ tokenów"; +"%@: %@" = "%@: %@"; +"No providers selected for Overview." = "Nie wybrano dostawców dla Przeglądu."; +"No overview data available." = "Brak dostępnych danych Przeglądu."; +"Auto uses the local IDE API first, then Google OAuth when the IDE is closed." = "Tryb Auto najpierw używa lokalnego API IDE, a potem Google OAuth, gdy IDE jest zamknięte."; +"Login with Google" = "Zaloguj się przez Google"; + +/* Popup panels */ +"No usage configured." = "Nie skonfigurowano użycia."; +"Quota" = "Limit"; +"Daily quota" = "Limit dzienny"; +"Total" = "Łącznie"; +"tokens" = "tokeny"; +"requests" = "żądania"; +"Latest" = "Najnowsze"; +"Monthly" = "Miesięcznie"; +"Sonnet" = "Sonnet"; +"Overages" = "Nadwyżki"; +"Activity" = "Aktywność"; +"Copied" = "Skopiowano"; +"Copy error" = "Błąd kopiowania"; +"Copy path" = "Skopiuj ścieżkę"; +"Extra usage spent" = "Wydane dodatkowe użycie"; +"Credits remaining" = "Pozostałe kredyty"; +"Using CLI fallback" = "Używane jest awaryjne CLI"; +"Balance updates in near-real time (up to 5 min lag)" = "Saldo aktualizuje się prawie w czasie rzeczywistym (opóźnienie do 5 min)"; +"Daily billing data finalizes at 07:00 UTC" = "Dzienne dane rozliczeniowe finalizują się o 07:00 UTC"; +"%@ of %@ credits left" = "%@ z %@ kredytów pozostało"; +"%@ of %@ bonus credits left" = "%@ z %@ bonusowych kredytów pozostało"; +"%@ / %@ (%@ remaining)" = "%@ / %@ (%@ pozostało)"; +"%@/%@ left" = "%@/%@ pozostało"; +"Gemini Flash" = "Gemini Flash"; +"Regenerates %@" = "Regeneruje się %@"; +"used after next regen" = "wykorzystano po następnym odnowieniu"; +"after next regen" = "po następnym odnowieniu"; +"Near full" = "Prawie pełne"; +"Full in ~1 regen" = "Pełne za ~1 odnowienie"; +"Full in ~%.0f regens" = "Pełne za ~%.0f odnowień"; +"Overage usage" = "Użycie nadwyżki"; +"Overage cost" = "Koszt nadwyżki"; +"credits" = "kredyty"; +"Zen balance" = "Saldo Zen"; +"API spend" = "Wydatki API"; +"Extra usage" = "Dodatkowe użycie"; +"Quota usage" = "Wykorzystanie limitu"; +"Your spend" = "Twoje wydatki"; +"%.0f%% used" = "wykorzystano %.0f%%"; +"Usage history (today)" = "Historia użycia (dzisiaj)"; +"Usage history (%d days)" = "Historia użycia (%d dni)"; +"%d percent remaining" = "pozostało %d procent"; +"Unknown" = "Nieznane"; +"stale data" = "nieaktualne dane"; +"No credits history data." = "Brak danych historii kredytów."; +"No credits history data available." = "Brak dostępnych danych historii kredytów."; +"Credits history chart" = "Wykres historii kredytów"; +"%d days of credits data" = "%d dni danych kredytów"; +"Usage breakdown chart" = "Wykres podziału użycia"; +"%d days of usage data across %d services" = "%d dni danych użycia dla %d usług"; +"Cost history chart" = "Wykres historii kosztów"; +"%d days of cost data" = "%d dni danych kosztów"; +"Plan utilization chart" = "Wykres wykorzystania planu"; +"%d utilization samples" = "%d próbek wykorzystania"; +"Hourly Usage" = "Użycie godzinowe"; +"Usage remaining" = "Pozostałe użycie"; +"Usage used" = "Wykorzystane użycie"; +"API key verified. Cloud quotas need browser cookies. Sign in to Ollama." = "Klucz API został zweryfikowany. Limity Cloud wymagają plików cookie przeglądarki. Zaloguj się do Ollama."; +"Last 30 days: %@ tokens" = "Ostatnie 30 dni: %@ tokenów"; +"7d spend" = "Wydatki 7 dni"; +"30d spend" = "Wydatki 30 dni"; +"Cache read" = "Odczyt z pamięci podręcznej"; +"Claude Admin API 30 day spend trend" = "Trend wydatków 30 dni Claude Admin API"; +"OpenRouter API key spend trend" = "Trend wydatków klucza API OpenRouter"; +"z.ai hourly token trend" = "Godzinowy trend tokenów z.ai"; +"MiniMax 30 day token usage trend" = "Trend użycia tokenów MiniMax z 30 dni"; +"Today cash" = "Dzisiejsza gotówka"; +"DeepSeek 30 day token usage trend" = "Trend użycia tokenów DeepSeek z 30 dni"; +"DeepSeek this month token usage trend" = "Trend użycia tokenów DeepSeek w tym miesiącu"; +"Chrome profile" = "Profil Chrome"; +"Choose which signed-in DeepSeek Platform session supplies detailed usage." = "Wybierz zalogowaną sesję DeepSeek Platform, która ma dostarczać szczegółowe dane użycia."; +"Detailed usage unavailable." = "Szczegółowe dane użycia są niedostępne."; +"Sign in to DeepSeek Platform in Chrome for detailed usage." = "Zaloguj się do DeepSeek Platform w Chrome, aby uzyskać szczegółowe dane użycia."; +"Select a DeepSeek Chrome profile in Settings." = "Wybierz profil Chrome DeepSeek w Ustawieniach."; +"Select profile…" = "Wybierz profil…"; +"cache-hit input" = "wejście trafienia pamięci podręcznej"; +"cache-miss input" = "wejście chybienia pamięci podręcznej"; +"output" = "wynik"; +"Requests" = "Żądania"; +"Reported by OpenAI Admin API organization usage." = "Zgłoszone przez użycie organizacji w OpenAI Admin API."; +"Reported by Mistral billing usage." = "Zgłoszone przez dane rozliczeniowe Mistral."; +"Google OAuth" = "Uwierzytelnianie Google OAuth"; +"Add accounts via GitHub OAuth Device Flow on the selected host." = "Dodawaj konta przez GitHub OAuth Device Flow na wybranym hoście."; +"Stores each signed-in Google account for quick Antigravity switching. Uses Antigravity.app OAuth when available, or ANTIGRAVITY_OAUTH_CLIENT_ID and ANTIGRAVITY_OAUTH_CLIENT_SECRET as an override." = "Przechowuje każde zalogowane konto Google, aby szybko przełączać Antigravity. Używa OAuth z Antigravity.app, gdy jest dostępne, albo ANTIGRAVITY_OAUTH_CLIENT_ID i ANTIGRAVITY_OAUTH_CLIENT_SECRET jako nadpisania."; +"Manual cleanup: past sessions" = "Ręczne czyszczenie: poprzednie sesje"; +"Clearing removes past resume, continue, and rewind history." = "Czyszczenie usuwa historię wcześniejszych wznowień, kontynuacji i cofnięć."; +"Manual cleanup: file checkpoints" = "Ręczne czyszczenie: punkty kontrolne plików"; +"Clearing removes checkpoint restore data for previous edits." = "Czyszczenie usuwa dane przywracania punktów kontrolnych dla wcześniejszych edycji."; +"Manual cleanup: saved plans" = "Ręczne czyszczenie: zapisane plany"; +"Clearing removes old plan-mode files." = "Czyszczenie usuwa stare pliki trybu planu."; +"Manual cleanup: debug logs" = "Ręczne czyszczenie: logi debugowania"; +"Clearing removes past debug logs." = "Czyszczenie usuwa wcześniejsze logi debugowania."; +"Manual cleanup: attachment cache" = "Ręczne czyszczenie: pamięć podręczna załączników"; +"Clearing removes cached large pastes or attached images." = "Czyszczenie usuwa zapisane duże wklejki lub dołączone obrazy."; +"Manual cleanup: session metadata" = "Ręczne czyszczenie: metadane sesji"; +"Clearing removes per-session environment metadata." = "Czyszczenie usuwa metadane środowiska przypisane do każdej sesji."; +"Manual cleanup: shell snapshots" = "Ręczne czyszczenie: migawki powłoki"; +"Clearing removes leftover runtime shell snapshot files." = "Czyszczenie usuwa pozostałe pliki migawek powłoki środowiska uruchomieniowego."; +"Manual cleanup: legacy todos" = "Ręczne czyszczenie: stare listy zadań"; +"Clearing removes legacy per-session task lists." = "Czyszczenie usuwa starsze listy zadań przypisane do sesji."; +"Manual cleanup: sessions" = "Ręczne czyszczenie: sesje"; +"Clearing removes past Codex session history." = "Czyszczenie usuwa historię poprzednich sesji Codex."; +"Manual cleanup: archived sessions" = "Ręczne czyszczenie: zarchiwizowane sesje"; +"Clearing removes archived Codex session history." = "Czyszczenie usuwa historię zarchiwizowanych sesji Codex."; +"Manual cleanup: cache" = "Ręczne czyszczenie: pamięć podręczna"; +"Clearing removes provider-owned cached data." = "Czyszczenie usuwa dane pamięci podręcznej należące do dostawców."; +"Manual cleanup: logs" = "Ręczne czyszczenie: logi"; +"Clearing removes local diagnostic logs." = "Czyszczenie usuwa lokalne logi diagnostyczne."; +"Manual cleanup: file history" = "Ręczne czyszczenie: historia plików"; +"Clearing removes local edit checkpoint history." = "Czyszczenie usuwa lokalną historię punktów kontrolnych edycji."; +"Manual cleanup: temporary data" = "Ręczne czyszczenie: dane tymczasowe"; +"Clearing removes local temporary provider data." = "Czyszczenie usuwa lokalne tymczasowe dane dostawców."; +"Total: %@" = "Łącznie: %@"; +"%d more items" = "Jeszcze %d pozycji"; +"Other (%d items)" = "Inne (%d elementów)"; +"Expand" = "Rozwiń"; +"Collapse" = "Zwiń"; +"Cleanup ideas" = "Pomysły na czyszczenie"; +"%d unreadable item(s) skipped" = "Pominięto %d nieczytelnych elementów"; + +"API key limit" = "Limit klucza API"; +"Auth" = "Uwierzytelnianie"; +"Auto" = "Automatycznie"; +"Disabled — no recent data" = "Wyłączone — brak ostatnich danych"; +"Limits not available" = "Limity niedostępne"; +"No usage yet" = "Brak użycia"; +"Not fetched yet" = "Jeszcze nie pobrano"; +"Refreshing" = "Odświeżanie"; +"Session" = "Sesja"; +"Source" = "Źródło"; +"State" = "Stan"; +"Unavailable" = "Niedostępne"; +"Weekly" = "Tydzień"; +"not detected" = "nie wykryto"; +"Estimated from local Codex logs for the selected account." = "Oszacowano na podstawie lokalnych logów Codex dla wybranego konta."; +"minimax_usage_amount_format" = "Użycie: %@ / %@"; +"minimax_used_percent_format" = "Wykorzystano %@"; +"minimax_service_text_generation" = "Generowanie tekstu"; +"minimax_service_text_to_speech" = "Synteza mowy"; +"minimax_service_music_generation" = "Generowanie muzyki"; +"minimax_service_image_generation" = "Generowanie obrazów"; +"minimax_service_lyrics_generation" = "Generowanie tekstów piosenek"; +"minimax_service_coding_plan_vlm" = "VLM planu kodowania"; +"minimax_service_coding_plan_search" = "Wyszukiwanie Coding Plan"; + +/* Additional provider settings and alerts */ +"%@ is waiting for permission" = "%@ czeka na pozwolenie"; +"%@ requests" = "%@ żądań"; +"%@: %@ credits" = "%@: %@ kredytów"; +"30d requests" = "Żądania 30 dni"; +"4 days" = "4 dni"; +"5 days" = "5 dni"; +"7 days" = "7 dni"; +"API key verifies Ollama Cloud access; cookies still expose quota limits." = "Klucz API potwierdza dostęp do Ollama Cloud; limity nadal są widoczne przez pliki cookie."; +"AWS access key ID. Can also be set with AWS_ACCESS_KEY_ID." = "Identyfikator klucza dostępu AWS. Można też ustawić przez AWS_ACCESS_KEY_ID."; +"AWS region. Can also be set with AWS_REGION." = "Region AWS. Można też ustawić przez AWS_REGION."; +"AWS secret access key. Can also be set with AWS_SECRET_ACCESS_KEY." = "Tajny klucz dostępu AWS. Można też ustawić przez AWS_SECRET_ACCESS_KEY."; +"Access key ID" = "Identyfikator klucza dostępu"; +"Add Account" = "Dodaj konto"; +"Adding Account…" = "Dodawanie konta…"; +"Antigravity login failed" = "Logowanie do Antigravity nie powiodło się"; +"Antigravity login timed out" = "Przekroczono limit czasu logowania do Antigravity"; +"Auth source" = "Źródło uwierzytelniania"; +"Automatic imports browser cookies from Xiaomi MiMo." = "Automatycznie importuje pliki cookie przeglądarki z Xiaomi MiMo."; +"Automatic imports Windsurf session data from Chromium browser localStorage." = "Automatycznie importuje dane sesji Windsurf z localStorage przeglądarki Chromium."; +"Automatic imports browser cookies from Bailian." = "Automatycznie importuje pliki cookie przeglądarki z Bailian."; +"Automatically imports browser cookies." = "Automatycznie importuje pliki cookie przeglądarki."; +"Automatically imports browser session cookies." = "Automatycznie importuje pliki cookie sesji przeglądarki."; +"Azure OpenAI deployment name. AZURE_OPENAI_DEPLOYMENT_NAME is also supported." = "Nazwa wdrożenia Azure OpenAI. Obsługiwane jest także AZURE_OPENAI_DEPLOYMENT_NAME."; +"Azure OpenAI key" = "Klucz Azure OpenAI"; +"Azure OpenAI resource endpoint. AZURE_OPENAI_ENDPOINT is also supported." = "Punkt końcowy zasobu Azure OpenAI. Obsługiwane jest także AZURE_OPENAI_ENDPOINT."; +"Base URL" = "Bazowy URL"; +"Base URL for the LLM-API-Key-Proxy instance." = "Bazowy URL dla instancji LLM-API-Key-Proxy."; +"Browser cookies" = "Pliki cookie przeglądarki"; +"Cap end" = "Koniec limitu"; +"Cap start" = "Początek limitu"; +"Capacity End" = "Koniec pojemności"; +"Capacity Start" = "Początek pojemności"; +"Changelog" = "Dziennik zmian"; +"Choose the Moonshot/Kimi API host for international or China mainland accounts." = "Wybierz host API Moonshot/Kimi dla kont międzynarodowych lub z Chin kontynentalnych."; +"CodexBar can't replace a system account that is signed in with an API key only setup." = "CodexBar nie może zastąpić konta systemowego zalogowanego wyłącznie przez konfigurację klucza API."; +"CodexBar could not find saved auth for that account. Re-authenticate it and try again." = "CodexBar nie mógł znaleźć zapisanego uwierzytelnienia dla tego konta. Uwierzytelnij je ponownie i spróbuj jeszcze raz."; +"CodexBar could not read managed account storage. Recover the store before adding another account." = "CodexBar nie mógł odczytać magazynu zarządzanych kont. Napraw magazyn przed dodaniem kolejnego konta."; +"CodexBar could not read saved auth for that account. Re-authenticate it and try again." = "CodexBar nie mógł odczytać zapisanego uwierzytelnienia dla tego konta. Uwierzytelnij je ponownie i spróbuj jeszcze raz."; +"CodexBar could not read the current system account on this Mac." = "CodexBar nie mógł odczytać bieżącego konta systemowego na tym Macu."; +"CodexBar could not replace the live Codex auth on this Mac." = "CodexBar nie mógł zastąpić aktywnego uwierzytelnienia Codex na tym Macu."; +"CodexBar could not safely preserve the current system account before switching." = "CodexBar nie mógł bezpiecznie zachować bieżącego konta systemowego przed przełączeniem."; +"CodexBar could not save the current system account before switching." = "CodexBar nie mógł zapisać bieżącego konta systemowego przed przełączeniem."; +"CodexBar could not update managed account storage." = "CodexBar nie mógł zaktualizować magazynu zarządzanych kont."; +"CodexBar found another managed account that already uses the current system account. Resolve the duplicate account before switching." = "CodexBar znalazł inne zarządzane konto, które już używa bieżącego konta systemowego. Rozwiąż duplikat konta przed przełączeniem."; +"CodexBar will ask macOS Keychain for “%@” so it can decrypt browser cookies and authenticate your account. Click OK to continue." = "CodexBar poprosi pęk kluczy macOS o „%@”, aby odszyfrować pliki cookie przeglądarki i uwierzytelnić twoje konto. Kliknij OK, aby kontynuować."; +"CodexBar will ask macOS Keychain for the Claude Code OAuth token so it can fetch your Claude usage. Click OK to continue." = "CodexBar poprosi pęk kluczy macOS o token OAuth Claude Code, aby pobrać twoje użycie Claude. Kliknij OK, aby kontynuować."; +"CodexBar will ask macOS Keychain for your Amp cookie header so it can fetch usage. Click OK to continue." = "CodexBar poprosi pęk kluczy macOS o nagłówek Cookie Amp, aby pobrać użycie. Kliknij OK, aby kontynuować."; +"CodexBar will ask macOS Keychain for your Augment cookie header so it can fetch usage. Click OK to continue." = "CodexBar poprosi pęk kluczy macOS o nagłówek Cookie Augment, aby pobrać użycie. Kliknij OK, aby kontynuować."; +"CodexBar will ask macOS Keychain for your Claude cookie header so it can fetch Claude web usage. Click OK to continue." = "CodexBar poprosi pęk kluczy macOS o nagłówek Cookie Claude, aby pobrać webowe użycie Claude. Kliknij OK, aby kontynuować."; +"CodexBar will ask macOS Keychain for your Cursor cookie header so it can fetch usage. Click OK to continue." = "CodexBar poprosi pęk kluczy macOS o nagłówek Cookie Cursor, aby pobrać użycie. Kliknij OK, aby kontynuować."; +"CodexBar will ask macOS Keychain for your Factory cookie header so it can fetch usage. Click OK to continue." = "CodexBar poprosi pęk kluczy macOS o nagłówek Cookie Factory, aby pobrać użycie. Kliknij OK, aby kontynuować."; +"CodexBar will ask macOS Keychain for your GitHub Copilot token so it can fetch usage. Click OK to continue." = "CodexBar poprosi pęk kluczy macOS o token GitHub Copilot, aby pobrać użycie. Kliknij OK, aby kontynuować."; +"CodexBar will ask macOS Keychain for your Kimi auth token so it can fetch usage. Click OK to continue." = "CodexBar poprosi pęk kluczy macOS o token uwierzytelniania Kimi, aby pobrać użycie. Kliknij OK, aby kontynuować."; +"CodexBar will ask macOS Keychain for your MiniMax API token so it can fetch usage. Click OK to continue." = "CodexBar poprosi pęk kluczy macOS o token API MiniMax, aby pobrać użycie. Kliknij OK, aby kontynuować."; +"CodexBar will ask macOS Keychain for your MiniMax cookie header so it can fetch usage. Click OK to continue." = "CodexBar poprosi pęk kluczy macOS o nagłówek Cookie MiniMax, aby pobrać użycie. Kliknij OK, aby kontynuować."; +"CodexBar will ask macOS Keychain for your OpenAI cookie header so it can fetch Codex dashboard extras. Click OK to continue." = "CodexBar poprosi pęk kluczy macOS o nagłówek Cookie OpenAI, aby pobrać dodatki panelu Codex. Kliknij OK, aby kontynuować."; +"CodexBar will ask macOS Keychain for your OpenCode cookie header so it can fetch usage. Click OK to continue." = "CodexBar poprosi pęk kluczy macOS o nagłówek Cookie OpenCode, aby pobrać użycie. Kliknij OK, aby kontynuować."; +"CodexBar will ask macOS Keychain for your Synthetic API key so it can fetch usage. Click OK to continue." = "CodexBar poprosi pęk kluczy macOS o klucz API Synthetic, aby pobrać użycie. Kliknij OK, aby kontynuować."; +"CodexBar will ask macOS Keychain for your z.ai API token so it can fetch usage. Click OK to continue." = "CodexBar poprosi pęk kluczy macOS o token API z.ai, aby pobrać użycie. Kliknij OK, aby kontynuować."; +"Could not open Cursor login in your browser." = "Nie udało się otworzyć logowania Cursor w przeglądarce."; +"Could not open browser for Antigravity" = "Nie udało się otworzyć przeglądarki dla Antigravity"; +"Credits used" = "Wykorzystane kredyty"; +"Day" = "Dzień"; +"Deployment" = "Wdrożenie"; +"Drag to reorder" = "Przeciągnij, aby zmienić kolejność"; +"Sort providers alphabetically" = "Sortuj dostawców alfabetycznie"; +"Sort providers alphabetically (enabled first)" = "Sortuj dostawców alfabetycznie (włączeni najpierw)"; +"Sorted alphabetically (enabled first) — click to use your custom order" = "Posortowano alfabetycznie (włączeni najpierw) — kliknij, aby użyć własnej kolejności"; +"Endpoint" = "Punkt końcowy"; +"Enterprise host" = "Host Enterprise"; +"Extra usage balance: %@" = "Saldo dodatkowego użycia: %@"; +"Keychain Access Required" = "Wymagany dostęp do pęku kluczy"; +"keychain_prompt_learn_more" = "Dowiedz się więcej…"; +"keychain_prompt_privacy_note" = "Hasło logowania do Maca jest obsługiwane przez macOS, a nie CodexBar. Dostęp do pęku kluczy możesz wyłączyć w dowolnym momencie w Ustawienia → Zaawansowane."; +"Kiro menu bar value" = "Wartość paska menu Kiro"; +"Label" = "Etykieta"; +"No organizations loaded. Click Refresh after setting your API key." = "Nie wczytano organizacji. Kliknij Odśwież po ustawieniu klucza API."; +"No output captured." = "Nie przechwycono danych wyjściowych."; +"No system account" = "Brak konta systemowego"; +"Oasis-Token" = "Token Oasis"; +"Open Augment (Log Out & Back In)" = "Otwórz Augment (wyloguj się i zaloguj ponownie)"; +"Open Codebuff Dashboard" = "Otwórz panel Codebuff"; +"Open Command Code Settings" = "Otwórz ustawienia Command Code"; +"Open Crof dashboard" = "Otwórz panel Crof"; +"Open Manus" = "Otwórz Manus"; +"Open MiMo Balance" = "Otwórz saldo MiMo"; +"Open Moonshot Console" = "Otwórz konsolę Moonshot"; +"Open Ollama API Keys" = "Otwórz klucze API Ollama"; +"Open StepFun Platform" = "Otwórz platformę StepFun"; +"Open T3 Chat Settings" = "Otwórz ustawienia T3 Chat"; +"Open Volcengine Ark Console" = "Otwórz konsolę Volcengine Ark"; +"Open legacy provider docs" = "Otwórz dokumentację starszych dostawców"; +"Open projects" = "Otwórz projekty"; +"Open this URL manually to continue login:\n\n%@" = "Otwórz ten URL ręcznie, aby kontynuować logowanie:\n\n%@"; +"Optional organization ID for accounts linked to multiple Anthropic organizations." = "Opcjonalny identyfikator organizacji dla kont powiązanych z wieloma organizacjami Anthropic."; +"Optional. Applies to the configured Admin API key; selected token accounts do not inherit OPENAI_PROJECT_ID." = "Opcjonalne. Dotyczy skonfigurowanego klucza API administratora; wybrane konta tokenów nie dziedziczą OPENAI_PROJECT_ID."; +"Optional. Enter your GitHub Enterprise host, for example octocorp.ghe.com. Leave blank for github.com." = "Opcjonalne. Wprowadź host GitHub Enterprise, na przykład octocorp.ghe.com. Pozostaw puste dla github.com."; +"Optional. Leave blank to discover and aggregate projects visible to the API key." = "Opcjonalne. Pozostaw puste, aby wykrywać i agregować projekty widoczne dla klucza API."; +"Org ID (optional)" = "ID organizacji (opcjonalnie)"; +"Organizations" = "Organizacje"; +"Organization ID" = "ID organizacji"; +"Password" = "Hasło"; +"%@ authentication is disabled." = "Uwierzytelnianie %@ jest wyłączone."; +"%@ cookies are disabled." = "Pliki cookie %@ są wyłączone."; +"%@ web API access is disabled." = "Dostęp do web API %@ jest wyłączony."; +"Disable %@ dashboard cookie usage." = "Wyłącz użycie plików cookie panelu %@."; +"Keychain access is disabled in Advanced, so browser cookie import is unavailable." = "Dostęp do pęku kluczy jest wyłączony w Zaawansowanych, więc import plików cookie przeglądarki jest niedostępny."; +"Manually paste an %@ from a browser session." = "Wklej ręcznie %@ z sesji przeglądarki."; +"Paste a Cookie header captured from %@." = "Wklej nagłówek Cookie przechwycony z %@."; +"Paste a Cookie header from %@." = "Wklej nagłówek Cookie z %@."; +"Paste a Cookie header or cURL capture from %@." = "Wklej nagłówek Cookie lub przechwycony cURL z %@."; +"Paste a Cookie header or full cURL capture from %@." = "Wklej nagłówek Cookie lub pełny przechwycony cURL z %@."; +"Paste a Cookie or Authorization header from %@." = "Wklej nagłówek Cookie lub Authorization z %@."; +"Paste a full cookie header or the %@ value." = "Wklej pełny nagłówek Cookie albo wartość %@."; +"Paste a Cookie header or full cURL capture from T3 Chat settings." = "Wklej nagłówek Cookie lub pełny przechwycony cURL z ustawień T3 Chat."; +"Paste the Cookie header from a request to admin.mistral.ai. Must contain an ory_session_* cookie." = "Wklej nagłówek Cookie z żądania do admin.mistral.ai. Musi zawierać plik cookie ory_session_*."; +"Paste the Oasis-Token from a logged-in browser session on platform.stepfun.com." = "Wklej Oasis-Token z zalogowanej sesji przeglądarki na platform.stepfun.com."; +"Paste the %@ JSON bundle from %@." = "Wklej pakiet JSON %@ z %@."; +"Paste the %@ value or a full Cookie header." = "Wklej wartość %@ albo pełny nagłówek Cookie."; +"Personal account" = "Konto osobiste"; +"Project ID" = "ID projektu"; +"Re-auth" = "Uwierzytelnij ponownie"; +"Re-login at claude.ai" = "Zaloguj się ponownie na claude.ai"; +"Re-authenticating…" = "Ponowne uwierzytelnianie…"; +"Refresh Session" = "Odśwież sesję"; +"Refresh organizations" = "Odśwież organizacje"; +"Region" = "Region"; +"Reload" = "Przeładuj"; +"Reorder" = "Zmień kolejność"; +"Secret access key" = "Tajny klucz dostępu"; +"Series" = "Seria"; +"Service" = "Usługa"; +"Show or hide Kiro credits, percent, or both next to the menu bar icon." = "Pokaż lub ukryj kredyty Kiro, procent albo oba obok ikony paska menu."; +"Show usage for organizations you belong to. Personal account is always shown." = "Pokazuj użycie dla organizacji, do których należysz. Konto osobiste jest zawsze widoczne."; +"Sign in to cursor.com in your browser, then refresh Cursor in CodexBar." = "Zaloguj się do cursor.com w przeglądarce, a następnie odśwież Cursor w CodexBar."; +"Simulated error text" = "Symulowany tekst błędu"; +"StepFun platform account (phone number or email)." = "Konto platformy StepFun (numer telefonu lub e-mail)."; +"Stored in ~/.codexbar/config.json." = "Przechowywane w ~/.codexbar/config.json."; +"Stored in ~/.codexbar/config.json. AZURE_OPENAI_API_KEY is also supported." = "Przechowywane w ~/.codexbar/config.json. Obsługiwane jest także AZURE_OPENAI_API_KEY."; +"Stored in ~/.codexbar/config.json. For the official Kimi API, use Moonshot / Kimi API." = "Przechowywane w ~/.codexbar/config.json. Dla oficjalnego API Kimi użyj Moonshot / Kimi API."; +"Stored in ~/.codexbar/config.json. Get your API key from the Volcengine Ark console." = "Przechowywane w ~/.codexbar/config.json. Pobierz klucz API z konsoli Volcengine Ark."; +"Stored in ~/.codexbar/config.json. Get your key from Ollama settings." = "Przechowywane w ~/.codexbar/config.json. Pobierz klucz z ustawień Ollama."; +"Stored in ~/.codexbar/config.json. Get your key from console.deepgram.com." = "Przechowywane w ~/.codexbar/config.json. Pobierz klucz z console.deepgram.com."; +"Stored in ~/.codexbar/config.json. Get your key from elevenlabs.io/app/settings/api-keys." = "Przechowywane w ~/.codexbar/config.json. Pobierz klucz z elevenlabs.io/app/settings/api-keys."; +"Stored in ~/.codexbar/config.json. Get your key from openrouter.ai/settings/keys and set a key spending limit there to enable API key quota tracking." = "Przechowywane w ~/.codexbar/config.json. Pobierz klucz z openrouter.ai/settings/keys i ustaw tam limit wydatków klucza, aby włączyć śledzenie limitu klucza API."; +"Stored in ~/.codexbar/config.json. In Warp, open Settings > Platform > API Keys, then create one." = "Przechowywane w ~/.codexbar/config.json. W Warp otwórz Ustawienia > Platform > API Keys, a następnie utwórz klucz."; +"Stored in ~/.codexbar/config.json. Metrics require Groq Enterprise Prometheus access." = "Przechowywane w ~/.codexbar/config.json. Metryki wymagają dostępu do Groq Enterprise Prometheus."; +"Stored in ~/.codexbar/config.json. OPENAI_ADMIN_KEY is preferred; OPENAI_API_KEY still works." = "Przechowywane w ~/.codexbar/config.json. Preferowany jest OPENAI_ADMIN_KEY; OPENAI_API_KEY nadal działa."; +"Stored in ~/.codexbar/config.json. Requires an Anthropic Admin API key." = "Przechowywane w ~/.codexbar/config.json. Wymaga klucza Anthropic Admin API."; +"Stored in ~/.codexbar/config.json. Used for /v1/quota-stats." = "Przechowywane w ~/.codexbar/config.json. Używane dla `/v1/quota-stats`."; +"Stored in ~/.codexbar/config.json. You can also provide CODEBUFF_API_KEY or let CodexBar read ~/.config/manicode/credentials.json (created by `codebuff login`)." = "Przechowywane w ~/.codexbar/config.json. Możesz też podać CODEBUFF_API_KEY albo pozwolić CodexBar odczytać ~/.config/manicode/credentials.json (utworzony przez `codebuff login`)."; +"Stored in ~/.codexbar/config.json. You can also provide CROF_API_KEY." = "Przechowywane w ~/.codexbar/config.json. Możesz też podać CROF_API_KEY."; +"Stored in ~/.codexbar/config.json. You can also provide KILO_API_KEY or ~/.local/share/kilo/auth.json (kilo.access)." = "Przechowywane w ~/.codexbar/config.json. Możesz też podać KILO_API_KEY albo ~/.local/share/kilo/auth.json (kilo.access)."; +"T3 Chat cookie" = "Plik cookie T3 Chat"; +"Team mode" = "Tryb zespołu"; +"That account is no longer available in CodexBar. Refresh the account list and try again." = "To konto nie jest już dostępne w CodexBar. Odśwież listę kont i spróbuj ponownie."; +"The browser login did not complete in time. Try Antigravity login again." = "Logowanie w przeglądarce nie zostało ukończone na czas. Spróbuj ponownie zalogować się do Antigravity."; +"Timed out waiting for Cursor login. %@" = "Przekroczono limit czasu oczekiwania na logowanie Cursor. %@"; +"Timed out waiting for Cursor login. %@ Last error: %@" = "Przekroczono limit czasu oczekiwania na logowanie Cursor. %@ Ostatni błąd: %@"; +"Today requests" = "Dzisiejsze żądania"; +"Total (30d): %@ credits" = "Łącznie (30 dni): %@ kredytów"; +"Username" = "Nazwa użytkownika"; +"Uses username + password to login and obtain an Oasis-Token automatically." = "Używa nazwy użytkownika i hasła do logowania oraz automatycznego uzyskania Oasis-Token."; +"Uses username + password to login and obtain an %@ automatically." = "Używa nazwy użytkownika i hasła do logowania oraz automatycznego uzyskania %@."; +"Utilization End" = "Koniec wykorzystania"; +"Utilization Start" = "Początek wykorzystania"; +"Verbosity" = "Szczegółowość"; +"Windsurf session JSON bundle" = "Pakiet JSON sesji Windsurf"; +"Workspace ID" = "ID workspace"; +"Your StepFun platform password. Used to login and obtain a session token." = "Twoje hasło do platformy StepFun. Służy do logowania i uzyskania tokenu sesji."; +"claude /login exited with status %d." = "`claude /login` zakończył się ze statusem %d."; +"codex login exited with status %d." = "`codex login` zakończył się ze statusem %d."; +"Cookie: …\n\nor paste a cURL capture from the Abacus AI dashboard" = "Cookie: …\n\nalbo wklej przechwycony cURL z panelu Abacus AI"; +"Cookie: …\n\nor paste the __Secure-next-auth.session-token value" = "Cookie: …\n\nalbo wklej wartość __Secure-next-auth.session-token"; +"Cookie: …\n\nor paste the kimi-auth token value" = "Cookie: …\n\nalbo wklej wartość tokenu kimi-auth"; +"session_id=...\n\nor paste just the session_id value" = "session_id=...\n\nalbo wklej samą wartość session_id"; +"Clear" = "Wyczyść"; +"No matching providers" = "Brak pasujących dostawców"; +"Search providers" = "Szukaj dostawców"; + +"Request quota: %@ / %@" = "Limit żądań: %@ / %@"; +"language_arabic" = "العربية"; +"language_persian" = "فارسی"; +"language_thai" = "ไทย"; +"language_galician" = "Galego"; +"Limit Reset Credits" = "Kredyty resetowania limitu"; +"1 available" = "1 dostępny"; +"%d available" = "%d dostępne"; +"Next expires %@" = "Następny wygasa %@"; +"Expires %@" = "Wygasa %@"; +"No expiry" = "Brak terminu ważności"; +"byte_unit_byte" = "bajt"; +"byte_unit_bytes" = "bajty"; +"byte_unit_kilobyte" = "kilobajt"; +"byte_unit_kilobytes" = "kilobajty"; +"byte_unit_megabyte" = "megabajt"; +"byte_unit_megabytes" = "megabajty"; +"byte_unit_gigabyte" = "gigabajt"; +"byte_unit_gigabytes" = "gigabajty"; + +/* Settings sidebar redesign */ +"Enable" = "Włącz"; +"Disable" = "Wyłącz"; +"providers_on_count" = "%d wł."; +"section_cost_summary" = "Podsumowanie kosztów"; +"section_command_line" = "Wiersz poleceń"; +"section_privacy" = "Prywatność"; +"section_diagnostics" = "Diagnostyka"; +"section_updates" = "Aktualizacje"; +"section_links" = "Linki"; +"Show Codex Spark usage" = "Pokaż użycie Codex Spark"; +"Shows Codex Spark quota rows in the menu and provider preview. Requires optional credits and extra usage in Display settings." = "Pokazuje wiersze limitu Codex Spark w menu i podglądzie dostawcy. Wymaga włączenia opcji „Pokaż kredyty + dodatkowe użycie” w ustawieniach Wyświetlanie."; +"Show Daily Routines usage" = "Pokaż użycie Codziennych rutyn"; +"Shows the Daily Routines quota row in the menu and provider preview. Requires optional credits and extra usage in Display settings." = "Pokazuje wiersz limitu Codziennych rutyn w menu i podglądzie dostawcy. Wymaga włączenia opcji „Pokaż kredyty + dodatkowe użycie” w ustawieniach Wyświetlanie."; +"Scroll to see more models" = "Przewiń, aby zobaczyć więcej modeli"; + +/* Shareable usage card */ +"Copy Image" = "Kopiuj obraz"; +"Copy Stats" = "Kopiuj statystyki"; +"Could not copy image" = "Nie udało się skopiować obrazu"; +"Image copied" = "Obraz skopiowany"; +"Image saved" = "Obraz zapisany"; +"Nothing is uploaded. This image is created on your Mac." = "Nic nie jest przesyłane. Ten obraz jest tworzony na Twoim Macu."; +"Save..." = "Zapisz..."; +"Share AI Usage" = "Udostępnij użycie AI"; +"Share Stats…" = "Udostępnij statystyki…"; +"Stats copied" = "Statystyki skopiowane"; +"Finish switching to a different Cursor account in your browser, then try again." = "Dokończ przełączanie na inne konto Cursor w przeglądarce, a następnie spróbuj ponownie."; +"Timed out waiting for Cursor account switch. %@" = "Upłynął limit czasu oczekiwania na przełączenie konta Cursor. %@"; +"Timed out waiting for Cursor account switch. %@ Last error: %@" = "Upłynął limit czasu oczekiwania na przełączenie konta Cursor. %@ Ostatni błąd: %@"; + +/* Spend dashboard */ +"tab_usage_spend" = "Użycie i wydatki"; +"Usage & Spend" = "Użycie i wydatki"; +"Local estimated cost history across supported providers." = "Lokalna historia szacowanych kosztów u obsługiwanych dostawców."; +"Time range" = "Zakres czasu"; +"Track costs" = "Śledź koszty"; +"Cost tracking is off" = "Śledzenie kosztów jest wyłączone"; +"Turn on Track costs to build local estimates." = "Włącz opcję „Śledź koszty”, aby tworzyć lokalne szacunki."; +"No local cost history yet" = "Brak lokalnej historii kosztów"; +"Turn on cost tracking or refresh after using a supported provider." = "Włącz śledzenie kosztów lub odśwież po użyciu obsługiwanego dostawcy."; +"Refresh failures" = "Błędy odświeżania"; +"Native currencies stay separate; Codex account rows exclude Pi session history." = "Waluty źródłowe pozostają rozdzielone; wiersze kont Codex nie obejmują historii sesji Pi."; +"Spend unavailable" = "Wydatki niedostępne"; +"Model breakdown unavailable" = "Podział według modeli jest niedostępny"; +"Local estimated history" = "Lokalna historia szacunkowa"; +"Coverage" = "Pokrycie"; +"Estimated spend" = "Szacowane wydatki"; +"Tracked tokens" = "Śledzone tokeny"; +"Subscriptions" = "Subskrypcje"; +"By subscription" = "Według subskrypcji"; +"No model-level history" = "Brak historii na poziomie modeli"; +"Daily estimated spend" = "Szacowane dzienne wydatki"; +"≈%d full 5h windows of weekly left · %d windows until reset" = "≈%d pełnych 5-godz. okien limitu tygodniowego · %d okien do resetu"; +"Weekly cannot run out before reset at this pace" = "Przy tym tempie limit tygodniowy nie może wyczerpać się przed resetem"; +"Weekly can run out ≈%d windows early" = "Limit tygodniowy może wyczerpać się ≈%d okien wcześniej"; +"Estimated: %@" = "Szacunek: %@"; +"session_quota_estimate_value_format" = "%1$@ %2$@"; +"session quota" = "limit sesji"; +"session quotas" = "limity sesji"; +"Coding Plan" = "Plan kodowania"; +"Agent Plan" = "Plan agenta"; +"Team" = "Zespół"; + +/* Menu bar layout editor */ +"menu_bar_layout_title" = "Układ"; +"menu_bar_layout_footer" = "Przeciągaj elementy, aby ułożyć pasek menu. Kliknij element, aby go dodać; zaznacz umieszczony element i naciśnij Delete, aby go usunąć."; +"menu_bar_layout_group_identity" = "Tożsamość"; +"menu_bar_layout_group_usage" = "Zużycie"; +"menu_bar_layout_group_time" = "Czas"; +"menu_bar_layout_group_money" = "Koszt"; +"menu_bar_layout_group_structure" = "Struktura"; +"menu_bar_layout_scope_all" = "Wszyscy dostawcy"; +"menu_bar_layout_scope_help" = "Edytuj układ domyślny lub zastąp go dla jednego dostawcy."; +"menu_bar_layout_use_all" = "Użyj układu wszystkich dostawców"; +"menu_bar_layout_preset" = "Ustawienie układu"; +"menu_bar_layout_preset_icon_percent" = "Ikona i procent"; +"menu_bar_layout_preset_icon_only" = "Tylko ikona"; +"menu_bar_layout_preset_percent_reset" = "Procent i reset"; +"menu_bar_layout_preset_compact_stacked" = "Kompaktowy stos"; +"menu_bar_layout_preset_custom" = "Niestandardowe"; +"menu_bar_layout_live_preview" = "Podgląd na żywo"; +"menu_bar_layout_strip" = "Pasek menu"; +"menu_bar_layout_remove_line_break" = "Usuń podział wiersza"; +"menu_bar_layout_chip_hint" = "Zaznacz, przeciągnij, aby zmienić kolejność, lub użyj akcji Usuń."; +"menu_bar_layout_palette_hint" = "Kliknij, aby dodać, lub przeciągnij do układu."; +"menu_bar_layout_empty_line" = "Upuść element tutaj"; +"menu_bar_layout_line" = "Wiersz %d"; +"menu_bar_layout_drag_remove" = "Przeciągnij tutaj, aby usunąć"; +"menu_bar_layout_size" = "Rozmiar"; +"menu_bar_layout_size_small" = "Mały"; +"menu_bar_layout_size_regular" = "Zwykły"; +"menu_bar_layout_gap" = "Odstęp"; +"menu_bar_layout_gap_tight" = "Wąski"; +"menu_bar_layout_gap_regular" = "Zwykły"; +"menu_bar_layout_keyboard_hint" = "Delete usuwa zaznaczony element"; +"menu_bar_layout_sample_account" = "konto"; +"menu_bar_layout_sample_runs_out" = "wyczerpie się pt."; +"menu_bar_layout_token_icon" = "Ikona"; +"menu_bar_layout_token_provider" = "Nazwa dostawcy"; +"menu_bar_layout_token_account" = "Konto"; +"menu_bar_layout_token_session" = "Sesja %"; +"menu_bar_layout_token_weekly" = "Tydzień %"; +"menu_bar_layout_token_auto" = "Automatycznie %"; +"menu_bar_layout_token_bar" = "Pasek użycia"; +"menu_bar_layout_token_resets_in" = "Reset za"; +"menu_bar_layout_token_reset_at" = "Reset o"; +"menu_bar_layout_token_runs_out" = "Wyczerpie się"; +"menu_bar_layout_token_cost_today" = "Koszt dzisiaj"; +"menu_bar_layout_token_cost_30d" = "Koszt 30 dni"; +"menu_bar_layout_token_space" = "Odstęp"; +"menu_bar_layout_token_line_break" = "Podział wiersza"; +"menu_bar_layout_token_separator_accessibility" = "Kropka oddzielająca"; + +/* Menu bar layout accessibility */ +"Icon unavailable" = "Ikona: Niedostępne"; +"%@ icon" = "%@: Ikona"; +"Provider name unavailable" = "Nazwa dostawcy: Niedostępne"; +"Account unavailable" = "Konto: Niedostępne"; +"%@ unavailable" = "%@: Niedostępne"; +"%@ %@" = "%@ %@"; +"Usage bar unavailable" = "Pasek użycia: Niedostępne"; +"Usage bar, %d of 3 filled" = "Pasek użycia: %d/3 wypełnione"; +"Reset countdown unavailable" = "Reset za: Niedostępne"; +"Reset time unavailable" = "Reset o: Niedostępne"; +"Run-out estimate unavailable" = "Wyczerpie się: Niedostępne"; +"Cost today unavailable" = "Koszt dzisiaj: Niedostępne"; +"30-day cost unavailable" = "Koszt 30 dni: Niedostępne"; +"Resets" = "Resety"; diff --git a/Sources/CodexBar/Resources/pl.lproj/Localizable.stringsdict b/Sources/CodexBar/Resources/pl.lproj/Localizable.stringsdict new file mode 100644 index 0000000000..3a126f9dbd --- /dev/null +++ b/Sources/CodexBar/Resources/pl.lproj/Localizable.stringsdict @@ -0,0 +1,61 @@ + + + + + ≈%d full 5h windows of weekly left · %d windows until reset + + NSStringLocalizedFormatKey + %#@left@ · %#@until@ + left + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + d + one + ≈%d pełne 5-godz. okno limitu tygodniowego + few + ≈%d pełne 5-godz. okna limitu tygodniowego + many + ≈%d pełnych 5-godz. okien limitu tygodniowego + other + ≈%d pełnych 5-godz. okien limitu tygodniowego + + until + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + d + one + %d okno do resetu + few + %d okna do resetu + many + %d okien do resetu + other + %d okien do resetu + + + Weekly can run out ≈%d windows early + + NSStringLocalizedFormatKey + %#@early@ + early + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + d + one + Limit tygodniowy może wyczerpać się ≈%d okno wcześniej + few + Limit tygodniowy może wyczerpać się ≈%d okna wcześniej + many + Limit tygodniowy może wyczerpać się ≈%d okien wcześniej + other + Limit tygodniowy może wyczerpać się ≈%d okien wcześniej + + + + diff --git a/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings b/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings index 5210e0c6e9..f80ae8c088 100644 --- a/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings @@ -1,8 +1,33 @@ /* Brazilian Portuguese localization for CodexBar */ +"tab_hooks" = "Hooks"; +"hooks_enable_title" = "Ativar hooks"; +"hooks_enable_subtitle" = "Executa comandos externos quando ocorrerem eventos de cota ou provedor."; +"hooks_trust_warning" = "Hooks podem executar comandos locais no seu Mac. Configure apenas comandos confiáveis."; +"hooks_rules_header" = "Regras"; +"hooks_empty" = "Nenhum hook configurado."; +"hooks_add_rule" = "Adicionar regra"; +"hooks_delete_rule" = "Excluir regra"; +"hooks_rule_enabled" = "Ativado"; +"hooks_event" = "Evento"; +"hooks_provider" = "Provedor"; +"hooks_any_provider" = "Qualquer provedor"; +"hooks_threshold" = "Executar com uso ≥"; +"hooks_threshold_placeholder" = "90"; +"hooks_executable_placeholder" = "/usr/local/bin/my-command"; +"hooks_arguments_placeholder" = "Argumentos"; +"hooks_argument_placeholder" = "Argumento"; +"hooks_add_argument" = "Adicionar argumento"; +"hooks_delete_argument" = "Excluir argumento"; + +"ollama_safari_cookie_access_hint" = "Os cookies do Safari precisam de Acesso Total ao Disco para o CodexBar (Ajustes do Sistema > Privacidade e Segurança)."; +"ollama_browser_cookie_decryption_denied" = "A descriptografia dos cookies do %@ foi recusada nas Chaves; tente novamente com uma atualização manual."; +"ollama_browser_cookie_decryption_disabled" = "A descriptografia dos cookies do %@ está desativada no CodexBar; ative o acesso às Chaves e atualize."; + " providers" = " provedores"; "(System)" = "(Sistema)"; "30d" = "30d"; +"7d" = "7d"; "A managed Codex login is already running. Wait for it to finish before adding " = "Um login gerenciado do Codex já está em andamento. Aguarde terminar antes de adicionar "; "API key" = "Chave de API"; "API region" = "Região da API"; @@ -99,6 +124,8 @@ "Could not start codex login" = "Não foi possível iniciar o login do Codex"; "Could not switch system account" = "Não foi possível trocar a conta do sistema"; "Credits" = "Créditos"; +"Individual credits" = "Créditos individuais"; +"Workspace" = "Workspace"; "Credits history" = "Histórico de créditos"; "Cursor login failed" = "Falha no login do Cursor"; "Custom" = "Personalizado"; @@ -232,6 +259,7 @@ "Picker subtitle" = "Subtítulo do seletor"; "Placeholder" = "Texto de exemplo"; "Plan" = "Plano"; +"Plan Usage" = "Uso do plano"; "Play full-screen confetti when weekly usage resets." = "Mostra confete em tela cheia quando o uso semanal for renovado."; "Polls OpenAI/Claude status pages and Google Workspace for " = "Consulta as páginas de status da OpenAI/Claude e o Google Workspace para "; "Prevents any Keychain access while enabled." = "Impede qualquer acesso ao Keychain quando ativado."; @@ -265,7 +293,8 @@ "Select the IDE to monitor" = "Selecione a IDE para monitorar"; "Session quota notifications" = "Notificações de cota de sessão"; "Session tokens" = "Tokens de sessão"; -"Settings" = "Ajustes"; +"provider_section_connection" = "Conexão"; +"provider_section_menu_bar" = "Barra de menus"; "Show Codex Credits and Claude Extra usage sections in the menu." = "Mostra as seções de créditos do Codex e uso extra do Claude no menu."; "Show Debug Settings" = "Mostrar ajustes de depuração"; "Show all token accounts" = "Mostrar todas as contas de token"; @@ -293,18 +322,18 @@ "Store multiple OpenCode Go Cookie headers." = "Armazena vários cabeçalhos Cookie do OpenCode Go."; "Stored in the CodexBar config file." = "Armazenado no arquivo de configuração do CodexBar."; "Stored in ~/.codexbar/config.json. " = "Armazenado em ~/.codexbar/config.json. "; -"Stored in ~/.codexbar/config.json. Generate one at kimi-k2.ai." = "Armazenado em ~/.codexbar/config.json. Gere um em kimi-k2.ai."; "Stored in ~/.codexbar/config.json. Paste the key from the Synthetic dashboard." = "Armazenado em ~/.codexbar/config.json. Cole a chave do dashboard Synthetic."; "Stored in ~/.codexbar/config.json. Paste your Coding Plan API key from Model Studio." = "Armazenado em ~/.codexbar/config.json. Cole sua chave de API do Coding Plan do Model Studio."; "Stored in ~/.codexbar/config.json. Paste your MiniMax API key." = "Armazenado em ~/.codexbar/config.json. Cole sua chave de API do MiniMax."; "Stored in ~/.codexbar/config.json. You can also provide KILO_API_KEY or " = "Armazenado em ~/.codexbar/config.json. Você também pode informar KILO_API_KEY ou "; "Stores local Codex usage history (8 weeks) to personalize Pace predictions." = "Armazena histórico local de uso do Codex (8 semanas) para personalizar previsões de Ritmo."; -"Subscription Utilization" = "Uso da assinatura"; "Surprise me" = "Surpreenda-me"; "Switcher shows icons" = "Alternador mostra ícones"; "Symlink CodexBarCLI to /usr/local/bin and /opt/homebrew/bin as codexbar." = "Cria symlink de CodexBarCLI em /usr/local/bin e /opt/homebrew/bin como codexbar."; "System" = "Sistema"; "Temporarily shows the loading animation after the next refresh." = "Mostra temporariamente a animação de carregamento após a próxima atualização."; +"terminal_app_subtitle" = "Terminal usado pela ação Abrir Terminal"; +"terminal_app_title" = "Terminal padrão"; "Tertiary (\\(label))" = "Terciário (\\(label))"; "Tertiary (\\(tertiaryTitle))" = "Terciário (\\(tertiaryTitle))"; "The default Codex account on this Mac." = "A conta Codex padrão deste Mac."; @@ -389,9 +418,19 @@ /* General Pane */ "section_system" = "Sistema"; "section_usage" = "Uso"; -"section_automation" = "Automação"; +"section_refreshing" = "Atualização"; +"section_alerts" = "Alertas"; +"section_celebrations" = "Celebrações"; +"section_icon" = "Ícone"; +"section_combined_icon" = "Ícone combinado"; +"section_animation" = "Animação"; +"section_content" = "Conteúdo"; +"section_agent_sessions" = "Sessões de agentes"; "language_title" = "Idioma"; "language_subtitle" = "Altera o idioma de exibição. Requer reiniciar o app para ter efeito completo."; +"currency_title" = "Moeda preferida"; +"currency_subtitle" = "Moeda para estimativas de custo e gastos. Usa taxas de câmbio atualizadas diariamente."; +"currency_auto" = "Automático (seguir provedor / USD)"; "language_system" = "Sistema"; "language_english" = "Inglês"; "language_spanish" = "Espanhol"; @@ -399,21 +438,42 @@ "language_chinese_simplified" = "Chinês simplificado"; "language_chinese_traditional" = "Chinês tradicional"; "language_portuguese_brazilian" = "Português (Brasil)"; +"language_swedish" = "Sueco"; +"language_dutch" = "Nederlands"; +"language_german" = "Deutsch"; +"language_french" = "Francês"; +"language_ukrainian" = "Ucraniano"; +"language_russian" = "Русский"; +"language_japanese" = "Japonês"; +"language_korean" = "Coreano"; +"language_italian" = "Italiano"; +"language_polish" = "Polski"; "start_at_login_title" = "Iniciar ao fazer login"; "start_at_login_subtitle" = "Abre o CodexBar automaticamente ao iniciar o Mac."; -"show_cost_summary" = "Mostrar resumo de custos"; "show_cost_summary_subtitle" = "Lê logs de uso locais. Mostra o custo de hoje + janela selecionada no menu."; +"cost_summary_style_title" = "Estilo de exibição"; +"cost_summary_style_inline" = "Somente embutido"; +"cost_summary_style_submenu" = "Somente submenu"; +"cost_summary_style_both" = "Ambos"; +"cost_summary_style_inline_help" = "Mostra o resumo de custos diretamente no menu principal."; +"cost_summary_style_submenu_help" = "Mostra o submenu Custo detalhado em vez disso."; +"cost_summary_style_both_help" = "Mostra o resumo no menu principal e o submenu Custo detalhado."; +"cost_history_window_title" = "Janela do histórico"; +"cost_history_window_help" = "Define quantos dias de logs de uso locais aparecem no menu."; "cost_history_days_title" = "Janela do histórico: %d dias"; -"cost_auto_refresh_info" = "Atualização automática: a cada hora · Timeout: 10 min"; -"refresh_cadence_title" = "Cadência de atualização"; -"refresh_cadence_subtitle" = "Frequência com que o CodexBar consulta provedores em segundo plano."; +"cost_auto_refresh_info" = "Atualização automática: intervalo global (mínimo de 5 min) · Timeout: 10 min"; +"cost_comparison_periods_title" = "Mostrar períodos de comparação mais curtos"; +"cost_comparison_periods_subtitle" = "Adiciona totais de 7, 30 e 90 dias quando couberem na janela de histórico selecionada. Esses totais reutilizam a mesma varredura local."; +"refresh_interval_title" = "Intervalo de atualização"; "manual_refresh_hint" = "A atualização automática está desativada; use Atualizar no menu."; +"refresh_on_open_title" = "Atualizar ao abrir o menu"; +"refresh_on_open_subtitle" = "Busca o uso mais recente de cada provedor sempre que você abre o menu."; "check_provider_status_title" = "Verificar status dos provedores"; "check_provider_status_subtitle" = "Consulta páginas de status da OpenAI/Claude e o Google Workspace para Gemini/Antigravity, exibindo incidentes no ícone e no menu."; -"session_quota_notifications_title" = "Notificações de cota de sessão"; "session_quota_notifications_subtitle" = "Notifica quando a cota de sessão de 5 horas chega a 0% e quando fica disponível novamente."; -"quota_warning_notifications_title" = "Notificações de alerta de cota"; +"quota_depleted_title" = "Cota esgotada e restaurada"; "quota_warning_notifications_subtitle" = "Avisa quando a cota restante da sessão ou da semana fica abaixo dos limites configurados."; +"threshold_warnings_title" = "Alertas de limite"; "quota_warnings_title" = "Alertas de cota"; "quota_warning_session" = "sessão"; "quota_warning_session_capitalized" = "Sessão"; @@ -422,6 +482,17 @@ "quota_warning_notification_title" = "%1$@: cota baixa (%2$@)"; "quota_warning_notification_body" = "%1$@ restante. Você atingiu o limite de alerta de %2$d%% (%3$@)."; "quota_warning_notification_body_with_account" = "Conta %1$@. %2$@ restante. Você atingiu o limite de alerta de %3$d%% (%4$@)."; +"predictive_pace_warnings_title" = "Alertas preditivos de ritmo"; +"predictive_pace_warnings_subtitle" = "Avisa para Codex e Claude quando o ritmo da sessão ou da semana pode esgotar a cota antes da redefinição."; +"confetti_on_reset_title" = "Confete na redefinição"; +"confetti_on_reset_subtitle" = "Mostra confete em tela cheia quando o uso é redefinido."; +"confetti_option_off" = "Desativado"; +"confetti_option_session" = "Redefinições de sessão"; +"confetti_option_weekly" = "Redefinições semanais"; +"confetti_option_both" = "Ambos"; +"predictive_pace_warning_notification_title" = "%1$@: alerta de ritmo (%2$@)"; +"predictive_pace_warning_notification_body" = "No ritmo atual, esta cota pode acabar em %1$@, antes da redefinição."; +"predictive_pace_warning_notification_body_with_account" = "Conta %1$@. No ritmo atual, esta cota pode acabar em %2$@, antes da redefinição."; "session_depleted_notification_title" = "Sessão do %@ esgotada"; "session_depleted_notification_body" = "0% restante. Avisaremos quando estiver disponível novamente."; "session_restored_notification_title" = "Sessão do %@ restaurada"; @@ -429,22 +500,30 @@ "quota_warning_warn_at" = "Alertar em"; "quota_warning_global_threshold_subtitle" = "Percentuais restantes para as janelas de sessão e semanal, a menos que um provedor defina valores próprios."; "quota_warning_sound" = "Reproduzir som de notificação"; +"quota_warning_onscreen_alert" = "Mostrar alerta de texto na tela"; "quota_warning_provider_inherits" = "Usa as configurações globais de alerta de cota, a menos que uma janela seja personalizada aqui."; +"quota_warning_provider_disabled" = "As notificações de alerta de cota e os marcadores das barras de uso estão desativados. Ative uma das duas opções para editar estas configurações salvas."; +"quota_warning_provider_markers_only" = "As notificações de alerta de cota estão desativadas globalmente. Estas configurações ainda controlam os marcadores das barras de uso."; +"quota_warning_global" = "Global"; "quota_warning_customize_thresholds" = "Personalizar limites de %@"; "quota_warning_enable_warnings" = "Ativar alertas de %@"; "quota_warning_window_warn_at" = "%@: alertar em"; "quota_warning_off" = "Desativado"; "quota_warning_inherited" = "Usando global: %@"; "quota_warning_depleted_only" = "somente ao esgotar"; -"quota_warning_upper" = "Limite superior"; +"quota_warning_upper" = "Mais alto"; "quota_warning_lower" = "Limite inferior"; +"quota_warning_warning" = "Aviso"; +"quota_warning_critical" = "Crítico"; "apply" = "Aplicar"; "quit_app" = "Encerrar CodexBar"; /* Tab titles */ "tab_general" = "Geral"; "tab_providers" = "Provedores"; -"tab_display" = "Exibição"; +"tab_notifications" = "Notificações"; +"tab_menu_bar" = "Barra de menus"; +"tab_menu" = "Menu"; "tab_advanced" = "Avançado"; "tab_about" = "Sobre"; "tab_debug" = "Depuração"; @@ -470,37 +549,44 @@ "menu_bar_metric_subtitle_deepseek" = "Mostra o saldo do DeepSeek na barra de menus."; "menu_bar_metric_subtitle_moonshot" = "Mostra o saldo da API Moonshot / Kimi na barra de menus."; "menu_bar_metric_subtitle_mistral" = "Mostra o gasto da API Mistral no mês atual na barra de menus."; -"menu_bar_metric_subtitle_kimik2" = "Mostra os créditos da chave de API do Kimi K2 na barra de menus."; "automatic" = "Automático"; "primary_api_key_limit" = "Primário (limite da chave de API)"; /* Display Pane */ -"section_menu_bar" = "Barra de menus"; +"menu_bar_style_title" = "Estilo da barra de menus"; +"menu_bar_style_subtitle" = "Como o item da barra de menus é desenhado."; +"menu_bar_inactive_display_contrast_title" = "Melhorar a visibilidade em telas inativas"; +"menu_bar_usage_colors_title" = "Uso codificado por cores"; +"menu_bar_usage_colors_subtitle" = "Colore o ícone da barra de menus de verde a vermelho conforme o uso aumenta."; +"menu_bar_inactive_display_contrast_subtitle" = "Usa renderização de alto contraste para manter o ícone e a métrica legíveis em outras telas."; +"menu_bar_style_critters" = "Bichinhos"; +"menu_bar_style_bars" = "Barras de medição"; +"menu_bar_style_icon_percent" = "Ícone e porcentagem"; +"switcher_rows_title" = "Linhas do alternador"; +"switcher_rows_icons" = "Ícones dos provedores"; +"switcher_rows_progress" = "Progresso semanal"; +"usage_bars_fill_title" = "Preenchimento das barras de uso"; +"usage_bars_fill_remaining" = "Como restante"; +"usage_bars_fill_used" = "Como consumido"; +"reset_times_title" = "Horários de renovação"; +"reset_times_countdown" = "Contagem regressiva"; +"reset_times_clock" = "Horário"; +"cost_summary_title" = "Resumo de custos"; +"cost_summary_off" = "Desativado"; "merge_icons_title" = "Mesclar Ícones"; "merge_icons_subtitle" = "Usa um único ícone na barra de menus com alternador de provedores."; -"switcher_shows_icons_title" = "Alternador mostra ícones"; -"switcher_shows_icons_subtitle" = "Mostra ícones dos provedores no alternador (caso contrário, mostra uma linha de progresso semanal)."; "show_most_used_provider_title" = "Mostrar provedor mais usado"; "show_most_used_provider_subtitle" = "A barra de menus mostra automaticamente o provedor mais próximo do limite de taxa."; -"menu_bar_shows_percent_title" = "Barra de menus mostra porcentagem"; -"menu_bar_shows_percent_subtitle" = "Substitui barras de bichinhos por ícones da marca do provedor e uma porcentagem."; "display_mode_title" = "Modo de exibição"; "display_mode_subtitle" = "Escolha o que mostrar na barra de menus (Ritmo mostra uso vs. esperado)."; -"section_menu_content" = "Conteúdo do menu"; -"show_usage_as_used_title" = "Mostrar uso como consumido"; -"show_usage_as_used_subtitle" = "As barras de progresso preenchem conforme você consome a cota (em vez de mostrar o restante)."; "show_quota_warning_markers_title" = "Mostrar marcadores de alerta de cota"; "show_quota_warning_markers_subtitle" = "Desenha marcas de limite nas barras de uso quando os alertas de cota estão configurados."; "weekly_progress_work_days_title" = "Dias úteis no progresso semanal"; -"weekly_progress_work_days_subtitle" = "Desenha marcas de limite de dia nas barras de uso semanal."; -"show_reset_time_as_clock_title" = "Mostrar renovação como horário"; -"show_reset_time_as_clock_subtitle" = "Mostra horários de renovação como horas absolutas, em vez de contagens regressivas."; +"weekly_progress_work_days_subtitle" = "Define os dias úteis para marcadores das barras de uso semanal e cálculos de ritmo."; "show_provider_changelog_links_title" = "Mostrar links de changelog dos provedores"; "show_provider_changelog_links_subtitle" = "Adiciona links de notas de versão para provedores baseados em CLI compatíveis no menu."; "show_credits_extra_usage_title" = "Mostrar créditos + uso extra"; "show_credits_extra_usage_subtitle" = "Mostra as seções de créditos do Codex e uso extra do Claude no menu."; -"show_all_token_accounts_title" = "Mostrar todas as contas de token"; -"show_all_token_accounts_subtitle" = "Empilha contas de token no menu (caso contrário, mostra uma barra de alternância de contas)."; "multi_account_layout_title" = "Layout de múltiplas contas"; "multi_account_layout_subtitle" = "Escolha alternância segmentada de contas ou cartões de contas empilhados."; "multi_account_layout_segmented" = "Segmentado"; @@ -511,6 +597,16 @@ "overview_no_providers_hint" = "Nenhum provedor ativado disponível para Visão geral."; "overview_rows_follow_order" = "As linhas da Visão geral sempre seguem a ordem dos provedores."; "overview_no_providers_selected" = "Nenhum provedor selecionado"; +"agent_sessions_title" = "Sessões de agentes"; +"agent_sessions_subtitle" = "Mostra no menu sessões locais e descobertas via SSH do Codex e Claude Code."; +"agent_sessions_hosts_title" = "Hosts SSH adicionais"; +"agent_sessions_footer" = "Macs na sua tailnet são descobertos automaticamente. As sessões locais são atualizadas a cada 30 segundos; os hosts remotos, a cada 60 segundos e quando o menu é aberto."; +"agent_session_labels_title" = "Rótulos de sessão"; +"agent_session_labels_subtitle" = "Escolha como as sessões de agentes são nomeadas."; +"agent_session_label_project" = "Projeto"; +"agent_session_label_descriptive" = "Descritivo"; +"agent_session_label_descriptive_and_project" = "Descritivo + projeto"; +"agent_session_unknown_project" = "Projeto desconhecido"; /* Advanced Pane */ "section_keyboard_shortcut" = "Atalho de teclado"; @@ -524,8 +620,6 @@ "show_debug_settings_subtitle" = "Exibe ferramentas de diagnóstico na aba Depuração."; "surprise_me_title" = "Surpreenda-me"; "surprise_me_subtitle" = "Veja se você gosta dos seus agentes se divertindo ali em cima."; -"weekly_limit_confetti_title" = "Confete do limite semanal"; -"weekly_limit_confetti_subtitle" = "Mostra confete em tela cheia quando o uso semanal for renovado."; "hide_personal_info_title" = "Ocultar informações pessoais"; "hide_personal_info_subtitle" = "Oculta endereços de e-mail na barra de menus e na UI do menu."; "show_provider_storage_usage_title" = "Mostrar uso de armazenamento dos provedores"; @@ -612,17 +706,24 @@ "metric_pref_tertiary" = "Terciário"; "metric_pref_extra_usage" = "Uso extra"; "metric_pref_average" = "Média"; +"metric_mistral_payg" = "Pay-as-you-go"; +"metric_mistral_monthly_plan" = "Monthly Plan"; /* Display modes */ "display_mode_percent" = "Porcentagem"; "display_mode_pace" = "Ritmo"; "display_mode_both" = "Ambos"; +"display_mode_reset_time" = "Tempo de redefinição"; "display_mode_percent_desc" = "Mostra a porcentagem restante/usada (ex.: 45%)"; "display_mode_pace_desc" = "Mostra o indicador de ritmo (ex.: +5%)"; "display_mode_both_desc" = "Mostra porcentagem e ritmo (ex.: 45% · +5%)"; +"display_mode_reset_time_desc" = "Mostra o tempo de redefinição da métrica selecionada (ex.: ↻ 3:56 PM)"; +"menu_bar_reset_when_exhausted_title" = "Mostrar horário de redefinição quando a cota acabar"; +"menu_bar_reset_when_exhausted_subtitle" = "Com 0% restante, mostra o tempo até a redefinição em vez da porcentagem"; /* Provider status */ "status_operational" = "Operacional"; +"status_degraded" = "Desempenho degradado"; "status_partial_outage" = "Falha parcial"; "status_major_outage" = "Falha geral"; "status_critical_issue" = "Problema crítico"; @@ -636,13 +737,20 @@ "refresh_5min" = "5 min"; "refresh_15min" = "15 min"; "refresh_30min" = "30 min"; +"refresh_adaptive" = "Adaptativo"; +"refresh_adaptive_agent_aware" = "Adaptativo (atividade de agentes)"; +"adaptive_activity_consent_title" = "Permitir atualização conforme a atividade?"; +"adaptive_activity_consent_message" = "O modo Adaptativo conforme a atividade de agentes pode inspecionar a lista de processos locais em execução, incluindo as linhas de comando, para identificar Codex e Claude e ler os metadados de sessões conhecidas a cada 30 segundos enquanto você programa. Com Agent Sessions desativado, o CodexBar mantém na memória apenas o horário da atividade mais recente e descarta os caminhos e as identidades das sessões. Esses dados não são enviados a lugar algum, e a detecção remota e o SSH permanecem desativados. Se você recusar, o CodexBar voltará ao modo Adaptativo comum sem verificações de atividade local."; +"adaptive_activity_consent_allow" = "Permitir atividade local"; +"adaptive_activity_consent_decline" = "Usar Adaptativo comum"; /* Additional keys */ "not_found" = "Não encontrado"; /* Cost estimation */ -"cost_header_estimated" = "Custo (estimado)"; "cost_estimate_hint" = "Estimado a partir de logs locais · pode diferir da sua fatura"; +"codex_api_estimate_hint" = "Estimado com base no uso de tokens · não é uma fatura de assinatura"; +"cost_data_explanation" = "Os custos podem ser informados pelo provedor ou estimados com base no uso de tokens a preços públicos de API. As estimativas não são cobranças de assinatura."; "No JetBrains IDE with AI Assistant detected. Install a JetBrains IDE and enable AI Assistant." = "Nenhuma IDE JetBrains com AI Assistant detectada. Instale uma IDE JetBrains e ative o AI Assistant."; "OpenRouter API token not configured. Set OPENROUTER_API_KEY environment variable or configure in Settings." = "Token de API do OpenRouter não configurado. Defina a variável de ambiente OPENROUTER_API_KEY ou configure em Ajustes."; "z.ai API token not found. Set apiKey in ~/.codexbar/config.json or Z_AI_API_KEY." = "Token de API do z.ai não encontrado. Defina apiKey em ~/.codexbar/config.json ou Z_AI_API_KEY."; @@ -672,6 +780,7 @@ "Today" = "Hoje"; "Today tokens" = "Tokens de hoje"; "30d cost" = "Custo 30 d"; +"%@ cost" = "Custo %@"; "30d tokens" = "Tokens 30 d"; "Latest tokens" = "Tokens recentes"; "Top model" = "Modelo principal"; @@ -679,6 +788,7 @@ "Add Account..." = "Adicionar conta..."; "Usage Dashboard" = "Dashboard de uso"; "Status Page" = "Página de status"; +"Open Status Page" = "Abrir página de status"; "Settings..." = "Ajustes..."; "About CodexBar" = "Sobre o CodexBar"; "Quit" = "Encerrar"; @@ -691,8 +801,12 @@ "Resets %@" = "Renova %@"; "Resets in %@" = "Renova em %@"; "Resets now" = "Renova agora"; +"reset_tomorrow_format" = "amanhã, %@"; "Lasts until reset" = "Dura até a renovação"; +"1.5× headroom" = "folga de 1,5×"; "Updated %@" = "Atualizado %@"; +"Updated relative %@" = "Atualizado %@"; +"Updated absolute %@" = "Atualizado %@"; "Updated %@h ago" = "Atualizado há %@h"; "Updated %@m ago" = "Atualizado há %@m"; "Updated just now" = "Atualizado agora mesmo"; @@ -756,6 +870,8 @@ /* Popup panels */ "No usage configured." = "Nenhum uso configurado."; "Quota" = "Cota"; +"Daily quota" = "Cota diária"; +"Total" = "Total"; "tokens" = "tokens"; "requests" = "requisições"; "Latest" = "Mais recente"; @@ -789,6 +905,7 @@ "API spend" = "Gasto de API"; "Extra usage" = "Uso extra"; "Quota usage" = "Uso da cota"; +"Your spend" = "Seu gasto"; "%.0f%% used" = "%.0f%% usado"; "Usage history (today)" = "Histórico de uso (hoje)"; "Usage history (%d days)" = "Histórico de uso (%d dias)"; @@ -808,7 +925,7 @@ "Hourly Usage" = "Uso por hora"; "Usage remaining" = "Uso restante"; "Usage used" = "Uso usado"; -"API key verified. Ollama does not expose Cloud quota limits through the API." = "Chave de API verificada. O Ollama não expõe limites de cota do Cloud pela API."; +"API key verified. Cloud quotas need browser cookies. Sign in to Ollama." = "Chave de API verificada. As cotas do Cloud exigem cookies do navegador. Entre no Ollama."; "Last 30 days: %@ tokens" = "Últimos 30 dias: %@ tokens"; "7d spend" = "Gasto 7 d"; "30d spend" = "Gasto 30 d"; @@ -904,7 +1021,7 @@ "Antigravity login failed" = "Falha no login do Antigravity"; "Antigravity login timed out" = "Tempo esgotado no login do Antigravity"; "Auth source" = "Fonte de autenticação"; -"Automatic imports Chrome browser cookies from Xiaomi MiMo." = "Importa automaticamente cookies do Chrome do Xiaomi MiMo."; +"Automatic imports browser cookies from Xiaomi MiMo." = "Importa automaticamente cookies do navegador do Xiaomi MiMo."; "Automatic imports Windsurf session data from Chromium browser localStorage." = "Importa automaticamente dados de sessão do Windsurf do localStorage do Chromium."; "Automatic imports browser cookies from Bailian." = "Importa automaticamente cookies do navegador do Bailian."; "Automatically imports browser cookies." = "Importa automaticamente cookies do navegador."; @@ -939,7 +1056,6 @@ "CodexBar will ask macOS Keychain for your Cursor cookie header so it can fetch usage. Click OK to continue." = "O CodexBar pedirá ao Chaves do macOS o cabeçalho Cookie do Cursor para buscar uso. Clique em OK para continuar."; "CodexBar will ask macOS Keychain for your Factory cookie header so it can fetch usage. Click OK to continue." = "O CodexBar pedirá ao Chaves do macOS o cabeçalho Cookie do Factory para buscar uso. Clique em OK para continuar."; "CodexBar will ask macOS Keychain for your GitHub Copilot token so it can fetch usage. Click OK to continue." = "O CodexBar pedirá ao Chaves do macOS o token do GitHub Copilot para buscar uso. Clique em OK para continuar."; -"CodexBar will ask macOS Keychain for your Kimi K2 API key so it can fetch usage. Click OK to continue." = "O CodexBar pedirá ao Chaves do macOS a chave API do Kimi K2 para buscar uso. Clique em OK para continuar."; "CodexBar will ask macOS Keychain for your Kimi auth token so it can fetch usage. Click OK to continue." = "O CodexBar pedirá ao Chaves do macOS o token de autenticação do Kimi para buscar uso. Clique em OK para continuar."; "CodexBar will ask macOS Keychain for your MiniMax API token so it can fetch usage. Click OK to continue." = "O CodexBar pedirá ao Chaves do macOS o token API do MiniMax para buscar uso. Clique em OK para continuar."; "CodexBar will ask macOS Keychain for your MiniMax cookie header so it can fetch usage. Click OK to continue." = "O CodexBar pedirá ao Chaves do macOS o cabeçalho Cookie do MiniMax para buscar uso. Clique em OK para continuar."; @@ -953,10 +1069,15 @@ "Day" = "Dia"; "Deployment" = "Deployment"; "Drag to reorder" = "Arraste para reordenar"; +"Sort providers alphabetically" = "Ordenar provedores alfabeticamente"; +"Sort providers alphabetically (enabled first)" = "Ordenar provedores alfabeticamente (ativados primeiro)"; +"Sorted alphabetically (enabled first) — click to use your custom order" = "Ordenados alfabeticamente (ativados primeiro) — clique para usar sua ordem personalizada"; "Endpoint" = "Endpoint"; "Enterprise host" = "Host Enterprise"; "Extra usage balance: %@" = "Saldo de uso extra: %@"; "Keychain Access Required" = "Acesso ao Chaves necessário"; +"keychain_prompt_learn_more" = "Saiba mais…"; +"keychain_prompt_privacy_note" = "A digitação da senha de início de sessão do Mac é processada pelo macOS, não pelo CodexBar. Você pode desativar o acesso às Chaves a qualquer momento em Ajustes → Avançado."; "Kiro menu bar value" = "Valor do Kiro na barra de menu"; "Label" = "Rótulo"; "No organizations loaded. Click Refresh after setting your API key." = "Nenhuma organização carregada. Clique em Atualizar depois de definir sua chave API."; @@ -983,6 +1104,7 @@ "Optional. Leave blank to discover and aggregate projects visible to the API key." = "Opcional. Deixe em branco para descobrir e agregar projetos visíveis à chave API."; "Org ID (optional)" = "ID da org. (opcional)"; "Organizations" = "Organizações"; +"Organization ID" = "ID da organização"; "Password" = "Senha"; "%@ authentication is disabled." = "A autenticação de %@ está desativada."; "%@ cookies are disabled." = "Os cookies de %@ estão desativados."; @@ -1004,6 +1126,7 @@ "Personal account" = "Conta pessoal"; "Project ID" = "ID do projeto"; "Re-auth" = "Reautenticar"; +"Re-login at claude.ai" = "Entrar novamente no claude.ai"; "Re-authenticating…" = "Reautenticando…"; "Refresh Session" = "Atualizar sessão"; "Refresh organizations" = "Atualizar organizações"; @@ -1035,6 +1158,7 @@ "Stored in ~/.codexbar/config.json. You can also provide CROF_API_KEY." = "Armazenado em ~/.codexbar/config.json. Você também pode fornecer CROF_API_KEY."; "Stored in ~/.codexbar/config.json. You can also provide KILO_API_KEY or ~/.local/share/kilo/auth.json (kilo.access)." = "Armazenado em ~/.codexbar/config.json. Você também pode fornecer KILO_API_KEY ou ~/.local/share/kilo/auth.json (kilo.access)."; "T3 Chat cookie" = "Cookie do T3 Chat"; +"Team mode" = "Modo de equipe"; "That account is no longer available in CodexBar. Refresh the account list and try again." = "Essa conta não está mais disponível no CodexBar. Atualize a lista de contas e tente novamente."; "The browser login did not complete in time. Try Antigravity login again." = "O login no navegador não foi concluído a tempo. Tente o login do Antigravity novamente."; "Timed out waiting for Cursor login. %@" = "Tempo esgotado aguardando o login do Cursor. %@"; @@ -1056,3 +1180,175 @@ "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"; + +"language_vietnamese" = "Vietnamita"; +"language_turkish" = "Türkçe"; +"language_indonesian" = "Indonésio"; +"language_arabic" = "العربية"; +"language_persian" = "فارسی"; +"language_thai" = "ไทย"; +"language_galician" = "Galego"; +"Limit Reset Credits" = "Créditos de redefinição de limite"; +"1 available" = "1 disponível"; +"%d available" = "%d disponíveis"; +"Next expires %@" = "Próximo expira %@"; +"Expires %@" = "Expira %@"; +"No expiry" = "Sem validade"; +"Other (%d items)" = "Outros (%d itens)"; +"Expand" = "Expandir"; +"Collapse" = "Recolher"; +"byte_unit_byte" = "byte"; +"byte_unit_bytes" = "bytes"; +"byte_unit_kilobyte" = "quilobyte"; +"byte_unit_kilobytes" = "quilobytes"; +"byte_unit_megabyte" = "megabyte"; +"byte_unit_megabytes" = "megabytes"; +"byte_unit_gigabyte" = "gigabyte"; +"byte_unit_gigabytes" = "gigabytes"; + +/* Settings sidebar redesign */ +"Enable" = "Ativar"; +"Disable" = "Desativar"; +"providers_on_count" = "%d ativos"; +"section_cost_summary" = "Resumo de custos"; +"section_command_line" = "Linha de comando"; +"section_privacy" = "Privacidade"; +"section_diagnostics" = "Diagnósticos"; +"section_updates" = "Atualizações"; +"section_links" = "Links"; +"Show Codex Spark usage" = "Mostrar uso do Codex Spark"; +"Shows Codex Spark quota rows in the menu and provider preview. Requires optional credits and extra usage in Display settings." = "Mostra as linhas de cota do Codex Spark no menu e na prévia do provedor. Requer ativar “Mostrar créditos + uso extra” nos ajustes de Exibição."; +"Show Daily Routines usage" = "Mostrar uso de Rotinas diárias"; +"Shows the Daily Routines quota row in the menu and provider preview. Requires optional credits and extra usage in Display settings." = "Mostra a linha de cota de Rotinas diárias no menu e na prévia do provedor. Requer ativar “Mostrar créditos + uso extra” nos ajustes de Exibição."; +"Scroll to see more models" = "Role para ver mais modelos"; +"Copy Image" = "Copiar imagem"; +"Copy Stats" = "Copiar estatísticas"; +"Could not copy image" = "Não foi possível copiar a imagem"; +"Image copied" = "Imagem copiada"; +"Image saved" = "Imagem salva"; +"Nothing is uploaded. This image is created on your Mac." = "Nada é enviado. Esta imagem é criada no seu Mac."; +"Save..." = "Salvar..."; +"Share AI Usage" = "Compartilhar uso de IA"; +"Share Stats…" = "Compartilhar estatísticas…"; +"Stats copied" = "Estatísticas copiadas"; +"DeepSeek this month token usage trend" = "Tendência de uso de tokens do DeepSeek neste mês"; +"Chrome profile" = "Perfil do Chrome"; +"Choose which signed-in DeepSeek Platform session supplies detailed usage." = "Escolha qual sessão conectada do DeepSeek Platform fornece o uso detalhado."; +"Detailed usage unavailable." = "Uso detalhado indisponível."; +"Sign in to DeepSeek Platform in Chrome for detailed usage." = "Entre no DeepSeek Platform pelo Chrome para ver o uso detalhado."; +"Select a DeepSeek Chrome profile in Settings." = "Selecione um perfil do Chrome para o DeepSeek nos Ajustes."; +"Select profile…" = "Selecionar perfil…"; + +"%@: %@" = "%@: %@"; +"Alternatively, set a custom path in Settings." = "Como alternativa, defina um caminho personalizado em Configurações."; +"Choose a supported browser so CodexBar can read the matching account." = "Escolha um navegador compatível para que o CodexBar possa ler a conta correspondente."; +"Choose Cursor account" = "Escolha a conta do Cursor"; +"Choose which Cursor account CodexBar should use." = "Escolha qual conta de Cursor o CodexBar deve usar."; +"Finish switching to a different Cursor account in your browser, then try again." = "Termine de mudar para uma conta diferente do Cursor no seu navegador e tente novamente."; +"Install a JetBrains IDE with AI Assistant enabled, then refresh CodexBar." = "Instale um IDE JetBrains com AI Assistant habilitado e atualize o CodexBar."; +"Request quota: %@ / %@" = "Cota de solicitação: %@ / %@"; +"Sign in with Claude Code..." = "Faça login com Claude Code..."; +"Timed out waiting for Cursor account switch. %@" = "Tempo limite esgotado aguardando a troca de conta do Cursor. %@"; +"Timed out waiting for Cursor account switch. %@ Last error: %@" = "Tempo limite esgotado aguardando a troca de conta do Cursor. %@ Último erro: %@"; +"Use Account" = "Usar conta"; +/* Spend dashboard */ +"tab_usage_spend" = "Uso e gastos"; +"Usage & Spend" = "Uso e gastos"; +"Local estimated cost history across supported providers." = "Histórico local de custos estimados nos provedores compatíveis."; +"Time range" = "Intervalo de tempo"; +"Track costs" = "Acompanhar custos"; +"Cost tracking is off" = "O acompanhamento de custos está desativado"; +"Turn on Track costs to build local estimates." = "Ative “Acompanhar custos” para criar estimativas locais."; +"No local cost history yet" = "Ainda não há histórico local de custos"; +"Turn on cost tracking or refresh after using a supported provider." = "Ative o acompanhamento de custos ou atualize após usar um provedor compatível."; +"Refresh failures" = "Falhas de atualização"; +"Native currencies stay separate; Codex account rows exclude Pi session history." = "As moedas originais permanecem separadas; as linhas de contas do Codex excluem o histórico de sessões do Pi."; +"Spend unavailable" = "Gastos indisponíveis"; +"Model breakdown unavailable" = "Detalhamento por modelo indisponível"; +"Local estimated history" = "Histórico local estimado"; +"Coverage" = "Cobertura"; +"Estimated spend" = "Gastos estimados"; +"Tracked tokens" = "Tokens acompanhados"; +"Subscriptions" = "Assinaturas"; +"By subscription" = "Por assinatura"; +"No model-level history" = "Sem histórico por modelo"; +"Daily estimated spend" = "Gasto diário estimado"; +"≈%d full 5h windows of weekly left · %d windows until reset" = "≈%d janelas completas de 5 h da cota semanal · %d janelas até a renovação"; +"Weekly cannot run out before reset at this pace" = "A cota semanal não pode acabar antes da renovação nesse ritmo"; +"Weekly can run out ≈%d windows early" = "A cota semanal pode acabar ≈%d janelas antes"; +"Estimated: %@" = "Estimativa: %@"; +"session_quota_estimate_value_format" = "%1$@ %2$@"; +"session quota" = "cota da sessão"; +"session quotas" = "cotas da sessão"; +"Coding Plan" = "Plano de codificação"; +"Agent Plan" = "Plano de agente"; +"Team" = "Equipe"; + +/* Menu bar layout editor */ +"menu_bar_layout_title" = "Layout"; +"menu_bar_layout_footer" = "Arraste os itens para organizar a barra de menus. Clique em um item para adicioná-lo; selecione um item posicionado e pressione Delete para removê-lo."; +"menu_bar_layout_group_identity" = "Identidade"; +"menu_bar_layout_group_usage" = "Uso"; +"menu_bar_layout_group_time" = "Tempo"; +"menu_bar_layout_group_money" = "Custo"; +"menu_bar_layout_group_structure" = "Estrutura"; +"menu_bar_layout_scope_all" = "Todos os provedores"; +"menu_bar_layout_scope_help" = "Edite o layout padrão ou substitua-o para um provedor."; +"menu_bar_layout_use_all" = "Usar layout de todos os provedores"; +"menu_bar_layout_preset" = "Predefinição de layout"; +"menu_bar_layout_preset_icon_percent" = "Ícone e percentual"; +"menu_bar_layout_preset_icon_only" = "Somente ícone"; +"menu_bar_layout_preset_percent_reset" = "Percentual e redefinição"; +"menu_bar_layout_preset_compact_stacked" = "Empilhado compacto"; +"menu_bar_layout_preset_custom" = "Personalizado"; +"menu_bar_layout_live_preview" = "Prévia ao vivo"; +"menu_bar_layout_strip" = "Faixa da barra de menus"; +"menu_bar_layout_remove_line_break" = "Remover quebra de linha"; +"menu_bar_layout_chip_hint" = "Selecione, arraste para reordenar ou use a ação Remover."; +"menu_bar_layout_palette_hint" = "Clique para adicionar ou arraste para o layout."; +"menu_bar_layout_empty_line" = "Solte um item aqui"; +"menu_bar_layout_line" = "Linha %d"; +"menu_bar_layout_drag_remove" = "Arraste aqui para remover"; +"menu_bar_layout_size" = "Tamanho"; +"menu_bar_layout_size_small" = "Pequeno"; +"menu_bar_layout_size_regular" = "Normal"; +"menu_bar_layout_gap" = "Espaçamento"; +"menu_bar_layout_gap_tight" = "Apertado"; +"menu_bar_layout_gap_regular" = "Normal"; +"menu_bar_layout_keyboard_hint" = "Delete remove o item selecionado"; +"menu_bar_layout_sample_account" = "conta"; +"menu_bar_layout_sample_runs_out" = "acaba sex."; +"menu_bar_layout_token_icon" = "Ícone"; +"menu_bar_layout_token_provider" = "Nome do provedor"; +"menu_bar_layout_token_account" = "Conta"; +"menu_bar_layout_token_session" = "Sessão %"; +"menu_bar_layout_token_weekly" = "Semanal %"; +"menu_bar_layout_token_auto" = "% automático"; +"menu_bar_layout_token_bar" = "Barra de uso"; +"menu_bar_layout_token_resets_in" = "Redefine em"; +"menu_bar_layout_token_reset_at" = "Redefine às"; +"menu_bar_layout_token_runs_out" = "Acaba"; +"menu_bar_layout_token_cost_today" = "Custo hoje"; +"menu_bar_layout_token_cost_30d" = "Custo em 30 dias"; +"menu_bar_layout_token_space" = "Espaço"; +"menu_bar_layout_token_line_break" = "Quebra de linha"; +"menu_bar_layout_token_separator_accessibility" = "Ponto separador"; + +/* Menu bar layout accessibility */ +"Icon unavailable" = "Ícone: Indisponível"; +"%@ icon" = "%@: Ícone"; +"Provider name unavailable" = "Nome do provedor: Indisponível"; +"Account unavailable" = "Conta: Indisponível"; +"%@ unavailable" = "%@: Indisponível"; +"%@ %@" = "%@ %@"; +"Usage bar unavailable" = "Barra de uso: Indisponível"; +"Usage bar, %d of 3 filled" = "Barra de uso: %d/3 preenchidos"; +"Reset countdown unavailable" = "Redefine em: Indisponível"; +"Reset time unavailable" = "Redefine às: Indisponível"; +"Run-out estimate unavailable" = "Acaba: Indisponível"; +"Cost today unavailable" = "Custo hoje: Indisponível"; +"30-day cost unavailable" = "Custo em 30 dias: Indisponível"; +"Resets" = "Redefinições"; diff --git a/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.stringsdict b/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.stringsdict new file mode 100644 index 0000000000..bc3220610f --- /dev/null +++ b/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.stringsdict @@ -0,0 +1,49 @@ + + + + + ≈%d full 5h windows of weekly left · %d windows until reset + + NSStringLocalizedFormatKey + %#@left@ · %#@until@ + left + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + d + one + ≈%d janela completa de 5 h da cota semanal + other + ≈%d janelas completas de 5 h da cota semanal + + until + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + d + one + %d janela até a renovação + other + %d janelas até a renovação + + + Weekly can run out ≈%d windows early + + NSStringLocalizedFormatKey + %#@early@ + early + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + d + one + A cota semanal pode acabar ≈%d janela antes + other + A cota semanal pode acabar ≈%d janelas antes + + + + diff --git a/Sources/CodexBar/Resources/ru.lproj/Localizable.strings b/Sources/CodexBar/Resources/ru.lproj/Localizable.strings new file mode 100644 index 0000000000..3583833235 --- /dev/null +++ b/Sources/CodexBar/Resources/ru.lproj/Localizable.strings @@ -0,0 +1,1355 @@ +/* Russian localization for CodexBar */ + +"tab_hooks" = "Хуки"; +"hooks_enable_title" = "Включить хуки"; +"hooks_enable_subtitle" = "Запускать внешние команды при событиях квоты или провайдера."; +"hooks_trust_warning" = "Хуки могут выполнять локальные команды на вашем Mac. Настраивайте только доверенные команды."; +"hooks_rules_header" = "Правила"; +"hooks_empty" = "Хуки не настроены."; +"hooks_add_rule" = "Добавить правило"; +"hooks_delete_rule" = "Удалить правило"; +"hooks_rule_enabled" = "Включено"; +"hooks_event" = "Событие"; +"hooks_provider" = "Провайдер"; +"hooks_any_provider" = "Любой провайдер"; +"hooks_threshold" = "Запускать при использовании ≥"; +"hooks_threshold_placeholder" = "90"; +"hooks_executable_placeholder" = "/usr/local/bin/my-command"; +"hooks_arguments_placeholder" = "Аргументы"; +"hooks_argument_placeholder" = "Аргумент"; +"hooks_add_argument" = "Добавить аргумент"; +"hooks_delete_argument" = "Удалить аргумент"; + +"ollama_safari_cookie_access_hint" = "Для файлов cookie Safari приложению CodexBar нужен полный доступ к диску (Системные настройки > Конфиденциальность и безопасность)."; +"ollama_browser_cookie_decryption_denied" = "Расшифровка файлов cookie %@ была отклонена в Связке ключей; повторите попытку с помощью ручного обновления."; +"ollama_browser_cookie_decryption_disabled" = "Расшифровка файлов cookie %@ отключена в CodexBar; включите доступ к Связке ключей и обновите."; + +" providers" = " провайдеров"; +"(System)" = "(Система)"; +"30d" = "30 дн."; +"7d" = "7 дн."; +"A managed Codex login is already running. Wait for it to finish before adding " = "Управляемый вход Codex уже активен. Подождите, пока он завершится, прежде чем добавлять "; +"API key" = "API-ключ"; +"API region" = "Регион API"; +"API token" = "API-токен"; +"API tokens" = "API-токены"; +"About" = "О"; +"Account" = "Аккаунт"; +"Accounts" = "Аккаунты"; +"Accounts subtitle" = "Описание аккаунтов"; +"Active" = "Активно"; +"Add" = "Добавить"; +"Add Workspace" = "Добавить рабочую область"; +"Advanced" = "Расширенные"; +"All" = "Все"; +"Always allow prompts" = "Всегда разрешать запросы"; +"Animation pattern" = "Шаблон анимации"; +"Antigravity login is managed in the app" = "Вход в Antigravity управляется в приложении"; +"Applies only to the Security.framework OAuth keychain reader." = "Применяется только к средству чтения OAuth из Keychain через Security.framework."; +"Alternatively, set a custom path in Settings." = "Или задайте собственный путь в настройках."; +"Auto falls back to the next source if the preferred one fails." = "Автоматически переключается на следующий источник, если предпочтительный не сработал."; +"Auto uses API first, then falls back to CLI on auth failures." = "Автоматически сначала использует API, затем переключается на CLI при ошибках авторизации."; +"Auto-detect" = "Автоопределение"; +"Auto-refresh is off; use the menu's Refresh command." = "Автообновление отключено; используйте команду меню «Обновить»."; +"Auto-refresh: hourly · Timeout: 10m" = "Автоматическое обновление: каждый час · Тайм-аут: 10 минут"; +"Automatic" = "Автоматически"; +"Automatic imports browser cookies and WorkOS tokens." = "Автоматически импортирует cookie браузера и токены WorkOS."; +"Automatic imports browser cookies and local storage tokens." = "Автоматически импортирует cookie браузера и токены локального хранилища."; +"Automatic imports browser cookies for dashboard extras." = "Автоматически импортирует cookie браузера для дополнительных данных дашборда."; +"Automatic imports browser cookies for the web API." = "Автоматически импортирует cookie браузера для веб-API."; +"Automatic imports browser cookies from Model Studio/Bailian." = "Автоматически импортирует cookie браузера из Model Studio/Bailian."; +"Automatic imports browser cookies from admin.mistral.ai." = "Автоматически импортирует cookie браузера из admin.mistral.ai."; +"Automatic imports browser cookies from opencode.ai." = "Автоматически импортирует cookie браузера из opencode.ai."; +"Automatic imports browser cookies or stored sessions." = "Автоматически импортирует cookie браузера или сохранённые сеансы."; +"Automatic imports browser cookies." = "Автоматически импортирует cookie браузера."; +"Automatically imports browser session cookie." = "Автоматически импортирует cookie сеанса браузера."; +"Automatically opens CodexBar when you start your Mac." = "Автоматически открывает CodexBar при запуске Mac."; +"Automation" = "Автоматизация"; +"Average (\\(label1) + \\(label2))" = "Среднее (\\(label1) + \\(label2))"; +"Average (\\(metadata.sessionLabel) + \\(metadata.weeklyLabel))" = "Среднее (\\(metadata.sessionLabel) + \\(metadata.weeklyLabel))"; +"Avoid Keychain prompts" = "Избегать запросов Keychain"; +"Balance" = "Баланс"; +"Battery Saver" = "Экономия энергии"; +"Bordered" = "С рамкой"; +"Build" = "Сборка"; +"Built \\(buildTimestamp)" = "Сборка: \\(buildTimestamp)"; +"Buy Credits..." = "Купить кредиты…"; +"Buy Credits…" = "Купить кредиты…"; +"CLI paths" = "Пути CLI"; +"CLI sessions" = "CLI-сеансы"; +"Caches" = "Кэши"; +"Cancel" = "Отмена"; +"Check for Updates…" = "Проверить наличие обновлений…"; +"Check for updates automatically" = "Автоматическая проверка обновлений"; +"Check if you like your agents having some fun up there." = "Включите, если хотите немного оживить индикаторы агентов в строке меню."; +"Check provider status" = "Проверить статус провайдера"; +"Choose Codex workspace" = "Выберите рабочую область Codex"; +"Choose the MiniMax host (global .io or China mainland .com)." = "Выберите хост MiniMax (глобальный .io или материковый Китай .com)."; +"Choose up to " = "Выберите до "; +"Choose up to \\(Self.maxOverviewProviders) providers" = "Выберите до \\(Self.maxOverviewProviders) провайдеров"; +"Choose up to \\(count) providers" = "Выберите до \\(count) провайдеров"; +"Choose what to show in the menu bar (Pace shows usage vs. expected)." = "Выберите, что показывать в строке меню: темп сравнивает фактическое использование с ожидаемым."; +"Choose which Codex account CodexBar should follow." = "Выберите аккаунт Codex, за которым должен следить CodexBar."; +"Choose which window drives the menu bar percent." = "Выберите окно, по которому рассчитывается процент в строке меню."; +"Chrome" = "Chrome"; +"Claude CLI not found" = "Claude CLI не найден"; +"Claude binary" = "Бинарный файл Claude"; +"Claude cookies" = "Cookie Claude"; +"Claude login failed" = "Не удалось войти в Claude"; +"Claude login timed out" = "Время входа в Claude истекло"; +"Close" = "Закрыть"; +"Code review" = "Ревью кода"; +"Codex CLI not found" = "Codex CLI не найден"; +"Codex account login already running" = "Вход в аккаунт Codex уже выполняется"; +"Codex binary" = "Бинарный файл Codex"; +"Codex login failed" = "Не удалось войти в Codex"; +"Codex login timed out" = "Время входа в Codex истекло"; +"CodexBar Lifecycle Keepalive" = "Поддержание жизненного цикла CodexBar"; +"CodexBar can't show its menu bar icon" = "CodexBar не может показать значок в строке меню"; +"CodexBar could not read managed account storage. " = "CodexBar не удалось прочитать хранилище управляемых аккаунтов. "; +"Configure…" = "Настроить…"; +"Connected" = "Подключено"; +"Controls how much detail is logged." = "Управляет подробностью журналирования."; +"Cookie header" = "Cookie-заголовок"; +"Cookie source" = "Источник Cookie"; +"Cookie: ..." = "Cookie: ..."; +"Cookie: \\u{2026}\\\n\\\nor paste a cURL capture from the Abacus AI dashboard" = "Cookie: \\u{2026}\\\n\\\nили вставьте снимок cURL из дашборда Abacus AI."; +"Cookie: \\u{2026}\\\n\\\nor paste the __Secure-next-auth.session-token value" = "Cookie: \\u{2026}\\\n\\\nили вставьте значение __Secure-next-auth.session-token"; +"Cookie: \\u{2026}\\\n\\\nor paste the kimi-auth token value" = "Cookie: \\u{2026}\\\n\\\nили вставьте значение токена kimi-auth"; +"Cookie: …" = "Cookie: …"; +"CopilotDeviceFlow" = "CopilotDeviceFlow"; +"Cost" = "Стоимость"; +"Could not add Codex account" = "Не удалось добавить аккаунт Codex"; +"Could not open Terminal for Gemini" = "Не удалось открыть Terminal для Gemini."; +"Could not start claude /login" = "Не удалось запустить claude /login"; +"Could not start codex login" = "Не удалось запустить вход в Codex"; +"Could not switch system account" = "Не удалось переключить системный аккаунт"; +"Credits" = "Кредиты"; +"Individual credits" = "Индивидуальные кредиты"; +"Workspace" = "Рабочая область"; +"Credits history" = "История кредитов"; +"Cursor login failed" = "Не удалось войти в Cursor"; +"Custom" = "Пользовательский"; +"Custom Path" = "Пользовательский путь"; +"Daily Routines" = "Ежедневные задачи"; +"Debug" = "Отладка"; +"Default" = "По умолчанию"; +"Disable Keychain access" = "Отключить доступ к Keychain"; +"Disabled" = "Отключено"; +"Dismiss" = "Закрыть"; +"Disconnected" = "Отключено"; +"Display" = "Отображение"; +"Display mode" = "Режим отображения"; +"Display reset times as absolute clock values instead of countdowns." = "Показывать время сброса как точное время, а не обратный отсчёт."; +"Done" = "Готово"; +"Effective PATH" = "Эффективный PATH"; +"Email" = "Email"; +"Enable Merge Icons to configure Overview tab providers." = "Включите «Объединять значки», чтобы настроить провайдеров вкладки «Обзор»."; +"Enable file logging" = "Включить запись логов"; +"Enabled" = "Включено"; +"Error" = "Ошибка"; +"Error simulation" = "Моделирование ошибок"; +"Expose troubleshooting tools in the Debug tab." = "Показывать инструменты диагностики на вкладке «Отладка»."; +"Failed" = "Не удалось"; +"False" = "Нет"; +"Fetch strategy attempts" = "Попытки стратегии получения данных"; +"Fetching" = "Получение данных"; +"Field" = "Поле"; +"Field subtitle" = "Описание поля"; +"Finish the current managed account change before switching the system account." = "Завершите текущее изменение управляемого аккаунта перед переключением системного аккаунта."; +"Force animation on next refresh" = "Показать анимацию при следующем обновлении"; +"Gateway region" = "Регион шлюза"; +"Gemini CLI not found" = "Gemini CLI не найден"; +"Gemini/Antigravity, surfacing incidents in the icon and menu." = "Gemini/Antigravity и показывает инциденты на значке и в меню."; +"General" = "Общие"; +"GitHub" = "GitHub"; +"GitHub Copilot Login" = "Вход в GitHub Copilot"; +"GitHub Login" = "Вход в GitHub"; +"Hide details" = "Скрыть подробности"; +"Hide personal information" = "Скрыть личную информацию"; +"Historical tracking" = "История использования"; +"How often CodexBar polls providers in the background." = "Как часто CodexBar опрашивает провайдеров в фоновом режиме."; +"Inactive" = "Неактивный"; +"Install CLI" = "Установить CLI"; +"Install the Claude CLI (npm i -g @anthropic-ai/claude-code) and try again." = "Установите Claude CLI (npm i -g @anthropic-ai/claude-code) и повторите попытку."; +"Install the Codex CLI (npm i -g @openai/codex) and try again." = "Установите Codex CLI (npm i -g @openai/codex) и повторите попытку."; +"Install the Gemini CLI (npm i -g @google/gemini-cli) and try again." = "Установите Gemini CLI (npm i -g @google/gemini-cli) и повторите попытку."; +"Install a JetBrains IDE with AI Assistant enabled, then refresh CodexBar." = "Установите JetBrains IDE с включённым AI Assistant, затем обновите CodexBar."; +"JetBrains AI is ready" = "JetBrains AI готов"; +"JetBrains IDE" = "JetBrains IDE"; +"Keep CLI sessions alive" = "Поддерживать CLI-сеансы активными"; +"Keyboard shortcut" = "Сочетание клавиш"; +"Keychain access" = "Доступ к Keychain"; +"Keychain prompt policy" = "Политика запросов Keychain"; +"Last \\(name) fetch failed:" = "Последнее получение \\(name) не удалось:"; +"Last \\(self.store.metadata(for: self.provider).displayName) fetch failed:" = "Последнее получение \\(self.store.metadata(for: self.provider).displayName) не удалось:"; +"Last attempt" = "Последняя попытка"; +"Link" = "Ссылка"; +"Loading animations" = "Анимации загрузки"; +"Loading…" = "Загрузка…"; +"Local" = "Локально"; +"Logging" = "Ведение журнала"; +"Login failed" = "Не удалось войти"; +"Login shell PATH (startup capture)" = "PATH login shell (снимок при запуске)"; +"Login timed out" = "Время входа истекло"; +"MCP details" = "Сведения MCP"; +"Managed Codex accounts unavailable" = "Управляемые аккаунты Codex недоступны."; +"Managed account storage is unreadable. Live account access is still available, " = "Хранилище управляемого аккаунта недоступно для чтения. Текущий аккаунт всё ещё доступен, "; +"Manual" = "Вручную"; +"May your tokens never run out—keep agent limits in view." = "Пусть ваши токены никогда не закончатся — помните об ограничениях агентов."; +"Menu bar" = "Строка меню"; +"Menu bar auto-shows the provider closest to its rate limit." = "Строка меню автоматически показывает провайдера, ближайшего к лимиту запросов."; +"Menu bar metric" = "Метрика строки меню"; +"Menu bar shows percent" = "Строка меню показывает проценты"; +"Menu content" = "Содержание меню"; +"Merge Icons" = "Объединить значки"; +"Never prompt" = "Никогда не запрашивать"; +"No" = "Нет"; +"No Codex accounts detected yet." = "Аккаунты Codex пока не обнаружены."; +"No JetBrains IDE detected" = "JetBrains IDE не обнаружена"; +"No cost history data." = "Нет истории расходов."; +"No data available" = "Нет доступных данных"; +"No data yet" = "Данных пока нет"; +"No enabled providers available for Overview." = "Нет включённых провайдеров для обзора."; +"No providers selected" = "Провайдеры не выбраны"; +"No token accounts yet." = "Токен-аккаунтов пока нет."; +"No usage breakdown data." = "Нет детализации использования."; +"None" = "Нет"; +"Notifications" = "Уведомления"; +"Notifies when the 5-hour session quota hits 0% and when it becomes " = "Уведомляет, когда квота 5-часового сеанса достигает 0% и когда она становится "; +"OK" = "OK"; +"Obscure email addresses in the menu bar and menu UI." = "Скрывать email-адреса в строке меню и интерфейсе меню."; +"Off" = "Выкл."; +"Offline" = "Оффлайн"; +"On" = "Вкл."; +"Online" = "Онлайн"; +"Only on user action" = "Только по действию пользователя"; +"Open" = "Открыть"; +"Open API Keys" = "Открыть ключи API"; +"Open Amp Settings" = "Открыть настройки Amp"; +"Open Antigravity to sign in, then refresh CodexBar." = "Откройте Antigravity, войдите в аккаунт, затем обновите CodexBar."; +"Open Browser" = "Открыть браузер"; +"Open Coding Plan" = "Открыть Coding Plan"; +"Open Console" = "Открыть консоль"; +"Open Dashboard" = "Открыть дашборд"; +"Open Mistral Admin" = "Открыть Mistral Admin"; +"Open Menu Bar Settings" = "Открыть настройки строки меню"; +"Open Ollama Settings" = "Открыть настройки Ollama"; +"Open Terminal" = "Открыть Terminal"; +"Open Usage Page" = "Открыть страницу использования"; +"Open Warp API Key Guide" = "Открыть руководство по API-ключу Warp"; +"Open menu" = "Открыть меню"; +"Open token file" = "Открыть файл токена"; +"OpenAI cookies" = "Cookie OpenAI"; +"OpenAI web extras" = "Доп. данные OpenAI Web"; +"Option A" = "Вариант А"; +"Option B" = "Вариант Б"; +"Optional override if workspace lookup fails." = "Необязательное переопределение на случай, если рабочая область не найдена."; +"Options" = "Параметры"; +"Override auto-detection with a custom IDE base path" = "Переопределить автоопределение собственным базовым путём IDE."; +"Overview" = "Обзор"; +"Overview rows always follow provider order." = "Строки обзора всегда следуют порядку провайдеров."; +"Overview tab providers" = "Провайдеры вкладки «Обзор»"; +"Paste API key…" = "Вставьте API-ключ…"; +"Paste API token…" = "Вставьте токен API…"; +"Paste key…" = "Вставить ключ…"; +"Paste sessionKey or OAuth token…" = "Вставьте sessionKey или токен OAuth…"; +"Paste the Cookie header from a request to admin.mistral.ai. " = "Вставьте Cookie-заголовок из запроса к admin.mistral.ai. "; +"Paste token…" = "Вставить токен…"; +"Personal" = "Личное"; +"Picker" = "Выбор"; +"Picker subtitle" = "Описание выбора"; +"Placeholder" = "Подсказка"; +"Plan" = "План"; +"Plan Usage" = "Использование плана"; +"Play full-screen confetti when weekly usage resets." = "Показывать полноэкранное конфетти при сбросе недельного лимита."; +"Polls OpenAI/Claude status pages and Google Workspace for " = "Опрашивает страницы статуса OpenAI/Claude и Google Workspace для "; +"Prevents any Keychain access while enabled." = "Блокирует любой доступ к Keychain, пока настройка включена."; +"Primary (API key limit)" = "Основной (лимит API-ключа)"; +"Primary (\\(label))" = "Основной (\\(label))"; +"Primary (\\(metadata.sessionLabel))" = "Основной (\\(metadata.sessionLabel))"; +"Probe logs" = "Журналы проверок"; +"Progress bars fill as you consume quota (instead of showing remaining)." = "Индикаторы заполняются по мере расходования квоты, а не показывают остаток."; +"Provider" = "Провайдер"; +"Providers" = "Провайдеры"; +"Quit CodexBar" = "Закрыть CodexBar"; +"Random (default)" = "Случайный (по умолчанию)"; +"Reads local usage logs. Shows today + last 30 days cost in the menu." = "Читает локальные журналы использования. Показывает в меню сегодняшние расходы и выбранное окно истории."; +"Refresh" = "Обновить"; +"Refresh cadence" = "Частота обновления"; +"Remote" = "Удалённо"; +"Remove" = "Удалить"; +"Remove Codex account?" = "Удалить аккаунт Codex?"; +"Remove \\(account.email) from CodexBar? Its managed Codex home will be deleted." = "Удалить \\(account.email) из CodexBar? Его управляемый каталог Codex будет удалён."; +"Remove \\(email) from CodexBar? Its managed Codex home will be deleted." = "Удалить \\(email) из CodexBar? Его управляемый каталог Codex будет удалён."; +"Remove selected account" = "Удалить выбранный аккаунт"; +"Replace critter bars with provider branding icons and a percentage." = "Заменить декоративные индикаторы значками провайдеров и процентом."; +"Replay selected animation" = "Воспроизвести выбранную анимацию"; +"Requires authentication via GitHub Device Flow." = "Требуется авторизация через GitHub Device Flow."; +"Resets: \\(reset)" = "Сброс: \\(reset)"; +"Rolling five-hour limit" = "Скользящий пятичасовой лимит"; +"Search hourly" = "Искать каждый час"; +"Secondary (\\(label))" = "Вторичный (\\(label))"; +"Secondary (\\(metadata.weeklyLabel))" = "Вторичный (\\(metadata.weeklyLabel))"; +"Select a provider" = "Выберите провайдера"; +"Select the IDE to monitor" = "Выберите IDE для мониторинга"; +"Session quota notifications" = "Уведомления о квоте сеанса"; +"Session tokens" = "Токены сеанса"; +"provider_section_connection" = "Подключение"; +"provider_section_menu_bar" = "Строка меню"; +"Show Codex Credits and Claude Extra usage sections in the menu." = "Показывать в меню разделы кредитов Codex и дополнительного использования Claude."; +"Show Debug Settings" = "Показать настройки отладки"; +"Show all token accounts" = "Показать все токены-аккаунты"; +"Show cost summary" = "Показать сводку расходов"; +"Show credits + extra usage" = "Показать кредиты + дополнительное использование"; +"Show details" = "Показать детали"; +"Show most-used provider" = "Показать наиболее часто используемого провайдера"; +"Show provider icons in the switcher (otherwise show a weekly progress line)." = "Показывать значки провайдеров в переключателе, иначе показывать строку недельного прогресса."; +"Show reset time as clock" = "Показывать время сброса в виде часов"; +"Show usage as used" = "Показывать израсходованное"; +"Sign in with Claude Code..." = "Войти через Claude Code…"; +"Sign in via button below" = "Войдите через кнопку ниже"; +"Skip teardown between probes (debug-only)." = "Не завершать сеансы между проверками (только для отладки)."; +"Stack token accounts in the menu (otherwise show an account switcher bar)." = "Группировать токен-аккаунты в меню; иначе показывать панель переключения аккаунтов."; +"Start at Login" = "Запускать при входе"; +"Status" = "Статус"; +"Store Claude sessionKey cookies or OAuth access tokens." = "Хранить cookie sessionKey Claude или OAuth-токены доступа."; +"Store multiple Abacus AI Cookie headers." = "Хранить несколько Cookie-заголовков Abacus AI."; +"Store multiple Augment Cookie headers." = "Хранить несколько Cookie-заголовков Augment."; +"Store multiple Cursor Cookie headers." = "Хранить несколько Cookie-заголовков Cursor."; +"Store multiple Factory Cookie headers." = "Хранить несколько Cookie-заголовков Factory."; +"Store multiple MiniMax Cookie headers." = "Хранить несколько Cookie-заголовков MiniMax."; +"Store multiple Mistral Cookie headers." = "Хранить несколько Cookie-заголовков Mistral."; +"Store multiple Ollama Cookie headers." = "Хранить несколько Cookie-заголовков Ollama."; +"Store multiple OpenCode Cookie headers." = "Хранить несколько Cookie-заголовков OpenCode."; +"Store multiple OpenCode Go Cookie headers." = "Хранить несколько Cookie-заголовков OpenCode Go."; +"Stored in the CodexBar config file." = "Хранится в конфигурационном файле CodexBar."; +"Stored in ~/.codexbar/config.json. " = "Хранится в ~/.codexbar/config.json. "; +"Stored in ~/.codexbar/config.json. Paste the key from the Synthetic dashboard." = "Хранится в ~/.codexbar/config.json. Вставьте ключ с дашборда Synthetic."; +"Stored in ~/.codexbar/config.json. Paste your Coding Plan API key from Model Studio." = "Хранится в ~/.codexbar/config.json. Вставьте API-ключ Coding Plan из Model Studio."; +"Stored in ~/.codexbar/config.json. Paste your MiniMax API key." = "Хранится в ~/.codexbar/config.json. Вставьте API-ключ MiniMax."; +"Stored in ~/.codexbar/config.json. You can also provide KILO_API_KEY or " = "Хранится в ~/.codexbar/config.json. Также можно задать KILO_API_KEY или "; +"Stores local Codex usage history (8 weeks) to personalize Pace predictions." = "Сохраняет локальную историю использования Codex (8 недель) для персонализации прогнозов Pace."; +"Surprise me" = "Удиви меня"; +"Switcher shows icons" = "Переключатель показывает значки"; +"Symlink CodexBarCLI to /usr/local/bin and /opt/homebrew/bin as codexbar." = "Создать symlink CodexBarCLI как codexbar в /usr/local/bin и /opt/homebrew/bin."; +"System" = "Система"; +"Temporarily shows the loading animation after the next refresh." = "Временно показывает анимацию загрузки после следующего обновления."; +"terminal_app_subtitle" = "Терминал для действия «Открыть Terminal»"; +"terminal_app_title" = "Терминал по умолчанию"; +"Tertiary (\\(label))" = "Третичный (\\(label))"; +"Tertiary (\\(tertiaryTitle))" = "Третичный (\\(tertiaryTitle))"; +"The default Codex account on this Mac." = "Аккаунт Codex по умолчанию на этом Mac."; +"Toggle" = "Переключить"; +"Toggle subtitle" = "Описание переключателя"; +"Token" = "Токен"; +"Trigger the menu bar menu from anywhere." = "Вызывать меню строки меню из любого места."; +"True" = "Да"; +"Twitter" = "Twitter"; +"Unsupported" = "Не поддерживается"; +"Update Channel" = "Канал обновлений"; +"Updated" = "Обновлено"; +"Updates unavailable in this build." = "Обновления недоступны в этой сборке."; +"Usage" = "Использование"; +"Usage breakdown" = "Детализация использования"; +"Usage history (30 days)" = "История использования (30 дней)"; +"Usage source" = "Источник использования"; +"Use BigModel for the China mainland endpoints (open.bigmodel.cn)." = "Использовать BigModel для эндпоинтов материкового Китая (open.bigmodel.cn)."; +"Use a single menu bar icon with a provider switcher." = "Использовать один значок в строке меню с переключателем провайдера."; +"Use international or China mainland console gateways for quota fetches." = "Использовать международный или материковый китайский шлюз консоли для получения квот."; +"Version" = "Версия"; +"Version \\(self.versionString)" = "Версия \\(self.versionString)"; +"Version \\(version)" = "Версия \\(version)"; +"Version \\(versionString)" = "Версия \\(versionString)"; +"Vertex AI Login" = "Вход в Vertex AI"; +"Wait for the current managed Codex login to finish before adding another account." = "Подождите, пока текущий управляемый вход Codex завершится, прежде чем добавлять ещё один аккаунт."; +"Waiting for Authentication..." = "Ожидание авторизации…"; +"Website" = "Сайт"; +"Weekly limit confetti" = "Конфетти при сбросе недельного лимита"; +"Weekly token limit" = "Еженедельный лимит токенов"; +"Weekly usage" = "Еженедельное использование"; +"Weekly usage unavailable for this account." = "Еженедельное использование недоступно для этого аккаунта."; +"Window: \\(window)" = "Окно: \\(window)"; +"Write logs to \\(self.fileLogPath) for debugging." = "Записывать журналы в \\(self.fileLogPath) для отладки."; +"Yes" = "Да"; +"\\(detail.modelCode): \\(usage)" = "\\(detail.modelCode): \\(usage)"; +"\\(name): \\(truncated)" = "\\(name): \\(truncated)"; +"\\(name): \\(updated) · 30d \\(cost)" = "\\(name): \\(updated) · 30д \\(cost)"; +"\\(name): fetching…\\(elapsed)" = "\\(name): получение…\\(elapsed)"; +"\\(name): last attempt \\(when)" = "\\(name): последняя попытка \\(when)"; +"\\(name): no data yet" = "\\(name): данных пока нет"; +"\\(name): unsupported" = "\\(name): не поддерживается"; +"all browsers" = "все браузеры"; +"available again." = "доступен снова."; +"built_format" = "Сборка %@"; +"copilot_complete_in_browser" = "Завершите авторизацию в браузере."; +"copilot_device_code" = "Код устройства скопирован в буфер обмена: %1$@\n\nПодтвердить по адресу: %2$@"; +"copilot_device_code_copied" = "Код устройства скопирован."; +"copilot_verify_at" = "Подтвердите на %@"; +"copilot_waiting_text" = "Завершите авторизацию в браузере.\nЭто окно закроется автоматически после завершения входа."; +"copilot_window_closes_auto" = "Это окно закроется автоматически после завершения входа."; +"cost_status_error" = "%1$@: %2$@"; +"cost_status_fetching" = "%1$@: загрузка… %2$@"; +"cost_status_last_attempt" = "%1$@: последняя попытка %2$@"; +"cost_status_no_data" = "%@: данных пока нет"; +"cost_status_snapshot" = "%1$@: %2$@ · %3$@ %4$@"; +"cost_status_unsupported" = "%@: не поддерживается"; +"credits_remaining" = "Кредиты: %@"; +"cursor_on_demand" = "По требованию: %@"; +"cursor_on_demand_with_limit" = "По требованию: %1$@ / %2$@"; +"extra_usage_format" = "Дополнительное использование: %1$@ / %2$@"; +"jetbrains_detected_generate" = "Обнаружено: %@. Используйте AI-помощник один раз, чтобы сгенерировать данные о квотах, затем обновите CodexBar."; +"jetbrains_detected_select" = "Обнаружено: %@. Выберите предпочитаемый IDE в настройках, затем обновите CodexBar."; +"last_fetch_failed_with_provider" = "Последнее получение %@ не удалось:"; +"last_spend" = "Последняя трата: %@"; +"mcp_model_usage" = "%1$@: %2$@"; +"mcp_resets" = "Сброс: %@"; +"mcp_window" = "Окно: %@"; +"metric_average" = "Среднее (%1$@ + %2$@)"; +"metric_primary" = "Основной (%@)"; +"metric_secondary" = "Вторичный (%@)"; +"metric_tertiary" = "Третичный (%@)"; +"multiple_workspaces_found" = "CodexBar нашёл несколько рабочих областей для %@. Выберите рабочую область для добавления."; +"ory_session_…=…; csrftoken=…" = "ory_session_…=…; csrftoken=…"; +"overview_choose_providers" = "Выберите до %@ провайдеров"; +"remove_account_message" = "Удалить %@ из CodexBar? Его управляемый каталог Codex будет удалён."; +"version_format" = "Версия %@"; +"vertex_ai_login_instructions" = "Чтобы отслеживать использование Vertex AI, авторизуйтесь через Google Cloud.\n\n1. Откройте Terminal\n2. Запустите: gcloud auth application-default login\n3. Следуйте инструкциям в браузере\n4. Укажите проект: gcloud config set project PROJECT_ID\n\nОткрыть Terminal сейчас?"; +"workspaceID is set but only opencode, opencodego, and deepgram support workspaceID." = "WorkspaceID установлен, но только opencode, opencodego и deepgram поддерживают WorkspaceID."; +"© 2026 Peter Steinberger. MIT License." = "© 2026 Peter Steinberger. Лицензия MIT."; + +/* General Pane */ +"section_system" = "Система"; +"section_usage" = "Использование"; +"section_refreshing" = "Обновление"; +"section_alerts" = "Оповещения"; +"section_celebrations" = "Празднования"; +"section_icon" = "Значок"; +"section_combined_icon" = "Объединённый значок"; +"section_animation" = "Анимация"; +"section_content" = "Содержимое"; +"section_agent_sessions" = "Сеансы агентов"; +"language_title" = "Язык"; +"language_subtitle" = "Изменяет язык интерфейса. Для полного применения нужен перезапуск приложения."; +"currency_title" = "Предпочитаемая валюта"; +"currency_subtitle" = "Валюта для оценки затрат и расходов. Используются курсы, обновляемые ежедневно."; +"currency_auto" = "Автоматически (валюта провайдера / USD)"; +"language_system" = "Системный"; +"language_english" = "English"; +"language_spanish" = "Español"; +"language_catalan" = "Català"; +"language_chinese_simplified" = "简体中文"; +"language_chinese_traditional" = "繁體中文"; +"language_portuguese_brazilian" = "Português (Brasil)"; +"language_dutch" = "Nederlands"; +"language_german" = "Deutsch"; +"language_swedish" = "Svenska"; +"language_french" = "Français"; +"language_ukrainian" = "Українська"; +"language_russian" = "Русский"; +"language_japanese" = "日本語"; +"language_korean" = "한국어"; +"language_turkish" = "Türkçe"; +"language_italian" = "Italiano"; +"language_polish" = "Polski"; +"start_at_login_title" = "Запускать при входе"; +"start_at_login_subtitle" = "Автоматически открывает CodexBar при запуске Mac."; +"show_cost_summary_subtitle" = "Читает локальные журналы использования. Показывает в меню сегодняшние расходы и выбранное окно истории."; +"cost_summary_style_title" = "Стиль отображения"; +"cost_summary_style_inline" = "Только в строке"; +"cost_summary_style_submenu" = "Только подменю"; +"cost_summary_style_both" = "Оба"; +"cost_summary_style_inline_help" = "Показывает сводку затрат прямо в главном меню."; +"cost_summary_style_submenu_help" = "Вместо этого отображается подробное подменю «Стоимость»."; +"cost_summary_style_both_help" = "Показывает как сводку главного меню, так и подробное подменю «Стоимость»."; +"cost_history_window_title" = "Окно истории"; +"cost_history_window_help" = "Задаёт, за сколько дней показывать локальные журналы использования в меню."; +"cost_history_days_title" = "Окно истории: %d дней"; +"cost_auto_refresh_info" = "Автоматическое обновление: общий интервал (минимум 5 минут) · Тайм-аут: 10 минут"; +"cost_comparison_periods_title" = "Показывать более короткие периоды сравнения"; +"cost_comparison_periods_subtitle" = "Добавляет итоги за 7, 30 и 90 дней, если они входят в выбранный период истории. Для этих итогов используется то же локальное сканирование."; +"refresh_interval_title" = "Частота обновления"; +"manual_refresh_hint" = "Автообновление отключено; используйте команду меню «Обновить»."; +"refresh_on_open_title" = "Обновить при открытии меню"; +"refresh_on_open_subtitle" = "Получайте последние данные об использовании для каждого провайдера каждый раз, когда вы открываете меню."; +"check_provider_status_title" = "Проверить статус провайдера"; +"check_provider_status_subtitle" = "Опрашивает страницы статуса OpenAI/Claude и Google Workspace для Gemini/Antigravity и показывает инциденты на значке и в меню."; +"session_quota_notifications_subtitle" = "Уведомляет, когда квота 5-часового сеанса достигает 0 % и когда она снова становится доступной."; +"quota_depleted_title" = "Исчерпание и восстановление квоты"; +"quota_warning_notifications_subtitle" = "Предупреждает, когда остаток сессионной или недельной квоты пересекает заданные пороги."; +"threshold_warnings_title" = "Предупреждения о порогах"; +"quota_warnings_title" = "Предупреждения о квотах"; +"quota_warning_session" = "сеанс"; +"quota_warning_session_capitalized" = "Сеанс"; +"quota_warning_weekly" = "неделя"; +"quota_warning_weekly_capitalized" = "Неделя"; +"quota_warning_notification_title" = "Низкая квота %1$@ %2$@"; +"quota_warning_notification_body" = "Осталось %1$@. Достигнут порог предупреждения %2$d%% для %3$@."; +"quota_warning_notification_body_with_account" = "Аккаунт %1$@. Осталось %2$@. Достигнут порог предупреждения %3$d%% для %4$@."; +"predictive_pace_warnings_title" = "Прогнозные предупреждения о темпе"; +"predictive_pace_warnings_subtitle" = "Предупреждает для Codex и Claude, если темп сеанса или недели может исчерпать квоту до сброса."; +"confetti_on_reset_title" = "Конфетти при сбросе"; +"confetti_on_reset_subtitle" = "Показывать полноэкранное конфетти при сбросе показателей использования."; +"confetti_option_off" = "Выкл."; +"confetti_option_session" = "Сбросы сеанса"; +"confetti_option_weekly" = "Недельные сбросы"; +"confetti_option_both" = "Оба варианта"; +"predictive_pace_warning_notification_title" = "%1$@: предупреждение о темпе (%2$@)"; +"predictive_pace_warning_notification_body" = "При текущем темпе эта квота может исчерпаться через %1$@, до сброса."; +"predictive_pace_warning_notification_body_with_account" = "Аккаунт %1$@. При текущем темпе эта квота может исчерпаться через %2$@, до сброса."; +"session_depleted_notification_title" = "Сеанс %@ исчерпан"; +"session_depleted_notification_body" = "Осталось 0%. Сообщим, когда квота снова станет доступна."; +"session_restored_notification_title" = "Сеанс %@ восстановлен"; +"session_restored_notification_body" = "Квота сеанса снова доступна."; +"quota_warning_warn_at" = "Предупреждать при"; +"quota_warning_global_threshold_subtitle" = "Остаток в процентах для сессионного и недельного окон, если провайдер не переопределяет пороги."; +"quota_warning_sound" = "Воспроизвести звук уведомления"; +"quota_warning_onscreen_alert" = "Показывать текстовое оповещение на экране"; +"quota_warning_provider_inherits" = "Использует глобальные настройки предупреждений о квоте, если окно не настроено отдельно."; +"quota_warning_provider_disabled" = "Уведомления о квотах и отметки на индикаторах использования отключены. Включите хотя бы одну из этих функций, чтобы изменить сохранённые настройки."; +"quota_warning_provider_markers_only" = "Уведомления о предупреждениях квоты отключены глобально. Эти настройки по-прежнему управляют отметками на индикаторах использования."; +"quota_warning_global" = "Глобально"; +"quota_warning_customize_thresholds" = "Настроить пороги %@"; +"quota_warning_enable_warnings" = "Включить предупреждения %@"; +"quota_warning_window_warn_at" = "Предупреждать для %@ при"; +"quota_warning_off" = "Выкл."; +"quota_warning_inherited" = "Унаследовано: %@"; +"quota_warning_depleted_only" = "только при исчерпании"; +"quota_warning_upper" = "Выше"; +"quota_warning_lower" = "Нижний порог"; +"quota_warning_warning" = "Предупреждение"; +"quota_warning_critical" = "Критично"; +"apply" = "Применить"; +"quit_app" = "Выйти из CodexBar"; + +/* Tab titles */ +"tab_general" = "Общие"; +"tab_providers" = "Провайдеры"; +"tab_notifications" = "Уведомления"; +"tab_menu_bar" = "Строка меню"; +"tab_menu" = "Меню"; +"tab_advanced" = "Расширенные"; +"tab_about" = "О приложении"; +"tab_debug" = "Отладка"; + +/* Providers Pane */ +"select_a_provider" = "Выберите провайдера"; +"cancel" = "Отмена"; +"last_fetch_failed" = "последнее получение не удалось"; +"usage_not_fetched_yet" = "данные ещё не получены"; +"managed_account_storage_unreadable" = "Хранилище управляемого аккаунта недоступно для чтения. Доступ к текущему аккаунту всё ещё доступен, но управляемое добавление, повторная авторизация и удаление отключены до восстановления хранилища."; +"remove_codex_account_title" = "Удалить аккаунт Codex?"; +"remove" = "Удалить"; +"managed_login_already_running" = "Управляемый вход Codex уже активен. Подождите, пока он завершится, прежде чем добавлять или повторно авторизовывать другой аккаунт."; +"managed_login_failed" = "Управляемый вход Codex не завершён. Убедитесь, что `codex --version` работает в Terminal. Если macOS заблокировала или переместила `codex` в Корзину, удалите старые дублирующиеся установки, запустите `npm install -g --include=optional @openai/codex@latest` и повторите попытку."; +"codex_login_output" = "вывод входа Codex:"; +"managed_login_missing_email" = "Вход в Codex выполнен, но email аккаунта недоступен. Повторите попытку после полного входа в аккаунт."; +"login_success_notification_title" = "%@ вход успешен"; +"login_success_notification_body" = "Можно вернуться в приложение; авторизация завершена."; +"workspace_selection_cancelled" = "CodexBar обнаружил несколько рабочих областей, но рабочая область не выбрана."; +"unsafe_managed_home" = "CodexBar отказался изменять неожиданный управляемый каталог: %@"; +"menu_bar_metric_title" = "Метрика строки меню"; +"menu_bar_metric_subtitle" = "Выберите, какое окно управляет процентами строки меню."; +"menu_bar_metric_subtitle_deepseek" = "Показывает баланс DeepSeek в строке меню."; +"menu_bar_metric_subtitle_moonshot" = "Показывает баланс Moonshot / Kimi API в строке меню."; +"menu_bar_metric_subtitle_mistral" = "В строке меню показаны расходы Mistral API за текущий месяц."; +"automatic" = "Автоматически"; +"primary_api_key_limit" = "Основной (лимит API-ключа)"; + +/* Display Pane */ +"menu_bar_style_title" = "Стиль строки меню"; +"menu_bar_style_subtitle" = "Как выглядит элемент строки меню."; +"menu_bar_inactive_display_contrast_title" = "Повысить видимость на неактивных дисплеях"; +"menu_bar_usage_colors_title" = "Цветовая индикация расхода"; +"menu_bar_usage_colors_subtitle" = "Окрашивает значок в строке меню от зелёного к красному по мере роста расхода."; +"menu_bar_inactive_display_contrast_subtitle" = "Использует высококонтрастную отрисовку, чтобы значок и показатель оставались читаемыми на других дисплеях."; +"menu_bar_style_critters" = "Декоративные индикаторы"; +"menu_bar_style_bars" = "Полосы-индикаторы"; +"menu_bar_style_icon_percent" = "Значок и процент"; +"switcher_rows_title" = "Строки переключателя"; +"switcher_rows_icons" = "Значки провайдеров"; +"switcher_rows_progress" = "Недельный прогресс"; +"usage_bars_fill_title" = "Заполнение индикаторов использования"; +"usage_bars_fill_remaining" = "По остатку"; +"usage_bars_fill_used" = "По расходу"; +"reset_times_title" = "Время сброса"; +"reset_times_countdown" = "Обратный отсчёт"; +"reset_times_clock" = "Точное время"; +"cost_summary_title" = "Сводка расходов"; +"cost_summary_off" = "Выкл."; +"merge_icons_title" = "Объединять значки"; +"merge_icons_subtitle" = "Использовать один значок в строке меню с переключателем провайдеров."; +"show_most_used_provider_title" = "Показывать провайдера с наибольшим использованием"; +"show_most_used_provider_subtitle" = "В строке меню автоматически отображается провайдер, ближайший к лимиту."; +"display_mode_title" = "Режим отображения"; +"display_mode_subtitle" = "Выберите, что показывать в строке меню: темп сравнивает фактическое использование с ожидаемым."; +"show_quota_warning_markers_title" = "Показывать маркеры предупреждений о квоте"; +"show_quota_warning_markers_subtitle" = "Рисует отметки порогов на индикаторах использования, если настроены предупреждения о квотах."; +"weekly_progress_work_days_title" = "Рабочие дни для недельного прогресса"; +"weekly_progress_work_days_subtitle" = "Задаёт рабочие дни для недельных отметок использования и расчёта темпа."; +"show_provider_changelog_links_title" = "Показывать ссылки на журналы изменений провайдеров"; +"show_provider_changelog_links_subtitle" = "Добавляет в меню ссылки на заметки к релизам для поддерживаемых CLI-провайдеров."; +"show_credits_extra_usage_title" = "Показывать кредиты и доп. использование"; +"show_credits_extra_usage_subtitle" = "Показывать в меню разделы кредитов Codex и дополнительного использования Claude."; +"multi_account_layout_title" = "Макет нескольких аккаунтов"; +"multi_account_layout_subtitle" = "Выберите сегментированное переключение аккаунтов или сгруппированные карты аккаунтов."; +"multi_account_layout_segmented" = "Сегментированный"; +"multi_account_layout_stacked" = "Стопкой"; +"overview_tab_providers_title" = "Провайдеры вкладки «Обзор»"; +"configure" = "Настроить…"; +"overview_enable_merge_icons_hint" = "Включите «Объединять значки», чтобы настроить провайдеров вкладки «Обзор»."; +"overview_no_providers_hint" = "Нет включённых провайдеров для обзора."; +"overview_rows_follow_order" = "Строки обзора всегда следуют порядку провайдеров."; +"overview_no_providers_selected" = "Провайдеры не выбраны"; +"agent_sessions_title" = "Сеансы агентов"; +"agent_sessions_subtitle" = "Показывать в меню локальные и обнаруженные по SSH сеансы Codex и Claude Code."; +"agent_sessions_hosts_title" = "Дополнительные SSH-хосты"; +"agent_sessions_footer" = "Компьютеры Mac в вашей сети tailnet обнаруживаются автоматически. Локальные сеансы обновляются каждые 30 секунд, удалённые хосты — каждые 60 секунд и при открытии меню."; +"agent_session_labels_title" = "Названия сеансов"; +"agent_session_labels_subtitle" = "Выберите, как называть сеансы агентов."; +"agent_session_label_project" = "Проект"; +"agent_session_label_descriptive" = "Описательное"; +"agent_session_label_descriptive_and_project" = "Описательное + проект"; +"agent_session_unknown_project" = "Неизвестный проект"; + +/* Advanced Pane */ +"section_keyboard_shortcut" = "Сочетание клавиш"; +"open_menu_shortcut_title" = "Открыть меню"; +"open_menu_shortcut_subtitle" = "Вызывать меню строки меню из любого места."; +"install_cli" = "Установить CLI"; +"install_cli_subtitle" = "Создать symlink CodexBarCLI как codexbar в /usr/local/bin и /opt/homebrew/bin."; +"cli_not_found" = "CodexBarCLI не найден в комплекте приложения."; +"no_writable_bin_dirs" = "Не найдено доступных для записи bin-каталогов."; +"show_debug_settings_title" = "Показать настройки отладки"; +"show_debug_settings_subtitle" = "Показывать инструменты диагностики на вкладке «Отладка»."; +"surprise_me_title" = "Удиви меня"; +"surprise_me_subtitle" = "Включите, если хотите немного оживить индикаторы агентов в строке меню."; +"hide_personal_info_title" = "Скрыть личную информацию"; +"hide_personal_info_subtitle" = "Скрывает email-адреса в строке меню и интерфейсе меню."; +"show_provider_storage_usage_title" = "Показать использование хранилища провайдера"; +"show_provider_storage_usage_subtitle" = "Показывать использование локального диска в меню. Сканирует известные пути, принадлежащие провайдеру, в фоновом режиме."; +"section_keychain_access" = "Доступ к Keychain"; +"keychain_access_caption" = "Отключает все операции чтения и записи Keychain. Используйте это, если macOS продолжает запрашивать «Chrome/Brave/Edge Safe Storage» даже после нажатия «Всегда разрешать». Импорт cookie браузера недоступен, пока настройка включена; вставьте заголовки Cookie вручную в разделе «Провайдеры». Claude/Codex OAuth через CLI по-прежнему работает."; +"disable_keychain_access_title" = "Отключить доступ к Keychain"; +"disable_keychain_access_subtitle" = "Блокирует любой доступ к Keychain, пока настройка включена."; + +/* About Pane */ +"about_tagline" = "Пусть ваши токены никогда не закончатся — помните об ограничениях агентов."; +"link_github" = "GitHub"; +"link_website" = "Сайт"; +"link_twitter" = "Twitter"; +"link_email" = "Email"; +"check_updates_auto" = "Автоматическая проверка обновлений"; +"update_channel" = "Канал обновлений"; +"check_for_updates" = "Проверить наличие обновлений…"; +"updates_unavailable" = "Обновления недоступны в этой сборке."; +"copyright" = "© 2026 Peter Steinberger. Лицензия MIT."; + +/* Debug Pane */ +"section_logging" = "Журналирование"; +"enable_file_logging" = "Включить запись логов"; +"enable_file_logging_subtitle" = "Записывать логи в %@ для отладки."; +"verbosity_title" = "Подробность"; +"verbosity_subtitle" = "Управляет подробностью журналирования."; +"open_log_file" = "Открыть файл логов"; +"force_animation_next_refresh" = "Показать анимацию при следующем обновлении"; +"force_animation_next_refresh_subtitle" = "Временно показывает анимацию загрузки после следующего обновления."; +"section_loading_animations" = "Анимации загрузки"; +"loading_animations_caption" = "Выберите шаблон и воспроизведите его в строке меню. «Случайный» сохраняет существующее поведение."; +"animation_random_default" = "Случайный (по умолчанию)"; +"replay_selected_animation" = "Воспроизвести выбранную анимацию"; +"blink_now" = "Моргнуть сейчас"; +"section_probe_logs" = "Журналы проверок"; +"probe_logs_caption" = "Получить последние выходные данные проверки для отладки; копирование сохраняет полный текст."; +"fetch_log" = "Получить лог"; +"copy" = "Копировать"; +"save_to_file" = "Сохранить в файл"; +"load_parse_dump" = "Загрузить дамп синтаксического анализа"; +"rerun_provider_autodetect" = "Повторно запустить автоопределение провайдера"; +"loading" = "Загрузка…"; +"no_log_yet_fetch" = "Лога пока нет. Нажмите «Получить лог», чтобы загрузить его."; +"section_fetch_strategy" = "Попытки стратегии получения данных"; +"fetch_strategy_caption" = "Решения и ошибки последнего получения данных для провайдера."; +"section_openai_cookies" = "Cookie OpenAI"; +"openai_cookies_caption" = "Импорт Cookie и журналы WebKit-сканирования из последней попытки импорта Cookie OpenAI."; +"no_log_yet" = "Журнала пока нет. Обновите cookie OpenAI в разделе «Провайдеры» → Codex, чтобы запустить импорт."; +"section_caches" = "Кэши"; +"caches_caption" = "Очистите кэшированные результаты сканирования затрат или кэши cookie браузера."; +"clear_cookie_cache" = "Очистить кэш cookie"; +"clear_cost_cache" = "Очистить кэш затрат"; +"section_notifications" = "Уведомления"; +"notifications_caption" = "Запускает тестовые уведомления для 5-часового окна сеанса (исчерпано/восстановлено)."; +"post_depleted" = "Показать «исчерпано»"; +"post_restored" = "Показать «восстановлено»"; +"section_cli_sessions" = "CLI-сеансы"; +"cli_sessions_caption" = "Поддерживать сеансы Codex/Claude CLI после проверки. По умолчанию они завершаются после сбора данных."; +"keep_cli_sessions_alive" = "Оставлять CLI-сеансы активными"; +"keep_cli_sessions_alive_subtitle" = "Не завершать сеансы между проверками (только для отладки)."; +"reset_cli_sessions" = "Сбросить CLI-сеансы"; +"section_error_simulation" = "Моделирование ошибок"; +"error_simulation_caption" = "Вставьте поддельное сообщение об ошибке в карточку меню для тестирования макета."; +"set_menu_error" = "Установить ошибку меню"; +"clear_menu_error" = "Удалить ошибку меню"; +"set_cost_error" = "Установить ошибку стоимости"; +"clear_cost_error" = "Удалить ошибку стоимости"; +"section_cli_paths" = "Пути CLI"; +"cli_paths_caption" = "Найденные бинарные файлы Codex и слои PATH; снимок PATH login shell при запуске (короткий тайм-аут)."; +"codex_binary" = "Бинарный файл Codex"; +"claude_binary" = "Бинарный файл Claude"; +"effective_path" = "Эффективный PATH"; +"unavailable" = "Недоступно"; +"login_shell_path" = "PATH login shell (снимок при запуске)"; +"cleared" = "Очищено."; +"no_fetch_attempts" = "Попыток получения пока нет."; +"macOS Tahoe can block menu bar apps in System Settings → Menu Bar → Allow in the Menu Bar. CodexBar is running, but macOS may be hiding its icon. Open Menu Bar settings and turn CodexBar on." = "macOS Tahoe может блокировать приложения строки меню в разделе «Системные настройки» → «Строка меню» → «Разрешить в строке меню». CodexBar запущен, но macOS может скрывать его значок. Откройте настройки строки меню и включите CodexBar."; + +/* Metric preferences */ +"metric_pref_automatic" = "Автоматически"; +"metric_pref_primary" = "Основной"; +"metric_pref_secondary" = "Вторичный"; +"metric_pref_tertiary" = "Третичный"; +"metric_pref_extra_usage" = "Дополнительное использование"; +"metric_pref_average" = "Среднее"; +"metric_mistral_payg" = "Оплата по мере использования"; +"metric_mistral_monthly_plan" = "Ежемесячный план"; + +/* Display modes */ +"display_mode_percent" = "Процент"; +"display_mode_pace" = "Темп"; +"display_mode_both" = "Оба"; +"display_mode_reset_time" = "Время сброса"; +"display_mode_percent_desc" = "Показывать оставшийся или израсходованный процент (например, 45%)"; +"display_mode_pace_desc" = "Показывать индикатор темпа (например, +5%)"; +"display_mode_both_desc" = "Показывать и процент, и темп (например, 45% · +5%)"; +"display_mode_reset_time_desc" = "Показывать время сброса для выбранного показателя (например, ↻ 15:56)."; +"menu_bar_reset_when_exhausted_title" = "Показывать время сброса, когда квота исчерпана"; +"menu_bar_reset_when_exhausted_subtitle" = "При 0% остатка показывает время до сброса вместо процента"; + +/* Provider status */ +"status_operational" = "Работает"; +"status_degraded" = "Сниженная производительность"; +"status_partial_outage" = "Частичный сбой"; +"status_major_outage" = "Серьёзный сбой"; +"status_critical_issue" = "Критическая проблема"; +"status_maintenance" = "Техническое обслуживание"; +"status_unknown" = "Статус неизвестен"; + +/* Refresh frequency */ +"refresh_manual" = "Вручную"; +"refresh_1min" = "1 мин."; +"refresh_2min" = "2 мин"; +"refresh_5min" = "5 мин."; +"refresh_15min" = "15 мин."; +"refresh_30min" = "30 мин."; +"refresh_adaptive" = "Адаптивно"; +"refresh_adaptive_agent_aware" = "Адаптивно (с учётом агентов)"; +"adaptive_activity_consent_title" = "Разрешить обновление с учётом активности?"; +"adaptive_activity_consent_message" = "Адаптивный режим с учётом агентов может проверять список запущенных локальных процессов, включая командные строки, чтобы распознавать Codex и Claude, а затем во время программирования каждые 30 секунд считывать метаданные известных сеансов. Когда Agent Sessions отключено, CodexBar хранит в памяти только время последней активности и отбрасывает пути и идентификаторы сеансов. Эти данные никуда не отправляются, а удалённое обнаружение и SSH остаются отключёнными. При отказе CodexBar вернётся к обычному адаптивному режиму без сканирования локальной активности."; +"adaptive_activity_consent_allow" = "Разрешить локальную активность"; +"adaptive_activity_consent_decline" = "Использовать обычный адаптивный режим"; + +/* Additional keys */ +"not_found" = "Не найден"; + +/* Cost estimation */ +"cost_estimate_hint" = "Оценка на основе локальных журналов · может отличаться от суммы в счёте."; +"codex_api_estimate_hint" = "Расчёт по использованию токенов · не счёт за подписку"; +"cost_data_explanation" = "Расходы могут быть предоставлены провайдером или рассчитаны по использованию токенов на основе общедоступных цен API. Оценки не являются платой за подписку."; +"No JetBrains IDE with AI Assistant detected. Install a JetBrains IDE and enable AI Assistant." = "JetBrains IDE с включённым AI Assistant не обнаружена. Установите JetBrains IDE и включите AI Assistant."; +"OpenRouter API token not configured. Set OPENROUTER_API_KEY environment variable or configure in Settings." = "API-токен OpenRouter не настроен. Задайте переменную окружения OPENROUTER_API_KEY или настройте его в настройках."; +"z.ai API token not found. Set apiKey in ~/.codexbar/config.json or Z_AI_API_KEY." = "API-токен z.ai не найден. Задайте apiKey в ~/.codexbar/config.json или Z_AI_API_KEY."; +"Missing DeepSeek API key." = "Отсутствует API-ключ DeepSeek."; +"%@ is unavailable in the current environment." = "%@ недоступен в текущей среде."; +"All Systems Operational" = "Все системы в рабочем состоянии"; +"Last 30 days" = "Последние 30 дней"; +"Last 30 days:" = "Последние 30 дней:"; +"This month" = "В этом месяце"; +"Store multiple OpenAI API keys." = "Хранить несколько API-ключей OpenAI."; +"Admin API key" = "Admin API-ключ"; +"Open billing" = "Открыть биллинг"; +"Google accounts" = "Аккаунты Google"; +"Store multiple Antigravity Google OAuth accounts for quick switching." = "Хранить несколько аккаунтов Antigravity Google OAuth для быстрого переключения."; +"Add Google Account" = "Добавить аккаунт Google"; +"Open Token Plan" = "Открыть Token Plan"; +"Text Generation" = "Генерация текста"; +"Text to Speech" = "Преобразование текста в речь"; +"Music Generation" = "Генерация музыки"; +"Image Generation" = "Генерация изображений"; +"No local data found" = "Локальные данные не найдены"; +"Credits unavailable; keep Codex running to refresh." = "Кредиты недоступны; оставьте Codex запущенным для обновления."; +"No available fetch strategy for minimax." = "Нет доступной стратегии получения для MiniMax."; +"No Cursor session found. Please log in to cursor.com in Safari, Chrome, Microsoft Edge, Brave, Arc, Dia, ChatGPT Atlas, Chromium, Helium, Vivaldi, Yandex Browser, Firefox, Zen, Colibri, Sidekick, Opera, Opera GX, or Edge Canary. If you use Safari, grant CodexBar Full Disk Access in System Settings ▸ Privacy & Security. You can also sign in to Cursor from the CodexBar menu (Add / switch account)." = "Сеанс Cursor не найден. Войдите на cursor.com в Safari, Chrome, Microsoft Edge, Brave, Arc, Dia, ChatGPT Atlas, Chromium, Helium, Vivaldi, Яндекс.Браузере, Firefox, Zen, Colibri, Sidekick, Opera, Opera GX или Edge Canary. Если используете Safari, выдайте CodexBar полный доступ к диску в «Системные настройки» ▸ «Конфиденциальность и безопасность». Также можно войти в Cursor из меню CodexBar: «Добавить/переключить аккаунт»."; +"No OpenCode session cookies found in browsers." = "В браузерах не найдены cookie сеанса OpenCode."; +"No available fetch strategy for %@." = "Нет доступной стратегии получения для %@."; +"Today" = "Сегодня"; +"Today tokens" = "Токены сегодня"; +"30d cost" = "Стоимость за 30 дн."; +"%@ cost" = "Стоимость за %@"; +"30d tokens" = "Токены за 30 дн."; +"Latest tokens" = "Последние токены"; +"Top model" = "Топ-модель"; +"Storage" = "Хранение"; +"Add Account..." = "Добавить аккаунт…"; +"Usage Dashboard" = "Дашборд использования"; +"Status Page" = "Страница статуса"; +"Open Status Page" = "Открыть страницу статуса"; +"Settings..." = "Настройки…"; +"About CodexBar" = "О CodexBar"; +"Quit" = "Выйти"; +"Last %d day" = "Последний %d день"; +"Last %d days" = "Последние %d дн."; +"%@ tokens" = "%@ токенов"; +"Latest billing day" = "Последний день биллинга"; +"Latest billing day (%@)" = "Последний день биллинга (%@)"; +"%@ left" = "%@ осталось"; +"Resets %@" = "Сброс %@"; +"Resets in %@" = "Сбрасывается через %@"; +"Resets now" = "Сбрасывается сейчас"; +"reset_tomorrow_format" = "завтра, %@"; +"Lasts until reset" = "Действует до сброса"; +"Updated %@" = "Обновлено %@"; +"Updated relative %@" = "Обновлено %@"; +"Updated absolute %@" = "Обновлено %@"; +"Updated %@h ago" = "Обновлено %@ч назад"; +"Updated %@m ago" = "Обновлено %@ мин. назад"; +"Updated just now" = "Обновлено только что"; +"Projected empty in %@" = "По прогнозу закончится через %@"; +"Runs out in %@" = "Закончится через %@"; +"Pace: %@" = "Темп: %@"; +"Pace: %@ · %@" = "Темп: %@ · %@"; +"1.5× headroom" = "Запас 1,5×"; +"%@ · %@" = "%@ · %@"; +"≈ %d%% run-out risk" = "≈ %d%% риск исчерпания"; +"%d%% in deficit" = "%d%% в дефиците"; +"%d%% in reserve" = "%d%% в резерве"; +"usage_percent_suffix_left" = "осталось"; +"usage_percent_suffix_used" = "использовано"; +"Store multiple DeepSeek API keys." = "Хранить несколько API-ключей DeepSeek."; +"This week" = "На этой неделе"; +"Week" = "неделя"; +"Month" = "Месяц"; +"Models" = "Модели"; +"24h tokens" = "Токены за 24 ч"; +"Latest hour" = "Последний час"; +"Peak hour" = "Час пик"; +"Top method" = "Основной метод"; +"30d cash" = "Расходы за 30 дн."; +"30d billing history from MiniMax web session" = "История платежей за 30 дней с веб-сеанса MiniMax"; +"AWS Cost Explorer billing can lag." = "Данные биллинга AWS Cost Explorer могут обновляться с задержкой."; +"Rate limit: %d / %@" = "Лимит запросов: %d / %@"; +"Key remaining" = "Осталось по ключу"; +"No limit set for the API key" = "Для API-ключа ограничение не задано."; +"API key limit unavailable right now" = "Лимит API-ключа сейчас недоступен"; +"This month: %@ tokens" = "В этом месяце: %@ токенов"; +"No utilization data yet." = "Данных об использовании пока нет."; +"No %@ utilization data yet." = "Данных об использовании %@ пока нет."; +"%@: %@%% used" = "%@: %@%% использовано"; +"%dd" = "%d дн."; +"today" = "сегодня"; +"just now" = "только что"; +"On pace" = "В темпе"; +"Runs out now" = "Сейчас заканчивается"; +"Projected empty now" = "По прогнозу закончится сейчас"; +"Switch Account..." = "Сменить аккаунт…"; +"Update ready, restart now?" = "Обновление готово, перезапустить сейчас?"; +"Daily" = "Ежедневно"; +"Hourly Tokens" = "Токены по часам"; +"No data" = "Нет данных"; +"No usage breakdown data available." = "Детализация использования недоступна."; + +"Today: %@ · %@ tokens" = "Сегодня: %@ · %@ токенов"; +"Today: %@" = "Сегодня: %@"; +"Today: %@ tokens" = "Сегодня: %@ токенов"; +"Last 30 days: %@ · %@ tokens" = "За последние 30 дней: %@ · %@ токенов"; +"Last 30 days: %@" = "Последние 30 дней: %@"; +"Est. total (30d): %@" = "Итого, оценка (30д): %@"; +"Est. total (%@): %@" = "Итого, оценка (%@): %@"; +"Hover a bar for details" = "Наведите на полосу, чтобы увидеть подробности"; +"%@: %@ · %@ tokens" = "%@: %@ · %@ токенов"; +"%@: %@" = "%@: %@"; +"No providers selected for Overview." = "Провайдеры для обзора не выбраны."; +"No overview data available." = "Обзорные данные отсутствуют."; +"Auto uses the local IDE API first, then Google OAuth when the IDE is closed." = "Автоматически сначала использует локальный API IDE, затем Google OAuth, когда IDE закрыта."; +"Login with Google" = "Войти через Google"; + +/* Popup panels */ +"No usage configured." = "Использование не настроено."; +"Quota" = "Квота"; +"Daily quota" = "Дневная квота"; +"Total" = "Всего"; +"tokens" = "токены"; +"requests" = "запросы"; +"Latest" = "Последние"; +"Monthly" = "Ежемесячно"; +"Sonnet" = "Sonnet"; +"Overages" = "Перерасходы"; +"Activity" = "Активность"; +"Copied" = "Скопировано"; +"Copy error" = "Ошибка копирования"; +"Copy path" = "Копировать путь"; +"Extra usage spent" = "Потрачено доп. использования"; +"Credits remaining" = "Оставшиеся кредиты"; +"Using CLI fallback" = "Используется резервный CLI"; +"Balance updates in near-real time (up to 5 min lag)" = "Обновления баланса практически в реальном времени (с задержкой до 5 минут)"; +"Daily billing data finalizes at 07:00 UTC" = "Данные ежедневного биллинга фиксируются в 07:00 UTC"; +"%@ of %@ credits left" = "Осталось %@ из %@ кредитов"; +"%@ of %@ bonus credits left" = "Осталось %@ из %@ бонусных кредитов"; +"%@ / %@ (%@ remaining)" = "%@ / %@ (осталось %@)"; +"%@/%@ left" = "%@/%@ осталось"; +"Gemini Flash" = "Gemini Flash"; +"Regenerates %@" = "Пополняется %@"; +"used after next regen" = "будет использовано после следующего пополнения"; +"after next regen" = "после следующего пополнения"; +"Near full" = "Почти заполнено"; +"Full in ~1 regen" = "Заполнится примерно за 1 пополнение"; +"Full in ~%.0f regens" = "Заполнится примерно за %.0f пополнений"; +"Overage usage" = "Перерасход"; +"Overage cost" = "Стоимость перерасхода"; +"credits" = "кредиты"; +"Zen balance" = "Баланс Zen"; +"API spend" = "Расходы API"; +"Extra usage" = "Дополнительное использование"; +"Quota usage" = "Использование квоты"; +"Your spend" = "Ваши расходы"; +"%.0f%% used" = "%.0f%% использовано"; +"Usage history (today)" = "История использования (сегодня)"; +"Usage history (%d days)" = "История использования (%d дней)"; +"%d percent remaining" = "Осталось %d процентов"; +"Unknown" = "Неизвестно"; +"stale data" = "устаревшие данные"; +"No credits history data." = "Нет истории кредитов."; +"No credits history data available." = "История кредитов недоступна."; +"Credits history chart" = "График истории кредитов"; +"%d days of credits data" = "%d дней данных о кредитах"; +"Usage breakdown chart" = "Диаграмма детализации использования"; +"%d days of usage data across %d services" = "Данные об использовании за %d дней по %d сервисам"; +"Cost history chart" = "Диаграмма истории расходов"; +"%d days of cost data" = "%d дней данных о расходах"; +"Plan utilization chart" = "График использования плана"; +"%d utilization samples" = "%d замеров использования"; +"Hourly Usage" = "Использование по часам"; +"Usage remaining" = "Осталось"; +"Usage used" = "Использовано"; +"API key verified. Cloud quotas need browser cookies. Sign in to Ollama." = "API-ключ проверен. Для квот Cloud нужны файлы cookie браузера. Войдите в Ollama."; +"Last 30 days: %@ tokens" = "За последние 30 дней: %@ токенов"; +"7d spend" = "Расходы за 7 дн."; +"30d spend" = "Расходы за 30 дн."; +"Cache read" = "Чтение кэша"; +"Claude Admin API 30 day spend trend" = "Тренд расходов Claude Admin API за 30 дней"; +"OpenRouter API key spend trend" = "Тренд расходов API-ключа OpenRouter"; +"z.ai hourly token trend" = "Почасовой тренд токенов z.ai"; +"MiniMax 30 day token usage trend" = "Тренд использования токенов MiniMax за 30 дней"; +"Today cash" = "Расходы сегодня"; +"DeepSeek 30 day token usage trend" = "Тренд использования токенов DeepSeek за 30 дней"; +"cache-hit input" = "ввод с попаданием в кэш"; +"cache-miss input" = "ввод без попадания в кэш"; +"output" = "вывод"; +"Requests" = "Запросы"; +"Reported by OpenAI Admin API organization usage." = "По данным OpenAI Admin API об использовании организации."; +"Reported by Mistral billing usage." = "По данным биллинга Mistral."; +"Google OAuth" = "Google OAuth"; +"Add accounts via GitHub OAuth Device Flow on the selected host." = "Добавлять аккаунты через GitHub OAuth Device Flow на выбранном хосте."; +"Stores each signed-in Google account for quick Antigravity switching. Uses Antigravity.app OAuth when available, or ANTIGRAVITY_OAUTH_CLIENT_ID and ANTIGRAVITY_OAUTH_CLIENT_SECRET as an override." = "Сохраняет каждый вошедший аккаунт Google для быстрого переключения Antigravity. Использует Antigravity.app OAuth, если он доступен, или ANTIGRAVITY_OAUTH_CLIENT_ID и ANTIGRAVITY_OAUTH_CLIENT_SECRET как переопределение."; +"Manual cleanup: past sessions" = "Ручная очистка: прошлые сеансы"; +"Clearing removes past resume, continue, and rewind history." = "Очистка удалит историю resume, continue и rewind."; +"Manual cleanup: file checkpoints" = "Ручная очистка: контрольные точки файлов"; +"Clearing removes checkpoint restore data for previous edits." = "Очистка удалит данные восстановления контрольных точек для прошлых правок."; +"Manual cleanup: saved plans" = "Ручная очистка: сохранённые планы"; +"Clearing removes old plan-mode files." = "Очистка удалит старые файлы режима планирования."; +"Manual cleanup: debug logs" = "Ручная очистка: журналы отладки"; +"Clearing removes past debug logs." = "Очистка удалит прошлые отладочные логи."; +"Manual cleanup: attachment cache" = "Ручная очистка: кэш вложений"; +"Clearing removes cached large pastes or attached images." = "Очистка удалит кэшированные большие вставки и вложенные изображения."; +"Manual cleanup: session metadata" = "Ручная очистка: метаданные сеанса"; +"Clearing removes per-session environment metadata." = "Очистка удалит метаданные окружения для каждого сеанса."; +"Manual cleanup: shell snapshots" = "Ручная очистка: снимки оболочки"; +"Clearing removes leftover runtime shell snapshot files." = "Очистка удалит оставшиеся runtime-снимки shell."; +"Manual cleanup: legacy todos" = "Ручная очистка: устаревшие задачи"; +"Clearing removes legacy per-session task lists." = "Очистка удалит устаревшие списки задач по сеансам."; +"Manual cleanup: sessions" = "Ручная очистка: сеансы"; +"Clearing removes past Codex session history." = "Очистка удалит историю прошлых сеансов Codex."; +"Manual cleanup: archived sessions" = "Ручная очистка: заархивированные сеансы"; +"Clearing removes archived Codex session history." = "Очистка удалит архивную историю сеансов Codex."; +"Manual cleanup: cache" = "Ручная очистка: кэш"; +"Clearing removes provider-owned cached data." = "Очистка удалит кэшированные данные провайдеров."; +"Manual cleanup: logs" = "Ручная очистка: журналы"; +"Clearing removes local diagnostic logs." = "Очистка удалит локальные диагностические логи."; +"Manual cleanup: file history" = "Ручная очистка: история файлов"; +"Clearing removes local edit checkpoint history." = "Очистка удалит локальную историю контрольных точек правок."; +"Manual cleanup: temporary data" = "Ручная очистка: временные данные"; +"Clearing removes local temporary provider data." = "Очистка удалит локальные временные данные провайдеров."; +"Total: %@" = "Итого: %@"; +"%d more items" = "Ещё %d элементов"; +"Other (%d items)" = "Другое (%d шт.)"; +"Expand" = "Развернуть"; +"Collapse" = "Свернуть"; +"Cleanup ideas" = "Рекомендации по очистке"; +"%d unreadable item(s) skipped" = "%d нечитаемых элементов пропущено"; + +"API key limit" = "Лимит API-ключа"; +"Auth" = "Авторизация"; +"Auto" = "Авто"; +"Disabled — no recent data" = "Отключено — нет последних данных"; +"Limits not available" = "Лимиты недоступны"; +"No usage yet" = "Использования пока нет"; +"Not fetched yet" = "Ещё не получено"; +"Refreshing" = "Обновление"; +"Session" = "Сеанс"; +"Source" = "Источник"; +"State" = "Состояние"; +"Unavailable" = "Недоступно"; +"Weekly" = "Недельный"; +"not detected" = "не обнаружено"; +"Estimated from local Codex logs for the selected account." = "Оценено на основе локальных журналов Codex для выбранного аккаунта."; +"minimax_usage_amount_format" = "Использование: %@ / %@"; +"minimax_used_percent_format" = "Использовано %@"; +"minimax_service_text_generation" = "Генерация текста"; +"minimax_service_text_to_speech" = "Преобразование текста в речь"; +"minimax_service_music_generation" = "Генерация музыки"; +"minimax_service_image_generation" = "Генерация изображений"; +"minimax_service_lyrics_generation" = "Генерация текстов"; +"minimax_service_coding_plan_vlm" = "Coding Plan VLM"; +"minimax_service_coding_plan_search" = "Поиск Coding Plan"; + +/* Additional provider settings and alerts */ +"%@ is waiting for permission" = "%@ ожидает разрешения"; +"%@ requests" = "%@ запросов"; +"%@: %@ credits" = "%@: %@ кредитов"; +"30d requests" = "Запросы за 30 дн."; +"4 days" = "4 дня"; +"5 days" = "5 дней"; +"7 days" = "7 дней"; +"API key verifies Ollama Cloud access; cookies still expose quota limits." = "API-ключ проверяет доступ к Ollama Cloud; cookie всё ещё нужны для лимитов квот."; +"AWS access key ID. Can also be set with AWS_ACCESS_KEY_ID." = "Идентификатор ключа доступа AWS. Также можно задать через AWS_ACCESS_KEY_ID."; +"AWS region. Can also be set with AWS_REGION." = "Регион AWS. Также можно задать через AWS_REGION."; +"AWS secret access key. Can also be set with AWS_SECRET_ACCESS_KEY." = "Секретный ключ доступа AWS. Также можно задать через AWS_SECRET_ACCESS_KEY."; +"Access key ID" = "Идентификатор ключа доступа"; +"Add Account" = "Добавить аккаунт"; +"Adding Account…" = "Добавление аккаунта…"; +"Antigravity login failed" = "Не удалось войти в Antigravity"; +"Antigravity login timed out" = "Время входа в Antigravity истекло"; +"Auth source" = "Источник авторизации"; +"Automatic imports browser cookies from Xiaomi MiMo." = "Автоматически импортирует cookie браузера из Xiaomi MiMo."; +"Automatic imports Windsurf session data from Chromium browser localStorage." = "Автоматически импортирует данные сеанса Windsurf из localStorage браузера Chromium."; +"Automatic imports browser cookies from Bailian." = "Автоматически импортирует cookie браузера из Bailian."; +"Automatically imports browser cookies." = "Автоматически импортирует cookie браузера."; +"Automatically imports browser session cookies." = "Автоматически импортирует cookie сеанса браузера."; +"Azure OpenAI deployment name. AZURE_OPENAI_DEPLOYMENT_NAME is also supported." = "Имя развёртывания Azure OpenAI. Также поддерживается AZURE_OPENAI_DEPLOYMENT_NAME."; +"Azure OpenAI key" = "API-ключ Azure OpenAI"; +"Azure OpenAI resource endpoint. AZURE_OPENAI_ENDPOINT is also supported." = "Эндпоинт ресурса Azure OpenAI. Также поддерживается AZURE_OPENAI_ENDPOINT."; +"Base URL" = "Базовый URL"; +"Base URL for the LLM-API-Key-Proxy instance." = "Базовый URL для экземпляра LLM-API-Key-Proxy."; +"Browser cookies" = "Cookie браузера"; +"Cap end" = "Окончание лимита"; +"Cap start" = "Начало лимита"; +"Capacity End" = "Окончание лимита"; +"Capacity Start" = "Начало лимита"; +"Changelog" = "Журнал изменений"; +"Choose the Moonshot/Kimi API host for international or China mainland accounts." = "Выберите хост Moonshot/Kimi API для международных аккаунтов или аккаунтов в материковом Китае."; +"CodexBar can't replace a system account that is signed in with an API key only setup." = "CodexBar не может заменить системный аккаунт, который настроен только через API-ключ."; +"CodexBar could not find saved auth for that account. Re-authenticate it and try again." = "CodexBar не удалось найти сохранённые данные авторизации для этого аккаунта. Авторизуйтесь заново и повторите попытку."; +"CodexBar could not read managed account storage. Recover the store before adding another account." = "CodexBar не удалось прочитать хранилище управляемых аккаунтов. Восстановите хранилище перед добавлением другого аккаунта."; +"CodexBar could not read saved auth for that account. Re-authenticate it and try again." = "CodexBar не удалось прочитать сохранённые данные авторизации для этого аккаунта. Авторизуйтесь заново и повторите попытку."; +"CodexBar could not read the current system account on this Mac." = "CodexBar не удалось прочитать текущий системный аккаунт на этом Mac."; +"CodexBar could not replace the live Codex auth on this Mac." = "CodexBar не удалось заменить текущую авторизацию Codex на этом Mac."; +"CodexBar could not safely preserve the current system account before switching." = "CodexBar не удалось безопасно сохранить текущий системный аккаунт перед переключением."; +"CodexBar could not save the current system account before switching." = "CodexBar не удалось сохранить текущий системный аккаунт перед переключением."; +"CodexBar could not update managed account storage." = "CodexBar не удалось обновить хранилище управляемых аккаунтов."; +"CodexBar found another managed account that already uses the current system account. Resolve the duplicate account before switching." = "CodexBar обнаружил другой управляемый аккаунт, который уже использует текущий системный аккаунт. Устраните дублирующий аккаунт перед переключением."; +"CodexBar will ask macOS Keychain for “%@” so it can decrypt browser cookies and authenticate your account. Click OK to continue." = "CodexBar запросит у macOS Keychain «%@», чтобы расшифровать cookie браузера и авторизовать ваш аккаунт. Нажмите OK, чтобы продолжить."; +"CodexBar will ask macOS Keychain for the Claude Code OAuth token so it can fetch your Claude usage. Click OK to continue." = "CodexBar запросит у macOS Keychain OAuth-токен Claude Code, чтобы получить данные об использовании Claude. Нажмите OK, чтобы продолжить."; +"CodexBar will ask macOS Keychain for your Amp cookie header so it can fetch usage. Click OK to continue." = "CodexBar запросит у macOS Keychain Cookie-заголовок Amp, чтобы получить данные об использовании. Нажмите OK, чтобы продолжить."; +"CodexBar will ask macOS Keychain for your Augment cookie header so it can fetch usage. Click OK to continue." = "CodexBar запросит у macOS Keychain Cookie-заголовок Augment, чтобы получить данные об использовании. Нажмите OK, чтобы продолжить."; +"CodexBar will ask macOS Keychain for your Claude cookie header so it can fetch Claude web usage. Click OK to continue." = "CodexBar запросит у macOS Keychain Cookie-заголовок Claude, чтобы получить данные об использовании Claude Web. Нажмите OK, чтобы продолжить."; +"CodexBar will ask macOS Keychain for your Cursor cookie header so it can fetch usage. Click OK to continue." = "CodexBar запросит у macOS Keychain Cookie-заголовок Cursor, чтобы получить данные об использовании. Нажмите OK, чтобы продолжить."; +"CodexBar will ask macOS Keychain for your Factory cookie header so it can fetch usage. Click OK to continue." = "CodexBar запросит у macOS Keychain Cookie-заголовок Factory, чтобы получить данные об использовании. Нажмите OK, чтобы продолжить."; +"CodexBar will ask macOS Keychain for your GitHub Copilot token so it can fetch usage. Click OK to continue." = "CodexBar запросит у macOS Keychain токен GitHub Copilot, чтобы получить данные об использовании. Нажмите OK, чтобы продолжить."; +"CodexBar will ask macOS Keychain for your Kimi auth token so it can fetch usage. Click OK to continue." = "CodexBar запросит у macOS Keychain токен авторизации Kimi, чтобы получить данные об использовании. Нажмите OK, чтобы продолжить."; +"CodexBar will ask macOS Keychain for your MiniMax API token so it can fetch usage. Click OK to continue." = "CodexBar запросит у macOS Keychain API-токен MiniMax, чтобы получить данные об использовании. Нажмите OK, чтобы продолжить."; +"CodexBar will ask macOS Keychain for your MiniMax cookie header so it can fetch usage. Click OK to continue." = "CodexBar запросит у macOS Keychain Cookie-заголовок MiniMax, чтобы получить данные об использовании. Нажмите OK, чтобы продолжить."; +"CodexBar will ask macOS Keychain for your OpenAI cookie header so it can fetch Codex dashboard extras. Click OK to continue." = "CodexBar запросит у macOS Keychain Cookie-заголовок OpenAI, чтобы получить дополнительные данные дашборда Codex. Нажмите OK, чтобы продолжить."; +"CodexBar will ask macOS Keychain for your OpenCode cookie header so it can fetch usage. Click OK to continue." = "CodexBar запросит у macOS Keychain Cookie-заголовок OpenCode, чтобы получить данные об использовании. Нажмите OK, чтобы продолжить."; +"CodexBar will ask macOS Keychain for your Synthetic API key so it can fetch usage. Click OK to continue." = "CodexBar запросит у macOS Keychain API-ключ Synthetic, чтобы получить данные об использовании. Нажмите OK, чтобы продолжить."; +"CodexBar will ask macOS Keychain for your z.ai API token so it can fetch usage. Click OK to continue." = "CodexBar запросит у macOS Keychain API-токен z.ai, чтобы получить данные об использовании. Нажмите OK, чтобы продолжить."; +"Could not open Cursor login in your browser." = "Не удалось открыть вход в Cursor в браузере."; +"Could not open browser for Antigravity" = "Не удалось открыть браузер для Antigravity."; +"Credits used" = "Использовано кредитов"; +"Day" = "День"; +"Deployment" = "Развёртывание"; +"Drag to reorder" = "Перетащите, чтобы изменить порядок"; +"Sort providers alphabetically" = "Сортировать провайдеров по алфавиту"; +"Sort providers alphabetically (enabled first)" = "Сортировать провайдеров по алфавиту (включённые сначала)"; +"Sorted alphabetically (enabled first) — click to use your custom order" = "Отсортировано по алфавиту (включённые сначала) — нажмите, чтобы использовать свой порядок."; +"Endpoint" = "Эндпоинт"; +"Enterprise host" = "Корпоративный хост"; +"Extra usage balance: %@" = "Баланс доп. использования: %@"; +"Keychain Access Required" = "Требуется доступ к Keychain"; +"keychain_prompt_learn_more" = "Узнать больше…"; +"keychain_prompt_privacy_note" = "macOS, а не CodexBar, обрабатывает любой ввод пароля для входа в Mac. Вы можете в любой момент отключить доступ к Keychain в «Настройки» → «Дополнительно»."; +"Kiro menu bar value" = "Значение строки меню Kiro"; +"Label" = "Метка"; +"No organizations loaded. Click Refresh after setting your API key." = "Организации не загружены. Нажмите «Обновить» после настройки API-ключа."; +"No output captured." = "Вывод не получен."; +"No system account" = "Нет системного аккаунта"; +"Oasis-Token" = "Oasis-Token"; +"Open Augment (Log Out & Back In)" = "Открыть Augment (выйти и войти снова)"; +"Open Codebuff Dashboard" = "Открыть Codebuff Dashboard"; +"Open Command Code Settings" = "Открыть настройки Command Code"; +"Open Crof dashboard" = "Открыть дашборд Crof"; +"Open Manus" = "Открыть Manus"; +"Open MiMo Balance" = "Открыть баланс MiMo"; +"Open Moonshot Console" = "Открыть консоль Moonshot"; +"Open Ollama API Keys" = "Открыть API-ключи Ollama"; +"Open StepFun Platform" = "Открыть платформу StepFun"; +"Open T3 Chat Settings" = "Открыть настройки чата T3"; +"Open Volcengine Ark Console" = "Открыть консоль Volcengine Ark"; +"Open legacy provider docs" = "Открыть документацию устаревшего провайдера"; +"Open projects" = "Открыть проекты"; +"Open this URL manually to continue login:\n\n%@" = "Откройте этот URL вручную, чтобы продолжить вход:\n\n%@"; +"Optional organization ID for accounts linked to multiple Anthropic organizations." = "Необязательный идентификатор организации для аккаунтов, связанных с несколькими организациями Anthropic."; +"Optional. Applies to the configured Admin API key; selected token accounts do not inherit OPENAI_PROJECT_ID." = "Необязательно. Применяется к настроенному Admin API-ключу; выбранные токен-аккаунты не наследуют OPENAI_PROJECT_ID."; +"Optional. Enter your GitHub Enterprise host, for example octocorp.ghe.com. Leave blank for github.com." = "Необязательно. Введите свой хост GitHub Enterprise, например octocorp.ghe.com. Оставьте пустым для github.com."; +"Optional. Leave blank to discover and aggregate projects visible to the API key." = "Необязательно. Оставьте поле пустым, чтобы обнаружить и агрегировать проекты, видимые по ключу API."; +"Org ID (optional)" = "Идентификатор организации (необязательно)"; +"Organizations" = "Организации"; +"Organization ID" = "Идентификатор организации"; +"Password" = "Пароль"; +"%@ authentication is disabled." = "Аутентификация %@ отключена."; +"%@ cookies are disabled." = "Cookie %@ отключены."; +"%@ web API access is disabled." = "Доступ к веб-API %@ отключён."; +"Disable %@ dashboard cookie usage." = "Отключить использование cookie дашборда %@."; +"Keychain access is disabled in Advanced, so browser cookie import is unavailable." = "Доступ к Keychain отключён на вкладке «Расширенные», поэтому импорт cookie браузера недоступен."; +"Manually paste an %@ from a browser session." = "Вручную вставьте %@ из сеанса браузера."; +"Paste a Cookie header captured from %@." = "Вставьте Cookie-заголовок, полученный из %@."; +"Paste a Cookie header from %@." = "Вставьте Cookie-заголовок из %@."; +"Paste a Cookie header or cURL capture from %@." = "Вставьте Cookie-заголовок или снимок cURL из %@."; +"Paste a Cookie header or full cURL capture from %@." = "Вставьте Cookie-заголовок или полный снимок cURL из %@."; +"Paste a Cookie or Authorization header from %@." = "Вставьте Cookie-заголовок или заголовок Authorization из %@."; +"Paste a full cookie header or the %@ value." = "Вставьте полный Cookie-заголовок или значение %@."; +"Paste a Cookie header or full cURL capture from T3 Chat settings." = "Вставьте Cookie-заголовок или полный снимок cURL из настроек T3 Chat."; +"Paste the Cookie header from a request to admin.mistral.ai. Must contain an ory_session_* cookie." = "Вставьте Cookie-заголовок из запроса к admin.mistral.ai. Он должен содержать cookie ory_session_*."; +"Paste the Oasis-Token from a logged-in browser session on platform.stepfun.com." = "Вставьте Oasis-Token из авторизованного сеанса браузера на platform.stepfun.com."; +"Paste the %@ JSON bundle from %@." = "Вставьте JSON-пакет %@ из %@."; +"Paste the %@ value or a full Cookie header." = "Вставьте значение %@ или полный Cookie-заголовок."; +"Personal account" = "Личный аккаунт"; +"Project ID" = "Идентификатор проекта"; +"Re-auth" = "Повторная авторизация"; +"Re-login at claude.ai" = "Повторно войти на claude.ai"; +"Re-authenticating…" = "Повторная авторизация…"; +"Refresh Session" = "Обновить сеанс"; +"Refresh organizations" = "Обновить организации"; +"Region" = "Регион"; +"Reload" = "Перезагрузить"; +"Reorder" = "Изменить порядок"; +"Secret access key" = "Секретный ключ доступа"; +"Series" = "Серия"; +"Service" = "Сервис"; +"Show or hide Kiro credits, percent, or both next to the menu bar icon." = "Показывать или скрывать кредиты Kiro, процент или оба значения рядом со значком в строке меню."; +"Show usage for organizations you belong to. Personal account is always shown." = "Показывать использование организаций, в которых вы состоите. Личный аккаунт отображается всегда."; +"Sign in to cursor.com in your browser, then refresh Cursor in CodexBar." = "Войдите в cursor.com в браузере, затем обновите Cursor в CodexBar."; +"Simulated error text" = "Имитированный текст ошибки"; +"StepFun platform account (phone number or email)." = "Аккаунт платформы StepFun (номер телефона или адрес электронной почты)."; +"Stored in ~/.codexbar/config.json." = "Хранится в ~/.codexbar/config.json."; +"Stored in ~/.codexbar/config.json. AZURE_OPENAI_API_KEY is also supported." = "Хранится в ~/.codexbar/config.json. AZURE_OPENAI_API_KEY также поддерживается."; +"Stored in ~/.codexbar/config.json. For the official Kimi API, use Moonshot / Kimi API." = "Хранится в ~/.codexbar/config.json. Для официального API Kimi используйте Moonshot / Kimi API."; +"Stored in ~/.codexbar/config.json. Get your API key from the Volcengine Ark console." = "Хранится в ~/.codexbar/config.json. Получите API-ключ в консоли Volcengine Ark."; +"Stored in ~/.codexbar/config.json. Get your key from Ollama settings." = "Хранится в ~/.codexbar/config.json. Получите API-ключ в настройках Ollama."; +"Stored in ~/.codexbar/config.json. Get your key from console.deepgram.com." = "Хранится в ~/.codexbar/config.json. Получите API-ключ на console.deepgram.com."; +"Stored in ~/.codexbar/config.json. Get your key from elevenlabs.io/app/settings/api-keys." = "Хранится в ~/.codexbar/config.json. Получите API-ключ на elevenlabs.io/app/settings/api-keys."; +"Stored in ~/.codexbar/config.json. Get your key from openrouter.ai/settings/keys and set a key spending limit there to enable API key quota tracking." = "Хранится в ~/.codexbar/config.json. Получите API-ключ на openrouter.ai/settings/keys и задайте там лимит расходов, чтобы включить отслеживание квоты API-ключа."; +"Stored in ~/.codexbar/config.json. In Warp, open Settings > Platform > API Keys, then create one." = "Хранится в ~/.codexbar/config.json. В Warp откройте «Настройки» > «Платформа» > «Ключи API», затем создайте ключ."; +"Stored in ~/.codexbar/config.json. Metrics require Groq Enterprise Prometheus access." = "Хранится в ~/.codexbar/config.json. Для метрик требуется доступ к Groq Enterprise Prometheus."; +"Stored in ~/.codexbar/config.json. OPENAI_ADMIN_KEY is preferred; OPENAI_API_KEY still works." = "Хранится в ~/.codexbar/config.json. Предпочтителен OPENAI_ADMIN_KEY; OPENAI_API_KEY по-прежнему работает."; +"Stored in ~/.codexbar/config.json. Requires an Anthropic Admin API key." = "Хранится в ~/.codexbar/config.json. Требуется API-ключ Anthropic Admin."; +"Stored in ~/.codexbar/config.json. Used for /v1/quota-stats." = "Хранится в ~/.codexbar/config.json. Используется для /v1/quota-stats."; +"Stored in ~/.codexbar/config.json. You can also provide CODEBUFF_API_KEY or let CodexBar read ~/.config/manicode/credentials.json (created by `codebuff login`)." = "Хранится в ~/.codexbar/config.json. Также можно задать CODEBUFF_API_KEY или разрешить CodexBar прочитать ~/.config/manicode/credentials.json, который создаёт `codebuff login`."; +"Stored in ~/.codexbar/config.json. You can also provide CROF_API_KEY." = "Хранится в ~/.codexbar/config.json. Также можно задать CROF_API_KEY."; +"Stored in ~/.codexbar/config.json. You can also provide KILO_API_KEY or ~/.local/share/kilo/auth.json (kilo.access)." = "Хранится в ~/.codexbar/config.json. Также можно задать KILO_API_KEY или ~/.local/share/kilo/auth.json (kilo.access)."; +"T3 Chat cookie" = "Cookie чата T3"; +"Team mode" = "Командный режим"; +"That account is no longer available in CodexBar. Refresh the account list and try again." = "Этот аккаунт больше недоступен в CodexBar. Обновите список аккаунтов и повторите попытку."; +"The browser login did not complete in time. Try Antigravity login again." = "Вход в браузере не завершился вовремя. Попробуйте войти в Antigravity ещё раз."; +"Timed out waiting for Cursor login. %@" = "Истекло время ожидания входа в Cursor. %@"; +"Timed out waiting for Cursor login. %@ Last error: %@" = "Истекло время ожидания входа в Cursor. %@ Последняя ошибка: %@"; +"Today requests" = "Запросы сегодня"; +"Total (30d): %@ credits" = "Итого (30д): %@ кредитов"; +"Username" = "Имя пользователя"; +"Uses username + password to login and obtain an Oasis-Token automatically." = "Использует имя пользователя и пароль для входа и автоматического получения Oasis-Token."; +"Uses username + password to login and obtain an %@ automatically." = "Использует имя пользователя и пароль для входа и автоматического получения %@."; +"Utilization End" = "Окончание использования"; +"Utilization Start" = "Начало использования"; +"Verbosity" = "Подробность"; +"Windsurf session JSON bundle" = "JSON-пакет сеанса Windsurf"; +"Workspace ID" = "Идентификатор рабочей области"; +"Your StepFun platform password. Used to login and obtain a session token." = "Ваш пароль платформы StepFun. Используется для входа и получения токена сеанса."; +"claude /login exited with status %d." = "claude /login завершился со статусом %d."; +"codex login exited with status %d." = "codex login завершился со статусом %d."; +"Cookie: …\n\nor paste a cURL capture from the Abacus AI dashboard" = "Cookie: …\n\nили вставьте снимок cURL из дашборда Abacus AI."; +"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"; +"session_id=...\n\nor paste just the session_id value" = "session_id=...\n\nили вставьте только значение session_id"; +"Clear" = "Очистить"; +"No matching providers" = "Нет подходящих провайдеров"; +"Search providers" = "Поиск провайдеров"; + +"language_vietnamese" = "Tiếng Việt"; +"language_indonesian" = "Bahasa Indonesia"; + +"Request quota: %@ / %@" = "Квота запросов: %@ / %@"; +"language_arabic" = "العربية"; +"language_persian" = "فارسی"; +"language_thai" = "ไทย"; +"language_galician" = "Galego"; +"Limit Reset Credits" = "Кредиты сброса лимита"; +"1 available" = "1 доступен"; +"%d available" = "%d доступен"; +"Next expires %@" = "Следующий срок действия истекает %@"; +"Expires %@" = "Истекает %@"; +"No expiry" = "Без срока действия"; +"byte_unit_byte" = "байт"; +"byte_unit_bytes" = "байты"; +"byte_unit_kilobyte" = "килобайт"; +"byte_unit_kilobytes" = "килобайты"; +"byte_unit_megabyte" = "мегабайт"; +"byte_unit_megabytes" = "мегабайты"; +"byte_unit_gigabyte" = "гигабайт"; +"byte_unit_gigabytes" = "гигабайты"; + +/* Settings sidebar redesign */ +"Enable" = "Включить"; +"Disable" = "Отключить"; +"providers_on_count" = "%d включено"; +"section_cost_summary" = "Сводная стоимость"; +"section_command_line" = "Командная строка"; +"section_privacy" = "Конфиденциальность"; +"section_diagnostics" = "Диагностика"; +"section_updates" = "Обновления"; +"section_links" = "Ссылки"; +"Show Codex Spark usage" = "Показывать использование Codex Spark"; +"Shows Codex Spark quota rows in the menu and provider preview. Requires optional credits and extra usage in Display settings." = "Показывает строки квот Codex Spark в меню и предварительном просмотре провайдера. Требует включить «Показывать кредиты и доп. использование» в настройках «Отображение»."; +"Show Daily Routines usage" = "Показывать использование ежедневных задач"; +"Shows the Daily Routines quota row in the menu and provider preview. Requires optional credits and extra usage in Display settings." = "Показывает строку квоты ежедневных задач в меню и предварительном просмотре провайдера. Требует включить «Показывать кредиты и доп. использование» в настройках «Отображение»."; +"Scroll to see more models" = "Прокрутите, чтобы увидеть больше моделей"; +"Copy Image" = "Копировать изображение"; +"Copy Stats" = "Копировать статистику"; +"Could not copy image" = "Не удалось скопировать изображение"; +"Image copied" = "Изображение скопировано"; +"Image saved" = "Изображение сохранено"; +"Nothing is uploaded. This image is created on your Mac." = "Ничего не загружается. Изображение создаётся на вашем Mac."; +"Save..." = "Сохранить..."; +"Share AI Usage" = "Поделиться использованием ИИ"; +"Share Stats…" = "Поделиться статистикой…"; +"Stats copied" = "Статистика скопирована"; +"DeepSeek this month token usage trend" = "Динамика использования токенов DeepSeek в этом месяце"; +"Chrome profile" = "Профиль Chrome"; +"Choose which signed-in DeepSeek Platform session supplies detailed usage." = "Выберите активный сеанс DeepSeek Platform для получения подробных данных об использовании."; +"Detailed usage unavailable." = "Подробные данные об использовании недоступны."; +"Sign in to DeepSeek Platform in Chrome for detailed usage." = "Войдите в DeepSeek Platform через Chrome, чтобы получить подробные данные об использовании."; +"Select a DeepSeek Chrome profile in Settings." = "Выберите профиль Chrome для DeepSeek в настройках."; +"Select profile…" = "Выбрать профиль…"; + +"Choose a supported browser so CodexBar can read the matching account." = "Выберите поддерживаемый браузер, чтобы CodexBar мог прочитать соответствующую учетную запись."; +"Choose Cursor account" = "Выберите учетную запись Cursor"; +"Choose which Cursor account CodexBar should use." = "Выберите, какую учетную запись Cursor следует использовать CodexBar."; +"Finish switching to a different Cursor account in your browser, then try again." = "Завершите переключение на другую учетную запись Cursor в браузере, а затем повторите попытку."; +"Timed out waiting for Cursor account switch. %@" = "Истекло время ожидания переключения учетной записи Cursor. %@"; +"Timed out waiting for Cursor account switch. %@ Last error: %@" = "Истекло время ожидания переключения учетной записи Cursor. %@ Последняя ошибка: %@"; +"Use Account" = "Использовать учетную запись"; +/* Spend dashboard */ +"tab_usage_spend" = "Использование и расходы"; +"Usage & Spend" = "Использование и расходы"; +"Local estimated cost history across supported providers." = "Локальная история предполагаемых расходов у поддерживаемых провайдеров."; +"Time range" = "Период"; +"Track costs" = "Отслеживать расходы"; +"Cost tracking is off" = "Отслеживание расходов выключено"; +"Turn on Track costs to build local estimates." = "Включите «Отслеживать расходы», чтобы создавать локальные оценки."; +"No local cost history yet" = "Локальной истории расходов пока нет"; +"Turn on cost tracking or refresh after using a supported provider." = "Включите отслеживание расходов или обновите данные после использования поддерживаемого провайдера."; +"Refresh failures" = "Ошибки обновления"; +"Native currencies stay separate; Codex account rows exclude Pi session history." = "Исходные валюты остаются раздельными; строки учётных записей Codex не включают историю сеансов Pi."; +"Spend unavailable" = "Расходы недоступны"; +"Model breakdown unavailable" = "Разбивка по моделям недоступна"; +"Local estimated history" = "Локальная история оценок"; +"Coverage" = "Охват"; +"Estimated spend" = "Предполагаемые расходы"; +"Tracked tokens" = "Отслеживаемые токены"; +"Subscriptions" = "Подписки"; +"By subscription" = "По подпискам"; +"No model-level history" = "Нет истории по моделям"; +"Daily estimated spend" = "Предполагаемые ежедневные расходы"; +"≈%d full 5h windows of weekly left · %d windows until reset" = "≈%d полных 5-часовых окон недельного лимита · %d окон до сброса"; +"Weekly cannot run out before reset at this pace" = "При таком темпе недельный лимит не может закончиться до сброса"; +"Weekly can run out ≈%d windows early" = "Недельный лимит может закончиться на ≈%d окон раньше"; +"Estimated: %@" = "Оценка: %@"; +"session_quota_estimate_value_format" = "%1$@ %2$@"; +"session quota" = "лимит сессии"; +"session quotas" = "лимиты сессий"; +"Coding Plan" = "План программирования"; +"Agent Plan" = "План агента"; +"Team" = "Команда"; + +/* Menu bar layout editor */ +"menu_bar_layout_title" = "Компоновка"; +"menu_bar_layout_footer" = "Перетаскивайте элементы, чтобы настроить строку меню. Нажмите элемент, чтобы добавить его; выберите размещённый элемент и нажмите Delete, чтобы удалить его."; +"menu_bar_layout_group_identity" = "Идентификация"; +"menu_bar_layout_group_usage" = "Использование"; +"menu_bar_layout_group_time" = "Время"; +"menu_bar_layout_group_money" = "Стоимость"; +"menu_bar_layout_group_structure" = "Структура"; +"menu_bar_layout_scope_all" = "Все провайдеры"; +"menu_bar_layout_scope_help" = "Измените компоновку по умолчанию или переопределите её для одного провайдера."; +"menu_bar_layout_use_all" = "Использовать компоновку всех провайдеров"; +"menu_bar_layout_preset" = "Шаблон компоновки"; +"menu_bar_layout_preset_icon_percent" = "Значок и процент"; +"menu_bar_layout_preset_icon_only" = "Только значок"; +"menu_bar_layout_preset_percent_reset" = "Процент и сброс"; +"menu_bar_layout_preset_compact_stacked" = "Компактно в две строки"; +"menu_bar_layout_preset_custom" = "Пользовательский"; +"menu_bar_layout_live_preview" = "Предпросмотр"; +"menu_bar_layout_strip" = "Строка меню"; +"menu_bar_layout_remove_line_break" = "Удалить разрыв строки"; +"menu_bar_layout_chip_hint" = "Выберите, перетащите для изменения порядка или используйте действие удаления."; +"menu_bar_layout_palette_hint" = "Нажмите, чтобы добавить, или перетащите в компоновку."; +"menu_bar_layout_empty_line" = "Перетащите элемент сюда"; +"menu_bar_layout_line" = "Строка %d"; +"menu_bar_layout_drag_remove" = "Перетащите сюда для удаления"; +"menu_bar_layout_size" = "Размер"; +"menu_bar_layout_size_small" = "Маленький"; +"menu_bar_layout_size_regular" = "Обычный"; +"menu_bar_layout_gap" = "Интервал"; +"menu_bar_layout_gap_tight" = "Узкий"; +"menu_bar_layout_gap_regular" = "Обычный"; +"menu_bar_layout_keyboard_hint" = "Delete удаляет выбранный элемент"; +"menu_bar_layout_sample_account" = "аккаунт"; +"menu_bar_layout_sample_runs_out" = "закончится пт."; +"menu_bar_layout_token_icon" = "Значок"; +"menu_bar_layout_token_provider" = "Имя провайдера"; +"menu_bar_layout_token_account" = "Аккаунт"; +"menu_bar_layout_token_session" = "Сеанс %"; +"menu_bar_layout_token_weekly" = "Недельный %"; +"menu_bar_layout_token_auto" = "Авто %"; +"menu_bar_layout_token_bar" = "Индикатор использования"; +"menu_bar_layout_token_resets_in" = "Сброс через"; +"menu_bar_layout_token_reset_at" = "Сброс в"; +"menu_bar_layout_token_runs_out" = "Закончится"; +"menu_bar_layout_token_cost_today" = "Расход сегодня"; +"menu_bar_layout_token_cost_30d" = "Расход за 30 дней"; +"menu_bar_layout_token_space" = "Пробел"; +"menu_bar_layout_token_line_break" = "Разрыв строки"; +"menu_bar_layout_token_separator_accessibility" = "Точка-разделитель"; + +/* Menu bar layout accessibility */ +"Icon unavailable" = "Значок: Недоступно"; +"%@ icon" = "%@: Значок"; +"Provider name unavailable" = "Имя провайдера: Недоступно"; +"Account unavailable" = "Аккаунт: Недоступно"; +"%@ unavailable" = "%@: Недоступно"; +"%@ %@" = "%@ %@"; +"Usage bar unavailable" = "Индикатор использования: Недоступно"; +"Usage bar, %d of 3 filled" = "Индикатор использования: %d/3 заполнено"; +"Reset countdown unavailable" = "Сброс через: Недоступно"; +"Reset time unavailable" = "Сброс в: Недоступно"; +"Run-out estimate unavailable" = "Закончится: Недоступно"; +"Cost today unavailable" = "Расход сегодня: Недоступно"; +"30-day cost unavailable" = "Расход за 30 дней: Недоступно"; +"Resets" = "Сбросы"; diff --git a/Sources/CodexBar/Resources/ru.lproj/Localizable.stringsdict b/Sources/CodexBar/Resources/ru.lproj/Localizable.stringsdict new file mode 100644 index 0000000000..35672a51fa --- /dev/null +++ b/Sources/CodexBar/Resources/ru.lproj/Localizable.stringsdict @@ -0,0 +1,61 @@ + + + + + ≈%d full 5h windows of weekly left · %d windows until reset + + NSStringLocalizedFormatKey + %#@left@ · %#@until@ + left + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + d + one + ≈%d полное 5-часовое окно недельного лимита + few + ≈%d полных 5-часовых окна недельного лимита + many + ≈%d полных 5-часовых окон недельного лимита + other + ≈%d полных 5-часовых окон недельного лимита + + until + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + d + one + %d окно до сброса + few + %d окна до сброса + many + %d окон до сброса + other + %d окон до сброса + + + Weekly can run out ≈%d windows early + + NSStringLocalizedFormatKey + %#@early@ + early + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + d + one + Недельный лимит может закончиться на ≈%d окно раньше + few + Недельный лимит может закончиться на ≈%d окна раньше + many + Недельный лимит может закончиться на ≈%d окон раньше + other + Недельный лимит может закончиться на ≈%d окон раньше + + + + diff --git a/Sources/CodexBar/Resources/sv.lproj/Localizable.strings b/Sources/CodexBar/Resources/sv.lproj/Localizable.strings index ecd338138f..2076356327 100644 --- a/Sources/CodexBar/Resources/sv.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/sv.lproj/Localizable.strings @@ -1,8 +1,33 @@ /* Swedish localization for CodexBar */ +"tab_hooks" = "Hooks"; +"hooks_enable_title" = "Aktivera hooks"; +"hooks_enable_subtitle" = "Kör externa kommandon vid kvot- eller leverantörshändelser."; +"hooks_trust_warning" = "Hooks kan köra lokala kommandon på din Mac. Konfigurera endast kommandon du litar på."; +"hooks_rules_header" = "Regler"; +"hooks_empty" = "Inga hooks har konfigurerats."; +"hooks_add_rule" = "Lägg till regel"; +"hooks_delete_rule" = "Ta bort regel"; +"hooks_rule_enabled" = "Aktiverad"; +"hooks_event" = "Händelse"; +"hooks_provider" = "Leverantör"; +"hooks_any_provider" = "Valfri leverantör"; +"hooks_threshold" = "Kör vid användning ≥"; +"hooks_threshold_placeholder" = "90"; +"hooks_executable_placeholder" = "/usr/local/bin/my-command"; +"hooks_arguments_placeholder" = "Argument"; +"hooks_argument_placeholder" = "Argument"; +"hooks_add_argument" = "Lägg till argument"; +"hooks_delete_argument" = "Ta bort argument"; + +"ollama_safari_cookie_access_hint" = "Safari-cookies kräver full skivåtkomst för CodexBar (Systeminställningar > Integritet och säkerhet)."; +"ollama_browser_cookie_decryption_denied" = "Dekryptering av %@-cookies nekades i Nyckelhanteraren; försök igen med en manuell uppdatering."; +"ollama_browser_cookie_decryption_disabled" = "Dekryptering av %@-cookies är inaktiverad i CodexBar; aktivera åtkomst till Nyckelhanteraren och uppdatera."; + " providers" = " leverantörer"; "(System)" = "(System)"; "30d" = "30 d"; +"7d" = "7 d"; "A managed Codex login is already running. Wait for it to finish before adding " = "En hanterad Codex-inloggning körs redan. Vänta tills den är klar innan du lägger till "; "API key" = "API-nyckel"; "API region" = "API-region"; @@ -99,6 +124,8 @@ "Could not start codex login" = "Kunde inte starta codex login"; "Could not switch system account" = "Kunde inte byta systemkonto"; "Credits" = "Krediter"; +"Individual credits" = "Individuella krediter"; +"Workspace" = "Arbetsyta"; "Credits history" = "Kredithistorik"; "Cursor login failed" = "Cursor-inloggning misslyckades"; "Custom" = "Anpassat"; @@ -233,6 +260,7 @@ "Picker subtitle" = "Väljarunderrubrik"; "Placeholder" = "Platshållare"; "Plan" = "Plan"; +"Plan Usage" = "Plananvändning"; "Play full-screen confetti when weekly usage resets." = "Spela konfetti i helskärm när veckoförbrukningen återställs."; "Polls OpenAI/Claude status pages and Google Workspace for " = "Kontrollerar OpenAI/Claude-statussidor och Google Workspace för "; "Prevents any Keychain access while enabled." = "Förhindrar all åtkomst till Nyckelring när det är aktiverat."; @@ -266,7 +294,8 @@ "Select the IDE to monitor" = "Välj IDE att övervaka"; "Session quota notifications" = "Aviseringar för sessionskvot"; "Session tokens" = "Sessionstoken"; -"Settings" = "Inställningar"; +"provider_section_connection" = "Anslutning"; +"provider_section_menu_bar" = "Menyrad"; "Show Codex Credits and Claude Extra usage sections in the menu." = "Visa avsnitt för Codex-krediter och Claude Extra-användning i menyn."; "Show Debug Settings" = "Visa felsökningsinställningar"; "Show all token accounts" = "Visa alla tokenkonton"; @@ -294,18 +323,18 @@ "Store multiple OpenCode Go Cookie headers." = "Spara flera Cookie-headers för OpenCode Go."; "Stored in the CodexBar config file." = "Sparas i CodexBars konfigurationsfil."; "Stored in ~/.codexbar/config.json. " = "Sparas i ~/.codexbar/config.json. "; -"Stored in ~/.codexbar/config.json. Generate one at kimi-k2.ai." = "Sparas i ~/.codexbar/config.json. Skapa en på kimi-k2.ai."; "Stored in ~/.codexbar/config.json. Paste the key from the Synthetic dashboard." = "Sparas i ~/.codexbar/config.json. Klistra in nyckeln från Synthetic-instrumentpanelen."; "Stored in ~/.codexbar/config.json. Paste your Coding Plan API key from Model Studio." = "Sparas i ~/.codexbar/config.json. Klistra in din Coding Plan-API-nyckel från Model Studio."; "Stored in ~/.codexbar/config.json. Paste your MiniMax API key." = "Sparas i ~/.codexbar/config.json. Klistra in din MiniMax-API-nyckel."; "Stored in ~/.codexbar/config.json. You can also provide KILO_API_KEY or " = "Sparas i ~/.codexbar/config.json. Du kan också ange KILO_API_KEY eller "; "Stores local Codex usage history (8 weeks) to personalize Pace predictions." = "Sparar lokal Codex-användningshistorik (8 veckor) för att anpassa taktprognoser."; -"Subscription Utilization" = "Abonnemangsutnyttjande"; "Surprise me" = "Överraska mig"; "Switcher shows icons" = "Växlaren visar ikoner"; "Symlink CodexBarCLI to /usr/local/bin and /opt/homebrew/bin as codexbar." = "Symlänka CodexBarCLI till /usr/local/bin och /opt/homebrew/bin som codexbar."; "System" = "System"; "Temporarily shows the loading animation after the next refresh." = "Visar tillfälligt laddningsanimationen efter nästa uppdatering."; +"terminal_app_subtitle" = "Terminal som används av åtgärden Öppna Terminal"; +"terminal_app_title" = "Standardterminal"; "Tertiary (\\(label))" = "Tertiär (\\(label))"; "Tertiary (\\(tertiaryTitle))" = "Tertiär (\\(tertiaryTitle))"; "The default Codex account on this Mac." = "Standardkontot för Codex på den här Macen."; @@ -390,9 +419,19 @@ /* General Pane */ "section_system" = "System"; "section_usage" = "Användning"; -"section_automation" = "Automatisering"; +"section_refreshing" = "Uppdatering"; +"section_alerts" = "Aviseringar"; +"section_celebrations" = "Firanden"; +"section_icon" = "Ikon"; +"section_combined_icon" = "Kombinerad ikon"; +"section_animation" = "Animering"; +"section_content" = "Innehåll"; +"section_agent_sessions" = "Agentsessioner"; "language_title" = "Språk"; "language_subtitle" = "Byt visningsspråk. Appen behöver startas om för att ändringen ska slå igenom helt."; +"currency_title" = "Önskad valuta"; +"currency_subtitle" = "Valuta för kostnadsuppskattningar och utgifter. Använder växelkurser som uppdateras dagligen."; +"currency_auto" = "Automatiskt (följ leverantör / USD)"; "language_system" = "System"; "language_english" = "English"; "language_spanish" = "Español"; @@ -400,22 +439,44 @@ "language_chinese_simplified" = "简体中文"; "language_chinese_traditional" = "繁體中文"; "language_portuguese_brazilian" = "Português (Brasil)"; +"language_dutch" = "Nederlands"; +"language_german" = "Deutsch"; "language_swedish" = "Svenska"; +"language_french" = "Franska"; +"language_ukrainian" = "Ukrainska"; +"language_russian" = "Русский"; +"language_japanese" = "Japanska"; +"language_korean" = "Koreanska"; +"language_turkish" = "Türkçe"; +"language_italian" = "Italiano"; +"language_indonesian" = "Indonesiska"; +"language_polish" = "Polski"; "start_at_login_title" = "Starta vid inloggning"; "start_at_login_subtitle" = "Öppnar CodexBar automatiskt när du startar din Mac."; -"show_cost_summary" = "Visa kostnadssammanfattning"; "show_cost_summary_subtitle" = "Läser lokala användningsloggar. Visar idag och valt historikfönster i menyn."; +"cost_summary_style_title" = "Visningsstil"; +"cost_summary_style_inline" = "Endast inbäddad"; +"cost_summary_style_submenu" = "Endast undermeny"; +"cost_summary_style_both" = "Båda"; +"cost_summary_style_inline_help" = "Visar kostnadssammanfattningen direkt i huvudmenyn."; +"cost_summary_style_submenu_help" = "Visar den detaljerade Kostnad-undermenyn i stället."; +"cost_summary_style_both_help" = "Visar både huvudmenyns sammanfattning och den detaljerade Kostnad-undermenyn."; +"cost_history_window_title" = "Historikfönster"; +"cost_history_window_help" = "Anger hur många dagar med lokala användningsloggar som visas i menyn."; "cost_history_days_title" = "Historikfönster: %d dagar"; -"cost_auto_refresh_info" = "Automatisk uppdatering: varje timme · Timeout: 10 min"; -"refresh_cadence_title" = "Uppdateringsintervall"; -"refresh_cadence_subtitle" = "Hur ofta CodexBar kontrollerar leverantörer i bakgrunden."; +"cost_auto_refresh_info" = "Automatisk uppdatering: globalt intervall (minst 5 min) · Timeout: 10 min"; +"cost_comparison_periods_title" = "Visa kortare jämförelseperioder"; +"cost_comparison_periods_subtitle" = "Lägger till summor för 7, 30 och 90 dagar när de ryms i det valda historikfönstret. Summorna återanvänder samma lokala genomsökning."; +"refresh_interval_title" = "Uppdateringsintervall"; "manual_refresh_hint" = "Automatisk uppdatering är avstängd. Använd Uppdatera i menyn."; +"refresh_on_open_title" = "Uppdatera när menyn öppnas"; +"refresh_on_open_subtitle" = "Hämtar den senaste användningen för varje leverantör varje gång du öppnar menyn."; "check_provider_status_title" = "Kontrollera leverantörsstatus"; "check_provider_status_subtitle" = "Kontrollerar OpenAI/Claude-statussidor och Google Workspace för Gemini/Antigravity och visar incidenter i ikonen och menyn."; -"session_quota_notifications_title" = "Aviseringar för sessionskvot"; "session_quota_notifications_subtitle" = "Aviserar när femtimmarssessionens kvot når 0 % och när den blir tillgänglig igen."; -"quota_warning_notifications_title" = "Kvotvarningsaviseringar"; +"quota_depleted_title" = "Kvoten tar slut och återställs"; "quota_warning_notifications_subtitle" = "Varnar när återstående sessions- eller veckokvot passerar inställda trösklar."; +"threshold_warnings_title" = "Tröskelvarningar"; "quota_warnings_title" = "Kvotvarningar"; "quota_warning_session" = "session"; "quota_warning_session_capitalized" = "Session"; @@ -424,6 +485,17 @@ "quota_warning_notification_title" = "%1$@ %2$@-kvot låg"; "quota_warning_notification_body" = "%1$@ kvar. Din varningströskel på %2$d %% för %3$@ har nåtts."; "quota_warning_notification_body_with_account" = "Konto %1$@. %2$@ kvar. Din varningströskel på %3$d %% för %4$@ har nåtts."; +"predictive_pace_warnings_title" = "Förutsägande taktvarningar"; +"predictive_pace_warnings_subtitle" = "Varnar för Codex och Claude när sessions- eller veckotakten kan tömma kvoten före återställning."; +"confetti_on_reset_title" = "Konfetti vid återställning"; +"confetti_on_reset_subtitle" = "Spela konfetti i helskärm när användningen återställs."; +"confetti_option_off" = "Av"; +"confetti_option_session" = "Sessionsåterställningar"; +"confetti_option_weekly" = "Veckoåterställningar"; +"confetti_option_both" = "Båda"; +"predictive_pace_warning_notification_title" = "%1$@ %2$@-taktvarning"; +"predictive_pace_warning_notification_body" = "I nuvarande takt kan den här kvoten ta slut om %1$@, innan den återställs."; +"predictive_pace_warning_notification_body_with_account" = "Konto %1$@. I nuvarande takt kan den här kvoten ta slut om %2$@, innan den återställs."; "session_depleted_notification_title" = "%@-sessionen är slut"; "session_depleted_notification_body" = "0 % kvar. Du får en avisering när den är tillgänglig igen."; "session_restored_notification_title" = "%@-sessionen är återställd"; @@ -431,22 +503,30 @@ "quota_warning_warn_at" = "Varna vid"; "quota_warning_global_threshold_subtitle" = "Återstående procent för sessions- och veckofönster, om inte en leverantör åsidosätter dem."; "quota_warning_sound" = "Spela aviseringsljud"; +"quota_warning_onscreen_alert" = "Visa textavisering på skärmen"; "quota_warning_provider_inherits" = "Använder de globala kvotvarningsinställningarna om inte ett fönster anpassas här."; +"quota_warning_provider_disabled" = "Aviseringar om kvotvarningar och markörer på användningsstaplar är inaktiverade. Aktivera ett av alternativen för att redigera de sparade inställningarna."; +"quota_warning_provider_markers_only" = "Aviseringar om kvotvarningar är inaktiverade globalt. De här inställningarna styr fortfarande markörerna på användningsstaplarna."; +"quota_warning_global" = "Globalt"; "quota_warning_customize_thresholds" = "Anpassa trösklar för %@"; "quota_warning_enable_warnings" = "Aktivera varningar för %@"; "quota_warning_window_warn_at" = "Varna vid för %@"; "quota_warning_off" = "Av"; "quota_warning_inherited" = "Ärvd: %@"; "quota_warning_depleted_only" = "bara slut"; -"quota_warning_upper" = "Övre"; +"quota_warning_upper" = "Högre"; "quota_warning_lower" = "Nedre"; +"quota_warning_warning" = "Varning"; +"quota_warning_critical" = "Kritisk"; "apply" = "Tillämpa"; "quit_app" = "Avsluta CodexBar"; /* Tab titles */ "tab_general" = "Allmänt"; "tab_providers" = "Leverantörer"; -"tab_display" = "Visning"; +"tab_notifications" = "Aviseringar"; +"tab_menu_bar" = "Menyrad"; +"tab_menu" = "Meny"; "tab_advanced" = "Avancerat"; "tab_about" = "Om"; "tab_debug" = "Felsök"; @@ -471,37 +551,44 @@ "menu_bar_metric_subtitle_deepseek" = "Visar DeepSeek-saldot i menyraden."; "menu_bar_metric_subtitle_moonshot" = "Visar saldot för Moonshot/Kimi API i menyraden."; "menu_bar_metric_subtitle_mistral" = "Visar den aktuella månadens Mistral API-utgift i menyraden."; -"menu_bar_metric_subtitle_kimik2" = "Visar Kimi K2-API-nyckelkrediter i menyraden."; "automatic" = "Automatiskt"; "primary_api_key_limit" = "Primär (API-nyckelgräns)"; /* Display Pane */ -"section_menu_bar" = "Menyrad"; +"menu_bar_style_title" = "Menyradsstil"; +"menu_bar_style_subtitle" = "Hur menyradsobjektet visas."; +"menu_bar_inactive_display_contrast_title" = "Förbättra synligheten på inaktiva skärmar"; +"menu_bar_usage_colors_title" = "Färgkodad användning"; +"menu_bar_usage_colors_subtitle" = "Färgar menyradsikonen från grönt till rött när användningen ökar."; +"menu_bar_inactive_display_contrast_subtitle" = "Använder kontrastrik rendering så att ikonen och mätvärdet förblir läsbara på andra skärmar."; +"menu_bar_style_critters" = "Figurer"; +"menu_bar_style_bars" = "Mätarstaplar"; +"menu_bar_style_icon_percent" = "Ikon och procent"; +"switcher_rows_title" = "Växlarrader"; +"switcher_rows_icons" = "Leverantörsikoner"; +"switcher_rows_progress" = "Veckoförlopp"; +"usage_bars_fill_title" = "Fyllning av användningsstaplar"; +"usage_bars_fill_remaining" = "Återstående"; +"usage_bars_fill_used" = "Förbrukat"; +"reset_times_title" = "Återställningstider"; +"reset_times_countdown" = "Nedräkning"; +"reset_times_clock" = "Klockslag"; +"cost_summary_title" = "Kostnadssammanfattning"; +"cost_summary_off" = "Av"; "merge_icons_title" = "Slå ihop ikoner"; "merge_icons_subtitle" = "Använd en enda menyradsikon med leverantörsväxlare."; -"switcher_shows_icons_title" = "Växlaren visar ikoner"; -"switcher_shows_icons_subtitle" = "Visa leverantörsikoner i växlaren (annars visas en veckoförloppslinje)."; "show_most_used_provider_title" = "Visa mest använda leverantör"; "show_most_used_provider_subtitle" = "Menyraden visar automatiskt leverantören som ligger närmast sin gräns."; -"menu_bar_shows_percent_title" = "Menyraden visar procent"; -"menu_bar_shows_percent_subtitle" = "Ersätt figurstaplar med leverantörsikoner och ett procenttal."; "display_mode_title" = "Visningsläge"; "display_mode_subtitle" = "Välj vad som ska visas i menyraden (takt visar användning mot förväntat)."; -"section_menu_content" = "Menyinnehåll"; -"show_usage_as_used_title" = "Visa användning som förbrukad"; -"show_usage_as_used_subtitle" = "Förloppsstaplar fylls när du förbrukar kvot i stället för att visa återstående."; "show_quota_warning_markers_title" = "Visa kvotvarningsmarkörer"; "show_quota_warning_markers_subtitle" = "Rita tröskelmarkeringar på användningsstaplar när kvotvarningar är konfigurerade."; "weekly_progress_work_days_title" = "Arbetsdagar i veckoförlopp"; -"weekly_progress_work_days_subtitle" = "Rita dagsgränsmarkeringar på veckostaplar."; -"show_reset_time_as_clock_title" = "Visa återställningstid som klockslag"; -"show_reset_time_as_clock_subtitle" = "Visa återställningstider som klockslag i stället för nedräkningar."; +"weekly_progress_work_days_subtitle" = "Ställ in arbetsdagar för markeringar i veckostaplar och tempoberäkningar."; "show_provider_changelog_links_title" = "Visa länkar till leverantörers ändringsloggar"; "show_provider_changelog_links_subtitle" = "Lägger till länkar till utgåvekommentarer för stödda CLI-baserade leverantörer i menyn."; "show_credits_extra_usage_title" = "Visa krediter och extra användning"; "show_credits_extra_usage_subtitle" = "Visa avsnitt för Codex-krediter och Claude Extra-användning i menyn."; -"show_all_token_accounts_title" = "Visa alla tokenkonton"; -"show_all_token_accounts_subtitle" = "Stapla tokenkonton i menyn (annars visas en kontoväxlare)."; "multi_account_layout_title" = "Layout för flera konton"; "multi_account_layout_subtitle" = "Välj segmenterad kontoväxling eller staplade kontokort."; "multi_account_layout_segmented" = "Segmenterad"; @@ -512,6 +599,16 @@ "overview_no_providers_hint" = "Inga aktiverade leverantörer är tillgängliga för översikten."; "overview_rows_follow_order" = "Översiktsrader följer alltid leverantörsordningen."; "overview_no_providers_selected" = "Inga leverantörer valda"; +"agent_sessions_title" = "Agentsessioner"; +"agent_sessions_subtitle" = "Visa lokala och via SSH upptäckta Codex- och Claude Code-sessioner i menyn."; +"agent_sessions_hosts_title" = "Ytterligare SSH-värdar"; +"agent_sessions_footer" = "Mac-datorer på ditt tailnet upptäcks automatiskt. Lokala sessioner uppdateras var 30:e sekund, fjärrvärdar var 60:e sekund och när menyn öppnas."; +"agent_session_labels_title" = "Sessionsetiketter"; +"agent_session_labels_subtitle" = "Välj hur agentsessioner ska namnges."; +"agent_session_label_project" = "Projekt"; +"agent_session_label_descriptive" = "Beskrivande"; +"agent_session_label_descriptive_and_project" = "Beskrivande + projekt"; +"agent_session_unknown_project" = "Okänt projekt"; /* Advanced Pane */ "section_keyboard_shortcut" = "Kortkommando"; @@ -525,8 +622,6 @@ "show_debug_settings_subtitle" = "Visa felsökningsverktyg på fliken Felsök."; "surprise_me_title" = "Överraska mig"; "surprise_me_subtitle" = "Kontrollera om du vill att agenterna ska få leka lite där uppe."; -"weekly_limit_confetti_title" = "Veckogränskonfetti"; -"weekly_limit_confetti_subtitle" = "Spela konfetti i helskärm när veckoförbrukningen återställs."; "hide_personal_info_title" = "Dölj personuppgifter"; "hide_personal_info_subtitle" = "Maskera e-postadresser i menyraden och menygränssnittet."; "show_provider_storage_usage_title" = "Visa leverantörers lagringsanvändning"; @@ -613,17 +708,24 @@ "metric_pref_tertiary" = "Tertiär"; "metric_pref_extra_usage" = "Extra användning"; "metric_pref_average" = "Genomsnitt"; +"metric_mistral_payg" = "Pay-as-you-go"; +"metric_mistral_monthly_plan" = "Monthly Plan"; /* Display modes */ "display_mode_percent" = "Procent"; "display_mode_pace" = "Takt"; "display_mode_both" = "Båda"; +"display_mode_reset_time" = "Återställningstid"; "display_mode_percent_desc" = "Visa återstående/förbrukad procent (t.ex. 45 %)"; "display_mode_pace_desc" = "Visa taktindikator (t.ex. +5 %)"; "display_mode_both_desc" = "Visa både procent och takt (t.ex. 45 % · +5 %)"; +"display_mode_reset_time_desc" = "Visa återställningstiden för valt mått (t.ex. ↻ 3:56 PM)"; +"menu_bar_reset_when_exhausted_title" = "Visa återställningstid när kvoten tar slut"; +"menu_bar_reset_when_exhausted_subtitle" = "Vid 0 % kvar visas tiden till återställning i stället för procenttalet"; /* Provider status */ "status_operational" = "Fungerar normalt"; +"status_degraded" = "Försämrad prestanda"; "status_partial_outage" = "Delvis avbrott"; "status_major_outage" = "Större avbrott"; "status_critical_issue" = "Kritiskt problem"; @@ -637,13 +739,20 @@ "refresh_5min" = "5 min"; "refresh_15min" = "15 min"; "refresh_30min" = "30 min"; +"refresh_adaptive" = "Adaptiv"; +"refresh_adaptive_agent_aware" = "Adaptiv (agentaktivitet)"; +"adaptive_activity_consent_title" = "Tillåta aktivitetsmedveten uppdatering?"; +"adaptive_activity_consent_message" = "Det agentmedvetna adaptiva läget kan granska listan över lokala processer som körs, inklusive kommandorader, för att identifiera Codex och Claude och sedan läsa kända sessionsmetadata var 30:e sekund medan du kodar. När Agent Sessions är avstängt använder CodexBar endast tiden för den senaste aktiviteten i minnet och kasserar sessionssökvägar och identiteter. Dessa data skickas ingenstans, och fjärridentifiering och SSH förblir avstängda. Om du avböjer återgår CodexBar till vanligt Adaptiv utan lokala aktivitetsskanningar."; +"adaptive_activity_consent_allow" = "Tillåt lokal aktivitet"; +"adaptive_activity_consent_decline" = "Använd vanligt Adaptiv"; /* Additional keys */ "not_found" = "Hittades inte"; /* Cost estimation */ -"cost_header_estimated" = "Kostnad (uppskattad)"; "cost_estimate_hint" = "Uppskattat från lokala loggar · kan skilja sig från din faktura"; +"codex_api_estimate_hint" = "Uppskattat från tokenanvändning · inte en prenumerationsfaktura"; +"cost_data_explanation" = "Kostnader kan rapporteras av leverantören eller uppskattas från tokenanvändning med offentliga API-priser. Uppskattningar är inte prenumerationsavgifter."; "No JetBrains IDE with AI Assistant detected. Install a JetBrains IDE and enable AI Assistant." = "Ingen JetBrains IDE med AI Assistant hittades. Installera en JetBrains IDE och aktivera AI Assistant."; "OpenRouter API token not configured. Set OPENROUTER_API_KEY environment variable or configure in Settings." = "OpenRouter-API-token är inte konfigurerad. Ange miljövariabeln OPENROUTER_API_KEY eller konfigurera i Inställningar."; "z.ai API token not found. Set apiKey in ~/.codexbar/config.json or Z_AI_API_KEY." = "z.ai-API-token hittades inte. Ange apiKey i ~/.codexbar/config.json eller Z_AI_API_KEY."; @@ -673,6 +782,7 @@ "Today" = "Idag"; "Today tokens" = "Token idag"; "30d cost" = "Kostnad 30 d"; +"%@ cost" = "Kostnad %@"; "30d tokens" = "Token 30 d"; "Latest tokens" = "Senaste token"; "Top model" = "Toppmodell"; @@ -680,6 +790,7 @@ "Add Account..." = "Lägg till konto..."; "Usage Dashboard" = "Användningsinstrumentpanel"; "Status Page" = "Statussida"; +"Open Status Page" = "Öppna statussida"; "Settings..." = "Inställningar..."; "About CodexBar" = "Om CodexBar"; "Quit" = "Avsluta"; @@ -692,8 +803,12 @@ "Resets %@" = "Återställs %@"; "Resets in %@" = "Återställs om %@"; "Resets now" = "Återställs nu"; +"reset_tomorrow_format" = "imorgon %@"; "Lasts until reset" = "Räcker till återställning"; +"1.5× headroom" = "1,5× marginal"; "Updated %@" = "Uppdaterad %@"; +"Updated relative %@" = "Uppdaterad %@"; +"Updated absolute %@" = "Uppdaterad %@"; "Updated %@h ago" = "Uppdaterad för %@ h sedan"; "Updated %@m ago" = "Uppdaterad för %@ min sedan"; "Updated just now" = "Uppdaterad nyss"; @@ -822,6 +937,7 @@ "Plan utilization chart" = "Diagram över plananvändning"; "The browser login did not complete in time. Try Antigravity login again." = "Webbläsarinloggningen blev inte klar i tid. Försök logga in i Antigravity igen."; "Organizations" = "Organisationer"; +"Organization ID" = "Organisations-ID"; "Your StepFun platform password. Used to login and obtain a session token." = "Ditt lösenord för StepFun-plattformen. Används för att logga in och hämta en sessionstoken."; "Open this URL manually to continue login:\n\n%@" = "Öppna denna URL manuellt för att fortsätta inloggningen:\n\n%@"; "CodexBar could not replace the live Codex auth on this Mac." = "CodexBar kunde inte ersätta aktiv Codex-autentisering på den här Mac-datorn."; @@ -895,12 +1011,14 @@ "Extra usage spent" = "Extra användning förbrukad"; "5 days" = "5 dagar"; "T3 Chat cookie" = "T3 Chat-cookie"; +"Team mode" = "Teamläge"; "Full in ~1 regen" = "Full om cirka 1 regenerering"; "DeepSeek 30 day token usage trend" = "30-dagars tokenanvändningstrend för DeepSeek"; "Reorder" = "Ändra ordning"; "Changelog" = "Ändringslogg"; "Deployment" = "Distribution"; "Quota usage" = "Kvotanvändning"; +"Your spend" = "Din utgift"; "No system account" = "Inget systemkonto"; "Stored in ~/.codexbar/config.json. Requires an Anthropic Admin API key." = "Sparas i ~/.codexbar/config.json. Kräver en Anthropic Admin API-nyckel."; "AWS region. Can also be set with AWS_REGION." = "AWS-region. Kan även anges med AWS_REGION."; @@ -917,6 +1035,9 @@ "Windsurf session JSON bundle" = "Windsurf-sessionspaket i JSON"; "CodexBar will ask macOS Keychain for your Cursor cookie header so it can fetch usage. Click OK to continue." = "CodexBar kommer att be macOS Nyckelring om din Cursor-cookie-header så att användning kan hämtas. Klicka på OK för att fortsätta."; "Drag to reorder" = "Dra för att ändra ordning"; +"Sort providers alphabetically" = "Sortera leverantörer alfabetiskt"; +"Sort providers alphabetically (enabled first)" = "Sortera leverantörer alfabetiskt (aktiverade först)"; +"Sorted alphabetically (enabled first) — click to use your custom order" = "Alfabetiskt sorterade (aktiverade först) — klicka för att använda din anpassade ordning"; "cache-hit input" = "cacheträff-indata"; "Automatically imports browser cookies." = "Importerar webbläsarcookies automatiskt."; "Open Volcengine Ark Console" = "Öppna Volcengine Ark-konsol"; @@ -1001,6 +1122,7 @@ "Credits used" = "Använda krediter"; "CodexBar will ask macOS Keychain for your OpenAI cookie header so it can fetch Codex dashboard extras. Click OK to continue." = "CodexBar kommer att be macOS Nyckelring om din OpenAI-cookie-header så att extra Codex-översiktsdata kan hämtas. Klicka på OK för att fortsätta."; "Re-auth" = "Autentisera igen"; +"Re-login at claude.ai" = "Logga in igen på claude.ai"; "cache-miss input" = "cachemiss-indata"; "Day" = "Dag"; "Optional. Applies to the configured Admin API key; selected token accounts do not inherit OPENAI_PROJECT_ID." = "Valfritt. Gäller den konfigurerade Admin API-nyckeln. Valda tokenkonton ärver inte OPENAI_PROJECT_ID."; @@ -1017,16 +1139,17 @@ "%@: %@ credits" = "%@: %@ krediter"; "CodexBar will ask macOS Keychain for your Kimi auth token so it can fetch usage. Click OK to continue." = "CodexBar kommer att be macOS Nyckelring om din Kimi-autentiseringstoken så att användning kan hämtas. Klicka på OK för att fortsätta."; "Keychain Access Required" = "Åtkomst till Nyckelring krävs"; +"keychain_prompt_learn_more" = "Läs mer…"; +"keychain_prompt_privacy_note" = "Inmatningen av Mac-inloggningslösenordet hanteras av macOS, inte CodexBar. Du kan när som helst inaktivera åtkomst till Nyckelring under Inställningar → Avancerat."; "Username" = "Användarnamn"; "CodexBar will ask macOS Keychain for your MiniMax cookie header so it can fetch usage. Click OK to continue." = "CodexBar kommer att be macOS Nyckelring om din MiniMax-cookie-header så att användning kan hämtas. Klicka på OK för att fortsätta."; "30d spend" = "30 d kostnad"; -"API key verified. Ollama does not expose Cloud quota limits through the API." = "API-nyckeln har verifierats. Ollama exponerar inte Cloud-kvotgränser via API:t."; +"API key verified. Cloud quotas need browser cookies. Sign in to Ollama." = "API-nyckeln har verifierats. Cloud-kvoter kräver webbläsarcookies. Logga in på Ollama."; "Series" = "Serie"; "Total (30d): %@ credits" = "Totalt (30 d): %@ krediter"; "Paste a Cookie header or full cURL capture from T3 Chat settings." = "Klistra in en Cookie-header eller fullständig cURL-fångst från T3 Chat-inställningarna."; "%d days of credits data" = "%d dagar med kreditdata"; "used after next regen" = "använt efter nästa regenerering"; -"CodexBar will ask macOS Keychain for your Kimi K2 API key so it can fetch usage. Click OK to continue." = "CodexBar kommer att be macOS Nyckelring om din Kimi K2-API-nyckel så att användning kan hämtas. Klicka på OK för att fortsätta."; "Usage breakdown chart" = "Diagram över användningsfördelning"; "Could not open browser for Antigravity" = "Kunde inte öppna webbläsare för Antigravity"; "Stored in ~/.codexbar/config.json. AZURE_OPENAI_API_KEY is also supported." = "Sparas i ~/.codexbar/config.json. AZURE_OPENAI_API_KEY stöds också."; @@ -1041,8 +1164,10 @@ "Paste a Cookie or Authorization header from %@." = "Klistra in en Cookie- eller Authorization-header från %@."; "stale data" = "inaktuella data"; "Quota" = "Kvot"; +"Daily quota" = "Daglig kvot"; +"Total" = "Totalt"; "Auth source" = "Autentiseringskälla"; -"Automatic imports Chrome browser cookies from Xiaomi MiMo." = "Importerar Chrome-cookies från Xiaomi MiMo automatiskt."; +"Automatic imports browser cookies from Xiaomi MiMo." = "Importerar webbläsarcookies från Xiaomi MiMo automatiskt."; "No usage configured." = "Ingen användning konfigurerad."; "Extra usage" = "Extra användning"; "That account is no longer available in CodexBar. Refresh the account list and try again." = "Kontot finns inte längre i CodexBar. Uppdatera kontolistan och försök igen."; @@ -1055,3 +1180,173 @@ "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"; + +"language_vietnamese" = "Vietnamesiska"; +"language_arabic" = "العربية"; +"language_persian" = "فارسی"; +"language_thai" = "ไทย"; +"language_galician" = "Galego"; +"Limit Reset Credits" = "Krediter för gränsåterställning"; +"1 available" = "1 tillgänglig"; +"%d available" = "%d tillgängliga"; +"Next expires %@" = "Nästa upphör %@"; +"Expires %@" = "Upphör %@"; +"No expiry" = "Inget utgångsdatum"; +"Other (%d items)" = "Övrigt (%d objekt)"; +"Expand" = "Expandera"; +"Collapse" = "Fäll ihop"; +"byte_unit_byte" = "byte"; +"byte_unit_bytes" = "byte"; +"byte_unit_kilobyte" = "kilobyte"; +"byte_unit_kilobytes" = "kilobyte"; +"byte_unit_megabyte" = "megabyte"; +"byte_unit_megabytes" = "megabyte"; +"byte_unit_gigabyte" = "gigabyte"; +"byte_unit_gigabytes" = "gigabyte"; + +/* Settings sidebar redesign */ +"Enable" = "Aktivera"; +"Disable" = "Inaktivera"; +"providers_on_count" = "%d på"; +"section_cost_summary" = "Kostnadsöversikt"; +"section_command_line" = "Kommandorad"; +"section_privacy" = "Integritet"; +"section_diagnostics" = "Diagnostik"; +"section_updates" = "Uppdateringar"; +"section_links" = "Länkar"; +"Show Codex Spark usage" = "Visa Codex Spark-användning"; +"Shows Codex Spark quota rows in the menu and provider preview. Requires optional credits and extra usage in Display settings." = "Visar kvotrader för Codex Spark i menyn och i förhandsvisningen för leverantören. Kräver att ”Visa krediter och extra användning” är aktiverat under Visning i Inställningar."; +"Show Daily Routines usage" = "Visa användning för Dagliga rutiner"; +"Shows the Daily Routines quota row in the menu and provider preview. Requires optional credits and extra usage in Display settings." = "Visar kvotraden för Dagliga rutiner i menyn och i förhandsvisningen för leverantören. Kräver att ”Visa krediter och extra användning” är aktiverat under Visning i Inställningar."; +"Scroll to see more models" = "Rulla för att se fler modeller"; +"Copy Image" = "Kopiera bild"; +"Copy Stats" = "Kopiera statistik"; +"Could not copy image" = "Kunde inte kopiera bilden"; +"Image copied" = "Bilden kopierades"; +"Image saved" = "Bilden sparades"; +"Nothing is uploaded. This image is created on your Mac." = "Inget laddas upp. Bilden skapas på din Mac."; +"Save..." = "Spara..."; +"Share AI Usage" = "Dela AI-användning"; +"Share Stats…" = "Dela statistik…"; +"Stats copied" = "Statistiken kopierades"; +"DeepSeek this month token usage trend" = "Trend för DeepSeek-tokenanvändning den här månaden"; +"Chrome profile" = "Chrome-profil"; +"Choose which signed-in DeepSeek Platform session supplies detailed usage." = "Välj vilken inloggad DeepSeek Platform-session som ska ge detaljerad användning."; +"Detailed usage unavailable." = "Detaljerad användning är inte tillgänglig."; +"Sign in to DeepSeek Platform in Chrome for detailed usage." = "Logga in på DeepSeek Platform i Chrome för detaljerad användning."; +"Select a DeepSeek Chrome profile in Settings." = "Välj en DeepSeek Chrome-profil i Inställningar."; +"Select profile…" = "Välj profil…"; + +"%@: %@" = "%@: %@"; +"Alternatively, set a custom path in Settings." = "Alternativt kan du ange en anpassad sökväg i Inställningar."; +"Choose a supported browser so CodexBar can read the matching account." = "Välj en webbläsare som stöds så att CodexBar kan läsa det matchande kontot."; +"Choose Cursor account" = "Välj Cursor-konto"; +"Choose which Cursor account CodexBar should use." = "Välj vilket Cursor-konto CodexBar ska använda."; +"Finish switching to a different Cursor account in your browser, then try again." = "Slutför bytet till ett annat Cursor-konto i webbläsaren och försök igen."; +"Install a JetBrains IDE with AI Assistant enabled, then refresh CodexBar." = "Installera en JetBrains IDE med AI Assistant aktiverad och uppdatera sedan CodexBar."; +"Request quota: %@ / %@" = "Begäranskvot: %@ / %@"; +"Sign in with Claude Code..." = "Logga in med Claude Code..."; +"Timed out waiting for Cursor account switch. %@" = "Tidsgränsen överskreds i väntan på byte av Cursor-konto. %@"; +"Timed out waiting for Cursor account switch. %@ Last error: %@" = "Tidsgränsen överskreds i väntan på byte av Cursor-konto. %@ Senaste fel: %@"; +"Use Account" = "Använd konto"; +/* Spend dashboard */ +"tab_usage_spend" = "Användning och utgifter"; +"Usage & Spend" = "Användning och utgifter"; +"Local estimated cost history across supported providers." = "Lokal historik över uppskattade kostnader från leverantörer som stöds."; +"Time range" = "Tidsintervall"; +"Track costs" = "Spåra kostnader"; +"Cost tracking is off" = "Kostnadsspårning är avstängd"; +"Turn on Track costs to build local estimates." = "Aktivera ”Spåra kostnader” för att skapa lokala uppskattningar."; +"No local cost history yet" = "Ingen lokal kostnadshistorik än"; +"Turn on cost tracking or refresh after using a supported provider." = "Aktivera kostnadsspårning eller uppdatera efter att ha använt en leverantör som stöds."; +"Refresh failures" = "Misslyckade uppdateringar"; +"Native currencies stay separate; Codex account rows exclude Pi session history." = "Ursprungliga valutor hålls åtskilda; rader för Codex-konton utesluter Pi-sessionshistorik."; +"Spend unavailable" = "Utgifter ej tillgängliga"; +"Model breakdown unavailable" = "Modellfördelning ej tillgänglig"; +"Local estimated history" = "Lokal uppskattad historik"; +"Coverage" = "Täckning"; +"Estimated spend" = "Uppskattade utgifter"; +"Tracked tokens" = "Spårade token"; +"Subscriptions" = "Abonnemang"; +"By subscription" = "Per abonnemang"; +"No model-level history" = "Ingen historik på modellnivå"; +"Daily estimated spend" = "Uppskattade dagliga utgifter"; +"≈%d full 5h windows of weekly left · %d windows until reset" = "≈%d fulla 5-timmarsfönster av veckokvoten kvar · %d fönster till återställning"; +"Weekly cannot run out before reset at this pace" = "Veckokvoten kan inte ta slut före återställningen i den här takten"; +"Weekly can run out ≈%d windows early" = "Veckokvoten kan ta slut ≈%d fönster tidigare"; +"Estimated: %@" = "Uppskattning: %@"; +"session_quota_estimate_value_format" = "%1$@ %2$@"; +"session quota" = "sessionskvot"; +"session quotas" = "sessionskvoter"; +"Coding Plan" = "Kodningsplan"; +"Agent Plan" = "Agentplan"; +"Team" = "Team"; + +/* Menu bar layout editor */ +"menu_bar_layout_title" = "Layout"; +"menu_bar_layout_footer" = "Dra brickor för att ordna menyraden. Klicka på en bricka för att lägga till den; markera en placerad bricka och tryck Delete för att ta bort den."; +"menu_bar_layout_group_identity" = "Identitet"; +"menu_bar_layout_group_usage" = "Användning"; +"menu_bar_layout_group_time" = "Tid"; +"menu_bar_layout_group_money" = "Kostnad"; +"menu_bar_layout_group_structure" = "Struktur"; +"menu_bar_layout_scope_all" = "Alla leverantörer"; +"menu_bar_layout_scope_help" = "Redigera standardlayouten eller åsidosätt den för en leverantör."; +"menu_bar_layout_use_all" = "Använd layout för alla leverantörer"; +"menu_bar_layout_preset" = "Layoutförval"; +"menu_bar_layout_preset_icon_percent" = "Ikon och procent"; +"menu_bar_layout_preset_icon_only" = "Endast ikon"; +"menu_bar_layout_preset_percent_reset" = "Procent och återställning"; +"menu_bar_layout_preset_compact_stacked" = "Kompakt staplad"; +"menu_bar_layout_preset_custom" = "Anpassat"; +"menu_bar_layout_live_preview" = "Liveförhandsvisning"; +"menu_bar_layout_strip" = "Menyradsremsa"; +"menu_bar_layout_remove_line_break" = "Ta bort radbrytning"; +"menu_bar_layout_chip_hint" = "Markera, dra för att ändra ordning eller använd åtgärden Ta bort."; +"menu_bar_layout_palette_hint" = "Klicka för att lägga till eller dra till layouten."; +"menu_bar_layout_empty_line" = "Släpp en bricka här"; +"menu_bar_layout_line" = "Rad %d"; +"menu_bar_layout_drag_remove" = "Dra hit för att ta bort"; +"menu_bar_layout_size" = "Storlek"; +"menu_bar_layout_size_small" = "Liten"; +"menu_bar_layout_size_regular" = "Normal"; +"menu_bar_layout_gap" = "Mellanrum"; +"menu_bar_layout_gap_tight" = "Tätt"; +"menu_bar_layout_gap_regular" = "Normal"; +"menu_bar_layout_keyboard_hint" = "Delete tar bort den markerade brickan"; +"menu_bar_layout_sample_account" = "konto"; +"menu_bar_layout_sample_runs_out" = "tar slut fre."; +"menu_bar_layout_token_icon" = "Ikon"; +"menu_bar_layout_token_provider" = "Leverantörsnamn"; +"menu_bar_layout_token_account" = "Konto"; +"menu_bar_layout_token_session" = "Session %"; +"menu_bar_layout_token_weekly" = "Vecka %"; +"menu_bar_layout_token_auto" = "Automatiskt %"; +"menu_bar_layout_token_bar" = "Användningsstapel"; +"menu_bar_layout_token_resets_in" = "Återställs om"; +"menu_bar_layout_token_reset_at" = "Återställs kl."; +"menu_bar_layout_token_runs_out" = "Tar slut"; +"menu_bar_layout_token_cost_today" = "Kostnad idag"; +"menu_bar_layout_token_cost_30d" = "Kostnad 30 dagar"; +"menu_bar_layout_token_space" = "Blanksteg"; +"menu_bar_layout_token_line_break" = "Radbrytning"; +"menu_bar_layout_token_separator_accessibility" = "Avgränsarpunkt"; + +/* Menu bar layout accessibility */ +"Icon unavailable" = "Ikon: Inte tillgänglig"; +"%@ icon" = "%@: Ikon"; +"Provider name unavailable" = "Leverantörsnamn: Inte tillgänglig"; +"Account unavailable" = "Konto: Inte tillgänglig"; +"%@ unavailable" = "%@: Inte tillgänglig"; +"%@ %@" = "%@ %@"; +"Usage bar unavailable" = "Användningsstapel: Inte tillgänglig"; +"Usage bar, %d of 3 filled" = "Användningsstapel: %d/3 fyllda"; +"Reset countdown unavailable" = "Återställs om: Inte tillgänglig"; +"Reset time unavailable" = "Återställs kl.: Inte tillgänglig"; +"Run-out estimate unavailable" = "Tar slut: Inte tillgänglig"; +"Cost today unavailable" = "Kostnad idag: Inte tillgänglig"; +"30-day cost unavailable" = "Kostnad 30 dagar: Inte tillgänglig"; +"Resets" = "Återställningar"; diff --git a/Sources/CodexBar/Resources/sv.lproj/Localizable.stringsdict b/Sources/CodexBar/Resources/sv.lproj/Localizable.stringsdict new file mode 100644 index 0000000000..dba0ed74dc --- /dev/null +++ b/Sources/CodexBar/Resources/sv.lproj/Localizable.stringsdict @@ -0,0 +1,49 @@ + + + + + ≈%d full 5h windows of weekly left · %d windows until reset + + NSStringLocalizedFormatKey + %#@left@ · %#@until@ + left + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + d + one + ≈%d fullt 5-timmarsfönster av veckokvoten kvar + other + ≈%d fulla 5-timmarsfönster av veckokvoten kvar + + until + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + d + one + %d fönster till återställning + other + %d fönster till återställning + + + Weekly can run out ≈%d windows early + + NSStringLocalizedFormatKey + %#@early@ + early + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + d + one + Veckokvoten kan ta slut ≈%d fönster tidigare + other + Veckokvoten kan ta slut ≈%d fönster tidigare + + + + diff --git a/Sources/CodexBar/Resources/th.lproj/Localizable.strings b/Sources/CodexBar/Resources/th.lproj/Localizable.strings new file mode 100644 index 0000000000..f4fc9b4176 --- /dev/null +++ b/Sources/CodexBar/Resources/th.lproj/Localizable.strings @@ -0,0 +1,1357 @@ +/* Thai localization for CodexBar */ + +"ollama_safari_cookie_access_hint" = "คุกกี้ Safari ต้องใช้สิทธิ์เข้าถึงดิสก์แบบเต็มสำหรับ CodexBar (การตั้งค่าระบบ > ความเป็นส่วนตัวและความปลอดภัย)"; +"ollama_browser_cookie_decryption_denied" = "การถอดรหัสคุกกี้ %@ ถูกปฏิเสธในพวงกุญแจ โปรดลองอีกครั้งด้วยการรีเฟรชด้วยตนเอง"; +"ollama_browser_cookie_decryption_disabled" = "การถอดรหัสคุกกี้ %@ ถูกปิดใช้งานใน CodexBar ให้เปิดการเข้าถึงพวงกุญแจแล้วรีเฟรช"; + +" providers" = "ผู้ให้บริการ "; +"(System)" = "(ระบบ)"; +"30d" = "30 วัน"; +"7d" = "7 วัน"; +"A managed Codex login is already running. Wait for it to finish before adding " = "การเข้าสู่ระบบ Codex ที่มีการจัดการกําลังทํางานอยู่แล้ว รอให้เสร็จก่อนที่จะเพิ่ม "; +"API key" = "ปุ่ม API"; +"API region" = "ภูมิภาค API"; +"API token" = "โทเค็น API"; +"API tokens" = "โทเค็น API"; +"About" = "เกี่ยวกับ"; +"Account" = "บัญชี"; +"Accounts" = "บัญชี"; +"Accounts subtitle" = "คําบรรยายบัญชี"; +"Active" = "คล่องแคล่ว"; +"Add" = "เพิ่ม"; +"Add Workspace" = "เพิ่มพื้นที่ทํางาน"; +"Advanced" = "ขั้นสูง"; +"All" = "ทั้งหมด"; +"Always allow prompts" = "อนุญาตข้อความแจ้งเสมอ"; +"Animation pattern" = "รูปแบบแอนิเมชั่น"; +"Antigravity login is managed in the app" = "Antigravity เข้าสู่ระบบได้รับการจัดการในแอป"; +"Applies only to the Security.framework OAuth keychain reader." = "นําไปใช้กับโปรแกรมอ่านพวงกุญแจ Security.framework OAuth เท่านั้น"; +"Alternatively, set a custom path in Settings." = "หรือกำหนดเส้นทางเองในการตั้งค่า"; +"Auto falls back to the next source if the preferred one fails." = "อัตโนมัติจะถอยกลับไปยังแหล่งที่มาถัดไปหากแหล่งที่ต้องการล้มเหลว"; +"Auto uses API first, then falls back to CLI on auth failures." = "อัตโนมัติใช้ API ก่อน จากนั้นจึงกลับไป CLI เมื่อการตรวจสอบสิทธิ์ล้มเหลว"; +"Auto-detect" = "ตรวจจับอัตโนมัติ"; +"Auto-refresh is off; use the menu's Refresh command." = "การรีเฟรชอัตโนมัติปิดอยู่ ใช้คําสั่งรีเฟรชของเมนู"; +"Auto-refresh: hourly · Timeout: 10m" = "รีเฟรชอัตโนมัติ: รายชั่วโมง · หมดเวลา: 10m"; +"Automatic" = "อัตโนมัติ"; +"Automatic imports browser cookies and WorkOS tokens." = "นําเข้าคุกกี้เบราว์เซอร์และโทเค็น WorkOS โดยอัตโนมัติ"; +"Automatic imports browser cookies and local storage tokens." = "นําเข้าคุกกี้เบราว์เซอร์และโทเค็นที่เก็บข้อมูลในเครื่องโดยอัตโนมัติ"; +"Automatic imports browser cookies for dashboard extras." = "นําเข้าคุกกี้เบราว์เซอร์โดยอัตโนมัติสําหรับส่วนเสริมของแดชบอร์ด"; +"Automatic imports browser cookies for the web API." = "นําเข้าคุกกี้เบราว์เซอร์สําหรับเว็บ API โดยอัตโนมัติ"; +"Automatic imports browser cookies from Model Studio/Bailian." = "นําเข้าคุกกี้เบราว์เซอร์โดยอัตโนมัติจาก Model Studio/Bailian."; +"Automatic imports browser cookies from admin.mistral.ai." = "นําเข้าคุกกี้เบราว์เซอร์จาก admin.mistral.ai โดยอัตโนมัติ"; +"Automatic imports browser cookies from opencode.ai." = "นําเข้าคุกกี้เบราว์เซอร์จาก opencode.ai โดยอัตโนมัติ"; +"Automatic imports browser cookies or stored sessions." = "นําเข้าคุกกี้เบราว์เซอร์หรือเซสชันที่เก็บไว้โดยอัตโนมัติ"; +"Automatic imports browser cookies." = "นําเข้าคุกกี้เบราว์เซอร์โดยอัตโนมัติ"; +"Automatically imports browser session cookie." = "นําเข้าคุกกี้เซสชันเบราว์เซอร์โดยอัตโนมัติ"; +"Automatically opens CodexBar when you start your Mac." = "เปิด CodexBar โดยอัตโนมัติเมื่อคุณเริ่มต้นระบบ Mac"; +"Automation" = "ระบบอัตโนมัติ"; +"Average (\\(label1) + \\(label2))" = "เฉลี่ย (\\(label1) + \\(label2))"; +"Average (\\(metadata.sessionLabel) + \\(metadata.weeklyLabel))" = "เฉลี่ย (\\(metadata.sessionLabel) + \\(metadata.weeklyLabel))"; +"Avoid Keychain prompts" = "หลีกเลี่ยงข้อความแจ้ง Keychain"; +"Balance" = "สมดุล"; +"Battery Saver" = "ประหยัดแบตเตอรี่"; +"Bordered" = "มีพรมแดน"; +"Build" = "สร้าง"; +"Built \\(buildTimestamp)" = "สร้าง \\(buildTimestamp)"; +"Buy Credits..." = "ซื้อเครดิต..."; +"Buy Credits…" = "ซื้อเครดิต..."; +"CLI paths" = "เส้นทาง CLI"; +"CLI sessions" = "CLI เซสชัน"; +"Caches" = "แคช"; +"Cancel" = "ยกเลิก"; +"Check for Updates…" = "ตรวจสอบการอัปเดต..."; +"Check for updates automatically" = "ตรวจสอบการอัปเดตโดยอัตโนมัติ"; +"Check if you like your agents having some fun up there." = "ตรวจสอบว่าคุณชอบให้ตัวแทนของคุณสนุกสนานที่นั่นหรือไม่"; +"Check provider status" = "ตรวจสอบสถานะผู้ให้บริการ"; +"Choose a supported browser so CodexBar can read the matching account." = "เลือกเบราว์เซอร์ที่รองรับเพื่อให้ CodexBar อ่านบัญชีที่ตรงกันได้"; +"Choose Codex workspace" = "เลือกพื้นที่ทํางาน Codex"; +"Choose Cursor account" = "เลือกบัญชี Cursor"; +"Choose the MiniMax host (global .io or China mainland .com)." = "เลือกโฮสต์ MiniMax (.io ทั่วโลกหรือจีนแผ่นดินใหญ่ .com)"; +"Choose up to " = "เลือกได้ถึง "; +"Choose up to \\(Self.maxOverviewProviders) providers" = "เลือกผู้ให้บริการได้สูงสุด \\(Self.maxOverviewProviders) ราย"; +"Choose up to \\(count) providers" = "เลือกผู้ให้บริการได้สูงสุด \\(count) ราย"; +"Choose what to show in the menu bar (Pace shows usage vs. expected)." = "เลือกสิ่งที่จะแสดงในแถบเมนู (อัตราก้าวแสดงการใช้งานเทียบกับที่คาดไว้)"; +"Choose which Codex account CodexBar should follow." = "เลือกบัญชี Codex CodexBar ควรติดตาม"; +"Choose which Cursor account CodexBar should use." = "เลือกบัญชี Cursor ที่ CodexBar ควรใช้"; +"Choose which window drives the menu bar percent." = "เลือกหน้าต่างที่จะขับเคลื่อนเปอร์เซ็นต์ของแถบเมนู"; +"Chrome" = "Chrome"; +"Claude CLI not found" = "ไม่พบ Claude CLI"; +"Claude binary" = "Claude ไบนารี"; +"Claude cookies" = "คุกกี้ Claude"; +"Claude login failed" = "การเข้าสู่ระบบ Claude ล้มเหลว"; +"Claude login timed out" = "Claude เข้าสู่ระบบหมดเวลา"; +"Close" = "ปิด"; +"Code review" = "การตรวจสอบโค้ด"; +"Codex CLI not found" = "ไม่พบ Codex CLI"; +"Codex account login already running" = "Codex การเข้าสู่ระบบบัญชีผู้ใช้ที่ทํางานอยู่แล้ว"; +"Codex binary" = "Codex ไบนารี"; +"Codex login failed" = "การเข้าสู่ระบบ Codex ล้มเหลว"; +"Codex login timed out" = "Codex เข้าสู่ระบบหมดเวลา"; +"CodexBar Lifecycle Keepalive" = "CodexBar วงจรชีวิต Keepalive"; +"CodexBar can't show its menu bar icon" = "CodexBar ไม่สามารถแสดงไอคอนแถบเมนูได้"; +"CodexBar could not read managed account storage. " = "CodexBar อ่านพื้นที่เก็บข้อมูลของบัญชีที่มีการจัดการไม่ได้ "; +"Configure…" = "กําหนดค่า..."; +"Connected" = "เชื่อมต่อ"; +"Controls how much detail is logged." = "ควบคุมจํานวนรายละเอียดที่บันทึกไว้"; +"Cookie header" = "ส่วนหัวของคุกกี้"; +"Cookie source" = "แหล่งที่มาของคุกกี้"; +"Cookie: ..." = "คุกกี้: ..."; +"Cookie: \\u{2026}\\\n\\\nor paste a cURL capture from the Abacus AI dashboard" = "คุกกี้: \\u{2026}\\\n\\\n หรือวางการจับภาพ cURL จากแดชบอร์ด Abacus AI"; +"Cookie: \\u{2026}\\\n\\\nor paste the __Secure-next-auth.session-token value" = "คุกกี้: \\u{2026}\\\n\\\n หรือวางค่า __Secure-next-auth.session-token"; +"Cookie: \\u{2026}\\\n\\\nor paste the kimi-auth token value" = "คุกกี้: \\u{2026}\\\n\\\n หรือวางค่าโทเค็น kimi-auth"; +"Cookie: …" = "คุกกี้: ..."; +"CopilotDeviceFlow" = "โคไพลอตอุปกรณ์โฟลว์"; +"Cost" = "ราคา"; +"Could not add Codex account" = "ไม่สามารถเพิ่มบัญชี Codex ได้"; +"Could not open Terminal for Gemini" = "ไม่สามารถเปิดเทอร์มินัลสําหรับ Gemini"; +"Could not start claude /login" = "ไม่สามารถเริ่ม claude /login"; +"Could not start codex login" = "ไม่สามารถเริ่มการเข้าสู่ระบบ codex"; +"Could not switch system account" = "ไม่สามารถสลับบัญชีระบบได้"; +"Credits" = "เครดิต"; +"Individual credits" = "หน่วยกิตส่วนบุคคล"; +"Workspace" = "พื้นที่ทํางาน"; +"Credits history" = "ประวัติเครดิต"; +"Cursor login failed" = "การเข้าสู่ระบบ Cursor ล้มเหลว"; +"Custom" = "กําหนดเอง"; +"Custom Path" = "เส้นทางที่กําหนดเอง"; +"Daily Routines" = "กิจวัตรประจําวัน"; +"Debug" = "แก้ไขข้อบกพร่อง"; +"Default" = "ค่าเริ่มต้น"; +"Disable Keychain access" = "ปิดใช้งานการเข้าถึง Keychain"; +"Disabled" = "พิการ"; +"Dismiss" = "ปิด"; +"Disconnected" = "ตัดการเชื่อมต่อ"; +"Display" = "แสดง"; +"Display mode" = "โหมดการแสดงผล"; +"Display reset times as absolute clock values instead of countdowns." = "แสดงเวลารีเซ็ตเป็นค่านาฬิกาสัมบูรณ์แทนการนับถอยหลัง"; +"Done" = "เสร็จสิ้น"; +"Effective PATH" = "PATH ที่มีประสิทธิภาพ"; +"Email" = "อีเมล"; +"Enable Merge Icons to configure Overview tab providers." = "เปิดใช้งานไอคอนผสานเพื่อกําหนดค่าผู้ให้บริการแท็บภาพรวม"; +"Enable file logging" = "เปิดใช้งานการบันทึกไฟล์"; +"Enabled" = "เปิดใช้งาน"; +"Error" = "ข้อผิดพลาด"; +"Error simulation" = "การจําลองข้อผิดพลาด"; +"Expose troubleshooting tools in the Debug tab." = "แสดงเครื่องมือการแก้ไขปัญหาในแท็บ แก้ไขข้อบกพร่อง"; +"Failed" = "ล้มเหลว"; +"False" = "เท็จ"; +"Fetch strategy attempts" = "ความพยายามในการดึงกลยุทธ์"; +"Fetching" = "การดึงข้อมูล"; +"Field" = "ฟิลด์"; +"Field subtitle" = "คําบรรยายของฟิลด์"; +"Finish the current managed account change before switching the system account." = "เปลี่ยนบัญชีที่จัดการปัจจุบันให้เสร็จสิ้นก่อนเปลี่ยนบัญชีระบบ"; +"Force animation on next refresh" = "บังคับให้เคลื่อนไหวในการรีเฟรชครั้งถัดไป"; +"Gateway region" = "ภูมิภาคเกตเวย์"; +"Gemini CLI not found" = "ไม่พบ Gemini CLI"; +"Gemini/Antigravity, surfacing incidents in the icon and menu." = "Gemini/Antigravity แสดงเหตุการณ์ในไอคอนและเมนู"; +"General" = "ทั่วไป"; +"GitHub" = "GitHub"; +"GitHub Copilot Login" = "เข้าสู่ระบบ GitHub Copilot"; +"GitHub Login" = "เข้าสู่ระบบ GitHub"; +"Hide details" = "ซ่อนรายละเอียด"; +"Hide personal information" = "ซ่อนข้อมูลส่วนบุคคล"; +"Historical tracking" = "การติดตามในอดีต"; +"How often CodexBar polls providers in the background." = "ความถี่ในการ CodexBar ผู้ให้บริการโพลในเบื้องหลัง"; +"Inactive" = "ไม่ได้ใช้งาน"; +"Install CLI" = "ติดตั้ง CLI"; +"Install the Claude CLI (npm i -g @anthropic-ai/claude-code) and try again." = "ติดตั้ง Claude CLI (npm i -g @anthropic-ai/claude-code) แล้วลองอีกครั้ง"; +"Install the Codex CLI (npm i -g @openai/codex) and try again." = "ติดตั้ง Codex CLI (npm i -g @openai/codex) แล้วลองอีกครั้ง"; +"Install the Gemini CLI (npm i -g @google/gemini-cli) and try again." = "ติดตั้ง Gemini CLI (npm i -g @google/gemini-cli) แล้วลองอีกครั้ง"; +"Install a JetBrains IDE with AI Assistant enabled, then refresh CodexBar." = "ติดตั้ง JetBrains IDE ที่เปิดใช้ AI Assistant แล้วรีเฟรช CodexBar"; +"JetBrains AI is ready" = "JetBrains AI พร้อมแล้ว"; +"JetBrains IDE" = "JetBrains IDE"; +"Keep CLI sessions alive" = "รักษาเซสชัน CLI ให้มีชีวิตอยู่"; +"Keyboard shortcut" = "แป้นพิมพ์ลัด"; +"Keychain access" = "การเข้าถึง Keychain"; +"Keychain prompt policy" = "นโยบายพร้อมท์ Keychain"; +"Last \\(name) fetch failed:" = "การดึงข้อมูล \\(name) ครั้งล่าสุดล้มเหลว:"; +"Last \\(self.store.metadata(for: self.provider).displayName) fetch failed:" = "การดึงข้อมูล Last \\(self.store.metadata(for: self.provider).displayName) ล้มเหลว:"; +"Last attempt" = "ความพยายามครั้งสุดท้าย"; +"Link" = "ลิงค์"; +"Loading animations" = "กําลังโหลดภาพเคลื่อนไหว"; +"Loading…" = "กําลังโหลด..."; +"Local" = "ท้องถิ่น"; +"Logging" = "การบันทึก"; +"Login failed" = "เข้าสู่ระบบล้มเหลว"; +"Login shell PATH (startup capture)" = "PATH เชลล์เข้าสู่ระบบ (การจับภาพการเริ่มต้น)"; +"Login timed out" = "หมดเวลาเข้าสู่ระบบ"; +"MCP details" = "รายละเอียด MCP"; +"Managed Codex accounts unavailable" = "บัญชี Codex ที่มีการจัดการไม่พร้อมใช้งาน"; +"Managed account storage is unreadable. Live account access is still available, " = "พื้นที่เก็บข้อมูลของบัญชีที่มีการจัดการไม่สามารถอ่านได้ การเข้าถึงบัญชีจริงยังคงมีอยู่ "; +"Manual" = "ด้วยมือ"; +"May your tokens never run out—keep agent limits in view." = "ขอให้โทเค็นของคุณไม่มีวันหมด ให้คํานึงถึงขีดจํากัดของเจ้าหน้าที่"; +"Menu bar" = "แถบเมนู"; +"Menu bar auto-shows the provider closest to its rate limit." = "แถบเมนูจะแสดงผู้ให้บริการที่ใกล้เคียงกับขีดจํากัดอัตรามากที่สุดโดยอัตโนมัติ"; +"Menu bar metric" = "เมตริกแถบเมนู"; +"Menu bar shows percent" = "แถบเมนูแสดงเปอร์เซ็นต์"; +"Menu content" = "เนื้อหาเมนู"; +"Merge Icons" = "ผสานไอคอน"; +"Never prompt" = "ไม่เคยแจ้ง"; +"No" = "ไม่"; +"No Codex accounts detected yet." = "ยังไม่พบบัญชี Codex"; +"No JetBrains IDE detected" = "ไม่พบ JetBrains IDE"; +"No cost history data." = "ไม่มีข้อมูลประวัติค่าใช้จ่าย"; +"No data available" = "ไม่มีข้อมูล"; +"No data yet" = "ยังไม่มีข้อมูล"; +"No enabled providers available for Overview." = "ไม่มีผู้ให้บริการที่เปิดใช้งานสําหรับภาพรวม"; +"No providers selected" = "ไม่มีผู้ให้บริการที่เลือก"; +"No token accounts yet." = "ยังไม่มีบัญชีโทเค็น"; +"No usage breakdown data." = "ไม่มีข้อมูลรายละเอียดการใช้งาน"; +"None" = "ไม่มี"; +"Notifications" = "การแจ้งเตือน"; +"Notifies when the 5-hour session quota hits 0% and when it becomes " = "แจ้งเตือนเมื่อโควตาเซสชัน 5 ชั่วโมงเหลือ 0% และเมื่อกลับมา"; +"OK" = "ตกลง"; +"Obscure email addresses in the menu bar and menu UI." = "ปิดบังที่อยู่อีเมลในแถบเมนูและ UI เมนู"; +"Off" = "ปิด"; +"Offline" = "ออฟไลน์"; +"On" = "เปิด"; +"Online" = "ออนไลน์"; +"Only on user action" = "เฉพาะกับการกระทําของผู้ใช้"; +"Open" = "เปิด"; +"Open API Keys" = "เปิดปุ่ม API"; +"Open Amp Settings" = "เปิดการตั้งค่า Amp"; +"Open Antigravity to sign in, then refresh CodexBar." = "เปิด Antigravity เพื่อลงชื่อเข้าใช้ แล้วรีเฟรช CodexBar"; +"Open Browser" = "เปิดเบราว์เซอร์"; +"Open Coding Plan" = "เปิดแผนการเข้ารหัส"; +"Open Console" = "เปิดคอนโซล"; +"Open Dashboard" = "เปิดแดชบอร์ด"; +"Open Mistral Admin" = "เปิดผู้ดูแลระบบ Mistral"; +"Open Menu Bar Settings" = "เปิดการตั้งค่าแถบเมนู"; +"Open Ollama Settings" = "เปิดการตั้งค่า Ollama"; +"Open Terminal" = "เปิดเทอร์มินัล"; +"Open Usage Page" = "เปิดหน้าการใช้งาน"; +"Open Warp API Key Guide" = "เปิดคู่มือคีย์ Warp API"; +"Open menu" = "เปิดเมนู"; +"Open token file" = "เปิดไฟล์โทเค็น"; +"OpenAI cookies" = "คุกกี้ OpenAI"; +"OpenAI web extras" = "OpenAI ความพิเศษของเว็บ"; +"Option A" = "ตัวเลือก A"; +"Option B" = "ตัวเลือก B"; +"Optional override if workspace lookup fails." = "การแทนที่ทางเลือกหากการค้นหาพื้นที่ทํางานล้มเหลว"; +"Options" = "ตัวเลือก"; +"Override auto-detection with a custom IDE base path" = "แทนที่การตรวจหาอัตโนมัติด้วยเส้นทางพื้นฐาน IDE แบบกําหนดเอง"; +"Overview" = "ภาพรวม"; +"Overview rows always follow provider order." = "แถวภาพรวมจะเป็นไปตามลําดับของผู้ให้บริการเสมอ"; +"Overview tab providers" = "ผู้ให้บริการแท็บภาพรวม"; +"Paste API key…" = "วาง API คีย์..."; +"Paste API token…" = "วางโทเค็น API..."; +"Paste key…" = "แป้นวาง..."; +"Paste sessionKey or OAuth token…" = "วาง sessionKey หรือ OAuth token..."; +"Paste the Cookie header from a request to admin.mistral.ai. " = "วางส่วนหัวคุกกี้จากคําขอไปยัง admin.mistral.ai. "; +"Paste token…" = "วางโทเค็น..."; +"Personal" = "ส่วนบุคคล"; +"Picker" = "หยิบ"; +"Picker subtitle" = "คําบรรยาย Picker"; +"Placeholder" = "ตัวยึดตําแหน่ง"; +"Plan" = "วางแผน"; +"Plan Usage" = "การใช้งานแผน"; +"Play full-screen confetti when weekly usage resets." = "เล่นลูกปาแบบเต็มหน้าจอเมื่อรีเซ็ตการใช้งานรายสัปดาห์"; +"Polls OpenAI/Claude status pages and Google Workspace for " = "โพล OpenAI/Claude หน้าสถานะและพื้นที่ทํางาน Google สําหรับ "; +"Prevents any Keychain access while enabled." = "ป้องกันการเข้าถึง Keychain ใดๆ ขณะเปิดใช้งาน"; +"Primary (API key limit)" = "หลัก (จํากัดคีย์ API)"; +"Primary (\\(label))" = "ประถมศึกษา (\\(label))"; +"Primary (\\(metadata.sessionLabel))" = "ประถมศึกษา (\\(metadata.sessionLabel))"; +"Probe logs" = "บันทึกโพรบ"; +"Progress bars fill as you consume quota (instead of showing remaining)." = "แถบความคืบหน้าจะเต็มเมื่อคุณใช้โควต้า (แทนที่จะแสดงปริมาณที่เหลืออยู่)"; +"Provider" = "ผู้ให้บริการ"; +"Providers" = "ผู้ให้บริการ"; +"Quit CodexBar" = "ออกจาก CodexBar"; +"Random (default)" = "สุ่ม (ค่าเริ่มต้น)"; +"Reads local usage logs. Shows today + last 30 days cost in the menu." = "อ่านบันทึกการใช้งานในเครื่อง แสดงวันนี้ + หน้าต่างประวัติที่เลือกในเมนู"; +"Refresh" = "รีเฟรช"; +"Refresh cadence" = "จังหวะการรีเฟรช"; +"Remote" = "ระยะไกล"; +"Remove" = "ลบ"; +"Remove Codex account?" = "ลบบัญชี Codex?"; +"Remove \\(account.email) from CodexBar? Its managed Codex home will be deleted." = "ลบ \\(account.email) ออกจาก CodexBar? ระบบจะลบหน้าแรก Codex ที่มีการจัดการ"; +"Remove \\(email) from CodexBar? Its managed Codex home will be deleted." = "ลบ \\(email) ออกจาก CodexBar? ระบบจะลบหน้าแรก Codex ที่มีการจัดการ"; +"Remove selected account" = "ลบบัญชีที่เลือก"; +"Replace critter bars with provider branding icons and a percentage." = "แทนที่แถบสัตว์ด้วยไอคอนการสร้างแบรนด์ของผู้ให้บริการและเปอร์เซ็นต์"; +"Replay selected animation" = "เล่นซ้ําภาพเคลื่อนไหวที่เลือก"; +"Requires authentication via GitHub Device Flow." = "ต้องมีการรับรองความถูกต้องผ่าน GitHub Device Flow"; +"Resets: \\(reset)" = "รีเซ็ต: \\(reset)"; +"Rolling five-hour limit" = "ขีด จํากัด ห้าชั่วโมง"; +"Search hourly" = "ค้นหารายชั่วโมง"; +"Secondary (\\(label))" = "รอง (\\(label))"; +"Secondary (\\(metadata.weeklyLabel))" = "รอง (\\(metadata.weeklyLabel))"; +"Select a provider" = "เลือกผู้ให้บริการ"; +"Select the IDE to monitor" = "เลือก IDE ที่จะตรวจสอบ"; +"Session quota notifications" = "การแจ้งเตือนโควต้าเซสชัน"; +"Session tokens" = "โทเค็นเซสชัน"; +"provider_section_connection" = "การเชื่อมต่อ"; +"provider_section_menu_bar" = "แถบเมนู"; +"Show Codex Credits and Claude Extra usage sections in the menu." = "แสดงส่วนเครดิต Codex และการใช้งานเพิ่มเติม Claude ในเมนู"; +"Show Debug Settings" = "แสดงการตั้งค่าการดีบัก"; +"Show all token accounts" = "แสดงบัญชีโทเค็นทั้งหมด"; +"Show cost summary" = "แสดงสรุปค่าใช้จ่าย"; +"Show credits + extra usage" = "แสดงเครดิต + การใช้งานเพิ่มเติม"; +"Show details" = "แสดงรายละเอียด"; +"Show most-used provider" = "แสดงผู้ให้บริการที่ใช้บ่อยที่สุด"; +"Show provider icons in the switcher (otherwise show a weekly progress line)." = "แสดงไอคอนผู้ให้บริการในตัวสลับ (มิฉะนั้นให้แสดงรายการความคืบหน้ารายสัปดาห์)"; +"Show reset time as clock" = "แสดงเวลารีเซ็ตเป็นนาฬิกา"; +"Show usage as used" = "แสดงการใช้งานตามที่ใช้"; +"Sign in with Claude Code..." = "ลงชื่อเข้าใช้ด้วย Claude Code..."; +"Sign in via button below" = "ลงชื่อเข้าใช้ผ่านปุ่มด้านล่าง"; +"Skip teardown between probes (debug-only)." = "ข้ามการฉีกขาดระหว่างโพรบ (ดีบักเท่านั้น)"; +"Stack token accounts in the menu (otherwise show an account switcher bar)." = "สแต็คบัญชีโทเค็นในเมนู (มิฉะนั้นจะแสดงแถบตัวสลับบัญชี)"; +"Start at Login" = "เริ่มต้นที่เข้าสู่ระบบ"; +"Status" = "สถานะ"; +"Store Claude sessionKey cookies or OAuth access tokens." = "จัดเก็บคุกกี้ sessionKey Claude หรือโทเค็นการเข้าถึง OAuth"; +"Store multiple Abacus AI Cookie headers." = "จัดเก็บส่วนหัว Abacus AI Cookie หลายรายการ"; +"Store multiple Augment Cookie headers." = "จัดเก็บส่วนหัว Augment Cookie หลายรายการ"; +"Store multiple Cursor Cookie headers." = "จัดเก็บส่วนหัว Cursor Cookie หลายรายการ"; +"Store multiple Factory Cookie headers." = "จัดเก็บส่วนหัว Factory Cookie หลายรายการ"; +"Store multiple MiniMax Cookie headers." = "จัดเก็บส่วนหัว MiniMax Cookie หลายรายการ"; +"Store multiple Mistral Cookie headers." = "จัดเก็บส่วนหัว Mistral Cookie หลายรายการ"; +"Store multiple Ollama Cookie headers." = "จัดเก็บส่วนหัว Ollama Cookie หลายรายการ"; +"Store multiple OpenCode Cookie headers." = "จัดเก็บส่วนหัว OpenCode Cookie หลายรายการ"; +"Store multiple OpenCode Go Cookie headers." = "จัดเก็บส่วนหัวคุกกี้ OpenCode Go หลายรายการ"; +"Stored in the CodexBar config file." = "เก็บไว้ในไฟล์กําหนดค่า CodexBar"; +"Stored in ~/.codexbar/config.json. " = "เก็บไว้ใน ~/.codexbar/config.json. "; +"Stored in ~/.codexbar/config.json. Paste the key from the Synthetic dashboard." = "เก็บไว้ใน ~/.codexbar/config.json. วางคีย์จากแดชบอร์ด Synthetic"; +"Stored in ~/.codexbar/config.json. Paste your Coding Plan API key from Model Studio." = "เก็บไว้ใน ~/.codexbar/config.json. วาง API คีย์แผนการเข้ารหัสของคุณจาก Model Studio"; +"Stored in ~/.codexbar/config.json. Paste your MiniMax API key." = "เก็บไว้ใน ~/.codexbar/config.json. วางคีย์ MiniMax API ของคุณ"; +"Stored in ~/.codexbar/config.json. You can also provide KILO_API_KEY or " = "เก็บไว้ใน ~/.codexbar/config.json. คุณยังสามารถระบุ KILO_API_KEY or "; +"Stores local Codex usage history (8 weeks) to personalize Pace predictions." = "จัดเก็บประวัติการใช้งาน Codex ในพื้นที่ (8 สัปดาห์) เพื่อปรับแต่งการคาดการณ์ Pace ในแบบของคุณ"; +"Surprise me" = "ทําให้ฉันประหลาดใจ"; +"Switcher shows icons" = "ตัวสลับแสดงไอคอน"; +"Symlink CodexBarCLI to /usr/local/bin and /opt/homebrew/bin as codexbar." = "Symlink CodexBarCLI เป็น /usr/local/bin และ /opt/homebrew/bin เป็น codexbar"; +"System" = "ระบบ"; +"Temporarily shows the loading animation after the next refresh." = "แสดงภาพเคลื่อนไหวการโหลดชั่วคราวหลังจากการรีเฟรชครั้งถัดไป"; +"terminal_app_subtitle" = "เทอร์มินัลที่ใช้โดยการดําเนินการ Open Terminal"; +"terminal_app_title" = "เทอร์มินัลเริ่มต้น"; +"Tertiary (\\(label))" = "ระดับอุดมศึกษา (\\(label))"; +"Tertiary (\\(tertiaryTitle))" = "ระดับอุดมศึกษา (\\(tertiaryTitle))"; +"The default Codex account on this Mac." = "บัญชี Codex เริ่มต้นบน Mac เครื่องนี้"; +"Toggle" = "สลับ"; +"Toggle subtitle" = "สลับคําบรรยาย"; +"Token" = "โทเค็น"; +"Trigger the menu bar menu from anywhere." = "ทริกเกอร์เมนูแถบเมนูได้จากทุกที่"; +"True" = "จริง"; +"Twitter" = "ทวิตเตอร์"; +"Unsupported" = "ไม่รองรับ"; +"Update Channel" = "อัปเดตช่อง"; +"Updated" = "อัพเดท"; +"Updates unavailable in this build." = "การอัปเดตไม่พร้อมใช้งานในรุ่นนี้"; +"Usage" = "การใช้"; +"Usage breakdown" = "รายละเอียดการใช้งาน"; +"Usage history (30 days)" = "ประวัติการใช้งาน"; +"Usage source" = "แหล่งที่มาของการใช้งาน"; +"Use Account" = "ใช้บัญชี"; +"Use BigModel for the China mainland endpoints (open.bigmodel.cn)." = "ใช้ BigModel สําหรับปลายทางจีนแผ่นดินใหญ่ (open.bigmodel.cn)"; +"Use a single menu bar icon with a provider switcher." = "ใช้ไอคอนแถบเมนูเดียวกับตัวสลับผู้ให้บริการ"; +"Use international or China mainland console gateways for quota fetches." = "ใช้เกตเวย์คอนโซลระหว่างประเทศหรือจีนแผ่นดินใหญ่สําหรับการดึงข้อมูลโควต้า"; +"Version" = "รุ่น"; +"Version \\(self.versionString)" = "เวอร์ชัน \\(self.versionString)"; +"Version \\(version)" = "เวอร์ชัน \\(version)"; +"Version \\(versionString)" = "เวอร์ชัน \\(versionString)"; +"Vertex AI Login" = "เข้าสู่ระบบ Vertex AI"; +"Wait for the current managed Codex login to finish before adding another account." = "รอให้การเข้าสู่ระบบ Codex ที่มีการจัดการปัจจุบันเสร็จสิ้นก่อนที่จะเพิ่มบัญชีอื่น"; +"Waiting for Authentication..." = "กําลังรอการรับรองความถูกต้อง..."; +"Website" = "เว็บไซต์"; +"Weekly limit confetti" = "ลูกปาจํากัดรายสัปดาห์"; +"Weekly token limit" = "ขีดจํากัดโทเค็นรายสัปดาห์"; +"Weekly usage" = "การใช้งานรายสัปดาห์"; +"Weekly usage unavailable for this account." = "การใช้งานรายสัปดาห์ไม่พร้อมใช้งานสําหรับบัญชีนี้"; +"Window: \\(window)" = "หน้าต่าง: \\(window)"; +"Write logs to \\(self.fileLogPath) for debugging." = "เขียนบันทึกไปยัง \\(self.fileLogPath) เพื่อแก้ไขข้อบกพร่อง"; +"Yes" = "ใช่"; +"\\(detail.modelCode): \\(usage)" = "\\(detail.modelCode): \\(usage)"; +"\\(name): \\(truncated)" = "\\(name): \\(truncated)"; +"\\(name): \\(updated) · 30d \\(cost)" = "\\(name): \\(updated) · 30d \\(cost)"; +"\\(name): fetching…\\(elapsed)" = "\\(name): กําลังดึง... \\(elapsed)"; +"\\(name): last attempt \\(when)" = "\\(name): ความพยายามครั้งสุดท้าย \\(when)"; +"\\(name): no data yet" = "\\(name): ยังไม่มีข้อมูล"; +"\\(name): unsupported" = "\\(name): ไม่รองรับ"; +"all browsers" = "เบราว์เซอร์ทั้งหมด"; +"available again." = "ใช้ได้อีกครั้ง"; +"built_format" = "สร้าง %@"; +"copilot_complete_in_browser" = "ลงชื่อเข้าใช้ในเบราว์เซอร์ให้เสร็จสมบูรณ์"; +"copilot_device_code" = "รหัสอุปกรณ์ที่คัดลอกไปยังคลิปบอร์ด: %1$@\n\n ดูได้ที่: %2$@"; +"copilot_device_code_copied" = "คัดลอกรหัสอุปกรณ์"; +"copilot_verify_at" = "ยืนยันที่ %@"; +"copilot_waiting_text" = "ลงชื่อเข้าใช้ในเบราว์เซอร์ให้เสร็จสมบูรณ์ \n หน้าต่างนี้จะปิดโดยอัตโนมัติเมื่อลงชื่อเข้าใช้เสร็จสมบูรณ์"; +"copilot_window_closes_auto" = "หน้าต่างนี้จะปิดโดยอัตโนมัติเมื่อการลงชื่อเข้าใช้เสร็จสมบูรณ์"; +"cost_status_error" = "%1$@: %2$@"; +"cost_status_fetching" = "%1$@: กําลังดึง... %2$@"; +"cost_status_last_attempt" = "%1$@: ความพยายามครั้งสุดท้าย %2$@"; +"cost_status_no_data" = "%@: ยังไม่มีข้อมูล"; +"cost_status_snapshot" = "%1$@: %2$@ · %3$@ %4$@"; +"cost_status_unsupported" = "%@: ไม่รองรับ"; +"credits_remaining" = "เครดิต: %@"; +"cursor_on_demand" = "ตามความต้องการ: %@"; +"cursor_on_demand_with_limit" = "ตามความต้องการ: %1$@ / %2$@"; +"extra_usage_format" = "การใช้งานเพิ่มเติม: %1$@ / %2$@"; +"jetbrains_detected_generate" = "ตรวจพบ: %@ ใช้ผู้ช่วย AI หนึ่งครั้งเพื่อสร้างข้อมูลโควต้า จากนั้นรีเฟรช CodexBar"; +"jetbrains_detected_select" = "ตรวจพบ: %@ เลือก IDE ที่คุณต้องการในการตั้งค่า จากนั้นรีเฟรช CodexBar"; +"last_fetch_failed_with_provider" = "การดึงข้อมูล %@ ครั้งล่าสุดล้มเหลว:"; +"last_spend" = "ใช้จ่ายครั้งล่าสุด: %@"; +"mcp_model_usage" = "%1$@: %2$@"; +"mcp_resets" = "รีเซ็ต: %@"; +"mcp_window" = "หน้าต่าง: %@"; +"metric_average" = "เฉลี่ย (%1$@ + %2$@)"; +"metric_primary" = "ประถมศึกษา (%@)"; +"metric_secondary" = "รอง (%@)"; +"metric_tertiary" = "ระดับอุดมศึกษา (%@)"; +"multiple_workspaces_found" = "CodexBar พบพื้นที่ทํางานหลายแห่งสําหรับ %@ โปรดเลือกพื้นที่ทํางานที่จะเพิ่ม"; +"ory_session_…=…; csrftoken=…" = "ory_session_...=...; csrftoken=..."; +"overview_choose_providers" = "เลือกผู้ให้บริการได้สูงสุด %@ ราย"; +"remove_account_message" = "ลบ %@ ออกจาก CodexBar? ระบบจะลบหน้าแรก Codex ที่มีการจัดการ"; +"version_format" = "เวอร์ชัน %@"; +"vertex_ai_login_instructions" = "หากต้องการติดตามการใช้งาน Vertex AI ให้ตรวจสอบสิทธิ์ด้วย Google Cloud.\n\n1 เปิดเทอร์มินัล \n2 เรียกใช้: gcloud auth application-default login\n3 ทําตามคําแนะนําของเบราว์เซอร์เพื่อลงชื่อเข้าใช้ \n4 ตั้งค่าโครงการของคุณ: gcloud config set project PROJECT_ID\n\nOpen Terminal ตอนนี้?"; +"workspaceID is set but only opencode, opencodego, and deepgram support workspaceID." = "มีการตั้งค่า workspaceID แต่รองรับ opencode, opencodego และ deepgram workspaceID เท่านั้น"; +"© 2026 Peter Steinberger. MIT License." = "© 2026 ปีเตอร์ สไตน์เบอร์เกอร์ ใบอนุญาต MIT"; + +/* General Pane */ +"section_system" = "ระบบ"; +"section_usage" = "การใช้"; +"section_refreshing" = "การรีเฟรช"; +"section_alerts" = "การแจ้งเตือน"; +"section_celebrations" = "การเฉลิมฉลอง"; +"section_icon" = "ไอคอน"; +"section_combined_icon" = "ไอคอนรวม"; +"section_animation" = "ภาพเคลื่อนไหว"; +"section_content" = "เนื้อหา"; +"section_agent_sessions" = "เซสชันเอเจนต์"; +"language_title" = "ภาษา"; +"language_subtitle" = "เปลี่ยนภาษาที่แสดง ต้องรีสตาร์ทแอปเพื่อให้มีผลเต็มที่"; +"currency_title" = "สกุลเงินที่ต้องการ"; +"currency_subtitle" = "สกุลเงินสำหรับประมาณการต้นทุนและค่าใช้จ่าย ใช้อัตราแลกเปลี่ยนที่อัปเดตทุกวัน"; +"currency_auto" = "อัตโนมัติ (ตามผู้ให้บริการ / USD)"; +"language_system" = "ระบบ"; +"language_english" = "อังกฤษ"; +"language_spanish" = "Español"; +"language_catalan" = "คาตาลา"; +"language_chinese_simplified" = "简体中文"; +"language_chinese_traditional" = "繁體中文"; +"language_portuguese_brazilian" = "Português (บราซิล)"; +"language_dutch" = "เนเธอร์แลนด์"; +"language_german" = "เยอรมัน"; +"language_swedish" = "สเวนสกา"; +"language_french" = "ฝรั่งเศส"; +"language_ukrainian" = "Українська"; +"language_russian" = "Русский"; +"language_japanese" = "ภาษาญี่ปุ่น"; +"language_korean" = "เกาหลี"; +"language_turkish" = "Türkçe"; +"language_italian" = "อิตาเลียโน"; +"language_polish" = "Polski"; +"start_at_login_title" = "เริ่มต้นที่เข้าสู่ระบบ"; +"start_at_login_subtitle" = "เปิด CodexBar โดยอัตโนมัติเมื่อคุณเริ่มต้นระบบ Mac"; +"show_cost_summary_subtitle" = "อ่านบันทึกการใช้งานในเครื่อง แสดงวันนี้ + หน้าต่างประวัติที่เลือกในเมนู"; +"cost_summary_style_title" = "รูปแบบการแสดงผล"; +"cost_summary_style_inline" = "อินไลน์เท่านั้น"; +"cost_summary_style_submenu" = "เมนูย่อยเท่านั้น"; +"cost_summary_style_both" = "ทั้งสอง"; +"cost_summary_style_inline_help" = "แสดงสรุปค่าใช้จ่ายในเมนูหลักโดยตรง"; +"cost_summary_style_submenu_help" = "แสดงเมนูย่อยค่าใช้จ่ายแบบละเอียดแทน"; +"cost_summary_style_both_help" = "แสดงทั้งสรุปในเมนูหลักและเมนูย่อยค่าใช้จ่ายแบบละเอียด"; +"cost_history_window_title" = "กรอบเวลาประวัติ"; +"cost_history_window_help" = "กำหนดจำนวนวันของบันทึกการใช้งานในเครื่องที่จะแสดงในเมนู"; +"cost_history_days_title" = "กรอบเวลาประวัติ: %d วัน"; +"cost_comparison_periods_title" = "แสดงช่วงเปรียบเทียบที่สั้นกว่า"; +"cost_comparison_periods_subtitle" = "เพิ่มยอดรวม 7, 30 และ 90 วันเมื่ออยู่ภายในกรอบเวลาประวัติที่เลือก โดยใช้การสแกนในเครื่องเดียวกัน"; +"cost_auto_refresh_info" = "รีเฟรชอัตโนมัติ: ช่วงเวลาส่วนกลาง (ขั้นต่ำ 5 นาที) · หมดเวลา: 10 นาที"; +"refresh_interval_title" = "ช่วงเวลาการรีเฟรช"; +"manual_refresh_hint" = "การรีเฟรชอัตโนมัติปิดอยู่ ใช้คําสั่งรีเฟรชของเมนู"; +"refresh_on_open_title" = "รีเฟรชเมื่อเปิดเมนู"; +"refresh_on_open_subtitle" = "ดึงข้อมูลการใช้งานล่าสุดของผู้ให้บริการทุกรายทุกครั้งที่คุณเปิดเมนู"; +"check_provider_status_title" = "ตรวจสอบสถานะผู้ให้บริการ"; +"check_provider_status_subtitle" = "โพล OpenAI/Claude หน้าสถานะและ Google Workspace for Gemini/Antigravity โดยแสดงเหตุการณ์ในไอคอนและเมนู"; +"session_quota_notifications_subtitle" = "แจ้งเตือนเมื่อโควตาเซสชัน 5 ชั่วโมงเหลือ 0% และเมื่อกลับมาใช้งานได้อีกครั้ง"; +"quota_depleted_title" = "โควต้าหมดและกลับมาใช้งานได้"; +"quota_warning_notifications_subtitle" = "เตือนเมื่อเซสชันหรือโควต้ารายสัปดาห์ที่เหลืออยู่ข้ามเกณฑ์ที่กําหนดค่าไว้"; +"threshold_warnings_title" = "คำเตือนตามเกณฑ์"; +"quota_warnings_title" = "คําเตือนโควต้า"; +"quota_warning_session" = "เซสชั่น"; +"quota_warning_session_capitalized" = "เซสชั่น"; +"quota_warning_weekly" = "รายสัปดาห์"; +"quota_warning_weekly_capitalized" = "รายสัปดาห์"; +"quota_warning_notification_title" = "โควตา%2$@ของ %1$@ ใกล้หมด"; +"quota_warning_notification_body" = "เหลือ %1$@ ถึงเกณฑ์การเตือน %2$d%% สำหรับโควตา%3$@แล้ว"; +"quota_warning_notification_body_with_account" = "บัญชี %1$@ เหลือ %2$@ ถึงเกณฑ์การเตือน %3$d%% สำหรับโควตา%4$@แล้ว"; +"predictive_pace_warnings_title" = "คำเตือนความเร็วการใช้เชิงคาดการณ์"; +"predictive_pace_warnings_subtitle" = "เตือนสำหรับ Codex และ Claude เมื่อความเร็วการใช้โควตาเซสชันหรือรายสัปดาห์อาจทำให้หมดก่อนรีเซ็ต"; +"confetti_on_reset_title" = "คอนเฟตตีเมื่อรีเซ็ต"; +"confetti_on_reset_subtitle" = "แสดงคอนเฟตตีเต็มหน้าจอเมื่อรีเซ็ตการใช้งาน"; +"confetti_option_off" = "ปิด"; +"confetti_option_session" = "การรีเซ็ตเซสชัน"; +"confetti_option_weekly" = "การรีเซ็ตรายสัปดาห์"; +"confetti_option_both" = "ทั้งสอง"; +"predictive_pace_warning_notification_title" = "%1$@ คำเตือนความเร็วการใช้%2$@"; +"predictive_pace_warning_notification_body" = "ด้วยความเร็วการใช้ปัจจุบัน โควตานี้อาจหมดใน %1$@ ก่อนที่จะรีเซ็ต"; +"predictive_pace_warning_notification_body_with_account" = "บัญชี %1$@ ด้วยความเร็วการใช้ปัจจุบัน โควตานี้อาจหมดใน %2$@ ก่อนที่จะรีเซ็ต"; +"session_depleted_notification_title" = "เซสชัน %@ หมดลง"; +"session_depleted_notification_body" = "เหลือ 0% จะแจ้งเตือนเมื่อกลับมาใช้งานได้อีกครั้ง"; +"session_restored_notification_title" = "%@ เซสชันที่กู้คืน"; +"session_restored_notification_body" = "โควต้าเซสชันพร้อมใช้งานอีกครั้ง"; +"quota_warning_warn_at" = "เตือนที่"; +"quota_warning_global_threshold_subtitle" = "เปอร์เซ็นต์ที่เหลืออยู่สําหรับกรอบเวลาเซสชันและรายสัปดาห์ เว้นแต่ผู้ให้บริการจะแทนที่"; +"quota_warning_sound" = "เล่นเสียงแจ้งเตือน"; +"quota_warning_onscreen_alert" = "แสดงการแจ้งเตือนข้อความบนหน้าจอ"; +"quota_warning_provider_inherits" = "ใช้การตั้งค่าคําเตือนโควต้าส่วนกลาง เว้นแต่จะมีการกําหนดหน้าต่างเองที่นี่"; +"quota_warning_provider_disabled" = "การแจ้งเตือนคำเตือนโควตาและเครื่องหมายบนแถบการใช้งานถูกปิดใช้งาน เปิดใช้งานอย่างใดอย่างหนึ่งเพื่อแก้ไขการตั้งค่าที่บันทึกไว้เหล่านี้"; +"quota_warning_provider_markers_only" = "การแจ้งเตือนคำเตือนโควตาถูกปิดใช้งานทั่วทั้งแอป การตั้งค่าเหล่านี้ยังคงควบคุมเครื่องหมายบนแถบการใช้งาน"; +"quota_warning_global" = "ส่วนกลาง"; +"quota_warning_customize_thresholds" = "ปรับแต่งเกณฑ์ %@"; +"quota_warning_enable_warnings" = "เปิดใช้งานคําเตือน %@"; +"quota_warning_window_warn_at" = "%@ เตือนที่"; +"quota_warning_off" = "ปิด"; +"quota_warning_inherited" = "สืบทอด: %@"; +"quota_warning_depleted_only" = "หมดลงเท่านั้น"; +"quota_warning_upper" = "สูงกว่า"; +"quota_warning_lower" = "ต่ํากว่า"; +"quota_warning_warning" = "คำเตือน"; +"quota_warning_critical" = "วิกฤติ"; +"apply" = "สมัคร"; +"quit_app" = "ออกจาก CodexBar"; + +/* Tab titles */ +"tab_general" = "ทั่วไป"; +"tab_providers" = "ผู้ให้บริการ"; +"tab_notifications" = "การแจ้งเตือน"; +"tab_menu_bar" = "แถบเมนู"; +"tab_menu" = "เมนู"; +"tab_advanced" = "ขั้นสูง"; +"tab_hooks" = "ฮุก"; +"tab_about" = "เกี่ยวกับ"; + +/* Hooks Pane */ +"hooks_enable_title" = "เปิดใช้งานฮุก"; +"hooks_enable_subtitle" = "เรียกใช้คำสั่งภายนอกเมื่อเกิดเหตุการณ์โควตาหรือผู้ให้บริการ"; +"hooks_trust_warning" = "ฮุกสามารถเรียกใช้คำสั่งในเครื่อง Mac ของคุณได้ ตั้งค่าเฉพาะคำสั่งที่คุณเชื่อถือเท่านั้น"; +"hooks_rules_header" = "กฎ"; +"hooks_empty" = "ยังไม่ได้ตั้งค่าฮุก"; +"hooks_add_rule" = "เพิ่มกฎ"; +"hooks_delete_rule" = "ลบกฎ"; +"hooks_rule_enabled" = "เปิดใช้งาน"; +"hooks_event" = "เหตุการณ์"; +"hooks_provider" = "ผู้ให้บริการ"; +"hooks_any_provider" = "ผู้ให้บริการใดก็ได้"; +"hooks_threshold" = "เรียกใช้เมื่อการใช้งาน ≥"; +"hooks_threshold_placeholder" = "90"; +"hooks_executable_placeholder" = "/usr/local/bin/my-command"; +"hooks_arguments_placeholder" = "อาร์กิวเมนต์"; +"hooks_argument_placeholder" = "อาร์กิวเมนต์"; +"hooks_add_argument" = "เพิ่มอาร์กิวเมนต์"; +"hooks_delete_argument" = "ลบอาร์กิวเมนต์"; +"tab_debug" = "แก้ไขข้อบกพร่อง"; + +/* Providers Pane */ +"select_a_provider" = "เลือกผู้ให้บริการ"; +"cancel" = "ยกเลิก"; +"last_fetch_failed" = "การดึงข้อมูลครั้งล่าสุดล้มเหลว"; +"usage_not_fetched_yet" = "ยังไม่ได้ดึงข้อมูลการใช้งาน"; +"managed_account_storage_unreadable" = "พื้นที่เก็บข้อมูลของบัญชีที่มีการจัดการไม่สามารถอ่านได้ สิทธิ์การเข้าถึงบัญชีจริงจะยังคงใช้งานได้ แต่การดําเนินการเพิ่มที่มีการจัดการ การตรวจสอบสิทธิ์อีกครั้ง และลบออกจะถูกปิดใช้งานจนกว่าร้านค้าจะกู้คืนได้"; +"remove_codex_account_title" = "ลบบัญชี Codex?"; +"remove" = "ลบ"; +"managed_login_already_running" = "การเข้าสู่ระบบ Codex ที่มีการจัดการกําลังทํางานอยู่แล้ว รอให้เสร็จสิ้นก่อนที่จะเพิ่มหรือตรวจสอบสิทธิ์บัญชีอื่นอีกครั้ง"; +"managed_login_failed" = "การเข้าสู่ระบบ Codex ที่มีการจัดการไม่เสร็จสมบูรณ์ ตรวจสอบว่า `codex --version` ใช้งานได้ในเทอร์มินัล หาก macOS บล็อกหรือย้าย `codex` ไปที่ถังขยะ ให้ลบการติดตั้งที่ซ้ํากันที่เก่า แล้วเรียกใช้ `npm install -g --include=optional @openai/codex@latest` แล้วลองอีกครั้ง"; +"codex_login_output" = "เอาต์พุตการเข้าสู่ระบบ Codex:"; +"managed_login_missing_email" = "Codex เข้าสู่ระบบเสร็จสมบูรณ์ แต่ไม่มีอีเมลบัญชีผู้ใช้ ลองอีกครั้งหลังจากยืนยันว่าบัญชีลงชื่อเข้าใช้อย่างสมบูรณ์แล้ว"; +"login_success_notification_title" = "%@ เข้าสู่ระบบสําเร็จ"; +"login_success_notification_body" = "คุณสามารถกลับไปที่แอพ การรับรองความถูกต้องเสร็จสิ้น"; +"workspace_selection_cancelled" = "CodexBar พบพื้นที่ทํางานหลายพื้นที่ แต่ไม่มีการเลือกพื้นที่ทํางาน"; +"unsafe_managed_home" = "CodexBar ปฏิเสธที่จะแก้ไขเส้นทางโฮมที่มีการจัดการที่ไม่คาดคิด: %@"; +"menu_bar_metric_title" = "เมตริกแถบเมนู"; +"menu_bar_metric_subtitle" = "เลือกหน้าต่างที่จะขับเคลื่อนเปอร์เซ็นต์ของแถบเมนู"; +"menu_bar_metric_subtitle_deepseek" = "แสดงยอดคงเหลือ DeepSeek ในแถบเมนู"; +"menu_bar_metric_subtitle_moonshot" = "แสดงยอดคงเหลือ Moonshot / Kimi API ในแถบเมนู"; +"menu_bar_metric_subtitle_mistral" = "แสดงการใช้จ่าย Mistral API เดือนปัจจุบันในแถบเมนู"; +"automatic" = "อัตโนมัติ"; +"primary_api_key_limit" = "หลัก (จํากัดคีย์ API)"; + +/* Display Pane */ +"menu_bar_style_title" = "รูปแบบแถบเมนู"; +"menu_bar_style_subtitle" = "รูปแบบการแสดงรายการในแถบเมนู"; +"menu_bar_inactive_display_contrast_title" = "เพิ่มการมองเห็นบนจอแสดงผลที่ไม่ได้ใช้งาน"; +"menu_bar_usage_colors_title" = "แสดงการใช้งานด้วยสี"; +"menu_bar_usage_colors_subtitle" = "ไล่สีไอคอนบนแถบเมนูจากเขียวไปแดงเมื่อการใช้งานเพิ่มขึ้น"; +"menu_bar_inactive_display_contrast_subtitle" = "ใช้การแสดงผลแบบคอนทราสต์สูงเพื่อให้ไอคอนและค่าชี้วัดอ่านได้บนจออื่น"; +"menu_bar_style_critters" = "ตัวการ์ตูน"; +"menu_bar_style_bars" = "แถบวัด"; +"menu_bar_style_icon_percent" = "ไอคอนและเปอร์เซ็นต์"; +"switcher_rows_title" = "แถวในตัวสลับ"; +"switcher_rows_icons" = "ไอคอนผู้ให้บริการ"; +"switcher_rows_progress" = "ความคืบหน้ารายสัปดาห์"; +"usage_bars_fill_title" = "รูปแบบการเติมแถบการใช้งาน"; +"usage_bars_fill_remaining" = "ตามปริมาณคงเหลือ"; +"usage_bars_fill_used" = "ตามปริมาณที่ใช้ไป"; +"reset_times_title" = "เวลารีเซ็ต"; +"reset_times_countdown" = "นับถอยหลัง"; +"reset_times_clock" = "เวลาตามนาฬิกา"; +"cost_summary_title" = "สรุปค่าใช้จ่าย"; +"cost_summary_off" = "ปิด"; +"merge_icons_title" = "ผสานไอคอน"; +"merge_icons_subtitle" = "ใช้ไอคอนแถบเมนูเดียวกับตัวสลับผู้ให้บริการ"; +"show_most_used_provider_title" = "แสดงผู้ให้บริการที่ใช้บ่อยที่สุด"; +"show_most_used_provider_subtitle" = "แถบเมนูจะแสดงผู้ให้บริการที่ใกล้เคียงกับขีดจํากัดอัตรามากที่สุดโดยอัตโนมัติ"; +"display_mode_title" = "โหมดการแสดงผล"; +"display_mode_subtitle" = "เลือกสิ่งที่จะแสดงในแถบเมนู (อัตราก้าวแสดงการใช้งานเทียบกับที่คาดไว้)"; +"show_quota_warning_markers_title" = "แสดงเครื่องหมายเตือนโควต้า"; +"show_quota_warning_markers_subtitle" = "วาดเครื่องหมายถูกเกณฑ์บนแถบการใช้งานเมื่อมีการกําหนดค่าคําเตือนโควต้า"; +"weekly_progress_work_days_title" = "วันทํางานความคืบหน้ารายสัปดาห์"; +"weekly_progress_work_days_subtitle" = "กําหนดวันทํางานสําหรับเครื่องหมายแถบการใช้งานรายสัปดาห์และการคํานวณความเร็ว"; +"show_provider_changelog_links_title" = "แสดงลิงก์บันทึกการเปลี่ยนแปลงของผู้ให้บริการ"; +"show_provider_changelog_links_subtitle" = "เพิ่มลิงก์บันทึกประจํารุ่นสําหรับผู้ให้บริการที่ได้รับการสนับสนุน CLI ในเมนู"; +"show_credits_extra_usage_title" = "แสดงเครดิต + การใช้งานเพิ่มเติม"; +"show_credits_extra_usage_subtitle" = "แสดงส่วนเครดิต Codex และการใช้งานเพิ่มเติม Claude ในเมนู"; +"multi_account_layout_title" = "รูปแบบหลายบัญชี"; +"multi_account_layout_subtitle" = "เลือกการสลับบัญชีแบบแบ่งกลุ่มหรือบัตรบัญชีแบบเรียงซ้อน"; +"multi_account_layout_segmented" = "แบ่งกลุ่ม"; +"multi_account_layout_stacked" = "ซ้อนกัน"; +"overview_tab_providers_title" = "ผู้ให้บริการแท็บภาพรวม"; +"configure" = "กําหนดค่า..."; +"overview_enable_merge_icons_hint" = "เปิดใช้งานไอคอนผสานเพื่อกําหนดค่าผู้ให้บริการแท็บภาพรวม"; +"overview_no_providers_hint" = "ไม่มีผู้ให้บริการที่เปิดใช้งานสําหรับภาพรวม"; +"overview_rows_follow_order" = "แถวภาพรวมจะเป็นไปตามลําดับของผู้ให้บริการเสมอ"; +"overview_no_providers_selected" = "ไม่มีผู้ให้บริการที่เลือก"; +"agent_sessions_title" = "เซสชันเอเจนต์"; +"agent_sessions_subtitle" = "แสดงเซสชัน Codex และ Claude Code ที่ค้นพบในเครื่องและผ่าน SSH ในเมนู"; +"agent_sessions_hosts_title" = "โฮสต์ SSH เพิ่มเติม"; +"agent_sessions_footer" = "ระบบจะค้นหา Mac บน tailnet ของคุณโดยอัตโนมัติ เซสชันในเครื่องจะรีเฟรชทุก 30 วินาที ส่วนโฮสต์ระยะไกลจะรีเฟรชทุก 60 วินาทีและเมื่อเปิดเมนู"; +"agent_session_labels_title" = "ป้ายกำกับเซสชัน"; +"agent_session_labels_subtitle" = "เลือกวิธีตั้งชื่อเซสชันเอเจนต์"; +"agent_session_label_project" = "โปรเจ็กต์"; +"agent_session_label_descriptive" = "เชิงบรรยาย"; +"agent_session_label_descriptive_and_project" = "เชิงบรรยาย + โปรเจ็กต์"; +"agent_session_unknown_project" = "โปรเจ็กต์ที่ไม่รู้จัก"; + +/* Advanced Pane */ +"section_keyboard_shortcut" = "แป้นพิมพ์ลัด"; +"open_menu_shortcut_title" = "เปิดเมนู"; +"open_menu_shortcut_subtitle" = "ทริกเกอร์เมนูแถบเมนูได้จากทุกที่"; +"install_cli" = "ติดตั้ง CLI"; +"install_cli_subtitle" = "Symlink CodexBarCLI เป็น /usr/local/bin และ /opt/homebrew/bin เป็น codexbar"; +"cli_not_found" = "ไม่พบ CodexBarCLI ใน App Bundle"; +"no_writable_bin_dirs" = "ไม่พบ bin dirs ที่เขียนได้"; +"show_debug_settings_title" = "แสดงการตั้งค่าการดีบัก"; +"show_debug_settings_subtitle" = "แสดงเครื่องมือการแก้ไขปัญหาในแท็บ แก้ไขข้อบกพร่อง"; +"surprise_me_title" = "ทําให้ฉันประหลาดใจ"; +"surprise_me_subtitle" = "ตรวจสอบว่าคุณชอบให้ตัวแทนของคุณสนุกสนานที่นั่นหรือไม่"; +"hide_personal_info_title" = "ซ่อนข้อมูลส่วนบุคคล"; +"hide_personal_info_subtitle" = "ปิดบังที่อยู่อีเมลในแถบเมนูและ UI เมนู"; +"show_provider_storage_usage_title" = "แสดงการใช้พื้นที่เก็บข้อมูลของผู้ให้บริการ"; +"show_provider_storage_usage_subtitle" = "แสดงการใช้ดิสก์ในเครื่องในเมนู สแกนเส้นทางที่รู้จักโดยผู้ให้บริการในเบื้องหลัง"; +"section_keychain_access" = "การเข้าถึง Keychain"; +"keychain_access_caption" = "ปิดใช้งานการอ่านและเขียน Keychain ทั้งหมด ใช้ตัวเลือกนี้หาก macOS ยังคงแจ้งให้ 'Chrome/Brave/Edge ที่เก็บข้อมูลที่ปลอดภัย' แม้ว่าจะคลิกอนุญาตเสมอแล้วก็ตาม การนําเข้าคุกกี้ของเบราว์เซอร์ไม่พร้อมใช้งานในขณะที่เปิดใช้งาน วางส่วนหัวของคุกกี้ด้วยตนเองในผู้ให้บริการ Claude/Codex OAuth ผ่าน CLI ยังคงใช้งานได้"; +"disable_keychain_access_title" = "ปิดใช้งานการเข้าถึง Keychain"; +"disable_keychain_access_subtitle" = "ป้องกันการเข้าถึง Keychain ใดๆ ขณะเปิดใช้งาน"; + +/* About Pane */ +"about_tagline" = "ขอให้โทเค็นของคุณไม่มีวันหมด ให้คํานึงถึงขีดจํากัดของเจ้าหน้าที่"; +"link_github" = "GitHub"; +"link_website" = "เว็บไซต์"; +"link_twitter" = "ทวิตเตอร์"; +"link_email" = "อีเมล"; +"check_updates_auto" = "ตรวจสอบการอัปเดตโดยอัตโนมัติ"; +"update_channel" = "อัปเดตช่อง"; +"check_for_updates" = "ตรวจสอบการอัปเดต..."; +"updates_unavailable" = "การอัปเดตไม่พร้อมใช้งานในรุ่นนี้"; +"copyright" = "© 2026 ปีเตอร์ สไตน์เบอร์เกอร์ ใบอนุญาต MIT"; + +/* Debug Pane */ +"section_logging" = "การบันทึก"; +"enable_file_logging" = "เปิดใช้งานการบันทึกไฟล์"; +"enable_file_logging_subtitle" = "เขียนบันทึกไปยัง %@ เพื่อแก้ไขข้อบกพร่อง"; +"verbosity_title" = "รายละเอียด"; +"verbosity_subtitle" = "ควบคุมจํานวนรายละเอียดที่บันทึกไว้"; +"open_log_file" = "เปิดไฟล์บันทึก"; +"force_animation_next_refresh" = "บังคับให้เคลื่อนไหวในการรีเฟรชครั้งถัดไป"; +"force_animation_next_refresh_subtitle" = "แสดงภาพเคลื่อนไหวการโหลดชั่วคราวหลังจากการรีเฟรชครั้งถัดไป"; +"section_loading_animations" = "กําลังโหลดภาพเคลื่อนไหว"; +"loading_animations_caption" = "เลือกรูปแบบและเล่นซ้ําในแถบเมนู \"สุ่ม\" จะคงพฤติกรรมที่มีอยู่ไว้"; +"animation_random_default" = "สุ่ม (ค่าเริ่มต้น)"; +"replay_selected_animation" = "เล่นซ้ําภาพเคลื่อนไหวที่เลือก"; +"blink_now" = "กะพริบตาตอนนี้"; +"section_probe_logs" = "บันทึกโพรบ"; +"probe_logs_caption" = "ดึงเอาท์พุตโพรบล่าสุดสําหรับการดีบัก สําเนาเก็บข้อความฉบับเต็ม"; +"fetch_log" = "ดึงข้อมูลบันทึก"; +"copy" = "สําเนา"; +"save_to_file" = "บันทึกลงในไฟล์"; +"load_parse_dump" = "โหลดการถ่ายโอนข้อมูลแยกวิเคราะห์"; +"rerun_provider_autodetect" = "เรียกใช้การตรวจหาอัตโนมัติของผู้ให้บริการอีกครั้ง"; +"loading" = "กําลังโหลด..."; +"no_log_yet_fetch" = "ยังไม่มีบันทึก ดึงข้อมูลเพื่อโหลด"; +"section_fetch_strategy" = "ความพยายามในการดึงกลยุทธ์"; +"fetch_strategy_caption" = "การตัดสินใจและข้อผิดพลาดในการดึงข้อมูลไปป์ไลน์ล่าสุดสําหรับผู้ให้บริการ"; +"section_openai_cookies" = "คุกกี้ OpenAI"; +"openai_cookies_caption" = "การนําเข้าคุกกี้ + บันทึกการขูด WebKit จากความพยายามใช้คุกกี้ OpenAI ครั้งล่าสุด"; +"no_log_yet" = "ยังไม่มีบันทึก อัปเดตคุกกี้ OpenAI ในผู้ให้บริการ→ Codex เพื่อเรียกใช้การนําเข้า"; +"section_caches" = "แคช"; +"caches_caption" = "ล้างผลการสแกนค่าใช้จ่ายที่แคชไว้หรือแคชคุกกี้ของเบราว์เซอร์"; +"clear_cookie_cache" = "ล้างแคชคุกกี้"; +"clear_cost_cache" = "ล้างแคชต้นทุน"; +"section_notifications" = "การแจ้งเตือน"; +"notifications_caption" = "ทริกเกอร์การแจ้งเตือนการทดสอบสําหรับกรอบเวลาเซสชัน 5 ชั่วโมง (หมด /restored)"; +"post_depleted" = "โพสต์หมด"; +"post_restored" = "โพสต์ที่ได้รับการกู้คืน"; +"section_cli_sessions" = "CLI เซสชัน"; +"cli_sessions_caption" = "รักษาเซสชัน Codex/Claude CLI ให้มีชีวิตอยู่หลังจากการสอบสวน ค่าเริ่มต้นจะออกเมื่อบันทึกข้อมูลแล้ว"; +"keep_cli_sessions_alive" = "รักษาเซสชัน CLI ให้มีชีวิตอยู่"; +"keep_cli_sessions_alive_subtitle" = "ข้ามการฉีกขาดระหว่างโพรบ (ดีบักเท่านั้น)"; +"reset_cli_sessions" = "รีเซ็ตเซสชัน CLI"; +"section_error_simulation" = "การจําลองข้อผิดพลาด"; +"error_simulation_caption" = "แทรกข้อความแสดงข้อผิดพลาดปลอมลงในการ์ดเมนูสําหรับการทดสอบเลย์เอาต์"; +"set_menu_error" = "ตั้งค่าเมนูผิดพลาด"; +"clear_menu_error" = "ล้างข้อผิดพลาดของเมนู"; +"set_cost_error" = "ตั้งค่าข้อผิดพลาดต้นทุน"; +"clear_cost_error" = "ล้างข้อผิดพลาดด้านต้นทุน"; +"section_cli_paths" = "เส้นทาง CLI"; +"cli_paths_caption" = "แก้ไข Codex ชั้นไบนารีและเลเยอร์ PATH การเข้าสู่ระบบเริ่มต้น PATH จับภาพ (หมดเวลาสั้น)"; +"codex_binary" = "Codex ไบนารี"; +"claude_binary" = "Claude ไบนารี"; +"effective_path" = "PATH ที่มีประสิทธิภาพ"; +"unavailable" = "ไม่พร้อมใช้งาน"; +"login_shell_path" = "PATH เชลล์เข้าสู่ระบบ (การจับภาพการเริ่มต้น)"; +"cleared" = "เคลียร์แล้ว"; +"no_fetch_attempts" = "ยังไม่มีความพยายามในการดึงข้อมูล"; +"macOS Tahoe can block menu bar apps in System Settings → Menu Bar → Allow in the Menu Bar. CodexBar is running, but macOS may be hiding its icon. Open Menu Bar settings and turn CodexBar on." = "macOS Tahoe สามารถบล็อกแอปแถบเมนูในการตั้งค่าระบบ→แถบเมนู→อนุญาตในแถบเมนู CodexBar กําลังทํางานอยู่ แต่ macOS อาจซ่อนไอคอนไว้ เปิดการตั้งค่าแถบเมนู แล้วเปิด CodexBar"; + +/* Metric preferences */ +"metric_pref_automatic" = "อัตโนมัติ"; +"metric_pref_primary" = "ประถมศึกษา"; +"metric_pref_secondary" = "มัธยมศึกษา"; +"metric_pref_tertiary" = "ระดับอุดมศึกษา"; +"metric_pref_extra_usage" = "การใช้งานเพิ่มเติม"; +"metric_pref_average" = "ค่าเฉลี่ย"; +"metric_mistral_payg" = "Pay-as-you-go"; +"metric_mistral_monthly_plan" = "Monthly Plan"; + +/* Display modes */ +"display_mode_percent" = "เปอร์เซ็นต์"; +"display_mode_pace" = "ก้าว"; +"display_mode_both" = "ทั้งสองอย่าง"; +"display_mode_reset_time" = "รีเซ็ตเวลา"; +"display_mode_percent_desc" = "แสดงเปอร์เซ็นต์ที่เหลือ /used (เช่น 45%)"; +"display_mode_pace_desc" = "แสดงตัวบ่งชี้อัตราการก้าว (เช่น +5%)"; +"display_mode_both_desc" = "แสดงทั้งเปอร์เซ็นต์และความเร็ว (เช่น 45% · +5%)"; +"display_mode_reset_time_desc" = "แสดงเวลารีเซ็ตสําหรับเมตริกที่เลือก (เช่น ↻ 15:56 น.)"; +"menu_bar_reset_when_exhausted_title" = "แสดงเวลารีเซ็ตเมื่อโควตาหมด"; +"menu_bar_reset_when_exhausted_subtitle" = "เมื่อเหลือ 0% จะแสดงเวลาจนถึงการรีเซ็ตแทนเปอร์เซ็นต์"; + +/* Provider status */ +"status_operational" = "การดําเนินงาน"; +"status_degraded" = "ประสิทธิภาพลดลง"; +"status_partial_outage" = "ไฟฟ้าดับบางส่วน"; +"status_major_outage" = "ไฟฟ้าดับครั้งใหญ่"; +"status_critical_issue" = "ปัญหาสําคัญ"; +"status_maintenance" = "ซ่อมบํารุง"; +"status_unknown" = "ไม่ทราบสถานะ"; + +/* Refresh frequency */ +"refresh_manual" = "ด้วยมือ"; +"refresh_1min" = "1 นาที"; +"refresh_2min" = "2 นาที"; +"refresh_5min" = "5 นาที"; +"refresh_15min" = "15 นาที"; +"refresh_30min" = "30 นาที"; +"refresh_adaptive" = "ปรับอัตโนมัติ"; +"refresh_adaptive_agent_aware" = "ปรับอัตโนมัติ (รับรู้กิจกรรมเอเจนต์)"; +"adaptive_activity_consent_title" = "อนุญาตการรีเฟรชตามกิจกรรมหรือไม่"; +"adaptive_activity_consent_message" = "โหมดปรับอัตโนมัติที่รับรู้กิจกรรมเอเจนต์สามารถตรวจสอบรายการโปรเซสที่กำลังทำงานในเครื่อง รวมถึงบรรทัดคำสั่ง เพื่อระบุ Codex และ Claude จากนั้นอ่านเมตาดาตาของเซสชันที่รู้จักทุก 30 วินาทีขณะคุณเขียนโค้ด เมื่อปิด Agent Sessions CodexBar จะใช้เฉพาะเวลาของกิจกรรมล่าสุดในหน่วยความจำ และทิ้งพาธกับข้อมูลระบุตัวตนของเซสชัน ข้อมูลนี้จะไม่ถูกส่งไปที่ใด และการค้นหาระยะไกลกับ SSH จะยังคงปิดอยู่ หากคุณปฏิเสธ CodexBar จะกลับสู่โหมดปรับอัตโนมัติปกติโดยไม่สแกนกิจกรรมในเครื่อง"; +"adaptive_activity_consent_allow" = "อนุญาตกิจกรรมในเครื่อง"; +"adaptive_activity_consent_decline" = "ใช้โหมดปรับอัตโนมัติปกติ"; + +/* Additional keys */ +"not_found" = "ไม่พบ"; + +/* Cost estimation */ +"cost_estimate_hint" = "ประมาณการจากบันทึกท้องถิ่น · อาจแตกต่างจากใบเรียกเก็บเงินของคุณ"; +"codex_api_estimate_hint" = "ประมาณจากการใช้โทเค็น · ไม่ใช่ใบเรียกเก็บค่าสมัครสมาชิก"; +"cost_data_explanation" = "ค่าใช้จ่ายอาจรายงานโดยผู้ให้บริการหรือประมาณจากการใช้โทเค็นตามราคา API สาธารณะ ค่าประมาณไม่ใช่ค่าบริการสมาชิก"; +"No JetBrains IDE with AI Assistant detected. Install a JetBrains IDE and enable AI Assistant." = "ตรวจไม่พบ JetBrains IDE ที่มี AI Assistant ติดตั้ง JetBrains IDE และเปิดใช้งาน AI Assistant"; +"OpenRouter API token not configured. Set OPENROUTER_API_KEY environment variable or configure in Settings." = "โทเค็น OpenRouter API ไม่ได้กําหนดค่า ตั้งค่าตัวแปรสภาพแวดล้อม OPENROUTER_API_KEY หรือกําหนดค่าในการตั้งค่า"; +"z.ai API token not found. Set apiKey in ~/.codexbar/config.json or Z_AI_API_KEY." = "ไม่พบโทเค็น z.ai API ตั้งค่า apiKey เป็น ~/.codexbar/config.json หรือ Z_AI_API_KEY"; +"Missing DeepSeek API key." = "ไม่มีคีย์ DeepSeek API"; +"%@ is unavailable in the current environment." = "%@ ไม่พร้อมใช้งานในสภาพแวดล้อมปัจจุบัน"; +"All Systems Operational" = "ทุกระบบทํางาน"; +"Last 30 days" = "30 วันที่ผ่านมา"; +"Last 30 days:" = "30 วันที่ผ่านมา:"; +"This month" = "เดือนนี้"; +"Store multiple OpenAI API keys." = "จัดเก็บปุ่ม OpenAI API หลายปุ่ม"; +"Admin API key" = "คีย์ API ของผู้ดูแลระบบ"; +"Open billing" = "การเรียกเก็บเงินแบบเปิด"; +"Google accounts" = "บัญชี Google"; +"Store multiple Antigravity Google OAuth accounts for quick switching." = "จัดเก็บบัญชี Antigravity Google OAuth หลายบัญชีเพื่อการสลับอย่างรวดเร็ว"; +"Add Google Account" = "เพิ่มบัญชี Google"; +"Open Token Plan" = "เปิดแผนโทเค็น"; +"Text Generation" = "การสร้างข้อความ"; +"Text to Speech" = "ข้อความเป็นคําพูด"; +"Music Generation" = "การสร้างเพลง"; +"Image Generation" = "การสร้างภาพ"; +"No local data found" = "ไม่พบข้อมูลในเครื่อง"; +"Credits unavailable; keep Codex running to refresh." = "ไม่มีเครดิต Codex ทํางานต่อไปเพื่อรีเฟรช"; +"No available fetch strategy for minimax." = "ไม่มีกลยุทธ์การดึงข้อมูลสําหรับ minimax"; +"No Cursor session found. Please log in to cursor.com in Safari, Chrome, Microsoft Edge, Brave, Arc, Dia, ChatGPT Atlas, Chromium, Helium, Vivaldi, Yandex Browser, Firefox, Zen, Colibri, Sidekick, Opera, Opera GX, or Edge Canary. If you use Safari, grant CodexBar Full Disk Access in System Settings ▸ Privacy & Security. You can also sign in to Cursor from the CodexBar menu (Add / switch account)." = "ไม่พบเซสชัน Cursor โปรดเข้าสู่ระบบ cursor.com ใน Safari, Chrome, Microsoft Edge, Brave, Arc, Dia, ChatGPT Atlas, Chromium, Helium, Vivaldi, Yandex Browser, Firefox, Zen, Colibri, Sidekick, Opera, Opera GX หรือ Edge Canary หากคุณใช้ Safari ให้สิทธิ์การเข้าถึงดิสก์แบบเต็ม CodexBar ใน การตั้งค่าระบบ ▸ ความเป็นส่วนตัวและความปลอดภัย คุณยังสามารถลงชื่อเข้าใช้ Cursor ได้จากเมนู CodexBar (เพิ่ม / สลับบัญชี)"; +"No OpenCode session cookies found in browsers." = "ไม่พบคุกกี้เซสชัน OpenCode ในเบราว์เซอร์"; +"No available fetch strategy for %@." = "ไม่มีกลยุทธ์การดึงข้อมูลสําหรับ %@"; +"Today" = "วันนี้"; +"Today tokens" = "โทเค็นวันนี้"; +"30d cost" = "ค่าใช้จ่าย 30d"; +"%@ cost" = "ค่าใช้จ่าย %@"; +"30d tokens" = "โทเค็น 30d"; +"Latest tokens" = "โทเค็นล่าสุด"; +"Top model" = "รุ่นยอดนิยม"; +"Storage" = "ค่าเช่าคลัง"; +"Add Account..." = "เพิ่มบัญชี..."; +"Usage Dashboard" = "แดชบอร์ดการใช้งาน"; +"Status Page" = "หน้าสถานะ"; +"Open Status Page" = "เปิดหน้าสถานะ"; +"Settings..." = "การตั้งค่า..."; +"About CodexBar" = "เกี่ยวกับ CodexBar"; +"Quit" = "ออก"; +"Last %d day" = "%d วันที่ผ่านมา"; +"Last %d days" = "%d วันที่ผ่านมา"; +"%@ tokens" = "โทเค็น %@"; +"Latest billing day" = "วันเรียกเก็บเงินล่าสุด"; +"Latest billing day (%@)" = "วันเรียกเก็บเงินล่าสุด (%@)"; +"%@ left" = "เหลือ %@"; +"Resets %@" = "รีเซ็ต %@"; +"Resets in %@" = "รีเซ็ตใน %@"; +"Resets now" = "รีเซ็ตเดี๋ยวนี้"; +"reset_tomorrow_format" = "พรุ่งนี้ %@"; +"Lasts until reset" = "คงอยู่จนกว่าจะรีเซ็ต"; +"1.5× headroom" = "เผื่อ 1.5×"; +"Updated %@" = "อัพเดท %@"; +"Updated relative %@" = "อัพเดท %@"; +"Updated absolute %@" = "อัพเดท %@"; +"Updated %@h ago" = "อัพเดท %@h ที่ผ่านมา"; +"Updated %@m ago" = "อัพเดท %@m ที่ผ่านมา"; +"Updated just now" = "อัปเดตเมื่อเร็ว ๆ นี้"; +"Projected empty in %@" = "คาดการณ์ว่างเปล่าเป็น %@"; +"Runs out in %@" = "หมดใน %@"; +"Pace: %@" = "ก้าว: %@"; +"Pace: %@ · %@" = "เพซ: %@ · %@"; +"%@ · %@" = "%@ · %@"; +"≈ %d%% run-out risk" = "≈ %d%% ความเสี่ยงในการหมด"; +"%d%% in deficit" = "%d%% ขาดดุล"; +"%d%% in reserve" = "%d%% สํารอง"; +"usage_percent_suffix_left" = "คงเหลือ"; +"usage_percent_suffix_used" = "ใช้แล้ว"; +"Store multiple DeepSeek API keys." = "จัดเก็บปุ่ม DeepSeek API หลายปุ่ม"; +"This week" = "สัปดาห์นี้"; +"Week" = "สัปดาห์"; +"Month" = "เดือน"; +"Models" = "โมเดล"; +"24h tokens" = "โทเค็น 24h"; +"Latest hour" = "ชั่วโมงล่าสุด"; +"Peak hour" = "ชั่วโมงเร่งด่วน"; +"Top method" = "วิธียอดนิยม"; +"30d cash" = "30d เงินสด"; +"30d billing history from MiniMax web session" = "30d ประวัติการเรียกเก็บเงินจากเซสชันเว็บ MiniMax"; +"AWS Cost Explorer billing can lag." = "AWS การเรียกเก็บเงิน Cost Explorer อาจล่าช้า"; +"Rate limit: %d / %@" = "จํากัดอัตรา: %d / %@"; +"Key remaining" = "กุญแจที่เหลืออยู่"; +"No limit set for the API key" = "ไม่มีขีดจํากัดสําหรับปุ่ม API"; +"API key limit unavailable right now" = "ขีดจํากัดคีย์ API ไม่พร้อมใช้งานในขณะนี้"; +"This month: %@ tokens" = "เดือนนี้: %@ โทเค็น"; +"No utilization data yet." = "ยังไม่มีข้อมูลการใช้งาน"; +"No %@ utilization data yet." = "ยังไม่มีข้อมูลการใช้ %@"; +"%@: %@%% used" = "%@: %@%% ใช้"; +"%dd" = "%d วัน"; +"today" = "วันนี้"; +"just now" = "เพิ่ง"; +"On pace" = "ก้าวไปข้างหน้า"; +"Runs out now" = "หมดแล้ว"; +"Projected empty now" = "คาดการณ์ว่างเปล่าในขณะนี้"; +"Switch Account..." = "สลับบัญชี..."; +"Update ready, restart now?" = "อัปเดตพร้อมแล้วรีสตาร์ททันทีใช่ไหม"; +"Daily" = "รายวัน"; +"Hourly Tokens" = "โทเค็นรายชั่วโมง"; +"No data" = "ไม่มีข้อมูล"; +"No usage breakdown data available." = "ไม่มีข้อมูลรายละเอียดการใช้งาน"; + +"Today: %@ · %@ tokens" = "วันนี้: %@ · 2 min · 2 min · ฟาร์ฮาน โทเค็น %@"; +"Today: %@" = "วันนี้: %@"; +"Today: %@ tokens" = "วันนี้: โทเค็น %@"; +"Last 30 days: %@ · %@ tokens" = "30 วันที่ผ่านมา: %@ · 2 min · โทเค็น %@"; +"Last 30 days: %@" = "30 วันที่ผ่านมา: %@"; +"Est. total (30d): %@" = "รวมโดยประมาณ (30d): %@"; +"Est. total (%@): %@" = "รวมโดยประมาณ (%@): %@"; +"Hover a bar for details" = "วางเมาส์เหนือแถบเพื่อดูรายละเอียด"; +"%@: %@ · %@ tokens" = "%@: %@ · โทเค็น %@"; +"%@: %@" = "%@: %@"; +"No providers selected for Overview." = "ไม่มีผู้ให้บริการที่เลือกสําหรับภาพรวม"; +"No overview data available." = "ไม่มีข้อมูลภาพรวม"; +"Auto uses the local IDE API first, then Google OAuth when the IDE is closed." = "อัตโนมัติจะใช้ IDE API ภายในเครื่องก่อน จากนั้นจึง Google OAuth เมื่อปิด IDE"; +"Login with Google" = "เข้าสู่ระบบด้วย Google"; + +/* Popup panels */ +"No usage configured." = "ไม่มีการกําหนดค่าการใช้งาน"; +"Quota" = "โควต้า"; +"Daily quota" = "โควต้ารายวัน"; +"Total" = "รวม"; +"tokens" = "โทเค็น"; +"requests" = "คําขอ"; +"Latest" = "ล่าสุด"; +"Monthly" = "รายเดือน"; +"Sonnet" = "โคลง"; +"Overages" = "ส่วนเกิน"; +"Activity" = "กิจกรรม"; +"Copied" = "คัดลอกแล้ว"; +"Copy error" = "ข้อผิดพลาดในการคัดลอก"; +"Copy path" = "คัดลอกเส้นทาง"; +"Extra usage spent" = "การใช้จ่ายเพิ่มเติม"; +"Credits remaining" = "เครดิตที่เหลืออยู่"; +"Using CLI fallback" = "การใช้ CLI สํารอง"; +"Balance updates in near-real time (up to 5 min lag)" = "อัปเดตเครื่องชั่งแบบเกือบเรียลไทม์ (หน่วงสูงสุด 5 นาที)"; +"Daily billing data finalizes at 07:00 UTC" = "ข้อมูลการเรียกเก็บเงินรายวันจะสรุปเวลา 07:00 น. UTC"; +"%@ of %@ credits left" = "เหลือ %@ จาก %@ หน่วยกิต"; +"%@ of %@ bonus credits left" = "%@ จาก %@ เครดิตโบนัสที่เหลืออยู่"; +"%@ / %@ (%@ remaining)" = "%@ / %@ (เหลือ %@)"; +"%@/%@ left" = "เหลือ %@/%@"; +"Gemini Flash" = "แฟลช Gemini"; +"Regenerates %@" = "ฟื้นฟู %@"; +"used after next regen" = "ใช้หลังจากการฟื้นฟูครั้งต่อไป"; +"after next regen" = "หลังจากการฟื้นฟูครั้งต่อไป"; +"Near full" = "ใกล้เต็ม"; +"Full in ~1 regen" = "เต็มที่ ~1 รีเจน"; +"Full in ~%.0f regens" = "เต็มไปด้วยการฟื้นฟู ~%.0f ครั้ง"; +"Overage usage" = "การใช้งานส่วนเกิน"; +"Overage cost" = "ค่าใช้จ่ายส่วนเกิน"; +"credits" = "เครดิต"; +"Zen balance" = "ความสมดุลแบบเซน"; +"API spend" = "API ใช้จ่าย"; +"Extra usage" = "การใช้งานเพิ่มเติม"; +"Quota usage" = "การใช้โควต้า"; +"Your spend" = "การใช้จ่ายของคุณ"; +"%.0f%% used" = "%.0f%% ใช้แล้ว"; +"Usage history (today)" = "ประวัติการใช้งาน (วันนี้)"; +"Usage history (%d days)" = "ประวัติการใช้งาน (%d วัน)"; +"%d percent remaining" = "เหลือ %d เปอร์เซ็นต์"; +"Unknown" = "ไม่ทราบ"; +"stale data" = "ข้อมูลเก่า"; +"No credits history data." = "ไม่มีข้อมูลประวัติเครดิต"; +"No credits history data available." = "ไม่มีข้อมูลประวัติเครดิต"; +"Credits history chart" = "แผนภูมิประวัติเครดิต"; +"%d days of credits data" = "ข้อมูลเครดิต %d วัน"; +"Usage breakdown chart" = "แผนภูมิการแจกแจงการใช้งาน"; +"%d days of usage data across %d services" = "ข้อมูลการใช้งาน %d วันในบริการ %d"; +"Cost history chart" = "แผนภูมิประวัติต้นทุน"; +"%d days of cost data" = "ข้อมูลค่าใช้จ่าย %d วัน"; +"Plan utilization chart" = "แผนการใช้แผน"; +"%d utilization samples" = "ตัวอย่างการใช้ประโยชน์ %d"; +"Hourly Usage" = "การใช้งานรายชั่วโมง"; +"Usage remaining" = "การใช้งานที่เหลืออยู่"; +"Usage used" = "การใช้งานที่ใช้"; +"API key verified. Cloud quotas need browser cookies. Sign in to Ollama." = "ยืนยันคีย์ API แล้ว โควตา Cloud ต้องใช้คุกกี้ของเบราว์เซอร์ ลงชื่อเข้าใช้ Ollama"; +"Last 30 days: %@ tokens" = "30 วันที่ผ่านมา: %@ โทเค็น"; +"7d spend" = "7d ใช้จ่าย"; +"30d spend" = "30d ใช้จ่าย"; +"Cache read" = "การอ่านแคช"; +"Claude Admin API 30 day spend trend" = "Claude Admin API เทรนด์การใช้จ่าย 30 วัน"; +"OpenRouter API key spend trend" = "OpenRouter API แนวโน้มการใช้จ่ายหลัก"; +"z.ai hourly token trend" = "z.ai แนวโน้มโทเค็นรายชั่วโมง"; +"MiniMax 30 day token usage trend" = "MiniMax แนวโน้มการใช้โทเค็น 30 วัน"; +"Today cash" = "เงินสดวันนี้"; +"DeepSeek 30 day token usage trend" = "DeepSeek แนวโน้มการใช้โทเค็น 30 วัน"; +"Detailed usage unavailable." = "ไม่มีข้อมูลการใช้งานโดยละเอียด"; +"Sign in to DeepSeek Platform in Chrome for detailed usage." = "ลงชื่อเข้าใช้ DeepSeek Platform ใน Chrome เพื่อดูรายละเอียดการใช้งาน"; +"Select a DeepSeek Chrome profile in Settings." = "เลือกโปรไฟล์ Chrome ของ DeepSeek ในการตั้งค่า"; +"DeepSeek this month token usage trend" = "แนวโน้มการใช้โทเค็น DeepSeek เดือนนี้"; +"Chrome profile" = "โปรไฟล์ Chrome"; +"Choose which signed-in DeepSeek Platform session supplies detailed usage." = "เลือกเซสชัน DeepSeek Platform ที่ลงชื่อเข้าใช้เพื่อแสดงรายละเอียดการใช้งาน"; +"Select profile…" = "เลือกโปรไฟล์…"; +"cache-hit input" = "อินพุต cache-hit"; +"cache-miss input" = "อินพุตแคชพลาด"; +"output" = "เอาท์พุท"; +"Requests" = "คําขอ"; +"Reported by OpenAI Admin API organization usage." = "รายงานโดยผู้ดูแลระบบ OpenAI API การใช้งานองค์กร"; +"Reported by Mistral billing usage." = "รายงานโดย Mistral การเรียกเก็บเงิน"; +"Google OAuth" = "Google OAuth"; +"Add accounts via GitHub OAuth Device Flow on the selected host." = "เพิ่มบัญชีผ่าน GitHub OAuth Device Flow บนโฮสต์ที่เลือก"; +"Stores each signed-in Google account for quick Antigravity switching. Uses Antigravity.app OAuth when available, or ANTIGRAVITY_OAUTH_CLIENT_ID and ANTIGRAVITY_OAUTH_CLIENT_SECRET as an override." = "จัดเก็บบัญชี Google ที่ลงชื่อเข้าใช้แต่ละบัญชีเพื่อการสลับ Antigravity อย่างรวดเร็ว ใช้ Antigravity.app OAuth เมื่อพร้อมใช้งาน หรือ ANTIGRAVITY_OAUTH_CLIENT_ID และ ANTIGRAVITY_OAUTH_CLIENT_SECRET เป็นการแทนที่"; +"Manual cleanup: past sessions" = "การล้างข้อมูลด้วยตนเอง: เซสชันที่ผ่านมา"; +"Clearing removes past resume, continue, and rewind history." = "การล้างข้อมูลจะลบประวัติการทํางานต่อ ดําเนินการต่อ และย้อนกลับในอดีต"; +"Manual cleanup: file checkpoints" = "การล้างข้อมูลด้วยตนเอง: จุดตรวจสอบไฟล์"; +"Clearing removes checkpoint restore data for previous edits." = "การล้างจะลบข้อมูลการคืนค่าจุดตรวจสําหรับการแก้ไขก่อนหน้านี้"; +"Manual cleanup: saved plans" = "การล้างข้อมูลด้วยตนเอง: แผนที่บันทึกไว้"; +"Clearing removes old plan-mode files." = "การล้างจะลบไฟล์โหมดแผนเก่า"; +"Manual cleanup: debug logs" = "การล้างข้อมูลด้วยตนเอง: บันทึกการดีบัก"; +"Clearing removes past debug logs." = "การล้างจะลบบันทึกการแก้ไขข้อบกพร่องที่ผ่านมา"; +"Manual cleanup: attachment cache" = "การล้างข้อมูลด้วยตนเอง: แคชไฟล์แนบ"; +"Clearing removes cached large pastes or attached images." = "การล้างจะลบแปะขนาดใหญ่ที่แคชไว้หรือรูปภาพที่แนบมา"; +"Manual cleanup: session metadata" = "การล้างข้อมูลด้วยตนเอง: ข้อมูลเมตาของเซสชัน"; +"Clearing removes per-session environment metadata." = "การล้างข้อมูลจะลบข้อมูลเมตาของสภาพแวดล้อมต่อเซสชัน"; +"Manual cleanup: shell snapshots" = "การล้างข้อมูลด้วยตนเอง: สแนปช็อตเปลือกหอย"; +"Clearing removes leftover runtime shell snapshot files." = "การล้างจะลบไฟล์สแนปช็อตเชลล์รันไทม์ที่เหลืออยู่"; +"Manual cleanup: legacy todos" = "การล้างข้อมูลด้วยตนเอง: สิ่งที่ต้องทําแบบเดิม"; +"Clearing removes legacy per-session task lists." = "การล้างจะลบรายการงานต่อเซสชันเดิม"; +"Manual cleanup: sessions" = "การล้างข้อมูลด้วยตนเอง: เซสชัน"; +"Clearing removes past Codex session history." = "การล้างจะลบประวัติเซสชัน Codex ที่ผ่านมา"; +"Manual cleanup: archived sessions" = "การล้างข้อมูลด้วยตนเอง: เซสชันที่เก็บถาวร"; +"Clearing removes archived Codex session history." = "การล้างข้อมูลจะลบประวัติเซสชัน Codex ที่เก็บถาวรออก"; +"Manual cleanup: cache" = "การล้างข้อมูลด้วยตนเอง: แคช"; +"Clearing removes provider-owned cached data." = "การล้างข้อมูลจะลบข้อมูลแคชที่ผู้ให้บริการเป็นเจ้าของ"; +"Manual cleanup: logs" = "การล้างข้อมูลด้วยตนเอง: บันทึก"; +"Clearing removes local diagnostic logs." = "การล้างข้อมูลจะลบบันทึกการวินิจฉัยในเครื่อง"; +"Manual cleanup: file history" = "การล้างข้อมูลด้วยตนเอง: ประวัติไฟล์"; +"Clearing removes local edit checkpoint history." = "การล้างจะลบประวัติจุดตรวจสอบการแก้ไขในเครื่อง"; +"Manual cleanup: temporary data" = "การล้างข้อมูลด้วยตนเอง: ข้อมูลชั่วคราว"; +"Clearing removes local temporary provider data." = "การหักล้างจะลบข้อมูลผู้ให้บริการชั่วคราวในเครื่อง"; +"Total: %@" = "ทั้งหมด: %@"; +"%d more items" = "%d รายการเพิ่มเติม"; +"Other (%d items)" = "อื่น ๆ (%d รายการ)"; +"Expand" = "ขยาย"; +"Collapse" = "ยุบ"; +"Cleanup ideas" = "ไอเดียการล้างข้อมูล"; +"%d unreadable item(s) skipped" = "ข้ามรายการที่อ่านไม่ได้ %d รายการ"; + +"API key limit" = "ขีดจํากัด API คีย์"; +"Auth" = "รับรองความถูกต้อง"; +"Auto" = "อัตโนมัติ"; +"Disabled — no recent data" = "ปิดใช้งาน — ไม่มีข้อมูลล่าสุด"; +"Limits not available" = "ไม่มีขีดจํากัด"; +"No usage yet" = "ยังไม่มีการใช้งาน"; +"Not fetched yet" = "ยังไม่ได้ดึงข้อมูล"; +"Refreshing" = "สดชื่น"; +"Session" = "เซสชั่น"; +"Source" = "แหล่งที่มา"; +"State" = "สถานะ"; +"Unavailable" = "ไม่พร้อมใช้งาน"; +"Weekly" = "รายสัปดาห์"; +"not detected" = "ตรวจไม่พบ"; +"Estimated from local Codex logs for the selected account." = "ประมาณการจากบันทึก Codex ท้องถิ่นสําหรับบัญชีที่เลือก"; +"minimax_usage_amount_format" = "การใช้งาน: %@ / %@"; +"minimax_used_percent_format" = "ใช้ไป %@"; +"minimax_service_text_generation" = "การสร้างข้อความ"; +"minimax_service_text_to_speech" = "ข้อความเป็นคําพูด"; +"minimax_service_music_generation" = "การสร้างเพลง"; +"minimax_service_image_generation" = "การสร้างภาพ"; +"minimax_service_lyrics_generation" = "การสร้างเนื้อเพลง"; +"minimax_service_coding_plan_vlm" = "VLM แผนการเข้ารหัส"; +"minimax_service_coding_plan_search" = "การค้นหาแผนการเข้ารหัส"; + +/* Additional provider settings and alerts */ +"%@ is waiting for permission" = "%@ กําลังรอการอนุญาต"; +"%@ requests" = "คําขอ %@"; +"%@: %@ credits" = "%@: %@ หน่วยกิต"; +"30d requests" = "คําขอ 30d"; +"4 days" = "4 วัน"; +"5 days" = "5 วัน"; +"7 days" = "7 วัน"; +"API key verifies Ollama Cloud access; cookies still expose quota limits." = "คีย์ API จะยืนยันการเข้าถึงระบบคลาวด์ Ollama คุกกี้ยังคงเปิดเผยขีดจํากัดโควต้า"; +"AWS access key ID. Can also be set with AWS_ACCESS_KEY_ID." = "AWS รหัสคีย์การเข้าถึง สามารถตั้งค่าด้วย AWS_ACCESS_KEY_ID"; +"AWS region. Can also be set with AWS_REGION." = "AWS ภูมิภาค สามารถตั้งค่าด้วย AWS_REGION"; +"AWS secret access key. Can also be set with AWS_SECRET_ACCESS_KEY." = "AWS คีย์การเข้าถึงลับ สามารถตั้งค่าด้วย AWS_SECRET_ACCESS_KEY"; +"Access key ID" = "รหัสคีย์การเข้าถึง"; +"Add Account" = "เพิ่มบัญชี"; +"Adding Account…" = "การเพิ่มบัญชี..."; +"Antigravity login failed" = "การเข้าสู่ระบบ Antigravity ล้มเหลว"; +"Antigravity login timed out" = "Antigravity เข้าสู่ระบบหมดเวลา"; +"Auth source" = "แหล่งที่มาของการตรวจสอบสิทธิ์"; +"Automatic imports browser cookies from Xiaomi MiMo." = "นําเข้าคุกกี้เบราว์เซอร์โดยอัตโนมัติจาก Xiaomi MiMo"; +"Automatic imports Windsurf session data from Chromium browser localStorage." = "นําเข้าข้อมูลเซสชัน Windsurf โดยอัตโนมัติจากเบราว์เซอร์ Chromium localStorage"; +"Automatic imports browser cookies from Bailian." = "นําเข้าคุกกี้เบราว์เซอร์จาก Bailian โดยอัตโนมัติ"; +"Automatically imports browser cookies." = "นําเข้าคุกกี้ของเบราว์เซอร์โดยอัตโนมัติ"; +"Automatically imports browser session cookies." = "นําเข้าคุกกี้เซสชันเบราว์เซอร์โดยอัตโนมัติ"; +"Azure OpenAI deployment name. AZURE_OPENAI_DEPLOYMENT_NAME is also supported." = "Azure OpenAI ชื่อการปรับใช้ นอกจากนี้ยังรองรับ AZURE_OPENAI_DEPLOYMENT_NAME"; +"Azure OpenAI key" = "ปุ่ม Azure OpenAI"; +"Azure OpenAI resource endpoint. AZURE_OPENAI_ENDPOINT is also supported." = "Azure OpenAI ตําแหน่งข้อมูลทรัพยากร นอกจากนี้ยังรองรับ AZURE_OPENAI_ENDPOINT"; +"Base URL" = "ฐาน URL"; +"Base URL for the LLM-API-Key-Proxy instance." = "URL พื้นฐานสําหรับอินสแตนซ์ LLM-API-Key-Proxy"; +"Browser cookies" = "คุกกี้เบราว์เซอร์"; +"Cap end" = "ปลายฝา"; +"Cap start" = "เริ่มต้นสูงสุด"; +"Capacity End" = "สิ้นสุดความจุ"; +"Capacity Start" = "ความจุเริ่มต้น"; +"Changelog" = "บันทึกการเปลี่ยนแปลง"; +"Choose the Moonshot/Kimi API host for international or China mainland accounts." = "เลือกโฮสต์ Moonshot/Kimi API สําหรับบัญชีระหว่างประเทศหรือจีนแผ่นดินใหญ่"; +"CodexBar can't replace a system account that is signed in with an API key only setup." = "CodexBar ไม่สามารถแทนที่บัญชีระบบที่ลงชื่อเข้าใช้ด้วยการตั้งค่าคีย์ API เท่านั้น"; +"CodexBar could not find saved auth for that account. Re-authenticate it and try again." = "CodexBar ไม่พบการตรวจสอบสิทธิ์ที่บันทึกไว้สําหรับบัญชีนั้น ตรวจสอบสิทธิ์อีกครั้งแล้วลองอีกครั้ง"; +"CodexBar could not read managed account storage. Recover the store before adding another account." = "CodexBar อ่านพื้นที่เก็บข้อมูลของบัญชีที่มีการจัดการไม่ได้ กู้คืนร้านค้าก่อนเพิ่มบัญชีอื่น"; +"CodexBar could not read saved auth for that account. Re-authenticate it and try again." = "CodexBar ไม่สามารถอ่านการตรวจสอบสิทธิ์ที่บันทึกไว้สําหรับบัญชีนั้นได้ ตรวจสอบสิทธิ์อีกครั้งแล้วลองอีกครั้ง"; +"CodexBar could not read the current system account on this Mac." = "CodexBar ไม่สามารถอ่านบัญชีระบบปัจจุบันบน Mac เครื่องนี้ได้"; +"CodexBar could not replace the live Codex auth on this Mac." = "CodexBar ไม่สามารถแทนที่การตรวจสอบสิทธิ์ Codex แบบสดบน Mac เครื่องนี้ได้"; +"CodexBar could not safely preserve the current system account before switching." = "CodexBar ไม่สามารถรักษาบัญชีระบบปัจจุบันได้อย่างปลอดภัยก่อนเปลี่ยน"; +"CodexBar could not save the current system account before switching." = "CodexBar ไม่สามารถบันทึกบัญชีระบบปัจจุบันก่อนที่จะเปลี่ยน"; +"CodexBar could not update managed account storage." = "CodexBar อัปเดตพื้นที่เก็บข้อมูลของบัญชีที่มีการจัดการไม่ได้"; +"CodexBar found another managed account that already uses the current system account. Resolve the duplicate account before switching." = "CodexBar พบบัญชีที่จัดการอื่นที่ใช้บัญชีระบบปัจจุบันอยู่แล้ว แก้ไขบัญชีที่ซ้ํากันก่อนเปลี่ยน"; +"CodexBar will ask macOS Keychain for “%@” so it can decrypt browser cookies and authenticate your account. Click OK to continue." = "CodexBar จะขอ \"%@\" จาก macOS Keychain เพื่อให้สามารถถอดรหัสคุกกี้ของเบราว์เซอร์และตรวจสอบบัญชีของคุณได้ คลิก ตกลง เพื่อดําเนินการต่อ"; +"CodexBar will ask macOS Keychain for the Claude Code OAuth token so it can fetch your Claude usage. Click OK to continue." = "CodexBar จะขอโทเค็น OAuth Claude Code จาก macOS Keychain เพื่อให้สามารถดึงข้อมูลการใช้งาน Claude ของคุณได้ คลิก ตกลง เพื่อดําเนินการต่อ"; +"CodexBar will ask macOS Keychain for your Amp cookie header so it can fetch usage. Click OK to continue." = "CodexBar จะขอส่วนหัวคุกกี้ Amp macOS Keychain ของคุณเพื่อให้สามารถดึงข้อมูลการใช้งานได้ คลิก ตกลง เพื่อดําเนินการต่อ"; +"CodexBar will ask macOS Keychain for your Augment cookie header so it can fetch usage. Click OK to continue." = "CodexBar จะขอส่วนหัวคุกกี้ Augment macOS Keychain ของคุณเพื่อให้สามารถดึงข้อมูลการใช้งานได้ คลิก ตกลง เพื่อดําเนินการต่อ"; +"CodexBar will ask macOS Keychain for your Claude cookie header so it can fetch Claude web usage. Click OK to continue." = "CodexBar จะขอส่วนหัวคุกกี้ Claude macOS Keychain ของคุณเพื่อให้สามารถดึงข้อมูลการใช้งานเว็บ Claude ได้ คลิก ตกลง เพื่อดําเนินการต่อ"; +"CodexBar will ask macOS Keychain for your Cursor cookie header so it can fetch usage. Click OK to continue." = "CodexBar จะขอส่วนหัวคุกกี้ Cursor macOS Keychain ของคุณเพื่อให้สามารถดึงข้อมูลการใช้งานได้ คลิก ตกลง เพื่อดําเนินการต่อ"; +"CodexBar will ask macOS Keychain for your Factory cookie header so it can fetch usage. Click OK to continue." = "CodexBar จะขอส่วนหัวคุกกี้ Factory macOS Keychain ของคุณเพื่อให้สามารถดึงข้อมูลการใช้งานได้ คลิก ตกลง เพื่อดําเนินการต่อ"; +"CodexBar will ask macOS Keychain for your GitHub Copilot token so it can fetch usage. Click OK to continue." = "CodexBar จะขอโทเค็น GitHub Copilot ของคุณจาก macOS Keychain เพื่อให้สามารถดึงข้อมูลการใช้งานได้ คลิก ตกลง เพื่อดําเนินการต่อ"; +"CodexBar will ask macOS Keychain for your Kimi auth token so it can fetch usage. Click OK to continue." = "CodexBar จะขอโทเค็นการรับรองความถูกต้อง Kimi macOS Keychain เพื่อให้สามารถดึงการใช้งานได้ คลิก ตกลง เพื่อดําเนินการต่อ"; +"CodexBar will ask macOS Keychain for your MiniMax API token so it can fetch usage. Click OK to continue." = "CodexBar จะขอโทเค็น MiniMax API ของคุณจาก macOS Keychain เพื่อให้สามารถดึงข้อมูลการใช้งานได้ คลิก ตกลง เพื่อดําเนินการต่อ"; +"CodexBar will ask macOS Keychain for your MiniMax cookie header so it can fetch usage. Click OK to continue." = "CodexBar จะขอส่วนหัวคุกกี้ MiniMax macOS Keychain ของคุณเพื่อให้สามารถดึงข้อมูลการใช้งานได้ คลิก ตกลง เพื่อดําเนินการต่อ"; +"CodexBar will ask macOS Keychain for your OpenAI cookie header so it can fetch Codex dashboard extras. Click OK to continue." = "CodexBar จะขอให้ macOS Keychain ป้อนส่วนหัวของคุกกี้ OpenAI ของคุณเพื่อให้สามารถดึงข้อมูลพิเศษ Codex แดชบอร์ดได้ คลิก ตกลง เพื่อดําเนินการต่อ"; +"CodexBar will ask macOS Keychain for your OpenCode cookie header so it can fetch usage. Click OK to continue." = "CodexBar จะขอส่วนหัวคุกกี้ OpenCode macOS Keychain ของคุณเพื่อให้สามารถดึงข้อมูลการใช้งานได้ คลิก ตกลง เพื่อดําเนินการต่อ"; +"CodexBar will ask macOS Keychain for your Synthetic API key so it can fetch usage. Click OK to continue." = "CodexBar จะขอคีย์ Synthetic API macOS Keychain เพื่อให้สามารถดึงข้อมูลการใช้งานได้ คลิก ตกลง เพื่อดําเนินการต่อ"; +"CodexBar will ask macOS Keychain for your z.ai API token so it can fetch usage. Click OK to continue." = "CodexBar จะขอโทเค็น z.ai API ของคุณจาก macOS Keychain เพื่อให้สามารถดึงข้อมูลการใช้งานได้ คลิก ตกลง เพื่อดําเนินการต่อ"; +"Could not open Cursor login in your browser." = "ไม่สามารถเปิด Cursor เข้าสู่ระบบในเบราว์เซอร์ของคุณ"; +"Could not open browser for Antigravity" = "ไม่สามารถเปิดเบราว์เซอร์สําหรับ Antigravity"; +"Credits used" = "เครดิตที่ใช้"; +"Day" = "วัน"; +"Deployment" = "การปรับใช้"; +"Drag to reorder" = "ลากเพื่อจัดลําดับใหม่"; +"Sort providers alphabetically" = "เรียงผู้ให้บริการตามตัวอักษร"; +"Sort providers alphabetically (enabled first)" = "เรียงผู้ให้บริการตามตัวอักษร (ที่เปิดใช้ก่อน)"; +"Sorted alphabetically (enabled first) — click to use your custom order" = "เรียงตามตัวอักษรแล้ว (ที่เปิดใช้ก่อน) — คลิกเพื่อใช้ลำดับที่กำหนดเอง"; +"Endpoint" = "ปลายทาง"; +"Enterprise host" = "โฮสต์องค์กร"; +"Extra usage balance: %@" = "ยอดการใช้งานเพิ่มเติม: %@"; +"Keychain Access Required" = "Keychain ต้องเข้าถึง"; +"keychain_prompt_learn_more" = "ดูเพิ่มเติม…"; +"keychain_prompt_privacy_note" = "macOS เป็นผู้จัดการการป้อนรหัสผ่านเข้าสู่ระบบ Mac ไม่ใช่ CodexBar คุณปิดการเข้าถึง Keychain ได้ทุกเมื่อใน การตั้งค่า → ขั้นสูง"; +"Kiro menu bar value" = "ค่าแถบเมนู Kiro"; +"Label" = "ฉลาก"; +"No organizations loaded. Click Refresh after setting your API key." = "ไม่มีองค์กรโหลด คลิกรีเฟรชหลังจากตั้งค่าปุ่ม API"; +"No output captured." = "ไม่มีการบันทึกเอาต์พุต"; +"No system account" = "ไม่มีบัญชีระบบ"; +"Oasis-Token" = "โอเอซิส-โทเค็น"; +"Open Augment (Log Out & Back In)" = "เปิด Augment (ออกจากระบบและกลับเข้ามาใหม่)"; +"Open Codebuff Dashboard" = "เปิดแดชบอร์ด Codebuff"; +"Open Command Code Settings" = "เปิดการตั้งค่า Command Code"; +"Open Crof dashboard" = "เปิดแดชบอร์ด Crof"; +"Open Manus" = "เปิด Manus"; +"Open MiMo Balance" = "เปิดยอดคงเหลือ MiMo"; +"Open Moonshot Console" = "เปิดคอนโซล Moonshot"; +"Open Ollama API Keys" = "เปิดปุ่ม Ollama API"; +"Open StepFun Platform" = "เปิดแพลตฟอร์ม StepFun"; +"Open T3 Chat Settings" = "เปิดการตั้งค่า T3 Chat"; +"Open Volcengine Ark Console" = "เปิดคอนโซล Volcengine Ark"; +"Open legacy provider docs" = "เปิดเอกสารของผู้ให้บริการเดิม"; +"Open projects" = "เปิดโปรเจ็กต์"; +"Open this URL manually to continue login:\n\n%@" = "เปิด URL นี้ด้วยตนเองเพื่อเข้าสู่ระบบต่อ:\n\n%@"; +"Optional organization ID for accounts linked to multiple Anthropic organizations." = "รหัสองค์กรที่ไม่บังคับสําหรับบัญชีที่เชื่อมโยงกับองค์กร Anthropic หลายองค์กร"; +"Optional. Applies to the configured Admin API key; selected token accounts do not inherit OPENAI_PROJECT_ID." = "ไม่บังคับ นําไปใช้กับคีย์ API ผู้ดูแลระบบที่กําหนดค่าไว้ บัญชีโทเค็นที่เลือกจะไม่สืบทอด OPENAI_PROJECT_ID"; +"Optional. Enter your GitHub Enterprise host, for example octocorp.ghe.com. Leave blank for github.com." = "ไม่บังคับ ป้อนโฮสต์ GitHub Enterprise ของคุณ เช่น octocorp.ghe.com เว้นว่างไว้ github.com"; +"Optional. Leave blank to discover and aggregate projects visible to the API key." = "ไม่บังคับ เว้นว่างไว้เพื่อค้นหาและรวมโครงการที่มองเห็นได้จากคีย์ API"; +"Org ID (optional)" = "รหัสองค์กร (ไม่บังคับ)"; +"Organizations" = "องค์กร"; +"Organization ID" = "รหัสองค์กร"; +"Password" = "รหัสผ่าน"; +"%@ authentication is disabled." = "%@ การตรวจสอบสิทธิ์ถูกปิดใช้งาน"; +"%@ cookies are disabled." = "คุกกี้ %@ ถูกปิดใช้งาน"; +"%@ web API access is disabled." = "%@ การเข้าถึง API เว็บถูกปิดใช้งาน"; +"Disable %@ dashboard cookie usage." = "ปิดใช้งานการใช้คุกกี้แดชบอร์ด %@"; +"Keychain access is disabled in Advanced, so browser cookie import is unavailable." = "การเข้าถึง Keychain ถูกปิดใช้งานในขั้นสูง ดังนั้นการนําเข้าคุกกี้ของเบราว์เซอร์จึงไม่พร้อมใช้งาน"; +"Manually paste an %@ from a browser session." = "วาง %@ จากเซสชันเบราว์เซอร์ด้วยตนเอง"; +"Paste a Cookie header captured from %@." = "วางส่วนหัวคุกกี้ที่บันทึกจาก %@"; +"Paste a Cookie header from %@." = "วางส่วนหัวคุกกี้จาก %@"; +"Paste a Cookie header or cURL capture from %@." = "วางส่วนหัวของคุกกี้หรือการจับภาพ cURL จาก %@"; +"Paste a Cookie header or full cURL capture from %@." = "วางส่วนหัวของคุกกี้หรือการจับภาพ cURL แบบเต็มจาก %@"; +"Paste a Cookie or Authorization header from %@." = "วางส่วนหัวคุกกี้หรือการให้สิทธิ์จาก %@"; +"Paste a full cookie header or the %@ value." = "วางส่วนหัวของคุกกี้แบบเต็มหรือค่า %@"; +"Paste a Cookie header or full cURL capture from T3 Chat settings." = "วางส่วนหัวของคุกกี้หรือการจับภาพ cURL แบบเต็มจากการตั้งค่า T3 Chat"; +"Paste the Cookie header from a request to admin.mistral.ai. Must contain an ory_session_* cookie." = "วางส่วนหัวคุกกี้จากคําขอไปยัง admin.mistral.ai ต้องมีคุกกี้ ory_session_*"; +"Paste the Oasis-Token from a logged-in browser session on platform.stepfun.com." = "วาง Oasis-Token จากเซสชันเบราว์เซอร์ที่เข้าสู่ระบบบน platform.stepfun.com"; +"Paste the %@ JSON bundle from %@." = "วางชุด %@ JSON จาก %@"; +"Paste the %@ value or a full Cookie header." = "วางค่า %@ หรือส่วนหัวของคุกกี้แบบเต็ม"; +"Personal account" = "บัญชีส่วนตัว"; +"Project ID" = "รหัสโครงการ"; +"Re-auth" = "ตรวจสอบสิทธิ์อีกครั้ง"; +"Re-login at claude.ai" = "เข้าสู่ระบบอีกครั้งที่ claude.ai"; +"Re-authenticating…" = "การตรวจสอบสิทธิ์อีกครั้ง..."; +"Refresh Session" = "รีเฟรชเซสชัน"; +"Refresh organizations" = "รีเฟรชองค์กร"; +"Region" = "ภูมิภาค"; +"Reload" = "โหลดซ้ํา"; +"Reorder" = "จัดลําดับใหม่"; +"Secret access key" = "คีย์การเข้าถึงลับ"; +"Series" = "ซีรีส์"; +"Service" = "บริการ"; +"Show or hide Kiro credits, percent, or both next to the menu bar icon." = "แสดงหรือซ่อนเครดิต เปอร์เซ็นต์ หรือทั้งสองอย่าง Kiro ถัดจากไอคอนแถบเมนู"; +"Show usage for organizations you belong to. Personal account is always shown." = "แสดงการใช้งานสําหรับองค์กรที่คุณเป็นสมาชิก บัญชีส่วนตัวจะแสดงอยู่เสมอ"; +"Sign in to cursor.com in your browser, then refresh Cursor in CodexBar." = "ลงชื่อเข้าใช้ cursor.com ในเบราว์เซอร์ แล้วรีเฟรช Cursor ใน CodexBar"; +"Simulated error text" = "ข้อความแสดงข้อผิดพลาดจําลอง"; +"StepFun platform account (phone number or email)." = "StepFun บัญชีแพลตฟอร์ม (หมายเลขโทรศัพท์หรืออีเมล)"; +"Stored in ~/.codexbar/config.json." = "เก็บไว้ใน ~/.codexbar/config.json."; +"Stored in ~/.codexbar/config.json. AZURE_OPENAI_API_KEY is also supported." = "รองรับการจัดเก็บไว้ใน ~/.codexbar/config.json. AZURE_OPENAI_API_KEY"; +"Stored in ~/.codexbar/config.json. For the official Kimi API, use Moonshot / Kimi API." = "เก็บไว้ใน ~/.codexbar/config.json. สําหรับ Kimi API อย่างเป็นทางการ ให้ใช้ Moonshot / Kimi API"; +"Stored in ~/.codexbar/config.json. Get your API key from the Volcengine Ark console." = "เก็บไว้ใน ~/.codexbar/config.json. รับคีย์ API ของคุณจากคอนโซล Volcengine Ark"; +"Stored in ~/.codexbar/config.json. Get your key from Ollama settings." = "เก็บไว้ใน ~/.codexbar/config.json. รับคีย์จากการตั้งค่า Ollama"; +"Stored in ~/.codexbar/config.json. Get your key from console.deepgram.com." = "เก็บไว้ใน ~/.codexbar/config.json. รับคีย์ของคุณจาก console.deepgram.com"; +"Stored in ~/.codexbar/config.json. Get your key from elevenlabs.io/app/settings/api-keys." = "เก็บไว้ใน ~/.codexbar/config.json. รับกุญแจของคุณจาก elevenlabs.io/app/settings/api-keys."; +"Stored in ~/.codexbar/config.json. Get your key from openrouter.ai/settings/keys and set a key spending limit there to enable API key quota tracking." = "เก็บไว้ใน ~/.codexbar/config.json. รับคีย์ของคุณจาก openrouter.ai/settings/keys และตั้งค่าขีดจํากัดการใช้จ่ายคีย์ที่นั่นเพื่อเปิดใช้งานการติดตามโควต้าคีย์ API"; +"Stored in ~/.codexbar/config.json. In Warp, open Settings > Platform > API Keys, then create one." = "เก็บไว้ใน ~/.codexbar/config.json. ใน Warp ให้เปิด การตั้งค่า > แพลตฟอร์ม > API คีย์ แล้วสร้างใหม่"; +"Stored in ~/.codexbar/config.json. Metrics require Groq Enterprise Prometheus access." = "ที่จัดเก็บไว้ในเมตริก ~/.codexbar/config.json. ต้องมีการเข้าถึง Enterprise Prometheus Groq"; +"Stored in ~/.codexbar/config.json. OPENAI_ADMIN_KEY is preferred; OPENAI_API_KEY still works." = "ควรเก็บไว้ใน ~/.codexbar/config.json. OPENAI_ADMIN_KEY OPENAI_API_KEY ยังคงใช้งานได้"; +"Stored in ~/.codexbar/config.json. Requires an Anthropic Admin API key." = "เก็บไว้ใน ~/.codexbar/config.json. ต้องใช้คีย์ API ผู้ดูแลระบบ Anthropic"; +"Stored in ~/.codexbar/config.json. Used for /v1/quota-stats." = "เก็บไว้ใน ~/.codexbar/config.json. ใช้สําหรับ /v1/quota-stats."; +"Stored in ~/.codexbar/config.json. You can also provide CODEBUFF_API_KEY or let CodexBar read ~/.config/manicode/credentials.json (created by `codebuff login`)." = "เก็บไว้ใน ~/.codexbar/config.json. คุณยังสามารถระบุ CODEBUFF_API_KEY หรือให้ CodexBar อ่าน ~/.config/manicode/credentials.json (สร้างโดย `codebuff login`)"; +"Stored in ~/.codexbar/config.json. You can also provide CROF_API_KEY." = "เก็บไว้ใน ~/.codexbar/config.json. คุณยังสามารถให้ CROF_API_KEY"; +"Stored in ~/.codexbar/config.json. You can also provide KILO_API_KEY or ~/.local/share/kilo/auth.json (kilo.access)." = "เก็บไว้ใน ~/.codexbar/config.json. คุณยังสามารถระบุ KILO_API_KEY หรือ ~/.local/share/kilo/auth.json (kilo.access) ได้อีกด้วย"; +"T3 Chat cookie" = "คุกกี้ T3 Chat"; +"Team mode" = "โหมดทีม"; +"That account is no longer available in CodexBar. Refresh the account list and try again." = "บัญชีนั้นไม่พร้อมใช้งานใน CodexBar อีกต่อไป รีเฟรชรายการบัญชีแล้วลองอีกครั้ง"; +"The browser login did not complete in time. Try Antigravity login again." = "การเข้าสู่ระบบเบราว์เซอร์ไม่เสร็จสมบูรณ์ทันเวลา ลองเข้าสู่ระบบ Antigravity อีกครั้ง"; +"Timed out waiting for Cursor login. %@" = "หมดเวลารอการเข้าสู่ระบบ Cursor %@"; +"Timed out waiting for Cursor login. %@ Last error: %@" = "หมดเวลารอการเข้าสู่ระบบ Cursor %@ ข้อผิดพลาดล่าสุด: %@"; +"Today requests" = "คําขอวันนี้"; +"Total (30d): %@ credits" = "รวม (30d): %@ หน่วยกิต"; +"Username" = "ชื่อผู้ใช้"; +"Uses username + password to login and obtain an Oasis-Token automatically." = "ใช้ชื่อผู้ใช้ + รหัสผ่านเพื่อเข้าสู่ระบบและรับ Oasis-Token โดยอัตโนมัติ"; +"Uses username + password to login and obtain an %@ automatically." = "ใช้ชื่อผู้ใช้ + รหัสผ่านเพื่อเข้าสู่ระบบและรับ %@ โดยอัตโนมัติ"; +"Utilization End" = "สิ้นสุดการใช้ประโยชน์"; +"Utilization Start" = "เริ่มต้นการใช้งาน"; +"Verbosity" = "รายละเอียด"; +"Windsurf session JSON bundle" = "Windsurf เซสชัน JSON บันเดิล"; +"Workspace ID" = "รหัสพื้นที่ทํางาน"; +"Your StepFun platform password. Used to login and obtain a session token." = "รหัสผ่านแพลตฟอร์ม StepFun ของคุณ ใช้เพื่อเข้าสู่ระบบและรับโทเค็นเซสชัน"; +"claude /login exited with status %d." = "Claude /login ออกจากสถานะด้วยสถานะ %d"; +"codex login exited with status %d." = "เข้าสู่ระบบ Codex ออกพร้อมกับสถานะ %d"; +"Cookie: …\n\nor paste a cURL capture from the Abacus AI dashboard" = "คุกกี้: ... \n\n หรือวางการจับภาพ cURL จากแดชบอร์ด Abacus AI"; +"Cookie: …\n\nor paste the __Secure-next-auth.session-token value" = "คุกกี้: ... \n\n หรือวางค่า __Secure-next-auth.session-token"; +"Cookie: …\n\nor paste the kimi-auth token value" = "คุกกี้: ... \n\n หรือวางค่าโทเค็น kimi-auth"; +"session_id=...\n\nor paste just the session_id value" = "session_id=... \n\n หรือวางเฉพาะค่า session_id"; +"Clear" = "ล้างค่าการค้นหา"; +"No matching providers" = "ไม่มีผู้ให้บริการที่ตรงกัน"; +"Search providers" = "ผู้ให้บริการการค้นหา"; + +"language_vietnamese" = "เวียดนาม"; +"language_indonesian" = "บาฮาซาอินโดนีเซีย"; + +"Request quota: %@ / %@" = "ขอโควต้า: %@ / %@"; +"language_arabic" = "العربية"; +"language_persian" = "فارسی"; +"language_thai" = "ไทย"; +"language_galician" = "Galego"; +"Limit Reset Credits" = "เครดิตรีเซ็ตขีดจำกัด"; +"1 available" = "1 รายการ"; +"%d available" = "%d รายการ"; +"Next expires %@" = "รายการถัดไปหมดอายุ %@"; +"Expires %@" = "หมดอายุ %@"; +"No expiry" = "ไม่มีวันหมดอายุ"; +"byte_unit_byte" = "ไบต์"; +"byte_unit_bytes" = "ไบต์"; +"byte_unit_kilobyte" = "กิโลไบต์"; +"byte_unit_kilobytes" = "กิโลไบต์"; +"byte_unit_megabyte" = "เมกะไบต์"; +"byte_unit_megabytes" = "เมกะไบต์"; +"byte_unit_gigabyte" = "กิกะไบต์"; +"byte_unit_gigabytes" = "กิกะไบต์"; + +/* Settings sidebar redesign */ +"Enable" = "เปิดใช้งาน"; +"Disable" = "ปิดใช้งาน"; +"providers_on_count" = "เปิดอยู่ %d"; +"section_cost_summary" = "สรุปค่าใช้จ่าย"; +"section_command_line" = "บรรทัดคำสั่ง"; +"section_privacy" = "ความเป็นส่วนตัว"; +"section_diagnostics" = "การวินิจฉัย"; +"section_updates" = "อัปเดต"; +"section_links" = "ลิงก์"; +"Show Codex Spark usage" = "แสดงการใช้งาน Codex Spark"; +"Shows Codex Spark quota rows in the menu and provider preview. Requires optional credits and extra usage in Display settings." = "แสดงแถวโควตา Codex Spark ในเมนูและตัวอย่างผู้ให้บริการ ต้องเปิดใช้ “แสดงเครดิต + การใช้งานเพิ่มเติม” ในการตั้งค่าการแสดงผล"; +"Show Daily Routines usage" = "แสดงการใช้งานกิจวัตรประจำวัน"; +"Shows the Daily Routines quota row in the menu and provider preview. Requires optional credits and extra usage in Display settings." = "แสดงแถวโควตากิจวัตรประจำวันในเมนูและตัวอย่างผู้ให้บริการ ต้องเปิดใช้ “แสดงเครดิต + การใช้งานเพิ่มเติม” ในการตั้งค่าการแสดงผล"; +"Scroll to see more models" = "เลื่อนเพื่อดูโมเดลเพิ่มเติม"; +/* Shareable usage card */ +"Copy Image" = "คัดลอกรูปภาพ"; +"Copy Stats" = "คัดลอกสถิติ"; +"Could not copy image" = "ไม่สามารถคัดลอกรูปภาพได้"; +"Image copied" = "คัดลอกรูปภาพแล้ว"; +"Image saved" = "บันทึกรูปภาพแล้ว"; +"Nothing is uploaded. This image is created on your Mac." = "ไม่มีการอัปโหลด รูปภาพนี้สร้างขึ้นบน Mac ของคุณ"; +"Save..." = "บันทึก..."; +"Share AI Usage" = "แชร์การใช้งาน AI"; +"Share Stats…" = "แชร์สถิติ…"; +"Stats copied" = "คัดลอกสถิติแล้ว"; +"Finish switching to a different Cursor account in your browser, then try again." = "สลับไปยังบัญชี Cursor อื่นในเบราว์เซอร์ให้เสร็จ แล้วลองอีกครั้ง"; +"Timed out waiting for Cursor account switch. %@" = "หมดเวลารอการสลับบัญชี Cursor %@"; +"Timed out waiting for Cursor account switch. %@ Last error: %@" = "หมดเวลารอการสลับบัญชี Cursor %@ ข้อผิดพลาดล่าสุด: %@"; + +/* Spend dashboard */ +"tab_usage_spend" = "การใช้งานและค่าใช้จ่าย"; +"Usage & Spend" = "การใช้งานและค่าใช้จ่าย"; +"Local estimated cost history across supported providers." = "ประวัติค่าใช้จ่ายโดยประมาณในเครื่องจากผู้ให้บริการที่รองรับ"; +"Time range" = "ช่วงเวลา"; +"Track costs" = "ติดตามค่าใช้จ่าย"; +"Cost tracking is off" = "ปิดการติดตามค่าใช้จ่ายอยู่"; +"Turn on Track costs to build local estimates." = "เปิด “ติดตามค่าใช้จ่าย” เพื่อสร้างการประมาณการในเครื่อง"; +"No local cost history yet" = "ยังไม่มีประวัติค่าใช้จ่ายในเครื่อง"; +"Turn on cost tracking or refresh after using a supported provider." = "เปิดการติดตามค่าใช้จ่ายหรือรีเฟรชหลังจากใช้ผู้ให้บริการที่รองรับ"; +"Refresh failures" = "การรีเฟรชที่ล้มเหลว"; +"Native currencies stay separate; Codex account rows exclude Pi session history." = "สกุลเงินต้นทางแยกจากกัน แถวบัญชี Codex ไม่รวมประวัติเซสชัน Pi"; +"Spend unavailable" = "ไม่มีข้อมูลค่าใช้จ่าย"; +"Model breakdown unavailable" = "ไม่มีรายละเอียดแยกตามโมเดล"; +"Local estimated history" = "ประวัติโดยประมาณในเครื่อง"; +"Coverage" = "ความครอบคลุม"; +"Estimated spend" = "ค่าใช้จ่ายโดยประมาณ"; +"Tracked tokens" = "โทเค็นที่ติดตาม"; +"Subscriptions" = "การสมัครสมาชิก"; +"By subscription" = "แยกตามการสมัครสมาชิก"; +"No model-level history" = "ไม่มีประวัติระดับโมเดล"; +"Daily estimated spend" = "ค่าใช้จ่ายรายวันโดยประมาณ"; +"≈%d full 5h windows of weekly left · %d windows until reset" = "เหลือโควตารายสัปดาห์ ≈%d ช่วงเต็ม 5 ชม. · อีก %d ช่วงจนรีเซ็ต"; +"Weekly cannot run out before reset at this pace" = "ด้วยอัตรานี้ โควตารายสัปดาห์จะไม่หมดก่อนรีเซ็ต"; +"Weekly can run out ≈%d windows early" = "โควตารายสัปดาห์อาจหมดเร็วขึ้น ≈%d ช่วง"; +"Estimated: %@" = "โดยประมาณ: %@"; +"session_quota_estimate_value_format" = "%1$@ %2$@"; +"session quota" = "โควตาเซสชัน"; +"session quotas" = "โควตาเซสชัน"; +"Coding Plan" = "แผนการเขียนโค้ด"; +"Agent Plan" = "แผนเอเจนต์"; +"Team" = "ทีม"; + +/* Menu bar layout editor */ +"menu_bar_layout_title" = "เค้าโครง"; +"menu_bar_layout_footer" = "ลากโทเค็นเพื่อจัดเรียงแถบเมนู คลิกโทเค็นเพื่อเพิ่ม เลือกโทเค็นที่วางแล้วและกด Delete เพื่อลบ"; +"menu_bar_layout_group_identity" = "ข้อมูลระบุตัวตน"; +"menu_bar_layout_group_usage" = "การใช้"; +"menu_bar_layout_group_time" = "เวลา"; +"menu_bar_layout_group_money" = "ค่าใช้จ่าย"; +"menu_bar_layout_group_structure" = "โครงสร้าง"; +"menu_bar_layout_scope_all" = "ผู้ให้บริการทั้งหมด"; +"menu_bar_layout_scope_help" = "แก้ไขเค้าโครงเริ่มต้นหรือกำหนดแทนสำหรับผู้ให้บริการหนึ่งราย"; +"menu_bar_layout_use_all" = "ใช้เค้าโครงของผู้ให้บริการทั้งหมด"; +"menu_bar_layout_preset" = "เค้าโครงสำเร็จรูป"; +"menu_bar_layout_preset_icon_percent" = "ไอคอนและเปอร์เซ็นต์"; +"menu_bar_layout_preset_icon_only" = "ไอคอนเท่านั้น"; +"menu_bar_layout_preset_percent_reset" = "เปอร์เซ็นต์และรีเซ็ต"; +"menu_bar_layout_preset_compact_stacked" = "ซ้อนแบบกะทัดรัด"; +"menu_bar_layout_preset_custom" = "กําหนดเอง"; +"menu_bar_layout_live_preview" = "ตัวอย่างสด"; +"menu_bar_layout_strip" = "แถบเมนู"; +"menu_bar_layout_remove_line_break" = "ลบการขึ้นบรรทัดใหม่"; +"menu_bar_layout_chip_hint" = "เลือก ลากเพื่อจัดลำดับใหม่ หรือใช้การทำงานลบ"; +"menu_bar_layout_palette_hint" = "คลิกเพื่อเพิ่มหรือลากลงในเค้าโครง"; +"menu_bar_layout_empty_line" = "วางโทเค็นที่นี่"; +"menu_bar_layout_line" = "บรรทัด %d"; +"menu_bar_layout_drag_remove" = "ลากมาที่นี่เพื่อลบ"; +"menu_bar_layout_size" = "ขนาด"; +"menu_bar_layout_size_small" = "เล็ก"; +"menu_bar_layout_size_regular" = "ปกติ"; +"menu_bar_layout_gap" = "ระยะห่าง"; +"menu_bar_layout_gap_tight" = "ชิด"; +"menu_bar_layout_gap_regular" = "ปกติ"; +"menu_bar_layout_keyboard_hint" = "Delete ลบโทเค็นที่เลือก"; +"menu_bar_layout_sample_account" = "บัญชี"; +"menu_bar_layout_sample_runs_out" = "หมดวันศุกร์"; +"menu_bar_layout_token_icon" = "ไอคอน"; +"menu_bar_layout_token_provider" = "ชื่อผู้ให้บริการ"; +"menu_bar_layout_token_account" = "บัญชี"; +"menu_bar_layout_token_session" = "เซสชั่น %"; +"menu_bar_layout_token_weekly" = "รายสัปดาห์ %"; +"menu_bar_layout_token_auto" = "% อัตโนมัติ"; +"menu_bar_layout_token_bar" = "แถบการใช้งาน"; +"menu_bar_layout_token_resets_in" = "รีเซ็ตใน"; +"menu_bar_layout_token_reset_at" = "รีเซ็ตเวลา"; +"menu_bar_layout_token_runs_out" = "หมด"; +"menu_bar_layout_token_cost_today" = "ค่าใช้จ่ายวันนี้"; +"menu_bar_layout_token_cost_30d" = "ค่าใช้จ่าย 30 วัน"; +"menu_bar_layout_token_space" = "ช่องว่าง"; +"menu_bar_layout_token_line_break" = "ขึ้นบรรทัดใหม่"; +"menu_bar_layout_token_separator_accessibility" = "จุดคั่น"; + +/* Menu bar layout accessibility */ +"Icon unavailable" = "ไอคอน: ไม่พร้อมใช้งาน"; +"%@ icon" = "%@: ไอคอน"; +"Provider name unavailable" = "ชื่อผู้ให้บริการ: ไม่พร้อมใช้งาน"; +"Account unavailable" = "บัญชี: ไม่พร้อมใช้งาน"; +"%@ unavailable" = "%@: ไม่พร้อมใช้งาน"; +"%@ %@" = "%@ %@"; +"Usage bar unavailable" = "แถบการใช้งาน: ไม่พร้อมใช้งาน"; +"Usage bar, %d of 3 filled" = "แถบการใช้งาน: %d/3 เต็ม"; +"Reset countdown unavailable" = "รีเซ็ตใน: ไม่พร้อมใช้งาน"; +"Reset time unavailable" = "รีเซ็ตเวลา: ไม่พร้อมใช้งาน"; +"Run-out estimate unavailable" = "หมด: ไม่พร้อมใช้งาน"; +"Cost today unavailable" = "ค่าใช้จ่ายวันนี้: ไม่พร้อมใช้งาน"; +"30-day cost unavailable" = "ค่าใช้จ่าย 30 วัน: ไม่พร้อมใช้งาน"; +"Resets" = "การรีเซ็ต"; diff --git a/Sources/CodexBar/Resources/th.lproj/Localizable.stringsdict b/Sources/CodexBar/Resources/th.lproj/Localizable.stringsdict new file mode 100644 index 0000000000..79a756131c --- /dev/null +++ b/Sources/CodexBar/Resources/th.lproj/Localizable.stringsdict @@ -0,0 +1,49 @@ + + + + + ≈%d full 5h windows of weekly left · %d windows until reset + + NSStringLocalizedFormatKey + %#@left@ · %#@until@ + left + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + d + one + เหลือโควตารายสัปดาห์ ≈%d ช่วงเต็ม 5 ชม. + other + เหลือโควตารายสัปดาห์ ≈%d ช่วงเต็ม 5 ชม. + + until + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + d + one + อีก %d ช่วงจนรีเซ็ต + other + อีก %d ช่วงจนรีเซ็ต + + + Weekly can run out ≈%d windows early + + NSStringLocalizedFormatKey + %#@early@ + early + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + d + one + โควตารายสัปดาห์อาจหมดเร็วขึ้น ≈%d ช่วง + other + โควตารายสัปดาห์อาจหมดเร็วขึ้น ≈%d ช่วง + + + + diff --git a/Sources/CodexBar/Resources/tr.lproj/Localizable.strings b/Sources/CodexBar/Resources/tr.lproj/Localizable.strings new file mode 100644 index 0000000000..6fa0b3e96f --- /dev/null +++ b/Sources/CodexBar/Resources/tr.lproj/Localizable.strings @@ -0,0 +1,1355 @@ +/* Turkish localization for CodexBar */ + +"ollama_safari_cookie_access_hint" = "Safari çerezleri için CodexBar’a Tam Disk Erişimi gerekir (Sistem Ayarları > Gizlilik ve Güvenlik)."; +"ollama_browser_cookie_decryption_denied" = "%@ çerezlerinin şifresini çözme Anahtar Zinciri’nde reddedildi; manuel yenilemeyle tekrar deneyin."; +"ollama_browser_cookie_decryption_disabled" = "%@ çerezlerinin şifresini çözme CodexBar’da devre dışı; Anahtar Zinciri erişimini etkinleştirip yenileyin."; + +" providers" = " sağlayıcı"; +"(System)" = "(Sistem)"; +"30d" = "30 gün"; +"7d" = "7 gün"; +"A managed Codex login is already running. Wait for it to finish before adding " = "Yönetilen bir Codex girişi zaten çalışıyor. Eklemeden önce bitmesini bekleyin "; +"API key" = "API anahtarı"; +"API region" = "API bölgesi"; +"API token" = "API jetonu"; +"API tokens" = "API jetonları"; +"About" = "Hakkında"; +"Account" = "Hesap"; +"Accounts" = "Hesaplar"; +"Accounts subtitle" = "Hesaplar alt başlığı"; +"Active" = "Etkin"; +"Add" = "Ekle"; +"Add Workspace" = "Çalışma Alanı Ekle"; +"Advanced" = "Gelişmiş"; +"All" = "Tümü"; +"Always allow prompts" = "Her zaman istemlere izin ver"; +"Animation pattern" = "Animasyon deseni"; +"Antigravity login is managed in the app" = "Antigravity girişi uygulama içinden yönetilir"; +"Applies only to the Security.framework OAuth keychain reader." = "Yalnızca Security.framework OAuth Anahtarlık okuyucusu için geçerlidir."; +"Alternatively, set a custom path in Settings." = "Alternatif olarak Ayarlar'da özel bir yol belirleyin."; +"Auto falls back to the next source if the preferred one fails." = "Otomatik mod, tercih edilen kaynak başarısız olursa bir sonrakine geçer."; +"Auto uses API first, then falls back to CLI on auth failures." = "Otomatik mod önce API'yi kullanır, kimlik doğrulama başarısız olursa CLI'ya geçer."; +"Auto-detect" = "Otomatik algıla"; +"Auto-refresh is off; use the menu's Refresh command." = "Otomatik yenileme kapalı; menüdeki Yenile komutunu kullanın."; +"Auto-refresh: hourly · Timeout: 10m" = "Otomatik yenileme: saatlik · Zaman aşımı: 10 dk"; +"Automatic" = "Otomatik"; +"Automatic imports browser cookies and WorkOS tokens." = "Otomatik olarak tarayıcı çerezlerini ve WorkOS jetonlarını içe aktarır."; +"Automatic imports browser cookies and local storage tokens." = "Otomatik olarak tarayıcı çerezlerini ve yerel depolama jetonlarını içe aktarır."; +"Automatic imports browser cookies for dashboard extras." = "Otomatik olarak panel ekleri için tarayıcı çerezlerini içe aktarır."; +"Automatic imports browser cookies for the web API." = "Otomatik olarak web API'si için tarayıcı çerezlerini içe aktarır."; +"Automatic imports browser cookies from Model Studio/Bailian." = "Otomatik olarak Model Studio/Bailian'dan tarayıcı çerezlerini içe aktarır."; +"Automatic imports browser cookies from admin.mistral.ai." = "Otomatik olarak admin.mistral.ai'dan tarayıcı çerezlerini içe aktarır."; +"Automatic imports browser cookies from opencode.ai." = "Otomatik olarak opencode.ai'dan tarayıcı çerezlerini içe aktarır."; +"Automatic imports browser cookies or stored sessions." = "Otomatik olarak tarayıcı çerezlerini veya kayıtlı oturumları içe aktarır."; +"Automatic imports browser cookies." = "Otomatik olarak tarayıcı çerezlerini içe aktarır."; +"Automatically imports browser session cookie." = "Otomatik olarak tarayıcı oturum çerezini içe aktarır."; +"Automatically opens CodexBar when you start your Mac." = "Mac'inizi başlattığınızda CodexBar'ı otomatik olarak açar."; +"Automation" = "Otomasyon"; +"Average (\\(label1) + \\(label2))" = "Ortalama (\\(label1) + \\(label2))"; +"Average (\\(metadata.sessionLabel) + \\(metadata.weeklyLabel))" = "Ortalama (\\(metadata.sessionLabel) + \\(metadata.weeklyLabel))"; +"Avoid Keychain prompts" = "Anahtarlık istemlerini atla"; +"Balance" = "Bakiye"; +"Battery Saver" = "Pil Tasarrufu"; +"Bordered" = "Kenarlıklı"; +"Build" = "Derleme"; +"Built \\(buildTimestamp)" = "Derlenme \\(buildTimestamp)"; +"Buy Credits..." = "Kredi Satın Al..."; +"Buy Credits…" = "Kredi Satın Al…"; +"CLI paths" = "CLI yolları"; +"CLI sessions" = "CLI oturumları"; +"Caches" = "Önbellekler"; +"Cancel" = "İptal"; +"Check for Updates…" = "Güncellemeleri Denetle…"; +"Check for updates automatically" = "Güncellemeleri otomatik denetle"; +"Check if you like your agents having some fun up there." = "Ajanlarınızın yukarıda biraz eğlenmesini istiyorsanız işaretleyin."; +"Check provider status" = "Sağlayıcı durumunu denetle"; +"Choose a supported browser so CodexBar can read the matching account." = "CodexBar'ın eşleşen hesabı okuyabilmesi için desteklenen bir tarayıcı seçin."; +"Choose Codex workspace" = "Codex çalışma alanını seçin"; +"Choose Cursor account" = "Cursor hesabını seçin"; +"Choose the MiniMax host (global .io or China mainland .com)." = "MiniMax sunucusunu seçin (küresel .io veya Çin anakarası .com)."; +"Choose up to " = "En fazla seçin "; +"Choose up to \\(Self.maxOverviewProviders) providers" = "En fazla \\(Self.maxOverviewProviders) sağlayıcı seçin"; +"Choose up to \\(count) providers" = "En fazla \\(count) sağlayıcı seçin"; +"Choose what to show in the menu bar (Pace shows usage vs. expected)." = "Menü çubuğunda ne gösterileceğini seçin (Hız, kullanımı beklentiyle karşılaştırır)."; +"Choose which Codex account CodexBar should follow." = "CodexBar'ın hangi Codex hesabını izleyeceğini seçin."; +"Choose which Cursor account CodexBar should use." = "CodexBar'ın hangi Cursor hesabını kullanacağını seçin."; +"Choose which window drives the menu bar percent." = "Menü çubuğu yüzdesini hangi pencerenin belirleyeceğini seçin."; +"Chrome" = "Chrome"; +"Claude CLI not found" = "Claude CLI bulunamadı"; +"Claude binary" = "Claude çalıştırılabilir dosyası"; +"Claude cookies" = "Claude çerezleri"; +"Claude login failed" = "Claude girişi başarısız"; +"Claude login timed out" = "Claude girişi zaman aşımına uğradı"; +"Close" = "Kapat"; +"Code review" = "Kod incelemesi"; +"Codex CLI not found" = "Codex CLI bulunamadı"; +"Codex account login already running" = "Codex hesap girişi zaten çalışıyor"; +"Codex binary" = "Codex çalıştırılabilir dosyası"; +"Codex login failed" = "Codex girişi başarısız"; +"Codex login timed out" = "Codex girişi zaman aşımına uğradı"; +"CodexBar Lifecycle Keepalive" = "CodexBar Yaşam Döngüsü Canlı Tutma"; +"CodexBar can't show its menu bar icon" = "CodexBar menü çubuğu simgesini gösteremiyor"; +"CodexBar could not read managed account storage. " = "CodexBar yönetilen hesap depolamasını okuyamadı. "; +"Configure…" = "Yapılandır…"; +"Connected" = "Bağlı"; +"Controls how much detail is logged." = "Ne kadar ayrıntının günlüğe kaydedileceğini denetler."; +"Cookie header" = "Çerez başlığı"; +"Cookie source" = "Çerez kaynağı"; +"Cookie: ..." = "Çerez: ..."; +"Cookie: \\u{2026}\\\n\\\nor paste a cURL capture from the Abacus AI dashboard" = "Çerez: \\u{2026}\\\n\\\nveya Abacus AI panelinden bir cURL yakalaması yapıştırın"; +"Cookie: \\u{2026}\\\n\\\nor paste the __Secure-next-auth.session-token value" = "Çerez: \\u{2026}\\\n\\\nveya __Secure-next-auth.session-token değerini yapıştırın"; +"Cookie: \\u{2026}\\\n\\\nor paste the kimi-auth token value" = "Çerez: \\u{2026}\\\n\\\nveya kimi-auth jeton değerini yapıştırın"; +"Cookie: …" = "Çerez: …"; +"CopilotDeviceFlow" = "CopilotDeviceFlow"; +"Cost" = "Maliyet"; +"Could not add Codex account" = "Codex hesabı eklenemedi"; +"Could not open Terminal for Gemini" = "Gemini için Terminal açılamadı"; +"Could not start claude /login" = "claude /login başlatılamadı"; +"Could not start codex login" = "codex login başlatılamadı"; +"Could not switch system account" = "Sistem hesabı değiştirilemedi"; +"Credits" = "Krediler"; +"Individual credits" = "Bireysel krediler"; +"Workspace" = "Çalışma alanı"; +"Credits history" = "Kredi geçmişi"; +"Cursor login failed" = "Cursor girişi başarısız"; +"Custom" = "Özel"; +"Custom Path" = "Özel Yol"; +"Daily Routines" = "Günlük Rutinler"; +"Debug" = "Hata Ayıklama"; +"Default" = "Varsayılan"; +"Disable Keychain access" = "Anahtarlık erişimini devre dışı bırak"; +"Disabled" = "Devre dışı"; +"Dismiss" = "Kapat"; +"Disconnected" = "Bağlantı kesildi"; +"Display" = "Görünüm"; +"Display mode" = "Görünüm modu"; +"Display reset times as absolute clock values instead of countdowns." = "Sıfırlama sürelerini geri sayım yerine mutlak saat değerleri olarak göster."; +"Done" = "Bitti"; +"Effective PATH" = "Etkili PATH"; +"Email" = "E-posta"; +"Enable Merge Icons to configure Overview tab providers." = "Genel Bakış sekmesi sağlayıcılarını yapılandırmak için Simgeleri Birleştir'i etkinleştirin."; +"Enable file logging" = "Dosya günlüğünü etkinleştir"; +"Enabled" = "Etkin"; +"Error" = "Hata"; +"Error simulation" = "Hata simülasyonu"; +"Expose troubleshooting tools in the Debug tab." = "Sorun giderme araçlarını Hata Ayıklama sekmesinde göster."; +"Failed" = "Başarısız"; +"False" = "Yanlış"; +"Fetch strategy attempts" = "Getirme stratejisi denemeleri"; +"Fetching" = "Getiriliyor"; +"Field" = "Alan"; +"Field subtitle" = "Alan alt başlığı"; +"Finish the current managed account change before switching the system account." = "Sistem hesabını değiştirmeden önce geçerli yönetilen hesap değişikliğini tamamlayın."; +"Force animation on next refresh" = "Sonraki yenilemede animasyonu zorla"; +"Gateway region" = "Ağ geçidi bölgesi"; +"Gemini CLI not found" = "Gemini CLI bulunamadı"; +"Gemini/Antigravity, surfacing incidents in the icon and menu." = "Gemini/Antigravity, olayları simge ve menüde gösterir."; +"General" = "Genel"; +"GitHub" = "GitHub"; +"GitHub Copilot Login" = "GitHub Copilot Girişi"; +"GitHub Login" = "GitHub Girişi"; +"Hide details" = "Ayrıntıları gizle"; +"Hide personal information" = "Kişisel bilgileri gizle"; +"Historical tracking" = "Geçmişsel izleme"; +"How often CodexBar polls providers in the background." = "CodexBar'ın arka planda sağlayıcıları ne sıklıkla sorgulayacağı."; +"Inactive" = "Devre dışı"; +"Install CLI" = "CLI Kur"; +"Install the Claude CLI (npm i -g @anthropic-ai/claude-code) and try again." = "Claude CLI'yi kurun (npm i -g @anthropic-ai/claude-code) ve tekrar deneyin."; +"Install the Codex CLI (npm i -g @openai/codex) and try again." = "Codex CLI'yi kurun (npm i -g @openai/codex) ve tekrar deneyin."; +"Install the Gemini CLI (npm i -g @google/gemini-cli) and try again." = "Gemini CLI'yi kurun (npm i -g @google/gemini-cli) ve tekrar deneyin."; +"Install a JetBrains IDE with AI Assistant enabled, then refresh CodexBar." = "AI Assistant etkin bir JetBrains IDE kurun, ardından CodexBar'ı yenileyin."; +"JetBrains AI is ready" = "JetBrains AI hazır"; +"JetBrains IDE" = "JetBrains IDE"; +"Keep CLI sessions alive" = "CLI oturumlarını canlı tut"; +"Keyboard shortcut" = "Klavye kısayolu"; +"Keychain access" = "Anahtarlık erişimi"; +"Keychain prompt policy" = "Anahtarlık istem ilkesi"; +"Last \\(name) fetch failed:" = "Son \\(name) getirmesi başarısız:"; +"Last \\(self.store.metadata(for: self.provider).displayName) fetch failed:" = "Son \\(self.store.metadata(for: self.provider).displayName) getirmesi başarısız:"; +"Last attempt" = "Son deneme"; +"Link" = "Bağlantı"; +"Loading animations" = "Yükleme animasyonları"; +"Loading…" = "Yükleniyor…"; +"Local" = "Yerel"; +"Logging" = "Günlükleme"; +"Login failed" = "Giriş başarısız"; +"Login shell PATH (startup capture)" = "Oturum kabuğu PATH (başlangıç yakalaması)"; +"Login timed out" = "Giriş zaman aşımına uğradı"; +"MCP details" = "MCP ayrıntıları"; +"Managed Codex accounts unavailable" = "Yönetilen Codex hesapları kullanılamıyor"; +"Managed account storage is unreadable. Live account access is still available, " = "Yönetilen hesap depolaması okunamıyor. Canlı hesap erişimi hâlâ kullanılabilir, "; +"Manual" = "El ile"; +"May your tokens never run out—keep agent limits in view." = "Jetonlarınız hiç bitmesin—ajan limitlerini göz önünde bulundurun."; +"Menu bar" = "Menü çubuğu"; +"Menu bar auto-shows the provider closest to its rate limit." = "Menü çubuğu, hız limitine en yakın sağlayıcıyı otomatik gösterir."; +"Menu bar metric" = "Menü çubuğu metriği"; +"Menu bar shows percent" = "Menü çubuğu yüzde gösterir"; +"Menu content" = "Menü içeriği"; +"Merge Icons" = "Simgeleri Birleştir"; +"Never prompt" = "Hiçbir zaman istem gösterme"; +"No" = "Hayır"; +"No Codex accounts detected yet." = "Henüz Codex hesabı algılanmadı."; +"No JetBrains IDE detected" = "JetBrains IDE algılanmadı"; +"No cost history data." = "Maliyet geçmişi verisi yok."; +"No data available" = "Veri yok"; +"No data yet" = "Henüz veri yok"; +"No enabled providers available for Overview." = "Genel Bakış için kullanılabilir etkin sağlayıcı yok."; +"No providers selected" = "Sağlayıcı seçilmedi"; +"No token accounts yet." = "Henüz jeton hesabı yok."; +"No usage breakdown data." = "Kullanım dağılım verisi yok."; +"None" = "Hiçbiri"; +"Notifications" = "Bildirimler"; +"Notifies when the 5-hour session quota hits 0% and when it becomes " = "5 saatlik oturum kotası %0'a ulaştığında ve tekrar "; +"OK" = "Tamam"; +"Obscure email addresses in the menu bar and menu UI." = "Menü çubuğu ve menü arayüzünde e-posta adreslerini gizle."; +"Off" = "Kapalı"; +"Offline" = "Çevrimdışı"; +"On" = "Açık"; +"Online" = "Çevrimiçi"; +"Only on user action" = "Yalnızca kullanıcı eyleminde"; +"Open" = "Aç"; +"Open API Keys" = "API Anahtarlarını Aç"; +"Open Amp Settings" = "Amp Ayarlarını Aç"; +"Open Antigravity to sign in, then refresh CodexBar." = "Giriş yapmak için Antigravity'yi açın, ardından CodexBar'ı yenileyin."; +"Open Browser" = "Tarayıcıyı Aç"; +"Open Coding Plan" = "Kodlama Planını Aç"; +"Open Console" = "Konsolu Aç"; +"Open Dashboard" = "Paneli Aç"; +"Open Mistral Admin" = "Mistral Yönetimini Aç"; +"Open Menu Bar Settings" = "Menü Çubuğu Ayarlarını Aç"; +"Open Ollama Settings" = "Ollama Ayarlarını Aç"; +"Open Terminal" = "Terminali Aç"; +"Open Usage Page" = "Kullanım Sayfasını Aç"; +"Open Warp API Key Guide" = "Warp API Anahtarı Kılavuzunu Aç"; +"Open menu" = "Menüyü aç"; +"Open token file" = "Jeton dosyasını aç"; +"OpenAI cookies" = "OpenAI çerezleri"; +"OpenAI web extras" = "OpenAI web ekleri"; +"Option A" = "Seçenek A"; +"Option B" = "Seçenek B"; +"Optional override if workspace lookup fails." = "Çalışma alanı araması başarısız olursa isteğe bağlı geçersiz kılma."; +"Options" = "Seçenekler"; +"Override auto-detection with a custom IDE base path" = "Otomatik algılamayı özel bir IDE temel yoluyla geçersiz kıl"; +"Overview" = "Genel Bakış"; +"Overview rows always follow provider order." = "Genel Bakış satırları her zaman sağlayıcı sırasını takip eder."; +"Overview tab providers" = "Genel Bakış sekmesi sağlayıcıları"; +"Paste API key…" = "API anahtarı yapıştır…"; +"Paste API token…" = "API jetonu yapıştır…"; +"Paste key…" = "Anahtar yapıştır…"; +"Paste sessionKey or OAuth token…" = "sessionKey veya OAuth jetonu yapıştır…"; +"Paste the Cookie header from a request to admin.mistral.ai. " = "admin.mistral.ai'a yapılan bir istekten Çerez başlığını yapıştırın. "; +"Paste token…" = "Jeton yapıştır…"; +"Personal" = "Kişisel"; +"Picker" = "Seçici"; +"Picker subtitle" = "Seçici alt başlığı"; +"Placeholder" = "Yer tutucu"; +"Plan" = "Plan"; +"Plan Usage" = "Plan Kullanımı"; +"Play full-screen confetti when weekly usage resets." = "Haftalık kullanım sıfırlandığında tam ekran konfeti oynat."; +"Polls OpenAI/Claude status pages and Google Workspace for " = "OpenAI/Claude durum sayfalarını ve Google Workspace'i "; +"Prevents any Keychain access while enabled." = "Etkinleştirildiğinde tüm Anahtarlık erişimini engeller."; +"Primary (API key limit)" = "Birincil (API anahtarı limiti)"; +"Primary (\\(label))" = "Birincil (\\(label))"; +"Primary (\\(metadata.sessionLabel))" = "Birincil (\\(metadata.sessionLabel))"; +"Probe logs" = "Sorgu günlükleri"; +"Progress bars fill as you consume quota (instead of showing remaining)." = "İlerleme çubukları kota tükettikçe dolar (kalanı göstermek yerine)."; +"Provider" = "Sağlayıcı"; +"Providers" = "Sağlayıcılar"; +"Quit CodexBar" = "CodexBar'dan Çık"; +"Random (default)" = "Rastgele (varsayılan)"; +"Reads local usage logs. Shows today + last 30 days cost in the menu." = "Yerel kullanım günlüklerini okur. Menüde bugün + seçilen geçmiş penceresini gösterir."; +"Refresh" = "Yenile"; +"Refresh cadence" = "Yenileme sıklığı"; +"Remote" = "Uzak"; +"Remove" = "Kaldır"; +"Remove Codex account?" = "Codex hesabı kaldırılsın mı?"; +"Remove \\(account.email) from CodexBar? Its managed Codex home will be deleted." = "\\(account.email) CodexBar'dan kaldırılsın mı? Yönetilen Codex evi silinecek."; +"Remove \\(email) from CodexBar? Its managed Codex home will be deleted." = "\\(email) CodexBar'dan kaldırılsın mı? Yönetilen Codex evi silinecek."; +"Remove selected account" = "Seçili hesabı kaldır"; +"Replace critter bars with provider branding icons and a percentage." = "Canavar çubuklarını sağlayıcı marka simgeleri ve yüzde ile değiştir."; +"Replay selected animation" = "Seçili animasyonu yeniden oynat"; +"Requires authentication via GitHub Device Flow." = "GitHub Cihaz Akışı ile kimlik doğrulaması gerektirir."; +"Resets: \\(reset)" = "Sıfırlama: \\(reset)"; +"Rolling five-hour limit" = "Kayan beş saatlik limit"; +"Search hourly" = "Saatlik ara"; +"Secondary (\\(label))" = "İkincil (\\(label))"; +"Secondary (\\(metadata.weeklyLabel))" = "İkincil (\\(metadata.weeklyLabel))"; +"Select a provider" = "Bir sağlayıcı seçin"; +"Select the IDE to monitor" = "İzlenecek IDE'yi seçin"; +"Session quota notifications" = "Oturum kota bildirimleri"; +"Session tokens" = "Oturum jetonları"; +"provider_section_connection" = "Bağlantı"; +"provider_section_menu_bar" = "Menü çubuğu"; +"Show Codex Credits and Claude Extra usage sections in the menu." = "Menüde Codex Kredileri ve Claude Ekstra kullanım bölümlerini göster."; +"Show Debug Settings" = "Hata Ayıklama Ayarlarını Göster"; +"Show all token accounts" = "Tüm jeton hesaplarını göster"; +"Show cost summary" = "Maliyet özetini göster"; +"Show credits + extra usage" = "Krediler + ekstra kullanımı göster"; +"Show details" = "Ayrıntıları göster"; +"Show most-used provider" = "En çok kullanılan sağlayıcıyı göster"; +"Show provider icons in the switcher (otherwise show a weekly progress line)." = "Değiştiricide sağlayıcı simgelerini göster (aksi takdirde haftalık ilerleme çizgisi göster)."; +"Show reset time as clock" = "Sıfırlama süresini saat olarak göster"; +"Show usage as used" = "Kullanımı harcanan olarak göster"; +"Sign in with Claude Code..." = "Claude Code ile giriş yap..."; +"Sign in via button below" = "Aşağıdaki düğmeyle oturum açın"; +"Skip teardown between probes (debug-only)." = "Sorgular arası sökmeyi atla (yalnızca hata ayıklama)."; +"Stack token accounts in the menu (otherwise show an account switcher bar)." = "Menüde jeton hesaplarını yığınla göster (aksi takdirde hesap değiştirici çubuk göster)."; +"Start at Login" = "Girişte Başlat"; +"Status" = "Durum"; +"Store Claude sessionKey cookies or OAuth access tokens." = "Claude sessionKey çerezlerini veya OAuth erişim jetonlarını depolayın."; +"Store multiple Abacus AI Cookie headers." = "Birden fazla Abacus AI Çerez başlığı depolayın."; +"Store multiple Augment Cookie headers." = "Birden fazla Augment Çerez başlığı depolayın."; +"Store multiple Cursor Cookie headers." = "Birden fazla Cursor Çerez başlığı depolayın."; +"Store multiple Factory Cookie headers." = "Birden fazla Factory Çerez başlığı depolayın."; +"Store multiple MiniMax Cookie headers." = "Birden fazla MiniMax Çerez başlığı depolayın."; +"Store multiple Mistral Cookie headers." = "Birden fazla Mistral Çerez başlığı depolayın."; +"Store multiple Ollama Cookie headers." = "Birden fazla Ollama Çerez başlığı depolayın."; +"Store multiple OpenCode Cookie headers." = "Birden fazla OpenCode Çerez başlığı depolayın."; +"Store multiple OpenCode Go Cookie headers." = "Birden fazla OpenCode Go Çerez başlığı depolayın."; +"Stored in the CodexBar config file." = "CodexBar yapılandırma dosyasında depolandı."; +"Stored in ~/.codexbar/config.json. " = "~/.codexbar/config.json dosyasında depolandı. "; +"Stored in ~/.codexbar/config.json. Paste the key from the Synthetic dashboard." = "~/.codexbar/config.json dosyasında depolandı. Synthetic panelinden anahtarı yapıştırın."; +"Stored in ~/.codexbar/config.json. Paste your Coding Plan API key from Model Studio." = "~/.codexbar/config.json dosyasında depolandı. Model Studio'dan Kodlama Planı API anahtarınızı yapıştırın."; +"Stored in ~/.codexbar/config.json. Paste your MiniMax API key." = "~/.codexbar/config.json dosyasında depolandı. MiniMax API anahtarınızı yapıştırın."; +"Stored in ~/.codexbar/config.json. You can also provide KILO_API_KEY or " = "~/.codexbar/config.json dosyasında depolandı. Ayrıca KILO_API_KEY sağlayabilirsiniz veya "; +"Stores local Codex usage history (8 weeks) to personalize Pace predictions." = "Hız tahminlerini kişiselleştirmek için yerel Codex kullanım geçmişini (8 hafta) depolar."; +"Surprise me" = "Beni şaşırt"; +"Switcher shows icons" = "Değiştirici simgeleri gösterir"; +"Symlink CodexBarCLI to /usr/local/bin and /opt/homebrew/bin as codexbar." = "CodexBarCLI'yi codexbar olarak /usr/local/bin ve /opt/homebrew/bin dizinlerine sembolik bağlayın."; +"System" = "Sistem"; +"Temporarily shows the loading animation after the next refresh." = "Sonraki yenilemeden sonra yükleme animasyonunu geçici olarak gösterir."; +"Tertiary (\\(label))" = "Üçüncül (\\(label))"; +"Tertiary (\\(tertiaryTitle))" = "Üçüncül (\\(tertiaryTitle))"; +"The default Codex account on this Mac." = "Bu Mac'teki varsayılan Codex hesabı."; +"Toggle" = "Geçiş"; +"Toggle subtitle" = "Geçiş alt başlığı"; +"Token" = "Jeton"; +"Trigger the menu bar menu from anywhere." = "Menü çubuğu menüsünü herhangi bir yerden tetikleyin."; +"True" = "Doğru"; +"Twitter" = "Twitter"; +"Unsupported" = "Desteklenmiyor"; +"Update Channel" = "Güncelleme Kanalı"; +"Updated" = "Güncellendi"; +"Updates unavailable in this build." = "Bu derlemede güncellemeler kullanılamıyor."; +"Usage" = "Kullanım"; +"Usage breakdown" = "Kullanım dağılımı"; +"Usage history (30 days)" = "Kullanım geçmişi"; +"Usage source" = "Kullanım kaynağı"; +"Use Account" = "Hesabı Kullan"; +"Use BigModel for the China mainland endpoints (open.bigmodel.cn)." = "Çin anakarası uç noktaları için BigModel kullanın (open.bigmodel.cn)."; +"Use a single menu bar icon with a provider switcher." = "Sağlayıcı değiştiricisiyle tek bir menü çubuğu simgesi kullan."; +"Use international or China mainland console gateways for quota fetches." = "Kota getirmeleri için uluslararası veya Çin anakarası konsol ağ geçitlerini kullan."; +"Version" = "Sürüm"; +"Version \\(self.versionString)" = "Sürüm \\(self.versionString)"; +"Version \\(version)" = "Sürüm \\(version)"; +"Version \\(versionString)" = "Sürüm \\(versionString)"; +"Vertex AI Login" = "Vertex AI Girişi"; +"Wait for the current managed Codex login to finish before adding another account." = "Başka bir hesap eklemeden önce geçerli yönetilen Codex girişinin bitmesini bekleyin."; +"Waiting for Authentication..." = "Kimlik Doğrulaması Bekleniyor..."; +"Website" = "Web Sitesi"; +"Weekly limit confetti" = "Haftalık limit konfetisi"; +"Weekly token limit" = "Haftalık jeton limiti"; +"Weekly usage" = "Haftalık kullanım"; +"Weekly usage unavailable for this account." = "Bu hesap için haftalık kullanım kullanılamıyor."; +"Window: \\(window)" = "Pencere: \\(window)"; +"Write logs to \\(self.fileLogPath) for debugging." = "Hata ayıklama için günlükleri \\(self.fileLogPath) konumuna yaz."; +"Yes" = "Evet"; +"\\(detail.modelCode): \\(usage)" = "\\(detail.modelCode): \\(usage)"; +"\\(name): \\(truncated)" = "\\(name): \\(truncated)"; +"\\(name): \\(updated) · 30d \\(cost)" = "\\(name): \\(updated) · 30 gün \\(cost)"; +"\\(name): fetching…\\(elapsed)" = "\\(name): getiriliyor…\\(elapsed)"; +"\\(name): last attempt \\(when)" = "\\(name): son deneme \\(when)"; +"\\(name): no data yet" = "\\(name): henüz veri yok"; +"\\(name): unsupported" = "\\(name): desteklenmiyor"; +"all browsers" = "tüm tarayıcılar"; +"available again." = "tekrar kullanılabilir olduğunda bildirir."; +"built_format" = "Derlenme %@"; +"copilot_complete_in_browser" = "Tarayıcınızda oturum açmayı tamamlayın."; +"copilot_device_code" = "Cihaz kodu panoya kopyalandı: %1$@\n\nDoğrulayın: %2$@"; +"copilot_device_code_copied" = "Cihaz kodu kopyalandı."; +"copilot_verify_at" = "Doğrulayın: %@"; +"copilot_waiting_text" = "Tarayıcınızda oturum açmayı tamamlayın.\nOturum açma tamamlandığında bu pencere otomatik olarak kapanır."; +"copilot_window_closes_auto" = "Oturum açma tamamlandığında bu pencere otomatik olarak kapanır."; +"cost_status_error" = "%1$@: %2$@"; +"cost_status_fetching" = "%1$@: getiriliyor… %2$@"; +"cost_status_last_attempt" = "%1$@: son deneme %2$@"; +"cost_status_no_data" = "%@: henüz veri yok"; +"cost_status_snapshot" = "%1$@: %2$@ · %3$@ %4$@"; +"cost_status_unsupported" = "%@: desteklenmiyor"; +"credits_remaining" = "Krediler: %@"; +"cursor_on_demand" = "İsteğe bağlı: %@"; +"cursor_on_demand_with_limit" = "İsteğe bağlı: %1$@ / %2$@"; +"extra_usage_format" = "Ekstra kullanım: %1$@ / %2$@"; +"jetbrains_detected_generate" = "Algılandı: %@. Kota verisini oluşturmak için AI asistanını bir kez kullanın, ardından CodexBar'ı yenileyin."; +"jetbrains_detected_select" = "Algılandı: %@. Ayarlar'da tercih ettiğiniz IDE'yi seçin, ardından CodexBar'ı yenileyin."; +"last_fetch_failed_with_provider" = "Son %@ getirmesi başarısız:"; +"last_spend" = "Son harcama: %@"; +"mcp_model_usage" = "%1$@: %2$@"; +"mcp_resets" = "Sıfırlama: %@"; +"mcp_window" = "Pencere: %@"; +"metric_average" = "Ortalama (%1$@ + %2$@)"; +"metric_primary" = "Birincil (%@)"; +"metric_secondary" = "İkincil (%@)"; +"metric_tertiary" = "Üçüncül (%@)"; +"multiple_workspaces_found" = "CodexBar %@ için birden fazla çalışma alanı buldu. Lütfen eklenecek çalışma alanını seçin."; +"ory_session_…=…; csrftoken=…" = "ory_session_…=…; csrftoken=…"; +"overview_choose_providers" = "En fazla %@ sağlayıcı seçin"; +"remove_account_message" = "%@ CodexBar'dan kaldırılsın mı? Yönetilen Codex evi silinecek."; +"version_format" = "Sürüm %@"; +"vertex_ai_login_instructions" = "Vertex AI kullanımını izlemek için Google Cloud ile kimlik doğrulaması yapın.\n\n1. Terminal'i açın\n2. Şunu çalıştırın: gcloud auth application-default login\n3. Tarayıcıdaki adımları takip ederek oturum açın\n4. Projenizi ayarlayın: gcloud config set project PROJECT_ID\n\nTerminal şimdi açılsın mı?"; +"workspaceID is set but only opencode, opencodego, and deepgram support workspaceID." = "workspaceID ayarlandı, ancak yalnızca opencode, opencodego ve deepgram workspaceID destekler."; +"© 2026 Peter Steinberger. MIT License." = "© 2026 Peter Steinberger. MIT Lisansı."; + +/* General Pane */ +"section_system" = "Sistem"; +"section_usage" = "Kullanım"; +"section_refreshing" = "Yenileme"; +"section_alerts" = "Uyarılar"; +"section_celebrations" = "Kutlamalar"; +"section_icon" = "Simge"; +"section_combined_icon" = "Birleşik simge"; +"section_animation" = "Animasyon"; +"section_content" = "İçerik"; +"section_agent_sessions" = "Ajan oturumları"; +"language_title" = "Dil"; +"language_subtitle" = "Görüntüleme dilini değiştirin. Tam olarak geçerli olması için uygulamanın yeniden başlatılması gerekir."; +"currency_title" = "Tercih edilen para birimi"; +"currency_subtitle" = "Maliyet tahminleri ve harcamalar için para birimi. Günlük güncellenen kurları kullanır."; +"currency_auto" = "Otomatik (sağlayıcı / USD)"; +"language_system" = "Sistem"; +"language_english" = "English"; +"language_spanish" = "Español"; +"language_catalan" = "Català"; +"language_chinese_simplified" = "简体中文"; +"language_chinese_traditional" = "繁體中文"; +"language_portuguese_brazilian" = "Português (Brasil)"; +"language_swedish" = "Svenska"; +"language_dutch" = "Nederlands"; +"language_german" = "Deutsch"; +"language_french" = "Français"; +"language_ukrainian" = "Українська"; +"language_russian" = "Русский"; +"language_vietnamese" = "Tiếng Việt"; +"language_japanese" = "日本語"; +"language_korean" = "한국어"; +"language_turkish" = "Türkçe"; +"language_italian" = "Italiano"; +"language_indonesian" = "Endonezce"; +"language_polish" = "Lehçe"; +"start_at_login_title" = "Girişte Başlat"; +"start_at_login_subtitle" = "Mac'inizi başlattığınızda CodexBar'ı otomatik olarak açar."; +"show_cost_summary_subtitle" = "Yerel kullanım günlüklerini okur. Menüde bugün + seçilen geçmiş penceresini gösterir."; +"cost_summary_style_title" = "Görüntüleme stili"; +"cost_summary_style_inline" = "Yalnızca satır içi"; +"cost_summary_style_submenu" = "Yalnızca alt menü"; +"cost_summary_style_both" = "İkisi de"; +"cost_summary_style_inline_help" = "Maliyet özetini doğrudan ana menüde gösterir."; +"cost_summary_style_submenu_help" = "Bunun yerine ayrıntılı Maliyet alt menüsünü gösterir."; +"cost_summary_style_both_help" = "Hem ana menü özetini hem de ayrıntılı Maliyet alt menüsünü gösterir."; +"cost_history_window_title" = "Geçmiş penceresi"; +"cost_history_window_help" = "Menüde kaç günlük yerel kullanım günlüğünün gösterileceğini belirler."; +"cost_history_days_title" = "Geçmiş penceresi: %d gün"; +"cost_comparison_periods_title" = "Daha kısa karşılaştırma dönemlerini göster"; +"cost_comparison_periods_subtitle" = "Seçilen geçmiş aralığına sığdığında 7, 30 ve 90 günlük toplamları ekler. Bu toplamlar aynı yerel taramayı yeniden kullanır."; +"cost_auto_refresh_info" = "Otomatik yenileme: genel aralık (en az 5 dk) · Zaman aşımı: 10 dk"; +"refresh_interval_title" = "Yenileme aralığı"; +"manual_refresh_hint" = "Otomatik yenileme kapalı; menüdeki Yenile komutunu kullanın."; +"refresh_on_open_title" = "Menü açıldığında yenile"; +"refresh_on_open_subtitle" = "Menüyü her açtığınızda her sağlayıcının en güncel kullanımını getirir."; +"check_provider_status_title" = "Sağlayıcı durumunu denetle"; +"check_provider_status_subtitle" = "OpenAI/Claude durum sayfalarını ve Google Workspace'i (Gemini/Antigravity) sorgular, olayları simge ve menüde gösterir."; +"session_quota_notifications_subtitle" = "5 saatlik oturum kotası %0'a ulaştığında ve tekrar kullanılabilir olduğunda bildirir."; +"quota_depleted_title" = "Kota tükenmesi ve yenilenmesi"; +"quota_warning_notifications_subtitle" = "Oturum veya haftalık kalan kota yapılandırılan eşikleri geçtiğinde uyarır."; +"threshold_warnings_title" = "Eşik uyarıları"; +"quota_warnings_title" = "Kota uyarıları"; +"quota_warning_session" = "oturum"; +"quota_warning_session_capitalized" = "Oturum"; +"quota_warning_weekly" = "haftalık"; +"quota_warning_weekly_capitalized" = "Haftalık"; +"quota_warning_notification_title" = "%1$@ %2$@ kotası düşük"; +"quota_warning_notification_body" = "%1$@ kaldı. %2$d%% %3$@ uyarı eşiğinize ulaşıldı."; +"quota_warning_notification_body_with_account" = "Hesap %1$@. %2$@ kaldı. %3$d%% %4$@ uyarı eşiğinize ulaşıldı."; +"predictive_pace_warnings_title" = "Öngörülü tempo uyarıları"; +"predictive_pace_warnings_subtitle" = "Codex ve Claude için oturum veya haftalık kullanım temposu kotayı sıfırlanmadan önce tüketebilecekse uyarır."; +"confetti_on_reset_title" = "Sıfırlamada konfeti"; +"confetti_on_reset_subtitle" = "Kullanım sıfırlandığında tam ekran konfeti göster."; +"confetti_option_off" = "Kapalı"; +"confetti_option_session" = "Oturum sıfırlamaları"; +"confetti_option_weekly" = "Haftalık sıfırlamalar"; +"confetti_option_both" = "İkisi de"; +"predictive_pace_warning_notification_title" = "%1$@ %2$@ tempo uyarısı"; +"predictive_pace_warning_notification_body" = "Mevcut tempoda bu kota sıfırlanmadan önce %1$@ içinde tükenebilir."; +"predictive_pace_warning_notification_body_with_account" = "Hesap %1$@. Mevcut tempoda bu kota sıfırlanmadan önce %2$@ içinde tükenebilir."; +"session_depleted_notification_title" = "%@ oturumu tükendi"; +"session_depleted_notification_body" = "0% kaldı. Tekrar kullanılabilir olduğunda bildirilecek."; +"session_restored_notification_title" = "%@ oturumu geri yüklendi"; +"session_restored_notification_body" = "Oturum kotası tekrar kullanılabilir."; +"quota_warning_warn_at" = "Uyarı eşikleri"; +"quota_warning_global_threshold_subtitle" = "Bir sağlayıcı geçersiz kılmadıkça oturum ve haftalık pencereler için kalan yüzdelerval."; +"quota_warning_sound" = "Bildirim sesi çal"; +"quota_warning_onscreen_alert" = "Ekranda metin uyarısı göster"; +"quota_warning_provider_inherits" = "Bir pencere burada özelleştirilmediği sürece genel kota uyarı ayarlarını kullanır."; +"quota_warning_provider_disabled" = "Kota uyarı bildirimleri ve kullanım çubuğu işaretleri devre dışı. Kaydedilmiş ayarları düzenlemek için ikisinden birini etkinleştirin."; +"quota_warning_provider_markers_only" = "Kota uyarısı bildirimleri uygulama genelinde devre dışı. Bu ayarlar kullanım çubuğu işaretlerini kontrol etmeye devam eder."; +"quota_warning_global" = "Genel"; +"quota_warning_customize_thresholds" = "%@ eşiklerini özelleştir"; +"quota_warning_enable_warnings" = "%@ uyarılarını etkinleştir"; +"quota_warning_window_warn_at" = "%@ uyarı eşiği"; +"quota_warning_off" = "Kapalı"; +"quota_warning_inherited" = "Devralınan: %@"; +"quota_warning_depleted_only" = "yalnızca tükenme"; +"quota_warning_upper" = "Daha yüksek"; +"quota_warning_lower" = "Alt"; +"quota_warning_warning" = "Uyarı"; +"quota_warning_critical" = "Kritik"; +"apply" = "Uygula"; +"quit_app" = "CodexBar'dan Çık"; + +/* Tab titles */ +"tab_general" = "Genel"; +"tab_providers" = "Sağlayıcılar"; +"tab_notifications" = "Bildirimler"; +"tab_menu_bar" = "Menü çubuğu"; +"tab_menu" = "Menü"; +"tab_advanced" = "Gelişmiş"; +"tab_hooks" = "Kancalar"; + +/* Hooks Pane */ +"hooks_enable_title" = "Kancaları etkinleştir"; +"hooks_enable_subtitle" = "Kota veya sağlayıcı olayları gerçekleştiğinde harici komutlar çalıştır."; +"hooks_trust_warning" = "Kancalar Mac'inizde yerel komutlar çalıştırabilir. Yalnızca güvendiğiniz komutları yapılandırın."; +"hooks_rules_header" = "Kurallar"; +"hooks_empty" = "Yapılandırılmış kanca yok."; +"hooks_add_rule" = "Kural ekle"; +"hooks_delete_rule" = "Kuralı sil"; +"hooks_rule_enabled" = "Etkin"; +"hooks_event" = "Olay"; +"hooks_provider" = "Sağlayıcı"; +"hooks_any_provider" = "Herhangi bir sağlayıcı"; +"hooks_threshold" = "Şu kullanımda tetikle ≥"; +"hooks_threshold_placeholder" = "90"; +"hooks_executable_placeholder" = "/usr/local/bin/my-command"; +"hooks_arguments_placeholder" = "Argümanlar"; +"hooks_argument_placeholder" = "Argüman"; +"hooks_add_argument" = "Argüman ekle"; +"hooks_delete_argument" = "Argümanı sil"; +"tab_about" = "Hakkında"; +"tab_debug" = "Hata Ayıklama"; + +/* Providers Pane */ +"select_a_provider" = "Bir sağlayıcı seçin"; +"cancel" = "İptal"; +"last_fetch_failed" = "son getirme başarısız"; +"usage_not_fetched_yet" = "kullanım henüz getirilmedi"; +"managed_account_storage_unreadable" = "Yönetilen hesap depolaması okunamıyor. Canlı hesap erişimi hâlâ kullanılabilir, ancak depo kurtarılana kadar yönetilen ekleme, yeniden kimlik doğrulama ve kaldırma işlemleri devre dışı bırakıldı."; +"remove_codex_account_title" = "Codex hesabı kaldırılsın mı?"; +"remove" = "Kaldır"; +"managed_login_already_running" = "Yönetilen bir Codex girişi zaten çalışıyor. Başka bir hesap eklemeden veya yeniden kimlik doğrulamadan önce bitmesini bekleyin."; +"managed_login_failed" = "Yönetilen Codex girişi tamamlanmadı. Terminal'de `codex --version` komutunun çalıştığını doğrulayın. macOS `codex` dosyasını engellediyse veya Çöp Kutusu'na taşıdıysa, eski yinelenen kurulumları kaldırın, `npm install -g --include=optional @openai/codex@latest` komutunu çalıştırın, ardından tekrar deneyin."; +"codex_login_output" = "codex login çıktısı:"; +"managed_login_missing_email" = "Codex girişi tamamlandı, ancak hesap e-postası bulunamadı. Hesabın tam olarak oturum açtığını doğruladıktan sonra tekrar deneyin."; +"login_success_notification_title" = "%@ girişi başarılı"; +"login_success_notification_body" = "Uygulamaya dönebilirsiniz; kimlik doğrulaması tamamlandı."; +"workspace_selection_cancelled" = "CodexBar birden fazla çalışma alanı buldu, ancak hiçbir çalışma alanı seçilmedi."; +"unsafe_managed_home" = "CodexBar beklenmeyen bir yönetilen ev yolunu değiştirmeyi reddetti: %@"; +"menu_bar_metric_title" = "Menü çubuğu metriği"; +"menu_bar_metric_subtitle" = "Menü çubuğu yüzdesini hangi pencerenin belirleyeceğini seçin."; +"menu_bar_metric_subtitle_deepseek" = "Menü çubuğunda DeepSeek bakiyesini gösterir."; +"menu_bar_metric_subtitle_moonshot" = "Menü çubuğunda Moonshot / Kimi API bakiyesini gösterir."; +"menu_bar_metric_subtitle_mistral" = "Menü çubuğunda geçerli ayın Mistral API harcamasını gösterir."; +"automatic" = "Otomatik"; +"primary_api_key_limit" = "Birincil (API anahtarı limiti)"; + +/* Display Pane */ +"menu_bar_style_title" = "Menü çubuğu stili"; +"menu_bar_style_subtitle" = "Menü çubuğu öğesinin nasıl çizileceğini belirler."; +"menu_bar_inactive_display_contrast_title" = "Etkin olmayan ekranlarda görünürlüğü artır"; +"menu_bar_usage_colors_title" = "Renk kodlu kullanım"; +"menu_bar_usage_colors_subtitle" = "Kullanım arttıkça menü çubuğu simgesini yeşilden kırmızıya renklendirir."; +"menu_bar_inactive_display_contrast_subtitle" = "Simge ve ölçümün diğer ekranlarda okunabilir kalması için yüksek kontrastlı işleme kullanır."; +"menu_bar_style_critters" = "Canavarlar"; +"menu_bar_style_bars" = "Ölçüm çubukları"; +"menu_bar_style_icon_percent" = "Simge ve yüzde"; +"switcher_rows_title" = "Değiştirici satırları"; +"switcher_rows_icons" = "Sağlayıcı simgeleri"; +"switcher_rows_progress" = "Haftalık ilerleme"; +"usage_bars_fill_title" = "Kullanım çubuklarının dolumu"; +"usage_bars_fill_remaining" = "Kalan miktara göre"; +"usage_bars_fill_used" = "Harcanan miktara göre"; +"reset_times_title" = "Sıfırlama zamanları"; +"reset_times_countdown" = "Geri sayım"; +"reset_times_clock" = "Saat"; +"cost_summary_title" = "Maliyet özeti"; +"cost_summary_off" = "Kapalı"; +"merge_icons_title" = "Simgeleri Birleştir"; +"merge_icons_subtitle" = "Sağlayıcı değiştiricisiyle tek bir menü çubuğu simgesi kullan."; +"show_most_used_provider_title" = "En çok kullanılan sağlayıcıyı göster"; +"show_most_used_provider_subtitle" = "Menü çubuğu, hız limitine en yakın sağlayıcıyı otomatik gösterir."; +"display_mode_title" = "Görünüm modu"; +"display_mode_subtitle" = "Menü çubuğunda ne gösterileceğini seçin (Hız, kullanımı beklentiyle karşılaştırır)."; +"show_quota_warning_markers_title" = "Kota uyarı işaretlerini göster"; +"show_quota_warning_markers_subtitle" = "Kota uyarıları yapılandırıldığında kullanım çubuklarına eşik çizgileri çizer."; +"weekly_progress_work_days_title" = "Haftalık ilerleme iş günleri"; +"weekly_progress_work_days_subtitle" = "Haftalık kullanım çubuğu işaretleri ve tempo hesaplamaları için iş günlerini ayarlar."; +"show_provider_changelog_links_title" = "Sağlayıcı değişiklik günlüğü bağlantılarını göster"; +"show_provider_changelog_links_subtitle" = "Desteklenen CLI destekli sağlayıcılar için menüye sürüm notları bağlantıları ekler."; +"show_credits_extra_usage_title" = "Krediler + ekstra kullanımı göster"; +"show_credits_extra_usage_subtitle" = "Menüde Codex Kredileri ve Claude Ekstra kullanım bölümlerini göster."; +"multi_account_layout_title" = "Çoklu hesap düzeni"; +"multi_account_layout_subtitle" = "Bölümlü hesap değiştirme veya yığınlı hesap kartları seçin."; +"multi_account_layout_segmented" = "Bölümlü"; +"multi_account_layout_stacked" = "Yığınlı"; +"overview_tab_providers_title" = "Genel Bakış sekmesi sağlayıcıları"; +"configure" = "Yapılandır…"; +"overview_enable_merge_icons_hint" = "Genel Bakış sekmesi sağlayıcılarını yapılandırmak için Simgeleri Birleştir'i etkinleştirin."; +"overview_no_providers_hint" = "Genel Bakış için kullanılabilir etkin sağlayıcı yok."; +"overview_rows_follow_order" = "Genel Bakış satırları her zaman sağlayıcı sırasını takip eder."; +"overview_no_providers_selected" = "Sağlayıcı seçilmedi"; +"agent_sessions_title" = "Ajan oturumları"; +"agent_sessions_subtitle" = "Menüde yerel ve SSH ile keşfedilen Codex ve Claude Code oturumlarını göster."; +"agent_sessions_hosts_title" = "Ek SSH sunucuları"; +"agent_sessions_footer" = "Tailnet'inizdeki Mac'ler otomatik olarak keşfedilir. Yerel oturumlar 30 saniyede bir; uzak sunucular 60 saniyede bir ve menü açıldığında yenilenir."; +"agent_session_labels_title" = "Oturum etiketleri"; +"agent_session_labels_subtitle" = "Ajan oturumlarının nasıl adlandırılacağını seçin."; +"agent_session_label_project" = "Proje"; +"agent_session_label_descriptive" = "Açıklayıcı"; +"agent_session_label_descriptive_and_project" = "Açıklayıcı + proje"; +"agent_session_unknown_project" = "Bilinmeyen proje"; + +/* Advanced Pane */ +"section_keyboard_shortcut" = "Klavye kısayolu"; +"open_menu_shortcut_title" = "Menüyü aç"; +"open_menu_shortcut_subtitle" = "Menü çubuğu menüsünü herhangi bir yerden tetikleyin."; +"install_cli" = "CLI Kur"; +"install_cli_subtitle" = "CodexBarCLI'yi codexbar olarak /usr/local/bin ve /opt/homebrew/bin dizinlerine sembolik bağlayın."; +"cli_not_found" = "CodexBarCLI uygulama paketinde bulunamadı."; +"no_writable_bin_dirs" = "Yazılabilir bin dizini bulunamadı."; +"show_debug_settings_title" = "Hata Ayıklama Ayarlarını Göster"; +"show_debug_settings_subtitle" = "Sorun giderme araçlarını Hata Ayıklama sekmesinde göster."; +"surprise_me_title" = "Beni şaşırt"; +"surprise_me_subtitle" = "Ajanlarınızın yukarıda biraz eğlenmesini istiyorsanız işaretleyin."; +"hide_personal_info_title" = "Kişisel bilgileri gizle"; +"hide_personal_info_subtitle" = "Menü çubuğu ve menü arayüzünde e-posta adreslerini gizle."; +"show_provider_storage_usage_title" = "Sağlayıcı depolama kullanımını göster"; +"show_provider_storage_usage_subtitle" = "Menülerde yerel disk kullanımını göster. Bilinen sağlayıcıya ait yolları arka planda tarar."; +"section_keychain_access" = "Anahtarlık erişimi"; +"keychain_access_caption" = "Tüm Anahtarlık okuma ve yazmalarını devre dışı bırakın. macOS 'Chrome/Brave/Edge Güvenli Depolama' için sürekli istem gösteriyorsa ve Her Zaman İzin Ver'e tıkladıktan sonra bile devam ediyorsa bunu kullanın. Etkinleştirildiğinde tarayıcı çerez içe aktarımı kullanılamaz; Sağlayıcılar bölümünden Çerez başlıklarını el ile yapıştırın. CLI üzerinden Claude/Codex OAuth hâlâ çalışır."; +"disable_keychain_access_title" = "Anahtarlık erişimini devre dışı bırak"; +"disable_keychain_access_subtitle" = "Etkinleştirildiğinde tüm Anahtarlık erişimini engeller."; + +/* About Pane */ +"about_tagline" = "Jetonlarınız hiç bitmesin—ajan limitlerini göz önünde bulundurun."; +"link_github" = "GitHub"; +"link_website" = "Web Sitesi"; +"link_twitter" = "Twitter"; +"link_email" = "E-posta"; +"check_updates_auto" = "Güncellemeleri otomatik denetle"; +"update_channel" = "Güncelleme Kanalı"; +"check_for_updates" = "Güncellemeleri Denetle…"; +"updates_unavailable" = "Bu derlemede güncellemeler kullanılamıyor."; +"copyright" = "© 2026 Peter Steinberger. MIT Lisansı."; + +/* Debug Pane */ +"section_logging" = "Günlükleme"; +"enable_file_logging" = "Dosya günlüğünü etkinleştir"; +"enable_file_logging_subtitle" = "Hata ayıklama için günlükleri %@ konumuna yaz."; +"verbosity_title" = "Ayrıntı düzeyi"; +"verbosity_subtitle" = "Ne kadar ayrıntının günlüğe kaydedileceğini denetler."; +"open_log_file" = "Günlük dosyasını aç"; +"force_animation_next_refresh" = "Sonraki yenilemede animasyonu zorla"; +"force_animation_next_refresh_subtitle" = "Sonraki yenilemeden sonra yükleme animasyonunu geçici olarak gösterir."; +"section_loading_animations" = "Yükleme animasyonları"; +"loading_animations_caption" = "Bir desen seçin ve menü çubuğunda yeniden oynatın. \"Rastgele\" mevcut davranışı korur."; +"animation_random_default" = "Rastgele (varsayılan)"; +"replay_selected_animation" = "Seçili animasyonu yeniden oynat"; +"blink_now" = "Şimdi yanıp sön"; +"section_probe_logs" = "Sorgu günlükleri"; +"probe_logs_caption" = "Hata ayıklama için en son sorgu çıktısını getirin; Kopyala tüm metni tutar."; +"fetch_log" = "Günlüğü getir"; +"copy" = "Kopyala"; +"save_to_file" = "Dosyaya kaydet"; +"load_parse_dump" = "Ayrıştırma dökümünü yükle"; +"rerun_provider_autodetect" = "Sağlayıcı otomatik algılamayı yeniden çalıştır"; +"loading" = "Yükleniyor…"; +"no_log_yet_fetch" = "Henüz günlük yok. Getirmek için tıklayın."; +"section_fetch_strategy" = "Getirme stratejisi denemeleri"; +"fetch_strategy_caption" = "Bir sağlayıcı için son_getirme işlem hattı kararları ve hataları."; +"section_openai_cookies" = "OpenAI çerezleri"; +"openai_cookies_caption" = "Son OpenAI çerez girişiminden çerez içe aktarımı + WebKit kazıma günlükleri."; +"no_log_yet" = "Henüz günlük yok. Bir içe aktarım çalıştırmak için Sağlayıcılar → Codex bölümünde OpenAI çerezlerini güncelleyin."; +"section_caches" = "Önbellekler"; +"caches_caption" = "Önbelleğe alınmış maliyet tarama sonuçlarını veya tarayıcı çerez önbelleklerini temizleyin."; +"clear_cookie_cache" = "Çerez önbelleğini temizle"; +"clear_cost_cache" = "Maliyet önbelleğini temizle"; +"section_notifications" = "Bildirimler"; +"notifications_caption" = "5 saatlik oturum penceresi için test bildirimlerini tetikleyin (tükendi/geri yüklendi)."; +"post_depleted" = "Tükendi bildirimi gönder"; +"post_restored" = "Geri yüklendi bildirimi gönder"; +"section_cli_sessions" = "CLI oturumları"; +"cli_sessions_caption" = "Bir sorgudan sonra Codex/Claude CLI oturumlarını canlı tutun. Varsayılan olarak veri yakalandıktan sonra çıkılır."; +"keep_cli_sessions_alive" = "CLI oturumlarını canlı tut"; +"keep_cli_sessions_alive_subtitle" = "Sorgular arası sökmeyi atla (yalnızca hata ayıklama)."; +"reset_cli_sessions" = "CLI oturumlarını sıfırla"; +"section_error_simulation" = "Hata simülasyonu"; +"error_simulation_caption" = "Düzen testi için menü kartına sahte bir hata mesajı enjekte edin."; +"set_menu_error" = "Menü hatası ayarla"; +"clear_menu_error" = "Menü hatasını temizle"; +"set_cost_error" = "Maliyet hatası ayarla"; +"clear_cost_error" = "Maliyet hatasını temizle"; +"section_cli_paths" = "CLI yolları"; +"cli_paths_caption" = "Çözülmüş Codex çalıştırılabilir dosyası ve PATH katmanları; başlangıç oturum açma PATH yakalaması (kısa zaman aşımı)."; +"codex_binary" = "Codex çalıştırılabilir dosyası"; +"claude_binary" = "Claude çalıştırılabilir dosyası"; +"effective_path" = "Etkili PATH"; +"unavailable" = "Kullanılamıyor"; +"login_shell_path" = "Oturum kabuğu PATH (başlangıç yakalaması)"; +"cleared" = "Temizlendi."; +"no_fetch_attempts" = "Henüz getirme denemesi yok."; +"macOS Tahoe can block menu bar apps in System Settings → Menu Bar → Allow in the Menu Bar. CodexBar is running, but macOS may be hiding its icon. Open Menu Bar settings and turn CodexBar on." = "macOS Tahoe, Sistem Ayarları → Menü Çubuğu → Menü Çubuğunda İzin Ver bölümünde menü çubuğu uygulamalarını engelleyebilir. CodexBar çalışıyor, ancak macOS simgesini gizliyor olabilir. Menü Çubuğu ayarlarını açın ve CodexBar'ı etkinleştirin."; + +/* Metric preferences */ +"metric_pref_automatic" = "Otomatik"; +"metric_pref_primary" = "Birincil"; +"metric_pref_secondary" = "İkincil"; +"metric_pref_tertiary" = "Üçüncül"; +"metric_pref_extra_usage" = "Ekstra kullanım"; +"metric_pref_average" = "Ortalama"; +"metric_mistral_payg" = "Pay-as-you-go"; +"metric_mistral_monthly_plan" = "Monthly Plan"; + +/* Display modes */ +"display_mode_percent" = "Yüzde"; +"display_mode_pace" = "Hız"; +"display_mode_both" = "İkisi de"; +"display_mode_percent_desc" = "Kalan/harcanan yüzdesini göster (ör. %45)"; +"display_mode_pace_desc" = "Hız göstergesini göster (ör. +%5)"; +"display_mode_both_desc" = "Hem yüzdeyi hem hızı göster (ör. %45 · +%5)"; + +/* Provider status */ +"status_operational" = "Çalışır durumda"; +"status_degraded" = "Düşük performans"; +"status_partial_outage" = "Kısmi kesinti"; +"status_major_outage" = "Büyük kesinti"; +"status_critical_issue" = "Kritik sorun"; +"status_maintenance" = "Bakım"; +"status_unknown" = "Durum bilinmiyor"; + +/* Refresh frequency */ +"refresh_manual" = "El ile"; +"refresh_1min" = "1 dak"; +"refresh_2min" = "2 dak"; +"refresh_5min" = "5 dak"; +"refresh_15min" = "15 dak"; +"refresh_30min" = "30 dak"; +"refresh_adaptive" = "Uyarlanabilir"; +"refresh_adaptive_agent_aware" = "Uyarlanabilir (ajan etkinliğine duyarlı)"; +"adaptive_activity_consent_title" = "Etkinliğe duyarlı yenilemeye izin verilsin mi?"; +"adaptive_activity_consent_message" = "Ajan etkinliğine duyarlı Uyarlanabilir mod, Codex ve Claude'u tanımak için komut satırları dahil yerel çalışan işlemler listesini inceleyebilir, ardından siz kod yazarken bilinen oturum meta verilerini 30 saniyede bir okuyabilir. Agent Sessions kapalıyken CodexBar bellekte yalnızca en son etkinlik zamanını kullanır ve oturum yolları ile kimliklerini atar. Bu veriler hiçbir yere gönderilmez; uzaktan keşif ve SSH kapalı kalır. Reddederseniz CodexBar yerel etkinlik taraması olmadan normal Uyarlanabilir moda döner."; +"adaptive_activity_consent_allow" = "Yerel Etkinliğe İzin Ver"; +"adaptive_activity_consent_decline" = "Normal Uyarlanabilir Modu Kullan"; + +/* Additional keys */ +"not_found" = "Bulunamadı"; + +/* Cost estimation */ +"cost_estimate_hint" = "Yerel günlüklerden tahmini · faturanızdan farklı olabilir"; +"codex_api_estimate_hint" = "Token kullanımından tahmin edilmiştir · abonelik faturası değildir"; +"cost_data_explanation" = "Maliyetler sağlayıcı tarafından bildirilebilir veya herkese açık API fiyatlarıyla token kullanımından tahmin edilebilir. Tahminler abonelik ücreti değildir."; +"No JetBrains IDE with AI Assistant detected. Install a JetBrains IDE and enable AI Assistant." = "AI Asistan içeren JetBrains IDE algılanmadı. Bir JetBrains IDE kurun ve AI Asistan'ı etkinleştirin."; +"OpenRouter API token not configured. Set OPENROUTER_API_KEY environment variable or configure in Settings." = "OpenRouter API jetonu yapılandırılmamış. OPENROUTER_API_KEY ortam değişkenini ayarlayın veya Ayarlar'dan yapılandırın."; +"z.ai API token not found. Set apiKey in ~/.codexbar/config.json or Z_AI_API_KEY." = "z.ai API jetonu bulunamadı. ~/.codexbar/config.json dosyasında apiKey ayarlayın veya Z_AI_API_KEY kullanın."; +"Missing DeepSeek API key." = "DeepSeek API anahtarı eksik."; +"%@ is unavailable in the current environment." = "%@ geçerli ortamda kullanılamıyor."; +"All Systems Operational" = "Tüm Sistemler Çalışır Durumda"; +"Last 30 days" = "Son 30 gün"; +"Last 30 days:" = "Son 30 gün:"; +"This month" = "Bu ay"; +"Store multiple OpenAI API keys." = "Birden fazla OpenAI API anahtarı depolayın."; +"Admin API key" = "Yönetici API anahtarı"; +"Open billing" = "Faturalandırmayı aç"; +"Google accounts" = "Google hesapları"; +"Store multiple Antigravity Google OAuth accounts for quick switching." = "Hızlı geçiş için birden fazla Antigravity Google OAuth hesabı depolayın."; +"Add Google Account" = "Google Hesabı Ekle"; +"Open Token Plan" = "Jeton Planını Aç"; +"Text Generation" = "Metin Üretimi"; +"Text to Speech" = "Metinden Sese"; +"Music Generation" = "Müzik Üretimi"; +"Image Generation" = "Görsel Üretimi"; +"No local data found" = "Yerel veri bulunamadı"; +"Credits unavailable; keep Codex running to refresh." = "Krediler kullanılamıyor; yenilemek için Codex'i çalışır durumda tutun."; +"No available fetch strategy for minimax." = "MiniMax için kullanılabilir getirme stratejisi yok."; +"No Cursor session found. Please log in to cursor.com in Safari, Chrome, Microsoft Edge, Brave, Arc, Dia, ChatGPT Atlas, Chromium, Helium, Vivaldi, Yandex Browser, Firefox, Zen, Colibri, Sidekick, Opera, Opera GX, or Edge Canary. If you use Safari, grant CodexBar Full Disk Access in System Settings ▸ Privacy & Security. You can also sign in to Cursor from the CodexBar menu (Add / switch account)." = "Cursor oturumu bulunamadı. Lütfen Safari, Chrome, Microsoft Edge, Brave, Arc, Dia, ChatGPT Atlas, Chromium, Helium, Vivaldi, Yandex Browser, Firefox, Zen, Colibri, Sidekick, Opera, Opera GX veya Edge Canary üzerinden cursor.com'da oturum açın. Safari kullanıyorsanız, Sistem Ayarları ▸ Gizlilik ve Güvenlik bölümünde CodexBar'a Tam Disk Erişimi verin. Cursor'a CodexBar menüsünden de oturum açabilirsiniz (Hesap ekle / değiştir)."; +"No OpenCode session cookies found in browsers." = "Tarayıcılarda OpenCode oturum çerezi bulunamadı."; +"No available fetch strategy for %@." = "%@ için kullanılabilir getirme stratejisi yok."; +"Today" = "Bugün"; +"Today tokens" = "Bugünkü jetonlar"; +"30d cost" = "30 günlük maliyet"; +"%@ cost" = "%@ maliyeti"; +"30d tokens" = "30 günlük jetonlar"; +"Latest tokens" = "Son jetonlar"; +"Top model" = "En çok kullanılan model"; +"Storage" = "Depolama"; +"Add Account..." = "Hesap Ekle..."; +"Usage Dashboard" = "Kullanım Paneli"; +"Status Page" = "Durum Sayfası"; +"Open Status Page" = "Durum Sayfasını Aç"; +"Settings..." = "Ayarlar..."; +"About CodexBar" = "CodexBar Hakkında"; +"Quit" = "Çık"; +"Last %d day" = "Son %d gün"; +"Last %d days" = "Son %d gün"; +"%@ tokens" = "%@ jeton"; +"Latest billing day" = "Son faturalandırma günü"; +"Latest billing day (%@)" = "Son faturalandırma günü (%@)"; +"%@ left" = "%@ kaldı"; +"Resets %@" = "Sıfırlanma: %@"; +"Resets in %@" = "%@ içinde sıfırlanır"; +"Resets now" = "Şimdi sıfırlanır"; +"reset_tomorrow_format" = "yarın, %@"; +"Lasts until reset" = "Sıfırlamaya kadar sürer"; +"1.5× headroom" = "1,5× pay"; +"Updated %@" = "Güncellendi: %@"; +"Updated relative %@" = "Güncellendi: %@"; +"Updated absolute %@" = "Güncellendi: %@"; +"Updated %@h ago" = "%@ saat önce güncellendi"; +"Updated %@m ago" = "%@ dakika önce güncellendi"; +"Updated just now" = "Az önce güncellendi"; +"Projected empty in %@" = "%@ içinde tükenmesi tahmin ediliyor"; +"Runs out in %@" = "%@ içinde biter"; +"Pace: %@" = "Hız: %@"; +"Pace: %@ · %@" = "Hız: %@ · %@"; +"%@ · %@" = "%@ · %@"; +"≈ %d%% run-out risk" = "≈ %%%d tükenme riski"; +"%d%% in deficit" = "%%%d açıkta"; +"%d%% in reserve" = "%%%d rezervde"; +"usage_percent_suffix_left" = "kaldı"; +"usage_percent_suffix_used" = "kullanıldı"; +"Store multiple DeepSeek API keys." = "Birden fazla DeepSeek API anahtarı depolayın."; +"This week" = "Bu hafta"; +"Week" = "Hafta"; +"Month" = "Ay"; +"Models" = "Modeller"; +"24h tokens" = "24 saatlik jetonlar"; +"Latest hour" = "Son saat"; +"Peak hour" = "Yoğun saat"; +"Top method" = "En çok kullanılan yöntem"; +"30d cash" = "30 günlük nakit"; +"30d billing history from MiniMax web session" = "MiniMax web oturumundan 30 günlük faturalandırma geçmişi"; +"AWS Cost Explorer billing can lag." = "AWS Cost Explorer faturalandırması gecikmeli olabilir."; +"Rate limit: %d / %@" = "Hız limiti: %d / %@"; +"Key remaining" = "Anahtar kalan"; +"No limit set for the API key" = "API anahtarı için limit ayarlanmamış"; +"API key limit unavailable right now" = "API anahtarı limiti şu anda kullanılamıyor"; +"This month: %@ tokens" = "Bu ay: %@ jeton"; +"No utilization data yet." = "Henüz kullanım verisi yok."; +"No %@ utilization data yet." = "Henüz %@ kullanım verisi yok."; +"%@: %@%% used" = "%@: %@%% kullanıldı"; +"%dd" = "%d gün"; +"today" = "bugün"; +"just now" = "az önce"; +"On pace" = "Hızda"; +"Runs out now" = "Şimdi biter"; +"Projected empty now" = "Şimdi tükenmesi tahmin ediliyor"; +"Switch Account..." = "Hesap Değiştir..."; +"Update ready, restart now?" = "Güncelleme hazır, şimdi yeniden başlatılsın mı?"; +"Daily" = "Günlük"; +"Hourly Tokens" = "Saatlik Jetonlar"; +"No data" = "Veri yok"; +"No usage breakdown data available." = "Kullanılabilir kullanım dağılım verisi yok."; + +"Today: %@ · %@ tokens" = "Bugün: %@ · %@ jeton"; +"Today: %@" = "Bugün: %@"; +"Today: %@ tokens" = "Bugün: %@ jeton"; +"Last 30 days: %@ · %@ tokens" = "Son 30 gün: %@ · %@ jeton"; +"Last 30 days: %@" = "Son 30 gün: %@"; +"Est. total (30d): %@" = "Tah. toplam (30 gün): %@"; +"Est. total (%@): %@" = "Tah. toplam (%@): %@"; +"Hover a bar for details" = "Ayrıntılar için bir çubuğun üzerine gelin"; +"%@: %@ · %@ tokens" = "%@: %@ · %@ jeton"; +"%@: %@" = "%@: %@"; +"No providers selected for Overview." = "Genel Bakış için sağlayıcı seçilmedi."; +"No overview data available." = "Kullanılabilir genel bakış verisi yok."; +"Auto uses the local IDE API first, then Google OAuth when the IDE is closed." = "Otomatik mod önce yerel IDE API'sini kullanır, IDE kapatıldığında Google OAuth'a geçer."; +"Login with Google" = "Google ile Giriş Yap"; + +/* Popup panels */ +"No usage configured." = "Kullanım yapılandırılmamış."; +"Quota" = "Kota"; +"Daily quota" = "Günlük kota"; +"Total" = "Toplam"; +"tokens" = "jeton"; +"requests" = "istek"; +"Latest" = "Son"; +"Monthly" = "Aylık"; +"Sonnet" = "Sonnet"; +"Overages" = "Aşmalar"; +"Activity" = "Etkinlik"; +"Copied" = "Kopyalandı"; +"Copy error" = "Hata kopyala"; +"Copy path" = "Yolu kopyala"; +"Extra usage spent" = "Harcanan ekstra kullanım"; +"Credits remaining" = "Kalan krediler"; +"Using CLI fallback" = "CLI yedek kullanılıyor"; +"Balance updates in near-real time (up to 5 min lag)" = "Bakiye neredeyse gerçek zamanlı güncellenir (en fazla 5 dk gecikme)"; +"Daily billing data finalizes at 07:00 UTC" = "Günlük faturalandırma verisi 07:00 UTC'de kesinleşir"; +"%@ of %@ credits left" = "%@ / %@ kredi kaldı"; +"%@ of %@ bonus credits left" = "%@ / %@ bonus kredi kaldı"; +"%@ / %@ (%@ remaining)" = "%@ / %@ (%@ kaldı)"; +"%@/%@ left" = "%@/%@ kaldı"; +"Gemini Flash" = "Gemini Flash"; +"Regenerates %@" = "Yenilenme: %@"; +"used after next regen" = "sonraki yenilemeden sonra kullanıldı"; +"after next regen" = "sonraki yenilemeden sonra"; +"Near full" = "Neredeyse dolu"; +"Full in ~1 regen" = "~1 yenilemede dolacak"; +"Full in ~%.0f regens" = "~%.0f yenilemede dolacak"; +"Overage usage" = "Aşım kullanımı"; +"Overage cost" = "Aşım maliyeti"; +"credits" = "kredi"; +"Zen balance" = "Zen bakiye"; +"API spend" = "API harcaması"; +"Extra usage" = "Ekstra kullanım"; +"Quota usage" = "Kota kullanımı"; +"Your spend" = "Harcamanız"; +"%.0f%% used" = "%%%.0f kullanıldı"; +"Usage history (today)" = "Kullanım geçmişi (bugün)"; +"Usage history (%d days)" = "Kullanım geçmişi (%d gün)"; +"%d percent remaining" = "%%%d kaldı"; +"Unknown" = "Bilinmiyor"; +"stale data" = "eski veri"; +"No credits history data." = "Kredi geçmişi verisi yok."; +"No credits history data available." = "Kullanılabilir kredi geçmişi verisi yok."; +"Credits history chart" = "Kredi geçmişi grafiği"; +"%d days of credits data" = "%d günlük kredi verisi"; +"Usage breakdown chart" = "Kullanım dağılım grafiği"; +"%d days of usage data across %d services" = "%d hizmet için %d günlük kullanım verisi"; +"Cost history chart" = "Maliyet geçmişi grafiği"; +"%d days of cost data" = "%d günlük maliyet verisi"; +"Plan utilization chart" = "Plan kullanım grafiği"; +"%d utilization samples" = "%d kullanım örneği"; +"Hourly Usage" = "Saatlik Kullanım"; +"Usage remaining" = "Kalan kullanım"; +"Usage used" = "Harcanan kullanım"; +"API key verified. Cloud quotas need browser cookies. Sign in to Ollama." = "API anahtarı doğrulandı. Cloud kotaları için tarayıcı çerezleri gerekir. Ollama'da oturum açın."; +"Last 30 days: %@ tokens" = "Son 30 gün: %@ jeton"; +"7d spend" = "7 günlük harcama"; +"30d spend" = "30 günlük harcama"; +"Cache read" = "Önbellek okuması"; +"Claude Admin API 30 day spend trend" = "Claude Yönetici API 30 günlük harcama trendi"; +"OpenRouter API key spend trend" = "OpenRouter API anahtarı harcama trendi"; +"z.ai hourly token trend" = "z.ai saatlik jeton trendi"; +"MiniMax 30 day token usage trend" = "MiniMax 30 günlük jeton kullanım trendi"; +"Today cash" = "Bugünkü nakit"; +"DeepSeek 30 day token usage trend" = "DeepSeek 30 günlük jeton kullanım trendi"; +"DeepSeek this month token usage trend" = "DeepSeek bu ayki jeton kullanım trendi"; +"Chrome profile" = "Chrome profili"; +"Choose which signed-in DeepSeek Platform session supplies detailed usage." = "Ayrıntılı kullanımı sağlayacak, oturum açılmış DeepSeek Platform oturumunu seçin."; +"Detailed usage unavailable." = "Ayrıntılı kullanım kullanılamıyor."; +"Sign in to DeepSeek Platform in Chrome for detailed usage." = "Ayrıntılı kullanım için Chrome'da DeepSeek Platform'a giriş yapın."; +"Select a DeepSeek Chrome profile in Settings." = "Ayarlarda bir DeepSeek Chrome profili seçin."; +"Select profile…" = "Profil seç…"; +"cache-hit input" = "önbellek-isabetli girdi"; +"cache-miss input" = "önbellek-kaçan girdi"; +"output" = "çıktı"; +"Requests" = "İstekler"; +"Reported by OpenAI Admin API organization usage." = "OpenAI Yönetici API kuruluş kullanımı tarafından bildirildi."; +"Reported by Mistral billing usage." = "Mistral faturalandırma kullanımı tarafından bildirildi."; +"Google OAuth" = "Google OAuth"; +"Add accounts via GitHub OAuth Device Flow on the selected host." = "Seçili sunucuda GitHub OAuth Cihaz Akışı ile hesap ekleyin."; +"Stores each signed-in Google account for quick Antigravity switching. Uses Antigravity.app OAuth when available, or ANTIGRAVITY_OAUTH_CLIENT_ID and ANTIGRAVITY_OAUTH_CLIENT_SECRET as an override." = "Hızlı Antigravity geçişi için oturum açmış her Google hesabını depolar. Kullanılabilir olduğunda Antigravity.app OAuth kullanır veya geçersiz kılma olarak ANTIGRAVITY_OAUTH_CLIENT_ID ve ANTIGRAVITY_OAUTH_CLIENT_SECRET kullanır."; +"Manual cleanup: past sessions" = "El ile temizlik: geçmiş oturumlar"; +"Clearing removes past resume, continue, and rewind history." = "Temizleme, geçmiş devam ettirme, sürdürme ve geri sarma geçmişini kaldırır."; +"Manual cleanup: file checkpoints" = "El ile temizlik: dosya kontrol noktaları"; +"Clearing removes checkpoint restore data for previous edits." = "Temizleme, önceki düzenlemeler için kontrol noktası geri yükleme verisini kaldırır."; +"Manual cleanup: saved plans" = "El ile temizlik: kaydedilen planlar"; +"Clearing removes old plan-mode files." = "Temizleme, eski plan modu dosyalarını kaldırır."; +"Manual cleanup: debug logs" = "El ile temizlik: hata ayıklama günlükleri"; +"Clearing removes past debug logs." = "Temizleme, geçmiş hata ayıklama günlüklerini kaldırır."; +"Manual cleanup: attachment cache" = "El ile temizlik: ek önbelleği"; +"Clearing removes cached large pastes or attached images." = "Temizleme, önbelleğe alınmış büyük yapıştırmaları veya eklenmiş görselleri kaldırır."; +"Manual cleanup: session metadata" = "El ile temizlik: oturum üst verisi"; +"Clearing removes per-session environment metadata." = "Temizleme, oturum bazlı ortam üst verisini kaldırır."; +"Manual cleanup: shell snapshots" = "El ile temizlik: kabuk anlık görüntüleri"; +"Clearing removes leftover runtime shell snapshot files." = "Temizleme, kalan çalışma zamanı kabuk anlık görüntü dosyalarını kaldırır."; +"Manual cleanup: legacy todos" = "El ile temizlik: eski yapılacaklar"; +"Clearing removes legacy per-session task lists." = "Temizleme, eski oturum bazlı görev listelerini kaldırır."; +"Manual cleanup: sessions" = "El ile temizlik: oturumlar"; +"Clearing removes past Codex session history." = "Temizleme, geçmiş Codex oturum geçmişini kaldırır."; +"Manual cleanup: archived sessions" = "El ile temizlik: arşivlenmiş oturumlar"; +"Clearing removes archived Codex session history." = "Temizleme, arşivlenmiş Codex oturum geçmişini kaldırır."; +"Manual cleanup: cache" = "El ile temizlik: önbellek"; +"Clearing removes provider-owned cached data." = "Temizleme, sağlayıcıya ait önbelleğe alınmış verileri kaldırır."; +"Manual cleanup: logs" = "El ile temizlik: günlükler"; +"Clearing removes local diagnostic logs." = "Temizleme, yerel tanılama günlüklerini kaldırır."; +"Manual cleanup: file history" = "El ile temizlik: dosya geçmişi"; +"Clearing removes local edit checkpoint history." = "Temizleme, yerel düzenleme kontrol noktası geçmişini kaldırır."; +"Manual cleanup: temporary data" = "El ile temizlik: geçici veriler"; +"Clearing removes local temporary provider data." = "Temizleme, yerel geçici sağlayıcı verilerini kaldırır."; +"Total: %@" = "Toplam: %@"; +"%d more items" = "%d öğe daha"; +"Other (%d items)" = "Diğer (%d öğe)"; +"Expand" = "Genişlet"; +"Collapse" = "Daralt"; +"Cleanup ideas" = "Temizlik önerileri"; +"%d unreadable item(s) skipped" = "%d okunamaz öğe atlandı"; +"API key limit" = "API anahtarı limiti"; +"Auth" = "Kimlik Doğr."; +"Auto" = "Otomatik"; +"Disabled — no recent data" = "Devre dışı — son veri yok"; +"Limits not available" = "Limitler kullanılamıyor"; +"No usage yet" = "Henüz kullanım yok"; +"Not fetched yet" = "Henüz getirilmedi"; +"Refreshing" = "Yenileniyor"; +"Session" = "Oturum"; +"Source" = "Kaynak"; +"State" = "Durum"; +"Unavailable" = "Kullanılamıyor"; +"Weekly" = "Haftalık"; +"not detected" = "algılanmadı"; +"Estimated from local Codex logs for the selected account." = "Seçili hesap için yerel Codex günlüklerinden tahmin edildi."; +"minimax_usage_amount_format" = "Kullanım: %@ / %@"; +"minimax_used_percent_format" = "%@ kullanıldı"; +"minimax_service_text_generation" = "Metin Üretimi"; +"minimax_service_text_to_speech" = "Metinden Sese"; +"minimax_service_music_generation" = "Müzik Üretimi"; +"minimax_service_image_generation" = "Görsel Üretimi"; +"minimax_service_lyrics_generation" = "Şarkı sözü üretimi"; +"minimax_service_coding_plan_vlm" = "Kodlama planı VLM"; +"minimax_service_coding_plan_search" = "Kodlama planı arama"; + +/* Additional provider settings and alerts */ +"%@ is waiting for permission" = "%@ izin bekliyor"; +"%@ requests" = "%@ istek"; +"%@: %@ credits" = "%@: %@ kredi"; +"30d requests" = "30 günlük istekler"; +"4 days" = "4 gün"; +"5 days" = "5 gün"; +"7 days" = "7 gün"; +"API key verifies Ollama Cloud access; cookies still expose quota limits." = "API anahtarı Ollama Cloud erişimini doğrular; çerezler kota limitlerini göstermeye devam eder."; +"AWS access key ID. Can also be set with AWS_ACCESS_KEY_ID." = "AWS erişim anahtarı kimliği. AWS_ACCESS_KEY_ID ile de ayarlanabilir."; +"AWS region. Can also be set with AWS_REGION." = "AWS bölgesi. AWS_REGION ile de ayarlanabilir."; +"AWS secret access key. Can also be set with AWS_SECRET_ACCESS_KEY." = "AWS gizli erişim anahtarı. AWS_SECRET_ACCESS_KEY ile de ayarlanabilir."; +"Access key ID" = "Erişim anahtarı kimliği"; +"Add Account" = "Hesap Ekle"; +"Adding Account…" = "Hesap Ekleniyor…"; +"Antigravity login failed" = "Antigravity girişi başarısız"; +"Antigravity login timed out" = "Antigravity girişi zaman aşımına uğradı"; +"Auth source" = "Kimlik doğrulama kaynağı"; +"Automatic imports browser cookies from Xiaomi MiMo." = "Xiaomi MiMo'dan tarayıcı çerezlerini otomatik olarak içe aktarır."; +"Automatic imports Windsurf session data from Chromium browser localStorage." = "Otomatik olarak Chromium tarayıcı yerel depolamasından Windsurf oturum verilerini içe aktarır."; +"Automatic imports browser cookies from Bailian." = "Otomatik olarak Bailian'dan tarayıcı çerezlerini içe aktarır."; +"Automatically imports browser cookies." = "Otomatik olarak tarayıcı çerezlerini içe aktarır."; +"Automatically imports browser session cookies." = "Otomatik olarak tarayıcı oturum çerezlerini içe aktarır."; +"Azure OpenAI deployment name. AZURE_OPENAI_DEPLOYMENT_NAME is also supported." = "Azure OpenAI dağıtım adı. AZURE_OPENAI_DEPLOYMENT_NAME de desteklenir."; +"Azure OpenAI key" = "Azure OpenAI anahtarı"; +"Azure OpenAI resource endpoint. AZURE_OPENAI_ENDPOINT is also supported." = "Azure OpenAI kaynak uç noktası. AZURE_OPENAI_ENDPOINT de desteklenir."; +"Base URL" = "Temel URL"; +"Base URL for the LLM-API-Key-Proxy instance." = "LLM-API-Key-Proxy örneği için temel URL."; +"Browser cookies" = "Tarayıcı çerezleri"; +"Cap end" = "Kapasite sonu"; +"Cap start" = "Kapasite başlangıcı"; +"Capacity End" = "Kapasite Sonu"; +"Capacity Start" = "Kapasite Başlangıcı"; +"Changelog" = "Değişiklik Günlüğü"; +"Choose the Moonshot/Kimi API host for international or China mainland accounts." = "Uluslararası veya Çin anakarası hesapları için Moonshot/Kimi API sunucusunu seçin."; +"CodexBar can't replace a system account that is signed in with an API key only setup." = "CodexBar, yalnızca API anahtarı ile oturum açmış bir sistem hesabını değiştiremez."; +"CodexBar could not find saved auth for that account. Re-authenticate it and try again." = "CodexBar bu hesap için kaydedilmiş kimlik doğrulaması bulamadı. Yeniden kimlik doğrulayın ve tekrar deneyin."; +"CodexBar could not read managed account storage. Recover the store before adding another account." = "CodexBar yönetilen hesap depolamasını okuyamadı. Başka bir hesap eklemeden önce depoyu kurtarın."; +"CodexBar could not read saved auth for that account. Re-authenticate it and try again." = "CodexBar bu hesap için kaydedilmiş kimlik doğrulamasını okuyamadı. Yeniden kimlik doğrulayın ve tekrar deneyin."; +"CodexBar could not read the current system account on this Mac." = "CodexBar bu Mac'teki geçerli sistem hesabını okuyamadı."; +"CodexBar could not replace the live Codex auth on this Mac." = "CodexBar bu Mac'teki canlı Codex kimlik doğrulamasını değiştiremedi."; +"CodexBar could not safely preserve the current system account before switching." = "CodexBar geçiş yapmadan önce geçerli sistem hesabını güvenli bir şekilde koruyamadı."; +"CodexBar could not save the current system account before switching." = "CodexBar geçiş yapmadan önce geçerli sistem hesabını kaydedemedi."; +"CodexBar could not update managed account storage." = "CodexBar yönetilen hesap depolamasını güncelleyemedi."; +"CodexBar found another managed account that already uses the current system account. Resolve the duplicate account before switching." = "CodexBar, geçerli sistem hesabını zaten kullanan başka bir yönetilen hesap buldu. Geçiş yapmadan önce yinelenen hesabı çözün."; +"CodexBar will ask macOS Keychain for “%@” so it can decrypt browser cookies and authenticate your account. Click OK to continue." = "CodexBar, tarayıcı çerezlerinin şifresini çözmek ve hesabınızı doğrulamak için macOS Anahtarlığı'ndan “%@” isteyecek. Devam etmek için Tamam'a tıklayın."; +"CodexBar will ask macOS Keychain for the Claude Code OAuth token so it can fetch your Claude usage. Click OK to continue." = "CodexBar, Claude kullanımınızı getirmek için macOS Anahtarlığı'ndan Claude Code OAuth jetonunu isteyecek. Devam etmek için Tamam'a tıklayın."; +"CodexBar will ask macOS Keychain for your Amp cookie header so it can fetch usage. Click OK to continue." = "CodexBar, kullanımı getirmek için macOS Anahtarlığı'ndan Amp çerez başlığınızı isteyecek. Devam etmek için Tamam'a tıklayın."; +"CodexBar will ask macOS Keychain for your Augment cookie header so it can fetch usage. Click OK to continue." = "CodexBar, kullanımı getirmek için macOS Anahtarlığı'ndan Augment çerez başlığınızı isteyecek. Devam etmek için Tamam'a tıklayın."; +"CodexBar will ask macOS Keychain for your Claude cookie header so it can fetch Claude web usage. Click OK to continue." = "CodexBar, Claude web kullanımını getirmek için macOS Anahtarlığı'ndan Claude çerez başlığınızı isteyecek. Devam etmek için Tamam'a tıklayın."; +"CodexBar will ask macOS Keychain for your Cursor cookie header so it can fetch usage. Click OK to continue." = "CodexBar, kullanımı getirmek için macOS Anahtarlığı'ndan Cursor çerez başlığınızı isteyecek. Devam etmek için Tamam'a tıklayın."; +"CodexBar will ask macOS Keychain for your Factory cookie header so it can fetch usage. Click OK to continue." = "CodexBar, kullanımı getirmek için macOS Anahtarlığı'ndan Factory çerez başlığınızı isteyecek. Devam etmek için Tamam'a tıklayın."; +"CodexBar will ask macOS Keychain for your GitHub Copilot token so it can fetch usage. Click OK to continue." = "CodexBar, kullanımı getirmek için macOS Anahtarlığı'ndan GitHub Copilot jetonunuzu isteyecek. Devam etmek için Tamam'a tıklayın."; +"CodexBar will ask macOS Keychain for your Kimi auth token so it can fetch usage. Click OK to continue." = "CodexBar, kullanımı getirmek için macOS Anahtarlığı'ndan Kimi kimlik doğrulama jetonunuzu isteyecek. Devam etmek için Tamam'a tıklayın."; +"CodexBar will ask macOS Keychain for your MiniMax API token so it can fetch usage. Click OK to continue." = "CodexBar, kullanımı getirmek için macOS Anahtarlığı'ndan MiniMax API jetonunuzu isteyecek. Devam etmek için Tamam'a tıklayın."; +"CodexBar will ask macOS Keychain for your MiniMax cookie header so it can fetch usage. Click OK to continue." = "CodexBar, kullanımı getirmek için macOS Anahtarlığı'ndan MiniMax çerez başlığınızı isteyecek. Devam etmek için Tamam'a tıklayın."; +"CodexBar will ask macOS Keychain for your OpenAI cookie header so it can fetch Codex dashboard extras. Click OK to continue." = "CodexBar, Codex panel eklerini getirmek için macOS Anahtarlığı'ndan OpenAI çerez başlığınızı isteyecek. Devam etmek için Tamam'a tıklayın."; +"CodexBar will ask macOS Keychain for your OpenCode cookie header so it can fetch usage. Click OK to continue." = "CodexBar, kullanımı getirmek için macOS Anahtarlığı'ndan OpenCode çerez başlığınızı isteyecek. Devam etmek için Tamam'a tıklayın."; +"CodexBar will ask macOS Keychain for your Synthetic API key so it can fetch usage. Click OK to continue." = "CodexBar, kullanımı getirmek için macOS Anahtarlığı'ndan Synthetic API anahtarınızı isteyecek. Devam etmek için Tamam'a tıklayın."; +"CodexBar will ask macOS Keychain for your z.ai API token so it can fetch usage. Click OK to continue." = "CodexBar, kullanımı getirmek için macOS Anahtarlığı'ndan z.ai API jetonunuzu isteyecek. Devam etmek için Tamam'a tıklayın."; +"Could not open Cursor login in your browser." = "Tarayıcınızda Cursor girişi açılamadı."; +"Could not open browser for Antigravity" = "Antigravity için tarayıcı açılamadı"; +"Credits used" = "Kullanılan krediler"; +"Day" = "Gün"; +"Deployment" = "Dağıtım"; +"Drag to reorder" = "Yeniden sıralamak için sürükleyin"; +"Sort providers alphabetically" = "Sağlayıcıları alfabetik sırala"; +"Sort providers alphabetically (enabled first)" = "Sağlayıcıları alfabetik sırala (etkin olanlar önce)"; +"Sorted alphabetically (enabled first) — click to use your custom order" = "Alfabetik sıralandı (etkin olanlar önce) — özel sıranızı kullanmak için tıklayın"; +"Endpoint" = "Uç nokta"; +"Enterprise host" = "Kurumsal sunucu"; +"Extra usage balance: %@" = "Ekstra kullanım bakiyesi: %@"; +"Keychain Access Required" = "Anahtarlık Erişimi Gerekli"; +"keychain_prompt_learn_more" = "Daha Fazla Bilgi…"; +"keychain_prompt_privacy_note" = "Mac oturum açma parolası girişini CodexBar değil macOS yönetir. Anahtarlık erişimini istediğiniz zaman Ayarlar → Gelişmiş bölümünden devre dışı bırakabilirsiniz."; +"Kiro menu bar value" = "Kiro menü çubuğu değeri"; +"Label" = "Etiket"; +"No organizations loaded. Click Refresh after setting your API key." = "Kuruluş yüklenmedi. API anahtarınızı ayarladıktan sonra Yenile'ye tıklayın."; +"No output captured." = "Çıktı yakalanmadı."; +"No system account" = "Sistem hesabı yok"; +"Oasis-Token" = "Oasis-Token"; +"Open Augment (Log Out & Back In)" = "Augment'i Aç (Oturumu Kapat ve Tekrar Aç)"; +"Open Codebuff Dashboard" = "Codebuff Panelini Aç"; +"Open Command Code Settings" = "Command Code Ayarlarını Aç"; +"Open Crof dashboard" = "Crof Panelini Aç"; +"Open Manus" = "Manus'u Aç"; +"Open MiMo Balance" = "MiMo Bakiyesini Aç"; +"Open Moonshot Console" = "Moonshot Konsolunu Aç"; +"Open Ollama API Keys" = "Ollama API Anahtarlarını Aç"; +"Open StepFun Platform" = "StepFun Platformunu Aç"; +"Open T3 Chat Settings" = "T3 Chat Ayarlarını Aç"; +"Open Volcengine Ark Console" = "Volcengine Ark Konsolunu Aç"; +"Open legacy provider docs" = "Eski sağlayıcı belgelerini aç"; +"Open projects" = "Projeleri aç"; +"Open this URL manually to continue login:\n\n%@" = "Girişe devam etmek için bu URL'yi el ile açın:\n\n%@"; +"Optional organization ID for accounts linked to multiple Anthropic organizations." = "Birden fazla Anthropic kuruluşuna bağlı hesaplar için isteğe bağlı kuruluş kimliği."; +"Optional. Applies to the configured Admin API key; selected token accounts do not inherit OPENAI_PROJECT_ID." = "İsteğe bağlı. Yapılandırılan Yönetici API anahtarına uygulanır; seçili jeton hesapları OPENAI_PROJECT_ID'yi devralmaz."; +"Optional. Enter your GitHub Enterprise host, for example octocorp.ghe.com. Leave blank for github.com." = "İsteğe bağlı. GitHub Enterprise sunucunuzu girin, örneğin octocorp.ghe.com. github.com için boş bırakın."; +"Optional. Leave blank to discover and aggregate projects visible to the API key." = "İsteğe bağlı. API anahtarına görünür projeleri keşfetmek ve toplamak için boş bırakın."; +"Org ID (optional)" = "Kuruluş Kimliği (isteğe bağlı)"; +"Organizations" = "Kuruluşlar"; +"Organization ID" = "Kuruluş Kimliği"; +"Password" = "Parola"; +"%@ authentication is disabled." = "%@ kimlik doğrulaması devre dışı."; +"%@ cookies are disabled." = "%@ çerezleri devre dışı."; +"%@ web API access is disabled." = "%@ web API erişimi devre dışı."; +"Disable %@ dashboard cookie usage." = "%@ panel çerez kullanımını devre dışı bırak."; +"Keychain access is disabled in Advanced, so browser cookie import is unavailable." = "Anahtarlık erişimi Gelişmiş bölümünde devre dışı bırakıldı, bu nedenle tarayıcı çerez içe aktarımı kullanılamıyor."; +"Manually paste an %@ from a browser session." = "Bir tarayıcı oturumundan %@ yapıştırın."; +"Paste a Cookie header captured from %@." = "%@ üzerinden yakalanan bir Çerez başlığı yapıştırın."; +"Paste a Cookie header from %@." = "%@ üzerinden bir Çerez başlığı yapıştırın."; +"Paste a Cookie header or cURL capture from %@." = "%@ üzerinden bir Çerez başlığı veya cURL yakalaması yapıştırın."; +"Paste a Cookie header or full cURL capture from %@." = "%@ üzerinden bir Çerez başlığı veya tam cURL yakalaması yapıştırın."; +"Paste a Cookie or Authorization header from %@." = "%@ üzerinden bir Çerez veya Yetkilendirme başlığı yapıştırın."; +"Paste a full cookie header or the %@ value." = "Tam bir çerez başlığı veya %@ değerini yapıştırın."; +"Paste a Cookie header or full cURL capture from T3 Chat settings." = "T3 Chat ayarlarından bir Çerez başlığı veya tam cURL yakalaması yapıştırın."; +"Paste the Cookie header from a request to admin.mistral.ai. Must contain an ory_session_* cookie." = "admin.mistral.ai'a yapılan bir istekten Çerez başlığını yapıştırın. Bir ory_session_* çerezi içermelidir."; +"Paste the Oasis-Token from a logged-in browser session on platform.stepfun.com." = "platform.stepfun.com'da oturum açmış bir tarayıcı oturumundan Oasis-Token yapıştırın."; +"Paste the %@ JSON bundle from %@." = "%@ üzerinden %@ JSON paketini yapıştırın."; +"Paste the %@ value or a full Cookie header." = "%@ değerini veya tam bir Çerez başlığı yapıştırın."; +"Personal account" = "Kişisel hesap"; +"Project ID" = "Proje Kimliği"; +"Re-auth" = "Yeniden doğrula"; +"Re-authenticating…" = "Yeniden doğrulanıyor…"; +"Refresh Session" = "Oturumu Yenile"; +"Refresh organizations" = "Kuruluşları yenile"; +"Region" = "Bölge"; +"Reload" = "Yeniden yükle"; +"Reorder" = "Yeniden sırala"; +"Secret access key" = "Gizli erişim anahtarı"; +"Series" = "Seri"; +"Service" = "Hizmet"; +"Show or hide Kiro credits, percent, or both next to the menu bar icon." = "Menü çubuğu simgesinin yanında Kiro kredilerini, yüzdeyi veya ikisini birden göster veya gizle."; +"Show usage for organizations you belong to. Personal account is always shown." = "Üye olduğunuz kuruluşların kullanımını göster. Kişisel hesap her zaman gösterilir."; +"Sign in to cursor.com in your browser, then refresh Cursor in CodexBar." = "Tarayıcınızda cursor.com'da oturum açın, ardından CodexBar'da Cursor'ı yenileyin."; +"Simulated error text" = "Simüle edilmiş hata metni"; +"StepFun platform account (phone number or email)." = "StepFun platform hesabı (telefon numarası veya e-posta)."; +"Stored in ~/.codexbar/config.json." = "~/.codexbar/config.json dosyasında depolandı."; +"Stored in ~/.codexbar/config.json. AZURE_OPENAI_API_KEY is also supported." = "~/.codexbar/config.json dosyasında depolandı. AZURE_OPENAI_API_KEY de desteklenir."; +"Stored in ~/.codexbar/config.json. For the official Kimi API, use Moonshot / Kimi API." = "~/.codexbar/config.json dosyasında depolandı. Resmi Kimi API'si için Moonshot / Kimi API kullanın."; +"Stored in ~/.codexbar/config.json. Get your API key from the Volcengine Ark console." = "~/.codexbar/config.json dosyasında depolandı. API anahtarınızı Volcengine Ark konsolundan alın."; +"Stored in ~/.codexbar/config.json. Get your key from Ollama settings." = "~/.codexbar/config.json dosyasında depolandı. Anahtarınızı Ollama ayarlarından alın."; +"Stored in ~/.codexbar/config.json. Get your key from console.deepgram.com." = "~/.codexbar/config.json dosyasında depolandı. Anahtarınızı console.deepgram.com'dan alın."; +"Stored in ~/.codexbar/config.json. Get your key from elevenlabs.io/app/settings/api-keys." = "~/.codexbar/config.json dosyasında depolandı. Anahtarınızı elevenlabs.io/app/settings/api-keys adresinden alın."; +"Stored in ~/.codexbar/config.json. Get your key from openrouter.ai/settings/keys and set a key spending limit there to enable API key quota tracking." = "~/.codexbar/config.json dosyasında depolandı. Anahtarınızı openrouter.ai/settings/keys adresinden alın ve API anahtarı kota izlemeyi etkinleştirmek için orada bir anahtar harcama limiti ayarlayın."; +"Stored in ~/.codexbar/config.json. In Warp, open Settings > Platform > API Keys, then create one." = "~/.codexbar/config.json dosyasında depolandı. Warp'ta Ayarlar > Platform > API Anahtarları'nı açın, ardından bir tane oluşturun."; +"Stored in ~/.codexbar/config.json. Metrics require Groq Enterprise Prometheus access." = "~/.codexbar/config.json dosyasında depolandı. Metrikler Groq Enterprise Prometheus erişimi gerektirir."; +"Stored in ~/.codexbar/config.json. OPENAI_ADMIN_KEY is preferred; OPENAI_API_KEY still works." = "~/.codexbar/config.json dosyasında depolandı. OPENAI_ADMIN_KEY tercih edilir; OPENAI_API_KEY hâlâ çalışır."; +"Stored in ~/.codexbar/config.json. Requires an Anthropic Admin API key." = "~/.codexbar/config.json dosyasında depolandı. Anthropic Yönetici API anahtarı gerektirir."; +"Stored in ~/.codexbar/config.json. Used for /v1/quota-stats." = "~/.codexbar/config.json dosyasında depolandı. /v1/quota-stats için kullanılır."; +"Stored in ~/.codexbar/config.json. You can also provide CODEBUFF_API_KEY or let CodexBar read ~/.config/manicode/credentials.json (created by `codebuff login`)." = "~/.codexbar/config.json dosyasında depolandı. Ayrıca CODEBUFF_API_KEY sağlayabilir veya CodexBar'ın ~/.config/manicode/credentials.json dosyasını okumasına izin verebilirsiniz (`codebuff login` tarafından oluşturulur)."; +"Stored in ~/.codexbar/config.json. You can also provide CROF_API_KEY." = "~/.codexbar/config.json dosyasında depolandı. Ayrıca CROF_API_KEY sağlayabilirsiniz."; +"Stored in ~/.codexbar/config.json. You can also provide KILO_API_KEY or ~/.local/share/kilo/auth.json (kilo.access)." = "~/.codexbar/config.json dosyasında depolandı. Ayrıca KILO_API_KEY veya ~/.local/share/kilo/auth.json (kilo.access) sağlayabilirsiniz."; +"T3 Chat cookie" = "T3 Chat çerezi"; +"Team mode" = "Takım modu"; +"That account is no longer available in CodexBar. Refresh the account list and try again." = "Bu hesap artık CodexBar'da kullanılamıyor. Hesap listesini yenileyin ve tekrar deneyin."; +"The browser login did not complete in time. Try Antigravity login again." = "Tarayıcı girişi zamanında tamamlanmadı. Antigravity girişini tekrar deneyin."; +"Timed out waiting for Cursor login. %@" = "Cursor girişi beklenirken zaman aşımı. %@"; +"Timed out waiting for Cursor login. %@ Last error: %@" = "Cursor girişi beklenirken zaman aşımı. %@ Son hata: %@"; +"Today requests" = "Bugünkü istekler"; +"Total (30d): %@ credits" = "Toplam (30 gün): %@ kredi"; +"Username" = "Kullanıcı adı"; +"Uses username + password to login and obtain an Oasis-Token automatically." = "Oturum açmak ve otomatik olarak bir Oasis-Token almak için kullanıcı adı + parola kullanır."; +"Uses username + password to login and obtain an %@ automatically." = "Oturum açmak ve otomatik olarak bir %@ almak için kullanıcı adı + parola kullanır."; +"Utilization End" = "Kullanım Sonu"; +"Utilization Start" = "Kullanım Başlangıcı"; +"Verbosity" = "Ayrıntı düzeyi"; +"Windsurf session JSON bundle" = "Windsurf oturum JSON paketi"; +"Workspace ID" = "Çalışma Alanı Kimliği"; +"Your StepFun platform password. Used to login and obtain a session token." = "StepFun platform parolanız. Oturum açmak ve bir oturum jetonu almak için kullanılır."; +"claude /login exited with status %d." = "claude /login %d durumuyla çıktı."; +"codex login exited with status %d." = "codex login %d durumuyla çıktı."; +"Cookie: …\n\nor paste a cURL capture from the Abacus AI dashboard" = "Çerez: …\n\nveya Abacus AI panelinden bir cURL yakalaması yapıştırın"; +"Cookie: …\n\nor paste the __Secure-next-auth.session-token value" = "Çerez: …\n\nveya __Secure-next-auth.session-token değerini yapıştırın"; +"Cookie: …\n\nor paste the kimi-auth token value" = "Çerez: …\n\nveya kimi-auth jeton değerini yapıştırın"; +"session_id=...\n\nor paste just the session_id value" = "session_id=...\n\nveya yalnızca session_id değerini yapıştırın"; +"Clear" = "Temizle"; +"No matching providers" = "Eşleşen sağlayıcı yok"; +"Search providers" = "Sağlayıcı ara"; +"Re-login at claude.ai" = "claude.ai'da yeniden oturum aç"; +"Request quota: %@ / %@" = "İstek kotası: %@ / %@"; +"display_mode_reset_time" = "Sıfırlama zamanı"; +"display_mode_reset_time_desc" = "Seçilen metrik için sıfırlama zamanını göster (örn. ↻ 15:56)"; +"menu_bar_reset_when_exhausted_title" = "Kota bittiğinde sıfırlama zamanını göster"; +"menu_bar_reset_when_exhausted_subtitle" = "%0 kaldığında yüzde yerine sıfırlamaya kalan süreyi gösterir"; +"terminal_app_title" = "Varsayılan Terminal"; +"terminal_app_subtitle" = "Terminali Aç eyleminde kullanılan terminal"; +"language_arabic" = "العربية"; +"language_persian" = "فارسی"; +"language_thai" = "ไทย"; +"language_galician" = "Galego"; +"Limit Reset Credits" = "Limit Sıfırlama Kredileri"; +"1 available" = "1 kullanılabilir"; +"%d available" = "%d kullanılabilir"; +"Next expires %@" = "Sonraki sona erme %@"; +"Expires %@" = "%@ sona eriyor"; +"No expiry" = "Son kullanma yok"; +"byte_unit_byte" = "bayt"; +"byte_unit_bytes" = "bayt"; +"byte_unit_kilobyte" = "kilobayt"; +"byte_unit_kilobytes" = "kilobayt"; +"byte_unit_megabyte" = "megabayt"; +"byte_unit_megabytes" = "megabayt"; +"byte_unit_gigabyte" = "gigabayt"; +"byte_unit_gigabytes" = "gigabayt"; + +/* Settings sidebar redesign */ +"Enable" = "Etkinleştir"; +"Disable" = "Devre dışı bırak"; +"providers_on_count" = "%d açık"; +"section_cost_summary" = "Maliyet özeti"; +"section_command_line" = "Komut satırı"; +"section_privacy" = "Gizlilik"; +"section_diagnostics" = "Tanılama"; +"section_updates" = "Güncellemeler"; +"section_links" = "Bağlantılar"; +"Show Codex Spark usage" = "Codex Spark kullanımını göster"; +"Shows Codex Spark quota rows in the menu and provider preview. Requires optional credits and extra usage in Display settings." = "Codex Spark kota satırlarını menüde ve sağlayıcı önizlemesinde gösterir. Görünüm ayarlarında “Krediler + ekstra kullanımı göster” seçeneğinin etkin olmasını gerektirir."; +"Show Daily Routines usage" = "Günlük Rutinler kullanımını göster"; +"Shows the Daily Routines quota row in the menu and provider preview. Requires optional credits and extra usage in Display settings." = "Günlük Rutinler kota satırını menüde ve sağlayıcı önizlemesinde gösterir. Görünüm ayarlarında “Krediler + ekstra kullanımı göster” seçeneğinin etkin olmasını gerektirir."; +"Scroll to see more models" = "Daha fazla model görmek için kaydırın"; + +/* Shareable usage card */ +"Copy Image" = "Görseli Kopyala"; +"Copy Stats" = "İstatistikleri Kopyala"; +"Could not copy image" = "Görsel kopyalanamadı"; +"Image copied" = "Görsel kopyalandı"; +"Image saved" = "Görsel kaydedildi"; +"Nothing is uploaded. This image is created on your Mac." = "Hiçbir şey yüklenmez. Bu görsel Mac'inizde oluşturulur."; +"Save..." = "Kaydet..."; +"Share AI Usage" = "Yapay Zekâ Kullanımını Paylaş"; +"Share Stats…" = "İstatistikleri Paylaş…"; +"Stats copied" = "İstatistikler kopyalandı"; +"Finish switching to a different Cursor account in your browser, then try again." = "Tarayıcınızda farklı bir Cursor hesabına geçişi tamamlayın, ardından yeniden deneyin."; +"Timed out waiting for Cursor account switch. %@" = "Cursor hesap değişikliği beklenirken zaman aşımına uğradı. %@"; +"Timed out waiting for Cursor account switch. %@ Last error: %@" = "Cursor hesap değişikliği beklenirken zaman aşımına uğradı. %@ Son hata: %@"; + +/* Spend dashboard */ +"tab_usage_spend" = "Kullanım ve Harcama"; +"Usage & Spend" = "Kullanım ve Harcama"; +"Local estimated cost history across supported providers." = "Desteklenen sağlayıcılardaki yerel tahmini maliyet geçmişi."; +"Time range" = "Zaman aralığı"; +"Track costs" = "Maliyetleri izle"; +"Cost tracking is off" = "Maliyet takibi kapalı"; +"Turn on Track costs to build local estimates." = "Yerel tahminler oluşturmak için “Maliyetleri izle” seçeneğini açın."; +"No local cost history yet" = "Henüz yerel maliyet geçmişi yok"; +"Turn on cost tracking or refresh after using a supported provider." = "Maliyet takibini açın veya desteklenen bir sağlayıcıyı kullandıktan sonra yenileyin."; +"Refresh failures" = "Yenileme hataları"; +"Native currencies stay separate; Codex account rows exclude Pi session history." = "Kaynak para birimleri ayrı tutulur; Codex hesap satırlarına Pi oturum geçmişi dahil edilmez."; +"Spend unavailable" = "Harcama verisi kullanılamıyor"; +"Model breakdown unavailable" = "Model dökümü kullanılamıyor"; +"Local estimated history" = "Yerel tahmini geçmiş"; +"Coverage" = "Kapsam"; +"Estimated spend" = "Tahmini harcama"; +"Tracked tokens" = "İzlenen tokenlar"; +"Subscriptions" = "Abonelikler"; +"By subscription" = "Aboneliğe göre"; +"No model-level history" = "Model düzeyinde geçmiş yok"; +"Daily estimated spend" = "Günlük tahmini harcama"; +"≈%d full 5h windows of weekly left · %d windows until reset" = "Haftalık kotadan ≈%d tam 5 saatlik pencere kaldı · sıfırlamaya %d pencere"; +"Weekly cannot run out before reset at this pace" = "Bu hızda haftalık kota sıfırlamadan önce tükenemez"; +"Weekly can run out ≈%d windows early" = "Haftalık kota ≈%d pencere erken tükenebilir"; +"Estimated: %@" = "Tahmini: %@"; +"session_quota_estimate_value_format" = "%1$@ %2$@"; +"session quota" = "oturum kotası"; +"session quotas" = "oturum kotaları"; +"Coding Plan" = "Kodlama Planı"; +"Agent Plan" = "Ajan Planı"; +"Team" = "Ekip"; + +/* Menu bar layout editor */ +"menu_bar_layout_title" = "Düzen"; +"menu_bar_layout_footer" = "Menü çubuğunu düzenlemek için belirteçleri sürükleyin. Eklemek için bir belirtece tıklayın; yerleştirilmiş bir belirteci seçip silmek için Delete tuşuna basın."; +"menu_bar_layout_group_identity" = "Kimlik"; +"menu_bar_layout_group_usage" = "Kullanım"; +"menu_bar_layout_group_time" = "Zaman"; +"menu_bar_layout_group_money" = "Maliyet"; +"menu_bar_layout_group_structure" = "Yapı"; +"menu_bar_layout_scope_all" = "Tüm sağlayıcılar"; +"menu_bar_layout_scope_help" = "Varsayılan düzeni değiştirin veya bir sağlayıcı için geçersiz kılın."; +"menu_bar_layout_use_all" = "Tüm sağlayıcılar düzenini kullan"; +"menu_bar_layout_preset" = "Düzen ön ayarı"; +"menu_bar_layout_preset_icon_percent" = "Simge ve yüzde"; +"menu_bar_layout_preset_icon_only" = "Yalnızca simge"; +"menu_bar_layout_preset_percent_reset" = "Yüzde ve sıfırlama"; +"menu_bar_layout_preset_compact_stacked" = "Kompakt yığın"; +"menu_bar_layout_preset_custom" = "Özel"; +"menu_bar_layout_live_preview" = "Canlı önizleme"; +"menu_bar_layout_strip" = "Menü çubuğu şeridi"; +"menu_bar_layout_remove_line_break" = "Satır sonunu kaldır"; +"menu_bar_layout_chip_hint" = "Seçin, yeniden sıralamak için sürükleyin veya Kaldır eylemini kullanın."; +"menu_bar_layout_palette_hint" = "Eklemek için tıklayın veya düzene sürükleyin."; +"menu_bar_layout_empty_line" = "Buraya bir belirteç bırakın"; +"menu_bar_layout_line" = "Satır %d"; +"menu_bar_layout_drag_remove" = "Kaldırmak için buraya sürükleyin"; +"menu_bar_layout_size" = "Boyut"; +"menu_bar_layout_size_small" = "Küçük"; +"menu_bar_layout_size_regular" = "Normal"; +"menu_bar_layout_gap" = "Boşluk"; +"menu_bar_layout_gap_tight" = "Dar"; +"menu_bar_layout_gap_regular" = "Normal"; +"menu_bar_layout_keyboard_hint" = "Delete seçili belirteci kaldırır"; +"menu_bar_layout_sample_account" = "hesap"; +"menu_bar_layout_sample_runs_out" = "Cum. biter"; +"menu_bar_layout_token_icon" = "Simge"; +"menu_bar_layout_token_provider" = "Sağlayıcı adı"; +"menu_bar_layout_token_account" = "Hesap"; +"menu_bar_layout_token_session" = "Oturum %"; +"menu_bar_layout_token_weekly" = "Haftalık %"; +"menu_bar_layout_token_auto" = "Otomatik %"; +"menu_bar_layout_token_bar" = "Kullanım çubuğu"; +"menu_bar_layout_token_resets_in" = "Sıfırlamaya"; +"menu_bar_layout_token_reset_at" = "Sıfırlama saati"; +"menu_bar_layout_token_runs_out" = "Biter"; +"menu_bar_layout_token_cost_today" = "Bugünkü maliyet"; +"menu_bar_layout_token_cost_30d" = "30 günlük maliyet"; +"menu_bar_layout_token_space" = "Boşluk"; +"menu_bar_layout_token_line_break" = "Satır sonu"; +"menu_bar_layout_token_separator_accessibility" = "Ayırıcı nokta"; + +/* Menu bar layout accessibility */ +"Icon unavailable" = "Simge: Kullanılamıyor"; +"%@ icon" = "%@: Simge"; +"Provider name unavailable" = "Sağlayıcı adı: Kullanılamıyor"; +"Account unavailable" = "Hesap: Kullanılamıyor"; +"%@ unavailable" = "%@: Kullanılamıyor"; +"%@ %@" = "%@ %@"; +"Usage bar unavailable" = "Kullanım çubuğu: Kullanılamıyor"; +"Usage bar, %d of 3 filled" = "Kullanım çubuğu: %d/3 dolu"; +"Reset countdown unavailable" = "Sıfırlamaya: Kullanılamıyor"; +"Reset time unavailable" = "Sıfırlama saati: Kullanılamıyor"; +"Run-out estimate unavailable" = "Biter: Kullanılamıyor"; +"Cost today unavailable" = "Bugünkü maliyet: Kullanılamıyor"; +"30-day cost unavailable" = "30 günlük maliyet: Kullanılamıyor"; +"Resets" = "Sıfırlamalar"; diff --git a/Sources/CodexBar/Resources/tr.lproj/Localizable.stringsdict b/Sources/CodexBar/Resources/tr.lproj/Localizable.stringsdict new file mode 100644 index 0000000000..21dbba65e0 --- /dev/null +++ b/Sources/CodexBar/Resources/tr.lproj/Localizable.stringsdict @@ -0,0 +1,49 @@ + + + + + ≈%d full 5h windows of weekly left · %d windows until reset + + NSStringLocalizedFormatKey + %#@left@ · %#@until@ + left + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + d + one + Haftalık kotadan ≈%d tam 5 saatlik pencere kaldı + other + Haftalık kotadan ≈%d tam 5 saatlik pencere kaldı + + until + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + d + one + sıfırlamaya %d pencere + other + sıfırlamaya %d pencere + + + Weekly can run out ≈%d windows early + + NSStringLocalizedFormatKey + %#@early@ + early + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + d + one + Haftalık kota ≈%d pencere erken tükenebilir + other + Haftalık kota ≈%d pencere erken tükenebilir + + + + diff --git a/Sources/CodexBar/Resources/uk.lproj/Localizable.strings b/Sources/CodexBar/Resources/uk.lproj/Localizable.strings new file mode 100644 index 0000000000..9b17fd3d9c --- /dev/null +++ b/Sources/CodexBar/Resources/uk.lproj/Localizable.strings @@ -0,0 +1,1353 @@ +/* Ukrainian localization for CodexBar */ + +"tab_hooks" = "Хуки"; +"hooks_enable_title" = "Увімкнути хуки"; +"hooks_enable_subtitle" = "Запускати зовнішні команди під час подій квоти або провайдера."; +"hooks_trust_warning" = "Хуки можуть виконувати локальні команди на вашому Mac. Налаштовуйте лише надійні команди."; +"hooks_rules_header" = "Правила"; +"hooks_empty" = "Хуки не налаштовано."; +"hooks_add_rule" = "Додати правило"; +"hooks_delete_rule" = "Видалити правило"; +"hooks_rule_enabled" = "Увімкнено"; +"hooks_event" = "Подія"; +"hooks_provider" = "Провайдер"; +"hooks_any_provider" = "Будь-який провайдер"; +"hooks_threshold" = "Запускати за використання ≥"; +"hooks_threshold_placeholder" = "90"; +"hooks_executable_placeholder" = "/usr/local/bin/my-command"; +"hooks_arguments_placeholder" = "Аргументи"; +"hooks_argument_placeholder" = "Аргумент"; +"hooks_add_argument" = "Додати аргумент"; +"hooks_delete_argument" = "Видалити аргумент"; + +"ollama_safari_cookie_access_hint" = "Для файлів cookie Safari програмі CodexBar потрібен повний доступ до диска (Системні параметри > Конфіденційність і безпека)."; +"ollama_browser_cookie_decryption_denied" = "Розшифрування файлів cookie %@ було відхилено у В’язці ключів; повторіть спробу за допомогою ручного оновлення."; +"ollama_browser_cookie_decryption_disabled" = "Розшифрування файлів cookie %@ вимкнено в CodexBar; увімкніть доступ до В’язки ключів і оновіть."; + +" providers" = "провайдерів"; +"(System)" = "(Система)"; +"30d" = "30д"; +"7d" = "7д"; +"A managed Codex login is already running. Wait for it to finish before adding " = "Керований вхід до Codex вже запущено. Перш ніж додавати, зачекайте, поки він закінчиться"; +"API key" = "Ключ API"; +"API region" = "регіон API"; +"API token" = "Маркер API"; +"API tokens" = "маркери API"; +"About" = "Про програму"; +"Account" = "Обліковий запис"; +"Accounts" = "Облікові записи"; +"Accounts subtitle" = "Підзаголовок облікових записів"; +"Active" = "Активний"; +"Add" = "Додати"; +"Add Workspace" = "Додати робочу область"; +"Advanced" = "Розширені"; +"All" = "Усі"; +"Always allow prompts" = "Завжди дозволяти підказки"; +"Animation pattern" = "Шаблон анімації"; +"Antigravity login is managed in the app" = "Вхід в Antigravity керується в додатку"; +"Applies only to the Security.framework OAuth keychain reader." = "Застосовується лише до зчитувача брелоків OAuth Security.framework."; +"Auto falls back to the next source if the preferred one fails." = "Автоматичний перехід до наступного джерела, якщо бажане не вдається."; +"Auto uses API first, then falls back to CLI on auth failures." = "Auto спочатку використовує API, а потім повертається до CLI у разі помилок авторизації."; +"Auto-detect" = "Автоматичне визначення"; +"Auto-refresh is off; use the menu's Refresh command." = "Автооновлення вимкнено; скористайтеся командою меню «Оновити»."; +"Auto-refresh: hourly · Timeout: 10m" = "Автоматичне оновлення: щогодини · Час очікування: 10 хв"; +"Automatic" = "Автоматично"; +"Automatic imports browser cookies and WorkOS tokens." = "Автоматично імпортує файли cookie браузера та маркери WorkOS."; +"Automatic imports browser cookies and local storage tokens." = "Автоматично імпортує файли cookie браузера та маркери локального зберігання."; +"Automatic imports browser cookies for dashboard extras." = "Автоматично імпортує файли cookie браузера для додаткових функцій панелі інструментів."; +"Automatic imports browser cookies for the web API." = "Автоматично імпортує файли cookie браузера для веб-API."; +"Automatic imports browser cookies from Model Studio/Bailian." = "Автоматично імпортує файли cookie браузера з Model Studio/Bailian."; +"Automatic imports browser cookies from admin.mistral.ai." = "Автоматично імпортує файли cookie браузера з admin.mistral.ai."; +"Automatic imports browser cookies from opencode.ai." = "Автоматично імпортує файли cookie браузера з opencode.ai."; +"Automatic imports browser cookies or stored sessions." = "Автоматично імпортує файли cookie браузера або збережені сесії."; +"Automatic imports browser cookies." = "Автоматично імпортує файли cookie браузера."; +"Automatically imports browser session cookie." = "Автоматично імпортує файл cookie сесії браузера."; +"Automatically opens CodexBar when you start your Mac." = "Автоматично відкриває CodexBar під час запуску Mac."; +"Automation" = "Автоматизація"; +"Average (\\(label1) + \\(label2))" = "Середній (\\(label1) + \\(label2))"; +"Average (\\(metadata.sessionLabel) + \\(metadata.weeklyLabel))" = "Середній (\\(metadata.sessionLabel) + \\(metadata.weeklyLabel))"; +"Avoid Keychain prompts" = "Уникайте підказок Keychain"; +"Balance" = "Баланс"; +"Battery Saver" = "Економія батареї"; +"Bordered" = "З рамкою"; +"Build" = "Збірка"; +"Built \\(buildTimestamp)" = "Побудовано \\(buildTimestamp)"; +"Buy Credits..." = "Купити кредити..."; +"Buy Credits…" = "Купити кредити…"; +"CLI paths" = "Шляхи CLI"; +"CLI sessions" = "Сесії CLI"; +"Caches" = "Кеші"; +"Cancel" = "Скасувати"; +"Check for Updates…" = "Перевірити наявність оновлень…"; +"Check for updates automatically" = "Автоматично перевіряти наявність оновлень"; +"Check if you like your agents having some fun up there." = "Перевірте, чи подобається вам, що ваші агенти розважаються там."; +"Check provider status" = "Перевірте статус провайдера"; +"Choose Codex workspace" = "Виберіть робочу область Codex"; +"Choose the MiniMax host (global .io or China mainland .com)." = "Виберіть хост MiniMax (глобальний .io або материковий Китай .com)."; +"Choose up to " = "Виберіть до"; +"Choose up to \\(Self.maxOverviewProviders) providers" = "Виберіть до \\(Self.maxOverviewProviders) постачальників"; +"Choose up to \\(count) providers" = "Виберіть до \\(count) постачальників"; +"Choose what to show in the menu bar (Pace shows usage vs. expected)." = "Виберіть, що відображати на панелі меню (Pace показує використання порівняно з очікуваним)."; +"Choose which Codex account CodexBar should follow." = "Виберіть, який обліковий запис Codex має дотримуватися CodexBar."; +"Choose which window drives the menu bar percent." = "Виберіть, яке вікно керує відсотками панелі меню."; +"Chrome" = "Chrome"; +"Claude CLI not found" = "Claude CLI не знайдено"; +"Claude binary" = "Claude бінарний"; +"Claude cookies" = "Печиво Claude"; +"Claude login failed" = "Помилка входу Claude"; +"Claude login timed out" = "Час очікування входу Claude минув"; +"Close" = "Закрити"; +"Code review" = "Огляд коду"; +"Codex CLI not found" = "Codex CLI не знайдено"; +"Codex account login already running" = "Вхід до облікового запису Codex уже запущено"; +"Codex binary" = "Двійковий код Codex"; +"Codex login failed" = "Помилка входу в Codex"; +"Codex login timed out" = "Час очікування входу в Codex минув"; +"CodexBar Lifecycle Keepalive" = "Життєвий цикл CodexBar Keepalive"; +"CodexBar can't show its menu bar icon" = "CodexBar не може показати піктограму панелі меню"; +"CodexBar could not read managed account storage. " = "CodexBar не вдалося прочитати сховище керованого облікового запису."; +"Configure…" = "Налаштувати…"; +"Connected" = "Підключено"; +"Controls how much detail is logged." = "Контролює, скільки деталей реєструється."; +"Cookie header" = "Заголовок файлу cookie"; +"Cookie source" = "Джерело файлів cookie"; +"Cookie: ..." = "Печиво: ..."; +"Cookie: \\u{2026}\\\n\\\nor paste a cURL capture from the Abacus AI dashboard" = "Файл cookie: \\u{2026}\\\n\\\nабо вставте запис cURL із інформаційної панелі Abacus AI"; +"Cookie: \\u{2026}\\\n\\\nor paste the __Secure-next-auth.session-token value" = "Файл cookie: \\u{2026}\\\n\\\nабо вставте значення __Secure-next-auth.session-token"; +"Cookie: \\u{2026}\\\n\\\nor paste the kimi-auth token value" = "Файл cookie: \\u{2026}\\\n\\\nабо вставте значення маркера kimi-auth"; +"Cookie: …" = "Печиво: …"; +"CopilotDeviceFlow" = "CopilotDeviceFlow"; +"Cost" = "Вартість"; +"Could not add Codex account" = "Не вдалося додати обліковий запис Codex"; +"Could not open Terminal for Gemini" = "Не вдалося відкрити термінал для Gemini"; +"Could not start claude /login" = "Не вдалося запустити claude /login"; +"Could not start codex login" = "Не вдалося розпочати вхід до Codex"; +"Could not switch system account" = "Не вдалося змінити обліковий запис системи"; +"Credits" = "Кредити"; +"Individual credits" = "Особисті кредити"; +"Workspace" = "Робоча область"; +"Credits history" = "Кредитна історія"; +"Cursor login failed" = "Помилка входу в систему курсору"; +"Custom" = "Користувацький"; +"Custom Path" = "Спеціальний шлях"; +"Daily Routines" = "Розпорядок дня"; +"Debug" = "Налагодження"; +"Default" = "Типово"; +"Disable Keychain access" = "Вимкнути доступ Keychain"; +"Disabled" = "Вимкнено"; +"Dismiss" = "Закрити"; +"Disconnected" = "Відключено"; +"Display" = "Відображення"; +"Display mode" = "Режим відображення"; +"Display reset times as absolute clock values instead of countdowns." = "Відображення часу скидання як абсолютних значень годинника замість зворотного відліку."; +"Done" = "Готово"; +"Effective PATH" = "Ефективний ШЛЯХ"; +"Email" = "Ел. пошта"; +"Enable Merge Icons to configure Overview tab providers." = "Увімкніть Merge Icons, щоб налаштувати постачальників вкладок «Огляд»."; +"Enable file logging" = "Увімкнути журналювання файлів"; +"Enabled" = "Увімкнено"; +"Error" = "Помилка"; +"Error simulation" = "Симуляція помилок"; +"Expose troubleshooting tools in the Debug tab." = "Розкрийте інструменти усунення несправностей на вкладці Debug."; +"Failed" = "Помилка"; +"False" = "Неправда"; +"Fetch strategy attempts" = "Спроби отримання стратегії"; +"Fetching" = "Отримання"; +"Field" = "Поле"; +"Field subtitle" = "Підзаголовок поля"; +"Finish the current managed account change before switching the system account." = "Завершіть зміну поточного керованого облікового запису, перш ніж змінювати обліковий запис системи."; +"Force animation on next refresh" = "Примусово запускати анімацію під час наступного оновлення"; +"Gateway region" = "Регіон шлюзу"; +"Gemini CLI not found" = "Gemini CLI не знайдено"; +"Gemini/Antigravity, surfacing incidents in the icon and menu." = "Gemini/Antigravity, інциденти на поверхні в іконці та меню."; +"General" = "Загальні"; +"GitHub" = "GitHub"; +"GitHub Copilot Login" = "Логін GitHub Copilot"; +"GitHub Login" = "Вхід на GitHub"; +"Hide details" = "Приховати деталі"; +"Hide personal information" = "Приховати особисту інформацію"; +"Historical tracking" = "Історичне відстеження"; +"How often CodexBar polls providers in the background." = "Як часто CodexBar опитує постачальників у фоновому режимі."; +"Inactive" = "Неактивний"; +"Install CLI" = "Встановіть CLI"; +"Install the Claude CLI (npm i -g @anthropic-ai/claude-code) and try again." = "Встановіть CLAude CLI (npm i -g @anthropic-ai/claude-code) і повторіть спробу."; +"Install the Codex CLI (npm i -g @openai/codex) and try again." = "Встановіть Codex CLI (npm i -g @openai/codex) і повторіть спробу."; +"Install the Gemini CLI (npm i -g @google/gemini-cli) and try again." = "Установіть Gemini CLI (npm i -g @google/gemini-cli) і повторіть спробу."; +"JetBrains AI is ready" = "JetBrains AI готовий"; +"JetBrains IDE" = "JetBrains IDE"; +"Keep CLI sessions alive" = "Підтримуйте сесії CLI"; +"Keyboard shortcut" = "Комбінація клавіш"; +"Keychain access" = "Доступ через брелок"; +"Keychain prompt policy" = "Політика оперативного брелока"; +"Last \\(name) fetch failed:" = "Помилка останнього \\(name) отримання:"; +"Last \\(self.store.metadata(for: self.provider).displayName) fetch failed:" = "Помилка отримання останнього \\(self.store.metadata(for: self.provider).displayName):"; +"Last attempt" = "Остання спроба"; +"Link" = "Посилання"; +"Loading animations" = "Завантаження анімацій"; +"Loading…" = "Завантаження…"; +"Local" = "Місцевий"; +"Logging" = "Лісозаготівля"; +"Login failed" = "Помилка входу"; +"Login shell PATH (startup capture)" = "ШЛЯХ до оболонки входу (запис під час запуску)"; +"Login timed out" = "Час входу минув"; +"MCP details" = "Деталі MCP"; +"Managed Codex accounts unavailable" = "Керовані облікові записи Codex недоступні"; +"Managed account storage is unreadable. Live account access is still available, " = "Сховище керованого облікового запису не читається. Доступ до реального облікового запису все ще доступний,"; +"Manual" = "Вручну"; +"May your tokens never run out—keep agent limits in view." = "Нехай ваші токени ніколи не закінчаться — пам’ятайте про ліміти агентів."; +"Menu bar" = "Рядок меню"; +"Menu bar auto-shows the provider closest to its rate limit." = "Рядок меню автоматично показує постачальника, який найближче до ліміту."; +"Menu bar metric" = "Метрика панелі меню"; +"Menu bar shows percent" = "Рядок меню показує відсотки"; +"Menu content" = "Зміст меню"; +"Merge Icons" = "Злиття значків"; +"Never prompt" = "Ніколи не підказуйте"; +"No" = "Ні"; +"No Codex accounts detected yet." = "Облікових записів Codex ще не виявлено."; +"No JetBrains IDE detected" = "JetBrains IDE не виявлено"; +"No cost history data." = "Немає даних історії витрат."; +"No data available" = "Немає даних"; +"No data yet" = "Даних ще немає"; +"No enabled providers available for Overview." = "Немає активованих постачальників, доступних для огляду."; +"No providers selected" = "Не вибрано жодного постачальника"; +"No token accounts yet." = "Жетонів ще немає."; +"No usage breakdown data." = "Немає даних про використання."; +"None" = "Жодного"; +"Notifications" = "Сповіщення"; +"Notifies when the 5-hour session quota hits 0% and when it becomes " = "Повідомляє, коли 5-годинна квота сеансу досягає 0% і коли вона стає"; +"OK" = "OK"; +"Obscure email addresses in the menu bar and menu UI." = "Незрозумілі адреси електронної пошти на панелі меню та інтерфейсі меню."; +"Off" = "Вимкнено"; +"Offline" = "Офлайн"; +"On" = "Увімкнено"; +"Online" = "Онлайн"; +"Only on user action" = "Тільки за діями користувача"; +"Open" = "Відкрити"; +"Open API Keys" = "Відкрити ключі API"; +"Open Amp Settings" = "Відкрийте налаштування підсилювача"; +"Open Antigravity to sign in, then refresh CodexBar." = "Відкрийте Antigravity, щоб увійти, а потім оновіть CodexBar."; +"Open Browser" = "Відкрийте браузер"; +"Open Coding Plan" = "Відкрити план кодування"; +"Open Console" = "Відкрийте консоль"; +"Open Dashboard" = "Відкрийте інформаційну панель"; +"Open Mistral Admin" = "Відкрийте Mistral Admin"; +"Open Menu Bar Settings" = "Відкрийте панель меню Параметри"; +"Open Ollama Settings" = "Відкрийте налаштування Ollama"; +"Open Terminal" = "Відкрийте термінал"; +"Open Usage Page" = "Відкрити сторінку використання"; +"Open Warp API Key Guide" = "Відкрийте посібник з ключів API Warp"; +"Open menu" = "Відкрити меню"; +"Open token file" = "Відкрити файл маркера"; +"OpenAI cookies" = "Файли cookie OpenAI"; +"OpenAI web extras" = "Веб-додатки OpenAI"; +"Option A" = "Варіант А"; +"Option B" = "Варіант Б"; +"Optional override if workspace lookup fails." = "Додаткове перевизначення, якщо пошук робочої області не вдається."; +"Options" = "Опції"; +"Override auto-detection with a custom IDE base path" = "Замініть автоматичне виявлення власним базовим шляхом IDE"; +"Overview" = "Огляд"; +"Overview rows always follow provider order." = "Оглядові рядки завжди відповідають порядку постачальника."; +"Overview tab providers" = "Постачальники вкладок огляду"; +"Paste API key…" = "Вставити ключ API…"; +"Paste API token…" = "Вставити маркер API…"; +"Paste key…" = "Вставити ключ…"; +"Paste sessionKey or OAuth token…" = "Вставте sessionKey або маркер OAuth…"; +"Paste the Cookie header from a request to admin.mistral.ai. " = "Вставте заголовок Cookie із запиту до admin.mistral.ai."; +"Paste token…" = "Вставити маркер…"; +"Personal" = "Особистий"; +"Picker" = "Пікер"; +"Picker subtitle" = "Підзаголовок засобу вибору"; +"Placeholder" = "Заповнювач"; +"Plan" = "План"; +"Plan Usage" = "Використання плану"; +"Play full-screen confetti when weekly usage resets." = "Відтворення конфетті на весь екран, коли тижневе використання скидається."; +"Polls OpenAI/Claude status pages and Google Workspace for " = "Опитує сторінки статусу OpenAI/Claude і Google Workspace для"; +"Prevents any Keychain access while enabled." = "Запобігає будь-якому доступу Keychain, коли ввімкнено."; +"Primary (API key limit)" = "Основний (обмеження ключа API)"; +"Primary (\\(label))" = "Основний (\\(label))"; +"Primary (\\(metadata.sessionLabel))" = "Основний (\\(metadata.sessionLabel))"; +"Probe logs" = "Зондові журнали"; +"Progress bars fill as you consume quota (instead of showing remaining)." = "Індикатори прогресу заповнюються, коли ви витрачаєте квоту (замість відображення залишку)."; +"Provider" = "Провайдер"; +"Providers" = "Провайдери"; +"Quit CodexBar" = "Закрийте CodexBar"; +"Random (default)" = "Випадковий (за замовчуванням)"; +"Reads local usage logs. Shows today + last 30 days cost in the menu." = "Читає локальні журнали використання. Показує сьогодні + вибране вікно історії в меню."; +"Refresh" = "Оновити"; +"Refresh cadence" = "Оновити каденцію"; +"Remote" = "Дистанційний"; +"Remove" = "Видалити"; +"Remove Codex account?" = "Видалити обліковий запис Codex?"; +"Remove \\(account.email) from CodexBar? Its managed Codex home will be deleted." = "Видалити \\(account.email) з CodexBar? Його керовану домашню сторінку Codex буде видалено."; +"Remove \\(email) from CodexBar? Its managed Codex home will be deleted." = "Видалити \\(email) з CodexBar? Його керовану домашню сторінку Codex буде видалено."; +"Remove selected account" = "Видалити вибраний обліковий запис"; +"Replace critter bars with provider branding icons and a percentage." = "Замініть смужки тварин на значки бренду постачальника та відсоток."; +"Replay selected animation" = "Повторити вибрану анімацію"; +"Requires authentication via GitHub Device Flow." = "Потрібна автентифікація через GitHub Device Flow."; +"Resets: \\(reset)" = "Скидання: \\(reset)"; +"Rolling five-hour limit" = "Рухливий п'ятигодинний ліміт"; +"Search hourly" = "Пошук щогодини"; +"Secondary (\\(label))" = "Вторинний (\\(label))"; +"Secondary (\\(metadata.weeklyLabel))" = "Вторинний (\\(metadata.weeklyLabel))"; +"Select a provider" = "Виберіть провайдера"; +"Select the IDE to monitor" = "Виберіть IDE для моніторингу"; +"Session quota notifications" = "Сповіщення про квоту сеансу"; +"Session tokens" = "Токени сесії"; +"provider_section_connection" = "Підключення"; +"provider_section_menu_bar" = "Рядок меню"; +"Show Codex Credits and Claude Extra usage sections in the menu." = "Показати в меню розділи використання кредитів Codex і Claude Extra."; +"Show Debug Settings" = "Показати налаштування налагодження"; +"Show all token accounts" = "Показати всі облікові записи маркерів"; +"Show cost summary" = "Показати підсумок витрат"; +"Show credits + extra usage" = "Показати кредити + додаткове використання"; +"Show details" = "Показати деталі"; +"Show most-used provider" = "Показати постачальника, який найчастіше використовується"; +"Show provider icons in the switcher (otherwise show a weekly progress line)." = "Показувати піктограми постачальників у комутаторі (інакше показувати щотижневий рядок прогресу)."; +"Show reset time as clock" = "Показувати час скидання як годинник"; +"Show usage as used" = "Показати використання як використане"; +"Sign in via button below" = "Увійдіть за допомогою кнопки нижче"; +"Skip teardown between probes (debug-only)." = "Пропустити демонтаж між зондами (лише для налагодження)."; +"Stack token accounts in the menu (otherwise show an account switcher bar)." = "Облікові записи маркерів стека в меню (інакше відображати панель перемикання облікових записів)."; +"Start at Login" = "Почніть із входу"; +"Status" = "Статус"; +"Store Claude sessionKey cookies or OAuth access tokens." = "Зберігайте файли cookie Claude sessionKey або маркери доступу OAuth."; +"Store multiple Abacus AI Cookie headers." = "Зберігайте кілька заголовків файлів cookie Abacus AI."; +"Store multiple Augment Cookie headers." = "Зберігайте кілька заголовків Augment Cookie."; +"Store multiple Cursor Cookie headers." = "Зберігайте кілька заголовків Cursor Cookie."; +"Store multiple Factory Cookie headers." = "Зберігайте кілька заголовків Factory Cookie."; +"Store multiple MiniMax Cookie headers." = "Зберігайте кілька заголовків MiniMax Cookie."; +"Store multiple Mistral Cookie headers." = "Зберігайте кілька заголовків Mistral Cookie."; +"Store multiple Ollama Cookie headers." = "Зберігайте кілька заголовків файлів cookie Ollama."; +"Store multiple OpenCode Cookie headers." = "Зберігайте кілька заголовків файлів cookie OpenCode."; +"Store multiple OpenCode Go Cookie headers." = "Зберігайте кілька заголовків OpenCode Go Cookie."; +"Stored in the CodexBar config file." = "Зберігається у конфігураційному файлі CodexBar."; +"Stored in ~/.codexbar/config.json. " = "Зберігається в ~/.codexbar/config.json."; +"Stored in ~/.codexbar/config.json. Paste the key from the Synthetic dashboard." = "Зберігається в ~/.codexbar/config.json. Вставте ключ із панелі приладів Synthetic."; +"Stored in ~/.codexbar/config.json. Paste your Coding Plan API key from Model Studio." = "Зберігається в ~/.codexbar/config.json. Вставте ключ API плану кодування з Model Studio."; +"Stored in ~/.codexbar/config.json. Paste your MiniMax API key." = "Зберігається в ~/.codexbar/config.json. Вставте ключ MiniMax API."; +"Stored in ~/.codexbar/config.json. You can also provide KILO_API_KEY or " = "Зберігається в ~/.codexbar/config.json. Ви також можете надати KILO_API_KEY або"; +"Stores local Codex usage history (8 weeks) to personalize Pace predictions." = "Зберігає локальну історію використання Codex (8 тижнів) для персоналізації прогнозів Pace."; +"Surprise me" = "Здивуйте мене"; +"Switcher shows icons" = "Перемикач показує значки"; +"Symlink CodexBarCLI to /usr/local/bin and /opt/homebrew/bin as codexbar." = "Символьне посилання CodexBarCLI на /usr/local/bin і /opt/homebrew/bin як codexbar."; +"System" = "Система"; +"Temporarily shows the loading animation after the next refresh." = "Тимчасово показує анімацію завантаження після наступного оновлення."; +"terminal_app_subtitle" = "Термінал, який використовується дією «Відкрити термінал»"; +"terminal_app_title" = "Термінал за замовчуванням"; +"Tertiary (\\(label))" = "Вищий (\\(label))"; +"Tertiary (\\(tertiaryTitle))" = "Вищий (\\(tertiaryTitle))"; +"The default Codex account on this Mac." = "Обліковий запис Codex за умовчанням на цьому Mac."; +"Toggle" = "Перемикач"; +"Toggle subtitle" = "Перемкнути субтитри"; +"Token" = "Токен"; +"Trigger the menu bar menu from anywhere." = "Викликати меню панелі меню з будь-якого місця."; +"True" = "Так"; +"Twitter" = "Twitter"; +"Unsupported" = "Не підтримується"; +"Update Channel" = "Оновити канал"; +"Updated" = "Оновлено"; +"Updates unavailable in this build." = "Оновлення недоступні в цій збірці."; +"Usage" = "Використання"; +"Usage breakdown" = "Розбивка використання"; +"Usage history (30 days)" = "Історія використання"; +"Usage source" = "Джерело використання"; +"Use BigModel for the China mainland endpoints (open.bigmodel.cn)." = "Використовуйте BigModel для кінцевих точок материкового Китаю (open.bigmodel.cn)."; +"Use a single menu bar icon with a provider switcher." = "Використовуйте одну піктограму панелі меню з перемикачем провайдерів."; +"Use international or China mainland console gateways for quota fetches." = "Використовуйте міжнародні консольні шлюзи або шлюзи материкової частини Китаю для отримання квот."; +"Version" = "Версія"; +"Version \\(self.versionString)" = "Версія \\(self.versionString)"; +"Version \\(version)" = "Версія \\(version)"; +"Version \\(versionString)" = "Версія \\(versionString)"; +"Vertex AI Login" = "Вхід у Vertex AI"; +"Wait for the current managed Codex login to finish before adding another account." = "Перш ніж додавати інший обліковий запис, дочекайтеся завершення поточного керованого входу в Codex."; +"Waiting for Authentication..." = "Очікування автентифікації..."; +"Website" = "Веб-сайт"; +"Weekly limit confetti" = "Щотижневий ліміт конфетті"; +"Weekly token limit" = "Тижневий ліміт жетонів"; +"Weekly usage" = "Щотижневе використання"; +"Weekly usage unavailable for this account." = "Щотижневе використання недоступне для цього облікового запису."; +"Window: \\(window)" = "Вікно: \\(window)"; +"Write logs to \\(self.fileLogPath) for debugging." = "Записати журнали в \\(self.fileLogPath) для налагодження."; +"Yes" = "Так"; +"\\(detail.modelCode): \\(usage)" = "\\(detail.modelCode): \\(usage)"; +"\\(name): \\(truncated)" = "\\(name): \\(truncated)"; +"\\(name): \\(updated) · 30d \\(cost)" = "\\(name): \\(updated) · 30 дн \\(cost)"; +"\\(name): fetching…\\(elapsed)" = "\\(name): отримання…\\(elapsed)"; +"\\(name): last attempt \\(when)" = "\\(name): остання спроба \\(when)"; +"\\(name): no data yet" = "\\(name): ще немає даних"; +"\\(name): unsupported" = "\\(name): не підтримується"; +"all browsers" = "всі браузери"; +"available again." = "знову доступний."; +"built_format" = "Побудовано %@"; +"copilot_complete_in_browser" = "Завершіть вхід у свій браузер."; +"copilot_device_code" = "Код пристрою скопійовано в буфер обміну: %1$@\n\nПеревірити за адресою: %2$@"; +"copilot_device_code_copied" = "Код пристрою скопійовано."; +"copilot_verify_at" = "Підтвердити в %@"; +"copilot_waiting_text" = "Завершіть вхід у свій браузер.\nЦе вікно закриється автоматично, коли вхід завершиться."; +"copilot_window_closes_auto" = "Це вікно закривається автоматично після завершення входу."; +"cost_status_error" = "%1$@: %2$@"; +"cost_status_fetching" = "%1$@: отримання… %2$@"; +"cost_status_last_attempt" = "%1$@: остання спроба %2$@"; +"cost_status_no_data" = "%@: ще немає даних"; +"cost_status_snapshot" = "%1$@: %2$@ · %3$@ %4$@"; +"cost_status_unsupported" = "%@: не підтримується"; +"credits_remaining" = "Кредити: %@"; +"cursor_on_demand" = "На вимогу: %@"; +"cursor_on_demand_with_limit" = "На вимогу: %1$@ / %2$@"; +"extra_usage_format" = "Додаткове використання: %1$@ / %2$@"; +"jetbrains_detected_generate" = "Виявлено: %@. Скористайтеся помічником штучного інтелекту один раз, щоб створити дані квоти, а потім оновіть CodexBar."; +"jetbrains_detected_select" = "Виявлено: %@. Виберіть бажану IDE у налаштуваннях, а потім оновіть CodexBar."; +"last_fetch_failed_with_provider" = "Помилка останнього %@ отримання:"; +"last_spend" = "Останні витрати: %@"; +"mcp_model_usage" = "%1$@: %2$@"; +"mcp_resets" = "Скидання: %@"; +"mcp_window" = "Вікно: %@"; +"metric_average" = "Середній (%1$@ + %2$@)"; +"metric_primary" = "Основний (%@)"; +"metric_secondary" = "Вторинний (%@)"; +"metric_tertiary" = "Вищий (%@)"; +"multiple_workspaces_found" = "CodexBar знайшов кілька робочих областей для %@. Виберіть робочу область для додавання."; +"ory_session_…=…; csrftoken=…" = "ory_session_…=…; csrftoken=…"; +"overview_choose_providers" = "Виберіть до %@ постачальників"; +"remove_account_message" = "Видалити %@ з CodexBar? Його керовану домашню сторінку Codex буде видалено."; +"version_format" = "Версія %@"; +"vertex_ai_login_instructions" = "Щоб відстежувати використання Vertex AI, пройдіть автентифікацію в Google Cloud.\n\n1. Відкрийте термінал\n2. Запустіть: gcloud auth application-default login\n3. Дотримуйтесь підказок браузера, щоб увійти\n4. Налаштуйте свій проект: gcloud config set project PROJECT_ID\n\nВідкрити термінал зараз?"; +"workspaceID is set but only opencode, opencodego, and deepgram support workspaceID." = "workspaceID встановлено, але лише opencode, opencodego та deepgram підтримують workspaceID."; +"© 2026 Peter Steinberger. MIT License." = "© 2026 Пітер Штайнбергер. Ліцензія MIT."; + +/* General Pane */ +"section_system" = "Система"; +"section_usage" = "Використання"; +"section_refreshing" = "Оновлення"; +"section_alerts" = "Сповіщення"; +"section_celebrations" = "Святкування"; +"section_icon" = "Значок"; +"section_combined_icon" = "Об’єднаний значок"; +"section_animation" = "Анімація"; +"section_content" = "Вміст"; +"section_agent_sessions" = "Сеанси агентів"; +"language_title" = "Мова"; +"language_subtitle" = "Змінює мову інтерфейсу. Для повного застосування потрібно перезапустити застосунок."; +"currency_title" = "Бажана валюта"; +"currency_subtitle" = "Валюта для оцінки вартості та витрат. Використовує курси, що оновлюються щодня."; +"currency_auto" = "Автоматично (валюта постачальника / USD)"; +"language_system" = "Система"; +"language_english" = "English"; +"language_spanish" = "Español"; +"language_catalan" = "Català"; +"language_chinese_simplified" = "简体中文"; +"language_chinese_traditional" = "繁體中文"; +"language_portuguese_brazilian" = "Português (Brasil)"; +"language_german" = "Deutsch"; +"language_swedish" = "Svenska"; +"language_french" = "Français"; +"language_dutch" = "Нідерландська"; +"language_ukrainian" = "Українська"; +"language_russian" = "Русский"; +"language_japanese" = "Японська"; +"language_korean" = "Корейська"; +"language_italian" = "Italiano"; +"language_vietnamese" = "В'єтнамська"; +"language_turkish" = "Türkçe"; +"language_indonesian" = "Індонезійська"; +"language_polish" = "Польська"; +"start_at_login_title" = "Почніть із входу"; +"start_at_login_subtitle" = "Автоматично відкриває CodexBar під час запуску Mac."; +"show_cost_summary_subtitle" = "Читає локальні журнали використання. Показує сьогодні + вибране вікно історії в меню."; +"cost_summary_style_title" = "Стиль відображення"; +"cost_summary_style_inline" = "Лише вбудовано"; +"cost_summary_style_submenu" = "Лише підменю"; +"cost_summary_style_both" = "Обидва"; +"cost_summary_style_inline_help" = "Показує підсумок витрат безпосередньо в головному меню."; +"cost_summary_style_submenu_help" = "Натомість показує детальне підменю Вартість."; +"cost_summary_style_both_help" = "Показує підсумок у головному меню та детальне підменю Вартість."; +"cost_history_window_title" = "Вікно історії"; +"cost_history_window_help" = "Визначає, скільки днів локальних журналів використання показувати в меню."; +"cost_history_days_title" = "Вікно історії: %d днів"; +"cost_auto_refresh_info" = "Автоматичне оновлення: загальний інтервал (мінімум 5 хв) · Час очікування: 10 хв"; +"cost_comparison_periods_title" = "Показувати коротші періоди порівняння"; +"cost_comparison_periods_subtitle" = "Додає підсумки за 7, 30 і 90 днів, якщо вони входять у вибране вікно історії. Для цих підсумків використовується те саме локальне сканування."; +"refresh_interval_title" = "Інтервал оновлення"; +"manual_refresh_hint" = "Автооновлення вимкнено; скористайтеся командою меню «Оновити»."; +"refresh_on_open_title" = "Оновлювати при відкритті меню"; +"refresh_on_open_subtitle" = "Отримує найновіші дані про використання для кожного провайдера щоразу, коли ви відкриваєте меню."; +"check_provider_status_title" = "Перевірте статус провайдера"; +"check_provider_status_subtitle" = "Опитує сторінки статусу OpenAI/Claude і Google Workspace для Gemini/Antigravity, виявляючи інциденти в значку та меню."; +"session_quota_notifications_subtitle" = "Повідомляє, коли 5-годинна квота сеансу досягає 0% і коли вона знову стає доступною."; +"quota_depleted_title" = "Вичерпання та відновлення квоти"; +"quota_warning_notifications_subtitle" = "Попереджає, коли залишок сеансу або тижневої квоти перевищує налаштовані порогові значення."; +"threshold_warnings_title" = "Попередження про порогові значення"; +"quota_warnings_title" = "Попередження про квоту"; +"quota_warning_session" = "сесії"; +"quota_warning_session_capitalized" = "Сесія"; +"quota_warning_weekly" = "щотижня"; +"quota_warning_weekly_capitalized" = "Щотижня"; +"quota_warning_notification_title" = "%1$@ %2$@ квота низька"; +"quota_warning_notification_body" = "%1$@ залишилося. Досягнуто %2$d%% %3$@ порогового значення попередження."; +"quota_warning_notification_body_with_account" = "Рахунок %1$@. Залишилося %2$@. Досягнуто %3$d%% %4$@ порогового значення попередження."; +"predictive_pace_warnings_title" = "Прогнозні попередження про темп"; +"predictive_pace_warnings_subtitle" = "Попереджає для Codex і Claude, коли темп сеансу або тижня може вичерпати квоту до скидання."; +"confetti_on_reset_title" = "Конфеті під час скидання"; +"confetti_on_reset_subtitle" = "Показувати повноекранне конфеті під час скидання показників використання."; +"confetti_option_off" = "Вимкнено"; +"confetti_option_session" = "Скидання сеансу"; +"confetti_option_weekly" = "Щотижневі скидання"; +"confetti_option_both" = "Обидва варіанти"; +"predictive_pace_warning_notification_title" = "%1$@: попередження про темп (%2$@)"; +"predictive_pace_warning_notification_body" = "За поточного темпу ця квота може вичерпатися за %1$@, до скидання."; +"predictive_pace_warning_notification_body_with_account" = "Обліковий запис %1$@. За поточного темпу ця квота може вичерпатися за %2$@, до скидання."; +"session_depleted_notification_title" = "%@ сеанс вичерпано"; +"session_depleted_notification_body" = "Залишилося 0%. Надішле сповіщення, коли знову стане доступним."; +"session_restored_notification_title" = "%@ сеанс відновлено"; +"session_restored_notification_body" = "Квота сесії знову доступна."; +"quota_warning_warn_at" = "Попередити при"; +"quota_warning_global_threshold_subtitle" = "Відсотки, що залишилися для вікон сесії та тижня, якщо постачальник не замінить їх."; +"quota_warning_sound" = "Відтворити звук сповіщення"; +"quota_warning_onscreen_alert" = "Показувати текстове сповіщення на екрані"; +"quota_warning_provider_inherits" = "Використовує глобальні параметри попередження про квоту, якщо тут не налаштовано вікно."; +"quota_warning_provider_disabled" = "Сповіщення про попередження щодо квоти та позначки на панелях використання вимкнено. Увімкніть хоча б одну з цих функцій, щоб редагувати збережені налаштування."; +"quota_warning_provider_markers_only" = "Сповіщення про попередження квоти вимкнено глобально. Ці налаштування й надалі керують позначками на панелях використання."; +"quota_warning_global" = "Глобально"; +"quota_warning_customize_thresholds" = "Налаштувати порогові значення %@"; +"quota_warning_enable_warnings" = "Увімкнути %@ попереджень"; +"quota_warning_window_warn_at" = "%@ попередити о"; +"quota_warning_off" = "Вимкнено"; +"quota_warning_inherited" = "Успадковано: %@"; +"quota_warning_depleted_only" = "лише виснажені"; +"quota_warning_upper" = "Вищий"; +"quota_warning_lower" = "Нижній"; +"quota_warning_warning" = "Попередження"; +"quota_warning_critical" = "Критично"; +"apply" = "Застосувати"; +"quit_app" = "Закрийте CodexBar"; + +/* Tab titles */ +"tab_general" = "Загальні"; +"tab_providers" = "Провайдери"; +"tab_notifications" = "Сповіщення"; +"tab_menu_bar" = "Рядок меню"; +"tab_menu" = "Меню"; +"tab_advanced" = "Розширені"; +"tab_about" = "Про програму"; +"tab_debug" = "Налагодження"; + +/* Providers Pane */ +"select_a_provider" = "Виберіть провайдера"; +"cancel" = "Скасувати"; +"last_fetch_failed" = "остання вибірка не вдалася"; +"usage_not_fetched_yet" = "використання ще не отримано"; +"managed_account_storage_unreadable" = "Сховище керованого облікового запису не читається. Доступ до поточного облікового запису все ще доступний, але керовані дії додавання, повторної авторизації та видалення вимкнено, доки магазин не буде відновлено."; +"remove_codex_account_title" = "Видалити обліковий запис Codex?"; +"remove" = "видалити"; +"managed_login_already_running" = "Керований вхід до Codex вже запущено. Зачекайте, поки це завершиться, перш ніж додавати або повторно автентифікувати інший обліковий запис."; +"managed_login_failed" = "Керований вхід Codex не завершено. Переконайтеся, що `codex --version` працює в терміналі. Якщо macOS заблокувала або перемістила `codex` у кошик, видаліть застарілі повторювані встановлення, запустіть `npm install -g --include=optional @openai/codex@latest`, а потім повторіть спробу."; +"codex_login_output" = "код входу в систему:"; +"managed_login_missing_email" = "Вхід до Codex завершено, але електронна адреса облікового запису недоступна. Повторіть спробу після того, як підтвердите, що обліковий запис повністю ввійшли."; +"login_success_notification_title" = "%@ вхід успішний"; +"login_success_notification_body" = "Ви можете повернутися до програми; аутентифікація завершена."; +"workspace_selection_cancelled" = "CodexBar знайшов кілька робочих областей, але жодна робоча область не була вибрана."; +"unsafe_managed_home" = "CodexBar відмовився змінити неочікуваний керований домашній шлях: %@"; +"menu_bar_metric_title" = "Метрика панелі меню"; +"menu_bar_metric_subtitle" = "Виберіть, яке вікно керує відсотками панелі меню."; +"menu_bar_metric_subtitle_deepseek" = "Показує баланс DeepSeek на панелі меню."; +"menu_bar_metric_subtitle_moonshot" = "Показує баланс API Moonshot / Kimi на панелі меню."; +"menu_bar_metric_subtitle_mistral" = "Показує поточні витрати Mistral API на панелі меню."; +"automatic" = "Автоматичний"; +"primary_api_key_limit" = "Основний (обмеження ключа API)"; + +/* Display Pane */ +"menu_bar_style_title" = "Стиль рядка меню"; +"menu_bar_style_subtitle" = "Визначає вигляд елемента рядка меню."; +"menu_bar_inactive_display_contrast_title" = "Покращити видимість на неактивних дисплеях"; +"menu_bar_usage_colors_title" = "Кольорова індикація витрат"; +"menu_bar_usage_colors_subtitle" = "Забарвлює піктограму в рядку меню від зеленого до червоного зі зростанням витрат."; +"menu_bar_inactive_display_contrast_subtitle" = "Використовує висококонтрастне відтворення, щоб піктограма й показник залишалися читабельними на інших дисплеях."; +"menu_bar_style_critters" = "Тварини"; +"menu_bar_style_bars" = "Смуги-індикатори"; +"menu_bar_style_icon_percent" = "Значок і відсоток"; +"switcher_rows_title" = "Рядки перемикача"; +"switcher_rows_icons" = "Значки провайдерів"; +"switcher_rows_progress" = "Тижневий прогрес"; +"usage_bars_fill_title" = "Заповнення смуг використання"; +"usage_bars_fill_remaining" = "За залишком"; +"usage_bars_fill_used" = "За використанням"; +"reset_times_title" = "Час скидання"; +"reset_times_countdown" = "Зворотний відлік"; +"reset_times_clock" = "Час на годиннику"; +"cost_summary_title" = "Підсумок витрат"; +"cost_summary_off" = "Вимкнено"; +"merge_icons_title" = "Злиття значків"; +"merge_icons_subtitle" = "Використовуйте одну піктограму панелі меню з перемикачем провайдерів."; +"show_most_used_provider_title" = "Показати постачальника, який найчастіше використовується"; +"show_most_used_provider_subtitle" = "Рядок меню автоматично показує постачальника, який найближче до ліміту."; +"display_mode_title" = "Режим відображення"; +"display_mode_subtitle" = "Виберіть, що відображати на панелі меню (Pace показує використання порівняно з очікуваним)."; +"show_quota_warning_markers_title" = "Показати маркери попередження про квоти"; +"show_quota_warning_markers_subtitle" = "Малюйте порогові позначки на панелях використання, коли налаштовано попередження про квоту."; +"weekly_progress_work_days_title" = "Щотижневі робочі дні"; +"weekly_progress_work_days_subtitle" = "Задайте робочі дні для позначок на смугах тижневого використання та розрахунків темпу."; +"show_provider_changelog_links_title" = "Показати посилання журналу змін провайдера"; +"show_provider_changelog_links_subtitle" = "Додає в меню посилання на примітки до випуску для підтримуваних постачальників, що підтримують CLI."; +"show_credits_extra_usage_title" = "Показати кредити + додаткове використання"; +"show_credits_extra_usage_subtitle" = "Показати в меню розділи використання кредитів Codex і Claude Extra."; +"multi_account_layout_title" = "Макет кількох облікових записів"; +"multi_account_layout_subtitle" = "Виберіть сегментоване перемикання облікових записів або складені картки облікових записів."; +"multi_account_layout_segmented" = "Сегментований"; +"multi_account_layout_stacked" = "складені"; +"overview_tab_providers_title" = "Постачальники вкладок огляду"; +"configure" = "Налаштувати…"; +"overview_enable_merge_icons_hint" = "Увімкніть Merge Icons, щоб налаштувати постачальників вкладок «Огляд»."; +"overview_no_providers_hint" = "Немає активованих постачальників, доступних для огляду."; +"overview_rows_follow_order" = "Оглядові рядки завжди відповідають порядку постачальника."; +"overview_no_providers_selected" = "Не вибрано жодного постачальника"; +"agent_sessions_title" = "Сеанси агентів"; +"agent_sessions_subtitle" = "Показувати в меню локальні й виявлені через SSH сеанси Codex і Claude Code."; +"agent_sessions_hosts_title" = "Додаткові хости SSH"; +"agent_sessions_footer" = "Комп’ютери Mac у вашій мережі tailnet виявляються автоматично. Локальні сеанси оновлюються кожні 30 секунд; віддалені хости — кожні 60 секунд і під час відкриття меню."; +"agent_session_labels_title" = "Назви сеансів"; +"agent_session_labels_subtitle" = "Виберіть, як називати сеанси агентів."; +"agent_session_label_project" = "Проєкт"; +"agent_session_label_descriptive" = "Описова"; +"agent_session_label_descriptive_and_project" = "Описова + проєкт"; +"agent_session_unknown_project" = "Невідомий проєкт"; + +/* Advanced Pane */ +"section_keyboard_shortcut" = "Комбінація клавіш"; +"open_menu_shortcut_title" = "Відкрити меню"; +"open_menu_shortcut_subtitle" = "Викликати меню панелі меню з будь-якого місця."; +"install_cli" = "Встановіть CLI"; +"install_cli_subtitle" = "Символьне посилання CodexBarCLI на /usr/local/bin і /opt/homebrew/bin як codexbar."; +"cli_not_found" = "CodexBarCLI не знайдено в пакеті програм."; +"no_writable_bin_dirs" = "Не знайдено записуваних каталогів кошика."; +"show_debug_settings_title" = "Показати налаштування налагодження"; +"show_debug_settings_subtitle" = "Розкрийте інструменти усунення несправностей на вкладці Debug."; +"surprise_me_title" = "Здивуйте мене"; +"surprise_me_subtitle" = "Перевірте, чи подобається вам, що ваші агенти розважаються там."; +"hide_personal_info_title" = "Приховати особисту інформацію"; +"hide_personal_info_subtitle" = "Незрозумілі адреси електронної пошти на панелі меню та інтерфейсі меню."; +"show_provider_storage_usage_title" = "Показати використання сховища постачальника"; +"show_provider_storage_usage_subtitle" = "Показати використання локального диска в меню. Сканує відомі шляхи, що належать провайдеру, у фоновому режимі."; +"section_keychain_access" = "Доступ через брелок"; +"keychain_access_caption" = "Вимкніть усі функції читання та запису Keychain. Використовуйте це, якщо macOS постійно запитує «Chrome/Brave/Edge Safe Storage» навіть після натискання «Завжди дозволяти». Імпорт файлів cookie браузера недоступний, якщо ввімкнено; вставте заголовки файлів cookie вручну в Провайдери. Claude/Codex OAuth через CLI все ще працює."; +"disable_keychain_access_title" = "Вимкнути доступ Keychain"; +"disable_keychain_access_subtitle" = "Запобігає будь-якому доступу Keychain, коли ввімкнено."; + +/* About Pane */ +"about_tagline" = "Нехай ваші токени ніколи не закінчаться — пам’ятайте про ліміти агентів."; +"link_github" = "GitHub"; +"link_website" = "Веб-сайт"; +"link_twitter" = "Twitter"; +"link_email" = "Електронна пошта"; +"check_updates_auto" = "Автоматично перевіряти наявність оновлень"; +"update_channel" = "Оновити канал"; +"check_for_updates" = "Перевірити наявність оновлень…"; +"updates_unavailable" = "Оновлення недоступні в цій збірці."; +"copyright" = "© 2026 Пітер Штайнбергер. Ліцензія MIT."; + +/* Debug Pane */ +"section_logging" = "Лісозаготівля"; +"enable_file_logging" = "Увімкнути журналювання файлів"; +"enable_file_logging_subtitle" = "Записати журнали до %@ для налагодження."; +"verbosity_title" = "Багатослівність"; +"verbosity_subtitle" = "Контролює, скільки деталей реєструється."; +"open_log_file" = "Відкрити файл журналу"; +"force_animation_next_refresh" = "Примусово запускати анімацію під час наступного оновлення"; +"force_animation_next_refresh_subtitle" = "Тимчасово показує анімацію завантаження після наступного оновлення."; +"section_loading_animations" = "Завантаження анімацій"; +"loading_animations_caption" = "Виберіть шаблон і відтворіть його на панелі меню. \\\"Випадкове\\\" зберігає існуючу поведінку."; +"animation_random_default" = "Випадковий (за замовчуванням)"; +"replay_selected_animation" = "Повторити вибрану анімацію"; +"blink_now" = "Поморгай зараз"; +"section_probe_logs" = "Зондові журнали"; +"probe_logs_caption" = "Отримати останній результат тестування для налагодження; Копія зберігає повний текст."; +"fetch_log" = "Отримати журнал"; +"copy" = "Копіювати"; +"save_to_file" = "Зберегти у файл"; +"load_parse_dump" = "Завантажити дамп аналізу"; +"rerun_provider_autodetect" = "Повторно запустіть автоматичне визначення постачальника"; +"loading" = "Завантаження…"; +"no_log_yet_fetch" = "Журналу ще немає. Отримати для завантаження."; +"section_fetch_strategy" = "Спроби отримання стратегії"; +"fetch_strategy_caption" = "Рішення та помилки конвеєра останньої вибірки для постачальника."; +"section_openai_cookies" = "Файли cookie OpenAI"; +"openai_cookies_caption" = "Імпорт файлів cookie + сканування журналів WebKit з останньої спроби файлів cookie OpenAI."; +"no_log_yet" = "Журналу ще немає. Оновіть файли cookie OpenAI у Постачальники → Codex, щоб запустити імпорт."; +"section_caches" = "Тайники"; +"caches_caption" = "Очистити кеш-пам’ять результатів сканування витрат або кешу файлів cookie браузера."; +"clear_cookie_cache" = "Очистити кеш cookie"; +"clear_cost_cache" = "Очистити кеш вартості"; +"section_notifications" = "Сповіщення"; +"notifications_caption" = "Запуск тестових сповіщень для 5-годинного вікна сеансу (вичерпано/відновлено)."; +"post_depleted" = "Повідомлення вичерпано"; +"post_restored" = "Пост відновлено"; +"section_cli_sessions" = "Сесії CLI"; +"cli_sessions_caption" = "Підтримуйте сесії Codex/Claude CLI після зонду. За замовчуванням виходить після збору даних."; +"keep_cli_sessions_alive" = "Підтримуйте сесії CLI"; +"keep_cli_sessions_alive_subtitle" = "Пропустити демонтаж між зондами (лише для налагодження)."; +"reset_cli_sessions" = "Скидання сеансів CLI"; +"section_error_simulation" = "Симуляція помилок"; +"error_simulation_caption" = "Введіть фальшиве повідомлення про помилку в картку меню для тестування макета."; +"set_menu_error" = "Помилка налаштування меню"; +"clear_menu_error" = "Помилка очищення меню"; +"set_cost_error" = "Помилка встановлення вартості"; +"clear_cost_error" = "Очистити помилку вартості"; +"section_cli_paths" = "Шляхи CLI"; +"cli_paths_caption" = "Вирішено двійковий шар Codex і PATH; запуск входу PATH захоплення (короткий тайм-аут)."; +"codex_binary" = "Двійковий код Codex"; +"claude_binary" = "Claude бінарний"; +"effective_path" = "Ефективний ШЛЯХ"; +"unavailable" = "Недоступний"; +"login_shell_path" = "ШЛЯХ до оболонки входу (запис під час запуску)"; +"cleared" = "Очищено."; +"no_fetch_attempts" = "Ще жодних спроб отримання."; +"macOS Tahoe can block menu bar apps in System Settings → Menu Bar → Allow in the Menu Bar. CodexBar is running, but macOS may be hiding its icon. Open Menu Bar settings and turn CodexBar on." = "macOS Tahoe може блокувати програми панелі меню в системних параметрах → Рядок меню → Дозволити на панелі меню. CodexBar працює, але macOS може приховувати свій значок. Відкрийте налаштування рядка меню та ввімкніть CodexBar."; + +/* Metric preferences */ +"metric_pref_automatic" = "Автоматичний"; +"metric_pref_primary" = "Первинний"; +"metric_pref_secondary" = "Вторинний"; +"metric_pref_tertiary" = "Третинний"; +"metric_pref_extra_usage" = "Додаткове використання"; +"metric_pref_average" = "Середній"; +"metric_mistral_payg" = "Pay-as-you-go"; +"metric_mistral_monthly_plan" = "Monthly Plan"; + +/* Display modes */ +"display_mode_percent" = "Відсоток"; +"display_mode_pace" = "Темп"; +"display_mode_both" = "Обидва"; +"display_mode_reset_time" = "Час скидання"; +"display_mode_percent_desc" = "Показати залишок/використаний відсоток (наприклад, 45%)"; +"display_mode_pace_desc" = "Показати індикатор темпу (наприклад, +5%)"; +"display_mode_both_desc" = "Показати відсоток і темп (наприклад, 45% · +5%)"; +"display_mode_reset_time_desc" = "Показувати час скидання для вибраного показника (наприклад, ↻ 15:56)"; +"menu_bar_reset_when_exhausted_title" = "Показувати час скидання, коли квоту вичерпано"; +"menu_bar_reset_when_exhausted_subtitle" = "За залишку 0% показує час до скидання замість відсотка"; + +/* Provider status */ +"status_operational" = "Працює"; +"status_degraded" = "Знижена продуктивність"; +"status_partial_outage" = "Часткове відключення"; +"status_major_outage" = "Серйозний збій"; +"status_critical_issue" = "Критична проблема"; +"status_maintenance" = "Технічне обслуговування"; +"status_unknown" = "Статус невідомий"; + +/* Refresh frequency */ +"refresh_manual" = "Інструкція"; +"refresh_1min" = "1 хв"; +"refresh_2min" = "2 хв"; +"refresh_5min" = "5 хв"; +"refresh_15min" = "15 хв"; +"refresh_30min" = "30 хв"; +"refresh_adaptive" = "Адаптивний"; +"refresh_adaptive_agent_aware" = "Адаптивний (з урахуванням агентів)"; +"adaptive_activity_consent_title" = "Дозволити оновлення з урахуванням активності?"; +"adaptive_activity_consent_message" = "Адаптивний режим з урахуванням агентів може перевіряти список запущених локальних процесів, зокрема командні рядки, щоб розпізнавати Codex і Claude, а потім під час програмування кожні 30 секунд зчитувати метадані відомих сеансів. Коли Agent Sessions вимкнено, CodexBar зберігає в пам’яті лише час останньої активності та відкидає шляхи й ідентифікатори сеансів. Ці дані нікуди не надсилаються, а віддалене виявлення та SSH залишаються вимкненими. Якщо відмовитися, CodexBar повернеться до звичайного адаптивного режиму без сканування локальної активності."; +"adaptive_activity_consent_allow" = "Дозволити локальну активність"; +"adaptive_activity_consent_decline" = "Використовувати звичайний адаптивний режим"; + +/* Additional keys */ +"not_found" = "Не знайдено"; + +/* Cost estimation */ +"cost_estimate_hint" = "Оцінка з місцевих журналів · може відрізнятися від вашого рахунку"; +"codex_api_estimate_hint" = "Розраховано за використанням токенів · не рахунок за підписку"; +"cost_data_explanation" = "Витрати можуть бути надані провайдером або розраховані за використанням токенів на основі загальнодоступних цін API. Оцінки не є платою за підписку."; +"No JetBrains IDE with AI Assistant detected. Install a JetBrains IDE and enable AI Assistant." = "Не виявлено JetBrains IDE з AI Assistant. Встановіть JetBrains IDE і ввімкніть AI Assistant."; +"OpenRouter API token not configured. Set OPENROUTER_API_KEY environment variable or configure in Settings." = "Маркер OpenRouter API не налаштовано. Установіть змінну середовища OPENROUTER_API_KEY або налаштуйте її в налаштуваннях."; +"z.ai API token not found. Set apiKey in ~/.codexbar/config.json or Z_AI_API_KEY." = "Маркер API z.ai не знайдено. Установіть apiKey у ~/.codexbar/config.json або Z_AI_API_KEY."; +"Missing DeepSeek API key." = "Відсутній ключ API DeepSeek."; +"%@ is unavailable in the current environment." = "%@ недоступний у поточному середовищі."; +"All Systems Operational" = "Всі системи працюють"; +"Last 30 days" = "Останні 30 днів"; +"Last 30 days:" = "Останні 30 днів:"; +"This month" = "Цей місяць"; +"Store multiple OpenAI API keys." = "Зберігайте кілька ключів OpenAI API."; +"Admin API key" = "Ключ API адміністратора"; +"Open billing" = "Відкритий білінг"; +"Google accounts" = "облікові записи Google"; +"Store multiple Antigravity Google OAuth accounts for quick switching." = "Зберігайте кілька облікових записів Antigravity Google OAuth для швидкого перемикання."; +"Add Google Account" = "Додайте обліковий запис Google"; +"Open Token Plan" = "Відкритий план токенів"; +"Text Generation" = "Генерація тексту"; +"Text to Speech" = "Перетворення тексту в мовлення"; +"Music Generation" = "Музичне покоління"; +"Image Generation" = "Генерація зображень"; +"No local data found" = "Немає локальних даних"; +"Credits unavailable; keep Codex running to refresh." = "Кредити недоступні; продовжуйте працювати Codex для оновлення."; +"No available fetch strategy for minimax." = "Немає доступної стратегії вибірки для minimax."; +"No Cursor session found. Please log in to cursor.com in Safari, Chrome, Microsoft Edge, Brave, Arc, Dia, ChatGPT Atlas, Chromium, Helium, Vivaldi, Yandex Browser, Firefox, Zen, Colibri, Sidekick, Opera, Opera GX, or Edge Canary. If you use Safari, grant CodexBar Full Disk Access in System Settings ▸ Privacy & Security. You can also sign in to Cursor from the CodexBar menu (Add / switch account)." = "Сеанс курсору не знайдено. Увійдіть на cursor.com у Safari, Chrome, Microsoft Edge, Brave, Arc, Dia, ChatGPT Atlas, Chromium, Helium, Vivaldi, Yandex Browser, Firefox, Zen, Colibri, Sidekick, Opera, Opera GX або Edge Canary. Якщо ви користуєтеся Safari, надайте CodexBar повний доступ до диска в системних параметрах ▸ Конфіденційність і безпека. Ви також можете ввійти в Cursor з меню CodexBar (Додати/змінити обліковий запис)."; +"No OpenCode session cookies found in browsers." = "У браузерах не знайдено сеансових файлів cookie OpenCode."; +"No available fetch strategy for %@." = "Немає доступної стратегії отримання для %@."; +"Today" = "Сьогодні"; +"Today tokens" = "Сьогодні жетони"; +"30d cost" = "Вартість 30д"; +"%@ cost" = "Вартість %@"; +"30d tokens" = "30d жетонів"; +"Latest tokens" = "Останні жетони"; +"Top model" = "Топ модель"; +"Storage" = "Зберігання"; +"Add Account..." = "Додати обліковий запис..."; +"Usage Dashboard" = "Панель використання"; +"Status Page" = "Сторінка стану"; +"Open Status Page" = "Відкрити сторінку стану"; +"Settings..." = "Налаштування..."; +"About CodexBar" = "Про CodexBar"; +"Quit" = "Вийти"; +"Last %d day" = "Останній %d день"; +"Last %d days" = "Останні %d днів"; +"%@ tokens" = "%@ токенів"; +"Latest billing day" = "Останній розрахунковий день"; +"Latest billing day (%@)" = "Останній розрахунковий день (%@)"; +"%@ left" = "Залишилося %@"; +"Resets %@" = "Скидання %@"; +"Resets in %@" = "Скидання через %@"; +"Resets now" = "Скидає зараз"; +"reset_tomorrow_format" = "завтра, %@"; +"Lasts until reset" = "Триває до скидання"; +"1.5× headroom" = "запас 1,5×"; +"Updated %@" = "Оновлено %@"; +"Updated relative %@" = "Оновлено %@"; +"Updated absolute %@" = "Оновлено %@"; +"Updated %@h ago" = "Оновлено %@ год тому"; +"Updated %@m ago" = "Оновлено %@хв тому"; +"Updated just now" = "Оновлено щойно"; +"Projected empty in %@" = "Передбачається порожній у %@"; +"Runs out in %@" = "Закінчується за %@"; +"Pace: %@" = "Темп: %@"; +"Pace: %@ · %@" = "Темп: %@ · %@"; +"%@ · %@" = "%@ · %@"; +"≈ %d%% run-out risk" = "≈ %d%% ризик вичерпання"; +"%d%% in deficit" = "%d%% в дефіциті"; +"%d%% in reserve" = "%d%% в резерві"; +"usage_percent_suffix_left" = "зліва"; +"usage_percent_suffix_used" = "використовується"; +"Store multiple DeepSeek API keys." = "Зберігайте кілька ключів DeepSeek API."; +"This week" = "Цього тижня"; +"Week" = "тиждень"; +"Month" = "місяць"; +"Models" = "Моделі"; +"24h tokens" = "24-годинні жетони"; +"Latest hour" = "Остання година"; +"Peak hour" = "Година пік"; +"Top method" = "Топовий спосіб"; +"30d cash" = "30д готівкою"; +"30d billing history from MiniMax web session" = "30-денна історія платежів з веб-сесії MiniMax"; +"AWS Cost Explorer billing can lag." = "Виставлення рахунків AWS Cost Explorer може затримуватися."; +"Rate limit: %d / %@" = "Ліміт швидкості: %d / %@"; +"Key remaining" = "Ключ залишився"; +"No limit set for the API key" = "Для ключа API не встановлено обмежень"; +"API key limit unavailable right now" = "Ліміт ключів API зараз недоступний"; +"This month: %@ tokens" = "Цей місяць: %@ токенів"; +"No utilization data yet." = "Даних про використання ще немає."; +"No %@ utilization data yet." = "Ще немає даних про використання %@."; +"%@: %@%% used" = "%@: використано %@%%."; +"%dd" = "%dд"; +"today" = "сьогодні"; +"just now" = "тільки зараз"; +"On pace" = "В темпі"; +"Runs out now" = "Зараз закінчується"; +"Projected empty now" = "Зараз проектується порожнім"; +"Switch Account..." = "Змінити обліковий запис..."; +"Update ready, restart now?" = "Оновлення готове, перезапустити?"; +"Daily" = "Щодня"; +"Hourly Tokens" = "Погодинні жетони"; +"No data" = "Немає даних"; +"No usage breakdown data available." = "Немає даних про розподіл використання."; + +"Today: %@ · %@ tokens" = "Сьогодні: %@ · %@ токенів"; +"Today: %@" = "Сьогодні: %@"; +"Today: %@ tokens" = "Сьогодні: %@ токенів"; +"Last 30 days: %@ · %@ tokens" = "Останні 30 днів: %@ · %@ токенів"; +"Last 30 days: %@" = "Останні 30 днів: %@"; +"Est. total (30d): %@" = "Приблизно всього (30 днів): %@"; +"Est. total (%@): %@" = "Приблизно всього (%@): %@"; +"Hover a bar for details" = "Щоб переглянути деталі, наведіть курсор на панель"; +"%@: %@ · %@ tokens" = "%@: %@ · %@ токенів"; +"No providers selected for Overview." = "Для огляду не вибрано жодного постачальника."; +"No overview data available." = "Немає оглядових даних."; +"Auto uses the local IDE API first, then Google OAuth when the IDE is closed." = "Auto спочатку використовує локальний API IDE, а потім Google OAuth, коли IDE закрито."; +"Login with Google" = "Увійти через Google"; + +/* Popup panels */ +"No usage configured." = "Використання не налаштовано."; +"Quota" = "Квота"; +"Daily quota" = "Денна квота"; +"Total" = "Усього"; +"tokens" = "жетони"; +"requests" = "запити"; +"Latest" = "Останній"; +"Monthly" = "Щомісяця"; +"Sonnet" = "Сонет"; +"Overages" = "Надлишки"; +"Activity" = "діяльність"; +"Copied" = "Скопійовано"; +"Copy error" = "Помилка копіювання"; +"Copy path" = "Копіювати шлях"; +"Extra usage spent" = "Витрачено додаткове використання"; +"Credits remaining" = "Залишок кредитів"; +"Using CLI fallback" = "Використання запасного CLI"; +"Balance updates in near-real time (up to 5 min lag)" = "Оновлення балансу майже в реальному часі (затримка до 5 хвилин)"; +"Daily billing data finalizes at 07:00 UTC" = "Щоденні платіжні дані завершуються о 07:00 UTC"; +"%@ of %@ credits left" = "Залишилося %@ з %@ кредитів"; +"%@ of %@ bonus credits left" = "Залишилося %@ з %@ бонусних кредитів"; +"%@ / %@ (%@ remaining)" = "%@ / %@ (залишилося %@)"; +"%@/%@ left" = "Залишилося %@/%@"; +"Gemini Flash" = "Gemini Флеш"; +"Regenerates %@" = "Регенерує %@"; +"used after next regen" = "використовується після наступної регенерації"; +"after next regen" = "після наступної реген"; +"Near full" = "Майже повний"; +"Full in ~1 regen" = "Повний за ~1 регенерацію"; +"Full in ~%.0f regens" = "Повний ~%.0f регенерацій"; +"Overage usage" = "Надмірне використання"; +"Overage cost" = "Перевищення вартості"; +"credits" = "кредити"; +"Zen balance" = "Дзен баланс"; +"API spend" = "Витрати API"; +"Extra usage" = "Додаткове використання"; +"Quota usage" = "Використання квоти"; +"Your spend" = "Ваші витрати"; +"%.0f%% used" = "Використано %.0f%%."; +"Usage history (today)" = "Історія використання (сьогодні)"; +"Usage history (%d days)" = "Історія використання (%d днів)"; +"%d percent remaining" = "Залишилося %d відсотків"; +"Unknown" = "Невідомо"; +"stale data" = "застарілі дані"; +"No credits history data." = "Немає даних про кредитну історію."; +"No credits history data available." = "Немає даних про кредитну історію."; +"Credits history chart" = "Графік кредитної історії"; +"%d days of credits data" = "Дані кредитів за %d днів"; +"Usage breakdown chart" = "Діаграма розподілу використання"; +"%d days of usage data across %d services" = "%d днів використання даних у %d службах"; +"Cost history chart" = "Графік історії витрат"; +"%d days of cost data" = "Дані про витрати за %d днів"; +"Plan utilization chart" = "Графік використання плану"; +"%d utilization samples" = "%d зразки використання"; +"Hourly Usage" = "Погодинне використання"; +"Usage remaining" = "Залишилося використання"; +"Usage used" = "Використання використано"; +"API key verified. Cloud quotas need browser cookies. Sign in to Ollama." = "Ключ API перевірено. Для квот Cloud потрібні файли cookie браузера. Увійдіть в Ollama."; +"Last 30 days: %@ tokens" = "Останні 30 днів: %@ токенів"; +"7d spend" = "7д витратити"; +"30d spend" = "витратити 30 днів"; +"Cache read" = "Читання кешу"; +"Claude Admin API 30 day spend trend" = "Claude Admin API 30-денна тенденція витрат"; +"OpenRouter API key spend trend" = "Тенденція витрат на ключ OpenRouter API"; +"z.ai hourly token trend" = "погодинний тренд токена z.ai"; +"MiniMax 30 day token usage trend" = "Тенденція використання токенів MiniMax за 30 днів"; +"Today cash" = "Сьогодні готівкою"; +"DeepSeek 30 day token usage trend" = "30-денна тенденція використання токенів DeepSeek"; +"cache-hit input" = "введення кешу"; +"cache-miss input" = "cache-miss input"; +"output" = "вихід"; +"Requests" = "Запити"; +"Reported by OpenAI Admin API organization usage." = "Повідомлено про використання організацією OpenAI Admin API."; +"Reported by Mistral billing usage." = "Повідомлено Mistral billing usage."; +"Google OAuth" = "Google OAuth"; +"Add accounts via GitHub OAuth Device Flow on the selected host." = "Додайте облікові записи через GitHub OAuth Device Flow на вибраному хості."; +"Stores each signed-in Google account for quick Antigravity switching. Uses Antigravity.app OAuth when available, or ANTIGRAVITY_OAUTH_CLIENT_ID and ANTIGRAVITY_OAUTH_CLIENT_SECRET as an override." = "Зберігає кожен обліковий запис Google, у який ви ввійшли, для швидкого перемикання Antigravity. Використовує OAuth Antigravity.app, якщо доступний, або ANTIGRAVITY_OAUTH_CLIENT_ID і ANTIGRAVITY_OAUTH_CLIENT_SECRET як заміну."; +"Manual cleanup: past sessions" = "Очищення вручну: минулі сесії"; +"Clearing removes past resume, continue, and rewind history." = "Очищення видаляє історію відновлення, продовження та перемотування назад."; +"Manual cleanup: file checkpoints" = "Ручне очищення: контрольні точки файлів"; +"Clearing removes checkpoint restore data for previous edits." = "Очищення видаляє дані відновлення контрольної точки для попередніх змін."; +"Manual cleanup: saved plans" = "Ручне очищення: збережені плани"; +"Clearing removes old plan-mode files." = "Очищення видаляє старі файли планового режиму."; +"Manual cleanup: debug logs" = "Ручне очищення: журнали налагодження"; +"Clearing removes past debug logs." = "Очищення видаляє попередні журнали налагодження."; +"Manual cleanup: attachment cache" = "Очищення вручну: кеш вкладень"; +"Clearing removes cached large pastes or attached images." = "Очищення видаляє кешовані великі вставки або прикріплені зображення."; +"Manual cleanup: session metadata" = "Очищення вручну: метадані сеансу"; +"Clearing removes per-session environment metadata." = "Очищення видаляє метадані середовища для кожного сеансу."; +"Manual cleanup: shell snapshots" = "Ручне очищення: знімки оболонки"; +"Clearing removes leftover runtime shell snapshot files." = "Очищення видаляє залишкові файли знімків оболонки виконання."; +"Manual cleanup: legacy todos" = "Очищення вручну: застарілі завдання"; +"Clearing removes legacy per-session task lists." = "Очищення видаляє застарілі списки сеансових завдань."; +"Manual cleanup: sessions" = "Ручне очищення: сесії"; +"Clearing removes past Codex session history." = "Очищення видаляє минулу історію сеансів Codex."; +"Manual cleanup: archived sessions" = "Очищення вручну: заархівовані сеанси"; +"Clearing removes archived Codex session history." = "Очищення видаляє архівну історію сеансів Codex."; +"Manual cleanup: cache" = "Очищення вручну: кеш"; +"Clearing removes provider-owned cached data." = "Очищення видаляє кешовані дані постачальника."; +"Manual cleanup: logs" = "Ручне очищення: журнали"; +"Clearing removes local diagnostic logs." = "Очищення видаляє локальні журнали діагностики."; +"Manual cleanup: file history" = "Ручне очищення: історія файлів"; +"Clearing removes local edit checkpoint history." = "Очищення видаляє локальну історію контрольних точок редагування."; +"Manual cleanup: temporary data" = "Очищення вручну: тимчасові дані"; +"Clearing removes local temporary provider data." = "Очищення видаляє локальні тимчасові дані постачальника."; +"Total: %@" = "Усього: %@"; +"%d more items" = "ще %d елементів"; +"Cleanup ideas" = "Ідеї ​​очищення"; +"%d unreadable item(s) skipped" = "%d нечитабельних елементів пропущено"; + +"API key limit" = "Обмеження ключа API"; +"Auth" = "Авт"; +"Auto" = "Авто"; +"Disabled — no recent data" = "Вимкнено — немає останніх даних"; +"Limits not available" = "Обмеження недоступні"; +"No usage yet" = "Поки що не використовується"; +"Not fetched yet" = "Ще не отримано"; +"Refreshing" = "Освіжаючий"; +"Session" = "Сесія"; +"Source" = "Джерело"; +"State" = "Держава"; +"Unavailable" = "Недоступний"; +"Weekly" = "Щотижня"; +"not detected" = "не виявлено"; +"Estimated from local Codex logs for the selected account." = "Оцінено з локальних журналів Codex для вибраного облікового запису."; +"minimax_usage_amount_format" = "Використання: %@ / %@"; +"minimax_used_percent_format" = "Використаний %@"; +"minimax_service_text_generation" = "Генерація тексту"; +"minimax_service_text_to_speech" = "Перетворення тексту в мовлення"; +"minimax_service_music_generation" = "Музичне покоління"; +"minimax_service_image_generation" = "Генерація зображень"; +"minimax_service_lyrics_generation" = "Генерація пісень"; +"minimax_service_coding_plan_vlm" = "План кодування VLM"; +"minimax_service_coding_plan_search" = "Пошук плану кодування"; + +/* Additional provider settings and alerts */ +"%@ is waiting for permission" = "%@ чекає на дозвіл"; +"%@ requests" = "%@ запитів"; +"%@: %@ credits" = "%@: %@ кредитів"; +"30d requests" = "30d запитів"; +"4 days" = "4 дні"; +"5 days" = "5 днів"; +"7 days" = "7 днів"; +"API key verifies Ollama Cloud access; cookies still expose quota limits." = "Ключ API перевіряє доступ до Ollama Cloud; файли cookie все ще розкривають обмеження квоти."; +"AWS access key ID. Can also be set with AWS_ACCESS_KEY_ID." = "Ідентифікатор ключа доступу до AWS. Також можна встановити за допомогою AWS_ACCESS_KEY_ID."; +"AWS region. Can also be set with AWS_REGION." = "Регіон AWS. Також можна встановити за допомогою AWS_REGION."; +"AWS secret access key. Can also be set with AWS_SECRET_ACCESS_KEY." = "Секретний ключ доступу до AWS. Також можна встановити за допомогою AWS_SECRET_ACCESS_KEY."; +"Access key ID" = "Ідентифікатор ключа доступу"; +"Add Account" = "Додати обліковий запис"; +"Adding Account…" = "Додавання облікового запису…"; +"Antigravity login failed" = "Помилка входу в Antigravity"; +"Antigravity login timed out" = "Час очікування входу в антигравітацію минув"; +"Auth source" = "Джерело авторизації"; +"Automatic imports browser cookies from Xiaomi MiMo." = "Автоматично імпортує файли cookie браузера з Xiaomi MiMo."; +"Automatic imports Windsurf session data from Chromium browser localStorage." = "Автоматично імпортує дані сесії Windsurf із локального сховища браузера Chromium."; +"Automatic imports browser cookies from Bailian." = "Автоматично імпортує файли cookie браузера з Bailian."; +"Automatically imports browser cookies." = "Автоматично імпортує файли cookie браузера."; +"Automatically imports browser session cookies." = "Автоматично імпортує файли cookie сеансу браузера."; +"Azure OpenAI deployment name. AZURE_OPENAI_DEPLOYMENT_NAME is also supported." = "Назва розгортання Azure OpenAI. AZURE_OPENAI_DEPLOYMENT_NAME також підтримується."; +"Azure OpenAI key" = "Ключ Azure OpenAI"; +"Azure OpenAI resource endpoint. AZURE_OPENAI_ENDPOINT is also supported." = "Кінцева точка ресурсу Azure OpenAI. AZURE_OPENAI_ENDPOINT також підтримується."; +"Base URL" = "Базовий URL"; +"Base URL for the LLM-API-Key-Proxy instance." = "Базова URL-адреса для примірника LLM-API-Key-Proxy."; +"Browser cookies" = "Файли cookie браузера"; +"Cap end" = "Кінець кришки"; +"Cap start" = "Початок шапки"; +"Capacity End" = "Кінець ємності"; +"Capacity Start" = "Ємність Старт"; +"Changelog" = "Журнал змін"; +"Choose the Moonshot/Kimi API host for international or China mainland accounts." = "Виберіть хост API Moonshot/Kimi для міжнародних або материкового Китаю облікових записів."; +"CodexBar can't replace a system account that is signed in with an API key only setup." = "CodexBar не може замінити системний обліковий запис, який увійшов лише за допомогою ключа API."; +"CodexBar could not find saved auth for that account. Re-authenticate it and try again." = "CodexBar не може знайти збережену авторизацію для цього облікового запису. Повторно автентифікуйте його та повторіть спробу."; +"CodexBar could not read managed account storage. Recover the store before adding another account." = "CodexBar не вдалося прочитати сховище керованого облікового запису. Відновіть магазин перед додаванням іншого облікового запису."; +"CodexBar could not read saved auth for that account. Re-authenticate it and try again." = "CodexBar не зміг прочитати збережену авторизацію для цього облікового запису. Повторно автентифікуйте його та повторіть спробу."; +"CodexBar could not read the current system account on this Mac." = "CodexBar не вдалося прочитати поточний обліковий запис системи на цьому Mac."; +"CodexBar could not replace the live Codex auth on this Mac." = "CodexBar не зміг замінити поточну автентифікацію Codex на цьому Mac."; +"CodexBar could not safely preserve the current system account before switching." = "CodexBar не зміг безпечно зберегти поточний обліковий запис системи перед перемиканням."; +"CodexBar could not save the current system account before switching." = "CodexBar не зміг зберегти поточний обліковий запис системи перед перемиканням."; +"CodexBar could not update managed account storage." = "CodexBar не вдалося оновити сховище керованого облікового запису."; +"CodexBar found another managed account that already uses the current system account. Resolve the duplicate account before switching." = "CodexBar знайшов інший керований обліковий запис, який уже використовує поточний системний обліковий запис. Усуньте дублікат облікового запису перед переходом."; +"CodexBar will ask macOS Keychain for “%@” so it can decrypt browser cookies and authenticate your account. Click OK to continue." = "CodexBar запитає у macOS Keychain «%@», щоб він міг розшифрувати файли cookie браузера та автентифікувати ваш обліковий запис. Натисніть OK, щоб продовжити."; +"CodexBar will ask macOS Keychain for the Claude Code OAuth token so it can fetch your Claude usage. Click OK to continue." = "CodexBar запитає у macOS Keychain маркер Claude Code OAuth, щоб отримати дані про використання Claude. Натисніть OK, щоб продовжити."; +"CodexBar will ask macOS Keychain for your Amp cookie header so it can fetch usage. Click OK to continue." = "CodexBar запитає у macOS Keychain ваш заголовок cookie Amp, щоб отримати інформацію про використання. Натисніть OK, щоб продовжити."; +"CodexBar will ask macOS Keychain for your Augment cookie header so it can fetch usage. Click OK to continue." = "CodexBar запитає у macOS Keychain ваш заголовок файлу cookie Augment, щоб отримати інформацію про використання. Натисніть OK, щоб продовжити."; +"CodexBar will ask macOS Keychain for your Claude cookie header so it can fetch Claude web usage. Click OK to continue." = "CodexBar запитає у macOS Keychain ваш заголовок файлу cookie Claude, щоб отримати інформацію про використання веб-сайту Claude. Натисніть OK, щоб продовжити."; +"CodexBar will ask macOS Keychain for your Cursor cookie header so it can fetch usage. Click OK to continue." = "CodexBar запитає у macOS Keychain ваш заголовок cookie Cursor, щоб отримати інформацію про використання. Натисніть OK, щоб продовжити."; +"CodexBar will ask macOS Keychain for your Factory cookie header so it can fetch usage. Click OK to continue." = "CodexBar запитає у macOS Keychain ваш заголовок Factory cookie, щоб отримати дані про використання. Натисніть OK, щоб продовжити."; +"CodexBar will ask macOS Keychain for your GitHub Copilot token so it can fetch usage. Click OK to continue." = "CodexBar запитає у macOS Keychain ваш маркер GitHub Copilot, щоб отримати інформацію про використання. Натисніть OK, щоб продовжити."; +"CodexBar will ask macOS Keychain for your Kimi auth token so it can fetch usage. Click OK to continue." = "CodexBar запитає у macOS Keychain ваш токен автентифікації Kimi, щоб отримати інформацію про використання. Натисніть OK, щоб продовжити."; +"CodexBar will ask macOS Keychain for your MiniMax API token so it can fetch usage. Click OK to continue." = "CodexBar запитає у macOS Keychain ваш токен MiniMax API, щоб отримати інформацію про використання. Натисніть OK, щоб продовжити."; +"CodexBar will ask macOS Keychain for your MiniMax cookie header so it can fetch usage. Click OK to continue." = "CodexBar запитає у macOS Keychain ваш заголовок cookie MiniMax, щоб отримати інформацію про використання. Натисніть OK, щоб продовжити."; +"CodexBar will ask macOS Keychain for your OpenAI cookie header so it can fetch Codex dashboard extras. Click OK to continue." = "CodexBar запитає у macOS Keychain ваш заголовок файлів cookie OpenAI, щоб він міг отримати додаткові елементи панелі інструментів Codex. Натисніть OK, щоб продовжити."; +"CodexBar will ask macOS Keychain for your OpenCode cookie header so it can fetch usage. Click OK to continue." = "CodexBar запитає у macOS Keychain ваш заголовок файлу cookie OpenCode, щоб отримати інформацію про використання. Натисніть OK, щоб продовжити."; +"CodexBar will ask macOS Keychain for your Synthetic API key so it can fetch usage. Click OK to continue." = "CodexBar запитає у macOS Keychain ваш синтетичний ключ API, щоб отримати дані про використання. Натисніть OK, щоб продовжити."; +"CodexBar will ask macOS Keychain for your z.ai API token so it can fetch usage. Click OK to continue." = "CodexBar запитає у macOS Keychain ваш токен API z.ai, щоб отримати інформацію про використання. Натисніть OK, щоб продовжити."; +"Could not open Cursor login in your browser." = "Не вдалося відкрити Cursor login у вашому браузері."; +"Could not open browser for Antigravity" = "Не вдалося відкрити браузер для Антигравітації"; +"Credits used" = "Використані кредити"; +"Day" = "День"; +"Deployment" = "Розгортання"; +"Drag to reorder" = "Перетягніть, щоб змінити порядок"; +"Sort providers alphabetically" = "Сортувати постачальників за алфавітом"; +"Sort providers alphabetically (enabled first)" = "Сортувати постачальників за алфавітом (увімкнені спочатку)"; +"Sorted alphabetically (enabled first) — click to use your custom order" = "Відсортовано за алфавітом (увімкнені спочатку) — натисніть, щоб використати власний порядок"; +"Endpoint" = "Кінцева точка"; +"Enterprise host" = "Корпоративний хост"; +"Extra usage balance: %@" = "Баланс додаткового використання: %@"; +"Keychain Access Required" = "Потрібен доступ до брелка"; +"keychain_prompt_learn_more" = "Докладніше…"; +"keychain_prompt_privacy_note" = "Введення пароля для входу на Mac обробляє macOS, а не CodexBar. Доступ до в'язки ключів можна будь-коли вимкнути в Налаштування → Розширені."; +"Kiro menu bar value" = "Значення панелі меню Kiro"; +"Label" = "Мітка"; +"No organizations loaded. Click Refresh after setting your API key." = "Організації не завантажено. Натисніть «Оновити» після встановлення ключа API."; +"No output captured." = "Немає вихідних даних."; +"No system account" = "Немає системного облікового запису"; +"Oasis-Token" = "Oasis-Token"; +"Open Augment (Log Out & Back In)" = "Відкрити доповнення (вийти та повернутися)"; +"Open Codebuff Dashboard" = "Відкрийте інформаційну панель Codebuff"; +"Open Command Code Settings" = "Відкрийте налаштування коду команди"; +"Open Crof dashboard" = "Відкрийте інформаційну панель Crof"; +"Open Manus" = "Відкрийте Manus"; +"Open MiMo Balance" = "Відкрийте MiMo Balance"; +"Open Moonshot Console" = "Відкрийте консоль Moonshot"; +"Open Ollama API Keys" = "Відкрийте ключі Ollama API"; +"Open StepFun Platform" = "Відкрийте платформу StepFun"; +"Open T3 Chat Settings" = "Відкрийте налаштування чату T3"; +"Open Volcengine Ark Console" = "Відкрийте консоль Volcengine Ark"; +"Open legacy provider docs" = "Відкрити застарілі документи постачальника"; +"Open projects" = "Відкриті проекти"; +"Open this URL manually to continue login:\n\n%@" = "Відкрийте цю URL-адресу вручну, щоб продовжити вхід: \n\n%@"; +"Optional organization ID for accounts linked to multiple Anthropic organizations." = "Додатковий ідентифікатор організації для облікових записів, пов’язаних із кількома організаціями Anthropic."; +"Optional. Applies to the configured Admin API key; selected token accounts do not inherit OPENAI_PROJECT_ID." = "Додатково. Застосовується до налаштованого ключа API адміністратора; вибрані облікові записи маркерів не успадковують OPENAI_PROJECT_ID."; +"Optional. Enter your GitHub Enterprise host, for example octocorp.ghe.com. Leave blank for github.com." = "Додатково. Введіть свій хост GitHub Enterprise, наприклад octocorp.ghe.com. Залиште поле порожнім для github.com."; +"Optional. Leave blank to discover and aggregate projects visible to the API key." = "Додатково. Залиште поле порожнім, щоб виявити та об’єднати проекти, видимі для ключа API."; +"Org ID (optional)" = "Ідентифікатор організації (необов’язково)"; +"Organizations" = "організації"; +"Organization ID" = "ID організації"; +"Password" = "Пароль"; +"%@ authentication is disabled." = "Автентифікацію %@ вимкнено."; +"%@ cookies are disabled." = "Файли cookie %@ вимкнено."; +"%@ web API access is disabled." = "Доступ до веб-API %@ вимкнено."; +"Disable %@ dashboard cookie usage." = "Вимкнути використання файлів cookie панелі інструментів %@."; +"Keychain access is disabled in Advanced, so browser cookie import is unavailable." = "Доступ Keychain вимкнено в Advanced, тому імпорт файлів cookie браузера недоступний."; +"Manually paste an %@ from a browser session." = "Вручну вставте %@ із сеансу браузера."; +"Paste a Cookie header captured from %@." = "Вставте заголовок файлу cookie, отриманий із %@."; +"Paste a Cookie header from %@." = "Вставте заголовок файлу cookie з %@."; +"Paste a Cookie header or cURL capture from %@." = "Вставте заголовок файлу cookie або запис cURL із %@."; +"Paste a Cookie header or full cURL capture from %@." = "Вставте заголовок файлу cookie або повний запис cURL із %@."; +"Paste a Cookie or Authorization header from %@." = "Вставте файл cookie або заголовок авторизації з %@."; +"Paste a full cookie header or the %@ value." = "Вставте повний заголовок cookie або значення %@."; +"Paste a Cookie header or full cURL capture from T3 Chat settings." = "Вставте заголовок файлу cookie або повний запис cURL із налаштувань T3 Chat."; +"Paste the Cookie header from a request to admin.mistral.ai. Must contain an ory_session_* cookie." = "Вставте заголовок Cookie із запиту до admin.mistral.ai. Має містити файл cookie ory_session_*."; +"Paste the Oasis-Token from a logged-in browser session on platform.stepfun.com." = "Вставте Oasis-Token із сеансу браузера, у якому ви ввійшли в систему, на platform.stepfun.com."; +"Paste the %@ JSON bundle from %@." = "Вставте пакет JSON %@ з %@."; +"Paste the %@ value or a full Cookie header." = "Вставте значення %@ або повний заголовок файлу cookie."; +"Personal account" = "Особистий рахунок"; +"Project ID" = "ID проекту"; +"Re-auth" = "Повторна авторизація"; +"Re-login at claude.ai" = "Повторно увійти на claude.ai"; +"Re-authenticating…" = "Повторна автентифікація…"; +"Refresh Session" = "Оновити сеанс"; +"Refresh organizations" = "Оновити організації"; +"Region" = "Регіон"; +"Reload" = "Перезавантажити"; +"Reorder" = "Змінити порядок"; +"Secret access key" = "Секретний ключ доступу"; +"Series" = "Серія"; +"Service" = "Сервіс"; +"Show or hide Kiro credits, percent, or both next to the menu bar icon." = "Показати або приховати кредити Kiro, відсотки або обидва поряд із піктограмою панелі меню."; +"Show usage for organizations you belong to. Personal account is always shown." = "Показати використання для організацій, до яких ви належите. Особистий рахунок відображається завжди."; +"Sign in to cursor.com in your browser, then refresh Cursor in CodexBar." = "Увійдіть на cursor.com у своєму браузері, а потім оновіть курсор у CodexBar."; +"Simulated error text" = "Змодельований текст помилки"; +"StepFun platform account (phone number or email)." = "Обліковий запис на платформі StepFun (номер телефону або електронна пошта)."; +"Stored in ~/.codexbar/config.json." = "Зберігається в ~/.codexbar/config.json."; +"Stored in ~/.codexbar/config.json. AZURE_OPENAI_API_KEY is also supported." = "Зберігається в ~/.codexbar/config.json. Також підтримується AZURE_OPENAI_API_KEY."; +"Stored in ~/.codexbar/config.json. For the official Kimi API, use Moonshot / Kimi API." = "Зберігається в ~/.codexbar/config.json. Для офіційного Kimi API використовуйте Moonshot / Kimi API."; +"Stored in ~/.codexbar/config.json. Get your API key from the Volcengine Ark console." = "Зберігається в ~/.codexbar/config.json. Отримайте ключ API з консолі Volcengine Ark."; +"Stored in ~/.codexbar/config.json. Get your key from Ollama settings." = "Зберігається в ~/.codexbar/config.json. Отримайте ключ із налаштувань Ollama."; +"Stored in ~/.codexbar/config.json. Get your key from console.deepgram.com." = "Зберігається в ~/.codexbar/config.json. Отримайте ключ на console.deepgram.com."; +"Stored in ~/.codexbar/config.json. Get your key from elevenlabs.io/app/settings/api-keys." = "Зберігається в ~/.codexbar/config.json. Отримайте свій ключ на сайті elevenlabs.io/app/settings/api-keys."; +"Stored in ~/.codexbar/config.json. Get your key from openrouter.ai/settings/keys and set a key spending limit there to enable API key quota tracking." = "Зберігається в ~/.codexbar/config.json. Отримайте свій ключ із openrouter.ai/settings/keys і встановіть там ліміт витрат на ключ, щоб увімкнути відстеження квоти ключів API."; +"Stored in ~/.codexbar/config.json. In Warp, open Settings > Platform > API Keys, then create one." = "Зберігається в ~/.codexbar/config.json. У Warp відкрийте Налаштування > Платформа > Ключі API, а потім створіть один."; +"Stored in ~/.codexbar/config.json. Metrics require Groq Enterprise Prometheus access." = "Зберігається в ~/.codexbar/config.json. Метрики потребують доступу до Groq Enterprise Prometheus."; +"Stored in ~/.codexbar/config.json. OPENAI_ADMIN_KEY is preferred; OPENAI_API_KEY still works." = "Зберігається в ~/.codexbar/config.json. OPENAI_ADMIN_KEY є кращим; OPENAI_API_KEY все ще працює."; +"Stored in ~/.codexbar/config.json. Requires an Anthropic Admin API key." = "Зберігається в ~/.codexbar/config.json. Потрібен ключ API адміністратора Anthropic."; +"Stored in ~/.codexbar/config.json. Used for /v1/quota-stats." = "Зберігається в ~/.codexbar/config.json. Використовується для /v1/quota-stats."; +"Stored in ~/.codexbar/config.json. You can also provide CODEBUFF_API_KEY or let CodexBar read ~/.config/manicode/credentials.json (created by `codebuff login`)." = "Зберігається в ~/.codexbar/config.json. Ви також можете надати CODEBUFF_API_KEY або дозволити CodexBar читати ~/.config/manicode/credentials.json (створений `codebuff login`)."; +"Stored in ~/.codexbar/config.json. You can also provide CROF_API_KEY." = "Зберігається в ~/.codexbar/config.json. Ви також можете надати CROF_API_KEY."; +"Stored in ~/.codexbar/config.json. You can also provide KILO_API_KEY or ~/.local/share/kilo/auth.json (kilo.access)." = "Зберігається в ~/.codexbar/config.json. Ви також можете надати KILO_API_KEY або ~/.local/share/kilo/auth.json (kilo.access)."; +"T3 Chat cookie" = "T3 Чат cookie"; +"Team mode" = "Командний режим"; +"That account is no longer available in CodexBar. Refresh the account list and try again." = "Цей обліковий запис більше не доступний у CodexBar. Оновіть список облікових записів і повторіть спробу."; +"The browser login did not complete in time. Try Antigravity login again." = "Вхід у браузер не завершено вчасно. Спробуйте ще раз увійти в Antigravity."; +"Timed out waiting for Cursor login. %@" = "Минув час очікування входу курсору. %@"; +"Timed out waiting for Cursor login. %@ Last error: %@" = "Минув час очікування входу курсору. %@ Остання помилка: %@"; +"Today requests" = "Сьогоднішні запити"; +"Total (30d): %@ credits" = "Усього (30 днів): %@ кредитів"; +"Username" = "Ім'я користувача"; +"Uses username + password to login and obtain an Oasis-Token automatically." = "Використовує ім’я користувача + пароль для входу та автоматичного отримання Oasis-Token."; +"Uses username + password to login and obtain an %@ automatically." = "Використовує ім’я користувача + пароль для входу та автоматичного отримання %@."; +"Utilization End" = "Кінець використання"; +"Utilization Start" = "Початок використання"; +"Verbosity" = "Багатослівність"; +"Windsurf session JSON bundle" = "Пакет JSON сеансу віндсерфінгу"; +"Workspace ID" = "Ідентифікатор робочої області"; +"Your StepFun platform password. Used to login and obtain a session token." = "Ваш пароль платформи StepFun. Використовується для входу та отримання маркера сесії."; +"claude /login exited with status %d." = "claude /login вийшов зі статусом %d."; +"codex login exited with status %d." = "вихід із входу в кодек із статусом %d."; +"Cookie: …\n\nor paste a cURL capture from the Abacus AI dashboard" = "Файл cookie: …\n\nабо вставте запис cURL із інформаційної панелі Abacus AI"; +"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"; +"session_id=...\n\nor paste just the session_id value" = "session_id=...\n\nабо вставте лише значення session_id"; +"Clear" = "ясно"; +"No matching providers" = "Немає відповідних постачальників"; +"Search providers" = "Пошук провайдерів"; +"language_arabic" = "العربية"; +"language_persian" = "فارسی"; +"language_thai" = "ไทย"; +"language_galician" = "Galego"; +"Limit Reset Credits" = "Кредити скидання ліміту"; +"1 available" = "1 доступне"; +"%d available" = "%d доступно"; +"Next expires %@" = "Наступне спливає %@"; +"Expires %@" = "Спливає %@"; +"No expiry" = "Без терміну дії"; +"Other (%d items)" = "Інше (%d елементів)"; +"Expand" = "Розгорнути"; +"Collapse" = "Згорнути"; +"byte_unit_byte" = "байт"; +"byte_unit_bytes" = "байти"; +"byte_unit_kilobyte" = "кілобайт"; +"byte_unit_kilobytes" = "кілобайти"; +"byte_unit_megabyte" = "мегабайт"; +"byte_unit_megabytes" = "мегабайти"; +"byte_unit_gigabyte" = "гігабайт"; +"byte_unit_gigabytes" = "гігабайти"; + +/* Settings sidebar redesign */ +"Enable" = "Увімкнути"; +"Disable" = "Вимкнути"; +"providers_on_count" = "%d увімкнено"; +"section_cost_summary" = "Зведення витрат"; +"section_command_line" = "Командний рядок"; +"section_privacy" = "Конфіденційність"; +"section_diagnostics" = "Діагностика"; +"section_updates" = "Оновлення"; +"section_links" = "Посилання"; +"Show Codex Spark usage" = "Показати використання Codex Spark"; +"Shows Codex Spark quota rows in the menu and provider preview. Requires optional credits and extra usage in Display settings." = "Показує рядки квоти Codex Spark у меню та попередньому перегляді провайдера. Потрібно ввімкнути «Показати кредити + додаткове використання» в налаштуваннях «Відображення»."; +"Show Daily Routines usage" = "Показати використання щоденних завдань"; +"Shows the Daily Routines quota row in the menu and provider preview. Requires optional credits and extra usage in Display settings." = "Показує рядок квоти щоденних завдань у меню та попередньому перегляді провайдера. Потрібно ввімкнути «Показати кредити + додаткове використання» в налаштуваннях «Відображення»."; +"Scroll to see more models" = "Прокрутіть, щоб побачити більше моделей"; +"Copy Image" = "Копіювати зображення"; +"Copy Stats" = "Копіювати статистику"; +"Could not copy image" = "Не вдалося скопіювати зображення"; +"Image copied" = "Зображення скопійовано"; +"Image saved" = "Зображення збережено"; +"Nothing is uploaded. This image is created on your Mac." = "Нічого не завантажується. Зображення створюється на вашому Mac."; +"Save..." = "Зберегти..."; +"Share AI Usage" = "Поділитися використанням ШІ"; +"Share Stats…" = "Поділитися статистикою…"; +"Stats copied" = "Статистику скопійовано"; +"DeepSeek this month token usage trend" = "Тенденція використання токенів DeepSeek цього місяця"; +"Chrome profile" = "Профіль Chrome"; +"Choose which signed-in DeepSeek Platform session supplies detailed usage." = "Виберіть сеанс DeepSeek Platform із виконаним входом, який надаватиме докладні дані про використання."; +"Detailed usage unavailable." = "Докладні дані про використання недоступні."; +"Sign in to DeepSeek Platform in Chrome for detailed usage." = "Увійдіть у DeepSeek Platform у Chrome, щоб переглянути докладні дані про використання."; +"Select a DeepSeek Chrome profile in Settings." = "Виберіть профіль Chrome для DeepSeek у налаштуваннях."; +"Select profile…" = "Вибрати профіль…"; + +"%@: %@" = "%@: %@"; +"Alternatively, set a custom path in Settings." = "Або встановіть спеціальний шлях у налаштуваннях."; +"Choose a supported browser so CodexBar can read the matching account." = "Виберіть підтримуваний браузер, щоб CodexBar міг читати відповідний обліковий запис."; +"Choose Cursor account" = "Виберіть обліковий запис Cursor"; +"Choose which Cursor account CodexBar should use." = "Виберіть, який обліковий запис Cursor має використовувати CodexBar."; +"Finish switching to a different Cursor account in your browser, then try again." = "Завершіть перехід до іншого облікового запису Cursor у своєму браузері та повторіть спробу."; +"Install a JetBrains IDE with AI Assistant enabled, then refresh CodexBar." = "Встановіть JetBrains IDE із увімкненим AI Assistant, а потім оновіть CodexBar."; +"Request quota: %@ / %@" = "Квота запитів: %@ / %@"; +"Sign in with Claude Code..." = "Увійдіть за допомогою Claude Code..."; +"Timed out waiting for Cursor account switch. %@" = "Минув час очікування зміни облікового запису Cursor. %@"; +"Timed out waiting for Cursor account switch. %@ Last error: %@" = "Минув час очікування зміни облікового запису Cursor. %@ Остання помилка: %@"; +"Use Account" = "Використати обліковий запис"; +/* Spend dashboard */ +"tab_usage_spend" = "Використання й витрати"; +"Usage & Spend" = "Використання й витрати"; +"Local estimated cost history across supported providers." = "Локальна історія орієнтовних витрат у підтримуваних провайдерів."; +"Time range" = "Період"; +"Track costs" = "Відстежувати витрати"; +"Cost tracking is off" = "Відстеження витрат вимкнено"; +"Turn on Track costs to build local estimates." = "Увімкніть «Відстежувати витрати», щоб створювати локальні оцінки."; +"No local cost history yet" = "Локальної історії витрат ще немає"; +"Turn on cost tracking or refresh after using a supported provider." = "Увімкніть відстеження витрат або оновіть дані після використання підтримуваного провайдера."; +"Refresh failures" = "Помилки оновлення"; +"Native currencies stay separate; Codex account rows exclude Pi session history." = "Вихідні валюти залишаються розділеними; рядки облікових записів Codex не включають історію сеансів Pi."; +"Spend unavailable" = "Витрати недоступні"; +"Model breakdown unavailable" = "Розподіл за моделями недоступний"; +"Local estimated history" = "Локальна історія оцінок"; +"Coverage" = "Охоплення"; +"Estimated spend" = "Орієнтовні витрати"; +"Tracked tokens" = "Відстежувані токени"; +"Subscriptions" = "Підписки"; +"By subscription" = "За підписками"; +"No model-level history" = "Немає історії за моделями"; +"Daily estimated spend" = "Орієнтовні щоденні витрати"; +"≈%d full 5h windows of weekly left · %d windows until reset" = "≈%d повних 5-годинних вікон тижневого ліміту · %d вікон до скидання"; +"Weekly cannot run out before reset at this pace" = "За такого темпу тижневий ліміт не може вичерпатися до скидання"; +"Weekly can run out ≈%d windows early" = "Тижневий ліміт може вичерпатися на ≈%d вікон раніше"; +"Estimated: %@" = "Оцінка: %@"; +"session_quota_estimate_value_format" = "%1$@ %2$@"; +"session quota" = "ліміт сесії"; +"session quotas" = "ліміти сесій"; +"Coding Plan" = "План кодування"; +"Agent Plan" = "План агента"; +"Team" = "Команда"; + +/* Menu bar layout editor */ +"menu_bar_layout_title" = "Компонування"; +"menu_bar_layout_footer" = "Перетягуйте елементи, щоб упорядкувати смугу меню. Натисніть елемент, щоб додати його; виберіть розміщений елемент і натисніть Delete, щоб видалити його."; +"menu_bar_layout_group_identity" = "Ідентифікація"; +"menu_bar_layout_group_usage" = "Використання"; +"menu_bar_layout_group_time" = "Час"; +"menu_bar_layout_group_money" = "Вартість"; +"menu_bar_layout_group_structure" = "Структура"; +"menu_bar_layout_scope_all" = "Усі провайдери"; +"menu_bar_layout_scope_help" = "Змініть типове компонування або перевизначте його для одного провайдера."; +"menu_bar_layout_use_all" = "Використовувати компонування всіх провайдерів"; +"menu_bar_layout_preset" = "Шаблон компонування"; +"menu_bar_layout_preset_icon_percent" = "Значок і відсоток"; +"menu_bar_layout_preset_icon_only" = "Лише значок"; +"menu_bar_layout_preset_percent_reset" = "Відсоток і скидання"; +"menu_bar_layout_preset_compact_stacked" = "Компактно у два рядки"; +"menu_bar_layout_preset_custom" = "Користувацький"; +"menu_bar_layout_live_preview" = "Попередній перегляд"; +"menu_bar_layout_strip" = "Смуга меню"; +"menu_bar_layout_remove_line_break" = "Видалити розрив рядка"; +"menu_bar_layout_chip_hint" = "Виберіть, перетягніть для зміни порядку або скористайтеся дією видалення."; +"menu_bar_layout_palette_hint" = "Натисніть, щоб додати, або перетягніть у компонування."; +"menu_bar_layout_empty_line" = "Перетягніть елемент сюди"; +"menu_bar_layout_line" = "Рядок %d"; +"menu_bar_layout_drag_remove" = "Перетягніть сюди, щоб видалити"; +"menu_bar_layout_size" = "Розмір"; +"menu_bar_layout_size_small" = "Малий"; +"menu_bar_layout_size_regular" = "Звичайний"; +"menu_bar_layout_gap" = "Інтервал"; +"menu_bar_layout_gap_tight" = "Вузький"; +"menu_bar_layout_gap_regular" = "Звичайний"; +"menu_bar_layout_keyboard_hint" = "Delete видаляє вибраний елемент"; +"menu_bar_layout_sample_account" = "обліковий запис"; +"menu_bar_layout_sample_runs_out" = "закінчиться пт."; +"menu_bar_layout_token_icon" = "Значок"; +"menu_bar_layout_token_provider" = "Назва провайдера"; +"menu_bar_layout_token_account" = "Обліковий запис"; +"menu_bar_layout_token_session" = "Сесія %"; +"menu_bar_layout_token_weekly" = "Щотижня %"; +"menu_bar_layout_token_auto" = "Авто %"; +"menu_bar_layout_token_bar" = "Індикатор використання"; +"menu_bar_layout_token_resets_in" = "Скидання через"; +"menu_bar_layout_token_reset_at" = "Скидання о"; +"menu_bar_layout_token_runs_out" = "Закінчиться"; +"menu_bar_layout_token_cost_today" = "Вартість сьогодні"; +"menu_bar_layout_token_cost_30d" = "Вартість за 30 днів"; +"menu_bar_layout_token_space" = "Пробіл"; +"menu_bar_layout_token_line_break" = "Розрив рядка"; +"menu_bar_layout_token_separator_accessibility" = "Крапка-роздільник"; + +/* Menu bar layout accessibility */ +"Icon unavailable" = "Значок: Недоступний"; +"%@ icon" = "%@: Значок"; +"Provider name unavailable" = "Назва провайдера: Недоступний"; +"Account unavailable" = "Обліковий запис: Недоступний"; +"%@ unavailable" = "%@: Недоступний"; +"%@ %@" = "%@ %@"; +"Usage bar unavailable" = "Індикатор використання: Недоступний"; +"Usage bar, %d of 3 filled" = "Індикатор використання: %d/3 заповнено"; +"Reset countdown unavailable" = "Скидання через: Недоступний"; +"Reset time unavailable" = "Скидання о: Недоступний"; +"Run-out estimate unavailable" = "Закінчиться: Недоступний"; +"Cost today unavailable" = "Вартість сьогодні: Недоступний"; +"30-day cost unavailable" = "Вартість за 30 днів: Недоступний"; +"Resets" = "Скидання"; diff --git a/Sources/CodexBar/Resources/uk.lproj/Localizable.stringsdict b/Sources/CodexBar/Resources/uk.lproj/Localizable.stringsdict new file mode 100644 index 0000000000..8aba699b4e --- /dev/null +++ b/Sources/CodexBar/Resources/uk.lproj/Localizable.stringsdict @@ -0,0 +1,61 @@ + + + + + ≈%d full 5h windows of weekly left · %d windows until reset + + NSStringLocalizedFormatKey + %#@left@ · %#@until@ + left + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + d + one + ≈%d повне 5-годинне вікно тижневого ліміту + few + ≈%d повні 5-годинні вікна тижневого ліміту + many + ≈%d повних 5-годинних вікон тижневого ліміту + other + ≈%d повних 5-годинних вікон тижневого ліміту + + until + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + d + one + %d вікно до скидання + few + %d вікна до скидання + many + %d вікон до скидання + other + %d вікон до скидання + + + Weekly can run out ≈%d windows early + + NSStringLocalizedFormatKey + %#@early@ + early + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + d + one + Тижневий ліміт може вичерпатися на ≈%d вікно раніше + few + Тижневий ліміт може вичерпатися на ≈%d вікна раніше + many + Тижневий ліміт може вичерпатися на ≈%d вікон раніше + other + Тижневий ліміт може вичерпатися на ≈%d вікон раніше + + + + diff --git a/Sources/CodexBar/Resources/vi.lproj/Localizable.strings b/Sources/CodexBar/Resources/vi.lproj/Localizable.strings new file mode 100644 index 0000000000..1d203e476f --- /dev/null +++ b/Sources/CodexBar/Resources/vi.lproj/Localizable.strings @@ -0,0 +1,1354 @@ +/* English localization for CodexBar (base/fallback) */ + +"tab_hooks" = "Hook"; +"hooks_enable_title" = "Bật hook"; +"hooks_enable_subtitle" = "Chạy lệnh bên ngoài khi có sự kiện hạn mức hoặc nhà cung cấp."; +"hooks_trust_warning" = "Hook có thể chạy lệnh cục bộ trên máy Mac. Chỉ cấu hình các lệnh bạn tin cậy."; +"hooks_rules_header" = "Quy tắc"; +"hooks_empty" = "Chưa cấu hình hook."; +"hooks_add_rule" = "Thêm quy tắc"; +"hooks_delete_rule" = "Xóa quy tắc"; +"hooks_rule_enabled" = "Đã bật"; +"hooks_event" = "Sự kiện"; +"hooks_provider" = "Nhà cung cấp"; +"hooks_any_provider" = "Nhà cung cấp bất kỳ"; +"hooks_threshold" = "Chạy khi mức sử dụng ≥"; +"hooks_threshold_placeholder" = "90"; +"hooks_executable_placeholder" = "/usr/local/bin/my-command"; +"hooks_arguments_placeholder" = "Đối số"; +"hooks_argument_placeholder" = "Đối số"; +"hooks_add_argument" = "Thêm đối số"; +"hooks_delete_argument" = "Xóa đối số"; + +"ollama_safari_cookie_access_hint" = "Cookie Safari cần Quyền truy cập toàn bộ ổ đĩa cho CodexBar (Cài đặt hệ thống > Quyền riêng tư & Bảo mật)."; +"ollama_browser_cookie_decryption_denied" = "Việc giải mã cookie %@ đã bị từ chối trong Chuỗi khóa; hãy thử lại bằng cách làm mới thủ công."; +"ollama_browser_cookie_decryption_disabled" = "Việc giải mã cookie %@ bị tắt trong CodexBar; hãy bật quyền truy cập Chuỗi khóa rồi làm mới."; + +" providers" = "nhà cung cấp"; +"(System)" = "(Hệ thống)"; +"30d" = "30d"; +"7d" = "7d"; +"A managed Codex login is already running. Wait for it to finish before adding " = "Đăng nhập Codex được quản lý đã chạy. Đợi quá trình này hoàn tất trước khi thêm"; +"API key" = "API khóa"; +"API region" = "API khu vực"; +"API token" = "API token"; +"API tokens" = "API mã thông báo"; +"About" = "Giới thiệu về"; +"Account" = "Tài khoản"; +"Accounts" = "Tài khoản"; +"Accounts subtitle" = "Phụ đề tài khoản"; +"Active" = "Đang hoạt động"; +"Add" = "Thêm"; +"Add Workspace" = "Thêm không gian làm việc"; +"Advanced" = "Nâng cao"; +"All" = "Tất cả"; +"Always allow prompts" = "Luôn cho phép lời nhắc"; +"Animation pattern" = "Mẫu hoạt ảnh"; +"Antigravity login is managed in the app" = "Đăng nhập chống trọng lực được quản lý trong ứng dụng"; +"Applies only to the Security.framework OAuth keychain reader." = "Chỉ áp dụng cho trình đọc chuỗi khóa Security.framework OAuth."; +"Auto falls back to the next source if the preferred one fails." = "Tự động quay lại nguồn tiếp theo nếu nguồn ưa thích không thành công."; +"Auto uses API first, then falls back to CLI on auth failures." = "Tự động sử dụng API trước, sau đó quay lại CLI khi xác thực không thành công."; +"Auto-detect" = "Tự động phát hiện"; +"Auto-refresh is off; use the menu's Refresh command." = "Tự động làm mới bị tắt; sử dụng lệnh Làm mới của menu."; +"Auto-refresh: hourly · Timeout: 10m" = "Tự động làm mới: hàng giờ · Thời gian chờ: 10 phút"; +"Automatic" = "Tự động"; +"Automatic imports browser cookies and WorkOS tokens." = "Tự động nhập cookie trình duyệt và mã thông báo WorkOS."; +"Automatic imports browser cookies and local storage tokens." = "Tự động nhập cookie trình duyệt và mã thông báo lưu trữ cục bộ."; +"Automatic imports browser cookies for dashboard extras." = "Tự động nhập cookie trình duyệt cho các tính năng bổ sung của trang tổng quan."; +"Automatic imports browser cookies for the web API." = "Tự động nhập cookie trình duyệt cho web API ."; +"Automatic imports browser cookies from Model Studio/Bailian." = "Tự động nhập cookie trình duyệt từ Model Studio/Bailian."; +"Automatic imports browser cookies from admin.mistral.ai." = "Tự động nhập cookie trình duyệt từ admin.mistral.ai."; +"Automatic imports browser cookies from opencode.ai." = "Tự động nhập cookie trình duyệt từ opencode.ai."; +"Automatic imports browser cookies or stored sessions." = "Tự động nhập cookie trình duyệt hoặc các phiên được lưu trữ."; +"Automatic imports browser cookies." = "Tự động nhập cookie trình duyệt."; +"Automatically imports browser session cookie." = "Tự động nhập cookie phiên trình duyệt."; +"Automatically opens CodexBar when you start your Mac." = "Tự động mở CodexBar khi bạn khởi động máy Mac."; +"Automation" = "Tự động hóa"; +"Average (\\(label1) + \\(label2))" = "Trung bình (\\(label1) + \\(label2))"; +"Average (\\(metadata.sessionLabel) + \\(metadata.weeklyLabel))" = "Trung bình (\\(metadata.sessionLabel) + \\(metadata.weeklyLabel))"; +"Avoid Keychain prompts" = "Tránh Keychain lời nhắc"; +"Balance" = "Số dư"; +"Battery Saver" = "Trình tiết kiệm pin"; +"Bordered" = "Có viền"; +"Build" = "Xây dựng"; +"Built \\(buildTimestamp)" = "Đã xây dựng \\(buildTimestamp)"; +"Buy Credits..." = "Mua tín dụng..."; +"Buy Credits…" = "Mua tín dụng... Đường dẫn"; +"CLI paths" = "CLI"; +"CLI sessions" = "CLI phiên"; +"Caches" = "Bộ nhớ đệm"; +"Cancel" = "Hủy"; +"Check for Updates…" = "Kiểm tra cập nhật…"; +"Check for updates automatically" = "Tự động kiểm tra cập nhật"; +"Check if you like your agents having some fun up there." = "Kiểm tra xem bạn có muốn nhân viên của mình vui vẻ ở đó không."; +"Check provider status" = "Kiểm tra Nhà cung cấp trạng thái"; +"Choose Codex workspace" = "Chọn không gian làm việc Codex"; +"Choose the MiniMax host (global .io or China mainland .com)." = "Chọn máy chủ MiniMax (toàn cầu .io hoặc Trung Quốc đại lục .com)."; +"Choose up to " = "Chọn tối đa"; +"Choose up to \\(Self.maxOverviewProviders) providers" = "Chọn tối đa nhà cung cấp \\(Self.maxOverviewProviders)"; +"Choose up to \\(count) providers" = "Chọn tối đa \\(count) nhà cung cấp"; +"Choose what to show in the menu bar (Pace shows usage vs. expected)." = "Chọn nội dung sẽ hiển thị trong thanh menu (Tốc độ hiển thị Mức sử dụng so với dự kiến)."; +"Choose which Codex account CodexBar should follow." = "Chọn tài khoản Codex mà CodexBar sẽ tuân theo."; +"Choose which window drives the menu bar percent." = "Chọn cửa sổ nào điều khiển phần trăm thanh menu."; +"Chrome" = "Chrome"; +"Claude CLI not found" = "Claude CLI không tìm thấy"; +"Claude binary" = "Claude nhị phân"; +"Claude cookies" = "Claude cookie"; +"Claude login failed" = "Claude đăng nhập không thành công"; +"Claude login timed out" = "Claude hết thời gian đăng nhập"; +"Close" = "Đóng"; +"Code review" = "Xem xét mã"; +"Codex CLI not found" = "Không tìm thấy Codex CLI"; +"Codex account login already running" = "Đăng nhập tài khoản Codex đã chạy"; +"Codex binary" = "Codex nhị phân"; +"Codex login failed" = "Đăng nhập Codex không thành công"; +"Codex login timed out" = "Đăng nhập Codex đã hết thời gian chờ"; +"CodexBar Lifecycle Keepalive" = "CodexBar Lifecycle Keepalive"; +"CodexBar can't show its menu bar icon" = "CodexBar không thể hiển thị biểu tượng thanh menu"; +"CodexBar could not read managed account storage. " = "CodexBar không thể đọc bộ nhớ tài khoản được quản lý."; +"Configure…" = "Định cấu hình…"; +"Connected" = "Đã kết nối"; +"Controls how much detail is logged." = "Kiểm soát lượng chi tiết được ghi lại."; +"Cookie header" = "Tiêu đề cookie"; +"Cookie source" = "Nguồn cookie"; +"Cookie: ..." = "Cookie: ..."; +"Cookie: \\u{2026}\\\n\\\nor paste a cURL capture from the Abacus AI dashboard" = "Cookie: \\u{2026}\\\n\\\nhoặc dán bản chụp cURL từ bảng điều khiển Abacus AI"; +"Cookie: \\u{2026}\\\n\\\nor paste the __Secure-next-auth.session-token value" = "Cookie: \\u{2026}\\\n\\\nor paste the __Secure-next-auth.session-token value"; +"Cookie: \\u{2026}\\\n\\\nor paste the kimi-auth token value" = "Cookie: \\u{2026}\\\n\\\nhoặc dán giá trị kimi-auth token"; +"Cookie: …" = "Cookie: …"; +"CopilotDeviceFlow" = "CopilotDeviceFlow"; +"Cost" = "Chi phí"; +"Could not add Codex account" = "Không thể thêm tài khoản Codex"; +"Could not open Terminal for Gemini" = "Không thể mở Terminal cho Gemini"; +"Could not start claude /login" = "Không thể bắt đầu claude /đăng nhập"; +"Could not start codex login" = "Không thể bắt đầu đăng nhập codex"; +"Could not switch system account" = "Không thể không chuyển đổi tài khoản hệ thống"; +"Credits" = "Tín dụng"; +"Individual credits" = "Tín dụng cá nhân"; +"Workspace" = "Không gian làm việc"; +"Credits history" = "Lịch sử tín dụng"; +"Cursor login failed" = "Đăng nhập con trỏ không thành công"; +"Custom" = "Tùy chỉnh"; +"Custom Path" = "Đường dẫn tùy chỉnh"; +"Daily Routines" = "Quy trình hàng ngày"; +"Debug" = "Gỡ lỗi"; +"Default" = "Mặc định"; +"Disable Keychain access" = "Tắt quyền truy cập Keychain"; +"Disabled" = "Đã tắt"; +"Dismiss" = "Loại bỏ"; +"Disconnected" = "Đã ngắt kết nối"; +"Display" = "Hiển thị"; +"Display mode" = "Chế độ hiển thị"; +"Display reset times as absolute clock values instead of countdowns." = "Hiển thị Đặt lại thời gian dưới dạng giá trị đồng hồ tuyệt đối thay vì đếm ngược."; +"Done" = "Xong"; +"Effective PATH" = "PATH hiệu quả"; +"Email" = "Email"; +"Enable Merge Icons to configure Overview tab providers." = "Bật Biểu tượng Hợp nhất để định cấu hình nhà cung cấp tab Tổng quan."; +"Enable file logging" = "Bật ghi nhật ký tệp"; +"Enabled" = "Đã bật"; +"Error" = "Lỗi"; +"Error simulation" = "Mô phỏng lỗi"; +"Expose troubleshooting tools in the Debug tab." = "Hiển thị các công cụ khắc phục sự cố trong tab Gỡ lỗi."; +"Failed" = "Không thành công"; +"False" = "Sai"; +"Fetch strategy attempts" = "Thử tìm nạp chiến lược"; +"Fetching" = "Đang tìm nạp"; +"Field" = "Trường"; +"Field subtitle" = "Tiêu đề phụ của trường"; +"Finish the current managed account change before switching the system account." = "Hoàn tất thay đổi tài khoản được quản lý hiện tại trước khi chuyển đổi tài khoản hệ thống."; +"Force animation on next refresh" = "Không tìm thấy hoạt ảnh bắt buộc trong lần làm mới tiếp theo"; +"Gateway region" = "Vùng cổng"; +"Gemini CLI not found" = "Gemini CLI"; +"Gemini/Antigravity, surfacing incidents in the icon and menu." = "Gemini /Phản trọng lực, xuất hiện các sự cố trong biểu tượng và menu."; +"General" = "Chung"; +"GitHub" = "GitHub"; +"GitHub Copilot Login" = "Đăng nhập GitHub Copilot"; +"GitHub Login" = "Đăng nhập GitHub"; +"Hide details" = "Ẩn chi tiết"; +"Hide personal information" = "Ẩn thông tin cá nhân"; +"Historical tracking" = "Theo dõi lịch sử"; +"How often CodexBar polls providers in the background." = "Tần suất CodexBar thăm dò ý kiến ​​các nhà cung cấp trong nền."; +"Inactive" = "Không hoạt động"; +"Install CLI" = "Cài đặt CLI"; +"Install the Claude CLI (npm i -g @anthropic-ai/claude-code) and try again." = "Cài đặt Claude CLI (npm i -g @anthropic-ai/claude-code) và thử lại."; +"Install the Codex CLI (npm i -g @openai/codex) and try again." = "Cài đặt Codex CLI (npm i -g @openai/codex) và thử lại."; +"Install the Gemini CLI (npm i -g @google/gemini-cli) and try again." = "Cài đặt Gemini CLI (npm i -g @google/gemini-cli) và thử lại."; +"JetBrains AI is ready" = "JetBrains AI đã sẵn sàng"; +"JetBrains IDE" = "JetBrains IDE"; +"Keep CLI sessions alive" = "Duy trì CLI phiên hoạt động"; +"Keyboard shortcut" = "Phím tắt"; +"Keychain access" = "Keychain truy cập"; +"Keychain prompt policy" = "Keychain chính sách nhắc"; +"Last \\(name) fetch failed:" = "Tìm nạp \\(name) lần cuối không thành công:"; +"Last \\(self.store.metadata(for: self.provider).displayName) fetch failed:" = "Lần tìm nạp \\(self.store.metadata(for: self.provider).displayName) lần cuối không thành công:"; +"Last attempt" = "Lần thử cuối cùng"; +"Link" = "Liên kết"; +"Loading animations" = "Đang tải hình động"; +"Loading…" = "Đang tải…"; +"Local" = "Cục bộ"; +"Logging" = "Ghi nhật ký"; +"Login failed" = "Đăng nhập không thành công"; +"Login shell PATH (startup capture)" = "Shell đăng nhập PATH (chụp khởi động)"; +"Login timed out" = "Đã hết thời gian đăng nhập"; +"MCP details" = "Chi tiết MCP"; +"Managed Codex accounts unavailable" = "Tài khoản Codex được quản lý không khả dụng"; +"Managed account storage is unreadable. Live account access is still available, " = "Không thể đọc được bộ nhớ tài khoản được quản lý. Quyền truy cập tài khoản trực tiếp vẫn khả dụng,"; +"Manual" = "Thủ công"; +"May your tokens never run out—keep agent limits in view." = "Cầu mong mã thông báo của bạn không bao giờ hết—luôn theo dõi giới hạn đại lý."; +"Menu bar" = "thanh menu"; +"Menu bar auto-shows the provider closest to its rate limit." = "thanh menu tự động hiển thị Nhà cung cấp gần nhất với giới hạn tốc độ của nó."; +"Menu bar metric" = "thanh menu số liệu"; +"Menu bar shows percent" = "thanh menu hiển thị phần trăm"; +"Menu content" = "Nội dung menu"; +"Merge Icons" = "Hợp nhất các biểu tượng"; +"Never prompt" = "Không bao giờ nhắc"; +"No" = "Không có"; +"No Codex accounts detected yet." = "Chưa phát hiện thấy tài khoản Codex nào."; +"No JetBrains IDE detected" = "Không phát hiện thấy JetBrains IDE"; +"No cost history data." = "Không có dữ liệu lịch sử chi phí."; +"No data available" = "Không có dữ liệu"; +"No data yet" = "Chưa có dữ liệu"; +"No enabled providers available for Overview." = "Không có nhà cung cấp nào được bật cho Tổng quan."; +"No providers selected" = "Chưa có nhà cung cấp nào được chọn"; +"No token accounts yet." = "Chưa có tài khoản token."; +"No usage breakdown data." = "Không có dữ liệu phân tích Mức sử dụng."; +"None" = "Không có"; +"Notifications" = "Thông báo"; +"Notifies when the 5-hour session quota hits 0% and when it becomes " = "Thông báo khi phiên 5 giờ Hạn mức đạt 0% và khi nó trở thành"; +"OK" = "OK"; +"Obscure email addresses in the menu bar and menu UI." = "Ẩn địa chỉ email trong thanh menu và giao diện người dùng menu."; +"Off" = "Tắt"; +"Offline" = "Ngoại tuyến"; +"On" = "Bật"; +"Online" = "Trực tuyến"; +"Only on user action" = "Chỉ khi hành động của người dùng"; +"Open" = "Mở"; +"Open API Keys" = "Mở API Phím"; +"Open Amp Settings" = "Mở Amp Cài đặt"; +"Open Antigravity to sign in, then refresh CodexBar." = "Mở Chống trọng lực để đăng nhập, sau đó làm mới CodexBar ."; +"Open Browser" = "Mở trình duyệt"; +"Open Coding Plan" = "Mở kế hoạch mã hóa"; +"Open Console" = "Mở bảng điều khiển"; +"Open Dashboard" = "Mở bảng điều khiển"; +"Open Mistral Admin" = "Mở quản trị viên Mistral"; +"Open Menu Bar Settings" = "Mở thanh menu Cài đặt"; +"Open Ollama Settings" = "Mở Ollama Cài đặt"; +"Open Terminal" = "Mở Terminal"; +"Open Usage Page" = "Mở Mức sử dụng Trang"; +"Open Warp API Key Guide" = "Hướng dẫn chính về Open Warp API"; +"Open menu" = "Mở menu"; +"Open token file" = "Mở token tệp"; +"OpenAI cookies" = "OpenAI cookie"; +"OpenAI web extras" = "OpenAI phần bổ sung web"; +"Option A" = "Tùy chọn A"; +"Option B" = "Tùy chọn B"; +"Optional override if workspace lookup fails." = "Ghi đè tùy chọn nếu tra cứu không gian làm việc không thành công."; +"Options" = "Tùy chọn"; +"Override auto-detection with a custom IDE base path" = "Ghi đè tính năng tự động phát hiện bằng đường dẫn cơ sở IDE tùy chỉnh"; +"Overview" = "Tổng quan"; +"Overview rows always follow provider order." = "Các hàng tổng quan luôn tuân theo thứ tự Nhà cung cấp."; +"Overview tab providers" = "Nhà cung cấp tab tổng quan"; +"Paste API key…" = "Dán API key…"; +"Paste API token…" = "Dán API token …"; +"Paste key…" = "Dán khóa…"; +"Paste sessionKey or OAuth token…" = "Dán sessionKey hoặc OAuth token …"; +"Paste the Cookie header from a request to admin.mistral.ai. " = "Dán tiêu đề Cookie từ yêu cầu tới admin.mistral.ai."; +"Paste token…" = "Dán token …"; +"Personal" = "Cá nhân"; +"Picker" = "Bộ chọn"; +"Picker subtitle" = "Tiêu đề phụ của bộ chọn"; +"Placeholder" = "Trình giữ chỗ"; +"Plan" = "Kế hoạch"; +"Plan Usage" = "Mức sử dụng gói"; +"Play full-screen confetti when weekly usage resets." = "Phát hoa giấy toàn màn hình khi đặt lại Mức sử dụng hàng tuần."; +"Polls OpenAI/Claude status pages and Google Workspace for " = "Cuộc thăm dò ý kiến ​​OpenAI / Claude trang trạng thái và Google Không gian làm việc dành cho"; +"Prevents any Keychain access while enabled." = "Ngăn chặn mọi quyền truy cập Keychain khi được bật."; +"Primary (API key limit)" = "Chính ( API giới hạn khóa)"; +"Primary (\\(label))" = "Chính (\\(label))"; +"Primary (\\(metadata.sessionLabel))" = "Chính (\\(metadata.sessionLabel))"; +"Probe logs" = "Nhật ký thăm dò"; +"Progress bars fill as you consume quota (instead of showing remaining)." = "Thanh tiến trình sẽ lấp đầy khi bạn sử dụng Hạn mức (thay vì hiển thị phần còn lại)."; +"Provider" = "Nhà cung cấp"; +"Providers" = "Nhà cung cấp"; +"Quit CodexBar" = "Thoát CodexBar"; +"Random (default)" = "Ngẫu nhiên (mặc định)"; +"Reads local usage logs. Shows today + last 30 days cost in the menu." = "Đọc nhật ký Mức sử dụng cục bộ. Hiển thị hôm nay + cửa sổ lịch sử đã chọn trong menu."; +"Refresh" = "Làm mới"; +"Refresh cadence" = "Nhịp làm mới"; +"Remote" = "Từ xa"; +"Remove" = "Xóa"; +"Remove Codex account?" = "Xóa tài khoản Codex?"; +"Remove \\(account.email) from CodexBar? Its managed Codex home will be deleted." = "Xóa \\(account.email) khỏi CodexBar ? Trang chủ Codex được quản lý của nó sẽ bị xóa."; +"Remove \\(email) from CodexBar? Its managed Codex home will be deleted." = "Xóa \\(email) khỏi CodexBar ? Trang chủ Codex được quản lý của nó sẽ bị xóa."; +"Remove selected account" = "Xóa tài khoản đã chọn"; +"Replace critter bars with provider branding icons and a percentage." = "Thay thế các thanh sinh vật bằng Nhà cung cấp biểu tượng nhãn hiệu và tỷ lệ phần trăm."; +"Replay selected animation" = "Phát lại hoạt ảnh đã chọn"; +"Requires authentication via GitHub Device Flow." = "Yêu cầu xác thực thông qua GitHub Device Flow."; +"Resets: \\(reset)" = "Đặt lại: \\(reset)"; +"Rolling five-hour limit" = "Giới hạn 5 giờ liên tục"; +"Search hourly" = "Tìm kiếm hàng giờ"; +"Secondary (\\(label))" = "Phụ (\\(label))"; +"Secondary (\\(metadata.weeklyLabel))" = "Phụ (\\(metadata.weeklyLabel))"; +"Select a provider" = "Chọn một Nhà cung cấp"; +"Select the IDE to monitor" = "Chọn IDE để giám sát"; +"Session quota notifications" = "Thông báo phiên Hạn mức"; +"Session tokens" = "Mã thông báo phiên"; +"provider_section_connection" = "Kết nối"; +"provider_section_menu_bar" = "Thanh menu"; +"Show Codex Credits and Claude Extra usage sections in the menu." = "Hiển thị Tín dụng Codex và Claude Các phần Mức sử dụng bổ sung trong menu."; +"Show Debug Settings" = "Hiển thị gỡ lỗi Cài đặt"; +"Show all token accounts" = "Hiển thị tất cả token tài khoản"; +"Show cost summary" = "Hiển thị tóm tắt chi phí"; +"Show credits + extra usage" = "Hiển thị tín dụng + bổ sung Mức sử dụng"; +"Show details" = "Hiển thị chi tiết"; +"Show most-used provider" = "Hiển thị Nhà cung cấp"; +"Show provider icons in the switcher (otherwise show a weekly progress line)." = "được sử dụng nhiều nhất Hiển thị các biểu tượng Nhà cung cấp trong trình chuyển đổi (nếu không thì hiển thị dòng tiến trình hàng tuần)."; +"Show reset time as clock" = "Hiển thị thời gian Đặt lại dưới dạng đồng hồ"; +"Show usage as used" = "Hiển thị Mức sử dụng như đã sử dụng"; +"Sign in via button below" = "Đăng nhập bằng nút bên dưới"; +"Skip teardown between probes (debug-only)." = "Bỏ qua việc phân tích giữa các thăm dò (chỉ dành cho gỡ lỗi)."; +"Stack token accounts in the menu (otherwise show an account switcher bar)." = "Xếp chồng các tài khoản token trong menu (nếu không sẽ hiển thị thanh trình chuyển đổi tài khoản)."; +"Start at Login" = "Bắt đầu khi đăng nhập"; +"Status" = "Trạng thái"; +"Store Claude sessionKey cookies or OAuth access tokens." = "Lưu trữ Claude cookie sessionKey hoặc OAuth mã thông báo truy cập."; +"Store multiple Abacus AI Cookie headers." = "Lưu trữ nhiều tiêu đề Cookie Abacus AI."; +"Store multiple Augment Cookie headers." = "Lưu trữ nhiều tiêu đề Cookie tăng cường."; +"Store multiple Cursor Cookie headers." = "Lưu trữ nhiều tiêu đề Cookie con trỏ."; +"Store multiple Factory Cookie headers." = "Lưu trữ nhiều tiêu đề Cookie gốc."; +"Store multiple MiniMax Cookie headers." = "Lưu trữ nhiều tiêu đề cookie MiniMax."; +"Store multiple Mistral Cookie headers." = "Lưu trữ nhiều tiêu đề Mistral Cookie."; +"Store multiple Ollama Cookie headers." = "Lưu trữ nhiều tiêu đề Cookie Ollama."; +"Store multiple OpenCode Cookie headers." = "Lưu trữ nhiều tiêu đề Cookie OpenCode."; +"Store multiple OpenCode Go Cookie headers." = "Lưu trữ nhiều tiêu đề Cookie OpenCode Go."; +"Stored in the CodexBar config file." = "Được lưu trữ trong tệp cấu hình CodexBar."; +"Stored in ~/.codexbar/config.json. " = "Được lưu trữ trong ~/.codexbar/config.json."; +"Stored in ~/.codexbar/config.json. Paste the key from the Synthetic dashboard." = "Được lưu trữ trong ~/.codexbar/config.json. Dán khóa từ bảng điều khiển Tổng hợp."; +"Stored in ~/.codexbar/config.json. Paste your Coding Plan API key from Model Studio." = "Được lưu trữ trong ~/.codexbar/config.json. Dán khóa Kế hoạch mã hóa API của bạn từ Model Studio."; +"Stored in ~/.codexbar/config.json. Paste your MiniMax API key." = "Được lưu trữ trong ~/.codexbar/config.json. Dán khóa MiniMax API của bạn."; +"Stored in ~/.codexbar/config.json. You can also provide KILO_API_KEY or " = "Được lưu trữ trong ~/.codexbar/config.json. Bạn cũng có thể cung cấp lịch sử KILO_API_KEY hoặc"; +"Stores local Codex usage history (8 weeks) to personalize Pace predictions." = "Stores Codex cục bộ Mức sử dụng (8 tuần) để cá nhân hóa dự đoán Pace."; +"Surprise me" = "Làm tôi ngạc nhiên"; +"Switcher shows icons" = "Trình chuyển đổi hiển thị các biểu tượng"; +"Symlink CodexBarCLI to /usr/local/bin and /opt/homebrew/bin as codexbar." = "Symlink CodexBarCLI tới /usr/local/bin và /opt/homebrew/bin dưới dạng codexbar."; +"System" = "Hệ thống"; +"Temporarily shows the loading animation after the next refresh." = "Tạm thời hiển thị hoạt ảnh đang tải sau lần làm mới tiếp theo."; +"terminal_app_subtitle" = "Terminal dùng cho tác vụ Mở Terminal"; +"terminal_app_title" = "Terminal mặc định"; +"Tertiary (\\(label))" = "Cấp ba (\\(label))"; +"Tertiary (\\(tertiaryTitle))" = "Cấp ba (\\(tertiaryTitle))"; +"The default Codex account on this Mac." = "Tài khoản Codex mặc định trên máy Mac này."; +"Toggle" = "Chuyển đổi"; +"Toggle subtitle" = "Chuyển đổi phụ đề"; +"Token" = "token"; +"Trigger the menu bar menu from anywhere." = "Kích hoạt menu thanh menu từ mọi nơi."; +"True" = "Đúng"; +"Twitter" = "Twitter"; +"Unsupported" = "Không được hỗ trợ"; +"Update Channel" = "Kênh cập nhật"; +"Updated" = "Đã cập nhật"; +"Updates unavailable in this build." = "Các bản cập nhật không có sẵn trong bản dựng này."; +"Usage" = "Mức sử dụng"; +"Usage breakdown" = "Mức sử dụng sự cố"; +"Usage history (30 days)" = "Mức sử dụng lịch sử"; +"Usage source" = "Mức sử dụng nguồn"; +"Use BigModel for the China mainland endpoints (open.bigmodel.cn)." = "Sử dụng BigModel cho các điểm cuối ở Trung Quốc đại lục (open.bigmodel.cn)."; +"Use a single menu bar icon with a provider switcher." = "Sử dụng một biểu tượng thanh menu duy nhất với trình chuyển đổi Nhà cung cấp."; +"Use international or China mainland console gateways for quota fetches." = "Sử dụng cổng bảng điều khiển quốc tế hoặc Trung Quốc đại lục để tìm nạp Hạn mức."; +"Version" = "Phiên bản"; +"Version \\(self.versionString)" = "Phiên bản \\(self.versionString)"; +"Version \\(version)" = "Phiên bản \\(version)"; +"Version \\(versionString)" = "Phiên bản \\(versionString)"; +"Vertex AI Login" = "Vertex AI Đăng nhập"; +"Wait for the current managed Codex login to finish before adding another account." = "Đợi quá trình đăng nhập Codex được quản lý hiện tại hoàn tất trước khi thêm tài khoản khác."; +"Waiting for Authentication..." = "Đang chờ xác thực..."; +"Website" = "Trang web"; +"Weekly limit confetti" = "Hoa giấy giới hạn hàng tuần"; +"Weekly token limit" = "token giới hạn"; +"Weekly usage" = "Hàng tuần Mức sử dụng"; +"Weekly usage unavailable for this account." = "Hàng tuần Mức sử dụng không khả dụng cho tài khoản này."; +"Window: \\(window)" = "Cửa sổ: \\(window)"; +"Write logs to \\(self.fileLogPath) for debugging." = "Ghi nhật ký vào \\(self.fileLogPath) để gỡ lỗi."; +"Yes" = "Có"; +"\\(detail.modelCode): \\(usage)" = "\\(detail.modelCode): \\(usage)"; +"\\(name): \\(truncated)" = "\\(name): \\(truncated)"; +"\\(name): \\(updated) · 30d \\(cost)" = "\\(name): \\(updated) · 30d \\(cost)"; +"\\(name): fetching…\\(elapsed)" = "\\(name): đang tìm nạp…\\(elapsed)"; +"\\(name): last attempt \\(when)" = "\\(name): lần thử cuối cùng \\(when)"; +"\\(name): no data yet" = "\\(name): chưa có dữ liệu"; +"\\(name): unsupported" = "\\(name): không được hỗ trợ"; +"all browsers" = "tất cả các trình duyệt"; +"available again." = "khả dụng trở lại."; +"built_format" = "Đã xây dựng %@"; +"copilot_complete_in_browser" = "Hoàn tất đăng nhập vào trình duyệt của bạn."; +"copilot_device_code" = "Mã thiết bị được sao chép vào bảng nhớ tạm: %1$@\n\nXác minh tại: %2$@"; +"copilot_device_code_copied" = "Đã sao chép mã thiết bị."; +"copilot_verify_at" = "Xác minh tại %@"; +"copilot_waiting_text" = "Hoàn tất đăng nhập vào trình duyệt của bạn.\nCửa sổ này tự động đóng khi quá trình đăng nhập hoàn tất."; +"copilot_window_closes_auto" = "Cửa sổ này tự động đóng khi quá trình đăng nhập hoàn tất."; +"cost_status_error" = "%1$@ : %2$@"; +"cost_status_fetching" = "%1$@ : đang tìm nạp… %2$@"; +"cost_status_last_attempt" = "%1$@ : lần thử cuối cùng %2$@"; +"cost_status_no_data" = "%@ : không có dữ liệu chưa"; +"cost_status_snapshot" = "%1$@ : %2$@ · %3$@ %4$@"; +"cost_status_unsupported" = "%@ : không được hỗ trợ"; +"credits_remaining" = "Tín dụng: %@"; +"cursor_on_demand" = "Theo yêu cầu: %@"; +"cursor_on_demand_with_limit" = "Theo yêu cầu: %1$@ / %2$@"; +"extra_usage_format" = "Mức sử dụng bổ sung : %1$@ / %2$@"; +"jetbrains_detected_generate" = "Đã phát hiện: %@ . Sử dụng trợ lý AI một lần để tạo dữ liệu Hạn mức, sau đó làm mới CodexBar ."; +"jetbrains_detected_select" = "Đã phát hiện: %@ . Chọn IDE ưa thích của bạn trong Cài đặt , sau đó làm mới CodexBar ."; +"last_fetch_failed_with_provider" = "Tìm nạp %@ lần cuối không thành công:"; +"last_spend" = "Chi tiêu lần cuối: %@"; +"mcp_model_usage" = "%1$@ : %2$@"; +"mcp_resets" = "Đặt lại: %@"; +"mcp_window" = "Cửa sổ: %@"; +"metric_average" = "Trung bình ( %1$@ + %2$@ )"; +"metric_primary" = "Sơ cấp ( %@ )"; +"metric_secondary" = "Trung học ( %@ )"; +"metric_tertiary" = "Cấp ba ( %@ )"; +"multiple_workspaces_found" = "CodexBar đã tìm thấy nhiều không gian làm việc cho %@ . Vui lòng chọn không gian làm việc để thêm."; +"ory_session_…=…; csrftoken=…" = "ory_session_…=…; csrftoken=…"; +"overview_choose_providers" = "Chọn tối đa %@ nhà cung cấp"; +"remove_account_message" = "Xóa %@ khỏi CodexBar ? Trang chủ Codex được quản lý của nó sẽ bị xóa."; +"version_format" = "Phiên bản %@"; +"vertex_ai_login_instructions" = "Để theo dõi Vertex AI Mức sử dụng , hãy xác thực bằng Google Cloud.\n\n1. Mở Terminal\n2. Chạy: gcloud auth application-default login\n3. Làm theo lời nhắc của trình duyệt để đăng nhập\n4. Đặt dự án của bạn: gcloud config set project PROJECT_ID\n\nMở Thiết bị đầu cuối bây giờ?"; +"workspaceID is set but only opencode, opencodego, and deepgram support workspaceID." = "ID không gian làm việc được đặt nhưng chỉ có mã mở, opencodego và deepgram hỗ trợ ID không gian làm việc."; +"© 2026 Peter Steinberger. MIT License." = "© 2026 Peter Steinberger. Giấy phép MIT."; + +/* General Pane */ +"section_system" = "Hệ thống"; +"section_usage" = "Mức sử dụng"; +"section_refreshing" = "Làm mới"; +"section_alerts" = "Cảnh báo"; +"section_celebrations" = "Ăn mừng"; +"section_icon" = "Biểu tượng"; +"section_combined_icon" = "Biểu tượng kết hợp"; +"section_animation" = "Hoạt ảnh"; +"section_content" = "Nội dung"; +"section_agent_sessions" = "Phiên tác nhân"; +"language_title" = "Ngôn ngữ"; +"language_subtitle" = "Thay đổi ngôn ngữ hiển thị. Yêu cầu khởi động lại ứng dụng để có hiệu lực đầy đủ."; +"currency_title" = "Tiền tệ ưu tiên"; +"currency_subtitle" = "Tiền tệ dùng cho ước tính chi phí và chi tiêu. Sử dụng tỷ giá được cập nhật hằng ngày."; +"currency_auto" = "Tự động (theo nhà cung cấp / USD)"; +"language_system" = "Hệ thống"; +"language_english" = "Tiếng Anh"; +"language_spanish" = "Español"; +"language_catalan" = "Català"; +"language_chinese_simplified" = "简体中文"; +"language_chinese_traditional" = "繁體中文"; +"language_portuguese_brazilian" = "Português (Brasil)"; +"language_german" = "Deutsch"; +"language_swedish" = "Svenska"; +"language_french" = "Tiếng Pháp"; +"language_dutch" = "Tiếng Hà Lan"; +"language_ukrainian" = "Tiếng Ukraina"; +"language_russian" = "Русский"; +"language_japanese" = "Tiếng Nhật"; +"language_korean" = "Tiếng Hàn"; +"language_italian" = "Italiano"; +"start_at_login_title" = "Bắt đầu khi đăng nhập"; +"start_at_login_subtitle" = "Tự động mở CodexBar khi bạn khởi động máy Mac."; +"show_cost_summary_subtitle" = "Đọc nhật ký Mức sử dụng cục bộ. Hiển thị hôm nay + cửa sổ lịch sử đã chọn trong menu."; +"cost_summary_style_title" = "Kiểu hiển thị"; +"cost_summary_style_inline" = "Chỉ nội tuyến"; +"cost_summary_style_submenu" = "Chỉ menu con"; +"cost_summary_style_both" = "Cả hai"; +"cost_summary_style_inline_help" = "Hiển thị tóm tắt chi phí trực tiếp trong menu chính."; +"cost_summary_style_submenu_help" = "Thay vào đó hiển thị menu con Chi phí chi tiết."; +"cost_summary_style_both_help" = "Hiển thị cả tóm tắt menu chính và menu con Chi phí chi tiết."; +"cost_history_window_title" = "Cửa sổ lịch sử"; +"cost_history_window_help" = "Đặt số ngày nhật ký sử dụng cục bộ xuất hiện trong menu."; +"cost_history_days_title" = "Cửa sổ lịch sử: %d ngày"; +"cost_auto_refresh_info" = "Tự động làm mới: khoảng thời gian chung (tối thiểu 5 phút) · Thời gian chờ: 10 phút"; +"cost_comparison_periods_title" = "Hiển thị các khoảng so sánh ngắn hơn"; +"cost_comparison_periods_subtitle" = "Thêm tổng 7, 30 và 90 ngày khi nằm trong cửa sổ lịch sử đã chọn. Các tổng này dùng lại cùng một lần quét cục bộ."; +"refresh_interval_title" = "Khoảng thời gian làm mới"; +"manual_refresh_hint" = "Tính năng tự động làm mới bị tắt; sử dụng lệnh Làm mới của menu."; +"refresh_on_open_title" = "Làm mới khi mở menu"; +"refresh_on_open_subtitle" = "Tải mức sử dụng mới nhất của mọi nhà cung cấp mỗi khi bạn mở menu."; +"check_provider_status_title" = "Kiểm tra Nhà cung cấp trạng thái"; +"check_provider_status_subtitle" = "Thăm dò ý kiến ​​OpenAI / Claude các trang trạng thái và Google Không gian làm việc dành cho Gemini /AntiGravity, phát hiện các sự cố trong biểu tượng và menu."; +"session_quota_notifications_subtitle" = "Thông báo khi phiên 5 giờ Hạn mức đạt 0% và khi phiên này khả dụng trở lại."; +"quota_depleted_title" = "Hạn mức đã cạn & được khôi phục"; +"quota_warning_notifications_subtitle" = "Cảnh báo khi phiên hoặc Hạn mức còn lại hàng tuần vượt qua ngưỡng được định cấu hình."; +"threshold_warnings_title" = "Cảnh báo ngưỡng"; +"quota_warnings_title" = "Hạn mức cảnh báo"; +"quota_warning_session" = "phiên"; +"quota_warning_session_capitalized" = "Phiên"; +"quota_warning_weekly" = "hàng tuần"; +"quota_warning_weekly_capitalized" = "Hàng tuần"; +"quota_warning_notification_title" = "%1$@ %2$@ Hạn mức thấp"; +"quota_warning_notification_body" = "%1$@ left. Reached your %2$d%% %3$@ warning threshold."; +"quota_warning_notification_body_with_account" = "Tài khoản %1$@ . Còn lại %2$@. Đã đạt đến ngưỡng cảnh báo %3$d %% %4$@ của bạn."; +"predictive_pace_warnings_title" = "Cảnh báo nhịp dùng dự đoán"; +"predictive_pace_warnings_subtitle" = "Cảnh báo cho Codex và Claude khi nhịp dùng phiên hoặc hằng tuần có thể làm hết hạn mức trước khi đặt lại."; +"confetti_on_reset_title" = "Pháo giấy khi đặt lại"; +"confetti_on_reset_subtitle" = "Hiển thị hiệu ứng pháo giấy toàn màn hình khi mức sử dụng được đặt lại."; +"confetti_option_off" = "Tắt"; +"confetti_option_session" = "Lần đặt lại phiên"; +"confetti_option_weekly" = "Lần đặt lại hằng tuần"; +"confetti_option_both" = "Cả hai"; +"predictive_pace_warning_notification_title" = "%1$@ cảnh báo nhịp dùng %2$@"; +"predictive_pace_warning_notification_body" = "Với nhịp dùng hiện tại, hạn mức này có thể hết trong %1$@, trước khi đặt lại."; +"predictive_pace_warning_notification_body_with_account" = "Tài khoản %1$@. Với nhịp dùng hiện tại, hạn mức này có thể hết trong %2$@, trước khi đặt lại."; +"session_depleted_notification_title" = "%@ phiên đã hết"; +"session_depleted_notification_body" = "còn lại 0%. Sẽ thông báo khi có lại."; +"session_restored_notification_title" = "%@ phiên đã được khôi phục"; +"session_restored_notification_body" = "Phiên Hạn mức đã có sẵn trở lại."; +"quota_warning_warn_at" = "Cảnh báo ở"; +"quota_warning_global_threshold_subtitle" = "Tỷ lệ phần trăm còn lại cho phiên và thời lượng hàng tuần trừ khi Nhà cung cấp ghi đè chúng."; +"quota_warning_sound" = "Phát âm thanh thông báo"; +"quota_warning_onscreen_alert" = "Hiển thị cảnh báo văn bản trên màn hình"; +"quota_warning_provider_inherits" = "Sử dụng cảnh báo Hạn mức toàn cầu Cài đặt trừ khi một cửa sổ được tùy chỉnh tại đây."; +"quota_warning_provider_disabled" = "Thông báo cảnh báo hạn mức và các dấu trên thanh mức sử dụng đều đang tắt. Bật một trong hai để chỉnh sửa các cài đặt đã lưu này."; +"quota_warning_provider_markers_only" = "Thông báo cảnh báo hạn mức đã bị tắt trên toàn ứng dụng. Các cài đặt này vẫn kiểm soát dấu trên thanh mức sử dụng."; +"quota_warning_global" = "Toàn cục"; +"quota_warning_customize_thresholds" = "Tùy chỉnh %@ ngưỡng"; +"quota_warning_enable_warnings" = "Bật %@ cảnh báo"; +"quota_warning_window_warn_at" = "%@ cảnh báo lúc"; +"quota_warning_off" = "Tắt"; +"quota_warning_inherited" = "Đã kế thừa: %@"; +"quota_warning_depleted_only" = "chỉ đã cạn"; +"quota_warning_upper" = "Cao hơn"; +"quota_warning_lower" = "Hạ"; +"quota_warning_warning" = "Cảnh báo"; +"quota_warning_critical" = "Nghiêm trọng"; +"apply" = "Áp dụng"; +"quit_app" = "Thoát CodexBar"; + +/* Tab titles */ +"tab_general" = "Chung"; +"tab_providers" = "Nhà cung cấp"; +"tab_notifications" = "Thông báo"; +"tab_menu_bar" = "Thanh menu"; +"tab_menu" = "Menu"; +"tab_advanced" = "Nâng cao"; +"tab_about" = "Giới thiệu về"; +"tab_debug" = "Gỡ lỗi"; + +/* Providers Pane */ +"select_a_provider" = "Chọn một Nhà cung cấp"; +"cancel" = "Hủy"; +"last_fetch_failed" = "lần tìm nạp cuối cùng không thành công"; +"usage_not_fetched_yet" = "Mức sử dụng chưa được tìm nạp"; +"managed_account_storage_unreadable" = "Bộ nhớ tài khoản được quản lý không thể đọc được. Quyền truy cập tài khoản trực tiếp vẫn khả dụng nhưng các hành động thêm, xác thực lại và xóa được quản lý sẽ bị vô hiệu hóa cho đến khi có thể khôi phục được cửa hàng."; +"remove_codex_account_title" = "Xóa tài khoản Codex?"; +"remove" = "Xóa"; +"managed_login_already_running" = "Đăng nhập Codex được quản lý đã chạy. Đợi quá trình hoàn tất trước khi thêm hoặc xác thực lại tài khoản khác."; +"managed_login_failed" = "Đăng nhập Codex được quản lý không hoàn tất. Xác minh rằng `codex --version` hoạt động trong Terminal. Nếu macOS đã chặn hoặc di chuyển `codex` vào Thùng rác, hãy xóa các bản cài đặt trùng lặp cũ, chạy `npm install -g --include=Optional @openai/codex@latest`, sau đó thử lại."; +"codex_login_output" = "đầu ra đăng nhập codex:"; +"managed_login_missing_email" = "Đăng nhập Codex đã hoàn tất nhưng không có email tài khoản. Hãy thử lại sau khi xác nhận tài khoản đã đăng nhập đầy đủ."; +"login_success_notification_title" = "%@ đăng nhập thành công"; +"login_success_notification_body" = "Bạn có thể quay lại ứng dụng; xác thực xong."; +"workspace_selection_cancelled" = "CodexBar đã tìm thấy nhiều không gian làm việc nhưng không có không gian làm việc nào được chọn."; +"unsafe_managed_home" = "CodexBar từ chối sửa đổi đường dẫn chính được quản lý không mong muốn: %@"; +"menu_bar_metric_title" = "thanh menu chỉ số"; +"menu_bar_metric_subtitle" = "Chọn cửa sổ nào thúc đẩy phần trăm thanh menu."; +"menu_bar_metric_subtitle_deepseek" = "Hiển thị số dư DeepSeek trong thanh menu ."; +"menu_bar_metric_subtitle_moonshot" = "Hiển thị số dư Moonshot / Kimi API trong thanh menu ."; +"menu_bar_metric_subtitle_mistral" = "Hiển thị mức chi tiêu API của Mistral trong tháng hiện tại trong thanh menu ."; +"automatic" = "Tự động"; +"primary_api_key_limit" = "Chính ( API giới hạn khóa)"; + +/* Display Pane */ +"menu_bar_style_title" = "Kiểu thanh menu"; +"menu_bar_style_subtitle" = "Cách hiển thị mục trên thanh menu."; +"menu_bar_inactive_display_contrast_title" = "Cải thiện khả năng hiển thị trên màn hình không hoạt động"; +"menu_bar_usage_colors_title" = "Mức dùng theo màu"; +"menu_bar_usage_colors_subtitle" = "Tô màu biểu tượng thanh menu từ xanh lá sang đỏ khi mức sử dụng tăng."; +"menu_bar_inactive_display_contrast_subtitle" = "Sử dụng hiển thị tương phản cao để biểu tượng và chỉ số vẫn dễ đọc trên các màn hình khác."; +"menu_bar_style_critters" = "Sinh vật"; +"menu_bar_style_bars" = "Thanh đo"; +"menu_bar_style_icon_percent" = "Biểu tượng & phần trăm"; +"switcher_rows_title" = "Các hàng của trình chuyển đổi"; +"switcher_rows_icons" = "Biểu tượng nhà cung cấp"; +"switcher_rows_progress" = "Tiến độ hằng tuần"; +"usage_bars_fill_title" = "Cách lấp đầy thanh sử dụng"; +"usage_bars_fill_remaining" = "Theo mức còn lại"; +"usage_bars_fill_used" = "Theo mức đã sử dụng"; +"reset_times_title" = "Thời gian đặt lại"; +"reset_times_countdown" = "Đếm ngược"; +"reset_times_clock" = "Mốc giờ"; +"cost_summary_title" = "Tóm tắt chi phí"; +"cost_summary_off" = "Tắt"; +"merge_icons_title" = "Hợp nhất các biểu tượng"; +"merge_icons_subtitle" = "Sử dụng một biểu tượng thanh menu duy nhất với trình chuyển đổi Nhà cung cấp."; +"show_most_used_provider_title" = "Hiển thị Nhà cung cấp"; +"show_most_used_provider_subtitle" = "thanh menu được sử dụng nhiều nhất tự động hiển thị Nhà cung cấp gần nhất với giới hạn tốc độ của nó."; +"display_mode_title" = "Chế độ hiển thị"; +"display_mode_subtitle" = "Chọn nội dung sẽ hiển thị trong thanh menu (Tốc độ hiển thị Mức sử dụng so với dự kiến)."; +"show_quota_warning_markers_title" = "Hiển thị Hạn mức dấu cảnh báo"; +"show_quota_warning_markers_subtitle" = "Vẽ dấu kiểm ngưỡng trên thanh Mức sử dụng khi cảnh báo Hạn mức được định cấu hình."; +"weekly_progress_work_days_title" = "Tiến độ ngày làm việc hàng tuần"; +"weekly_progress_work_days_subtitle" = "Đặt ngày làm việc cho các vạch trên thanh sử dụng hằng tuần và phép tính nhịp độ."; +"show_provider_changelog_links_title" = "Hiển thị Nhà cung cấp liên kết nhật ký thay đổi"; +"show_provider_changelog_links_subtitle" = "Thêm liên kết ghi chú phát hành cho các nhà cung cấp được hỗ trợ CLI vào menu."; +"show_credits_extra_usage_title" = "Hiển thị tín dụng + phần Mức sử dụng"; +"show_credits_extra_usage_subtitle" = "Hiển thị tín dụng Codex và Claude Các phần Mức sử dụng bổ sung trong menu."; +"multi_account_layout_title" = "Bố cục nhiều tài khoản"; +"multi_account_layout_subtitle" = "Chọn thẻ tài khoản chuyển đổi phân đoạn hoặc thẻ tài khoản xếp chồng."; +"multi_account_layout_segmented" = "Được phân đoạn"; +"multi_account_layout_stacked" = "Xếp chồng"; +"overview_tab_providers_title" = "Nhà cung cấp tab tổng quan"; +"configure" = "Định cấu hình…"; +"overview_enable_merge_icons_hint" = "Bật Hợp nhất Biểu tượng để định cấu hình nhà cung cấp tab Tổng quan."; +"overview_no_providers_hint" = "Không có nhà cung cấp nào được bật cho phần Tổng quan."; +"overview_rows_follow_order" = "Các hàng tổng quan luôn tuân theo thứ tự Nhà cung cấp."; +"overview_no_providers_selected" = "Không có nhà cung cấp nào được chọn"; +"agent_sessions_title" = "Phiên tác nhân"; +"agent_sessions_subtitle" = "Hiển thị các phiên Codex và Claude Code cục bộ cùng các phiên được phát hiện qua SSH trong menu."; +"agent_sessions_hosts_title" = "Máy chủ SSH bổ sung"; +"agent_sessions_footer" = "Các máy Mac trên tailnet của bạn được tự động phát hiện. Các phiên cục bộ được làm mới 30 giây một lần; các máy chủ từ xa được làm mới 60 giây một lần và khi menu mở."; +"agent_session_labels_title" = "Nhãn phiên"; +"agent_session_labels_subtitle" = "Chọn cách đặt tên cho các phiên tác nhân."; +"agent_session_label_project" = "Dự án"; +"agent_session_label_descriptive" = "Mô tả"; +"agent_session_label_descriptive_and_project" = "Mô tả + dự án"; +"agent_session_unknown_project" = "Dự án không xác định"; + +/* Advanced Pane */ +"section_keyboard_shortcut" = "Phím tắt"; +"open_menu_shortcut_title" = "Mở menu"; +"open_menu_shortcut_subtitle" = "Kích hoạt menu thanh menu từ mọi nơi."; +"install_cli" = "Cài đặt CLI"; +"install_cli_subtitle" = "Liên kết tượng trưng CodexBarCLI tới /usr/local/bin và /opt/homebrew/bin dưới dạng codexbar."; +"cli_not_found" = "Không tìm thấy CodexBarCLI trong gói ứng dụng."; +"no_writable_bin_dirs" = "Không tìm thấy thư mục bin có thể ghi."; +"show_debug_settings_title" = "Hiển thị gỡ lỗi Cài đặt"; +"show_debug_settings_subtitle" = "Hiển thị các công cụ khắc phục sự cố trong tab Gỡ lỗi."; +"surprise_me_title" = "Làm tôi ngạc nhiên"; +"surprise_me_subtitle" = "Kiểm tra xem bạn có thích các đại lý của mình vui vẻ ở đó không."; +"hide_personal_info_title" = "Ẩn thông tin cá nhân"; +"hide_personal_info_subtitle" = "Địa chỉ email tối nghĩa trong thanh menu và giao diện người dùng menu."; +"show_provider_storage_usage_title" = "Hiển thị Nhà cung cấp bộ nhớ Mức sử dụng"; +"show_provider_storage_usage_subtitle" = "Hiển thị ổ đĩa cục bộ Mức sử dụng trong menu. Quét các đường dẫn thuộc quyền sở hữu của Nhà cung cấp đã biết ở chế độ nền."; +"section_keychain_access" = "Keychain quyền truy cập"; +"keychain_access_caption" = "Tắt tất cả Keychain đọc và ghi. Hãy sử dụng tùy chọn này nếu macOS liên tục nhắc về ' Chrome /Brave/Edge Safe Storage' ngay cả sau khi nhấp vào Luôn cho phép. Nhập cookie trình duyệt không khả dụng khi được bật; dán tiêu đề Cookie theo cách thủ công vào Nhà cung cấp. Claude /Codex OAuth thông qua CLI vẫn hoạt động."; +"disable_keychain_access_title" = "Vô hiệu hóa quyền truy cập Keychain"; +"disable_keychain_access_subtitle" = "Ngăn chặn mọi quyền truy cập Keychain khi được bật."; + +/* About Pane */ +"about_tagline" = "Cầu mong mã thông báo của bạn không bao giờ hết—giữ giới hạn đại lý trong tầm mắt."; +"link_github" = "GitHub"; +"link_website" = "Trang web"; +"link_twitter" = "Twitter"; +"link_email" = "Email"; +"check_updates_auto" = "Tự động kiểm tra các bản cập nhật"; +"update_channel" = "Kênh cập nhật"; +"check_for_updates" = "Kiểm tra các bản cập nhật…"; +"updates_unavailable" = "Các bản cập nhật không có sẵn trong bản dựng này."; +"copyright" = "© 2026 Peter Steinberger. Giấy phép MIT."; + +/* Debug Pane */ +"section_logging" = "Ghi nhật ký"; +"enable_file_logging" = "Cho phép ghi nhật ký tệp"; +"enable_file_logging_subtitle" = "Ghi nhật ký vào %@ để gỡ lỗi."; +"verbosity_title" = "Độ chi tiết"; +"verbosity_subtitle" = "Kiểm soát lượng chi tiết được ghi lại."; +"open_log_file" = "Mở tệp nhật ký"; +"force_animation_next_refresh" = "Buộc hoạt ảnh vào lần làm mới tiếp theo"; +"force_animation_next_refresh_subtitle" = "Tạm thời hiển thị hoạt ảnh đang tải sau lần làm mới tiếp theo."; +"section_loading_animations" = "Đang tải hình động"; +"loading_animations_caption" = "Chọn một mẫu và phát lại nó trong thanh menu . \" Ngẫu nhiên \" giữ nguyên hành vi hiện có."; +"animation_random_default" = "Ngẫu nhiên (mặc định)"; +"replay_selected_animation" = "Phát lại hoạt ảnh đã chọn"; +"blink_now" = "Nhấp nháy ngay"; +"section_probe_logs" = "Nhật ký thăm dò"; +"probe_logs_caption" = "Tìm nạp đầu ra thăm dò mới nhất để gỡ lỗi; Sao chép giữ toàn bộ văn bản."; +"fetch_log" = "Nhật ký tìm nạp"; +"copy" = "Sao chép"; +"save_to_file" = "Lưu vào tệp"; +"load_parse_dump" = "Tải kết xuất phân tích cú pháp"; +"rerun_provider_autodetect" = "Chạy lại Nhà cung cấp tự động phát hiện"; +"loading" = "Đang tải…"; +"no_log_yet_fetch" = "Chưa có nhật ký nào. Tìm nạp để tải."; +"section_fetch_strategy" = "Lần thử chiến lược tìm nạp"; +"fetch_strategy_caption" = "Tìm nạp lần cuối các quyết định và lỗi về đường dẫn cho Nhà cung cấp ."; +"section_openai_cookies" = "OpenAI cookie"; +"openai_cookies_caption" = "Nhập cookie + nhật ký trích xuất WebKit từ lần thử cookie OpenAI gần đây nhất."; +"no_log_yet" = "Chưa có nhật ký nào. Cập nhật cookie OpenAI trong Nhà cung cấp → Codex để chạy quá trình nhập."; +"section_caches" = "Bộ nhớ đệm"; +"caches_caption" = "Xóa kết quả quét chi phí được lưu trong bộ nhớ đệm hoặc bộ nhớ đệm cookie của trình duyệt."; +"clear_cookie_cache" = "Xóa bộ nhớ đệm cookie"; +"clear_cost_cache" = "Xóa bộ nhớ đệm chi phí"; +"section_notifications" = "Thông báo"; +"notifications_caption" = "Kích hoạt thông báo kiểm tra cho khoảng thời gian phiên 5 giờ (đã cạn/được khôi phục)."; +"post_depleted" = "Đã hết bài đăng"; +"post_restored" = "Đã khôi phục bài đăng"; +"section_cli_sessions" = "CLI phiên"; +"cli_sessions_caption" = "Giữ cho các phiên Codex/ Claude CLI vẫn tồn tại sau khi thăm dò. Thoát mặc định sau khi dữ liệu được ghi lại."; +"keep_cli_sessions_alive" = "Duy trì CLI phiên"; +"keep_cli_sessions_alive_subtitle" = "Bỏ qua việc phân tích giữa các lần thăm dò (chỉ gỡ lỗi)."; +"reset_cli_sessions" = "Đặt lại CLI phiên"; +"section_error_simulation" = "Mô phỏng lỗi"; +"error_simulation_caption" = "Đưa thông báo lỗi giả vào thẻ menu để kiểm tra bố cục."; +"set_menu_error" = "Đặt lỗi menu"; +"clear_menu_error" = "Xóa lỗi menu"; +"set_cost_error" = "Lỗi đặt chi phí"; +"clear_cost_error" = "Xóa lỗi chi phí"; +"section_cli_paths" = "CLI đường dẫn"; +"cli_paths_caption" = "Đã giải quyết các lớp nhị phân Codex và PATH; chụp PATH đăng nhập khởi động (thời gian chờ ngắn)."; +"codex_binary" = "Codex nhị phân"; +"claude_binary" = "Claude nhị phân"; +"effective_path" = "PATH hiệu quả"; +"unavailable" = "Không khả dụng"; +"login_shell_path" = "Shell đăng nhập PATH (chụp khởi động)"; +"cleared" = "Đã xóa."; +"no_fetch_attempts" = "Chưa có lần tìm nạp nào."; +"macOS Tahoe can block menu bar apps in System Settings → Menu Bar → Allow in the Menu Bar. CodexBar is running, but macOS may be hiding its icon. Open Menu Bar settings and turn CodexBar on." = "macOS Tahoe có thể chặn thanh menu ứng dụng trong Hệ thống Cài đặt → thanh menu → Cho phép trong thanh menu . CodexBar đang chạy nhưng macOS có thể đang ẩn biểu tượng của nó. Mở thanh menu Cài đặt và bật CodexBar."; + +/* Metric preferences */ +"metric_pref_automatic" = "Tự động"; +"metric_pref_primary" = "Chính"; +"metric_pref_secondary" = "Trung học"; +"metric_pref_tertiary" = "Đại học"; +"metric_pref_extra_usage" = "Bổ sung Mức sử dụng"; +"metric_pref_average" = "Trung bình"; +"metric_mistral_payg" = "Pay-as-you-go"; +"metric_mistral_monthly_plan" = "Monthly Plan"; + +/* Display modes */ +"display_mode_percent" = "Phần trăm"; +"display_mode_pace" = "Tốc độ"; +"display_mode_both" = "Cả hai"; +"display_mode_reset_time" = "Thời gian đặt lại"; +"display_mode_percent_desc" = "Hiển thị phần trăm còn lại/đã sử dụng (ví dụ: 45%)"; +"display_mode_pace_desc" = "Hiển thị chỉ báo tốc độ (ví dụ: +5%)"; +"display_mode_both_desc" = "Hiển thị cả phần trăm và tốc độ (ví dụ: 45% · +5%)"; +"display_mode_reset_time_desc" = "Hiển thị thời gian đặt lại của chỉ số đã chọn (ví dụ: ↻ 15:56)"; +"menu_bar_reset_when_exhausted_title" = "Hiển thị thời gian đặt lại khi hết hạn mức"; +"menu_bar_reset_when_exhausted_subtitle" = "Khi còn 0%, hiển thị thời gian đến lúc đặt lại thay vì phần trăm"; + +/* Provider status */ +"status_operational" = "Hoạt động"; +"status_degraded" = "Hiệu suất giảm"; +"status_partial_outage" = "Mất điện một phần"; +"status_major_outage" = "Mất điện lớn"; +"status_critical_issue" = "Sự cố nghiêm trọng"; +"status_maintenance" = "Bảo trì"; +"status_unknown" = "Trạng thái không xác định"; + +/* Refresh frequency */ +"refresh_manual" = "Thủ công"; +"refresh_1min" = "1 phút"; +"refresh_2min" = "2 phút"; +"refresh_5min" = "5 phút"; +"refresh_15min" = "15 phút"; +"refresh_30min" = "30 phút"; +"refresh_adaptive" = "Thích ứng"; +"refresh_adaptive_agent_aware" = "Thích ứng (nhận biết tác nhân)"; +"adaptive_activity_consent_title" = "Cho phép làm mới theo hoạt động?"; +"adaptive_activity_consent_message" = "Chế độ Thích ứng nhận biết tác nhân có thể kiểm tra danh sách tiến trình cục bộ đang chạy, bao gồm cả dòng lệnh, để nhận diện Codex và Claude, sau đó đọc siêu dữ liệu của các phiên đã biết mỗi 30 giây trong khi bạn viết mã. Khi tắt Agent Sessions, CodexBar chỉ dùng thời điểm hoạt động gần nhất trong bộ nhớ và loại bỏ đường dẫn cùng danh tính phiên. Dữ liệu này không được gửi đi đâu, còn tính năng phát hiện từ xa và SSH vẫn tắt. Nếu bạn từ chối, CodexBar trở về chế độ Thích ứng thông thường mà không quét hoạt động cục bộ."; +"adaptive_activity_consent_allow" = "Cho phép hoạt động cục bộ"; +"adaptive_activity_consent_decline" = "Dùng Thích ứng thông thường"; + +/* Additional keys */ +"not_found" = "Không tìm thấy"; + +/* Cost estimation */ +"cost_estimate_hint" = "Ước tính từ nhật ký cục bộ · có thể khác với hóa đơn của bạn"; +"codex_api_estimate_hint" = "Ước tính từ mức sử dụng token · không phải hóa đơn đăng ký"; +"cost_data_explanation" = "Chi phí có thể do nhà cung cấp báo cáo hoặc được ước tính từ mức sử dụng token theo giá API công khai. Các ước tính không phải phí đăng ký."; +"No JetBrains IDE with AI Assistant detected. Install a JetBrains IDE and enable AI Assistant." = "Không phát hiện thấy IDE JetBrains nào có Trợ lý AI. Cài đặt JetBrains IDE và bật Trợ lý AI."; +"OpenRouter API token not configured. Set OPENROUTER_API_KEY environment variable or configure in Settings." = "OpenRouter API token chưa được định cấu hình. Đặt biến môi trường OPENROUTER_API_KEY hoặc định cấu hình trong Cài đặt ."; +"z.ai API token not found. Set apiKey in ~/.codexbar/config.json or Z_AI_API_KEY." = "không tìm thấy z.ai API token. Đặt apiKey trong ~/.codexbar/config.json hoặc Z_AI_API_KEY."; +"Missing DeepSeek API key." = "Thiếu khóa DeepSeek API."; +"%@ is unavailable in the current environment." = "%@ không khả dụng trong môi trường hiện tại."; +"All Systems Operational" = "Tất cả hệ thống đều hoạt động"; +"Last 30 days" = "30 ngày qua"; +"Last 30 days:" = "30 ngày qua:"; +"This month" = "Tháng này"; +"Store multiple OpenAI API keys." = "Lưu trữ nhiều khóa OpenAI API."; +"Admin API key" = "Khóa quản trị API"; +"Open billing" = "Mở thanh toán"; +"Google accounts" = "Google tài khoản"; +"Store multiple Antigravity Google OAuth accounts for quick switching." = "Lưu trữ nhiều tài khoản AntiGravity Google OAuth để chuyển đổi nhanh chóng."; +"Add Google Account" = "Thêm Google Tài khoản"; +"Open Token Plan" = "Mở token Kế hoạch"; +"Text Generation" = "Tạo văn bản"; +"Text to Speech" = "Chuyển văn bản thành giọng nói"; +"Music Generation" = "Tạo nhạc"; +"Image Generation" = "Tạo hình ảnh"; +"No local data found" = "Không tìm thấy dữ liệu cục bộ"; +"Credits unavailable; keep Codex running to refresh." = "Không có tín dụng; giữ Codex chạy để làm mới."; +"No available fetch strategy for minimax." = "Không có chiến lược tìm nạp nào cho minimax."; +"No Cursor session found. Please log in to cursor.com in Safari, Chrome, Microsoft Edge, Brave, Arc, Dia, ChatGPT Atlas, Chromium, Helium, Vivaldi, Yandex Browser, Firefox, Zen, Colibri, Sidekick, Opera, Opera GX, or Edge Canary. If you use Safari, grant CodexBar Full Disk Access in System Settings ▸ Privacy & Security. You can also sign in to Cursor from the CodexBar menu (Add / switch account)." = "Không tìm thấy phiên Con trỏ. Vui lòng đăng nhập vào con trỏ.com bằng Safari , Chrome , Microsoft Edge, Brave, Arc, Dia, ChatGPT Atlas, Chrome, Helium, Vivaldi, Yandex Browser, Firefox , Zen, Colibri, Sidekick, Opera, Opera GX hoặc Edge Canary. Nếu bạn sử dụng Safari , hãy cấp cho CodexBar Quyền truy cập toàn bộ đĩa trong Hệ thống Cài đặt ▸ Quyền riêng tư & Bảo mật. Bạn cũng có thể đăng nhập vào Cursor từ menu CodexBar (Thêm/chuyển đổi tài khoản)."; +"No OpenCode session cookies found in browsers." = "Không tìm thấy cookie phiên OpenCode trong trình duyệt."; +"No available fetch strategy for %@." = "Không có chiến lược tìm nạp nào cho %@ ."; +"Today" = "Hôm nay"; +"Today tokens" = "Mã thông báo hôm nay"; +"30d cost" = "giá 30d"; +"%@ cost" = "giá %@"; +"30d tokens" = "mã thông báo 30d"; +"Latest tokens" = "Mã thông báo mới nhất"; +"Top model" = "Mô hình hàng đầu"; +"Storage" = "Bộ nhớ"; +"Add Account..." = "Thêm tài khoản..."; +"Usage Dashboard" = "Mức sử dụng Trang tổng quan"; +"Status Page" = "Trang trạng thái"; +"Open Status Page" = "Mở trang trạng thái"; +"Settings..." = "Cài đặt ..."; +"About CodexBar" = "Giới thiệu về CodexBar"; +"Quit" = "Thoát"; +"Last %d day" = "Ngày %d cuối cùng"; +"Last %d days" = "%d ngày cuối cùng"; +"%@ tokens" = "%@ mã thông báo"; +"Latest billing day" = "Ngày thanh toán muộn nhất"; +"Latest billing day (%@)" = "Ngày thanh toán muộn nhất ( %@ )"; +"%@ left" = "còn lại %@"; +"Resets %@" = "Đặt lại %@"; +"Resets in %@" = "Đặt lại sau %@"; +"Resets now" = "Đặt lại ngay"; +"reset_tomorrow_format" = "ngày mai, %@"; +"Lasts until reset" = "Kéo dài cho đến Đặt lại"; +"1.5× headroom" = "dư địa 1,5×"; +"Updated %@" = "Đã cập nhật %@"; +"Updated relative %@" = "Đã cập nhật %@"; +"Updated absolute %@" = "Đã cập nhật %@"; +"Updated %@h ago" = "Đã cập nhật %@ h trước"; +"Updated %@m ago" = "Đã cập nhật %@ tháng trước"; +"Updated just now" = "Vừa cập nhật"; +"Projected empty in %@" = "Dự kiến trống trong %@"; +"Runs out in %@" = "Hết trong %@"; +"Pace: %@" = "Tốc độ: %@"; +"Pace: %@ · %@" = "Pace: %@ · %@"; +"%@ · %@" = "%@ · %@"; +"≈ %d%% run-out risk" = "≈ %d %% rủi ro cạn kiệt"; +"%d%% in deficit" = "%d %% thâm hụt"; +"%d%% in reserve" = "%d %% dự trữ"; +"usage_percent_suffix_left" = "left"; +"usage_percent_suffix_used" = "đã sử dụng"; +"Store multiple DeepSeek API keys." = "Lưu trữ nhiều khóa DeepSeek API."; +"This week" = "Tuần này"; +"Week" = "Tuần"; +"Month" = "Tháng"; +"Models" = "Mô hình"; +"24h tokens" = "Token 24 giờ"; +"Latest hour" = "Giờ mới nhất"; +"Peak hour" = "Giờ cao điểm"; +"Top method" = "Phương thức hàng đầu"; +"30d cash" = "tiền mặt 30d"; +"30d billing history from MiniMax web session" = "Thanh toán 30 ngày lịch sử từ MiniMax phiên web"; +"AWS Cost Explorer billing can lag." = "Việc thanh toán AWS Cost Explorer có thể bị trễ."; +"Rate limit: %d / %@" = "Giới hạn tốc độ: %d / %@"; +"Key remaining" = "Khóa còn lại"; +"No limit set for the API key" = "Không có giới hạn nào được đặt cho khóa API"; +"API key limit unavailable right now" = "Giới hạn khóa API hiện không khả dụng"; +"This month: %@ tokens" = "Tháng này: mã thông báo %@"; +"No utilization data yet." = "Chưa có dữ liệu sử dụng."; +"No %@ utilization data yet." = "Chưa có dữ liệu sử dụng %@."; +"%@: %@%% used" = "%@ : %@ %% đã sử dụng"; +"%dd" = "%d d"; +"today" = "hôm nay"; +"just now" = "vừa rồi"; +"On pace" = "Đang tiến hành"; +"Runs out now" = "Sắp hết"; +"Projected empty now" = "Dự kiến trống"; +"Switch Account..." = "Chuyển tài khoản..."; +"Update ready, restart now?" = "Cập nhật đã sẵn sàng, khởi động lại ngay bây giờ?"; +"Daily" = "Hàng ngày"; +"Hourly Tokens" = "Mã thông báo hàng giờ"; +"No data" = "Không có dữ liệu"; +"No usage breakdown data available." = "Không có dữ liệu phân tích Mức sử dụng."; + +"Today: %@ · %@ tokens" = "Hôm nay: %@ · %@ mã thông báo"; +"Today: %@" = "Hôm nay: %@"; +"Today: %@ tokens" = "Hôm nay: %@ mã thông báo"; +"Last 30 days: %@ · %@ tokens" = "30 ngày qua: %@ · %@ mã thông báo"; +"Last 30 days: %@" = "30 ngày qua: %@"; +"Est. total (30d): %@" = "Ước tính tổng cộng (30 ngày): %@"; +"Est. total (%@): %@" = "Ước tính tổng ( %@ ): %@"; +"Hover a bar for details" = "Di chuột qua thanh để biết thông tin chi tiết"; +"%@: %@ · %@ tokens" = "%@ : %@ · %@ mã thông báo"; +"No providers selected for Overview." = "Không có nhà cung cấp nào được chọn cho Tổng quan."; +"No overview data available." = "Không có dữ liệu tổng quan."; +"Auto uses the local IDE API first, then Google OAuth when the IDE is closed." = "Tự động sử dụng IDE cục bộ API trước, sau đó là Google OAuth khi IDE đóng."; +"Login with Google" = "Đăng nhập bằng Google"; + +/* Popup panels */ +"No usage configured." = "Chưa định cấu hình Mức sử dụng."; +"Quota" = "Hạn mức"; +"Daily quota" = "Hạn mức hằng ngày"; +"Total" = "Tổng"; +"tokens" = "mã thông báo"; +"requests" = "yêu cầu"; +"Latest" = "Mới nhất"; +"Monthly" = "Hàng tháng"; +"Sonnet" = "Sonnet"; +"Overages" = "Quá tải"; +"Activity" = "Hoạt động"; +"Copied" = "Đã sao chép"; +"Copy error" = "Lỗi sao chép"; +"Copy path" = "Sao chép đường dẫn"; +"Extra usage spent" = "Thêm Mức sử dụng đã chi tiêu"; +"Credits remaining" = "Tín dụng còn lại"; +"Using CLI fallback" = "Sử dụng CLI dự phòng"; +"Balance updates in near-real time (up to 5 min lag)" = "Cập nhật số dư trong thời gian gần như thực (độ trễ tối đa 5 phút)"; +"Daily billing data finalizes at 07:00 UTC" = "Dữ liệu thanh toán hàng ngày sẽ hoàn tất lúc 07:00 UTC"; +"%@ of %@ credits left" = "%@ trong số %@ tín dụng còn lại"; +"%@ of %@ bonus credits left" = "%@ trong số %@ tín dụng thưởng còn lại"; +"%@ / %@ (%@ remaining)" = "%@ / %@ ( %@ còn lại)"; +"%@/%@ left" = "%@ / %@ left"; +"Gemini Flash" = "Gemini Flash"; +"Regenerates %@" = "Tái tạo %@"; +"used after next regen" = "được sử dụng sau lần tái sinh tiếp theo"; +"after next regen" = "sau đợt regen tiếp theo"; +"Near full" = "Gần đầy"; +"Full in ~1 regen" = "Đầy đủ trong ~1 regen"; +"Full in ~%.0f regens" = "Đầy đủ trong ~%.0f regens"; +"Overage usage" = "Quá mức Mức sử dụng"; +"Overage cost" = "Chi phí quá mức"; +"credits" = "tín dụng"; +"Zen balance" = "Số dư Zen"; +"API spend" = "API chi tiêu"; +"Extra usage" = "Thêm Mức sử dụng"; +"Quota usage" = "Hạn mức Mức sử dụng"; +"Your spend" = "Chi tiêu của bạn"; +"%.0f%% used" = "%.0f%% đã sử dụng"; +"Usage history (today)" = "Mức sử dụng lịch sử (hôm nay)"; +"Usage history (%d days)" = "Mức sử dụng lịch sử ( %d ngày)"; +"%d percent remaining" = "%d phần trăm còn lại"; +"Unknown" = "Không xác định"; +"stale data" = "dữ liệu cũ"; +"No credits history data." = "Không có dữ liệu lịch sử tín dụng."; +"No credits history data available." = "Không có dữ liệu lịch sử tín dụng."; +"Credits history chart" = "Biểu đồ lịch sử tín dụng"; +"%d days of credits data" = "%d ngày dữ liệu tín dụng"; +"Usage breakdown chart" = "Mức sử dụng biểu đồ phân tích"; +"%d days of usage data across %d services" = "%d ngày của dữ liệu Mức sử dụng trên %d dịch vụ"; +"Cost history chart" = "Biểu đồ lịch sử chi phí"; +"%d days of cost data" = "%d ngày của dữ liệu chi phí"; +"Plan utilization chart" = "Biểu đồ sử dụng kế hoạch"; +"%d utilization samples" = "%d mẫu sử dụng"; +"Hourly Usage" = "Hàng giờ Mức sử dụng"; +"Usage remaining" = "Mức sử dụng"; +"Usage used" = "Mức sử dụng đã sử dụng khóa"; +"API key verified. Cloud quotas need browser cookies. Sign in to Ollama." = "Đã xác minh khóa API. Hạn mức Cloud yêu cầu cookie trình duyệt. Hãy đăng nhập vào Ollama."; +"Last 30 days: %@ tokens" = "30 ngày qua: %@ mã thông báo"; +"7d spend" = "chi tiêu 7 ngày"; +"30d spend" = "chi tiêu 30 ngày"; +"Cache read" = "Đọc bộ nhớ đệm"; +"Claude Admin API 30 day spend trend" = "Claude Quản trị viên API Xu hướng chi tiêu 30 ngày"; +"OpenRouter API key spend trend" = "Xu hướng chi tiêu khóa API OpenRouter"; +"z.ai hourly token trend" = "z.ai hàng giờ token xu hướng"; +"MiniMax 30 day token usage trend" = "MiniMax 30 ngày token Mức sử dụng xu hướng"; +"Today cash" = "Tiền mặt hôm nay"; +"DeepSeek 30 day token usage trend" = "DeepSeek 30 ngày token Mức sử dụng xu hướng"; +"cache-hit input" = "đầu vào truy cập bộ đệm"; +"cache-miss input" = "đầu vào bỏ lỡ bộ đệm"; +"output" = "đầu ra"; +"Requests" = "Yêu cầu"; +"Reported by OpenAI Admin API organization usage." = "Được báo cáo bởi OpenAI Quản trị viên API tổ chức Mức sử dụng ."; +"Reported by Mistral billing usage." = "Được báo cáo bởi thanh toán Mistral Mức sử dụng ."; +"Google OAuth" = "Google OAuth"; +"Add accounts via GitHub OAuth Device Flow on the selected host." = "Thêm tài khoản qua GitHub OAuth Luồng thiết bị trên máy chủ đã chọn."; +"Stores each signed-in Google account for quick Antigravity switching. Uses Antigravity.app OAuth when available, or ANTIGRAVITY_OAUTH_CLIENT_ID and ANTIGRAVITY_OAUTH_CLIENT_SECRET as an override." = "Lưu trữ từng tài khoản Google đã đăng nhập để chuyển đổi Chống hấp dẫn nhanh chóng. Sử dụng AntiGravity.app OAuth khi có sẵn hoặc ANTIGRAVITY_OAUTH_CLIENT_ID và ANTIGRAVITY_OAUTH_CLIENT_SECRET làm ghi đè."; +"Manual cleanup: past sessions" = "Dọn dẹp thủ công: các phiên trước đây"; +"Clearing removes past resume, continue, and rewind history." = "Việc xóa sẽ xóa lịch sử tiếp tục, tiếp tục và tua lại trong quá khứ."; +"Manual cleanup: file checkpoints" = "Dọn dẹp thủ công: điểm kiểm tra tệp"; +"Clearing removes checkpoint restore data for previous edits." = "Việc xóa sẽ xóa dữ liệu khôi phục điểm kiểm tra cho các chỉnh sửa trước đó."; +"Manual cleanup: saved plans" = "Dọn dẹp thủ công: các gói đã lưu"; +"Clearing removes old plan-mode files." = "Việc xóa sẽ xóa các tệp chế độ gói cũ."; +"Manual cleanup: debug logs" = "Dọn dẹp thủ công: nhật ký gỡ lỗi"; +"Clearing removes past debug logs." = "Việc xóa sẽ xóa nhật ký gỡ lỗi trước đây."; +"Manual cleanup: attachment cache" = "Dọn dẹp thủ công: bộ đệm đính kèm"; +"Clearing removes cached large pastes or attached images." = "Việc xóa sẽ xóa các miếng dán lớn hoặc hình ảnh đính kèm được lưu trong bộ nhớ đệm."; +"Manual cleanup: session metadata" = "Dọn dẹp thủ công: siêu dữ liệu phiên"; +"Clearing removes per-session environment metadata." = "Việc xóa sẽ xóa siêu dữ liệu môi trường mỗi phiên."; +"Manual cleanup: shell snapshots" = "Dọn dẹp thủ công: ảnh chụp nhanh shell"; +"Clearing removes leftover runtime shell snapshot files." = "Việc xóa sẽ xóa các tệp ảnh chụp nhanh shell thời gian chạy còn sót lại."; +"Manual cleanup: legacy todos" = "Dọn dẹp thủ công: việc cần làm cũ"; +"Clearing removes legacy per-session task lists." = "Việc xóa sẽ xóa danh sách nhiệm vụ cũ mỗi phiên."; +"Manual cleanup: sessions" = "Dọn dẹp thủ công: phiên"; +"Clearing removes past Codex session history." = "Việc xóa sẽ xóa lịch sử phiên Codex trước đây."; +"Manual cleanup: archived sessions" = "Dọn dẹp thủ công: các phiên đã lưu trữ"; +"Clearing removes archived Codex session history." = "Việc xóa sẽ xóa lịch sử phiên Codex đã lưu trữ."; +"Manual cleanup: cache" = "Dọn dẹp thủ công: bộ đệm"; +"Clearing removes provider-owned cached data." = "Việc xóa sẽ xóa dữ liệu được lưu trong bộ nhớ đệm thuộc quyền sở hữu của Nhà cung cấp."; +"Manual cleanup: logs" = "Dọn dẹp thủ công: nhật ký"; +"Clearing removes local diagnostic logs." = "Việc xóa sẽ xóa nhật ký chẩn đoán cục bộ."; +"Manual cleanup: file history" = "Dọn dẹp thủ công: lịch sử tệp"; +"Clearing removes local edit checkpoint history." = "Việc xóa sẽ xóa lịch sử điểm kiểm tra chỉnh sửa cục bộ."; +"Manual cleanup: temporary data" = "Dọn dẹp thủ công: dữ liệu tạm thời"; +"Clearing removes local temporary provider data." = "Việc xóa sẽ xóa dữ liệu Nhà cung cấp tạm thời cục bộ."; +"Total: %@" = "Tổng cộng: %@"; +"%d more items" = "%d mục khác"; +"Cleanup ideas" = "Ý tưởng dọn dẹp"; +"%d unreadable item(s) skipped" = "%d (các) mục không thể đọc được đã bỏ qua"; + +"API key limit" = "API giới hạn khóa"; +"Auth" = "Xác thực"; +"Auto" = "Tự động"; +"Disabled — no recent data" = "Đã tắt — không có dữ liệu gần đây"; +"Limits not available" = "Không có giới hạn"; +"No usage yet" = "Chưa có Mức sử dụng"; +"Not fetched yet" = "Chưa được tìm nạp"; +"Refreshing" = "Đang làm mới"; +"Session" = "Phiên"; +"Source" = "Nguồn"; +"State" = "Trạng thái"; +"Unavailable" = "Không có sẵn"; +"Weekly" = "Không phát hiện được"; +"not detected" = "hàng tuần"; +"Estimated from local Codex logs for the selected account." = "Được ước tính từ nhật ký Codex cục bộ cho tài khoản đã chọn."; +"minimax_usage_amount_format" = "Mức sử dụng : %@ / %@"; +"minimax_used_percent_format" = "Đã sử dụng %@"; +"minimax_service_text_generation" = "Tạo văn bản"; +"minimax_service_text_to_speech" = "Chuyển văn bản thành giọng nói"; +"minimax_service_music_generation" = "Tạo nhạc"; +"minimax_service_image_generation" = "Tạo hình ảnh"; +"minimax_service_lyrics_generation" = "Tạo lời bài hát"; +"minimax_service_coding_plan_vlm" = "Kế hoạch mã hóa VLM"; +"minimax_service_coding_plan_search" = "Tìm kiếm kế hoạch mã hóa"; + +/* Additional provider settings and alerts */ +"%@ is waiting for permission" = "%@ đang chờ cấp phép"; +"%@ requests" = "%@ yêu cầu"; +"%@: %@ credits" = "%@: %@ credits"; +"30d requests" = "yêu cầu 30 ngày"; +"4 days" = "4 ngày"; +"5 days" = "5 ngày"; +"7 days" = "7 ngày"; +"API key verifies Ollama Cloud access; cookies still expose quota limits." = "API khóa xác minh quyền truy cập vào Đám mây Ollama; cookie vẫn hiển thị giới hạn Hạn mức."; +"AWS access key ID. Can also be set with AWS_ACCESS_KEY_ID." = "ID khóa truy cập AWS. Cũng có thể được đặt bằng AWS_ACCESS_KEY_ID."; +"AWS region. Can also be set with AWS_REGION." = "Khu vực AWS. Cũng có thể được đặt bằng AWS_REGION."; +"AWS secret access key. Can also be set with AWS_SECRET_ACCESS_KEY." = "Khóa truy cập bí mật AWS. Cũng có thể được đặt bằng AWS_SECRET_ACCESS_KEY."; +"Access key ID" = "ID khóa truy cập"; +"Add Account" = "Thêm tài khoản"; +"Adding Account…" = "Đang thêm tài khoản…"; +"Antigravity login failed" = "Đăng nhập chống trọng lực không thành công"; +"Antigravity login timed out" = "Đã hết thời gian đăng nhập chống trọng lực"; +"Auth source" = "Nguồn xác thực"; +"Automatic imports browser cookies from Xiaomi MiMo." = "Tự động nhập cookie trình duyệt từ Xiaomi MiMo."; +"Automatic imports Windsurf session data from Chromium browser localStorage." = "Tự động nhập dữ liệu phiên Windsurf từ localStorage của trình duyệt Chrome."; +"Automatic imports browser cookies from Bailian." = "Tự động nhập cookie trình duyệt từ Bailian."; +"Automatically imports browser cookies." = "Tự động nhập cookie trình duyệt."; +"Automatically imports browser session cookies." = "Tự động nhập cookie phiên trình duyệt."; +"Azure OpenAI deployment name. AZURE_OPENAI_DEPLOYMENT_NAME is also supported." = "tên triển khai Azure OpenAI. AZURE_OPENAI_DEPLOYMENT_NAME cũng được hỗ trợ."; +"Azure OpenAI key" = "Khóa Azure OpenAI"; +"Azure OpenAI resource endpoint. AZURE_OPENAI_ENDPOINT is also supported." = "Điểm cuối tài nguyên Azure OpenAI. AZURE_OPENAI_ENDPOINT cũng được hỗ trợ."; +"Base URL" = "Base URL"; +"Base URL for the LLM-API-Key-Proxy instance." = "Base URL cho phiên bản LLM- API -Key-Proxy."; +"Browser cookies" = "Cookie trình duyệt"; +"Cap end" = "Cap end"; +"Cap start" = "Cap start"; +"Capacity End" = "Dung lượng End"; +"Capacity Start" = "Dung lượng Start"; +"Changelog" = "Changelog"; +"Choose the Moonshot/Kimi API host for international or China mainland accounts." = "Chọn máy chủ Moonshot/Kimi API cho các tài khoản quốc tế hoặc Trung Quốc đại lục."; +"CodexBar can't replace a system account that is signed in with an API key only setup." = "CodexBar không thể thay thế tài khoản hệ thống được đăng nhập bằng thiết lập chỉ khóa API."; +"CodexBar could not find saved auth for that account. Re-authenticate it and try again." = "CodexBar không thể tìm thấy xác thực đã lưu cho tài khoản đó. Xác thực lại nó và thử lại."; +"CodexBar could not read managed account storage. Recover the store before adding another account." = "CodexBar không thể đọc bộ nhớ tài khoản được quản lý. Khôi phục cửa hàng trước khi thêm tài khoản khác."; +"CodexBar could not read saved auth for that account. Re-authenticate it and try again." = "CodexBar không thể đọc xác thực đã lưu cho tài khoản đó. Xác thực lại nó và thử lại."; +"CodexBar could not read the current system account on this Mac." = "CodexBar không thể đọc tài khoản hệ thống hiện tại trên máy Mac này."; +"CodexBar could not replace the live Codex auth on this Mac." = "CodexBar không thể thay thế xác thực Codex trực tiếp trên máy Mac này."; +"CodexBar could not safely preserve the current system account before switching." = "CodexBar không thể bảo toàn tài khoản hệ thống hiện tại một cách an toàn trước khi chuyển đổi."; +"CodexBar could not save the current system account before switching." = "CodexBar không thể lưu tài khoản hệ thống hiện tại trước khi chuyển đổi."; +"CodexBar could not update managed account storage." = "CodexBar không thể cập nhật bộ nhớ tài khoản được quản lý."; +"CodexBar found another managed account that already uses the current system account. Resolve the duplicate account before switching." = "CodexBar đã tìm thấy một tài khoản được quản lý khác đã sử dụng tài khoản hệ thống hiện tại. Giải quyết tài khoản trùng lặp trước khi chuyển đổi."; +"CodexBar will ask macOS Keychain for “%@” so it can decrypt browser cookies and authenticate your account. Click OK to continue." = "CodexBar sẽ yêu cầu macOS Keychain cho “ %@ ” để nó có thể giải mã cookie của trình duyệt và xác thực tài khoản của bạn. Nhấn OK để tiếp tục."; +"CodexBar will ask macOS Keychain for the Claude Code OAuth token so it can fetch your Claude usage. Click OK to continue." = "CodexBar sẽ yêu cầu macOS Keychain cung cấp Claude Mã OAuth token để nó có thể tìm nạp Claude Mức sử dụng của bạn. Nhấn OK để tiếp tục."; +"CodexBar will ask macOS Keychain for your Amp cookie header so it can fetch usage. Click OK to continue." = "CodexBar sẽ yêu cầu macOS Keychain cho tiêu đề cookie Amp của bạn để nó có thể tìm nạp Mức sử dụng . Nhấn OK để tiếp tục."; +"CodexBar will ask macOS Keychain for your Augment cookie header so it can fetch usage. Click OK to continue." = "CodexBar sẽ yêu cầu macOS Keychain cho tiêu đề cookie Augment của bạn để nó có thể tìm nạp Mức sử dụng . Nhấn OK để tiếp tục."; +"CodexBar will ask macOS Keychain for your Claude cookie header so it can fetch Claude web usage. Click OK to continue." = "CodexBar sẽ yêu cầu macOS Keychain về tiêu đề cookie Claude của bạn để nó có thể tìm nạp Claude web Mức sử dụng . Nhấn OK để tiếp tục."; +"CodexBar will ask macOS Keychain for your Cursor cookie header so it can fetch usage. Click OK to continue." = "CodexBar sẽ yêu cầu macOS Keychain cho tiêu đề cookie Con trỏ của bạn để nó có thể tìm nạp Mức sử dụng . Nhấn OK để tiếp tục."; +"CodexBar will ask macOS Keychain for your Factory cookie header so it can fetch usage. Click OK to continue." = "CodexBar sẽ yêu cầu macOS Keychain cho tiêu đề cookie Factory của bạn để nó có thể tìm nạp Mức sử dụng . Nhấn OK để tiếp tục."; +"CodexBar will ask macOS Keychain for your GitHub Copilot token so it can fetch usage. Click OK to continue." = "CodexBar sẽ yêu cầu macOS Keychain cho GitHub Copilot token của bạn để nó có thể tìm nạp Mức sử dụng . Nhấn OK để tiếp tục."; +"CodexBar will ask macOS Keychain for your Kimi auth token so it can fetch usage. Click OK to continue." = "CodexBar sẽ yêu cầu macOS Keychain cho Kimi auth token của bạn để nó có thể tìm nạp Mức sử dụng . Nhấn OK để tiếp tục."; +"CodexBar will ask macOS Keychain for your MiniMax API token so it can fetch usage. Click OK to continue." = "CodexBar sẽ yêu cầu macOS Keychain cung cấp MiniMax API token của bạn để nó có thể tìm nạp Mức sử dụng . Nhấn OK để tiếp tục."; +"CodexBar will ask macOS Keychain for your MiniMax cookie header so it can fetch usage. Click OK to continue." = "CodexBar sẽ yêu cầu macOS Keychain cho tiêu đề cookie MiniMax của bạn để nó có thể tìm nạp Mức sử dụng . Nhấn OK để tiếp tục."; +"CodexBar will ask macOS Keychain for your OpenAI cookie header so it can fetch Codex dashboard extras. Click OK to continue." = "CodexBar sẽ yêu cầu macOS Keychain cho tiêu đề cookie OpenAI của bạn để nó có thể tìm nạp các tính năng bổ sung của trang tổng quan Codex. Nhấn OK để tiếp tục."; +"CodexBar will ask macOS Keychain for your OpenCode cookie header so it can fetch usage. Click OK to continue." = "CodexBar sẽ yêu cầu macOS Keychain cho tiêu đề cookie OpenCode của bạn để nó có thể tìm nạp Mức sử dụng . Nhấn OK để tiếp tục."; +"CodexBar will ask macOS Keychain for your Synthetic API key so it can fetch usage. Click OK to continue." = "CodexBar sẽ yêu cầu macOS Keychain cung cấp khóa Tổng hợp API của bạn để nó có thể tìm nạp Mức sử dụng . Nhấn OK để tiếp tục."; +"CodexBar will ask macOS Keychain for your z.ai API token so it can fetch usage. Click OK to continue." = "CodexBar sẽ yêu cầu macOS Keychain cung cấp z.ai API token của bạn để nó có thể tìm nạp Mức sử dụng . Nhấn OK để tiếp tục."; +"Could not open Cursor login in your browser." = "Không thể mở đăng nhập Con trỏ trong trình duyệt của bạn."; +"Could not open browser for Antigravity" = "Không thể mở trình duyệt cho AntiGravity"; +"Credits used" = "Tín dụng đã sử dụng"; +"Day" = "Ngày"; +"Deployment" = "Triển khai"; +"Drag to reorder" = "Kéo để sắp xếp lại"; +"Sort providers alphabetically" = "Sắp xếp nhà cung cấp theo bảng chữ cái"; +"Sort providers alphabetically (enabled first)" = "Sắp xếp nhà cung cấp theo bảng chữ cái (đã bật trước)"; +"Sorted alphabetically (enabled first) — click to use your custom order" = "Đã sắp xếp theo bảng chữ cái (đã bật trước) — nhấp để dùng thứ tự tùy chỉnh"; +"Endpoint" = "Điểm cuối"; +"Enterprise host" = "Máy chủ doanh nghiệp"; +"Extra usage balance: %@" = "Extra usage balance: %@"; +"Keychain Access Required" = "Keychain Yêu cầu quyền truy cập"; +"keychain_prompt_learn_more" = "Tìm hiểu thêm…"; +"keychain_prompt_privacy_note" = "macOS, không phải CodexBar, xử lý việc nhập mật khẩu đăng nhập Mac. Bạn có thể tắt mọi quyền truy cập Keychain bất kỳ lúc nào trong Cài đặt → Nâng cao."; +"Kiro menu bar value" = "Kiro thanh menu value"; +"Label" = "Nhãn"; +"No organizations loaded. Click Refresh after setting your API key." = "Chưa có tổ chức nào được tải. Nhấp vào Làm mới sau khi đặt khóa API của bạn."; +"No output captured." = "Không ghi được đầu ra nào."; +"No system account" = "Không có tài khoản hệ thống"; +"Oasis-Token" = "Oasis- token"; +"Open Augment (Log Out & Back In)" = "Mở phần mở rộng (Đăng xuất và quay lại)"; +"Open Codebuff Dashboard" = "Mở bảng điều khiển Codebuff"; +"Open Command Code Settings" = "Mở mã lệnh Cài đặt"; +"Open Crof dashboard" = "Mở bảng điều khiển Crof"; +"Open Manus" = "Mở Manus"; +"Open MiMo Balance" = "Mở MiMo Balance"; +"Open Moonshot Console" = "Mở Bảng điều khiển Moonshot"; +"Open Ollama API Keys" = "Mở Ollama API Phím"; +"Open StepFun Platform" = "Mở Nền tảng StepFun"; +"Open T3 Chat Settings" = "Mở Trò chuyện T3 Cài đặt"; +"Open Volcengine Ark Console" = "Mở Bảng điều khiển Volcengine Ark"; +"Open legacy provider docs" = "Mở di sản Nhà cung cấp docs"; +"Open projects" = "Mở dự án"; +"Open this URL manually to continue login:\n\n%@" = "Open this URL manually to continue login:\n\n%@"; +"Optional organization ID for accounts linked to multiple Anthropic organizations." = "ID tổ chức tùy chọn cho các tài khoản được liên kết với nhiều tổ chức Anthropic."; +"Optional. Applies to the configured Admin API key; selected token accounts do not inherit OPENAI_PROJECT_ID." = "Tùy chọn. Áp dụng cho khóa Quản trị viên API đã định cấu hình; tài khoản token đã chọn không kế thừa OPENAI_PROJECT_ID."; +"Optional. Enter your GitHub Enterprise host, for example octocorp.ghe.com. Leave blank for github.com." = "Tùy chọn. Nhập máy chủ GitHub Enterprise của bạn, ví dụ: octocorp.ghe.com. Để trống cho github.com."; +"Optional. Leave blank to discover and aggregate projects visible to the API key." = "Tùy chọn. Để trống để khám phá và tổng hợp các dự án hiển thị với khóa API."; +"Org ID (optional)" = "ID tổ chức (tùy chọn)"; +"Organizations" = "Tổ chức"; +"Organization ID" = "ID tổ chức"; +"Password" = "Mật khẩu"; +"%@ authentication is disabled." = "%@ xác thực bị tắt. Cookie"; +"%@ cookies are disabled." = "%@ bị tắt. Quyền truy cập"; +"%@ web API access is disabled." = "%@ web API bị vô hiệu hóa."; +"Disable %@ dashboard cookie usage." = "Tắt %@ cookie trang tổng quan Mức sử dụng . Quyền truy cập"; +"Keychain access is disabled in Advanced, so browser cookie import is unavailable." = "Keychain bị vô hiệu hóa trong Nâng cao, do đó, tính năng nhập cookie trình duyệt không khả dụng."; +"Manually paste an %@ from a browser session." = "Dán %@ theo cách thủ công từ phiên trình duyệt."; +"Paste a Cookie header captured from %@." = "Dán tiêu đề Cookie được lấy từ %@ ."; +"Paste a Cookie header from %@." = "Dán tiêu đề Cookie từ %@ ."; +"Paste a Cookie header or cURL capture from %@." = "Dán tiêu đề Cookie hoặc chụp cURL từ %@ ."; +"Paste a Cookie header or full cURL capture from %@." = "Dán tiêu đề Cookie hoặc chụp cURL đầy đủ từ %@ ."; +"Paste a Cookie or Authorization header from %@." = "Dán tiêu đề Cookie hoặc Ủy quyền từ %@ ."; +"Paste a full cookie header or the %@ value." = "Dán tiêu đề cookie đầy đủ hoặc giá trị %@."; +"Paste a Cookie header or full cURL capture from T3 Chat settings." = "Dán tiêu đề Cookie hoặc chụp cURL đầy đủ từ Trò chuyện T3 Cài đặt ."; +"Paste the Cookie header from a request to admin.mistral.ai. Must contain an ory_session_* cookie." = "Dán tiêu đề Cookie từ yêu cầu tới admin.mistral.ai. Phải chứa cookie ory_session_*."; +"Paste the Oasis-Token from a logged-in browser session on platform.stepfun.com." = "Dán Oasis- token từ phiên trình duyệt đã đăng nhập trên platform.stepfun.com."; +"Paste the %@ JSON bundle from %@." = "Dán gói %@ JSON từ %@ ."; +"Paste the %@ value or a full Cookie header." = "Dán giá trị %@ hoặc tiêu đề Cookie đầy đủ."; +"Personal account" = "Tài khoản cá nhân"; +"Project ID" = "ID dự án"; +"Re-auth" = "Xác thực lại"; +"Re-login at claude.ai" = "Đăng nhập lại vào claude.ai"; +"Re-authenticating…" = "Xác thực lại…"; +"Refresh Session" = "Làm mới phiên"; +"Refresh organizations" = "Làm mới tổ chức"; +"Region" = "Khu vực"; +"Reload" = "Tải lại"; +"Reorder" = "Sắp xếp lại"; +"Secret access key" = "Khóa truy cập bí mật"; +"Series" = "Chuỗi"; +"Service" = "Dịch vụ"; +"Show or hide Kiro credits, percent, or both next to the menu bar icon." = "Hiển thị hoặc ẩn tín dụng Kiro, phần trăm hoặc cả hai bên cạnh biểu tượng thanh menu."; +"Show usage for organizations you belong to. Personal account is always shown." = "Hiển thị Mức sử dụng cho các tổ chức mà bạn là thành viên. Tài khoản cá nhân luôn được hiển thị."; +"Sign in to cursor.com in your browser, then refresh Cursor in CodexBar." = "Đăng nhập vào con trỏ.com trong trình duyệt của bạn, sau đó làm mới Con trỏ trong CodexBar ."; +"Simulated error text" = "Văn bản lỗi mô phỏng"; +"StepFun platform account (phone number or email)." = "Tài khoản nền tảng StepFun (số điện thoại hoặc email)."; +"Stored in ~/.codexbar/config.json." = "Được lưu trữ trong ~/.codexbar/config.json."; +"Stored in ~/.codexbar/config.json. AZURE_OPENAI_API_KEY is also supported." = "Được lưu trữ trong ~/.codexbar/config.json. AZURE_OPENAI_API_KEY cũng được hỗ trợ."; +"Stored in ~/.codexbar/config.json. For the official Kimi API, use Moonshot / Kimi API." = "Được lưu trữ trong ~/.codexbar/config.json. Đối với Kimi API chính thức, hãy sử dụng Moonshot / Kimi API ."; +"Stored in ~/.codexbar/config.json. Get your API key from the Volcengine Ark console." = "Được lưu trữ trong ~/.codexbar/config.json. Nhận khóa API của bạn từ bảng điều khiển Volcengine Ark."; +"Stored in ~/.codexbar/config.json. Get your key from Ollama settings." = "Được lưu trữ trong ~/.codexbar/config.json. Nhận chìa khóa của bạn từ Ollama Cài đặt ."; +"Stored in ~/.codexbar/config.json. Get your key from console.deepgram.com." = "Được lưu trữ trong ~/.codexbar/config.json. Nhận chìa khóa của bạn từ console.deepgram.com."; +"Stored in ~/.codexbar/config.json. Get your key from elevenlabs.io/app/settings/api-keys." = "Được lưu trữ trong ~/.codexbar/config.json. Nhận khóa của bạn từ Elevenlabs.io/app/ Cài đặt /api-keys."; +"Stored in ~/.codexbar/config.json. Get your key from openrouter.ai/settings/keys and set a key spending limit there to enable API key quota tracking." = "Được lưu trữ trong ~/.codexbar/config.json. Nhận khóa của bạn từ openrouter.ai/ Cài đặt /keys và đặt giới hạn chi tiêu cho khóa ở đó để cho phép theo dõi API khóa Hạn mức."; +"Stored in ~/.codexbar/config.json. In Warp, open Settings > Platform > API Keys, then create one." = "Được lưu trữ trong ~/.codexbar/config.json. Trong Warp, hãy mở Khóa Cài đặt > Nền tảng > API, sau đó tạo một khóa."; +"Stored in ~/.codexbar/config.json. Metrics require Groq Enterprise Prometheus access." = "Được lưu trữ trong ~/.codexbar/config.json. Các số liệu yêu cầu quyền truy cập Groq Enterprise Prometheus."; +"Stored in ~/.codexbar/config.json. OPENAI_ADMIN_KEY is preferred; OPENAI_API_KEY still works." = "Được lưu trữ trong ~/.codexbar/config.json. OPENAI_ADMIN_KEY được ưu tiên; OPENAI_API_KEY vẫn hoạt động."; +"Stored in ~/.codexbar/config.json. Requires an Anthropic Admin API key." = "Được lưu trữ trong ~/.codexbar/config.json. Yêu cầu khóa Anthropic Quản trị viên API."; +"Stored in ~/.codexbar/config.json. Used for /v1/quota-stats." = "Được lưu trữ trong ~/.codexbar/config.json. Được sử dụng cho /v1/ Hạn mức -stats."; +"Stored in ~/.codexbar/config.json. You can also provide CODEBUFF_API_KEY or let CodexBar read ~/.config/manicode/credentials.json (created by `codebuff login`)." = "Được lưu trữ trong ~/.codexbar/config.json. Bạn cũng có thể cung cấp CODEBUFF_API_KEY hoặc để CodexBar đọc ~/.config/manicode/credentials.json (được tạo bởi `codebuff login`)."; +"Stored in ~/.codexbar/config.json. You can also provide CROF_API_KEY." = "Được lưu trữ trong ~/.codexbar/config.json. Bạn cũng có thể cung cấp CROF_API_KEY."; +"Stored in ~/.codexbar/config.json. You can also provide KILO_API_KEY or ~/.local/share/kilo/auth.json (kilo.access)." = "Được lưu trữ trong ~/.codexbar/config.json. Bạn cũng có thể cung cấp KILO_API_KEY hoặc ~/.local/share/kilo/auth.json (kilo.access)."; +"T3 Chat cookie" = "Cookie trò chuyện T3"; +"Team mode" = "Chế độ nhóm"; +"That account is no longer available in CodexBar. Refresh the account list and try again." = "Tài khoản đó không còn khả dụng trong CodexBar . Hãy làm mới danh sách tài khoản và thử lại."; +"The browser login did not complete in time. Try Antigravity login again." = "Quá trình đăng nhập trình duyệt không hoàn tất kịp thời. Hãy thử đăng nhập lại bằng AntiGravity."; +"Timed out waiting for Cursor login. %@" = "Đã hết thời gian chờ đăng nhập Con trỏ. %@"; +"Timed out waiting for Cursor login. %@ Last error: %@" = "Đã hết thời gian chờ đăng nhập Con trỏ. %@ Lỗi cuối cùng: %@"; +"Today requests" = "Hôm nay yêu cầu"; +"Total (30d): %@ credits" = "Tổng cộng (30d): %@ tín dụng"; +"Username" = "Tên người dùng"; +"Uses username + password to login and obtain an Oasis-Token automatically." = "Sử dụng tên người dùng + mật khẩu để đăng nhập và nhận Oasis- token một cách tự động."; +"Uses username + password to login and obtain an %@ automatically." = "Sử dụng tên người dùng + mật khẩu để đăng nhập và tự động nhận được %@."; +"Utilization End" = "Kết thúc sử dụng"; +"Utilization Start" = "Bắt đầu sử dụng"; +"Verbosity" = "Độ chi tiết"; +"Windsurf session JSON bundle" = "Phiên lướt ván buồm JSON gói"; +"Workspace ID" = "ID không gian làm việc"; +"Your StepFun platform password. Used to login and obtain a session token." = "Mật khẩu nền tảng StepFun của bạn. Được sử dụng để đăng nhập và nhận phiên token ."; +"claude /login exited with status %d." = "claude /đăng nhập đã thoát với trạng thái %d ."; +"codex login exited with status %d." = "đăng nhập codex đã thoát với trạng thái %d ."; +"Cookie: …\n\nor paste a cURL capture from the Abacus AI dashboard" = "Cookie: …\n\nhoặc dán bản chụp cURL từ bảng thông tin Abacus AI"; +"Cookie: …\n\nor paste the __Secure-next-auth.session-token value" = "Cookie: …\n\nhoặc dán giá trị __Secure-next-auth.session- token"; +"Cookie: …\n\nor paste the kimi-auth token value" = "Cookie: …\n\nhoặc dán giá trị kimi-auth token"; +"session_id=...\n\nor paste just the session_id value" = "session_id=...\n\nhoặc chỉ dán giá trị session_id"; +"Clear" = "Xóa"; +"No matching providers" = "Không có nhà cung cấp phù hợp"; +"Search providers" = "Tìm kiếm nhà cung cấp"; + +"language_vietnamese" = "Tiếng Việt"; +"language_turkish" = "Türkçe"; +"language_indonesian" = "Tiếng Indonesia"; +"language_polish" = "Tiếng Ba Lan"; +"language_arabic" = "العربية"; +"language_persian" = "فارسی"; +"language_thai" = "ไทย"; +"language_galician" = "Galego"; +"Limit Reset Credits" = "Lượt đặt lại giới hạn"; +"1 available" = "1 lượt"; +"%d available" = "%d lượt"; +"Next expires %@" = "Lượt tiếp theo hết hạn %@"; +"Expires %@" = "Hết hạn %@"; +"No expiry" = "Không hết hạn"; +"Other (%d items)" = "Khác (%d mục)"; +"Expand" = "Mở rộng"; +"Collapse" = "Thu gọn"; +"byte_unit_byte" = "byte"; +"byte_unit_bytes" = "byte"; +"byte_unit_kilobyte" = "kilobyte"; +"byte_unit_kilobytes" = "kilobyte"; +"byte_unit_megabyte" = "megabyte"; +"byte_unit_megabytes" = "megabyte"; +"byte_unit_gigabyte" = "gigabyte"; +"byte_unit_gigabytes" = "gigabyte"; + +/* Settings sidebar redesign */ +"Enable" = "Bật"; +"Disable" = "Tắt"; +"providers_on_count" = "%d đang bật"; +"section_cost_summary" = "Tóm tắt chi phí"; +"section_command_line" = "Dòng lệnh"; +"section_privacy" = "Quyền riêng tư"; +"section_diagnostics" = "Chẩn đoán"; +"section_updates" = "Cập nhật"; +"section_links" = "Liên kết"; +"Show Codex Spark usage" = "Hiển thị mức sử dụng Codex Spark"; +"Shows Codex Spark quota rows in the menu and provider preview. Requires optional credits and extra usage in Display settings." = "Hiển thị các hàng hạn mức Codex Spark trong menu và bản xem trước của nhà cung cấp. Yêu cầu bật “Hiển thị tín dụng + mức sử dụng bổ sung” trong phần cài đặt Hiển thị."; +"Show Daily Routines usage" = "Hiển thị mức sử dụng Quy trình hàng ngày"; +"Shows the Daily Routines quota row in the menu and provider preview. Requires optional credits and extra usage in Display settings." = "Hiển thị hàng hạn mức Quy trình hàng ngày trong menu và bản xem trước của nhà cung cấp. Yêu cầu bật “Hiển thị tín dụng + mức sử dụng bổ sung” trong phần cài đặt Hiển thị."; +"Scroll to see more models" = "Cuộn để xem thêm mô hình"; +"Copy Image" = "Sao chép hình ảnh"; +"Copy Stats" = "Sao chép số liệu"; +"Could not copy image" = "Không thể sao chép hình ảnh"; +"Image copied" = "Đã sao chép hình ảnh"; +"Image saved" = "Đã lưu hình ảnh"; +"Nothing is uploaded. This image is created on your Mac." = "Không có dữ liệu nào được tải lên. Hình ảnh này được tạo trên máy Mac của bạn."; +"Save..." = "Lưu..."; +"Share AI Usage" = "Chia sẻ mức sử dụng AI"; +"Share Stats…" = "Chia sẻ số liệu…"; +"Stats copied" = "Đã sao chép số liệu"; +"DeepSeek this month token usage trend" = "Xu hướng sử dụng token DeepSeek trong tháng này"; +"Chrome profile" = "Hồ sơ Chrome"; +"Choose which signed-in DeepSeek Platform session supplies detailed usage." = "Chọn phiên DeepSeek Platform đã đăng nhập để cung cấp thông tin sử dụng chi tiết."; +"Detailed usage unavailable." = "Không có thông tin sử dụng chi tiết."; +"Sign in to DeepSeek Platform in Chrome for detailed usage." = "Đăng nhập vào DeepSeek Platform trong Chrome để xem thông tin sử dụng chi tiết."; +"Select a DeepSeek Chrome profile in Settings." = "Chọn một hồ sơ Chrome cho DeepSeek trong Cài đặt."; +"Select profile…" = "Chọn hồ sơ…"; + +"%@: %@" = "%@: %@"; +"Alternatively, set a custom path in Settings." = "Ngoài ra, hãy đặt đường dẫn tùy chỉnh trong Cài đặt."; +"Choose a supported browser so CodexBar can read the matching account." = "Chọn một trình duyệt được hỗ trợ để CodexBar có thể đọc tài khoản phù hợp."; +"Choose Cursor account" = "Chọn tài khoản Cursor"; +"Choose which Cursor account CodexBar should use." = "Chọn tài khoản Cursor mà CodexBar nên sử dụng."; +"Finish switching to a different Cursor account in your browser, then try again." = "Hoàn tất việc chuyển sang tài khoản Cursor khác trong trình duyệt, sau đó thử lại."; +"Install a JetBrains IDE with AI Assistant enabled, then refresh CodexBar." = "Cài đặt JetBrains IDE có bật AI Assistant, sau đó làm mới CodexBar."; +"Request quota: %@ / %@" = "Hạn mức yêu cầu: %@ / %@"; +"Sign in with Claude Code..." = "Đăng nhập bằng Claude Code..."; +"Timed out waiting for Cursor account switch. %@" = "Đã hết thời gian chờ chuyển đổi tài khoản Cursor. %@"; +"Timed out waiting for Cursor account switch. %@ Last error: %@" = "Đã hết thời gian chờ chuyển đổi tài khoản Cursor. %@ Lỗi gần nhất: %@"; +"Use Account" = "Sử dụng tài khoản"; +/* Spend dashboard */ +"tab_usage_spend" = "Sử dụng & Chi tiêu"; +"Usage & Spend" = "Sử dụng & Chi tiêu"; +"Local estimated cost history across supported providers." = "Lịch sử chi phí ước tính cục bộ trên các nhà cung cấp được hỗ trợ."; +"Time range" = "Khoảng thời gian"; +"Track costs" = "Theo dõi chi phí"; +"Cost tracking is off" = "Đang tắt tính năng theo dõi chi phí"; +"Turn on Track costs to build local estimates." = "Bật “Theo dõi chi phí” để tạo ước tính cục bộ."; +"No local cost history yet" = "Chưa có lịch sử chi phí cục bộ"; +"Turn on cost tracking or refresh after using a supported provider." = "Bật tính năng theo dõi chi phí hoặc làm mới sau khi sử dụng nhà cung cấp được hỗ trợ."; +"Refresh failures" = "Lần làm mới thất bại"; +"Native currencies stay separate; Codex account rows exclude Pi session history." = "Đơn vị tiền tệ gốc được giữ riêng biệt; các hàng tài khoản Codex không bao gồm lịch sử phiên Pi."; +"Spend unavailable" = "Không có dữ liệu chi tiêu"; +"Model breakdown unavailable" = "Phân tích theo mô hình không khả dụng"; +"Local estimated history" = "Lịch sử ước tính cục bộ"; +"Coverage" = "Phạm vi"; +"Estimated spend" = "Chi tiêu ước tính"; +"Tracked tokens" = "Token được theo dõi"; +"Subscriptions" = "Gói đăng ký"; +"By subscription" = "Theo gói đăng ký"; +"No model-level history" = "Không có lịch sử theo mô hình"; +"Daily estimated spend" = "Chi tiêu ước tính hằng ngày"; +"≈%d full 5h windows of weekly left · %d windows until reset" = "Còn ≈%d cửa sổ 5 giờ đầy đủ của hạn mức tuần · %d cửa sổ đến khi đặt lại"; +"Weekly cannot run out before reset at this pace" = "Với tốc độ này, hạn mức tuần không thể hết trước khi đặt lại"; +"Weekly can run out ≈%d windows early" = "Hạn mức tuần có thể hết sớm ≈%d cửa sổ"; +"Estimated: %@" = "Ước tính: %@"; +"session_quota_estimate_value_format" = "%1$@ %2$@"; +"session quota" = "hạn mức phiên"; +"session quotas" = "hạn mức phiên"; +"Coding Plan" = "Gói lập trình"; +"Agent Plan" = "Gói tác nhân"; +"Team" = "Nhóm"; + +/* Menu bar layout editor */ +"menu_bar_layout_title" = "Bố cục"; +"menu_bar_layout_footer" = "Kéo các thẻ để sắp xếp thanh menu. Bấm vào thẻ để thêm; chọn thẻ đã đặt rồi nhấn Delete để xóa."; +"menu_bar_layout_group_identity" = "Danh tính"; +"menu_bar_layout_group_usage" = "Mức sử dụng"; +"menu_bar_layout_group_time" = "Thời gian"; +"menu_bar_layout_group_money" = "Chi phí"; +"menu_bar_layout_group_structure" = "Cấu trúc"; +"menu_bar_layout_scope_all" = "Tất cả nhà cung cấp"; +"menu_bar_layout_scope_help" = "Chỉnh sửa bố cục mặc định hoặc ghi đè cho một nhà cung cấp."; +"menu_bar_layout_use_all" = "Dùng bố cục của mọi nhà cung cấp"; +"menu_bar_layout_preset" = "Mẫu bố cục"; +"menu_bar_layout_preset_icon_percent" = "Biểu tượng và phần trăm"; +"menu_bar_layout_preset_icon_only" = "Chỉ biểu tượng"; +"menu_bar_layout_preset_percent_reset" = "Phần trăm và đặt lại"; +"menu_bar_layout_preset_compact_stacked" = "Xếp chồng gọn"; +"menu_bar_layout_preset_custom" = "Tùy chỉnh"; +"menu_bar_layout_live_preview" = "Xem trước trực tiếp"; +"menu_bar_layout_strip" = "Dải thanh menu"; +"menu_bar_layout_remove_line_break" = "Xóa ngắt dòng"; +"menu_bar_layout_chip_hint" = "Chọn, kéo để sắp xếp lại hoặc dùng thao tác Xóa."; +"menu_bar_layout_palette_hint" = "Bấm để thêm hoặc kéo vào bố cục."; +"menu_bar_layout_empty_line" = "Thả một thẻ vào đây"; +"menu_bar_layout_line" = "Dòng %d"; +"menu_bar_layout_drag_remove" = "Kéo vào đây để xóa"; +"menu_bar_layout_size" = "Kích thước"; +"menu_bar_layout_size_small" = "Nhỏ"; +"menu_bar_layout_size_regular" = "Thường"; +"menu_bar_layout_gap" = "Khoảng cách"; +"menu_bar_layout_gap_tight" = "Hẹp"; +"menu_bar_layout_gap_regular" = "Thường"; +"menu_bar_layout_keyboard_hint" = "Delete xóa thẻ đã chọn"; +"menu_bar_layout_sample_account" = "tài khoản"; +"menu_bar_layout_sample_runs_out" = "hết vào T6"; +"menu_bar_layout_token_icon" = "Biểu tượng"; +"menu_bar_layout_token_provider" = "Tên nhà cung cấp"; +"menu_bar_layout_token_account" = "Tài khoản"; +"menu_bar_layout_token_session" = "Phiên %"; +"menu_bar_layout_token_weekly" = "Hàng tuần %"; +"menu_bar_layout_token_auto" = "% tự động"; +"menu_bar_layout_token_bar" = "Thanh sử dụng"; +"menu_bar_layout_token_resets_in" = "Đặt lại sau"; +"menu_bar_layout_token_reset_at" = "Đặt lại lúc"; +"menu_bar_layout_token_runs_out" = "Sắp hết"; +"menu_bar_layout_token_cost_today" = "Chi phí hôm nay"; +"menu_bar_layout_token_cost_30d" = "Chi phí 30 ngày"; +"menu_bar_layout_token_space" = "Khoảng trắng"; +"menu_bar_layout_token_line_break" = "Ngắt dòng"; +"menu_bar_layout_token_separator_accessibility" = "Dấu chấm phân cách"; + +/* Menu bar layout accessibility */ +"Icon unavailable" = "Biểu tượng: Không có sẵn"; +"%@ icon" = "%@: Biểu tượng"; +"Provider name unavailable" = "Tên nhà cung cấp: Không có sẵn"; +"Account unavailable" = "Tài khoản: Không có sẵn"; +"%@ unavailable" = "%@: Không có sẵn"; +"%@ %@" = "%@ %@"; +"Usage bar unavailable" = "Thanh sử dụng: Không có sẵn"; +"Usage bar, %d of 3 filled" = "Thanh sử dụng: %d/3 đã tô"; +"Reset countdown unavailable" = "Đặt lại sau: Không có sẵn"; +"Reset time unavailable" = "Đặt lại lúc: Không có sẵn"; +"Run-out estimate unavailable" = "Sắp hết: Không có sẵn"; +"Cost today unavailable" = "Chi phí hôm nay: Không có sẵn"; +"30-day cost unavailable" = "Chi phí 30 ngày: Không có sẵn"; +"Resets" = "Lần đặt lại"; diff --git a/Sources/CodexBar/Resources/vi.lproj/Localizable.stringsdict b/Sources/CodexBar/Resources/vi.lproj/Localizable.stringsdict new file mode 100644 index 0000000000..f3290de2e2 --- /dev/null +++ b/Sources/CodexBar/Resources/vi.lproj/Localizable.stringsdict @@ -0,0 +1,49 @@ + + + + + ≈%d full 5h windows of weekly left · %d windows until reset + + NSStringLocalizedFormatKey + %#@left@ · %#@until@ + left + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + d + one + Còn ≈%d cửa sổ 5 giờ đầy đủ của hạn mức tuần + other + Còn ≈%d cửa sổ 5 giờ đầy đủ của hạn mức tuần + + until + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + d + one + %d cửa sổ đến khi đặt lại + other + %d cửa sổ đến khi đặt lại + + + Weekly can run out ≈%d windows early + + NSStringLocalizedFormatKey + %#@early@ + early + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + d + one + Hạn mức tuần có thể hết sớm ≈%d cửa sổ + other + Hạn mức tuần có thể hết sớm ≈%d cửa sổ + + + + diff --git a/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings b/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings index c0319f3292..487de0f042 100644 --- a/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings @@ -1,8 +1,33 @@ /* Chinese (Simplified) localization for CodexBar */ +"tab_hooks" = "钩子"; +"hooks_enable_title" = "启用钩子"; +"hooks_enable_subtitle" = "在配额或提供商事件发生时运行外部命令。"; +"hooks_trust_warning" = "钩子可以在 Mac 上执行本地命令。请仅配置你信任的命令。"; +"hooks_rules_header" = "规则"; +"hooks_empty" = "未配置钩子。"; +"hooks_add_rule" = "添加规则"; +"hooks_delete_rule" = "删除规则"; +"hooks_rule_enabled" = "已启用"; +"hooks_event" = "事件"; +"hooks_provider" = "提供商"; +"hooks_any_provider" = "任意提供商"; +"hooks_threshold" = "使用率 ≥ 时运行"; +"hooks_threshold_placeholder" = "90"; +"hooks_executable_placeholder" = "/usr/local/bin/my-command"; +"hooks_arguments_placeholder" = "参数"; +"hooks_argument_placeholder" = "参数"; +"hooks_add_argument" = "添加参数"; +"hooks_delete_argument" = "删除参数"; + +"ollama_safari_cookie_access_hint" = "CodexBar 需要“完全磁盘访问权限”才能读取 Safari Cookie(系统设置 > 隐私与安全性)。"; +"ollama_browser_cookie_decryption_denied" = "钥匙串拒绝解密 %@ Cookie;请通过手动刷新重试。"; +"ollama_browser_cookie_decryption_disabled" = "CodexBar 中已停用 %@ Cookie 解密;请启用钥匙串访问权限并刷新。"; + " providers" = " 提供商"; "(System)" = "(System)"; "30d" = "30 天"; +"7d" = "7 天"; "A managed Codex login is already running. Wait for it to finish before adding " = "托管 Codex 登录已在运行。请等待其完成后再添加 "; "API key" = "API 密钥"; "API key limit" = "API 密钥限制"; @@ -101,6 +126,8 @@ "Could not start codex login" = "无法启动 codex login"; "Could not switch system account" = "无法切换系统账户"; "Credits" = "额度"; +"Individual credits" = "个人额度"; +"Workspace" = "工作区"; "Credits history" = "额度记录"; "Cursor login failed" = "Cursor 登录失败"; "Custom" = "自定义"; @@ -237,6 +264,7 @@ "Picker subtitle" = "选择器副标题"; "Placeholder" = "占位符"; "Plan" = "套餐"; +"Plan Usage" = "套餐用量"; "Play full-screen confetti when weekly usage resets." = "当每周用量重置时播放全屏彩纸。"; "Polls OpenAI/Claude status pages and Google Workspace for " = "轮询 OpenAI/Claude 状态页面和 Google Workspace,以检查"; "Prevents any Keychain access while enabled." = "启用时阻止任何钥匙串访问。"; @@ -272,7 +300,8 @@ "Session" = "会话"; "Session quota notifications" = "会话配额通知"; "Session tokens" = "会话令牌"; -"Settings" = "设置"; +"provider_section_connection" = "连接"; +"provider_section_menu_bar" = "菜单栏"; "Show Codex Credits and Claude Extra usage sections in the menu." = "在菜单中显示 Codex 额度和 Claude 额外用量部分。"; "Show Debug Settings" = "显示调试设置"; "Show all token accounts" = "显示所有令牌账户"; @@ -302,18 +331,18 @@ "Store multiple OpenCode Go Cookie headers." = "存储多个 OpenCode Go Cookie 标头。"; "Stored in the CodexBar config file." = "存储在 CodexBar 配置文件中。"; "Stored in ~/.codexbar/config.json. " = "存储在 ~/.codexbar/config.json 中。"; -"Stored in ~/.codexbar/config.json. Generate one at kimi-k2.ai." = "存储在 ~/.codexbar/config.json 中。可在 kimi-k2.ai 生成。"; "Stored in ~/.codexbar/config.json. Paste the key from the Synthetic dashboard." = "存储在 ~/.codexbar/config.json 中。请粘贴来自 Synthetic 仪表盘的密钥。"; "Stored in ~/.codexbar/config.json. Paste your Coding Plan API key from Model Studio." = "存储在 ~/.codexbar/config.json 中。请粘贴来自 Model Studio 的 Coding Plan API 密钥。"; "Stored in ~/.codexbar/config.json. Paste your MiniMax API key." = "存储在 ~/.codexbar/config.json 中。请粘贴你的 MiniMax API 密钥。"; "Stored in ~/.codexbar/config.json. You can also provide KILO_API_KEY or " = "存储在 ~/.codexbar/config.json 中。你也可以提供 KILO_API_KEY 或"; "Stores local Codex usage history (8 weeks) to personalize Pace predictions." = "存储本地 Codex 用量历史(8 周),用于个性化进度预测。"; -"Subscription Utilization" = "订阅使用率"; "Surprise me" = "给我惊喜"; "Switcher shows icons" = "切换器显示图标"; "Symlink CodexBarCLI to /usr/local/bin and /opt/homebrew/bin as codexbar." = "将 CodexBarCLI 作为 codexbar 符号链接到 /usr/local/bin 和 /opt/homebrew/bin。"; "System" = "系统"; "Temporarily shows the loading animation after the next refresh." = "下次刷新后临时显示加载动画。"; +"terminal_app_subtitle" = "“打开终端”操作使用的终端"; +"terminal_app_title" = "默认终端"; "Tertiary (\\(label))" = "第三(\\(label))"; "Tertiary (\\(tertiaryTitle))" = "第三(\\(tertiaryTitle))"; "The default Codex account on this Mac." = "此 Mac 上的默认 Codex 账户。"; @@ -396,9 +425,19 @@ "© 2026 Peter Steinberger. MIT License." = "© 2026 Peter Steinberger。MIT 许可证。"; "section_system" = "系统"; "section_usage" = "用量"; -"section_automation" = "自动化"; +"section_refreshing" = "刷新"; +"section_alerts" = "提醒"; +"section_celebrations" = "庆祝"; +"section_icon" = "图标"; +"section_combined_icon" = "合并图标"; +"section_animation" = "动画"; +"section_content" = "内容"; +"section_agent_sessions" = "智能体会话"; "language_title" = "语言"; "language_subtitle" = "更改显示语言。需要重启应用才能完全生效。"; +"currency_title" = "首选货币"; +"currency_subtitle" = "用于费用估算和支出指标的货币。使用每日更新的实时汇率。"; +"currency_auto" = "自动(跟随提供商 / USD)"; "language_system" = "跟随系统"; "language_english" = "English"; "language_spanish" = "Español"; @@ -406,21 +445,42 @@ "language_chinese_simplified" = "简体中文"; "language_chinese_traditional" = "繁體中文"; "language_portuguese_brazilian" = "Português (Brasil)"; +"language_swedish" = "瑞典语"; +"language_dutch" = "Nederlands"; +"language_german" = "Deutsch"; +"language_french" = "法语"; +"language_ukrainian" = "乌克兰语"; +"language_russian" = "Русский"; +"language_japanese" = "日语"; +"language_korean" = "韩语"; +"language_italian" = "Italiano"; +"language_polish" = "Polski"; "start_at_login_title" = "开机启动"; "start_at_login_subtitle" = "启动 Mac 时自动打开 CodexBar。"; -"show_cost_summary" = "显示费用摘要"; "show_cost_summary_subtitle" = "读取本地用量日志。在菜单中显示今天及所选历史窗口的费用。"; +"cost_summary_style_title" = "显示样式"; +"cost_summary_style_inline" = "仅内联"; +"cost_summary_style_submenu" = "仅子菜单"; +"cost_summary_style_both" = "两者"; +"cost_summary_style_inline_help" = "直接在主菜单中显示费用摘要。"; +"cost_summary_style_submenu_help" = "改为显示详细的费用子菜单。"; +"cost_summary_style_both_help" = "同时显示主菜单摘要和详细的费用子菜单。"; +"cost_history_window_title" = "历史窗口"; +"cost_history_window_help" = "设置菜单中显示多少天的本地使用日志。"; "cost_history_days_title" = "历史窗口:%d 天"; -"cost_auto_refresh_info" = "自动刷新:每小时 · 超时:10 分钟"; -"refresh_cadence_title" = "刷新频率"; -"refresh_cadence_subtitle" = "CodexBar 在后台轮询提供商的频率。"; +"cost_auto_refresh_info" = "自动刷新:全局间隔(最短 5 分钟)· 超时:10 分钟"; +"cost_comparison_periods_title" = "显示更短的对比周期"; +"cost_comparison_periods_subtitle" = "当 7 天、30 天和 90 天处于所选历史窗口内时,添加相应汇总。这些汇总复用同一次本地扫描。"; +"refresh_interval_title" = "刷新间隔"; "manual_refresh_hint" = "自动刷新已关闭;请使用菜单中的“刷新”命令。"; +"refresh_on_open_title" = "打开菜单时刷新"; +"refresh_on_open_subtitle" = "每次打开菜单时获取每个提供商的最新用量。"; "check_provider_status_title" = "检查提供商状态"; "check_provider_status_subtitle" = "轮询 OpenAI/Claude 状态页面和 Google Workspace 的 Gemini/Antigravity,在图标和菜单中显示故障信息。"; -"session_quota_notifications_title" = "会话配额通知"; "session_quota_notifications_subtitle" = "当 5 小时会话配额用完及恢复时发送通知。"; -"quota_warning_notifications_title" = "配额预警通知"; +"quota_depleted_title" = "配额耗尽与恢复"; "quota_warning_notifications_subtitle" = "当会话或每周剩余配额低于设置的阈值时提醒。"; +"threshold_warnings_title" = "阈值预警"; "quota_warnings_title" = "配额预警"; "quota_warning_session" = "会话"; "quota_warning_session_capitalized" = "会话"; @@ -429,20 +489,28 @@ "quota_warning_warn_at" = "预警阈值"; "quota_warning_global_threshold_subtitle" = "会话和每周窗口的剩余百分比,除非提供商单独覆盖。"; "quota_warning_sound" = "播放通知声音"; +"quota_warning_onscreen_alert" = "显示屏幕文字提醒"; "quota_warning_provider_inherits" = "默认使用全局配额预警设置,除非在这里自定义窗口。"; +"quota_warning_provider_disabled" = "配额预警通知和用量条标记均已关闭。启用其中任一项即可编辑这些已保存的设置。"; +"quota_warning_provider_markers_only" = "配额预警通知已全局关闭。这些设置仍会控制用量条标记。"; +"quota_warning_global" = "全局"; "quota_warning_customize_thresholds" = "自定义 %@ 阈值"; "quota_warning_enable_warnings" = "启用 %@ 预警"; "quota_warning_window_warn_at" = "%@ 预警阈值"; "quota_warning_off" = "关闭"; "quota_warning_inherited" = "继承:%@"; "quota_warning_depleted_only" = "仅耗尽时"; -"quota_warning_upper" = "上限"; +"quota_warning_upper" = "较高"; "quota_warning_lower" = "下限"; +"quota_warning_warning" = "警告"; +"quota_warning_critical" = "严重"; "apply" = "应用"; "quit_app" = "退出 CodexBar"; "tab_general" = "通用"; "tab_providers" = "提供商"; -"tab_display" = "显示"; +"tab_notifications" = "通知"; +"tab_menu_bar" = "菜单栏"; +"tab_menu" = "菜单"; "tab_advanced" = "高级"; "tab_about" = "关于"; "tab_debug" = "调试"; @@ -464,33 +532,40 @@ "menu_bar_metric_subtitle_deepseek" = "在菜单栏显示 DeepSeek 余额。"; "menu_bar_metric_subtitle_moonshot" = "在菜单栏显示 Moonshot / Kimi API 余额。"; "menu_bar_metric_subtitle_mistral" = "在菜单栏显示 Mistral API 本月支出。"; -"menu_bar_metric_subtitle_kimik2" = "在菜单栏显示 Kimi K2 API 密钥额度。"; "automatic" = "自动"; "primary_api_key_limit" = "主要(API 密钥限制)"; -"section_menu_bar" = "菜单栏"; +"menu_bar_style_title" = "菜单栏样式"; +"menu_bar_style_subtitle" = "菜单栏项目的绘制方式。"; +"menu_bar_inactive_display_contrast_title" = "提高非活跃显示器上的可见性"; +"menu_bar_usage_colors_title" = "用量颜色标识"; +"menu_bar_usage_colors_subtitle" = "随着用量上升,菜单栏图标由绿变红。"; +"menu_bar_inactive_display_contrast_subtitle" = "使用高对比度绘制,让其他显示器上的图标和指标仍清晰可读。"; +"menu_bar_style_critters" = "小动物"; +"menu_bar_style_bars" = "进度条"; +"menu_bar_style_icon_percent" = "图标和百分比"; +"switcher_rows_title" = "切换器行"; +"switcher_rows_icons" = "提供商图标"; +"switcher_rows_progress" = "每周进度"; +"usage_bars_fill_title" = "用量条填充方式"; +"usage_bars_fill_remaining" = "按剩余量"; +"usage_bars_fill_used" = "按已用量"; +"reset_times_title" = "重置时间"; +"reset_times_countdown" = "倒计时"; +"reset_times_clock" = "时钟时间"; +"cost_summary_title" = "费用摘要"; +"cost_summary_off" = "关闭"; "merge_icons_title" = "合并图标"; "merge_icons_subtitle" = "使用单个菜单栏图标并带提供商切换器。"; -"switcher_shows_icons_title" = "切换器显示图标"; -"switcher_shows_icons_subtitle" = "在切换器中显示提供商图标(否则显示每周进度线)。"; "show_most_used_provider_title" = "显示用量最高的提供商"; "show_most_used_provider_subtitle" = "菜单栏会自动显示最接近速率限制的提供商。"; -"menu_bar_shows_percent_title" = "菜单栏显示百分比"; -"menu_bar_shows_percent_subtitle" = "将小动物进度条替换为提供商品牌图标和百分比。"; "display_mode_title" = "显示模式"; "display_mode_subtitle" = "选择菜单栏中显示的内容(进度会显示实际用量与预期的对比)。"; -"section_menu_content" = "菜单内容"; -"show_usage_as_used_title" = "显示已使用用量"; -"show_usage_as_used_subtitle" = "进度条会随配额消耗而填充(而不是显示剩余量)。"; "show_quota_warning_markers_title" = "显示配额预警标记"; "show_quota_warning_markers_subtitle" = "配置配额预警后,在用量条上绘制阈值刻度标记。"; -"show_reset_time_as_clock_title" = "将重置时间显示为时钟"; -"show_reset_time_as_clock_subtitle" = "将重置时间显示为绝对时钟值,而不是倒计时。"; "show_provider_changelog_links_title" = "显示提供商变更日志链接"; "show_provider_changelog_links_subtitle" = "在菜单中为支持的 CLI 提供商添加发布说明链接。"; "show_credits_extra_usage_title" = "显示额度 + 额外用量"; "show_credits_extra_usage_subtitle" = "在菜单中显示 Codex 额度和 Claude 额外用量部分。"; -"show_all_token_accounts_title" = "显示所有令牌账户"; -"show_all_token_accounts_subtitle" = "在菜单中堆叠令牌账户(否则显示账户切换栏)。"; "multi_account_layout_title" = "多账户布局"; "multi_account_layout_subtitle" = "选择分段账户切换或堆叠账户卡片。"; "multi_account_layout_segmented" = "分段"; @@ -501,6 +576,16 @@ "overview_no_providers_hint" = "“概览”中没有可用的已启用提供商。"; "overview_rows_follow_order" = "概览行始终遵循提供商顺序。"; "overview_no_providers_selected" = "未选择提供商"; +"agent_sessions_title" = "智能体会话"; +"agent_sessions_subtitle" = "在菜单中显示本地及通过 SSH 发现的 Codex 和 Claude Code 会话。"; +"agent_sessions_hosts_title" = "其他 SSH 主机"; +"agent_sessions_footer" = "系统会自动发现 tailnet 上的 Mac。本地会话每 30 秒刷新一次;远程主机每 60 秒以及打开菜单时刷新。"; +"agent_session_labels_title" = "会话标签"; +"agent_session_labels_subtitle" = "选择智能体会话的命名方式。"; +"agent_session_label_project" = "项目"; +"agent_session_label_descriptive" = "描述性"; +"agent_session_label_descriptive_and_project" = "描述性 + 项目"; +"agent_session_unknown_project" = "未知项目"; "section_keyboard_shortcut" = "快捷键"; "open_menu_shortcut_title" = "打开菜单"; "open_menu_shortcut_subtitle" = "从任意位置触发菜单栏菜单。"; @@ -512,8 +597,6 @@ "show_debug_settings_subtitle" = "在“调试”标签中显示故障排除工具。"; "surprise_me_title" = "给我惊喜"; "surprise_me_subtitle" = "看看你是否喜欢你的智能体在上面找点乐子。"; -"weekly_limit_confetti_title" = "每周限制彩纸"; -"weekly_limit_confetti_subtitle" = "当每周用量重置时播放全屏彩纸。"; "hide_personal_info_title" = "隐藏个人信息"; "hide_personal_info_subtitle" = "在菜单栏和菜单界面中隐藏电子邮件地址。"; "show_provider_storage_usage_title" = "显示提供商存储用量"; @@ -593,13 +676,20 @@ "metric_pref_tertiary" = "第三"; "metric_pref_extra_usage" = "额外用量"; "metric_pref_average" = "平均"; +"metric_mistral_payg" = "Pay-as-you-go"; +"metric_mistral_monthly_plan" = "Monthly Plan"; "display_mode_percent" = "百分比"; "display_mode_pace" = "进度"; "display_mode_both" = "两者"; +"display_mode_reset_time" = "重置时间"; "display_mode_percent_desc" = "显示剩余/已使用百分比(例如 45%)"; "display_mode_pace_desc" = "显示进度指示器(例如 +5%)"; "display_mode_both_desc" = "同时显示百分比和进度(例如 45% · +5%)"; +"display_mode_reset_time_desc" = "显示所选指标的重置时间(例如 ↻ 3:56 PM)"; +"menu_bar_reset_when_exhausted_title" = "配额用尽时显示重置时间"; +"menu_bar_reset_when_exhausted_subtitle" = "剩余 0% 时,显示距重置的时间而非百分比"; "status_operational" = "正常运行"; +"status_degraded" = "性能下降"; "status_partial_outage" = "部分中断"; "status_major_outage" = "重大中断"; "status_critical_issue" = "严重问题"; @@ -611,13 +701,20 @@ "refresh_5min" = "5 分钟"; "refresh_15min" = "15 分钟"; "refresh_30min" = "30 分钟"; +"refresh_adaptive" = "自适应"; +"refresh_adaptive_agent_aware" = "自适应(感知智能体活动)"; +"adaptive_activity_consent_title" = "允许根据活动自适应刷新?"; +"adaptive_activity_consent_message" = "感知智能体活动的自适应模式可以检查本地运行中的进程列表(包括命令行)来识别 Codex 和 Claude,并在你编写代码时每 30 秒读取一次已知的会话元数据。关闭 Agent Sessions 时,CodexBar 仅在内存中使用最近一次活动时间,并丢弃会话路径和身份信息。此数据不会发送到任何地方,远程探测和 SSH 保持关闭。如果拒绝,CodexBar 将返回不扫描本地活动的普通自适应模式。"; +"adaptive_activity_consent_allow" = "允许本地活动"; +"adaptive_activity_consent_decline" = "使用普通自适应模式"; "not_found" = "未找到"; "CodexBar can't show its menu bar icon" = "CodexBar 无法显示菜单栏图标"; "Dismiss" = "关闭"; "Open Menu Bar Settings" = "打开菜单栏设置"; "macOS Tahoe can block menu bar apps in System Settings → Menu Bar → Allow in the Menu Bar. CodexBar is running, but macOS may be hiding its icon. Open Menu Bar settings and turn CodexBar on." = "macOS Tahoe 可能会在“系统设置”→“菜单栏”→“允许显示在菜单栏”中阻止菜单栏应用。CodexBar 正在运行,但 macOS 可能隐藏了它的图标。请打开菜单栏设置并启用 CodexBar。"; -"cost_header_estimated" = "费用(估算)"; "cost_estimate_hint" = "根据本地日志估算 · 可能与账单不同"; +"codex_api_estimate_hint" = "根据 Token 用量估算 · 不是订阅账单"; +"cost_data_explanation" = "费用可能由提供商报告,也可能根据 Token 用量按公开 API 价格估算。估算值不是订阅费用。"; "Estimated from local Codex logs for the selected account." = "根据所选账户的本地 Codex 日志估算。"; "No JetBrains IDE with AI Assistant detected. Install a JetBrains IDE and enable AI Assistant." = "未检测到启用 AI Assistant 的 JetBrains IDE。请安装 JetBrains IDE 并启用 AI Assistant。"; "OpenRouter API token not configured. Set OPENROUTER_API_KEY environment variable or configure in Settings." = "未配置 OpenRouter API 令牌。请设置 OPENROUTER_API_KEY 环境变量,或在“设置”中配置。"; @@ -649,6 +746,7 @@ "Today" = "今日"; "Today tokens" = "今日 token 用量"; "30d cost" = "近 30 天费用"; +"%@ cost" = "%@费用"; "30d tokens" = "近 30 天 token 用量"; "Latest tokens" = "最近 token 用量"; "Top model" = "最常用模型"; @@ -656,6 +754,7 @@ "Add Account..." = "添加账户…"; "Usage Dashboard" = "用量仪表盘"; "Status Page" = "状态页"; +"Open Status Page" = "打开状态页"; "Settings..." = "设置…"; "About CodexBar" = "关于 CodexBar"; "Quit" = "退出"; @@ -668,8 +767,12 @@ "Resets %@" = "重置于 %@"; "Resets in %@" = "%@后重置"; "Resets now" = "立即重置"; +"reset_tomorrow_format" = "明天 %@"; "Lasts until reset" = "持续到重置"; +"1.5× headroom" = "1.5 倍余量"; "Updated %@" = "更新于 %@"; +"Updated relative %@" = "%@已更新"; +"Updated absolute %@" = "更新于 %@"; "Updated %@h ago" = "%@ 小时前更新"; "Updated %@m ago" = "%@ 分钟前更新"; "Updated just now" = "刚刚更新"; @@ -687,7 +790,7 @@ "This week" = "本周"; "Week" = "本周"; "Month" = "本月"; -"Models" = "模型数"; +"Models" = "模型"; "24h tokens" = "24 小时 token 用量"; "Latest hour" = "最近 1 小时"; "Peak hour" = "峰值小时"; @@ -737,6 +840,8 @@ /* Popup panels */ "No usage configured." = "尚未配置用量。"; "Quota" = "配额"; +"Daily quota" = "每日配额"; +"Total" = "总计"; "tokens" = "token"; "requests" = "请求"; "Latest" = "最新"; @@ -770,6 +875,7 @@ "API spend" = "API 支出"; "Extra usage" = "额外用量"; "Quota usage" = "配额用量"; +"Your spend" = "您的支出"; "%.0f%% used" = "已使用 %.0f%%"; "Usage history (today)" = "用量记录(今天)"; "Usage history (%d days)" = "用量记录(%d 天)"; @@ -788,7 +894,7 @@ "Hourly Usage" = "每小时用量"; "Usage remaining" = "剩余用量"; "Usage used" = "已使用用量"; -"API key verified. Ollama does not expose Cloud quota limits through the API." = "API 密钥已验证。Ollama 不会通过 API 暴露 Cloud 配额限制。"; +"API key verified. Cloud quotas need browser cookies. Sign in to Ollama." = "API 密钥已验证。Cloud 配额需要浏览器 Cookie。请登录 Ollama。"; "Last 30 days: %@ tokens" = "近 30 天:%@ token"; "7d spend" = "7 天支出"; "30d spend" = "30 天支出"; @@ -835,7 +941,7 @@ "Cleanup ideas" = "清理建议"; "%d unreadable item(s) skipped" = "已跳过 %d 个不可读项目"; "weekly_progress_work_days_title" = "工作日刻度线"; -"weekly_progress_work_days_subtitle" = "在每周用量条上显示按天分隔的刻度线。"; +"weekly_progress_work_days_subtitle" = "设置用于每周用量条刻度和进度计算的工作日。"; "copilot_device_code" = "设备代码已复制到剪贴板:%1$@\n\n请在以下地址验证:%2$@"; "copilot_waiting_text" = "请在浏览器中完成登录。\n登录完成后,此窗口会自动关闭。"; "vertex_ai_login_instructions" = "要跟踪 Vertex AI 用量,请通过 Google Cloud 进行认证。\n\n1. 打开终端\n2. 运行:gcloud auth application-default login\n3. 按照浏览器提示登录\n4. 设置你的项目:gcloud config set project PROJECT_ID\n\n是否现在打开终端?"; @@ -859,6 +965,17 @@ "quota_warning_notification_title" = "%1$@ 的 %2$@ 额度偏低"; "quota_warning_notification_body" = "剩余 %1$@。已达到 %2$d%% 的 %3$@ 预警阈值。"; "quota_warning_notification_body_with_account" = "账户 %1$@。剩余 %2$@。已达到 %3$d%% 的 %4$@ 预警阈值。"; +"predictive_pace_warnings_title" = "预测性节奏预警"; +"predictive_pace_warnings_subtitle" = "当 Codex 和 Claude 的会话或每周使用节奏可能在重置前耗尽配额时提醒。"; +"confetti_on_reset_title" = "重置时播放彩带"; +"confetti_on_reset_subtitle" = "用量重置时播放全屏彩带。"; +"confetti_option_off" = "关闭"; +"confetti_option_session" = "会话重置"; +"confetti_option_weekly" = "每周重置"; +"confetti_option_both" = "两者"; +"predictive_pace_warning_notification_title" = "%1$@ %2$@节奏预警"; +"predictive_pace_warning_notification_body" = "按当前节奏,此配额可能会在重置前于 %1$@ 后耗尽。"; +"predictive_pace_warning_notification_body_with_account" = "账户 %1$@。按当前节奏,此配额可能会在重置前于 %2$@ 后耗尽。"; /* Additional provider settings and alerts */ "%@ is waiting for permission" = "%@ 正在等待权限"; @@ -878,7 +995,7 @@ "Antigravity login failed" = "Antigravity 登录失败"; "Antigravity login timed out" = "Antigravity 登录超时"; "Auth source" = "认证来源"; -"Automatic imports Chrome browser cookies from Xiaomi MiMo." = "自动导入 Xiaomi MiMo 的 Chrome 浏览器 Cookie。"; +"Automatic imports browser cookies from Xiaomi MiMo." = "自动导入 Xiaomi MiMo 的浏览器 Cookie。"; "Automatic imports Windsurf session data from Chromium browser localStorage." = "自动从 Chromium 浏览器 localStorage 导入 Windsurf 会话数据。"; "Automatic imports browser cookies from Bailian." = "自动导入 Bailian 的浏览器 Cookie。"; "Automatically imports browser cookies." = "自动导入浏览器 Cookie。"; @@ -913,7 +1030,6 @@ "CodexBar will ask macOS Keychain for your Cursor cookie header so it can fetch usage. Click OK to continue." = "CodexBar 将向 macOS 钥匙串请求你的 Cursor Cookie 标头,以获取用量。点击“确定”继续。"; "CodexBar will ask macOS Keychain for your Factory cookie header so it can fetch usage. Click OK to continue." = "CodexBar 将向 macOS 钥匙串请求你的 Factory Cookie 标头,以获取用量。点击“确定”继续。"; "CodexBar will ask macOS Keychain for your GitHub Copilot token so it can fetch usage. Click OK to continue." = "CodexBar 将向 macOS 钥匙串请求你的 GitHub Copilot token,以获取用量。点击“确定”继续。"; -"CodexBar will ask macOS Keychain for your Kimi K2 API key so it can fetch usage. Click OK to continue." = "CodexBar 将向 macOS 钥匙串请求你的 Kimi K2 API 密钥,以获取用量。点击“确定”继续。"; "CodexBar will ask macOS Keychain for your Kimi auth token so it can fetch usage. Click OK to continue." = "CodexBar 将向 macOS 钥匙串请求你的 Kimi 认证 token,以获取用量。点击“确定”继续。"; "CodexBar will ask macOS Keychain for your MiniMax API token so it can fetch usage. Click OK to continue." = "CodexBar 将向 macOS 钥匙串请求你的 MiniMax API token,以获取用量。点击“确定”继续。"; "CodexBar will ask macOS Keychain for your MiniMax cookie header so it can fetch usage. Click OK to continue." = "CodexBar 将向 macOS 钥匙串请求你的 MiniMax Cookie 标头,以获取用量。点击“确定”继续。"; @@ -927,10 +1043,15 @@ "Day" = "日期"; "Deployment" = "部署"; "Drag to reorder" = "拖动以重新排序"; +"Sort providers alphabetically" = "按字母顺序排列提供商"; +"Sort providers alphabetically (enabled first)" = "按字母顺序排列提供商(已启用的优先)"; +"Sorted alphabetically (enabled first) — click to use your custom order" = "已按字母顺序排列(已启用的优先)— 点击使用自定义顺序"; "Endpoint" = "端点"; "Enterprise host" = "Enterprise 主机"; "Extra usage balance: %@" = "额外使用量余额:%@"; "Keychain Access Required" = "需要钥匙串访问权限"; +"keychain_prompt_learn_more" = "了解更多…"; +"keychain_prompt_privacy_note" = "Mac 登录密码由 macOS(而非 CodexBar)处理。你可以随时在“设置”→“高级”中停用所有钥匙串访问。"; "Kiro menu bar value" = "Kiro 菜单栏数值"; "Label" = "标签"; "No organizations loaded. Click Refresh after setting your API key." = "尚未加载组织。设置 API 密钥后点击“刷新”。"; @@ -957,6 +1078,7 @@ "Optional. Leave blank to discover and aggregate projects visible to the API key." = "选填。留空会发现并汇总 API 密钥可见的项目。"; "Org ID (optional)" = "组织 ID(选填)"; "Organizations" = "组织"; +"Organization ID" = "组织 ID"; "Password" = "密码"; "%@ authentication is disabled." = "%@ 认证已禁用。"; "%@ cookies are disabled." = "%@ Cookie 已禁用。"; @@ -978,6 +1100,7 @@ "Personal account" = "个人账号"; "Project ID" = "项目 ID"; "Re-auth" = "重新认证"; +"Re-login at claude.ai" = "重新登录 claude.ai"; "Re-authenticating…" = "正在重新认证…"; "Refresh Session" = "刷新会话"; "Refresh organizations" = "刷新组织"; @@ -1009,6 +1132,7 @@ "Stored in ~/.codexbar/config.json. You can also provide CROF_API_KEY." = "存储在 ~/.codexbar/config.json 中。你也可以提供 CROF_API_KEY。"; "Stored in ~/.codexbar/config.json. You can also provide KILO_API_KEY or ~/.local/share/kilo/auth.json (kilo.access)." = "存储在 ~/.codexbar/config.json 中。你也可以提供 KILO_API_KEY 或 ~/.local/share/kilo/auth.json(kilo.access)。"; "T3 Chat cookie" = "T3 Chat Cookie"; +"Team mode" = "团队模式"; "That account is no longer available in CodexBar. Refresh the account list and try again." = "该账号已无法在 CodexBar 中使用。请刷新账号列表后再试。"; "The browser login did not complete in time. Try Antigravity login again." = "浏览器登录未在时限内完成。请再次尝试 Antigravity 登录。"; "Timed out waiting for Cursor login. %@" = "等待 Cursor 登录超时。%@"; @@ -1030,3 +1154,176 @@ "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" = "搜索提供商"; + +"language_vietnamese" = "越南语"; + +"Request quota: %@ / %@" = "请求额度:%@ / %@"; +"language_turkish" = "Türkçe"; +"language_indonesian" = "印度尼西亚语"; +"language_arabic" = "العربية"; +"language_persian" = "فارسی"; +"language_thai" = "ไทย"; +"language_galician" = "Galego"; +"Limit Reset Credits" = "限额重置额度"; +"1 available" = "1 次可用"; +"%d available" = "%d 次可用"; +"Next expires %@" = "下一个将于 %@ 到期"; +"Expires %@" = "%@ 到期"; +"No expiry" = "永不过期"; +"Other (%d items)" = "其他(%d 项)"; +"Expand" = "展开"; +"Collapse" = "收起"; +"byte_unit_byte" = "字节"; +"byte_unit_bytes" = "字节"; +"byte_unit_kilobyte" = "千字节"; +"byte_unit_kilobytes" = "千字节"; +"byte_unit_megabyte" = "兆字节"; +"byte_unit_megabytes" = "兆字节"; +"byte_unit_gigabyte" = "吉字节"; +"byte_unit_gigabytes" = "吉字节"; + +/* Settings sidebar redesign */ +"Enable" = "启用"; +"Disable" = "停用"; +"providers_on_count" = "%d 个已开启"; +"section_cost_summary" = "费用摘要"; +"section_command_line" = "命令行"; +"section_privacy" = "隐私"; +"section_diagnostics" = "诊断"; +"section_updates" = "更新"; +"section_links" = "链接"; +"Show Codex Spark usage" = "显示 Codex Spark 用量"; +"Shows Codex Spark quota rows in the menu and provider preview. Requires optional credits and extra usage in Display settings." = "在菜单和提供商预览中显示 Codex Spark 配额行。需要在“显示”设置中启用“显示额度 + 额外用量”。"; +"Show Daily Routines usage" = "显示日常任务用量"; +"Shows the Daily Routines quota row in the menu and provider preview. Requires optional credits and extra usage in Display settings." = "在菜单和提供商预览中显示日常任务配额行。需要在“显示”设置中启用“显示额度 + 额外用量”。"; +"Scroll to see more models" = "滚动查看更多模型"; +"Copy Image" = "复制图像"; +"Copy Stats" = "复制统计数据"; +"Could not copy image" = "无法复制图像"; +"Image copied" = "图像已复制"; +"Image saved" = "图像已保存"; +"Nothing is uploaded. This image is created on your Mac." = "不会上传任何内容。此图像在你的 Mac 上生成。"; +"Save..." = "存储..."; +"Share AI Usage" = "分享 AI 使用情况"; +"Share Stats…" = "分享统计数据…"; +"Stats copied" = "统计数据已复制"; +"DeepSeek this month token usage trend" = "DeepSeek 本月 token 用量趋势"; +"Chrome profile" = "Chrome 配置文件"; +"Choose which signed-in DeepSeek Platform session supplies detailed usage." = "选择要提供详细用量的已登录 DeepSeek Platform 会话。"; +"Detailed usage unavailable." = "详细用量不可用。"; +"Sign in to DeepSeek Platform in Chrome for detailed usage." = "请在 Chrome 中登录 DeepSeek Platform 以查看详细用量。"; +"Select a DeepSeek Chrome profile in Settings." = "请在“设置”中选择 DeepSeek Chrome 配置文件。"; +"Select profile…" = "选择配置文件…"; + +"%@: %@" = "%@:%@"; +"Alternatively, set a custom path in Settings." = "或者在“设置”中指定自定义路径。"; +"Choose a supported browser so CodexBar can read the matching account." = "选择一个受支持的浏览器,以便 CodexBar 读取对应账户。"; +"Choose Cursor account" = "选择 Cursor 账户"; +"Choose which Cursor account CodexBar should use." = "选择 CodexBar 应使用的 Cursor 账户。"; +"Finish switching to a different Cursor account in your browser, then try again." = "在浏览器中完成切换到其他 Cursor 账户,然后重试。"; +"Install a JetBrains IDE with AI Assistant enabled, then refresh CodexBar." = "安装并启用 JetBrains IDE 的 AI Assistant,然后刷新 CodexBar。"; +"Sign in with Claude Code..." = "使用 Claude Code 登录..."; +"Timed out waiting for Cursor account switch. %@" = "等待切换 Cursor 账户超时。%@"; +"Timed out waiting for Cursor account switch. %@ Last error: %@" = "等待切换 Cursor 账户超时。%@ 最近错误:%@"; +"Use Account" = "使用此账户"; +/* Spend dashboard */ +"tab_usage_spend" = "用量与支出"; +"Usage & Spend" = "用量与支出"; +"Local estimated cost history across supported providers." = "所有受支持提供商的本地估算费用历史。"; +"Time range" = "时间范围"; +"Track costs" = "跟踪费用"; +"Cost tracking is off" = "费用跟踪已关闭"; +"Turn on Track costs to build local estimates." = "启用“跟踪费用”以生成本地估算。"; +"No local cost history yet" = "暂无本地费用历史"; +"Turn on cost tracking or refresh after using a supported provider." = "启用费用跟踪,或在使用受支持的提供商后刷新。"; +"Refresh failures" = "刷新失败"; +"Native currencies stay separate; Codex account rows exclude Pi session history." = "各原始币种保持分开;Codex 帐户行不包含 Pi 会话历史。"; +"Spend unavailable" = "支出数据不可用"; +"Model breakdown unavailable" = "模型明细不可用"; +"Local estimated history" = "本地估算历史"; +"Coverage" = "覆盖范围"; +"Estimated spend" = "估算支出"; +"Tracked tokens" = "已跟踪 token"; +"Subscriptions" = "订阅"; +"By subscription" = "按订阅"; +"No model-level history" = "暂无模型级历史"; +"Daily estimated spend" = "每日估算支出"; +"≈%d full 5h windows of weekly left · %d windows until reset" = "每周额度约剩 %d 个完整 5 小时窗口 · 距重置还有 %d 个窗口"; +"Weekly cannot run out before reset at this pace" = "按此速度,每周额度无法在重置前用完"; +"Weekly can run out ≈%d windows early" = "每周额度可能提前约 %d 个窗口用完"; +"Estimated: %@" = "估算:%@"; +"session_quota_estimate_value_format" = "%1$@%2$@"; +"session quota" = "会话额度"; +"session quotas" = "会话额度"; +"Coding Plan" = "编程套餐"; +"Agent Plan" = "智能体套餐"; +"Team" = "团队"; + +/* Menu bar layout editor */ +"menu_bar_layout_title" = "布局"; +"menu_bar_layout_footer" = "拖动项目以排列菜单栏。点按项目可追加;选择已放置的项目并按 Delete 键可将其移除。"; +"menu_bar_layout_group_identity" = "身份"; +"menu_bar_layout_group_usage" = "用量"; +"menu_bar_layout_group_time" = "时间"; +"menu_bar_layout_group_money" = "费用"; +"menu_bar_layout_group_structure" = "结构"; +"menu_bar_layout_scope_all" = "所有提供商"; +"menu_bar_layout_scope_help" = "编辑默认布局,或为单个提供商设置覆盖布局。"; +"menu_bar_layout_use_all" = "使用所有提供商布局"; +"menu_bar_layout_preset" = "布局预设"; +"menu_bar_layout_preset_icon_percent" = "图标与百分比"; +"menu_bar_layout_preset_icon_only" = "仅图标"; +"menu_bar_layout_preset_percent_reset" = "百分比与重置"; +"menu_bar_layout_preset_compact_stacked" = "紧凑堆叠"; +"menu_bar_layout_preset_custom" = "自定义"; +"menu_bar_layout_live_preview" = "实时预览"; +"menu_bar_layout_strip" = "菜单栏条带"; +"menu_bar_layout_remove_line_break" = "移除换行"; +"menu_bar_layout_chip_hint" = "选择、拖动以重新排序,或使用移除操作。"; +"menu_bar_layout_palette_hint" = "点按以追加,或拖入布局。"; +"menu_bar_layout_empty_line" = "将项目拖放到此处"; +"menu_bar_layout_line" = "第 %d 行"; +"menu_bar_layout_drag_remove" = "拖到此处以移除"; +"menu_bar_layout_size" = "大小"; +"menu_bar_layout_size_small" = "小"; +"menu_bar_layout_size_regular" = "常规"; +"menu_bar_layout_gap" = "间距"; +"menu_bar_layout_gap_tight" = "紧凑"; +"menu_bar_layout_gap_regular" = "常规"; +"menu_bar_layout_keyboard_hint" = "Delete 键会移除所选项目"; +"menu_bar_layout_sample_account" = "帐户"; +"menu_bar_layout_sample_runs_out" = "周五用尽"; +"menu_bar_layout_token_icon" = "图标"; +"menu_bar_layout_token_provider" = "提供商名称"; +"menu_bar_layout_token_account" = "账户"; +"menu_bar_layout_token_session" = "会话 %"; +"menu_bar_layout_token_weekly" = "每周 %"; +"menu_bar_layout_token_auto" = "自动 %"; +"menu_bar_layout_token_bar" = "用量条"; +"menu_bar_layout_token_resets_in" = "重置倒计时"; +"menu_bar_layout_token_reset_at" = "重置时间"; +"menu_bar_layout_token_runs_out" = "预计用尽"; +"menu_bar_layout_token_cost_today" = "今日费用"; +"menu_bar_layout_token_cost_30d" = "30 天费用"; +"menu_bar_layout_token_space" = "空格"; +"menu_bar_layout_token_line_break" = "换行"; +"menu_bar_layout_token_separator_accessibility" = "分隔点"; + +/* Menu bar layout accessibility */ +"Icon unavailable" = "图标: 不可用"; +"%@ icon" = "%@: 图标"; +"Provider name unavailable" = "提供商名称: 不可用"; +"Account unavailable" = "账户: 不可用"; +"%@ unavailable" = "%@: 不可用"; +"%@ %@" = "%@ %@"; +"Usage bar unavailable" = "用量条: 不可用"; +"Usage bar, %d of 3 filled" = "用量条: %d/3 已填充"; +"Reset countdown unavailable" = "重置倒计时: 不可用"; +"Reset time unavailable" = "重置时间: 不可用"; +"Run-out estimate unavailable" = "预计用尽: 不可用"; +"Cost today unavailable" = "今日费用: 不可用"; +"30-day cost unavailable" = "30 天费用: 不可用"; +"Resets" = "重置"; diff --git a/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.stringsdict b/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.stringsdict new file mode 100644 index 0000000000..ad20059002 --- /dev/null +++ b/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.stringsdict @@ -0,0 +1,49 @@ + + + + + ≈%d full 5h windows of weekly left · %d windows until reset + + NSStringLocalizedFormatKey + %#@left@ · %#@until@ + left + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + d + one + 每周额度约剩 %d 个完整 5 小时窗口 + other + 每周额度约剩 %d 个完整 5 小时窗口 + + until + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + d + one + 距重置还有 %d 个窗口 + other + 距重置还有 %d 个窗口 + + + Weekly can run out ≈%d windows early + + NSStringLocalizedFormatKey + %#@early@ + early + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + d + one + 每周额度可能提前约 %d 个窗口用完 + other + 每周额度可能提前约 %d 个窗口用完 + + + + diff --git a/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings b/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings index f87b1583d8..d900b8893a 100644 --- a/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings @@ -1,8 +1,33 @@ /* Chinese (Traditional) localization for CodexBar */ +"tab_hooks" = "掛鉤"; +"hooks_enable_title" = "啟用掛鉤"; +"hooks_enable_subtitle" = "在配額或提供者事件發生時執行外部指令。"; +"hooks_trust_warning" = "掛鉤可以在 Mac 上執行本機指令。請只設定你信任的指令。"; +"hooks_rules_header" = "規則"; +"hooks_empty" = "尚未設定掛鉤。"; +"hooks_add_rule" = "新增規則"; +"hooks_delete_rule" = "刪除規則"; +"hooks_rule_enabled" = "已啟用"; +"hooks_event" = "事件"; +"hooks_provider" = "提供者"; +"hooks_any_provider" = "任何提供者"; +"hooks_threshold" = "使用率 ≥ 時執行"; +"hooks_threshold_placeholder" = "90"; +"hooks_executable_placeholder" = "/usr/local/bin/my-command"; +"hooks_arguments_placeholder" = "引數"; +"hooks_argument_placeholder" = "引數"; +"hooks_add_argument" = "新增引數"; +"hooks_delete_argument" = "刪除引數"; + +"ollama_safari_cookie_access_hint" = "CodexBar 需要「完整磁碟存取權」才能讀取 Safari Cookie(系統設定 > 隱私權與安全性)。"; +"ollama_browser_cookie_decryption_denied" = "鑰匙圈拒絕解密 %@ Cookie;請透過手動重新整理再試一次。"; +"ollama_browser_cookie_decryption_disabled" = "CodexBar 中已停用 %@ Cookie 解密;請啟用鑰匙圈存取權並重新整理。"; + " providers" = " 提供者"; -"(System)" = "(System)"; +"(System)" = "(系統)"; "30d" = "30 天"; +"7d" = "7 天"; "A managed Codex login is already running. Wait for it to finish before adding " = "託管 Codex 登入已在執行。請等待其完成後再新增 "; "API key" = "API 金鑰"; "API key limit" = "API 金鑰限制"; @@ -22,6 +47,7 @@ "Animation pattern" = "動畫模式"; "Antigravity login is managed in the app" = "Antigravity 登入由 App 管理"; "Applies only to the Security.framework OAuth keychain reader." = "僅適用於 Security.framework OAuth 鑰匙圈讀取器。"; +"Alternatively, set a custom path in Settings." = "或是在「設定」中指定自訂路徑。"; "Auth" = "認證"; "Auto" = "自動"; "Auto falls back to the next source if the preferred one fails." = "如果偏好的來源失敗,自動改用下一個來源。"; @@ -65,9 +91,9 @@ "Choose up to " = "選擇最多 "; "Choose up to \\(Self.maxOverviewProviders) providers" = "選擇最多 \\(Self.maxOverviewProviders) 個提供者"; "Choose up to \\(count) providers" = "選擇最多 \\(count) 個提供者"; -"Choose what to show in the menu bar (Pace shows usage vs. expected)." = "選擇選單列中顯示的內容(進度會比較實際與預期使用量)。"; +"Choose what to show in the menu bar (Pace shows usage vs. expected)." = "選擇選單列要顯示的內容(進度會比較目前用量和時間進度)。"; "Choose which Codex account CodexBar should follow." = "選擇 CodexBar 要追蹤的 Codex 帳號。"; -"Choose which window drives the menu bar percent." = "選擇用於驅動選單列百分比的時段。"; +"Choose which window drives the menu bar percent." = "選擇選單列百分比要依據哪個時段。"; "Chrome" = "Chrome"; "Claude CLI not found" = "找不到 Claude CLI"; "Claude binary" = "Claude 二進位檔案"; @@ -101,6 +127,8 @@ "Could not start codex login" = "無法啟動 codex login"; "Could not switch system account" = "無法切換系統帳號"; "Credits" = "額度"; +"Individual credits" = "個人額度"; +"Workspace" = "工作區"; "Credits history" = "額度歷史"; "Cursor login failed" = "Cursor 登入失敗"; "Custom" = "自訂"; @@ -148,6 +176,7 @@ "Install the Claude CLI (npm i -g @anthropic-ai/claude-code) and try again." = "安裝 Claude CLI(npm i -g @anthropic-ai/claude-code)後重試。"; "Install the Codex CLI (npm i -g @openai/codex) and try again." = "安裝 Codex CLI(npm i -g @openai/codex)後重試。"; "Install the Gemini CLI (npm i -g @google/gemini-cli) and try again." = "安裝 Gemini CLI(npm i -g @google/gemini-cli)後重試。"; +"Install a JetBrains IDE with AI Assistant enabled, then refresh CodexBar." = "請安裝已啟用 AI Assistant 的 JetBrains IDE,然後重新整理 CodexBar。"; "JetBrains AI is ready" = "JetBrains AI 已就緒"; "JetBrains IDE" = "JetBrains IDE"; "Keep CLI sessions alive" = "保持 CLI 工作階段存活"; @@ -220,7 +249,7 @@ "OpenAI web extras" = "OpenAI Web 附加功能"; "Option A" = "選項 A"; "Option B" = "選項 B"; -"Optional override if workspace lookup fails." = "找不到工作區時可選的覆寫值。"; +"Optional override if workspace lookup fails." = "找不到工作區時,可手動指定。"; "Options" = "選項"; "Override auto-detection with a custom IDE base path" = "使用自訂 IDE 基礎路徑覆蓋自動偵測"; "Overview" = "概覽"; @@ -237,6 +266,7 @@ "Picker subtitle" = "選擇器副標題"; "Placeholder" = "預留位置"; "Plan" = "方案"; +"Plan Usage" = "方案用量"; "Play full-screen confetti when weekly usage resets." = "每週使用量重置時播放全螢幕慶祝動畫。"; "Polls OpenAI/Claude status pages and Google Workspace for " = "輪詢 OpenAI/Claude 狀態頁面和 Google Workspace,以檢查"; "Prevents any Keychain access while enabled." = "啟用時封鎖任何鑰匙圈存取。"; @@ -272,7 +302,8 @@ "Session" = "工作階段"; "Session quota notifications" = "工作階段配額通知"; "Session tokens" = "工作階段 token"; -"Settings" = "設定"; +"provider_section_connection" = "連線"; +"provider_section_menu_bar" = "選單列"; "Show Codex Credits and Claude Extra usage sections in the menu." = "在選單中顯示 Codex 額度和 Claude 額外使用量部分。"; "Show Debug Settings" = "顯示除錯設定"; "Show all token accounts" = "顯示所有 token 帳號"; @@ -283,6 +314,7 @@ "Show provider icons in the switcher (otherwise show a weekly progress line)." = "在切換器中顯示提供者圖示(否則顯示每週進度線)。"; "Show reset time as clock" = "以時鐘時間顯示重置時間"; "Show usage as used" = "以已用量顯示"; +"Sign in with Claude Code..." = "使用 Claude Code 登入…"; "Sign in via button below" = "透過下方按鈕登入"; "Skip teardown between probes (debug-only)." = "探測之間跳過清理(僅限除錯)。"; "Source" = "來源"; @@ -302,13 +334,11 @@ "Store multiple OpenCode Go Cookie headers." = "儲存多個 OpenCode Go Cookie 標頭。"; "Stored in the CodexBar config file." = "儲存在 CodexBar 設定檔中。"; "Stored in ~/.codexbar/config.json. " = "儲存在 ~/.codexbar/config.json 中。"; -"Stored in ~/.codexbar/config.json. Generate one at kimi-k2.ai." = "儲存在 ~/.codexbar/config.json 中。可在 kimi-k2.ai 產生。"; "Stored in ~/.codexbar/config.json. Paste the key from the Synthetic dashboard." = "儲存在 ~/.codexbar/config.json 中。請貼上來自 Synthetic 儀表板的金鑰。"; "Stored in ~/.codexbar/config.json. Paste your Coding Plan API key from Model Studio." = "儲存在 ~/.codexbar/config.json 中。請貼上來自 Model Studio 的 Coding Plan API 金鑰。"; "Stored in ~/.codexbar/config.json. Paste your MiniMax API key." = "儲存在 ~/.codexbar/config.json 中。請貼上你的 MiniMax API 金鑰。"; "Stored in ~/.codexbar/config.json. You can also provide KILO_API_KEY or " = "儲存在 ~/.codexbar/config.json 中。你也可以提供 KILO_API_KEY 或"; "Stores local Codex usage history (8 weeks) to personalize Pace predictions." = "儲存本機 Codex 使用量歷史(8 週),用於個人化進度預測。"; -"Subscription Utilization" = "訂閱使用率"; "Surprise me" = "給我驚喜"; "Switcher shows icons" = "切換器顯示圖示"; "Symlink CodexBarCLI to /usr/local/bin and /opt/homebrew/bin as codexbar." = "將 CodexBarCLI 作為 codexbar 符號連結到 /usr/local/bin 和 /opt/homebrew/bin。"; @@ -363,7 +393,7 @@ "available again." = "恢復可用時傳送通知。"; "built_format" = "建置於 %@"; "copilot_complete_in_browser" = "請在瀏覽器中完成登入。"; -"copilot_device_code_copied" = "裝置代碼已複製。"; +"copilot_device_code_copied" = "裝置碼已複製。"; "copilot_verify_at" = "請在 %@ 驗證"; "copilot_window_closes_auto" = "登入完成後,此視窗會自動關閉。"; "cost_status_error" = "%1$@:%2$@"; @@ -396,9 +426,19 @@ "© 2026 Peter Steinberger. MIT License." = "© 2026 Peter Steinberger。MIT 許可證。"; "section_system" = "系統"; "section_usage" = "使用量"; -"section_automation" = "自動化"; +"section_refreshing" = "重新整理"; +"section_alerts" = "提醒"; +"section_celebrations" = "慶祝"; +"section_icon" = "圖示"; +"section_combined_icon" = "合併圖示"; +"section_animation" = "動畫"; +"section_content" = "內容"; +"section_agent_sessions" = "Agent 工作階段"; "language_title" = "語言"; "language_subtitle" = "更改顯示語言。需要重新啟動 App 才會完全生效。"; +"currency_title" = "偏好貨幣"; +"currency_subtitle" = "用於費用估算與支出指標的貨幣。使用每日更新的即時匯率。"; +"currency_auto" = "自動(依供應商 / USD)"; "language_system" = "依照系統"; "language_english" = "English"; "language_spanish" = "Español"; @@ -406,21 +446,42 @@ "language_chinese_simplified" = "简体中文"; "language_chinese_traditional" = "繁體中文"; "language_portuguese_brazilian" = "Português (Brasil)"; +"language_swedish" = "瑞典語"; +"language_dutch" = "Nederlands"; +"language_german" = "Deutsch"; +"language_french" = "法語"; +"language_ukrainian" = "烏克蘭語"; +"language_russian" = "Русский"; +"language_japanese" = "日語"; +"language_korean" = "韓語"; +"language_italian" = "Italiano"; +"language_polish" = "Polski"; "start_at_login_title" = "登入時啟動"; "start_at_login_subtitle" = "登入 Mac 時自動開啟 CodexBar。"; -"show_cost_summary" = "顯示費用摘要"; "show_cost_summary_subtitle" = "讀取本機使用量記錄。在選單中顯示今天及所選歷史時段的費用。"; +"cost_summary_style_title" = "顯示樣式"; +"cost_summary_style_inline" = "僅內嵌"; +"cost_summary_style_submenu" = "僅子選單"; +"cost_summary_style_both" = "兩者"; +"cost_summary_style_inline_help" = "直接在主選單中顯示費用摘要。"; +"cost_summary_style_submenu_help" = "改為顯示詳細的費用子選單。"; +"cost_summary_style_both_help" = "同時顯示主選單摘要和詳細的費用子選單。"; +"cost_history_window_title" = "歷史時段"; +"cost_history_window_help" = "設定選單中顯示多少天的本機使用記錄。"; "cost_history_days_title" = "歷史時段:%d 天"; -"cost_auto_refresh_info" = "自動重新整理:每小時 · 逾時:10 分鐘"; -"refresh_cadence_title" = "重新整理頻率"; -"refresh_cadence_subtitle" = "CodexBar 在背景輪詢提供者的頻率。"; +"cost_auto_refresh_info" = "自動重新整理:全域間隔(最短 5 分鐘)· 逾時:10 分鐘"; +"cost_comparison_periods_title" = "顯示較短的比較期間"; +"cost_comparison_periods_subtitle" = "當 7 天、30 天和 90 天落在所選歷史時段內時,加入相應總計。這些總計會重複使用同一次本機掃描。"; +"refresh_interval_title" = "重新整理間隔"; "manual_refresh_hint" = "自動重新整理已關閉;請使用選單中的「重新整理」指令。"; +"refresh_on_open_title" = "開啟選單時重新整理"; +"refresh_on_open_subtitle" = "每次開啟選單時擷取每個供應商的最新用量。"; "check_provider_status_title" = "檢查提供者狀態"; "check_provider_status_subtitle" = "輪詢 OpenAI/Claude 狀態頁面和 Google Workspace 的 Gemini/Antigravity,並在圖示和選單中顯示服務異常資訊。"; -"session_quota_notifications_title" = "工作階段配額通知"; "session_quota_notifications_subtitle" = "當 5 小時工作階段配額用完或恢復可用時傳送通知。"; -"quota_warning_notifications_title" = "配額提醒通知"; +"quota_depleted_title" = "配額用完與恢復"; "quota_warning_notifications_subtitle" = "當工作階段或每週剩餘配額達到設定門檻時提醒。"; +"threshold_warnings_title" = "門檻提醒"; "quota_warnings_title" = "配額提醒"; "quota_warning_session" = "工作階段"; "quota_warning_session_capitalized" = "工作階段"; @@ -429,6 +490,17 @@ "quota_warning_notification_title" = "%1$@ %2$@配額偏低"; "quota_warning_notification_body" = "剩餘 %1$@。已達到 %2$d%% %3$@提醒門檻。"; "quota_warning_notification_body_with_account" = "帳號 %1$@。剩餘 %2$@。已達到 %3$d%% %4$@提醒門檻。"; +"predictive_pace_warnings_title" = "預測性節奏提醒"; +"predictive_pace_warnings_subtitle" = "當 Codex 和 Claude 的工作階段或每週使用節奏可能在重設前耗盡配額時提醒。"; +"confetti_on_reset_title" = "重設時播放彩帶"; +"confetti_on_reset_subtitle" = "使用量重設時播放全螢幕彩帶。"; +"confetti_option_off" = "關閉"; +"confetti_option_session" = "工作階段重設"; +"confetti_option_weekly" = "每週重設"; +"confetti_option_both" = "兩者"; +"predictive_pace_warning_notification_title" = "%1$@ %2$@節奏提醒"; +"predictive_pace_warning_notification_body" = "依目前節奏,此配額可能會在重設前於 %1$@ 後耗盡。"; +"predictive_pace_warning_notification_body_with_account" = "帳號 %1$@。依目前節奏,此配額可能會在重設前於 %2$@ 後耗盡。"; "session_depleted_notification_title" = "%@ 工作階段已用完"; "session_depleted_notification_body" = "剩餘 0%。恢復可用時會再通知。"; "session_restored_notification_title" = "%@ 工作階段已恢復"; @@ -436,20 +508,28 @@ "quota_warning_warn_at" = "提醒門檻"; "quota_warning_global_threshold_subtitle" = "工作階段和每週時段的剩餘百分比,除非提供者另有設定。"; "quota_warning_sound" = "播放通知音效"; +"quota_warning_onscreen_alert" = "顯示螢幕文字提醒"; "quota_warning_provider_inherits" = "預設使用全域配額提醒設定,除非在此自訂時段。"; +"quota_warning_provider_disabled" = "配額提醒通知和使用量條標記均已關閉。啟用其中任一項即可編輯這些已儲存的設定。"; +"quota_warning_provider_markers_only" = "配額提醒通知已全域關閉。這些設定仍會控制使用量條標記。"; +"quota_warning_global" = "全域"; "quota_warning_customize_thresholds" = "自訂 %@ 門檻"; "quota_warning_enable_warnings" = "啟用 %@ 提醒"; "quota_warning_window_warn_at" = "%@ 提醒門檻"; "quota_warning_off" = "關閉"; "quota_warning_inherited" = "繼承:%@"; "quota_warning_depleted_only" = "僅用完時"; -"quota_warning_upper" = "上限"; +"quota_warning_upper" = "較高"; "quota_warning_lower" = "下限"; +"quota_warning_warning" = "警告"; +"quota_warning_critical" = "嚴重"; "apply" = "套用"; "quit_app" = "結束 CodexBar"; "tab_general" = "一般"; "tab_providers" = "提供者"; -"tab_display" = "顯示"; +"tab_notifications" = "通知"; +"tab_menu_bar" = "選單列"; +"tab_menu" = "選單"; "tab_advanced" = "進階"; "tab_about" = "關於"; "tab_debug" = "除錯"; @@ -469,39 +549,46 @@ "workspace_selection_cancelled" = "CodexBar 發現多個工作區,但未選擇任何工作區。"; "unsafe_managed_home" = "CodexBar 拒絕修改意外的託管主目錄路徑:%@"; "menu_bar_metric_title" = "選單列指標"; -"menu_bar_metric_subtitle" = "選擇哪個時段驅動選單列百分比。"; +"menu_bar_metric_subtitle" = "選擇選單列百分比要依據哪個時段。"; "menu_bar_metric_subtitle_deepseek" = "在選單列顯示 DeepSeek 餘額。"; "menu_bar_metric_subtitle_moonshot" = "在選單列顯示 Moonshot / Kimi API 餘額。"; "menu_bar_metric_subtitle_mistral" = "在選單列顯示 Mistral API 本月支出。"; -"menu_bar_metric_subtitle_kimik2" = "在選單列顯示 Kimi K2 API 金鑰額度。"; "automatic" = "自動"; "primary_api_key_limit" = "主要(API 金鑰限制)"; -"section_menu_bar" = "選單列"; +"menu_bar_style_title" = "選單列樣式"; +"menu_bar_style_subtitle" = "選單列項目的繪製方式。"; +"menu_bar_inactive_display_contrast_title" = "改善非使用中顯示器上的可見度"; +"menu_bar_usage_colors_title" = "用量顏色標示"; +"menu_bar_usage_colors_subtitle" = "隨著用量上升,選單列圖示由綠轉紅。"; +"menu_bar_inactive_display_contrast_subtitle" = "使用高對比顯示,讓其他顯示器上的圖示與指標保持清晰可讀。"; +"menu_bar_style_critters" = "小動物"; +"menu_bar_style_bars" = "進度條"; +"menu_bar_style_icon_percent" = "圖示和百分比"; +"switcher_rows_title" = "切換器列"; +"switcher_rows_icons" = "提供者圖示"; +"switcher_rows_progress" = "每週進度"; +"usage_bars_fill_title" = "使用量條填滿方式"; +"usage_bars_fill_remaining" = "按剩餘量"; +"usage_bars_fill_used" = "按已用量"; +"reset_times_title" = "重設時間"; +"reset_times_countdown" = "倒數計時"; +"reset_times_clock" = "時鐘時間"; +"cost_summary_title" = "費用摘要"; +"cost_summary_off" = "關閉"; "merge_icons_title" = "合併圖示"; -"merge_icons_subtitle" = "使用單一選單列圖示並帶提供者切換器。"; -"switcher_shows_icons_title" = "切換器顯示圖示"; -"switcher_shows_icons_subtitle" = "在切換器中顯示提供者圖示(否則顯示每週進度線)。"; +"merge_icons_subtitle" = "使用單一選單列圖示,並提供提供者切換器。"; "show_most_used_provider_title" = "顯示使用量最高的提供者"; -"show_most_used_provider_subtitle" = "選單列會自動顯示最接近速率限制的提供者。"; -"menu_bar_shows_percent_title" = "選單列顯示百分比"; -"menu_bar_shows_percent_subtitle" = "將小動物進度條替換為提供者品牌圖示和百分比。"; +"show_most_used_provider_subtitle" = "選單列會自動顯示最接近用量上限的提供者。"; "display_mode_title" = "顯示模式"; -"display_mode_subtitle" = "選擇選單列中顯示的內容(進度會比較實際與預期使用量)。"; -"section_menu_content" = "選單內容"; -"show_usage_as_used_title" = "以已用量顯示"; -"show_usage_as_used_subtitle" = "進度條會隨配額消耗而填滿(而不是顯示剩餘量)。"; +"display_mode_subtitle" = "選擇選單列要顯示的內容(進度會比較目前用量和時間進度)。"; "show_quota_warning_markers_title" = "顯示配額提醒標記"; "show_quota_warning_markers_subtitle" = "設定配額提醒後,在使用量條上繪製門檻刻度標記。"; "weekly_progress_work_days_title" = "每週進度工作日標記"; -"weekly_progress_work_days_subtitle" = "在每週使用量條上繪製日期邊界刻度標記。"; -"show_reset_time_as_clock_title" = "以時鐘時間顯示重置時間"; -"show_reset_time_as_clock_subtitle" = "將重置時間顯示為絕對時鐘值,而不是倒數計時。"; +"weekly_progress_work_days_subtitle" = "設定用於每週用量條刻度與進度計算的工作日。"; "show_provider_changelog_links_title" = "顯示提供者版本資訊連結"; "show_provider_changelog_links_subtitle" = "在選單中為支援的 CLI 提供者新增發行說明連結。"; "show_credits_extra_usage_title" = "顯示額度 + 額外使用量"; "show_credits_extra_usage_subtitle" = "在選單中顯示 Codex 額度和 Claude 額外使用量部分。"; -"show_all_token_accounts_title" = "顯示所有 token 帳號"; -"show_all_token_accounts_subtitle" = "在選單中堆疊 token 帳號(否則顯示帳號切換欄)。"; "multi_account_layout_title" = "多帳號版面配置"; "multi_account_layout_subtitle" = "選擇分段帳號切換或堆疊帳號卡片。"; "multi_account_layout_segmented" = "分段"; @@ -512,6 +599,16 @@ "overview_no_providers_hint" = "「概覽」中沒有可用的已啟用提供者。"; "overview_rows_follow_order" = "概覽列一律依提供者順序排列。"; "overview_no_providers_selected" = "未選擇提供者"; +"agent_sessions_title" = "Agent 工作階段"; +"agent_sessions_subtitle" = "在選單中顯示本機及透過 SSH 發現的 Codex 和 Claude Code 工作階段。"; +"agent_sessions_hosts_title" = "其他 SSH 主機"; +"agent_sessions_footer" = "系統會自動發現 tailnet 上的 Mac。本機工作階段每 30 秒重新整理一次;遠端主機每 60 秒以及開啟選單時重新整理。"; +"agent_session_labels_title" = "工作階段標籤"; +"agent_session_labels_subtitle" = "選擇 Agent 工作階段的命名方式。"; +"agent_session_label_project" = "專案"; +"agent_session_label_descriptive" = "描述性"; +"agent_session_label_descriptive_and_project" = "描述性 + 專案"; +"agent_session_unknown_project" = "未知專案"; "section_keyboard_shortcut" = "快速鍵"; "open_menu_shortcut_title" = "開啟選單"; "open_menu_shortcut_subtitle" = "從任意位置觸發選單列選單。"; @@ -521,14 +618,13 @@ "no_writable_bin_dirs" = "找不到可寫的 bin 目錄。"; "show_debug_settings_title" = "顯示除錯設定"; "show_debug_settings_subtitle" = "在「除錯」標籤中顯示疑難排解工具。"; +"1.5× headroom" = "1.5 倍餘裕"; "surprise_me_title" = "給我驚喜"; "surprise_me_subtitle" = "讓選單列上的 Agent 多一點變化。"; -"weekly_limit_confetti_title" = "每週重置慶祝動畫"; -"weekly_limit_confetti_subtitle" = "每週使用量重置時播放全螢幕慶祝動畫。"; "hide_personal_info_title" = "隱藏個人資訊"; "hide_personal_info_subtitle" = "在選單列和選單介面中隱藏電子郵件地址。"; "show_provider_storage_usage_title" = "顯示提供者儲存使用量"; -"show_provider_storage_usage_subtitle" = "在選單中顯示本機磁碟使用量。會在背景掃描已知的提供者自有路徑。"; +"show_provider_storage_usage_subtitle" = "在選單中顯示本機磁碟使用量。會在背景掃描已知的提供者專用路徑。"; "section_keychain_access" = "鑰匙圈存取"; "keychain_access_caption" = "停用所有鑰匙圈讀寫。如果 macOS 在你按下一律允許後仍持續要求存取「Chrome/Brave/Edge Safe Storage」,可使用此選項。啟用時無法匯入瀏覽器 Cookie;請在「提供者」中手動貼上 Cookie 標頭。透過 CLI 的 Claude/Codex OAuth 仍可使用。"; "disable_keychain_access_title" = "停用鑰匙圈存取"; @@ -604,13 +700,20 @@ "metric_pref_tertiary" = "第三"; "metric_pref_extra_usage" = "額外使用量"; "metric_pref_average" = "平均"; +"metric_mistral_payg" = "依用量計費"; +"metric_mistral_monthly_plan" = "月租方案"; "display_mode_percent" = "百分比"; "display_mode_pace" = "進度"; "display_mode_both" = "兩者"; +"display_mode_reset_time" = "重置時間"; "display_mode_percent_desc" = "顯示剩餘/已使用百分比(例如 45%)"; "display_mode_pace_desc" = "顯示進度指示器(例如 +5%)"; "display_mode_both_desc" = "同時顯示百分比和進度(例如 45% · +5%)"; +"display_mode_reset_time_desc" = "顯示所選指標的重置時間(例如 ↻ 3:56 PM)"; +"menu_bar_reset_when_exhausted_title" = "配額用盡時顯示重設時間"; +"menu_bar_reset_when_exhausted_subtitle" = "剩餘 0% 時,顯示距重設的時間而非百分比"; "status_operational" = "運作正常"; +"status_degraded" = "效能下降"; "status_partial_outage" = "部分服務中斷"; "status_major_outage" = "重大服務中斷"; "status_critical_issue" = "嚴重問題"; @@ -622,20 +725,29 @@ "refresh_5min" = "5 分鐘"; "refresh_15min" = "15 分鐘"; "refresh_30min" = "30 分鐘"; +"refresh_adaptive" = "自適應"; +"refresh_adaptive_agent_aware" = "自適應(感知代理程式活動)"; +"adaptive_activity_consent_title" = "允許根據活動自適應重新整理?"; +"adaptive_activity_consent_message" = "感知代理程式活動的自適應模式可以檢查本機執行中的程序列表(包括命令列)來辨識 Codex 和 Claude,並在你編寫程式碼時每 30 秒讀取一次已知的工作階段中繼資料。關閉 Agent Sessions 時,CodexBar 僅在記憶體中使用最近一次活動時間,並捨棄工作階段路徑和身分資訊。此資料不會傳送到任何地方,遠端偵測和 SSH 保持關閉。如果拒絕,CodexBar 將返回不掃描本機活動的普通自適應模式。"; +"adaptive_activity_consent_allow" = "允許本機活動"; +"adaptive_activity_consent_decline" = "使用普通自適應模式"; "not_found" = "找不到"; "CodexBar can't show its menu bar icon" = "CodexBar 無法顯示選單列圖示"; "Dismiss" = "關閉"; "Open Menu Bar Settings" = "開啟選單列設定"; "macOS Tahoe can block menu bar apps in System Settings → Menu Bar → Allow in the Menu Bar. CodexBar is running, but macOS may be hiding its icon. Open Menu Bar settings and turn CodexBar on." = "macOS Tahoe 可能會在「系統設定」→「選單列」→「允許顯示在選單列」中封鎖選單列 App。CodexBar 正在執行,但 macOS 可能隱藏了它的圖示。請開啟選單列設定並啟用 CodexBar。"; -"cost_header_estimated" = "費用(估算)"; "cost_estimate_hint" = "根據本機記錄估算 · 可能與帳單不同"; -"copilot_device_code" = "裝置代碼已複製到剪貼簿:%1$@\n\n請到以下網址驗證:%2$@"; +"codex_api_estimate_hint" = "根據 Token 用量估算 · 不是訂閱帳單"; +"cost_data_explanation" = "費用可能由供應商回報,也可能根據 Token 用量按公開 API 價格估算。估算值不是訂閱費用。"; +"copilot_device_code" = "裝置碼已複製到剪貼簿:%1$@\n\n請到以下網址驗證:%2$@"; "copilot_waiting_text" = "請在瀏覽器中完成登入。\n登入完成後,此視窗會自動關閉。"; "vertex_ai_login_instructions" = "要追蹤 Vertex AI 使用量,請透過 Google Cloud 進行認證。\n\n1. 開啟終端\n2. 執行:gcloud auth application-default login\n3. 依照瀏覽器提示登入\n4. 設定你的專案:gcloud config set project PROJECT_ID\n\n要現在開啟終端嗎?"; /* Popup panels */ "No usage configured." = "尚未設定使用量。"; "Quota" = "配額"; +"Daily quota" = "每日配額"; +"Total" = "總計"; "tokens" = "token"; "requests" = "請求"; "Latest" = "最新"; @@ -669,6 +781,7 @@ "API spend" = "API 支出"; "Extra usage" = "額外使用量"; "Quota usage" = "配額使用量"; +"Your spend" = "您的支出"; "%.0f%% used" = "已使用 %.0f%%"; "Usage history (today)" = "使用量記錄(今天)"; "Usage history (%d days)" = "使用量記錄(%d 天)"; @@ -687,7 +800,7 @@ "Hourly Usage" = "每小時使用量"; "Usage remaining" = "剩餘使用量"; "Usage used" = "已使用使用量"; -"API key verified. Ollama does not expose Cloud quota limits through the API." = "API 金鑰已驗證。Ollama 不會透過 API 暴露 Cloud 配額限制。"; +"API key verified. Cloud quotas need browser cookies. Sign in to Ollama." = "API 金鑰已驗證。Cloud 配額需要瀏覽器 Cookie。請登入 Ollama。"; "Last 30 days: %@ tokens" = "近 30 天:%@ token"; "7d spend" = "7 天支出"; "30d spend" = "30 天支出"; @@ -696,7 +809,7 @@ "OpenRouter API key spend trend" = "OpenRouter API 金鑰支出趨勢"; "z.ai hourly token trend" = "z.ai 每小時 token 趨勢"; "MiniMax 30 day token usage trend" = "MiniMax 30 天 token 使用量趨勢"; -"Today cash" = "今日現金"; +"Today cash" = "今日費用"; "DeepSeek 30 day token usage trend" = "DeepSeek 30 天 token 使用量趨勢"; "cache-hit input" = "快取命中輸入"; "cache-miss input" = "快取未命中輸入"; @@ -707,6 +820,7 @@ "Today" = "今天"; "Today tokens" = "今日 token"; "30d cost" = "近 30 天費用"; +"%@ cost" = "%@費用"; "30d tokens" = "近 30 天 token"; "Latest tokens" = "最新 token"; "Top model" = "主要模型"; @@ -714,6 +828,7 @@ "Add Account..." = "新增帳號…"; "Usage Dashboard" = "使用量儀表板"; "Status Page" = "狀態頁"; +"Open Status Page" = "打開狀態頁"; "Settings..." = "設定…"; "About CodexBar" = "關於 CodexBar"; "Quit" = "結束"; @@ -725,15 +840,15 @@ "This week" = "本週"; "Week" = "週"; "Month" = "月"; -"Models" = "模型數"; +"Models" = "模型"; "24h tokens" = "24 小時 token"; "Latest hour" = "最新小時"; "Peak hour" = "尖峰小時"; "Top method" = "主要方法"; -"30d cash" = "30 天現金"; +"30d cash" = "近 30 天費用"; "30d billing history from MiniMax web session" = "來自 MiniMax 網頁工作階段的 30 天帳單記錄"; "AWS Cost Explorer billing can lag." = "AWS Cost Explorer 帳單資料可能延遲。"; -"Rate limit: %d / %@" = "速率限制: %d / %@"; +"Rate limit: %d / %@" = "速率限制:%d / %@"; "Key remaining" = "金鑰剩餘額度"; "No limit set for the API key" = "此 API 金鑰未設定限制"; "API key limit unavailable right now" = "目前無法取得 API 金鑰限制"; @@ -753,6 +868,7 @@ "Est. total (%@): %@" = "估計總計(%@):%@"; "Hover a bar for details" = "停留在長條上查看詳細資料"; "%@: %@ · %@ tokens" = "%@:%@ · %@ token"; +"%@: %@" = "%@:%@"; "No providers selected for Overview." = "概覽尚未選擇提供者。"; "No overview data available." = "概覽尚無可用資料。"; @@ -774,7 +890,7 @@ "Antigravity login failed" = "Antigravity 登入失敗"; "Antigravity login timed out" = "Antigravity 登入逾時"; "Auth source" = "認證來源"; -"Automatic imports Chrome browser cookies from Xiaomi MiMo." = "自動匯入 Xiaomi MiMo 的 Chrome 瀏覽器 Cookie。"; +"Automatic imports browser cookies from Xiaomi MiMo." = "自動匯入 Xiaomi MiMo 的瀏覽器 Cookie。"; "Automatic imports Windsurf session data from Chromium browser localStorage." = "自動從 Chromium 瀏覽器 localStorage 匯入 Windsurf 工作階段資料。"; "Automatic imports browser cookies from Bailian." = "自動匯入 Bailian 的瀏覽器 Cookie。"; "Automatically imports browser cookies." = "自動匯入瀏覽器 Cookie。"; @@ -783,7 +899,7 @@ "Azure OpenAI key" = "Azure OpenAI 金鑰"; "Azure OpenAI resource endpoint. AZURE_OPENAI_ENDPOINT is also supported." = "Azure OpenAI 資源端點。也支援 AZURE_OPENAI_ENDPOINT。"; "Base URL" = "Base URL"; -"Base URL for the LLM-API-Key-Proxy instance." = "LLM-API-Key-Proxy 實例的 Base URL。"; +"Base URL for the LLM-API-Key-Proxy instance." = "LLM-API-Key-Proxy 執行個體的 Base URL。"; "Browser cookies" = "瀏覽器 Cookie"; "Cap end" = "上限終點"; "Cap start" = "上限起點"; @@ -809,7 +925,6 @@ "CodexBar will ask macOS Keychain for your Cursor cookie header so it can fetch usage. Click OK to continue." = "CodexBar 將向 macOS 鑰匙圈要求你的 Cursor Cookie 標頭,以取得使用量。按一下「確定」繼續。"; "CodexBar will ask macOS Keychain for your Factory cookie header so it can fetch usage. Click OK to continue." = "CodexBar 將向 macOS 鑰匙圈要求你的 Factory Cookie 標頭,以取得使用量。按一下「確定」繼續。"; "CodexBar will ask macOS Keychain for your GitHub Copilot token so it can fetch usage. Click OK to continue." = "CodexBar 將向 macOS 鑰匙圈要求你的 GitHub Copilot token,以取得使用量。按一下「確定」繼續。"; -"CodexBar will ask macOS Keychain for your Kimi K2 API key so it can fetch usage. Click OK to continue." = "CodexBar 將向 macOS 鑰匙圈要求你的 Kimi K2 API 金鑰,以取得使用量。按一下「確定」繼續。"; "CodexBar will ask macOS Keychain for your Kimi auth token so it can fetch usage. Click OK to continue." = "CodexBar 將向 macOS 鑰匙圈要求你的 Kimi 認證 token,以取得使用量。按一下「確定」繼續。"; "CodexBar will ask macOS Keychain for your MiniMax API token so it can fetch usage. Click OK to continue." = "CodexBar 將向 macOS 鑰匙圈要求你的 MiniMax API token,以取得使用量。按一下「確定」繼續。"; "CodexBar will ask macOS Keychain for your MiniMax cookie header so it can fetch usage. Click OK to continue." = "CodexBar 將向 macOS 鑰匙圈要求你的 MiniMax Cookie 標頭,以取得使用量。按一下「確定」繼續。"; @@ -823,10 +938,15 @@ "Day" = "日期"; "Deployment" = "部署"; "Drag to reorder" = "拖曳以重新排序"; +"Sort providers alphabetically" = "依字母順序排列提供者"; +"Sort providers alphabetically (enabled first)" = "依字母順序排列提供者(已啟用的優先)"; +"Sorted alphabetically (enabled first) — click to use your custom order" = "已依字母順序排列(已啟用的優先)— 按一下使用自訂順序"; "Endpoint" = "端點"; "Enterprise host" = "Enterprise 主機"; "Extra usage balance: %@" = "額外使用量餘額:%@"; "Keychain Access Required" = "需要鑰匙圈存取權"; +"keychain_prompt_learn_more" = "進一步瞭解…"; +"keychain_prompt_privacy_note" = "Mac 登入密碼由 macOS(而非 CodexBar)處理。你可以隨時在「設定」→「進階」中停用所有鑰匙圈存取。"; "Kiro menu bar value" = "Kiro 選單列數值"; "Label" = "標籤"; "No organizations loaded. Click Refresh after setting your API key." = "尚未載入組織。設定 API 金鑰後按一下「重新整理」。"; @@ -853,11 +973,12 @@ "Optional. Leave blank to discover and aggregate projects visible to the API key." = "選填。留空會探索並彙總 API 金鑰可見的專案。"; "Org ID (optional)" = "組織 ID(選填)"; "Organizations" = "組織"; +"Organization ID" = "組織 ID"; "Password" = "密碼"; "%@ authentication is disabled." = "%@ 認證已停用。"; "%@ cookies are disabled." = "%@ Cookie 已停用。"; "%@ web API access is disabled." = "%@ Web API 存取已停用。"; -"Disable %@ dashboard cookie usage." = "停用 %@ 儀表板 Cookie 用法。"; +"Disable %@ dashboard cookie usage." = "停用 %@ 儀表板 Cookie。"; "Keychain access is disabled in Advanced, so browser cookie import is unavailable." = "進階設定中已停用鑰匙圈存取,因此無法匯入瀏覽器 Cookie。"; "Manually paste an %@ from a browser session." = "從瀏覽器工作階段中手動貼上 %@。"; "Paste a Cookie header captured from %@." = "貼上從 %@ 擷取的 Cookie 標頭。"; @@ -874,6 +995,7 @@ "Personal account" = "個人帳號"; "Project ID" = "專案 ID"; "Re-auth" = "重新認證"; +"Re-login at claude.ai" = "重新登入 claude.ai"; "Re-authenticating…" = "正在重新認證…"; "Refresh Session" = "重新整理工作階段"; "Refresh organizations" = "重新整理組織"; @@ -905,7 +1027,8 @@ "Stored in ~/.codexbar/config.json. You can also provide CROF_API_KEY." = "儲存在 ~/.codexbar/config.json 中。你也可以提供 CROF_API_KEY。"; "Stored in ~/.codexbar/config.json. You can also provide KILO_API_KEY or ~/.local/share/kilo/auth.json (kilo.access)." = "儲存在 ~/.codexbar/config.json 中。你也可以提供 KILO_API_KEY 或 ~/.local/share/kilo/auth.json(kilo.access)。"; "T3 Chat cookie" = "T3 Chat Cookie"; -"That account is no longer available in CodexBar. Refresh the account list and try again." = "該帳號已無法在 CodexBar 中使用。請重新整理帳號列表後再試。"; +"Team mode" = "團隊模式"; +"That account is no longer available in CodexBar. Refresh the account list and try again." = "該帳號已無法在 CodexBar 中使用。請重新整理帳號清單後再試。"; "The browser login did not complete in time. Try Antigravity login again." = "瀏覽器登入未在時限內完成。請再次嘗試 Antigravity 登入。"; "Timed out waiting for Cursor login. %@" = "等待 Cursor 登入逾時。%@"; "Timed out waiting for Cursor login. %@ Last error: %@" = "等待 Cursor 登入逾時。%@ 最後錯誤:%@"; @@ -926,3 +1049,336 @@ "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" = "搜尋提供者"; + +"language_vietnamese" = "越南語"; +"reset_tomorrow_format" = "明天 %@"; + +"Request quota: %@ / %@" = "請求額度:%@ / %@"; +"language_turkish" = "Türkçe"; +"language_indonesian" = "印尼語"; +"language_arabic" = "العربية"; +"language_persian" = "فارسی"; +"language_thai" = "ไทย"; +"language_galician" = "Galego"; +"Limit Reset Credits" = "限額重設額度"; +"1 available" = "1 次可用"; +"%d available" = "%d 次可用"; +"Next expires %@" = "下一個將於 %@ 到期"; +"Expires %@" = "%@ 到期"; +"No expiry" = "永不過期"; +"Other (%d items)" = "其他(%d 個項目)"; +"Expand" = "展開"; +"Collapse" = "收合"; +"byte_unit_byte" = "B"; +"byte_unit_bytes" = "B"; +"byte_unit_kilobyte" = "KB"; +"byte_unit_kilobytes" = "KB"; +"byte_unit_megabyte" = "MB"; +"byte_unit_megabytes" = "MB"; +"byte_unit_gigabyte" = "GB"; +"byte_unit_gigabytes" = "GB"; + +/* Added zh-Hant parity with English catalog */ +"minimax_service_music_generation" = "音樂生成"; +"minimax_service_coding_plan_search" = "Coding Plan 搜尋"; +"Clearing removes old plan-mode files." = "會移除舊的 plan-mode 檔案。"; +"%.0f%% %@" = "%2$@ %1$.0f%%"; +"<1%% %@" = "%1$@ <1%%"; +"%@: %@%% used" = "%@:已使用 %@%%"; +"terminal_app_subtitle" = "「開啟終端」動作使用的終端"; +"Admin API key" = "Admin API 金鑰"; +"Add Google Account" = "新增 Google 帳號"; +"usage_percent_suffix_left" = "剩餘"; +"Clearing removes local diagnostic logs." = "會移除本機診斷記錄。"; +"Projected empty in %@" = "預估 %@ 後用完"; +"Music Generation" = "音樂生成"; +"Clearing removes per-session environment metadata." = "會移除各工作階段的環境中繼資料。"; +"Estimated from local Codex logs for the selected account." = "根據所選帳號的本機 Codex 記錄估算。"; +"Manual cleanup: attachment cache" = "手動清理:附件快取"; +"No utilization data yet." = "尚無使用率資料。"; +"Stores each signed-in Google account for quick Antigravity switching. Uses Antigravity.app OAuth when available, or ANTIGRAVITY_OAUTH_CLIENT_ID and ANTIGRAVITY_OAUTH_CLIENT_SECRET as an override." = "儲存每個已登入的 Google 帳號,方便快速切換 Antigravity。可用時會使用 Antigravity.app OAuth,或以 ANTIGRAVITY_OAUTH_CLIENT_ID 和 ANTIGRAVITY_OAUTH_CLIENT_SECRET 覆寫。"; +"minimax_service_text_to_speech" = "文字轉語音"; +"Resets in %@" = "%@ 後重置"; +"minimax_service_image_generation" = "圖片生成"; +"Pace: %@" = "進度:%@"; +"Clearing removes leftover runtime shell snapshot files." = "會移除殘留的執行階段 shell 快照檔案。"; +"OpenRouter API token not configured. Set OPENROUTER_API_KEY environment variable or configure in Settings." = "尚未設定 OpenRouter API token。請設定 OPENROUTER_API_KEY 環境變數,或在「設定」中設定。"; +"just now" = "剛剛"; +"Store multiple OpenAI API keys." = "儲存多個 OpenAI API 金鑰。"; +"Clearing removes local edit checkpoint history." = "會移除本機編輯檢查點歷史。"; +"Resets %@" = "%@ 重置"; +"Image Generation" = "圖片生成"; +"No JetBrains IDE with AI Assistant detected. Install a JetBrains IDE and enable AI Assistant." = "未偵測到含 AI Assistant 的 JetBrains IDE。請安裝 JetBrains IDE 並啟用 AI Assistant。"; +"Manual cleanup: sessions" = "手動清理:工作階段"; +"%d%% in deficit" = "比目前進度多用 %d%%"; +"Resets now" = "正在重置"; +"Google OAuth" = "Google OAuth"; +"Clearing removes provider-owned cached data." = "會移除提供者專用的快取資料。"; +"Manual cleanup: shell snapshots" = "手動清理:shell 快照"; +"Manual cleanup: file history" = "手動清理:檔案歷史"; +"today" = "今天"; +"Manual cleanup: legacy todos" = "手動清理:舊版待辦事項"; +"Store multiple DeepSeek API keys." = "儲存多個 DeepSeek API 金鑰。"; +"%d more items" = "另有 %d 個項目"; +"No local data found" = "找不到本機資料"; +"Updated %@h ago" = "%@ 小時前已更新"; +"minimax_service_lyrics_generation" = "歌詞生成"; +"Manual cleanup: debug logs" = "手動清理:除錯記錄"; +"minimax_usage_amount_format" = "使用量:%@ / %@"; +"Last 30 days:" = "近 30 天:"; +"Missing DeepSeek API key." = "缺少 DeepSeek API 金鑰。"; +"Google accounts" = "Google 帳號"; +"Runs out in %@" = "%@ 後用完"; +"terminal_app_title" = "預設終端"; +"minimax_service_text_generation" = "文字生成"; +"Runs out now" = "現在用完"; +"Clearing removes legacy per-session task lists." = "會移除舊版各工作階段工作清單。"; +"Clearing removes past debug logs." = "會移除過去的除錯記錄。"; +"%d%% in reserve" = "比目前進度少用 %d%%"; +"Auto uses the local IDE API first, then Google OAuth when the IDE is closed." = "自動模式會先使用本機 IDE API;IDE 關閉時改用 Google OAuth。"; +"Manual cleanup: temporary data" = "手動清理:暫存資料"; +"minimax_used_percent_format" = "已使用 %@"; +"minimax_service_coding_plan_vlm" = "Coding Plan VLM"; +"≈ %d%% run-out risk" = "約 %d%% 機率會用完"; +"No available fetch strategy for %@." = "%@ 沒有可用的取得策略。"; +"%@ left" = "剩餘 %@"; +"Manual cleanup: cache" = "手動清理:快取"; +"This month" = "本月"; +"Manual cleanup: archived sessions" = "手動清理:封存的工作階段"; +"Credits unavailable; keep Codex running to refresh." = "無法取得額度;請保持 Codex 執行以便重新整理。"; +"Clearing removes cached large pastes or attached images." = "會移除快取的大型貼上內容或附加圖片。"; +"No available fetch strategy for minimax." = "minimax 沒有可用的取得策略。"; +"Store multiple Antigravity Google OAuth accounts for quick switching." = "儲存多個 Antigravity Google OAuth 帳號,方便快速切換。"; +"Manual cleanup: logs" = "手動清理:記錄"; +"Manual cleanup: file checkpoints" = "手動清理:檔案檢查點"; +"Updated %@" = "%@ 已更新"; +"Updated relative %@" = "%@已更新"; +"Updated absolute %@" = "%@ 已更新"; +"Pace: %@ · %@" = "進度:%@ · %@"; +"%@ · %@" = "%@ · %@"; +"No OpenCode session cookies found in browsers." = "在瀏覽器中找不到 OpenCode 工作階段 Cookie。"; +"Projected empty now" = "預估現在用完"; +"Manual cleanup: saved plans" = "手動清理:已儲存計畫"; +"Clearing removes archived Codex session history." = "會移除封存的 Codex 工作階段歷史。"; +"All Systems Operational" = "所有系統運作正常"; +"Clearing removes past resume, continue, and rewind history." = "會移除過去的 resume、continue 和 rewind 歷史。"; +"%@ is unavailable in the current environment." = "目前環境無法使用 %@。"; +"Clearing removes local temporary provider data." = "會移除本機提供者暫存資料。"; +"No Cursor session found. Please log in to cursor.com in Safari, Chrome, Microsoft Edge, Brave, Arc, Dia, ChatGPT Atlas, Chromium, Helium, Vivaldi, Yandex Browser, Firefox, Zen, Colibri, Sidekick, Opera, Opera GX, or Edge Canary. If you use Safari, grant CodexBar Full Disk Access in System Settings ▸ Privacy & Security. You can also sign in to Cursor from the CodexBar menu (Add / switch account)." = "找不到 Cursor 工作階段。請在 Safari、Chrome、Microsoft Edge、Brave、Arc、Dia、ChatGPT Atlas、Chromium、Helium、Vivaldi、Yandex Browser、Firefox、Zen、Colibri、Sidekick、Opera、Opera GX 或 Edge Canary 中登入 cursor.com。如果你使用 Safari,請在「系統設定」▸「隱私權與安全性」授予 CodexBar 完整磁碟存取權。你也可以從 CodexBar 選單登入 Cursor(新增 / 切換帳號)。"; +"On pace" = "用量正常"; +"Lasts until reset" = "可撐到重置"; +"z.ai API token not found. Set apiKey in ~/.codexbar/config.json or Z_AI_API_KEY." = "找不到 z.ai API token。請在 ~/.codexbar/config.json 設定 apiKey,或設定 Z_AI_API_KEY。"; +"Total: %@" = "總計:%@"; +"usage_percent_suffix_used" = "已用"; +"Manual cleanup: session metadata" = "手動清理:工作階段中繼資料"; +"Updated %@m ago" = "%@ 分鐘前已更新"; +"Text Generation" = "文字生成"; +"Add accounts via GitHub OAuth Device Flow on the selected host." = "透過所選主機上的 GitHub OAuth 裝置流程新增帳號。"; +"%d unreadable item(s) skipped" = "已略過 %d 個無法讀取的項目"; +"Login with Google" = "使用 Google 登入"; +"Manual cleanup: past sessions" = "手動清理:過去的工作階段"; +"Last 30 days" = "近 30 天"; +"Clearing removes past Codex session history." = "會移除過去的 Codex 工作階段歷史。"; +"%dd" = "%d 天"; +"Open billing" = "開啟帳單"; +"Updated just now" = "剛剛更新"; +"Open Token Plan" = "開啟 Token Plan"; +"Text to Speech" = "文字轉語音"; +"No %@ utilization data yet." = "尚無 %@ 使用率資料。"; +"Cleanup ideas" = "建議清理項目"; +"Clearing removes checkpoint restore data for previous edits." = "會移除先前編輯的檢查點還原資料。"; + +/* Runtime localization for dynamic strings */ +"Cached: %1$@ • %2$@" = "已快取:%1$@ • %2$@"; +"Cached values from %@." = "使用 %@ 的快取值。"; +"Codex CLI is not signed in. Run `codex login --device-auth`, then refresh." = "Codex CLI 尚未登入。請執行 `codex login --device-auth`,然後重新整理。"; +"Codex CLI missing. Install via `npm i -g @openai/codex` (or bun install) and restart." = "缺少 Codex CLI。請用 `npm i -g @openai/codex` 安裝(或使用 bun install),然後重新啟動。"; +"Codex session expired. Sign in again." = "Codex 工作階段已過期。請重新登入。"; +"OpenAI web refresh was interrupted. Refresh OpenAI cookies and try again." = "OpenAI Web 重新整理已中斷。請重新整理 OpenAI Cookie 後再試一次。"; +"OpenAI web refresh timed out. Refresh OpenAI cookies and try again." = "OpenAI Web 重新整理逾時。請重新整理 OpenAI Cookie 後再試一次。"; +"OpenAI web refresh hit a network error. Check your connection, then refresh OpenAI cookies and try again." = "OpenAI Web 重新整理遇到網路錯誤。請檢查連線,然後重新整理 OpenAI Cookie 後再試一次。"; +"Codex usage is temporarily unavailable. Try refreshing." = "Codex 使用量暫時無法取得。請嘗試重新整理。"; +"Last OpenAI dashboard refresh failed: %1$@. Cached values from %2$@." = "上次 OpenAI 儀表板重新整理失敗:%1$@。使用 %2$@ 的快取值。"; +"Last Codex credits refresh failed: %1$@. Cached values from %2$@." = "上次 Codex 額度重新整理失敗:%1$@。使用 %2$@ 的快取值。"; +"OpenAI web dashboard refresh timed out. CodexBar will retry after the refresh cooldown." = "OpenAI Web 儀表板重新整理逾時。CodexBar 會在重新整理冷卻時間後重試。"; +"Codex account changed; importing browser cookies…" = "Codex 帳號已變更;正在匯入瀏覽器 Cookie…"; +"Managed Codex account data is unavailable." = "無法取得託管 Codex 帳號資料。"; +"Fix the managed account store before importing OpenAI cookies." = "請先修復託管帳號儲存區,再匯入 OpenAI Cookie。"; +"Fix the managed account store before refreshing OpenAI web data." = "請先修復託管帳號儲存區,再重新整理 OpenAI Web 資料。"; +"The selected managed Codex account is unavailable." = "所選託管 Codex 帳號無法使用。"; +"Pick another Codex account before importing OpenAI cookies." = "請先選擇另一個 Codex 帳號,再匯入 OpenAI Cookie。"; +"Pick another Codex account before refreshing OpenAI web data." = "請先選擇另一個 Codex 帳號,再重新整理 OpenAI Web 資料。"; +"The selected Codex profile has no verified account email." = "所選 Codex 設定檔沒有已驗證的帳號電子郵件。"; +"Refresh the profile before importing OpenAI cookies." = "請先重新整理設定檔,再匯入 OpenAI Cookie。"; +"Refresh the profile before refreshing OpenAI web data." = "請先重新整理設定檔,再重新整理 OpenAI Web 資料。"; +"No matching OpenAI web session found." = "找不到相符的 OpenAI Web 工作階段。"; +"No matching OpenAI web session found for %@." = "找不到 %@ 的相符 OpenAI Web 工作階段。"; +"OpenAI cookies are for %@." = "OpenAI Cookie 屬於 %@。"; +"OpenAI cookies are for %1$@, not %2$@." = "OpenAI Cookie 屬於 %1$@,不是 %2$@。"; +"Codex credits are still loading; will retry shortly." = "Codex 額度仍在載入;稍後會重試。"; +"Could Not Identify GitHub Account" = "無法識別 GitHub 帳號"; +"GitHub login succeeded, but CodexBar could not verify which account it belongs to. Please try again." = "GitHub 登入成功,但 CodexBar 無法確認這屬於哪個帳號。請再試一次。"; +"Token Refreshed" = "Token 已重新整理"; +"Account Added" = "已新增帳號"; +"Login Successful" = "登入成功"; +"Login Failed" = "登入失敗"; +"You can close this window and return to CodexBar." = "你可以關閉此視窗並返回 CodexBar。"; +"You can close this window and try again." = "你可以關閉此視窗後再試一次。"; +"Requesting login…" = "正在要求登入…"; +"Waiting in browser…" = "正在瀏覽器中等待…"; +"Managed account storage unavailable" = "託管帳號儲存區無法使用"; +"Managed Codex login in progress…" = "託管 Codex 登入進行中…"; +"%d percent" = "%d%%"; +"Settings unavailable." = "無法取得設定。"; +"Failed to resolve Kilo credentials." = "無法解析 Kilo 憑證。"; +"Failed to load organizations." = "無法載入組織。"; +"Hidden" = "隱藏"; +"Credits left" = "剩餘額度"; +"Percent left" = "剩餘百分比"; +"Credits + percent" = "額度 + 百分比"; +"Used / total" = "已用 / 總量"; +"Overage credits at zero" = "歸零時顯示超額額度"; +"Overage cost at zero" = "歸零時顯示超額費用"; +"Overage credits + cost at zero" = "歸零時顯示超額額度 + 費用"; + +/* Settings sidebar redesign */ +"Enable" = "啟用"; +"Disable" = "停用"; +"providers_on_count" = "%d 個已開啟"; +"section_cost_summary" = "費用摘要"; +"section_command_line" = "命令列"; +"section_privacy" = "隱私權"; +"section_diagnostics" = "診斷"; +"section_updates" = "更新"; +"section_links" = "連結"; +"Show Codex Spark usage" = "顯示 Codex Spark 使用量"; +"Shows Codex Spark quota rows in the menu and provider preview. Requires optional credits and extra usage in Display settings." = "在選單和提供者預覽中顯示 Codex Spark 配額列。需要在「顯示」設定中啟用「顯示額度 + 額外使用量」。"; +"Show Daily Routines usage" = "顯示每日例行工作使用量"; +"Shows the Daily Routines quota row in the menu and provider preview. Requires optional credits and extra usage in Display settings." = "在選單和提供者預覽中顯示每日例行工作的配額列。需要在「顯示」設定中啟用「顯示額度 + 額外使用量」。"; +"Scroll to see more models" = "捲動查看更多模型"; +"Copy Image" = "拷貝影像"; +"Copy Stats" = "拷貝統計資料"; +"Could not copy image" = "無法拷貝影像"; +"Image copied" = "已拷貝影像"; +"Image saved" = "已儲存影像"; +"Nothing is uploaded. This image is created on your Mac." = "不會上傳任何內容。此影像是在你的 Mac 上製作。"; +"Save..." = "儲存..."; +"Share AI Usage" = "分享 AI 使用情況"; +"Share Stats…" = "分享統計資料…"; +"Stats copied" = "已拷貝統計資料"; +"DeepSeek this month token usage trend" = "DeepSeek 本月 token 使用量趨勢"; +"Chrome profile" = "Chrome 設定檔"; +"Choose which signed-in DeepSeek Platform session supplies detailed usage." = "選擇要提供詳細使用量的已登入 DeepSeek Platform 工作階段。"; +"Detailed usage unavailable." = "無法取得詳細使用量。"; +"Sign in to DeepSeek Platform in Chrome for detailed usage." = "請在 Chrome 中登入 DeepSeek Platform 以查看詳細使用量。"; +"Select a DeepSeek Chrome profile in Settings." = "請在「設定」中選擇 DeepSeek Chrome 設定檔。"; +"Select profile…" = "選擇設定檔…"; + +"Choose a supported browser so CodexBar can read the matching account." = "選擇一個支援的瀏覽器,讓 CodexBar 可以讀取對應帳號。"; +"Choose Cursor account" = "選擇 Cursor 帳號"; +"Choose which Cursor account CodexBar should use." = "選擇 CodexBar 應使用的 Cursor 帳號。"; +"Finish switching to a different Cursor account in your browser, then try again." = "在瀏覽器中完成切換到其他 Cursor 帳號,然後再試一次。"; +"Timed out waiting for Cursor account switch. %@" = "等待切換 Cursor 帳號逾時。%@"; +"Timed out waiting for Cursor account switch. %@ Last error: %@" = "等待切換 Cursor 帳號逾時。%@ 最近錯誤:%@"; +"Use Account" = "使用此帳號"; +/* Spend dashboard */ +"tab_usage_spend" = "使用量與支出"; +"Usage & Spend" = "使用量與支出"; +"Local estimated cost history across supported providers." = "所有支援提供者的本機預估費用歷史。"; +"Time range" = "時間範圍"; +"Track costs" = "追蹤費用"; +"Cost tracking is off" = "費用追蹤已關閉"; +"Turn on Track costs to build local estimates." = "開啟「追蹤費用」以建立本機預估。"; +"No local cost history yet" = "尚無本機費用歷史"; +"Turn on cost tracking or refresh after using a supported provider." = "開啟費用追蹤,或在使用支援的提供者後重新整理。"; +"Refresh failures" = "重新整理失敗"; +"Native currencies stay separate; Codex account rows exclude Pi session history." = "各原始幣別保持分開;Codex 帳號列不包含 Pi 工作階段歷史。"; +"Spend unavailable" = "無法取得支出資料"; +"Model breakdown unavailable" = "無法取得模型明細"; +"Local estimated history" = "本機預估歷史"; +"Coverage" = "涵蓋範圍"; +"Estimated spend" = "預估支出"; +"Tracked tokens" = "已追蹤 token"; +"Subscriptions" = "訂閱"; +"By subscription" = "依訂閱"; +"No model-level history" = "尚無模型層級歷史"; +"Daily estimated spend" = "每日預估支出"; +"≈%d full 5h windows of weekly left · %d windows until reset" = "每週額度約剩 %d 個完整 5 小時視窗 · 距重置還有 %d 個視窗"; +"Weekly cannot run out before reset at this pace" = "依此速度,每週額度無法在重置前用完"; +"Weekly can run out ≈%d windows early" = "每週額度可能提前約 %d 個視窗用完"; +"Estimated: %@" = "預估:%@"; +"session_quota_estimate_value_format" = "%1$@%2$@"; +"session quota" = "工作階段額度"; +"session quotas" = "工作階段額度"; +"Coding Plan" = "程式設計方案"; +"Agent Plan" = "智慧體方案"; +"Team" = "團隊"; + +/* Menu bar layout editor */ +"menu_bar_layout_title" = "佈局"; +"menu_bar_layout_footer" = "拖曳項目以排列選單列。點按項目可附加;選取已放置的項目並按 Delete 鍵可將其移除。"; +"menu_bar_layout_group_identity" = "身分"; +"menu_bar_layout_group_usage" = "使用量"; +"menu_bar_layout_group_time" = "時間"; +"menu_bar_layout_group_money" = "費用"; +"menu_bar_layout_group_structure" = "結構"; +"menu_bar_layout_scope_all" = "所有供應商"; +"menu_bar_layout_scope_help" = "編輯預設佈局,或為單一供應商設定覆寫佈局。"; +"menu_bar_layout_use_all" = "使用所有供應商佈局"; +"menu_bar_layout_preset" = "佈局預設"; +"menu_bar_layout_preset_icon_percent" = "圖示與百分比"; +"menu_bar_layout_preset_icon_only" = "僅圖示"; +"menu_bar_layout_preset_percent_reset" = "百分比與重設"; +"menu_bar_layout_preset_compact_stacked" = "緊湊堆疊"; +"menu_bar_layout_preset_custom" = "自訂"; +"menu_bar_layout_live_preview" = "即時預覽"; +"menu_bar_layout_strip" = "選單列條帶"; +"menu_bar_layout_remove_line_break" = "移除換行"; +"menu_bar_layout_chip_hint" = "選取、拖曳以重新排序,或使用移除動作。"; +"menu_bar_layout_palette_hint" = "點按以附加,或拖入佈局。"; +"menu_bar_layout_empty_line" = "將項目拖放到此處"; +"menu_bar_layout_line" = "第 %d 行"; +"menu_bar_layout_drag_remove" = "拖到此處以移除"; +"menu_bar_layout_size" = "大小"; +"menu_bar_layout_size_small" = "小"; +"menu_bar_layout_size_regular" = "一般"; +"menu_bar_layout_gap" = "間距"; +"menu_bar_layout_gap_tight" = "緊湊"; +"menu_bar_layout_gap_regular" = "一般"; +"menu_bar_layout_keyboard_hint" = "Delete 鍵會移除所選項目"; +"menu_bar_layout_sample_account" = "帳號"; +"menu_bar_layout_sample_runs_out" = "週五用盡"; +"menu_bar_layout_token_icon" = "圖示"; +"menu_bar_layout_token_provider" = "供應商名稱"; +"menu_bar_layout_token_account" = "帳號"; +"menu_bar_layout_token_session" = "工作階段 %"; +"menu_bar_layout_token_weekly" = "每週 %"; +"menu_bar_layout_token_auto" = "自動 %"; +"menu_bar_layout_token_bar" = "用量列"; +"menu_bar_layout_token_resets_in" = "重設倒數"; +"menu_bar_layout_token_reset_at" = "重設時間"; +"menu_bar_layout_token_runs_out" = "預計用盡"; +"menu_bar_layout_token_cost_today" = "今日費用"; +"menu_bar_layout_token_cost_30d" = "30 天費用"; +"menu_bar_layout_token_space" = "空格"; +"menu_bar_layout_token_line_break" = "換行"; +"menu_bar_layout_token_separator_accessibility" = "分隔點"; + +/* Menu bar layout accessibility */ +"Icon unavailable" = "圖示: 無法使用"; +"%@ icon" = "%@: 圖示"; +"Provider name unavailable" = "供應商名稱: 無法使用"; +"Account unavailable" = "帳號: 無法使用"; +"%@ unavailable" = "%@: 無法使用"; +"%@ %@" = "%@ %@"; +"Usage bar unavailable" = "用量列: 無法使用"; +"Usage bar, %d of 3 filled" = "用量列: %d/3 已填滿"; +"Reset countdown unavailable" = "重設倒數: 無法使用"; +"Reset time unavailable" = "重設時間: 無法使用"; +"Run-out estimate unavailable" = "預計用盡: 無法使用"; +"Cost today unavailable" = "今日費用: 無法使用"; +"30-day cost unavailable" = "30 天費用: 無法使用"; +"Resets" = "重設"; diff --git a/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.stringsdict b/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.stringsdict new file mode 100644 index 0000000000..992c83cf84 --- /dev/null +++ b/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.stringsdict @@ -0,0 +1,49 @@ + + + + + ≈%d full 5h windows of weekly left · %d windows until reset + + NSStringLocalizedFormatKey + %#@left@ · %#@until@ + left + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + d + one + 每週額度約剩 %d 個完整 5 小時視窗 + other + 每週額度約剩 %d 個完整 5 小時視窗 + + until + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + d + one + 距重置還有 %d 個視窗 + other + 距重置還有 %d 個視窗 + + + Weekly can run out ≈%d windows early + + NSStringLocalizedFormatKey + %#@early@ + early + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + d + one + 每週額度可能提前約 %d 個視窗用完 + other + 每週額度可能提前約 %d 個視窗用完 + + + + diff --git a/Sources/CodexBar/ScreenConfettiOverlayController.swift b/Sources/CodexBar/ScreenConfettiOverlayController.swift index acacceb5e7..b6544bad42 100644 --- a/Sources/CodexBar/ScreenConfettiOverlayController.swift +++ b/Sources/CodexBar/ScreenConfettiOverlayController.swift @@ -11,7 +11,7 @@ final class ScreenConfettiOverlayController { private var windows: [NSWindow] = [] private var dismissalTask: Task? - func play(originInScreen origin: CGPoint?) { + func play(originInScreen origin: CGPoint?, colors: [ProviderColor]) { guard self.windows.isEmpty else { self.logger.debug("Ignoring confetti trigger while overlay is already active") return @@ -23,7 +23,9 @@ final class ScreenConfettiOverlayController { return } - let palette = Self.randomPalette() + let palette = colors.map { color in + Color(red: color.red, green: color.green, blue: color.blue) + } self.windows = screens.map { screen in let frame = screen.frame let localOrigin = Self.localOrigin(in: frame, from: origin) @@ -99,17 +101,6 @@ final class ScreenConfettiOverlayController { x: min(max(resolved.x, insetFrame.minX), insetFrame.maxX) - screenFrame.minX, y: min(max(resolved.y, insetFrame.minY), insetFrame.maxY) - screenFrame.minY) } - - private static func randomPalette() -> [Color] { - let hue = Double.random(in: 0...1) - let hueOffsets = [0.0, 0.08, 0.16, 0.5, 0.66, 0.83] - return hueOffsets.map { offset in - Color( - hue: (hue + offset).truncatingRemainder(dividingBy: 1), - saturation: Double.random(in: 0.55...0.95), - brightness: Double.random(in: 0.85...1)) - } - } } private final class ClickThroughOverlayPanel: NSPanel { diff --git a/Sources/CodexBar/SessionEquivalentForecast.swift b/Sources/CodexBar/SessionEquivalentForecast.swift new file mode 100644 index 0000000000..c89f048d3f --- /dev/null +++ b/Sources/CodexBar/SessionEquivalentForecast.swift @@ -0,0 +1,496 @@ +import CodexBarCore +import Foundation + +struct SessionEquivalentBurnEstimate: Equatable, Sendable { + let medianWeeklyPercentPerWindow: Double + let sampleCount: Int +} + +struct SessionEquivalentForecast: Equatable, Sendable { + static let sessionWindowMinutes = 300 + static let weeklyWindowMinutes = 10080 + static let resetTolerance: TimeInterval = 2 * 60 + + let estimatedWindowsToExhaustWeekly: Double + let windowsUntilReset: Int + let availableWindowsUntilReset: Double + let sampleCount: Int + let weeklyResetsAt: Date + let weeklyUsedPercent: Double + let weeklyWindowID: String? + + init( + estimatedWindowsToExhaustWeekly: Double, + windowsUntilReset: Int, + availableWindowsUntilReset: Double? = nil, + sampleCount: Int, + weeklyResetsAt: Date, + weeklyUsedPercent: Double, + weeklyWindowID: String? = nil) + { + self.estimatedWindowsToExhaustWeekly = estimatedWindowsToExhaustWeekly + self.windowsUntilReset = windowsUntilReset + self.availableWindowsUntilReset = availableWindowsUntilReset ?? Double(windowsUntilReset) + self.sampleCount = sampleCount + self.weeklyResetsAt = weeklyResetsAt + self.weeklyUsedPercent = weeklyUsedPercent + self.weeklyWindowID = weeklyWindowID + } + + static func make( + sessionWindow: RateWindow, + weeklyWindow: RateWindow, + burnEstimate: SessionEquivalentBurnEstimate, + weeklyWindowID: String? = nil, + now: Date, + workDays: Int?, + calendar: Calendar = .current) -> Self? + { + guard !sessionWindow.isSyntheticPlaceholder, + sessionWindow.windowMinutes.map({ PlanUtilizationSeriesName.session.canonicalWindowMinutes($0) }) + == self.sessionWindowMinutes, + weeklyWindow.windowMinutes.map({ PlanUtilizationSeriesName.weekly.canonicalWindowMinutes($0) }) + == self.weeklyWindowMinutes, + let weeklyResetsAt = weeklyWindow.resetsAt, + weeklyWindow.usedPercent.isFinite, + (0...100).contains(weeklyWindow.usedPercent), + burnEstimate.medianWeeklyPercentPerWindow.isFinite, + burnEstimate.medianWeeklyPercentPerWindow > 0, + burnEstimate.sampleCount >= SessionEquivalentBurnEstimator.minimumSampleCount + else { + return nil + } + + let sessionSeconds = TimeInterval(Self.sessionWindowMinutes * 60) + let weeklySeconds = TimeInterval(Self.weeklyWindowMinutes * 60) + if let sessionResetsAt = sessionWindow.resetsAt { + let sessionRemaining = sessionResetsAt.timeIntervalSince(now) + guard sessionRemaining.isFinite, + sessionRemaining > 0, + sessionRemaining <= sessionSeconds + Self.resetTolerance + else { + return nil + } + } + + let weeklyRemaining = weeklyResetsAt.timeIntervalSince(now) + guard weeklyRemaining.isFinite, + weeklyRemaining > 0, + weeklyRemaining <= weeklySeconds + Self.resetTolerance + else { + return nil + } + + let remainingWeeklyPercent = (100 - weeklyWindow.usedPercent).clamped(to: 0...100) + guard remainingWeeklyPercent > 0 else { return nil } + let estimatedWindows = remainingWeeklyPercent / burnEstimate.medianWeeklyPercentPerWindow + guard estimatedWindows.isFinite, estimatedWindows >= 0 else { return nil } + + let remainingSeconds = Self.effectiveRemainingSeconds( + from: now, + to: weeklyResetsAt, + workDays: workDays, + calendar: calendar) + guard remainingSeconds >= 0 else { return nil } + let availableWindowsUntilReset = remainingSeconds / sessionSeconds + let windowsUntilReset = Int(floor(availableWindowsUntilReset)) + + return Self( + estimatedWindowsToExhaustWeekly: estimatedWindows, + windowsUntilReset: windowsUntilReset, + availableWindowsUntilReset: availableWindowsUntilReset, + sampleCount: burnEstimate.sampleCount, + weeklyResetsAt: weeklyResetsAt, + weeklyUsedPercent: weeklyWindow.usedPercent, + weeklyWindowID: weeklyWindowID) + } + + func applies(to weeklyWindow: RateWindow, windowID: String?) -> Bool { + guard weeklyWindow.windowMinutes.map({ PlanUtilizationSeriesName.weekly.canonicalWindowMinutes($0) }) + == Self.weeklyWindowMinutes, + let resetsAt = weeklyWindow.resetsAt + else { + return false + } + return self.weeklyWindowID == windowID + && abs(resetsAt.timeIntervalSince(self.weeklyResetsAt)) < 2 * 60 + && abs(weeklyWindow.usedPercent - self.weeklyUsedPercent) < 0.001 + } + + private static func effectiveRemainingSeconds( + from now: Date, + to resetsAt: Date, + workDays: Int?, + calendar: Calendar) -> TimeInterval + { + let wallClockSeconds = max(0, resetsAt.timeIntervalSince(now)) + guard let workDays, workDays >= 2, workDays < 7 else { return wallClockSeconds } + + var workSeconds: TimeInterval = 0 + var cursor = now + while cursor < resetsAt { + guard let nextDay = calendar.date( + byAdding: .day, + value: 1, + to: calendar.startOfDay(for: cursor)), + nextDay > cursor + else { + return wallClockSeconds + } + let sliceEnd = min(nextDay, resetsAt) + if Self.isWorkday(cursor, workDays: workDays, calendar: calendar) { + workSeconds += sliceEnd.timeIntervalSince(cursor) + } + cursor = sliceEnd + } + return workSeconds + } + + private static func isWorkday(_ date: Date, workDays: Int, calendar: Calendar) -> Bool { + let weekday = calendar.component(.weekday, from: date) + let isoWeekday = weekday == 1 ? 7 : weekday - 1 + return isoWeekday <= workDays + } +} + +enum SessionEquivalentBurnEstimator { + static let defaultSampleLimit = 7 + static let minimumSampleCount = 3 + private static let observationAlignmentTolerance: TimeInterval = 0 + private static let resetEquivalenceTolerance = SessionEquivalentForecast.resetTolerance + + private struct SessionGroup { + let resetsAt: Date + var entries: [PlanUtilizationHistoryEntry] + var maximumUsedPercent: Double + } + + private struct BurnObservation { + let sessionUsedPercent: Double + let weeklyEntry: PlanUtilizationHistoryEntry + } + + static func estimate( + histories: [PlanUtilizationSeriesHistory], + currentSessionResetsAt: Date?, + now: Date, + sampleLimit: Int = Self.defaultSampleLimit) -> SessionEquivalentBurnEstimate? + { + guard sampleLimit > 0, + let sessionHistory = histories.first(where: { + $0.name == .session + && $0.name.canonicalWindowMinutes($0.windowMinutes) + == SessionEquivalentForecast.sessionWindowMinutes + }), + let weeklyHistory = histories.first(where: { + $0.name == .weekly + && $0.name.canonicalWindowMinutes($0.windowMinutes) + == SessionEquivalentForecast.weeklyWindowMinutes + }) + else { + return nil + } + + let sessionDuration = TimeInterval(SessionEquivalentForecast.sessionWindowMinutes * 60) + let weeklyDuration = TimeInterval(SessionEquivalentForecast.weeklyWindowMinutes * 60) + guard Self.isChronologicallyOrdered(sessionHistory.entries), + Self.isChronologicallyOrdered(weeklyHistory.entries) + else { + return nil + } + if let currentSessionResetsAt { + let currentSessionRemaining = currentSessionResetsAt.timeIntervalSince(now) + guard currentSessionRemaining.isFinite, + currentSessionRemaining > 0, + currentSessionRemaining <= sessionDuration + Self.resetEquivalenceTolerance + else { + return nil + } + } + + var groups: [SessionGroup] = [] + groups.reserveCapacity(sessionHistory.entries.count) + for entry in sessionHistory.entries { + guard entry.usedPercent.isFinite, + (0...100).contains(entry.usedPercent), + let resetsAt = entry.resetsAt, + Self.isPlausibleReset( + resetsAt, + capturedAt: entry.capturedAt, + duration: sessionDuration) + else { + continue + } + if let lastIndex = groups.indices.last, + abs(groups[lastIndex].resetsAt.timeIntervalSince(resetsAt)) <= Self.resetEquivalenceTolerance + { + groups[lastIndex].entries.append(entry) + groups[lastIndex].maximumUsedPercent = max(groups[lastIndex].maximumUsedPercent, entry.usedPercent) + } else { + guard groups.last.map({ $0.resetsAt <= resetsAt }) ?? true else { return nil } + groups.append(SessionGroup( + resetsAt: resetsAt, + entries: [entry], + maximumUsedPercent: entry.usedPercent)) + } + } + + let completedActiveGroups = groups.reversed().compactMap { group -> SessionGroup? in + let precedesCurrentSession = currentSessionResetsAt.map { + group.resetsAt < $0.addingTimeInterval(-Self.resetEquivalenceTolerance) + } ?? true + guard precedesCurrentSession, + group.resetsAt <= now, + group.maximumUsedPercent > 0 + else { + return nil + } + return group + } + + let weeklyEntries = weeklyHistory.entries.filter { entry in + entry.usedPercent.isFinite + && (0...100).contains(entry.usedPercent) + && entry.resetsAt.map { + Self.isPlausibleReset($0, capturedAt: entry.capturedAt, duration: weeklyDuration) + } == true + } + guard !weeklyEntries.isEmpty else { return nil } + + var burns: [Double] = [] + let candidateGroups = completedActiveGroups.prefix(sampleLimit) + burns.reserveCapacity(candidateGroups.count) + for group in candidateGroups { + guard let fullAllowanceBurn = Self.normalizedBurn( + for: group, + weeklyEntries: weeklyEntries, + sessionDuration: sessionDuration) + else { continue } + burns.append(fullAllowanceBurn) + } + + guard burns.count >= Self.minimumSampleCount else { return nil } + burns.sort() + let middle = burns.count / 2 + let median = burns.count.isMultiple(of: 2) + ? (burns[middle - 1] + burns[middle]) / 2 + : burns[middle] + guard median.isFinite, median > 0 else { return nil } + return SessionEquivalentBurnEstimate( + medianWeeklyPercentPerWindow: median, + sampleCount: burns.count) + } + + private static func normalizedBurn( + for group: SessionGroup, + weeklyEntries: [PlanUtilizationHistoryEntry], + sessionDuration: TimeInterval) -> Double? + { + guard let firstSessionEntry = group.entries.first, + let lastSessionEntry = group.entries.last + else { + return nil + } + + var observations: [BurnObservation] = [] + let windowStart = group.resetsAt.addingTimeInterval(-sessionDuration) + if let weeklyStart = Self.nearestEntry( + to: windowStart, + entries: weeklyEntries, + tolerance: Self.resetEquivalenceTolerance, + requireNotAfterTarget: true), + weeklyStart.capturedAt <= windowStart, + weeklyStart.capturedAt < firstSessionEntry.capturedAt + { + observations.append(BurnObservation(sessionUsedPercent: 0, weeklyEntry: weeklyStart)) + } + + for sessionEntry in group.entries { + guard let weeklyEntry = Self.nearestEntry( + to: sessionEntry.capturedAt, + entries: weeklyEntries, + tolerance: Self.observationAlignmentTolerance) + else { + continue + } + observations.append(BurnObservation( + sessionUsedPercent: sessionEntry.usedPercent, + weeklyEntry: weeklyEntry)) + } + + if group.maximumUsedPercent >= 100, + let weeklyEnd = Self.nearestEntry( + to: group.resetsAt, + entries: weeklyEntries, + tolerance: Self.resetEquivalenceTolerance, + requireNotAfterTarget: true), + weeklyEnd.capturedAt <= group.resetsAt, + lastSessionEntry.capturedAt < weeklyEnd.capturedAt + { + observations.append(BurnObservation(sessionUsedPercent: 100, weeklyEntry: weeklyEnd)) + } + + observations.sort { lhs, rhs in + if lhs.weeklyEntry.capturedAt != rhs.weeklyEntry.capturedAt { + return lhs.weeklyEntry.capturedAt < rhs.weeklyEntry.capturedAt + } + return lhs.sessionUsedPercent < rhs.sessionUsedPercent + } + guard let start = observations.first, + let end = observations.last, + start.weeklyEntry.capturedAt < end.weeklyEntry.capturedAt, + let startReset = start.weeklyEntry.resetsAt, + let endReset = end.weeklyEntry.resetsAt, + abs(startReset.timeIntervalSince(endReset)) <= Self.resetEquivalenceTolerance + else { + return nil + } + + let sessionConsumption = end.sessionUsedPercent - start.sessionUsedPercent + let weeklyBurn = end.weeklyEntry.usedPercent - start.weeklyEntry.usedPercent + guard sessionConsumption.isFinite, + sessionConsumption > 0, + weeklyBurn.isFinite, + weeklyBurn > 0 + else { + return nil + } + let fullAllowanceBurn = 100 * weeklyBurn / sessionConsumption + guard fullAllowanceBurn.isFinite, fullAllowanceBurn > 0 else { return nil } + return fullAllowanceBurn + } + + private static func nearestEntry( + to target: Date, + entries: [PlanUtilizationHistoryEntry], + tolerance: TimeInterval, + requireNotAfterTarget: Bool = false) -> PlanUtilizationHistoryEntry? + { + var lower = 0 + var upper = entries.count + while lower < upper { + let middle = (lower + upper) / 2 + if entries[middle].capturedAt < target { + lower = middle + 1 + } else { + upper = middle + } + } + + var candidates: [PlanUtilizationHistoryEntry] = [] + if lower < entries.count { + candidates.append(entries[lower]) + } + if lower > 0 { + candidates.append(entries[lower - 1]) + } + return candidates + .filter { !requireNotAfterTarget || $0.capturedAt <= target } + .filter { abs($0.capturedAt.timeIntervalSince(target)) <= tolerance } + .min { lhs, rhs in + abs(lhs.capturedAt.timeIntervalSince(target)) < abs(rhs.capturedAt.timeIntervalSince(target)) + } + } + + private static func isChronologicallyOrdered(_ entries: [PlanUtilizationHistoryEntry]) -> Bool { + guard entries.allSatisfy(\.capturedAt.timeIntervalSinceReferenceDate.isFinite) else { return false } + return zip(entries, entries.dropFirst()).allSatisfy { pair in + pair.0.capturedAt <= pair.1.capturedAt + } + } + + private static func isPlausibleReset( + _ resetsAt: Date, + capturedAt: Date, + duration: TimeInterval) -> Bool + { + let remaining = resetsAt.timeIntervalSince(capturedAt) + return remaining.isFinite + && remaining >= -Self.resetEquivalenceTolerance + && remaining <= duration + Self.resetEquivalenceTolerance + } +} + +private struct SessionEquivalentBurnCacheKey: Equatable { + static let idleTimeBucketSeconds: TimeInterval = 60 + + let historyRevision: Int + let historySelectionIdentity: String + let currentSessionResetsAt: Date? + let weeklyWindowID: String? + let idleTimeBucket: Int64? +} + +struct SessionEquivalentBurnCacheEntry { + fileprivate let key: SessionEquivalentBurnCacheKey + fileprivate let estimate: SessionEquivalentBurnEstimate? +} + +@MainActor +extension UsageStore { + func sessionEquivalentForecast( + provider: UsageProvider, + sessionWindow: RateWindow, + weeklyWindow: RateWindow, + weeklyWindowID: String? = nil, + historyIdentity: String? = nil, + historySelection: PlanUtilizationHistorySelection? = nil, + now: Date = .init()) -> SessionEquivalentForecast? + { + guard sessionWindow.windowMinutes.map({ PlanUtilizationSeriesName.session.canonicalWindowMinutes($0) }) + == SessionEquivalentForecast.sessionWindowMinutes + else { + return nil + } + let currentSessionResetsAt = sessionWindow.resetsAt + guard currentSessionResetsAt?.timeIntervalSinceReferenceDate.isFinite ?? true else { + return nil + } + + let selection = historySelection ?? self.planUtilizationHistorySelection(for: provider) + guard self.sessionEquivalentHistoryIdentityMatches( + provider: provider, + accountKey: selection.accountKey, + historyIdentity: historyIdentity) + else { + return nil + } + let cacheKey = SessionEquivalentBurnCacheKey( + historyRevision: self.planUtilizationHistoryRevision, + historySelectionIdentity: selection.cacheIdentity, + currentSessionResetsAt: currentSessionResetsAt, + weeklyWindowID: weeklyWindowID, + idleTimeBucket: currentSessionResetsAt == nil + ? Int64(floor(now.timeIntervalSinceReferenceDate / + SessionEquivalentBurnCacheKey.idleTimeBucketSeconds)) + : nil) + let burnEstimate: SessionEquivalentBurnEstimate? + if let cached = self.sessionEquivalentBurnCache[provider], cached.key == cacheKey { + burnEstimate = cached.estimate + } else { + burnEstimate = SessionEquivalentBurnEstimator.estimate( + histories: selection.histories, + currentSessionResetsAt: currentSessionResetsAt, + now: now) + self.sessionEquivalentHistoryScanCount &+= 1 + self.sessionEquivalentBurnCache[provider] = SessionEquivalentBurnCacheEntry( + key: cacheKey, + estimate: burnEstimate) + } + + guard let burnEstimate else { return nil } + return SessionEquivalentForecast.make( + sessionWindow: sessionWindow, + weeklyWindow: weeklyWindow, + burnEstimate: burnEstimate, + weeklyWindowID: weeklyWindowID, + now: now, + workDays: self.settings.weeklyProgressWorkDays) + } + + #if DEBUG + var _sessionEquivalentHistoryScanCountForTesting: Int { + self.sessionEquivalentHistoryScanCount + } + #endif +} diff --git a/Sources/CodexBar/SessionQuotaNotifications.swift b/Sources/CodexBar/SessionQuotaNotifications.swift index 3ea0ada276..d1b0f6dd0d 100644 --- a/Sources/CodexBar/SessionQuotaNotifications.swift +++ b/Sources/CodexBar/SessionQuotaNotifications.swift @@ -9,22 +9,102 @@ enum SessionQuotaTransition: Equatable { case restored } +struct SessionQuotaTransitionState: Equatable { + let remaining: Double + let source: UsageStore.SessionQuotaWindowSource + let observedAt: Date + let codexOwnerKey: CodexSessionQuotaOwnerKey? + let trustedResetBoundary: Date? + let pendingCodexRestoreObservationAt: Date? + + func advancingObservationWatermark(to observedAt: Date) -> Self { + guard observedAt > self.observedAt else { return self } + return Self( + remaining: self.remaining, + source: self.source, + observedAt: observedAt, + codexOwnerKey: self.codexOwnerKey, + trustedResetBoundary: self.trustedResetBoundary, + pendingCodexRestoreObservationAt: self.pendingCodexRestoreObservationAt) + } +} + +struct CodexSessionQuotaBaselineRequirement: Equatable { + let observedAtWatermark: Date? + + func merging(observedAt: Date?) -> Self { + guard let observedAt else { return self } + guard let watermark = self.observedAtWatermark else { + return Self(observedAtWatermark: observedAt) + } + return Self(observedAtWatermark: max(watermark, observedAt)) + } + + func admits(observedAt: Date) -> Bool { + self.observedAtWatermark.map { observedAt > $0 } ?? true + } +} + +enum SessionQuotaTransitionOutcome: Equatable { + case none + case depleted + case restored + case baselineChanged + case staleCodexObservation + case suppressedCodexRestore + case awaitingCodexRestoreConfirmation + + var transition: SessionQuotaTransition { + switch self { + case .depleted: .depleted + case .restored: .restored + default: .none + } + } +} + +struct SessionQuotaTransitionEvaluation: Equatable { + let outcome: SessionQuotaTransitionOutcome + let state: SessionQuotaTransitionState +} + +struct SessionQuotaTransitionObservation: Equatable { + let provider: UsageProvider + let remaining: Double + let source: UsageStore.SessionQuotaWindowSource + let resetBoundary: Date? + let observedAt: Date + let evaluationTime: Date + let codexOwnerKey: CodexSessionQuotaOwnerKey? +} + struct QuotaWarningEvent: Equatable { let window: QuotaWarningWindow let threshold: Int let currentRemaining: Double let accountDisplayName: String? + /// Stable id of the extra rate window this warning is for (e.g. `claude-weekly-scoped-fable`), + /// used to keep OS notification ids unique across sibling windows. `nil` for the primary + /// session/weekly lanes. + let windowID: String? + /// Human-facing window label to render instead of the generic session/weekly name + /// (e.g. "Fable only", "Daily Routines"). `nil` falls back to the localized lane name. + let windowDisplayLabel: String? init( window: QuotaWarningWindow, threshold: Int, currentRemaining: Double, - accountDisplayName: String? = nil) + accountDisplayName: String? = nil, + windowID: String? = nil, + windowDisplayLabel: String? = nil) { self.window = window self.threshold = threshold self.currentRemaining = currentRemaining self.accountDisplayName = accountDisplayName + self.windowID = windowID + self.windowDisplayLabel = windowDisplayLabel } } @@ -43,8 +123,12 @@ enum SessionQuotaNotificationLogic { let wasDepleted = previousRemaining <= Self.depletedThreshold let isDepleted = currentRemaining <= Self.depletedThreshold - if !wasDepleted, isDepleted { return .depleted } - if wasDepleted, !isDepleted { return .restored } + if !wasDepleted, isDepleted { + return .depleted + } + if wasDepleted, !isDepleted { + return .restored + } return .none } @@ -67,15 +151,204 @@ enum SessionQuotaNotificationLogic { } } +enum SessionQuotaTransitionReducer { + static func evaluate( + previous: SessionQuotaTransitionState?, + observation: SessionQuotaTransitionObservation, + notificationsEnabled: Bool, + forceBaseline: Bool = false) -> SessionQuotaTransitionEvaluation + { + if forceBaseline { + return SessionQuotaTransitionEvaluation( + outcome: .baselineChanged, + state: self.baselineState(observation: observation)) + } + guard let previous else { + return SessionQuotaTransitionEvaluation( + outcome: notificationsEnabled && SessionQuotaNotificationLogic.isDepleted(observation.remaining) + ? .depleted + : .none, + state: self.baselineState(observation: observation)) + } + + let ownerChanged = observation.provider == .codex && previous.codexOwnerKey != observation.codexOwnerKey + guard previous.source == observation.source, !ownerChanged else { + return SessionQuotaTransitionEvaluation( + outcome: .baselineChanged, + state: Self.baselineState(observation: observation)) + } + + if observation.provider == .codex, observation.observedAt <= previous.observedAt { + return SessionQuotaTransitionEvaluation(outcome: .staleCodexObservation, state: previous) + } + + guard notificationsEnabled else { + return SessionQuotaTransitionEvaluation( + outcome: .none, + state: Self.updatedState( + previous: previous, + observation: observation)) + } + + let transition = SessionQuotaNotificationLogic.transition( + previousRemaining: previous.remaining, + currentRemaining: observation.remaining) + if transition != .restored || observation.provider != .codex { + let outcome: SessionQuotaTransitionOutcome = switch transition { + case .none: .none + case .depleted: .depleted + case .restored: .restored + } + let preserveDepletedBoundary = observation.provider == .codex && + previous.trustedResetBoundary != nil && + SessionQuotaNotificationLogic.isDepleted(previous.remaining) && + SessionQuotaNotificationLogic.isDepleted(observation.remaining) + let preserveCodexBoundary = preserveDepletedBoundary || + (observation.provider == .codex && previous.trustedResetBoundary.map { + observation.evaluationTime < $0 || observation.observedAt < $0 + } == true) + return SessionQuotaTransitionEvaluation( + outcome: outcome, + state: Self.updatedState( + previous: previous, + observation: observation, + preserveCodexResetBoundary: preserveCodexBoundary)) + } + + if let trustedResetBoundary = previous.trustedResetBoundary { + // The prior depleted boundary is authoritative while it remains in the future. A transient + // positive sample must not replace it, even when that sample advertises an advanced boundary. + guard observation.evaluationTime >= trustedResetBoundary, + observation.observedAt >= trustedResetBoundary + else { + return SessionQuotaTransitionEvaluation( + outcome: .suppressedCodexRestore, + state: Self.preservedDepletedState( + previous: previous, + observation: observation)) + } + + if let resetBoundary = self.validResetBoundary( + observation.resetBoundary, + observedAt: observation.observedAt, + evaluationTime: observation.evaluationTime), + !UsageStore.areEquivalentPlanUtilizationResetBoundaries(trustedResetBoundary, resetBoundary) + { + if resetBoundary > trustedResetBoundary { + return SessionQuotaTransitionEvaluation( + outcome: .restored, + state: Self.updatedState( + previous: previous, + observation: observation)) + } + } + } + + // Missing, equivalent, regressed, or already elapsed metadata can be a stale post-reset snapshot. + // Two fresh positive observations confirm the restore without trusting one ambiguous sample. + if let pending = previous.pendingCodexRestoreObservationAt, observation.observedAt > pending { + return SessionQuotaTransitionEvaluation( + outcome: .restored, + state: Self.updatedState( + previous: previous, + observation: observation)) + } + return SessionQuotaTransitionEvaluation( + outcome: .awaitingCodexRestoreConfirmation, + state: Self.preservedDepletedState( + previous: previous, + observation: observation, + pendingRestoreObservationAt: observation.observedAt)) + } + + private static func baselineState( + observation: SessionQuotaTransitionObservation) -> SessionQuotaTransitionState + { + SessionQuotaTransitionState( + remaining: observation.remaining, + source: observation.source, + observedAt: observation.observedAt, + codexOwnerKey: observation.provider == .codex ? observation.codexOwnerKey : nil, + trustedResetBoundary: observation.provider == .codex + ? self.validResetBoundary( + observation.resetBoundary, + observedAt: observation.observedAt, + evaluationTime: observation.evaluationTime) + : nil, + pendingCodexRestoreObservationAt: nil) + } + + private static func updatedState( + previous: SessionQuotaTransitionState, + observation: SessionQuotaTransitionObservation, + preserveCodexResetBoundary: Bool = false) -> SessionQuotaTransitionState + { + let trustedResetBoundary: Date? = if observation.provider != .codex { + nil + } else if preserveCodexResetBoundary { + previous.trustedResetBoundary + } else { + self.monotonicResetBoundary( + previous: previous.trustedResetBoundary, + current: self.validResetBoundary( + observation.resetBoundary, + observedAt: observation.observedAt, + evaluationTime: observation.evaluationTime)) + } + return SessionQuotaTransitionState( + remaining: observation.remaining, + source: observation.source, + observedAt: observation.observedAt, + codexOwnerKey: observation.provider == .codex ? observation.codexOwnerKey : nil, + trustedResetBoundary: trustedResetBoundary, + pendingCodexRestoreObservationAt: nil) + } + + private static func preservedDepletedState( + previous: SessionQuotaTransitionState, + observation: SessionQuotaTransitionObservation, + pendingRestoreObservationAt: Date? = nil) -> SessionQuotaTransitionState + { + SessionQuotaTransitionState( + remaining: previous.remaining, + source: observation.source, + observedAt: observation.observedAt, + codexOwnerKey: observation.codexOwnerKey, + trustedResetBoundary: previous.trustedResetBoundary, + pendingCodexRestoreObservationAt: pendingRestoreObservationAt) + } + + private static func monotonicResetBoundary(previous: Date?, current: Date?) -> Date? { + guard let previous else { return current } + guard UsageStore.limitResetBoundaryAdvanced(previous: previous, current: current) else { return previous } + return current + } + + private static func validResetBoundary( + _ candidate: Date?, + observedAt: Date, + evaluationTime: Date) -> Date? + { + guard let candidate, candidate > observedAt, candidate > evaluationTime else { return nil } + return candidate + } +} + enum QuotaWarningNotificationLogic { + static func notificationIDPrefix(provider: UsageProvider, event: QuotaWarningEvent) -> String { + let windowSegment = event.windowID.map { "-\($0)" } ?? "" + return "quota-warning-\(provider.rawValue)-\(event.window.rawValue)\(windowSegment)-\(event.threshold)" + } + static func notificationCopy( providerName: String, window: QuotaWarningWindow, threshold: Int, currentRemaining: Double, - accountDisplayName: String? = nil) -> (title: String, body: String) + accountDisplayName: String? = nil, + windowDisplayLabel: String? = nil) -> (title: String, body: String) { - let windowLabel = window.localizedNotificationDisplayName + let windowLabel = windowDisplayLabel ?? window.localizedNotificationDisplayName let remainingText = Self.percentText(currentRemaining) let title = L("quota_warning_notification_title", providerName, windowLabel) let body = if let accountDisplayName { @@ -128,15 +401,138 @@ enum QuotaWarningNotificationLogic { } } +@MainActor +extension UsageStore { + func sessionQuotaWindow( + provider: UsageProvider, + snapshot: UsageSnapshot) -> (window: RateWindow, source: SessionQuotaWindowSource)? + { + // MiMo/Qoder balances are never session quotas. Crof is handled below so quota-backed + // Crof snapshots can still participate when a real request-quota window is present. + guard provider != .mimo, provider != .qoder else { return nil } + if provider == .antigravity { + guard let window = Self.antigravityWindow(snapshot: snapshot, windowMinutes: 5 * 60) else { + return nil + } + let source: SessionQuotaWindowSource = Self.hasAntigravityQuotaSummaryWindows(snapshot: snapshot) + ? .antigravityQuotaSummary + : .antigravityLegacy + return (window, source) + } + // z.ai's typed sessionTokenLimit is rendered in the tertiary lane when the response also + // contains its weekly token limit and MCP time limit. Prefer that semantic session lane. + if provider == .zai, let tertiary = snapshot.tertiary { + return (tertiary, .zaiTertiary) + } + if let primary = snapshot.primary, Self.isSessionWindow(primary) { + // Crof credits-only balances publish a duration-less primary with no secondary quota + // window. Keep that PAYG shape out of session-quota transitions so a $0 balance cannot + // fire session-limit alerts/hooks. Quota-backed Crof (secondary credits) still qualifies. + if provider == .crof, snapshot.secondary == nil { + return nil + } + return (primary, .primary) + } + if provider == .copilot, let secondary = snapshot.secondary { + return (secondary, .copilotSecondaryFallback) + } + return nil + } + + private static func isSessionWindow(_ window: RateWindow) -> Bool { + guard let minutes = window.windowMinutes else { return true } + return minutes <= 6 * 60 + } + + func clearSessionQuotaTransitionState(provider: UsageProvider) { + let removedState = self.sessionQuotaTransitionStates.removeValue(forKey: provider) + // Generic provider cleanup can run while Codex is disabled or temporarily unavailable. Preserve + // an already-depleted baseline across recovery so depletion cannot refire, but let a newly depleted + // account notify after a positive baseline was discarded. + if provider == .codex, + let removedState, + SessionQuotaNotificationLogic.isDepleted(removedState.remaining) + { + self.updateCodexSessionQuotaBaselineRequirement(observedAt: removedState.observedAt) + } + } + + func requireFreshCodexSessionQuotaBaseline(observedAt: Date? = nil) { + let removedState = self.sessionQuotaTransitionStates.removeValue(forKey: .codex) + self.updateCodexSessionQuotaBaselineRequirement(observedAt: removedState?.observedAt) + self.updateCodexSessionQuotaBaselineRequirement(observedAt: observedAt) + } + + private func updateCodexSessionQuotaBaselineRequirement(observedAt: Date?) { + let requirement = self.codexSessionQuotaBaselineRequirement ?? + CodexSessionQuotaBaselineRequirement(observedAtWatermark: nil) + self.codexSessionQuotaBaselineRequirement = requirement.merging(observedAt: observedAt) + } + + private static let antigravityQuotaSummaryWindowIDPrefix = "antigravity-quota-summary-" + + static func hasAntigravityQuotaSummaryWindows(snapshot: UsageSnapshot) -> Bool { + snapshot.extraRateWindows?.contains { + $0.id.hasPrefix(Self.antigravityQuotaSummaryWindowIDPrefix) + } == true + } + + static func antigravityWindow( + snapshot: UsageSnapshot, + windowMinutes: Int) -> RateWindow? + { + let windows: [RateWindow] = if Self.hasAntigravityQuotaSummaryWindows(snapshot: snapshot) { + snapshot.extraRateWindows? + .filter { + $0.usageKnown + && $0.id.hasPrefix(Self.antigravityQuotaSummaryWindowIDPrefix) + && $0.window.windowMinutes == windowMinutes + } + .map(\.window) ?? [] + } else { + [snapshot.primary, snapshot.secondary, snapshot.tertiary] + .compactMap(\.self) + .filter { + // Legacy Antigravity family lanes historically drive session notifications. + $0.windowMinutes == windowMinutes + || (windowMinutes == 5 * 60 && $0.windowMinutes == nil) + } + } + return windows.max { $0.usedPercent < $1.usedPercent } + } +} + @MainActor protocol SessionQuotaNotifying: AnyObject { func post(transition: SessionQuotaTransition, provider: UsageProvider, badge: NSNumber?) - func postQuotaWarning(event: QuotaWarningEvent, provider: UsageProvider, soundEnabled: Bool) + func postQuotaWarning( + event: QuotaWarningEvent, + provider: UsageProvider, + soundEnabled: Bool, + onScreenAlertEnabled: Bool) + func postPredictivePaceWarning( + event: PredictivePaceWarningEvent, + provider: UsageProvider, + soundEnabled: Bool, + onScreenAlertEnabled: Bool, + now: Date) +} + +@MainActor +extension SessionQuotaNotifying { + func postPredictivePaceWarning( + event _: PredictivePaceWarningEvent, + provider _: UsageProvider, + soundEnabled _: Bool, + onScreenAlertEnabled _: Bool, + now _: Date) + {} } @MainActor final class SessionQuotaNotifier: SessionQuotaNotifying { private let logger = CodexBarLog.logger(LogCategories.sessionQuotaNotifications) + private lazy var alertOverlay = QuotaWarningAlertOverlayController() init() {} @@ -156,7 +552,12 @@ final class SessionQuotaNotifier: SessionQuotaNotifying { AppNotifications.shared.post(idPrefix: idPrefix, title: title, body: body, badge: badge) } - func postQuotaWarning(event: QuotaWarningEvent, provider: UsageProvider, soundEnabled: Bool = true) { + func postQuotaWarning( + event: QuotaWarningEvent, + provider: UsageProvider, + soundEnabled: Bool = true, + onScreenAlertEnabled: Bool = false) + { let providerName = ProviderDescriptorRegistry.descriptor(for: provider).metadata.displayName let threshold = event.threshold let copy = QuotaWarningNotificationLogic.notificationCopy( @@ -164,12 +565,16 @@ final class SessionQuotaNotifier: SessionQuotaNotifying { window: event.window, threshold: threshold, currentRemaining: event.currentRemaining, - accountDisplayName: event.accountDisplayName) - let idPrefix = "quota-warning-\(provider.rawValue)-\(event.window.rawValue)-\(threshold)" + accountDisplayName: event.accountDisplayName, + windowDisplayLabel: event.windowDisplayLabel) + let idPrefix = QuotaWarningNotificationLogic.notificationIDPrefix(provider: provider, event: event) self.logger.info("enqueuing", metadata: ["prefix": idPrefix]) if soundEnabled { (NSSound(named: "Glass") ?? NSSound(named: "Ping"))?.play() } + if onScreenAlertEnabled { + self.alertOverlay.show(title: copy.title, message: copy.body) + } NotificationCenter.default.post( name: .codexbarQuotaWarningDidPost, object: QuotaWarningPostedEvent( @@ -179,10 +584,33 @@ final class SessionQuotaNotifier: SessionQuotaNotifying { postedAt: Date())) AppNotifications.shared.post(idPrefix: idPrefix, title: copy.title, body: copy.body, soundEnabled: false) } + + func postPredictivePaceWarning( + event: PredictivePaceWarningEvent, + provider: UsageProvider, + soundEnabled: Bool = true, + onScreenAlertEnabled: Bool = false, + now: Date = .init()) + { + let providerName = ProviderDescriptorRegistry.descriptor(for: provider).metadata.displayName + let copy = PredictivePaceWarningNotificationLogic.notificationCopy( + providerName: providerName, + event: event, + now: now) + let idPrefix = PredictivePaceWarningNotificationLogic.notificationIDPrefix(provider: provider, event: event) + self.logger.info("enqueuing", metadata: ["prefix": idPrefix]) + if soundEnabled { + (NSSound(named: "Glass") ?? NSSound(named: "Ping"))?.play() + } + if onScreenAlertEnabled { + self.alertOverlay.show(title: copy.title, message: copy.body) + } + AppNotifications.shared.post(idPrefix: idPrefix, title: copy.title, body: copy.body, soundEnabled: false) + } } extension QuotaWarningWindow { - fileprivate var localizedNotificationDisplayName: String { + var localizedNotificationDisplayName: String { switch self { case .session: L("quota_warning_session") case .weekly: L("quota_warning_weekly") diff --git a/Sources/CodexBar/SettingsStore+Config.swift b/Sources/CodexBar/SettingsStore+Config.swift index e4ef5d2aa3..70751376b3 100644 --- a/Sources/CodexBar/SettingsStore+Config.swift +++ b/Sources/CodexBar/SettingsStore+Config.swift @@ -16,6 +16,12 @@ extension SettingsStore { global: self.quotaWarningThresholds(window)) } + func explicitQuotaWarningThresholds(provider: UsageProvider, window: QuotaWarningWindow) -> [Int]? { + self.quotaWarningWindowConfig(provider: provider, window: window)? + .thresholds + .map(QuotaWarningThresholds.sanitized) + } + func quotaWarningEnabled(provider: UsageProvider, window: QuotaWarningWindow) -> Bool { self.quotaWarningConfig(for: provider).isEnabled( for: window, @@ -27,22 +33,47 @@ extension SettingsStore { } func setQuotaWarningThresholds(provider: UsageProvider, window: QuotaWarningWindow, thresholds: [Int]?) { + let sanitizedThresholds = thresholds.map(QuotaWarningThresholds.sanitized) + let currentThresholds = self.quotaWarningWindowConfig(provider: provider, window: window)? + .thresholds + .map(QuotaWarningThresholds.sanitized) + guard currentThresholds != sanitizedThresholds else { return } + self.updateProviderConfig(provider: provider) { entry in var config = entry.quotaWarnings ?? QuotaWarningConfig() switch window { case .session: var windowConfig = config.session ?? QuotaWarningWindowConfig() - windowConfig.thresholds = thresholds.map(QuotaWarningThresholds.sanitized) + windowConfig.thresholds = sanitizedThresholds config.session = windowConfig.hasOverride ? windowConfig : nil case .weekly: var windowConfig = config.weekly ?? QuotaWarningWindowConfig() - windowConfig.thresholds = thresholds.map(QuotaWarningThresholds.sanitized) + windowConfig.thresholds = sanitizedThresholds config.weekly = windowConfig.hasOverride ? windowConfig : nil } entry.quotaWarnings = config.isEmpty ? nil : config } } + func setQuotaWarningThresholdsIfOverridden( + provider: UsageProvider, + window: QuotaWarningWindow, + thresholds: [Int]?) + { + guard let windowConfig = self.quotaWarningWindowConfig(provider: provider, window: window), + windowConfig.hasOverride + else { return } + + let sanitizedThresholds = thresholds.map(QuotaWarningThresholds.sanitized) + let currentThresholds = windowConfig.thresholds.map(QuotaWarningThresholds.sanitized) + let inheritedThresholds = QuotaWarningThresholds.sanitized(self.quotaWarningThresholds(window)) + if currentThresholds == nil, sanitizedThresholds == inheritedThresholds { + return + } + + self.setQuotaWarningThresholds(provider: provider, window: window, thresholds: thresholds) + } + func setQuotaWarningOverride( provider: UsageProvider, window: QuotaWarningWindow, @@ -84,6 +115,42 @@ extension SettingsStore { } } + // MARK: - Hooks + + var hooksConfig: HooksConfig { + self.configSnapshot.hooks ?? HooksConfig() + } + + var hooksEnabled: Bool { + self.hooksConfig.enabled + } + + var hookRules: [HookRule] { + self.hooksConfig.events + } + + func setHooksEnabled(_ enabled: Bool) { + self.updateHooks { $0.enabled = enabled } + } + + func addHookRule(_ rule: HookRule) { + self.updateHooks { $0.events.append(rule) } + } + + func updateHookRule(_ rule: HookRule) { + self.updateHooks { config in + if let index = config.events.firstIndex(where: { $0.id == rule.id }) { + config.events[index] = rule + } + } + } + + func removeHookRule(id: String) { + self.updateHooks { config in + config.events.removeAll { $0.id == id } + } + } + var tokenAccountsByProvider: [UsageProvider: ProviderTokenAccountData] { get { Dictionary(uniqueKeysWithValues: self.configSnapshot.providers.compactMap { entry in @@ -97,6 +164,21 @@ extension SettingsStore { } } +extension SettingsStore { + private func quotaWarningWindowConfig( + provider: UsageProvider, + window: QuotaWarningWindow) -> QuotaWarningWindowConfig? + { + let config = self.quotaWarningConfig(for: provider) + switch window { + case .session: + return config.session + case .weekly: + return config.weekly + } + } +} + extension SettingsStore { func resolvedCookieSource( provider: UsageProvider, diff --git a/Sources/CodexBar/SettingsStore+ConfigPersistence.swift b/Sources/CodexBar/SettingsStore+ConfigPersistence.swift index 5b17761dd3..f519919dc1 100644 --- a/Sources/CodexBar/SettingsStore+ConfigPersistence.swift +++ b/Sources/CodexBar/SettingsStore+ConfigPersistence.swift @@ -4,48 +4,48 @@ import Foundation private enum ConfigChangeOrigin { case localUser case externalSync - case reload } private struct ConfigChangeContext { let origin: ConfigChangeOrigin let reason: String + let affectsBackgroundWork: Bool - static func local(reason: String) -> Self { - Self(origin: .localUser, reason: reason) + static func local(reason: String, affectsBackgroundWork: Bool) -> Self { + Self(origin: .localUser, reason: reason, affectsBackgroundWork: affectsBackgroundWork) } - static func external(reason: String) -> Self { - Self(origin: .externalSync, reason: reason) - } - - static func reload(reason: String) -> Self { - Self(origin: .reload, reason: reason) + static func external(reason: String, affectsBackgroundWork: Bool) -> Self { + Self(origin: .externalSync, reason: reason, affectsBackgroundWork: affectsBackgroundWork) } var shouldBroadcast: Bool { switch self.origin { case .localUser: true - case .externalSync, .reload: + case .externalSync: false } } } extension SettingsStore { - private func updateConfig(reason: String, mutate: (inout CodexBarConfig) -> Void) { + private func updateConfig( + reason: String, + affectsBackgroundWork: Bool, + mutate: (inout CodexBarConfig) -> Void) + { guard !self.configLoading else { return } var config = self.config mutate(&config) self.config = config.normalized() self.updateProviderState(config: self.config) self.schedulePersistConfig() - self.bumpConfigRevision(.local(reason: reason)) + self.bumpConfigRevision(.local(reason: reason, affectsBackgroundWork: affectsBackgroundWork)) } func updateProviderConfig(provider: UsageProvider, mutate: (inout ProviderConfig) -> Void) { - self.updateConfig(reason: "provider-\(provider.rawValue)") { config in + self.updateConfig(reason: "provider-\(provider.rawValue)", affectsBackgroundWork: true) { config in if let index = config.providers.firstIndex(where: { $0.id == provider }) { var entry = config.providers[index] mutate(&entry) @@ -58,6 +58,40 @@ extension SettingsStore { } } + func updateHooks(_ mutate: (inout HooksConfig) -> Void) { + // Hooks never affect provider fetching, so mark the change as not affecting + // background work: the config persists and the pane re-renders (via + // configRevision), but no provider refresh is triggered. + self.updateConfig(reason: "hooks", affectsBackgroundWork: false) { config in + var hooks = config.hooks ?? HooksConfig() + mutate(&hooks) + config.hooks = (hooks.enabled || !hooks.events.isEmpty) ? hooks : nil + } + } + + /// Persists provider settings that only affect an already-visible provider detail. + /// This avoids rebuilding status items and open menus for a local selection change. + func updateProviderDetailConfig( + provider: UsageProvider, + mutate: (inout ProviderConfig) -> Void) + { + guard !self.configLoading else { return } + var config = self.config + if let index = config.providers.firstIndex(where: { $0.id == provider }) { + var entry = config.providers[index] + mutate(&entry) + config.providers[index] = entry + } else { + var entry = ProviderConfig(id: provider) + mutate(&entry) + config.providers.append(entry) + } + self.config = config.normalized() + self.updateProviderState(config: self.config) + self.schedulePersistConfig() + self.providerDetailSettingsRevision &+= 1 + } + func updateProviderTokenAccounts(_ accounts: [UsageProvider: ProviderTokenAccountData]) { let summary = accounts .sorted { $0.key.rawValue < $1.key.rawValue } @@ -69,7 +103,7 @@ extension SettingsStore { "providers": "\(accounts.count)", "summary": summary, ]) - self.updateConfig(reason: "token-accounts") { config in + self.updateConfig(reason: "token-accounts", affectsBackgroundWork: true) { config in var seen: Set = [] for index in config.providers.indices { let provider = config.providers[index].id @@ -83,7 +117,7 @@ extension SettingsStore { } func setProviderOrder(_ order: [UsageProvider]) { - self.updateConfig(reason: "order") { config in + self.updateConfig(reason: "order", affectsBackgroundWork: false) { config in let configsByID = Dictionary(uniqueKeysWithValues: config.providers.map { ($0.id, $0) }) var seen: Set = [] var ordered: [ProviderConfig] = [] @@ -103,29 +137,72 @@ extension SettingsStore { } } - func reloadConfig(reason: String) { + func reloadConfig(reason: String, affectsBackgroundWork: Bool? = nil) { guard !self.configLoading else { return } do { guard let loaded = try self.configStore.load() else { return } - self.applyExternalConfig(loaded, reason: "reload-\(reason)") + self.applyExternalConfig( + loaded, + reason: "reload-\(reason)", + affectsBackgroundWork: affectsBackgroundWork) } catch { CodexBarLog.logger(LogCategories.configStore).error("Failed to reload config: \(error)") } } - func applyExternalConfig(_ config: CodexBarConfig, reason: String) { + func applyExternalConfig( + _ config: CodexBarConfig, + reason: String, + affectsBackgroundWork: Bool? = nil) + { guard !self.configLoading else { return } + let normalized = config.normalized() + let inferredBackgroundWorkChange = Self.configChangeAffectsBackgroundWork( + from: self.config, + to: normalized) + let resolvedBackgroundWorkChange = (affectsBackgroundWork ?? false) || inferredBackgroundWorkChange self.configLoading = true - self.config = config - self.updateProviderState(config: config) + self.config = normalized + self.updateProviderState(config: normalized) self.configLoading = false - self.bumpConfigRevision(.external(reason: "sync-\(reason)")) + self.bumpConfigRevision(.external( + reason: "sync-\(reason)", + affectsBackgroundWork: resolvedBackgroundWorkChange)) + } + + private static func configChangeAffectsBackgroundWork( + from previous: CodexBarConfig, + to current: CodexBarConfig) -> Bool + { + guard let previousData = orderIndependentConfigData(previous), + let currentData = orderIndependentConfigData(current) + else { + return true + } + return previousData != currentData + } + + private static func orderIndependentConfigData(_ config: CodexBarConfig) -> Data? { + var canonical = config.normalized() + canonical.providers.sort { $0.id.rawValue < $1.id.rawValue } + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + return try? encoder.encode(canonical) } private func bumpConfigRevision(_ context: ConfigChangeContext) { + // Account routing derives from config paths and source selection. Never let an old + // reconciliation snapshot survive a config reload, even when another provider changed. + self.invalidateCodexAccountReconciliationSnapshotCache() + self.cachedCodexAccountMenuProjection = nil self.configRevision &+= 1 + if context.affectsBackgroundWork { + self.noteBackgroundWorkSettingsChanged() + } CodexBarLog.logger(LogCategories.settings) - .debug("Config revision bumped (\(context.reason)) -> \(self.configRevision)") + .debug( + "Config revision bumped (\(context.reason)) -> \(self.configRevision)", + metadata: ["backgroundWork": context.affectsBackgroundWork ? "1" : "0"]) guard context.shouldBroadcast else { return } NotificationCenter.default.post( name: .codexbarProviderConfigDidChange, @@ -134,6 +211,7 @@ extension SettingsStore { "config": self.config, "reason": context.reason, "revision": self.configRevision, + "affectsBackgroundWork": context.affectsBackgroundWork, ]) } diff --git a/Sources/CodexBar/SettingsStore+Defaults.swift b/Sources/CodexBar/SettingsStore+Defaults.swift index 27f4ae0916..e514518fc9 100644 --- a/Sources/CodexBar/SettingsStore+Defaults.swift +++ b/Sources/CodexBar/SettingsStore+Defaults.swift @@ -5,11 +5,53 @@ import ServiceManagement extension SettingsStore { private static let mergedOverviewSelectionEditedActiveProvidersKey = "mergedOverviewSelectionEditedActiveProviders" + func noteBackgroundWorkSettingsChanged() { + self.backgroundWorkSettingsRevision &+= 1 + } + var refreshFrequency: RefreshFrequency { get { self.defaultsState.refreshFrequency } set { + let previousValue = self.defaultsState.refreshFrequency + if newValue == .adaptiveAgentAware, + previousValue != .adaptiveAgentAware, + self.defaultsState.adaptiveActivityScanConsent == .declined + { + self.defaultsState.adaptiveActivityScanConsent = .undecided + self.userDefaults.set( + AdaptiveActivityScanConsent.undecided.rawValue, + forKey: "adaptiveActivityScanConsent") + } self.defaultsState.refreshFrequency = newValue self.userDefaults.set(newValue.rawValue, forKey: "refreshFrequency") + self.noteBackgroundWorkSettingsChanged() + } + } + + var adaptiveActivityScanConsent: AdaptiveActivityScanConsent { + get { self.defaultsState.adaptiveActivityScanConsent } + set { + self.defaultsState.adaptiveActivityScanConsent = newValue + self.userDefaults.set(newValue.rawValue, forKey: "adaptiveActivityScanConsent") + self.noteBackgroundWorkSettingsChanged() + } + } + + var adaptiveActivityScanningEnabled: Bool { + self.refreshFrequency == .adaptiveAgentAware && self.adaptiveActivityScanConsent == .allowed + } + + var shouldRequestAdaptiveActivityScanConsent: Bool { + self.refreshFrequency == .adaptiveAgentAware && self.adaptiveActivityScanConsent == .undecided + } + + /// When enabled, keeping the menu open through its short refresh delay fetches usage for every + /// enabled provider. The periodic refresh clock remains unchanged. See `scheduleOpenMenuRefresh`. + var refreshAllProvidersOnMenuOpen: Bool { + get { self.defaultsState.refreshAllProvidersOnMenuOpen } + set { + self.defaultsState.refreshAllProvidersOnMenuOpen = newValue + self.userDefaults.set(newValue, forKey: "refreshAllProvidersOnMenuOpen") } } @@ -39,6 +81,7 @@ extension SettingsStore { Self.sharedDefaults?.set(newValue, forKey: "debugDisableKeychainAccess") } KeychainAccessGate.isDisabled = newValue + self.noteBackgroundWorkSettingsChanged() } } @@ -68,6 +111,7 @@ extension SettingsStore { set { self.defaultsState.debugKeepCLISessionsAlive = newValue self.userDefaults.set(newValue, forKey: "debugKeepCLISessionsAlive") + self.noteBackgroundWorkSettingsChanged() } } @@ -92,6 +136,7 @@ extension SettingsStore { set { self.defaultsState.statusChecksEnabled = newValue self.userDefaults.set(newValue, forKey: "statusChecksEnabled") + self.noteBackgroundWorkSettingsChanged() } } @@ -100,6 +145,7 @@ extension SettingsStore { set { self.defaultsState.sessionQuotaNotificationsEnabled = newValue self.userDefaults.set(newValue, forKey: "sessionQuotaNotificationsEnabled") + self.noteBackgroundWorkSettingsChanged() } } @@ -108,6 +154,17 @@ extension SettingsStore { set { self.defaultsState.quotaWarningNotificationsEnabled = newValue self.userDefaults.set(newValue, forKey: "quotaWarningNotificationsEnabled") + self.noteBackgroundWorkSettingsChanged() + } + } + + var predictivePaceWarningNotificationsEnabled: Bool { + get { self.defaultsState.predictivePaceWarningNotificationsEnabled } + set { + guard self.defaultsState.predictivePaceWarningNotificationsEnabled != newValue else { return } + self.defaultsState.predictivePaceWarningNotificationsEnabled = newValue + self.userDefaults.set(newValue, forKey: "predictivePaceWarningNotificationsEnabled") + self.noteBackgroundWorkSettingsChanged() } } @@ -115,12 +172,19 @@ extension SettingsStore { get { QuotaWarningThresholds.sanitized(self.defaultsState.quotaWarningThresholdsRaw) } set { let sanitized = QuotaWarningThresholds.sanitized(newValue) + guard QuotaWarningThresholds.sanitized(self.defaultsState.quotaWarningThresholdsRaw) != sanitized + || QuotaWarningThresholds.sanitized(self.defaultsState.quotaWarningSessionThresholdsRaw) != sanitized + || QuotaWarningThresholds.sanitized(self.defaultsState.quotaWarningWeeklyThresholdsRaw) != sanitized + else { + return + } self.defaultsState.quotaWarningThresholdsRaw = sanitized self.defaultsState.quotaWarningSessionThresholdsRaw = sanitized self.defaultsState.quotaWarningWeeklyThresholdsRaw = sanitized self.userDefaults.set(sanitized, forKey: "quotaWarningThresholds") self.userDefaults.set(sanitized, forKey: "quotaWarningSessionThresholds") self.userDefaults.set(sanitized, forKey: "quotaWarningWeeklyThresholds") + self.noteBackgroundWorkSettingsChanged() } } @@ -135,6 +199,7 @@ extension SettingsStore { func setQuotaWarningThresholds(_ window: QuotaWarningWindow, thresholds: [Int]) { let sanitized = QuotaWarningThresholds.sanitized(thresholds) + guard self.quotaWarningThresholds(window) != sanitized else { return } switch window { case .session: self.defaultsState.quotaWarningSessionThresholdsRaw = sanitized @@ -143,6 +208,7 @@ extension SettingsStore { self.defaultsState.quotaWarningWeeklyThresholdsRaw = sanitized self.userDefaults.set(sanitized, forKey: "quotaWarningWeeklyThresholds") } + self.noteBackgroundWorkSettingsChanged() } func quotaWarningWindowEnabled(_ window: QuotaWarningWindow) -> Bool { @@ -163,6 +229,7 @@ extension SettingsStore { self.defaultsState.quotaWarningWeeklyEnabled = enabled self.userDefaults.set(enabled, forKey: "quotaWarningWeeklyEnabled") } + self.noteBackgroundWorkSettingsChanged() } var quotaWarningSoundEnabled: Bool { @@ -170,6 +237,15 @@ extension SettingsStore { set { self.defaultsState.quotaWarningSoundEnabled = newValue self.userDefaults.set(newValue, forKey: "quotaWarningSoundEnabled") + self.noteBackgroundWorkSettingsChanged() + } + } + + var quotaWarningOnScreenAlertEnabled: Bool { + get { self.defaultsState.quotaWarningOnScreenAlertEnabled } + set { + self.defaultsState.quotaWarningOnScreenAlertEnabled = newValue + self.userDefaults.set(newValue, forKey: "quotaWarningOnScreenAlertEnabled") } } @@ -225,74 +301,55 @@ extension SettingsStore { } } - private var menuBarDisplayModeRaw: String? { - get { self.defaultsState.menuBarDisplayModeRaw } + var menuBarHidesCritters: Bool { + get { self.defaultsState.menuBarHidesCritters } set { - self.defaultsState.menuBarDisplayModeRaw = newValue - if let raw = newValue { - self.userDefaults.set(raw, forKey: "menuBarDisplayMode") - } else { - self.userDefaults.removeObject(forKey: "menuBarDisplayMode") - } + self.defaultsState.menuBarHidesCritters = newValue + self.userDefaults.set(newValue, forKey: "menuBarHidesCritters") } } - var menuBarDisplayMode: MenuBarDisplayMode { - get { MenuBarDisplayMode(rawValue: self.menuBarDisplayModeRaw ?? "") ?? .percent } - set { self.menuBarDisplayModeRaw = newValue.rawValue } - } - - private var menuBarSeparatorStyleRaw: String? { - get { self.defaultsState.menuBarSeparatorStyleRaw } + var menuBarUsageColorsEnabled: Bool { + get { self.defaultsState.menuBarUsageColorsEnabled } set { - self.defaultsState.menuBarSeparatorStyleRaw = newValue - if let raw = newValue { - self.userDefaults.set(raw, forKey: "menuBarSeparatorStyle") - } else { - self.userDefaults.removeObject(forKey: "menuBarSeparatorStyle") - } + self.defaultsState.menuBarUsageColorsEnabled = newValue + self.userDefaults.set(newValue, forKey: "menuBarUsageColorsEnabled") } } - var menuBarSeparatorStyle: MenuBarSeparatorStyle { - get { MenuBarSeparatorStyle(rawValue: self.menuBarSeparatorStyleRaw ?? "") ?? .dot } - set { self.menuBarSeparatorStyleRaw = newValue.rawValue } + var menuBarHighContrastOnInactiveDisplays: Bool { + get { self.defaultsState.menuBarHighContrastOnInactiveDisplays } + set { + self.defaultsState.menuBarHighContrastOnInactiveDisplays = newValue + self.userDefaults.set(newValue, forKey: "menuBarHighContrastOnInactiveDisplays") + } } - private var menuBarPercentTimeWindowRaw: String? { - get { self.defaultsState.menuBarPercentTimeWindowRaw } + private var menuBarDisplayModeRaw: String? { + get { self.defaultsState.menuBarDisplayModeRaw } set { - self.defaultsState.menuBarPercentTimeWindowRaw = newValue + self.defaultsState.menuBarDisplayModeRaw = newValue if let raw = newValue { - self.userDefaults.set(raw, forKey: "menuBarPercentTimeWindow") + self.userDefaults.set(raw, forKey: "menuBarDisplayMode") } else { - self.userDefaults.removeObject(forKey: "menuBarPercentTimeWindow") + self.userDefaults.removeObject(forKey: "menuBarDisplayMode") } } } - var menuBarPercentTimeWindow: MenuBarTimeWindow { - get { MenuBarTimeWindow(rawValue: self.menuBarPercentTimeWindowRaw ?? "") ?? .session } - set { self.menuBarPercentTimeWindowRaw = newValue.rawValue } + var menuBarDisplayMode: MenuBarDisplayMode { + get { MenuBarDisplayMode(rawValue: self.menuBarDisplayModeRaw ?? "") ?? .percent } + set { self.menuBarDisplayModeRaw = newValue.rawValue } } - private var menuBarPaceTimeWindowRaw: String? { - get { self.defaultsState.menuBarPaceTimeWindowRaw } + var menuBarShowsResetTimeWhenExhausted: Bool { + get { self.defaultsState.menuBarShowsResetTimeWhenExhausted } set { - self.defaultsState.menuBarPaceTimeWindowRaw = newValue - if let raw = newValue { - self.userDefaults.set(raw, forKey: "menuBarPaceTimeWindow") - } else { - self.userDefaults.removeObject(forKey: "menuBarPaceTimeWindow") - } + self.defaultsState.menuBarShowsResetTimeWhenExhausted = newValue + self.userDefaults.set(newValue, forKey: "menuBarShowsResetTimeWhenExhausted") } } - var menuBarPaceTimeWindow: MenuBarTimeWindow { - get { MenuBarTimeWindow(rawValue: self.menuBarPaceTimeWindowRaw ?? "") ?? .weekly } - set { self.menuBarPaceTimeWindowRaw = newValue.rawValue } - } - private var kiroMenuBarDisplayModeRaw: String? { get { self.defaultsState.kiroMenuBarDisplayModeRaw } set { @@ -315,6 +372,7 @@ extension SettingsStore { set { self.defaultsState.multiAccountMenuLayoutRaw = newValue.rawValue self.userDefaults.set(newValue.rawValue, forKey: "multiAccountMenuLayout") + self.noteBackgroundWorkSettingsChanged() } } @@ -328,6 +386,7 @@ extension SettingsStore { set { self.defaultsState.historicalTrackingEnabled = newValue self.userDefaults.set(newValue, forKey: "historicalTrackingEnabled") + self.noteBackgroundWorkSettingsChanged() } } @@ -339,11 +398,124 @@ extension SettingsStore { } } + var menuBarLayout: MenuBarLayout { + get { + self.defaultsState.storedMenuBarLayout ?? MenuBarLayout.migrated( + iconStyle: self.menuBarIconStyle, + displayMode: self.menuBarDisplayMode, + metricPreference: .automatic, + resetTimeDisplayStyle: self.resetTimeDisplayStyle) + } + set { + self.defaultsState.storedMenuBarLayout = newValue + self.persistMenuBarLayout(newValue, key: "menuBarLayout") + } + } + + var hasStoredMenuBarLayout: Bool { + self.defaultsState.storedMenuBarLayout != nil + } + + var menuBarLayoutOverrides: [UsageProvider: MenuBarLayout] { + Dictionary(uniqueKeysWithValues: self.defaultsState.menuBarLayoutOverridesRaw.compactMap { key, value in + UsageProvider(rawValue: key).map { ($0, value) } + }) + } + + func menuBarLayout(for provider: UsageProvider) -> MenuBarLayout { + self.menuBarLayoutResolution(for: provider).layout + } + + func menuBarLayoutForGlobalEditing(representativeProvider: UsageProvider?) -> MenuBarLayout { + if let stored = self.defaultsState.storedMenuBarLayout { + return stored + } + guard let representativeProvider else { return self.menuBarLayout } + return self.menuBarLayoutResolution(for: representativeProvider).layout + } + + func menuBarLayoutResolution(for provider: UsageProvider) -> MenuBarLayoutResolution { + if let override = self.defaultsState.menuBarLayoutOverridesRaw[provider.rawValue] { + return .stored(override) + } + if let stored = self.defaultsState.storedMenuBarLayout { + return .stored(stored) + } + return .legacy( + iconStyle: self.menuBarIconStyle, + displayMode: self.menuBarDisplayMode, + metricPreference: self.menuBarMetricPreference(for: provider), + resetTimeDisplayStyle: self.resetTimeDisplayStyle, + provider: provider) + } + + func setMenuBarLayout(_ layout: MenuBarLayout, for provider: UsageProvider?) { + if let provider { + self.defaultsState.menuBarLayoutOverridesRaw[provider.rawValue] = layout + self.persistMenuBarLayoutOverrides() + } else { + self.menuBarLayout = layout + } + } + + func removeMenuBarLayoutOverride(for provider: UsageProvider) { + guard self.defaultsState.menuBarLayoutOverridesRaw.removeValue(forKey: provider.rawValue) != nil else { return } + self.persistMenuBarLayoutOverrides() + } + + var menuBarLayoutSize: MenuBarLayoutSize { + get { MenuBarLayoutSize(rawValue: self.defaultsState.menuBarLayoutSizeRaw) ?? .regular } + set { + self.defaultsState.menuBarLayoutSizeRaw = newValue.rawValue + self.userDefaults.set(newValue.rawValue, forKey: "menuBarLayoutSize") + } + } + + var menuBarLayoutGap: MenuBarLayoutGap { + get { MenuBarLayoutGap(rawValue: self.defaultsState.menuBarLayoutGapRaw) ?? .regular } + set { + self.defaultsState.menuBarLayoutGapRaw = newValue.rawValue + self.userDefaults.set(newValue.rawValue, forKey: "menuBarLayoutGap") + } + } + + private func persistMenuBarLayout(_ layout: MenuBarLayout, key: String) { + guard let data = try? JSONEncoder().encode(layout) else { return } + self.userDefaults.set(data, forKey: key) + } + + private func persistMenuBarLayoutOverrides() { + guard let data = try? JSONEncoder().encode(self.defaultsState.menuBarLayoutOverridesRaw) else { return } + self.userDefaults.set(data, forKey: "menuBarLayoutOverrides") + } + + var copilotIconSecondaryWindowIDRaw: String { + get { self.defaultsState.copilotIconSecondaryWindowIDRaw } + set { + self.defaultsState.copilotIconSecondaryWindowIDRaw = newValue + self.userDefaults.set(newValue, forKey: "copilotIconSecondaryWindowID") + } + } + var costUsageEnabled: Bool { get { self.defaultsState.costUsageEnabled } set { + let changed = self.defaultsState.costUsageEnabled != newValue self.defaultsState.costUsageEnabled = newValue self.userDefaults.set(newValue, forKey: "tokenCostUsageEnabled") + if changed { + self.costUsageSettingsRevision &+= 1 + } + self.noteBackgroundWorkSettingsChanged() + } + } + + var codexLocalSessionCostLedgerEnabled: Bool { + get { self.defaultsState.codexLocalSessionCostLedgerEnabled } + set { + self.defaultsState.codexLocalSessionCostLedgerEnabled = newValue + self.userDefaults.set(newValue, forKey: "codexLocalSessionCostLedgerEnabled") + self.noteBackgroundWorkSettingsChanged() } } @@ -351,11 +523,37 @@ extension SettingsStore { get { self.defaultsState.costUsageHistoryDays } set { let clamped = max(1, min(365, newValue)) + let changed = self.defaultsState.costUsageHistoryDays != clamped self.defaultsState.costUsageHistoryDays = clamped self.userDefaults.set(clamped, forKey: "tokenCostUsageHistoryDays") + if changed { + self.costUsageSettingsRevision &+= 1 + } + self.noteBackgroundWorkSettingsChanged() + } + } + + var costComparisonPeriodsEnabled: Bool { + get { self.defaultsState.costComparisonPeriodsEnabled } + set { + self.defaultsState.costComparisonPeriodsEnabled = newValue + self.userDefaults.set(newValue, forKey: "costComparisonPeriodsEnabled") } } + var costSummaryDisplayStyleRaw: String { + get { self.defaultsState.costSummaryDisplayStyleRaw } + set { + self.defaultsState.costSummaryDisplayStyleRaw = newValue + self.userDefaults.set(newValue, forKey: "costSummaryDisplayStyle") + } + } + + var costSummaryDisplayStyle: CostSummaryDisplayStyle { + get { CostSummaryDisplayStyle(rawValue: self.costSummaryDisplayStyleRaw) ?? .both } + set { self.costSummaryDisplayStyleRaw = newValue.rawValue } + } + var hidePersonalInfo: Bool { get { self.defaultsState.hidePersonalInfo } set { @@ -372,6 +570,14 @@ extension SettingsStore { } } + var confettiOnSessionLimitResetsEnabled: Bool { + get { self.defaultsState.confettiOnSessionLimitResetsEnabled } + set { + self.defaultsState.confettiOnSessionLimitResetsEnabled = newValue + self.userDefaults.set(newValue, forKey: "confettiOnSessionLimitResetsEnabled") + } + } + var confettiOnWeeklyLimitResetsEnabled: Bool { get { self.defaultsState.confettiOnWeeklyLimitResetsEnabled } set { @@ -396,28 +602,34 @@ extension SettingsStore { set { self.defaultsState.claudeOAuthKeychainPromptModeRaw = newValue.rawValue self.userDefaults.set(newValue.rawValue, forKey: "claudeOAuthKeychainPromptMode") + self.noteBackgroundWorkSettingsChanged() } } var claudeOAuthKeychainReadStrategy: ClaudeOAuthKeychainReadStrategy { get { guard let raw = self.defaultsState.claudeOAuthKeychainReadStrategyRaw else { - return .securityCLIExperimental + return .securityFramework } - return ClaudeOAuthKeychainReadStrategy(rawValue: raw) ?? .securityFramework + let strategy = ClaudeOAuthKeychainReadStrategy(rawValue: raw) ?? .securityFramework + return strategy == .securityCLIExperimental ? .securityFramework : strategy } set { self.defaultsState.claudeOAuthKeychainReadStrategyRaw = newValue.rawValue self.userDefaults.set(newValue.rawValue, forKey: "claudeOAuthKeychainReadStrategy") + self.noteBackgroundWorkSettingsChanged() } } var claudeOAuthPromptFreeCredentialsEnabled: Bool { - get { self.claudeOAuthKeychainReadStrategy == .securityCLIExperimental } + get { self.claudeOAuthKeychainPromptMode == .never } set { - self.claudeOAuthKeychainReadStrategy = newValue - ? .securityCLIExperimental - : .securityFramework + self.claudeOAuthKeychainReadStrategy = .securityFramework + if newValue { + self.claudeOAuthKeychainPromptMode = .never + } else if self.claudeOAuthKeychainPromptMode == .never { + self.claudeOAuthKeychainPromptMode = .onlyOnUserAction + } } } @@ -426,6 +638,18 @@ extension SettingsStore { set { self.claudeWebExtrasEnabledRaw = newValue } } + var copilotBudgetExtrasEnabled: Bool { + get { self.defaultsState.copilotBudgetExtrasEnabled } + set { + self.defaultsState.copilotBudgetExtrasEnabled = newValue + self.userDefaults.set(newValue, forKey: "copilotBudgetExtrasEnabled") + CodexBarLog.logger(LogCategories.settings).info( + "Copilot budget extras updated", + metadata: ["enabled": newValue ? "1" : "0"]) + self.noteBackgroundWorkSettingsChanged() + } + } + private var claudeWebExtrasEnabledRaw: Bool { get { self.defaultsState.claudeWebExtrasEnabledRaw } set { @@ -434,6 +658,7 @@ extension SettingsStore { CodexBarLog.logger(LogCategories.settings).info( "Claude web extras updated", metadata: ["enabled": newValue ? "1" : "0"]) + self.noteBackgroundWorkSettingsChanged() } } @@ -442,6 +667,24 @@ extension SettingsStore { set { self.defaultsState.showOptionalCreditsAndExtraUsage = newValue self.userDefaults.set(newValue, forKey: "showOptionalCreditsAndExtraUsage") + // This flag also controls ProviderFetchContext.includeOptionalUsage, so it is not display-only. + self.noteBackgroundWorkSettingsChanged() + } + } + + var claudeDailyRoutinesUsageVisible: Bool { + get { self.defaultsState.claudeDailyRoutinesUsageVisible } + set { + self.defaultsState.claudeDailyRoutinesUsageVisible = newValue + self.userDefaults.set(newValue, forKey: "claudeDailyRoutinesUsageVisible") + } + } + + var codexSparkUsageVisible: Bool { + get { self.defaultsState.codexSparkUsageVisible } + set { + self.defaultsState.codexSparkUsageVisible = newValue + self.userDefaults.set(newValue, forKey: "codexSparkUsageVisible") } } @@ -453,6 +696,7 @@ extension SettingsStore { CodexBarLog.logger(LogCategories.settings).info( "OpenAI web access updated", metadata: ["enabled": newValue ? "1" : "0"]) + self.noteBackgroundWorkSettingsChanged() } } @@ -464,6 +708,7 @@ extension SettingsStore { CodexBarLog.logger(LogCategories.settings).info( "OpenAI web battery saver updated", metadata: ["enabled": newValue ? "1" : "0"]) + self.noteBackgroundWorkSettingsChanged() } } @@ -475,6 +720,7 @@ extension SettingsStore { CodexBarLog.logger(LogCategories.settings).info( "Provider storage footprints updated", metadata: ["enabled": newValue ? "1" : "0"]) + self.noteBackgroundWorkSettingsChanged() } } @@ -486,14 +732,6 @@ extension SettingsStore { } } - var colorCodedIcons: Bool { - get { self.defaultsState.colorCodedIcons } - set { - self.defaultsState.colorCodedIcons = newValue - self.userDefaults.set(newValue, forKey: "colorCodedIcons") - } - } - var mergeIcons: Bool { get { self.defaultsState.mergeIcons } set { @@ -511,9 +749,9 @@ extension SettingsStore { } var mergedMenuLastSelectedWasOverview: Bool { - get { self.defaultsState.mergedMenuLastSelectedWasOverview } + get { self.mergedMenuLastSelectedWasOverviewStorage } set { - self.defaultsState.mergedMenuLastSelectedWasOverview = newValue + self.mergedMenuLastSelectedWasOverviewStorage = newValue self.userDefaults.set(newValue, forKey: "mergedMenuLastSelectedWasOverview") } } @@ -527,9 +765,9 @@ extension SettingsStore { } private var selectedMenuProviderRaw: String? { - get { self.defaultsState.selectedMenuProviderRaw } + get { self.selectedMenuProviderRawStorage } set { - self.defaultsState.selectedMenuProviderRaw = newValue + self.selectedMenuProviderRawStorage = newValue if let raw = newValue { self.userDefaults.set(raw, forKey: "selectedMenuProvider") } else { @@ -696,6 +934,17 @@ extension SettingsStore { } } + /// Whether the Providers settings pane displays providers sorted alphabetically (enabled on + /// top). Defaults to `false`. Purely a display preference — it never rewrites the stored manual + /// order, so turning it on sorts the display without losing the user's hand-arranged sequence. + var providersSortedAlphabetically: Bool { + get { self.defaultsState.providersSortedAlphabetically } + set { + self.defaultsState.providersSortedAlphabetically = newValue + self.userDefaults.set(newValue, forKey: "providersSortedAlphabetically") + } + } + var appLanguage: String { get { self.defaultsState.appLanguageRaw ?? "" } set { @@ -706,7 +955,7 @@ extension SettingsStore { if self.userDefaults !== UserDefaults.standard { UserDefaults.standard.set(stored, forKey: "appLanguage") } - UserDefaults.standard.set([stored], forKey: "AppleLanguages") + UserDefaults.standard.removeObject(forKey: "AppleLanguages") } else { self.userDefaults.removeObject(forKey: "appLanguage") if self.userDefaults !== UserDefaults.standard { @@ -714,6 +963,7 @@ extension SettingsStore { } UserDefaults.standard.removeObject(forKey: "AppleLanguages") } + resetCodexBarLocalizationCache() } } @@ -721,6 +971,46 @@ extension SettingsStore { get { self.debugLoadingPatternRaw.flatMap(LoadingPattern.init(rawValue:)) } set { self.debugLoadingPatternRaw = newValue?.rawValue } } + + var terminalApp: TerminalApp { + get { TerminalApp(rawValue: self.defaultsState.terminalAppRaw ?? "") ?? .terminal } + set { + self.defaultsState.terminalAppRaw = newValue.rawValue + self.userDefaults.set(newValue.rawValue, forKey: "terminalApp") + } + } + + var agentSessionsEnabled: Bool { + get { self.defaultsState.agentSessionsEnabled } + set { + self.defaultsState.agentSessionsEnabled = newValue + self.userDefaults.set(newValue, forKey: "agentSessionsEnabled") + } + } + + var agentSessionLabelStyle: AgentSessionLabelStyle { + get { AgentSessionLabelStyle(rawValue: self.defaultsState.agentSessionLabelStyleRaw) ?? .project } + set { + self.defaultsState.agentSessionLabelStyleRaw = newValue.rawValue + self.userDefaults.set(newValue.rawValue, forKey: "agentSessionLabelStyle") + } + } + + var agentSessionsManualHosts: String { + get { self.defaultsState.agentSessionsManualHosts } + set { + self.defaultsState.agentSessionsManualHosts = newValue + self.userDefaults.set(newValue, forKey: "agentSessionsManualHosts") + } + } + + var preferredCurrencyCode: String { + get { self.defaultsState.preferredCurrencyCode } + set { + self.defaultsState.preferredCurrencyCode = newValue + self.userDefaults.set(newValue, forKey: "preferredCurrencyCode") + } + } } extension SettingsStore { @@ -730,7 +1020,9 @@ extension SettingsStore { for provider in providers where !seen.contains(provider) { seen.insert(provider) normalized.append(provider) - if let maxCount, normalized.count >= maxCount { break } + if let maxCount, normalized.count >= maxCount { + break + } } return normalized } diff --git a/Sources/CodexBar/SettingsStore+MenuObservation.swift b/Sources/CodexBar/SettingsStore+MenuObservation.swift index 600ad3c0dc..bb8eab9984 100644 --- a/Sources/CodexBar/SettingsStore+MenuObservation.swift +++ b/Sources/CodexBar/SettingsStore+MenuObservation.swift @@ -5,6 +5,7 @@ extension SettingsStore { _ = self.providerOrder _ = self.providerEnablement _ = self.refreshFrequency + _ = self.adaptiveActivityScanConsent _ = self.launchAtLogin _ = self.debugMenuEnabled _ = self.debugDisableKeychainAccess @@ -12,40 +13,58 @@ extension SettingsStore { _ = self.statusChecksEnabled _ = self.sessionQuotaNotificationsEnabled _ = self.quotaWarningNotificationsEnabled + _ = self.predictivePaceWarningNotificationsEnabled _ = self.quotaWarningThresholds _ = self.quotaWarningThresholds(.session) _ = self.quotaWarningThresholds(.weekly) _ = self.quotaWarningWindowEnabled(.session) _ = self.quotaWarningWindowEnabled(.weekly) _ = self.quotaWarningSoundEnabled + _ = self.quotaWarningOnScreenAlertEnabled _ = self.quotaWarningMarkersVisible _ = self.weeklyProgressWorkDays _ = self.usageBarsShowUsed _ = self.resetTimesShowAbsolute _ = self.providerChangelogLinksEnabled _ = self.menuBarShowsBrandIconWithPercent + _ = self.menuBarHidesCritters + _ = self.menuBarUsageColorsEnabled + _ = self.menuBarHighContrastOnInactiveDisplays _ = self.menuBarShowsHighestUsage _ = self.menuBarDisplayMode - _ = self.menuBarSeparatorStyle - _ = self.menuBarPercentTimeWindow - _ = self.menuBarPaceTimeWindow + _ = self.menuBarShowsResetTimeWhenExhausted _ = self.kiroMenuBarDisplayMode _ = self.historicalTrackingEnabled _ = self.multiAccountMenuLayout _ = self.menuBarMetricPreferencesRaw + _ = self.menuBarLayout + _ = self.menuBarLayoutOverrides + _ = self.menuBarLayoutSize + _ = self.menuBarLayoutGap + _ = self.copilotIconSecondaryWindowIDRaw _ = self.costUsageEnabled + _ = self.codexLocalSessionCostLedgerEnabled _ = self.costUsageHistoryDays + _ = self.costComparisonPeriodsEnabled + _ = self.costSummaryDisplayStyle _ = self.appLanguage _ = self.hidePersonalInfo _ = self.randomBlinkEnabled + _ = self.confettiOnSessionLimitResetsEnabled _ = self.confettiOnWeeklyLimitResetsEnabled _ = self.claudeOAuthKeychainPromptMode _ = self.claudeOAuthKeychainReadStrategy _ = self.claudeWebExtrasEnabled + _ = self.copilotBudgetExtrasEnabled _ = self.showOptionalCreditsAndExtraUsage + _ = self.claudeDailyRoutinesUsageVisible + _ = self.codexSparkUsageVisible _ = self.openAIWebAccessEnabled _ = self.openAIWebBatterySaverEnabled _ = self.providerStorageFootprintsEnabled + _ = self.agentSessionsEnabled + _ = self.agentSessionLabelStyle + _ = self.agentSessionsManualHosts _ = self.codexUsageDataSource _ = self.codexActiveSource _ = self.claudeUsageDataSource @@ -63,11 +82,10 @@ extension SettingsStore { _ = self.augmentCookieSource _ = self.ampCookieSource _ = self.t3ChatCookieSource + _ = self.zoomMateCookieSource _ = self.ollamaCookieSource - _ = self.colorCodedIcons _ = self.mergeIcons _ = self.switcherShowsIcons - _ = self.mergedMenuLastSelectedWasOverview _ = self.mergedOverviewSelectedProviders _ = self.zaiAPIToken _ = self.syntheticAPIToken @@ -82,17 +100,16 @@ extension SettingsStore { _ = self.minimaxCookieHeader _ = self.minimaxAPIToken _ = self.kimiManualCookieHeader - _ = self.kimiK2APIToken _ = self.kiloAPIToken _ = self.augmentCookieHeader _ = self.ampCookieHeader _ = self.t3ChatCookieHeader + _ = self.zoomMateCookieHeader _ = self.ollamaCookieHeader _ = self.copilotAPIToken _ = self.warpAPIToken _ = self.tokenAccountsByProvider _ = self.debugLoadingPattern - _ = self.selectedMenuProvider _ = self.configRevision return 0 } diff --git a/Sources/CodexBar/SettingsStore+MenuPreferences.swift b/Sources/CodexBar/SettingsStore+MenuPreferences.swift index ce8dacf45e..7f28ffa86f 100644 --- a/Sources/CodexBar/SettingsStore+MenuPreferences.swift +++ b/Sources/CodexBar/SettingsStore+MenuPreferences.swift @@ -1,18 +1,226 @@ import CodexBarCore import Foundation +enum MenuBarIconStyle: String, CaseIterable { + case critters + case bars + case iconAndPercent + + var label: String { + switch self { + case .critters: L("menu_bar_style_critters") + case .bars: L("menu_bar_style_bars") + case .iconAndPercent: L("menu_bar_style_icon_percent") + } + } +} + +enum SwitcherRowsOption: String, CaseIterable { + case icons + case progress + + var label: String { + switch self { + case .icons: L("switcher_rows_icons") + case .progress: L("switcher_rows_progress") + } + } +} + +enum UsageBarsFillOption: String, CaseIterable { + case remaining + case used + + var label: String { + switch self { + case .remaining: L("usage_bars_fill_remaining") + case .used: L("usage_bars_fill_used") + } + } +} + +enum ResetTimesOption: String, CaseIterable { + case countdown + case clock + + var label: String { + switch self { + case .countdown: L("reset_times_countdown") + case .clock: L("reset_times_clock") + } + } +} + +enum ConfettiCelebrationOption: String, CaseIterable { + case off + case session + case weekly + case both + + var label: String { + switch self { + case .off: L("confetti_option_off") + case .session: L("confetti_option_session") + case .weekly: L("confetti_option_weekly") + case .both: L("confetti_option_both") + } + } +} + +enum CostSummaryOption: String, CaseIterable { + case off + case inlineSummary + case costSubmenu + case both + + var label: String { + switch self { + case .off: L("cost_summary_off") + case .inlineSummary: CostSummaryDisplayStyle.inlineSummary.label + case .costSubmenu: CostSummaryDisplayStyle.costSubmenu.label + case .both: CostSummaryDisplayStyle.both.label + } + } +} + +enum AgentSessionLabelStyle: String, CaseIterable { + case project + case descriptive + case descriptiveAndProject + + var label: String { + switch self { + case .project: L("agent_session_label_project") + case .descriptive: L("agent_session_label_descriptive") + case .descriptiveAndProject: L("agent_session_label_descriptive_and_project") + } + } + + func label(for session: AgentSession) -> String { + let project = session.projectName?.trimmingCharacters(in: .whitespacesAndNewlines) + let descriptive = session.sessionName?.trimmingCharacters(in: .whitespacesAndNewlines) + switch self { + case .project: + return project?.nilIfEmpty ?? L("agent_session_unknown_project") + case .descriptive: + return descriptive?.nilIfEmpty ?? project?.nilIfEmpty ?? L("agent_session_unknown_project") + case .descriptiveAndProject: + guard let descriptive = descriptive?.nilIfEmpty else { + return project?.nilIfEmpty ?? L("agent_session_unknown_project") + } + guard let project = project?.nilIfEmpty, + descriptive.caseInsensitiveCompare(project) != .orderedSame + else { return descriptive } + return "\(descriptive) · \(project)" + } + } +} + +extension String { + fileprivate var nilIfEmpty: String? { + self.isEmpty ? nil : self + } +} + extension SettingsStore { + var menuBarIconStyle: MenuBarIconStyle { + get { + if self.menuBarShowsBrandIconWithPercent { + return .iconAndPercent + } + return self.menuBarHidesCritters ? .bars : .critters + } + set { + switch newValue { + case .critters: + self.menuBarShowsBrandIconWithPercent = false + self.menuBarHidesCritters = false + case .bars: + self.menuBarShowsBrandIconWithPercent = false + self.menuBarHidesCritters = true + case .iconAndPercent: + self.menuBarShowsBrandIconWithPercent = true + } + } + } + + var switcherRowsOption: SwitcherRowsOption { + get { self.switcherShowsIcons ? .icons : .progress } + set { self.switcherShowsIcons = newValue == .icons } + } + + var usageBarsFillOption: UsageBarsFillOption { + get { self.usageBarsShowUsed ? .used : .remaining } + set { self.usageBarsShowUsed = newValue == .used } + } + + var resetTimesOption: ResetTimesOption { + get { self.resetTimesShowAbsolute ? .clock : .countdown } + set { self.resetTimesShowAbsolute = newValue == .clock } + } + + var confettiCelebrationOption: ConfettiCelebrationOption { + get { + switch (self.confettiOnSessionLimitResetsEnabled, self.confettiOnWeeklyLimitResetsEnabled) { + case (false, false): .off + case (true, false): .session + case (false, true): .weekly + case (true, true): .both + } + } + set { + self.confettiOnSessionLimitResetsEnabled = newValue == .session || newValue == .both + self.confettiOnWeeklyLimitResetsEnabled = newValue == .weekly || newValue == .both + } + } + + var costSummaryOption: CostSummaryOption { + get { + guard self.costUsageEnabled else { return .off } + switch self.costSummaryDisplayStyle { + case .inlineSummary: return .inlineSummary + case .costSubmenu: return .costSubmenu + case .both: return .both + } + } + set { + switch newValue { + case .off: + self.costUsageEnabled = false + case .inlineSummary: + self.costSummaryDisplayStyle = .inlineSummary + self.costUsageEnabled = true + case .costSubmenu: + self.costSummaryDisplayStyle = .costSubmenu + self.costUsageEnabled = true + case .both: + self.costSummaryDisplayStyle = .both + self.costUsageEnabled = true + } + } + } + func menuBarMetricPreference(for provider: UsageProvider) -> MenuBarMetricPreference { - if Self.isBalanceOnlyProvider(provider) { + if Self.isBalanceOnlyProvider(provider), provider != .mistral { return .automatic } + if provider == .mistral { + let raw = self.menuBarMetricPreferencesRaw[provider.rawValue] ?? "" + let preference = MenuBarMetricPreference(rawValue: raw) ?? .automatic + switch preference { + case .automatic, .monthlyPlan: + return preference + case .primary, .secondary, .primaryAndSecondary, .tertiary, .extraUsage, .average: + return .automatic + } + } if provider == .openrouter { let raw = self.menuBarMetricPreferencesRaw[provider.rawValue] ?? "" let preference = MenuBarMetricPreference(rawValue: raw) ?? .automatic switch preference { case .automatic, .primary: return preference - case .secondary, .average, .tertiary, .extraUsage: + case .secondary, .primaryAndSecondary, .average, .tertiary, .extraUsage, .monthlyPlan: return .automatic } } @@ -21,29 +229,48 @@ extension SettingsStore { if preference == .average, !self.menuBarMetricSupportsAverage(for: provider) { return .automatic } + if preference == .primaryAndSecondary, !self.menuBarMetricSupportsPrimaryAndSecondary(for: provider) { + return .automatic + } if preference == .tertiary, !self.menuBarMetricSupportsTertiary(for: provider) { return .automatic } if preference == .extraUsage, !self.menuBarMetricSupportsExtraUsage(for: provider) { return .automatic } + if preference == .monthlyPlan { + return .automatic + } return preference } func setMenuBarMetricPreference(_ preference: MenuBarMetricPreference, for provider: UsageProvider) { - if Self.isBalanceOnlyProvider(provider) { + if Self.isBalanceOnlyProvider(provider), provider != .mistral { self.menuBarMetricPreferencesRaw[provider.rawValue] = MenuBarMetricPreference.automatic.rawValue return } + if provider == .mistral { + switch preference { + case .automatic, .monthlyPlan: + self.menuBarMetricPreferencesRaw[provider.rawValue] = preference.rawValue + case .primary, .secondary, .primaryAndSecondary, .tertiary, .extraUsage, .average: + self.menuBarMetricPreferencesRaw[provider.rawValue] = MenuBarMetricPreference.automatic.rawValue + } + return + } if provider == .openrouter { switch preference { case .automatic, .primary: self.menuBarMetricPreferencesRaw[provider.rawValue] = preference.rawValue - case .secondary, .average, .tertiary, .extraUsage: + case .secondary, .primaryAndSecondary, .average, .tertiary, .extraUsage, .monthlyPlan: self.menuBarMetricPreferencesRaw[provider.rawValue] = MenuBarMetricPreference.automatic.rawValue } return } + if preference == .primaryAndSecondary, !self.menuBarMetricSupportsPrimaryAndSecondary(for: provider) { + self.menuBarMetricPreferencesRaw[provider.rawValue] = MenuBarMetricPreference.automatic.rawValue + return + } if preference == .tertiary, !self.menuBarMetricSupportsTertiary(for: provider) { self.menuBarMetricPreferencesRaw[provider.rawValue] = MenuBarMetricPreference.automatic.rawValue return @@ -52,6 +279,10 @@ extension SettingsStore { self.menuBarMetricPreferencesRaw[provider.rawValue] = MenuBarMetricPreference.automatic.rawValue return } + if preference == .monthlyPlan { + self.menuBarMetricPreferencesRaw[provider.rawValue] = MenuBarMetricPreference.automatic.rawValue + return + } self.menuBarMetricPreferencesRaw[provider.rawValue] = preference.rawValue } @@ -59,6 +290,10 @@ extension SettingsStore { provider == .gemini } + func menuBarMetricSupportsPrimaryAndSecondary(for provider: UsageProvider) -> Bool { + provider == .codex || provider == .claude + } + func menuBarMetricSupportsTertiary(for provider: UsageProvider) -> Bool { provider == .cursor || provider == .perplexity || provider == .zai } @@ -96,8 +331,9 @@ extension SettingsStore { } func isCostUsageEffectivelyEnabled(for provider: UsageProvider) -> Bool { - self.costUsageEnabled - && ProviderDescriptorRegistry.descriptor(for: provider).tokenCost.supportsTokenCost + let isEnabled = self.costUsageEnabled || + (provider == .codex && self.codexLocalSessionCostLedgerEnabled) + return isEnabled && ProviderDescriptorRegistry.descriptor(for: provider).tokenCost.supportsTokenCost } var resetTimeDisplayStyle: ResetTimeDisplayStyle { @@ -106,7 +342,7 @@ extension SettingsStore { static func isBalanceOnlyProvider(_ provider: UsageProvider) -> Bool { switch provider { - case .deepseek, .mistral, .kimik2, .moonshot: + case .deepseek, .deepinfra, .mistral, .moonshot, .poe: true default: false diff --git a/Sources/CodexBar/SettingsStore+ProviderDetection.swift b/Sources/CodexBar/SettingsStore+ProviderDetection.swift index d4efded24a..e2f35b3491 100644 --- a/Sources/CodexBar/SettingsStore+ProviderDetection.swift +++ b/Sources/CodexBar/SettingsStore+ProviderDetection.swift @@ -1,6 +1,30 @@ +import AppKit import CodexBarCore import Foundation +enum ProviderDetectionPolicy { + struct Signals { + let codexCLIInstalled: Bool + let claudeCLIInstalled: Bool + let claudeDesktopInstalled: Bool + let geminiCLIInstalled: Bool + let geminiConfigured: Bool + let antigravityAvailable: Bool + } + + static func enabledProviders(signals: Signals) -> Set { + var enabled: Set = [] + if signals.codexCLIInstalled { enabled.insert(.codex) } + if signals.claudeCLIInstalled || signals.claudeDesktopInstalled { enabled.insert(.claude) } + if signals.geminiCLIInstalled, signals.geminiConfigured { enabled.insert(.gemini) } + if signals.antigravityAvailable { enabled.insert(.antigravity) } + + // Keep the historical Codex default when no usable provider source is found. + if enabled.isEmpty { enabled.insert(.codex) } + return enabled + } +} + extension SettingsStore { func runInitialProviderDetectionIfNeeded(force: Bool = false) { guard force || !self.providerDetectionCompleted else { return } @@ -13,51 +37,58 @@ extension SettingsStore { func applyProviderDetection() async { guard !self.providerDetectionCompleted else { return } - let codexInstalled = BinaryLocator.resolveCodexBinary() != nil - let claudeInstalled = BinaryLocator.resolveClaudeBinary() != nil - let geminiInstalled = BinaryLocator.resolveGeminiBinary() != nil + let codexCLIInstalled = BinaryLocator.resolveCodexBinary() != nil + let claudeCLIInstalled = BinaryLocator.resolveClaudeBinary() != nil + let claudeDesktopInstalled = NSWorkspace.shared.urlForApplication( + withBundleIdentifier: "com.anthropic.claudefordesktop") != nil + let geminiCLIInstalled = BinaryLocator.resolveGeminiBinary() != nil + let geminiConfigured = FileManager.default.fileExists( + atPath: FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent(".gemini/oauth_creds.json").path) let antigravityRunning = await AntigravityStatusProbe.isRunning() let antigravityLoggedIn = FileManager.default.fileExists( atPath: AntigravityOAuthCredentialsStore().fileURL.path) let logger = CodexBarLog.logger(LogCategories.providerDetection) - // If none installed, keep Codex enabled to match previous behavior. - let noneInstalled = !codexInstalled && !claudeInstalled && !geminiInstalled && !antigravityRunning && - !antigravityLoggedIn - let enableCodex = codexInstalled || noneInstalled - let enableClaude = claudeInstalled - let enableGemini = geminiInstalled - let enableAntigravity = antigravityRunning || antigravityLoggedIn + let enabledProviders = ProviderDetectionPolicy.enabledProviders(signals: .init( + codexCLIInstalled: codexCLIInstalled, + claudeCLIInstalled: claudeCLIInstalled, + claudeDesktopInstalled: claudeDesktopInstalled, + geminiCLIInstalled: geminiCLIInstalled, + geminiConfigured: geminiConfigured, + antigravityAvailable: antigravityRunning || antigravityLoggedIn)) logger.info( "Provider detection results", metadata: [ - "codexInstalled": codexInstalled ? "1" : "0", - "claudeInstalled": claudeInstalled ? "1" : "0", - "geminiInstalled": geminiInstalled ? "1" : "0", + "codexCLIInstalled": codexCLIInstalled ? "1" : "0", + "claudeCLIInstalled": claudeCLIInstalled ? "1" : "0", + "claudeDesktopInstalled": claudeDesktopInstalled ? "1" : "0", + "geminiCLIInstalled": geminiCLIInstalled ? "1" : "0", + "geminiConfigured": geminiConfigured ? "1" : "0", "antigravityRunning": antigravityRunning ? "1" : "0", "antigravityLoggedIn": antigravityLoggedIn ? "1" : "0", ]) logger.info( "Provider detection enablement", metadata: [ - "codex": enableCodex ? "1" : "0", - "claude": enableClaude ? "1" : "0", - "gemini": enableGemini ? "1" : "0", - "antigravity": enableAntigravity ? "1" : "0", + "codex": enabledProviders.contains(.codex) ? "1" : "0", + "claude": enabledProviders.contains(.claude) ? "1" : "0", + "gemini": enabledProviders.contains(.gemini) ? "1" : "0", + "antigravity": enabledProviders.contains(.antigravity) ? "1" : "0", ]) self.updateProviderConfig(provider: .codex) { entry in - entry.enabled = enableCodex + entry.enabled = enabledProviders.contains(.codex) } self.updateProviderConfig(provider: .claude) { entry in - entry.enabled = enableClaude + entry.enabled = enabledProviders.contains(.claude) } self.updateProviderConfig(provider: .gemini) { entry in - entry.enabled = enableGemini + entry.enabled = enabledProviders.contains(.gemini) } self.updateProviderConfig(provider: .antigravity) { entry in - entry.enabled = enableAntigravity + entry.enabled = enabledProviders.contains(.antigravity) } self.providerDetectionCompleted = true logger.info("Provider detection completed") diff --git a/Sources/CodexBar/SettingsStore+TokenAccounts.swift b/Sources/CodexBar/SettingsStore+TokenAccounts.swift index 01c1387c7b..cdd28e628b 100644 --- a/Sources/CodexBar/SettingsStore+TokenAccounts.swift +++ b/Sources/CodexBar/SettingsStore+TokenAccounts.swift @@ -18,6 +18,16 @@ extension SettingsStore { return data.accounts[index] } + /// Returns the saved account that currently owns provider fetches and account-scoped state. + /// Cursor keeps saved manual credentials when browser login switches back to Automatic, but those credentials + /// stay passive until the user explicitly selects one again. + func effectiveSelectedTokenAccount(for provider: UsageProvider) -> ProviderTokenAccount? { + if provider == .cursor, self.cursorCookieSource == .auto { + return nil + } + return self.selectedTokenAccount(for: provider) + } + func setActiveTokenAccountIndex(_ index: Int, for provider: UsageProvider) { guard let data = self.tokenAccountsData(for: provider), !data.accounts.isEmpty else { return } let clamped = min(max(index, 0), data.accounts.count - 1) @@ -28,6 +38,7 @@ extension SettingsStore { self.updateProviderConfig(provider: provider) { entry in entry.tokenAccounts = updated } + self.applyTokenAccountCookieSourceIfNeeded(provider: provider) CodexBarLog.logger(LogCategories.tokenAccounts).info( "Active token account updated", metadata: [ @@ -41,7 +52,9 @@ extension SettingsStore { label: String, token: String, externalIdentifier: String? = nil, - organizationID: String? = nil) + usageScope: String? = nil, + organizationID: String? = nil, + workspaceID: String? = nil) { guard TokenAccountSupportCatalog.support(for: provider) != nil else { return } let trimmedToken = token.trimmingCharacters(in: .whitespacesAndNewlines) @@ -49,8 +62,12 @@ extension SettingsStore { let trimmedLabel = label.trimmingCharacters(in: .whitespacesAndNewlines) let trimmedIdentifier = externalIdentifier?.trimmingCharacters(in: .whitespacesAndNewlines) let normalisedIdentifier = (trimmedIdentifier?.isEmpty ?? true) ? nil : trimmedIdentifier + let trimmedUsageScope = usageScope?.trimmingCharacters(in: .whitespacesAndNewlines) + let normalisedUsageScope = (trimmedUsageScope?.isEmpty ?? true) ? nil : trimmedUsageScope let trimmedOrganizationID = organizationID?.trimmingCharacters(in: .whitespacesAndNewlines) let normalisedOrganizationID = (trimmedOrganizationID?.isEmpty ?? true) ? nil : trimmedOrganizationID + let trimmedWorkspaceID = workspaceID?.trimmingCharacters(in: .whitespacesAndNewlines) + let normalisedWorkspaceID = (trimmedWorkspaceID?.isEmpty ?? true) ? nil : trimmedWorkspaceID let existing = self.tokenAccountsData(for: provider) let accounts = existing?.accounts ?? [] let fallbackLabel = trimmedLabel.isEmpty ? "Account \(accounts.count + 1)" : trimmedLabel @@ -61,7 +78,9 @@ extension SettingsStore { addedAt: Date().timeIntervalSince1970, lastUsed: nil, externalIdentifier: normalisedIdentifier, - organizationID: normalisedOrganizationID) + usageScope: normalisedUsageScope, + organizationID: normalisedOrganizationID, + workspaceID: normalisedWorkspaceID) let updated = ProviderTokenAccountData( version: existing?.version ?? 1, accounts: accounts + [account], @@ -87,7 +106,9 @@ extension SettingsStore { label: String? = nil, token: String? = nil, externalIdentifier: String?? = nil, - organizationID: String?? = nil) + usageScope: String?? = nil, + organizationID: String?? = nil, + workspaceID: String?? = nil) { guard let data = self.tokenAccountsData(for: provider), !data.accounts.isEmpty else { return } guard let index = data.accounts.firstIndex(where: { $0.id == accountID }) else { return } @@ -104,6 +125,13 @@ extension SettingsStore { } else { resolvedIdentifier = existing.externalIdentifier } + let resolvedUsageScope: String? + if let usageScope { + let trimmed = usageScope?.trimmingCharacters(in: .whitespacesAndNewlines) + resolvedUsageScope = (trimmed?.isEmpty ?? true) ? nil : trimmed + } else { + resolvedUsageScope = existing.usageScope + } let resolvedOrganizationID: String? if let organizationID { let trimmed = organizationID?.trimmingCharacters(in: .whitespacesAndNewlines) @@ -111,6 +139,13 @@ extension SettingsStore { } else { resolvedOrganizationID = existing.organizationID } + let resolvedWorkspaceID: String? + if let workspaceID { + let trimmed = workspaceID?.trimmingCharacters(in: .whitespacesAndNewlines) + resolvedWorkspaceID = (trimmed?.isEmpty ?? true) ? nil : trimmed + } else { + resolvedWorkspaceID = existing.workspaceID + } let updatedAccount = ProviderTokenAccount( id: existing.id, label: (trimmedLabel?.isEmpty == false) ? trimmedLabel! : existing.label, @@ -118,7 +153,9 @@ extension SettingsStore { addedAt: existing.addedAt, lastUsed: existing.lastUsed, externalIdentifier: resolvedIdentifier, - organizationID: resolvedOrganizationID) + usageScope: resolvedUsageScope, + organizationID: resolvedOrganizationID, + workspaceID: resolvedWorkspaceID) var accounts = data.accounts accounts[index] = updatedAccount @@ -145,6 +182,7 @@ extension SettingsStore { guard let data = self.tokenAccountsData(for: provider), !data.accounts.isEmpty else { return } let activeAccountID = data.accounts[data.clampedActiveIndex()].id guard let removedIndex = data.accounts.firstIndex(where: { $0.id == accountID }) else { return } + let removedAccount = data.accounts[removedIndex] let filtered = data.accounts.filter { $0.id != accountID } self.updateProviderConfig(provider: provider) { entry in if filtered.isEmpty { @@ -166,6 +204,10 @@ extension SettingsStore { entry.apiKey = nil } } + self.applyTokenAccountRemovalSideEffectsIfNeeded( + provider: provider, + removedAccount: removedAccount, + remainingAccounts: filtered) CodexBarLog.logger(LogCategories.tokenAccounts).info( "Token account removed", metadata: [ @@ -212,4 +254,111 @@ extension SettingsStore { else { return } ProviderCatalog.implementation(for: provider)?.applyTokenAccountCookieSource(settings: self) } + + private func applyTokenAccountRemovalSideEffectsIfNeeded( + provider: UsageProvider, + removedAccount: ProviderTokenAccount, + remainingAccounts: [ProviderTokenAccount]) + { + guard provider == .antigravity else { return } + guard let removedCredentials = AntigravityOAuthCredentialsStore.credentials( + fromTokenAccountValue: removedAccount.token) + else { + return + } + let hasMatchingRemainingAccount = remainingAccounts.contains { account in + guard let credentials = AntigravityOAuthCredentialsStore.credentials(fromTokenAccountValue: account.token) + else { + return false + } + return Self.antigravityCredentialsMatchAccount(credentials, removedCredentials) + } + guard !hasMatchingRemainingAccount else { return } + + Self.clearMatchingAntigravitySharedCredentials( + store: self.antigravityOAuthCredentialsStore, + removedCredentials: removedCredentials) + } + + private nonisolated static func clearMatchingAntigravitySharedCredentials( + store: AntigravityOAuthCredentialsStore, + removedCredentials: AntigravityOAuthCredentials) + { + do { + try store.deleteIfPresent { sharedCredentials in + self.antigravitySharedCredentialsMatchRemovedAccount( + sharedCredentials, + removedCredentials) + } + } catch { + CodexBarLog.logger(LogCategories.tokenAccounts).warning( + "Failed to clear Antigravity OAuth cache after account removal", + metadata: ["error": error.localizedDescription]) + } + } + + private nonisolated static func antigravitySharedCredentialsMatchRemovedAccount( + _ shared: AntigravityOAuthCredentials, + _ removed: AntigravityOAuthCredentials) -> Bool + { + if let sharedRefreshToken = self.normalizedAntigravityCredentialToken(shared.refreshToken), + let removedRefreshToken = self.normalizedAntigravityCredentialToken(removed.refreshToken) + { + return sharedRefreshToken == removedRefreshToken + } + if let sharedAccessToken = self.normalizedAntigravityCredentialToken(shared.accessToken), + let removedAccessToken = self.normalizedAntigravityCredentialToken(removed.accessToken) + { + return sharedAccessToken == removedAccessToken + } + guard self.normalizedAntigravityCredentialToken(shared.refreshToken) == nil, + self.normalizedAntigravityCredentialToken(removed.refreshToken) == nil, + self.normalizedAntigravityCredentialToken(shared.accessToken) == nil, + self.normalizedAntigravityCredentialToken(removed.accessToken) == nil + else { + return false + } + return self.normalizedAntigravityAccountEmail(shared.resolvedAccountEmail) + == self.normalizedAntigravityAccountEmail(removed.resolvedAccountEmail) + } + + private nonisolated static func antigravityCredentialsMatchAccount( + _ lhs: AntigravityOAuthCredentials, + _ rhs: AntigravityOAuthCredentials) -> Bool + { + if let lhsEmail = self.normalizedAntigravityAccountEmail(lhs.resolvedAccountEmail), + let rhsEmail = self.normalizedAntigravityAccountEmail(rhs.resolvedAccountEmail) + { + return lhsEmail == rhsEmail + } + if let lhsRefreshToken = self.normalizedAntigravityCredentialToken(lhs.refreshToken), + let rhsRefreshToken = self.normalizedAntigravityCredentialToken(rhs.refreshToken) + { + return lhsRefreshToken == rhsRefreshToken + } + if let lhsAccessToken = self.normalizedAntigravityCredentialToken(lhs.accessToken), + let rhsAccessToken = self.normalizedAntigravityCredentialToken(rhs.accessToken) + { + return lhsAccessToken == rhsAccessToken + } + return false + } + + private nonisolated static func normalizedAntigravityAccountEmail(_ email: String?) -> String? { + guard let value = email?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased(), + !value.isEmpty + else { + return nil + } + return value + } + + private nonisolated static func normalizedAntigravityCredentialToken(_ token: String?) -> String? { + guard let value = token?.trimmingCharacters(in: .whitespacesAndNewlines), + !value.isEmpty + else { + return nil + } + return value + } } diff --git a/Sources/CodexBar/SettingsStore+TokenCost.swift b/Sources/CodexBar/SettingsStore+TokenCost.swift index 03e748403b..c4881e95d6 100644 --- a/Sources/CodexBar/SettingsStore+TokenCost.swift +++ b/Sources/CodexBar/SettingsStore+TokenCost.swift @@ -1,7 +1,24 @@ +import CodexBarCore import Foundation extension SettingsStore { + func costSummaryShowsInlineDashboard(for provider: UsageProvider) -> Bool { + // DeepSeek has no cost submenu, so any enabled cost-summary style falls back to inline. + if provider == .deepseek { + return self.costUsageEnabled + } + return self.isCostUsageEffectivelyEnabled(for: provider) && + self.costSummaryDisplayStyle.showsInlineSummary + } + + func costSummaryShowsSubmenu(for provider: UsageProvider) -> Bool { + self.isCostUsageEffectivelyEnabled(for: provider) && + self.costSummaryDisplayStyle.showsCostSubmenu + } + func applyTokenCostDefaultIfNeeded() { + // Tests cover detection directly; skip filesystem-driven auto-enablement to keep startup deterministic. + guard !Self.isRunningTests else { return } // Settings are persisted in UserDefaults.standard. guard UserDefaults.standard.object(forKey: "tokenCostUsageEnabled") == nil else { return } @@ -18,8 +35,11 @@ extension SettingsStore { nonisolated static func hasAnyTokenCostUsageSources( env: [String: String] = ProcessInfo.processInfo.environment, - fileManager: FileManager = .default) -> Bool + fileManager: FileManager = .default, + homeDirectory: URL? = nil) -> Bool { + let home = homeDirectory ?? fileManager.homeDirectoryForCurrentUser + func hasAnyJsonl(in root: URL) -> Bool { guard fileManager.fileExists(atPath: root.path) else { return false } guard let enumerator = fileManager.enumerator( @@ -39,7 +59,7 @@ extension SettingsStore { if let raw, !raw.isEmpty { return URL(fileURLWithPath: raw).appendingPathComponent("sessions", isDirectory: true) } - return fileManager.homeDirectoryForCurrentUser + return home .appendingPathComponent(".codex", isDirectory: true) .appendingPathComponent("sessions", isDirectory: true) }() @@ -51,8 +71,12 @@ extension SettingsStore { .appendingPathComponent("archived_sessions", isDirectory: true) }() - if hasAnyJsonl(in: codexRoot) { return true } - if let archivedCodexRoot, hasAnyJsonl(in: archivedCodexRoot) { return true } + if hasAnyJsonl(in: codexRoot) { + return true + } + if let archivedCodexRoot, hasAnyJsonl(in: archivedCodexRoot) { + return true + } let claudeRoots: [URL] = { if let env = env["CLAUDE_CONFIG_DIR"]?.trimmingCharacters(in: .whitespacesAndNewlines), @@ -68,11 +92,10 @@ extension SettingsStore { } } - let home = fileManager.homeDirectoryForCurrentUser return [ home.appendingPathComponent(".config/claude/projects", isDirectory: true), home.appendingPathComponent(".claude/projects", isDirectory: true), - ] + ] + ClaudeDesktopProjectsLocator.roots(homeDirectory: home, fileManager: fileManager) }() return claudeRoots.contains(where: hasAnyJsonl(in:)) diff --git a/Sources/CodexBar/SettingsStore.swift b/Sources/CodexBar/SettingsStore.swift index 4446acb7b7..0ef2f6caab 100644 --- a/Sources/CodexBar/SettingsStore.swift +++ b/Sources/CodexBar/SettingsStore.swift @@ -10,11 +10,17 @@ enum RefreshFrequency: String, CaseIterable, Identifiable { case fiveMinutes case fifteenMinutes case thirtyMinutes + case adaptive + /// Adaptive plus consent-gated local agent activity. Kept after plain Adaptive so the + /// privacy-preserving mode remains the first adaptive choice. + case adaptiveAgentAware var id: String { self.rawValue } + /// nil for `.manual` (no timer) and adaptive modes (delay is computed per tick by + /// `AdaptiveRefreshPolicy`, not a fixed interval). var seconds: TimeInterval? { switch self { case .manual: nil @@ -23,6 +29,7 @@ enum RefreshFrequency: String, CaseIterable, Identifiable { case .fiveMinutes: 300 case .fifteenMinutes: 900 case .thirtyMinutes: 1800 + case .adaptive, .adaptiveAgentAware: nil } } @@ -34,17 +41,31 @@ enum RefreshFrequency: String, CaseIterable, Identifiable { case .fiveMinutes: L("refresh_5min") case .fifteenMinutes: L("refresh_15min") case .thirtyMinutes: L("refresh_30min") + case .adaptive: L("refresh_adaptive") + case .adaptiveAgentAware: L("refresh_adaptive_agent_aware") } } + + var usesAdaptivePolicy: Bool { + self == .adaptive || self == .adaptiveAgentAware + } +} + +enum AdaptiveActivityScanConsent: String, Sendable { + case undecided + case allowed + case declined } enum MenuBarMetricPreference: String, CaseIterable, Identifiable { case automatic case primary case secondary + case primaryAndSecondary case tertiary case extraUsage case average + case monthlyPlan var id: String { self.rawValue @@ -55,9 +76,11 @@ enum MenuBarMetricPreference: String, CaseIterable, Identifiable { case .automatic: L("metric_pref_automatic") case .primary: L("metric_pref_primary") case .secondary: L("metric_pref_secondary") + case .primaryAndSecondary: "\(L("metric_pref_primary")) + \(L("metric_pref_secondary"))" case .tertiary: L("metric_pref_tertiary") case .extraUsage: L("metric_pref_extra_usage") case .average: L("metric_pref_average") + case .monthlyPlan: L("metric_mistral_monthly_plan") } } } @@ -79,15 +102,15 @@ enum KiroMenuBarDisplayMode: String, CaseIterable, Identifiable { var label: String { switch self { - case .automatic: "Automatic" - case .hidden: "Hidden" - case .creditsLeft: "Credits left" - case .percentLeft: "Percent left" - case .creditsAndPercent: "Credits + percent" - case .usedAndTotal: "Used / total" - case .overageCreditsWhenExhausted: "Overage credits at zero" - case .overageCostWhenExhausted: "Overage cost at zero" - case .overageCreditsAndCostWhenExhausted: "Overage credits + cost at zero" + case .automatic: L("Automatic") + case .hidden: L("Hidden") + case .creditsLeft: L("Credits left") + case .percentLeft: L("Percent left") + case .creditsAndPercent: L("Credits + percent") + case .usedAndTotal: L("Used / total") + case .overageCreditsWhenExhausted: L("Overage credits at zero") + case .overageCostWhenExhausted: L("Overage cost at zero") + case .overageCreditsAndCostWhenExhausted: L("Overage credits + cost at zero") } } } @@ -108,34 +131,121 @@ enum MultiAccountMenuLayout: String, CaseIterable, Identifiable { } } +enum CostSummaryDisplayStyle: String, CaseIterable, Identifiable { + case inlineSummary + case costSubmenu + case both + + var id: String { + self.rawValue + } + + var label: String { + switch self { + case .inlineSummary: L("cost_summary_style_inline") + case .costSubmenu: L("cost_summary_style_submenu") + case .both: L("cost_summary_style_both") + } + } + + var helpText: String { + switch self { + case .inlineSummary: L("cost_summary_style_inline_help") + case .costSubmenu: L("cost_summary_style_submenu_help") + case .both: L("cost_summary_style_both_help") + } + } + + var showsInlineSummary: Bool { + self != .costSubmenu + } + + var showsCostSubmenu: Bool { + self != .inlineSummary + } +} + +struct CachedCodexAccountReconciliationSnapshot { + let activeSource: CodexActiveSource + let loadedAt: Date + let snapshot: CodexAccountReconciliationSnapshot +} + +struct CachedCodexAccountMenuProjection: Equatable { + let activeSource: CodexActiveSource + let loadedAt: Date + let projection: CodexVisibleAccountProjection +} + +enum CodexAccountMenuProjectionRevalidationResult: Equatable { + case skipped + case discarded + case unchanged + case updated +} + @MainActor @Observable final class SettingsStore { static let sharedDefaults = AppGroupSupport.sharedDefaults() - static let mergedOverviewProviderLimit = 3 + static let mergedOverviewProviderLimit = 6 + static let productionCodexAccountReconciliationSnapshotCacheInterval: TimeInterval = 2 static let isRunningTests: Bool = { let env = ProcessInfo.processInfo.environment - if env["XCTestConfigurationFilePath"] != nil { return true } - if env["TESTING_LIBRARY_VERSION"] != nil { return true } - if env["SWIFT_TESTING"] != nil { return true } + if env["XCTestConfigurationFilePath"] != nil { + return true + } + if env["TESTING_LIBRARY_VERSION"] != nil { + return true + } + if env["SWIFT_TESTING"] != nil { + return true + } return NSClassFromString("XCTestCase") != nil }() + #if DEBUG + static var codexAccountReconciliationSnapshotCacheIntervalOverrideForTesting: TimeInterval? + #endif + @ObservationIgnored let userDefaults: UserDefaults @ObservationIgnored let configStore: CodexBarConfigStore + @ObservationIgnored let antigravityOAuthCredentialsStore: AntigravityOAuthCredentialsStore @ObservationIgnored var config: CodexBarConfig @ObservationIgnored var configPersistTask: Task? @ObservationIgnored var configLoading = false @ObservationIgnored var tokenAccountsLoaded = false + @ObservationIgnored var cachedCodexAccountReconciliationSnapshot: + CachedCodexAccountReconciliationSnapshot? + @ObservationIgnored var cachedCodexAccountMenuProjection: CachedCodexAccountMenuProjection? + @ObservationIgnored var codexAccountReconciliationGeneration: UInt = 0 + #if DEBUG + @ObservationIgnored var _test_codexAccountSnapshotLoader: + (@Sendable (CodexActiveSource) -> CodexAccountReconciliationSnapshot)? + #endif + @ObservationIgnored var mergedMenuLastSelectedWasOverviewStorage = false + @ObservationIgnored var selectedMenuProviderRawStorage: String? var defaultsState: SettingsDefaultsState var configRevision: Int = 0 + var providerDetailSettingsRevision: Int = 0 + var backgroundWorkSettingsRevision: Int = 0 + var costUsageSettingsRevision: UInt64 = 0 var providerOrder: [UsageProvider] = [] var providerEnablement: [UsageProvider: Bool] = [:] + @ObservationIgnored var providerEnablementRevisions: [UsageProvider: UInt64] = [:] + @ObservationIgnored var providerConfigRevisions: [UsageProvider: UInt64] = [:] + @ObservationIgnored var providerConfigFingerprints: [UsageProvider: Data] = [:] static func shouldBridgeSharedDefaults(for userDefaults: UserDefaults) -> Bool { - if !self.isRunningTests { return true } - if userDefaults === UserDefaults.standard { return true } - if let shared = sharedDefaults, userDefaults === shared { return true } + if !self.isRunningTests { + return true + } + if userDefaults === UserDefaults.standard { + return true + } + if let shared = sharedDefaults, userDefaults === shared { + return true + } return false } @@ -162,7 +272,6 @@ final class SettingsStore { minimaxCookieStore: any MiniMaxCookieStoring = KeychainMiniMaxCookieStore(), minimaxAPITokenStore: any MiniMaxAPITokenStoring = KeychainMiniMaxAPITokenStore(), kimiTokenStore: any KimiTokenStoring = KeychainKimiTokenStore(), - kimiK2TokenStore: any KimiK2TokenStoring = KeychainKimiK2TokenStore(), augmentCookieStore: any CookieHeaderStoring = KeychainCookieHeaderStore( account: "augment-cookie", promptKind: .augmentCookie), @@ -170,8 +279,13 @@ final class SettingsStore { account: "amp-cookie", promptKind: .ampCookie), copilotTokenStore: any CopilotTokenStoring = KeychainCopilotTokenStore(), - tokenAccountStore: any ProviderTokenAccountStoring = FileTokenAccountStore()) + tokenAccountStore: any ProviderTokenAccountStoring = FileTokenAccountStore(), + antigravityOAuthCredentialsStore: AntigravityOAuthCredentialsStore = AntigravityOAuthCredentialsStore(), + performInitialProviderDetection: Bool = !SettingsStore.isRunningTests) { + // Capture this before app-group/config migrations can create prior-installation state. + let hadExistingConfig = (try? configStore.load()) != nil + let hadPreviousInstallationState = hadExistingConfig || Self.hadPreviousAppLaunch(userDefaults: userDefaults) let appGroupID = AppGroupSupport.currentGroupID() let appGroupMigration: AppGroupSupport.MigrationResult if Self.isRunningTests { @@ -199,7 +313,6 @@ final class SettingsStore { userDefaults.set(legacyOpenAIWebAccess, forKey: "openAIWebAccessEnabled") } let hasStoredOpenAIWebAccessPreference = userDefaults.object(forKey: "openAIWebAccessEnabled") != nil - let hadExistingConfig = (try? configStore.load()) != nil let legacyStores = CodexBarConfigMigrator.LegacyStores( zaiTokenStore: zaiTokenStore, syntheticTokenStore: syntheticTokenStore, @@ -211,7 +324,6 @@ final class SettingsStore { minimaxCookieStore: minimaxCookieStore, minimaxAPITokenStore: minimaxAPITokenStore, kimiTokenStore: kimiTokenStore, - kimiK2TokenStore: kimiK2TokenStore, augmentCookieStore: augmentCookieStore, ampCookieStore: ampCookieStore, copilotTokenStore: copilotTokenStore, @@ -222,16 +334,24 @@ final class SettingsStore { stores: legacyStores) self.userDefaults = userDefaults self.configStore = configStore + self.antigravityOAuthCredentialsStore = antigravityOAuthCredentialsStore self.config = config self.configLoading = true - self.defaultsState = Self.loadDefaultsState(userDefaults: userDefaults) + let defaultsState = Self.loadDefaultsState( + userDefaults: userDefaults, + hadPreviousInstallationState: hadPreviousInstallationState) + self.defaultsState = defaultsState + self.mergedMenuLastSelectedWasOverviewStorage = defaultsState.mergedMenuLastSelectedWasOverview + self.selectedMenuProviderRawStorage = defaultsState.selectedMenuProviderRaw self.updateProviderState(config: config) self.configLoading = false CodexBarLog.setFileLoggingEnabled(self.debugFileLoggingEnabled) userDefaults.removeObject(forKey: "showCodexUsage") userDefaults.removeObject(forKey: "showClaudeUsage") LaunchAtLoginManager.setEnabled(self.launchAtLogin) - self.runInitialProviderDetectionIfNeeded() + if performInitialProviderDetection { + self.runInitialProviderDetectionIfNeeded() + } self.ensureAlibabaProviderAutoEnabledIfNeeded() self.applyTokenCostDefaultIfNeeded() if self.claudeUsageDataSource != .cli { @@ -258,6 +378,12 @@ final class SettingsStore { } extension SettingsStore { + private struct NotificationDefaults { + let statusChecksEnabled: Bool + let sessionQuotaNotificationsEnabled: Bool + let predictivePaceWarningNotificationsEnabled: Bool + } + private static func scheduleAppGroupMigration() { Task.detached(priority: .utility) { let result = AppGroupSupport.migrateLegacyDataIfNeeded() @@ -276,35 +402,29 @@ extension SettingsStore { hadExistingConfig: Bool) -> Bool { guard let codex = config.providerConfig(for: .codex) else { return false } - if let cookieSource = codex.cookieSource { return cookieSource.isEnabled } - if codex.sanitizedCookieHeader != nil { return true } + if let cookieSource = codex.cookieSource { + return cookieSource.isEnabled + } + if codex.sanitizedCookieHeader != nil { + return true + } return hadExistingConfig } // swiftlint:disable:next function_body_length - private static func loadDefaultsState(userDefaults: UserDefaults) -> SettingsDefaultsState { - let refreshDefault = userDefaults.string(forKey: "refreshFrequency") - .flatMap(RefreshFrequency.init(rawValue:)) - let refreshFrequency = refreshDefault ?? .fiveMinutes - if Self.isRunningTests, refreshDefault == nil { - userDefaults.set(refreshFrequency.rawValue, forKey: "refreshFrequency") - } + private static func loadDefaultsState( + userDefaults: UserDefaults, + hadPreviousInstallationState: Bool) -> SettingsDefaultsState + { + let refreshFrequency = Self.loadRefreshFrequency( + userDefaults: userDefaults, + hadPreviousInstallationState: hadPreviousInstallationState) + let adaptiveActivityScanConsent = Self.loadAdaptiveActivityScanConsent(userDefaults: userDefaults) + let refreshAllProvidersOnMenuOpen = userDefaults.object( + forKey: "refreshAllProvidersOnMenuOpen") as? Bool ?? false let launchAtLogin = userDefaults.object(forKey: "launchAtLogin") as? Bool ?? false let debugMenuEnabled = userDefaults.object(forKey: "debugMenuEnabled") as? Bool ?? false - let debugDisableKeychainAccess: Bool = { - if let stored = userDefaults.object(forKey: "debugDisableKeychainAccess") as? Bool { - return stored - } - if Self.shouldBridgeSharedDefaults(for: userDefaults), - let shared = Self.sharedDefaults?.object(forKey: "debugDisableKeychainAccess") as? Bool - { - if Self.isRunningTests { - userDefaults.set(shared, forKey: "debugDisableKeychainAccess") - } - return shared - } - return false - }() + let debugDisableKeychainAccess = Self.loadDebugDisableKeychainAccess(userDefaults: userDefaults) let debugFileLoggingEnabled = userDefaults.object(forKey: "debugFileLoggingEnabled") as? Bool ?? false let debugLogLevelRaw = userDefaults.string(forKey: "debugLogLevel") ?? CodexBarLog.Level.verbose.rawValue if Self.isRunningTests, userDefaults.string(forKey: "debugLogLevel") == nil { @@ -312,12 +432,7 @@ extension SettingsStore { } let debugLoadingPatternRaw = userDefaults.string(forKey: "debugLoadingPattern") let debugKeepCLISessionsAlive = userDefaults.object(forKey: "debugKeepCLISessionsAlive") as? Bool ?? false - let statusChecksEnabled = userDefaults.object(forKey: "statusChecksEnabled") as? Bool ?? true - let sessionQuotaDefault = userDefaults.object(forKey: "sessionQuotaNotificationsEnabled") as? Bool - let sessionQuotaNotificationsEnabled = sessionQuotaDefault ?? true - if Self.isRunningTests, sessionQuotaDefault == nil { - userDefaults.set(true, forKey: "sessionQuotaNotificationsEnabled") - } + let notificationDefaults = Self.loadNotificationDefaults(userDefaults: userDefaults) let quotaWarnings = Self.loadQuotaWarningDefaults(userDefaults: userDefaults) let quotaWarningMarkersVisibleDefault = userDefaults.object(forKey: "quotaWarningMarkersVisible") as? Bool let quotaWarningMarkersVisible = quotaWarningMarkersVisibleDefault ?? true @@ -331,38 +446,63 @@ extension SettingsStore { forKey: "providerChangelogLinksEnabled") as? Bool ?? false let menuBarShowsBrandIconWithPercent = userDefaults.object( forKey: "menuBarShowsBrandIconWithPercent") as? Bool ?? false + let menuBarHidesCritters = userDefaults.object(forKey: "menuBarHidesCritters") as? Bool ?? false + // Fork default: on. Upstream ships this off so an update never changes an existing + // menu bar; colored icons are the reason this fork exists, so it defaults on here. + let menuBarUsageColorsEnabled = userDefaults + .object(forKey: "menuBarUsageColorsEnabled") as? Bool ?? true + let menuBarHighContrastOnInactiveDisplays = userDefaults.object( + forKey: "menuBarHighContrastOnInactiveDisplays") as? Bool ?? false let menuBarDisplayModeRaw = userDefaults.string(forKey: "menuBarDisplayMode") ?? MenuBarDisplayMode.percent.rawValue - let menuBarSeparatorStyleRaw = userDefaults.string(forKey: "menuBarSeparatorStyle") - ?? MenuBarSeparatorStyle.dot.rawValue - let menuBarPercentTimeWindowRaw = userDefaults.string(forKey: "menuBarPercentTimeWindow") - ?? MenuBarTimeWindow.session.rawValue - let menuBarPaceTimeWindowRaw = userDefaults.string(forKey: "menuBarPaceTimeWindow") - ?? MenuBarTimeWindow.weekly.rawValue + let menuBarShowsResetTimeWhenExhausted = userDefaults.object( + forKey: "menuBarShowsResetTimeWhenExhausted") as? Bool ?? false let kiroMenuBarDisplayModeRaw = userDefaults.string(forKey: "kiroMenuBarDisplayMode") ?? KiroMenuBarDisplayMode.automatic.rawValue let historicalTrackingEnabled = userDefaults.object(forKey: "historicalTrackingEnabled") as? Bool ?? false - let multiAccountMenuLayoutRaw = userDefaults.string(forKey: "multiAccountMenuLayout") ?? { - let legacyShowAll = userDefaults.object(forKey: "showAllTokenAccountsInMenu") as? Bool ?? false - return legacyShowAll ? MultiAccountMenuLayout.stacked.rawValue : MultiAccountMenuLayout.segmented.rawValue - }() + let multiAccountMenuLayoutRaw = Self.loadMultiAccountMenuLayoutRaw(userDefaults: userDefaults) let resolvedPreferences = Self.loadMenuBarMetricPreferences(userDefaults: userDefaults) + let storedMenuBarLayout = Self.loadMenuBarLayout(userDefaults: userDefaults, key: "menuBarLayout") + let menuBarLayoutOverridesRaw = Self.loadMenuBarLayoutOverrides(userDefaults: userDefaults) + let menuBarLayoutSizeRaw = userDefaults.string(forKey: "menuBarLayoutSize") + ?? MenuBarLayoutSize.regular.rawValue + let menuBarLayoutGapRaw = userDefaults.string(forKey: "menuBarLayoutGap") + ?? MenuBarLayoutGap.regular.rawValue + let copilotBudgetExtrasEnabled = userDefaults.object(forKey: "copilotBudgetExtrasEnabled") as? Bool ?? false + let copilotIconSecondaryWindowIDRaw = Self.loadCopilotIconSecondaryWindowIDRaw(userDefaults: userDefaults) let costUsageEnabled = userDefaults.object(forKey: "tokenCostUsageEnabled") as? Bool ?? false + let codexLocalSessionCostLedgerEnabled = userDefaults.object( + forKey: "codexLocalSessionCostLedgerEnabled") as? Bool ?? false let rawCostUsageHistoryDays = userDefaults.object(forKey: "tokenCostUsageHistoryDays") as? Int ?? 30 let costUsageHistoryDays = max(1, min(365, rawCostUsageHistoryDays)) + let costComparisonPeriodsEnabled = userDefaults.object( + forKey: "costComparisonPeriodsEnabled") as? Bool ?? false + let costSummaryDisplayStyleRaw = Self.loadCostSummaryDisplayStyleRaw( + userDefaults: userDefaults, + costUsageEnabled: costUsageEnabled) let hidePersonalInfo = userDefaults.object(forKey: "hidePersonalInfo") as? Bool ?? false let randomBlinkEnabled = userDefaults.object(forKey: "randomBlinkEnabled") as? Bool ?? false - let confettiOnWeeklyLimitResetsEnabled = userDefaults.object( - forKey: "confettiOnWeeklyLimitResetsEnabled") as? Bool ?? false + let confettiOnReset = Self.loadConfettiOnResetDefaults(userDefaults: userDefaults) let menuBarShowsHighestUsage = userDefaults.object(forKey: "menuBarShowsHighestUsage") as? Bool ?? false + let claudeOAuthKeychainReadStrategyRaw = Self.loadClaudeOAuthKeychainReadStrategyRaw(userDefaults: userDefaults) let claudeOAuthKeychainPromptModeRaw = userDefaults.string(forKey: "claudeOAuthKeychainPromptMode") - let claudeOAuthKeychainReadStrategyRaw = userDefaults.string(forKey: "claudeOAuthKeychainReadStrategy") let claudeWebExtrasEnabledRaw = userDefaults.object(forKey: "claudeWebExtrasEnabled") as? Bool ?? false let creditsExtrasDefault = userDefaults.object(forKey: "showOptionalCreditsAndExtraUsage") as? Bool let showOptionalCreditsAndExtraUsage = creditsExtrasDefault ?? true if Self.isRunningTests, creditsExtrasDefault == nil { userDefaults.set(true, forKey: "showOptionalCreditsAndExtraUsage") } + let claudeDailyRoutinesUsageVisibleDefault = userDefaults.object( + forKey: "claudeDailyRoutinesUsageVisible") as? Bool + let claudeDailyRoutinesUsageVisible = claudeDailyRoutinesUsageVisibleDefault ?? true + if Self.isRunningTests, claudeDailyRoutinesUsageVisibleDefault == nil { + userDefaults.set(true, forKey: "claudeDailyRoutinesUsageVisible") + } + let codexSparkUsageVisibleDefault = userDefaults.object(forKey: "codexSparkUsageVisible") as? Bool + let codexSparkUsageVisible = codexSparkUsageVisibleDefault ?? true + if Self.isRunningTests, codexSparkUsageVisibleDefault == nil { + userDefaults.set(true, forKey: "codexSparkUsageVisible") + } let openAIWebAccessDefault = userDefaults.object(forKey: "openAIWebAccessEnabled") as? Bool let openAIWebAccessEnabled = openAIWebAccessDefault ?? false if Self.isRunningTests, openAIWebAccessDefault == nil { @@ -379,7 +519,6 @@ extension SettingsStore { userDefaults.set(false, forKey: "providerStorageFootprintsEnabled") } let jetbrainsIDEBasePath = userDefaults.string(forKey: "jetbrainsIDEBasePath") ?? "" - let colorCodedIcons = userDefaults.object(forKey: "colorCodedIcons") as? Bool ?? true let mergeIcons = userDefaults.object(forKey: "mergeIcons") as? Bool ?? true let switcherShowsIcons = userDefaults.object(forKey: "switcherShowsIcons") as? Bool ?? true let mergedMenuLastSelectedWasOverview = userDefaults.object( @@ -388,10 +527,18 @@ extension SettingsStore { forKey: "mergedOverviewSelectedProviders") as? [String] ?? [] let selectedMenuProviderRaw = userDefaults.string(forKey: "selectedMenuProvider") let providerDetectionCompleted = userDefaults.object(forKey: "providerDetectionCompleted") as? Bool ?? false + let providersSortedAlphabetically = userDefaults.object( + forKey: "providersSortedAlphabetically") as? Bool ?? false let appLanguageRaw = userDefaults.string(forKey: "appLanguage") - + let agentSessionsEnabled = userDefaults.object(forKey: "agentSessionsEnabled") as? Bool ?? false + let agentSessionLabelStyleRaw = userDefaults.string(forKey: "agentSessionLabelStyle") + ?? AgentSessionLabelStyle.project.rawValue + let agentSessionsManualHosts = userDefaults.string(forKey: "agentSessionsManualHosts") ?? "" + let preferredCurrencyCode = userDefaults.string(forKey: "preferredCurrencyCode") ?? "USD" return SettingsDefaultsState( refreshFrequency: refreshFrequency, + adaptiveActivityScanConsent: adaptiveActivityScanConsent, + refreshAllProvidersOnMenuOpen: refreshAllProvidersOnMenuOpen, launchAtLogin: launchAtLogin, debugMenuEnabled: debugMenuEnabled, debugDisableKeychainAccess: debugDisableKeychainAccess, @@ -399,62 +546,223 @@ extension SettingsStore { debugLogLevelRaw: debugLogLevelRaw, debugLoadingPatternRaw: debugLoadingPatternRaw, debugKeepCLISessionsAlive: debugKeepCLISessionsAlive, - statusChecksEnabled: statusChecksEnabled, - sessionQuotaNotificationsEnabled: sessionQuotaNotificationsEnabled, + statusChecksEnabled: notificationDefaults.statusChecksEnabled, + sessionQuotaNotificationsEnabled: notificationDefaults.sessionQuotaNotificationsEnabled, quotaWarningNotificationsEnabled: quotaWarnings.notificationsEnabled, + predictivePaceWarningNotificationsEnabled: notificationDefaults.predictivePaceWarningNotificationsEnabled, quotaWarningThresholdsRaw: quotaWarnings.thresholdsRaw, quotaWarningSessionThresholdsRaw: quotaWarnings.sessionThresholdsRaw, quotaWarningWeeklyThresholdsRaw: quotaWarnings.weeklyThresholdsRaw, quotaWarningSessionEnabled: quotaWarnings.sessionEnabled, quotaWarningWeeklyEnabled: quotaWarnings.weeklyEnabled, quotaWarningSoundEnabled: quotaWarnings.soundEnabled, + quotaWarningOnScreenAlertEnabled: quotaWarnings.onScreenAlertEnabled, quotaWarningMarkersVisible: quotaWarningMarkersVisible, weeklyProgressWorkDays: weeklyProgressWorkDays, usageBarsShowUsed: usageBarsShowUsed, resetTimesShowAbsolute: resetTimesShowAbsolute, providerChangelogLinksEnabled: providerChangelogLinksEnabled, menuBarShowsBrandIconWithPercent: menuBarShowsBrandIconWithPercent, + menuBarHidesCritters: menuBarHidesCritters, + menuBarUsageColorsEnabled: menuBarUsageColorsEnabled, + menuBarHighContrastOnInactiveDisplays: menuBarHighContrastOnInactiveDisplays, menuBarDisplayModeRaw: menuBarDisplayModeRaw, - menuBarSeparatorStyleRaw: menuBarSeparatorStyleRaw, - menuBarPercentTimeWindowRaw: menuBarPercentTimeWindowRaw, - menuBarPaceTimeWindowRaw: menuBarPaceTimeWindowRaw, + menuBarShowsResetTimeWhenExhausted: menuBarShowsResetTimeWhenExhausted, kiroMenuBarDisplayModeRaw: kiroMenuBarDisplayModeRaw, historicalTrackingEnabled: historicalTrackingEnabled, multiAccountMenuLayoutRaw: multiAccountMenuLayoutRaw, menuBarMetricPreferencesRaw: resolvedPreferences, + storedMenuBarLayout: storedMenuBarLayout, + menuBarLayoutOverridesRaw: menuBarLayoutOverridesRaw, + menuBarLayoutSizeRaw: menuBarLayoutSizeRaw, + menuBarLayoutGapRaw: menuBarLayoutGapRaw, + copilotBudgetExtrasEnabled: copilotBudgetExtrasEnabled, + copilotIconSecondaryWindowIDRaw: copilotIconSecondaryWindowIDRaw, costUsageEnabled: costUsageEnabled, + codexLocalSessionCostLedgerEnabled: codexLocalSessionCostLedgerEnabled, costUsageHistoryDays: costUsageHistoryDays, + costComparisonPeriodsEnabled: costComparisonPeriodsEnabled, + costSummaryDisplayStyleRaw: costSummaryDisplayStyleRaw, hidePersonalInfo: hidePersonalInfo, randomBlinkEnabled: randomBlinkEnabled, - confettiOnWeeklyLimitResetsEnabled: confettiOnWeeklyLimitResetsEnabled, + confettiOnSessionLimitResetsEnabled: confettiOnReset.session, + confettiOnWeeklyLimitResetsEnabled: confettiOnReset.weekly, menuBarShowsHighestUsage: menuBarShowsHighestUsage, claudeOAuthKeychainPromptModeRaw: claudeOAuthKeychainPromptModeRaw, claudeOAuthKeychainReadStrategyRaw: claudeOAuthKeychainReadStrategyRaw, claudeWebExtrasEnabledRaw: claudeWebExtrasEnabledRaw, showOptionalCreditsAndExtraUsage: showOptionalCreditsAndExtraUsage, + claudeDailyRoutinesUsageVisible: claudeDailyRoutinesUsageVisible, + codexSparkUsageVisible: codexSparkUsageVisible, openAIWebAccessEnabled: openAIWebAccessEnabled, openAIWebBatterySaverEnabled: openAIWebBatterySaverEnabled, providerStorageFootprintsEnabled: providerStorageFootprintsEnabled, jetbrainsIDEBasePath: jetbrainsIDEBasePath, - colorCodedIcons: colorCodedIcons, mergeIcons: mergeIcons, switcherShowsIcons: switcherShowsIcons, mergedMenuLastSelectedWasOverview: mergedMenuLastSelectedWasOverview, mergedOverviewSelectedProvidersRaw: mergedOverviewSelectedProvidersRaw, selectedMenuProviderRaw: selectedMenuProviderRaw, providerDetectionCompleted: providerDetectionCompleted, - appLanguageRaw: appLanguageRaw) + providersSortedAlphabetically: providersSortedAlphabetically, + appLanguageRaw: appLanguageRaw, + terminalAppRaw: userDefaults.string(forKey: "terminalApp"), + agentSessionsEnabled: agentSessionsEnabled, + agentSessionLabelStyleRaw: agentSessionLabelStyleRaw, + agentSessionsManualHosts: agentSessionsManualHosts, + preferredCurrencyCode: preferredCurrencyCode) + } + + private static func hadPreviousAppLaunch(userDefaults: UserDefaults) -> Bool { + userDefaults.object(forKey: "providerDetectionCompleted") != nil || + userDefaults.object(forKey: AppGroupSupport.migrationVersionKey) != nil + } + + private static func loadRefreshFrequency( + userDefaults: UserDefaults, + hadPreviousInstallationState: Bool) -> RefreshFrequency + { + let rawValue = userDefaults.object(forKey: "refreshFrequency") + if let stored = rawValue as? String, + let frequency = RefreshFrequency(rawValue: stored) + { + return frequency + } + + // An invalid value is existing state. Missing state is Adaptive only when no prior-installation + // state existed before migrations began; legacy unset users keep the old five-minute fallback. + let frequency: RefreshFrequency = rawValue == nil && !hadPreviousInstallationState ? .adaptive : .fiveMinutes + userDefaults.set(frequency.rawValue, forKey: "refreshFrequency") + return frequency + } + + private static func loadAdaptiveActivityScanConsent( + userDefaults: UserDefaults) -> AdaptiveActivityScanConsent + { + if let rawValue = userDefaults.string(forKey: "adaptiveActivityScanConsent"), + let consent = AdaptiveActivityScanConsent(rawValue: rawValue) + { + return consent + } + + userDefaults.set(AdaptiveActivityScanConsent.undecided.rawValue, forKey: "adaptiveActivityScanConsent") + return .undecided + } + + private static func loadNotificationDefaults(userDefaults: UserDefaults) -> NotificationDefaults { + NotificationDefaults( + statusChecksEnabled: userDefaults.object(forKey: "statusChecksEnabled") as? Bool ?? true, + sessionQuotaNotificationsEnabled: self.loadSessionQuotaNotificationsDefault(userDefaults: userDefaults), + predictivePaceWarningNotificationsEnabled: userDefaults.object( + forKey: "predictivePaceWarningNotificationsEnabled") as? Bool ?? false) + } + + private static func loadCostSummaryDisplayStyleRaw( + userDefaults: UserDefaults, + costUsageEnabled: Bool) -> String + { + if let storedCostSummaryDisplayStyle = userDefaults.string(forKey: "costSummaryDisplayStyle"), + CostSummaryDisplayStyle(rawValue: storedCostSummaryDisplayStyle) != nil + { + return storedCostSummaryDisplayStyle + } + let migratedStyle = CostSummaryDisplayStyle.both.rawValue + if costUsageEnabled || userDefaults.object(forKey: "costSummaryDisplayStyle") != nil { + userDefaults.set(migratedStyle, forKey: "costSummaryDisplayStyle") + } + return migratedStyle + } + + private static func loadClaudeOAuthKeychainReadStrategyRaw(userDefaults: UserDefaults) -> String? { + let key = "claudeOAuthKeychainReadStrategy" + guard let raw = userDefaults.string(forKey: key) else { return nil } + guard let strategy = ClaudeOAuthKeychainReadStrategy(rawValue: raw) else { return raw } + guard strategy == .securityCLIExperimental else { return raw } + + let migrated = ClaudeOAuthKeychainReadStrategy.securityFramework.rawValue + userDefaults.set(migrated, forKey: key) + let promptModeKey = "claudeOAuthKeychainPromptMode" + if userDefaults.string(forKey: promptModeKey) == nil { + userDefaults.set(ClaudeOAuthKeychainPromptMode.never.rawValue, forKey: promptModeKey) + } + return migrated + } + + private static func loadConfettiOnResetDefaults(userDefaults: UserDefaults) -> (session: Bool, weekly: Bool) { + ( + session: userDefaults.object(forKey: "confettiOnSessionLimitResetsEnabled") as? Bool ?? false, + weekly: userDefaults.object(forKey: "confettiOnWeeklyLimitResetsEnabled") as? Bool ?? false) } private static func loadMenuBarMetricPreferences(userDefaults: UserDefaults) -> [String: String] { let storedPreferences = userDefaults.dictionary(forKey: "menuBarMetricPreferences") as? [String: String] ?? [:] - if !storedPreferences.isEmpty { - return storedPreferences + let preferences: [String: String] = if !storedPreferences.isEmpty { + storedPreferences + } else if let menuBarMetricRaw = userDefaults.string(forKey: "menuBarMetricPreference"), + let legacyPreference = MenuBarMetricPreference(rawValue: menuBarMetricRaw) + { + Dictionary( + uniqueKeysWithValues: UsageProvider.allCases.map { ($0.rawValue, legacyPreference.rawValue) }) + } else { + [:] + } + + let migrationKey = "antigravityTwoPoolMetricPreferenceMigrated" + guard !userDefaults.bool(forKey: migrationKey) else { return preferences } + + // Tagged builds through v0.35 used primary=Claude, secondary=Gemini Pro, + // and tertiary=Gemini Flash. Remap those meanings once to the two-pool schema. + var migrated = preferences + switch MenuBarMetricPreference(rawValue: migrated[UsageProvider.antigravity.rawValue] ?? "") { + case .primary: + migrated[UsageProvider.antigravity.rawValue] = MenuBarMetricPreference.secondary.rawValue + case .secondary: + migrated[UsageProvider.antigravity.rawValue] = MenuBarMetricPreference.primary.rawValue + case .tertiary: + migrated[UsageProvider.antigravity.rawValue] = MenuBarMetricPreference.primary.rawValue + case .automatic, .primaryAndSecondary, .extraUsage, .average, .monthlyPlan, .none: + break + } + userDefaults.set(migrated, forKey: "menuBarMetricPreferences") + userDefaults.set(true, forKey: migrationKey) + return migrated + } + + private static func loadMenuBarLayout(userDefaults: UserDefaults, key: String) -> MenuBarLayout? { + guard let data = userDefaults.data(forKey: key) else { return nil } + return try? JSONDecoder().decode(MenuBarLayout.self, from: data) + } + + private static func loadMenuBarLayoutOverrides(userDefaults: UserDefaults) -> [String: MenuBarLayout] { + guard let data = userDefaults.data(forKey: "menuBarLayoutOverrides") else { return [:] } + return (try? JSONDecoder().decode([String: MenuBarLayout].self, from: data)) ?? [:] + } + + private static func loadMultiAccountMenuLayoutRaw(userDefaults: UserDefaults) -> String { + if let layout = userDefaults.string(forKey: "multiAccountMenuLayout") { + return layout + } + let legacyShowAll = userDefaults.object(forKey: "showAllTokenAccountsInMenu") as? Bool ?? false + return legacyShowAll ? MultiAccountMenuLayout.stacked.rawValue : MultiAccountMenuLayout.segmented.rawValue + } + + private static func loadCopilotIconSecondaryWindowIDRaw(userDefaults: UserDefaults) -> String { + userDefaults.string(forKey: "copilotIconSecondaryWindowID") ?? CopilotIconSecondaryWindowSelection.chat + } + + private static func loadDebugDisableKeychainAccess(userDefaults: UserDefaults) -> Bool { + if let stored = userDefaults.object(forKey: "debugDisableKeychainAccess") as? Bool { + return stored } - guard let menuBarMetricRaw = userDefaults.string(forKey: "menuBarMetricPreference"), - let legacyPreference = MenuBarMetricPreference(rawValue: menuBarMetricRaw) - else { return [:] } - return Dictionary(uniqueKeysWithValues: UsageProvider.allCases.map { ($0.rawValue, legacyPreference.rawValue) }) + if Self.shouldBridgeSharedDefaults(for: userDefaults), + let shared = Self.sharedDefaults?.object(forKey: "debugDisableKeychainAccess") as? Bool + { + if Self.isRunningTests { + userDefaults.set(shared, forKey: "debugDisableKeychainAccess") + } + return shared + } + return false } private struct LoadedQuotaWarningDefaults { @@ -465,6 +773,15 @@ extension SettingsStore { var sessionEnabled: Bool var weeklyEnabled: Bool var soundEnabled: Bool + var onScreenAlertEnabled: Bool + } + + private static func loadSessionQuotaNotificationsDefault(userDefaults: UserDefaults) -> Bool { + let stored = userDefaults.object(forKey: "sessionQuotaNotificationsEnabled") as? Bool + if Self.isRunningTests, stored == nil { + userDefaults.set(true, forKey: "sessionQuotaNotificationsEnabled") + } + return stored ?? true } private static func loadQuotaWarningDefaults(userDefaults: UserDefaults) -> LoadedQuotaWarningDefaults { @@ -503,6 +820,12 @@ extension SettingsStore { userDefaults.set(true, forKey: "quotaWarningSoundEnabled") } + let onScreenAlertDefault = userDefaults.object(forKey: "quotaWarningOnScreenAlertEnabled") as? Bool + let onScreenAlertEnabled = onScreenAlertDefault ?? false + if Self.isRunningTests, onScreenAlertDefault == nil { + userDefaults.set(false, forKey: "quotaWarningOnScreenAlertEnabled") + } + return LoadedQuotaWarningDefaults( notificationsEnabled: notificationsEnabled, thresholdsRaw: thresholdsRaw, @@ -510,7 +833,8 @@ extension SettingsStore { weeklyThresholdsRaw: weeklyThresholdsRaw, sessionEnabled: sessionEnabled, weeklyEnabled: weeklyEnabled, - soundEnabled: soundEnabled) + soundEnabled: soundEnabled, + onScreenAlertEnabled: onScreenAlertEnabled) } } @@ -528,11 +852,35 @@ extension SettingsStore { enablement.reserveCapacity(metadata.count) for provider in UsageProvider.allCases { let defaultEnabled = metadata[provider]?.defaultEnabled ?? false - enablement[provider] = config.providerConfig(for: provider)?.enabled ?? defaultEnabled + let providerConfig = config.providerConfig(for: provider) ?? ProviderConfig(id: provider) + let isEnabled = providerConfig.enabled ?? defaultEnabled + if let previous = self.providerEnablement[provider], previous != isEnabled { + self.providerEnablementRevisions[provider, default: 0] &+= 1 + } + let fingerprint = Self.providerConfigFingerprint(providerConfig) + if let previous = self.providerConfigFingerprints[provider], previous != fingerprint { + self.providerConfigRevisions[provider, default: 0] &+= 1 + } + self.providerConfigFingerprints[provider] = fingerprint + enablement[provider] = isEnabled } self.providerEnablement = enablement } + private static func providerConfigFingerprint(_ config: ProviderConfig) -> Data { + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + return (try? encoder.encode(config)) ?? Data() + } + + func providerEnablementRevision(for provider: UsageProvider) -> UInt64 { + self.providerEnablementRevisions[provider, default: 0] + } + + func providerConfigRevision(for provider: UsageProvider) -> UInt64 { + self.providerConfigRevisions[provider, default: 0] + } + func orderedProviders() -> [UsageProvider] { if self.providerOrder.isEmpty { self.updateProviderState(config: self.configSnapshot) diff --git a/Sources/CodexBar/SettingsStoreState.swift b/Sources/CodexBar/SettingsStoreState.swift index 0e5c79f4a5..da6f00c4bd 100644 --- a/Sources/CodexBar/SettingsStoreState.swift +++ b/Sources/CodexBar/SettingsStoreState.swift @@ -2,6 +2,8 @@ import Foundation struct SettingsDefaultsState { var refreshFrequency: RefreshFrequency + var adaptiveActivityScanConsent: AdaptiveActivityScanConsent + var refreshAllProvidersOnMenuOpen: Bool var launchAtLogin: Bool var debugMenuEnabled: Bool var debugDisableKeychainAccess: Bool @@ -12,46 +14,66 @@ struct SettingsDefaultsState { var statusChecksEnabled: Bool var sessionQuotaNotificationsEnabled: Bool var quotaWarningNotificationsEnabled: Bool + var predictivePaceWarningNotificationsEnabled: Bool var quotaWarningThresholdsRaw: [Int] var quotaWarningSessionThresholdsRaw: [Int] var quotaWarningWeeklyThresholdsRaw: [Int] var quotaWarningSessionEnabled: Bool var quotaWarningWeeklyEnabled: Bool var quotaWarningSoundEnabled: Bool + var quotaWarningOnScreenAlertEnabled: Bool var quotaWarningMarkersVisible: Bool var weeklyProgressWorkDays: Int? var usageBarsShowUsed: Bool var resetTimesShowAbsolute: Bool var providerChangelogLinksEnabled: Bool var menuBarShowsBrandIconWithPercent: Bool + var menuBarHidesCritters: Bool + var menuBarUsageColorsEnabled: Bool + var menuBarHighContrastOnInactiveDisplays: Bool var menuBarDisplayModeRaw: String? - var menuBarSeparatorStyleRaw: String? - var menuBarPercentTimeWindowRaw: String? - var menuBarPaceTimeWindowRaw: String? + var menuBarShowsResetTimeWhenExhausted: Bool var kiroMenuBarDisplayModeRaw: String? var historicalTrackingEnabled: Bool var multiAccountMenuLayoutRaw: String var menuBarMetricPreferencesRaw: [String: String] + var storedMenuBarLayout: MenuBarLayout? + var menuBarLayoutOverridesRaw: [String: MenuBarLayout] + var menuBarLayoutSizeRaw: String + var menuBarLayoutGapRaw: String + var copilotBudgetExtrasEnabled: Bool + var copilotIconSecondaryWindowIDRaw: String var costUsageEnabled: Bool + var codexLocalSessionCostLedgerEnabled: Bool var costUsageHistoryDays: Int + var costComparisonPeriodsEnabled: Bool + var costSummaryDisplayStyleRaw: String var hidePersonalInfo: Bool var randomBlinkEnabled: Bool + var confettiOnSessionLimitResetsEnabled: Bool var confettiOnWeeklyLimitResetsEnabled: Bool var menuBarShowsHighestUsage: Bool var claudeOAuthKeychainPromptModeRaw: String? var claudeOAuthKeychainReadStrategyRaw: String? var claudeWebExtrasEnabledRaw: Bool var showOptionalCreditsAndExtraUsage: Bool + var claudeDailyRoutinesUsageVisible: Bool + var codexSparkUsageVisible: Bool var openAIWebAccessEnabled: Bool var openAIWebBatterySaverEnabled: Bool var providerStorageFootprintsEnabled: Bool var jetbrainsIDEBasePath: String - var colorCodedIcons: Bool var mergeIcons: Bool var switcherShowsIcons: Bool var mergedMenuLastSelectedWasOverview: Bool var mergedOverviewSelectedProvidersRaw: [String] var selectedMenuProviderRaw: String? var providerDetectionCompleted: Bool + var providersSortedAlphabetically: Bool var appLanguageRaw: String? + var terminalAppRaw: String? + var agentSessionsEnabled: Bool + var agentSessionLabelStyleRaw: String + var agentSessionsManualHosts: String + var preferredCurrencyCode: String } diff --git a/Sources/CodexBar/ShareStatsCardView.swift b/Sources/CodexBar/ShareStatsCardView.swift new file mode 100644 index 0000000000..1cd70ccfe2 --- /dev/null +++ b/Sources/CodexBar/ShareStatsCardView.swift @@ -0,0 +1,346 @@ +import SwiftUI + +struct ShareStatsCardView: View { + static let size = CGSize(width: 1200, height: 630) + + let payload: ShareStatsPayload + + static func providerDisplayLimit(for providerCount: Int) -> Int { + providerCount > 5 ? 4 : min(providerCount, 5) + } + + static func providerPaletteIndex( + for model: ShareStatsModelPayload, + providers: [ShareStatsProviderPayload]) -> Int? + { + providers.firstIndex { $0.provider == model.provider } + } + + private let background = Color(red: 0.078, green: 0.067, blue: 0.063) + private let primary = Color(red: 0.96, green: 0.94, blue: 0.91) + private let secondary = Color(red: 0.70, green: 0.66, blue: 0.62) + private let accent = Color(red: 0.93, green: 0.56, blue: 0.36) + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + self.header + self.hero + .padding(.top, 16) + Rectangle() + .fill(self.secondary.opacity(0.22)) + .frame(height: 1) + .padding(.vertical, 17) + self.rankings + .frame(height: 286, alignment: .top) + Spacer(minLength: 10) + self.footer + } + .padding(.horizontal, 52) + .padding(.vertical, 34) + .frame(width: Self.size.width, height: Self.size.height, alignment: .topLeading) + .background(self.background) + .foregroundStyle(self.primary) + .environment(\.colorScheme, .dark) + } + + private var header: some View { + HStack(alignment: .center) { + HStack(spacing: 14) { + ShareStatsMark(accent: self.accent) + .frame(width: 34, height: 34) + Text("CodexBar") + .font(.system(size: 26, weight: .semibold, design: .rounded)) + } + Spacer() + Text("LOCAL SNAPSHOT") + .font(.system(size: 14, weight: .semibold, design: .rounded)) + .tracking(1.8) + .foregroundStyle(self.secondary) + .padding(.horizontal, 15) + .padding(.vertical, 9) + .overlay { + RoundedRectangle(cornerRadius: 12) + .stroke(self.secondary.opacity(0.45), lineWidth: 1) + } + } + } + + private var hero: some View { + HStack(alignment: .bottom, spacing: 52) { + VStack(alignment: .leading, spacing: 2) { + Text("TRACKED TOKENS · \(self.payload.days) DAYS") + .font(.system(size: 20, weight: .semibold, design: .rounded)) + .tracking(1.8) + .foregroundStyle(self.secondary) + Text(self.payload.totalTokens.map(ShareStatsFormatting.compactCount) ?? "—") + .font(.system(size: 104, weight: .semibold, design: .rounded)) + .monospacedDigit() + .lineLimit(1) + .minimumScaleFactor(0.72) + } + .frame(maxWidth: .infinity, alignment: .leading) + + VStack(alignment: .leading, spacing: 9) { + Text("EST. \(self.payload.days)-DAY SPEND") + .font(.system(size: 18, weight: .semibold, design: .rounded)) + .tracking(1.2) + .foregroundStyle(self.secondary) + ForEach(self.payload.currencies.prefix(2)) { currency in + HStack(alignment: .firstTextBaseline) { + Text("\(currency.currencyCode) · \(currency.coveredDayCount)/\(self.payload.days)d") + .font(.system(size: 17, weight: .semibold, design: .rounded)) + .foregroundStyle(self.secondary) + Spacer() + Text(currency.estimatedCost.map { + ShareStatsFormatting.currency($0, code: currency.currencyCode) + } ?? "Unavailable") + .font(.system(size: 32, weight: .semibold, design: .rounded)) + .monospacedDigit() + .lineLimit(1) + .minimumScaleFactor(0.72) + } + } + Text(self.currencySummary) + .font(.system(size: 16, weight: .medium, design: .rounded)) + .foregroundStyle(self.secondary) + } + .frame(width: 390, alignment: .leading) + } + .frame(height: 132, alignment: .bottom) + } + + private var currencySummary: String { + let hiddenCount = self.payload.currencies.count - min(self.payload.currencies.count, 2) + return hiddenCount > 0 + ? "+\(hiddenCount) more currencies · see subscription rows" + : "\(self.payload.providers.count) subscriptions · native currencies kept separate" + } + + private var rankings: some View { + HStack(alignment: .top, spacing: 46) { + VStack(alignment: .leading, spacing: 6) { + self.sectionHeader("SUBSCRIPTIONS", detail: "\(self.payload.providers.count) CONNECTED") + ForEach( + Array(self.payload.providers.prefix(self.providerDisplayLimit).enumerated()), + id: \.offset) + { index, provider in + ShareStatsProviderRow( + rank: index + 1, + provider: provider, + days: self.payload.days, + color: ShareStatsPalette.color(at: index)) + } + if self.payload.providers.count > self.providerDisplayLimit { + Text("+\(self.payload.providers.count - self.providerDisplayLimit) more configured") + .font(.system(size: 16, weight: .medium, design: .rounded)) + .foregroundStyle(self.secondary) + .padding(.leading, 20) + } + } + .frame(width: 554, alignment: .topLeading) + + VStack(alignment: .leading, spacing: 0) { + VStack(alignment: .leading, spacing: 6) { + self.sectionHeader("TOP MODELS", detail: "BY USAGE") + if self.payload.topModels.isEmpty { + Text("No model-level history in this local snapshot") + .font(.system(size: 18, weight: .medium, design: .rounded)) + .foregroundStyle(self.secondary) + .padding(.top, 4) + } else { + ForEach( + Array(self.payload.topModels.prefix(3).enumerated()), + id: \.offset) + { index, model in + ShareStatsModelRow( + rank: index + 1, + model: model, + color: self.color(for: model)) + } + } + } + Text("Only aggregate usage, plan tier, and estimated spend are included.") + .font(.system(size: 16, weight: .medium, design: .rounded)) + .foregroundStyle(self.secondary) + .padding(.top, 18) + } + .frame(maxWidth: .infinity, alignment: .topLeading) + } + } + + private var providerDisplayLimit: Int { + Self.providerDisplayLimit(for: self.payload.providers.count) + } + + private func color(for model: ShareStatsModelPayload) -> Color { + guard let index = Self.providerPaletteIndex(for: model, providers: self.payload.providers) else { + return self.secondary + } + return ShareStatsPalette.color(at: index) + } + + private func sectionHeader(_ title: String, detail: String) -> some View { + HStack(alignment: .firstTextBaseline) { + Text(title) + .font(.system(size: 18, weight: .semibold, design: .rounded)) + .tracking(1.5) + Spacer() + Text(detail) + .font(.system(size: 16, weight: .semibold, design: .rounded)) + .tracking(1.0) + } + .foregroundStyle(self.secondary) + } + + private var footer: some View { + HStack(spacing: 12) { + Label("LOCAL · AGGREGATE ONLY", systemImage: "lock.shield") + Spacer() + Text("DATA THROUGH \(ShareStatsFormatting.dataThrough(self.payload.periodEnd).uppercased())") + } + .font(.system(size: 14, weight: .medium, design: .rounded)) + .tracking(0.7) + .foregroundStyle(self.secondary) + } +} + +private struct ShareStatsModelRow: View { + let rank: Int + let model: ShareStatsModelPayload + let color: Color + + var body: some View { + HStack(spacing: 9) { + Capsule() + .fill(self.color) + .frame(width: 5, height: 34) + Text(String(format: "%02d", self.rank)) + .font(.system(size: 17, weight: .bold, design: .rounded)) + .monospacedDigit() + .foregroundStyle(Color(red: 0.70, green: 0.66, blue: 0.62)) + .frame(width: 27, alignment: .leading) + VStack(alignment: .leading, spacing: 1) { + Text(self.model.modelName) + .font(.system(size: 20, weight: .semibold, design: .rounded)) + .lineLimit(1) + .minimumScaleFactor(0.82) + Text(self.model.providerName) + .font(.system(size: 16, weight: .medium, design: .rounded)) + .foregroundStyle(Color(red: 0.70, green: 0.66, blue: 0.62)) + .lineLimit(1) + } + Spacer(minLength: 10) + Text(self.detail) + .font(.system(size: 17, weight: .medium, design: .rounded)) + .monospacedDigit() + .foregroundStyle(Color(red: 0.78, green: 0.74, blue: 0.69)) + .lineLimit(1) + } + .padding(.horizontal, 9) + .frame(height: 48) + .background(Color.white.opacity(0.035), in: RoundedRectangle(cornerRadius: 9)) + .overlay { + RoundedRectangle(cornerRadius: 9) + .stroke(Color.white.opacity(0.10), lineWidth: 1) + } + } + + private var detail: String { + if let cost = self.model.estimatedCost, cost.isFinite { + return "~\(ShareStatsFormatting.currency(cost, code: self.model.currencyCode))" + } + return self.model.totalTokens.map(ShareStatsFormatting.compactCount) ?? "used" + } +} + +private struct ShareStatsProviderRow: View { + let rank: Int + let provider: ShareStatsProviderPayload + let days: Int + let color: Color + + var body: some View { + HStack(spacing: 9) { + Capsule() + .fill(self.color) + .frame(width: 6, height: 30) + Text(String(format: "%02d", self.rank)) + .font(.system(size: 17, weight: .bold, design: .rounded)) + .monospacedDigit() + .foregroundStyle(Color(red: 0.70, green: 0.66, blue: 0.62)) + .frame(width: 27, alignment: .leading) + HStack(spacing: 8) { + Text(self.provider.providerName) + .font(.system(size: 20, weight: .semibold, design: .rounded)) + .lineLimit(1) + .minimumScaleFactor(0.82) + if let subscriptionName = self.provider.subscriptionName { + Text("· \(subscriptionName)") + .font(.system(size: 17, weight: .medium, design: .rounded)) + .foregroundStyle(Color(red: 0.70, green: 0.66, blue: 0.62)) + .lineLimit(1) + } + } + Spacer(minLength: 12) + Text(self.detail) + .font(.system(size: 18, weight: .medium, design: .rounded)) + .monospacedDigit() + .foregroundStyle(Color(red: 0.78, green: 0.74, blue: 0.69)) + .lineLimit(1) + .minimumScaleFactor(0.82) + } + .padding(.horizontal, 9) + .frame(height: 44) + .background(Color.white.opacity(0.035), in: RoundedRectangle(cornerRadius: 9)) + .overlay { + RoundedRectangle(cornerRadius: 9) + .stroke(Color.white.opacity(0.10), lineWidth: 1) + } + } + + private var detail: String { + var metrics: [String] = [] + if let tokens = self.provider.totalTokens { + metrics.append(ShareStatsFormatting.compactCount(tokens)) + } + if let cost = self.provider.estimatedCost, cost.isFinite { + metrics.append("~\(ShareStatsFormatting.currency(cost, code: self.provider.currencyCode))") + if self.provider.coveredDayCount < self.days { + metrics.append("\(self.provider.coveredDayCount)/\(self.days)d") + } + } else { + metrics.append("Spend unavailable") + } + return metrics.isEmpty ? "connected" : metrics.joined(separator: " · ") + } +} + +private enum ShareStatsPalette { + static let colors = [ + Color(red: 1.00, green: 0.60, blue: 0.38), + Color(red: 0.60, green: 0.66, blue: 1.00), + Color(red: 0.38, green: 0.84, blue: 0.72), + Color(red: 0.95, green: 0.79, blue: 0.41), + Color(red: 0.44, green: 0.77, blue: 0.96), + Color(red: 0.95, green: 0.55, blue: 0.67), + ] + + static func color(at index: Int) -> Color { + self.colors[index % self.colors.count] + } +} + +private struct ShareStatsMark: View { + let accent: Color + + var body: some View { + HStack(alignment: .bottom, spacing: 4) { + ForEach(Array([0.38, 0.68, 1.0].enumerated()), id: \.offset) { _, height in + Capsule() + .fill(self.accent) + .frame(width: 5, height: 28 * height) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) + } +} diff --git a/Sources/CodexBar/ShareStatsPayload.swift b/Sources/CodexBar/ShareStatsPayload.swift new file mode 100644 index 0000000000..744f41ee17 --- /dev/null +++ b/Sources/CodexBar/ShareStatsPayload.swift @@ -0,0 +1,482 @@ +import CodexBarCore +import Foundation + +struct ShareStatsProviderPayload: Sendable, Equatable { + let provider: UsageProvider + let providerName: String + let subscriptionName: String? + let currencyCode: String + let totalTokens: Int? + let estimatedCost: Double? + let coveredDayCount: Int +} + +struct ShareStatsModelPayload: Sendable, Equatable { + let provider: UsageProvider + let providerName: String + let modelName: String + let currencyCode: String + let totalTokens: Int? + let estimatedCost: Double? +} + +private struct ShareStatsModelFamilyKey: Hashable { + let provider: UsageProvider + let providerName: String + let modelName: String + let currencyCode: String +} + +private struct ShareStatsModelFamilyAccumulator { + let key: ShareStatsModelFamilyKey + private var totalTokens: Int? + private var estimatedCost: Double? + private var tokenOverflowed = false + private var costOverflowed = false + private var tokenIncomplete: Bool + private var costIncomplete: Bool + + init(key: ShareStatsModelFamilyKey, row: ShareStatsModelPayload) { + self.key = key + self.totalTokens = row.totalTokens + self.estimatedCost = row.estimatedCost + self.tokenIncomplete = row.totalTokens == nil + self.costIncomplete = row.estimatedCost == nil + } + + mutating func add(_ row: ShareStatsModelPayload) { + self.tokenIncomplete = self.tokenIncomplete || row.totalTokens == nil + self.costIncomplete = self.costIncomplete || row.estimatedCost == nil + if !self.tokenOverflowed, let value = row.totalTokens { + if let totalTokens { + let result = totalTokens.addingReportingOverflow(value) + self.totalTokens = result.overflow ? nil : result.partialValue + self.tokenOverflowed = result.overflow + } else { + self.totalTokens = value + } + } + if !self.costOverflowed, let value = row.estimatedCost { + if let estimatedCost { + let total = estimatedCost + value + self.estimatedCost = total.isFinite ? total : nil + self.costOverflowed = !total.isFinite + } else { + self.estimatedCost = value + } + } + } + + var payload: ShareStatsModelPayload? { + let totalTokens = self.tokenIncomplete ? nil : self.totalTokens + let estimatedCost = self.costIncomplete ? nil : self.estimatedCost + guard totalTokens != nil || estimatedCost != nil else { return nil } + return ShareStatsModelPayload( + provider: self.key.provider, + providerName: self.key.providerName, + modelName: self.key.modelName, + currencyCode: self.key.currencyCode, + totalTokens: totalTokens, + estimatedCost: estimatedCost) + } +} + +struct ShareStatsCurrencyPayload: Sendable, Equatable, Identifiable { + let currencyCode: String + let estimatedCost: Double? + let coveredDayCount: Int + + var id: String { + self.currencyCode + } +} + +struct ShareStatsPayload: Sendable, Equatable { + let days: Int + let periodEnd: Date + let providers: [ShareStatsProviderPayload] + let topModels: [ShareStatsModelPayload] + let currencies: [ShareStatsCurrencyPayload] + let totalTokens: Int? + + var hasShareableData: Bool { + !self.providers.isEmpty && self.providers.contains { provider in + provider.totalTokens != nil || provider.estimatedCost != nil + } + } +} + +struct ShareStatsSubscriptionName: Sendable, Equatable { + let displayName: String + + private init(displayName: String) { + self.displayName = displayName + } + + private static let labelsByProvider: [String: [String: String]] = [ + UsageProvider.codex.rawValue: [ + "guest": "Guest", "free": "Free", "go": "Go", "plus": "Plus", "plus plan": "Plus", + "chatgpt plus": "Plus", "chatgpt-plus": "Plus", "chatgpt_plus": "Plus", + "pro": "Pro 20x", "codex pro": "Pro 20x", + "prolite": "Pro 5x", "pro_lite": "Pro 5x", "pro-lite": "Pro 5x", + "pro lite": "Pro 5x", "codex pro lite": "Pro 5x", + "free_workspace": "Free Workspace", "team": "Team", "business": "Business", + "education": "Education", "quorum": "Quorum", "k12": "K12", + "enterprise": "Enterprise", "edu": "Edu", + ], + UsageProvider.claude.rawValue: [ + "free": "Free", "claude free": "Free", "pro": "Pro", "claude pro": "Pro", + "max": "Max", "claude max": "Max", "max 5x": "Max 5x", "claude max 5x": "Max 5x", + "max 20x": "Max 20x", "claude max 20x": "Max 20x", "team": "Team", + "claude team": "Team", "claude team standard": "Team Standard", + "claude team premium": "Team Premium", "enterprise": "Enterprise", + "claude enterprise": "Enterprise", "ultra": "Ultra", "claude ultra": "Ultra", + ], + UsageProvider.cursor.rawValue: [ + "free": "Cursor Free", "cursor free": "Cursor Free", + "hobby": "Cursor Hobby", "cursor hobby": "Cursor Hobby", + "pro": "Cursor Pro", "cursor pro": "Cursor Pro", + "team": "Cursor Team", "cursor team": "Cursor Team", + "business": "Cursor Business", "cursor business": "Cursor Business", + "enterprise": "Cursor Enterprise", "cursor enterprise": "Cursor Enterprise", + "ultra": "Cursor Ultra", "cursor ultra": "Cursor Ultra", + ], + UsageProvider.alibaba.rawValue: [ + "lite": "Lite", "coding plan lite": "Lite", "pro": "Pro", "active pro": "Pro", + "alibaba coding plan pro": "Pro", "starter": "Starter", "enterprise": "Enterprise", + ], + UsageProvider.alibabatokenplan.rawValue: [ + "token plan": "Token Plan", "token plan pro": "Token Plan Pro", + "token plan plus": "Token Plan Plus", + ], + UsageProvider.gemini.rawValue: [ + "free": "Free", "paid": "Paid", "plus": "Plus", "workspace": "Workspace", + "legacy": "Legacy", "gemini code assist in google one ai pro": "Google One AI Pro", + ], + UsageProvider.antigravity.rawValue: [ + "free": "Free", "paid": "Paid", "pro": "Pro", + "ultra": "Google AI Ultra", "google ai ultra": "Google AI Ultra", + ], + UsageProvider.copilot.rawValue: [ + "free": "Free", "individual": "Individual", "pro": "Individual", + "business": "Business", "enterprise": "Enterprise", + ], + UsageProvider.devin.rawValue: [ + "free": "Free", "core": "Core", "pro": "Pro", "team": "Team", "enterprise": "Enterprise", + ], + UsageProvider.zai.rawValue: [ + "free": "Free", "pro": "Pro", "max": "Max", "team": "Team", + ], + UsageProvider.minimax.rawValue: [ + "free": "Free", "pro": "Pro", "plus": "Plus", "max": "Max", "ultra": "Ultra", + "minimax star": "MiniMax Star", "combo star": "Combo Star", "coding plan pro": "Coding Plan Pro", + "token plan pro": "Token Plan Pro", "token plan · tokenplanplus-年度会员": "Token Plan Plus", + "tokenplanplus-年度会员": "Token Plan Plus", "tokenplanmax-年度会员": "Token Plan Max", + "tokenplanultra-年度会员": "Token Plan Ultra", + ], + UsageProvider.augment.rawValue: [ + "free": "Free", "community": "Community", "indie": "Indie", "pro": "Pro", + "team": "Team", "enterprise": "Enterprise", + ], + UsageProvider.elevenlabs.rawValue: [ + "free": "Free", "starter": "Starter", "creator": "Creator", "pro": "Pro", + "scale": "Scale", "business": "Business", "growing business": "Business", + "enterprise": "Enterprise", + ], + UsageProvider.windsurf.rawValue: [ + "free": "Free", "pro": "Pro", "team": "Teams", "teams": "Teams", + "enterprise": "Enterprise", "ultimate": "Ultimate", + ], + UsageProvider.zed.rawValue: [ + "zed free": "Zed Free", "zed pro": "Zed Pro", "zed pro trial": "Zed Pro Trial", + "zed student": "Zed Student", "zed business": "Zed Business", + ], + UsageProvider.perplexity.rawValue: ["pro": "Pro", "max": "Max"], + UsageProvider.sakana.rawValue: [ + "standard": "Standard", "standard $20/mo": "Standard", "pro": "Pro", "enterprise": "Enterprise", + ], + UsageProvider.abacus.rawValue: [ + "basic": "Basic", "pro": "Pro", "team": "Team", "enterprise": "Enterprise", + ], + UsageProvider.synthetic.rawValue: [ + "starter": "Starter", "pro": "Pro", "team": "Team", "enterprise": "Enterprise", + ], + UsageProvider.t3chat.rawValue: ["free": "Free", "pro": "Pro", "team": "Team"], + UsageProvider.sub2api.rawValue: [ + "free": "Free", "pro": "Pro", "team": "Team", "claude team": "Team", + "enterprise": "Enterprise", "wallet plan": "Wallet", + ], + ] + + /// Converts plan-bearing provider identity into a closed, non-identifying share-card value. + static func from(snapshot: UsageSnapshot?, provider: UsageProvider) -> Self? { + guard let identity = snapshot?.identity(for: provider), + let rawName = identity.loginMethod, + !Self.matchesAccountIdentity(rawName, identity: identity) + else { return nil } + + let key = rawName.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + guard !key.isEmpty, let displayName = Self.labelsByProvider[provider.rawValue]?[key] else { return nil } + return Self(displayName: displayName) + } + + static func first(from snapshots: [UsageSnapshot?], provider: UsageProvider) -> Self? { + snapshots.lazy.compactMap { Self.from(snapshot: $0, provider: provider) }.first + } + + private static func matchesAccountIdentity(_ rawName: String, identity: ProviderIdentitySnapshot) -> Bool { + let candidate = rawName.trimmingCharacters(in: .whitespacesAndNewlines) + return [identity.accountEmail, identity.accountOrganization] + .compactMap { $0?.trimmingCharacters(in: .whitespacesAndNewlines) } + .contains { $0.localizedCaseInsensitiveCompare(candidate) == .orderedSame } + } +} + +enum ShareStatsSanitizer { + static func modelName(_ rawValue: String) -> String? { + guard let value = self.safeLabel( + rawValue, + maximumLength: 72, + maximumWords: 3, + requireModelShape: true) + else { return nil } + + let normalized = value.lowercased() + let regionalPrefixes = ["us.", "eu.", "apac.", "global."] + let familyName = regionalPrefixes.first { normalized.hasPrefix($0) }.map { + String(normalized.dropFirst($0.count)) + } ?? normalized + let publicModelFamilies: [(prefixes: [String], label: String)] = [ + (["amazon.nova-", "nova-"], "Amazon Nova"), + (["anthropic.claude-", "claude-", "claude "], "Claude"), + (["chatgpt-", "gpt-"], "GPT"), + (["codex-"], "Codex"), + (["command-"], "Command"), + (["dall-e-"], "DALL-E"), + (["deepseek-"], "DeepSeek"), + (["codestral-", "devstral-", "magistral-", "mistral-", "mistral ", "mistral.", "mixtral-"], "Mistral"), + (["gemma-"], "Gemma"), + (["google.gemini-", "gemini-", "gemini "], "Gemini"), + (["glm-"], "GLM"), + (["grok-"], "Grok"), + (["kimi-", "moonshot-"], "Kimi"), + (["meta.llama", "llama-", "llama "], "Llama"), + (["minimax-"], "MiniMax"), + (["o1"], "o1"), + (["o3"], "o3"), + (["o4"], "o4"), + (["phi-"], "Phi"), + (["qwen"], "Qwen"), + (["sonar-"], "Sonar"), + (["text-embedding-"], "OpenAI Embeddings"), + (["tts-"], "OpenAI TTS"), + (["whisper-"], "Whisper"), + ] + guard !normalized.contains("://"), + !normalized.contains("/"), + !normalized.contains("\\") + else { return nil } + return publicModelFamilies.first { family in + family.prefixes.contains(where: familyName.hasPrefix) + }?.label + } + + private static func safeLabel( + _ rawValue: String, + maximumLength: Int, + maximumWords: Int, + requireModelShape: Bool) -> String? + { + let value = rawValue.trimmingCharacters(in: .whitespacesAndNewlines) + guard !value.isEmpty, + value.count <= maximumLength, + !value.contains("@"), + !value.contains(where: { $0.isNewline || $0.isASCII && $0.asciiValue.map { $0 < 0x20 } == true }), + value.split(whereSeparator: { $0.isWhitespace }).count <= maximumWords, + value + .range(of: #"(?i)(^|[/\\])(?:Users|home|private|Volumes)([/\\]|$)"#, options: .regularExpression) == + nil, + value.range(of: #"(?i)^[a-z]:\\"#, options: .regularExpression) == nil, + value.range( + of: #"(?i)\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b"#, + options: .regularExpression) == nil, + value.range(of: #"(?i)\b[0-9a-f]{24,}\b"#, options: .regularExpression) == nil, + value.range(of: #"^[\p{L}\p{N}][\p{L}\p{N} ._+:/()\-]*$"#, options: .regularExpression) != nil + else { return nil } + + if requireModelShape { + let hasModelPunctuation = value.contains { "-_/+.".contains($0) } + guard hasModelPunctuation || value.contains(where: \Character.isNumber) else { return nil } + } + return value + } +} + +enum ShareStatsBuilder { + static func make( + model: SpendDashboardModel, + subscriptionNames: [String: ShareStatsSubscriptionName] = [:]) -> ShareStatsPayload? + { + let providers = model.groups.flatMap { group in + group.providers.map { row in + ShareStatsProviderPayload( + provider: row.provider, + providerName: row.displayName, + subscriptionName: subscriptionNames[row.id]?.displayName, + currencyCode: group.currencyCode, + totalTokens: row.totalTokens, + estimatedCost: self.finiteCost(row.totalCost), + coveredDayCount: row.coveredDayCount) + } + } + let sanitizedModels = model.groups.filter { + $0.modelHistoryCompleteness == .complete + }.flatMap { group in + group.models.compactMap { row -> ShareStatsModelPayload? in + let estimatedCost = self.finiteCost(row.totalCost) + guard let modelName = ShareStatsSanitizer.modelName(row.modelName), + row.totalTokens != nil + else { return nil } + return ShareStatsModelPayload( + provider: row.provider, + providerName: row.providerName, + modelName: modelName, + currencyCode: group.currencyCode, + totalTokens: row.totalTokens, + estimatedCost: estimatedCost) + } + } + var modelFamilies: [ShareStatsModelFamilyKey: ShareStatsModelFamilyAccumulator] = [:] + for row in sanitizedModels { + let key = ShareStatsModelFamilyKey( + provider: row.provider, + providerName: row.providerName, + modelName: row.modelName, + currencyCode: row.currencyCode) + if var existing = modelFamilies[key] { + existing.add(row) + modelFamilies[key] = existing + } else { + modelFamilies[key] = ShareStatsModelFamilyAccumulator(key: key, row: row) + } + } + let topModels = modelFamilies.values.compactMap(\.payload).sorted { lhs, rhs in + switch (lhs.totalTokens, rhs.totalTokens) { + case let (left?, right?) where left != right: return left > right + case (_?, nil): return true + case (nil, _?): return false + default: + if lhs.providerName != rhs.providerName { + return lhs.providerName < rhs.providerName + } + return lhs.modelName < rhs.modelName + } + } + let currencies = model.groups.map { + ShareStatsCurrencyPayload( + currencyCode: $0.currencyCode, + estimatedCost: self.finiteCost($0.totalCost), + coveredDayCount: $0.coveredDayCount) + } + let totalTokens = self.combinedTotalTokens(model.groups.map(\.totalTokens)) + let periodEnd = model.groups.map(\.chartDomain.upperBound).max() ?? Date() + let payload = ShareStatsPayload( + days: model.requestedDays, + periodEnd: periodEnd, + providers: providers, + topModels: topModels, + currencies: currencies, + totalTokens: totalTokens) + return payload.hasShareableData ? payload : nil + } + + private static func finiteCost(_ value: Double?) -> Double? { + guard let value, value.isFinite, value >= 0 else { return nil } + return value + } + + static func combinedTotalTokens(_ values: [Int?]) -> Int? { + var total = 0 + for value in values { + guard let value else { return nil } + let result = total.addingReportingOverflow(value) + guard !result.overflow else { return nil } + total = result.partialValue + } + return total + } +} + +enum ShareStatsFormatting { + static func compactCount(_ value: Int) -> String { + let magnitude = abs(Double(value)) + let divisor: Double + let suffix: String + switch magnitude { + case 1_000_000_000...: divisor = 1_000_000_000; suffix = "B" + case 1_000_000...: divisor = 1_000_000; suffix = "M" + case 1000...: divisor = 1000; suffix = "K" + default: return value.formatted(.number.grouping(.automatic)) + } + let scaled = Double(value) / divisor + let digits = magnitude >= divisor * 100 ? 0 : magnitude >= divisor * 10 ? 1 : 2 + return scaled.formatted(.number.precision(.fractionLength(0...digits))) + suffix + } + + static func currency(_ value: Double, code: String) -> String { + UsageFormatter.currencyString(value, currencyCode: code) + } + + static func dataThrough(_ date: Date, calendar: Calendar = .current) -> String { + let formatter = DateFormatter() + formatter.calendar = calendar + formatter.timeZone = calendar.timeZone + formatter.locale = .current + formatter.setLocalizedDateFormatFromTemplate("MMM d, yyyy") + return formatter.string(from: date) + } + + static func text(_ payload: ShareStatsPayload) -> String { + var lines = ["My AI subscriptions · last \(payload.days) days"] + if let tokens = payload.totalTokens { + lines.append("\(self.compactCount(tokens)) tracked tokens") + } + lines.append(contentsOf: payload.currencies.map { currency in + let spend = currency.estimatedCost.map { "\(self.currency($0, code: currency.currencyCode)) estimated" } + ?? "Spend unavailable" + return "\(currency.currencyCode): \(spend) · " + + "coverage \(currency.coveredDayCount)/\(payload.days) days" + }) + lines.append(contentsOf: payload.providers.map { provider in + var metrics: [String] = [] + if let tokens = provider.totalTokens { + metrics.append("\(self.compactCount(tokens)) tokens") + } + if let cost = provider.estimatedCost { + metrics.append("~\(self.currency(cost, code: provider.currencyCode)) est") + } else { + metrics.append("Spend unavailable") + } + if provider.estimatedCost != nil, provider.coveredDayCount < payload.days { + metrics.append("\(provider.coveredDayCount)/\(payload.days) days") + } + let subscription = provider.subscriptionName.map { " · \($0)" } ?? "" + return "\(provider.providerName)\(subscription): \(metrics.joined(separator: " · "))" + }) + if !payload.topModels.isEmpty { + lines.append("Top models:") + lines.append(contentsOf: payload.topModels.prefix(5).map { model in + var metrics: [String] = [] + if let tokens = model.totalTokens { + metrics.append("\(self.compactCount(tokens)) tokens") + } + if let cost = model.estimatedCost { + metrics.append("~\(self.currency(cost, code: model.currencyCode)) est") + } + return "\(model.modelName) (\(model.providerName)): \(metrics.joined(separator: " · "))" + }) + } + lines.append("Generated locally by CodexBar · Data through \(self.dataThrough(payload.periodEnd))") + return lines.joined(separator: "\n") + } +} diff --git a/Sources/CodexBar/ShareStatsRenderer.swift b/Sources/CodexBar/ShareStatsRenderer.swift new file mode 100644 index 0000000000..7d440c8bea --- /dev/null +++ b/Sources/CodexBar/ShareStatsRenderer.swift @@ -0,0 +1,75 @@ +import AppKit +import SwiftUI + +@MainActor +enum ShareStatsRenderer { + static func pngData(for payload: ShareStatsPayload) -> Data? { + let size = ShareStatsCardView.size + let view = NSHostingView(rootView: ShareStatsCardView(payload: payload)) + view.frame = CGRect(origin: .zero, size: size) + view.layoutSubtreeIfNeeded() + + guard let representation = NSBitmapImageRep( + bitmapDataPlanes: nil, + pixelsWide: Int(size.width), + pixelsHigh: Int(size.height), + bitsPerSample: 8, + samplesPerPixel: 4, + hasAlpha: true, + isPlanar: false, + colorSpaceName: .deviceRGB, + bytesPerRow: 0, + bitsPerPixel: 0) + else { return nil } + representation.size = size + guard let context = NSGraphicsContext(bitmapImageRep: representation) else { return nil } + view.displayIgnoringOpacity(view.bounds, in: context) + return representation.representation(using: .png, properties: [:]) + } + + static func image(for payload: ShareStatsPayload) -> NSImage? { + guard let data = self.pngData(for: payload) else { return nil } + return NSImage(data: data) + } +} + +@MainActor +enum ShareStatsExporter { + static func copyImage(_ payload: ShareStatsPayload) -> Bool { + guard let data = ShareStatsRenderer.pngData(for: payload), + let image = NSImage(data: data) else { return false } + let pasteboard = NSPasteboard.general + let item = NSPasteboardItem() + item.setData(data, forType: .png) + if let tiff = image.tiffRepresentation { + item.setData(tiff, forType: .tiff) + } + pasteboard.clearContents() + return pasteboard.writeObjects([item]) + } + + static func copyText(_ payload: ShareStatsPayload) { + MenuPasteboardCopy.perform(ShareStatsFormatting.text(payload)) + } + + static func saveImage(_ payload: ShareStatsPayload) -> Bool { + guard let data = ShareStatsRenderer.pngData(for: payload) else { return false } + let panel = NSSavePanel() + panel.allowedContentTypes = [.png] + panel.canCreateDirectories = true + panel.nameFieldStringValue = self.defaultFilename(payload) + let response = panel.runModal() + guard response == .OK, let url = panel.url else { return false } + do { + try data.write(to: url, options: .atomic) + return true + } catch { + NSSound.beep() + return false + } + } + + private static func defaultFilename(_ payload: ShareStatsPayload) -> String { + "codexbar-subscriptions-last-\(payload.days)-days.png" + } +} diff --git a/Sources/CodexBar/ShareStatsWindowController.swift b/Sources/CodexBar/ShareStatsWindowController.swift new file mode 100644 index 0000000000..7a4ffe3e0f --- /dev/null +++ b/Sources/CodexBar/ShareStatsWindowController.swift @@ -0,0 +1,139 @@ +import AppKit +import SwiftUI + +@MainActor +final class ShareStatsPresenter { + static let shared = ShareStatsPresenter() + + private var windowController: ShareStatsWindowController? + + func present(payload: ShareStatsPayload) { + let controller = self.windowController ?? ShareStatsWindowController(payload: payload) + controller.update(payload: payload) + self.windowController = controller + controller.present() + } +} + +@MainActor +final class ShareStatsWindowController: NSWindowController, NSWindowDelegate { + private(set) var payload: ShareStatsPayload + + init(payload: ShareStatsPayload) { + self.payload = payload + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 820, height: 565), + styleMask: [.titled, .closable, .miniaturizable], + backing: .buffered, + defer: false) + window.title = L("Share AI Usage") + window.isReleasedWhenClosed = false + window.center() + super.init(window: window) + window.delegate = self + self.installContent() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func update(payload: ShareStatsPayload) { + self.payload = payload + self.installContent() + } + + func present() { + NSApp.activate(ignoringOtherApps: true) + self.showWindow(nil) + self.window?.makeKeyAndOrderFront(nil) + } + + private func installContent() { + self.window?.contentViewController = NSHostingController(rootView: ShareStatsPreviewView( + payload: self.payload, + copyImage: { [weak self] in + guard let self else { return false } + return ShareStatsExporter.copyImage(self.payload) + }, + copyText: { [weak self] in + guard let self else { return } + ShareStatsExporter.copyText(self.payload) + }, + saveImage: { [weak self] in + guard let self else { return false } + return ShareStatsExporter.saveImage(self.payload) + })) + } +} + +private struct ShareStatsPreviewView: View { + let payload: ShareStatsPayload + let copyImage: @MainActor () -> Bool + let copyText: @MainActor () -> Void + let saveImage: @MainActor () -> Bool + + @State private var statusMessage: String? + + var body: some View { + VStack(spacing: 20) { + ShareStatsScaledPreview(payload: self.payload) + .clipShape(RoundedRectangle(cornerRadius: 12)) + .overlay { + RoundedRectangle(cornerRadius: 12) + .stroke(Color.primary.opacity(0.12), lineWidth: 1) + } + .shadow(color: .black.opacity(0.18), radius: 18, y: 8) + + HStack(spacing: 12) { + Button { + self.statusMessage = self.copyImage() ? L("Image copied") : L("Could not copy image") + } label: { + Label(L("Copy Image"), systemImage: "photo.on.rectangle") + } + .keyboardShortcut(.defaultAction) + + Button { + self.copyText() + self.statusMessage = L("Stats copied") + } label: { + Label(L("Copy Stats"), systemImage: "doc.on.doc") + } + + Button { + if self.saveImage() { + self.statusMessage = L("Image saved") + } + } label: { + Label(L("Save..."), systemImage: "square.and.arrow.down") + } + + Spacer() + + Text(self.statusMessage ?? L("Nothing is uploaded. This image is created on your Mac.")) + .font(.footnote) + .foregroundStyle(.secondary) + .accessibilityLabel(self + .statusMessage ?? L("Nothing is uploaded. This image is created on your Mac.")) + } + } + .padding(24) + .frame(minWidth: 780, minHeight: 525) + } +} + +private struct ShareStatsScaledPreview: View { + let payload: ShareStatsPayload + + var body: some View { + GeometryReader { proxy in + let scale = min( + proxy.size.width / ShareStatsCardView.size.width, + proxy.size.height / ShareStatsCardView.size.height) + ShareStatsCardView(payload: self.payload) + .scaleEffect(scale, anchor: .topLeading) + } + .aspectRatio(ShareStatsCardView.size.width / ShareStatsCardView.size.height, contentMode: .fit) + } +} diff --git a/Sources/CodexBar/SpendDashboardController.swift b/Sources/CodexBar/SpendDashboardController.swift new file mode 100644 index 0000000000..8b7de621e3 --- /dev/null +++ b/Sources/CodexBar/SpendDashboardController.swift @@ -0,0 +1,1054 @@ +import CodexBarCore +import CryptoKit +import Foundation +import Observation + +struct SpendDashboardConfiguration: Equatable, Sendable { + let costUsageEnabled: Bool + let preferredCurrencyCode: String + let providerIDs: [String] + let codexAccountIdentities: [String] + let codexAccountDisplayNames: [String: String] + let sourceOwnershipFingerprints: [String] + let sourceRevisions: [String] + + init( + costUsageEnabled: Bool, + preferredCurrencyCode: String = "auto", + providerIDs: [String], + codexAccountIdentities: [String], + codexAccountDisplayNames: [String: String] = [:], + sourceOwnershipFingerprints: [String] = [], + sourceRevisions: [String] = []) + { + self.costUsageEnabled = costUsageEnabled + self.preferredCurrencyCode = preferredCurrencyCode + self.providerIDs = providerIDs + self.codexAccountIdentities = codexAccountIdentities + self.codexAccountDisplayNames = codexAccountDisplayNames + self.sourceOwnershipFingerprints = sourceOwnershipFingerprints + self.sourceRevisions = sourceRevisions + } +} + +struct CodexSpendScanRequest: Equatable, Sendable { + let id: String + let displayName: String + let source: CodexActiveSource + let homePath: String + let authFingerprint: String? + let authFileWasReadable: Bool + let cacheIdentity: String +} + +enum SpendDashboardRequestBuildMode: Equatable, Sendable { + case refreshMissing + case forceRefresh + case captureOnly + + var forcesLoader: Bool { + self == .forceRefresh + } + + func shouldRefresh(hasPublication: Bool) -> Bool { + switch self { + case .refreshMissing: !hasPublication + case .forceRefresh: true + case .captureOnly: false + } + } +} + +struct SpendDashboardLoadRequest: Sendable { + let configuration: SpendDashboardConfiguration + let capturedInputs: [SpendDashboardModel.ProviderInput] + let unavailableSourceIDs: Set + let confirmedEmptySourceIDs: Set + let codexRequests: [CodexSpendScanRequest] + let now: Date + let force: Bool + + init( + configuration: SpendDashboardConfiguration, + capturedInputs: [SpendDashboardModel.ProviderInput], + unavailableSourceIDs: Set, + confirmedEmptySourceIDs: Set = [], + codexRequests: [CodexSpendScanRequest], + now: Date, + force: Bool) + { + self.configuration = configuration + self.capturedInputs = capturedInputs + self.unavailableSourceIDs = unavailableSourceIDs + self.confirmedEmptySourceIDs = confirmedEmptySourceIDs + self.codexRequests = codexRequests + self.now = now + self.force = force + } +} + +struct SpendDashboardLoadResult: Sendable { + let inputs: [SpendDashboardModel.ProviderInput] + let failedSourceIDs: Set + let invalidatedSourceIDs: Set + + init( + inputs: [SpendDashboardModel.ProviderInput], + failedSourceIDs: Set, + invalidatedSourceIDs: Set = []) + { + self.inputs = inputs + self.failedSourceIDs = failedSourceIDs + self.invalidatedSourceIDs = invalidatedSourceIDs + } + + var failedSourceCount: Int { + self.failedSourceIDs.count + } +} + +struct CodexSpendSnapshotLoadContext: Sendable { + let account: CodexSpendScanRequest + let cacheRoot: URL + let now: Date + let force: Bool + let historyDays: Int + let refreshPricingInBackground: Bool + let includePiSessions: Bool +} + +enum SpendDashboardSource { + typealias CodexSnapshotLoader = @Sendable (CodexSpendSnapshotLoadContext) async throws + -> CostUsageTokenSnapshot + + static let scanDays = 30 + + @MainActor + static func configuration(settings: SettingsStore, store: UsageStore) -> SpendDashboardConfiguration { + let providers = self.costCapableProviders(store: store) + let codexRequests = providers.contains(.codex) + ? self.codexRequests(settings: settings, store: store) + : [] + return self.configuration( + settings: settings, + store: store, + providers: providers, + codexRequests: codexRequests) + } + + @MainActor + private static func configuration( + settings: SettingsStore, + store: UsageStore, + providers: [UsageProvider], + codexRequests: [CodexSpendScanRequest]) -> SpendDashboardConfiguration + { + SpendDashboardConfiguration( + costUsageEnabled: settings.costUsageEnabled, + preferredCurrencyCode: settings.preferredCurrencyCode, + providerIDs: providers.map(\.rawValue), + codexAccountIdentities: codexRequests.map { "\($0.id)|\($0.cacheIdentity)" }, + codexAccountDisplayNames: self.codexDisplayNamesByID(codexRequests), + sourceOwnershipFingerprints: self.sourceOwnershipFingerprints( + providers: providers, + settings: settings, + store: store), + sourceRevisions: self.sourceRevisions(providers: providers, settings: settings, store: store)) + } + + @MainActor + static func makeRequest( + settings: SettingsStore, + store: UsageStore, + mode: SpendDashboardRequestBuildMode, + now: Date? = nil, + nowProvider: @escaping @Sendable () -> Date = { Date() }) async -> SpendDashboardLoadRequest + { + guard settings.costUsageEnabled else { + return SpendDashboardLoadRequest( + configuration: self.configuration(settings: settings, store: store), + capturedInputs: [], + unavailableSourceIDs: [], + codexRequests: [], + now: now ?? nowProvider(), + force: mode.forcesLoader) + } + + let initialProviders = self.costCapableProviders(store: store) + let providerBaselines = initialProviders.filter { $0 != .codex }.map { provider in + ( + provider: provider, + publication: store.tokenSnapshotPublicationForCurrentProviderConfig(for: provider), + publicationRevision: store.tokenSnapshotPublicationRevision(for: provider)) + } + for baseline in providerBaselines where mode.shouldRefresh(hasPublication: baseline.publication != nil) { + if UsageStore.tokenCostRequiresProviderSnapshot(baseline.provider) { + await store.refreshProvider(baseline.provider) + } else { + await store.refreshTokenUsageNow(for: baseline.provider, force: true) + } + } + + // A later provider refresh can suspend while an earlier provider publishes again. + // Capture every provider only after all refresh work finishes so the request owns the + // newest same-scope publication available at this boundary. + let captureNow = now ?? nowProvider() + let providers = self.costCapableProviders(store: store) + let codexRequests = providers.contains(.codex) + ? self.codexRequests(settings: settings, store: store) + : [] + let configuration = self.configuration( + settings: settings, + store: store, + providers: providers, + codexRequests: codexRequests) + guard configuration.costUsageEnabled else { + return SpendDashboardLoadRequest( + configuration: configuration, + capturedInputs: [], + unavailableSourceIDs: [], + codexRequests: [], + now: captureNow, + force: mode.forcesLoader) + } + + var inputs: [SpendDashboardModel.ProviderInput] = [] + var unavailableSourceIDs: Set = [] + var confirmedEmptySourceIDs: Set = [] + for provider in providers where provider != .codex { + guard let baseline = providerBaselines.first(where: { $0.provider == provider }) else { + unavailableSourceIDs.insert(provider.rawValue) + continue + } + let shouldRefresh = mode.shouldRefresh(hasPublication: baseline.publication != nil) + let current = store.tokenSnapshotPublicationForCurrentProviderConfig(for: provider) + guard let current else { + unavailableSourceIDs.insert(provider.rawValue) + continue + } + if shouldRefresh, baseline.publicationRevision == current.publicationRevision { + unavailableSourceIDs.insert(provider.rawValue) + continue + } + guard let snapshot = current.snapshot else { + confirmedEmptySourceIDs.insert(provider.rawValue) + continue + } + inputs.append(SpendDashboardModel.ProviderInput( + provider: provider, + displayName: store.metadata(for: provider).displayName, + snapshot: snapshot)) + } + return SpendDashboardLoadRequest( + configuration: configuration, + capturedInputs: inputs, + unavailableSourceIDs: unavailableSourceIDs, + confirmedEmptySourceIDs: confirmedEmptySourceIDs, + codexRequests: codexRequests, + now: captureNow, + force: mode.forcesLoader) + } + + static func load(_ request: SpendDashboardLoadRequest) async -> SpendDashboardLoadResult { + await self.load(request, codexSnapshotLoader: { context in + try await self.loadCodexSnapshot(context) + }) + } + + static func load( + _ request: SpendDashboardLoadRequest, + codexSnapshotLoader: CodexSnapshotLoader) async -> SpendDashboardLoadResult + { + var inputs = request.capturedInputs + var failedSourceIDs = request.unavailableSourceIDs + var invalidatedSourceIDs: Set = [] + for account in request.codexRequests { + let sourceID = "codex:\(account.id)" + do { + guard self.currentAuthFingerprint(for: account) == account.authFingerprint else { + failedSourceIDs.insert(sourceID) + invalidatedSourceIDs.insert(sourceID) + continue + } + let cacheRoot = UsageStore.costUsageCacheDirectory() + .appendingPathComponent("accounts", isDirectory: true) + .appendingPathComponent(account.cacheIdentity, isDirectory: true) + let snapshot = try await codexSnapshotLoader(CodexSpendSnapshotLoadContext( + account: account, + cacheRoot: cacheRoot, + now: request.now, + force: request.force, + historyDays: Self.scanDays, + refreshPricingInBackground: false, + includePiSessions: false)) + try Task.checkCancellation() + guard self.currentAuthFingerprint(for: account) == account.authFingerprint else { + failedSourceIDs.insert(sourceID) + invalidatedSourceIDs.insert(sourceID) + continue + } + inputs.append(SpendDashboardModel.ProviderInput( + id: sourceID, + provider: .codex, + displayName: account.displayName, + modelProviderName: ProviderDescriptorRegistry.descriptor(for: .codex).metadata.displayName, + snapshot: snapshot)) + } catch is CancellationError { + failedSourceIDs.formUnion(request.codexRequests.map { "codex:\($0.id)" }) + return SpendDashboardLoadResult( + inputs: [], + failedSourceIDs: failedSourceIDs, + invalidatedSourceIDs: invalidatedSourceIDs) + } catch { + failedSourceIDs.insert(sourceID) + } + } + let lateInvalidatedSourceIDs = Set(request.codexRequests.compactMap { account in + self.currentAuthFingerprint(for: account) == account.authFingerprint + ? nil + : "codex:\(account.id)" + }) + failedSourceIDs.formUnion(lateInvalidatedSourceIDs) + invalidatedSourceIDs.formUnion(lateInvalidatedSourceIDs) + inputs.removeAll { lateInvalidatedSourceIDs.contains($0.id) } + return SpendDashboardLoadResult( + inputs: inputs, + failedSourceIDs: failedSourceIDs, + invalidatedSourceIDs: invalidatedSourceIDs) + } + + private static func loadCodexSnapshot( + _ context: CodexSpendSnapshotLoadContext) async throws -> CostUsageTokenSnapshot + { + try await CostUsageFetcher(cacheRoot: context.cacheRoot).loadTokenSnapshot( + provider: .codex, + environment: CodexHomeScope.scopedEnvironment(base: [:], codexHome: context.account.homePath), + now: context.now, + forceRefresh: context.force, + codexHomePath: context.account.homePath, + historyDays: context.historyDays, + refreshPricingInBackground: context.refreshPricingInBackground, + includePiSessions: context.includePiSessions) + } + + @MainActor + static func costCapableProviders(store: UsageStore) -> [UsageProvider] { + store.enabledProvidersForDisplay().filter { + ProviderDescriptorRegistry.descriptor(for: $0).tokenCost.supportsTokenCost + } + } + + @MainActor + static func codexRequests(settings: SettingsStore, store: UsageStore) -> [CodexSpendScanRequest] { + let accounts = settings.codexVisibleAccountProjection.visibleAccounts + let providerName = store.metadata(for: .codex).displayName + return accounts.enumerated().compactMap { index, account in + let homePath: String? = switch account.selectionSource { + case .liveSystem: + settings.liveSystemCodexHomePath(forActiveSource: .liveSystem) + case let .managedAccount(id): + settings.managedCodexRemoteHomePath(forActiveSource: .managedAccount(id: id)) + case let .profileHome(path): + settings.profileCodexHomePath(forActiveSource: .profileHome(path: path)) + } + return self.codexRequest( + account: account, + homePath: homePath, + providerName: providerName, + index: index, + count: accounts.count) + } + } + + @MainActor + private static func sourceRevisions( + providers: [UsageProvider], + settings: SettingsStore, + store: UsageStore) -> [String] + { + ["settings:\(settings.configRevision)"] + providers.compactMap { provider in + guard provider != .codex else { return nil } + let current = store.tokenSnapshotPublicationForCurrentProviderConfig(for: provider) + guard let current else { return "\(provider.rawValue):unavailable" } + guard let snapshot = current.snapshot else { + return "\(provider.rawValue):empty:\(current.publicationRevision)" + } + return "\(provider.rawValue):snapshot:\(current.publicationRevision):\(self.snapshotRevision(snapshot))" + } + } + + private static func snapshotRevision(_ snapshot: CostUsageTokenSnapshot) -> String { + var encoder = SpendDashboardSnapshotRevisionEncoder() + encoder.append(snapshot.currencyCode) + encoder.append(snapshot.historyDays) + encoder.append(snapshot.historyCoverageIsEstablished) + encoder.append(snapshot.updatedAt.timeIntervalSinceReferenceDate) + encoder.append(snapshot.last30DaysTokens) + encoder.append(snapshot.last30DaysCostUSD) + encoder.append(snapshot.daily.count) + for entry in snapshot.daily { + encoder.append(entry.date) + encoder.append(entry.inputTokens) + encoder.append(entry.cacheReadTokens) + encoder.append(entry.cacheCreationTokens) + encoder.append(entry.outputTokens) + encoder.append(entry.totalTokens) + encoder.append(entry.requestCount) + encoder.append(entry.costUSD) + encoder.append(entry.modelBreakdowns?.count) + for breakdown in entry.modelBreakdowns ?? [] { + encoder.append(breakdown.modelName) + encoder.append(breakdown.totalTokens) + encoder.append(breakdown.requestCount) + encoder.append(breakdown.costUSD) + encoder.append(breakdown.standardCostUSD) + encoder.append(breakdown.priorityCostUSD) + encoder.append(breakdown.standardTokens) + encoder.append(breakdown.priorityTokens) + } + } + return encoder.finalize() + } + + @MainActor + private static func sourceOwnershipFingerprints( + providers: [UsageProvider], + settings: SettingsStore, + store: UsageStore) -> [String] + { + providers.compactMap { provider in + guard provider != .codex else { return nil } + var config = settings.providerConfig(for: provider) ?? ProviderConfig(id: provider) + config.enabled = nil + config.quotaWarnings = nil + // The dashboard follows the effective account, not the whole saved-account collection. + // Inactive-account edits must not invalidate visible spend for the selected account. + config.tokenAccounts = nil + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + let encoded = (try? encoder.encode(config)) ?? Data() + let scope = store.tokenSnapshotScopeSignature(for: provider) + let accountOwnership = settings.effectiveSelectedTokenAccount(for: provider) + .map { store.tokenAccountSnapshotCacheKey(provider: provider, account: $0) } + ?? "ambient" + return "\(provider.rawValue):\(self.sha256(encoded)):\(self.sha256(scope)):" + + self.sha256(accountOwnership) + } + } + + static func codexRequest( + account: CodexVisibleAccount, + homePath: String?, + providerName: String, + index: Int, + count: Int) -> CodexSpendScanRequest? + { + guard let homePath = CodexHomeScope.normalizedHomePath(homePath) else { return nil } + var isDirectory: ObjCBool = false + guard FileManager.default.fileExists(atPath: homePath, isDirectory: &isDirectory), + isDirectory.boolValue, + FileManager.default.isReadableFile(atPath: homePath) + else { return nil } + let sourceToken = self.sourceToken(account.selectionSource) + let liveAuthFingerprint = CodexAuthFingerprint.fingerprint(homePath: homePath) + let authFingerprint = liveAuthFingerprint + ?? CodexAuthFingerprint.normalize(account.authFingerprint) + let cacheIdentity = self.sha256([ + account.id, + sourceToken, + homePath, + authFingerprint ?? "missing-auth", + ].joined(separator: "\u{0}")) + let displayName = count == 1 + ? providerName + : "\(providerName) · #\(codexBarLocalizedInteger(index + 1))" + return CodexSpendScanRequest( + id: account.id, + displayName: displayName, + source: account.selectionSource, + homePath: homePath, + authFingerprint: authFingerprint, + authFileWasReadable: liveAuthFingerprint != nil, + cacheIdentity: cacheIdentity) + } + + private static func codexDisplayNamesByID(_ requests: [CodexSpendScanRequest]) -> [String: String] { + requests.reduce(into: [:]) { result, request in + result["codex:\(request.id)"] = request.displayName + } + } + + private static func sourceToken(_ source: CodexActiveSource) -> String { + switch source { + case .liveSystem: "live" + case let .managedAccount(id): "managed:\(id.uuidString.lowercased())" + case let .profileHome(path): "profile:\(path)" + } + } + + private static func sha256(_ value: String) -> String { + self.sha256(Data(value.utf8)) + } + + private static func sha256(_ value: Data) -> String { + SHA256.hash(data: value).map { String(format: "%02x", $0) }.joined() + } + + private static func currentAuthFingerprint(for request: CodexSpendScanRequest) -> String? { + let current = CodexAuthFingerprint.fingerprint(homePath: request.homePath) + return request.authFileWasReadable ? current : current ?? request.authFingerprint + } +} + +private struct SpendDashboardSnapshotRevisionEncoder { + private var hasher = SHA256() + + mutating func append(_ value: String) { + let data = Data(value.utf8) + self.append(UInt64(data.count)) + self.hasher.update(data: data) + } + + mutating func append(_ value: Int) { + self.append(UInt64(bitPattern: Int64(value))) + } + + mutating func append(_ value: Int?) { + guard let value else { + self.appendPresence(false) + return + } + self.appendPresence(true) + self.append(value) + } + + mutating func append(_ value: Bool) { + self.appendPresence(value) + } + + mutating func append(_ value: Double) { + self.append(value.bitPattern) + } + + mutating func append(_ value: Double?) { + guard let value else { + self.appendPresence(false) + return + } + self.appendPresence(true) + self.append(value) + } + + mutating func finalize() -> String { + self.hasher.finalize().map { String(format: "%02x", $0) }.joined() + } + + private mutating func appendPresence(_ isPresent: Bool) { + var byte: UInt8 = isPresent ? 1 : 0 + withUnsafeBytes(of: &byte) { bytes in + self.hasher.update(data: Data(bytes)) + } + } + + private mutating func append(_ value: UInt64) { + var value = value.bigEndian + withUnsafeBytes(of: &value) { bytes in + self.hasher.update(data: Data(bytes)) + } + } +} + +@MainActor +@Observable +final class SpendDashboardController { + typealias RequestBuilder = @MainActor @Sendable (SpendDashboardRequestBuildMode) async + -> SpendDashboardLoadRequest + typealias Loader = @Sendable (SpendDashboardLoadRequest) async -> SpendDashboardLoadResult + + private enum ReconciliationObservation: Sendable { + case confirmedEmpty + case confirmedNonempty(SpendDashboardModel.ProviderInput) + } + + private struct ForcedOutcome: Sendable { + let request: SpendDashboardLoadRequest + let result: SpendDashboardLoadResult + let invalidatedSourceIDs: Set + let observations: [String: ReconciliationObservation] + + func incorporating(capture: SpendDashboardLoadRequest) -> Self { + var observations = self.observations + for input in capture.capturedInputs { + let forcedRevision = Self.sourceRevision(for: input.id, in: self.request.configuration) + let captureRevision = Self.sourceRevision(for: input.id, in: capture.configuration) + let hasNewerSourceRevision = forcedRevision != nil + && captureRevision != nil + && forcedRevision != captureRevision + if self.result.failedSourceIDs.contains(input.id), + observations[input.id] == nil, + !hasNewerSourceRevision + { + continue + } + observations[input.id] = .confirmedNonempty(input) + } + for sourceID in capture.confirmedEmptySourceIDs { + observations[sourceID] = .confirmedEmpty + } + return Self( + request: self.request, + result: self.result, + invalidatedSourceIDs: self.invalidatedSourceIDs, + observations: observations) + } + + private static func sourceRevision( + for sourceID: String, + in configuration: SpendDashboardConfiguration) -> String? + { + let prefix = "\(sourceID):" + return configuration.sourceRevisions.first { $0.hasPrefix(prefix) } + } + + var confirmedEmptySourceIDs: Set { + Set(self.observations.compactMap { sourceID, observation in + guard case .confirmedEmpty = observation else { return nil } + return sourceID + }) + } + + var confirmedNonemptyInputs: [SpendDashboardModel.ProviderInput] { + self.observations.sorted { $0.key < $1.key }.compactMap { _, observation in + guard case let .confirmedNonempty(input) = observation else { return nil } + return input + } + } + } + + private struct ReconciledOutcome: Sendable { + let result: SpendDashboardLoadResult + let confirmedEmptySourceIDs: Set + } + + private enum LoadPhase: Sendable { + case ordinary + case forcing + case reconciling(ForcedOutcome) + + var buildMode: SpendDashboardRequestBuildMode { + switch self { + case .ordinary: .refreshMissing + case .forcing: .forceRefresh + case .reconciling: .captureOnly + } + } + + var manualRefreshOutstanding: Bool { + switch self { + case .ordinary: false + case .forcing, .reconciling: true + } + } + } + + private(set) var model = SpendDashboardModel(requestedDays: 30, groups: []) + private(set) var isRefreshing = false + private(set) var failedSourceCount = 0 + private(set) var generation: UInt64 = 0 + private(set) var configuration: SpendDashboardConfiguration? + private(set) var selectedDays: Int + + private static let daysDefaultsKey = "settingsSpendDashboardDays" + private let userDefaults: UserDefaults + private let requestBuilder: RequestBuilder + private let loader: Loader + private let nowProvider: @Sendable () -> Date + private var loadTask: Task? + private var loadedInputs: [SpendDashboardModel.ProviderInput] = [] + private var loadedAt = Date() + private var lastSuccessfulConfiguration: SpendDashboardConfiguration? + private var phase = LoadPhase.ordinary + + init( + userDefaults: UserDefaults = .standard, + requestBuilder: @escaping RequestBuilder, + loader: @escaping Loader = SpendDashboardSource.load, + nowProvider: @escaping @Sendable () -> Date = { Date() }) + { + self.userDefaults = userDefaults + self.requestBuilder = requestBuilder + self.loader = loader + self.nowProvider = nowProvider + self.selectedDays = Self.normalizedDays(userDefaults.integer(forKey: Self.daysDefaultsKey)) + } + + func update(configuration: SpendDashboardConfiguration, force: Bool = false) { + self.refreshRetainedCodexDisplayNames(configuration.codexAccountDisplayNames) + if force { + self.configuration = configuration + self.startLoad(configuration: configuration, phase: .forcing) + return + } + guard configuration != self.configuration else { return } + let previousConfiguration = self.configuration + self.configuration = configuration + if self.phase.manualRefreshOutstanding, + let previousConfiguration, + Self.sameSourceOwnership(previousConfiguration, configuration) + { + return + } + let nextPhase: LoadPhase = self.phase.manualRefreshOutstanding ? .forcing : .ordinary + self.startLoad(configuration: configuration, phase: nextPhase) + } + + private func startLoad( + configuration: SpendDashboardConfiguration, + phase: LoadPhase) + { + self.generation &+= 1 + let generation = self.generation + self.loadTask?.cancel() + let invalidatedSourceIDs = switch phase { + case let .reconciling(outcome): outcome.invalidatedSourceIDs + case .ordinary, .forcing: + Self.invalidatedSourceIDs( + previous: self.lastSuccessfulConfiguration, + current: configuration) + } + self.phase = phase + + if !invalidatedSourceIDs.isEmpty { + self.loadedInputs.removeAll { invalidatedSourceIDs.contains($0.id) } + self.failedSourceCount = 0 + self.rebuildModel() + } + + guard configuration.costUsageEnabled, !configuration.providerIDs.isEmpty else { + self.loadedInputs = [] + self.failedSourceCount = 0 + self.isRefreshing = false + self.lastSuccessfulConfiguration = configuration + self.phase = .ordinary + self.loadTask = nil + self.rebuildModel() + return + } + + self.isRefreshing = true + self.loadTask = Task { [weak self] in + guard let self else { return } + let request = await self.requestBuilder(phase.buildMode) + guard !Task.isCancelled, + generation == self.generation + else { return } + await self.handleBuiltRequest( + request, + startedWith: configuration, + phase: phase, + generation: generation, + invalidatedSourceIDs: invalidatedSourceIDs) + } + } + + private func handleBuiltRequest( + _ request: SpendDashboardLoadRequest, + startedWith startConfiguration: SpendDashboardConfiguration, + phase: LoadPhase, + generation: UInt64, + invalidatedSourceIDs: Set) async + { + guard let targetConfiguration = self.configuration else { return } + if case let .reconciling(outcome) = phase, + !Self.sameSourceOwnership(outcome.request.configuration, targetConfiguration) + { + self.startLoad(configuration: targetConfiguration, phase: .forcing) + return + } + guard Self.sameSourceOwnership(startConfiguration, targetConfiguration) else { + self.restartAfterBuildMismatch(targetConfiguration, phase: phase) + return + } + + let phase: LoadPhase = if case let .reconciling(outcome) = phase, + Self.sameSourceOwnership(request.configuration, targetConfiguration) + { + .reconciling(outcome.incorporating(capture: request)) + } else { + phase + } + + if request.configuration != targetConfiguration { + if case .forcing = phase, + Self.sameSourceOwnership(request.configuration, targetConfiguration) + { + // Same-owner revision churn does not justify another provider force. The forced + // loader executes once; its mandatory capture barrier reconciles the latest token. + if targetConfiguration == startConfiguration { + self.configuration = request.configuration + } + } else if targetConfiguration == startConfiguration, + Self.sameSourceOwnership(targetConfiguration, request.configuration) + { + // The request owns an atomic newer same-owner capture. Adopt it even when the + // external observation callback has not delivered that revision yet. + self.configuration = request.configuration + } else { + let nextConfiguration = targetConfiguration == startConfiguration + ? request.configuration + : targetConfiguration + self.restartAfterBuildMismatch(nextConfiguration, phase: phase) + return + } + } + + switch phase { + case .ordinary: + let result = await self.loader(request) + guard !Task.isCancelled, + generation == self.generation, + let latestConfiguration = self.configuration + else { return } + guard request.configuration == latestConfiguration else { + self.startLoad(configuration: latestConfiguration, phase: .ordinary) + return + } + self.apply( + request: request, + result: result, + invalidatedSourceIDs: invalidatedSourceIDs, + confirmedEmptySourceIDs: request.confirmedEmptySourceIDs) + + case .forcing: + let result = await self.loader(request) + guard !Task.isCancelled, + generation == self.generation, + let latestConfiguration = self.configuration + else { return } + guard Self.sameSourceOwnership(request.configuration, latestConfiguration) else { + self.startLoad(configuration: latestConfiguration, phase: .forcing) + return + } + let outcome = ForcedOutcome( + request: request, + result: result, + invalidatedSourceIDs: invalidatedSourceIDs, + observations: Dictionary(uniqueKeysWithValues: request.confirmedEmptySourceIDs.map { + ($0, ReconciliationObservation.confirmedEmpty) + })) + self.startLoad(configuration: latestConfiguration, phase: .reconciling(outcome)) + + case let .reconciling(outcome): + let reconciled = Self.merge(outcome: outcome, capture: request) + self.apply( + request: request, + result: reconciled.result, + invalidatedSourceIDs: outcome.invalidatedSourceIDs, + confirmedEmptySourceIDs: reconciled.confirmedEmptySourceIDs) + } + } + + private func restartAfterBuildMismatch( + _ configuration: SpendDashboardConfiguration, + phase: LoadPhase) + { + self.configuration = configuration + let nextPhase: LoadPhase = switch phase { + case .ordinary: .ordinary + case .forcing: .forcing + case let .reconciling(outcome): + Self.sameSourceOwnership(outcome.request.configuration, configuration) + ? .reconciling(outcome) + : .forcing + } + self.startLoad(configuration: configuration, phase: nextPhase) + } + + private func apply( + request: SpendDashboardLoadRequest, + result: SpendDashboardLoadResult, + invalidatedSourceIDs: Set, + confirmedEmptySourceIDs: Set) + { + let codexDisplayNames = request.configuration.codexAccountDisplayNames + self.refreshRetainedCodexDisplayNames(codexDisplayNames) + var nextInputs = result.inputs + if !result.failedSourceIDs.isEmpty { + let freshIDs = Set(nextInputs.map(\.id)) + let unsafeSourceIDs = invalidatedSourceIDs + .union(result.invalidatedSourceIDs) + .union(confirmedEmptySourceIDs) + nextInputs.append(contentsOf: self.loadedInputs.filter { + result.failedSourceIDs.contains($0.id) && + !unsafeSourceIDs.contains($0.id) && + !freshIDs.contains($0.id) + }.map { Self.relabelCodexInput($0, displayNamesByID: codexDisplayNames) }) + } + self.configuration = request.configuration + self.loadedInputs = nextInputs + self.loadedAt = request.now + self.lastSuccessfulConfiguration = request.configuration + self.failedSourceCount = result.failedSourceCount + self.isRefreshing = false + self.phase = .ordinary + self.loadTask = nil + self.rebuildModel() + } + + private static func merge( + outcome: ForcedOutcome, + capture: SpendDashboardLoadRequest) -> ReconciledOutcome + { + let forceFailed = outcome.result.failedSourceIDs + let invalidated = outcome.result.invalidatedSourceIDs + let barrierFailed = capture.unavailableSourceIDs + let forcedCodexIDs = Set(outcome.request.codexRequests.map { "codex:\($0.id)" }) + let confirmedNonemptyInputs = outcome.confirmedNonemptyInputs + let confirmedNonemptyIDs = Set(confirmedNonemptyInputs.map(\.id)) + var inputs = capture.capturedInputs.filter { + (!forceFailed.contains($0.id) || confirmedNonemptyIDs.contains($0.id)) && + !invalidated.contains($0.id) && + !outcome.confirmedEmptySourceIDs.contains($0.id) + } + var capturedIDs = Set(inputs.map(\.id)) + for input in confirmedNonemptyInputs + where !capturedIDs.contains(input.id) && !invalidated.contains(input.id) + { + inputs.append(input) + capturedIDs.insert(input.id) + } + for input in outcome.result.inputs + where !capturedIDs.contains(input.id) && + !forceFailed.contains(input.id) && + !invalidated.contains(input.id) && + !outcome.confirmedEmptySourceIDs.contains(input.id) && + (forcedCodexIDs.contains(input.id) || barrierFailed.contains(input.id)) + { + inputs.append(input) + capturedIDs.insert(input.id) + } + return ReconciledOutcome( + result: SpendDashboardLoadResult( + inputs: inputs, + failedSourceIDs: forceFailed.union(barrierFailed), + invalidatedSourceIDs: invalidated), + confirmedEmptySourceIDs: outcome.confirmedEmptySourceIDs) + } + + func refresh() { + guard let configuration else { return } + self.update(configuration: configuration, force: true) + } + + func selectDays(_ days: Int) { + let days = Self.normalizedDays(days) + guard days != self.selectedDays else { return } + self.selectedDays = days + self.userDefaults.set(days, forKey: Self.daysDefaultsKey) + self.rebuildModel() + } + + func refreshDateWindow(now: Date? = nil) { + self.loadedAt = now ?? self.nowProvider() + self.rebuildModel() + guard let configuration else { return } + let nextPhase: LoadPhase = self.phase.manualRefreshOutstanding ? .forcing : .ordinary + self.startLoad(configuration: configuration, phase: nextPhase) + } + + func stop() { + self.loadTask?.cancel() + self.loadTask = nil + self.configuration = nil + self.isRefreshing = false + self.phase = .ordinary + } + + private func rebuildModel() { + self.model = SpendDashboardModel.build( + inputs: self.loadedInputs, + requestedDays: self.selectedDays, + now: self.loadedAt, + preferredCurrencyCode: self.configuration?.preferredCurrencyCode ?? "auto") + } + + private func refreshRetainedCodexDisplayNames(_ displayNamesByID: [String: String]) { + guard !displayNamesByID.isEmpty else { return } + var didChange = false + let relabeled = self.loadedInputs.map { input in + let updated = Self.relabelCodexInput(input, displayNamesByID: displayNamesByID) + didChange = didChange || updated.displayName != input.displayName + return updated + } + guard didChange else { return } + self.loadedInputs = relabeled + self.rebuildModel() + } + + private static func relabelCodexInput( + _ input: SpendDashboardModel.ProviderInput, + displayNamesByID: [String: String]) -> SpendDashboardModel.ProviderInput + { + guard input.provider == .codex, + let displayName = displayNamesByID[input.id], + displayName != input.displayName + else { return input } + return SpendDashboardModel.ProviderInput( + id: input.id, + provider: input.provider, + displayName: displayName, + modelProviderName: input.modelProviderName, + snapshot: input.snapshot) + } + + private static func sameSourceOwnership( + _ lhs: SpendDashboardConfiguration, + _ rhs: SpendDashboardConfiguration) -> Bool + { + lhs.costUsageEnabled == rhs.costUsageEnabled && + lhs.providerIDs == rhs.providerIDs && + lhs.codexAccountIdentities == rhs.codexAccountIdentities && + lhs.sourceOwnershipFingerprints == rhs.sourceOwnershipFingerprints + } + + private static func invalidatedSourceIDs( + previous: SpendDashboardConfiguration?, + current: SpendDashboardConfiguration) -> Set + { + guard let previous else { return [] } + let previousOwnership = self.sourceOwnershipByID(previous.sourceOwnershipFingerprints) + let currentOwnership = self.sourceOwnershipByID(current.sourceOwnershipFingerprints) + let providerIDs = Set(previousOwnership.keys).union(currentOwnership.keys) + let changedProviderIDs = providerIDs.filter { previousOwnership[$0] != currentOwnership[$0] } + + let previousCodexOwnership = self.codexOwnershipByID(previous.codexAccountIdentities) + let currentCodexOwnership = self.codexOwnershipByID(current.codexAccountIdentities) + let codexIDs = Set(previousCodexOwnership.keys).union(currentCodexOwnership.keys) + let changedCodexIDs = codexIDs.filter { + previousCodexOwnership[$0] != currentCodexOwnership[$0] + } + return Set(changedProviderIDs).union(changedCodexIDs) + } + + private static func sourceOwnershipByID(_ fingerprints: [String]) -> [String: String] { + Dictionary(uniqueKeysWithValues: fingerprints.compactMap { fingerprint in + guard let separator = fingerprint.firstIndex(of: ":") else { return nil } + let sourceID = String(fingerprint[.. [String: String] { + Dictionary(uniqueKeysWithValues: identities.compactMap { identity in + guard let separator = identity.lastIndex(of: "|") else { return nil } + let accountID = String(identity[.. Int { + value == 7 ? 7 : 30 + } +} diff --git a/Sources/CodexBar/SpendDashboardModel.swift b/Sources/CodexBar/SpendDashboardModel.swift new file mode 100644 index 0000000000..96d3b5db9a --- /dev/null +++ b/Sources/CodexBar/SpendDashboardModel.swift @@ -0,0 +1,706 @@ +import CodexBarCore +import Foundation + +struct SpendDashboardModel: Equatable, Sendable { + struct ProviderInput: Sendable { + let id: String + let provider: UsageProvider + let displayName: String + let modelProviderName: String + let snapshot: CostUsageTokenSnapshot + + init( + id: String? = nil, + provider: UsageProvider, + displayName: String, + modelProviderName: String? = nil, + snapshot: CostUsageTokenSnapshot) + { + self.id = id ?? provider.rawValue + self.provider = provider + self.displayName = displayName + self.modelProviderName = modelProviderName ?? displayName + self.snapshot = snapshot + } + } + + struct ProviderRow: Identifiable, Equatable, Sendable { + let id: String + let rank: Int + let provider: UsageProvider + let displayName: String + let totalTokens: Int? + let totalCost: Double? + let coveredDayCount: Int + } + + struct ModelRow: Identifiable, Equatable, Sendable { + let rank: Int + let provider: UsageProvider + let providerName: String + let modelName: String + let totalTokens: Int? + let totalCost: Double? + + var id: String { + "\(self.provider.rawValue):\(self.modelName)" + } + } + + struct DailyPoint: Identifiable, Equatable, Sendable { + let sourceID: String + let provider: UsageProvider + let providerName: String + let day: Date + let cost: Double + let stackStart: Double + let stackEnd: Double + + var id: String { + "\(self.sourceID):\(Int(self.day.timeIntervalSince1970))" + } + } + + enum ModelHistoryCompleteness: Equatable, Sendable { + case complete + case incomplete + } + + struct CurrencyGroup: Identifiable, Equatable, Sendable { + let currencyCode: String + let providers: [ProviderRow] + let models: [ModelRow] + let dailyPoints: [DailyPoint] + let totalTokens: Int? + let totalCost: Double? + let coveredDayCount: Int + let chartDomain: ClosedRange + let modelHistoryCompleteness: ModelHistoryCompleteness + + var id: String { + self.currencyCode + } + } + + let requestedDays: Int + let groups: [CurrencyGroup] + + static func build( + inputs: [ProviderInput], + requestedDays: Int, + now: Date, + calendar: Calendar = .current, + preferredCurrencyCode: String = "auto") -> Self + { + let days = max(1, min(30, requestedDays)) + let calculationCalendar = Self.gregorianCalendar(timeZone: calendar.timeZone) + let classifiedInputs = inputs.compactMap { input -> ClassifiedInput? in + guard let sourceCurrencyCode = Self.currencyCode(input.snapshot.currencyCode) else { return nil } + let targetCurrencyCode = UsageFormatter.effectiveCurrencyCode( + preferred: preferredCurrencyCode, + providerCurrency: sourceCurrencyCode) + let conversion = CurrencyExchange.shared.convert( + amount: 1, + from: sourceCurrencyCode, + to: targetCurrencyCode) + return ClassifiedInput( + currencyCode: conversion == nil ? sourceCurrencyCode : targetCurrencyCode, + input: input, + costMultiplier: conversion ?? 1) + } + let groups = Dictionary(grouping: classifiedInputs, by: { $0.currencyCode }) + .map { currencyCode, inputs in + Self.buildCurrencyGroup( + currencyCode: currencyCode, + inputs: inputs, + days: days, + now: now, + calendar: calculationCalendar) + } + .sorted { $0.currencyCode < $1.currencyCode } + return Self(requestedDays: days, groups: groups) + } + + private struct ClassifiedInput { + let currencyCode: String + let input: ProviderInput + let costMultiplier: Double + } + + private struct InputSummary { + let input: ProviderInput + let costMultiplier: Double + let entries: [WindowEntry] + let totalTokens: Int? + let totalCost: Double? + let coveredInterval: ClosedRange? + let coveredDayCount: Int + let hasInvalidCostHistory: Bool + } + + private struct WindowEntry { + let day: Date + let entry: CostUsageDailyReport.Entry + } + + private struct ModelKey: Hashable { + let provider: UsageProvider + let modelName: String + } + + private struct ModelAccumulator { + let providerName: String + var tokens: Int? + var cost: Double? + var sawTokens = false + var sawCost = false + var invalidTokens = false + var invalidCost = false + var overflowedTokens = false + var overflowedCost = false + } + + private struct ModelSummary { + let rows: [ModelRow] + let completeness: ModelHistoryCompleteness + } + + private struct DailyKey: Hashable { + let day: Date + let sourceID: String + } + + private struct DailyAccumulator { + let provider: UsageProvider + let providerName: String + var cost: Double? + var invalid = false + var overflowed = false + } + + private static func buildCurrencyGroup( + currencyCode: String, + inputs: [ClassifiedInput], + days: Int, + now: Date, + calendar: Calendar) -> CurrencyGroup + { + let bounds = Self.bounds(days: days, now: now, calendar: calendar) + let summaries = inputs.map { classified in + Self.inputSummary( + input: classified.input, + costMultiplier: classified.costMultiplier, + bounds: bounds, + calendar: calendar) + } + let providers = Self.providerRows(summaries) + let completeModelSummaries = summaries.filter { summary in + guard summary.totalCost != nil else { return false } + return Self.modelSummary(summaries: [summary]).completeness == .complete + } + let modelSummary = Self.modelSummary(summaries: completeModelSummaries) + let modelHistoryCompleteness = completeModelSummaries.count == summaries.count + ? ModelHistoryCompleteness.complete + : ModelHistoryCompleteness.incomplete + let dailyPoints = Self.dailyPoints(summaries: summaries) + return CurrencyGroup( + currencyCode: currencyCode, + providers: providers, + models: modelSummary.rows, + dailyPoints: dailyPoints, + totalTokens: Self.completeIntSum(providers.map(\.totalTokens)), + totalCost: Self.completeCostSum(providers.map(\.totalCost)), + coveredDayCount: Self.commonCoverageDayCount(summaries: summaries, calendar: calendar), + chartDomain: Self.chartDomain(bounds: bounds, calendar: calendar), + modelHistoryCompleteness: modelHistoryCompleteness) + } + + private static func inputSummary( + input: ProviderInput, + costMultiplier: Double, + bounds: ClosedRange, + calendar: Calendar) -> InputSummary + { + let coveredInterval = Self.coverageInterval( + input: input, + bounds: bounds, + displayCalendar: calendar) + var entries: [WindowEntry] = [] + var hasInvalidCostHistory = false + var hasInvalidTokenHistory = false + for entry in input.snapshot.daily { + guard let day = Self.day(entry.date, provider: input.provider, displayCalendar: calendar) else { + hasInvalidCostHistory = hasInvalidCostHistory || !Self.hasProvenZeroCost(entry) + hasInvalidTokenHistory = hasInvalidTokenHistory || !Self.hasProvenZeroTokens(entry) + continue + } + guard bounds.contains(day) else { continue } + guard coveredInterval?.contains(day) == true else { + hasInvalidCostHistory = hasInvalidCostHistory || !Self.hasProvenZeroCost(entry) + hasInvalidTokenHistory = hasInvalidTokenHistory || !Self.hasProvenZeroTokens(entry) + continue + } + entries.append(WindowEntry(day: day, entry: entry)) + } + let coveredDayCount = Self.dayCount(in: coveredInterval, calendar: calendar) + let hasCompleteTokenHistory = Self.hasCompleteTokenHistory(input, displayCalendar: calendar) + let tokenAggregateIsConsistent = input.snapshot.last30DaysTokens == nil || hasCompleteTokenHistory + let totalTokens = hasInvalidTokenHistory || !tokenAggregateIsConsistent + ? nil + : entries.isEmpty + ? (coveredDayCount > 0 && hasCompleteTokenHistory ? 0 : nil) + : Self.completeIntSum(entries.map { Self.nonnegative($0.entry.totalTokens) }) + let hasCompleteCostHistory = Self.hasCompleteCostHistory(input, displayCalendar: calendar) + let costAggregateIsConsistent = input.snapshot.last30DaysCostUSD == nil || hasCompleteCostHistory + let invalidCostHistory = hasInvalidCostHistory || !costAggregateIsConsistent + let totalCost = invalidCostHistory + ? nil + : entries.isEmpty + ? (coveredDayCount > 0 && hasCompleteCostHistory ? 0 : nil) + : Self.completeCostSum(entries.map { + Self.validCost($0.entry.costUSD).map { $0 * costMultiplier } + }) + return InputSummary( + input: input, + costMultiplier: costMultiplier, + entries: entries, + totalTokens: totalTokens, + totalCost: totalCost, + coveredInterval: coveredInterval, + coveredDayCount: coveredDayCount, + hasInvalidCostHistory: invalidCostHistory) + } + + private static func providerRows(_ summaries: [InputSummary]) -> [ProviderRow] { + summaries.enumerated() + .sorted { lhs, rhs in + switch (lhs.element.totalCost, rhs.element.totalCost) { + case let (left?, right?) where left != right: left > right + case (_?, nil): true + case (nil, _?): false + default: lhs.offset < rhs.offset + } + } + .enumerated() + .map { rank, entry in + ProviderRow( + id: entry.element.input.id, + rank: rank + 1, + provider: entry.element.input.provider, + displayName: entry.element.input.displayName, + totalTokens: entry.element.totalTokens, + totalCost: entry.element.totalCost, + coveredDayCount: entry.element.coveredDayCount) + } + } + + private static func modelSummary(summaries: [InputSummary]) -> ModelSummary { + var aggregates: [ModelKey: ModelAccumulator] = [:] + var completeness = ModelHistoryCompleteness.complete + for summary in summaries { + let input = summary.input + let hasCompleteTokenHistory = summary.totalTokens != nil && summary.entries.allSatisfy { + Self.hasCompleteModelTokenCoverage($0.entry) + } + for windowEntry in summary.entries { + let entry = windowEntry.entry + let breakdowns = entry.modelBreakdowns ?? [] + if !Self.hasCompleteModelCostCoverage(entry) { + completeness = .incomplete + } + for breakdown in breakdowns { + let name = breakdown.modelName.trimmingCharacters(in: .whitespacesAndNewlines) + guard !name.isEmpty else { continue } + let key = ModelKey(provider: input.provider, modelName: name) + var aggregate = aggregates[key] ?? ModelAccumulator( + providerName: input.modelProviderName, + tokens: 0, + cost: 0) + if hasCompleteTokenHistory, + let tokens = Self.nonnegative(breakdown.totalTokens) + { + aggregate.sawTokens = true + aggregate.tokens = Self.add( + tokens, + to: aggregate.tokens, + overflowed: &aggregate.overflowedTokens) + } else { + aggregate.invalidTokens = true + } + if let cost = Self.validCost(breakdown.costUSD).map({ $0 * summary.costMultiplier }) { + aggregate.sawCost = true + aggregate.cost = Self.add(cost, to: aggregate.cost, overflowed: &aggregate.overflowedCost) + } else { + aggregate.invalidCost = true + } + aggregates[key] = aggregate + } + } + } + if aggregates.values.contains(where: { + !$0.sawCost || $0.invalidCost || $0.overflowedCost || $0.cost == nil + }) { + completeness = .incomplete + } + + let rows = aggregates.map { key, value in + ModelRow( + rank: 0, + provider: key.provider, + providerName: value.providerName, + modelName: key.modelName, + totalTokens: value.sawTokens && !value.invalidTokens && !value.overflowedTokens ? value.tokens : nil, + totalCost: value.sawCost && !value.invalidCost && !value.overflowedCost ? value.cost : nil) + } + .sorted { lhs, rhs in + switch (lhs.totalCost, rhs.totalCost) { + case let (left?, right?) where left != right: return left > right + case (_?, nil): return true + case (nil, _?): return false + default: + if lhs.providerName != rhs.providerName { + return lhs.providerName < rhs.providerName + } + return lhs.modelName < rhs.modelName + } + } + .enumerated() + .map { rank, row in + ModelRow( + rank: rank + 1, + provider: row.provider, + providerName: row.providerName, + modelName: row.modelName, + totalTokens: row.totalTokens, + totalCost: row.totalCost) + } + return ModelSummary(rows: rows, completeness: completeness) + } + + private static func hasProvenZeroCost(_ entry: CostUsageDailyReport.Entry) -> Bool { + self.validCost(entry.costUSD) == 0 + && (entry.modelBreakdowns?.allSatisfy(self.hasProvenZeroCost) ?? true) + } + + private static func hasProvenZeroCost(_ breakdown: CostUsageDailyReport.ModelBreakdown) -> Bool { + let optionalCosts = [breakdown.standardCostUSD, breakdown.priorityCostUSD] + return Self.validCost(breakdown.costUSD) == 0 + && optionalCosts.allSatisfy { value in + value == nil || Self.validCost(value) == 0 + } + } + + private static func hasProvenZeroTokens(_ entry: CostUsageDailyReport.Entry) -> Bool { + let optionalTokens = [ + entry.inputTokens, + entry.cacheReadTokens, + entry.cacheCreationTokens, + entry.outputTokens, + ] + return Self.nonnegative(entry.totalTokens) == 0 + && optionalTokens.allSatisfy { $0 == nil || Self.nonnegative($0) == 0 } + && (entry.modelBreakdowns?.allSatisfy(Self.hasProvenZeroTokens) ?? true) + } + + private static func hasProvenZeroTokens(_ breakdown: CostUsageDailyReport.ModelBreakdown) -> Bool { + let optionalTokens = [breakdown.standardTokens, breakdown.priorityTokens] + return Self.nonnegative(breakdown.totalTokens) == 0 + && optionalTokens.allSatisfy { $0 == nil || Self.nonnegative($0) == 0 } + } + + private static func hasCompleteModelCostCoverage(_ entry: CostUsageDailyReport.Entry) -> Bool { + var totalCost = 0.0 + var sawNamedBreakdown = false + for breakdown in entry.modelBreakdowns ?? [] { + let name = breakdown.modelName.trimmingCharacters(in: .whitespacesAndNewlines) + guard !name.isEmpty else { + guard Self.hasProvenZeroCost(breakdown) else { return false } + continue + } + sawNamedBreakdown = true + guard let cost = Self.validCost(breakdown.costUSD) else { return false } + totalCost += cost + guard totalCost.isFinite else { return false } + } + + guard sawNamedBreakdown else { return Self.hasProvenZeroCost(entry) } + guard let entryCost = Self.validCost(entry.costUSD) else { return false } + return Self.costsMatch(entryCost, totalCost) + } + + private static func hasCompleteModelTokenCoverage(_ entry: CostUsageDailyReport.Entry) -> Bool { + var totalTokens = 0 + var sawNamedBreakdown = false + for breakdown in entry.modelBreakdowns ?? [] { + let name = breakdown.modelName.trimmingCharacters(in: .whitespacesAndNewlines) + guard !name.isEmpty else { + guard Self.hasProvenZeroTokens(breakdown) else { return false } + continue + } + sawNamedBreakdown = true + guard let tokens = Self.nonnegative(breakdown.totalTokens) else { return false } + let addition = totalTokens.addingReportingOverflow(tokens) + guard !addition.overflow else { return false } + totalTokens = addition.partialValue + } + + guard sawNamedBreakdown else { return Self.hasProvenZeroTokens(entry) } + guard let entryTokens = Self.nonnegative(entry.totalTokens) else { return false } + return entryTokens == totalTokens + } + + private static func costsMatch(_ lhs: Double, _ rhs: Double) -> Bool { + let scaledTolerance = max(abs(lhs), abs(rhs)) * 1e-12 + let tolerance = min(1e-6, max(1e-9, scaledTolerance)) + return abs(lhs - rhs) <= tolerance + } + + private static func hasCompleteCostHistory( + _ input: ProviderInput, + displayCalendar: Calendar) -> Bool + { + guard let aggregate = validCost(input.snapshot.last30DaysCostUSD) else { return false } + let coverage = Self.sourceCoverageInterval(input: input, displayCalendar: displayCalendar) + var dailyTotal = 0.0 + for entry in input.snapshot.daily { + guard let day = Self.day(entry.date, provider: input.provider, displayCalendar: displayCalendar) else { + guard Self.hasProvenZeroCost(entry) else { return false } + continue + } + guard coverage.contains(day) else { continue } + guard let cost = validCost(entry.costUSD) else { return false } + dailyTotal += cost + guard dailyTotal.isFinite else { return false } + } + return self.costsMatch(aggregate, dailyTotal) + } + + private static func hasCompleteTokenHistory( + _ input: ProviderInput, + displayCalendar: Calendar) -> Bool + { + guard let aggregate = nonnegative(input.snapshot.last30DaysTokens) else { return false } + let coverage = Self.sourceCoverageInterval(input: input, displayCalendar: displayCalendar) + var dailyTotal = 0 + for entry in input.snapshot.daily { + guard let day = Self.day(entry.date, provider: input.provider, displayCalendar: displayCalendar) else { + guard Self.hasProvenZeroTokens(entry) else { return false } + continue + } + guard coverage.contains(day) else { continue } + guard let tokens = nonnegative(entry.totalTokens) else { return false } + let addition = dailyTotal.addingReportingOverflow(tokens) + guard !addition.overflow else { return false } + dailyTotal = addition.partialValue + } + return aggregate == dailyTotal + } + + private static func dailyPoints(summaries: [InputSummary]) -> [DailyPoint] { + var aggregates: [DailyKey: DailyAccumulator] = [:] + for summary in summaries where !summary.hasInvalidCostHistory { + let input = summary.input + for windowEntry in summary.entries { + let day = windowEntry.day + let entry = windowEntry.entry + let key = DailyKey(day: day, sourceID: input.id) + var aggregate = aggregates[key] ?? DailyAccumulator( + provider: input.provider, + providerName: input.displayName, + cost: 0) + if let cost = Self.validCost(entry.costUSD).map({ $0 * summary.costMultiplier }) { + aggregate.cost = Self.add(cost, to: aggregate.cost, overflowed: &aggregate.overflowed) + } else { + aggregate.invalid = true + } + aggregates[key] = aggregate + } + } + + let byDay = Dictionary(grouping: aggregates, by: { $0.key.day }) + return byDay.keys.sorted().flatMap { day -> [DailyPoint] in + let rows = (byDay[day] ?? []) + .filter { !$0.value.invalid && !$0.value.overflowed && $0.value.cost != nil } + .sorted { $0.key.sourceID < $1.key.sourceID } + guard let total = Self.completeCostSum(rows.map(\.value.cost)), total.isFinite else { return [] } + var cursor = 0.0 + var points: [DailyPoint] = [] + for (key, value) in rows { + guard let cost = value.cost else { return [] } + let start = cursor + cursor += cost + points.append(DailyPoint( + sourceID: key.sourceID, + provider: value.provider, + providerName: value.providerName, + day: day, + cost: cost, + stackStart: start, + stackEnd: cursor)) + } + return points + } + } + + private static func bounds(days: Int, now: Date, calendar: Calendar) -> ClosedRange { + let end = calendar.startOfDay(for: now) + let start = calendar.date(byAdding: .day, value: -(days - 1), to: end) ?? end + return start...end + } + + private static func gregorianCalendar(timeZone: TimeZone) -> Calendar { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = timeZone + return calendar + } + + private static func chartDomain(bounds: ClosedRange, calendar: Calendar) -> ClosedRange { + let end = calendar.date(byAdding: .day, value: 1, to: bounds.upperBound) ?? bounds.upperBound + return bounds.lowerBound...end + } + + private static func coverageInterval( + input: ProviderInput, + bounds: ClosedRange, + displayCalendar: Calendar) -> ClosedRange? + { + guard input.snapshot.historyCoverageIsEstablished else { return nil } + let sourceCoverage = Self.sourceCoverageInterval(input: input, displayCalendar: displayCalendar) + let overlapStart = max(bounds.lowerBound, sourceCoverage.lowerBound) + let overlapEnd = min(bounds.upperBound, sourceCoverage.upperBound) + guard overlapStart <= overlapEnd else { return nil } + return overlapStart...overlapEnd + } + + private static func sourceCoverageInterval( + input: ProviderInput, + displayCalendar: Calendar) -> ClosedRange + { + let bucketCalendar = Self.bucketCalendar(for: input.provider, displayCalendar: displayCalendar) + let bucketEnd = bucketCalendar.startOfDay(for: input.snapshot.updatedAt) + let scanEnd = displayCalendar.startOfDay(for: bucketEnd) + let scanDays = max(1, input.snapshot.historyDays) + let bucketStart = bucketCalendar.date(byAdding: .day, value: -(scanDays - 1), to: bucketEnd) ?? bucketEnd + let scanStart = displayCalendar.startOfDay(for: bucketStart) + return scanStart...scanEnd + } + + private static func commonCoverageDayCount(summaries: [InputSummary], calendar: Calendar) -> Int { + guard let first = summaries.first?.coveredInterval else { return 0 } + var intersection = first + for summary in summaries.dropFirst() { + guard let interval = summary.coveredInterval else { return 0 } + let start = max(intersection.lowerBound, interval.lowerBound) + let end = min(intersection.upperBound, interval.upperBound) + guard start <= end else { return 0 } + intersection = start...end + } + return Self.dayCount(in: intersection, calendar: calendar) + } + + private static func dayCount(in interval: ClosedRange?, calendar: Calendar) -> Int { + guard let interval else { return 0 } + return (calendar.dateComponents([.day], from: interval.lowerBound, to: interval.upperBound).day ?? 0) + 1 + } + + private static func day( + _ rawValue: String, + provider: UsageProvider, + displayCalendar: Calendar) -> Date? + { + let bytes = Array(rawValue.utf8) + let digitIndices = [0, 1, 2, 3, 5, 6, 8, 9] + guard bytes.count == 10, + bytes[4] == 45, + bytes[7] == 45, + digitIndices.allSatisfy({ (48...57).contains(bytes[$0]) }) + else { return nil } + let parts = rawValue.split(separator: "-") + let bucketCalendar = Self.bucketCalendar(for: provider, displayCalendar: displayCalendar) + guard parts.count == 3, + let year = Int(parts[0]), + let month = Int(parts[1]), + let day = Int(parts[2]), + let date = bucketCalendar.date(from: DateComponents(year: year, month: month, day: day)) + else { return nil } + guard bucketCalendar.dateComponents([.year, .month, .day], from: date) == DateComponents( + year: year, + month: month, + day: day) + else { return nil } + return displayCalendar.startOfDay(for: date) + } + + private static func bucketCalendar(for provider: UsageProvider, displayCalendar: Calendar) -> Calendar { + guard provider == .mistral else { return displayCalendar } + // Mistral labels both daily buckets and snapshot coverage by UTC day. Map each UTC boundary into the + // containing local dashboard day instead of reinterpreting the label as a local date. + return self.gregorianCalendar(timeZone: TimeZone(secondsFromGMT: 0) ?? .gmt) + } + + private static func currencyCode(_ rawValue: String) -> String? { + let value = rawValue.trimmingCharacters(in: .whitespacesAndNewlines).uppercased() + return value.isEmpty || value == "XXX" ? nil : value + } + + private static func validCost(_ value: Double?) -> Double? { + guard let value, value.isFinite, value >= 0 else { return nil } + return value + } + + private static func nonnegative(_ value: Int?) -> Int? { + guard let value, value >= 0 else { return nil } + return value + } + + private static func safeCostSum(_ values: [Double]) -> Double? { + guard !values.isEmpty else { return nil } + var result = 0.0 + for value in values { + result += value + guard result.isFinite else { return nil } + } + return result + } + + private static func completeCostSum(_ values: [Double?]) -> Double? { + guard values.allSatisfy({ $0 != nil }) else { return nil } + return self.safeCostSum(values.compactMap(\.self)) + } + + private static func safeIntSum(_ values: [Int]) -> Int? { + guard !values.isEmpty else { return nil } + var result = 0 + for value in values { + let addition = result.addingReportingOverflow(value) + guard !addition.overflow else { return nil } + result = addition.partialValue + } + return result + } + + private static func completeIntSum(_ values: [Int?]) -> Int? { + guard values.allSatisfy({ $0 != nil }) else { return nil } + return self.safeIntSum(values.compactMap(\.self)) + } + + private static func add(_ value: Int, to current: Int?, overflowed: inout Bool) -> Int? { + guard !overflowed, let current else { return nil } + let addition = current.addingReportingOverflow(value) + if addition.overflow { + overflowed = true + return nil + } + return addition.partialValue + } + + private static func add(_ value: Double, to current: Double?, overflowed: inout Bool) -> Double? { + guard !overflowed, let current else { return nil } + let result = current + value + guard result.isFinite else { + overflowed = true + return nil + } + return result + } +} diff --git a/Sources/CodexBar/StatusComponentsMenuView.swift b/Sources/CodexBar/StatusComponentsMenuView.swift new file mode 100644 index 0000000000..30fc4a0549 --- /dev/null +++ b/Sources/CodexBar/StatusComponentsMenuView.swift @@ -0,0 +1,115 @@ +import SwiftUI + +extension ProviderStatusIndicator { + /// Traffic-light color used for the per-component dot in the status submenu. + fileprivate var dotColor: Color { + switch self { + case .none: Color(red: 0.20, green: 0.78, blue: 0.35) + case .minor, .maintenance: Color(red: 0.96, green: 0.77, blue: 0.13) + case .major, .critical: Color(red: 0.91, green: 0.30, blue: 0.24) + case .unknown: Color.secondary + } + } +} + +/// Renders the list of statuspage.io component rows inside the provider's status submenu. +/// Each leaf row is: colored dot (far left) · service name · right-aligned status text. +/// A component group renders as an expandable dropdown: the parent shows the group's own +/// status, and a chevron reveals the individual child statuses indented beneath it +/// (modeled on the "Other" disclosure in StorageBreakdownMenuView). +struct StatusComponentsMenuView: View { + let components: [ProviderStatusComponent] + let width: CGFloat + /// Invoked after a group expands or collapses so the host can re-measure the row height. + let onToggle: (() -> Void)? + + @State private var expandedGroupIDs: Set = [] + + init( + components: [ProviderStatusComponent], + width: CGFloat, + onToggle: (() -> Void)? = nil) + { + self.components = components + self.width = width + self.onToggle = onToggle + } + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + ForEach(self.components) { component in + if component.isGroup { + self.groupRow(component) + } else { + self.statusRow(component) + } + } + } + .padding(.horizontal, 14) + .padding(.vertical, 8) + .frame(width: self.width, alignment: .leading) + .fixedSize(horizontal: false, vertical: true) + } + + /// A single leaf row: dot · name · right-aligned status. + private func statusRow(_ component: ProviderStatusComponent, indented: Bool = false) -> some View { + HStack(spacing: 8) { + Circle() + .fill(component.indicator.dotColor) + .frame(width: 8, height: 8) + Text(component.name) + .font(.system(size: 13)) + .foregroundStyle(.primary) + .lineLimit(1) + Spacer(minLength: 16) + Text(component.statusLabel) + .font(.system(size: 12)) + .foregroundStyle(.secondary) + .lineLimit(1) + } + .padding(.leading, indented ? 17 : 0) + } + + /// An expandable group: parent status row with a chevron, revealing children when expanded. + private func groupRow(_ group: ProviderStatusComponent) -> some View { + let isExpanded = self.expandedGroupIDs.contains(group.id) + return VStack(alignment: .leading, spacing: 6) { + Button { + if isExpanded { + self.expandedGroupIDs.remove(group.id) + } else { + self.expandedGroupIDs.insert(group.id) + } + self.onToggle?() + } label: { + HStack(spacing: 8) { + Circle() + .fill(group.indicator.dotColor) + .frame(width: 8, height: 8) + Text(group.name) + .font(.system(size: 13)) + .foregroundStyle(.primary) + .lineLimit(1) + Image(systemName: isExpanded ? "chevron.down" : "chevron.right") + .font(.system(size: 9, weight: .semibold)) + .foregroundStyle(.secondary) + Spacer(minLength: 16) + Text(group.statusLabel) + .font(.system(size: 12)) + .foregroundStyle(.secondary) + .lineLimit(1) + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + + if isExpanded { + VStack(alignment: .leading, spacing: 6) { + ForEach(group.children) { child in + self.statusRow(child, indented: true) + } + } + } + } + } +} diff --git a/Sources/CodexBar/StatusItemController+AccountMenuDisplay.swift b/Sources/CodexBar/StatusItemController+AccountMenuDisplay.swift index ea0bb9d2df..aa40d7eb62 100644 --- a/Sources/CodexBar/StatusItemController+AccountMenuDisplay.swift +++ b/Sources/CodexBar/StatusItemController+AccountMenuDisplay.swift @@ -1,9 +1,56 @@ import AppKit import CodexBarCore +enum ClaudeSwapMenuPrecedence { + static func prefersClaudeSwap( + provider: UsageProvider, + accountCount: Int, + showSingleAccount: Bool) -> Bool + { + provider == .claude && ClaudeSwapAccountProjection.shouldPresentAccounts( + accountCount: accountCount, + showSingleAccount: showSingleAccount) + } +} + extension StatusItemController { + private static let defaultCodexAccountMenuProjectionRevalidationEnabled = !SettingsStore.isRunningTests + + #if DEBUG + private static var codexAccountMenuProjectionRevalidationEnabledForTesting = + defaultCodexAccountMenuProjectionRevalidationEnabled + + static func setCodexAccountMenuProjectionRevalidationEnabledForTesting(_ enabled: Bool) { + self.codexAccountMenuProjectionRevalidationEnabledForTesting = enabled + } + + static func resetCodexAccountMenuProjectionRevalidationEnabledForTesting() { + self.codexAccountMenuProjectionRevalidationEnabledForTesting = + self.defaultCodexAccountMenuProjectionRevalidationEnabled + } + #endif + + private static var codexAccountMenuProjectionRevalidationEnabled: Bool { + #if DEBUG + self.codexAccountMenuProjectionRevalidationEnabledForTesting + #else + self.defaultCodexAccountMenuProjectionRevalidationEnabled + #endif + } + func tokenAccountMenuDisplay(for provider: UsageProvider) -> TokenAccountMenuDisplay? { guard TokenAccountSupportCatalog.support(for: provider) != nil else { return nil } + // Retained Cursor manual accounts are dormant while Automatic browser discovery owns the live snapshot. + guard self.settings.effectiveSelectedTokenAccount(for: provider) != nil else { return nil } + // Eligible claude-swap rows are the selected Claude account source, so do not mix them + // with token-account cards or the segmented token-account switcher. + if ClaudeSwapMenuPrecedence.prefersClaudeSwap( + provider: provider, + accountCount: self.store.claudeSwapAccountSnapshots.count, + showSingleAccount: self.settings.claudeSwapShowSingleAccount) + { + return nil + } let accounts = self.settings.tokenAccounts(for: provider) guard accounts.count > 1 else { return nil } let activeIndex = self.settings.tokenAccountsData(for: provider)?.clampedActiveIndex() ?? 0 @@ -27,15 +74,31 @@ extension StatusItemController { matching accounts: [ProviderTokenAccount]) -> [TokenAccountUsageSnapshot] { var snapshotsByID: [UUID: TokenAccountUsageSnapshot] = [:] - for snapshot in self.store.accountSnapshots[provider] ?? [] { + for snapshot in self.store.validTokenAccountSnapshots(provider: provider, accounts: accounts) { snapshotsByID[snapshot.account.id] = snapshot } return accounts.compactMap { snapshotsByID[$0.id] } } + func tokenAccountMenuCardModel( + for provider: UsageProvider, + accountSnapshot: TokenAccountUsageSnapshot) -> UsageMenuCardView.Model? + { + let label = accountSnapshot.account.displayName.trimmingCharacters(in: .whitespacesAndNewlines) + return self.menuCardModel( + for: provider, + snapshotOverride: accountSnapshot.snapshot, + errorOverride: accountSnapshot.error, + forceOverrideCard: true, + accountOverride: AccountInfo(email: label.isEmpty ? nil : label, plan: nil), + historySelectionOverride: self.store.planUtilizationHistorySelection( + for: provider, + account: accountSnapshot.account)) + } + func codexAccountMenuDisplay(for provider: UsageProvider) -> CodexAccountMenuDisplay? { guard provider == .codex else { return nil } - let projection = self.settings.codexVisibleAccountProjection + guard let projection = self.settings.codexVisibleAccountProjectionForMenuDisplay else { return nil } guard projection.visibleAccounts.count > 1 else { return nil } let showAll = self.settings.multiAccountMenuLayout == .stacked let accounts = showAll @@ -52,12 +115,38 @@ extension StatusItemController { layout: showAll ? .stacked : .segmented) } + func scheduleCodexAccountMenuProjectionRevalidationIfNeeded(for providers: [UsageProvider]) { + guard Self.codexAccountMenuProjectionRevalidationEnabled else { return } + guard providers.contains(.codex) else { return } + guard self.settings.codexAccountMenuProjectionNeedsRevalidation else { return } + guard self.codexAccountMenuProjectionRevalidationTask == nil else { return } + + self.codexAccountMenuProjectionRevalidationTask = Task { @MainActor [weak self] in + guard let settings = self?.settings else { return } + let result = await settings.revalidateCodexAccountMenuProjection() + guard let self else { return } + guard !Task.isCancelled else { + self.codexAccountMenuProjectionRevalidationTask = nil + return + } + self.codexAccountMenuProjectionRevalidationTask = nil + + switch result { + case .updated: + self.invalidateMenus(refreshOpenMenus: false) + case .discarded, .skipped, .unchanged: + break + } + } + } + private func codexAccountSnapshots(matching accounts: [CodexVisibleAccount]) -> [CodexAccountUsageSnapshot] { - var snapshotsByID: [String: CodexAccountUsageSnapshot] = [:] - for snapshot in self.store.codexAccountSnapshots { - snapshotsByID[snapshot.id] = snapshot + accounts.compactMap { account in + self.store.codexAccountSnapshots.first { snapshot in + snapshot.id == account.id && + UsageStore.codexPriorSnapshotAccountMatches(snapshot.account, account: account) + } } - return accounts.compactMap { snapshotsByID[$0.id] } } func stableCodexAccountMenuDisplay( diff --git a/Sources/CodexBar/StatusItemController+Actions.swift b/Sources/CodexBar/StatusItemController+Actions.swift index fbb4ebcde5..b7a74b7370 100644 --- a/Sources/CodexBar/StatusItemController+Actions.swift +++ b/Sources/CodexBar/StatusItemController+Actions.swift @@ -1,6 +1,15 @@ import AppKit import CodexBarCore +extension StatusItemController { + /// Identifies which manual refresh a task belongs to, so per-provider refreshes stay independent + /// of each other and of the all-providers refresh. + enum ManualRefreshScope: Hashable { + case global + case provider(UsageProvider) + } +} + enum LoginNotificationLogic { static func notificationCopy(providerName: String) -> (title: String, body: String) { ( @@ -12,31 +21,266 @@ 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 self.withProviderInteraction(interaction) { + await self.store.refresh(forceTokenUsage: forceTokenUsage) + guard !Task.isCancelled, !self.hasPreparedForAppShutdown else { return } + self.store.scheduleStorageFootprintRefreshForOverview(force: true) + if refreshOpenMenusWhenComplete { + self.refreshOpenMenusAfterExplicitStoreAction() + } else { + self.invalidateMenus() + } + } + } + + func performStoreRefresh( + enrichmentMode: UsageStore.RefreshEnrichmentMode, + refreshOpenMenusWhenComplete: Bool, + interaction: ProviderInteraction) async + { + await self.withProviderInteraction(interaction) { + await self.store.refresh(enrichmentMode: enrichmentMode) + guard !Task.isCancelled, !self.hasPreparedForAppShutdown else { return } + self.store.scheduleStorageFootprintRefreshForOverview(force: true) + if refreshOpenMenusWhenComplete { + self.refreshOpenMenusAfterExplicitStoreAction() + } else { + self.invalidateMenus() + } + } + } + + func performStoreRefresh( + for provider: UsageProvider, + refreshOpenMenusWhenComplete: Bool, + interaction: ProviderInteraction) async + { + await self.withProviderInteraction(interaction) { + await self.store.awaitForcedRefreshEnrichment() + guard !Task.isCancelled, !self.hasPreparedForAppShutdown else { return } + let refreshStartedAt = Date() + await self.store.refreshProvider(provider) + guard !Task.isCancelled, !self.hasPreparedForAppShutdown else { return } + await self.store.refreshProviderStatus(provider) + guard !Task.isCancelled, !self.hasPreparedForAppShutdown else { return } + await self.store.refreshTokenUsageNow(for: provider, force: true) + guard !Task.isCancelled, !self.hasPreparedForAppShutdown else { return } + if provider == .codex { + await self.store.refreshCreditsNow(minimumSnapshotUpdatedAt: refreshStartedAt) + guard !Task.isCancelled, !self.hasPreparedForAppShutdown else { return } + await self.store.refreshOpenAIDashboardIfNeeded( + force: true, + expectedGuard: self.store.freshCodexOpenAIWebRefreshGuard(), + bypassCoalescing: true) + guard !Task.isCancelled, !self.hasPreparedForAppShutdown else { return } + if self.store.openAIDashboardRequiresLogin { + await self.store.refreshProvider(.codex) + guard !Task.isCancelled, !self.hasPreparedForAppShutdown else { return } + await self.store.refreshCreditsNow(minimumSnapshotUpdatedAt: refreshStartedAt) + guard !Task.isCancelled, !self.hasPreparedForAppShutdown else { return } + } + } + self.store.scheduleStorageFootprintRefresh(for: [provider], force: true) + self.store.persistWidgetSnapshot(reason: "provider-refresh") + if refreshOpenMenusWhenComplete { + self.refreshOpenMenusAfterExplicitStoreAction() + } else { + self.invalidateMenus() + } + } + } + + private func withProviderInteraction( + _ interaction: ProviderInteraction, + operation: () async -> Void) async + { + if interaction == .userInitiated { + await BrowserCookieAccessGate.withExplicitRetry { + await ProviderInteractionContext.$current.withValue(interaction) { + await operation() } } + } else { + await ProviderInteractionContext.$current.withValue(interaction) { + await operation() + } } } func refreshOpenMenusAfterExplicitStoreAction() { - self.invalidateMenus(refreshOpenMenus: true) + self.invalidateMenus( + refreshOpenMenus: true, + deferOpenParentMenuRebuild: true) } @objc func refreshNow() { - self.refreshStore(forceTokenUsage: true) + self.startManualRefresh( + for: nil, + originatingMenuID: nil, + originatingMenuInteractionGeneration: nil) + } + + @objc func refreshMenuItem(_ sender: NSMenuItem) { + self.refreshMenuProviderNow(in: sender.menu) + } + + func refreshMenuProviderNow(in menu: NSMenu?) { + let originatingMenuID = menu.map(ObjectIdentifier.init) + let originatingMenuInteractionGeneration = originatingMenuID.flatMap { + self.menuSession.menuInteractionGeneration(for: $0) + } + self.startManualRefresh( + for: self.manualRefreshProvider(for: menu), + originatingMenuID: originatingMenuID, + originatingMenuInteractionGeneration: originatingMenuInteractionGeneration) + } + + private func refreshMenuProviderNow( + menuID: ObjectIdentifier, + originatingMenuInteractionGeneration: Int) + { + let menu = self.openMenus[menuID] ?? self.mergedMenu.flatMap { + ObjectIdentifier($0) == menuID ? $0 : nil + } + let provider = menu.flatMap { self.manualRefreshProvider(for: $0) } ?? self.menuProviders[menuID] + self.startManualRefresh( + for: provider, + originatingMenuID: menuID, + originatingMenuInteractionGeneration: originatingMenuInteractionGeneration) + } + + private func startManualRefresh( + for provider: UsageProvider?, + originatingMenuID: ObjectIdentifier?, + originatingMenuInteractionGeneration: Int?) + { + let scope: ManualRefreshScope = provider.map(ManualRefreshScope.provider) ?? .global + let scopedRefreshInFlight = provider.map { self.store.refreshingProviders.contains($0) } + ?? !self.store.refreshingProviders.isEmpty + // Two different providers may refresh concurrently, but an all-providers (.global) refresh must + // not overlap a per-provider one (or vice versa) — that would duplicate the shared fetch work. + let conflictsWithOtherScope = scope == .global + ? self.manualRefreshTasks.contains { $0.key != .global } + : self.manualRefreshTasks[.global] != nil + guard !self.hasPreparedForAppShutdown, + self.manualRefreshTasks[scope] == nil, + !conflictsWithOtherScope, + !self.store.hasForcedRefreshEnrichmentInFlight, + !self.store.isRefreshing, + !scopedRefreshInFlight + else { return } + + let frozenModels = self.frozenManualRefreshMenuCardModels() + let viewportRestoreRequests = self.armManualRefreshViewportRestoreRequests( + originatingMenuID: originatingMenuID, + originatingMenuInteractionGeneration: originatingMenuInteractionGeneration) + let task = Task { @MainActor [weak self] in + guard let self else { return } + var completed = false + defer { + self.manualRefreshTasks[scope] = nil + self.menuCardRefreshMonitor.endManualRefresh(for: provider) + self.updatePersistentRefreshItemsEnabled() + if completed { + self.scheduleCompletedManualRefreshViewportRestore(viewportRestoreRequests) + } else { + self.cancelManualRefreshViewportRestoreRequests(viewportRestoreRequests) + } + self.completeParentMenuRebuildAfterHostedSubviewCloseIfNeeded() + self.prepareAttachedClosedMenusIfNeeded() + } + guard !Task.isCancelled, !self.hasPreparedForAppShutdown else { return } + #if DEBUG + if let operation = self._test_manualRefreshOperation { + await operation() + guard !Task.isCancelled, !self.hasPreparedForAppShutdown else { return } + completed = true + return + } + #endif + if let provider { + await self.performStoreRefresh( + for: provider, + refreshOpenMenusWhenComplete: true, + interaction: .userInitiated) + } else { + await self.performStoreRefresh( + enrichmentMode: .forcedBackground, + refreshOpenMenusWhenComplete: true, + interaction: .userInitiated) + } + guard !Task.isCancelled, !self.hasPreparedForAppShutdown else { return } + completed = true + } + self.manualRefreshTasks[scope] = task + self.menuCardRefreshMonitor.beginManualRefresh(frozenModels: frozenModels, provider: provider) + self.updatePersistentRefreshItemsEnabled() + } + + private func manualRefreshProvider(for menu: NSMenu?) -> UsageProvider? { + guard let menu else { return nil } + if self.shouldMergeIcons { + guard self.mergedMenu == nil || menu === self.mergedMenu else { return nil } + guard !self.isMergedOverviewSelected(in: menu) else { return nil } + return self.resolvedMenuProvider() + } + return self.menuProviders[ObjectIdentifier(menu)] + } + + private func frozenManualRefreshMenuCardModels() -> [UsageProvider: UsageMenuCardView.Model] { + var providers = self.store.enabledProvidersForDisplay() + if let lastMenuProvider, + !providers.contains(lastMenuProvider) + { + providers.append(lastMenuProvider) + } + if providers.isEmpty, + let defaultProvider = self.settings.orderedProviders().first ?? UsageProvider.allCases.first + { + providers.append(defaultProvider) + } + + var models: [UsageProvider: UsageMenuCardView.Model] = [:] + for provider in providers { + models[provider] = self.menuCardModel(for: provider) + } + return models + } + + func performPersistentRefreshAction(in menuID: ObjectIdentifier) { + guard let menuInteractionGeneration = self.menuSession.menuInteractionGeneration(for: menuID) else { return } + self.performPersistentRefreshAction( + in: menuID, + menuInteractionGeneration: menuInteractionGeneration) } - nonisolated func performPersistentRefreshAction() { + nonisolated func performPersistentRefreshAction( + in menuID: ObjectIdentifier, + menuInteractionGeneration: Int) + { Task { @MainActor [weak self] in - self?.refreshNow() + guard let self else { return } + self.refreshMenuProviderNow( + menuID: menuID, + originatingMenuInteractionGeneration: menuInteractionGeneration) } } @@ -86,10 +330,18 @@ extension StatusItemController: StatusItemMenuPersistentActionDelegate { NSWorkspace.shared.open(url) } - func dashboardURL(for provider: UsageProvider) -> URL? { + func dashboardURL( + for provider: UsageProvider, + environment: [String: String] = ProcessInfo.processInfo.environment) -> URL? + { if provider == .alibaba { return self.settings.alibabaCodingPlanAPIRegion.dashboardURL } + if provider == .alibabatokenplan { + return AlibabaTokenPlanUsageFetcher.dashboardURL( + region: self.settings.alibabaTokenPlanAPIRegion, + environment: environment) + } if provider == .minimax { return self.settings.minimaxAPIRegion.dashboardURL } @@ -98,6 +350,25 @@ extension StatusItemController: StatusItemMenuPersistentActionDelegate { return self.settings.opencodegoDashboardURL } + if provider == .wayfinder { + return WayfinderProviderImplementation.dashboardURL( + settings: self.settings, + environment: environment) + } + + if provider == .zai { + return ZaiUsageFetcher.resolveDashboardURL( + region: self.settings.zaiAPIRegion, + environment: environment, + usageScope: self.settings.zaiEffectiveUsageScope()) + } + + if provider == .qoder { + return QoderProviderDescriptor.dashboardURL( + settings: self.settings.qoderSettingsSnapshot(tokenOverride: nil), + sourceLabel: self.store.sourceLabel(for: .qoder)) + } + let meta = self.store.metadata(for: provider) let urlString: String? = if provider == .claude, self.store.isClaudeSubscription() { meta.subscriptionDashboardURL ?? meta.dashboardURL @@ -123,8 +394,21 @@ extension StatusItemController: StatusItemMenuPersistentActionDelegate { let autoStart = true let accountEmail = self.store.codexAccountEmailForOpenAIDashboard() + let cacheScope = self.store.codexCookieCacheScopeForOpenAIWeb() + guard OpenAICreditsPurchaseWindowController.canOpenPurchaseWindow( + accountEmail: accountEmail, + cacheScope: cacheScope) + else { + self.creditsPurchaseWindow?.close() + self.creditsPurchaseWindow = nil + return + } let controller = self.creditsPurchaseWindow ?? OpenAICreditsPurchaseWindowController() - controller.show(purchaseURL: url, accountEmail: accountEmail, autoStartPurchase: autoStart) + controller.show( + purchaseURL: url, + accountEmail: accountEmail, + cacheScope: cacheScope, + autoStartPurchase: autoStart) self.creditsPurchaseWindow = controller } @@ -150,7 +434,17 @@ extension StatusItemController: StatusItemMenuPersistentActionDelegate { let preferred = self.lastMenuProvider ?? (self.store.isEnabled(.codex) ? .codex : self.store.enabledProviders().first) - let provider = preferred ?? .codex + self.openStatusPage(for: preferred ?? .codex) + } + + @objc func openStatusPageFromMenuItem(_ sender: NSMenuItem) { + let provider = (sender.identifier?.rawValue).flatMap(UsageProvider.init(rawValue:)) + ?? self.lastMenuProvider + ?? .codex + self.openStatusPage(for: provider) + } + + private func openStatusPage(for provider: UsageProvider) { let meta = self.store.metadata(for: provider) let urlString = meta.statusPageURL ?? meta.statusLinkURL guard let urlString, let url = URL(string: urlString) else { return } @@ -169,7 +463,7 @@ extension StatusItemController: StatusItemMenuPersistentActionDelegate { @objc func openTerminalCommand(_ sender: NSMenuItem) { let command = sender.representedObject as? String ?? "claude" - Self.openTerminal(command: command) + self.openTerminal(command: command) } @objc func openLoginToProvider(_ sender: NSMenuItem) { @@ -246,11 +540,12 @@ extension StatusItemController: StatusItemMenuPersistentActionDelegate { } @objc func showSettingsGeneral() { - self.openSettings(tab: .general) + // Restore the last selected pane; only About navigates explicitly. + self.openSettings(pane: nil) } @objc func showSettingsAbout() { - self.openSettings(tab: .about) + self.openSettings(pane: .about) } func openMenuFromShortcut() { @@ -301,19 +596,35 @@ extension StatusItemController: StatusItemMenuPersistentActionDelegate { return CGPoint(x: screenFrame.midX, y: screenFrame.midY) } - private func openSettings(tab: PreferencesTab) { + private func openSettings(pane: SettingsPane?) { DispatchQueue.main.async { - self.preferencesSelection.tab = tab + if let pane { + self.preferencesSelection.pane = pane + } NSApp.activate(ignoringOtherApps: true) - NotificationCenter.default.post( - name: .codexbarOpenSettings, - object: nil, - userInfo: ["tab": tab.rawValue]) + let outcome = SettingsWindowOpener.live().open(preferred: .notification) + switch outcome { + case .preferred: + break + case .fallback: + self.menuLogger.warning("Settings notification relay unavailable; used AppKit fallback") + case .failed: + self.menuLogger.error("Failed to open Settings; notification relay and AppKit fallback unavailable") + } } } @objc func quit() { - NSApp.terminate(nil) + let openMenus = Array(self.openMenus.values) + for menu in openMenus { + menu.cancelTrackingWithoutAnimation() + } + + self.scheduleQuitTermination { [weak self] in + guard let self else { return } + self.prepareForAppShutdown() + self.terminateApplicationForQuit() + } } @objc func copyError(_ sender: NSMenuItem) { @@ -324,25 +635,48 @@ extension StatusItemController: StatusItemMenuPersistentActionDelegate { } } - private static func openTerminal(command: String) { - let escaped = command - .replacingOccurrences(of: "\\\\", with: "\\\\\\\\") - .replacingOccurrences(of: "\"", with: "\\\"") - let script = """ - tell application "Terminal" - activate - do script "\(escaped)" - end tell - """ - if let appleScript = NSAppleScript(source: script) { + func openTerminal(command: String) { + let terminal = self.settings.terminalApp + + if terminal == .iTerm, !terminal.isInstalled { + CodexBarLog.logger(LogCategories.terminal).warning( + "iTerm is not installed, falling back to Terminal.app", + metadata: ["terminal": terminal.rawValue]) + Self.openTerminalInDefaultTerminal(command: command) + return + } + + if Self.executeAppleScript(terminal.appleScript(command: command)) { + return + } + guard terminal != .terminal else { return } + + CodexBarLog.logger(LogCategories.terminal).warning( + "\(terminal.label) AppleScript failed, falling back to Terminal.app", + metadata: ["terminal": terminal.rawValue]) + Self.openTerminalInDefaultTerminal(command: command) + } + + private static func openTerminalInDefaultTerminal(command: String) { + self.executeAppleScript(TerminalApp.terminal.appleScript(command: command)) + } + + /// Executes an AppleScript and returns `true` on success, `false` on failure. + @discardableResult + private static func executeAppleScript(_ source: String) -> Bool { + if let appleScript = NSAppleScript(source: source) { var error: NSDictionary? appleScript.executeAndReturnError(&error) if let error { CodexBarLog.logger(LogCategories.terminal).error( - "Failed to open Terminal", + "Failed to execute AppleScript", metadata: ["error": String(describing: error)]) + return false } + return true } + CodexBarLog.logger(LogCategories.terminal).error("Failed to compile AppleScript") + return false } private func resolvedShortcutProvider() -> UsageProvider { @@ -406,13 +740,13 @@ extension StatusItemController: StatusItemMenuPersistentActionDelegate { title: L("Claude CLI not found"), message: L("Install the Claude CLI (npm i -g @anthropic-ai/claude-code) and try again.")) case let .launchFailed(message): - self.presentLoginAlert(title: L("Could not start claude /login"), message: message) + self.presentLoginAlert(title: L("Could not start Claude Code login"), message: message) case .timedOut: self.presentLoginAlert( title: L("Claude login timed out"), message: self.trimmedLoginOutput(result.output)) case let .failed(status): - let statusLine = String(format: L("claude /login exited with status %d."), status) + let statusLine = String(format: L("claude auth login exited with status %d."), status) let message = self.trimmedLoginOutput(result.output.isEmpty ? statusLine : result.output) self.presentLoginAlert(title: L("Claude login failed"), message: message) } @@ -517,8 +851,12 @@ extension StatusItemController: StatusItemMenuPersistentActionDelegate { private func trimmedLoginOutput(_ text: String) -> String { let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) let limit = 600 - if trimmed.isEmpty { return L("No output captured.") } - if trimmed.count <= limit { return trimmed } + if trimmed.isEmpty { + return L("No output captured.") + } + if trimmed.count <= limit { + return trimmed + } let idx = trimmed.index(trimmed.startIndex, offsetBy: limit) return "\(trimmed[.. 1 && !values[1].isEmpty ? values[1] : nil + let session = if let remoteHost { + self.agentSessions.remoteHosts + .first(where: { $0.host == remoteHost })? + .sessions.first(where: { $0.id == sessionID }) + } else { + self.agentSessions.localSessions.first(where: { $0.id == sessionID }) + } + guard let session else { return } + self.agentSessions.focus(session, remoteHost: remoteHost) + } +} diff --git a/Sources/CodexBar/StatusItemController+Animation.swift b/Sources/CodexBar/StatusItemController+Animation.swift index ae50b8c4b4..3f1346e12b 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() @@ -233,54 +232,41 @@ extension StatusItemController { } @discardableResult - func applyIcon(phase: Double?) -> Bool { // swiftlint:disable:this function_body_length + func applyIcon( + phase: Double?, + bypassMergedMenuTrackingDeferral: Bool = false) -> Bool + { guard let button = self.statusItem.button else { return false } + if !bypassMergedMenuTrackingDeferral, + self.deferMergedIconRenderDuringMenuTrackingIfNeeded() { return true } let style = self.store.iconStyle let showUsed = self.settings.usageBarsShowUsed let showBrandPercent = self.settings.menuBarShowsBrandIconWithPercent let primaryProvider = self.primaryProviderForUnifiedIcon() + let resolverStyle = self.store.style(for: primaryProvider) let snapshot = self.store.snapshot(for: primaryProvider) let warningFlash = self.quotaWarningFlashActive(provider: primaryProvider) + if let layoutResult = self.applyStoredUnifiedMenuBarLayoutIfNeeded( + provider: primaryProvider, + snapshot: snapshot, + warningFlash: warningFlash) + { + return layoutResult + } + // IconRenderer treats these values as a left-to-right "progress fill" percentage; depending on the // user setting we pass either "percent left" or "percent used". - let resolved = snapshot.map { - IconRemainingResolver.resolvedPercents( - snapshot: $0, - style: style, - showUsed: showUsed) - } + let resolved = self.resolvedMenuBarIconPercents( + provider: primaryProvider, + snapshot: snapshot, + style: resolverStyle, + showUsed: showUsed, + renderingStyle: style) var primary = resolved?.primary var weekly = resolved?.secondary - if showUsed, - primaryProvider == .warp, - let remaining = snapshot?.secondary?.remainingPercent, - remaining <= 0 - { - // Preserve Warp "no bonus/exhausted bonus" layout even in show-used mode. - weekly = 0 - } - if showUsed, - primaryProvider == .warp, - let remaining = snapshot?.secondary?.remainingPercent, - remaining > 0, - weekly == 0 - { - // In show-used mode, `0` means "unused", not "missing". Keep the weekly lane present. - weekly = Self.loadingPercentEpsilon - } - let codexProjection = self.store.codexConsumerProjectionIfNeeded( - for: primaryProvider, - surface: .menuBar, - snapshotOverride: snapshot, - now: snapshot?.updatedAt ?? Date()) - var credits: Double? = - codexProjection?.menuBarFallback == .creditsBalance - ? self.store.codexMenuBarCreditsRemaining( - snapshotOverride: snapshot, - now: snapshot?.updatedAt ?? Date()) - : nil + var credits = self.menuBarCreditsRemainingForIcon(provider: primaryProvider, snapshot: snapshot) var stale = self.store.isStale(provider: primaryProvider) var morphProgress: Double? @@ -313,26 +299,12 @@ extension StatusItemController { let tilt: CGFloat = style == .combined ? 0 : self.tiltAmount(for: primaryProvider) * .pi / 28 - let statusIndicator: ProviderStatusIndicator = { - for provider in self.store.enabledProvidersForDisplay() { - let indicator = self.store.statusIndicator(for: provider) - if indicator.hasIssue { return indicator } - } - return .none - }() - - let usageColor: NSColor? = { - guard self.settings.colorCodedIcons, !needsAnimation else { return nil } - return UsageColorLevel.tintColor(for: snapshot?.primary?.usedPercent) - }() - let tintSignature = usageColor == nil - ? "nil" - : Self.iconSignatureValue(snapshot?.primary?.usedPercent) - + let statusIndicator = self.store.statusIndicator(for: primaryProvider) if showBrandPercent, let brand = ProviderBrandIcon.image(for: primaryProvider) { let displayText = self.menuBarDisplayText(for: primaryProvider, snapshot: snapshot) + let displayedImage = warningFlash ? Self.quotaWarningFlashImage(base: brand) : brand let signature = [ "mode=brandPercent", "provider=\(primaryProvider.rawValue)", @@ -343,23 +315,25 @@ extension StatusItemController { "stale=\(stale ? "1" : "0")", "status=\(statusIndicator.rawValue)", "text=\(displayText ?? "nil")", - "tint=\(tintSignature)", "warningFlash=\(warningFlash ? "1" : "0")", "anim=\(needsAnimation ? "1" : "0")", + "hideCritters=\(self.settings.menuBarHidesCritters ? "1" : "0")", + "highContrast=\(self.shouldUseHighContrastStatusItemContent ? "1" : "0")", ].joined(separator: "|") if self.shouldSkipMergedIconRender(signature) { + // AppKit can lose button content state independently of the cached render signature. + // Keep this cheap path self-healing even when the provider image itself can be skipped. + self.setButtonContent(image: displayedImage, title: displayText, for: button) self.noteIconPerfRender(skipped: true) return true } - self.setButtonImage( - warningFlash ? Self.quotaWarningFlashImage(base: brand) : brand, for: button) - self.setButtonTitle(displayText, for: button) - self.setButtonTintColor(usageColor, for: button) + self.setButtonContent(image: displayedImage, title: displayText, for: button) self.noteIconPerfRender(skipped: false) return false } - self.setButtonTitle(nil, for: button) + // Brand + percent returns above; remaining paths are image-only apart from the debug marker. + let canSkipCachedRender = self.prepareButtonForImageOnlyCacheHit(button) if let morphProgress { let signature = [ "mode=morph", @@ -369,16 +343,23 @@ extension StatusItemController { "status=\(statusIndicator.rawValue)", "warningFlash=\(warningFlash ? "1" : "0")", "anim=\(needsAnimation ? "1" : "0")", + "hideCritters=\(self.settings.menuBarHidesCritters ? "1" : "0")", + "highContrast=\(self.shouldUseHighContrastStatusItemContent ? "1" : "0")", ].joined(separator: "|") - if self.shouldSkipMergedIconRender(signature) { + if self.shouldSkipMergedIconRender(signature), canSkipCachedRender { self.noteIconPerfRender(skipped: true) return true } - let image = IconRenderer.makeMorphIcon(progress: morphProgress, style: style) - self.setButtonImage( - warningFlash ? Self.quotaWarningFlashImage(base: image) : image, for: button) - self.setButtonTintColor(nil, for: button) + let image = IconRenderer.makeMorphIcon( + progress: morphProgress, + style: style, + hideCritters: self.settings.menuBarHidesCritters) + self.setButtonContent( + image: warningFlash ? Self.quotaWarningFlashImage(base: image) : image, + title: nil, + for: button) } else { + let tint = self.menuBarUsageTint(primaryBarPercent: primary, showUsed: showUsed) let signature = [ "mode=icon", "provider=\(primaryProvider.rawValue)", @@ -391,11 +372,13 @@ extension StatusItemController { "blink=\(Self.iconSignatureValue(Double(blink)))", "wiggle=\(Self.iconSignatureValue(Double(wiggle)))", "tilt=\(Self.iconSignatureValue(Double(tilt)))", - "tint=\(tintSignature)", "warningFlash=\(warningFlash ? "1" : "0")", "anim=\(needsAnimation ? "1" : "0")", + "hideCritters=\(self.settings.menuBarHidesCritters ? "1" : "0")", + "highContrast=\(self.shouldUseHighContrastStatusItemContent ? "1" : "0")", + "usageColors=\(tint == nil ? "0" : "1")", ].joined(separator: "|") - if self.shouldSkipMergedIconRender(signature) { + if self.shouldSkipMergedIconRender(signature), canSkipCachedRender { self.noteIconPerfRender(skipped: true) return true } @@ -409,15 +392,57 @@ extension StatusItemController { wiggle: wiggle, tilt: tilt, statusIndicator: statusIndicator, - tintColor: usageColor) - self.setButtonImage( - warningFlash ? Self.quotaWarningFlashImage(base: image) : image, for: button) - self.setButtonTintColor(usageColor, for: button) + hideCritters: self.settings.menuBarHidesCritters, + tint: tint) + self.setButtonContent( + image: warningFlash ? Self.quotaWarningFlashImage(base: image) : image, + title: nil, + for: button) } self.noteIconPerfRender(skipped: false) return false } + private func applyStoredUnifiedMenuBarLayoutIfNeeded( + provider: UsageProvider, + snapshot: UsageSnapshot?, + warningFlash: Bool) + -> Bool? + { + guard self.settings.menuBarShowsBrandIconWithPercent else { + self.statusItem.length = NSStatusItem.variableLength + return nil + } + guard let wasCached = self.applyStoredMenuBarLayoutIfNeeded( + provider: provider, + snapshot: snapshot, + icon: ProviderBrandIcon.image(for: provider), + warningFlash: warningFlash, + statusItem: self.statusItem) + else { return nil } + self.noteIconPerfRender(skipped: wasCached) + return wasCached + } + + private func deferMergedIconRenderDuringMenuTrackingIfNeeded() -> Bool { + guard self.shouldMergeIcons, self.isMergedMenuOpen else { return false } + self.deferredMergedIconRenderAfterTracking = true + self.noteIconPerfRender(skipped: true) + return true + } + + func applyDeferredMergedIconRenderAfterTrackingIfNeeded() { + guard self.deferredMergedIconRenderAfterTracking else { return } + guard self.shouldMergeIcons else { + self.deferredMergedIconRenderAfterTracking = false + return + } + guard !self.isMergedMenuOpen else { return } + self.deferredMergedIconRenderAfterTracking = false + let phase: Double? = self.animationDriver == nil ? nil : self.animationPhase + self.applyIcon(phase: phase) + } + private func shouldSkipMergedIconRender(_ signature: String) -> Bool { guard self.shouldMergeIcons else { self.lastAppliedMergedIconRenderSignature = signature @@ -439,88 +464,64 @@ extension StatusItemController { } @discardableResult - func applyIcon(for provider: UsageProvider, phase: Double?) -> Bool { // swiftlint:disable:this function_body_length + func applyIcon(for provider: UsageProvider, phase: Double?) -> Bool { guard let button = self.statusItems[provider]?.button else { return false } let snapshot = self.store.snapshot(for: provider) // IconRenderer treats these values as a left-to-right "progress fill" percentage; depending on the // user setting we pass either "percent left" or "percent used". let showUsed = self.settings.usageBarsShowUsed let showBrandPercent = self.settings.menuBarShowsBrandIconWithPercent + if !showBrandPercent { + self.statusItems[provider]?.length = NSStatusItem.variableLength + } let style: IconStyle = self.store.style(for: provider) let warningFlash = self.quotaWarningFlashActive(provider: provider) - let isAnimatingForColor = phase != nil && self.shouldAnimate(provider: provider) - let usageColor: NSColor? = { - guard self.settings.colorCodedIcons, !isAnimatingForColor else { return nil } - return UsageColorLevel.tintColor(for: snapshot?.primary?.usedPercent) - }() - let tintSignature = usageColor == nil - ? "nil" - : Self.iconSignatureValue(snapshot?.primary?.usedPercent) + if showBrandPercent, + let statusItem = self.statusItems[provider], + let wasCached = self.applyStoredMenuBarLayoutIfNeeded( + provider: provider, + snapshot: snapshot, + icon: ProviderBrandIcon.image(for: provider), + warningFlash: warningFlash, + statusItem: statusItem) + { + self.noteIconPerfRender(skipped: wasCached) + return wasCached + } if showBrandPercent, let brand = ProviderBrandIcon.image(for: provider) { let displayText = self.menuBarDisplayText(for: provider, snapshot: snapshot) + let displayedImage = warningFlash ? Self.quotaWarningFlashImage(base: brand) : brand let signature = [ "mode=brandPercent", "provider=\(provider.rawValue)", "style=\(String(describing: style))", "text=\(displayText ?? "nil")", - "tint=\(tintSignature)", "warningFlash=\(warningFlash ? "1" : "0")", + "highContrast=\(self.shouldUseHighContrastStatusItemContent ? "1" : "0")", ].joined(separator: "|") if self.shouldSkipProviderIconRender(provider: provider, signature: signature) { + self.setButtonContent(image: displayedImage, title: displayText, for: button) self.noteIconPerfRender(skipped: true) return true } - self.setButtonImage( - warningFlash ? Self.quotaWarningFlashImage(base: brand) : brand, for: button) - self.setButtonTitle(displayText, for: button) - self.setButtonTintColor(usageColor, for: button) + self.setButtonContent(image: displayedImage, title: displayText, for: button) self.noteIconPerfRender(skipped: false) return false } - self.setButtonTitle(nil, for: button) - // OpenRouter always gets a meter here — the brand-logo fallback was removed on purpose. - let resolved = snapshot.map { - IconRemainingResolver.resolvedPercents( - snapshot: $0, - style: style, - showUsed: showUsed) - } + let resolved = self.resolvedMenuBarIconPercents( + provider: provider, + snapshot: snapshot, + style: style, + showUsed: showUsed) var primary = resolved?.primary var weekly = resolved?.secondary - if showUsed, - provider == .warp, - let remaining = snapshot?.secondary?.remainingPercent, - remaining <= 0 - { - // Preserve Warp "no bonus/exhausted bonus" layout even in show-used mode. - weekly = 0 - } - if showUsed, - provider == .warp, - let remaining = snapshot?.secondary?.remainingPercent, - remaining > 0, - weekly == 0 - { - // In show-used mode, `0` means "unused", not "missing". Keep the weekly lane present. - weekly = Self.loadingPercentEpsilon - } - let codexProjection = self.store.codexConsumerProjectionIfNeeded( - for: provider, - surface: .menuBar, - snapshotOverride: snapshot, - now: snapshot?.updatedAt ?? Date()) - var credits: Double? = - codexProjection?.menuBarFallback == .creditsBalance - ? self.store.codexMenuBarCreditsRemaining( - snapshotOverride: snapshot, - now: snapshot?.updatedAt ?? Date()) - : nil + var credits = self.menuBarCreditsRemainingForIcon(provider: provider, snapshot: snapshot) var stale = self.store.isStale(provider: provider) var morphProgress: Double? @@ -557,6 +558,8 @@ extension StatusItemController { let wiggle = self.wiggleAmount(for: provider) let tilt = self.tiltAmount(for: provider) * .pi / 28 // limit to ~6.4° let statusIndicator = self.store.statusIndicator(for: provider) + // Brand + percent returns above; remaining paths are image-only apart from the debug marker. + let canSkipCachedRender = self.prepareButtonForImageOnlyCacheHit(button) if let morphProgress { let signature = [ "mode=morph", @@ -566,16 +569,23 @@ extension StatusItemController { "status=\(statusIndicator.rawValue)", "warningFlash=\(warningFlash ? "1" : "0")", "loading=\(isLoading ? "1" : "0")", + "hideCritters=\(self.settings.menuBarHidesCritters ? "1" : "0")", + "highContrast=\(self.shouldUseHighContrastStatusItemContent ? "1" : "0")", ].joined(separator: "|") - if self.shouldSkipProviderIconRender(provider: provider, signature: signature) { + if self.shouldSkipProviderIconRender(provider: provider, signature: signature), canSkipCachedRender { self.noteIconPerfRender(skipped: true) return true } - let image = IconRenderer.makeMorphIcon(progress: morphProgress, style: style) - self.setButtonImage( - warningFlash ? Self.quotaWarningFlashImage(base: image) : image, for: button) - self.setButtonTintColor(nil, for: button) + let image = IconRenderer.makeMorphIcon( + progress: morphProgress, + style: style, + hideCritters: self.settings.menuBarHidesCritters) + self.setButtonContent( + image: warningFlash ? Self.quotaWarningFlashImage(base: image) : image, + title: nil, + for: button) } else { + let tint = self.menuBarUsageTint(primaryBarPercent: primary, showUsed: showUsed) let signature = [ "mode=icon", "provider=\(provider.rawValue)", @@ -588,11 +598,13 @@ extension StatusItemController { "blink=\(Self.iconSignatureValue(Double(blink)))", "wiggle=\(Self.iconSignatureValue(Double(wiggle)))", "tilt=\(Self.iconSignatureValue(Double(tilt)))", - "tint=\(tintSignature)", "warningFlash=\(warningFlash ? "1" : "0")", "loading=\(isLoading ? "1" : "0")", + "hideCritters=\(self.settings.menuBarHidesCritters ? "1" : "0")", + "highContrast=\(self.shouldUseHighContrastStatusItemContent ? "1" : "0")", + "usageColors=\(tint == nil ? "0" : "1")", ].joined(separator: "|") - if self.shouldSkipProviderIconRender(provider: provider, signature: signature) { + if self.shouldSkipProviderIconRender(provider: provider, signature: signature), canSkipCachedRender { self.noteIconPerfRender(skipped: true) return true } @@ -606,20 +618,95 @@ extension StatusItemController { wiggle: wiggle, tilt: tilt, statusIndicator: statusIndicator, - tintColor: usageColor) - self.setButtonImage( - warningFlash ? Self.quotaWarningFlashImage(base: image) : image, for: button) - self.setButtonTintColor(usageColor, for: button) + hideCritters: self.settings.menuBarHidesCritters, + tint: tint) + self.setButtonContent( + image: warningFlash ? Self.quotaWarningFlashImage(base: image) : image, + title: nil, + for: button) } self.noteIconPerfRender(skipped: false) return false } - private static func iconSignatureValue(_ value: Double?) -> String { + static func iconSignatureValue(_ value: Double?) -> String { guard let value else { return "nil" } return String(format: "%.3f", value) } + /// Tint for the meter icon, or `nil` to leave it an untinted template. + /// + /// `primaryBarPercent` follows `usageBarsShowUsed`, so it is normalized to a used percentage here. + /// Reading it as "remaining" would paint a freshly reset quota red. + func menuBarUsageTint(primaryBarPercent: Double?, showUsed: Bool) -> NSColor? { + guard self.settings.menuBarUsageColorsEnabled else { return nil } + return MenuBarUsageTint.color(forUsedPercent: primaryBarPercent.map { showUsed ? $0 : 100 - $0 }) + } + + func resolvedMenuBarIconPercents( + provider: UsageProvider, + snapshot: UsageSnapshot?, + style: IconStyle, + showUsed: Bool, + renderingStyle: IconStyle? = nil) + -> (primary: Double?, secondary: Double?)? + { + guard let snapshot else { return nil } + let preference = self.settings.menuBarMetricPreference(for: provider, snapshot: snapshot) + if preference == .monthlyPlan { + guard let metricWindow = self.menuBarMetricWindowForIconOverride( + preference: preference, + provider: provider, + snapshot: snapshot) + else { + return (primary: nil, secondary: nil) + } + return ( + primary: showUsed ? metricWindow.usedPercent : metricWindow.remainingPercent, + secondary: nil) + } + if provider == .mistral { + return (primary: nil, secondary: nil) + } + return IconRemainingResolver.resolvedPercents( + snapshot: snapshot, + style: style, + showUsed: showUsed, + renderingStyle: renderingStyle, + secondaryOverrideWindowID: self.settings.copilotIconSecondaryWindowOverrideID(snapshot: snapshot)) + } + + private func menuBarMetricWindowForIconOverride( + preference: MenuBarMetricPreference, + provider: UsageProvider, + snapshot: UsageSnapshot) + -> RateWindow? + { + MenuBarMetricWindowResolver.rateWindow( + preference: preference, + provider: provider, + snapshot: snapshot, + supportsAverage: self.settings.menuBarMetricSupportsAverage(for: provider)) + } + + func menuBarCreditsRemainingForIcon( + provider: UsageProvider, + snapshot: UsageSnapshot?, + now: Date = Date()) -> Double? + { + // Derive the menu-bar credits fallback from the same Codex projection path the rendered + // icon and menu use (`codexConsumerProjection` -> `menuBarFallback`), instead of a + // hand-rolled rate-window predicate. The projection is pure value composition over + // already-loaded snapshot/credits state (no IO), so this stays cheap while keeping the + // icon render, this signature input, and the menu-bar fallback semantics on a single + // source of truth — a hand-rolled approximation can silently drift from the projection + // as its fallback logic evolves. + guard provider == .codex else { return nil } + return self.store.codexMenuBarCreditsRemaining( + snapshotOverride: snapshot, + now: now) + } + func quotaWarningFlashActive(provider: UsageProvider, now: Date = Date()) -> Bool { guard let until = self.quotaWarningFlashUntil[provider] else { return false } if until > now { return true } @@ -629,6 +716,42 @@ extension StatusItemController { return false } + func startQuotaWarningFlash(provider: UsageProvider, postedAt: Date = Date()) { + let until = postedAt.addingTimeInterval(Self.quotaWarningFlashDuration) + self.quotaWarningFlashUntil[provider] = until + self.quotaWarningFlashTasks[provider]?.cancel() + self.updateIcons() + self.applyQuotaWarningIconDuringMergedMenuTrackingIfNeeded() + self.quotaWarningFlashTasks[provider] = Task { [weak self] in + try? await Task.sleep(for: .seconds(Self.quotaWarningFlashDuration)) + await MainActor.run { [weak self] in + self?.clearExpiredQuotaWarningFlash(provider: provider) + } + } + } + + func clearExpiredQuotaWarningFlash(provider: UsageProvider, now: Date = Date()) { + guard let currentUntil = self.quotaWarningFlashUntil[provider], + currentUntil <= now + else { + return + } + self.quotaWarningFlashUntil.removeValue(forKey: provider) + self.quotaWarningFlashTasks.removeValue(forKey: provider) + self.updateIcons() + self.applyQuotaWarningIconDuringMergedMenuTrackingIfNeeded() + } + + private func applyQuotaWarningIconDuringMergedMenuTrackingIfNeeded() { + guard self.shouldMergeIcons, + self.isMergedMenuOpen + else { + return + } + let phase: Double? = self.animationDriver == nil ? nil : self.animationPhase + self.applyIcon(phase: phase, bypassMergedMenuTrackingDeferral: true) + } + static func quotaWarningFlashImage(base: NSImage) -> NSImage { let image = NSImage(size: base.size) image.lockFocus() @@ -643,18 +766,54 @@ extension StatusItemController { return image } - private func setButtonImage(_ image: NSImage, for button: NSStatusBarButton) { - if button.image === image { return } - button.image = image + var shouldUseHighContrastStatusItemContent: Bool { + self.settings.menuBarHighContrastOnInactiveDisplays + && self.settings.menuBarIconStyle == .iconAndPercent } - private func setButtonTintColor(_ color: NSColor?, for button: NSStatusBarButton) { - if button.contentTintColor == color { return } - button.contentTintColor = color + func prepareButtonForImageOnlyCacheHit(_ button: NSStatusBarButton) -> Bool { + if self.shouldUseHighContrastStatusItemContent { + guard button.image == nil, + button.imagePosition == .noImage, + button.attributedTitle.length > 0 + else { return false } + return button.attributedTitle.attribute( + .attachment, + at: 0, + effectiveRange: nil) is NSTextAttachment + } + + let value = Self.buttonTitle( + nil, + hasImage: true, + isDebugApp: Self.isDebugApp(bundleIdentifier: Bundle.main.bundleIdentifier)) + if button.title != value { + button.title = value + } + let position: NSControl.ImagePosition = value.isEmpty ? .imageOnly : .imageLeft + if button.imagePosition != position { + button.imagePosition = position + } + return true } - private func setButtonTitle(_ title: String?, for button: NSStatusBarButton) { - let value = Self.buttonTitle(title, hasImage: button.image != nil) + private func setButtonContent(image: NSImage, title: String?, for button: NSStatusBarButton) { + let isDebugApp = Self.isDebugApp(bundleIdentifier: Bundle.main.bundleIdentifier) + let value = Self.buttonTitle( + title, + hasImage: true, + isDebugApp: isDebugApp) + + if self.shouldUseHighContrastStatusItemContent { + button.image = nil + button.imagePosition = .noImage + button.attributedTitle = Self.highContrastButtonTitle(image: image, title: value) + return + } + + if button.image !== image { + button.image = image + } if button.title != value { button.title = value } @@ -664,37 +823,95 @@ extension StatusItemController { } } - nonisolated static func buttonTitle(_ title: String?, hasImage: Bool) -> String { - guard let title, !title.isEmpty else { return "" } - return hasImage ? " \(title)" : title + static func highContrastButtonTitle(image: NSImage, title: String) -> NSAttributedString { + let font = NSFont.systemFont(ofSize: NSFont.systemFontSize) + let attachment = NSTextAttachment() + attachment.image = image + attachment.bounds = NSRect( + x: 0, + y: ((font.capHeight - image.size.height) / 2).rounded(), + width: image.size.width, + height: image.size.height) + + let value = NSMutableAttributedString(attachment: attachment) + if !title.isEmpty { + value.append(NSAttributedString( + string: title, + attributes: [ + .font: font, + .foregroundColor: NSColor.labelColor, + ])) + } + return value } - func menuBarDisplayText(for provider: UsageProvider, snapshot: UsageSnapshot?) -> String? { + nonisolated static func buttonTitle(_ title: String?, hasImage: Bool, isDebugApp: Bool = false) -> String { + var parts: [String] = [] + if let title, !title.isEmpty { + parts.append(title) + } + if isDebugApp { + parts.append("D") + } + let value = parts.joined(separator: " ") + return hasImage && !value.isEmpty ? " \(value)" : value + } + + func menuBarDisplayText( + for provider: UsageProvider, + snapshot: UsageSnapshot?, + now: Date = .init()) -> String? + { + let mode = self.settings.menuBarDisplayMode if provider == .openrouter, self.settings.menuBarMetricPreference(for: provider, snapshot: snapshot) == .automatic, let balance = snapshot?.openRouterUsage?.balance { - return UsageFormatter.usdString(balance) + return UsageFormatter.convertedCostString( + balance, + preferredCurrency: self.settings.preferredCurrencyCode, + providerCurrency: "USD") + } + if provider == .opencodego, + let balance = Self.openCodeGoZenBalanceDisplayText(snapshot: snapshot) + { + return balance } if provider == .deepseek, let balance = Self.deepSeekBalanceDisplayText(snapshot: snapshot) { return balance } + if provider == .deepinfra, + let balance = Self.deepInfraBalanceDisplayText(snapshot: snapshot) + { + return balance + } + if provider == .mimo, + let balance = Self.miMoBalanceDisplayText( + snapshot: snapshot, + preference: self.settings.menuBarMetricPreference(for: provider, snapshot: snapshot)) + { + return balance + } if provider == .moonshot, let balance = Self.moonshotBalanceDisplayText(snapshot: snapshot) { return balance } - if provider == .mistral, - let spend = Self.mistralSpendDisplayText(snapshot: snapshot) + if provider == .poe, + let balance = Self.poeBalanceDisplayText(snapshot: snapshot) { - return spend + return balance } - if provider == .kimik2, - let credits = Self.kimiK2CreditsDisplayText(snapshot: snapshot) - { - return credits + if provider == .mistral { + let preference = self.settings.menuBarMetricPreference(for: provider, snapshot: snapshot) + let hasMonthlyPlan = snapshot?.extraRateWindows?.contains { $0.id == "mistral-monthly-plan" } == true + if preference != .monthlyPlan || !hasMonthlyPlan, + let spend = Self.mistralSpendDisplayText(snapshot: snapshot) + { + return spend + } } if provider == .kiro { return Self.kiroDisplayText( @@ -702,54 +919,48 @@ extension StatusItemController { mode: self.settings.kiroMenuBarDisplayMode, showUsed: self.settings.usageBarsShowUsed) } - if self.settings.menuBarMetricPreference(for: provider, snapshot: snapshot) == .extraUsage, + if mode != .resetTime, + self.settings.menuBarMetricPreference(for: provider, snapshot: snapshot) == .extraUsage, + provider != .cursor || mode == .pace, let spend = Self.extraUsageSpendDisplayText(snapshot: snapshot) { return spend } - let percentWindow: RateWindow? = switch self.settings.menuBarPercentTimeWindow { - case .session: - self.menuBarPercentWindow(for: provider, snapshot: snapshot) - case .weekly: - snapshot?.secondary ?? self.menuBarPercentWindow(for: provider, snapshot: snapshot) - } - let mode = self.settings.menuBarDisplayMode - let now = Date() + let percentWindow = self.menuBarPercentWindow(for: provider, snapshot: snapshot, now: now) let codexProjection = self.store.codexConsumerProjectionIfNeeded( for: provider, surface: .menuBar, snapshotOverride: snapshot, now: now) + + // The combined "Session + Weekly" metric (Codex and Claude) shows both lanes in percent mode + // ("5h 12% · W 45%") and, in pace/both modes, pairs the session usage with the weekly pace. + let combinedLanes = self.combinedSessionWeeklyLanes( + for: provider, snapshot: snapshot, projection: codexProjection) + let pace: UsagePace? switch mode { case .percent: pace = nil case .pace, .both: - switch self.settings.menuBarPaceTimeWindow { - case .session: - let sessionWindow = snapshot?.primary ?? snapshot?.secondary - pace = sessionWindow.flatMap { window in - UsagePaceText.sessionPace(provider: provider, window: window, now: now) - } - case .weekly: - let weeklyWindow = - codexProjection?.rateWindow(for: .weekly) - ?? snapshot?.secondary - // Abacus has no secondary window; pace is computed on primary monthly credits - ?? (provider == .abacus ? snapshot?.primary : nil) - pace = weeklyWindow.flatMap { window in - self.store.weeklyPace(provider: provider, window: window, now: now) - } + let paceWindow = self.menuBarPaceWindow( + for: provider, + snapshot: snapshot, + projection: codexProjection, + combinedLanes: combinedLanes, + percentWindow: percentWindow) + pace = paceWindow.flatMap { window in + self.store.weeklyPace(provider: provider, window: window, now: now) } + case .resetTime: + return MenuBarDisplayText.displayText( + mode: mode, + percentWindow: percentWindow, + showUsed: self.settings.usageBarsShowUsed, + resetTimeDisplayStyle: self.settings.resetTimeDisplayStyle, + now: now) } - let displayText = MenuBarDisplayText.displayText( - mode: mode, - percentWindow: percentWindow, - pace: pace, - showUsed: self.settings.usageBarsShowUsed, - separatorStyle: self.settings.menuBarSeparatorStyle) - if mode == .percent, !self.settings.usageBarsShowUsed, codexProjection?.menuBarFallback == .creditsBalance, @@ -761,8 +972,32 @@ extension StatusItemController { .creditsString(from: creditsRemaining) .replacingOccurrences(of: " left", with: "") } + if let combinedLanes, mode == .percent { + if let combinedText = MenuBarDisplayText.combinedSessionWeeklyPercentText( + sessionWindow: combinedLanes.session, + weeklyWindow: combinedLanes.weekly, + showUsed: self.settings.usageBarsShowUsed, + resetTimeDisplayStyle: self.settings.resetTimeDisplayStyle, + showsResetTimeWhenExhausted: self.settings.menuBarShowsResetTimeWhenExhausted, + now: now) + { + return combinedText + } + } - return displayText + let displayPercentWindow: RateWindow? = if let combinedLanes { + Self.combinedDisplayPercentWindow(lanes: combinedLanes, fallback: percentWindow) + } else { + percentWindow + } + return MenuBarDisplayText.displayText( + mode: mode, + percentWindow: displayPercentWindow, + pace: pace, + showUsed: self.settings.usageBarsShowUsed, + resetTimeDisplayStyle: self.settings.resetTimeDisplayStyle, + showsResetTimeWhenExhausted: self.settings.menuBarShowsResetTimeWhenExhausted, + now: now) } nonisolated static func deepSeekBalanceDisplayText(snapshot: UsageSnapshot?) -> String? { @@ -779,6 +1014,39 @@ extension StatusItemController { return balance.map(String.init) } + nonisolated static func deepInfraBalanceDisplayText(snapshot: UsageSnapshot?) -> String? { + guard + let detail = snapshot?.primary?.resetDescription? + .trimmingCharacters(in: .whitespacesAndNewlines), + let balanceDetail = detail.components(separatedBy: " · ").dropLast().last? + .trimmingCharacters(in: .whitespacesAndNewlines), + balanceDetail.hasPrefix("$"), + let value = balanceDetail.split(separator: " ", maxSplits: 1).first + else { + return nil + } + + let prefix = balanceDetail.contains(" owed") ? "-" : "" + return prefix + String(value) + } + + nonisolated static func miMoBalanceDisplayText( + snapshot: UsageSnapshot?, + preference: MenuBarMetricPreference) -> String? + { + guard let snapshot, let mimoUsage = snapshot.mimoUsage else { return nil } + if snapshot.primary != nil, preference != .secondary { return nil } + let detail = mimoUsage.balanceDetail + return detail.components(separatedBy: " (Paid:").first + } + + nonisolated static func poeBalanceDisplayText(snapshot: UsageSnapshot?) -> String? { + self.displayValue( + from: snapshot?.loginMethod(for: .poe), + prefix: "Balance:", + removingSuffix: "") + } + nonisolated static func moonshotBalanceDisplayText(snapshot: UsageSnapshot?) -> String? { self.displayValue( from: snapshot?.loginMethod(for: .moonshot), @@ -799,13 +1067,6 @@ extension StatusItemController { removingSuffix: " this month") } - nonisolated static func kimiK2CreditsDisplayText(snapshot: UsageSnapshot?) -> String? { - self.displayValue( - from: snapshot?.identity?.loginMethod, - prefix: "Credits:", - removingSuffix: " left") - } - nonisolated static func extraUsageSpendDisplayText(snapshot: UsageSnapshot?) -> String? { guard let cost = snapshot?.providerCost, cost.limit > 0, @@ -816,6 +1077,17 @@ extension StatusItemController { return UsageFormatter.currencyString(cost.used, currencyCode: cost.currencyCode) } + nonisolated static func openCodeGoZenBalanceDisplayText(snapshot: UsageSnapshot?) -> String? { + guard snapshot?.primary == nil, + snapshot?.secondary == nil, + let cost = snapshot?.providerCost, + cost.period == "Zen balance" + else { + return nil + } + return UsageFormatter.currencyString(cost.used, currencyCode: cost.currencyCode) + } + nonisolated static func kiroDisplayText( snapshot: UsageSnapshot?, mode: KiroMenuBarDisplayMode, @@ -935,19 +1207,184 @@ extension StatusItemController { return value.isEmpty ? nil : value } - private func menuBarPercentWindow(for provider: UsageProvider, snapshot: UsageSnapshot?) + private func menuBarPercentWindow(for provider: UsageProvider, snapshot: UsageSnapshot?, now: Date) -> RateWindow? { - self.menuBarMetricWindow(for: provider, snapshot: snapshot) + self.menuBarMetricWindow(for: provider, snapshot: snapshot, now: now) + } + + /// Resolves the session (5h) and weekly (7d) lanes for the combined "Session + Weekly" menu-bar + /// metric, or nil when that metric is not active for `provider`. Codex resolves its lanes through the + /// consumer projection; Claude has none, so it classifies by window cadence — a 7-day window the OAuth + /// mapper parked in `primary` (the five_hour fallback) must not be mislabeled as a 5-hour session lane. + private func combinedSessionWeeklyLanes( + for provider: UsageProvider, + snapshot: UsageSnapshot?, + projection: CodexConsumerProjection?) -> (session: RateWindow?, weekly: RateWindow?)? + { + guard provider == .codex || provider == .claude, + self.settings.menuBarMetricPreference(for: provider, snapshot: snapshot) == .primaryAndSecondary + else { return nil } + // A Claude account that only exposes an enterprise/extra-usage spend limit has no real + // session/weekly lanes; defer to the resolver's spend-limit routing instead of rendering an + // empty or 0% placeholder lane under the combined metric. + if provider == .claude, + let snapshot, + MenuBarMetricWindowResolver.claudeSpendLimitWindow(snapshot: snapshot) != nil + { + return nil + } + let session = Self.combinedSessionLane(snapshot: snapshot, projection: projection) + let weekly: RateWindow? = if let projection { + projection.menuBarSelectableRateWindow(for: .weekly) + } else { + Self.rateWindow(in: snapshot, matchingCadenceMinutes: Self.weeklyWindowMinutes) + } + return (session, weekly) } - private func primaryProviderForUnifiedIcon() -> UsageProvider { - // When "show highest usage" is enabled, auto-select the provider closest to rate limit. - if self.settings.menuBarShowsHighestUsage, - self.shouldMergeIcons, - let highest = self.store.providerWithHighestUsage() + /// Reset dates for every lane whose menu-bar text is currently rendered as a reset time, so the + /// countdown scheduler can refresh each of them. Reset-time mode drives a single window. The smart + /// "reset time when exhausted" option can surface BOTH combined session/weekly lanes in percent mode, + /// while pace/both render the one lane chosen by `combinedDisplayPercentWindow` — mirror that presentation + /// here rather than scheduling whichever lane happened to drive the icon. + func menuBarDisplayedResetDates(for provider: UsageProvider, now: Date) -> [Date] { + let snapshot = self.store.snapshot(for: provider) + let layoutResolution = self.settings.menuBarLayoutResolution(for: provider) + if !layoutResolution.usesLegacyRendering, + self.settings.menuBarIconStyle == .iconAndPercent + { + let showsReset = layoutResolution.layout.lines + .joined() + .contains { $0 == .resetCountdown || $0 == .resetAbsolute } + guard showsReset else { return [] } + let window = self.menuBarLayoutWindows(provider: provider, snapshot: snapshot, now: now).automatic + return window?.resetsAt.map { [$0] } ?? [] + } + let mode = self.settings.menuBarDisplayMode + + let projection = self.store.codexConsumerProjectionIfNeeded( + for: provider, + surface: .menuBar, + snapshotOverride: snapshot, + now: now) + if let lanes = self.combinedSessionWeeklyLanes( + for: provider, snapshot: snapshot, projection: projection), + lanes.session != nil || lanes.weekly != nil { - return highest.provider + switch mode { + case .percent: + // Percent renders both lanes independently, so schedule every exhausted reset. + return [lanes.session, lanes.weekly] + .compactMap(\.self) + .filter { $0.remainingPercent <= 0 } + .compactMap(\.resetsAt) + case .pace, .both: + // Pace/both render one usage lane alongside the weekly pace. Use that exact lane rather + // than `menuBarMetricWindow`, whose tie-breaking can select the other exhausted window. + let window = Self.combinedDisplayPercentWindow( + lanes: lanes, + fallback: self.menuBarMetricWindow(for: provider, snapshot: snapshot, now: now)) + guard let window, window.remainingPercent <= 0 else { return [] } + return window.resetsAt.map { [$0] } ?? [] + case .resetTime: + break + } + } + + guard let window = self.menuBarMetricWindow(for: provider, snapshot: snapshot, now: now) + else { return [] } + // Outside reset-time mode the reset text is only visible once the quota is exhausted. + if mode != .resetTime, window.remainingPercent > 0 { return [] } + return window.resetsAt.map { [$0] } ?? [] + } + + /// The combined metric's session (5h) lane. Codex resolves it through the consumer projection; other + /// providers classify by window cadence. A 5-hour lane the provider only synthesized to stand in for an + /// absent session — Claude web's null `five_hour` placeholder, flagged at the boundary — is dropped so a + /// weekly-only account falls back to its weekly lane instead of rendering a phantom `5h 0%`/`5h 100%` + /// session. A genuine session (even one freshly reset to 0%) is not flagged, so it is kept. + private static func combinedSessionLane( + snapshot: UsageSnapshot?, + projection: CodexConsumerProjection?) -> RateWindow? + { + if let projection { + return projection.menuBarSelectableRateWindow(for: .session) + } + guard let session = Self.rateWindow(in: snapshot, matchingCadenceMinutes: Self.sessionWindowMinutes) + else { return nil } + if session.isSyntheticPlaceholder { + return nil + } + return session + } + + /// The window the weekly pace is computed on in pace/both modes. Codex paces on its projected weekly + /// lane; the combined Session + Weekly metric paces on the weekly lane too (matching Codex); Abacus + /// has no secondary window so it paces on the primary monthly credits; everything else paces on the + /// selected percent window. + private func menuBarPaceWindow( + for provider: UsageProvider, + snapshot: UsageSnapshot?, + projection: CodexConsumerProjection?, + combinedLanes: (session: RateWindow?, weekly: RateWindow?)?, + percentWindow: RateWindow?) -> RateWindow? + { + if let projection { + return projection.menuBarSelectableRateWindow(for: .weekly) + } + if provider == .abacus { + return snapshot?.primary + } + if let combinedLanes { + return combinedLanes.weekly + } + return percentWindow + } + + /// The usage window shown for the combined metric in pace/both modes. It pairs the SESSION usage with + /// the weekly pace, so the usage component normally comes from the session lane — not the + /// most-constrained lane that drives the icon/bar. Two exceptions: fall back to the weekly lane when no + /// session lane exists (the five_hour OAuth fallback or Claude web's filtered null-session + /// placeholder), and surface the weekly lane when it is exhausted + /// — it is then the binding cap with no pace to show, and a roomy session number would hide it. + private static func combinedDisplayPercentWindow( + lanes: (session: RateWindow?, weekly: RateWindow?), + fallback: RateWindow?) -> RateWindow? + { + if let weekly = lanes.weekly, weekly.remainingPercent <= 0 { + return weekly + } + return lanes.session ?? lanes.weekly ?? fallback + } + + private static let sessionWindowMinutes = 5 * 60 + private static let weeklyWindowMinutes = 7 * 24 * 60 + + /// Returns the first session/weekly snapshot lane whose window cadence matches `minutes`. + /// Used by the combined Session + Weekly metric for providers without a Codex consumer + /// projection so a fallback weekly window parked in `primary` is not mislabeled as a session lane. + private static func rateWindow(in snapshot: UsageSnapshot?, matchingCadenceMinutes minutes: Int) -> RateWindow? { + [snapshot?.primary, snapshot?.secondary] + .compactMap(\.self) + .first { $0.windowMinutes == minutes } + } + + func primaryProviderForUnifiedIcon() -> UsageProvider { + // When "show highest usage" is enabled, rank the existing Overview subset by proximity to its limit. + if self.settings.menuBarShowsHighestUsage, self.shouldMergeIcons { + let activeProviders = self.store.enabledProvidersForDisplay() + let overviewProviders = self.settings.resolvedMergedOverviewProviders( + activeProviders: activeProviders, + maxVisibleProviders: SettingsStore.mergedOverviewProviderLimit) + if let highest = self.store.providerWithHighestUsage(candidateProviders: overviewProviders) { + return highest.provider + } + // A nonempty Overview selection remains authoritative while its providers are loading, + // unrankable, or exhausted. Only an explicitly empty Overview may use the broad fallback. + if let fallback = overviewProviders.first(where: { self.store.isEnabled($0) }) { + return fallback + } } if self.shouldMergeIcons, self.settings.mergedMenuLastSelectedWasOverview { let enabledProviders = self.store.enabledProvidersForDisplay() @@ -964,7 +1401,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 } @@ -1013,7 +1450,7 @@ extension StatusItemController { self.tickBlink(now: now) } - private func shouldAnimate(provider: UsageProvider, mergeIcons: Bool? = nil) -> Bool { + func shouldAnimate(provider: UsageProvider, mergeIcons: Bool? = nil) -> Bool { if self.store.debugForceAnimation { return true } let isMerged = mergeIcons ?? self.shouldMergeIcons @@ -1027,11 +1464,11 @@ extension StatusItemController { if isFallbackOnly { return false } let isStale = self.store.isStale(provider: provider) - let hasData = self.store.snapshot(for: provider) != nil - if provider == .warp, !hasData, self.store.refreshingProviders.contains(provider) { + let hasSatisfiedUsageFetch = self.store.hasSatisfiedUsageFetch(for: provider) + if provider == .warp, !hasSatisfiedUsageFetch, self.store.refreshingProviders.contains(provider) { return true } - return !hasData && !isStale + return !hasSatisfiedUsageFetch && !isStale } func updateAnimationState() { diff --git a/Sources/CodexBar/StatusItemController+ClaudeSwapMenu.swift b/Sources/CodexBar/StatusItemController+ClaudeSwapMenu.swift new file mode 100644 index 0000000000..6d2cc8fb87 --- /dev/null +++ b/Sources/CodexBar/StatusItemController+ClaudeSwapMenu.swift @@ -0,0 +1,65 @@ +import AppKit +import CodexBarCore + +extension StatusItemController { + func addClaudeSwapMenuCards( + to menu: NSMenu, + captureMenu: NSMenu, + context: MenuCardContext) + { + let cardRows = self.store.claudeSwapAccountSnapshots.compactMap { account -> + (account: ProviderAccountUsageSnapshot, model: UsageMenuCardView.Model)? in + guard let model = self.menuCardModel( + for: .claude, + snapshotOverride: account.snapshot, + errorOverride: ClaudeSwapAccountProjection.displayError( + accountError: account.error, + adapterError: self.store.claudeSwapLastError, + switchError: self.store.claudeSwapTransientState.lastErrorAccountID == account.id + ? self.store.claudeSwapTransientState.lastError + : nil), + forceOverrideCard: account.snapshot == nil, + accountOverride: AccountInfo( + email: account.displayLabel, + plan: nil), + planOverride: self.claudeSwapAccountActionLabel(account)) + else { + return nil + } + return (account, model) + } + self.addStackedMenuCards( + cardRows.map(\.model), + to: menu, + context: context, + planAction: { [weak self] index in + guard cardRows.indices.contains(index) else { return nil } + return self?.claudeSwapAccountSwitchAction(cardRows[index].account, menu: captureMenu) + }) + } + + private func claudeSwapAccountActionLabel(_ account: ProviderAccountUsageSnapshot) -> String? { + if account.isActive { + return L("Active") + } + if self.store.claudeSwapTransientState.switchingAccountID == account.id { + return L("Loading…") + } + guard self.store.claudeSwapTransientState.task == nil, account.canActivate else { return nil } + return L("Switch Account...") + } + + private func claudeSwapAccountSwitchAction( + _ account: ProviderAccountUsageSnapshot, + menu: NSMenu) + -> (() -> Void)? + { + guard self.store.claudeSwapTransientState.task == nil, account.canActivate else { return nil } + let accountID = account.id + return { [weak self, weak menu] in + guard let self else { return } + self.advanceMenuInteraction(for: menu) + self.store.switchClaudeSwapAccount(accountID) + } + } +} diff --git a/Sources/CodexBar/StatusItemController+CodexStackedMenu.swift b/Sources/CodexBar/StatusItemController+CodexStackedMenu.swift index c26f9aa857..79ae741f38 100644 --- a/Sources/CodexBar/StatusItemController+CodexStackedMenu.swift +++ b/Sources/CodexBar/StatusItemController+CodexStackedMenu.swift @@ -27,12 +27,17 @@ extension StatusItemController { snapshotOverride: accountSnapshot?.snapshot, errorOverride: health.label, forceOverrideCard: accountSnapshot == nil, - accountOverride: self.accountInfo(for: account)) + accountOverride: self.accountInfo(for: account), + historySelectionOverride: self.store.codexPlanUtilizationHistorySelection( + forVisibleAccount: account)) guard let model else { continue } menu.addItem(self.makeMenuCardItem( UsageMenuCardView(model: model, width: context.menuWidth), id: "menuCard-\(cardIndex)", - width: context.menuWidth)) + width: context.menuWidth, + heightCacheScope: account.id, + heightCacheFingerprint: model.heightFingerprint(section: "card"), + containsInteractiveControls: true)) cardIndex += 1 if account.id != section.accounts.last?.id { menu.addItem(.separator()) @@ -48,7 +53,10 @@ extension StatusItemController { menu.addItem(self.makeMenuCardItem( UsageMenuCardView(model: model, width: context.menuWidth), id: "menuCard", - width: context.menuWidth)) + width: context.menuWidth, + heightCacheScope: context.currentProvider.rawValue, + heightCacheFingerprint: model.heightFingerprint(section: "card"), + containsInteractiveControls: true)) } menu.addItem(.separator()) if self.addStorageMenuCardSection(to: menu, provider: context.currentProvider, width: context.menuWidth) { diff --git a/Sources/CodexBar/StatusItemController+CostMenuCard.swift b/Sources/CodexBar/StatusItemController+CostMenuCard.swift index a0bf479395..be919949e7 100644 --- a/Sources/CodexBar/StatusItemController+CostMenuCard.swift +++ b/Sources/CodexBar/StatusItemController+CostMenuCard.swift @@ -1,44 +1,133 @@ import AppKit +import CodexBarCore +import SwiftUI + +private struct CostMenuCardRowView: View { + let title: String + let detailLines: [String] + let width: CGFloat + @Environment(\.menuItemHighlighted) private var isHighlighted + + var body: some View { + VStack(alignment: .leading, spacing: 2) { + Text(self.title) + .font(.system(size: NSFont.menuFont(ofSize: 0).pointSize)) + .lineLimit(1) + ForEach(self.detailLines.indices, id: \.self) { index in + Text(self.detailLines[index]) + .font(.system(size: NSFont.smallSystemFontSize)) + .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted)) + .lineLimit(1) + .truncationMode(.tail) + } + } + .padding(.leading, 20) + .padding(.trailing, 28) + .padding(.vertical, 6) + .frame(width: self.width, alignment: .leading) + } +} extension StatusItemController { static var costMenuTitle: String { L("Cost") } - func makeCostMenuCardItem(model: UsageMenuCardView.Model, submenu: NSMenu?) -> NSMenuItem { - let tooltipLines = Self.costMenuTooltipLines(tokenUsage: model.tokenUsage) - let visibleDetailLines = Self.costMenuVisibleDetailLines(tokenUsage: model.tokenUsage) - let item = NSMenuItem(title: Self.costMenuTitle, action: nil, keyEquivalent: "") + static func costMenuTitleForProvider(_: UsageProvider) -> String { + self.costMenuTitle + } + + func makeCostMenuCardItem( + model: UsageMenuCardView.Model, + submenu: NSMenu?, + width: CGFloat) -> NSMenuItem + { + let title = Self.costMenuTitleForProvider(model.provider) + let tooltipLines = Self.costMenuTooltipLines(provider: model.provider, tokenUsage: model.tokenUsage) + let visibleDetailLines = Self.costMenuVisibleDetailLines( + provider: model.provider, + tokenUsage: model.tokenUsage, + hasSubmenu: submenu != nil) + guard visibleDetailLines.isEmpty == false, self.menuCardRenderingEnabledForController else { + return Self.makeNativeCostMenuCardItem( + title: title, + visibleDetailLines: visibleDetailLines, + tooltipLines: tooltipLines, + submenu: submenu) + } + + let item = self.makeMenuCardItem( + CostMenuCardRowView( + title: title, + detailLines: visibleDetailLines, + width: width), + id: "menuCardCost", + width: width, + heightCacheScope: model.provider.rawValue, + heightCacheFingerprint: "costMenuRow:\(visibleDetailLines.count)", + submenu: submenu, + submenuIndicatorAlignment: .trailing, + submenuIndicatorTopPadding: 0) + item.title = title + item.toolTip = tooltipLines.joined(separator: "\n") + return item + } + + private static func makeNativeCostMenuCardItem( + title: String, + visibleDetailLines: [String], + tooltipLines: [String], + submenu: NSMenu?) -> NSMenuItem + { + let item = NSMenuItem(title: title, action: nil, keyEquivalent: "") item.isEnabled = true item.representedObject = "menuCardCost" item.submenu = submenu - item.toolTip = tooltipLines.joined(separator: "\n") + // Submenu cost rows already show these details; keep tooltips only for inline rows + // where they reveal truncated text and avoid flashes during in-place menu refreshes. + if submenu == nil { + item.toolTip = tooltipLines.joined(separator: "\n") + } if #available(macOS 14.4, *) { item.subtitle = visibleDetailLines.joined(separator: "\n") } else if !visibleDetailLines.isEmpty { - item.attributedTitle = Self.costMenuFallbackAttributedTitle(visibleDetailLines: visibleDetailLines) + item.attributedTitle = Self.costMenuFallbackAttributedTitle( + title: title, + visibleDetailLines: visibleDetailLines) } return item } - static func costMenuTooltipLines(tokenUsage: UsageMenuCardView.Model.TokenUsageSection?) -> [String] { - [ + static func costMenuTooltipLines( + provider _: UsageProvider, + tokenUsage: UsageMenuCardView.Model.TokenUsageSection?) -> [String] + { + let lines = [ tokenUsage?.sessionLine, tokenUsage?.monthLine, - tokenUsage?.hintLine, - tokenUsage?.errorLine, + tokenUsage?.meteredLine, ] .compactMap(\.self) - .filter { !$0.isEmpty } + + (tokenUsage?.comparisonLines ?? []) + + [tokenUsage?.hintLine, tokenUsage?.errorLine].compactMap(\.self) + return lines.filter { !$0.isEmpty } } - static func costMenuVisibleDetailLines(tokenUsage: UsageMenuCardView.Model.TokenUsageSection?) -> [String] { - let primaryLines = [ + static func costMenuVisibleDetailLines( + provider: UsageProvider, + tokenUsage: UsageMenuCardView.Model.TokenUsageSection?, + hasSubmenu: Bool) -> [String] + { + guard !hasSubmenu else { return [] } + let primaryLines = ([ tokenUsage?.sessionLine, tokenUsage?.monthLine, - tokenUsage?.errorLine, + tokenUsage?.meteredLine, ] .compactMap(\.self) + + (tokenUsage?.comparisonLines ?? []) + + [provider == .codex ? tokenUsage?.hintLine : nil].compactMap(\.self) + + [tokenUsage?.errorLine].compactMap(\.self)) .filter { !$0.isEmpty } guard primaryLines.isEmpty else { return primaryLines } return [tokenUsage?.hintLine] @@ -46,9 +135,12 @@ extension StatusItemController { .filter { !$0.isEmpty } } - static func costMenuFallbackAttributedTitle(visibleDetailLines: [String]) -> NSAttributedString { + static func costMenuFallbackAttributedTitle( + title: String, + visibleDetailLines: [String]) -> NSAttributedString + { let detailText = visibleDetailLines.joined(separator: " | ") - let title = detailText.isEmpty ? self.costMenuTitle : "\(self.costMenuTitle) \(detailText)" + let title = detailText.isEmpty ? title : "\(title) \(detailText)" let attributedTitle = NSMutableAttributedString( string: title, attributes: [.font: NSFont.menuFont(ofSize: NSFont.systemFontSize)]) diff --git a/Sources/CodexBar/StatusItemController+CountdownRefresh.swift b/Sources/CodexBar/StatusItemController+CountdownRefresh.swift new file mode 100644 index 0000000000..7fa38c6071 --- /dev/null +++ b/Sources/CodexBar/StatusItemController+CountdownRefresh.swift @@ -0,0 +1,164 @@ +import CodexBarCore +import Foundation + +extension StatusItemController { + private nonisolated static let menuBarCountdownRefreshEpsilon: TimeInterval = 0.05 + + func scheduleMenuBarCountdownRefreshIfNeeded(now: Date = .init()) { + self.menuBarCountdownRefreshTask?.cancel() + self.menuBarCountdownRefreshTask = nil + + var delays: [TimeInterval] = [] + let providers = self.menuBarRefreshProviders() + let displayMode = self.settings.menuBarDisplayMode + let smartExhaustedActive = self.settings.menuBarShowsBrandIconWithPercent + && self.settings.menuBarShowsResetTimeWhenExhausted + && displayMode != .resetTime + + var countdownResetDates: [Date] = [] + var absoluteResetDates: [Date] = [] + for provider in providers { + let resetDates = self.menuBarDisplayedResetDates(for: provider, now: now) + let resolution = self.settings.menuBarLayoutResolution(for: provider) + if !resolution.usesLegacyRendering, + self.settings.menuBarIconStyle == .iconAndPercent + { + let tokens = resolution.layout.lines.joined() + if tokens.contains(.resetCountdown) { + countdownResetDates.append(contentsOf: resetDates) + } + if tokens.contains(.resetAbsolute) { + absoluteResetDates.append(contentsOf: resetDates) + } + continue + } + + guard self.settings.menuBarShowsBrandIconWithPercent, + displayMode == .resetTime || smartExhaustedActive + else { continue } + switch self.settings.resetTimeDisplayStyle { + case .countdown: + countdownResetDates.append(contentsOf: resetDates) + case .absolute: + absoluteResetDates.append(contentsOf: resetDates) + } + } + + if let delay = Self.menuBarCountdownRefreshDelay(resetDates: countdownResetDates, now: now) { + // Countdown text ticks every minute; refresh on each displayed-minute boundary (the last of + // which lands at the reset, flipping a smart-exhausted lane back to the percentage). + delays.append(delay) + } + if let delay = Self.menuBarAbsoluteRefreshDelay(resetDates: absoluteResetDates, now: now) { + // Absolute clocks don't tick each minute, but their human-friendly date label can change at + // local midnight (for example, "tomorrow" becomes a same-day time). Wake at that boundary or + // the reset itself, whichever comes first; the next icon update schedules any later boundary. + delays.append(delay) + } + + if self.menuBarObservesCodexReset(providers: providers) { + let projection = self.store.codexConsumerProjection(surface: .menuBar, now: now) + if let resetAt = projection.nextMenuBarStateChangeAt { + delays.append(max( + Self.menuBarCountdownRefreshEpsilon, + resetAt.timeIntervalSince(now) + Self.menuBarCountdownRefreshEpsilon)) + } + } + guard let delay = delays.min() else { return } + + self.menuBarCountdownRefreshTask = Task { @MainActor [weak self] in + do { + try await Task.sleep(for: .seconds(delay)) + } catch { + return + } + guard let self, !Task.isCancelled else { return } + self.menuBarCountdownRefreshTask = nil + self.updateIcons() + } + } + + nonisolated static func menuBarCountdownRefreshDelay( + resetDates: [Date], + now: Date) + -> TimeInterval? + { + resetDates.compactMap { resetDate -> TimeInterval? in + let remaining = resetDate.timeIntervalSince(now) + guard remaining > 0 else { return nil } + let displayedMinutes = ceil(remaining / 60) + let nextBoundaryRemaining = max(0, displayedMinutes - 1) * 60 + return max( + self.menuBarCountdownRefreshEpsilon, + remaining - nextBoundaryRemaining + self.menuBarCountdownRefreshEpsilon) + }.min() + } + + nonisolated static func menuBarAbsoluteRefreshDelay( + resetDates: [Date], + now: Date, + calendar: Calendar = .current) + -> TimeInterval? + { + guard let nextDayStart = calendar.dateInterval(of: .day, for: now)?.end else { return nil } + + return resetDates.compactMap { resetDate -> TimeInterval? in + guard resetDate > now else { return nil } + let nextTextChange = min(resetDate, nextDayStart) + return max( + self.menuBarCountdownRefreshEpsilon, + nextTextChange.timeIntervalSince(now) + self.menuBarCountdownRefreshEpsilon) + }.min() + } + + private func menuBarRefreshProviders() -> [UsageProvider] { + if self.shouldMergeIcons { + return [self.primaryProviderForUnifiedIcon()] + } + return UsageProvider.allCases.filter(self.isVisible) + } + + private func menuBarObservesCodexReset(providers: [UsageProvider]) -> Bool { + if providers.contains(.codex) { + return true + } + guard self.shouldMergeIcons, self.settings.menuBarShowsHighestUsage else { + return false + } + let activeProviders = self.store.enabledProvidersForDisplay() + return self.settings.resolvedMergedOverviewProviders( + activeProviders: activeProviders, + maxVisibleProviders: SettingsStore.mergedOverviewProviderLimit).contains(.codex) + } + + func observeMenuBarTimeEnvironmentChanges() { + for name in [ + Notification.Name.NSSystemClockDidChange, + .NSSystemTimeZoneDidChange, + .NSCalendarDayChanged, + ] { + NotificationCenter.default.addObserver( + self, + selector: #selector(self.handleMenuBarTimeEnvironmentDidChange), + name: name, + object: nil) + } + } + + @objc nonisolated func handleMenuBarTimeEnvironmentDidChange() { + Task { @MainActor [weak self] in + guard let self, !self.hasPreparedForAppShutdown else { return } + self.handleMenuBarTimeEnvironmentChange() + } + } + + func handleMenuBarTimeEnvironmentChange() { + self.updateIcons() + } + + #if DEBUG + func _test_isMenuBarCountdownRefreshScheduled() -> Bool { + self.menuBarCountdownRefreshTask != nil + } + #endif +} diff --git a/Sources/CodexBar/StatusItemController+HostedSubmenus.swift b/Sources/CodexBar/StatusItemController+HostedSubmenus.swift index 65bad39ddf..9923a05a8b 100644 --- a/Sources/CodexBar/StatusItemController+HostedSubmenus.swift +++ b/Sources/CodexBar/StatusItemController+HostedSubmenus.swift @@ -1,8 +1,56 @@ import AppKit import CodexBarCore +import QuartzCore import SwiftUI +enum HostedSubviewContentFingerprint: Equatable { + case text(String) + case costHistory(CostHistoryChartMenuView.RenderFingerprint) +} + +struct HostedSubviewRenderSignature: Equatable { + let chartID: String + let providerRawValue: String? + let widthBitPattern: UInt64 + let content: HostedSubviewContentFingerprint +} + +final class HostedSubviewRenderSignatureBox: NSObject { + let signature: HostedSubviewRenderSignature + + init(_ signature: HostedSubviewRenderSignature) { + self.signature = signature + } +} + extension StatusItemController { + private struct HostedSubviewIdentity { + let chartID: String + let provider: UsageProvider? + let providerRawValue: String? + } + + func refreshHostedSubviewHeights(in menu: NSMenu) { + let width = self.renderedMenuWidth(for: menu) + + for item in menu.items { + guard let view = item.view else { continue } + let height = self.hostedSubviewFittingHeight(for: view, width: width) + view.frame = NSRect(origin: .zero, size: NSSize(width: width, height: height)) + } + } + + /// Measures the natural height of a hosted submenu view at the given width using the live + /// view that will actually be displayed. Hosted chart items used to spin up a second, + /// throwaway `NSHostingController` purely to size the chart even though every build path + /// immediately re-measures the live view via `fittingSize`; that extra SwiftUI hierarchy was + /// pure overhead on a popup-menu hot path, so callers now size the displayed view directly. + func hostedSubviewFittingHeight(for view: NSView, width: CGFloat) -> CGFloat { + view.frame = NSRect(origin: .zero, size: NSSize(width: width, height: 1)) + view.layoutSubtreeIfNeeded() + return view.fittingSize.height + } + func isHostedSubviewMenu(_ menu: NSMenu) -> Bool { let ids: Set = [ Self.usageBreakdownChartID, @@ -10,6 +58,7 @@ extension StatusItemController { Self.costHistoryChartID, Self.usageHistoryChartID, Self.storageBreakdownID, + Self.statusComponentsID, Self.zaiHourlyUsageChartID, ] return menu.items.contains { item in @@ -37,18 +86,26 @@ extension StatusItemController { return submenu } - func hydrateHostedSubviewMenuIfNeeded(_ menu: NSMenu, width requestedWidth: CGFloat? = nil) { + @discardableResult + func hydrateHostedSubviewMenuIfNeeded(_ menu: NSMenu, width requestedWidth: CGFloat? = nil) -> Bool { guard let placeholder = menu.items.first, menu.items.count == 1, placeholder.view == nil, let chartID = placeholder.representedObject as? String else { - return + return false } let width = requestedWidth ?? self.renderedMenuWidth(for: menu.supermenu ?? menu) + let identity = HostedSubviewIdentity( + chartID: chartID, + provider: placeholder.toolTip.flatMap(UsageProvider.init(rawValue:)), + providerRawValue: placeholder.toolTip) menu.removeAllItems() + let t0 = CACurrentMediaTime() + MainThreadActivityBreadcrumb.push("hydrateChart:\(chartID)") + defer { MainThreadActivityBreadcrumb.pop() } let didHydrate: Bool = switch chartID { case Self.usageBreakdownChartID: self.appendUsageBreakdownChartItem(to: menu, width: width) @@ -78,6 +135,14 @@ extension StatusItemController { } else { false } + case Self.statusComponentsID: + if let providerRawValue = self.hostedSubviewProviderRawValue(for: placeholder), + let provider = UsageProvider(rawValue: providerRawValue) + { + self.appendStatusComponentsItem(to: menu, provider: provider, width: width) + } else { + false + } case Self.zaiHourlyUsageChartID: if let providerRawValue = placeholder.toolTip, let provider = UsageProvider(rawValue: providerRawValue) @@ -89,13 +154,241 @@ extension StatusItemController { default: false } + self.logChartRenderDurationIfSlow("hydrateHostedSubview:\(chartID)", startedAt: t0) + + if !didHydrate { + self.appendHostedSubviewUnavailableItem( + to: menu, + chartID: chartID, + providerRawValue: placeholder.toolTip) + } + self.recordHostedSubviewRenderSignature(for: menu, identity: identity, width: width) + return true + } + + func refreshHostedSubviewMenu(_ menu: NSMenu) { + let width = self.renderedMenuWidth(for: menu) + guard let identity = self.hostedSubviewIdentity(for: menu) else { + self.refreshHostedSubviewHeights(in: menu) + return + } + let signature = self.hostedSubviewRenderSignature(identity: identity, width: width) + if self.hostedSubviewRenderSignatures.object(forKey: menu)?.signature == signature { + if identity.chartID == Self.zaiHourlyUsageChartID { + self.refreshHostedSubviewHeights(in: menu) + } + return + } + + menu.removeAllItems() + let t0 = CACurrentMediaTime() + MainThreadActivityBreadcrumb.push("refreshChart:\(identity.chartID)") + defer { MainThreadActivityBreadcrumb.pop() } + 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.statusComponentsID: + if let provider = identity.provider { + self.appendStatusComponentsItem(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 + } + self.logChartRenderDurationIfSlow("refreshHostedSubview:\(identity.chartID)", startedAt: t0) + + if !didHydrate { + self.appendHostedSubviewUnavailableItem( + to: menu, + chartID: identity.chartID, + providerRawValue: identity.provider?.rawValue ?? identity.providerRawValue) + } + self.hostedSubviewRenderSignatures.setObject( + HostedSubviewRenderSignatureBox(signature), + forKey: menu) + } + + private func hostedSubviewIdentity(for menu: NSMenu) + -> HostedSubviewIdentity? { + for item in menu.items { + guard let chartID = item.representedObject as? String else { continue } + let providerRawValue = self.hostedSubviewProviderRawValue(for: item) + return HostedSubviewIdentity( + chartID: chartID, + provider: providerRawValue.flatMap(UsageProvider.init(rawValue:)), + providerRawValue: providerRawValue) + } + return nil + } + + private func hostedSubviewProviderRawValue(for item: NSMenuItem) -> String? { + if let providerRawValue = item.toolTip { + return providerRawValue + } + guard item.representedObject as? String == Self.statusComponentsID else { return nil } + return item.identifier?.rawValue + } + + private func recordHostedSubviewRenderSignature( + for menu: NSMenu, + identity: HostedSubviewIdentity, + width: CGFloat) + { + let signature = self.hostedSubviewRenderSignature(identity: identity, width: width) + self.hostedSubviewRenderSignatures.setObject( + HostedSubviewRenderSignatureBox(signature), + forKey: menu) + } + + private func hostedSubviewRenderSignature( + identity: HostedSubviewIdentity, + width: CGFloat) -> HostedSubviewRenderSignature + { + let contentSignature: HostedSubviewContentFingerprint = switch identity.chartID { + case Self.usageBreakdownChartID: + .text(Self.dashboardBreakdownReadinessSignature( + OpenAIDashboardDailyBreakdown.removingSkillUsageServices( + from: self.store.openAIDashboard?.usageBreakdown ?? []))) + case Self.creditsHistoryChartID: + .text(Self.dashboardBreakdownReadinessSignature(self.store.openAIDashboard?.dailyBreakdown ?? [])) + case Self.costHistoryChartID: + if let provider = identity.provider { + self.costHistoryRenderFingerprint(for: provider) + } else { + .text("missing-provider") + } + case Self.usageHistoryChartID: + .text(identity.provider.map(self.usageHistoryRenderSignature(for:)) ?? "missing-provider") + case Self.storageBreakdownID: + .text(identity.provider.map(self.storageBreakdownRenderSignature(for:)) ?? "missing-provider") + case Self.statusComponentsID: + .text(identity.provider.map(self.statusComponentsRenderSignature(for:)) ?? "missing-provider") + case Self.zaiHourlyUsageChartID: + .text(identity.provider.map(self.zaiHourlyUsageRenderSignature(for:)) ?? "missing-provider") + default: + .text("unknown") + } + return HostedSubviewRenderSignature( + chartID: identity.chartID, + providerRawValue: identity.providerRawValue, + widthBitPattern: Double(width).bitPattern, + content: contentSignature) + } + + private func costHistoryRenderFingerprint(for provider: UsageProvider) -> HostedSubviewContentFingerprint { + guard let snapshot = self.tokenSnapshotForCostHistorySubmenu(provider: provider) else { + return .text("none") + } + return .costHistory(CostHistoryChartMenuView.renderFingerprint(from: snapshot, provider: provider)) + } + + private func usageHistoryRenderSignature(for provider: UsageProvider) -> String { + let snapshot = self.store.snapshot(for: provider) + let selection = self.store.planUtilizationHistorySelection(for: provider) + return [ + "\(self.store.planUtilizationHistoryRevision)", + "\(Int(Date().timeIntervalSince1970 / 60))", + selection.accountKey ?? "unscoped", + snapshot?.primary == nil ? "0" : "1", + snapshot?.secondary == nil ? "0" : "1", + snapshot?.tertiary == nil ? "0" : "1", + ].joined(separator: "|") + } + + func statusComponentsRenderSignature(for provider: UsageProvider) -> String { + let components = self.store.statusComponents(for: provider) + guard !components.isEmpty else { return "none" } + func signature(_ component: ProviderStatusComponent) -> String { + let childSig = component.children.map(signature).joined(separator: ",") + return "\(component.id)=\(component.indicator.rawValue)[\(childSig)]" + } + return components.map(signature).joined(separator: ";") + } - guard !didHydrate else { return } + private func storageBreakdownRenderSignature(for provider: UsageProvider) -> String { + guard let footprint = self.store.storageFootprint(for: provider) else { return "none" } + let components = footprint.components + .map { "\($0.path)=\($0.totalBytes)" } + .joined(separator: ";") + return [ + "\(footprint.totalBytes)", + footprint.paths.joined(separator: ";"), + footprint.missingPaths.joined(separator: ";"), + footprint.unreadablePaths.joined(separator: ";"), + components, + String(Double(self.storageBreakdownMenuMaxHeight()).bitPattern, radix: 16), + ].joined(separator: "|") + } + + private func zaiHourlyUsageRenderSignature(for provider: UsageProvider) -> String { + guard let modelUsage = self.store.snapshot(for: provider)?.zaiUsage?.modelUsage else { return "none" } + return Self.zaiHourlyUsageRenderSignature(modelUsage: modelUsage, now: Date()) + } + static func zaiHourlyUsageRenderSignature(modelUsage: ZaiModelUsageData, now: Date) -> String { + let models = modelUsage.modelDataList + .map { model in + let usage = model.tokensUsage + .map { $0.map(String.init) ?? "nil" } + .joined(separator: ",") + return "\(model.modelName ?? "")=\(usage)" + } + .joined(separator: ";") + let ranges: [ZaiHourlyRange] = [.today(referenceDate: now), .last24h] + let visibleBars = ranges + .map { range in + ZaiHourlyBars.from(modelData: modelUsage, range: range, now: now) + .map { bar in + let segments = bar.segments + .map { "\($0.model)=\($0.tokens)" } + .joined(separator: ",") + return "\(bar.label):\(segments)" + } + .joined(separator: ";") + } + return [ + modelUsage.xTime.joined(separator: ","), + models, + visibleBars.joined(separator: "|"), + ].joined(separator: "|") + } + + 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) } @@ -105,7 +398,7 @@ extension StatusItemController { from: self.store.openAIDashboard?.usageBreakdown ?? []) guard !breakdown.isEmpty else { return false } - if !Self.menuCardRenderingEnabled { + if !self.menuCardRenderingEnabledForController { let chartItem = NSMenuItem() chartItem.isEnabled = true chartItem.representedObject = Self.usageBreakdownChartID @@ -115,9 +408,9 @@ extension StatusItemController { let chartView = UsageBreakdownChartMenuView(breakdown: breakdown, width: width) let hosting = MenuHostingView(rootView: chartView) - let controller = NSHostingController(rootView: chartView) - let size = controller.sizeThatFits(in: CGSize(width: width, height: .greatestFiniteMagnitude)) - hosting.frame = NSRect(origin: .zero, size: NSSize(width: width, height: size.height)) + hosting.frame = NSRect( + origin: .zero, + size: NSSize(width: width, height: self.hostedSubviewFittingHeight(for: hosting, width: width))) let chartItem = NSMenuItem() chartItem.view = hosting @@ -132,7 +425,7 @@ extension StatusItemController { let breakdown = self.store.openAIDashboard?.dailyBreakdown ?? [] guard !breakdown.isEmpty else { return false } - if !Self.menuCardRenderingEnabled { + if !self.menuCardRenderingEnabledForController { let chartItem = NSMenuItem() chartItem.isEnabled = true chartItem.representedObject = Self.creditsHistoryChartID @@ -142,9 +435,9 @@ extension StatusItemController { let chartView = CreditsHistoryChartMenuView(breakdown: breakdown, width: width) let hosting = MenuHostingView(rootView: chartView) - let controller = NSHostingController(rootView: chartView) - let size = controller.sizeThatFits(in: CGSize(width: width, height: .greatestFiniteMagnitude)) - hosting.frame = NSRect(origin: .zero, size: NSSize(width: width, height: size.height)) + hosting.frame = NSRect( + origin: .zero, + size: NSSize(width: width, height: self.hostedSubviewFittingHeight(for: hosting, width: width))) let chartItem = NSMenuItem() chartItem.view = hosting @@ -163,10 +456,11 @@ extension StatusItemController { guard let tokenSnapshot = self.tokenSnapshotForCostHistorySubmenu(provider: provider) else { return false } guard !tokenSnapshot.daily.isEmpty else { return false } - if !Self.menuCardRenderingEnabled { + if !self.menuCardRenderingEnabledForController { let chartItem = NSMenuItem() chartItem.isEnabled = true chartItem.representedObject = Self.costHistoryChartID + chartItem.toolTip = provider.rawValue submenu.addItem(chartItem) return true } @@ -178,16 +472,19 @@ extension StatusItemController { currencyCode: tokenSnapshot.currencyCode, historyDays: tokenSnapshot.historyDays, windowLabel: tokenSnapshot.historyLabel, + projects: provider == .codex ? tokenSnapshot.projects : [], + sessions: provider == .codex ? tokenSnapshot.sessions : [], width: width) let hosting = MenuHostingView(rootView: chartView) - let controller = NSHostingController(rootView: chartView) - let size = controller.sizeThatFits(in: CGSize(width: width, height: .greatestFiniteMagnitude)) - hosting.frame = NSRect(origin: .zero, size: NSSize(width: width, height: size.height)) + hosting.applyMeasuredHeight( + width: width, + height: self.hostedSubviewFittingHeight(for: hosting, width: width)) let chartItem = NSMenuItem() chartItem.view = hosting chartItem.isEnabled = true chartItem.representedObject = Self.costHistoryChartID + chartItem.toolTip = provider.rawValue submenu.addItem(chartItem) return true } @@ -203,7 +500,7 @@ extension StatusItemController { !footprint.components.isEmpty else { return false } - if !Self.menuCardRenderingEnabled { + if !self.menuCardRenderingEnabledForController { let item = NSMenuItem() item.isEnabled = true item.representedObject = Self.storageBreakdownID @@ -213,11 +510,24 @@ extension StatusItemController { } let maxHeight = self.storageBreakdownMenuMaxHeight() - let view = StorageBreakdownMenuView(footprint: footprint, width: width, maxHeight: maxHeight) + final class HostingRelay { + weak var hosting: MenuHostingView? + var collapsedHeight: CGFloat = 1 + } + let relay = HostingRelay() + let view = StorageBreakdownMenuView( + footprint: footprint, + width: width, + maxHeight: maxHeight, + onExpansionHeightChange: { additionalHeight in + relay.hosting?.applyMeasuredHeight( + width: width, + height: min(maxHeight, relay.collapsedHeight + additionalHeight)) + }) let hosting = MenuHostingView(rootView: view) - let controller = NSHostingController(rootView: view) - let size = controller.sizeThatFits(in: CGSize(width: width, height: maxHeight)) - hosting.frame = NSRect(origin: .zero, size: NSSize(width: width, height: size.height)) + relay.hosting = hosting + relay.collapsedHeight = self.hostedSubviewFittingHeight(for: hosting, width: width) + hosting.applyMeasuredHeight(width: width, height: relay.collapsedHeight) let item = NSMenuItem() item.view = hosting @@ -228,6 +538,77 @@ extension StatusItemController { return true } + @discardableResult + func appendStatusComponentsItem( + to submenu: NSMenu, + provider: UsageProvider, + width: CGFloat) -> Bool + { + // The list of component rows is shown only once the provider's status has been fetched. + // Before the first fetch lands the submenu still renders (just the website link below), so + // every provider with a status feed gets the native submenu rather than a bare link; it + // re-hydrates with the live component list once data arrives (see makeStatusComponentsSubmenu). + let components = Self.filterStatusComponents(self.store.statusComponents(for: provider), for: provider) + if !components.isEmpty { + if self.menuCardRenderingEnabledForController { + final class HostingRelay { + weak var hosting: MenuHostingView? + } + let relay = HostingRelay() + let listView = StatusComponentsMenuView( + components: components, + width: width, + onToggle: { + // Re-measure the live content after SwiftUI applies the expand/collapse so the + // row grows/shrinks to fit exactly (no leftover blank space). + DispatchQueue.main.async { + guard let hosting = relay.hosting else { return } + hosting.applyMeasuredHeight( + width: width, + height: hosting.measuredFittingHeight(width: width)) + } + }) + let hosting = MenuHostingView(rootView: listView) + relay.hosting = hosting + hosting.applyMeasuredHeight(width: width, height: hosting.measuredFittingHeight(width: width)) + + let listItem = NSMenuItem() + listItem.view = hosting + listItem.isEnabled = false + listItem.representedObject = Self.statusComponentsID + listItem.toolTip = provider.rawValue + submenu.addItem(listItem) + } else { + let placeholder = NSMenuItem() + placeholder.isEnabled = false + placeholder.representedObject = Self.statusComponentsID + placeholder.toolTip = provider.rawValue + submenu.addItem(placeholder) + } + + submenu.addItem(.separator()) + } + + let linkItem = NSMenuItem( + title: L("Open Status Page"), + action: #selector(self.openStatusPageFromMenuItem(_:)), + keyEquivalent: "") + linkItem.target = self + // Tag the link with the chart identity so the menu is still recognized as a status + // submenu (and re-hydrates) when the component list hasn't loaded yet and the link is the + // only row. The identifier also scopes the action to this submenu's provider so a later + // menu selection change cannot open another provider's status page. + linkItem.representedObject = Self.statusComponentsID + linkItem.identifier = NSUserInterfaceItemIdentifier(provider.rawValue) + if let image = NSImage(systemSymbolName: "arrow.up.right.square", accessibilityDescription: nil) { + image.isTemplate = true + image.size = NSSize(width: 16, height: 16) + linkItem.image = image + } + submenu.addItem(linkItem) + return true + } + private func storageBreakdownMenuMaxHeight() -> CGFloat { let visibleHeight = NSScreen.main?.visibleFrame.height ?? 900 return min(620, max(360, floor(visibleHeight * 0.72))) @@ -244,7 +625,7 @@ extension StatusItemController { let modelUsage = snapshot.zaiUsage?.modelUsage else { return false } - if !Self.menuCardRenderingEnabled { + if !self.menuCardRenderingEnabledForController { let chartItem = NSMenuItem() chartItem.isEnabled = false chartItem.representedObject = Self.zaiHourlyUsageChartID @@ -255,9 +636,9 @@ extension StatusItemController { let chartView = ZaiHourlyUsageChartMenuView(modelUsage: modelUsage, width: width) let hosting = MenuHostingView(rootView: chartView) - let controller = NSHostingController(rootView: chartView) - let size = controller.sizeThatFits(in: CGSize(width: width, height: .greatestFiniteMagnitude)) - hosting.frame = NSRect(origin: .zero, size: NSSize(width: width, height: size.height)) + hosting.frame = NSRect( + origin: .zero, + size: NSSize(width: width, height: self.hostedSubviewFittingHeight(for: hosting, width: width))) let chartItem = NSMenuItem() chartItem.view = hosting @@ -268,3 +649,16 @@ extension StatusItemController { return true } } + +#if DEBUG +extension StatusItemController { + func _hostedSubviewRenderSignatureForTesting(menu: NSMenu, width: CGFloat) -> HostedSubviewRenderSignature? { + guard let identity = self.hostedSubviewIdentity(for: menu) else { return nil } + return self.hostedSubviewRenderSignature(identity: identity, width: width) + } + + func _storedHostedSubviewRenderSignatureForTesting(menu: NSMenu) -> HostedSubviewRenderSignature? { + self.hostedSubviewRenderSignatures.object(forKey: menu)?.signature + } +} +#endif diff --git a/Sources/CodexBar/StatusItemController+IconObservation.swift b/Sources/CodexBar/StatusItemController+IconObservation.swift new file mode 100644 index 0000000000..8aac9d0f22 --- /dev/null +++ b/Sources/CodexBar/StatusItemController+IconObservation.swift @@ -0,0 +1,102 @@ +import CodexBarCore +import Foundation + +extension StatusItemController { + func storeIconObservationSignature() -> String { + let showBrandPercent = self.settings.menuBarShowsBrandIconWithPercent + let mergeIcons = self.shouldMergeIcons + let visibleProviders = self.store.enabledProvidersForDisplay().map(\.rawValue).sorted().joined(separator: ",") + let providerSignatures: String + let primaryProvider: UsageProvider? + if mergeIcons { + let primary = self.primaryProviderForUnifiedIcon() + primaryProvider = primary + providerSignatures = self.providerStoreIconObservationSignature( + for: primary, + showBrandPercent: showBrandPercent) + } else { + primaryProvider = nil + providerSignatures = UsageProvider.allCases + .filter { self.isVisible($0) } + .map { self.providerStoreIconObservationSignature(for: $0, showBrandPercent: showBrandPercent) } + .joined(separator: "||") + } + return [ + "merge=\(mergeIcons ? "1" : "0")", + "visible=\(visibleProviders)", + "primary=\(primaryProvider?.rawValue ?? "nil")", + "iconStyle=\(self.store.iconStyle.rawValue)", + "showUsed=\(self.settings.usageBarsShowUsed ? "1" : "0")", + "brandPercent=\(showBrandPercent ? "1" : "0")", + "hideCritters=\(self.settings.menuBarHidesCritters ? "1" : "0")", + "usageColors=\(self.settings.menuBarUsageColorsEnabled ? "1" : "0")", + "needsAnimation=\(self.needsMenuBarIconAnimation() ? "1" : "0")", + providerSignatures, + ].joined(separator: "|") + } + + private func providerStoreIconObservationSignature(for provider: UsageProvider, showBrandPercent: Bool) -> String { + let snapshot = self.store.snapshot(for: provider) + let style = self.store.style(for: provider) + let resolved = self.resolvedMenuBarIconPercents( + provider: provider, + snapshot: snapshot, + style: style, + showUsed: self.settings.usageBarsShowUsed) + let creditsRemaining = self.menuBarCreditsRemainingForIcon(provider: provider, snapshot: snapshot) + let displayText = showBrandPercent ? self.menuBarDisplayText(for: provider, snapshot: snapshot) : nil + let layoutCostSignature = showBrandPercent + ? self.storedMenuBarLayoutCostSignature(for: provider) + : nil + let layoutAccountSignature = showBrandPercent + ? self.storedMenuBarLayoutAccountSignature(for: provider, snapshot: snapshot) + : nil + + return [ + provider.rawValue, + "style=\(style.rawValue)", + "primary=\(Self.iconSignatureValue(resolved?.primary))", + "weekly=\(Self.iconSignatureValue(resolved?.secondary))", + "credits=\(Self.iconSignatureValue(creditsRemaining))", + "stale=\(self.store.isStale(provider: provider) ? "1" : "0")", + "status=\(self.store.statusIndicator(for: provider).rawValue)", + "anim=\(self.shouldAnimate(provider: provider) ? "1" : "0")", + "refreshing=\(self.store.refreshingProviders.contains(provider) ? "1" : "0")", + "text=\(displayText ?? "nil")", + "layoutCost=\(layoutCostSignature ?? "nil")", + "layoutAccount=\(layoutAccountSignature ?? "nil")", + ].joined(separator: "|") + } + + private func storedMenuBarLayoutAccountSignature( + for provider: UsageProvider, + snapshot: UsageSnapshot?) + -> String? + { + let resolution = self.settings.menuBarLayoutResolution(for: provider) + guard !resolution.usesLegacyRendering, + resolution.layout.lines.joined().contains(.accountLabel), + let accountLabel = self.menuBarLayoutAccountLabel(provider: provider, snapshot: snapshot) + else { return nil } + + var hasher = Hasher() + hasher.combine(accountLabel) + return String(hasher.finalize()) + } + + private func storedMenuBarLayoutCostSignature(for provider: UsageProvider) -> String? { + let resolution = self.settings.menuBarLayoutResolution(for: provider) + guard !resolution.usesLegacyRendering else { return nil } + + let tokens = resolution.layout.lines.joined() + let showsToday = tokens.contains(.costToday) + let showsLast30Days = tokens.contains(.cost30d) + guard showsToday || showsLast30Days else { return nil } + + let costs = self.menuBarLayoutCostStrings(provider: provider) + return [ + "today=\(showsToday ? costs.today ?? "nil" : "unused")", + "last30Days=\(showsLast30Days ? costs.last30Days ?? "nil" : "unused")", + ].joined(separator: ",") + } +} diff --git a/Sources/CodexBar/StatusItemController+MemoryPressure.swift b/Sources/CodexBar/StatusItemController+MemoryPressure.swift new file mode 100644 index 0000000000..b7658a5594 --- /dev/null +++ b/Sources/CodexBar/StatusItemController+MemoryPressure.swift @@ -0,0 +1,50 @@ +import AppKit +import CodexBarCore + +extension StatusItemController { + func trimRebuildableCachesForMemoryPressure() -> MemoryPressureCacheTrimSummary { + let mergedSwitcherSelectionCount = self.mergedSwitcherContentCaches.values.reduce(0) { total, entries in + total + entries.count + } + let summary = MemoryPressureCacheTrimSummary( + menuCardHeights: self.menuCardHeightCache.count, + menuWidths: self.measuredStandardMenuWidthCache.count, + mergedSwitcherSelections: mergedSwitcherSelectionCount, + recycledMenuCardViews: self.menuCardViewRecyclePool.count) + + self.menuCardHeightCache.removeAll(keepingCapacity: false) + self.measuredStandardMenuWidthCache.removeAll(keepingCapacity: false) + self.mergedSwitcherContentCaches.removeAll(keepingCapacity: false) + self.menuCardViewRecyclePool.removeAll(keepingCapacity: false) + self.menuBarLayoutRenderer.removeAll() + + return summary + } + + #if DEBUG + func seedRebuildableCachesForMemoryPressureProof() { + let menu = NSMenu() + let cacheEntry = CachedMergedSwitcherMenuContent( + requiredMenuContentVersion: self.menuSession.contentVersion, + menuWidth: 300, + codexAccountDisplay: nil, + tokenAccountDisplay: nil, + localizationSignature: self.menuLocalizationSignature(), + items: []) + self.menuCardHeightCache[ + MenuCardHeightCacheKey( + id: "debug-memory-pressure-card", + scope: UsageProvider.codex.rawValue, + width: 30000, + textScale: Self.menuCardHeightTextScaleToken(), + fingerprint: "debug-memory-pressure"), + ] = 44 + self.measuredStandardMenuWidthCache["debug-memory-pressure-width"] = 300 + self.mergedSwitcherContentCaches[ObjectIdentifier(menu)] = [ + .overview: cacheEntry, + .provider(.codex): cacheEntry, + ] + self.menuCardViewRecyclePool["debug-memory-pressure-card"] = NSView() + } + #endif +} diff --git a/Sources/CodexBar/StatusItemController+Menu.swift b/Sources/CodexBar/StatusItemController+Menu.swift index f3926a932d..ec361edf8b 100644 --- a/Sources/CodexBar/StatusItemController+Menu.swift +++ b/Sources/CodexBar/StatusItemController+Menu.swift @@ -9,7 +9,8 @@ import SwiftUI extension StatusItemController { static let menuCardBaseWidth: CGFloat = 310 private static let maxOverviewProviders = SettingsStore.mergedOverviewProviderLimit - private static let overviewRowIdentifierPrefix = "overviewRow-" + static let overviewRowIdentifierPrefix = "overviewRow-" + static let persistentRefreshMenuItemID = "persistentRefreshAction" private static let defaultMenuOpenRefreshDelay: Duration = .seconds(1.2) #if DEBUG private static var menuOpenRefreshDelayForTesting: Duration = .seconds(1.2) @@ -35,8 +36,9 @@ extension StatusItemController { static let costHistoryChartID = "costHistoryChart" static let usageHistoryChartID = "usageHistoryChart" static let storageBreakdownID = "storageBreakdown" + static let statusComponentsID = "statusComponents" - private func shortcut(for action: MenuDescriptor.MenuAction) -> (key: String, modifiers: NSEvent.ModifierFlags)? { + func shortcut(for action: MenuDescriptor.MenuAction) -> (key: String, modifiers: NSEvent.ModifierFlags)? { switch action { case .refresh: ("r", [.command]) @@ -49,22 +51,6 @@ extension StatusItemController { } } - private func menuCardWidth( - for providers: [UsageProvider], - sections: [MenuDescriptor.Section]) -> CGFloat - { - _ = providers - let baselineWidth = Self.menuCardBaseWidth - return max(baselineWidth, self.measuredStandardMenuWidth(for: sections, baseWidth: baselineWidth)) - } - - private func measuredStandardMenuWidth(for sections: [MenuDescriptor.Section], baseWidth: CGFloat) -> CGFloat { - let measuringMenu = NSMenu() - measuringMenu.autoenablesItems = false - self.addActionableSections(sections, to: measuringMenu, width: baseWidth) - return ceil(measuringMenu.size.width) - } - func makeMenu() -> NSMenu { guard self.shouldMergeIcons else { return self.makeMenu(for: nil) @@ -72,17 +58,47 @@ extension StatusItemController { return self.makeBaseMenu() } + func menuNeedsUpdate(_ menu: NSMenu) { + guard self.shouldMergeIcons, menu === self.mergedMenu else { return } + self.refreshMenuForOpenIfNeeded(menu, provider: self.resolvedMenuProvider()) + } + func menuWillOpen(_ menu: NSMenu) { + // Records interaction and may bring an adaptive timer forward; never refreshes synchronously. + self.store.noteMenuOpened() + self.agentSessions.refreshOnMenuOpen() + + let trace = self.beginMenuOperationTrace("menuWillOpen", breadcrumb: "menuWillOpen") + defer { self.endMenuOperationTrace(trace, menu: menu, provider: self.menuProvider(for: menu)) } + + // Keep the menu drawing in the current system appearance rather than the menu bar's + // (possibly dark) vibrant appearance. Done before any early return so submenus match too. + StatusMenuAppearance.pin(menu) + + self.cancelDeferredMenuInteractionRefreshTask() + self.cancelClosedMenuRebuild(menu) + + self.beginMenuTrackingSession(for: menu) + + // Track whether this is the root menu opening (no menus were open). Only the root open rebuilds + // all content from current data, so the readiness baseline is re-anchored only here — re-anchoring + // on a nested submenu open could mask a pending refresh for the already-open parent menu. + let menuTrackingWasIdle = self.openMenus.isEmpty + 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.hydrateHostedSubviewMenuIfNeeded(menu) { + self.refreshHostedSubviewMenu(menu) + } + 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 + if menuTrackingWasIdle { + self.resyncMenuAdjunctReadinessBaseline() + } } // Removed redundant async refresh - single pass is sufficient after initial layout return @@ -107,16 +123,30 @@ 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 { + if self.settings.providerStorageFootprintsEnabled { + self.store.refreshStorageFootprintsForOverview() + } + + let menuWasFreshBeforeOpen = !self.menuNeedsRefresh(menu) + self.refreshMenuForOpenIfNeeded(menu, provider: provider) + self.scheduleCodexAccountMenuProjectionRevalidationIfNeeded( + for: self.renderedProviders(for: menu)) + 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 + // Only re-anchor when the opened menu actually shows current data. During an in-flight provider + // refresh `refreshMenuForOpenIfNeeded` can preserve stale content; resyncing the baseline to + // live store data in that case would mask the refresh-completion update (#1351). + if menuTrackingWasIdle, !self.menuNeedsRefresh(menu) { + self.resyncMenuAdjunctReadinessBaselineForRootOpen( + menu, + provider: provider, + menuWasFreshBeforeOpen: menuWasFreshBeforeOpen) + } self.installProviderSwitcherShortcutMonitorIfNeeded(for: menu) // Only schedule refresh after menu is registered as open - refreshNow is called async self.scheduleOpenMenuRefresh(for: menu) @@ -127,32 +157,44 @@ extension StatusItemController { let wasHostedSubviewMenu = self.isHostedSubviewMenu(menu) self.forgetClosedMenu(menu) if wasHostedSubviewMenu { - self.refreshOpenMenusIfNeeded() + self.refreshOpenMenusAfterHostedSubviewClose() } } func forgetClosedMenu(_ menu: NSMenu) { let key = ObjectIdentifier(menu) + let wasMergedMenu = menu === self.mergedMenu + + self.endMenuTrackingSession(for: menu) if key == self.providerSwitcherShortcutMenuID { self.removeProviderSwitcherShortcutMonitor() } - self.openMenus.removeValue(forKey: key) - self.menuRefreshTasks.removeValue(forKey: key)?.cancel() - self.openMenuRebuildTasks.removeValue(forKey: key)?.cancel() - self.openMenuRebuildTokens.removeValue(forKey: key) - self.openMenuRebuildsClosingHostedSubviewMenus.remove(key) - if let highlightedView = self.highlightedMenuItems.removeValue(forKey: key)?.view { - (highlightedView as? MenuCardHighlighting)?.setHighlighted(false) + self.clearMergedSwitcherContentCache(for: menu) + let wasTracked = self.openMenus.removeValue(forKey: key) != nil + let menuTrackingEnded = wasTracked && self.openMenus.isEmpty + if self.openMenus.isEmpty { + self.parentMenuRebuildPendingAfterHostedSubviewClose = false } + self.cancelMenuWork(key) + self.clearMenuHighlight(key) let isPersistentMenu = menu === self.mergedMenu || menu === self.fallbackMenu || self.providerMenus.values.contains { $0 === menu } if !isPersistentMenu { - self.menuProviders.removeValue(forKey: key) - self.menuVersions.removeValue(forKey: key) + self.removeMenuTrackingState(key) + } else if self.menuNeedsRefresh(menu) { + self.handleClosedPersistentMenuNeedingRefresh(menu) + } + self.menuSession.clearParentRebuildDeferral(key) + self.scheduleDeferredMenuInteractionRefreshIfNeeded() + if wasMergedMenu { + self.applyDeferredMergedIconRenderAfterTrackingIfNeeded() + } + if menuTrackingEnded { + self.prepareAttachedClosedMenusIfNeeded() } } @@ -160,20 +202,34 @@ extension StatusItemController { let key = ObjectIdentifier(menu) let previous = self.highlightedMenuItems[key] guard previous !== item else { return } + let previousWasNative = self.isNativeMenuItemHighlighted(in: menu) if let previous { (previous.view as? MenuCardHighlighting)?.setHighlighted(false) } - if let item, item.isEnabled { + if let item, + item.isEnabled, + (item.view as? MenuCardHighlighting)?.allowsMenuHighlight != false + { self.highlightedMenuItems[key] = item (item.view as? MenuCardHighlighting)?.setHighlighted(true) } else { self.highlightedMenuItems.removeValue(forKey: key) } + + if previousWasNative, !self.isNativeMenuItemHighlighted(in: menu) { + self.resumeMenuRebuildDeferredForNativeHighlightIfNeeded(menu) + } } func populateMenu(_ menu: NSMenu, provider: UsageProvider?) { + let trace = self.beginMenuOperationTrace( + "populateMenu", + breadcrumb: "populateMenu:\(provider?.rawValue ?? "merged")") + defer { self.endMenuOperationTrace(trace, menu: menu, provider: provider) } + defer { self.refreshMenuCardHeights(in: menu) } + let enabledProviders = self.store.enabledProvidersForDisplay() let includesOverview = self.includesOverviewTab(enabledProviders: enabledProviders) let switcherSelection = self.shouldMergeIcons && enabledProviders.count > 1 @@ -200,16 +256,13 @@ extension StatusItemController { let openAIContext = self.openAIWebContext( currentProvider: currentProvider, showAllAccounts: showAllAccounts) - let descriptor = MenuDescriptor.build( + let descriptor = self.makeMenuDescriptor( provider: selectedProvider, - store: self.store, - settings: self.settings, - account: self.account, - managedCodexAccountCoordinator: self.managedCodexAccountCoordinator, - codexAccountPromotionCoordinator: self.codexAccountPromotionCoordinator, - updateReady: self.updater.updateStatus.isUpdateReady, includeContextualActions: !isOverviewSelected) - let menuWidth = self.menuCardWidth(for: enabledProviders, sections: descriptor.sections) + let menuWidth = self.menuCardWidth( + for: enabledProviders, + selectedProvider: selectedProvider, + descriptor: descriptor) let hasTokenSwitcher = menu.items.contains { $0.view is TokenAccountSwitcherView } let hasCodexSwitcher = menu.items.contains { $0.view is CodexAccountSwitcherView } @@ -267,7 +320,8 @@ extension StatusItemController { menuWidth: menuWidth, codexAccountDisplay: codexAccountDisplay, tokenAccountDisplay: tokenAccountDisplay, - openAIContext: openAIContext)) + openAIContext: openAIContext, + descriptor: descriptor)) return } @@ -299,7 +353,8 @@ extension StatusItemController { menuWidth: menuWidth, codexAccountDisplay: codexAccountDisplay, tokenAccountDisplay: tokenAccountDisplay, - openAIContext: openAIContext)) + openAIContext: openAIContext, + descriptor: descriptor)) return } @@ -345,73 +400,17 @@ extension StatusItemController { return reusableRows } - /// Smart update: rebuild everything below the provider switcher while keeping the switcher view intact. - private struct MenuUpdateContext { - let provider: UsageProvider? - let currentProvider: UsageProvider - let switcherSelection: ProviderSwitcherSelection - let menuWidth: CGFloat - let codexAccountDisplay: CodexAccountMenuDisplay? - let tokenAccountDisplay: TokenAccountMenuDisplay? - let openAIContext: OpenAIWebContext - } - - /// Smart update: rebuild everything below the provider switcher while keeping the switcher view intact. - private func updateMenuContentPreservingSwitcher( - _ menu: NSMenu, - context: MenuUpdateContext) - { - self.performMenuMutationWithoutAnimation { - let contentStartIndex = self.providerSwitcherContentStartIndex(in: menu) - if let switcherView = menu.items.first?.view as? ProviderSwitcherView { - switcherView.updateSelection(context.switcherSelection) - switcherView.updateQuotaIndicators() - } - while menu.items.count > contentStartIndex { - menu.removeItem(at: contentStartIndex) - } - - let enabledProviders = self.store.enabledProvidersForDisplay() - self.rememberMergedSwitcherState(enabledProviders, context.switcherSelection) - self.addCodexAccountSwitcherIfNeeded( - to: menu, - display: context.codexAccountDisplay, - width: context.menuWidth) - self.lastCodexAccountMenuDisplay = context.codexAccountDisplay - self.addTokenAccountSwitcherIfNeeded( - to: menu, - display: context.tokenAccountDisplay, - width: context.menuWidth) - self.lastTokenAccountMenuDisplay = context.tokenAccountDisplay - - let descriptor = MenuDescriptor.build( - provider: context.provider, - store: self.store, - settings: self.settings, - account: self.account, - managedCodexAccountCoordinator: self.managedCodexAccountCoordinator, - codexAccountPromotionCoordinator: self.codexAccountPromotionCoordinator, - updateReady: self.updater.updateStatus.isUpdateReady, - includeContextualActions: context.switcherSelection != .overview) - - let menuContext = MenuCardContext( - currentProvider: context.currentProvider, - selectedProvider: context.provider, - menuWidth: context.menuWidth, - codexAccountDisplay: context.codexAccountDisplay, - tokenAccountDisplay: context.tokenAccountDisplay, - openAIContext: context.openAIContext) - self.addPrimaryMenuContent(to: menu, context: menuContext, switcherSelection: context.switcherSelection) - self.addActionableSections(descriptor.sections, to: menu, width: context.menuWidth) - } - } - private func rebuildMenuContent( _ menu: NSMenu, context: MenuRebuildContext) { self.performMenuMutationWithoutAnimation { + let displacedSelection = self.lastMergedMenuContentSelection + self.lastMergedMenuContentSelection = nil + self.harvestRecyclableMenuCardViews(in: menu, fromIndex: 0, displacedSelection: displacedSelection) + defer { self.clearMenuCardViewRecyclePool() } menu.removeAllItems() + let contentSelection = context.switcherSelection ?? .provider(context.currentProvider) self.addProviderSwitcherIfNeeded( to: menu, enabledProviders: context.enabledProviders, @@ -425,6 +424,17 @@ extension StatusItemController { context.switcherSelection, context.includesOverview) } + if self.shouldMergeIcons, + context.enabledProviders.count > 1, + self.addCachedMergedSwitcherContent( + for: contentSelection, + to: menu, + menuWidth: context.menuWidth, + codexAccountDisplay: context.codexAccountDisplay, + tokenAccountDisplay: context.tokenAccountDisplay) + { + return + } self.addCodexAccountSwitcherIfNeeded( to: menu, display: context.codexAccountDisplay, @@ -445,8 +455,14 @@ extension StatusItemController { self.addPrimaryMenuContent( to: menu, context: menuContext, - switcherSelection: context.switcherSelection ?? .provider(context.currentProvider)) + switcherSelection: contentSelection) self.addActionableSections(context.descriptor.sections, to: menu, width: context.menuWidth) + self.cacheVisibleMergedSwitcherContent( + in: menu, + selection: contentSelection, + contentStartIndex: self.providerSwitcherContentStartIndex(in: menu), + menuWidth: context.menuWidth, + contentVersion: self.menuSession.contentVersion) } } @@ -459,7 +475,7 @@ extension StatusItemController { surface: .liveCard) let hasCreditsHistory = codexProjection?.hasCreditsHistory == true let hasUsageBreakdown = codexProjection?.hasUsageBreakdown == true - let hasCostHistory = self.settings.isCostUsageEffectivelyEnabled(for: currentProvider) && + let hasCostHistory = self.settings.costSummaryShowsSubmenu(for: currentProvider) && (self.store.tokenSnapshot(for: currentProvider)?.daily.isEmpty == false) let canShowBuyCredits = self.settings.showOptionalCreditsAndExtraUsage && codexProjection?.canShowBuyCredits == true @@ -491,16 +507,32 @@ extension StatusItemController { menu.addItem(.separator()) } - private func addTokenAccountSwitcherIfNeeded(to menu: NSMenu, display: TokenAccountMenuDisplay?, width: CGFloat) { + func addTokenAccountSwitcherIfNeeded( + to menu: NSMenu, + display: TokenAccountMenuDisplay?, + width: CGFloat, + captureMenu: NSMenu? = nil) + { guard let display, display.showSwitcher else { return } - let switcherItem = self.makeTokenAccountSwitcherItem(display: display, menu: menu, width: width) + let switcherItem = self.makeTokenAccountSwitcherItem( + display: display, + menu: captureMenu ?? menu, + width: width) menu.addItem(switcherItem) menu.addItem(.separator()) } - private func addCodexAccountSwitcherIfNeeded(to menu: NSMenu, display: CodexAccountMenuDisplay?, width: CGFloat) { + func addCodexAccountSwitcherIfNeeded( + to menu: NSMenu, + display: CodexAccountMenuDisplay?, + width: CGFloat, + captureMenu: NSMenu? = nil) + { guard let display, display.showSwitcher else { return } - let switcherItem = self.makeCodexAccountSwitcherItem(display: display, menu: menu, width: width) + let switcherItem = self.makeCodexAccountSwitcherItem( + display: display, + menu: captureMenu ?? menu, + width: width) menu.addItem(switcherItem) menu.addItem(.separator()) } @@ -509,8 +541,12 @@ extension StatusItemController { private func addOverviewRows( to menu: NSMenu, enabledProviders: [UsageProvider], - menuWidth: CGFloat) -> Bool + menuWidth: CGFloat, + captureMenu: NSMenu? = nil) -> Bool { + // Rows may be built into a detached scratch menu for in-place reconciliation; + // interaction closures must always reference the live menu they end up serving. + let interactionMenu = captureMenu ?? menu let overviewProviders = self.settings.reconcileMergedOverviewSelectedProviders( activeProviders: enabledProviders) let rows: [(provider: UsageProvider, model: UsageMenuCardView.Model)] = overviewProviders @@ -521,6 +557,9 @@ extension StatusItemController { } guard !rows.isEmpty else { return false } + let t0 = CACurrentMediaTime() + defer { self.logChartRenderDurationIfSlow("addOverviewRows(\(rows.count))", startedAt: t0) } + for (index, row) in rows.enumerated() { let identifier = "\(Self.overviewRowIdentifierPrefix)\(row.provider.rawValue)" let storageText = self.store.storageFootprintText(for: row.provider) @@ -532,14 +571,22 @@ extension StatusItemController { OverviewMenuCardRowView(model: row.model, storageText: storageText, width: menuWidth), id: identifier, width: menuWidth, + heightCacheScope: row.provider.rawValue, + heightCacheFingerprint: row.model.heightFingerprint( + section: "overview", + additional: [UsageMenuCardView.Model.heightFingerprintField("storage", storageText)]), submenu: submenu, - onClick: { [weak self, weak menu] in - guard let self, let menu else { return } - self.selectOverviewProvider(row.provider, menu: menu) + containsInteractiveControls: row.model.subtitleStyle == .error || row.model.usesLiveSubtitle, + usesGPUSelection: true, + onClick: { [weak self, weak interactionMenu] in + guard let self, let interactionMenu else { return } + self.selectOverviewProvider(row.provider, menu: interactionMenu) }) - // Keep menu item action wired for keyboard activation and accessibility action paths. - item.target = self - item.action = #selector(self.selectOverviewProvider(_:)) + if submenu == nil { + // Keep plain rows wired for keyboard activation and accessibility action paths. + item.target = self + item.action = #selector(self.selectOverviewProvider(_:)) + } menu.addItem(item) if index < rows.count - 1 { menu.addItem(.separator()) @@ -561,21 +608,31 @@ extension StatusItemController { menu.addItem(item) } - private func addMenuCards(to menu: NSMenu, context: MenuCardContext) -> Bool { + private func addMenuCards(to menu: NSMenu, context: MenuCardContext, captureMenu: NSMenu? = nil) -> Bool { if let codexAccountDisplay = context.codexAccountDisplay, codexAccountDisplay.showAll { self.addStackedCodexMenuCards(codexAccountDisplay, to: menu, context: context) return false } + // Eligible claude-swap rows take precedence over Claude token-account cards; otherwise + // the stacked token-account branch below would return before rendering the adapter rows. + if ClaudeSwapMenuPrecedence.prefersClaudeSwap( + provider: context.currentProvider, + accountCount: self.store.claudeSwapAccountSnapshots.count, + showSingleAccount: self.settings.claudeSwapShowSingleAccount) + { + self.addClaudeSwapMenuCards(to: menu, captureMenu: captureMenu ?? menu, context: context) + return false + } + if let tokenAccountDisplay = context.tokenAccountDisplay, tokenAccountDisplay.showAll { let accountSnapshots = tokenAccountDisplay.snapshots let cards = accountSnapshots.isEmpty ? [] : accountSnapshots.compactMap { accountSnapshot in - self.menuCardModel( + self.tokenAccountMenuCardModel( for: context.currentProvider, - snapshotOverride: accountSnapshot.snapshot, - errorOverride: accountSnapshot.error) + accountSnapshot: accountSnapshot) } self.addStackedMenuCards(cards, to: menu, context: context) return false @@ -594,8 +651,9 @@ extension StatusItemController { } guard let model = self.menuCardModel(for: context.selectedProvider) else { return false } - if context.openAIContext.hasOpenAIWebMenuItems || self - .hasOpenAIAPIUsageSubmenu(provider: context.currentProvider) + let renderedModel = self.menuCardRefreshMonitor.model(for: model.provider, fallback: model) + if context.openAIContext.hasOpenAIWebMenuItems || + self.requiresSectionedMenuForProviderDerivedCost(provider: context.currentProvider) { let webItems = OpenAIWebMenuItems( hasUsageBreakdown: context.openAIContext.hasUsageBreakdown, @@ -605,16 +663,19 @@ extension StatusItemController { self.addMenuCardSections( to: menu, model: model, - provider: context.currentProvider, + layoutModel: renderedModel, width: context.menuWidth, webItems: webItems) return true } menu.addItem(self.makeMenuCardItem( - UsageMenuCardView(model: model, width: context.menuWidth), + UsageMenuCardView(model: model, layoutModel: renderedModel, width: context.menuWidth), id: "menuCard", - width: context.menuWidth)) + width: context.menuWidth, + heightCacheScope: context.currentProvider.rawValue, + heightCacheFingerprint: renderedModel.heightFingerprint(section: "card"), + containsInteractiveControls: true)) if self.addStorageMenuCardSection(to: menu, provider: context.currentProvider, width: context.menuWidth) { menu.addItem(.separator()) } @@ -625,23 +686,34 @@ extension StatusItemController { return false } - private func addStackedMenuCards( + func addStackedMenuCards( _ cards: [UsageMenuCardView.Model], to menu: NSMenu, - context: MenuCardContext) + context: MenuCardContext, + planAction: ((Int) -> (() -> Void)?)? = nil) { if cards.isEmpty, let model = self.menuCardModel(for: context.selectedProvider) { + let renderedModel = self.menuCardRefreshMonitor.model(for: model.provider, fallback: model) menu.addItem(self.makeMenuCardItem( - UsageMenuCardView(model: model, width: context.menuWidth), + UsageMenuCardView(model: model, layoutModel: renderedModel, width: context.menuWidth), id: "menuCard", - width: context.menuWidth)) + width: context.menuWidth, + heightCacheScope: context.currentProvider.rawValue, + heightCacheFingerprint: renderedModel.heightFingerprint(section: "card"), + containsInteractiveControls: true)) menu.addItem(.separator()) } else { for (index, model) in cards.enumerated() { menu.addItem(self.makeMenuCardItem( - UsageMenuCardView(model: model, width: context.menuWidth), + UsageMenuCardView( + model: model, + width: context.menuWidth, + planAction: planAction?(index)), id: "menuCard-\(index)", - width: context.menuWidth)) + width: context.menuWidth, + heightCacheScope: "\(context.currentProvider.rawValue)-\(index)", + heightCacheFingerprint: model.heightFingerprint(section: "card"), + containsInteractiveControls: true)) if index < cards.count - 1 { menu.addItem(.separator()) } @@ -674,21 +746,24 @@ extension StatusItemController { _ = self.addCostHistorySubmenu(to: menu, provider: currentProvider) } } - menu.addItem(.separator()) + if menu.items.last?.isSeparatorItem != true { + menu.addItem(.separator()) + } } - private func addPrimaryMenuContent( + func addPrimaryMenuContent( to menu: NSMenu, context: MenuCardContext, - switcherSelection: ProviderSwitcherSelection) + switcherSelection: ProviderSwitcherSelection, + captureMenu: NSMenu? = nil) { - self.store.refreshStorageFootprintsForOverview() if switcherSelection == .overview { let enabledProviders = self.store.enabledProvidersForDisplay() if self.addOverviewRows( to: menu, enabledProviders: enabledProviders, - menuWidth: context.menuWidth) + menuWidth: context.menuWidth, + captureMenu: captureMenu) { menu.addItem(.separator()) } else { @@ -696,19 +771,13 @@ extension StatusItemController { menu.addItem(.separator()) } } else { - let addedOpenAIWebItems = self.addMenuCards(to: menu, context: context) + let addedOpenAIWebItems = self.addMenuCards(to: menu, context: context, captureMenu: captureMenu) self.addOpenAIWebItemsIfNeeded( to: menu, currentProvider: context.currentProvider, context: context.openAIContext, addedOpenAIWebItems: addedOpenAIWebItems) - if self.addUsageHistoryMenuItemIfNeeded( - to: menu, - provider: context.currentProvider, - width: context.menuWidth) - { - menu.addItem(.separator()) - } + self.addUsageHistoryClusterIfNeeded(to: menu, context: context) if self.addZaiHourlyUsageMenuItemIfNeeded( to: menu, provider: context.currentProvider, @@ -719,14 +788,13 @@ extension StatusItemController { } } - private func addActionableSections(_ sections: [MenuDescriptor.Section], to menu: NSMenu, width: CGFloat) { - let actionableSections = sections.filter { section in - section.entries.contains { entry in - if case .action = entry { return true } - if case .submenu = entry { return true } - return false - } - } + func addActionableSections( + _ sections: [MenuDescriptor.Section], + to menu: NSMenu, + width: CGFloat, + captureMenu: NSMenu? = nil) + { + let actionableSections = sections.filter { section in section.entries.contains(where: \ .isActionable) } for (index, section) in actionableSections.enumerated() { for entry in section.entries { switch entry { @@ -748,16 +816,16 @@ extension StatusItemController { } menu.addItem(item) case let .action(title, action): - let localizedTitle = L(title) - if self.usesPersistentMenuActionItem(for: action) { - menu.addItem(self.makePersistentMenuActionItem( - title: localizedTitle, - action: action, - menu: menu, - width: width)) + if action == .refresh { + let item = self.makePersistentRefreshItem( + title: L(title), + menu: captureMenu ?? menu, + width: width) + menu.addItem(item) + self.persistentRefreshItems.add(item) continue } - + let localizedTitle = L(title) let (selector, represented) = self.selector(for: action) let item = NSMenuItem(title: localizedTitle, action: selector, keyEquivalent: "") item.target = self @@ -773,6 +841,11 @@ extension StatusItemController { image.size = NSSize(width: 16, height: 16) item.image = image } + self.attachStatusComponentsSubmenuIfNeeded( + to: item, + action: action, + menu: captureMenu ?? menu, + width: width) if case let .switchAccount(targetProvider) = action, let subtitle = self.switchAccountSubtitle(for: targetProvider) { @@ -785,6 +858,11 @@ extension StatusItemController { self.applySubtitle(subtitle, to: item, title: localizedTitle) } menu.addItem(item) + case let .unavailable(title, tooltip): + let item = NSMenuItem(title: title, action: nil, keyEquivalent: "") + item.isEnabled = false + item.toolTip = tooltip + menu.addItem(item) case let .submenu(title, systemImageName, submenuItems): let item = NSMenuItem(title: title, action: nil, keyEquivalent: "") if let systemImageName, @@ -820,54 +898,6 @@ extension StatusItemController { } } - private func makePersistentMenuActionItem( - title: String, - action: MenuDescriptor.MenuAction, - menu: NSMenu, - width: CGFloat) -> NSMenuItem - { - let shortcut = self.shortcut(for: action) - let row = PersistentMenuActionItemView( - title: title, - systemImageName: self.persistentMenuActionSystemImageName(for: action), - shortcutText: shortcut.map { self.shortcutLabel(for: $0) }, - width: width, - onClick: { [weak self, weak menu] in - self?.performPersistentMenuAction(action, in: menu) - }) - - let item = NSMenuItem(title: title, action: nil, keyEquivalent: shortcut?.key ?? "") - item.keyEquivalentModifierMask = shortcut?.modifiers ?? NSEvent.ModifierFlags() - item.isEnabled = true - item.view = row - item.toolTip = title - if action != .refresh { - let (selector, represented) = self.selector(for: action) - item.action = selector - item.target = self - item.representedObject = represented - } - return item - } - - private func shortcutLabel(for shortcut: (key: String, modifiers: NSEvent.ModifierFlags)) -> String { - var label = "" - if shortcut.modifiers.contains(.control) { - label += "^" - } - if shortcut.modifiers.contains(.option) { - label += "⌥" - } - if shortcut.modifiers.contains(.shift) { - label += "⇧" - } - if shortcut.modifiers.contains(.command) { - label += "⌘" - } - label += shortcut.key.uppercased() - return label - } - private func makeWrappedSecondaryTextItem(text: String, width: CGFloat) -> NSMenuItem { let item = NSMenuItem(title: "", action: nil, keyEquivalent: "") let view = self.makeWrappedSecondaryTextView(text: text) @@ -892,6 +922,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), @@ -921,6 +952,7 @@ extension StatusItemController { menu.autoenablesItems = false menu.delegate = self menu.persistentActionDelegate = self + StatusMenuAppearance.pin(menu) return menu } @@ -945,24 +977,27 @@ extension StatusItemController { }, onSelect: { [weak self, weak menu] selection in guard let self, let menu else { return } - let provider: UsageProvider? - switch selection { - case .overview: - self.settings.mergedMenuLastSelectedWasOverview = true - provider = self.resolvedMenuProvider() - case let .provider(selectedProvider): - self.settings.mergedMenuLastSelectedWasOverview = false - self.selectedMenuProvider = selectedProvider - provider = selectedProvider - } - switch selection { - case .overview: - self.lastMenuProvider = provider ?? .codex - case let .provider(provider): - self.lastMenuProvider = provider + var provider: UsageProvider? + self.preservingMergedSwitcherContentCachesDuringInvalidation { + switch selection { + case .overview: + self.settings.mergedMenuLastSelectedWasOverview = true + provider = self.resolvedMenuProvider() + case let .provider(selectedProvider): + self.settings.mergedMenuLastSelectedWasOverview = false + self.selectedMenuProvider = selectedProvider + provider = selectedProvider + } + switch selection { + case .overview: + self.lastMenuProvider = provider ?? .codex + case let .provider(provider): + self.lastMenuProvider = provider + } + self.lastMergedSwitcherSelection = selection + self.refreshProviderSelectionDependentUI(deferRendering: true) } - self.lastMergedSwitcherSelection = selection - self.deferSwitcherMenuRebuildIfStillVisible(menu, provider: provider) + self.requestProviderSwitcherMenuRebuild(menu, provider: provider) }) let item = NSMenuItem() item.view = view @@ -981,7 +1016,13 @@ extension StatusItemController { width: width, onSelect: { [weak self, weak menu] index -> Task? in guard let self, let menu else { return nil } + guard display.accounts.indices.contains(index) else { return nil } + let selectedAccount = display.accounts[index] + self.advanceMenuInteraction(for: menu) self.settings.setActiveTokenAccountIndex(index, for: display.provider) + self.store.activateCachedTokenAccountSnapshot( + provider: display.provider, + accountID: selectedAccount.id) self.applyIcon(phase: nil) self.deferSwitcherMenuRebuildIfStillVisible(menu, provider: display.provider) return Task { @MainActor [weak self, weak menu] in @@ -990,7 +1031,7 @@ extension StatusItemController { await self.store.refreshProvider(display.provider) } guard let menu else { return } - self.refreshOpenMenuIfStillVisible(menu, provider: display.provider) + self.deferSwitcherMenuRebuildIfStillVisible(menu, provider: display.provider) } }) let item = NSMenuItem() @@ -1021,30 +1062,35 @@ extension StatusItemController { @discardableResult private func handleCodexVisibleAccountSelection(_ account: CodexVisibleAccount, menu: NSMenu?) -> Bool { let visibleAccountID = account.id + self.advanceMenuInteraction(for: menu) self.settings.selectDisplayedCodexVisibleAccount(account) if self.store.prepareCodexAccountScopedRefreshIfNeeded(), let menu { self.deferSwitcherMenuRebuildIfStillVisible(menu, provider: .codex) } - Task { @MainActor in + let store = self.store + let settings = self.settings + Task { @MainActor [weak controller = self, weak menu, store, settings] in await ProviderInteractionContext.$current.withValue(.userInitiated) { - await self.store.refreshCodexAccountScopedState( + await store.refreshCodexAccountScopedState( allowDisabled: true, - phaseDidChange: { [weak self, weak menu] _ in - guard let self, let menu else { return } - guard self.settings.codexVisibleAccountProjection.activeVisibleAccountID == visibleAccountID + phaseDidChange: { [weak controller, weak menu, settings] _ in + guard let controller, let menu else { return } + guard settings.codexVisibleAccountProjection.activeVisibleAccountID == visibleAccountID else { return } - self.refreshOpenMenuIfStillVisible(menu, provider: .codex) + controller.refreshOpenMenuIfStillVisible(menu, provider: .codex) }) } } return true } - private func resolvedMenuProvider(enabledProviders: [UsageProvider]? = nil) -> UsageProvider? { + func resolvedMenuProvider(enabledProviders: [UsageProvider]? = nil) -> UsageProvider? { let enabled = enabledProviders ?? self.store.enabledProvidersForDisplay() - if enabled.isEmpty { return .codex } + if enabled.isEmpty { + return .codex + } if let selected = self.selectedMenuProvider, enabled.contains(selected) { return selected } @@ -1069,16 +1115,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 +1128,19 @@ 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. + // Exception: when `refreshAllProvidersOnMenuOpen` is enabled, every enabled provider is refreshed on + // open regardless of freshness — still after the delay below, and still via the light usage-only + // primitive so the OpenAI dashboard scrape stays deferred until the menu closes. + let providersNeedingRetryAtOpen = self.delayedRefreshRetryProviders(for: menu).filter { + self.store.needsUsageRefreshRetry(for: $0) + } + if !providersNeedingRetryAtOpen.isEmpty { + self.deferMenuInteractionRefreshIfNeeded(providers: providersNeedingRetryAtOpen) } let key = ObjectIdentifier(menu) self.menuRefreshTasks[key]?.cancel() @@ -1123,18 +1148,70 @@ 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 guard self.openMenus[ObjectIdentifier(menu)] != nil else { return } - guard !self.store.isRefreshing else { return } - let retryProviders = self.delayedRefreshRetryProviders(for: menu) - let retryStaleProviderCount = retryProviders.count { self.store.isStale(provider: $0) } - 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) + let refreshAllOnOpen = self.settings.refreshAllProvidersOnMenuOpen + let enabledProviders = self.store.enabledProvidersForBackgroundWork() + let visibleProviders = self.delayedRefreshRetryProviders(for: menu) + let plan = MenuOpenRefreshPlan.resolve(.init( + refreshAllOnOpen: refreshAllOnOpen, + enabledProviders: enabledProviders, + visibleProviders: visibleProviders, + refreshingProviders: self.store.refreshingProviders, + staleProviders: Set(visibleProviders.filter { self.store.isStale(provider: $0) }), + missingProviders: Set(visibleProviders.filter { !self.store.hasSatisfiedUsageFetch(for: $0) }))) + if plan.refreshCodexDashboard { + self.deferOpenAIDashboardRefreshUntilMenuCloses(reason: "refresh all") + } + let retryProviders = plan.providers + guard !retryProviders.isEmpty else { + self.clearSatisfiedDeferredMenuInteractionRefreshes( + for: self.delayedRefreshRetryProviders(for: menu)) + // Ordinary store changes intentionally stay queued until the next open. Rebuilding here + // made first-open work such as the storage scan flash the visible menu after 1.2 seconds. + if !providersNeedingRetryAtOpen.isEmpty, self.menuNeedsRefresh(menu) { + self.scheduleOpenMenuRebuildIfStillVisible( + menu, + provider: self.menuProvider(for: menu), + resyncReadinessBaselineAfterRebuild: self.openMenus.count == 1) + } + return + } + self.deferMenuInteractionRefreshIfNeeded(providers: retryProviders) + await ProviderInteractionContext.$current.withValue(.background) { + if plan.scheduling == .concurrent { + // Refresh concurrently so one slow provider doesn't delay the rest, mirroring the + // periodic refresh in `UsageStore.runRefresh`. `coalesceIfRefreshing` makes each call + // wait for any in-flight refresh (e.g. a manual refresh) instead of overriding it. + await withTaskGroup(of: Void.self) { group in + for provider in retryProviders { + group.addTask { + await self.store.refreshProvider(provider, coalesceIfRefreshing: true) + } + } + } + } else { + for provider in retryProviders { + guard !Task.isCancelled else { return } + await self.store.refreshProvider(provider, coalesceIfRefreshing: true) + } + } + } + let stillNeedsRetry = retryProviders.contains { + self.store.needsUsageRefreshRetry(for: $0) + } + if !stillNeedsRetry { + self.clearSatisfiedDeferredMenuInteractionRefreshes(for: retryProviders) + } + guard !Task.isCancelled else { return } + guard self.openMenus[ObjectIdentifier(menu)] != nil else { return } + self.invalidateMenus( + refreshOpenMenus: true, + deferOpenParentMenuRebuild: false, + allowStaleContentDuringDataRefresh: true) } } @@ -1142,11 +1219,15 @@ extension StatusItemController { let providersToCheck = self.delayedRefreshRetryProviders(for: menu) guard !providersToCheck.isEmpty else { return false } return providersToCheck.contains { provider in - self.store.isStale(provider: provider) || self.store.snapshot(for: provider) == nil + self.store.needsUsageRefreshRetry(for: provider) } } private func delayedRefreshRetryProviders(for menu: NSMenu) -> [UsageProvider] { + self.renderedProviders(for: menu) + } + + func renderedProviders(for menu: NSMenu) -> [UsageProvider] { let enabledProviders = self.store.enabledProvidersForDisplay() guard !enabledProviders.isEmpty else { return [] } let includesOverview = self.includesOverviewTab(enabledProviders: enabledProviders) @@ -1170,107 +1251,30 @@ extension StatusItemController { return enabledProviders } - private func refreshMenuCardHeights(in menu: NSMenu) { - // Re-measure the menu card height right before display to avoid stale/incorrect sizing when content - // changes (e.g. dashboard error lines causing wrapping). - let cardItems = menu.items.filter { item in - (item.representedObject as? String)?.hasPrefix("menuCard") == true - } - for item in cardItems { - guard let view = item.view else { continue } - let width = self.renderedMenuWidth(for: menu) - let height = self.menuCardHeight(for: view, width: width) - view.frame = NSRect( - origin: .zero, - size: NSSize(width: width, height: height)) - } - } - - func makeMenuCardItem( - _ view: some View, - id: String, - width: CGFloat, - submenu: NSMenu? = nil, - submenuIndicatorAlignment: Alignment = .topTrailing, - submenuIndicatorTopPadding: CGFloat = 8, - onClick: (() -> Void)? = nil) -> NSMenuItem - { - if !Self.menuCardRenderingEnabled { - let item = NSMenuItem() - item.isEnabled = true - item.representedObject = id - item.submenu = submenu - if submenu != nil { - item.target = self - item.action = #selector(self.menuCardNoOp(_:)) - } - return item - } - - let highlightState = MenuCardHighlightState() - let wrapped = MenuCardSectionContainerView( - highlightState: highlightState, - showsSubmenuIndicator: submenu != nil, - submenuIndicatorAlignment: submenuIndicatorAlignment, - submenuIndicatorTopPadding: submenuIndicatorTopPadding) - { - view - } - let hosting = MenuCardItemHostingView(rootView: wrapped, highlightState: highlightState, onClick: onClick) - // Set frame with target width immediately - let height = self.menuCardHeight(for: hosting, width: width) - hosting.frame = NSRect(origin: .zero, size: NSSize(width: width, height: height)) - let item = NSMenuItem() - item.view = hosting - item.isEnabled = true - item.representedObject = id - item.submenu = submenu - if submenu != nil { - item.target = self - item.action = #selector(self.menuCardNoOp(_:)) - } - return item - } - - private func menuCardHeight(for view: NSView, width: CGFloat) -> CGFloat { - let basePadding: CGFloat = 6 - let descenderSafety: CGFloat = 1 - - // Fast path: use protocol-based measurement when available (avoids layout passes) - if let measured = view as? MenuCardMeasuring { - return max(1, ceil(measured.measuredHeight(width: width) + basePadding + descenderSafety)) - } - - // Set frame with target width before measuring. - view.frame = NSRect(origin: .zero, size: NSSize(width: width, height: 1)) - - // Use fittingSize directly - SwiftUI hosting views respect the frame width for wrapping - let fitted = view.fittingSize - - return max(1, ceil(fitted.height + basePadding + descenderSafety)) - } - private func addMenuCardSections( to menu: NSMenu, model: UsageMenuCardView.Model, - provider: UsageProvider, + layoutModel: UsageMenuCardView.Model, width: CGFloat, webItems: OpenAIWebMenuItems) { - let hasUsageBlock = model.hasUsageContent - let hasCredits = model.creditsText != nil - let hasExtraUsage = model.providerCost != nil - let hasCost = model.tokenUsage != nil - let hasStorage = self.store.storageFootprintText(for: provider) != nil + let provider = layoutModel.provider + let hasCredits = layoutModel.creditsText != nil + let hasExtraUsage = layoutModel.providerCost != nil + let hasCost = layoutModel.tokenUsage != nil let bottomPadding = CGFloat(hasCredits ? 4 : 6) let sectionSpacing = CGFloat(6) - let usageBottomPadding = bottomPadding let creditsBottomPadding = bottomPadding + func addSectionSeparator() { + guard menu.items.last?.isSeparatorItem != true else { return } + menu.addItem(.separator()) + } - if hasUsageBlock { + if layoutModel.hasUsageContent { let usageView = UsageMenuCardHeaderAndUsageSectionView( model: model, - bottomPadding: usageBottomPadding, + layoutModel: layoutModel, + bottomPadding: bottomPadding, width: width) let usageSubmenu = self.makeUsageSubmenu( provider: provider, @@ -1281,28 +1285,33 @@ extension StatusItemController { usageView, id: "menuCardUsage", width: width, - submenu: usageSubmenu)) + heightCacheScope: provider.rawValue, + heightCacheFingerprint: layoutModel.heightFingerprint(section: "usage"), + submenu: usageSubmenu, + containsInteractiveControls: true)) } else { let headerView = UsageMenuCardHeaderSectionView( - model: model, + model: layoutModel, showDivider: false, width: width) - menu.addItem(self.makeMenuCardItem(headerView, id: "menuCardHeader", width: width)) - } - - if hasStorage || hasCredits || hasExtraUsage || hasCost { - menu.addItem(.separator()) + menu.addItem(self.makeMenuCardItem( + headerView, + id: "menuCardHeader", + width: width, + heightCacheScope: provider.rawValue, + heightCacheFingerprint: layoutModel.heightFingerprint(section: "header"), + containsInteractiveControls: true)) } if self.addStorageMenuCardSection(to: menu, provider: provider, width: width), - hasCredits || hasExtraUsage || hasCost + hasCredits || hasExtraUsage { - menu.addItem(.separator()) + addSectionSeparator() } if hasCredits { if hasExtraUsage || hasCost { - menu.addItem(.separator()) + addSectionSeparator() } let creditsView = UsageMenuCardCreditsSectionView( model: model, @@ -1315,6 +1324,8 @@ extension StatusItemController { creditsView, id: "menuCardCredits", width: width, + heightCacheScope: provider.rawValue, + heightCacheFingerprint: layoutModel.heightFingerprint(section: "credits"), submenu: creditsSubmenu)) if webItems.canShowBuyCredits { menu.addItem(self.makeBuyCreditsItem()) @@ -1322,7 +1333,7 @@ extension StatusItemController { } if hasExtraUsage { if hasCredits { - menu.addItem(.separator()) + addSectionSeparator() } let extraUsageSubmenu = self.makeOpenAIAPIUsageSubmenu(provider: provider, width: width) let extraUsageView = UsageMenuCardExtraUsageSectionView( @@ -1334,33 +1345,28 @@ extension StatusItemController { extraUsageView, id: "menuCardExtraUsage", width: width, + heightCacheScope: provider.rawValue, + heightCacheFingerprint: layoutModel.heightFingerprint(section: "extraUsage"), submenu: extraUsageSubmenu)) } if hasCost { if hasCredits || hasExtraUsage { - menu.addItem(.separator()) + addSectionSeparator() } let costSubmenu = webItems.hasCostHistory ? self .makeCostHistorySubmenu(provider: provider, width: width) : nil - menu.addItem(self.makeCostMenuCardItem(model: model, submenu: costSubmenu)) + menu.addItem(self.makeCostMenuCardItem( + model: model, + submenu: costSubmenu, + width: width)) + } + if !hasCredits, webItems.hasCreditsHistory, self.settings.showOptionalCreditsAndExtraUsage { + addSectionSeparator() + _ = self.addCreditsHistorySubmenu(to: menu) + } + if !hasCredits, webItems.canShowBuyCredits { + menu.addItem(self.makeBuyCreditsItem()) } - } - - @discardableResult - func addStorageMenuCardSection(to menu: NSMenu, provider: UsageProvider, width: CGFloat) -> Bool { - guard let storageText = self.store.storageFootprintText(for: provider) else { return false } - let storageView = StorageMenuCardSectionView( - storageText: storageText, - topPadding: 6, - bottomPadding: 6, - width: width) - let storageSubmenu = self.makeStorageBreakdownSubmenu(provider: provider, width: width) - menu.addItem(self.makeMenuCardItem( - storageView, - id: "menuCardStorage", - width: width, - submenu: storageSubmenu)) - return true } private func switcherIcon(for provider: UsageProvider) -> NSImage { @@ -1372,40 +1378,26 @@ extension StatusItemController { let snapshot = self.store.snapshot(for: provider) let showUsed = self.settings.usageBarsShowUsed let style = self.store.style(for: provider) + let now = Date() let resolved = snapshot.map { IconRemainingResolver.resolvedPercents( snapshot: $0, style: style, - showUsed: showUsed) + showUsed: showUsed, + secondaryOverrideWindowID: self.settings.copilotIconSecondaryWindowOverrideID(snapshot: $0), + now: now) } let primary = resolved?.primary - var weekly = resolved?.secondary - if showUsed, - provider == .warp, - let remaining = snapshot?.secondary?.remainingPercent, - remaining <= 0 - { - // Preserve Warp "no bonus/exhausted bonus" layout even in show-used mode. - weekly = 0 - } - if showUsed, - provider == .warp, - let remaining = snapshot?.secondary?.remainingPercent, - remaining > 0, - weekly == 0 - { - // In show-used mode, `0` means "unused", not "missing". Keep the weekly lane present. - weekly = 0.0001 - } + let weekly = resolved?.secondary let creditsProjection = self.store.codexConsumerProjectionIfNeeded( for: provider, surface: .menuBar, snapshotOverride: snapshot, - now: snapshot?.updatedAt ?? Date()) + now: now) let credits = creditsProjection?.menuBarFallback == .creditsBalance ? self.store.codexMenuBarCreditsRemaining( snapshotOverride: snapshot, - now: snapshot?.updatedAt ?? Date()) + now: now) : nil let stale = self.store.isStale(provider: provider) let indicator = self.store.statusIndicator(for: provider) @@ -1418,7 +1410,8 @@ extension StatusItemController { blink: 0, wiggle: 0, tilt: 0, - statusIndicator: indicator) + statusIndicator: indicator, + hideCritters: self.settings.menuBarHidesCritters) image.isTemplate = true return image } @@ -1484,6 +1477,13 @@ extension StatusItemController { if provider == .openai { return self.makeOpenAIAPIUsageSubmenu(provider: provider, width: width) } + // Mistral's top usage pane has no rate-limit bars of its own, so its cost history hangs + // off this row instead. Other `tokenCostRequiresProviderSnapshot` providers (e.g. + // opencodego) show real rate-limit bars here and get their own "Cost" row instead + // (see `makeCostMenuCardItem`), matching Codex/Claude's structure. + if provider == .mistral { + return self.makeCostHistorySubmenu(provider: provider, width: width) + } if provider == .zai { return self.makeZaiUsageDetailsSubmenu(snapshot: snapshot) } @@ -1578,6 +1578,18 @@ extension StatusItemController { provider == .openai && self.tokenSnapshotForCostHistorySubmenu(provider: provider)?.daily.isEmpty == false } + /// Unlike `makeUsageSubmenu`'s and `tokenCostMenuSectionEnabled`'s provider checks, this one + /// intentionally reuses `tokenCostRequiresProviderSnapshot`: any provider whose cost is + /// sourced by projecting a snapshot field (rather than the CostUsageFetcher pipeline) can only + /// render that cost through `addMenuCardSections`'s sectioned layout, so the two concepts are + /// genuinely coupled here, not coincidentally aliased. The name is deliberately broader than + /// "top-pane submenu" — opencodego satisfies this via its collapsible "Cost" row, not a + /// provider-native top-pane submenu like openai/mistral. + private func requiresSectionedMenuForProviderDerivedCost(provider: UsageProvider) -> Bool { + UsageStore.tokenCostRequiresProviderSnapshot(provider) && + self.tokenSnapshotForCostHistorySubmenu(provider: provider)?.daily.isEmpty == false + } + func makeStorageBreakdownSubmenu(provider: UsageProvider, width: CGFloat? = nil) -> NSMenu? { guard self.store.storageFootprint(for: provider)?.components.isEmpty == false else { return nil } if let width { @@ -1589,6 +1601,59 @@ extension StatusItemController { return self.makeHostedSubviewPlaceholderMenu(chartID: Self.storageBreakdownID, provider: provider) } + /// Providers that surface the live component list as a native submenu. Every other provider + /// keeps the plain "Status Page" link that opens the website. Kept deliberately small: these + /// are the statuspage.io/incident.io feeds we actively curate and trust to render well. + static let statusComponentsSubmenuProviders: Set = [.claude, .codex, .augment, .zoommate] + + /// Filters `components` down to a provider's descriptor-owned named allowlist, if configured; + /// returns `components` unchanged when the provider has no allowlist. Matching is by exact + /// `name` equality at the top level only (groups and leaves alike). + static func filterStatusComponents( + _ components: [ProviderStatusComponent], + for provider: UsageProvider) -> [ProviderStatusComponent] + { + let metadata = ProviderDescriptorRegistry.descriptor(for: provider).metadata + guard let allowlist = metadata.statusComponentAllowlist else { return components } + return components.filter { allowlist.contains($0.name) } + } + + /// Builds the status submenu (component rows + a website link) for the curated providers in + /// `statusComponentsSubmenuProviders`. Gated on the provider being in that allowlist (and + /// having a component feed) rather than on components being loaded yet: status is fetched + /// asynchronously, so gating on loaded components would leave the row as a plain link for any + /// provider whose first fetch hasn't landed at menu-build time. The submenu hydrates from the + /// live component list each time it opens (and shows just the website link until the first + /// fetch lands). Returns nil for all other providers, which keep the plain status-page link. + /// For curated providers, turns the "Status Page" row into a submenu of live component + /// statuses instead of a direct website link (the link moves to the bottom of the submenu). + func attachStatusComponentsSubmenuIfNeeded( + to item: NSMenuItem, + action: MenuDescriptor.MenuAction, + menu: NSMenu, + width: CGFloat) + { + guard action == .statusPage, + let statusProvider = self.menuProvider(for: menu) ?? self.lastMenuProvider, + let submenu = self.makeStatusComponentsSubmenu(provider: statusProvider, width: width) + else { return } + item.action = nil + item.submenu = submenu + } + + func makeStatusComponentsSubmenu(provider: UsageProvider, width: CGFloat? = nil) -> NSMenu? { + guard self.store.statusChecksEnabled else { return nil } + guard Self.statusComponentsSubmenuProviders.contains(provider) else { return nil } + guard ProviderDescriptorRegistry.descriptor(for: provider).metadata.statusPageURL != nil else { return nil } + if let width { + return self.makeHostedSubviewPlaceholderMenu( + chartID: Self.statusComponentsID, + provider: provider, + width: width) + } + return self.makeHostedSubviewPlaceholderMenu(chartID: Self.statusComponentsID, provider: provider) + } + private func isOpenAIWebSubviewMenu(_ menu: NSMenu) -> Bool { let ids: Set = [ Self.usageBreakdownChartID, @@ -1600,46 +1665,7 @@ extension StatusItemController { } } - func refreshHostedSubviewHeights(in menu: NSMenu) { - let width = self.renderedMenuWidth(for: menu) - - for item in menu.items { - guard let view = item.view else { continue } - view.frame = NSRect(origin: .zero, size: NSSize(width: width, height: 1)) - view.layoutSubtreeIfNeeded() - let height = view.fittingSize.height - view.frame = NSRect(origin: .zero, size: NSSize(width: width, height: height)) - } - } - - @objc private func menuCardNoOp(_ sender: NSMenuItem) { + @objc func menuCardNoOp(_ sender: NSMenuItem) { _ = sender } - - @objc private func selectOverviewProvider(_ sender: NSMenuItem) { - guard let represented = sender.representedObject as? String, - represented.hasPrefix(Self.overviewRowIdentifierPrefix) - else { - return - } - let rawProvider = String(represented.dropFirst(Self.overviewRowIdentifierPrefix.count)) - guard let provider = UsageProvider(rawValue: rawProvider), - let menu = sender.menu - else { - return - } - - self.selectOverviewProvider(provider, menu: menu) - } - - private func selectOverviewProvider(_ provider: UsageProvider, menu: NSMenu) { - if !self.settings.mergedMenuLastSelectedWasOverview, self.selectedMenuProvider == provider { return } - self.settings.mergedMenuLastSelectedWasOverview = false - self.lastMergedSwitcherSelection = nil - self.selectedMenuProvider = provider - self.lastMenuProvider = provider - self.populateMenu(menu, provider: provider) - self.markMenuFresh(menu) - self.applyIcon(phase: nil) - } } diff --git a/Sources/CodexBar/StatusItemController+MenuActionMapping.swift b/Sources/CodexBar/StatusItemController+MenuActionMapping.swift index c4becbc97d..4898026b04 100644 --- a/Sources/CodexBar/StatusItemController+MenuActionMapping.swift +++ b/Sources/CodexBar/StatusItemController+MenuActionMapping.swift @@ -4,7 +4,7 @@ extension StatusItemController { func selector(for action: MenuDescriptor.MenuAction) -> (Selector, Any?) { switch action { case .installUpdate: (#selector(self.installUpdate), nil) - case .refresh: (#selector(self.refreshNow), nil) + case .refresh: (#selector(self.refreshMenuItem(_:)), nil) case .refreshAugmentSession: (#selector(self.refreshAugmentSession), nil) case .dashboard: (#selector(self.openDashboard), nil) case .statusPage: (#selector(self.openStatusPage), nil) @@ -20,14 +20,16 @@ extension StatusItemController { case .about: (#selector(self.showSettingsAbout), nil) case .quit: (#selector(self.quit), nil) case let .copyError(message): (#selector(self.copyError(_:)), message) + case let .focusAgentSession(session, remoteHost): + (#selector(self.focusAgentSession(_:)), [session.id, remoteHost ?? ""]) } } func codexAddAccountSubtitle() -> String? { if self.settings.hasUnreadableManagedCodexAccountStore { - return "Managed account storage unavailable" + return L("Managed account storage unavailable") } guard self.managedCodexAccountCoordinator.isAuthenticatingManagedAccount else { return nil } - return "Managed Codex login in progress…" + return L("Managed Codex login in progress…") } } diff --git a/Sources/CodexBar/StatusItemController+MenuAppearance.swift b/Sources/CodexBar/StatusItemController+MenuAppearance.swift new file mode 100644 index 0000000000..c83ddad62f --- /dev/null +++ b/Sources/CodexBar/StatusItemController+MenuAppearance.swift @@ -0,0 +1,13 @@ +import AppKit + +@MainActor +enum StatusMenuAppearance { + static func pin(_ menu: NSMenu) { + self.pin(menu, to: NSApplication.shared.effectiveAppearance) + } + + static func pin(_ menu: NSMenu, to appearance: NSAppearance) { + // The exact effective appearance carries accessibility attributes that its name can omit. + menu.appearance = appearance + } +} diff --git a/Sources/CodexBar/StatusItemController+MenuBarLayout.swift b/Sources/CodexBar/StatusItemController+MenuBarLayout.swift new file mode 100644 index 0000000000..f39fbd21f1 --- /dev/null +++ b/Sources/CodexBar/StatusItemController+MenuBarLayout.swift @@ -0,0 +1,168 @@ +import AppKit +import CodexBarCore +import Foundation + +extension StatusItemController { + func applyStoredMenuBarLayoutIfNeeded( + provider: UsageProvider, + snapshot: UsageSnapshot?, + icon: NSImage?, + warningFlash: Bool, + statusItem: NSStatusItem, + now: Date = .init()) + -> Bool? + { + let resolution = self.settings.menuBarLayoutResolution(for: provider) + guard !resolution.usesLegacyRendering, + self.settings.menuBarIconStyle == .iconAndPercent, + let button = statusItem.button + else { + statusItem.length = NSStatusItem.variableLength + return nil + } + + let renderedIcon = icon.map { warningFlash ? Self.quotaWarningFlashImage(base: $0) : $0 } + let data = self.menuBarLayoutRenderData( + provider: provider, + snapshot: snapshot, + warningFlash: warningFlash, + now: now) + let minute = Date(timeIntervalSince1970: floor(now.timeIntervalSince1970 / 60) * 60) + let appearanceName = button.effectiveAppearance.bestMatch(from: [.darkAqua, .aqua])?.rawValue ?? "default" + let options = MenuBarLayoutRenderOptions( + size: self.settings.menuBarLayoutSize, + highContrast: self.shouldUseHighContrastStatusItemContent, + showUsed: self.settings.usageBarsShowUsed, + appearanceName: appearanceName, + isDebugApp: Self.isDebugApp(bundleIdentifier: Bundle.main.bundleIdentifier), + now: minute) + let rendered = self.menuBarLayoutRenderer.render( + layout: resolution.layout, + data: data, + icon: renderedIcon, + options: options) + let wasCached = button.image == nil + && button.imagePosition == .noImage + && button.attributedTitle.isEqual(to: rendered.attributedTitle) + self.setButtonLayoutContent(rendered, for: button, statusItem: statusItem) + return wasCached + } + + func menuBarLayoutRenderData( + provider: UsageProvider, + snapshot: UsageSnapshot?, + warningFlash: Bool, + now: Date = .init()) + -> MenuBarLayoutRenderData + { + let windows = self.menuBarLayoutWindows(provider: provider, snapshot: snapshot, now: now) + let paceWindow = windows.weekly ?? windows.automatic + let runsOut = paceWindow + .flatMap { self.store.weeklyPace(provider: provider, window: $0, now: now) } + .flatMap { UsagePaceText.weeklyDetail(provider: provider, pace: $0, now: now).rightLabel } + let costStrings = self.menuBarLayoutCostStrings(provider: provider, now: now) + let providerName = L(self.store.metadata(for: provider).displayName) + let accountLabel = self.menuBarLayoutAccountLabel(provider: provider, snapshot: snapshot) + + return MenuBarLayoutRenderData( + iconKey: "\(provider.rawValue):\(warningFlash ? "warning" : "normal")", + providerName: providerName, + accountLabel: accountLabel, + session: MenuBarLayoutRenderWindow(windows.session), + weekly: MenuBarLayoutRenderWindow(windows.weekly), + automatic: MenuBarLayoutRenderWindow(windows.automatic), + runsOut: runsOut, + costToday: costStrings.today, + cost30d: costStrings.last30Days) + } + + func menuBarLayoutAccountLabel(provider: UsageProvider, snapshot: UsageSnapshot?) -> String? { + let rawAccountLabel = snapshot?.accountEmail(for: provider)? + .trimmingCharacters(in: .whitespacesAndNewlines) + return self.settings.hidePersonalInfo || rawAccountLabel?.isEmpty != false + ? nil + : rawAccountLabel + } + + func menuBarLayoutCostStrings( + provider: UsageProvider, + now: Date = .init()) + -> (today: String?, last30Days: String?) + { + let snapshot = self.store.tokenSnapshotForCurrentProviderConfig(for: provider)?.snapshot + let sourceCurrencyCode = snapshot?.currencyCode ?? "USD" + let preferredCurrencyCode = self.settings.preferredCurrencyCode + + let today = MenuBarLayoutCostResolver.todayCostUSD(snapshot: snapshot, now: now).map { + UsageFormatter.convertedCostString( + $0, + preferredCurrency: preferredCurrencyCode, + providerCurrency: sourceCurrencyCode) + } + let last30Days = snapshot?.last30DaysCostUSD.map { + UsageFormatter.convertedCostString( + $0, + preferredCurrency: preferredCurrencyCode, + providerCurrency: sourceCurrencyCode) + } + return (today, last30Days) + } + + func menuBarLayoutWindows( + provider: UsageProvider, + snapshot: UsageSnapshot?, + now: Date) + -> (session: RateWindow?, weekly: RateWindow?, automatic: RateWindow?) + { + if provider == .codex, + let projection = self.store.codexConsumerProjectionIfNeeded( + for: provider, + surface: .menuBar, + snapshotOverride: snapshot, + now: now) + { + let session = projection.menuBarSelectableRateWindow(for: .session) + let weekly = projection.menuBarSelectableRateWindow(for: .weekly) + let automatic = projection.visibleRateLanes + .lazy + .compactMap { projection.menuBarSelectableRateWindow(for: $0) } + .first + return (session, weekly, automatic) + } + + let semanticWindows = MenuBarLayoutSemanticWindowResolver.windows( + provider: provider, + snapshot: snapshot) + let automatic = MenuBarMetricWindowResolver.rateWindow( + preference: .automatic, + provider: provider, + snapshot: snapshot, + supportsAverage: self.settings.menuBarMetricSupportsAverage(for: provider), + antigravityPrioritizeExhaustedQuotas: self.settings.antigravityPrioritizeExhaustedQuotas, + now: now) + return (semanticWindows.session, semanticWindows.weekly, automatic) + } + + private func setButtonLayoutContent( + _ rendered: MenuBarLayoutRenderedTitle, + for button: NSStatusBarButton, + statusItem: NSStatusItem) + { + button.image = nil + button.imagePosition = .noImage + if !button.attributedTitle.isEqual(to: rendered.attributedTitle) { + button.attributedTitle = rendered.attributedTitle + } + if button.accessibilityTitle() != rendered.accessibilityLabel { + button.setAccessibilityTitle(rendered.accessibilityLabel) + } + + // AppKit exposes no content-inset API on NSStatusBarButton. Explicit item length is the actual + // status-item padding mechanism: tight removes most edge space; regular keeps the native breathing room. + let bounds = rendered.attributedTitle.boundingRect( + with: NSSize(width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude), + options: [.usesLineFragmentOrigin, .usesFontLeading]) + let horizontalPadding: CGFloat = self.settings.menuBarLayoutGap == .tight ? 3 : 10 + statusItem.length = max(18, ceil(bounds.width) + horizontalPadding) + } +} diff --git a/Sources/CodexBar/StatusItemController+MenuCardHeightCache.swift b/Sources/CodexBar/StatusItemController+MenuCardHeightCache.swift new file mode 100644 index 0000000000..5f1bd162b5 --- /dev/null +++ b/Sources/CodexBar/StatusItemController+MenuCardHeightCache.swift @@ -0,0 +1,54 @@ +import AppKit + +extension StatusItemController { + struct MenuCardHeightCacheKey: Hashable { + let id: String + let scope: String + let width: Int + let textScale: Int + let fingerprint: String + } + + /// Measured card height also depends on the resolved font sizes, which the menu cards + /// derive from semantic text styles (`.body`, `.footnote`, …). Those scale with the + /// macOS system text-size / Dynamic Type setting, which is neither part of the content + /// fingerprint nor invalidated on rebuild. Fold the current resolved scale into the key + /// so a runtime text-size change forces a fresh measurement instead of returning a + /// height measured at the old scale (clipped / over-tall cards). + static func menuCardHeightTextScaleToken() -> Int { + Int((NSFont.preferredFont(forTextStyle: .body).pointSize * 100).rounded()) + } + + func cachedMenuCardHeight( + for id: String, + scope: String, + width: CGFloat, + fingerprint: String? = nil, + measure: () -> CGFloat) -> CGFloat + { + let key = MenuCardHeightCacheKey( + id: id, + scope: scope, + width: Int((width * 100).rounded()), + textScale: Self.menuCardHeightTextScaleToken(), + fingerprint: fingerprint ?? "version:\(self.menuSession.contentVersion)") + if let cached = self.menuCardHeightCache[key] { + return cached + } + let height = measure() + if self.menuCardHeightCache.count > 256 { + self.menuCardHeightCache.removeAll(keepingCapacity: true) + } + self.menuCardHeightCache[key] = height + return height + } + + func pruneVersionScopedMenuCardHeightCache() { + let currentVersionFingerprint = "version:\(self.menuSession.contentVersion)" + for key in self.menuCardHeightCache.keys + where key.fingerprint.hasPrefix("version:") && key.fingerprint != currentVersionFingerprint + { + self.menuCardHeightCache.removeValue(forKey: key) + } + } +} diff --git a/Sources/CodexBar/StatusItemController+MenuCardItems.swift b/Sources/CodexBar/StatusItemController+MenuCardItems.swift new file mode 100644 index 0000000000..95eb1facec --- /dev/null +++ b/Sources/CodexBar/StatusItemController+MenuCardItems.swift @@ -0,0 +1,178 @@ +import AppKit +import SwiftUI + +extension StatusItemController { + func refreshMenuCardHeights(in menu: NSMenu) { + let width = self.renderedMenuWidth(for: menu) + for item in menu.items { + if let view = item.view as? PersistentRefreshMenuView { + guard abs(view.frame.width - width) > 0.5 else { continue } + view.applySize(width: width, height: PersistentRefreshRowMetrics.defaults.rowHeight) + continue + } + guard let view = item.view, view is any MenuCardMeasuring else { continue } + guard abs(view.frame.width - width) > 0.5 else { continue } + let id = item.representedObject as? String ?? "menuCard" + let scope = self.menuProvider(for: menu)?.rawValue ?? id + let height = self.cachedMenuCardHeight(for: id, scope: scope, width: width) { + self.menuCardHeight(for: view, width: width) + } + view.frame = NSRect( + origin: .zero, + size: NSSize(width: width, height: height)) + } + } + + func makeMenuCardItem( + _ view: CardContent, + id: String, + width: CGFloat, + heightCacheScope: String? = nil, + heightCacheFingerprint: String? = nil, + submenu: NSMenu? = nil, + submenuIndicatorAlignment: Alignment = .topTrailing, + submenuIndicatorTopPadding: CGFloat = 8, + containsInteractiveControls: Bool = false, + usesGPUSelection: Bool = false, + onClick: (() -> Void)? = nil) -> NSMenuItem + { + let allowsMenuHighlight = submenu != nil || onClick != nil + if !self.menuCardRenderingEnabledForController { + let item = NSMenuItem() + item.isEnabled = allowsMenuHighlight + item.representedObject = id + item.submenu = submenu + if submenu != nil { + item.target = self + item.action = #selector(self.menuCardNoOp(_:)) + } + return item + } + + if usesGPUSelection { + // Selection is painted by AppKit/GPU, so the SwiftUI content is pinned to its normal + // appearance via a `highlightState` that is never flipped; these rows skip hosting-view + // recycling because the recycler is typed to `MenuCardItemHostingView`. + let interactiveRegionStore = MenuCardInteractiveRegionStore() + let wrapped = MenuCardSectionContainerView( + highlightState: MenuCardHighlightState(), + showsSubmenuIndicator: submenu != nil, + submenuIndicatorAlignment: submenuIndicatorAlignment, + submenuIndicatorTopPadding: submenuIndicatorTopPadding, + refreshMonitor: self.menuCardRefreshMonitor, + interactiveRegionStore: interactiveRegionStore) + { + view + } + let gpuHosting = GPUSelectionHostingView( + rootView: wrapped, + allowsMenuHighlight: allowsMenuHighlight, + containsInteractiveControls: containsInteractiveControls, + interactiveRegionStore: interactiveRegionStore, + onClick: onClick) + let gpuHeight = self.cachedMenuCardHeight( + for: id, + scope: heightCacheScope ?? id, + width: width, + fingerprint: heightCacheFingerprint) + { + self.menuCardHeight(for: gpuHosting, width: width) + } + gpuHosting.frame = NSRect(origin: .zero, size: NSSize(width: width, height: gpuHeight)) + return self.makeMenuCardNSMenuItem( + hosting: gpuHosting, + id: id, + submenu: submenu, + isEnabled: allowsMenuHighlight || containsInteractiveControls) + } + + let hosting: MenuCardItemHostingView> + if let recycled = self.takeRecyclableMenuCardView( + for: id, + as: MenuCardItemHostingView>.self) + { + let wrapped = MenuCardSectionContainerView( + highlightState: recycled.highlightState, + showsSubmenuIndicator: submenu != nil, + submenuIndicatorAlignment: submenuIndicatorAlignment, + submenuIndicatorTopPadding: submenuIndicatorTopPadding, + refreshMonitor: self.menuCardRefreshMonitor, + interactiveRegionStore: recycled.interactiveRegionStore) + { + view + } + recycled.prepareForReuse( + rootView: wrapped, + allowsMenuHighlight: allowsMenuHighlight, + containsInteractiveControls: containsInteractiveControls, + onClick: onClick) + hosting = recycled + } else { + let highlightState = MenuCardHighlightState() + let interactiveRegionStore = MenuCardInteractiveRegionStore() + let wrapped = MenuCardSectionContainerView( + highlightState: highlightState, + showsSubmenuIndicator: submenu != nil, + submenuIndicatorAlignment: submenuIndicatorAlignment, + submenuIndicatorTopPadding: submenuIndicatorTopPadding, + refreshMonitor: self.menuCardRefreshMonitor, + interactiveRegionStore: interactiveRegionStore) + { + view + } + hosting = MenuCardItemHostingView( + rootView: wrapped, + highlightState: highlightState, + allowsMenuHighlight: allowsMenuHighlight, + containsInteractiveControls: containsInteractiveControls, + interactiveRegionStore: interactiveRegionStore, + onClick: onClick) + } + let height = self.cachedMenuCardHeight( + for: id, + scope: heightCacheScope ?? id, + width: width, + fingerprint: heightCacheFingerprint) + { + self.menuCardHeight(for: hosting, width: width) + } + hosting.frame = NSRect(origin: .zero, size: NSSize(width: width, height: height)) + return self.makeMenuCardNSMenuItem( + hosting: hosting, + id: id, + submenu: submenu, + isEnabled: allowsMenuHighlight || containsInteractiveControls) + } + + /// Wraps a measured hosting view in the `NSMenuItem` the menu installs, wiring submenu routing. + private func makeMenuCardNSMenuItem( + hosting: NSView, + id: String, + submenu: NSMenu?, + isEnabled: Bool) -> NSMenuItem + { + let item = NSMenuItem() + item.view = hosting + item.isEnabled = isEnabled + item.representedObject = id + item.submenu = submenu + if submenu != nil { + item.target = self + item.action = #selector(self.menuCardNoOp(_:)) + } + return item + } + + private func menuCardHeight(for view: NSView, width: CGFloat) -> CGFloat { + let basePadding: CGFloat = 6 + let descenderSafety: CGFloat = 1 + + if let measured = view as? MenuCardMeasuring { + return max(1, ceil(measured.measuredHeight(width: width) + basePadding + descenderSafety)) + } + + view.frame = NSRect(origin: .zero, size: NSSize(width: width, height: 1)) + let fitted = view.fittingSize + return max(1, ceil(fitted.height + basePadding + descenderSafety)) + } +} diff --git a/Sources/CodexBar/StatusItemController+MenuCardModel.swift b/Sources/CodexBar/StatusItemController+MenuCardModel.swift index 1518d12235..6e53de36b4 100644 --- a/Sources/CodexBar/StatusItemController+MenuCardModel.swift +++ b/Sources/CodexBar/StatusItemController+MenuCardModel.swift @@ -2,12 +2,24 @@ import CodexBarCore import Foundation extension StatusItemController { + func makeMenuCardRefreshMonitor() -> MenuCardRefreshMonitor { + MenuCardRefreshMonitor( + resolveModel: { [weak self] provider in + self?.menuCardModel(for: provider) + }, + isProviderRefreshActive: { [weak self] provider in + self?.store.refreshingProviders.contains(provider) == true + }) + } + func menuCardModel( for provider: UsageProvider?, snapshotOverride: UsageSnapshot? = nil, errorOverride: String? = nil, forceOverrideCard: Bool = false, - accountOverride: AccountInfo? = nil) -> UsageMenuCardView.Model? + accountOverride: AccountInfo? = nil, + historySelectionOverride: PlanUtilizationHistorySelection? = nil, + planOverride: String? = nil) -> UsageMenuCardView.Model? { let target = provider ?? self.store.enabledProvidersForDisplay().first ?? .codex let metadata = self.store.metadata(for: target) @@ -23,7 +35,7 @@ extension StatusItemController { let snapshot: UsageSnapshot? = if surface == .overrideCard { snapshotOverride } else { - snapshotOverride ?? self.store.snapshot(for: target) + snapshotOverride ?? self.store.presentationSnapshot(for: target) } let projectedTokenSnapshot = self.store.tokenSnapshot(fromProviderSnapshot: snapshot, provider: target) let storedTokenSnapshot = UsageStore.tokenCostRequiresProviderSnapshot(target) @@ -44,9 +56,11 @@ extension StatusItemController { let tokenError: String? if let codexProjection { credits = codexProjection.credits?.snapshot - creditsError = codexProjection.credits?.userFacingError + // Credits and dashboard collection are optional adjuncts. Keep their setup diagnostics in + // provider Settings so a signed-out browser does not dominate the glanceable menu card. + creditsError = nil dashboard = nil - dashboardError = codexProjection.userFacingErrors.dashboard + dashboardError = nil if surface == .liveCard { tokenSnapshot = projectedTokenSnapshot ?? storedTokenSnapshot tokenError = self.store.tokenError(for: target) @@ -55,7 +69,7 @@ extension StatusItemController { tokenError = nil } } else if ProviderDescriptorRegistry.descriptor(for: target).tokenCost.supportsTokenCost, - snapshotOverride == nil + surface == .liveCard { credits = nil creditsError = nil @@ -72,19 +86,19 @@ extension StatusItemController { tokenError = nil } - let sourceLabel = snapshotOverride == nil ? self.store.sourceLabel(for: target) : nil + let sourceLabel = surface == .liveCard ? self.store.sourceLabel(for: target) : nil let kiloAutoMode = target == .kilo && self.settings.kiloUsageDataSource == .auto - // Abacus uses primary for monthly credits (no secondary window) - let paceWindow = target == .abacus ? snapshot?.primary : snapshot?.secondary - let weeklyPace = if let codexProjection, - let weekly = codexProjection.rateWindow(for: .weekly) - { - self.store.weeklyPace(provider: target, window: weekly, now: now) - } else { - paceWindow.flatMap { window in - self.store.weeklyPace(provider: target, window: window, now: now) - } - } + let (weeklyPace, sessionEquivalentForecast) = self.resolvePaceAndForecast( + target: target, + snapshot: snapshot, + codexProjection: codexProjection, + usesOverrideCard: surface == .overrideCard, + historySelectionOverride: historySelectionOverride, + now: now) + let fallbackAccount = accountOverride + ?? (metadata.usesAccountFallback + ? self.store.accountInfo(for: target) + : AccountInfo(email: nil, plan: nil)) let input = UsageMenuCardView.Model.Input( provider: target, metadata: metadata, @@ -96,28 +110,123 @@ extension StatusItemController { dashboardError: dashboardError, tokenSnapshot: tokenSnapshot, tokenError: tokenError, - account: accountOverride ?? self.store.accountInfo(for: target), - isRefreshing: self.store.shouldShowRefreshingMenuCard(for: target), + account: fallbackAccount, + accountIsAuthoritative: accountOverride != nil, + planOverride: planOverride, + isRefreshing: self.store.shouldShowRefreshingMenuCardIndicator(for: target), + // Provider-level errors can belong to a different account, so + // override cards never inherit them (same rule as the snapshot, + // token-cost, and source-label fallbacks above). lastError: errorOverride ?? codexProjection?.userFacingErrors.usage - ?? self.store.userFacingError(for: target), + ?? (surface == .liveCard ? self.store.userFacingError(for: target) : nil), + limitsAvailability: self.store.knownLimitsAvailability(for: target), usageBarsShowUsed: self.settings.usageBarsShowUsed, resetTimeDisplayStyle: self.settings.resetTimeDisplayStyle, tokenCostUsageEnabled: self.settings.isCostUsageEffectivelyEnabled(for: target), + codexLocalSessionCostLedgerEnabled: self.settings.codexLocalSessionCostLedgerEnabled, + tokenCostInlineDashboardEnabled: self.settings.costSummaryShowsInlineDashboard(for: target), + // openai/mistral's cost history always surfaces via the inline dashboard or a + // dedicated top-pane submenu (see `makeUsageSubmenu`), so they skip the generic + // "Cost" row. This must stay an explicit provider check rather than reusing + // `usesProviderCostHistoryAsPrimaryDashboard` (or `tokenCostRequiresProviderSnapshot`): + // both of those sets are shared with unrelated concerns (inline-dashboard eligibility, + // provider-derived snapshot sourcing) and gain members for reasons that have nothing to + // do with whether this row should show, silently disabling the Cost row for those + // providers too (e.g. groq's addition to the inline-dashboard set previously did this). + tokenCostMenuSectionEnabled: target != .mistral && target != .openai && + self.settings.costSummaryShowsSubmenu(for: target), + costComparisonPeriodsEnabled: self.settings.costComparisonPeriodsEnabled, showOptionalCreditsAndExtraUsage: self.settings.showOptionalCreditsAndExtraUsage, + claudeDailyRoutinesUsageVisible: self.settings.claudeDailyRoutinesUsageVisible, + codexSparkUsageVisible: self.settings.codexSparkUsageVisible, + copilotBudgetExtrasEnabled: self.settings.copilotBudgetExtrasEnabled, sourceLabel: sourceLabel, kiloAutoMode: kiloAutoMode, hidePersonalInfo: self.settings.hidePersonalInfo, weeklyPace: weeklyPace, + sessionEquivalentForecast: sessionEquivalentForecast, quotaWarningThresholds: [ .session: self.quotaWarningMarkerThresholds(provider: target, window: .session), .weekly: self.quotaWarningMarkerThresholds(provider: target, window: .weekly), ], workDaysPerWeek: self.settings.weeklyProgressWorkDays, + usesLiveSubtitle: surface == .liveCard, + preferredCurrencyCode: self.settings.preferredCurrencyCode, now: now) return UsageMenuCardView.Model.make(input) } + // swiftlint:disable:next function_parameter_count + private func resolvePaceAndForecast( + target: UsageProvider, + snapshot: UsageSnapshot?, + codexProjection: CodexConsumerProjection?, + usesOverrideCard: Bool, + historySelectionOverride: PlanUtilizationHistorySelection?, + now: Date) + -> (weeklyPace: UsagePace?, sessionEquivalentForecast: SessionEquivalentForecast?) + { + let paceWindow = target == .abacus || target == .kimi + ? snapshot?.primary : snapshot?.secondary + let historySelection = self.sessionEquivalentHistorySelection( + provider: target, + snapshot: snapshot, + usesOverrideCard: usesOverrideCard, + override: historySelectionOverride) + let weeklyPace = if let codexProjection, + let weekly = codexProjection.rateWindow(for: .weekly) + { + self.store.weeklyPace(provider: target, window: weekly, now: now) + } else { + paceWindow.flatMap { window in + self.store.weeklyPace(provider: target, window: window, now: now) + } + } + let forecast: SessionEquivalentForecast? = if let codexProjection, + let session = codexProjection + .rateWindow(for: .session), + let weekly = codexProjection + .rateWindow(for: .weekly) + { + self.store.sessionEquivalentForecast( + provider: target, + sessionWindow: session, + weeklyWindow: weekly, + historySelection: historySelection, + now: now) + } else if let snapshot, + let windows = self.store.sessionEquivalentWindows( + provider: target, snapshot: snapshot) + { + self.store.sessionEquivalentForecast( + provider: target, + sessionWindow: windows.session, + weeklyWindow: windows.weekly, + weeklyWindowID: windows.weeklyWindowID, + historyIdentity: windows.historyIdentity, + historySelection: historySelection, + now: now) + } else { + nil + } + return (weeklyPace, forecast) + } + + private func sessionEquivalentHistorySelection( + provider: UsageProvider, + snapshot: UsageSnapshot?, + usesOverrideCard: Bool, + override: PlanUtilizationHistorySelection?) -> PlanUtilizationHistorySelection? + { + guard usesOverrideCard else { return nil } + if let override { + return override + } + guard let snapshot else { return .unavailable } + return self.store.planUtilizationHistorySelection(for: provider, snapshotOverride: snapshot) + } + func accountInfo(for account: CodexVisibleAccount) -> AccountInfo { AccountInfo(email: account.email, plan: account.workspaceLabel) } diff --git a/Sources/CodexBar/StatusItemController+MenuCardRecycling.swift b/Sources/CodexBar/StatusItemController+MenuCardRecycling.swift new file mode 100644 index 0000000000..6430984a1f --- /dev/null +++ b/Sources/CodexBar/StatusItemController+MenuCardRecycling.swift @@ -0,0 +1,69 @@ +import AppKit + +extension StatusItemController { + /// Collects the card hosting views of items the current populate pass is about to discard + /// so `makeMenuCardItem` can reuse them for cards with the same identifier (or, failing + /// that, the same content type) instead of building fresh hosting views. + /// + /// Safety: live menu items can alias one merged-switcher cache entry — the one for the + /// selection currently displayed, re-cached at the end of every populate. Consuming that + /// entry up front (`displacedSelection`) guarantees no cache entry can still reference a + /// harvested view; entries for other selections only hold items already detached from the + /// menu. Harvested views are detached from their outgoing items; whatever the pass does + /// not consume is released by `clearMenuCardViewRecyclePool`. + func harvestRecyclableMenuCardViews( + in menu: NSMenu, + fromIndex: Int, + displacedSelection: ProviderSwitcherSelection?, + preserveHighlightedItem: Bool = false) + { + self.menuCardViewRecyclePool.removeAll(keepingCapacity: true) + let menuKey = ObjectIdentifier(menu) + if let displacedSelection { + self.mergedSwitcherContentCaches[menuKey]?.removeValue(forKey: displacedSelection) + } + guard self.menuCardRenderingEnabledForController else { return } + guard fromIndex >= 0, fromIndex < menu.items.count else { return } + for item in menu.items[fromIndex...] { + guard let id = item.representedObject as? String else { continue } + guard let view = item.view, view is any MenuCardMeasuring else { continue } + guard self.menuCardViewRecyclePool[id] == nil else { continue } + // Unhighlight before detaching: the highlight tracker unwinds through the + // outgoing item's `view`, which is about to become nil, so a recycled view + // would otherwise re-attach visibly highlighted with no path to clear it. + if self.highlightedMenuItems[menuKey] === item { + if !preserveHighlightedItem { + self.highlightedMenuItems.removeValue(forKey: menuKey) + } + } + (view as? MenuCardHighlighting)?.setHighlighted(false) + item.view = nil + self.menuCardViewRecyclePool[id] = view + } + } + + /// Pops a pool entry adoptable as `ViewType`: the same card identifier when its view + /// matches, otherwise the first type-compatible leftover. The fallback is what makes + /// provider switches cheap — a different provider's card with a different identifier but + /// the same SwiftUI content type (for example two providers' usage cards) is repainted + /// in place instead of being rebuilt. + func takeRecyclableMenuCardView(for id: String, as type: ViewType.Type) -> ViewType? { + if let candidate = self.menuCardViewRecyclePool.removeValue(forKey: id) { + if let adopted = candidate as? ViewType { + return adopted + } + // A same-id view of an incompatible shape can never be adopted later in this + // pass; dropping it restores the build-fresh behavior. + return nil + } + guard let match = self.menuCardViewRecyclePool.first(where: { $0.value is ViewType }) else { + return nil + } + self.menuCardViewRecyclePool.removeValue(forKey: match.key) + return match.value as? ViewType + } + + func clearMenuCardViewRecyclePool() { + self.menuCardViewRecyclePool.removeAll(keepingCapacity: true) + } +} diff --git a/Sources/CodexBar/StatusItemController+MenuInteractionRefresh.swift b/Sources/CodexBar/StatusItemController+MenuInteractionRefresh.swift new file mode 100644 index 0000000000..77858bb3ca --- /dev/null +++ b/Sources/CodexBar/StatusItemController+MenuInteractionRefresh.swift @@ -0,0 +1,175 @@ +import AppKit +import CodexBarCore +import QuartzCore + +extension StatusItemController { + private static let defaultDeferredMenuInteractionRefreshDelay: Duration = .milliseconds(250) + private static let slowMenuOperationThreshold: TimeInterval = 0.15 + private static let slowChartRenderThreshold: TimeInterval = 0.050 + + #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 + } + + struct MenuOperationTrace { + let operation: String + let startedAt: CFTimeInterval + } + + /// Pairs the slow-operation timing log with a watchdog breadcrumb so a hang during + /// the operation is attributed to it even when the operation never finishes logging. + func beginMenuOperationTrace( + _ operation: String, + breadcrumb: @autoclosure () -> String) -> MenuOperationTrace + { + MainThreadActivityBreadcrumb.push(breadcrumb()) + return MenuOperationTrace(operation: operation, startedAt: CACurrentMediaTime()) + } + + func endMenuOperationTrace(_ trace: MenuOperationTrace, menu: NSMenu, provider: UsageProvider?) { + MainThreadActivityBreadcrumb.pop() + self.logMenuOperationDurationIfSlow( + trace.operation, + startedAt: trace.startedAt, + menu: menu, + provider: provider) + } + + 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 logChartRenderDurationIfSlow(_ label: String, startedAt: CFTimeInterval) { + let elapsed = CACurrentMediaTime() - startedAt + guard elapsed >= Self.slowChartRenderThreshold else { return } + self.menuLogger.warning( + "slow chart render", + metadata: [ + "section": label, + "durationMs": String(format: "%.1f", elapsed * 1000), + ]) + } + + func deferMenuInteractionRefreshIfNeeded(providers: [UsageProvider]) { + guard !self.store.isRefreshing else { return } + self.deferredMenuInteractionRefreshProviders.formUnion(providers) + } + + func clearSatisfiedDeferredMenuInteractionRefreshes(for providers: [UsageProvider]) { + for provider in providers + where !self.store.needsUsageRefreshRetry(for: provider) + { + self.deferredMenuInteractionRefreshProviders.remove(provider) + } + } + + 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 pendingProviders = self.deferredMenuInteractionRefreshProviders + let hasProviderRefreshInFlight = pendingProviders.contains { + self.store.refreshingProviders.contains($0) + } + guard !self.store.isRefreshing, + !self.store.hasForcedRefreshEnrichmentInFlight, + !hasProviderRefreshInFlight + else { + self.deferredMenuInteractionRefreshTask = nil + self.scheduleDeferredMenuInteractionRefreshIfNeeded( + delay: Self.defaultDeferredMenuInteractionRefreshDelay) + return + } + self.clearSatisfiedDeferredMenuInteractionRefreshes(for: Array(pendingProviders)) + 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 + } + self.deferredMenuInteractionRefreshTask = nil + self.deferredMenuInteractionRefreshProviders.removeAll() + 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 52944810f9..abad4320d1 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: "|") @@ -24,6 +25,7 @@ extension StatusItemController { self.lastSwitcherProviders = providers self.lastSwitcherUsageBarsShowUsed = self.settings.usageBarsShowUsed self.lastMergedSwitcherSelection = selection + self.lastMergedMenuContentSelection = selection self.lastSwitcherIncludesOverview = includesOverview self.lastMenuLocalizationSignature = self.menuLocalizationSignature() } diff --git a/Sources/CodexBar/StatusItemController+MenuPresentation.swift b/Sources/CodexBar/StatusItemController+MenuPresentation.swift index f1097a50b5..5ceead77eb 100644 --- a/Sources/CodexBar/StatusItemController+MenuPresentation.swift +++ b/Sources/CodexBar/StatusItemController+MenuPresentation.swift @@ -5,10 +5,12 @@ import SwiftUI extension StatusItemController { func switcherWeeklyRemaining(for provider: UsageProvider) -> Double? { - Self.switcherWeeklyMetricPercent( + let snapshot = self.store.snapshot(for: provider) + return Self.switcherWeeklyMetricPercent( for: provider, - snapshot: self.store.snapshot(for: provider), - showUsed: self.settings.usageBarsShowUsed) + snapshot: snapshot, + showUsed: self.settings.usageBarsShowUsed, + preference: self.settings.menuBarMetricPreference(for: provider, snapshot: snapshot)) } func applySubtitle(_ subtitle: String, to item: NSMenuItem, title: String) { @@ -60,9 +62,16 @@ extension StatusItemController { @MainActor protocol MenuCardHighlighting: AnyObject { + var allowsMenuHighlight: Bool { get } func setHighlighted(_ highlighted: Bool) } +extension MenuCardHighlighting { + var allowsMenuHighlight: Bool { + true + } +} + @MainActor protocol MenuCardMeasuring: AnyObject { func measuredHeight(width: CGFloat) -> CGFloat @@ -75,15 +84,64 @@ final class MenuCardHighlightState { } final class MenuHostingView: NSHostingView { + /// The height AppKit should give this item's menu row. NSMenu reads `intrinsicContentSize` + /// (not the explicit `frame`) when it lays out custom-view rows, so a measured height that + /// only lives in `frame` is silently reverted to the open-time row height — leaving the + /// SwiftUI content centered in a stale, oversized row. Routing the height through the + /// intrinsic size is the channel the menu actually honors. + private var measuredHeight: CGFloat? + override var allowsVibrancy: Bool { true } + + override var intrinsicContentSize: NSSize { + guard let measuredHeight else { return super.intrinsicContentSize } + return NSSize(width: NSView.noIntrinsicMetric, height: measuredHeight) + } + + func applyMeasuredHeight(width: CGFloat, height: CGFloat) { + let resolvedHeight = max(1, ceil(height)) + guard self.measuredHeight != resolvedHeight || self.frame.height != resolvedHeight else { return } + + self.measuredHeight = resolvedHeight + self.frame = NSRect( + origin: self.frame.origin, + size: NSSize(width: width, height: resolvedHeight)) + self.invalidateIntrinsicContentSize() + self.layoutSubtreeIfNeeded() + self.superview?.layoutSubtreeIfNeeded() + } + + /// Measures the true SwiftUI content height at `width`. The cached `measuredHeight` is routed + /// through `intrinsicContentSize`, so `fittingSize` would otherwise echo the stale cached value; + /// clearing it for the measurement lets the live content size drive the result. Used to resize + /// the row exactly when expandable content (e.g. status groups) toggles. + func measuredFittingHeight(width: CGFloat) -> CGFloat { + let saved = self.measuredHeight + self.measuredHeight = nil + self.frame = NSRect(origin: self.frame.origin, size: NSSize(width: width, height: 1)) + self.invalidateIntrinsicContentSize() + self.layoutSubtreeIfNeeded() + let height = self.fittingSize.height + self.measuredHeight = saved + return height + } } @MainActor final class MenuCardItemHostingView: NSHostingView, MenuCardHighlighting, MenuCardMeasuring { - private let highlightState: MenuCardHighlightState - private let onClick: (() -> Void)? + let highlightState: MenuCardHighlightState + private(set) var allowsMenuHighlight: Bool + private var onClick: (() -> Void)? + private var containsInteractiveControls: Bool + let interactiveRegionStore: MenuCardInteractiveRegionStore? + private var isPressed = false + private var isForwardingHostedControlPress = false + #if DEBUG + private var testForwardedHostedControlMouseDown = false + private var testForwardedHostedControlMouseUp = false + #endif override var allowsVibrancy: Bool { true @@ -95,19 +153,61 @@ final class MenuCardItemHostingView: NSHostingView, Menu return NSSize(width: self.frame.width, height: size.height) } - init(rootView: Content, highlightState: MenuCardHighlightState, onClick: (() -> Void)? = nil) { + init( + rootView: Content, + highlightState: MenuCardHighlightState, + allowsMenuHighlight: Bool, + containsInteractiveControls: Bool = false, + interactiveRegionStore: MenuCardInteractiveRegionStore? = nil, + onClick: (() -> Void)? = nil) + { self.highlightState = highlightState + self.allowsMenuHighlight = allowsMenuHighlight + self.containsInteractiveControls = containsInteractiveControls + self.interactiveRegionStore = interactiveRegionStore self.onClick = onClick super.init(rootView: rootView) - if onClick != nil { - let recognizer = NSClickGestureRecognizer(target: self, action: #selector(self.handlePrimaryClick(_:))) - recognizer.buttonMask = 0x1 - self.addGestureRecognizer(recognizer) + } + + /// Reuses this hosting view for a rebuilt card with the same identity: the replaced + /// `rootView` is diffed in place by SwiftUI instead of tearing down and recreating the + /// hosting view and its graph. Callers must construct `rootView` around this view's own + /// `highlightState` so menu hover highlighting keeps driving the rendered content. + func prepareForReuse( + rootView: Content, + allowsMenuHighlight: Bool, + containsInteractiveControls: Bool = false, + onClick: (() -> Void)?) + { + self.rootView = rootView + self.allowsMenuHighlight = allowsMenuHighlight + self.containsInteractiveControls = containsInteractiveControls + self.onClick = onClick + self.isPressed = false + self.isForwardingHostedControlPress = false + } + + /// `NSMenu` tracking consumes keyboard events before they reach a menu item's custom view, so + /// the pointer `onClick` path has no native counterpart for assistive tech. Expose the row as an + /// accessibility button whose press mirrors a click, giving VoiceOver an activation path that runs + /// `onClick` (and therefore keeps the menu open) instead of regressing to mouse-only. + override func accessibilityRole() -> NSAccessibility.Role? { + self.onClick == nil ? super.accessibilityRole() : .button + } + + override func accessibilityPerformPress() -> Bool { + guard let onClick = self.onClick else { + return super.accessibilityPerformPress() } + onClick() + return true } required init(rootView: Content) { self.highlightState = MenuCardHighlightState() + self.allowsMenuHighlight = false + self.containsInteractiveControls = false + self.interactiveRegionStore = nil self.onClick = nil super.init(rootView: rootView) } @@ -121,15 +221,96 @@ final class MenuCardItemHostingView: NSHostingView, Menu true } - @objc private func handlePrimaryClick(_ recognizer: NSClickGestureRecognizer) { - guard recognizer.state == .ended else { return } - self.onClick?() + override func hitTest(_ point: NSPoint) -> NSView? { + let descendant = super.hitTest(point) + if let descendant { + var current: NSView? = descendant + while let view = current, view !== self { + if view is NSButton || view is NSControl { + return descendant + } + current = view.superview + } + if self.hitsHostedInteractiveControl(at: point) { + return descendant + } + if descendant !== self, self.onClick != nil { + return self + } + } + return descendant + } + + private func locationInView(for event: NSEvent) -> NSPoint { + guard self.window != nil else { + return event.locationInWindow + } + return self.convert(event.locationInWindow, from: nil) + } + + override func mouseDown(with event: NSEvent) { + guard event.type == .leftMouseDown, self.onClick != nil else { + super.mouseDown(with: event) + return + } + let localPoint = self.locationInView(for: event) + if self.beginPrimaryPress(at: localPoint) { + #if DEBUG + self.testForwardedHostedControlMouseDown = true + #endif + super.mouseDown(with: event) + } + } + + override func mouseUp(with event: NSEvent) { + guard event.type == .leftMouseUp, self.onClick != nil else { + super.mouseUp(with: event) + return + } + let result = self.endPrimaryPress(at: self.locationInView(for: event)) + if result.forwardToHostedControl { + #if DEBUG + self.testForwardedHostedControlMouseUp = true + #endif + super.mouseUp(with: event) + return + } + if result.invokeRowAction { + self.onClick?() + } + } + + /// Returns whether AppKit should forward the press into a nested SwiftUI control. + private func beginPrimaryPress(at point: NSPoint) -> Bool { + if self.hitsHostedInteractiveControl(at: point) { + self.isForwardingHostedControlPress = true + return true + } + self.isPressed = self.bounds.contains(point) + return false + } + + private func endPrimaryPress(at point: NSPoint) -> (forwardToHostedControl: Bool, invokeRowAction: Bool) { + if self.isForwardingHostedControlPress { + self.isForwardingHostedControlPress = false + return (true, false) + } + defer { self.isPressed = false } + return (false, self.isPressed && self.bounds.contains(point)) + } + + private func hitsHostedInteractiveControl(at point: NSPoint) -> Bool { + self.containsInteractiveControls && + (self.interactiveRegionStore?.contains( + point, + hostingBounds: self.bounds, + fittedSize: self.fittingSize) == true) } func measuredHeight(width: CGFloat) -> CGFloat { - let controller = NSHostingController(rootView: self.rootView) - let measured = controller.sizeThatFits(in: CGSize(width: width, height: .greatestFiniteMagnitude)) - return measured.height + self.frame = NSRect(origin: self.frame.origin, size: NSSize(width: width, height: 1)) + self.layoutSubtreeIfNeeded() + return self.fittingSize.height } func setHighlighted(_ highlighted: Bool) { @@ -138,16 +319,311 @@ final class MenuCardItemHostingView: NSHostingView, Menu } } +@MainActor +final class PersistentRefreshMenuView: NSView, MenuCardHighlighting { + private static let minimumShortcutColumnWidth: CGFloat = 44 + private static let titleShortcutGap: CGFloat = 8 + private static let shortcutReferenceText = "⌘ R" + + private let selectionView = NSVisualEffectView() + private let iconView = NSImageView() + private let titleField: NSTextField + private let shortcutField: NSTextField? + private var isRowHighlighted = false + private var isRowEnabled = true + private var rowHeight = PersistentRefreshRowMetrics.defaults.rowHeight + private var onClick: (() -> Void)? + + override var allowsVibrancy: Bool { + true + } + + override var intrinsicContentSize: NSSize { + NSSize(width: self.frame.width, height: self.rowHeight) + } + + init( + title: String, + systemImageName: String?, + shortcutText: String?, + onClick: (() -> Void)? = nil) + { + self.titleField = NSTextField(labelWithString: title) + self.shortcutField = shortcutText.map(NSTextField.init(labelWithString:)) + self.onClick = onClick + super.init(frame: .zero) + self.setupSelectionView() + self.setupIconView(systemImageName: systemImageName) + self.setupTextFields() + if onClick != nil { + self.installClickRecognizer() + } + self.updateColors() + } + + @available(*, unavailable) + required init?(coder _: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func acceptsFirstMouse(for event: NSEvent?) -> Bool { + true + } + + override func accessibilityRole() -> NSAccessibility.Role? { + self.onClick == nil ? super.accessibilityRole() : .button + } + + override func accessibilityLabel() -> String? { + self.titleField.stringValue + } + + override func isAccessibilityEnabled() -> Bool { + self.isRowEnabled + } + + override func accessibilityPerformPress() -> Bool { + guard self.isRowEnabled else { return false } + guard let onClick = self.onClick else { + return super.accessibilityPerformPress() + } + onClick() + return true + } + + func applySize(width: CGFloat, height: CGFloat) { + self.rowHeight = max(1, ceil(height)) + self.frame = NSRect(origin: .zero, size: NSSize(width: width, height: self.rowHeight)) + self.invalidateIntrinsicContentSize() + self.needsLayout = true + } + + func setHighlighted(_ highlighted: Bool) { + guard self.isRowHighlighted != highlighted else { return } + self.isRowHighlighted = highlighted + self.selectionView.isHidden = !highlighted + self.updateColors() + } + + func setEnabled(_ enabled: Bool) { + guard self.isRowEnabled != enabled else { return } + self.isRowEnabled = enabled + if !enabled { + self.isRowHighlighted = false + self.selectionView.isHidden = true + } + self.updateColors() + } + + override func layout() { + super.layout() + + let metrics = PersistentRefreshRowMetrics.defaults + self.selectionView.frame = self.bounds.insetBy( + dx: metrics.selectionHorizontalInset, + dy: metrics.selectionVerticalInset) + self.selectionView.layer?.cornerRadius = metrics.selectionCornerRadius + + var leadingX = metrics.leadingPadding + if self.iconView.image != nil { + let iconSide = metrics.iconWidth + self.iconView.symbolConfiguration = Self.iconConfiguration(for: metrics) + self.iconView.frame = NSRect( + x: leadingX, + y: floor((self.bounds.height - iconSide) / 2), + width: iconSide, + height: iconSide) + leadingX += metrics.iconWidth + metrics.iconTitleSpacing + } + + var titleMaxX = self.bounds.maxX - metrics.trailingPadding + if let shortcutField { + shortcutField.font = Self.shortcutFont(for: metrics) + let shortcutSize = shortcutField.intrinsicContentSize + let referenceWidth = Self.shortcutReferenceWidth(for: metrics) + let shortcutColumnWidth = max(Self.minimumShortcutColumnWidth, referenceWidth, shortcutSize.width) + let shortcutX = self.bounds.maxX + - metrics.trailingPadding + + metrics.shortcutXOffset + - referenceWidth + shortcutField.frame = NSRect( + x: shortcutX, + y: floor((self.bounds.height - shortcutSize.height) / 2) + metrics.shortcutYOffset, + width: shortcutColumnWidth, + height: shortcutSize.height) + titleMaxX = shortcutX - Self.titleShortcutGap + } + + let titleSize = self.titleField.intrinsicContentSize + self.titleField.frame = NSRect( + x: leadingX, + y: floor((self.bounds.height - titleSize.height) / 2), + width: max(0, titleMaxX - leadingX), + height: titleSize.height) + } + + private func setupSelectionView() { + self.selectionView.material = .selection + self.selectionView.blendingMode = .withinWindow + self.selectionView.state = .active + self.selectionView.isEmphasized = true + self.selectionView.isHidden = true + self.selectionView.wantsLayer = true + self.selectionView.layer?.masksToBounds = true + self.addSubview(self.selectionView) + } + + private func setupIconView(systemImageName: String?) { + guard let systemImageName, + let baseImage = NSImage(systemSymbolName: systemImageName, accessibilityDescription: nil) + else { + self.iconView.isHidden = true + return + } + + baseImage.isTemplate = true + self.iconView.image = baseImage + self.iconView.symbolConfiguration = Self.iconConfiguration(for: PersistentRefreshRowMetrics.defaults) + self.iconView.imageScaling = .scaleProportionallyDown + self.iconView.contentTintColor = .labelColor + self.addSubview(self.iconView) + } + + private func setupTextFields() { + // Title truncates, shortcut clips; configuring them separately keeps the shortcut column stable. + self.titleField.font = NSFont.menuFont(ofSize: 0) + self.configureTitleField(self.titleField) + + if let shortcutField { + shortcutField.font = Self.shortcutFont(for: PersistentRefreshRowMetrics.defaults) + self.configureShortcutField(shortcutField) + } + } + + private func configureTitleField(_ field: NSTextField) { + field.lineBreakMode = .byTruncatingTail + field.maximumNumberOfLines = 1 + field.allowsDefaultTighteningForTruncation = true + field.backgroundColor = .clear + self.addSubview(field) + } + + private func configureShortcutField(_ field: NSTextField) { + field.alignment = .left + field.lineBreakMode = .byClipping + field.maximumNumberOfLines = 1 + field.allowsDefaultTighteningForTruncation = false + field.backgroundColor = .clear + self.addSubview(field) + } + + private func installClickRecognizer() { + let recognizer = NSClickGestureRecognizer(target: self, action: #selector(self.handlePrimaryClick(_:))) + recognizer.buttonMask = 0x1 + self.addGestureRecognizer(recognizer) + } + + private func updateColors() { + guard self.isRowEnabled else { + self.titleField.textColor = .disabledControlTextColor + self.shortcutField?.textColor = .disabledControlTextColor + self.iconView.contentTintColor = .disabledControlTextColor + return + } + + if self.isRowHighlighted { + self.titleField.textColor = .selectedMenuItemTextColor + self.shortcutField?.textColor = .selectedMenuItemTextColor + self.iconView.contentTintColor = .selectedMenuItemTextColor + return + } + + self.titleField.textColor = .labelColor + self.shortcutField?.textColor = .tertiaryLabelColor + self.iconView.contentTintColor = .labelColor + } + + private static func iconConfiguration(for metrics: PersistentRefreshRowMetrics) -> NSImage.SymbolConfiguration { + NSImage.SymbolConfiguration(pointSize: metrics.iconSymbolPointSize, weight: metrics.iconSymbolWeight) + } + + private static func shortcutFont(for metrics: PersistentRefreshRowMetrics) -> NSFont { + NSFont.menuFont(ofSize: metrics.shortcutFontSize) + } + + private static func shortcutReferenceWidth(for metrics: PersistentRefreshRowMetrics) -> CGFloat { + (self.shortcutReferenceText as NSString).size(withAttributes: [ + .font: self.shortcutFont(for: metrics), + ]).width + } + + @objc private func handlePrimaryClick(_ recognizer: NSClickGestureRecognizer) { + guard recognizer.state == .ended else { return } + guard self.isRowEnabled else { return } + self.onClick?() + } +} + +#if DEBUG +extension MenuCardItemHostingView { + var _test_forwardedHostedControlEvents: (mouseDown: Bool, mouseUp: Bool) { + (self.testForwardedHostedControlMouseDown, self.testForwardedHostedControlMouseUp) + } + + func _test_hitsHostedInteractiveControl(at point: NSPoint) -> Bool { + self.hitsHostedInteractiveControl(at: point) + } + + func _test_simulateRuntimeClick(at point: NSPoint? = nil) -> Bool { + let clickPoint = point ?? NSPoint(x: self.bounds.midX, y: self.bounds.midY) + guard let onClick = self.onClick else { return false } + guard !self.beginPrimaryPress(at: clickPoint) else { + _ = self.endPrimaryPress(at: clickPoint) + return false + } + let result = self.endPrimaryPress(at: clickPoint) + guard result.invokeRowAction else { return false } + onClick() + return true + } +} +#endif + struct MenuCardSectionContainerView: View { @Bindable var highlightState: MenuCardHighlightState let showsSubmenuIndicator: Bool let submenuIndicatorAlignment: Alignment let submenuIndicatorTopPadding: CGFloat + var refreshMonitor: MenuCardRefreshMonitor? + var interactiveRegionStore: MenuCardInteractiveRegionStore? @ViewBuilder let content: () -> Content + init( + highlightState: MenuCardHighlightState, + showsSubmenuIndicator: Bool, + submenuIndicatorAlignment: Alignment, + submenuIndicatorTopPadding: CGFloat, + refreshMonitor: MenuCardRefreshMonitor?, + interactiveRegionStore: MenuCardInteractiveRegionStore? = nil, + @ViewBuilder content: @escaping () -> Content) + { + self.highlightState = highlightState + self.showsSubmenuIndicator = showsSubmenuIndicator + self.submenuIndicatorAlignment = submenuIndicatorAlignment + self.submenuIndicatorTopPadding = submenuIndicatorTopPadding + self.refreshMonitor = refreshMonitor + self.interactiveRegionStore = interactiveRegionStore + self.content = content + } + var body: some View { self.content() .environment(\.menuItemHighlighted, self.highlightState.isHighlighted) + .environment(\.menuCardRefreshMonitor, self.refreshMonitor) + .coordinateSpace(name: MenuCardInteractiveRegionPreferenceKey.coordinateSpaceName) + .onPreferenceChange(MenuCardInteractiveRegionPreferenceKey.self) { regions in + self.interactiveRegionStore?.regions = regions + } .foregroundStyle(MenuHighlightStyle.primary(self.highlightState.isHighlighted)) .background(alignment: .topLeading) { if self.highlightState.isHighlighted { @@ -170,121 +646,40 @@ struct MenuCardSectionContainerView: View { } @MainActor -final class PersistentMenuActionItemView: NSView, MenuCardHighlighting { - static let rowHeight: CGFloat = 28 - - private let backgroundView = NSView() - private let imageView = NSImageView() - private let titleField: NSTextField - private let shortcutField: NSTextField - private let onClick: () -> Void - - override var intrinsicContentSize: NSSize { - NSSize(width: self.frame.width > 0 ? self.frame.width : NSView.noIntrinsicMetric, height: Self.rowHeight) - } - - override var fittingSize: NSSize { - NSSize(width: self.frame.width, height: Self.rowHeight) - } - - override func setFrameSize(_ newSize: NSSize) { - super.setFrameSize(NSSize(width: newSize.width, height: Self.rowHeight)) - } - - init( - title: String, - systemImageName: String?, - shortcutText: String?, - width: CGFloat, - onClick: @escaping () -> Void) - { - self.titleField = NSTextField(labelWithString: title) - self.shortcutField = NSTextField(labelWithString: shortcutText ?? "") - self.onClick = onClick - super.init(frame: NSRect(origin: .zero, size: NSSize(width: width, height: Self.rowHeight))) - self.setupView(systemImageName: systemImageName) - self.setHighlighted(false) +@Observable +final class MenuCardInteractiveRegionStore { + var regions: [CGRect] = [] + + func contains(_ point: CGPoint, hostingBounds: CGRect, fittedSize: CGSize) -> Bool { + // NSHostingView centers intrinsic-height SwiftUI content in the menu item's padded frame. + // Preference rectangles use the SwiftUI root's coordinates, so remove that AppKit offset. + let contentOrigin = CGPoint( + x: max(0, (hostingBounds.width - fittedSize.width) / 2), + y: max(0, (hostingBounds.height - fittedSize.height) / 2)) + let contentPoint = CGPoint(x: point.x - contentOrigin.x, y: point.y - contentOrigin.y) + return self.regions.contains { $0.contains(contentPoint) } } +} - @available(*, unavailable) - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } +struct MenuCardInteractiveRegionPreferenceKey: PreferenceKey { + static let coordinateSpaceName = "MenuCardInteractiveRegion" + static let defaultValue: [CGRect] = [] - override func acceptsFirstMouse(for event: NSEvent?) -> Bool { - true - } - - override func mouseUp(with event: NSEvent) { - guard event.type == .leftMouseUp else { return } - self.onClick() + static func reduce(value: inout [CGRect], nextValue: () -> [CGRect]) { + value.append(contentsOf: nextValue()) } +} - func setHighlighted(_ highlighted: Bool) { - let primaryColor = highlighted ? NSColor.selectedMenuItemTextColor : NSColor.controlTextColor - let secondaryColor = highlighted ? NSColor.selectedMenuItemTextColor : NSColor.secondaryLabelColor - self.backgroundView.isHidden = !highlighted - self.titleField.textColor = primaryColor - self.shortcutField.textColor = secondaryColor - self.imageView.contentTintColor = primaryColor - } - - private func setupView(systemImageName: String?) { - self.backgroundView.wantsLayer = true - self.backgroundView.layer?.cornerRadius = 6 - self.backgroundView.layer?.backgroundColor = NSColor.selectedContentBackgroundColor.cgColor - self.backgroundView.translatesAutoresizingMaskIntoConstraints = false - self.addSubview(self.backgroundView) - - if let systemImageName, - let image = NSImage(systemSymbolName: systemImageName, accessibilityDescription: nil) - { - image.isTemplate = true - image.size = NSSize(width: 16, height: 16) - self.imageView.image = image +extension View { + func menuCardInteractiveControl(isEnabled: Bool = true) -> some View { + self.background { + GeometryReader { proxy in + Color.clear.preference( + key: MenuCardInteractiveRegionPreferenceKey.self, + value: isEnabled + ? [proxy.frame(in: .named(MenuCardInteractiveRegionPreferenceKey.coordinateSpaceName))] + : []) + } } - self.imageView.translatesAutoresizingMaskIntoConstraints = false - - self.titleField.font = NSFont.menuFont(ofSize: NSFont.systemFontSize) - self.titleField.lineBreakMode = .byTruncatingTail - self.titleField.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) - self.titleField.translatesAutoresizingMaskIntoConstraints = false - - self.shortcutField.font = NSFont.menuFont(ofSize: NSFont.smallSystemFontSize) - self.shortcutField.alignment = .right - self.shortcutField.lineBreakMode = .byTruncatingTail - self.shortcutField.setContentHuggingPriority(.required, for: .horizontal) - self.shortcutField.setContentCompressionResistancePriority(.required, for: .horizontal) - self.shortcutField.translatesAutoresizingMaskIntoConstraints = false - - let spacer = NSView() - spacer.translatesAutoresizingMaskIntoConstraints = false - spacer.setContentHuggingPriority(.defaultLow, for: .horizontal) - - let stack = NSStackView() - stack.orientation = .horizontal - stack.alignment = .centerY - stack.spacing = 8 - stack.translatesAutoresizingMaskIntoConstraints = false - stack.addArrangedSubview(self.imageView) - stack.addArrangedSubview(self.titleField) - stack.addArrangedSubview(spacer) - stack.addArrangedSubview(self.shortcutField) - self.addSubview(stack) - - NSLayoutConstraint.activate([ - self.backgroundView.leadingAnchor.constraint(equalTo: self.leadingAnchor, constant: 6), - self.backgroundView.trailingAnchor.constraint(equalTo: self.trailingAnchor, constant: -6), - self.backgroundView.topAnchor.constraint(equalTo: self.topAnchor, constant: 2), - self.backgroundView.bottomAnchor.constraint(equalTo: self.bottomAnchor, constant: -2), - - self.imageView.widthAnchor.constraint(equalToConstant: 18), - self.imageView.heightAnchor.constraint(equalToConstant: 18), - self.shortcutField.widthAnchor.constraint(equalToConstant: 38), - - stack.leadingAnchor.constraint(equalTo: self.leadingAnchor, constant: 12), - stack.trailingAnchor.constraint(equalTo: self.trailingAnchor, constant: -12), - stack.centerYAnchor.constraint(equalTo: self.centerYAnchor), - ]) } } diff --git a/Sources/CodexBar/StatusItemController+MenuReconcile.swift b/Sources/CodexBar/StatusItemController+MenuReconcile.swift new file mode 100644 index 0000000000..6fc7cb02b4 --- /dev/null +++ b/Sources/CodexBar/StatusItemController+MenuReconcile.swift @@ -0,0 +1,221 @@ +import AppKit + +/// Pre-harvest snapshot of one live content row, captured before card views are detached +/// into the recycle pool so reconciliation can still compare row shapes afterwards. +struct MenuRowShape { + let isSeparator: Bool + let requiresNativeImageReplacement: Bool + let id: String? + let viewClassName: String? +} + +extension StatusItemController { + func menuContentShapes(in menu: NSMenu, fromIndex: Int) -> [MenuRowShape] { + guard fromIndex >= 0, fromIndex <= menu.items.count else { return [] } + return menu.items[fromIndex...].map { item in + MenuRowShape( + isSeparator: item.isSeparatorItem, + requiresNativeImageReplacement: self.shouldReplaceNativeImageItemDuringReconcile(item), + id: item.representedObject as? String, + viewClassName: item.view.map { String(describing: type(of: $0)) }) + } + } + + /// Identifies leaf AppKit image items that should be replaced instead of updated in place. + /// + /// AppKit can retain stale layout state for standard image-backed menu items after repeated + /// in-place updates, which makes rows such as "Status Page" drift horizontally. Submenu rows + /// stay on the normal reconciliation path because replacing their parent item can disturb an + /// active submenu. + private func shouldReplaceNativeImageItemDuringReconcile(_ item: NSMenuItem) -> Bool { + !item.isSeparatorItem && item.view == nil && item.image != nil && item.submenu == nil + } + + /// Position-wise in-place reconciliation: live rows whose shape matches the freshly + /// built content (separator placement, card identifier, view class) are updated in + /// place — views transplanted, plain rows recopied — and only the mismatched middle + /// span is removed and reinserted. Matching runs from both ends, so the expensive card + /// rows at the top and the shared action rows at the bottom survive even a provider + /// switch whose middle sections differ; AppKit then relayouts the open tracked menu for + /// the few changed rows instead of once per row. + func reconcileMenuContent( + _ menu: NSMenu, + fromIndex: Int, + shapes: [MenuRowShape], + with scratch: NSMenu) + { + defer { self.finishReconciledHighlightTracking(in: menu) } + let newItems = scratch.items + scratch.removeAllItems() + guard menu.items.count - fromIndex == shapes.count else { + // The live region changed underneath the snapshot; replace it wholesale. + self.replaceMenuContent(menu, fromIndex: fromIndex, with: newItems) + return + } + + func updatable(_ shape: MenuRowShape, _ newItem: NSMenuItem) -> Bool { + guard shape.isSeparator == newItem.isSeparatorItem else { return false } + if shape.isSeparator { return true } + guard !shape.requiresNativeImageReplacement, + !self.shouldReplaceNativeImageItemDuringReconcile(newItem) + else { return false } + guard shape.id == newItem.representedObject as? String else { return false } + return shape.viewClassName == newItem.view.map { String(describing: type(of: $0)) } + } + + var prefix = 0 + while prefix < min(shapes.count, newItems.count), updatable(shapes[prefix], newItems[prefix]) { + prefix += 1 + } + var suffix = 0 + while suffix < min(shapes.count, newItems.count) - prefix, + updatable(shapes[shapes.count - 1 - suffix], newItems[newItems.count - 1 - suffix]) + { + suffix += 1 + } + + for offset in 0.. [NSMenuItem] + { + guard fromIndex >= 0, fromIndex <= menu.items.count else { return [] } + defer { self.finishReconciledHighlightTracking(in: menu) } + + let liveItems = Array(menu.items[fromIndex...]) + let liveCount = liveItems.count + let sharedCount = min(liveCount, newItems.count) + var displacedItems: [NSMenuItem] = [] + displacedItems.reserveCapacity(liveCount) + for offset in 0.. liveCount { + for offset in liveCount.. newItems.count { + for offset in newItems.count.. fromIndex { + menu.removeItem(at: fromIndex) + } + for item in newItems { + menu.addItem(item) + } + } + + private func updateMenuItemInPlace(_ liveItem: NSMenuItem, from newItem: NSMenuItem) { + if liveItem.isSeparatorItem { return } + let remainsHighlighted = liveItem.menu.map { + self.highlightedMenuItems[ObjectIdentifier($0)] === liveItem + } ?? false + // Detach from the scratch item first so a view or submenu is never referenced by + // two menu items at once. + let view = newItem.view + newItem.view = nil + let submenu = newItem.submenu + newItem.submenu = nil + liveItem.view = view + liveItem.submenu = submenu + liveItem.title = newItem.title + liveItem.attributedTitle = newItem.attributedTitle + liveItem.action = newItem.action + liveItem.target = newItem.target + liveItem.representedObject = newItem.representedObject + liveItem.state = newItem.state + liveItem.isEnabled = newItem.isEnabled + let allowsHighlight = (view as? MenuCardHighlighting)?.allowsMenuHighlight != false + (view as? MenuCardHighlighting)?.setHighlighted(newItem.isEnabled && allowsHighlight && remainsHighlighted) + liveItem.image = newItem.image + liveItem.toolTip = newItem.toolTip + liveItem.keyEquivalent = newItem.keyEquivalent + liveItem.keyEquivalentModifierMask = newItem.keyEquivalentModifierMask + liveItem.indentationLevel = newItem.indentationLevel + liveItem.tag = newItem.tag + liveItem.identifier = newItem.identifier + liveItem.isHidden = newItem.isHidden + liveItem.isAlternate = newItem.isAlternate + liveItem.allowsKeyEquivalentWhenHidden = newItem.allowsKeyEquivalentWhenHidden + liveItem.onStateImage = newItem.onStateImage + liveItem.offStateImage = newItem.offStateImage + liveItem.mixedStateImage = newItem.mixedStateImage + if #available(macOS 14.4, *) { + liveItem.subtitle = newItem.subtitle + } + if self.isPersistentRefreshItem(liveItem) { + self.persistentRefreshItems.add(liveItem) + } + } + + private func swapMenuItemContents(_ liveItem: NSMenuItem, _ cachedItem: NSMenuItem) { + let holder = NSMenuItem() + self.updateMenuItemInPlace(holder, from: liveItem) + self.updateMenuItemInPlace(liveItem, from: cachedItem) + self.updateMenuItemInPlace(cachedItem, from: holder) + } +} diff --git a/Sources/CodexBar/StatusItemController+MenuRefreshScheduling.swift b/Sources/CodexBar/StatusItemController+MenuRefreshScheduling.swift index 594aa483d4..6f8adbcdef 100644 --- a/Sources/CodexBar/StatusItemController+MenuRefreshScheduling.swift +++ b/Sources/CodexBar/StatusItemController+MenuRefreshScheduling.swift @@ -3,6 +3,220 @@ import CodexBarCore import QuartzCore extension StatusItemController { + private static let providerSwitcherMenuRebuildDebounceNanoseconds: UInt64 = 0 + + private struct ScheduledOpenMenuRebuild { + let provider: UsageProvider? + let shouldCloseHostedSubviewMenus: Bool + let beforeRebuild: (@MainActor () -> Bool)? + } + + func didMenuAdjunctReadinessChange() -> Bool { + let signature = self.menuAdjunctReadinessSignature() + defer { self.recordMenuAdjunctReadinessBaseline(signature) } + return signature != self.lastMenuAdjunctReadinessSignature + } + + /// Resyncs the readiness baseline to the data the menu was just built from. + /// + /// Because the baseline is no longer recomputed on every store change while all menus are closed, + /// it can drift from the live store state. When a root menu opens and is actually rebuilt (or is + /// already fresh for the current `menuContentVersion`), the baseline must be re-anchored here; + /// otherwise a later open-menu store change that happens to revert to the stale baseline value would + /// be treated as "unchanged" and skip a needed rebuild, leaving the visible menu showing the older + /// content. Callers must **not** invoke this when `refreshMenuForOpenIfNeeded` preserved stale + /// content during an in-flight refresh — that would record live store data while the visible menu + /// still shows older content and mask the refresh-completion update. + func resyncMenuAdjunctReadinessBaseline() { + self.recordMenuAdjunctReadinessBaseline(self.menuAdjunctReadinessSignature()) + } + + /// Resyncs a root-menu baseline after open and handles the narrow race where a store change + /// has updated live data but its deferred observation task has not invalidated menus yet. + /// + /// If a previously fresh menu sees new live data before the observer version tick, invalidate all + /// menus first and rebuild only the opened menu. The matching observer can then skip the expensive + /// readiness comparison while still invalidating menu-observed state that is not in the signature. + func resyncMenuAdjunctReadinessBaselineForRootOpen( + _ menu: NSMenu, + provider: UsageProvider?, + menuWasFreshBeforeOpen: Bool) + { + let signature = self.menuAdjunctReadinessSignature() + let menuKey = ObjectIdentifier(menu) + let menuRenderedCurrentSignature = + self.menuSession.renderedVersion(for: menuKey) == self.menuSession.contentVersion && + self.menuReadinessSignatures[menuKey] == signature + guard signature != self.lastMenuAdjunctReadinessSignature else { + guard menuWasFreshBeforeOpen, !menuRenderedCurrentSignature else { + self.lastMenuAdjunctReadinessBaselineVersion = self.menuSession.contentVersion + return + } + guard !self.isMenuDataRefreshInFlight else { return } + self.invalidateMenus() + self.populateMenu(menu, provider: provider) + self.markMenuFresh(menu) + self.rememberRootOpenHandledMenuObservation(signature: signature) + self.recordMenuAdjunctReadinessBaseline(signature) + return + } + + if menuWasFreshBeforeOpen { + if self.isMenuDataRefreshInFlight, !menuRenderedCurrentSignature { + return + } + if menuRenderedCurrentSignature { + self.recordMenuAdjunctReadinessBaseline(signature) + return + } + self.invalidateMenus() + self.populateMenu(menu, provider: provider) + self.markMenuFresh(menu) + self.rememberRootOpenHandledMenuObservation(signature: signature) + } + self.recordMenuAdjunctReadinessBaseline(signature) + } + + private func recordMenuAdjunctReadinessBaseline(_ signature: String) { + self.lastMenuAdjunctReadinessSignature = signature + self.lastMenuAdjunctReadinessBaselineVersion = self.menuSession.contentVersion + } + + private func rememberRootOpenHandledMenuObservation(signature: String) { + self.rootOpenHandledMenuObservationSignature = signature + Task { @MainActor [weak self] in + await Task.yield() + if self?.rootOpenHandledMenuObservationSignature == signature { + self?.rootOpenHandledMenuObservationSignature = nil + } + } + } + + func consumeRootOpenHandledMenuObservationIfNeeded() -> Bool { + guard let handledSignature = self.rootOpenHandledMenuObservationSignature else { return false } + let signature = self.menuAdjunctReadinessSignature() + guard signature == handledSignature else { + self.rootOpenHandledMenuObservationSignature = nil + return false + } + self.rootOpenHandledMenuObservationSignature = nil + self.recordMenuAdjunctReadinessBaseline(signature) + return true + } + + func menuAdjunctReadinessSignature() -> String { + let dashboard = self.store.openAIDashboard + let dashboardUsageBreakdown = OpenAIDashboardDailyBreakdown.removingSkillUsageServices( + from: dashboard?.usageBreakdown ?? []) + var parts = [ + "costEnabled=\(self.settings.costUsageEnabled ? "1" : "0")", + "codexLocalCost=\(self.settings.codexLocalSessionCostLedgerEnabled ? "1" : "0")", + "costStyle=\(self.settings.costSummaryDisplayStyle.rawValue)", + "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)", + "claudeSwapRevision=\(self.store.claudeSwapRevision)", + ] + + 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)", + "statusComponents=\(self.statusComponentsRenderSignature(for: provider))", + "refreshing=\(self.store.shouldShowRefreshingMenuCardIndicator(for: provider) ? "1" : "0")", + "usageHistory=\(usageHistoryVisible ? "1" : "0")", + ].joined(separator: ":")) + } + + return parts.joined(separator: "|") + } + + 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: ";") + let projects = snapshot.projects + .map { project in + let sources = project.sources + .map { source in + [ + source.name, + source.path ?? "", + "\(source.totalTokens ?? -1)", + Self.formatOptionalDoubleForSignature(source.totalCostUSD), + ].joined(separator: ",") + } + .joined(separator: "|") + return [ + project.name, + project.path ?? "", + "\(project.totalTokens ?? -1)", + Self.formatOptionalDoubleForSignature(project.totalCostUSD), + sources, + ].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)", + "projects=\(projects)", + ].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) + } + + /// The signature is only ever compared for equality against the previous signature, so it does + /// not need a human-readable decimal form. `String(format: "%.8f", …)` is a surprisingly hot + /// cost here because it runs for every daily/service value across every enabled provider on each + /// store mutation. The raw bit pattern is both exact (no rounding collisions) and far cheaper. + private static func formatDoubleForSignature(_ value: Double) -> String { + String(value.bitPattern, radix: 16) + } + func performMenuMutationWithoutAnimation(_ updates: () -> Void) { CATransaction.begin() CATransaction.setDisableActions(true) @@ -13,30 +227,79 @@ extension StatusItemController { func deferSwitcherMenuRebuildIfStillVisible(_ menu: NSMenu, provider: UsageProvider?) { self.providerSwitcherUpdateToken &+= 1 let updateToken = self.providerSwitcherUpdateToken + #if DEBUG + let debounceNanoseconds = self._test_providerSwitcherMenuRebuildDebounceNanoseconds ?? ( + self._test_openMenuRebuildObserver == nil ? Self.providerSwitcherMenuRebuildDebounceNanoseconds : 0) + #else + let debounceNanoseconds = Self.providerSwitcherMenuRebuildDebounceNanoseconds + #endif + #if DEBUG + let usesTaskSchedulerForTesting = self._test_openMenuRefreshYieldOverride != nil + || self._test_openMenuRebuildObserver != nil + #else + let usesTaskSchedulerForTesting = false + #endif + if debounceNanoseconds == 0, !usesTaskSchedulerForTesting { + self.scheduleProviderSwitcherTrackingMenuRebuildIfStillVisible( + menu, + provider: provider) + { [weak self] in + guard let self else { return false } + return self.providerSwitcherUpdateToken == updateToken + } + return + } self.scheduleOpenMenuRebuildIfStillVisible( menu, provider: provider, - closeHostedSubviewMenusBeforeRebuild: true) + closeHostedSubviewMenusBeforeRebuild: true, + debounceNanoseconds: debounceNanoseconds) { [weak self] in guard let self else { return false } return self.providerSwitcherUpdateToken == updateToken } } + private func scheduleProviderSwitcherTrackingMenuRebuildIfStillVisible( + _ menu: NSMenu, + provider: UsageProvider?, + beforeRebuild: @escaping @MainActor () -> Bool) + { + let key = ObjectIdentifier(menu) + self.openMenuRebuildsClosingHostedSubviewMenus.insert(key) + let rebuildToken = self.openMenuRebuildRequests.replaceRequest(for: key) + self.openMenuRebuildTasks.removeValue(forKey: key)?.cancel() + + ProviderSwitcherTrackingRunLoopScheduler.schedule { [weak self, weak menu] in + guard let self, let menu else { return } + self.performScheduledOpenMenuRebuild( + menu, + key: key, + rebuildToken: rebuildToken, + request: ScheduledOpenMenuRebuild( + provider: provider, + shouldCloseHostedSubviewMenus: true, + beforeRebuild: beforeRebuild)) + } + } + func scheduleOpenMenuRebuildIfStillVisible( _ menu: NSMenu, provider: UsageProvider?, closeHostedSubviewMenusBeforeRebuild: Bool = false, + resyncReadinessBaselineAfterRebuild: Bool = false, + debounceNanoseconds: UInt64 = 0, beforeRebuild: (@MainActor () -> Bool)? = nil) { let key = ObjectIdentifier(menu) + if resyncReadinessBaselineAfterRebuild { + self.pendingMenuBaselineResyncs.insert(key) + } if closeHostedSubviewMenusBeforeRebuild { self.openMenuRebuildsClosingHostedSubviewMenus.insert(key) } let shouldCloseHostedSubviewMenus = self.openMenuRebuildsClosingHostedSubviewMenus.contains(key) - self.openMenuRebuildTokenCounter &+= 1 - let rebuildToken = self.openMenuRebuildTokenCounter - self.openMenuRebuildTokens[key] = rebuildToken + let rebuildToken = self.openMenuRebuildRequests.replaceRequest(for: key) self.openMenuRebuildTasks[key]?.cancel() self.openMenuRebuildTasks[key] = Task { @MainActor [weak self, weak menu] in guard let self, let menu else { return } @@ -49,21 +312,43 @@ extension StatusItemController { #else await Task.yield() #endif - guard !Task.isCancelled else { return } - guard self.openMenuRebuildTokens[key] == rebuildToken else { return } - defer { - if self.openMenuRebuildTokens[key] == rebuildToken { - self.openMenuRebuildTasks.removeValue(forKey: key) - self.openMenuRebuildTokens.removeValue(forKey: key) - self.openMenuRebuildsClosingHostedSubviewMenus.remove(key) - } + if debounceNanoseconds > 0 { + try? await Task.sleep(nanoseconds: debounceNanoseconds) } - guard self.openMenus[key] != nil else { return } - guard beforeRebuild?() ?? true else { return } - if shouldCloseHostedSubviewMenus { - self.closeHostedSubviewMenusForParentSwitch() + guard !Task.isCancelled else { return } + self.performScheduledOpenMenuRebuild( + menu, + key: key, + rebuildToken: rebuildToken, + request: ScheduledOpenMenuRebuild( + provider: provider, + shouldCloseHostedSubviewMenus: shouldCloseHostedSubviewMenus, + beforeRebuild: beforeRebuild)) + } + } + + private func performScheduledOpenMenuRebuild( + _ menu: NSMenu, + key: ObjectIdentifier, + rebuildToken: Int, + request: ScheduledOpenMenuRebuild) + { + guard self.openMenuRebuildRequests.isCurrent(rebuildToken, for: key) else { return } + defer { + if self.openMenuRebuildRequests.finish(rebuildToken, for: key) { + self.openMenuRebuildTasks.removeValue(forKey: key) + self.openMenuRebuildsClosingHostedSubviewMenus.remove(key) } - self.rebuildOpenMenuIfStillVisible(menu, provider: provider) + } + guard self.openMenus[key] != nil else { return } + guard request.beforeRebuild?() ?? true else { return } + if request.shouldCloseHostedSubviewMenus { + self.closeHostedSubviewMenusForParentSwitch() + } + self.rebuildOpenMenuIfStillVisible(menu, provider: request.provider) + if self.pendingMenuBaselineResyncs.contains(key), !self.menuNeedsRefresh(menu) { + self.pendingMenuBaselineResyncs.remove(key) + self.resyncMenuAdjunctReadinessBaseline() } } diff --git a/Sources/CodexBar/StatusItemController+MenuRowReordering.swift b/Sources/CodexBar/StatusItemController+MenuRowReordering.swift new file mode 100644 index 0000000000..cdd28589de --- /dev/null +++ b/Sources/CodexBar/StatusItemController+MenuRowReordering.swift @@ -0,0 +1,47 @@ +import AppKit + +extension StatusItemController { + func addUsageHistoryClusterIfNeeded(to menu: NSMenu, context: MenuCardContext) { + if self.addUsageHistoryMenuItemIfNeeded( + to: menu, + provider: context.currentProvider, + width: context.menuWidth) + { + self.moveCostAndStorageRowsUnderUsageHistory(in: menu) + menu.addItem(.separator()) + } + } + + func moveCostAndStorageRowsUnderUsageHistory(in menu: NSMenu) { + guard let usageHistoryItem = menu.items.first(where: { + ($0.representedObject as? String) == "usageHistorySubmenu" + }) else { return } + + let rowIDs = ["menuCardCost", "menuCardStorage"] + let rowsToMove = rowIDs.compactMap { rowID in + menu.items.first { ($0.representedObject as? String) == rowID } + } + guard !rowsToMove.isEmpty else { return } + + for item in rowsToMove { + menu.removeItem(item) + } + + guard let usageHistoryIndex = menu.items.firstIndex(where: { $0 === usageHistoryItem }) else { return } + for (offset, item) in rowsToMove.enumerated() { + menu.insertItem(item, at: min(usageHistoryIndex + 1 + offset, menu.items.count)) + } + self.collapseAdjacentSeparators(in: menu) + } + + private func collapseAdjacentSeparators(in menu: NSMenu) { + var index = 1 + while index < menu.items.count { + if menu.items[index - 1].isSeparatorItem, menu.items[index].isSeparatorItem { + menu.removeItem(at: index) + } else { + index += 1 + } + } + } +} diff --git a/Sources/CodexBar/StatusItemController+MenuSmartUpdate.swift b/Sources/CodexBar/StatusItemController+MenuSmartUpdate.swift new file mode 100644 index 0000000000..13e6fd34ae --- /dev/null +++ b/Sources/CodexBar/StatusItemController+MenuSmartUpdate.swift @@ -0,0 +1,141 @@ +import AppKit +import CodexBarCore +import SwiftUI + +extension StatusItemController { + /// Smart update: rebuild everything below the provider switcher while keeping the switcher view intact. + struct MenuUpdateContext { + let provider: UsageProvider? + let currentProvider: UsageProvider + let switcherSelection: ProviderSwitcherSelection + let menuWidth: CGFloat + let codexAccountDisplay: CodexAccountMenuDisplay? + let tokenAccountDisplay: TokenAccountMenuDisplay? + let openAIContext: OpenAIWebContext + let descriptor: MenuDescriptor + } + + /// Smart update: rebuild everything below the provider switcher while keeping the switcher view intact. + func updateMenuContentPreservingSwitcher( + _ menu: NSMenu, + context: MenuUpdateContext) + { + self.performMenuMutationWithoutAnimation { + let contentStartIndex = self.providerSwitcherContentStartIndex(in: menu) + if let switcherView = menu.items.first?.view as? ProviderSwitcherView { + switcherView.updateSelection(context.switcherSelection) + switcherView.updateQuotaIndicators() + } + let outgoingSelection = self.lastMergedMenuContentSelection + let isSelectionSwitch = outgoingSelection != nil && outgoingSelection != context.switcherSelection + let enabledProviders = self.store.enabledProvidersForDisplay() + + if isSelectionSwitch, + let outgoingSelection, + let cachedItems = self.reusableMergedSwitcherContent( + for: context.switcherSelection, + in: menu, + menuWidth: context.menuWidth, + codexAccountDisplay: context.codexAccountDisplay, + tokenAccountDisplay: context.tokenAccountDisplay) + { + // Park the outgoing payloads for an equally instant switch-back. Compatible + // menu-item shells stay attached, avoiding the empty intermediate layout that + // AppKit can visibly render when the whole content block is removed first. + let outgoingCodexAccountDisplay = self.lastCodexAccountMenuDisplay + let outgoingTokenAccountDisplay = self.lastTokenAccountMenuDisplay + self.rememberMergedSwitcherState(enabledProviders, context.switcherSelection) + let displacedItems = self.replaceMenuContentKeepingRowsVisible( + menu, + fromIndex: contentStartIndex, + with: cachedItems) + // Cached items may have changed refresh state while detached from a menu. + self.updatePersistentRefreshItemsEnabled() + self.refreshMenuCardHeights(in: menu) + self.cacheMergedSwitcherContent( + displacedItems, + in: menu, + selection: outgoingSelection, + context: MergedSwitcherContentCacheContext( + menuWidth: context.menuWidth, + codexAccountDisplay: outgoingCodexAccountDisplay, + tokenAccountDisplay: outgoingTokenAccountDisplay, + contentVersion: nil)) + self.lastCodexAccountMenuDisplay = context.codexAccountDisplay + self.lastTokenAccountMenuDisplay = context.tokenAccountDisplay + self.cacheVisibleMergedSwitcherContent( + in: menu, + selection: context.switcherSelection, + contentStartIndex: contentStartIndex, + menuWidth: context.menuWidth, + contentVersion: self.menuSession.contentVersion) + return + } + + // Rebuild path (data tick, or switch whose incoming tab must be built): recycle + // the outgoing hosting views and reconcile in place when the row skeleton is + // unchanged, so an open tracked menu sees content mutations instead of item + // churn. The fresh content is built into a detached scratch menu while its + // interaction closures capture the live menu they will serve. + let shapes = self.menuContentShapes(in: menu, fromIndex: contentStartIndex) + self.harvestRecyclableMenuCardViews( + in: menu, + fromIndex: contentStartIndex, + displacedSelection: outgoingSelection, + preserveHighlightedItem: true) + defer { self.clearMenuCardViewRecyclePool() } + self.rememberMergedSwitcherState(enabledProviders, context.switcherSelection) + let scratch = NSMenu() + scratch.autoenablesItems = false + self.addSwitcherScopedMenuContent(into: scratch, captureMenu: menu, context: context) + self.reconcileMenuContent(menu, fromIndex: contentStartIndex, shapes: shapes, with: scratch) + self.refreshMenuCardHeights(in: menu) + self.cacheVisibleMergedSwitcherContent( + in: menu, + selection: context.switcherSelection, + contentStartIndex: contentStartIndex, + menuWidth: context.menuWidth, + contentVersion: self.menuSession.contentVersion) + } + } + + /// Adds everything below the provider switcher (account switchers, card content, and + /// actionable sections) to `target`, which may be a detached scratch menu; interaction + /// closures always capture `captureMenu`, the live menu the rows will serve. + private func addSwitcherScopedMenuContent( + into target: NSMenu, + captureMenu: NSMenu, + context: MenuUpdateContext) + { + self.addCodexAccountSwitcherIfNeeded( + to: target, + display: context.codexAccountDisplay, + width: context.menuWidth, + captureMenu: captureMenu) + self.lastCodexAccountMenuDisplay = context.codexAccountDisplay + self.addTokenAccountSwitcherIfNeeded( + to: target, + display: context.tokenAccountDisplay, + width: context.menuWidth, + captureMenu: captureMenu) + self.lastTokenAccountMenuDisplay = context.tokenAccountDisplay + + let menuContext = MenuCardContext( + currentProvider: context.currentProvider, + selectedProvider: context.provider, + menuWidth: context.menuWidth, + codexAccountDisplay: context.codexAccountDisplay, + tokenAccountDisplay: context.tokenAccountDisplay, + openAIContext: context.openAIContext) + self.addPrimaryMenuContent( + to: target, + context: menuContext, + switcherSelection: context.switcherSelection, + captureMenu: captureMenu) + self.addActionableSections( + context.descriptor.sections, + to: target, + width: context.menuWidth, + captureMenu: captureMenu) + } +} diff --git a/Sources/CodexBar/StatusItemController+MenuTracking.swift b/Sources/CodexBar/StatusItemController+MenuTracking.swift index d210bff624..6d9d5d5dde 100644 --- a/Sources/CodexBar/StatusItemController+MenuTracking.swift +++ b/Sources/CodexBar/StatusItemController+MenuTracking.swift @@ -1,13 +1,473 @@ import AppKit +import CodexBarCore extension StatusItemController { + func beginMenuTrackingSession(for menu: NSMenu) { + if menu.supermenu != nil, !self.isHostedSubviewMenu(menu) { + self.advanceMenuInteraction(for: self.rootMenu(for: menu)) + } + let menuID = ObjectIdentifier(menu) + let generation = self.menuSession.beginTrackingSession(menuID) + (menu as? StatusItemMenu)?.menuInteractionGeneration = generation + } + + func endMenuTrackingSession(for menu: NSMenu) { + (menu as? StatusItemMenu)?.menuInteractionGeneration = nil + self.menuSession.endTrackingSession(ObjectIdentifier(menu)) + } + + private func rootMenu(for menu: NSMenu) -> NSMenu { + var root = menu + while let parent = root.supermenu { + root = parent + } + return root + } + + private static let defaultClosedMenuPreparationDelay: Duration = .milliseconds(350) + + var isMenuRefreshEnabled: Bool { + #if DEBUG + if let menuRefreshEnabledOverrideForTesting { + return menuRefreshEnabledOverrideForTesting + } + #endif + return self.menuRefreshEnabledForController + } + + #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 + let preservesMergedSwitcherContentCaches = self.preservesMergedSwitcherContentCachesDuringInvalidation + self.menuSession.invalidate( + allowsStaleContent: allowStaleContentDuringDataRefresh, + requiresRebuild: !preservesMergedSwitcherContentCaches) + if !preservesMergedSwitcherContentCaches { + self.clearMergedSwitcherContentCaches() + } + self.pruneVersionScopedMenuCardHeightCache() + guard self.isMenuRefreshEnabled else { return } + if !self.openMenus.isEmpty { + guard refreshOpenMenus else { return } + self.refreshOpenMenusAllowingParentRebuild( + deferParentRebuildDuringTracking: deferOpenParentMenuRebuild) + self.scheduleOpenMenuInvalidationRetry( + deferParentRebuildDuringTracking: deferOpenParentMenuRebuild) + return + } + if allowStaleContentDuringDataRefresh { + if !self.cancelNonRequiredClosedMenuPreparation() { + self.prepareAttachedClosedMenusIfNeeded() + } + return + } + self.prepareAttachedClosedMenusIfNeeded() + } + + @discardableResult + private func cancelNonRequiredClosedMenuPreparation() -> Bool { + let menus = self.attachedMenusForClosedPreparation() + let menuIDs = menus.map(ObjectIdentifier.init) + guard !self.menuSession.hasRequiredClosedPreparation(for: menuIDs) else { return false } + self.cancelAllClosedMenuRebuilds() + for menuID in menuIDs { + self.menuSession.clearNextOpenDeferral(menuID) + } + return true + } + + func prepareAttachedClosedMenusIfNeeded() { + guard self.isMenuRefreshEnabled else { return } + guard self.openMenus.isEmpty else { return } + guard !self.isMenuDataRefreshInFlight else { return } + let menus = self.attachedMenusForClosedPreparation() + let preparationPlan = self.menuSession.closedPreparationPlan( + for: menus.lazy.map(ObjectIdentifier.init)) + guard preparationPlan != .none else { return } + for menu in menus { + let key = ObjectIdentifier(menu) + switch preparationPlan { + case .none: + return + case .nonDeferred: + guard !self.menuSession.isDeferredUntilNextOpen(key) else { continue } + case let .required(requiredVersion): + self.menuSession.clearNextOpenDeferral(key) + guard self.menuSession.isRenderedVersion(key, olderThan: requiredVersion) else { continue } + } + // Pre-warming the merged menu while it is closed runs a full main-thread populateMenu + // (incl. SwiftUI hosting-view layout) that menuWillOpen redoes synchronously on display + // anyway. In Merge Icons mode it is the only attached menu, so this just relocates that + // work into a background freeze on every store tick (#1274). Defer it until next open. + if menu === self.mergedMenu { + self.menuSession.deferUntilNextOpen(key) + continue + } + self.rebuildClosedMenuIfNeeded(menu) + } + } + + var isMenuDataRefreshInFlight: Bool { + self.store.isRefreshing || + !self.manualRefreshTasks.isEmpty || + !self.store.refreshingProviders.isEmpty || + UsageProvider.allCases.contains { self.store.isTokenRefreshInFlight(for: $0) } + } + + func removeMenuTrackingState(_ key: ObjectIdentifier) { + self.menuProviders.removeValue(forKey: key) + self.menuSession.removeMenu(key) + self.menuReadinessSignatures.removeValue(forKey: key) + self.menuIdentitySignatures.removeValue(forKey: key) + } + + func cancelMenuWork(_ key: ObjectIdentifier) { + self.menuRefreshTasks.removeValue(forKey: key)?.cancel() + self.closedMenuRebuildTasks.removeValue(forKey: key)?.cancel() + self.closedMenuRebuildRequests.cancel(for: key) + self.openMenuRebuildTasks.removeValue(forKey: key)?.cancel() + self.openMenuRebuildRequests.cancel(for: key) + self.openMenuRebuildsClosingHostedSubviewMenus.remove(key) + self.pendingMenuBaselineResyncs.remove(key) + self.cancelManualRefreshViewportRestore(for: key) + } + + func clearMenuHighlight(_ key: ObjectIdentifier) { + if let highlightedView = self.highlightedMenuItems.removeValue(forKey: key)?.view { + (highlightedView as? MenuCardHighlighting)?.setHighlighted(false) + } + self.nativeHighlightDeferredMenuRebuilds.removeValue(forKey: key) + } + + func removeMenuLifecycleState(_ key: ObjectIdentifier) { + self.openMenus.removeValue(forKey: key) + self.cancelMenuWork(key) + self.clearMenuHighlight(key) + self.removeMenuTrackingState(key) + } + + func handleClosedPersistentMenuNeedingRefresh(_ menu: NSMenu) { + if menu === self.mergedMenu { + // Closing the merged menu is on the user's dismiss path. Leave stale content attached and let + // menuWillOpen rebuild it, while other closed-menu invalidations can still prepare in the background. + self.menuSession.deferUntilNextOpen(ObjectIdentifier(menu)) + } else { + self.rebuildClosedMenuIfNeeded(menu) + } + } + + func refreshMenuForOpenIfNeeded(_ menu: NSMenu, provider: UsageProvider?) { + self.menuSession.clearNextOpenDeferral(ObjectIdentifier(menu)) + guard self.menuNeedsRefresh(menu) else { return } + if self.canPreserveStaleMenuContentForInstantOpen(menu) { + #if DEBUG + self.menuLogger.debug( + "menu open kept existing content for instant render", + metadata: [ + "items": "\(menu.items.count)", + "provider": provider?.rawValue ?? "nil", + "storeRefreshing": self.store.isRefreshing ? "1" : "0", + ]) + #endif + if self.isMenuRefreshEnabled, !self.isMenuDataRefreshInFlight { + self.scheduleOpenMenuRebuildIfStillVisible( + menu, + provider: provider, + resyncReadinessBaselineAfterRebuild: self.openMenus.isEmpty) + } + return + } + self.populateMenu(menu, provider: provider) + self.markMenuFresh(menu) + } + + private func canPreserveStaleMenuContentForInstantOpen(_ menu: NSMenu) -> Bool { + guard !menu.items.isEmpty else { return false } + let key = ObjectIdentifier(menu) + return self.menuSession.canPreserveStaleContent(for: key) && + self.menuIdentitySignatures[key] == self.menuIdentitySignature( + for: self.renderedProviders(for: menu)) + } + + 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) + let menuKey = ObjectIdentifier(menu) + let trackedWindowWidth: CGFloat? = if self.openMenus[menuKey] != nil { + menu.items.lazy.compactMap { item -> CGFloat? in + guard let window = item.view?.window else { return nil } + let contentWidth = window.contentLayoutRect.width + return contentWidth > 0 ? contentWidth : window.frame.width + }.first + } else { + nil + } + return Self.resolvedRenderedMenuWidth( + menuWidth: menu.size.width, + trackedWindowWidth: trackedWindowWidth) + } + + static func resolvedRenderedMenuWidth( + menuWidth: CGFloat, + trackedWindowWidth: CGFloat?) -> CGFloat + { + max( + ceil(menuWidth), + ceil(trackedWindowWidth ?? 0), + 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) + let rebuildToken = self.closedMenuRebuildRequests.replaceRequest(for: key) + 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.closedMenuRebuildRequests.finish(rebuildToken, for: key) { + self.closedMenuRebuildTasks.removeValue(forKey: key) + } + } + guard let menu else { return } + guard self.closedMenuRebuildRequests.isCurrent(rebuildToken, for: key) else { return } + guard !self.hasPreparedForAppShutdown else { return } + guard !self.isMenuDataRefreshInFlight else { return } + // A delayed prewarm for one menu must never populate while another menu is tracking. + guard self.openMenus.isEmpty else { return } + guard self.menuNeedsRefresh(menu) else { return } + self.populateMenu(menu, provider: provider) + self.markMenuFresh(menu) + #if DEBUG + if self.lastLoggedClosedMenuRebuildVersion != self.menuSession.contentVersion { + self.lastLoggedClosedMenuRebuildVersion = self.menuSession.contentVersion + 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.closedMenuRebuildRequests.cancel(for: key) + } + + func cancelAllClosedMenuRebuilds() { + for task in self.closedMenuRebuildTasks.values { + task.cancel() + } + self.closedMenuRebuildTasks.removeAll(keepingCapacity: false) + self.closedMenuRebuildRequests.cancelAll() + } + + func menuNeedsRefresh(_ menu: NSMenu) -> Bool { + self.menuSession.needsRefresh(ObjectIdentifier(menu)) + } + + func markMenuFresh(_ menu: NSMenu) { + let key = ObjectIdentifier(menu) + self.menuSession.markFresh(key) + self.menuReadinessSignatures[key] = self.menuAdjunctReadinessSignature() + self.menuIdentitySignatures[key] = self.menuIdentitySignature( + for: self.renderedProviders(for: menu)) + } + + private func menuIdentitySignature(for providers: [UsageProvider]) -> String { + var parts: [String] = [] + for target in providers { + parts.append(target.rawValue) + parts.append(self.providerIdentitySignature(self.store.snapshot(for: target)?.identity(for: target))) + + if target != .codex, self.store.metadata(for: target).usesAccountFallback { + let account = self.store.accountInfo(for: target) + parts.append(Self.menuIdentityField(account.email)) + parts.append(Self.menuIdentityField(account.plan)) + } + + for accountSnapshot in self.store.accountSnapshots[target] ?? [] { + parts.append(accountSnapshot.account.id.uuidString) + parts.append(Self.menuIdentityField(accountSnapshot.account.label)) + parts.append(self.providerIdentitySignature(accountSnapshot.snapshot?.identity(for: target))) + } + + if target == .codex { + parts.append(Self.menuIdentityField(self.account.email)) + parts.append(Self.menuIdentityField(self.account.plan)) + for account in self.settings.codexVisibleAccountProjectionForMenuDisplay?.visibleAccounts ?? [] { + parts.append(Self.menuIdentityField(account.id)) + parts.append(Self.menuIdentityField(account.email)) + parts.append(Self.menuIdentityField(account.workspaceLabel)) + parts.append(account.isActive ? "active" : "inactive") + parts.append(account.isLive ? "live" : "stored") + } + for accountSnapshot in self.store.codexAccountSnapshots { + parts.append(Self.menuIdentityField(accountSnapshot.id)) + parts.append(self.providerIdentitySignature(accountSnapshot.snapshot?.identity(for: target))) + } + } + + if target == .kilo { + for scopeSnapshot in self.store.kiloScopeSnapshots { + parts.append(Self.menuIdentityField(scopeSnapshot.id)) + parts.append(self.providerIdentitySignature(scopeSnapshot.snapshot?.identity(for: target))) + } + } + + if target == .claude { + parts.append(Self.menuIdentityField(self.store.claudeSwapLastError ?? "")) + for accountSnapshot in self.store.claudeSwapAccountSnapshots { + parts.append(Self.menuIdentityField(accountSnapshot.id.opaqueID)) + parts.append(accountSnapshot.isActive ? "active" : "inactive") + parts.append(self.providerIdentitySignature(accountSnapshot.snapshot?.identity(for: target))) + } + } + } + return parts.joined(separator: "|") + } + + private func providerIdentitySignature(_ identity: ProviderIdentitySnapshot?) -> String { + [ + identity?.providerID?.rawValue ?? "", + Self.menuIdentityField(identity?.accountEmail), + Self.menuIdentityField(identity?.accountOrganization), + Self.menuIdentityField(identity?.loginMethod), + ].joined(separator: ":") + } + + private static func menuIdentityField(_ value: String?) -> String { + let value = value ?? "" + return "\(value.utf8.count):\(value)" + } + + func hasOpenHostedSubviewMenu() -> Bool { + self.openMenus.values.contains { self.isHostedSubviewMenu($0) } + } + + func hasOpenNonHostedChildMenu() -> Bool { + self.openMenus.values.contains { $0.supermenu != nil && !self.isHostedSubviewMenu($0) } + } + + func refreshOpenMenuIfStillVisible(_ menu: NSMenu, provider: UsageProvider?) { + let key = ObjectIdentifier(menu) + guard self.openMenus[key] != nil else { return } + if self.isHostedSubviewMenu(menu) { + self.scheduleOpenMenuRebuildIfStillVisible(menu, provider: provider) + return + } + self.invalidateMenus( + refreshOpenMenus: true, + deferOpenParentMenuRebuild: true, + allowStaleContentDuringDataRefresh: true) + } + + func rebuildOpenMenuIfStillVisible(_ menu: NSMenu, provider: UsageProvider?) { + let key = ObjectIdentifier(menu) + guard self.openMenus[key] != nil else { return } + let isHostedSubviewMenu = self.isHostedSubviewMenu(menu) + guard isHostedSubviewMenu || !self.hasOpenHostedSubviewMenu() else { return } + guard !self.isNativeMenuItemHighlighted(in: menu) else { + self.nativeHighlightDeferredMenuRebuilds[key] = NativeHighlightDeferredMenuRebuild(provider: provider) + return + } + self.nativeHighlightDeferredMenuRebuilds.removeValue(forKey: key) + if isHostedSubviewMenu { + self.refreshHostedSubviewMenu(menu) + } else { + self.populateMenu(menu, provider: provider) + self.markMenuFresh(menu) + self.menuSession.clearParentRebuildDeferral(key) + self.applyIcon(phase: nil) + self.scheduleDeferredManualRefreshViewportRestoreAfterRebuild(for: menu) + } + #if DEBUG + self._test_openMenuRebuildObserver?(menu) + #endif + } + + func isNativeMenuItemHighlighted(in menu: NSMenu) -> Bool { + let key = ObjectIdentifier(menu) + guard let item = self.highlightedMenuItems[key], item.menu === menu else { return false } + return item.isEnabled && item.view == nil + } + + func resumeMenuRebuildDeferredForNativeHighlightIfNeeded(_ menu: NSMenu) { + let key = ObjectIdentifier(menu) + guard let deferredRebuild = self.nativeHighlightDeferredMenuRebuilds[key] else { return } + guard self.openMenus[key] === menu else { + self.nativeHighlightDeferredMenuRebuilds.removeValue(forKey: key) + self.pendingMenuBaselineResyncs.remove(key) + return + } + let isHostedSubviewMenu = self.isHostedSubviewMenu(menu) + guard isHostedSubviewMenu || !self.hasOpenHostedSubviewMenu() else { return } + self.nativeHighlightDeferredMenuRebuilds.removeValue(forKey: key) + self.scheduleOpenMenuRebuildIfStillVisible( + menu, + provider: deferredRebuild.provider) } func refreshOpenMenusIfNeeded() { - guard Self.menuRefreshEnabled else { return } + guard self.isMenuRefreshEnabled else { return } guard !self.openMenus.isEmpty else { return } self.refreshOpenMenusIfNeeded(allowsParentRebuild: false) } @@ -16,13 +476,67 @@ extension StatusItemController { self.refreshOpenMenusAllowingParentRebuild() } - func refreshOpenMenusAllowingParentRebuild() { - guard Self.menuRefreshEnabled else { return } + func refreshOpenMenusAfterHostedSubviewClose() { + guard self.isMenuRefreshEnabled else { return } guard !self.openMenus.isEmpty else { return } + if self.isMenuDataRefreshInFlight { + self.parentMenuRebuildPendingAfterHostedSubviewClose = true + return + } + self.parentMenuRebuildPendingAfterHostedSubviewClose = false self.refreshOpenMenusIfNeeded(allowsParentRebuild: true) + self.resumeParentMenuRebuildsDeferredForNativeHighlightAfterHostedSubviewClose() + } + + private func resumeParentMenuRebuildsDeferredForNativeHighlightAfterHostedSubviewClose() { + guard !self.hasOpenHostedSubviewMenu() else { return } + let deferredParents = self.openMenus.values.filter { menu in + let key = ObjectIdentifier(menu) + return !self.isHostedSubviewMenu(menu) && + self.nativeHighlightDeferredMenuRebuilds[key] != nil + } + // Schedule the saved explicit request after the generic dirty-menu pass, even when the native + // highlight is still active. The scheduled rebuild will defer again, preserving its provider. + for menu in deferredParents { + self.resumeMenuRebuildDeferredForNativeHighlightIfNeeded(menu) + } + } + + func completeParentMenuRebuildAfterHostedSubviewCloseIfNeeded() { + guard self.parentMenuRebuildPendingAfterHostedSubviewClose else { return } + guard !self.isMenuDataRefreshInFlight else { return } + guard !self.hasOpenHostedSubviewMenu() else { return } + self.refreshOpenMenusAfterHostedSubviewClose() + } + + func refreshOpenMenusAllowingParentRebuild(deferParentRebuildDuringTracking: Bool = false) { + guard self.isMenuRefreshEnabled else { return } + guard !self.openMenus.isEmpty else { return } + self.refreshOpenMenusIfNeeded( + allowsParentRebuild: true, + deferParentRebuildDuringTracking: deferParentRebuildDuringTracking) + } + + 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) { + 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 +547,8 @@ extension StatusItemController { self.refreshOpenMenuIfNeeded( menu, allowsParentRebuild: allowsParentRebuild, + deferParentRebuildDuringTracking: deferParentRebuildDuringTracking, + respectsParentRebuildDeferral: respectsParentRebuildDeferral, hasOpenHostedSubviewMenu: hasOpenHostedSubviewMenu) } self.removeOrphanedOpenMenuEntries(orphanedKeys) @@ -41,15 +557,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.scheduleOpenMenuRebuildIfStillVisible(menu, provider: self.menuProvider(for: menu)) return } guard allowsParentRebuild else { return } - guard !hasOpenHostedSubviewMenu else { return } guard self.menuNeedsRefresh(menu) else { return } + let key = ObjectIdentifier(menu) + + if deferParentRebuildDuringTracking { + self.menuSession.deferParentRebuild(key) + return + } + if respectsParentRebuildDeferral, self.menuSession.isParentRebuildDeferred(key) { + return + } + self.menuSession.clearParentRebuildDeferral(key) + guard !hasOpenHostedSubviewMenu else { return } let provider = self.menuProvider(for: menu) self.scheduleOpenMenuRebuildIfStillVisible(menu, provider: provider) @@ -57,10 +585,7 @@ extension StatusItemController { private func removeOrphanedOpenMenuEntries(_ keys: [ObjectIdentifier]) { for key in keys { - self.openMenus.removeValue(forKey: key) - self.menuRefreshTasks.removeValue(forKey: key)?.cancel() - self.menuProviders.removeValue(forKey: key) - self.menuVersions.removeValue(forKey: key) + self.removeMenuLifecycleState(key) } } } diff --git a/Sources/CodexBar/StatusItemController+MenuTypes.swift b/Sources/CodexBar/StatusItemController+MenuTypes.swift index 7c0f3b1b28..ada2187c6b 100644 --- a/Sources/CodexBar/StatusItemController+MenuTypes.swift +++ b/Sources/CodexBar/StatusItemController+MenuTypes.swift @@ -2,6 +2,14 @@ import AppKit import CodexBarCore import SwiftUI +extension StatusItemController { + var fallbackProvider: UsageProvider? { + // Intentionally uses availability-filtered list: fallback activates when no provider + // can actually work, ensuring at least a codex icon is always visible. + self.store.enabledProviders().isEmpty ? .codex : nil + } +} + extension ProviderSwitcherSelection { var provider: UsageProvider? { switch self { @@ -14,6 +22,8 @@ extension ProviderSwitcherSelection { } struct OverviewMenuCardRowView: View { + static let showsSectionDividers = false + let model: UsageMenuCardView.Model let storageText: String? let width: CGFloat @@ -23,14 +33,15 @@ struct OverviewMenuCardRowView: View { VStack(alignment: .leading, spacing: 0) { UsageMenuCardHeaderSectionView( model: self.model, - showDivider: self.hasUsageBlock, + showDivider: Self.showsSectionDividers && self.hasUsageBlock, width: self.width) if self.hasUsageBlock { UsageMenuCardUsageSectionView( model: self.model, showBottomDivider: false, bottomPadding: 6, - width: self.width) + width: self.width, + showsSectionDividers: Self.showsSectionDividers) } if let storageText { HStack(alignment: .firstTextBaseline, spacing: 4) { @@ -43,7 +54,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) @@ -93,7 +104,9 @@ struct TokenAccountMenuDisplay: Equatable { id: account.id, label: account.label, externalIdentifier: account.externalIdentifier, - organizationID: account.organizationID) + usageScope: account.usageScope, + organizationID: account.organizationID, + workspaceID: account.workspaceID) } } @@ -111,7 +124,9 @@ struct TokenAccountMenuDisplay: Equatable { let id: UUID let label: String let externalIdentifier: String? + let usageScope: String? let organizationID: String? + let workspaceID: String? } private struct SnapshotIdentity: Equatable { diff --git a/Sources/CodexBar/StatusItemController+MenuViewportRestore.swift b/Sources/CodexBar/StatusItemController+MenuViewportRestore.swift new file mode 100644 index 0000000000..b8c2da7829 --- /dev/null +++ b/Sources/CodexBar/StatusItemController+MenuViewportRestore.swift @@ -0,0 +1,730 @@ +import AppKit + +struct ManualRefreshViewportRestoreRequest { + let generation: Int + let menuInteractionGeneration: Int + let switcherSelection: ProviderSwitcherSelection? +} + +struct MenuViewportGeometry: Equatable { + let documentID: ObjectIdentifier + let clipID: ObjectIdentifier + let documentSize: CGSize + let documentIsFlipped: Bool + let clipSize: CGSize + let clipOrigin: CGPoint +} + +enum MenuViewportGeometryTransition: Equatable { + case unchanged + case layout + case movement +} + +private struct MenuViewportOriginRange { + private(set) var minimumX: CGFloat + private(set) var maximumX: CGFloat + private(set) var minimumY: CGFloat + private(set) var maximumY: CGFloat + + init(baseline: CGPoint, current: CGPoint) { + self.minimumX = min(baseline.x, current.x) + self.maximumX = max(baseline.x, current.x) + self.minimumY = min(baseline.y, current.y) + self.maximumY = max(baseline.y, current.y) + } + + mutating func include(_ origin: CGPoint) { + self.minimumX = min(self.minimumX, origin.x) + self.maximumX = max(self.maximumX, origin.x) + self.minimumY = min(self.minimumY, origin.y) + self.maximumY = max(self.maximumY, origin.y) + } + + func exceeds(_ tolerance: CGFloat) -> Bool { + self.maximumX - self.minimumX > tolerance || self.maximumY - self.minimumY > tolerance + } +} + +@MainActor +final class ManualRefreshViewportMovementTracker: NSObject { + private weak var scrollView: NSScrollView? + private weak var clipView: NSClipView? + private weak var documentView: NSView? + private let originalPostsBoundsChangedNotifications: Bool + private let originalClipPostsFrameChangedNotifications: Bool + private let originalDocumentPostsFrameChangedNotifications: Bool + private var baselineGeometry: MenuViewportGeometry? + private var pendingOriginRange: MenuViewportOriginRange? + private var afterSettleOperations: [@MainActor () -> Void] = [] + private var settleScheduled = false + private var permitsPostLayoutTopCorrection = false + private var isActive = true + private(set) var observedMovement = false + + init(scrollView: NSScrollView) { + let clipView = scrollView.contentView + let documentView = scrollView.documentView + self.scrollView = scrollView + self.clipView = clipView + self.documentView = documentView + self.originalPostsBoundsChangedNotifications = clipView.postsBoundsChangedNotifications + self.originalClipPostsFrameChangedNotifications = clipView.postsFrameChangedNotifications + self.originalDocumentPostsFrameChangedNotifications = documentView?.postsFrameChangedNotifications ?? false + self.baselineGeometry = nil + self.pendingOriginRange = nil + super.init() + clipView.postsBoundsChangedNotifications = true + clipView.postsFrameChangedNotifications = true + documentView?.postsFrameChangedNotifications = true + self.baselineGeometry = StatusItemController.menuViewportGeometry(in: scrollView) + NotificationCenter.default.addObserver( + self, + selector: #selector(self.viewportGeometryDidChange(_:)), + name: NSView.boundsDidChangeNotification, + object: clipView) + NotificationCenter.default.addObserver( + self, + selector: #selector(self.viewportGeometryDidChange(_:)), + name: NSView.frameDidChangeNotification, + object: clipView) + if let documentView { + NotificationCenter.default.addObserver( + self, + selector: #selector(self.viewportGeometryDidChange(_:)), + name: NSView.frameDidChangeNotification, + object: documentView) + } + } + + deinit { + NotificationCenter.default.removeObserver(self) + } + + func stop() { + guard self.isActive else { return } + self.isActive = false + self.clipView?.postsBoundsChangedNotifications = self.originalPostsBoundsChangedNotifications + self.clipView?.postsFrameChangedNotifications = self.originalClipPostsFrameChangedNotifications + self.documentView?.postsFrameChangedNotifications = self.originalDocumentPostsFrameChangedNotifications + self.settleScheduled = false + self.permitsPostLayoutTopCorrection = false + self.pendingOriginRange = nil + self.afterSettleOperations.removeAll(keepingCapacity: false) + self.documentView = nil + self.clipView = nil + self.scrollView = nil + } + + func isTracking(_ scrollView: NSScrollView) -> Bool { + self.scrollView === scrollView && + self.clipView === scrollView.contentView && + self.documentView === scrollView.documentView + } + + /// Make settled refresh geometry the baseline for the short completion-to-delivery window. + /// Callers must enter through `afterPendingGeometrySettles` so stale AppKit geometry is never sampled. + func rebaseAfterRefreshLayout() { + guard !self.observedMovement, let scrollView = self.scrollView else { return } + self.baselineGeometry = StatusItemController.menuViewportGeometry(in: scrollView) + self.pendingOriginRange = nil + } + + func afterPendingGeometrySettles(_ operation: @escaping @MainActor () -> Void) { + guard self.isActive else { return } + guard self.settleScheduled else { + operation() + return + } + self.afterSettleOperations.append(operation) + } + + func settlePendingGeometryChanges() { + self.settleScheduled = false + if self.isActive, + !self.observedMovement, + let scrollView = self.scrollView, + let current = StatusItemController.menuViewportGeometry(in: scrollView) + { + if let baselineGeometry = self.baselineGeometry { + let pendingMovement = self.pendingOriginRange?.exceeds(1) == true + switch StatusItemController.menuViewportGeometryTransition(from: baselineGeometry, to: current) { + case .unchanged: + if pendingMovement { + if self.consumePostLayoutTopCorrectionIfNeeded(current) { + self.baselineGeometry = current + } else { + self.observedMovement = true + } + } + // Otherwise keep the original baseline so fractional scroll deltas accumulate. + case .layout: + self.baselineGeometry = current + // AppKit can publish the new document frame one run-loop pass before its + // automatic reset to the menu's top. Only that no-op restore target is safe + // to absorb; an arbitrary next origin is newer user movement. + self.permitsPostLayoutTopCorrection = !pendingMovement + case .movement: + if self.consumePostLayoutTopCorrectionIfNeeded(current) { + self.baselineGeometry = current + } else { + self.observedMovement = true + } + } + } else { + self.baselineGeometry = current + } + self.pendingOriginRange = nil + } + let operations = self.afterSettleOperations + self.afterSettleOperations.removeAll(keepingCapacity: false) + for operation in operations where self.isActive { + operation() + } + } + + private func consumePostLayoutTopCorrectionIfNeeded(_ geometry: MenuViewportGeometry) -> Bool { + guard self.permitsPostLayoutTopCorrection else { return false } + self.permitsPostLayoutTopCorrection = false + return StatusItemController.menuViewportGeometryIsAtTop(geometry) + } + + @objc private func viewportGeometryDidChange(_: Notification) { + guard self.isActive, !self.observedMovement else { return } + if let origin = self.scrollView?.contentView.bounds.origin { + if self.pendingOriginRange == nil { + self.pendingOriginRange = MenuViewportOriginRange( + baseline: self.baselineGeometry?.clipOrigin ?? origin, + current: origin) + } else { + self.pendingOriginRange?.include(origin) + } + } + guard !self.settleScheduled else { return } + self.settleScheduled = true + ProviderSwitcherTrackingRunLoopScheduler.schedule { [weak self] in + self?.settlePendingGeometryChanges() + } + } +} + +private struct ManualRefreshViewportMovementTracking { + let generation: Int + let tracker: ManualRefreshViewportMovementTracker +} + +@MainActor +final class ManualRefreshViewportRestoreState { + var deferredUntilRebuild: [ObjectIdentifier: ManualRefreshViewportRestoreRequest] = [:] + private var movementTrackers: [ObjectIdentifier: ManualRefreshViewportMovementTracking] = [:] + + func startMovementTracking( + for key: ObjectIdentifier, + generation: Int, + scrollView: NSScrollView) + { + self.stopMovementTracking(for: key) + self.movementTrackers[key] = ManualRefreshViewportMovementTracking( + generation: generation, + tracker: ManualRefreshViewportMovementTracker(scrollView: scrollView)) + } + + func prepareForCompletedRefreshLayout( + for key: ObjectIdentifier, + generation: Int, + scrollView: NSScrollView, + completion: @escaping @MainActor () -> Void) + { + self.prepareMovementTracking( + for: key, + generation: generation, + scrollView: scrollView, + rebaseAfterLayout: true, + completion: completion) + } + + func prepareForDelivery( + for key: ObjectIdentifier, + generation: Int, + scrollView: NSScrollView, + completion: @escaping @MainActor () -> Void) + { + self.prepareMovementTracking( + for: key, + generation: generation, + scrollView: scrollView, + rebaseAfterLayout: false, + completion: completion) + } + + func afterMovementSettles( + for key: ObjectIdentifier, + generation: Int, + operation: @escaping @MainActor () -> Void) + { + guard let tracking = self.movementTrackers[key], tracking.generation == generation else { + operation() + return + } + let tracker = tracking.tracker + tracker.afterPendingGeometrySettles { [weak self, weak tracker] in + guard let self, + let tracker, + let current = self.movementTrackers[key], + current.generation == generation, + current.tracker === tracker + else { return } + operation() + } + } + + func observedMovement(for key: ObjectIdentifier, generation: Int) -> Bool { + guard let tracking = self.movementTrackers[key], tracking.generation == generation else { return false } + return tracking.tracker.observedMovement + } + + func stopMovementTracking(for key: ObjectIdentifier, generation: Int? = nil) { + guard let tracking = self.movementTrackers[key], + generation == nil || tracking.generation == generation + else { return } + tracking.tracker.stop() + self.movementTrackers.removeValue(forKey: key) + } + + func stopAllMovementTracking() { + for tracking in self.movementTrackers.values { + tracking.tracker.stop() + } + self.movementTrackers.removeAll(keepingCapacity: false) + } + + private func prepareMovementTracking( + for key: ObjectIdentifier, + generation: Int, + scrollView: NSScrollView, + rebaseAfterLayout: Bool, + completion: @escaping @MainActor () -> Void) + { + guard let tracking = self.movementTrackers[key] else { + self.startMovementTracking(for: key, generation: generation, scrollView: scrollView) + completion() + return + } + guard tracking.generation == generation else { + // A newer overlapping provider refresh owns this menu's tracker. Let the caller's + // context check discard the stale completion without erasing newer movement. + completion() + return + } + let tracker = tracking.tracker + tracker.afterPendingGeometrySettles { [weak self, weak tracker] in + guard let self, + let tracker, + let current = self.movementTrackers[key], + current.generation == generation, + current.tracker === tracker + else { return } + if !tracker.observedMovement { + if tracker.isTracking(scrollView) { + if rebaseAfterLayout { + tracker.rebaseAfterRefreshLayout() + } + } else { + self.startMovementTracking(for: key, generation: generation, scrollView: scrollView) + } + } + completion() + } + } + + #if DEBUG + var testOperation: (@MainActor () async -> Void)? + var testObserver: (@MainActor (NSMenu) -> Void)? + var testScheduler: ((@escaping @MainActor () -> Void) -> Void)? + #endif +} + +extension StatusItemController { + /// A user-initiated manual refresh reconciles the tracked menu in place, and the row + /// geometry and AppKit scroll state it changes can leave the private menu viewport anchored + /// mid-list with no way back to the top short of closing and reopening the menu. Arm a token + /// before refreshing so a close and reopen cannot transfer the restore to a new tracking + /// session. Background refreshes never enter this path and therefore never move the viewport. + func armManualRefreshViewportRestoreRequests( + originatingMenuID: ObjectIdentifier?, + originatingMenuInteractionGeneration: Int?) + -> [ObjectIdentifier: ManualRefreshViewportRestoreRequest] + { + let candidates: [(ObjectIdentifier, NSMenu)] + if let originatingMenuID { + guard let menu = self.openMenus[originatingMenuID] else { return [:] } + candidates = [(originatingMenuID, menu)] + } else { + candidates = Array(self.openMenus) + } + + var requests: [ObjectIdentifier: ManualRefreshViewportRestoreRequest] = [:] + for (key, menu) in candidates where menu.supermenu == nil && !self.isHostedSubviewMenu(menu) { + guard let menuInteractionGeneration = self.menuSession.menuInteractionGeneration(for: key) else { continue } + if key == originatingMenuID, + let originatingMenuInteractionGeneration, + menuInteractionGeneration != originatingMenuInteractionGeneration + { + continue + } + self.manualRefreshViewportRestoreState.deferredUntilRebuild.removeValue(forKey: key) + let generation = self.menuSession.armViewportRestore(key) + if let scrollView = Self.attachedMenuScrollView(in: menu) { + self.manualRefreshViewportRestoreState.startMovementTracking( + for: key, + generation: generation, + scrollView: scrollView) + } + requests[key] = ManualRefreshViewportRestoreRequest( + generation: generation, + menuInteractionGeneration: menuInteractionGeneration, + switcherSelection: self.viewportRestoreSwitcherSelection(for: menu)) + } + return requests + } + + /// A completed manual refresh updates live card content without rebuilding the tracked + /// parent menu. Restore on AppKit's tracking run loop after that live layout settles. The + /// exact token prevents an older completion from consuming a newer refresh or menu session. + func scheduleCompletedManualRefreshViewportRestore( + _ requests: [ObjectIdentifier: ManualRefreshViewportRestoreRequest]) + { + for (key, request) in requests { + guard self.isCurrentManualRefreshViewportRestoreContext(request, for: key), + let menu = self.openMenus[key] + else { + self.cancelManualRefreshViewportRestoreRequest(request, for: key) + continue + } + let completion: @MainActor () -> Void = { [weak self, weak menu] in + guard let self else { return } + guard let menu else { + self.cancelManualRefreshViewportRestoreRequest(request, for: key) + return + } + self.continueSchedulingCompletedManualRefreshViewportRestore( + request, + for: key, + menu: menu) + } + if let scrollView = Self.attachedMenuScrollView(in: menu) { + self.manualRefreshViewportRestoreState.prepareForCompletedRefreshLayout( + for: key, + generation: request.generation, + scrollView: scrollView, + completion: completion) + } else { + completion() + } + } + } + + private func continueSchedulingCompletedManualRefreshViewportRestore( + _ request: ManualRefreshViewportRestoreRequest, + for key: ObjectIdentifier, + menu: NSMenu) + { + guard self.isCurrentManualRefreshViewportRestoreContext(request, for: key), + !self.hasPreparedForAppShutdown, + self.openMenus[key] === menu, + ObjectIdentifier(menu) == key, + menu.supermenu == nil, + !self.isHostedSubviewMenu(menu), + request.switcherSelection == self.viewportRestoreSwitcherSelection(for: menu), + self.menuNeedsRefresh(menu), + !self.manualRefreshViewportRestoreState.observedMovement( + for: key, + generation: request.generation), + !self.hasOpenNonHostedChildMenu() + else { + self.cancelManualRefreshViewportRestoreRequest(request, for: key) + return + } + if self.hasOpenHostedSubviewMenu() || + self.parentMenuRebuildPendingAfterHostedSubviewClose || + self.openMenuRebuildRequests.tokens[key] != nil + { + self.manualRefreshViewportRestoreState.deferredUntilRebuild[key] = request + return + } + guard !self.hasMenuItemHighlightedForViewportRestore(in: menu) else { + self.cancelManualRefreshViewportRestoreRequest(request, for: key) + return + } + self.scheduleManualRefreshViewportRestore(request, for: menu) + } + + func scheduleDeferredManualRefreshViewportRestoreAfterRebuild(for menu: NSMenu) { + let key = ObjectIdentifier(menu) + guard let request = self.manualRefreshViewportRestoreState.deferredUntilRebuild.removeValue(forKey: key) + else { return } + let completion: @MainActor () -> Void = { [weak self, weak menu] in + guard let self else { return } + guard let menu else { + self.cancelManualRefreshViewportRestoreRequest(request, for: key) + return + } + self.continueSchedulingDeferredManualRefreshViewportRestore( + request, + for: key, + menu: menu) + } + if let scrollView = Self.attachedMenuScrollView(in: menu) { + self.manualRefreshViewportRestoreState.prepareForDelivery( + for: key, + generation: request.generation, + scrollView: scrollView, + completion: completion) + } else { + completion() + } + } + + private func continueSchedulingDeferredManualRefreshViewportRestore( + _ request: ManualRefreshViewportRestoreRequest, + for key: ObjectIdentifier, + menu: NSMenu) + { + guard self.isCurrentManualRefreshViewportRestoreContext(request, for: key), + !self.hasPreparedForAppShutdown, + self.openMenus[key] === menu, + !self.hasOpenNonHostedChildMenu(), + !self.hasOpenHostedSubviewMenu(), + !self.hasMenuItemHighlightedForViewportRestore(in: menu), + !self.manualRefreshViewportRestoreState.observedMovement( + for: key, + generation: request.generation), + request.switcherSelection == self.viewportRestoreSwitcherSelection(for: menu) + else { + self.cancelManualRefreshViewportRestoreRequest(request, for: key) + return + } + self.scheduleManualRefreshViewportRestore(request, for: menu) + } + + private func scheduleManualRefreshViewportRestore( + _ request: ManualRefreshViewportRestoreRequest, + for menu: NSMenu) + { + let key = ObjectIdentifier(menu) + let delivery: @MainActor () -> Void = { [weak self, weak menu] in + guard let self else { return } + guard self.isCurrentManualRefreshViewportRestoreContext(request, for: key) else { + self.cancelManualRefreshViewportRestoreRequest(request, for: key) + return + } + guard !self.hasPreparedForAppShutdown, + let menu, + self.openMenus[key] === menu, + request.switcherSelection == self.viewportRestoreSwitcherSelection(for: menu) + else { + self.cancelManualRefreshViewportRestoreRequest(request, for: key) + return + } + guard !self.hasOpenNonHostedChildMenu() else { + self.cancelManualRefreshViewportRestoreRequest(request, for: key) + return + } + let menuIsDirty = self.menuNeedsRefresh(menu) + let parentRebuildPending = self.openMenuRebuildRequests.tokens[key] != nil || + (self.parentMenuRebuildPendingAfterHostedSubviewClose && menuIsDirty) + if self.hasOpenHostedSubviewMenu() { + guard menuIsDirty || parentRebuildPending else { + self.cancelManualRefreshViewportRestoreRequest(request, for: key) + return + } + self.manualRefreshViewportRestoreState.deferredUntilRebuild[key] = request + return + } + if parentRebuildPending { + self.manualRefreshViewportRestoreState.deferredUntilRebuild[key] = request + return + } + guard !self.hasMenuItemHighlightedForViewportRestore(in: menu), + !self.manualRefreshViewportRestoreState.observedMovement( + for: key, + generation: request.generation) + else { + self.cancelManualRefreshViewportRestoreRequest(request, for: key) + return + } + guard self.menuSession.consumeViewportRestore(key, generation: request.generation) else { return } + self.manualRefreshViewportRestoreState.stopMovementTracking( + for: key, + generation: request.generation) + self.restoreMenuViewportToTop(menu) + } + let operation: @MainActor () -> Void = { [weak self] in + self?.manualRefreshViewportRestoreState.afterMovementSettles( + for: key, + generation: request.generation, + operation: delivery) + } + #if DEBUG + if let scheduler = self._test_menuViewportRestoreScheduler { + scheduler(operation) + } else { + ProviderSwitcherTrackingRunLoopScheduler.schedule(operation) + } + #else + ProviderSwitcherTrackingRunLoopScheduler.schedule(operation) + #endif + } + + func cancelManualRefreshViewportRestore(for key: ObjectIdentifier) { + self.manualRefreshViewportRestoreState.deferredUntilRebuild.removeValue(forKey: key) + self.manualRefreshViewportRestoreState.stopMovementTracking(for: key) + self.menuSession.cancelViewportRestore(key) + } + + private func cancelManualRefreshViewportRestoreRequest( + _ request: ManualRefreshViewportRestoreRequest, + for key: ObjectIdentifier) + { + if self.manualRefreshViewportRestoreState.deferredUntilRebuild[key]?.generation == request.generation { + self.manualRefreshViewportRestoreState.deferredUntilRebuild.removeValue(forKey: key) + } + self.manualRefreshViewportRestoreState.stopMovementTracking( + for: key, + generation: request.generation) + self.menuSession.consumeViewportRestore(key, generation: request.generation) + } + + func cancelManualRefreshViewportRestoreRequests( + _ requests: [ObjectIdentifier: ManualRefreshViewportRestoreRequest]) + { + for (key, request) in requests { + self.cancelManualRefreshViewportRestoreRequest(request, for: key) + } + } + + private func viewportRestoreSwitcherSelection(for menu: NSMenu) -> ProviderSwitcherSelection? { + guard self.shouldMergeIcons, menu === self.mergedMenu else { return nil } + if self.isMergedOverviewSelected(in: menu) { + return .overview + } + return .provider(self.resolvedMenuProvider() ?? .codex) + } + + private func isCurrentManualRefreshViewportRestoreContext( + _ request: ManualRefreshViewportRestoreRequest, + for key: ObjectIdentifier) + -> Bool + { + self.menuSession.isCurrentViewportRestore(request.generation, for: key) && + self.menuSession.isCurrentMenuInteraction(request.menuInteractionGeneration, for: key) + } + + private func hasMenuItemHighlightedForViewportRestore(in menu: NSMenu) -> Bool { + let key = ObjectIdentifier(menu) + guard let item = self.highlightedMenuItems[key], item.menu === menu else { return false } + return item.isEnabled + } + + func advanceMenuInteraction(for menu: NSMenu?) { + guard let menu else { return } + let key = ObjectIdentifier(menu) + guard self.openMenus[key] === menu, + let generation = self.menuSession.advanceMenuInteraction(for: key) + else { return } + (menu as? StatusItemMenu)?.menuInteractionGeneration = generation + } + + func restoreMenuViewportToTop(_ menu: NSMenu) { + #if DEBUG + if let observer = self._test_menuViewportRestoreObserver { + observer(menu) + return + } + #endif + guard let scrollView = Self.attachedMenuScrollView(in: menu), + let documentView = scrollView.documentView + else { return } + let clipView = scrollView.contentView + guard let target = Self.menuViewportTopOffset( + documentIsFlipped: documentView.isFlipped, + documentHeight: documentView.frame.height, + clipHeight: clipView.bounds.height, + currentOffset: clipView.documentVisibleRect.origin.y) + else { return } + self.performMenuMutationWithoutAnimation { + clipView.scroll(to: NSPoint(x: clipView.documentVisibleRect.origin.x, y: target)) + scrollView.reflectScrolledClipView(clipView) + } + } + + /// The view-based menu (`NSMenuScrollView` → `NSClipView` → table representation) + /// recycles row views once they scroll offscreen, so the shared scroll view must be + /// resolved through whichever item view is currently attached to the menu window. + static func attachedMenuScrollView(in menu: NSMenu) -> NSScrollView? { + for item in menu.items { + if let scrollView = item.view?.enclosingScrollView { + return scrollView + } + } + return nil + } + + static func menuViewportGeometry(in scrollView: NSScrollView) -> MenuViewportGeometry? { + guard let documentView = scrollView.documentView else { return nil } + let clipView = scrollView.contentView + return MenuViewportGeometry( + documentID: ObjectIdentifier(documentView), + clipID: ObjectIdentifier(clipView), + documentSize: documentView.frame.size, + documentIsFlipped: documentView.isFlipped, + clipSize: clipView.bounds.size, + clipOrigin: clipView.bounds.origin) + } + + /// Bounds notifications can arrive before AppKit exposes updated row geometry. Compare only + /// coalesced, settled samples: any geometry change is layout; stable geometry exposes scrolling. + static func menuViewportGeometryTransition( + from previous: MenuViewportGeometry, + to current: MenuViewportGeometry, + movementTolerance: CGFloat = 1) + -> MenuViewportGeometryTransition + { + // AppKit can publish an origin reset before exposing a row-size change. A mixed batch is + // therefore irreducibly ambiguous: treat it as layout, then catch repeating edge-scroll + // ticks against the new stable geometry on the next batch. + guard previous.documentID == current.documentID, + previous.clipID == current.clipID, + previous.documentSize == current.documentSize, + previous.documentIsFlipped == current.documentIsFlipped, + previous.clipSize == current.clipSize + else { return .layout } + let moved = abs(current.clipOrigin.x - previous.clipOrigin.x) > movementTolerance || + abs(current.clipOrigin.y - previous.clipOrigin.y) > movementTolerance + return moved ? .movement : .unchanged + } + + static func menuViewportGeometryIsAtTop( + _ geometry: MenuViewportGeometry, + tolerance: CGFloat = 1) + -> Bool + { + let maximumOffset = max(0, geometry.documentSize.height - geometry.clipSize.height) + let topOffset = geometry.documentIsFlipped ? 0 : maximumOffset + return abs(geometry.clipOrigin.y - topOffset) <= tolerance + } + + /// Returns the offset that shows the top of the menu content, or nil when the menu is + /// not scrollable or the viewport is already there. + static func menuViewportTopOffset( + documentIsFlipped: Bool, + documentHeight: CGFloat, + clipHeight: CGFloat, + currentOffset: CGFloat) -> CGFloat? + { + guard clipHeight > 0, documentHeight - clipHeight > 0.5 else { return nil } + let top: CGFloat = documentIsFlipped ? 0 : documentHeight - clipHeight + guard abs(currentOffset - top) > 0.5 else { return nil } + return top + } +} diff --git a/Sources/CodexBar/StatusItemController+MenuWidthCache.swift b/Sources/CodexBar/StatusItemController+MenuWidthCache.swift new file mode 100644 index 0000000000..b9312b9662 --- /dev/null +++ b/Sources/CodexBar/StatusItemController+MenuWidthCache.swift @@ -0,0 +1,149 @@ +import AppKit +import CodexBarCore + +extension StatusItemController { + private static let measuredStandardMenuWidthCacheLimit = 96 + + func menuCardWidth( + for providers: [UsageProvider], + selectedProvider: UsageProvider?, + descriptor: MenuDescriptor) -> CGFloat + { + let sectionSets: [[MenuDescriptor.Section]] = if self.shouldMergeIcons, providers.count > 1 { + providers.map { provider in + if provider == selectedProvider { + return descriptor.sections + } + return self.makeMenuDescriptor( + provider: provider, + includeContextualActions: true).sections + } + } else { + [descriptor.sections] + } + return self.measuredMenuCardWidth(for: sectionSets) + } + + func measuredMenuCardWidth(for sectionSets: [[MenuDescriptor.Section]]) -> CGFloat { + let baselineWidth = Self.menuCardBaseWidth + return sectionSets.reduce(baselineWidth) { width, sections in + max(width, self.measuredStandardMenuWidth(for: sections, baseWidth: baselineWidth)) + } + } + + func makeMenuDescriptor( + provider: UsageProvider?, + includeContextualActions: Bool) -> MenuDescriptor + { + MenuDescriptor.build( + provider: provider, + store: self.store, + settings: self.settings, + account: self.account, + managedCodexAccountCoordinator: self.managedCodexAccountCoordinator, + codexAccountPromotionCoordinator: self.codexAccountPromotionCoordinator, + updateReady: self.updater.updateStatus.isUpdateReady, + includeContextualActions: includeContextualActions, + agentSessionsEnabled: self.settings.agentSessionsEnabled, + agentSessionLabelStyle: self.settings.agentSessionLabelStyle, + localAgentSessions: self.agentSessions.localSessions, + remoteAgentHosts: self.agentSessions.remoteHosts) + } + + func measuredStandardMenuWidth(for sections: [MenuDescriptor.Section], baseWidth: CGFloat) -> CGFloat { + let cacheKey = self.measuredStandardMenuWidthCacheKey(for: sections, baseWidth: baseWidth) + if let cached = self.measuredStandardMenuWidthCache[cacheKey] { + return cached + } + + let measuringMenu = NSMenu() + measuringMenu.autoenablesItems = false + self.addActionableSections(sections, to: measuringMenu, width: baseWidth) + let measured = ceil(measuringMenu.size.width) + if self.measuredStandardMenuWidthCache.count >= Self.measuredStandardMenuWidthCacheLimit { + self.measuredStandardMenuWidthCache.removeAll(keepingCapacity: true) + } + self.measuredStandardMenuWidthCache[cacheKey] = measured + return measured + } + + private func measuredStandardMenuWidthCacheKey( + for sections: [MenuDescriptor.Section], + baseWidth: CGFloat) -> String + { + var parts = [ + "base=\(Int((baseWidth * 100).rounded()))", + "font=\(Self.menuCardHeightTextScaleToken())", + self.menuLocalizationSignature(), + ] + for section in sections { + parts.append("[") + for entry in section.entries { + parts.append(self.measuredStandardMenuWidthCacheToken(for: entry)) + } + parts.append("]") + } + return parts.joined(separator: "\u{1f}") + } + + private func measuredStandardMenuWidthCacheToken(for entry: MenuDescriptor.Entry) -> String { + switch entry { + case let .text(text, style): + "text:\(style):\(text)" + case let .action(title, action): + "action:\(title):\(self.measuredStandardMenuWidthCacheToken(for: action))" + case let .unavailable(title, tooltip): + "unavailable:\(title):\(tooltip ?? "")" + case let .submenu(title, systemImageName, submenuItems): + "submenu:\(title):\(systemImageName ?? ""):" + submenuItems.map { item in + [ + item.title, + item.isEnabled ? "1" : "0", + item.isChecked ? "1" : "0", + item.action.map(self.measuredStandardMenuWidthCacheToken(for:)) ?? "", + ].joined(separator: ":") + }.joined(separator: ",") + case .divider: + "divider" + } + } + + private func measuredStandardMenuWidthCacheToken(for action: MenuDescriptor.MenuAction) -> String { + switch action { + case .installUpdate: + "installUpdate" + case .refresh: + "refresh" + case .refreshAugmentSession: + "refreshAugmentSession" + case .dashboard: + "dashboard" + case .statusPage: + "statusPage" + case .changelog: + "changelog" + case .addCodexAccount: + "addCodexAccount:\(self.codexAddAccountSubtitle() ?? "")" + case let .requestCodexSystemPromotion(id): + "requestCodexSystemPromotion:\(id)" + case let .addProviderAccount(provider): + "addProviderAccount:\(provider.rawValue)" + case let .switchAccount(provider): + "switchAccount:\(provider.rawValue):\(self.switchAccountSubtitle(for: provider) ?? "")" + case let .openTerminal(command): + "openTerminal:\(command)" + case let .loginToProvider(url): + "loginToProvider:\(url)" + case .settings: + "settings" + case .about: + "about" + case .quit: + "quit" + case let .copyError(message): + "copyError:\(message)" + case let .focusAgentSession(session, remoteHost): + "focusAgentSession:\(remoteHost ?? "local"):\(session.id)" + } + } +} diff --git a/Sources/CodexBar/StatusItemController+MergedSwitcherContentCache.swift b/Sources/CodexBar/StatusItemController+MergedSwitcherContentCache.swift new file mode 100644 index 0000000000..f060a53edf --- /dev/null +++ b/Sources/CodexBar/StatusItemController+MergedSwitcherContentCache.swift @@ -0,0 +1,142 @@ +import AppKit + +struct CachedMergedSwitcherMenuContent { + let requiredMenuContentVersion: Int + let menuWidth: CGFloat + let codexAccountDisplay: CodexAccountMenuDisplay? + let tokenAccountDisplay: TokenAccountMenuDisplay? + let localizationSignature: String + let items: [NSMenuItem] + + func matches( + requiredMenuContentVersion: Int, + menuWidth: CGFloat, + codexAccountDisplay: CodexAccountMenuDisplay?, + tokenAccountDisplay: TokenAccountMenuDisplay?, + localizationSignature: String) + -> Bool + { + self.requiredMenuContentVersion >= requiredMenuContentVersion && + abs(self.menuWidth - menuWidth) <= 0.5 && + self.codexAccountDisplay == codexAccountDisplay && + self.tokenAccountDisplay == tokenAccountDisplay && + self.localizationSignature == localizationSignature + } +} + +struct MergedSwitcherContentCacheContext { + let menuWidth: CGFloat + let codexAccountDisplay: CodexAccountMenuDisplay? + let tokenAccountDisplay: TokenAccountMenuDisplay? + let contentVersion: Int? +} + +extension StatusItemController { + func preservingMergedSwitcherContentCachesDuringInvalidation(_ body: () -> Void) { + let previous = self.preservesMergedSwitcherContentCachesDuringInvalidation + self.preservesMergedSwitcherContentCachesDuringInvalidation = true + defer { self.preservesMergedSwitcherContentCachesDuringInvalidation = previous } + body() + } + + func clearMergedSwitcherContentCaches() { + self.mergedSwitcherContentCaches.removeAll(keepingCapacity: true) + } + + func clearMergedSwitcherContentCache(for menu: NSMenu) { + self.mergedSwitcherContentCaches.removeValue(forKey: ObjectIdentifier(menu)) + } + + func cacheVisibleMergedSwitcherContent( + in menu: NSMenu, + selection: ProviderSwitcherSelection, + contentStartIndex: Int, + menuWidth: CGFloat, + contentVersion: Int? = nil) + { + guard self.shouldMergeIcons else { return } + guard menu.items.first?.view is ProviderSwitcherView else { return } + guard contentStartIndex < menu.items.count else { return } + let items = Array(menu.items[contentStartIndex...]) + self.cacheMergedSwitcherContent( + items, + in: menu, + selection: selection, + context: MergedSwitcherContentCacheContext( + menuWidth: menuWidth, + codexAccountDisplay: self.lastCodexAccountMenuDisplay, + tokenAccountDisplay: self.lastTokenAccountMenuDisplay, + contentVersion: contentVersion)) + } + + func cacheMergedSwitcherContent( + _ items: [NSMenuItem], + in menu: NSMenu, + selection: ProviderSwitcherSelection, + context: MergedSwitcherContentCacheContext) + { + guard !items.isEmpty else { return } + + let entry = CachedMergedSwitcherMenuContent( + requiredMenuContentVersion: context.contentVersion ?? + self.menuSession.renderedVersion(for: ObjectIdentifier(menu)) ?? + self.menuSession.latestRequiredRebuildVersion, + menuWidth: context.menuWidth, + codexAccountDisplay: context.codexAccountDisplay, + tokenAccountDisplay: context.tokenAccountDisplay, + localizationSignature: self.lastMenuLocalizationSignature, + items: items) + self.mergedSwitcherContentCaches[ObjectIdentifier(menu), default: [:]][selection] = entry + } + + /// Returns a reusable cached content block, evicting stale entries without attaching them. + func reusableMergedSwitcherContent( + for selection: ProviderSwitcherSelection, + in menu: NSMenu, + menuWidth: CGFloat, + codexAccountDisplay: CodexAccountMenuDisplay?, + tokenAccountDisplay: TokenAccountMenuDisplay?) + -> [NSMenuItem]? + { + let key = ObjectIdentifier(menu) + guard let entry = self.mergedSwitcherContentCaches[key]?[selection] else { return nil } + guard entry.matches( + requiredMenuContentVersion: self.menuSession.latestRequiredRebuildVersion, + menuWidth: menuWidth, + codexAccountDisplay: codexAccountDisplay, + tokenAccountDisplay: tokenAccountDisplay, + localizationSignature: self.menuLocalizationSignature()) + else { + self.mergedSwitcherContentCaches[key]?.removeValue(forKey: selection) + return nil + } + return entry.items + } + + func addCachedMergedSwitcherContent( + for selection: ProviderSwitcherSelection, + to menu: NSMenu, + menuWidth: CGFloat, + codexAccountDisplay: CodexAccountMenuDisplay?, + tokenAccountDisplay: TokenAccountMenuDisplay?) + -> Bool + { + guard let items = self.reusableMergedSwitcherContent( + for: selection, + in: menu, + menuWidth: menuWidth, + codexAccountDisplay: codexAccountDisplay, + tokenAccountDisplay: tokenAccountDisplay) + else { return false } + + self.lastCodexAccountMenuDisplay = codexAccountDisplay + self.lastTokenAccountMenuDisplay = tokenAccountDisplay + for item in items { + menu.addItem(item) + } + // Detached Refresh items cannot observe a completed manual refresh. Recompute only + // after AppKit has restored their menu so provider-scoped busy state is available. + self.updatePersistentRefreshItemsEnabled() + return true + } +} diff --git a/Sources/CodexBar/StatusItemController+OverviewScroll.swift b/Sources/CodexBar/StatusItemController+OverviewScroll.swift new file mode 100644 index 0000000000..5fb980afdd --- /dev/null +++ b/Sources/CodexBar/StatusItemController+OverviewScroll.swift @@ -0,0 +1,126 @@ +import AppKit + +enum OverviewScrollStep { + case up + case down +} + +extension StatusItemController { + /// Line distance per highlight step for classic scroll wheels. + private static let lineScrollStepThreshold: CGFloat = 0.9 + /// A single fast flick should not race the highlight through the whole list. + private static let maxScrollStepsPerEvent = 3 + + /// Classic scroll wheels keep row-to-row overview navigation. Precise trackpad scrolling is + /// left to AppKit's native menu scroller so the content follows the user's fingers instead + /// of waiting for a threshold and jumping the highlighted row. + @discardableResult + func handleOverviewScrollWheel(_ event: NSEvent, menu: NSMenu) -> Bool { + guard self.menuHasOverviewRows(menu) else { + self.overviewScrollAccumulatedDelta = 0 + return false + } + // Leave the wheel alone while a row submenu is open (e.g. scrollable charts); + // only the root overview list translates scrolling into highlight movement. + guard self.openMenus.count <= 1 else { + self.overviewScrollAccumulatedDelta = 0 + return false + } + guard !event.hasPreciseScrollingDeltas else { + self.overviewScrollAccumulatedDelta = 0 + return false + } + // Precise trackpad/Magic Mouse scrolling already returned above, so this only guards + // non-precise devices that still report a momentum phase: swallow that flick tail so the + // highlight does not keep stepping after the fingers lift. + guard event.momentumPhase.isEmpty else { return true } + let delta = event.scrollingDeltaY + guard delta != 0 else { return false } + + if self.overviewScrollAccumulatedDelta != 0, + (delta > 0) != (self.overviewScrollAccumulatedDelta > 0) + { + self.overviewScrollAccumulatedDelta = 0 + } + self.overviewScrollAccumulatedDelta += delta + + let threshold = Self.lineScrollStepThreshold + var steps = 0 + while abs(self.overviewScrollAccumulatedDelta) >= threshold, steps < Self.maxScrollStepsPerEvent { + let movingUp = self.overviewScrollAccumulatedDelta > 0 + self.overviewScrollAccumulatedDelta += movingUp ? -threshold : threshold + self.postOverviewScrollNavigation(movingUp ? .up : .down, menu: menu) + steps += 1 + } + // Discard the remainder once the cap is hit, otherwise the leftover delta from a + // fast flick would keep emitting capped batches on the next small scroll. + if steps == Self.maxScrollStepsPerEvent { + self.overviewScrollAccumulatedDelta = 0 + } + return true + } + + func menuHasOverviewRows(_ menu: NSMenu) -> Bool { + menu.items.contains { item in + (item.representedObject as? String)?.hasPrefix(Self.overviewRowIdentifierPrefix) == true + } + } + + func resetOverviewScrollAccumulation() { + self.overviewScrollAccumulatedDelta = 0 + } + + private func postOverviewScrollNavigation(_ step: OverviewScrollStep, menu: NSMenu) { + if let handler = self.overviewScrollNavigationHandlerForTesting { + handler(step) + return + } + guard let target = self.overviewScrollTargetItem(in: menu, step: step) else { return } + let menuID = ObjectIdentifier(menu) + guard self.highlightedMenuItems[menuID] !== target else { return } + + // Advance local state immediately so a capped multi-step flick can target successive rows + // before AppKit drains the synthetic mouse-move events. + self.menu(menu, willHighlight: target) + + guard let view = target.view, + let window = view.window + else { return } + let location = view.convert( + NSPoint(x: view.bounds.midX, y: view.bounds.midY), + to: nil) + guard let event = NSEvent.mouseEvent( + with: .mouseMoved, + location: location, + modifierFlags: [], + timestamp: ProcessInfo.processInfo.systemUptime, + windowNumber: window.windowNumber, + context: nil, + eventNumber: 0, + clickCount: 0, + pressure: 0) + else { return } + NSApp.postEvent(event, atStart: false) + } + + func overviewScrollTargetItem(in menu: NSMenu, step: OverviewScrollStep) -> NSMenuItem? { + let rows = menu.items.filter { item in + (item.representedObject as? String)?.hasPrefix(Self.overviewRowIdentifierPrefix) == true + } + guard !rows.isEmpty else { return nil } + + guard let current = self.highlightedMenuItems[ObjectIdentifier(menu)], + let currentIndex = rows.firstIndex(where: { $0 === current }) + else { + return step == .down ? rows.first : rows.last + } + + let targetIndex: Int = switch step { + case .up: + max(0, currentIndex - 1) + case .down: + min(rows.count - 1, currentIndex + 1) + } + return rows[targetIndex] + } +} diff --git a/Sources/CodexBar/StatusItemController+OverviewSubmenus.swift b/Sources/CodexBar/StatusItemController+OverviewSubmenus.swift index bfefcf78db..91064b356f 100644 --- a/Sources/CodexBar/StatusItemController+OverviewSubmenus.swift +++ b/Sources/CodexBar/StatusItemController+OverviewSubmenus.swift @@ -17,7 +17,17 @@ extension StatusItemController { { return submenu } - if model.tokenUsage != nil, + // Mistral's top usage pane has no rate-limit bars of its own, so its Overview row always + // prioritizes cost history too. Other `tokenCostRequiresProviderSnapshot` providers (e.g. + // opencodego) show real rate-limit bars and should fall through to the settings-gated + // check below, same as Codex/Claude (see StatusItemController+Menu.swift's makeUsageSubmenu). + if provider == .mistral, + let submenu = self.makeCostHistorySubmenu(provider: provider, width: width) + { + return submenu + } + if self.settings.costSummaryShowsSubmenu(for: provider), + model.tokenUsage != nil, let submenu = self.makeCostHistorySubmenu(provider: provider, width: width) { return submenu @@ -27,4 +37,34 @@ extension StatusItemController { } return self.makeStorageBreakdownSubmenu(provider: provider, width: width) } + + @objc func selectOverviewProvider(_ sender: NSMenuItem) { + guard let represented = sender.representedObject as? String, + represented.hasPrefix(Self.overviewRowIdentifierPrefix) + else { + return + } + let rawProvider = String(represented.dropFirst(Self.overviewRowIdentifierPrefix.count)) + guard let provider = UsageProvider(rawValue: rawProvider), + let menu = sender.menu + else { + return + } + + self.selectOverviewProvider(provider, menu: menu) + } + + func selectOverviewProvider(_ provider: UsageProvider, menu: NSMenu) { + if !self.settings.mergedMenuLastSelectedWasOverview, self.selectedMenuProvider == provider { return } + self.preservingMergedSwitcherContentCachesDuringInvalidation { + self.settings.mergedMenuLastSelectedWasOverview = false + self.lastMergedSwitcherSelection = .provider(provider) + self.selectedMenuProvider = provider + self.lastMenuProvider = provider + self.refreshProviderSelectionDependentUI(deferRendering: true) + } + // Custom-view clicks stay open and rebuild next turn. Standard menu-item activation can close; + // menuWillOpen then renders the saved provider without doing structural work inside the action. + self.requestProviderSwitcherMenuRebuild(menu, provider: provider) + } } diff --git a/Sources/CodexBar/StatusItemController+PersistentMenuActions.swift b/Sources/CodexBar/StatusItemController+PersistentMenuActions.swift index 6037bc432b..bf3db110d3 100644 --- a/Sources/CodexBar/StatusItemController+PersistentMenuActions.swift +++ b/Sources/CodexBar/StatusItemController+PersistentMenuActions.swift @@ -1,56 +1,60 @@ import AppKit extension StatusItemController { - func usesPersistentMenuActionItem(for action: MenuDescriptor.MenuAction) -> Bool { - switch action { - case .installUpdate, .refresh, .settings, .about, .quit: - true - default: - false + /// Updates persistent Refresh rows in place while their menus are tracking. + func updatePersistentRefreshItemsEnabled() { + for item in self.persistentRefreshItems.allObjects { + guard self.isPersistentRefreshItem(item) else { + self.persistentRefreshItems.remove(item) + continue + } + guard let menu = item.menu else { continue } + let enabled = !self.isRefreshActionInFlight(for: menu) + if !enabled, self.highlightedMenuItems[ObjectIdentifier(menu)] === item { + (item.view as? MenuCardHighlighting)?.setHighlighted(false) + self.highlightedMenuItems.removeValue(forKey: ObjectIdentifier(menu)) + } + item.isEnabled = enabled + (item.view as? PersistentRefreshMenuView)?.setEnabled(enabled) } } - func persistentMenuActionSystemImageName(for action: MenuDescriptor.MenuAction) -> String? { - switch action { - case .installUpdate: - "arrow.down.circle" - case .refresh: - MenuDescriptor.MenuActionSystemImage.refresh.rawValue - case .settings: - MenuDescriptor.MenuActionSystemImage.settings.rawValue - case .about: - MenuDescriptor.MenuActionSystemImage.about.rawValue - case .quit: - MenuDescriptor.MenuActionSystemImage.quit.rawValue - default: - action.systemImageName + func isRefreshActionInFlight(for menu: NSMenu) -> Bool { + if self.store.hasForcedRefreshEnrichmentInFlight { + return true + } + + // An all-providers manual refresh (⌘R / overview) legitimately busies every row. + if self.manualRefreshTasks[.global] != nil { + return true } - } - func performPersistentMenuAction(_ action: MenuDescriptor.MenuAction, in menu: NSMenu?) { - switch action { - case .refresh: - self.refreshNow() - case .installUpdate: - self.closeMenuForPersistentAction(menu) - self.installUpdate() - case .settings: - self.closeMenuForPersistentAction(menu) - self.showSettingsGeneral() - case .about: - self.closeMenuForPersistentAction(menu) - self.showSettingsAbout() - case .quit: - self.closeMenuForPersistentAction(menu) - self.quit() - default: - break + if self.isMergedOverviewSelected(in: menu) { + // Overview stands for every provider, so it is busy while ANY manual refresh runs — + // including the post-fetch tail of a per-provider refresh, after `refreshingProviders` + // has cleared but its `.provider` task is still finishing status/token/credits work. + return self.store.isRefreshing + || !self.manualRefreshTasks.isEmpty + || !self.store.refreshingProviders.isEmpty + } + if let provider = self.menuProvider(for: menu) { + // A manual refresh of a different provider must not grey out this provider's row: only + // reflect the global refresh, this provider's own manual refresh, and its store refresh. + return self.store.isRefreshing + || self.manualRefreshTasks[.provider(provider)] != nil + || self.store.refreshingProviders.contains(provider) } + return self.store.isRefreshing + || !self.manualRefreshTasks.isEmpty + || !self.store.refreshingProviders.isEmpty } - private func closeMenuForPersistentAction(_ menu: NSMenu?) { - guard let menu else { return } - menu.cancelTrackingWithoutAnimation() - self.forgetClosedMenu(menu) + func isMergedOverviewSelected(in menu: NSMenu) -> Bool { + guard self.shouldMergeIcons else { return false } + if let mergedMenu = self.mergedMenu, menu !== mergedMenu { return false } + let providers = self.settings.resolvedMergedOverviewProviders( + activeProviders: self.store.enabledProvidersForDisplay(), + maxVisibleProviders: SettingsStore.mergedOverviewProviderLimit) + return !providers.isEmpty && self.settings.mergedMenuLastSelectedWasOverview } } diff --git a/Sources/CodexBar/StatusItemController+PersistentRefreshMenuItem.swift b/Sources/CodexBar/StatusItemController+PersistentRefreshMenuItem.swift new file mode 100644 index 0000000000..9bf32eb867 --- /dev/null +++ b/Sources/CodexBar/StatusItemController+PersistentRefreshMenuItem.swift @@ -0,0 +1,58 @@ +import AppKit + +extension StatusItemController { + func isPersistentRefreshItem(_ item: NSMenuItem) -> Bool { + item.representedObject as? String == Self.persistentRefreshMenuItemID + } + + func makePersistentRefreshItem(title: String, menu: NSMenu, width: CGFloat) -> NSMenuItem { + let shortcutText = self.shortcut(for: .refresh).map(Self.shortcutDisplayLabel) + let metrics = PersistentRefreshRowMetrics.defaults + let view = PersistentRefreshMenuView( + title: title, + systemImageName: MenuDescriptor.MenuAction.refresh.systemImageName, + shortcutText: shortcutText, + onClick: { [weak self, weak menu] in + guard let self, let menu else { return } + if let menu = menu as? StatusItemMenu { + menu.requestPersistentRefreshAction() + } else { + self.performPersistentRefreshAction(in: ObjectIdentifier(menu)) + } + }) + let enabled = !self.isRefreshActionInFlight(for: menu) + view.setEnabled(enabled) + view.applySize(width: width, height: metrics.rowHeight) + + let item = NSMenuItem() + item.title = title + item.representedObject = Self.persistentRefreshMenuItemID + item.view = view + item.isEnabled = enabled + item.keyEquivalentModifierMask = [] + item.toolTip = title + return item + } + + private static func shortcutDisplayLabel( + for shortcut: (key: String, modifiers: NSEvent.ModifierFlags)) -> String + { + var label = "" + if shortcut.modifiers.contains(.control) { + label += "^" + } + if shortcut.modifiers.contains(.option) { + label += "⌥" + } + if shortcut.modifiers.contains(.shift) { + label += "⇧" + } + if shortcut.modifiers.contains(.command) { + label += "⌘" + } + if !label.isEmpty { + label += " " + } + return label + shortcut.key.uppercased() + } +} diff --git a/Sources/CodexBar/StatusItemController+ProviderNavigation.swift b/Sources/CodexBar/StatusItemController+ProviderNavigation.swift index 8a10919387..cc4c71e653 100644 --- a/Sources/CodexBar/StatusItemController+ProviderNavigation.swift +++ b/Sources/CodexBar/StatusItemController+ProviderNavigation.swift @@ -1,7 +1,44 @@ +import AppKit import CodexBarCore extension StatusItemController { - func navigateProviderSwitcher(_ direction: StatusItemMenuProviderNavigationDirection) { + func refreshProviderSelectionDependentUI( + refreshOpenMenus: Bool = false, + deferRendering: Bool = false) + { + #if DEBUG + guard !self.isReleasedForTesting else { return } + #endif + self.advanceMenuInteraction(for: self.mergedMenu) + self.invalidateMenus(refreshOpenMenus: refreshOpenMenus) + if deferRendering { + self.scheduleProviderSelectionUIRefresh() + return + } + self.refreshProviderSelectionRendering() + } + + private func scheduleProviderSelectionUIRefresh() { + self.providerSelectionUIRefreshTask?.cancel() + self.providerSelectionUIRefreshTask = Task { @MainActor [weak self] in + await Task.yield() + guard !Task.isCancelled, let self else { return } + self.refreshProviderSelectionRendering() + self.providerSelectionUIRefreshTask = nil + } + } + + private func refreshProviderSelectionRendering() { + self.updateAnimationState() + self.updateBlinkingState() + let phase: Double? = self.needsMenuBarIconAnimation() ? self.animationPhase : nil + self.applyIcon(phase: phase) + } + + func navigateProviderSwitcher( + _ direction: StatusItemMenuProviderNavigationDirection, + menu: NSMenu? = nil) + { guard self.shouldMergeIcons else { return } let enabledProviders = self.store.enabledProvidersForDisplay() guard enabledProviders.count > 1 else { return } @@ -26,22 +63,37 @@ extension StatusItemController { let delta = direction == .next ? 1 : -1 let nextIndex = (currentIndex + delta + selections.count) % selections.count let selection = selections[nextIndex] - switch selection { + let menuProvider: UsageProvider = switch selection { case .overview: - self.settings.mergedMenuLastSelectedWasOverview = true - self.lastMenuProvider = self.navigationResolvedProvider(enabledProviders: enabledProviders) ?? .codex + self.navigationResolvedProvider(enabledProviders: enabledProviders) ?? .codex case let .provider(provider): - self.settings.mergedMenuLastSelectedWasOverview = false - self.selectedMenuProvider = provider - self.lastMenuProvider = provider + provider + } + self.preservingMergedSwitcherContentCachesDuringInvalidation { + switch selection { + case .overview: + self.settings.mergedMenuLastSelectedWasOverview = true + self.lastMenuProvider = self.navigationResolvedProvider(enabledProviders: enabledProviders) ?? .codex + case let .provider(provider): + self.settings.mergedMenuLastSelectedWasOverview = false + self.selectedMenuProvider = provider + self.lastMenuProvider = provider + } + self.lastMergedSwitcherSelection = selection + self.refreshProviderSelectionDependentUI(deferRendering: true) + } + let trackedMenu = menu ?? self.providerSwitcherShortcutMenuID.flatMap { self.openMenus[$0] } + if let trackedMenu { + self.requestProviderSwitcherMenuRebuild( + trackedMenu, + provider: menuProvider) } - self.lastMergedSwitcherSelection = selection - self.invalidateMenus(refreshOpenMenus: true) - self.applyIcon(phase: nil) } private func navigationResolvedProvider(enabledProviders: [UsageProvider]) -> UsageProvider? { - if enabledProviders.isEmpty { return .codex } + if enabledProviders.isEmpty { + return .codex + } if let selected = self.selectedMenuProvider, enabledProviders.contains(selected) { return selected } diff --git a/Sources/CodexBar/StatusItemController+ProviderSwitcher.swift b/Sources/CodexBar/StatusItemController+ProviderSwitcher.swift index 3b0f687356..4a7821a61c 100644 --- a/Sources/CodexBar/StatusItemController+ProviderSwitcher.swift +++ b/Sources/CodexBar/StatusItemController+ProviderSwitcher.swift @@ -1,42 +1,154 @@ import AppKit import CodexBarCore +struct PendingProviderSwitcherRebuild { + let menu: NSMenu + let provider: UsageProvider? +} + +/// Skips the event-queue peek on run-loop passes where no event of the monitored kinds +/// can possibly be pending. The menu-tracking run loop spins on every mouse move, and the +/// session-wide event counters for keys and clicks are far cheaper to read than +/// `NSApp.nextEvent` is to call, so gating on them removes the per-pass peek cost from +/// hover-heavy menu interaction (mouse moves never advance these counters). +@MainActor +final class ProviderSwitcherEventPeekGate { + private let eventTypes: [CGEventType] + private let counterProvider: (CGEventType) -> UInt32 + private var lastCounters: [UInt32]? + private var heldKeyCodes: Set = [] + private var emptyPeekBudget = 0 + + init( + eventTypes: [CGEventType], + counterProvider: @escaping (CGEventType) -> UInt32 = { type in + CGEventSource.counterForEventType(.combinedSessionState, eventType: type) + }) + { + self.eventTypes = eventTypes + self.counterProvider = counterProvider + } + + /// True when an event of a monitored kind may have been posted since the last check. + func shouldPeek() -> Bool { + let counters = self.eventTypes.map(self.counterProvider) + let countersChanged = self.lastCounters.map { counters != $0 } ?? true + self.lastCounters = counters + if countersChanged { + // The observer runs before run-loop sources. WindowServer can advance a counter + // one pass before AppKit queues the NSEvent, so require two empty peeks before + // considering the queue caught up. + self.emptyPeekBudget = max(self.emptyPeekBudget, 2) + } + // CoreGraphics does not count key autorepeat events. Keep peeking while a key is + // held so repeated provider-navigation events are still handled. + if !self.heldKeyCodes.isEmpty { + return true + } + return self.emptyPeekBudget > 0 + } + + func observe(_ event: NSEvent) { + // An unhandled event stays queued until AppKit processes it after this observer. + // Keep peeking until a later pass proves the matching queue is empty. + self.emptyPeekBudget = max(self.emptyPeekBudget, 1) + switch event.type { + case .keyDown: + self.heldKeyCodes.insert(event.keyCode) + case .keyUp: + self.heldKeyCodes.remove(event.keyCode) + default: + break + } + } + + func observeQueueEmpty(afterFindingEvent: Bool) { + if afterFindingEvent { + // A counter snapshot can represent multiple events that AppKit delivers across + // run-loop passes. Keep one empty proof pending after draining available events. + self.emptyPeekBudget = max(self.emptyPeekBudget - 1, 1) + } else if self.emptyPeekBudget > 0 { + self.emptyPeekBudget -= 1 + } + } +} + +/// Handles provider-switcher keyboard shortcuts and overview scrolling while the merged +/// status menu is open. `NSMenu` tracking pulls events itself, so local event monitors, +/// Carbon dispatcher handlers, registered hot keys (tracking pushes a hotkey-disable mode), +/// and `menuHasKeyEquivalent` never see these events — peeking the queue from a run-loop +/// observer is the only delivery path. +/// +/// The peek itself must not disturb the tracking session: `NSApp.nextEvent` re-enters the +/// event loop in the mode it is given, and re-entering `.eventTracking` dispatches the menu +/// session's own timers and sources mid-observer. When that landed during menu setup or amid +/// rapid claimed key repeats, it killed the session and left a zombie menu on screen that no +/// longer dequeued events: clicks sat in the queue for tens of seconds while the cursor +/// beach-balled. Three guards prevent that: peeks run in a private run-loop mode with no +/// sources or timers registered (the queue is mode-agnostic, so matching still works), the +/// peek only starts once the tracking loop is confirmed pumping, and mouse clicks are not +/// monitored at all (`ProviderSwitcherView` handles those via its own `mouseDown`/`mouseUp` +/// overrides), so the monitor never dequeues a click meant for AppKit. +@MainActor final class ProviderSwitcherShortcutEventMonitor { - private let events: NSEvent.EventTypeMask private let callback: @MainActor (NSEvent) -> Bool private let observer: CFRunLoopObserver + private let trackingState = ProviderSwitcherMenuTrackingState() private var isActive = false - init(events: NSEvent.EventTypeMask, callback: @escaping @MainActor (NSEvent) -> Bool) { - self.events = events + /// A run-loop mode nothing else registers sources or timers in, so running the loop in + /// this mode while polling the event queue cannot dispatch menu-session work re-entrantly. + private static let peekMode = RunLoop.Mode("com.steipete.codexbar.switcher-peek") + + init( + events: NSEvent.EventTypeMask, + peekGate: ProviderSwitcherEventPeekGate = ProviderSwitcherEventPeekGate( + eventTypes: [.keyDown, .keyUp, .scrollWheel]), + callback: @escaping @MainActor (NSEvent) -> Bool) + { self.callback = callback + let trackingState = self.trackingState self.observer = CFRunLoopObserverCreateWithHandler( nil, CFRunLoopActivity.beforeSources.rawValue, true, 0) - { [events, callback] _, _ in + { [events, peekGate, callback, trackingState] _, _ in MainActor.assumeIsolated { + guard trackingState.isTrackingActive else { return } + guard peekGate.shouldPeek() else { return } + var foundEvent = false + var blockedByUnhandledEvent = false while let event = NSApp.nextEvent( matching: events, until: .distantPast, - inMode: .eventTracking, + inMode: Self.peekMode, dequeue: false) { - guard callback(event) else { break } + foundEvent = true + peekGate.observe(event) + guard callback(event) else { + blockedByUnhandledEvent = true + break + } _ = NSApp.nextEvent( matching: events, until: .distantPast, - inMode: .eventTracking, + inMode: Self.peekMode, dequeue: true) } + if !blockedByUnhandledEvent { + peekGate.observeQueueEmpty(afterFindingEvent: foundEvent) + } } } } deinit { - self.stop() + MainActor.assumeIsolated { + self.stop() + } } func start() { @@ -46,9 +158,21 @@ final class ProviderSwitcherShortcutEventMonitor { self.observer, CFRunLoopMode(RunLoop.Mode.eventTracking.rawValue as CFString)) self.isActive = true + // The menus this monitors are shown via `popUpMenuPositioningItem`, which posts no + // NSMenu tracking notifications. Arm the gate from a block queued in the tracking + // run-loop mode instead: it can only execute once the menu's tracking session is alive + // and pumping the run loop, which keeps peeks away from menu setup. + let trackingState = self.trackingState + RunLoop.main.perform(inModes: [.eventTracking]) { + MainActor.assumeIsolated { + trackingState.isTrackingActive = true + } + } + CFRunLoopWakeUp(CFRunLoopGetMain()) } func stop() { + self.trackingState.isTrackingActive = false guard self.isActive else { return } CFRunLoopRemoveObserver( RunLoop.main.getCFRunLoop(), @@ -58,26 +182,78 @@ final class ProviderSwitcherShortcutEventMonitor { } } +/// Tracks whether an `NSMenu` tracking session is currently alive, so the shortcut monitor +/// only touches the event queue while AppKit is actually pumping it. +@MainActor +private final class ProviderSwitcherMenuTrackingState { + var isTrackingActive = false +} + +@MainActor +private final class ProviderSwitcherTrackingRunLoopOperation { + private var operation: (@MainActor () -> Void)? + + init(operation: @escaping @MainActor () -> Void) { + self.operation = operation + } + + func run() { + guard let operation = self.operation else { return } + self.operation = nil + operation() + } +} + +@MainActor +enum ProviderSwitcherTrackingRunLoopScheduler { + static func schedule(_ operation: @escaping @MainActor () -> Void) { + let pending = ProviderSwitcherTrackingRunLoopOperation(operation: operation) + let runLoop = CFRunLoopGetMain() + // Main-actor tasks can starve while AppKit owns the modal menu loop. Queue in both modes so the + // rebuild runs during tracking, with the default mode as a fallback if tracking ends first. + let modes = [ + RunLoop.Mode.eventTracking.rawValue, + RunLoop.Mode.default.rawValue, + ] + for mode in modes { + CFRunLoopPerformBlock(runLoop, mode as CFString) { + MainActor.assumeIsolated { + pending.run() + } + } + } + CFRunLoopWakeUp(runLoop) + } +} + extension StatusItemController { func installProviderSwitcherShortcutMonitorIfNeeded(for menu: NSMenu) { - guard Self.menuRefreshEnabled, - self.shouldMergeIcons, - menu.items.first?.view is ProviderSwitcherView - else { + guard self.isMenuRefreshEnabled else { + return + } + let hasProviderSwitcher = self.shouldMergeIcons && menu.items.first?.view is ProviderSwitcherView + let hasPersistentRefresh = menu.items.contains { self.isPersistentRefreshItem($0) } + guard hasProviderSwitcher || hasPersistentRefresh else { return } self.removeProviderSwitcherShortcutMonitor() - let monitor = ProviderSwitcherShortcutEventMonitor(events: [.keyDown]) { [weak self, weak menu] event in + self.resetOverviewScrollAccumulation() + // Every tracked menu observes wheel events so a manual scroll made after Refresh + // invalidates that refresh's pending viewport restore. Unhandled wheel events remain + // queued for AppKit's native menu scroller. + let eventMask: NSEvent.EventTypeMask = [.keyDown, .keyUp, .scrollWheel] + let monitor = ProviderSwitcherShortcutEventMonitor( + events: eventMask) + { [weak self, weak menu] event in guard let self, let menu, - self.openMenus[ObjectIdentifier(menu)] != nil, - menu.items.first?.view is ProviderSwitcherView + self.openMenus[ObjectIdentifier(menu)] != nil else { return false } - return self.handleProviderSwitcherShortcut(event, menu: menu) + return self.handleMenuTrackingShortcutEvent(event, menu: menu) } monitor.start() self.providerSwitcherShortcutEventMonitor = monitor @@ -88,6 +264,26 @@ extension StatusItemController { self.providerSwitcherShortcutEventMonitor?.stop() self.providerSwitcherShortcutEventMonitor = nil self.providerSwitcherShortcutMenuID = nil + self.clearProviderSwitcherPointerInteraction() + } + + @discardableResult + func handleMenuTrackingShortcutEvent(_ event: NSEvent, menu: NSMenu) -> Bool { + if event.type == .scrollWheel { + self.advanceMenuInteraction(for: menu) + } + if StatusItemMenu.isPersistentRefreshShortcut(for: event), + menu.items.contains(where: self.isPersistentRefreshItem) + { + if let menu = menu as? StatusItemMenu { + menu.requestPersistentRefreshAction() + } else { + self.performPersistentRefreshAction(in: ObjectIdentifier(menu)) + } + return true + } + guard menu.items.first?.view is ProviderSwitcherView else { return false } + return self.handleProviderSwitcherTrackingEvent(event, menu: menu) } func providerSwitcherContentStartIndex(in menu: NSMenu) -> Int { @@ -100,12 +296,85 @@ extension StatusItemController { return self.selectProviderSwitcherSegment(at: index, menu: menu) } if let direction = StatusItemMenu.providerNavigationDirection(for: event) { - self.navigateProviderSwitcher(direction) + self.navigateProviderSwitcher(direction, menu: menu) return true } return false } + @discardableResult + func handleProviderSwitcherTrackingEvent(_ event: NSEvent, menu: NSMenu) -> Bool { + switch event.type { + case .keyDown: + return self.handleProviderSwitcherShortcut(event, menu: menu) + case .leftMouseDown: + guard let switcher = menu.items.first?.view as? ProviderSwitcherView else { return false } + self.beginProviderSwitcherPointerInteraction(in: menu) + let handled = switcher.handleMenuTrackingMouseDown(event) + if !handled { + self.clearProviderSwitcherPointerInteraction(in: menu) + } + return handled + case .leftMouseUp: + guard self.providerSwitcherPointerInteractionMenuID == ObjectIdentifier(menu) else { + return false + } + guard let switcher = menu.items.first?.view as? ProviderSwitcherView else { + self.clearProviderSwitcherPointerInteraction(in: menu) + return true + } + _ = switcher.handleMenuTrackingMouseUp(event) + self.finishProviderSwitcherPointerInteraction(in: menu) + return true + case .scrollWheel: + return self.handleOverviewScrollWheel(event, menu: menu) + default: + return false + } + } + + func requestProviderSwitcherMenuRebuild(_ menu: NSMenu, provider: UsageProvider?) { + guard self.providerSwitcherPointerInteractionMenuID == ObjectIdentifier(menu) else { + self.deferSwitcherMenuRebuildIfStillVisible(menu, provider: provider) + return + } + self.pendingProviderSwitcherPointerRebuild = PendingProviderSwitcherRebuild( + menu: menu, + provider: provider) + } + + private func beginProviderSwitcherPointerInteraction(in menu: NSMenu) { + let menuID = ObjectIdentifier(menu) + if self.providerSwitcherPointerInteractionMenuID != menuID { + self.pendingProviderSwitcherPointerRebuild = nil + } + self.providerSwitcherPointerInteractionMenuID = menuID + } + + private func finishProviderSwitcherPointerInteraction(in menu: NSMenu) { + let menuID = ObjectIdentifier(menu) + guard self.providerSwitcherPointerInteractionMenuID == menuID else { return } + self.providerSwitcherPointerInteractionMenuID = nil + guard let pending = self.pendingProviderSwitcherPointerRebuild, + pending.menu === menu + else { + self.pendingProviderSwitcherPointerRebuild = nil + return + } + self.pendingProviderSwitcherPointerRebuild = nil + self.deferSwitcherMenuRebuildIfStillVisible(menu, provider: pending.provider) + } + + private func clearProviderSwitcherPointerInteraction(in menu: NSMenu? = nil) { + if let menu, + self.providerSwitcherPointerInteractionMenuID != ObjectIdentifier(menu) + { + return + } + self.providerSwitcherPointerInteractionMenuID = nil + self.pendingProviderSwitcherPointerRebuild = nil + } + @discardableResult private func selectProviderSwitcherSegment(at index: Int, menu: NSMenu) -> Bool { guard let switcherView = menu.items.first?.view as? ProviderSwitcherView, @@ -113,7 +382,6 @@ extension StatusItemController { else { return false } - self.applyIcon(phase: nil) return true } } diff --git a/Sources/CodexBar/StatusItemController+Shutdown.swift b/Sources/CodexBar/StatusItemController+Shutdown.swift index 7dcfdcff8d..e6085be31c 100644 --- a/Sources/CodexBar/StatusItemController+Shutdown.swift +++ b/Sources/CodexBar/StatusItemController+Shutdown.swift @@ -22,10 +22,20 @@ extension StatusItemController { } private func cancelShutdownTasks() { + self.agentSessions.stop() self.blinkTask?.cancel() self.blinkTask = nil + self.menuBarCountdownRefreshTask?.cancel() + self.menuBarCountdownRefreshTask = nil self.loginTask?.cancel() self.loginTask = nil + for task in self.manualRefreshTasks.values { + task.cancel() + } + self.manualRefreshTasks.removeAll() + self.store.cancelForcedRefreshEnrichment() + self.store.cancelRequiredRefresh() + self.menuCardRefreshMonitor.resetManualRefresh() self.screenChangeVisibilityTask?.cancel() self.screenChangeVisibilityTask = nil self.pendingScreenChangePreviousCount = nil @@ -46,21 +56,42 @@ 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 + self.codexAccountMenuProjectionRevalidationTask?.cancel() + self.codexAccountMenuProjectionRevalidationTask = nil + self.providerSelectionUIRefreshTask?.cancel() + self.providerSelectionUIRefreshTask = nil + self.deferredMergedIconRenderAfterTracking = false + self.providerSwitcherPointerInteractionMenuID = nil + self.pendingProviderSwitcherPointerRebuild = nil } private func clearShutdownMenuState() { self.removeProviderSwitcherShortcutMonitor() self.menuRefreshTasks.removeAll(keepingCapacity: false) + self.closedMenuRebuildTasks.removeAll(keepingCapacity: false) + self.closedMenuRebuildRequests.cancelAll() self.openMenuRebuildTasks.removeAll(keepingCapacity: false) - self.openMenuRebuildTokens.removeAll(keepingCapacity: false) + self.openMenuRebuildRequests.cancelAll() self.openMenuRebuildsClosingHostedSubviewMenus.removeAll(keepingCapacity: false) + self.menuSession.clearMenuTracking() + self.manualRefreshViewportRestoreState.deferredUntilRebuild.removeAll(keepingCapacity: false) + self.manualRefreshViewportRestoreState.stopAllMovementTracking() self.openMenus.removeAll(keepingCapacity: false) self.highlightedMenuItems.removeAll(keepingCapacity: false) + self.nativeHighlightDeferredMenuRebuilds.removeAll(keepingCapacity: false) + self.pendingMenuBaselineResyncs.removeAll(keepingCapacity: false) + self.menuCardHeightCache.removeAll(keepingCapacity: false) + self.measuredStandardMenuWidthCache.removeAll(keepingCapacity: false) + self.mergedSwitcherContentCaches.removeAll(keepingCapacity: false) self.menuProviders.removeAll(keepingCapacity: false) - self.menuVersions.removeAll(keepingCapacity: false) + self.menuReadinessSignatures.removeAll(keepingCapacity: false) + self.menuIdentitySignatures.removeAll(keepingCapacity: false) self.providerMenus.removeAll(keepingCapacity: false) self.mergedMenu = nil self.fallbackMenu = nil diff --git a/Sources/CodexBar/StatusItemController+StatusItemVending.swift b/Sources/CodexBar/StatusItemController+StatusItemVending.swift new file mode 100644 index 0000000000..492eccf2d5 --- /dev/null +++ b/Sources/CodexBar/StatusItemController+StatusItemVending.swift @@ -0,0 +1,41 @@ +import AppKit +import CodexBarCore + +extension StatusItemController { + /// Lazily retrieves or creates a status item for the given provider. + func lazyStatusItem(for provider: UsageProvider) -> NSStatusItem { + self.vendStatusItem(for: provider) + } + + private func vendStatusItem( + for provider: UsageProvider, + onCreated: ((NSStatusItem) -> Void)? = nil) + -> NSStatusItem + { + if let existing = self.statusItems[provider] { + return existing + } + return Self.makeStatusItem( + statusBar: self.statusBar, + identity: .provider(provider), + defaults: self.settings.userDefaults, + legacyDefaultItemIndex: self.legacyDefaultItemIndex(forNewProvider: provider), + onCreated: { item in + // Register before invoking the caller/setup callbacks: button configuration and + // icon-observation can synchronously re-enter vending for this provider, and an + // unregistered item there vends a duplicate (issue #2162). + self.statusItems[provider] = item + onCreated?(item) + }) + } + + #if DEBUG + func _test_vendStatusItem( + for provider: UsageProvider, + onCreated: @escaping (NSStatusItem) -> Void) + -> NSStatusItem + { + self.vendStatusItem(for: provider, onCreated: onCreated) + } + #endif +} diff --git a/Sources/CodexBar/StatusItemController+StorageMenuCard.swift b/Sources/CodexBar/StatusItemController+StorageMenuCard.swift new file mode 100644 index 0000000000..efebe77e63 --- /dev/null +++ b/Sources/CodexBar/StatusItemController+StorageMenuCard.swift @@ -0,0 +1,26 @@ +import AppKit +import CodexBarCore + +extension StatusItemController { + @discardableResult + func addStorageMenuCardSection(to menu: NSMenu, provider: UsageProvider, width: CGFloat) -> Bool { + guard let storageText = self.store.storageFootprintText(for: provider) else { return false } + let storageSubmenu = self.makeStorageBreakdownSubmenu(provider: provider, width: width) + menu.addItem(Self.makeNativeStorageMenuCardItem(storageText: storageText, submenu: storageSubmenu)) + return true + } + + private static func makeNativeStorageMenuCardItem(storageText: String, submenu: NSMenu?) -> NSMenuItem { + let menuFont = NSFont.menuFont(ofSize: 0) + let title = NSMutableAttributedString(string: L("Storage"), attributes: [.font: menuFont]) + title.append(NSAttributedString( + string: " \(storageText)", + attributes: [.font: menuFont, .foregroundColor: NSColor.secondaryLabelColor])) + let item = NSMenuItem(title: L("Storage"), action: nil, keyEquivalent: "") + item.attributedTitle = title + item.isEnabled = submenu != nil + item.representedObject = "menuCardStorage" + item.submenu = submenu + return item + } +} diff --git a/Sources/CodexBar/StatusItemController+SwitcherMetrics.swift b/Sources/CodexBar/StatusItemController+SwitcherMetrics.swift index d078111143..47c4f1377c 100644 --- a/Sources/CodexBar/StatusItemController+SwitcherMetrics.swift +++ b/Sources/CodexBar/StatusItemController+SwitcherMetrics.swift @@ -4,9 +4,20 @@ extension StatusItemController { nonisolated static func switcherWeeklyMetricPercent( for provider: UsageProvider, snapshot: UsageSnapshot?, - showUsed: Bool) -> Double? + showUsed: Bool, + preference: MenuBarMetricPreference = .automatic) -> Double? { - let window = snapshot?.switcherWeeklyWindow(for: provider, showUsed: showUsed) + let window: RateWindow? = if preference == .monthlyPlan { + MenuBarMetricWindowResolver.rateWindow( + preference: preference, + provider: provider, + snapshot: snapshot, + supportsAverage: false) + } else if provider == .mistral { + nil + } else { + snapshot?.switcherWeeklyWindow(for: provider, showUsed: showUsed) + } guard let window else { return nil } return showUsed ? window.usedPercent : window.remainingPercent } diff --git a/Sources/CodexBar/StatusItemController+SwitcherViews.swift b/Sources/CodexBar/StatusItemController+SwitcherViews.swift index ee6d2048f0..9bcef6a293 100644 --- a/Sources/CodexBar/StatusItemController+SwitcherViews.swift +++ b/Sources/CodexBar/StatusItemController+SwitcherViews.swift @@ -2,7 +2,7 @@ import AppKit import CodexBarCore import QuartzCore -enum ProviderSwitcherSelection: Equatable { +enum ProviderSwitcherSelection: Hashable { case overview case provider(UsageProvider) } @@ -40,14 +40,10 @@ final class ProviderSwitcherView: NSView { private var preferredWidth: CGFloat = 0 private var hoveredButtonTag: Int? private var pressedButtonTag: Int? - private let lightModeOverlayLayer = CALayer() - private static let quotaIndicatorHeight: CGFloat = 3 + private var selectedSegmentIndex: Int? + private static let quotaIndicatorHeight: CGFloat = 2 private static let quotaIndicatorBottomInset: CGFloat = 2 private static let quotaIndicatorHorizontalInset: CGFloat = 8 - private static let quotaIndicatorContentGap: CGFloat = 3 - private static var quotaIndicatorReservedHeight: CGFloat { - quotaIndicatorContentGap + quotaIndicatorHeight + quotaIndicatorBottomInset - } init( providers: [UsageProvider], @@ -102,7 +98,7 @@ final class ProviderSwitcherView: NSView { maxAllowedSegmentWidth: initialMaxAllowedSegmentWidth, stackedIcons: self.stackedIcons) self.rowSpacing = self.stackedIcons ? 4 : 2 - self.rowHeight = Self.switcherRowHeight(stackedIcons: self.stackedIcons) + self.rowHeight = Self.switcherButtonHeight(stackedIcons: self.stackedIcons, rowCount: self.rowCount) let height: CGFloat = self.rowHeight * CGFloat(self.rowCount) + self.rowSpacing * CGFloat(max(0, self.rowCount - 1)) self.preferredWidth = width @@ -110,20 +106,6 @@ final class ProviderSwitcherView: NSView { Self.clearButtonWidthCache() self.wantsLayer = true self.layer?.masksToBounds = false - self.lightModeOverlayLayer.masksToBounds = false - self.layer?.insertSublayer(self.lightModeOverlayLayer, at: 0) - self.updateLightModeStyling() - - let layoutCount = Self.layoutCount(for: self.segments.count, rows: self.rowCount) - let outerPadding: CGFloat = Self.switcherOuterPadding( - for: width, - count: layoutCount, - minimumGap: minimumGap) - let maxAllowedSegmentWidth = Self.maxAllowedUniformSegmentWidth( - for: width, - count: layoutCount, - outerPadding: outerPadding, - minimumGap: minimumGap) func makeButton(index: Int, segment: Segment) -> NSButton { let button: NSButton @@ -163,13 +145,6 @@ final class ProviderSwitcherView: NSView { button.imagePosition = .noImage } - let remaining: Double? = switch segment.selection { - case let .provider(provider): - self.weeklyRemainingProvider(provider) - case .overview: - nil - } - self.addQuotaIndicator(to: button, selection: segment.selection, remainingPercent: remaining) button.bezelStyle = .regularSquare button.isBordered = false button.controlSize = .small @@ -182,6 +157,7 @@ final class ProviderSwitcherView: NSView { button.state = (selected == segment.selection) ? .on : .off button.toolTip = nil button.translatesAutoresizingMaskIntoConstraints = false + button.heightAnchor.constraint(equalToConstant: self.rowHeight).isActive = true self.buttons.append(button) return button } @@ -189,24 +165,41 @@ final class ProviderSwitcherView: NSView { for (index, segment) in self.segments.enumerated() { let button = makeButton(index: index, segment: segment) self.addSubview(button) + self.addQuotaIndicator( + to: button, + selection: segment.selection, + remainingPercent: self.remainingPercent(for: segment.selection)) + } + self.selectedSegmentIndex = selected.flatMap { selected in + self.segments.firstIndex { $0.selection == selected } } + let layoutCount = Self.layoutCount(for: self.segments.count, rows: self.rowCount) + let requiredUniformWidth = self.stackedIcons + ? nil + : self.buttons.map(Self.maxToggleWidth(for:)).max() + let layoutMetrics = Self.switcherLayoutMetrics( + for: width, + count: layoutCount, + minimumGap: minimumGap, + requiredSegmentWidth: requiredUniformWidth) + let uniformWidth: CGFloat if self.rowCount > 1 || !self.stackedIcons { - uniformWidth = self.applyUniformSegmentWidth(maxAllowedWidth: maxAllowedSegmentWidth) + uniformWidth = self.applyUniformSegmentWidth(maxAllowedWidth: layoutMetrics.maxAllowedSegmentWidth) if uniformWidth > 0 { self.segmentWidths = Array(repeating: uniformWidth, count: self.buttons.count) } } else { self.segmentWidths = self.applyNonUniformSegmentWidths( totalWidth: width, - outerPadding: outerPadding, + outerPadding: layoutMetrics.outerPadding, minimumGap: minimumGap) uniformWidth = 0 } self.applyLayout( - outerPadding: outerPadding, + outerPadding: layoutMetrics.outerPadding, minimumGap: minimumGap, uniformWidth: uniformWidth) if width > 0 { @@ -217,14 +210,8 @@ final class ProviderSwitcherView: NSView { self.updateButtonStyles() } - override func layout() { - super.layout() - self.lightModeOverlayLayer.frame = self.bounds - } - override func viewDidChangeEffectiveAppearance() { super.viewDidChangeEffectiveAppearance() - self.updateLightModeStyling() self.updateButtonStyles() } @@ -261,7 +248,7 @@ final class ProviderSwitcherView: NSView { override func mouseMoved(with event: NSEvent) { let location = self.convert(event.locationInWindow, from: nil) - let hoveredTag = self.buttons.first(where: { $0.frame.contains(location) })?.tag + let hoveredTag = self.button(at: location)?.tag guard hoveredTag != self.hoveredButtonTag else { return } self.hoveredButtonTag = hoveredTag self.updateButtonStyles() @@ -294,21 +281,52 @@ final class ProviderSwitcherView: NSView { } override func mouseDown(with event: NSEvent) { - let location = self.convert(event.locationInWindow, from: nil) - self.pressedButtonTag = self.buttons.first(where: { $0.frame.contains(location) })?.tag + _ = self.handleMenuTrackingMouseDown(event) } override func mouseUp(with event: NSEvent) { - defer { self.pressedButtonTag = nil } - guard let pressedTag = self.pressedButtonTag else { return } - let location = self.convert(event.locationInWindow, from: nil) - guard let releasedTag = self.buttons.first(where: { $0.frame.contains(location) })?.tag, - releasedTag == pressedTag, + _ = self.handleMenuTrackingMouseUp(event) + } + + @discardableResult + func handleMenuTrackingMouseDown(_ event: NSEvent) -> Bool { + guard event.type == .leftMouseDown else { return false } + let location = self.locationInView(for: event) + guard let pressedTag = self.button(at: location)?.tag, self.segments.indices.contains(pressedTag) else { - return + return false } + self.pressedButtonTag = pressedTag + return true + } + + @discardableResult + func handleMenuTrackingMouseUp(_ event: NSEvent) -> Bool { + guard event.type == .leftMouseUp else { return false } + defer { self.pressedButtonTag = nil } + guard let pressedTag = self.pressedButtonTag else { return false } + let location = self.locationInView(for: event) + guard let releasedTag = self.button(at: location)?.tag, + releasedTag == pressedTag + else { + return true + } + // Commit only after the matching release. The controller schedules structural menu + // replacement after this callback returns so AppKit can finish the tracking transaction. self.applySelection(at: pressedTag) + return true + } + + private func locationInView(for event: NSEvent) -> NSPoint { + guard let eventWindow = event.window, + let viewWindow = self.window, + eventWindow !== viewWindow + else { + return self.convert(event.locationInWindow, from: nil) + } + let screenLocation = eventWindow.convertPoint(toScreen: event.locationInWindow) + return self.convert(viewWindow.convertPoint(fromScreen: screenLocation), from: nil) } func handleKeyboardSelection(at index: Int) -> Bool { @@ -319,22 +337,14 @@ final class ProviderSwitcherView: NSView { private func applySelection(at index: Int) { let selection = self.segments[index].selection + guard self.selectedSegmentIndex != index else { + self.updateSelection(selection) + return + } self.updateSelection(selection) self.onSelect(selection) } - #if DEBUG - /// Simulates the runtime click path (mouseDown → mouseUp on this view) that the menu uses - /// in production, bypassing `NSButton.performClick`. Tests use this to cover the path that - /// regressed in issue #867. - @discardableResult - func _test_simulateRuntimeClick(buttonTag: Int) -> Bool { - guard self.segments.indices.contains(buttonTag) else { return false } - self.applySelection(at: buttonTag) - return true - } - #endif - private func applyLayout( outerPadding: CGFloat, minimumGap: CGFloat, @@ -553,12 +563,17 @@ final class ProviderSwitcherView: NSView { return rows } - private static func switcherRowHeight(stackedIcons: Bool) -> CGFloat { - let baseRowHeight: CGFloat = stackedIcons ? 36 : 30 - return baseRowHeight + self.quotaIndicatorReservedHeight + private static func switcherButtonHeight(stackedIcons: Bool, rowCount: Int) -> CGFloat { + guard stackedIcons else { return 30 } + return rowCount >= 3 ? 39 : 36 } - private static func switcherOuterPadding(for width: CGFloat, count: Int, minimumGap: CGFloat) -> CGFloat { + private static func switcherOuterPadding( + for width: CGFloat, + count: Int, + minimumGap: CGFloat, + requiredSegmentWidth: CGFloat? = nil) -> CGFloat + { // Align with the card's left/right content grid when possible. let preferred: CGFloat = 16 let reduced: CGFloat = 10 @@ -573,8 +588,27 @@ final class ProviderSwitcherView: NSView { // Only sacrifice padding when we'd otherwise squeeze buttons into unreadable widths. let minimumComfortableAverage: CGFloat = count >= 5 ? 50 : 54 - if averageButtonWidth(outerPadding: preferred) >= minimumComfortableAverage { return preferred } - if averageButtonWidth(outerPadding: reduced) >= minimumComfortableAverage { return reduced } + func fits(outerPadding: CGFloat) -> Bool { + if let requiredSegmentWidth { + let allowedWidth = self.maxAllowedUniformSegmentWidth( + for: width, + count: count, + outerPadding: outerPadding, + minimumGap: minimumGap) + let evenAllowedWidth = allowedWidth.truncatingRemainder(dividingBy: 2) == 0 + ? allowedWidth + : allowedWidth - 1 + let desiredWidth = ceil(requiredSegmentWidth) + let evenDesiredWidth = desiredWidth.truncatingRemainder(dividingBy: 2) == 0 + ? desiredWidth + : desiredWidth + 1 + return evenAllowedWidth >= evenDesiredWidth + } + return averageButtonWidth(outerPadding: outerPadding) >= minimumComfortableAverage + } + + if fits(outerPadding: preferred) { return preferred } + if fits(outerPadding: reduced) { return reduced } return minimal } @@ -588,10 +622,15 @@ final class ProviderSwitcherView: NSView { } func updateSelection(_ selection: ProviderSwitcherSelection) { + var selectedIndex: Int? for (index, button) in self.buttons.enumerated() { let isSelected = self.segments.indices.contains(index) && self.segments[index].selection == selection + if isSelected { + selectedIndex = index + } button.state = isSelected ? .on : .off } + self.selectedSegmentIndex = selectedIndex self.updateButtonStyles() } @@ -603,26 +642,23 @@ final class ProviderSwitcherView: NSView { for (index, button) in self.buttons.enumerated() { guard self.segments.indices.contains(index) else { continue } let segment = self.segments[index] - let remaining: Double? = switch segment.selection { - case let .provider(provider): - self.weeklyRemainingProvider(provider) - case .overview: - nil - } + let remaining = self.remainingPercent(for: segment.selection) let key = ObjectIdentifier(button) if let remaining { if var indicator = self.quotaIndicators[key] { - Self.updateQuotaIndicatorFill( - indicator: &indicator, - remainingPercent: remaining, - selection: segment.selection) - self.quotaIndicators[key] = indicator + let newRatio = Self.quotaIndicatorRatio(remainingPercent: remaining) + if newRatio != indicator.fillRatio { + Self.updateQuotaIndicatorFill( + indicator: &indicator, + remainingPercent: remaining, + selection: segment.selection) + self.quotaIndicators[key] = indicator + } } else { self.addQuotaIndicator(to: button, selection: segment.selection, remainingPercent: remaining) } } else if let indicator = self.quotaIndicators.removeValue(forKey: key) { - Self.applyQuotaBarContentInset(to: button, height: 0) indicator.track.removeFromSuperview() continue } @@ -630,6 +666,15 @@ final class ProviderSwitcherView: NSView { } } + private func remainingPercent(for selection: ProviderSwitcherSelection) -> Double? { + switch selection { + case let .provider(provider): + self.weeklyRemainingProvider(provider) + case .overview: + nil + } + } + @objc private func handleSelection(_ sender: NSButton) { let index = sender.tag guard self.segments.indices.contains(index) else { return } @@ -658,54 +703,10 @@ final class ProviderSwitcherView: NSView { } } - #if DEBUG - func _test_buttonFrames() -> [NSRect] { - self.buttons.map(\.frame) - } - - func _test_buttonFittingSizes() -> [NSSize] { - self.buttons.map(\.fittingSize) - } - - func _test_rowCount() -> Int { - self.rowCount - } - - func _test_rowHeight() -> CGFloat { - self.rowHeight - } - - func _test_setHoveredButtonTag(_ tag: Int?) { - self.hoveredButtonTag = tag - self.updateButtonStyles() - } - - func _test_quotaIndicatorFillRatios() -> [CGFloat] { - self.buttons.compactMap { button in - self.quotaIndicators[ObjectIdentifier(button)]?.fillRatio - } - } - - func _test_quotaIndicatorFillFrames() -> [NSRect] { - self.buttons.compactMap { button in - self.quotaIndicators[ObjectIdentifier(button)]?.fill.frame - } - } - #endif - private func isLightMode() -> Bool { self.effectiveAppearance.bestMatch(from: [.aqua, .darkAqua]) == .aqua } - private func updateLightModeStyling() { - guard self.isLightMode() else { - self.lightModeOverlayLayer.backgroundColor = nil - return - } - // The menu card background is very bright in light mode; add a subtle neutral wash to ground the switcher. - self.lightModeOverlayLayer.backgroundColor = NSColor.black.withAlphaComponent(0.035).cgColor - } - private func hoverPlateColor() -> CGColor { if self.isLightMode() { return NSColor.black.withAlphaComponent(0.095).cgColor @@ -896,10 +897,172 @@ final class ProviderSwitcherView: NSView { } } +extension ProviderSwitcherView { + private static func switcherLayoutMetrics( + for width: CGFloat, + count: Int, + minimumGap: CGFloat, + requiredSegmentWidth: CGFloat?) -> (outerPadding: CGFloat, maxAllowedSegmentWidth: CGFloat) + { + let outerPadding = self.switcherOuterPadding( + for: width, + count: count, + minimumGap: minimumGap, + requiredSegmentWidth: requiredSegmentWidth) + let maxAllowedSegmentWidth = self.maxAllowedUniformSegmentWidth( + for: width, + count: count, + outerPadding: outerPadding, + minimumGap: minimumGap) + return (outerPadding, maxAllowedSegmentWidth) + } +} + +extension ProviderSwitcherView { + fileprivate func button(at location: NSPoint) -> NSButton? { + self.buttons.first { $0.frame.contains(location) } + } +} + +#if DEBUG +extension ProviderSwitcherView { + func _test_mouseDownEvent(buttonTag: Int) -> NSEvent? { + self._test_mouseEvent(buttonTag: buttonTag, type: .leftMouseDown) + } + + func _test_mouseUpEvent(buttonTag: Int) -> NSEvent? { + self._test_mouseEvent(buttonTag: buttonTag, type: .leftMouseUp) + } + + func _test_quotaIndicatorMouseEvent(buttonTag: Int, type: NSEvent.EventType) -> NSEvent? { + guard let button = self.buttons.first(where: { $0.tag == buttonTag }), + let track = self.quotaIndicators[ObjectIdentifier(button)]?.track + else { + return nil + } + self.updateConstraintsForSubtreeIfNeeded() + self.layoutSubtreeIfNeeded() + let point = self.convert(NSPoint(x: track.bounds.midX, y: track.bounds.midY), from: track) + return self._test_mouseEvent(at: point, type: type) + } + + private func _test_mouseEvent(buttonTag: Int, type: NSEvent.EventType) -> NSEvent? { + guard let button = self.buttons.first(where: { $0.tag == buttonTag }) else { return nil } + self.updateConstraintsForSubtreeIfNeeded() + self.layoutSubtreeIfNeeded() + let point = self.convert(NSPoint(x: button.bounds.midX, y: button.bounds.midY), from: button) + return self._test_mouseEvent(at: point, type: type) + } + + private func _test_mouseEvent(at point: NSPoint, type: NSEvent.EventType) -> NSEvent? { + NSEvent.mouseEvent( + with: type, + location: point, + modifierFlags: [], + timestamp: 0, + windowNumber: 0, + context: nil, + eventNumber: type == .leftMouseDown ? 1 : 2, + clickCount: 1, + pressure: type == .leftMouseDown ? 1 : 0) + } + + @discardableResult + func _test_simulateMouseDown(buttonTag: Int) -> Bool { + guard let event = self._test_mouseDownEvent(buttonTag: buttonTag) else { return false } + return self.handleMenuTrackingMouseDown(event) + } + + /// Simulates the parent-view event path used while NSMenu owns mouse tracking. + @discardableResult + func _test_simulateRuntimeClick(buttonTag: Int) -> Bool { + guard self._test_simulateMouseDown(buttonTag: buttonTag) else { return false } + guard let event = self._test_mouseUpEvent(buttonTag: buttonTag) else { return false } + guard self.handleMenuTrackingMouseUp(event) else { return false } + return self.selectedSegmentIndex == buttonTag + } + + @discardableResult + func _test_simulateRuntimeClickOnQuotaIndicator(buttonTag: Int) -> Bool { + guard let mouseDown = self._test_quotaIndicatorMouseEvent(buttonTag: buttonTag, type: .leftMouseDown), + self.handleMenuTrackingMouseDown(mouseDown), + let mouseUp = self._test_quotaIndicatorMouseEvent(buttonTag: buttonTag, type: .leftMouseUp), + self.handleMenuTrackingMouseUp(mouseUp) + else { + return false + } + return self.selectedSegmentIndex == buttonTag + } + + @discardableResult + func _test_simulateNativeAction(buttonTag: Int, state: NSControl.StateValue) -> Bool { + guard let button = self.buttons.first(where: { $0.tag == buttonTag }) else { return false } + button.state = state + self.handleSelection(button) + return true + } + + func _test_buttonFrames() -> [NSRect] { + self.buttons.map(\.frame) + } + + func _test_buttonFittingSizes() -> [NSSize] { + self.buttons.map(\.fittingSize) + } + + func _test_buttonDesiredWidths() -> [CGFloat] { + self.buttons.map(Self.maxToggleWidth(for:)) + } + + func _test_buttonContentFrames() -> [NSRect?] { + self.buttons.map { button in + button.subviews.first(where: { $0 is NSStackView })?.frame + } + } + + func _test_rowCount() -> Int { + self.rowCount + } + + func _test_rowHeight() -> CGFloat { + self.rowHeight + } + + func _test_setHoveredButtonTag(_ tag: Int?) { + self.hoveredButtonTag = tag + self.updateButtonStyles() + } + + func _test_quotaIndicatorFillRatios() -> [CGFloat] { + self.buttons.compactMap { button in + self.quotaIndicators[ObjectIdentifier(button)]?.fillRatio + } + } + + func _test_quotaIndicatorFillFrames() -> [NSRect] { + self.buttons.compactMap { button in + self.quotaIndicators[ObjectIdentifier(button)]?.fill.frame + } + } + + func _test_quotaIndicatorTrackFrames() -> [NSRect] { + self.buttons.compactMap { button in + guard let track = self.quotaIndicators[ObjectIdentifier(button)]?.track else { return nil } + return self.convert(track.bounds, from: track) + } + } + + func _test_quotaIndicatorConstraintIdentifiers() -> [ObjectIdentifier] { + self.buttons.compactMap { button in + self.quotaIndicators[ObjectIdentifier(button)].map { ObjectIdentifier($0.fillWidthConstraint) } + } + } +} +#endif + extension ProviderSwitcherView { private func addQuotaIndicator(to view: NSView, selection: ProviderSwitcherSelection, remainingPercent: Double?) { guard let remainingPercent else { return } - Self.applyQuotaBarContentInset(to: view) let track = NSView() track.wantsLayer = true @@ -947,13 +1110,6 @@ extension ProviderSwitcherView { self.updateQuotaIndicatorVisibility(for: view) } - fileprivate static func applyQuotaBarContentInset( - to view: NSView, - height: CGFloat = quotaIndicatorReservedHeight) - { - (view as? ProviderSwitcherToggleButton)?.setQuotaBarReservedHeight(height) - } - private func updateQuotaIndicatorVisibility(for view: NSView) { guard let indicator = self.quotaIndicators[ObjectIdentifier(view)] else { return } let isSelected = (view as? NSButton)?.state == .on @@ -1283,17 +1439,13 @@ final class CodexAccountSwitcherView: NSView { var emailWidth = max(minimumEmailWidth, contentWidth * 0.58) var workspaceWidth = max(minimumWorkspaceWidth, contentWidth - emailWidth) - /// Note: takes the widths as parameters rather than capturing the mutable - /// `emailWidth` / `workspaceWidth` vars below. Capturing those `var`s in a - /// nested function crashes swift-frontend (IRGen, SIGABRT) under the - /// Swift 6.2.3 + macOS 26.4 SDK toolchain. - func makeTitle(emailWidth: CGFloat, workspaceWidth: CGFloat) -> String { - let emailText = self.truncateMiddle(account.email, toFit: emailWidth) - let workspaceText = self.truncateTail(workspace, toFit: workspaceWidth) - return "\(emailText)\(separator)\(workspaceText)" + func makeTitle() -> String { + let email = self.truncateMiddle(account.email, toFit: emailWidth) + let workspace = self.truncateTail(workspace, toFit: workspaceWidth) + return "\(email)\(separator)\(workspace)" } - var title = makeTitle(emailWidth: emailWidth, workspaceWidth: workspaceWidth) + var title = makeTitle() var attempts = 0 while self.textWidth(title) > availableTextWidth, attempts < 16 { let emailText = self.truncateMiddle(account.email, toFit: emailWidth) @@ -1309,7 +1461,7 @@ final class CodexAccountSwitcherView: NSView { break } - title = makeTitle(emailWidth: emailWidth, workspaceWidth: workspaceWidth) + title = makeTitle() attempts += 1 } diff --git a/Sources/CodexBar/StatusItemController+UsageHistoryMenu.swift b/Sources/CodexBar/StatusItemController+UsageHistoryMenu.swift index 97908cfee3..7955d1873c 100644 --- a/Sources/CodexBar/StatusItemController+UsageHistoryMenu.swift +++ b/Sources/CodexBar/StatusItemController+UsageHistoryMenu.swift @@ -12,21 +12,10 @@ extension StatusItemController { @discardableResult func addUsageHistoryMenuItemIfNeeded(to menu: NSMenu, provider: UsageProvider, width: CGFloat) -> Bool { guard let submenu = self.makeUsageHistorySubmenu(provider: provider, width: width) else { return false } - let item = self.makeMenuCardItem( - HStack(spacing: 0) { - Text(L("Subscription Utilization")) - .font(.system(size: NSFont.menuFont(ofSize: 0).pointSize)) - .lineLimit(1) - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.leading, 14) - .padding(.trailing, 28) - .padding(.vertical, 8) - }, - id: "usageHistorySubmenu", - width: width, - submenu: submenu, - submenuIndicatorAlignment: .trailing, - submenuIndicatorTopPadding: 0) + let item = NSMenuItem(title: L("Plan Usage"), action: nil, keyEquivalent: "") + item.isEnabled = true + item.representedObject = "usageHistorySubmenu" + item.submenu = submenu menu.addItem(item) return true } @@ -51,10 +40,11 @@ extension StatusItemController { let histories = self.store.planUtilizationHistory(for: provider) let snapshot = self.store.snapshot(for: provider) - if !Self.menuCardRenderingEnabled { + if !self.menuCardRenderingEnabledForController { let chartItem = NSMenuItem() chartItem.isEnabled = true chartItem.representedObject = Self.usageHistoryChartID + chartItem.toolTip = provider.rawValue submenu.addItem(chartItem) return true } @@ -65,14 +55,15 @@ extension StatusItemController { snapshot: snapshot, width: width) let hosting = UsageHistoryMenuHostingView(rootView: chartView) - let controller = NSHostingController(rootView: chartView) - let size = controller.sizeThatFits(in: CGSize(width: width, height: .greatestFiniteMagnitude)) - hosting.frame = NSRect(origin: .zero, size: NSSize(width: width, height: size.height)) + hosting.frame = NSRect( + origin: .zero, + size: NSSize(width: width, height: self.hostedSubviewFittingHeight(for: hosting, width: width))) let chartItem = NSMenuItem() 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+WidgetSnapshot.swift b/Sources/CodexBar/StatusItemController+WidgetSnapshot.swift new file mode 100644 index 0000000000..7281cf6602 --- /dev/null +++ b/Sources/CodexBar/StatusItemController+WidgetSnapshot.swift @@ -0,0 +1,16 @@ +extension StatusItemController { + func widgetDisplaySettingsSignature() -> String { + [ + "enabled=\(self.store.enabledProvidersForDisplay().map(\.rawValue).joined(separator: ","))", + "showUsed=\(self.settings.usageBarsShowUsed ? "1" : "0")", + "optional=\(self.settings.showOptionalCreditsAndExtraUsage ? "1" : "0")", + ].joined(separator: "|") + } + + func persistWidgetSnapshotIfWidgetDisplaySettingsChanged() { + let signature = self.widgetDisplaySettingsSignature() + guard signature != self.lastWidgetDisplaySettingsSignature else { return } + self.lastWidgetDisplaySettingsSignature = signature + self.store.persistWidgetSnapshot(reason: "settings-display") + } +} diff --git a/Sources/CodexBar/StatusItemController+ZaiHourlyChartMenu.swift b/Sources/CodexBar/StatusItemController+ZaiHourlyChartMenu.swift index 3751e1c61f..712fb31371 100644 --- a/Sources/CodexBar/StatusItemController+ZaiHourlyChartMenu.swift +++ b/Sources/CodexBar/StatusItemController+ZaiHourlyChartMenu.swift @@ -24,6 +24,8 @@ extension StatusItemController { }, id: "zaiHourlyUsageSubmenu", width: width, + heightCacheScope: provider.rawValue, + heightCacheFingerprint: "zaiHourlyUsageSubmenu:\(provider.rawValue)", submenu: submenu, submenuIndicatorAlignment: .trailing, submenuIndicatorTopPadding: 0) diff --git a/Sources/CodexBar/StatusItemController.swift b/Sources/CodexBar/StatusItemController.swift index 4eacbb4ca0..f511ace591 100644 --- a/Sources/CodexBar/StatusItemController.swift +++ b/Sources/CodexBar/StatusItemController.swift @@ -10,6 +10,10 @@ protocol StatusItemControlling: AnyObject { func openMenuFromShortcut() func runLoginFlowFromSettings(provider: UsageProvider) async func celebrationOriginPoint(for provider: UsageProvider?) -> CGPoint? + func trimRebuildableCachesForMemoryPressure() -> MemoryPressureCacheTrimSummary + #if DEBUG + func seedRebuildableCachesForMemoryPressureProof() + #endif func prepareForAppShutdown() } @@ -18,9 +22,21 @@ extension StatusItemControlling { nil } + func trimRebuildableCachesForMemoryPressure() -> MemoryPressureCacheTrimSummary { + MemoryPressureCacheTrimSummary() + } + + #if DEBUG + func seedRebuildableCachesForMemoryPressureProof() {} + #endif + func prepareForAppShutdown() {} } +struct NativeHighlightDeferredMenuRebuild { + let provider: UsageProvider? +} + @MainActor final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControlling { // Disable SwiftUI menu cards + menu refresh work in tests to avoid swiftpm-testing-helper crashes. @@ -29,12 +45,23 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin private(set) static var menuRefreshEnabled = !SettingsStore.isRunningTests static let quotaWarningFlashDuration: TimeInterval = 60 private nonisolated static let statusItemAccessibilityTitle = "CodexBar" + private nonisolated static let debugStatusItemAccessibilityTitle = "CodexBar Debug" 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: @@ -45,15 +72,18 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin } } - #if DEBUG - static func setMenuRefreshEnabledForTesting(_ enabled: Bool) { - self.menuRefreshEnabled = enabled + nonisolated static func isDebugApp(bundleIdentifier: String?) -> Bool { + bundleIdentifier?.contains(".debug") == true } - static func resetMenuRefreshEnabledForTesting() { - self.menuRefreshEnabled = self.defaultMenuRefreshEnabled + nonisolated static func statusItemAccessibilityTitle(isDebugApp: Bool) -> String { + isDebugApp ? self.debugStatusItemAccessibilityTitle : self.statusItemAccessibilityTitle } + + #if DEBUG + var menuRefreshEnabledOverrideForTesting: Bool? #endif + typealias Factory = @MainActor ( UsageStore, @@ -91,39 +121,96 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin let store: UsageStore let settings: SettingsStore + let agentSessions: AgentSessionsStore + lazy var menuCardRefreshMonitor = self.makeMenuCardRefreshMonitor() + let account: AccountInfo let updater: UpdaterProviding let managedCodexAccountCoordinator: ManagedCodexAccountCoordinator let codexAccountPromotionCoordinator: CodexAccountPromotionCoordinator let statusBar: NSStatusBar + let menuCardRenderingEnabledForController: Bool + let menuRefreshEnabledForController: Bool var statusItem: NSStatusItem var statusItems: [UsageProvider: NSStatusItem] = [:] + /// App intent survives Tahoe changing `NSStatusItem.isVisible` after Control Center rejects its scene. + var expectedVisibleStatusItemAutosaveNames: Set = [] var lastMenuProvider: UsageProvider? var menuProviders: [ObjectIdentifier: UsageProvider] = [:] - var menuContentVersion: Int = 0 - var menuVersions: [ObjectIdentifier: Int] = [:] + var menuSession = MenuSessionCoordinator() + var menuReadinessSignatures: [ObjectIdentifier: String] = [:] + let hostedSubviewRenderSignatures = NSMapTable.weakToStrongObjects() + /// Persistent Refresh rows are weakly tracked so their enabled state can change during menu tracking. + let persistentRefreshItems = NSHashTable.weakObjects() + var menuCardHeightCache: [MenuCardHeightCacheKey: CGFloat] = [:] + var measuredStandardMenuWidthCache: [String: CGFloat] = [:] + var lastMenuAdjunctReadinessSignature = "" + var lastMenuAdjunctReadinessBaselineVersion = 0 + var rootOpenHandledMenuObservationSignature: String? var mergedMenu: NSMenu? var providerMenus: [UsageProvider: NSMenu] = [:] var fallbackMenu: NSMenu? var openMenus: [ObjectIdentifier: NSMenu] = [:] var menuRefreshTasks: [ObjectIdentifier: Task] = [:] + /// Manual refreshes tracked per scope so refreshing one provider neither greys out nor blocks + /// a manual refresh of another. `.global` covers the all-providers refresh (⌘R / merged overview). + var manualRefreshTasks: [ManualRefreshScope: Task] = [:] + + var closedMenuRebuildTasks: [ObjectIdentifier: Task] = [:] + var closedMenuRebuildRequests = MenuRebuildRequestRegistry() var openMenuRebuildTasks: [ObjectIdentifier: Task] = [:] - var openMenuRebuildTokens: [ObjectIdentifier: Int] = [:] - var openMenuRebuildTokenCounter = 0 + var openMenuRebuildRequests = MenuRebuildRequestRegistry() + var menuIdentitySignatures: [ObjectIdentifier: String] = [:] + var codexAccountMenuProjectionRevalidationTask: Task? var openMenuRebuildsClosingHostedSubviewMenus: Set = [] + var parentMenuRebuildPendingAfterHostedSubviewClose = false + var deferredMenuInteractionRefreshProviders: Set = [] + var deferredMenuInteractionRefreshPending: Bool { + !self.deferredMenuInteractionRefreshProviders.isEmpty + } + + var deferredOpenAIDashboardRefreshReason: String? + var deferredMenuInteractionRefreshTask: Task? var highlightedMenuItems: [ObjectIdentifier: NSMenuItem] = [:] + /// Open-menu rebuilds paused so AppKit's native selection background cannot retain stale geometry. + var nativeHighlightDeferredMenuRebuilds: [ObjectIdentifier: NativeHighlightDeferredMenuRebuild] = [:] + /// Baseline resync intent survives rebuild coalescing and any native-row or hosted-submenu deferral. + var pendingMenuBaselineResyncs: Set = [] var providerSwitcherShortcutEventMonitor: ProviderSwitcherShortcutEventMonitor? var providerSwitcherShortcutMenuID: ObjectIdentifier? + var providerSwitcherPointerInteractionMenuID: ObjectIdentifier? + var pendingProviderSwitcherPointerRebuild: PendingProviderSwitcherRebuild? + var overviewScrollAccumulatedDelta: CGFloat = 0 + var overviewScrollNavigationHandlerForTesting: ((OverviewScrollStep) -> Void)? var hasPreparedForAppShutdown = false + var scheduleQuitTermination: (@escaping @MainActor () -> Void) -> Void = { operation in + DispatchQueue.main.async { + Task { @MainActor in + operation() + } + } + } + + var terminateApplicationForQuit: @MainActor () -> Void = { + NSApp.terminate(nil) + } + + 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_providerSwitcherMenuRebuildDebounceNanoseconds: UInt64? var _test_codexAmbientLoginRunnerOverride: (@MainActor (TimeInterval) async -> CodexLoginRunner.Result)? #endif + var manualRefreshViewportRestoreState = ManualRefreshViewportRestoreState() var blinkTask: Task? + var menuBarCountdownRefreshTask: Task? var loginTask: Task? { didSet { self.refreshMenusForLoginStateChange() } } @@ -163,6 +250,11 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin private var lastMergeIcons: Bool private var lastSwitcherShowsIcons: Bool private var lastObservedUsageBarsShowUsed: Bool + var lastWidgetDisplaySettingsSignature = "" + var lastAgentSessionsEnabled: Bool + var lastAgentSessionsManualHosts: String + var lastAgentSessionsRefreshFrequency: RefreshFrequency + var lastAdaptiveActivityScanningEnabled: Bool /// Tracks which `usageBarsShowUsed` mode the provider switcher was built with. /// Used to decide whether we can "smart update" menu content without rebuilding the switcher. var lastSwitcherUsageBarsShowUsed: Bool @@ -176,21 +268,33 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin var lastSwitcherProviders: [UsageProvider] = [] /// Tracks which switcher tab state was used for the current merged-menu switcher instance. var lastMergedSwitcherSelection: ProviderSwitcherSelection? + /// Tracks which provider/overview content is currently attached below the merged-menu switcher. + var lastMergedMenuContentSelection: ProviderSwitcherSelection? /// Tracks the visible Codex account switcher contents for merged-menu smart updates. var lastCodexAccountMenuDisplay: CodexAccountMenuDisplay? /// Tracks the visible token account switcher contents for merged-menu smart updates. var lastTokenAccountMenuDisplay: TokenAccountMenuDisplay? + /// Keeps detached merged-menu tab content reusable while the same menu remains open. + var mergedSwitcherContentCaches: [ObjectIdentifier: [ProviderSwitcherSelection: CachedMergedSwitcherMenuContent]] + = [:] + var preservesMergedSwitcherContentCachesDuringInvalidation = false + /// Card hosting views harvested from items about to be discarded by the current populate + /// pass, keyed by card identifier; consumed by `makeMenuCardItem` and cleared when the + /// pass finishes. Never outlives a single synchronous menu population. + var menuCardViewRecyclePool: [String: NSView] = [:] /// Monotonic token used to ignore stale deferred provider-switcher menu rebuilds. var providerSwitcherUpdateToken = 0 + var providerSelectionUIRefreshTask: Task? + var deferredMergedIconRenderAfterTracking = false var lastAppliedMergedIconRenderSignature: String? var lastAppliedProviderIconRenderSignatures: [UsageProvider: String] = [:] + let menuBarLayoutRenderer = MenuBarLayoutRenderer() var lastObservedStoreIconWorkSignature: String? var iconPerfRefreshCycleMetrics: IconPerfRefreshCycleMetrics? var iconPerfUpdatePassActive = false var lastKnownScreenCount: Int var pendingScreenChangePreviousCount: Int? var screenChangeVisibilityTask: Task? - private var appearanceObservation: NSKeyValueObservation? let loginLogger = CodexBarLog.logger(LogCategories.login) let menuLogger = CodexBarLog.logger(LogCategories.app) var selectedMenuProvider: UsageProvider? { @@ -198,14 +302,28 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin set { self.settings.selectedMenuProvider = newValue } } - private static func makeStatusItem(statusBar: NSStatusBar, identity: StatusItemIdentity) -> NSStatusItem { + static func makeStatusItem( + statusBar: NSStatusBar, + identity: StatusItemIdentity, + defaults: UserDefaults, + legacyDefaultItemIndex: Int?, + onCreated: ((NSStatusItem) -> Void)? = nil) + -> NSStatusItem + { + MenuBarStatusItemPlacementPreflight.prepare( + defaults: defaults, + autosaveName: identity.autosaveName, + legacyDefaultItemIndex: legacyDefaultItemIndex) let item = statusBar.statusItem(withLength: NSStatusItem.variableLength) + onCreated?(item) + item.autosaveName = identity.autosaveName if let button = item.button { + let title = self.statusItemAccessibilityTitle( + isDebugApp: self.isDebugApp(bundleIdentifier: Bundle.main.bundleIdentifier)) // Ensure the icon is rendered at 1:1 without resampling (crisper edges for template images). button.imageScaling = .scaleNone button.setAccessibilityIdentifier(identity.accessibilityIdentifier) - button.setAccessibilityTitle(self.statusItemAccessibilityTitle) - button.toolTip = self.statusItemAccessibilityTitle + button.setAccessibilityTitle(title) } return item } @@ -233,54 +351,22 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin case waitingBrowser } - func menuBarMetricWindow(for provider: UsageProvider, snapshot: UsageSnapshot?) -> RateWindow? { + func menuBarMetricWindow(for provider: UsageProvider, snapshot: UsageSnapshot?, now: Date = Date()) -> RateWindow? { if provider == .codex { - return self.codexMenuBarMetricWindow(snapshot: snapshot) + return self.codexMenuBarMetricWindow(snapshot: snapshot, now: now) } return MenuBarMetricWindowResolver.rateWindow( preference: self.settings.menuBarMetricPreference(for: provider, snapshot: snapshot), provider: provider, snapshot: snapshot, - supportsAverage: self.settings.menuBarMetricSupportsAverage(for: provider)) + supportsAverage: self.settings.menuBarMetricSupportsAverage(for: provider), + antigravityPrioritizeExhaustedQuotas: self.settings.antigravityPrioritizeExhaustedQuotas, + now: now) } - private func codexMenuBarMetricWindow(snapshot: UsageSnapshot?) -> RateWindow? { + private func codexMenuBarMetricWindow(snapshot: UsageSnapshot?, now: Date) -> RateWindow? { guard let snapshot else { return nil } - let projection = CodexConsumerProjection.make( - surface: .menuBar, - context: CodexConsumerProjection.Context( - snapshot: snapshot, - rawUsageError: nil, - liveCredits: self.store.credits, - rawCreditsError: self.store.lastCreditsError, - liveDashboard: self.store.openAIDashboard, - rawDashboardError: self.store.lastOpenAIDashboardError, - dashboardAttachmentAuthorized: self.store.openAIDashboardAttachmentAuthorized, - dashboardRequiresLogin: self.store.openAIDashboardRequiresLogin, - now: snapshot.updatedAt)) - let lanes = projection.visibleRateLanes - let first = lanes.first.flatMap { projection.rateWindow(for: $0) } - let second = lanes.dropFirst().first.flatMap { projection.rateWindow(for: $0) } - let preference = self.settings.menuBarMetricPreference(for: .codex, snapshot: snapshot) - - switch preference { - case .secondary, .tertiary: - return second ?? first - case .extraUsage: - return first - case .average: - guard self.settings.menuBarMetricSupportsAverage(for: .codex), - let primary = first, - let secondary = second - else { - return first - } - let usedPercent = (primary.usedPercent + secondary.usedPercent) / 2 - return RateWindow( - usedPercent: usedPercent, windowMinutes: nil, resetsAt: nil, resetDescription: nil) - case .automatic, .primary: - return first - } + return self.store.codexMenuBarMetricWindow(snapshot: snapshot, now: now) } init( @@ -293,6 +379,8 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin ManagedCodexAccountCoordinator(), codexAccountPromotionCoordinator: CodexAccountPromotionCoordinator? = nil, statusBar: NSStatusBar = .system, + menuCardRenderingEnabled: Bool = StatusItemController.menuCardRenderingEnabled, + menuRefreshEnabled: Bool = StatusItemController.menuRefreshEnabled, observeProviderConfigNotifications: Bool = !SettingsStore.isRunningTests) { if SettingsStore.isRunningTests { @@ -300,6 +388,7 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin } self.store = store self.settings = settings + self.agentSessions = AgentSessionsStore(settings: settings) self.account = account self.updater = updater self.preferencesSelection = preferencesSelection @@ -315,11 +404,21 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin self.lastMergeIcons = settings.mergeIcons self.lastSwitcherShowsIcons = settings.switcherShowsIcons self.lastObservedUsageBarsShowUsed = settings.usageBarsShowUsed + self.lastAgentSessionsEnabled = settings.agentSessionsEnabled + self.lastAgentSessionsManualHosts = settings.agentSessionsManualHosts + self.lastAgentSessionsRefreshFrequency = settings.refreshFrequency + self.lastAdaptiveActivityScanningEnabled = settings.adaptiveActivityScanningEnabled self.lastSwitcherUsageBarsShowUsed = settings.usageBarsShowUsed + self.menuCardRenderingEnabledForController = menuCardRenderingEnabled + self.menuRefreshEnabledForController = menuRefreshEnabled 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() @@ -328,9 +427,18 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin "Repaired hidden macOS status-item visibility defaults", metadata: ["keys": repairedStatusItemVisibilityKeys.joined(separator: ",")]) } + self.lastMenuAdjunctReadinessSignature = self.menuAdjunctReadinessSignature() + self.lastMenuAdjunctReadinessBaselineVersion = self.menuSession.contentVersion + self.lastWidgetDisplaySettingsSignature = self.widgetDisplaySettingsSignature() self.wireBindings() + self.wireAgentSessionUpdates() + if !SettingsStore.isRunningTests { + self.agentSessions.start() + } self.updateVisibility() self.updateIcons() + self.scheduleCodexAccountMenuProjectionRevalidationIfNeeded( + for: self.store.enabledProvidersForDisplay()) self.scheduleStartupStatusItemVisibilityCheck() NotificationCenter.default.addObserver( self, @@ -359,21 +467,7 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin selector: #selector(self.handleScreenParametersDidChange(_:)), name: NSApplication.didChangeScreenParametersNotification, object: nil) - - // On macOS 26+, usage colors are baked into non-template images. Re-render when the system - // appearance changes so dynamic colors (systemGreen/Orange/Red) resolve to their new values. - // The render-skip signatures don't encode appearance, so clear them first — otherwise an - // unchanged usage/status value would short-circuit the re-render and leave the stale bitmap. - if #available(macOS 26, *) { - self.appearanceObservation = NSApp.observe(\.effectiveAppearance) { [weak self] _, _ in - Task { @MainActor in - guard let self else { return } - self.lastAppliedMergedIconRenderSignature = nil - self.lastAppliedProviderIconRenderSignatures.removeAll() - self.updateIcons() - } - } - } + self.observeMenuBarTimeEnvironmentChanges() } convenience init( @@ -383,6 +477,8 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin updater: UpdaterProviding, preferencesSelection: PreferencesSelection, statusBar: NSStatusBar = .system, + menuCardRenderingEnabled: Bool = StatusItemController.menuCardRenderingEnabled, + menuRefreshEnabled: Bool = StatusItemController.menuRefreshEnabled, observeProviderConfigNotifications: Bool = !SettingsStore.isRunningTests) { self.init( @@ -394,6 +490,8 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin managedCodexAccountCoordinator: ManagedCodexAccountCoordinator(), codexAccountPromotionCoordinator: nil, statusBar: statusBar, + menuCardRenderingEnabled: menuCardRenderingEnabled, + menuRefreshEnabled: menuRefreshEnabled, observeProviderConfigNotifications: observeProviderConfigNotifications) } @@ -413,12 +511,29 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin } onChange: { [weak self] in Task { @MainActor [weak self] in guard let self else { return } - self.observeStoreChanges() - self.invalidateMenus() + self.handleObservedStoreMenuChange() } } } + func handleObservedStoreMenuChange() { + self.observeStoreChanges() + self.updatePersistentRefreshItemsEnabled() + let rootOpenHandledReadiness = self.consumeRootOpenHandledMenuObservationIfNeeded() + // `refreshOpenMenus` is only consulted when a menu is currently open. + // Computing the readiness signature serializes every enabled provider's + // token snapshot and 30-day daily breakdown, which is wasted main-thread + // work on the common path where no menu is open (background refresh ticks). + let refreshOpenMenus = self.openMenus.isEmpty + ? false + : rootOpenHandledReadiness || self.didMenuAdjunctReadinessChange() + self.invalidateMenus( + refreshOpenMenus: refreshOpenMenus, + deferOpenParentMenuRebuild: true, + allowStaleContentDuringDataRefresh: true) + self.completeParentMenuRebuildAfterHostedSubviewCloseIfNeeded() + } + private func observeStoreIconChanges() { withObservationTracking { _ = self.store.iconObservationToken @@ -428,57 +543,13 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin self.observeStoreIconChanges() let signature = self.storeIconObservationSignature() guard signature != self.lastObservedStoreIconWorkSignature else { return } - self.lastObservedStoreIconWorkSignature = signature - self.updateIcons() + // Reuse the signature we just computed for the change check; `updateIcons` would + // otherwise recompute the identical value on the same main-actor turn. + self.updateIcons(precomputedStoreIconSignature: signature) } } } - func storeIconObservationSignature() -> String { - let showBrandPercent = self.settings.menuBarShowsBrandIconWithPercent - let mergeIcons = self.shouldMergeIcons - let needsAnimation = self.needsMenuBarIconAnimation() - let providerSignatures = UsageProvider.allCases.map { - self.providerStoreIconObservationSignature(for: $0, showBrandPercent: showBrandPercent) - }.joined(separator: "||") - let visibleProviders = self.store.enabledProvidersForDisplay().map(\.rawValue).sorted().joined(separator: ",") - return [ - "merge=\(mergeIcons ? "1" : "0")", - "visible=\(visibleProviders)", - "iconStyle=\(String(describing: self.store.iconStyle))", - "brandPercent=\(showBrandPercent ? "1" : "0")", - "needsAnimation=\(needsAnimation ? "1" : "0")", - providerSignatures, - ].joined(separator: "|") - } - - private func providerStoreIconObservationSignature(for provider: UsageProvider, showBrandPercent: Bool) -> String { - let snapshot = self.store.snapshot(for: provider) - let stale = self.store.isStale(provider: provider) - let status = self.store.statusIndicator(for: provider).rawValue - let isVisibleForAnimation = self.shouldMergeIcons ? self.isEnabled(provider) : self.isVisible(provider) - let isAnimating = isVisibleForAnimation && !stale && snapshot == nil - let isRefreshingWarpPlaceholder = self.store.refreshingProviders.contains(provider) - let creditsRemaining = provider == .codex - ? self.store.codexMenuBarCreditsRemaining( - snapshotOverride: snapshot, - now: snapshot?.updatedAt ?? Date()) - : nil - let displayText = showBrandPercent ? self.menuBarDisplayText(for: provider, snapshot: snapshot) : nil - - return [ - provider.rawValue, - "style=\(String(describing: self.store.style(for: provider)))", - "snapshot=\(String(describing: snapshot))", - "stale=\(stale ? "1" : "0")", - "status=\(status)", - "anim=\(isAnimating ? "1" : "0")", - "refreshing=\(isRefreshingWarpPlaceholder ? "1" : "0")", - "credits=\(String(describing: creditsRemaining))", - "text=\(displayText ?? "nil")", - ].joined(separator: "|") - } - private func observeDebugForceAnimation() { withObservationTracking { _ = self.store.debugForceAnimation @@ -513,13 +584,19 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin guard !self.isReleasedForTesting else { return } #endif let reason = notification.userInfo?["reason"] as? String ?? "unknown" + let affectsBackgroundWork = notification.userInfo?["affectsBackgroundWork"] as? Bool if let source = notification.object as? SettingsStore, source !== self.settings { if let config = notification.userInfo?["config"] as? CodexBarConfig { - self.settings.applyExternalConfig(config, reason: "external-\(reason)") + self.settings.applyExternalConfig( + config, + reason: "external-\(reason)", + affectsBackgroundWork: affectsBackgroundWork) } else { - self.settings.reloadConfig(reason: "external-\(reason)") + self.settings.reloadConfig( + reason: "external-\(reason)", + affectsBackgroundWork: affectsBackgroundWork) } } self.handleProviderConfigChange(reason: "notification:\(reason)") @@ -530,26 +607,6 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin self.startQuotaWarningFlash(provider: event.provider, postedAt: event.postedAt) } - func startQuotaWarningFlash(provider: UsageProvider, postedAt: Date = Date()) { - let until = postedAt.addingTimeInterval(Self.quotaWarningFlashDuration) - self.quotaWarningFlashUntil[provider] = until - self.quotaWarningFlashTasks[provider]?.cancel() - self.updateIcons() - self.quotaWarningFlashTasks[provider] = Task { [weak self] in - try? await Task.sleep(for: .seconds(Self.quotaWarningFlashDuration)) - await MainActor.run { [weak self] in - guard let self else { return } - if let currentUntil = self.quotaWarningFlashUntil[provider], - currentUntil <= Date() - { - self.quotaWarningFlashUntil.removeValue(forKey: provider) - self.quotaWarningFlashTasks.removeValue(forKey: provider) - self.updateIcons() - } - } - } - } - private func observeUpdaterChanges() { withObservationTracking { _ = self.updater.updateStatus.isUpdateReady @@ -579,33 +636,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 @@ -643,8 +673,10 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin #if DEBUG guard !self.isReleasedForTesting else { return } #endif + self.synchronizeAgentSessionsForSettingsChange() let configChanged = self.settings.configRevision != self.lastConfigRevision let orderChanged = self.settings.providerOrder != self.lastProviderOrder + let localizationChanged = self.menuLocalizationSignature() != self.lastMenuLocalizationSignature let shouldRefreshOpenMenus = self.shouldRefreshOpenMenusForProviderSwitcher() self.invalidateMenus() if orderChanged || configChanged { @@ -652,26 +684,39 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin } self.updateVisibility() self.updateIcons() + self.persistWidgetSnapshotIfWidgetDisplaySettingsChanged() if shouldRefreshOpenMenus { - self.refreshOpenMenusForStructureChange() + self.refreshOpenMenusAllowingParentRebuild( + deferParentRebuildDuringTracking: !localizationChanged) } } - private func updateIcons() { + /// Updates the menu bar icons. + /// + /// The store-icon observer already computes `storeIconObservationSignature()` to decide whether any + /// icon work is needed, so it passes that value in via `precomputedStoreIconSignature` to avoid + /// recomputing the identical signature on the same main-actor turn. Other callers omit it and let the + /// signature refresh here, keeping `lastObservedStoreIconWorkSignature` current as the change gate. + func updateIcons(precomputedStoreIconSignature: String? = nil) { #if DEBUG guard !self.isReleasedForTesting else { return } #endif - self.lastObservedStoreIconWorkSignature = self.storeIconObservationSignature() + MainThreadActivityBreadcrumb.push("updateIcons") + self.scheduleMenuBarCountdownRefreshIfNeeded() + self.lastObservedStoreIconWorkSignature = precomputedStoreIconSignature ?? self.storeIconObservationSignature() self.beginIconPerfUpdatePass() - defer { self.endIconPerfUpdatePass() } + defer { + self.endIconPerfUpdatePass() + MainThreadActivityBreadcrumb.pop() + } // Avoid flicker: when an animation driver is active, store updates can call `updateIcons()` and // briefly overwrite the animated frame with the static (phase=nil) icon. let phase: Double? = self.needsMenuBarIconAnimation() ? self.animationPhase : nil if self.shouldMergeIcons { let skippedMergedRender = self.applyIcon(phase: phase) if skippedMergedRender, - let mergedMenu = self.mergedMenu, - self.statusItem.menu === mergedMenu + !self.deferredMergedIconRenderAfterTracking, + self.mergedMenu != nil { return } @@ -694,23 +739,17 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin return self.openMenus[ObjectIdentifier(mergedMenu)] != nil } - /// Lazily retrieves or creates a status item for the given provider - func lazyStatusItem(for provider: UsageProvider) -> NSStatusItem { - if let existing = self.statusItems[provider] { - return existing - } - let item = Self.makeStatusItem(statusBar: self.statusBar, identity: .provider(provider)) - self.statusItems[provider] = item - return item - } - func recreateStatusItemsForVisibilityRecovery() { #if DEBUG guard !self.isReleasedForTesting else { return } #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) } @@ -727,8 +766,13 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin let anyEnabled = !self.store.enabledProvidersForDisplay().isEmpty let force = self.store.debugForceAnimation let mergeIcons = self.shouldMergeIcons + var expectedVisibleAutosaveNames: Set = [] if mergeIcons { - self.statusItem.isVisible = anyEnabled || force + let shouldBeVisible = anyEnabled || force + self.statusItem.isVisible = shouldBeVisible + if shouldBeVisible { + expectedVisibleAutosaveNames.insert(self.statusItem.autosaveName) + } for provider in Array(self.statusItems.keys) { self.removeProviderStatusItem(for: provider) } @@ -742,22 +786,18 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin if shouldBeVisible { let item = self.lazyStatusItem(for: provider) item.isVisible = true + expectedVisibleAutosaveNames.insert(item.autosaveName) } else { self.removeProviderStatusItem(for: provider) } } self.attachMenus(fallback: fallback) } + self.expectedVisibleStatusItemAutosaveNames = expectedVisibleAutosaveNames self.updateAnimationState() self.updateBlinkingState() } - var fallbackProvider: UsageProvider? { - // Intentionally uses availability-filtered list: fallback activates when no provider - // can actually work, ensuring at least a codex icon is always visible. - self.store.enabledProviders().isEmpty ? .codex : nil - } - func isEnabled(_ provider: UsageProvider) -> Bool { self.store.isEnabled(provider) } @@ -782,6 +822,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) { @@ -812,6 +853,7 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin item.menu = nil } } + self.prepareAttachedClosedMenusIfNeeded() } private func rebuildProviderStatusItems() { @@ -835,14 +877,11 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin private func removeProviderStatusItem(for provider: UsageProvider) { if let menu = self.providerMenus.removeValue(forKey: provider) { let menuID = ObjectIdentifier(menu) - self.menuProviders.removeValue(forKey: menuID) - self.menuVersions.removeValue(forKey: menuID) - self.openMenus.removeValue(forKey: menuID) - self.menuRefreshTasks.removeValue(forKey: menuID)?.cancel() - self.openMenuRebuildTasks.removeValue(forKey: menuID)?.cancel() - self.openMenuRebuildTokens.removeValue(forKey: menuID) - self.openMenuRebuildsClosingHostedSubviewMenus.remove(menuID) - self.highlightedMenuItems.removeValue(forKey: menuID) + if menuID == self.providerSwitcherShortcutMenuID { + self.removeProviderSwitcherShortcutMonitor() + } + self.clearMergedSwitcherContentCache(for: menu) + self.removeMenuLifecycleState(menuID) } guard let item = self.statusItems.removeValue(forKey: provider) else { return } @@ -866,8 +905,8 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin let base: String switch self.loginPhase { case .idle: return nil - case .requesting: base = "Requesting login…" - case .waitingBrowser: base = "Waiting in browser…" + case .requesting: base = L("Requesting login…") + case .waitingBrowser: base = L("Waiting in browser…") } let prefix = ProviderDescriptorRegistry.descriptor(for: provider).metadata.displayName return "\(prefix): \(base)" @@ -879,6 +918,7 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin animationDriver?.stop() } self.blinkTask?.cancel() + self.menuBarCountdownRefreshTask?.cancel() self.loginTask?.cancel() self.screenChangeVisibilityTask?.cancel() self.pendingScreenChangePreviousCount = nil @@ -886,7 +926,79 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin } } +#if DEBUG extension StatusItemController { + var _test_manualRefreshOperation: (@MainActor () async -> Void)? { + get { self.manualRefreshViewportRestoreState.testOperation } + set { self.manualRefreshViewportRestoreState.testOperation = newValue } + } + + var _test_menuViewportRestoreObserver: (@MainActor (NSMenu) -> Void)? { + get { self.manualRefreshViewportRestoreState.testObserver } + set { self.manualRefreshViewportRestoreState.testObserver = newValue } + } + + var _test_menuViewportRestoreScheduler: ((@escaping @MainActor () -> Void) -> Void)? { + get { self.manualRefreshViewportRestoreState.testScheduler } + set { self.manualRefreshViewportRestoreState.testScheduler = newValue } + } + + var menuContentVersion: Int { + get { self.menuSession.contentVersion } + set { self.menuSession.replaceContentVersionForTesting(newValue) } + } + + var latestRequiredMenuRebuildVersion: Int { + self.menuSession.latestRequiredRebuildVersion + } + + var latestDataOnlyMenuContentVersion: Int { + self.menuSession.latestDataOnlyContentVersion + } + + var latestStructuralMenuContentVersion: Int { + self.menuSession.latestStructuralContentVersion + } + + var menuVersions: [ObjectIdentifier: Int] { + get { self.menuSession.renderedVersions } + set { self.menuSession.replaceRenderedVersionsForTesting(newValue) } + } + + var closedMenusDeferredUntilNextOpen: Set { + get { self.menuSession.deferredUntilNextOpen } + set { self.menuSession.replaceDeferredMenusForTesting(newValue) } + } + + var parentMenuRebuildsDeferredDuringTracking: Set { + self.menuSession.parentRebuildsDeferredDuringTracking + } + + var closedMenuRebuildTokens: [ObjectIdentifier: Int] { + self.closedMenuRebuildRequests.tokens + } +} +#endif + +#if DEBUG +extension StatusItemController { + static func setMenuRefreshEnabledForTesting(_ enabled: Bool) { + self.menuRefreshEnabled = enabled + } + + static func resetMenuRefreshEnabledForTesting() { + self.menuRefreshEnabled = self.defaultMenuRefreshEnabled + } +} +#endif + +extension StatusItemController { + 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/StatusItemMenu.swift b/Sources/CodexBar/StatusItemMenu.swift index be8a7b0d78..b377b893fe 100644 --- a/Sources/CodexBar/StatusItemMenu.swift +++ b/Sources/CodexBar/StatusItemMenu.swift @@ -6,7 +6,9 @@ enum StatusItemMenuProviderNavigationDirection { } protocol StatusItemMenuPersistentActionDelegate: AnyObject { - func performPersistentRefreshAction() + func performPersistentRefreshAction( + in menuID: ObjectIdentifier, + menuInteractionGeneration: Int) func performPersistentSettingsAction() func performPersistentQuitAction() func performProviderNavigation(_ direction: StatusItemMenuProviderNavigationDirection) @@ -14,12 +16,20 @@ protocol StatusItemMenuPersistentActionDelegate: AnyObject { final class StatusItemMenu: NSMenu { weak var persistentActionDelegate: StatusItemMenuPersistentActionDelegate? + var menuInteractionGeneration: Int? + + func requestPersistentRefreshAction() { + guard let menuInteractionGeneration else { return } + self.persistentActionDelegate?.performPersistentRefreshAction( + in: ObjectIdentifier(self), + menuInteractionGeneration: menuInteractionGeneration) + } override func performKeyEquivalent(with event: NSEvent) -> Bool { if let action = Self.persistentAction(for: event) { switch action { case .refresh: - self.persistentActionDelegate?.performPersistentRefreshAction() + self.requestPersistentRefreshAction() case .settings: self.persistentActionDelegate?.performPersistentSettingsAction() case .quit: @@ -43,6 +53,10 @@ final class StatusItemMenu: NSMenu { case quit } + nonisolated static func isPersistentRefreshShortcut(for event: NSEvent) -> Bool { + self.persistentAction(for: event) == .refresh + } + private nonisolated static func persistentAction(for event: NSEvent) -> PersistentAction? { guard event.type == .keyDown else { return nil } diff --git a/Sources/CodexBar/StorageBreakdownMenuView.swift b/Sources/CodexBar/StorageBreakdownMenuView.swift index 276a5365c9..94de6a1fe8 100644 --- a/Sources/CodexBar/StorageBreakdownMenuView.swift +++ b/Sources/CodexBar/StorageBreakdownMenuView.swift @@ -2,53 +2,120 @@ import AppKit import CodexBarCore import SwiftUI -struct StorageMenuCardSectionView: View { - let storageText: String - let topPadding: CGFloat - let bottomPadding: CGFloat - let width: CGFloat - - var body: some View { - VStack(alignment: .leading, spacing: 6) { - Text(L("Storage")) - .font(.body) - .fontWeight(.medium) - Text(self.storageText) - .font(.caption) - } - .padding(.horizontal, 16) - .padding(.top, self.topPadding) - .padding(.bottom, self.bottomPadding) - .frame(width: self.width, alignment: .leading) - } -} - struct StorageBreakdownMenuView: View { let footprint: ProviderStorageFootprint let width: CGFloat let maxHeight: CGFloat + let onExpansionHeightChange: ((CGFloat) -> Void)? + + @State private var otherExpanded = false - init(footprint: ProviderStorageFootprint, width: CGFloat, maxHeight: CGFloat = 560) { + init( + footprint: ProviderStorageFootprint, + width: CGFloat, + maxHeight: CGFloat = 560, + onExpansionHeightChange: ((CGFloat) -> Void)? = nil) + { self.footprint = footprint self.width = width self.maxHeight = maxHeight + self.onExpansionHeightChange = onExpansionHeightChange } + /// One entry in the segmented bar and its matching legend row. Overflow components past the row + /// budget collapse into a single trailing "Other" segment with no copyable path of its own. + private struct Segment: Identifiable { + let id: String + let name: String + let bytes: Int64 + let color: Color + let path: String? + } + + /// How many legend rows we let the breakdown show before collapsing the tail into "Other". + private static let maxRows = 8 + + private static let segmentPalette: [Color] = [ + Color(red: 0.20, green: 0.51, blue: 0.96), + Color(red: 0.96, green: 0.55, blue: 0.20), + Color(red: 0.30, green: 0.78, blue: 0.47), + Color(red: 0.66, green: 0.42, blue: 0.93), + Color(red: 0.95, green: 0.74, blue: 0.22), + Color(red: 0.92, green: 0.36, blue: 0.55), + Color(red: 0.27, green: 0.76, blue: 0.82), + ] + + private static let otherColor = Color(nsColor: .tertiaryLabelColor) + private static let overflowRowHeight: CGFloat = 18 + private static let overflowRowSpacing: CGFloat = 4 + private static let overflowTopSpacing: CGFloat = 6 + var cleanupRecommendations: [ProviderStorageRecommendation] { self.footprint.cleanupRecommendations } var copyablePaths: [String] { let recommendationPaths = self.cleanupRecommendations.map(\.path) - return self.visibleComponents.map(\.path) + recommendationPaths + return self.footprint.components.map(\.path) + recommendationPaths + } + + /// Visible components mapped to colored segments, with any tail beyond `maxRows` folded into a + /// single "Other" entry so the bar and legend never exceed the row budget. + private var segments: [Segment] { + let components = self.footprint.components + guard !components.isEmpty else { return [] } + + func color(_ index: Int) -> Color { + Self.segmentPalette[index % Self.segmentPalette.count] + } + + func segment(_ component: ProviderStorageFootprint.Component, _ index: Int) -> Segment { + Segment( + id: component.id, + name: component.name, + bytes: max(component.totalBytes, 0), + color: color(index), + path: component.path) + } + + if components.count <= Self.maxRows { + return components.enumerated().map { segment($1, $0) } + } + + let visible = components.prefix(Self.maxRows - 1) + let overflow = components.dropFirst(Self.maxRows - 1) + let otherBytes = overflow.reduce(Int64(0)) { partial, component in + let bytes = max(component.totalBytes, 0) + let (sum, overflowed) = partial.addingReportingOverflow(bytes) + return overflowed ? .max : sum + } + return visible.enumerated().map { segment($1, $0) } + [ + Segment( + id: "__other__", + name: String(format: L("Other (%d items)"), overflow.count), + bytes: otherBytes, + color: Self.otherColor, + path: nil), + ] + } + + private var segmentTotalBytes: Double { + self.segments.reduce(0) { $0 + Double($1.bytes) } } - private var visibleComponents: [ProviderStorageFootprint.Component] { - Array(self.footprint.components.prefix(8)) + /// The components folded into the trailing "Other" segment, revealed when it is expanded. + private var overflowComponents: [ProviderStorageFootprint.Component] { + let components = self.footprint.components + guard components.count > Self.maxRows else { return [] } + return Array(components.dropFirst(Self.maxRows - 1)) } - private var maxBytes: Int64 { - max(self.visibleComponents.map(\.totalBytes).max() ?? 0, 1) + private var overflowExpansionHeight: CGFloat { + let count = CGFloat(self.overflowComponents.count) + guard count > 0 else { return 0 } + return Self.overflowTopSpacing + + count * Self.overflowRowHeight + + (count - 1) * Self.overflowRowSpacing } var body: some View { @@ -70,28 +137,24 @@ struct StorageBreakdownMenuView: View { Text(L("Storage")) .font(.body) .fontWeight(.medium) - Text(String(format: L("Total: %@"), UsageFormatter.byteCountString(self.footprint.totalBytes))) + Text(String(format: L("Total: %@"), UsageFormatter.byteCountStringLong(self.footprint.totalBytes))) .font(.caption) .foregroundStyle(.secondary) } - if self.visibleComponents.isEmpty { + if self.segments.isEmpty { Text(L("No local data found")) .font(.footnote) .foregroundStyle(.secondary) } else { - VStack(alignment: .leading, spacing: 8) { - ForEach(self.visibleComponents) { component in - self.componentRow(component) + self.segmentedBar + VStack(alignment: .leading, spacing: 6) { + ForEach(self.segments) { segment in + self.legendRow(segment) } } } - if self.footprint.components.count > self.visibleComponents.count { - Text(String(format: L("%d more items"), self.footprint.components.count - self.visibleComponents.count)) - .font(.caption) - .foregroundStyle(.secondary) - } if !self.cleanupRecommendations.isEmpty { Divider() .padding(.vertical, 2) @@ -115,34 +178,106 @@ struct StorageBreakdownMenuView: View { .frame(width: self.width, alignment: .leading) } - private func componentRow(_ component: ProviderStorageFootprint.Component) -> some View { - let fraction = CGFloat(max(0, min(1, Double(component.totalBytes) / Double(self.maxBytes)))) - return VStack(alignment: .leading, spacing: 4) { - HStack(alignment: .firstTextBaseline) { - Text(component.path) + private var segmentedBar: some View { + GeometryReader { proxy in + ZStack(alignment: .leading) { + Capsule() + .fill(Color(nsColor: .quaternaryLabelColor)) + HStack(spacing: 0) { + ForEach(self.segments) { segment in + Rectangle() + .fill(segment.color) + .frame(width: self.segmentWidth(segment, barWidth: proxy.size.width)) + } + } + } + .clipShape(Capsule()) + } + .frame(height: 5) + } + + /// Each segment gets at least `minWidth` so tiny components stay visible, with the remaining width + /// shared by byte proportion. Reserving the minimums (rather than flooring each width with `max`) + /// keeps the segments summing to exactly `barWidth`, so none get clipped off the capsule's end. + private func segmentWidth(_ segment: Segment, barWidth: CGFloat) -> CGFloat { + let minWidth: CGFloat = 2 + let count = CGFloat(self.segments.count) + guard self.segmentTotalBytes > 0 else { return barWidth / max(count, 1) } + let reserved = minWidth * count + guard barWidth > reserved else { return barWidth / max(count, 1) } + let remainder = barWidth - reserved + let proportion = CGFloat(Double(segment.bytes) / self.segmentTotalBytes) + return minWidth + remainder * proportion + } + + private func legendRow(_ segment: Segment) -> some View { + let isOther = segment.path == nil + return VStack(alignment: .leading, spacing: 6) { + HStack(spacing: 8) { + Circle() + .fill(segment.color) + .frame(width: 9, height: 9) + Text(segment.name) .font(.caption) + .foregroundStyle(.primary) .lineLimit(1) .truncationMode(.middle) - .help(component.path) + .help(segment.path ?? segment.name) .layoutPriority(1) Spacer() - StoragePathCopyButton(path: component.path) - Text(UsageFormatter.byteCountString(component.totalBytes)) + if let path = segment.path { + StoragePathCopyButton(path: path) + } else { + self.otherExpandButton + } + Text(UsageFormatter.byteCountString(segment.bytes)) .font(.caption) .foregroundStyle(.secondary) .lineLimit(1) } - GeometryReader { proxy in - ZStack(alignment: .leading) { - Capsule() - .fill(Color(nsColor: .quaternaryLabelColor)) - Capsule() - .fill(self.providerColor) - .frame(width: max(2, proxy.size.width * fraction)) + if isOther, self.otherExpanded { + self.overflowList + } + } + } + + private var otherExpandButton: some View { + Button { + self.otherExpanded.toggle() + self.onExpansionHeightChange?(self.otherExpanded ? self.overflowExpansionHeight : 0) + } label: { + Image(systemName: self.otherExpanded ? "chevron.down" : "chevron.right") + .font(.caption2.weight(.semibold)) + .foregroundStyle(.secondary) + .frame(width: 18, height: 18) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .help(self.otherExpanded ? L("Collapse") : L("Expand")) + .accessibilityLabel(self.otherExpanded ? L("Collapse") : L("Expand")) + } + + /// Plain name + size rows for the items folded into "Other" — no colors, indented under its name. + private var overflowList: some View { + VStack(alignment: .leading, spacing: 4) { + ForEach(self.overflowComponents) { component in + HStack(spacing: 8) { + Text(component.name) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.middle) + .help(component.path) + Spacer() + StoragePathCopyButton(path: component.path) + Text(UsageFormatter.byteCountString(component.totalBytes)) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) } } - .frame(height: 5) } + .padding(.leading, 17) } private func recommendationRow(_ recommendation: ProviderStorageRecommendation) -> some View { @@ -176,12 +311,31 @@ struct StorageBreakdownMenuView: View { .fixedSize(horizontal: false, vertical: true) } } +} + +#if DEBUG +extension StorageBreakdownMenuView { + var _segmentNamesForTesting: [String] { + self.segments.map(\.name) + } + + var _segmentBytesForTesting: [Int64] { + self.segments.map(\.bytes) + } + + var _overflowNamesForTesting: [String] { + self.overflowComponents.map(\.name) + } - private var providerColor: Color { - let color = ProviderDescriptorRegistry.descriptor(for: self.footprint.provider).branding.color - return Color(red: color.red, green: color.green, blue: color.blue) + var _overflowExpansionHeightForTesting: CGFloat { + self.overflowExpansionHeight + } + + func _segmentWidthsForTesting(barWidth: CGFloat) -> [CGFloat] { + self.segments.map { self.segmentWidth($0, barWidth: barWidth) } } } +#endif struct StoragePathCopyButton: View { let path: String @@ -191,17 +345,14 @@ struct StoragePathCopyButton: View { var body: some View { Button { - Self.copyToPasteboard(self.path) - withAnimation(.easeOut(duration: 0.12)) { - self.didCopy = true - } self.resetTask?.cancel() - self.resetTask = Task { @MainActor in - try? await Task.sleep(for: .seconds(0.9)) - withAnimation(.easeOut(duration: 0.2)) { + MenuPasteboardCopy.perform(self.path, completion: { + self.didCopy = true + self.resetTask = Task { @MainActor in + try? await Task.sleep(for: .seconds(0.9)) self.didCopy = false } - } + }) } label: { Image(systemName: self.didCopy ? "checkmark" : "doc.on.doc") .font(.caption2.weight(.semibold)) @@ -213,10 +364,4 @@ struct StoragePathCopyButton: View { .help(self.didCopy ? L("Copied") : L("Copy path")) .accessibilityLabel(self.didCopy ? L("Copied") : L("Copy path")) } - - static func copyToPasteboard(_ path: String) { - let pasteboard = NSPasteboard.general - pasteboard.clearContents() - pasteboard.setString(path, forType: .string) - } } diff --git a/Sources/CodexBar/SyntheticTokenStore.swift b/Sources/CodexBar/SyntheticTokenStore.swift index fb4c78fd60..b3d3a41b36 100644 --- a/Sources/CodexBar/SyntheticTokenStore.swift +++ b/Sources/CodexBar/SyntheticTokenStore.swift @@ -50,7 +50,7 @@ struct KeychainSyntheticTokenStore: SyntheticTokenStoring { account: self.account)) } - let status = SecItemCopyMatching(query as CFDictionary, &result) + let status = KeychainSecurity.copyMatching(query as CFDictionary, &result) if status == errSecItemNotFound { return nil } @@ -91,7 +91,7 @@ struct KeychainSyntheticTokenStore: SyntheticTokenStoring { kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly, ] - let updateStatus = SecItemUpdate(query as CFDictionary, attributes as CFDictionary) + let updateStatus = KeychainSecurity.update(query as CFDictionary, attributes as CFDictionary) if updateStatus == errSecSuccess { return } @@ -104,7 +104,7 @@ struct KeychainSyntheticTokenStore: SyntheticTokenStoring { for (key, value) in attributes { addQuery[key] = value } - let addStatus = SecItemAdd(addQuery as CFDictionary, nil) + let addStatus = KeychainSecurity.add(addQuery as CFDictionary, nil) guard addStatus == errSecSuccess else { Self.log.error("Keychain add failed: \(addStatus)") throw SyntheticTokenStoreError.keychainStatus(addStatus) @@ -118,7 +118,7 @@ struct KeychainSyntheticTokenStore: SyntheticTokenStoring { kSecAttrService as String: self.service, kSecAttrAccount as String: self.account, ] - let status = SecItemDelete(query as CFDictionary) + let status = KeychainSecurity.delete(query as CFDictionary) if status == errSecSuccess || status == errSecItemNotFound { return } diff --git a/Sources/CodexBar/TerminalApp.swift b/Sources/CodexBar/TerminalApp.swift new file mode 100644 index 0000000000..ece08f7ea3 --- /dev/null +++ b/Sources/CodexBar/TerminalApp.swift @@ -0,0 +1,123 @@ +import AppKit + +enum TerminalApp: String, CaseIterable, Identifiable { + static let pickerIconSize = NSSize(width: 16, height: 16) + + case terminal + case iTerm + + var id: String { + self.rawValue + } + + var label: String { + switch self { + case .terminal: "Terminal" + case .iTerm: "iTerm" + } + } + + var bundleIdentifier: String { + switch self { + case .terminal: "com.apple.Terminal" + case .iTerm: "com.googlecode.iterm2" + } + } + + var isInstalled: Bool { + self.isInstalled { NSWorkspace.shared.urlForApplication(withBundleIdentifier: $0) } + } + + func isInstalled(applicationURL: (String) -> URL?) -> Bool { + self == .terminal || applicationURL(self.bundleIdentifier) != nil + } + + var appIcon: NSImage? { + guard let appURL = NSWorkspace.shared.urlForApplication(withBundleIdentifier: self.bundleIdentifier) else { + return nil + } + return NSWorkspace.shared.icon(forFile: appURL.path) + } + + var pickerIcon: NSImage? { + self.appIcon.map(Self.pickerIcon(from:)) + } + + static func pickerIcon(from icon: NSImage) -> NSImage { + let sourceSize = icon.size + let targetSize = self.pickerIconSize + + guard sourceSize.width.isFinite, sourceSize.width > 0, + sourceSize.height.isFinite, sourceSize.height > 0 + else { + let empty = NSImage(size: targetSize) + empty.isTemplate = icon.isTemplate + return empty + } + + // MenuPickerStyle sizes selected images from their intrinsic NSImage dimensions. + let resized = NSImage(size: targetSize, flipped: false) { _ in + let scale = min(targetSize.width / sourceSize.width, targetSize.height / sourceSize.height) + let scaledSize = NSSize(width: sourceSize.width * scale, height: sourceSize.height * scale) + let drawingRect = NSRect( + x: (targetSize.width - scaledSize.width) / 2, + y: (targetSize.height - scaledSize.height) / 2, + width: scaledSize.width, + height: scaledSize.height) + NSGraphicsContext.current?.imageInterpolation = .high + icon.draw( + in: drawingRect, + from: NSRect(origin: .zero, size: sourceSize), + operation: .copy, + fraction: 1) + return true + } + resized.isTemplate = icon.isTemplate + return resized + } + + static var installed: [Self] { + self.installed { NSWorkspace.shared.urlForApplication(withBundleIdentifier: $0) } + } + + static func installed(applicationURL: (String) -> URL?) -> [Self] { + self.allCases.filter { $0.isInstalled(applicationURL: applicationURL) } + } + + static func pickerOptions(selected: Self) -> [Self] { + self.pickerOptions(selected: selected) { NSWorkspace.shared.urlForApplication(withBundleIdentifier: $0) } + } + + static func pickerOptions(selected: Self, applicationURL: (String) -> URL?) -> [Self] { + self.allCases.filter { $0 == selected || $0.isInstalled(applicationURL: applicationURL) } + } + + func appleScript(command: String) -> String { + let escaped = Self.escapeForAppleScript(command) + return switch self { + case .terminal: + """ + tell application "Terminal" + activate + do script "\(escaped)" + end tell + """ + case .iTerm: + """ + tell application "iTerm" + activate + set newWindow to (create window with default profile) + tell current session of newWindow + write text "\(escaped)" + end tell + end tell + """ + } + } + + static func escapeForAppleScript(_ command: String) -> String { + command + .replacingOccurrences(of: "\\", with: "\\\\") + .replacingOccurrences(of: "\"", with: "\\\"") + } +} diff --git a/Sources/CodexBar/UsageBreakdownChartMenuView.swift b/Sources/CodexBar/UsageBreakdownChartMenuView.swift index 0b1ca524dc..75b7aefd07 100644 --- a/Sources/CodexBar/UsageBreakdownChartMenuView.swift +++ b/Sources/CodexBar/UsageBreakdownChartMenuView.swift @@ -4,6 +4,12 @@ import SwiftUI @MainActor struct UsageBreakdownChartMenuView: View { + enum PresentationState: Equatable { + case empty + case totalsOnly + case chart + } + private struct Point: Identifiable { let id: String let date: Date @@ -19,23 +25,49 @@ struct UsageBreakdownChartMenuView: View { } private let breakdown: [OpenAIDashboardDailyBreakdown] + private let now: Date + private let calendar: Calendar private let width: CGFloat @State private var selectedDayKey: String? - init(breakdown: [OpenAIDashboardDailyBreakdown], width: CGFloat) { + init( + breakdown: [OpenAIDashboardDailyBreakdown], + now: Date = Date(), + calendar: Calendar = .current, + width: CGFloat) + { self.breakdown = breakdown + self.now = now + self.calendar = calendar self.width = width } var body: some View { - let model = Self.makeModel(from: self.breakdown) + let summary = OpenAIDashboardDailyBreakdown.recentUsageSummary( + from: self.breakdown, + now: self.now, + calendar: self.calendar) + let model = Self.makeModel(from: summary.daily) + let presentationState = Self.presentationState( + hasSummary: !summary.daily.isEmpty, + hasChartPoints: !model.points.isEmpty) VStack(alignment: .leading, spacing: 10) { - if model.points.isEmpty { + if presentationState != .empty { + HStack(alignment: .firstTextBaseline) { + self.summaryMetric(title: L("Today"), credits: summary.todayCredits) + Spacer(minLength: 12) + self.summaryMetric( + title: String(format: L("Last %d days"), summary.historyDays), + credits: summary.totalCredits) + } + } + + if presentationState == .empty { Text(L("No usage breakdown data.")) .font(.footnote) .foregroundStyle(.secondary) .accessibilityLabel(L("No usage breakdown data available.")) - } else { + } else if presentationState == .chart { Chart { ForEach(model.points) { point in BarMark( @@ -55,12 +87,16 @@ struct UsageBreakdownChartMenuView: View { .chartForegroundStyleScale(domain: model.services, range: model.serviceColors) .chartYAxis(.hidden) .chartXAxis { - AxisMarks(values: model.axisDates) { _ in + AxisMarks(values: model.axisDates) { value in AxisGridLine().foregroundStyle(Color.clear) AxisTick().foregroundStyle(Color.clear) - AxisValueLabel(format: .dateTime.month(.abbreviated).day()) - .font(.caption2) - .foregroundStyle(Color(nsColor: .tertiaryLabelColor)) + if let date = value.as(Date.self) { + AxisValueLabel(anchor: Self.xAxisLabelAnchor(for: date, axisDates: model.axisDates)) { + Text(date, format: .dateTime.month(.abbreviated).day()) + .font(.caption2) + .foregroundStyle(Color(nsColor: .tertiaryLabelColor)) + } + } } } .chartLegend(.hidden) @@ -133,6 +169,12 @@ struct UsageBreakdownChartMenuView: View { .frame(minWidth: self.width, maxWidth: .infinity, alignment: .leading) } + static func presentationState(hasSummary: Bool, hasChartPoints: Bool) -> PresentationState { + if hasChartPoints { return .chart } + if hasSummary { return .totalsOnly } + return .empty + } + private struct Model { let points: [Point] let breakdownByDayKey: [String: OpenAIDashboardDailyBreakdown] @@ -154,6 +196,25 @@ struct UsageBreakdownChartMenuView: View { private static let selectionBandColor = Color(nsColor: .labelColor).opacity(0.1) + private func summaryMetric(title: String, credits: Double?) -> some View { + VStack(alignment: .leading, spacing: 1) { + Text(title) + .font(.caption2) + .foregroundStyle(.secondary) + Text(Self.creditsString(credits)) + .font(.subheadline) + .fontWeight(.semibold) + .monospacedDigit() + } + .accessibilityElement(children: .combine) + } + + private static func creditsString(_ credits: Double?) -> String { + guard let credits, credits.isFinite else { return "—" } + let value = credits.formatted(.number.precision(.fractionLength(0...2))) + return "\(value) \(L("credits"))" + } + private static func makeModel(from breakdown: [OpenAIDashboardDailyBreakdown]) -> Model { let sorted = OpenAIDashboardDailyBreakdown.removingSkillUsageServices(from: breakdown) .sorted { lhs, rhs in lhs.day < rhs.day } @@ -262,6 +323,16 @@ struct UsageBreakdownChartMenuView: View { return [firstDate, lastDate] } + private static func xAxisLabelAnchor(for date: Date, axisDates: [Date]) -> UnitPoint { + if let first = axisDates.first, Calendar.current.isDate(date, inSameDayAs: first) { + return .topLeading + } + if let last = axisDates.last, Calendar.current.isDate(date, inSameDayAs: last) { + return .topTrailing + } + return .top + } + private static func dateFromDayKey(_ key: String) -> Date? { let parts = key.split(separator: "-") guard parts.count == 3, @@ -291,14 +362,6 @@ struct UsageBreakdownChartMenuView: View { let date = model.dayDates[index].date guard let x = proxy.position(forX: date) else { return nil } - func xForIndex(_ idx: Int) -> CGFloat? { - guard idx >= 0, idx < model.dayDates.count else { return nil } - return proxy.position(forX: model.dayDates[idx].date) - } - - let xPrev = xForIndex(index - 1) - let xNext = xForIndex(index + 1) - if model.dayDates.count <= 1 { return CGRect( x: plotFrame.origin.x, @@ -307,24 +370,14 @@ struct UsageBreakdownChartMenuView: View { height: plotFrame.height) } - let leftInPlot: CGFloat = if let xPrev { - (xPrev + x) / 2 - } else if let xNext { - x - (xNext - x) / 2 - } else { - x - 8 - } + // Use the calendar day slot width (always 1 day on the time axis) so the band is the + // same size for every bar regardless of gaps in the data. + let nextDayX = proxy.position(forX: ChartBarHoverSelection.nextCalendarDay(after: date)) ?? (x + 20) + let slotWidth = abs(nextDayX - x) + let barHalfWidth = slotWidth * 0.25 + 2 - let rightInPlot: CGFloat = if let xNext { - (xNext + x) / 2 - } else if let xPrev { - x + (x - xPrev) / 2 - } else { - x + 8 - } - - let left = plotFrame.origin.x + min(leftInPlot, rightInPlot) - let right = plotFrame.origin.x + max(leftInPlot, rightInPlot) + let left = plotFrame.origin.x + x - barHalfWidth + let right = plotFrame.origin.x + x + barHalfWidth return CGRect(x: left, y: plotFrame.origin.y, width: right - left, height: plotFrame.height) } @@ -347,6 +400,24 @@ struct UsageBreakdownChartMenuView: View { guard let date: Date = proxy.value(atX: xInPlot) else { return } guard let nearest = self.nearestDayKey(to: date, model: model) else { return } + // Stay on the last selected bar when cursor is in the gap between bars; only switch + // selection when the cursor is over the bar's own visual body. + // Skip this gate for single-day charts: no gap exists, and selectionBandRect + // already covers the full plot width in that case. + if model.selectableDayDates.count > 1, + let nearestEntry = model.selectableDayDates.first(where: { $0.dayKey == nearest }), + let barX = proxy.position(forX: nearestEntry.date) + { + let nextDayX = proxy.position(forX: ChartBarHoverSelection.nextCalendarDay(after: nearestEntry.date)) ?? + (barX + 20) + let slotWidth = abs(nextDayX - barX) + guard ChartBarHoverSelection.accepts( + distanceFromBarCenter: abs(location.x - (plotFrame.origin.x + barX)), + barHalfWidth: slotWidth * 0.25 + 2, + selectableCount: model.selectableDayDates.count) + else { return } + } + if self.selectedDayKey != nearest { self.selectedDayKey = nearest } diff --git a/Sources/CodexBar/UsageColorLevel.swift b/Sources/CodexBar/UsageColorLevel.swift deleted file mode 100644 index bb2eb6bc52..0000000000 --- a/Sources/CodexBar/UsageColorLevel.swift +++ /dev/null @@ -1,23 +0,0 @@ -import AppKit - -enum UsageColorLevel: Sendable { - /// Returns a smoothly interpolated tint color based on usage percentage. - /// - 0-70%: green blending toward orange - /// - 70-90%: orange blending toward red - /// - >= 90%: red - /// - nil usage: returns nil (monochrome fallback) - static func tintColor(for usedPercent: Double?) -> NSColor? { - guard let pct = usedPercent else { return nil } - let clamped = min(max(pct, 0), 100) - - if clamped < 70 { - let fraction = CGFloat(clamped / 70) - return NSColor.systemGreen.blended(withFraction: fraction, of: .systemOrange) - } else if clamped < 90 { - let fraction = CGFloat((clamped - 70) / 20) - return NSColor.systemOrange.blended(withFraction: fraction, of: .systemRed) - } else { - return .systemRed - } - } -} diff --git a/Sources/CodexBar/UsageMenuCardHeaderAndUsageSectionView.swift b/Sources/CodexBar/UsageMenuCardHeaderAndUsageSectionView.swift index 66a9edf27b..72e341d135 100644 --- a/Sources/CodexBar/UsageMenuCardHeaderAndUsageSectionView.swift +++ b/Sources/CodexBar/UsageMenuCardHeaderAndUsageSectionView.swift @@ -2,13 +2,14 @@ import SwiftUI struct UsageMenuCardHeaderAndUsageSectionView: View { let model: UsageMenuCardView.Model + let layoutModel: UsageMenuCardView.Model let bottomPadding: CGFloat let width: CGFloat var body: some View { VStack(alignment: .leading, spacing: 0) { UsageMenuCardHeaderSectionView( - model: self.model, + model: self.layoutModel, showDivider: true, width: self.width) UsageMenuCardUsageSectionView( diff --git a/Sources/CodexBar/UsageMenuCardLayout.swift b/Sources/CodexBar/UsageMenuCardLayout.swift new file mode 100644 index 0000000000..8814a37cc6 --- /dev/null +++ b/Sources/CodexBar/UsageMenuCardLayout.swift @@ -0,0 +1,17 @@ +import CoreGraphics + +enum UsageMenuCardLayout { + static let horizontalPadding: CGFloat = 20 + static let headerOnlyVerticalPadding: CGFloat = 6 + static let headerContentSpacing: CGFloat = 6 + static let sectionTopPadding: CGFloat = 6 + static let usageSectionTopPadding: CGFloat = 10 + static let sectionBottomPadding: CGFloat = 6 + static let headerLineSpacing: CGFloat = 4 + static let headerColumnSpacing: CGFloat = 12 + + static var postHeaderDividerContentSpacing: CGFloat { + // Reproduces Overview's header-bottom + usage-top gap so full cards align. + sectionBottomPadding + usageSectionTopPadding + } +} diff --git a/Sources/CodexBar/UsagePaceText.swift b/Sources/CodexBar/UsagePaceText.swift index fb5af32d74..76cd6e833a 100644 --- a/Sources/CodexBar/UsagePaceText.swift +++ b/Sources/CodexBar/UsagePaceText.swift @@ -9,29 +9,75 @@ enum UsagePaceText { let stage: UsagePace.Stage } + struct SessionEquivalentDetail: Equatable { + let leftText: String + let rightText: String + let accessibilityLabel: String + } + private enum DetailContext { case session case weekly } - static func weeklySummary(pace: UsagePace, now: Date = .init()) -> String { - let detail = self.weeklyDetail(pace: pace, now: now) + static func weeklySummary(provider: UsageProvider, pace: UsagePace, now: Date = .init()) -> String { + let detail = self.weeklyDetail(provider: provider, pace: pace, now: now) if let rightLabel = detail.rightLabel { return L("Pace: %@ · %@", detail.leftLabel, rightLabel) } return L("Pace: %@", detail.leftLabel) } - static func weeklyDetail(pace: UsagePace, now: Date = .init()) -> WeeklyDetail { + static func weeklyDetail(provider: UsageProvider, pace: UsagePace, now: Date = .init()) -> WeeklyDetail { WeeklyDetail( leftLabel: self.detailLeftLabel(for: pace), - rightLabel: self.detailRightLabel(for: pace, context: .weekly, now: now), + rightLabel: self.detailRightLabel(for: pace, provider: provider, context: .weekly, now: now), expectedUsedPercent: pace.expectedUsedPercent, stage: pace.stage) } + static func sessionEquivalentDetail(forecast: SessionEquivalentForecast) -> SessionEquivalentDetail { + let leftText = Self.sessionQuotaEstimateText(forecast.estimatedWindowsToExhaustWeekly) + let rightText = Self.windowsUntilResetText(forecast.windowsUntilReset) + return SessionEquivalentDetail( + leftText: leftText, + rightText: rightText, + accessibilityLabel: L("%@ · %@", leftText, rightText)) + } + + private static func sessionQuotaEstimateText(_ value: Double) -> String { + let displayedEstimate: String + let unit: String + if value.isFinite, value > 0 { + let boundedValue = min(value, 1_000_000) + let roundedValue = (boundedValue * 10).rounded() / 10 + displayedEstimate = roundedValue.formatted( + .number + .precision(.fractionLength(0...1)) + .locale(codexBarLocalizedLocale())) + unit = roundedValue > 0 && roundedValue <= 1 ? L("session quota") : L("session quotas") + } else { + displayedEstimate = codexBarLocalizedInteger(0) + unit = L("session quotas") + } + let estimateValue = L("session_quota_estimate_value_format", displayedEstimate, unit) + return L("Estimated: %@", L("%@ left", estimateValue)) + } + + private static func windowsUntilResetText(_ count: Int) -> String { + let combinedText = String( + format: L("≈%d full 5h windows of weekly left · %d windows until reset"), + locale: codexBarLocalizedResourceLocale(), + arguments: [0, count]) + guard let separatorRange = combinedText.range(of: " · ") else { return combinedText } + return String(combinedText[separatorRange.upperBound...]) + } + private static func detailLeftLabel(for pace: UsagePace) -> String { let deltaValue = Int(abs(pace.deltaPercent).rounded()) + if deltaValue == 0 { + return L("On pace") + } switch pace.stage { case .onTrack: return L("On pace") @@ -42,10 +88,15 @@ enum UsagePaceText { } } - private static func detailRightLabel(for pace: UsagePace, context: DetailContext, now: Date) -> String? { + private static func detailRightLabel( + for pace: UsagePace, + provider: UsageProvider, + context: DetailContext, + now: Date) -> String? + { let etaLabel: String? if pace.willLastToReset { - etaLabel = L("Lasts until reset") + etaLabel = self.combinedLastsLabel(for: pace, provider: provider) } else if let etaSeconds = pace.etaSeconds { let etaText = Self.durationText(seconds: etaSeconds, now: now) if context == .session { @@ -60,17 +111,40 @@ enum UsagePaceText { guard let runOutProbability = pace.runOutProbability else { return etaLabel } let roundedRisk = self.roundedRiskPercent(runOutProbability) let riskLabel = L("≈ %d%% run-out risk", roundedRisk) + if pace.willLastToReset, roundedRisk > 0 { + return riskLabel + } if let etaLabel { return L("%@ · %@", etaLabel, riskLabel) } return riskLabel } + private static func combinedLastsLabel(for pace: UsagePace, provider: UsageProvider) -> String { + guard provider == .codex else { return L("Lasts until reset") } + guard let speedLabel = self.speedHintLabel(for: pace) else { + return L("Lasts until reset") + } + return L("%@ · %@", L("Lasts until reset"), speedLabel) + } + + private static func speedHintLabel(for pace: UsagePace) -> String? { + guard pace.deltaPercent < -15, + let multiplier = pace.speedMultiplierToReset, + multiplier >= 1.5 + else { return nil } + return L("1.5× headroom") + } + private static func durationText(seconds: TimeInterval, now: Date) -> String { let date = now.addingTimeInterval(seconds) let countdown = UsageFormatter.resetCountdownDescription(from: date, now: now) - if countdown == "now" { return "now" } - if countdown.hasPrefix("in ") { return String(countdown.dropFirst(3)) } + if countdown == "now" { + return "now" + } + if countdown.hasPrefix("in ") { + return String(countdown.dropFirst(3)) + } return countdown } @@ -81,8 +155,18 @@ enum UsagePaceText { } static func sessionPace(provider: UsageProvider, window: RateWindow, now: Date) -> UsagePace? { - guard provider == .codex || provider == .claude || provider == .ollama else { return nil } - if provider == .ollama, window.windowMinutes == nil { return nil } + guard provider == .codex || provider == .claude || provider == .ollama || provider == .antigravity || + provider == .kimi + else { return nil } + if provider == .ollama, window.windowMinutes == nil { + return nil + } + if provider == .antigravity, let windowMinutes = window.windowMinutes, windowMinutes != 300 { + return nil + } + if provider == .kimi, window.windowMinutes != KimiProviderDescriptor.sessionWindowMinutes { + return nil + } guard window.remainingPercent > 0 else { return nil } guard let pace = UsagePace.weekly(window: window, now: now, defaultWindowMinutes: 300) else { return nil } guard pace.expectedUsedPercent >= 3 else { return nil } @@ -93,7 +177,7 @@ enum UsagePaceText { guard let pace = sessionPace(provider: provider, window: window, now: now) else { return nil } return WeeklyDetail( leftLabel: Self.detailLeftLabel(for: pace), - rightLabel: Self.detailRightLabel(for: pace, context: .session, now: now), + rightLabel: Self.detailRightLabel(for: pace, provider: provider, context: .session, now: now), expectedUsedPercent: pace.expectedUsedPercent, stage: pace.stage) } diff --git a/Sources/CodexBar/UsageProgressBar.swift b/Sources/CodexBar/UsageProgressBar.swift index 63bdc68264..de00f0486a 100644 --- a/Sources/CodexBar/UsageProgressBar.swift +++ b/Sources/CodexBar/UsageProgressBar.swift @@ -2,7 +2,27 @@ import SwiftUI /// Static progress fill with no implicit animations, used inside the menu card. struct UsageProgressBar: View { + enum MarkerKind: Equatable { + case quotaWarning + case workdayBoundary + } + + struct Marker: Equatable { + let percent: Double + let kind: MarkerKind + } + private static let paceStripeCount = 3 + private static let stripePunchOpacity = 0.9 + + private nonisolated static var warningMarkerPunchWidth: CGFloat { + 5 + } + + private nonisolated static var warningMarkerStripeWidth: CGFloat { + 1 + } + private static func paceStripeWidth(for scale: CGFloat) -> CGFloat { 2 } @@ -18,6 +38,7 @@ struct UsageProgressBar: View { let pacePercent: Double? let paceOnTop: Bool let warningMarkerPercents: [Double] + let workdayMarkerPercents: [Double] @Environment(\.menuItemHighlighted) private var isHighlighted @Environment(\.displayScale) private var displayScale @@ -27,7 +48,8 @@ struct UsageProgressBar: View { accessibilityLabel: String, pacePercent: Double? = nil, paceOnTop: Bool = true, - warningMarkerPercents: [Double] = []) + warningMarkerPercents: [Double] = [], + workdayMarkerPercents: [Double] = []) { self.percent = percent self.tint = tint @@ -35,6 +57,7 @@ struct UsageProgressBar: View { self.pacePercent = pacePercent self.paceOnTop = paceOnTop self.warningMarkerPercents = warningMarkerPercents + self.workdayMarkerPercents = workdayMarkerPercents } private var clamped: Double { @@ -48,15 +71,16 @@ struct UsageProgressBar: View { // which caused the status item icon to disappear (issue #805). Canvas { context, size in let scale = max(self.displayScale, 1) - let fillWidth = size.width * self.clamped / 100 + let fillPercent = Self.renderedFillPercent(self.clamped) + let fillWidth = size.width * fillPercent / 100 let paceWidth = size.width * Self.clampedPercent(self.pacePercent) / 100 let tipWidth = max(25, size.height * 6.5) let stripeInset = 1 / scale let tipOffset = paceWidth - tipWidth + (Self.paceStripeSpan(for: scale) / 2) + stripeInset let showTip = self.pacePercent != nil && tipWidth > 0.5 - let markerPercents = self.warningMarkerPercents - .map(Self.clampedPercent) - .filter { $0 > 0 && $0 < 100 } + let markers = Self.resolvedMarkers( + warningPercents: self.warningMarkerPercents, + workdayPercents: self.workdayMarkerPercents) let cornerRadius = size.height / 2 let cornerSize = CGSize(width: cornerRadius, height: cornerRadius) @@ -77,17 +101,31 @@ struct UsageProgressBar: View { with: .color(MenuHighlightStyle.progressTint(self.isHighlighted, fallback: self.tint))) } - if !markerPercents.isEmpty { - let markerColor = Self.warningMarkerColor(isHighlighted: self.isHighlighted) - for markerPercent in markerPercents { - let x = size.width * markerPercent / 100 + for marker in markers { + let x = size.width * marker.percent / 100 + switch marker.kind { + case .quotaWarning: let markerRect = Self.warningMarkerRect(x: x, size: size, scale: scale) - let markerPath = Path { p in - p.addRoundedRect( - in: markerRect, - cornerSize: CGSize(width: markerRect.width / 2, height: markerRect.width / 2)) + let markerStripeRect = Self.warningMarkerStripeRect(markerRect, scale: scale) + let markerPunchPath = Path { p in + p.addRect(Self.extendedMarkerRect(markerRect, size: size)) + } + let markerStripePath = Path { p in + p.addRect(Self.extendedMarkerRect(markerStripeRect, size: size)) } - context.fill(markerPath, with: .color(markerColor)) + + // Match the pace stripe treatment: punch through the bar, then draw a slimmer neutral stripe. + context.blendMode = .destinationOut + context.fill(markerPunchPath, with: .color(.white.opacity(Self.stripePunchOpacity))) + context.blendMode = .normal + context.fill( + markerStripePath, + with: .color(Self.warningMarkerColor(isHighlighted: self.isHighlighted))) + case .workdayBoundary: + let markerRect = Self.workdayMarkerRect(x: x, size: size, scale: scale) + context.fill( + Path(markerRect), + with: .color(Self.workdayMarkerColor(isHighlighted: self.isHighlighted))) } } @@ -110,7 +148,7 @@ struct UsageProgressBar: View { // Punch out of the accumulated track+fill pixels. context.blendMode = .destinationOut - context.fill(stripes.punched.applying(shift), with: .color(.white.opacity(0.9))) + context.fill(stripes.punched.applying(shift), with: .color(.white.opacity(Self.stripePunchOpacity))) context.blendMode = .normal context.fill(stripes.center.applying(shift), with: .color(stripeColor)) @@ -118,7 +156,60 @@ struct UsageProgressBar: View { } .frame(height: 6) .accessibilityLabel(self.accessibilityLabel) - .accessibilityValue("\(Int(self.clamped)) percent") + .accessibilityValue(self.markerAccessibilityValue) + } + + private var markerAccessibilityValue: String { + var parts = [L("%d percent", Self.displayPercent(self.clamped))] + let markers = Self.resolvedMarkers( + warningPercents: self.warningMarkerPercents, + workdayPercents: self.workdayMarkerPercents) + let warnings = markers.filter { $0.kind == .quotaWarning }.map(Self.markerPercentText) + let workdays = markers.filter { $0.kind == .workdayBoundary }.map(Self.markerPercentText) + if !warnings.isEmpty { + parts.append("\(L("quota_warnings_title")): \(warnings.joined(separator: ", "))") + } + if !workdays.isEmpty { + parts.append("\(L("weekly_progress_work_days_title")): \(workdays.joined(separator: ", "))") + } + return parts.joined(separator: ". ") + } + + nonisolated static func resolvedMarkers( + warningPercents: [Double], + workdayPercents: [Double]) -> [Marker] + { + let warnings = Self.normalizedMarkerPercents(warningPercents) + let workdays = Self.normalizedMarkerPercents(workdayPercents) + .filter { workday in !warnings.contains { abs($0 - workday) < 0.001 } } + return ( + warnings.map { Marker(percent: $0, kind: .quotaWarning) } + + workdays.map { Marker(percent: $0, kind: .workdayBoundary) }) + .sorted { lhs, rhs in lhs.percent < rhs.percent } + } + + private nonisolated static func normalizedMarkerPercents(_ values: [Double]) -> [Double] { + values + .map(self.clampedPercent) + .filter { $0 > 0 && $0 < 100 } + .reduce(into: [Double]()) { result, value in + if !result.contains(where: { abs($0 - value) < 0.001 }) { + result.append(value) + } + } + } + + private nonisolated static func markerPercentText(_ marker: Marker) -> String { + "\(Int(marker.percent.rounded()))%" + } + + /// Aligns edge rendering with the rounded percent label: sub-0.5% is empty and 99.5%+ is full. + nonisolated static func renderedFillPercent(_ percent: Double) -> Double { + let clamped = Self.clampedPercent(percent) + let displayPercent = Self.displayPercent(clamped) + if displayPercent <= 0 { return 0 } + if displayPercent >= 100 { return 100 } + return clamped } private static func paceStripePaths(size: CGSize, scale: CGFloat) -> (punched: Path, center: Path) { @@ -171,24 +262,64 @@ struct UsageProgressBar: View { nonisolated static func warningMarkerRect(x: CGFloat, size: CGSize, scale rawScale: CGFloat) -> CGRect { let scale = max(rawScale, 1) - let width = max(1 / scale, 1) - let height = min(size.height, max(1 / scale, size.height * 0.55)) + let width = Self.warningMarkerPunchWidth let align: (CGFloat) -> CGFloat = { value in (value * scale).rounded() / scale } return CGRect( x: align(x - width / 2), - y: align((size.height - height) / 2), + y: 0, + width: width, + height: align(size.height)) + } + + nonisolated static func warningMarkerStripeRect(_ markerRect: CGRect, scale rawScale: CGFloat) -> CGRect { + let scale = max(rawScale, 1) + let width = min(markerRect.width, max(1 / scale, Self.warningMarkerStripeWidth)) + let align: (CGFloat) -> CGFloat = { value in + (value * scale).rounded() / scale + } + + return CGRect( + x: align(markerRect.midX - width / 2), + y: markerRect.minY, + width: width, + height: markerRect.height) + } + + nonisolated static func workdayMarkerRect(x: CGFloat, size: CGSize, scale rawScale: CGFloat) -> CGRect { + let scale = max(rawScale, 1) + let width = 1 / scale + let height = max(1 / scale, size.height * 0.5) + let align: (CGFloat) -> CGFloat = { value in + (value * scale).rounded() / scale + } + return CGRect( + x: align(x - width / 2), + y: align(size.height - height), width: width, height: align(height)) } + private nonisolated static func extendedMarkerRect(_ rect: CGRect, size: CGSize) -> CGRect { + let extend = size.height * 2 + return rect.insetBy(dx: 0, dy: -extend) + } + nonisolated static func warningMarkerColor(isHighlighted: Bool) -> Color { - isHighlighted ? .white.opacity(0.72) : .primary.opacity(0.32) + isHighlighted ? .white.opacity(0.96) : .primary.opacity(0.68) + } + + nonisolated static func workdayMarkerColor(isHighlighted: Bool) -> Color { + isHighlighted ? .white.opacity(0.55) : .primary.opacity(0.30) + } + + private nonisolated static func displayPercent(_ percent: Double) -> Int { + Int(self.clampedPercent(percent).rounded()) } - private static func clampedPercent(_ value: Double?) -> Double { + private nonisolated static func clampedPercent(_ value: Double?) -> Double { guard let value else { return 0 } return min(100, max(0, value)) } diff --git a/Sources/CodexBar/UsageStore+APIKeyDebug.swift b/Sources/CodexBar/UsageStore+APIKeyDebug.swift new file mode 100644 index 0000000000..e78d80019d --- /dev/null +++ b/Sources/CodexBar/UsageStore+APIKeyDebug.swift @@ -0,0 +1,107 @@ +import CodexBarCore +import Foundation + +extension UsageStore { + struct APIKeyDebugContext { + let label: String + let resolution: ProviderTokenResolution? + let configToken: String? + let hasEnvToken: Bool + let hasTokenAccount: Bool + } + + func openAIAPIKeyDebugContext(processEnvironment: [String: String]) -> APIKeyDebugContext { + self.apiKeyDebugContext( + provider: .openai, + label: "OPENAI_API_KEY", + processEnvironment: processEnvironment, + resolution: ProviderTokenResolver.openAIAPIResolution, + hasEnvToken: { OpenAIAPISettingsReader.apiKey(environment: $0) != nil }) + } + + func azureOpenAIAPIKeyDebugContext(processEnvironment: [String: String]) -> APIKeyDebugContext { + let config = self.settings.providerConfig(for: .azureopenai) + let environment = ProviderConfigEnvironment.applyProviderConfigOverrides( + base: processEnvironment, + provider: .azureopenai, + config: config) + return APIKeyDebugContext( + label: "AZURE_OPENAI_API_KEY", + resolution: ProviderTokenResolver.azureOpenAIResolution(environment: environment), + configToken: config?.sanitizedAPIKey, + hasEnvToken: AzureOpenAISettingsReader.apiKey(environment: processEnvironment) != nil, + hasTokenAccount: false) + } + + func openRouterAPIKeyDebugContext(processEnvironment: [String: String]) -> APIKeyDebugContext { + self.apiKeyDebugContext( + provider: .openrouter, + label: "OPENROUTER_API_KEY", + processEnvironment: processEnvironment, + resolution: ProviderTokenResolver.openRouterResolution, + hasEnvToken: { OpenRouterSettingsReader.apiToken(environment: $0) != nil }) + } + + func elevenLabsAPIKeyDebugContext(processEnvironment: [String: String]) -> APIKeyDebugContext { + self.apiKeyDebugContext( + provider: .elevenlabs, + label: "ELEVENLABS_API_KEY", + processEnvironment: processEnvironment, + resolution: ProviderTokenResolver.elevenLabsResolution, + hasEnvToken: { ElevenLabsSettingsReader.apiKey(environment: $0) != nil }) + } + + func apiKeyDebugContext( + provider: UsageProvider, + label: String, + processEnvironment: [String: String], + resolution: ([String: String]) -> ProviderTokenResolution?, + hasEnvToken: ([String: String]) -> Bool) -> APIKeyDebugContext + { + let config = self.settings.providerConfig(for: provider) + let environment = ProviderConfigEnvironment.applyAPIKeyOverride( + base: processEnvironment, + provider: provider, + config: config) + return APIKeyDebugContext( + label: label, + resolution: resolution(environment), + configToken: config?.sanitizedAPIKey, + hasEnvToken: hasEnvToken(processEnvironment), + hasTokenAccount: false) + } + + nonisolated static func apiKeyDebugLine(_ context: APIKeyDebugContext) -> String { + self.apiKeyDebugLine( + label: context.label, + resolution: context.resolution, + configToken: context.configToken, + hasEnvToken: context.hasEnvToken, + hasTokenAccount: context.hasTokenAccount) + } + + nonisolated static func apiKeyDebugLine( + label: String, + resolution: ProviderTokenResolution?, + configToken: String?, + hasEnvToken: Bool, + hasTokenAccount: Bool = false) -> String + { + let hasAny = resolution != nil + let hasConfigToken = !(configToken?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ?? true) + let source: String = if resolution == nil { + "none" + } else if hasTokenAccount, hasEnvToken { + "settings-token-account (overrides env)" + } else if hasTokenAccount { + "settings-token-account" + } else if hasConfigToken, hasEnvToken { + "settings-config (overrides env)" + } else if hasConfigToken { + "settings-config" + } else { + resolution?.source.rawValue ?? "environment" + } + return "\(label)=\(hasAny ? "present" : "missing") source=\(source)" + } +} diff --git a/Sources/CodexBar/UsageStore+Accessors.swift b/Sources/CodexBar/UsageStore+Accessors.swift index 4224e8d720..e013db3189 100644 --- a/Sources/CodexBar/UsageStore+Accessors.swift +++ b/Sources/CodexBar/UsageStore+Accessors.swift @@ -2,6 +2,16 @@ import CodexBarCore import Foundation extension UsageStore { + struct DeepSeekProfileTransition { + var snapshot: UsageSnapshot + let accountID: UUID? + let hasSyntheticBalance: Bool + } + + func version(for provider: UsageProvider) -> String? { + self.versions[provider] + } + var codexSnapshot: UsageSnapshot? { self.snapshots[.codex] } @@ -10,6 +20,62 @@ extension UsageStore { self.snapshots[.claude] } + func presentationSnapshot(for provider: UsageProvider) -> UsageSnapshot? { + if provider == .deepseek, + let transition = self.deepseekProfileTransition, + transition.accountID == self.settings.selectedTokenAccount(for: .deepseek)?.id + { + return transition.snapshot + } + if let snapshot = self.snapshots[provider] { + return snapshot + } + guard provider == .deepseek, self.refreshingProviders.contains(provider) else { return nil } + return self.lastKnownResetSnapshots[provider] + } + + func beginDeepSeekProfileTransition(preservingBalance: Bool = true) { + guard self.deepseekProfileTransition == nil, + let snapshot = self.snapshots[.deepseek] ?? self.lastKnownResetSnapshots[.deepseek] + else { return } + var transitionSnapshot = snapshot.withoutDeepSeekDetailedUsage() + if !preservingBalance { + transitionSnapshot = transitionSnapshot.with( + primary: RateWindow( + usedPercent: 0, + windowMinutes: nil, + resetsAt: nil, + resetDescription: L("Refreshing")), + secondary: nil) + } + self.deepseekProfileTransition = DeepSeekProfileTransition( + snapshot: transitionSnapshot, + accountID: self.settings.selectedTokenAccount(for: .deepseek)?.id, + hasSyntheticBalance: !preservingBalance) + } + + func markDeepSeekProfileTransitionUnavailable() { + guard var transition = self.deepseekProfileTransition, + transition.hasSyntheticBalance + else { return } + transition.snapshot = transition.snapshot.with( + primary: RateWindow( + usedPercent: 0, + windowMinutes: nil, + resetsAt: nil, + resetDescription: L("Unavailable")), + secondary: nil) + self.deepseekProfileTransition = transition + } + + func clearDeepSeekProfileTransition() { + self.deepseekProfileTransition = nil + } + + var deepseekProfileTransitionSnapshot: UsageSnapshot? { + self.deepseekProfileTransition?.snapshot + } + var lastCodexError: String? { self.errors[.codex] } @@ -34,10 +100,23 @@ extension UsageStore { self.errors[provider] } + func diagnostic(for provider: UsageProvider) -> String? { + self.diagnostics[provider] + } + func userFacingError(for provider: UsageProvider) -> String? { if let raw = self.errors[provider] { - guard provider == .codex else { return raw } - return CodexUIErrorMapper.userFacingMessage(raw) + switch provider { + case .codex: + return CodexUIErrorMapper.userFacingMessage(raw) + case .ollama: + return OllamaUIErrorMapper.userFacingMessage(raw) + default: + return raw + } + } + if let diagnostic = self.diagnostics[provider] { + return diagnostic } return self.unavailableMessage(for: provider) } @@ -56,12 +135,26 @@ extension UsageStore { return ZaiSettingsError.missingToken.errorDescription case .openrouter: return OpenRouterSettingsError.missingToken.errorDescription + case .clawrouter: + return ClawRouterUsageError.missingCredentials.errorDescription + case .sub2api: + let environment = ProviderRegistry.makeEnvironment( + base: self.environmentBase, + provider: provider, + settings: self.settings, + tokenOverride: nil) + if Sub2APISettingsReader.apiKey(environment: environment) == nil { + return Sub2APIUsageError.missingCredentials.errorDescription + } + return Sub2APIUsageError.missingBaseURL.errorDescription case .azureopenai: return AzureOpenAISettingsError.missingAPIKey.errorDescription case .elevenlabs: return ElevenLabsUsageError.missingCredentials.errorDescription case .deepseek: return DeepSeekUsageError.missingCredentials.errorDescription + case .deepinfra: + return DeepInfraUsageError.missingCredentials.errorDescription case .perplexity: return PerplexityAPIError.missingToken.errorDescription case .minimax: @@ -82,16 +175,36 @@ extension UsageStore { self.status(for: provider)?.indicator ?? .none } + func statusComponents(for provider: UsageProvider) -> [ProviderStatusComponent] { + guard self.statusChecksEnabled else { return [] } + return self.statusComponents[provider] ?? [] + } + func accountInfo(for provider: UsageProvider) -> AccountInfo { - guard provider == .codex else { - return self.codexFetcher.loadAccountInfo() + let now = Date() + let configRevision = self.settings.configRevision + if let cached = self.accountInfoCache[provider], + cached.isValid(now: now, configRevision: configRevision) + { + return cached.account + } + + let account: AccountInfo + if provider == .codex { + let env = ProviderRegistry.makeEnvironment( + base: self.environmentBase, + provider: .codex, + settings: self.settings, + tokenOverride: nil) + let fetcher = ProviderRegistry.makeFetcher(base: self.codexFetcher, provider: .codex, env: env) + account = fetcher.loadAccountInfo() + } else { + account = self.codexFetcher.loadAccountInfo() } - let env = ProviderRegistry.makeEnvironment( - base: self.environmentBase, - provider: .codex, - settings: self.settings, - tokenOverride: nil) - let fetcher = ProviderRegistry.makeFetcher(base: self.codexFetcher, provider: .codex, env: env) - return fetcher.loadAccountInfo() + self.accountInfoCache[provider] = AccountInfoCacheEntry( + account: account, + configRevision: configRevision, + expiresAt: now.addingTimeInterval(self.accountInfoCacheTTL)) + return account } } diff --git a/Sources/CodexBar/UsageStore+AdaptiveRefresh.swift b/Sources/CodexBar/UsageStore+AdaptiveRefresh.swift new file mode 100644 index 0000000000..20e29a700a --- /dev/null +++ b/Sources/CodexBar/UsageStore+AdaptiveRefresh.swift @@ -0,0 +1,155 @@ +import Foundation + +/// Wiring around `AdaptiveRefreshPolicy` for `UsageStore.startTimer()`: gathering live signals, +/// logging the resulting decision, and applying the DEBUG-only sleep-duration override used by +/// tests. Split out of UsageStore.swift to keep that file's class body under the lint line limit. +extension UsageStore { + func effectiveTimerSleepDuration(_ computed: Duration) -> Duration { + #if DEBUG + self.refreshTimerSleepOverrideForTesting ?? computed + #else + computed + #endif + } + + /// Pure wiring helper: builds the `AdaptiveRefreshPolicy.Input` from explicit values and + /// returns the resulting decision. `startTimer()` supplies live `ProcessInfo` state and + /// `lastMenuOpenAt` at call time; this stays a plain, testable function of its arguments. + nonisolated static func adaptiveRefreshDecision( + now: Date, + lastMenuOpenAt: Date?, + lastCodingActivityAt: Date? = nil, + lowPowerModeEnabled: Bool, + thermalState: ProcessInfo.ThermalState, + policy: AdaptiveRefreshPolicy = AdaptiveRefreshPolicy()) -> AdaptiveRefreshPolicy.Decision + { + policy.nextDelay(for: AdaptiveRefreshPolicy.Input( + now: now, + lastMenuOpenAt: lastMenuOpenAt, + lastCodingActivityAt: lastCodingActivityAt, + lowPowerModeEnabled: lowPowerModeEnabled, + thermalState: thermalState)) + } + + nonisolated static func shouldAdvanceAdaptiveTimer(scheduledAt: Date?, candidate: Date) -> Bool { + guard let scheduledAt else { return true } + return candidate < scheduledAt + } + + func noteCodingActivityObserved(at date: Date, now: Date = Date()) { + guard self.settings.adaptiveActivityScanningEnabled else { return } + self.retainCodingActivityIfNewer(date) + self.advanceAdaptiveTimerIfEarlier(at: now) + } + + func advanceAdaptiveTimerIfEarlier(at date: Date) { + guard self.settings.refreshFrequency.usesAdaptivePolicy else { return } + let decision = Self.adaptiveRefreshDecision( + now: date, + lastMenuOpenAt: self.lastMenuOpenAt, + lastCodingActivityAt: self.settings.adaptiveActivityScanningEnabled ? self.lastCodingActivityAt : nil, + lowPowerModeEnabled: ProcessInfo.processInfo.isLowPowerModeEnabled, + thermalState: ProcessInfo.processInfo.thermalState) + let candidate = date.addingTimeInterval(TimeInterval(decision.delay.components.seconds)) + guard Self.shouldAdvanceAdaptiveTimer( + scheduledAt: self.adaptiveRefreshScheduledAt, + candidate: candidate) + else { return } + self.restartAdaptiveTimerPreservingResetBoundary() + } + + /// Advances a fixed timer from the last scheduled tick instead of the refresh completion time. + /// Missed ticks are skipped so a refresh that runs longer than its interval does not create + /// overlapping catch-up refreshes. + nonisolated static func nextFixedTimerScheduledAt( + previousScheduledAt: ContinuousClock.Instant, + completedAt: ContinuousClock.Instant, + interval: Duration) -> ContinuousClock.Instant + { + precondition(interval > .zero) + var scheduledAt = previousScheduledAt + interval + while scheduledAt <= completedAt { + scheduledAt += interval + } + return scheduledAt + } + + nonisolated static func runFixedRefreshTimer( + interval: Duration, + sleepOverride: Duration? = nil, + now: @escaping @Sendable () async -> ContinuousClock.Instant = { ContinuousClock.now }, + sleep: @escaping @Sendable (Duration) async throws -> Void = { duration in + try await Task.sleep(for: duration) + }, + refresh: @escaping @Sendable () async -> Void) async + { + precondition(interval > .zero) + var scheduledAt = await now() + interval + while !Task.isCancelled { + let current = await now() + let computedSleep = current >= scheduledAt ? .zero : scheduledAt - current + do { + try await sleep(sleepOverride ?? computedSleep) + } catch { + return + } + guard !Task.isCancelled else { return } + await refresh() + scheduledAt = await self.nextFixedTimerScheduledAt( + previousScheduledAt: scheduledAt, + completedAt: now(), + interval: interval) + } + } + + func logAdaptiveRefreshDecision(_ decision: AdaptiveRefreshPolicy.Decision) { + // Reason and delay only; never provider/account/email/path/credential/response data. + // No "adaptive refresh: " prefix — the adaptiveRefresh log category already identifies the source. + self.adaptiveRefreshLogger.debug( + "reason=\(decision.reason.rawValue) delay=\(decision.delay.components.seconds)s") + } + + /// Computes this tick's adaptive sleep duration (and logs the decision) while briefly holding a + /// strong reference to `store`; returns nil once the store has deallocated, ending the loop. + /// Kept as a separate call so the strong reference doesn't extend into the caller's `Task.sleep`. + static func nextAdaptiveTimerSleepDuration(for store: UsageStore?) async -> Duration? { + guard let store else { return nil } + let now = Date() + let decision = Self.adaptiveRefreshDecision( + now: now, + lastMenuOpenAt: store.lastMenuOpenAt, + lastCodingActivityAt: store.settings.adaptiveActivityScanningEnabled + ? store.lastCodingActivityAt + : nil, + lowPowerModeEnabled: ProcessInfo.processInfo.isLowPowerModeEnabled, + thermalState: ProcessInfo.processInfo.thermalState) + store.adaptiveRefreshScheduledAt = now.addingTimeInterval(TimeInterval(decision.delay.components.seconds)) + store.logAdaptiveRefreshDecision(decision) + return store.effectiveTimerSleepDuration(decision.delay) + } + + /// The refresh interval scheduling *heuristics* (reset-boundary refresh, OpenAI web staleness, + /// persistent-CLI-session idle windows) should use as "how often does a normal refresh happen". + /// This is deliberately distinct from `RefreshFrequency.seconds`, which is nil for both `.manual` + /// (no timer at all — heuristics correctly get nil here too) and `.adaptive` (no *fixed* + /// interval, but ticks are still happening on a real, computable cadence). For `.adaptive`, this + /// resolves to what `AdaptiveRefreshPolicy` would decide right now from live signals, so those + /// heuristics stay active and roughly proportionate instead of silently behaving like manual. + func normalRefreshIntervalForHeuristics() -> TimeInterval? { + switch self.settings.refreshFrequency { + case .manual: + nil + case .adaptive, .adaptiveAgentAware: + TimeInterval(Self.adaptiveRefreshDecision( + now: Date(), + lastMenuOpenAt: self.lastMenuOpenAt, + lastCodingActivityAt: self.settings.adaptiveActivityScanningEnabled + ? self.lastCodingActivityAt + : nil, + lowPowerModeEnabled: ProcessInfo.processInfo.isLowPowerModeEnabled, + thermalState: ProcessInfo.processInfo.thermalState).delay.components.seconds) + default: + self.settings.refreshFrequency.seconds + } + } +} diff --git a/Sources/CodexBar/UsageStore+BackgroundRefresh.swift b/Sources/CodexBar/UsageStore+BackgroundRefresh.swift index b892b0b5a3..5fefcd1fe8 100644 --- a/Sources/CodexBar/UsageStore+BackgroundRefresh.swift +++ b/Sources/CodexBar/UsageStore+BackgroundRefresh.swift @@ -3,27 +3,100 @@ import Foundation @MainActor extension UsageStore { - private func clearProviderState(_ provider: UsageProvider) { + struct ProviderPublicationRevision: Equatable { + let cleanupRevision: UInt64 + let enablementRevision: UInt64 + } + + /// Invalidates in-flight provider work and clears its transient runtime/UI state. + /// Settings, token-account configuration, historical datasets, credits/dashboard caches, + /// and disk-backed Codex account snapshots intentionally remain owned by their existing lifetimes. + func clearProviderState(_ provider: UsageProvider) { + self.invalidateProviderRefreshRequests(provider) + self.clearProviderRuntimeState(provider) + } + + /// Cancels and retires in-flight work without clearing the provider's current presentation state. + func invalidateProviderRefreshRequests(_ provider: UsageProvider) { + self.providerRefreshCoordinator.invalidateRequests(for: provider) + } + + /// The active refresh uses this when it discovers its own provider is disabled. Its replacing + /// request already invalidated predecessors, so canceling the current coordinator state here + /// would make it cancel itself before its waiters can drain. + func clearProviderRuntimeState(_ provider: UsageProvider) { + self.providerCleanupRevisions[provider, default: 0] &+= 1 self.refreshingProviders.remove(provider) self.snapshots.removeValue(forKey: provider) + self.lastKnownResetSnapshots.removeValue(forKey: provider) self.errors[provider] = nil + self.diagnostics[provider] = nil + if provider == .deepseek { + self.clearDeepSeekProfileTransition() + } + if provider == .gemini { + self.clearGeminiConsumerTierDeprecationObservation() + } + self.knownLimitsAvailabilityByProvider.removeValue(forKey: provider) self.lastSourceLabels.removeValue(forKey: provider) self.lastFetchAttempts.removeValue(forKey: provider) self.accountSnapshots.removeValue(forKey: provider) - self.tokenSnapshots.removeValue(forKey: provider) + self.tokenAccountLiveStateProviders.remove(provider) + if provider == .codex { + self.codexAccountSnapshots = [] + self.lastCodexUsagePublicationGuard = nil + } + if provider == .kilo { + self.kiloScopeSnapshots = [] + } + if provider == .claude { + self.widgetUsagePreservationBlockedProviders.insert(provider) + self.clearClaudeSwapAccountState() + } + self.clearTokenSnapshot(for: provider) self.tokenErrors[provider] = nil self.providerStorageFootprints.removeValue(forKey: provider) self.failureGates[provider]?.reset() self.tokenFailureGates[provider]?.reset() self.statuses.removeValue(forKey: provider) - self.lastKnownSessionRemaining.removeValue(forKey: provider) - self.lastKnownSessionWindowSource.removeValue(forKey: provider) + self.statusComponents.removeValue(forKey: provider) + self.clearSessionQuotaTransitionState(provider: provider) + self.predictivePaceWarningNotifiedKeys = Set( + self.predictivePaceWarningNotifiedKeys.filter { $0.provider != provider }) + self.quotaWarningState = self.quotaWarningState.filter { $0.key.provider != provider } self.lastTokenFetchAt.removeValue(forKey: provider) + self.lastTokenFetchScope.removeValue(forKey: provider) + } + + func providerCleanupRevision(for provider: UsageProvider) -> UInt64 { + self.providerCleanupRevisions[provider, default: 0] + } + + func providerCleanupRevisionIsCurrent(_ revision: UInt64, for provider: UsageProvider) -> Bool { + self.providerCleanupRevision(for: provider) == revision + } + + func providerPublicationRevision(for provider: UsageProvider) -> ProviderPublicationRevision { + ProviderPublicationRevision( + cleanupRevision: self.providerCleanupRevision(for: provider), + enablementRevision: self.settings.providerEnablementRevision(for: provider)) + } + + func providerPublicationRevisionIsCurrent( + _ revision: ProviderPublicationRevision, + for provider: UsageProvider) -> Bool + { + self.providerCleanupRevisionIsCurrent(revision.cleanupRevision, for: provider) && + revision.enablementRevision == self.settings.providerEnablementRevision(for: provider) } func clearDisabledProviderState(enabledProviders: Set) { for provider in UsageProvider.allCases where !enabledProviders.contains(provider) { - self.clearProviderState(provider) + if self.currentProviderRefreshAllowsDisabledPublication(provider) { + self.clearProviderRuntimeState(provider) + } else { + self.clearProviderState(provider) + } } } diff --git a/Sources/CodexBar/UsageStore+ClaudeOAuthHistoryTypes.swift b/Sources/CodexBar/UsageStore+ClaudeOAuthHistoryTypes.swift new file mode 100644 index 0000000000..8fa97ee80c --- /dev/null +++ b/Sources/CodexBar/UsageStore+ClaudeOAuthHistoryTypes.swift @@ -0,0 +1,23 @@ +import Foundation + +extension UsageStore { + enum ClaudeOAuthActiveAccountObservation: Equatable, Sendable { + case stable(identity: String?) + case changed + } + + struct ClaudeOAuthAccountBindingCandidate: Codable, Equatable { + let identity: String + let observedAt: Date + } + + struct ClaudeOAuthHistoryEvidence { + let owner: String + let persistentRefHash: String? + let keychainCredentialMismatch: Bool + let keychainCredentialAbsent: Bool + let keychainCredentialUnavailable: Bool + let activeAccountObservation: ClaudeOAuthActiveAccountObservation + let observedAt: Date + } +} diff --git a/Sources/CodexBar/UsageStore+CodexResetCredits.swift b/Sources/CodexBar/UsageStore+CodexResetCredits.swift new file mode 100644 index 0000000000..a5d84af753 --- /dev/null +++ b/Sources/CodexBar/UsageStore+CodexResetCredits.swift @@ -0,0 +1,134 @@ +import CodexBarCore +import Foundation + +extension UsageStore { + typealias CodexResetCreditsFetcher = @Sendable ([String: String]) async throws + -> CodexRateLimitResetCreditsSnapshot? + + func codexResetCreditsFetcher() -> CodexResetCreditsFetcher { + if let override = self._test_codexResetCreditsFetcherOverride { + return override + } + return { env in + try await Self.fetchCodexResetCredits(env: env) + } + } + + func handleCodexResetCreditNotifications(snapshot: UsageSnapshot) { + guard self.settings.showOptionalCreditsAndExtraUsage, + let resetCredits = snapshot.codexResetCredits + else { + return + } + CodexResetCreditExpiryNotifier().postExpiringCreditsIfNeeded( + snapshot: resetCredits, + resetStyle: self.settings.resetTimeDisplayStyle) + } + + nonisolated static func attachingCodexResetCreditsIfNeeded( + to outcome: ProviderFetchOutcome, + env: [String: String], + fetcher: @escaping CodexResetCreditsFetcher) async -> ProviderFetchOutcome + { + guard case let .success(result) = outcome.result else { return outcome } + let requiresResetCreditRescue = Self.requiresResetCreditRescue(result) + if result.usage.codexResetCredits != nil { + return outcome + } + + do { + try Task.checkCancellation() + let resetCredits = try await fetcher(env) + try Task.checkCancellation() + if requiresResetCreditRescue, + (resetCredits?.availableInventory(at: result.usage.updatedAt).count ?? 0) == 0 + { + return outcome.replacingResult(with: .failure(UsageError.noRateLimitsFound)) + } + return outcome.replacingUsage(result.usage.withCodexResetCredits(resetCredits)) + } catch { + if error is CancellationError || Task.isCancelled { + return ProviderFetchOutcome(result: .failure(CancellationError()), attempts: outcome.attempts) + } + if requiresResetCreditRescue { + return outcome.replacingResult(with: .failure(UsageError.noRateLimitsFound)) + } + // A successful usage refresh must not retain reset-credit inventory from an older snapshot. + return outcome.replacingUsage(result.usage.withCodexResetCredits(nil)) + } + } + + private nonisolated static func requiresResetCreditRescue(_ result: ProviderFetchResult) -> Bool { + result.strategyID == "codex.oauth" + && result.credits == nil + && result.usage.primary == nil + && result.usage.secondary == nil + && result.usage.tertiary == nil + && (result.usage.extraRateWindows?.isEmpty ?? true) + } + + nonisolated static func fetchCodexResetCredits( + env: [String: String]) async throws -> CodexRateLimitResetCreditsSnapshot? + { + try Task.checkCancellation() + let credentials = try CodexOAuthCredentialsStore.loadOAuthTokens(env: env) + return try await Self.fetchCodexResetCredits( + credentials: credentials, + env: env, + request: { accessToken, accountId, requestEnvironment in + try await CodexOAuthUsageFetcher.fetchRateLimitResetCredits( + accessToken: accessToken, + accountId: accountId, + env: requestEnvironment) + }) + } + + private nonisolated static func fetchCodexResetCredits( + credentials: CodexOAuthCredentials, + env: [String: String], + request: @escaping @Sendable (String, String?, [String: String]) async throws + -> CodexRateLimitResetCreditsSnapshot?) async throws -> CodexRateLimitResetCreditsSnapshot? + { + try Task.checkCancellation() + // Supplemental inventory is strictly read-only. The main OAuth usage strategy owns token refreshes; + // CLI/web winners with stale credentials simply skip this best-effort GET. + guard !credentials.needsRefresh else { return nil } + return try await request(credentials.accessToken, credentials.accountId, env) + } + + nonisolated static func _fetchCodexResetCreditsForTesting( + credentials: CodexOAuthCredentials, + env: [String: String] = [:], + request: @escaping @Sendable (String, String?, [String: String]) async throws + -> CodexRateLimitResetCreditsSnapshot?) async throws -> CodexRateLimitResetCreditsSnapshot? + { + try await self.fetchCodexResetCredits(credentials: credentials, env: env, request: request) + } +} + +extension ProviderFetchOutcome { + func replacingUsage(_ usage: UsageSnapshot) -> ProviderFetchOutcome { + guard case let .success(result) = self.result else { return self } + return ProviderFetchOutcome( + result: .success(ProviderFetchResult( + usage: usage, + credits: result.credits, + dashboard: result.dashboard, + sourceLabel: result.sourceLabel, + strategyID: result.strategyID, + strategyKind: result.strategyKind, + diagnostic: result.diagnostic, + claudeOAuthKeychainPersistentRefHash: result.claudeOAuthKeychainPersistentRefHash, + claudeOAuthHistoryOwnerIdentifier: result.claudeOAuthHistoryOwnerIdentifier, + claudeOAuthKeychainCredentialMismatch: result.claudeOAuthKeychainCredentialMismatch, + claudeOAuthKeychainCredentialAbsent: result.claudeOAuthKeychainCredentialAbsent, + claudeOAuthKeychainCredentialUnavailable: result.claudeOAuthKeychainCredentialUnavailable)), + attempts: self.attempts) + } + + fileprivate func replacingResult( + with result: Result) -> ProviderFetchOutcome + { + ProviderFetchOutcome(result: result, attempts: self.attempts) + } +} diff --git a/Sources/CodexBar/UsageStore+GeminiMigration.swift b/Sources/CodexBar/UsageStore+GeminiMigration.swift new file mode 100644 index 0000000000..fce0a8ed46 --- /dev/null +++ b/Sources/CodexBar/UsageStore+GeminiMigration.swift @@ -0,0 +1,16 @@ +import CodexBarCore + +extension UsageStore { + static func isGeminiConsumerTierDeprecationError(_ error: Error?) -> Bool { + (error as? GeminiStatusProbeError) == .consumerTierDeprecated + } + + func observeGeminiConsumerTierDeprecation(from error: Error) { + guard Self.isGeminiConsumerTierDeprecationError(error) else { return } + self.geminiObservedConsumerTierDeprecation = true + } + + func clearGeminiConsumerTierDeprecationObservation() { + self.geminiObservedConsumerTierDeprecation = false + } +} diff --git a/Sources/CodexBar/UsageStore+HighestUsage.swift b/Sources/CodexBar/UsageStore+HighestUsage.swift index 9a2d730e33..f681256c4a 100644 --- a/Sources/CodexBar/UsageStore+HighestUsage.swift +++ b/Sources/CodexBar/UsageStore+HighestUsage.swift @@ -3,18 +3,30 @@ import Foundation @MainActor extension UsageStore { - /// Returns the enabled provider with the highest usage percentage (closest to rate limit). + /// Returns the enabled candidate provider with the highest usage percentage (closest to rate limit). /// Excludes providers that are fully rate-limited. - func providerWithHighestUsage() -> (provider: UsageProvider, usedPercent: Double)? { + func providerWithHighestUsage(candidateProviders: [UsageProvider]? = nil, now: Date = Date()) + -> (provider: UsageProvider, usedPercent: Double)? + { + let candidateSet = candidateProviders.map(Set.init) var highest: (provider: UsageProvider, usedPercent: Double)? - for provider in self.enabledProviders() { + for provider in self.enabledProviders() + where candidateSet?.contains(provider) ?? true + { guard let snapshot = self.snapshots[provider] else { continue } - let window = self.menuBarMetricWindowForHighestUsage(provider: provider, snapshot: snapshot) - let percent = window?.usedPercent ?? 0 + guard let window = self.menuBarMetricWindowForHighestUsage( + provider: provider, + snapshot: snapshot, + now: now) + else { + continue + } + let percent = window.usedPercent guard !self.shouldExcludeFromHighestUsage( provider: provider, snapshot: snapshot, - metricPercent: percent) + metricPercent: percent, + now: now) else { continue } @@ -25,22 +37,72 @@ extension UsageStore { return highest } - private func menuBarMetricWindowForHighestUsage(provider: UsageProvider, snapshot: UsageSnapshot) -> RateWindow? { - MenuBarMetricWindowResolver.rateWindow( - preference: self.settings.menuBarMetricPreference(for: provider, snapshot: snapshot), + private func menuBarMetricWindowForHighestUsage( + provider: UsageProvider, + snapshot: UsageSnapshot, + now: Date) -> RateWindow? + { + let effectivePreference = self.settings.menuBarMetricPreference(for: provider, snapshot: snapshot) + if provider == .antigravity, + effectivePreference == .automatic, + !self.settings.antigravityPrioritizeExhaustedQuotas + { + return Self.mostConstrainedAntigravityQuotaSummaryWindow(snapshot: snapshot) + } + if provider == .codex { + return self.codexMenuBarMetricWindow(snapshot: snapshot, now: now) + } + return MenuBarMetricWindowResolver.rateWindow( + preference: effectivePreference, provider: provider, snapshot: snapshot, - supportsAverage: self.settings.menuBarMetricSupportsAverage(for: provider)) + supportsAverage: self.settings.menuBarMetricSupportsAverage(for: provider), + antigravityPrioritizeExhaustedQuotas: self.settings.antigravityPrioritizeExhaustedQuotas, + now: now) } private func shouldExcludeFromHighestUsage( provider: UsageProvider, snapshot: UsageSnapshot, - metricPercent: Double) + metricPercent: Double, + now: Date) -> Bool { let effectivePreference = self.settings.menuBarMetricPreference(for: provider, snapshot: snapshot) guard metricPercent >= 100 else { return false } + if provider == .codex || provider == .claude, effectivePreference == .primaryAndSecondary { + if provider == .codex, + self.codexConsumerProjection( + surface: .menuBar, + snapshotOverride: snapshot, + now: now).hasBindingWeeklyCap + { + return true + } + // A Claude spend-limit-only snapshot has no real session/weekly lanes; the metric resolves to + // the spend-limit window, so reaching here (metricPercent >= 100) means the spend limit itself + // is exhausted. Mirror that resolver fallback and exclude, instead of inspecting the raw 0% + // placeholder primary that would otherwise keep it eligible. + if provider == .claude, MenuBarMetricWindowResolver.claudeSpendLimitWindow(snapshot: snapshot) != nil { + return true + } + // Ignore synthesized placeholder lanes (e.g. Claude web's null `five_hour` 0% session) so a + // fully exhausted weekly-only account is excluded rather than kept eligible by a phantom 0%. + let percents = [snapshot.primary, snapshot.secondary] + .compactMap(\.self) + .filter { !$0.isSyntheticPlaceholder } + .map(\.usedPercent) + guard !percents.isEmpty else { return true } + return percents.allSatisfy { $0 >= 100 } + } + if provider == .antigravity, effectivePreference == .automatic { + if self.settings.antigravityPrioritizeExhaustedQuotas { + return MenuBarMetricWindowResolver.antigravityQuotaSummaryFamiliesAreAllBlocked(snapshot: snapshot) + } + let windows = Self.antigravityRenderedQuotaSummaryWindows(snapshot: snapshot) + guard !windows.isEmpty else { return true } + return windows.allSatisfy { $0.usedPercent >= 100 } + } if provider == .copilot, effectivePreference == .automatic, let primary = snapshot.primary, @@ -60,6 +122,40 @@ extension UsageStore { guard !percents.isEmpty else { return true } return percents.allSatisfy { $0 >= 100 } } + if effectivePreference == .automatic, + MenuBarMetricWindowResolver.automaticSelectionPrioritizesExhaustedWindow(for: provider) + { + let percents = [ + snapshot.primary?.usedPercent, + snapshot.secondary?.usedPercent, + snapshot.tertiary?.usedPercent, + ].compactMap(\.self) + guard !percents.isEmpty else { return true } + return percents.allSatisfy { $0 >= 100 } + } + return true } + + private nonisolated static func mostConstrainedAntigravityQuotaSummaryWindow( + snapshot: UsageSnapshot) + -> RateWindow? + { + let windows = self.antigravityRenderedQuotaSummaryWindows(snapshot: snapshot) + guard !windows.isEmpty else { return nil } + + let usableWindows = windows.filter { $0.usedPercent < 100 } + if let maxUsable = usableWindows.max(by: { $0.usedPercent < $1.usedPercent }) { + return maxUsable + } + return windows.max(by: { $0.usedPercent < $1.usedPercent }) + } + + private nonisolated static func antigravityRenderedQuotaSummaryWindows( + snapshot: UsageSnapshot) + -> [RateWindow] + { + let windows = IconRemainingResolver.resolvedWindows(snapshot: snapshot, style: .antigravity) + return [windows.primary, windows.secondary].compactMap(\.self) + } } diff --git a/Sources/CodexBar/UsageStore+HistoricalPace.swift b/Sources/CodexBar/UsageStore+HistoricalPace.swift index c8443008be..ecdb5e8637 100644 --- a/Sources/CodexBar/UsageStore+HistoricalPace.swift +++ b/Sources/CodexBar/UsageStore+HistoricalPace.swift @@ -9,10 +9,11 @@ extension UsageStore { func weeklyPace(provider: UsageProvider, window: RateWindow, now: Date = .init()) -> UsagePace? { guard window.remainingPercent > 0 else { return nil } let resolved: UsagePace? + let workDays = self.settings.weeklyProgressWorkDays // Codex can refine pace with historical samples because its dashboard exposes enough weekly history to build // an account-scoped usage curve. Other providers should not need a hard-coded allowlist: if their RateWindow // includes a reset time and window duration, the generic linear pace calculation is already defensible. - if provider == .codex, self.settings.historicalTrackingEnabled { + if provider == .codex, self.settings.historicalTrackingEnabled, workDays == nil { let codexAccountKey = self.codexOwnershipContext().canonicalKey if self.codexHistoricalDatasetAccountKey == codexAccountKey, let historical = CodexHistoricalPaceEvaluator.evaluate( @@ -22,14 +23,18 @@ extension UsageStore { { resolved = historical } else { - resolved = UsagePace.weekly(window: window, now: now, defaultWindowMinutes: 10080) + resolved = UsagePace.weekly(window: window, now: now, defaultWindowMinutes: 10080, workDays: workDays) } + } else if provider == .codex, self.settings.historicalTrackingEnabled { + // An explicit work-day schedule is the user's declared plan and takes precedence over learned history. + // Keep collecting history in the background so Automatic can resume historical pacing immediately. + resolved = UsagePace.weekly(window: window, now: now, defaultWindowMinutes: 10080, workDays: workDays) } else { // Generic providers must carry an explicit window duration. Using the 10080-minute fallback for // windows without windowMinutes would fabricate a weekly pace for non-weekly windows // (e.g. Factory monthly with only resetsAt). guard window.windowMinutes != nil else { return nil } - resolved = UsagePace.weekly(window: window, now: now, defaultWindowMinutes: 10080) + resolved = UsagePace.weekly(window: window, now: now, defaultWindowMinutes: 10080, workDays: workDays) } guard let resolved else { return nil } diff --git a/Sources/CodexBar/UsageStore+Hooks.swift b/Sources/CodexBar/UsageStore+Hooks.swift new file mode 100644 index 0000000000..c9058ee29b --- /dev/null +++ b/Sources/CodexBar/UsageStore+Hooks.swift @@ -0,0 +1,260 @@ +import CodexBarCore +import Foundation + +@MainActor +extension UsageStore { + /// Builds a `HookEvent` and dispatches it to any matching external hooks. + /// + /// Fire-and-forget: the actual process runs on a detached task so nothing + /// blocks the menu-bar refresh. No-op unless the user enabled hooks. + /// `usagePercent` is a 0...1 fraction. + func emitHook( + _ type: HookEventType, + provider: UsageProvider, + window: String? = nil, + usagePercent: Double? = nil, + resetAt: Date? = nil, + status: String? = nil, + accountDisplayName: String? = nil) + { + guard let hooks = self.settings.config.hooks, + hooks.enabled, + hooks.events.count <= HooksConfig.maximumRuleCount + else { return } + + let event = HookEvent( + event: type, + provider: provider.rawValue, + account: self.settings.hidePersonalInfo ? nil : accountDisplayName, + window: window, + usagePercent: usagePercent, + resetAt: resetAt, + status: status, + timestamp: Date()) + + let limiter = self.hookRateLimiter + let environment = self.environmentBase + Task.detached(priority: .utility) { + await HookRunner.dispatch( + event: event, + config: hooks, + rateLimiter: limiter, + baseEnvironment: environment) + } + } + + func emitQuotaReachedHook( + provider: UsageProvider, + sessionWindow: (window: RateWindow, source: SessionQuotaWindowSource), + snapshot: UsageSnapshot) + { + self.emitHook( + .quotaReached, + provider: provider, + window: QuotaWarningWindow.session.displayName, + usagePercent: sessionWindow.window.usedPercent / 100, + resetAt: sessionWindow.window.resetsAt, + accountDisplayName: self.hookAccountDisplayName(provider: provider, snapshot: snapshot)) + } + + /// Emits `quota_reset` when a session/weekly limit reset is detected. The + /// account label is redacted when the user hides personal info. + func emitQuotaResetHook( + provider: UsageProvider, + window: QuotaWarningWindow, + usedPercent: Double, + accountLabel: String?) + { + self.emitHook( + .quotaReset, + provider: provider, + window: window.displayName, + usagePercent: usedPercent / 100, + accountDisplayName: self.settings.hidePersonalInfo ? nil : accountLabel) + } + + /// Emits `provider_unavailable` / `provider_recovered` on genuine outage + /// transitions. `.unknown` (transient/first fetch) and `.maintenance` never + /// flip the tracked state, so a hiccuped status probe cannot fire a hook. + func emitProviderStatusHooks(provider: UsageProvider, indicator: ProviderStatusIndicator) { + let isOutage: Bool + switch indicator { + case .minor, .major, .critical: + isOutage = true + case .none: + isOutage = false + case .maintenance, .unknown: + return + } + + let wasOutage = self.providerStatusHadIssue[provider] ?? false + if isOutage, !wasOutage { + self.providerStatusHadIssue[provider] = true + self.emitHook(.providerUnavailable, provider: provider, status: indicator.rawValue) + } else if !isOutage, wasOutage { + self.providerStatusHadIssue[provider] = false + self.emitHook(.providerRecovered, provider: provider, status: indicator.rawValue) + } + } + + /// Identifies a quota lane for quota_low hook crossing detection. + struct QuotaLowHookLane { + let window: QuotaWarningWindow + let windowID: String? + let label: String + } + + /// Fires `quota_low` hooks driven by each rule's own usage threshold, crossed + /// upward, independent of the notification thresholds and preferences. A rule + /// with no threshold falls back to the provider's notification thresholds so a + /// "notify me when quota is low" hook still fires at the app's warning points. + /// + /// Crossing history is keyed by the same account-scoped `QuotaWarningStateKey` + /// as the notification path (including `accountDiscriminator`), so accounts that + /// share a provider track their crossings independently. + func dispatchQuotaLowHooks( + provider: UsageProvider, + lane: QuotaLowHookLane, + rateWindow: RateWindow?, + accountDiscriminator: String?, + accountDisplayName: String?) + { + guard let hooks = self.settings.config.hooks, + hooks.enabled, + hooks.events.count <= HooksConfig.maximumRuleCount + else { return } + let rules = hooks.events.filter { rule in + rule.enabled + && rule.event == .quotaLow + && (rule.provider == nil || rule.provider == provider.rawValue) + } + guard !rules.isEmpty else { return } + + let key = QuotaWarningStateKey( + provider: provider, + window: lane.window, + accountDiscriminator: accountDiscriminator, + windowID: lane.windowID) + guard let rateWindow else { + self.quotaLowHookUsage.removeValue(forKey: key) + return + } + let current = rateWindow.usedPercent / 100 + let previous = self.quotaLowHookUsage[key] + self.quotaLowHookUsage[key] = current + // No crossing can be established from the first sample; avoid firing on a + // fresh launch when usage is already high. + guard let previous else { return } + + let fallbackThresholds = self.settings + .resolvedQuotaWarningThresholds(provider: provider, window: lane.window) + .map { (100.0 - Double($0)) / 100.0 } + let crossed = QuotaLowHookThreshold.crossedRules( + rules, + previousUsage: previous, + currentUsage: current, + fallbackThresholds: fallbackThresholds) + guard !crossed.isEmpty else { return } + + let event = HookEvent( + event: .quotaLow, + provider: provider.rawValue, + account: self.settings.hidePersonalInfo ? nil : accountDisplayName, + window: lane.label, + usagePercent: current, + resetAt: rateWindow.resetsAt, + timestamp: Date()) + let config = HooksConfig(enabled: true, events: crossed) + let limiter = self.hookRateLimiter + let environment = self.environmentBase + Task.detached(priority: .utility) { + await HookRunner.dispatch( + event: event, + config: config, + rateLimiter: limiter, + baseEnvironment: environment) + } + } + + /// Drops baselines while no quota-low rule is active. A later re-enable must + /// establish a fresh sample instead of firing for a crossing that happened + /// while command execution was disabled. + func clearQuotaLowHookUsage(provider: UsageProvider) { + self.quotaLowHookUsage = self.quotaLowHookUsage.filter { $0.key.provider != provider } + } + + /// Any persisted config edit can include a hook disable/re-enable or rule + /// replacement. Reset crossing baselines on the next sample so transitions + /// that occurred while the prior configuration was inactive never execute. + func resetQuotaLowHookUsageIfConfigurationChanged() { + let revision = self.settings.configRevision + guard self.quotaLowHookConfigRevision != revision else { return } + self.quotaLowHookUsage.removeAll() + self.quotaLowHookConfigRevision = revision + } + + /// Extra quota lanes can disappear between snapshots. Forget their baselines + /// so a later reappearance starts fresh rather than reporting a stale crossing. + func pruneQuotaLowHookUsage( + provider: UsageProvider, + accountDiscriminator: String?, + keepingExtraWindowIDs: Set) + { + self.quotaLowHookUsage = self.quotaLowHookUsage.filter { key, _ in + guard key.provider == provider, + key.accountDiscriminator == accountDiscriminator, + let windowID = key.windowID + else { return true } + return keepingExtraWindowIDs.contains(windowID) + } + } + + /// True when the user has an enabled hook rule for this event and provider. + /// + /// Used to run quota transition detection even when the matching notification + /// preference is off, so hooks fire independently of notifications. Returns + /// false for everyone who has not configured such a rule, so notification + /// behavior is unchanged for them. + func hasQuotaHookRule(event: HookEventType, provider: UsageProvider) -> Bool { + guard let hooks = self.settings.config.hooks, hooks.enabled else { return false } + return hooks.events.contains { rule in + rule.enabled + && rule.event == event + && (rule.provider == nil || rule.provider == provider.rawValue) + } + } + + /// Coarse, non-secret category for a refresh failure. Never forwards the raw + /// error description, which can include provider response-body previews. + nonisolated static func refreshFailureHookStatus(_ error: Error) -> String { + if error is CancellationError { return "cancelled" } + if isPermissionPromptWaiting(error) { return "auth_required" } + let nsError = error as NSError + if nsError.domain == NSURLErrorDomain { + switch nsError.code { + case NSURLErrorCancelled: + return "cancelled" + case NSURLErrorTimedOut: + return "timeout" + case NSURLErrorNotConnectedToInternet, + NSURLErrorNetworkConnectionLost, + NSURLErrorCannotConnectToHost, + NSURLErrorCannotFindHost, + NSURLErrorDNSLookupFailed: + return "offline" + default: + return "network_error" + } + } + return "error" + } + + /// Account label for a hook payload, redacted when the user hides personal info. + func hookAccountDisplayName(provider: UsageProvider, snapshot: UsageSnapshot) -> String? { + guard !self.settings.hidePersonalInfo else { return nil } + let account = snapshot.accountEmail(for: provider)? + .trimmingCharacters(in: .whitespacesAndNewlines) + guard let account, !account.isEmpty else { return nil } + return account + } +} diff --git a/Sources/CodexBar/UsageStore+LimitResetBoundary.swift b/Sources/CodexBar/UsageStore+LimitResetBoundary.swift new file mode 100644 index 0000000000..9d17f3d4fd --- /dev/null +++ b/Sources/CodexBar/UsageStore+LimitResetBoundary.swift @@ -0,0 +1,13 @@ +import Foundation + +extension UsageStore { + nonisolated static func limitResetBoundaryAdvanced( + previous: Date?, + current: Date?, + requiresPreviousBoundary: Bool = false) -> Bool + { + guard let previous else { return !requiresPreviousBoundary } + guard let current else { return false } + return !self.areEquivalentPlanUtilizationResetBoundaries(previous, current) && current > previous + } +} diff --git a/Sources/CodexBar/UsageStore+LimitResetCelebration.swift b/Sources/CodexBar/UsageStore+LimitResetCelebration.swift new file mode 100644 index 0000000000..02c2ae4d3a --- /dev/null +++ b/Sources/CodexBar/UsageStore+LimitResetCelebration.swift @@ -0,0 +1,238 @@ +import CodexBarCore +import Foundation + +extension UsageStore { + private nonisolated static let limitResetThreshold = 1.0 + private nonisolated static let claudeWeeklyRecoveryObservationCount = 2 + + struct LimitResetDetectorState: Codable, Equatable { + let wasAboveThreshold: Bool + let lastObservedAt: Date + let sourceRawValue: String? + var resetBoundary: Date? + var recoveryAboveThresholdCount: Int? + /// Identity-less Claude CLI samples share one detector key and can be transient. + /// Require a second low sample before celebrating an apparent reset from that key. + var pendingLowConfirmation: Bool + + init( + wasAboveThreshold: Bool, + lastObservedAt: Date, + sourceRawValue: String?, + resetBoundary: Date? = nil, + recoveryAboveThresholdCount: Int? = nil, + pendingLowConfirmation: Bool = false) + { + self.wasAboveThreshold = wasAboveThreshold + self.lastObservedAt = lastObservedAt + self.sourceRawValue = sourceRawValue + self.resetBoundary = resetBoundary + self.recoveryAboveThresholdCount = recoveryAboveThresholdCount + self.pendingLowConfirmation = pendingLowConfirmation + } + + private enum CodingKeys: String, CodingKey { + case wasAboveThreshold + case lastObservedAt + case sourceRawValue + case resetBoundary + case recoveryAboveThresholdCount + case pendingLowConfirmation + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.wasAboveThreshold = try container.decode(Bool.self, forKey: .wasAboveThreshold) + self.lastObservedAt = try container.decode(Date.self, forKey: .lastObservedAt) + self.sourceRawValue = try container.decodeIfPresent(String.self, forKey: .sourceRawValue) + self.resetBoundary = try container.decodeIfPresent(Date.self, forKey: .resetBoundary) + self.recoveryAboveThresholdCount = try container.decodeIfPresent( + Int.self, + forKey: .recoveryAboveThresholdCount) + self.pendingLowConfirmation = try container.decodeIfPresent( + Bool.self, + forKey: .pendingLowConfirmation) ?? false + } + } + + struct LimitResetDetectionContext { + let provider: UsageProvider + let account: ProviderTokenAccount? + let snapshot: UsageSnapshot + let accountKey: String? + let capturedAt: Date + let codexLimitResetOwnerKey: CodexLimitResetOwnerKey? + } + + struct LimitResetObservation { + let usedPercent: Double + let observedAt: Date + let resetBoundary: Date? + let source: SessionQuotaWindowSource? + } + + struct LimitResetDetectionDescriptor { + let seriesName: PlanUtilizationSeriesName + let defaultsKey: String + let resetKind: String + } + + func postLimitResetCelebrationIfNeeded( + states: inout [String: LimitResetDetectorState], + context: LimitResetDetectionContext, + descriptor: LimitResetDetectionDescriptor, + observation: LimitResetObservation?) + { + guard let observation else { return } + + guard let accountIdentifier = self.limitResetAccountIdentifier( + provider: context.provider, + account: context.account, + snapshot: context.snapshot, + accountKey: context.accountKey, + codexLimitResetOwnerKey: context.codexLimitResetOwnerKey) + else { + return + } + let detectorKey = Self.limitResetDetectorStateKey( + provider: context.provider, + accountIdentifier: accountIdentifier) + let requiresLowConfirmation = context.provider == .claude + && accountIdentifier == context.provider.rawValue + let currentUsed = observation.usedPercent + let currentObservedAt = observation.observedAt + let wasAboveThreshold = currentUsed > Self.limitResetThreshold + if let existingState = states[detectorKey], + currentObservedAt <= existingState.lastObservedAt + { + return + } + + let previousState = states[detectorKey] + let isClaudeWeekly = context.provider == .claude && descriptor.seriesName == .weekly + let claudeWeeklyRecoveryPending = isClaudeWeekly + && previousState?.recoveryAboveThresholdCount != nil + let sourceRawValue = observation.source?.rawValue + let sourceChanged = descriptor.seriesName == .session && previousState?.sourceRawValue != nil + && previousState?.sourceRawValue != sourceRawValue + let resetBoundaryAllowsPost = if descriptor.seriesName == .session { + Self.limitResetBoundaryAdvanced( + previous: previousState?.resetBoundary, + current: observation.resetBoundary) + } else if context.provider == .codex, descriptor.seriesName == .weekly { + Self.limitResetBoundaryAdvanced( + previous: previousState?.resetBoundary, + current: observation.resetBoundary, + requiresPreviousBoundary: true) + } else { + true + } + let crossedBelowThreshold = !sourceChanged && previousState?.wasAboveThreshold == true && !wasAboveThreshold + let confirmingLowSample = !sourceChanged && previousState?.pendingLowConfirmation == true && !wasAboveThreshold + let shouldPost = if requiresLowConfirmation { + confirmingLowSample && !claudeWeeklyRecoveryPending + } else { + crossedBelowThreshold && resetBoundaryAllowsPost && !claudeWeeklyRecoveryPending + } + let suppressedGuardedCrossing = crossedBelowThreshold && !resetBoundaryAllowsPost + let shouldAwaitLowConfirmation = requiresLowConfirmation + && crossedBelowThreshold + && !confirmingLowSample + && resetBoundaryAllowsPost + && !claudeWeeklyRecoveryPending + // Sessions retain the last non-regressed boundary on every guarded sample. Codex weekly crossings + // adopt a newly appearing boundary so a later genuine advance can still trigger once. + let shouldPreserveBoundary = !sourceChanged && !resetBoundaryAllowsPost + && (descriptor.seriesName == .session || previousState?.resetBoundary != nil) + let shouldPreserveBaseline = suppressedGuardedCrossing + let previousRecoveryCount = previousState?.recoveryAboveThresholdCount ?? 0 + let nextRecoveryCount = if claudeWeeklyRecoveryPending { + wasAboveThreshold ? previousRecoveryCount + 1 : 0 + } else { + 0 + } + let claudeWeeklyRecoveryConfirmed = claudeWeeklyRecoveryPending + && nextRecoveryCount >= Self.claudeWeeklyRecoveryObservationCount + let nextWasAboveThreshold = if claudeWeeklyRecoveryPending { + claudeWeeklyRecoveryConfirmed + } else if shouldPreserveBaseline || shouldAwaitLowConfirmation { + true + } else { + wasAboveThreshold + } + let persistedRecoveryCount: Int? = if shouldPost { + 0 + } else if claudeWeeklyRecoveryPending, !claudeWeeklyRecoveryConfirmed { + nextRecoveryCount + } else { + nil + } + states[detectorKey] = LimitResetDetectorState( + // A transient zero must not erase the baseline needed to recognize the real reset that follows. + wasAboveThreshold: nextWasAboveThreshold, + lastObservedAt: currentObservedAt, + sourceRawValue: sourceRawValue, + resetBoundary: shouldPreserveBoundary ? previousState?.resetBoundary : observation.resetBoundary, + recoveryAboveThresholdCount: persistedRecoveryCount, + pendingLowConfirmation: shouldAwaitLowConfirmation) + self.persistLimitResetDetectorStates( + states, + defaultsKey: descriptor.defaultsKey, + logName: descriptor.resetKind) + + if claudeWeeklyRecoveryPending, wasAboveThreshold { + CodexBarLog.logger(LogCategories.confetti).debug( + "Confirming Claude weekly usage recovery after celebration", + metadata: [ + "accountIdentifier": accountIdentifier, + "confirmationCount": String(nextRecoveryCount), + "observedAt": String(format: "%.0f", currentObservedAt.timeIntervalSince1970), + ]) + } + + guard shouldPost else { return } + let accountLabel = self.limitResetAccountLabel( + provider: context.provider, + account: context.account, + snapshot: context.snapshot) + + CodexBarLog.logger(LogCategories.confetti).info( + "\(descriptor.resetKind.capitalized) limit reset", + metadata: [ + "provider": context.provider.rawValue, + "accountIdentifier": accountIdentifier, + "accountLabel": accountLabel ?? "", + "resetKind": descriptor.resetKind, + "usedPercent": String(format: "%.2f", currentUsed), + "observedAt": String(format: "%.0f", currentObservedAt.timeIntervalSince1970), + ]) + switch descriptor.seriesName { + case .session: + self.emitQuotaResetHook( + provider: context.provider, + window: .session, + usedPercent: currentUsed, + accountLabel: accountLabel) + let event = SessionLimitResetEvent( + provider: context.provider, + accountIdentifier: accountIdentifier, + accountLabel: accountLabel, + usedPercent: currentUsed) + NotificationCenter.default.post(name: .codexbarSessionLimitReset, object: event) + case .weekly: + self.emitQuotaResetHook( + provider: context.provider, + window: .weekly, + usedPercent: currentUsed, + accountLabel: accountLabel) + let event = WeeklyLimitResetEvent( + provider: context.provider, + accountIdentifier: accountIdentifier, + accountLabel: accountLabel, + usedPercent: currentUsed) + NotificationCenter.default.post(name: .codexbarWeeklyLimitReset, object: event) + default: + return + } + } +} diff --git a/Sources/CodexBar/UsageStore+LimitResetIdentity.swift b/Sources/CodexBar/UsageStore+LimitResetIdentity.swift new file mode 100644 index 0000000000..86b1d6e602 --- /dev/null +++ b/Sources/CodexBar/UsageStore+LimitResetIdentity.swift @@ -0,0 +1,32 @@ +import CodexBarCore + +extension UsageStore { + func limitResetAccountIdentifier( + provider: UsageProvider, + account: ProviderTokenAccount?, + snapshot: UsageSnapshot, + accountKey: String?, + codexLimitResetOwnerKey: CodexLimitResetOwnerKey?) -> String? + { + if provider == .codex { + return codexLimitResetOwnerKey?.rawValue + } + let identity = snapshot.identity(for: provider) + return account?.id.uuidString.lowercased() + ?? accountKey + ?? identity?.accountEmail + ?? identity?.accountOrganization + ?? provider.rawValue + } + + func limitResetAccountLabel( + provider: UsageProvider, + account: ProviderTokenAccount?, + snapshot: UsageSnapshot) -> String? + { + let identity = snapshot.identity(for: provider) + return account?.label + ?? identity?.accountEmail + ?? identity?.accountOrganization + } +} diff --git a/Sources/CodexBar/UsageStore+MemoryPressure.swift b/Sources/CodexBar/UsageStore+MemoryPressure.swift new file mode 100644 index 0000000000..c21829e28b --- /dev/null +++ b/Sources/CodexBar/UsageStore+MemoryPressure.swift @@ -0,0 +1,39 @@ +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 + } + } + } + + func trimRebuildableCachesForMemoryPressure() -> MemoryPressureCacheTrimSummary { + let openAIWebDebugLineCount = self.openAIWebDebugLines.count + let summary = MemoryPressureCacheTrimSummary(openAIWebDebugLines: openAIWebDebugLineCount) + + self.openAIWebDebugLines.removeAll(keepingCapacity: false) + self.openAIDashboardCookieImportDebugLog = nil + + return summary + } + + #if DEBUG + func seedRebuildableCachesForMemoryPressureProof() { + self.openAIWebDebugLines = [ + "debug memory pressure line 1", + "debug memory pressure line 2", + ] + self.openAIDashboardCookieImportDebugLog = self.openAIWebDebugLines.joined(separator: "\n") + } + #endif +} diff --git a/Sources/CodexBar/UsageStore+OpenAIWeb.swift b/Sources/CodexBar/UsageStore+OpenAIWeb.swift index 3b75b446ce..45a4673e4c 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,8 +52,21 @@ extension UsageStore { afterCookieImport ? self.openAIWebPostImportFetchTimeout : self.openAIWebRetryFetchTimeout } - private func openAIWebRefreshIntervalSeconds() -> TimeInterval { - let base = max(self.settings.refreshFrequency.seconds ?? 0, 120) + nonisolated static func refreshPhase( + hasCompletedInitialRefresh: Bool) -> ProviderRefreshPhase + { + hasCompletedInitialRefresh ? .regular : .startup + } + + nonisolated static func openAIWebRefreshPhase( + providerRefreshPhase: ProviderRefreshPhase, + startupConnectivityRetryAttempt: Int?) -> ProviderRefreshPhase + { + startupConnectivityRetryAttempt == nil ? providerRefreshPhase : .startup + } + + func openAIWebRefreshIntervalSeconds() -> TimeInterval { + let base = max(self.normalRefreshIntervalForHeuristics() ?? 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, @@ -76,7 +108,7 @@ extension UsageStore { "batterySaverEnabled": self.settings.openAIWebBatterySaverEnabled ? "1" : "0", "interaction": ProviderInteractionContext.current == .userInitiated ? "user" : "background", ]) - let expectedGuard = self.currentCodexOpenAIWebRefreshGuard() + let expectedGuard = self.freshCodexOpenAIWebRefreshGuard() Task { await self.refreshOpenAIDashboardIfNeeded(force: forceRefresh, expectedGuard: expectedGuard) } } @@ -88,19 +120,29 @@ extension UsageStore { allowCodexUsageBackfill: Bool = true) async { guard self.shouldApplyOpenAIDashboardRefreshTask(token: refreshTaskToken) else { return } - if let expectedGuard, - !self.shouldApplyOpenAIDashboardRefreshGuard( - expectedGuard: expectedGuard, - routingTargetEmail: targetEmail) - { - return - } - + self.settings.invalidateCodexAccountReconciliationSnapshotCache() let authority = self.evaluateCodexDashboardAuthority( dashboard: dash, sourceKind: .liveWeb, routingTargetEmail: targetEmail) + if let expectedGuard { + let shouldApply = switch authority.decision.disposition { + case .attach: + self.shouldApplyOpenAIDashboardRefreshGuard( + expectedGuard: expectedGuard, + routingTargetEmail: targetEmail) + case .displayOnly, .failClosed: + self.shouldApplyOpenAIDashboardPolicyResult( + expectedGuard: expectedGuard, + routingTargetEmail: targetEmail) + } + guard shouldApply else { return } + } + let attachedAccountEmail = self.codexDashboardAttachmentEmail(from: authority.input) + self.reconcileCodexPublishedUsageOwner(with: self.freshCodexAccountScopedRefreshGuard( + preferCurrentSnapshot: true, + allowLastKnownLiveFallback: false)) await self.applyOpenAIDashboardAuthorityDecision( authority.decision, @@ -132,6 +174,10 @@ extension UsageStore { await self.failClosedRefreshForMissingManagedCodexTarget() return } + if self.openAIWebProfileTargetEmailIsMissing() { + await self.failClosedRefreshForMissingProfileCodexTarget() + return + } OpenAIDashboardFetcher.evictAllCachedWebViews() await MainActor.run { @@ -170,6 +216,10 @@ extension UsageStore { await self.failClosedRefreshForMissingManagedCodexTarget() return } + if self.openAIWebProfileTargetEmailIsMissing() { + await self.failClosedRefreshForMissingProfileCodexTarget() + return + } OpenAIDashboardFetcher.evictAllCachedWebViews() await MainActor.run { @@ -208,12 +258,16 @@ extension UsageStore { if decision.allowedEffects.contains(.usageBackfill), allowCodexUsageBackfill, self.snapshots[.codex] == nil, - let usage = dashboard.toUsageSnapshot(provider: .codex, accountEmail: attachedAccountEmail) + let usage = dashboard.toUsageSnapshot(provider: .codex, accountEmail: attachedAccountEmail), + CodexWeeklyResetConfirmation.initialDecision(previous: nil, initial: usage) == .publishInitial { self.snapshots[.codex] = usage self.errors[.codex] = nil self.failureGates[.codex]?.recordSuccess() self.lastSourceLabels[.codex] = "openai-web" + self.lastCodexUsagePublicationGuard = self.currentCodexAccountScopedRefreshGuard( + preferCurrentSnapshot: true, + allowLastKnownLiveFallback: false) } if decision.allowedEffects.contains(.creditsAttachment), @@ -289,15 +343,7 @@ extension UsageStore { private func clearDashboardDerivedCodexUsageIfNeeded() { guard self.lastSourceLabels[.codex] == "openai-web" else { return } - self.snapshots.removeValue(forKey: .codex) - self.errors[.codex] = nil - self.lastSourceLabels.removeValue(forKey: .codex) - self.lastFetchAttempts.removeValue(forKey: .codex) - self.accountSnapshots.removeValue(forKey: .codex) - self.codexAccountSnapshots = [] - self.failureGates[.codex]?.reset() - self.lastKnownSessionRemaining.removeValue(forKey: .codex) - self.lastKnownSessionWindowSource.removeValue(forKey: .codex) + self.clearCodexPublishedUsageState() } private func clearDashboardDerivedCreditsIfNeeded() { @@ -311,9 +357,17 @@ extension UsageStore { } private func clearDashboardRefreshGuardSeedIfNeeded() { - self.lastCodexAccountScopedRefreshGuard = self.currentCodexAccountScopedRefreshGuard( + let currentGuard = self.currentCodexAccountScopedRefreshGuard( preferCurrentSnapshot: false, allowLastKnownLiveFallback: false) + if self.snapshots[.codex] != nil, + self.lastCodexUsagePublicationGuard.map({ + !Self.codexScopedRefreshGuardsMatchAccount($0, currentGuard) + }) ?? true + { + self.clearCodexPublishedUsageState() + } + self.lastCodexAccountScopedRefreshGuard = currentGuard } private func openAIDashboardPolicyFailureMessage( @@ -365,6 +419,10 @@ extension UsageStore { await self.failClosedRefreshForMissingManagedCodexTarget() return } + if self.openAIWebProfileTargetEmailIsMissing() { + await self.failClosedRefreshForMissingProfileCodexTarget() + return + } let allowCurrentSnapshotFallback = expectedGuard?.source == .liveSystem && expectedGuard? .identity == .unresolved @@ -379,7 +437,13 @@ extension UsageStore { await task.value return } - self.handleOpenAIWebTargetEmailChangeIfNeeded(targetEmail: targetEmail) + if bypassCoalescing { + self.openAIDashboardBackgroundRefreshTask?.cancel() + self.openAIDashboardRefreshTask?.cancel() + } + self.handleOpenAIWebTargetEmailChangeIfNeeded( + targetEmail: targetEmail, + targetScope: self.codexCookieCacheScopeForOpenAIWeb()) let now = Date() let minInterval = self.openAIWebRefreshIntervalSeconds() @@ -402,7 +466,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) @@ -449,6 +514,7 @@ extension UsageStore { } } + guard !Task.isCancelled else { return } await self.refreshOpenAIDashboardIfNeeded(force: false, expectedGuard: expectedGuard) guard !Task.isCancelled else { return } self.persistWidgetSnapshot(reason: "dashboard") @@ -509,6 +575,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 +591,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 +641,18 @@ 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: L( + "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 +679,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 +731,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 +795,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( @@ -743,26 +826,29 @@ extension UsageStore { // MARK: - OpenAI web account switching - /// Detect Codex account email changes and clear stale OpenAI web state so the UI can't show the wrong user. - /// This does not delete other per-email WebKit cookie stores (we keep multiple accounts around). - func handleOpenAIWebTargetEmailChangeIfNeeded(targetEmail: String?) { + /// Detect Codex account-source changes and clear stale OpenAI web state so the UI can't show the wrong user. + /// This does not delete other isolated WebKit cookie stores (we keep multiple accounts around). + func handleOpenAIWebTargetEmailChangeIfNeeded( + targetEmail: String?, + targetScope: CookieHeaderCache.Scope? = nil) + { let normalized = targetEmail? .trimmingCharacters(in: .whitespacesAndNewlines) .lowercased() guard let normalized, !normalized.isEmpty else { return } - let previous = self.lastOpenAIDashboardTargetEmail + let isolationKey = Self.openAIWebTargetIsolationKey(email: normalized, scope: targetScope) + let previousIsolationKey = self.lastOpenAIDashboardTargetIsolationKey self.lastOpenAIDashboardTargetEmail = normalized + self.lastOpenAIDashboardTargetIsolationKey = isolationKey - if let previous, - !previous.isEmpty, - previous != normalized + if let previousIsolationKey, + previousIsolationKey != isolationKey { let stamp = Date().formatted(date: .abbreviated, time: .shortened) self.logOpenAIWeb( - "[\(stamp)] Codex account changed: \(previous) → \(normalized); " + - "clearing OpenAI web snapshot") + "[\(stamp)] Codex account source changed; clearing OpenAI web snapshot") self.openAIWebAccountDidChange = true self.openAIDashboard = nil self.openAIDashboardAttachmentAuthorized = false @@ -771,19 +857,26 @@ extension UsageStore { self.lastOpenAIDashboardError = nil self.lastOpenAIDashboardAttemptAt = nil self.openAIDashboardRequiresLogin = true - self.openAIDashboardCookieImportStatus = "Codex account changed; importing browser cookies…" + self.openAIDashboardCookieImportStatus = L("Codex account changed; importing browser cookies…") self.lastOpenAIDashboardCookieImportAttemptAt = nil self.lastOpenAIDashboardCookieImportEmail = nil } } + nonisolated static func openAIWebTargetIsolationKey( + email: String, + scope: CookieHeaderCache.Scope?) -> String + { + "\(email.lowercased())|\(scope?.isolationIdentifier ?? "live")" + } + func importOpenAIDashboardBrowserCookiesNow() async { self.resetOpenAIWebDebugLog(context: "manual import") let targetEmail = self.currentCodexOpenAIWebTargetEmail( allowCurrentSnapshotFallback: true, allowLastKnownLiveFallback: false) _ = await self.importOpenAIDashboardCookiesIfNeeded(targetEmail: targetEmail, force: true) - let expectedGuard = self.currentCodexOpenAIWebRefreshGuard() + let expectedGuard = self.freshCodexOpenAIWebRefreshGuard() await self.refreshOpenAIDashboardIfNeeded( force: true, expectedGuard: expectedGuard, @@ -814,11 +907,15 @@ extension UsageStore { if allowLastKnownLiveFallback { let lastKnown = self.lastKnownLiveSystemCodexEmail?.trimmingCharacters(in: .whitespacesAndNewlines) - if let lastKnown, !lastKnown.isEmpty { return lastKnown } + if let lastKnown, !lastKnown.isEmpty { + return lastKnown + } } return nil case .managedAccount: return self.codexAccountEmailForOpenAIDashboard() + case let .profileHome(path): + return self.currentProfileCodexRuntimeEmail(path: path) } } @@ -829,7 +926,8 @@ extension UsageStore { let source = String(describing: expectedGuard?.source ?? self.settings.codexResolvedActiveSource) let identityKey = Self.codexIdentityGuardKey(expectedGuard?.identity ?? .unresolved) ?? "unresolved" let accountKey = Self.normalizeCodexAccountScopedKey(targetEmail) ?? "unknown" - return "\(source)|\(identityKey)|\(accountKey)" + let authFingerprint = CodexAuthFingerprint.normalize(expectedGuard?.authFingerprint) ?? "nil" + return "\(source)|\(identityKey)|\(accountKey)|auth:\(authFingerprint)" } private func actionableOpenAIDashboardImportFailure(targetEmail: String?) -> String? { @@ -914,6 +1012,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 +1028,18 @@ 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, + cacheScope: self.codexCookieCacheScopeForOpenAIWeb(), logger: logger, debugDumpHTML: timeout != Self.openAIWebPrimaryFetchTimeout, + allowNavigationTimeoutRetry: allowNavigationTimeoutRetry, timeout: timeout) } @@ -943,8 +1047,8 @@ extension UsageStore { self.applyOpenAIDashboardCleanup(Set(CodexDashboardCleanup.allCases), preserveVisibleDashboard: false) self.openAIDashboardRequiresLogin = true self.openAIDashboardCookieImportStatus = [ - "Managed Codex account data is unavailable.", - "Fix the managed account store before importing OpenAI cookies.", + L("Managed Codex account data is unavailable."), + L("Fix the managed account store before importing OpenAI cookies."), ].joined(separator: " ") return nil } @@ -953,8 +1057,8 @@ extension UsageStore { self.applyOpenAIDashboardCleanup(Set(CodexDashboardCleanup.allCases), preserveVisibleDashboard: false) self.openAIDashboardRequiresLogin = true self.lastOpenAIDashboardError = [ - "Managed Codex account data is unavailable.", - "Fix the managed account store before refreshing OpenAI web data.", + L("Managed Codex account data is unavailable."), + L("Fix the managed account store before refreshing OpenAI web data."), ].joined(separator: " ") } @@ -962,8 +1066,8 @@ extension UsageStore { self.applyOpenAIDashboardCleanup(Set(CodexDashboardCleanup.allCases), preserveVisibleDashboard: false) self.openAIDashboardRequiresLogin = true self.openAIDashboardCookieImportStatus = [ - "The selected managed Codex account is unavailable.", - "Pick another Codex account before importing OpenAI cookies.", + L("The selected managed Codex account is unavailable."), + L("Pick another Codex account before importing OpenAI cookies."), ].joined(separator: " ") return nil } @@ -972,8 +1076,27 @@ extension UsageStore { self.applyOpenAIDashboardCleanup(Set(CodexDashboardCleanup.allCases), preserveVisibleDashboard: false) self.openAIDashboardRequiresLogin = true self.lastOpenAIDashboardError = [ - "The selected managed Codex account is unavailable.", - "Pick another Codex account before refreshing OpenAI web data.", + L("The selected managed Codex account is unavailable."), + L("Pick another Codex account before refreshing OpenAI web data."), + ].joined(separator: " ") + } + + private func failClosedForMissingProfileCodexTarget() async -> String? { + self.applyOpenAIDashboardCleanup(Set(CodexDashboardCleanup.allCases), preserveVisibleDashboard: false) + self.openAIDashboardRequiresLogin = true + self.openAIDashboardCookieImportStatus = [ + L("The selected Codex profile has no verified account email."), + L("Refresh the profile before importing OpenAI cookies."), + ].joined(separator: " ") + return nil + } + + private func failClosedRefreshForMissingProfileCodexTarget() async { + self.applyOpenAIDashboardCleanup(Set(CodexDashboardCleanup.allCases), preserveVisibleDashboard: false) + self.openAIDashboardRequiresLogin = true + self.lastOpenAIDashboardError = [ + L("The selected Codex profile has no verified account email."), + L("Refresh the profile before refreshing OpenAI web data."), ].joined(separator: " ") } @@ -986,6 +1109,10 @@ extension UsageStore { _ = await self.failClosedForMissingManagedCodexTarget() return true } + if self.openAIWebProfileTargetEmailIsMissing() { + _ = await self.failClosedForMissingProfileCodexTarget() + return true + } return false } @@ -1136,7 +1263,9 @@ extension UsageStore { } else { found .sorted { lhs, rhs in - if lhs.sourceLabel == rhs.sourceLabel { return lhs.email < rhs.email } + if lhs.sourceLabel == rhs.sourceLabel { + return lhs.email < rhs.email + } return lhs.sourceLabel < rhs.sourceLabel } .map { "\($0.sourceLabel): \($0.email)" } @@ -1156,6 +1285,7 @@ extension UsageStore { } case .noCookiesFound, .browserAccessDenied, + .browserCookieLoadTimedOut, .dashboardStillRequiresLogin, .manualCookieHeaderInvalid: self.logOpenAIWeb("[\(stamp)] import failed: \(err.localizedDescription)") @@ -1202,6 +1332,7 @@ extension UsageStore { self.lastOpenAIDashboardSnapshot = nil self.lastOpenAIDashboardAttachmentAuthorized = false self.lastOpenAIDashboardTargetEmail = nil + self.lastOpenAIDashboardTargetIsolationKey = nil self.lastOpenAIDashboardAttemptAt = nil self.openAIDashboardRequiresLogin = false self.openAIDashboardCookieImportStatus = nil @@ -1234,6 +1365,13 @@ extension UsageStore { return self.selectedManagedCodexAccountForOpenAIWeb() == nil } + private func openAIWebProfileTargetEmailIsMissing() -> Bool { + guard case let .profileHome(path) = self.settings.codexResolvedActiveSource else { + return false + } + return self.currentProfileCodexRuntimeEmail(path: path) == nil + } + private func selectedManagedCodexAccountForOpenAIWeb() -> ManagedCodexAccount? { guard case let .managedAccount(id) = self.settings.codexResolvedActiveSource else { return nil @@ -1255,7 +1393,9 @@ extension UsageStore { guard allowLastKnownLiveFallback else { return nil } let lastKnown = self.lastKnownLiveSystemCodexEmail?.trimmingCharacters(in: .whitespacesAndNewlines) - if let lastKnown, !lastKnown.isEmpty { return lastKnown } + if let lastKnown, !lastKnown.isEmpty { + return lastKnown + } return nil case .managedAccount: if self.openAIWebManagedTargetStoreIsUnreadable() { @@ -1264,7 +1404,16 @@ extension UsageStore { let managed = self.currentManagedCodexRuntimeEmail()? .trimmingCharacters(in: .whitespacesAndNewlines) - if let managed, !managed.isEmpty { return managed } + if let managed, !managed.isEmpty { + return managed + } + return nil + case let .profileHome(path): + let profile = self.currentProfileCodexRuntimeEmail(path: path)? + .trimmingCharacters(in: .whitespacesAndNewlines) + if let profile, !profile.isEmpty { + return profile + } return nil } } @@ -1275,6 +1424,8 @@ extension UsageStore { nil case let .managedAccount(id): self.openAIWebManagedTargetStoreIsUnreadable() ? .managedStoreUnreadable : .managedAccount(id) + case let .profileHome(path): + .profileHome(path) } } } @@ -1284,6 +1435,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 } @@ -1292,7 +1444,9 @@ extension UsageStore { } nonisolated static func shouldSkipOpenAIWebRefresh(_ context: OpenAIWebRefreshGateContext) -> Bool { - if context.force || context.accountDidChange { return false } + if context.force || context.accountDidChange { + return false + } if let lastAttemptAt = context.lastAttemptAt, context.now.timeIntervalSince(lastAttemptAt) < context.refreshInterval { @@ -1307,6 +1461,17 @@ 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, @@ -1319,7 +1484,9 @@ extension UsageStore { let targetEmail = self.currentCodexOpenAIWebTargetEmail( allowCurrentSnapshotFallback: true, allowLastKnownLiveFallback: true) - self.handleOpenAIWebTargetEmailChangeIfNeeded(targetEmail: targetEmail) + self.handleOpenAIWebTargetEmailChangeIfNeeded( + targetEmail: targetEmail, + targetScope: self.codexCookieCacheScopeForOpenAIWeb()) } func openAIDashboardFriendlyError( @@ -1388,13 +1555,13 @@ extension UsageStore { let targetLabel = targetEmail?.trimmingCharacters(in: .whitespacesAndNewlines) if normalizedFound.isEmpty { guard let targetLabel, !targetLabel.isEmpty else { - return "No matching OpenAI web session found." + return L("No matching OpenAI web session found.") } - return "No matching OpenAI web session found for \(targetLabel)." + return L("No matching OpenAI web session found for %@.", targetLabel) } guard let targetLabel, !targetLabel.isEmpty else { - return "OpenAI cookies are for \(foundLabel)." + return L("OpenAI cookies are for %@.", foundLabel) } - return "OpenAI cookies are for \(foundLabel), not \(targetLabel)." + return L("OpenAI cookies are for %1$@, not %2$@.", foundLabel, targetLabel) } } diff --git a/Sources/CodexBar/UsageStore+PlanUtilization.swift b/Sources/CodexBar/UsageStore+PlanUtilization.swift index 82b81708b5..75d26ce540 100644 --- a/Sources/CodexBar/UsageStore+PlanUtilization.swift +++ b/Sources/CodexBar/UsageStore+PlanUtilization.swift @@ -2,21 +2,31 @@ import CodexBarCore import Foundation extension UsageStore { - private nonisolated static let weeklyLimitResetThreshold = 1.0 + nonisolated static let sessionLimitResetDetectorDefaultsKey = "sessionLimitResetDetectorStates" private nonisolated static let weeklyLimitResetDetectorDefaultsKey = "weeklyLimitResetDetectorStates" - private nonisolated static let weeklyWindowMinutes = 7 * 24 * 60 - - struct WeeklyLimitResetDetectorState: Codable, Equatable { - let wasAboveThreshold: Bool - let lastObservedAt: Date - } + private nonisolated static let claudeOAuthAccountUuidMapDefaultsKey = "ClaudeOAuthHistoryOwnerAccountUuidMapV1" + private nonisolated static let claudeOAuthAccountCandidateMapDefaultsKey = + "ClaudeOAuthHistoryOwnerAccountCandidateMapV1" + nonisolated static let sessionWindowMinutes = 5 * 60 + nonisolated static let weeklyWindowMinutes = 7 * 24 * 60 + nonisolated static let planUtilizationUnscopedPreferredKey = "__unscoped__" + private nonisolated static let claudeOAuthPlanUtilizationAccountKeyPrefix = "__claude_oauth__:" func supportsPlanUtilizationHistory(for provider: UsageProvider) -> Bool { switch provider { - case .codex, .claude: + case .codex, .claude, .antigravity, .opencodego: true default: - false + if self.planUtilizationHistory[provider]?.isEmpty == false { + true + } else if self.settings.historicalTrackingEnabled, let snapshot = self.snapshots[provider] { + !self.planUtilizationSeriesSamples( + provider: provider, + snapshot: snapshot, + capturedAt: snapshot.updatedAt).isEmpty + } else { + false + } } } @@ -29,14 +39,40 @@ extension UsageStore { let windowMinutes: Int } - private struct PlanUtilizationSeriesSample { + struct PlanUtilizationSeriesSample { let name: PlanUtilizationSeriesName let windowMinutes: Int let entry: PlanUtilizationHistoryEntry } func planUtilizationHistory(for provider: UsageProvider) -> [PlanUtilizationSeriesHistory] { + self.planUtilizationHistorySelection(for: provider).histories + } + + func planUtilizationHistorySelection(for provider: UsageProvider) + -> PlanUtilizationHistorySelection + { + // The persisted history has not been read yet. Return the in-memory + // stub (empty) without performing account migration or enqueueing an + // empty persistence snapshot — otherwise a startup refresh racing the + // background load would record samples against an empty bucket and + // overwrite real disk history. + if !self.planUtilizationHistoryLoaded { + let providerBuckets = self.planUtilizationHistory[provider] ?? PlanUtilizationHistoryBuckets() + return PlanUtilizationHistorySelection(accountKey: nil, histories: providerBuckets.histories(for: nil)) + } var providerBuckets = self.planUtilizationHistory[provider] ?? PlanUtilizationHistoryBuckets() + if provider == .claude, + providerBuckets.preferredAccountKey == Self.planUtilizationUnscopedPreferredKey + || Self.isClaudeOAuthPlanUtilizationAccountKey(providerBuckets.preferredAccountKey) + { + // Persisted OAuth provenance outranks an unrelated configured token account. The unscoped + // sentinel intentionally resolves to nil, including after the history store is reloaded. + let accountKey = self.stickyPlanUtilizationAccountKey(providerBuckets: providerBuckets) + return PlanUtilizationHistorySelection( + accountKey: accountKey, + histories: providerBuckets.histories(for: accountKey)) + } let originalProviderBuckets = providerBuckets let accountKey = self.resolvePlanUtilizationAccountKey( provider: provider, @@ -45,21 +81,119 @@ extension UsageStore { providerBuckets: &providerBuckets) self.planUtilizationHistory[provider] = providerBuckets if providerBuckets != originalProviderBuckets { + self.planUtilizationHistoryRevision &+= 1 + self.sessionEquivalentBurnCache.removeValue(forKey: provider) + let snapshotToPersist = self.planUtilizationHistory + Task { + await self.planUtilizationPersistenceCoordinator.enqueue(snapshotToPersist) + } + } + return PlanUtilizationHistorySelection( + accountKey: accountKey, + histories: providerBuckets.histories(for: accountKey)) + } + + func planUtilizationHistorySelection( + for provider: UsageProvider, + account: ProviderTokenAccount) -> PlanUtilizationHistorySelection + { + guard self.planUtilizationHistoryLoaded, + let accountKey = Self.planUtilizationAccountKey(provider: provider, account: account) + else { + return .unavailable + } + if self.settings.effectiveSelectedTokenAccount(for: provider)?.id == account.id { + let currentSelection = self.planUtilizationHistorySelection(for: provider) + if currentSelection.accountKey == accountKey { + return currentSelection + } + } + let providerBuckets = self.planUtilizationHistory[provider] ?? PlanUtilizationHistoryBuckets() + return PlanUtilizationHistorySelection( + accountKey: accountKey, + histories: providerBuckets.histories(for: accountKey)) + } + + func planUtilizationHistorySelection( + for provider: UsageProvider, + snapshotOverride snapshot: UsageSnapshot) -> PlanUtilizationHistorySelection + { + guard self.planUtilizationHistoryLoaded, + let accountKey = Self.planUtilizationIdentityAccountKey(provider: provider, snapshot: snapshot) + else { + return .unavailable + } + if self.settings.effectiveSelectedTokenAccount(for: provider) == nil, + let currentSnapshot = self.snapshots[provider], + Self.planUtilizationIdentityAccountKey(provider: provider, snapshot: currentSnapshot) == accountKey + { + let currentSelection = self.planUtilizationHistorySelection(for: provider) + if currentSelection.accountKey == accountKey { + return currentSelection + } + } + let providerBuckets = self.planUtilizationHistory[provider] ?? PlanUtilizationHistoryBuckets() + return PlanUtilizationHistorySelection( + accountKey: accountKey, + histories: providerBuckets.histories(for: accountKey)) + } + + func codexPlanUtilizationHistories(forVisibleAccount account: CodexVisibleAccount) + -> [PlanUtilizationSeriesHistory] + { + self.codexPlanUtilizationHistorySelection(forVisibleAccount: account).histories + } + + func codexPlanUtilizationHistorySelection(forVisibleAccount account: CodexVisibleAccount) + -> PlanUtilizationHistorySelection + { + // Same gate as `planUtilizationHistorySelection`: defer ownership + // migration until the persisted history has been read. Unlike the live + // selection, an explicit account must never borrow unscoped startup data. + if !self.planUtilizationHistoryLoaded { + return .unavailable + } + var providerBuckets = self.planUtilizationHistory[.codex] ?? PlanUtilizationHistoryBuckets() + let originalProviderBuckets = providerBuckets + let ownership = self.codexOwnershipContext(forVisibleAccount: account) + guard let canonicalKey = ownership.canonicalKey else { return .unavailable } + + if ownership.hasAdjacentEmailScopeAmbiguity { + guard canonicalKey != ownership.canonicalEmailHashKey else { return .unavailable } + return PlanUtilizationHistorySelection( + accountKey: canonicalKey, + histories: providerBuckets.histories(for: canonicalKey)) + } + + let accountKey = self.materializeCodexPlanUtilizationHistoryIfNeeded( + into: canonicalKey, + ownership: ownership, + shouldAdoptUnscopedHistory: true, + providerBuckets: &providerBuckets) + self.planUtilizationHistory[.codex] = providerBuckets + if providerBuckets != originalProviderBuckets { + self.planUtilizationHistoryRevision &+= 1 + self.sessionEquivalentBurnCache.removeValue(forKey: .codex) let snapshotToPersist = self.planUtilizationHistory Task { await self.planUtilizationPersistenceCoordinator.enqueue(snapshotToPersist) } } - return providerBuckets.histories(for: accountKey) + return PlanUtilizationHistorySelection( + accountKey: accountKey, + histories: providerBuckets.histories(for: accountKey)) } func shouldShowRefreshingMenuCard(for provider: UsageProvider) -> Bool { - let isRefreshing = self.isRefreshing || self.refreshingProviders.contains(provider) - return isRefreshing + self.refreshingProviders.contains(provider) && self.snapshots[provider] == nil && self.error(for: provider) == nil } + func shouldShowRefreshingMenuCardIndicator(for provider: UsageProvider) -> Bool { + self.refreshingProviders.contains(provider) && self.error(for: provider) == nil + } + func shouldHidePlanUtilizationMenuItem(for provider: UsageProvider) -> Bool { guard self.supportsPlanUtilizationHistory(for: provider) else { return true } return self.shouldShowRefreshingMenuCard(for: provider) @@ -69,52 +203,132 @@ extension UsageStore { provider: UsageProvider, snapshot: UsageSnapshot, account: ProviderTokenAccount? = nil, + claudeOAuthPersistentRefHash: String? = nil, + claudeOAuthHistoryOwnerIdentifier: String? = nil, + claudeOAuthKeychainCredentialMismatch: Bool = false, + claudeOAuthKeychainCredentialAbsent: Bool = false, + claudeOAuthKeychainCredentialUnavailable: Bool = false, + claudeOAuthActiveAccountObservation: ClaudeOAuthActiveAccountObservation = .stable(identity: nil), + isClaudeOAuthSample: Bool = false, shouldUpdatePreferredAccountKey: Bool = true, shouldAdoptUnscopedHistory: Bool = true, + codexLimitResetOwnerKey: CodexLimitResetOwnerKey? = nil, now: Date = Date()) async { - let samples = self.planUtilizationSeriesSamples(provider: provider, snapshot: snapshot, capturedAt: now) - guard !samples.isEmpty else { return } - - let detectorAccountKey = self.planUtilizationAccountKey( - for: provider, + let detectorSamples = self.planUtilizationSeriesSamples( + provider: provider, snapshot: snapshot, - preferredAccount: account) - await MainActor.run { - self.postWeeklyLimitResetCelebrationIfNeeded( + capturedAt: now) + let samples = provider == .antigravity + ? self.planUtilizationSeriesSamples( provider: provider, - account: account, snapshot: snapshot, - accountKey: detectorAccountKey, - samples: samples) + capturedAt: now, + forSessionEquivalents: true) + : detectorSamples + var effectiveOwner = claudeOAuthHistoryOwnerIdentifier + if provider == .claude, isClaudeOAuthSample, let owner = claudeOAuthHistoryOwnerIdentifier { + effectiveOwner = self.resolvedClaudeOAuthHistoryOwner(evidence: ClaudeOAuthHistoryEvidence( + owner: owner, + persistentRefHash: claudeOAuthPersistentRefHash, + keychainCredentialMismatch: claudeOAuthKeychainCredentialMismatch, + keychainCredentialAbsent: claudeOAuthKeychainCredentialAbsent, + keychainCredentialUnavailable: claudeOAuthKeychainCredentialUnavailable, + activeAccountObservation: claudeOAuthActiveAccountObservation, + observedAt: now)) + } + let detectorAccountKey = if provider == .claude, isClaudeOAuthSample { + Self.claudeOAuthPlanUtilizationAccountKey( + historyOwnerIdentifier: effectiveOwner, + corroboratingPersistentRefHash: claudeOAuthPersistentRefHash) + } else { + self.planUtilizationAccountKey( + for: provider, + snapshot: snapshot, + preferredAccount: account) + } + if provider == .claude, isClaudeOAuthSample, detectorAccountKey == nil { + // Persisting without a high-entropy owner would merge unrelated OAuth accounts into `unscoped`. + return + } + let detectorContext = LimitResetDetectionContext( + provider: provider, + account: account, + snapshot: snapshot, + accountKey: detectorAccountKey, + capturedAt: now, + codexLimitResetOwnerKey: codexLimitResetOwnerKey) + await MainActor.run { + self.postLimitResetCelebrationsIfNeeded( + context: detectorContext, + samples: detectorSamples) } - guard self.supportsPlanUtilizationHistory(for: provider) else { return } + guard !samples.isEmpty else { return } + guard self.shouldRecordPlanUtilizationHistory(for: provider) else { return } guard !self.shouldDeferClaudePlanUtilizationHistory(provider: provider) else { return } + // Wait for the persisted history to finish loading before mutating + // `self.planUtilizationHistory`. A startup refresh racing the + // background decode would otherwise record samples against an empty + // bucket and overwrite real disk history on the next persistence + // enqueue. + if !self.planUtilizationHistoryLoaded { + // `_cancelPlanUtilizationHistoryLoadForTesting` cancels the task + // and flips `loaded` to true; this branch only runs when the load + // is still pending. Cancellation here (deinit during a startup + // refresh) means the in-memory dictionary is empty — proceeding + // is the safer choice than discarding the sample. + _ = await self.planUtilizationHistoryLoadTask?.result + } + var snapshotToPersist: [UsageProvider: PlanUtilizationHistoryBuckets]? await MainActor.run { var providerBuckets = self.planUtilizationHistory[provider] ?? PlanUtilizationHistoryBuckets() - let preferredAccount = account ?? self.settings.selectedTokenAccount(for: provider) + let originalProviderBuckets = providerBuckets + let preferredAccount = account ?? self.settings.effectiveSelectedTokenAccount(for: provider) let accountKey = self.resolvePlanUtilizationAccountKey( provider: provider, snapshot: snapshot, preferredAccount: preferredAccount, + claudeOAuthPersistentRefHash: claudeOAuthPersistentRefHash, + claudeOAuthHistoryOwnerIdentifier: effectiveOwner, + isClaudeOAuthSample: isClaudeOAuthSample, shouldUpdatePreferredAccountKey: shouldUpdatePreferredAccountKey, shouldAdoptUnscopedHistory: shouldAdoptUnscopedHistory, providerBuckets: &providerBuckets) - let histories = providerBuckets.histories(for: accountKey) + var histories = providerBuckets.histories(for: accountKey) + let originalHistories = histories + var samplesToPersist = samples + if provider == .antigravity, + samples.contains(where: { $0.name == .session }), + !histories.contains(where: { $0.name == .session }) + { + // Pre-feature Antigravity history could contain a provider-wide weekly maximum. + // Drop it before starting the Gemini-pinned session/weekly pair. + histories.removeAll { $0.name == .weekly } + } + if ![UsageProvider.codex, .claude, .antigravity].contains(provider) { + self.reconcileGenericSessionEquivalentHistory( + scope: (provider, accountKey), + snapshot: snapshot, + providerBuckets: &providerBuckets, + histories: &histories, + samples: &samplesToPersist) + self.sessionEquivalentBurnCache.removeValue(forKey: provider) + } - guard let updatedHistories = Self.updatedPlanUtilizationHistories( + let updatedHistories = Self.updatedPlanUtilizationHistories( existingHistories: histories, - samples: samples) - else { - return + samples: samplesToPersist) ?? histories + if updatedHistories != originalHistories { + providerBuckets.setHistories(updatedHistories, for: accountKey) } - providerBuckets.setHistories(updatedHistories, for: accountKey) + guard providerBuckets != originalProviderBuckets else { return } self.planUtilizationHistory[provider] = providerBuckets + self.planUtilizationHistoryRevision &+= 1 snapshotToPersist = self.planUtilizationHistory } @@ -122,6 +336,15 @@ extension UsageStore { await self.planUtilizationPersistenceCoordinator.enqueue(snapshotToPersist) } + private func shouldRecordPlanUtilizationHistory(for provider: UsageProvider) -> Bool { + switch provider { + case .codex, .claude, .antigravity, .opencodego: + true + default: + self.settings.historicalTrackingEnabled + } + } + private nonisolated static func updatedPlanUtilizationHistories( existingHistories: [PlanUtilizationSeriesHistory], samples: [PlanUtilizationSeriesSample]) -> [PlanUtilizationSeriesHistory]? @@ -238,6 +461,7 @@ extension UsageStore { nonisolated static var _planUtilizationMaxSamplesForTesting: Int { self.planUtilizationMaxSamples } + #endif private nonisolated static func clampedPercent(_ value: Double?) -> Double? { @@ -245,72 +469,84 @@ extension UsageStore { return max(0, min(100, value)) } - private func postWeeklyLimitResetCelebrationIfNeeded( - provider: UsageProvider, - account: ProviderTokenAccount?, - snapshot: UsageSnapshot, - accountKey: String?, + private func postLimitResetCelebrationsIfNeeded( + context: LimitResetDetectionContext, samples: [PlanUtilizationSeriesSample]) { - guard let weeklySample = samples.last(where: { $0.name == .weekly }) else { return } - - let accountIdentifier = self.weeklyLimitResetAccountIdentifier( - provider: provider, - account: account, - snapshot: snapshot, - accountKey: accountKey) - let detectorKey = Self.weeklyLimitResetDetectorStateKey( - provider: provider, - accountIdentifier: accountIdentifier) - let currentUsed = weeklySample.entry.usedPercent - let currentObservedAt = weeklySample.entry.capturedAt - let wasAboveThreshold = currentUsed > Self.weeklyLimitResetThreshold - if let existingState = self.weeklyLimitResetDetectorStates[detectorKey], - currentObservedAt <= existingState.lastObservedAt - { - return + let shouldIgnoreCommandCode = context.provider == .commandcode + && context.snapshot.commandCodeSubscriptionEnrichmentUnavailable + let sessionObservation: LimitResetObservation? = if shouldIgnoreCommandCode { + nil + } else if context.provider == .codex { + samples.last(where: { $0.name == .session }).map { + LimitResetObservation( + usedPercent: $0.entry.usedPercent, + observedAt: $0.entry.capturedAt, + resetBoundary: $0.entry.resetsAt, + source: nil) + } + } else { + self.sessionQuotaWindow(provider: context.provider, snapshot: context.snapshot).flatMap { resolved in + guard Self.isSemanticSessionResetWindow(resolved) else { return nil } + return Self.clampedPercent(resolved.window.usedPercent).map { + LimitResetObservation( + usedPercent: $0, + observedAt: context.capturedAt, + resetBoundary: resolved.window.resetsAt, + source: resolved.source) + } + } } + self.postLimitResetCelebrationIfNeeded( + states: &self.sessionLimitResetDetectorStates, + context: context, + descriptor: LimitResetDetectionDescriptor( + seriesName: .session, + defaultsKey: Self.sessionLimitResetDetectorDefaultsKey, + resetKind: "session"), + observation: sessionObservation) + let weeklyObservation = samples.last(where: { $0.name == .weekly }).map { + LimitResetObservation( + usedPercent: $0.entry.usedPercent, + observedAt: $0.entry.capturedAt, + resetBoundary: $0.entry.resetsAt, + source: nil) + } + self.postLimitResetCelebrationIfNeeded( + states: &self.weeklyLimitResetDetectorStates, + context: context, + descriptor: LimitResetDetectionDescriptor( + seriesName: .weekly, + defaultsKey: Self.weeklyLimitResetDetectorDefaultsKey, + resetKind: "weekly"), + observation: weeklyObservation) + } - let shouldPost = self.weeklyLimitResetDetectorStates[detectorKey]?.wasAboveThreshold == true - && !wasAboveThreshold - self.weeklyLimitResetDetectorStates[detectorKey] = WeeklyLimitResetDetectorState( - wasAboveThreshold: wasAboveThreshold, - lastObservedAt: currentObservedAt) - self.persistWeeklyLimitResetDetectorStates() - - guard shouldPost else { return } - let accountLabel = self.weeklyLimitResetAccountLabel( - provider: provider, - account: account, - snapshot: snapshot) - let event = WeeklyLimitResetEvent( - provider: provider, - accountIdentifier: accountIdentifier, - accountLabel: accountLabel, - usedPercent: currentUsed) - - CodexBarLog.logger(LogCategories.confetti).info( - "Weekly limit reset", - metadata: [ - "provider": provider.rawValue, - "accountIdentifier": accountIdentifier, - "accountLabel": accountLabel ?? "", - "usedPercent": String(format: "%.2f", currentUsed), - "observedAt": String(format: "%.0f", currentObservedAt.timeIntervalSince1970), - ]) - NotificationCenter.default.post(name: .codexbarWeeklyLimitReset, object: event) + private static func isSemanticSessionResetWindow( + _ resolved: (window: RateWindow, source: SessionQuotaWindowSource)) -> Bool + { + guard !resolved.window.isSyntheticPlaceholder else { return false } + switch resolved.source { + case .primary: + guard let minutes = resolved.window.windowMinutes else { return false } + return minutes > 0 && minutes <= 6 * 60 + case .copilotSecondaryFallback, .zaiTertiary, .antigravityQuotaSummary, .antigravityLegacy: + return true + } } private func planUtilizationSeriesSamples( provider: UsageProvider, snapshot: UsageSnapshot, - capturedAt: Date) -> [PlanUtilizationSeriesSample] + capturedAt: Date, + forSessionEquivalents: Bool = false) -> [PlanUtilizationSeriesSample] { var samplesByKey: [PlanUtilizationSeriesKey: PlanUtilizationSeriesSample] = [:] func appendWindow(_ window: RateWindow?, name: PlanUtilizationSeriesName?) { guard let name, let window, + !window.isSyntheticPlaceholder, let windowMinutes = window.windowMinutes, windowMinutes > 0, let usedPercent = Self.clampedPercent(window.usedPercent) @@ -342,10 +578,42 @@ extension UsageStore { appendWindow(snapshot.primary, name: .session) appendWindow(snapshot.secondary, name: .weekly) appendWindow(snapshot.tertiary, name: .opus) + case .opencodego: + appendWindow(snapshot.primary, name: .session) + appendWindow(snapshot.secondary, name: .weekly) + appendWindow(snapshot.tertiary, name: .monthly) + case .antigravity: + if forSessionEquivalents { + guard let windows = self.sessionEquivalentWindows(provider: provider, snapshot: snapshot) else { + return [] + } + appendWindow(windows.session, name: .session) + appendWindow(windows.weekly, name: .weekly) + } else { + let namedWeeklyWindows = snapshot.extraRateWindows? + .filter { + $0.usageKnown + && $0.id.hasPrefix("antigravity-quota-summary-") + && $0.window.windowMinutes == Self.weeklyWindowMinutes + } + .map(\.window) ?? [] + if let mostUsedWeeklyWindow = namedWeeklyWindows.max(by: { $0.usedPercent < $1.usedPercent }) { + appendWindow(mostUsedWeeklyWindow, name: .weekly) + } else { + appendWindow( + self.planUtilizationWeeklyWindow(provider: provider, snapshot: snapshot), + name: .weekly) + } + } default: - for window in [snapshot.primary, snapshot.secondary, snapshot.tertiary] { - guard let window, window.windowMinutes == Self.weeklyWindowMinutes else { continue } - appendWindow(window, name: .weekly) + let components = Self.genericSessionEquivalentWindowComponents(snapshot: snapshot) + switch Self.genericSessionEquivalentWindowPairResolution(snapshot: snapshot) { + case let .resolved(session, weekly, _, _): + appendWindow(session, name: .session) + appendWindow(weekly, name: .weekly) + case .incomplete, .ambiguous: + appendWindow(components.session?.window, name: .session) + appendWindow(components.weekly?.window, name: .weekly) } } @@ -486,7 +754,7 @@ extension UsageStore { snapshot: UsageSnapshot? = nil, preferredAccount: ProviderTokenAccount? = nil) -> String? { - let account = preferredAccount ?? self.settings.selectedTokenAccount(for: provider) + let account = preferredAccount ?? self.settings.effectiveSelectedTokenAccount(for: provider) let accountKey = Self.planUtilizationAccountKey(provider: provider, account: account) if let accountKey { return accountKey @@ -503,6 +771,29 @@ extension UsageStore { return self.sha256Hex("\(provider.rawValue):token-account:\(account.id.uuidString.lowercased())") } + /// The Keychain row reference is corroborating provenance, not principal identity. Excluding it from the + /// canonical key keeps one credential stable when its row is recreated, while requiring the credential + /// discriminator ensures an in-place login replacement cannot inherit the prior principal's history. + private nonisolated static func claudeOAuthPlanUtilizationAccountKey( + historyOwnerIdentifier: String?, + corroboratingPersistentRefHash _: String? = nil) -> String? + { + guard let normalizedIdentifier = historyOwnerIdentifier? + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased(), + normalizedIdentifier.count == 64, + normalizedIdentifier.allSatisfy(\.isHexDigit) + else { + return nil + } + let digest = self.sha256Hex("claude:oauth-history-owner:v2:\(normalizedIdentifier)") + return "\(self.claudeOAuthPlanUtilizationAccountKeyPrefix)\(digest)" + } + + private nonisolated static func isClaudeOAuthPlanUtilizationAccountKey(_ accountKey: String?) -> Bool { + accountKey?.hasPrefix(self.claudeOAuthPlanUtilizationAccountKeyPrefix) == true + } + private nonisolated static func planUtilizationIdentityAccountKey( provider: UsageProvider, snapshot: UsageSnapshot) -> String? @@ -573,67 +864,253 @@ extension UsageStore { provider == .claude && self.shouldHidePlanUtilizationMenuItem(for: .claude) } - private func weeklyLimitResetAccountIdentifier( + nonisolated static func limitResetDetectorStateKey( provider: UsageProvider, - account: ProviderTokenAccount?, - snapshot: UsageSnapshot, - accountKey: String?) -> String + accountIdentifier: String) -> String { - let identity = snapshot.identity(for: provider) - return account?.id.uuidString.lowercased() - ?? accountKey - ?? identity?.accountEmail - ?? identity?.accountOrganization - ?? provider.rawValue + "\(provider.rawValue):\(accountIdentifier)" } - private func weeklyLimitResetAccountLabel( - provider: UsageProvider, - account: ProviderTokenAccount?, - snapshot: UsageSnapshot) -> String? + nonisolated static func loadWeeklyLimitResetDetectorStates(from userDefaults: UserDefaults) + -> [String: LimitResetDetectorState] { - let identity = snapshot.identity(for: provider) - return account?.label - ?? identity?.accountEmail - ?? identity?.accountOrganization + var states = self.loadLimitResetDetectorStates( + from: userDefaults, + defaultsKey: self.weeklyLimitResetDetectorDefaultsKey, + logName: "weekly") + let legacyClaudeLowStateKeys = states.compactMap { key, state in + key.hasPrefix("\(UsageProvider.claude.rawValue):") + && !state.wasAboveThreshold + && state.recoveryAboveThresholdCount == nil + ? key + : nil + } + for key in legacyClaudeLowStateKeys { + guard var migratedState = states[key] else { continue } + migratedState.recoveryAboveThresholdCount = 0 + states[key] = migratedState + } + return states } - private nonisolated static func weeklyLimitResetDetectorStateKey( - provider: UsageProvider, - accountIdentifier: String) -> String + nonisolated static func loadLimitResetDetectorStates( + from userDefaults: UserDefaults, + defaultsKey: String, + logName: String) -> [String: LimitResetDetectorState] { - "\(provider.rawValue):\(accountIdentifier)" + guard let data = userDefaults.data(forKey: defaultsKey) else { return [:] } + do { + return try JSONDecoder().decode([String: LimitResetDetectorState].self, from: data) + } catch { + CodexBarLog.logger(LogCategories.confetti).error( + "Failed to decode \(logName) limit reset detector state", + metadata: ["error": String(describing: error)]) + return [:] + } } - nonisolated static func loadWeeklyLimitResetDetectorStates(from userDefaults: UserDefaults) - -> [String: WeeklyLimitResetDetectorState] + func persistLimitResetDetectorStates( + _ states: [String: LimitResetDetectorState], + defaultsKey: String, + logName: String) { - guard let data = userDefaults.data(forKey: self.weeklyLimitResetDetectorDefaultsKey) else { return [:] } do { - return try JSONDecoder().decode([String: WeeklyLimitResetDetectorState].self, from: data) + let data = try JSONEncoder().encode(states) + self.settings.userDefaults.set(data, forKey: defaultsKey) + } catch { + CodexBarLog.logger(LogCategories.confetti).error( + "Failed to encode \(logName) limit reset detector state", + metadata: ["error": String(describing: error)]) + } + } + + // MARK: - Active Claude account corroboration (~/.claude.json) + + /// The currently-active Claude account UUID, read prompt-free from `~/.claude.json`. This is the only + /// always-fresh, never-gated signal of the active account on a background poll: Claude Code's `/login` + /// updates the Keychain item in place and leaves `~/.claude/.credentials.json` stale, but immediately + /// rewrites `oauthAccount.accountUuid` in this sibling plain file. Returns nil on absence/corruption. + nonisolated static func activeClaudeAccountUuid() -> String? { + ClaudeActiveAccountProbe.activeClaudeAccountUuid() + } + + /// Persisted `historyOwnerIdentifier -> hashed active account identity` bindings. + nonisolated static func loadClaudeOAuthAccountUuidMap(from userDefaults: UserDefaults) -> [String: String] { + guard let data = userDefaults.data(forKey: claudeOAuthAccountUuidMapDefaultsKey) else { return [:] } + do { + return try JSONDecoder().decode([String: String].self, from: data) } catch { CodexBarLog.logger(LogCategories.confetti).error( - "Failed to decode weekly limit reset detector state", + "Failed to decode Claude OAuth history owner account UUID map", metadata: ["error": String(describing: error)]) return [:] } } - private func persistWeeklyLimitResetDetectorStates() { + /// Persist the `historyOwnerIdentifier -> active accountUuid` map. Mirrors `persistLimitResetDetectorStates`. + func persistClaudeOAuthAccountUuidMap(_ map: [String: String]) { + do { + let data = try JSONEncoder().encode(map) + self.settings.userDefaults.set(data, forKey: Self.claudeOAuthAccountUuidMapDefaultsKey) + } catch { + CodexBarLog.logger(LogCategories.confetti).error( + "Failed to encode Claude OAuth history owner account UUID map", + metadata: ["error": String(describing: error)]) + } + } + + nonisolated static func loadClaudeOAuthAccountBindingCandidateMap( + from userDefaults: UserDefaults) -> [String: ClaudeOAuthAccountBindingCandidate] + { + guard let data = userDefaults.data(forKey: claudeOAuthAccountCandidateMapDefaultsKey) else { return [:] } + do { + return try JSONDecoder().decode([String: ClaudeOAuthAccountBindingCandidate].self, from: data) + } catch { + CodexBarLog.logger(LogCategories.confetti).error( + "Failed to decode Claude OAuth account binding candidates", + metadata: ["error": String(describing: error)]) + return [:] + } + } + + private func confirmClaudeOAuthAccountBindingCandidate( + owner: String, + identity: String, + observedAt: Date) -> Bool + { + var candidates = Self.loadClaudeOAuthAccountBindingCandidateMap(from: self.settings.userDefaults) + if let candidate = candidates[owner], + candidate.identity == identity, + candidate.observedAt < observedAt + { + candidates.removeValue(forKey: owner) + self.persistClaudeOAuthAccountBindingCandidateMap(candidates) + return true + } + candidates[owner] = ClaudeOAuthAccountBindingCandidate(identity: identity, observedAt: observedAt) + self.persistClaudeOAuthAccountBindingCandidateMap(candidates) + return false + } + + private func resolvedClaudeOAuthHistoryOwner(evidence: ClaudeOAuthHistoryEvidence) -> String? { + let requiresClaudeCodeCorroboration = evidence.persistentRefHash != nil + || evidence.keychainCredentialMismatch + || evidence.keychainCredentialAbsent + || evidence.keychainCredentialUnavailable + guard requiresClaudeCodeCorroboration else { + // Explicit/environment credentials do not belong to Claude Code's active-account lifecycle. + return evidence.owner + } + guard case let .stable(currentAccountIdentity) = evidence.activeAccountObservation else { + // An account/credential change while capturing the UUID cannot safely identify this sample. + return nil + } + var map = Self.loadClaudeOAuthAccountUuidMap(from: self.settings.userDefaults) + if let mapped = map[evidence.owner] { + guard let currentAccountIdentity else { + return evidence.keychainCredentialMismatch || evidence.keychainCredentialUnavailable + ? nil + : evidence.owner + } + guard mapped != currentAccountIdentity else { + self.clearClaudeOAuthAccountBindingCandidate(owner: evidence.owner) + return evidence.owner + } + guard evidence.persistentRefHash != nil, + self.confirmClaudeOAuthAccountBindingCandidate( + owner: evidence.owner, + identity: currentAccountIdentity, + observedAt: evidence.observedAt) + else { + return nil + } + // Two stable exact-Keychain observations repair a binding poisoned by a non-atomic login. + map[evidence.owner] = currentAccountIdentity + self.persistClaudeOAuthAccountUuidMap(map) + return evidence.owner + } + + if evidence.keychainCredentialUnavailable, + !evidence.keychainCredentialMismatch + { + // With no authoritative binding, the secret-derived file owner is the only safe bootstrap scope. + // Existing bindings are checked above, so normal background gating cannot bypass a detected switch. + return evidence.owner + } + if evidence.keychainCredentialAbsent { + // A proven-empty Keychain leaves the file credential as the only owner. Existing bindings were + // checked above, so an unbound owner is safe without inventing account continuity. + return evidence.owner + } + + guard let currentAccountIdentity else { + return evidence.keychainCredentialMismatch || evidence.keychainCredentialUnavailable + ? nil + : evidence.owner + } + guard evidence.persistentRefHash != nil else { return nil } + // Two stable exact-Keychain observations are required before a first binding becomes authoritative. + if self.confirmClaudeOAuthAccountBindingCandidate( + owner: evidence.owner, + identity: currentAccountIdentity, + observedAt: evidence.observedAt) + { + map[evidence.owner] = currentAccountIdentity + self.persistClaudeOAuthAccountUuidMap(map) + } + return evidence.owner + } + + private func clearClaudeOAuthAccountBindingCandidate(owner: String) { + var candidates = Self.loadClaudeOAuthAccountBindingCandidateMap(from: self.settings.userDefaults) + guard candidates.removeValue(forKey: owner) != nil else { return } + self.persistClaudeOAuthAccountBindingCandidateMap(candidates) + } + + private func persistClaudeOAuthAccountBindingCandidateMap( + _ candidates: [String: ClaudeOAuthAccountBindingCandidate]) + { do { - let data = try JSONEncoder().encode(self.weeklyLimitResetDetectorStates) - self.settings.userDefaults.set(data, forKey: Self.weeklyLimitResetDetectorDefaultsKey) + let data = try JSONEncoder().encode(candidates) + self.settings.userDefaults.set(data, forKey: Self.claudeOAuthAccountCandidateMapDefaultsKey) } catch { CodexBarLog.logger(LogCategories.confetti).error( - "Failed to encode weekly limit reset detector state", + "Failed to encode Claude OAuth account binding candidates", metadata: ["error": String(describing: error)]) } } + nonisolated static func activeClaudeAccountIdentity() -> String? { + self.activeClaudeAccountUuid().map(self.claudeAccountIdentity) + } + + private nonisolated static func claudeAccountIdentity(_ uuid: String) -> String { + self.sha256Hex( + "claude:active-account:v1:\(uuid.trimmingCharacters(in: .whitespacesAndNewlines).lowercased())") + } + + #if DEBUG + static func withActiveClaudeAccountUuidForTesting( + _ uuid: String?, + _ body: () async throws -> T) async rethrows -> T + { + try await ClaudeActiveAccountProbe.$activeClaudeAccountUuidOverrideForTesting.withValue( + .value(uuid), + operation: body) + } + + nonisolated static func _activeClaudeAccountIdentityForTesting(_ uuid: String) -> String { + self.claudeAccountIdentity(uuid) + } + #endif + private func resolvePlanUtilizationAccountKey( provider: UsageProvider, snapshot: UsageSnapshot?, preferredAccount: ProviderTokenAccount?, + claudeOAuthPersistentRefHash: String? = nil, + claudeOAuthHistoryOwnerIdentifier: String? = nil, + isClaudeOAuthSample: Bool = false, shouldUpdatePreferredAccountKey: Bool = true, shouldAdoptUnscopedHistory: Bool = true, providerBuckets: inout PlanUtilizationHistoryBuckets) -> String? @@ -646,12 +1123,36 @@ extension UsageStore { providerBuckets: &providerBuckets) } - let resolvedAccount = preferredAccount ?? self.settings.selectedTokenAccount(for: provider) + // Claude's unscoped history is only safe to adopt during the first unambiguous migration. + // The sentinel marks identityless OAuth, while any scoped bucket proves multiple owners may exist. + let canAdoptUnscopedHistory = shouldAdoptUnscopedHistory + && !(provider == .claude + && (providerBuckets.preferredAccountKey == Self.planUtilizationUnscopedPreferredKey + || !providerBuckets.accounts.isEmpty)) + + if provider == .claude, isClaudeOAuthSample { + if let oauthAccountKey = Self.claudeOAuthPlanUtilizationAccountKey( + historyOwnerIdentifier: claudeOAuthHistoryOwnerIdentifier, + corroboratingPersistentRefHash: claudeOAuthPersistentRefHash) + { + if shouldUpdatePreferredAccountKey { + providerBuckets.preferredAccountKey = oauthAccountKey + } + // Existing unscoped or identity-keyed history can belong to another OAuth account. + // Preserve it in place rather than silently adopting it into this opaque account. + return oauthAccountKey + } + // Never append identityless OAuth samples to the shared unscoped bucket. A future fetch with + // trustworthy ownership evidence can start a scoped history without inheriting this sample. + return nil + } + + let resolvedAccount = preferredAccount ?? self.settings.effectiveSelectedTokenAccount(for: provider) if let tokenAccountKey = Self.planUtilizationAccountKey(provider: provider, account: resolvedAccount) { if shouldUpdatePreferredAccountKey { providerBuckets.preferredAccountKey = tokenAccountKey } - if shouldAdoptUnscopedHistory { + if canAdoptUnscopedHistory { self.adoptPlanUtilizationUnscopedHistoryIfNeeded( into: tokenAccountKey, provider: provider, @@ -671,7 +1172,7 @@ extension UsageStore { if shouldUpdatePreferredAccountKey { providerBuckets.preferredAccountKey = resolvedIdentityAccountKey } - if shouldAdoptUnscopedHistory { + if canAdoptUnscopedHistory { self.adoptPlanUtilizationUnscopedHistoryIfNeeded( into: resolvedIdentityAccountKey, provider: provider, @@ -732,6 +1233,7 @@ extension UsageStore { targetCanonicalKey: canonicalKey, canonicalEmailHashKey: ownership.canonicalEmailHashKey) if matchesTargetContinuity, + !Self.codexPlanHistoryOwnerIsAmbiguousEmailScope(owner, ownership: ownership), let accountHistories = providerBuckets.accounts[rawKey], !accountHistories.isEmpty { @@ -775,6 +1277,21 @@ extension UsageStore { return canonicalKey } + private static func codexPlanHistoryOwnerIsAmbiguousEmailScope( + _ owner: CodexHistoryPersistedOwner, + ownership: CodexOwnershipContext) -> Bool + { + guard ownership.hasAdjacentEmailScopeAmbiguity else { return false } + return switch owner { + case let .canonical(key): + key == ownership.canonicalEmailHashKey + case .legacyEmailHash: + true + case .legacyOpaqueScoped, .legacyUnscoped: + false + } + } + private func materializeLegacyClaudePlanUtilizationHistoryIfNeeded( into accountKey: String, provider: UsageProvider, @@ -817,11 +1334,17 @@ extension UsageStore { ]) providerBuckets.setHistories(mergedHistory, for: accountKey) providerBuckets.setHistories([], for: nil) + if ![UsageProvider.codex, .claude, .antigravity].contains(provider) { + providerBuckets.moveSessionEquivalentWindowPairIdentity(from: nil, to: accountKey) + } } private func stickyPlanUtilizationAccountKey( providerBuckets: PlanUtilizationHistoryBuckets) -> String? { + if providerBuckets.preferredAccountKey == Self.planUtilizationUnscopedPreferredKey { + return nil + } let knownAccountKeys = self.knownPlanUtilizationAccountKeys(providerBuckets: providerBuckets) guard !knownAccountKeys.isEmpty else { return nil } @@ -922,7 +1445,7 @@ extension UsageStore { return true } - private nonisolated static func areEquivalentPlanUtilizationResetBoundaries(_ lhs: Date?, _ rhs: Date?) -> Bool { + nonisolated static func areEquivalentPlanUtilizationResetBoundaries(_ lhs: Date?, _ rhs: Date?) -> Bool { guard let lhs, let rhs else { return false } return abs(lhs.timeIntervalSince(rhs)) < self.planUtilizationResetEquivalenceToleranceSeconds } @@ -1059,6 +1582,15 @@ extension UsageStore { self.planUtilizationAccountKey(provider: provider, account: account) } + nonisolated static func _claudeOAuthPlanUtilizationAccountKeyForTesting( + historyOwnerIdentifier: String?, + persistentRefHash: String? = nil) -> String? + { + self.claudeOAuthPlanUtilizationAccountKey( + historyOwnerIdentifier: historyOwnerIdentifier, + corroboratingPersistentRefHash: persistentRefHash) + } + nonisolated static func _legacyClaudePlanUtilizationEmailAccountKeyForTesting(snapshot: UsageSnapshot) -> String? { self.legacyClaudePlanUtilizationEmailAccountKey(snapshot: snapshot) } @@ -1106,3 +1638,46 @@ actor PlanUtilizationHistoryPersistenceCoordinator { }.value } } + +/// Prompt-free reader for the active Claude account UUID recorded in `~/.claude.json`. The `@TaskLocal` test +/// seam lives here (not on `UsageStore`) because Swift forbids stored properties in extensions and task-local +/// storage must be nonisolated, whereas `UsageStore` is `@MainActor`. +private enum ClaudeActiveAccountProbe { + #if DEBUG + enum Override: Sendable { + case value(String?) + } + + @TaskLocal static var activeClaudeAccountUuidOverrideForTesting: Override? + #endif + + private struct ClaudeConfigAccount: Decodable { + struct OAuthAccount: Decodable { + let accountUuid: String? + } + + let oauthAccount: OAuthAccount? + } + + static func activeClaudeAccountUuid() -> String? { + #if DEBUG + if case let .value(uuid) = self.activeClaudeAccountUuidOverrideForTesting { + return uuid + } + #endif + // `~/.claude.json` is a SIBLING of `.claude/`, not inside it. Home resolution mirrors + // `ClaudeOAuthCredentials.defaultCredentialsURL()`. This intentionally does NOT honor + // CLAUDE_CONFIG_DIR: the credential store that yields `historyOwnerIdentifier` is purely + // home-relative, so the accountUuid corroboration must resolve against the same home or the + // two signals would point at different accounts. + let url = FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(".claude.json") + guard let data = try? Data(contentsOf: url), + let decoded = try? JSONDecoder().decode(ClaudeConfigAccount.self, from: data), + let uuid = decoded.oauthAccount?.accountUuid?.trimmingCharacters(in: .whitespacesAndNewlines), + !uuid.isEmpty + else { + return nil + } + return uuid + } +} diff --git a/Sources/CodexBar/UsageStore+PlanUtilizationLoading.swift b/Sources/CodexBar/UsageStore+PlanUtilizationLoading.swift new file mode 100644 index 0000000000..6337bdd4af --- /dev/null +++ b/Sources/CodexBar/UsageStore+PlanUtilizationLoading.swift @@ -0,0 +1,37 @@ +import Foundation + +extension UsageStore { + static func resolvedPlanHistoryStore( + _ store: PlanUtilizationHistoryStore?, + startup: StartupBehavior) -> PlanUtilizationHistoryStore + { + store ?? (startup.automaticallyStartsBackgroundWork + ? .defaultAppSupport() + : PlanUtilizationHistoryStore(directoryURL: nil)) + } + + func startPlanUtilizationHistoryLoad(gate: PlanUtilizationHistoryLoadGate?, enabled: Bool) { + guard enabled || gate != nil else { + self.planUtilizationHistoryLoaded = true + return + } + let historyStore = self.planUtilizationHistoryStore + self.planUtilizationHistoryLoadTask = Task { @MainActor [weak self] in + // In-memory starts empty; mutation paths and sync menu accessors gate on + // `planUtilizationHistoryLoaded` until the background decode publishes once. + if let gate { + let shouldLoad = await withTaskCancellationHandler { + await gate.wait() + } onCancel: { + gate.cancel() + } + guard shouldLoad, !Task.isCancelled else { return } + } + let loaded = await historyStore.loadAsync() + guard !Task.isCancelled, let self, !self.planUtilizationHistoryLoaded else { return } + self.planUtilizationHistory = loaded + self.planUtilizationHistoryLoaded = true + self.planUtilizationHistoryRevision &+= 1 + } + } +} diff --git a/Sources/CodexBar/UsageStore+ProviderRuntime.swift b/Sources/CodexBar/UsageStore+ProviderRuntime.swift new file mode 100644 index 0000000000..860a4705ee --- /dev/null +++ b/Sources/CodexBar/UsageStore+ProviderRuntime.swift @@ -0,0 +1,21 @@ +import CodexBarCore + +extension UsageStore { + func performRuntimeAction(_ action: ProviderRuntimeAction, for provider: UsageProvider) async { + guard let runtime = self.providerRuntimes[provider] else { return } + let context = ProviderRuntimeContext(provider: provider, settings: self.settings, store: self) + await runtime.perform(action: action, context: context) + } + + func updateProviderRuntimes() { + for (provider, runtime) in self.providerRuntimes { + let context = ProviderRuntimeContext(provider: provider, settings: self.settings, store: self) + if self.isEnabled(provider) { + runtime.start(context: context) + } else { + runtime.stop(context: context) + } + runtime.settingsDidChange(context: context) + } + } +} diff --git a/Sources/CodexBar/UsageStore+ProviderStatus.swift b/Sources/CodexBar/UsageStore+ProviderStatus.swift new file mode 100644 index 0000000000..43e09b45d7 --- /dev/null +++ b/Sources/CodexBar/UsageStore+ProviderStatus.swift @@ -0,0 +1,55 @@ +import CodexBarCore +import Foundation + +extension UsageStore { + func refreshProviderStatus(_ provider: UsageProvider) async { + guard self.settings.statusChecksEnabled else { return } + guard let meta = self.providerMetadata[provider] else { return } + let publicationRevision = self.providerPublicationRevision(for: provider) + + do { + let status: ProviderStatus + var components: [ProviderStatusComponent]? + if let override = self._test_providerStatusFetchOverride { + status = try await override(provider) + } else if let urlString = meta.statusPageURL, let baseURL = URL(string: urlString) { + let summary = try await Self.fetchStatusSummary(from: baseURL) + status = summary.status + components = summary.components + } else if let productID = meta.statusWorkspaceProductID { + status = try await Self.fetchWorkspaceStatus(productID: productID) + } else { + return + } + guard self.statusRefreshPublicationIsCurrent(publicationRevision, for: provider) else { return } + self.statuses[provider] = status + // A component endpoint is best-effort. Preserve the last good list when the + // overall status succeeds but the component request or decoding fails. + if let components { + self.statusComponents[provider] = components + } + self.emitProviderStatusHooks(provider: provider, indicator: status.indicator) + } catch { + guard self.statusRefreshPublicationIsCurrent(publicationRevision, for: provider) else { return } + self.recordStartupConnectivityRetryableFailure(error) + // Keep the previous status to avoid flapping when the API hiccups. + if self.statuses[provider] == nil { + self.statuses[provider] = ProviderStatus( + indicator: .unknown, + description: error.localizedDescription, + updatedAt: nil) + } + } + } + + private func statusRefreshPublicationIsCurrent( + _ publicationRevision: ProviderPublicationRevision, + for provider: UsageProvider) -> Bool + { + self.providerPublicationRevisionIsCurrent(publicationRevision, for: provider) && + self.settings.statusChecksEnabled && + self.settings.isProviderEnabledCached( + provider: provider, + metadataByProvider: self.providerMetadata) + } +} diff --git a/Sources/CodexBar/UsageStore+ProviderStorage.swift b/Sources/CodexBar/UsageStore+ProviderStorage.swift index 3e5197eb51..826ab19907 100644 --- a/Sources/CodexBar/UsageStore+ProviderStorage.swift +++ b/Sources/CodexBar/UsageStore+ProviderStorage.swift @@ -45,7 +45,16 @@ extension UsageStore { self.clearStorageFootprints() return } - guard let request = self.makeStorageRefreshRequest(for: providers) else { + let environment = self.environmentBase + let managedAccountsOverride = self.managedCodexAccountsForStorageOverride + let request = await Task.detached(priority: .utility) { + let managedAccounts = Self.loadManagedCodexAccountsForStorage(override: managedAccountsOverride) + return Self.makeStorageRefreshRequest( + for: providers, + environment: environment, + managedAccounts: managedAccounts) + }.value + guard let request else { self.clearStorageFootprints() return } @@ -67,6 +76,7 @@ extension UsageStore { updatedAt: Date()) self.storageRefreshTask = nil self.storageRefreshInFlightSignature = nil + self.storageRefreshInFlightRequestKey = nil } func scheduleStorageFootprintRefresh(for providers: [UsageProvider], force: Bool = false) { @@ -74,19 +84,23 @@ extension UsageStore { self.clearStorageFootprints() return } - guard let request = self.makeStorageRefreshRequest(for: providers) else { + let managedAccountsOverride = self.managedCodexAccountsForStorageOverride + let requestKey = Self.storageRefreshRequestKey( + for: providers, + managedAccountsOverride: managedAccountsOverride) + guard !requestKey.isEmpty else { self.clearStorageFootprints() return } let now = Date() if self.storageRefreshTask != nil, - self.storageRefreshInFlightSignature == request.signature + self.storageRefreshInFlightRequestKey == nil || self.storageRefreshInFlightRequestKey == requestKey { return } if !force { - if self.lastStorageRefreshSignature == request.signature, + if self.lastStorageRefreshRequestKey == requestKey, let lastStorageRefreshAt, now.timeIntervalSince(lastStorageRefreshAt) < Self.automaticStorageRefreshInterval { @@ -97,9 +111,32 @@ extension UsageStore { self.storageRefreshTask?.cancel() self.storageRefreshGeneration &+= 1 let generation = self.storageRefreshGeneration - self.storageRefreshInFlightSignature = request.signature + self.storageRefreshInFlightSignature = nil + self.storageRefreshInFlightRequestKey = requestKey + let environment = self.environmentBase self.storageRefreshTask = Task.detached(priority: .utility) { [weak self] in + let managedAccounts = Self.loadManagedCodexAccountsForStorage(override: managedAccountsOverride) + guard let request = Self.makeStorageRefreshRequest( + for: providers, + environment: environment, + managedAccounts: managedAccounts) + else { + await MainActor.run { [weak self] in + guard let self, + !Task.isCancelled, + generation == self.storageRefreshGeneration + else { return } + self.providerStorageFootprints.removeAll() + self.storageRefreshTask = nil + self.storageRefreshInFlightSignature = nil + self.storageRefreshInFlightRequestKey = nil + self.lastStorageRefreshSignature = nil + self.lastStorageRefreshRequestKey = requestKey + self.lastStorageRefreshAt = Date() + } + return + } let footprints = Self.scanStorageFootprints(candidatePathsByProvider: request.candidatePathsByProvider) await MainActor.run { [weak self] in @@ -112,9 +149,11 @@ extension UsageStore { footprints, providers: request.providers, signature: request.signature, + requestKey: requestKey, updatedAt: Date()) self.storageRefreshTask = nil self.storageRefreshInFlightSignature = nil + self.storageRefreshInFlightRequestKey = nil } } } @@ -123,7 +162,9 @@ extension UsageStore { self.storageRefreshTask?.cancel() self.storageRefreshTask = nil self.storageRefreshInFlightSignature = nil + self.storageRefreshInFlightRequestKey = nil self.lastStorageRefreshSignature = nil + self.lastStorageRefreshRequestKey = nil self.lastStorageRefreshAt = nil self.providerStorageFootprints.removeAll() } @@ -132,23 +173,44 @@ extension UsageStore { _ footprints: [UsageProvider: ProviderStorageFootprint], providers: [UsageProvider], signature: String, + requestKey: String? = nil, updatedAt: Date) { let providerSet = Set(providers) - self.providerStorageFootprints = self.providerStorageFootprints.filter { !providerSet.contains($0.key) } + var updated = self.providerStorageFootprints.filter { !providerSet.contains($0.key) } for provider in providers { - self.providerStorageFootprints[provider] = footprints[provider] + // Reuse the existing footprint when only its scan timestamp would change, so the equality + // guard below treats an unchanged scan as a no-op. + if let incoming = footprints[provider], + let existing = self.providerStorageFootprints[provider], + existing.hasSameContents(as: incoming) + { + updated[provider] = existing + } else { + updated[provider] = footprints[provider] + } + } + // Only republish the observable footprints when a value actually changed. Storage scans run + // on every menu open and roughly every 5 minutes; an unconditional re-assignment wakes + // `menuObservationToken` -> `invalidateMenus` churn (clearing menu caches) even when the + // scanned bytes are identical. + if updated != self.providerStorageFootprints { + self.providerStorageFootprints = updated } self.lastStorageRefreshSignature = signature + self.lastStorageRefreshRequestKey = requestKey ?? signature self.lastStorageRefreshAt = updatedAt } - private func makeStorageRefreshRequest(for providers: [UsageProvider]) -> StorageRefreshRequest? { + private nonisolated static func makeStorageRefreshRequest( + for providers: [UsageProvider], + environment: [String: String], + managedAccounts: [ManagedCodexAccount]) + -> StorageRefreshRequest? + { let uniqueProviders = Array(Set(providers)).sorted { $0.rawValue < $1.rawValue } guard !uniqueProviders.isEmpty else { return nil } - let environment = self.environmentBase - let managedAccounts = self.loadManagedCodexAccountsForStorage() var candidatePathsByProvider: [UsageProvider: [String]] = [:] for provider in uniqueProviders { @@ -175,9 +237,39 @@ extension UsageStore { signature: signature) } - private func loadManagedCodexAccountsForStorage() -> [ManagedCodexAccount] { - if let managedCodexAccountsForStorageOverride { - return managedCodexAccountsForStorageOverride + private nonisolated static func storageRefreshRequestKey( + for providers: [UsageProvider], + managedAccountsOverride: [ManagedCodexAccount]?) + -> String + { + let uniqueProviders = Array(Set(providers)) + .sorted { $0.rawValue < $1.rawValue } + let providerKey = uniqueProviders.map(\.rawValue).joined(separator: ",") + guard uniqueProviders.contains(.codex) else { return providerKey } + + let managedAccountsRevision: String + if let managedAccountsOverride { + managedAccountsRevision = Array(Set(managedAccountsOverride.map(\.managedHomePath))) + .sorted() + .joined(separator: "\u{1f}") + } else { + let fileURL = FileManagedCodexAccountStore.defaultURL() + let attributes = try? FileManager.default.attributesOfItem(atPath: fileURL.path) + let modificationDate = (attributes?[.modificationDate] as? Date)? + .timeIntervalSinceReferenceDate.bitPattern ?? 0 + let fileNumber = (attributes?[.systemFileNumber] as? NSNumber)?.uint64Value ?? 0 + let fileSize = (attributes?[.size] as? NSNumber)?.uint64Value ?? 0 + managedAccountsRevision = "\(fileNumber):\(modificationDate):\(fileSize)" + } + return "\(providerKey)\u{1e}\(managedAccountsRevision)" + } + + private nonisolated static func loadManagedCodexAccountsForStorage( + override: [ManagedCodexAccount]?) + -> [ManagedCodexAccount] + { + if let override { + return override } return (try? FileManagedCodexAccountStore().loadAccounts().accounts) ?? [] } diff --git a/Sources/CodexBar/UsageStore+QuotaWarnings.swift b/Sources/CodexBar/UsageStore+QuotaWarnings.swift new file mode 100644 index 0000000000..fbb55e25cc --- /dev/null +++ b/Sources/CodexBar/UsageStore+QuotaWarnings.swift @@ -0,0 +1,276 @@ +import CodexBarCore +import Foundation + +extension UsageStore { + struct QuotaWarningStateKey: Hashable { + let provider: UsageProvider + let window: QuotaWarningWindow + /// Keeps independent accounts from sharing threshold-crossing state. `nil` preserves the + /// legacy single-account lane when no stable account owner is available. + let accountDiscriminator: String? + /// Distinguishes independent extra rate windows that share a provider/window lane + /// (e.g. multiple `claude-weekly-scoped-*` windows) so their fired-threshold state + /// does not clobber each other or the primary session/weekly lanes. `nil` for the + /// primary session and weekly lanes. + let windowID: String? + + init( + provider: UsageProvider, + window: QuotaWarningWindow, + accountDiscriminator: String?, + windowID: String? = nil) + { + self.provider = provider + self.window = window + self.accountDiscriminator = accountDiscriminator + self.windowID = windowID + } + } + + struct QuotaWarningState { + var lastRemaining: Double? + var firedThresholds: Set = [] + var source: SessionQuotaWindowSource? + } +} + +@MainActor +extension UsageStore { + private struct QuotaWarningAccountContext { + let discriminator: String? + let displayName: String? + } + + func handleQuotaWarningTransitions( + provider: UsageProvider, + snapshot: UsageSnapshot, + accountDiscriminator: String? = nil) + { + let notificationsEnabled = self.settings.quotaWarningNotificationsEnabled + // Hooks have their own enable switch and per-rule thresholds, so quota_low + // hooks run on a separate path that does not depend on the notification + // preference or the notification thresholds. + self.resetQuotaLowHookUsageIfConfigurationChanged() + let hooksActive = self.hasQuotaHookRule(event: .quotaLow, provider: provider) + if !hooksActive { + self.clearQuotaLowHookUsage(provider: provider) + } + guard notificationsEnabled || hooksActive else { return } + if provider == .commandcode, snapshot.commandCodeSubscriptionEnrichmentUnavailable { return } + + let accountContext = QuotaWarningAccountContext( + discriminator: accountDiscriminator, + displayName: self.quotaWarningAccountDisplayName(provider: provider, snapshot: snapshot)) + let source: SessionQuotaWindowSource? = if provider == .antigravity { + Self.hasAntigravityQuotaSummaryWindows(snapshot: snapshot) + ? .antigravityQuotaSummary + : .antigravityLegacy + } else { + nil + } + let primaryWindow: RateWindow? + let secondaryWindow: RateWindow? + if provider == .antigravity { + primaryWindow = Self.antigravityWindow(snapshot: snapshot, windowMinutes: 5 * 60) + secondaryWindow = Self.antigravityWindow(snapshot: snapshot, windowMinutes: 7 * 24 * 60) + } else { + // Crof credits-only accounts publish a duration-less balance as `primary`; a drained + // prepaid balance is not a quota threshold crossing, so it must not raise warnings. + // Crof accounts that do expose request quotas (secondary present) keep normal warnings. + let isBalanceOnlyCrof = provider == .crof && snapshot.secondary == nil + let suppressWindows = provider == .mimo || provider == .qoder || isBalanceOnlyCrof + primaryWindow = suppressWindows ? nil : snapshot.primary + secondaryWindow = suppressWindows ? nil : snapshot.secondary + } + let primaryWindowDisplayLabel = provider == .amp + ? AmpProviderDescriptor.primaryLabel(details: snapshot.ampUsage) + : nil + let secondaryWindowDisplayLabel = provider == .amp + ? AmpProviderDescriptor.secondaryLabel(details: snapshot.ampUsage) + : nil + if notificationsEnabled { + self.handleQuotaWarningTransition( + provider: provider, + window: .session, + rateWindow: primaryWindow, + source: source, + accountContext: accountContext, + windowDisplayLabel: primaryWindowDisplayLabel) + self.handleQuotaWarningTransition( + provider: provider, + window: .weekly, + rateWindow: secondaryWindow, + source: source, + accountContext: accountContext, + windowDisplayLabel: secondaryWindowDisplayLabel) + self.handleClaudeExtraWindowQuotaWarnings( + provider: provider, + snapshot: snapshot, + accountContext: accountContext) + } + + if hooksActive { + self.dispatchQuotaLowHooks( + provider: provider, + lane: QuotaLowHookLane( + window: .session, + windowID: nil, + label: primaryWindowDisplayLabel ?? QuotaWarningWindow.session.displayName), + rateWindow: primaryWindow, + accountDiscriminator: accountContext.discriminator, + accountDisplayName: accountContext.displayName) + self.dispatchQuotaLowHooks( + provider: provider, + lane: QuotaLowHookLane( + window: .weekly, + windowID: nil, + label: secondaryWindowDisplayLabel ?? QuotaWarningWindow.weekly.displayName), + rateWindow: secondaryWindow, + accountDiscriminator: accountContext.discriminator, + accountDisplayName: accountContext.displayName) + let extraWindows = provider == .claude + ? (snapshot.extraRateWindows ?? []).filter(Self.isClaudeNotifiableExtraWindow) + : [] + for named in extraWindows { + self.dispatchQuotaLowHooks( + provider: provider, + lane: QuotaLowHookLane(window: .weekly, windowID: named.id, label: named.title), + rateWindow: named.window, + accountDiscriminator: accountContext.discriminator, + accountDisplayName: accountContext.displayName) + } + self.pruneQuotaLowHookUsage( + provider: provider, + accountDiscriminator: accountContext.discriminator, + keepingExtraWindowIDs: Set(extraWindows.map(\.id))) + } + } + + /// Emit weekly-lane quota warnings for Claude's extra rate windows — model-scoped weekly + /// carve-outs (`claude-weekly-scoped-*`, e.g. Fable) and Daily Routines — which surface in the + /// menu but were otherwise silent. Antigravity's summary windows are already covered by the + /// primary and weekly lanes above, so they are excluded here. + private func handleClaudeExtraWindowQuotaWarnings( + provider: UsageProvider, + snapshot: UsageSnapshot, + accountContext: QuotaWarningAccountContext) + { + guard provider == .claude else { return } + guard self.settings.quotaWarningEnabled(provider: provider, window: .weekly) else { + self.clearQuotaWarningState(provider: provider, window: .weekly) + return + } + + let windows = (snapshot.extraRateWindows ?? []).filter(Self.isClaudeNotifiableExtraWindow) + for named in windows { + self.handleQuotaWarningTransition( + provider: provider, + window: .weekly, + rateWindow: named.window, + source: nil, + accountContext: accountContext, + windowID: named.id, + windowDisplayLabel: named.title) + } + // A missing extras payload is not authoritative, but when another notifiable window remains, + // reconcile tracked IDs so a later incarnation of a disappeared window can warn again. + guard !windows.isEmpty else { return } + let activeIDs = Set(windows.map(\.id)) + let staleKeys = self.quotaWarningState.keys.filter { key in + guard key.provider == provider, + key.window == .weekly, + key.accountDiscriminator == accountContext.discriminator, + let windowID = key.windowID + else { return false } + return !activeIDs.contains(windowID) + } + for key in staleKeys { + self.quotaWarningState.removeValue(forKey: key) + } + } + + private static func isClaudeNotifiableExtraWindow(_ named: NamedRateWindow) -> Bool { + guard named.usageKnown else { return false } + return named.id.hasPrefix("claude-weekly-scoped-") || named.id == "claude-routines" + } + + private func handleQuotaWarningTransition( + provider: UsageProvider, + window: QuotaWarningWindow, + rateWindow: RateWindow?, + source: SessionQuotaWindowSource?, + accountContext: QuotaWarningAccountContext, + windowID: String? = nil, + windowDisplayLabel: String? = nil) + { + let key = QuotaWarningStateKey( + provider: provider, + window: window, + accountDiscriminator: accountContext.discriminator, + windowID: windowID) + guard self.settings.quotaWarningEnabled(provider: provider, window: window) else { + self.clearQuotaWarningState(provider: provider, window: window) + return + } + guard let rateWindow else { + self.quotaWarningState.removeValue(forKey: key) + return + } + guard !rateWindow.isSyntheticPlaceholder else { return } + + let thresholds = self.settings.resolvedQuotaWarningThresholds(provider: provider, window: window) + let currentRemaining = rateWindow.remainingPercent + let previousState = self.quotaWarningState[key] + if let previousState, previousState.source != source { + self.quotaWarningState[key] = QuotaWarningState( + lastRemaining: currentRemaining, + source: source) + return + } + var state = previousState ?? QuotaWarningState(source: source) + let cleared = QuotaWarningNotificationLogic.thresholdsToClear( + currentRemaining: currentRemaining, + alreadyFired: state.firedThresholds) + state.firedThresholds.subtract(cleared) + + if let threshold = QuotaWarningNotificationLogic.crossedThreshold( + previousRemaining: state.lastRemaining, + currentRemaining: currentRemaining, + thresholds: thresholds, + alreadyFired: state.firedThresholds) + { + state.firedThresholds.formUnion(QuotaWarningNotificationLogic.firedThresholdsAfterWarning( + threshold: threshold, + thresholds: thresholds)) + self.postQuotaWarning( + QuotaWarningEvent( + window: window, + threshold: threshold, + currentRemaining: currentRemaining, + accountDisplayName: accountContext.displayName, + windowID: windowID, + windowDisplayLabel: windowDisplayLabel), + provider: provider) + } + + state.lastRemaining = currentRemaining + self.quotaWarningState[key] = state + } + + private func clearQuotaWarningState(provider: UsageProvider, window: QuotaWarningWindow) { + let keys = self.quotaWarningState.keys.filter { + $0.provider == provider && $0.window == window + } + for key in keys { + self.quotaWarningState.removeValue(forKey: key) + } + } + + private func quotaWarningAccountDisplayName(provider: UsageProvider, snapshot: UsageSnapshot) -> String? { + guard !self.settings.hidePersonalInfo else { return nil } + let account = snapshot.accountEmail(for: provider)? + .trimmingCharacters(in: .whitespacesAndNewlines) + guard let account, !account.isEmpty else { return nil } + return account + } +} diff --git a/Sources/CodexBar/UsageStore+Refresh.swift b/Sources/CodexBar/UsageStore+Refresh.swift index db3c9d2e21..a133454949 100644 --- a/Sources/CodexBar/UsageStore+Refresh.swift +++ b/Sources/CodexBar/UsageStore+Refresh.swift @@ -2,6 +2,95 @@ import CodexBarCore import Foundation extension UsageStore { + nonisolated static func codexSessionQuotaOwnerKey( + for refreshGuard: CodexAccountScopedRefreshGuard?) -> CodexSessionQuotaOwnerKey? + { + guard let refreshGuard else { return nil } + return CodexSessionQuotaOwnerKey(refreshGuard: refreshGuard) + } + + nonisolated static func codexSessionQuotaOwnersMatch( + _ lhs: CodexAccountScopedRefreshGuard?, + _ rhs: CodexAccountScopedRefreshGuard?) -> Bool + { + guard let lhsKey = self.codexSessionQuotaOwnerKey(for: lhs), + let rhsKey = self.codexSessionQuotaOwnerKey(for: rhs) + else { + return false + } + return lhsKey == rhsKey + } + + private struct ProviderRefreshOutcomeContext { + let generation: UInt64 + let codexExpectedGuard: CodexAccountScopedRefreshGuard? + let tokenAccount: ProviderTokenAccount? + let priorTokenAccountSnapshot: TokenAccountUsageSnapshot? + let codexLimitResetOwnerKey: CodexLimitResetOwnerKey? + let claudeOAuthHistoryPersistentRefHash: String? + let claudeOAuthActiveAccountObservation: ClaudeOAuthActiveAccountObservation + + var codexSessionQuotaOwnerKey: CodexSessionQuotaOwnerKey? { + UsageStore.codexSessionQuotaOwnerKey(for: self.codexExpectedGuard) + } + } + + private struct CodexRefreshPublicationPreparation { + let expectedGuard: CodexAccountScopedRefreshGuard + let limitResetOwnerKey: CodexLimitResetOwnerKey? + let previousSnapshot: UsageSnapshot? + let missingWindowBackfillSnapshot: UsageSnapshot? + } + + private static func warningAccountDiscriminator( + provider: UsageProvider, + tokenAccount: ProviderTokenAccount?, + result: ProviderFetchResult, + context: ProviderRefreshOutcomeContext) -> String? + { + if let tokenAccount { + return self.warningTokenAccountDiscriminator(tokenAccount) + } + if provider == .codex { + return context.codexSessionQuotaOwnerKey?.rawValue + } + guard provider == .claude else { return nil } + return self.warningClaudeAccountDiscriminator( + strategyKind: result.strategyKind, + observation: context.claudeOAuthActiveAccountObservation, + oauthHistoryOwnerIdentifier: result.claudeOAuthHistoryOwnerIdentifier) + } + + static func commandCodeSnapshotResolvingDepletionOnEnrichmentFailure( + current: UsageSnapshot, + previous: UsageSnapshot?) -> UsageSnapshot + { + let previousProvesPaidDepletion = previous?.commandCodeHasSubscriptionPlan == true || + (previous?.commandCodeSubscriptionEnrichmentUnavailable == true && + previous?.commandCodeMonthlyGrantDepleted == true && + previous?.primary?.usedPercent == 100) + guard current.commandCodeSubscriptionEnrichmentUnavailable, + current.commandCodeMonthlyGrantDepleted, + previousProvesPaidDepletion, + let previousPrimary = previous?.primary + else { + return current + } + let depleted = RateWindow( + usedPercent: 100, + windowMinutes: previousPrimary.windowMinutes, + resetsAt: previousPrimary.resetsAt, + resetDescription: previousPrimary.resetDescription) + return current.with(primary: depleted, secondary: current.secondary) + } + + func refreshForSettingsChange() async { + await self.runRefresh( + startupConnectivityRetryAttempt: nil, + coalesceProviderRefreshesOverride: false, + waitForRefreshAvailability: true) + } + func prepareRefreshState(for provider: UsageProvider? = nil) { guard provider == nil || provider == .codex else { return } _ = self.settings.persistResolvedCodexActiveSourceCorrectionIfNeeded() @@ -20,28 +109,210 @@ extension UsageStore { return self.providerSpecs[provider] } - func refreshProvider(_ provider: UsageProvider, allowDisabled: Bool = false) async { + func refreshProvider( + _ provider: UsageProvider, + allowDisabled: Bool = false, + coalesceIfRefreshing: Bool = false) async + { + // Codex source reconciliation can persist a settings correction. Perform it before + // capturing the publication revision so the request cannot invalidate itself. self.prepareRefreshState(for: provider) + while coalesceIfRefreshing, + let existingState = self.providerRefreshCoordinator.coalescingState(for: provider) + { + switch await self.providerRefreshCoordinator.wait(for: provider, state: existingState) { + case .cancelled: + return + case .retryRequired: + self.providerRefreshCoordinator.remove(existingState, for: provider) + continue + case .completed: + return + } + } + + let request = self.providerRefreshCoordinator.beginReplacingRequest(for: provider) + self.providerRefreshPublicationContexts[provider] = ProviderRefreshPublicationContext( + generation: request.generation, + enablementRevision: self.settings.providerEnablementRevision(for: provider), + configRevision: self.settings.providerConfigRevision(for: provider), + tokenCostScopeSignature: Self.tokenCostRequiresProviderSnapshot(provider) + ? self.tokenSnapshotScopeSignature(for: provider) + : nil, + allowDisabled: allowDisabled) + let task = Task { @MainActor [weak self] in + guard let self else { return } + var snapshotUpdatedAtBeforeRefresh: Date? + var didStartRefresh = false + for predecessorState in request.predecessorStates { + await predecessorState.waitForTaskCompletion() + } + if !Task.isCancelled, + self.providerRefreshCoordinator.isCurrent(request.generation, for: provider) + { + // A replacement can wait behind a predecessor while Settings changes. Capture + // the publication inputs at actual fetch start so that queued work uses the new + // configuration, while later changes still reject its suspended result. + self.providerRefreshPublicationContexts[provider] = ProviderRefreshPublicationContext( + generation: request.generation, + enablementRevision: self.settings.providerEnablementRevision(for: provider), + configRevision: self.settings.providerConfigRevision(for: provider), + tokenCostScopeSignature: Self.tokenCostRequiresProviderSnapshot(provider) + ? self.tokenSnapshotScopeSignature(for: provider) + : nil, + allowDisabled: allowDisabled) + snapshotUpdatedAtBeforeRefresh = self.snapshot(for: provider)?.updatedAt + didStartRefresh = true + await ProviderRefreshRequestContext.withNewRequest { + await self.refreshProviderTracked( + provider, + allowDisabled: allowDisabled, + generation: request.generation) + } + } + let publishedNewSnapshot = didStartRefresh && + self.snapshot(for: provider)?.updatedAt != snapshotUpdatedAtBeforeRefresh + let retryRequired = !publishedNewSnapshot && + (Task.isCancelled || !self.isCurrentProviderRefreshGeneration( + provider, + generation: request.generation)) + self.providerRefreshCoordinator.complete( + request.state, + for: provider, + retryRequired: retryRequired) + } + request.state.install(task: task) + _ = await self.providerRefreshCoordinator.wait(for: provider, state: request.state) + } + + func isCurrentProviderRefreshGeneration(_ provider: UsageProvider, generation: UInt64?) -> Bool { + guard let generation else { return true } + guard self.providerRefreshCoordinator.isCurrent(generation, for: provider), + let context = self.providerRefreshPublicationContexts[provider], + context.generation == generation + else { + return false + } + return context.enablementRevision == self.settings.providerEnablementRevision(for: provider) && + context.configRevision == self.settings.providerConfigRevision(for: provider) && + (context.tokenCostScopeSignature == nil || + context.tokenCostScopeSignature == self.tokenSnapshotScopeSignature(for: provider)) + } + + func currentProviderRefreshAllowsDisabledPublication(_ provider: UsageProvider) -> Bool { + guard let context = self.providerRefreshPublicationContexts[provider], + context.allowDisabled, + let state = self.providerRefreshCoordinator.coalescingState(for: provider), + state.generation == context.generation + else { + return false + } + return true + } + + private func refreshProviderTracked( + _ provider: UsageProvider, + allowDisabled: Bool, + generation: UInt64) async + { + if self.providerRefreshCoordinator.beginActivity(for: provider) { + self.refreshingProviders.insert(provider) + } + defer { + if self.providerRefreshCoordinator.endActivity(for: provider) { + self.refreshingProviders.remove(provider) + } + } + await self.refreshProviderNow( + provider, + allowDisabled: allowDisabled, + generation: generation) + } + + private func prepareCodexRefreshPublication() -> CodexRefreshPublicationPreparation { + let previousGuard = self.lastCodexUsagePublicationGuard + let expectedGuard = self.freshCodexAccountScopedRefreshGuard() + let hydrationCandidates = self.codexAccountSnapshots + let projection = self.settings.codexVisibleAccountProjection + let visibleAccounts = projection.visibleAccounts + let ownerKey = self.codexLimitResetOwnerKey( + expectedGuard: expectedGuard, + visibleAccounts: visibleAccounts) + let previousOwnerKey = previousGuard.flatMap { + CodexLimitResetOwnerKey(identity: $0.identity, accountEmail: $0.accountKey) + } + let ownerMatchesPrevious = ownerKey != nil && ownerKey == previousOwnerKey + self.reconcileCodexAccountStateForUsageOwner(expectedGuard) + + let hydratedPrior: CodexAccountUsageSnapshot? = { + guard let ownerKey, let activeVisibleAccountID = projection.activeVisibleAccountID else { return nil } + let matches = hydrationCandidates.filter { row in + row.snapshot != nil && + row.id == activeVisibleAccountID && + self.codexLimitResetOwnerKey( + forVisibleAccount: row.account, + visibleAccounts: visibleAccounts) == ownerKey + } + guard matches.count == 1 else { return nil } + return matches[0] + }() + if self.snapshots[.codex] == nil, + let hydratedPrior, + let hydratedSnapshot = hydratedPrior.snapshot + { + self.snapshots[.codex] = hydratedSnapshot + self.lastKnownResetSnapshots[.codex] = hydratedSnapshot + self.errors[.codex] = hydratedPrior.error + self.lastSourceLabels[.codex] = hydratedPrior.sourceLabel + self.lastCodexUsagePublicationGuard = expectedGuard + self.lastCodexAccountScopedRefreshGuard = expectedGuard + } + + var trustedCandidates = ownerMatchesPrevious + ? [self.snapshots[.codex], self.lastKnownResetSnapshots[.codex]].compactMap(\.self) + : [] + if let hydratedSnapshot = hydratedPrior?.snapshot { + trustedCandidates.append(hydratedSnapshot) + } + let weeklyCandidates = trustedCandidates.filter { + CodexConsumerProjection.sourceRateWindow(for: .weekly, snapshot: $0) != nil + } + let previousSnapshot = (weeklyCandidates.isEmpty ? trustedCandidates : weeklyCandidates) + .max { $0.updatedAt < $1.updatedAt } + let missingWindowBackfillSnapshot = Self.codexMergedResetBackfillSnapshot(trustedCandidates) + return CodexRefreshPublicationPreparation( + expectedGuard: expectedGuard, + limitResetOwnerKey: ownerKey, + previousSnapshot: previousSnapshot, + missingWindowBackfillSnapshot: missingWindowBackfillSnapshot) + } + + private func refreshProviderNow( + _ provider: UsageProvider, + allowDisabled: Bool, + generation: UInt64) async + { guard let spec = await self.providerRefreshSpec(provider) else { return } - let codexExpectedGuard = provider == .codex ? self.currentCodexAccountScopedRefreshGuard() : nil + guard self.isCurrentProviderRefreshGeneration(provider, generation: generation) else { return } + let codexPreparation = provider == .codex ? self.prepareCodexRefreshPublication() : nil + let codexExpectedGuard = codexPreparation?.expectedGuard + let codexLimitResetOwnerKey = codexPreparation?.limitResetOwnerKey if !spec.isEnabled(), !allowDisabled { await self.clearDisabledProviderRefreshState(provider) return } - self.refreshingProviders.insert(provider) - defer { self.refreshingProviders.remove(provider) } - if provider == .codex, self.shouldFetchAllCodexVisibleAccounts() { - await self.refreshCodexVisibleAccountsForMenu() + await self.refreshCodexVisibleAccountsForMenu(generation: generation) return } else if provider == .codex { self.codexAccountSnapshots = [] } if provider == .kilo, self.shouldFanOutKiloScopes() { - await self.refreshKiloScopes() + await self.refreshKiloScopes(generation: generation) + guard self.isCurrentProviderRefreshGeneration(provider, generation: generation) else { return } // Continue to also fetch the personal snapshot through the regular path // so the existing single-card render keeps working when only personal is shown. // The presence of multi-element kiloScopeSnapshots triggers stacked rendering. @@ -49,34 +320,101 @@ extension UsageStore { await MainActor.run { self.kiloScopeSnapshots = [] } } + if provider == .claude { + self.scheduleClaudeSwapAccountRefresh(generation: generation) + } + let tokenAccounts = self.tokenAccounts(for: provider) if self.shouldFetchAllTokenAccounts(provider: provider, accounts: tokenAccounts) { - await self.refreshTokenAccounts(provider: provider, accounts: tokenAccounts) + await self.refreshTokenAccounts( + provider: provider, + accounts: tokenAccounts, + generation: generation) return } else { _ = await MainActor.run { - self.accountSnapshots.removeValue(forKey: provider) + self.reconcileSelectedTokenAccountSnapshotBeforeRefresh( + provider: provider, + accounts: tokenAccounts) } } + self.diagnostics[provider] = nil let claudeAuthStateBeforeFetch = provider == .claude ? await Self.captureClaudeRefreshAuthState(invalidateCredentialsFile: true) : nil - let fetchContext = spec.makeFetchContext() + let tokenAccount = self.settings.effectiveSelectedTokenAccount(for: provider) + let priorTokenAccountSnapshot = self.tokenAccountSnapshot(provider: provider, account: tokenAccount) + let fetchContext = self.makeFetchContext(provider: provider, override: nil) let descriptor = spec.descriptor + let codexResetCreditsFetcher = self.codexResetCreditsFetcher() + let previousCodexSnapshot = codexPreparation?.previousSnapshot + let codexMissingWindowBackfillSnapshot = codexPreparation?.missingWindowBackfillSnapshot + let fetchOutcome: @Sendable () async -> ProviderFetchOutcome = { + let outcome = await descriptor.fetchOutcome(context: fetchContext) + guard provider == .codex else { return outcome } + return await Self.attachingCodexResetCreditsIfNeeded( + to: outcome, + env: fetchContext.env, + fetcher: codexResetCreditsFetcher) + } // Keep provider fetch work off MainActor so slow keychain/process reads don't stall menu/UI responsiveness. - let outcome = await withTaskGroup( - of: ProviderFetchOutcome.self, - returning: ProviderFetchOutcome.self) - { group in - group.addTask { - await descriptor.fetchOutcome(context: fetchContext) + let initialOutcome: ProviderFetchOutcome = if let override = self._test_providerFetchOutcomeOverride { + await override(provider) + } else { + await withTaskGroup( + of: ProviderFetchOutcome.self, + returning: ProviderFetchOutcome.self) + { group in + group.addTask(operation: fetchOutcome) + return await group.next()! } - return await group.next()! } - let claudeAuthFingerprintAfterFetch = provider == .claude - ? await Self.captureClaudeAuthFingerprintToken() + let outcome: ProviderFetchOutcome + if provider == .codex { + if case let .success(result) = initialOutcome.result, + let codexExpectedGuard, + !self.shouldApplyCodexUsageResult( + expectedGuard: codexExpectedGuard, + usage: result.usage.scoped(to: .codex)) + { + self.retireCodexStateIfRefreshOwnerChanged( + expectedGuard: codexExpectedGuard, + generation: generation) + return + } + guard let admittedOutcome = await Self.codexOutcomeAdmittedForPublication( + initialOutcome: initialOutcome, + previousSnapshot: previousCodexSnapshot, + missingWindowBackfillSnapshot: codexMissingWindowBackfillSnapshot, + fetchConfirmation: fetchOutcome) + else { + if let codexExpectedGuard { + self.retireCodexStateIfRefreshOwnerChanged( + expectedGuard: codexExpectedGuard, + generation: generation) + } + return + } + if case let .success(result) = admittedOutcome.result, + let codexExpectedGuard, + !self.shouldApplyCodexUsageResult( + expectedGuard: codexExpectedGuard, + usage: result.usage.scoped(to: .codex)) + { + self.retireCodexStateIfRefreshOwnerChanged( + expectedGuard: codexExpectedGuard, + generation: generation) + return + } + outcome = admittedOutcome + } else { + outcome = initialOutcome + } + let claudeHistoryAccountState = provider == .claude + ? await Self.captureClaudeHistoryAccountState() : nil + let claudeAuthFingerprintAfterFetch = claudeHistoryAccountState?.fingerprintToken let claudeAuthChangedDuringFetch = Self.claudeAuthChangedDuringFetch( provider: provider, beforeFetch: claudeAuthStateBeforeFetch, @@ -88,107 +426,367 @@ extension UsageStore { let shouldConsumeClaudeKeychainFingerprint = Self.shouldConsumeClaudeKeychainFingerprintChange( beforeFetch: claudeAuthStateBeforeFetch, changedDuringFetch: claudeAuthChangedDuringFetch) - await MainActor.run { - self.lastFetchAttempts[provider] = outcome.attempts + let claudeOAuthHistoryPersistentRefHash = Self.stableClaudeKeychainPersistentRefHash( + beforeFetch: claudeAuthStateBeforeFetch, + afterFetchFingerprintToken: claudeAuthFingerprintAfterFetch, + afterFetchPersistentRefHash: claudeHistoryAccountState?.keychainPersistentRefHash, + accountStateWasStable: claudeHistoryAccountState?.wasStable == true) + let claudeOAuthActiveAccountObservation = Self.claudeOAuthActiveAccountObservation( + beforeFetch: claudeAuthStateBeforeFetch, + afterFetch: claudeHistoryAccountState) + // Credential detection consumes change markers. Clean up before rejecting a superseded generation; + // replacement refreshes wait for their predecessor, so they cannot race this state reset. + if claudeCredentialsChanged { + await self.clearClaudeCredentialDerivedStateForCredentialSwap() } + if shouldConsumeClaudeKeychainFingerprint { + _ = await Self.consumeClaudeKeychainFingerprintChangeWithoutPrompt() + } + guard self.isCurrentProviderRefreshGeneration(provider, generation: generation) else { return } + await self.applyProviderRefreshOutcome( + provider: provider, + outcome: outcome, + context: ProviderRefreshOutcomeContext( + generation: generation, + codexExpectedGuard: codexExpectedGuard, + tokenAccount: tokenAccount, + priorTokenAccountSnapshot: priorTokenAccountSnapshot, + codexLimitResetOwnerKey: codexLimitResetOwnerKey, + claudeOAuthHistoryPersistentRefHash: claudeOAuthHistoryPersistentRefHash, + claudeOAuthActiveAccountObservation: claudeOAuthActiveAccountObservation)) + } + private func applyProviderRefreshOutcome( + provider: UsageProvider, + outcome: ProviderFetchOutcome, + context: ProviderRefreshOutcomeContext) async + { switch outcome.result { case let .success(result): - let scoped = result.usage.scoped(to: provider) + await self.applyProviderRefreshSuccess( + provider: provider, + result: result, + attempts: outcome.attempts, + context: context) + case let .failure(error): + await self.applyProviderRefreshFailure( + provider: provider, + error: error, + attempts: outcome.attempts, + context: context) + } + } + + private func applyProviderRefreshSuccess( + provider: UsageProvider, + result: ProviderFetchResult, + attempts: [ProviderFetchAttempt], + context: ProviderRefreshOutcomeContext) async + { + let rawScoped = result.usage.scoped(to: provider) + if provider == .codex, + let codexExpectedGuard = context.codexExpectedGuard, + !self.shouldApplyCodexUsageResult(expectedGuard: codexExpectedGuard, usage: rawScoped) + { + self.retireCodexStateIfRefreshOwnerChanged( + expectedGuard: codexExpectedGuard, + generation: context.generation) + return + } + let scoped = Self.codexUsageWithExpectedEmailIfMissing( + provider: provider, + usage: rawScoped, + expectedGuard: context.codexExpectedGuard) + let currentTokenAccount = context.tokenAccount.flatMap { account in + self.uniqueTokenAccount(provider: provider, accountID: account.id) + } + if context.tokenAccount != nil, currentTokenAccount == nil { + return + } + let accountScoped = if let tokenAccount = currentTokenAccount { + self.applyAccountLabel(scoped, provider: provider, account: tokenAccount) + } else { + scoped + } + let backfilled = await MainActor.run { () -> UsageSnapshot? in + guard self.isCurrentProviderRefreshGeneration(provider, generation: context.generation) else { + return nil + } if provider == .codex, - let codexExpectedGuard, - !self.shouldApplyCodexUsageResult(expectedGuard: codexExpectedGuard, usage: scoped) + let codexExpectedGuard = context.codexExpectedGuard, + !self.shouldApplyCodexUsageResult(expectedGuard: codexExpectedGuard, usage: rawScoped) { - return + self.retireCodexStateIfRefreshOwnerChanged( + expectedGuard: codexExpectedGuard, + generation: context.generation) + return nil } - let backfilled = await MainActor.run { - if claudeCredentialsChanged { - self.clearClaudeCredentialDerivedStateForCredentialSwapNow() - } - let backfilled = scoped.backfillingResetTimes(from: self.lastKnownResetSnapshots[provider]) - self.handleQuotaWarningTransitions(provider: provider, snapshot: backfilled) - self.handleSessionQuotaTransition(provider: provider, snapshot: backfilled) - self.lastKnownResetSnapshots[provider] = backfilled - self.snapshots[provider] = backfilled - if let tokenSnapshot = self.tokenSnapshot(fromProviderSnapshot: backfilled, provider: provider) { - self.tokenSnapshots[provider] = tokenSnapshot - self.tokenErrors[provider] = nil - self.tokenFailureGates[provider]?.recordSuccess() - } else if Self.tokenCostRequiresProviderSnapshot(provider) { - self.tokenSnapshots.removeValue(forKey: provider) - self.tokenErrors[provider] = nil - } - self.lastSourceLabels[provider] = result.sourceLabel - self.errors[provider] = nil - self.failureGates[provider]?.recordSuccess() - if provider == .codex { - self.rememberLiveSystemCodexEmailIfNeeded(scoped.accountEmail(for: .codex)) - self.seedCodexAccountScopedRefreshGuard(accountEmail: scoped.accountEmail(for: .codex)) - } - return backfilled - } - if shouldConsumeClaudeKeychainFingerprint { - _ = await Self.consumeClaudeKeychainFingerprintChangeWithoutPrompt() + self.lastFetchAttempts[provider] = attempts + let resetBackfillSource = if provider == .codex { + context.codexLimitResetOwnerKey == nil + ? nil + : self.codexLastKnownResetSnapshot(matching: context.codexExpectedGuard) + } else { + self.lastKnownResetSnapshots[provider] } - await self.recordPlanUtilizationHistorySample( + let profileStable = self.preservingDeepSeekProfileCatalog(in: accountScoped, provider: provider) + let stabilized = Self.commandCodeSnapshotResolvingDepletionOnEnrichmentFailure( + current: profileStable, + previous: self.snapshots[provider]) + let backfilled = stabilized.backfillingResetTimes(from: resetBackfillSource) + let warningAccountDiscriminator = Self.warningAccountDiscriminator( provider: provider, - snapshot: backfilled) - if let runtime = self.providerRuntimes[provider] { - let context = ProviderRuntimeContext( - provider: provider, settings: self.settings, store: self) - runtime.providerDidRefresh(context: context, provider: provider) - } + tokenAccount: currentTokenAccount, + result: result, + context: context) + self.handleQuotaWarningTransitions( + provider: provider, + snapshot: backfilled, + accountDiscriminator: warningAccountDiscriminator) + self.handleSessionQuotaTransition( + provider: provider, + snapshot: backfilled, + codexOwnerKey: provider == .codex ? context.codexSessionQuotaOwnerKey : nil) + self.handlePredictivePaceWarningTransitions( + provider: provider, + snapshot: backfilled, + accountDiscriminatorOverride: provider == .claude ? warningAccountDiscriminator : nil) if provider == .codex { - self.recordCodexHistoricalSampleIfNeeded(snapshot: backfilled) + self.handleCodexResetCreditNotifications(snapshot: backfilled) } - case let .failure(error): - if provider == .codex, - let codexExpectedGuard, - !self.shouldApplyCodexScopedFailure(expectedGuard: codexExpectedGuard) - { - return + self.lastKnownResetSnapshots[provider] = backfilled + self.snapshots[provider] = backfilled + self.widgetUsagePreservationBlockedProviders.remove(provider) + if provider == .deepseek { + self.clearDeepSeekProfileTransition() + } + if let tokenSnapshot = self.tokenSnapshot(fromProviderSnapshot: backfilled, provider: provider) { + self.publishTokenSnapshot(tokenSnapshot, for: provider) + self.tokenErrors[provider] = nil + self.tokenFailureGates[provider]?.recordSuccess() + } else if Self.tokenCostRequiresProviderSnapshot(provider) { + self.publishConfirmedEmptyTokenSnapshot(for: provider) + self.tokenErrors[provider] = nil } - if claudeCredentialsChanged { - await self.clearClaudeCredentialDerivedStateForCredentialSwap() + self.lastSourceLabels[provider] = result.sourceLabel + self.errors[provider] = nil + self.diagnostics[provider] = result.diagnostic + if let tokenAccount = currentTokenAccount { + self.cacheTokenAccountSnapshot( + provider: provider, + account: tokenAccount, + snapshot: backfilled, + sourceLabel: result.sourceLabel) + } + if provider == .gemini { + self.clearGeminiConsumerTierDeprecationObservation() } - if shouldConsumeClaudeKeychainFingerprint { - _ = await Self.consumeClaudeKeychainFingerprintChangeWithoutPrompt() + self.knownLimitsAvailabilityByProvider.removeValue(forKey: provider) + self.failureGates[provider]?.recordSuccess() + if provider == .codex { + self.rememberLiveSystemCodexEmailIfNeeded(scoped.accountEmail(for: .codex)) + self.seedCodexAccountScopedRefreshGuard(accountEmail: scoped.accountEmail(for: .codex)) + self.lastCodexUsagePublicationGuard = self.lastCodexAccountScopedRefreshGuard + self.persistSingleCodexAccountSnapshot( + backfilled, + sourceLabel: result.sourceLabel, + expectedGuard: context.codexExpectedGuard, + expectedOwnerKey: context.codexLimitResetOwnerKey) } - await self.handleProviderFetchFailure(provider: provider, error: error) + return backfilled + } + guard let backfilled else { return } + let isClaudeOAuthSample = provider == .claude + && result.strategyKind == .oauth + let claudeOAuthPersistentRefHash: String? = if isClaudeOAuthSample, + result.claudeOAuthKeychainPersistentRefHash == context + .claudeOAuthHistoryPersistentRefHash + { + result.claudeOAuthKeychainPersistentRefHash + } else { + nil + } + await self.recordPlanUtilizationHistorySample( + provider: provider, + snapshot: backfilled, + claudeOAuthPersistentRefHash: claudeOAuthPersistentRefHash, + claudeOAuthHistoryOwnerIdentifier: isClaudeOAuthSample + ? result.claudeOAuthHistoryOwnerIdentifier + : nil, + claudeOAuthKeychainCredentialMismatch: isClaudeOAuthSample + && result.claudeOAuthKeychainCredentialMismatch, + claudeOAuthKeychainCredentialAbsent: isClaudeOAuthSample + && result.claudeOAuthKeychainCredentialAbsent, + claudeOAuthKeychainCredentialUnavailable: isClaudeOAuthSample + && (result.claudeOAuthKeychainCredentialUnavailable + || (result.claudeOAuthKeychainPersistentRefHash != nil + && claudeOAuthPersistentRefHash == nil)), + claudeOAuthActiveAccountObservation: context.claudeOAuthActiveAccountObservation, + isClaudeOAuthSample: isClaudeOAuthSample, + codexLimitResetOwnerKey: context.codexLimitResetOwnerKey) + guard self.isCurrentProviderRefreshGeneration(provider, generation: context.generation) else { return } + if let runtime = self.providerRuntimes[provider] { + let runtimeContext = ProviderRuntimeContext( + provider: provider, settings: self.settings, store: self) + runtime.providerDidRefresh(context: runtimeContext, provider: provider) + } + if provider == .codex { + self.recordCodexHistoricalSampleIfNeeded(snapshot: backfilled) } } - private func clearDisabledProviderRefreshState(_ provider: UsageProvider) async { - self.refreshingProviders.remove(provider) - await MainActor.run { - self.snapshots.removeValue(forKey: provider) - self.lastKnownResetSnapshots.removeValue(forKey: provider) - self.errors[provider] = nil - self.lastSourceLabels.removeValue(forKey: provider) - self.lastFetchAttempts.removeValue(forKey: provider) - self.accountSnapshots.removeValue(forKey: provider) - if provider == .codex { - self.codexAccountSnapshots = [] - } - if provider == .kilo { - self.kiloScopeSnapshots = [] + private func applyProviderRefreshFailure( + provider: UsageProvider, + error: Error, + attempts: [ProviderFetchAttempt], + context: ProviderRefreshOutcomeContext) async + { + if provider == .codex, + let codexExpectedGuard = context.codexExpectedGuard, + !self.shouldApplyCodexScopedFailure(expectedGuard: codexExpectedGuard) + { + self.retireCodexStateIfRefreshOwnerChanged( + expectedGuard: codexExpectedGuard, + generation: context.generation) + return + } + // Credential-change cleanup already ran above; cancellation is now safe to suppress. + if Self.errorIsCancellation(error) { + if provider == .deepseek, + self.isCurrentProviderRefreshGeneration(provider, generation: context.generation) + { + self.markDeepSeekProfileTransitionUnavailable() } - self.tokenSnapshots.removeValue(forKey: provider) - self.tokenErrors[provider] = nil - self.failureGates[provider]?.reset() - self.tokenFailureGates[provider]?.reset() - self.statuses.removeValue(forKey: provider) - self.lastKnownSessionRemaining.removeValue(forKey: provider) - self.lastKnownSessionWindowSource.removeValue(forKey: provider) - self.quotaWarningState = self.quotaWarningState.filter { $0.key.provider != provider } - self.lastTokenFetchAt.removeValue(forKey: provider) + return + } + guard self.isCurrentProviderRefreshGeneration(provider, generation: context.generation) else { return } + if provider == .deepseek { + self.markDeepSeekProfileTransitionUnavailable() } + self.bindCodexFailurePublicationOwner( + provider: provider, + expectedGuard: context.codexExpectedGuard) + self.lastFetchAttempts[provider] = attempts + self.recordStartupConnectivityRetryableFailure(error) + await self.handleProviderFetchFailure( + provider: provider, + error: error, + attempts: attempts, + context: context) + } + + private func preservingDeepSeekProfileCatalog( + in snapshot: UsageSnapshot, + provider: UsageProvider) -> UsageSnapshot + { + guard provider == .deepseek else { return snapshot } + return snapshot.preservingDeepSeekPlatformProfiles(from: self.presentationSnapshot(for: .deepseek)) + } + + private func bindCodexFailurePublicationOwner( + provider: UsageProvider, + expectedGuard: CodexAccountScopedRefreshGuard?) + { + guard provider == .codex, let expectedGuard else { return } + self.lastCodexUsagePublicationGuard = expectedGuard + } + + private func retireCodexStateIfRefreshOwnerChanged( + expectedGuard: CodexAccountScopedRefreshGuard, + generation: UInt64) + { + guard self.isCurrentProviderRefreshGeneration(.codex, generation: generation) else { return } + let currentGuard = self.freshCodexAccountScopedRefreshGuard() + guard !Self.codexScopedRefreshGuardsMatchAccount(expectedGuard, currentGuard) else { return } + self.reconcileCodexAccountStateForUsageOwner(currentGuard) + } + + private nonisolated static func codexUsageWithExpectedEmailIfMissing( + provider: UsageProvider, + usage: UsageSnapshot, + expectedGuard: CodexAccountScopedRefreshGuard?) -> UsageSnapshot + { + guard provider == .codex, + CodexIdentityResolver.normalizeEmail(usage.accountEmail(for: .codex)) == nil, + let accountEmail = CodexIdentityResolver.normalizeEmail(expectedGuard?.accountKey) + else { + return usage + } + let identity = usage.identity(for: .codex) + return usage.withIdentity(ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: accountEmail, + accountOrganization: identity?.accountOrganization, + loginMethod: identity?.loginMethod)) + } + + private func persistSingleCodexAccountSnapshot( + _ snapshot: UsageSnapshot, + sourceLabel: String, + expectedGuard: CodexAccountScopedRefreshGuard?, + expectedOwnerKey: CodexLimitResetOwnerKey?) + { + guard let expectedGuard, + let expectedOwnerKey + else { return } + + let currentGuard = self.freshCodexAccountScopedRefreshGuard() + guard Self.codexScopedRefreshGuardsMatchAccount(expectedGuard, currentGuard), + let currentOwnerKey = CodexLimitResetOwnerKey( + identity: currentGuard.identity, + accountEmail: currentGuard.accountKey), + currentOwnerKey == expectedOwnerKey + else { return } + + let visibleAccounts = self.freshCodexVisibleAccountsForSnapshotHydration() + let activeMatches = visibleAccounts.filter { + $0.isActive && + $0.selectionSource == currentGuard.source && + CodexIdentityResolver.normalizeEmail($0.email) == currentGuard.accountKey + } + guard activeMatches.count == 1, + let account = activeMatches.first, + let snapshotEmail = CodexIdentityResolver.normalizeEmail(snapshot.accountEmail(for: .codex)), + snapshotEmail == CodexIdentityResolver.normalizeEmail(currentGuard.accountKey), + snapshotEmail == CodexIdentityResolver.normalizeEmail(account.email), + self.codexLimitResetOwnerKey( + forVisibleAccount: account, + visibleAccounts: visibleAccounts) == currentOwnerKey + else { return } + + let identity = snapshot.identity(for: .codex) + let relabeled = snapshot.withIdentity(ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: account.email, + accountOrganization: identity?.accountOrganization, + loginMethod: identity?.loginMethod ?? account.workspaceLabel)) + let currentSnapshots = [CodexAccountUsageSnapshot( + account: account, + snapshot: relabeled, + error: nil, + sourceLabel: sourceLabel)] + self.codexAccountSnapshots = currentSnapshots + self.codexAccountUsageSnapshotStore?.store(currentSnapshots) + } + + private func clearDisabledProviderRefreshState(_ provider: UsageProvider) async { + self.clearProviderRuntimeState(provider) } private struct ClaudeRefreshAuthState { let fingerprintToken: String let credentialsFileChanged: Bool let keychainFingerprintChanged: Bool + let keychainPersistentRefHash: String? + let activeAccountIdentity: String? + let accountStateWasStable: Bool + } + + private struct ClaudeHistoryAccountState { + let fingerprintToken: String + let keychainPersistentRefHash: String? + let activeAccountIdentity: String? + let wasStable: Bool } private nonisolated static func claudeCredentialsChanged( @@ -220,30 +818,128 @@ extension UsageStore { { await withTaskGroup(of: ClaudeRefreshAuthState.self, returning: ClaudeRefreshAuthState.self) { group in group.addTask { - let fingerprintToken = ClaudeOAuthCredentialsStore.authFingerprintToken() let credentialsFileChanged = invalidateCredentialsFile ? ClaudeOAuthCredentialsStore.invalidateCacheIfCredentialsFileChanged() : false let keychainFingerprintChanged = ClaudeOAuthCredentialsStore .claudeKeychainFingerprintChangedWithoutConsuming() + let fingerprintBefore = ClaudeOAuthCredentialsStore.authFingerprintToken() + let persistentRefBefore = ClaudeOAuthCredentialsStore + .claudeKeychainPersistentRefHashWithoutPrompt() + let activeAccountIdentity = Self.activeClaudeAccountIdentity() + let persistentRefAfter = ClaudeOAuthCredentialsStore + .claudeKeychainPersistentRefHashWithoutPrompt() + let fingerprintAfter = ClaudeOAuthCredentialsStore.authFingerprintToken() + let accountStateWasStable = fingerprintBefore == fingerprintAfter + && persistentRefBefore == persistentRefAfter return ClaudeRefreshAuthState( - fingerprintToken: fingerprintToken, + fingerprintToken: fingerprintAfter, credentialsFileChanged: credentialsFileChanged, - keychainFingerprintChanged: keychainFingerprintChanged) + keychainFingerprintChanged: keychainFingerprintChanged, + keychainPersistentRefHash: persistentRefAfter, + activeAccountIdentity: activeAccountIdentity, + accountStateWasStable: accountStateWasStable) } return await group.next()! } } - private nonisolated static func captureClaudeAuthFingerprintToken() async -> String { - await withTaskGroup(of: String.self, returning: String.self) { group in + private nonisolated static func captureClaudeHistoryAccountState() async -> ClaudeHistoryAccountState { + await withTaskGroup(of: ClaudeHistoryAccountState.self, returning: ClaudeHistoryAccountState.self) { group in group.addTask { - ClaudeOAuthCredentialsStore.authFingerprintToken() + let fingerprintBefore = ClaudeOAuthCredentialsStore.authFingerprintToken() + let persistentRefBefore = ClaudeOAuthCredentialsStore + .claudeKeychainPersistentRefHashWithoutPrompt() + let activeAccountIdentity = Self.activeClaudeAccountIdentity() + let persistentRefAfter = ClaudeOAuthCredentialsStore + .claudeKeychainPersistentRefHashWithoutPrompt() + let fingerprintAfter = ClaudeOAuthCredentialsStore.authFingerprintToken() + let wasStable = fingerprintBefore == fingerprintAfter && persistentRefBefore == persistentRefAfter + return ClaudeHistoryAccountState( + fingerprintToken: fingerprintAfter, + keychainPersistentRefHash: persistentRefAfter, + activeAccountIdentity: activeAccountIdentity, + wasStable: wasStable) } return await group.next()! } } + private nonisolated static func claudeOAuthActiveAccountObservation( + beforeFetch: ClaudeRefreshAuthState?, + afterFetch: ClaudeHistoryAccountState?) -> ClaudeOAuthActiveAccountObservation + { + guard let beforeFetch, + beforeFetch.accountStateWasStable, + let afterFetch, + afterFetch.wasStable, + beforeFetch.activeAccountIdentity == afterFetch.activeAccountIdentity + else { + return .changed + } + return .stable(identity: afterFetch.activeAccountIdentity) + } + + private nonisolated static func stableClaudeKeychainPersistentRefHash( + beforeFetch: ClaudeRefreshAuthState?, + afterFetchFingerprintToken: String?, + afterFetchPersistentRefHash: String?, + accountStateWasStable: Bool) -> String? + { + guard accountStateWasStable, + let beforeFetch, + beforeFetch.accountStateWasStable, + beforeFetch.fingerprintToken == afterFetchFingerprintToken, + let beforeFetchPersistentRefHash = beforeFetch.keychainPersistentRefHash, + beforeFetchPersistentRefHash == afterFetchPersistentRefHash + else { + return nil + } + return beforeFetchPersistentRefHash + } + + #if DEBUG + nonisolated static func _stableClaudeKeychainPersistentRefHashForTesting( + beforeFetchFingerprintToken: String, + afterFetchFingerprintToken: String, + beforeFetchPersistentRefHash: String?, + afterFetchPersistentRefHash: String?) -> String? + { + self.stableClaudeKeychainPersistentRefHash( + beforeFetch: ClaudeRefreshAuthState( + fingerprintToken: beforeFetchFingerprintToken, + credentialsFileChanged: false, + keychainFingerprintChanged: false, + keychainPersistentRefHash: beforeFetchPersistentRefHash, + activeAccountIdentity: nil, + accountStateWasStable: true), + afterFetchFingerprintToken: afterFetchFingerprintToken, + afterFetchPersistentRefHash: afterFetchPersistentRefHash, + accountStateWasStable: true) + } + + nonisolated static func _claudeOAuthActiveAccountObservationForTesting( + identityBeforeFetch: String?, + identityAfterFetch: String?, + beforeFetchWasStable: Bool = true, + afterFetchWasStable: Bool = true) -> ClaudeOAuthActiveAccountObservation + { + self.claudeOAuthActiveAccountObservation( + beforeFetch: ClaudeRefreshAuthState( + fingerprintToken: "before", + credentialsFileChanged: false, + keychainFingerprintChanged: false, + keychainPersistentRefHash: "before-ref", + activeAccountIdentity: identityBeforeFetch, + accountStateWasStable: beforeFetchWasStable), + afterFetch: ClaudeHistoryAccountState( + fingerprintToken: "after", + keychainPersistentRefHash: "after-ref", + activeAccountIdentity: identityAfterFetch, + wasStable: afterFetchWasStable)) + } + #endif + private nonisolated static func invalidateClaudeCredentialsFileCacheIfChanged() async -> Bool { await withTaskGroup(of: Bool.self, returning: Bool.self) { group in group.addTask { @@ -274,34 +970,129 @@ extension UsageStore { } private func clearClaudeCredentialDerivedStateForCredentialSwapNow() { + self.widgetUsagePreservationBlockedProviders.insert(.claude) self.snapshots.removeValue(forKey: .claude) self.lastKnownResetSnapshots.removeValue(forKey: .claude) self.errors[.claude] = nil + self.knownLimitsAvailabilityByProvider.removeValue(forKey: .claude) self.lastSourceLabels.removeValue(forKey: .claude) self.accountSnapshots.removeValue(forKey: .claude) - self.tokenSnapshots.removeValue(forKey: .claude) + self.clearTokenSnapshot(for: .claude) self.tokenErrors[.claude] = nil self.failureGates[.claude]?.reset() self.tokenFailureGates[.claude]?.reset() - self.lastKnownSessionRemaining.removeValue(forKey: .claude) - self.lastKnownSessionWindowSource.removeValue(forKey: .claude) + self.clearSessionQuotaTransitionState(provider: .claude) self.quotaWarningState = self.quotaWarningState.filter { $0.key.provider != .claude } self.lastTokenFetchAt.removeValue(forKey: .claude) } - private func handleProviderFetchFailure(provider: UsageProvider, error: Error) async { + private func handleProviderFetchFailure( + provider: UsageProvider, + error: Error, + attempts: [ProviderFetchAttempt], + context: ProviderRefreshOutcomeContext) async + { let shouldNotifyPermissionPrompt = Self.isPermissionPromptWaiting(error) await MainActor.run { + guard self.isCurrentProviderRefreshGeneration(provider, generation: context.generation) else { return } + self.diagnostics[provider] = nil + if provider == .gemini, Self.isGeminiConsumerTierDeprecationError(error) { + // This is a durable provider migration signal, not a transient fetch failure. + // Surface it immediately so a cached snapshot cannot hide the required handoff. + self.observeGeminiConsumerTierDeprecation(from: error) + self.errors[provider] = error.localizedDescription + self.snapshots.removeValue(forKey: provider) + self.lastKnownResetSnapshots.removeValue(forKey: provider) + self.knownLimitsAvailabilityByProvider.removeValue(forKey: provider) + self.lastSourceLabels.removeValue(forKey: provider) + self.failureGates[provider]?.reset() + return + } + if provider == .claude, + ClaudeUsageError.isClaudeOAuthUsageRateLimit(error) + { + if let (account, cached) = self.validatedClaudeOAuthTokenAccountFallback(context: context), + let snapshot = cached.snapshot + { + self.snapshots[provider] = snapshot + self.lastKnownResetSnapshots[provider] = snapshot + self.lastSourceLabels[provider] = "oauth" + self.cacheTokenAccountSnapshot( + provider: provider, + account: account, + snapshot: snapshot, + sourceLabel: "oauth") + self.errors[provider] = nil + self.failureGates[provider]?.reset() + return + } + // Credential-change cleanup runs before failure handling and removes all unscoped Claude state. + // A surviving OAuth snapshot therefore belongs to the credential observed across this refresh. + if context.tokenAccount == nil, + self.snapshots[provider] != nil, + self.lastSourceLabels[provider] == "oauth" + { + self.errors[provider] = nil + self.failureGates[provider]?.reset() + return + } + } + let hadKnownUnavailableLimits = self.knownLimitsAvailabilityByProvider[provider]?.isUnavailable == true + self.knownLimitsAvailabilityByProvider.removeValue(forKey: provider) + if provider == .claude, + ClaudeStatusProbe.isSubscriptionQuotaUnavailableDescription(error.localizedDescription) + { + // This is a successful answer about quota availability, not a transient probe failure. + // Drop prior limits immediately so an Education subscription notice cannot leave stale bars visible. + self.snapshots.removeValue(forKey: provider) + self.lastKnownResetSnapshots.removeValue(forKey: provider) + self.clearSessionQuotaTransitionState(provider: provider) + self.quotaWarningState = self.quotaWarningState.filter { $0.key.provider != provider } + self.lastSourceLabels.removeValue(forKey: provider) + self.errors[provider] = nil + self.knownLimitsAvailabilityByProvider[provider] = .unavailable + self.widgetUsagePreservationBlockedProviders.insert(provider) + self.failureGates[provider]?.reset() + return + } + if provider == .claude, + hadKnownUnavailableLimits, + Self.shouldPreservePriorSnapshot(after: error, hadPriorData: true) || + Self.isClaudeCLIRateLimitFailure(error) + { + self.errors[provider] = nil + self.knownLimitsAvailabilityByProvider[provider] = .unavailable + return + } let hadPriorData = self.snapshots[provider] != nil + let isTerminalClaudeCLIParseFailure = + provider == .claude && + hadPriorData && + Self.lastAvailableFailedFetchKind(from: attempts) == .cli && + Self.isClaudeCLIUsageParseFailure(error) let preservesPriorData = Self.shouldPreservePriorSnapshot( after: error, - hadPriorData: hadPriorData) + hadPriorData: hadPriorData) || + (provider == .claude && + hadPriorData && + (Self.isClaudeCLIRateLimitFailure(error) || + isTerminalClaudeCLIParseFailure)) 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) + Self.isClaudeUsageProbeTimeout(error) || Self.isClaudeCLIRateLimitFailure(error) { self.errors[provider] = nil return @@ -312,9 +1103,16 @@ extension UsageStore { } if shouldSurface { self.errors[provider] = error.localizedDescription - if !preservesPriorData { + if !preservesPriorData, !preservesClaudeWebSessionFailure { self.snapshots.removeValue(forKey: provider) + if Self.tokenCostRequiresProviderSnapshot(provider) { + self.clearTokenSnapshot(for: provider) + } } + self.emitHook( + .refreshFailed, + provider: provider, + status: Self.refreshFailureHookStatus(error)) } else { self.errors[provider] = nil } @@ -322,6 +1120,7 @@ extension UsageStore { self.postPermissionPromptNotificationIfNeeded(provider: provider, error: error) } } + guard self.isCurrentProviderRefreshGeneration(provider, generation: context.generation) else { return } if let runtime = self.providerRuntimes[provider] { let context = ProviderRuntimeContext( provider: provider, settings: self.settings, store: self) @@ -329,10 +1128,41 @@ extension UsageStore { } } + private func validatedClaudeOAuthTokenAccountFallback( + context: ProviderRefreshOutcomeContext) -> (ProviderTokenAccount, TokenAccountUsageSnapshot)? + { + guard let fetchedAccount = context.tokenAccount, + let cached = context.priorTokenAccountSnapshot, + cached.account.id == fetchedAccount.id, + cached.sourceLabel == "oauth", + cached.snapshot != nil, + let currentAccount = self.uniqueTokenAccount(provider: .claude, accountID: fetchedAccount.id), + cached.cacheKey == self.tokenAccountSnapshotCacheKey(provider: .claude, account: currentAccount) + else { + return nil + } + return (currentAccount, cached) + } + + private func tokenAccountSnapshot( + provider: UsageProvider, + account: ProviderTokenAccount?) -> TokenAccountUsageSnapshot? + { + guard let account else { return nil } + return self.accountSnapshots[provider]?.first { cached in + cached.account.id == account.id && + cached.cacheKey == self.tokenAccountSnapshotCacheKey(provider: provider, account: account) + } + } + private static func shouldPreservePriorSnapshot(after error: Error, hadPriorData: Bool) -> Bool { guard hadPriorData else { return false } - if error is CancellationError { return true } - if self.isPreservableNetworkTransportError(error) { return true } + if error is CancellationError { + return true + } + if self.isPreservableNetworkTransportError(error) { + return true + } let message = error.localizedDescription.lowercased() return message.contains("timed out") || @@ -342,6 +1172,12 @@ extension UsageStore { message.contains("not connected to the internet") } + private static func lastAvailableFailedFetchKind(from attempts: [ProviderFetchAttempt]) -> ProviderFetchKind? { + attempts.last { attempt in + attempt.wasAvailable && attempt.errorDescription != nil + }?.kind + } + static func isPreservableNetworkTransportError(_ error: Error) -> Bool { let nsError = error as NSError guard nsError.domain == NSURLErrorDomain else { return false } @@ -359,11 +1195,70 @@ 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 } + if case ClaudeStatusProbeError.timedOut = error { + return true + } return error.localizedDescription == ClaudeStatusProbeError.timedOut.localizedDescription } + private static func isClaudeCLIRateLimitFailure(_ error: Error) -> Bool { + ClaudeUsageFetcher.isCLIRateLimitError(error) + } + + private static func isClaudeCLIUsageParseFailure(_ error: Error) -> Bool { + if case let ClaudeStatusProbeError.parseFailed(message) = error { + return !ClaudeStatusProbe.isSubscriptionQuotaUnavailableDescription(message) + } + if case let ClaudeUsageError.parseFailed(message) = error { + return !ClaudeStatusProbe.isSubscriptionQuotaUnavailableDescription(message) + } + return false + } + + 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+RefreshEnrichment.swift b/Sources/CodexBar/UsageStore+RefreshEnrichment.swift new file mode 100644 index 0000000000..c755db1dcd --- /dev/null +++ b/Sources/CodexBar/UsageStore+RefreshEnrichment.swift @@ -0,0 +1,325 @@ +import CodexBarCore +import Foundation + +extension UsageStore { + enum RefreshEnrichmentMode: Equatable, Sendable { + case automatic + case forcedForeground + case forcedBackground + } + + struct RequiredRefreshRequest: Sendable { + var throughGeneration: UInt64 + var startupConnectivityRetryAttempt: Int? + var coalesceProviderRefreshes: Bool + var interaction: ProviderInteraction + + mutating func merge(_ newer: Self) { + self.throughGeneration = max(self.throughGeneration, newer.throughGeneration) + if let newerAttempt = newer.startupConnectivityRetryAttempt { + self.startupConnectivityRetryAttempt = max( + self.startupConnectivityRetryAttempt ?? newerAttempt, + newerAttempt) + } + // Replacement is the stronger policy: a settings change must not join work started + // with the old configuration merely because another required refresh arrived first. + self.coalesceProviderRefreshes = self.coalesceProviderRefreshes && newer.coalesceProviderRefreshes + if newer.interaction == .userInitiated { + self.interaction = .userInitiated + } + } + } + + func refresh(forceTokenUsage: Bool = false) async { + if forceTokenUsage { + await self.refresh(enrichmentMode: .forcedForeground) + } else { + await self.runRefresh( + startupConnectivityRetryAttempt: nil, + waitForRefreshAvailability: true) + } + } + + private struct ForcedRefreshEnrichmentRequest: Sendable { + let generation: UInt64 + let refreshStartedAt: Date + let openAIWebRefreshPhase: ProviderRefreshPhase + } + + func refresh(enrichmentMode: RefreshEnrichmentMode) async { + if enrichmentMode == .forcedForeground { + await self.cancelForcedRefreshEnrichmentAndWait() + } + await self.runRefresh( + enrichmentMode: enrichmentMode, + startupConnectivityRetryAttempt: nil) + } + + func enqueueRequiredRefresh( + startupConnectivityRetryAttempt: Int?, + coalesceProviderRefreshesOverride: Bool?) async -> Bool + { + self.requiredRefreshRequestGeneration &+= 1 + let interaction = ProviderInteractionContext.current + let request = RequiredRefreshRequest( + throughGeneration: self.requiredRefreshRequestGeneration, + startupConnectivityRetryAttempt: startupConnectivityRetryAttempt, + coalesceProviderRefreshes: coalesceProviderRefreshesOverride ?? (interaction == .background), + interaction: interaction) + if var pending = self.pendingRequiredRefreshRequest { + pending.merge(request) + self.pendingRequiredRefreshRequest = pending + } else { + self.pendingRequiredRefreshRequest = request + } + + if let task = self.requiredRefreshTask { + return await task.value + } + + let token = UUID() + self.requiredRefreshTaskToken = token + let task = Task { @MainActor [weak self] in + guard let self else { return false } + let didRefresh = await self.drainRequiredRefreshRequests() + self.completeRequiredRefreshTask(token: token) + return didRefresh + } + self.requiredRefreshTask = task + return await task.value + } + + func cancelRequiredRefresh() { + self.pendingRequiredRefreshRequest = nil + self.requiredRefreshTaskToken = nil + let task = self.requiredRefreshTask + self.requiredRefreshTask = nil + task?.cancel() + } + + private func drainRequiredRefreshRequests() async -> Bool { + var completedAnyRefresh = false + while !Task.isCancelled { + guard await self.waitForRequiredRefreshAvailability(), + let request = self.pendingRequiredRefreshRequest + else { + break + } + self.pendingRequiredRefreshRequest = nil + + let didRefresh = await ProviderInteractionContext.$current.withValue(request.interaction) { + await self.runRefresh( + startupConnectivityRetryAttempt: request.startupConnectivityRetryAttempt, + coalesceProviderRefreshesOverride: request.coalesceProviderRefreshes) + } + if didRefresh { + completedAnyRefresh = true + self.requiredRefreshCompletedGeneration = max( + self.requiredRefreshCompletedGeneration, + request.throughGeneration) + } else if !Task.isCancelled { + var retry = request + if let pending = self.pendingRequiredRefreshRequest { + retry.merge(pending) + } + self.pendingRequiredRefreshRequest = retry + } + } + return completedAnyRefresh + } + + private func waitForRequiredRefreshAvailability() async -> Bool { + while self.isRefreshing || self.hasForcedRefreshEnrichmentInFlight { + guard !Task.isCancelled else { return false } + if self.hasForcedRefreshEnrichmentInFlight { + await self.awaitForcedRefreshEnrichment() + } else { + do { + try await Task.sleep(for: .milliseconds(20)) + } catch { + return false + } + } + } + return !Task.isCancelled + } + + private func completeRequiredRefreshTask(token: UUID) { + guard self.requiredRefreshTaskToken == token else { return } + self.requiredRefreshTask = nil + self.requiredRefreshTaskToken = nil + } + + func enqueueForcedRefreshEnrichment( + generation: UInt64, + refreshStartedAt: Date, + openAIWebRefreshPhase: ProviderRefreshPhase) + { + let request = ForcedRefreshEnrichmentRequest( + generation: generation, + refreshStartedAt: refreshStartedAt, + openAIWebRefreshPhase: openAIWebRefreshPhase) + if let predecessor = self.forcedRefreshEnrichmentTask { + self.replacePendingForcedRefreshEnrichment(request, predecessor: predecessor) + } else { + self.startForcedRefreshEnrichment(request) + } + } + + func awaitForcedRefreshEnrichment() async { + var reportedWait = false + while !Task.isCancelled { + guard let task = self.pendingForcedRefreshEnrichmentTask ?? self.forcedRefreshEnrichmentTask else { + return + } + if !reportedWait { + self._test_forcedRefreshEnrichmentWaitObserver?() + reportedWait = true + } + await task.value + } + } + + func cancelForcedRefreshEnrichment() { + _ = self.cancelForcedRefreshEnrichmentTasks() + } + + private func cancelForcedRefreshEnrichmentAndWait() async { + let tasks = self.cancelForcedRefreshEnrichmentTasks() + for task in tasks { + await task.value + } + } + + private func startForcedRefreshEnrichment(_ request: ForcedRefreshEnrichmentRequest) { + let token = UUID() + self.forcedRefreshEnrichmentToken = token + self.hasForcedRefreshEnrichmentInFlight = true + self.forcedRefreshEnrichmentTask = Task(priority: .utility) { @MainActor [weak self] in + guard let self else { return } + await self.runForcedRefreshEnrichment(request) + self.completeForcedRefreshEnrichment(token: token) + } + } + + private func replacePendingForcedRefreshEnrichment( + _ request: ForcedRefreshEnrichmentRequest, + predecessor: Task) + { + self.pendingForcedRefreshEnrichmentTask?.cancel() + let token = UUID() + self.pendingForcedRefreshEnrichmentToken = token + self.hasForcedRefreshEnrichmentInFlight = true + self.pendingForcedRefreshEnrichmentTask = Task(priority: .utility) { @MainActor [weak self] in + await predecessor.value + guard !Task.isCancelled, + let self, + self.pendingForcedRefreshEnrichmentToken == token, + let promotedTask = self.pendingForcedRefreshEnrichmentTask + else { return } + + self.pendingForcedRefreshEnrichmentTask = nil + self.pendingForcedRefreshEnrichmentToken = nil + self.forcedRefreshEnrichmentTask = promotedTask + self.forcedRefreshEnrichmentToken = token + await self.runForcedRefreshEnrichment(request) + self.completeForcedRefreshEnrichment(token: token) + } + } + + private func completeForcedRefreshEnrichment(token: UUID) { + guard self.forcedRefreshEnrichmentToken == token else { return } + // Keep the completed predecessor installed until its latest pending waiter promotes itself. + // This avoids an actor-reentrancy gap where a new request could otherwise start beside it. + guard self.pendingForcedRefreshEnrichmentTask == nil else { return } + self.forcedRefreshEnrichmentTask = nil + self.forcedRefreshEnrichmentToken = nil + self.hasForcedRefreshEnrichmentInFlight = false + } + + private func cancelForcedRefreshEnrichmentTasks() -> [Task] { + let tasks = [ + self.forcedRefreshEnrichmentTask, + self.pendingForcedRefreshEnrichmentTask, + self.openAIDashboardBackgroundRefreshTask, + self.openAIDashboardRefreshTask, + ].compactMap(\.self) + + self.forcedRefreshEnrichmentTask = nil + self.forcedRefreshEnrichmentToken = nil + self.pendingForcedRefreshEnrichmentTask = nil + self.pendingForcedRefreshEnrichmentToken = nil + self.hasForcedRefreshEnrichmentInFlight = false + tasks.forEach { $0.cancel() } + self.invalidateOpenAIDashboardRefreshTask() + return tasks + } + + private func runForcedRefreshEnrichment(_ request: ForcedRefreshEnrichmentRequest) async { + await withTaskGroup(of: Void.self) { group in + group.addTask { + await self.refreshCreditsNow(minimumSnapshotUpdatedAt: request.refreshStartedAt) + } + group.addTask { + await self.refreshTokenUsageSequenceNow(force: true) + } + } + guard !Task.isCancelled else { return } + + await self.refreshOpenAIWebAfterProviderRefresh( + force: true, + refreshPhase: request.openAIWebRefreshPhase) + guard !Task.isCancelled else { return } + + if self.openAIDashboardRequiresLogin, + request.generation == self.forcedRefreshEnrichmentGeneration + { + // Join a newer in-flight Codex request rather than replacing it. A newer accepted all-provider + // pass owns reconciliation even before it can enqueue its tail, so recheck generation afterward. + await self.refreshProvider(.codex, coalesceIfRefreshing: true) + guard !Task.isCancelled else { return } + if request.generation == self.forcedRefreshEnrichmentGeneration { + await self.refreshCreditsNow(minimumSnapshotUpdatedAt: request.refreshStartedAt) + guard !Task.isCancelled else { return } + } + } + + self.persistWidgetSnapshot(reason: "forced-refresh-enrichment") + } + + func refreshOpenAIWebAfterProviderRefresh( + force: Bool, + refreshPhase: ProviderRefreshPhase) async + { + self.syncOpenAIWebState() + let refreshPolicy = OpenAIWebRefreshPolicyContext( + accessEnabled: self.isEnabled(.codex) && + self.settings.openAIWebAccessEnabled && + self.settings.codexCookieSource.isEnabled, + batterySaverEnabled: self.settings.openAIWebBatterySaverEnabled, + force: force, + refreshPhase: refreshPhase) + let shouldRefreshOpenAIWeb = Self.shouldRunOpenAIWebRefresh(refreshPolicy) + self.openAIWebLogger.debug( + "OpenAI web refresh gate", + metadata: [ + "allowed": shouldRefreshOpenAIWeb ? "1" : "0", + "accessEnabled": refreshPolicy.accessEnabled ? "1" : "0", + "batterySaverEnabled": refreshPolicy.batterySaverEnabled ? "1" : "0", + "force": refreshPolicy.force ? "1" : "0", + "interaction": ProviderInteractionContext.current == .userInitiated ? "user" : "background", + "phase": refreshPhase == .startup ? "startup" : "regular", + ]) + guard shouldRefreshOpenAIWeb, !Task.isCancelled else { return } + + let codexDashboardGuard = self.freshCodexOpenAIWebRefreshGuard() + if force { + await self.refreshOpenAIDashboardIfNeeded( + force: true, + expectedGuard: codexDashboardGuard, + bypassCoalescing: true) + } else { + self.scheduleOpenAIDashboardRefreshIfNeeded(expectedGuard: codexDashboardGuard) + } + } +} diff --git a/Sources/CodexBar/UsageStore+ResetBoundaryRefresh.swift b/Sources/CodexBar/UsageStore+ResetBoundaryRefresh.swift new file mode 100644 index 0000000000..bd447f88ce --- /dev/null +++ b/Sources/CodexBar/UsageStore+ResetBoundaryRefresh.swift @@ -0,0 +1,133 @@ +import CodexBarCore +import Foundation + +extension UsageStore { + private struct ResetBoundaryRefreshCandidate { + var refreshAt: Date + var boundaryRefreshAt: Date + } + + func scheduleResetBoundaryRefreshIfNeeded( + normalRefreshInterval: TimeInterval?, + now: Date = Date()) + { + guard let candidate = Self.nextResetBoundaryRefreshCandidate( + snapshots: self.snapshots, + normalRefreshInterval: normalRefreshInterval, + attemptedBoundaryRefreshes: self.attemptedResetBoundaryRefreshes, + now: now) + else { + self.cancelResetBoundaryRefresh() + return + } + + let refreshAt = candidate.refreshAt + if let scheduledResetBoundaryRefreshAt, + abs(scheduledResetBoundaryRefreshAt.timeIntervalSince(refreshAt)) < 1 + { + return + } + + self.cancelResetBoundaryRefresh() + self.scheduledResetBoundaryRefreshAt = refreshAt + self.resetBoundaryRefreshTask = Task.detached(priority: .utility) { [weak self] in + let delay = max(0, refreshAt.timeIntervalSince(Date())) + try? await Task.sleep(for: .seconds(delay)) + guard !Task.isCancelled else { return } + await self?.runResetBoundaryRefresh(boundaryRefreshAt: candidate.boundaryRefreshAt) + } + } + + func runResetBoundaryRefresh(boundaryRefreshAt: Date) async { + self.resetBoundaryRefreshTask = nil + self.scheduledResetBoundaryRefreshAt = nil + guard Self.shouldRecordResetBoundaryAttempt(isRefreshing: self.isRefreshing) else { return } + // Mark the boundary before the pass so runRefresh cannot schedule the same stale boundary again. + self.recordAttemptedResetBoundaryRefresh(boundaryRefreshAt) + await self.runRefresh( + startupConnectivityRetryAttempt: nil, + waitForRefreshAvailability: true) + } + + private func recordAttemptedResetBoundaryRefresh(_ refreshAt: Date) { + self.attemptedResetBoundaryRefreshes.insert(refreshAt) + if self.attemptedResetBoundaryRefreshes.count > 64, + let oldest = self.attemptedResetBoundaryRefreshes.min() + { + self.attemptedResetBoundaryRefreshes.remove(oldest) + } + } + + func cancelResetBoundaryRefresh() { + self.resetBoundaryRefreshTask?.cancel() + self.resetBoundaryRefreshTask = nil + self.scheduledResetBoundaryRefreshAt = nil + } + + nonisolated static func nextResetBoundaryRefreshDate( + snapshots: [UsageProvider: UsageSnapshot], + normalRefreshInterval: TimeInterval?, + attemptedBoundaryRefreshes: Set = [], + now: Date) + -> Date? + { + self.nextResetBoundaryRefreshCandidate( + snapshots: snapshots, + normalRefreshInterval: normalRefreshInterval, + attemptedBoundaryRefreshes: attemptedBoundaryRefreshes, + now: now)? + .refreshAt + } + + nonisolated static func shouldRecordResetBoundaryAttempt(isRefreshing: Bool) -> Bool { + !isRefreshing + } + + private nonisolated static func nextResetBoundaryRefreshCandidate( + snapshots: [UsageProvider: UsageSnapshot], + normalRefreshInterval: TimeInterval?, + attemptedBoundaryRefreshes: Set = [], + now: Date) + -> ResetBoundaryRefreshCandidate? + { + guard let normalRefreshInterval else { return nil } + let normalRefreshDate = now.addingTimeInterval(normalRefreshInterval) + return snapshots.values + .flatMap { snapshot in + Self.resetBoundaryRefreshCandidates( + snapshot: snapshot, + now: now, + normalRefreshDate: normalRefreshDate, + attemptedBoundaryRefreshes: attemptedBoundaryRefreshes) + } + .min { $0.refreshAt < $1.refreshAt } + } + + private nonisolated static func resetBoundaryRefreshCandidates( + snapshot: UsageSnapshot, + now: Date, + normalRefreshDate: Date, + attemptedBoundaryRefreshes: Set) + -> [ResetBoundaryRefreshCandidate] + { + snapshot.allRateWindows().compactMap { window in + guard let resetsAt = window.resetsAt else { return nil } + let boundaryRefreshAt = resetsAt.addingTimeInterval(Self.resetBoundaryRefreshGraceSeconds) + guard !attemptedBoundaryRefreshes.contains(boundaryRefreshAt) else { return nil } + guard boundaryRefreshAt <= normalRefreshDate else { return nil } + guard snapshot.updatedAt < boundaryRefreshAt else { return nil } + return ResetBoundaryRefreshCandidate( + refreshAt: max( + boundaryRefreshAt, + now.addingTimeInterval(Self.resetBoundaryRefreshMinimumDelaySeconds)), + boundaryRefreshAt: boundaryRefreshAt) + } + } +} + +extension UsageSnapshot { + fileprivate func allRateWindows() -> [RateWindow] { + [self.primary, self.secondary, self.tertiary].compactMap(\.self) + + (self.extraRateWindows?.map(\.window) ?? []) + } +} diff --git a/Sources/CodexBar/UsageStore+SessionEquivalents.swift b/Sources/CodexBar/UsageStore+SessionEquivalents.swift new file mode 100644 index 0000000000..dad24811e8 --- /dev/null +++ b/Sources/CodexBar/UsageStore+SessionEquivalents.swift @@ -0,0 +1,422 @@ +import CodexBarCore +import Foundation + +enum SessionEquivalentWindowPairResolution { + case resolved( + session: RateWindow, + weekly: RateWindow, + weeklyWindowID: String?, + historyIdentity: String) + case incomplete + case ambiguous + + var isAmbiguous: Bool { + if case .ambiguous = self { + return true + } + return false + } +} + +struct SessionEquivalentWindowComponent { + let window: RateWindow + let namedID: String? + let historyIdentity: String +} + +extension UsageStore { + nonisolated static let legacySessionEquivalentHistoryIdentityDefaultsKey = + "SessionEquivalentHistoryWindowPairsV2" + private nonisolated static let unresolvedSessionEquivalentComponentIdentity = "__unresolved__" + + func planUtilizationWeeklyWindow(provider: UsageProvider, snapshot: UsageSnapshot) -> RateWindow? { + if provider == .antigravity { + let namedWeeklyWindows = snapshot.extraRateWindows? + .filter { + $0.usageKnown + && $0.id.hasPrefix("antigravity-quota-summary-") + && $0.window.windowMinutes == Self.weeklyWindowMinutes + } + .map(\.window) ?? [] + if let mostUsedWeeklyWindow = namedWeeklyWindows.max(by: { $0.usedPercent < $1.usedPercent }) { + return mostUsedWeeklyWindow + } + + let legacyWeeklyWindows = [snapshot.primary, snapshot.secondary, snapshot.tertiary] + .compactMap(\.self) + .filter { $0.windowMinutes == Self.weeklyWindowMinutes } + + (snapshot.extraRateWindows? + .filter { $0.usageKnown && $0.window.windowMinutes == Self.weeklyWindowMinutes } + .map(\.window) ?? []) + return legacyWeeklyWindows.max(by: { $0.usedPercent < $1.usedPercent }) + } + + let standardWeeklyWindow = [snapshot.primary, snapshot.secondary, snapshot.tertiary] + .compactMap(\.self) + .first { $0.windowMinutes == Self.weeklyWindowMinutes } + let extraWeeklyWindow = snapshot.extraRateWindows? + .lazy + .first { $0.usageKnown && $0.window.windowMinutes == Self.weeklyWindowMinutes }? + .window + return standardWeeklyWindow ?? extraWeeklyWindow + } + + func sessionEquivalentWindows(provider: UsageProvider, snapshot: UsageSnapshot) + -> (session: RateWindow, weekly: RateWindow, weeklyWindowID: String?, historyIdentity: String?)? + { + if provider == .antigravity { + return Self.antigravitySessionEquivalentWindows(snapshot: snapshot) + } + if provider == .claude { + guard let session = snapshot.primary, + session.windowMinutes.map({ PlanUtilizationSeriesName.session.canonicalWindowMinutes($0) }) + == Self.sessionWindowMinutes, + let weekly = snapshot.secondary, + weekly.windowMinutes.map({ PlanUtilizationSeriesName.weekly.canonicalWindowMinutes($0) }) + == Self.weeklyWindowMinutes + else { + return nil + } + return (session, weekly, nil, nil) + } + guard case let .resolved(session, weekly, weeklyWindowID, historyIdentity) = + Self.genericSessionEquivalentWindowPairResolution(snapshot: snapshot) + else { + return nil + } + return (session, weekly, weeklyWindowID, historyIdentity) + } + + nonisolated static func genericSessionEquivalentWindowPairResolution(snapshot: UsageSnapshot) + -> SessionEquivalentWindowPairResolution + { + let session = Self.sessionEquivalentWindowResolution( + snapshot: snapshot, + windowMinutes: Self.sessionWindowMinutes) + let weekly = Self.sessionEquivalentWindowResolution( + snapshot: snapshot, + windowMinutes: Self.weeklyWindowMinutes) + if session.isAmbiguous || weekly.isAmbiguous { + return .ambiguous + } + guard case let .resolved(sessionWindow, _, sessionIdentity) = session, + case let .resolved(weeklyWindow, weeklyNamedID, weeklyIdentity) = weekly + else { + return .incomplete + } + guard Self.hasCanonicalSessionEquivalentRelationship( + sessionIdentity: sessionIdentity, + weeklyIdentity: weeklyIdentity) + else { + return .ambiguous + } + return .resolved( + session: sessionWindow, + weekly: weeklyWindow, + weeklyWindowID: weeklyNamedID, + historyIdentity: Self.sessionEquivalentPairIdentity( + session: sessionIdentity, + weekly: weeklyIdentity)) + } + + nonisolated static func genericSessionEquivalentWindowComponents(snapshot: UsageSnapshot) + -> (session: SessionEquivalentWindowComponent?, weekly: SessionEquivalentWindowComponent?) + { + func component(_ resolution: SessionEquivalentWindowResolution) -> SessionEquivalentWindowComponent? { + guard case let .resolved(window, namedID, identity) = resolution else { return nil } + return SessionEquivalentWindowComponent(window: window, namedID: namedID, historyIdentity: identity) + } + + return ( + session: component(Self.sessionEquivalentWindowResolution( + snapshot: snapshot, + windowMinutes: Self.sessionWindowMinutes)), + weekly: component(Self.sessionEquivalentWindowResolution( + snapshot: snapshot, + windowMinutes: Self.weeklyWindowMinutes))) + } + + nonisolated static func sessionEquivalentPairComponents(from identity: String) + -> (session: String, weekly: String)? + { + let bytes = Array(identity.utf8) + var offset = 0 + + func parseComponent() -> String? { + let lengthStart = offset + while offset < bytes.count, bytes[offset] >= 48, bytes[offset] <= 57 { + offset += 1 + } + guard offset > lengthStart, + offset < bytes.count, + bytes[offset] == 35, + let lengthText = String(bytes: bytes[lengthStart..= 0, length <= bytes.count - offset else { return nil } + let endOffset = offset + length + let componentBytes = bytes[offset.. Bool + { + guard ![UsageProvider.codex, .claude, .antigravity].contains(provider) else { return true } + guard let historyIdentity else { return false } + let persistedIdentity = self.planUtilizationHistory[provider]? + .sessionEquivalentWindowPairIdentity(for: accountKey) + return (persistedIdentity ?? self.legacySessionEquivalentHistoryIdentity( + provider: provider, + accountKey: accountKey)) == historyIdentity + } + + func legacySessionEquivalentHistoryIdentity(provider: UsageProvider, accountKey: String?) -> String? { + let identityKey = "\(provider.rawValue)|\(accountKey ?? Self.planUtilizationUnscopedPreferredKey)" + let identities = self.settings.userDefaults.dictionary( + forKey: Self.legacySessionEquivalentHistoryIdentityDefaultsKey) as? [String: String] + return identities?[identityKey] + } + + func reconcileGenericSessionEquivalentHistory( + scope: (provider: UsageProvider, accountKey: String?), + snapshot: UsageSnapshot, + providerBuckets: inout PlanUtilizationHistoryBuckets, + histories: inout [PlanUtilizationSeriesHistory], + samples: inout [PlanUtilizationSeriesSample]) + { + var previousIdentity = self.genericSessionEquivalentPreviousIdentity( + provider: scope.provider, + accountKey: scope.accountKey, + providerBuckets: &providerBuckets) + switch Self.genericSessionEquivalentWindowPairResolution(snapshot: snapshot) { + case let .resolved(_, _, _, resolvedIdentity): + Self.reconcileResolvedGenericSessionEquivalentIdentity( + previousIdentity: previousIdentity, + resolvedIdentity: resolvedIdentity, + accountKey: scope.accountKey, + providerBuckets: &providerBuckets, + histories: &histories) + case .incomplete: + let currentWeeklyIdentity = Self.genericSessionEquivalentWindowComponents(snapshot: snapshot) + .weekly?.historyIdentity + if previousIdentity == nil, let currentWeeklyIdentity { + previousIdentity = Self.sessionEquivalentPairIdentity( + session: Self.unresolvedSessionEquivalentComponentIdentity, + weekly: currentWeeklyIdentity) + providerBuckets.setSessionEquivalentWindowPairIdentity(previousIdentity, for: scope.accountKey) + } + let previousComponents = previousIdentity.flatMap(Self.sessionEquivalentPairComponents(from:)) + if previousComponents?.session == Self.unresolvedSessionEquivalentComponentIdentity, + previousComponents?.weekly == currentWeeklyIdentity + { + samples.removeAll { $0.name == .session } + } else if previousIdentity != nil { + samples.removeAll { $0.name == .session || $0.name == .weekly } + } + case .ambiguous: + let currentWeeklyIdentity = Self.genericSessionEquivalentWindowComponents(snapshot: snapshot) + .weekly?.historyIdentity + if previousIdentity == nil, let currentWeeklyIdentity { + previousIdentity = Self.sessionEquivalentPairIdentity( + session: Self.unresolvedSessionEquivalentComponentIdentity, + weekly: currentWeeklyIdentity) + providerBuckets.setSessionEquivalentWindowPairIdentity(previousIdentity, for: scope.accountKey) + } + Self.reconcileAmbiguousGenericSessionEquivalentSamples( + previousIdentity: previousIdentity, + snapshot: snapshot, + samples: &samples) + } + } + + private func genericSessionEquivalentPreviousIdentity( + provider: UsageProvider, + accountKey: String?, + providerBuckets: inout PlanUtilizationHistoryBuckets) -> String? + { + let persistedIdentity = providerBuckets.sessionEquivalentWindowPairIdentity(for: accountKey) + let previousIdentity = persistedIdentity ?? self.legacySessionEquivalentHistoryIdentity( + provider: provider, + accountKey: accountKey) + if persistedIdentity == nil, let previousIdentity { + providerBuckets.setSessionEquivalentWindowPairIdentity(previousIdentity, for: accountKey) + } + return previousIdentity + } + + private nonisolated static func reconcileResolvedGenericSessionEquivalentIdentity( + previousIdentity: String?, + resolvedIdentity: String, + accountKey: String?, + providerBuckets: inout PlanUtilizationHistoryBuckets, + histories: inout [PlanUtilizationSeriesHistory]) + { + guard previousIdentity != resolvedIdentity else { return } + if let previousIdentity, + let previousComponents = sessionEquivalentPairComponents(from: previousIdentity), + let resolvedComponents = sessionEquivalentPairComponents(from: resolvedIdentity) + { + histories.removeAll { + ($0.name == .session && previousComponents.session != resolvedComponents.session) + || ($0.name == .weekly && previousComponents.weekly != resolvedComponents.weekly) + } + } else if previousIdentity != nil { + histories.removeAll { $0.name == .session || $0.name == .weekly } + } else { + histories.removeAll { $0.name == .session } + } + providerBuckets.setSessionEquivalentWindowPairIdentity(resolvedIdentity, for: accountKey) + } + + private nonisolated static func reconcileAmbiguousGenericSessionEquivalentSamples( + previousIdentity: String?, + snapshot: UsageSnapshot, + samples: inout [PlanUtilizationSeriesSample]) + { + let currentWeeklyIdentity = Self.genericSessionEquivalentWindowComponents(snapshot: snapshot) + .weekly?.historyIdentity + let previousWeeklyIdentity = previousIdentity.flatMap { + Self.sessionEquivalentPairComponents(from: $0)?.weekly + } + samples.removeAll { sample in + if sample.name == .session { + return true + } + if sample.name == .weekly, previousIdentity != nil { + return previousWeeklyIdentity == nil || previousWeeklyIdentity != currentWeeklyIdentity + } + return false + } + } + + func planUtilizationSessionWindow(provider: UsageProvider, snapshot: UsageSnapshot) -> RateWindow? { + let standardSessionWindow = [snapshot.primary, snapshot.secondary, snapshot.tertiary] + .compactMap(\.self) + .first { $0.windowMinutes == Self.sessionWindowMinutes } + let extraSessionWindow = snapshot.extraRateWindows? + .lazy + .first { $0.usageKnown && $0.window.windowMinutes == Self.sessionWindowMinutes }? + .window + return standardSessionWindow + ?? self.sessionQuotaWindow(provider: provider, snapshot: snapshot)?.window + ?? extraSessionWindow + } + + private nonisolated static func antigravitySessionEquivalentWindows(snapshot: UsageSnapshot) + -> (session: RateWindow, weekly: RateWindow, weeklyWindowID: String?, historyIdentity: String?)? + { + let namedWindows = snapshot.extraRateWindows? + .filter { $0.usageKnown && $0.id.hasPrefix("antigravity-quota-summary-") } ?? [] + let grouped = Dictionary(grouping: namedWindows) { window in + Self.antigravityQuotaFamilyKey(window.id) + } + let completeGeminiFamilies: [(session: NamedRateWindow, weekly: NamedRateWindow)] = grouped.keys + .filter { $0 == "gemini" }.compactMap { family in + guard let windows = grouped[family] else { return nil } + let sessions = windows.filter { $0.window.windowMinutes == Self.sessionWindowMinutes } + let weeklies = windows.filter { $0.window.windowMinutes == Self.weeklyWindowMinutes } + guard sessions.count == 1, weeklies.count == 1 else { return nil } + return (session: sessions[0], weekly: weeklies[0]) + } + guard completeGeminiFamilies.count == 1, let pair = completeGeminiFamilies.first else { return nil } + return (pair.session.window, pair.weekly.window, pair.weekly.id, nil) + } + + private enum SessionEquivalentWindowResolution { + case resolved(window: RateWindow, namedID: String?, identity: String) + case incomplete + case ambiguous + + var isAmbiguous: Bool { + if case .ambiguous = self { + return true + } + return false + } + } + + private nonisolated static func sessionEquivalentWindowResolution( + snapshot: UsageSnapshot, + windowMinutes: Int) -> SessionEquivalentWindowResolution + { + let standardCandidates: [(window: RateWindow, identity: String)] = [ + snapshot.primary.map { ($0, "standard:primary") }, + snapshot.secondary.map { ($0, "standard:secondary") }, + snapshot.tertiary.map { ($0, "standard:tertiary") }, + ].compactMap(\.self).filter { $0.window.windowMinutes == windowMinutes } + if standardCandidates.count == 1, let candidate = standardCandidates.first { + return .resolved(window: candidate.window, namedID: nil, identity: candidate.identity) + } + guard standardCandidates.isEmpty else { return .ambiguous } + + let namedCandidates = snapshot.extraRateWindows?.filter { + $0.window.windowMinutes == windowMinutes + } ?? [] + guard namedCandidates.count <= 1 else { return .ambiguous } + guard let candidate = namedCandidates.first, candidate.usageKnown else { return .incomplete } + return .resolved(window: candidate.window, namedID: candidate.id, identity: "named:\(candidate.id)") + } + + private nonisolated static func sessionEquivalentPairIdentity(session: String, weekly: String) -> String { + "\(session.utf8.count)#\(session)\(weekly.utf8.count)#\(weekly)" + } + + private nonisolated static func hasCanonicalSessionEquivalentRelationship( + sessionIdentity: String, + weeklyIdentity: String) -> Bool + { + if sessionIdentity.hasPrefix("standard:"), weeklyIdentity.hasPrefix("standard:") { + return true + } + guard sessionIdentity.hasPrefix("named:"), weeklyIdentity.hasPrefix("named:") else { return false } + let sessionID = String(sessionIdentity.dropFirst("named:".count)) + let weeklyID = String(weeklyIdentity.dropFirst("named:".count)) + guard let sessionFamily = Self.sessionEquivalentFamily( + id: sessionID, + suffixes: ["-session", "_session", " session", "-5h", "_5h", " 5h"]), + let weeklyFamily = Self.sessionEquivalentFamily( + id: weeklyID, + suffixes: ["-weekly", "_weekly", " weekly"]) + else { + return false + } + return sessionFamily == weeklyFamily + } + + private nonisolated static func sessionEquivalentFamily(id: String, suffixes: [String]) -> String? { + let normalized = id.lowercased() + guard let suffix = suffixes.first(where: { normalized.hasSuffix($0) }) else { return nil } + let family = normalized.dropLast(suffix.count) + return family.isEmpty ? nil : String(family) + } + + private nonisolated static func antigravityQuotaFamilyKey(_ id: String) -> String { + var key = String(id.dropFirst("antigravity-quota-summary-".count)).lowercased() + let suffixes = [ + "-5h limit", "_5h_limit", "-weekly", "_weekly", " weekly", + "-session", "_session", " session", "-5h", "_5h", " 5h", + ] + if let suffix = suffixes.first(where: { key.hasSuffix($0) }) { + key.removeLast(suffix.count) + } else if ["weekly", "session", "5h"].contains(key) { + key = "" + } + return key + } +} diff --git a/Sources/CodexBar/UsageStore+SessionQuotaTransition.swift b/Sources/CodexBar/UsageStore+SessionQuotaTransition.swift new file mode 100644 index 0000000000..77d0d5bb5c --- /dev/null +++ b/Sources/CodexBar/UsageStore+SessionQuotaTransition.swift @@ -0,0 +1,147 @@ +import CodexBarCore +import Foundation + +@MainActor +extension UsageStore { + func handleSessionQuotaTransition( + provider: UsageProvider, + snapshot: UsageSnapshot, + codexOwnerKey: CodexSessionQuotaOwnerKey? = nil, + now: Date = Date()) + { + // Session quota notifications are tied to the primary session window. Copilot free plans can + // expose only chat quota, so allow Copilot to fall back to secondary for transition tracking. + // Command Code synthesizes a depleted primary while subscription enrichment is unavailable. + // Preserve the prior notification state for that placeholder, but accept positive credit data. + if provider == .commandcode, + snapshot.commandCodeSubscriptionEnrichmentUnavailable, + SessionQuotaNotificationLogic.isDepleted(snapshot.primary?.remainingPercent) + { + return + } + // Hooks have their own enable switch, so a configured quota_reached hook must fire on a + // real depletion even when session quota notifications are off. Run transition detection + // whenever notifications OR a matching hook rule is active; gate the OS notification post + // on the notification setting, but emit the hook on any depletion. + let notificationsEnabled = self.settings.sessionQuotaNotificationsEnabled + let hooksActive = self.hasQuotaHookRule(event: .quotaReached, provider: provider) + let detectionEnabled = notificationsEnabled || hooksActive + if provider == .codex, !detectionEnabled { + self.requireFreshCodexSessionQuotaBaseline(observedAt: snapshot.updatedAt) + self.sessionQuotaLogger.debug("Codex session notifications disabled; cleared notification baseline") + return + } + if provider == .codex, codexOwnerKey == nil { + self.requireFreshCodexSessionQuotaBaseline(observedAt: snapshot.updatedAt) + self.sessionQuotaLogger.debug("missing Codex session owner; cleared notification baseline") + return + } + guard let sessionWindow = self.sessionQuotaWindow(provider: provider, snapshot: snapshot) else { + if provider == .commandcode, snapshot.commandCodeSubscriptionEnrichmentUnavailable { + return + } + if provider == .codex { + if let previous = self.sessionQuotaTransitionStates[.codex] { + if previous.codexOwnerKey != codexOwnerKey { + self.requireFreshCodexSessionQuotaBaseline(observedAt: snapshot.updatedAt) + } else { + self.sessionQuotaTransitionStates[.codex] = previous.advancingObservationWatermark( + to: snapshot.updatedAt) + } + } else if self.codexSessionQuotaBaselineRequirement != nil { + self.requireFreshCodexSessionQuotaBaseline(observedAt: snapshot.updatedAt) + } + self.sessionQuotaLogger.debug("missing Codex session window; retained notification baseline") + } else { + self.clearSessionQuotaTransitionState(provider: provider) + } + return + } + guard !sessionWindow.window.isSyntheticPlaceholder else { return } + let currentRemaining = sessionWindow.window.remainingPercent + let currentSource = sessionWindow.source + let currentResetBoundary = sessionWindow.window.resetsAt + if provider == .codex, + let requirement = self.codexSessionQuotaBaselineRequirement, + !requirement.admits(observedAt: snapshot.updatedAt) + { + self.sessionQuotaLogger.debug("ignored stale session observation while awaiting a fresh Codex baseline") + return + } + let previousState = self.sessionQuotaTransitionStates[provider] + let forceBaseline = provider == .codex && self.codexSessionQuotaBaselineRequirement != nil + let evaluation = SessionQuotaTransitionReducer.evaluate( + previous: previousState, + observation: SessionQuotaTransitionObservation( + provider: provider, + remaining: currentRemaining, + source: currentSource, + resetBoundary: currentResetBoundary, + observedAt: snapshot.updatedAt, + evaluationTime: now, + codexOwnerKey: codexOwnerKey), + notificationsEnabled: detectionEnabled, + forceBaseline: forceBaseline) + self.sessionQuotaTransitionStates[provider] = evaluation.state + if provider == .codex { + self.codexSessionQuotaBaselineRequirement = nil + } + + let providerText = provider.rawValue + let previousRemaining = previousState?.remaining + switch evaluation.outcome { + case .none: + if SessionQuotaNotificationLogic.isDepleted(currentRemaining) || + SessionQuotaNotificationLogic.isDepleted(previousRemaining) + { + let reason = self.settings.sessionQuotaNotificationsEnabled + ? "no transition" + : "notifications disabled" + self.sessionQuotaLogger.debug( + "\(reason): provider=\(providerText) " + + "prev=\(previousRemaining ?? -1) curr=\(currentRemaining)") + } + case .baselineChanged: + self.sessionQuotaLogger.debug( + "session notification baseline changed: provider=\(providerText) curr=\(currentRemaining)") + case .staleCodexObservation: + self.sessionQuotaLogger.debug( + "ignored stale session observation: provider=\(providerText) curr=\(currentRemaining)") + case .suppressedCodexRestore: + self.sessionQuotaLogger.info( + "suppressed transient restore: provider=\(providerText) " + + "prev=\(previousRemaining ?? -1) curr=\(currentRemaining)") + case .awaitingCodexRestoreConfirmation: + self.sessionQuotaLogger.info( + "awaiting restore confirmation: provider=\(providerText) " + + "prev=\(previousRemaining ?? -1) curr=\(currentRemaining)") + case .depleted, .restored: + let transition = evaluation.outcome.transition + self.sessionQuotaLogger.info( + "transition \(String(describing: transition)): provider=\(providerText) " + + "prev=\(previousRemaining ?? -1) curr=\(currentRemaining)") + self.publishSessionQuotaTransition( + transition, + provider: provider, + sessionWindow: sessionWindow, + snapshot: snapshot, + notificationsEnabled: notificationsEnabled) + } + } + + /// Posts the OS notification (only when enabled) and emits the quota_reached hook on depletion. + private func publishSessionQuotaTransition( + _ transition: SessionQuotaTransition, + provider: UsageProvider, + sessionWindow: (window: RateWindow, source: SessionQuotaWindowSource), + snapshot: UsageSnapshot, + notificationsEnabled: Bool) + { + if notificationsEnabled { + self.sessionQuotaNotifier.post(transition: transition, provider: provider, badge: nil) + } + if transition == .depleted { + self.emitQuotaReachedHook(provider: provider, sessionWindow: sessionWindow, snapshot: snapshot) + } + } +} diff --git a/Sources/CodexBar/UsageStore+StartupConnectivityRetry.swift b/Sources/CodexBar/UsageStore+StartupConnectivityRetry.swift new file mode 100644 index 0000000000..6101f11b54 --- /dev/null +++ b/Sources/CodexBar/UsageStore+StartupConnectivityRetry.swift @@ -0,0 +1,85 @@ +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, + waitForRefreshAvailability: true) + } 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+Status.swift b/Sources/CodexBar/UsageStore+Status.swift index 4d1b85e4d3..95e01aeae4 100644 --- a/Sources/CodexBar/UsageStore+Status.swift +++ b/Sources/CodexBar/UsageStore+Status.swift @@ -1,8 +1,51 @@ import CodexBarCore import Foundation +/// Shared, lock-guarded ISO8601 formatters for status feeds. Allocating a fresh +/// `ISO8601DateFormatter` per decoded date field is a measurable share of decoding the +/// Google Workspace incidents feed, which can run to hundreds of kilobytes (#1399). +private final class StatusISO8601FormatterBox: @unchecked Sendable { + let lock = NSLock() + let withFractional: ISO8601DateFormatter = { + let fmt = ISO8601DateFormatter() + fmt.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return fmt + }() + + let plain: ISO8601DateFormatter = { + let fmt = ISO8601DateFormatter() + fmt.formatOptions = [.withInternetDateTime] + return fmt + }() +} + +private enum StatusFeedDateParser { + static let box = StatusISO8601FormatterBox() + + static func parse(_ text: String) -> Date? { + self.box.lock.lock() + defer { self.box.lock.unlock() } + return self.box.withFractional.date(from: text) ?? self.box.plain.date(from: text) + } + + static func decodingStrategy() -> JSONDecoder.DateDecodingStrategy { + .custom { decoder in + let container = try decoder.singleValueContainer() + let raw = try container.decode(String.self) + guard let date = StatusFeedDateParser.parse(raw) else { + throw DecodingError.dataCorruptedError(in: container, debugDescription: "Invalid ISO8601 date") + } + return date + } + } +} + extension UsageStore { - static func fetchStatus( + /// Status feeds decode off the main actor: the Google Workspace incidents payload alone + /// can be hundreds of kilobytes and cost 150-340ms to decode (#1399), and these helpers + /// touch no store state. + @concurrent + nonisolated static func fetchStatus( from baseURL: URL, transport: any ProviderHTTPTransport = ProviderHTTPClient.shared) async throws -> ProviderStatus @@ -32,17 +75,214 @@ extension UsageStore { } let decoder = JSONDecoder() - decoder.dateDecodingStrategy = .custom { decoder in - let container = try decoder.singleValueContainer() - let raw = try container.decode(String.self) - let formatter = ISO8601DateFormatter() - formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] - if let date = formatter.date(from: raw) { return date } - formatter.formatOptions = [.withInternetDateTime] - if let date = formatter.date(from: raw) { return date } - throw DecodingError.dataCorruptedError(in: container, debugDescription: "Invalid ISO8601 date") + decoder.dateDecodingStrategy = StatusFeedDateParser.decodingStrategy() + + let response = try decoder.decode(Response.self, from: data) + let indicator = ProviderStatusIndicator(rawValue: response.status.indicator) ?? .unknown + return ProviderStatus( + indicator: indicator, + description: response.status.description, + updatedAt: response.page?.updatedAt) + } + + /// Resolves the provider's status and component list. + /// + /// OpenAI's status page is powered by incident.io, whose native feed groups components + /// (APIs / ChatGPT / Codex / FedRAMP) — so we try that first. Classic Atlassian + /// statuspage.io pages (Claude, Cursor, GitHub) expose a flat `api/v2` feed, which we fall + /// back to. `components.json` is used for the flat list because `summary.json` omits unlisted + /// components such as "FedRAMP". + @concurrent + nonisolated static func fetchStatusSummary( + from baseURL: URL, + transport: any ProviderHTTPTransport = ProviderHTTPClient.shared) + async throws -> (status: ProviderStatus, components: [ProviderStatusComponent]?) + { + // incident.io native feed (grouped): https:///proxy/ + if let host = baseURL.host, + let proxyURL = URL(string: "https://\(host)/proxy/\(host)") + { + var proxyRequest = URLRequest(url: proxyURL) + proxyRequest.timeoutInterval = 10 + if let (data, _) = try? await transport.data(for: proxyRequest), + let parsed = try? Self.parseIncidentIOSummary(data: data) + { + // The proxy feed derives the indicator from component leaves but carries no + // top-level description or timestamp; fetch the summary endpoint so callers + // get the full status banner. + let overlay = try? await Self.fetchStatus(from: baseURL, transport: transport) + let status = ProviderStatus( + indicator: parsed.status.indicator, + description: overlay?.description, + updatedAt: overlay?.updatedAt) + return (status, parsed.components) + } } + // Classic statuspage.io fallback. + var summaryRequest = URLRequest(url: baseURL.appendingPathComponent("api/v2/summary.json")) + summaryRequest.timeoutInterval = 10 + var componentsRequest = URLRequest(url: baseURL.appendingPathComponent("api/v2/components.json")) + componentsRequest.timeoutInterval = 10 + + let (summaryData, _) = try await transport.data(for: summaryRequest) + let status = try Self.parseStatuspageStatus(data: summaryData) + let components: [ProviderStatusComponent]? = if let (componentsData, _) = try? await transport + .data(for: componentsRequest) + { + try? Self.parseStatuspageComponents(data: componentsData) + } else { + nil + } + return (status, components) + } + + /// Parses incident.io's native status-page summary (`/proxy/`). Groups come from + /// `structure.items`; per-component statuses come from `affected_components` (anything not + /// listed there is operational). A group's status aggregates the worst of its children. + nonisolated static func parseIncidentIOSummary( + data: Data) + throws -> (status: ProviderStatus, components: [ProviderStatusComponent]) + { + // The incident.io payload mirrors a deeply nested JSON shape; the response models follow it + // 1:1 for clarity, which exceeds the default type-nesting depth. + // swiftlint:disable nesting + struct Response: Decodable { + struct Summary: Decodable { + struct AffectedComponent: Decodable { + let componentID: String + let status: String? + private enum CodingKeys: String, CodingKey { + case componentID = "component_id" + case status + } + } + + struct Structure: Decodable { + struct Item: Decodable { + struct Group: Decodable { + struct Child: Decodable { + let componentID: String + let name: String? + let hidden: Bool? + private enum CodingKeys: String, CodingKey { + case componentID = "component_id" + case name, hidden + } + } + + let id: String + let name: String? + let hidden: Bool? + let components: [Child]? + } + + struct Component: Decodable { + let componentID: String + let name: String? + let hidden: Bool? + private enum CodingKeys: String, CodingKey { + case componentID = "component_id" + case name, hidden + } + } + + let group: Group? + let component: Component? + } + + let items: [Item]? + } + + let affectedComponents: [AffectedComponent]? + let structure: Structure? + private enum CodingKeys: String, CodingKey { + case affectedComponents = "affected_components" + case structure + } + } + + let summary: Summary? + } + // swiftlint:enable nesting + + let response = try JSONDecoder().decode(Response.self, from: data) + guard let summary = response.summary, + let items = summary.structure?.items, + !items.isEmpty + else { + throw URLError(.cannotParseResponse) + } + + var statusByID: [String: String] = [:] + for affected in summary.affectedComponents ?? [] { + statusByID[affected.componentID] = affected.status + } + + func leaf(id: String, name: String) -> ProviderStatusComponent { + let raw = statusByID[id] ?? "operational" + return ProviderStatusComponent( + id: id, + name: name, + indicator: ProviderStatusComponent.indicator(forStatuspageStatus: raw), + status: raw) + } + + var topLevel: [ProviderStatusComponent] = [] + for item in items { + if let group = item.group, group.hidden != true { + let children = (group.components ?? []) + .filter { $0.hidden != true } + .compactMap { child -> ProviderStatusComponent? in + guard let name = Self.normalizedStatusComponentName(child.name) else { return nil } + return leaf(id: child.componentID, name: name) + } + guard let groupName = Self.normalizedStatusComponentName(group.name) else { continue } + let worst = children.max { Self.indicatorRank($0.indicator) < Self.indicatorRank($1.indicator) } + topLevel.append(ProviderStatusComponent( + id: group.id, + name: groupName, + indicator: worst?.indicator ?? .none, + status: worst?.status ?? "operational", + children: children)) + } else if let component = item.component, + component.hidden != true, + let name = Self.normalizedStatusComponentName(component.name) + { + topLevel.append(leaf(id: component.componentID, name: name)) + } + } + + let leaves = topLevel.flatMap { $0.isGroup ? $0.children : [$0] } + let overall = leaves.max { Self.indicatorRank($0.indicator) < Self.indicatorRank($1.indicator) } + let status = ProviderStatus( + indicator: overall?.indicator ?? .none, + description: nil, + updatedAt: nil) + return (status, topLevel) + } + + nonisolated static func parseStatuspageStatus(data: Data) throws -> ProviderStatus { + struct Response: Decodable { + struct Status: Decodable { + let indicator: String + let description: String? + } + + struct Page: Decodable { + let updatedAt: Date? + + private enum CodingKeys: String, CodingKey { + case updatedAt = "updated_at" + } + } + + let page: Page? + let status: Status + } + + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = StatusFeedDateParser.decodingStrategy() let response = try decoder.decode(Response.self, from: data) let indicator = ProviderStatusIndicator(rawValue: response.status.indicator) ?? .unknown return ProviderStatus( @@ -51,9 +291,70 @@ extension UsageStore { updatedAt: response.page?.updatedAt) } - static func fetchWorkspaceStatus( + nonisolated static func parseStatuspageComponents(data: Data) throws -> [ProviderStatusComponent] { + struct Response: Decodable { + struct Component: Decodable { + let id: String + let name: String + let status: String + let group: Bool? + let groupID: String? + let position: Int? + + private enum CodingKeys: String, CodingKey { + case id, name, status, group, position + case groupID = "group_id" + } + } + + let components: [Component]? + } + + let response = try JSONDecoder().decode(Response.self, from: data) + let raw = (response.components ?? []) + .filter { Self.normalizedStatusComponentName($0.name) != nil } + .sorted { ($0.position ?? 0) < ($1.position ?? 0) } + + func makeRow( + _ component: Response.Component, + children: [ProviderStatusComponent]) -> ProviderStatusComponent + { + ProviderStatusComponent( + id: component.id, + name: Self.normalizedStatusComponentName(component.name) ?? component.name, + indicator: ProviderStatusComponent.indicator(forStatuspageStatus: component.status), + status: component.status, + children: children) + } + + // Children keyed by their parent group id, preserving position order. + var childrenByGroup: [String: [ProviderStatusComponent]] = [:] + for component in raw where component.group != true { + guard let groupID = component.groupID else { continue } + childrenByGroup[groupID, default: []].append(makeRow(component, children: [])) + } + + // Top-level rows: groups (with their children) and ungrouped leaf components, in order. + return raw.compactMap { component in + if component.group == true { + return makeRow(component, children: childrenByGroup[component.id] ?? []) + } + // Skip leaves that belong to a group; they are rendered inside the group's dropdown. + if component.groupID != nil { return nil } + return makeRow(component, children: []) + } + } + + private nonisolated static func normalizedStatusComponentName(_ name: String?) -> String? { + guard let name = name?.trimmingCharacters(in: .whitespacesAndNewlines), !name.isEmpty else { return nil } + return name + } + + @concurrent + nonisolated static func fetchWorkspaceStatus( productID: String, - transport: any ProviderHTTPTransport = ProviderHTTPClient.shared) + transport: any ProviderHTTPTransport = ProviderHTTPClient.shared, + beforeDecoding: (@Sendable () -> Void)? = nil) async throws -> ProviderStatus { guard let url = URL(string: "https://www.google.com/appsstatus/dashboard/incidents.json") else { @@ -62,22 +363,14 @@ extension UsageStore { var request = URLRequest(url: url) request.timeoutInterval = 10 let (data, _) = try await transport.data(for: request) + beforeDecoding?() return try Self.parseGoogleWorkspaceStatus(data: data, productID: productID) } - static func parseGoogleWorkspaceStatus(data: Data, productID: String) throws -> ProviderStatus { + nonisolated static func parseGoogleWorkspaceStatus(data: Data, productID: String) throws -> ProviderStatus { let decoder = JSONDecoder() decoder.keyDecodingStrategy = .convertFromSnakeCase - decoder.dateDecodingStrategy = .custom { decoder in - let container = try decoder.singleValueContainer() - let raw = try container.decode(String.self) - let formatter = ISO8601DateFormatter() - formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] - if let date = formatter.date(from: raw) { return date } - formatter.formatOptions = [.withInternetDateTime] - if let date = formatter.date(from: raw) { return date } - throw DecodingError.dataCorruptedError(in: container, debugDescription: "Invalid ISO8601 date") - } + decoder.dateDecodingStrategy = StatusFeedDateParser.decodingStrategy() let incidents = try decoder.decode([GoogleWorkspaceIncident].self, from: data) let active = incidents.filter { $0.isRelevant(productID: productID) && $0.isActive } @@ -105,7 +398,7 @@ extension UsageStore { return ProviderStatus(indicator: best.indicator, description: description, updatedAt: updatedAt) } - private static func indicatorRank(_ indicator: ProviderStatusIndicator) -> Int { + private nonisolated static func indicatorRank(_ indicator: ProviderStatusIndicator) -> Int { switch indicator { case .none: 0 case .maintenance: 1 @@ -116,7 +409,7 @@ extension UsageStore { } } - private static func workspaceIndicator(status: String?, severity: String?) -> ProviderStatusIndicator { + private nonisolated static func workspaceIndicator(status: String?, severity: String?) -> ProviderStatusIndicator { switch status?.uppercased() { case "AVAILABLE": return .none case "SERVICE_INFORMATION": return .minor @@ -134,7 +427,7 @@ extension UsageStore { } } - private static func workspaceSummary(from text: String?) -> String? { + private nonisolated static func workspaceSummary(from text: String?) -> String? { guard let text else { return nil } let normalized = text .replacingOccurrences(of: "\r\n", with: "\n") diff --git a/Sources/CodexBar/UsageStore+Timeout.swift b/Sources/CodexBar/UsageStore+Timeout.swift index ac81d04bcb..bd10e88dee 100644 --- a/Sources/CodexBar/UsageStore+Timeout.swift +++ b/Sources/CodexBar/UsageStore+Timeout.swift @@ -1,19 +1,97 @@ import Foundation extension UsageStore { + private nonisolated static let probeTimeoutQueue = DispatchQueue( + label: "com.steipete.codexbar.probe-timeouts", + qos: .userInitiated) + + private final class ProbeTimeoutRace: @unchecked Sendable { + private let lock = NSLock() + private var continuation: CheckedContinuation? + private var result: String? + private var cancellations: [() -> Void] = [] + + func install(_ continuation: CheckedContinuation) { + let result: String? = self.lock.withLock { + if let result = self.result { + return result + } + self.continuation = continuation + return nil + } + if let result { + continuation.resume(returning: result) + } + } + + func install(_ task: Task) { + self.installCancellation { + task.cancel() + } + } + + func install(_ workItem: DispatchWorkItem) { + self.installCancellation { + workItem.cancel() + } + } + + private func installCancellation(_ cancellation: @escaping () -> Void) { + let shouldCancel = self.lock.withLock { + guard self.result == nil else { return true } + self.cancellations.append(cancellation) + return false + } + if shouldCancel { + cancellation() + } + } + + func complete(with result: String) { + let completion = self.lock.withLock { + guard self.result == nil else { + return (nil as CheckedContinuation?, [] as [() -> Void]) + } + self.result = result + let continuation = self.continuation + self.continuation = nil + let cancellations = self.cancellations + self.cancellations.removeAll() + return (continuation, cancellations) + } + completion.1.forEach { $0() } + completion.0?.resume(returning: result) + } + } + nonisolated static func runWithTimeout( seconds: Double, operation: @escaping @Sendable () async -> String) async -> String { - await withTaskGroup(of: String?.self) { group -> String in - group.addTask { await operation() } - group.addTask { - try? await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000)) - return nil + let timeoutMessage = "Probe timed out after \(Int(seconds))s" + let race = ProbeTimeoutRace() + + return await withTaskCancellationHandler { + await withCheckedContinuation { continuation in + race.install(continuation) + + race.install(Task { + let result = await operation() + race.complete(with: result) + }) + + // A Swift task-based timer can be delayed when the cooperative pool is + // saturated by blocking probes. Dispatch keeps the timeout wall-clock bounded. + let timeoutWorkItem = DispatchWorkItem { + race.complete(with: timeoutMessage) + } + race.install(timeoutWorkItem) + Self.probeTimeoutQueue.asyncAfter( + deadline: .now() + max(seconds, 0), + execute: timeoutWorkItem) } - let result = await group.next()?.flatMap(\.self) - group.cancelAll() - return result ?? "Probe timed out after \(Int(seconds))s" + } onCancel: { + race.complete(with: timeoutMessage) } } } diff --git a/Sources/CodexBar/UsageStore+TokenAccountLabels.swift b/Sources/CodexBar/UsageStore+TokenAccountLabels.swift new file mode 100644 index 0000000000..7e1329b547 --- /dev/null +++ b/Sources/CodexBar/UsageStore+TokenAccountLabels.swift @@ -0,0 +1,35 @@ +import CodexBarCore +import Foundation + +extension UsageStore { + func applyAccountLabel( + _ snapshot: UsageSnapshot, + provider: UsageProvider, + account: ProviderTokenAccount) -> UsageSnapshot + { + let label = account.label.trimmingCharacters(in: .whitespacesAndNewlines) + guard !label.isEmpty else { return snapshot } + let existing = snapshot.identity(for: provider) + let email = existing?.accountEmail?.trimmingCharacters(in: .whitespacesAndNewlines) + let resolvedEmail = (email?.isEmpty ?? true) ? label : email + let identity = ProviderIdentitySnapshot( + providerID: provider, + accountEmail: resolvedEmail, + accountOrganization: existing?.accountOrganization, + loginMethod: existing?.loginMethod) + return snapshot.withIdentity(identity) + } + + func applyCodexVisibleAccountLabel(_ snapshot: UsageSnapshot, account: CodexVisibleAccount) -> UsageSnapshot { + let existing = snapshot.identity(for: .codex) + let email = existing?.accountEmail?.trimmingCharacters(in: .whitespacesAndNewlines) + let resolvedEmail = (email?.isEmpty ?? true) ? account.email : email + let loginMethod = existing?.loginMethod ?? account.workspaceLabel + let identity = ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: resolvedEmail, + accountOrganization: existing?.accountOrganization, + loginMethod: loginMethod) + return snapshot.withIdentity(identity) + } +} diff --git a/Sources/CodexBar/UsageStore+TokenAccounts.swift b/Sources/CodexBar/UsageStore+TokenAccounts.swift index 4a5a96d4e7..219251ec02 100644 --- a/Sources/CodexBar/UsageStore+TokenAccounts.swift +++ b/Sources/CodexBar/UsageStore+TokenAccounts.swift @@ -1,4 +1,5 @@ import CodexBarCore +import CryptoKit import Foundation struct TokenAccountUsageSnapshot: Identifiable { @@ -7,13 +8,21 @@ struct TokenAccountUsageSnapshot: Identifiable { let snapshot: UsageSnapshot? let error: String? let sourceLabel: String? + let cacheKey: String - init(account: ProviderTokenAccount, snapshot: UsageSnapshot?, error: String?, sourceLabel: String?) { + init( + account: ProviderTokenAccount, + snapshot: UsageSnapshot?, + error: String?, + sourceLabel: String?, + cacheKey: String) + { self.id = account.id self.account = account self.snapshot = snapshot self.error = error self.sourceLabel = sourceLabel + self.cacheKey = cacheKey } } @@ -33,6 +42,137 @@ struct CodexAccountUsageSnapshot: Identifiable { } } +extension UsageStore { + func activateCachedTokenAccountSnapshot(provider: UsageProvider, accountID: UUID) { + guard self.settings.effectiveSelectedTokenAccount(for: provider)?.id == accountID else { return } + self.tokenAccountLiveStateProviders.insert(provider) + guard let account = self.uniqueTokenAccount(provider: provider, accountID: accountID), + let cached = self.accountSnapshots[provider]?.first(where: { + $0.account.id == accountID && $0.cacheKey == self.tokenAccountSnapshotCacheKey( + provider: provider, + account: account) + }) + else { + self.accountSnapshots[provider]?.removeAll { $0.account.id == accountID } + self.knownLimitsAvailabilityByProvider.removeValue(forKey: provider) + // Never show the previous account's usage under the newly selected account. Segmented layouts only + // fetch the active account, so an uncached selection must render as refreshing until its fetch completes. + self.clearTokenAccountLiveSnapshot(provider: provider) + return + } + + self.knownLimitsAvailabilityByProvider[provider] = .resolve( + provider: provider, + snapshot: cached.snapshot, + lastErrorDescription: cached.error) + + if let snapshot = cached.snapshot { + self.snapshots[provider] = snapshot + self.lastKnownResetSnapshots[provider] = snapshot + self.installProviderDerivedTokenSnapshot(from: snapshot, for: provider) + } else { + self.snapshots.removeValue(forKey: provider) + self.lastKnownResetSnapshots.removeValue(forKey: provider) + self.resetProviderDerivedTokenSnapshot(for: provider) + } + self.errors[provider] = cached.error + if let sourceLabel = cached.sourceLabel { + self.lastSourceLabels[provider] = sourceLabel + } else { + self.lastSourceLabels.removeValue(forKey: provider) + } + } + + func cacheTokenAccountSnapshot( + provider: UsageProvider, + account: ProviderTokenAccount, + snapshot: UsageSnapshot, + sourceLabel: String?) + { + guard provider != .cursor || self.settings.cursorCookieSource != .auto else { return } + let cached = TokenAccountUsageSnapshot( + account: account, + snapshot: snapshot, + error: nil, + sourceLabel: sourceLabel, + cacheKey: self.tokenAccountSnapshotCacheKey(provider: provider, account: account)) + var snapshots = self.accountSnapshots[provider] ?? [] + if let index = snapshots.firstIndex(where: { $0.account.id == account.id }) { + snapshots[index] = cached + } else { + snapshots.append(cached) + } + self.accountSnapshots[provider] = snapshots + } + + func pruneTokenAccountSnapshots(provider: UsageProvider, accounts: [ProviderTokenAccount]) { + let retained = self.validTokenAccountSnapshots(provider: provider, accounts: accounts) + if retained.isEmpty { + self.accountSnapshots.removeValue(forKey: provider) + } else { + self.accountSnapshots[provider] = retained + } + } + + func reconcileSelectedTokenAccountSnapshotBeforeRefresh( + provider: UsageProvider, + accounts: [ProviderTokenAccount]) + { + self.pruneTokenAccountSnapshots(provider: provider, accounts: accounts) + guard let selectedAccount = self.settings.effectiveSelectedTokenAccount(for: provider) else { + if self.tokenAccountLiveStateProviders.remove(provider) != nil { + self.knownLimitsAvailabilityByProvider.removeValue(forKey: provider) + self.clearTokenAccountLiveSnapshot(provider: provider) + } + return + } + // A Settings edit can invalidate the selected credential or endpoint before its replacement refresh + // completes. Reconcile the live card now so a failed/cancelled fetch cannot retain old-account data. + self.activateCachedTokenAccountSnapshot(provider: provider, accountID: selectedAccount.id) + } + + private func clearTokenAccountLiveSnapshot(provider: UsageProvider) { + self.snapshots.removeValue(forKey: provider) + self.resetProviderDerivedTokenSnapshot(for: provider) + self.errors.removeValue(forKey: provider) + self.lastSourceLabels.removeValue(forKey: provider) + self.lastKnownResetSnapshots.removeValue(forKey: provider) + } + + func validTokenAccountSnapshots( + provider: UsageProvider, + accounts: [ProviderTokenAccount]) -> [TokenAccountUsageSnapshot] + { + let accountsByID = Dictionary(grouping: accounts, by: \.id).compactMapValues { matches in + matches.count == 1 ? matches[0] : nil + } + return (self.accountSnapshots[provider] ?? []).filter { cached in + guard let account = accountsByID[cached.account.id] else { return false } + return cached.cacheKey == self.tokenAccountSnapshotCacheKey(provider: provider, account: account) + } + } + + func tokenAccountSnapshotCacheKey(provider: UsageProvider, account: ProviderTokenAccount) -> String { + var config = self.settings.configSnapshot.providerConfig(for: provider) ?? ProviderConfig(id: provider) + // Active selection and sibling accounts must not invalidate a valid per-account snapshot. + config.tokenAccounts = nil + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + var material = Data(provider.rawValue.utf8) + material.append((try? encoder.encode(config)) ?? Data()) + material.append((try? encoder.encode(account)) ?? Data()) + if Self.tokenCostRequiresProviderSnapshot(provider) { + material.append(Data(self.tokenSnapshotScopeSignature(for: provider).utf8)) + } + return SHA256.hash(data: material).map { String(format: "%02x", $0) }.joined() + } + + func uniqueTokenAccount(provider: UsageProvider, accountID: UUID) -> ProviderTokenAccount? { + let matches = self.settings.tokenAccounts(for: provider).filter { $0.id == accountID } + return matches.count == 1 ? matches[0] : nil + } +} + private struct TokenAccountFetchResult { let index: Int let account: ProviderTokenAccount @@ -42,12 +182,32 @@ private struct TokenAccountFetchResult { private struct CodexAccountFetchResult { let index: Int let account: CodexVisibleAccount - let outcome: ProviderFetchOutcome + let outcome: ProviderFetchOutcome? + let limitResetOwnerKey: CodexLimitResetOwnerKey? +} + +private struct CodexAccountFetchRequest { + let index: Int + let account: CodexVisibleAccount + let previousSnapshot: UsageSnapshot? + let missingWindowBackfillSnapshot: UsageSnapshot? + let limitResetOwnerKey: CodexLimitResetOwnerKey? + let descriptor: ProviderDescriptor + let context: ProviderFetchContext +} + +private struct CodexManagedVisibleAccountRuntimeState { + let authFingerprint: String? + let workspaceAccountID: String? } extension UsageStore { static let tokenAccountMenuSnapshotLimit = 6 + func freshCodexVisibleAccountsForSnapshotHydration() -> [CodexVisibleAccount] { + self.freshCodexVisibleAccountProjectionForAccountRefresh().visibleAccounts + } + func tokenAccounts(for provider: UsageProvider) -> [ProviderTokenAccount] { guard TokenAccountSupportCatalog.support(for: provider) != nil else { return [] } return self.settings.tokenAccounts(for: provider) @@ -55,16 +215,18 @@ extension UsageStore { func shouldFetchAllTokenAccounts(provider: UsageProvider, accounts: [ProviderTokenAccount]) -> Bool { guard TokenAccountSupportCatalog.support(for: provider) != nil else { return false } + guard self.settings.effectiveSelectedTokenAccount(for: provider) != nil else { return false } return self.settings.multiAccountMenuLayout == .stacked && accounts.count > 1 } func shouldFetchAllCodexVisibleAccounts() -> Bool { - self.settings.multiAccountMenuLayout == .stacked && - self.settings.codexVisibleAccountProjection.visibleAccounts.count > 1 + let projection = self.freshCodexVisibleAccountProjectionForAccountRefresh() + return self.settings.multiAccountMenuLayout == .stacked && + projection.visibleAccounts.count > 1 } - func refreshCodexVisibleAccountsForMenu() async { - let projection = self.settings.codexVisibleAccountProjection + func refreshCodexVisibleAccountsForMenu(generation: UInt64? = nil) async { + let projection = self.freshCodexVisibleAccountProjectionForAccountRefresh() let accounts = self.limitedCodexVisibleAccounts( projection.visibleAccounts, snapshots: self.codexAccountSnapshots, @@ -73,91 +235,376 @@ extension UsageStore { self.codexAccountSnapshots = [] return } + let managedAccountIDsWithReadableAuthAtStart = self.codexManagedAccountIDsWithReadableAuth() let originalVisibleAccountID = projection.activeVisibleAccountID let originalSelectionSource = originalVisibleAccountID.flatMap { projection.source(forVisibleAccountID: $0) } - let priorByAccountID = Dictionary(uniqueKeysWithValues: self.codexAccountSnapshots.map { ($0.id, $0) }) + let originalVisibleAccount = originalVisibleAccountID.flatMap { id in + accounts.first { $0.id == id } + } + let priorSnapshots = self.codexAccountSnapshots var snapshots: [CodexAccountUsageSnapshot] = [] var selectedOutcome: ProviderFetchOutcome? + var selectedAccount: CodexVisibleAccount? var selectedSnapshot: UsageSnapshot? var selectedSourceLabel: String? - var sawAnyNonCancellationOutcome = false + var selectedLimitResetOwnerKey: CodexLimitResetOwnerKey? - let results = await self.fetchCodexVisibleAccountOutcomes(accounts) + let results = await self.fetchCodexVisibleAccountOutcomes( + accounts, + allVisibleAccounts: projection.visibleAccounts, + priorSnapshots: priorSnapshots, + activeVisibleAccountID: originalVisibleAccountID) for result in results { let account = result.account - let outcome = result.outcome - let isCancellation = Self.outcomeIsCancellation(outcome) - if !isCancellation { - sawAnyNonCancellationOutcome = true + let priorSnapshot = Self.codexPriorAccountSnapshot( + matching: account, + in: priorSnapshots) + guard let outcome = result.outcome else { + if let priorSnapshot { + snapshots.append(priorSnapshot) + } + if account.id == originalVisibleAccountID { + selectedAccount = account + selectedLimitResetOwnerKey = result.limitResetOwnerKey + } + continue } let resolved = self.resolveCodexAccountOutcome( outcome, account: account, - priorSnapshot: priorByAccountID[account.id]) + priorSnapshot: priorSnapshot, + resetBackfillSnapshots: result.limitResetOwnerKey == nil + ? [] + : self.codexResetBackfillSnapshots( + for: account, + priorSnapshot: priorSnapshot, + activeVisibleAccountID: originalVisibleAccountID)) if let snapshot = resolved.snapshot { snapshots.append(snapshot) } if account.id == originalVisibleAccountID { selectedOutcome = outcome + selectedAccount = account selectedSnapshot = resolved.usage selectedSourceLabel = resolved.sourceLabel + selectedLimitResetOwnerKey = result.limitResetOwnerKey } } - let shouldPreservePriorState = !sawAnyNonCancellationOutcome && - snapshots.allSatisfy { $0.snapshot == nil } - if !shouldPreservePriorState { - self.codexAccountSnapshots = snapshots - self.codexAccountUsageSnapshotStore?.store(snapshots) + let currentProjection = self.freshCodexVisibleAccountProjectionForAccountRefresh( + requireLiveManagedAuthFor: managedAccountIDsWithReadableAuthAtStart) + guard self.isCurrentProviderRefreshGeneration(.codex, generation: generation) else { return } + let currentSnapshots = snapshots.compactMap { snapshot -> CodexAccountUsageSnapshot? in + guard let currentAccount = Self.currentCodexVisibleAccount( + matching: snapshot.account, + projection: currentProjection, + allowProviderAccountAuthFingerprintMismatch: snapshot.error == nil) + else { + return nil + } + guard currentAccount != snapshot.account else { return snapshot } + return CodexAccountUsageSnapshot( + account: currentAccount, + snapshot: Self.codexVisibleAccountSnapshotRelabeledForCurrentProjection( + snapshot.snapshot, + account: currentAccount), + error: snapshot.error, + sourceLabel: snapshot.sourceLabel) } + self.codexAccountSnapshots = currentSnapshots + self.codexAccountUsageSnapshotStore?.store(currentSnapshots) let selectionStillMatches = self.codexVisibleSelectionStillMatches( originalVisibleAccountID: originalVisibleAccountID, - originalSelectionSource: originalSelectionSource) - if let selectedOutcome, selectionStillMatches { - await self.applySelectedCodexVisibleAccountOutcome( + originalSelectionSource: originalSelectionSource, + originalAccount: originalVisibleAccount, + currentProjection: currentProjection) + guard let selectedOutcome, let selectedAccount else { + if selectionStillMatches, + let selectedID = currentProjection.activeVisibleAccountID, + let preserved = currentSnapshots.first(where: { $0.id == selectedID }), + let snapshot = preserved.snapshot + { + self.snapshots[.codex] = snapshot + self.lastKnownResetSnapshots[.codex] = snapshot + let publicationGuard = Self.codexScopedRefreshGuard(for: preserved.account) + self.lastCodexUsagePublicationGuard = publicationGuard + self.lastCodexAccountScopedRefreshGuard = publicationGuard + } else if !selectionStillMatches { + self.reconcileCodexAccountStateForUsageOwner(self.freshCodexAccountScopedRefreshGuard()) + } + return + } + guard selectionStillMatches else { + self.reconcileCodexAccountStateForUsageOwner(self.freshCodexAccountScopedRefreshGuard()) + return + } + + let allowSelectedAuthFingerprintMismatch = switch selectedOutcome.result { + case .success: + true + case .failure: + false + } + let currentSelectedAccount = Self.currentCodexVisibleAccount( + matching: selectedAccount, + projection: currentProjection, + allowProviderAccountAuthFingerprintMismatch: allowSelectedAuthFingerprintMismatch) + if let currentSelectedAccount { + let currentSelectedSnapshot = Self.codexVisibleAccountSnapshotRelabeledForCurrentProjection( + selectedSnapshot, + account: currentSelectedAccount) + if self.shouldApplySelectedCodexVisibleAccountOutcome( selectedOutcome, - snapshot: selectedSnapshot, - sourceLabel: selectedSourceLabel) + snapshot: currentSelectedSnapshot) + { + await self.applySelectedCodexVisibleAccountOutcome( + selectedOutcome, + account: currentSelectedAccount, + snapshot: currentSelectedSnapshot, + sourceLabel: selectedSourceLabel, + limitResetOwnerKey: selectedLimitResetOwnerKey, + generation: generation) + } + } else { + self.reconcileCodexAccountStateForUsageOwner(self.freshCodexAccountScopedRefreshGuard()) } } func codexVisibleSelectionStillMatches( originalVisibleAccountID: String?, - originalSelectionSource: CodexActiveSource?) -> Bool + originalSelectionSource: CodexActiveSource?, + originalAccount: CodexVisibleAccount? = nil, + currentProjection: CodexVisibleAccountProjection? = nil) -> Bool + { + let currentProjection = currentProjection ?? self.settings.codexVisibleAccountProjection + let currentActiveAccount = currentProjection.activeVisibleAccountID.flatMap { id in + currentProjection.visibleAccounts.first { $0.id == id } + } + let currentSelectionSource = currentActiveAccount?.selectionSource + if currentProjection.activeVisibleAccountID == originalVisibleAccountID, + currentSelectionSource == originalSelectionSource + { + guard let originalAccount else { return true } + guard let currentActiveAccount else { return false } + return Self.codexVisibleAccountMatchesCurrentProjection( + originalAccount, + account: currentActiveAccount) + } + guard let originalAccount, let currentActiveAccount, currentSelectionSource == originalSelectionSource else { + return false + } + return Self.codexVisibleAccountMatchesCurrentProjection(originalAccount, account: currentActiveAccount) + } + + private func freshCodexVisibleAccountProjectionForAccountRefresh( + requireLiveManagedAuthFor accountIDs: Set = []) -> CodexVisibleAccountProjection + { + // Auth files can change while account fetches are in flight, so account refreshes bypass the + // short-lived reconciliation cache used for normal menu rendering and stale-result guards. + self.settings.invalidateCodexAccountReconciliationSnapshotCache() + let snapshot = self.settings.codexAccountReconciliationSnapshot + return Self.codexVisibleAccountProjectionWithFreshManagedAuthFingerprints( + CodexVisibleAccountProjection.make(from: snapshot), + snapshot: snapshot, + requireLiveManagedAuthFor: accountIDs) + } + + private func codexManagedAccountIDsWithReadableAuth() -> Set { + Set(self.settings.codexAccountReconciliationSnapshot.storedAccounts.compactMap { account in + CodexAuthFingerprint.fingerprint(homePath: account.managedHomePath) == nil ? nil : account.id + }) + } + + private nonisolated static func codexVisibleAccountProjectionWithFreshManagedAuthFingerprints( + _ projection: CodexVisibleAccountProjection, + snapshot: CodexAccountReconciliationSnapshot, + requireLiveManagedAuthFor accountIDs: Set = []) -> CodexVisibleAccountProjection + { + let managedRuntimeStates = Dictionary( + uniqueKeysWithValues: snapshot.storedAccounts.map { account in + let workspaceAccountID: String? = switch snapshot.runtimeIdentity(for: account) { + case let .providerAccount(id): + id + case .emailOnly, .unresolved: + nil + } + let authFingerprint = CodexAuthFingerprint.fingerprint(homePath: account.managedHomePath) + let requiresLiveAuth = accountIDs.contains(account.id) + return (account.id, CodexManagedVisibleAccountRuntimeState( + authFingerprint: authFingerprint ?? (requiresLiveAuth ? nil : account.authFingerprint), + workspaceAccountID: authFingerprint == nil && requiresLiveAuth + ? nil + : (workspaceAccountID ?? account.workspaceAccountID))) + }) + let visibleAccounts = projection.visibleAccounts.map { account in + guard case let .managedAccount(id) = account.selectionSource else { return account } + let accountWorkspaceAccountID = account.workspaceAccountID + .map(CodexOpenAIWorkspaceIdentity.normalizeWorkspaceAccountID) + let runtimeWorkspaceAccountID = managedRuntimeStates[id]?.workspaceAccountID + .map(CodexOpenAIWorkspaceIdentity.normalizeWorkspaceAccountID) + guard let runtimeState = managedRuntimeStates[id], + runtimeState.authFingerprint != account.authFingerprint || + runtimeWorkspaceAccountID != accountWorkspaceAccountID + else { + return account + } + return CodexVisibleAccount( + id: account.id, + email: account.email, + workspaceLabel: account.workspaceLabel, + workspaceAccountID: runtimeState.workspaceAccountID, + authFingerprint: runtimeState.authFingerprint, + storedAccountID: account.storedAccountID, + selectionSource: account.selectionSource, + isActive: account.isActive, + isLive: account.isLive, + canReauthenticate: account.canReauthenticate, + canRemove: account.canRemove) + } + return CodexVisibleAccountProjection( + visibleAccounts: visibleAccounts, + activeVisibleAccountID: projection.activeVisibleAccountID, + liveVisibleAccountID: projection.liveVisibleAccountID, + hasUnreadableAddedAccountStore: projection.hasUnreadableAddedAccountStore) + } + + private static func currentCodexVisibleAccount( + matching account: CodexVisibleAccount, + projection: CodexVisibleAccountProjection, + allowProviderAccountAuthFingerprintMismatch: Bool = true) -> CodexVisibleAccount? + { + if let currentAccount = projection.visibleAccounts.first(where: { $0.id == account.id }), + self.codexVisibleAccountMatchesCurrentProjection( + account, + account: currentAccount, + allowProviderAccountAuthFingerprintMismatch: allowProviderAccountAuthFingerprintMismatch) + { + return currentAccount + } + return projection.visibleAccounts.first { + self.codexVisibleAccountMatchesCurrentProjection( + account, + account: $0, + allowProviderAccountAuthFingerprintMismatch: allowProviderAccountAuthFingerprintMismatch) + } + } + + private static func codexVisibleAccountSnapshotRelabeledForCurrentProjection( + _ snapshot: UsageSnapshot?, + account: CodexVisibleAccount) -> UsageSnapshot? + { + guard let snapshot else { return nil } + let existing = snapshot.identity(for: .codex) + return snapshot.withIdentity(ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: account.email, + accountOrganization: existing?.accountOrganization, + loginMethod: existing?.loginMethod ?? account.workspaceLabel)) + } + + private static func codexVisibleAccountMatchesCurrentProjection( + _ prior: CodexVisibleAccount, + account: CodexVisibleAccount, + allowProviderAccountAuthFingerprintMismatch: Bool = true) -> Bool + { + guard prior.selectionSource == account.selectionSource else { return false } + + guard let priorEmail = CodexIdentityResolver.normalizeEmail(prior.email), + let accountEmail = CodexIdentityResolver.normalizeEmail(account.email), + priorEmail == accountEmail + else { + return false + } + + let priorWorkspaceID = self.normalizedCodexVisibleAccountText(prior.workspaceAccountID) + .map(CodexOpenAIWorkspaceIdentity.normalizeWorkspaceAccountID) + let accountWorkspaceID = self.normalizedCodexVisibleAccountText(account.workspaceAccountID) + .map(CodexOpenAIWorkspaceIdentity.normalizeWorkspaceAccountID) + if priorWorkspaceID != nil || accountWorkspaceID != nil { + guard priorWorkspaceID == accountWorkspaceID else { return false } + if !allowProviderAccountAuthFingerprintMismatch { + guard self.codexVisibleAccountAuthFingerprintMatches(prior, account: account) else { return false } + } + return true + } + + let priorAuthFingerprint = CodexAuthFingerprint.normalize(prior.authFingerprint) + let accountAuthFingerprint = CodexAuthFingerprint.normalize(account.authFingerprint) + if priorAuthFingerprint != nil || accountAuthFingerprint != nil { + guard priorAuthFingerprint == accountAuthFingerprint else { return false } + } + + return true + } + + private static func codexVisibleAccountAuthFingerprintMatches( + _ prior: CodexVisibleAccount, + account: CodexVisibleAccount) -> Bool { - let currentProjection = self.settings.codexVisibleAccountProjection - let currentSelectionSource = originalVisibleAccountID.flatMap { - currentProjection.source(forVisibleAccountID: $0) + let priorAuthFingerprint = CodexAuthFingerprint.normalize(prior.authFingerprint) + let accountAuthFingerprint = CodexAuthFingerprint.normalize(account.authFingerprint) + if priorAuthFingerprint != nil || accountAuthFingerprint != nil { + return priorAuthFingerprint == accountAuthFingerprint + } + return true + } + + func shouldApplySelectedCodexVisibleAccountOutcome( + _ outcome: ProviderFetchOutcome, + snapshot: UsageSnapshot?) -> Bool + { + switch outcome.result { + case .success: + snapshot != nil + case .failure: + true } - return currentProjection.activeVisibleAccountID == originalVisibleAccountID && - currentSelectionSource == originalSelectionSource } - func refreshTokenAccounts(provider: UsageProvider, accounts: [ProviderTokenAccount]) async { - let selectedAccount = self.settings.selectedTokenAccount(for: provider) + func refreshTokenAccounts( + provider: UsageProvider, + accounts: [ProviderTokenAccount], + generation: UInt64? = nil) async + { + guard let selectedAccount = self.settings.effectiveSelectedTokenAccount(for: provider) else { + await MainActor.run { + self.reconcileSelectedTokenAccountSnapshotBeforeRefresh( + provider: provider, + accounts: accounts) + } + return + } let limitedAccounts = self.limitedTokenAccounts(accounts, selected: selectedAccount) - let effectiveSelected = selectedAccount ?? limitedAccounts.first + let effectiveSelected = selectedAccount // Capture the prior per-account snapshot state so we can preserve last-good // data when an in-flight refresh is cancelled (e.g. menu tab switches). Without // this, cancellation produces empty/error snapshots and the menu briefly shows // misleading cards for accounts that previously had valid data. - let priorSnapshots = await MainActor.run { self.accountSnapshots[provider] ?? [] } + let priorSnapshots = await MainActor.run { + self.pruneTokenAccountSnapshots(provider: provider, accounts: accounts) + self.activateCachedTokenAccountSnapshot(provider: provider, accountID: effectiveSelected.id) + return self.accountSnapshots[provider] ?? [] + } let priorByAccountID = Dictionary(uniqueKeysWithValues: priorSnapshots.map { ($0.account.id, $0) }) var snapshots: [TokenAccountUsageSnapshot] = [] var historySamples: [(account: ProviderTokenAccount, snapshot: UsageSnapshot)] = [] var selectedOutcome: ProviderFetchOutcome? + var resolvedSelectedAccount: ProviderTokenAccount? var selectedSnapshot: UsageSnapshot? + var selectedAccountSnapshot: TokenAccountUsageSnapshot? var sawAnyNonCancellationOutcome = false let results = await self.fetchTokenAccountOutcomes(provider: provider, accounts: limitedAccounts) + guard self.isCurrentProviderRefreshGeneration(provider, generation: generation) else { return } for result in results { - let account = result.account + guard let account = self.uniqueTokenAccount(provider: provider, accountID: result.account.id) + else { continue } let outcome = result.outcome let isCancellation = Self.outcomeIsCancellation(outcome) if !isCancellation { @@ -171,12 +618,14 @@ extension UsageStore { if let snapshot = resolved.snapshot { snapshots.append(snapshot) } - if let usage = resolved.usage { + if let usage = resolved.freshUsage { historySamples.append((account: account, snapshot: usage)) } - if account.id == effectiveSelected?.id { + if account.id == effectiveSelected.id { selectedOutcome = outcome + resolvedSelectedAccount = account selectedSnapshot = resolved.usage + selectedAccountSnapshot = resolved.snapshot } } @@ -191,14 +640,17 @@ extension UsageStore { } } - if let selectedOutcome { + if let selectedOutcome, let resolvedSelectedAccount { await self.applySelectedOutcome( selectedOutcome, provider: provider, - account: effectiveSelected, - fallbackSnapshot: selectedSnapshot) + account: resolvedSelectedAccount, + fallbackSnapshot: selectedSnapshot, + fallbackAccountSnapshot: selectedAccountSnapshot, + generation: generation) } + guard self.isCurrentProviderRefreshGeneration(provider, generation: generation) else { return } await self.recordFetchedTokenAccountPlanUtilizationHistory( provider: provider, samples: historySamples, @@ -215,7 +667,20 @@ extension UsageStore { return false } - private static func errorIsCancellation(_ error: any Error) -> Bool { + private nonisolated static func codexUsageOutcomeMatchesVisibleAccount( + _ outcome: ProviderFetchOutcome, + account: CodexVisibleAccount) -> Bool + { + guard case let .success(result) = outcome.result else { return true } + guard let resultEmail = CodexIdentityResolver.normalizeEmail( + result.usage.scoped(to: .codex).accountEmail(for: .codex)) + else { + return true + } + return resultEmail == CodexIdentityResolver.normalizeEmail(account.email) + } + + nonisolated static func errorIsCancellation(_ error: any Error) -> Bool { if error is CancellationError { return true } @@ -235,7 +700,9 @@ extension UsageStore { selected: ProviderTokenAccount?) -> [ProviderTokenAccount] { let limit = Self.tokenAccountMenuSnapshotLimit - if accounts.count <= limit { return accounts } + if accounts.count <= limit { + return accounts + } var limited = Array(accounts.prefix(limit)) if let selected, !limited.contains(where: { $0.id == selected.id }) { limited.removeLast() @@ -254,7 +721,9 @@ extension UsageStore { snapshots: snapshots, activeVisibleAccountID: activeVisibleAccountID) let limit = Self.tokenAccountMenuSnapshotLimit - if accounts.count <= limit { return accounts } + if accounts.count <= limit { + return accounts + } var limited = Array(accounts.prefix(limit)) if let activeVisibleAccountID, let active = accounts.first(where: { $0.id == activeVisibleAccountID }), @@ -277,7 +746,12 @@ extension UsageStore { provider: provider, override: override, codexActiveSourceOverride: codexActiveSourceOverride) - return await descriptor.fetchOutcome(context: context) + let outcome = await descriptor.fetchOutcome(context: context) + guard provider == .codex else { return outcome } + return await Self.attachingCodexResetCreditsIfNeeded( + to: outcome, + env: context.env, + fetcher: self.codexResetCreditsFetcher()) } private func fetchTokenAccountOutcomes( @@ -297,6 +771,34 @@ extension UsageStore { return (index, account, descriptor, context) } + if let delay = TokenAccountSupportCatalog.support(for: provider)?.minimumDelayBetweenAccountRefreshes { + var results: [TokenAccountFetchResult] = [] + results.reserveCapacity(requests.count) + for request in requests { + if !results.isEmpty { + do { + try await Task.sleep(for: delay) + } catch { + for pending in requests.dropFirst(results.count) { + results.append(TokenAccountFetchResult( + index: pending.index, + account: pending.account, + outcome: ProviderFetchOutcome( + result: .failure(CancellationError()), + attempts: []))) + } + return results + } + } + let outcome = await request.descriptor.fetchOutcome(context: request.context) + results.append(TokenAccountFetchResult( + index: request.index, + account: request.account, + outcome: outcome)) + } + return results + } + return await withTaskGroup( of: TokenAccountFetchResult.self, returning: [TokenAccountFetchResult].self) @@ -320,22 +822,42 @@ extension UsageStore { } } - private func fetchCodexVisibleAccountOutcomes(_ accounts: [CodexVisibleAccount]) async + private func fetchCodexVisibleAccountOutcomes( + _ accounts: [CodexVisibleAccount], + allVisibleAccounts: [CodexVisibleAccount], + priorSnapshots: [CodexAccountUsageSnapshot], + activeVisibleAccountID: String?) async -> [CodexAccountFetchResult] { - let requests: [( - index: Int, - account: CodexVisibleAccount, - descriptor: ProviderDescriptor, - context: ProviderFetchContext)] = - accounts.enumerated().map { index, account in - let descriptor = self.providerSpecs[.codex]?.descriptor ?? ProviderDescriptorRegistry - .descriptor(for: .codex) - let context = self.makeFetchContext( - provider: .codex, - override: nil, - codexActiveSourceOverride: account.selectionSource) - return (index, account, descriptor, context) - } + let resetCreditsFetcher = self.codexResetCreditsFetcher() + let requests: [CodexAccountFetchRequest] = accounts.enumerated().map { index, account in + let descriptor = self.providerSpecs[.codex]?.descriptor ?? ProviderDescriptorRegistry + .descriptor(for: .codex) + let context = self.makeFetchContext( + provider: .codex, + override: nil, + codexActiveSourceOverride: account.selectionSource) + let limitResetOwnerKey = self.codexLimitResetOwnerKey( + forVisibleAccount: account, + visibleAccounts: allVisibleAccounts) + let priorSnapshot = Self.codexPriorAccountSnapshot( + matching: account, + in: priorSnapshots) + let trustedBackfillSnapshots = limitResetOwnerKey == nil + ? [] + : self.codexResetBackfillSnapshots( + for: account, + priorSnapshot: priorSnapshot, + activeVisibleAccountID: activeVisibleAccountID) + let missingWindowBackfillSnapshot = Self.codexMergedResetBackfillSnapshot(trustedBackfillSnapshots) + return CodexAccountFetchRequest( + index: index, + account: account, + previousSnapshot: limitResetOwnerKey == nil ? nil : priorSnapshot?.snapshot, + missingWindowBackfillSnapshot: missingWindowBackfillSnapshot, + limitResetOwnerKey: limitResetOwnerKey, + descriptor: descriptor, + context: context) + } return await withTaskGroup( of: CodexAccountFetchResult.self, @@ -343,11 +865,37 @@ extension UsageStore { { group in for request in requests { group.addTask { - let outcome = await request.descriptor.fetchOutcome(context: request.context) + let fetchOutcome: CodexWeeklyConfirmationFetch = { + let baseOutcome = await request.descriptor.fetchOutcome(context: request.context) + return await Self.attachingCodexResetCreditsIfNeeded( + to: baseOutcome, + env: request.context.env, + fetcher: resetCreditsFetcher) + } + let initialOutcome = await fetchOutcome() + let outcome: ProviderFetchOutcome? = if Self.codexUsageOutcomeMatchesVisibleAccount( + initialOutcome, + account: request.account) + { + if let admitted = await Self.codexOutcomeAdmittedForPublication( + initialOutcome: initialOutcome, + previousSnapshot: request.previousSnapshot, + missingWindowBackfillSnapshot: request.missingWindowBackfillSnapshot, + fetchConfirmation: fetchOutcome), + Self.codexUsageOutcomeMatchesVisibleAccount(admitted, account: request.account) + { + admitted + } else { + nil + } + } else { + nil + } return CodexAccountFetchResult( index: request.index, account: request.account, - outcome: outcome) + outcome: outcome, + limitResetOwnerKey: request.limitResetOwnerKey) } } @@ -382,37 +930,95 @@ extension UsageStore { tokenOverride: override, codexActiveSourceOverride: codexActiveSourceOverride) let fetcher = ProviderRegistry.makeFetcher(base: self.codexFetcher, provider: provider, env: env) - let verbose = self.settings.isVerboseLoggingEnabled + let contextProvider = provider + let publicationGeneration = self.providerRefreshPublicationContexts[provider]?.generation + let contextConfigRevision = self.settings.providerConfigRevision(for: provider) + let originalAccountToken = account?.token + let originalManualToken = provider == .stepfun ? self.settings.stepfunToken : nil return ProviderFetchContext( runtime: .app, sourceMode: sourceMode, includeCredits: includeCredits, - includeOptionalUsage: self.settings.showOptionalCreditsAndExtraUsage, + includeOptionalUsage: ProviderTokenAccountSelection.shouldIncludeOptionalUsage( + provider: provider, + settings: self.settings, + override: override), webTimeout: 60, webDebugDumpHTML: false, - verbose: verbose, + verbose: self.settings.isVerboseLoggingEnabled, env: env, settings: snapshot, fetcher: fetcher, claudeFetcher: self.claudeFetcher, browserDetection: self.browserDetection, selectedTokenAccountID: account?.id, - tokenAccountTokenUpdater: { [weak settings = self.settings] provider, accountID, token in + tokenAccountTokenUpdater: { [weak self] provider, accountID, token in await MainActor.run { - settings?.updateTokenAccount( + guard let self, provider == contextProvider, + self.settings.tokenAccounts(for: provider) + .first(where: { $0.id == accountID })?.token == originalAccountToken + else { + return + } + guard self.providerConfigMutationIsCurrent( + provider: provider, + generation: publicationGeneration, + originalConfigRevision: contextConfigRevision) + else { return } + self.settings.updateTokenAccount( provider: provider, accountID: accountID, token: token) + self.advanceProviderRefreshConfigRevision( + provider: provider, + generation: publicationGeneration) } }, - providerManualTokenUpdater: { [weak settings = self.settings] provider, token in + providerManualTokenUpdater: { [weak self] provider, token in await MainActor.run { - if provider == .stepfun { - settings?.stepfunToken = token - } + guard let self, provider == .stepfun, + self.settings.stepfunToken == originalManualToken + else { return } + guard self.providerConfigMutationIsCurrent( + provider: provider, + generation: publicationGeneration, + originalConfigRevision: contextConfigRevision) + else { return } + self.settings.stepfunToken = token + self.advanceProviderRefreshConfigRevision( + provider: provider, + generation: publicationGeneration) } }, - costUsageHistoryDays: self.settings.costUsageHistoryDays) + costUsageHistoryDays: self.settings.costUsageHistoryDays, + persistsCLISessions: true, + persistentCLISessionIdleWindow: ProviderRegistry.persistentCLISessionIdleWindow( + refreshInterval: self.normalRefreshIntervalForHeuristics())) + } + + private func providerConfigMutationIsCurrent( + provider: UsageProvider, + generation: UInt64?, + originalConfigRevision: UInt64) -> Bool + { + guard let generation else { return true } + let currentConfigRevision = self.settings.providerConfigRevision(for: provider) + guard let publication = self.providerRefreshPublicationContexts[provider] else { return false } + if publication.generation == generation { + return publication.configRevision == currentConfigRevision + } + // A replacement waits for its predecessor before capturing fetch inputs. Let the predecessor persist an + // authorized refresh token while its original config is unchanged; the replacement will then start from it. + return originalConfigRevision == currentConfigRevision + } + + private func advanceProviderRefreshConfigRevision(provider: UsageProvider, generation: UInt64?) { + guard let generation, + var publication = self.providerRefreshPublicationContexts[provider], + publication.generation == generation + else { return } + publication.configRevision = self.settings.providerConfigRevision(for: provider) + self.providerRefreshPublicationContexts[provider] = publication } func sourceMode(for provider: UsageProvider) -> ProviderSourceMode { @@ -424,6 +1030,7 @@ extension UsageStore { private struct ResolvedAccountOutcome { let snapshot: TokenAccountUsageSnapshot? let usage: UsageSnapshot? + let freshUsage: UsageSnapshot? } private struct ResolvedCodexAccountOutcome { @@ -445,6 +1052,249 @@ extension UsageStore { return message.isEmpty ? "Refresh failed" : message } + private func codexResetBackfillSnapshots( + for account: CodexVisibleAccount, + priorSnapshot: CodexAccountUsageSnapshot?, + activeVisibleAccountID: String?) -> [UsageSnapshot] + { + var snapshots: [UsageSnapshot] = [] + if let priorSnapshot, + Self.codexPriorSnapshotAccountMatches(priorSnapshot.account, account: account), + let prior = priorSnapshot.snapshot + { + snapshots.append(prior) + } + if account.id == activeVisibleAccountID, + let lastKnown = self.codexLastKnownResetSnapshot(for: account) + { + snapshots.append(lastKnown) + } + // Plan history remains display-only: its legacy provider and email keys cannot prove + // the composite publication owner required for quota state. + return snapshots + } + + private func codexLastKnownResetSnapshot(for account: CodexVisibleAccount) -> UsageSnapshot? { + guard let snapshot = self.lastKnownResetSnapshots[.codex], + Self.codexVisibleAccountEmailMatches(snapshot: snapshot, account: account), + Self.codexScopedGuard(self.lastCodexUsagePublicationGuard, matches: account) + else { + return nil + } + return snapshot + } + + func codexLastKnownResetSnapshot(matching guardValue: CodexAccountScopedRefreshGuard?) -> UsageSnapshot? { + guard let guardValue, + let lastGuard = self.lastCodexUsagePublicationGuard, + Self.codexScopedRefreshGuardAllowsResetBackfill(lastGuard, matching: guardValue) + else { + return nil + } + return self.lastKnownResetSnapshots[.codex] + } + + private nonisolated static func codexVisibleAccountEmailMatches( + snapshot: UsageSnapshot, + account: CodexVisibleAccount) -> Bool + { + guard let identity = snapshot.identity(for: .codex), + let identityEmail = CodexIdentityResolver.normalizeEmail(identity.accountEmail), + let accountEmail = CodexIdentityResolver.normalizeEmail(account.email), + identityEmail == accountEmail + else { + return false + } + return true + } + + nonisolated static func codexPriorSnapshotAccountMatches( + _ prior: CodexVisibleAccount, + account: CodexVisibleAccount) -> Bool + { + guard let priorEmail = CodexIdentityResolver.normalizeEmail(prior.email), + let accountEmail = CodexIdentityResolver.normalizeEmail(account.email), + priorEmail == accountEmail + else { + return false + } + + let priorWorkspaceID = self.normalizedCodexVisibleAccountText(prior.workspaceAccountID) + .map(CodexOpenAIWorkspaceIdentity.normalizeWorkspaceAccountID) + let accountWorkspaceID = self.normalizedCodexVisibleAccountText(account.workspaceAccountID) + .map(CodexOpenAIWorkspaceIdentity.normalizeWorkspaceAccountID) + if priorWorkspaceID != nil || accountWorkspaceID != nil { + return priorWorkspaceID == accountWorkspaceID + } + + let priorAuthFingerprint = CodexAuthFingerprint.normalize(prior.authFingerprint) + let accountAuthFingerprint = CodexAuthFingerprint.normalize(account.authFingerprint) + if priorAuthFingerprint != nil || accountAuthFingerprint != nil { + guard priorAuthFingerprint == accountAuthFingerprint else { return false } + } + + if prior.selectionSource == account.selectionSource { + switch account.selectionSource { + case .managedAccount: + return true + case .liveSystem: + return prior.id == account.id + case .profileHome: + return true + } + } + + guard prior.id != prior.email, account.id != account.email else { return false } + return prior.id == account.id + } + + private nonisolated static func codexPriorAccountSnapshot( + matching account: CodexVisibleAccount, + in snapshots: [CodexAccountUsageSnapshot]) -> CodexAccountUsageSnapshot? + { + if let exact = snapshots.first(where: { $0.id == account.id }), + self.codexPriorSnapshotAccountMatches(exact.account, account: account) + { + return exact + } + let matches = snapshots.filter { + self.codexPriorSnapshotAccountMatches($0.account, account: account) + } + guard matches.count == 1 else { return nil } + return matches[0] + } + + private nonisolated static func codexScopedGuard( + _ guardValue: CodexAccountScopedRefreshGuard?, + matches account: CodexVisibleAccount) -> Bool + { + guard let guardValue, guardValue.source == account.selectionSource else { return false } + let guardAuthFingerprint = CodexAuthFingerprint.normalize(guardValue.authFingerprint) + let accountAuthFingerprint = CodexAuthFingerprint.normalize(account.authFingerprint) + if guardAuthFingerprint != nil || accountAuthFingerprint != nil { + guard guardAuthFingerprint == accountAuthFingerprint else { return false } + } + let identity = self.codexVisibleAccountIdentity(for: account) + if identity != .unresolved { + return guardValue.identity == identity + } + guard let accountKey = CodexIdentityResolver.normalizeEmail(account.email) else { return false } + return guardValue.accountKey == accountKey + } + + private nonisolated static func codexScopedRefreshGuardAllowsResetBackfill( + _ lastGuard: CodexAccountScopedRefreshGuard, + matching expectedGuard: CodexAccountScopedRefreshGuard) -> Bool + { + self.codexScopedRefreshGuardsMatchAccount(lastGuard, expectedGuard) + } + + private nonisolated static func codexScopedRefreshGuard(for account: CodexVisibleAccount) + -> CodexAccountScopedRefreshGuard + { + let accountEmail = CodexIdentityResolver.normalizeEmail(account.email) + return CodexAccountScopedRefreshGuard( + source: account.selectionSource, + identity: self.codexVisibleAccountIdentity(for: account), + accountKey: accountEmail, + authFingerprint: account.authFingerprint) + } + + private nonisolated static func codexVisibleAccountIdentity(for account: CodexVisibleAccount) -> CodexIdentity { + if let workspaceAccountID = self.normalizedCodexVisibleAccountText(account.workspaceAccountID) { + return .providerAccount(id: CodexOpenAIWorkspaceIdentity.normalizeWorkspaceAccountID(workspaceAccountID)) + } + return CodexIdentityResolver.resolve(accountId: nil, email: account.email) + } + + private nonisolated static func normalizedCodexVisibleAccountText(_ text: String?) -> String? { + guard let trimmed = text?.trimmingCharacters(in: .whitespacesAndNewlines), !trimmed.isEmpty else { + return nil + } + return trimmed + } + + nonisolated static func codexBackfillingResetWindows( + _ snapshot: UsageSnapshot, + from cached: UsageSnapshot) -> UsageSnapshot + { + let primary = self.codexBackfillingResetWindow( + CodexConsumerProjection.sourceRateWindow(for: .session, snapshot: snapshot), + from: CodexConsumerProjection.sourceRateWindow(for: .session, snapshot: cached)) + let secondary = self.codexBackfillingResetWindow( + CodexConsumerProjection.sourceRateWindow(for: .weekly, snapshot: snapshot), + from: CodexConsumerProjection.sourceRateWindow(for: .weekly, snapshot: cached)) + guard primary != snapshot.primary || secondary != snapshot.secondary else { return snapshot } + return snapshot.with(primary: primary, secondary: secondary) + } + + nonisolated static func codexMergedResetBackfillSnapshot( + _ snapshots: [UsageSnapshot], + now: Date = Date()) -> UsageSnapshot? + { + let primary = self.codexPreferredResetBackfillWindow( + snapshots.enumerated().compactMap { index, snapshot in + CodexConsumerProjection.sourceRateWindow(for: .session, snapshot: snapshot) + .map { (window: $0, updatedAt: snapshot.updatedAt, priority: index) } + }, + now: now) + let secondary = self.codexPreferredResetBackfillWindow( + snapshots.enumerated().compactMap { index, snapshot in + CodexConsumerProjection.sourceRateWindow(for: .weekly, snapshot: snapshot) + .map { (window: $0, updatedAt: snapshot.updatedAt, priority: index) } + }, + now: now) + guard primary != nil || secondary != nil else { return nil } + return UsageSnapshot( + primary: primary, + secondary: secondary, + updatedAt: snapshots.map(\.updatedAt).max() ?? now) + } + + private nonisolated static func codexPreferredResetBackfillWindow( + _ windows: [(window: RateWindow, updatedAt: Date, priority: Int)], + now: Date) -> RateWindow? + { + windows + .filter { ($0.window.resetsAt ?? .distantPast) > now } + .max { lhs, rhs in + if lhs.updatedAt != rhs.updatedAt { + return lhs.updatedAt < rhs.updatedAt + } + if lhs.priority != rhs.priority { + return lhs.priority < rhs.priority + } + let lhsReset = lhs.window.resetsAt ?? .distantPast + let rhsReset = rhs.window.resetsAt ?? .distantPast + if lhsReset != rhsReset { + return lhsReset < rhsReset + } + return (lhs.window.windowMinutes ?? 0) < (rhs.window.windowMinutes ?? 0) + } + .map(\.window) + } + + private nonisolated static func codexBackfillingResetWindow( + _ window: RateWindow?, + from cached: RateWindow?) -> RateWindow? + { + guard let cached, + let resetsAt = cached.resetsAt, + resetsAt > Date() + else { + return window + } + if let window { + return window.backfillingResetTime(from: cached) + } + guard let windowMinutes = cached.windowMinutes, windowMinutes > 0 else { return nil } + return RateWindow( + usedPercent: cached.usedPercent, + windowMinutes: windowMinutes, + resetsAt: resetsAt, + resetDescription: cached.resetDescription) + } + func recordFetchedTokenAccountPlanUtilizationHistory( provider: UsageProvider, samples: [(account: ProviderTokenAccount, snapshot: UsageSnapshot)], @@ -474,48 +1324,79 @@ extension UsageStore { account: account, snapshot: labeled, error: nil, - sourceLabel: result.sourceLabel) - return ResolvedAccountOutcome(snapshot: snapshot, usage: labeled) + sourceLabel: result.sourceLabel, + cacheKey: self.tokenAccountSnapshotCacheKey(provider: provider, account: account)) + return ResolvedAccountOutcome(snapshot: snapshot, usage: labeled, freshUsage: labeled) case let .failure(error): // Preserve the last-good snapshot when the refresh was cancelled (e.g. the // user switched menu tabs mid-flight). Without this the per-account list // would briefly render error chips for accounts that already had data. if Self.errorIsCancellation(error) { if let priorSnapshot, priorSnapshot.snapshot != nil { - return ResolvedAccountOutcome(snapshot: priorSnapshot, usage: priorSnapshot.snapshot) + return ResolvedAccountOutcome( + snapshot: priorSnapshot, + usage: priorSnapshot.snapshot, + freshUsage: nil) } // No usable prior data: skip this row entirely. The caller will // either preserve the existing per-account state or fall back to // the single live card. Rendering a "cancelled" placeholder here // produces visually duplicate cards with no useful data. - return ResolvedAccountOutcome(snapshot: nil, usage: nil) + return ResolvedAccountOutcome(snapshot: nil, usage: nil, freshUsage: nil) + } + if provider == .claude, + ClaudeUsageError.isClaudeOAuthUsageRateLimit(error), + let priorSnapshot, + priorSnapshot.sourceLabel == "oauth", + priorSnapshot.cacheKey == self.tokenAccountSnapshotCacheKey(provider: provider, account: account), + let priorUsage = priorSnapshot.snapshot + { + let snapshot = TokenAccountUsageSnapshot( + account: account, + snapshot: priorUsage, + error: nil, + sourceLabel: "oauth", + cacheKey: priorSnapshot.cacheKey) + return ResolvedAccountOutcome(snapshot: snapshot, usage: priorUsage, freshUsage: nil) } let snapshot = TokenAccountUsageSnapshot( account: account, snapshot: nil, error: self.tokenAccountSnapshotErrorMessage(error), - sourceLabel: nil) - return ResolvedAccountOutcome(snapshot: snapshot, usage: nil) + sourceLabel: nil, + cacheKey: self.tokenAccountSnapshotCacheKey(provider: provider, account: account)) + return ResolvedAccountOutcome(snapshot: snapshot, usage: nil, freshUsage: nil) } } private func resolveCodexAccountOutcome( _ outcome: ProviderFetchOutcome, account: CodexVisibleAccount, - priorSnapshot: CodexAccountUsageSnapshot? = nil) -> ResolvedCodexAccountOutcome + priorSnapshot: CodexAccountUsageSnapshot? = nil, + resetBackfillSnapshots: [UsageSnapshot] = []) -> ResolvedCodexAccountOutcome { switch outcome.result { case let .success(result): let scoped = result.usage.scoped(to: .codex) + if let resultEmail = CodexIdentityResolver.normalizeEmail(scoped.accountEmail(for: .codex)), + resultEmail != CodexIdentityResolver.normalizeEmail(account.email) + { + return ResolvedCodexAccountOutcome( + snapshot: priorSnapshot, + usage: nil, + sourceLabel: priorSnapshot?.sourceLabel) + } let labeled = self.applyCodexVisibleAccountLabel(scoped, account: account) + let backfilled = Self.codexMergedResetBackfillSnapshot(resetBackfillSnapshots) + .map { Self.codexBackfillingResetWindows(labeled, from: $0) } ?? labeled let snapshot = CodexAccountUsageSnapshot( account: account, - snapshot: labeled, + snapshot: backfilled, error: nil, sourceLabel: result.sourceLabel) return ResolvedCodexAccountOutcome( snapshot: snapshot, - usage: labeled, + usage: backfilled, sourceLabel: result.sourceLabel) case let .failure(error): if Self.errorIsCancellation(error) { @@ -569,31 +1450,55 @@ extension UsageStore { func applySelectedCodexVisibleAccountOutcome( _ outcome: ProviderFetchOutcome, + account: CodexVisibleAccount, snapshot: UsageSnapshot?, - sourceLabel: String?) async + sourceLabel: String?, + limitResetOwnerKey: CodexLimitResetOwnerKey?, + generation: UInt64? = nil) async { - self.lastFetchAttempts[.codex] = outcome.attempts + guard self.isCurrentProviderRefreshGeneration(.codex, generation: generation) else { return } switch outcome.result { case .success: guard let snapshot else { return } - let backfilled = snapshot.backfillingResetTimes(from: self.lastKnownResetSnapshots[.codex]) - self.handleSessionQuotaTransition(provider: .codex, snapshot: backfilled) - self.lastKnownResetSnapshots[.codex] = backfilled - self.snapshots[.codex] = backfilled + let publicationGuard = Self.codexScopedRefreshGuard(for: account) + let codexOwnerKey = Self.codexSessionQuotaOwnerKey(for: publicationGuard) + self.lastFetchAttempts[.codex] = outcome.attempts + self.handleCodexResetCreditNotifications(snapshot: snapshot) + self.handleQuotaWarningTransitions( + provider: .codex, + snapshot: snapshot, + accountDiscriminator: codexOwnerKey?.rawValue) + self.handleSessionQuotaTransition( + provider: .codex, + snapshot: snapshot, + codexOwnerKey: codexOwnerKey) + self.handlePredictivePaceWarningTransitions(provider: .codex, snapshot: snapshot) + self.lastKnownResetSnapshots[.codex] = snapshot + self.lastCodexUsagePublicationGuard = publicationGuard + self.lastCodexAccountScopedRefreshGuard = publicationGuard + self.snapshots[.codex] = snapshot if let sourceLabel { self.lastSourceLabels[.codex] = sourceLabel } self.errors[.codex] = nil self.failureGates[.codex]?.recordSuccess() - self.rememberLiveSystemCodexEmailIfNeeded(backfilled.accountEmail(for: .codex)) - self.seedCodexAccountScopedRefreshGuard(accountEmail: backfilled.accountEmail(for: .codex)) - await self.recordPlanUtilizationHistorySample(provider: .codex, snapshot: backfilled) - self.recordCodexHistoricalSampleIfNeeded(snapshot: backfilled) + self.rememberLiveSystemCodexEmailIfNeeded(snapshot.accountEmail(for: .codex)) + self.seedCodexAccountScopedRefreshGuard(accountEmail: account.email) + await self.recordPlanUtilizationHistorySample( + provider: .codex, + snapshot: snapshot, + codexLimitResetOwnerKey: limitResetOwnerKey) + guard self.isCurrentProviderRefreshGeneration(.codex, generation: generation) else { return } + self.recordCodexHistoricalSampleIfNeeded(snapshot: snapshot) case let .failure(error): guard let message = self.tokenAccountErrorMessage(error) else { self.errors[.codex] = nil return } + let publicationGuard = Self.codexScopedRefreshGuard(for: account) + self.lastCodexUsagePublicationGuard = publicationGuard + self.lastCodexAccountScopedRefreshGuard = publicationGuard + self.lastFetchAttempts[.codex] = outcome.attempts let hadPriorData = self.snapshots[.codex] != nil let shouldSurface = self.failureGates[.codex]? @@ -611,11 +1516,15 @@ extension UsageStore { _ outcome: ProviderFetchOutcome, provider: UsageProvider, account: ProviderTokenAccount?, - fallbackSnapshot: UsageSnapshot?) async + fallbackSnapshot: UsageSnapshot?, + fallbackAccountSnapshot: TokenAccountUsageSnapshot? = nil, + generation: UInt64? = nil) async { await MainActor.run { + guard self.isCurrentProviderRefreshGeneration(provider, generation: generation) else { return } self.lastFetchAttempts[provider] = outcome.attempts } + guard self.isCurrentProviderRefreshGeneration(provider, generation: generation) else { return } switch outcome.result { case let .success(result): let scoped = result.usage.scoped(to: provider) @@ -625,22 +1534,72 @@ extension UsageStore { scoped } let backfilled = await MainActor.run { - let backfilled = labeled.backfillingResetTimes(from: self.lastKnownResetSnapshots[provider]) - self.handleQuotaWarningTransitions(provider: provider, snapshot: backfilled) + guard self.isCurrentProviderRefreshGeneration(provider, generation: generation) else { + return nil as UsageSnapshot? + } + let profileStable = provider == .deepseek + ? labeled.preservingDeepSeekPlatformProfiles( + from: self.presentationSnapshot(for: .deepseek)) + : labeled + let backfilled = profileStable.backfillingResetTimes(from: self.lastKnownResetSnapshots[provider]) + let warningAccountDiscriminator = Self.warningTokenAccountDiscriminator(account) + self.handleQuotaWarningTransitions( + provider: provider, + snapshot: backfilled, + accountDiscriminator: warningAccountDiscriminator) self.handleSessionQuotaTransition(provider: provider, snapshot: backfilled) + self.handlePredictivePaceWarningTransitions( + provider: provider, + snapshot: backfilled, + accountDiscriminatorOverride: provider == .claude ? warningAccountDiscriminator : nil) self.lastKnownResetSnapshots[provider] = backfilled self.snapshots[provider] = backfilled + self.widgetUsagePreservationBlockedProviders.remove(provider) + if provider == .deepseek { + self.clearDeepSeekProfileTransition() + } + self.publishProviderDerivedTokenSnapshot(from: backfilled, for: provider) self.lastSourceLabels[provider] = result.sourceLabel self.errors[provider] = nil + self.knownLimitsAvailabilityByProvider.removeValue(forKey: provider) self.failureGates[provider]?.recordSuccess() return backfilled } + guard let backfilled else { return } await self.recordPlanUtilizationHistorySample( provider: provider, snapshot: backfilled, account: account) case let .failure(error): await MainActor.run { + if provider == .claude, + ClaudeUsageError.isClaudeOAuthUsageRateLimit(error), + let account, + let currentAccount = self.uniqueTokenAccount(provider: provider, accountID: account.id), + let fallbackAccountSnapshot, + fallbackAccountSnapshot.account.id == currentAccount.id, + fallbackAccountSnapshot.sourceLabel == "oauth", + fallbackAccountSnapshot.cacheKey == self.tokenAccountSnapshotCacheKey( + provider: provider, + account: currentAccount), + let fallback = fallbackAccountSnapshot.snapshot + { + self.snapshots[provider] = fallback + self.lastKnownResetSnapshots[provider] = fallback + self.lastSourceLabels[provider] = "oauth" + self.cacheTokenAccountSnapshot( + provider: provider, + account: currentAccount, + snapshot: fallback, + sourceLabel: "oauth") + self.errors[provider] = nil + self.failureGates[provider]?.reset() + return + } + self.knownLimitsAvailabilityByProvider.removeValue(forKey: provider) + if provider == .deepseek { + self.markDeepSeekProfileTransitionUnavailable() + } guard let message = self.tokenAccountErrorMessage(error) else { self.errors[provider] = nil return @@ -651,41 +1610,11 @@ extension UsageStore { if shouldSurface { self.errors[provider] = message self.snapshots.removeValue(forKey: provider) + self.clearProviderDerivedTokenSnapshot(for: provider) } else { self.errors[provider] = nil } } } } - - func applyAccountLabel( - _ snapshot: UsageSnapshot, - provider: UsageProvider, - account: ProviderTokenAccount) -> UsageSnapshot - { - let label = account.label.trimmingCharacters(in: .whitespacesAndNewlines) - guard !label.isEmpty else { return snapshot } - let existing = snapshot.identity(for: provider) - let email = existing?.accountEmail?.trimmingCharacters(in: .whitespacesAndNewlines) - let resolvedEmail = (email?.isEmpty ?? true) ? label : email - let identity = ProviderIdentitySnapshot( - providerID: provider, - accountEmail: resolvedEmail, - accountOrganization: existing?.accountOrganization, - loginMethod: existing?.loginMethod) - return snapshot.withIdentity(identity) - } - - func applyCodexVisibleAccountLabel(_ snapshot: UsageSnapshot, account: CodexVisibleAccount) -> UsageSnapshot { - let existing = snapshot.identity(for: .codex) - let email = existing?.accountEmail?.trimmingCharacters(in: .whitespacesAndNewlines) - let resolvedEmail = (email?.isEmpty ?? true) ? account.email : email - let loginMethod = existing?.loginMethod ?? account.workspaceLabel - let identity = ProviderIdentitySnapshot( - providerID: .codex, - accountEmail: resolvedEmail, - accountOrganization: existing?.accountOrganization, - loginMethod: loginMethod) - return snapshot.withIdentity(identity) - } } diff --git a/Sources/CodexBar/UsageStore+TokenCost.swift b/Sources/CodexBar/UsageStore+TokenCost.swift index 13c162ed9e..a55a14e72c 100644 --- a/Sources/CodexBar/UsageStore+TokenCost.swift +++ b/Sources/CodexBar/UsageStore+TokenCost.swift @@ -1,11 +1,193 @@ import CodexBarCore import Foundation +struct CurrentProviderConfigTokenSnapshot: Sendable, Equatable { + let snapshot: CostUsageTokenSnapshot + let publicationRevision: UInt64 +} + +struct CurrentProviderConfigTokenPublication: Sendable, Equatable { + let snapshot: CostUsageTokenSnapshot? + let publicationRevision: UInt64 +} + +struct TokenSnapshotPublication: Sendable, Equatable { + let snapshot: CostUsageTokenSnapshot? + let publicationRevision: UInt64 + let providerConfigRevision: UInt64 + let scopeSignature: String +} + extension UsageStore { + enum CursorCostCookiePreparation { + case proceed(String?) + case reject + } + + func prepareCursorCostCookie(for provider: UsageProvider) -> CursorCostCookiePreparation { + guard provider == .cursor, self.settings.cursorCookieSource == .manual else { + return .proceed(nil) + } + guard let header = CookieHeaderNormalizer.normalize(self.settings.cursorCookieHeader) else { + self.lastTokenFetchAt.removeValue(forKey: provider) + self.lastTokenFetchScope.removeValue(forKey: provider) + self.clearTokenSnapshot(for: provider) + self.tokenErrors[provider] = "Cursor cost requires a non-empty Manual cookie header." + self.tokenFailureGates[provider]?.reset() + return .reject + } + return .proceed(header) + } + + func loadTokenUsageSnapshot( + provider: UsageProvider, + force: Bool, + now: Date, + codexHomePath: String?, + historyDays: Int, + cursorCookieHeaderOverride: String? = nil) async throws -> CostUsageTokenSnapshot + { + if let override = self._test_tokenUsageSnapshotLoaderOverride { + return try await override(provider, force, now, codexHomePath, historyDays) + } + + let fetcher = self.costUsageFetcher + let timeoutSeconds = self.tokenFetchTimeout + let allowPricingRefresh = provider != .codex || !self.settings.codexLocalSessionCostLedgerEnabled + let environment = provider == .bedrock + ? ProviderRegistry.makeEnvironment( + base: self.environmentBase, + provider: provider, + settings: self.settings, + tokenOverride: nil) + : self.environmentBase + return try await withThrowingTaskGroup(of: CostUsageTokenSnapshot.self) { group in + group.addTask(priority: .utility) { + try await fetcher.loadTokenSnapshot( + provider: provider, + environment: environment, + now: now, + forceRefresh: force, + allowVertexClaudeFallback: !self.isEnabled(.claude), + codexHomePath: codexHomePath, + historyDays: historyDays, + cursorCookieHeaderOverride: cursorCookieHeaderOverride, + allowPricingRefresh: allowPricingRefresh, + bypassScannerDebounce: true) + } + group.addTask { + try await Task.sleep(nanoseconds: UInt64(timeoutSeconds * 1_000_000_000)) + throw CostUsageError.timedOut(seconds: Int(timeoutSeconds)) + } + defer { group.cancelAll() } + guard let snapshot = try await group.next() else { throw CancellationError() } + return snapshot + } + } + func tokenSnapshot(for provider: UsageProvider) -> CostUsageTokenSnapshot? { self.tokenSnapshots[provider] } + func tokenSnapshotForCurrentProviderConfig( + for provider: UsageProvider) -> CurrentProviderConfigTokenSnapshot? + { + guard let publication = self.tokenSnapshotPublicationForCurrentProviderConfig(for: provider), + let snapshot = publication.snapshot + else { return nil } + return CurrentProviderConfigTokenSnapshot( + snapshot: snapshot, + publicationRevision: publication.publicationRevision) + } + + func tokenSnapshotPublicationForCurrentProviderConfig( + for provider: UsageProvider) -> CurrentProviderConfigTokenPublication? + { + guard let publication = self.tokenSnapshotPublications[provider], + publication.providerConfigRevision == self.settings.providerConfigRevision(for: provider), + publication.scopeSignature == self.tokenSnapshotScopeSignature(for: provider) + else { return nil } + return CurrentProviderConfigTokenPublication( + snapshot: publication.snapshot, + publicationRevision: publication.publicationRevision) + } + + func tokenSnapshotPublicationRevision(for provider: UsageProvider) -> UInt64 { + self.tokenSnapshotPublicationRevisions[provider] ?? 0 + } + + func publishTokenSnapshot(_ snapshot: CostUsageTokenSnapshot, for provider: UsageProvider) { + self.tokenSnapshots[provider] = snapshot + self.publishTokenSnapshotState(snapshot, for: provider) + } + + func publishConfirmedEmptyTokenSnapshot(for provider: UsageProvider) { + self.tokenSnapshots.removeValue(forKey: provider) + self.publishTokenSnapshotState(nil, for: provider) + } + + private func publishTokenSnapshotState(_ snapshot: CostUsageTokenSnapshot?, for provider: UsageProvider) { + self.tokenSnapshotPublicationRevisions[provider, default: 0] &+= 1 + self.tokenSnapshotPublications[provider] = TokenSnapshotPublication( + snapshot: snapshot, + publicationRevision: self.tokenSnapshotPublicationRevision(for: provider), + providerConfigRevision: self.settings.providerConfigRevision(for: provider), + scopeSignature: self.tokenSnapshotScopeSignature(for: provider)) + } + + func installCachedTokenSnapshot(_ snapshot: CostUsageTokenSnapshot, for provider: UsageProvider) { + self.tokenSnapshots[provider] = snapshot + self.tokenSnapshotPublications[provider] = TokenSnapshotPublication( + snapshot: snapshot, + publicationRevision: self.tokenSnapshotPublicationRevision(for: provider), + providerConfigRevision: self.settings.providerConfigRevision(for: provider), + scopeSignature: self.tokenSnapshotScopeSignature(for: provider)) + } + + func clearTokenSnapshot(for provider: UsageProvider) { + self.tokenSnapshots.removeValue(forKey: provider) + self.tokenSnapshotPublications.removeValue(forKey: provider) + } + + func clearTokenSnapshots() { + self.tokenSnapshots.removeAll() + self.tokenSnapshotPublications.removeAll() + } + + func installProviderDerivedTokenSnapshot(from snapshot: UsageSnapshot, for provider: UsageProvider) { + guard Self.tokenCostRequiresProviderSnapshot(provider) else { return } + if let tokenSnapshot = self.tokenSnapshot(fromProviderSnapshot: snapshot, provider: provider) { + self.installCachedTokenSnapshot(tokenSnapshot, for: provider) + } else { + self.clearTokenSnapshot(for: provider) + } + self.tokenErrors[provider] = nil + self.tokenFailureGates[provider]?.recordSuccess() + } + + func publishProviderDerivedTokenSnapshot(from snapshot: UsageSnapshot, for provider: UsageProvider) { + guard Self.tokenCostRequiresProviderSnapshot(provider) else { return } + if let tokenSnapshot = self.tokenSnapshot(fromProviderSnapshot: snapshot, provider: provider) { + self.publishTokenSnapshot(tokenSnapshot, for: provider) + } else { + self.publishConfirmedEmptyTokenSnapshot(for: provider) + } + self.tokenErrors[provider] = nil + self.tokenFailureGates[provider]?.recordSuccess() + } + + func resetProviderDerivedTokenSnapshot(for provider: UsageProvider) { + guard Self.tokenCostRequiresProviderSnapshot(provider) else { return } + self.clearTokenSnapshot(for: provider) + self.tokenErrors[provider] = nil + self.tokenFailureGates[provider]?.reset() + } + + func clearProviderDerivedTokenSnapshot(for provider: UsageProvider) { + guard Self.tokenCostRequiresProviderSnapshot(provider) else { return } + self.clearTokenSnapshot(for: provider) + } + func tokenError(for provider: UsageProvider) -> String? { self.tokenErrors[provider] } @@ -14,20 +196,201 @@ extension UsageStore { self.lastTokenFetchAt[provider] } + @discardableResult + func hydrateCachedTokenSnapshots(now: Date = Date()) -> Task? { + guard self.settings.isCostUsageEffectivelyEnabled(for: .codex) else { return nil } + guard self.settings.enabledProvidersOrdered(metadataByProvider: self.providerMetadata).contains(.codex) else { + return nil + } + + let scope = self.tokenCostScope(for: .codex) + let historyDays = self.settings.costUsageHistoryDays + let publicationRevision = self.providerPublicationRevision(for: .codex) + let providerConfigRevision = self.settings.providerConfigRevision(for: .codex) + let costUsageSettingsRevision = self.settings.costUsageSettingsRevision + let tokenSnapshotScopeSignature = self.tokenSnapshotScopeSignature(for: .codex) + let tokenSnapshotPublicationRevision = self.tokenSnapshotPublicationRevision(for: .codex) + return Task { @MainActor [weak self] in + guard let self else { return } + guard self.tokenSnapshotPublicationForCurrentProviderConfig(for: .codex) == nil else { return } + let result: (snapshot: CostUsageTokenSnapshot, lastRefreshAt: Date?)? = if let override = self + ._test_cachedCodexTokenSnapshotLoaderOverride + { + await override(now, scope.codexHomePath, historyDays) + } else { + await self.costUsageFetcher.loadCachedCodexTokenSnapshotResult( + now: now, + codexHomePath: scope.codexHomePath, + historyDays: historyDays) + .map { (snapshot: $0.snapshot, lastRefreshAt: $0.lastRefreshAt) } + } + guard let result + else { + return + } + guard self.providerPublicationRevisionIsCurrent(publicationRevision, for: .codex), + self.settings.providerConfigRevision(for: .codex) == providerConfigRevision, + self.settings.costUsageSettingsRevision == costUsageSettingsRevision, + self.settings.isCostUsageEffectivelyEnabled(for: .codex), + self.isEnabled(.codex), + self.tokenCostScope(for: .codex).signature == scope.signature, + self.settings.costUsageHistoryDays == historyDays, + self.tokenSnapshotScopeSignature(for: .codex) == tokenSnapshotScopeSignature, + self.tokenSnapshotPublicationRevision(for: .codex) == tokenSnapshotPublicationRevision, + self.tokenSnapshotPublicationForCurrentProviderConfig(for: .codex) == nil + else { + return + } + self.installCachedTokenSnapshot(result.snapshot, for: .codex) + self.tokenErrors[.codex] = nil + if let tokenFetchTTL = self.tokenFetchTTL, + let lastRefreshAt = result.lastRefreshAt, + now.timeIntervalSince(lastRefreshAt) >= 0, + now.timeIntervalSince(lastRefreshAt) < tokenFetchTTL + { + self.lastTokenFetchAt[.codex] = lastRefreshAt + self.lastTokenFetchScope[.codex] = tokenSnapshotScopeSignature + } + } + } + func isTokenRefreshInFlight(for provider: UsageProvider) -> Bool { self.tokenRefreshInFlight.contains(provider) } func tokenCostScope(for provider: UsageProvider) -> (codexHomePath: String?, signature: String) { + if provider == .vertexai { + return (nil, "vertexai:allow-claude-fallback=\(!self.isEnabled(.claude))") + } guard provider == .codex else { return (nil, provider.rawValue) } - let homePath = self.settings.activeManagedCodexRemoteHomePath? - .trimmingCharacters(in: .whitespacesAndNewlines) - guard let homePath, !homePath.isEmpty else { + if self.settings.codexLocalSessionCostLedgerEnabled { return (nil, "codex:ambient") } - return (homePath, "codex:managed:\(homePath)") + let activeSource = self.settings.codexActiveSource + switch activeSource { + case .liveSystem: + return (nil, "codex:ambient") + case let .managedAccount(id): + let homePath = self.settings.managedCodexRemoteHomePath(forActiveSource: activeSource)? + .trimmingCharacters(in: .whitespacesAndNewlines) + if let homePath, !homePath.isEmpty { + return (homePath, "codex:managed:\(homePath)") + } + let unavailablePath = Self.costUsageCacheDirectory() + .appendingPathComponent("unavailable-managed", isDirectory: true) + .appendingPathComponent(id.uuidString, isDirectory: true) + .path + return (unavailablePath, "codex:managed:unavailable:\(id.uuidString)") + case .profileHome: + let homePath = self.settings.profileCodexHomePath(forActiveSource: activeSource)? + .trimmingCharacters(in: .whitespacesAndNewlines) + if let homePath, !homePath.isEmpty { + return (homePath, "codex:profile:\(homePath)") + } + let unavailablePath = Self.costUsageCacheDirectory() + .appendingPathComponent("unavailable-profile", isDirectory: true) + .path + return (unavailablePath, "codex:profile-unavailable") + } + } + + func tokenSnapshotScopeSignature(for provider: UsageProvider) -> String { + let scope = self.tokenCostScope(for: provider) + let historyDays = self.settings.costUsageHistoryDays + let base = "\(scope.signature)|historyDays=\(historyDays)" + + "|settingsRevision=\(self.settings.costUsageSettingsRevision)" + guard provider == .cursor else { + return base + } + + let source = self.settings.cursorCookieSource + if source == .manual { + let headerFingerprint = CookieHeaderNormalizer.normalize(self.settings.cursorCookieHeader) + .map(CookieHeaderCache.credentialFingerprint) ?? "missing" + return "\(base)|cursorCookie=manual:\(headerFingerprint)" + } + + let credentialFingerprint = CookieHeaderCache.loadForDisplay(provider: .cursor) + .map { CookieHeaderCache.credentialFingerprint($0.cookieHeader) } ?? "unresolved" + return self.cursorCostScopeSignature( + historyDays: historyDays, + source: source, + credentialFingerprint: credentialFingerprint) + } + + func cursorCostScopeSignature( + historyDays: Int, + source: ProviderCookieSource, + credentialFingerprint: String) -> String + { + let scope = self.tokenCostScope(for: .cursor) + return "\(scope.signature)|historyDays=\(historyDays)" + + "|settingsRevision=\(self.settings.costUsageSettingsRevision)" + + "|cursorCookie=\(source.rawValue):\(credentialFingerprint)" + } + + func tokenRefreshCanReuseCurrentSnapshot( + provider: UsageProvider, + now: Date, + costScopeSignature: String) -> Bool + { + guard self.tokenSnapshotPublicationForCurrentProviderConfig(for: provider) != nil, + let last = self.lastTokenFetchAt[provider], + self.lastTokenFetchScope[provider] == costScopeSignature + else { + return false + } + guard let tokenFetchTTL = self.tokenFetchTTL else { return false } + return now.timeIntervalSince(last) < tokenFetchTTL + } + + func tokenRefreshPublicationIsCurrent( + provider: UsageProvider, + publicationRevision: ProviderPublicationRevision, + providerConfigRevision: UInt64, + historyDays: Int, + costScopeSignature: String, + fetchedCredentialScopeFingerprint: String? = nil) -> Bool + { + guard self.providerPublicationRevisionIsCurrent(publicationRevision, for: provider), + self.settings.providerConfigRevision(for: provider) == providerConfigRevision, + self.settings.costUsageEnabled, + self.isEnabled(provider), + self.settings.costUsageHistoryDays == historyDays + else { + return false + } + let currentSignature = self.tokenSnapshotScopeSignature(for: provider) + if provider == .cursor, + self.settings.cursorCookieSource == .auto, + costScopeSignature.contains("|cursorCookie=auto:"), + let fetchedCredentialScopeFingerprint + { + let resolvedSignature = self.cursorCostScopeSignature( + historyDays: historyDays, + source: .auto, + credentialFingerprint: fetchedCredentialScopeFingerprint) + return currentSignature == resolvedSignature + } + return currentSignature == costScopeSignature + } + + func completedTokenCostScopeSignature( + provider: UsageProvider, + historyDays: Int, + initialSignature: String, + snapshot: CostUsageTokenSnapshot) -> String + { + guard provider == .cursor, + self.settings.cursorCookieSource == .auto, + let fingerprint = snapshot.credentialScopeFingerprint + else { return initialSignature } + return self.cursorCostScopeSignature( + historyDays: historyDays, + source: .auto, + credentialFingerprint: fingerprint) } func tokenSnapshot( @@ -40,6 +403,14 @@ extension UsageStore { snapshot?.openAIAPIUsage?.toCostUsageTokenSnapshot() case .mistral: snapshot?.mistralUsage?.toCostUsageTokenSnapshot(historyDays: self.settings.costUsageHistoryDays) + case .opencodego: + // Web-only source mode and machines with no readable local database leave + // `opencodegoUsage.daily` empty; a non-nil-but-dataless projection would still + // surface a Cost row whose history submenu has nothing to render. + snapshot?.opencodegoUsage.flatMap { usage in + usage.daily.isEmpty ? nil : usage + .toCostUsageTokenSnapshot(historyDays: self.settings.costUsageHistoryDays) + } default: nil } @@ -47,7 +418,7 @@ extension UsageStore { nonisolated static func tokenCostRequiresProviderSnapshot(_ provider: UsageProvider) -> Bool { switch provider { - case .mistral, .openai: + case .mistral, .openai, .opencodego: true default: false @@ -63,6 +434,37 @@ 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.clearTokenSnapshots() + 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+TokenRefreshSequence.swift b/Sources/CodexBar/UsageStore+TokenRefreshSequence.swift new file mode 100644 index 0000000000..ff719fcea5 --- /dev/null +++ b/Sources/CodexBar/UsageStore+TokenRefreshSequence.swift @@ -0,0 +1,159 @@ +import CodexBarCore +import Foundation + +extension UsageStore { + private enum TokenRefreshSequenceScope: Sendable { + case all + case provider(UsageProvider) + case providers([UsageProvider]) + } + + func startTokenTimer() { + self.tokenTimerTask?.cancel() + guard let wait = self.tokenFetchTTL else { return } + self.tokenTimerTask = Task.detached(priority: .utility) { [weak self] in + while !Task.isCancelled { + do { + try await Task.sleep(for: .seconds(wait)) + } catch { + return + } + await self?.scheduleTokenRefresh() + } + } + } + + func scheduleTokenRefresh() { + guard self.tokenRefreshSequenceTask == nil, !self.hasForcedRefreshEnrichmentInFlight else { return } + if self.startPendingTokenRefreshRetryIfPossible() { + return + } + self.startTokenRefreshSequence(force: false, scope: .all) + } + + func refreshTokenUsageSequenceNow(force: Bool) async { + guard let task = await self.serializedTokenRefreshTask(force: force, scope: .all) else { return } + await self.awaitTokenRefreshSequence(task) + } + + func refreshTokenUsageNow(for provider: UsageProvider, force: Bool) async { + if force, + self.tokenRefreshSequenceTask != nil, + let activeProvider = self.tokenRefreshSequenceProvider, + activeProvider != provider + { + // A scoped user refresh can run beside unrelated scheduled work. The scheduled + // sequence still owns the shared slot, so the timer cannot introduce a third pass. + await self.refreshTokenUsage(provider, force: true) + self.scheduleMemoryPressureRelief() + return + } + guard let task = await self.serializedTokenRefreshTask(force: force, scope: .provider(provider)) else { + return + } + await self.awaitTokenRefreshSequence(task) + } + + private func serializedTokenRefreshTask( + force: Bool, + scope: TokenRefreshSequenceScope) async -> Task? + { + if force { + while let existing = self.tokenRefreshSequenceTask { + existing.cancel() + await existing.value + guard !Task.isCancelled else { return nil } + } + } else if let existing = self.tokenRefreshSequenceTask { + return existing + } + return self.startTokenRefreshSequence(force: force, scope: scope) + } + + @discardableResult + private func startTokenRefreshSequence( + force: Bool, + scope: TokenRefreshSequenceScope) -> Task + { + let providers: [UsageProvider] = switch scope { + case .all: + self.enabledProvidersForBackgroundWork() + case let .provider(provider): + [provider] + case let .providers(providers): + providers + } + let token = UUID() + self.tokenRefreshSequenceToken = token + // Publish the first owner before installing the task. A scoped forced refresh can arrive + // before the task gets its first MainActor turn and must not mistake this slot for unknown work. + self.tokenRefreshSequenceProvider = providers.first + let task = Task(priority: .utility) { @MainActor [weak self] in + guard let self else { return } + await self.refreshTokenUsageSequence(providers: providers, force: force) + self.completeTokenRefreshSequence(token: token) + } + self.tokenRefreshSequenceTask = task + return task + } + + private func awaitTokenRefreshSequence(_ task: Task) async { + await withTaskCancellationHandler { + await task.value + } onCancel: { + task.cancel() + } + } + + private func completeTokenRefreshSequence(token: UUID) { + guard self.tokenRefreshSequenceToken == token else { return } + self.tokenRefreshSequenceTask = nil + self.tokenRefreshSequenceToken = nil + self.tokenRefreshSequenceProvider = nil + self.startPendingTokenRefreshRetryIfPossible() + } + + func requestTokenRefreshAfterStaleCompletion(for provider: UsageProvider) { + self.tokenRefreshRetryProviders.insert(provider) + Task { @MainActor [weak self] in + await Task.yield() + self?.startPendingTokenRefreshRetryIfPossible() + } + } + + @discardableResult + private func startPendingTokenRefreshRetryIfPossible() -> Bool { + guard !self.tokenRefreshRetryProviders.isEmpty, + self.tokenRefreshSequenceTask == nil, + self.settings.costUsageEnabled || self.settings.codexLocalSessionCostLedgerEnabled + else { + return false + } + let providers = self.enabledProvidersForBackgroundWork().filter(self.tokenRefreshRetryProviders.contains) + guard !providers.isEmpty else { return false } + self.tokenRefreshRetryProviders.subtract(providers) + // Retry only lanes whose prior completion was rejected. Disabled lanes remain pending + // until re-enabled, while unrelated providers keep their valid TTL and avoid a second scan. + self.startTokenRefreshSequence(force: true, scope: .providers(providers)) + return true + } + + private func refreshTokenUsageSequence(providers: [UsageProvider], force: Bool) async { + defer { self.tokenRefreshSequenceProvider = nil } + for provider in providers { + if Task.isCancelled { + break + } + self.tokenRefreshSequenceProvider = provider + await self.refreshTokenUsage(provider, force: force) + self.tokenRefreshSequenceProvider = nil + } + self.scheduleMemoryPressureRelief() + } + + #if DEBUG + func scheduleTokenRefreshForTesting() { + self.scheduleTokenRefresh() + } + #endif +} diff --git a/Sources/CodexBar/UsageStore+WidgetSnapshot.swift b/Sources/CodexBar/UsageStore+WidgetSnapshot.swift index e8befc3215..6eb8f14eb9 100644 --- a/Sources/CodexBar/UsageStore+WidgetSnapshot.swift +++ b/Sources/CodexBar/UsageStore+WidgetSnapshot.swift @@ -6,7 +6,17 @@ import WidgetKit extension UsageStore { func persistWidgetSnapshot(reason: String) { - let snapshot = self.makeWidgetSnapshot() + // A fresh process has token-cost data before a user-authorized Claude OAuth refresh can run. + // Keep the last queued snapshot in memory so back-to-back writes cannot race the on-disk cache. + let previousSnapshot = self.lastQueuedWidgetSnapshot ?? { + #if DEBUG + // Snapshot-save overrides must stay isolated from a developer's real app-group data. + guard self._test_widgetSnapshotSaveOverride == nil else { return nil } + #endif + return WidgetSnapshotStore.load() + }() + let snapshot = self.makeWidgetSnapshot(previousSnapshot: previousSnapshot) + self.lastQueuedWidgetSnapshot = snapshot let previousTask = self.widgetSnapshotPersistTask self.widgetSnapshotPersistTask = Task { @MainActor in _ = await previousTask?.result @@ -25,19 +35,54 @@ extension UsageStore { } } - private func makeWidgetSnapshot() -> WidgetSnapshot { + private func makeWidgetSnapshot(previousSnapshot: WidgetSnapshot?) -> WidgetSnapshot { + let now = Date() let enabledProviders = self.enabledProviders() let entries = UsageProvider.allCases.compactMap { provider in - self.makeWidgetEntry(for: provider) + self.makeWidgetEntry( + for: provider, + now: now, + previousEntry: previousSnapshot?.entries.first { $0.provider == provider }) } - return WidgetSnapshot(entries: entries, enabledProviders: enabledProviders, generatedAt: Date()) + return WidgetSnapshot( + entries: entries, + enabledProviders: enabledProviders, + usageBarsShowUsed: self.settings.usageBarsShowUsed, + generatedAt: now) } - private func makeWidgetEntry(for provider: UsageProvider) -> WidgetSnapshot.ProviderEntry? { - guard let snapshot = self.snapshots[provider] else { return nil } + private func makeWidgetEntry( + for provider: UsageProvider, + now: Date, + previousEntry: WidgetSnapshot.ProviderEntry?) -> WidgetSnapshot.ProviderEntry? + { + let snapshot = self.snapshots[provider] + let storedTokenSnapshot = self.tokenSnapshotForCurrentProviderConfig(for: provider)?.snapshot + let claudeQuotaOwnerKey: String? = if provider == .claude { + self.claudeWidgetQuotaOwnerKey() + } else { + nil + } + let preservedClaudeUsage: PreservedClaudeWidgetUsage? = if provider == .claude, + snapshot == nil, + !self.widgetUsagePreservationBlockedProviders + .contains(provider), + self.knownLimitsAvailabilityByProvider[provider]? + .isUnavailable != true + { + Self.preservedClaudeWidgetUsage( + from: previousEntry, + expectedQuotaOwnerKey: claudeQuotaOwnerKey) + } else { + nil + } + guard snapshot != nil || + (provider == .claude && (storedTokenSnapshot != nil || preservedClaudeUsage != nil)) + else { + return nil + } - let tokenSnapshot = self.tokenSnapshot(fromProviderSnapshot: snapshot, provider: provider) ?? self - .tokenSnapshots[provider] + let tokenSnapshot = storedTokenSnapshot let dailyUsage = tokenSnapshot?.daily.map { entry in WidgetSnapshot.DailyUsagePoint( dayKey: entry.date, @@ -46,15 +91,17 @@ extension UsageStore { } ?? [] let tokenUsage = Self.widgetTokenUsageSummary(from: tokenSnapshot, provider: provider) - let usageRows = self.widgetUsageRows(provider: provider, snapshot: snapshot) + let usageRows = snapshot.map { + self.widgetUsageRows(provider: provider, snapshot: $0, now: now) + } ?? preservedClaudeUsage?.usageRows ?? [] let creditsRemaining: Double? let codeReviewRemaining: Double? - if provider == .codex { + if provider == .codex, let snapshot { let projection = self.codexConsumerProjection( surface: .widget, snapshotOverride: snapshot, - now: snapshot.updatedAt) + now: now) let displayOnlyExtrasHidden = projection.dashboardVisibility == .displayOnly creditsRemaining = displayOnlyExtrasHidden ? nil : projection.credits?.remaining codeReviewRemaining = displayOnlyExtrasHidden ? nil : projection.remainingPercent(for: .codeReview) @@ -62,29 +109,111 @@ extension UsageStore { creditsRemaining = nil codeReviewRemaining = nil } + let providerCost: ProviderCostSnapshot? = if provider == .devin, + self.settings.showOptionalCreditsAndExtraUsage + { + snapshot?.providerCost + } else { + nil + } + let quotaOwnerKey: String? = if provider == .claude { + snapshot != nil ? claudeQuotaOwnerKey : preservedClaudeUsage?.quotaOwnerKey + } else { + nil + } return WidgetSnapshot.ProviderEntry( provider: provider, - updatedAt: snapshot.updatedAt, - primary: snapshot.primary, - secondary: snapshot.secondary, - tertiary: snapshot.tertiary, + updatedAt: snapshot?.updatedAt ?? preservedClaudeUsage?.updatedAt ?? tokenSnapshot?.updatedAt ?? now, + primary: snapshot?.primary ?? preservedClaudeUsage?.primary, + secondary: snapshot?.secondary ?? preservedClaudeUsage?.secondary, + tertiary: snapshot?.tertiary ?? preservedClaudeUsage?.tertiary, usageRows: usageRows, creditsRemaining: creditsRemaining, codeReviewRemainingPercent: codeReviewRemaining, tokenUsage: tokenUsage, - dailyUsage: dailyUsage) + dailyUsage: dailyUsage, + providerCost: providerCost, + quotaOwnerKey: quotaOwnerKey) + } + + private struct PreservedClaudeWidgetUsage { + let updatedAt: Date + let primary: RateWindow? + let secondary: RateWindow? + let tertiary: RateWindow? + let usageRows: [WidgetSnapshot.WidgetUsageRowSnapshot]? + let quotaOwnerKey: String? } - private nonisolated static func widgetTokenUsageSummary( + private func claudeWidgetQuotaOwnerKey() -> String { + if let account = self.settings.effectiveSelectedTokenAccount(for: .claude) { + return self.tokenAccountSnapshotCacheKey(provider: .claude, account: account) + } + let environment = ProviderRegistry.makeEnvironment( + base: self.environmentBase, + provider: .claude, + settings: self.settings, + tokenOverride: nil) + return ClaudeOAuthCredentialsStore.credentialsProfileIdentifier(environment: environment) + } + + private nonisolated static func preservedClaudeWidgetUsage( + from entry: WidgetSnapshot.ProviderEntry?, + expectedQuotaOwnerKey: String?) -> PreservedClaudeWidgetUsage? + { + guard let entry, entry.provider == .claude else { return nil } + guard let expectedQuotaOwnerKey, + let quotaOwnerKey = entry.quotaOwnerKey, + quotaOwnerKey == expectedQuotaOwnerKey + else { + return nil + } + + let primary = entry.primary?.isSyntheticPlaceholder == true ? nil : entry.primary + let secondary = entry.secondary?.isSyntheticPlaceholder == true ? nil : entry.secondary + let tertiary = entry.tertiary?.isSyntheticPlaceholder == true ? nil : entry.tertiary + let usageRows = entry.usageRows?.filter { row in + guard row.window?.isSyntheticPlaceholder != true else { return false } + return switch row.id { + case "primary": primary != nil + case "secondary": secondary != nil + case "tertiary": tertiary != nil + default: row.percentLeft != nil + } + } + guard primary != nil || secondary != nil || tertiary != nil || usageRows?.isEmpty == false else { + return nil + } + return PreservedClaudeWidgetUsage( + updatedAt: entry.updatedAt, + primary: primary, + secondary: secondary, + tertiary: tertiary, + usageRows: usageRows, + quotaOwnerKey: quotaOwnerKey) + } + + nonisolated static func widgetTokenUsageSummary( from snapshot: CostUsageTokenSnapshot?, provider: UsageProvider) -> WidgetSnapshot.TokenUsageSummary? { guard let snapshot else { return nil } let fallbackTokens = snapshot.daily.compactMap(\.totalTokens).reduce(0, +) let monthTokensValue = snapshot.last30DaysTokens ?? (fallbackTokens > 0 ? fallbackTokens : nil) - let sessionLabel = provider == .bedrock || provider == .mistral ? "Latest billing day" : "Today" - let monthLabel = snapshot.historyLabel ?? (snapshot.historyDays == 1 ? "Today" : "\(snapshot.historyDays)d") + let sessionLabel = if provider == .bedrock || provider == .mistral { + "Latest billing day" + } else if provider == .codex { + "Today API est. · not billed" + } else { + "Today" + } + let defaultMonthLabel = snapshot.historyDays == 1 ? "Today" : "\(snapshot.historyDays)d" + let monthLabel = if provider == .codex { + "\(snapshot.historyLabel ?? defaultMonthLabel) API est. · not billed" + } else { + snapshot.historyLabel ?? defaultMonthLabel + } return WidgetSnapshot.TokenUsageSummary( sessionCostUSD: snapshot.sessionCostUSD, sessionTokens: snapshot.sessionTokens, @@ -92,21 +221,23 @@ extension UsageStore { last30DaysTokens: monthTokensValue, currencyCode: snapshot.currencyCode, sessionLabel: sessionLabel, - last30DaysLabel: monthLabel) + last30DaysLabel: monthLabel, + updatedAt: snapshot.updatedAt) } private func widgetUsageRows( provider: UsageProvider, - snapshot: UsageSnapshot) -> [WidgetSnapshot.WidgetUsageRowSnapshot] + snapshot: UsageSnapshot, + now: Date) -> [WidgetSnapshot.WidgetUsageRowSnapshot] { let metadata = ProviderDefaults.metadata[provider] if provider == .codex { let projection = self.codexConsumerProjection( surface: .widget, snapshotOverride: snapshot, - now: snapshot.updatedAt) + now: now) return projection.visibleRateLanes.compactMap { lane in - guard let window = projection.rateWindow(for: lane) else { return nil } + guard let window = projection.sourceRateWindow(for: lane) else { return nil } let title = switch lane { case .session: metadata?.sessionLabel ?? "Session" @@ -116,18 +247,76 @@ extension UsageStore { return WidgetSnapshot.WidgetUsageRowSnapshot( id: lane.rawValue, title: title, - percentLeft: window.remainingPercent) + percentLeft: window.remainingPercent, + window: window) } } + if provider == .claude, + let spendLimit = MenuBarMetricWindowResolver.claudeSpendLimitWindow(snapshot: snapshot) + { + let period = snapshot.providerCost?.period?.trimmingCharacters(in: .whitespacesAndNewlines) + let title = period.flatMap { $0.isEmpty ? nil : $0 } ?? "Extra usage" + return [ + WidgetSnapshot.WidgetUsageRowSnapshot( + id: "extraUsage", + title: title, + percentLeft: spendLimit.remainingPercent, + window: spendLimit), + ] + } + if provider == .antigravity, + let rows = Self.antigravityQuotaSummaryWidgetRows(snapshot: snapshot), + !rows.isEmpty + { + return rows + } + if provider == .antigravity, + snapshot.primary == nil, + snapshot.secondary == nil, + let rows = Self.antigravityLegacyExtraWidgetRows(snapshot: snapshot), + !rows.isEmpty + { + return rows + } let primaryTitle: String = { + // Legacy request-based Cursor plans track a request quota, not the token-based "Total" pool. + if provider == .cursor, snapshot.cursorRequests != nil { + return "Requests" + } if provider == .grok, let dyn = GrokProviderDescriptor.primaryLabel(window: snapshot.primary) { return dyn } + if provider == .doubao, + let dyn = DoubaoProviderDescriptor.primaryLabel(window: snapshot.primary) + { + return dyn + } + if provider == .amp, + let dyn = AmpProviderDescriptor.primaryLabel(details: snapshot.ampUsage) + { + return dyn + } + if provider == .crof { + return CrofProviderDescriptor.primaryLabel(snapshot: snapshot) + } + if provider == .alibabatokenplan, + let dyn = AlibabaTokenPlanProviderDescriptor.primaryLabel(window: snapshot.primary) + { + return dyn + } return metadata?.sessionLabel ?? "Session" }() + let secondaryTitle = if provider == .amp { + AmpProviderDescriptor.secondaryLabel(details: snapshot.ampUsage) ?? metadata?.weeklyLabel ?? "Weekly" + } else if provider == .alibabatokenplan { + AlibabaTokenPlanProviderDescriptor.secondaryLabel(window: snapshot.secondary) ?? + metadata?.weeklyLabel ?? "Weekly" + } else { + metadata?.weeklyLabel ?? "Weekly" + } var rows: [WidgetSnapshot.WidgetUsageRowSnapshot] = [ WidgetSnapshot.WidgetUsageRowSnapshot( @@ -136,7 +325,7 @@ extension UsageStore { percentLeft: snapshot.primary?.remainingPercent), WidgetSnapshot.WidgetUsageRowSnapshot( id: "secondary", - title: metadata?.weeklyLabel ?? "Weekly", + title: secondaryTitle, percentLeft: snapshot.secondary?.remainingPercent), ] if metadata?.supportsOpus == true { @@ -145,6 +334,51 @@ extension UsageStore { title: metadata?.opusLabel ?? "Opus", percentLeft: snapshot.tertiary?.remainingPercent)) } + if provider == .kimi { + // Keep persisted widget order stable and include only Kimi's intentional subscription lanes. + let kimiWindowIDs = ["kimi-monthly", "kimi-code-7d"] + rows.append(contentsOf: kimiWindowIDs.compactMap { id in + guard let window = snapshot.extraRateWindows?.first(where: { $0.id == id }), window.usageKnown + else { return nil } + return WidgetSnapshot.WidgetUsageRowSnapshot( + id: window.id, + title: window.title, + percentLeft: window.window.remainingPercent) + }) + } return rows.filter { $0.percentLeft != nil } } + + private nonisolated static let antigravityQuotaSummaryWindowIDPrefix = "antigravity-quota-summary-" + private nonisolated static let antigravityCompactFallbackWindowIDPrefix = "antigravity-compact-fallback-" + + private nonisolated static func antigravityQuotaSummaryWidgetRows( + snapshot: UsageSnapshot) -> [WidgetSnapshot.WidgetUsageRowSnapshot]? + { + guard let windows = snapshot.extraRateWindows?.filter({ + $0.id.hasPrefix(Self.antigravityQuotaSummaryWindowIDPrefix) + }), !windows.isEmpty else { + return nil + } + return windows.map { namedWindow in + WidgetSnapshot.WidgetUsageRowSnapshot( + id: namedWindow.id, + title: namedWindow.title, + percentLeft: namedWindow.usageKnown ? namedWindow.window.remainingPercent : nil) + } + } + + private nonisolated static func antigravityLegacyExtraWidgetRows( + snapshot: UsageSnapshot) -> [WidgetSnapshot.WidgetUsageRowSnapshot]? + { + let windows = snapshot.extraRateWindows? + .filter { $0.id.hasPrefix(Self.antigravityCompactFallbackWindowIDPrefix) && $0.usageKnown } + guard let windows, !windows.isEmpty else { return nil } + return windows.map { namedWindow in + WidgetSnapshot.WidgetUsageRowSnapshot( + id: namedWindow.id, + title: namedWindow.title, + percentLeft: namedWindow.window.remainingPercent) + } + } } diff --git a/Sources/CodexBar/UsageStore.swift b/Sources/CodexBar/UsageStore.swift index d2c35695d2..19c9488717 100644 --- a/Sources/CodexBar/UsageStore.swift +++ b/Sources/CodexBar/UsageStore.swift @@ -11,11 +11,15 @@ extension UsageStore { var menuObservationToken: Int { _ = self.snapshots _ = self.errors + _ = self.diagnostics + _ = self.knownLimitsAvailabilityByProvider _ = self.lastSourceLabels _ = self.lastFetchAttempts - _ = self.accountSnapshots - _ = self.codexAccountSnapshots + _ = (self.accountSnapshots, self.tokenAccountLiveStateProviders, self.codexAccountSnapshots) _ = self.kiloScopeSnapshots + _ = self.claudeSwapAccountSnapshots + _ = self.claudeSwapLastError + _ = self.claudeSwapRevision _ = self.tokenSnapshots _ = self.tokenErrors _ = self.tokenRefreshInFlight @@ -24,13 +28,16 @@ extension UsageStore { _ = self.openAIDashboard _ = self.lastOpenAIDashboardError _ = self.openAIDashboardRequiresLogin + _ = self.openAIDashboardAttachmentRevision _ = self.versions _ = self.isRefreshing + _ = self.hasForcedRefreshEnrichmentInFlight _ = self.refreshingProviders _ = self.pathDebugInfo _ = self.statuses _ = self.probeLogs _ = self.historicalPaceRevision + _ = self.planUtilizationHistoryRevision _ = self.providerStorageFootprints return 0 } @@ -38,6 +45,8 @@ extension UsageStore { var iconObservationToken: Int { _ = self.snapshots _ = self.errors + _ = self.diagnostics + _ = self.knownLimitsAvailabilityByProvider _ = self.credits _ = self.lastCreditsError _ = self.openAIDashboard @@ -45,36 +54,14 @@ extension UsageStore { _ = self.openAIDashboardRequiresLogin _ = self.refreshingProviders _ = self.statuses + _ = self.tokenSnapshotPublications _ = self.historicalPaceRevision return 0 } func observeSettingsChanges() { withObservationTracking { - _ = self.settings.refreshFrequency - _ = self.settings.statusChecksEnabled - _ = self.settings.sessionQuotaNotificationsEnabled - _ = self.settings.quotaWarningNotificationsEnabled - _ = self.settings.quotaWarningThresholds - _ = self.settings.quotaWarningThresholds(.session) - _ = self.settings.quotaWarningThresholds(.weekly) - _ = self.settings.quotaWarningSoundEnabled - _ = self.settings.usageBarsShowUsed - _ = self.settings.costUsageEnabled - _ = self.settings.costUsageHistoryDays - _ = self.settings.randomBlinkEnabled - _ = self.settings.configRevision - for implementation in ProviderCatalog.all { - implementation.observeSettings(self.settings) - } - _ = self.settings.multiAccountMenuLayout - _ = self.settings.tokenAccountsByProvider - _ = self.settings.mergeIcons - _ = self.settings.selectedMenuProvider - _ = self.settings.debugLoadingPattern - _ = self.settings.debugKeepCLISessionsAlive - _ = self.settings.historicalTrackingEnabled - _ = self.settings.providerStorageFootprintsEnabled + _ = self.backgroundWorkSettingsObservationToken } onChange: { [weak self] in Task { @MainActor [weak self] in guard let self else { return } @@ -83,22 +70,70 @@ extension UsageStore { self.probeLogs = [:] guard self.startupBehavior.automaticallyStartsBackgroundWork else { return } self.startTimer() + self.startTokenTimer() self.updateProviderRuntimes() + let enabledNow = Set(self.settings.enabledProvidersOrdered( + metadataByProvider: self.providerMetadata)) + if enabledNow != self.versionDetectionProviders { + self.detectVersions() + } await self.refreshHistoricalDatasetIfNeeded() - await self.refresh() + await self.refreshForSettingsChange() } } } + var backgroundWorkSettingsObservationToken: Int { + _ = self.settings.backgroundWorkSettingsRevision + return 0 + } + var attachedOpenAIDashboardSnapshot: OpenAIDashboardSnapshot? { guard self.openAIDashboardAttachmentAuthorized else { return nil } return self.openAIDashboard } + + private static func isRunningTestsProcess() -> Bool { + let environment = ProcessInfo.processInfo.environment + let testKeys = ["XCTestConfigurationFilePath", "XCTestSessionIdentifier", "SWIFT_TESTING_ENABLED"] + return testKeys.contains(where: { environment[$0] != nil }) || CommandLine.arguments.contains { argument in + argument.contains("xctest") || argument.contains("swift-testing") + } + } + + /// Returns the login method (plan type) for the specified provider, if available. + private func loginMethod(for provider: UsageProvider) -> String? { + self.snapshots[provider]?.loginMethod(for: provider) + } + + /// Returns true if the Claude account appears to be a subscription (Max, Pro, Ultra, Team). + /// Returns false for API users or when plan cannot be determined. + func isClaudeSubscription() -> Bool { + Self.isSubscriptionPlan(self.loginMethod(for: .claude)) + } + + /// Determines if a login method string indicates a Claude subscription plan. + /// Known subscription indicators: Max, Pro, Ultra, Team (case-insensitive). + nonisolated static func isSubscriptionPlan(_ loginMethod: String?) -> Bool { + ClaudePlan.isSubscriptionLoginMethod(loginMethod) + } + + var preferredSnapshot: UsageSnapshot? { + for provider in self.enabledProviders() { + if let snap = self.snapshots[provider] { + return snap + } + } + return nil + } } @MainActor @Observable final class UsageStore { + nonisolated static let resetBoundaryRefreshGraceSeconds: TimeInterval = 30 + nonisolated static let resetBoundaryRefreshMinimumDelaySeconds: TimeInterval = 5 + private struct ProviderAvailabilityCacheEntry { let available: Bool let configRevision: Int @@ -109,44 +144,43 @@ final class UsageStore { } } + struct AccountInfoCacheEntry { + let account: AccountInfo + let configRevision: Int + let expiresAt: Date + + func isValid(now: Date, configRevision: Int) -> Bool { + self.configRevision == configRevision && self.expiresAt > now + } + } + enum CodexCreditsSource { case none case api 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 diagnostics: [UsageProvider: String] = [:] + var geminiObservedConsumerTierDeprecation = false + var knownLimitsAvailabilityByProvider: [UsageProvider: UsageLimitsAvailability] = [:] var lastSourceLabels: [UsageProvider: String] = [:] var lastFetchAttempts: [UsageProvider: [ProviderFetchAttempt]] = [:] var accountSnapshots: [UsageProvider: [TokenAccountUsageSnapshot]] = [:] + var tokenAccountLiveStateProviders: Set = [] var codexAccountSnapshots: [CodexAccountUsageSnapshot] = [] var kiloScopeSnapshots: [KiloScopeSnapshot] = [] + var claudeSwapAccountSnapshots: [ProviderAccountUsageSnapshot] = [] + var claudeSwapLastRefreshAt: Date? + var claudeSwapLastError: String? + var claudeSwapDetectedVersion: String? + var claudeSwapRevision: UInt64 = 0 + @ObservationIgnored var claudeSwapRefreshTask: Task? + @ObservationIgnored var claudeSwapTransientState = ClaudeSwapTransientState() var tokenSnapshots: [UsageProvider: CostUsageTokenSnapshot] = [:] + var tokenSnapshotPublications: [UsageProvider: TokenSnapshotPublication] = [:] + var tokenSnapshotPublicationRevisions: [UsageProvider: UInt64] = [:] var tokenErrors: [UsageProvider: String] = [:] var tokenRefreshInFlight: Set = [] var credits: CreditsSnapshot? @@ -157,26 +191,39 @@ final class UsageStore { var openAIDashboardCookieImportStatus: String? var openAIDashboardCookieImportDebugLog: String? var versions: [UsageProvider: String] = [:] + @ObservationIgnored var versionDetectionProviders: Set = [] var isRefreshing = false + var hasForcedRefreshEnrichmentInFlight = false var refreshingProviders: Set = [] var debugForceAnimation = false var pathDebugInfo: PathDebugSnapshot = .empty var statuses: [UsageProvider: ProviderStatus] = [:] + var statusComponents: [UsageProvider: [ProviderStatusComponent]] = [:] 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? + @ObservationIgnored var lastOpenAIDashboardTargetIsolationKey: String? @ObservationIgnored var lastOpenAIDashboardAttemptAt: Date? @ObservationIgnored var lastOpenAIDashboardCookieImportAttemptAt: Date? @ObservationIgnored var lastOpenAIDashboardCookieImportEmail: String? @ObservationIgnored var lastCodexAccountScopedRefreshGuard: CodexAccountScopedRefreshGuard? + @ObservationIgnored var lastCodexUsagePublicationGuard: CodexAccountScopedRefreshGuard? @ObservationIgnored var lastKnownLiveSystemCodexEmail: String? @ObservationIgnored var openAIWebAccountDidChange: Bool = false @ObservationIgnored var creditsRefreshTask: Task? @@ -195,43 +242,98 @@ 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_codexResetCreditsFetcherOverride: CodexResetCreditsFetcher? @ObservationIgnored var _test_widgetSnapshotSaveOverride: (@MainActor (WidgetSnapshot) async -> Void)? @ObservationIgnored var _test_providerRefreshOverride: (@MainActor (UsageProvider) async -> Void)? + @ObservationIgnored var _test_providerFetchOutcomeOverride: (@MainActor ( + UsageProvider) async -> ProviderFetchOutcome)? @ObservationIgnored var _test_tokenUsageRefreshOverride: (@MainActor (UsageProvider, Bool) async -> Void)? + @ObservationIgnored var _test_tokenUsageSnapshotLoaderOverride: (@MainActor ( + UsageProvider, + Bool, + Date, + String?, + Int) async throws -> CostUsageTokenSnapshot)? + @ObservationIgnored var _test_cachedCodexTokenSnapshotLoaderOverride: (@MainActor ( + Date, + String?, + Int) async -> (snapshot: CostUsageTokenSnapshot, lastRefreshAt: Date?)?)? + @ObservationIgnored var _test_providerStatusFetchOverride: (@MainActor ( + UsageProvider) async throws -> ProviderStatus)? + @ObservationIgnored var _test_forcedRefreshEnrichmentWaitObserver: (@MainActor () -> Void)? + @ObservationIgnored var _test_startupConnectivityRetryScheduled: (@MainActor (Int, TimeInterval) -> Void)? + @ObservationIgnored var _test_startupConnectivityRetrySleepOverride: (@MainActor ( + TimeInterval) async throws -> Void)? @ObservationIgnored var widgetSnapshotPersistTask: Task? + @ObservationIgnored var lastQueuedWidgetSnapshot: WidgetSnapshot? + @ObservationIgnored var widgetUsagePreservationBlockedProviders: Set = [] @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 @ObservationIgnored let environmentBase: [String: String] - @ObservationIgnored private let sessionQuotaNotifier: any SessionQuotaNotifying - @ObservationIgnored private let sessionQuotaLogger = CodexBarLog.logger(LogCategories.sessionQuota) + @ObservationIgnored let sessionQuotaNotifier: any SessionQuotaNotifying + @ObservationIgnored let sessionQuotaLogger = CodexBarLog.logger(LogCategories.sessionQuota) @ObservationIgnored let openAIWebLogger = CodexBarLog.logger(LogCategories.openAIWeb) @ObservationIgnored private let tokenCostLogger = CodexBarLog.logger(LogCategories.tokenCost) @ObservationIgnored let augmentLogger = CodexBarLog.logger(LogCategories.augment) @ObservationIgnored let providerLogger = CodexBarLog.logger(LogCategories.providers) + @ObservationIgnored let adaptiveRefreshLogger = CodexBarLog.logger(LogCategories.adaptiveRefresh) @ObservationIgnored var openAIWebDebugLines: [String] = [] @ObservationIgnored var failureGates: [UsageProvider: ConsecutiveFailureGate] = [:] @ObservationIgnored var tokenFailureGates: [UsageProvider: ConsecutiveFailureGate] = [:] @ObservationIgnored var providerSpecs: [UsageProvider: ProviderSpec] = [:] @ObservationIgnored let providerMetadata: [UsageProvider: ProviderMetadata] @ObservationIgnored var providerRuntimes: [UsageProvider: any ProviderRuntime] = [:] + @ObservationIgnored var providerRefreshCoordinator = ProviderRefreshCoordinator() + @ObservationIgnored var providerRefreshPublicationContexts: [UsageProvider: ProviderRefreshPublicationContext] = [:] + @ObservationIgnored var providerCleanupRevisions: [UsageProvider: UInt64] = [:] @ObservationIgnored private var providerAvailabilityCache: [UsageProvider: ProviderAvailabilityCacheEntry] = [:] + @ObservationIgnored var accountInfoCache: [UsageProvider: AccountInfoCacheEntry] = [:] @ObservationIgnored private var timerTask: Task? - @ObservationIgnored private var tokenTimerTask: Task? - @ObservationIgnored private var tokenRefreshSequenceTask: Task? + /// In-memory only; resets on every launch. + @ObservationIgnored private(set) var lastMenuOpenAt: Date? + /// Latest local Codex/Claude transcript activity observed by the existing session scanner. + /// In-memory only; paths and session identities never enter the refresh policy. + @ObservationIgnored private(set) var lastCodingActivityAt: Date? + @ObservationIgnored var adaptiveRefreshScheduledAt: Date? + @ObservationIgnored var tokenTimerTask: Task? + @ObservationIgnored var tokenRefreshSequenceTask: Task? + @ObservationIgnored var tokenRefreshSequenceToken: UUID? + @ObservationIgnored var tokenRefreshSequenceProvider: UsageProvider? + @ObservationIgnored var tokenRefreshRetryProviders: Set = [] + @ObservationIgnored var forcedRefreshEnrichmentTask: Task? + @ObservationIgnored var forcedRefreshEnrichmentToken: UUID? + @ObservationIgnored var pendingForcedRefreshEnrichmentTask: Task? + @ObservationIgnored var pendingForcedRefreshEnrichmentToken: UUID? + @ObservationIgnored var forcedRefreshEnrichmentGeneration: UInt64 = 0 + @ObservationIgnored var requiredRefreshTask: Task? + @ObservationIgnored var requiredRefreshTaskToken: UUID? + @ObservationIgnored var pendingRequiredRefreshRequest: RequiredRefreshRequest? + @ObservationIgnored var requiredRefreshRequestGeneration: UInt64 = 0 + @ObservationIgnored var requiredRefreshCompletedGeneration: UInt64 = 0 + @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? + @ObservationIgnored var storageRefreshInFlightRequestKey: String? @ObservationIgnored var lastStorageRefreshSignature: String? + @ObservationIgnored var lastStorageRefreshRequestKey: String? @ObservationIgnored var lastStorageRefreshAt: Date? @ObservationIgnored var managedCodexAccountsForStorageOverride: [ManagedCodexAccount]? @ObservationIgnored private var pathDebugRefreshTask: Task? + @ObservationIgnored var resetBoundaryRefreshTask: Task? + @ObservationIgnored var scheduledResetBoundaryRefreshAt: Date? + @ObservationIgnored var attemptedResetBoundaryRefreshes: Set = [] @ObservationIgnored var codexPlanHistoryBackfillTask: Task? @ObservationIgnored let historicalUsageHistoryStore: HistoricalUsageHistoryStore @ObservationIgnored let planUtilizationHistoryStore: PlanUtilizationHistoryStore @@ -239,19 +341,56 @@ final class UsageStore { @ObservationIgnored var codexHistoricalDataset: CodexHistoricalDataset? @ObservationIgnored var codexHistoricalDatasetAccountKey: String? @ObservationIgnored var lastKnownResetSnapshots: [UsageProvider: UsageSnapshot] = [:] - @ObservationIgnored var lastKnownSessionRemaining: [UsageProvider: Double] = [:] - @ObservationIgnored var lastKnownSessionWindowSource: [UsageProvider: SessionQuotaWindowSource] = [:] + @ObservationIgnored var deepseekProfileTransition: DeepSeekProfileTransition? + @ObservationIgnored var sessionQuotaTransitionStates: [UsageProvider: SessionQuotaTransitionState] = [:] + @ObservationIgnored var codexSessionQuotaBaselineRequirement: CodexSessionQuotaBaselineRequirement? + var codexSessionQuotaBaselineRequired: Bool { + self.codexSessionQuotaBaselineRequirement != nil + } + @ObservationIgnored var quotaWarningState: [QuotaWarningStateKey: QuotaWarningState] = [:] + @ObservationIgnored let hookRateLimiter = HookRateLimiter() + @ObservationIgnored var providerStatusHadIssue: [UsageProvider: Bool] = [:] + /// Last observed usage fraction (0...1) per account and quota-warning lane, used + /// to detect upward crossings of a quota_low hook rule's own threshold. + @ObservationIgnored var quotaLowHookUsage: [QuotaWarningStateKey: Double] = [:] + @ObservationIgnored var quotaLowHookConfigRevision: Int? + @ObservationIgnored var predictivePaceWarningNotifiedKeys: Set = [] @ObservationIgnored var lastPermissionPromptNotificationAt: [UsageProvider: Date] = [:] @ObservationIgnored var lastTokenFetchAt: [UsageProvider: Date] = [:] @ObservationIgnored var lastTokenFetchScope: [UsageProvider: String] = [:] @ObservationIgnored var planUtilizationHistory: [UsageProvider: PlanUtilizationHistoryBuckets] = [:] - @ObservationIgnored var weeklyLimitResetDetectorStates: [String: WeeklyLimitResetDetectorState] = [:] + @ObservationIgnored var sessionEquivalentBurnCache: [UsageProvider: SessionEquivalentBurnCacheEntry] = [:] + @ObservationIgnored var sessionEquivalentHistoryScanCount: Int = 0 + + /// Background load task; cleared on deinit and on the cancel test seam. + @ObservationIgnored var planUtilizationHistoryLoadTask: Task? + /// Set once after the load completes. Gates mutation paths and sync menu + /// accessors so they cannot race the decode or write empty history back to disk. + @ObservationIgnored var planUtilizationHistoryLoaded: Bool = false + @ObservationIgnored var sessionLimitResetDetectorStates: [String: LimitResetDetectorState] = [:] + @ObservationIgnored var weeklyLimitResetDetectorStates: [String: LimitResetDetectorState] = [:] @ObservationIgnored private var hasCompletedInitialRefresh: Bool = false @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 accountInfoCacheTTL: TimeInterval = 30 + /// Token scans can cause an additional widget snapshot publication. Keep the shortest automatic + /// cadence at five minutes so one- and two-minute provider refreshes do not exhaust WidgetKit's + /// reload budget or repeatedly traverse large local histories. + static let minimumTokenFetchTTL: TimeInterval = 5 * 60 + + var tokenFetchTTL: TimeInterval? { + Self.tokenFetchTTL(for: self.settings.refreshFrequency) + } + + static func tokenFetchTTL(for frequency: RefreshFrequency) -> TimeInterval? { + let interval = frequency.usesAdaptivePolicy + ? AdaptiveRefreshPolicy.nominalIntervalForHeuristics + : frequency.seconds + return interval.map { max($0, Self.minimumTokenFetchTTL) } + } + + @ObservationIgnored let tokenFetchTimeout: TimeInterval = 10 * 60 + @ObservationIgnored let startupBehavior: StartupBehavior @ObservationIgnored let planUtilizationPersistenceCoordinator: PlanUtilizationHistoryPersistenceCoordinator init( @@ -262,11 +401,12 @@ final class UsageStore { settings: SettingsStore, registry: ProviderRegistry = .shared, historicalUsageHistoryStore: HistoricalUsageHistoryStore = HistoricalUsageHistoryStore(), - planUtilizationHistoryStore: PlanUtilizationHistoryStore = .defaultAppSupport(), + planUtilizationHistoryStore: PlanUtilizationHistoryStore? = nil, codexAccountUsageSnapshotStore: (any CodexAccountUsageSnapshotStoring)? = nil, sessionQuotaNotifier: any SessionQuotaNotifying = SessionQuotaNotifier(), startupBehavior: StartupBehavior = .automatic, - environmentBase: [String: String] = ProcessInfo.processInfo.environment) + environmentBase: [String: String] = ProcessInfo.processInfo.environment, + planUtilizationHistoryLoadGateForTesting: PlanUtilizationHistoryLoadGate? = nil) { self.codexFetcher = fetcher self.browserDetection = browserDetection @@ -276,13 +416,14 @@ final class UsageStore { self.registry = registry self.environmentBase = environmentBase self.historicalUsageHistoryStore = historicalUsageHistoryStore - self.planUtilizationHistoryStore = planUtilizationHistoryStore - self.sessionQuotaNotifier = sessionQuotaNotifier self.startupBehavior = startupBehavior.resolved(isRunningTests: Self.isRunningTestsProcess()) + let planHistoryStore = Self.resolvedPlanHistoryStore(planUtilizationHistoryStore, startup: self.startupBehavior) + self.planUtilizationHistoryStore = planHistoryStore + self.sessionQuotaNotifier = sessionQuotaNotifier self.codexAccountUsageSnapshotStore = codexAccountUsageSnapshotStore ?? (self.startupBehavior.automaticallyStartsBackgroundWork ? FileCodexAccountUsageSnapshotStore() : nil) self.planUtilizationPersistenceCoordinator = PlanUtilizationHistoryPersistenceCoordinator( - store: planUtilizationHistoryStore) + store: planHistoryStore) self.providerMetadata = registry.metadata self .failureGates = Dictionary( @@ -301,11 +442,17 @@ final class UsageStore { self.providerRuntimes = Dictionary(uniqueKeysWithValues: ProviderCatalog.all.compactMap { implementation in implementation.makeRuntime().map { (implementation.id, $0) } }) - self.planUtilizationHistory = planUtilizationHistoryStore.load() + self.startPlanUtilizationHistoryLoad( + gate: planUtilizationHistoryLoadGateForTesting, + enabled: self.startupBehavior.automaticallyStartsBackgroundWork) + self.sessionLimitResetDetectorStates = Self.loadLimitResetDetectorStates( + from: settings.userDefaults, + defaultsKey: Self.sessionLimitResetDetectorDefaultsKey, + logName: "session") self.weeklyLimitResetDetectorStates = Self.loadWeeklyLimitResetDetectorStates(from: settings.userDefaults) if let codexAccountUsageSnapshotStore = self.codexAccountUsageSnapshotStore { self.codexAccountSnapshots = codexAccountUsageSnapshotStore.load( - for: settings.codexVisibleAccountProjection.visibleAccounts) + for: self.freshCodexVisibleAccountsForSnapshotHydration()) } self.logStartupState() self.bindSettings() @@ -316,6 +463,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 @@ -329,52 +477,16 @@ final class UsageStore { Task { @MainActor [weak self] in await self?.refreshHistoricalDatasetIfNeeded() } - Task { await self.refresh() } + Task { await self.refresh(enrichmentMode: .automatic) } self.startTimer() self.startTokenTimer() } - private static func isRunningTestsProcess() -> Bool { - let environment = ProcessInfo.processInfo.environment - if environment["XCTestConfigurationFilePath"] != nil { return true } - if environment["XCTestSessionIdentifier"] != nil { return true } - if environment["SWIFT_TESTING_ENABLED"] != nil { return true } - return CommandLine.arguments.contains { argument in - argument.contains("xctest") || argument.contains("swift-testing") - } - } - - /// Returns the login method (plan type) for the specified provider, if available. - private func loginMethod(for provider: UsageProvider) -> String? { - self.snapshots[provider]?.loginMethod(for: provider) - } - - /// Returns true if the Claude account appears to be a subscription (Max, Pro, Ultra, Team). - /// Returns false for API users or when plan cannot be determined. - func isClaudeSubscription() -> Bool { - Self.isSubscriptionPlan(self.loginMethod(for: .claude)) - } - - /// Determines if a login method string indicates a Claude subscription plan. - /// Known subscription indicators: Max, Pro, Ultra, Team (case-insensitive). - nonisolated static func isSubscriptionPlan(_ loginMethod: String?) -> Bool { - ClaudePlan.isSubscriptionLoginMethod(loginMethod) - } - - func version(for provider: UsageProvider) -> String? { - self.versions[provider] - } - - var preferredSnapshot: UsageSnapshot? { - for provider in self.enabledProviders() { - if let snap = self.snapshots[provider] { return snap } - } - return nil - } - var iconStyle: IconStyle { let enabled = self.enabledProviders() - if enabled.count > 1 { return .combined } + if enabled.count > 1 { + return .combined + } if let provider = enabled.first { return self.style(for: provider) } @@ -462,6 +574,18 @@ final class UsageStore { self.errors[provider] != nil } + func knownLimitsAvailability(for provider: UsageProvider) -> UsageLimitsAvailability? { + self.knownLimitsAvailabilityByProvider[provider] + } + + func hasSatisfiedUsageFetch(for provider: UsageProvider) -> Bool { + self.snapshot(for: provider) != nil || self.knownLimitsAvailability(for: provider)?.isUnavailable == true + } + + func needsUsageRefreshRetry(for provider: UsageProvider) -> Bool { + self.isStale(provider: provider) || !self.hasSatisfiedUsageFetch(for: provider) + } + func isEnabled(_ provider: UsageProvider) -> Bool { let enabled = self.settings.isProviderEnabledCached( provider: provider, @@ -510,39 +634,52 @@ final class UsageStore { self.providerAvailabilityCache.removeAll(keepingCapacity: true) } - func performRuntimeAction(_ action: ProviderRuntimeAction, for provider: UsageProvider) async { - guard let runtime = self.providerRuntimes[provider] else { return } - let context = ProviderRuntimeContext(provider: provider, settings: self.settings, store: self) - await runtime.perform(action: action, context: context) - } + #if DEBUG + @ObservationIgnored private(set) var completedRefreshCountForTesting = 0 + #endif - private func updateProviderRuntimes() { - for (provider, runtime) in self.providerRuntimes { - let context = ProviderRuntimeContext(provider: provider, settings: self.settings, store: self) - if self.isEnabled(provider) { - runtime.start(context: context) - } else { - runtime.stop(context: context) - } - runtime.settingsDidChange(context: context) + @discardableResult + func runRefresh( + enrichmentMode: RefreshEnrichmentMode = .automatic, + startupConnectivityRetryAttempt: Int?, + coalesceProviderRefreshesOverride: Bool? = nil, + waitForRefreshAvailability: Bool = false) async -> Bool + { + if enrichmentMode == .automatic, waitForRefreshAvailability { + return await self.enqueueRequiredRefresh( + startupConnectivityRetryAttempt: startupConnectivityRetryAttempt, + coalesceProviderRefreshesOverride: coalesceProviderRefreshesOverride) } - } - func refresh(forceTokenUsage: Bool = false) async { - guard !self.isRefreshing else { return } + guard !self.isRefreshing else { return false } + guard enrichmentMode != .automatic || !self.hasForcedRefreshEnrichmentInFlight else { return false } + let forcedBackgroundGeneration: UInt64? + if enrichmentMode == .forcedBackground { + self.forcedRefreshEnrichmentGeneration &+= 1 + forcedBackgroundGeneration = self.forcedRefreshEnrichmentGeneration + } else { + forcedBackgroundGeneration = nil + } 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() let availableRefreshProviders = Set(self.enabledProviders()) let refreshStartedAt = Date() - await ProviderRefreshContext.$current.withValue(refreshPhase) { + let completedRefresh = await ProviderRefreshContext.$current.withValue(refreshPhase) { self.isRefreshing = true defer { self.isRefreshing = false self.hasCompletedInitialRefresh = true + self.startupConnectivityRetryRefreshActive = false } self.clearDisabledProviderState(enabledProviders: enabledProviderSet) @@ -553,65 +690,75 @@ final class UsageStore { await withTaskGroup(of: Void.self) { group in for provider in refreshProviders { - group.addTask { await self.refreshProvider(provider) } + group.addTask { + await self.refreshProvider( + provider, + coalesceIfRefreshing: coalesceProviderRefreshesOverride ?? + (ProviderInteractionContext.current == .background)) + } if availableRefreshProviders.contains(provider) { - group.addTask { await self.refreshStatus(provider) } + group.addTask { await self.refreshProviderStatus(provider) } } } - if forceTokenUsage { + if enrichmentMode == .forcedForeground { group.addTask { await self.refreshCreditsNow(minimumSnapshotUpdatedAt: refreshStartedAt) } } } + guard !Task.isCancelled else { return false } - if !forceTokenUsage { + if enrichmentMode == .automatic { self.scheduleCreditsRefreshIfNeeded(minimumSnapshotUpdatedAt: refreshStartedAt) } - if forceTokenUsage { + if enrichmentMode == .forcedForeground { await self.refreshTokenUsageSequenceNow(force: true) - } else { + } else if enrichmentMode == .automatic { // Token-cost usage can be slow; run it outside regular/menu-open refreshes so we don't block UI. - self.scheduleTokenRefresh(force: false) + self.scheduleTokenRefresh() } // OpenAI web scrape depends on the current Codex account email (which can change after login/account // switch). Run this after Codex usage refresh so we don't accidentally scrape with stale credentials. - self.syncOpenAIWebState() - let refreshPolicy = OpenAIWebRefreshPolicyContext( - accessEnabled: self.isEnabled(.codex) && - self.settings.openAIWebAccessEnabled && - self.settings.codexCookieSource.isEnabled, - batterySaverEnabled: self.settings.openAIWebBatterySaverEnabled, - force: forceTokenUsage) - let shouldRefreshOpenAIWeb = Self.shouldRunOpenAIWebRefresh(refreshPolicy) - self.openAIWebLogger.debug( - "OpenAI web refresh gate", - metadata: [ - "allowed": shouldRefreshOpenAIWeb ? "1" : "0", - "accessEnabled": refreshPolicy.accessEnabled ? "1" : "0", - "batterySaverEnabled": refreshPolicy.batterySaverEnabled ? "1" : "0", - "force": refreshPolicy.force ? "1" : "0", - "interaction": ProviderInteractionContext.current == .userInitiated ? "user" : "background", - "phase": refreshPhase == .startup ? "startup" : "regular", - ]) - if shouldRefreshOpenAIWeb { - let codexDashboardGuard = self.currentCodexOpenAIWebRefreshGuard() - if forceTokenUsage { - await self.refreshOpenAIDashboardIfNeeded( - force: true, - expectedGuard: codexDashboardGuard) - } else { - self.scheduleOpenAIDashboardRefreshIfNeeded(expectedGuard: codexDashboardGuard) - } + if enrichmentMode == .forcedBackground { + // Account ownership must fail closed before the responsive foreground pass returns; + // only the expensive dashboard fetch belongs in the deferred enrichment tail. + self.syncOpenAIWebState() + } else { + await self.refreshOpenAIWebAfterProviderRefresh( + force: enrichmentMode == .forcedForeground, + refreshPhase: openAIWebRefreshPhase) } - if forceTokenUsage, self.openAIDashboardRequiresLogin { + if enrichmentMode == .forcedForeground, self.openAIDashboardRequiresLogin { await self.refreshProvider(.codex) await self.refreshCreditsNow(minimumSnapshotUpdatedAt: refreshStartedAt) } self.persistWidgetSnapshot(reason: "refresh") + if let forcedBackgroundGeneration { + self.enqueueForcedRefreshEnrichment( + generation: forcedBackgroundGeneration, + refreshStartedAt: refreshStartedAt, + openAIWebRefreshPhase: openAIWebRefreshPhase) + } + return true + } + + guard completedRefresh else { return false } + + self.scheduleResetBoundaryRefreshIfNeeded( + normalRefreshInterval: self.normalRefreshIntervalForHeuristics()) + + if allowsStartupConnectivityRetry { + self.completeStartupConnectivityRetryPass(currentAttempt: startupConnectivityRetryAttempt ?? 0) + } + if refreshPhase == .startup { + self.scheduleMemoryPressureRelief() } + #if DEBUG + self.completedRefreshCountForTesting += 1 + #endif + return true } /// For demo/testing: drop the snapshot so the loading animation plays, then restore the last snapshot. @@ -634,63 +781,64 @@ final class UsageStore { self.observeSettingsChanges() } - private func startTimer() { - self.timerTask?.cancel() - guard let wait = self.settings.refreshFrequency.seconds else { return } - - // Background poller so the menu stays responsive; canceled when settings change or store deallocates. - self.timerTask = Task.detached(priority: .utility) { [weak self] in - while !Task.isCancelled { - try? await Task.sleep(for: .seconds(wait)) - await self?.refresh() - } - } - } + #if DEBUG + @ObservationIgnored private(set) var refreshTimerSleepOverrideForTesting: Duration? - private func startTokenTimer() { - self.tokenTimerTask?.cancel() - let wait = self.tokenFetchTTL - self.tokenTimerTask = Task.detached(priority: .utility) { [weak self] in - while !Task.isCancelled { - try? await Task.sleep(for: .seconds(wait)) - await self?.scheduleTokenRefresh(force: false) - } - } + /// Sets this store's timer sleep override and restarts the timer with it applied, so tests can + /// observe multiple fixed/adaptive ticks without waiting real minutes. The reason/delay a tick + /// computes and logs is unaffected; only how long it sleeps before acting on that decision + /// changes. Instance-scoped (not a shared global) so concurrently running tests, each with their + /// own `UsageStore`, cannot clobber one another's override. + func restartTimerWithSleepOverrideForTesting(_ duration: Duration?) { + self.refreshTimerSleepOverrideForTesting = duration + self.startTimer() } + #endif - private func scheduleTokenRefresh(force: Bool) { - if force { - self.tokenRefreshSequenceTask?.cancel() - self.tokenRefreshSequenceTask = nil - } else if self.tokenRefreshSequenceTask != nil { - return + private func startTimer(preservingResetBoundaryRefresh: Bool = false) { + self.timerTask?.cancel() + self.adaptiveRefreshScheduledAt = nil + if !preservingResetBoundaryRefresh { + self.cancelResetBoundaryRefresh() } - self.tokenRefreshSequenceTask = Task(priority: .utility) { [weak self] in - guard let self else { return } - defer { - Task { @MainActor [weak self] in - self?.tokenRefreshSequenceTask = nil + let frequency = self.settings.refreshFrequency + guard frequency != .manual else { return } + + if frequency.usesAdaptivePolicy { + // Background poller so the menu stays responsive; canceled when settings change or store + // deallocates. Delay is recomputed before every tick from live power/thermal state and the + // in-memory menu-open signal; the policy itself stays pure (Input is built here). `self` is + // only strongly held for the brief, synchronous decision computation below, never across + // the sleep — a weak reference lets the store deallocate mid-sleep, same as fixed mode. + self.timerTask = Task.detached(priority: .utility) { [weak self] in + while !Task.isCancelled { + guard let sleepDuration = await Self.nextAdaptiveTimerSleepDuration(for: self) else { return } + try? await Task.sleep(for: sleepDuration) + guard !Task.isCancelled else { return } + await self?.refresh(enrichmentMode: .automatic) } } - await self.refreshTokenUsageSequence(force: force) - } - } - - private func refreshTokenUsageSequenceNow(force: Bool) async { - if force, let existing = self.tokenRefreshSequenceTask { - existing.cancel() - await existing.value - self.tokenRefreshSequenceTask = nil + return } - await self.refreshTokenUsageSequence(force: force) - } + guard let wait = frequency.seconds else { return } + #if DEBUG + let fixedTimerSleepOverride = self.refreshTimerSleepOverrideForTesting + #else + let fixedTimerSleepOverride: Duration? = nil + #endif - private func refreshTokenUsageSequence(force: Bool) async { - for provider in self.enabledProvidersForBackgroundWork() { - if Task.isCancelled { break } - await self.refreshTokenUsage(provider, force: force) + // Background poller so the menu stays responsive; canceled when settings change or store deallocates. + // Fixed cadence is anchored to the scheduled tick time, not refresh completion, so slow provider + // work doesn't permanently stretch a two-minute interval into "refresh duration + two minutes". + self.timerTask = Task.detached(priority: .utility) { [weak self] in + await Self.runFixedRefreshTimer( + interval: .seconds(wait), + sleepOverride: fixedTimerSleepOverride, + refresh: { [weak self] in + await self?.refresh(enrichmentMode: .automatic) + }) } } @@ -698,216 +846,43 @@ final class UsageStore { self.timerTask?.cancel() self.tokenTimerTask?.cancel() self.tokenRefreshSequenceTask?.cancel() + self.forcedRefreshEnrichmentTask?.cancel() + self.pendingForcedRefreshEnrichmentTask?.cancel() + self.requiredRefreshTask?.cancel() + self.creditsRefreshTask?.cancel() + self.openAIDashboardBackgroundRefreshTask?.cancel() + self.openAIDashboardRefreshTask?.cancel() + self.memoryPressureReliefTask?.cancel() + self.startupConnectivityRetryTask?.cancel() self.storageRefreshTask?.cancel() self.codexPlanHistoryBackfillTask?.cancel() + self.resetBoundaryRefreshTask?.cancel() + self.planUtilizationHistoryLoadTask?.cancel() } enum SessionQuotaWindowSource: String { case primary case copilotSecondaryFallback + case zaiTertiary + case antigravityQuotaSummary + case antigravityLegacy } - struct QuotaWarningStateKey: Hashable { - let provider: UsageProvider - let window: QuotaWarningWindow - } - - struct QuotaWarningState { - var lastRemaining: Double? - var firedThresholds: Set = [] - } - - private func sessionQuotaWindow( - provider: UsageProvider, - snapshot: UsageSnapshot) -> (window: RateWindow, source: SessionQuotaWindowSource)? - { - if let primary = snapshot.primary, Self.isSessionWindow(primary) { - return (primary, .primary) - } - if provider == .copilot, let secondary = snapshot.secondary { - return (secondary, .copilotSecondaryFallback) - } - return nil - } - - private static func isSessionWindow(_ window: RateWindow) -> Bool { - guard let minutes = window.windowMinutes else { return true } - return minutes <= 6 * 60 - } - - func handleSessionQuotaTransition(provider: UsageProvider, snapshot: UsageSnapshot) { - // Session quota notifications are tied to the primary session window. Copilot free plans can - // expose only chat quota, so allow Copilot to fall back to secondary for transition tracking. - guard let sessionWindow = self.sessionQuotaWindow(provider: provider, snapshot: snapshot) else { - self.lastKnownSessionRemaining.removeValue(forKey: provider) - self.lastKnownSessionWindowSource.removeValue(forKey: provider) - return - } - let currentRemaining = sessionWindow.window.remainingPercent - let currentSource = sessionWindow.source - let previousRemaining = self.lastKnownSessionRemaining[provider] - let previousSource = self.lastKnownSessionWindowSource[provider] - - if let previousSource, previousSource != currentSource { - let providerText = provider.rawValue - self.sessionQuotaLogger.debug( - "session window source changed: provider=\(providerText) prevSource=\(previousSource.rawValue) " + - "currSource=\(currentSource.rawValue) curr=\(currentRemaining)") - self.lastKnownSessionRemaining[provider] = currentRemaining - self.lastKnownSessionWindowSource[provider] = currentSource - return - } - - defer { - self.lastKnownSessionRemaining[provider] = currentRemaining - self.lastKnownSessionWindowSource[provider] = currentSource - } - - guard self.settings.sessionQuotaNotificationsEnabled else { - if SessionQuotaNotificationLogic.isDepleted(currentRemaining) || - SessionQuotaNotificationLogic.isDepleted(previousRemaining) - { - let providerText = provider.rawValue - let message = - "notifications disabled: provider=\(providerText) " + - "prev=\(previousRemaining ?? -1) curr=\(currentRemaining)" - self.sessionQuotaLogger.debug(message) - } - return - } - - guard previousRemaining != nil else { - if SessionQuotaNotificationLogic.isDepleted(currentRemaining) { - let providerText = provider.rawValue - let message = "startup depleted: provider=\(providerText) curr=\(currentRemaining)" - self.sessionQuotaLogger.info(message) - self.sessionQuotaNotifier.post(transition: .depleted, provider: provider, badge: nil) - } - return - } - - let transition = SessionQuotaNotificationLogic.transition( - previousRemaining: previousRemaining, - currentRemaining: currentRemaining) - guard transition != .none else { - if SessionQuotaNotificationLogic.isDepleted(currentRemaining) || - SessionQuotaNotificationLogic.isDepleted(previousRemaining) - { - let providerText = provider.rawValue - let message = - "no transition: provider=\(providerText) " + - "prev=\(previousRemaining ?? -1) curr=\(currentRemaining)" - self.sessionQuotaLogger.debug(message) - } - return - } - - let providerText = provider.rawValue - let transitionText = String(describing: transition) - let message = - "transition \(transitionText): provider=\(providerText) " + - "prev=\(previousRemaining ?? -1) curr=\(currentRemaining)" - self.sessionQuotaLogger.info(message) - - self.sessionQuotaNotifier.post(transition: transition, provider: provider, badge: nil) - } - - func handleQuotaWarningTransitions(provider: UsageProvider, snapshot: UsageSnapshot) { - guard self.settings.quotaWarningNotificationsEnabled else { return } - - let accountDisplayName = self.quotaWarningAccountDisplayName(provider: provider, snapshot: snapshot) - self.handleQuotaWarningTransition( + func postQuotaWarning(_ event: QuotaWarningEvent, provider: UsageProvider) { + self.sessionQuotaNotifier.postQuotaWarning( + event: event, provider: provider, - window: .session, - rateWindow: snapshot.primary, - accountDisplayName: accountDisplayName) - self.handleQuotaWarningTransition( - provider: provider, - window: .weekly, - rateWindow: snapshot.secondary, - accountDisplayName: accountDisplayName) + soundEnabled: self.settings.quotaWarningSoundEnabled, + onScreenAlertEnabled: self.settings.quotaWarningOnScreenAlertEnabled) } - private func handleQuotaWarningTransition( - provider: UsageProvider, - window: QuotaWarningWindow, - rateWindow: RateWindow?, - accountDisplayName: String?) - { - let key = QuotaWarningStateKey(provider: provider, window: window) - guard self.settings.quotaWarningEnabled(provider: provider, window: window) else { - self.quotaWarningState.removeValue(forKey: key) - return - } - guard let rateWindow else { - self.quotaWarningState.removeValue(forKey: key) - return - } - - let thresholds = self.settings.resolvedQuotaWarningThresholds(provider: provider, window: window) - let currentRemaining = rateWindow.remainingPercent - var state = self.quotaWarningState[key] ?? QuotaWarningState() - let cleared = QuotaWarningNotificationLogic.thresholdsToClear( - currentRemaining: currentRemaining, - alreadyFired: state.firedThresholds) - state.firedThresholds.subtract(cleared) - - if let threshold = QuotaWarningNotificationLogic.crossedThreshold( - previousRemaining: state.lastRemaining, - currentRemaining: currentRemaining, - thresholds: thresholds, - alreadyFired: state.firedThresholds) - { - state.firedThresholds.formUnion(QuotaWarningNotificationLogic.firedThresholdsAfterWarning( - threshold: threshold, - thresholds: thresholds)) - self.sessionQuotaNotifier.postQuotaWarning( - event: QuotaWarningEvent( - window: window, - threshold: threshold, - currentRemaining: currentRemaining, - accountDisplayName: accountDisplayName), - provider: provider, - soundEnabled: self.settings.quotaWarningSoundEnabled) - } - - state.lastRemaining = currentRemaining - self.quotaWarningState[key] = state - } - - private func quotaWarningAccountDisplayName(provider: UsageProvider, snapshot: UsageSnapshot) -> String? { - guard !self.settings.hidePersonalInfo else { return nil } - let account = snapshot.accountEmail(for: provider)? - .trimmingCharacters(in: .whitespacesAndNewlines) - guard let account, !account.isEmpty else { return nil } - return account - } - - private func refreshStatus(_ provider: UsageProvider) async { - guard self.settings.statusChecksEnabled else { return } - guard let meta = self.providerMetadata[provider] else { return } - - do { - let status: ProviderStatus - 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) - } else { - return - } - await MainActor.run { self.statuses[provider] = status } - } catch { - // Keep the previous status to avoid flapping when the API hiccups. - await MainActor.run { - if self.statuses[provider] == nil { - self.statuses[provider] = ProviderStatus( - indicator: .unknown, - description: error.localizedDescription, - updatedAt: nil) - } - } - } + func postPredictivePaceWarning(_ event: PredictivePaceWarningEvent, provider: UsageProvider, now: Date) { + self.sessionQuotaNotifier.postPredictivePaceWarning( + event: event, + provider: provider, + soundEnabled: self.settings.quotaWarningSoundEnabled, + onScreenAlertEnabled: self.settings.quotaWarningOnScreenAlertEnabled, + now: now) } } @@ -921,6 +896,7 @@ extension UsageStore { try? output.write(to: url, atomically: true, encoding: .utf8) await MainActor.run { let snippet = String(output.prefix(180)).replacingOccurrences(of: "\n", with: " ") + self.knownLimitsAvailabilityByProvider.removeValue(forKey: .claude) self.errors[.claude] = "[Claude] \(snippet) (saved: \(url.path))" NSWorkspace.shared.open(url) } @@ -936,6 +912,7 @@ extension UsageStore { return url } catch { await MainActor.run { + self.knownLimitsAvailabilityByProvider.removeValue(forKey: provider) self.errors[provider] = "Failed to save log: \(error.localizedDescription)" } return nil @@ -990,9 +967,11 @@ extension UsageStore { let unimplementedDebugLogMessages: [UsageProvider: String] = [ .gemini: "Gemini debug log not yet implemented", .antigravity: "Antigravity debug log not yet implemented", + .clinepass: "ClinePass debug log not yet implemented", .opencode: "OpenCode debug log not yet implemented", .alibaba: "Alibaba Coding Plan debug log not yet implemented", .alibabatokenplan: "Alibaba Token Plan debug log not yet implemented", + .qwencloud: "Qwen Cloud debug log not yet implemented", .factory: "Droid debug log not yet implemented", .copilot: "Copilot debug log not yet implemented", .manus: "Manus debug log not yet implemented", @@ -1000,19 +979,30 @@ extension UsageStore { .kilo: "Kilo debug log not yet implemented", .kiro: "Kiro debug log not yet implemented", .kimi: "Kimi debug log not yet implemented", - .kimik2: "Kimi K2 debug log not yet implemented", .jetbrains: "JetBrains AI debug log not yet implemented", .mimo: "Xiaomi MiMo debug log not yet implemented", .doubao: "Doubao debug log not yet implemented", + .sakana: "Sakana AI debug log not yet implemented", .venice: "Venice debug log not yet implemented", + .deepinfra: "DeepInfra debug log not yet implemented", .commandcode: "Command Code debug log not yet implemented", + .qoder: "Qoder debug log not yet implemented", .stepfun: "StepFun debug log not yet implemented", .bedrock: "Bedrock debug log not yet implemented", .grok: "Grok debug log not yet implemented", .groq: "Groq debug log not yet implemented", .t3chat: "T3 Chat debug log not yet implemented", + .zoommate: "ZoomMate debug log not yet implemented", + .xai: "xAI debug log not yet implemented", .llmproxy: "LLM Proxy debug log not yet implemented", + .litellm: "LiteLLM debug log not yet implemented", .deepgram: "Deepgram debug log not yet implemented", + .chutes: "Chutes debug log not yet implemented", + .clawrouter: "ClawRouter debug log not yet implemented", + .wayfinder: "Wayfinder debug log not yet implemented", + .sub2api: "sub2api debug log not yet implemented", + .zenmux: "ZenMux debug log not yet implemented", + .aiand: "ai& debug log not yet implemented", ] let buildText = { switch provider { @@ -1087,10 +1077,12 @@ extension UsageStore { configToken: nil, hasEnvToken: deepSeekHasEnvToken, hasTokenAccount: deepSeekHasTokenAccount) - case .gemini, .antigravity, .opencode, .opencodego, .alibabatokenplan, .factory, .copilot, - .vertexai, .kilo, .kiro, .kimi, .kimik2, .moonshot, .jetbrains, .perplexity, .mimo, .doubao, - .abacus, .mistral, .codebuff, .crof, .windsurf, .venice, .manus, .commandcode, .stepfun, .bedrock, - .grok, .groq, .t3chat, .llmproxy, .deepgram: + case .clinepass, .gemini, .antigravity, .opencode, .opencodego, .alibabatokenplan, .qwencloud, .factory, + .copilot, .devin, .vertexai, .kilo, .kiro, .kimi, .moonshot, .jetbrains, .perplexity, + .mimo, .doubao, .sakana, .abacus, .mistral, .deepinfra, .codebuff, .crof, .windsurf, + .venice, .manus, .commandcode, .qoder, .stepfun, .bedrock, .grok, .groq, .t3chat, .llmproxy, + .litellm, .zed, .deepgram, .poe, .chutes, .neuralwatt, .clawrouter, .longcat, .wayfinder, + .sub2api, .zenmux, .aiand, .zoommate, .xai: return unimplementedDebugLogMessages[provider] ?? "Debug log not yet implemented" } } @@ -1211,104 +1203,6 @@ extension UsageStore { #endif } - private struct APIKeyDebugContext { - let label: String - let resolution: ProviderTokenResolution? - let configToken: String? - let hasEnvToken: Bool - let hasTokenAccount: Bool - } - - private func openAIAPIKeyDebugContext(processEnvironment: [String: String]) -> APIKeyDebugContext { - let config = self.settings.providerConfig(for: .openai) - let environment = ProviderConfigEnvironment.applyAPIKeyOverride( - base: processEnvironment, - provider: .openai, - config: config) - return APIKeyDebugContext( - label: "OPENAI_API_KEY", - resolution: ProviderTokenResolver.openAIAPIResolution(environment: environment), - configToken: config?.sanitizedAPIKey, - hasEnvToken: OpenAIAPISettingsReader.apiKey(environment: processEnvironment) != nil, - hasTokenAccount: false) - } - - private func azureOpenAIAPIKeyDebugContext(processEnvironment: [String: String]) -> APIKeyDebugContext { - let config = self.settings.providerConfig(for: .azureopenai) - let environment = ProviderConfigEnvironment.applyProviderConfigOverrides( - base: processEnvironment, - provider: .azureopenai, - config: config) - return APIKeyDebugContext( - label: "AZURE_OPENAI_API_KEY", - resolution: ProviderTokenResolver.azureOpenAIResolution(environment: environment), - configToken: config?.sanitizedAPIKey, - hasEnvToken: AzureOpenAISettingsReader.apiKey(environment: processEnvironment) != nil, - hasTokenAccount: false) - } - - private func openRouterAPIKeyDebugContext(processEnvironment: [String: String]) -> APIKeyDebugContext { - let config = self.settings.providerConfig(for: .openrouter) - let environment = ProviderConfigEnvironment.applyAPIKeyOverride( - base: processEnvironment, - provider: .openrouter, - config: config) - return APIKeyDebugContext( - label: "OPENROUTER_API_KEY", - resolution: ProviderTokenResolver.openRouterResolution(environment: environment), - configToken: config?.sanitizedAPIKey, - hasEnvToken: OpenRouterSettingsReader.apiToken(environment: processEnvironment) != nil, - hasTokenAccount: false) - } - - private func elevenLabsAPIKeyDebugContext(processEnvironment: [String: String]) -> APIKeyDebugContext { - let config = self.settings.providerConfig(for: .elevenlabs) - let environment = ProviderConfigEnvironment.applyAPIKeyOverride( - base: processEnvironment, - provider: .elevenlabs, - config: config) - return APIKeyDebugContext( - label: "ELEVENLABS_API_KEY", - resolution: ProviderTokenResolver.elevenLabsResolution(environment: environment), - configToken: config?.sanitizedAPIKey, - hasEnvToken: ElevenLabsSettingsReader.apiKey(environment: processEnvironment) != nil, - hasTokenAccount: false) - } - - private nonisolated static func apiKeyDebugLine(_ context: APIKeyDebugContext) -> String { - self.apiKeyDebugLine( - label: context.label, - resolution: context.resolution, - configToken: context.configToken, - hasEnvToken: context.hasEnvToken, - hasTokenAccount: context.hasTokenAccount) - } - - private nonisolated static func apiKeyDebugLine( - label: String, - resolution: ProviderTokenResolution?, - configToken: String?, - hasEnvToken: Bool, - hasTokenAccount: Bool = false) -> String - { - let hasAny = resolution != nil - let hasConfigToken = !(configToken?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ?? true) - let source: String = if resolution == nil { - "none" - } else if hasTokenAccount, hasEnvToken { - "settings-token-account (overrides env)" - } else if hasTokenAccount { - "settings-token-account" - } else if hasConfigToken, hasEnvToken { - "settings-config (overrides env)" - } else if hasConfigToken { - "settings-config" - } else { - resolution?.source.rawValue ?? "environment" - } - return "\(label)=\(hasAny ? "present" : "missing") source=\(source)" - } - private static func debugCursorLog( browserDetection: BrowserDetection, cursorCookieSource: ProviderCookieSource, @@ -1397,8 +1291,19 @@ extension UsageStore { } } - private func detectVersions() { - let implementations = ProviderCatalog.all + /// Version probes can spawn subprocesses (Antigravity's `ps` scan trips a TCC + /// prompt, CLI providers exec their binaries), so disabled providers must not + /// be probed (#2267). Settings changes re-run this when the enabled set changes. + static func versionDetectionImplementations( + enabled: Set) -> [any ProviderImplementation] + { + ProviderCatalog.all.filter { enabled.contains($0.id) } + } + + func detectVersions() { + let enabled = Set(self.settings.enabledProvidersOrdered(metadataByProvider: self.providerMetadata)) + self.versionDetectionProviders = enabled + let implementations = Self.versionDetectionImplementations(enabled: enabled) let browserDetection = self.browserDetection Task { @MainActor [weak self] in let resolved = await Task.detached { () -> [UsageProvider: String] in @@ -1451,79 +1356,39 @@ 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 { + func refreshTokenUsage(_ provider: UsageProvider, force: Bool) async { guard ProviderDescriptorRegistry.descriptor(for: provider).tokenCost.supportsTokenCost else { - self.tokenSnapshots.removeValue(forKey: provider) - self.tokenErrors[provider] = nil - self.tokenFailureGates[provider]?.reset() - self.lastTokenFetchAt.removeValue(forKey: provider) - self.lastTokenFetchScope.removeValue(forKey: provider) - return - } - - if let override = self._test_tokenUsageRefreshOverride { - await override(provider, force) + self.resetTokenUsageState(for: provider) return } if Self.tokenCostRequiresProviderSnapshot(provider) { - if let snapshot = self.tokenSnapshot(fromProviderSnapshot: self.snapshots[provider], provider: provider) { - self.tokenSnapshots[provider] = snapshot + if self.tokenSnapshotPublicationForCurrentProviderConfig(for: provider) != nil { self.tokenErrors[provider] = nil self.tokenFailureGates[provider]?.recordSuccess() self.persistWidgetSnapshot(reason: "token-usage") } else { - self.tokenSnapshots.removeValue(forKey: provider) + self.clearTokenSnapshot(for: provider) self.tokenErrors[provider] = nil self.tokenFailureGates[provider]?.reset() } return } - guard self.settings.costUsageEnabled else { - self.tokenSnapshots.removeValue(forKey: provider) - self.tokenErrors[provider] = nil - self.tokenFailureGates[provider]?.reset() - self.lastTokenFetchAt.removeValue(forKey: provider) - self.lastTokenFetchScope.removeValue(forKey: provider) + guard self.settings.isCostUsageEffectivelyEnabled(for: provider) else { + self.resetTokenUsageState(for: provider) return } guard self.isEnabled(provider) else { - self.tokenSnapshots.removeValue(forKey: provider) - self.tokenErrors[provider] = nil - self.tokenFailureGates[provider]?.reset() - self.lastTokenFetchAt.removeValue(forKey: provider) - self.lastTokenFetchScope.removeValue(forKey: provider) + self.resetTokenUsageState(for: provider) + return + } + + // Cursor cost honors the same cookie policy as status: when the user set the cookie source + // to Off, skip the network fetch entirely (mirrors CursorProviderDescriptor.checkStatus). + if provider == .cursor, self.settings.cursorCookieSource == .off { + self.resetTokenUsageState(for: provider) return } @@ -1531,12 +1396,19 @@ extension UsageStore { let now = Date() let historyDays = self.settings.costUsageHistoryDays + // Cursor cost reuses the status cookie policy: a Manual source forwards the manual header so + // cost and status share the same session; other sources fall back to auto resolution. + guard case let .proceed(cursorCookieHeaderOverride) = self.prepareCursorCostCookie(for: provider) else { + return + } let costScope = self.tokenCostScope(for: provider) - let costScopeSignature = "\(costScope.signature)|historyDays=\(historyDays)" - if !force, - let last = self.lastTokenFetchAt[provider], - self.lastTokenFetchScope[provider] == costScopeSignature, - now.timeIntervalSince(last) < self.tokenFetchTTL + let costScopeSignature = self.tokenSnapshotScopeSignature(for: provider) + let publicationRevision = self.providerPublicationRevision(for: provider) + let providerConfigRevision = self.settings.providerConfigRevision(for: provider) + if !force, self.tokenRefreshCanReuseCurrentSnapshot( + provider: provider, + now: now, + costScopeSignature: costScopeSignature) { return } @@ -1545,84 +1417,180 @@ extension UsageStore { self.tokenRefreshInFlight.insert(provider) defer { self.tokenRefreshInFlight.remove(provider) } + if let override = self._test_tokenUsageRefreshOverride { + await override(provider, force) + if Task.isCancelled { + self.lastTokenFetchAt.removeValue(forKey: provider) + self.lastTokenFetchScope.removeValue(forKey: provider) + } + return + } + let startedAt = Date() - let providerText = provider.rawValue self.tokenCostLogger - .debug("cost usage start provider=\(providerText) force=\(force)") + .debug("cost usage start provider=\(provider.rawValue) force=\(force)") do { - let fetcher = self.costUsageFetcher - let timeoutSeconds = self.tokenFetchTimeout - let environment = provider == .bedrock - ? ProviderRegistry.makeEnvironment( - base: self.environmentBase, + // Codex cost usage scans the explicit token-cost scope: selected managed account by + // default, or this Mac's ambient Codex home when the local ledger is enabled. + let snapshot = try await self.loadTokenUsageSnapshot( + provider: provider, + force: force, + now: now, + codexHomePath: costScope.codexHomePath, + historyDays: historyDays, + cursorCookieHeaderOverride: cursorCookieHeaderOverride) + try Task.checkCancellation() + let completedCostScopeSignature = self.completedTokenCostScopeSignature( + provider: provider, + historyDays: historyDays, + initialSignature: costScopeSignature, + snapshot: snapshot) + guard self.tokenRefreshPublicationIsCurrent( + provider: provider, + publicationRevision: publicationRevision, + providerConfigRevision: providerConfigRevision, + historyDays: historyDays, + costScopeSignature: costScopeSignature, + fetchedCredentialScopeFingerprint: snapshot.credentialScopeFingerprint) + else { + self.clearTokenFetchMetadataIfMatching( provider: provider, - settings: self.settings, - tokenOverride: nil) - : self.environmentBase - // Codex cost usage scans local session logs from this machine. That data is - // intentionally presented as provider-level local telemetry rather than managed-account - // remote state, so managed Codex account selection does not retarget that fetch. - // If the UI later needs account-scoped token history, it should label and source that - // separately instead of silently changing the meaning of this section. - let snapshot = try await withThrowingTaskGroup(of: CostUsageTokenSnapshot.self) { group in - group.addTask(priority: .utility) { - try await fetcher.loadTokenSnapshot( - provider: provider, - environment: environment, - now: now, - forceRefresh: force, - allowVertexClaudeFallback: !self.isEnabled(.claude), - codexHomePath: costScope.codexHomePath, - historyDays: historyDays) - } - group.addTask { - try await Task.sleep(nanoseconds: UInt64(timeoutSeconds * 1_000_000_000)) - throw CostUsageError.timedOut(seconds: Int(timeoutSeconds)) - } - defer { group.cancelAll() } - guard let snapshot = try await group.next() else { throw CancellationError() } - return snapshot + attemptedAt: now, + costScopeSignature: costScopeSignature) + self.requestTokenRefreshAfterStaleCompletion(for: provider) + return } + self.lastTokenFetchScope[provider] = completedCostScopeSignature - guard !snapshot.daily.isEmpty else { - self.tokenSnapshots.removeValue(forKey: provider) + guard !snapshot.daily.isEmpty || snapshot.meteredCostUSD != nil else { + self.publishConfirmedEmptyTokenSnapshot(for: provider) self.tokenErrors[provider] = Self.tokenCostNoDataMessage(for: provider) self.tokenFailureGates[provider]?.recordSuccess() return } - let duration = Date().timeIntervalSince(startedAt) - let sessionCost = snapshot.sessionCostUSD - .map { UsageFormatter.currencyString($0, currencyCode: snapshot.currencyCode) } ?? "—" - let monthCost = snapshot.last30DaysCostUSD - .map { UsageFormatter.currencyString($0, currencyCode: snapshot.currencyCode) } ?? "—" - let durationText = String(format: "%.2f", duration) - let message = - "cost usage success provider=\(providerText) " + - "duration=\(durationText)s " + - "today=\(sessionCost) " + - "historyDays=\(historyDays) windowCost=\(monthCost)" - self.tokenCostLogger.info(message) - self.tokenSnapshots[provider] = snapshot + self.logTokenUsageSuccess( + provider: provider, + snapshot: snapshot, + historyDays: historyDays, + startedAt: startedAt) + self.publishTokenSnapshot(snapshot, for: provider) self.tokenErrors[provider] = nil self.tokenFailureGates[provider]?.recordSuccess() self.persistWidgetSnapshot(reason: "token-usage") } catch { - if error is CancellationError { return } + guard self.tokenRefreshPublicationIsCurrent( + provider: provider, + publicationRevision: publicationRevision, + providerConfigRevision: providerConfigRevision, + historyDays: historyDays, + costScopeSignature: costScopeSignature) + else { + self.clearTokenFetchMetadataIfMatching( + provider: provider, + attemptedAt: now, + costScopeSignature: costScopeSignature) + self.requestTokenRefreshAfterStaleCompletion(for: provider) + return + } + if error is CancellationError { + self.clearTokenFetchMetadataIfMatching( + provider: provider, + attemptedAt: now, + costScopeSignature: costScopeSignature) + return + } let duration = Date().timeIntervalSince(startedAt) let msg = error.localizedDescription let durationText = String(format: "%.2f", duration) - let message = "cost usage failed provider=\(providerText) duration=\(durationText)s error=\(msg)" + let message = "cost usage failed provider=\(provider.rawValue) duration=\(durationText)s error=\(msg)" self.tokenCostLogger.error(message) + if Self.tokenFetchFailureAllowsEarlyRetry(error) { + self.clearTokenFetchMetadataIfMatching( + provider: provider, + attemptedAt: now, + costScopeSignature: costScopeSignature) + } let hadPriorData = self.tokenSnapshots[provider] != nil let shouldSurface = self.tokenFailureGates[provider]? .shouldSurfaceError(onFailureWithPriorData: hadPriorData) ?? true if shouldSurface { self.tokenErrors[provider] = error.localizedDescription - self.tokenSnapshots.removeValue(forKey: provider) + self.clearTokenSnapshot(for: provider) } else { self.tokenErrors[provider] = nil } } } + + private func resetTokenUsageState(for provider: UsageProvider) { + self.clearTokenSnapshot(for: provider) + self.tokenErrors[provider] = nil + self.tokenFailureGates[provider]?.reset() + self.lastTokenFetchAt.removeValue(forKey: provider) + self.lastTokenFetchScope.removeValue(forKey: provider) + } + + private func logTokenUsageSuccess( + provider: UsageProvider, + snapshot: CostUsageTokenSnapshot, + historyDays: Int, + startedAt: Date) + { + let durationText = String(format: "%.2f", Date().timeIntervalSince(startedAt)) + let sessionCost = snapshot.sessionCostUSD + .map { UsageFormatter.currencyString($0, currencyCode: snapshot.currencyCode) } ?? "—" + let monthCost = snapshot.last30DaysCostUSD + .map { UsageFormatter.currencyString($0, currencyCode: snapshot.currencyCode) } ?? "—" + let message = + "cost usage success provider=\(provider.rawValue) " + + "duration=\(durationText)s " + + "today=\(sessionCost) " + + "historyDays=\(historyDays) windowCost=\(monthCost)" + self.tokenCostLogger.info(message) + } + + private func clearTokenFetchMetadataIfMatching( + provider: UsageProvider, + attemptedAt: Date, + costScopeSignature: String) + { + guard self.lastTokenFetchAt[provider] == attemptedAt, + self.lastTokenFetchScope[provider] == costScopeSignature + else { + return + } + self.lastTokenFetchAt.removeValue(forKey: provider) + self.lastTokenFetchScope.removeValue(forKey: provider) + } + + /// Fast failures may retry on the next scheduled pass instead of waiting out the fetch + /// TTL; timed-out scans keep the TTL so a slow corpus cannot thrash back-to-back rescans. + nonisolated static func tokenFetchFailureAllowsEarlyRetry(_ error: Error) -> Bool { + if case CostUsageError.timedOut = error { + return false + } + return true + } +} + +extension UsageStore { + func retainCodingActivityIfNewer(_ date: Date) { + if self.lastCodingActivityAt.map({ date > $0 }) ?? true { + self.lastCodingActivityAt = date + } + } + + func clearCodingActivityObservation() { + self.lastCodingActivityAt = nil + } + + func restartAdaptiveTimerPreservingResetBoundary() { + self.startTimer(preservingResetBoundaryRefresh: true) + } + + func noteMenuOpened(at date: Date = Date()) { + self.lastMenuOpenAt = date + self.advanceAdaptiveTimerIfEarlier(at: date) + } } diff --git a/Sources/CodexBar/UsageStoreSupport.swift b/Sources/CodexBar/UsageStoreSupport.swift index 3ac92fc59e..d553e56e1b 100644 --- a/Sources/CodexBar/UsageStoreSupport.swift +++ b/Sources/CodexBar/UsageStoreSupport.swift @@ -34,6 +34,59 @@ struct ProviderStatus { let updatedAt: Date? } +struct ProviderRefreshPublicationContext { + let generation: UInt64 + let enablementRevision: UInt64 + var configRevision: UInt64 + let tokenCostScopeSignature: String? + let allowDisabled: Bool +} + +/// A single component/service row on a statuspage.io-style status page +/// (e.g. "Codex API", "CLI", "FedRAMP") with its current state. A row with non-empty +/// `children` is a component group and renders as an expandable dropdown. +struct ProviderStatusComponent: Identifiable, Equatable { + let id: String + let name: String + let indicator: ProviderStatusIndicator + /// Raw provider status. The display label is localized when the row renders so changing + /// the app language does not require another network refresh. + let status: String + /// Child rows for a component group; empty for leaf components. + var children: [ProviderStatusComponent] = [] + + var isGroup: Bool { + !self.children.isEmpty + } + + var statusLabel: String { + Self.label(forStatuspageStatus: self.status) + } + + /// Maps a statuspage.io component `status` string to our indicator + display label. + static func indicator(forStatuspageStatus status: String) -> ProviderStatusIndicator { + switch status { + case "operational": .none + case "degraded_performance": .minor + case "partial_outage": .major + case "major_outage", "full_outage": .critical + case "under_maintenance": .maintenance + default: .unknown + } + } + + static func label(forStatuspageStatus status: String) -> String { + switch status { + case "operational": L("status_operational") + case "degraded_performance": L("status_degraded") + case "partial_outage": L("status_partial_outage") + case "major_outage", "full_outage": L("status_major_outage") + case "under_maintenance": L("status_maintenance") + default: L("status_unknown") + } + } +} + /// Tracks consecutive failures so we can ignore a single flake when we previously had fresh data. struct ConsecutiveFailureGate { private(set) var streak: Int = 0 @@ -49,7 +102,9 @@ struct ConsecutiveFailureGate { /// Returns true when the caller should surface the error to the UI. mutating func shouldSurfaceError(onFailureWithPriorData hadPriorData: Bool) -> Bool { self.streak += 1 - if hadPriorData, self.streak == 1 { return false } + if hadPriorData, self.streak == 1 { + return false + } return true } } @@ -61,7 +116,11 @@ extension UsageStore { } func _setTokenSnapshotForTesting(_ snapshot: CostUsageTokenSnapshot?, provider: UsageProvider) { - self.tokenSnapshots[provider] = snapshot + if let snapshot { + self.publishTokenSnapshot(snapshot, for: provider) + } else { + self.clearTokenSnapshot(for: provider) + } } func _setTokenErrorForTesting(_ error: String?, provider: UsageProvider) { @@ -72,10 +131,35 @@ extension UsageStore { self.errors[provider] = error } + func _setKnownLimitsAvailabilityForTesting( + _ availability: UsageLimitsAvailability?, + provider: UsageProvider) + { + self.knownLimitsAvailabilityByProvider[provider] = availability + } + func _setCodexHistoricalDatasetForTesting(_ dataset: CodexHistoricalDataset?, accountKey: String? = nil) { self.codexHistoricalDataset = dataset self.codexHistoricalDatasetAccountKey = accountKey self.historicalPaceRevision += 1 } + + /// Cancels the one-shot persisted plan-utilization load and treats the + /// in-memory dictionary as "loaded" so callers can assign state directly + /// without racing the background decode. Used by test helpers that + /// intentionally seed history from scratch. + func _cancelPlanUtilizationHistoryLoadForTesting() { + self.planUtilizationHistoryLoadTask?.cancel() + self.planUtilizationHistoryLoadTask = nil + self.planUtilizationHistoryLoaded = true + } + + /// Awaits the background plan-utilization load task to completion. Used + /// by tests that write history files to disk before constructing + /// `UsageStore` and then expect the dictionary to be populated by the + /// time assertions run. + func _waitForPlanUtilizationHistoryLoadForTesting() async { + await self.planUtilizationHistoryLoadTask?.value + } } #endif diff --git a/Sources/CodexBar/ZaiTokenStore.swift b/Sources/CodexBar/ZaiTokenStore.swift index ee4c391810..2b33921127 100644 --- a/Sources/CodexBar/ZaiTokenStore.swift +++ b/Sources/CodexBar/ZaiTokenStore.swift @@ -67,7 +67,7 @@ struct KeychainZaiTokenStore: ZaiTokenStoring { account: self.account)) } - let status = SecItemCopyMatching(query as CFDictionary, &result) + let status = KeychainSecurity.copyMatching(query as CFDictionary, &result) if status == errSecItemNotFound { // Cache the nil result Self.cacheLock.lock() @@ -123,7 +123,7 @@ struct KeychainZaiTokenStore: ZaiTokenStoring { kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly, ] - let updateStatus = SecItemUpdate(query as CFDictionary, attributes as CFDictionary) + let updateStatus = KeychainSecurity.update(query as CFDictionary, attributes as CFDictionary) if updateStatus == errSecSuccess { // Update cache Self.cacheLock.lock() @@ -141,7 +141,7 @@ struct KeychainZaiTokenStore: ZaiTokenStoring { for (key, value) in attributes { addQuery[key] = value } - let addStatus = SecItemAdd(addQuery as CFDictionary, nil) + let addStatus = KeychainSecurity.add(addQuery as CFDictionary, nil) guard addStatus == errSecSuccess else { Self.log.error("Keychain add failed: \(addStatus)") throw ZaiTokenStoreError.keychainStatus(addStatus) @@ -161,7 +161,7 @@ struct KeychainZaiTokenStore: ZaiTokenStoring { kSecAttrService as String: self.service, kSecAttrAccount as String: self.account, ] - let status = SecItemDelete(query as CFDictionary) + let status = KeychainSecurity.delete(query as CFDictionary) if status == errSecSuccess || status == errSecItemNotFound { // Invalidate cache Self.cacheLock.lock() diff --git a/Sources/CodexBarCLI/CLICacheCommand.swift b/Sources/CodexBarCLI/CLICacheCommand.swift index 4fe11aecd7..78d63e96ec 100644 --- a/Sources/CodexBarCLI/CLICacheCommand.swift +++ b/Sources/CodexBarCLI/CLICacheCommand.swift @@ -29,11 +29,12 @@ extension CodexBarCLI { if clearCookies { if let rawProvider { if let provider = ProviderDescriptorRegistry.cliNameMap[rawProvider.lowercased()] { - let cleared = CookieHeaderCache.clearAllScopes(provider: provider) + let summary = CookieHeaderCache.clearAllScopesDetailed(provider: provider) results.append(CacheClearResult( cache: "cookies", provider: provider.rawValue, - cleared: cleared)) + cleared: summary.clearedCount, + error: Self.cookieClearError(failedCount: summary.failedCount))) } else { Self.exit( code: .failure, @@ -42,8 +43,12 @@ extension CodexBarCLI { kind: .args) } } else { - let cleared = CookieHeaderCache.clearAll() - results.append(CacheClearResult(cache: "cookies", provider: nil, cleared: cleared)) + let summary = CookieHeaderCache.clearAllDetailed() + results.append(CacheClearResult( + cache: "cookies", + provider: nil, + cleared: summary.clearedCount, + error: Self.cookieClearError(failedCount: summary.failedCount))) } } @@ -87,6 +92,11 @@ extension CodexBarCLI { guard rawProvider != nil, clearCost else { return nil } return "--provider only scopes cookie caches. Use --cookies --provider , or omit --provider." } + + private static func cookieClearError(failedCount: Int) -> String? { + guard failedCount > 0 else { return nil } + return "Cookie cache cleanup failed for \(failedCount) operation\(failedCount == 1 ? "" : "s")" + } } struct CacheOptions: CommanderParsable { diff --git a/Sources/CodexBarCLI/CLICardsBriefRenderer.swift b/Sources/CodexBarCLI/CLICardsBriefRenderer.swift new file mode 100644 index 0000000000..5c60f4ee01 --- /dev/null +++ b/Sources/CodexBarCLI/CLICardsBriefRenderer.swift @@ -0,0 +1,611 @@ +import CodexBarCore +import Foundation + +struct CLICardsBriefRow: Sendable, Equatable { + let provider: UsageProvider + let providerName: String + let sourceLabel: String + let planBadge: String? + let accountLabel: String? + let isActive: Bool + let accountProblem: String? + let metricLabel: String? + let usedPercent: Double? + let resetLabel: String? + let resetAt: Date? +} + +private struct CLICardsBriefColumns { + let provider: Int + let usage: Int + let reset: Int +} + +enum CLICardsBriefRenderer { + private static let warningUsedThreshold = 85.0 + private static let tableBorderOverhead = 10 + private static let providerColumnMin = 20 + private static let providerColumnFloor = 8 + private static let providerColumnMax = 34 + private static let usageColumnMin = 22 + private static let usageColumnFloor = 9 + private static let usageColumnWidth = 28 + private static let usageBarMaxWidth = 22 + private static let resetColumnMin = 8 + private static let resetColumnFloor = 5 + private static let resetColumnMax = 10 + + static func makeRows(cards: [CLICardModel]) -> [CLICardsBriefRow] { + cards.map { card in + let metric = card.metrics.first + let usedPercent = metric.map { max(0, min(100, 100 - $0.remainingPercent)) } + let resetLabel = Self.briefResetLabel(metric?.resetText) + return CLICardsBriefRow( + provider: card.provider, + providerName: card.title, + sourceLabel: card.sourceLabel, + planBadge: card.planBadge, + accountLabel: card.accountLine, + isActive: card.isActive, + accountProblem: card.accountProblem, + metricLabel: metric?.label, + usedPercent: usedPercent, + resetLabel: resetLabel, + resetAt: metric?.resetAt) + } + } + + static func render( + rows: [CLICardsBriefRow], + failures: [CLICardFailure], + terminalWidth: Int, + useColor: Bool, + enhanced: Bool = false, + now: Date = Date()) -> String + { + guard !rows.isEmpty else { + return CLICardsRenderer.renderFailuresOnly(failures, useColor: useColor) + } + + var lines: [String] = [] + lines.append(Self.titleLine(now: now, terminalWidth: terminalWidth, useColor: useColor, enhanced: enhanced)) + lines.append(contentsOf: Self.summaryLines( + rows: rows, + now: now, + terminalWidth: terminalWidth, + useColor: useColor, + enhanced: enhanced)) + lines.append("") + lines.append(contentsOf: Self.tableLines( + rows: rows, + terminalWidth: terminalWidth, + useColor: useColor, + enhanced: enhanced)) + + let warningLines = Self.warningLines(rows: rows, terminalWidth: terminalWidth, useColor: useColor) + if !warningLines.isEmpty { + lines.append("") + lines.append(contentsOf: warningLines) + } + + if !failures.isEmpty { + lines.append("") + lines.append(CLICardsRenderer.renderFailureFooter(failures: failures, useColor: useColor)) + } + + return lines.joined(separator: "\n") + } + + private static func titleLine(now: Date, terminalWidth: Int, useColor: Bool, enhanced: Bool) -> String { + let left: String = if useColor, enhanced { + CLIRenderer.colorizeEnhancedAccentBold("codexbar • AI Usage & Limits") + } else if useColor { + CLIRenderer.colorizeAccentBold("codexbar • AI Usage & Limits") + } else { + "codexbar • AI Usage & Limits" + } + let timestamp = Self.timestampString(now: now) + guard Self.visibleLength(left) + timestamp.count + 1 <= terminalWidth else { + if Self.visibleLength(left) <= terminalWidth { return left } + return Self.truncatePlain(TextParsing.stripANSICodes(left), width: terminalWidth) + } + let gap = max(1, terminalWidth - Self.visibleLength(left) - timestamp.count) + let right: String = if useColor, enhanced { + CLIRenderer.colorizeEnhancedReadableMuted(timestamp) + } else if useColor { + CLIRenderer.colorizeSubtle(timestamp) + } else { + timestamp + } + return left + String(repeating: " ", count: gap) + right + } + + private static func summaryLines( + rows: [CLICardsBriefRow], + now: Date, + terminalWidth: Int, + useColor: Bool, + enhanced: Bool) -> [String] + { + var parts: [String] = [] + if let nextReset = Self.nextResetSummary(rows: rows, now: now) { + parts.append("Next reset: \(nextReset)") + } + let text = parts.joined(separator: " • ") + guard !text.isEmpty else { return [] } + let lines = Self.wrapText( + text, + firstPrefix: "", + continuationPrefix: " ", + width: terminalWidth) + if useColor, enhanced { + return lines.map(CLIRenderer.colorizeEnhancedReadable) + } + if useColor { + return lines.map(CLIRenderer.colorizeReadable) + } + return lines + } + + private static func providerPlainLabel(_ row: CLICardsBriefRow) -> String { + if let account = row.accountLabel?.trimmingCharacters(in: .whitespacesAndNewlines), !account.isEmpty { + let active = row.isActive ? " [active]" : "" + return "\(row.providerName)\(active) · \(account) · \(row.sourceLabel)" + } + if let plan = row.planBadge, !plan.isEmpty { + return "\(row.providerName) · \(row.sourceLabel) · \(plan)" + } + return "\(row.providerName) · \(row.sourceLabel)" + } + + private static func tableLines( + rows: [CLICardsBriefRow], + terminalWidth: Int, + useColor: Bool, + enhanced: Bool) -> [String] + { + let columns = Self.tableColumnWidths( + rows: rows, + terminalWidth: terminalWidth) + + let top = Self.tableTop( + providerWidth: columns.provider, + usageWidth: columns.usage, + resetWidth: columns.reset, + useColor: useColor, + enhanced: enhanced) + let header = Self.tableHeaderRow( + providerWidth: columns.provider, + usageWidth: columns.usage, + resetWidth: columns.reset, + useColor: useColor, + enhanced: enhanced) + let divider = Self.tableDivider( + providerWidth: columns.provider, + usageWidth: columns.usage, + resetWidth: columns.reset, + useColor: useColor, + enhanced: enhanced) + + var lines = [top, header, divider] + for row in rows { + lines.append(Self.dataRow( + row: row, + columns: columns, + useColor: useColor, + enhanced: enhanced)) + } + lines.append(Self.tableBottom( + providerWidth: columns.provider, + usageWidth: columns.usage, + resetWidth: columns.reset, + useColor: useColor, + enhanced: enhanced)) + return lines + } + + private static func tableBorderLine(_ line: String, useColor: Bool, enhanced: Bool) -> String { + guard useColor else { return line } + if enhanced { + return CLIRenderer.colorizeEnhancedBorder(line) + } + return CLIRenderer.colorizeCardBorder(line) + } + + private static func tableHeaderRow( + providerWidth: Int, + usageWidth: Int, + resetWidth: Int, + useColor: Bool, + enhanced: Bool) -> String + { + let provider = Self.styledHeaderLabel("Provider", width: providerWidth, useColor: useColor, enhanced: enhanced) + let usage = Self.styledHeaderLabel("Usage", width: usageWidth, useColor: useColor, enhanced: enhanced) + let reset = Self.styledHeaderLabel( + "Reset", + width: resetWidth, + alignRight: true, + useColor: useColor, + enhanced: enhanced) + return "│ \(provider) │ \(usage) │ \(reset) │" + } + + private static func styledHeaderLabel( + _ text: String, + width: Int, + alignRight: Bool = false, + useColor: Bool, + enhanced: Bool) -> String + { + let styled: String = if useColor, enhanced { + CLIRenderer.colorizeEnhancedReadable(text) + } else if useColor { + CLIRenderer.colorizeReadable(text) + } else { + text + } + return Self.pad(styled, width: width, alignRight: alignRight) + } + + private static func tableTop( + providerWidth: Int, + usageWidth: Int, + resetWidth: Int, + useColor: Bool, + enhanced: Bool) -> String + { + let line = "┌" + String(repeating: "─", count: providerWidth + 2) + + "┬" + String(repeating: "─", count: usageWidth + 2) + + "┬" + String(repeating: "─", count: resetWidth + 2) + "┐" + return Self.tableBorderLine(line, useColor: useColor, enhanced: enhanced) + } + + private static func tableBottom( + providerWidth: Int, + usageWidth: Int, + resetWidth: Int, + useColor: Bool, + enhanced: Bool) -> String + { + let line = "└" + String(repeating: "─", count: providerWidth + 2) + + "┴" + String(repeating: "─", count: usageWidth + 2) + + "┴" + String(repeating: "─", count: resetWidth + 2) + "┘" + return Self.tableBorderLine(line, useColor: useColor, enhanced: enhanced) + } + + private static func tableDivider( + providerWidth: Int, + usageWidth: Int, + resetWidth: Int, + useColor: Bool, + enhanced: Bool) -> String + { + let line = "├" + String(repeating: "─", count: providerWidth + 2) + + "┼" + String(repeating: "─", count: usageWidth + 2) + + "┼" + String(repeating: "─", count: resetWidth + 2) + "┤" + return Self.tableBorderLine(line, useColor: useColor, enhanced: enhanced) + } + + private static func styledProviderCell( + row: CLICardsBriefRow, + width: Int, + useColor: Bool, + enhanced: Bool) -> String + { + if row.isActive, width < row.providerName.count + " [active]".count { + let fitted = Self.fitCell("[active]", width: width) + if useColor, enhanced { + return CLIRenderer.colorizeEnhancedReadable(fitted) + } + return useColor ? CLIRenderer.colorizeReadable(fitted) : fitted + } + if row.accountLabel?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false { + let fitted = Self.fitCell(Self.providerPlainLabel(row), width: width) + if useColor, enhanced { + return CLIRenderer.colorizeEnhancedReadable(fitted) + } + return useColor ? CLIRenderer.colorizeReadable(fitted) : fitted + } + guard useColor else { + return self.plainProviderCell(row: row, width: width) + } + + let sourceVisibleWidth = row.sourceLabel.count + 2 + let prefixVisibleWidth = row.providerName.count + 1 + sourceVisibleWidth + let plan: String? = row.planBadge.flatMap { $0.isEmpty ? nil : $0 } + let planPrefix = " · " + let planWidth = plan.map { _ in max(0, width - prefixVisibleWidth - planPrefix.count) } ?? 0 + + let styled: String = if enhanced { + CLIRenderer.colorizeEnhancedAccentBold(row.providerName) + + " " + + CLIRenderer.colorizeEnhancedBadge(row.sourceLabel) + + Self.styledProviderPlan( + plan, + prefix: planPrefix, + width: planWidth, + useColor: useColor, + enhanced: enhanced) + } else { + CLIRenderer.colorizeReadable(row.providerName) + + " " + + CLIRenderer.colorizeCardBadge(row.sourceLabel) + + Self.styledProviderPlan( + plan, + prefix: planPrefix, + width: planWidth, + useColor: useColor, + enhanced: enhanced) + } + return Self.fitCell(styled, width: width) + } + + private static func styledProviderPlan( + _ plan: String?, + prefix: String, + width: Int, + useColor: Bool, + enhanced: Bool) -> String + { + guard let plan, width > 0 else { return "" } + let text = prefix + Self.truncatePlain(plan, width: width) + if useColor, enhanced { + return CLIRenderer.colorizeEnhancedReadableMuted(text) + } + if useColor { + return CLIRenderer.colorizeReadableMuted(text) + } + return text + } + + private static func plainProviderCell(row: CLICardsBriefRow, width: Int) -> String { + if row.accountLabel?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false { + return self.fitCell(self.providerPlainLabel(row), width: width) + } + guard + let plan = row.planBadge, + !plan.isEmpty + else { + return self.fitCell(self.providerPlainLabel(row), width: width) + } + + let prefix = "\(row.providerName) · \(row.sourceLabel) · " + let planWidth = max(0, width - prefix.count) + if planWidth > 0 { + return Self.pad(prefix + Self.truncatePlain(plan, width: planWidth), width: width) + } + return Self.fitCell("\(row.providerName) · \(row.sourceLabel)", width: width) + } + + private static func styledResetCell(_ text: String, width: Int, useColor: Bool, enhanced: Bool) -> String { + let styled: String = if useColor, enhanced { + CLIRenderer.colorizeEnhancedReadableMuted(text) + } else if useColor { + CLIRenderer.colorizeReadableMuted(text) + } else { + text + } + return Self.fitCell(styled, width: width, alignRight: true) + } + + private static func dataRow( + row: CLICardsBriefRow, + columns: CLICardsBriefColumns, + useColor: Bool, + enhanced: Bool) -> String + { + let provider = Self.styledProviderCell( + row: row, + width: columns.provider, + useColor: useColor, + enhanced: enhanced) + let usage: String + if let problem = row.accountProblem, !problem.isEmpty { + let fitted = Self.fitCell(problem, width: columns.usage) + if useColor, enhanced { + usage = CLIRenderer.colorizeEnhancedReadable(fitted) + } else if useColor { + usage = CLIRenderer.colorizeReadable(fitted) + } else { + usage = fitted + } + } else if let used = row.usedPercent { + let percent = String(format: "%.0f%%", used.rounded()) + let barWidth = max(4, min(Self.usageBarMaxWidth, columns.usage - Self.visibleLength(percent) - 1)) + let bar: String = if useColor, enhanced { + CLIRenderer.gradientUsedBar(usedPercent: used, width: barWidth) + } else { + CLIRenderer.cardUsedBar(usedPercent: used, width: barWidth, useColor: useColor) + } + let coloredPercent: String = if useColor, enhanced { + CLIRenderer.colorizeEnhancedUsedPercent(percent, usedPercent: used) + } else { + CLIRenderer.colorizeCardUsedPercent(percent, usedPercent: used, useColor: useColor) + } + usage = Self.pad("\(coloredPercent) \(bar)", width: columns.usage) + } else { + usage = Self.pad("—", width: columns.usage) + } + let reset = Self.styledResetCell( + row.resetLabel ?? "—", + width: columns.reset, + useColor: useColor, + enhanced: enhanced) + return "│ \(provider) │ \(usage) │ \(reset) │" + } + + private static func warningLines( + rows: [CLICardsBriefRow], + terminalWidth: Int, + useColor: Bool) -> [String] + { + let warnings = rows.compactMap { row -> String? in + guard let used = row.usedPercent, used >= Self.warningUsedThreshold else { return nil } + let label = row.metricLabel ?? "Usage" + return "\(row.providerName) \(label): \(Int(used.rounded()))% used" + } + guard !warnings.isEmpty else { return [] } + let lines = Self.wrapText( + warnings.joined(separator: "; "), + firstPrefix: "⚠ Warnings: ", + continuationPrefix: " ", + width: terminalWidth) + return useColor ? lines.map(CLIRenderer.colorizeWarning) : lines + } + + private static func wrapText( + _ text: String, + firstPrefix: String, + continuationPrefix: String, + width: Int) -> [String] + { + let lineWidth = max(16, width) + var lines: [String] = [] + var line = firstPrefix + var hasContent = false + for word in text.split(separator: " ").map(String.init) { + let separator = hasContent ? " " : "" + if line.count + separator.count + word.count <= lineWidth { + line += separator + word + hasContent = true + continue + } + if hasContent { + lines.append(line) + } else if !line.isEmpty { + lines.append(Self.truncatePlain(line, width: lineWidth)) + } + let available = max(1, lineWidth - continuationPrefix.count) + line = continuationPrefix + Self.truncatePlain(word, width: available) + hasContent = true + } + if hasContent { + lines.append(line) + } + return lines + } + + private static func nextResetSummary(rows: [CLICardsBriefRow], now: Date) -> String? { + guard let (row, label, _) = rows.compactMap({ row -> (CLICardsBriefRow, String, Date)? in + guard let reset = row.resetLabel, !reset.isEmpty, reset != "—" else { return nil } + let sortDate = row.resetAt ?? Self.resetSortDate(reset, now: now) + guard let sortDate else { return nil } + return (row, reset, sortDate) + }).min(by: { $0.2 < $1.2 }) + else { return nil } + let separator = Self.resetDurationMinutes(label) == nil ? " · " : " in " + return "\(row.providerName)\(separator)\(label)" + } + + private static func resetSortDate(_ label: String, now: Date) -> Date? { + guard let minutes = resetDurationMinutes(label) else { return nil } + return now.addingTimeInterval(TimeInterval(minutes * 60)) + } + + private static func resetDurationMinutes(_ label: String) -> Int? { + var minutes = 0 + var matched = false + if let match = label.range(of: #"(\d+)d"#, options: .regularExpression) { + matched = true + minutes += (Int(label[match].dropLast()) ?? 0) * 24 * 60 + } + if let match = label.range(of: #"(\d+)h"#, options: .regularExpression) { + matched = true + minutes += (Int(label[match].dropLast()) ?? 0) * 60 + } + if let match = label.range(of: #"(\d+)m"#, options: .regularExpression) { + matched = true + minutes += Int(label[match].dropLast()) ?? 0 + } + return matched ? minutes : nil + } + + private static func tableColumnWidths( + rows: [CLICardsBriefRow], + terminalWidth: Int) -> CLICardsBriefColumns + { + let providerContent = rows.map { Self.providerPlainLabel($0).count }.max() ?? Self.providerColumnMin + let resetContent = rows.compactMap(\.resetLabel).map(\.count).max() ?? 6 + + var providerWidth = min(Self.providerColumnMax, max(Self.providerColumnMin, providerContent)) + var usageWidth = Self.usageColumnWidth + var resetWidth = min(Self.resetColumnMax, max(Self.resetColumnMin, resetContent)) + + while providerWidth + usageWidth + resetWidth + Self.tableBorderOverhead > terminalWidth { + if usageWidth > Self.usageColumnMin { + usageWidth -= 1 + } else if providerWidth > Self.providerColumnMin { + providerWidth -= 1 + } else if resetWidth > Self.resetColumnMin { + resetWidth -= 1 + } else { + break + } + } + + while providerWidth + usageWidth + resetWidth + Self.tableBorderOverhead > terminalWidth { + if providerWidth > Self.providerColumnFloor { + providerWidth -= 1 + } else if usageWidth > Self.usageColumnFloor { + usageWidth -= 1 + } else if resetWidth > Self.resetColumnFloor { + resetWidth -= 1 + } else { + break + } + } + + return CLICardsBriefColumns(provider: providerWidth, usage: usageWidth, reset: resetWidth) + } + + private static func briefResetLabel(_ resetText: String?) -> String? { + guard var text = resetText?.trimmingCharacters(in: .whitespacesAndNewlines), !text.isEmpty else { + return nil + } + if text.hasPrefix("⏳ ") { + text = String(text.dropFirst(2)) + } + if text.hasPrefix("Resets in ") { + text = String(text.dropFirst("Resets in ".count)) + } else if text.hasPrefix("Resets ") { + text = String(text.dropFirst("Resets ".count)) + } + return text.isEmpty ? nil : text + } + + private static func timestampString(now: Date) -> String { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.timeZone = .current + formatter.dateFormat = "yyyy-MM-dd HH:mm zzz" + return formatter.string(from: now) + } + + private static func pad(_ text: String, width: Int, alignRight: Bool = false) -> String { + let visible = Self.visibleLength(text) + if visible >= width { return text } + let padding = String(repeating: " ", count: width - visible) + return alignRight ? padding + text : text + padding + } + + private static func fitCell(_ text: String, width: Int, alignRight: Bool = false) -> String { + let visible = Self.visibleLength(text) + if visible <= width { + return Self.pad(text, width: width, alignRight: alignRight) + } + let plain = TextParsing.stripANSICodes(text) + let clipped = Self.truncatePlain(plain, width: width) + return alignRight ? Self.pad(clipped, width: width, alignRight: true) : clipped + } + + private static func truncatePlain(_ text: String, width: Int) -> String { + guard width > 0 else { return "" } + if text.count <= width { return text } + guard width > 1 else { return String(text.prefix(width)) } + return String(text.prefix(width - 1)) + "…" + } + + private static func visibleLength(_ text: String) -> Int { + TextParsing.stripANSICodes(text).count + } +} diff --git a/Sources/CodexBarCLI/CLICardsCommand.swift b/Sources/CodexBarCLI/CLICardsCommand.swift new file mode 100644 index 0000000000..919102a1ce --- /dev/null +++ b/Sources/CodexBarCLI/CLICardsCommand.swift @@ -0,0 +1,228 @@ +import CodexBarCore +import Commander +import Foundation + +struct CardsOptions: CommanderParsable { + private static let sourceHelp: String = { + #if os(macOS) + "Data source: auto | web | cli | oauth | api (auto behavior is provider-specific)" + #else + "Data source: auto | web | cli | oauth | api (web/auto are macOS only for web-capable providers)" + #endif + }() + + @Flag(names: [.short("v"), .long("verbose")], help: "Enable verbose logging") + var verbose: Bool = false + + @Flag(name: .long("json-output"), help: "Emit machine-readable logs") + var jsonOutput: Bool = false + + @Option(name: .long("log-level"), help: "Set log level (trace|verbose|debug|info|warning|error|critical)") + var logLevel: String? + + @Option( + name: .long("provider"), + help: ProviderHelp.optionHelp) + var provider: ProviderSelection? + + @Option(name: .long("account"), help: "Token account label to use (from config.json)") + var account: String? + + @Option(name: .long("account-index"), help: "Token account index (1-based)") + var accountIndex: Int? + + @Flag(name: .long("all-accounts"), help: "Fetch all token accounts, or all visible Codex accounts") + var allAccounts: Bool = false + + @Flag(name: .long("no-credits"), help: "Skip Codex credits line") + var noCredits: Bool = false + + @Flag(name: .long("no-color"), help: "Disable ANSI colors in text output") + var noColor: Bool = false + + @Flag(name: .long("status"), help: "Fetch and include provider status") + var status: Bool = false + + @Flag(name: .long("web"), help: "Alias for --source web") + var web: Bool = false + + @Option(name: .long("source"), help: Self.sourceHelp) + var source: String? + + @Option(name: .long("web-timeout"), help: "Web fetch timeout (seconds; source=auto or web)") + var webTimeout: Double? + + @Flag(name: .long("web-debug-dump-html"), help: "Dump HTML snapshots to /tmp when Codex dashboard data is missing") + var webDebugDumpHtml: Bool = false + + @Flag(name: .long("antigravity-plan-debug"), help: "Emit Antigravity planInfo fields (debug)") + var antigravityPlanDebug: Bool = false + + @Flag(name: .long("augment-debug"), help: "Emit Augment API responses (debug)") + var augmentDebug: Bool = false + + @Flag(name: .long("brief"), help: "Compact table layout instead of the card grid") + var brief: Bool = false +} + +extension CodexBarCLI { + static func runCards(_ values: ParsedValues) async { + let output = CLIOutputPreferences.from(values: values) + let config = Self.loadConfig(output: output) + let provider = Self.decodeProvider(from: values, config: config) + let includeCredits = !values.flags.contains("noCredits") + let includeStatus = values.flags.contains("status") + let sourceModeRaw = values.options["source"]?.last + let parsedSourceMode = Self.decodeSourceMode(from: values) + if sourceModeRaw != nil, parsedSourceMode == nil { + Self.exit( + code: .failure, + message: "Error: --source must be auto|web|cli|oauth|api.", + output: output, + kind: .args) + } + let antigravityPlanDebug = values.flags.contains("antigravityPlanDebug") + let augmentDebug = values.flags.contains("augmentDebug") + let webDebugDumpHTML = values.flags.contains("webDebugDumpHtml") + let webTimeout: TimeInterval + do { + webTimeout = try Self.decodeWebTimeout(from: values) ?? 60 + } catch { + Self.exit(code: .failure, message: "Error: \(error.localizedDescription)", output: output, kind: .args) + } + let verbose = values.flags.contains("verbose") + let noColor = values.flags.contains("noColor") + let useColor = Self.shouldUseColor(noColor: noColor, format: .text) + let brief = values.flags.contains("brief") + let resetStyle = Self.resetTimeDisplayStyleFromDefaults() + let weeklyWorkDays = Self.weeklyProgressWorkDaysFromDefaults() + let providerList = provider.asList + let claudeConfig = config.providerConfig(for: .claude) + + let tokenSelection: TokenAccountCLISelection + do { + tokenSelection = try Self.decodeTokenAccountSelection(from: values) + } catch { + Self.exit(code: .failure, message: "Error: \(error.localizedDescription)", output: output, kind: .args) + } + + if tokenSelection.allAccounts, tokenSelection.label != nil || tokenSelection.index != nil { + Self.exit( + code: .failure, + message: "Error: --all-accounts cannot be combined with --account or --account-index.", + output: output, + kind: .args) + } + + if tokenSelection.usesOverride { + guard providerList.count == 1 else { + Self.exit( + code: .failure, + message: "Error: account selection requires a single provider.", + output: output, + kind: .args) + } + let supportsAllCodexAccounts = providerList[0] == .codex + && tokenSelection.allAccounts + && tokenSelection.label == nil + && tokenSelection.index == nil + guard supportsAllCodexAccounts || TokenAccountSupportCatalog.support(for: providerList[0]) != nil else { + Self.exit( + code: .failure, + message: "Error: \(providerList[0].rawValue) does not support token accounts.", + output: output, + kind: .args) + } + } + + let browserDetection = BrowserDetection() + let fetcher = UsageFetcher() + let claudeFetcher = ClaudeUsageFetcher(browserDetection: browserDetection) + let tokenContext: TokenAccountCLIContext + do { + tokenContext = try TokenAccountCLIContext( + selection: tokenSelection, + config: config, + verbose: verbose) + } catch { + Self.exit(code: .failure, message: "Error: \(error.localizedDescription)", output: output, kind: .config) + } + + var cards: [CLICardModel] = [] + var failures: [CLICardFailure] = [] + var exitCode: ExitCode = .success + let command = UsageCommandContext( + format: .text, + includeCredits: includeCredits, + sourceModeOverride: parsedSourceMode, + antigravityPlanDebug: antigravityPlanDebug, + augmentDebug: augmentDebug, + webDebugDumpHTML: webDebugDumpHTML, + webTimeout: webTimeout, + verbose: verbose, + useColor: useColor, + resetStyle: resetStyle, + weeklyWorkDays: weeklyWorkDays, + jsonOnly: output.jsonOnly, + includeAllCodexAccounts: tokenSelection.allAccounts && providerList == [.codex], + fetcher: fetcher, + claudeFetcher: claudeFetcher, + browserDetection: browserDetection, + cardsLayout: true) + + for provider in providerList { + let status = includeStatus ? await Self.fetchStatus(for: provider) : nil + let claudeSwapEligible = CLIClaudeSwapCards.isEligible( + provider: provider, + integrationEnabled: claudeConfig?.claudeSwapEnabled == true, + hasExplicitAccountSelection: tokenSelection.usesOverride, + sourceModeOverride: parsedSourceMode) + let result = await CLIClaudeSwapCards.fetch( + eligible: claudeSwapEligible, + executablePath: CLIClaudeSwapCards.executablePath(from: claudeConfig), + showSingleAccount: claudeConfig?.claudeSwapShowSingleAccount == true, + renderOptions: CLIClaudeSwapCardsRenderOptions( + status: status, + useColor: useColor, + resetStyle: resetStyle, + weeklyWorkDays: weeklyWorkDays, + now: Date()), + ambientFetch: { + await ProviderInteractionContext.$current.withValue(.background) { + await Self.fetchUsageOutputs( + provider: provider, + status: status, + tokenContext: tokenContext, + command: command) + } + }) + if result.exitCode != .success { exitCode = result.exitCode } + cards.append(contentsOf: result.cards) + failures.append(contentsOf: result.cardFailures) + } + + let rendered: String + let enhanced = CLITerminalCapabilities.supportsEnhancedCards(useColor: useColor) + if brief { + let rows = CLICardsBriefRenderer.makeRows(cards: cards) + rendered = CLICardsBriefRenderer.render( + rows: rows, + failures: failures, + terminalWidth: CLICardsRenderer.terminalColumnCount(), + useColor: useColor, + enhanced: enhanced) + } else { + rendered = CLICardsRenderer.render( + cards: cards, + failures: failures, + terminalWidth: CLICardsRenderer.terminalColumnCount(), + useColor: useColor, + enhanced: enhanced) + } + if !rendered.isEmpty { + print(rendered) + } + + Self.exit(code: exitCode, output: output, kind: exitCode == .success ? .runtime : .provider) + } +} diff --git a/Sources/CodexBarCLI/CLICardsRenderer.swift b/Sources/CodexBarCLI/CLICardsRenderer.swift new file mode 100644 index 0000000000..f98b9c9de2 --- /dev/null +++ b/Sources/CodexBarCLI/CLICardsRenderer.swift @@ -0,0 +1,657 @@ +import CodexBarCore +import Foundation +#if canImport(Glibc) +import Glibc +#elseif canImport(Musl) +import Musl +#elseif canImport(Darwin) +import Darwin +#endif + +struct CLICardMetric: Sendable, Equatable { + let label: String + let remainingPercent: Double + let resetText: String? + let resetAt: Date? + let detailText: String? + + init( + label: String, + remainingPercent: Double, + resetText: String?, + resetAt: Date? = nil, + detailText: String? = nil) + { + self.label = label + self.remainingPercent = remainingPercent + self.resetText = resetText + self.resetAt = resetAt + self.detailText = detailText + } +} + +struct CLICardModel: Sendable, Equatable { + let provider: UsageProvider + let title: String + let sourceLabel: String + let planBadge: String? + let accountLine: String? + let isActive: Bool + let accountProblem: String? + let infoLines: [String] + let metrics: [CLICardMetric] + let extraLines: [String] + let statusLine: String? + + init( + provider: UsageProvider, + title: String, + sourceLabel: String, + planBadge: String?, + accountLine: String?, + isActive: Bool = false, + accountProblem: String? = nil, + infoLines: [String], + metrics: [CLICardMetric], + extraLines: [String], + statusLine: String?) + { + self.provider = provider + self.title = title + self.sourceLabel = sourceLabel + self.planBadge = planBadge + self.accountLine = accountLine + self.isActive = isActive + self.accountProblem = accountProblem + self.infoLines = infoLines + self.metrics = metrics + self.extraLines = extraLines + self.statusLine = statusLine + } +} + +struct CLICardFailure: Sendable, Equatable { + let provider: UsageProvider + let accountLabel: String? + let message: String +} + +struct CLICardBuildInput: Sendable { + let provider: UsageProvider + let snapshot: UsageSnapshot + let credits: CreditsSnapshot? + let source: String + let status: ProviderStatusPayload? + let notes: [String] + let useColor: Bool + let resetStyle: ResetTimeDisplayStyle + let weeklyWorkDays: Int? + let now: Date +} + +enum CLICardsRenderer { + static let minCardWidth = 38 + static let maxCardWidth = 42 + static let cardGap = 2 + + static func terminalColumnCount() -> Int { + if let value = terminalColumnCountFromTTY(), value > 0 { + return value + } + if let columns = ProcessInfo.processInfo.environment["COLUMNS"], + let value = Int(columns.trimmingCharacters(in: .whitespacesAndNewlines)), + value > 0 + { + return value + } + return 80 + } + + private static func terminalColumnCountFromTTY(fileDescriptor: Int32 = STDOUT_FILENO) -> Int? { + guard isatty(fileDescriptor) == 1 else { return nil } + var windowSize = winsize(ws_row: 0, ws_col: 0, ws_xpixel: 0, ws_ypixel: 0) + guard ioctl(fileDescriptor, UInt(TIOCGWINSZ), &windowSize) == 0 else { return nil } + let columns = Int(windowSize.ws_col) + return columns > 0 ? columns : nil + } + + static func columnCount(terminalWidth: Int, minCardWidth: Int = Self.minCardWidth) -> Int { + let usable = max(minCardWidth, terminalWidth) + return max(1, (usable + Self.cardGap) / (minCardWidth + Self.cardGap)) + } + + static func cardWidth(terminalWidth: Int, columns: Int) -> Int { + let totalGaps = (columns - 1) * Self.cardGap + let availableWidth = max(1, (terminalWidth - totalGaps) / columns) + return min(Self.maxCardWidth, availableWidth) + } + + static func makeCard(_ input: CLICardBuildInput) -> CLICardModel { + let provider = input.provider + let snapshot = input.snapshot + let displayName = ProviderDescriptorRegistry.descriptor(for: provider).metadata.displayName + let context = RenderContext( + header: displayName, + status: input.status, + useColor: input.useColor, + resetStyle: input.resetStyle, + weeklyWorkDays: input.weeklyWorkDays, + notes: input.notes) + let infoLines = CLIRenderer.collectCardInfoLines( + provider: provider, + snapshot: snapshot, + credits: input.credits, + notes: input.notes, + useColor: input.useColor, + now: input.now) + let metrics = CLIRenderer.collectCardMetrics( + provider: provider, + snapshot: snapshot, + resetStyle: input.resetStyle, + now: input.now) + let extraLines = CLIRenderer.collectCardExtraLines( + provider: provider, + snapshot: snapshot, + credits: input.credits, + context: context, + now: input.now) + let statusLine: String? + if let status = input.status { + let line = "Status: \(status.indicator.label)\(status.descriptionSuffix)" + statusLine = CLIRenderer.colorizeStatusLine(line, indicator: status.indicator, useColor: input.useColor) + } else { + statusLine = nil + } + return CLICardModel( + provider: provider, + title: displayName, + sourceLabel: Self.normalizedSourceLabel(input.source), + planBadge: CLIRenderer.planBadgeText(provider: provider, snapshot: snapshot), + accountLine: snapshot.accountEmail(for: provider), + infoLines: infoLines, + metrics: metrics, + extraLines: extraLines, + statusLine: statusLine) + } + + static func makeClaudeSwapCard( + account: ProviderAccountUsageSnapshot, + renderOptions: CLIClaudeSwapCardsRenderOptions) -> CLICardModel + { + let sanitizedLabel = CLIClaudeSwapText.sanitizeLabel(account.displayLabel) + let label = sanitizedLabel.isEmpty + ? CLIClaudeSwapText.sanitizeLabel("Account \(account.id.opaqueID)") + : sanitizedLabel + let problem = account.error.map(CLIClaudeSwapText.sanitizeDiagnostic) + if let snapshot = account.snapshot { + let base = Self.makeCard(CLICardBuildInput( + provider: .claude, + snapshot: snapshot, + credits: nil, + source: ClaudeSwapAccountProjection.sourceLabel, + status: renderOptions.status, + notes: [], + useColor: renderOptions.useColor, + resetStyle: renderOptions.resetStyle, + weeklyWorkDays: renderOptions.weeklyWorkDays, + now: renderOptions.now)) + return CLICardModel( + provider: base.provider, + title: base.title, + sourceLabel: base.sourceLabel, + planBadge: nil, + accountLine: label, + isActive: account.isActive, + accountProblem: problem, + infoLines: base.infoLines, + metrics: base.metrics, + extraLines: base.extraLines, + statusLine: base.statusLine) + } + + let statusLine: String? = renderOptions.status.map { status in + let line = "Status: \(status.indicator.label)\(status.descriptionSuffix)" + return CLIRenderer.colorizeStatusLine( + line, + indicator: status.indicator, + useColor: renderOptions.useColor) + } + return CLICardModel( + provider: .claude, + title: ProviderDescriptorRegistry.descriptor(for: .claude).metadata.displayName, + sourceLabel: ClaudeSwapAccountProjection.sourceLabel, + planBadge: nil, + accountLine: label, + isActive: account.isActive, + accountProblem: problem, + infoLines: [], + metrics: [], + extraLines: [], + statusLine: statusLine) + } + + static func render( + cards: [CLICardModel], + failures: [CLICardFailure], + terminalWidth: Int, + useColor: Bool, + enhanced: Bool = false) -> String + { + guard !cards.isEmpty else { + return self.renderFailuresOnly(failures, useColor: useColor) + } + + let columns = Self.columnCount(terminalWidth: terminalWidth) + let width = Self.cardWidth(terminalWidth: terminalWidth, columns: columns) + var chunks: [String] = [] + + for rowStart in stride(from: 0, to: cards.count, by: columns) { + let rowCards = Array(cards[rowStart.. String in + if lineIndex < lines.count - 1 { + return lines[lineIndex] + } + if lineIndex == rowHeight - 1, let bottom = lines.last { + return bottom + } + return Self.emptyCardLine(width: width, useColor: useColor, enhanced: enhanced) + } + chunks.append(parts.joined(separator: String(repeating: " ", count: Self.cardGap))) + } + if rowStart + columns < cards.count { + chunks.append("") + } + } + + if !failures.isEmpty { + if !chunks.isEmpty { + chunks.append("") + } + chunks.append(Self.renderFailureFooter(failures: failures, useColor: useColor)) + } + + return chunks.joined(separator: "\n") + } + + static func renderCard(_ card: CLICardModel, width: Int, useColor: Bool, enhanced: Bool = false) -> [String] { + let innerWidth = max(12, width - 4) + var lines: [String] = [] + lines.append(Self.boxLine(kind: .top, innerWidth: innerWidth, useColor: useColor, enhanced: enhanced)) + lines.append(Self.headerLine(card: card, innerWidth: innerWidth, useColor: useColor, enhanced: enhanced)) + + if let account = card.accountLine?.trimmingCharacters(in: .whitespacesAndNewlines), !account.isEmpty { + let active = card.isActive ? " [active]" : "" + let labelWidth = max(1, innerWidth - 2 - active.count) + let accountText = "@ \(Self.truncatePlain(account, width: labelWidth))\(active)" + lines.append(Self.contentLine( + accountText, + innerWidth: innerWidth, + useColor: useColor, + enhanced: enhanced, + style: .subtle)) + } + + lines.append(Self.separatorLine(innerWidth: innerWidth, useColor: useColor, enhanced: enhanced)) + + if let problem = card.accountProblem, !problem.isEmpty { + for problemLine in Self.wrapPlainText(problem, width: innerWidth) { + lines.append(Self.contentLine( + problemLine, + innerWidth: innerWidth, + useColor: useColor, + enhanced: enhanced)) + } + } + + for infoLine in card.infoLines { + lines.append(Self.detailLine( + infoLine, + innerWidth: innerWidth, + useColor: useColor, + enhanced: enhanced)) + } + + if !card.metrics.isEmpty, !card.infoLines.isEmpty { + lines.append(Self.contentLine("", innerWidth: innerWidth, useColor: useColor, enhanced: enhanced)) + } + + for (index, metric) in card.metrics.enumerated() { + if index > 0 { + lines.append(Self.contentLine("", innerWidth: innerWidth, useColor: useColor, enhanced: enhanced)) + } + lines.append(Self.metricLabelLine( + metric: metric, + innerWidth: innerWidth, + useColor: useColor, + enhanced: enhanced)) + lines.append(Self.metricBarLine( + metric: metric, + innerWidth: innerWidth, + useColor: useColor, + enhanced: enhanced)) + if let resetText = metric.resetText { + lines.append(Self.contentLine( + resetText, + innerWidth: innerWidth, + useColor: useColor, + enhanced: enhanced, + style: .subtle)) + } + if let detailText = metric.detailText { + lines.append(Self.contentLine( + detailText, + innerWidth: innerWidth, + useColor: useColor, + enhanced: enhanced, + style: .subtle)) + } + } + + for extraLine in card.extraLines { + lines.append(Self.detailLine(extraLine, innerWidth: innerWidth, useColor: useColor, enhanced: enhanced)) + } + + if let statusLine = card.statusLine { + lines.append(Self.contentLine(statusLine, innerWidth: innerWidth, useColor: useColor, enhanced: enhanced)) + } + + lines.append(Self.boxLine(kind: .bottom, innerWidth: innerWidth, useColor: useColor, enhanced: enhanced)) + return lines + } + + private enum BoxLineKind { + case top + case bottom + } + + private enum ContentStyle: Equatable { + case normal + case subtle + case border + } + + private static func boxLine(kind: BoxLineKind, innerWidth: Int, useColor: Bool, enhanced: Bool) -> String { + let chars = switch kind { + case .top: ("╭", "╮") + case .bottom: ("╰", "╯") + } + let line = chars.0 + String(repeating: "─", count: innerWidth + 2) + chars.1 + return Self.styleBorder(line, useColor: useColor, enhanced: enhanced) + } + + private static func headerLine(card: CLICardModel, innerWidth: Int, useColor: Bool, enhanced: Bool) -> String { + let title: String + let badge: String + if useColor, enhanced { + title = CLIRenderer.colorizeEnhancedAccentBold(card.title) + badge = CLIRenderer.colorizeEnhancedBadge(card.sourceLabel) + } else if useColor { + title = CLIRenderer.colorizeAccentBold(card.title) + badge = CLIRenderer.colorizeCardBadge(card.sourceLabel) + } else { + title = card.title + badge = "[\(card.sourceLabel)]" + } + let left = "\(title) \(badge)" + let leftVisible = Self.visibleLength(left) + let rawPlanText = card.planBadge.map { "PLAN \($0)" } ?? "" + let maxPlanWidth = max(0, innerWidth - leftVisible - 1) + let planText = maxPlanWidth >= 8 ? Self.truncatePlain(rawPlanText, width: maxPlanWidth) : "" + let planVisible = Self.visibleLength(planText) + let gap = max(1, innerWidth - leftVisible - planVisible) + let plan = Self.planPill(text: planText, useColor: useColor, enhanced: enhanced) + let content = left + String(repeating: " ", count: gap) + plan + return Self.sideBorder(content, innerWidth: innerWidth, useColor: useColor, enhanced: enhanced) + } + + private static func planPill(text: String, useColor: Bool, enhanced: Bool) -> String { + guard !text.isEmpty else { return "" } + let pieces = text.split(separator: " ", maxSplits: 1).map(String.init) + guard pieces.count == 2 else { + return useColor ? CLIRenderer.colorizeCardPlanBox(text) : text + } + if useColor, enhanced { + return CLIRenderer.colorizeEnhancedPlanLabel(pieces[0]) + + " " + + CLIRenderer.colorizeEnhancedPlanValue(pieces[1]) + } + if useColor { + return CLIRenderer.colorizeCardPlanBox(pieces[0]) + + " " + + CLIRenderer.colorizeWarning(pieces[1]) + } + return text + } + + private static func separatorLine(innerWidth: Int, useColor: Bool, enhanced: Bool) -> String { + self.sideBorder( + String(repeating: "─", count: innerWidth), + innerWidth: innerWidth, + useColor: useColor, + enhanced: enhanced, + contentStyle: .border) + } + + private static func metricLabelLine( + metric: CLICardMetric, + innerWidth: Int, + useColor: Bool, + enhanced: Bool) -> String + { + let percentText = UsageFormatter.usageLine( + remaining: metric.remainingPercent, + used: 100 - metric.remainingPercent, + showUsed: false) + let coloredPercent: String = if useColor, enhanced { + CLIRenderer.colorizeEnhancedRemainingPercent(percentText, remainingPercent: metric.remainingPercent) + } else { + CLIRenderer.colorizeCardPercent( + percentText, + remainingPercent: metric.remainingPercent, + useColor: useColor) + } + let label: String = if useColor, enhanced { + CLIRenderer.colorizeEnhancedReadable(metric.label) + } else if useColor { + CLIRenderer.colorizeReadable(metric.label) + } else { + metric.label + } + let gap = max(1, innerWidth - Self.visibleLength(label) - Self.visibleLength(coloredPercent)) + let content = label + String(repeating: " ", count: gap) + coloredPercent + return Self.sideBorder(content, innerWidth: innerWidth, useColor: useColor, enhanced: enhanced) + } + + private static func metricBarLine( + metric: CLICardMetric, + innerWidth: Int, + useColor: Bool, + enhanced: Bool) -> String + { + let barWidth = max(4, innerWidth - 4) + let bar: String = if useColor, enhanced { + CLIRenderer.gradientRemainingTrackBar(remainingPercent: metric.remainingPercent, width: barWidth) + } else { + CLIRenderer.cardBlockBar( + remainingPercent: metric.remainingPercent, + width: barWidth, + useColor: useColor) + } + return Self.sideBorder("[ \(bar) ]", innerWidth: innerWidth, useColor: useColor, enhanced: enhanced) + } + + private static func detailLine(_ content: String, innerWidth: Int, useColor: Bool, enhanced: Bool) -> String { + let normalized = Self.normalizeGlyphs(content) + let plain = TextParsing.stripANSICodes(normalized) + let parts = plain.split(separator: ":", maxSplits: 1).map(String.init) + guard parts.count == 2 else { + return Self.contentLine(normalized, innerWidth: innerWidth, useColor: useColor, enhanced: enhanced) + } + + let rawLabel = parts[0].trimmingCharacters(in: .whitespacesAndNewlines) + ":" + let rawValue = parts[1].trimmingCharacters(in: .whitespacesAndNewlines) + let label: String = if useColor, enhanced { + CLIRenderer.colorizeEnhancedReadable(rawLabel) + } else if useColor { + CLIRenderer.colorizeReadable(rawLabel) + } else { + rawLabel + } + let value: String = if useColor, enhanced { + CLIRenderer.colorizeEnhancedGood(rawValue) + } else if useColor { + CLIRenderer.colorizeAccent(rawValue) + } else { + rawValue + } + let gap = max(1, innerWidth - Self.visibleLength(label) - Self.visibleLength(value)) + let line = label + String(repeating: " ", count: gap) + value + return Self.sideBorder(line, innerWidth: innerWidth, useColor: useColor, enhanced: enhanced) + } + + private static func contentLine( + _ content: String, + innerWidth: Int, + useColor: Bool, + enhanced: Bool, + style: ContentStyle = .normal) -> String + { + let normalized = Self.normalizeGlyphs(content) + let stripped = TextParsing.stripANSICodes(normalized) + let clipped = stripped.count <= innerWidth + ? normalized + : (innerWidth <= 1 ? String(stripped.prefix(innerWidth)) : String(stripped.prefix(innerWidth - 1)) + "…") + let display: String = if style == .subtle, useColor, enhanced { + CLIRenderer.colorizeEnhancedSubtle(TextParsing.stripANSICodes(clipped)) + } else if style == .subtle, useColor { + CLIRenderer.colorizeSubtle(TextParsing.stripANSICodes(clipped)) + } else { + clipped + } + return Self.sideBorder(display, innerWidth: innerWidth, useColor: useColor, enhanced: enhanced) + } + + private static func sideBorder( + _ content: String, + innerWidth: Int, + useColor: Bool, + enhanced: Bool, + contentStyle: ContentStyle = .normal) -> String + { + let fitted = Self.fitContent(content, width: innerWidth) + let padding = max(0, innerWidth - Self.visibleLength(fitted)) + let padded = fitted + String(repeating: " ", count: padding) + let visible = "│ \(padded) │" + guard useColor else { return visible } + let left = Self.styleBorder("│ ", useColor: useColor, enhanced: enhanced) + let right = Self.styleBorder(" │", useColor: useColor, enhanced: enhanced) + let styledContent: String = if contentStyle == .border { + Self.styleBorder(padded, useColor: useColor, enhanced: enhanced) + } else { + padded + } + return left + styledContent + right + } + + private static func styleBorder(_ text: String, useColor: Bool, enhanced: Bool) -> String { + guard useColor else { return text } + if enhanced { + return CLIRenderer.colorizeEnhancedBorder(text) + } + return CLIRenderer.colorizeCardBorder(text) + } + + private static func emptyCardLine(width: Int, useColor: Bool, enhanced: Bool) -> String { + let innerWidth = max(12, width - 4) + return Self.sideBorder("", innerWidth: innerWidth, useColor: useColor, enhanced: enhanced) + } + + private static func visibleLength(_ text: String) -> Int { + TextParsing.stripANSICodes(self.normalizeGlyphs(text)).count + } + + private static func truncatePlain(_ text: String, width: Int) -> String { + guard width > 0 else { return "" } + guard text.count > width else { return text } + if width <= 1 { return String(text.prefix(width)) } + return String(text.prefix(width - 1)) + "…" + } + + private static func wrapPlainText(_ text: String, width: Int) -> [String] { + guard width > 0 else { return [] } + var lines: [String] = [] + var line = "" + for word in text.split(whereSeparator: \.isWhitespace).map(String.init) { + if word.count > width { + if !line.isEmpty { + lines.append(line) + line = "" + } + var remainder = word[...] + while remainder.count > width { + let end = remainder.index(remainder.startIndex, offsetBy: width) + lines.append(String(remainder[.. String { + guard self.visibleLength(text) > width else { return text } + return self.truncatePlain(TextParsing.stripANSICodes(text), width: width) + } + + private static func normalizeGlyphs(_ text: String) -> String { + text + .replacingOccurrences(of: "👤", with: "@") + .replacingOccurrences(of: "⏳ Resets in ", with: "Reset in ") + .replacingOccurrences(of: "⏳ Resets ", with: "Reset ") + .replacingOccurrences(of: "⏳ ", with: "Reset ") + } + + static func renderFailureFooter(failures: [CLICardFailure], useColor: Bool) -> String { + var lines = ["Failed providers:"] + for failure in failures { + let name = ProviderDescriptorRegistry.descriptor(for: failure.provider).metadata.displayName + if let account = failure.accountLabel, !account.isEmpty { + lines.append(" - \(name) (\(account)): \(failure.message)") + } else { + lines.append(" - \(name): \(failure.message)") + } + } + let text = lines.joined(separator: "\n") + guard useColor else { return text } + return CLIRenderer.colorizeError(text) + } + + static func renderFailuresOnly(_ failures: [CLICardFailure], useColor: Bool) -> String { + guard !failures.isEmpty else { return "" } + return self.renderFailureFooter(failures: failures, useColor: useColor) + } + + private static func normalizedSourceLabel(_ source: String) -> String { + let trimmed = source.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.isEmpty { return "auto" } + if trimmed.contains("oauth") { return "oauth" } + if trimmed.contains("web") || trimmed.contains("openai-web") { return "web" } + if trimmed.contains("api") { return "api" } + if trimmed.contains("cli") { return "cli" } + return trimmed + } +} diff --git a/Sources/CodexBarCLI/CLIClaudeSwapCards.swift b/Sources/CodexBarCLI/CLIClaudeSwapCards.swift new file mode 100644 index 0000000000..a98153ab31 --- /dev/null +++ b/Sources/CodexBarCLI/CLIClaudeSwapCards.swift @@ -0,0 +1,158 @@ +import CodexBarCore +import Foundation + +enum CLIClaudeSwapText { + static let labelScalarLimit = 256 + static let diagnosticScalarLimit = 512 + + static func sanitizeLabel(_ text: String) -> String { + self.sanitize(text, scalarLimit: self.labelScalarLimit) + } + + static func sanitizeDiagnostic(_ text: String) -> String { + self.sanitize(text, scalarLimit: self.diagnosticScalarLimit) + } + + private enum EscapeState { + case plain + case escape + case controlSequence + case operatingSystemCommand + case operatingSystemCommandEscape + } + + private static func sanitize(_ text: String, scalarLimit: Int) -> String { + var state = EscapeState.plain + var scalars: [Unicode.Scalar] = [] + scalars.reserveCapacity(min(text.unicodeScalars.count, scalarLimit)) + + for scalar in text.unicodeScalars { + switch state { + case .escape: + if scalar.value == 0x5B { + state = .controlSequence + } else if scalar.value == 0x5D { + state = .operatingSystemCommand + } else if (0x30...0x7E).contains(scalar.value) { + state = .plain + } + case .controlSequence: + if (0x40...0x7E).contains(scalar.value) { + state = .plain + } + case .operatingSystemCommand: + if scalar.value == 0x07 { + state = .plain + } else if scalar.value == 0x1B { + state = .operatingSystemCommandEscape + } + case .operatingSystemCommandEscape: + state = scalar.value == 0x5C ? .plain : .operatingSystemCommand + case .plain: + switch scalar.value { + case 0x0A, 0x0D, 0x2028, 0x2029: + scalars.append(" ") + case 0x1B: + state = .escape + case 0x9B: + state = .controlSequence + case 0x9D: + state = .operatingSystemCommand + default: + let category = scalar.properties.generalCategory + if category != .control, category != .format { + scalars.append(scalar) + } + } + } + } + + return String(String.UnicodeScalarView(scalars.prefix(scalarLimit))) + .trimmingCharacters(in: .whitespacesAndNewlines) + } +} + +struct CLIClaudeSwapCardsRenderOptions: Sendable { + let status: ProviderStatusPayload? + let useColor: Bool + let resetStyle: ResetTimeDisplayStyle + let weeklyWorkDays: Int? + let now: Date +} + +enum CLIClaudeSwapCards { + typealias AccountListReader = @Sendable (String) async throws -> ClaudeSwapAccountList + typealias AmbientFetch = @Sendable () async -> UsageCommandOutput + + static func executablePath(from config: ProviderConfig?) -> String { + config?.sanitizedClaudeSwapExecutablePath ?? "" + } + + static func isEligible( + provider: UsageProvider, + integrationEnabled: Bool, + hasExplicitAccountSelection: Bool, + sourceModeOverride: ProviderSourceMode?) -> Bool + { + provider == .claude + && integrationEnabled + && !hasExplicitAccountSelection + && (sourceModeOverride == nil || sourceModeOverride == .auto) + } + + static func fetch( + eligible: Bool, + executablePath: String, + showSingleAccount: Bool = false, + renderOptions: CLIClaudeSwapCardsRenderOptions, + ambientFetch: @escaping AmbientFetch) async -> UsageCommandOutput + { + await self.fetch( + eligible: eligible, + executablePath: executablePath, + showSingleAccount: showSingleAccount, + renderOptions: renderOptions, + ambientFetch: ambientFetch, + accountListReader: { path in + try await ClaudeSwapAccountReader.readAccountList(executablePath: path) + }) + } + + static func fetch( + eligible: Bool, + executablePath: String, + showSingleAccount: Bool = false, + renderOptions: CLIClaudeSwapCardsRenderOptions, + ambientFetch: @escaping AmbientFetch, + accountListReader: @escaping AccountListReader) async -> UsageCommandOutput + { + guard eligible else { return await ambientFetch() } + + do { + let list = try await accountListReader(executablePath) + let accounts = ClaudeSwapAccountProjection.accountSnapshots(from: list, now: renderOptions.now) + guard ClaudeSwapAccountProjection.shouldPresentAccounts( + accountCount: accounts.count, + showSingleAccount: showSingleAccount) + else { return await ambientFetch() } + + var output = UsageCommandOutput() + output.cards = accounts.map { account in + CLICardsRenderer.makeClaudeSwapCard( + account: account, + renderOptions: renderOptions) + } + return output + } catch { + var output = await ambientFetch() + let diagnostic = CLIClaudeSwapText.sanitizeDiagnostic(error.localizedDescription) + let message = diagnostic.isEmpty ? "claude-swap list failed." : diagnostic + output.cardFailures.append(CLICardFailure( + provider: .claude, + accountLabel: ClaudeSwapAccountProjection.sourceLabel, + message: message)) + output.exitCode = .failure + return output + } + } +} diff --git a/Sources/CodexBarCLI/CLIConfigCommand.swift b/Sources/CodexBarCLI/CLIConfigCommand.swift index 380dc8bde3..9346551507 100644 --- a/Sources/CodexBarCLI/CLIConfigCommand.swift +++ b/Sources/CodexBarCLI/CLIConfigCommand.swift @@ -3,6 +3,29 @@ import Commander import Foundation extension CodexBarCLI { + static func runConfig(path: [String], values: ParsedValues) { + switch path { + case ["config", "validate"]: + self.runConfigValidate(values) + case ["config", "dump"]: + self.runConfigDump(values) + case ["config", "providers"]: + self.runConfigProviders(values) + case ["config", "enable"]: + self.runConfigSetProviderEnabled(values, enabled: true) + case ["config", "disable"]: + self.runConfigSetProviderEnabled(values, enabled: false) + case ["config", "set-api-key"]: + self.runConfigSetAPIKey(values) + default: + self.exit( + code: .failure, + message: "Unknown command", + output: CLIOutputPreferences.from(values: values), + kind: .args) + } + } + static func runConfigValidate(_ values: ParsedValues) { let output = CLIOutputPreferences.from(values: values) let config = Self.loadConfig(output: output) @@ -31,7 +54,8 @@ extension CodexBarCLI { static func runConfigDump(_ values: ParsedValues) { let output = CLIOutputPreferences.from(values: values) - let config = Self.loadConfig(output: output) + let showSecrets = values.flags.contains("showSecrets") + let config = Self.loadConfig(output: output).sanitizedForDump(showSecrets: showSecrets) Self.printJSON(config, pretty: output.pretty) Self.exit(code: .success, output: output, kind: .config) } @@ -127,11 +151,23 @@ extension CodexBarCLI { let enableProvider = !values.flags.contains("noEnable") let store = CodexBarConfigStore() var config = Self.loadConfig(output: output) + let accountOptions: ConfigAPIKeyAccountOptions? + do { + accountOptions = try Self.resolveConfigAPIKeyAccountOptions( + provider: provider, + label: values.options["label"]?.last, + usageScope: values.options["usageScope"]?.last, + organizationID: values.options["organizationId"]?.last, + workspaceID: values.options["workspaceId"]?.last) + } catch { + Self.exit(code: .failure, message: error.localizedDescription, output: output, kind: .args) + } config = Self.configSettingAPIKey( config, provider: provider, apiKey: apiKey, - enableProvider: enableProvider) + enableProvider: enableProvider, + accountOptions: accountOptions) do { try store.save(config) @@ -148,7 +184,8 @@ extension CodexBarCLI { case .text: let name = ProviderDescriptorRegistry.descriptor(for: provider).metadata.displayName let suffix = result.enabled ? " and enabled" : "" - print("Config: stored API key for \(name)\(suffix)") + let action = accountOptions == nil ? "stored API key" : "stored team token account" + print("Config: \(action) for \(name)\(suffix)") case .json: Self.printJSON(result, pretty: output.pretty) } @@ -177,10 +214,34 @@ extension CodexBarCLI { _ config: CodexBarConfig, provider: UsageProvider, apiKey: String, - enableProvider: Bool) -> CodexBarConfig + enableProvider: Bool, + accountOptions: ConfigAPIKeyAccountOptions? = nil) -> CodexBarConfig { var updated = config.normalized() var providerConfig = updated.providerConfig(for: provider) ?? ProviderConfig(id: provider) + if let accountOptions { + let existing = providerConfig.tokenAccounts + let accounts = existing?.accounts ?? [] + let account = ProviderTokenAccount( + id: UUID(), + label: accountOptions.label, + token: apiKey, + addedAt: Date().timeIntervalSince1970, + lastUsed: nil, + usageScope: accountOptions.usageScope.rawValue, + organizationID: accountOptions.organizationID, + workspaceID: accountOptions.workspaceID) + providerConfig.tokenAccounts = ProviderTokenAccountData( + version: existing?.version ?? 1, + accounts: accounts + [account], + activeIndex: accounts.count) + providerConfig.apiKey = nil + if enableProvider { + providerConfig.enabled = true + } + updated.setProviderConfig(providerConfig) + return updated + } providerConfig.apiKey = apiKey if enableProvider { providerConfig.enabled = true @@ -189,6 +250,48 @@ extension CodexBarCLI { return updated } + static func resolveConfigAPIKeyAccountOptions( + provider: UsageProvider, + label: String?, + usageScope: String?, + organizationID: String?, + workspaceID: String?) throws -> ConfigAPIKeyAccountOptions? + { + let cleanedLabel = Self.cleanConfigValue(label) + let cleanedScope = Self.cleanConfigValue(usageScope) + let cleanedOrganizationID = try Self.cleanSingleLineConfigValue( + organizationID, + fieldName: "organization-id") + let cleanedWorkspaceID = try Self.cleanSingleLineConfigValue( + workspaceID, + fieldName: "workspace-id") + let hasAccountOptions = cleanedLabel != nil || + cleanedScope != nil || + cleanedOrganizationID != nil || + cleanedWorkspaceID != nil + guard hasAccountOptions else { return nil } + + guard provider == .zai else { + throw CLIArgumentError("Token-account options are only supported for --provider zai.") + } + + guard cleanedScope?.lowercased() == ZaiUsageScope.team.rawValue else { + throw CLIArgumentError("Use --usage-scope team for z.ai team accounts, or omit account options.") + } + guard let organizationID = cleanedOrganizationID else { + throw CLIArgumentError("Missing --organization-id for z.ai team usage.") + } + guard let workspaceID = cleanedWorkspaceID else { + throw CLIArgumentError("Missing --workspace-id for z.ai team usage.") + } + + return ConfigAPIKeyAccountOptions( + label: cleanedLabel ?? "Team", + usageScope: .team, + organizationID: organizationID, + workspaceID: workspaceID) + } + static func configSettingProviderEnabled( _ config: CodexBarConfig, provider: UsageProvider, @@ -226,6 +329,26 @@ extension CodexBarCLI { value = value.trimmingCharacters(in: .whitespacesAndNewlines) return value.isEmpty ? nil : value } + + private static func cleanConfigValue(_ raw: String?) -> String? { + guard let value = self.cleanConfigSecret(raw) else { return nil } + return value + } + + private static func cleanSingleLineConfigValue(_ raw: String?, fieldName: String) throws -> String? { + guard let value = self.cleanConfigValue(raw) else { return nil } + guard !value.contains(where: \.isNewline) else { + throw CLIArgumentError("--\(fieldName) must be a single line.") + } + return value + } +} + +struct ConfigAPIKeyAccountOptions: Equatable { + let label: String + let usageScope: ZaiUsageScope + let organizationID: String + let workspaceID: String } struct ConfigOptions: CommanderParsable { @@ -251,6 +374,32 @@ struct ConfigOptions: CommanderParsable { var pretty: Bool = false } +struct ConfigDumpOptions: CommanderParsable { + @Flag(names: [.short("v"), .long("verbose")], help: "Enable verbose logging") + var verbose: Bool = false + + @Flag(name: .long("json-output"), help: "Emit machine-readable logs") + var jsonOutput: Bool = false + + @Option(name: .long("log-level"), help: "Set log level (trace|verbose|debug|info|warning|error|critical)") + var logLevel: String? + + @Option(name: .long("format"), help: "Output format: text | json") + var format: OutputFormat? + + @Flag(name: .long("json"), help: "") + var jsonShortcut: Bool = false + + @Flag(name: .long("json-only"), help: "Emit JSON only (suppress non-JSON output)") + var jsonOnly: Bool = false + + @Flag(name: .long("pretty"), help: "Pretty-print JSON output") + var pretty: Bool = false + + @Flag(name: .long("show-secrets"), help: "Include raw un-redacted API keys and tokens in output") + var showSecrets: Bool = false +} + struct ConfigSetAPIKeyOptions: CommanderParsable { @Flag(names: [.short("v"), .long("verbose")], help: "Enable verbose logging") var verbose: Bool = false @@ -284,6 +433,18 @@ struct ConfigSetAPIKeyOptions: CommanderParsable { @Flag(name: .long("no-enable"), help: "Store the key without enabling the provider") var noEnable: Bool = false + + @Option(name: .long("label"), help: "Token-account label (z.ai team mode)") + var label: String? + + @Option(name: .long("usage-scope"), help: "Token-account usage scope (z.ai: team)") + var usageScope: String? + + @Option(name: .long("organization-id"), help: "z.ai BigModel organization ID for team usage") + var organizationId: String? + + @Option(name: .long("workspace-id"), help: "z.ai BigModel project ID for team usage") + var workspaceId: String? } struct ConfigProviderToggleOptions: CommanderParsable { diff --git a/Sources/CodexBarCLI/CLICookieCommand.swift b/Sources/CodexBarCLI/CLICookieCommand.swift new file mode 100644 index 0000000000..26d14a6f7b --- /dev/null +++ b/Sources/CodexBarCLI/CLICookieCommand.swift @@ -0,0 +1,353 @@ +import CodexBarCore +import Commander +import Foundation + +extension CodexBarCLI { + static func runCookieRefresh(_ values: ParsedValues) async { + let output = CLIOutputPreferences.from(values: values) + let rawProvider = values.options["provider"]?.last + let refreshAll = values.flags.contains("all") + + guard (rawProvider != nil) != refreshAll else { + Self.exit( + code: .failure, + message: "Specify exactly one of --provider or --all.", + output: output, + kind: .args) + } + + #if os(macOS) + let targets: [ProviderDescriptor] + do { + targets = try Self.cookieRefreshTargets(rawProvider: rawProvider, refreshAll: refreshAll) + } catch { + Self.exit( + code: .failure, + message: error.localizedDescription, + output: output, + kind: .args) + } + + let config = Self.loadConfig(output: output) + let tokenContext: TokenAccountCLIContext + do { + tokenContext = try TokenAccountCLIContext( + selection: TokenAccountCLISelection(label: nil, index: nil, allAccounts: false), + config: config, + verbose: values.flags.contains("verbose")) + } catch { + Self.exit( + code: .failure, + message: "Could not prepare provider settings.", + output: output, + kind: .config) + } + + let browserDetection = BrowserDetection() + let allowKeychainPrompt = values.flags.contains("allowKeychainPrompt") + let results = await Self.performCookieRefreshes( + targets: targets, + allowKeychainPrompt: allowKeychainPrompt, + preflight: { descriptor in + Self.cookieRefreshSkipResult(descriptor: descriptor, config: config) + }, + operation: { descriptor in + await Self.refreshCookie( + descriptor: descriptor, + config: config, + tokenContext: tokenContext, + browserDetection: browserDetection) + }) + + Self.printCookieRefreshResults(results, output: output) + let hasErrors = results.contains(where: \.isFailure) + Self.exit(code: hasErrors ? .failure : .success, output: output, kind: .runtime) + #else + Self.exit( + code: .failure, + message: "Cookie refresh is only supported on macOS.", + output: output, + kind: .args) + #endif + } + + #if os(macOS) + static func cookieRefreshTargets( + rawProvider: String?, + refreshAll: Bool, + descriptors: [ProviderDescriptor] = ProviderDescriptorRegistry.all) throws -> [ProviderDescriptor] + { + let supported = descriptors.filter { descriptor in + descriptor.metadata.browserCookieOrder != nil && descriptor.fetchPlan.sourceModes.contains(.web) + } + if refreshAll { + guard !supported.isEmpty else { throw CookieRefreshCommandError.noSupportedProviders } + return supported + } + + guard let rawProvider, + let provider = ProviderDescriptorRegistry.cliNameMap[rawProvider.lowercased()] + else { + throw CookieRefreshCommandError.unknownProvider(rawProvider ?? "") + } + guard let descriptor = supported.first(where: { $0.id == provider }) else { + throw CookieRefreshCommandError.unsupportedProvider(rawProvider) + } + return [descriptor] + } + + static func performCookieRefreshes( + targets: [ProviderDescriptor], + allowKeychainPrompt: Bool, + preflight: (ProviderDescriptor) -> CookieRefreshResult? = { _ in nil }, + operation: (ProviderDescriptor) async -> CookieRefreshResult) async -> [CookieRefreshResult] + { + var results: [CookieRefreshResult] = [] + results.reserveCapacity(targets.count) + for descriptor in targets { + if let result = preflight(descriptor) { + results.append(result) + continue + } + + let browsers = descriptor.metadata.browserCookieOrder ?? [] + let needsAcknowledgement = BrowserCookieAccessGate.requiresKeychainPromptAcknowledgement(for: browsers) + guard !needsAcknowledgement || allowKeychainPrompt else { + results.append(CookieRefreshResult( + provider: descriptor.cli.name, + status: .blocked, + message: Self.keychainPromptAcknowledgementHint)) + continue + } + + let result: CookieRefreshResult = if allowKeychainPrompt { + await BrowserCookieAccessGate.withExplicitRetry { + await ProviderInteractionContext.$current.withValue(.userInitiated) { + await operation(descriptor) + } + } + } else { + await ProviderInteractionContext.$current.withValue(.userInitiated) { + await operation(descriptor) + } + } + results.append(result) + } + return results + } + + static func cookieRefreshFailure(provider: UsageProvider, error _: any Error) -> CookieRefreshResult { + let descriptor = ProviderDescriptorRegistry.descriptor(for: provider) + let promptCapableBrowsers = (descriptor.metadata.browserCookieOrder ?? []) + .filter { BrowserCookieAccessGate.requiresKeychainPromptAcknowledgement(for: [$0]) } + if let browser = promptCapableBrowsers.first, KeychainAccessGate.isDisabled { + return CookieRefreshResult( + provider: descriptor.cli.name, + status: .failed, + message: "\(browser.displayName) cookie decryption is disabled in CodexBar; " + + "enable Keychain access and refresh.") + } + if let browser = promptCapableBrowsers.first(where: { BrowserCookieAccessGate.hasActiveDenial(for: $0) }) { + return CookieRefreshResult( + provider: descriptor.cli.name, + status: .failed, + message: "\(browser.displayName) cookie decryption was declined in Keychain; " + + "retry with --allow-keychain-prompt.") + } + return CookieRefreshResult( + provider: descriptor.cli.name, + status: .failed, + message: self.browserCookieAccessFailureHint) + } + + static func cookieRefreshText(_ results: [CookieRefreshResult]) -> String { + results.map { result in + let marker = switch result.status { + case .refreshed: "✅" + case .skipped: "↷" + case .blocked: "⚠️" + case .failed: "❌" + } + return "\(result.provider): \(marker) \(result.message)" + }.joined(separator: "\n") + } + + private static let keychainPromptAcknowledgementHint = + "Browser cookie decryption may open a macOS Keychain prompt. " + + "Retry interactively with --allow-keychain-prompt to acknowledge it." + + private static let browserCookieAccessFailureHint = + "No browser session cookie was refreshed. Sign in in a configured browser and retry. " + + "If Keychain access was declined, CodexBar keeps the six-hour denial cooldown; " + + "use --allow-keychain-prompt only for an explicit interactive retry." + + private static func refreshCookie( + descriptor: ProviderDescriptor, + config: CodexBarConfig, + tokenContext: TokenAccountCLIContext, + browserDetection: BrowserDetection) async -> CookieRefreshResult + { + let provider = descriptor.id + if let result = Self.cookieRefreshSkipResult(descriptor: descriptor, config: config) { + return result + } + + return await Self.withCookieRefreshCacheSuppressed(provider: provider, providerName: descriptor.cli.name) { + let environment = tokenContext.environment( + base: ProcessInfo.processInfo.environment, + provider: provider, + account: nil) + let context = ProviderFetchContext( + runtime: .cli, + sourceMode: .web, + includeCredits: false, + includeOptionalUsage: false, + webTimeout: 60, + webDebugDumpHTML: false, + verbose: false, + env: environment, + settings: tokenContext.settingsSnapshot(for: provider, account: nil), + fetcher: tokenContext.fetcher(base: UsageFetcher(), provider: provider, env: environment), + claudeFetcher: ClaudeUsageFetcher(browserDetection: browserDetection), + browserDetection: browserDetection) + let outcome = await descriptor.fetchOutcome(context: context) + return switch outcome.result { + case .success: + CookieRefreshResult( + provider: descriptor.cli.name, + status: .refreshed, + message: "Browser cookie refreshed.") + case let .failure(error): + Self.cookieRefreshFailure(provider: provider, error: error) + } + } + } + + static func withCookieRefreshCacheSuppressed( + provider: UsageProvider, + providerName: String, + operation: () async -> CookieRefreshResult) async -> CookieRefreshResult + { + guard let gate = CookieHeaderCache.beginRefreshReadSuppression(provider: provider) else { + return CookieRefreshResult( + provider: providerName, + status: .failed, + message: "Cookie cache could not be read safely; no browser import was attempted.") + } + defer { CookieHeaderCache.endRefreshReadSuppression(gate) } + let result = await operation() + guard !result.isFailure else { return result } + + let commit = CookieHeaderCache.commitRefreshReadSuppression(gate) + guard commit.stagedCount > 0, + commit.committedCount == commit.stagedCount, + commit.failedCount == 0 + else { + return CookieRefreshResult( + provider: providerName, + status: .failed, + message: "Browser cookie validation succeeded, but the refreshed session could not be saved.") + } + return result + } + + private static func cookieRefreshSkipResult( + descriptor: ProviderDescriptor, + config: CodexBarConfig) -> CookieRefreshResult? + { + switch config.providerConfig(for: descriptor.id)?.cookieSource ?? .auto { + case .manual: + CookieRefreshResult( + provider: descriptor.cli.name, + status: .skipped, + message: "Browser refresh skipped because this provider uses a manual cookie.") + case .off: + CookieRefreshResult( + provider: descriptor.cli.name, + status: .skipped, + message: "Browser refresh skipped because browser cookies are disabled for this provider.") + case .auto: + nil + } + } + + private static func printCookieRefreshResults( + _ results: [CookieRefreshResult], + output: CLIOutputPreferences) + { + switch output.format { + case .text: + if !output.jsonOnly { + print(self.cookieRefreshText(results)) + } + case .json: + printJSON(results, pretty: output.pretty) + } + } + #endif +} + +enum CookieRefreshStatus: String, Encodable { + case refreshed + case skipped + case blocked + case failed +} + +struct CookieRefreshResult: Encodable { + let provider: String + let status: CookieRefreshStatus + let message: String + + var isFailure: Bool { + self.status == .blocked || self.status == .failed + } +} + +private enum CookieRefreshCommandError: LocalizedError { + case noSupportedProviders + case unknownProvider(String) + case unsupportedProvider(String) + + var errorDescription: String? { + switch self { + case .noSupportedProviders: + "No providers support browser cookie refresh on this platform." + case let .unknownProvider(provider): + "Unknown provider: \(provider)" + case let .unsupportedProvider(provider): + "\(provider) does not support browser cookie refresh." + } + } +} + +struct CookieOptions: CommanderParsable { + @Flag(names: [.short("v"), .long("verbose")], help: "Enable verbose logging") + var verbose: Bool = false + + @Flag(name: .long("json-output"), help: "Emit machine-readable logs") + var jsonOutput: Bool = false + + @Flag(name: .long("json"), help: "Output as JSON") + var jsonShortcut: Bool = false + + @Flag(name: .long("json-only"), help: "Output as JSON only (no text)") + var jsonOnly: Bool = false + + @Flag(name: .long("pretty"), help: "Pretty-print JSON output") + var pretty: Bool = false + + @Option(name: .long("format"), help: "Output format: text | json") + var format: OutputFormat? + + @Flag(name: .long("all"), help: "Refresh every browser-cookie provider") + var all: Bool = false + + @Option(name: .long("provider"), help: "Refresh a specific browser-cookie provider") + var provider: String? + + @Flag( + name: .long("allow-keychain-prompt"), + help: "Acknowledge that Chromium cookie decryption may open a macOS Keychain prompt") + var allowKeychainPrompt: Bool = false +} diff --git a/Sources/CodexBarCLI/CLICostCommand.swift b/Sources/CodexBarCLI/CLICostCommand.swift index 7f63b24129..0497db6ea7 100644 --- a/Sources/CodexBarCLI/CLICostCommand.swift +++ b/Sources/CodexBarCLI/CLICostCommand.swift @@ -3,7 +3,15 @@ import Commander import Foundation extension CodexBarCLI { - private static let costSupportedProviders: Set = [.claude, .codex] + private static let costSupportedProviders: Set = { + #if os(macOS) + [.claude, .codex, .cursor] + #else + // Cursor cost relies on the macOS-only dashboard fetch path; `supportsTokenSnapshot(.cursor)` + // is false elsewhere, so don't advertise Cursor cost where it can only fail. + [.claude, .codex] + #endif + }() static func runCost(_ values: ParsedValues) async { let output = CLIOutputPreferences.from(values: values) @@ -23,7 +31,7 @@ extension CodexBarCLI { guard !providers.isEmpty else { Self.exit( code: .failure, - message: "Error: cost is only supported for Claude and Codex.", + message: "Error: cost is only supported for \(Self.costSupportedProviderNames()).", output: output, kind: .args) } @@ -32,23 +40,65 @@ extension CodexBarCLI { let forceRefresh = values.flags.contains("refresh") let useColor = Self.shouldUseColor(noColor: values.flags.contains("noColor"), format: format) let historyDays = Self.decodeCostHistoryDays(from: values) + // Cursor cost reuses the same cookie-source policy as usage fetches: reject the fetch when the + // user set Cursor cookies to Off, and forward the Manual header so the dashboard request uses + // the configured session instead of auto-resolving a different one. + let cursorCookieSettings: ProviderSettingsSnapshot.CursorProviderSettings? + let cursorCookieSettingsError: Error? + do { + cursorCookieSettings = try Self.cursorCookieSettings(config: config, providers: providers) + cursorCookieSettingsError = nil + } catch { + cursorCookieSettings = nil + cursorCookieSettingsError = error + } + let groupBy = Self.decodeCostGroupBy(from: values) + if groupBy == .project { + let unsupportedProjectProviders = providers.filter { $0 != .codex } + if !unsupportedProjectProviders.isEmpty, !output.jsonOnly { + let names = unsupportedProjectProviders + .map { ProviderDescriptorRegistry.descriptor(for: $0).metadata.displayName } + .sorted() + .joined(separator: ", ") + Self.writeStderr("Skipping project grouping for providers without Codex project data: \(names)\n") + } + } let fetcher = CostUsageFetcher() var sections: [String] = [] var payload: [CostPayload] = [] var exitCode: ExitCode = .success - for provider in providers { + for provider in providers where groupBy != .project || provider == .codex || format == .json { + if let error = Self.cursorCostAvailabilityError( + provider, + settings: cursorCookieSettings, + resolutionError: cursorCookieSettingsError) + { + exitCode = Self.mapError(error) + if format == .json { + payload.append(Self.makeCostPayload(provider: provider, snapshot: nil, error: error)) + } else if !output.jsonOnly { + Self.writeStderr("Error: \(error.localizedDescription)\n") + } + continue + } do { - // Cost usage is local-only; it does not require web/CLI provider fetches. + // Claude/Codex cost comes from local logs; Cursor cost is fetched from its + // cookie-authenticated dashboard API via the shared session resolution. let snapshot = try await fetcher.loadTokenSnapshot( provider: provider, forceRefresh: forceRefresh, historyDays: historyDays, + cursorCookieHeaderOverride: Self.cursorCostHeaderOverride(provider, settings: cursorCookieSettings), refreshPricingInBackground: false) switch format { case .text: - sections.append(Self.renderCostText(provider: provider, snapshot: snapshot, useColor: useColor)) + sections.append(Self.renderCostText( + provider: provider, + snapshot: snapshot, + groupBy: groupBy, + useColor: useColor)) case .json: payload.append(Self.makeCostPayload(provider: provider, snapshot: snapshot, error: nil)) } @@ -76,13 +126,25 @@ extension CodexBarCLI { Self.exit(code: exitCode, output: output, kind: exitCode == .success ? .runtime : .provider) } + enum CostGroupBy: String { + case none + case project + } + static func renderCostText( provider: UsageProvider, snapshot: CostUsageTokenSnapshot, + groupBy: CostGroupBy = .none, useColor: Bool) -> String { let name = ProviderDescriptorRegistry.descriptor(for: provider).metadata.displayName - let header = Self.costHeaderLine("\(name) Cost (API-rate estimate)", useColor: useColor) + let title = provider == .codex + ? "\(name) API-equivalent estimate (not billed)" + : "\(name) Cost (API-rate estimate)" + let header = Self.costHeaderLine(title, useColor: useColor) + if groupBy == .project, provider == .codex { + return Self.renderProjectCostText(header: header, snapshot: snapshot) + } let todayCost = snapshot.sessionCostUSD .map { UsageFormatter.currencyString($0, currencyCode: snapshot.currencyCode) } ?? "—" @@ -98,8 +160,56 @@ extension CodexBarCLI { "\(historyLabel): \(monthCost) · \($0) tokens" } ?? "\(historyLabel): \(monthCost)" - let hintLine = UsageFormatter.costEstimateHint(provider: provider) - return [header, todayLine, monthLine, hintLine].joined(separator: "\n") + // Plan-metered spend over the same window (what Cursor actually deducts), shown + // alongside the API-rate estimate. Only providers like Cursor report it. + let meteredLine: String? = snapshot.meteredCostUSD.map { + let amount = UsageFormatter.currencyString($0, currencyCode: snapshot.currencyCode) + return "Cursor-metered: \(amount) (\(historyLabel.lowercased()))" + } + + let hintLine = Self.costEstimateHint(provider: provider) + return [header, todayLine, monthLine, meteredLine, hintLine] + .compactMap(\.self) + .joined(separator: "\n") + } + + private static func renderProjectCostText(header: String, snapshot: CostUsageTokenSnapshot) -> String { + let historyLabel = snapshot.historyLabel + ?? (snapshot.historyDays == 1 ? "Today" : "Last \(snapshot.historyDays) days") + var lines = [header, "Projects (\(historyLabel)):"] + guard !snapshot.projects.isEmpty else { + lines.append("—") + lines.append(Self.costEstimateHint(provider: .codex)) + return lines.joined(separator: "\n") + } + for project in snapshot.projects { + let cost = project.totalCostUSD + .map { UsageFormatter.currencyString($0, currencyCode: snapshot.currencyCode) } ?? "—" + let tokens = project.totalTokens.map { UsageFormatter.tokenCountString($0) } + let summary = tokens.map { "\(cost) · \($0) tokens" } ?? cost + lines.append("\(project.name): \(summary)") + if let path = project.path { + lines.append(" \(path)") + } + for source in project.sources { + let sourceCost = source.totalCostUSD + .map { UsageFormatter.currencyString($0, currencyCode: snapshot.currencyCode) } ?? "—" + let sourceTokens = source.totalTokens.map { UsageFormatter.tokenCountString($0) } + let sourceSummary = sourceTokens.map { "\(sourceCost) · \($0) tokens" } ?? sourceCost + lines.append(" - \(source.name): \(sourceSummary)") + if let path = source.path { + lines.append(" \(path)") + } + } + } + lines.append(Self.costEstimateHint(provider: .codex)) + return lines.joined(separator: "\n") + } + + private static func costEstimateHint(provider: UsageProvider) -> String { + provider == .codex + ? "Not a subscription bill or plan value · local usage × public API prices" + : UsageFormatter.costEstimateHint(provider: provider) } private static func costHeaderLine(_ header: String, useColor: Bool) -> String { @@ -116,27 +226,31 @@ extension CodexBarCLI { snapshot: CostUsageTokenSnapshot?, error: Error?) -> CostPayload { - let daily = snapshot?.daily.map { entry in - CostDailyEntryPayload( - date: entry.date, - inputTokens: entry.inputTokens, - outputTokens: entry.outputTokens, - cacheReadTokens: entry.cacheReadTokens, - cacheCreationTokens: entry.cacheCreationTokens, - totalTokens: entry.totalTokens, - costUSD: entry.costUSD, - modelsUsed: entry.modelsUsed, - modelBreakdowns: entry.modelBreakdowns?.map { breakdown in - CostModelBreakdownPayload( - modelName: breakdown.modelName, - costUSD: breakdown.costUSD, - totalTokens: breakdown.totalTokens) - }) - } ?? [] + let daily = snapshot?.daily.map(Self.costDailyPayload(from:)) ?? [] + let projects = provider == .codex + ? snapshot?.projects.map { project in + CostProjectPayload( + name: project.name, + path: project.path, + totalTokens: project.totalTokens, + totalCostUSD: project.totalCostUSD, + daily: project.daily.map(Self.costDailyPayload(from:)), + modelBreakdowns: project.modelBreakdowns?.map(Self.costModelBreakdownPayload(from:)), + sources: project.sources.map { source in + CostProjectSourcePayload( + name: source.name, + path: source.path, + totalTokens: source.totalTokens, + totalCostUSD: source.totalCostUSD, + daily: source.daily.map(Self.costDailyPayload(from:)), + modelBreakdowns: source.modelBreakdowns?.map(Self.costModelBreakdownPayload(from:))) + }) + } ?? [] + : [] return CostPayload( provider: provider.rawValue, - source: "local", + source: provider == .cursor ? "web" : "local", updatedAt: snapshot?.updatedAt ?? (error == nil ? nil : Date()), currencyCode: snapshot?.currencyCode, sessionTokens: snapshot?.sessionTokens, @@ -144,11 +258,35 @@ extension CodexBarCLI { historyDays: snapshot?.historyDays, last30DaysTokens: snapshot?.last30DaysTokens, last30DaysCostUSD: snapshot?.last30DaysCostUSD, + meteredCostUSD: snapshot?.meteredCostUSD, daily: daily, + projects: projects, totals: snapshot.flatMap(Self.costTotals(from:)), error: error.map { Self.makeErrorPayload($0) }) } + private static func costDailyPayload(from entry: CostUsageDailyReport.Entry) -> CostDailyEntryPayload { + CostDailyEntryPayload( + date: entry.date, + inputTokens: entry.inputTokens, + outputTokens: entry.outputTokens, + cacheReadTokens: entry.cacheReadTokens, + cacheCreationTokens: entry.cacheCreationTokens, + totalTokens: entry.totalTokens, + costUSD: entry.costUSD, + modelsUsed: entry.modelsUsed, + modelBreakdowns: entry.modelBreakdowns?.map(self.costModelBreakdownPayload(from:))) + } + + private static func costModelBreakdownPayload( + from breakdown: CostUsageDailyReport.ModelBreakdown) -> CostModelBreakdownPayload + { + CostModelBreakdownPayload( + modelName: breakdown.modelName, + costUSD: breakdown.costUSD, + totalTokens: breakdown.totalTokens) + } + private static func costTotals(from snapshot: CostUsageTokenSnapshot) -> CostTotalsPayload? { let entries = snapshot.daily guard !entries.isEmpty else { @@ -218,6 +356,79 @@ extension CodexBarCLI { else { return 30 } return max(1, min(365, parsed)) } + + private static func decodeCostGroupBy(from values: ParsedValues) -> CostGroupBy { + guard let raw = values.options["groupBy"]?.last?.trimmingCharacters(in: .whitespacesAndNewlines), + !raw.isEmpty + else { return .none } + return CostGroupBy(rawValue: raw.lowercased()) ?? .none + } + + /// Human-readable list of providers that support a cost report, used by both `cost` and serve. + static func costSupportedProviderNames() -> String { + self.costSupportedProviders + .map { ProviderDescriptorRegistry.descriptor(for: $0).metadata.displayName } + .sorted() + .joined(separator: ", ") + } + + /// Resolve the configured Cursor cookie settings (source + manual header) the same way the CLI + /// usage path does, so Cursor cost honors Off/Manual instead of always auto-resolving a session. + /// Shared by `cost` and the serve `/cost` route. + static func cursorCookieSettings( + config: CodexBarConfig, + providers: [UsageProvider]) throws -> ProviderSettingsSnapshot.CursorProviderSettings? + { + guard providers.contains(.cursor) else { return nil } + let selection = TokenAccountCLISelection(label: nil, index: nil, allAccounts: false) + let context = try TokenAccountCLIContext(selection: selection, config: config, verbose: false) + let account = try context.resolvedAccounts(for: .cursor).first + return context.settingsSnapshot(for: .cursor, account: account)?.cursor + } + + /// Return the actionable error for a Cursor cost fetch disabled by cookie-source policy. + static func cursorCostAvailabilityError( + _ provider: UsageProvider, + settings: ProviderSettingsSnapshot.CursorProviderSettings?, + resolutionError: Error? = nil) -> Error? + { + guard provider == .cursor else { return nil } + if let resolutionError { + return resolutionError + } + guard let settings else { return nil } + switch settings.cookieSource { + case .off: + return CursorCostAvailabilityError.cookieSourceOff + case .manual where CookieHeaderNormalizer.normalize(settings.manualCookieHeader) == nil: + return CursorCostAvailabilityError.manualCookieMissing + default: + return nil + } + } + + /// Manual cookie header to forward for a Cursor cost fetch, or nil for auto/non-cursor sources. + static func cursorCostHeaderOverride( + _ provider: UsageProvider, + settings: ProviderSettingsSnapshot.CursorProviderSettings?) -> String? + { + guard provider == .cursor, settings?.cookieSource == .manual else { return nil } + return CookieHeaderNormalizer.normalize(settings?.manualCookieHeader) + } +} + +enum CursorCostAvailabilityError: LocalizedError { + case cookieSourceOff + case manualCookieMissing + + var errorDescription: String? { + switch self { + case .cookieSourceOff: + "Cursor cost is unavailable because the Cursor cookie source is set to Off." + case .manualCookieMissing: + "Cursor cost requires a non-empty Manual cookie header." + } + } } struct CostOptions: CommanderParsable { @@ -255,9 +466,12 @@ struct CostOptions: CommanderParsable { @Option(name: .long("days"), help: "Cost history window in days (1...365)") var days: Int? + + @Option(name: .long("group-by"), help: "Group text output by: project") + var groupBy: String? } -struct CostPayload: Encodable { +struct CostPayload: Encodable, Sendable { let provider: String let source: String let updatedAt: Date? @@ -267,7 +481,9 @@ struct CostPayload: Encodable { let historyDays: Int? let last30DaysTokens: Int? let last30DaysCostUSD: Double? + let meteredCostUSD: Double? let daily: [CostDailyEntryPayload] + let projects: [CostProjectPayload] let totals: CostTotalsPayload? let error: ProviderErrorPayload? @@ -281,7 +497,9 @@ struct CostPayload: Encodable { historyDays: Int?, last30DaysTokens: Int?, last30DaysCostUSD: Double?, + meteredCostUSD: Double? = nil, daily: [CostDailyEntryPayload], + projects: [CostProjectPayload] = [], totals: CostTotalsPayload?, error: ProviderErrorPayload?) { @@ -294,13 +512,15 @@ struct CostPayload: Encodable { self.historyDays = historyDays self.last30DaysTokens = last30DaysTokens self.last30DaysCostUSD = last30DaysCostUSD + self.meteredCostUSD = meteredCostUSD self.daily = daily + self.projects = projects self.totals = totals self.error = error } } -struct CostDailyEntryPayload: Encodable { +struct CostDailyEntryPayload: Encodable, Sendable { let date: String let inputTokens: Int? let outputTokens: Int? @@ -324,7 +544,7 @@ struct CostDailyEntryPayload: Encodable { } } -struct CostModelBreakdownPayload: Encodable { +struct CostModelBreakdownPayload: Encodable, Sendable { let modelName: String let costUSD: Double? let totalTokens: Int? @@ -336,7 +556,63 @@ struct CostModelBreakdownPayload: Encodable { } } -struct CostTotalsPayload: Encodable { +struct CostProjectPayload: Encodable, Sendable { + let name: String + let path: String? + let totalTokens: Int? + let totalCostUSD: Double? + let daily: [CostDailyEntryPayload] + let modelBreakdowns: [CostModelBreakdownPayload]? + let sources: [CostProjectSourcePayload] + + private enum CodingKeys: String, CodingKey { + case name + case path + case totalTokens + case totalCostUSD = "totalCost" + case daily + case modelBreakdowns + case sources + } + + init( + name: String, + path: String?, + totalTokens: Int?, + totalCostUSD: Double?, + daily: [CostDailyEntryPayload], + modelBreakdowns: [CostModelBreakdownPayload]?, + sources: [CostProjectSourcePayload] = []) + { + self.name = name + self.path = path + self.totalTokens = totalTokens + self.totalCostUSD = totalCostUSD + self.daily = daily + self.modelBreakdowns = modelBreakdowns + self.sources = sources + } +} + +struct CostProjectSourcePayload: Encodable, Sendable { + let name: String + let path: String? + let totalTokens: Int? + let totalCostUSD: Double? + let daily: [CostDailyEntryPayload] + let modelBreakdowns: [CostModelBreakdownPayload]? + + private enum CodingKeys: String, CodingKey { + case name + case path + case totalTokens + case totalCostUSD = "totalCost" + case daily + case modelBreakdowns + } +} + +struct CostTotalsPayload: Encodable, Sendable { let totalInputTokens: Int? let totalOutputTokens: Int? let cacheReadTokens: Int? diff --git a/Sources/CodexBarCLI/CLIDiagnoseCommand.swift b/Sources/CodexBarCLI/CLIDiagnoseCommand.swift index 9725f2d10d..26a0e60984 100644 --- a/Sources/CodexBarCLI/CLIDiagnoseCommand.swift +++ b/Sources/CodexBarCLI/CLIDiagnoseCommand.swift @@ -33,6 +33,7 @@ extension CodexBarCLI { let providers = providerSelection.asList let pretty = values.flags.contains("pretty") let verbose = values.flags.contains("verbose") + let outputPath = values.options["output"]?.last let browserDetection = BrowserDetection() let baseFetcher = UsageFetcher() @@ -72,7 +73,11 @@ extension CodexBarCLI { } var jsonString = String(data: data, encoding: .utf8) ?? "{}" jsonString = LogRedactor.redact(jsonString) - print(jsonString) + if let outputPath, !outputPath.isEmpty { + try Self.writeDiagnosticExport(jsonString, to: outputPath) + } else { + print(jsonString) + } } catch { Self.exit( code: .failure, @@ -83,6 +88,17 @@ extension CodexBarCLI { Self.exit(code: .success, output: output, kind: .runtime) } + + static func writeDiagnosticExport(_ jsonString: String, to path: String) throws { + let url = URL(fileURLWithPath: path) + let parent = url.deletingLastPathComponent() + if !parent.path.isEmpty { + try FileManager.default.createDirectory( + at: parent, + withIntermediateDirectories: true) + } + try jsonString.write(to: url, atomically: true, encoding: .utf8) + } } extension CodexBarCLI { @@ -138,7 +154,8 @@ extension CodexBarCLI { account: account, config: tokenContext.config.providerConfig(for: provider), environment: env, - settings: settings))) + settings: settings), + appVersion: Self.currentVersion())) } static func diagnosticAuthSummary( @@ -202,14 +219,24 @@ extension CodexBarCLI { BedrockSettingsReader.hasCredentials(environment: environment) case .claude: ClaudeAdminAPISettingsReader.apiKey(environment: environment) != nil + case .clinepass: + ClinePassSettingsReader.apiKey(environment: environment) != nil case .codebuff: CodebuffSettingsReader.apiKey(environment: environment) != nil + case .chutes: + ChutesSettingsReader.apiKey(environment: environment) != nil + case .zenmux: + ZenMuxSettingsReader.managementAPIKey(environment: environment) != nil + case .aiand: + AiAndSettingsReader.apiKey(environment: environment) != nil case .crof: CrofSettingsReader.apiKey(environment: environment) != nil case .deepgram: DeepgramSettingsReader.apiKey(environment: environment) != nil case .deepseek: DeepSeekSettingsReader.apiKey(environment: environment) != nil + case .deepinfra: + DeepInfraSettingsReader.apiKey(environment: environment) != nil case .doubao: DoubaoSettingsReader.apiKey(environment: environment) != nil case .elevenlabs: @@ -218,6 +245,10 @@ extension CodexBarCLI { GroqSettingsReader.apiKey(environment: environment) != nil case .kilo: KiloSettingsReader.apiKey(environment: environment) != nil + case .factory: + FactorySettingsReader.apiKey(environment: environment) != nil + case .neuralwatt: + NeuralWattSettingsReader.apiKey(environment: environment) != nil default: false } @@ -228,10 +259,14 @@ extension CodexBarCLI { environment: [String: String]) -> Bool { switch provider { - case .kimik2: - KimiK2SettingsReader.apiKey(environment: environment) != nil + case .kimi: + KimiSettingsReader.apiKey(environment: environment) != nil case .llmproxy: LLMProxySettingsReader.apiKey(environment: environment) != nil + case .clawrouter: + ClawRouterSettingsReader.apiKey(environment: environment) != nil + case .sub2api: + Sub2APISettingsReader.apiKey(environment: environment) != nil case .moonshot: MoonshotSettingsReader.apiKey(environment: environment) != nil case .ollama: @@ -248,6 +283,8 @@ extension CodexBarCLI { VeniceSettingsReader.apiKey(environment: environment) != nil case .warp: WarpSettingsReader.apiKey(environment: environment) != nil + case .xai: + XAISettingsReader.apiKey(environment: environment) != nil case .zai: ZaiSettingsReader.apiToken(environment: environment) != nil default: @@ -262,6 +299,8 @@ extension CodexBarCLI { switch provider { case .alibabatokenplan: AlibabaTokenPlanSettingsReader.cookieHeader(environment: environment) != nil + case .qwencloud: + QwenCloudSettingsReader.cookieHeader(environment: environment) != nil case .kimi: KimiSettingsReader.authToken(environment: environment) != nil case .manus: diff --git a/Sources/CodexBarCLI/CLIEntry.swift b/Sources/CodexBarCLI/CLIEntry.swift index 35eb55b2be..a24e4e6e46 100644 --- a/Sources/CodexBarCLI/CLIEntry.swift +++ b/Sources/CodexBarCLI/CLIEntry.swift @@ -1,9 +1,14 @@ import CodexBarCore import Commander +#if os(Linux) +import CoreFoundation +#endif #if canImport(Darwin) import Darwin -#else +#elseif canImport(Glibc) import Glibc +#elseif canImport(Musl) +import Musl #endif import Foundation #if canImport(FoundationNetworking) @@ -13,6 +18,8 @@ import FoundationNetworking @main enum CodexBarCLI { static func main() async { + self.configureLinuxTimeZoneIfNeeded() + let rawArgv = Array(CommandLine.arguments.dropFirst()) let argv = Self.effectiveArgv(rawArgv) let outputPreferences = CLIOutputPreferences.from(argv: argv) @@ -32,28 +39,32 @@ enum CodexBarCLI { let invocation = try program.resolve(argv: argv) Self.bootstrapLogging(path: invocation.path, values: invocation.parsedValues) switch invocation.path { - case ["usage"]: - await self.runUsage(invocation.parsedValues) + case ["cards"], ["usage"]: + await self.runUsageDisplay(path: invocation.path, values: invocation.parsedValues) case ["cost"]: await self.runCost(invocation.parsedValues) + case ["sessions", "list"]: + await self.runSessions(invocation.parsedValues) + case ["sessions", "focus"]: + await self.runSessionsFocus(invocation.parsedValues) case ["serve"]: await self.runServe(invocation.parsedValues) - case ["config", "validate"]: - self.runConfigValidate(invocation.parsedValues) - case ["config", "dump"]: - self.runConfigDump(invocation.parsedValues) - case ["config", "providers"]: - self.runConfigProviders(invocation.parsedValues) - case ["config", "enable"]: - self.runConfigSetProviderEnabled(invocation.parsedValues, enabled: true) - case ["config", "disable"]: - self.runConfigSetProviderEnabled(invocation.parsedValues, enabled: false) - case ["config", "set-api-key"]: - self.runConfigSetAPIKey(invocation.parsedValues) + case let path where path.first == "config": + self.runConfig(path: path, values: invocation.parsedValues) + case let path where path.first == "hooks": + await self.runHooks(path: path, values: invocation.parsedValues) case ["cache", "clear"]: self.runCacheClear(invocation.parsedValues) + case ["cookie", "refresh"]: + await self.runCookieRefreshWithTermination(invocation.parsedValues) case ["diagnose"]: + let signalMonitor = CLITerminationSignalMonitor { signalNumber in + CLITerminationSignalMonitor.terminateActiveHelpersAndReraise(signalNumber) + } + defer { signalMonitor.cancel() } await self.runDiagnose(invocation.parsedValues) + case ["guard"]: + await self.runGuard(invocation.parsedValues) default: Self.exit( code: .failure, @@ -62,36 +73,93 @@ enum CodexBarCLI { kind: .args) } } catch let error as CommanderProgramError { - Self.exit(code: .failure, message: error.description, output: outputPreferences, kind: .args) + let exitCode: ExitCode = argv.first == "guard" ? .usage : .failure + Self.exit(code: exitCode, message: error.description, output: outputPreferences, kind: .args) } catch { Self.exit(code: .failure, message: error.localizedDescription, output: outputPreferences, kind: .runtime) } } + private static func runUsageDisplay(path: [String], values: ParsedValues) async { + let signalMonitor = CLITerminationSignalMonitor { signalNumber in + CLITerminationSignalMonitor.terminateActiveHelpersAndReraise(signalNumber) + } + defer { signalMonitor.cancel() } + switch path { + case ["cards"]: + await self.runCards(values) + default: + await self.runUsage(values) + } + } + + private static func runCookieRefreshWithTermination(_ values: ParsedValues) async { + let signalMonitor = CLITerminationSignalMonitor { signalNumber in + CLITerminationSignalMonitor.terminateActiveHelpersAndReraise(signalNumber) + } + defer { signalMonitor.cancel() } + await self.runCookieRefresh(values) + } + private static func commandDescriptors() -> [CommandDescriptor] { + let cardsSignature = CommandSignature.describe(CardsOptions()) let usageSignature = CommandSignature.describe(UsageOptions()) let costSignature = CommandSignature.describe(CostOptions()) + let sessionsSignature = CommandSignature.describe(SessionsOptions()) + let sessionsFocusSignature = CommandSignature.describe(SessionsFocusOptions()) let serveSignature = CommandSignature.describe(ServeOptions()) let configSignature = CommandSignature.describe(ConfigOptions()) + let configDumpSignature = CommandSignature.describe(ConfigDumpOptions()) let configProviderToggleSignature = CommandSignature.describe(ConfigProviderToggleOptions()) let configSetAPIKeySignature = CommandSignature.describe(ConfigSetAPIKeyOptions()) let cacheSignature = CommandSignature.describe(CacheOptions()) let diagnoseSignature = CommandSignature.describe(DiagnoseOptions()) + let hooksSignature = CommandSignature.describe(HooksOptions()) + let hooksTestSignature = CommandSignature.describe(HooksTestOptions()) + let guardSignature = CommandSignature.describe(GuardOptions()) return [ + CommandDescriptor( + name: "cards", + abstract: "Print usage as a terminal card grid", + discussion: nil, + signature: cardsSignature), CommandDescriptor( name: "usage", abstract: "Print usage as text or JSON", discussion: nil, signature: usageSignature), + CommandDescriptor( + name: "guard", + abstract: "Exit non-zero when a provider lacks quota headroom (for gating scripts)", + discussion: nil, + signature: guardSignature), CommandDescriptor( name: "cost", abstract: "Print local cost usage as text or JSON", discussion: nil, signature: costSignature), + CommandDescriptor( + name: "sessions", + abstract: "List live Codex and Claude Code sessions", + discussion: nil, + signature: CommandSignature(), + subcommands: [ + CommandDescriptor( + name: "list", + abstract: "List live Codex and Claude Code sessions", + discussion: nil, + signature: sessionsSignature), + CommandDescriptor( + name: "focus", + abstract: "Focus the window for a session", + discussion: nil, + signature: sessionsFocusSignature), + ], + defaultSubcommandName: "list"), CommandDescriptor( name: "serve", - abstract: "Serve usage and cost JSON over localhost HTTP", + abstract: "Serve usage, cost, and dashboard JSON over HTTP", discussion: nil, signature: serveSignature), CommandDescriptor( @@ -109,7 +177,7 @@ enum CodexBarCLI { name: "dump", abstract: "Print normalized config JSON", discussion: nil, - signature: configSignature), + signature: configDumpSignature), CommandDescriptor( name: "providers", abstract: "List provider enablement", @@ -132,6 +200,34 @@ enum CodexBarCLI { signature: configSetAPIKeySignature), ], defaultSubcommandName: "validate"), + CommandDescriptor( + name: "hooks", + abstract: "Run external commands on quota/provider events", + discussion: nil, + signature: CommandSignature(), + subcommands: [ + CommandDescriptor( + name: "list", + abstract: "List configured hooks", + discussion: nil, + signature: hooksSignature), + CommandDescriptor( + name: "enable", + abstract: "Enable hooks", + discussion: nil, + signature: hooksSignature), + CommandDescriptor( + name: "disable", + abstract: "Disable hooks", + discussion: nil, + signature: hooksSignature), + CommandDescriptor( + name: "test", + abstract: "Fire matching hooks for an event", + discussion: nil, + signature: hooksTestSignature), + ], + defaultSubcommandName: "list"), CommandDescriptor( name: "cache", abstract: "Cache management", @@ -145,6 +241,7 @@ enum CodexBarCLI { signature: cacheSignature), ], defaultSubcommandName: "clear"), + Self.cookieCommandDescriptor(), CommandDescriptor( name: "diagnose", abstract: "Run provider diagnostic and emit safe JSON export", @@ -153,8 +250,99 @@ enum CodexBarCLI { ] } + private static func cookieCommandDescriptor() -> CommandDescriptor { + CommandDescriptor( + name: "cookie", + abstract: "Cookie management", + discussion: nil, + signature: CommandSignature(), + subcommands: [ + CommandDescriptor( + name: "refresh", + abstract: "Re-import browser cookie for a provider", + discussion: "Clears the provider cookie cache and re-imports through its browser-backed " + + "web strategy. Prompt-capable browsers require --allow-keychain-prompt.", + signature: CommandSignature.describe(CookieOptions())), + ], + defaultSubcommandName: "refresh") + } + // MARK: - Helpers + static func linuxTimeZoneBootstrapIdentifier( + currentValue: String?, + localTimeReadable: Bool, + resolvedLocalTimePath: String?) -> String? + { + guard currentValue == nil, localTimeReadable else { return nil } + return self.linuxTimeZoneIdentifier(from: resolvedLocalTimePath) + } + + static func linuxTimeZoneIdentifier(from resolvedLocalTimePath: String?) -> String? { + guard let resolvedLocalTimePath, + let marker = resolvedLocalTimePath.range(of: "/zoneinfo/") + else { return nil } + + var identifier = String(resolvedLocalTimePath[marker.upperBound...]) + for prefix in ["posix/", "right/"] where identifier.hasPrefix(prefix) { + identifier.removeFirst(prefix.count) + } + + let components = identifier.split(separator: "/", omittingEmptySubsequences: false) + guard !components.isEmpty, + components.allSatisfy({ !$0.isEmpty && $0 != "." && $0 != ".." }) + else { return nil } + return identifier + } + + private static func configureLinuxTimeZoneIfNeeded() { + #if os(Linux) + let currentValue = getenv("TZ").map { String(cString: $0) } + let localTimeReadable = access("/etc/localtime", R_OK) == 0 + let resolvedLocalTimePath = self.resolvedLinuxLocalTimePath() + guard let identifier = self.linuxTimeZoneBootstrapIdentifier( + currentValue: currentValue, + localTimeReadable: localTimeReadable, + resolvedLocalTimePath: resolvedLocalTimePath) + else { return } + + guard self.primeCoreFoundationTimeZone(identifier: identifier, filePath: "/etc/localtime") else { return } + + // FoundationEssentials reads the IANA identifier while legacy formatters use the + // CoreFoundation cache primed above when /usr/share/zoneinfo is unavailable. + setenv("TZ", identifier, 0) + #endif + } + + static func primeCoreFoundationTimeZone(identifier: String, filePath: String) -> Bool { + #if os(Linux) + guard let data = try? Data(contentsOf: URL(fileURLWithPath: filePath)), !data.isEmpty else { return false } + guard let name = identifier.withCString({ + CFStringCreateWithCString(nil, $0, CFStringBuiltInEncodings.UTF8.rawValue) + }) else { return false } + guard let timeZoneData = data.withUnsafeBytes({ rawBuffer -> CFData? in + let bytes = rawBuffer.bindMemory(to: UInt8.self) + return CFDataCreate(nil, bytes.baseAddress, bytes.count) + }) else { return false } + return CFTimeZoneCreate(nil, name, timeZoneData) != nil + #else + return false + #endif + } + + private static func resolvedLinuxLocalTimePath() -> String? { + #if os(Linux) + var buffer = [CChar](repeating: 0, count: Int(PATH_MAX)) + guard realpath("/etc/localtime", &buffer) != nil else { return nil } + return buffer.withUnsafeBufferPointer { rawBuffer in + guard let baseAddress = rawBuffer.baseAddress else { return nil } + return String(cString: baseAddress) + } + #else + return nil + #endif + } + private static func bootstrapLogging(path: [String], values: ParsedValues) { CodexBarLog.bootstrapIfNeeded(self.loggingConfiguration(path: path, values: values)) } @@ -174,7 +362,9 @@ enum CodexBarCLI { static func effectiveArgv(_ argv: [String]) -> [String] { guard let first = argv.first else { return ["usage"] } - if first.hasPrefix("-") { return ["usage"] + argv } + if first.hasPrefix("-") { + return ["usage"] + argv + } return argv } } diff --git a/Sources/CodexBarCLI/CLIErrorReporting.swift b/Sources/CodexBarCLI/CLIErrorReporting.swift index 954e11174b..b52027c420 100644 --- a/Sources/CodexBarCLI/CLIErrorReporting.swift +++ b/Sources/CodexBarCLI/CLIErrorReporting.swift @@ -1,14 +1,14 @@ import CodexBarCore import Foundation -enum CLIErrorKind: String, Encodable { +enum CLIErrorKind: String, Encodable, Sendable { case args case config case provider case runtime } -struct ProviderErrorPayload: Encodable { +struct ProviderErrorPayload: Encodable, Sendable { let code: Int32 let message: String let kind: CLIErrorKind? @@ -49,6 +49,7 @@ extension CodexBarCLI { static func makeProviderErrorPayload( provider: UsageProvider, account: String?, + cacheAccountKey: String? = nil, source: String, status: ProviderStatusPayload?, error: Error, @@ -57,6 +58,7 @@ extension CodexBarCLI { ProviderPayload( provider: provider, account: account, + cacheAccountKey: cacheAccountKey, version: nil, source: source, status: status, diff --git a/Sources/CodexBarCLI/CLIExitCode.swift b/Sources/CodexBarCLI/CLIExitCode.swift index d658552d4f..654df628f7 100644 --- a/Sources/CodexBarCLI/CLIExitCode.swift +++ b/Sources/CodexBarCLI/CLIExitCode.swift @@ -4,6 +4,7 @@ enum ExitCode: Int32 { case binaryNotFound = 2 case parseError = 3 case timeout = 4 + case usage = 64 init(_ rawValue: Int) { self = ExitCode(rawValue: Int32(rawValue)) ?? .failure diff --git a/Sources/CodexBarCLI/CLIGuardCommand.swift b/Sources/CodexBarCLI/CLIGuardCommand.swift new file mode 100644 index 0000000000..87d1a7d51b --- /dev/null +++ b/Sources/CodexBarCLI/CLIGuardCommand.swift @@ -0,0 +1,400 @@ +import CodexBarCore +import Commander +import Foundation + +extension CodexBarCLI { + /// Window selected by the `guard` command: `session` maps to the primary + /// rate window, `weekly` maps to the secondary rate window. + enum GuardWindow: String { + case session + case weekly + + var payloadValue: String { + self.rawValue + } + } + + /// Pure gating outcome. Kept free of I/O so it is unit-testable off-network. + enum GuardDecision: String { + case ok + case blocked + case unknown + } + + enum GuardUnavailableReason: String, Sendable { + case accountResolution = "account-resolution" + case fetchFailed = "fetch-failed" + case timeout + case windowUnavailable = "window-unavailable" + } + + enum GuardFetchOutcome: Sendable { + case available(Double) + case unavailable(GuardUnavailableReason) + } + + struct GuardEvaluation: Sendable { + let decision: GuardDecision + let exitCode: Int32 + let remainingPercent: Double? + let unavailableReason: GuardUnavailableReason? + } + + /// Command-specific stable status codes. `69` is sysexits `EX_UNAVAILABLE`. + private enum GuardExitCode: Int32 { + case safe = 0 + case blocked = 1 + case unavailable = 69 + } + + /// Pure decision core for `codexbar guard`. + /// + /// - unavailable quota → `.unknown` (exit `0` when `failOpen`, else `69`). + /// - remaining quota at or above the threshold → `.ok` (exit `0`). + /// - otherwise → `.blocked` (exit `1`). + static func evaluateGuard( + outcome: GuardFetchOutcome, + minimumRemainingPercent: Double, + failOpen: Bool) -> GuardEvaluation + { + guard case let .available(remainingPercent) = outcome else { + guard case let .unavailable(reason) = outcome else { preconditionFailure("Unhandled guard outcome") } + return GuardEvaluation( + decision: .unknown, + exitCode: failOpen ? GuardExitCode.safe.rawValue : GuardExitCode.unavailable.rawValue, + remainingPercent: nil, + unavailableReason: reason) + } + if remainingPercent >= minimumRemainingPercent { + return GuardEvaluation( + decision: .ok, + exitCode: GuardExitCode.safe.rawValue, + remainingPercent: remainingPercent, + unavailableReason: nil) + } + return GuardEvaluation( + decision: .blocked, + exitCode: GuardExitCode.blocked.rawValue, + remainingPercent: remainingPercent, + unavailableReason: nil) + } + + /// Remaining headroom (`100 - usedPercent`) for a resolved rate window, or `nil` when the window + /// is absent or a synthetic placeholder. A synthetic window is a lane the provider did not + /// actually report (e.g. Claude with no live five-hour session), so it must not read as free + /// headroom and let the gate pass on a phantom metric. + static func guardRemainingHeadroom(for window: RateWindow?) -> Double? { + guard let window, !window.isSyntheticPlaceholder else { return nil } + return 100 - window.usedPercent + } + + static func runGuard(_ values: ParsedValues) async { + let output = CLIOutputPreferences.from(values: values) + let json = values.flags.contains("json") + let failOpen = values.flags.contains("failOpen") + let verbose = values.flags.contains("verbose") + + guard let window = Self.decodeGuardWindow(from: values) else { + Self.exitGuardArgumentError("--window must be session|weekly.", output: output) + } + + let minimumRemainingPercent: Double + switch Self.decodeGuardMinimumRemaining(from: values) { + case let .success(value): + minimumRemainingPercent = value + case .failure: + Self.exitGuardArgumentError( + "--min-remaining must be a finite percent between 0 and 100.", + output: output) + } + + let timeout: TimeInterval + switch Self.decodeGuardTimeout(from: values) { + case let .success(value): + timeout = value + case .failure: + Self.exitGuardArgumentError( + "--timeout must be a finite number of seconds from 0 through 86400.", + output: output) + } + + let provider: UsageProvider + switch Self.decodeGuardProvider(from: values) { + case let .success(value): + provider = value + case let .failure(error): + Self.exitGuardArgumentError(error.localizedDescription, output: output) + } + let config = Self.loadConfig(output: output) + + let outcome = await Self.runGuardFetch(timeout: timeout) { + await ProviderInteractionContext.$current.withValue(.background) { + await Self.guardFetchOutcome( + provider: provider, + window: window, + config: config, + verbose: verbose, + webTimeout: timeout > 0 ? timeout : 60) + } + } + if case .unavailable(.timeout) = outcome { + TTYCommandRunner.terminateActiveProcessesForAppShutdown() + } + + let evaluation = Self.evaluateGuard( + outcome: outcome, + minimumRemainingPercent: minimumRemainingPercent, + failOpen: failOpen) + + Self.emitGuardResult( + provider: provider, + window: window, + minimumRemainingPercent: minimumRemainingPercent, + evaluation: evaluation, + json: json, + pretty: output.pretty) + Self.platformExit(evaluation.exitCode) + } + + // MARK: - Argument decoding + + private static func exitGuardArgumentError(_ message: String, output: CLIOutputPreferences) -> Never { + self.exit(code: .usage, message: "Error: \(message)", output: output, kind: .args) + } + + static func decodeGuardWindow(from values: ParsedValues) -> GuardWindow? { + guard let raw = values.options["window"]?.last else { return .session } + return GuardWindow(rawValue: raw.lowercased()) + } + + static func guardProvider(rawOverride: String?) -> Result { + guard let rawOverride else { + return .failure(CLIArgumentError("guard requires --provider .")) + } + guard let selection = ProviderSelection(argument: rawOverride) else { + return .failure(CLIArgumentError("unknown provider '\(rawOverride)'.")) + } + guard selection.asList.count == 1, let provider = selection.asList.first else { + return .failure(CLIArgumentError("guard requires exactly one --provider.")) + } + return .success(provider) + } + + private static func decodeGuardProvider(from values: ParsedValues) -> Result { + self.guardProvider(rawOverride: values.options["provider"]?.last) + } + + static func decodeGuardMinimumRemaining(from values: ParsedValues) -> Result { + guard let raw = values.options["minRemaining"]?.last else { return .success(10) } + guard let value = Double(raw), value.isFinite, value >= 0, value <= 100 else { + return .failure(CLIArgumentError("--min-remaining must be a finite percent between 0 and 100.")) + } + return .success(value) + } + + static func decodeGuardTimeout(from values: ParsedValues) -> Result { + self.guardTimeout(raw: values.options["timeout"]?.last) + } + + static func guardTimeout(raw: String?) -> Result { + guard let raw else { return .success(60) } + guard let value = TimeInterval(raw), value.isFinite, value >= 0, value <= 86400 else { + return .failure(CLIArgumentError("--timeout must be a finite number of seconds from 0 through 86400.")) + } + return .success(value) + } + + // MARK: - Fetch + + static func runGuardFetch( + timeout: TimeInterval, + operation: @escaping @Sendable () async -> GuardFetchOutcome) async -> GuardFetchOutcome + { + let sourceTask = Task { + await operation() + } + guard timeout > 0 else { + return await (try? sourceTask.value) ?? .unavailable(.fetchFailed) + } + + let join = BoundedTaskJoin(sourceTask: sourceTask) + return switch await join.value(joinGrace: .seconds(timeout)) { + case let .value(outcome): outcome + case .failure: .unavailable(.fetchFailed) + case .timedOut: .unavailable(.timeout) + } + } + + private static func guardFetchOutcome( + provider: UsageProvider, + window: GuardWindow, + config: CodexBarConfig, + verbose: Bool, + webTimeout: TimeInterval) async -> GuardFetchOutcome + { + let tokenContext: TokenAccountCLIContext + do { + tokenContext = try TokenAccountCLIContext( + selection: TokenAccountCLISelection(label: nil, index: nil, allAccounts: false), + config: config, + verbose: verbose) + } catch { + return .unavailable(.accountResolution) + } + + // Resolve the configured token account the same way `usage` does, so token-only + // providers (e.g. Claude, z.ai, OpenAI) fetch their quota instead of returning unknown. + let account: ProviderTokenAccount? + do { + account = try tokenContext.resolvedAccounts(for: provider).first + } catch { + return .unavailable(.accountResolution) + } + + let browserDetection = BrowserDetection() + let fetcher = UsageFetcher() + let claudeFetcher = ClaudeUsageFetcher(browserDetection: browserDetection) + + let env = tokenContext.environment( + base: ProcessInfo.processInfo.environment, + provider: provider, + account: account) + let settings = tokenContext.settingsSnapshot(for: provider, account: account) + let baseSource = tokenContext.preferredSourceMode(for: provider) + let effectiveSourceMode = tokenContext.effectiveSourceMode( + base: baseSource, + provider: provider, + account: account) + + let fetchContext = ProviderFetchContext( + runtime: .cli, + sourceMode: effectiveSourceMode, + includeCredits: false, + webTimeout: webTimeout, + webDebugDumpHTML: false, + verbose: verbose, + env: env, + settings: settings, + fetcher: tokenContext.fetcher(base: fetcher, provider: provider, env: env), + claudeFetcher: claudeFetcher, + browserDetection: browserDetection, + // Guard is read-only: omit updater callbacks so refresh-dependent credentials fail unavailable. + selectedTokenAccountID: account?.id) + + let outcome = await Self.fetchProviderUsage(provider: provider, context: fetchContext) + if verbose { + Self.printFetchAttempts(provider: provider, attempts: outcome.attempts) + } + + switch outcome.result { + case let .success(result): + let usage = result.usage.scoped(to: provider) + let rateWindow = window == .session ? usage.primary : usage.secondary + guard let remaining = Self.guardRemainingHeadroom(for: rateWindow) else { + return .unavailable(.windowUnavailable) + } + return .available(remaining) + case .failure: + return .unavailable(.fetchFailed) + } + } + + // MARK: - Output + + private struct GuardResultPayload: Encodable { + let provider: String + let window: String + let remainingPercent: Double? + let minimumRemainingPercent: Double + let decision: String + let exitCode: Int32 + let unavailableReason: String? + + private enum CodingKeys: String, CodingKey { + case provider + case window + case remainingPercent + case minimumRemainingPercent + case decision + case exitCode + case unavailableReason + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(self.provider, forKey: .provider) + try container.encode(self.window, forKey: .window) + try container.encode(self.minimumRemainingPercent, forKey: .minimumRemainingPercent) + try container.encode(self.decision, forKey: .decision) + try container.encode(self.exitCode, forKey: .exitCode) + if let remainingPercent = self.remainingPercent { + try container.encode(remainingPercent, forKey: .remainingPercent) + } else { + try container.encodeNil(forKey: .remainingPercent) + } + if let unavailableReason = self.unavailableReason { + try container.encode(unavailableReason, forKey: .unavailableReason) + } else { + try container.encodeNil(forKey: .unavailableReason) + } + } + } + + // swiftlint:disable:next function_parameter_count + private static func emitGuardResult( + provider: UsageProvider, + window: GuardWindow, + minimumRemainingPercent: Double, + evaluation: GuardEvaluation, + json: Bool, + pretty: Bool) + { + if json { + let payload = GuardResultPayload( + provider: provider.rawValue, + window: window.payloadValue, + remainingPercent: evaluation.remainingPercent, + minimumRemainingPercent: minimumRemainingPercent, + decision: evaluation.decision.rawValue, + exitCode: evaluation.exitCode, + unavailableReason: evaluation.unavailableReason?.rawValue) + Self.printJSON(payload, pretty: pretty) + return + } + print(self.guardHumanLine( + provider: provider, + window: window, + remainingPercent: evaluation.remainingPercent, + minimumRemainingPercent: minimumRemainingPercent, + decision: evaluation.decision, + unavailableReason: evaluation.unavailableReason)) + } + + static func guardHumanLine( + provider: UsageProvider, + window: GuardWindow, + remainingPercent: Double?, + minimumRemainingPercent: Double, + decision: GuardDecision, + unavailableReason: GuardUnavailableReason? = nil) -> String + { + let remainingText = remainingPercent + .map { "\(Self.guardPercentString($0)) remaining" } ?? "unknown" + let verdict = switch decision { + case .ok: "OK" + case .blocked: "BLOCKED" + case .unknown: "UNKNOWN" + } + let reasonText = unavailableReason.map { "; \($0.rawValue)" } ?? "" + return "\(provider.rawValue) \(window.payloadValue): \(remainingText) — " + + "\(verdict) (minimum \(Self.guardPercentString(minimumRemainingPercent))\(reasonText))" + } + + private static func guardPercentString(_ value: Double) -> String { + let rounded = value.rounded() + if abs(value - rounded) < 0.05 { + return "\(Int(rounded))%" + } + return String(format: "%.1f%%", value) + } +} diff --git a/Sources/CodexBarCLI/CLIHelp.swift b/Sources/CodexBarCLI/CLIHelp.swift index 27e7055244..3eb27e8bca 100644 --- a/Sources/CodexBarCLI/CLIHelp.swift +++ b/Sources/CodexBarCLI/CLIHelp.swift @@ -2,6 +2,45 @@ import CodexBarCore import Foundation extension CodexBarCLI { + static func cardsHelp(version: String) -> String { + """ + CodexBar \(version) + + Usage: + codexbar cards [--json-output] [--log-level ] [-v|--verbose] + [--provider \(ProviderHelp.list)] + [--account \s*([^<]+?)\s*"#, + in: html) + } + + private static func parsePlanPrice(_ html: String) -> String? { + let pattern = #"]*data-slot="card-title"[^>]*>[\s\S]*?[^<]+\s*"# + + #"]*>\s*([^<]+?)\s*"# + return self.capture( + pattern: pattern, + in: html) + } + + /// The billing page always server-renders "Resets on " in UTC — the client only + /// corrects it to the viewer's local timezone after JS hydration, which this HTML-only + /// scraper never runs. Parsing with any other timezone silently shifts every reset by the + /// device's UTC offset (see steipete/CodexBar#1826). + private static func parseResetDate(_ value: String) -> Date? { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.timeZone = TimeZone(identifier: "UTC") + formatter.dateFormat = "MMMM d, yyyy 'at' h:mm a" + return formatter.date(from: trimmed) + } + + private static func capture(pattern: String, in html: String) -> String? { + guard let match = self.firstMatch(pattern: pattern, in: html) else { return nil } + return self.capture(1, in: html, match: match) + } + + private static func firstMatch(pattern: String, in html: String) -> NSTextCheckingResult? { + guard let regex = try? NSRegularExpression(pattern: pattern, options: [.caseInsensitive]) else { + return nil + } + let range = NSRange(html.startIndex.. String? { + let range = match.range(at: index) + guard range.location != NSNotFound, + let swiftRange = Range(range, in: html) + else { + return nil + } + let value = html[swiftRange].trimmingCharacters(in: .whitespacesAndNewlines) + return value.isEmpty ? nil : value + } +} diff --git a/Sources/CodexBarCore/Providers/Shared/AliyunOneConsole/AliyunOneConsoleChromiumCookieFallbackImporter.swift b/Sources/CodexBarCore/Providers/Shared/AliyunOneConsole/AliyunOneConsoleChromiumCookieFallbackImporter.swift new file mode 100644 index 0000000000..0b06d9d740 --- /dev/null +++ b/Sources/CodexBarCore/Providers/Shared/AliyunOneConsole/AliyunOneConsoleChromiumCookieFallbackImporter.swift @@ -0,0 +1,321 @@ +import Foundation + +#if os(macOS) +import CommonCrypto +import Security +import SQLite3 +import SweetCookieKit + +enum AliyunOneConsoleChromiumCookieFallbackImporter { + private struct ChromiumCookieRecord { + let domain: String + let name: String + let path: String + let value: String + let expires: Date? + let isSecure: Bool + } + + enum ImportError: LocalizedError { + case keyUnavailable(browser: Browser) + case keychainDenied(browser: Browser) + case sqliteFailed(label: String, details: String) + + var errorDescription: String? { + switch self { + case let .keyUnavailable(browser): + "\(browser.displayName) Safe Storage key not found." + case let .keychainDenied(browser): + "macOS Keychain denied access to \(browser.displayName) Safe Storage." + case let .sqliteFailed(label, details): + "\(label) cookie fallback failed: \(details)" + } + } + } + + static func importSession( + browser: Browser, + domains: [String], + isAuthenticatedSession: ([HTTPCookie]) -> Bool, + sessionLabel: String, + cookieClient: BrowserCookieClient = BrowserCookieClient(), + logger: ((String) -> Void)? = nil) throws -> AliyunOneConsoleCookieImporter.SessionInfo? + { + let stores = try cookieClient.codexBarStores(for: browser).filter { $0.databaseURL != nil } + guard !stores.isEmpty else { return nil } + + logger?("Trying \(browser.displayName) Chromium fallback") + let keys = try self.derivedKeys(for: browser) + for store in stores { + let cookies = try self.loadCookies(from: store, domains: domains, keys: keys) + guard !cookies.isEmpty else { continue } + if isAuthenticatedSession(cookies) { + logger?("Found \(cookies.count) \(sessionLabel) cookies via \(store.label) fallback") + return AliyunOneConsoleCookieImporter.SessionInfo(cookies: cookies, sourceLabel: store.label) + } + } + return nil + } + + private static func loadCookies( + from store: BrowserCookieStore, + domains: [String], + keys: [Data]) throws -> [HTTPCookie] + { + guard let sourceDB = store.databaseURL else { return [] } + let records = try self.readCookiesFromLockedDB( + sourceDB: sourceDB, + domains: domains, + keys: keys, + label: store.label) + return records.compactMap(self.makeCookie) + } + + private static func readCookiesFromLockedDB( + sourceDB: URL, + domains: [String], + keys: [Data], + label: String) throws -> [ChromiumCookieRecord] + { + let tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent("aliyun-oneconsole-chromium-cookies-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + + let copiedDB = tempDir.appendingPathComponent("Cookies") + try FileManager.default.copyItem(at: sourceDB, to: copiedDB) + for suffix in ["-wal", "-shm"] { + let src = URL(fileURLWithPath: sourceDB.path + suffix) + if FileManager.default.fileExists(atPath: src.path) { + let dst = URL(fileURLWithPath: copiedDB.path + suffix) + try? FileManager.default.copyItem(at: src, to: dst) + } + } + defer { try? FileManager.default.removeItem(at: tempDir) } + + return try self.readCookies(fromDB: copiedDB.path, domains: domains, keys: keys, label: label) + } + + private static func readCookies( + fromDB path: String, + domains: [String], + keys: [Data], + label: String) throws -> [ChromiumCookieRecord] + { + var db: OpaquePointer? + guard sqlite3_open_v2(path, &db, SQLITE_OPEN_READONLY, nil) == SQLITE_OK else { + throw ImportError.sqliteFailed(label: label, details: String(cString: sqlite3_errmsg(db))) + } + defer { sqlite3_close(db) } + + let sql = "SELECT host_key, name, path, expires_utc, is_secure, value, encrypted_value FROM cookies" + var stmt: OpaquePointer? + guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { + throw ImportError.sqliteFailed(label: label, details: String(cString: sqlite3_errmsg(db))) + } + defer { sqlite3_finalize(stmt) } + + var records: [ChromiumCookieRecord] = [] + while sqlite3_step(stmt) == SQLITE_ROW { + guard let hostKey = self.readText(stmt, index: 0), self.matches(domain: hostKey, patterns: domains) else { + continue + } + guard let name = self.readText(stmt, index: 1), let path = self.readText(stmt, index: 2) else { + continue + } + + let value: String? = if let plain = self.readText(stmt, index: 5), !plain.isEmpty { + plain + } else if let encrypted = self.readBlob(stmt, index: 6) { + self.decrypt(encrypted, usingAnyOf: keys) + } else { + nil + } + guard let value, !value.isEmpty else { continue } + + records.append(ChromiumCookieRecord( + domain: AliyunOneConsoleCookieImporter.normalizeCookieDomain(hostKey), + name: name, + path: path, + value: value, + expires: self.chromiumExpiry(sqlite3_column_int64(stmt, 3)), + isSecure: sqlite3_column_int(stmt, 4) != 0)) + } + + return records.filter { record in + guard let expires = record.expires else { return true } + return expires >= Date() + } + } + + private static func derivedKeys(for browser: Browser) throws -> [Data] { + var keys: [Data] = [] + var sawDenied = false + + for label in browser.safeStorageLabels { + switch KeychainAccessPreflight.checkGenericPassword(service: label.service, account: label.account) { + case .interactionRequired: + sawDenied = true + continue + case .allowed, .notFound, .failure: + break + } + + if let password = self.safeStoragePassword(service: label.service, account: label.account) { + keys.append(self.deriveKey(from: password)) + } + } + + if !keys.isEmpty { + return keys + } + if sawDenied { + throw ImportError.keychainDenied(browser: browser) + } + throw ImportError.keyUnavailable(browser: browser) + } + + private static func safeStoragePassword(service: String, account: String) -> String? { + // The preflight classifies prompt-requiring items as .interactionRequired, but its + // .notFound (gate disabled) and .failure outcomes still reach this read. Honor the + // access gate and keep the read strictly non-interactive so it can never prompt. + guard !KeychainAccessGate.isDisabled else { return nil } + var query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account, + kSecMatchLimit as String: kSecMatchLimitOne, + kSecReturnData as String: true, + ] + KeychainNoUIQuery.apply(to: &query) + + var result: AnyObject? + let status = KeychainSecurity.copyMatching(query as CFDictionary, &result) + guard status == errSecSuccess, let data = result as? Data else { return nil } + return String(data: data, encoding: .utf8) + } + + private static func deriveKey(from password: String) -> Data { + let salt = Data("saltysalt".utf8) + var key = Data(count: kCCKeySizeAES128) + let keyLength = key.count + _ = key.withUnsafeMutableBytes { keyBytes in + password.utf8CString.withUnsafeBytes { passBytes in + salt.withUnsafeBytes { saltBytes in + CCKeyDerivationPBKDF( + CCPBKDFAlgorithm(kCCPBKDF2), + passBytes.bindMemory(to: Int8.self).baseAddress, + passBytes.count - 1, + saltBytes.bindMemory(to: UInt8.self).baseAddress, + salt.count, + CCPseudoRandomAlgorithm(kCCPRFHmacAlgSHA1), + 1003, + keyBytes.bindMemory(to: UInt8.self).baseAddress, + keyLength) + } + } + } + return key + } + + private static func decrypt(_ encryptedValue: Data, usingAnyOf keys: [Data]) -> String? { + for key in keys { + if let value = self.decrypt(encryptedValue, key: key) { + return value + } + } + return nil + } + + private static func decrypt(_ encryptedValue: Data, key: Data) -> String? { + guard encryptedValue.count > 3 else { return nil } + let prefix = String(data: encryptedValue.prefix(3), encoding: .utf8) + guard prefix == "v10" else { return nil } + + let payload = Data(encryptedValue.dropFirst(3)) + let iv = Data(repeating: 0x20, count: kCCBlockSizeAES128) + var outLength = 0 + var out = Data(count: payload.count + kCCBlockSizeAES128) + let outCapacity = out.count + + let status = out.withUnsafeMutableBytes { outBytes in + payload.withUnsafeBytes { payloadBytes in + key.withUnsafeBytes { keyBytes in + iv.withUnsafeBytes { ivBytes in + CCCrypt( + CCOperation(kCCDecrypt), + CCAlgorithm(kCCAlgorithmAES), + CCOptions(kCCOptionPKCS7Padding), + keyBytes.baseAddress, + key.count, + ivBytes.baseAddress, + payloadBytes.baseAddress, + payload.count, + outBytes.baseAddress, + outCapacity, + &outLength) + } + } + } + } + + guard status == kCCSuccess else { return nil } + out.count = outLength + + if let value = String(data: out, encoding: .utf8), !value.isEmpty { + return value + } + if out.count > 32 { + let trimmed = out.dropFirst(32) + if let value = String(data: trimmed, encoding: .utf8), !value.isEmpty { + return value + } + } + return nil + } + + private static func makeCookie(from record: ChromiumCookieRecord) -> HTTPCookie? { + var properties: [HTTPCookiePropertyKey: Any] = [ + .domain: record.domain, + .path: record.path, + .name: record.name, + .value: record.value, + ] + if record.isSecure { + properties[.secure] = true + } + if let expires = record.expires { + properties[.expires] = expires + } + return HTTPCookie(properties: properties) + } + + private static func readText(_ stmt: OpaquePointer?, index: Int32) -> String? { + guard sqlite3_column_type(stmt, index) != SQLITE_NULL, + let value = sqlite3_column_text(stmt, index) + else { + return nil + } + return String(cString: value) + } + + private static func readBlob(_ stmt: OpaquePointer?, index: Int32) -> Data? { + guard sqlite3_column_type(stmt, index) != SQLITE_NULL, + let bytes = sqlite3_column_blob(stmt, index) + else { + return nil + } + return Data(bytes: bytes, count: Int(sqlite3_column_bytes(stmt, index))) + } + + private static func matches(domain: String, patterns: [String]) -> Bool { + AliyunOneConsoleCookieImporter.matchesCookieDomain(domain, patterns: patterns) + } + + private static func chromiumExpiry(_ expiresUTC: Int64) -> Date? { + guard expiresUTC > 0 else { return nil } + let seconds = (Double(expiresUTC) / 1_000_000.0) - 11_644_473_600.0 + guard seconds > 0 else { return nil } + return Date(timeIntervalSince1970: seconds) + } +} +#endif diff --git a/Sources/CodexBarCore/Providers/Shared/AliyunOneConsole/AliyunOneConsoleCookieHeaders.swift b/Sources/CodexBarCore/Providers/Shared/AliyunOneConsole/AliyunOneConsoleCookieHeaders.swift new file mode 100644 index 0000000000..79449708be --- /dev/null +++ b/Sources/CodexBarCore/Providers/Shared/AliyunOneConsole/AliyunOneConsoleCookieHeaders.swift @@ -0,0 +1,155 @@ +import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +/// Generic cookie-header pair for Aliyun OneConsole-based providers. +/// +/// Each provider keeps one HTTPCookie jar but may need to send a slightly +/// different Cookie header on dashboard GETs versus API POSTs (different +/// host scopes, different CSRF / sec_token presence). This struct holds +/// both headers together and handles the cached-header round-trip so +/// providers don't reinvent that plumbing. +public struct OneConsoleCookieHeaders: Sendable { + public let apiCookieHeader: String + public let dashboardCookieHeader: String + + public init(apiCookieHeader: String, dashboardCookieHeader: String) { + self.apiCookieHeader = apiCookieHeader + self.dashboardCookieHeader = dashboardCookieHeader + } + + public init?(singleHeader raw: String?) { + guard let normalized = CookieHeaderNormalizer.normalize(raw) else { return nil } + self.apiCookieHeader = normalized + self.dashboardCookieHeader = normalized + } + + public init?(cachedHeader raw: String?, cacheNamespace: String) { + var valuesByName: [String: String] = [:] + for pair in CookieHeaderNormalizer.pairs(from: raw ?? "") { + valuesByName[pair.name] = pair.value + } + if let encodedAPI = valuesByName["__codexbar_\(cacheNamespace)_api"], + let encodedDashboard = valuesByName["__codexbar_\(cacheNamespace)_dashboard"], + let apiHeader = Self.decodeCachedHeader(encodedAPI), + let dashboardHeader = Self.decodeCachedHeader(encodedDashboard), + let normalizedAPI = CookieHeaderNormalizer.normalize(apiHeader), + let normalizedDashboard = CookieHeaderNormalizer.normalize(dashboardHeader) + { + self.init(apiCookieHeader: normalizedAPI, dashboardCookieHeader: normalizedDashboard) + return + } + + self.init(singleHeader: raw) + } + + public func cacheCookieHeader(namespace: String) -> String { + [ + "__codexbar_\(namespace)_api=\(Self.encodeCachedHeader(self.apiCookieHeader))", + "__codexbar_\(namespace)_dashboard=\(Self.encodeCachedHeader(self.dashboardCookieHeader))", + ].joined(separator: "; ") + } + + public var apiCookieNames: [String] { + Self.cookieNames(from: self.apiCookieHeader) + } + + public var dashboardCookieNames: [String] { + Self.cookieNames(from: self.dashboardCookieHeader) + } + + public func hasCookie(named name: String) -> Bool { + Self.cookieNames(from: self.apiCookieHeader).contains(name) || + Self.cookieNames(from: self.dashboardCookieHeader).contains(name) + } + + private static func cookieNames(from header: String) -> [String] { + CookieHeaderNormalizer.pairs(from: header) + .map(\.name) + .filter { !$0.isEmpty } + .uniquedSorted() + } + + private static func encodeCachedHeader(_ header: String) -> String { + Data(header.utf8).base64EncodedString() + } + + private static func decodeCachedHeader(_ encoded: String) -> String? { + guard let data = Data(base64Encoded: encoded) else { return nil } + return String(data: data, encoding: .utf8) + } +} + +/// Builds a Cookie header from an [HTTPCookie] jar scoped to a target URL. +public enum OneConsoleCookieHeaderBuilder { + /// Returns a single Cookie header string that includes every cookie in + /// `cookies` whose domain and path match `targetURL`, choosing the most + /// specific cookie (longest path, longest domain, latest expiry) when + /// duplicates exist. + public static func header(from cookies: [HTTPCookie], targetURL: URL) -> String? { + var byName: [String: HTTPCookie] = [:] + for cookie in cookies { + guard !cookie.name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { continue } + guard !cookie.value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { continue } + if let expiry = cookie.expiresDate, expiry < Date() { continue } + guard Self.matchesRequestURL(cookie: cookie, url: targetURL) else { continue } + + if let existing = byName[cookie.name] { + if Self.cookieSortKey(for: cookie) >= Self.cookieSortKey(for: existing) { + byName[cookie.name] = cookie + } + } else { + byName[cookie.name] = cookie + } + } + + guard !byName.isEmpty else { return nil } + return byName.keys.sorted().compactMap { name in + guard let cookie = byName[name] else { return nil } + return "\(cookie.name)=\(cookie.value)" + }.joined(separator: "; ") + } + + private static func matchesRequestURL(cookie: HTTPCookie, url: URL) -> Bool { + guard let host = url.host?.lowercased() else { return false } + let normalizedDomain = cookie.domain.lowercased().trimmingCharacters(in: CharacterSet(charactersIn: ".")) + guard !normalizedDomain.isEmpty else { return false } + guard host == normalizedDomain || host.hasSuffix(".\(normalizedDomain)") else { return false } + + let cookiePath = cookie.path.isEmpty ? "/" : cookie.path + let requestPath = url.path.isEmpty ? "/" : url.path + if requestPath == cookiePath { + return true + } + guard requestPath.hasPrefix(cookiePath) else { return false } + guard cookiePath != "/" else { return true } + if cookiePath.hasSuffix("/") { + return true + } + guard + let boundaryIndex = requestPath.index( + requestPath.startIndex, + offsetBy: cookiePath.count, + limitedBy: requestPath.endIndex), + boundaryIndex < requestPath.endIndex + else { + return true + } + return requestPath[boundaryIndex] == "/" + } + + private static func cookieSortKey(for cookie: HTTPCookie) -> (Int, Int, Date) { + let pathLength = cookie.path.count + let normalizedDomain = cookie.domain.lowercased().trimmingCharacters(in: CharacterSet(charactersIn: ".")) + let domainLength = normalizedDomain.count + let expiry = cookie.expiresDate ?? .distantPast + return (pathLength, domainLength, expiry) + } +} + +extension [String] { + func uniquedSorted() -> [String] { + Array(Set(self)).sorted() + } +} diff --git a/Sources/CodexBarCore/Providers/Shared/AliyunOneConsole/AliyunOneConsoleCookieImporter.swift b/Sources/CodexBarCore/Providers/Shared/AliyunOneConsole/AliyunOneConsoleCookieImporter.swift new file mode 100644 index 0000000000..ac35e4d493 --- /dev/null +++ b/Sources/CodexBarCore/Providers/Shared/AliyunOneConsole/AliyunOneConsoleCookieImporter.swift @@ -0,0 +1,215 @@ +import Foundation + +public struct AliyunOneConsoleCookieImportError: LocalizedError, Sendable { + public let details: String? + + public init(details: String? = nil) { + self.details = details + } + + public var errorDescription: String? { + self.details + } +} + +#if os(macOS) +import SweetCookieKit + +/// Generic browser-cookie importer for Aliyun OneConsole-based providers. +/// +/// Each provider (Alibaba Coding Plan, Alibaba Token Plan, Qwen Cloud, ...) declares +/// its own cookie domains and "is this an authenticated session" predicate. Everything +/// else -- the per-browser iteration, Chromium fallback, Keychain preflight, and +/// diagnostic collection -- is shared here. +public enum AliyunOneConsoleCookieImporter { + private static let cookieClient = BrowserCookieClient() + + public struct SessionInfo: Sendable { + public let cookies: [HTTPCookie] + public let sourceLabel: String + + public init(cookies: [HTTPCookie], sourceLabel: String) { + self.cookies = cookies + self.sourceLabel = sourceLabel + } + + public var cookieHeader: String { + var byName: [String: HTTPCookie] = [:] + byName.reserveCapacity(self.cookies.count) + + for cookie in self.cookies { + if let expiry = cookie.expiresDate, expiry < Date() { + continue + } + guard !cookie.value.isEmpty else { continue } + if let existing = byName[cookie.name] { + let existingExpiry = existing.expiresDate ?? .distantPast + let candidateExpiry = cookie.expiresDate ?? .distantPast + if candidateExpiry >= existingExpiry { + byName[cookie.name] = cookie + } + } else { + byName[cookie.name] = cookie + } + } + + return byName.keys.sorted().compactMap { name in + guard let cookie = byName[name] else { return nil } + return "\(cookie.name)=\(cookie.value)" + }.joined(separator: "; ") + } + } + + /// Generic browser-cookie import for Aliyun OneConsole-based providers. + /// The domain list, session-validation rules, and browser-import order are + /// provider-specific; everything else is shared. + public static func importSession( + browserDetection: BrowserDetection, + domains: [String], + isAuthenticatedSession: @escaping ([HTTPCookie]) -> Bool, + logPrefix: String, + sessionLabel: String, + importOrder: BrowserCookieImportOrder = Browser.defaultImportOrder, + logger: ((String) -> Void)? = nil) throws -> SessionInfo + { + let log: (String) -> Void = { msg in logger?("[\(logPrefix)] \(msg)") } + var accessDeniedHints: [String] = [] + var failureDetails: [String] = [] + let installedBrowsers = self.cookieImportCandidates( + browserDetection: browserDetection, + importOrder: importOrder) + log("Cookie import candidates: \(installedBrowsers.map(\.displayName).joined(separator: ", "))") + + for browserSource in installedBrowsers { + do { + log("Checking \(browserSource.displayName)") + let query = BrowserCookieQuery(domains: domains) + let sources = try Self.cookieClient.codexBarRecords( + matching: query, + in: browserSource, + logger: log) + if sources.isEmpty { + log("No matching cookie records in \(browserSource.displayName)") + if let fallbackSession = try Self.importChromiumFallbackSession( + browser: browserSource, + domains: domains, + isAuthenticatedSession: isAuthenticatedSession, + sessionLabel: sessionLabel, + logger: log) + { + return fallbackSession + } + } + for source in sources where !source.records.isEmpty { + let httpCookies = BrowserCookieClient.makeHTTPCookies(source.records, origin: query.origin) + if isAuthenticatedSession(httpCookies) { + log("Found \(httpCookies.count) \(sessionLabel) cookies in \(source.label)") + return SessionInfo(cookies: httpCookies, sourceLabel: source.label) + } + log( + "Skipping \(source.label): missing auth cookies" + + " (\(httpCookies.count) cookies)") + } + if let fallbackSession = try Self.importChromiumFallbackSession( + browser: browserSource, + domains: domains, + isAuthenticatedSession: isAuthenticatedSession, + sessionLabel: sessionLabel, + logger: log) + { + return fallbackSession + } + } catch let error as BrowserCookieError { + BrowserCookieAccessGate.recordIfNeeded(error) + if let hint = error.accessDeniedHint { + accessDeniedHints.append(hint) + } + failureDetails.append("\(browserSource.displayName): \(error.localizedDescription)") + log("\(browserSource.displayName) cookie import failed: \(error.localizedDescription)") + } catch { + failureDetails.append("\(browserSource.displayName): \(error.localizedDescription)") + log("\(browserSource.displayName) cookie import failed: \(error.localizedDescription)") + } + } + + let details = (Array(Set(accessDeniedHints)).sorted() + Array(Set(failureDetails)).sorted()) + .joined(separator: " ") + throw AliyunOneConsoleCookieImportError(details: details.isEmpty ? nil : details) + } + + public static func hasSession( + browserDetection: BrowserDetection, + domains: [String], + isAuthenticatedSession: @escaping ([HTTPCookie]) -> Bool, + logPrefix: String, + sessionLabel: String, + importOrder: BrowserCookieImportOrder = Browser.defaultImportOrder, + logger: ((String) -> Void)? = nil) -> Bool + { + do { + _ = try self.importSession( + browserDetection: browserDetection, + domains: domains, + isAuthenticatedSession: isAuthenticatedSession, + logPrefix: logPrefix, + sessionLabel: sessionLabel, + importOrder: importOrder, + logger: logger) + return true + } catch { + return false + } + } + + private static func importChromiumFallbackSession( + browser: Browser, + domains: [String], + isAuthenticatedSession: @escaping ([HTTPCookie]) -> Bool, + sessionLabel: String, + logger: ((String) -> Void)? = nil) throws -> SessionInfo? + { + guard browser.usesChromiumProfileStore else { return nil } + guard let fallbackSession = try AliyunOneConsoleChromiumCookieFallbackImporter.importSession( + browser: browser, + domains: domains, + isAuthenticatedSession: isAuthenticatedSession, + sessionLabel: sessionLabel, + logger: logger) + else { + return nil + } + guard isAuthenticatedSession(fallbackSession.cookies) else { + logger?( + "Fallback cookies missing auth cookies" + + " (\(fallbackSession.cookies.count) cookies); ignoring" + + " \(fallbackSession.sourceLabel)") + return nil + } + logger?( + "Using fallback import (\(fallbackSession.cookies.count) \(sessionLabel) cookies)" + + " from \(fallbackSession.sourceLabel)") + return fallbackSession + } + + public static func cookieImportCandidates( + browserDetection: BrowserDetection, + importOrder: BrowserCookieImportOrder) -> [Browser] + { + importOrder.cookieImportCandidates(using: browserDetection) + } + + public static func matchesCookieDomain(_ domain: String, patterns: [String]) -> Bool { + let normalized = self.normalizeCookieDomain(domain) + return patterns.contains { pattern in + let normalizedPattern = self.normalizeCookieDomain(pattern) + return normalized == normalizedPattern || normalized.hasSuffix(".\(normalizedPattern)") + } + } + + public static func normalizeCookieDomain(_ domain: String) -> String { + let trimmed = domain.trimmingCharacters(in: .whitespacesAndNewlines) + let normalized = trimmed.hasPrefix(".") ? String(trimmed.dropFirst()) : trimmed + return normalized.lowercased() + } +} +#endif diff --git a/Sources/CodexBarCore/Providers/Shared/AliyunOneConsole/OneConsoleCookieRouting.swift b/Sources/CodexBarCore/Providers/Shared/AliyunOneConsole/OneConsoleCookieRouting.swift new file mode 100644 index 0000000000..e3cd432e04 --- /dev/null +++ b/Sources/CodexBarCore/Providers/Shared/AliyunOneConsole/OneConsoleCookieRouting.swift @@ -0,0 +1,123 @@ +import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +/// Redirect policy for Aliyun OneConsole sessions with distinct dashboard and API cookie scopes. +/// +/// Trusted dashboard/API redirects receive the matching cookie header. Cross-origin +/// redirects are followed only as credential-free GET/HEAD navigations; body-bearing +/// redirects are rejected so a 307/308 cannot forward `sec_token` or request parameters. +public struct OneConsoleCookieRouting: Sendable { + private static let redirectStatusCodes: Set = [301, 302, 303, 307, 308] + + public let apiURL: URL + public let dashboardURL: URL + public let apiCookieHeader: String + public let dashboardCookieHeader: String + + public init( + apiURL: URL, + dashboardURL: URL, + apiCookieHeader: String, + dashboardCookieHeader: String) + { + self.apiURL = apiURL + self.dashboardURL = dashboardURL + self.apiCookieHeader = apiCookieHeader + self.dashboardCookieHeader = dashboardCookieHeader + } + + public func redirectedRequest( + forRedirectFrom original: URLRequest, + response: HTTPURLResponse, + to redirected: URLRequest) -> URLRequest? + { + guard Self.redirectStatusCodes.contains(response.statusCode) else { return nil } + guard let originalURL = original.url, + originalURL.scheme?.caseInsensitiveCompare("https") == .orderedSame, + originalURL.user == nil, + originalURL.password == nil + else { + return nil + } + let sourceURL = response.url ?? originalURL + guard sourceURL.scheme?.caseInsensitiveCompare("https") == .orderedSame, + sourceURL.user == nil, + sourceURL.password == nil + else { + return nil + } + guard let redirectedURL = redirected.url, + redirectedURL.scheme?.caseInsensitiveCompare("https") == .orderedSame, + redirectedURL.host != nil, + redirectedURL.user == nil, + redirectedURL.password == nil + else { + return nil + } + + let isCrossOrigin = !Self.isSameOrigin(sourceURL, redirectedURL) + if isCrossOrigin, !Self.isCredentialFreeNavigation(redirected) { + return nil + } + + let acceptsHTML = original.value(forHTTPHeaderField: "Accept")?.contains("text/html") == true + var routed: URLRequest + if isCrossOrigin { + guard let sanitized = Self.sanitizedNavigation(from: redirected) else { return nil } + routed = sanitized + } else { + routed = redirected + } + if Self.isSameOrigin(redirectedURL, self.apiURL), + redirectedURL.path == self.apiURL.path, + !acceptsHTML + { + routed.setValue(self.apiCookieHeader, forHTTPHeaderField: "Cookie") + return routed + } + if Self.isSameOrigin(redirectedURL, self.dashboardURL) { + routed.setValue(self.dashboardCookieHeader, forHTTPHeaderField: "Cookie") + return routed + } + + return Self.sanitizedNavigation(from: routed) + } + + private static func isCredentialFreeNavigation(_ request: URLRequest) -> Bool { + let method = request.httpMethod?.uppercased() ?? "GET" + guard method == "GET" || method == "HEAD" else { return false } + return request.httpBody == nil && request.httpBodyStream == nil + } + + private static func sanitizedNavigation(from request: URLRequest) -> URLRequest? { + guard let url = request.url, self.isCredentialFreeNavigation(request) else { return nil } + var sanitized = URLRequest( + url: url, + cachePolicy: request.cachePolicy, + timeoutInterval: request.timeoutInterval) + sanitized.httpMethod = request.httpMethod?.uppercased() ?? "GET" + for header in ["Accept", "Accept-Language", "User-Agent"] { + if let value = request.value(forHTTPHeaderField: header) { + sanitized.setValue(value, forHTTPHeaderField: header) + } + } + return sanitized + } + + private static func isSameOrigin(_ lhs: URL, _ rhs: URL) -> Bool { + lhs.scheme?.lowercased() == rhs.scheme?.lowercased() && + lhs.host?.lowercased() == rhs.host?.lowercased() && + self.normalizedPort(lhs) == self.normalizedPort(rhs) + } + + private static func normalizedPort(_ url: URL) -> Int? { + if let port = url.port { return port } + switch url.scheme?.lowercased() { + case "http": return 80 + case "https": return 443 + default: return nil + } + } +} diff --git a/Sources/CodexBarCore/Providers/Shared/AliyunOneConsole/OneConsoleJSON.swift b/Sources/CodexBarCore/Providers/Shared/AliyunOneConsole/OneConsoleJSON.swift new file mode 100644 index 0000000000..7a45602f1b --- /dev/null +++ b/Sources/CodexBarCore/Providers/Shared/AliyunOneConsole/OneConsoleJSON.swift @@ -0,0 +1,223 @@ +import Foundation + +/// Recursive JSON traversal + scalar coercion helpers shared across Aliyun +/// OneConsole-based providers. The gateway responses nest subscription +/// metadata inside double-stringified JSON envelopes; these helpers walk +/// the tree without committing to a particular payload schema. +public enum OneConsoleJSON { + /// Recursively expands any string value that itself parses as JSON, + /// so consumers can treat `{"data": "{\"foo\": 1}"}` as if it were + /// `{"data": {"foo": 1}}`. Non-JSON strings and primitives pass through. + public static func expandEmbeddedJSON(_ value: Any) -> Any { + if let string = value as? String { + let trimmed = string.trimmingCharacters(in: .whitespacesAndNewlines) + guard trimmed.hasPrefix("{") || trimmed.hasPrefix("[") else { return value } + guard let data = trimmed.data(using: .utf8), + let decoded = try? JSONSerialization.jsonObject(with: data) + else { + return value + } + return self.expandEmbeddedJSON(decoded) + } + if let dictionary = value as? [String: Any] { + return dictionary.mapValues(self.expandEmbeddedJSON) + } + if let array = value as? [Any] { + return array.map(self.expandEmbeddedJSON) + } + return value + } + + /// Returns the first dictionary anywhere in `value` that contains any + /// of the given keys. Useful for "find the quota object regardless of + /// where the API nested it" parsers. + public static func findObject( + containingAnyOf keys: Set, + in value: Any) -> [String: Any]? + { + if let dictionary = value as? [String: Any] { + if !keys.isDisjoint(with: dictionary.keys) { + return dictionary + } + for nested in dictionary.values { + if let found = self.findObject(containingAnyOf: keys, in: nested) { + return found + } + } + } else if let array = value as? [Any] { + for nested in array { + if let found = self.findObject(containingAnyOf: keys, in: nested) { + return found + } + } + } + return nil + } + + /// Returns the first value associated with any of `keys` anywhere in `value`. + public static func findFirstValue(forKeys keys: [String], in value: Any) -> Any? { + let lowercasedKeys = Set(keys.map { $0.lowercased() }) + if let dictionary = value as? [String: Any] { + for (key, nested) in dictionary where lowercasedKeys.contains(key.lowercased()) { + return nested + } + for nested in dictionary.values { + if let found = self.findFirstValue(forKeys: keys, in: nested) { + return found + } + } + } else if let array = value as? [Any] { + for nested in array { + if let found = self.findFirstValue(forKeys: keys, in: nested) { + return found + } + } + } + return nil + } + + /// Returns the first string value associated with any of `keys` in `value`. + public static func findFirstString(forKeys keys: [String], in value: Any) -> String? { + for key in keys { + if let found = self.findFirstConvertedValue( + forKey: key, + in: value, + transform: self.string) + { + return found + } + } + return nil + } + + /// Returns the first integer value associated with any of `keys` in `value`. + public static func findFirstInt(forKeys keys: [String], in value: Any) -> Int? { + for key in keys { + if let found = self.findFirstConvertedValue( + forKey: key, + in: value, + transform: self.int) + { + return found + } + } + return nil + } + + /// Returns the first array value associated with any of `keys` in `value`. + public static func findFirstArray(forKeys keys: [String], in value: Any) -> [Any]? { + for key in keys { + if let found = self.findFirstConvertedValue( + forKey: key, + in: value, + transform: { $0 as? [Any] }) + { + return found + } + } + return nil + } + + /// Searches one key at a time so caller priority is preserved across the full tree. + /// Invalid values do not mask a later valid value for the same key. + private static func findFirstConvertedValue( + forKey expectedKey: String, + in value: Any, + transform: (Any?) -> T?) -> T? + { + if let dictionary = value as? [String: Any] { + for (key, nested) in dictionary where key.caseInsensitiveCompare(expectedKey) == .orderedSame { + if let converted = transform(nested) { + return converted + } + } + for nested in dictionary.values { + if let found = self.findFirstConvertedValue( + forKey: expectedKey, + in: nested, + transform: transform) + { + return found + } + } + } else if let array = value as? [Any] { + for nested in array { + if let found = self.findFirstConvertedValue( + forKey: expectedKey, + in: nested, + transform: transform) + { + return found + } + } + } + return nil + } + + /// Coerces `value` to Double. Accepts NSNumber, Int, Double, and numeric + /// strings. Returns nil if the value is non-numeric. + public static func number(_ value: Any?) -> Double? { + guard let value else { return nil } + if let number = value as? NSNumber { + return number.doubleValue + } + if let string = value as? String { + return Double(string.trimmingCharacters(in: .whitespacesAndNewlines)) + } + return nil + } + + /// Coerces `value` to Int. Accepts NSNumber, Int, Int64, Double (truncated), + /// and numeric strings. + public static func int(_ value: Any?) -> Int? { + guard let value else { return nil } + if let intValue = value as? Int { return intValue } + if let int64Value = value as? Int64 { return Int(int64Value) } + if let number = value as? NSNumber { return number.intValue } + if let doubleValue = value as? Double { return Int(doubleValue) } + if let string = value as? String { + return Int(string.trimmingCharacters(in: .whitespacesAndNewlines)) + } + return nil + } + + /// Coerces `value` to String after trimming whitespace. Returns nil for + /// empty or non-string values. + public static func string(_ value: Any?) -> String? { + guard let value = value as? String else { return nil } + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } + + /// Converts a 0..1 ratio into a 0..100 percentage, clamping to that range. + /// Returns nil for non-finite ratios. + public static func percentagePoints(fromRatio ratio: Double?) -> Double? { + guard let ratio, ratio.isFinite else { return nil } + return min(max(ratio, 0), 1) * 100 + } + + /// Coerces `value` to a Date. Accepts: + /// - Positive numeric epoch in seconds or milliseconds (auto-detected via magnitude) + /// - ISO 8601 strings + /// - "yyyy-MM-dd", "yyyy-MM-dd HH:mm", and "yyyy-MM-dd HH:mm:ss" strings + public static func date(_ value: Any?) -> Date? { + if let number = self.number(value), number > 0 { + let seconds = number >= 1_000_000_000_000 ? number / 1000 : number + return Date(timeIntervalSince1970: seconds) + } + guard let string = value as? String else { return nil } + let formatter = ISO8601DateFormatter() + if let date = formatter.date(from: string) { + return date + } + let dateFormatter = DateFormatter() + dateFormatter.locale = Locale(identifier: "en_US_POSIX") + for format in ["yyyy-MM-dd", "yyyy-MM-dd HH:mm", "yyyy-MM-dd HH:mm:ss"] { + dateFormatter.dateFormat = format + if let date = dateFormatter.date(from: string) { + return date + } + } + return nil + } +} diff --git a/Sources/CodexBarCore/Providers/Shared/AliyunOneConsole/OneConsoleSECTokenResolver.swift b/Sources/CodexBarCore/Providers/Shared/AliyunOneConsole/OneConsoleSECTokenResolver.swift new file mode 100644 index 0000000000..ad685272c6 --- /dev/null +++ b/Sources/CodexBarCore/Providers/Shared/AliyunOneConsole/OneConsoleSECTokenResolver.swift @@ -0,0 +1,233 @@ +import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +/// Resolves the Aliyun OneConsole `sec_token` for a given authenticated +/// session. The token is required for every gateway API call; it lives +/// either in the dashboard HTML (as an inline JS constant), on the user-info +/// endpoint, or as a cookie scoped to the console host. Providers configure +/// which sources to probe and how to recognize their login page. +public struct OneConsoleSECTokenResolver: Sendable { + public struct Configuration: Sendable { + /// Resolves the dashboard URL for the current environment (host override aware). + public let dashboardURL: @Sendable ([String: String]) -> URL + /// Path under the dashboard host to the user-info JSON endpoint (typically "tool/user/info.json"). + public let userInfoPath: String + /// Provider-specific login-page detector. OneConsole providers use + /// different passport hosts and login markup, so the provider owns this + /// classification instead of flattening alternatives into shared rules. + public let isLoginPage: @Sendable (String) -> Bool + /// Additional regex patterns the provider wants to try beyond the + /// built-in set (secToken, sec_token, csrfToken). + public let extraHTMLPatterns: [String] + + public init( + dashboardURL: @escaping @Sendable ([String: String]) -> URL, + userInfoPath: String, + isLoginPage: @escaping @Sendable (String) -> Bool, + extraHTMLPatterns: [String] = []) + { + self.dashboardURL = dashboardURL + self.userInfoPath = userInfoPath + self.isLoginPage = isLoginPage + self.extraHTMLPatterns = extraHTMLPatterns + } + } + + public enum Source: String, Sendable { + case dashboardHTML = "dashboard-html" + case cookie + case userInfo = "user-info" + } + + public struct Resolved: Sendable, Equatable { + public let value: String + public let source: Source + } + + public let configuration: Configuration + + public init(configuration: Configuration) { + self.configuration = configuration + } + + public func resolve( + cookieHeader: String, + environment: [String: String], + transport: any ProviderHTTPTransport) async throws -> Resolved + { + let dashboardURL = self.configuration.dashboardURL(environment) + + // 1. Try the dashboard HTML. The one-console injects sec_token as an + // inline JS constant; it's the freshest source. + var dashboardFailure: Error? + do { + let token = try await self.fetchFromDashboard( + cookieHeader: cookieHeader, + dashboardURL: dashboardURL, + transport: transport) + return Resolved(value: token, source: .dashboardHTML) + } catch OneConsoleSECTokenError.notFound { + // Continue through the token fallbacks. + } catch { + dashboardFailure = error + } + + // 2. Fall back to a sec_token cookie scoped to the console host. + if let cookieToken = Self.secTokenCookieValue(from: cookieHeader, host: dashboardURL.host) { + return Resolved(value: cookieToken, source: .cookie) + } + + // 3. Final fallback: the user-info JSON endpoint. + do { + let userInfoToken = try await self.fetchFromUserInfo( + cookieHeader: cookieHeader, + dashboardURL: dashboardURL, + transport: transport) + return Resolved(value: userInfoToken, source: .userInfo) + } catch OneConsoleSECTokenError.notFound { + // A retained dashboard failure is more accurate than missing credentials. + } catch { + throw error + } + + if let dashboardFailure { + throw dashboardFailure + } + + throw OneConsoleSECTokenError.notFound + } + + // MARK: - Dashboard HTML + + private func fetchFromDashboard( + cookieHeader: String, + dashboardURL: URL, + transport: any ProviderHTTPTransport) async throws -> String + { + var request = URLRequest(url: dashboardURL) + request.httpMethod = "GET" + request.timeoutInterval = 20 + request.setValue("text/html,application/xhtml+xml", forHTTPHeaderField: "Accept") + request.setValue(cookieHeader, forHTTPHeaderField: "Cookie") + + let (data, response) = try await transport.data(for: request) + guard let http = response as? HTTPURLResponse else { + throw URLError(.badServerResponse) + } + guard http.statusCode == 200 else { + if (500...599).contains(http.statusCode) { + throw URLError(.badServerResponse) + } + throw OneConsoleSECTokenError.notFound + } + guard let html = String(data: data, encoding: .utf8) else { + throw OneConsoleSECTokenError.notFound + } + if self.configuration.isLoginPage(html) { + throw OneConsoleSECTokenError.notFound + } + if let token = Self.extractToken(from: html, extraPatterns: self.configuration.extraHTMLPatterns) { + return token + } + throw OneConsoleSECTokenError.notFound + } + + private static func extractToken(from html: String, extraPatterns: [String]) -> String? { + let patterns = [ + #""secToken"\s*:\s*"([^"]+)""#, + #""sec_token"\s*:\s*"([^"]+)""#, + #"secToken['"]?\s*[:=]\s*['"]([^'"]+)['"]"#, + #"sec_token['"]?\s*[:=]\s*['"]([^'"]+)['"]"#, + #"csrfToken['"]?\s*[:=]\s*['"]([^'"]+)['"]"#, + ] + extraPatterns + for pattern in patterns { + if let token = Self.firstMatchGroup(pattern: pattern, in: html), !token.isEmpty { + return token + } + } + return nil + } + + private static func firstMatchGroup(pattern: String, in text: String) -> String? { + guard let regex = try? NSRegularExpression(pattern: pattern, options: []) else { + return nil + } + let range = NSRange(text.startIndex.. 1, + let valueRange = Range(match.range(at: 1), in: text) + else { + return nil + } + let value = text[valueRange].trimmingCharacters(in: .whitespacesAndNewlines) + return value.isEmpty ? nil : String(value) + } + + // MARK: - Cookie fallback + + private static func secTokenCookieValue(from cookieHeader: String, host: String?) -> String? { + var fallback: String? + for pair in CookieHeaderNormalizer.pairs(from: cookieHeader) { + guard pair.name.lowercased() == "sec_token", !pair.value.isEmpty else { continue } + if let host, pair.value.contains(host) { + return pair.value + } + fallback = pair.value + } + return fallback + } + + // MARK: - User-info fallback + + private func fetchFromUserInfo( + cookieHeader: String, + dashboardURL: URL, + transport: any ProviderHTTPTransport) async throws -> String + { + var components = URLComponents() + components.scheme = dashboardURL.scheme ?? "https" + components.host = dashboardURL.host ?? "home.qwencloud.com" + if let port = dashboardURL.port { + components.port = port + } + components.path = self.configuration.userInfoPath + guard let url = components.url else { + throw OneConsoleSECTokenError.notFound + } + + var request = URLRequest(url: url) + request.httpMethod = "GET" + request.timeoutInterval = 20 + request.setValue("application/json, text/plain, */*", forHTTPHeaderField: "Accept") + request.setValue(cookieHeader, forHTTPHeaderField: "Cookie") + + let (data, response) = try await transport.data(for: request) + guard let http = response as? HTTPURLResponse, http.statusCode == 200 else { + throw OneConsoleSECTokenError.notFound + } + guard let json = try? JSONSerialization.jsonObject(with: data) else { + throw OneConsoleSECTokenError.notFound + } + let expanded = OneConsoleJSON.expandEmbeddedJSON(json) + if let token = OneConsoleJSON.findFirstString( + forKeys: ["secToken", "sec_token", "csrfToken", "token"], + in: expanded) + { + return token + } + throw OneConsoleSECTokenError.notFound + } +} + +public enum OneConsoleSECTokenError: LocalizedError, Sendable { + case notFound + + public var errorDescription: String? { + switch self { + case .notFound: + "sec_token not found in dashboard, cookies, or user-info" + } + } +} diff --git a/Sources/CodexBarCore/Providers/Shared/CurlCaptureParser.swift b/Sources/CodexBarCore/Providers/Shared/CurlCaptureParser.swift new file mode 100644 index 0000000000..1f09108884 --- /dev/null +++ b/Sources/CodexBarCore/Providers/Shared/CurlCaptureParser.swift @@ -0,0 +1,120 @@ +import Foundation + +/// Shared parsing for "Copy as cURL" DevTools captures pasted by users into manual-auth provider +/// settings fields. Extracted from T3 Chat's original implementation (see #1830-era history) so +/// other web-cookie/bearer-token providers (e.g. ZoomMate) can reuse the exact same regex/shell +/// unescaping behavior instead of duplicating subtle parsing logic. +public enum CurlCaptureParser { + /// Extracts the request URL from the standard DevTools "Copy as cURL" shape, where the URL is + /// the first argument after `curl`. Returns `nil` for malformed captures or option-first forms. + public static func requestURL(from raw: String) -> URL? { + let pattern = + #"(?s)(?:^|\s)curl\s+"# + + #"(?:\$'((?:\\.|[^'])*)'|'([^']*)'|\"((?:\\.|[^\"])*)\"|([^\s\\]+))"# + guard let regex = try? NSRegularExpression(pattern: pattern, options: []) else { return nil } + let range = NSRange(raw.startIndex.. [String] { + var fields: [String] = [] + let pattern = + #"(?s)(?:^|\s)(?:-H|--header)(?:\s+|=|(?=['"$]))"# + + #"(?:\$'((?:\\.|[^'])*)'|'([^']*)'|"((?:\\.|[^"])*)"|(\S+))"# + guard let regex = try? NSRegularExpression(pattern: pattern, options: []) else { return fields } + let range = NSRange(raw.startIndex.. String? { + for field in fields { + guard let colon = field.firstIndex(of: ":") else { continue } + let rawName = field[.. canonical HTTP header name). + public static func forwardedHeaders(from fields: [String], allowlist: [String: String]) -> [String: String] { + var headers: [String: String] = [:] + for field in fields { + guard let colon = field.firstIndex(of: ":") else { continue } + let rawName = field[.. String? { + guard match.numberOfRanges > index, + let range = Range(match.range(at: index), in: raw) + else { + return nil + } + return String(raw[range]) + } + + private static func unescapeShellSegment(_ raw: String, ansi: Bool) -> String { + var output = "" + var index = raw.startIndex + while index < raw.endIndex { + guard raw[index] == "\\" else { + output.append(raw[index]) + index = raw.index(after: index) + continue + } + let next = raw.index(after: index) + guard next < raw.endIndex else { return output } + switch raw[next] { + case "n" where ansi: + output.append("\n") + case "r" where ansi: + output.append("\r") + case "t" where ansi: + output.append("\t") + case "\n": + break + default: + output.append(raw[next]) + } + index = raw.index(after: next) + } + return output + } +} diff --git a/Sources/CodexBarCore/Providers/StepFun/StepFunProviderDescriptor.swift b/Sources/CodexBarCore/Providers/StepFun/StepFunProviderDescriptor.swift index 5724db3b94..69c389c3a7 100644 --- a/Sources/CodexBarCore/Providers/StepFun/StepFunProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/StepFun/StepFunProviderDescriptor.swift @@ -1,9 +1,8 @@ -import CodexBarMacroSupport import Foundation -@ProviderDescriptorRegistration -@ProviderDescriptorDefinition public enum StepFunProviderDescriptor { + public static let descriptor: ProviderDescriptor = Self.makeDescriptor() + static func makeDescriptor() -> ProviderDescriptor { ProviderDescriptor( id: .stepfun, @@ -28,7 +27,12 @@ public enum StepFunProviderDescriptor { branding: ProviderBranding( iconStyle: .stepfun, iconResourceName: "ProviderIcon-stepfun", - color: ProviderColor(red: 0.13, green: 0.59, blue: 0.95)), + color: ProviderColor(red: 0.13, green: 0.59, blue: 0.95), + confettiPalette: [ + ProviderColor(hex: 0x000000), + ProviderColor(hex: 0xFFFFFF), + ProviderColor(hex: 0x858585), + ]), tokenCost: ProviderTokenCostConfig( supportsTokenCost: false, noDataMessage: { "StepFun per-day cost history is not available via API." }), diff --git a/Sources/CodexBarCore/Providers/StepFun/StepFunUsageFetcher.swift b/Sources/CodexBarCore/Providers/StepFun/StepFunUsageFetcher.swift index 9c8bca186b..6d7fac3f5b 100644 --- a/Sources/CodexBarCore/Providers/StepFun/StepFunUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/StepFun/StepFunUsageFetcher.swift @@ -16,6 +16,11 @@ public struct StepFunFlexibleNumber: Decodable, Sendable { self.value = Double(intVal) } else if let doubleVal = try? container.decode(Double.self) { self.value = doubleVal + } else if let strVal = try? container.decode(String.self), + let parsed = Double(strVal) + { + // The API returns some numeric fields as JSON strings (e.g. "400000000"). + self.value = parsed } else { self.value = 0 } @@ -56,6 +61,8 @@ public struct StepFunRateLimitResponse: Decodable, Sendable { public let weeklyUsageLeftRate: StepFunFlexibleNumber? public let fiveHourUsageResetTime: StepFunFlexibleTimestamp? public let weeklyUsageResetTime: StepFunFlexibleTimestamp? + public let planFamily: StepFunFlexibleNumber? + public let planCreditRateLimit: StepFunPlanCreditRateLimit? enum CodingKeys: String, CodingKey { case status @@ -66,11 +73,98 @@ public struct StepFunRateLimitResponse: Decodable, Sendable { case weeklyUsageLeftRate = "weekly_usage_left_rate" case fiveHourUsageResetTime = "five_hour_usage_reset_time" case weeklyUsageResetTime = "weekly_usage_reset_time" + case planFamily = "plan_family" + case planCreditRateLimit = "plan_credit_rate_limit" } public var isSuccess: Bool { self.status == 1 } + + /// StepFun runs two Step Plan billing models side by side after the 2026-06-18 + /// upgrade (docs/zh/step-plan/upgrade-notice): the grandfathered **Coding Plan** + /// meters rolling **5-hour / weekly** windows, while the current **Token Plan** + /// meters a monthly **Credit** pool via `plan_credit_rate_limit` (its rate windows + /// come back as 0 with `reset_time` `"0"` — "no window configured", not "used up"). + /// + /// Classify by the shape the payload actually carries rather than trusting + /// `plan_family` alone: a live rolling window means Coding Plan; no window plus a + /// Credit pool means Token Plan. `plan_family` (2 == the Credit family) is only a + /// tie-breaker for an ambiguous payload (e.g. a brand-new plan with neither a live + /// window nor credit yet), so a future family-id change can't silently flip a + /// windowed plan onto the credit renderer or vice versa. + var isCreditPlan: Bool { + let hasLiveWindow = (self.fiveHourUsageResetTime?.value ?? 0) > 0 + || (self.weeklyUsageResetTime?.value ?? 0) > 0 + if hasLiveWindow { + return false + } + let hasCreditPool = self.planCreditRateLimit?.subscriptionCreditLeftRate != nil + || self.planCreditRateLimit?.topupCreditLeftRate != nil + || !(self.planCreditRateLimit?.creditBuckets?.isEmpty ?? true) + if hasCreditPool { + return true + } + return (self.planFamily?.value).map { $0 == 2 } ?? false + } +} + +/// The `plan_credit_rate_limit` object returned for credit-based plans. +public struct StepFunPlanCreditRateLimit: Decodable, Sendable { + public let subscriptionCreditLeftRate: StepFunFlexibleNumber? + public let subscriptionCreditResetTime: StepFunFlexibleTimestamp? + public let topupCreditLeftRate: StepFunFlexibleNumber? + public let creditBuckets: [StepFunPlanCreditBucket]? + + enum CodingKeys: String, CodingKey { + case subscriptionCreditLeftRate = "subscription_credit_left_rate" + case subscriptionCreditResetTime = "subscription_credit_reset_time" + case topupCreditLeftRate = "topup_credit_left_rate" + case creditBuckets = "credit_buckets" + } + + /// Combined remaining fraction across subscription + top-up credits. + var totalCreditLeftRate: Double? { + // Subscription and top-up rates are independent fractions, so adding them + // does not produce a combined rate. Prefer the absolute bucket balances. + if let buckets = creditBuckets, !buckets.isEmpty { + let balances = buckets.compactMap { bucket -> (total: Double, residual: Double)? in + guard let total = bucket.creditTotal?.value, + let residual = bucket.creditResidual?.value, + total.isFinite, + residual.isFinite, + total > 0, + residual >= 0, + residual <= total + else { return nil } + return (total, residual) + } + if balances.count == buckets.count { + let total = balances.reduce(0.0) { $0 + $1.total } + let residual = balances.reduce(0.0) { $0 + $1.residual } + return residual / total + } + } + + // Without bucket sizes there is no sound way to weight both rates. The + // subscription balance is the primary plan allowance; use top-up only + // when no subscription rate is present. + return self.subscriptionCreditLeftRate?.value ?? self.topupCreditLeftRate?.value + } +} + +public struct StepFunPlanCreditBucket: Decodable, Sendable { + public let creditTotal: StepFunFlexibleNumber? + public let creditResidual: StepFunFlexibleNumber? + public let expireAt: StepFunFlexibleTimestamp? + public let nextResetAt: StepFunFlexibleTimestamp? + + enum CodingKeys: String, CodingKey { + case creditTotal = "credit_total" + case creditResidual = "credit_residual" + case expireAt = "expire_at" + case nextResetAt = "next_reset_at" + } } // MARK: - Plan status response types @@ -126,6 +220,9 @@ public struct StepFunUsageSnapshot: Sendable { public let weeklyUsageResetTime: Date public let planName: String? public let updatedAt: Date + public let creditLeftRate: Double? + public let creditResetTime: Date? + public let isCreditPlan: Bool public init( fiveHourUsageLeftRate: Double, @@ -133,7 +230,10 @@ public struct StepFunUsageSnapshot: Sendable { fiveHourUsageResetTime: Date, weeklyUsageResetTime: Date, planName: String? = nil, - updatedAt: Date) + updatedAt: Date, + creditLeftRate: Double? = nil, + creditResetTime: Date? = nil, + isCreditPlan: Bool = false) { self.fiveHourUsageLeftRate = fiveHourUsageLeftRate self.weeklyUsageLeftRate = weeklyUsageLeftRate @@ -141,9 +241,43 @@ public struct StepFunUsageSnapshot: Sendable { self.weeklyUsageResetTime = weeklyUsageResetTime self.planName = planName self.updatedAt = updatedAt + self.creditLeftRate = creditLeftRate + self.creditResetTime = creditResetTime + self.isCreditPlan = isCreditPlan } public func toUsageSnapshot() -> UsageSnapshot { + let trimmedPlan = self.planName?.trimmingCharacters(in: .whitespacesAndNewlines) + let loginMethod = (trimmedPlan?.isEmpty ?? true) ? "password" : trimmedPlan + + let identity = ProviderIdentitySnapshot( + providerID: .stepfun, + accountEmail: nil, + accountOrganization: nil, + loginMethod: loginMethod) + + // Token Plan (Credit pool) carries no live 5h/weekly windows. Show the credit + // balance as the primary window and drop the meaningless 0%-left rate windows + // entirely. Coding Plan keeps its rolling windows and falls through below. + if self.isCreditPlan, let creditRate = self.creditLeftRate { + let creditUsedPercent = max(0, min(100, (1.0 - creditRate) * 100)) + let resetDate = self.creditResetTime ?? Date.distantFuture + let resetDescription = UsageFormatter.resetDescription(from: resetDate) + let creditWindow = RateWindow( + usedPercent: creditUsedPercent, + windowMinutes: nil, + resetsAt: resetDate, + resetDescription: resetDescription) + + return UsageSnapshot( + primary: creditWindow, + secondary: nil, + tertiary: nil, + updatedAt: self.updatedAt, + identity: identity) + } + + // Rate-window plans: five-hour window as primary, weekly as secondary. // Five-hour window: primary let fiveHourUsedPercent = max(0, min(100, (1.0 - self.fiveHourUsageLeftRate) * 100)) let fiveHourResetDescription = UsageFormatter.resetDescription(from: self.fiveHourUsageResetTime) @@ -162,15 +296,6 @@ public struct StepFunUsageSnapshot: Sendable { resetsAt: self.weeklyUsageResetTime, resetDescription: weeklyResetDescription) - let trimmedPlan = self.planName?.trimmingCharacters(in: .whitespacesAndNewlines) - let loginMethod = (trimmedPlan?.isEmpty ?? true) ? "password" : trimmedPlan - - let identity = ProviderIdentitySnapshot( - providerID: .stepfun, - accountEmail: nil, - accountOrganization: nil, - loginMethod: loginMethod) - return UsageSnapshot( primary: fiveHourWindow, secondary: weeklyWindow, @@ -231,18 +356,53 @@ public struct StepFunUsageFetcher: Sendable { URL(string: "https://platform.stepfun.com/passport/proto.api.passport.v1.PassportService/RefreshToken")! private static let timeoutSeconds: TimeInterval = 15 - private static let webID = "c8a1002d2c457e758785a9979832217c7c0b884c" + /// Fallback webid used only for the initial device-registration / login flow, + /// before we have a token to derive the real device_id from. + private static let defaultWebID = "c8a1002d2c457e758785a9979832217c7c0b884c" private static let appID = "10300" private static let baseHeaders: [String: String] = [ "content-type": "application/json", "oasis-appid": appID, "oasis-platform": "web", - "oasis-webid": webID, + "oasis-webid": defaultWebID, "user-agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) " + "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36", ] + /// Extract the `device_id` from a token's JWT payload to use as the Oasis-Webid. + /// The refresh-token half of the "access...refresh" pair carries a `device_id` + /// claim that must match the Oasis-Webid header/cookie, otherwise the server + /// returns "auth failed: oasis-token is embezzled". + private static func webID(forToken token: String) -> String { + // The token is either a bare JWT or an "access...refresh" pair. + // The device_id lives in the refresh half; fall back to the access half. + let halves = token.components(separatedBy: "...") + for half in halves.reversed() { + if let webid = Self.extractDeviceID(from: half), !webid.isEmpty { + return webid + } + } + return Self.defaultWebID + } + + /// Decode the JWT payload (without signature verification) and return `device_id`. + private static func extractDeviceID(from jwt: String) -> String? { + let parts = jwt.components(separatedBy: ".") + guard parts.count >= 2 else { return nil } + var payload = parts[1] + // base64url padding + while payload.count % 4 != 0 { + payload.append("=") + } + guard let data = Data(base64Encoded: payload.replacingOccurrences(of: "-", with: "+").replacingOccurrences( + of: "_", + with: "/")), + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] + else { return nil } + return json["device_id"] as? String + } + // MARK: - Public API /// Perform the full login flow (username + password → Oasis-Token) and return the token. @@ -373,8 +533,10 @@ public struct StepFunUsageFetcher: Sendable { for (key, value) in self.baseHeaders { request.setValue(value, forHTTPHeaderField: key) } + let webid = Self.webID(forToken: anonToken) + request.setValue(webid, forHTTPHeaderField: "oasis-webid") request.setValue( - "Oasis-Token=\(anonToken); Oasis-Webid=\(self.webID); INGRESSCOOKIE=\(ingressCookie)", + "Oasis-Token=\(anonToken); Oasis-Webid=\(webid); INGRESSCOOKIE=\(ingressCookie)", forHTTPHeaderField: "Cookie") request.timeoutInterval = self.timeoutSeconds @@ -405,6 +567,7 @@ public struct StepFunUsageFetcher: Sendable { guard !normalized.isEmpty else { throw StepFunUsageError.missingToken } + let webid = Self.webID(forToken: normalized) var request = URLRequest(url: self.refreshTokenURL) request.httpMethod = "POST" @@ -412,9 +575,10 @@ public struct StepFunUsageFetcher: Sendable { for (key, value) in self.baseHeaders { request.setValue(value, forHTTPHeaderField: key) } + request.setValue(webid, forHTTPHeaderField: "oasis-webid") request.setValue(normalized, forHTTPHeaderField: "Oasis-Token") request.setValue( - "Oasis-Token=\(normalized); Oasis-Webid=\(self.webID)", + "Oasis-Token=\(normalized); Oasis-Webid=\(webid)", forHTTPHeaderField: "Cookie") request.timeoutInterval = self.timeoutSeconds @@ -450,13 +614,16 @@ public struct StepFunUsageFetcher: Sendable { // MARK: - Query usage private static func queryUsage(token: String) async throws -> StepFunUsageSnapshot { + let webid = Self.webID(forToken: token) var request = URLRequest(url: self.apiURL) request.httpMethod = "POST" request.httpBody = Data("{}".utf8) for (key, value) in self.baseHeaders { request.setValue(value, forHTTPHeaderField: key) } - request.setValue("Oasis-Token=\(token); Oasis-Webid=\(self.webID)", forHTTPHeaderField: "Cookie") + // Override the header webid with the one matching this token's device_id. + request.setValue(webid, forHTTPHeaderField: "oasis-webid") + request.setValue("Oasis-Token=\(token); Oasis-Webid=\(webid)", forHTTPHeaderField: "Cookie") request.timeoutInterval = self.timeoutSeconds let response = try await ProviderHTTPClient.shared.response(for: request) @@ -482,7 +649,10 @@ public struct StepFunUsageFetcher: Sendable { fiveHourUsageResetTime: snapshot.fiveHourUsageResetTime, weeklyUsageResetTime: snapshot.weeklyUsageResetTime, planName: planName, - updatedAt: snapshot.updatedAt) + updatedAt: snapshot.updatedAt, + creditLeftRate: snapshot.creditLeftRate, + creditResetTime: snapshot.creditResetTime, + isCreditPlan: snapshot.isCreditPlan) } return snapshot @@ -491,13 +661,15 @@ public struct StepFunUsageFetcher: Sendable { // MARK: - Plan Status private static func queryPlanStatus(token: String) async throws -> String? { + let webid = Self.webID(forToken: token) var request = URLRequest(url: self.planStatusURL) request.httpMethod = "POST" request.httpBody = Data("{}".utf8) for (key, value) in self.baseHeaders { request.setValue(value, forHTTPHeaderField: key) } - request.setValue("Oasis-Token=\(token); Oasis-Webid=\(self.webID)", forHTTPHeaderField: "Cookie") + request.setValue(webid, forHTTPHeaderField: "oasis-webid") + request.setValue("Oasis-Token=\(token); Oasis-Webid=\(webid)", forHTTPHeaderField: "Cookie") request.timeoutInterval = self.timeoutSeconds let response = try await ProviderHTTPClient.shared.response(for: request) @@ -536,19 +708,36 @@ public struct StepFunUsageFetcher: Sendable { throw StepFunUsageError.apiError(msg) } - guard let fiveHourRate = decoded.fiveHourUsageLeftRate, - let weeklyRate = decoded.weeklyUsageLeftRate, - let fiveHourReset = decoded.fiveHourUsageResetTime, - let weeklyReset = decoded.weeklyUsageResetTime - else { - throw StepFunUsageError.parseFailed("Missing usage rate or reset time fields") + // Credit-based plans (plan_family=2) don't populate the rate-window fields + // meaningfully, so don't require them. Fall back to 0/epoch if absent. + let fiveHourRate = decoded.fiveHourUsageLeftRate?.value ?? 0 + let weeklyRate = decoded.weeklyUsageLeftRate?.value ?? 0 + let fiveHourReset = decoded.fiveHourUsageResetTime?.value ?? 0 + let weeklyReset = decoded.weeklyUsageResetTime?.value ?? 0 + + // For non-credit plans, require the rate fields to be present. + if !decoded.isCreditPlan { + guard decoded.fiveHourUsageLeftRate != nil, + decoded.weeklyUsageLeftRate != nil, + decoded.fiveHourUsageResetTime != nil, + decoded.weeklyUsageResetTime != nil + else { + throw StepFunUsageError.parseFailed("Missing usage rate or reset time fields") + } } + let creditLeftRate = decoded.planCreditRateLimit?.totalCreditLeftRate + let creditResetTime = decoded.planCreditRateLimit?.subscriptionCreditResetTime + .map { Date(timeIntervalSince1970: TimeInterval($0.value)) } + return StepFunUsageSnapshot( - fiveHourUsageLeftRate: fiveHourRate.value, - weeklyUsageLeftRate: weeklyRate.value, - fiveHourUsageResetTime: Date(timeIntervalSince1970: TimeInterval(fiveHourReset.value)), - weeklyUsageResetTime: Date(timeIntervalSince1970: TimeInterval(weeklyReset.value)), - updatedAt: Date()) + fiveHourUsageLeftRate: fiveHourRate, + weeklyUsageLeftRate: weeklyRate, + fiveHourUsageResetTime: Date(timeIntervalSince1970: TimeInterval(fiveHourReset)), + weeklyUsageResetTime: Date(timeIntervalSince1970: TimeInterval(weeklyReset)), + updatedAt: Date(), + creditLeftRate: creditLeftRate, + creditResetTime: creditResetTime, + isCreditPlan: decoded.isCreditPlan) } } diff --git a/Sources/CodexBarCore/Providers/Sub2API/Sub2APIProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Sub2API/Sub2APIProviderDescriptor.swift new file mode 100644 index 0000000000..14d0f970a6 --- /dev/null +++ b/Sources/CodexBarCore/Providers/Sub2API/Sub2APIProviderDescriptor.swift @@ -0,0 +1,68 @@ +import Foundation + +public enum Sub2APIProviderDescriptor { + public static func primaryLabel(details: Sub2APIUsageDetails?) -> String? { + details?.kind == .subscription ? "Daily quota" : nil + } + + public static let descriptor = ProviderDescriptor( + id: .sub2api, + metadata: ProviderMetadata( + id: .sub2api, + displayName: "sub2api", + sessionLabel: "Quota", + weeklyLabel: "Weekly quota", + opusLabel: "Monthly quota", + supportsOpus: true, + supportsCredits: false, + creditsHint: "Reads key quota, subscription limits, usage, and wallet balance from /v1/usage.", + toggleTitle: "Show sub2api usage", + cliName: "sub2api", + defaultEnabled: false, + dashboardURL: nil, + statusPageURL: nil), + branding: ProviderBranding( + iconStyle: .sub2api, + iconResourceName: "ProviderIcon-sub2api", + color: ProviderColor(red: 45 / 255, green: 198 / 255, blue: 216 / 255), + confettiPalette: [ + ProviderColor(hex: 0x1F62FF), + ProviderColor(hex: 0x60EDF6), + ProviderColor(hex: 0x74F9B0), + ]), + tokenCost: ProviderTokenCostConfig( + supportsTokenCost: false, + noDataMessage: { "sub2api spend is reported by its usage API." }), + fetchPlan: ProviderFetchPlan( + sourceModes: [.auto, .api], + pipeline: ProviderFetchPipeline(resolveStrategies: { _ in [Sub2APIAPIFetchStrategy()] })), + cli: ProviderCLIConfig( + name: "sub2api", + aliases: ["sub-2-api"], + versionDetector: nil)) +} + +struct Sub2APIAPIFetchStrategy: ProviderFetchStrategy { + let id = "sub2api.api" + let kind: ProviderFetchKind = .apiToken + + func isAvailable(_ context: ProviderFetchContext) async -> Bool { + Sub2APISettingsReader.apiKey(environment: context.env) != nil && + Sub2APISettingsReader.baseURL(environment: context.env) != nil + } + + func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult { + guard let apiKey = Sub2APISettingsReader.apiKey(environment: context.env) else { + throw Sub2APIUsageError.missingCredentials + } + guard let baseURL = Sub2APISettingsReader.baseURL(environment: context.env) else { + throw Sub2APIUsageError.missingBaseURL + } + let usage = try await Sub2APIUsageFetcher.fetchUsage(apiKey: apiKey, baseURL: baseURL) + return self.makeResult(usage: usage.toUsageSnapshot(), sourceLabel: "api") + } + + func shouldFallback(on _: Error, context _: ProviderFetchContext) -> Bool { + false + } +} diff --git a/Sources/CodexBarCore/Providers/Sub2API/Sub2APISettingsReader.swift b/Sources/CodexBarCore/Providers/Sub2API/Sub2APISettingsReader.swift new file mode 100644 index 0000000000..ba33e6305c --- /dev/null +++ b/Sources/CodexBarCore/Providers/Sub2API/Sub2APISettingsReader.swift @@ -0,0 +1,53 @@ +import Foundation + +public enum Sub2APISettingsError: LocalizedError, Equatable, Sendable { + case invalidBaseURL + + public var errorDescription: String? { + "sub2api base URL must use HTTPS, or loopback HTTP for local development, without embedded credentials." + } +} + +public enum Sub2APISettingsReader { + public static let apiKeyEnvironmentKey = "SUB2API_API_KEY" + public static let baseURLEnvironmentKey = "SUB2API_BASE_URL" + + public static func apiKey( + environment: [String: String] = ProcessInfo.processInfo.environment) -> String? + { + self.cleaned(environment[self.apiKeyEnvironmentKey]) + } + + public static func baseURL( + environment: [String: String] = ProcessInfo.processInfo.environment) -> URL? + { + guard let raw = self.cleaned(environment[self.baseURLEnvironmentKey]) else { return nil } + let validator = ProviderEndpointOverrideValidator() + guard let url = validator.validatedURLAllowingLoopbackHTTP(raw), + url.query == nil, + url.fragment == nil + else { return nil } + return url + } + + public static func validateBaseURL( + environment: [String: String] = ProcessInfo.processInfo.environment) throws + { + guard self.baseURL(environment: environment) != nil else { + throw Sub2APISettingsError.invalidBaseURL + } + } + + static func cleaned(_ raw: String?) -> String? { + guard var value = raw?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty else { + return nil + } + if (value.hasPrefix("\"") && value.hasSuffix("\"")) || + (value.hasPrefix("'") && value.hasSuffix("'")) + { + value = String(value.dropFirst().dropLast()) + } + value = value.trimmingCharacters(in: .whitespacesAndNewlines) + return value.isEmpty ? nil : value + } +} diff --git a/Sources/CodexBarCore/Providers/Sub2API/Sub2APIUsageFetcher.swift b/Sources/CodexBarCore/Providers/Sub2API/Sub2APIUsageFetcher.swift new file mode 100644 index 0000000000..d30a54c782 --- /dev/null +++ b/Sources/CodexBarCore/Providers/Sub2API/Sub2APIUsageFetcher.swift @@ -0,0 +1,457 @@ +import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +public enum Sub2APIUsageError: LocalizedError, Equatable, Sendable { + case missingCredentials + case missingBaseURL + case invalidCredentials + case apiError(Int) + case parseFailed(String) + + public var errorDescription: String? { + switch self { + case .missingCredentials: + "Missing sub2api API key. Add a group API key in Settings or set SUB2API_API_KEY." + case .missingBaseURL: + "Missing or invalid sub2api base URL. Add one in Settings or set SUB2API_BASE_URL." + case .invalidCredentials: + "sub2api rejected the API key. Check that the key is active and assigned to a group." + case let .apiError(statusCode): + "sub2api API returned HTTP \(statusCode)." + case let .parseFailed(message): + "Could not parse sub2api usage: \(message)" + } + } +} + +public struct Sub2APIUsageDetails: Codable, Sendable, Equatable { + public enum Kind: String, Codable, Sendable { + case keyQuota + case subscription + case wallet + case unknown + } + + public struct Totals: Codable, Sendable, Equatable { + public let requests: Int + public let totalTokens: Int + public let actualCostUSD: Double + + public init(requests: Int, totalTokens: Int, actualCostUSD: Double) { + self.requests = requests + self.totalTokens = totalTokens + self.actualCostUSD = actualCostUSD + } + } + + public let kind: Kind + public let balance: Double? + public let unit: String + public let today: Totals? + public let total: Totals? + + public init(kind: Kind, balance: Double?, unit: String, today: Totals?, total: Totals?) { + self.kind = kind + self.balance = balance + self.unit = unit + self.today = today + self.total = total + } +} + +public struct Sub2APIUsageSnapshot: Sendable, Equatable { + public struct Quota: Sendable, Equatable { + public let limit: Double + public let used: Double + public let remaining: Double + public let unit: String + } + + public struct RateLimit: Sendable, Equatable { + public let window: String + public let limit: Double + public let used: Double + public let remaining: Double + public let resetAt: Date? + } + + public struct Subscription: Sendable, Equatable { + public let dailyUsageUSD: Double + public let weeklyUsageUSD: Double + public let monthlyUsageUSD: Double + public let dailyLimitUSD: Double? + public let weeklyLimitUSD: Double? + public let monthlyLimitUSD: Double? + public let expiresAt: Date? + } + + public struct UsageTotals: Sendable, Equatable { + public let requests: Int + public let totalTokens: Int + public let actualCostUSD: Double + } + + public let mode: String + public let isValid: Bool + public let status: String? + public let planName: String? + public let remaining: Double? + public let unit: String + public let balance: Double? + public let quota: Quota? + public let rateLimits: [RateLimit] + public let subscription: Subscription? + public let todayUsage: UsageTotals? + public let totalUsage: UsageTotals? + public let expiresAt: Date? + public let updatedAt: Date + + public func toUsageSnapshot() -> UsageSnapshot { + let subscription = self.subscription + let kind: Sub2APIUsageDetails.Kind = if subscription != nil { + .subscription + } else if self.quota != nil || !self.rateLimits.isEmpty { + .keyQuota + } else if self.balance != nil { + .wallet + } else { + .unknown + } + let subscriptionWindows = subscription.map { subscription in + [ + Self.rateWindow( + usage: subscription.dailyUsageUSD, + limit: subscription.dailyLimitUSD, + windowMinutes: 24 * 60), + Self.rateWindow( + usage: subscription.weeklyUsageUSD, + limit: subscription.weeklyLimitUSD, + windowMinutes: 7 * 24 * 60), + Self.rateWindow( + usage: subscription.monthlyUsageUSD, + limit: subscription.monthlyLimitUSD, + windowMinutes: 30 * 24 * 60), + ] + } + let primary = subscriptionWindows?[0] ?? self.quota.map(Self.quotaWindow) + let secondary = subscriptionWindows?[1] + let tertiary = subscriptionWindows?[2] + let namedWindows = self.rateLimits.map { rateLimit in + NamedRateWindow( + id: rateLimit.window, + title: Self.rateLimitTitle(rateLimit.window), + window: RateWindow( + usedPercent: Self.usedPercent(usage: rateLimit.used, limit: rateLimit.limit), + windowMinutes: Self.windowMinutes(rateLimit.window), + resetsAt: rateLimit.resetAt, + resetDescription: Self.amountDescription(used: rateLimit.used, limit: rateLimit.limit))) + } + let usageDetails = Sub2APIUsageDetails( + kind: kind, + balance: self.balance, + unit: self.unit, + today: self.todayUsage.map { + Sub2APIUsageDetails.Totals( + requests: $0.requests, + totalTokens: $0.totalTokens, + actualCostUSD: $0.actualCostUSD) + }, + total: self.totalUsage.map { + Sub2APIUsageDetails.Totals( + requests: $0.requests, + totalTokens: $0.totalTokens, + actualCostUSD: $0.actualCostUSD) + }) + + return UsageSnapshot( + primary: primary, + secondary: secondary, + tertiary: tertiary, + extraRateWindows: namedWindows.isEmpty ? nil : namedWindows, + sub2APIUsage: usageDetails, + subscriptionExpiresAt: subscription?.expiresAt ?? self.expiresAt, + updatedAt: self.updatedAt, + identity: ProviderIdentitySnapshot( + providerID: .sub2api, + accountEmail: nil, + accountOrganization: self.planName, + loginMethod: self.planName), + dataConfidence: .exact) + } + + private static func quotaWindow(_ quota: Quota) -> RateWindow { + RateWindow( + usedPercent: self.usedPercent(usage: quota.used, limit: quota.limit), + windowMinutes: nil, + resetsAt: nil, + resetDescription: self.amountDescription(used: quota.used, limit: quota.limit, unit: quota.unit)) + } + + private static func rateWindow(usage: Double, limit: Double?, windowMinutes: Int) -> RateWindow? { + guard let limit, limit > 0 else { return nil } + return RateWindow( + usedPercent: self.usedPercent(usage: usage, limit: limit), + windowMinutes: windowMinutes, + resetsAt: nil, + resetDescription: self.amountDescription(used: usage, limit: limit)) + } + + private static func usedPercent(usage: Double, limit: Double) -> Double { + guard limit > 0 else { return 0 } + return min(100, max(0, usage / limit * 100)) + } + + private static func amountDescription(used: Double, limit: Double, unit: String = "USD") -> String { + "\(self.currencyString(used, unit: unit)) / \(self.currencyString(limit, unit: unit))" + } + + private static func currencyString(_ value: Double, unit: String) -> String { + unit.uppercased() == "USD" ? UsageFormatter.usdString(value) : String(format: "%.2f %@", value, unit) + } + + private static func windowMinutes(_ window: String) -> Int? { + switch window.lowercased() { + case "5h": 5 * 60 + case "1d": 24 * 60 + case "7d": 7 * 24 * 60 + default: nil + } + } + + private static func rateLimitTitle(_ window: String) -> String { + switch window.lowercased() { + case "5h": "5 hour limit" + case "1d": "Daily limit" + case "7d": "7 day limit" + default: "\(window) limit" + } + } +} + +private struct Sub2APIUsageResponse: Decodable { + struct Quota: Decodable { + let limit: Double + let used: Double + let remaining: Double + let unit: String? + } + + struct RateLimit: Decodable { + let window: String + let limit: Double + let used: Double + let remaining: Double + let resetAt: String? + + private enum CodingKeys: String, CodingKey { + case window + case limit + case used + case remaining + case resetAt = "reset_at" + } + } + + struct Subscription: Decodable { + let dailyUsageUSD: Double? + let weeklyUsageUSD: Double? + let monthlyUsageUSD: Double? + let dailyLimitUSD: Double? + let weeklyLimitUSD: Double? + let monthlyLimitUSD: Double? + let expiresAt: String? + + private enum CodingKeys: String, CodingKey { + case dailyUsageUSD = "daily_usage_usd" + case weeklyUsageUSD = "weekly_usage_usd" + case monthlyUsageUSD = "monthly_usage_usd" + case dailyLimitUSD = "daily_limit_usd" + case weeklyLimitUSD = "weekly_limit_usd" + case monthlyLimitUSD = "monthly_limit_usd" + case expiresAt = "expires_at" + } + } + + struct Usage: Decodable { + struct Totals: Decodable { + let requests: Int? + let totalTokens: Int? + let actualCost: Double? + + private enum CodingKeys: String, CodingKey { + case requests + case totalTokens = "total_tokens" + case actualCost = "actual_cost" + } + } + + let today: Totals? + let total: Totals? + } + + let mode: String? + let isValid: Bool? + let status: String? + let planName: String? + let remaining: Double? + let unit: String? + let balance: Double? + let quota: Quota? + let rateLimits: [RateLimit]? + let subscription: Subscription? + let usage: Usage? + let expiresAt: String? + + private enum CodingKeys: String, CodingKey { + case mode + case isValid + case status + case planName + case remaining + case unit + case balance + case quota + case rateLimits = "rate_limits" + case subscription + case usage + case expiresAt = "expires_at" + } +} + +public struct Sub2APIUsageFetcher: Sendable { + public init() {} + + public static func fetchUsage( + apiKey: String, + baseURL: URL, + transport: any ProviderHTTPTransport = ProviderHTTPClient.shared, + timeout: Duration = .seconds(15), + updatedAt: Date = Date()) async throws -> Sub2APIUsageSnapshot + { + let cleanedAPIKey = apiKey.trimmingCharacters(in: .whitespacesAndNewlines) + guard !cleanedAPIKey.isEmpty else { throw Sub2APIUsageError.missingCredentials } + + var request = URLRequest(url: self.usageRequestURL(baseURL: baseURL)) + request.httpMethod = "GET" + request.setValue("Bearer \(cleanedAPIKey)", forHTTPHeaderField: "Authorization") + request.setValue("application/json", forHTTPHeaderField: "Accept") + request.timeoutInterval = 15 + + let responseTask = Task { + try await transport.response(for: request) + } + let response: ProviderHTTPResponse = switch await BoundedTaskJoin(sourceTask: responseTask) + .value(joinGrace: timeout) + { + case let .value(response): response + case let .failure(error): throw error + case .timedOut: throw URLError(.timedOut) + } + switch response.statusCode { + case 200..<300: + let snapshot = try self.parseSnapshot(data: response.data, updatedAt: updatedAt) + guard snapshot.isValid else { throw Sub2APIUsageError.invalidCredentials } + return snapshot + case 401, 403: + throw Sub2APIUsageError.invalidCredentials + default: + throw Sub2APIUsageError.apiError(response.statusCode) + } + } + + public static func _parseSnapshotForTesting(_ data: Data, updatedAt: Date) throws -> Sub2APIUsageSnapshot { + try self.parseSnapshot(data: data, updatedAt: updatedAt) + } + + public static func _usageURLForTesting(baseURL: URL) -> URL { + self.usageURL(baseURL: baseURL) + } + + private static func usageURL(baseURL: URL) -> URL { + let components = baseURL.path.split(separator: "/") + if components.suffix(2) == ["v1", "usage"] { + return baseURL + } + if components.last == "v1" { + return baseURL.appendingPathComponent("usage") + } + return baseURL.appendingPathComponent("v1/usage") + } + + private static func usageRequestURL(baseURL: URL) -> URL { + let url = self.usageURL(baseURL: baseURL) + guard var components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { return url } + components.queryItems = [ + URLQueryItem(name: "days", value: "30"), + URLQueryItem(name: "timezone", value: TimeZone.current.identifier), + ] + return components.url ?? url + } + + private static func parseSnapshot(data: Data, updatedAt: Date) throws -> Sub2APIUsageSnapshot { + do { + let response = try JSONDecoder().decode(Sub2APIUsageResponse.self, from: data) + let unit = response.unit ?? response.quota?.unit ?? "USD" + return Sub2APIUsageSnapshot( + mode: response.mode ?? "unknown", + isValid: response.isValid ?? true, + status: response.status, + planName: response.planName, + remaining: response.remaining, + unit: unit, + balance: response.balance, + quota: response.quota.map { + Sub2APIUsageSnapshot.Quota( + limit: $0.limit, + used: $0.used, + remaining: $0.remaining, + unit: $0.unit ?? unit) + }, + rateLimits: (response.rateLimits ?? []).map { + Sub2APIUsageSnapshot.RateLimit( + window: $0.window, + limit: $0.limit, + used: $0.used, + remaining: $0.remaining, + resetAt: self.parseDate($0.resetAt)) + }, + subscription: response.subscription.map { + Sub2APIUsageSnapshot.Subscription( + dailyUsageUSD: $0.dailyUsageUSD ?? 0, + weeklyUsageUSD: $0.weeklyUsageUSD ?? 0, + monthlyUsageUSD: $0.monthlyUsageUSD ?? 0, + dailyLimitUSD: $0.dailyLimitUSD, + weeklyLimitUSD: $0.weeklyLimitUSD, + monthlyLimitUSD: $0.monthlyLimitUSD, + expiresAt: self.parseDate($0.expiresAt)) + }, + todayUsage: self.usageTotals(response.usage?.today), + totalUsage: self.usageTotals(response.usage?.total), + expiresAt: self.parseDate(response.expiresAt), + updatedAt: updatedAt) + } catch let error as Sub2APIUsageError { + throw error + } catch { + throw Sub2APIUsageError.parseFailed(error.localizedDescription) + } + } + + private static func usageTotals(_ totals: Sub2APIUsageResponse.Usage.Totals?) + -> Sub2APIUsageSnapshot.UsageTotals? + { + guard let totals else { return nil } + return Sub2APIUsageSnapshot.UsageTotals( + requests: totals.requests ?? 0, + totalTokens: totals.totalTokens ?? 0, + actualCostUSD: totals.actualCost ?? 0) + } + + private static func parseDate(_ raw: String?) -> Date? { + guard let raw else { return nil } + let fractional = ISO8601DateFormatter() + fractional.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return fractional.date(from: raw) ?? ISO8601DateFormatter().date(from: raw) + } +} diff --git a/Sources/CodexBarCore/Providers/Synthetic/SyntheticProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Synthetic/SyntheticProviderDescriptor.swift index b0501408df..f5881ec3a1 100644 --- a/Sources/CodexBarCore/Providers/Synthetic/SyntheticProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Synthetic/SyntheticProviderDescriptor.swift @@ -1,9 +1,8 @@ -import CodexBarMacroSupport import Foundation -@ProviderDescriptorRegistration -@ProviderDescriptorDefinition public enum SyntheticProviderDescriptor { + public static let descriptor: ProviderDescriptor = Self.makeDescriptor() + static func makeDescriptor() -> ProviderDescriptor { ProviderDescriptor( id: .synthetic, @@ -26,43 +25,25 @@ public enum SyntheticProviderDescriptor { branding: ProviderBranding( iconStyle: .synthetic, iconResourceName: "ProviderIcon-synthetic", - color: ProviderColor(red: 20 / 255, green: 20 / 255, blue: 20 / 255)), + color: ProviderColor(red: 20 / 255, green: 20 / 255, blue: 20 / 255), + confettiPalette: [ + ProviderColor(hex: 0x6366F1), + ProviderColor(hex: 0x3E3E3E), + ProviderColor(hex: 0xF7F6F3), + ]), tokenCost: ProviderTokenCostConfig( supportsTokenCost: false, noDataMessage: { "Synthetic cost summary is not supported." }), - fetchPlan: ProviderFetchPlan( - sourceModes: [.auto, .api], - pipeline: ProviderFetchPipeline(resolveStrategies: { _ in [SyntheticAPIFetchStrategy()] })), + fetchPlan: .apiToken( + strategyID: "synthetic.api", + resolveToken: { ProviderTokenResolver.syntheticToken(environment: $0) }, + missingCredentialsError: { SyntheticSettingsError.missingToken }, + loadUsage: { apiKey, _ in + try await SyntheticUsageFetcher.fetchUsage(apiKey: apiKey).toUsageSnapshot() + }), cli: ProviderCLIConfig( name: "synthetic", aliases: ["synthetic.new"], versionDetector: nil)) } } - -struct SyntheticAPIFetchStrategy: ProviderFetchStrategy { - let id: String = "synthetic.api" - let kind: ProviderFetchKind = .apiToken - - func isAvailable(_ context: ProviderFetchContext) async -> Bool { - Self.resolveToken(environment: context.env) != nil - } - - func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult { - guard let apiKey = Self.resolveToken(environment: context.env) else { - throw SyntheticSettingsError.missingToken - } - let usage = try await SyntheticUsageFetcher.fetchUsage(apiKey: apiKey) - return self.makeResult( - usage: usage.toUsageSnapshot(), - sourceLabel: "api") - } - - func shouldFallback(on _: Error, context _: ProviderFetchContext) -> Bool { - false - } - - private static func resolveToken(environment: [String: String]) -> String? { - ProviderTokenResolver.syntheticToken(environment: environment) - } -} diff --git a/Sources/CodexBarCore/Providers/T3Chat/T3ChatProviderDescriptor.swift b/Sources/CodexBarCore/Providers/T3Chat/T3ChatProviderDescriptor.swift index 9317b40736..2ec8762aa6 100644 --- a/Sources/CodexBarCore/Providers/T3Chat/T3ChatProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/T3Chat/T3ChatProviderDescriptor.swift @@ -1,9 +1,8 @@ -import CodexBarMacroSupport import Foundation -@ProviderDescriptorRegistration -@ProviderDescriptorDefinition public enum T3ChatProviderDescriptor { + public static let descriptor: ProviderDescriptor = Self.makeDescriptor() + static func makeDescriptor() -> ProviderDescriptor { ProviderDescriptor( id: .t3chat, @@ -28,7 +27,12 @@ public enum T3ChatProviderDescriptor { branding: ProviderBranding( iconStyle: .t3chat, iconResourceName: "ProviderIcon-t3chat", - color: ProviderColor(red: 245 / 255, green: 102 / 255, blue: 71 / 255)), + color: ProviderColor(red: 245 / 255, green: 102 / 255, blue: 71 / 255), + confettiPalette: [ + ProviderColor(hex: 0x970B72), + ProviderColor(hex: 0xE6229C), + ProviderColor(hex: 0xFEA0F6), + ]), tokenCost: ProviderTokenCostConfig( supportsTokenCost: false, noDataMessage: { "T3 Chat cost summary is not supported." }), diff --git a/Sources/CodexBarCore/Providers/T3Chat/T3ChatUsageFetcher.swift b/Sources/CodexBarCore/Providers/T3Chat/T3ChatUsageFetcher.swift index bddc960e47..53478f6096 100644 --- a/Sources/CodexBarCore/Providers/T3Chat/T3ChatUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/T3Chat/T3ChatUsageFetcher.swift @@ -244,11 +244,11 @@ public struct T3ChatUsageFetcher: Sendable { static func requestContext(from raw: String?) -> RequestContext? { guard let raw = raw?.trimmingCharacters(in: .whitespacesAndNewlines), !raw.isEmpty else { return nil } - let headerFields = Self.headerFields(from: raw) + let headerFields = CurlCaptureParser.headerFields(from: raw) guard let cookieHeader = Self.cookieHeader(from: headerFields) ?? CookieHeaderNormalizer.normalize(raw) else { return nil } - let headers = Self.forwardedHeaders(from: headerFields) + let headers = CurlCaptureParser.forwardedHeaders(from: headerFields, allowlist: self.forwardedManualHeaders) return RequestContext(cookieHeader: cookieHeader, headers: headers) } @@ -268,88 +268,9 @@ public struct T3ChatUsageFetcher: Sendable { request.setValue("no-cache", forHTTPHeaderField: "Cache-Control") } - private static func forwardedHeaders(from fields: [String]) -> [String: String] { - var headers: [String: String] = [:] - for field in fields { - guard let colon = field.firstIndex(of: ":") else { continue } - let rawName = field[.. String? { - for field in fields { - guard let colon = field.firstIndex(of: ":") else { continue } - let rawName = field[.. [String] { - var fields: [String] = [] - let pattern = - #"(?s)(?:^|\s)(?:-H|--header)(?:\s+|=|(?=['"$]))"# + - #"(?:\$'((?:\\.|[^'])*)'|'([^']*)'|"((?:\\.|[^"])*)"|(\S+))"# - guard let regex = try? NSRegularExpression(pattern: pattern, options: []) else { return fields } - let range = NSRange(raw.startIndex.. String? { - guard match.numberOfRanges > index, - let range = Range(match.range(at: index), in: raw) - else { - return nil - } - return String(raw[range]) - } - - private static func unescapeShellSegment(_ raw: String, ansi: Bool) -> String { - var output = "" - var index = raw.startIndex - while index < raw.endIndex { - guard raw[index] == "\\" else { - output.append(raw[index]) - index = raw.index(after: index) - continue - } - let next = raw.index(after: index) - guard next < raw.endIndex else { return output } - switch raw[next] { - case "n" where ansi: - output.append("\n") - case "r" where ansi: - output.append("\r") - case "t" where ansi: - output.append("\t") - case "\n": - break - default: - output.append(raw[next]) - } - index = raw.index(after: next) - } - return output + guard let raw = CurlCaptureParser.headerValue(named: "Cookie", in: fields) else { return nil } + return CookieHeaderNormalizer.normalize(raw) } private static func customerDataURL() throws -> URL { diff --git a/Sources/CodexBarCore/Providers/Venice/VeniceProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Venice/VeniceProviderDescriptor.swift index b44db67cdb..08383155f2 100644 --- a/Sources/CodexBarCore/Providers/Venice/VeniceProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Venice/VeniceProviderDescriptor.swift @@ -1,9 +1,8 @@ -import CodexBarMacroSupport import Foundation -@ProviderDescriptorRegistration -@ProviderDescriptorDefinition public enum VeniceProviderDescriptor { + public static let descriptor: ProviderDescriptor = Self.makeDescriptor() + static func makeDescriptor() -> ProviderDescriptor { ProviderDescriptor( id: .venice, @@ -28,43 +27,25 @@ public enum VeniceProviderDescriptor { branding: ProviderBranding( iconStyle: .venice, iconResourceName: "ProviderIcon-venice", - color: ProviderColor(red: 0.2, green: 0.6, blue: 1.0)), + color: ProviderColor(red: 0.2, green: 0.6, blue: 1.0), + confettiPalette: [ + ProviderColor(hex: 0x0E2942), + ProviderColor(hex: 0xF7F5ED), + ProviderColor(hex: 0x3C8FDD), + ]), tokenCost: ProviderTokenCostConfig( supportsTokenCost: false, noDataMessage: { "Venice per-day cost history is not available via API." }), - fetchPlan: ProviderFetchPlan( - sourceModes: [.auto, .api], - pipeline: ProviderFetchPipeline(resolveStrategies: { _ in [VeniceAPIFetchStrategy()] })), + fetchPlan: .apiToken( + strategyID: "venice.api", + resolveToken: { ProviderTokenResolver.veniceToken(environment: $0) }, + missingCredentialsError: { VeniceUsageError.missingCredentials }, + loadUsage: { apiKey, _ in + try await VeniceUsageFetcher.fetchUsage(apiKey: apiKey).toUsageSnapshot() + }), cli: ProviderCLIConfig( name: "venice", aliases: ["ven"], versionDetector: nil)) } } - -struct VeniceAPIFetchStrategy: ProviderFetchStrategy { - let id: String = "venice.api" - let kind: ProviderFetchKind = .apiToken - - func isAvailable(_ context: ProviderFetchContext) async -> Bool { - Self.resolveToken(environment: context.env) != nil - } - - func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult { - guard let apiKey = Self.resolveToken(environment: context.env) else { - throw VeniceUsageError.missingCredentials - } - let usage = try await VeniceUsageFetcher.fetchUsage(apiKey: apiKey) - return self.makeResult( - usage: usage.toUsageSnapshot(), - sourceLabel: "api") - } - - func shouldFallback(on _: Error, context _: ProviderFetchContext) -> Bool { - false - } - - private static func resolveToken(environment: [String: String]) -> String? { - ProviderTokenResolver.veniceToken(environment: environment) - } -} diff --git a/Sources/CodexBarCore/Providers/VertexAI/VertexAIProviderDescriptor.swift b/Sources/CodexBarCore/Providers/VertexAI/VertexAIProviderDescriptor.swift index 715aac4446..9b4e66efaf 100644 --- a/Sources/CodexBarCore/Providers/VertexAI/VertexAIProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/VertexAI/VertexAIProviderDescriptor.swift @@ -1,9 +1,8 @@ -import CodexBarMacroSupport import Foundation -@ProviderDescriptorRegistration -@ProviderDescriptorDefinition public enum VertexAIProviderDescriptor { + public static let descriptor: ProviderDescriptor = Self.makeDescriptor() + static func makeDescriptor() -> ProviderDescriptor { ProviderDescriptor( id: .vertexai, @@ -27,7 +26,12 @@ public enum VertexAIProviderDescriptor { branding: ProviderBranding( iconStyle: .vertexai, iconResourceName: "ProviderIcon-vertexai", - color: ProviderColor(red: 66 / 255, green: 133 / 255, blue: 244 / 255)), + color: ProviderColor(red: 66 / 255, green: 133 / 255, blue: 244 / 255), + confettiPalette: [ + ProviderColor(hex: 0x4285F4), + ProviderColor(hex: 0xEA4335), + ProviderColor(hex: 0xFBBC04), + ]), tokenCost: ProviderTokenCostConfig( supportsTokenCost: true, noDataMessage: { "No Vertex AI cost data found in Claude logs. Ensure entries include Vertex metadata." diff --git a/Sources/CodexBarCore/Providers/Warp/WarpProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Warp/WarpProviderDescriptor.swift index 29506321ca..924b981a11 100644 --- a/Sources/CodexBarCore/Providers/Warp/WarpProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Warp/WarpProviderDescriptor.swift @@ -1,9 +1,8 @@ -import CodexBarMacroSupport import Foundation -@ProviderDescriptorRegistration -@ProviderDescriptorDefinition public enum WarpProviderDescriptor { + public static let descriptor: ProviderDescriptor = Self.makeDescriptor() + static func makeDescriptor() -> ProviderDescriptor { ProviderDescriptor( id: .warp, @@ -27,43 +26,25 @@ public enum WarpProviderDescriptor { branding: ProviderBranding( iconStyle: .warp, iconResourceName: "ProviderIcon-warp", - color: ProviderColor(red: 147 / 255, green: 139 / 255, blue: 180 / 255)), + color: ProviderColor(red: 147 / 255, green: 139 / 255, blue: 180 / 255), + confettiPalette: [ + ProviderColor(hex: 0xC7AEFF), + ProviderColor(hex: 0x1C1A26), + ProviderColor(hex: 0xFFFFFF), + ]), tokenCost: ProviderTokenCostConfig( supportsTokenCost: false, noDataMessage: { "Warp cost summary is not available." }), - fetchPlan: ProviderFetchPlan( - sourceModes: [.auto, .api], - pipeline: ProviderFetchPipeline(resolveStrategies: { _ in [WarpAPIFetchStrategy()] })), + fetchPlan: .apiToken( + strategyID: "warp.api", + resolveToken: { ProviderTokenResolver.warpToken(environment: $0) }, + missingCredentialsError: { WarpUsageError.missingCredentials }, + loadUsage: { apiKey, _ in + try await WarpUsageFetcher.fetchUsage(apiKey: apiKey).toUsageSnapshot() + }), cli: ProviderCLIConfig( name: "warp", aliases: ["warp-ai", "warp-terminal"], versionDetector: nil)) } } - -struct WarpAPIFetchStrategy: ProviderFetchStrategy { - let id: String = "warp.api" - let kind: ProviderFetchKind = .apiToken - - func isAvailable(_ context: ProviderFetchContext) async -> Bool { - Self.resolveToken(environment: context.env) != nil - } - - func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult { - guard let apiKey = Self.resolveToken(environment: context.env) else { - throw WarpUsageError.missingCredentials - } - let usage = try await WarpUsageFetcher.fetchUsage(apiKey: apiKey) - return self.makeResult( - usage: usage.toUsageSnapshot(), - sourceLabel: "api") - } - - func shouldFallback(on _: Error, context _: ProviderFetchContext) -> Bool { - false - } - - private static func resolveToken(environment: [String: String]) -> String? { - ProviderTokenResolver.warpToken(environment: environment) - } -} diff --git a/Sources/CodexBarCore/Providers/Wayfinder/WayfinderProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Wayfinder/WayfinderProviderDescriptor.swift new file mode 100644 index 0000000000..f7322abdc9 --- /dev/null +++ b/Sources/CodexBarCore/Providers/Wayfinder/WayfinderProviderDescriptor.swift @@ -0,0 +1,65 @@ +import Foundation + +public enum WayfinderProviderDescriptor { + public static let descriptor: ProviderDescriptor = Self.makeDescriptor() + + static func makeDescriptor() -> ProviderDescriptor { + ProviderDescriptor( + id: .wayfinder, + metadata: ProviderMetadata( + id: .wayfinder, + displayName: "Wayfinder", + sessionLabel: "Savings", + weeklyLabel: "Requests", + opusLabel: nil, + supportsOpus: false, + supportsCredits: false, + creditsHint: "", + toggleTitle: "Show Wayfinder usage", + cliName: "wayfinder", + defaultEnabled: false, + dashboardURL: WayfinderSettingsReader.dashboardURL(environment: [:]).absoluteString, + statusPageURL: nil), + branding: ProviderBranding( + iconStyle: .wayfinder, + iconResourceName: "ProviderIcon-wayfinder", + color: ProviderColor(red: 16 / 255, green: 163 / 255, blue: 127 / 255), + confettiPalette: [ + ProviderColor(hex: 0x10A37F), + ProviderColor(hex: 0xBD6A13), + ProviderColor(hex: 0x0D0D0D), + ]), + tokenCost: ProviderTokenCostConfig( + supportsTokenCost: false, + noDataMessage: { "Wayfinder savings are reported by its local gateway." }), + fetchPlan: ProviderFetchPlan( + sourceModes: [.auto, .api], + pipeline: ProviderFetchPipeline(resolveStrategies: { _ in [WayfinderGatewayFetchStrategy()] })), + cli: ProviderCLIConfig( + name: "wayfinder", + aliases: ["wayfinder-router"], + versionDetector: nil)) + } +} + +struct WayfinderGatewayFetchStrategy: ProviderFetchStrategy { + let id = "wayfinder.api" + let kind: ProviderFetchKind = .apiToken + + func isAvailable(_: ProviderFetchContext) async -> Bool { + // The gateway's read-only endpoints are unauthenticated; the provider is + // opt-in (defaultEnabled: false), so no credential gates availability. + true + } + + func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult { + try WayfinderSettingsReader.validateEndpointOverride(environment: context.env) + let usage = try await WayfinderUsageFetcher.fetchUsage( + baseURL: WayfinderSettingsReader.baseURL(environment: context.env)) + return self.makeResult(usage: usage.toUsageSnapshot(), sourceLabel: "api") + } + + func shouldFallback(on _: Error, context _: ProviderFetchContext) -> Bool { + false + } +} diff --git a/Sources/CodexBarCore/Providers/Wayfinder/WayfinderSettingsReader.swift b/Sources/CodexBarCore/Providers/Wayfinder/WayfinderSettingsReader.swift new file mode 100644 index 0000000000..fd5b7289da --- /dev/null +++ b/Sources/CodexBarCore/Providers/Wayfinder/WayfinderSettingsReader.swift @@ -0,0 +1,66 @@ +import Foundation + +public enum WayfinderSettingsError: LocalizedError, Equatable, Sendable { + case invalidEndpointOverride(String) + + public var errorDescription: String? { + switch self { + case let .invalidEndpointOverride(key): + "Wayfinder gateway URL override \(key) is invalid. Use an HTTPS URL, or plain HTTP for " + + "loopback addresses only, without embedded credentials." + } + } +} + +public enum WayfinderSettingsReader { + public static let baseURLEnvironmentKey = "WAYFINDER_GATEWAY_URL" + public static let defaultBaseURL = URL(string: "http://127.0.0.1:8088")! + + public static func baseURL( + environment: [String: String] = ProcessInfo.processInfo.environment) -> URL + { + guard let raw = self.cleaned(environment[self.baseURLEnvironmentKey]) else { + return self.defaultBaseURL + } + // Loopback HTTP is allowed because the gateway is a local service; the default + // base URL is plain HTTP on 127.0.0.1. Non-loopback hosts must use HTTPS. + return ProviderEndpointOverrideValidator().validatedURLAllowingLoopbackHTTP(raw) ?? self.defaultBaseURL + } + + public static func validateEndpointOverride( + environment: [String: String] = ProcessInfo.processInfo.environment) throws + { + guard let raw = self.cleaned(environment[self.baseURLEnvironmentKey]) else { return } + guard ProviderEndpointOverrideValidator().validatedURLAllowingLoopbackHTTP(raw) != nil else { + throw WayfinderSettingsError.invalidEndpointOverride(self.baseURLEnvironmentKey) + } + } + + public static func dashboardURL( + environment: [String: String] = ProcessInfo.processInfo.environment) -> URL + { + self.appending(path: "router", to: self.baseURL(environment: environment)) + } + + static func appending(path: String, to baseURL: URL) -> URL { + var components = URLComponents(url: baseURL, resolvingAgainstBaseURL: false) ?? URLComponents() + let basePath = components.path.hasSuffix("/") ? String(components.path.dropLast()) : components.path + components.path = "\(basePath)/\(path)" + components.query = nil + components.fragment = nil + return components.url ?? baseURL + } + + static func cleaned(_ raw: String?) -> String? { + guard var value = raw?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty else { + return nil + } + if (value.hasPrefix("\"") && value.hasSuffix("\"")) || + (value.hasPrefix("'") && value.hasSuffix("'")) + { + value = String(value.dropFirst().dropLast()) + } + value = value.trimmingCharacters(in: .whitespacesAndNewlines) + return value.isEmpty ? nil : value + } +} diff --git a/Sources/CodexBarCore/Providers/Wayfinder/WayfinderUsageFetcher.swift b/Sources/CodexBarCore/Providers/Wayfinder/WayfinderUsageFetcher.swift new file mode 100644 index 0000000000..8132186f78 --- /dev/null +++ b/Sources/CodexBarCore/Providers/Wayfinder/WayfinderUsageFetcher.swift @@ -0,0 +1,465 @@ +import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +public enum WayfinderUsageError: LocalizedError, Equatable, Sendable { + case gatewayUnreachable + case apiError(Int) + case parseFailed(String) + case unexpectedRedirect + + public var errorDescription: String? { + switch self { + case .gatewayUnreachable: + "Could not reach the Wayfinder gateway. Start it with `wayfinder-router serve` " + + "(default http://127.0.0.1:8088) or fix the Gateway URL in Settings." + case let .apiError(statusCode): + "Wayfinder gateway returned HTTP \(statusCode)." + case let .parseFailed(message): + "Could not parse Wayfinder gateway response: \(message)" + case .unexpectedRedirect: + "Wayfinder gateway request was redirected to a different origin." + } + } +} + +public struct WayfinderUsageSnapshot: Codable, Sendable, Equatable { + public struct RouteSummary: Codable, Sendable, Equatable { + public let name: String + public let requests: Int + public let saved: Double + public let tokens: Int + + public init(name: String, requests: Int, saved: Double, tokens: Int) { + self.name = name + self.requests = requests + self.saved = saved + self.tokens = tokens + } + } + + public let gatewayStatus: String + public let offline: Bool + public let dryRun: Bool + public let missingKeys: [String] + public let modelCount: Int + public let requests: Int + public let tokens: Int + public let realized: Double + public let baseline: Double + public let saved: Double + public let savedPct: Double + public let priced: Bool + public let routes: [RouteSummary] + public let avgDecisionMs: Double? + public let updatedAt: Date + + public init( + gatewayStatus: String, + offline: Bool, + dryRun: Bool, + missingKeys: [String], + modelCount: Int, + requests: Int, + tokens: Int, + realized: Double, + baseline: Double, + saved: Double, + savedPct: Double, + priced: Bool, + routes: [RouteSummary], + avgDecisionMs: Double?, + updatedAt: Date) + { + self.gatewayStatus = gatewayStatus + self.offline = offline + self.dryRun = dryRun + self.missingKeys = missingKeys + self.modelCount = modelCount + self.requests = requests + self.tokens = tokens + self.realized = realized + self.baseline = baseline + self.saved = saved + self.savedPct = savedPct + self.priced = priced + self.routes = routes + self.avgDecisionMs = avgDecisionMs + self.updatedAt = updatedAt + } + + public var statusLabel: String { + if self.offline { + return "Offline mode" + } + if self.dryRun { + return "Dry run" + } + if self.gatewayStatus == "degraded" { + let count = self.missingKeys.count + guard count > 0 else { return "Degraded" } + return count == 1 ? "Degraded — 1 key missing" : "Degraded — \(count) keys missing" + } + return "Local gateway" + } + + public var modelCountLabel: String { + self.modelCount == 1 ? "1 model" : "\(self.modelCount) models" + } + + public var gatewaySummary: String { + var summary = "\(self.gatewayStatus) · \(self.modelCountLabel)" + if self.offline { + summary += " · offline" + } + if self.dryRun { + summary += " · dry run" + } + return summary + } + + public var displayLines: [String] { + var lines = ["Gateway: \(self.gatewaySummary)"] + if let routed = self.routedSummary { + lines.append("Routed: \(routed)") + } + if let saved = self.savedSummary { + lines.append("Saved: \(saved)") + } + if let avgDecision = self.avgDecisionSummary { + lines.append("Avg decision: \(avgDecision)") + } + return lines + } + + /// "local: 10 · cloud: 4" — the gateway's own configured route names, not a guessed + /// local/cloud split: `/router/models` has no field asserting which tier is "local", and + /// route names are whatever the user named their endpoints in the Wayfinder config. + /// nil until the gateway has routed anything in the period. + public var routedSummary: String? { + guard self.requests > 0 else { return nil } + let mix = self.routes.prefix(5) + .map { "\($0.name): \(UsageFormatter.tokenCountString($0.requests))" } + .joined(separator: " · ") + return mix.isEmpty ? nil : mix + } + + /// "$4.12 · 38.2% vs highest-cost route" when priced; percent-only otherwise. + /// Savings in relative (unpriced) units are never rendered as dollars. + public var savedSummary: String? { + guard self.requests > 0, self.saved > 0 else { return nil } + let pct = "\(Self.percentText(self.savedPct))% vs highest-cost route" + guard self.priced else { return pct } + let amount = self.saved < 0.01 + ? "<$0.01" + : UsageFormatter.currencyString(self.saved, currencyCode: "USD") + return "\(amount) · \(pct)" + } + + public var avgDecisionSummary: String? { + guard let ms = self.avgDecisionMs else { return nil } + return String(format: "%.1f ms", ms) + } + + private static func percentText(_ value: Double) -> String { + value == value.rounded() + ? String(format: "%.0f", value) + : String(format: "%.1f", value) + } + + public func toUsageSnapshot() -> UsageSnapshot { + // No rate window and no providerCost: the gateway has no quota semantics, and + // sub-cent realized spend would render as a meaningless cost meter. Savings are + // surfaced through the dedicated Wayfinder lines instead. + UsageSnapshot( + primary: nil, + secondary: nil, + providerCost: nil, + wayfinderUsage: self, + updatedAt: self.updatedAt, + identity: ProviderIdentitySnapshot( + providerID: .wayfinder, + accountEmail: nil, + accountOrganization: "\(self.modelCountLabel) · local gateway", + loginMethod: self.statusLabel), + dataConfidence: .exact) + } +} + +private struct WayfinderHealthResponse: Decodable { + let status: String + let offline: Bool + let missingKeys: [String]? + + enum CodingKeys: String, CodingKey { + case status + case offline + case missingKeys = "missing_keys" + } +} + +private struct WayfinderModelsResponse: Decodable { + struct Model: Decodable { + let name: String + } + + let models: [Model] + let dryRun: Bool + + enum CodingKeys: String, CodingKey { + case models + case dryRun = "dry_run" + } +} + +private struct WayfinderSavingsResponse: Decodable { + struct RouteBucket: Decodable { + let requests: Int + let saved: Double + let tokens: Int + } + + let priced: Bool + let requests: Int + let tokens: Int + let realized: Double + let baseline: Double + let saved: Double + let savedPct: Double + let byRoute: [String: RouteBucket] + + enum CodingKeys: String, CodingKey { + case priced + case requests + case tokens + case realized + case baseline + case saved + case savedPct = "saved_pct" + case byRoute = "by_route" + } +} + +public enum WayfinderUsageFetcher { + /// Savings window mirrored in the "Last 30 days" period label of `toUsageSnapshot()`. + static let savingsPeriod = "30d" + static let decisionLatencyMetric = "wayfinder_router_decision_latency_seconds" + + public static func fetchUsage( + baseURL: URL, + updatedAt: Date = Date()) async throws -> WayfinderUsageSnapshot + { + try await self.fetchUsage( + baseURL: baseURL, + transport: self.isolatedTransport, + updatedAt: updatedAt) + } + + public static func fetchUsage( + baseURL: URL, + transport: any ProviderHTTPTransport, + updatedAt: Date = Date()) async throws -> WayfinderUsageSnapshot + { + let healthData = try await self.get(path: "healthz", baseURL: baseURL, transport: transport) + let modelsData = try await self.get(path: "router/models", baseURL: baseURL, transport: transport) + let savingsData = try await self.get( + path: "v1/savings", + queryItems: [URLQueryItem(name: "period", value: self.savingsPeriod)], + baseURL: baseURL, + transport: transport) + // Latency is best-effort: the snapshot must never fail because /metrics is unavailable. + // Cancellation is control flow, though, and must still stop the whole refresh. + let metricsData: Data? + do { + metricsData = try await self.get(path: "metrics", baseURL: baseURL, transport: transport) + } catch { + if self.isCancellation(error) { + throw CancellationError() + } + metricsData = nil + } + + return try self.makeSnapshot( + healthData: healthData, + modelsData: modelsData, + savingsData: savingsData, + metricsText: metricsData.flatMap { String(data: $0, encoding: .utf8) }, + updatedAt: updatedAt) + } + + public static func _makeSnapshotForTesting( + healthData: Data, + modelsData: Data, + savingsData: Data, + metricsText: String?, + updatedAt: Date) throws -> WayfinderUsageSnapshot + { + try self.makeSnapshot( + healthData: healthData, + modelsData: modelsData, + savingsData: savingsData, + metricsText: metricsText, + updatedAt: updatedAt) + } + + public static func _averageDecisionMillisecondsForTesting(_ text: String) -> Double? { + self.averageDecisionMilliseconds(fromPrometheusText: text) + } + + private static func makeSnapshot( + healthData: Data, + modelsData: Data, + savingsData: Data, + metricsText: String?, + updatedAt: Date) throws -> WayfinderUsageSnapshot + { + let health = try self.parseHealth(data: healthData) + let models = try self.parseModels(data: modelsData) + let savings = try self.parseSavings(data: savingsData) + let avgDecisionMs = metricsText.flatMap { self.averageDecisionMilliseconds(fromPrometheusText: $0) } + + return WayfinderUsageSnapshot( + gatewayStatus: health.status, + offline: health.offline, + dryRun: models.dryRun, + missingKeys: health.missingKeys ?? [], + modelCount: models.models.count, + requests: savings.requests, + tokens: savings.tokens, + realized: savings.realized, + baseline: savings.baseline, + saved: savings.saved, + savedPct: savings.savedPct, + priced: savings.priced, + routes: savings.byRoute.map { name, bucket in + WayfinderUsageSnapshot.RouteSummary( + name: name, + requests: bucket.requests, + saved: bucket.saved, + tokens: bucket.tokens) + }.sorted { + if $0.requests != $1.requests { + return $0.requests > $1.requests + } + return $0.name < $1.name + }, + avgDecisionMs: avgDecisionMs, + updatedAt: updatedAt) + } + + public static func _endpointURLForTesting(baseURL: URL, path: String) -> URL { + self.endpointURL(baseURL: baseURL, path: path, queryItems: []) + } + + private static func get( + path: String, + queryItems: [URLQueryItem] = [], + baseURL: URL, + transport: any ProviderHTTPTransport) async throws -> Data + { + var request = URLRequest(url: self.endpointURL(baseURL: baseURL, path: path, queryItems: queryItems)) + request.httpMethod = "GET" + request.timeoutInterval = 5 + + let response: ProviderHTTPResponse + do { + response = try await transport.response(for: request) + } catch { + if self.isCancellation(error) { + throw CancellationError() + } + throw WayfinderUsageError.gatewayUnreachable + } + try self.validateSameOrigin(response: response, request: request) + guard (200..<300).contains(response.statusCode) else { + throw WayfinderUsageError.apiError(response.statusCode) + } + return response.data + } + + private static func endpointURL(baseURL: URL, path: String, queryItems: [URLQueryItem]) -> URL { + var components = URLComponents(url: baseURL, resolvingAgainstBaseURL: false) ?? URLComponents() + let basePath = components.path.hasSuffix("/") ? String(components.path.dropLast()) : components.path + components.path = "\(basePath)/\(path)" + components.queryItems = queryItems.isEmpty ? nil : queryItems + return components.url ?? baseURL + } + + private static func validateSameOrigin(response: ProviderHTTPResponse, request: URLRequest) throws { + guard let requestURL = request.url, + let responseURL = response.response.url, + requestURL.scheme?.lowercased() == responseURL.scheme?.lowercased(), + requestURL.host?.lowercased() == responseURL.host?.lowercased(), + self.effectivePort(for: requestURL) == self.effectivePort(for: responseURL) + else { + throw WayfinderUsageError.unexpectedRedirect + } + } + + private static func effectivePort(for url: URL) -> Int? { + if let port = url.port { + return port + } + switch url.scheme?.lowercased() { + case "https": return 443 + case "http": return 80 + default: return nil + } + } + + private static let isolatedTransport: any ProviderHTTPTransport = { + let configuration = URLSessionConfiguration.ephemeral + configuration.httpCookieStorage = nil + configuration.httpShouldSetCookies = false + configuration.urlCache = nil + configuration.requestCachePolicy = .reloadIgnoringLocalCacheData + return ProviderHTTPClient(session: ProviderHTTPClient.redirectGuardedSession(configuration: configuration)) + }() + + private static func isCancellation(_ error: Error) -> Bool { + error is CancellationError || (error as? URLError)?.code == .cancelled || Task.isCancelled + } + + private static func parseHealth(data: Data) throws -> WayfinderHealthResponse { + try self.decode(WayfinderHealthResponse.self, from: data, endpoint: "/healthz") + } + + private static func parseModels(data: Data) throws -> WayfinderModelsResponse { + try self.decode(WayfinderModelsResponse.self, from: data, endpoint: "/router/models") + } + + private static func parseSavings(data: Data) throws -> WayfinderSavingsResponse { + try self.decode(WayfinderSavingsResponse.self, from: data, endpoint: "/v1/savings") + } + + private static func decode(_ type: T.Type, from data: Data, endpoint: String) throws -> T { + do { + return try JSONDecoder().decode(type, from: data) + } catch { + throw WayfinderUsageError.parseFailed("\(endpoint): \(error.localizedDescription)") + } + } + + private static func averageDecisionMilliseconds(fromPrometheusText text: String) -> Double? { + var sum: Double? + var count: Double? + for line in text.split(separator: "\n") { + if let value = self.metricValue(line: line, name: "\(self.decisionLatencyMetric)_sum") { + sum = value + } else if let value = self.metricValue(line: line, name: "\(self.decisionLatencyMetric)_count") { + count = value + } + } + guard let sum, let count, count > 0 else { return nil } + return sum / count * 1000 + } + + private static func metricValue(line: Substring, name: String) -> Double? { + guard line.hasPrefix(name) else { return nil } + let rest = line.dropFirst(name.count) + guard let first = rest.first, first == " " || first == "{" else { return nil } + guard let valueToken = rest.split(separator: " ").last else { return nil } + return Double(valueToken) + } +} diff --git a/Sources/CodexBarCore/Providers/Windsurf/WindsurfDevinSessionImporter.swift b/Sources/CodexBarCore/Providers/Windsurf/WindsurfDevinSessionImporter.swift index 47a3504ee3..64597cfd79 100644 --- a/Sources/CodexBarCore/Providers/Windsurf/WindsurfDevinSessionImporter.swift +++ b/Sources/CodexBarCore/Providers/Windsurf/WindsurfDevinSessionImporter.swift @@ -5,12 +5,50 @@ import SweetCookieKit #if os(macOS) enum WindsurfDevinSessionImporter { - nonisolated(unsafe) static var importSessionsOverrideForTesting: - ((BrowserDetection, ((String) -> Void)?) -> [SessionInfo])? - nonisolated(unsafe) static var importPreferredSessionsOverrideForTesting: - ((BrowserDetection, ((String) -> Void)?) -> [SessionInfo])? - nonisolated(unsafe) static var importFallbackSessionsOverrideForTesting: - ((BrowserDetection, ((String) -> Void)?) -> [SessionInfo])? + #if DEBUG + final class ImportSessionsOverrideStore: @unchecked Sendable { + let importSessions: (BrowserDetection, ((String) -> Void)?) -> [SessionInfo] + + init(importSessions: @escaping (BrowserDetection, ((String) -> Void)?) -> [SessionInfo]) { + self.importSessions = importSessions + } + } + + @TaskLocal private static var taskImportSessionsOverrideStore: ImportSessionsOverrideStore? + @TaskLocal private static var taskImportPreferredSessionsOverrideStore: ImportSessionsOverrideStore? + @TaskLocal private static var taskImportFallbackSessionsOverrideStore: ImportSessionsOverrideStore? + + static func withImportSessionsOverrideForTesting( + _ override: ((BrowserDetection, ((String) -> Void)?) -> [SessionInfo])?, + operation: () async throws -> T) async rethrows -> T + { + try await self.$taskImportSessionsOverrideStore.withValue(override.map(ImportSessionsOverrideStore.init)) { + try await operation() + } + } + + static func withImportPreferredSessionsOverrideForTesting( + _ override: ((BrowserDetection, ((String) -> Void)?) -> [SessionInfo])?, + operation: () async throws -> T) async rethrows -> T + { + try await self.$taskImportPreferredSessionsOverrideStore.withValue( + override.map(ImportSessionsOverrideStore.init)) + { + try await operation() + } + } + + static func withImportFallbackSessionsOverrideForTesting( + _ override: ((BrowserDetection, ((String) -> Void)?) -> [SessionInfo])?, + operation: () async throws -> T) async rethrows -> T + { + try await self.$taskImportFallbackSessionsOverrideStore.withValue( + override.map(ImportSessionsOverrideStore.init)) + { + try await operation() + } + } + #endif static let defaultPreferredBrowsers: [Browser] = [.chrome] static let fallbackBrowsers: [Browser] = [ .chromeBeta, @@ -40,9 +78,11 @@ enum WindsurfDevinSessionImporter { browserDetection: BrowserDetection, logger: ((String) -> Void)? = nil) -> [SessionInfo] { - if let override = self.importSessionsOverrideForTesting { + #if DEBUG + if let override = self.taskImportSessionsOverrideStore?.importSessions { return override(browserDetection, logger) } + #endif let log: (String) -> Void = { msg in logger?("[windsurf-storage] \(msg)") } let preferredSessions = self.importSessions( @@ -70,9 +110,11 @@ enum WindsurfDevinSessionImporter { browserDetection: BrowserDetection, logger: ((String) -> Void)? = nil) -> [SessionInfo] { - if let override = self.importPreferredSessionsOverrideForTesting { + #if DEBUG + if let override = self.taskImportPreferredSessionsOverrideStore?.importSessions { return override(browserDetection, logger) } + #endif let log: (String) -> Void = { msg in logger?("[windsurf-storage] \(msg)") } return self.importSessions( browserDetection: browserDetection, @@ -84,9 +126,11 @@ enum WindsurfDevinSessionImporter { browserDetection: BrowserDetection, logger: ((String) -> Void)? = nil) -> [SessionInfo] { - if let override = self.importFallbackSessionsOverrideForTesting { + #if DEBUG + if let override = self.taskImportFallbackSessionsOverrideStore?.importSessions { return override(browserDetection, logger) } + #endif let log: (String) -> Void = { msg in logger?("[windsurf-storage] \(msg)") } return self.importSessions( browserDetection: browserDetection, @@ -148,6 +192,20 @@ enum WindsurfDevinSessionImporter { let url: URL } + struct LocalStorageSnapshot: Equatable { + let storage: [String: String] + let sourceSuffix: String? + } + + typealias LocalStorageOriginEntries = ( + origin: URL, + entries: [SweetCookieKit.ChromiumLocalStorageEntry]) + + static let localStorageOrigins = [ + URL(string: "https://app.devin.ai")!, + URL(string: "https://windsurf.com")!, + ] + private static func importSessions( browserDetection: BrowserDetection, browsers: [Browser], @@ -162,10 +220,13 @@ enum WindsurfDevinSessionImporter { } for candidate in candidates { - let storage = self.readLocalStorage(from: candidate.url, logger: logger) - guard let session = self.session(from: storage, sourceLabel: candidate.label) else { continue } - logger("Found Windsurf devin session in \(candidate.label)") - sessions.append(session) + let snapshots = self.readLocalStorageSnapshots(from: candidate.url, logger: logger) + for snapshot in snapshots { + let sourceLabel = self.sourceLabel(candidate.label, suffix: snapshot.sourceSuffix) + guard let session = self.session(from: snapshot.storage, sourceLabel: sourceLabel) else { continue } + logger("Found Windsurf devin session in \(sourceLabel)") + sessions.append(session) + } } return self.deduplicateSessions(sessions) @@ -213,30 +274,62 @@ enum WindsurfDevinSessionImporter { } } - private static func readLocalStorage( + private static func readLocalStorageSnapshots( from levelDBURL: URL, - logger: ((String) -> Void)? = nil) -> [String: String] + logger: ((String) -> Void)? = nil) -> [LocalStorageSnapshot] { - var storage: [String: String] = [:] + let originEntries = Self.localStorageOrigins.map { origin in + let entries = SweetCookieKit.ChromiumLocalStorageReader.readEntries( + for: origin.absoluteString, + in: levelDBURL, + logger: logger) + return (origin: origin, entries: entries) + } - let entries = SweetCookieKit.ChromiumLocalStorageReader.readEntries( - for: "https://windsurf.com", + let textEntries = SweetCookieKit.ChromiumLocalStorageReader.readTextEntries( in: levelDBURL, logger: logger) + return self.localStorageSnapshots(from: originEntries, textEntries: textEntries) + } - for entry in entries where Self.targetKeys.contains(entry.key) { - storage[entry.key] = self.decodedStorageValue(entry.value) + static func localStorageSnapshots( + from originEntries: [LocalStorageOriginEntries], + textEntries: [SweetCookieKit.ChromiumLevelDBTextEntry]) -> [LocalStorageSnapshot] + { + var snapshots = self.localStorageSnapshots(from: originEntries) + let textStorage = self.storage(from: textEntries) + if textStorage.count == Self.targetKeys.count { + snapshots.append(LocalStorageSnapshot(storage: textStorage, sourceSuffix: nil)) } - if storage.count == Self.targetKeys.count { - return storage + return snapshots + } + + static func localStorageSnapshots(from originEntries: [LocalStorageOriginEntries]) -> [LocalStorageSnapshot] { + originEntries.compactMap { originEntry in + let storage = self.storage(from: originEntry.entries) + guard storage.count == Self.targetKeys.count else { return nil } + return LocalStorageSnapshot( + storage: storage, + sourceSuffix: originEntry.origin.host ?? originEntry.origin.absoluteString) } + } - let textEntries = SweetCookieKit.ChromiumLocalStorageReader.readTextEntries( - in: levelDBURL, - logger: logger) + private static func storage( + from entries: [SweetCookieKit.ChromiumLocalStorageEntry]) -> [String: String] + { + var storage: [String: String] = [:] + for entry in entries where storage[entry.key] == nil && Self.targetKeys.contains(entry.key) { + storage[entry.key] = self.decodedStorageValue(entry.value) + } + return storage + } - for entry in textEntries { + private static func storage( + from entries: [SweetCookieKit.ChromiumLevelDBTextEntry]) -> [String: String] + { + var storage: [String: String] = [:] + for entry in entries { guard storage[entry.key] == nil, Self.targetKeys.contains(entry.key) else { continue } storage[entry.key] = self.decodedStorageValue(entry.value) } @@ -244,6 +337,11 @@ enum WindsurfDevinSessionImporter { return storage } + private static func sourceLabel(_ label: String, suffix: String?) -> String { + guard let suffix else { return label } + return "\(label) (\(suffix))" + } + private static let targetKeys: Set = [ "devin_session_token", "devin_auth1_token", diff --git a/Sources/CodexBarCore/Providers/Windsurf/WindsurfProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Windsurf/WindsurfProviderDescriptor.swift index 377fbc398a..050426377d 100644 --- a/Sources/CodexBarCore/Providers/Windsurf/WindsurfProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Windsurf/WindsurfProviderDescriptor.swift @@ -1,9 +1,8 @@ -import CodexBarMacroSupport import Foundation -@ProviderDescriptorRegistration -@ProviderDescriptorDefinition public enum WindsurfProviderDescriptor { + public static let descriptor: ProviderDescriptor = Self.makeDescriptor() + static func makeDescriptor() -> ProviderDescriptor { ProviderDescriptor( id: .windsurf, @@ -26,7 +25,12 @@ public enum WindsurfProviderDescriptor { branding: ProviderBranding( iconStyle: .windsurf, iconResourceName: "ProviderIcon-windsurf", - color: ProviderColor(red: 52 / 255, green: 232 / 255, blue: 187 / 255)), + color: ProviderColor(red: 52 / 255, green: 232 / 255, blue: 187 / 255), + confettiPalette: [ + ProviderColor(hex: 0x000000), + ProviderColor(hex: 0x09B6A2), + ProviderColor(hex: 0x34E8BB), + ]), tokenCost: ProviderTokenCostConfig( supportsTokenCost: false, noDataMessage: { "Windsurf cost summary is not supported." }), diff --git a/Sources/CodexBarCore/Providers/Windsurf/WindsurfStatusProbe.swift b/Sources/CodexBarCore/Providers/Windsurf/WindsurfStatusProbe.swift index 73f863ec8e..09a87594d5 100644 --- a/Sources/CodexBarCore/Providers/Windsurf/WindsurfStatusProbe.swift +++ b/Sources/CodexBarCore/Providers/Windsurf/WindsurfStatusProbe.swift @@ -259,16 +259,15 @@ extension WindsurfCachedPlanInfo { resetDescription: "\(clampedUsed) / \(total) \(unit)") } - private static func formatResetDescription(_ date: Date?) -> String? { + static func formatResetDescription(_ date: Date?, now: Date = Date()) -> String? { guard let date else { return nil } - let now = Date() let interval = date.timeIntervalSince(now) guard interval > 0 else { return "Expired" } let hours = Int(interval / 3600) let minutes = Int((interval.truncatingRemainder(dividingBy: 3600)) / 60) - if hours > 24 { + if hours >= 24 { let days = hours / 24 let remainingHours = hours % 24 return "Resets in \(days)d \(remainingHours)h" diff --git a/Sources/CodexBarCore/Providers/Windsurf/WindsurfWebFetcher.swift b/Sources/CodexBarCore/Providers/Windsurf/WindsurfWebFetcher.swift index e936389bd2..b2c3ddf8b1 100644 --- a/Sources/CodexBarCore/Providers/Windsurf/WindsurfWebFetcher.swift +++ b/Sources/CodexBarCore/Providers/Windsurf/WindsurfWebFetcher.swift @@ -79,16 +79,15 @@ extension WindsurfGetPlanStatusResponse { identity: identity) } - private static func formatResetDescription(_ date: Date?) -> String? { + static func formatResetDescription(_ date: Date?, now: Date = Date()) -> String? { guard let date else { return nil } - let now = Date() let interval = date.timeIntervalSince(now) guard interval > 0 else { return "Expired" } let hours = Int(interval / 3600) let minutes = Int((interval.truncatingRemainder(dividingBy: 3600)) / 60) - if hours > 24 { + if hours >= 24 { let days = hours / 24 let remainingHours = hours % 24 return "Resets in \(days)d \(remainingHours)h" @@ -119,7 +118,8 @@ public enum WindsurfWebFetcherError: LocalizedError, Sendable { public var errorDescription: String? { switch self { case .noSessionData: - "No Windsurf web session found in Chromium localStorage. Sign in to windsurf.com in Chrome or Edge first." + "No Windsurf web session found in Chromium localStorage. " + + "Sign in to app.devin.ai or windsurf.com in Chrome first." case let .invalidManualSession(message): "Invalid Windsurf session payload: \(message)" case let .apiCallFailed(message): diff --git a/Sources/CodexBarCore/Providers/XAI/XAIBillingFetcher.swift b/Sources/CodexBarCore/Providers/XAI/XAIBillingFetcher.swift new file mode 100644 index 0000000000..9c2eeeb3b8 --- /dev/null +++ b/Sources/CodexBarCore/Providers/XAI/XAIBillingFetcher.swift @@ -0,0 +1,308 @@ +import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +public enum XAIBillingError: LocalizedError, Sendable, Equatable { + case notConfigured + case missingTeamID + case invalidTeamID + case authenticationRejected + case teamNotFound + case rateLimited + case apiError(Int) + case parseFailed(String) + + public var errorDescription: String? { + switch self { + case .notConfigured: + "Missing xAI Management API key. Add one in Settings or set XAI_MANAGEMENT_API_KEY. " + + "Inference API keys are not accepted by the Management API." + case .missingTeamID: + "Missing xAI team ID. Add it in Settings or set XAI_TEAM_ID " + + "(shown in the xAI Console URL and team settings)." + case .invalidTeamID: + "The xAI team ID must be a single identifier without path separators." + case .authenticationRejected: + "xAI rejected the Management API key. Create one in the xAI Console under " + + "Settings > Management Keys; inference API keys are not accepted." + case .teamNotFound: + "xAI returned 404 for this team. Check the team ID, and that the Management key " + + "belongs to the same team." + case .rateLimited: + "xAI Management API rate limit exceeded. Usage will refresh on the next cycle." + case let .apiError(statusCode): + "xAI Management API returned HTTP \(statusCode)." + case let .parseFailed(message): + "Could not parse xAI billing data: \(message)" + } + } +} + +public enum XAIBillingFetcher { + static let baseURL = URL(string: "https://management-api.x.ai")! + public static let historyDays = 30 + private static let requestTimeoutSeconds: TimeInterval = 15 + + public static func fetchUsage( + managementKey rawKey: String, + teamID rawTeamID: String, + transport: any ProviderHTTPTransport = ProviderHTTPClient.shared, + now: Date = Date()) async throws -> XAIUsageSnapshot + { + guard let key = XAISettingsReader.cleaned(rawKey) else { + throw XAIBillingError.notConfigured + } + guard let teamID = XAISettingsReader.cleaned(rawTeamID) else { + throw XAIBillingError.missingTeamID + } + guard !teamID.contains("/"), teamID != ".", teamID != ".." else { + throw XAIBillingError.invalidTeamID + } + + let balanceUSD = try await self.fetchBalanceUSD(key: key, teamID: teamID, transport: transport) + + // History is best-effort enrichment: the balance is independently useful, + // so only credential problems (and cancellation) are allowed to escalate. + var daily: [XAIUsageSnapshot.DailyBucket] = [] + var limitReached = false + do { + (daily, limitReached) = try await self.fetchDailyUsage( + key: key, + teamID: teamID, + transport: transport, + now: now) + } catch is CancellationError { + throw CancellationError() + } catch let error as URLError where error.code == .cancelled { + throw CancellationError() + } catch XAIBillingError.authenticationRejected { + throw XAIBillingError.authenticationRejected + } catch { + if Task.isCancelled { + throw CancellationError() + } + } + + return XAIUsageSnapshot( + balanceUSD: balanceUSD, + daily: daily, + historyDays: self.historyDays, + limitReached: limitReached, + updatedAt: now) + } + + // MARK: - Balance + + private static func fetchBalanceUSD( + key: String, + teamID: String, + transport: any ProviderHTTPTransport) async throws -> Double + { + var request = URLRequest(url: self.teamURL(teamID: teamID, suffix: ["prepaid", "balance"])) + request.httpMethod = "GET" + request.timeoutInterval = self.requestTimeoutSeconds + request.setValue("Bearer \(key)", forHTTPHeaderField: "Authorization") + request.setValue("application/json", forHTTPHeaderField: "Accept") + + let response = try await transport.response(for: request) + guard (200..<300).contains(response.statusCode) else { + throw self.error(statusCode: response.statusCode) + } + + let envelope: BalanceEnvelope + do { + envelope = try JSONDecoder().decode(BalanceEnvelope.self, from: response.data) + } catch { + throw XAIBillingError.parseFailed(error.localizedDescription) + } + return try self.balanceUSD(fromLedgerCents: envelope.total.val) + } + + /// The ledger records credit as negative cents (a $10 top-up is "-1000"), + /// so the remaining balance is the negated cent value in dollars. A body + /// without a parseable total must fail loudly — never read as $0.00. + static func balanceUSD(fromLedgerCents raw: String) throws -> Double { + let value = raw.trimmingCharacters(in: .whitespacesAndNewlines) + guard !value.isEmpty, + value.range(of: #"^-?\d+(\.\d+)?$"#, options: .regularExpression) != nil, + let cents = Double(value), cents.isFinite + else { + throw XAIBillingError.parseFailed("balance total.val is not a cent amount: \(raw)") + } + return -cents / 100.0 + } + + // MARK: - Usage history + + private static func fetchDailyUsage( + key: String, + teamID: String, + transport: any ProviderHTTPTransport, + now: Date) async throws -> ([XAIUsageSnapshot.DailyBucket], Bool) + { + var request = URLRequest(url: self.teamURL(teamID: teamID, suffix: ["usage"])) + request.httpMethod = "POST" + request.timeoutInterval = self.requestTimeoutSeconds + request.setValue("Bearer \(key)", forHTTPHeaderField: "Authorization") + request.setValue("application/json", forHTTPHeaderField: "Accept") + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.httpBody = try JSONEncoder().encode(self.usageRequestBody(now: now)) + + let response = try await transport.response(for: request) + guard (200..<300).contains(response.statusCode) else { + throw self.error(statusCode: response.statusCode) + } + + let envelope: UsageEnvelope + do { + envelope = try JSONDecoder().decode(UsageEnvelope.self, from: response.data) + } catch { + throw XAIBillingError.parseFailed(error.localizedDescription) + } + + var totalsByDay: [String: Double] = [:] + for series in envelope.timeSeries { + for point in series.dataPoints { + let day = try self.utcDay(fromTimestamp: point.timestamp) + totalsByDay[day, default: 0] += point.values.first ?? 0 + } + } + let daily = totalsByDay + .map { XAIUsageSnapshot.DailyBucket(day: $0.key, costUSD: $0.value) } + .sorted { $0.day < $1.day } + return (daily, envelope.limitReached ?? false) + } + + private static func usageRequestBody(now: Date) -> UsageRequestEnvelope { + let calendar = Self.utcCalendar + let windowStart = calendar.startOfDay( + for: calendar.date(byAdding: .day, value: -(self.historyDays - 1), to: now) ?? now) + return UsageRequestEnvelope( + analyticsRequest: .init( + timeRange: .init( + startTime: Self.requestTimestampFormatter.string(from: windowStart), + endTime: Self.requestTimestampFormatter.string(from: now), + timezone: "Etc/GMT"), + timeUnit: "TIME_UNIT_DAY", + values: [.init(name: "usd", aggregation: "AGGREGATION_SUM")], + groupBy: [], + filters: [])) + } + + private static func utcDay(fromTimestamp timestamp: String) throws -> String { + guard let date = iso8601Fractional.date(from: timestamp) + ?? iso8601.date(from: timestamp) + else { + throw XAIBillingError.parseFailed("usage timestamp is not ISO 8601: \(timestamp)") + } + return Self.dayFormatter.string(from: date) + } + + // MARK: - Shared + + private static func teamURL(teamID: String, suffix: [String]) -> URL { + var url = self.baseURL + .appendingPathComponent("v1") + .appendingPathComponent("billing") + .appendingPathComponent("teams") + .appendingPathComponent(teamID) + for component in suffix { + url = url.appendingPathComponent(component) + } + return url + } + + private static func error(statusCode: Int) -> XAIBillingError { + switch statusCode { + case 401, 403: + .authenticationRejected + case 404: + .teamNotFound + case 429: + .rateLimited + default: + .apiError(statusCode) + } + } + + private static var utcCalendar: Calendar { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(identifier: "UTC")! + return calendar + } + + private static func utcFormatter(dateFormat: String) -> DateFormatter { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.timeZone = TimeZone(identifier: "UTC")! + formatter.dateFormat = dateFormat + return formatter + } + + private static var requestTimestampFormatter: DateFormatter { + self.utcFormatter(dateFormat: "yyyy-MM-dd HH:mm:ss") + } + + private static var dayFormatter: DateFormatter { + self.utcFormatter(dateFormat: "yyyy-MM-dd") + } + + private static var iso8601Fractional: ISO8601DateFormatter { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return formatter + } + + private static var iso8601: ISO8601DateFormatter { + ISO8601DateFormatter() + } +} + +// MARK: - Wire types + +private struct BalanceEnvelope: Decodable { + struct Amount: Decodable { + /// Required: a 200 error envelope must not decode into a $0.00 balance. + let val: String + } + + let total: Amount +} + +private struct UsageRequestEnvelope: Encodable { + struct AnalyticsRequest: Encodable { + struct TimeRange: Encodable { + let startTime: String + let endTime: String + let timezone: String + } + + struct Value: Encodable { + let name: String + let aggregation: String + } + + let timeRange: TimeRange + let timeUnit: String + let values: [Value] + let groupBy: [String] + let filters: [String] + } + + let analyticsRequest: AnalyticsRequest +} + +private struct UsageEnvelope: Decodable { + struct Series: Decodable { + struct DataPoint: Decodable { + let timestamp: String + let values: [Double] + } + + let dataPoints: [DataPoint] + } + + let timeSeries: [Series] + let limitReached: Bool? +} diff --git a/Sources/CodexBarCore/Providers/XAI/XAIProviderDescriptor.swift b/Sources/CodexBarCore/Providers/XAI/XAIProviderDescriptor.swift new file mode 100644 index 0000000000..72abbcac28 --- /dev/null +++ b/Sources/CodexBarCore/Providers/XAI/XAIProviderDescriptor.swift @@ -0,0 +1,69 @@ +import Foundation + +public enum XAIProviderDescriptor { + public static let descriptor: ProviderDescriptor = Self.makeDescriptor() + + static func makeDescriptor() -> ProviderDescriptor { + ProviderDescriptor( + id: .xai, + metadata: ProviderMetadata( + id: .xai, + displayName: "xAI", + sessionLabel: "Spend", + weeklyLabel: "Spend", + opusLabel: nil, + supportsOpus: false, + supportsCredits: false, + creditsHint: "", + toggleTitle: "Show xAI usage", + cliName: "xai", + defaultEnabled: false, + dashboardURL: "https://console.x.ai", + statusPageURL: nil, + statusLinkURL: "https://status.x.ai"), + branding: ProviderBranding( + iconStyle: .xai, + iconResourceName: "ProviderIcon-xai", + color: ProviderColor(red: 142 / 255, green: 142 / 255, blue: 147 / 255), + confettiPalette: [ + ProviderColor(hex: 0x1A1A1A), + ProviderColor(hex: 0x8E8E93), + ProviderColor(hex: 0xF5F5F7), + ]), + tokenCost: ProviderTokenCostConfig( + supportsTokenCost: false, + noDataMessage: { "xAI spend history comes from the Management API billing endpoints." }), + fetchPlan: ProviderFetchPlan( + sourceModes: [.auto, .api], + pipeline: ProviderFetchPipeline(resolveStrategies: { _ in [XAIAPIFetchStrategy()] })), + cli: ProviderCLIConfig( + name: "xai", + versionDetector: nil)) + } +} + +struct XAIAPIFetchStrategy: ProviderFetchStrategy { + let id = "xai.api" + let kind: ProviderFetchKind = .apiToken + + func isAvailable(_ context: ProviderFetchContext) async -> Bool { + XAISettingsReader.apiKey(environment: context.env) != nil + } + + func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult { + guard let key = XAISettingsReader.apiKey(environment: context.env) else { + throw XAIBillingError.notConfigured + } + guard let teamID = XAISettingsReader.teamID(environment: context.env) else { + throw XAIBillingError.missingTeamID + } + let usage = try await XAIBillingFetcher.fetchUsage(managementKey: key, teamID: teamID) + return self.makeResult( + usage: usage.toUsageSnapshot(), + sourceLabel: "api") + } + + func shouldFallback(on _: Error, context _: ProviderFetchContext) -> Bool { + false + } +} diff --git a/Sources/CodexBarCore/Providers/XAI/XAISettingsReader.swift b/Sources/CodexBarCore/Providers/XAI/XAISettingsReader.swift new file mode 100644 index 0000000000..a5c42587ff --- /dev/null +++ b/Sources/CodexBarCore/Providers/XAI/XAISettingsReader.swift @@ -0,0 +1,31 @@ +import Foundation + +public enum XAISettingsReader { + public static let apiKeyEnvironmentKey = "XAI_MANAGEMENT_API_KEY" + public static let teamIDEnvironmentKey = "XAI_TEAM_ID" + + public static func apiKey( + environment: [String: String] = ProcessInfo.processInfo.environment) -> String? + { + self.cleaned(environment[self.apiKeyEnvironmentKey]) + } + + public static func teamID( + environment: [String: String] = ProcessInfo.processInfo.environment) -> String? + { + self.cleaned(environment[self.teamIDEnvironmentKey]) + } + + static func cleaned(_ raw: String?) -> String? { + guard var value = raw?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty else { + return nil + } + if (value.hasPrefix("\"") && value.hasSuffix("\"")) || + (value.hasPrefix("'") && value.hasSuffix("'")) + { + value = String(value.dropFirst().dropLast()) + } + value = value.trimmingCharacters(in: .whitespacesAndNewlines) + return value.isEmpty ? nil : value + } +} diff --git a/Sources/CodexBarCore/Providers/XAI/XAIUsageSnapshot.swift b/Sources/CodexBarCore/Providers/XAI/XAIUsageSnapshot.swift new file mode 100644 index 0000000000..22ebe0bb3d --- /dev/null +++ b/Sources/CodexBarCore/Providers/XAI/XAIUsageSnapshot.swift @@ -0,0 +1,110 @@ +import Foundation + +/// Prepaid balance plus daily USD spend from the xAI Management API billing +/// endpoints. Mirrors the Groq/OpenAI daily cost-history shape so it renders +/// through the shared cost-history inline dashboard. +public struct XAIUsageSnapshot: Codable, Equatable, Sendable { + public struct DailyBucket: Codable, Equatable, Sendable, Identifiable { + /// UTC day in `yyyy-MM-dd` (usage is requested in `Etc/GMT`); the inline + /// dashboard derives its axis labels from this exact format. + public let day: String + public let costUSD: Double + + public var id: String { + self.day + } + + public init(day: String, costUSD: Double) { + self.day = day + self.costUSD = costUSD + } + } + + /// Remaining prepaid credit in dollars. The balance endpoint reports an + /// inverted ledger in string USD cents (a $10 top-up is "-1000"), so this + /// is `-cents / 100`; a negative value means the team is in deficit. + public let balanceUSD: Double + public let daily: [DailyBucket] + public let historyDays: Int + /// True when the usage endpoint reported its cardinality cap; daily sums + /// may then be incomplete and must not be presented as exact. + public let limitReached: Bool + public let updatedAt: Date + + public init( + balanceUSD: Double, + daily: [DailyBucket], + historyDays: Int = 30, + limitReached: Bool = false, + updatedAt: Date) + { + self.balanceUSD = balanceUSD + self.daily = daily.sorted { $0.day < $1.day } + self.historyDays = max(1, min(365, historyDays)) + self.limitReached = limitReached + self.updatedAt = updatedAt + } + + public var historyWindowPeriodLabel: String { + let base = self.historyDays == 1 ? "Today" : "Last \(self.historyDays) days" + return self.limitReached ? "\(base) (partial)" : base + } + + public var windowCostUSD: Double { + self.daily.reduce(0) { $0 + $1.costUSD } + } + + public func toUsageSnapshot() -> UsageSnapshot { + UsageSnapshot( + primary: nil, + secondary: nil, + providerCost: ProviderCostSnapshot( + used: self.balanceUSD, + limit: 0, + currencyCode: "USD", + period: "Prepaid credits", + updatedAt: self.updatedAt), + xaiUsage: self, + updatedAt: self.updatedAt, + identity: ProviderIdentitySnapshot( + providerID: .xai, + accountEmail: nil, + accountOrganization: nil, + loginMethod: "Management API"), + dataConfidence: self.limitReached ? .estimated : .exact) + } + + /// Nil when no history came back: the inline dashboard should fall through + /// instead of charting an empty series as if the team genuinely spent $0. + public func costHistorySnapshot() -> CostUsageTokenSnapshot? { + guard !self.daily.isEmpty else { return nil } + let entries = self.daily.map { bucket in + CostUsageDailyReport.Entry( + date: bucket.day, + inputTokens: nil, + outputTokens: nil, + totalTokens: nil, + costUSD: bucket.costUSD, + modelsUsed: nil, + modelBreakdowns: nil) + } + let today = self.daily.first { $0.day == Self.utcDayString(from: self.updatedAt) } + return CostUsageTokenSnapshot( + sessionTokens: nil, + sessionCostUSD: today?.costUSD ?? 0, + last30DaysTokens: nil, + last30DaysCostUSD: self.windowCostUSD, + historyDays: self.historyDays, + historyLabel: self.limitReached ? self.historyWindowPeriodLabel : nil, + daily: entries, + updatedAt: self.updatedAt) + } + + private static func utcDayString(from date: Date) -> String { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.timeZone = TimeZone(identifier: "UTC")! + formatter.dateFormat = "yyyy-MM-dd" + return formatter.string(from: date) + } +} diff --git a/Sources/CodexBarCore/Providers/Zai/ZaiAPIRegion.swift b/Sources/CodexBarCore/Providers/Zai/ZaiAPIRegion.swift index 7a7943286f..4c939be3fb 100644 --- a/Sources/CodexBarCore/Providers/Zai/ZaiAPIRegion.swift +++ b/Sources/CodexBarCore/Providers/Zai/ZaiAPIRegion.swift @@ -32,4 +32,22 @@ public enum ZaiAPIRegion: String, CaseIterable, Sendable { public var modelUsageURL: URL { URL(string: self.baseURLString)!.appendingPathComponent(Self.modelUsagePath) } + + public var dashboardURL: URL { + switch self { + case .global: + URL(string: "https://z.ai/manage-apikey/coding-plan/personal/my-plan")! + case .bigmodelCN: + URL(string: "https://bigmodel.cn/coding-plan/personal/usage")! + } + } + + public var teamDashboardURL: URL { + switch self { + case .global: + self.dashboardURL + case .bigmodelCN: + URL(string: "https://bigmodel.cn/coding-plan/team/usage-stats")! + } + } } diff --git a/Sources/CodexBarCore/Providers/Zai/ZaiProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Zai/ZaiProviderDescriptor.swift index 2c600a3d41..13995d248e 100644 --- a/Sources/CodexBarCore/Providers/Zai/ZaiProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Zai/ZaiProviderDescriptor.swift @@ -1,9 +1,8 @@ -import CodexBarMacroSupport import Foundation -@ProviderDescriptorRegistration -@ProviderDescriptorDefinition public enum ZaiProviderDescriptor { + public static let descriptor: ProviderDescriptor = Self.makeDescriptor() + static func makeDescriptor() -> ProviderDescriptor { ProviderDescriptor( id: .zai, @@ -21,52 +20,37 @@ public enum ZaiProviderDescriptor { defaultEnabled: false, isPrimaryProvider: false, usesAccountFallback: false, - dashboardURL: "https://z.ai/manage-apikey/subscription", + dashboardURL: ZaiAPIRegion.global.dashboardURL.absoluteString, statusPageURL: nil), branding: ProviderBranding( iconStyle: .zai, iconResourceName: "ProviderIcon-zai", - color: ProviderColor(red: 232 / 255, green: 90 / 255, blue: 106 / 255)), + color: ProviderColor(red: 232 / 255, green: 90 / 255, blue: 106 / 255), + confettiPalette: [ + ProviderColor(hex: 0x126EF6), + ProviderColor(hex: 0x2D2D2D), + ProviderColor(hex: 0xDFE2E7), + ]), tokenCost: ProviderTokenCostConfig( supportsTokenCost: false, noDataMessage: { "z.ai cost summary is not supported." }), - fetchPlan: ProviderFetchPlan( - sourceModes: [.auto, .api], - pipeline: ProviderFetchPipeline(resolveStrategies: { _ in [ZaiAPIFetchStrategy()] })), + fetchPlan: .apiToken( + strategyID: "zai.api", + resolveToken: { ProviderTokenResolver.zaiToken(environment: $0) }, + missingCredentialsError: { ZaiSettingsError.missingToken }, + loadUsage: { apiKey, context in + let settings = context.settings?.zai + let region = settings?.apiRegion ?? .global + return try await ZaiUsageFetcher.fetchUsageWithModelUsage( + apiKey: apiKey, + region: region, + usageScope: settings?.usageScope, + teamContext: settings?.teamContext, + environment: context.env).toUsageSnapshot() + }), cli: ProviderCLIConfig( name: "zai", aliases: ["z.ai"], versionDetector: nil)) } } - -struct ZaiAPIFetchStrategy: ProviderFetchStrategy { - let id: String = "zai.api" - let kind: ProviderFetchKind = .apiToken - - func isAvailable(_ context: ProviderFetchContext) async -> Bool { - Self.resolveToken(environment: context.env) != nil - } - - func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult { - guard let apiKey = Self.resolveToken(environment: context.env) else { - throw ZaiSettingsError.missingToken - } - let region = context.settings?.zai?.apiRegion ?? .global - let usage = try await ZaiUsageFetcher.fetchUsageWithModelUsage( - apiKey: apiKey, - region: region, - environment: context.env) - return self.makeResult( - usage: usage.toUsageSnapshot(), - sourceLabel: "api") - } - - func shouldFallback(on _: Error, context _: ProviderFetchContext) -> Bool { - false - } - - private static func resolveToken(environment: [String: String]) -> String? { - ProviderTokenResolver.zaiToken(environment: environment) - } -} diff --git a/Sources/CodexBarCore/Providers/Zai/ZaiSettingsReader.swift b/Sources/CodexBarCore/Providers/Zai/ZaiSettingsReader.swift index 6379137979..5f92a201a9 100644 --- a/Sources/CodexBarCore/Providers/Zai/ZaiSettingsReader.swift +++ b/Sources/CodexBarCore/Providers/Zai/ZaiSettingsReader.swift @@ -6,6 +6,8 @@ public struct ZaiSettingsReader: Sendable { public static let apiTokenKey = "Z_AI_API_KEY" public static let apiHostKey = "Z_AI_API_HOST" public static let quotaURLKey = "Z_AI_QUOTA_URL" + public static let bigModelOrganizationKey = "Z_AI_BIGMODEL_ORGANIZATION" + public static let bigModelProjectKey = "Z_AI_BIGMODEL_PROJECT" public static func apiToken( environment: [String: String] = ProcessInfo.processInfo.environment) -> String? @@ -24,10 +26,36 @@ public struct ZaiSettingsReader: Sendable { environment: [String: String] = ProcessInfo.processInfo.environment) -> URL? { guard let raw = self.cleaned(environment[quotaURLKey]) else { return nil } - if let url = URL(string: raw), url.scheme != nil { - return url + return ProviderEndpointOverrideValidator.normalizedHTTPSURL(from: raw) + } + + public static func validateEndpointOverrides( + environment: [String: String] = ProcessInfo.processInfo.environment) throws + { + try self.validateQuotaEndpointOverride(environment: environment) + try self.validateAPIHostEndpointOverride(environment: environment) + } + + public static func validateQuotaEndpointOverride( + environment: [String: String] = ProcessInfo.processInfo.environment) throws + { + if let raw = self.cleaned(environment[self.quotaURLKey]) { + guard ProviderEndpointOverrideValidator.normalizedHTTPSURL(from: raw) != nil else { + throw ZaiSettingsError.invalidEndpointOverride(self.quotaURLKey) + } + return + } + + try self.validateAPIHostEndpointOverride(environment: environment) + } + + public static func validateAPIHostEndpointOverride( + environment: [String: String] = ProcessInfo.processInfo.environment) throws + { + guard let raw = self.cleaned(environment[self.apiHostKey]) else { return } + guard ProviderEndpointOverrideValidator.normalizedHTTPSURL(from: raw) != nil else { + throw ZaiSettingsError.invalidEndpointOverride(self.apiHostKey) } - return URL(string: "https://\(raw)") } static func cleaned(_ raw: String?) -> String? { @@ -46,13 +74,16 @@ public struct ZaiSettingsReader: Sendable { } } -public enum ZaiSettingsError: LocalizedError, Sendable { +public enum ZaiSettingsError: LocalizedError, Sendable, Equatable { case missingToken + case invalidEndpointOverride(String) public var errorDescription: String? { switch self { case .missingToken: "z.ai API token not found. Set apiKey in ~/.codexbar/config.json or Z_AI_API_KEY." + case let .invalidEndpointOverride(key): + "z.ai endpoint override \(key) must use HTTPS or a bare host." } } } diff --git a/Sources/CodexBarCore/Providers/Zai/ZaiUsageStats.swift b/Sources/CodexBarCore/Providers/Zai/ZaiUsageStats.swift index eb45fc5795..39380095bf 100644 --- a/Sources/CodexBarCore/Providers/Zai/ZaiUsageStats.swift +++ b/Sources/CodexBarCore/Providers/Zai/ZaiUsageStats.swift @@ -18,6 +18,32 @@ public enum ZaiLimitUnit: Int, Sendable { case weeks = 6 } +public enum ZaiUsageScope: String, CaseIterable, Codable, Sendable { + case personal + case team +} + +public struct ZaiBigModelTeamContext: Equatable, Sendable { + public let organizationID: String + public let projectID: String + + public init?(organizationID: String?, projectID: String?) { + guard let organizationID = ZaiSettingsReader.cleaned(organizationID), + let projectID = ZaiSettingsReader.cleaned(projectID) + else { + return nil + } + self.organizationID = organizationID + self.projectID = projectID + } + + public init?(environment: [String: String] = ProcessInfo.processInfo.environment) { + self.init( + organizationID: environment[ZaiSettingsReader.bigModelOrganizationKey], + projectID: environment[ZaiSettingsReader.bigModelProjectKey]) + } +} + /// A single limit entry from the z.ai API public struct ZaiLimitEntry: Sendable { public let type: ZaiLimitType @@ -58,7 +84,9 @@ extension ZaiLimitEntry { if let computed = self.computedUsedPercent { return computed } - return self.percentage + // The raw API percentage can fall outside 0...100 (z.ai omits/misreports quota fields); + // clamp it like computedUsedPercent and every sibling provider instead of surfacing it raw. + return min(100, max(0, self.percentage)) } public var windowMinutes: Int? { @@ -220,13 +248,19 @@ extension ZaiUsageSnapshot { /// Z.ai quota limit API response private struct ZaiQuotaLimitResponse: Decodable { let code: Int - let msg: String + let msg: String? let data: ZaiQuotaLimitData? let success: Bool var isSuccess: Bool { self.success && self.code == 200 } + + var errorMessage: String { + let message = self.msg?.trimmingCharacters(in: .whitespacesAndNewlines) + if let message, !message.isEmpty { return message } + return "Z.ai quota API returned code \(self.code)" + } } private struct ZaiQuotaLimitData: Decodable { @@ -309,24 +343,52 @@ public struct ZaiUsageFetcher: Sendable { return region.quotaLimitURL } + /// Resolves the canonical dashboard for the effective quota endpoint without opening custom override hosts. + public static func resolveDashboardURL( + region: ZaiAPIRegion, + environment: [String: String] = ProcessInfo.processInfo.environment, + usageScope: ZaiUsageScope = .personal) -> URL + { + let quotaHost = self.resolveQuotaURL(region: region, environment: environment).host?.lowercased() + if quotaHost == ZaiAPIRegion.global.quotaLimitURL.host?.lowercased() { + return usageScope == .team ? ZaiAPIRegion.global.teamDashboardURL : ZaiAPIRegion.global.dashboardURL + } + if quotaHost == ZaiAPIRegion.bigmodelCN.quotaLimitURL.host?.lowercased() { + return usageScope == .team ? ZaiAPIRegion.bigmodelCN.teamDashboardURL : ZaiAPIRegion.bigmodelCN.dashboardURL + } + return usageScope == .team ? region.teamDashboardURL : region.dashboardURL + } + /// Fetches usage stats from z.ai using the provided API key public static func fetchUsage( apiKey: String, region: ZaiAPIRegion = .global, - environment: [String: String] = ProcessInfo.processInfo.environment) async throws -> ZaiUsageSnapshot + usageScope: ZaiUsageScope? = nil, + teamContext: ZaiBigModelTeamContext? = nil, + environment: [String: String] = ProcessInfo.processInfo.environment, + transport: any ProviderHTTPTransport = ProviderHTTPClient.shared) async throws -> ZaiUsageSnapshot { guard !apiKey.isEmpty else { throw ZaiUsageError.invalidCredentials } + try ZaiSettingsReader.validateQuotaEndpointOverride(environment: environment) - let quotaURL = self.resolveQuotaURL(region: region, environment: environment) + let resolvedScope = usageScope ?? .personal + let quotaURL = try self.requestURL( + baseURL: self.resolveQuotaURL(region: region, environment: environment), + usageScope: resolvedScope) + let resolvedTeamContext = try self.resolvedTeamContext( + usageScope: resolvedScope, + explicit: teamContext, + environment: environment) var request = URLRequest(url: quotaURL) request.httpMethod = "GET" - request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "authorization") + request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") request.setValue("application/json", forHTTPHeaderField: "accept") + self.applyTeamHeaders(resolvedTeamContext, to: &request) - let response = try await ProviderHTTPClient.shared.response(for: request) + let response = try await transport.response(for: request) let data = response.data guard response.statusCode == 200 else { let errorMessage = String(data: data, encoding: .utf8) ?? "Unknown error" @@ -367,6 +429,38 @@ public struct ZaiUsageFetcher: Sendable { return "\(host)\(port)\(path)" } + private static func requestURL(baseURL: URL, usageScope: ZaiUsageScope) throws -> URL { + guard usageScope == .team else { return baseURL } + guard var components = URLComponents(url: baseURL, resolvingAgainstBaseURL: false) else { + throw ZaiUsageError.networkError("Invalid URL") + } + var items = components.queryItems ?? [] + items.removeAll { $0.name == "type" } + items.append(URLQueryItem(name: "type", value: "2")) + components.queryItems = items + guard let url = components.url else { + throw ZaiUsageError.networkError("Invalid URL") + } + return url + } + + private static func resolvedTeamContext( + usageScope: ZaiUsageScope, + explicit: ZaiBigModelTeamContext?, + environment: [String: String]) throws -> ZaiBigModelTeamContext? + { + guard usageScope == .team else { return nil } + if let explicit { return explicit } + if let context = ZaiBigModelTeamContext(environment: environment) { return context } + throw ZaiUsageError.missingTeamContext + } + + private static func applyTeamHeaders(_ context: ZaiBigModelTeamContext?, to request: inout URLRequest) { + guard let context else { return } + request.setValue(context.organizationID, forHTTPHeaderField: "Bigmodel-Organization") + request.setValue(context.projectID, forHTTPHeaderField: "Bigmodel-Project") + } + static func parseUsageSnapshot(from data: Data) throws -> ZaiUsageSnapshot { guard !data.isEmpty else { throw ZaiUsageError.parseFailed("Empty response body") @@ -376,7 +470,7 @@ public struct ZaiUsageFetcher: Sendable { let apiResponse = try decoder.decode(ZaiQuotaLimitResponse.self, from: data) guard apiResponse.isSuccess else { - throw ZaiUsageError.apiError(apiResponse.msg) + throw ZaiUsageError.apiError(apiResponse.errorMessage) } guard let responseData = apiResponse.data else { @@ -423,18 +517,11 @@ public struct ZaiUsageFetcher: Sendable { private static func quotaURL(baseURLString: String) -> URL? { guard let cleaned = ZaiSettingsReader.cleaned(baseURLString) else { return nil } - - if let url = URL(string: cleaned), url.scheme != nil { - if url.path.isEmpty || url.path == "/" { - return url.appendingPathComponent(Self.quotaAPIPath) - } - return url - } - guard let base = URL(string: "https://\(cleaned)") else { return nil } - if base.path.isEmpty || base.path == "/" { - return base.appendingPathComponent(Self.quotaAPIPath) + guard let url = ProviderEndpointOverrideValidator.normalizedHTTPSURL(from: cleaned) else { return nil } + if url.path.isEmpty || url.path == "/" { + return url.appendingPathComponent(Self.quotaAPIPath) } - return base + return url } } @@ -551,11 +638,21 @@ extension ZaiUsageFetcher { public static func fetchModelUsage( apiKey: String, region: ZaiAPIRegion = .global, - environment: [String: String] = ProcessInfo.processInfo.environment) async throws -> ZaiModelUsageData + usageScope: ZaiUsageScope? = nil, + teamContext: ZaiBigModelTeamContext? = nil, + environment: [String: String] = ProcessInfo.processInfo.environment, + transport: any ProviderHTTPTransport = ProviderHTTPClient.shared) async throws -> ZaiModelUsageData { guard !apiKey.isEmpty else { throw ZaiUsageError.invalidCredentials } + try ZaiSettingsReader.validateAPIHostEndpointOverride(environment: environment) + + let resolvedScope = usageScope ?? .personal + let resolvedTeamContext = try self.resolvedTeamContext( + usageScope: resolvedScope, + explicit: teamContext, + environment: environment) let baseURL: URL = if let host = ZaiSettingsReader.apiHost(environment: environment), let resolved = Self.modelUsageURL(baseURLString: host) @@ -593,6 +690,9 @@ extension ZaiUsageFetcher { URLQueryItem(name: "startTime", value: startTime), URLQueryItem(name: "endTime", value: endTime), ] + if resolvedScope == .team { + components.queryItems?.append(URLQueryItem(name: "type", value: "3")) + } guard let requestURL = components.url else { throw ZaiUsageError.networkError("Invalid URL") @@ -602,8 +702,9 @@ extension ZaiUsageFetcher { request.httpMethod = "GET" request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") request.setValue("application/json", forHTTPHeaderField: "Content-Type") + self.applyTeamHeaders(resolvedTeamContext, to: &request) - let response = try await ProviderHTTPClient.shared.response(for: request) + let response = try await transport.response(for: request) let data = response.data guard response.statusCode == 200 else { let errorMessage = String(data: data, encoding: .utf8) ?? "Unknown error" @@ -643,12 +744,28 @@ extension ZaiUsageFetcher { public static func fetchUsageWithModelUsage( apiKey: String, region: ZaiAPIRegion = .global, - environment: [String: String] = ProcessInfo.processInfo.environment) async throws -> ZaiUsageSnapshot + usageScope: ZaiUsageScope? = nil, + teamContext: ZaiBigModelTeamContext? = nil, + environment: [String: String] = ProcessInfo.processInfo.environment, + transport: any ProviderHTTPTransport = ProviderHTTPClient.shared) async throws -> ZaiUsageSnapshot { - let snapshot = try await Self.fetchUsage(apiKey: apiKey, region: region, environment: environment) + try ZaiSettingsReader.validateEndpointOverrides(environment: environment) + let snapshot = try await Self.fetchUsage( + apiKey: apiKey, + region: region, + usageScope: usageScope, + teamContext: teamContext, + environment: environment, + transport: transport) let modelUsage: ZaiModelUsageData? do { - modelUsage = try await Self.fetchModelUsage(apiKey: apiKey, region: region, environment: environment) + modelUsage = try await Self.fetchModelUsage( + apiKey: apiKey, + region: region, + usageScope: usageScope, + teamContext: teamContext, + environment: environment, + transport: transport) } catch { Self.log.info("z.ai model usage fetch failed (non-fatal): \(error.localizedDescription)") modelUsage = nil @@ -668,18 +785,11 @@ extension ZaiUsageFetcher { private static func modelUsageURL(baseURLString: String) -> URL? { guard let cleaned = ZaiSettingsReader.cleaned(baseURLString) else { return nil } let path = "api/monitor/usage/model-usage" - - if let url = URL(string: cleaned), url.scheme != nil { - if url.path.isEmpty || url.path == "/" { - return url.appendingPathComponent(path) - } - return url - } - guard let base = URL(string: "https://\(cleaned)") else { return nil } - if base.path.isEmpty || base.path == "/" { - return base.appendingPathComponent(path) + guard let url = ProviderEndpointOverrideValidator.normalizedHTTPSURL(from: cleaned) else { return nil } + if url.path.isEmpty || url.path == "/" { + return url.appendingPathComponent(path) } - return base + return url } } @@ -714,6 +824,7 @@ private struct ZaiModelDataItemRaw: Decodable { /// Errors that can occur during z.ai usage fetching public enum ZaiUsageError: LocalizedError, Sendable { case invalidCredentials + case missingTeamContext case networkError(String) case apiError(String) case parseFailed(String) @@ -722,6 +833,8 @@ public enum ZaiUsageError: LocalizedError, Sendable { switch self { case .invalidCredentials: "Invalid z.ai API credentials" + case .missingTeamContext: + "z.ai BigModel team usage requires both Organization ID and Project ID." case let .networkError(message): "z.ai network error: \(message)" case let .apiError(message): diff --git a/Sources/CodexBarCore/Providers/Zed/ZedProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Zed/ZedProviderDescriptor.swift new file mode 100644 index 0000000000..30a2403787 --- /dev/null +++ b/Sources/CodexBarCore/Providers/Zed/ZedProviderDescriptor.swift @@ -0,0 +1,65 @@ +import Foundation + +public enum ZedProviderDescriptor { + public static let descriptor: ProviderDescriptor = Self.makeDescriptor() + + static func makeDescriptor() -> ProviderDescriptor { + ProviderDescriptor( + id: .zed, + metadata: ProviderMetadata( + id: .zed, + displayName: "Zed", + sessionLabel: "Edit predictions", + weeklyLabel: "Billing cycle", + opusLabel: nil, + supportsOpus: false, + supportsCredits: false, + creditsHint: "", + toggleTitle: "Show Zed usage", + cliName: "zed", + defaultEnabled: false, + isPrimaryProvider: false, + usesAccountFallback: false, + dashboardURL: nil, + statusPageURL: nil), + branding: ProviderBranding( + iconStyle: .zed, + iconResourceName: "ProviderIcon-zed", + color: ProviderColor(red: 8 / 255, green: 78 / 255, blue: 255 / 255), + confettiPalette: [ + ProviderColor(hex: 0x084CCF), + ProviderColor(hex: 0x000000), + ProviderColor(hex: 0xFFFFFF), + ]), + tokenCost: ProviderTokenCostConfig( + supportsTokenCost: false, + noDataMessage: { "Zed cost summary is not supported." }), + fetchPlan: ProviderFetchPlan( + sourceModes: [.auto, .api], + pipeline: ProviderFetchPipeline(resolveStrategies: { _ in + [ZedLocalFetchStrategy()] + })), + cli: ProviderCLIConfig( + name: "zed", + versionDetector: nil)) + } +} + +struct ZedLocalFetchStrategy: ProviderFetchStrategy { + let id: String = "zed.local" + let kind: ProviderFetchKind = .localProbe + + func isAvailable(_: ProviderFetchContext) async -> Bool { + true + } + + func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult { + _ = context + let snapshot = try await ZedStatusProbe().fetch() + return self.makeResult(usage: snapshot.toUsageSnapshot(), sourceLabel: "local") + } + + func shouldFallback(on _: Error, context _: ProviderFetchContext) -> Bool { + false + } +} diff --git a/Sources/CodexBarCore/Providers/Zed/ZedStatusProbe.swift b/Sources/CodexBarCore/Providers/Zed/ZedStatusProbe.swift new file mode 100644 index 0000000000..f082d1bb34 --- /dev/null +++ b/Sources/CodexBarCore/Providers/Zed/ZedStatusProbe.swift @@ -0,0 +1,553 @@ +import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +// MARK: - Models + +public struct ZedAuthenticatedUserResponse: Decodable, Equatable, Sendable { + public let user: ZedAuthenticatedUser + public let plan: ZedPlanInfo + + public init(user: ZedAuthenticatedUser, plan: ZedPlanInfo) { + self.user = user + self.plan = plan + } +} + +public struct ZedAuthenticatedUser: Decodable, Equatable, Sendable { + public let id: Int + public let githubLogin: String + public let name: String? + + enum CodingKeys: String, CodingKey { + case id + case githubLogin = "github_login" + case name + } +} + +public struct ZedPlanInfo: Decodable, Equatable, Sendable { + public let planV3: String + public let subscriptionPeriod: ZedSubscriptionPeriod? + public let usage: ZedCurrentUsage + public let hasOverdueInvoices: Bool + + enum CodingKeys: String, CodingKey { + case planV3 = "plan_v3" + case subscriptionPeriod = "subscription_period" + case usage + case hasOverdueInvoices = "has_overdue_invoices" + } +} + +public struct ZedSubscriptionPeriod: Decodable, Equatable, Sendable { + public let startedAt: Date + public let endedAt: Date + + enum CodingKeys: String, CodingKey { + case startedAt = "started_at" + case endedAt = "ended_at" + } +} + +public struct ZedCurrentUsage: Decodable, Equatable, Sendable { + public let editPredictions: ZedUsageData + + enum CodingKeys: String, CodingKey { + case editPredictions = "edit_predictions" + } +} + +public struct ZedUsageData: Decodable, Equatable, Sendable { + public let used: Int + public let limit: ZedUsageLimit +} + +public enum ZedUsageLimit: Equatable, Sendable { + case limited(Int) + case unlimited +} + +extension ZedUsageLimit: Decodable { + public init(from decoder: Decoder) throws { + if let single = try? decoder.singleValueContainer() { + if let string = try? single.decode(String.self), string == "unlimited" { + self = .unlimited + return + } + if let value = try? single.decode(Int.self) { + self = .limited(value) + return + } + } + + let container = try decoder.container(keyedBy: CodingKeys.self) + if let value = try container.decodeIfPresent(Int.self, forKey: .limited) { + self = .limited(value) + return + } + + throw DecodingError.dataCorrupted( + DecodingError.Context(codingPath: decoder.codingPath, debugDescription: "Unrecognized Zed usage limit")) + } + + private enum CodingKeys: String, CodingKey { + case limited + } +} + +public struct ZedCredentials: Equatable, Sendable { + public let userID: String + public let accessToken: String + + public init(userID: String, accessToken: String) { + self.userID = userID + self.accessToken = accessToken + } + + public var authorizationHeader: String { + "\(self.userID) \(self.accessToken)" + } +} + +public struct ZedUsageSnapshot: Sendable, Equatable { + public let response: ZedAuthenticatedUserResponse + public let updatedAt: Date + + public init(response: ZedAuthenticatedUserResponse, updatedAt: Date = Date()) { + self.response = response + self.updatedAt = updatedAt + } +} + +// MARK: - Errors + +public enum ZedStatusProbeError: LocalizedError, Sendable, Equatable { + case notSupported + case notSignedIn + case keychainUnavailable + case invalidServerURL(String) + case untrustedServerConfiguration + case networkError(String) + case httpError(Int) + case unauthorized + case parseFailed(String) + + public var errorDescription: String? { + switch self { + case .notSupported: + "Zed is only supported on macOS." + case .notSignedIn: + "Not signed in to Zed. Sign in from the Zed editor app with GitHub." + case .keychainUnavailable: + "Could not read Zed credentials from the Keychain. Grant CodexBar Keychain access or sign in to Zed again." + case let .invalidServerURL(value): + "Zed server URL is invalid: \(value)" + case .untrustedServerConfiguration: + "Zed custom servers must use HTTPS and store credentials under the same server URL." + case let .networkError(message): + "Zed cloud API request failed: \(message)" + case let .httpError(status): + "Zed cloud API returned HTTP \(status)." + case .unauthorized: + "Zed credentials are invalid or expired. Sign in to Zed again." + case let .parseFailed(message): + "Could not parse Zed account response: \(message)" + } + } +} + +// MARK: - Settings + +public struct ZedClientSettings: Sendable, Equatable { + public let credentialsURL: String? + public let serverURL: String? + + public init(credentialsURL: String?, serverURL: String?) { + self.credentialsURL = credentialsURL + self.serverURL = serverURL + } + + public var keychainServiceURL: String { + let trimmedCredentials = self.credentialsURL?.trimmingCharacters(in: .whitespacesAndNewlines) + if let trimmedCredentials, !trimmedCredentials.isEmpty { + return trimmedCredentials + } + let trimmedServer = self.serverURL?.trimmingCharacters(in: .whitespacesAndNewlines) + if let trimmedServer, !trimmedServer.isEmpty { + return trimmedServer + } + return ZedStatusProbe.defaultKeychainServiceURL + } + + public var cloudAPIURL: URL? { + let trimmedServer = self.serverURL?.trimmingCharacters(in: .whitespacesAndNewlines) + let server = if let trimmedServer, !trimmedServer.isEmpty { + trimmedServer + } else { + ZedStatusProbe.defaultKeychainServiceURL + } + let isTrustedZedServer = server == "https://zed.dev" || server == "https://staging.zed.dev" + let trimmedCredentials = self.credentialsURL?.trimmingCharacters(in: .whitespacesAndNewlines) + if !isTrustedZedServer, + let trimmedCredentials, + !trimmedCredentials.isEmpty, + trimmedCredentials != server + { + return nil + } + let cloudBase = switch server { + case "https://zed.dev", "https://staging.zed.dev": + "https://cloud.zed.dev" + default: + server + } + guard let baseURL = URL(string: cloudBase), + let scheme = baseURL.scheme?.lowercased(), + scheme == "https", + baseURL.host != nil + else { + return nil + } + return baseURL.appendingPathComponent("client/users/me") + } + + public static func load(from url: URL = ZedStatusProbe.defaultSettingsURL) -> ZedClientSettings? { + guard let data = try? Data(contentsOf: url) else { return nil } + struct Payload: Decodable { + let credentialsURL: String? + let serverURL: String? + + enum CodingKeys: String, CodingKey { + case credentialsURL = "credentials_url" + case serverURL = "server_url" + } + } + guard let payload = try? JSONDecoder().decode(Payload.self, from: data) else { return nil } + return ZedClientSettings( + credentialsURL: payload.credentialsURL, + serverURL: payload.serverURL) + } +} + +// MARK: - Credentials + +public protocol ZedCredentialsReading: Sendable { + func loadCredentials(serviceURL: String) throws -> ZedCredentials? +} + +#if os(macOS) +import Security + +public struct ZedKeychainCredentialsReader: ZedCredentialsReading, Sendable { + public init() {} + + public func loadCredentials(serviceURL: String) throws -> ZedCredentials? { + if let credentials = try self.loadInternetPasswordCredentials(server: serviceURL) { + return credentials + } + return try self.loadGenericPasswordCredentials(service: serviceURL) + } + + private func loadInternetPasswordCredentials(server: String) throws -> ZedCredentials? { + var query: [String: Any] = [ + kSecClass as String: kSecClassInternetPassword, + kSecAttrServer as String: server, + kSecMatchLimit as String: kSecMatchLimitOne, + kSecReturnAttributes as String: true, + kSecReturnData as String: true, + ] + KeychainNoUIQuery.apply(to: &query) + return try self.credentials(from: query) + } + + private func loadGenericPasswordCredentials(service: String) throws -> ZedCredentials? { + var query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecMatchLimit as String: kSecMatchLimitOne, + kSecReturnAttributes as String: true, + kSecReturnData as String: true, + ] + KeychainNoUIQuery.apply(to: &query) + return try self.credentials(from: query) + } + + private func credentials(from query: [String: Any]) throws -> ZedCredentials? { + var result: AnyObject? + let status = KeychainSecurity.copyMatching(query as CFDictionary, &result) + switch status { + case errSecSuccess: + break + case errSecItemNotFound: + return nil + case errSecInteractionNotAllowed, errSecAuthFailed, errSecNoAccessForItem: + throw ZedStatusProbeError.keychainUnavailable + default: + throw ZedStatusProbeError.keychainUnavailable + } + + guard let item = result as? [String: Any], + let account = item[kSecAttrAccount as String] as? String, + !account.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + else { + return nil + } + + let tokenData: Data? = if let data = item[kSecValueData as String] as? Data { + data + } else { + nil + } + guard let tokenData, + let accessToken = String(data: tokenData, encoding: .utf8), + !accessToken.isEmpty + else { + return nil + } + + return ZedCredentials(userID: account, accessToken: accessToken) + } +} +#else +public struct ZedKeychainCredentialsReader: ZedCredentialsReading, Sendable { + public init() {} + + public func loadCredentials(serviceURL _: String) throws -> ZedCredentials? { + throw ZedStatusProbeError.notSupported + } +} +#endif + +// MARK: - Probe + +public struct ZedStatusProbe: Sendable { + public static let defaultKeychainServiceURL = "https://zed.dev" + public static let cloudAPIURL = URL(string: "https://cloud.zed.dev/client/users/me")! + + public static var defaultSettingsURL: URL { + let home = FileManager.default.homeDirectoryForCurrentUser + return home + .appendingPathComponent(".config/zed/settings.json") + } + + private static let logger = CodexBarLog.logger(LogCategories.zed) + + private let credentialsReader: any ZedCredentialsReading + private let transport: any ProviderHTTPTransport + private let settingsLoader: @Sendable () -> ZedClientSettings? + + public init( + credentialsReader: any ZedCredentialsReading = ZedKeychainCredentialsReader(), + transport: any ProviderHTTPTransport = ProviderHTTPClient.shared, + settingsLoader: @escaping @Sendable () -> ZedClientSettings? = { ZedClientSettings.load() }) + { + self.credentialsReader = credentialsReader + self.transport = transport + self.settingsLoader = settingsLoader + } + + public func fetch() async throws -> ZedUsageSnapshot { + let settings = self.settingsLoader() + let serviceURL = settings?.keychainServiceURL ?? Self.defaultKeychainServiceURL + let cloudAPIURL: URL + if let settings { + guard let configuredURL = settings.cloudAPIURL else { + let serverURL = settings.serverURL ?? "" + guard URL(string: serverURL)?.scheme?.lowercased() == "https" else { + throw ZedStatusProbeError.invalidServerURL(serverURL) + } + throw ZedStatusProbeError.untrustedServerConfiguration + } + cloudAPIURL = configuredURL + } else { + cloudAPIURL = Self.cloudAPIURL + } + guard let credentials = try self.credentialsReader.loadCredentials(serviceURL: serviceURL) else { + throw ZedStatusProbeError.notSignedIn + } + + let response = try await self.fetchAuthenticatedUser(credentials: credentials, apiURL: cloudAPIURL) + return ZedUsageSnapshot(response: response) + } + + private func fetchAuthenticatedUser( + credentials: ZedCredentials, + apiURL: URL) async throws -> ZedAuthenticatedUserResponse + { + var request = URLRequest(url: apiURL) + request.httpMethod = "GET" + request.setValue(credentials.authorizationHeader, forHTTPHeaderField: "Authorization") + request.setValue("application/json", forHTTPHeaderField: "Accept") + + let httpResponse: ProviderHTTPResponse + do { + httpResponse = try await self.transport.response(for: request) + } catch is CancellationError { + throw CancellationError() + } catch let error as URLError where error.code == .cancelled { + throw CancellationError() + } catch { + Self.logger.debug("Zed cloud API transport failed: \(error.localizedDescription)") + throw ZedStatusProbeError.networkError(error.localizedDescription) + } + + switch httpResponse.statusCode { + case 200: + return try Self.parseResponse(httpResponse.data) + case 401, 403: + throw ZedStatusProbeError.unauthorized + default: + throw ZedStatusProbeError.httpError(httpResponse.statusCode) + } + } + + public static func parseResponse(_ data: Data) throws -> ZedAuthenticatedUserResponse { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .custom { decoder in + let container = try decoder.singleValueContainer() + let value = try container.decode(String.self) + if let date = Self.parseISO8601Date(value) { + return date + } + throw DecodingError.dataCorruptedError( + in: container, + debugDescription: "Invalid ISO8601 date: \(value)") + } + do { + return try decoder.decode(ZedAuthenticatedUserResponse.self, from: data) + } catch { + throw ZedStatusProbeError.parseFailed(error.localizedDescription) + } + } + + private static func parseISO8601Date(_ value: String) -> Date? { + let withFractional = ISO8601DateFormatter() + withFractional.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + if let date = withFractional.date(from: value) { + return date + } + let plain = ISO8601DateFormatter() + plain.formatOptions = [.withInternetDateTime] + return plain.date(from: value) + } +} + +// MARK: - UsageSnapshot mapping + +extension ZedUsageSnapshot { + public func toUsageSnapshot() -> UsageSnapshot { + let plan = self.response.plan + let user = self.response.user + + let primary = Self.makeEditPredictionsWindow( + used: plan.usage.editPredictions.used, + limit: plan.usage.editPredictions.limit) + + let secondary = plan.subscriptionPeriod.map { period in + RateWindow( + usedPercent: Self.billingCycleUsedPercent(startedAt: period.startedAt, endedAt: period.endedAt), + windowMinutes: nil, + resetsAt: period.endedAt, + resetDescription: Self.formatResetDescription(period.endedAt)) + } + + var extraRateWindows: [NamedRateWindow] = [] + if plan.hasOverdueInvoices { + extraRateWindows.append(NamedRateWindow( + id: "zed.overdue-invoices", + title: "Billing", + window: RateWindow( + usedPercent: 100, + windowMinutes: nil, + resetsAt: nil, + resetDescription: "Overdue invoices"), + usageKnown: false)) + } + + let identity = ProviderIdentitySnapshot( + providerID: .zed, + accountEmail: user.githubLogin.nilIfEmpty, + accountOrganization: user.name?.nilIfEmpty, + loginMethod: Self.displayPlanName(plan.planV3)) + + return UsageSnapshot( + primary: primary, + secondary: secondary, + extraRateWindows: extraRateWindows.isEmpty ? nil : extraRateWindows, + subscriptionRenewsAt: plan.subscriptionPeriod?.endedAt, + updatedAt: self.updatedAt, + identity: identity) + } + + private static func makeEditPredictionsWindow(used: Int, limit: ZedUsageLimit) -> RateWindow? { + switch limit { + case .unlimited: + return RateWindow( + usedPercent: 0, + windowMinutes: nil, + resetsAt: nil, + resetDescription: "Unlimited") + case let .limited(total): + guard total > 0 else { return nil } + let clampedUsed = max(0, min(total, used)) + let usedPercent = Double(clampedUsed) / Double(total) * 100.0 + return RateWindow( + usedPercent: usedPercent, + windowMinutes: nil, + resetsAt: nil, + resetDescription: "\(clampedUsed) / \(total) predictions") + } + } + + public static func displayPlanName(_ rawPlan: String) -> String { + switch rawPlan.lowercased() { + case "zed_free": "Zed Free" + case "zed_pro": "Zed Pro" + case "zed_pro_trial": "Zed Pro Trial" + case "zed_student": "Zed Student" + case "zed_business": "Zed Business" + default: + rawPlan + .replacingOccurrences(of: "_", with: " ") + .split(separator: " ") + .map { word in + word.prefix(1).uppercased() + word.dropFirst().lowercased() + } + .joined(separator: " ") + } + } + + private static func billingCycleUsedPercent(startedAt: Date, endedAt: Date) -> Double { + let now = Date() + let total = endedAt.timeIntervalSince(startedAt) + guard total > 0 else { return 0 } + let elapsed = now.timeIntervalSince(startedAt) + return max(0, min(100, elapsed / total * 100)) + } + + static func formatResetDescription(_ date: Date, now: Date = Date()) -> String? { + let interval = date.timeIntervalSince(now) + guard interval > 0 else { return "Cycle ended" } + + let hours = Int(interval / 3600) + let minutes = Int((interval.truncatingRemainder(dividingBy: 3600)) / 60) + + if hours >= 24 { + let days = hours / 24 + let remainingHours = hours % 24 + return "Cycle ends in \(days)d \(remainingHours)h" + } else if hours > 0 { + return "Cycle ends in \(hours)h \(minutes)m" + } else { + return "Cycle ends in \(minutes)m" + } + } +} + +extension String { + fileprivate var nilIfEmpty: String? { + self.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? nil : self + } +} diff --git a/Sources/CodexBarCore/Providers/ZenMux/ZenMuxProviderDescriptor.swift b/Sources/CodexBarCore/Providers/ZenMux/ZenMuxProviderDescriptor.swift new file mode 100644 index 0000000000..3781fc7585 --- /dev/null +++ b/Sources/CodexBarCore/Providers/ZenMux/ZenMuxProviderDescriptor.swift @@ -0,0 +1,71 @@ +import Foundation + +public enum ZenMuxProviderDescriptor { + public static let descriptor: ProviderDescriptor = Self.makeDescriptor() + + static func makeDescriptor() -> ProviderDescriptor { + ProviderDescriptor( + id: .zenmux, + metadata: ProviderMetadata( + id: .zenmux, + displayName: "ZenMux", + sessionLabel: "5-hour quota", + weeklyLabel: "Weekly quota", + opusLabel: nil, + supportsOpus: false, + supportsCredits: false, + creditsHint: "", + toggleTitle: "Show ZenMux usage", + cliName: "zenmux", + defaultEnabled: false, + dashboardURL: "https://zenmux.ai/platform/management", + statusPageURL: nil), + branding: ProviderBranding( + iconStyle: .zenmux, + iconResourceName: "ProviderIcon-zenmux", + color: ProviderColor(red: 108 / 255, green: 92 / 255, blue: 231 / 255), + confettiPalette: [ + ProviderColor(hex: 0x6C5CE7), + ProviderColor(hex: 0xA29BFE), + ProviderColor(hex: 0xFFFFFF), + ]), + tokenCost: ProviderTokenCostConfig( + supportsTokenCost: false, + noDataMessage: { "ZenMux cost history is not exposed by the Management API." }), + fetchPlan: ProviderFetchPlan( + sourceModes: [.auto, .api], + pipeline: ProviderFetchPipeline(resolveStrategies: { _ in [ZenMuxAPIFetchStrategy()] })), + cli: ProviderCLIConfig( + name: "zenmux", + aliases: ["zen-mux"], + versionDetector: nil)) + } +} + +struct ZenMuxAPIFetchStrategy: ProviderFetchStrategy { + let id = "zenmux.api" + let kind: ProviderFetchKind = .apiToken + + func isAvailable(_ context: ProviderFetchContext) async -> Bool { + ZenMuxSettingsReader.managementAPIKey(environment: context.env) != nil + } + + func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult { + guard let credential = ZenMuxSettingsReader.managementAPIKey(environment: context.env) else { + throw ZenMuxUsageError.notConfigured + } + let shouldFetchCredits = context.runtime == .app + ? context.includeOptionalUsage + : context.includeCredits + let result = try await ZenMuxUsageFetcher.fetchUsage( + credential, + includePaygBalance: shouldFetchCredits) + return self.makeResult( + usage: result.usage.toUsageSnapshot(paygBalanceUSD: result.paygBalanceUSD), + sourceLabel: "api") + } + + func shouldFallback(on _: Error, context _: ProviderFetchContext) -> Bool { + false + } +} diff --git a/Sources/CodexBarCore/Providers/ZenMux/ZenMuxSettingsReader.swift b/Sources/CodexBarCore/Providers/ZenMux/ZenMuxSettingsReader.swift new file mode 100644 index 0000000000..2652f5b941 --- /dev/null +++ b/Sources/CodexBarCore/Providers/ZenMux/ZenMuxSettingsReader.swift @@ -0,0 +1,24 @@ +import Foundation + +public enum ZenMuxSettingsReader { + public static let managementAPIKeyEnvironmentKey = "ZENMUX_MANAGEMENT_API_KEY" + + public static func managementAPIKey( + environment: [String: String] = ProcessInfo.processInfo.environment) -> String? + { + self.cleaned(environment[self.managementAPIKeyEnvironmentKey]) + } + + static func cleaned(_ raw: String?) -> String? { + guard var value = raw?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty else { + return nil + } + if (value.hasPrefix("\"") && value.hasSuffix("\"")) || + (value.hasPrefix("'") && value.hasSuffix("'")) + { + value = String(value.dropFirst().dropLast()) + } + value = value.trimmingCharacters(in: .whitespacesAndNewlines) + return value.isEmpty ? nil : value + } +} diff --git a/Sources/CodexBarCore/Providers/ZenMux/ZenMuxUsageFetcher.swift b/Sources/CodexBarCore/Providers/ZenMux/ZenMuxUsageFetcher.swift new file mode 100644 index 0000000000..a57d60f283 --- /dev/null +++ b/Sources/CodexBarCore/Providers/ZenMux/ZenMuxUsageFetcher.swift @@ -0,0 +1,297 @@ +import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +public enum ZenMuxUsageError: LocalizedError, Sendable, Equatable { + case notConfigured + case authenticationRejected + case apiError(Int) + case parseFailed(String) + + public var errorDescription: String? { + switch self { + case .notConfigured: + "Missing ZenMux Management API key. Add one in Settings or set ZENMUX_MANAGEMENT_API_KEY." + case .authenticationRejected: + "ZenMux rejected the Management API key. Standard inference API keys are not supported." + case let .apiError(statusCode): + "ZenMux Management API returned HTTP \(statusCode)." + case let .parseFailed(message): + "Could not parse ZenMux usage: \(message)" + } + } +} + +public struct ZenMuxUsageSnapshot: Sendable, Equatable { + public struct QuotaWindow: Sendable, Equatable { + public let usageFraction: Double + public let resetsAt: Date? + public let maxFlows: Double + public let usedFlows: Double + public let remainingFlows: Double + + public init( + usageFraction: Double, + resetsAt: Date?, + maxFlows: Double, + usedFlows: Double, + remainingFlows: Double) + { + self.usageFraction = usageFraction + self.resetsAt = resetsAt + self.maxFlows = maxFlows + self.usedFlows = usedFlows + self.remainingFlows = remainingFlows + } + + func rateWindow(windowMinutes: Int) -> RateWindow { + RateWindow( + usedPercent: (self.usageFraction * 100).clamped(to: 0...100), + windowMinutes: windowMinutes, + resetsAt: self.resetsAt, + resetDescription: "\(Self.amount(self.usedFlows)) / \(Self.amount(self.maxFlows)) flows") + } + + private static func amount(_ value: Double) -> String { + value.rounded() == value + ? String(format: "%.0f", value) + : String(format: "%.2f", value) + } + } + + public let planTier: String + public let subscriptionExpiresAt: Date? + public let accountStatus: String + public let fiveHour: QuotaWindow + public let weekly: QuotaWindow + public let updatedAt: Date + + public init( + planTier: String, + subscriptionExpiresAt: Date?, + accountStatus: String, + fiveHour: QuotaWindow, + weekly: QuotaWindow, + updatedAt: Date) + { + self.planTier = planTier + self.subscriptionExpiresAt = subscriptionExpiresAt + self.accountStatus = accountStatus + self.fiveHour = fiveHour + self.weekly = weekly + self.updatedAt = updatedAt + } + + public func toUsageSnapshot(paygBalanceUSD: Double? = nil) -> UsageSnapshot { + let plan = self.planTier.trimmingCharacters(in: .whitespacesAndNewlines) + let status = self.accountStatus.trimmingCharacters(in: .whitespacesAndNewlines) + let loginMethod = status.lowercased() == "healthy" || status.isEmpty + ? Self.planLabel(plan) + : [Self.planLabel(plan), status.capitalized].compactMap(\.self).joined(separator: " · ") + + return UsageSnapshot( + primary: self.fiveHour.rateWindow(windowMinutes: 5 * 60), + secondary: self.weekly.rateWindow(windowMinutes: 7 * 24 * 60), + providerCost: paygBalanceUSD.map { + ProviderCostSnapshot( + used: $0, + limit: 0, + currencyCode: "USD", + period: "ZenMux PAYG balance", + updatedAt: self.updatedAt) + }, + subscriptionExpiresAt: self.subscriptionExpiresAt, + updatedAt: self.updatedAt, + identity: ProviderIdentitySnapshot( + providerID: .zenmux, + accountEmail: nil, + accountOrganization: nil, + loginMethod: loginMethod), + dataConfidence: .exact) + } + + private static func planLabel(_ tier: String) -> String? { + guard !tier.isEmpty else { return nil } + return "\(tier.capitalized) plan" + } +} + +public enum ZenMuxUsageFetcher { + private static let managementBaseURL = URL(string: "https://zenmux.ai/api/v1/management")! + private static let requestTimeoutSeconds: TimeInterval = 15 + + public static func fetchUsage( + _ rawCredential: String, + includePaygBalance: Bool, + transport: any ProviderHTTPTransport = ProviderHTTPClient.shared, + now: Date = Date()) async throws -> (usage: ZenMuxUsageSnapshot, paygBalanceUSD: Double?) + { + guard let credential = ZenMuxSettingsReader.cleaned(rawCredential) else { + throw ZenMuxUsageError.notConfigured + } + let subscriptionData = try await self.get( + pathComponents: ["subscription", "detail"], + credential: credential, + transport: transport) + let usage = try self.parseSubscription(subscriptionData, now: now) + + guard includePaygBalance else { return (usage, nil) } + let paygBalanceUSD: Double? + do { + let balanceData = try await self.get( + pathComponents: ["payg", "balance"], + credential: credential, + transport: transport) + paygBalanceUSD = try self.parsePaygBalanceUSD(balanceData) + } catch is CancellationError { + throw CancellationError() + } catch let error as URLError where error.code == .cancelled { + throw CancellationError() + } catch ZenMuxUsageError.authenticationRejected { + throw ZenMuxUsageError.authenticationRejected + } catch { + if Task.isCancelled { + throw CancellationError() + } + paygBalanceUSD = nil + } + return (usage, paygBalanceUSD) + } + + private static func get( + pathComponents: [String], + credential: String, + transport: any ProviderHTTPTransport) async throws -> Data + { + let url = pathComponents.reduce(self.managementBaseURL) { partial, component in + partial.appendingPathComponent(component) + } + var request = URLRequest(url: url) + request.httpMethod = "GET" + request.timeoutInterval = self.requestTimeoutSeconds + request.setValue("Bearer \(credential)", forHTTPHeaderField: "Authorization") + request.setValue("application/json", forHTTPHeaderField: "Accept") + + let response = try await transport.response(for: request) + guard (200..<300).contains(response.statusCode) else { + if response.statusCode == 401 || response.statusCode == 403 { + throw ZenMuxUsageError.authenticationRejected + } + throw ZenMuxUsageError.apiError(response.statusCode) + } + return response.data + } + + private static func parseSubscription(_ data: Data, now: Date) throws -> ZenMuxUsageSnapshot { + let response: SubscriptionEnvelope + do { + response = try JSONDecoder().decode(SubscriptionEnvelope.self, from: data) + } catch { + throw ZenMuxUsageError.parseFailed(error.localizedDescription) + } + guard response.success else { + throw ZenMuxUsageError.parseFailed("subscription response reported failure") + } + + return ZenMuxUsageSnapshot( + planTier: response.data.plan.tier, + subscriptionExpiresAt: self.date(response.data.plan.expiresAt), + accountStatus: response.data.accountStatus, + fiveHour: response.data.quota5Hour.snapshot(), + weekly: response.data.quota7Day.snapshot(), + updatedAt: now) + } + + private static func parsePaygBalanceUSD(_ data: Data) throws -> Double { + let response: BalanceEnvelope + do { + response = try JSONDecoder().decode(BalanceEnvelope.self, from: data) + } catch { + throw ZenMuxUsageError.parseFailed(error.localizedDescription) + } + guard response.success else { + throw ZenMuxUsageError.parseFailed("balance response reported failure") + } + guard response.data.currency.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == "usd" else { + throw ZenMuxUsageError.parseFailed("balance currency is not USD") + } + return response.data.totalCredits + } + + fileprivate static func date(_ raw: String?) -> Date? { + guard let raw else { return nil } + let fractional = ISO8601DateFormatter() + fractional.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return fractional.date(from: raw) ?? ISO8601DateFormatter().date(from: raw) + } +} + +private struct SubscriptionEnvelope: Decodable { + struct DataPayload: Decodable { + struct Plan: Decodable { + let tier: String + let expiresAt: String? + + enum CodingKeys: String, CodingKey { + case tier + case expiresAt = "expires_at" + } + } + + struct Quota: Decodable { + let usagePercentage: Double + let resetsAt: String? + let maxFlows: Double + let usedFlows: Double + let remainingFlows: Double + + enum CodingKeys: String, CodingKey { + case usagePercentage = "usage_percentage" + case resetsAt = "resets_at" + case maxFlows = "max_flows" + case usedFlows = "used_flows" + case remainingFlows = "remaining_flows" + } + + func snapshot() -> ZenMuxUsageSnapshot.QuotaWindow { + ZenMuxUsageSnapshot.QuotaWindow( + usageFraction: self.usagePercentage, + resetsAt: ZenMuxUsageFetcher.date(self.resetsAt), + maxFlows: self.maxFlows, + usedFlows: self.usedFlows, + remainingFlows: self.remainingFlows) + } + } + + let plan: Plan + let accountStatus: String + let quota5Hour: Quota + let quota7Day: Quota + + enum CodingKeys: String, CodingKey { + case plan + case accountStatus = "account_status" + case quota5Hour = "quota_5_hour" + case quota7Day = "quota_7_day" + } + } + + let success: Bool + let data: DataPayload +} + +private struct BalanceEnvelope: Decodable { + struct DataPayload: Decodable { + let currency: String + let totalCredits: Double + + enum CodingKeys: String, CodingKey { + case currency + case totalCredits = "total_credits" + } + } + + let success: Bool + let data: DataPayload +} diff --git a/Sources/CodexBarCore/Providers/ZoomMate/ZoomMateBearerTokenCache.swift b/Sources/CodexBarCore/Providers/ZoomMate/ZoomMateBearerTokenCache.swift new file mode 100644 index 0000000000..26914db789 --- /dev/null +++ b/Sources/CodexBarCore/Providers/ZoomMate/ZoomMateBearerTokenCache.swift @@ -0,0 +1,64 @@ +import Foundation +#if canImport(CryptoKit) +import CryptoKit +#else +import Crypto +#endif + +/// Process-lifetime, in-memory cache of freshly-minted ZoomMate bearer JWTs. +/// +/// The `.auto` cookie-mint path exchanges long-lived browser session cookies for a short-lived +/// (~hourly) bearer JWT on demand. Without a cache that mint happens on *every* refresh; this cache +/// lets a still-valid token be reused across refreshes instead. +/// +/// Safety properties (why reuse can't serve a bad token): +/// - Entries are keyed by a non-reversible SHA-256 of the originating host-scoped cookie headers, so distinct +/// browser sessions / accounts never collide and the raw cookies are never stored as a key. +/// - A token is cached *only* when its JWT carries a decodable `exp` claim, and is served only +/// while `now < exp - refreshSkew`. A token whose expiry cannot be determined is never cached +/// (the caller mints fresh), so the cache can never hand back a token past its own expiry. +/// - Nothing is persisted — the cache is empty on every launch. +/// +/// A revoked-before-expiry session is handled by the caller: a `401/403` from a downstream request +/// evicts the entry (see `ZoomMateWebFetchStrategy`) so the next refresh mints fresh. +actor ZoomMateBearerTokenCache { + static let shared = ZoomMateBearerTokenCache() + + /// Refresh this many seconds before the JWT's own `exp`, so an in-flight request never rides a + /// token that expires mid-flight. + static let refreshSkew: TimeInterval = 60 + + struct Entry: Sendable { + let token: String + let accountEmail: String? + let expiry: Date + } + + private var entries: [String: Entry] = [:] + + /// Non-reversible cache key for a cookie session. SHA-256 hex of its canonical host map. + static func key(forCookieHeaders cookieHeaders: ZoomMateCookieHeaders) -> String { + let canonical = cookieHeaders.encodedForStorage() ?? "" + let digest = SHA256.hash(data: Data(canonical.utf8)) + return digest.map { String(format: "%02x", $0) }.joined() + } + + /// Returns the cached entry for `key` when it is still comfortably in-date, evicting and + /// returning `nil` once it enters the `refreshSkew` window (or has passed `exp`). + func validEntry(forKey key: String, now: Date) -> Entry? { + guard let entry = self.entries[key] else { return nil } + guard entry.expiry.addingTimeInterval(-Self.refreshSkew) > now else { + self.entries[key] = nil + return nil + } + return entry + } + + func store(_ entry: Entry, forKey key: String) { + self.entries[key] = entry + } + + func invalidate(forKey key: String) { + self.entries[key] = nil + } +} diff --git a/Sources/CodexBarCore/Providers/ZoomMate/ZoomMateCookieImporter.swift b/Sources/CodexBarCore/Providers/ZoomMate/ZoomMateCookieImporter.swift new file mode 100644 index 0000000000..37dc42be10 --- /dev/null +++ b/Sources/CodexBarCore/Providers/ZoomMate/ZoomMateCookieImporter.swift @@ -0,0 +1,138 @@ +import Foundation +#if os(macOS) +import SweetCookieKit +#endif + +/// Cookie headers narrowed to ZoomMate's fixed request hosts. Keeping the destination in the +/// credential value makes it impossible for host failover to reuse a leaf-host cookie on its +/// sibling host. +public struct ZoomMateCookieHeaders: Codable, Equatable, Sendable { + static let allowedHosts = ["ai.zoom.us", "zoommate.zoom.us"] + + private let headersByHost: [String: String] + + public init(headersByHost: [String: String]) { + self.headersByHost = Dictionary(uniqueKeysWithValues: Self.allowedHosts.compactMap { host in + guard let header = headersByHost[host]?.trimmingCharacters(in: .whitespacesAndNewlines), + !header.isEmpty + else { + return nil + } + return (host, header) + }) + } + + public func header(forHost host: String) -> String? { + self.headersByHost[host.lowercased()] + } + + public var isEmpty: Bool { + self.headersByHost.isEmpty + } + + func encodedForStorage() -> String? { + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + guard let data = try? encoder.encode(self) else { return nil } + return String(data: data, encoding: .utf8) + } + + static func decodeFromStorage(_ value: String) -> Self? { + guard let data = value.data(using: .utf8) else { return nil } + return try? JSONDecoder().decode(Self.self, from: data) + } +} + +#if os(macOS) +private let zoomMateCookieImportOrder: BrowserCookieImportOrder = + ProviderDefaults.metadata[.zoommate]?.browserCookieOrder ?? Browser.defaultImportOrder + +/// Imports ZoomMate's browser session cookies (not the bearer JWT itself — see +/// `ZoomMateUsageFetcher.mintBearerToken`, which exchanges these cookies for a fresh JWT via +/// ZoomMate's own cookie-to-token bootstrap endpoint). Modeled on `T3ChatCookieImporter`. +public enum ZoomMateCookieImporter { + private static let cookieClient = BrowserCookieClient() + /// Includes the parent "zoom.us" domain — ZoomMate's SSO session cookies (`_zm_*`, + /// `cf_clearance`, etc.) are scoped to the shared parent domain, not the leaf subdomains, and + /// domain matching here is substring-based (`.contains`), so this one pattern also matches the + /// leaf domains below; both are kept for clarity. The over-broad `.contains("zoom.us")` read is + /// then narrowed at send time by `isSendable(toSessionHosts:)`. + private static let cookieDomains = ["zoommate.zoom.us", "ai.zoom.us", "zoom.us"] + + public struct SessionInfo: Sendable { + public let cookieHeaders: ZoomMateCookieHeaders + public let sourceLabel: String + + public init(cookieHeaders: ZoomMateCookieHeaders, sourceLabel: String) { + self.cookieHeaders = cookieHeaders + self.sourceLabel = sourceLabel + } + } + + public static func importSession( + browserDetection: BrowserDetection, + logger: (@Sendable (String) -> Void)? = nil) throws -> SessionInfo + { + try self.importSessions(browserDetection: browserDetection, logger: logger)[0] + } + + public static func importSessions( + browserDetection: BrowserDetection, + logger: (@Sendable (String) -> Void)? = nil) throws -> [SessionInfo] + { + let log: @Sendable (String) -> Void = { msg in logger?("[zoommate-cookie] \(msg)") } + let installed = zoomMateCookieImportOrder.cookieImportCandidates(using: browserDetection) + var sessions: [SessionInfo] = [] + + for browserSource in installed { + do { + let query = BrowserCookieQuery(domains: self.cookieDomains) + let sources = try self.cookieClient.codexBarRecords( + matching: query, + in: browserSource, + logger: log) + for source in sources where !source.records.isEmpty { + let cookies = BrowserCookieClient.makeHTTPCookies(source.records, origin: query.origin) + let cookieHeaders = Self.cookieHeaders(from: cookies) + guard !cookieHeaders.isEmpty else { continue } + log("\(source.label): found host-scoped cookie headers") + sessions.append(SessionInfo(cookieHeaders: cookieHeaders, sourceLabel: source.label)) + } + } catch { + BrowserCookieAccessGate.recordIfNeeded(error) + log("\(browserSource.displayName) cookie import failed: \(error.localizedDescription)") + } + } + + guard !sessions.isEmpty else { throw ZoomMateUsageError.noSession } + return sessions + } + + /// Whether a browser would attach a cookie scoped to `cookieDomain` to a request to `host`, per + /// RFC 6265 domain-matching: a host-only cookie matches its exact host; a + /// domain cookie (stored with a leading dot) matches that host and all of its subdomains. This + /// keeps parent `.zoom.us` SSO cookies while preventing an `ai.zoom.us` host-only cookie from + /// reaching `zoommate.zoom.us` (and vice versa). + static func isSendable(cookieDomain: String, toHost host: String) -> Bool { + let normalizedDomain = cookieDomain.lowercased() + let normalizedHost = host.lowercased() + guard ZoomMateCookieHeaders.allowedHosts.contains(normalizedHost), !normalizedDomain.isEmpty else { + return false + } + guard normalizedDomain.hasPrefix(".") else { return normalizedHost == normalizedDomain } + let bareDomain = String(normalizedDomain.dropFirst()) + guard !bareDomain.isEmpty else { return false } + return normalizedHost == bareDomain || normalizedHost.hasSuffix("." + bareDomain) + } + + static func cookieHeaders(from cookies: [HTTPCookie]) -> ZoomMateCookieHeaders { + let pairs: [(String, String)] = ZoomMateCookieHeaders.allowedHosts.compactMap { host in + let sendable = cookies.filter { Self.isSendable(cookieDomain: $0.domain, toHost: host) } + guard !sendable.isEmpty else { return nil } + let header = sendable.map { "\($0.name)=\($0.value)" }.joined(separator: "; ") + return (host, header) + } + return ZoomMateCookieHeaders(headersByHost: Dictionary(uniqueKeysWithValues: pairs)) + } +} +#endif diff --git a/Sources/CodexBarCore/Providers/ZoomMate/ZoomMateCreditsHistoryFetcher.swift b/Sources/CodexBarCore/Providers/ZoomMate/ZoomMateCreditsHistoryFetcher.swift new file mode 100644 index 0000000000..5ee73b0a00 --- /dev/null +++ b/Sources/CodexBarCore/Providers/ZoomMate/ZoomMateCreditsHistoryFetcher.swift @@ -0,0 +1,260 @@ +import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +/// One raw ledger row from `GET .../credits/history` (design.md D3). `time` is ISO8601-shaped; +/// `cost` is the credits consumed by that session/task run. +public struct ZoomMateCreditHistoryRecord: Decodable, Sendable { + public let sessionID: String? + public let title: String? + public let cost: Double? + public let time: String? + public let isRunning: Bool? + public let isDeleted: Bool? + + private enum CodingKeys: String, CodingKey { + case sessionID = "session_id" + case title + case cost + case time + case isRunning = "is_running" + case isDeleted = "is_deleted" + } + + public init( + sessionID: String?, + title: String?, + cost: Double?, + time: String?, + isRunning: Bool?, + isDeleted: Bool?) + { + self.sessionID = sessionID + self.title = title + self.cost = cost + self.time = time + self.isRunning = isRunning + self.isDeleted = isDeleted + } +} + +/// Aggregated result of fetching `credits/history` across as many pages as needed to cover the +/// requested window. Kept separate from the daily-bucketed breakdown so the same raw records can +/// be re-aggregated without refetching. +/// +/// `creditStatus` carries the `credits/status` snapshot the history fetch was paired with, so +/// the menu layer can compute the pacing verdict (`ZoomMateUsageSnapshot.pacingVerdict`) directly +/// from this one attached object instead of needing a second field on `UsageSnapshot` — deferring +/// pace computation to render time also means it always reflects "now," not the last fetch time. +public struct ZoomMateCreditsHistorySnapshot: Sendable { + public let records: [ZoomMateCreditHistoryRecord] + public let creditStatus: ZoomMateCreditStatus? + public let updatedAt: Date + + public init( + records: [ZoomMateCreditHistoryRecord], + creditStatus: ZoomMateCreditStatus? = nil, + updatedAt: Date) + { + self.records = records + self.creditStatus = creditStatus + self.updatedAt = updatedAt + } + + /// Pacing verdict computed from the paired `credits/status` snapshot, if one was attached at + /// fetch time. `nil` when no `creditStatus` is available (e.g. it wasn't passed to `fetch`) + /// or when the account is unlimited / missing cycle dates — see + /// `ZoomMateCreditStatus.pacingVerdict`. + public func pacingVerdict(now: Date = Date()) -> UsagePace? { + self.creditStatus?.pacingVerdict(now: now) + } +} + +/// Fetches and paginates `GET https://ai.zoom.us/ai-computer/api/v1/credits/history` (design.md +/// D3). Reuses the same minted-bearer `RequestContext` as `credits/status` — no separate auth +/// mechanism. `app_id` is confirmed not a scoping filter (D3/R2), so a fixed placeholder matching +/// ZoomMate's own web UI (`demo_app`) is sent on every request. +public struct ZoomMateCreditsHistoryFetcher: Sendable { + private static let log = CodexBarLog.logger(LogCategories.zoommate) + private static let historyPath = "/ai-computer/api/v1/credits/history" + private static let refererURL = URL(string: "https://zoommate.zoom.us")! + private static let userAgent = + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) " + + "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36" + + /// Confirmed cheap for real accounts (design.md D3/R3): 30 days of history is at most a + /// couple of pages at this size, well below any practical rate-limit concern. A larger + /// `limit` than the web UI's `10` reduces round-trips without meaningfully increasing + /// payload size (records are small). + public static let defaultPageLimit = 50 + /// Hard ceiling on pagination requests per fetch, independent of the account's actual + /// history size — guards against an unexpectedly large or misbehaving account/response + /// (e.g. a `total` that never gets satisfied) turning into an unbounded fetch loop. + public static let maxPages = 20 + + public init() {} + + /// Fetches every record whose `time` falls within `[startTime, endTime]`, paginating with + /// `limit`/`page` until the endpoint's flat `total` count is satisfied (no other pagination + /// metadata exists — design.md D3). + public static func fetch( + context: ZoomMateUsageFetcher.RequestContext, + startTime: Date, + endTime: Date, + creditStatus: ZoomMateCreditStatus? = nil, + limit: Int = ZoomMateCreditsHistoryFetcher.defaultPageLimit, + timeout: TimeInterval = 15, + now: Date = Date(), + transport: any ProviderHTTPTransport = ProviderHTTPClient.shared) async throws -> ZoomMateCreditsHistorySnapshot + { + // The whole pagination loop fails over as a unit so all pages of one snapshot come from + // the same host. + try await ZoomMateUsageFetcher.withAPIHostFailover( + hosts: ZoomMateUsageFetcher.hosts(preferred: context.preferredHost)) + { host in + var allRecords: [ZoomMateCreditHistoryRecord] = [] + var page = 0 + var total = Int.max + + while page * limit < total, page < self.maxPages { + let request = PageRequest( + host: host, + context: context, + startTime: startTime, + endTime: endTime, + limit: limit, + page: page, + timeout: timeout, + transport: transport) + let envelope = try await self.fetchPage(request) + guard let data = envelope.data else { + throw ZoomMateUsageError.parseFailed("Missing data object in credits/history response.") + } + let pageRecords = data.records ?? [] + allRecords.append(contentsOf: pageRecords) + total = data.total ?? allRecords.count + if pageRecords.isEmpty { + // Defensive: stop if the server ever returns an empty page before `total` is + // reached, rather than looping until `maxPages`. + break + } + // Defensive date-boundary stop (design.md D2): `total` reflects the account's entire + // history, not just the requested window, so a server-side filtering quirk could + // otherwise cause extra pagination past what the window actually needs. If every + // record on this page is already older than the requested `startTime` (rows are + // sorted `time desc`, so an entirely-stale page means all subsequent pages are stale + // too), stop here rather than trusting `total`/`maxPages` to eventually end the loop. + let allOlderThanWindow = pageRecords.allSatisfy { record in + guard let time = record.time, let parsed = Self.parseRecordTime(time) else { return false } + return parsed < startTime + } + if allOlderThanWindow { + break + } + page += 1 + } + + return ZoomMateCreditsHistorySnapshot(records: allRecords, creditStatus: creditStatus, updatedAt: now) + } + } + + private static func fetchPage(_ pageRequest: PageRequest) async throws -> HistoryEnvelope { + var components = URLComponents(string: "https://\(pageRequest.host)\(self.historyPath)")! + components.queryItems = [ + URLQueryItem(name: "app_id", value: "demo_app"), + URLQueryItem(name: "limit", value: String(pageRequest.limit)), + URLQueryItem(name: "page", value: String(pageRequest.page)), + URLQueryItem(name: "sort_by", value: "time"), + URLQueryItem(name: "sort_order", value: "desc"), + URLQueryItem(name: "start_time", value: Self.iso8601String(pageRequest.startTime)), + URLQueryItem(name: "end_time", value: Self.iso8601String(pageRequest.endTime)), + ] + guard let url = components.url else { + throw ZoomMateUsageError.apiError("Failed to build credits/history URL.") + } + + var request = URLRequest(url: url) + request.httpMethod = "GET" + request.timeoutInterval = pageRequest.timeout + request.setValue("application/json, text/plain, */*", forHTTPHeaderField: "Accept") + request.setValue("en-US,en;q=0.9", forHTTPHeaderField: "Accept-Language") + request.setValue(self.userAgent, forHTTPHeaderField: "User-Agent") + request.setValue("empty", forHTTPHeaderField: "Sec-Fetch-Dest") + request.setValue("cors", forHTTPHeaderField: "Sec-Fetch-Mode") + request.setValue("same-site", forHTTPHeaderField: "Sec-Fetch-Site") + for (name, value) in pageRequest.context.headers { + request.setValue(value, forHTTPHeaderField: name) + } + request.setValue( + pageRequest.context.cookieHeaders.header(forHost: pageRequest.host), + forHTTPHeaderField: "Cookie") + request.setValue(pageRequest.context.authorization, forHTTPHeaderField: "Authorization") + request.setValue(self.refererURL.absoluteString, forHTTPHeaderField: "Origin") + request.setValue(self.refererURL.absoluteString, forHTTPHeaderField: "Referer") + + let response = try await pageRequest.transport.response(for: request) + let data = response.data + guard response.statusCode == 200 else { + Self.log.error("ZoomMate credits/history returned \(response.statusCode)") + if response.statusCode == 401 || response.statusCode == 403 { + throw ZoomMateUsageError.invalidCredentials + } + throw ZoomMateUsageError.apiError("HTTP \(response.statusCode)") + } + + do { + return try JSONDecoder().decode(HistoryEnvelope.self, from: data) + } catch { + Self.log.error("ZoomMate credits/history parse failed") + throw ZoomMateUsageError.parseFailed(error.localizedDescription) + } + } + + private static func iso8601String(_ date: Date) -> String { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime] + return formatter.string(from: date) + } + + /// Parses a record's `time` field for the pagination date-boundary check. Tries with and + /// without fractional seconds, matching the range of ISO8601 shapes the API may return. + private static func parseRecordTime(_ text: String) -> Date? { + let withFractional = ISO8601DateFormatter() + withFractional.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + if let date = withFractional.date(from: text) { + return date + } + let plain = ISO8601DateFormatter() + plain.formatOptions = [.withInternetDateTime] + return plain.date(from: text) + } + + private struct PageRequest { + let host: String + let context: ZoomMateUsageFetcher.RequestContext + let startTime: Date + let endTime: Date + let limit: Int + let page: Int + let timeout: TimeInterval + let transport: any ProviderHTTPTransport + } + + private struct HistoryEnvelope: Decodable { + struct DataBox: Decodable { + let records: [ZoomMateCreditHistoryRecord]? + let total: Int? + } + + let data: DataBox? + let statusCode: Int? + let errorMessage: String? + + private enum CodingKeys: String, CodingKey { + case data + case statusCode = "status_code" + case errorMessage = "error_message" + } + } +} diff --git a/Sources/CodexBarCore/Providers/ZoomMate/ZoomMateModels.swift b/Sources/CodexBarCore/Providers/ZoomMate/ZoomMateModels.swift new file mode 100644 index 0000000000..2df6f0ebb3 --- /dev/null +++ b/Sources/CodexBarCore/Providers/ZoomMate/ZoomMateModels.swift @@ -0,0 +1,248 @@ +import Foundation + +private func zoomMateDate(fromMilliseconds raw: Int64?) -> Date? { + guard let raw, raw > 0 else { return nil } + return Date(timeIntervalSince1970: Double(raw) / 1000) +} + +public enum ZoomMateUsageError: LocalizedError, Sendable { + case noCapture + case noSession + case invalidCredentials + case apiError(String) + case parseFailed(String) + + public var errorDescription: String? { + switch self { + case .noCapture: + "Paste a cURL capture of the HTTPS ZoomMate credits/status request " + + "(from ai.zoom.us or zoommate.zoom.us)." + case .noSession: + "No ZoomMate session is cached and no session cookies were imported from Chrome. " + + "Sign in to zoommate.zoom.us in Chrome and refresh from CodexBar, or paste a cURL capture." + case .invalidCredentials: + "ZoomMate rejected the current credentials. Sign in again in Chrome or paste a fresh cURL capture." + case let .apiError(message): + "ZoomMate API error: \(message)" + case let .parseFailed(message): + "Could not parse ZoomMate usage: \(message)" + } + } +} + +/// Decoded shape of `data.credit_status` from +/// `GET https://ai.zoom.us/ai-computer/api/v1/credits/status`. Dates are epoch milliseconds. +public struct ZoomMateCreditStatus: Decodable, Sendable { + public let budgetCap: Double? + public let usedCredit: Double? + public let remainingCredit: Double? + public let overageCredit: Double? + public let allowOverage: Bool? + public let cycleStartDate: Int64? + public let cycleEndDate: Int64? + public let isQuotaAvailable: Bool? + public let isUnlimited: Bool? + + private enum CodingKeys: String, CodingKey { + case budgetCap = "budget_cap" + case usedCredit = "used_credit" + case remainingCredit = "remaining_credit" + case overageCredit = "overage_credit" + case allowOverage = "allow_overage" + case cycleStartDate = "cycle_start_date" + case cycleEndDate = "cycle_end_date" + case isQuotaAvailable = "is_quota_available" + case isUnlimited = "is_unlimited" + } + + public init( + budgetCap: Double?, + usedCredit: Double?, + remainingCredit: Double?, + overageCredit: Double?, + allowOverage: Bool?, + cycleStartDate: Int64?, + cycleEndDate: Int64?, + isQuotaAvailable: Bool?, + isUnlimited: Bool?) + { + self.budgetCap = budgetCap + self.usedCredit = usedCredit + self.remainingCredit = remainingCredit + self.overageCredit = overageCredit + self.allowOverage = allowOverage + self.cycleStartDate = cycleStartDate + self.cycleEndDate = cycleEndDate + self.isQuotaAvailable = isQuotaAvailable + self.isUnlimited = isUnlimited + } +} + +public struct ZoomMateUsageSnapshot: Sendable { + public let creditStatus: ZoomMateCreditStatus + public let updatedAt: Date + + public init(creditStatus: ZoomMateCreditStatus, updatedAt: Date) { + self.creditStatus = creditStatus + self.updatedAt = updatedAt + } + + /// Implements design D5's credits mapping. `history` is optional and attached only when a + /// `credits/history` fetch succeeded (design.md D3) — its absence never blocks the primary + /// credits/status snapshot from being usable. + public func toUsageSnapshot( + history: ZoomMateCreditsHistorySnapshot? = nil, + accountEmail: String? = nil) -> UsageSnapshot + { + let budgetCap = self.creditStatus.budgetCap ?? 0 + let usedCredit = self.creditStatus.usedCredit ?? 0 + let isUnlimited = self.creditStatus.isUnlimited ?? false + + let usedPercent: Double = if isUnlimited || budgetCap <= 0 { + 0 + } else { + min(100, max(0, usedCredit / budgetCap * 100)) + } + + let resetsAt: Date? = (isUnlimited || budgetCap <= 0) + ? nil + : zoomMateDate(fromMilliseconds: self.creditStatus.cycleEndDate) + + let primary = RateWindow( + usedPercent: usedPercent, + windowMinutes: nil, + resetsAt: resetsAt, + resetDescription: "Credits") + + let identity = ProviderIdentitySnapshot( + providerID: .zoommate, + accountEmail: accountEmail, + accountOrganization: nil, + loginMethod: accountEmail != nil ? "Cookie" : nil) + + return UsageSnapshot( + primary: primary, + secondary: nil, + zoommateCreditsHistory: history, + updatedAt: self.updatedAt, + identity: identity) + } + + /// Pacing verdict (design.md D3), delegated to `ZoomMateCreditStatus.pacingVerdict` so both + /// this snapshot and `ZoomMateCreditsHistorySnapshot` (which carries its own paired + /// `creditStatus`) can compute the identical verdict without duplicating the algorithm. + public func pacingVerdict(now: Date = Date()) -> UsagePace? { + self.creditStatus.pacingVerdict(now: now) + } +} + +extension ZoomMateCreditStatus { + /// Pacing verdict (design.md D3): reuses `UsagePace`'s generic stage thresholds rather than + /// reinventing them. ZoomMate's billing cycle has an arbitrary length (not a fixed weekly + /// cadence), so `windowMinutes` is set to the actual cycle duration in minutes — with + /// `workDays: nil`, `UsagePace.weekly()`'s workday-aware branch never engages and it reduces + /// to a plain linear elapsed-fraction-of-cycle comparison, which is exactly what's needed + /// here despite the "weekly" name. + public func pacingVerdict(now: Date = Date()) -> UsagePace? { + guard let budgetCap, budgetCap > 0, + self.isUnlimited != true, + let cycleStartMillis = self.cycleStartDate, + let cycleEndMillis = self.cycleEndDate + else { + return nil + } + guard let cycleStart = zoomMateDate(fromMilliseconds: cycleStartMillis), + let cycleEnd = zoomMateDate(fromMilliseconds: cycleEndMillis), + cycleEnd > cycleStart + else { + return nil + } + + let usedCredit = self.usedCredit ?? 0 + let usedPercent = min(100, max(0, usedCredit / budgetCap * 100)) + let cycleMinutes = Int(cycleEnd.timeIntervalSince(cycleStart) / 60) + guard cycleMinutes > 0 else { return nil } + + let window = RateWindow( + usedPercent: usedPercent, + windowMinutes: cycleMinutes, + resetsAt: cycleEnd, + resetDescription: "Credits") + return UsagePace.weekly(window: window, now: now, workDays: nil) + } +} + +/// One calendar day's total credit consumption, aggregated from raw `credits/history` ledger +/// records. Mirrors `OpenAIDashboardDailyBreakdown`'s shape (`day` as a local `yyyy-MM-dd` key) +/// so the same day-key parsing/formatting used by the Codex credits-history chart applies here +/// unchanged. +public struct ZoomMateCreditDailyBreakdown: Equatable, Sendable { + /// Day key in `yyyy-MM-dd` (local time). + public let day: String + public let totalCreditsUsed: Double + + public init(day: String, totalCreditsUsed: Double) { + self.day = day + self.totalCreditsUsed = totalCreditsUsed + } +} + +extension ZoomMateCreditsHistorySnapshot { + /// Aggregates raw `credits/history` records into a Today/N-day series, one entry per + /// calendar day (local time) that has at least one qualifying record. `is_deleted` records + /// are excluded per design.md D3 (they represent removed sessions, not real spend); running + /// sessions (`is_running == true`) are still counted since their `cost` reflects consumption + /// so far. Records with an unparseable `time` or a negative `cost` are skipped defensively + /// rather than corrupting the aggregate. + /// + /// Records older than a trailing 30-calendar-day window from `now` are excluded before + /// bucketing, mirroring `CostUsageFetcher`'s `since = now - (historyDays - 1)` boundary + /// (design.md D3). This makes the 30-day window an explicit, model-level guarantee rather + /// than an implicit assumption inherited from the fetcher's request parameters — the result + /// stays calendar-bounded even if the fetch window, caching, or pagination ever changes. + public func dailyBreakdown(calendar: Calendar = .current, now: Date = Date()) -> [ZoomMateCreditDailyBreakdown] { + var totalsByDay: [String: Double] = [:] + let dayKeyFormatter = DateFormatter() + dayKeyFormatter.calendar = calendar + dayKeyFormatter.timeZone = calendar.timeZone + dayKeyFormatter.dateFormat = "yyyy-MM-dd" + + let isoFormatter = ISO8601DateFormatter() + isoFormatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + let isoFormatterNoFraction = ISO8601DateFormatter() + isoFormatterNoFraction.formatOptions = [.withInternetDateTime] + + // Rolling window is inclusive, so a 30-day display starts 29 days before `now`. + let since = calendar.date(byAdding: .day, value: -29, to: now) ?? now + + for record in self.records { + guard record.isDeleted != true else { continue } + guard let cost = record.cost, cost >= 0 else { continue } + guard let timeString = record.time else { continue } + guard let date = isoFormatter.date(from: timeString) ?? isoFormatterNoFraction.date(from: timeString) + else { + continue + } + guard date >= calendar.startOfDay(for: since) else { continue } + let dayKey = dayKeyFormatter.string(from: date) + totalsByDay[dayKey, default: 0] += cost + } + + return totalsByDay + .map { ZoomMateCreditDailyBreakdown(day: $0.key, totalCreditsUsed: $0.value) } + .sorted { $0.day < $1.day } + } + + /// Sum of `cost` for whichever calendar day (local time) is "today" relative to `now`, i.e. + /// the current-day bucket from `dailyBreakdown()` if one exists. Used by the inline Today/30d + /// KPI tiles (tasks.md 3.4 follow-up) so the UI layer doesn't need to re-derive day-key + /// formatting itself. + public func todayCreditsUsed(now: Date = Date(), calendar: Calendar = .current) -> Double? { + let dayKeyFormatter = DateFormatter() + dayKeyFormatter.calendar = calendar + dayKeyFormatter.timeZone = calendar.timeZone + dayKeyFormatter.dateFormat = "yyyy-MM-dd" + let todayKey = dayKeyFormatter.string(from: now) + return self.dailyBreakdown(calendar: calendar, now: now).first { $0.day == todayKey }?.totalCreditsUsed + } +} diff --git a/Sources/CodexBarCore/Providers/ZoomMate/ZoomMateProviderDescriptor.swift b/Sources/CodexBarCore/Providers/ZoomMate/ZoomMateProviderDescriptor.swift new file mode 100644 index 0000000000..409dc9feac --- /dev/null +++ b/Sources/CodexBarCore/Providers/ZoomMate/ZoomMateProviderDescriptor.swift @@ -0,0 +1,171 @@ +import Foundation + +public enum ZoomMateProviderDescriptor { + public static let descriptor: ProviderDescriptor = Self.makeDescriptor() + + static func makeDescriptor() -> ProviderDescriptor { + ProviderDescriptor( + id: .zoommate, + metadata: ProviderMetadata( + id: .zoommate, + displayName: "ZoomMate", + sessionLabel: "Credits", + weeklyLabel: "Credits", + opusLabel: nil, + supportsOpus: false, + supportsCredits: true, + creditsHint: "Shows used/remaining credits against your ZoomMate budget cap.", + toggleTitle: "Show ZoomMate usage", + cliName: "zoommate", + defaultEnabled: false, + isPrimaryProvider: false, + usesAccountFallback: false, + browserCookieOrder: ProviderBrowserCookieDefaults.chromeOnlyImportOrder, + dashboardURL: "https://zoommate.zoom.us/#/?settings=credit-usage", + subscriptionDashboardURL: nil, + statusPageURL: "https://www.zoomstatus.com/", + statusComponentAllowlist: [ + "Zoom Meetings", + "ZoomMate", + "My Notes", + "Zoom Workflows", + "Zoom Developer Platform", + "Zoom Support", + "Zoom Website", + ]), + branding: ProviderBranding( + iconStyle: .zoommate, + iconResourceName: "ProviderIcon-zoommate", + // Zoom Brand Center "Visual identity > Color", retrieved 2026-07-18: + // https://brand.zoom.com/document/1#/visual-identity/color + // Bloom is primary; Dawn and Midnight are supporting core colors. + color: ProviderColor(red: 11 / 255, green: 92 / 255, blue: 255 / 255), + confettiPalette: [ + ProviderColor(hex: 0x0B5CFF), + ProviderColor(hex: 0xB4D0F8), + ProviderColor(hex: 0x00053D), + ]), + tokenCost: ProviderTokenCostConfig( + supportsTokenCost: false, + noDataMessage: { "ZoomMate cost summary is not supported." }), + fetchPlan: ProviderFetchPlan( + sourceModes: [.auto, .web], + pipeline: ProviderFetchPipeline(resolveStrategies: { _ in [ZoomMateWebFetchStrategy()] })), + cli: ProviderCLIConfig( + name: "zoommate", + aliases: [], + versionDetector: nil)) + } +} + +/// Single unified strategy (modeled on `T3ChatWebFetchStrategy`) branching internally on the +/// selected `cookieSource`: `.auto` resolves a cookie session — the `CookieHeaderCache`d host map +/// first, else a fresh browser import whose validated headers are persisted back through the cache — +/// and mints a bearer JWT via `ZoomMateUsageFetcher.mintBearerToken`, reusing a still-valid token +/// from `ZoomMateBearerTokenCache` across refreshes; `.manual` uses the pasted cURL capture. +/// Cookies outlive the ~hourly JWT by weeks, so minting from cookies (and caching the result until +/// it nears expiry) avoids the manual re-paste entirely as long as the underlying browser session +/// stays valid, and the persisted headers let background refreshes and the bundled CLI reuse that +/// session without rereading Chrome. A rejected session clears the cached header and retries once +/// with a fresh import (see `fetch`). +struct ZoomMateWebFetchStrategy: ProviderFetchStrategy { + let id: String = "zoommate.web" + let kind: ProviderFetchKind = .web + + func isAvailable(_ context: ProviderFetchContext) async -> Bool { + let cookieSource = context.settings?.zoommate?.cookieSource ?? .auto + guard cookieSource != .off else { return false } + if cookieSource == .manual { + return true + } + #if os(macOS) + return true + #else + return false + #endif + } + + func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult { + let cookieSource = context.settings?.zoommate?.cookieSource ?? .auto + do { + return try await self.fetchOnce(context, allowCachedCookieHeader: true) + } catch ZoomMateUsageError.invalidCredentials where cookieSource == .auto { + // The persisted cookie session (or a bearer minted from it) was rejected. Drop the + // cached headers and retry once against a fresh browser import, mirroring + // OpenCodeUsageFetchStrategy. Outside user-initiated contexts the import is + // gate-blocked, so the retry surfaces `noSession` instead of replaying a dead cookie. + CookieHeaderCache.clear(provider: .zoommate) + return try await self.fetchOnce(context, allowCachedCookieHeader: false) + } + } + + private func fetchOnce( + _ context: ProviderFetchContext, + allowCachedCookieHeader: Bool) async throws -> ProviderFetchResult + { + let fetcher = ZoomMateUsageFetcher(browserDetection: context.browserDetection) + let manual = Self.manualCookieHeader(from: context) + let logger: (@Sendable (String) -> Void)? = context.verbose + ? { @Sendable msg in CodexBarLog.logger(LogCategories.zoommate).verbose(msg) } + : nil + let requestContext = try await fetcher.resolveRequestContext( + manualCaptureOverride: manual, + allowCachedCookieHeader: allowCachedCookieHeader, + timeout: context.webTimeout, + logger: logger) + let snapshot: ZoomMateUsageSnapshot + do { + snapshot = try await ZoomMateUsageFetcher.fetchCreditsStatus( + context: requestContext, + timeout: context.webTimeout) + } catch ZoomMateUsageError.invalidCredentials { + // A reused cached bearer token was rejected (revoked session before its own expiry). + // Evict it so the next refresh mints fresh rather than replaying the dead token. + await Self.invalidateCachedBearerToken(for: requestContext) + throw ZoomMateUsageError.invalidCredentials + } + + // The Today/30-day history chart (design.md D3) is a non-fatal adjunct: a failure here + // (e.g. a transient credits/history error) must never block the primary credits/status + // snapshot from being usable, mirroring ZaiUsageStats.fetchUsageWithModelUsage's + // secondary-fetch pattern. + var history: ZoomMateCreditsHistorySnapshot? + do { + let now = Date() + let startTime = Calendar.current.date(byAdding: .day, value: -30, to: now) ?? now + history = try await ZoomMateCreditsHistoryFetcher.fetch( + context: requestContext, + startTime: startTime, + endTime: now, + creditStatus: snapshot.creditStatus, + timeout: context.webTimeout) + } catch ZoomMateUsageError.invalidCredentials { + await Self.invalidateCachedBearerToken(for: requestContext) + CodexBarLog.logger(LogCategories.zoommate) + .info("ZoomMate credits history fetch failed (non-fatal): invalid credentials") + history = nil + } catch { + CodexBarLog.logger(LogCategories.zoommate) + .info("ZoomMate credits history fetch failed (non-fatal): \(error.localizedDescription)") + history = nil + } + + return self.makeResult( + usage: snapshot.toUsageSnapshot(history: history, accountEmail: requestContext.accountEmail), + sourceLabel: "web") + } + + func shouldFallback(on _: Error, context _: ProviderFetchContext) -> Bool { + false + } + + private static func manualCookieHeader(from context: ProviderFetchContext) -> String? { + guard context.settings?.zoommate?.cookieSource == .manual else { return nil } + return context.settings?.zoommate?.manualCookieHeader ?? "" + } + + private static func invalidateCachedBearerToken(for requestContext: ZoomMateUsageFetcher.RequestContext) async { + guard let cacheKey = requestContext.cacheKey else { return } + await ZoomMateBearerTokenCache.shared.invalidate(forKey: cacheKey) + } +} diff --git a/Sources/CodexBarCore/Providers/ZoomMate/ZoomMateUsageFetcher.swift b/Sources/CodexBarCore/Providers/ZoomMate/ZoomMateUsageFetcher.swift new file mode 100644 index 0000000000..e9bdf8538e --- /dev/null +++ b/Sources/CodexBarCore/Providers/ZoomMate/ZoomMateUsageFetcher.swift @@ -0,0 +1,555 @@ +import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +public struct ZoomMateUsageFetcher: Sendable { + private static let log = CodexBarLog.logger(LogCategories.zoommate) + private static let refererURL = URL(string: "https://zoommate.zoom.us")! + /// First-party API hosts, tried in order. `ai.zoom.us` and `zoommate.zoom.us` currently serve + /// the same `/ai-computer/` API interchangeably and either may retire in the future, so every + /// API request falls over to the next host on non-auth failures via `withAPIHostFailover` + /// (precedent: `FactoryStatusProbe`'s base-URL candidates). + static let apiHosts = ZoomMateCookieHeaders.allowedHosts + static let creditsStatusPath = "/ai-computer/api/v1/credits/status" + private static let userAgent = + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) " + + "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36" + + /// Forwarded headers allowlist for the manual `.web` cURL capture. Unlike T3 Chat's, this + /// MUST include `authorization` (design D2) because ZoomMate's credential is a bearer token, + /// not a cookie. + private static let forwardedManualHeaders = [ + "authorization": "Authorization", + "cookie": "Cookie", + "user-agent": "User-Agent", + "accept": "Accept", + "accept-language": "Accept-Language", + "sec-fetch-dest": "Sec-Fetch-Dest", + "sec-fetch-mode": "Sec-Fetch-Mode", + "sec-fetch-site": "Sec-Fetch-Site", + ] + + public struct RequestContext: Sendable { + public let authorization: String + public let headers: [String: String] + public let cookieHeaders: ZoomMateCookieHeaders + public let preferredHost: String? + /// Signed-in user's email, when known. Only populated by the `.auto` cookie-mint path + /// (sourced from the login bootstrap response's `data.user_profile.email`); the manual + /// `.web` cURL-capture path has no equivalent payload to read it from, so this stays `nil` + /// there. + public let accountEmail: String? + /// Bearer-token cache key for the originating cookie session (`.auto` path only). Lets a + /// caller evict the reused token from `ZoomMateBearerTokenCache` when a downstream request + /// rejects it (`401/403`). `nil` for the manual `.web` path, which carries its own bearer. + public let cacheKey: String? + + public init( + authorization: String, + headers: [String: String] = [:], + cookieHeaders: ZoomMateCookieHeaders = ZoomMateCookieHeaders(headersByHost: [:]), + preferredHost: String? = nil, + accountEmail: String? = nil, + cacheKey: String? = nil) + { + self.authorization = authorization + self.headers = headers + self.cookieHeaders = cookieHeaders + self.preferredHost = preferredHost + self.accountEmail = accountEmail + self.cacheKey = cacheKey + } + } + + /// Result of `mintBearerToken`: the freshly-minted bearer JWT plus whatever identity + /// enrichment (currently just `email`) the same login bootstrap response happened to include + /// in its `data.user_profile` object. Modeled on the small multi-field result structs other + /// providers return from a single fetch (e.g. `ZoomMateCookieImporter.SessionInfo`). + public struct MintedToken: Sendable { + public let bearerToken: String + public let accountEmail: String? + + public init(bearerToken: String, accountEmail: String?) { + self.bearerToken = bearerToken + self.accountEmail = accountEmail + } + } + + public let browserDetection: BrowserDetection + + public init(browserDetection: BrowserDetection) { + self.browserDetection = browserDetection + } + + public func fetch( + manualCaptureOverride: String? = nil, + timeout: TimeInterval = 15, + logger: (@Sendable (String) -> Void)? = nil, + now: Date = Date(), + transport: any ProviderHTTPTransport = ProviderHTTPClient.shared) async throws -> ZoomMateUsageSnapshot + { + let log: @Sendable (String) -> Void = { msg in logger?("[zoommate] \(msg)") } + let context = try await self.resolveRequestContext( + manualCaptureOverride: manualCaptureOverride, + timeout: timeout, + logger: log, + transport: transport) + if !context.headers.isEmpty || !context.cookieHeaders.isEmpty { + var names = Set(context.headers.keys) + if !context.cookieHeaders.isEmpty { + names.insert("Cookie") + } + let headerNames = names.sorted().joined(separator: ", ") + log("Forwarding captured headers: \(headerNames)") + } + return try await Self.fetchCreditsStatus( + context: context, + timeout: timeout, + now: now, + transport: transport) + } + + public static func fetchCreditsStatus( + context: RequestContext, + timeout: TimeInterval = 15, + now: Date = Date(), + transport: any ProviderHTTPTransport = ProviderHTTPClient.shared) async throws -> ZoomMateUsageSnapshot + { + try await self.withAPIHostFailover(hosts: self.hosts(preferred: context.preferredHost)) { host in + try await self.fetchCreditsStatus( + context: context, + host: host, + timeout: timeout, + now: now, + transport: transport) + } + } + + /// Runs one API request per host in `apiHosts` order, returning the first success. Auth + /// rejections and parse failures propagate immediately — the host answered, so retrying the + /// interchangeable alternate cannot help; anything else (unreachable host, non-auth HTTP + /// error) falls through to the next host so the provider keeps working if either host + /// retires. + static func withAPIHostFailover( + hosts: [String] = ZoomMateUsageFetcher.apiHosts, + operation: (String) async throws -> T) async throws -> T + { + var lastError: Error? + for (index, host) in hosts.enumerated() { + try Task.checkCancellation() + do { + return try await operation(host) + } catch is CancellationError { + throw CancellationError() + } catch let error as URLError where error.code == .cancelled { + throw CancellationError() + } catch ZoomMateUsageError.invalidCredentials { + throw ZoomMateUsageError.invalidCredentials + } catch let ZoomMateUsageError.parseFailed(message) { + throw ZoomMateUsageError.parseFailed(message) + } catch { + if Task.isCancelled { + throw CancellationError() + } + lastError = error + if index < hosts.count - 1 { + Self.log.info("ZoomMate API host unavailable; retrying on the alternate host") + } + } + } + throw lastError ?? ZoomMateUsageError.apiError("No ZoomMate API host succeeded.") + } + + private static func fetchCreditsStatus( + context: RequestContext, + host: String, + timeout: TimeInterval, + now: Date, + transport: any ProviderHTTPTransport) async throws -> ZoomMateUsageSnapshot + { + var request = URLRequest(url: URL(string: "https://\(host)\(self.creditsStatusPath)")!) + request.httpMethod = "GET" + request.timeoutInterval = timeout + self.applyDefaultHeaders(to: &request) + for (name, value) in context.headers { + request.setValue(value, forHTTPHeaderField: name) + } + request.setValue(context.cookieHeaders.header(forHost: host), forHTTPHeaderField: "Cookie") + // Authorization is always sent (the required credential per design D2). Origin and Referer + // are fixed here so captured values can never widen the first-party request boundary. + request.setValue(context.authorization, forHTTPHeaderField: "Authorization") + request.setValue(self.refererURL.absoluteString, forHTTPHeaderField: "Origin") + request.setValue(self.refererURL.absoluteString, forHTTPHeaderField: "Referer") + + let response = try await transport.response(for: request) + let data = response.data + guard response.statusCode == 200 else { + Self.log.error("ZoomMate API returned \(response.statusCode)") + if response.statusCode == 401 || response.statusCode == 403 { + throw ZoomMateUsageError.invalidCredentials + } + throw ZoomMateUsageError.apiError("HTTP \(response.statusCode)") + } + + do { + let envelope = try JSONDecoder().decode(CreditsStatusEnvelope.self, from: data) + guard let creditStatus = envelope.data?.creditStatus else { + throw ZoomMateUsageError.parseFailed("Missing credit_status object.") + } + return ZoomMateUsageSnapshot(creditStatus: creditStatus, updatedAt: now) + } catch let error as ZoomMateUsageError { + throw error + } catch { + Self.log.error("ZoomMate credits/status parse failed") + throw ZoomMateUsageError.parseFailed(error.localizedDescription) + } + } + + /// Exchanges ZoomMate/Zoom session cookie headers for a fresh bearer JWT via ZoomMate's own + /// cookie-to-token bootstrap endpoint — the same call its web frontend makes on every page + /// load. Cookies (session/SSO-backed) live far longer than the ~hourly JWT, so minting a fresh + /// token from cookies avoids the manual re-paste entirely as long as the underlying browser + /// session cookies remain valid. Callers should prefer `cachedOrMintedToken`, which reuses a + /// still-valid minted token from `ZoomMateBearerTokenCache` instead of re-minting every fetch. + public static func mintBearerToken( + cookieHeaders: ZoomMateCookieHeaders, + timeout: TimeInterval = 15, + transport: any ProviderHTTPTransport = ProviderHTTPClient.shared) async throws -> MintedToken + { + try await self.withAPIHostFailover { host in + try await self.mintBearerToken( + cookieHeader: cookieHeaders.header(forHost: host), + host: host, + timeout: timeout, + transport: transport) + } + } + + private static func mintBearerToken( + cookieHeader: String?, + host: String, + timeout: TimeInterval, + transport: any ProviderHTTPTransport) async throws -> MintedToken + { + var components = URLComponents(string: "https://\(host)/ai-computer/api/v1/login/")! + components.queryItems = [URLQueryItem(name: "continue", value: "https://zoommate.zoom.us/")] + guard let url = components.url else { + throw ZoomMateUsageError.apiError("Failed to build login bootstrap URL.") + } + + var request = URLRequest(url: url) + request.httpMethod = "GET" + request.timeoutInterval = timeout + self.applyDefaultHeaders(to: &request) + request.setValue(self.refererURL.absoluteString, forHTTPHeaderField: "Origin") + request.setValue(self.refererURL.absoluteString, forHTTPHeaderField: "Referer") + request.setValue(cookieHeader, forHTTPHeaderField: "Cookie") + + let response = try await transport.response(for: request) + let data = response.data + guard response.statusCode == 200 else { + Self.log.error("ZoomMate login bootstrap returned \(response.statusCode)") + if response.statusCode == 401 || response.statusCode == 403 { + throw ZoomMateUsageError.invalidCredentials + } + throw ZoomMateUsageError.apiError("HTTP \(response.statusCode)") + } + + do { + let envelope = try JSONDecoder().decode(LoginBootstrapEnvelope.self, from: data) + guard let nak = envelope.data?.nak, !nak.isEmpty else { + throw ZoomMateUsageError.parseFailed("Missing nak in login bootstrap response.") + } + let email = envelope.data?.userProfile?.email?.trimmingCharacters(in: .whitespacesAndNewlines) + return MintedToken(bearerToken: nak, accountEmail: (email?.isEmpty ?? true) ? nil : email) + } catch let error as ZoomMateUsageError { + throw error + } catch { + throw ZoomMateUsageError.parseFailed(error.localizedDescription) + } + } + + func resolveRequestContext( + manualCaptureOverride: String?, + allowCachedCookieHeader: Bool = true, + timeout: TimeInterval, + logger: (@Sendable (String) -> Void)?, + cache: ZoomMateBearerTokenCache = .shared, + transport: any ProviderHTTPTransport = ProviderHTTPClient.shared) async throws -> RequestContext + { + if let manualCaptureOverride { + guard let override = Self.requestContext(from: manualCaptureOverride) else { + throw ZoomMateUsageError.noCapture + } + logger?("[zoommate] Using manual cURL capture") + return override + } + + #if os(macOS) + // Cached host-scoped cookie headers first (Perplexity/OpenCode precedent): Chrome's cookie decryption + // is gated behind user-initiated contexts (`BrowserCookieAccessGate`) to avoid Keychain + // prompts, so background refreshes and the bundled CLI must be able to run entirely from + // the last validated session instead of rereading the browser. + if allowCachedCookieHeader, + let cached = CookieHeaderCache.load(provider: .zoommate), + let cookieHeaders = ZoomMateCookieHeaders.decodeFromStorage(cached.cookieHeader), + !cookieHeaders.isEmpty + { + logger?("[zoommate] Using cached cookie headers from \(cached.sourceLabel)") + return try await Self.requestContext( + forCookieHeaders: cookieHeaders, + persistingValidatedHeaderAs: nil, + cache: cache, + timeout: timeout, + transport: transport, + logger: logger) + } + + let sessions = try ZoomMateCookieImporter.importSessions( + browserDetection: self.browserDetection, + logger: logger) + return try await Self.requestContext( + forCookieSessions: sessions, + cache: cache, + timeout: timeout, + transport: transport, + logger: logger) + #else + throw ZoomMateUsageError.noSession + #endif + } + + #if os(macOS) + /// Tries browser cookie profiles in import order, advancing only when the login bootstrap + /// explicitly rejects a candidate. Network and parse failures surface immediately rather than + /// being hidden by another profile. Only the first successfully minted session is persisted. + static func requestContext( + forCookieSessions sessions: [ZoomMateCookieImporter.SessionInfo], + cache: ZoomMateBearerTokenCache = .shared, + timeout: TimeInterval, + transport: any ProviderHTTPTransport = ProviderHTTPClient.shared, + logger: (@Sendable (String) -> Void)?) async throws -> RequestContext + { + guard !sessions.isEmpty else { throw ZoomMateUsageError.noSession } + + for session in sessions { + logger?("[zoommate] Trying cookies from \(session.sourceLabel)") + do { + return try await self.requestContext( + forCookieHeaders: session.cookieHeaders, + persistingValidatedHeaderAs: session.sourceLabel, + cache: cache, + timeout: timeout, + transport: transport, + logger: logger) + } catch ZoomMateUsageError.invalidCredentials { + logger?("[zoommate] Cookie session from \(session.sourceLabel) was rejected") + } + } + + throw ZoomMateUsageError.invalidCredentials + } + + /// Builds the `.auto` request context for a cookie session: reuses or mints the bearer JWT + /// and, when `sourceLabel` is non-nil (a fresh browser import), persists the now-validated + /// cookie headers through `CookieHeaderCache`. The successful mint is the validation — + /// ZoomMate's login bootstrap rejects a dead session with 401/403 before anything is stored. + /// Only the cookie headers are persisted; the minted bearer stays in the in-memory + /// `ZoomMateBearerTokenCache`. + static func requestContext( + forCookieHeaders cookieHeaders: ZoomMateCookieHeaders, + persistingValidatedHeaderAs sourceLabel: String?, + cache: ZoomMateBearerTokenCache = .shared, + timeout: TimeInterval, + transport: any ProviderHTTPTransport = ProviderHTTPClient.shared, + logger: (@Sendable (String) -> Void)?) async throws -> RequestContext + { + let minted = try await Self.cachedOrMintedToken( + cookieHeaders: cookieHeaders, + cache: cache, + timeout: timeout, + transport: transport, + logger: logger) + if let sourceLabel, let encodedCookieHeaders = cookieHeaders.encodedForStorage() { + CookieHeaderCache.store( + provider: .zoommate, + cookieHeader: encodedCookieHeaders, + sourceLabel: sourceLabel) + } + return RequestContext( + authorization: Self.bearerHeaderValue(from: minted.bearerToken), + cookieHeaders: cookieHeaders, + accountEmail: minted.accountEmail, + cacheKey: ZoomMateBearerTokenCache.key(forCookieHeaders: cookieHeaders)) + } + #endif + + /// Returns a still-valid cached bearer token for `cookieHeaders`, or mints a fresh one and caches + /// it when the minted JWT exposes an `exp` claim. A token whose expiry can't be read is returned + /// but never cached, so `.auto` refreshes degrade to the mint-every-fetch behavior rather than + /// risk serving an undatable (possibly expired) token. + static func cachedOrMintedToken( + cookieHeaders: ZoomMateCookieHeaders, + cache: ZoomMateBearerTokenCache, + timeout: TimeInterval, + transport: any ProviderHTTPTransport, + logger: (@Sendable (String) -> Void)?) async throws -> MintedToken + { + let cacheKey = ZoomMateBearerTokenCache.key(forCookieHeaders: cookieHeaders) + if let entry = await cache.validEntry(forKey: cacheKey, now: Date()) { + logger?("[zoommate] Reusing cached bearer token") + return MintedToken(bearerToken: entry.token, accountEmail: entry.accountEmail) + } + let minted = try await Self.mintBearerToken( + cookieHeaders: cookieHeaders, + timeout: timeout, + transport: transport) + if let expiry = Self.expiry(fromJWT: minted.bearerToken) { + await cache.store( + ZoomMateBearerTokenCache.Entry( + token: minted.bearerToken, + accountEmail: minted.accountEmail, + expiry: expiry), + forKey: cacheKey) + logger?("[zoommate] Minted fresh bearer token via cookie session (cached until expiry)") + } else { + logger?("[zoommate] Minted fresh bearer token via cookie session (not cached: no expiry claim)") + } + return minted + } + + /// Reads the `exp` claim (seconds since epoch) from a bearer JWT, returning its expiry `Date`. + /// Returns `nil` for anything that isn't a decodable JWT with a numeric `exp` — the caller then + /// treats the token as non-cacheable. Mirrors the base64url/JSON payload decode used elsewhere + /// (e.g. `MiniMaxLocalStorageImporter`); no signature verification (we minted it ourselves). + static func expiry(fromJWT token: String) -> Date? { + let raw = Self.bearerHeaderValue(from: token).dropFirst("Bearer ".count) + let parts = raw.split(separator: ".") + guard parts.count >= 2, let data = Self.base64URLDecode(String(parts[1])) else { return nil } + guard let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let exp = (object["exp"] as? NSNumber)?.doubleValue, exp > 0 + else { + return nil + } + return Date(timeIntervalSince1970: exp) + } + + private static func base64URLDecode(_ value: String) -> Data? { + var base64 = value.replacingOccurrences(of: "-", with: "+") + .replacingOccurrences(of: "_", with: "/") + let padding = (4 - base64.count % 4) % 4 + if padding > 0 { + base64.append(String(repeating: "=", count: padding)) + } + return Data(base64Encoded: base64) + } + + static func bearerHeaderValue(from rawToken: String) -> String { + let trimmed = rawToken.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.lowercased().hasPrefix("bearer ") { + return trimmed + } + return "Bearer \(trimmed)" + } + + /// Parses a manual cURL capture into a `RequestContext`. Returns `nil` when no non-empty + /// `Authorization` header can be extracted — that's the required credential (design D2). + static func requestContext(from raw: String?) -> RequestContext? { + guard let raw = raw?.trimmingCharacters(in: .whitespacesAndNewlines), !raw.isEmpty else { return nil } + guard let captureURL = CurlCaptureParser.requestURL(from: raw), + self.isAllowedCaptureURL(captureURL), + let captureHost = captureURL.host?.lowercased() + else { + return nil + } + let headerFields = CurlCaptureParser.headerFields(from: raw) + guard let authorization = CurlCaptureParser.headerValue(named: "Authorization", in: headerFields), + !authorization.isEmpty + else { + return nil + } + var headers = CurlCaptureParser.forwardedHeaders(from: headerFields, allowlist: self.forwardedManualHeaders) + headers.removeValue(forKey: "Authorization") + let cookieHeader = headers.removeValue(forKey: "Cookie") + let cookieHeaders = ZoomMateCookieHeaders(headersByHost: cookieHeader.map { [captureHost: $0] } ?? [:]) + return RequestContext( + authorization: Self.bearerHeaderValue(from: authorization), + headers: headers, + cookieHeaders: cookieHeaders, + preferredHost: captureHost) + } + + /// Captures are accepted from any host in `apiHosts` (the interchangeable first-party API + /// hosts) — DevTools shows the credits/status request on whichever host the web client used — + /// but only for the exact HTTPS credits/status path with no port, userinfo, query, or fragment. + private static func isAllowedCaptureURL(_ url: URL) -> Bool { + guard let host = url.host?.lowercased() else { return false } + return url.scheme?.lowercased() == "https" && + self.apiHosts.contains(host) && + url.port == nil && + url.user == nil && + url.password == nil && + url.path == self.creditsStatusPath && + url.query == nil && + url.fragment == nil + } + + private static func applyDefaultHeaders(to request: inout URLRequest) { + request.setValue("application/json, text/plain, */*", forHTTPHeaderField: "Accept") + request.setValue("en-US,en;q=0.9", forHTTPHeaderField: "Accept-Language") + request.setValue(self.userAgent, forHTTPHeaderField: "User-Agent") + request.setValue("empty", forHTTPHeaderField: "Sec-Fetch-Dest") + request.setValue("cors", forHTTPHeaderField: "Sec-Fetch-Mode") + request.setValue("same-site", forHTTPHeaderField: "Sec-Fetch-Site") + } + + static func hosts(preferred host: String?) -> [String] { + guard let host = host?.lowercased(), self.apiHosts.contains(host) else { return self.apiHosts } + return [host] + self.apiHosts.filter { $0 != host } + } + + private struct CreditsStatusEnvelope: Decodable { + struct DataBox: Decodable { + let creditStatus: ZoomMateCreditStatus? + + private enum CodingKeys: String, CodingKey { + case creditStatus = "credit_status" + } + } + + let data: DataBox? + let statusCode: Int? + let errorMessage: String? + + private enum CodingKeys: String, CodingKey { + case data + case statusCode = "status_code" + case errorMessage = "error_message" + } + } + + /// Shape of ZoomMate's cookie-to-token bootstrap response (`GET .../login/?continue=...`). + /// `data.nak` (the freshly-minted bearer JWT) is required; `data.user_profile.email` is + /// decoded as an optional identity-enrichment nice-to-have (never required — a missing/absent + /// `user_profile` or `email` must never fail the mint). The rest of the payload (permissions, + /// cluster config, etc.) is ignored. + private struct LoginBootstrapEnvelope: Decodable { + struct UserProfile: Decodable { + let email: String? + } + + struct DataBox: Decodable { + let nak: String? + let userProfile: UserProfile? + + private enum CodingKeys: String, CodingKey { + case nak + case userProfile = "user_profile" + } + } + + let success: Bool? + let data: DataBox? + } +} diff --git a/Sources/CodexBarCore/RemoteSessionFetcher.swift b/Sources/CodexBarCore/RemoteSessionFetcher.swift new file mode 100644 index 0000000000..e69c23db28 --- /dev/null +++ b/Sources/CodexBarCore/RemoteSessionFetcher.swift @@ -0,0 +1,280 @@ +import Foundation + +public struct RemoteSessionHostResult: Equatable, Sendable, Identifiable { + public let host: String + public let sessions: [AgentSession] + public let error: String? + + public var id: String { + self.host + } + + public var isReachable: Bool { + self.error == nil + } + + public init(host: String, sessions: [AgentSession], error: String?) { + self.host = host + self.sessions = sessions + self.error = error + } +} + +public enum TailscaleStatusParser { + /// Parses hosts from `tailscale status --json` output. + /// + /// Returns `nil` when `data` is not recognizable Tailscale status JSON — a failed, wrong, or + /// non-Tailscale `tailscale` binary — so callers can fall through to the next candidate. Returns a + /// possibly-empty list for a valid status that simply has no eligible peers (a real answer, stop). + package static func parseHosts(from data: Data, excludingLocalHost localHost: String? = nil) -> [String]? { + guard let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return nil } + if let rawBackendState = root["BackendState"] { + guard let backendState = rawBackendState as? String, + backendState.caseInsensitiveCompare("Running") == .orderedSame + else { return nil } + } + + let selfStatus: [String: Any]? + if let rawSelf = root["Self"] { + guard let parsedSelf = rawSelf as? [String: Any] else { return nil } + selfStatus = parsedSelf + } else { + selfStatus = nil + } + + let peers: [[String: Any]] + let hasPeerShape: Bool + switch root["Peer"] { + case let dictionary as [String: [String: Any]]: + peers = Array(dictionary.values) + hasPeerShape = true + case let array as [[String: Any]]: + peers = array + hasPeerShape = true + case is NSNull: + peers = [] + hasPeerShape = true + case nil: + peers = [] + hasPeerShape = false + default: + return nil + } + guard selfStatus != nil || hasPeerShape else { return nil } + let localLabels = Set([ + localHost, + selfStatus?["DNSName"] as? String, + selfStatus?["HostName"] as? String, + ].compactMap(self.firstDNSLabel).map { $0.lowercased() }) + + var seen = Set() + return peers.compactMap { peer in + guard peer["Online"] as? Bool == true, + let operatingSystem = peer["OS"] as? String, + operatingSystem == "macOS" || operatingSystem == "linux", + let label = self.firstDNSLabel(peer["DNSName"] as? String) + else { return nil } + let normalized = label.lowercased() + guard !localLabels.contains(normalized), seen.insert(normalized).inserted else { return nil } + return label + }.sorted() + } + + /// Convenience returning `[]` for unparseable output. Prefer `parseHosts` when the caller needs to + /// distinguish a failed probe from an empty tailnet. + public static func hosts(from data: Data, excludingLocalHost localHost: String? = nil) -> [String] { + self.parseHosts(from: data, excludingLocalHost: localHost) ?? [] + } + + private static func firstDNSLabel(_ value: String?) -> String? { + guard let value else { return nil } + let trimmed = value.trimmingCharacters(in: CharacterSet(charactersIn: ".")) + guard let label = trimmed.split(separator: ".").first, !label.isEmpty else { return nil } + return String(label) + } +} + +public struct RemoteSessionFetcher: Sendable { + public static let bundledCLIFallback = "/Applications/CodexBar.app/Contents/Helpers/CodexBarCLI" + + public init() {} + + public func discoveredHosts( + environment: [String: String] = ProcessInfo.processInfo.environment, + localHost: String = ProcessInfo.processInfo.hostName) async -> [String] + { + let probeEnvironment = Self.tailscaleCLIEnvironment(from: environment) + let candidates = Self.tailscaleBinaryCandidates(path: environment["PATH"]) + .filter { FileManager.default.isExecutableFile(atPath: $0) } + return await Self.firstDiscoveredHosts(candidates: candidates, localHost: localHost) { binary in + guard let result = try? await SubprocessRunner.run( + binary: binary, + arguments: ["status", "--json"], + environment: probeEnvironment, + timeout: 5, + label: "Tailscale session host discovery") + else { return nil } + return Data(result.stdout.utf8) + } + } + + /// Runs `tailscale status --json` on each candidate in order, falling through to the next when a + /// candidate fails (`run` returns nil), returns invalid status JSON, or reports an inactive backend. + /// Returns the first candidate's parsed hosts (possibly empty), or `[]` if none succeed. This keeps + /// the app-binary fallback working even when an earlier — but non-functional — `tailscale` variant + /// is installed (e.g. an open-source/Homebrew CLI that isn't the active client). + package static func firstDiscoveredHosts( + candidates: [String], + localHost: String?, + run: (String) async -> Data?) async -> [String] + { + for binary in candidates { + guard let data = await run(binary), + let hosts = TailscaleStatusParser.parseHosts(from: data, excludingLocalHost: localHost) + else { continue } + return hosts + } + return [] + } + + public func fetch( + hosts: [String], + environment: [String: String] = ProcessInfo.processInfo.environment) async -> [RemoteSessionHostResult] + { + let normalizedHosts = Self.sanitizedHosts(hosts) + return await withTaskGroup( + of: RemoteSessionHostResult.self, + returning: [RemoteSessionHostResult].self) + { group in + for host in normalizedHosts { + group.addTask { + await self.fetch(host: host, environment: environment) + } + } + var results: [RemoteSessionHostResult] = [] + for await result in group { + results.append(result) + } + return results + .sorted { lhs, rhs in lhs.host.localizedCaseInsensitiveCompare(rhs.host) == .orderedAscending } + } + } + + public func focus( + sessionID: String, + host: String, + environment: [String: String] = ProcessInfo.processInfo.environment) async + { + guard let host = Self.sanitizedHosts([host]).first else { return } + guard let ssh = self.findExecutable("ssh", environment: environment) ?? + (["/usr/bin/ssh", "/bin/ssh"].first { FileManager.default.isExecutableFile(atPath: $0) }) + else { return } + let command = "codexbar sessions focus \(Self.shellQuote(sessionID)) || " + + "\(Self.shellQuote(Self.bundledCLIFallback)) sessions focus \(Self.shellQuote(sessionID))" + _ = try? await SubprocessRunner.run( + binary: ssh, + arguments: ["-o", "BatchMode=yes", "-o", "ConnectTimeout=3", host, "sh", "-lc", Self.shellQuote(command)], + environment: environment, + timeout: 5, + acceptsNonZeroExit: true, + label: "focus remote agent session") + } + + private func fetch(host: String, environment: [String: String]) async -> RemoteSessionHostResult { + guard let ssh = self.findExecutable("ssh", environment: environment) ?? + (["/usr/bin/ssh", "/bin/ssh"].first { FileManager.default.isExecutableFile(atPath: $0) }) + else { + return RemoteSessionHostResult(host: host, sessions: [], error: "ssh not found") + } + let command = "codexbar sessions --json || " + + "\(Self.shellQuote(Self.bundledCLIFallback)) sessions --json" + do { + let result = try await SubprocessRunner.run( + binary: ssh, + arguments: [ + "-o", "BatchMode=yes", + "-o", "ConnectTimeout=3", + host, + "sh", "-lc", Self.shellQuote(command), + ], + environment: environment, + timeout: 5, + label: "fetch remote agent sessions") + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + var sessions = try decoder.decode([AgentSession].self, from: Data(result.stdout.utf8)) + for index in sessions.indices { + sessions[index].host = host + } + return RemoteSessionHostResult(host: host, sessions: sessions, error: nil) + } catch { + return RemoteSessionHostResult(host: host, sessions: [], error: error.localizedDescription) + } + } + + /// Ordered candidate paths for the `tailscale` CLI, most-preferred first. + /// + /// The macOS app ships its CLI as a thin `/bin/sh` wrapper (usually + /// `/usr/local/bin/tailscale`) around the app's dual-mode binary. We prefer the + /// wrapper, but a GUI-launched CodexBar inherits a minimal `PATH` (`/usr/bin:/bin`) + /// that omits the standard CLI locations, so we also probe them explicitly before + /// falling back to the app binary itself. + package static func tailscaleBinaryCandidates(path: String?) -> [String] { + let pathDirs = path?.split(separator: ":").map(String.init) ?? [] + var seen = Set() + var candidates = (pathDirs + ["/usr/local/bin", "/opt/homebrew/bin"]) + .filter { seen.insert($0).inserted } + .map { $0 + "/tailscale" } + // Last resort: the dual-mode app binary. Must be run via + // `tailscaleCLIEnvironment(from:)` so it stays in CLI mode. + candidates.append("/Applications/Tailscale.app/Contents/MacOS/Tailscale") + return candidates + } + + /// Environment that keeps the dual-mode Tailscale app binary in CLI mode. + /// + /// With no shell/terminal marker present the binary boots the full menu-bar GUI + /// (SkyLight/WindowServer, status icon) instead of running the CLI: it never emits + /// JSON, the probe times out, and the Tailscale icon flickers on every refresh. A + /// set `TERM` or `SHLVL` forces CLI mode (argv[0] casing and `XPC_SERVICE_NAME` do + /// not). `SHLVL` is what the app's own `/bin/sh` CLI wrapper injects, so we mirror it here. + /// + /// Applied to every probe, not just the app-binary fallback: it is redundant but harmless for the + /// CLI wrapper (itself a `/bin/sh` script that already exports `SHLVL`), and injecting it + /// unconditionally keeps CLI mode guaranteed regardless of which binary `tailscaleBinary` resolves. + /// An existing `TERM`/`SHLVL` (real terminal context) is left untouched. + package static func tailscaleCLIEnvironment(from environment: [String: String]) -> [String: String] { + guard environment["TERM"] == nil, environment["SHLVL"] == nil else { return environment } + var environment = environment + environment["SHLVL"] = "1" + return environment + } + + private func findExecutable(_ name: String, environment: [String: String]) -> String? { + let path = environment["PATH"] ?? "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin" + return path.split(separator: ":") + .map { String($0) + "/" + name } + .first { FileManager.default.isExecutableFile(atPath: $0) } + } + + public static func sanitizedHosts(_ hosts: [String]) -> [String] { + var seen = Set() + return hosts.compactMap { rawHost in + let host = rawHost.trimmingCharacters(in: .whitespacesAndNewlines) + let hasUnsafeScalar = host.unicodeScalars.contains { scalar in + CharacterSet.controlCharacters.contains(scalar) || + CharacterSet.whitespacesAndNewlines.contains(scalar) + } + guard !host.isEmpty, + !host.hasPrefix("-"), + !hasUnsafeScalar, + seen.insert(host.lowercased()).inserted + else { return nil } + return host + } + } + + private static func shellQuote(_ value: String) -> String { + "'" + value.replacingOccurrences(of: "'", with: "'\\''") + "'" + } +} diff --git a/Sources/CodexBarCore/SessionWindowFocuser.swift b/Sources/CodexBarCore/SessionWindowFocuser.swift new file mode 100644 index 0000000000..c832e7341d --- /dev/null +++ b/Sources/CodexBarCore/SessionWindowFocuser.swift @@ -0,0 +1,107 @@ +#if os(macOS) +import AppKit +import ApplicationServices +import Foundation + +public enum SessionFocusResult: Equatable, Sendable { + case focused + case activatedApplicationOnly + case failed +} + +@MainActor +public enum SessionWindowFocuser { + private static let knownBundleIdentifiers: Set = [ + "com.mitchellh.ghostty", + "com.googlecode.iterm2", + "com.apple.Terminal", + "dev.warp.Warp-Stable", + "com.github.wez.wezterm", + "net.kovidgoyal.kitty", + "org.alacritty", + "com.microsoft.VSCode", + "com.todesktop.230313mzl4w4u92", + "dev.zed.Zed", + "com.anthropic.claudefordesktop", + ] + + @discardableResult + public static func focus(_ session: AgentSession, promptForAccessibility: Bool = true) -> SessionFocusResult { + guard let application = self.application(for: session) else { return .failed } + guard application.activate() else { return .failed } + + let trusted = AXIsProcessTrustedWithOptions( + ["AXTrustedCheckOptionPrompt": promptForAccessibility] as CFDictionary) + guard trusted else { return .activatedApplicationOnly } + + let appElement = AXUIElementCreateApplication(application.processIdentifier) + var windowsValue: CFTypeRef? + guard AXUIElementCopyAttributeValue(appElement, kAXWindowsAttribute as CFString, &windowsValue) == .success, + let windows = windowsValue as? [AXUIElement], + let window = self.preferredWindow(windows, session: session) ?? windows.first + else { return .activatedApplicationOnly } + AXUIElementPerformAction(window, kAXRaiseAction as CFString) + return .focused + } + + private static func application(for session: AgentSession) -> NSRunningApplication? { + if let pid = session.pid { + var currentPID = pid + var fallback: NSRunningApplication? + var visited = Set() + while currentPID > 0, visited.insert(currentPID).inserted { + if let application = NSRunningApplication(processIdentifier: currentPID) { + fallback = fallback ?? application + if let bundleIdentifier = application.bundleIdentifier, + self.knownBundleIdentifiers.contains(bundleIdentifier) + { + return application + } + } + guard let parent = self.parentPID(of: currentPID), parent != currentPID else { break } + currentPID = parent + } + if let fallback { + return fallback + } + } + + let bundleIdentifier: String? = switch (session.provider, session.source) { + case (.claude, .desktopApp): "com.anthropic.claudefordesktop" + case (.codex, .desktopApp): "com.openai.codex" + default: nil + } + guard let bundleIdentifier else { return nil } + return NSRunningApplication.runningApplications(withBundleIdentifier: bundleIdentifier).first + } + + private static func preferredWindow(_ windows: [AXUIElement], session: AgentSession) -> AXUIElement? { + let candidates = [session.projectName, session.cwd.map { URL(fileURLWithPath: $0).lastPathComponent }] + .compactMap { $0?.lowercased() } + .filter { !$0.isEmpty } + guard !candidates.isEmpty else { return nil } + return windows.first { window in + var titleValue: CFTypeRef? + guard AXUIElementCopyAttributeValue(window, kAXTitleAttribute as CFString, &titleValue) == .success, + let title = titleValue as? String + else { return false } + let lowercasedTitle = title.lowercased() + return candidates.contains { lowercasedTitle.contains($0) } + } + } + + private static func parentPID(of pid: Int32) -> Int32? { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/bin/ps") + process.arguments = ["-o", "ppid=", "-p", String(pid)] + let pipe = Pipe() + process.standardOutput = pipe + process.standardError = FileHandle.nullDevice + guard (try? process.run()) != nil else { return nil } + process.waitUntilExit() + let data = pipe.fileHandleForReading.readDataToEndOfFile() + guard let output = String(data: data, encoding: .utf8) else { return nil } + return Int32(output.trimmingCharacters(in: .whitespacesAndNewlines)) + } +} +#endif diff --git a/Sources/CodexBarCore/TokenAccountSupport.swift b/Sources/CodexBarCore/TokenAccountSupport.swift index 37ec660a97..5fc47b5d39 100644 --- a/Sources/CodexBarCore/TokenAccountSupport.swift +++ b/Sources/CodexBarCore/TokenAccountSupport.swift @@ -13,6 +13,7 @@ public struct TokenAccountSupport: Sendable { public let requiresManualCookieSource: Bool public let cookieName: String? public let environmentKeysToScrub: [String] + public let minimumDelayBetweenAccountRefreshes: Duration? public init( title: String, @@ -21,7 +22,8 @@ public struct TokenAccountSupport: Sendable { injection: TokenAccountInjection, requiresManualCookieSource: Bool, cookieName: String?, - environmentKeysToScrub: [String] = []) + environmentKeysToScrub: [String] = [], + minimumDelayBetweenAccountRefreshes: Duration? = nil) { self.title = title self.subtitle = subtitle @@ -30,6 +32,7 @@ public struct TokenAccountSupport: Sendable { self.requiresManualCookieSource = requiresManualCookieSource self.cookieName = cookieName self.environmentKeysToScrub = environmentKeysToScrub + self.minimumDelayBetweenAccountRefreshes = minimumDelayBetweenAccountRefreshes } } @@ -85,6 +88,9 @@ public enum TokenAccountSupportCatalog { guard let support = self.support(for: provider) else { return token.trimmingCharacters(in: .whitespacesAndNewlines) } + if provider == .ollama, let cookieName = support.cookieName { + return normalizedOllamaTokenAccountHeader(token, defaultCookieName: cookieName) + } return self.normalizedCookieHeader(token, support: support) } diff --git a/Sources/CodexBarCore/TokenAccountSupportCatalog+Data.swift b/Sources/CodexBarCore/TokenAccountSupportCatalog+Data.swift index 12ec1cee55..ae2807a2fe 100644 --- a/Sources/CodexBarCore/TokenAccountSupportCatalog+Data.swift +++ b/Sources/CodexBarCore/TokenAccountSupportCatalog+Data.swift @@ -10,6 +10,13 @@ extension TokenAccountSupportCatalog { requiresManualCookieSource: false, cookieName: nil, environmentKeysToScrub: [OpenAIAPISettingsReader.projectIDEnvironmentKey]), + .openrouter: TokenAccountSupport( + title: "API keys", + subtitle: "Store multiple OpenRouter API keys.", + placeholder: "sk-or-v1-...", + injection: .environment(key: OpenRouterSettingsReader.envKey), + requiresManualCookieSource: false, + cookieName: nil), .claude: TokenAccountSupport( title: "Claude credentials", subtitle: "Store Claude sessionKey cookies, OAuth tokens, or Anthropic Admin API keys.", @@ -24,6 +31,13 @@ extension TokenAccountSupportCatalog { injection: .environment(key: DeepSeekSettingsReader.apiKeyEnvironmentKey), requiresManualCookieSource: false, cookieName: nil), + .deepinfra: TokenAccountSupport( + title: "API tokens", + subtitle: "Store multiple DeepInfra API keys.", + placeholder: "Paste API key…", + injection: .environment(key: DeepInfraSettingsReader.apiKeyEnvironmentKey), + requiresManualCookieSource: false, + cookieName: nil), .antigravity: TokenAccountSupport( title: "Google accounts", subtitle: "Store multiple Antigravity Google OAuth accounts for quick switching.", @@ -89,11 +103,11 @@ extension TokenAccountSupportCatalog { cookieName: nil), .ollama: TokenAccountSupport( title: "Session tokens", - subtitle: "Store multiple Ollama Cookie headers.", - placeholder: "Cookie: …", + subtitle: "Store multiple Ollama Cookie headers or session values.", + placeholder: "Cookie header or bare session value", injection: .cookieHeader, requiresManualCookieSource: true, - cookieName: nil), + cookieName: ollamaDefaultSessionCookieName), .abacus: TokenAccountSupport( title: "Session tokens", subtitle: "Store multiple Abacus AI Cookie headers.", @@ -108,6 +122,13 @@ extension TokenAccountSupportCatalog { injection: .cookieHeader, requiresManualCookieSource: true, cookieName: nil), + .qoder: TokenAccountSupport( + title: "Session tokens", + subtitle: "Store multiple Qoder Cookie headers.", + placeholder: "Cookie: …", + injection: .cookieHeader, + requiresManualCookieSource: true, + cookieName: nil), .copilot: TokenAccountSupport( title: "GitHub accounts", subtitle: "Sign in with multiple GitHub accounts via OAuth.", @@ -129,6 +150,14 @@ extension TokenAccountSupportCatalog { injection: .environment(key: ElevenLabsSettingsReader.apiKeyEnvironmentKey), requiresManualCookieSource: false, cookieName: nil), + .neuralwatt: TokenAccountSupport( + title: "API keys", + subtitle: "Store multiple Neuralwatt API keys.", + placeholder: "sk-...", + injection: .environment(key: NeuralWattSettingsReader.apiKeyEnvironmentKey), + requiresManualCookieSource: false, + cookieName: nil, + minimumDelayBetweenAccountRefreshes: .seconds(1)), .groq: TokenAccountSupport( title: "API keys", subtitle: "Store multiple Groq API keys.", @@ -143,6 +172,20 @@ extension TokenAccountSupportCatalog { injection: .environment(key: LLMProxySettingsReader.apiKeyEnvironmentKey), requiresManualCookieSource: false, cookieName: nil), + .litellm: TokenAccountSupport( + title: "API keys", + subtitle: "Store multiple LiteLLM API keys.", + placeholder: "Paste LiteLLM API key…", + injection: .environment(key: LiteLLMSettingsReader.apiKeyEnvironmentKey), + requiresManualCookieSource: false, + cookieName: nil), + .sub2api: TokenAccountSupport( + title: "Group API keys", + subtitle: "Store one labeled sub2api API key for each group you want to monitor.", + placeholder: "Paste sub2api API key…", + injection: .environment(key: Sub2APISettingsReader.apiKeyEnvironmentKey), + requiresManualCookieSource: false, + cookieName: nil), .stepfun: TokenAccountSupport( title: "Session tokens", subtitle: "Store multiple StepFun Oasis-Token values.", diff --git a/Sources/CodexBarCore/TokenAccounts.swift b/Sources/CodexBarCore/TokenAccounts.swift index 73110d28de..4dfa9578b7 100644 --- a/Sources/CodexBarCore/TokenAccounts.swift +++ b/Sources/CodexBarCore/TokenAccounts.swift @@ -9,9 +9,15 @@ public struct ProviderTokenAccount: Codable, Identifiable, Sendable { /// Stable provider-specific identity (e.g. GitHub `login`) used for /// re-auth deduplication. Optional so legacy accounts keep working. public let externalIdentifier: String? + /// Optional provider-specific usage scope. z.ai uses `personal` / `team`. + public let usageScope: String? /// Optional provider-specific organization/workspace target. Claude web /// sessionKey accounts use this to disambiguate linked Anthropic emails. + /// z.ai team accounts use this for the BigModel organization header. public let organizationID: String? + /// Optional provider-specific workspace/project target. z.ai team accounts + /// use this for the BigModel project header. + public let workspaceID: String? enum CodingKeys: String, CodingKey { case id @@ -20,7 +26,9 @@ public struct ProviderTokenAccount: Codable, Identifiable, Sendable { case addedAt case lastUsed case externalIdentifier + case usageScope case organizationID = "organizationId" + case workspaceID } public init( @@ -30,7 +38,9 @@ public struct ProviderTokenAccount: Codable, Identifiable, Sendable { addedAt: TimeInterval, lastUsed: TimeInterval?, externalIdentifier: String? = nil, - organizationID: String? = nil) + usageScope: String? = nil, + organizationID: String? = nil, + workspaceID: String? = nil) { self.id = id self.label = label @@ -38,7 +48,9 @@ public struct ProviderTokenAccount: Codable, Identifiable, Sendable { self.addedAt = addedAt self.lastUsed = lastUsed self.externalIdentifier = externalIdentifier + self.usageScope = usageScope self.organizationID = organizationID + self.workspaceID = workspaceID } public var displayName: String { @@ -49,10 +61,31 @@ public struct ProviderTokenAccount: Codable, Identifiable, Sendable { Self.clean(self.organizationID) } + public var sanitizedUsageScope: String? { + Self.clean(self.usageScope) + } + + public var sanitizedWorkspaceID: String? { + Self.clean(self.workspaceID) + } + private static func clean(_ raw: String?) -> String? { let trimmed = raw?.trimmingCharacters(in: .whitespacesAndNewlines) return (trimmed?.isEmpty ?? true) ? nil : trimmed } + + public func sanitizedForDump() -> ProviderTokenAccount { + ProviderTokenAccount( + id: self.id, + label: self.label, + token: "[REDACTED]", + addedAt: self.addedAt, + lastUsed: self.lastUsed, + externalIdentifier: self.externalIdentifier, + usageScope: self.usageScope, + organizationID: self.organizationID, + workspaceID: self.workspaceID) + } } public struct ProviderTokenAccountData: Codable, Sendable { @@ -70,6 +103,13 @@ public struct ProviderTokenAccountData: Codable, Sendable { guard !self.accounts.isEmpty else { return 0 } return min(max(self.activeIndex, 0), self.accounts.count - 1) } + + public func sanitizedForDump() -> ProviderTokenAccountData { + ProviderTokenAccountData( + version: self.version, + accounts: self.accounts.map { $0.sanitizedForDump() }, + activeIndex: self.activeIndex) + } } private struct ProviderTokenAccountsFile: Codable { diff --git a/Sources/CodexBarCore/UsageChartScale.swift b/Sources/CodexBarCore/UsageChartScale.swift new file mode 100644 index 0000000000..81c7eec6b7 --- /dev/null +++ b/Sources/CodexBarCore/UsageChartScale.swift @@ -0,0 +1,16 @@ +import Foundation + +public struct UsageChartScale: Equatable, Sendable { + public let maximum: Double + + public init(values: [Double]) { + self.maximum = values + .filter { $0.isFinite && $0 > 0 } + .max() ?? 0 + } + + public func fraction(for value: Double) -> Double { + guard self.maximum > 0, value.isFinite, value > 0 else { return 0 } + return min(value / self.maximum, 1) + } +} diff --git a/Sources/CodexBarCore/UsageFetcher.swift b/Sources/CodexBarCore/UsageFetcher.swift index 471f1147f5..8d2e70d175 100644 --- a/Sources/CodexBarCore/UsageFetcher.swift +++ b/Sources/CodexBarCore/UsageFetcher.swift @@ -1,6 +1,8 @@ import Foundation public struct RateWindow: Codable, Equatable, Sendable { + /// Provider usage value, intentionally not normalized globally. Pace and provider-specific diagnostics may + /// preserve raw over-quota values; display-only projections should use `UsagePercent.displayClamped`. public let usedPercent: Double public let windowMinutes: Int? public let resetsAt: Date? @@ -8,19 +10,63 @@ public struct RateWindow: Codable, Equatable, Sendable { public let resetDescription: String? /// Optional percent restored on the next regeneration tick for providers with rolling recovery. public let nextRegenPercent: Double? + /// Whether this window was synthesized to stand in for a quota lane the provider did not actually + /// report, rather than being a real zero-usage window. + /// + /// Claude web returns a `0%` five-hour window when `five_hour` is `null` (an account with no live + /// session but a real weekly lane). Lane classifiers — e.g. the combined "Session + Weekly" menu-bar + /// metric — must treat such a window as "no session lane present" instead of surfacing a phantom + /// `5h 0%`/`5h 100%` session. A genuine session, even one freshly reset to 0%, is NOT a placeholder. + /// Missing values decode as `false` for older cached payloads. + public let isSyntheticPlaceholder: Bool public init( usedPercent: Double, windowMinutes: Int?, resetsAt: Date?, resetDescription: String?, - nextRegenPercent: Double? = nil) + nextRegenPercent: Double? = nil, + isSyntheticPlaceholder: Bool = false) { self.usedPercent = usedPercent self.windowMinutes = windowMinutes self.resetsAt = resetsAt self.resetDescription = resetDescription self.nextRegenPercent = nextRegenPercent + self.isSyntheticPlaceholder = isSyntheticPlaceholder + } + + private enum CodingKeys: String, CodingKey { + case usedPercent + case windowMinutes + case resetsAt + case resetDescription + case nextRegenPercent + case isSyntheticPlaceholder + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.usedPercent = try container.decode(Double.self, forKey: .usedPercent) + self.windowMinutes = try container.decodeIfPresent(Int.self, forKey: .windowMinutes) + self.resetsAt = try container.decodeIfPresent(Date.self, forKey: .resetsAt) + self.resetDescription = try container.decodeIfPresent(String.self, forKey: .resetDescription) + self.nextRegenPercent = try container.decodeIfPresent(Double.self, forKey: .nextRegenPercent) + self.isSyntheticPlaceholder = + try container.decodeIfPresent(Bool.self, forKey: .isSyntheticPlaceholder) ?? false + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(self.usedPercent, forKey: .usedPercent) + try container.encodeIfPresent(self.windowMinutes, forKey: .windowMinutes) + try container.encodeIfPresent(self.resetsAt, forKey: .resetsAt) + try container.encodeIfPresent(self.resetDescription, forKey: .resetDescription) + try container.encodeIfPresent(self.nextRegenPercent, forKey: .nextRegenPercent) + // Only persist the flag when set, keeping payloads identical for the common (real-window) case. + if self.isSyntheticPlaceholder { + try container.encode(true, forKey: .isSyntheticPlaceholder) + } } public var remainingPercent: Double { @@ -28,14 +74,24 @@ public struct RateWindow: Codable, Equatable, Sendable { } public func backfillingResetTime(from cached: RateWindow?, now: Date = .init()) -> RateWindow { - if self.resetsAt != nil { return self } + if self.resetsAt != nil { + return self + } guard let cachedReset = cached?.resetsAt, cachedReset > now else { return self } + let windowMinutes = if let windowMinutes = self.windowMinutes, windowMinutes > 0 { + windowMinutes + } else { + cached?.windowMinutes + } return RateWindow( usedPercent: self.usedPercent, - windowMinutes: self.windowMinutes ?? cached?.windowMinutes, + windowMinutes: windowMinutes, resetsAt: cachedReset, resetDescription: self.resetDescription ?? cached?.resetDescription, - nextRegenPercent: self.nextRegenPercent) + nextRegenPercent: self.nextRegenPercent, + // Preserve the placeholder marker: backfilling a stale reset onto Claude web's null-session + // placeholder must not let it masquerade as a real session lane. + isSyntheticPlaceholder: self.isSyntheticPlaceholder) } } @@ -43,39 +99,44 @@ public struct NamedRateWindow: Codable, Equatable, Sendable { public let id: String public let title: String public let window: RateWindow - - public init(id: String, title: String, window: RateWindow) { + /// Whether `window.usedPercent` reflects known quota usage. + /// + /// Some providers expose reset metadata for a named quota window before + /// they expose remaining usage. Keep those windows visible for reset/debug + /// context, but mark them so clients do not render `usedPercent` as a real + /// exhausted quota. Missing values decode as `true` for older cached payloads. + public let usageKnown: Bool + + public init(id: String, title: String, window: RateWindow, usageKnown: Bool = true) { self.id = id self.title = title self.window = window + self.usageKnown = usageKnown } -} -public struct ProviderIdentitySnapshot: Codable, Sendable { - public let providerID: UsageProvider? - public let accountEmail: String? - public let accountOrganization: String? - public let loginMethod: String? + private enum CodingKeys: String, CodingKey { + case id + case title + case window + case usageKnown + } - public init( - providerID: UsageProvider?, - accountEmail: String?, - accountOrganization: String?, - loginMethod: String?) - { - self.providerID = providerID - self.accountEmail = accountEmail - self.accountOrganization = accountOrganization - self.loginMethod = loginMethod + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.id = try container.decode(String.self, forKey: .id) + self.title = try container.decode(String.self, forKey: .title) + self.window = try container.decode(RateWindow.self, forKey: .window) + self.usageKnown = try container.decodeIfPresent(Bool.self, forKey: .usageKnown) ?? true } - public func scoped(to provider: UsageProvider) -> ProviderIdentitySnapshot { - if self.providerID == provider { return self } - return ProviderIdentitySnapshot( - providerID: provider, - accountEmail: self.accountEmail, - accountOrganization: self.accountOrganization, - loginMethod: self.loginMethod) + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(self.id, forKey: .id) + try container.encode(self.title, forKey: .title) + try container.encode(self.window, forKey: .window) + if !self.usageKnown { + try container.encode(false, forKey: .usageKnown) + } } } @@ -86,17 +147,40 @@ public struct UsageSnapshot: Codable, Sendable { public let extraRateWindows: [NamedRateWindow]? public let providerCost: ProviderCostSnapshot? public let kiroUsage: KiroUsageDetails? + public let ampUsage: AmpUsageDetails? public let zaiUsage: ZaiUsageSnapshot? + public let zoommateCreditsHistory: ZoomMateCreditsHistorySnapshot? public let minimaxUsage: MiniMaxUsageSnapshot? public let deepseekUsage: DeepSeekUsageSummary? + public let deepseekDetailedUsageState: DeepSeekDetailedUsageState + public let deepseekPlatformProfiles: [DeepSeekPlatformProfile] + public let opencodegoUsage: OpenCodeGoUsageSnapshot? + public let mimoUsage: MiMoUsageSnapshot? public let openRouterUsage: OpenRouterUsageSnapshot? + public let sakanaPayAsYouGo: SakanaPayAsYouGoSnapshot? + public let clawRouterUsage: ClawRouterUsageSnapshot? + public let sub2APIUsage: Sub2APIUsageDetails? + public let wayfinderUsage: WayfinderUsageSnapshot? public let openAIAPIUsage: OpenAIAPIUsageSnapshot? + public let groqConsoleUsage: GroqConsoleUsageSnapshot? + public let codexResetCredits: CodexRateLimitResetCreditsSnapshot? public let claudeAdminAPIUsage: ClaudeAdminAPIUsageSnapshot? public let mistralUsage: MistralUsageSnapshot? public let deepgramUsage: DeepgramUsageSnapshot? + public let poeUsage: PoeUsageHistorySnapshot? + public let xaiUsage: XAIUsageSnapshot? public let cursorRequests: CursorRequestUsage? + /// Live-only marker for optional Command Code subscription lookup failure. + public let commandCodeSubscriptionEnrichmentUnavailable: Bool + /// Live-only marker that Command Code returned a recognized subscription plan. + public let commandCodeHasSubscriptionPlan: Bool + /// Live-only marker that Command Code's monthly grant has no remaining credits. + public let commandCodeMonthlyGrantDepleted: Bool + public let subscriptionExpiresAt: Date? + public let subscriptionRenewsAt: Date? public let updatedAt: Date public let identity: ProviderIdentitySnapshot? + public let dataConfidence: UsageDataConfidence private enum CodingKeys: String, CodingKey { case primary @@ -105,13 +189,26 @@ public struct UsageSnapshot: Codable, Sendable { case extraRateWindows case providerCost case kiroUsage + case ampUsage + case mimoUsage case openRouterUsage + case sakanaPayAsYouGo + case clawRouterUsage + case sub2APIUsage + case wayfinderUsage case openAIAPIUsage + case groqConsoleUsage + case codexResetCredits case claudeAdminAPIUsage case mistralUsage case deepgramUsage + case poeUsage + case xaiUsage + case subscriptionExpiresAt + case subscriptionRenewsAt case updatedAt case identity + case dataConfidence case accountEmail case accountOrganization case loginMethod @@ -123,36 +220,90 @@ public struct UsageSnapshot: Codable, Sendable { tertiary: RateWindow? = nil, extraRateWindows: [NamedRateWindow]? = nil, kiroUsage: KiroUsageDetails? = nil, + ampUsage: AmpUsageDetails? = nil, providerCost: ProviderCostSnapshot? = nil, zaiUsage: ZaiUsageSnapshot? = nil, + zoommateCreditsHistory: ZoomMateCreditsHistorySnapshot? = nil, minimaxUsage: MiniMaxUsageSnapshot? = nil, deepseekUsage: DeepSeekUsageSummary? = nil, + deepseekDetailedUsageState: DeepSeekDetailedUsageState = .notRequested, + deepseekPlatformProfiles: [DeepSeekPlatformProfile] = [], + opencodegoUsage: OpenCodeGoUsageSnapshot? = nil, + mimoUsage: MiMoUsageSnapshot? = nil, openRouterUsage: OpenRouterUsageSnapshot? = nil, + sakanaPayAsYouGo: SakanaPayAsYouGoSnapshot? = nil, + clawRouterUsage: ClawRouterUsageSnapshot? = nil, + sub2APIUsage: Sub2APIUsageDetails? = nil, + wayfinderUsage: WayfinderUsageSnapshot? = nil, openAIAPIUsage: OpenAIAPIUsageSnapshot? = nil, + groqConsoleUsage: GroqConsoleUsageSnapshot? = nil, + codexResetCredits: CodexRateLimitResetCreditsSnapshot? = nil, claudeAdminAPIUsage: ClaudeAdminAPIUsageSnapshot? = nil, mistralUsage: MistralUsageSnapshot? = nil, deepgramUsage: DeepgramUsageSnapshot? = nil, + poeUsage: PoeUsageHistorySnapshot? = nil, + xaiUsage: XAIUsageSnapshot? = nil, cursorRequests: CursorRequestUsage? = nil, + commandCodeSubscriptionEnrichmentUnavailable: Bool = false, + commandCodeHasSubscriptionPlan: Bool = false, + commandCodeMonthlyGrantDepleted: Bool = false, + subscriptionExpiresAt: Date? = nil, + subscriptionRenewsAt: Date? = nil, updatedAt: Date, - identity: ProviderIdentitySnapshot? = nil) + identity: ProviderIdentitySnapshot? = nil, + dataConfidence: UsageDataConfidence = .unknown) { self.primary = primary self.secondary = secondary self.tertiary = tertiary self.extraRateWindows = extraRateWindows self.kiroUsage = kiroUsage + self.ampUsage = ampUsage self.providerCost = providerCost self.zaiUsage = zaiUsage + self.zoommateCreditsHistory = zoommateCreditsHistory self.minimaxUsage = minimaxUsage self.deepseekUsage = deepseekUsage + self.deepseekDetailedUsageState = deepseekDetailedUsageState + self.deepseekPlatformProfiles = deepseekPlatformProfiles + self.opencodegoUsage = opencodegoUsage + self.mimoUsage = mimoUsage self.openRouterUsage = openRouterUsage + self.sakanaPayAsYouGo = sakanaPayAsYouGo + self.clawRouterUsage = clawRouterUsage + self.sub2APIUsage = sub2APIUsage + self.wayfinderUsage = wayfinderUsage self.openAIAPIUsage = openAIAPIUsage + self.groqConsoleUsage = groqConsoleUsage + self.codexResetCredits = codexResetCredits self.claudeAdminAPIUsage = claudeAdminAPIUsage self.mistralUsage = mistralUsage self.deepgramUsage = deepgramUsage + self.poeUsage = poeUsage + self.xaiUsage = xaiUsage self.cursorRequests = cursorRequests + self.commandCodeSubscriptionEnrichmentUnavailable = commandCodeSubscriptionEnrichmentUnavailable + self.commandCodeHasSubscriptionPlan = commandCodeHasSubscriptionPlan + self.commandCodeMonthlyGrantDepleted = commandCodeMonthlyGrantDepleted + self.subscriptionExpiresAt = subscriptionExpiresAt + self.subscriptionRenewsAt = subscriptionRenewsAt self.updatedAt = updatedAt self.identity = identity + self.dataConfidence = dataConfidence + } + + public func with(extraRateWindows: [NamedRateWindow]?) -> UsageSnapshot { + self.replacing(extraRateWindows: .value(extraRateWindows)) + } + + public func withCodexResetCredits(_ resetCredits: CodexRateLimitResetCreditsSnapshot?) -> UsageSnapshot { + self.replacing(codexResetCredits: .value(resetCredits)) + } + + public func with(primary: RateWindow?, secondary: RateWindow?) -> UsageSnapshot { + self.replacing( + primary: .value(primary), + secondary: .value(secondary)) } public init(from decoder: Decoder) throws { @@ -163,18 +314,48 @@ public struct UsageSnapshot: Codable, Sendable { self.extraRateWindows = try container.decodeIfPresent([NamedRateWindow].self, forKey: .extraRateWindows) self.providerCost = try container.decodeIfPresent(ProviderCostSnapshot.self, forKey: .providerCost) self.kiroUsage = try container.decodeIfPresent(KiroUsageDetails.self, forKey: .kiroUsage) + self.ampUsage = try container.decodeIfPresent(AmpUsageDetails.self, forKey: .ampUsage) self.zaiUsage = nil // Not persisted, fetched fresh each time + self.zoommateCreditsHistory = nil // Not persisted, fetched fresh each time self.minimaxUsage = nil // Not persisted, fetched fresh each time self.deepseekUsage = nil // Not persisted, fetched fresh each time + self.deepseekDetailedUsageState = .notRequested // Live-only fetch state + self.deepseekPlatformProfiles = [] // Live-only browser profile catalog + self.opencodegoUsage = nil // Not persisted, fetched fresh each time + self.mimoUsage = try container.decodeIfPresent(MiMoUsageSnapshot.self, forKey: .mimoUsage) self.openRouterUsage = try container.decodeIfPresent(OpenRouterUsageSnapshot.self, forKey: .openRouterUsage) + self.sakanaPayAsYouGo = try container.decodeIfPresent( + SakanaPayAsYouGoSnapshot.self, + forKey: .sakanaPayAsYouGo) + self.clawRouterUsage = try container.decodeIfPresent(ClawRouterUsageSnapshot.self, forKey: .clawRouterUsage) + self.sub2APIUsage = try container.decodeIfPresent(Sub2APIUsageDetails.self, forKey: .sub2APIUsage) + self.wayfinderUsage = try container.decodeIfPresent(WayfinderUsageSnapshot.self, forKey: .wayfinderUsage) self.openAIAPIUsage = try container.decodeIfPresent(OpenAIAPIUsageSnapshot.self, forKey: .openAIAPIUsage) + self.groqConsoleUsage = try container.decodeIfPresent( + GroqConsoleUsageSnapshot.self, + forKey: .groqConsoleUsage) + self.codexResetCredits = try container.decodeIfPresent( + CodexRateLimitResetCreditsSnapshot.self, + forKey: .codexResetCredits) self.claudeAdminAPIUsage = try container.decodeIfPresent( ClaudeAdminAPIUsageSnapshot.self, forKey: .claudeAdminAPIUsage) self.mistralUsage = try container.decodeIfPresent(MistralUsageSnapshot.self, forKey: .mistralUsage) self.deepgramUsage = try container.decodeIfPresent(DeepgramUsageSnapshot.self, forKey: .deepgramUsage) + self.poeUsage = try container.decodeIfPresent(PoeUsageHistorySnapshot.self, forKey: .poeUsage) + self.xaiUsage = try container.decodeIfPresent(XAIUsageSnapshot.self, forKey: .xaiUsage) self.cursorRequests = nil // Not persisted, fetched fresh each time + self.commandCodeSubscriptionEnrichmentUnavailable = false // Live-only fetch state + self.commandCodeHasSubscriptionPlan = false // Live-only fetch state + self.commandCodeMonthlyGrantDepleted = false // Live-only fetch state + self.subscriptionExpiresAt = try container.decodeIfPresent(Date.self, forKey: .subscriptionExpiresAt) + self.subscriptionRenewsAt = try container.decodeIfPresent(Date.self, forKey: .subscriptionRenewsAt) self.updatedAt = try container.decode(Date.self, forKey: .updatedAt) + if let dataConfidence = try container.decodeIfPresent(String.self, forKey: .dataConfidence) { + self.dataConfidence = UsageDataConfidence(rawValue: dataConfidence) ?? .unknown + } else { + self.dataConfidence = .unknown + } if let identity = try container.decodeIfPresent(ProviderIdentitySnapshot.self, forKey: .identity) { self.identity = identity } else { @@ -202,13 +383,28 @@ public struct UsageSnapshot: Codable, Sendable { try container.encodeIfPresent(self.extraRateWindows, forKey: .extraRateWindows) try container.encodeIfPresent(self.providerCost, forKey: .providerCost) try container.encodeIfPresent(self.kiroUsage, forKey: .kiroUsage) + try container.encodeIfPresent(self.ampUsage, forKey: .ampUsage) + try container.encodeIfPresent(self.mimoUsage, forKey: .mimoUsage) try container.encodeIfPresent(self.openRouterUsage, forKey: .openRouterUsage) + try container.encodeIfPresent(self.sakanaPayAsYouGo, forKey: .sakanaPayAsYouGo) + try container.encodeIfPresent(self.clawRouterUsage, forKey: .clawRouterUsage) + try container.encodeIfPresent(self.sub2APIUsage, forKey: .sub2APIUsage) + try container.encodeIfPresent(self.wayfinderUsage, forKey: .wayfinderUsage) try container.encodeIfPresent(self.openAIAPIUsage, forKey: .openAIAPIUsage) + try container.encodeIfPresent(self.groqConsoleUsage, forKey: .groqConsoleUsage) + try container.encodeIfPresent(self.codexResetCredits, forKey: .codexResetCredits) try container.encodeIfPresent(self.claudeAdminAPIUsage, forKey: .claudeAdminAPIUsage) try container.encodeIfPresent(self.mistralUsage, forKey: .mistralUsage) try container.encodeIfPresent(self.deepgramUsage, forKey: .deepgramUsage) + try container.encodeIfPresent(self.poeUsage, forKey: .poeUsage) + try container.encodeIfPresent(self.xaiUsage, forKey: .xaiUsage) + try container.encodeIfPresent(self.subscriptionExpiresAt, forKey: .subscriptionExpiresAt) + try container.encodeIfPresent(self.subscriptionRenewsAt, forKey: .subscriptionRenewsAt) try container.encode(self.updatedAt, forKey: .updatedAt) try container.encodeIfPresent(self.identity, forKey: .identity) + if self.dataConfidence != .unknown { + try container.encode(self.dataConfidence, forKey: .dataConfidence) + } try container.encodeIfPresent(self.identity?.accountEmail, forKey: .accountEmail) try container.encodeIfPresent(self.identity?.accountOrganization, forKey: .accountOrganization) try container.encodeIfPresent(self.identity?.loginMethod, forKey: .loginMethod) @@ -241,36 +437,6 @@ public struct UsageSnapshot: Codable, Sendable { return fallbackWindows + [primary] } - public func switcherWeeklyWindow(for provider: UsageProvider, showUsed: Bool) -> RateWindow? { - switch provider { - case .factory: - // Factory prefers secondary window - return self.secondary ?? self.primary - case .perplexity: - return self.automaticPerplexityWindow() - case .cursor: - // Cursor: fall back to on-demand budget when the included plan is exhausted (only in - // "show remaining" mode). The secondary/tertiary lanes are Total/Auto/API breakdowns, - // not extra capacity, so they should not replace the remaining paid quota indicator. - if !showUsed, - let primary = self.primary, - primary.remainingPercent <= 0, - let providerCost = self.providerCost, - providerCost.limit > 0 - { - let usedPercent = max(0, min(100, (providerCost.used / providerCost.limit) * 100)) - return RateWindow( - usedPercent: usedPercent, - windowMinutes: nil, - resetsAt: providerCost.resetsAt, - resetDescription: nil) - } - return self.primary ?? self.secondary - default: - return self.primary ?? self.secondary - } - } - public func accountEmail(for provider: UsageProvider) -> String? { self.identity(for: provider)?.accountEmail } @@ -292,61 +458,45 @@ public struct UsageSnapshot: Codable, Sendable { UsageLimitsAvailability.resolve(provider: provider, snapshot: self).isUnavailable } - /// Keep this initializer-style copy in sync with UsageSnapshot fields so relabeling/scoping never drops data. public func withIdentity(_ identity: ProviderIdentitySnapshot?) -> UsageSnapshot { - UsageSnapshot( - primary: self.primary, - secondary: self.secondary, - tertiary: self.tertiary, - extraRateWindows: self.extraRateWindows, - kiroUsage: self.kiroUsage, - providerCost: self.providerCost, - zaiUsage: self.zaiUsage, - minimaxUsage: self.minimaxUsage, - deepseekUsage: self.deepseekUsage, - openRouterUsage: self.openRouterUsage, - openAIAPIUsage: self.openAIAPIUsage, - claudeAdminAPIUsage: self.claudeAdminAPIUsage, - mistralUsage: self.mistralUsage, - deepgramUsage: self.deepgramUsage, - cursorRequests: self.cursorRequests, - updatedAt: self.updatedAt, - identity: identity) + self.replacing(identity: .value(identity)) + } + + public func withDataConfidence(_ dataConfidence: UsageDataConfidence) -> UsageSnapshot { + self.replacing(dataConfidence: .value(dataConfidence)) } public func scoped(to provider: UsageProvider) -> UsageSnapshot { guard let identity else { return self } let scopedIdentity = identity.scoped(to: provider) - if scopedIdentity.providerID == identity.providerID { return self } + if scopedIdentity.providerID == identity.providerID { + return self + } return self.withIdentity(scopedIdentity) } public func backfillingResetTimes(from cached: UsageSnapshot?, now: Date = .init()) -> UsageSnapshot { guard let cached else { return self } guard Self.identitiesMatch(self.identity, cached.identity) else { return self } - let primary = self.primary?.backfillingResetTime(from: cached.primary, now: now) + // Amp's percentage-based daily quota supersedes the legacy rolling-replenishment cadence. Do not attach + // that older exact reset to the new daily window; other providers retain the shared backfill behavior. + let cachedPrimary: RateWindow? = if self.identity?.providerID == .amp, + self.primary?.resetDescription == "resets daily" + { + nil + } else { + cached.primary + } + let primary = self.primary?.backfillingResetTime(from: cachedPrimary, now: now) let secondary = self.secondary?.backfillingResetTime(from: cached.secondary, now: now) let tertiary = self.tertiary?.backfillingResetTime(from: cached.tertiary, now: now) if primary == self.primary, secondary == self.secondary, tertiary == self.tertiary { return self } - return UsageSnapshot( - primary: primary, - secondary: secondary, - tertiary: tertiary, - extraRateWindows: self.extraRateWindows, - providerCost: self.providerCost, - zaiUsage: self.zaiUsage, - minimaxUsage: self.minimaxUsage, - deepseekUsage: self.deepseekUsage, - openRouterUsage: self.openRouterUsage, - openAIAPIUsage: self.openAIAPIUsage, - claudeAdminAPIUsage: self.claudeAdminAPIUsage, - mistralUsage: self.mistralUsage, - deepgramUsage: self.deepgramUsage, - cursorRequests: self.cursorRequests, - updatedAt: self.updatedAt, - identity: self.identity) + return self.replacing( + primary: .value(primary), + secondary: .value(secondary), + tertiary: .value(tertiary)) } private func orderedPerplexityFallbackWindows() -> [RateWindow] { @@ -357,8 +507,15 @@ public struct UsageSnapshot: Codable, Sendable { } private static func identitiesMatch(_ lhs: ProviderIdentitySnapshot?, _ rhs: ProviderIdentitySnapshot?) -> Bool { - if lhs == nil, rhs == nil { return true } + if lhs == nil, rhs == nil { + return true + } guard let lhs, let rhs else { return false } + let lhsAccountID = lhs.accountID?.trimmingCharacters(in: .whitespacesAndNewlines) + let rhsAccountID = rhs.accountID?.trimmingCharacters(in: .whitespacesAndNewlines) + if let lhsAccountID, let rhsAccountID, !lhsAccountID.isEmpty, !rhsAccountID.isEmpty { + return lhsAccountID == rhsAccountID + } let lhsEmail = lhs.accountEmail?.trimmingCharacters(in: .whitespacesAndNewlines) let rhsEmail = rhs.accountEmail?.trimmingCharacters(in: .whitespacesAndNewlines) if let lhsEmail, let rhsEmail, !lhsEmail.isEmpty, !rhsEmail.isEmpty { @@ -366,6 +523,70 @@ public struct UsageSnapshot: Codable, Sendable { } return true } + + enum Replacement { + case unchanged + case value(Value) + + func resolving(_ current: Value) -> Value { + switch self { + case .unchanged: current + case let .value(value): value + } + } + } + + func replacing( + primary: Replacement = .unchanged, + secondary: Replacement = .unchanged, + tertiary: Replacement = .unchanged, + extraRateWindows: Replacement<[NamedRateWindow]?> = .unchanged, + deepseekUsage: Replacement = .unchanged, + deepseekDetailedUsageState: Replacement = .unchanged, + deepseekPlatformProfiles: Replacement<[DeepSeekPlatformProfile]> = .unchanged, + codexResetCredits: Replacement = .unchanged, + identity: Replacement = .unchanged, + dataConfidence: Replacement = .unchanged) -> UsageSnapshot + { + UsageSnapshot( + primary: primary.resolving(self.primary), + secondary: secondary.resolving(self.secondary), + tertiary: tertiary.resolving(self.tertiary), + extraRateWindows: extraRateWindows.resolving(self.extraRateWindows), + kiroUsage: self.kiroUsage, + ampUsage: self.ampUsage, + providerCost: self.providerCost, + zaiUsage: self.zaiUsage, + zoommateCreditsHistory: self.zoommateCreditsHistory, + minimaxUsage: self.minimaxUsage, + deepseekUsage: deepseekUsage.resolving(self.deepseekUsage), + deepseekDetailedUsageState: deepseekDetailedUsageState.resolving(self.deepseekDetailedUsageState), + deepseekPlatformProfiles: deepseekPlatformProfiles.resolving(self.deepseekPlatformProfiles), + opencodegoUsage: self.opencodegoUsage, + mimoUsage: self.mimoUsage, + openRouterUsage: self.openRouterUsage, + sakanaPayAsYouGo: self.sakanaPayAsYouGo, + clawRouterUsage: self.clawRouterUsage, + sub2APIUsage: self.sub2APIUsage, + wayfinderUsage: self.wayfinderUsage, + openAIAPIUsage: self.openAIAPIUsage, + groqConsoleUsage: self.groqConsoleUsage, + codexResetCredits: codexResetCredits.resolving(self.codexResetCredits), + claudeAdminAPIUsage: self.claudeAdminAPIUsage, + mistralUsage: self.mistralUsage, + deepgramUsage: self.deepgramUsage, + poeUsage: self.poeUsage, + xaiUsage: self.xaiUsage, + cursorRequests: self.cursorRequests, + commandCodeSubscriptionEnrichmentUnavailable: self.commandCodeSubscriptionEnrichmentUnavailable, + commandCodeHasSubscriptionPlan: self.commandCodeHasSubscriptionPlan, + commandCodeMonthlyGrantDepleted: self.commandCodeMonthlyGrantDepleted, + subscriptionExpiresAt: self.subscriptionExpiresAt, + subscriptionRenewsAt: self.subscriptionRenewsAt, + updatedAt: self.updatedAt, + identity: identity.resolving(self.identity), + dataConfidence: dataConfidence.resolving(self.dataConfidence)) + } } public struct AccountInfo: Equatable, Sendable { @@ -386,10 +607,16 @@ public struct AccountInfo: Equatable, Sendable { public struct CodexCLIAccountSnapshot: Sendable { public let usage: UsageSnapshot? public let credits: CreditsSnapshot? + public let identity: ProviderIdentitySnapshot? - public init(usage: UsageSnapshot?, credits: CreditsSnapshot?) { + public init( + usage: UsageSnapshot?, + credits: CreditsSnapshot?, + identity: ProviderIdentitySnapshot? = nil) + { self.usage = usage self.credits = credits + self.identity = identity } } @@ -428,6 +655,22 @@ public enum UsageLimitsAvailability: Equatable, Sendable { account: AccountInfo? = nil, lastErrorDescription: String? = nil) -> Self { + if provider == .claude { + guard snapshot == nil else { return .available } + return ClaudeStatusProbe.isSubscriptionQuotaUnavailableDescription(lastErrorDescription) + ? .unavailable + : .available + } + + if provider == .doubao || provider == .antigravity { + guard let snapshot, + snapshot.identity(for: provider) != nil + else { + return .available + } + return snapshot.hasRateLimitWindows ? .available : .unavailable + } + guard provider == .codex else { return .available } if let snapshot { @@ -482,13 +725,88 @@ private enum RPCAccountDetails: Decodable { private struct RPCRateLimitsResponse: Decodable, Encodable { let rateLimits: RPCRateLimitSnapshot + let rateLimitsByLimitId: [String: RPCRateLimitSnapshot]? + + enum CodingKeys: String, CodingKey { + case rateLimits + case rateLimitsByLimitId + case rateLimitsByLimitIdSnake = "rate_limits_by_limit_id" + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.rateLimits = try container.decode(RPCRateLimitSnapshot.self, forKey: .rateLimits) + self.rateLimitsByLimitId = (try? container.decodeIfPresent( + [String: RPCRateLimitSnapshot].self, + forKey: .rateLimitsByLimitId)) + ?? (try? container.decodeIfPresent( + [String: RPCRateLimitSnapshot].self, + forKey: .rateLimitsByLimitIdSnake)) + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(self.rateLimits, forKey: .rateLimits) + try container.encodeIfPresent(self.rateLimitsByLimitId, forKey: .rateLimitsByLimitId) + } } private struct RPCRateLimitSnapshot: Decodable, Encodable { + let limitId: String? + let limitName: String? let primary: RPCRateLimitWindow? let secondary: RPCRateLimitWindow? let credits: RPCCreditsSnapshot? + let individualLimit: RPCSpendControlLimitSnapshot? let planType: String? + let rateLimitReachedType: String? + + enum CodingKeys: String, CodingKey { + case limitId + case limitIdSnake = "limit_id" + case limitName + case limitNameSnake = "limit_name" + case primary + case secondary + case credits + case individualLimit + case individualLimitSnake = "individual_limit" + case planType + case planTypeSnake = "plan_type" + case rateLimitReachedType + case rateLimitReachedTypeSnake = "rate_limit_reached_type" + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.limitId = (try? container.decodeIfPresent(String.self, forKey: .limitId)) + ?? (try? container.decodeIfPresent(String.self, forKey: .limitIdSnake)) + self.limitName = (try? container.decodeIfPresent(String.self, forKey: .limitName)) + ?? (try? container.decodeIfPresent(String.self, forKey: .limitNameSnake)) + self.primary = try? container.decodeIfPresent(RPCRateLimitWindow.self, forKey: .primary) + self.secondary = try? container.decodeIfPresent(RPCRateLimitWindow.self, forKey: .secondary) + self.credits = try? container.decodeIfPresent(RPCCreditsSnapshot.self, forKey: .credits) + self.individualLimit = (try? container.decodeIfPresent( + RPCSpendControlLimitSnapshot.self, + forKey: .individualLimit)) + ?? (try? container.decodeIfPresent(RPCSpendControlLimitSnapshot.self, forKey: .individualLimitSnake)) + self.planType = (try? container.decodeIfPresent(String.self, forKey: .planType)) + ?? (try? container.decodeIfPresent(String.self, forKey: .planTypeSnake)) + self.rateLimitReachedType = (try? container.decodeIfPresent(String.self, forKey: .rateLimitReachedType)) + ?? (try? container.decodeIfPresent(String.self, forKey: .rateLimitReachedTypeSnake)) + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encodeIfPresent(self.limitId, forKey: .limitId) + try container.encodeIfPresent(self.limitName, forKey: .limitName) + try container.encodeIfPresent(self.primary, forKey: .primary) + try container.encodeIfPresent(self.secondary, forKey: .secondary) + try container.encodeIfPresent(self.credits, forKey: .credits) + try container.encodeIfPresent(self.individualLimit, forKey: .individualLimit) + try container.encodeIfPresent(self.planType, forKey: .planType) + try container.encodeIfPresent(self.rateLimitReachedType, forKey: .rateLimitReachedType) + } } private struct RPCRateLimitWindow: Decodable, Encodable { @@ -503,6 +821,72 @@ private struct RPCCreditsSnapshot: Decodable, Encodable { let balance: String? } +private struct RPCSpendControlLimitSnapshot: Decodable, Encodable { + let limit: Double? + let used: Double? + let remainingPercent: Double? + let resetsAt: Int? + + enum CodingKeys: String, CodingKey { + case limit + case used + case remainingPercent + case remainingPercentSnake = "remaining_percent" + case resetsAt + case resetsAtSnake = "resets_at" + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.limit = Self.decodeFlexibleDouble(container, forKey: .limit) + self.used = Self.decodeFlexibleDouble(container, forKey: .used) + self.remainingPercent = Self.decodeFlexibleDouble(container, forKey: .remainingPercent) + ?? Self.decodeFlexibleDouble(container, forKey: .remainingPercentSnake) + self.resetsAt = Self.decodeFlexibleInt(container, forKey: .resetsAt) + ?? Self.decodeFlexibleInt(container, forKey: .resetsAtSnake) + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encodeIfPresent(self.limit, forKey: .limit) + try container.encodeIfPresent(self.used, forKey: .used) + try container.encodeIfPresent(self.remainingPercent, forKey: .remainingPercent) + try container.encodeIfPresent(self.resetsAt, forKey: .resetsAt) + } + + private static func decodeFlexibleDouble( + _ container: KeyedDecodingContainer, + forKey key: CodingKeys) -> Double? + { + if let value = try? container.decodeIfPresent(Double.self, forKey: key) { + return value + } + if let value = try? container.decodeIfPresent(Int.self, forKey: key) { + return Double(value) + } + if let value = try? container.decodeIfPresent(String.self, forKey: key) { + return Double(value.trimmingCharacters(in: .whitespacesAndNewlines)) + } + return nil + } + + private static func decodeFlexibleInt( + _ container: KeyedDecodingContainer, + forKey key: CodingKeys) -> Int? + { + if let value = try? container.decodeIfPresent(Int.self, forKey: key) { + return value + } + if let value = try? container.decodeIfPresent(Double.self, forKey: key) { + return Int(value) + } + if let value = try? container.decodeIfPresent(String.self, forKey: key) { + return Int(value.trimmingCharacters(in: .whitespacesAndNewlines)) + } + return nil + } +} + private struct RPCRateLimitsErrorBody: Decodable { let email: String? let planType: String? @@ -537,13 +921,6 @@ enum RPCWireError: Error, LocalizedError { } } -typealias CodexExecutableResolver = @Sendable (_ environment: [String: String], _ executable: String) -> String? - -let defaultCodexExecutableResolver: CodexExecutableResolver = { environment, executable in - BinaryLocator.resolveCodexBinary(env: environment) - ?? TTYCommandRunner.which(executable) -} - /// RPC helper used on background tasks; safe because we confine it to the owning task. private final class CodexRPCClient: @unchecked Sendable { private static let log = CodexBarLog.logger(LogCategories.codexRPC) @@ -557,27 +934,6 @@ private final class CodexRPCClient: @unchecked Sendable { private let initializeTimeoutSeconds: TimeInterval private let requestTimeoutSeconds: TimeInterval - private final class LineBuffer: @unchecked Sendable { - private let lock = NSLock() - private var buffer = Data() - - func appendAndDrainLines(_ data: Data) -> [Data] { - self.lock.lock() - defer { self.lock.unlock() } - - self.buffer.append(data) - var out: [Data] = [] - while let newline = self.buffer.firstIndex(of: 0x0A) { - let lineData = Data(self.buffer[.. [String: Any] { for await lineData in self.stdoutLineStream { - if lineData.isEmpty { continue } + if lineData.isEmpty { + continue + } if let json = try? JSONSerialization.jsonObject(with: lineData) as? [String: Any] { return json } @@ -818,26 +1187,28 @@ public struct UsageFetcher: Sendable { private let initializeTimeoutSeconds: TimeInterval private let requestTimeoutSeconds: TimeInterval private let codexExecutableResolver: CodexExecutableResolver + private let codexArguments: [String] public init(environment: [String: String] = ProcessInfo.processInfo.environment) { self.environment = environment self.initializeTimeoutSeconds = 8.0 self.requestTimeoutSeconds = 3.0 self.codexExecutableResolver = defaultCodexExecutableResolver - LoginShellPathCache.shared.captureOnce() + self.codexArguments = ["-s", "read-only", "-a", "untrusted", "app-server"] } init( environment: [String: String], initializeTimeoutSeconds: TimeInterval, requestTimeoutSeconds: TimeInterval, + codexArguments: [String] = ["-s", "read-only", "-a", "untrusted", "app-server"], codexExecutableResolver: @escaping CodexExecutableResolver = defaultCodexExecutableResolver) { self.environment = environment self.initializeTimeoutSeconds = initializeTimeoutSeconds self.requestTimeoutSeconds = requestTimeoutSeconds self.codexExecutableResolver = codexExecutableResolver - LoginShellPathCache.shared.captureOnce() + self.codexArguments = codexArguments } public func loadLatestUsage(keepCLISessionsAlive: Bool = false) async throws -> UsageSnapshot { @@ -850,6 +1221,7 @@ public struct UsageFetcher: Sendable { public func loadLatestCLIAccountSnapshot() async throws -> CodexCLIAccountSnapshot { let rpc = try CodexRPCClient( + arguments: self.codexArguments, environment: self.environment, initializeTimeoutSeconds: self.initializeTimeoutSeconds, requestTimeoutSeconds: self.requestTimeoutSeconds, @@ -860,19 +1232,28 @@ public struct UsageFetcher: Sendable { // The app-server answers on a single stdout stream, so keep requests // serialized to avoid starving one reader when multiple awaiters race // for the same pipe. - let limits = try await rpc.fetchRateLimits().rateLimits + let limitsResponse = try await rpc.fetchRateLimits() + let limits = limitsResponse.rateLimits let account = try? await rpc.fetchAccount() let rateLimitsPlan = Self.normalizedCodexAccountField(limits.planType) let identity = ProviderIdentitySnapshot( providerID: .codex, accountEmail: account?.account.flatMap { details in - if case let .chatgpt(email, _) = details { email } else { nil } + if case let .chatgpt(email, _) = details { + email + } else { + nil + } }, accountOrganization: nil, loginMethod: account?.account.flatMap { details in - if case let .chatgpt(_, plan) = details { plan } else { nil } + if case let .chatgpt(_, plan) = details { + plan + } else { + nil + } } ?? rateLimitsPlan) - let credits = Self.makeCredits(from: limits.credits) + let credits = Self.makeCredits(from: limits, rateLimitsByLimitId: limitsResponse.rateLimitsByLimitId) let shouldReturnUnavailableUsage = credits == nil || rateLimitsPlan != nil let usage = CodexReconciledState.fromCLI( primary: Self.makeWindow(from: limits.primary), @@ -885,14 +1266,16 @@ public struct UsageFetcher: Sendable { } return CodexCLIAccountSnapshot( usage: usage, - credits: credits) + credits: credits, + identity: identity) } catch { let usage = Self.recoverUsageFromRPCError(error) let credits = Self.recoverCreditsFromRPCError(error) if usage != nil || credits != nil { return CodexCLIAccountSnapshot( usage: usage, - credits: credits) + credits: credits, + identity: usage?.identity) } throw error } @@ -909,6 +1292,7 @@ public struct UsageFetcher: Sendable { public func debugRawRateLimits() async -> String { do { let rpc = try CodexRPCClient( + arguments: self.codexArguments, environment: self.environment, initializeTimeoutSeconds: self.initializeTimeoutSeconds, requestTimeoutSeconds: self.requestTimeoutSeconds, @@ -992,9 +1376,73 @@ public struct UsageFetcher: Sendable { return val } - private static func makeCredits(from rpc: RPCCreditsSnapshot?) -> CreditsSnapshot? { - guard let rpc else { return nil } - return CreditsSnapshot(remaining: self.parseCredits(rpc.balance), events: [], updatedAt: Date()) + private static func makeCredits( + from limits: RPCRateLimitSnapshot, + rateLimitsByLimitId: [String: RPCRateLimitSnapshot]? = nil) -> CreditsSnapshot? + { + let updatedAt = Date() + let balance = limits.credits.map { self.parseCredits($0.balance) } + let creditLimit = self.codexCreditLimit( + from: limits, + rateLimitsByLimitId: rateLimitsByLimitId, + updatedAt: updatedAt) + guard balance != nil || creditLimit != nil else { return nil } + return CreditsSnapshot( + remaining: balance ?? 0, + events: [], + updatedAt: updatedAt, + codexCreditLimit: creditLimit) + } + + private static func codexCreditLimit( + from limits: RPCRateLimitSnapshot, + rateLimitsByLimitId: [String: RPCRateLimitSnapshot]?, + updatedAt: Date) -> CodexCreditLimitSnapshot? + { + let candidates = [limits] + (rateLimitsByLimitId?.values.sorted { + ($0.limitName ?? $0.limitId ?? "") < ($1.limitName ?? $1.limitId ?? "") + } ?? []) + for candidate in candidates { + if let limit = self.codexCreditLimit(from: candidate, updatedAt: updatedAt) { + return limit + } + } + return nil + } + + private static func codexCreditLimit( + from snapshot: RPCRateLimitSnapshot, + updatedAt: Date) -> CodexCreditLimitSnapshot? + { + guard let individualLimit = snapshot.individualLimit else { return nil } + guard let limit = individualLimit.limit, limit > 0 else { return nil } + let used: Double = if let used = individualLimit.used { + used + } else if let remainingPercent = individualLimit.remainingPercent { + limit * max(0, min(100, 100 - remainingPercent)) / 100 + } else { + 0 + } + let remainingPercent = individualLimit.remainingPercent ?? max(0, min(100, 100 - (used / limit * 100))) + let resetsAt = individualLimit.resetsAt.flatMap { value -> Date? in + guard value > 0 else { return nil } + return Date(timeIntervalSince1970: TimeInterval(value)) + } + return CodexCreditLimitSnapshot( + title: self.codexCreditLimitTitle(from: snapshot.limitName), + used: used, + limit: limit, + remainingPercent: remainingPercent, + resetsAt: resetsAt, + updatedAt: updatedAt) + } + + private static func codexCreditLimitTitle(from limitName: String?) -> String { + let trimmed = limitName?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + if trimmed.isEmpty { + return "Monthly credit limit" + } + return trimmed } private static func emptyCodexUsageSnapshotIfIdentified(identity: ProviderIdentitySnapshot) -> UsageSnapshot? { diff --git a/Sources/CodexBarCore/UsageFormatter.swift b/Sources/CodexBarCore/UsageFormatter.swift index 9f0b1b641e..f81fe62976 100644 --- a/Sources/CodexBarCore/UsageFormatter.swift +++ b/Sources/CodexBarCore/UsageFormatter.swift @@ -50,16 +50,31 @@ public enum UsageFormatter { if let provider { return provider(key) } + #if canImport(ObjectiveC) + // Bundle(for:) requires Objective-C bundle introspection. Linux uses the English + // fallback below; app localization is injected through localizationProvider. let coreBundle = Bundle(for: BundleToken.self) let coreValue = NSLocalizedString(key, tableName: "Localizable", bundle: coreBundle, value: key, comment: "") if coreValue != key { return coreValue } let mainValue = NSLocalizedString(key, tableName: "Localizable", bundle: .main, value: key, comment: "") if mainValue != key { return mainValue } + #endif switch key { + case "Updated relative %@": return "Updated %@" + case "Updated absolute %@": return "Updated %@" case "usage_percent_suffix_left": return "left" case "usage_percent_suffix_used": return "used" + case "reset_tomorrow_format": return "tomorrow, %@" + case "byte_unit_byte": return "byte" + case "byte_unit_bytes": return "bytes" + case "byte_unit_kilobyte": return "kilobyte" + case "byte_unit_kilobytes": return "kilobytes" + case "byte_unit_megabyte": return "megabyte" + case "byte_unit_megabytes": return "megabytes" + case "byte_unit_gigabyte": return "gigabyte" + case "byte_unit_gigabytes": return "gigabytes" default: return key } } @@ -69,13 +84,26 @@ public enum UsageFormatter { return String(format: format, locale: self.currentLocale(), arguments: args) } + public static func percentText(_ percent: Double, suffix: String) -> String { + let clamped = min(100, max(0, percent)) + if clamped > 0, clamped < 1 { + return self.localized("<1%% %@", suffix) + } + return self.localized("%.0f%% %@", clamped, suffix) + } + public static func usageLine(remaining: Double, used: Double, showUsed: Bool) -> String { let percent = showUsed ? used : remaining - let clamped = min(100, max(0, percent)) let suffix = showUsed ? self.localized("usage_percent_suffix_used") : self.localized("usage_percent_suffix_left") - return String(format: "%.0f%% %@", clamped, suffix) + return self.percentText(percent, suffix: suffix) + } + + public static func percentString(_ percent: Double) -> String { + let clamped = min(100, max(0, percent)) + if clamped > 0, clamped < 1 { return "<1%" } + return String(format: "%.0f%%", clamped) } public static func resetCountdownDescription(from date: Date, now: Date = .init()) -> String { @@ -89,6 +117,7 @@ public enum UsageFormatter { if days > 0 { if hours > 0 { return "in \(days)d \(hours)h" } + if minutes > 0 { return "in \(days)d \(minutes)m" } return "in \(days)d" } if hours > 0 { @@ -107,7 +136,8 @@ public enum UsageFormatter { if let tomorrow = calendar.date(byAdding: .day, value: 1, to: now), calendar.isDate(date, inSameDayAs: tomorrow) { - return "tomorrow, \(date.formatted(.dateTime.hour().minute().locale(self.currentLocale())))" + let timeStr = date.formatted(.dateTime.hour().minute().locale(self.currentLocale())) + return self.localized("reset_tomorrow_format", timeStr) } return date.formatted(.dateTime.month(.abbreviated).day().hour().minute().locale(self.currentLocale())) } @@ -156,7 +186,7 @@ public enum UsageFormatter { let rel = RelativeDateTimeFormatter() rel.locale = self.currentLocale() rel.unitsStyle = .abbreviated - return self.localized("Updated %@", rel.localizedString(for: date, relativeTo: now)) + return self.localized("Updated relative %@", rel.localizedString(for: date, relativeTo: now)) #else let seconds = max(0, Int(now.timeIntervalSince(date))) if seconds < 3600 { @@ -168,19 +198,22 @@ public enum UsageFormatter { #endif } else { return self.localized( - "Updated %@", + "Updated absolute %@", date.formatted(.dateTime.hour().minute().locale(self.currentLocale()))) } } public static func creditsString(from value: Double) -> String { + self.localized("%@ left", self.creditsNumberString(from: value)) + } + + public static func creditsNumberString(from value: Double) -> String { let number = NumberFormatter() number.numberStyle = .decimal number.maximumFractionDigits = 2 // Use explicit locale for consistent formatting on all systems number.locale = Locale(identifier: "en_US_POSIX") - let formatted = number.string(from: NSNumber(value: value)) ?? String(format: "%.2f", value) - return self.localized("%@ left", formatted) + return number.string(from: NSNumber(value: value)) ?? String(format: "%.2f", value) } public static func kiroCreditNumber(_ value: Double) -> String { @@ -191,6 +224,83 @@ public enum UsageFormatter { return String(format: "%.2f", value) } + /// Formats a USD value into a target currency code with exchange rate conversion applied. + public static func convertedCostString(_ usdValue: Double, targetCurrency: String) -> String { + let converted = Self.convertedCost( + usdValue, + preferredCurrency: targetCurrency, + providerCurrency: "USD") + return self.currencyString(converted.value, currencyCode: converted.currencyCode) + } + + /// Formats a value from one currency into another via USD pivot conversion. + /// Useful when displaying provider costs that are denominated in non-USD currencies + /// (e.g., Anthropic extra usage returned in GBP) under the user's preferred currency. + public static func convertedCostString( + _ value: Double, + fromCurrency: String, + targetCurrency: String) -> String + { + guard let converted = CurrencyExchange.shared.convert( + amount: value, + from: fromCurrency, + to: targetCurrency) + else { + return self.currencyString(value, currencyCode: fromCurrency) + } + return self.currencyString(converted, currencyCode: targetCurrency) + } + + /// Resolves the effective currency code for cost display given user preference + /// and an optional provider currency. Returns the provider currency when preference + /// is "auto", otherwise returns the explicit preference. + public static func effectiveCurrencyCode( + preferred: String, + providerCurrency: String?) -> String + { + guard preferred != "auto", !preferred.isEmpty else { + return providerCurrency ?? "USD" + } + return preferred + } + + /// Formats a cost value with smart currency conversion. + /// - When `preferredCurrency` is "auto", renders in `providerCurrency` (or USD fallback) without conversion. + /// - When `preferredCurrency` is an explicit code, converts from `providerCurrency` to the target. + public static func convertedCostString( + _ value: Double, + preferredCurrency: String, + providerCurrency: String?) -> String + { + let converted = Self.convertedCost( + value, + preferredCurrency: preferredCurrency, + providerCurrency: providerCurrency) + return Self.currencyString(converted.value, currencyCode: converted.currencyCode) + } + + /// Resolves and converts a numeric cost while preserving its source currency + /// when the requested exchange rate is unavailable. + public static func convertedCost( + _ value: Double, + preferredCurrency: String, + providerCurrency: String?) -> (value: Double, currencyCode: String) + { + let sourceCurrency = providerCurrency ?? "USD" + let targetCurrency = Self.effectiveCurrencyCode( + preferred: preferredCurrency, + providerCurrency: providerCurrency) + guard targetCurrency != sourceCurrency, + let converted = CurrencyExchange.shared.convert( + amount: value, + from: sourceCurrency, + to: targetCurrency) + else { + return (value, sourceCurrency) + } + return (converted, targetCurrency) + } + /// Formats a USD value with proper negative handling and thousand separators. /// Uses Swift's modern FormatStyle API (iOS 15+/macOS 12+) for robust, locale-aware formatting. public static func usdString(_ value: Double) -> String { @@ -204,6 +314,8 @@ public enum UsageFormatter { case .claude: "Estimated from local Claude logs at API rates; token totals include cache read/write tokens " + "and may differ from Claude Code /status." + case .cursor: + "From Cursor's usage dashboard at vendor token rates; may differ from your invoice." default: self.costEstimateHint } @@ -216,6 +328,16 @@ public enum UsageFormatter { value.formatted(.currency(code: currencyCode).locale(Locale(identifier: "en_US"))) } + public static func compactCurrencyString(_ value: Double, currencyCode: String) -> String { + if value != 0, abs(value) < 1 { + return self.currencyString(value, currencyCode: currencyCode) + } + return value.formatted( + .currency(code: currencyCode) + .precision(.fractionLength(0)) + .locale(Locale(identifier: "en_US"))) + } + public static func tokenCountString(_ value: Int) -> String { let absValue = abs(value) let sign = value < 0 ? "-" : "" @@ -239,16 +361,12 @@ public enum UsageFormatter { return "\(sign)\(formatted)\(unit.suffix)" } - let formatter = NumberFormatter() - formatter.numberStyle = .decimal - formatter.usesGroupingSeparator = true - formatter.locale = Locale(identifier: "en_US_POSIX") - return formatter.string(from: NSNumber(value: value)) ?? "\(value)" + return "\(value)" } public static func byteCountString(_ bytes: Int64) -> String { let sign = bytes < 0 ? "-" : "" - let absBytes = Double(Swift.abs(bytes)) + let absBytes = Double(bytes.magnitude) let units: [(threshold: Double, divisor: Double, suffix: String)] = [ (1024 * 1024 * 1024, 1024 * 1024 * 1024, "GB"), (1024 * 1024, 1024 * 1024, "MB"), @@ -265,6 +383,30 @@ public enum UsageFormatter { return "\(bytes) B" } + /// Same magnitudes as `byteCountString`, but spelled out ("megabytes" instead of "MB"). + public static func byteCountStringLong(_ bytes: Int64) -> String { + let sign = bytes < 0 ? "-" : "" + let absBytes = Double(bytes.magnitude) + let units: [(threshold: Double, divisor: Double, singularKey: String, pluralKey: String)] = [ + (1024 * 1024 * 1024, 1024 * 1024 * 1024, "byte_unit_gigabyte", "byte_unit_gigabytes"), + (1024 * 1024, 1024 * 1024, "byte_unit_megabyte", "byte_unit_megabytes"), + (1024, 1024, "byte_unit_kilobyte", "byte_unit_kilobytes"), + ] + + for unit in units where absBytes >= unit.threshold { + let scaled = absBytes / unit.divisor + let format = scaled >= 10 || scaled.rounded(.towardZero) == scaled ? "%.0f" : "%.1f" + let formatted = String(format: format, locale: self.currentLocale(), scaled) + let displayScale = format == "%.0f" ? 1.0 : 10.0 + let displayedValue = (scaled * displayScale).rounded() / displayScale + let word = self.localized(displayedValue == 1 ? unit.singularKey : unit.pluralKey) + return "\(sign)\(formatted) \(word)" + } + + let word = self.localized(bytes.magnitude == 1 ? "byte_unit_byte" : "byte_unit_bytes") + return "\(bytes) \(word)" + } + public static func creditEventSummary(_ event: CreditEvent) -> String { let formatter = DateFormatter() formatter.dateStyle = .medium @@ -305,6 +447,7 @@ public enum UsageFormatter { public static func modelDisplayName(_ raw: String) -> String { var cleaned = raw.trimmingCharacters(in: .whitespacesAndNewlines) guard !cleaned.isEmpty else { return raw } + if CostUsagePricing.isCodexUnattributedModel(cleaned) { return "Unknown model" } let patterns = [ #"(?:-|\s)\d{8}$"#, diff --git a/Sources/CodexBarCore/UsagePace.swift b/Sources/CodexBarCore/UsagePace.swift index 2c93f0a474..8f55aa4475 100644 --- a/Sources/CodexBarCore/UsagePace.swift +++ b/Sources/CodexBarCore/UsagePace.swift @@ -18,6 +18,7 @@ public struct UsagePace: Sendable { public let etaSeconds: TimeInterval? public let willLastToReset: Bool public let runOutProbability: Double? + public let speedMultiplierToReset: Double? public init( stage: Stage, @@ -26,7 +27,8 @@ public struct UsagePace: Sendable { actualUsedPercent: Double, etaSeconds: TimeInterval?, willLastToReset: Bool, - runOutProbability: Double? = nil) + runOutProbability: Double? = nil, + speedMultiplierToReset: Double? = nil) { self.stage = stage self.deltaPercent = deltaPercent @@ -35,12 +37,15 @@ public struct UsagePace: Sendable { self.etaSeconds = etaSeconds self.willLastToReset = willLastToReset self.runOutProbability = runOutProbability + self.speedMultiplierToReset = speedMultiplierToReset } public static func weekly( window: RateWindow, now: Date = .init(), - defaultWindowMinutes: Int = 10080) -> UsagePace? + defaultWindowMinutes: Int = 10080, + workDays: Int? = nil, + calendar: Calendar = .current) -> UsagePace? { guard let resetsAt = window.resetsAt else { return nil } let minutes = window.windowMinutes ?? defaultWindowMinutes @@ -51,7 +56,20 @@ public struct UsagePace: Sendable { guard timeUntilReset > 0 else { return nil } guard timeUntilReset <= duration else { return nil } let elapsed = (duration - timeUntilReset).clamped(to: 0...duration) - let expected = ((elapsed / duration) * 100).clamped(to: 0...100) + let workdayProgress: WorkdayProgress? = if let workDays, workDays >= 2, workDays < 7, + minutes == 10080 + { + Self.workdayProgress( + now: now, + duration: duration, + resetsAt: resetsAt, + workDays: workDays, + calendar: calendar) + } else { + nil + } + let expected = workdayProgress?.expectedUsedPercent + ?? ((elapsed / duration) * 100).clamped(to: 0...100) let actual = window.usedPercent.clamped(to: 0...100) if elapsed == 0, actual > 0 { return nil @@ -62,18 +80,35 @@ public struct UsagePace: Sendable { var etaSeconds: TimeInterval? var willLastToReset = false - if elapsed > 0, actual > 0 { - let rate = actual / elapsed + let paceElapsed = workdayProgress?.elapsedSeconds ?? elapsed + let effectiveTimeUntilReset = workdayProgress?.remainingSeconds ?? timeUntilReset + let projectedRemainingUsage = paceElapsed > 0 + ? actual * effectiveTimeUntilReset / paceElapsed + : 0 + let speedMultiplierToReset = Self.safeSpeedMultiplier( + remainingCapacity: 100 - actual, + projectedRemainingUsage: projectedRemainingUsage) + if actual >= 100 { + etaSeconds = 0 + } else if paceElapsed > 0, actual > 0 { + let rate = actual / paceElapsed if rate > 0 { - let remaining = max(0, 100 - actual) + let remaining = 100 - actual let candidate = remaining / rate - if candidate >= timeUntilReset { + if candidate >= effectiveTimeUntilReset { willLastToReset = true + } else if let workDays = workdayProgress?.workDays { + etaSeconds = Self.wallClockInterval( + from: now, + to: resetsAt, + consumingWorkSeconds: candidate, + workDays: workDays, + calendar: calendar) } else { etaSeconds = candidate } } - } else if elapsed > 0, actual == 0 { + } else if paceElapsed > 0, actual == 0 { willLastToReset = true } @@ -84,7 +119,8 @@ public struct UsagePace: Sendable { actualUsedPercent: actual, etaSeconds: etaSeconds, willLastToReset: willLastToReset, - runOutProbability: nil) + runOutProbability: nil, + speedMultiplierToReset: speedMultiplierToReset) } public static func historical( @@ -92,7 +128,8 @@ public struct UsagePace: Sendable { actualUsedPercent: Double, etaSeconds: TimeInterval?, willLastToReset: Bool, - runOutProbability: Double?) -> UsagePace + runOutProbability: Double?, + projectedRemainingUsage: Double? = nil) -> UsagePace { let expected = expectedUsedPercent.clamped(to: 0...100) let actual = actualUsedPercent.clamped(to: 0...100) @@ -104,7 +141,116 @@ public struct UsagePace: Sendable { actualUsedPercent: actual, etaSeconds: etaSeconds, willLastToReset: willLastToReset, - runOutProbability: runOutProbability) + runOutProbability: runOutProbability, + speedMultiplierToReset: projectedRemainingUsage.flatMap { + Self.safeSpeedMultiplier( + remainingCapacity: 100 - actual, + projectedRemainingUsage: $0) + }) + } + + private static func safeSpeedMultiplier( + remainingCapacity: Double, + projectedRemainingUsage: Double) -> Double? + { + guard remainingCapacity > 0, projectedRemainingUsage > 0 else { return nil } + let multiplier = remainingCapacity / projectedRemainingUsage + return multiplier.isFinite ? multiplier : nil + } + + private struct WorkdayProgress { + let workDays: Int + let totalSeconds: TimeInterval + let elapsedSeconds: TimeInterval + let remainingSeconds: TimeInterval + + var expectedUsedPercent: Double { + ((self.elapsedSeconds / self.totalSeconds) * 100).clamped(to: 0...100) + } + } + + /// Splits the weekly window at local day boundaries so reset offsets do not shift weekday classification. + private static func workdayProgress( + now: Date, + duration: TimeInterval, + resetsAt: Date, + workDays: Int, + calendar: Calendar) -> WorkdayProgress? + { + let windowStart = resetsAt.addingTimeInterval(-duration) + + var totalWorkSeconds: TimeInterval = 0 + var elapsedWorkSeconds: TimeInterval = 0 + var remainingWorkSeconds: TimeInterval = 0 + + var cursor = windowStart + while cursor < resetsAt { + guard let startOfNextDay = Self.nextDayBoundary(after: cursor, calendar: calendar), + startOfNextDay > cursor + else { + return nil + } + let sliceEnd = min(startOfNextDay, resetsAt) + + if Self.isWorkday(cursor, calendar: calendar, workDays: workDays) { + let sliceDuration = sliceEnd.timeIntervalSince(cursor) + totalWorkSeconds += sliceDuration + if now > cursor { + elapsedWorkSeconds += min(now, sliceEnd).timeIntervalSince(cursor) + } + if now < sliceEnd { + remainingWorkSeconds += sliceEnd.timeIntervalSince(max(now, cursor)) + } + } + cursor = sliceEnd + } + + guard totalWorkSeconds > 0 else { return nil } + return WorkdayProgress( + workDays: workDays, + totalSeconds: totalWorkSeconds, + elapsedSeconds: elapsedWorkSeconds, + remainingSeconds: remainingWorkSeconds) + } + + private static func wallClockInterval( + from now: Date, + to resetsAt: Date, + consumingWorkSeconds requiredWorkSeconds: TimeInterval, + workDays: Int, + calendar: Calendar) -> TimeInterval? + { + guard requiredWorkSeconds > 0 else { return 0 } + + var remaining = requiredWorkSeconds + var cursor = now + while cursor < resetsAt { + guard let startOfNextDay = Self.nextDayBoundary(after: cursor, calendar: calendar), + startOfNextDay > cursor + else { + return nil + } + let sliceEnd = min(startOfNextDay, resetsAt) + if Self.isWorkday(cursor, calendar: calendar, workDays: workDays) { + let available = sliceEnd.timeIntervalSince(cursor) + if remaining <= available { + return cursor.addingTimeInterval(remaining).timeIntervalSince(now) + } + remaining -= available + } + cursor = sliceEnd + } + return nil + } + + private static func nextDayBoundary(after date: Date, calendar: Calendar) -> Date? { + calendar.date(byAdding: .day, value: 1, to: calendar.startOfDay(for: date)) + } + + private static func isWorkday(_ date: Date, calendar: Calendar, workDays: Int) -> Bool { + let weekday = calendar.component(.weekday, from: date) + let isoWeekday = weekday == 1 ? 7 : weekday - 1 + return isoWeekday <= workDays } private static func stage(for delta: Double) -> Stage { diff --git a/Sources/CodexBarCore/UsagePercent.swift b/Sources/CodexBarCore/UsagePercent.swift new file mode 100644 index 0000000000..c34efd1fa1 --- /dev/null +++ b/Sources/CodexBarCore/UsagePercent.swift @@ -0,0 +1,24 @@ +/// A provider-reported usage percentage before and after display normalization. +/// +/// Keep `raw` when over-quota values carry meaning for provider-specific details or pace diagnostics. +/// Use `displayClamped` when projecting a percentage into a headline or `RateWindow` display value. +/// `RateWindow` itself intentionally remains raw-capable so callers must choose the appropriate contract. +public struct UsagePercent: Equatable, Sendable { + public let raw: Double + + public init(raw: Double) { + self.raw = raw + } + + /// Computes an unbounded percentage from a numeric quota ratio. + /// + /// Callers must resolve the provider-specific fallback for a missing or non-positive limit first. + public init(used: Double, limit: Double) { + precondition(limit > 0, "Usage percent requires a positive limit") + self.raw = (used / limit) * 100 + } + + public var displayClamped: Double { + self.raw.clamped(to: 0...100) + } +} diff --git a/Sources/CodexBarCore/UsageSnapshot+SwitcherWeeklyWindow.swift b/Sources/CodexBarCore/UsageSnapshot+SwitcherWeeklyWindow.swift new file mode 100644 index 0000000000..b688f34c7d --- /dev/null +++ b/Sources/CodexBarCore/UsageSnapshot+SwitcherWeeklyWindow.swift @@ -0,0 +1,60 @@ +extension UsageSnapshot { + public func switcherWeeklyWindow(for provider: UsageProvider, showUsed: Bool) -> RateWindow? { + // This surface is labelled "Weekly progress", so prefer a real 7-day lane when one is + // available. Some providers publish model-specific weekly lanes in extraRateWindows. + if let weekly = self.mostConstrainedSwitcherWeeklyWindow(for: provider) { + return weekly + } + + // Keep the existing provider-specific fallback for providers without a weekly allowance. + switch provider { + case .factory: + // Factory prefers secondary window + return self.secondary ?? self.primary + case .perplexity: + return self.automaticPerplexityWindow() + case .cursor: + // Cursor: fall back to on-demand budget when the included plan is exhausted (only in + // "show remaining" mode). The secondary/tertiary lanes are Total/Auto/API breakdowns, + // not extra capacity, so they should not replace the remaining paid quota indicator. + if !showUsed, + let primary = self.primary, + primary.remainingPercent <= 0, + let providerCost = self.providerCost, + providerCost.limit > 0 + { + let usedPercent = max(0, min(100, (providerCost.used / providerCost.limit) * 100)) + return RateWindow( + usedPercent: usedPercent, + windowMinutes: nil, + resetsAt: providerCost.resetsAt, + resetDescription: nil) + } + return self.primary ?? self.secondary + default: + return self.primary ?? self.secondary + } + } + + private func mostConstrainedSwitcherWeeklyWindow(for provider: UsageProvider) -> RateWindow? { + // Claude's Sonnet/Opus tertiary and model-scoped extras (Fable, Daily Routines) belong on + // the detail card. The overview switcher should track account Weekly so an exhausted + // carve-out does not empty the bar while Weekly still has quota left. + let standardWindows: [RateWindow] = switch provider { + case .claude: + [self.primary, self.secondary].compactMap(\.self) + default: + [self.primary, self.secondary, self.tertiary].compactMap(\.self) + } + let namedWindows = (self.extraRateWindows ?? []) + .filter(\.usageKnown) + .filter { named in + guard provider == .claude else { return true } + return !named.id.hasPrefix("claude-weekly-scoped-") && named.id != "claude-routines" + } + .map(\.window) + return (standardWindows + namedWindows) + .filter { $0.windowMinutes == 7 * 24 * 60 } + .max { $0.usedPercent < $1.usedPercent } + } +} diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CodexSubagentRolloutShape.swift b/Sources/CodexBarCore/Vendored/CostUsage/CodexSubagentRolloutShape.swift new file mode 100644 index 0000000000..56f5608a56 --- /dev/null +++ b/Sources/CodexBarCore/Vendored/CostUsage/CodexSubagentRolloutShape.swift @@ -0,0 +1,206 @@ +import Foundation + +extension CostUsageScanner { + enum CodexSubagentCounterSemantics: Equatable { + case independent + case copiedPrefix + } + + /// Subagent source is lineage evidence, not counter semantics. The first session metadata + /// owns leaf identity. Embedded ancestor metadata proves a copied prefix by itself; compact + /// rollouts need both the first-turn boundary and an exact parent snapshot match in the scanner. + /// Do not restore a blanket "all subagents are independent/inherited" rule. + struct CodexSubagentRolloutShape { + let counterSemantics: CodexSubagentCounterSemantics + let ownedSuffix: CodexSubagentOwnedSuffix? + let ownedSuffixCandidate: CodexSubagentOwnedSuffixCandidate? + let inferredParentSessionID: String? + + struct CodexSubagentOwnedSuffix { + let startLineIndex: Int + let rawTotalsBaseline: CostUsageCodexTotals + } + + struct CodexSubagentOwnedSuffixCandidate { + let ownedSuffix: CodexSubagentOwnedSuffix + let parentTotalsAtBoundary: CostUsageCodexTotals + } + + struct Observation { + let lineIndex: Int + let kind: Kind + + enum Kind { + case sessionMetadata(id: String?) + case turnContext + case interAgentCommunication(triggerTurn: Bool) + case tokenCount(total: CostUsageCodexTotals?, last: CostUsageCodexTotals?) + } + } + + static func classify( + leafSessionID: String?, + observedSessionIDs: [String?]) -> Self + { + let normalizedLeafID = Self.normalizedSessionID(leafSessionID) + + let hasEmbeddedAncestor: Bool = if let normalizedLeafID { + observedSessionIDs.contains { Self.normalizedSessionID($0) != normalizedLeafID } + } else { + observedSessionIDs.count > 1 || observedSessionIDs.contains { Self.normalizedSessionID($0) != nil } + } + let distinctAncestorIDs = Set(observedSessionIDs + .compactMap(Self.normalizedSessionID) + .filter { normalizedLeafID == nil || $0 != normalizedLeafID }) + let inferredParentSessionID = distinctAncestorIDs.count == 1 ? distinctAncestorIDs.first : nil + + return Self( + counterSemantics: hasEmbeddedAncestor ? .copiedPrefix : .independent, + ownedSuffix: nil, + ownedSuffixCandidate: nil, + inferredParentSessionID: inferredParentSessionID) + } + + static func classify( + leafSessionID: String?, + observations: [Observation], + hasExplicitParent: Bool = false) -> Self + { + let metadataIDs = observations.reduce(into: [String?]()) { result, observation in + guard case let .sessionMetadata(id) = observation.kind else { return } + result.append(id) + } + let metadataShape = Self.classify( + leafSessionID: leafSessionID, + observedSessionIDs: metadataIDs) + let canProposeParentConfirmedSuffix = metadataShape.counterSemantics == .independent + && hasExplicitParent + guard metadataShape.counterSemantics == .copiedPrefix || canProposeParentConfirmedSuffix + else { return metadataShape } + + let normalizedLeafID = Self.normalizedSessionID(leafSessionID) + var lastRawTotals: CostUsageCodexTotals? + var pendingTurnContext: (lineIndex: Int, baseline: CostUsageCodexTotals)? + var ownedSuffix: CodexSubagentOwnedSuffix? + var parentTotalsAtBoundary: CostUsageCodexTotals? + var inspectedOwnedSuffixFirstTotal = false + var observedAuthoritativeMetadata = false + var observedTurnContext = false + + for observation in observations { + switch observation.kind { + case let .sessionMetadata(id): + let normalizedID = Self.normalizedSessionID(id) + let isEmbeddedAncestor: Bool = if !observedAuthoritativeMetadata { + false + } else if let normalizedLeafID { + normalizedID != normalizedLeafID + } else { + true + } + observedAuthoritativeMetadata = true + if isEmbeddedAncestor { + // A later ancestor meta proves that any earlier candidate boundary was replay. + ownedSuffix = nil + parentTotalsAtBoundary = nil + inspectedOwnedSuffixFirstTotal = false + } + pendingTurnContext = nil + + case .turnContext: + let isFirstTurnContext = !observedTurnContext + observedTurnContext = true + let acceptsBoundary = metadataShape.counterSemantics == .copiedPrefix + || (canProposeParentConfirmedSuffix && isFirstTurnContext) + pendingTurnContext = acceptsBoundary + ? lastRawTotals.map { (observation.lineIndex, $0) } + : nil + + case let .interAgentCommunication(triggerTurn): + if ownedSuffix == nil, + triggerTurn, + let pendingTurnContext, + observation.lineIndex == pendingTurnContext.lineIndex + 1, + metadataShape.counterSemantics == .copiedPrefix + || Self.totalsContainUsage(pendingTurnContext.baseline) + { + ownedSuffix = Self.CodexSubagentOwnedSuffix( + startLineIndex: pendingTurnContext.lineIndex, + rawTotalsBaseline: pendingTurnContext.baseline) + parentTotalsAtBoundary = pendingTurnContext.baseline + inspectedOwnedSuffixFirstTotal = false + } + pendingTurnContext = nil + + case let .tokenCount(total, last): + if !inspectedOwnedSuffixFirstTotal, + let suffix = ownedSuffix, + let total + { + inspectedOwnedSuffixFirstTotal = true + if let last, + Self.totalsEqual(total, last), + !Self.totalsAtLeast(total, suffix.rawTotalsBaseline) + { + // Some future protocol may copy history and then restart its counter. + // Require both a strong boundary and total==last reset evidence. + ownedSuffix = Self.CodexSubagentOwnedSuffix( + startLineIndex: suffix.startLineIndex, + rawTotalsBaseline: .init(input: 0, cached: 0, output: 0)) + } + } + if let total { + lastRawTotals = total + } + pendingTurnContext = nil + } + } + + if metadataShape.counterSemantics == .copiedPrefix { + return Self( + counterSemantics: .copiedPrefix, + ownedSuffix: ownedSuffix, + ownedSuffixCandidate: nil, + inferredParentSessionID: metadataShape.inferredParentSessionID) + } + + let candidate: CodexSubagentOwnedSuffixCandidate? = if let ownedSuffix, let parentTotalsAtBoundary { + Self.CodexSubagentOwnedSuffixCandidate( + ownedSuffix: ownedSuffix, + parentTotalsAtBoundary: parentTotalsAtBoundary) + } else { + nil + } + return Self( + counterSemantics: .independent, + ownedSuffix: nil, + ownedSuffixCandidate: candidate, + inferredParentSessionID: metadataShape.inferredParentSessionID) + } + + static func sameConcreteSessionID(_ lhs: String?, _ rhs: String?) -> Bool { + guard let lhs = normalizedSessionID(lhs), + let rhs = normalizedSessionID(rhs) + else { return false } + return lhs == rhs + } + + private static func totalsEqual(_ lhs: CostUsageCodexTotals, _ rhs: CostUsageCodexTotals) -> Bool { + lhs.input == rhs.input && lhs.cached == rhs.cached && lhs.output == rhs.output + } + + private static func totalsAtLeast(_ lhs: CostUsageCodexTotals, _ rhs: CostUsageCodexTotals) -> Bool { + lhs.input >= rhs.input && lhs.cached >= rhs.cached && lhs.output >= rhs.output + } + + private static func totalsContainUsage(_ totals: CostUsageCodexTotals) -> Bool { + totals.input > 0 || totals.cached > 0 || totals.output > 0 + } + + private static func normalizedSessionID(_ value: String?) -> String? { + guard let value else { return nil } + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } + } +} diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCache.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCache.swift index effe1478dd..e13e0bcd3b 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCache.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCache.swift @@ -1,12 +1,19 @@ import Foundation enum CostUsageCacheIO { + /// Producer keys from older parser hashes whose caches are still valid under the current + /// delta semantics. Cleared for #2037: interleave containment changed how cumulative + /// totals are counted, so every earlier cache must be rebuilt. + private static let compatibleCodexProducerKeys: Set = [] + + /// Parsing and attribution changes rotate the Codex parser producer key. + /// Increment this artifact version only when the stored schema or cache layout becomes incompatible. private static func artifactVersion(for provider: UsageProvider) -> Int { switch provider { case .codex: - 8 + 11 case .claude, .vertexai: - 2 + 6 default: 1 } @@ -28,21 +35,40 @@ enum CostUsageCacheIO { static func load( provider: UsageProvider, cacheRoot: URL? = nil, - producerKey: String? = nil) -> CostUsageCache + producerKey: String? = nil, + calendar: Calendar? = nil) -> CostUsageCache { let url = self.cacheFileURL(provider: provider, cacheRoot: cacheRoot) let expectedProducerKey = producerKey ?? self.currentProducerKey(provider: provider) - if let decoded = self.loadCache(at: url, expectedProducerKey: expectedProducerKey) { return decoded } + let compatibleProducerKeys = producerKey == nil && provider == .codex + ? self.compatibleCodexProducerKeys + : [] + if let decoded = self.loadCache( + at: url, + expectedProducerKey: expectedProducerKey, + compatibleProducerKeys: compatibleProducerKeys) + { + if let calendar, decoded.timeZoneIdentifier != calendar.timeZone.identifier { + return CostUsageCache() + } + return decoded + } return CostUsageCache() } - private static func loadCache(at url: URL, expectedProducerKey: String?) -> CostUsageCache? { + private static func loadCache( + at url: URL, + expectedProducerKey: String?, + compatibleProducerKeys: Set) -> CostUsageCache? + { guard let data = try? Data(contentsOf: url) else { return nil } guard let decoded = try? JSONDecoder().decode(CostUsageCache.self, from: data) else { return nil } guard decoded.version == 1 else { return nil } if let expectedProducerKey { - guard decoded.producerKey == expectedProducerKey else { return nil } + guard decoded.producerKey == expectedProducerKey + || decoded.producerKey.map(compatibleProducerKeys.contains) == true + else { return nil } } return decoded } @@ -51,7 +77,8 @@ enum CostUsageCacheIO { provider: UsageProvider, cache: CostUsageCache, cacheRoot: URL? = nil, - producerKey: String? = nil) + producerKey: String? = nil, + calendar: Calendar = .current) { let url = self.cacheFileURL(provider: provider, cacheRoot: cacheRoot) let dir = url.deletingLastPathComponent() @@ -59,6 +86,7 @@ enum CostUsageCacheIO { var cache = cache cache.producerKey = producerKey ?? self.currentProducerKey(provider: provider) + cache.timeZoneIdentifier = calendar.timeZone.identifier let tmp = dir.appendingPathComponent(".tmp-\(UUID().uuidString).json", isDirectory: false) let data = (try? JSONEncoder().encode(cache)) ?? Data() @@ -89,8 +117,10 @@ struct CostUsageCache: Codable { var lastScanUnixMs: Int64 = 0 var scanSinceKey: String? var scanUntilKey: String? + var timeZoneIdentifier: String? var codexPricingKey: String? var codexPriorityMetadataKey: String? + var codexProjectMetadataVersion: Int? var codexPriorityTurnKeys: [String: String]? var codexPriorityTurnIDsByDay: [String: [String]]? @@ -113,10 +143,18 @@ struct CostUsageFileUsage: Codable { var lastTotals: CostUsageCodexTotals? var lastCountedTotals: CostUsageCodexTotals? var lastRawTotalsBaseline: CostUsageCodexTotals? + var lastRawTotalsWatermark: CostUsageCodexTotals? + var seenRawTotals: [CostUsageCodexTotals]? var hasDivergentTotals: Bool? + var hasInterleavedTotals: Bool? var lastCodexTurnID: String? var sessionId: String? var forkedFromId: String? + var forkBaselineDependencyKey: String? + var projectPath: String? + var canonicalProjectPath: String? + var codexCostCacheComplete: Bool? + var codexSession: CostUsageCodexSessionMetadata? var codexCostNanos: [String: [String: Int64]]? var codexPrioritySurchargeNanos: [String: [String: Int64]]? var codexStandardCostNanos: [String: [String: Int64]]? @@ -124,12 +162,75 @@ struct CostUsageFileUsage: Codable { var codexStandardTokens: [String: [String: Int]]? var codexPriorityTokens: [String: [String: Int]]? var codexTurnIDs: [String]? + /// Refreshed by Codex normalization paths, never by sidecar cache validation. + var codexWorkspaceContentFingerprint: String? var codexRows: [CostUsageScanner.CodexUsageRow]? var claudeRows: [CostUsageScanner.ClaudeUsageRow]? + /// Identity and target size for an in-progress bounded Codex parse. + var codexScanFileId: String? + var codexScanTargetSize: Int64? + var codexScanComplete: Bool? + var codexJSONLResumeState: CostUsageJsonl.ResumeState? + /// Compact relevant events retained while a subagent rollout awaits full-shape classification. + var codexBufferedSubagentLines: [CostUsageScanner.CodexBufferedFastLine]? +} + +struct CostUsageCodexSessionMetadata: Codable, Equatable { + var sessionId: String? + var forkedFromId: String? + var cwd: String? + var title: String? + var startedAtUnixMs: Int64? + var latestActivityUnixMs: Int64? + + var isEmpty: Bool { + self.sessionId == nil + && self.forkedFromId == nil + && self.cwd == nil + && self.title == nil + && self.startedAtUnixMs == nil + && self.latestActivityUnixMs == nil + } + + func merging(_ newer: CostUsageCodexSessionMetadata) -> CostUsageCodexSessionMetadata { + CostUsageCodexSessionMetadata( + sessionId: newer.sessionId ?? self.sessionId, + forkedFromId: newer.forkedFromId ?? self.forkedFromId, + cwd: newer.cwd ?? self.cwd, + title: newer.title ?? self.title, + startedAtUnixMs: Self.earlier(self.startedAtUnixMs, newer.startedAtUnixMs), + latestActivityUnixMs: Self.later(self.latestActivityUnixMs, newer.latestActivityUnixMs)) + } + + private static func earlier(_ lhs: Int64?, _ rhs: Int64?) -> Int64? { + switch (lhs, rhs) { + case let (lhs?, rhs?): min(lhs, rhs) + case let (lhs?, nil): lhs + case let (nil, rhs?): rhs + case (nil, nil): nil + } + } + + private static func later(_ lhs: Int64?, _ rhs: Int64?) -> Int64? { + switch (lhs, rhs) { + case let (lhs?, rhs?): max(lhs, rhs) + case let (lhs?, nil): lhs + case let (nil, rhs?): rhs + case (nil, nil): nil + } + } } -struct CostUsageCodexTotals: Codable { +struct CostUsageCodexTotals: Codable, Equatable { var input: Int var cached: Int var output: Int + var reasoning: Int? + + init(input: Int, cached: Int, output: Int, reasoning: Int? = nil) { + self.input = input + self.cached = cached + self.output = output + self.reasoning = reasoning + } } diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageJsonl.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageJsonl.swift index 7e13a91832..4911826e5b 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageJsonl.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageJsonl.swift @@ -6,6 +6,235 @@ enum CostUsageJsonl { let wasTruncated: Bool } + struct ResumeState: Codable { + let offset: Int64 + fileprivate let lineStartOffset: Int64 + fileprivate let prefix: Data + fileprivate let lineBytes: Int + fileprivate let truncated: Bool + fileprivate let jsonTailState: JSONTailState + } + + struct ScanProgress { + let committedOffset: Int64 + let readOffset: Int64 + let resumeState: ResumeState? + } + + fileprivate struct JSONTailState: Codable { + private enum ScalarState: Codable { + case notScalar + case trueLiteral(Int) + case falseLiteral(Int) + case nullLiteral(Int) + case number(NumberState) + case invalid + } + + private enum NumberState: Codable { + private enum ByteKind { + case zero + case digit + case decimalPoint + case exponentMarker + case sign + case whitespace + case other + + init(_ byte: UInt8) { + switch byte { + case 0x30: self = .zero + case 0x31...0x39: self = .digit + case 0x2E: self = .decimalPoint + case 0x65, 0x45: self = .exponentMarker + case 0x2B, 0x2D: self = .sign + case 0x20, 0x09, 0x0A, 0x0D: self = .whitespace + default: self = .other + } + } + } + + case sign + case zero + case integer + case decimalPoint + case fraction + case exponentMarker + case exponentSign + case exponentDigits + case finished + case invalid + + var canCommitAtEOF: Bool { + switch self { + case .finished, .invalid: + true + case .sign, .zero, .integer, .decimalPoint, .fraction, + .exponentMarker, .exponentSign, .exponentDigits: + false + } + } + + func appending(_ byte: UInt8) -> Self { + switch (self, ByteKind(byte)) { + case (.invalid, _): .invalid + case (.finished, .whitespace): .finished + case (.sign, .zero): .zero + case (.sign, .digit): .integer + case (.zero, .decimalPoint): .decimalPoint + case (.zero, .exponentMarker): .exponentMarker + case (.integer, .zero), (.integer, .digit): .integer + case (.integer, .decimalPoint): .decimalPoint + case (.integer, .exponentMarker): .exponentMarker + case (.decimalPoint, .zero), (.decimalPoint, .digit): .fraction + case (.fraction, .zero), (.fraction, .digit): .fraction + case (.fraction, .exponentMarker): .exponentMarker + case (.exponentMarker, .sign): .exponentSign + case (.exponentMarker, .zero), (.exponentMarker, .digit): .exponentDigits + case (.exponentSign, .zero), (.exponentSign, .digit): .exponentDigits + case (.exponentDigits, .zero), (.exponentDigits, .digit): .exponentDigits + case (.zero, .whitespace), + (.integer, .whitespace), + (.fraction, .whitespace), + (.exponentDigits, .whitespace): .finished + default: .invalid + } + } + } + + private static let trueLiteral = Array("true".utf8) + private static let falseLiteral = Array("false".utf8) + private static let nullLiteral = Array("null".utf8) + + private var containerDepth = 0 + private var insideString = false + private var escaping = false + private var sawNonWhitespace = false + private var scalarState = ScalarState.notScalar + + mutating func reset() { + self = Self() + } + + var isStructurallyComplete: Bool { + guard self.sawNonWhitespace else { return false } + switch self.scalarState { + case .notScalar: + return !self.insideString && self.containerDepth == 0 + case let .trueLiteral(matched): + return matched == Self.trueLiteral.count + case let .falseLiteral(matched): + return matched == Self.falseLiteral.count + case let .nullLiteral(matched): + return matched == Self.nullLiteral.count + case let .number(state): + return state.canCommitAtEOF + case .invalid: + return true + } + } + + mutating func append(_ byte: UInt8) { + if !self.sawNonWhitespace { + self.start(byte) + return + } + + guard !self.appendScalar(byte) else { return } + self.appendContainer(byte) + } + + private mutating func start(_ byte: UInt8) { + guard !Self.isWhitespace(byte) else { return } + self.sawNonWhitespace = true + switch byte { + case 0x22: + self.insideString = true + case 0x7B, 0x5B: + self.containerDepth = 1 + case 0x74: + self.scalarState = .trueLiteral(1) + case 0x66: + self.scalarState = .falseLiteral(1) + case 0x6E: + self.scalarState = .nullLiteral(1) + case 0x2D: + self.scalarState = .number(.sign) + case 0x30: + self.scalarState = .number(.zero) + case 0x31...0x39: + self.scalarState = .number(.integer) + default: + self.scalarState = .invalid + } + } + + private mutating func appendScalar(_ byte: UInt8) -> Bool { + switch self.scalarState { + case let .trueLiteral(matched): + self.scalarState = self.advanceLiteral(byte, expected: Self.trueLiteral, matched: matched) + .map(ScalarState.trueLiteral) ?? .invalid + return true + case let .falseLiteral(matched): + self.scalarState = self.advanceLiteral(byte, expected: Self.falseLiteral, matched: matched) + .map(ScalarState.falseLiteral) ?? .invalid + return true + case let .nullLiteral(matched): + self.scalarState = self.advanceLiteral(byte, expected: Self.nullLiteral, matched: matched) + .map(ScalarState.nullLiteral) ?? .invalid + return true + case let .number(state): + self.scalarState = .number(state.appending(byte)) + return true + case .invalid: + return true + case .notScalar: + return false + } + } + + private mutating func appendContainer(_ byte: UInt8) { + if self.insideString { + if self.escaping { + self.escaping = false + } else if byte == 0x5C { + self.escaping = true + } else if byte == 0x22 { + self.insideString = false + } + return + } + + switch byte { + case 0x20, 0x09, 0x0D: + return + case 0x22: + self.insideString = true + case 0x7B, 0x5B: + self.containerDepth += 1 + case 0x7D, 0x5D: + self.containerDepth = max(0, self.containerDepth - 1) + default: + break + } + } + + private func advanceLiteral( + _ byte: UInt8, + expected: [UInt8], + matched: Int) -> Int? + { + if matched < expected.count { + return byte == expected[matched] ? matched + 1 : nil + } + return Self.isWhitespace(byte) ? matched : nil + } + + private static func isWhitespace(_ byte: UInt8) -> Bool { + byte == 0x20 || byte == 0x09 || byte == 0x0A || byte == 0x0D + } + } + @discardableResult static func scan( fileURL: URL, @@ -20,6 +249,7 @@ enum CostUsageJsonl { offset: offset, maxLineBytes: maxLineBytes, prefixBytes: prefixBytes, + maxBytesToRead: nil, checkCancellation: nil, onLine: onLine) } @@ -30,23 +260,51 @@ enum CostUsageJsonl { offset: Int64 = 0, maxLineBytes: Int, prefixBytes: Int, + maxBytesToRead: Int64? = nil, checkCancellation: (() throws -> Void)? = nil, onLine: (Line) -> Void) throws -> Int64 + { + try self.scanBounded( + fileURL: fileURL, + offset: offset, + maxLineBytes: maxLineBytes, + prefixBytes: prefixBytes, + maxBytesToRead: maxBytesToRead, + resumeState: nil, + checkCancellation: checkCancellation, + onLine: onLine).committedOffset + } + + // swiftlint:disable:next function_parameter_count + static func scanBounded( + fileURL: URL, + offset: Int64 = 0, + maxLineBytes: Int, + prefixBytes: Int, + maxBytesToRead: Int64?, + resumeState: ResumeState?, + checkCancellation: (() throws -> Void)? = nil, + onLine: (Line) -> Void) throws -> ScanProgress { let handle = try FileHandle(forReadingFrom: fileURL) defer { try? handle.close() } - let startOffset = max(0, offset) + let startOffset = resumeState?.offset ?? max(0, offset) if startOffset > 0 { try handle.seek(toOffset: UInt64(startOffset)) } - var current = Data() + var current = resumeState?.prefix ?? Data() current.reserveCapacity(4 * 1024) - var lineBytes = 0 - var truncated = false + var lineBytes = resumeState?.lineBytes ?? 0 + var truncated = resumeState?.truncated ?? false var bytesRead: Int64 = 0 + var lineStartOffset = resumeState?.lineStartOffset ?? startOffset + var committedOffset = lineStartOffset + var jsonTailState = resumeState?.jsonTailState ?? JSONTailState() + let fileSize = (try? FileManager.default.attributesOfItem(atPath: fileURL.path)[.size] as? NSNumber)? + .int64Value func appendSegment(_ bytes: UnsafePointer, count: Int) { guard count > 0 else { return } @@ -69,37 +327,88 @@ enum CostUsageJsonl { current.removeAll(keepingCapacity: true) lineBytes = 0 truncated = false + jsonTailState.reset() + } + + func currentResumeState() -> ResumeState? { + guard lineBytes > 0 else { return nil } + return ResumeState( + offset: startOffset + bytesRead, + lineStartOffset: lineStartOffset, + prefix: current, + lineBytes: lineBytes, + truncated: truncated, + jsonTailState: jsonTailState) + } + + func hasCompleteJSONTail() -> Bool { + guard jsonTailState.isStructurallyComplete else { return false } + if truncated { + // The full record is intentionally not retained. Its incremental state is enough + // to keep incomplete containers, strings, literals, and numbers retriable. + return true + } + guard lineBytes == current.count else { return false } + return (try? JSONSerialization.jsonObject(with: current, options: [.fragmentsAllowed])) != nil } while true { try checkCancellation?() - let chunk = try handle.read(upToCount: 256 * 1024) ?? Data() - if chunk.isEmpty { - flushLine() + let remaining = maxBytesToRead.map { max(0, $0 - bytesRead) } + if remaining == 0 { + if let fileSize, startOffset + bytesRead >= fileSize, hasCompleteJSONTail() { + flushLine() + committedOffset = startOffset + bytesRead + lineStartOffset = committedOffset + } break } - - 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) + let reachedEOF = try autoreleasepool { + let readCount = min(256 * 1024, Int(remaining ?? Int64(256 * 1024))) + let chunk = try handle.read(upToCount: readCount) ?? Data() + if chunk.isEmpty { + if hasCompleteJSONTail() { flushLine() - segmentStart = index + 1 + committedOffset = startOffset + bytesRead + lineStartOffset = committedOffset } - index += 1 + return true } - if segmentStart < rawBuffer.count { - appendSegment(base.advanced(by: segmentStart), count: rawBuffer.count - segmentStart) + + try checkCancellation?() + bytesRead += Int64(chunk.count) + let chunkStartOffset = startOffset + 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() + committedOffset = chunkStartOffset + Int64(index + 1) + lineStartOffset = committedOffset + segmentStart = index + 1 + } else { + jsonTailState.append(base[index]) + } + index += 1 + } + if segmentStart < rawBuffer.count { + appendSegment(base.advanced(by: segmentStart), count: rawBuffer.count - segmentStart) + } } + return false + } + if reachedEOF { + break } try checkCancellation?() } - return startOffset + bytesRead + return ScanProgress( + committedOffset: committedOffset, + readOffset: startOffset + bytesRead, + resumeState: currentResumeState()) } } diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing.swift index 057098b068..3727bf2ce2 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing.swift @@ -2,45 +2,57 @@ import Foundation enum CostUsagePricing { private static let codexPriorityInputTokenLimit = 272_000 + static let codexUnattributedModel = "unknown" struct CodexPricing { let inputCostPerToken: Double let outputCostPerToken: Double let cacheReadInputCostPerToken: Double? + /// Optional cache-write (cache creation) rate. When nil, write tokens are billed at the + /// uncached input rate (legacy Codex folding behavior). + let cacheWriteInputCostPerToken: Double? let displayLabel: String? let thresholdTokens: Int? let inputCostPerTokenAboveThreshold: Double? let outputCostPerTokenAboveThreshold: Double? let cacheReadInputCostPerTokenAboveThreshold: Double? + let cacheWriteInputCostPerTokenAboveThreshold: Double? let priorityInputCostPerToken: Double? let priorityOutputCostPerToken: Double? let priorityCacheReadInputCostPerToken: Double? + let priorityCacheWriteInputCostPerToken: Double? init( inputCostPerToken: Double, outputCostPerToken: Double, cacheReadInputCostPerToken: Double?, displayLabel: String?, + cacheWriteInputCostPerToken: Double? = nil, thresholdTokens: Int? = nil, inputCostPerTokenAboveThreshold: Double? = nil, outputCostPerTokenAboveThreshold: Double? = nil, cacheReadInputCostPerTokenAboveThreshold: Double? = nil, + cacheWriteInputCostPerTokenAboveThreshold: Double? = nil, priorityInputCostPerToken: Double? = nil, priorityOutputCostPerToken: Double? = nil, - priorityCacheReadInputCostPerToken: Double? = nil) + priorityCacheReadInputCostPerToken: Double? = nil, + priorityCacheWriteInputCostPerToken: Double? = nil) { self.inputCostPerToken = inputCostPerToken self.outputCostPerToken = outputCostPerToken self.cacheReadInputCostPerToken = cacheReadInputCostPerToken + self.cacheWriteInputCostPerToken = cacheWriteInputCostPerToken self.displayLabel = displayLabel self.thresholdTokens = thresholdTokens self.inputCostPerTokenAboveThreshold = inputCostPerTokenAboveThreshold self.outputCostPerTokenAboveThreshold = outputCostPerTokenAboveThreshold self.cacheReadInputCostPerTokenAboveThreshold = cacheReadInputCostPerTokenAboveThreshold + self.cacheWriteInputCostPerTokenAboveThreshold = cacheWriteInputCostPerTokenAboveThreshold self.priorityInputCostPerToken = priorityInputCostPerToken self.priorityOutputCostPerToken = priorityOutputCostPerToken self.priorityCacheReadInputCostPerToken = priorityCacheReadInputCostPerToken + self.priorityCacheWriteInputCostPerToken = priorityCacheWriteInputCostPerToken } } @@ -57,6 +69,14 @@ enum CostUsagePricing { let cacheReadInputCostPerTokenAboveThreshold: Double? } + private struct ClaudeCostTokens { + let input: Int + let cacheRead: Int + let cacheCreation: Int + let cacheCreation1h: Int + let output: Int + } + private static let codex: [String: CodexPricing] = [ "gpt-5": CodexPricing( inputCostPerToken: 1.25e-6, @@ -175,6 +195,55 @@ enum CostUsagePricing { outputCostPerToken: 1.8e-4, cacheReadInputCostPerToken: nil, displayLabel: nil), + // GPT-5.6 Sol/Terra/Luna (OpenAI pricing page + model cards). + // Long context: prompts with >272K input tokens are 2x input / 1.5x output for the full + // request. Cache writes: 1.25x uncached input. Priority rates are explicit because support + // and multipliers are provider contracts, not properties that can be inferred from Standard. + "gpt-5.6-sol": CodexPricing( + inputCostPerToken: 5e-6, + outputCostPerToken: 3e-5, + cacheReadInputCostPerToken: 5e-7, + displayLabel: nil, + cacheWriteInputCostPerToken: 6.25e-6, + thresholdTokens: 272_000, + inputCostPerTokenAboveThreshold: 1e-5, + outputCostPerTokenAboveThreshold: 4.5e-5, + cacheReadInputCostPerTokenAboveThreshold: 1e-6, + cacheWriteInputCostPerTokenAboveThreshold: 1.25e-5, + priorityInputCostPerToken: 1e-5, + priorityOutputCostPerToken: 6e-5, + priorityCacheReadInputCostPerToken: 1e-6, + priorityCacheWriteInputCostPerToken: 1.25e-5), + "gpt-5.6-terra": CodexPricing( + inputCostPerToken: 2.5e-6, + outputCostPerToken: 1.5e-5, + cacheReadInputCostPerToken: 2.5e-7, + displayLabel: nil, + cacheWriteInputCostPerToken: 3.125e-6, + thresholdTokens: 272_000, + inputCostPerTokenAboveThreshold: 5e-6, + outputCostPerTokenAboveThreshold: 2.25e-5, + cacheReadInputCostPerTokenAboveThreshold: 5e-7, + cacheWriteInputCostPerTokenAboveThreshold: 6.25e-6, + priorityInputCostPerToken: 5e-6, + priorityOutputCostPerToken: 3e-5, + priorityCacheReadInputCostPerToken: 5e-7, + priorityCacheWriteInputCostPerToken: 6.25e-6), + "gpt-5.6-luna": CodexPricing( + inputCostPerToken: 1e-6, + outputCostPerToken: 6e-6, + cacheReadInputCostPerToken: 1e-7, + displayLabel: nil, + cacheWriteInputCostPerToken: 1.25e-6, + thresholdTokens: 272_000, + inputCostPerTokenAboveThreshold: 2e-6, + outputCostPerTokenAboveThreshold: 9e-6, + cacheReadInputCostPerTokenAboveThreshold: 2e-7, + cacheWriteInputCostPerTokenAboveThreshold: 2.5e-6, + priorityInputCostPerToken: 2e-6, + priorityOutputCostPerToken: 1.2e-5, + priorityCacheReadInputCostPerToken: 2e-7, + priorityCacheWriteInputCostPerToken: 2.5e-6), ] static func codexBuiltInPricingFingerprint() -> String { @@ -186,14 +255,17 @@ enum CostUsagePricing { self.optionalPricingFingerprint(pricing.inputCostPerToken), self.optionalPricingFingerprint(pricing.outputCostPerToken), self.optionalPricingFingerprint(pricing.cacheReadInputCostPerToken), + self.optionalPricingFingerprint(pricing.cacheWriteInputCostPerToken), pricing.displayLabel ?? "nil", pricing.thresholdTokens.map(String.init) ?? "nil", self.optionalPricingFingerprint(pricing.inputCostPerTokenAboveThreshold), self.optionalPricingFingerprint(pricing.outputCostPerTokenAboveThreshold), self.optionalPricingFingerprint(pricing.cacheReadInputCostPerTokenAboveThreshold), + self.optionalPricingFingerprint(pricing.cacheWriteInputCostPerTokenAboveThreshold), self.optionalPricingFingerprint(pricing.priorityInputCostPerToken), self.optionalPricingFingerprint(pricing.priorityOutputCostPerToken), self.optionalPricingFingerprint(pricing.priorityCacheReadInputCostPerToken), + self.optionalPricingFingerprint(pricing.priorityCacheWriteInputCostPerToken), ].joined(separator: "|")) } return parts.joined(separator: "\n") @@ -205,6 +277,16 @@ enum CostUsagePricing { } private static let claude: [String: ClaudePricing] = [ + "claude-fable-5": ClaudePricing( + inputCostPerToken: 1e-5, + outputCostPerToken: 5e-5, + cacheCreationInputCostPerToken: 1.25e-5, + cacheReadInputCostPerToken: 1e-6, + thresholdTokens: nil, + inputCostPerTokenAboveThreshold: nil, + outputCostPerTokenAboveThreshold: nil, + cacheCreationInputCostPerTokenAboveThreshold: nil, + cacheReadInputCostPerTokenAboveThreshold: nil), "claude-haiku-4-5-20251001": ClaudePricing( inputCostPerToken: 1e-6, outputCostPerToken: 5e-6, @@ -275,6 +357,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, @@ -290,11 +382,11 @@ enum CostUsagePricing { outputCostPerToken: 1.5e-5, cacheCreationInputCostPerToken: 3.75e-6, cacheReadInputCostPerToken: 3e-7, - thresholdTokens: 200_000, - inputCostPerTokenAboveThreshold: 6e-6, - outputCostPerTokenAboveThreshold: 2.25e-5, - cacheCreationInputCostPerTokenAboveThreshold: 7.5e-6, - cacheReadInputCostPerTokenAboveThreshold: 6e-7), + thresholdTokens: nil, + inputCostPerTokenAboveThreshold: nil, + outputCostPerTokenAboveThreshold: nil, + cacheCreationInputCostPerTokenAboveThreshold: nil, + cacheReadInputCostPerTokenAboveThreshold: nil), "claude-sonnet-4-5-20250929": ClaudePricing( inputCostPerToken: 3e-6, outputCostPerToken: 1.5e-5, @@ -337,6 +429,30 @@ enum CostUsagePricing { cacheReadInputCostPerTokenAboveThreshold: 6e-7), ] + private static let claudeFullContextStandardPricingCutoff = Date(timeIntervalSince1970: 1_773_360_000) + private static let claudeHistoricalLongContext: [String: ClaudePricing] = [ + "claude-opus-4-6": ClaudePricing( + inputCostPerToken: 5e-6, + outputCostPerToken: 2.5e-5, + cacheCreationInputCostPerToken: 6.25e-6, + cacheReadInputCostPerToken: 5e-7, + thresholdTokens: 200_000, + inputCostPerTokenAboveThreshold: 1e-5, + outputCostPerTokenAboveThreshold: 3.75e-5, + cacheCreationInputCostPerTokenAboveThreshold: 1.25e-5, + cacheReadInputCostPerTokenAboveThreshold: 1e-6), + "claude-sonnet-4-6": ClaudePricing( + inputCostPerToken: 3e-6, + outputCostPerToken: 1.5e-5, + cacheCreationInputCostPerToken: 3.75e-6, + cacheReadInputCostPerToken: 3e-7, + thresholdTokens: 200_000, + inputCostPerTokenAboveThreshold: 6e-6, + outputCostPerTokenAboveThreshold: 2.25e-5, + cacheCreationInputCostPerTokenAboveThreshold: 7.5e-6, + cacheReadInputCostPerTokenAboveThreshold: 6e-7), + ] + private static let codexModelsDevProviderID = "openai" private static let claudeModelsDevProviderID = "anthropic" @@ -346,6 +462,11 @@ enum CostUsagePricing { trimmed = String(trimmed.dropFirst("openai/".count)) } + // OpenAI routes the unsuffixed gpt-5.6 alias to Sol. + if trimmed == "gpt-5.6" { + return "gpt-5.6-sol" + } + if self.codex[trimmed] != nil { return trimmed } @@ -359,6 +480,10 @@ enum CostUsagePricing { return trimmed } + static func isCodexUnattributedModel(_ raw: String) -> Bool { + self.normalizeCodexModel(raw) == self.codexUnattributedModel + } + static func codexDisplayLabel(model: String) -> String? { let key = self.normalizeCodexModel(model) return self.codex[key]?.displayLabel @@ -398,21 +523,56 @@ enum CostUsagePricing { inputTokens: Int, cachedInputTokens: Int, outputTokens: Int, + cacheWriteInputTokens: Int = 0, modelsDevCatalog: ModelsDevCatalog? = nil, modelsDevCacheRoot: URL? = nil) -> Double? { let key = self.normalizeCodexModel(model) - if let lookup = self.modelsDevLookup( + guard key != self.codexUnattributedModel else { return nil } + let modelsDevLookup = self.modelsDevLookup( providerID: self.codexModelsDevProviderID, model: model, catalog: modelsDevCatalog, cacheRoot: modelsDevCacheRoot) - { + ?? (model == key ? nil : self.modelsDevLookup( + providerID: self.codexModelsDevProviderID, + model: key, + catalog: modelsDevCatalog, + cacheRoot: modelsDevCacheRoot)) + if let lookup = modelsDevLookup { + let bundled = self.codex[key] + // A missing catalog context block means models.dev has no long-context opinion, so use + // the bundled tuple. Once the block exists, preserve its omissions and normal fallback + // semantics instead of filling individual fields from a different pricing source. + let bundledLongContext = lookup.pricing.thresholdTokens == nil ? bundled : nil + let cacheReadAboveThreshold = lookup.pricing.cacheReadInputCostPerTokenAboveThreshold + ?? (lookup.pricing.thresholdTokens != nil + ? lookup.pricing.cacheReadInputCostPerToken + ?? lookup.pricing.inputCostPerTokenAboveThreshold + ?? lookup.pricing.inputCostPerToken + : bundledLongContext?.cacheReadInputCostPerTokenAboveThreshold) + let cacheWriteAboveThreshold = lookup.pricing.cacheCreationInputCostPerTokenAboveThreshold + ?? (lookup.pricing.thresholdTokens != nil + ? lookup.pricing.cacheCreationInputCostPerToken + ?? lookup.pricing.inputCostPerTokenAboveThreshold + ?? lookup.pricing.inputCostPerToken + : bundledLongContext?.cacheWriteInputCostPerTokenAboveThreshold) return self.codexCostUSD( pricing: lookup.pricing, - thresholdTokens: self.codex[key]?.thresholdTokens, + thresholdTokens: bundled?.thresholdTokens ?? lookup.pricing.thresholdTokens, + inputCostPerTokenAboveThreshold: lookup.pricing.inputCostPerTokenAboveThreshold + ?? bundledLongContext?.inputCostPerTokenAboveThreshold, + outputCostPerTokenAboveThreshold: lookup.pricing.outputCostPerTokenAboveThreshold + ?? bundledLongContext?.outputCostPerTokenAboveThreshold, + cacheReadInputCostPerToken: lookup.pricing.cacheReadInputCostPerToken + ?? bundled?.cacheReadInputCostPerToken, + cacheReadInputCostPerTokenAboveThreshold: cacheReadAboveThreshold, + cacheWriteInputCostPerToken: lookup.pricing.cacheCreationInputCostPerToken + ?? bundled?.cacheWriteInputCostPerToken, + cacheWriteInputCostPerTokenAboveThreshold: cacheWriteAboveThreshold, inputTokens: inputTokens, cachedInputTokens: cachedInputTokens, + cacheWriteInputTokens: cacheWriteInputTokens, outputTokens: outputTokens) } @@ -421,6 +581,7 @@ enum CostUsagePricing { pricing: pricing, inputTokens: inputTokens, cachedInputTokens: cachedInputTokens, + cacheWriteInputTokens: cacheWriteInputTokens, outputTokens: outputTokens) } @@ -428,6 +589,7 @@ enum CostUsagePricing { model: String, inputTokens: Int, cachedInputTokens: Int = 0, + cacheWriteInputTokens: Int = 0, outputTokens: Int) -> Double? { let key = self.normalizeCodexModel(model) @@ -435,6 +597,8 @@ enum CostUsagePricing { let priorityInputCostPerToken = pricing.priorityInputCostPerToken, let priorityOutputCostPerToken = pricing.priorityOutputCostPerToken else { return nil } + // OpenAI does not support Priority processing for long-context requests. Do not combine + // the independent Standard long-context and Priority short-context rate tables. if max(0, inputTokens) > self.codexPriorityInputTokenLimit { return nil } @@ -443,11 +607,13 @@ enum CostUsagePricing { inputCostPerToken: priorityInputCostPerToken, outputCostPerToken: priorityOutputCostPerToken, cacheReadInputCostPerToken: pricing.priorityCacheReadInputCostPerToken, - displayLabel: nil) + displayLabel: nil, + cacheWriteInputCostPerToken: pricing.priorityCacheWriteInputCostPerToken) return self.codexCostUSD( pricing: priorityPricing, inputTokens: inputTokens, cachedInputTokens: cachedInputTokens, + cacheWriteInputTokens: cacheWriteInputTokens, outputTokens: outputTokens) } @@ -455,47 +621,76 @@ enum CostUsagePricing { pricing: CodexPricing, inputTokens: Int, cachedInputTokens: Int, + cacheWriteInputTokens: Int = 0, outputTokens: Int) -> Double { - let cached = min(max(0, cachedInputTokens), max(0, inputTokens)) - let nonCached = max(0, inputTokens - cached) + // Codex/OpenAI reports `input_tokens` as the total prompt size, with cached reads as a + // SUBSET of it. Cache writes (when tracked separately, e.g. Pi) are also a subset of the + // non-cached remainder. Clamp so tokens are never invented or double-billed. + let totalInput = max(0, inputTokens) + let cached = min(max(0, cachedInputTokens), totalInput) + let remainingAfterCache = totalInput - cached + let cacheWrite = min(max(0, cacheWriteInputTokens), remainingAfterCache) + let nonCached = remainingAfterCache - cacheWrite let cachedRate = pricing.cacheReadInputCostPerToken ?? pricing.inputCostPerToken - let usesLongContextRates = pricing.thresholdTokens.map { max(0, inputTokens) > $0 } ?? false + let usesLongContextRates = pricing.thresholdTokens.map { totalInput > $0 } ?? false let inputRate = usesLongContextRates ? pricing.inputCostPerTokenAboveThreshold ?? pricing.inputCostPerToken : pricing.inputCostPerToken let cachedInputRate = usesLongContextRates - ? pricing.cacheReadInputCostPerTokenAboveThreshold ?? cachedRate + ? pricing.cacheReadInputCostPerTokenAboveThreshold ?? pricing.cacheReadInputCostPerToken ?? inputRate : cachedRate + let cacheWriteRate = usesLongContextRates + ? pricing.cacheWriteInputCostPerTokenAboveThreshold + ?? pricing.cacheWriteInputCostPerToken + ?? inputRate + : pricing.cacheWriteInputCostPerToken ?? inputRate let outputRate = usesLongContextRates ? pricing.outputCostPerTokenAboveThreshold ?? pricing.outputCostPerToken : pricing.outputCostPerToken return (Double(nonCached) * inputRate) + (Double(cached) * cachedInputRate) + + (Double(cacheWrite) * cacheWriteRate) + (Double(max(0, outputTokens)) * outputRate) } private static func codexCostUSD( pricing: ModelsDevPricingInfo, thresholdTokens: Int? = nil, + inputCostPerTokenAboveThreshold: Double? = nil, + outputCostPerTokenAboveThreshold: Double? = nil, + cacheReadInputCostPerToken: Double? = nil, + cacheReadInputCostPerTokenAboveThreshold: Double? = nil, + cacheWriteInputCostPerToken: Double? = nil, + cacheWriteInputCostPerTokenAboveThreshold: Double? = nil, inputTokens: Int, cachedInputTokens: Int, + cacheWriteInputTokens: Int = 0, outputTokens: Int) -> Double { self.codexCostUSD( pricing: CodexPricing( inputCostPerToken: pricing.inputCostPerToken, outputCostPerToken: pricing.outputCostPerToken, - cacheReadInputCostPerToken: pricing.cacheReadInputCostPerToken, + cacheReadInputCostPerToken: cacheReadInputCostPerToken + ?? pricing.cacheReadInputCostPerToken, displayLabel: nil, + cacheWriteInputCostPerToken: cacheWriteInputCostPerToken + ?? pricing.cacheCreationInputCostPerToken, thresholdTokens: thresholdTokens ?? pricing.thresholdTokens, - inputCostPerTokenAboveThreshold: pricing.inputCostPerTokenAboveThreshold, - outputCostPerTokenAboveThreshold: pricing.outputCostPerTokenAboveThreshold, - cacheReadInputCostPerTokenAboveThreshold: pricing.cacheReadInputCostPerTokenAboveThreshold), + inputCostPerTokenAboveThreshold: inputCostPerTokenAboveThreshold + ?? pricing.inputCostPerTokenAboveThreshold, + outputCostPerTokenAboveThreshold: outputCostPerTokenAboveThreshold + ?? pricing.outputCostPerTokenAboveThreshold, + cacheReadInputCostPerTokenAboveThreshold: cacheReadInputCostPerTokenAboveThreshold + ?? pricing.cacheReadInputCostPerTokenAboveThreshold, + cacheWriteInputCostPerTokenAboveThreshold: cacheWriteInputCostPerTokenAboveThreshold + ?? pricing.cacheCreationInputCostPerTokenAboveThreshold), inputTokens: inputTokens, cachedInputTokens: cachedInputTokens, + cacheWriteInputTokens: cacheWriteInputTokens, outputTokens: outputTokens) } @@ -504,10 +699,29 @@ enum CostUsagePricing { inputTokens: Int, cacheReadInputTokens: Int, cacheCreationInputTokens: Int, + cacheCreationInputTokens1h: Int = 0, outputTokens: Int, + pricingDate: Date? = nil, modelsDevCatalog: ModelsDevCatalog? = nil, modelsDevCacheRoot: URL? = nil) -> Double? { + let tokens = ClaudeCostTokens( + input: inputTokens, + cacheRead: cacheReadInputTokens, + cacheCreation: cacheCreationInputTokens, + cacheCreation1h: cacheCreationInputTokens1h, + output: outputTokens) + let key = self.normalizeClaudeModel(model) + if let pricingDate, + let historicalPricing = self.claudeHistoricalLongContext[key], + let currentPricing = self.claude[key] + { + return self.claudeCostUSD( + pricing: pricingDate < self.claudeFullContextStandardPricingCutoff + ? historicalPricing + : currentPricing, + tokens: tokens) + } if let lookup = self.modelsDevLookup( providerID: self.claudeModelsDevProviderID, model: model, @@ -516,64 +730,50 @@ enum CostUsagePricing { { return self.claudeCostUSD( pricing: lookup.pricing, - inputTokens: inputTokens, - cacheReadInputTokens: cacheReadInputTokens, - cacheCreationInputTokens: cacheCreationInputTokens, - outputTokens: outputTokens) + tokens: tokens) } - let key = self.normalizeClaudeModel(model) guard let pricing = self.claude[key] else { return nil } return self.claudeCostUSD( pricing: pricing, - inputTokens: inputTokens, - cacheReadInputTokens: cacheReadInputTokens, - cacheCreationInputTokens: cacheCreationInputTokens, - outputTokens: outputTokens) + tokens: tokens) } private static func claudeCostUSD( pricing: ClaudePricing, - inputTokens: Int, - cacheReadInputTokens: Int, - cacheCreationInputTokens: Int, - outputTokens: Int) -> Double + tokens: ClaudeCostTokens) -> Double { - func tiered(_ tokens: Int, base: Double, above: Double?, threshold: Int?) -> Double { - guard let threshold, let above else { return Double(tokens) * base } - let below = min(tokens, threshold) - let over = max(tokens - threshold, 0) - return Double(below) * base + Double(over) * above - } + let input = max(0, tokens.input) + let cacheRead = max(0, tokens.cacheRead) + let cacheCreationTotal = max(0, tokens.cacheCreation) + let cacheCreation1h = min(max(0, tokens.cacheCreation1h), cacheCreationTotal) + let cacheCreation5m = cacheCreationTotal - cacheCreation1h + let usesLongContextRates = pricing.thresholdTokens.map { + input + cacheRead + cacheCreationTotal > $0 + } ?? false + let inputRate = usesLongContextRates + ? pricing.inputCostPerTokenAboveThreshold ?? pricing.inputCostPerToken + : pricing.inputCostPerToken + let cacheReadRate = usesLongContextRates + ? pricing.cacheReadInputCostPerTokenAboveThreshold ?? pricing.cacheReadInputCostPerToken + : pricing.cacheReadInputCostPerToken + let cacheCreation5mRate = usesLongContextRates + ? pricing.cacheCreationInputCostPerTokenAboveThreshold ?? pricing.cacheCreationInputCostPerToken + : pricing.cacheCreationInputCostPerToken + let outputRate = usesLongContextRates + ? pricing.outputCostPerTokenAboveThreshold ?? pricing.outputCostPerToken + : pricing.outputCostPerToken - return tiered( - max(0, inputTokens), - base: pricing.inputCostPerToken, - above: pricing.inputCostPerTokenAboveThreshold, - threshold: pricing.thresholdTokens) - + tiered( - max(0, cacheReadInputTokens), - base: pricing.cacheReadInputCostPerToken, - above: pricing.cacheReadInputCostPerTokenAboveThreshold, - threshold: pricing.thresholdTokens) - + tiered( - max(0, cacheCreationInputTokens), - base: pricing.cacheCreationInputCostPerToken, - above: pricing.cacheCreationInputCostPerTokenAboveThreshold, - threshold: pricing.thresholdTokens) - + tiered( - max(0, outputTokens), - base: pricing.outputCostPerToken, - above: pricing.outputCostPerTokenAboveThreshold, - threshold: pricing.thresholdTokens) + return Double(input) * inputRate + + Double(cacheRead) * cacheReadRate + + Double(cacheCreation5m) * cacheCreation5mRate + + Double(cacheCreation1h) * inputRate * 2 + + Double(max(0, tokens.output)) * outputRate } private static func claudeCostUSD( pricing: ModelsDevPricingInfo, - inputTokens: Int, - cacheReadInputTokens: Int, - cacheCreationInputTokens: Int, - outputTokens: Int) -> Double + tokens: ClaudeCostTokens) -> Double { self.claudeCostUSD( pricing: ClaudePricing( @@ -586,10 +786,7 @@ enum CostUsagePricing { outputCostPerTokenAboveThreshold: pricing.outputCostPerTokenAboveThreshold, cacheCreationInputCostPerTokenAboveThreshold: pricing.cacheCreationInputCostPerTokenAboveThreshold, cacheReadInputCostPerTokenAboveThreshold: pricing.cacheReadInputCostPerTokenAboveThreshold), - inputTokens: inputTokens, - cacheReadInputTokens: cacheReadInputTokens, - cacheCreationInputTokens: cacheCreationInputTokens, - outputTokens: outputTokens) + tokens: tokens) } static func modelsDevCatalog(now: Date = Date(), cacheRoot: URL? = nil) -> ModelsDevCatalog? { diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricingKey.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricingKey.swift new file mode 100644 index 0000000000..1e3e8d565e --- /dev/null +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricingKey.swift @@ -0,0 +1,76 @@ +#if canImport(CryptoKit) +import CryptoKit +#else +import Crypto +#endif +import Foundation + +enum CostUsagePricingKey { + static func codex( + modelsDevArtifact: ModelsDevCacheArtifact?, + formulaVersion: Int, + parserHash: String? = nil, + modelsDevProviderIDs: Set = ["openai"]) -> String + { + var parts = [ + "costFormulaVersion=\(formulaVersion)", + "builtInPricing:\n\(CostUsagePricing.codexBuiltInPricingFingerprint())", + ] + if let parserHash { + parts.append("parserHash=\(parserHash)") + } + + let prefix: String + if let modelsDevArtifact { + prefix = "models-dev-v\(modelsDevArtifact.version)" + let modelsDevPricing = self.modelsDevPricingFingerprint( + modelsDevArtifact.catalog, + providerIDs: modelsDevProviderIDs) + parts.append("modelsDevPricing:\n\(modelsDevPricing)") + } else { + prefix = "builtin" + parts.append("modelsDevPricing:none") + } + return "\(prefix)-\(self.sha256Hex(Data(parts.joined(separator: "\n").utf8)))" + } + + private static func modelsDevPricingFingerprint( + _ catalog: ModelsDevCatalog, + providerIDs: Set) -> String + { + var parts: [String] = [] + let normalizedProviderIDs = Set(providerIDs.map(ModelsDevProvider.normalizeProviderID)) + for providerID in normalizedProviderIDs.sorted() { + guard let provider = catalog.providers[providerID] else { continue } + for modelKey in provider.models.keys.sorted() { + guard let model = provider.models[modelKey], model.isPriceable else { continue } + let cost = model.cost + let contextOver200K = cost?.contextOver200K + parts.append([ + "provider=\(providerID)", + "model=\(modelKey)", + model.id, + self.optionalDoubleFingerprint(cost?.input), + self.optionalDoubleFingerprint(cost?.output), + self.optionalDoubleFingerprint(cost?.cacheRead), + self.optionalDoubleFingerprint(cost?.cacheWrite), + contextOver200K == nil ? "contextOver200K=absent" : "contextOver200K=present", + self.optionalDoubleFingerprint(contextOver200K?.input), + self.optionalDoubleFingerprint(contextOver200K?.output), + self.optionalDoubleFingerprint(contextOver200K?.cacheRead), + self.optionalDoubleFingerprint(contextOver200K?.cacheWrite), + ].joined(separator: "|")) + } + } + return parts.joined(separator: "\n") + } + + private static func optionalDoubleFingerprint(_ value: Double?) -> String { + guard let value else { return "nil" } + return String(format: "%.17g", value) + } + + private static func sha256Hex(_ data: Data) -> String { + SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined() + } +} diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+CacheHelpers.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+CacheHelpers.swift index 64d351eb91..d6c5882db9 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+CacheHelpers.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+CacheHelpers.swift @@ -1,19 +1,32 @@ +// swiftlint:disable file_length + import Foundation +#if canImport(Musl) +import Musl +#elseif canImport(Glibc) +import Glibc +#else +import Darwin +#endif extension CostUsageScanner { - static func codexRowsByDayModel( - cache: CostUsageCache, - range: CostUsageDayRange) -> [String: [String: [CodexUsageRow]]] - { - var rowsByDayModel: [String: [String: [CodexUsageRow]]] = [:] - for usage in cache.files.values { - for row in usage.codexRows ?? [] { - guard CostUsageDayRange.isInRange(dayKey: row.day, since: range.sinceKey, until: range.untilKey) - else { continue } - rowsByDayModel[row.day, default: [:]][row.model, default: []].append(row) + private final class CodexModelsDevCatalogResolver { + private var catalog: ModelsDevCatalog? + private let cacheRoot: URL? + + init(catalog: ModelsDevCatalog?, cacheRoot: URL?) { + self.catalog = catalog + self.cacheRoot = cacheRoot + } + + func load(_ loader: (URL?) -> ModelsDevCatalog?) -> ModelsDevCatalog { + if let catalog { + return catalog } + let loaded = loader(self.cacheRoot) ?? ModelsDevCatalog(providers: [:]) + self.catalog = loaded + return loaded } - return rowsByDayModel } static func codexRowsByDayModel( @@ -71,6 +84,12 @@ extension CostUsageScanner { self.codexIntByDayModel(cache: cache, range: range) { $0.codexPriorityTokens } } + static func codexReportDayKeys(cache: CostUsageCache, range: CostUsageDayRange) -> [String] { + cache.days.keys.sorted().filter { + CostUsageDayRange.isInRange(dayKey: $0, since: range.sinceKey, until: range.untilKey) + } + } + static func codexNanosByDayModel( cache: CostUsageCache, range: CostUsageDayRange, @@ -263,10 +282,18 @@ extension CostUsageScanner { lastTotals: CostUsageCodexTotals? = nil, lastCountedTotals: CostUsageCodexTotals? = nil, lastRawTotalsBaseline: CostUsageCodexTotals? = nil, + lastRawTotalsWatermark: CostUsageCodexTotals? = nil, + seenRawTotals: [CostUsageCodexTotals]? = nil, hasDivergentTotals: Bool? = nil, + hasInterleavedTotals: Bool? = nil, lastCodexTurnID: String? = nil, sessionId: String? = nil, forkedFromId: String? = nil, + forkBaselineDependencyKey: String? = nil, + projectPath: String? = nil, + canonicalProjectPath: String? = nil, + codexCostCacheComplete: Bool? = true, + codexSession: CostUsageCodexSessionMetadata? = nil, codexCostNanos: [String: [String: Int64]]? = nil, codexPrioritySurchargeNanos: [String: [String: Int64]]? = nil, codexStandardCostNanos: [String: [String: Int64]]? = nil, @@ -275,7 +302,12 @@ extension CostUsageScanner { codexPriorityTokens: [String: [String: Int]]? = nil, codexTurnIDs: [String]? = nil, codexRows: [CodexUsageRow]? = nil, - claudeRows: [ClaudeUsageRow]? = nil) -> CostUsageFileUsage + claudeRows: [ClaudeUsageRow]? = nil, + codexScanFileId: String? = nil, + codexScanTargetSize: Int64? = nil, + codexScanComplete: Bool? = nil, + codexJSONLResumeState: CostUsageJsonl.ResumeState? = nil, + codexBufferedSubagentLines: [CodexBufferedFastLine]? = nil) -> CostUsageFileUsage { CostUsageFileUsage( mtimeUnixMs: mtimeUnixMs, @@ -286,10 +318,18 @@ extension CostUsageScanner { lastTotals: lastTotals, lastCountedTotals: lastCountedTotals, lastRawTotalsBaseline: lastRawTotalsBaseline, + lastRawTotalsWatermark: lastRawTotalsWatermark, + seenRawTotals: seenRawTotals, hasDivergentTotals: hasDivergentTotals, + hasInterleavedTotals: hasInterleavedTotals, lastCodexTurnID: lastCodexTurnID, sessionId: sessionId, forkedFromId: forkedFromId, + forkBaselineDependencyKey: forkBaselineDependencyKey, + projectPath: projectPath, + canonicalProjectPath: canonicalProjectPath, + codexCostCacheComplete: codexCostCacheComplete, + codexSession: codexSession, codexCostNanos: codexCostNanos, codexPrioritySurchargeNanos: codexPrioritySurchargeNanos, codexStandardCostNanos: codexStandardCostNanos, @@ -298,70 +338,92 @@ extension CostUsageScanner { codexPriorityTokens: codexPriorityTokens, codexTurnIDs: codexTurnIDs, codexRows: codexRows, - claudeRows: claudeRows) + claudeRows: claudeRows, + codexScanFileId: codexScanFileId, + codexScanTargetSize: codexScanTargetSize, + codexScanComplete: codexScanComplete, + codexJSONLResumeState: codexJSONLResumeState, + codexBufferedSubagentLines: codexBufferedSubagentLines) } static func needsCodexCostCache(_ usage: CostUsageFileUsage) -> Bool { !(usage.codexRows?.isEmpty ?? true) - && (usage.codexCostNanos == nil || self.needsCodexModeSplitCache(usage)) + && (usage.codexCostCacheComplete != true || self.needsCodexModeSplitCache(usage)) } static func needsCodexCostCache(_ usage: CostUsageFileUsage, range: CostUsageDayRange) -> Bool { + guard usage.codexCostCacheComplete != true || self.needsCodexModeSplitCache(usage) else { + return false + } guard let rows = usage.codexRows, !rows.isEmpty else { return false } return rows.contains { CostUsageDayRange.isInRange(dayKey: $0.day, since: range.sinceKey, until: range.untilKey) - } && (usage.codexCostNanos == nil || Self.needsCodexModeSplitCache(usage)) + } } static func needsCodexModeSplitCache(_ usage: CostUsageFileUsage) -> Bool { - usage.codexStandardCostNanos == nil - || usage.codexPriorityCostNanos == nil - || usage.codexStandardTokens == nil - || usage.codexPriorityTokens == nil + let hasStandardCost = !(usage.codexStandardCostNanos?.isEmpty ?? true) + let hasPriorityCost = !(usage.codexPriorityCostNanos?.isEmpty ?? true) + let hasStandardTokens = !(usage.codexStandardTokens?.isEmpty ?? true) + let hasPriorityTokens = !(usage.codexPriorityTokens?.isEmpty ?? true) + + // Token maps are also the completion marker for models with no known pricing. + guard hasStandardTokens || hasPriorityTokens else { return true } + return (hasStandardCost && !hasStandardTokens) || (hasPriorityCost && !hasPriorityTokens) } static func codexFileUsageWithCostCache( _ usage: CostUsageFileUsage, context: CodexFileScanContext) -> CostUsageFileUsage + { + self.codexFileUsageWithCostCache( + usage, + range: context.range, + priorityTurns: context.resources.priorityTurns, + modelsDevCatalog: context.resources.modelsDevCatalog, + modelsDevCacheRoot: context.resources.modelsDevCacheRoot) + } + + static func codexFileUsageWithCostCache( + _ usage: CostUsageFileUsage, + range: CostUsageDayRange, + priorityTurns: [String: CodexPriorityTurnMetadata], + modelsDevCatalog: ModelsDevCatalog?, + modelsDevCacheRoot: URL?) -> CostUsageFileUsage { guard let rows = usage.codexRows, !rows.isEmpty else { return usage } var migratedRows: [CodexUsageRow] = [] - var retainedRows: [CodexUsageRow] = [] - for row in rows { - if CostUsageDayRange.isInRange( - dayKey: row.day, - since: context.range.scanSinceKey, - until: context.range.scanUntilKey) - { - migratedRows.append(row) - } else { - retainedRows.append(row) - } + for row in rows where CostUsageDayRange.isInRange( + dayKey: row.day, + since: range.scanSinceKey, + until: range.scanUntilKey) + { + migratedRows.append(row) } guard !migratedRows.isEmpty else { return usage } let splitMaps = Self.codexModeSplitMaps( rows: migratedRows, - range: context.range, - priorityTurns: context.resources.priorityTurns, - modelsDevCatalog: context.resources.modelsDevCatalog, - modelsDevCacheRoot: context.resources.modelsDevCacheRoot) + range: range, + priorityTurns: priorityTurns, + modelsDevCatalog: modelsDevCatalog, + modelsDevCacheRoot: modelsDevCacheRoot) var updated = usage updated.codexCostNanos = Self.mergeMissingCostMaps( usage.codexCostNanos, Self.codexCostNanos( rows: migratedRows, - range: context.range, - modelsDevCatalog: context.resources.modelsDevCatalog, - modelsDevCacheRoot: context.resources.modelsDevCacheRoot)) + range: range, + modelsDevCatalog: modelsDevCatalog, + modelsDevCacheRoot: modelsDevCacheRoot)) updated.codexPrioritySurchargeNanos = Self.mergeMissingCostMaps( usage.codexPrioritySurchargeNanos, Self.codexPrioritySurchargeNanos( rows: migratedRows, - range: context.range, - priorityTurns: context.resources.priorityTurns, - modelsDevCatalog: context.resources.modelsDevCatalog, - modelsDevCacheRoot: context.resources.modelsDevCacheRoot)) + range: range, + priorityTurns: priorityTurns, + modelsDevCatalog: modelsDevCatalog, + modelsDevCacheRoot: modelsDevCacheRoot)) updated.codexStandardCostNanos = Self.mergeMissingCostMaps( usage.codexStandardCostNanos, splitMaps.standardCostNanos) @@ -374,9 +436,61 @@ extension CostUsageScanner { updated.codexPriorityTokens = Self.mergeMissingIntMaps( usage.codexPriorityTokens, splitMaps.priorityTokens) + updated.codexCostCacheComplete = true updated.codexTurnIDs = Self.mergeCodexTurnIDs(usage.codexTurnIDs, rows: migratedRows) - updated.codexRows = retainedRows.isEmpty ? nil : retainedRows - return updated + updated.codexRows = Self.codexRowsWithPricingAudit( + rows, + priorityTurns: priorityTurns, + modelsDevCatalog: modelsDevCatalog, + modelsDevCacheRoot: modelsDevCacheRoot) + return updated.refreshingCodexWorkspaceUsageFingerprint() + } + + static func codexRowsWithPricingAudit( + _ rows: [CodexUsageRow], + priorityTurns: [String: CodexPriorityTurnMetadata], + modelsDevCatalog: ModelsDevCatalog?, + modelsDevCacheRoot: URL?) -> [CodexUsageRow] + { + rows.map { row in + let priorityMetadata = row.turnID.flatMap { priorityTurns[$0] } + let pricedModel = priorityMetadata.map { Self.codexPriorityPricingModel(for: row, priorityMetadata: $0) } + ?? row.model + let baseCost = CostUsagePricing.codexCostUSD( + model: pricedModel, + inputTokens: row.input, + cachedInputTokens: row.cached, + outputTokens: row.output, + modelsDevCatalog: modelsDevCatalog, + modelsDevCacheRoot: modelsDevCacheRoot) + let exactCost: Double? = if priorityMetadata != nil, + let priorityCost = CostUsagePricing.codexPriorityCostUSD( + model: pricedModel, + inputTokens: row.input, + cachedInputTokens: row.cached, + outputTokens: row.output) + { + max(priorityCost, baseCost ?? priorityCost) + } else { + baseCost + } + let totalTokens = max(0, row.input) + max(0, row.output) + return CodexUsageRow( + day: row.day, + model: row.model, + rawModel: row.rawModel, + turnID: row.turnID, + eventIndex: row.eventIndex, + timestampUnixMs: row.timestampUnixMs, + input: row.input, + cached: row.cached, + output: row.output, + reasoning: row.reasoning, + knownCostNanos: exactCost.map { Int64(($0 * Self.costScale).rounded()) }, + unpricedTokens: exactCost == nil ? totalTokens : 0, + pricingModel: pricedModel, + pricingMode: priorityMetadata == nil ? "standard" : "priority") + } } static func codexMergedCostMap( @@ -528,6 +642,173 @@ extension CostUsageScanner { return ids.sorted() } + static func mergeCodexRows( + _ existing: [CodexUsageRow]?, + rows: [CodexUsageRow], + sessionId: String?) -> [CodexUsageRow]? + { + var merged = (existing ?? []).filter { self.hasStableCodexRowIdentity($0) } + let existingKeys = Set(merged.map { Self.codexUsageRowKey(sessionId: sessionId, row: $0) }) + for row in rows where !existingKeys.contains(Self.codexUsageRowKey(sessionId: sessionId, row: row)) { + merged.append(row) + } + return merged.isEmpty ? nil : merged + } + + static func hasStableCodexRowIdentity(_ row: CodexUsageRow) -> Bool { + row.eventIndex != nil + } + + static func codexRowsNeedIdentityRescan(_ rows: [CodexUsageRow]) -> Bool { + rows.contains { !Self.hasStableCodexRowIdentity($0) } + } + + static func cachedCodexRowsNeedIdentityRescan(_ usage: CostUsageFileUsage) -> Bool { + let rows = usage.codexRows ?? [] + return (!usage.days.isEmpty && rows.isEmpty) || Self.codexRowsNeedIdentityRescan(rows) + } + + static func nextCodexUsageRowIndex(_ rows: [CodexUsageRow]?) -> Int { + guard let rows, !rows.isEmpty else { return 0 } + if let maxIndex = rows.compactMap(\.eventIndex).max() { + return maxIndex + 1 + } + return rows.count + } + + static func codexUsageRowKey( + sessionId: String?, + fileIdentity: String? = nil, + row: CodexUsageRow) -> String + { + [ + sessionId.map { "session:\($0)" } ?? "file:\(fileIdentity ?? "")", + row.turnID ?? "", + row.eventIndex.map(String.init) ?? "", + row.day, + row.model, + String(row.input), + String(row.cached), + String(row.output), + ].joined(separator: "\u{1F}") + } + + static func uniqueCodexRows( + rows: [CodexUsageRow], + sessionId: String?, + fileIdentity: String, + state: inout CodexScanState) -> [CodexUsageRow] + { + var unique: [CodexUsageRow] = [] + var acceptedKeys = Set() + for row in rows { + let key = Self.codexUsageRowKey(sessionId: sessionId, fileIdentity: fileIdentity, row: row) + if !state.seenCodexUsageRowKeys.contains(key) { + unique.append(row) + acceptedKeys.insert(key) + } + } + state.seenCodexUsageRowKeys.formUnion(acceptedKeys) + return unique + } + + static func rememberCodexRows( + _ rows: [CodexUsageRow], + sessionId: String?, + fileIdentity: String, + state: inout CodexScanState) + { + for row in rows { + state.seenCodexUsageRowKeys.insert(self.codexUsageRowKey( + sessionId: sessionId, + fileIdentity: fileIdentity, + row: row)) + } + } + + static func codexFileDays(rows: [CodexUsageRow]) -> [String: [String: [Int]]] { + var days: [String: [String: [Int]]] = [:] + for row in rows { + let packed = days[row.day]?[row.model] ?? [] + days[row.day, default: [:]][row.model] = Self.addPacked( + a: packed, + b: [row.input, row.cached, row.output], + sign: 1) + } + return days + } + + static func codexFileUsageByFilteringRows( + _ usage: CostUsageFileUsage, + rows: [CodexUsageRow], + context: CodexFileScanContext) -> CostUsageFileUsage + { + var days = Self.fileDaysOutsideScanWindow(usage.days, range: context.range) + let rowsInScanWindow = rows.filter { + CostUsageDayRange.isInRange( + dayKey: $0.day, + since: context.range.scanSinceKey, + until: context.range.scanUntilKey) + } + Self.mergeFileDays(existing: &days, delta: Self.codexFileDays(rows: rowsInScanWindow)) + let splitMaps = Self.codexModeSplitMaps( + rows: rows, + range: context.range, + priorityTurns: context.resources.priorityTurns, + modelsDevCatalog: context.resources.modelsDevCatalog, + modelsDevCacheRoot: context.resources.modelsDevCacheRoot) + + return Self.makeFileUsage( + mtimeUnixMs: usage.mtimeUnixMs, + size: usage.size, + days: days, + parsedBytes: usage.parsedBytes, + lastModel: usage.lastModel, + lastTotals: usage.lastTotals, + lastCountedTotals: usage.lastCountedTotals, + lastRawTotalsBaseline: usage.lastRawTotalsBaseline, + lastRawTotalsWatermark: usage.lastRawTotalsWatermark, + seenRawTotals: usage.seenRawTotals, + hasDivergentTotals: usage.hasDivergentTotals, + hasInterleavedTotals: usage.hasInterleavedTotals, + lastCodexTurnID: usage.lastCodexTurnID, + sessionId: usage.sessionId, + forkedFromId: usage.forkedFromId, + forkBaselineDependencyKey: usage.forkBaselineDependencyKey, + projectPath: usage.projectPath, + canonicalProjectPath: usage.canonicalProjectPath, + codexCostNanos: Self.mergeCostMaps( + Self.costMapOutsideScanWindow(usage.codexCostNanos, range: context.range), + Self.codexCostNanos( + rows: rows, + range: context.range, + modelsDevCatalog: context.resources.modelsDevCatalog, + modelsDevCacheRoot: context.resources.modelsDevCacheRoot)), + codexPrioritySurchargeNanos: Self.mergeCostMaps( + Self.costMapOutsideScanWindow(usage.codexPrioritySurchargeNanos, range: context.range), + Self.codexPrioritySurchargeNanos( + rows: rows, + range: context.range, + priorityTurns: context.resources.priorityTurns, + modelsDevCatalog: context.resources.modelsDevCatalog, + modelsDevCacheRoot: context.resources.modelsDevCacheRoot)), + codexStandardCostNanos: Self.mergeCostMaps( + Self.costMapOutsideScanWindow(usage.codexStandardCostNanos, range: context.range), + splitMaps.standardCostNanos), + codexPriorityCostNanos: Self.mergeCostMaps( + Self.costMapOutsideScanWindow(usage.codexPriorityCostNanos, range: context.range), + splitMaps.priorityCostNanos), + codexStandardTokens: Self.mergeIntMaps( + Self.intMapOutsideScanWindow(usage.codexStandardTokens, range: context.range), + splitMaps.standardTokens), + codexPriorityTokens: Self.mergeIntMaps( + Self.intMapOutsideScanWindow(usage.codexPriorityTokens, range: context.range), + splitMaps.priorityTokens), + codexTurnIDs: Self.mergeCodexTurnIDs(nil, rows: rows), + codexRows: rows) + .refreshingCodexWorkspaceUsageFingerprint() + } + static func mergeCostMaps( _ existing: [String: [String: Int64]]?, _ delta: [String: [String: Int64]]?) -> [String: [String: Int64]]? @@ -617,14 +898,22 @@ extension CostUsageScanner { static func codexFileMetadata(fileURL: URL) -> CodexFileMetadata { let path = fileURL.path - let attrs = (try? FileManager.default.attributesOfItem(atPath: path)) ?? [:] - let mtime = (attrs[.modificationDate] as? Date)?.timeIntervalSince1970 ?? 0 - let size = (attrs[.size] as? NSNumber)?.int64Value ?? 0 + var info = stat() + guard path.withCString({ fstatat(AT_FDCWD, $0, &info, 0) }) == 0 else { + return CodexFileMetadata(path: path, mtimeUnixMs: 0, size: 0, fileId: nil) + } + #if os(Linux) + let modifiedSeconds = Int64(info.st_mtim.tv_sec) + let modifiedNanoseconds = Int64(info.st_mtim.tv_nsec) + #else + let modifiedSeconds = Int64(info.st_mtimespec.tv_sec) + let modifiedNanoseconds = Int64(info.st_mtimespec.tv_nsec) + #endif return CodexFileMetadata( path: path, - mtimeUnixMs: Int64(mtime * 1000), - size: size, - fileId: Self.fileIdentityString(fileURL: fileURL)) + mtimeUnixMs: modifiedSeconds * 1000 + modifiedNanoseconds / 1_000_000, + size: Int64(info.st_size), + fileId: "\(info.st_dev):\(info.st_ino)") } static func dropCachedCodexFile( @@ -639,17 +928,24 @@ extension CostUsageScanner { } static func rememberScannedCodexFile( - fileURL: URL, - metadata: CodexFileMetadata, - sessionId: String?, + input: CodexFileScanInput, + session: CodexScannedSession, + rows: [CodexUsageRow], context: CodexFileScanContext, state: inout CodexScanState) { - if let sessionId { - state.seenSessionIds.insert(sessionId) - context.resources.fileIndex.remember(fileURL: fileURL, sessionId: sessionId) + if let sessionId = session.id { + context.resources.fileIndex.remember(fileURL: input.fileURL, sessionId: sessionId) + if session.contributedUsage { + state.contributingSessionIds.insert(sessionId) + } } - if let fileId = metadata.fileId { + Self.rememberCodexRows( + rows, + sessionId: session.id, + fileIdentity: input.metadata.path, + state: &state) + if let fileId = input.metadata.fileId { state.seenFileIds.insert(fileId) } } @@ -658,25 +954,67 @@ extension CostUsageScanner { input: CodexFileScanInput, context: CodexFileScanContext, cache: inout CostUsageCache, - state: inout CodexScanState) -> Bool + state: inout CodexScanState) throws -> Bool { guard let cached = input.cached else { return false } let needsSessionId = cached.sessionId == nil guard cached.mtimeUnixMs == input.metadata.mtimeUnixMs, cached.size == input.metadata.size, + cached.codexScanComplete != false, !needsSessionId, !context.forceFullScan else { return false } guard !Self.cachedCodexFileNeedsPriorityRescan(cached, context: context) else { return false } - if Self.needsCodexCostCache(cached, range: context.range) { - cache.files[input.metadata.path] = Self.codexFileUsageWithCostCache(cached, context: context) + let sessionAlreadyContributed = cached.sessionId.map { state.contributingSessionIds.contains($0) } ?? false + let cachedRows = cached.codexRows ?? [] + if Self.cachedCodexRowsNeedIdentityRescan(cached) { + return false + } + if let parentSessionId = cached.forkedFromId { + guard let cachedDependencyKey = cached.forkBaselineDependencyKey else { return false } + if cachedDependencyKey != Self.codexForkDependencyNotRequiredKey { + let currentDependencyKey = try context.resources.inheritedResolver + .currentDependencyKey(for: parentSessionId) + guard cachedDependencyKey == currentDependencyKey else { return false } + } } + + if sessionAlreadyContributed { + guard !cachedRows.isEmpty else { return false } + let uniqueRows = Self.uniqueCodexRows( + rows: cachedRows, + sessionId: cached.sessionId, + fileIdentity: input.metadata.path, + state: &state) + guard !uniqueRows.isEmpty else { + Self.dropCachedCodexFile(path: input.metadata.path, cached: cached, cache: &cache) + return true + } + Self.applyFileDays(cache: &cache, fileDays: cached.days, sign: -1) + let filtered = Self.codexFileUsageByFilteringRows(cached, rows: uniqueRows, context: context) + cache.files[input.metadata.path] = filtered + Self.applyFileDays(cache: &cache, fileDays: filtered.days, sign: 1) + Self.rememberScannedCodexFile( + input: input, + session: CodexScannedSession(id: cached.sessionId, days: filtered.days), + rows: uniqueRows, + context: context, + state: &state) + return true + } + + let current = if Self.needsCodexCostCache(cached, range: context.range) { + Self.codexFileUsageWithCostCache(cached, context: context) + } else { + cached + } + cache.files[input.metadata.path] = current Self.rememberScannedCodexFile( - fileURL: input.fileURL, - metadata: input.metadata, - sessionId: cached.sessionId, + input: input, + session: CodexScannedSession(id: current.sessionId, days: current.days), + rows: cachedRows, context: context, state: &state) return true @@ -693,22 +1031,56 @@ extension CostUsageScanner { return !(Set(cached.codexTurnIDs ?? []).isDisjoint(with: context.changedPriorityTurnIDs)) } + // swiftlint:disable:next function_body_length static func appendCodexFileIncrementIfPossible( input: CodexFileScanInput, context: CodexFileScanContext, cache: inout CostUsageCache, - state: inout CodexScanState) throws -> Bool + state: inout CodexScanState, + maxBytesToRead: Int64? = nil) throws -> Bool { try context.checkCancellation?() guard let cached = input.cached, cached.sessionId != nil, !context.forceFullScan else { return false } guard !Self.cachedCodexFileNeedsPriorityRescan(cached, context: context) else { return false } + if Self.cachedCodexRowsNeedIdentityRescan(cached) { + return false + } + // Subagent shape depends on the complete lineage prefix. Appended metadata can change an + // independent counter into a copied-prefix rollout, so a tail-only parse is not sound. let startOffset = cached.parsedBytes ?? cached.size + let hasMatchingResumeOffset = cached.codexJSONLResumeState?.offset == nil + || cached.codexJSONLResumeState?.offset == startOffset + let isResumablePartial = cached.codexScanComplete == false + && cached.codexScanFileId != nil + && cached.codexScanFileId == input.metadata.fileId + && cached.codexScanTargetSize == input.metadata.size + && cached.mtimeUnixMs == input.metadata.mtimeUnixMs + && hasMatchingResumeOffset + if cached.codexScanComplete == false, !isResumablePartial { + return false + } + if !isResumablePartial, try Self.codexFileIsSubagentThread( + fileURL: input.fileURL, + checkCancellation: context.checkCancellation) + { + return false + } let initialCountedTotals = cached.lastCountedTotals ?? cached.lastTotals let initialRawTotalsBaseline = cached.lastRawTotalsBaseline ?? cached.lastTotals - let canIncremental = input.metadata.size > cached.size && startOffset > 0 + let initialHasDivergentTotals = cached.hasDivergentTotals ?? (cached.lastTotals == nil) + // Correctness-critical interleave state is watermark + interleaved flag (+ counted/raw). + // `seenRawTotals` is optional precision only and must not gate incremental resume (#2037). + let hasIncompleteInterleaveState = + (cached.hasInterleavedTotals == true && cached.lastRawTotalsWatermark == nil) + || (cached.lastRawTotalsWatermark != nil && cached.hasInterleavedTotals == nil) + || (initialHasDivergentTotals && cached.lastRawTotalsWatermark == nil) + let canIncremental = startOffset > 0 && startOffset <= input.metadata.size - && initialCountedTotals != nil - && cached.forkedFromId == nil + && (isResumablePartial + || (input.metadata.size > cached.size + && initialCountedTotals != nil + && cached.forkedFromId == nil + && !hasIncompleteInterleaveState)) guard canIncremental else { return false } let delta = try Self.parseCodexFileCancellable( @@ -718,27 +1090,81 @@ extension CostUsageScanner { initialModel: cached.lastModel, initialTotals: initialCountedTotals, initialRawTotalsBaseline: initialRawTotalsBaseline, - initialHasDivergentTotals: cached.hasDivergentTotals ?? (cached.lastTotals == nil), + initialRawTotalsWatermark: cached.lastRawTotalsWatermark, + initialSeenRawTotals: cached.seenRawTotals ?? [], + initialHasDivergentTotals: initialHasDivergentTotals, + initialHasInterleavedTotals: cached.hasInterleavedTotals ?? false, initialCodexTurnID: cached.lastCodexTurnID, + initialCodexUsageRowIndex: Self.nextCodexUsageRowIndex(cached.codexRows), + initialBufferedSubagentLines: cached.codexBufferedSubagentLines, + initialJSONLResumeState: cached.codexJSONLResumeState, + maxBytesToRead: maxBytesToRead, checkCancellation: context.checkCancellation) - if delta.forkedFromId != nil { + if delta.forkedFromId != nil, !isResumablePartial { return false } - let sessionId = delta.sessionId ?? cached.sessionId - if let sessionId, state.seenSessionIds.contains(sessionId) { + let migrated = Self.codexFileUsageWithCostCache(cached, context: context) + let cachedSessionMetadata = migrated.codexSession ?? CostUsageCodexSessionMetadata( + sessionId: migrated.sessionId, + forkedFromId: migrated.forkedFromId, + cwd: nil, + title: nil, + startedAtUnixMs: nil, + latestActivityUnixMs: nil) + let codexSession = cachedSessionMetadata.merging(delta.codexSession) + let sessionId = codexSession.sessionId ?? delta.sessionId ?? cached.sessionId + let projectPath = delta.projectPath ?? cached.projectPath + let forkBaselineDependencyKey = Self.codexForkBaselineDependencyKey( + parentSessionId: delta.forkedFromId, + dependsOnParentTotals: delta.dependsOnParentTotals, + inheritedResolver: context.resources.inheritedResolver) + let canonicalProjectPath = delta.projectPath.map { + context.resources.projectPathResolver.canonicalProjectPath(for: $0) + } ?? cached.canonicalProjectPath ?? context.resources.projectPathResolver.canonicalProjectPath(for: projectPath) + let sessionAlreadyContributed = sessionId.map { state.contributingSessionIds.contains($0) } ?? false + let cachedRows = cached.codexRows ?? [] + let retainedCachedRows: [CodexUsageRow] + if sessionAlreadyContributed { + retainedCachedRows = Self.uniqueCodexRows( + rows: cachedRows, + sessionId: sessionId, + fileIdentity: input.metadata.path, + state: &state) + } else { + Self.rememberCodexRows( + cachedRows, + sessionId: sessionId, + fileIdentity: input.metadata.path, + state: &state) + retainedCachedRows = cachedRows + } + let uniqueRows = Self.uniqueCodexRows( + rows: delta.rows, + sessionId: sessionId, + fileIdentity: input.metadata.path, + state: &state) + + let migratedCached = sessionAlreadyContributed + ? Self.codexFileUsageByFilteringRows(migrated, rows: retainedCachedRows, context: context) + : migrated + if sessionAlreadyContributed, migratedCached.days.isEmpty, uniqueRows.isEmpty { Self.dropCachedCodexFile(path: input.metadata.path, cached: cached, cache: &cache) return true } + let uniqueDays = Self.codexFileDays(rows: uniqueRows) - let migratedCached = Self.codexFileUsageWithCostCache(cached, context: context) - if !delta.days.isEmpty { - Self.applyFileDays(cache: &cache, fileDays: delta.days, sign: 1) + if sessionAlreadyContributed { + Self.applyFileDays(cache: &cache, fileDays: cached.days, sign: -1) + Self.applyFileDays(cache: &cache, fileDays: migratedCached.days, sign: 1) + } + if !uniqueDays.isEmpty { + Self.applyFileDays(cache: &cache, fileDays: uniqueDays, sign: 1) } var mergedDays = migratedCached.days - Self.mergeFileDays(existing: &mergedDays, delta: delta.days) + Self.mergeFileDays(existing: &mergedDays, delta: uniqueDays) let splitMaps = Self.codexModeSplitMaps( - rows: delta.rows, + rows: uniqueRows, range: context.range, priorityTurns: context.resources.priorityTurns, modelsDevCatalog: context.resources.modelsDevCatalog, @@ -752,17 +1178,24 @@ extension CostUsageScanner { lastTotals: delta.lastTotals, lastCountedTotals: delta.lastCountedTotals, lastRawTotalsBaseline: delta.lastRawTotalsBaseline, + lastRawTotalsWatermark: delta.lastRawTotalsWatermark, + seenRawTotals: delta.seenRawTotals, hasDivergentTotals: delta.hasDivergentTotals, + hasInterleavedTotals: delta.hasInterleavedTotals, lastCodexTurnID: delta.lastCodexTurnID, sessionId: sessionId, - forkedFromId: delta.forkedFromId ?? migratedCached.forkedFromId, + forkedFromId: codexSession.forkedFromId ?? delta.forkedFromId ?? migratedCached.forkedFromId, + forkBaselineDependencyKey: forkBaselineDependencyKey ?? migratedCached.forkBaselineDependencyKey, + projectPath: projectPath, + canonicalProjectPath: canonicalProjectPath, + codexSession: codexSession.isEmpty ? nil : codexSession, codexCostNanos: Self.codexMergedCostMap( migratedCached.codexCostNanos, - deltaRows: delta.rows, + deltaRows: uniqueRows, context: context), codexPrioritySurchargeNanos: Self.codexMergedPrioritySurchargeMap( migratedCached.codexPrioritySurchargeNanos, - deltaRows: delta.rows, + deltaRows: uniqueRows, context: context), codexStandardCostNanos: Self.mergeCostMaps( migratedCached.codexStandardCostNanos, @@ -776,12 +1209,22 @@ extension CostUsageScanner { codexPriorityTokens: Self.mergeIntMaps( migratedCached.codexPriorityTokens, splitMaps.priorityTokens), - codexTurnIDs: Self.mergeCodexTurnIDs(migratedCached.codexTurnIDs, rows: delta.rows), - codexRows: migratedCached.codexRows) + codexTurnIDs: Self.mergeCodexTurnIDs(migratedCached.codexTurnIDs, rows: uniqueRows), + codexRows: Self.codexRowsWithPricingAudit( + Self.mergeCodexRows(retainedCachedRows, rows: uniqueRows, sessionId: sessionId) ?? [], + priorityTurns: context.resources.priorityTurns, + modelsDevCatalog: context.resources.modelsDevCatalog, + modelsDevCacheRoot: context.resources.modelsDevCacheRoot), + codexScanFileId: input.metadata.fileId, + codexScanTargetSize: input.metadata.size, + codexScanComplete: delta.parsedBytes >= input.metadata.size && delta.jsonlResumeState == nil, + codexJSONLResumeState: delta.jsonlResumeState, + codexBufferedSubagentLines: delta.bufferedSubagentLines) + .refreshingCodexWorkspaceUsageFingerprint() Self.rememberScannedCodexFile( - fileURL: input.fileURL, - metadata: input.metadata, - sessionId: sessionId, + input: input, + session: CodexScannedSession(id: sessionId, days: mergedDays), + rows: uniqueRows, context: context, state: &state) return true @@ -791,7 +1234,8 @@ extension CostUsageScanner { input: CodexFileScanInput, context: CodexFileScanContext, cache: inout CostUsageCache, - state: inout CodexScanState) throws + state: inout CodexScanState, + maxBytesToRead: Int64? = nil) throws { try context.checkCancellation?() if let cached = input.cached { @@ -805,16 +1249,45 @@ extension CostUsageScanner { let parsed = try Self.parseCodexFileCancellable( fileURL: input.fileURL, range: context.range, + maxBytesToRead: maxBytesToRead, inheritedTotalsResolver: context.resources.inheritedResolver.inheritedTotals(for:atOrBefore:), checkCancellation: context.checkCancellation) - let sessionId = parsed.sessionId ?? input.cached?.sessionId - if let sessionId, state.seenSessionIds.contains(sessionId) { + let forkBaselineDependencyKey = Self.codexForkBaselineDependencyKey( + parentSessionId: parsed.forkedFromId, + dependsOnParentTotals: parsed.dependsOnParentTotals, + inheritedResolver: context.resources.inheritedResolver) + let cachedSessionMetadata = input.cached?.codexSession ?? CostUsageCodexSessionMetadata( + sessionId: input.cached?.sessionId, + forkedFromId: input.cached?.forkedFromId, + cwd: nil, + title: nil, + startedAtUnixMs: nil, + latestActivityUnixMs: nil) + let parsedCodexSession = cachedSessionMetadata.merging(parsed.codexSession) + let sessionId = parsedCodexSession.sessionId ?? parsed.sessionId ?? input.cached?.sessionId + let projectPath = parsed.projectPath ?? input.cached?.projectPath + let canonicalProjectPath = parsed.projectPath.map { + context.resources.projectPathResolver.canonicalProjectPath(for: $0) + } ?? input.cached?.canonicalProjectPath ?? context.resources.projectPathResolver + .canonicalProjectPath(for: projectPath) + let uniqueRows = Self.uniqueCodexRows( + rows: parsed.rows, + sessionId: sessionId, + fileIdentity: input.metadata.path, + state: &state) + if let sessionId, + state.contributingSessionIds.contains(sessionId), + uniqueRows.isEmpty, + usageDays.isEmpty, + parsed.bufferedSubagentLines == nil + { cache.files.removeValue(forKey: input.metadata.path) return } - Self.mergeFileDays(existing: &usageDays, delta: parsed.days) + let uniqueDays = Self.codexFileDays(rows: uniqueRows) + Self.mergeFileDays(existing: &usageDays, delta: uniqueDays) let splitMaps = Self.codexModeSplitMaps( - rows: parsed.rows, + rows: uniqueRows, range: context.range, priorityTurns: context.resources.priorityTurns, modelsDevCatalog: context.resources.modelsDevCatalog, @@ -829,16 +1302,23 @@ extension CostUsageScanner { lastTotals: parsed.lastTotals, lastCountedTotals: parsed.lastCountedTotals, lastRawTotalsBaseline: parsed.lastRawTotalsBaseline, + lastRawTotalsWatermark: parsed.lastRawTotalsWatermark, + seenRawTotals: parsed.seenRawTotals, hasDivergentTotals: parsed.hasDivergentTotals, + hasInterleavedTotals: parsed.hasInterleavedTotals, lastCodexTurnID: parsed.lastCodexTurnID, sessionId: sessionId, - forkedFromId: parsed.forkedFromId, + forkedFromId: parsedCodexSession.forkedFromId ?? parsed.forkedFromId, + forkBaselineDependencyKey: forkBaselineDependencyKey, + projectPath: projectPath, + canonicalProjectPath: canonicalProjectPath, + codexSession: parsedCodexSession.isEmpty ? nil : parsedCodexSession, codexCostNanos: Self.mergeCostMaps( context.dropDeferredCodexRows ? nil : Self.costMapOutsideScanWindow(migratedCached?.codexCostNanos, range: context.range), Self.codexCostNanos( - rows: parsed.rows, + rows: uniqueRows, range: context.range, modelsDevCatalog: context.resources.modelsDevCatalog, modelsDevCacheRoot: context.resources.modelsDevCacheRoot)), @@ -847,7 +1327,7 @@ extension CostUsageScanner { ? nil : Self.costMapOutsideScanWindow(migratedCached?.codexPrioritySurchargeNanos, range: context.range), Self.codexPrioritySurchargeNanos( - rows: parsed.rows, + rows: uniqueRows, range: context.range, priorityTurns: context.resources.priorityTurns, modelsDevCatalog: context.resources.modelsDevCatalog, @@ -873,18 +1353,43 @@ extension CostUsageScanner { : Self.intMapOutsideScanWindow(migratedCached?.codexPriorityTokens, range: context.range), splitMaps.priorityTokens), codexTurnIDs: context.dropDeferredCodexRows - ? Self.codexTurnIDs(rows: parsed.rows) - : Self.mergeCodexTurnIDs(migratedCached?.codexTurnIDs, rows: parsed.rows), - codexRows: context.dropDeferredCodexRows ? nil : migratedCached?.codexRows) + ? Self.codexTurnIDs(rows: uniqueRows) + : Self.mergeCodexTurnIDs(migratedCached?.codexTurnIDs, rows: uniqueRows), + codexRows: context.dropDeferredCodexRows + ? nil + : Self.codexRowsWithPricingAudit( + Self.mergeCodexRows(migratedCached?.codexRows, rows: uniqueRows, sessionId: sessionId) ?? [], + priorityTurns: context.resources.priorityTurns, + modelsDevCatalog: context.resources.modelsDevCatalog, + modelsDevCacheRoot: context.resources.modelsDevCacheRoot), + codexScanFileId: input.metadata.fileId, + codexScanTargetSize: input.metadata.size, + codexScanComplete: parsed.parsedBytes >= input.metadata.size && parsed.jsonlResumeState == nil, + codexJSONLResumeState: parsed.jsonlResumeState, + codexBufferedSubagentLines: parsed.bufferedSubagentLines) + .refreshingCodexWorkspaceUsageFingerprint() Self.applyFileDays(cache: &cache, fileDays: cache.files[input.metadata.path]?.days ?? [:], sign: 1) Self.rememberScannedCodexFile( - fileURL: input.fileURL, - metadata: input.metadata, - sessionId: sessionId, + input: input, + session: CodexScannedSession(id: sessionId, days: usageDays), + rows: uniqueRows, context: context, state: &state) } + static func codexForkBaselineDependencyKey( + parentSessionId: String?, + dependsOnParentTotals: Bool, + inheritedResolver: CodexInheritedTotalsResolver) -> String? + { + guard let parentSessionId else { return nil } + guard dependsOnParentTotals else { return Self.codexForkDependencyNotRequiredKey } + + // A nil key means the parent changed while its snapshots were read (or no stable + // snapshot was resolved). Preserve nil so the child cannot be reused on the next scan. + return inheritedResolver.dependencyKeyUsed(for: parentSessionId) + } + static func mergeFileDays( existing: inout [String: [String: [Int]]], delta: [String: [String: [Int]]]) @@ -984,35 +1489,43 @@ extension CostUsageScanner { range: CostUsageDayRange, modelsDevCatalog: ModelsDevCatalog? = nil, modelsDevCacheRoot: URL? = nil, - priorityTurns: [String: CodexPriorityTurnMetadata] = [:]) -> CostUsageDailyReport + priorityTurns: [String: CodexPriorityTurnMetadata] = [:], + modelsDevCatalogLoader: (URL?) -> ModelsDevCatalog? = { + CostUsagePricing.modelsDevCatalog(cacheRoot: $0) + }) -> CostUsageDailyReport { - var entries: [CostUsageDailyReport.Entry] = [] - var totalInput = 0 - var totalOutput = 0 - var totalTokens = 0 - var totalCost: Double = 0 - var costSeen = false - - let dayKeys = cache.days.keys.sorted().filter { - CostUsageDayRange.isInRange(dayKey: $0, since: range.sinceKey, until: range.untilKey) - } - let costNanosByDayModel = self.codexCostNanosByDayModel(cache: cache, range: range) - let prioritySurchargeNanosByDayModel = self.codexPrioritySurchargeNanosByDayModel(cache: cache, range: range) - let standardCostNanosByDayModel = self.codexStandardCostNanosByDayModel(cache: cache, range: range) - let priorityCostNanosByDayModel = self.codexPriorityCostNanosByDayModel(cache: cache, range: range) - let standardTokensByDayModel = self.codexStandardTokensByDayModel(cache: cache, range: range) - let priorityTokensByDayModel = self.codexPriorityTokensByDayModel(cache: cache, range: range) - - let hasCodexRows = cache.files.values.contains { - !($0.codexRows?.isEmpty ?? true) + let catalogResolver = CodexModelsDevCatalogResolver( + catalog: modelsDevCatalog, + cacheRoot: modelsDevCacheRoot) + var reportCache = cache + for (path, usage) in cache.files where self.needsCodexCostCache(usage, range: range) { + reportCache.files[path] = self.codexFileUsageWithCostCache( + usage, + range: range, + priorityTurns: priorityTurns, + modelsDevCatalog: catalogResolver.load(modelsDevCatalogLoader), + modelsDevCacheRoot: modelsDevCacheRoot) } - let rowsByDayModel = hasCodexRows ? self.codexRowsByDayModel(cache: cache, range: range) : [:] + var entries: [CostUsageDailyReport.Entry] = [] + var (totalInput, totalCacheRead, totalOutput, totalTokens) = (0, 0, 0, 0) + var (totalCost, costSeen) = (0.0, false) + + let dayKeys = self.codexReportDayKeys(cache: reportCache, range: range) + let costNanosByDayModel = self.codexCostNanosByDayModel(cache: reportCache, range: range) + let prioritySurchargeNanosByDayModel = self.codexPrioritySurchargeNanosByDayModel( + cache: reportCache, + range: range) + let standardCostNanosByDayModel = self.codexStandardCostNanosByDayModel(cache: reportCache, range: range) + let priorityCostNanosByDayModel = self.codexPriorityCostNanosByDayModel(cache: reportCache, range: range) + let standardTokensByDayModel = self.codexStandardTokensByDayModel(cache: reportCache, range: range) + let priorityTokensByDayModel = self.codexPriorityTokensByDayModel(cache: reportCache, range: range) for day in dayKeys { - guard let models = cache.days[day] else { continue } + guard let models = reportCache.days[day] else { continue } let modelNames = models.keys.sorted() var dayInput = 0 + var dayCacheRead = 0 var dayOutput = 0 var breakdown: [CostUsageDailyReport.ModelBreakdown] = [] var dayCost: Double = 0 @@ -1026,22 +1539,20 @@ extension CostUsageScanner { let totalTokens = input + output dayInput += input + dayCacheRead += cached dayOutput += output - let rows = rowsByDayModel[day]?[model] - let rowCostBreakdown = rows.map { - self.codexRowCostBreakdown( - rows: $0, - priorityTurns: priorityTurns, - modelsDevCatalog: modelsDevCatalog, - modelsDevCacheRoot: modelsDevCacheRoot) - } let cachedBaseCost = costNanosByDayModel[day]?[model].map { Double($0) / Self.costScale } - let rowTotalCost = cachedBaseCost == nil ? rowCostBreakdown?.totalCostUSD : nil - let standardCost = standardCostNanosByDayModel[day]?[model].map { Double($0) / Self.costScale } - ?? (rowCostBreakdown?.hasModeSplit == true ? rowCostBreakdown?.optionalStandardCostUSD : nil) - let priorityCost = priorityCostNanosByDayModel[day]?[model].map { Double($0) / Self.costScale } - ?? (rowCostBreakdown?.hasModeSplit == true ? rowCostBreakdown?.optionalPriorityCostUSD : nil) + let cachedStandardCost = standardCostNanosByDayModel[day]?[model].map { + Double($0) / Self.costScale + } + let cachedPriorityCost = priorityCostNanosByDayModel[day]?[model].map { + Double($0) / Self.costScale + } + let cachedStandardTokens = standardTokensByDayModel[day]?[model] + let cachedPriorityTokens = priorityTokensByDayModel[day]?[model] + let standardCost = cachedStandardCost + let priorityCost = cachedPriorityCost let splitTotalCost: Double? = if standardCost != nil || priorityCost != nil { (standardCost ?? 0) + (priorityCost ?? 0) } else { @@ -1049,36 +1560,20 @@ extension CostUsageScanner { } var cost = splitTotalCost ?? cachedBaseCost - ?? rowTotalCost ?? CostUsagePricing.codexCostUSD( model: model, inputTokens: input, cachedInputTokens: cached, outputTokens: output, - modelsDevCatalog: modelsDevCatalog, + modelsDevCatalog: catalogResolver.load(modelsDevCatalogLoader), modelsDevCacheRoot: modelsDevCacheRoot) if splitTotalCost == nil, let surchargeNanos = prioritySurchargeNanosByDayModel[day]?[model], cachedBaseCost != nil { cost = (cost ?? 0) + (Double(surchargeNanos) / Self.costScale) - } else if splitTotalCost == nil, - rowTotalCost == nil, - !priorityTurns.isEmpty, - let rows, - let surcharge = self.codexPrioritySurchargeUSD( - rows: rows, - priorityTurns: priorityTurns, - modelsDevCatalog: modelsDevCatalog, - modelsDevCacheRoot: modelsDevCacheRoot) - { - cost = (cost ?? 0) + surcharge } - let standardModeTokens = standardTokensByDayModel[day]?[model] - ?? (rowCostBreakdown?.hasModeSplit == true ? rowCostBreakdown?.optionalStandardTokens : nil) - let priorityModeTokens = priorityTokensByDayModel[day]?[model] - ?? (rowCostBreakdown?.hasModeSplit == true ? rowCostBreakdown?.optionalPriorityTokens : nil) - let hasModeSplit = priorityCost != nil || priorityModeTokens != nil + let hasModeSplit = priorityCost != nil || cachedPriorityTokens != nil breakdown.append( CostUsageDailyReport.ModelBreakdown( modelName: model, @@ -1086,8 +1581,8 @@ extension CostUsageScanner { totalTokens: totalTokens, standardCostUSD: hasModeSplit ? standardCost : nil, priorityCostUSD: hasModeSplit ? priorityCost : nil, - standardTokens: hasModeSplit ? standardModeTokens : nil, - priorityTokens: hasModeSplit ? priorityModeTokens : nil)) + standardTokens: hasModeSplit ? cachedStandardTokens : nil, + priorityTokens: hasModeSplit ? cachedPriorityTokens : nil)) if let cost { dayCost += cost dayCostSeen = true @@ -1100,12 +1595,14 @@ extension CostUsageScanner { date: day, inputTokens: dayInput, outputTokens: dayOutput, + cacheReadTokens: dayCacheRead > 0 ? dayCacheRead : nil, totalTokens: dayTotal, costUSD: entryCost, modelsUsed: modelNames, modelBreakdowns: Self.sortedModelBreakdowns(breakdown))) totalInput += dayInput + totalCacheRead += dayCacheRead totalOutput += dayOutput totalTokens += dayTotal if let entryCost { @@ -1119,6 +1616,7 @@ extension CostUsageScanner { : CostUsageDailyReport.Summary( totalInputTokens: totalInput, totalOutputTokens: totalOutput, + cacheReadTokens: totalCacheRead > 0 ? totalCacheRead : nil, totalTokens: totalTokens, totalCostUSD: costSeen ? totalCost : nil) @@ -1145,7 +1643,7 @@ extension CostUsageScanner { } } - static func parseDayKey(_ key: String) -> Date? { + static func parseDayKey(_ key: String, calendar: Calendar = .current) -> Date? { let parts = key.split(separator: "-") guard parts.count == 3 else { return nil } guard @@ -1154,9 +1652,10 @@ extension CostUsageScanner { let day = Int(parts[2]) else { return nil } + let calendar = CostUsageDayRange.localGregorianCalendar(matching: calendar) var comps = DateComponents() - comps.calendar = Calendar.current - comps.timeZone = TimeZone.current + comps.calendar = calendar + comps.timeZone = calendar.timeZone comps.year = year comps.month = month comps.day = day @@ -1174,16 +1673,24 @@ extension Data { extension [Int] { subscript(safe index: Int) -> Int? { - if index < 0 { return nil } - if index >= self.count { return nil } + if index < 0 { + return nil + } + if index >= self.count { + return nil + } return self[index] } } extension [UInt8] { subscript(safe index: Int) -> UInt8? { - if index < 0 { return nil } - if index >= self.count { return nil } + if index < 0 { + return nil + } + if index >= self.count { + return nil + } return self[index] } } diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift index 3d1578e386..e862bb349e 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift @@ -3,12 +3,38 @@ import Foundation extension CostUsageScanner { // MARK: - Claude - private static func defaultClaudeProjectsRoots(options: Options) -> [URL] { + private struct ClaudeTokens { + let input: Int + let cacheRead: Int + let cacheCreate: Int + let cacheCreate1h: Int + let output: Int + let costNanos: Int + let costPriced: Bool + } + + private struct ClaudeDayModelKey: Hashable { + let day: String + let model: String + } + + private struct ClaudeRepricedCost { + var total: Double = 0 + var sampleCount: Int = 0 + var unresolved = false + } + + static func defaultClaudeProjectsRoots( + options: Options, + environment: [String: String] = ProcessInfo.processInfo.environment, + homeDirectory: URL = FileManager.default.homeDirectoryForCurrentUser, + fileManager: FileManager = .default) -> [URL] + { if let override = options.claudeProjectsRoots { return override } var roots: [URL] = [] - if let env = ProcessInfo.processInfo.environment["CLAUDE_CONFIG_DIR"]? + if let env = environment["CLAUDE_CONFIG_DIR"]? .trimmingCharacters(in: .whitespacesAndNewlines), !env.isEmpty { @@ -23,12 +49,27 @@ extension CostUsageScanner { } } } else { - let home = FileManager.default.homeDirectoryForCurrentUser - roots.append(home.appendingPathComponent(".config/claude/projects", isDirectory: true)) - roots.append(home.appendingPathComponent(".claude/projects", isDirectory: true)) + roots.append(homeDirectory.appendingPathComponent(".config/claude/projects", isDirectory: true)) + roots.append(homeDirectory.appendingPathComponent(".claude/projects", isDirectory: true)) + roots.append(contentsOf: ClaudeDesktopProjectsLocator.roots( + homeDirectory: homeDirectory, + fileManager: fileManager)) } - return roots + return self.deduplicatedClaudeProjectRoots(roots) + } + + private static func deduplicatedClaudeProjectRoots(_ roots: [URL]) -> [URL] { + var seen: Set = [] + var out: [URL] = [] + for root in roots { + let standardized = root.standardizedFileURL + let path = standardized.path + guard !seen.contains(path) else { continue } + seen.insert(path) + out.append(standardized) + } + return out } static func parseClaudeFile( @@ -59,21 +100,12 @@ extension CostUsageScanner { modelsDevCacheRoot: URL? = nil, checkCancellation: CancellationCheck? = nil) throws -> ClaudeParseResult { - struct ClaudeTokens: Sendable { - let input: Int - let cacheRead: Int - let cacheCreate: Int - let output: Int - let costNanos: Int - let costPriced: Bool - } - func add(dayKey: String, model: String, tokens: ClaudeTokens, days: inout [String: [String: [Int]]]) { guard CostUsageDayRange.isInRange(dayKey: dayKey, since: range.scanSinceKey, until: range.scanUntilKey) else { return } let normModel = CostUsagePricing.normalizeClaudeModel(model) var dayModels = days[dayKey] ?? [:] - var packed = dayModels[normModel] ?? [0, 0, 0, 0, 0, 0, 0] + var packed = dayModels[normModel] ?? [0, 0, 0, 0, 0, 0, 0, 0] packed[0] = (packed[safe: 0] ?? 0) + tokens.input packed[1] = (packed[safe: 1] ?? 0) + tokens.cacheRead packed[2] = (packed[safe: 2] ?? 0) + tokens.cacheCreate @@ -81,6 +113,7 @@ extension CostUsageScanner { packed[4] = (packed[safe: 4] ?? 0) + tokens.costNanos packed[5] = (packed[safe: 5] ?? 0) + 1 packed[6] = (packed[safe: 6] ?? 0) + (tokens.costPriced ? 1 : 0) + packed[7] = (packed[safe: 7] ?? 0) + tokens.cacheCreate1h dayModels[normModel] = packed days[dayKey] = dayModels } @@ -127,8 +160,10 @@ extension CostUsageScanner { else { return } guard Self.matchesClaudeProviderFilter(obj: obj, filter: providerFilter) else { return } - guard let tsText = obj["timestamp"] as? String else { return } - guard let dayKey = Self.dayKeyFromTimestamp(tsText) ?? Self.dayKeyFromParsedISO(tsText) + guard let tsText = obj["timestamp"] as? String, let timestamp = Self.dateFromTimestamp(tsText) + else { return } + guard let dayKey = Self.dayKeyFromTimestamp(tsText, calendar: range.calendar) + ?? Self.dayKeyFromParsedISO(tsText, calendar: range.calendar) else { return } guard let message = obj["message"] as? [String: Any] else { return } @@ -137,6 +172,9 @@ extension CostUsageScanner { let input = max(0, toInt(usage["input_tokens"])) let cacheCreate = max(0, toInt(usage["cache_creation_input_tokens"])) + let cacheCreate1h = Self.claudeOneHourCacheCreationTokens( + usage: usage, + total: cacheCreate) let cacheRead = max(0, toInt(usage["cache_read_input_tokens"])) let output = max(0, toInt(usage["output_tokens"])) if input == 0, cacheCreate == 0, cacheRead == 0, output == 0 { return } @@ -146,7 +184,9 @@ extension CostUsageScanner { inputTokens: input, cacheReadInputTokens: cacheRead, cacheCreationInputTokens: cacheCreate, + cacheCreationInputTokens1h: cacheCreate1h, outputTokens: output, + pricingDate: timestamp, modelsDevCatalog: modelsDevCatalog, modelsDevCacheRoot: modelsDevCacheRoot) let costNanos = cost.map { Int(($0 * costScale).rounded()) } ?? 0 @@ -154,6 +194,7 @@ extension CostUsageScanner { input: input, cacheRead: cacheRead, cacheCreate: cacheCreate, + cacheCreate1h: cacheCreate1h, output: output, costNanos: costNanos, costPriced: cost != nil) @@ -177,11 +218,13 @@ extension CostUsageScanner { sessionId: sessionId, messageId: messageId, requestId: requestId, + timestampUnixMs: Int64((timestamp.timeIntervalSince1970 * 1000).rounded()), isSidechain: toBool(obj["isSidechain"]), pathRole: pathRole, input: tokens.input, cacheRead: tokens.cacheRead, cacheCreate: tokens.cacheCreate, + cacheCreate1h: tokens.cacheCreate1h, output: tokens.output, costNanos: tokens.costNanos, costPriced: tokens.costPriced) @@ -210,6 +253,7 @@ extension CostUsageScanner { input: row.input, cacheRead: row.cacheRead, cacheCreate: row.cacheCreate, + cacheCreate1h: row.cacheCreate1h ?? 0, output: row.output, costNanos: row.costNanos, costPriced: row.costPriced ?? (row.costNanos > 0)) @@ -219,6 +263,12 @@ extension CostUsageScanner { return ClaudeParseResult(days: days, rows: rows, parsedBytes: parsedBytes) } + private static func claudeOneHourCacheCreationTokens(usage: [String: Any], total: Int) -> Int { + guard let cacheCreation = usage["cache_creation"] as? [String: Any] else { return 0 } + let tokens = (cacheCreation["ephemeral_1h_input_tokens"] as? NSNumber)?.intValue ?? 0 + return min(total, max(0, tokens)) + } + private static func claudePathRole(fileURL: URL) -> ClaudePathRole { fileURL.path.contains("/subagents/") ? .subagent : .parent } @@ -270,29 +320,15 @@ extension CostUsageScanner { return lhs.path < rhs.path } - private static func rebuildClaudeDays(cache: inout CostUsageCache) { - var days: [String: [String: [Int]]] = [:] + private static func reconciledClaudeRows(cache: CostUsageCache) -> [ClaudeUsageRow] { + var rows: [ClaudeUsageRow] = [] var winners: [String: (path: String, row: ClaudeUsageRow)] = [:] - func addRow(_ row: ClaudeUsageRow) { - var dayModels = days[row.dayKey] ?? [:] - var packed = dayModels[row.model] ?? [0, 0, 0, 0, 0, 0, 0] - packed[0] = (packed[safe: 0] ?? 0) + row.input - packed[1] = (packed[safe: 1] ?? 0) + row.cacheRead - packed[2] = (packed[safe: 2] ?? 0) + row.cacheCreate - packed[3] = (packed[safe: 3] ?? 0) + row.output - packed[4] = (packed[safe: 4] ?? 0) + row.costNanos - packed[5] = (packed[safe: 5] ?? 0) + 1 - packed[6] = (packed[safe: 6] ?? 0) + ((row.costPriced ?? (row.costNanos > 0)) ? 1 : 0) - dayModels[row.model] = packed - days[row.dayKey] = dayModels - } - for path in cache.files.keys.sorted() { - guard let rows = cache.files[path]?.claudeRows else { continue } - for row in rows { + guard let fileRows = cache.files[path]?.claudeRows else { continue } + for row in fileRows { guard let canonicalKey = Self.claudeCanonicalRowKey(row) else { - addRow(row) + rows.append(row) continue } let candidate = (path: path, row: row) @@ -306,8 +342,26 @@ extension CostUsageScanner { } } - for winner in winners.values { - addRow(winner.row) + rows.append(contentsOf: winners.keys.sorted().compactMap { winners[$0]?.row }) + return rows + } + + private static func rebuildClaudeDays(cache: inout CostUsageCache) { + var days: [String: [String: [Int]]] = [:] + + for row in Self.reconciledClaudeRows(cache: cache) { + var dayModels = days[row.dayKey] ?? [:] + var packed = dayModels[row.model] ?? [0, 0, 0, 0, 0, 0, 0, 0] + packed[0] = (packed[safe: 0] ?? 0) + row.input + packed[1] = (packed[safe: 1] ?? 0) + row.cacheRead + packed[2] = (packed[safe: 2] ?? 0) + row.cacheCreate + packed[3] = (packed[safe: 3] ?? 0) + row.output + packed[4] = (packed[safe: 4] ?? 0) + row.costNanos + packed[5] = (packed[safe: 5] ?? 0) + 1 + packed[6] = (packed[safe: 6] ?? 0) + ((row.costPriced ?? (row.costNanos > 0)) ? 1 : 0) + packed[7] = (packed[safe: 7] ?? 0) + (row.cacheCreate1h ?? 0) + dayModels[row.model] = packed + days[row.dayKey] = dayModels } cache.days = days @@ -603,7 +657,10 @@ extension CostUsageScanner { options: Options, checkCancellation: CancellationCheck?) throws -> CostUsageDailyReport { - var cache = CostUsageCacheIO.load(provider: provider, cacheRoot: options.cacheRoot) + var cache = CostUsageCacheIO.load( + provider: provider, + cacheRoot: options.cacheRoot, + calendar: range.calendar) let nowMs = Int64(now.timeIntervalSince1970 * 1000) let refreshMs = Int64(max(0, options.refreshMinIntervalSeconds) * 1000) @@ -614,7 +671,6 @@ extension CostUsageScanner { || cache.lastScanUnixMs == 0 || nowMs - cache.lastScanUnixMs > refreshMs - let roots = self.defaultClaudeProjectsRoots(options: options) let providerFilter = options.claudeLogProviderFilter var touched: Set = [] @@ -634,6 +690,7 @@ extension CostUsageScanner { modelsDevCacheRoot: options.cacheRoot, checkCancellation: checkCancellation) + let roots = self.defaultClaudeProjectsRoots(options: options) for root in roots { try Self.scanClaudeRoot( root: root, @@ -655,7 +712,11 @@ extension CostUsageScanner { cache.scanUntilKey = range.scanUntilKey cache.lastScanUnixMs = nowMs try checkCancellation?() - CostUsageCacheIO.save(provider: provider, cache: cache, cacheRoot: options.cacheRoot) + CostUsageCacheIO.save( + provider: provider, + cache: cache, + cacheRoot: options.cacheRoot, + calendar: range.calendar) } let modelsDevCatalog = CostUsagePricing.modelsDevCatalog(now: now, cacheRoot: options.cacheRoot) @@ -681,6 +742,41 @@ extension CostUsageScanner { var totalCost: Double = 0 var costSeen = false let costScale = 1_000_000_000.0 + var repricedCosts: [ClaudeDayModelKey: ClaudeRepricedCost] = [:] + + for row in Self.reconciledClaudeRows(cache: cache) { + let key = ClaudeDayModelKey(day: row.dayKey, model: row.model) + var aggregate = repricedCosts[key] ?? ClaudeRepricedCost() + aggregate.sampleCount += 1 + let isPriced = row.costPriced ?? (row.costNanos > 0) + let currentPricingCost = CostUsagePricing.claudeCostUSD( + model: row.model, + inputTokens: row.input, + cacheReadInputTokens: row.cacheRead, + cacheCreationInputTokens: row.cacheCreate, + cacheCreationInputTokens1h: row.cacheCreate1h ?? 0, + outputTokens: row.output, + pricingDate: row.timestampUnixMs.map { + Date(timeIntervalSince1970: Double($0) / 1000) + }, + modelsDevCatalog: modelsDevCatalog, + modelsDevCacheRoot: modelsDevCacheRoot) + let cost: Double? = if isPriced, row.costNanos == 0 { + 0 + } else if let currentPricingCost { + currentPricingCost + } else if isPriced { + Double(row.costNanos) / costScale + } else { + nil + } + if let cost { + aggregate.total += cost + } else { + aggregate.unresolved = true + } + repricedCosts[key] = aggregate + } let dayKeys = cache.days.keys.sorted().filter { CostUsageDayRange.isInRange(dayKey: $0, since: range.sinceKey, until: range.untilKey) @@ -705,10 +801,7 @@ extension CostUsageScanner { let cacheRead = packed[safe: 1] ?? 0 let cacheCreate = packed[safe: 2] ?? 0 let output = packed[safe: 3] ?? 0 - let cachedCost = packed[safe: 4] ?? 0 let sampleCount = packed[safe: 5] ?? 0 - let pricedSampleCount = packed[safe: 6] ?? 0 - let hasCompleteCachedCost = sampleCount > 0 && pricedSampleCount == sampleCount let totalTokens = input + cacheRead + cacheCreate + output // Cache tokens are tracked separately; totalTokens includes input + cache. @@ -717,16 +810,16 @@ extension CostUsageScanner { dayCacheCreate += cacheCreate dayOutput += output - let currentPricingCost = CostUsagePricing.claudeCostUSD( - model: model, - inputTokens: input, - cacheReadInputTokens: cacheRead, - cacheCreationInputTokens: cacheCreate, - outputTokens: output, - modelsDevCatalog: modelsDevCatalog, - modelsDevCacheRoot: modelsDevCacheRoot) - // Cached costs are accumulated per request, which preserves Claude long-context threshold boundaries. - let cost = hasCompleteCachedCost ? Double(cachedCost) / costScale : currentPricingCost + let repricedCost = repricedCosts[ClaudeDayModelKey(day: day, model: model)] + let currentPricingCost: Double? = if let repricedCost, + repricedCost.sampleCount == sampleCount, + !repricedCost.unresolved + { + repricedCost.total + } else { + nil + } + let cost = currentPricingCost breakdown.append( CostUsageDailyReport.ModelBreakdown( modelName: model, diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+CodexFastJSON.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+CodexFastJSON.swift new file mode 100644 index 0000000000..9080839149 --- /dev/null +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+CodexFastJSON.swift @@ -0,0 +1,309 @@ +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 extractJSONByteStringFieldAllowingEmpty( + _ 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) + 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) + } + } + + static func extractJSONByteBoolField( + _ field: [UInt8], + from bytes: UnsafeBufferPointer, + in range: Range, + atDepth targetDepth: Int) -> Bool? + { + self.extractJSONByteField(field, from: bytes, in: range, atDepth: targetDepth) { valueIndex in + self.parseJSONByteBool(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 parseJSONByteBool( + in bytes: UnsafeBufferPointer, + index: inout Int, + limit: Int) -> Bool? + { + if index + 4 <= limit, + bytes[index] == 0x74, + bytes[index + 1] == 0x72, + bytes[index + 2] == 0x75, + bytes[index + 3] == 0x65 + { + index += 4 + return true + } + if index + 5 <= limit, + bytes[index] == 0x66, + bytes[index + 1] == 0x61, + bytes[index + 2] == 0x6C, + bytes[index + 3] == 0x73, + bytes[index + 4] == 0x65 + { + index += 5 + return false + } + return 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+CodexPriority.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+CodexPriority.swift index 9583a0c1c7..af24e5e88c 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+CodexPriority.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+CodexPriority.swift @@ -19,6 +19,116 @@ extension CostUsageScanner { .appendingPathComponent("logs_2.sqlite", isDirectory: false) } + #if canImport(SQLite3) + /// Accumulated priority-turn state for one trace database. The `logs` table uses an + /// `INTEGER PRIMARY KEY AUTOINCREMENT` id, so rowids are monotonic and never + /// reused. Codex prunes old rows in place, so source row IDs are retained and cheaply + /// revalidated before each incremental scan. + struct CodexPriorityTurnsMemoState { + var observationID: UInt64 + var coverageSinceEpoch: Int64 + var lastRowID: Int64 + var fileIdentity: UInt64? + var turns: [String: CodexPriorityTurnMetadata] + var requestSourcesByTurnID: [String: [Int64: CodexPriorityTurnMetadata]] + var priorityCompletedModelsByTurnID: [String: [Int64: String]] + var completedModelsByTurnID: [String: [Int64: String]] + var completedTurnIDInsertionOrder: [String] + var completedTurnIDInsertionOrderStartIndex: Int + } + + /// Completion models for known priority turns are retained with those turns. Completions + /// seen before their request are pending and may belong to non-priority turns, so that + /// separate map is bounded to keep memory constant while preserving ordering. + static let codexPriorityCompletedModelRetentionLimit = 4096 + + private final class CodexPriorityLockedState: @unchecked Sendable { + private let lock = NSLock() + private var state: State + + init(_ state: State) { + self.state = state + } + + func withLock(_ body: (inout State) throws -> Result) rethrows -> Result { + self.lock.lock() + defer { self.lock.unlock() } + return try body(&self.state) + } + } + + private static let codexPriorityTurnsMemo = + CodexPriorityLockedState<[String: CodexPriorityTurnsMemoState]>([:]) + private static let codexPriorityTurnsObservationCounter = CodexPriorityLockedState(0) + + private static func nextCodexPriorityTurnsObservationID() -> UInt64 { + self.codexPriorityTurnsObservationCounter.withLock { + $0 &+= 1 + return $0 + } + } + + /// Scans run outside the lock, so overlapping refreshes can write back out of order. + /// A monotonically increasing observation ID makes the later-started scan authoritative; + /// same-observation test snapshots still use coverage/cursor dominance. + static func storeCodexPriorityTurnsMemoIfNewer( + _ updated: CodexPriorityTurnsMemoState, + forPath path: String) + { + self.codexPriorityTurnsMemo.withLock { memo in + if let existing = memo[path], + existing.observationID > updated.observationID + { + return + } + if let existing = memo[path], + existing.observationID == updated.observationID, + existing.fileIdentity == updated.fileIdentity, + existing.coverageSinceEpoch <= updated.coverageSinceEpoch, + existing.lastRowID >= updated.lastRowID + { + return + } + memo[path] = updated + } + } + + static func _test_resetCodexPriorityTurnsMemo() { + self.codexPriorityTurnsMemo.withLock { $0.removeAll() } + self.codexPriorityTurnsObservationCounter.withLock { $0 = 0 } + } + + static func _test_codexPriorityTurnsMemoState(forPath path: String) -> CodexPriorityTurnsMemoState? { + self.codexPriorityTurnsMemo.withLock { $0[path] } + } + + static func _test_accumulateCodexPriorityTurns( + _ db: OpaquePointer?, + into state: inout CodexPriorityTurnsMemoState) -> Bool + { + self.accumulateCodexPriorityTurns(db, into: &state) + } + + static func _test_codexPriorityAccumulationQuery( + _ db: OpaquePointer?, + lastRowID: Int64, + coverageSinceEpoch: Int64) -> String + { + self.codexPriorityAccumulationPlan( + db, + lastRowID: lastRowID, + coverageSinceEpoch: coverageSinceEpoch).query + } + #endif + + /// Resolves priority turn metadata from the codex CLI trace database. The full-table + /// `LIKE` scan over `feedback_log_body` grows with the database (hundreds of megabytes on + /// active machines) and used to run on every refresh past the scan interval. For windows + /// that extend through today — every live refresh — the result is now accumulated per + /// database in process memory and only rows appended since the last call are examined; the + /// database shrinking or being replaced, or the requested window expanding earlier than + /// the accumulated coverage, triggers a full rescan. Windows that end before today keep + /// the original bounded one-shot query so historical lookups never pay an open-ended scan. static func codexPriorityTurns( databaseURL: URL? = nil, sinceDayKey: String? = nil, @@ -28,41 +138,136 @@ extension CostUsageScanner { guard FileManager.default.fileExists(atPath: url.path) else { return [:] } #if canImport(SQLite3) + if let untilDayKey, untilDayKey < CostUsageDayRange.dayKey(from: Date()) { + return self.boundedCodexPriorityTurns( + databaseURL: url, + sinceDayKey: sinceDayKey, + untilDayKey: untilDayKey) + } + + guard let opened = self.openCodexPriorityDatabase(at: url) else { return [:] } + let db = opened.db + let fileIdentity = opened.fileIdentity + defer { sqlite3_close(db) } + sqlite3_busy_timeout(db, 250) + + let observationID = self.nextCodexPriorityTurnsObservationID() + guard let maxRowID = self.maxCodexLogsRowID(db) else { return [:] } + + let requestedSinceEpoch: Int64 = if sinceDayKey != nil || untilDayKey != nil { + self.epochSeconds(forDayKey: sinceDayKey ?? "0000-01-01") ?? 0 + } else { + 0 + } + + var state = self.codexPriorityTurnsMemo.withLock { $0[url.path] } + if let memo = state, + maxRowID < memo.lastRowID + || requestedSinceEpoch < memo.coverageSinceEpoch + || memo.fileIdentity != fileIdentity + { + state = nil + } + var resolved = state ?? CodexPriorityTurnsMemoState( + observationID: observationID, + coverageSinceEpoch: requestedSinceEpoch, + lastRowID: 0, + fileIdentity: fileIdentity, + turns: [:], + requestSourcesByTurnID: [:], + priorityCompletedModelsByTurnID: [:], + completedModelsByTurnID: [:], + completedTurnIDInsertionOrder: [], + completedTurnIDInsertionOrderStartIndex: 0) + resolved.observationID = observationID + + var prunedDeletedSources = false + if state != nil { + var pruned = resolved + guard let didPrune = self.pruneDeletedCodexPrioritySources(db, from: &pruned) else { + return self.filteredResolvedCodexPriorityTurns( + resolved, + sinceDayKey: sinceDayKey, + untilDayKey: untilDayKey) + } + resolved = pruned + prunedDeletedSources = didPrune + } + + if maxRowID > resolved.lastRowID { + var updated = resolved + guard self.accumulateCodexPriorityTurns(db, into: &updated) else { + return self.filteredResolvedCodexPriorityTurns( + resolved, + sinceDayKey: sinceDayKey, + untilDayKey: untilDayKey) + } + updated.lastRowID = maxRowID + self.storeCodexPriorityTurnsMemoIfNewer(updated, forPath: url.path) + resolved = updated + } else if state == nil || prunedDeletedSources { + self.storeCodexPriorityTurnsMemoIfNewer(resolved, forPath: url.path) + } + + return self.filteredResolvedCodexPriorityTurns( + resolved, + sinceDayKey: sinceDayKey, + untilDayKey: untilDayKey) + #else + return [:] + #endif + } + + #if canImport(SQLite3) + private static func filteredResolvedCodexPriorityTurns( + _ state: CodexPriorityTurnsMemoState, + sinceDayKey: String?, + untilDayKey: String?) -> [String: CodexPriorityTurnMetadata] + { + var turns = state.turns + for (turnID, completedModels) in state.priorityCompletedModelsByTurnID { + turns[turnID]?.model = self.latestCodexCompletedModel(completedModels) + } + guard sinceDayKey != nil || untilDayKey != nil else { return turns } + return turns.filter { _, turn in + self.timestamp(turn.timestamp, isInRangeSince: sinceDayKey, until: untilDayKey) + } + } + + private static func latestCodexCompletedModel(_ modelsByRowID: [Int64: String]) -> String? { + modelsByRowID.max { $0.key < $1.key }?.value + } + + /// The pre-memo one-shot query, kept for windows that end before today: both `ts` bounds + /// stay in SQL, so a narrow historical window never scans the database tail. + private static func boundedCodexPriorityTurns( + databaseURL: URL, + sinceDayKey: String?, + untilDayKey: String?) -> [String: CodexPriorityTurnMetadata] + { var db: OpaquePointer? - guard sqlite3_open_v2(url.path, &db, SQLITE_OPEN_READONLY, nil) == SQLITE_OK else { + guard sqlite3_open_v2(databaseURL.path, &db, SQLITE_OPEN_READONLY, nil) == SQLITE_OK else { sqlite3_close(db) return [:] } defer { sqlite3_close(db) } sqlite3_busy_timeout(db, 250) - let query = if sinceDayKey != nil || untilDayKey != nil { - """ - select ts, feedback_log_body - from logs - where ts >= ? and ts < ? - and (feedback_log_body like '%websocket request:%' - or feedback_log_body like '%response.completed%') - """ - } else { - """ - select ts, feedback_log_body - from logs - where feedback_log_body like '%websocket request:%' - or feedback_log_body like '%response.completed%' - """ - } + let query = """ + select ts, feedback_log_body + from logs + where ts >= ? and ts < ? + and (feedback_log_body like '%websocket request:%' + or feedback_log_body like '%response.completed%') + """ var stmt: OpaquePointer? guard sqlite3_prepare_v2(db, query, -1, &stmt, nil) == SQLITE_OK else { return [:] } defer { sqlite3_finalize(stmt) } - - if sinceDayKey != nil || untilDayKey != nil { - let start = self.epochSeconds(forDayKey: sinceDayKey ?? "0000-01-01") ?? 0 - let end = self.epochSeconds(forDayKey: self.nextDayKey(after: untilDayKey ?? "9999-12-30")) - ?? Int64.max - sqlite3_bind_int64(stmt, 1, start) - sqlite3_bind_int64(stmt, 2, end) - } + let start = self.epochSeconds(forDayKey: sinceDayKey ?? "0000-01-01") ?? 0 + let end = self.epochSeconds(forDayKey: self.nextDayKey(after: untilDayKey ?? "9999-12-30")) + ?? Int64.max + sqlite3_bind_int64(stmt, 1, start) + sqlite3_bind_int64(stmt, 2, end) var turns: [String: CodexPriorityTurnMetadata] = [:] var completedModelsByTurnID: [String: String] = [:] @@ -87,11 +292,252 @@ extension CostUsageScanner { turns[parsed.turnID] = parsed } return turns - #else - return [:] - #endif } + private static func maxCodexLogsRowID(_ db: OpaquePointer?) -> Int64? { + var stmt: OpaquePointer? + guard sqlite3_prepare_v2(db, "select max(rowid) from logs", -1, &stmt, nil) == SQLITE_OK + else { return nil } + defer { sqlite3_finalize(stmt) } + guard sqlite3_step(stmt) == SQLITE_ROW else { return nil } + return sqlite3_column_int64(stmt, 0) + } + + static func openCodexPriorityDatabase( + at url: URL, + afterOpen: (() -> Void)? = nil) -> (db: OpaquePointer?, fileIdentity: UInt64)? + { + guard let fileIdentity = self.codexPriorityDatabaseFileIdentity(at: url) else { return nil } + var db: OpaquePointer? + guard sqlite3_open_v2(url.path, &db, SQLITE_OPEN_READONLY, nil) == SQLITE_OK else { + sqlite3_close(db) + return nil + } + afterOpen?() + guard self.codexPriorityDatabaseFileIdentity(at: url) == fileIdentity else { + sqlite3_close(db) + return nil + } + return (db, fileIdentity) + } + + private static func codexPriorityDatabaseFileIdentity(at url: URL) -> UInt64? { + (try? FileManager.default.attributesOfItem(atPath: url.path))?[.systemFileNumber] + .flatMap { $0 as? UInt64 } + } + + private static func pruneDeletedCodexPrioritySources( + _ db: OpaquePointer?, + from state: inout CodexPriorityTurnsMemoState) -> Bool? + { + let sourceRowIDs = state.requestSourcesByTurnID.values.flatMap(\.keys) + + state.priorityCompletedModelsByTurnID.values.flatMap(\.keys) + + state.completedModelsByTurnID.values.flatMap(\.keys) + guard let retainedRowIDs = self.retainedCodexPrioritySourceRowIDs(db, rowIDs: sourceRowIDs) else { + return nil + } + + var didPrune = false + for (turnID, sources) in state.requestSourcesByTurnID { + let retainedSources = sources.filter { retainedRowIDs.contains($0.key) } + guard retainedSources.count != sources.count else { continue } + didPrune = true + if retainedSources.isEmpty { + state.requestSourcesByTurnID.removeValue(forKey: turnID) + state.turns.removeValue(forKey: turnID) + if let completedModels = state.priorityCompletedModelsByTurnID.removeValue(forKey: turnID) { + self.storePendingCodexCompletedModels(completedModels, turnID: turnID, in: &state) + } + } else { + state.requestSourcesByTurnID[turnID] = retainedSources + state.turns[turnID] = retainedSources.max { $0.key < $1.key }?.value + } + } + + didPrune = self.pruneDeletedCodexCompletedModels( + retainedRowIDs: retainedRowIDs, + from: &state.priorityCompletedModelsByTurnID) || didPrune + didPrune = self.pruneDeletedCodexCompletedModels( + retainedRowIDs: retainedRowIDs, + from: &state.completedModelsByTurnID) || didPrune + self.compactCodexPendingCompletionOrderPrefix(in: &state) + state.completedTurnIDInsertionOrder.removeAll { state.completedModelsByTurnID[$0] == nil } + return didPrune + } + + private static func pruneDeletedCodexCompletedModels( + retainedRowIDs: Set, + from modelsByTurnID: inout [String: [Int64: String]]) -> Bool + { + var didPrune = false + for (turnID, modelsByRowID) in modelsByTurnID { + let retainedModels = modelsByRowID.filter { retainedRowIDs.contains($0.key) } + guard retainedModels.count != modelsByRowID.count else { continue } + didPrune = true + if retainedModels.isEmpty { + modelsByTurnID.removeValue(forKey: turnID) + } else { + modelsByTurnID[turnID] = retainedModels + } + } + return didPrune + } + + private static func retainedCodexPrioritySourceRowIDs( + _ db: OpaquePointer?, + rowIDs: [Int64]) -> Set? + { + guard !rowIDs.isEmpty else { return [] } + + var retained: Set = [] + let chunkSize = 500 + for start in stride(from: 0, to: rowIDs.count, by: chunkSize) { + let end = min(start + chunkSize, rowIDs.count) + let chunk = rowIDs[start.. self.codexPriorityCompletedModelRetentionLimit { + let evicted = state.completedTurnIDInsertionOrder[ + state.completedTurnIDInsertionOrderStartIndex, + ] + state.completedTurnIDInsertionOrderStartIndex += 1 + state.completedModelsByTurnID.removeValue(forKey: evicted) + if state.completedTurnIDInsertionOrderStartIndex + >= self.codexPriorityCompletedModelRetentionLimit + { + self.compactCodexPendingCompletionOrderPrefix(in: &state) + } + } + } + state.completedModelsByTurnID[turnID, default: [:]].merge(completedModels) { _, new in new } + } + + private static func compactCodexPendingCompletionOrderPrefix( + in state: inout CodexPriorityTurnsMemoState) + { + guard state.completedTurnIDInsertionOrderStartIndex > 0 else { return } + state.completedTurnIDInsertionOrder.removeFirst(state.completedTurnIDInsertionOrderStartIndex) + state.completedTurnIDInsertionOrderStartIndex = 0 + } + + private static func accumulateCodexPriorityTurns( + _ db: OpaquePointer?, + into state: inout CodexPriorityTurnsMemoState) -> Bool + { + let plan = self.codexPriorityAccumulationPlan( + db, + lastRowID: state.lastRowID, + coverageSinceEpoch: state.coverageSinceEpoch) + var stmt: OpaquePointer? + guard sqlite3_prepare_v2(db, plan.query, -1, &stmt, nil) == SQLITE_OK else { return false } + defer { sqlite3_finalize(stmt) } + if plan.usesTimestampIndex { + sqlite3_bind_int64(stmt, 1, state.coverageSinceEpoch) + } else { + sqlite3_bind_int64(stmt, 1, state.lastRowID) + sqlite3_bind_int64(stmt, 2, state.coverageSinceEpoch) + } + + while true { + let stepResult = sqlite3_step(stmt) + guard stepResult == SQLITE_ROW else { return stepResult == SQLITE_DONE } + let rowID = sqlite3_column_int64(stmt, 0) + let timestamp = self.timestamp(stmt: stmt, index: 1) + guard let body = self.text(stmt: stmt, index: 2) else { continue } + if let completed = self.parseCodexCompletedTraceRow(body: body) { + if state.turns[completed.turnID] != nil { + state.priorityCompletedModelsByTurnID[completed.turnID, default: [:]][rowID] = completed.model + } else { + self.storePendingCodexCompletedModels( + [rowID: completed.model], + turnID: completed.turnID, + in: &state) + } + continue + } + guard let parsed = self.parseCodexPriorityTraceRow(timestamp: timestamp, body: body) + else { continue } + state.turns[parsed.turnID] = parsed + state.requestSourcesByTurnID[parsed.turnID, default: [:]][rowID] = parsed + if let completedModels = state.completedModelsByTurnID.removeValue(forKey: parsed.turnID) { + self.compactCodexPendingCompletionOrderPrefix(in: &state) + state.completedTurnIDInsertionOrder.removeAll { $0 == parsed.turnID } + state.priorityCompletedModelsByTurnID[parsed.turnID] = completedModels + } + } + } + + private static func codexPriorityAccumulationPlan( + _ db: OpaquePointer?, + lastRowID: Int64, + coverageSinceEpoch: Int64) -> (query: String, usesTimestampIndex: Bool) + { + if lastRowID == 0, + coverageSinceEpoch > 0, + self.hasCodexLogsTimestampIndex(db) + { + return ( + """ + select rowid, ts, feedback_log_body + from logs indexed by idx_logs_ts + where ts >= ? + and (feedback_log_body like '%websocket request:%' + or feedback_log_body like '%response.completed%') + order by rowid + """, + true) + } + return ( + """ + select rowid, ts, feedback_log_body + from logs + where rowid > ? and ts >= ? + and (feedback_log_body like '%websocket request:%' + or feedback_log_body like '%response.completed%') + order by rowid + """, + false) + } + + private static func hasCodexLogsTimestampIndex(_ db: OpaquePointer?) -> Bool { + var stmt: OpaquePointer? + let query = """ + select 1 + from sqlite_master + where type = 'index' and tbl_name = 'logs' and name = 'idx_logs_ts' + limit 1 + """ + guard sqlite3_prepare_v2(db, query, -1, &stmt, nil) == SQLITE_OK else { return false } + defer { sqlite3_finalize(stmt) } + return sqlite3_step(stmt) == SQLITE_ROW + } + #endif + static func parseCodexPriorityTraceRow(timestamp: String?, body: String) -> CodexPriorityTurnMetadata? { guard let markerRange = body.range(of: self.requestMarker) else { return nil } let prefix = String(body[.. String { guard let date = self.localDate(forDayKey: dayKey), - let next = Calendar.current.date(byAdding: .day, value: 1, to: date) + let next = CostUsageScanner.CostUsageDayRange.localGregorianCalendar() + .date(byAdding: .day, value: 1, to: date) else { return dayKey } return CostUsageScanner.CostUsageDayRange.dayKey(from: next) } @@ -198,7 +645,7 @@ extension CostUsageScanner { let day = Int(parts[2]) else { return nil } var components = DateComponents() - components.calendar = Calendar.current + components.calendar = CostUsageScanner.CostUsageDayRange.localGregorianCalendar() components.year = year components.month = month components.day = day diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+CodexTruncatedPrefix.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+CodexTruncatedPrefix.swift index 2c2f7554d7..f736c387e5 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+CodexTruncatedPrefix.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+CodexTruncatedPrefix.swift @@ -1,23 +1,47 @@ import Foundation extension CostUsageScanner { - static func extractCodexTurnContextModel(from bytes: Data) -> String? { - guard let text = truncatedUTF8String(from: bytes) else { return nil } + static func extractCodexTruncatedSessionMetadata(from bytes: Data) -> + (isSessionMetadata: Bool, sessionID: String?) + { + guard let text = truncatedUTF8String(from: bytes) else { return (false, nil) } + let object = text[...] + guard Self.extractJSONStringField("type", from: object, atDepth: 1) == "session_meta" else { + return (false, nil) + } + guard let payloadText = Self.extractJSONObjectField("payload", from: object, atDepth: 1) else { + return (true, nil) + } + let sessionID = Self.extractJSONStringField("id", from: payloadText, atDepth: 1) + ?? Self.extractJSONStringField("session_id", from: payloadText, atDepth: 1) + ?? Self.extractJSONStringField("sessionId", from: payloadText, atDepth: 1) + return (true, sessionID) + } + + static func extractCodexTruncatedTurnContext(from bytes: Data) -> (isValid: Bool, model: String?) { + guard let text = truncatedUTF8String(from: bytes) else { return (false, nil) } let object = text[...] guard Self.extractJSONStringField("type", from: object, atDepth: 1) == "turn_context", + let timestamp = Self.extractJSONStringField("timestamp", from: object, atDepth: 1), + Self.dayKeyFromTimestamp(timestamp) ?? Self.dayKeyFromParsedISO(timestamp) != nil, let payloadText = Self.extractJSONObjectField("payload", from: object, atDepth: 1) - else { return nil } + else { return (false, nil) } - let payloadModel = Self.extractJSONStringField("model", from: payloadText, atDepth: 1) - ?? Self.extractJSONStringField("model_name", from: payloadText, atDepth: 1) - if let payloadModel { return payloadModel } - - guard let infoText = Self.extractJSONObjectField("info", from: payloadText, atDepth: 1) else { return nil } - return Self.extractJSONStringField("model", from: infoText, atDepth: 1) - ?? Self.extractJSONStringField("model_name", from: infoText, atDepth: 1) + let infoText = Self.extractJSONObjectField("info", from: payloadText, atDepth: 1) + let model = Self.codexTurnContextModel( + payloadModel: Self.extractJSONStringFieldAllowingEmpty("model", from: payloadText, atDepth: 1), + payloadModelName: Self.extractJSONStringFieldAllowingEmpty("model_name", from: payloadText, atDepth: 1), + infoModel: infoText.flatMap { + Self.extractJSONStringFieldAllowingEmpty("model", from: $0, atDepth: 1) + }, + infoModelName: infoText.flatMap { + Self.extractJSONStringFieldAllowingEmpty("model_name", from: $0, atDepth: 1) + }) + guard let model, model.isEmpty else { return (true, model) } + return (true, Self.isCompleteJSONObject(payloadText) ? "" : nil) } - 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 +51,34 @@ extension CostUsageScanner { return nil } - private static func extractJSONStringField( + static func isCompleteJSONObject(_ text: Substring) -> Bool { + guard text.first == "{" else { return false } + var index = text.startIndex + var depth = 0 + while index < text.endIndex { + switch text[index] { + case "{": + depth += 1 + text.formIndex(after: &index) + case "}": + depth -= 1 + text.formIndex(after: &index) + if depth == 0 { + return true + } + if depth < 0 { + return false + } + case "\"": + guard Self.parseJSONString(in: text, index: &index) != nil else { return false } + default: + text.formIndex(after: &index) + } + } + return false + } + + static func extractJSONStringField( _ field: String, from text: Substring, atDepth targetDepth: Int) -> String? @@ -39,7 +90,18 @@ extension CostUsageScanner { } } - private static func extractJSONObjectField( + static func extractJSONStringFieldAllowingEmpty( + _ field: String, + from text: Substring, + atDepth targetDepth: Int) -> String? + { + self.extractJSONField(field, from: text, atDepth: targetDepth) { text, index in + guard index < text.endIndex, text[index] == "\"" else { return nil } + return Self.parseJSONString(in: text, index: &index) + } + } + + static func extractJSONObjectField( _ field: String, from text: Substring, atDepth targetDepth: Int) -> Substring? @@ -50,7 +112,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 +161,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 +185,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+Projects.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Projects.swift new file mode 100644 index 0000000000..5e79e86c07 --- /dev/null +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Projects.swift @@ -0,0 +1,261 @@ +import Foundation + +extension CostUsageScanner { + static func codexCache(_ cache: CostUsageCache, scopedTo roots: [URL]) -> CostUsageCache { + var scoped = cache + scoped.files = cache.files.filter { filePath, _ in + Self.isWithinCodexRoots(fileURL: URL(fileURLWithPath: filePath), roots: roots) + } + scoped.days = [:] + for usage in scoped.files.values { + Self.applyFileDays(cache: &scoped, fileDays: usage.days, sign: 1) + } + return scoped + } + + static func buildCodexSessionBreakdownsFromCache( + cache: CostUsageCache, + range: CostUsageDayRange, + modelsDevCatalog: ModelsDevCatalog? = nil, + modelsDevCacheRoot: URL? = nil, + sessionRoots: [URL]? = nil, + priorityTurns: [String: CodexPriorityTurnMetadata] = [:], + modelsDevCatalogLoader: (URL?) -> ModelsDevCatalog? = { + CostUsagePricing.modelsDevCatalog(cacheRoot: $0) + }) -> [CostUsageSessionBreakdown] + { + let resolvedModelsDevCatalog = modelsDevCatalog + ?? modelsDevCatalogLoader(modelsDevCacheRoot) + ?? ModelsDevCatalog(providers: [:]) + var latestFileBySessionID: [String: (path: String, usage: CostUsageFileUsage)] = [:] + + for (filePath, usage) in cache.files { + if let sessionRoots, + !Self.isWithinCodexRoots(fileURL: URL(fileURLWithPath: filePath), roots: sessionRoots) + { + continue + } + guard usage.touchesCodexScanWindow(sinceKey: range.scanSinceKey, untilKey: range.scanUntilKey) else { + continue + } + let sessionID = usage.sessionId ?? URL(fileURLWithPath: filePath).deletingPathExtension().lastPathComponent + guard !sessionID.isEmpty else { continue } + if let existing = latestFileBySessionID[sessionID], existing.usage.mtimeUnixMs >= usage.mtimeUnixMs { + continue + } + latestFileBySessionID[sessionID] = (filePath, usage) + } + + return latestFileBySessionID.compactMap { sessionID, file in + var fileCache = CostUsageCache() + fileCache.files[file.path] = file.usage + fileCache.days = file.usage.days + let report = Self.buildCodexReportFromCache( + cache: fileCache, + range: range, + modelsDevCatalog: resolvedModelsDevCatalog, + priorityTurns: priorityTurns) + guard !report.data.isEmpty else { return nil } + + let summary = report.summary + let requestCounts = report.data.compactMap(\.requestCount) + return CostUsageSessionBreakdown( + sessionID: sessionID, + lastActivity: Date(timeIntervalSince1970: TimeInterval(file.usage.mtimeUnixMs) / 1000), + inputTokens: summary?.totalInputTokens, + cachedInputTokens: summary?.cacheReadTokens, + outputTokens: summary?.totalOutputTokens, + totalTokens: summary?.totalTokens, + requestCount: requestCounts.isEmpty ? nil : requestCounts.reduce(0, +), + costUSD: summary?.totalCostUSD, + modelBreakdowns: Self.codexProjectModelBreakdowns(from: report.data) ?? []) + } + .sorted { lhs, rhs in + if lhs.lastActivity != rhs.lastActivity { + return lhs.lastActivity > rhs.lastActivity + } + return lhs.sessionID > rhs.sessionID + } + } + + static func buildCodexProjectBreakdownsFromCache( + cache: CostUsageCache, + range: CostUsageDayRange, + modelsDevCatalog: ModelsDevCatalog? = nil, + modelsDevCacheRoot: URL? = nil, + priorityTurns: [String: CodexPriorityTurnMetadata] = [:], + modelsDevCatalogLoader: (URL?) -> ModelsDevCatalog? = { + CostUsagePricing.modelsDevCatalog(cacheRoot: $0) + }) -> [CostUsageProjectBreakdown] + { + // Project rollups build one report per cached session file. Resolve pricing once so every + // row does not fall back through ModelsDevCache.load and repeat filesystem metadata reads. + let resolvedModelsDevCatalog = modelsDevCatalog + ?? modelsDevCatalogLoader(modelsDevCacheRoot) + ?? ModelsDevCatalog(providers: [:]) + let projectPathResolver = CodexCanonicalProjectPathResolver() + var accumulatorsByProjectPath: [String: CodexProjectBreakdownAccumulator] = [:] + for (filePath, usage) in cache.files { + guard usage.touchesCodexScanWindow(sinceKey: range.scanSinceKey, untilKey: range.scanUntilKey) else { + continue + } + var fileCache = CostUsageCache() + fileCache.files[filePath] = usage + fileCache.days = usage.days + let report = Self.buildCodexReportFromCache( + cache: fileCache, + range: range, + modelsDevCatalog: resolvedModelsDevCatalog, + priorityTurns: priorityTurns) + guard !report.data.isEmpty else { continue } + let projectKey = usage.canonicalProjectPath + ?? projectPathResolver.canonicalProjectPath(for: usage.projectPath) + ?? "" + let sourceKey = usage.projectPath ?? "" + var accumulator = accumulatorsByProjectPath[projectKey] ?? CodexProjectBreakdownAccumulator() + accumulator.add(report: report, sourcePath: sourceKey) + accumulatorsByProjectPath[projectKey] = accumulator + } + + return accumulatorsByProjectPath.map { projectPath, accumulator in + let merged = CostUsageDailyReport.merged(accumulator.reports) + let resolvedPath = projectPath.isEmpty ? nil : projectPath + return CostUsageProjectBreakdown( + name: Self.codexProjectName(path: resolvedPath), + path: resolvedPath, + totalTokens: merged.summary?.totalTokens, + totalCostUSD: merged.summary?.totalCostUSD, + daily: merged.data, + modelBreakdowns: Self.codexProjectModelBreakdowns(from: merged.data), + sources: Self.codexProjectSourceBreakdowns(from: accumulator.reportsBySourcePath)) + } + .sorted { lhs, rhs in + let lhsCost = lhs.totalCostUSD ?? -1 + let rhsCost = rhs.totalCostUSD ?? -1 + if lhsCost != rhsCost { + return lhsCost > rhsCost + } + let lhsTokens = lhs.totalTokens ?? -1 + let rhsTokens = rhs.totalTokens ?? -1 + if lhsTokens != rhsTokens { + return lhsTokens > rhsTokens + } + return lhs.name.localizedStandardCompare(rhs.name) == .orderedAscending + } + } + + private static func codexProjectName(path: String?) -> String { + guard let path, !path.isEmpty else { return CostUsageProjectBreakdown.unknownProjectName } + let name = URL(fileURLWithPath: path, isDirectory: true).lastPathComponent + return name.isEmpty ? path : name + } + + private struct CodexProjectBreakdownAccumulator { + var reports: [CostUsageDailyReport] = [] + var reportsBySourcePath: [String: [CostUsageDailyReport]] = [:] + + mutating func add(report: CostUsageDailyReport, sourcePath: String) { + self.reports.append(report) + self.reportsBySourcePath[sourcePath, default: []].append(report) + } + } + + private static func codexProjectSourceBreakdowns( + from reportsBySourcePath: [String: [CostUsageDailyReport]]) -> [CostUsageProjectSourceBreakdown] + { + reportsBySourcePath.map { sourcePath, reports in + let merged = CostUsageDailyReport.merged(reports) + let resolvedPath = sourcePath.isEmpty ? nil : sourcePath + return CostUsageProjectSourceBreakdown( + name: Self.codexProjectName(path: resolvedPath), + path: resolvedPath, + totalTokens: merged.summary?.totalTokens, + totalCostUSD: merged.summary?.totalCostUSD, + daily: merged.data, + modelBreakdowns: Self.codexProjectModelBreakdowns(from: merged.data)) + } + .sorted { lhs, rhs in + let lhsCost = lhs.totalCostUSD ?? -1 + let rhsCost = rhs.totalCostUSD ?? -1 + if lhsCost != rhsCost { + return lhsCost > rhsCost + } + let lhsTokens = lhs.totalTokens ?? -1 + let rhsTokens = rhs.totalTokens ?? -1 + if lhsTokens != rhsTokens { + return lhsTokens > rhsTokens + } + return lhs.name.localizedStandardCompare(rhs.name) == .orderedAscending + } + } + + private struct ProjectBreakdownAccumulator { + var totalTokens = 0 + var sawTotalTokens = false + var costUSD: Double = 0 + var sawCost = false + var standardCostUSD: Double = 0 + var sawStandardCost = false + var priorityCostUSD: Double = 0 + var sawPriorityCost = false + var standardTokens = 0 + var sawStandardTokens = false + var priorityTokens = 0 + var sawPriorityTokens = false + + mutating func add(_ breakdown: CostUsageDailyReport.ModelBreakdown) { + if let totalTokens = breakdown.totalTokens { + self.totalTokens += totalTokens + self.sawTotalTokens = true + } + if let costUSD = breakdown.costUSD { + self.costUSD += costUSD + self.sawCost = true + } + if let standardCostUSD = breakdown.standardCostUSD { + self.standardCostUSD += standardCostUSD + self.sawStandardCost = true + } + if let priorityCostUSD = breakdown.priorityCostUSD { + self.priorityCostUSD += priorityCostUSD + self.sawPriorityCost = true + } + if let standardTokens = breakdown.standardTokens { + self.standardTokens += standardTokens + self.sawStandardTokens = true + } + if let priorityTokens = breakdown.priorityTokens { + self.priorityTokens += priorityTokens + self.sawPriorityTokens = true + } + } + + func build(modelName: String) -> CostUsageDailyReport.ModelBreakdown { + CostUsageDailyReport.ModelBreakdown( + modelName: modelName, + costUSD: self.sawCost ? self.costUSD : nil, + totalTokens: self.sawTotalTokens ? self.totalTokens : nil, + standardCostUSD: self.sawStandardCost ? self.standardCostUSD : nil, + priorityCostUSD: self.sawPriorityCost ? self.priorityCostUSD : nil, + standardTokens: self.sawStandardTokens ? self.standardTokens : nil, + priorityTokens: self.sawPriorityTokens ? self.priorityTokens : nil) + } + } + + private static func codexProjectModelBreakdowns( + from entries: [CostUsageDailyReport.Entry]) -> [CostUsageDailyReport.ModelBreakdown]? + { + var accumulators: [String: ProjectBreakdownAccumulator] = [:] + for entry in entries { + for breakdown in entry.modelBreakdowns ?? [] { + var accumulator = accumulators[breakdown.modelName] ?? ProjectBreakdownAccumulator() + accumulator.add(breakdown) + accumulators[breakdown.modelName] = accumulator + } + } + guard !accumulators.isEmpty else { return nil } + return Self.sortedModelBreakdowns(accumulators.map { modelName, accumulator in + accumulator.build(modelName: modelName) + }) + } +} diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Timestamp.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Timestamp.swift index b8bf32153c..ead9180405 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Timestamp.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Timestamp.swift @@ -30,7 +30,7 @@ extension CostUsageScanner { CostUsageTimestampParser.parseISO(text) } - static func dayKeyFromTimestamp(_ text: String) -> String? { + static func dayKeyFromTimestamp(_ text: String, calendar: Calendar = .current) -> String? { let bytes = Array(text.utf8) guard bytes.count >= 20 else { return nil } guard bytes[safe: 4] == 45, bytes[safe: 7] == 45 else { return nil } @@ -102,16 +102,17 @@ extension CostUsageScanner { comps.second = second guard let date = comps.date else { return nil } - let local = Calendar.current.dateComponents([.year, .month, .day], from: date) + let local = CostUsageDayRange.localGregorianCalendar(matching: calendar) + .dateComponents([.year, .month, .day], from: date) guard let localYear = local.year, let localMonth = local.month, let localDay = local.day else { return nil } return String(format: "%04d-%02d-%02d", localYear, localMonth, localDay) } - static func dayKeyFromParsedISO(_ text: String) -> String? { + static func dayKeyFromParsedISO(_ text: String, calendar: Calendar = .current) -> String? { guard let date = CostUsageTimestampParser.parseISO(text) else { return nil } - return CostUsageDayRange.dayKey(from: date) + return CostUsageDayRange.dayKey(from: date, calendar: calendar) } private static func parse2(_ bytes: [UInt8], at index: Int) -> Int? { diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift index 09e2014e7d..fe2677734a 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift @@ -3,15 +3,20 @@ import CryptoKit #else import Crypto #endif +import Dispatch import Foundation // swiftlint:disable type_body_length file_length enum CostUsageScanner { + static let codexProjectMetadataVersion = 1 typealias CancellationCheck = () throws -> Void static let log = CodexBarLog.logger(LogCategories.tokenCost) static let codexActiveSessionLookbackDays = 30 static let costScale = 1_000_000_000.0 + /// Reserved cache marker. Resolver-produced dependencies use `file|...` or `missing:...`; + /// this value records that lineage exists but this rollout owns its counter or suffix. + static let codexForkDependencyNotRequiredKey = "mode:lineage-only:v1" enum ClaudeLogProviderFilter { case all @@ -24,25 +29,83 @@ enum CostUsageScanner { var claudeProjectsRoots: [URL]? var cacheRoot: URL? var codexTraceDatabaseURL: URL? + var calendar: Calendar var refreshMinIntervalSeconds: TimeInterval = 60 var claudeLogProviderFilter: ClaudeLogProviderFilter = .all /// Force a full rescan, ignoring per-file cache and incremental offsets. var forceRescan: Bool = false + /// Maximum bounded slice read from one Codex rollout per refresh. Larger files + /// resume from cached progress on later refreshes. Default 256 MiB. + var maxCodexSessionFileBytes: Int64 = 256 * 1024 * 1024 + /// Soft budget for newly-read Codex session bytes in one refresh. + /// Remaining dirty files are deferred to later refreshes. Default 512 MiB. + var maxCodexScanBytesPerRefresh: Int64 = 512 * 1024 * 1024 + /// Prefer newest session files first so recent usage lands before catch-up work. + var preferNewestCodexSessionsFirst: Bool = true init( codexSessionsRoot: URL? = nil, claudeProjectsRoots: [URL]? = nil, cacheRoot: URL? = nil, codexTraceDatabaseURL: URL? = nil, + calendar: Calendar = .current, claudeLogProviderFilter: ClaudeLogProviderFilter = .all, - forceRescan: Bool = false) + forceRescan: Bool = false, + maxCodexSessionFileBytes: Int64 = 256 * 1024 * 1024, + maxCodexScanBytesPerRefresh: Int64 = 512 * 1024 * 1024, + preferNewestCodexSessionsFirst: Bool = true) { self.codexSessionsRoot = codexSessionsRoot self.claudeProjectsRoots = claudeProjectsRoots self.cacheRoot = cacheRoot self.codexTraceDatabaseURL = codexTraceDatabaseURL + self.calendar = calendar self.claudeLogProviderFilter = claudeLogProviderFilter self.forceRescan = forceRescan + self.maxCodexSessionFileBytes = max(0, maxCodexSessionFileBytes) + self.maxCodexScanBytesPerRefresh = max(0, maxCodexScanBytesPerRefresh) + self.preferNewestCodexSessionsFirst = preferNewestCodexSessionsFirst + } + } + + /// Per-refresh work limiter for Codex cost scans. Prevents multi-GB rollout corpora from + /// monopolizing a core for hours while still allowing progressive catch-up. + final class CodexScanBudget: @unchecked Sendable { + let maxFileBytes: Int64 + let maxBytesPerRefresh: Int64 + private(set) var bytesConsumed: Int64 = 0 + private(set) var resumedPartialFileCount = 0 + private(set) var deferredByBudgetFileCount = 0 + + init(maxFileBytes: Int64, maxBytesPerRefresh: Int64) { + self.maxFileBytes = max(0, maxFileBytes) + self.maxBytesPerRefresh = max(0, maxBytesPerRefresh) + } + + enum Admission { + case allow(Int64) + case deferBudget + } + + func admit(workBytes: Int64) -> Admission { + let work = max(0, workBytes) + let refreshRemaining = self.maxBytesPerRefresh > 0 + ? max(0, self.maxBytesPerRefresh - self.bytesConsumed) + : Int64.max + if work > 0, refreshRemaining == 0 { + self.deferredByBudgetFileCount += 1 + return .deferBudget + } + let fileAllowance = self.maxFileBytes > 0 ? self.maxFileBytes : Int64.max + let allowance = min(work, fileAllowance, refreshRemaining) + if allowance < work { + self.resumedPartialFileCount += 1 + } + return .allow(allowance) + } + + func consume(workBytes: Int64) { + self.bytesConsumed += max(0, workBytes) } } @@ -53,25 +116,84 @@ enum CostUsageScanner { let lastTotals: CostUsageCodexTotals? let lastCountedTotals: CostUsageCodexTotals? let lastRawTotalsBaseline: CostUsageCodexTotals? + let lastRawTotalsWatermark: CostUsageCodexTotals? + let seenRawTotals: [CostUsageCodexTotals] let hasDivergentTotals: Bool + let hasInterleavedTotals: Bool let lastCodexTurnID: String? let sessionId: String? let forkedFromId: String? + let dependsOnParentTotals: Bool + let projectPath: String? + let codexSession: CostUsageCodexSessionMetadata let rows: [CodexUsageRow] + let jsonlResumeState: CostUsageJsonl.ResumeState? + let bufferedSubagentLines: [CodexBufferedFastLine]? } struct CodexUsageRow: Codable, Equatable { let day: String let model: String + let rawModel: String? let turnID: String? + let eventIndex: Int? + let timestampUnixMs: Int64? let input: Int let cached: Int let output: Int + let reasoning: Int? + let knownCostNanos: Int64? + let unpricedTokens: Int? + let pricingModel: String? + let pricingMode: String? + + init( + day: String, + model: String, + rawModel: String? = nil, + turnID: String?, + eventIndex: Int?, + timestampUnixMs: Int64? = nil, + input: Int, + cached: Int, + output: Int, + reasoning: Int? = nil, + knownCostNanos: Int64? = nil, + unpricedTokens: Int? = nil, + pricingModel: String? = nil, + pricingMode: String? = nil) + { + self.day = day + self.model = model + self.rawModel = rawModel + self.turnID = turnID + self.eventIndex = eventIndex + self.timestampUnixMs = timestampUnixMs + self.input = input + self.cached = cached + self.output = output + self.reasoning = reasoning.map { min(max(0, $0), max(0, output)) } + self.knownCostNanos = knownCostNanos + self.unpricedTokens = unpricedTokens + self.pricingModel = pricingModel + self.pricingMode = pricingMode + } } struct CodexScanState { - var seenSessionIds: Set = [] + var contributingSessionIds: Set = [] var seenFileIds: Set = [] + var seenCodexUsageRowKeys: Set = [] + } + + struct CodexScannedSession { + let id: String? + let contributedUsage: Bool + + init(id: String?, days: [String: [String: [Int]]]) { + self.id = id + self.contributedUsage = !days.isEmpty + } } private struct CodexTimestampedTotals { @@ -116,7 +238,8 @@ enum CostUsageScanner { CostUsageCodexTotals( input: lhs.input + rhs.input, cached: lhs.cached + rhs.cached, - output: lhs.output + rhs.output) + output: lhs.output + rhs.output, + reasoning: self.codexAddOptional(lhs.reasoning, rhs.reasoning)) } private static func codexMinTotals( @@ -126,18 +249,24 @@ enum CostUsageScanner { CostUsageCodexTotals( input: min(lhs.input, rhs.input), cached: min(lhs.cached, rhs.cached), - output: min(lhs.output, rhs.output)) + output: min(lhs.output, rhs.output), + reasoning: self.codexMinOptional(lhs.reasoning, rhs.reasoning)) } private static func codexTotalDelta( from baseline: CostUsageCodexTotals?, to current: CostUsageCodexTotals) -> CostUsageCodexTotals { + let reasoning = Self.codexOptionalDelta( + from: baseline?.reasoning, + to: current.reasoning, + hasBaseline: baseline != nil) let baseline = baseline ?? .init(input: 0, cached: 0, output: 0) return CostUsageCodexTotals( input: max(0, current.input - baseline.input), cached: max(0, current.cached - baseline.cached), - output: max(0, current.output - baseline.output)) + output: max(0, current.output - baseline.output), + reasoning: reasoning) } private static func codexDivergentTotalDelta( @@ -158,12 +287,282 @@ enum CostUsageScanner { return CostUsageCodexTotals( input: delta(raw: rawBaseline.input, counted: countedBaseline.input, current: current.input), cached: delta(raw: rawBaseline.cached, counted: countedBaseline.cached, current: current.cached), - output: delta(raw: rawBaseline.output, counted: countedBaseline.output, current: current.output)) + output: delta(raw: rawBaseline.output, counted: countedBaseline.output, current: current.output), + reasoning: Self.codexDivergentOptionalDelta( + raw: rawBaseline.reasoning, + counted: countedBaseline.reasoning, + current: current.reasoning)) + } + + private static func codexMaxTotals( + _ lhs: CostUsageCodexTotals?, + _ rhs: CostUsageCodexTotals) -> CostUsageCodexTotals + { + guard let lhs else { return rhs } + return CostUsageCodexTotals( + input: max(lhs.input, rhs.input), + cached: max(lhs.cached, rhs.cached), + output: max(lhs.output, rhs.output), + reasoning: Self.codexMaxOptional(lhs.reasoning, rhs.reasoning)) + } + + /// Post-latch totals containment for interleaved cumulative counters (issue #2037 Phase 1). + /// + /// - When `current` is below the watermark, resume from the counted baseline so #968-style + /// recovery still works (`current - counted`). + /// - When `current` is at/above the watermark, advance from `max(watermark, counted)` so a + /// high/low lineage flip cannot re-count the gap between lineages. + private static func codexContainedTotalDelta( + watermark: CostUsageCodexTotals?, + counted: CostUsageCodexTotals?, + current: CostUsageCodexTotals) -> CostUsageCodexTotals + { + let watermark = watermark ?? .init(input: 0, cached: 0, output: 0) + let counted = counted ?? .init(input: 0, cached: 0, output: 0) + + func component(water: Int, counted: Int, current: Int) -> Int { + if current >= water { + return max(0, current - max(water, counted)) + } + return max(0, current - counted) + } + + return CostUsageCodexTotals( + input: component(water: watermark.input, counted: counted.input, current: current.input), + cached: component(water: watermark.cached, counted: counted.cached, current: current.cached), + output: component(water: watermark.output, counted: counted.output, current: current.output), + reasoning: Self.codexContainedOptionalDelta( + water: watermark.reasoning, + counted: counted.reasoning, + current: current.reasoning)) + } + + private static func codexAddOptional(_ lhs: Int?, _ rhs: Int?) -> Int? { + guard let lhs, let rhs else { return nil } + return lhs + rhs + } + + private static func codexMinOptional(_ lhs: Int?, _ rhs: Int?) -> Int? { + guard let lhs, let rhs else { return nil } + return min(lhs, rhs) + } + + private static func codexMaxOptional(_ lhs: Int?, _ rhs: Int?) -> Int? { + switch (lhs, rhs) { + case let (lhs?, rhs?): max(lhs, rhs) + case let (lhs?, nil): lhs + case let (nil, rhs?): rhs + case (nil, nil): nil + } + } + + private static func codexSubtractOptional(_ value: Int?, _ baseline: Int?) -> Int? { + guard let value, let baseline else { return nil } + return max(0, value - baseline) + } + + private static func codexOptionalDelta(from baseline: Int?, to current: Int?, hasBaseline: Bool) -> Int? { + guard let current else { return nil } + if !hasBaseline { return current } + guard let baseline else { return nil } + return max(0, current - baseline) + } + + private static func codexDivergentOptionalDelta(raw: Int?, counted: Int?, current: Int?) -> Int? { + guard let raw, let counted, let current else { return nil } + if current >= raw { + return max(0, current - raw) + } + return max(0, current - counted) + } + + private static func codexContainedOptionalDelta(water: Int?, counted: Int?, current: Int?) -> Int? { + guard let water, let counted, let current else { return nil } + if current >= water { + return max(0, current - max(water, counted)) + } + return max(0, current - counted) + } + + /// Post-latch event delta: contained totals growth, optionally capped by `last`. + /// + /// `last` alone must never increase counted usage when the contained totals delta is zero + /// (smaller lineage below the watermark is an accepted Phase 1 undercount). + private static func codexPostLatchEventDelta( + watermark: CostUsageCodexTotals?, + counted: CostUsageCodexTotals?, + current: CostUsageCodexTotals, + adjustedLast: CostUsageCodexTotals?) -> CostUsageCodexTotals + { + let contained = Self.codexContainedTotalDelta( + watermark: watermark, + counted: counted, + current: current) + guard let adjustedLast else { return contained } + return Self.codexMinTotals(adjustedLast, contained) + } + + /// Shared accounting guard for cumulative Codex token counters (issue #2037). + /// + /// Ultra-mode sessions interleave cumulative snapshots from several fork lineages inside one + /// session file. The tracker keeps a monotonic high watermark (never lowered). After a drop + /// latches interleaved mode, deltas use `codexPostLatchEventDelta` so gap recounting is + /// impossible. `seenRawTotals` is an optional precision optimization for exact re-emissions; + /// correctness does not depend on it once post-latch containment is active. + struct CodexTotalsTracker { + static let seenRawTotalsLimit = 64 + + private(set) var watermark: CostUsageCodexTotals? + private(set) var seenRawTotals: [CostUsageCodexTotals] + private(set) var sawInterleavedTotals: Bool + + init( + watermark: CostUsageCodexTotals? = nil, + seenRawTotals: [CostUsageCodexTotals] = [], + sawInterleavedTotals: Bool = false) + { + self.watermark = watermark + self.seenRawTotals = Array(seenRawTotals.suffix(Self.seenRawTotalsLimit)) + self.sawInterleavedTotals = sawInterleavedTotals + } + + func isSeen(_ totals: CostUsageCodexTotals) -> Bool { + self.seenRawTotals.contains { CostUsageScanner.codexTotalsEqual($0, totals) } + } + + /// Latches interleaved mode when any component of an observed cumulative snapshot drops + /// strictly below the watermark. A monotonic counter cannot decrease, so a drop means either + /// a second lineage or a reset; both must stop trusting gap-sized totals deltas. + mutating func latchIfBelowWatermark(_ totals: CostUsageCodexTotals) { + guard let watermark = self.watermark else { return } + if totals.input < watermark.input + || totals.cached < watermark.cached + || totals.output < watermark.output + { + self.sawInterleavedTotals = true + } + } + + /// Records an observed cumulative snapshot: raises the watermark and remembers the exact + /// value for best-effort re-emission suppression. Call after computing the event's delta. + mutating func commitObserved(_ totals: CostUsageCodexTotals) { + self.raiseWatermark(to: totals) + if !self.seenRawTotals.contains(where: { CostUsageScanner.codexTotalsEqual($0, totals) }) { + self.seenRawTotals.append(totals) + if self.seenRawTotals.count > Self.seenRawTotalsLimit { + self.seenRawTotals.removeFirst(self.seenRawTotals.count - Self.seenRawTotalsLimit) + } + } + } + + /// Raises the watermark for baseline assignments that are not observed raw snapshots + /// (for example counted totals in last-only streams). Never lowers it. + mutating func raiseWatermark(to totals: CostUsageCodexTotals) { + self.watermark = CostUsageScanner.codexMaxTotals(self.watermark, totals) + } + } + + /// Cumulative-totals accounting for parent-session snapshot building. Applies the same + /// containment policy as `parseCodexFileCancellable` so fork children inherit baselines + /// computed under identical rules. + private struct CodexSnapshotAccumulator { + var countedTotals: CostUsageCodexTotals? + var rawTotalsBaseline: CostUsageCodexTotals? + var sawDivergentTotals = false + var tracker = CodexTotalsTracker() + + /// Applies one token-count event and returns the counted cumulative totals afterwards. + mutating func apply( + last: CostUsageCodexTotals?, + total: CostUsageCodexTotals?) -> CostUsageCodexTotals + { + let hasReasoning = last?.reasoning != nil || total?.reasoning != nil + let base = self.countedTotals ?? .init( + input: 0, + cached: 0, + output: 0, + reasoning: hasReasoning ? 0 : nil) + if let total { + // Best-effort exact re-emission suppression (precision only; containment is load-bearing). + if self.tracker.isSeen(total) { + return base + } + self.tracker.latchIfBelowWatermark(total) + } + let watermarkBaseline = self.tracker.watermark ?? self.rawTotalsBaseline + defer { + if let total { + self.tracker.commitObserved(total) + } + } + + if let last { + var countedDelta = last + if let total { + if self.tracker.sawInterleavedTotals { + countedDelta = CostUsageScanner.codexPostLatchEventDelta( + watermark: watermarkBaseline, + counted: self.countedTotals, + current: total, + adjustedLast: last) + } else { + let totalDelta = CostUsageScanner.codexTotalDelta(from: watermarkBaseline, to: total) + if CostUsageScanner.codexShouldPreferTotalDelta( + rawBaseline: watermarkBaseline, + currentTotal: total, + totalDelta: totalDelta, + lastDelta: last, + sawDivergentTotals: self.sawDivergentTotals) + { + countedDelta = totalDelta + } + } + let next = CostUsageScanner.codexAddTotals(base, countedDelta) + self.countedTotals = next + self.rawTotalsBaseline = total + if !CostUsageScanner.codexTotalsEqual(total, next) { + self.sawDivergentTotals = true + } + return next + } + let next = CostUsageScanner.codexAddTotals(base, countedDelta) + self.countedTotals = next + self.rawTotalsBaseline = next + self.tracker.raiseWatermark(to: next) + return next + } + + if let total { + let delta: CostUsageCodexTotals = if self.tracker.sawInterleavedTotals { + CostUsageScanner.codexContainedTotalDelta( + watermark: watermarkBaseline, + counted: self.countedTotals, + current: total) + } else if self.sawDivergentTotals { + CostUsageScanner.codexDivergentTotalDelta( + rawBaseline: watermarkBaseline, + countedBaseline: self.countedTotals, + current: total) + } else { + CostUsageScanner.codexTotalDelta(from: watermarkBaseline, to: total) + } + let counted = CostUsageScanner.codexAddTotals(base, delta) + self.countedTotals = counted + self.rawTotalsBaseline = total + if !CostUsageScanner.codexTotalsEqual(total, counted) { + self.sawDivergentTotals = true + } + return counted + } + + return base + } } struct CodexScanResources { let fileIndex: CodexSessionFileIndex let inheritedResolver: CodexInheritedTotalsResolver + let projectPathResolver: CodexCanonicalProjectPathResolver let modelsDevCatalog: ModelsDevCatalog? let modelsDevCacheRoot: URL? let priorityTurns: [String: CodexPriorityTurnMetadata] @@ -177,6 +576,95 @@ enum CostUsageScanner { let changedPriorityTurnIDs: Set let resources: CodexScanResources let checkCancellation: CancellationCheck? + let scanBudget: CodexScanBudget? + } + + final class CodexCanonicalProjectPathResolver { + private var cache: [String: String] = [:] + private let homeCodexWorktreesPrefix: String + + init(homeDirectory: URL = FileManager.default.homeDirectoryForCurrentUser) { + self.homeCodexWorktreesPrefix = homeDirectory + .appendingPathComponent(".codex/worktrees", isDirectory: true) + .standardizedFileURL + .path + } + + func canonicalProjectPath(for projectPath: String?) -> String? { + guard let projectPath else { return nil } + if let cached = self.cache[projectPath] { + return cached + } + let resolved = self.resolveCanonicalProjectPath(projectPath) ?? projectPath + self.cache[projectPath] = resolved + return resolved + } + + private func resolveCanonicalProjectPath(_ projectPath: String) -> String? { + var isDirectory: ObjCBool = false + guard FileManager.default.fileExists(atPath: projectPath, isDirectory: &isDirectory), + isDirectory.boolValue + else { return nil } + guard let output = self.gitWorktreeList(projectPath: projectPath) else { return nil } + let worktrees = output + .split(separator: "\n") + .compactMap { line -> String? in + guard line.hasPrefix("worktree ") else { return nil } + let rawPath = line.dropFirst("worktree ".count) + return Self.standardizedAbsolutePath(String(rawPath)) + } + guard !worktrees.isEmpty else { return nil } + return worktrees.first { !self.isEphemeralWorktreePath($0) } + } + + private func gitWorktreeList(projectPath: String) -> String? { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/env") + process.arguments = ["git", "-C", projectPath, "worktree", "list", "--porcelain"] + + let outputPipe = Pipe() + let errorPipe = Pipe() + process.standardOutput = outputPipe + process.standardError = errorPipe + let outputCapture = ProcessPipeCapture(pipe: outputPipe) + let errorCapture = ProcessPipeCapture(pipe: errorPipe) + + let semaphore = DispatchSemaphore(value: 0) + process.terminationHandler = { _ in semaphore.signal() } + do { + try process.run() + } catch { + return nil + } + outputCapture.start() + errorCapture.start() + + if semaphore.wait(timeout: .now() + .seconds(1)) == .timedOut { + process.terminate() + outputCapture.stop() + errorCapture.stop() + return nil + } + let data = outputCapture.finishSynchronously(timeout: 0.1) + errorCapture.stop() + guard process.terminationStatus == 0 else { return nil } + return String(data: data, encoding: .utf8) + } + + private func isEphemeralWorktreePath(_ path: String) -> Bool { + path == self.homeCodexWorktreesPrefix + || path.hasPrefix(self.homeCodexWorktreesPrefix + "/") + || path.hasSuffix("/.codex/worktrees") + || path.contains("/.codex/worktrees/") + || path == "/private/tmp" + || path.hasPrefix("/private/tmp/") + } + + private static func standardizedAbsolutePath(_ path: String) -> String? { + let expanded = (path as NSString).expandingTildeInPath + guard expanded.hasPrefix("/") else { return nil } + return URL(fileURLWithPath: expanded, isDirectory: true).standardizedFileURL.path + } } struct CodexRefreshPlan { @@ -186,6 +674,7 @@ enum CostUsageScanner { let rootsChanged: Bool let windowExpanded: Bool let needsCostCacheMigration: Bool + let needsProjectMetadataMigration: Bool let modelsDevCatalog: ModelsDevCatalog? let codexPricingKey: String let codexPriorityMetadataKey: String @@ -292,13 +781,24 @@ enum CostUsageScanner { } final class CodexInheritedTotalsResolver { + private struct SnapshotResolution { + let dependencyKey: String? + let snapshots: [CodexTimestampedTotals]? + } + private let fileIndex: CodexSessionFileIndex private let checkCancellation: CancellationCheck? - private var snapshotsBySessionId: [String: [CodexTimestampedTotals]] = [:] + private let scanBudget: CodexScanBudget? + private var snapshotResolutions: [String: SnapshotResolution] = [:] - init(fileIndex: CodexSessionFileIndex, checkCancellation: CancellationCheck?) { + init( + fileIndex: CodexSessionFileIndex, + checkCancellation: CancellationCheck?, + scanBudget: CodexScanBudget? = nil) + { self.fileIndex = fileIndex self.checkCancellation = checkCancellation + self.scanBudget = scanBudget } func inheritedTotals(for sessionId: String, atOrBefore cutoffTimestamp: String) throws -> CodexForkBaseline { @@ -314,7 +814,7 @@ enum CostUsageScanner { "Codex cost usage could not parse fork timestamp; falling back to lexical comparison", metadata: ["sessionId": sessionId, "timestamp": cutoffTimestamp]) } - guard let snapshots = try self.snapshots(for: sessionId) else { return .unresolved } + guard let snapshots = try self.snapshotResolution(for: sessionId).snapshots else { return .unresolved } var inherited: CostUsageCodexTotals? for snapshot in snapshots { let isAtOrBefore: Bool = if let snapshotDate = snapshot.date, let cutoffDate { @@ -329,8 +829,31 @@ enum CostUsageScanner { return .resolved(inherited) } - private func snapshots(for sessionId: String) throws -> [CodexTimestampedTotals]? { - if let cached = self.snapshotsBySessionId[sessionId] { + func currentDependencyKey(for sessionId: String) throws -> String { + guard let fileURL = try self.fileIndex.fileURL(for: sessionId) else { + return "missing:\(sessionId)" + } + return self.dependencyKey(for: sessionId, fileURL: fileURL) + } + + func dependencyKeyUsed(for sessionId: String) -> String? { + self.snapshotResolutions[sessionId]?.dependencyKey + } + + private func dependencyKey(for sessionId: String, fileURL: URL) -> String { + let metadata = CostUsageScanner.codexFileMetadata(fileURL: fileURL) + return [ + "file", + sessionId, + fileURL.standardizedFileURL.path, + metadata.fileId ?? "unknown", + String(metadata.mtimeUnixMs), + String(metadata.size), + ].joined(separator: "|") + } + + private func snapshotResolution(for sessionId: String) throws -> SnapshotResolution { + if let cached = self.snapshotResolutions[sessionId] { return cached } try self.checkCancellation?() @@ -338,29 +861,98 @@ enum CostUsageScanner { CostUsageScanner.log.warning( "Codex cost usage parent session file not found", metadata: ["sessionId": sessionId]) - return nil + let resolution = SnapshotResolution( + dependencyKey: "missing:\(sessionId)", + snapshots: nil) + self.snapshotResolutions[sessionId] = resolution + return resolution } - let parsed = try CostUsageScanner.parseCodexTokenSnapshots( - fileURL: fileURL, - checkCancellation: self.checkCancellation) - guard let parsedSessionId = parsed.sessionId else { - CostUsageScanner.log.warning( - "Codex cost usage parent session missing session metadata", - metadata: ["sessionId": sessionId, "path": fileURL.path]) - return nil + + let parentMetadata = CostUsageScanner.codexFileMetadata(fileURL: fileURL) + if let budget = self.scanBudget { + switch budget.admit(workBytes: parentMetadata.size) { + case let .allow(allowance) where allowance >= parentMetadata.size: + break + case .allow: + CostUsageScanner.log.warning( + "Deferring oversized Codex parent baseline read while its file scan resumes", + metadata: [ + "sessionId": sessionId, + "path": fileURL.path, + "bytes": "\(parentMetadata.size)", + "slice": "\(budget.maxFileBytes)", + ]) + let resolution = SnapshotResolution( + dependencyKey: self.dependencyKey(for: sessionId, fileURL: fileURL), + snapshots: nil) + self.snapshotResolutions[sessionId] = resolution + return resolution + case .deferBudget: + CostUsageScanner.log.debug( + "Deferring Codex parent session baseline read until a later refresh", + metadata: [ + "sessionId": sessionId, + "path": fileURL.path, + "pendingBytes": "\(parentMetadata.size)", + "consumed": "\(budget.bytesConsumed)", + "limit": "\(budget.maxBytesPerRefresh)", + ]) + let resolution = SnapshotResolution( + dependencyKey: self.dependencyKey(for: sessionId, fileURL: fileURL), + snapshots: nil) + self.snapshotResolutions[sessionId] = resolution + return resolution + } } - if parsedSessionId != sessionId { - CostUsageScanner.log.warning( - "Codex cost usage parent session resolved to mismatched session id", - metadata: [ - "requestedSessionId": sessionId, - "resolvedSessionId": parsedSessionId, - "path": fileURL.path, - ]) - return nil + + for _ in 0..<2 { + let dependencyKeyBeforeParse = self.dependencyKey(for: sessionId, fileURL: fileURL) + let parsed = try CostUsageScanner.parseCodexTokenSnapshots( + fileURL: fileURL, + checkCancellation: self.checkCancellation) + let dependencyKeyAfterParse = self.dependencyKey(for: sessionId, fileURL: fileURL) + guard dependencyKeyBeforeParse == dependencyKeyAfterParse else { continue } + + guard let parsedSessionId = parsed.sessionId else { + CostUsageScanner.log.warning( + "Codex cost usage parent session missing session metadata", + metadata: ["sessionId": sessionId, "path": fileURL.path]) + let resolution = SnapshotResolution( + dependencyKey: dependencyKeyAfterParse, + snapshots: nil) + self.snapshotResolutions[sessionId] = resolution + self.scanBudget?.consume(workBytes: parentMetadata.size) + return resolution + } + if parsedSessionId != sessionId { + CostUsageScanner.log.warning( + "Codex cost usage parent session resolved to mismatched session id", + metadata: [ + "requestedSessionId": sessionId, + "resolvedSessionId": parsedSessionId, + "path": fileURL.path, + ]) + let resolution = SnapshotResolution( + dependencyKey: dependencyKeyAfterParse, + snapshots: nil) + self.snapshotResolutions[sessionId] = resolution + self.scanBudget?.consume(workBytes: parentMetadata.size) + return resolution + } + let resolution = SnapshotResolution( + dependencyKey: dependencyKeyAfterParse, + snapshots: parsed.snapshots) + self.snapshotResolutions[sessionId] = resolution + self.scanBudget?.consume(workBytes: parentMetadata.size) + return resolution } - self.snapshotsBySessionId[sessionId] = parsed.snapshots - return parsed.snapshots + + CostUsageScanner.log.warning( + "Codex cost usage parent session changed while reading; deferring inherited baseline", + metadata: ["sessionId": sessionId, "path": fileURL.path]) + let resolution = SnapshotResolution(dependencyKey: nil, snapshots: nil) + self.snapshotResolutions[sessionId] = resolution + return resolution } } @@ -381,11 +973,13 @@ enum CostUsageScanner { let sessionId: String? let messageId: String? let requestId: String? + let timestampUnixMs: Int64? let isSidechain: Bool let pathRole: ClaudePathRole let input: Int let cacheRead: Int let cacheCreate: Int + let cacheCreate1h: Int? let output: Int let costNanos: Int let costPriced: Bool? @@ -416,7 +1010,7 @@ enum CostUsageScanner { options: Options = Options(), checkCancellation: CancellationCheck?) throws -> CostUsageDailyReport { - let range = CostUsageDayRange(since: since, until: until) + let range = CostUsageDayRange(since: since, until: until, calendar: options.calendar) let emptyReport = CostUsageDailyReport(data: [], summary: nil) try checkCancellation?() @@ -445,12 +1039,13 @@ enum CostUsageScanner { now: now, options: filtered, checkCancellation: checkCancellation) - case .openai, .azureopenai, .zai, .gemini, .antigravity, .cursor, .opencode, .opencodego, .alibaba, - .alibabatokenplan, .factory, - .copilot, .minimax, .manus, .kilo, .kiro, .kimi, .kimik2, .moonshot, .augment, .jetbrains, .amp, .ollama, - .t3chat, .synthetic, .openrouter, .elevenlabs, .warp, .perplexity, .mimo, .doubao, .abacus, .mistral, - .deepseek, .codebuff, .crof, .windsurf, .venice, .commandcode, .stepfun, .bedrock, .grok, .groq, - .llmproxy, .deepgram: + case .openai, .azureopenai, .clinepass, .zai, .gemini, .antigravity, .cursor, .opencode, .opencodego, .alibaba, + .alibabatokenplan, .qwencloud, .factory, + .copilot, .devin, .minimax, .manus, .kilo, .kiro, .kimi, .moonshot, .augment, .jetbrains, .amp, + .ollama, .t3chat, .synthetic, .openrouter, .elevenlabs, .warp, .perplexity, .mimo, .doubao, .sakana, + .abacus, .mistral, .deepseek, .deepinfra, .codebuff, .crof, .windsurf, .zed, .venice, .commandcode, + .qoder, .stepfun, .bedrock, .grok, .groq, .llmproxy, .litellm, .deepgram, .poe, .chutes, .neuralwatt, + .clawrouter, .longcat, .sub2api, .wayfinder, .zenmux, .aiand, .zoommate, .xai: return emptyReport } } @@ -462,26 +1057,34 @@ enum CostUsageScanner { let untilKey: String let scanSinceKey: String let scanUntilKey: String + let calendar: Calendar + + init(since: Date, until: Date, calendar: Calendar = .current) { + let calendar = Self.localGregorianCalendar(matching: calendar) + self.calendar = calendar + self.sinceKey = Self.dayKey(from: since, calendar: calendar) + self.untilKey = Self.dayKey(from: until, calendar: calendar) + let scanSince = calendar.date(byAdding: .day, value: -1, to: since) ?? since + let scanUntil = calendar.date(byAdding: .day, value: 1, to: until) ?? until + self.scanSinceKey = Self.dayKey(from: scanSince, calendar: calendar) + self.scanUntilKey = Self.dayKey(from: scanUntil, calendar: calendar) + } - init(since: Date, until: Date) { - self.sinceKey = Self.dayKey(from: since) - self.untilKey = Self.dayKey(from: until) - self.scanSinceKey = Self.dayKey(from: Calendar.current.date(byAdding: .day, value: -1, to: since) ?? since) - self.scanUntilKey = Self.dayKey(from: Calendar.current.date(byAdding: .day, value: 1, to: until) ?? until) + static func localGregorianCalendar(matching calendar: Calendar = .current) -> Calendar { + CostUsageLocalDay.gregorianCalendar(matching: calendar) } - static func dayKey(from date: Date) -> String { - let cal = Calendar.current - let comps = cal.dateComponents([.year, .month, .day], from: date) - let y = comps.year ?? 1970 - let m = comps.month ?? 1 - let d = comps.day ?? 1 - return String(format: "%04d-%02d-%02d", y, m, d) + static func dayKey(from date: Date, calendar: Calendar = .current) -> String { + CostUsageLocalDay.key(from: date, calendar: calendar) } static func isInRange(dayKey: String, since: String, until: String) -> Bool { - if dayKey < since { return false } - if dayKey > until { return false } + if dayKey < since { + return false + } + if dayKey > until { + return false + } return true } } @@ -489,7 +1092,9 @@ enum CostUsageScanner { // MARK: - Codex private static func defaultCodexSessionsRoot(options: Options) -> URL { - if let override = options.codexSessionsRoot { return override } + if let override = options.codexSessionsRoot { + return override + } let env = ProcessInfo.processInfo.environment["CODEX_HOME"]?.trimmingCharacters(in: .whitespacesAndNewlines) if let env, !env.isEmpty { return URL(fileURLWithPath: env).appendingPathComponent("sessions", isDirectory: true) @@ -499,7 +1104,7 @@ enum CostUsageScanner { .appendingPathComponent("sessions", isDirectory: true) } - private static func codexSessionsRoots(options: Options) -> [URL] { + static func codexSessionsRoots(options: Options) -> [URL] { let root = self.defaultCodexSessionsRoot(options: options) if let archived = self.codexArchivedSessionsRoot(sessionsRoot: root) { return [root, archived] @@ -518,12 +1123,14 @@ enum CostUsageScanner { root: URL, scanSinceKey: String, scanUntilKey: String, - includeRecursive: Bool) -> [URL] + includeRecursive: Bool, + calendar: Calendar = .current) -> [URL] { let partitioned = self.listCodexSessionFilesByDatePartition( root: root, scanSinceKey: scanSinceKey, - scanUntilKey: scanUntilKey) + scanUntilKey: scanUntilKey, + calendar: calendar) let flat = self.listCodexSessionFilesFlat(root: root, scanSinceKey: scanSinceKey, scanUntilKey: scanUntilKey) let recursive = includeRecursive ? self.listCodexLegacySessionFilesRecursive(root: root) : [] var seen: Set = [] @@ -538,9 +1145,11 @@ enum CostUsageScanner { private static func cachedCodexSessionFiles( cache: CostUsageCache, range: CostUsageDayRange, - roots: [URL]) -> [URL] + roots: [URL], + excludingPaths: Set) -> [URL] { cache.files.compactMap { path, usage in + guard !excludingPaths.contains(path) else { return nil } let hasRelevantDay = usage.days.keys.contains { CostUsageDayRange.isInRange(dayKey: $0, since: range.scanSinceKey, until: range.scanUntilKey) } @@ -552,10 +1161,18 @@ enum CostUsageScanner { } } - private static func cachedCodexSessionIndex(cache: CostUsageCache, roots: [URL]) -> [String: URL] { + private static func cachedCodexSessionIndex( + cache: CostUsageCache, + roots: [URL], + knownExistingPaths: Set) -> [String: URL] + { var out: [String: URL] = [:] for (path, usage) in cache.files { guard let sessionId = usage.sessionId, !sessionId.isEmpty else { continue } + if knownExistingPaths.contains(path) { + out[sessionId] = URL(fileURLWithPath: path) + continue + } guard FileManager.default.fileExists(atPath: path) else { continue } let fileURL = URL(fileURLWithPath: path) guard Self.isWithinCodexRoots(fileURL: fileURL, roots: roots) else { continue } @@ -572,45 +1189,19 @@ enum CostUsageScanner { return out } + static func codexRootsFingerprint(options: Options) -> [String: Int64] { + self.codexRootsFingerprint(self.codexSessionsRoots(options: options)) + } + + /// Bump when the cost FORMULA changes (not the rates) so caches written by an older formula + /// are invalidated and repriced. The pricing fingerprints below only capture rate constants, + /// so formula-only fixes would otherwise reuse stale precomputed costs. + private static let codexCostFormulaVersion = 2 + private static func codexPricingKey(modelsDevArtifact: ModelsDevCacheArtifact?) -> String { - guard let modelsDevArtifact else { - let fingerprint = CostUsagePricing.codexBuiltInPricingFingerprint() - return "builtin-\(Self.sha256Hex(Data(fingerprint.utf8)))" - } - let fingerprint = self.modelsDevPricingFingerprint(modelsDevArtifact.catalog) - return "models-dev-v\(modelsDevArtifact.version)-\(Self.sha256Hex(Data(fingerprint.utf8)))" - } - - private static func modelsDevPricingFingerprint(_ catalog: ModelsDevCatalog) -> String { - var parts: [String] = [] - for providerID in catalog.providers.keys.sorted() { - guard let provider = catalog.providers[providerID] else { continue } - parts.append("provider=\(providerID)|\(provider.id ?? "")") - for modelKey in provider.models.keys.sorted() { - guard let model = provider.models[modelKey] else { continue } - let cost = model.cost - let contextOver200K = cost?.contextOver200K - parts.append([ - "model=\(modelKey)", - model.id, - Self.optionalDoubleFingerprint(cost?.input), - Self.optionalDoubleFingerprint(cost?.output), - Self.optionalDoubleFingerprint(cost?.cacheRead), - Self.optionalDoubleFingerprint(cost?.cacheWrite), - Self.optionalDoubleFingerprint(contextOver200K?.input), - Self.optionalDoubleFingerprint(contextOver200K?.output), - Self.optionalDoubleFingerprint(contextOver200K?.cacheRead), - Self.optionalDoubleFingerprint(contextOver200K?.cacheWrite), - model.limit?.context.map(String.init) ?? "nil", - ].joined(separator: "|")) - } - } - return parts.joined(separator: "\n") - } - - private static func optionalDoubleFingerprint(_ value: Double?) -> String { - guard let value else { return "nil" } - return String(format: "%.17g", value) + CostUsagePricingKey.codex( + modelsDevArtifact: modelsDevArtifact, + formulaVersion: self.codexCostFormulaVersion) } private static func codexPriorityMetadataKey(databaseURL: URL?) -> String { @@ -625,11 +1216,12 @@ enum CostUsageScanner { } private static func codexPriorityTurnKeys( - _ priorityTurns: [String: CodexPriorityTurnMetadata]) -> [String: String] + _ priorityTurns: [String: CodexPriorityTurnMetadata], + calendar: Calendar) -> [String: String] { var partsByDay: [String: [String]] = [:] for (turnID, turn) in priorityTurns { - guard let dayKey = self.codexPriorityDayKey(turn) else { continue } + guard let dayKey = self.codexPriorityDayKey(turn, calendar: calendar) else { continue } partsByDay[dayKey, default: []].append([ turnID, turn.model ?? "", @@ -645,22 +1237,30 @@ enum CostUsageScanner { } private static func codexPriorityTurnIDsByDay( - _ priorityTurns: [String: CodexPriorityTurnMetadata]) -> [String: [String]] + _ priorityTurns: [String: CodexPriorityTurnMetadata], + calendar: Calendar) -> [String: [String]] { var out: [String: Set] = [:] for (turnID, turn) in priorityTurns { - guard let dayKey = self.codexPriorityDayKey(turn) else { continue } + guard let dayKey = self.codexPriorityDayKey(turn, calendar: calendar) else { continue } out[dayKey, default: []].insert(turnID) } return out.mapValues { $0.sorted() } } - private static func codexPriorityDayKey(_ turn: CodexPriorityTurnMetadata) -> String? { + private static func codexPriorityDayKey( + _ turn: CodexPriorityTurnMetadata, + calendar: Calendar) -> String? + { guard let timestamp = turn.timestamp else { return nil } let dayKeyFromEpoch = Int64(timestamp).map { - CostUsageDayRange.dayKey(from: Date(timeIntervalSince1970: TimeInterval($0))) + CostUsageDayRange.dayKey( + from: Date(timeIntervalSince1970: TimeInterval($0)), + calendar: calendar) } - return dayKeyFromEpoch ?? self.dayKeyFromTimestamp(timestamp) ?? self.dayKeyFromParsedISO(timestamp) + return dayKeyFromEpoch + ?? self.dayKeyFromTimestamp(timestamp, calendar: calendar) + ?? self.dayKeyFromParsedISO(timestamp, calendar: calendar) } private static func codexPriorityTurnKeysChanged( @@ -668,7 +1268,10 @@ enum CostUsageScanner { new: [String: String], range: CostUsageDayRange) -> Bool { - for dayKey in self.dayKeys(sinceKey: range.scanSinceKey, untilKey: range.scanUntilKey) + for dayKey in self.dayKeys( + sinceKey: range.scanSinceKey, + untilKey: range.scanUntilKey, + calendar: range.calendar) where old?[dayKey] != new[dayKey] { return true @@ -684,7 +1287,11 @@ enum CostUsageScanner { range: CostUsageDayRange) -> Set { var out = Set() - for dayKey in self.dayKeys(sinceKey: range.scanSinceKey, untilKey: range.scanUntilKey) { + for dayKey in self.dayKeys( + sinceKey: range.scanSinceKey, + untilKey: range.scanUntilKey, + calendar: range.calendar) + { let oldIDs = Set(old?[dayKey] ?? []) let newIDs = Set(new[dayKey] ?? []) if oldIDs != newIDs || oldKeys?[dayKey] != newKeys[dayKey] { @@ -703,7 +1310,11 @@ enum CostUsageScanner { retainedUntilKey: String) -> [String: String]? { var out = existing ?? [:] - for dayKey in self.dayKeys(sinceKey: range.scanSinceKey, untilKey: range.scanUntilKey) { + for dayKey in self.dayKeys( + sinceKey: range.scanSinceKey, + untilKey: range.scanUntilKey, + calendar: range.calendar) + { out[dayKey] = new[dayKey] } out = out.filter { key, _ in @@ -720,7 +1331,11 @@ enum CostUsageScanner { retainedUntilKey: String) -> [String: [String]]? { var out = existing ?? [:] - for dayKey in self.dayKeys(sinceKey: range.scanSinceKey, untilKey: range.scanUntilKey) { + for dayKey in self.dayKeys( + sinceKey: range.scanSinceKey, + untilKey: range.scanUntilKey, + calendar: range.calendar) + { out[dayKey] = new[dayKey] ?? [] } out = out.filter { key, _ in @@ -737,14 +1352,19 @@ enum CostUsageScanner { root: URL, scanSinceKey: String, scanUntilKey: String, - modifiedSince: Date) -> [URL] + modifiedSince: Date, + calendar: Calendar = .current) -> [URL] { - let lookbackSinceKey = self.dayKey(scanSinceKey, addingDays: -self.codexActiveSessionLookbackDays) + let lookbackSinceKey = self.dayKey( + scanSinceKey, + addingDays: -self.codexActiveSessionLookbackDays, + calendar: calendar) ?? scanSinceKey let partitioned = self.listCodexSessionFilesByDatePartition( root: root, scanSinceKey: lookbackSinceKey, - scanUntilKey: scanUntilKey) + scanUntilKey: scanUntilKey, + calendar: calendar) let partitionedModified = self.filterRecentlyModified(files: partitioned, modifiedSince: modifiedSince) let legacyRecursive = self.listCodexRecentlyModifiedFilesRecursive(root: root, modifiedSince: modifiedSince) @@ -770,24 +1390,40 @@ enum CostUsageScanner { value.count == length && value.allSatisfy(\.isNumber) } - private static func dayKey(_ dayKey: String, addingDays days: Int) -> String? { - guard let date = self.parseDayKey(dayKey) else { return nil } - guard let shifted = Calendar.current.date(byAdding: .day, value: days, to: date) else { return nil } - return CostUsageDayRange.dayKey(from: shifted) + private static func dayKey( + _ dayKey: String, + addingDays days: Int, + calendar: Calendar = .current) -> String? + { + let calendar = CostUsageDayRange.localGregorianCalendar(matching: calendar) + guard let date = self.parseDayKey(dayKey, calendar: calendar) else { return nil } + guard let shifted = calendar.date(byAdding: .day, value: days, to: date) else { return nil } + return CostUsageDayRange.dayKey(from: shifted, calendar: calendar) + } + + private static func localStartOfDay(_ dayKey: String, calendar: Calendar) -> Date? { + let calendar = CostUsageDayRange.localGregorianCalendar(matching: calendar) + return self.parseDayKey(dayKey, calendar: calendar).map { calendar.startOfDay(for: $0) } } - private static func dayKeys(sinceKey: String, untilKey: String) -> [String] { - guard let since = self.parseDayKey(sinceKey), - self.parseDayKey(untilKey) != nil + private static func dayKeys( + sinceKey: String, + untilKey: String, + calendar: Calendar = .current) -> [String] + { + let calendar = CostUsageDayRange.localGregorianCalendar(matching: calendar) + guard let since = self.parseDayKey(sinceKey, calendar: calendar), + self.parseDayKey(untilKey, calendar: calendar) != nil else { return sinceKey <= untilKey ? [sinceKey] : [] } var out: [String] = [] var cursor = since - let calendar = Calendar.current - while CostUsageDayRange.dayKey(from: cursor) <= untilKey { - out.append(CostUsageDayRange.dayKey(from: cursor)) + while CostUsageDayRange.dayKey(from: cursor, calendar: calendar) <= untilKey { + out.append(CostUsageDayRange.dayKey(from: cursor, calendar: calendar)) guard let next = calendar.date(byAdding: .day, value: 1, to: cursor) else { break } - if next <= cursor { break } + if next <= cursor { + break + } cursor = next } return out @@ -811,11 +1447,13 @@ enum CostUsageScanner { return out } - private static func isWithinCodexRoots(fileURL: URL, roots: [URL]) -> Bool { + static func isWithinCodexRoots(fileURL: URL, roots: [URL]) -> Bool { let filePath = fileURL.standardizedFileURL.path return roots.contains { root in let rootPath = root.standardizedFileURL.path - if filePath == rootPath { return true } + if filePath == rootPath { + return true + } let prefix = rootPath.hasSuffix("/") ? rootPath : rootPath + "/" return filePath.hasPrefix(prefix) } @@ -824,15 +1462,17 @@ enum CostUsageScanner { private static func listCodexSessionFilesByDatePartition( root: URL, scanSinceKey: String, - scanUntilKey: String) -> [URL] + scanUntilKey: String, + calendar: Calendar = .current) -> [URL] { guard FileManager.default.fileExists(atPath: root.path) else { return [] } + let calendar = CostUsageDayRange.localGregorianCalendar(matching: calendar) var out: [URL] = [] - var date = Self.parseDayKey(scanSinceKey) ?? Date() - let untilDate = Self.parseDayKey(scanUntilKey) ?? date + var date = Self.parseDayKey(scanSinceKey, calendar: calendar) ?? Date() + let untilDate = Self.parseDayKey(scanUntilKey, calendar: calendar) ?? date while date <= untilDate { - let comps = Calendar.current.dateComponents([.year, .month, .day], from: date) + let comps = calendar.dateComponents([.year, .month, .day], from: date) let y = String(format: "%04d", comps.year ?? 1970) let m = String(format: "%02d", comps.month ?? 1) let d = String(format: "%02d", comps.day ?? 1) @@ -851,7 +1491,7 @@ enum CostUsageScanner { } } - date = Calendar.current.date(byAdding: .day, value: 1, to: date) ?? untilDate.addingTimeInterval(1) + date = calendar.date(byAdding: .day, value: 1, to: date) ?? untilDate.addingTimeInterval(1) } return out @@ -917,19 +1557,103 @@ enum CostUsageScanner { return String(filename[matchRange]) } - static func fileIdentityString(fileURL: URL) -> String? { - guard let values = try? fileURL.resourceValues(forKeys: [.fileResourceIdentifierKey]) else { return nil } - guard let identifier = values.fileResourceIdentifier else { return nil } - if let data = identifier as? Data { - return data.base64EncodedString() - } - return String(describing: identifier) - } - - private struct CodexSessionMetadata { + struct CodexSessionMetadata: Codable { let sessionId: String? let forkedFromId: String? let forkTimestamp: String? + let projectPath: String? + let isSubagentThread: Bool + } + + struct CodexTurnContextMetadata: Codable { + let timestamp: String? + let model: String? + let cwd: String? + let title: String? + } + + struct CodexTokenCountRecord: Codable { + let timestamp: String + let model: String? + let turnID: String? + let last: CostUsageCodexTotals? + let total: CostUsageCodexTotals? + } + + enum CodexFastLine: Codable { + case sessionMeta(CodexSessionMetadata) + case turnContext(CodexTurnContextMetadata) + case interAgentCommunication(triggerTurn: Bool) + case taskStarted(turnID: String?) + case tokenCount(CodexTokenCountRecord) + + var requiresValidTimestamp: Bool { + switch self { + case .sessionMeta: + false + case .turnContext, .interAgentCommunication, .taskStarted, .tokenCount: + true + } + } + } + + struct CodexBufferedFastLine: Codable { + let lineIndex: Int + let line: CodexFastLine + } + + 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 codexJSONFieldReasoningOutputTokens = Array("reasoning_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 codexJSONFieldSource = Array("source".utf8) + private static let codexJSONFieldSubagent = Array("subagent".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 codexJSONFieldTitle = Array("title".utf8) + private static let codexJSONFieldName = Array("name".utf8) + private static let codexJSONFieldTotalTokenUsage = Array("total_token_usage".utf8) + private static let codexJSONFieldTriggerTurn = Array("trigger_turn".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 let codexJSONFieldCwd = Array("cwd".utf8) + private static let codexJSONFieldCurrentWorkingDirectory = Array("current_working_directory".utf8) + private static let codexJSONFieldCurrentWorkingDirectoryCamel = Array("currentWorkingDirectory".utf8) + + static func codexModelEvidence(_ raw: String?) -> String? { + guard let trimmed = raw?.trimmingCharacters(in: .whitespacesAndNewlines), !trimmed.isEmpty else { return nil } + return trimmed + } + + static func codexTurnContextModel( + payloadModel: String?, + payloadModelName: String?, + infoModel: String?, + infoModelName: String?) -> String? + { + var sawCandidate = false + for candidate in [payloadModel, payloadModelName, infoModel, infoModelName] { + guard let candidate else { continue } + sawCandidate = true + if let model = self.codexModelEvidence(candidate) { + return model + } + } + // nil means the context omitted every model field; an empty value explicitly clears stale context. + return sawCandidate ? "" : nil } private static func codexForkParentId(from payload: [String: Any]?) -> String? { @@ -944,13 +1668,415 @@ enum CostUsageScanner { return nil } - private static func parseCodexSessionIdentifier( + 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 codexIsSubagentThread(from payload: [String: Any]?) -> Bool { + guard let payload else { return false } + if let source = payload["source"] as? String { + return source.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == "subagent" + } + if let source = payload["source"] as? [String: Any] { + return source["subagent"] is String || source["subagent"] is [String: Any] + } + return false + } + + private static func codexIsSubagentThread( + from bytes: UnsafeBufferPointer, + in payloadRange: Range) -> Bool + { + if let source = extractJSONByteStringField( + self.codexJSONFieldSource, + from: bytes, + in: payloadRange, + atDepth: 1) + { + return source.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == "subagent" + } + guard let sourceRange = extractJSONByteObjectField( + self.codexJSONFieldSource, + from: bytes, + in: payloadRange, + atDepth: 1) + else { return false } + return extractJSONByteStringField( + self.codexJSONFieldSubagent, + from: bytes, + in: sourceRange, + atDepth: 1) != nil + || extractJSONByteObjectField( + self.codexJSONFieldSubagent, + from: bytes, + in: sourceRange, + atDepth: 1) != 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? + { + // `session_id` identifies the shared multi-agent tree. `id` identifies this rollout/thread, + // and both fields have appeared at either metadata level. + let candidates: [String?] = [ + payloadRange.flatMap { + Self.extractJSONByteStringField(Self.codexJSONFieldId, from: bytes, in: $0, atDepth: 1) + }, + Self.extractJSONByteStringField(Self.codexJSONFieldId, from: bytes, in: rootRange, atDepth: 1), + payloadRange.flatMap { + Self.extractJSONByteStringField(Self.codexJSONFieldSessionId, from: bytes, in: $0, atDepth: 1) + }, + payloadRange.flatMap { + Self.extractJSONByteStringField(Self.codexJSONFieldSessionIdCamel, from: bytes, in: $0, atDepth: 1) + }, + Self.extractJSONByteStringField(Self.codexJSONFieldSessionId, from: bytes, in: rootRange, atDepth: 1), + Self.extractJSONByteStringField(Self.codexJSONFieldSessionIdCamel, from: bytes, in: rootRange, atDepth: 1), + ] + for value in candidates where value?.isEmpty == false { + return value + } + return nil + } + + static func normalizedCodexProjectPath(_ rawPath: String?) -> String? { + guard let rawPath = rawPath?.trimmingCharacters(in: .whitespacesAndNewlines), + !rawPath.isEmpty + else { return nil } + let expanded = (rawPath as NSString).expandingTildeInPath + guard expanded.hasPrefix("/") else { return nil } + return URL(fileURLWithPath: expanded, isDirectory: true).standardizedFileURL.path + } + + private static func codexProjectPath( + from bytes: UnsafeBufferPointer, + payloadRange: Range?) -> String? + { + guard let payloadRange else { return nil } + return Self.normalizedCodexProjectPath( + Self.extractJSONByteStringField(Self.codexJSONFieldCwd, from: bytes, in: payloadRange, atDepth: 1)) + } + + 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) + let reasoning = Self.extractJSONByteIntField( + Self.codexJSONFieldReasoningOutputTokens, + from: bytes, + in: objectRange, + atDepth: 1).map { min(max(0, $0), output) } + return CostUsageCodexTotals(input: input, cached: cached, output: output, reasoning: reasoning) + } + + private static func codexInterAgentCommunication( + from bytes: UnsafeBufferPointer, + in objectRange: Range) -> CodexFastLine? + { + guard let payloadRange = extractJSONByteObjectField( + codexJSONFieldPayload, + from: bytes, + in: objectRange, + atDepth: 1), + let triggerTurn = extractJSONByteBoolField( + codexJSONFieldTriggerTurn, + from: bytes, + in: payloadRange, + atDepth: 1) + else { return nil } + return .interAgentCommunication(triggerTurn: triggerTurn) + } + + // swiftlint:disable:next function_body_length + 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.. Bool? { + let timestamp = bytes.withUnsafeBytes { rawBytes in + let rawBuffer = rawBytes.bindMemory(to: UInt8.self) + guard !rawBuffer.isEmpty else { return nil as String? } + return Self.extractJSONByteStringField( + Self.codexJSONFieldTimestamp, + from: rawBuffer, + in: 0.. String? { try self.parseCodexSessionMetadata(fileURL: fileURL, checkCancellation: checkCancellation)?.sessionId } + static let codexSessionMetadataMaxLineBytes = 256 * 1024 + + private static func codexSessionMetadata(from obj: [String: Any]) -> CodexSessionMetadata? { + guard obj["type"] as? String == "session_meta" else { return nil } + let payload = obj["payload"] as? [String: Any] + return CodexSessionMetadata( + sessionId: payload?["id"] as? String + ?? obj["id"] as? String + ?? payload?["session_id"] as? String + ?? payload?["sessionId"] as? String + ?? obj["session_id"] as? String + ?? obj["sessionId"] as? String, + forkedFromId: Self.codexForkParentId(from: payload), + forkTimestamp: payload?["timestamp"] as? String + ?? obj["timestamp"] as? String, + projectPath: Self.normalizedCodexProjectPath(payload?["cwd"] as? String), + isSubagentThread: Self.codexIsSubagentThread(from: payload)) + } + private static func parseCodexSessionMetadata( fileURL: URL, checkCancellation: CancellationCheck? = nil) throws -> CodexSessionMetadata? @@ -967,38 +2093,65 @@ enum CostUsageScanner { defer { try? handle.close() } var buffer = Data() - let newline = Data([0x0A]) + var discardingOversizedLine = false 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 } - guard obj["type"] as? String == "session_meta" else { return nil } - let payload = obj["payload"] as? [String: Any] - return CodexSessionMetadata( - sessionId: payload?["session_id"] as? String - ?? payload?["sessionId"] as? String - ?? payload?["id"] as? String - ?? obj["session_id"] as? String - ?? obj["sessionId"] as? String - ?? obj["id"] as? String, - forkedFromId: Self.codexForkParentId(from: payload), - forkTimestamp: payload?["timestamp"] as? String - ?? obj["timestamp"] as? String) + return Self.codexSessionMetadata(from: obj) } } do { - while let chunk = try handle.read(upToCount: 64 * 1024), !chunk.isEmpty { - try checkCancellation?() - buffer.append(chunk) - while let newlineRange = buffer.range(of: newline) { - let lineData = buffer.subdata(in: 0.. Bool in + guard let chunk = try handle.read(upToCount: 64 * 1024), !chunk.isEmpty else { + return true } + try checkCancellation?() + + var segmentStart = chunk.startIndex + while segmentStart < chunk.endIndex { + let newlineIndex = chunk[segmentStart...].firstIndex(of: 0x0A) + let segmentEnd = newlineIndex ?? chunk.endIndex + + if !discardingOversizedLine { + let segmentCount = chunk.distance(from: segmentStart, to: segmentEnd) + let remainingBytes = Self.codexSessionMetadataMaxLineBytes - buffer.count + if segmentCount <= remainingBytes { + buffer.append(contentsOf: chunk[segmentStart.. Bool + { + try self.parseCodexSessionMetadata( + fileURL: fileURL, + checkCancellation: checkCancellation)?.isSubagentThread == true + } + private static func parseCodexTokenSnapshots( fileURL: URL, checkCancellation: CancellationCheck? = nil) throws -> ( @@ -1023,9 +2187,7 @@ enum CostUsageScanner { snapshots: [CodexTimestampedTotals]) { var sessionId: String? - var previousTotals: CostUsageCodexTotals? - var rawTotalsBaseline: CostUsageCodexTotals? - var sawDivergentTotals = false + var accumulator = CodexSnapshotAccumulator() var snapshots: [CodexTimestampedTotals] = [] var warnedAboutUnparsedTimestamp = false @@ -1041,6 +2203,15 @@ enum CostUsageScanner { return date } + func appendSnapshot(timestamp: String, last: CostUsageCodexTotals?, total: CostUsageCodexTotals?) { + guard last != nil || total != nil else { return } + let counted = accumulator.apply(last: last, total: total) + snapshots.append(CodexTimestampedTotals( + timestamp: timestamp, + date: parsedSnapshotDate(timestamp: timestamp), + totals: counted)) + } + do { _ = try CostUsageJsonl.scan( fileURL: fileURL, @@ -1049,6 +2220,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, .interAgentCommunication, .taskStarted: + break + } + return + } + autoreleasepool { guard let obj = (try? JSONSerialization.jsonObject(with: line.bytes)) as? [String: Any] else { return } @@ -1073,75 +2258,31 @@ enum CostUsageScanner { guard let timestamp = obj["timestamp"] as? String else { return } func toInt(_ value: Any?) -> Int { - if let number = value as? NSNumber { return number.intValue } + if let number = value as? NSNumber { + return number.intValue + } 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 { + let output = toInt($0["output_tokens"]) + return CostUsageCodexTotals( + input: toInt($0["input_tokens"]), + cached: toInt($0["cached_input_tokens"] ?? $0["cache_read_input_tokens"]), + output: output, + reasoning: ($0["reasoning_output_tokens"] as? NSNumber) + .map { min(max(0, $0.intValue), max(0, output)) }) + } + let last = (info["last_token_usage"] as? [String: Any]).map { + let output = max(0, toInt($0["output_tokens"])) + return CostUsageCodexTotals( + input: max(0, toInt($0["input_tokens"])), + cached: max(0, toInt($0["cached_input_tokens"] ?? $0["cache_read_input_tokens"])), + output: output, + reasoning: ($0["reasoning_output_tokens"] as? NSNumber) + .map { min(max(0, $0.intValue), output) }) } + appendSnapshot(timestamp: timestamp, last: last, total: total) } }) } catch is CancellationError { @@ -1164,6 +2305,7 @@ enum CostUsageScanner { initialRawTotalsBaseline: CostUsageCodexTotals? = nil, initialHasDivergentTotals: Bool = false, initialCodexTurnID: String? = nil, + initialCodexUsageRowIndex: Int = 0, inheritedTotalsResolver: ((String, String) -> CodexForkBaseline)? = nil) -> CodexParseResult { let throwingResolver: ((String, String) throws -> CodexForkBaseline)? = inheritedTotalsResolver @@ -1180,6 +2322,7 @@ enum CostUsageScanner { initialRawTotalsBaseline: initialRawTotalsBaseline, initialHasDivergentTotals: initialHasDivergentTotals, initialCodexTurnID: initialCodexTurnID, + initialCodexUsageRowIndex: initialCodexUsageRowIndex, inheritedTotalsResolver: throwingResolver, checkCancellation: nil)) ?? CodexParseResult( days: [:], @@ -1188,11 +2331,25 @@ enum CostUsageScanner { lastTotals: initialTotals, lastCountedTotals: initialTotals, lastRawTotalsBaseline: initialRawTotalsBaseline, + lastRawTotalsWatermark: initialRawTotalsBaseline, + seenRawTotals: [], hasDivergentTotals: initialHasDivergentTotals, + hasInterleavedTotals: false, lastCodexTurnID: initialCodexTurnID, sessionId: nil, forkedFromId: nil, - rows: []) + dependsOnParentTotals: false, + projectPath: nil, + codexSession: CostUsageCodexSessionMetadata( + sessionId: nil, + forkedFromId: nil, + cwd: nil, + title: nil, + startedAtUnixMs: nil, + latestActivityUnixMs: nil), + rows: [], + jsonlResumeState: nil, + bufferedSubagentLines: nil) } // swiftlint:disable:next cyclomatic_complexity function_body_length @@ -1203,8 +2360,15 @@ enum CostUsageScanner { initialModel: String? = nil, initialTotals: CostUsageCodexTotals? = nil, initialRawTotalsBaseline: CostUsageCodexTotals? = nil, + initialRawTotalsWatermark: CostUsageCodexTotals? = nil, + initialSeenRawTotals: [CostUsageCodexTotals] = [], initialHasDivergentTotals: Bool = false, + initialHasInterleavedTotals: Bool = false, initialCodexTurnID: String? = nil, + initialCodexUsageRowIndex: Int = 0, + initialBufferedSubagentLines: [CodexBufferedFastLine]? = nil, + initialJSONLResumeState: CostUsageJsonl.ResumeState? = nil, + maxBytesToRead: Int64? = nil, inheritedTotalsResolver: ((String, String) throws -> CodexForkBaseline)? = nil, checkCancellation: CancellationCheck? = nil) throws -> CodexParseResult { @@ -1212,14 +2376,35 @@ enum CostUsageScanner { var previousTotals = initialTotals var sessionId: String? var forkedFromId: String? + var projectPath: String? + var isSubagentThread = false + var didCaptureLeafMetadata = false + var forkTimestamp: String? + var subagentCounterSemantics: CodexSubagentCounterSemantics? + var usesLocalSubagentBoundary = false + var candidateBoundaryDependsOnParentTotals = false + var parentConfirmedLocalBoundary = false + var suppressUnownedCopiedPrefix = false + var codexSession = CostUsageCodexSessionMetadata( + sessionId: nil, + forkedFromId: nil, + cwd: nil, + title: nil, + startedAtUnixMs: nil, + latestActivityUnixMs: nil) var inheritedTotals: CostUsageCodexTotals? var remainingInheritedTotals: CostUsageCodexTotals? var forkBaselineResolved = false var hasUnresolvedForkBaseline = false var unresolvedForkTotalWatermark: CostUsageCodexTotals? var currentTurnID = initialCodexTurnID + var codexUsageRowIndex = initialCodexUsageRowIndex var rawTotalsBaseline = initialRawTotalsBaseline ?? initialTotals var sawDivergentTotals = initialHasDivergentTotals + var tracker = CodexTotalsTracker( + watermark: initialRawTotalsWatermark ?? initialRawTotalsBaseline ?? initialTotals, + seenRawTotals: initialSeenRawTotals, + sawInterleavedTotals: initialHasInterleavedTotals) var deferredError: Error? var days: [String: [String: [Int]]] = [:] @@ -1239,6 +2424,41 @@ enum CostUsageScanner { days[dayKey] = dayModels } + func sanitizedString(_ value: String?) -> String? { + guard let value else { return nil } + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } + + func unixMilliseconds(from timestamp: String?) -> Int64? { + guard let timestamp, + let date = Self.dateFromTimestamp(timestamp) + else { return nil } + return Int64((date.timeIntervalSince1970 * 1000).rounded()) + } + + func observeTimestamp(_ timestamp: String?) { + guard let unixMs = unixMilliseconds(from: timestamp) else { return } + codexSession.startedAtUnixMs = switch codexSession.startedAtUnixMs { + case let current?: min(current, unixMs) + case nil: unixMs + } + codexSession.latestActivityUnixMs = switch codexSession.latestActivityUnixMs { + case let current?: max(current, unixMs) + case nil: unixMs + } + } + + func observeCwd(_ value: String?) { + guard let value = sanitizedString(value) else { return } + codexSession.cwd = value + } + + func observeTitle(_ value: String?) { + guard let value = sanitizedString(value) else { return } + codexSession.title = value + } + func resolveForkBaseline(parentSessionId: String, forkedAt: String) throws { guard !forkBaselineResolved else { return } guard let inheritedTotalsResolver else { return } @@ -1253,39 +2473,396 @@ enum CostUsageScanner { } } - let maxLineBytes = 256 * 1024 - let prefixBytes = maxLineBytes + func configureForkAccountingIfReady() throws { + guard let forkedFromId else { return } + if isSubagentThread, subagentCounterSemantics == nil { + return + } + if subagentCounterSemantics == .independent || usesLocalSubagentBoundary { + forkBaselineResolved = true + inheritedTotals = nil + remainingInheritedTotals = nil + hasUnresolvedForkBaseline = false + return + } + try resolveForkBaseline( + parentSessionId: forkedFromId, + forkedAt: forkTimestamp ?? "") + } - if startOffset == 0, - let metadata = try Self.parseCodexSessionMetadata( - fileURL: fileURL, - checkCancellation: checkCancellation) - { + func handleSessionMetadata(_ metadata: CodexSessionMetadata) throws { + // The first parsed session_meta is the authoritative leaf. Copied prefixes can + // contain many embedded ancestor metas; they are shape evidence, never new identity. + if didCaptureLeafMetadata { + // A same-leaf restart may add metadata that was absent from the initial record. + // Enrich missing fork/project fields without allowing an ancestor to replace identity. + guard CodexSubagentRolloutShape.sameConcreteSessionID(metadata.sessionId, sessionId) else { return } + if forkedFromId == nil, let enrichedParentID = metadata.forkedFromId { + forkedFromId = enrichedParentID + codexSession.forkedFromId = enrichedParentID + forkTimestamp = metadata.forkTimestamp ?? forkTimestamp + try configureForkAccountingIfReady() + } + if projectPath == nil { + projectPath = metadata.projectPath + } + observeTimestamp(metadata.forkTimestamp) + if codexSession.cwd == nil { + observeCwd(metadata.projectPath) + } + return + } + didCaptureLeafMetadata = true sessionId = metadata.sessionId forkedFromId = metadata.forkedFromId - if let forkedFromId = metadata.forkedFromId, - inheritedTotals == nil + forkTimestamp = metadata.forkTimestamp + projectPath = metadata.projectPath + codexSession.sessionId = metadata.sessionId + codexSession.forkedFromId = metadata.forkedFromId + observeTimestamp(metadata.forkTimestamp) + observeCwd(metadata.projectPath) + isSubagentThread = metadata.isSubagentThread + try configureForkAccountingIfReady() + } + + // swiftlint:disable:next function_body_length + func handleTokenCount(_ record: CodexTokenCountRecord) throws { + observeTimestamp(record.timestamp) + guard let dayKey = Self.dayKeyFromTimestamp(record.timestamp, calendar: range.calendar) + ?? Self.dayKeyFromParsedISO(record.timestamp, calendar: range.calendar) + else { return } + guard !suppressUnownedCopiedPrefix else { return } + + let model = Self.codexModelEvidence(currentModel) + ?? Self.codexModelEvidence(record.model) + ?? CostUsagePricing.codexUnattributedModel + let total = record.total + let last = record.last + + var deltaInput = 0 + var deltaCached = 0 + var deltaOutput = 0 + var deltaReasoning: Int? + + 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), + reasoning: Self.codexSubtractOptional(rawDelta.reasoning, remaining.reasoning)) + + remaining.input = max(0, remaining.input - rawDelta.input) + remaining.cached = max(0, remaining.cached - rawDelta.cached) + remaining.output = max(0, remaining.output - rawDelta.output) + remaining.reasoning = Self.codexSubtractOptional(remaining.reasoning, rawDelta.reasoning) + remainingInheritedTotals = if remaining.input == 0, remaining.cached == 0, + remaining.output == 0 + { + nil + } else { + remaining + } + + return adjusted + } + + // Fork totals are normalized against the selected baseline. Classified independent + // counters and locally delimited suffixes intentionally bypass the parent baseline. + let adjustedTotal: CostUsageCodexTotals? = total.map { rawTotals in + guard let inheritedTotals, !hasUnresolvedForkBaseline else { return rawTotals } + return CostUsageCodexTotals( + input: max(0, rawTotals.input - inheritedTotals.input), + cached: max(0, rawTotals.cached - inheritedTotals.cached), + output: max(0, rawTotals.output - inheritedTotals.output), + reasoning: Self.codexSubtractOptional(rawTotals.reasoning, inheritedTotals.reasoning)) + } + + if let adjustedTotal { + // Only committed observations enter the seen set. Replacing this with a bare + // watermark-equality check would skip first-time fork baseline bookkeeping. + // Post-latch containment remains the load-bearing overcount guard. + if tracker.isSeen(adjustedTotal) { + return + } + tracker.latchIfBelowWatermark(adjustedTotal) + } + let watermarkBaseline = tracker.watermark ?? rawTotalsBaseline + defer { + if let adjustedTotal { + tracker.commitObserved(adjustedTotal) + } + } + + func totalsDerivedDelta(to currentTotals: CostUsageCodexTotals) -> CostUsageCodexTotals { + if tracker.sawInterleavedTotals { + return Self.codexContainedTotalDelta( + watermark: watermarkBaseline, + counted: previousTotals, + current: currentTotals) + } + if sawDivergentTotals { + return Self.codexDivergentTotalDelta( + rawBaseline: watermarkBaseline, + countedBaseline: previousTotals, + current: currentTotals) + } + return Self.codexTotalDelta(from: watermarkBaseline, to: currentTotals) + } + + func commitDelta(_ delta: CostUsageCodexTotals, rawBaseline: CostUsageCodexTotals) { + deltaInput = delta.input + deltaCached = delta.cached + deltaOutput = delta.output + deltaReasoning = delta.reasoning + let prev = previousTotals ?? .init( + input: 0, + cached: 0, + output: 0, + reasoning: delta.reasoning == nil ? nil : 0) + previousTotals = Self.codexAddTotals(prev, delta) + rawTotalsBaseline = rawBaseline + if !Self.codexTotalsEqual(rawTotalsBaseline, previousTotals) { + sawDivergentTotals = true + } + } + + let handledUnresolvedForkTotal = hasUnresolvedForkBaseline && total != nil + if hasUnresolvedForkBaseline, let total { + // `unresolvedForkTotalWatermark` is a presence sentinel for "skip the first + // unresolved-fork totals row"; delta baselines come from the global tracker. + let currentRawTotals = total + defer { + unresolvedForkTotalWatermark = currentRawTotals + } + guard let last, + unresolvedForkTotalWatermark != nil + else { + return + } + + let adjustedDelta = Self.codexMinTotals( + last, + Self.codexTotalDelta(from: watermarkBaseline, to: currentRawTotals)) + deltaInput = adjustedDelta.input + deltaCached = adjustedDelta.cached + deltaOutput = adjustedDelta.output + deltaReasoning = adjustedDelta.reasoning + let prev = previousTotals ?? .init( + input: 0, + cached: 0, + output: 0, + reasoning: adjustedDelta.reasoning == nil ? nil : 0) + previousTotals = Self.codexAddTotals(prev, adjustedDelta) + rawTotalsBaseline = previousTotals + } + + if !handledUnresolvedForkTotal, + let currentTotals = adjustedTotal, + forkedFromId != nil, + !hasUnresolvedForkBaseline + { + // Non-interleaved forks keep totals-only accounting (#1164 / 45b68c34). + // After latch, use post-latch containment capped by last when present. + let delta: CostUsageCodexTotals = if tracker.sawInterleavedTotals { + Self.codexPostLatchEventDelta( + watermark: watermarkBaseline, + counted: previousTotals, + current: currentTotals, + adjustedLast: last.map { adjustedLastDelta($0) }) + } else { + totalsDerivedDelta(to: currentTotals) + } + commitDelta(delta, rawBaseline: currentTotals) + remainingInheritedTotals = nil + } else if !handledUnresolvedForkTotal, let last { + let rawDelta = last + let hadRemainingInheritedTotals = remainingInheritedTotals != nil + var adjustedDelta = adjustedLastDelta(rawDelta) + let prev = previousTotals ?? .init( + input: 0, + cached: 0, + output: 0, + reasoning: adjustedDelta.reasoning == nil ? nil : 0) + + if let currentTotals = adjustedTotal, !hasUnresolvedForkBaseline { + if tracker.sawInterleavedTotals { + adjustedDelta = Self.codexPostLatchEventDelta( + watermark: watermarkBaseline, + counted: previousTotals, + current: currentTotals, + adjustedLast: adjustedDelta) + remainingInheritedTotals = nil + } else { + let totalDelta = Self.codexTotalDelta(from: watermarkBaseline, to: currentTotals) + if !hadRemainingInheritedTotals, + Self.codexShouldPreferTotalDelta( + rawBaseline: watermarkBaseline, + currentTotal: currentTotals, + totalDelta: totalDelta, + lastDelta: rawDelta, + sawDivergentTotals: sawDivergentTotals) + { + adjustedDelta = totalDelta + remainingInheritedTotals = nil + } + } + commitDelta(adjustedDelta, rawBaseline: currentTotals) + } else { + let countedTotals = Self.codexAddTotals(prev, adjustedDelta) + deltaInput = adjustedDelta.input + deltaCached = adjustedDelta.cached + deltaOutput = adjustedDelta.output + deltaReasoning = adjustedDelta.reasoning + previousTotals = countedTotals + rawTotalsBaseline = countedTotals + tracker.raiseWatermark(to: countedTotals) + } + } else if !handledUnresolvedForkTotal, let currentTotals = adjustedTotal { + commitDelta(totalsDerivedDelta(to: currentTotals), rawBaseline: currentTotals) + remainingInheritedTotals = nil + } else if !handledUnresolvedForkTotal { + return + } + + if deltaInput == 0, deltaCached == 0, deltaOutput == 0 { + return + } + let eventIndex = codexUsageRowIndex + codexUsageRowIndex += 1 + let normModel = CostUsagePricing.normalizeCodexModel(model) + add( + dayKey: dayKey, + model: normModel, + input: deltaInput, + cached: deltaCached, + output: deltaOutput) + if CostUsageDayRange.isInRange( + dayKey: dayKey, + since: range.scanSinceKey, + until: range.scanUntilKey) { - let forkedAt = metadata.forkTimestamp ?? "" - try resolveForkBaseline(parentSessionId: forkedFromId, forkedAt: forkedAt) + rows.append(CodexUsageRow( + day: dayKey, + model: normModel, + rawModel: model, + turnID: record.turnID ?? currentTurnID, + eventIndex: eventIndex, + timestampUnixMs: unixMilliseconds(from: record.timestamp), + input: deltaInput, + cached: deltaCached, + output: deltaOutput, + reasoning: deltaReasoning)) + } + } + + func processFastLine(_ fastLine: CodexFastLine) throws { + switch fastLine { + case let .sessionMeta(metadata): + try handleSessionMetadata(metadata) + case let .turnContext(metadata): + observeTimestamp(metadata.timestamp) + observeCwd(metadata.cwd) + observeTitle(metadata.title) + if let model = metadata.model { + // An explicitly blank context clears stale model evidence; an omitted field preserves it. + currentModel = sanitizedString(model) + } + case .interAgentCommunication: + break + case let .taskStarted(turnID): + currentTurnID = turnID + case let .tokenCount(record): + try handleTokenCount(record) + } + } + + let maxLineBytes = 256 * 1024 + let prefixBytes = maxLineBytes + + var pendingSubagentLines = initialBufferedSubagentLines + + if let initialBufferedSubagentLines, startOffset > 0 { + for buffered in initialBufferedSubagentLines { + guard case let .sessionMeta(metadata) = buffered.line else { continue } + try handleSessionMetadata(metadata) + } + } else if startOffset == 0, + let metadata = try Self.parseCodexSessionMetadata( + fileURL: fileURL, + checkCancellation: checkCancellation) + { + try handleSessionMetadata(metadata) + if metadata.isSubagentThread { + // Subagent provenance can omit a fork id. Buffer parsed events, not JSON, so + // classification remains one disk pass and reuses the existing totals reducer. + pendingSubagentLines = [] + } + } + + func routeFastLine(_ fastLine: CodexFastLine, lineIndex: Int) throws { + if pendingSubagentLines != nil { + pendingSubagentLines?.append(Self.CodexBufferedFastLine(lineIndex: lineIndex, line: fastLine)) + } else { + try processFastLine(fastLine) } } var parsedBytes: Int64 + let targetSize = Self.codexFileMetadata(fileURL: fileURL).size + var physicalLineIndex = (initialBufferedSubagentLines?.last?.lineIndex ?? -1) + 1 + var jsonlResumeState = initialJSONLResumeState do { - parsedBytes = try CostUsageJsonl.scan( + let scanProgress = try CostUsageJsonl.scanBounded( fileURL: fileURL, offset: startOffset, maxLineBytes: maxLineBytes, prefixBytes: prefixBytes, + maxBytesToRead: maxBytesToRead, + resumeState: initialJSONLResumeState, checkCancellation: checkCancellation, onLine: { line in - if deferredError != nil { return } + let lineIndex = physicalLineIndex + physicalLineIndex += 1 + if deferredError != nil { + return + } guard !line.bytes.isEmpty else { return } if line.wasTruncated { // `turn_context` can carry very large prompts, but its model usually appears near the start. - if let model = Self.extractCodexTurnContextModel(from: line.bytes) { - currentModel = model + // A truncated line cannot be structurally validated with Foundation, so + // only accept the canonical root discriminator to avoid prompt-text hits. + let truncatedTurnContext = Self.extractCodexTruncatedTurnContext(from: line.bytes) + if truncatedTurnContext.isValid { + do { + try routeFastLine( + .turnContext(CodexTurnContextMetadata( + timestamp: nil, + model: truncatedTurnContext.model, + cwd: nil, + title: nil)), + lineIndex: lineIndex) + } catch { + deferredError = error + } + } + if pendingSubagentLines != nil { + let truncatedMetadata = Self.extractCodexTruncatedSessionMetadata(from: line.bytes) + if truncatedMetadata.isSessionMetadata { + do { + try routeFastLine( + .sessionMeta(CodexSessionMetadata( + sessionId: truncatedMetadata.sessionID, + forkedFromId: nil, + forkTimestamp: nil, + projectPath: nil, + isSubagentThread: false)), + lineIndex: lineIndex) + } catch { + deferredError = error + } + } } return } @@ -1293,7 +2870,11 @@ enum CostUsageScanner { guard line.bytes.containsAscii(#""type":"event_msg""#) || line.bytes.containsAscii(#""type":"turn_context""#) + || line.bytes.containsAscii(#""turn_context""#) || line.bytes.containsAscii(#""type":"session_meta""#) + || line.bytes.containsAscii(#""session_meta""#) + || line.bytes.containsAscii(#""type":"inter_agent_communication_metadata""#) + || line.bytes.containsAscii(#""inter_agent_communication_metadata""#) else { return } if line.bytes.containsAscii(#""type":"event_msg""#), @@ -1303,6 +2884,23 @@ enum CostUsageScanner { return } + if let fastLine = Self.parseCodexFastLine(line.bytes) { + let timestampValidity = fastLine.requiresValidTimestamp + ? Self.codexFastLineTimestampValidity(line.bytes) + : true + if timestampValidity == true { + do { + try routeFastLine(fastLine, lineIndex: lineIndex) + } catch { + deferredError = error + } + return + } + if timestampValidity == false { + return + } + } + autoreleasepool { guard let obj = (try? JSONSerialization.jsonObject(with: line.bytes)) as? [String: Any], @@ -1310,45 +2908,55 @@ enum CostUsageScanner { else { return } if type == "session_meta" { - let payload = obj["payload"] as? [String: Any] - if sessionId == nil { - sessionId = payload?["session_id"] as? String - ?? payload?["sessionId"] as? String - ?? payload?["id"] as? String - ?? obj["session_id"] as? String - ?? obj["sessionId"] as? String - ?? obj["id"] as? String - } - if forkedFromId == nil { - forkedFromId = Self.codexForkParentId(from: payload) - } - if let forkedFromId { - let forkedAt = payload?["timestamp"] as? String - ?? obj["timestamp"] as? String - ?? "" - do { - try resolveForkBaseline(parentSessionId: forkedFromId, forkedAt: forkedAt) - } catch { - deferredError = error - return - } + guard let metadata = Self.codexSessionMetadata(from: obj) else { return } + do { + try routeFastLine(.sessionMeta(metadata), lineIndex: lineIndex) + } catch { + deferredError = error } return } guard let tsText = obj["timestamp"] as? String else { return } - guard let dayKey = Self.dayKeyFromTimestamp(tsText) ?? Self.dayKeyFromParsedISO(tsText) + guard Self.dayKeyFromTimestamp(tsText) ?? Self.dayKeyFromParsedISO(tsText) != nil else { return } + if type == "inter_agent_communication_metadata" { + let payload = obj["payload"] as? [String: Any] + do { + try routeFastLine( + .interAgentCommunication(triggerTurn: payload?["trigger_turn"] as? Bool == true), + lineIndex: lineIndex) + } catch { + deferredError = error + } + return + } + if type == "turn_context" { + var metadata = CodexTurnContextMetadata( + timestamp: tsText, + model: nil, + cwd: nil, + title: nil) if let payload = obj["payload"] as? [String: Any] { - if let model = payload["model"] as? String { - currentModel = model - } else if let info = payload["info"] as? [String: Any], - let model = info["model"] as? String - { - currentModel = model - } + let info = payload["info"] as? [String: Any] + metadata = CodexTurnContextMetadata( + timestamp: tsText, + model: Self.codexTurnContextModel( + payloadModel: payload["model"] as? String, + payloadModelName: payload["model_name"] as? String, + infoModel: info?["model"] as? String, + infoModelName: info?["model_name"] as? String), + cwd: payload["cwd"] as? String + ?? payload["current_working_directory"] as? String + ?? payload["currentWorkingDirectory"] as? String, + title: payload["title"] as? String ?? payload["name"] as? String) + } + do { + try routeFastLine(.turnContext(metadata), lineIndex: lineIndex) + } catch { + deferredError = error } return } @@ -1356,218 +2964,166 @@ enum CostUsageScanner { guard type == "event_msg" else { return } guard let payload = obj["payload"] as? [String: Any] else { return } if (payload["type"] as? String) == "task_started" { - currentTurnID = Self.codexTurnID(from: payload) + do { + try routeFastLine( + .taskStarted(turnID: Self.codexTurnID(from: payload)), + lineIndex: lineIndex) + } catch { + deferredError = error + } return } guard (payload["type"] as? String) == "token_count" else { return } let info = payload["info"] as? [String: Any] - let modelFromInfo = info?["model"] as? String - ?? info?["model_name"] as? String - ?? payload["model"] as? String - ?? obj["model"] as? String - let model = currentModel ?? modelFromInfo ?? "gpt-5" + let modelFromInfo = Self.codexModelEvidence(info?["model"] as? String) + ?? Self.codexModelEvidence(info?["model_name"] as? String) + ?? Self.codexModelEvidence(payload["model"] as? String) + ?? Self.codexModelEvidence(obj["model"] as? String) func toInt(_ v: Any?) -> Int { - if let n = v as? NSNumber { return n.intValue } + if let n = v as? NSNumber { + return n.intValue + } return 0 } func tokenTotals(_ usage: [String: Any]) -> CostUsageCodexTotals { - CostUsageCodexTotals( + let output = max(0, toInt(usage["output_tokens"])) + return CostUsageCodexTotals( input: max(0, toInt(usage["input_tokens"])), cached: max(0, toInt(usage["cached_input_tokens"] ?? usage["cache_read_input_tokens"])), - output: max(0, toInt(usage["output_tokens"]))) + output: output, + reasoning: (usage["reasoning_output_tokens"] as? NSNumber) + .map { min(max(0, $0.intValue), output) }) } - let total = (info?["total_token_usage"] as? [String: Any]) - let last = (info?["last_token_usage"] as? [String: Any]) - - 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 record = CodexTokenCountRecord( + timestamp: tsText, + model: modelFromInfo, + turnID: Self.codexTurnID(from: payload), + last: (info?["last_token_usage"] as? [String: Any]).map(tokenTotals), + total: (info?["total_token_usage"] as? [String: Any]).map(tokenTotals)) + do { + try routeFastLine(.tokenCount(record), lineIndex: lineIndex) + } catch { + deferredError = error } + } + }) + parsedBytes = scanProgress.readOffset + jsonlResumeState = scanProgress.resumeState + if let deferredError { + throw deferredError + } - let handledUnresolvedForkTotal = hasUnresolvedForkBaseline && total != nil - if hasUnresolvedForkBaseline, let total { - let currentRawTotals = tokenTotals(total) - defer { - unresolvedForkTotalWatermark = currentRawTotals - } - guard let last, - let watermark = unresolvedForkTotalWatermark - else { - return + if let pendingSubagentLines, parsedBytes >= targetSize, jsonlResumeState == nil { + // Same-leaf metadata can fill lineage fields after the opening record. Collect it + // before replay so copied-prefix totals never run once on the wrong baseline, and + // so an owned-suffix filter cannot discard the only fork identifier. + for buffered in pendingSubagentLines { + guard case let .sessionMeta(metadata) = buffered.line, + CodexSubagentRolloutShape.sameConcreteSessionID(metadata.sessionId, sessionId) + else { continue } + if forkedFromId == nil, let enrichedParentID = metadata.forkedFromId { + forkedFromId = enrichedParentID + codexSession.forkedFromId = enrichedParentID + forkTimestamp = metadata.forkTimestamp ?? forkTimestamp + } + if projectPath == nil { + projectPath = metadata.projectPath + } + observeTimestamp(metadata.forkTimestamp) + if codexSession.cwd == nil { + observeCwd(metadata.projectPath) + } + } + let observations = pendingSubagentLines.compactMap { buffered -> CodexSubagentRolloutShape + .Observation? in + let kind: CodexSubagentRolloutShape.Observation.Kind + switch buffered.line { + case let .sessionMeta(metadata): + kind = .sessionMetadata(id: metadata.sessionId) + case .turnContext: + kind = .turnContext + case let .interAgentCommunication(triggerTurn): + kind = .interAgentCommunication(triggerTurn: triggerTurn) + case let .tokenCount(record): + kind = .tokenCount(total: record.total, last: record.last) + case .taskStarted: + return nil + } + return Self.CodexSubagentRolloutShape.Observation( + lineIndex: buffered.lineIndex, + kind: kind) + } + let shape = CodexSubagentRolloutShape.classify( + leafSessionID: sessionId, + observations: observations, + hasExplicitParent: forkedFromId != nil) + subagentCounterSemantics = shape.counterSemantics + if forkedFromId == nil { + forkedFromId = shape.inferredParentSessionID + } + var ownedSuffix = shape.ownedSuffix + if let candidate = shape.ownedSuffixCandidate, + let parentSessionID = forkedFromId + { + candidateBoundaryDependsOnParentTotals = true + if let inheritedTotalsResolver { + switch try inheritedTotalsResolver(parentSessionID, forkTimestamp ?? "") { + case let .resolved(parentTotals): + if Self.codexTotalsEqual(parentTotals, candidate.parentTotalsAtBoundary) { + subagentCounterSemantics = .copiedPrefix + ownedSuffix = candidate.ownedSuffix + parentConfirmedLocalBoundary = true } - - let rawLastDelta = tokenTotals(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 + case .unresolved: + break } - - if !handledUnresolvedForkTotal, - let total, - forkedFromId != nil, - !hasUnresolvedForkBaseline - { - let rawTotals = tokenTotals(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 = 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 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 = tokenTotals(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 = tokenTotals(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)) + } + } + suppressUnownedCopiedPrefix = subagentCounterSemantics == .copiedPrefix + && ownedSuffix == nil + && forkedFromId == nil + if let ownedSuffix { + usesLocalSubagentBoundary = true + previousTotals = nil + // Keep totals-derived accounting after the boundary. Real flat-total rows + // repeat the previous token payload with a fresh outer timestamp; their + // non-zero `last` is replay evidence, not new usage (#2037). + rawTotalsBaseline = ownedSuffix.rawTotalsBaseline + sawDivergentTotals = false + tracker = CodexTotalsTracker( + watermark: ownedSuffix.rawTotalsBaseline, + seenRawTotals: [], + sawInterleavedTotals: false) + currentModel = nil + currentTurnID = nil + unresolvedForkTotalWatermark = nil + } + self.log.debug( + "Codex cost usage classified subagent rollout counter semantics", + metadata: [ + "sessionId": sessionId ?? "unknown", + "semantics": subagentCounterSemantics == .copiedPrefix ? "copiedPrefix" : "independent", + "localBoundary": ownedSuffix == nil ? "false" : "true", + "parentConfirmedBoundary": parentConfirmedLocalBoundary ? "true" : "false", + "suppressedUnownedPrefix": suppressUnownedCopiedPrefix ? "true" : "false", + "sessionMetadataCount": String(observations.count(where: { + if case .sessionMetadata = $0.kind { + true } 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 + false } - 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: Self.codexTurnID(from: payload) ?? currentTurnID, - input: deltaInput, - cached: cachedClamp, - output: deltaOutput)) - } - } - }) - if let deferredError { - throw deferredError + })), + ]) + try configureForkAccountingIfReady() + for buffered in pendingSubagentLines + where ownedSuffix.map({ buffered.lineIndex >= $0.startLineIndex }) ?? true + { + try processFastLine(buffered.line) + } } } catch is CancellationError { throw CancellationError() @@ -1576,6 +3132,7 @@ enum CostUsageScanner { "Codex cost usage failed while scanning session file", metadata: ["path": fileURL.path, "error": error.localizedDescription]) parsedBytes = startOffset + jsonlResumeState = initialJSONLResumeState } return CodexParseResult( @@ -1587,11 +3144,21 @@ enum CostUsageScanner { : previousTotals, lastCountedTotals: previousTotals, lastRawTotalsBaseline: rawTotalsBaseline, + lastRawTotalsWatermark: tracker.watermark, + seenRawTotals: tracker.seenRawTotals, hasDivergentTotals: sawDivergentTotals && !Self.codexTotalsEqual(rawTotalsBaseline, previousTotals), + hasInterleavedTotals: tracker.sawInterleavedTotals, lastCodexTurnID: currentTurnID, sessionId: sessionId, forkedFromId: forkedFromId, - rows: rows) + dependsOnParentTotals: forkedFromId != nil + && (candidateBoundaryDependsOnParentTotals + || (subagentCounterSemantics != .independent && !usesLocalSubagentBoundary)), + projectPath: projectPath, + codexSession: codexSession, + rows: rows, + jsonlResumeState: jsonlResumeState, + bufferedSubagentLines: parsedBytes < targetSize || jsonlResumeState != nil ? pendingSubagentLines : nil) } private static func codexTurnID(from payload: [String: Any]) -> String? { @@ -1618,19 +3185,94 @@ enum CostUsageScanner { } let cached = cache.files[metadata.path] - if let cachedSessionId = cached?.sessionId, state.seenSessionIds.contains(cachedSessionId) { - Self.dropCachedCodexFile(path: metadata.path, cached: cached, cache: &cache) - return - } let input = CodexFileScanInput(fileURL: fileURL, metadata: metadata, cached: cached) - if Self.keepCachedCodexFileIfFresh(input: input, context: context, cache: &cache, state: &state) { + if try Self.keepCachedCodexFileIfFresh(input: input, context: context, cache: &cache, state: &state) { return } - if try Self.appendCodexFileIncrementIfPossible(input: input, context: context, cache: &cache, state: &state) { + + let pendingWorkBytes = Self.pendingCodexScanWorkBytes(metadata: metadata, cached: cached) + let allowedWorkBytes: Int64 + if let budget = context.scanBudget { + switch budget.admit(workBytes: pendingWorkBytes) { + case let .allow(allowance): + allowedWorkBytes = allowance + case .deferBudget: + Self.log.debug( + "Deferring Codex session cost scan until a later refresh", + metadata: [ + "path": metadata.path, + "pendingBytes": "\(pendingWorkBytes)", + "consumed": "\(budget.bytesConsumed)", + "limit": "\(budget.maxBytesPerRefresh)", + ]) + // Preserve stale cache so later refreshes can resume catch-up. + return + } + } else { + allowedWorkBytes = pendingWorkBytes + } + + if try Self.appendCodexFileIncrementIfPossible( + input: input, + context: context, + cache: &cache, + state: &state, + maxBytesToRead: allowedWorkBytes) + { + context.scanBudget?.consume(workBytes: allowedWorkBytes) return } - try Self.rescanCodexFile(input: input, context: context, cache: &cache, state: &state) + let fullRescanWorkBytes = max(0, metadata.size) + let fullRescanAllowedBytes: Int64 + if fullRescanWorkBytes == pendingWorkBytes { + fullRescanAllowedBytes = allowedWorkBytes + } else if let budget = context.scanBudget { + switch budget.admit(workBytes: fullRescanWorkBytes) { + case let .allow(allowance): + fullRescanAllowedBytes = allowance + case .deferBudget: + // No work was consumed by the rejected incremental path, so this is only + // reachable when the refresh budget has no allowance for the full rescan. + return + } + } else { + fullRescanAllowedBytes = fullRescanWorkBytes + } + + try Self.rescanCodexFile( + input: input, + context: context, + cache: &cache, + state: &state, + maxBytesToRead: fullRescanAllowedBytes) + context.scanBudget?.consume(workBytes: fullRescanAllowedBytes) + } + + static func pendingCodexScanWorkBytes(metadata: CodexFileMetadata, cached: CostUsageFileUsage?) -> Int64 { + // Called only after keepCachedCodexFileIfFresh failed. Even when size/mtime still match + // (forced full rescan, priority invalidation, fork-dependency drift, etc.), the scanner + // will read the whole file — never report zero pending work in that case. + guard let cached else { return max(0, metadata.size) } + if cached.codexScanComplete == false { + if cached.codexScanFileId != nil, + cached.codexScanFileId == metadata.fileId, + cached.codexScanTargetSize == metadata.size, + cached.mtimeUnixMs == metadata.mtimeUnixMs + { + return max(0, metadata.size - (cached.parsedBytes ?? 0)) + } + return max(0, metadata.size) + } + let startOffset = cached.parsedBytes ?? cached.size + if metadata.size > cached.size, + startOffset > 0, + startOffset <= metadata.size, + cached.forkedFromId == nil + { + return max(0, metadata.size - startOffset) + } + return max(0, metadata.size) } private static func makeCodexRefreshPlan( @@ -1646,6 +3288,7 @@ enum CostUsageScanner { let rootsChanged = cache.roots != rootsFingerprint let windowExpanded = Self.requestedWindowExpandsCache(range: range, cache: cache) let needsCostCacheMigration = cache.files.values.contains { Self.needsCodexCostCache($0, range: range) } + let needsProjectMetadataMigration = cache.codexProjectMetadataVersion != Self.codexProjectMetadataVersion let modelsDevLoad = ModelsDevCache.load(now: now, cacheRoot: options.cacheRoot) let modelsDevCatalog = modelsDevLoad.artifact?.catalog let codexPricingKey = Self.codexPricingKey(modelsDevArtifact: modelsDevLoad.artifact) @@ -1664,6 +3307,7 @@ enum CostUsageScanner { || windowExpanded || rootsChanged || needsCostCacheMigration + || needsProjectMetadataMigration || needsTurnIDCacheMigration || pricingChanged || priorityMetadataChanged @@ -1674,8 +3318,8 @@ enum CostUsageScanner { databaseURL: options.codexTraceDatabaseURL, sinceDayKey: range.scanSinceKey, untilDayKey: range.scanUntilKey) : [:] - let priorityTurnKeys = Self.codexPriorityTurnKeys(priorityTurns) - let priorityTurnIDsByDay = Self.codexPriorityTurnIDsByDay(priorityTurns) + let priorityTurnKeys = Self.codexPriorityTurnKeys(priorityTurns, calendar: range.calendar) + let priorityTurnIDsByDay = Self.codexPriorityTurnIDsByDay(priorityTurns, calendar: range.calendar) let priorityTurnsChanged = shouldInspectPriorityTurns && hasPriorityMetadata && Self.codexPriorityTurnKeysChanged( @@ -1694,6 +3338,7 @@ enum CostUsageScanner { || windowExpanded || rootsChanged || needsCostCacheMigration + || needsProjectMetadataMigration || needsTurnIDCacheMigration || pricingChanged || priorityMetadataChanged @@ -1709,6 +3354,7 @@ enum CostUsageScanner { rootsChanged: rootsChanged, windowExpanded: windowExpanded, needsCostCacheMigration: needsCostCacheMigration, + needsProjectMetadataMigration: needsProjectMetadataMigration, modelsDevCatalog: modelsDevCatalog, codexPricingKey: codexPricingKey, codexPriorityMetadataKey: codexPriorityMetadataKey, @@ -1724,13 +3370,29 @@ enum CostUsageScanner { shouldRefresh: shouldRefresh) } + private static func loadCodexCache(options: Options, range: CostUsageDayRange) -> CostUsageCache { + CostUsageCacheIO.load( + provider: .codex, + cacheRoot: options.cacheRoot, + calendar: range.calendar) + } + + private static func saveCodexCache(_ cache: CostUsageCache, options: Options, range: CostUsageDayRange) { + CostUsageCacheIO.save( + provider: .codex, + cache: cache, + cacheRoot: options.cacheRoot, + calendar: range.calendar) + } + + // swiftlint:disable:next function_body_length private static func loadCodexDaily( range: CostUsageDayRange, now: Date, options: Options, checkCancellation: CancellationCheck?) throws -> CostUsageDailyReport { - var cache = CostUsageCacheIO.load(provider: .codex, cacheRoot: options.cacheRoot) + var cache = Self.loadCodexCache(options: options, range: range) let nowMs = Int64(now.timeIntervalSince1970 * 1000) let plan = Self.makeCodexRefreshPlan(cache: cache, range: range, now: now, nowMs: nowMs, options: options) @@ -1743,8 +3405,7 @@ enum CostUsageScanner { let cachedSinceKey = cache.scanSinceKey let cachedUntilKey = cache.scanUntilKey let shouldRunColdCacheLookback = cache.files.isEmpty || plan.rootsChanged - let coldCacheLookbackStart = Self.parseDayKey(range.scanSinceKey) - .map { Calendar.current.startOfDay(for: $0) } + let coldCacheLookbackStart = Self.localStartOfDay(range.scanSinceKey, calendar: options.calendar) var seenPaths: Set = [] var files: [URL] = [] for root in plan.roots { @@ -1752,7 +3413,8 @@ enum CostUsageScanner { root: root, scanSinceKey: range.scanSinceKey, scanUntilKey: range.scanUntilKey, - includeRecursive: options.forceRescan) + includeRecursive: options.forceRescan, + calendar: options.calendar) for fileURL in rootFiles.sorted(by: { $0.path < $1.path }) where !seenPaths.contains(fileURL.path) { seenPaths.insert(fileURL.path) files.append(fileURL) @@ -1763,7 +3425,8 @@ enum CostUsageScanner { root: root, scanSinceKey: range.scanSinceKey, scanUntilKey: range.scanUntilKey, - modifiedSince: coldCacheLookbackStart) + modifiedSince: coldCacheLookbackStart, + calendar: options.calendar) for fileURL in recentlyModifiedFiles.sorted(by: { $0.path < $1.path }) where !seenPaths.contains(fileURL.path) { @@ -1773,48 +3436,70 @@ enum CostUsageScanner { } } - for fileURL in Self.cachedCodexSessionFiles(cache: cache, range: range, roots: plan.roots) + for fileURL in Self.cachedCodexSessionFiles( + cache: cache, + range: range, + roots: plan.roots, + excludingPaths: seenPaths) .sorted(by: { $0.path < $1.path }) - where !seenPaths.contains(fileURL.path) { seenPaths.insert(fileURL.path) files.append(fileURL) } - let filePathsInScan = Set(files.map(\.path)) + if options.preferNewestCodexSessionsFirst { + files = Self.sortedCodexSessionFilesNewestFirst(files) + } + let filePathsInScan = Set(files.map(\.path)) var scanState = CodexScanState() let fileIndex = CodexSessionFileIndex( files: files, roots: plan.roots, - cachedSessionFiles: Self.cachedCodexSessionIndex(cache: cache, roots: plan.roots), + cachedSessionFiles: Self.cachedCodexSessionIndex( + cache: cache, + roots: plan.roots, + knownExistingPaths: filePathsInScan), checkCancellation: checkCancellation) + let scanBudget = CodexScanBudget( + maxFileBytes: options.maxCodexSessionFileBytes, + maxBytesPerRefresh: options.maxCodexScanBytesPerRefresh) let inheritedResolver = CodexInheritedTotalsResolver( fileIndex: fileIndex, - checkCancellation: checkCancellation) + checkCancellation: checkCancellation, + scanBudget: scanBudget) let resources = CodexScanResources( fileIndex: fileIndex, inheritedResolver: inheritedResolver, + projectPathResolver: CodexCanonicalProjectPathResolver(), modelsDevCatalog: plan.modelsDevCatalog, modelsDevCacheRoot: options.cacheRoot, priorityTurns: plan.priorityTurns) + let scanContext = Self.codexFileScanContext( + range: range, + options: options, + plan: plan, + resources: resources, + checkCancellation: checkCancellation, + scanBudget: scanBudget) for fileURL in files { try Self.scanCodexFile( fileURL: fileURL, - context: CodexFileScanContext( - range: range, - forceFullScan: options - .forceRescan || plan.windowExpanded || plan.pricingChanged || plan.priorityMetadataChanged, - dropDeferredCodexRows: options.forceRescan || plan.pricingChanged || plan - .priorityMetadataChanged - || plan.needsTurnIDCacheMigration, - requiresTurnIDCache: plan.needsTurnIDCacheMigration, - changedPriorityTurnIDs: plan.changedPriorityTurnIDs, - resources: resources, - checkCancellation: checkCancellation), + context: scanContext, cache: &cache, state: &scanState) } + if scanBudget.resumedPartialFileCount > 0 || scanBudget.deferredByBudgetFileCount > 0 { + Self.log.info( + "Codex cost scan applied work limits", + metadata: [ + "partialFiles": "\(scanBudget.resumedPartialFileCount)", + "deferredByBudget": "\(scanBudget.deferredByBudgetFileCount)", + "bytesConsumed": "\(scanBudget.bytesConsumed)", + "maxFileBytes": "\(scanBudget.maxFileBytes)", + "maxBytesPerRefresh": "\(scanBudget.maxBytesPerRefresh)", + ]) + } try checkCancellation?() Self.pruneForceRescanFilesOutsideWindow( @@ -1823,6 +3508,7 @@ enum CostUsageScanner { isForceRescan: options.forceRescan) let shouldDropAllUnscannedFiles = options.forceRescan || plan.rootsChanged || cache.files.isEmpty + || plan.needsProjectMetadataMigration for key in cache.files.keys where !filePathsInScan.contains(key) { guard let old = cache.files[key] else { continue } let shouldDrop = shouldDropAllUnscannedFiles || @@ -1846,7 +3532,7 @@ enum CostUsageScanner { } let shouldRetainWiderWindow = !options.forceRescan && !plan.pricingChanged && !plan - .priorityMetadataChanged && !plan.needsTurnIDCacheMigration + .priorityMetadataChanged && !plan.needsTurnIDCacheMigration && !plan.needsProjectMetadataMigration let retainedSinceKey = shouldRetainWiderWindow ? [cachedSinceKey, range.scanSinceKey].compactMap(\.self).min() ?? range.scanSinceKey : range.scanSinceKey @@ -1859,6 +3545,7 @@ enum CostUsageScanner { cache.scanUntilKey = retainedUntilKey cache.codexPricingKey = plan.codexPricingKey cache.codexPriorityMetadataKey = plan.codexPriorityMetadataKey + cache.codexProjectMetadataVersion = Self.codexProjectMetadataVersion if plan.hasPriorityMetadata { cache.codexPriorityTurnKeys = Self.mergePriorityTurnKeys( existing: shouldRetainWiderWindow ? cache.codexPriorityTurnKeys : nil, @@ -1875,7 +3562,7 @@ enum CostUsageScanner { } cache.lastScanUnixMs = nowMs try checkCancellation?() - CostUsageCacheIO.save(provider: .codex, cache: cache, cacheRoot: options.cacheRoot) + Self.saveCodexCache(cache, options: options, range: range) } return Self.buildCodexReportFromCache( @@ -1885,6 +3572,41 @@ enum CostUsageScanner { modelsDevCacheRoot: options.cacheRoot, priorityTurns: plan.priorityTurns) } + + private static func codexFileScanContext( + range: CostUsageDayRange, + options: Options, + plan: CodexRefreshPlan, + resources: CodexScanResources, + checkCancellation: CancellationCheck?, + scanBudget: CodexScanBudget? = nil) -> CodexFileScanContext + { + CodexFileScanContext( + range: range, + forceFullScan: options.forceRescan || plan.windowExpanded || plan.pricingChanged + || plan.priorityMetadataChanged || plan.needsProjectMetadataMigration, + dropDeferredCodexRows: options.forceRescan || plan.pricingChanged || plan.priorityMetadataChanged + || plan.needsTurnIDCacheMigration, + requiresTurnIDCache: plan.needsTurnIDCacheMigration, + changedPriorityTurnIDs: plan.changedPriorityTurnIDs, + resources: resources, + checkCancellation: checkCancellation, + scanBudget: scanBudget) + } + + static func sortedCodexSessionFilesNewestFirst(_ files: [URL]) -> [URL] { + files.sorted { lhs, rhs in + let left = Self.codexFileMetadata(fileURL: lhs) + let right = Self.codexFileMetadata(fileURL: rhs) + if left.mtimeUnixMs != right.mtimeUnixMs { + return left.mtimeUnixMs > right.mtimeUnixMs + } + if left.size != right.size { + return left.size < right.size + } + return lhs.path < rhs.path + } + } } // swiftlint:enable type_body_length diff --git a/Sources/CodexBarCore/Vendored/CostUsage/ModelsDevPricing.swift b/Sources/CodexBarCore/Vendored/CostUsage/ModelsDevPricing.swift index 87801eb678..6915086291 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/ModelsDevPricing.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/ModelsDevPricing.swift @@ -68,17 +68,36 @@ struct ModelsDevCatalog: Codable, Equatable { return self.providers[providerID]?.pricing(modelID: rawModelID) } - func containsProviderIDs(_ providerIDs: some Sequence) -> Bool { - providerIDs.allSatisfy { self.providers.keys.contains(ModelsDevProvider.normalizeProviderID($0)) } + func isPlausibleRefresh() -> Bool { + // These are the direct pricing sources CodexBar relies on. Requiring both + // rejects empty/partial responses without comparing against a fallback- + // enriched cache that intentionally grows as models.dev churns. + ["anthropic", "openai"].allSatisfy { providerID in + self.providers[providerID]?.models.values.contains(where: \.isPriceable) == true + } } - func containsProviderModels(from cachedCatalog: ModelsDevCatalog) -> Bool { - cachedCatalog.providers.allSatisfy { providerID, cachedProvider in - guard let provider = self.providers[ModelsDevProvider.normalizeProviderID(providerID)] else { return false } - return cachedProvider.models.values - .filter(\.isPriceable) - .allSatisfy { provider.containsModel(matching: $0) } + func mergingFallbackPricing(from cachedCatalog: ModelsDevCatalog) -> ModelsDevCatalog { + var merged = self + for (providerID, cachedProvider) in cachedCatalog.providers { + let normalizedProviderID = ModelsDevProvider.normalizeProviderID(providerID) + guard var provider = merged.providers[normalizedProviderID] else { + merged.providers[normalizedProviderID] = cachedProvider + continue + } + + for (modelKey, cachedModel) in cachedProvider.models + where cachedModel.isPriceable && !provider.containsPricedModel( + withStableIdentity: cachedModel.stableIdentity) + { + let fallbackKey = provider.models[modelKey] == nil + ? modelKey + : "codexbar-fallback:\(modelKey):\(cachedModel.normalizedID)" + provider.models[fallbackKey] = cachedModel + } + merged.providers[normalizedProviderID] = provider } + return merged } } @@ -149,21 +168,21 @@ struct ModelsDevProvider: Codable, Equatable { { return ModelsDevPricingLookup(pricing: pricing, normalizedModelID: candidate) } - } - for candidate in candidates { - if let match = self.models.values.first(where: { $0.normalizedID == candidate }), - let pricing = match.pricing(providerID: self.id ?? self.mapKey ?? "", providerName: self.name) - { - return ModelsDevPricingLookup(pricing: pricing, normalizedModelID: match.normalizedID) + for match in self.models.values where match.normalizedID == candidate { + if let pricing = match.pricing(providerID: self.id ?? self.mapKey ?? "", providerName: self.name) { + return ModelsDevPricingLookup(pricing: pricing, normalizedModelID: match.normalizedID) + } } } return nil } - func containsModel(matching cachedModel: ModelsDevModel) -> Bool { - self.pricing(modelID: cachedModel.id) != nil + func containsPricedModel(withStableIdentity modelID: String) -> Bool { + self.models.values.contains { model in + model.isPriceable && model.stableIdentity == modelID + } } } @@ -177,6 +196,10 @@ struct ModelsDevModel: Codable, Equatable { ModelsDevModelIDNormalizer.normalize(self.id) } + var stableIdentity: String { + ModelsDevModelIDNormalizer.stableIdentity(self.id) + } + var isPriceable: Bool { self.cost?.input != nil && self.cost?.output != nil } @@ -244,7 +267,29 @@ enum ModelsDevModelIDNormalizer { raw.trimmingCharacters(in: .whitespacesAndNewlines) } - static func candidates(_ raw: String) -> [String] { + static func stableIdentity(_ raw: String) -> String { + let normalized = self.normalize(raw) + if let atSign = normalized.firstIndex(of: "@") { + let base = String(normalized[.. String { + self.candidates(raw, preserveDatedSnapshots: true).reversed().lazy + .map { candidate in + guard candidate.hasSuffix("@default") else { return candidate } + return String(candidate.dropLast("@default".count)) + } + .first { !$0.isEmpty } ?? self.normalize(raw) + } + + static func candidates(_ raw: String, preserveDatedSnapshots: Bool = false) -> [String] { var candidates: [String] = [] func append(_ value: String) { @@ -278,20 +323,22 @@ enum ModelsDevModelIDNormalizer { let candidate = candidates[index] if let atSign = candidate.firstIndex(of: "@") { let base = String(candidate[.. Outcome? { + self.lock.lock() + defer { self.lock.unlock() } + guard let entry = self.entries[path], + entry.modificationDate == modificationDate, + entry.size == size + else { + return nil + } + return entry.outcome + } + + func store(path: String, modificationDate: Date?, size: Int?, outcome: Outcome) { + self.lock.lock() + defer { self.lock.unlock() } + self.entries[path] = Entry(modificationDate: modificationDate, size: size, outcome: outcome) + } + + func invalidate(path: String) { + self.lock.lock() + defer { self.lock.unlock() } + self.entries.removeValue(forKey: path) + } +} + enum ModelsDevCache { enum Error: Swift.Error, Equatable { case unreadable @@ -328,6 +426,17 @@ enum ModelsDevCache { static let artifactVersion = 1 static let ttlSeconds: TimeInterval = 24 * 60 * 60 + private static let memo = ModelsDevCacheMemo() + + private static func fileMetadata(at url: URL) -> (modificationDate: Date?, size: Int?) { + guard let attributes = try? FileManager.default.attributesOfItem(atPath: url.path) else { + return (nil, nil) + } + let modificationDate = attributes[.modificationDate] as? Date + let size = (attributes[.size] as? NSNumber)?.intValue + return (modificationDate, size) + } + private static func defaultCacheRoot() -> URL { let root = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first! return root.appendingPathComponent("CodexBar", isDirectory: true) @@ -342,41 +451,72 @@ enum ModelsDevCache { static func load(now: Date = Date(), cacheRoot: URL? = nil) -> ModelsDevCacheLoadResult { let url = self.cacheFileURL(cacheRoot: cacheRoot) + let metadata = Self.fileMetadata(at: url) + + // Staleness depends on `now`, so the result is always rebuilt; only the read+decode outcome is memoized. + if let outcome = Self.memo.outcome( + path: url.path, + modificationDate: metadata.modificationDate, + size: metadata.size) + { + return Self.result(for: outcome, now: now) + } + + let outcome = Self.readOutcome(at: url) + Self.memo.store( + path: url.path, + modificationDate: metadata.modificationDate, + size: metadata.size, + outcome: outcome) + return Self.result(for: outcome, now: now) + } + + private static func readOutcome(at url: URL) -> ModelsDevCacheMemo.Outcome { guard let data = try? Data(contentsOf: url) else { - return ModelsDevCacheLoadResult(artifact: nil, isStale: true, error: .unreadable) + return .failure(.unreadable) } let decoder = JSONDecoder() decoder.dateDecodingStrategy = .iso8601 guard let decoded = try? decoder.decode(ModelsDevCacheArtifact.self, from: data) else { - return ModelsDevCacheLoadResult(artifact: nil, isStale: true, error: .invalidJSON) + return .failure(.invalidJSON) } guard decoded.version == Self.artifactVersion else { - return ModelsDevCacheLoadResult(artifact: nil, isStale: true, error: .invalidVersion) + return .failure(.invalidVersion) + } + return .decoded(decoded) + } + + private static func result(for outcome: ModelsDevCacheMemo.Outcome, now: Date) -> ModelsDevCacheLoadResult { + switch outcome { + case let .decoded(artifact): + ModelsDevCacheLoadResult( + artifact: artifact, + isStale: now.timeIntervalSince(artifact.fetchedAt) > Self.ttlSeconds, + error: nil) + case let .failure(error): + ModelsDevCacheLoadResult(artifact: nil, isStale: true, error: error) } - - return ModelsDevCacheLoadResult( - artifact: decoded, - isStale: now.timeIntervalSince(decoded.fetchedAt) > Self.ttlSeconds, - error: nil) } - static func save(catalog: ModelsDevCatalog, fetchedAt: Date = Date(), cacheRoot: URL? = nil) { + @discardableResult + static func save(catalog: ModelsDevCatalog, fetchedAt: Date = Date(), cacheRoot: URL? = nil) -> Bool { let artifact = ModelsDevCacheArtifact( version: Self.artifactVersion, fetchedAt: fetchedAt, catalog: catalog) - self.save(artifact: artifact, cacheRoot: cacheRoot) + return self.save(artifact: artifact, cacheRoot: cacheRoot) } - static func save(artifact: ModelsDevCacheArtifact, cacheRoot: URL? = nil) { + @discardableResult + static func save(artifact: ModelsDevCacheArtifact, cacheRoot: URL? = nil) -> Bool { let url = self.cacheFileURL(cacheRoot: cacheRoot) let dir = url.deletingLastPathComponent() try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) let encoder = JSONEncoder() encoder.dateEncodingStrategy = .iso8601 - guard let data = try? encoder.encode(artifact) else { return } + guard let data = try? encoder.encode(artifact) else { return false } let tmp = dir.appendingPathComponent(".tmp-\(UUID().uuidString).json", isDirectory: false) do { @@ -386,8 +526,12 @@ enum ModelsDevCache { } else { try FileManager.default.moveItem(at: tmp, to: url) } + // The on-disk catalog changed; drop the memo so the next load decodes the fresh file. + Self.memo.invalidate(path: url.path) + return true } catch { try? FileManager.default.removeItem(at: tmp) + return false } } } @@ -402,7 +546,7 @@ struct URLSessionModelsDevTransport: ModelsDevHTTPTransport { } } -struct ModelsDevClient { +struct ModelsDevClient: Sendable { enum Error: Swift.Error, Equatable { case invalidResponse case httpStatus(Int) @@ -437,7 +581,16 @@ struct ModelsDevClient { } } +enum ModelsDevUnknownModelRefreshOutcome: Equatable { + case pricingAvailable + case unavailable +} + +private let modelsDevCatalogRetryInterval: TimeInterval = 15 * 60 + enum ModelsDevPricingPipeline { + private static let refreshCoordinator = ModelsDevRefreshCoordinator() + static func lookup( providerID: String, modelID: String, @@ -458,16 +611,107 @@ enum ModelsDevPricingPipeline { let load = ModelsDevCache.load(now: now, cacheRoot: cacheRoot) guard load.isStale else { return } + let cachePath = ModelsDevCache.cacheFileURL(cacheRoot: cacheRoot).standardizedFileURL.path + _ = await self.refreshCoordinator.refresh( + cachePath: cachePath, + now: now) + { + await self.refreshStaleCache(now: now, cacheRoot: cacheRoot, client: client) + } + } + + static func refreshForUnknownModelsIfNeeded( + providerID: String, + modelIDs: Set, + now: Date = Date(), + cacheRoot: URL? = nil, + client: ModelsDevClient = ModelsDevClient()) async -> ModelsDevUnknownModelRefreshOutcome + { + guard !modelIDs.isEmpty else { return .unavailable } + let load = ModelsDevCache.load(now: now, cacheRoot: cacheRoot) + let unknownModelIDs = modelIDs.filter { + load.artifact?.catalog.pricing(providerID: providerID, modelID: $0) == nil + } + guard !unknownModelIDs.isEmpty else { return .pricingAvailable } + if let fetchedAt = load.artifact?.fetchedAt, + now.timeIntervalSince(fetchedAt) < modelsDevCatalogRetryInterval + { + return .unavailable + } + + let cachePath = ModelsDevCache.cacheFileURL(cacheRoot: cacheRoot).standardizedFileURL.path + _ = await self.refreshCoordinator.refresh( + cachePath: cachePath, + now: now) + { + await self.performRefresh(now: now, cacheRoot: cacheRoot, client: client) + } + + let refreshedCatalog = ModelsDevCache.load(now: now, cacheRoot: cacheRoot).artifact?.catalog + let pricingBecameAvailable = unknownModelIDs.contains { + refreshedCatalog?.pricing(providerID: providerID, modelID: $0) != nil + } + return pricingBecameAvailable ? .pricingAvailable : .unavailable + } + + private static func performRefresh( + now: Date, + cacheRoot: URL?, + client: ModelsDevClient) async -> Bool + { do { let catalog = try await client.fetchCatalog() - if let oldCatalog = load.artifact?.catalog, - !catalog.containsProviderModels(from: oldCatalog) - { - return - } - ModelsDevCache.save(catalog: catalog, fetchedAt: now, cacheRoot: cacheRoot) + guard catalog.isPlausibleRefresh() else { return false } + let oldCatalog = ModelsDevCache.load(now: now, cacheRoot: cacheRoot).artifact?.catalog + let refreshedCatalog = oldCatalog.map { catalog.mergingFallbackPricing(from: $0) } ?? catalog + return ModelsDevCache.save(catalog: refreshedCatalog, fetchedAt: now, cacheRoot: cacheRoot) } catch { - // Best-effort refresh only. Future scanner integration should keep using the last valid cache. + return false + } + } + + static func refreshStaleCache( + now: Date, + cacheRoot: URL?, + client: ModelsDevClient) async -> Bool + { + guard ModelsDevCache.load(now: now, cacheRoot: cacheRoot).isStale else { return true } + return await self.performRefresh(now: now, cacheRoot: cacheRoot, client: client) + } +} + +private actor ModelsDevRefreshCoordinator { + private struct InFlightRefresh { + let id: UUID + let task: Task + } + + private var inFlightByCachePath: [String: InFlightRefresh] = [:] + private var lastCatalogAttemptByCachePath: [String: Date] = [:] + + func refresh( + cachePath: String, + now: Date, + operation: @escaping @Sendable () async -> Bool) async -> Bool + { + if let inFlight = self.inFlightByCachePath[cachePath] { + return await inFlight.task.value + } + if let lastAttempt = self.lastCatalogAttemptByCachePath[cachePath], + now.timeIntervalSince(lastAttempt) < modelsDevCatalogRetryInterval + { + return false + } + self.lastCatalogAttemptByCachePath[cachePath] = now + + let inFlight = InFlightRefresh( + id: UUID(), + task: Task { await operation() }) + self.inFlightByCachePath[cachePath] = inFlight + let result = await inFlight.task.value + if self.inFlightByCachePath[cachePath]?.id == inFlight.id { + self.inFlightByCachePath[cachePath] = nil } + return result } } diff --git a/Sources/CodexBarCore/WidgetSnapshot.swift b/Sources/CodexBarCore/WidgetSnapshot.swift index 17affa8b1c..1080856a10 100644 --- a/Sources/CodexBarCore/WidgetSnapshot.swift +++ b/Sources/CodexBarCore/WidgetSnapshot.swift @@ -5,11 +5,13 @@ public struct WidgetSnapshot: Codable, Sendable { public let id: String public let title: String public let percentLeft: Double? + public let window: RateWindow? - public init(id: String, title: String, percentLeft: Double?) { + public init(id: String, title: String, percentLeft: Double?, window: RateWindow? = nil) { self.id = id self.title = title self.percentLeft = percentLeft + self.window = window } } @@ -24,6 +26,8 @@ public struct WidgetSnapshot: Codable, Sendable { public let codeReviewRemainingPercent: Double? public let tokenUsage: TokenUsageSummary? public let dailyUsage: [DailyUsagePoint] + public let providerCost: ProviderCostSnapshot? + public let quotaOwnerKey: String? public init( provider: UsageProvider, @@ -35,7 +39,9 @@ public struct WidgetSnapshot: Codable, Sendable { creditsRemaining: Double?, codeReviewRemainingPercent: Double?, tokenUsage: TokenUsageSummary?, - dailyUsage: [DailyUsagePoint]) + dailyUsage: [DailyUsagePoint], + providerCost: ProviderCostSnapshot? = nil, + quotaOwnerKey: String? = nil) { self.provider = provider self.updatedAt = updatedAt @@ -47,10 +53,16 @@ public struct WidgetSnapshot: Codable, Sendable { self.codeReviewRemainingPercent = codeReviewRemainingPercent self.tokenUsage = tokenUsage self.dailyUsage = dailyUsage + self.providerCost = providerCost + self.quotaOwnerKey = quotaOwnerKey } } public struct TokenUsageSummary: Codable, Sendable { + /// Token-cost rows refresh on a slower cadence than quota rows; beyond this lag the + /// widget discloses their own age instead of inheriting `ProviderEntry.updatedAt`. + public static let staleLagThreshold: TimeInterval = 10 * 60 + public let sessionCostUSD: Double? public let sessionTokens: Int? public let last30DaysCostUSD: Double? @@ -58,6 +70,7 @@ public struct WidgetSnapshot: Codable, Sendable { public let currencyCode: String public let sessionLabel: String public let last30DaysLabel: String + public let updatedAt: Date? public init( sessionCostUSD: Double?, @@ -66,7 +79,8 @@ public struct WidgetSnapshot: Codable, Sendable { last30DaysTokens: Int?, currencyCode: String = "USD", sessionLabel: String = "Today", - last30DaysLabel: String = "30d") + last30DaysLabel: String = "30d", + updatedAt: Date? = nil) { self.sessionCostUSD = sessionCostUSD self.sessionTokens = sessionTokens @@ -81,6 +95,13 @@ public struct WidgetSnapshot: Codable, Sendable { self.last30DaysLabel = last30DaysLabel.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? "30d" : last30DaysLabel + self.updatedAt = updatedAt + } + + /// Unknown age (legacy snapshots) counts as fresh. + public func isStale(comparedTo entryUpdatedAt: Date) -> Bool { + guard let updatedAt else { return false } + return entryUpdatedAt.timeIntervalSince(updatedAt) > Self.staleLagThreshold } private enum CodingKeys: String, CodingKey { @@ -91,6 +112,7 @@ public struct WidgetSnapshot: Codable, Sendable { case currencyCode case sessionLabel case last30DaysLabel + case updatedAt } public init(from decoder: Decoder) throws { @@ -102,7 +124,8 @@ public struct WidgetSnapshot: Codable, Sendable { last30DaysTokens: container.decodeIfPresent(Int.self, forKey: .last30DaysTokens), currencyCode: container.decodeIfPresent(String.self, forKey: .currencyCode) ?? "USD", sessionLabel: container.decodeIfPresent(String.self, forKey: .sessionLabel) ?? "Today", - last30DaysLabel: container.decodeIfPresent(String.self, forKey: .last30DaysLabel) ?? "30d") + last30DaysLabel: container.decodeIfPresent(String.self, forKey: .last30DaysLabel) ?? "30d", + updatedAt: container.decodeIfPresent(Date.self, forKey: .updatedAt)) } } @@ -120,17 +143,25 @@ public struct WidgetSnapshot: Codable, Sendable { public let entries: [ProviderEntry] public let enabledProviders: [UsageProvider] + public let usageBarsShowUsed: Bool public let generatedAt: Date - public init(entries: [ProviderEntry], enabledProviders: [UsageProvider]? = nil, generatedAt: Date) { + public init( + entries: [ProviderEntry], + enabledProviders: [UsageProvider]? = nil, + usageBarsShowUsed: Bool = false, + generatedAt: Date) + { self.entries = entries self.enabledProviders = enabledProviders ?? entries.map(\.provider) + self.usageBarsShowUsed = usageBarsShowUsed self.generatedAt = generatedAt } private enum CodingKeys: String, CodingKey { case entries case enabledProviders + case usageBarsShowUsed case generatedAt } @@ -140,12 +171,14 @@ public struct WidgetSnapshot: Codable, Sendable { self.generatedAt = try container.decode(Date.self, forKey: .generatedAt) self.enabledProviders = try container.decodeIfPresent([UsageProvider].self, forKey: .enabledProviders) ?? self.entries.map(\.provider) + self.usageBarsShowUsed = try container.decodeIfPresent(Bool.self, forKey: .usageBarsShowUsed) ?? false } public func encode(to encoder: Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) try container.encode(self.entries, forKey: .entries) try container.encode(self.enabledProviders, forKey: .enabledProviders) + try container.encode(self.usageBarsShowUsed, forKey: .usageBarsShowUsed) try container.encode(self.generatedAt, forKey: .generatedAt) } } diff --git a/Sources/CodexBarMacroSupport/ProviderRegistrationMacros.swift b/Sources/CodexBarMacroSupport/ProviderRegistrationMacros.swift deleted file mode 100644 index 4adf0218b4..0000000000 --- a/Sources/CodexBarMacroSupport/ProviderRegistrationMacros.swift +++ /dev/null @@ -1,14 +0,0 @@ -@attached(peer, names: prefixed(_CodexBarDescriptorRegistration_)) -public macro ProviderDescriptorRegistration() = #externalMacro( - module: "CodexBarMacros", - type: "ProviderDescriptorRegistrationMacro") - -@attached(member, names: named(descriptor)) -public macro ProviderDescriptorDefinition() = #externalMacro( - module: "CodexBarMacros", - type: "ProviderDescriptorDefinitionMacro") - -@attached(peer, names: prefixed(_CodexBarImplementationRegistration_)) -public macro ProviderImplementationRegistration() = #externalMacro( - module: "CodexBarMacros", - type: "ProviderImplementationRegistrationMacro") diff --git a/Sources/CodexBarMacros/ProviderRegistrationMacros.swift b/Sources/CodexBarMacros/ProviderRegistrationMacros.swift deleted file mode 100644 index 1072e73fec..0000000000 --- a/Sources/CodexBarMacros/ProviderRegistrationMacros.swift +++ /dev/null @@ -1,216 +0,0 @@ -import SwiftCompilerPlugin -import SwiftDiagnostics -import SwiftSyntax -import SwiftSyntaxBuilder -import SwiftSyntaxMacros - -private enum ProviderMacroError { - struct Message: DiagnosticMessage { - let message: String - let diagnosticID: MessageID - let severity: DiagnosticSeverity - } - - static func unsupportedTarget(_ context: some MacroExpansionContext, node: SyntaxProtocol, macro: String) { - context.diagnose(Diagnostic( - node: node, - message: Message( - message: "@\(macro) must be attached to a struct, class, or enum.", - diagnosticID: MessageID(domain: "CodexBarMacros", id: "unsupported_target"), - severity: .error))) - } - - static func missingDescriptor(_ context: some MacroExpansionContext, node: SyntaxProtocol, typeName: String) { - context.diagnose(Diagnostic( - node: node, - message: Message( - message: "\(typeName) must declare static let descriptor or static func makeDescriptor() " + - "to use @ProviderDescriptorRegistration.", - diagnosticID: MessageID(domain: "CodexBarMacros", id: "missing_descriptor"), - severity: .error))) - } - - static func missingMakeDescriptor(_ context: some MacroExpansionContext, node: SyntaxProtocol, typeName: String) { - context.diagnose(Diagnostic( - node: node, - message: Message( - message: "\(typeName) must declare static func makeDescriptor() to use @ProviderDescriptorDefinition.", - diagnosticID: MessageID(domain: "CodexBarMacros", id: "missing_make_descriptor"), - severity: .error))) - } - - static func duplicateDescriptor(_ context: some MacroExpansionContext, node: SyntaxProtocol, typeName: String) { - context.diagnose(Diagnostic( - node: node, - message: Message( - message: "\(typeName) already declares descriptor; remove @ProviderDescriptorDefinition.", - diagnosticID: MessageID(domain: "CodexBarMacros", id: "duplicate_descriptor"), - severity: .error))) - } - - static func missingInit(_ context: some MacroExpansionContext, node: SyntaxProtocol, typeName: String) { - context.diagnose(Diagnostic( - node: node, - message: Message( - message: "\(typeName) must provide an init() to use @ProviderImplementationRegistration.", - diagnosticID: MessageID(domain: "CodexBarMacros", id: "missing_init"), - severity: .error))) - } -} - -private enum ProviderMacroIntrospection { - static func typeDecl(from declaration: some DeclSyntaxProtocol) -> (decl: DeclGroupSyntax, name: String)? { - if let decl = declaration.as(StructDeclSyntax.self) { return (decl, decl.name.text) } - if let decl = declaration.as(ClassDeclSyntax.self) { return (decl, decl.name.text) } - if let decl = declaration.as(EnumDeclSyntax.self) { return (decl, decl.name.text) } - return nil - } - - static func hasStaticDescriptor(in decl: DeclGroupSyntax) -> Bool { - for member in decl.memberBlock.members { - guard let varDecl = member.decl.as(VariableDeclSyntax.self) else { continue } - guard self.isStatic(varDecl.modifiers) else { continue } - for binding in varDecl.bindings { - guard let pattern = binding.pattern.as(IdentifierPatternSyntax.self) else { continue } - if pattern.identifier.text == "descriptor" { return true } - } - } - return false - } - - static func hasMakeDescriptor(in decl: DeclGroupSyntax) -> Bool { - for member in decl.memberBlock.members { - guard let funcDecl = member.decl.as(FunctionDeclSyntax.self) else { continue } - guard self.isStatic(funcDecl.modifiers) else { continue } - if funcDecl.name.text == "makeDescriptor" { return true } - } - return false - } - - static func hasAccessibleInit(in decl: DeclGroupSyntax) -> Bool { - if self.hasZeroArgInit(in: decl) { return true } - if decl.is(EnumDeclSyntax.self) { return false } - return self.canSynthesizeDefaultInit(in: decl) - } - - private static func hasZeroArgInit(in decl: DeclGroupSyntax) -> Bool { - for member in decl.memberBlock.members { - guard let initDecl = member.decl.as(InitializerDeclSyntax.self) else { continue } - let params = initDecl.signature.parameterClause.parameters - if params.isEmpty { return true } - let allDefaulted = params.allSatisfy { $0.defaultValue != nil } - if allDefaulted { return true } - } - return false - } - - private static func canSynthesizeDefaultInit(in decl: DeclGroupSyntax) -> Bool { - for member in decl.memberBlock.members { - guard let varDecl = member.decl.as(VariableDeclSyntax.self) else { continue } - guard !self.isStatic(varDecl.modifiers) else { continue } - for binding in varDecl.bindings { - if binding.accessorBlock != nil { continue } - if binding.initializer == nil { return false } - } - } - return true - } - - private static func isStatic(_ modifiers: DeclModifierListSyntax?) -> Bool { - guard let modifiers else { return false } - return modifiers.contains { $0.name.tokenKind == .keyword(.static) } - } -} - -public struct ProviderDescriptorRegistrationMacro: PeerMacro { - public static func expansion( - of _: AttributeSyntax, - providingPeersOf declaration: some DeclSyntaxProtocol, - in context: some MacroExpansionContext) throws -> [DeclSyntax] - { - guard let (decl, typeName) = ProviderMacroIntrospection.typeDecl(from: declaration) else { - ProviderMacroError.unsupportedTarget( - context, - node: Syntax(declaration), - macro: "ProviderDescriptorRegistration") - return [] - } - - let hasDescriptor = ProviderMacroIntrospection.hasStaticDescriptor(in: decl) - let hasMakeDescriptor = ProviderMacroIntrospection.hasMakeDescriptor(in: decl) - guard hasDescriptor || hasMakeDescriptor else { - ProviderMacroError.missingDescriptor(context, node: Syntax(declaration), typeName: typeName) - return [] - } - - let registerName = "_CodexBarDescriptorRegistration_\(typeName)" - return [ - DeclSyntax( - "private let \(raw: registerName) = ProviderDescriptorRegistry.register(\(raw: typeName).descriptor)"), - ] - } -} - -public struct ProviderDescriptorDefinitionMacro: MemberMacro { - public static func expansion( - of _: AttributeSyntax, - providingMembersOf declaration: some DeclGroupSyntax, - in context: some MacroExpansionContext) throws -> [DeclSyntax] - { - guard let (decl, typeName) = ProviderMacroIntrospection.typeDecl(from: declaration) else { - ProviderMacroError.unsupportedTarget( - context, - node: Syntax(declaration), - macro: "ProviderDescriptorDefinition") - return [] - } - - if ProviderMacroIntrospection.hasStaticDescriptor(in: decl) { - ProviderMacroError.duplicateDescriptor(context, node: Syntax(declaration), typeName: typeName) - return [] - } - - guard ProviderMacroIntrospection.hasMakeDescriptor(in: decl) else { - ProviderMacroError.missingMakeDescriptor(context, node: Syntax(declaration), typeName: typeName) - return [] - } - - return [DeclSyntax("public static let descriptor: ProviderDescriptor = Self.makeDescriptor()")] - } -} - -public struct ProviderImplementationRegistrationMacro: PeerMacro { - public static func expansion( - of _: AttributeSyntax, - providingPeersOf declaration: some DeclSyntaxProtocol, - in context: some MacroExpansionContext) throws -> [DeclSyntax] - { - guard let (decl, typeName) = ProviderMacroIntrospection.typeDecl(from: declaration) else { - ProviderMacroError.unsupportedTarget( - context, - node: Syntax(declaration), - macro: "ProviderImplementationRegistration") - return [] - } - - guard ProviderMacroIntrospection.hasAccessibleInit(in: decl) else { - ProviderMacroError.missingInit(context, node: Syntax(declaration), typeName: typeName) - return [] - } - - let registerName = "_CodexBarImplementationRegistration_\(typeName)" - return [ - DeclSyntax( - "private let \(raw: registerName) = ProviderImplementationRegistry.register(\(raw: typeName)())"), - ] - } -} - -@main -struct CodexBarMacroPlugin: CompilerPlugin { - let providingMacros: [Macro.Type] = [ - ProviderDescriptorRegistrationMacro.self, - ProviderDescriptorDefinitionMacro.self, - ProviderImplementationRegistrationMacro.self, - ] -} diff --git a/Sources/CodexBarWidget/BurnDownWidgetProvider.swift b/Sources/CodexBarWidget/BurnDownWidgetProvider.swift new file mode 100644 index 0000000000..351936a437 --- /dev/null +++ b/Sources/CodexBarWidget/BurnDownWidgetProvider.swift @@ -0,0 +1,234 @@ +import AppIntents +import CodexBarCore +import WidgetKit + +enum BurnProviderChoice: String, AppEnum { + case codex + case claude + + static let typeDisplayRepresentation = TypeDisplayRepresentation(name: "Provider") + + static let caseDisplayRepresentations: [BurnProviderChoice: DisplayRepresentation] = [ + .codex: DisplayRepresentation(title: "Codex"), + .claude: DisplayRepresentation(title: "Claude"), + ] + + var provider: UsageProvider { + switch self { + case .codex: .codex + case .claude: .claude + } + } +} + +enum BurnWindowChoice: String, AppEnum { + case session + case weekly + + static let typeDisplayRepresentation = TypeDisplayRepresentation(name: "Usage window") + + static let caseDisplayRepresentations: [BurnWindowChoice: DisplayRepresentation] = [ + .session: DisplayRepresentation(title: "Session (5-hour)"), + .weekly: DisplayRepresentation(title: "Weekly (7-day)"), + ] +} + +struct BurnDownSelectionIntent: AppIntent, WidgetConfigurationIntent { + static let title: LocalizedStringResource = "Burn Down" + static let description = IntentDescription("Select the provider and usage window to display.") + + @Parameter(title: "Provider", default: .codex) + var provider: BurnProviderChoice + + @Parameter(title: "Usage window", default: .session) + var window: BurnWindowChoice + + init() { + self.provider = .codex + self.window = .session + } +} + +struct BurnProviderSelectionIntent: AppIntent, WidgetConfigurationIntent { + static let title: LocalizedStringResource = "Burn Down Provider" + static let description = IntentDescription("Select the provider to display.") + + @Parameter(title: "Provider", default: .codex) + var provider: BurnProviderChoice + + init() { + self.provider = .codex + } +} + +struct BurnDownEntry: TimelineEntry { + let date: Date + let provider: UsageProvider + let window: BurnWindowChoice + let snapshot: WidgetSnapshot +} + +struct CombinedBurnDownEntry: TimelineEntry { + let date: Date + let provider: UsageProvider + let snapshot: WidgetSnapshot +} + +struct BurnDownState { + private static let sessionWindowMinutes = 5 * 60 + private static let weeklyWindowMinutes = 7 * 24 * 60 + + let entry: WidgetSnapshot.ProviderEntry + let selection: BurnWindowChoice + let now: Date + + init?( + snapshot: WidgetSnapshot, + provider: UsageProvider, + selection: BurnWindowChoice, + now: Date = Date()) + { + guard let entry = snapshot.entries.first(where: { $0.provider == provider }) else { return nil } + self.entry = entry + self.selection = selection + self.now = now + } + + var secondaryGloballyCapsPrimary: Bool { + switch self.entry.provider { + case .codex, .claude: true + default: false + } + } + + var secondaryExhausted: Bool { + guard self.secondaryGloballyCapsPrimary, let secondary = self.secondaryWindow else { return false } + guard secondary.remainingPercent <= 0 else { return false } + return secondary.resetsAt.map { $0 > self.now } ?? true + } + + var primaryWindow: RateWindow? { + guard let primary = self.window(minutes: Self.sessionWindowMinutes) else { return nil } + guard self.secondaryExhausted, primary.remainingPercent > 0 else { return primary } + return RateWindow( + usedPercent: 100, + windowMinutes: primary.windowMinutes, + resetsAt: primary.resetsAt, + resetDescription: primary.resetDescription, + nextRegenPercent: primary.nextRegenPercent) + } + + var secondaryWindow: RateWindow? { + self.window(minutes: Self.weeklyWindowMinutes) + } + + var selectedWindow: RateWindow? { + switch self.selection { + case .session: self.primaryWindow + case .weekly: self.secondaryWindow + } + } + + var blankPrimaryChart: Bool { + self.selection == .session + && self.secondaryExhausted + && self.window(minutes: Self.sessionWindowMinutes) != nil + } + + var selectedResetOverride: Date? { + self.blankPrimaryChart ? self.secondaryWindow?.resetsAt : nil + } + + private func window(minutes: Int) -> RateWindow? { + [self.entry.primary, self.entry.secondary] + .compactMap(\.self) + .first { $0.windowMinutes == minutes } + } +} + +enum BurnDownRefreshSchedule { + private static let minimumInterval: TimeInterval = 5 * 60 + private static let maximumInterval: TimeInterval = 30 * 60 + + static func nextRefresh( + snapshot: WidgetSnapshot, + provider: UsageProvider, + now: Date = Date()) -> Date + { + let fallback = now.addingTimeInterval(self.maximumInterval) + guard let entry = snapshot.entries.first(where: { $0.provider == provider }) else { return fallback } + let nextReset = [entry.primary?.resetsAt, entry.secondary?.resetsAt] + .compactMap(\.self) + .filter { $0 > now } + .min()? + .addingTimeInterval(1) + if let nextReset { + let target = min(fallback, nextReset) + let minimumDate = now.addingTimeInterval(self.minimumInterval) + return max(minimumDate, target) + } + return fallback + } +} + +struct BurnDownTimelineProvider: AppIntentTimelineProvider { + func placeholder(in context: Context) -> BurnDownEntry { + BurnDownEntry( + date: Date(), + provider: .codex, + window: .session, + snapshot: WidgetPreviewData.snapshot()) + } + + func snapshot(for configuration: BurnDownSelectionIntent, in context: Context) async -> BurnDownEntry { + BurnDownEntry( + date: Date(), + provider: configuration.provider.provider, + window: configuration.window, + snapshot: WidgetSnapshotStore.load() ?? WidgetPreviewData.snapshot()) + } + + func timeline( + for configuration: BurnDownSelectionIntent, + in context: Context) async -> Timeline + { + let entry = BurnDownEntry( + date: Date(), + provider: configuration.provider.provider, + window: configuration.window, + snapshot: WidgetSnapshotStore.load() ?? WidgetPreviewData.emptySnapshot()) + let refresh = BurnDownRefreshSchedule.nextRefresh(snapshot: entry.snapshot, provider: entry.provider) + return Timeline(entries: [entry], policy: .after(refresh)) + } +} + +struct CombinedBurnDownTimelineProvider: AppIntentTimelineProvider { + func placeholder(in context: Context) -> CombinedBurnDownEntry { + CombinedBurnDownEntry( + date: Date(), + provider: .codex, + snapshot: WidgetPreviewData.snapshot()) + } + + func snapshot( + for configuration: BurnProviderSelectionIntent, + in context: Context) async -> CombinedBurnDownEntry + { + CombinedBurnDownEntry( + date: Date(), + provider: configuration.provider.provider, + snapshot: WidgetSnapshotStore.load() ?? WidgetPreviewData.snapshot()) + } + + func timeline( + for configuration: BurnProviderSelectionIntent, + in context: Context) async -> Timeline + { + let entry = CombinedBurnDownEntry( + date: Date(), + provider: configuration.provider.provider, + snapshot: WidgetSnapshotStore.load() ?? WidgetPreviewData.emptySnapshot()) + let refresh = BurnDownRefreshSchedule.nextRefresh(snapshot: entry.snapshot, provider: entry.provider) + return Timeline(entries: [entry], policy: .after(refresh)) + } +} diff --git a/Sources/CodexBarWidget/BurnDownWidgetViews.swift b/Sources/CodexBarWidget/BurnDownWidgetViews.swift new file mode 100644 index 0000000000..4ba3037065 --- /dev/null +++ b/Sources/CodexBarWidget/BurnDownWidgetViews.swift @@ -0,0 +1,682 @@ +import AppKit +import CodexBarCore +import SwiftUI +import WidgetKit + +// MARK: - Entry View + +struct BurnDownWidgetView: View { + let entry: BurnDownEntry + + var body: some View { + let state = BurnDownState( + snapshot: self.entry.snapshot, + provider: self.entry.provider, + selection: self.entry.window) + + Group { + if let state, let window = state.selectedWindow { + BurnDownLayout( + window: window, + provider: self.entry.provider, + blankChart: state.blankPrimaryChart, + resetsAtOverride: state.selectedResetOverride) + } else { + self.emptyState + } + } + .containerBackground(for: .widget) { + BurnWidgetBackground() + } + } + + private var emptyState: some View { + VStack(spacing: 6) { + Text("Open CodexBar") + .font(.body) + .fontWeight(.semibold) + Text("Usage data will appear once the app refreshes.") + .font(.caption) + .multilineTextAlignment(.center) + .opacity(0.55) + } + .padding(12) + } +} + +// MARK: - Main Layout + +private struct BurnDownLayout: View { + @Environment(\.widgetRenderingMode) private var renderingMode + @Environment(\.colorScheme) private var colorScheme + + let window: RateWindow + let provider: UsageProvider + /// True when the session window is blocked because the weekly budget is exhausted: + /// suppress the chart and retarget "Resets in" to the weekly reset. + var blankChart = false + /// When set, "Resets in" counts down to this date (the weekly reset) instead of the + /// session window's own reset. + var resetsAtOverride: Date? + + var body: some View { + let dark = self.colorScheme == .dark + let isMonochrome = self.renderingMode != .fullColor + let geom = BurnGeom(window: self.window) + let theme = BurnTheme(provider: self.provider, geom: geom, dark: dark, isMonochrome: isMonochrome) + let windowMins = self.window.windowMinutes ?? 300 + let isDailyWindow = windowMins >= 1440 + let now = Date() + let estimatedResetMinutes = self.blankChart || geom.tNow >= 1 + ? nil + : (1 - geom.tNow) * Double(windowMins) + let explicitReset = self.blankChart + ? self.resetsAtOverride + : self.resetsAtOverride ?? self.window.resetsAt + let effectiveResetAt = burnEffectiveResetDate( + explicitResetAt: explicitReset, + estimatedResetMinutes: estimatedResetMinutes, + now: now) + let resetsIn = effectiveResetAt.map { max(0, $0.timeIntervalSince(now) / 60) } ?? 0 + let outInMins = geom.slope < -0.01 ? (geom.vNow / -geom.slope) * Double(windowMins) : Double.infinity + // Very early in the window a single sample can't forecast a credible run-out: a + // tiny burst right after reset extrapolates to "runs dry in minutes" even at ~99% + // remaining. Match the design's fresh-window behaviour ("Runs out: after reset") + // and only surface the estimate once enough of the window has elapsed to trust the + // average burn rate. + let windowEstablished = geom.tNow >= 0.08 + let runsDryBefore = geom.runsOut && outInMins < resetsIn && windowEstablished + + let sign = geom.margin >= 0 ? "+" : "−" + let badgeNum = "\(sign)\(abs(Int(geom.margin.rounded())))%" + // Per the design's edge states, "fresh" and "spent" show only the glyph + word + // (◆ full / ■ spent) with no pace number — the margin is meaningless once the + // budget is full or gone. + let showBadgeNumber = !geom.depleted && !geom.fresh + let statusWord: String = geom.depleted ? "spent" : geom.fresh ? "full" + : geom.status == .ahead ? "conserving" : geom.status == .behind ? "over pace" : "on pace" + let arrow: String = geom.depleted ? "■" : geom.fresh ? "◆" + : geom.status == .ahead ? "▲" : geom.status == .behind ? "▼" : "●" + + let axisDates = burnAxisDateRange( + effectiveResetAt: effectiveResetAt, + windowMinutes: windowMins, + now: now) + let startLabel = burnAxisLabel(axisDates.start, isDailyWindow: isDailyWindow) + let resetLabel = burnAxisLabel(axisDates.reset, isDailyWindow: isDailyWindow) + + VStack(spacing: 0) { + // Header: brand + pace badge + HStack(alignment: .top) { + VStack(alignment: .leading, spacing: 2) { + HStack(spacing: 6) { + Circle() + .fill(theme.brandDot) + .frame(width: 7, height: 7) + .shadow(color: theme.brandDot.opacity(0.7), radius: 3.5) + Text(burnProviderName(self.provider)) + .font(.system(size: 14.5, weight: .semibold)) + .foregroundStyle(theme.text) + .lineLimit(1) + } + Text(burnWindowLabel(self.window.windowMinutes)) + .font(.system(size: 11)) + .foregroundStyle(theme.sub) + .kerning(0.2) + } + + Spacer() + + VStack(alignment: .trailing, spacing: 1) { + if showBadgeNumber { + Text(badgeNum) + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(theme.statusColor) + .monospacedDigit() + } + HStack(spacing: 3) { + Text(arrow) + .font(.system(size: 8)) + .foregroundStyle(theme.statusColor) + Text(statusWord) + .font(.system(size: 10.5)) + .foregroundStyle(theme.sub) + } + } + } + + // Body: stats + hero / chart + HStack(alignment: .bottom, spacing: 13) { + // Left: stats + hero % + VStack(alignment: .leading, spacing: 0) { + VStack(spacing: 5) { + BurnResetStatRow(resetAt: effectiveResetAt, theme: theme) + BurnStatRow( + label: geom.depleted ? "Ran out" : runsDryBefore ? "Runs out in" : "Runs out", + value: geom + .depleted ? "budget spent" : runsDryBefore ? "~\(burnFmtDuration(outInMins))" : + "after reset", + theme: theme, + danger: geom.depleted || runsDryBefore) + } + .padding(.top, 8) + + Spacer() + + HStack(alignment: .lastTextBaseline, spacing: 5) { + Text("\(Int(geom.vNow.rounded()))") + .font(.system(size: 41, weight: .semibold)) + .foregroundStyle(geom.depleted ? theme.danger : theme.text) + .monospacedDigit() + .lineLimit(1) + .minimumScaleFactor(0.75) + Text("%") + .font(.system(size: 19, weight: .medium)) + .foregroundStyle(theme.sub) + Text("left") + .font(.system(size: 11)) + .foregroundStyle(theme.sub) + } + } + .frame(width: 143, alignment: .leading) + + // Right: chart + axis. Blanked when the session window is blocked by the + // weekly cap — there's no session burn to chart until the weekly resets. + VStack(spacing: 2) { + if self.blankChart { + Color.clear.frame(height: 84) + Color.clear.frame(height: 13) + } else { + BurnChartCanvas( + geom: geom, + theme: theme) + .frame(height: 84) + + BurnAxisRow( + startLabel: startLabel, + resetLabel: resetLabel, + tNow: geom.tNow, + theme: theme) + .frame(height: 13) + } + } + .frame(maxWidth: .infinity) + } + } + .padding(.horizontal, 15) + .padding(.top, 13) + .padding(.bottom, 12) + } +} + +// MARK: - Stat Row + +private struct BurnStatRow: View { + let label: String + let value: String + let theme: BurnTheme + let danger: Bool + + var body: some View { + HStack(alignment: .firstTextBaseline) { + Text(self.label) + .font(.system(size: 11.5)) + .foregroundStyle(self.theme.sub) + Spacer() + Text(self.value) + .font(.system(size: 12.5, weight: .semibold)) + .foregroundStyle(self.danger ? self.theme.danger : self.theme.text) + .monospacedDigit() + .lineLimit(1) + } + } +} + +private struct BurnResetStatRow: View { + let resetAt: Date? + let theme: BurnTheme + + var body: some View { + HStack(alignment: .firstTextBaseline) { + Text("Resets in") + .font(.system(size: 11.5)) + .foregroundStyle(self.theme.sub) + Spacer() + if let resetAt { + Text(resetAt, style: .relative) + .font(.system(size: 12.5, weight: .semibold)) + .foregroundStyle(self.theme.text) + .monospacedDigit() + .lineLimit(1) + } else { + Text("—") + .font(.system(size: 12.5, weight: .semibold)) + .foregroundStyle(self.theme.text) + } + } + } +} + +// MARK: - Axis Row + +private struct BurnAxisRow: View { + let startLabel: String + let resetLabel: String + let tNow: Double + let theme: BurnTheme + + var body: some View { + GeometryReader { geo in + // Hide "now" when it would collide with the start/reset labels. The edge labels + // are anchored to the ends, so estimate their widths (≈9.5pt monospaced digits) + // and only show "now" when it clears both with a small gap — otherwise the + // now-dot on the chart already conveys position. Matches the design's rule that + // "now" hides near an end label. + let w = geo.size.width + let approxChar: CGFloat = 5.8 + let nowX = self.tNow * w + let nowHalf: CGFloat = 13 + let gap: CGFloat = 6 + let clearsStart = nowX - nowHalf > CGFloat(self.startLabel.count) * approxChar + gap + let clearsReset = nowX + nowHalf < w - CGFloat(self.resetLabel.count) * approxChar - gap + let showNow = self.tNow > 0.05 && self.tNow < 0.95 && clearsStart && clearsReset + + ZStack(alignment: .leading) { + Text(self.startLabel) + .font(.system(size: 9.5)) + .foregroundStyle(self.theme.sub) + .monospacedDigit() + .frame(maxWidth: .infinity, alignment: .leading) + + if showNow { + Text("now") + .font(.system(size: 9.5, weight: .semibold)) + .foregroundStyle(self.theme.text) + .position(x: nowX, y: geo.size.height / 2) + } + + Text(self.resetLabel) + .font(.system(size: 9.5)) + .foregroundStyle(self.theme.sub) + .monospacedDigit() + .frame(maxWidth: .infinity, alignment: .trailing) + } + } + } +} + +// MARK: - Chart Canvas + +private struct BurnChartCanvas: View { + let geom: BurnGeom + let theme: BurnTheme + + var body: some View { + Canvas { context, size in + let w = size.width + let h = size.height + let padT: CGFloat = 8 + let padB: CGFloat = 2 + let padL: CGFloat = 1 + let padR: CGFloat = 1 + + func X(_ t: Double) -> CGFloat { + padL + CGFloat(t) * (w - padL - padR) + } + func Y(_ v: Double) -> CGFloat { + padT + CGFloat(1 - v / 100) * (h - padT - padB) + } + + let tNow = self.geom.tNow + let vNow = self.geom.vNow + + // --- Now vertical hairline --- + do { + var p = Path() + p.move(to: CGPoint(x: X(tNow), y: Y(100))) + p.addLine(to: CGPoint(x: X(tNow), y: Y(0))) + context.stroke(p, with: .color(self.theme.chartGrid), lineWidth: 1) + } + + // --- Baseline --- + do { + var p = Path() + p.move(to: CGPoint(x: X(0), y: Y(0))) + p.addLine(to: CGPoint(x: X(1), y: Y(0))) + context.stroke(p, with: .color(self.theme.chartGrid), lineWidth: 1) + } + + // --- Area fill (gradient from actual line down to baseline) --- + do { + var p = Path() + p.move(to: CGPoint(x: X(0), y: Y(100))) + p.addLine(to: CGPoint(x: X(tNow), y: Y(vNow))) + p.addLine(to: CGPoint(x: X(tNow), y: Y(0))) + p.addLine(to: CGPoint(x: X(0), y: Y(0))) + p.closeSubpath() + + let gradient = Gradient(stops: [ + .init(color: self.theme.chartFillTop.opacity(self.theme.chartFillTopOpacity), location: 0), + .init(color: self.theme.chartFillTop.opacity(0), location: 0.92), + ]) + context.fill( + p, + with: .linearGradient( + gradient, + startPoint: CGPoint(x: 0, y: padT), + endPoint: CGPoint(x: 0, y: h))) + } + + // --- Ideal line (dashed, knocked back) --- + do { + var p = Path() + p.move(to: CGPoint(x: X(0), y: Y(100))) + p.addLine(to: CGPoint(x: X(1), y: Y(0))) + context.stroke( + p, + with: .color(self.theme.chartIdeal), + style: StrokeStyle(lineWidth: 1.4, lineCap: .round, dash: [2.5, 3])) + } + + // --- Projection (fine dotted) --- + if self.geom.slope < -0.01 { + var p = Path() + p.move(to: CGPoint(x: X(tNow), y: Y(vNow))) + p.addLine(to: CGPoint(x: X(self.geom.projT), y: Y(self.geom.projV))) + context.stroke( + p, + with: .color(self.theme.chartProj.opacity(0.95)), + style: StrokeStyle(lineWidth: 1.6, lineCap: .round, dash: [0.5, 3.5])) + } + + // --- Actual line (solid, hero) --- + do { + var p = Path() + p.move(to: CGPoint(x: X(0), y: Y(100))) + p.addLine(to: CGPoint(x: X(tNow), y: Y(vNow))) + context.stroke( + p, + with: .color(self.theme.chartLine), + style: StrokeStyle(lineWidth: 2.4, lineCap: .round, lineJoin: .round)) + } + + // --- Now dot (filled, with ring punched in bg color) --- + let dotCenter = CGPoint(x: X(tNow), y: Y(vNow)) + let ringPath = Path(ellipseIn: CGRect( + x: dotCenter.x - 5.4, y: dotCenter.y - 5.4, width: 10.8, height: 10.8)) + context.fill(ringPath, with: .color(self.theme.chartNowRing)) + let dotPath = Path(ellipseIn: CGRect( + x: dotCenter.x - 3.4, y: dotCenter.y - 3.4, width: 6.8, height: 6.8)) + context.fill(dotPath, with: .color(self.theme.chartNowDot)) + } + } +} + +// MARK: - Background + +struct BurnWidgetBackground: View { + @Environment(\.widgetRenderingMode) private var renderingMode + @Environment(\.colorScheme) private var colorScheme + + var body: some View { + if self.renderingMode == .fullColor { + let dark = self.colorScheme == .dark + LinearGradient( + colors: dark + ? [BurnPalette.darkBgTop, BurnPalette.darkBgBottom] + : [BurnPalette.lightBgTop, BurnPalette.lightBgBottom], + startPoint: .init(x: 0.15, y: 0), + endPoint: .init(x: 0.85, y: 1)) + .overlay(alignment: .top) { + LinearGradient( + colors: [ + Color.white.opacity(dark ? 0.04 : 0.60), + Color.white.opacity(0), + ], + startPoint: .top, + endPoint: .center) + } + } else { + Color.clear + } + } +} + +// MARK: - Theme + +struct BurnTheme { + let text: Color + let sub: Color + let hair: Color + let accent: Color + let statusColor: Color + let danger: Color + let brandDot: Color + let chartLine: Color + let chartFillTop: Color + let chartFillTopOpacity: Double + let chartIdeal: Color + let chartProj: Color + let chartGrid: Color + let chartNowDot: Color + let chartNowRing: Color + + init(provider: UsageProvider, geom: BurnGeom, dark: Bool, isMonochrome: Bool) { + if isMonochrome { + let fg = dark ? Color.white : Color.black + self.text = fg.opacity(dark ? 0.95 : 0.90) + self.sub = fg.opacity(dark ? 0.50 : 0.46) + self.hair = fg.opacity(dark ? 0.13 : 0.11) + self.accent = fg.opacity(dark ? 0.95 : 0.85) + self.statusColor = fg.opacity(dark ? 0.90 : 0.80) + self.danger = fg.opacity(dark ? 0.95 : 0.85) + self.brandDot = fg.opacity(dark ? 0.85 : 0.72) + self.chartLine = fg.opacity(dark ? 0.95 : 0.85) + self.chartFillTop = fg.opacity(dark ? 0.95 : 0.85) + self.chartFillTopOpacity = dark ? 0.20 : 0.16 + self.chartIdeal = fg.opacity(dark ? 0.36 : 0.30) + self.chartProj = fg.opacity(dark ? 0.60 : 0.48) + self.chartGrid = fg.opacity(dark ? 0.12 : 0.10) + self.chartNowDot = fg.opacity(dark ? 1.0 : 0.92) + self.chartNowRing = dark + ? Color(red: 0.10, green: 0.10, blue: 0.12).opacity(0.92) + : Color(red: 0.96, green: 0.96, blue: 0.97).opacity(0.95) + } else { + let accentColor = Self.accentColor(geom.status, dark: dark) + self.accent = accentColor + self.statusColor = accentColor + self.text = dark ? Color.white.opacity(0.98) : Color(white: 0.20) + self.sub = dark ? Color(white: 0.60) : Color(white: 0.45) + self.hair = dark ? Color.white.opacity(0.10) : Color.black.opacity(0.09) + self.danger = BurnPalette.behindDark + self.brandDot = Self.brandDotColor(provider) + self.chartLine = accentColor + self.chartFillTop = accentColor + self.chartFillTopOpacity = dark ? 0.30 : 0.22 + self.chartIdeal = dark ? Color.white.opacity(0.45) : Color.black.opacity(0.50) + // Projection goes red when behind (the one allowed color cue in full-color mode) + self.chartProj = geom.status == .behind ? BurnPalette.behindDark : accentColor + self.chartGrid = dark ? Color.white.opacity(0.10) : Color.black.opacity(0.09) + self.chartNowDot = accentColor + self.chartNowRing = dark ? BurnPalette.darkBgBottom : BurnPalette.lightBgBottom + } + } + + private static func accentColor(_ status: BurnGeom.Status, dark: Bool) -> Color { + switch status { + case .ahead: dark ? BurnPalette.aheadDark : BurnPalette.aheadLight + case .onpace: dark ? BurnPalette.onpaceDark : BurnPalette.onpaceLight + case .behind: dark ? BurnPalette.behindDark : BurnPalette.behindLight + } + } + + private static func brandDotColor(_ provider: UsageProvider) -> Color { + switch provider { + case .claude: BurnPalette.claudeDot + case .codex: BurnPalette.codexDot + case .gemini: BurnPalette.geminiDot + default: BurnPalette.genericDot + } + } +} + +// MARK: - Palette + +enum BurnPalette { + // Status accents — approximated from OKLCH (L=0.80 dark, L=0.62 light) + // oklch(0.80 0.15 152) / oklch(0.62 0.15 152) — green + static let aheadDark = Color(red: 0.306, green: 0.800, blue: 0.506) + static let aheadLight = Color(red: 0.192, green: 0.620, blue: 0.376) + // oklch(0.80 0.11 236) / oklch(0.62 0.11 236) — blue + static let onpaceDark = Color(red: 0.408, green: 0.668, blue: 0.910) + static let onpaceLight = Color(red: 0.264, green: 0.474, blue: 0.712) + // oklch(0.72 0.19 26) / oklch(0.60 0.19 26) — red-orange + static let behindDark = Color(red: 0.922, green: 0.420, blue: 0.227) + static let behindLight = Color(red: 0.762, green: 0.294, blue: 0.137) + + // Brand identity dots — always the LLM's hue + static let claudeDot = Color(red: 0.880, green: 0.580, blue: 0.180) // clay/amber, hue 48 + static let codexDot = Color(red: 0.120, green: 0.780, blue: 0.598) // teal, hue 168 + static let geminiDot = Color(red: 0.420, green: 0.440, blue: 0.900) // indigo, hue 268 + static let genericDot = Color(white: 0.60) + + // Backgrounds + static let darkBgTop = Color(red: 0.108, green: 0.108, blue: 0.132) + static let darkBgBottom = Color(red: 0.132, green: 0.132, blue: 0.156) + static let lightBgTop = Color(white: 0.990) + static let lightBgBottom = Color(red: 0.940, green: 0.940, blue: 0.960) +} + +// MARK: - Geometry + +struct BurnGeom { + enum Status { case ahead, onpace, behind } + + let vNow: Double // % remaining (0..100) + let tNow: Double // position in window (0..1) + let idealNow: Double // what you should have left = 100 * (1 - tNow) + let margin: Double // vNow - idealNow; + = conserving, − = over pace + let slope: Double // %/unit-t (negative = burning) + let projT: Double // t where projection ends + let projV: Double // v where projection ends + let runsOut: Bool // projection hits 0 inside the window + + var status: Status { + self.margin > 4 ? .ahead : self.margin < -4 ? .behind : .onpace + } + + var depleted: Bool { + self.vNow <= 0.5 + } + + var fresh: Bool { + self.vNow >= 99.5 + } + + init(window: RateWindow) { + let remaining = max(0, min(100, window.remainingPercent)) + self.vNow = remaining + + let t: Double + if let resetsAt = window.resetsAt, let windowMins = window.windowMinutes, windowMins > 0 { + let minutesUntilReset = max(0, resetsAt.timeIntervalSinceNow / 60) + let minutesElapsed = Double(windowMins) - minutesUntilReset + t = max(0.001, min(0.999, minutesElapsed / Double(windowMins))) + } else { + t = max(0.001, min(0.999, window.usedPercent / 100.0)) + } + self.tNow = t + self.idealNow = 100.0 * (1.0 - t) + self.margin = remaining - self.idealNow + + let slope = t > 0.001 ? (remaining - 100.0) / t : -remaining + self.slope = slope + + if slope < -0.01 { + let tOut = t + remaining / -slope + if tOut <= 1.0 { + self.projT = tOut + self.projV = 0 + self.runsOut = true + } else { + self.projT = 1.0 + self.projV = max(0, remaining + slope * (1.0 - t)) + self.runsOut = false + } + } else { + self.projT = 1.0 + self.projV = remaining + self.runsOut = false + } + } +} + +// MARK: - Helpers + +func burnWindowLabel(_ windowMinutes: Int?) -> String { + guard let mins = windowMinutes else { return "Usage limit" } + if mins < 60 { return "\(mins)-minute limit" } + let hours = mins / 60 + if hours < 24 { return "\(hours)-hour limit" } + return "\(hours / 24)-day limit" +} + +func burnEffectiveResetDate( + explicitResetAt: Date?, + estimatedResetMinutes: Double?, + now: Date) -> Date? +{ + if let explicitResetAt { + return explicitResetAt > now ? explicitResetAt : nil + } + guard let estimatedResetMinutes, estimatedResetMinutes > 0 else { return nil } + return now.addingTimeInterval(estimatedResetMinutes * 60) +} + +func burnAxisDateRange( + effectiveResetAt: Date?, + windowMinutes: Int, + now: Date) -> (start: Date, reset: Date) +{ + let reset = effectiveResetAt ?? now + return (reset.addingTimeInterval(-Double(windowMinutes) * 60), reset) +} + +func burnCompactWindowLabel(_ windowMinutes: Int?, fallback: String) -> String { + guard let minutes = windowMinutes else { return fallback } + if minutes < 60 { return "\(minutes)M" } + let hours = minutes / 60 + if hours < 24 { return "\(hours)H" } + return "\(hours / 24)D" +} + +func burnFmtDuration(_ minutes: Double) -> String { + guard minutes.isFinite, minutes > 0 else { return "—" } + if minutes >= 1440 { + let d = Int(minutes / 1440) + let h = Int(minutes / 60) % 24 + return "\(d)d \(h)h" + } + let h = Int(minutes / 60) + let m = Int(minutes) % 60 + if h <= 0 { return "\(max(1, m))m" } + return "\(h)h \(String(format: "%02d", m))m" +} + +func burnAxisLabel(_ date: Date, isDailyWindow: Bool) -> String { + let f = DateFormatter() + if isDailyWindow { + // A rolling multi-day window's start and reset fall on the same weekday, so "EEE" + // would print the same label at both ends ("Sat … Sat"). Use a numeric date so the + // two ends are distinguishable. + f.setLocalizedDateFormatFromTemplate("Md") + } else { + f.dateStyle = .none + f.timeStyle = .short + } + return f.string(from: date) +} + +func burnProviderName(_ provider: UsageProvider) -> String { + ProviderDefaults.metadata[provider]?.displayName ?? provider.rawValue.capitalized +} diff --git a/Sources/CodexBarWidget/CodexBarWidgetBundle.swift b/Sources/CodexBarWidget/CodexBarWidgetBundle.swift index 4b65052170..a2d2cd23ca 100644 --- a/Sources/CodexBarWidget/CodexBarWidgetBundle.swift +++ b/Sources/CodexBarWidget/CodexBarWidgetBundle.swift @@ -8,6 +8,8 @@ struct CodexBarWidgetBundle: WidgetBundle { CodexBarUsageWidget() CodexBarHistoryWidget() CodexBarCompactWidget() + CodexBarBurnDownWidget() + CodexBarCombinedBurnDownWidget() } } @@ -77,3 +79,37 @@ struct CodexBarCompactWidget: Widget { .supportedFamilies([.systemSmall]) } } + +struct CodexBarBurnDownWidget: Widget { + private let kind = "CodexBarBurnDownWidget" + + var body: some WidgetConfiguration { + AppIntentConfiguration( + kind: self.kind, + intent: BurnDownSelectionIntent.self, + provider: BurnDownTimelineProvider()) + { entry in + BurnDownWidgetView(entry: entry) + } + .configurationDisplayName("CodexBar Burn Down") + .description("Remaining budget compared with an ideal steady burn rate.") + .supportedFamilies([.systemMedium]) + } +} + +struct CodexBarCombinedBurnDownWidget: Widget { + private let kind = "CodexBarCombinedBurnDownWidget" + + var body: some WidgetConfiguration { + AppIntentConfiguration( + kind: self.kind, + intent: BurnProviderSelectionIntent.self, + provider: CombinedBurnDownTimelineProvider()) + { entry in + CombinedBurnDownWidgetView(entry: entry) + } + .configurationDisplayName("CodexBar Burn Down (Combined)") + .description("Session and weekly burn-down charts in one tile.") + .supportedFamilies([.systemMedium]) + } +} diff --git a/Sources/CodexBarWidget/CodexBarWidgetProvider.swift b/Sources/CodexBarWidget/CodexBarWidgetProvider.swift index aa3c5cb345..86acf1caea 100644 --- a/Sources/CodexBarWidget/CodexBarWidgetProvider.swift +++ b/Sources/CodexBarWidget/CodexBarWidgetProvider.swift @@ -9,13 +9,18 @@ enum ProviderChoice: String, AppEnum { case gemini case alibaba case alibabatokenplan + case qwencloud case antigravity + case cursor case zai case copilot + case devin case minimax case kilo case opencode case opencodego + case mistral + case kimi static let typeDisplayRepresentation = TypeDisplayRepresentation(name: "Provider") @@ -25,13 +30,18 @@ enum ProviderChoice: String, AppEnum { .gemini: DisplayRepresentation(title: "Gemini"), .alibaba: DisplayRepresentation(title: "Alibaba"), .alibabatokenplan: DisplayRepresentation(title: "Alibaba Token Plan"), + .qwencloud: DisplayRepresentation(title: "Qwen Cloud"), .antigravity: DisplayRepresentation(title: "Antigravity"), + .cursor: DisplayRepresentation(title: "Cursor"), .zai: DisplayRepresentation(title: "z.ai"), .copilot: DisplayRepresentation(title: "Copilot"), + .devin: DisplayRepresentation(title: "Devin"), .minimax: DisplayRepresentation(title: "MiniMax"), .kilo: DisplayRepresentation(title: "Kilo"), .opencode: DisplayRepresentation(title: "OpenCode"), .opencodego: DisplayRepresentation(title: "OpenCode Go"), + .mistral: DisplayRepresentation(title: "Mistral"), + .kimi: DisplayRepresentation(title: "Kimi"), ] var provider: UsageProvider { @@ -41,13 +51,18 @@ enum ProviderChoice: String, AppEnum { case .gemini: .gemini case .alibaba: .alibaba case .alibabatokenplan: .alibabatokenplan + case .qwencloud: .qwencloud case .antigravity: .antigravity + case .cursor: .cursor case .zai: .zai case .copilot: .copilot + case .devin: .devin case .minimax: .minimax case .kilo: .kilo case .opencode: .opencode case .opencodego: .opencodego + case .mistral: .mistral + case .kimi: .kimi } } @@ -58,16 +73,19 @@ enum ProviderChoice: String, AppEnum { case .openai: return nil // OpenAI not yet supported in widgets case .azureopenai: return nil // Azure OpenAI not yet supported in widgets case .claude: self = .claude + case .clinepass: return nil // ClinePass not yet supported in widgets case .gemini: self = .gemini case .alibaba: self = .alibaba case .alibabatokenplan: self = .alibabatokenplan + case .qwencloud: self = .qwencloud case .antigravity: self = .antigravity - case .cursor: return nil // Cursor not yet supported in widgets + case .cursor: self = .cursor case .opencode: self = .opencode case .opencodego: self = .opencodego case .zai: self = .zai case .factory: return nil // Factory not yet supported in widgets case .copilot: self = .copilot + case .devin: self = .devin case .minimax: self = .minimax case .manus: return nil // Manus not yet supported in widgets case .vertexai: return nil // Vertex AI not yet supported in widgets @@ -75,33 +93,48 @@ enum ProviderChoice: String, AppEnum { case .kiro: return nil // Kiro not yet supported in widgets case .augment: return nil // Augment not yet supported in widgets case .jetbrains: return nil // JetBrains not yet supported in widgets - case .kimi: return nil // Kimi not yet supported in widgets - case .kimik2: return nil // Kimi K2 not yet supported in widgets + case .kimi: self = .kimi case .moonshot: return nil // Moonshot not yet supported in widgets case .amp: return nil // Amp not yet supported in widgets case .t3chat: return nil // T3 Chat not yet supported in widgets + case .zoommate: return nil // ZoomMate not yet supported in widgets case .ollama: return nil // Ollama not yet supported in widgets case .synthetic: return nil // Synthetic not yet supported in widgets case .openrouter: return nil // OpenRouter not yet supported in widgets + case .clawrouter: return nil // ClawRouter not yet supported in widgets + case .sub2api: return nil // sub2api not yet supported in widgets + case .wayfinder: return nil // Wayfinder not yet supported in widgets case .elevenlabs: return nil // ElevenLabs not yet supported in widgets case .warp: return nil // Warp not yet supported in widgets case .windsurf: return nil // Windsurf not yet supported in widgets case .perplexity: return nil // Perplexity not yet supported in widgets case .mimo: return nil // Xiaomi MiMo not yet supported in widgets case .doubao: return nil // Doubao not yet supported in widgets + case .sakana: return nil // Sakana AI not yet supported in widgets case .abacus: return nil // Abacus AI not yet supported in widgets - case .mistral: return nil // Mistral not yet supported in widgets + case .mistral: self = .mistral case .deepseek: return nil // DeepSeek not yet supported in widgets + case .deepinfra: return nil // DeepInfra not yet supported in widgets case .codebuff: return nil // Codebuff not yet supported in widgets case .crof: return nil // Crof not yet supported in widgets case .venice: return nil // Venice not yet supported in widgets case .commandcode: return nil // CommandCode not yet supported in widgets + case .qoder: return nil // Qoder not yet supported in widgets case .stepfun: return nil // StepFun not yet supported in widgets case .bedrock: return nil // Bedrock not yet supported in widgets case .grok: return nil // Grok not yet supported in widgets case .groq: return nil // Groq not yet supported in widgets case .llmproxy: return nil // LLM Proxy not yet supported in widgets + case .litellm: return nil // LiteLLM not yet supported in widgets case .deepgram: return nil // Deepgram not yet supported in widgets + case .poe: return nil // Poe not yet supported in widgets + case .chutes: return nil // Chutes not yet supported in widgets + case .longcat: return nil // LongCat not yet supported in widgets + case .zed: return nil // Zed not yet supported in widgets + case .neuralwatt: return nil // Neuralwatt not yet supported in widgets + case .zenmux: return nil // ZenMux not yet supported in widgets + case .aiand: return nil // ai& not yet supported in widgets + case .xai: return nil // xAI not yet supported in widgets } } } @@ -210,8 +243,9 @@ struct CodexBarTimelineProvider: AppIntentTimelineProvider { { let provider = configuration.provider.provider let snapshot = WidgetSnapshotStore.load() ?? WidgetPreviewData.emptySnapshot() - let entry = CodexBarWidgetEntry(date: Date(), provider: provider, snapshot: snapshot) - let refresh = Date().addingTimeInterval(30 * 60) + let now = Date() + let entry = CodexBarWidgetEntry(date: now, provider: provider, snapshot: snapshot) + let refresh = BurnDownRefreshSchedule.nextRefresh(snapshot: snapshot, provider: provider, now: now) return Timeline(entries: [entry], policy: .after(refresh)) } } @@ -233,7 +267,10 @@ struct CodexBarSwitcherTimelineProvider: TimelineProvider { func getTimeline(in context: Context, completion: @escaping (Timeline) -> Void) { let entry = self.makeEntry() - let refresh = Date().addingTimeInterval(30 * 60) + let refresh = BurnDownRefreshSchedule.nextRefresh( + snapshot: entry.snapshot, + provider: entry.provider, + now: entry.date) completion(Timeline(entries: [entry], policy: .after(refresh))) } @@ -306,8 +343,12 @@ enum WidgetPreviewData { } static func snapshot() -> WidgetSnapshot { - let primary = RateWindow(usedPercent: 35, windowMinutes: nil, resetsAt: nil, resetDescription: "Resets in 4h") - let secondary = RateWindow(usedPercent: 60, windowMinutes: nil, resetsAt: nil, resetDescription: "Resets in 3d") + let primary = RateWindow(usedPercent: 35, windowMinutes: 300, resetsAt: nil, resetDescription: "Resets in 4h") + let secondary = RateWindow( + usedPercent: 60, + windowMinutes: 10080, + resetsAt: nil, + resetDescription: "Resets in 3d") let entry = WidgetSnapshot.ProviderEntry( provider: .codex, updatedAt: Date(), diff --git a/Sources/CodexBarWidget/CodexBarWidgetViews.swift b/Sources/CodexBarWidget/CodexBarWidgetViews.swift index 68fd97228b..6cfaee31ee 100644 --- a/Sources/CodexBarWidget/CodexBarWidgetViews.swift +++ b/Sources/CodexBarWidget/CodexBarWidgetViews.swift @@ -2,21 +2,26 @@ import CodexBarCore import SwiftUI import WidgetKit +extension EnvironmentValues { + @Entry fileprivate var widgetUsageShowsUsed: Bool = false +} + struct CodexBarUsageWidgetView: View { @Environment(\.widgetFamily) private var family let entry: CodexBarWidgetEntry var body: some View { let providerEntry = self.entry.snapshot.entries.first { $0.provider == self.entry.provider } - ZStack { - Color.black.opacity(0.02) + Group { if let providerEntry { self.content(providerEntry: providerEntry) } else { self.emptyState } } + .frame(maxWidth: .infinity, maxHeight: .infinity) .containerBackground(.fill.tertiary, for: .widget) + .environment(\.widgetUsageShowsUsed, self.entry.snapshot.usageBarsShowUsed) } @ViewBuilder @@ -50,14 +55,14 @@ struct CodexBarHistoryWidgetView: View { var body: some View { let providerEntry = self.entry.snapshot.entries.first { $0.provider == self.entry.provider } - ZStack { - Color.black.opacity(0.02) + Group { if let providerEntry { HistoryView(entry: providerEntry, isLarge: self.family == .systemLarge) } else { self.emptyState } } + .frame(maxWidth: .infinity, maxHeight: .infinity) .containerBackground(.fill.tertiary, for: .widget) } @@ -79,14 +84,14 @@ struct CodexBarCompactWidgetView: View { var body: some View { let providerEntry = self.entry.snapshot.entries.first { $0.provider == self.entry.provider } - ZStack { - Color.black.opacity(0.02) + Group { if let providerEntry { CompactMetricView(entry: providerEntry, metric: self.entry.metric) } else { self.emptyState } } + .frame(maxWidth: .infinity, maxHeight: .infinity) .containerBackground(.fill.tertiary, for: .widget) } @@ -109,24 +114,23 @@ struct CodexBarSwitcherWidgetView: View { var body: some View { let providerEntry = self.entry.snapshot.entries.first { $0.provider == self.entry.provider } - ZStack { - Color.black.opacity(0.02) - VStack(alignment: .leading, spacing: 10) { - ProviderSwitcherRow( - providers: self.entry.availableProviders, - selected: self.entry.provider, - updatedAt: providerEntry?.updatedAt ?? Date(), - compact: self.family == .systemSmall, - showsTimestamp: self.family != .systemSmall) - if let providerEntry { - self.content(providerEntry: providerEntry) - } else { - self.emptyState - } + VStack(alignment: .leading, spacing: 10) { + ProviderSwitcherRow( + providers: self.entry.availableProviders, + selected: self.entry.provider, + updatedAt: providerEntry?.updatedAt ?? Date(), + compact: self.family == .systemSmall, + showsTimestamp: self.family != .systemSmall) + if let providerEntry { + self.content(providerEntry: providerEntry) + } else { + self.emptyState } - .padding(12) } + .padding(12) + .frame(maxWidth: .infinity, maxHeight: .infinity) .containerBackground(.fill.tertiary, for: .widget) + .environment(\.widgetUsageShowsUsed, self.entry.snapshot.usageBarsShowUsed) } @ViewBuilder @@ -158,7 +162,7 @@ private struct CompactMetricView: View { let metric: CompactMetric var body: some View { - let display = self.display + let display = CompactMetricFormatter.display(for: self.entry, metric: self.metric) VStack(alignment: .leading, spacing: 8) { HeaderView(provider: self.entry.provider, updatedAt: self.entry.updatedAt) VStack(alignment: .leading, spacing: 2) { @@ -177,28 +181,60 @@ private struct CompactMetricView: View { } .padding(12) } +} - private var display: (value: String, label: String, detail: String?) { - switch self.metric { +struct CompactMetricDisplay: Equatable { + let value: String + let label: String + let detail: String? +} + +enum CompactMetricFormatter { + static func display(for entry: WidgetSnapshot.ProviderEntry, metric: CompactMetric) -> CompactMetricDisplay { + switch metric { case .credits: - let value = self.entry.creditsRemaining.map(WidgetFormat.credits) ?? "—" - return (value, "Credits left", nil) + if let cost = WidgetBalanceFormatter.extraUsageCost(for: entry) { + return CompactMetricDisplay( + value: WidgetFormat.currency(cost.used, code: cost.currencyCode), + label: "Extra usage balance", + detail: nil) + } + let value = entry.creditsRemaining.map(WidgetFormat.credits) ?? "—" + return CompactMetricDisplay(value: value, label: "Credits left", detail: nil) case .todayCost: - let value = self.entry.tokenUsage.map { token in + let value = entry.tokenUsage.map { token in token.sessionCostUSD.map { WidgetFormat.currency($0, code: token.currencyCode) } ?? "—" } ?? "—" - let detail = self.entry.tokenUsage?.sessionTokens.map(WidgetFormat.tokenCount) - let label = self.entry.tokenUsage.map { "\($0.sessionLabel) cost" } ?? "Today cost" - return (value, label, detail) + let detail = entry.tokenUsage?.sessionTokens.map(WidgetFormat.tokenCount) + let label = entry.tokenUsage.map { + WidgetFormat.tokenRowTitle( + Self.costMetricLabel($0.sessionLabel, provider: entry.provider), + summary: $0, + entryUpdatedAt: entry.updatedAt) + } ?? "Today cost" + return CompactMetricDisplay(value: value, label: label, detail: detail) case .last30DaysCost: - let value = self.entry.tokenUsage.map { token in + let value = entry.tokenUsage.map { token in token.last30DaysCostUSD.map { WidgetFormat.currency($0, code: token.currencyCode) } ?? "—" } ?? "—" - let detail = self.entry.tokenUsage?.last30DaysTokens.map(WidgetFormat.tokenCount) - let label = self.entry.tokenUsage.map { "\($0.last30DaysLabel) cost" } ?? "30d cost" - return (value, label, detail) + let detail = entry.tokenUsage?.last30DaysTokens.map(WidgetFormat.tokenCount) + let label = entry.tokenUsage.map { + WidgetFormat.tokenRowTitle( + Self.costMetricLabel($0.last30DaysLabel, provider: entry.provider), + summary: $0, + entryUpdatedAt: entry.updatedAt) + } ?? "30d cost" + return CompactMetricDisplay(value: value, label: label, detail: detail) } } + + static func costMetricLabel(_ label: String, provider: UsageProvider) -> String { + guard provider == .codex else { return "\(label) cost" } + // Existing widget timelines may predate the estimate labels. Do not leave a bare + // dollar value until the app next republishes it. + guard !label.contains("API est.") else { return label } + return "\(label) API est. · not billed" + } } private struct ProviderSwitcherRow: View { @@ -267,6 +303,7 @@ private struct ProviderSwitchChip: View { case .openai: "OpenAI" case .azureopenai: "Azure OpenAI" case .claude: "Claude" + case .clinepass: "ClinePass" case .gemini: "Gemini" case .antigravity: "Anti" case .cursor: "Cursor" @@ -274,9 +311,11 @@ private struct ProviderSwitchChip: View { case .opencodego: "OpenCode Go" case .alibaba: "Alibaba" case .alibabatokenplan: "Token Plan" + case .qwencloud: "Qwen Cloud" case .zai: "z.ai" case .factory: "Droid" case .copilot: "Copilot" + case .devin: "Devin" case .minimax: "MiniMax" case .manus: "Manus" case .vertexai: "Vertex" @@ -285,32 +324,47 @@ private struct ProviderSwitchChip: View { case .augment: "Augment" case .jetbrains: "JetBrains" case .kimi: "Kimi" - case .kimik2: "Kimi K2" case .moonshot: "Moonshot" case .amp: "Amp" case .t3chat: "T3 Chat" + case .zoommate: "ZoomMate" case .ollama: "Ollama" case .synthetic: "Synthetic" case .openrouter: "OpenRouter" + case .clawrouter: "ClawRouter" + case .sub2api: "sub2api" + case .wayfinder: "Wayfinder" case .elevenlabs: "ElevenLabs" case .warp: "Warp" case .windsurf: "Windsurf" case .perplexity: "Pplx" case .mimo: "MiMo" case .doubao: "Doubao" + case .sakana: "Sakana" case .abacus: "Abacus" case .mistral: "Mistral" case .deepseek: "DeepSeek" + case .deepinfra: "DeepInfra" case .codebuff: "Codebuff" case .crof: "Crof" case .venice: "Venice" case .commandcode: "Command Code" + case .qoder: "Qoder" case .stepfun: "StepFun" case .bedrock: "Bedrock" case .grok: "Grok" case .groq: "Groq" case .llmproxy: "LLM Proxy" + case .litellm: "LiteLLM" case .deepgram: "Deepgram" + case .poe: "Poe" + case .chutes: "Chutes" + case .longcat: "LongCat" + case .zed: "Zed" + case .neuralwatt: "Neuralwatt" + case .zenmux: "ZenMux" + case .aiand: "ai&" + case .xai: "xAI" } } } @@ -320,7 +374,10 @@ private struct SwitcherSmallUsageView: View { var body: some View { VStack(alignment: .leading, spacing: 8) { - ForEach(WidgetUsageRow.rows(for: self.entry)) { row in + ForEach(WidgetUsageRow.rows( + for: self.entry, + limit: WidgetUsageRow.smallWidgetRowLimit(for: self.entry))) + { row in UsageBarRow( title: row.title, percentLeft: row.percentLeft, @@ -332,6 +389,20 @@ private struct SwitcherSmallUsageView: View { percentLeft: codeReview, color: WidgetColors.color(for: self.entry.provider)) } + if let token = WidgetUsageRow.compactTokenUsage(for: self.entry) { + ValueLine( + title: WidgetFormat.tokenRowTitle( + token.sessionLabel, + summary: token, + entryUpdatedAt: self.entry.updatedAt), + value: WidgetFormat.costAndTokens( + cost: token.sessionCostUSD, + tokens: token.sessionTokens, + currencyCode: token.currencyCode)) + } + if let balance = extraUsageBalanceLine(for: entry) { + balance + } } } } @@ -341,7 +412,10 @@ private struct SwitcherMediumUsageView: View { var body: some View { VStack(alignment: .leading, spacing: 10) { - ForEach(WidgetUsageRow.rows(for: self.entry)) { row in + ForEach(WidgetUsageRow.rows( + for: self.entry, + limit: WidgetUsageRow.mediumWidgetRowLimit(for: self.entry))) + { row in UsageBarRow( title: row.title, percentLeft: row.percentLeft, @@ -352,12 +426,18 @@ private struct SwitcherMediumUsageView: View { } if let token = entry.tokenUsage { ValueLine( - title: token.sessionLabel, + title: WidgetFormat.tokenRowTitle( + token.sessionLabel, + summary: token, + entryUpdatedAt: self.entry.updatedAt), value: WidgetFormat.costAndTokens( cost: token.sessionCostUSD, tokens: token.sessionTokens, currencyCode: token.currencyCode)) } + if let balance = extraUsageBalanceLine(for: entry) { + balance + } } } } @@ -385,20 +465,32 @@ private struct SwitcherLargeUsageView: View { if let token = entry.tokenUsage { VStack(alignment: .leading, spacing: 4) { ValueLine( - title: token.sessionLabel, + title: WidgetFormat.tokenRowTitle( + token.sessionLabel, + summary: token, + entryUpdatedAt: self.entry.updatedAt), value: WidgetFormat.costAndTokens( cost: token.sessionCostUSD, tokens: token.sessionTokens, currencyCode: token.currencyCode)) ValueLine( - title: token.last30DaysLabel, + title: WidgetFormat.tokenRowTitle( + token.last30DaysLabel, + summary: token, + entryUpdatedAt: self.entry.updatedAt), value: WidgetFormat.costAndTokens( cost: token.last30DaysCostUSD, tokens: token.last30DaysTokens, currencyCode: token.currencyCode)) } } - UsageHistoryChart(points: self.entry.dailyUsage, color: WidgetColors.color(for: self.entry.provider)) + if let balance = extraUsageBalanceLine(for: entry) { + balance + } + UsageHistoryChart( + points: self.entry.dailyUsage, + color: WidgetColors.color(for: self.entry.provider), + currencyCode: self.entry.tokenUsage?.currencyCode) .frame(height: 50) } } @@ -410,7 +502,10 @@ private struct SmallUsageView: View { var body: some View { VStack(alignment: .leading, spacing: 8) { HeaderView(provider: self.entry.provider, updatedAt: self.entry.updatedAt) - ForEach(WidgetUsageRow.rows(for: self.entry)) { row in + ForEach(WidgetUsageRow.rows( + for: self.entry, + limit: WidgetUsageRow.smallWidgetRowLimit(for: self.entry))) + { row in UsageBarRow( title: row.title, percentLeft: row.percentLeft, @@ -422,6 +517,20 @@ private struct SmallUsageView: View { percentLeft: codeReview, color: WidgetColors.color(for: self.entry.provider)) } + if let token = WidgetUsageRow.compactTokenUsage(for: self.entry) { + ValueLine( + title: WidgetFormat.tokenRowTitle( + token.sessionLabel, + summary: token, + entryUpdatedAt: self.entry.updatedAt), + value: WidgetFormat.costAndTokens( + cost: token.sessionCostUSD, + tokens: token.sessionTokens, + currencyCode: token.currencyCode)) + } + if let balance = extraUsageBalanceLine(for: entry) { + balance + } } .padding(12) } @@ -433,7 +542,10 @@ private struct MediumUsageView: View { var body: some View { VStack(alignment: .leading, spacing: 10) { HeaderView(provider: self.entry.provider, updatedAt: self.entry.updatedAt) - ForEach(WidgetUsageRow.rows(for: self.entry)) { row in + ForEach(WidgetUsageRow.rows( + for: self.entry, + limit: WidgetUsageRow.mediumWidgetRowLimit(for: self.entry))) + { row in UsageBarRow( title: row.title, percentLeft: row.percentLeft, @@ -444,12 +556,18 @@ private struct MediumUsageView: View { } if let token = entry.tokenUsage { ValueLine( - title: token.sessionLabel, + title: WidgetFormat.tokenRowTitle( + token.sessionLabel, + summary: token, + entryUpdatedAt: self.entry.updatedAt), value: WidgetFormat.costAndTokens( cost: token.sessionCostUSD, tokens: token.sessionTokens, currencyCode: token.currencyCode)) } + if let balance = extraUsageBalanceLine(for: entry) { + balance + } } .padding(12) } @@ -479,20 +597,32 @@ private struct LargeUsageView: View { if let token = entry.tokenUsage { VStack(alignment: .leading, spacing: 4) { ValueLine( - title: token.sessionLabel, + title: WidgetFormat.tokenRowTitle( + token.sessionLabel, + summary: token, + entryUpdatedAt: self.entry.updatedAt), value: WidgetFormat.costAndTokens( cost: token.sessionCostUSD, tokens: token.sessionTokens, currencyCode: token.currencyCode)) ValueLine( - title: token.last30DaysLabel, + title: WidgetFormat.tokenRowTitle( + token.last30DaysLabel, + summary: token, + entryUpdatedAt: self.entry.updatedAt), value: WidgetFormat.costAndTokens( cost: token.last30DaysCostUSD, tokens: token.last30DaysTokens, currencyCode: token.currencyCode)) } } - UsageHistoryChart(points: self.entry.dailyUsage, color: WidgetColors.color(for: self.entry.provider)) + if let balance = extraUsageBalanceLine(for: entry) { + balance + } + UsageHistoryChart( + points: self.entry.dailyUsage, + color: WidgetColors.color(for: self.entry.provider), + currencyCode: self.entry.tokenUsage?.currencyCode) .frame(height: 50) } .padding(12) @@ -504,31 +634,206 @@ struct WidgetUsageRow: Identifiable, Equatable { let title: String let percentLeft: Double? - static func rows(for entry: WidgetSnapshot.ProviderEntry) -> [WidgetUsageRow] { + private enum AntigravityQuotaFamily { + case gemini + case claudeGPT + } + + static func smallWidgetRowLimit(for entry: WidgetSnapshot.ProviderEntry) -> Int? { + if entry.provider == .kimi { return 3 } + return self.antigravityQuotaSummaryRowLimit(for: entry, limit: 2) + } + + static func mediumWidgetRowLimit(for entry: WidgetSnapshot.ProviderEntry) -> Int? { + if entry.provider == .kimi { return 3 } + return self.antigravityQuotaSummaryRowLimit(for: entry, limit: 3) + } + + private static func antigravityQuotaSummaryRowLimit( + for entry: WidgetSnapshot.ProviderEntry, + limit: Int) -> Int? + { + guard entry.provider == .antigravity, + entry.usageRows?.contains(where: { + $0.id.hasPrefix("antigravity-quota-summary-") + }) == true + else { + return nil + } + return limit + } + + static func rows( + for entry: WidgetSnapshot.ProviderEntry, + limit: Int? = nil, + now: Date = Date()) -> [WidgetUsageRow] + { + let rows: [WidgetUsageRow] if let usageRows = entry.usageRows { - return usageRows.map { row in - WidgetUsageRow(id: row.id, title: row.title, percentLeft: row.percentLeft) - } - } - - let metadata = ProviderDefaults.metadata[entry.provider] - var rows = [ - WidgetUsageRow( - id: "primary", - title: metadata?.sessionLabel ?? "Session", - percentLeft: entry.primary?.remainingPercent), - WidgetUsageRow( - id: "secondary", - title: metadata?.weeklyLabel ?? "Weekly", - percentLeft: entry.secondary?.remainingPercent), - ] - if metadata?.supportsOpus == true { - rows.append(WidgetUsageRow( - id: "tertiary", - title: metadata?.opusLabel ?? "Opus", - percentLeft: entry.tertiary?.remainingPercent)) - } - return rows.filter { $0.percentLeft != nil } + let resolvedSnapshots = usageRows.map { row in + guard row.window == nil, + let window = self.legacyCodexRateWindow(for: row.id, entry: entry) + else { + return row + } + return WidgetSnapshot.WidgetUsageRowSnapshot( + id: row.id, + title: row.title, + percentLeft: row.percentLeft, + window: window) + } + let sourceRows = resolvedSnapshots.map { row in + WidgetUsageRow( + id: row.id, + title: row.title, + percentLeft: row.window?.remainingPercent ?? row.percentLeft) + } + rows = self.applyingCodexWeeklyCap( + sourceRows, + snapshots: resolvedSnapshots, + provider: entry.provider, + now: now) + } else { + let metadata = ProviderDefaults.metadata[entry.provider] + var defaultRows = [ + WidgetUsageRow( + id: "primary", + title: metadata?.sessionLabel ?? "Session", + percentLeft: entry.primary?.remainingPercent), + WidgetUsageRow( + id: "secondary", + title: metadata?.weeklyLabel ?? "Weekly", + percentLeft: entry.secondary?.remainingPercent), + ] + if metadata?.supportsOpus == true { + defaultRows.append(WidgetUsageRow( + id: "tertiary", + title: metadata?.opusLabel ?? "Opus", + percentLeft: entry.tertiary?.remainingPercent)) + } + rows = defaultRows.filter { $0.percentLeft != nil } + } + guard let limit else { return rows } + if entry.provider == .antigravity, + limit >= 2, + rows.contains(where: { $0.id.hasPrefix("antigravity-quota-summary-") }) + { + var selected = [AntigravityQuotaFamily.gemini, .claudeGPT].compactMap { family in + rows + .filter { self.antigravityQuotaFamily(for: $0) == family } + .min(by: self.isMoreConstrained) + } + let selectedIDs = Set(selected.map(\.id)) + let fallbackRows = rows.enumerated() + .filter { !selectedIDs.contains($0.element.id) } + .sorted { lhs, rhs in + switch (lhs.element.percentLeft, rhs.element.percentLeft) { + case let (.some(left), .some(right)): + left == right ? lhs.offset < rhs.offset : left < right + case (.some, .none): + true + case (.none, .some): + false + case (.none, .none): + lhs.offset < rhs.offset + } + } + .map(\.element) + selected.append(contentsOf: fallbackRows.prefix(max(0, limit - selected.count))) + return selected + } + return Array(rows.prefix(max(0, limit))) + } + + private static func applyingCodexWeeklyCap( + _ rows: [WidgetUsageRow], + snapshots: [WidgetSnapshot.WidgetUsageRowSnapshot], + provider: UsageProvider, + now: Date) -> [WidgetUsageRow] + { + guard provider == .codex, + let weekly = snapshots.first(where: { $0.id == "weekly" })?.window, + weekly.remainingPercent <= 0, + weekly.resetsAt.map({ $0 > now }) ?? true + else { + return rows + } + return rows.map { row in + guard row.id == "session" else { return row } + return WidgetUsageRow(id: row.id, title: row.title, percentLeft: 0) + } + } + + private static func legacyCodexRateWindow( + for rowID: String, + entry: WidgetSnapshot.ProviderEntry) -> RateWindow? + { + guard entry.provider == .codex else { return nil } + let candidates = [(entry.primary, "session"), (entry.secondary, "weekly")] + for (window, fallbackID) in candidates { + guard let window else { continue } + let classifiedID = switch window.windowMinutes { + case 300: "session" + case 10080: "weekly" + default: fallbackID + } + if classifiedID == rowID { + return window + } + } + return nil + } + + static func compactTokenUsage( + for entry: WidgetSnapshot.ProviderEntry) -> WidgetSnapshot.TokenUsageSummary? + { + guard self.rows(for: entry).isEmpty, + entry.codeReviewRemainingPercent == nil + else { + return nil + } + return entry.tokenUsage + } + + private static func antigravityQuotaFamily(for row: WidgetUsageRow) -> AntigravityQuotaFamily? { + guard row.id.hasPrefix("antigravity-quota-summary-") else { return nil } + let id = row.id.lowercased() + if id.contains("gemini") { + return .gemini + } + if id.contains("3p") || id.contains("third-party") { + return .claudeGPT + } + + let title = row.title.lowercased() + if title.contains("gemini") { + return .gemini + } + if title.contains("claude") || title.contains("gpt") { + return .claudeGPT + } + return nil + } + + private static func isMoreConstrained(_ lhs: WidgetUsageRow, than rhs: WidgetUsageRow) -> Bool { + switch (lhs.percentLeft, rhs.percentLeft) { + case let (.some(left), .some(right)): + left < right + case (.some, .none): + true + case (.none, .some): + false + case (.none, .none): + false + } + } +} + +enum WidgetUsageDisplay { + static func percent(fromRemaining remaining: Double?, showUsed: Bool) -> Double? { + guard let remaining else { return nil } + let clamped = max(0, min(100, remaining)) + return showUsed ? 100 - clamped : clamped } } @@ -539,17 +844,26 @@ private struct HistoryView: View { var body: some View { VStack(alignment: .leading, spacing: 12) { HeaderView(provider: self.entry.provider, updatedAt: self.entry.updatedAt) - UsageHistoryChart(points: self.entry.dailyUsage, color: WidgetColors.color(for: self.entry.provider)) + UsageHistoryChart( + points: self.entry.dailyUsage, + color: WidgetColors.color(for: self.entry.provider), + currencyCode: self.entry.tokenUsage?.currencyCode) .frame(height: self.isLarge ? 90 : 60) if let token = entry.tokenUsage { ValueLine( - title: token.sessionLabel, + title: WidgetFormat.tokenRowTitle( + token.sessionLabel, + summary: token, + entryUpdatedAt: self.entry.updatedAt), value: WidgetFormat.costAndTokens( cost: token.sessionCostUSD, tokens: token.sessionTokens, currencyCode: token.currencyCode)) ValueLine( - title: token.last30DaysLabel, + title: WidgetFormat.tokenRowTitle( + token.last30DaysLabel, + summary: token, + entryUpdatedAt: self.entry.updatedAt), value: WidgetFormat.costAndTokens( cost: token.last30DaysCostUSD, tokens: token.last30DaysTokens, @@ -578,22 +892,24 @@ private struct HeaderView: View { } private struct UsageBarRow: View { + @Environment(\.widgetUsageShowsUsed) private var showUsed let title: String let percentLeft: Double? let color: Color var body: some View { + let percent = WidgetUsageDisplay.percent(fromRemaining: self.percentLeft, showUsed: self.showUsed) VStack(alignment: .leading, spacing: 4) { HStack { Text(self.title) .font(.caption) Spacer() - Text(WidgetFormat.percent(self.percentLeft)) + Text(WidgetFormat.percent(percent)) .font(.caption) .foregroundStyle(.secondary) } GeometryReader { proxy in - let width = max(0, min(1, (percentLeft ?? 0) / 100)) * proxy.size.width + let width = max(0, min(1, (percent ?? 0) / 100)) * proxy.size.width ZStack(alignment: .leading) { Capsule().fill(Color.primary.opacity(0.08)) Capsule().fill(self.color).frame(width: width) @@ -613,8 +929,16 @@ private struct ValueLine: View { Text(self.title) .font(.caption) .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.tail) + .frame(minWidth: 0, maxWidth: .infinity, alignment: .leading) Text(self.value) .font(.caption) + .monospacedDigit() + .lineLimit(1) + .minimumScaleFactor(0.8) + .allowsTightening(true) + .layoutPriority(1) } } } @@ -622,27 +946,50 @@ private struct ValueLine: View { private struct UsageHistoryChart: View { let points: [WidgetSnapshot.DailyUsagePoint] let color: Color + let currencyCode: String? var body: some View { + let isCostMode = UsageHistoryChartMode.isCostMode(self.points) let values = self.points.map { point -> Double in - if let cost = point.costUSD { return cost } + if isCostMode { return point.costUSD ?? 0 } return Double(point.totalTokens ?? 0) } - let maxValue = values.max() ?? 0 - HStack(alignment: .bottom, spacing: 2) { - ForEach(values.indices, id: \.self) { index in - let value = values[index] - let height = maxValue > 0 ? CGFloat(value / maxValue) : 0 - RoundedRectangle(cornerRadius: 2) - .fill(self.color.opacity(0.85)) - .frame(maxWidth: .infinity) - .scaleEffect(x: 1, y: height, anchor: .bottom) - .animation(.easeOut(duration: 0.2), value: height) + let scale = UsageChartScale(values: values) + VStack(alignment: .trailing, spacing: 2) { + if isCostMode, + let currencyCode = self.currencyCode, + scale.maximum > 0 + { + Text(UsageFormatter.compactCurrencyString(scale.maximum, currencyCode: currencyCode)) + .font(.caption2) + .foregroundStyle(.secondary) + .monospacedDigit() + .lineLimit(1) + .allowsTightening(true) + } + GeometryReader { geometry in + HStack(alignment: .bottom, spacing: 2) { + ForEach(values.indices, id: \.self) { index in + let fraction = scale.fraction(for: values[index]) + RoundedRectangle(cornerRadius: 2) + .fill(self.color.opacity(0.85)) + .frame(maxWidth: .infinity) + .frame(height: max(fraction > 0 ? 2 : 0, CGFloat(fraction) * geometry.size.height)) + .animation(.easeOut(duration: 0.2), value: fraction) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom) } } } } +enum UsageHistoryChartMode { + static func isCostMode(_ points: [WidgetSnapshot.DailyUsagePoint]) -> Bool { + !points.isEmpty && points.allSatisfy { $0.costUSD != nil } + } +} + enum WidgetColors { // swiftlint:disable:next cyclomatic_complexity static func color(for provider: UsageProvider) -> Color { @@ -655,6 +1002,11 @@ enum WidgetColors { Color(red: 0, green: 120 / 255, blue: 212 / 255) case .claude: Color(red: 204 / 255, green: 124 / 255, blue: 94 / 255) + case .clinepass: + Color( + red: ClinePassProviderDescriptor.descriptor.branding.color.red, + green: ClinePassProviderDescriptor.descriptor.branding.color.green, + blue: ClinePassProviderDescriptor.descriptor.branding.color.blue) case .gemini: Color(red: 171 / 255, green: 135 / 255, blue: 234 / 255) case .antigravity: @@ -667,12 +1019,16 @@ enum WidgetColors { Color(red: 59 / 255, green: 130 / 255, blue: 246 / 255) case .alibaba, .alibabatokenplan: Color(red: 1.0, green: 106 / 255, blue: 0) + case .qwencloud: + Color(red: 97 / 255, green: 92 / 255, blue: 237 / 255) case .zai: Color(red: 232 / 255, green: 90 / 255, blue: 106 / 255) case .factory: Color(red: 255 / 255, green: 107 / 255, blue: 53 / 255) // Factory orange case .copilot: Color(red: 168 / 255, green: 85 / 255, blue: 247 / 255) // Purple + case .devin: + Color(red: 70 / 255, green: 180 / 255, blue: 130 / 255) case .minimax: Color(red: 254 / 255, green: 96 / 255, blue: 60 / 255) case .manus: @@ -689,20 +1045,26 @@ enum WidgetColors { Color(red: 255 / 255, green: 51 / 255, blue: 153 / 255) // JetBrains pink case .kimi: Color(red: 254 / 255, green: 96 / 255, blue: 60 / 255) // Kimi orange - case .kimik2: - Color(red: 76 / 255, green: 0 / 255, blue: 255 / 255) // Kimi K2 purple case .moonshot: Color(red: 32 / 255, green: 93 / 255, blue: 235 / 255) case .amp: Color(red: 220 / 255, green: 38 / 255, blue: 38 / 255) // Amp red case .t3chat: Color(red: 245 / 255, green: 102 / 255, blue: 71 / 255) + case .zoommate: + Color(red: 11 / 255, green: 92 / 255, blue: 255 / 255) // Zoom blue case .ollama: Color(red: 32 / 255, green: 32 / 255, blue: 32 / 255) // Ollama charcoal case .synthetic: Color(red: 20 / 255, green: 20 / 255, blue: 20 / 255) // Synthetic charcoal case .openrouter: Color(red: 111 / 255, green: 66 / 255, blue: 193 / 255) // OpenRouter purple + case .clawrouter: + Color(red: 89 / 255, green: 110 / 255, blue: 246 / 255) + case .sub2api: + Color(red: 45 / 255, green: 198 / 255, blue: 216 / 255) + case .wayfinder: + Color(red: 16 / 255, green: 163 / 255, blue: 127 / 255) case .elevenlabs: Color(red: 235 / 255, green: 235 / 255, blue: 230 / 255) case .warp: @@ -715,12 +1077,16 @@ enum WidgetColors { Color(red: 1.0, green: 105 / 255, blue: 0) case .doubao: Color(red: 45 / 255, green: 136 / 255, blue: 255 / 255) // Doubao blue + case .sakana: + Color(red: 41 / 255, green: 117 / 255, blue: 219 / 255) case .abacus: Color(red: 56 / 255, green: 189 / 255, blue: 248 / 255) case .mistral: Color(red: 255 / 255, green: 80 / 255, blue: 15 / 255) // Mistral orange case .deepseek: Color(red: 82 / 255, green: 125 / 255, blue: 240 / 255) + case .deepinfra: + Color(red: 42 / 255, green: 50 / 255, blue: 117 / 255) case .codebuff: Color(red: 68 / 255, green: 255 / 255, blue: 0 / 255) // Codebuff lime case .crof: @@ -729,6 +1095,8 @@ enum WidgetColors { Color(red: 51 / 255, green: 153 / 255, blue: 1.0) case .commandcode: Color(red: 0, green: 0, blue: 0) + case .qoder: + Color(red: 16 / 255, green: 185 / 255, blue: 129 / 255) case .stepfun: Color(red: 255 / 255, green: 140 / 255, blue: 0 / 255) // StepFun orange case .bedrock: @@ -739,12 +1107,57 @@ enum WidgetColors { Color(red: 245 / 255, green: 104 / 255, blue: 68 / 255) case .llmproxy: Color(red: 36 / 255, green: 180 / 255, blue: 126 / 255) + case .litellm: + Color(red: 76 / 255, green: 137 / 255, blue: 240 / 255) case .deepgram: Color(red: 10 / 255, green: 18 / 255, blue: 27 / 255) + case .poe: + Color(red: 93 / 255, green: 92 / 255, blue: 222 / 255) // Poe purple + case .chutes: + Color(red: 24 / 255, green: 160 / 255, blue: 88 / 255) + case .longcat: + Color(red: 255 / 255, green: 209 / 255, blue: 0 / 255) + case .zed: + Color(red: 64 / 255, green: 156 / 255, blue: 255 / 255) + case .neuralwatt: + Color(red: 56 / 255, green: 217 / 255, blue: 140 / 255) + case .zenmux: + Color(red: 108 / 255, green: 92 / 255, blue: 231 / 255) + case .aiand: + Color(red: 226 / 255, green: 92 / 255, blue: 43 / 255) + case .xai: + Color(red: 142 / 255, green: 142 / 255, blue: 147 / 255) } } } +struct WidgetBalanceLine: Equatable { + let title: String + let value: String +} + +enum WidgetBalanceFormatter { + static func extraUsageCost(for entry: WidgetSnapshot.ProviderEntry) -> ProviderCostSnapshot? { + guard entry.provider == .devin, + let cost = entry.providerCost, + cost.period == "Extra usage balance" + else { return nil } + return cost + } + + static func extraUsageBalance(for entry: WidgetSnapshot.ProviderEntry) -> WidgetBalanceLine? { + guard let cost = self.extraUsageCost(for: entry) else { return nil } + return WidgetBalanceLine( + title: "Extra usage", + value: "Balance: \(WidgetFormat.currency(cost.used, code: cost.currencyCode))") + } +} + +private func extraUsageBalanceLine(for entry: WidgetSnapshot.ProviderEntry) -> ValueLine? { + guard let line = WidgetBalanceFormatter.extraUsageBalance(for: entry) else { return nil } + return ValueLine(title: line.title, value: line.value) +} + enum WidgetFormat { static func percent(_ value: Double?) -> String { guard let value else { return "—" } @@ -777,11 +1190,7 @@ enum WidgetFormat { } static func tokenCount(_ value: Int) -> String { - let formatter = NumberFormatter() - formatter.numberStyle = .decimal - formatter.maximumFractionDigits = 0 - let raw = formatter.string(from: NSNumber(value: value)) ?? "\(value)" - return "\(raw) tokens" + "\(UsageFormatter.tokenCountString(value)) tokens" } static func relativeDate(_ date: Date) -> String { @@ -790,4 +1199,15 @@ enum WidgetFormat { formatter.unitsStyle = .short return formatter.localizedString(for: date, relativeTo: Date()) } + + /// Suffixes the title with the token snapshot's own age once it lags the entry's + /// freshness signal past `TokenUsageSummary.staleLagThreshold`. + static func tokenRowTitle( + _ base: String, + summary: WidgetSnapshot.TokenUsageSummary, + entryUpdatedAt: Date) -> String + { + guard summary.isStale(comparedTo: entryUpdatedAt), let updatedAt = summary.updatedAt else { return base } + return "\(base) · \(self.relativeDate(updatedAt))" + } } diff --git a/Sources/CodexBarWidget/CombinedBurnDownWidgetViews.swift b/Sources/CodexBarWidget/CombinedBurnDownWidgetViews.swift new file mode 100644 index 0000000000..258812b5ad --- /dev/null +++ b/Sources/CodexBarWidget/CombinedBurnDownWidgetViews.swift @@ -0,0 +1,480 @@ +import AppKit +import CodexBarCore +import SwiftUI +import WidgetKit + +// MARK: - Entry View + +struct CombinedBurnDownWidgetView: View { + let entry: CombinedBurnDownEntry + + var body: some View { + let state = BurnDownState( + snapshot: self.entry.snapshot, + provider: self.entry.provider, + selection: .session) + + Group { + if let state { + CombinedBurnDownLayout(state: state, provider: self.entry.provider) + } else { + self.emptyState + } + } + .containerBackground(for: .widget) { + BurnWidgetBackground() + } + } + + private var emptyState: some View { + VStack(spacing: 6) { + Text("Open CodexBar") + .font(.body) + .fontWeight(.semibold) + Text("Usage data will appear once the app refreshes.") + .font(.caption) + .multilineTextAlignment(.center) + .opacity(0.55) + } + .padding(12) + } +} + +// MARK: - Layout + +private struct CombinedBurnDownLayout: View { + @Environment(\.widgetRenderingMode) private var renderingMode + @Environment(\.colorScheme) private var colorScheme + + let state: BurnDownState + let provider: UsageProvider + + var body: some View { + let dark = self.colorScheme == .dark + let isMonochrome = self.renderingMode != .fullColor + + let sessionWindow = self.state.primaryWindow + let weeklyWindow = self.state.secondaryWindow + + let sessionGeom = sessionWindow.map { BurnGeom(window: $0) } + let weeklyGeom = weeklyWindow.map { BurnGeom(window: $0) } + + // Use a neutral baseline theme for the header/hairline colors + let baseGeom = sessionGeom ?? weeklyGeom ?? BurnGeom( + window: RateWindow( + usedPercent: 50, + windowMinutes: 300, + resetsAt: Date().addingTimeInterval(2.5 * 3600), + resetDescription: nil)) + let baseTheme = BurnTheme( + provider: self.provider, + geom: baseGeom, + dark: dark, + isMonochrome: isMonochrome) + + VStack(spacing: 0) { + // Header + HStack(alignment: .center) { + HStack(spacing: 6) { + Circle() + .fill(baseTheme.brandDot) + .frame(width: 7, height: 7) + .shadow(color: baseTheme.brandDot.opacity(0.7), radius: 3.5) + Text(burnProviderName(self.provider)) + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(baseTheme.text) + .lineLimit(1) + } + Spacer() + Text("Session & weekly limits") + .font(.system(size: 10)) + .foregroundStyle(baseTheme.sub) + .kerning(0.3) + } + + // Two rows + VStack(spacing: 0) { + // 5H row — shows % remaining by default + if let win = sessionWindow, let geom = sessionGeom { + CombinedBurnRow( + window: win, + geom: geom, + theme: BurnTheme( + provider: self.provider, + geom: geom, + dark: dark, + isMonochrome: isMonochrome), + tag: burnCompactWindowLabel(win.windowMinutes, fallback: "S"), + periods: 5, + metric: .remaining, + dark: dark, + blankChart: self.state.blankPrimaryChart, + resetsAtOverride: self.state.selectedResetOverride) + } else { + CombinedEmptyRow(tag: "S", theme: baseTheme) + } + + Rectangle() + .fill(baseTheme.hair) + .frame(height: 1) + + // 7D row — shows % off pace by default + if let win = weeklyWindow, let geom = weeklyGeom { + CombinedBurnRow( + window: win, + geom: geom, + theme: BurnTheme( + provider: self.provider, + geom: geom, + dark: dark, + isMonochrome: isMonochrome), + tag: burnCompactWindowLabel(win.windowMinutes, fallback: "W"), + periods: 7, + metric: .pace, + dark: dark) + } else { + CombinedEmptyRow(tag: "W", theme: baseTheme) + } + } + .frame(maxHeight: .infinity) + .padding(.top, 6) + } + .padding(.horizontal, 15) + .padding(.top, 12) + .padding(.bottom, 11) + } +} + +// MARK: - Metric + +private enum CombinedMetric { + case remaining // % left (default for 5H) + case pace // % off ideal pace (default for 7D) + case used // % consumed +} + +// MARK: - Row + +private struct CombinedBurnRow: View { + let window: RateWindow + let geom: BurnGeom + let theme: BurnTheme + let tag: String + let periods: Int + let metric: CombinedMetric + let dark: Bool + var blankChart = false + var resetsAtOverride: Date? + + var body: some View { + let windowMins = self.window.windowMinutes ?? 300 + let isDailyWindow = windowMins >= 1440 + + let heroNum = self.metric == .pace ? abs(Int(self.geom.margin.rounded())) + : self.metric == .used ? Int((100 - self.geom.vNow).rounded()) + : Int(self.geom.vNow.rounded()) + let suffix = self.metric == .remaining ? "left" : self.metric == .used ? "used" : "" + let prefixArrow = self.metric == .pace + + let paceWord: String = self.geom.depleted ? "spent" : self.geom.fresh ? "full" + : self.geom.status == .ahead ? "under pace" + : self.geom.status == .behind ? "over pace" : "on pace" + let arrow: String = self.geom.depleted ? "■" : self.geom.fresh ? "◆" + : self.geom.status == .ahead ? "▲" : self.geom.status == .behind ? "▼" : "●" + + let explicitReset = self.blankChart + ? self.resetsAtOverride + : self.resetsAtOverride ?? self.window.resetsAt + let now = Date() + let estimatedResetMinutes = self.blankChart || self.geom.tNow >= 1 + ? nil + : (1 - self.geom.tNow) * Double(windowMins) + let effectiveResetDate = burnEffectiveResetDate( + explicitResetAt: explicitReset, + estimatedResetMinutes: estimatedResetMinutes, + now: now) + let heroColor = self.geom.depleted ? self.theme.danger : self.theme.statusColor + + HStack(alignment: .center, spacing: 12) { + // Label column + VStack(alignment: .leading, spacing: 0) { + // Line 1: tag + (arrow for non-pace metrics) + pace word + // For remaining/used: "5H ▼ over pace". For pace: "7D on pace" (arrow is on hero line). + HStack(alignment: .firstTextBaseline, spacing: 5) { + Text(self.tag) + .font(.system(size: 9.5, weight: .heavy)) + .foregroundStyle(self.theme.sub) + .kerning(1) + HStack(alignment: .firstTextBaseline, spacing: 2) { + if !prefixArrow { + Text(arrow) + .font(.system(size: 8)) + .foregroundStyle(self.theme.statusColor) + } + Text(paceWord) + .font(.system(size: 10, weight: .semibold)) + .foregroundStyle(self.theme.statusColor) + } + .lineLimit(1) + } + + // Line 2: hero number. Pace metric prefixes an arrow glyph. + HStack(alignment: .lastTextBaseline, spacing: 2) { + if prefixArrow { + Text(arrow) + .font(.system(size: 13, weight: .bold)) + .foregroundStyle(heroColor) + } + Text("\(heroNum)") + .font(.system(size: 27, weight: .semibold)) + .foregroundStyle(heroColor) + .monospacedDigit() + .lineLimit(1) + .minimumScaleFactor(0.8) + Text("%") + .font(.system(size: 13, weight: .medium)) + .foregroundStyle(self.theme.sub) + if !suffix.isEmpty { + Text(suffix) + .font(.system(size: 10)) + .foregroundStyle(self.theme.sub) + } + } + .padding(.top, 1) + + // Line 3: reset line — refresh glyph + countdown + compact time + HStack(spacing: 4) { + Image(systemName: "arrow.clockwise") + .font(.system(size: 9)) + .foregroundStyle(self.theme.sub.opacity(0.85)) + if let effectiveResetDate { + Text(effectiveResetDate, style: .relative) + .font(.system(size: 9.5, weight: .medium)) + .foregroundStyle(self.theme.text) + .monospacedDigit() + Text("· \(combinedCompactResetTime(effectiveResetDate, isDailyWindow: isDailyWindow))") + .font(.system(size: 9.5, weight: .medium)) + .foregroundStyle(self.theme.text) + } else { + Text("—") + .font(.system(size: 9.5, weight: .medium)) + .foregroundStyle(self.theme.text) + } + } + .padding(.top, 2) + .lineLimit(1) + } + .frame(width: 112, alignment: .leading) + + // Chart column — blanked when the session window is blocked by an exhausted + // weekly cap; there is no session burn to chart until the weekly resets. + if self.blankChart { + Color.clear + .frame(maxWidth: .infinity) + .frame(height: 50) + } else { + CombinedBurnChartCanvas(geom: self.geom, theme: self.theme, periods: self.periods, dark: self.dark) + .frame(maxWidth: .infinity) + .frame(height: 50) + } + } + .frame(maxHeight: .infinity) + } +} + +// MARK: - Empty Row + +private struct CombinedEmptyRow: View { + let tag: String + let theme: BurnTheme + + var body: some View { + HStack { + Text(self.tag) + .font(.system(size: 9.5, weight: .heavy)) + .foregroundStyle(self.theme.sub) + .kerning(1) + Text("No data") + .font(.system(size: 10)) + .foregroundStyle(self.theme.sub) + Spacer() + } + .frame(maxHeight: .infinity) + } +} + +// MARK: - Mini Chart Canvas + +private struct CombinedBurnChartCanvas: View { + let geom: BurnGeom + let theme: BurnTheme + let periods: Int + let dark: Bool + + var body: some View { + Canvas { context, size in + let w = size.width + let h = size.height + let padT: CGFloat = 5 + let padB: CGFloat = 2 + let padL: CGFloat = 1 + let padR: CGFloat = 1 + + func X(_ t: Double) -> CGFloat { + padL + CGFloat(t) * (w - padL - padR) + } + func Y(_ v: Double) -> CGFloat { + padT + CGFloat(1 - v / 100) * (h - padT - padB) + } + + let tNow = self.geom.tNow + let vNow = self.geom.vNow + let barColor = self.dark ? Color.white : Color.black + + // --- Usage bars (background texture) --- + // Drawn first so the actual line renders on top. + // Heights are relative-to-ideal: idealPerPeriod maps to ~46% of plot height. + let plotH = h - padT - padB + let refH = 0.46 * plotH // reference height = ideal-pace bar height + let idealPerPeriod = 100.0 / Double(self.periods) + let burnRate = tNow > 0.001 ? (100.0 - vNow) / tNow : 0.0 // %/unit-t + let slotW = (w - padL - padR) / CGFloat(self.periods) + + for i in 0.. 0 { + let rect = CGRect( + x: slotX, + y: h - padB - baseH, + width: slotW - 1, + height: baseH) + context.fill(Path(rect), with: .color(barColor.opacity(0.17))) + } + // Overage segment — above ideal reference line + if totalBarH > refH { + let overH = totalBarH - refH + let rect = CGRect( + x: slotX, + y: h - padB - totalBarH, + width: slotW - 1, + height: overH) + context.fill(Path(rect), with: .color(barColor.opacity(0.34))) + } + } else if slotStart < tNow { + // Current (partial) period — narrower bar ending at tNow + let partialFrac = (tNow - slotStart) / (slotEnd - slotStart) + let consumed = burnRate * (tNow - slotStart) + let ratio = consumed / idealPerPeriod + let totalBarH = CGFloat(ratio) * refH + let baseH = min(totalBarH, refH) + let barW = CGFloat(partialFrac) * (slotW - 1) + + if baseH > 0 { + let rect = CGRect( + x: slotX, + y: h - padB - baseH, + width: barW, + height: baseH) + context.fill(Path(rect), with: .color(barColor.opacity(0.13))) + } + if totalBarH > refH { + let overH = totalBarH - refH + let rect = CGRect( + x: slotX, + y: h - padB - totalBarH, + width: barW, + height: overH) + context.fill(Path(rect), with: .color(barColor.opacity(0.26))) + } + } else { + // Future period — faint full-height placeholder + let rect = CGRect( + x: slotX, + y: h - padB - refH, + width: slotW - 1, + height: refH) + context.fill(Path(rect), with: .color(barColor.opacity(0.045))) + } + } + + // --- Now vertical hairline --- + do { + var p = Path() + p.move(to: CGPoint(x: X(tNow), y: Y(100))) + p.addLine(to: CGPoint(x: X(tNow), y: Y(0))) + context.stroke(p, with: .color(self.theme.chartGrid), lineWidth: 1) + } + + // --- Baseline --- + do { + var p = Path() + p.move(to: CGPoint(x: X(0), y: Y(0))) + p.addLine(to: CGPoint(x: X(1), y: Y(0))) + context.stroke(p, with: .color(self.theme.chartGrid), lineWidth: 1) + } + + // --- Ideal line (dashed, recedes) --- + do { + var p = Path() + p.move(to: CGPoint(x: X(0), y: Y(100))) + p.addLine(to: CGPoint(x: X(1), y: Y(0))) + context.stroke( + p, + with: .color(self.theme.chartIdeal), + style: StrokeStyle(lineWidth: 1.4, lineCap: .round, dash: [2.5, 3])) + } + + // --- Projection (fine dotted) --- + if self.geom.slope < -0.01 { + var p = Path() + p.move(to: CGPoint(x: X(tNow), y: Y(vNow))) + p.addLine(to: CGPoint(x: X(self.geom.projT), y: Y(self.geom.projV))) + context.stroke( + p, + with: .color(self.theme.chartProj.opacity(0.95)), + style: StrokeStyle(lineWidth: 1.6, lineCap: .round, dash: [0.5, 3.5])) + } + + // --- Actual line (solid, dominant) --- + do { + var p = Path() + p.move(to: CGPoint(x: X(0), y: Y(100))) + p.addLine(to: CGPoint(x: X(tNow), y: Y(vNow))) + context.stroke( + p, + with: .color(self.theme.chartLine), + style: StrokeStyle(lineWidth: 2.4, lineCap: .round, lineJoin: .round)) + } + + // --- Now dot (filled, ringed) --- + let dotCenter = CGPoint(x: X(tNow), y: Y(vNow)) + let ringPath = Path(ellipseIn: CGRect( + x: dotCenter.x - 5.4, y: dotCenter.y - 5.4, width: 10.8, height: 10.8)) + context.fill(ringPath, with: .color(self.theme.chartNowRing)) + let dotPath = Path(ellipseIn: CGRect( + x: dotCenter.x - 3.4, y: dotCenter.y - 3.4, width: 6.8, height: 6.8)) + context.fill(dotPath, with: .color(self.theme.chartNowDot)) + } + } +} + +// MARK: - Compact reset time helper + +/// Formats a reset date compactly: "4:30p", "5p", "Sun 9a". +/// Weekday prefix is added for the 7-day window or when the reset is ≥20h away. +private func combinedCompactResetTime(_ date: Date, isDailyWindow: Bool) -> String { + let includeDay = isDailyWindow || date.timeIntervalSinceNow >= 20 * 3600 + let formatter = DateFormatter() + formatter.setLocalizedDateFormatFromTemplate(includeDay ? "EEEjm" : "jm") + return formatter.string(from: date) +} diff --git a/Tests/AdaptiveReplayCLITests/CLIArgumentsTests.swift b/Tests/AdaptiveReplayCLITests/CLIArgumentsTests.swift new file mode 100644 index 0000000000..bb4512aeeb --- /dev/null +++ b/Tests/AdaptiveReplayCLITests/CLIArgumentsTests.swift @@ -0,0 +1,59 @@ +import AdaptiveReplayKit +import Testing +@testable import AdaptiveReplayCLI + +struct CLIArgumentsTests { + @Test(arguments: [ + "fixed-0m", + "fixed--1m", + "fixed-3m", + "fixed-9223372036854775807m", + ]) + func `rejects invalid fixed interval names before policy construction`(rawPolicyName: String) { + let arguments = CLIArguments.parse(["trace.jsonl", "--policy", rawPolicyName]) + + guard case let .invalid(message) = arguments else { + Issue.record("Expected \(rawPolicyName) to be rejected") + return + } + #expect(message.contains(rawPolicyName)) + #expect(message.contains(ReplayPolicyName.expectedValues)) + } + + @Test(arguments: ReplayPolicyName.allCases) + func `accepts every documented policy name`(policyName: ReplayPolicyName) { + let arguments = CLIArguments.parse(["trace.jsonl", "--policy", policyName.rawValue]) + + guard case let .run(tracePath, policyNames, jsonOutput, _) = arguments else { + Issue.record("Expected \(policyName.rawValue) to be accepted") + return + } + #expect(tracePath == "trace.jsonl") + #expect(policyNames == [policyName]) + #expect(policyNames.map(\.policy.name) == [policyName.rawValue]) + #expect(!jsonOutput) + } + + @Test + func `omitting policy selects every documented policy`() { + let arguments = CLIArguments.parse(["trace.jsonl"]) + + guard case let .run(_, policyNames, _, _) = arguments else { + Issue.record("Expected the default policy set") + return + } + #expect(policyNames == ReplayPolicyName.allCases) + } + + @Test + func `agent aware activity policy remains a distinct selectable mode`() { + let arguments = CLIArguments.parse(["trace.jsonl", "--policy", "adaptive-activity"]) + + guard case let .run(_, policyNames, _, _) = arguments else { + Issue.record("Expected the released alias to remain accepted") + return + } + #expect(policyNames == [.adaptiveActivity]) + #expect(policyNames.map(\.policy.name) == ["adaptive-activity"]) + } +} diff --git a/Tests/AdaptiveReplayKitTests/ActivityCoverageStatsTests.swift b/Tests/AdaptiveReplayKitTests/ActivityCoverageStatsTests.swift new file mode 100644 index 0000000000..b545698ec5 --- /dev/null +++ b/Tests/AdaptiveReplayKitTests/ActivityCoverageStatsTests.swift @@ -0,0 +1,96 @@ +import AdaptiveReplayKit +import Foundation +import Testing + +/// Purely informational trace-level stats surfaced by `AdaptiveReplayCLI` — computed directly from +/// raw `decision` records, independent of any `ReplayPolicy` or `ReplayEngine` simulation. +struct ActivityCoverageStatsTests { + private static let referenceNow = Date(timeIntervalSinceReferenceDate: 800_000_000) + + private static func decision(codex: TimeInterval?, claude: TimeInterval?) -> AdaptiveRefreshTraceRecord { + .decision( + timestamp: self.referenceNow, + menuAgeSeconds: nil, + lowPowerModeEnabled: false, + thermalState: .nominal, + reason: "warm", + delaySeconds: 300, + codexActivitySeconds: codex, + claudeActivitySeconds: claude) + } + + @Test + func `an empty trace reports zero decisions and zero fractions`() { + let stats = ActivityCoverageStats.compute(from: []) + #expect(stats.decisionCount == 0) + #expect(stats.sampledCount == 0) + #expect(stats.activeCount == 0) + #expect(stats.sampledFraction == 0) + #expect(stats.activeFraction == 0) + } + + @Test + func `non decision records are ignored entirely`() { + let records: [AdaptiveRefreshTraceRecord] = [ + .menuOpen(timestamp: Self.referenceNow), + .refreshCompleted(timestamp: Self.referenceNow), + ] + let stats = ActivityCoverageStats.compute(from: records) + #expect(stats.decisionCount == 0) + } + + @Test + func `a decision with neither activity field set counts toward decisionCount but not sampledCount`() { + let stats = ActivityCoverageStats.compute(from: [Self.decision(codex: nil, claude: nil)]) + #expect(stats.decisionCount == 1) + #expect(stats.sampledCount == 0) + #expect(stats.activeCount == 0) + } + + @Test + func `a decision with only one activity field set still counts as sampled`() { + let stats = ActivityCoverageStats.compute(from: [Self.decision(codex: 500, claude: nil)]) + #expect(stats.sampledCount == 1) + } + + @Test + func `a sampled decision under the active threshold on either CLI counts as active`() { + let codexActive = ActivityCoverageStats.compute(from: [Self.decision(codex: 100, claude: nil)]) + #expect(codexActive.activeCount == 1) + + let claudeActive = ActivityCoverageStats.compute(from: [Self.decision(codex: nil, claude: 100)]) + #expect(claudeActive.activeCount == 1) + } + + @Test + func `a sampled decision at or above the active threshold on both CLIs does not count as active`() { + let stats = ActivityCoverageStats.compute(from: [Self.decision(codex: 500, claude: 400)]) + #expect(stats.sampledCount == 1) + #expect(stats.activeCount == 0) + } + + @Test + func `fractions are computed against decisionCount and sampledCount respectively`() { + let records: [AdaptiveRefreshTraceRecord] = [ + Self.decision(codex: 100, claude: nil), // sampled, active + Self.decision(codex: 500, claude: 400), // sampled, not active + Self.decision(codex: nil, claude: nil), // not sampled + Self.decision(codex: nil, claude: nil), // not sampled + ] + let stats = ActivityCoverageStats.compute(from: records) + #expect(stats.decisionCount == 4) + #expect(stats.sampledCount == 2) + #expect(stats.activeCount == 1) + #expect(stats.sampledFraction == 0.5) + #expect(stats.activeFraction == 0.5) + } + + @Test + func `a custom active threshold changes the active classification`() { + let stats = ActivityCoverageStats.compute( + from: [Self.decision(codex: 250, claude: nil)], + activeThresholdSeconds: 60) + #expect(stats.sampledCount == 1) + #expect(stats.activeCount == 0) + } +} diff --git a/Tests/AdaptiveReplayKitTests/AdaptiveRefreshPolicyCoreTests.swift b/Tests/AdaptiveReplayKitTests/AdaptiveRefreshPolicyCoreTests.swift new file mode 100644 index 0000000000..75d1a06a2e --- /dev/null +++ b/Tests/AdaptiveReplayKitTests/AdaptiveRefreshPolicyCoreTests.swift @@ -0,0 +1,145 @@ +import AdaptiveRefreshCore +import Foundation +import Testing + +struct AdaptiveRefreshPolicyCoreTests { + private static let referenceNow = Date(timeIntervalSinceReferenceDate: 800_000_000) + + private func input( + ageSeconds: TimeInterval?, + codingActivityAgeSeconds: TimeInterval? = nil, + lowPowerModeEnabled: Bool = false, + thermalPressure: AdaptiveRefreshPolicyCore.ThermalPressure = .nominal) + -> AdaptiveRefreshPolicyCore.Input + { + AdaptiveRefreshPolicyCore.Input( + now: Self.referenceNow, + lastMenuOpenAt: ageSeconds.map { Self.referenceNow.addingTimeInterval(-$0) }, + lastCodingActivityAt: codingActivityAgeSeconds.map { Self.referenceNow.addingTimeInterval(-$0) }, + lowPowerModeEnabled: lowPowerModeEnabled, + thermalPressure: thermalPressure) + } + + @Test(arguments: [ + (-600.0, AdaptiveRefreshPolicyCore.Reason.recentInteraction, 120), + (0.0, .recentInteraction, 120), + (299.0, .recentInteraction, 120), + (300.0, .recentInteraction, 120), + (301.0, .warm, 300), + (3599.0, .warm, 300), + (3600.0, .warm, 300), + (3601.0, .idle, 900), + (14399.0, .idle, 900), + (14400.0, .longIdle, 1800), + (100_000.0, .longIdle, 1800), + ]) + func `age determines the canonical table boundary`( + ageSeconds: TimeInterval, + expectedReason: AdaptiveRefreshPolicyCore.Reason, + expectedDelaySeconds: Int) + { + let decision = AdaptiveRefreshPolicyCore().nextDelay(for: self.input(ageSeconds: ageSeconds)) + #expect(decision.reason == expectedReason) + #expect(decision.delay == .seconds(expectedDelaySeconds)) + } + + @Test + func `nil last menu open is long idle`() { + let decision = AdaptiveRefreshPolicyCore().nextDelay(for: self.input(ageSeconds: nil)) + #expect(decision.reason == .longIdle) + #expect(decision.delay == .seconds(30 * 60)) + } + + @Test + func `low power mode wins over recent interaction`() { + let decision = AdaptiveRefreshPolicyCore().nextDelay(for: self.input( + ageSeconds: 0, + lowPowerModeEnabled: true)) + #expect(decision.reason == .constrained) + #expect(decision.delay == .seconds(30 * 60)) + } + + @Test + func `thermal pressure wins when no menu open is recorded`() { + let decision = AdaptiveRefreshPolicyCore().nextDelay(for: self.input( + ageSeconds: nil, + thermalPressure: .constrained)) + #expect(decision.reason == .constrained) + #expect(decision.delay == .seconds(30 * 60)) + } + + @Test(arguments: [TimeInterval(3601), 14400, 100_000]) + func `recent coding activity caps slower menu decisions at five minutes`(ageSeconds: TimeInterval) { + let decision = AdaptiveRefreshPolicyCore().nextDelay(for: self.input( + ageSeconds: ageSeconds, + codingActivityAgeSeconds: 0)) + #expect(decision.reason == .codingActivity) + #expect(decision.delay == .seconds(5 * 60)) + } + + @Test + func `coding activity does not lengthen recent or warm decisions`() { + let recent = AdaptiveRefreshPolicyCore().nextDelay(for: self.input( + ageSeconds: 0, + codingActivityAgeSeconds: 0)) + let warm = AdaptiveRefreshPolicyCore().nextDelay(for: self.input( + ageSeconds: 301, + codingActivityAgeSeconds: 0)) + #expect(recent.reason == .recentInteraction) + #expect(recent.delay == .seconds(2 * 60)) + #expect(warm.reason == .warm) + #expect(warm.delay == .seconds(5 * 60)) + } + + @Test + func `constraints win and the coding activity boundary is exclusive`() { + let constrained = AdaptiveRefreshPolicyCore().nextDelay(for: self.input( + ageSeconds: nil, + codingActivityAgeSeconds: 0, + lowPowerModeEnabled: true)) + let insideBoundary = AdaptiveRefreshPolicyCore().nextDelay(for: self.input( + ageSeconds: nil, + codingActivityAgeSeconds: 299)) + let atBoundary = AdaptiveRefreshPolicyCore().nextDelay(for: self.input( + ageSeconds: nil, + codingActivityAgeSeconds: 300)) + #expect(constrained.reason == .constrained) + #expect(constrained.delay == .seconds(30 * 60)) + #expect(insideBoundary.reason == .codingActivity) + #expect(insideBoundary.delay == .seconds(5 * 60)) + #expect(atBoundary.reason == .longIdle) + #expect(atBoundary.delay == .seconds(30 * 60)) + } + + @Test + func `future timestamps read as recent`() { + let decision = AdaptiveRefreshPolicyCore().nextDelay(for: self.input(ageSeconds: -1_000_000)) + #expect(decision.reason == .recentInteraction) + #expect(decision.delay == .seconds(2 * 60)) + } + + @Test + func `every decision stays within the two to thirty minute bounds`() { + let ages: [TimeInterval?] = [nil, -1_000_000, 0, 300, 301, 3600, 3601, 14399, 14400, 1_000_000] + for age in ages { + for lowPowerModeEnabled in [false, true] { + for thermalPressure in [ + AdaptiveRefreshPolicyCore.ThermalPressure.nominal, + .constrained, + ] { + let decision = AdaptiveRefreshPolicyCore().nextDelay(for: self.input( + ageSeconds: age, + lowPowerModeEnabled: lowPowerModeEnabled, + thermalPressure: thermalPressure)) + #expect(decision.delay >= .seconds(2 * 60)) + #expect(decision.delay <= .seconds(30 * 60)) + } + } + } + } + + @Test + func `nominal heuristic interval remains five minutes`() { + #expect(AdaptiveRefreshPolicyCore.nominalIntervalForHeuristics == 5 * 60) + } +} diff --git a/Tests/AdaptiveReplayKitTests/AdaptiveReplayEngineTests.swift b/Tests/AdaptiveReplayKitTests/AdaptiveReplayEngineTests.swift new file mode 100644 index 0000000000..fdbf9f3caf --- /dev/null +++ b/Tests/AdaptiveReplayKitTests/AdaptiveReplayEngineTests.swift @@ -0,0 +1,282 @@ +import AdaptiveReplayKit +import Foundation +import Testing + +/// Hand-computed metric checks against small synthetic traces, plus determinism and baseline +/// (manual/fixed) sanity checks for `ReplayEngine`. Trace construction stays in-code (no fixture +/// files): each trace is small enough that its expected metrics can be derived by hand in the +/// comments beside it, which is the actual verification for requirement 4 ("metric math verified +/// against hand-computed values"). +struct AdaptiveReplayEngineTests { + private static let epoch = Date(timeIntervalSinceReferenceDate: 0) + + private func at(_ seconds: TimeInterval) -> Date { + Self.epoch.addingTimeInterval(seconds) + } + + /// A one-hour span (t=0...3600) pinned by two `decision` boundary records, `FixedIntervalPolicy` + /// refreshing every 10 minutes, and four `menuOpen` events chosen so each falls a different, + /// hand-computable number of seconds after the preceding simulated refresh. + /// + /// Refreshes land at t=600,1200,...,3600 (6 total: cursor starts at 0, and 3600 <= end is still + /// included). Staleness samples: menuOpen@50 -> 50-0=50 (no refresh yet, falls back to + /// time-since-trace-start); @900 -> 900-600=300; @2200 -> 2200-1800=400; @3500 -> 3500-3000=500. + /// mean=(50+300+400+500)/4=312.5, median (nearest-rank, sorted=[50,300,400,500])=sorted[1]=300, + /// p95=sorted[3]=500. + private func fixedCadenceTrace() -> [AdaptiveRefreshTraceRecord] { + [ + .decision( + timestamp: self.at(0), + menuAgeSeconds: nil, + lowPowerModeEnabled: false, + thermalState: .nominal, + reason: "longIdle", + delaySeconds: 1800), + .decision( + timestamp: self.at(3600), + menuAgeSeconds: nil, + lowPowerModeEnabled: false, + thermalState: .nominal, + reason: "longIdle", + delaySeconds: 1800), + .menuOpen(timestamp: self.at(50)), + .menuOpen(timestamp: self.at(900)), + .menuOpen(timestamp: self.at(2200)), + .menuOpen(timestamp: self.at(3500)), + ] + } + + @Test + func `fixed cadence refresh count and staleness match hand computation`() throws { + let metrics = ReplayEngine.run(trace: self.fixedCadenceTrace(), policy: FixedIntervalPolicy(minutes: 10)) + + #expect(metrics.totalRefreshCount == 6) + #expect(metrics.simulatedSpanSeconds == 3600.0) + #expect(metrics.refreshCountPer24h == 144.0) // 6 refreshes/hour * 24h + #expect(metrics.interactionAdvanceCount == 0) // fixed cadence never advances on interaction + + let staleness = try #require(metrics.stalenessAtMenuOpen) + #expect(staleness.sampleCount == 4) + #expect(staleness.mean == 312.5) + #expect(staleness.median == 300.0) + #expect(staleness.p95 == 500.0) + } + + @Test + func `replaying the same trace and policy twice is deterministic`() { + let trace = self.fixedCadenceTrace() + let first = ReplayEngine.run(trace: trace, policy: FixedIntervalPolicy(minutes: 10)) + let second = ReplayEngine.run(trace: trace, policy: FixedIntervalPolicy(minutes: 10)) + #expect(first == second) + } + + @Test + func `manual policy never schedules a refresh`() { + let metrics = ReplayEngine.run(trace: self.fixedCadenceTrace(), policy: ManualPolicy()) + #expect(metrics.totalRefreshCount == 0) + #expect(metrics.refreshCountPer24h == 0.0) + } + + @Test + func `a trace with no menu-open events reports no staleness stats`() { + let trace: [AdaptiveRefreshTraceRecord] = [ + .decision( + timestamp: self.at(0), + menuAgeSeconds: nil, + lowPowerModeEnabled: false, + thermalState: .nominal, + reason: "longIdle", + delaySeconds: 1800), + .refreshCompleted(timestamp: self.at(1800)), + ] + let metrics = ReplayEngine.run(trace: trace, policy: AdaptiveReplayPolicy()) + #expect(metrics.stalenessAtMenuOpen == nil) + } + + /// A single constrained (`lowPowerModeEnabled: true`) sample at t=0, held for the whole + /// 0...1000 span (no later sample overrides it), replayed against `FixedIntervalPolicy(2m)` + /// (120s, well under the 30-minute constrained floor). + /// + /// decide() is called at cursor = 0,120,240,...,960 (9 calls: the call at 960 computes + /// next=1080 > end=1000 and breaks before appending). All 9 calls see the constrained sample, + /// and every one returns a 120s delay, so all 9 are violations. 8 of those calls' `next` landed + /// at or before 1000 (120,240,...,960), so 8 refreshes were recorded. + private func constrainedTrace() -> [AdaptiveRefreshTraceRecord] { + [ + .decision( + timestamp: self.at(0), + menuAgeSeconds: nil, + lowPowerModeEnabled: true, + thermalState: .nominal, + reason: "constrained", + delaySeconds: 1800), + .menuOpen(timestamp: self.at(1000)), + ] + } + + @Test + func `a policy that ignores the constrained floor is flagged non-compliant`() { + let metrics = ReplayEngine.run(trace: self.constrainedTrace(), policy: FixedIntervalPolicy(minutes: 2)) + + #expect(metrics.totalRefreshCount == 8) + #expect(metrics.constrainedCompliance.constrainedDecisionCount == 9) + #expect(metrics.constrainedCompliance.violationCount == 9) + #expect(!metrics.constrainedCompliance.isCompliant) + } + + @Test + func `the shared adaptive policy honors the constrained floor`() { + let metrics = ReplayEngine.run(trace: self.constrainedTrace(), policy: AdaptiveReplayPolicy()) + + #expect(metrics.constrainedCompliance.constrainedDecisionCount == 1) + #expect(metrics.constrainedCompliance.violationCount == 0) + #expect(metrics.constrainedCompliance.isCompliant) + // The menu open at t=1000 is still under low-power, so the advance-check itself also + // returns the constrained floor (candidate = 1000+1800 = 2800), which is later than the + // already-scheduled t=1800 tick — no advance is taken. Mirrors the real + // `noteMenuOpened(at:)` guard: opening the menu while constrained never shortens the timer. + #expect(metrics.interactionAdvanceCount == 0) + } + + @Test + func `an empty trace reports zero metrics without crashing`() { + let metrics = ReplayEngine.run(trace: [], policy: AdaptiveReplayPolicy()) + #expect(metrics.totalRefreshCount == 0) + #expect(metrics.simulatedSpanSeconds == 0.0) + #expect(metrics.stalenessAtMenuOpen == nil) + #expect(metrics.constrainedCompliance.constrainedDecisionCount == 0) + #expect(metrics.interactionAdvanceCount == 0) + } + + // MARK: - Interaction-advance path (mirrors UsageStore.noteMenuOpened(at:)) + + /// A 300-second span with a single tick boundary at t=0 (which alone would schedule a longIdle + /// refresh at t=1800, far past the trace's end) and one `menuOpen` at t=50 landing inside that + /// tick's window. + /// + /// Hand computation for `AdaptiveReplayPolicy` (`advancesOnInteraction == true`): + /// - cursor=0: decide(now:0, lastMenuOpenAt: nil) -> longIdle, delay=1800 -> next=1800. + /// menuOpen@50 falls in (0, 1800]: decide(now:50, lastMenuOpenAt:50) (age 0) -> + /// recentInteraction, delay=120 -> candidate=170. 170 < 1800, so the schedule advances: + /// next=170 (1 advance so far). next(170) <= end(300), so a refresh lands at t=170. + /// - cursor=170: decide(now:170, lastMenuOpenAt:50) (age 120 <= 300 recentInteractionThreshold) + /// -> recentInteraction, delay=120 -> next=290. No more menu opens to scan. 290 <= 300, so a + /// refresh lands at t=290. + /// - cursor=290: decide(now:290, lastMenuOpenAt:50) (age 240 <= 300) -> recentInteraction, + /// delay=120 -> next=410. 410 > end(300), loop breaks without appending. + /// + /// Total: 2 refreshes (170, 290), 1 interaction advance. Without the advance, the *only* + /// schedulable event would be the t=1800 tick, which falls entirely outside this 300s span — + /// i.e. `totalRefreshCount` would be 0. The non-zero count here is only possible because the + /// engine reproduces the interaction-advance path. + private func menuOpenAdvanceTrace() -> [AdaptiveRefreshTraceRecord] { + [ + .decision( + timestamp: self.at(0), + menuAgeSeconds: nil, + lowPowerModeEnabled: false, + thermalState: .nominal, + reason: "longIdle", + delaySeconds: 1800), + .decision( + timestamp: self.at(300), + menuAgeSeconds: nil, + lowPowerModeEnabled: false, + thermalState: .nominal, + reason: "longIdle", + delaySeconds: 1800), + .menuOpen(timestamp: self.at(50)), + ] + } + + @Test + func `a menu open pulls the adaptive schedule forward, matching hand computation`() { + let metrics = ReplayEngine.run(trace: self.menuOpenAdvanceTrace(), policy: AdaptiveReplayPolicy()) + + #expect(metrics.totalRefreshCount == 2) + #expect(metrics.interactionAdvanceCount == 1) + } + + @Test + func `a policy that does not advance on interaction ignores the same menu open`() { + // Same trace, but FixedIntervalPolicy(30m) never overrides `advancesOnInteraction` (stays + // false), matching fixed-cadence refresh frequencies in the real app, which never wire + // `noteMenuOpened(at:)`'s advance check at all. The t=1800 tick falls outside the 300s + // span, so nothing is scheduled — the menu open at t=50 has zero scheduling effect. + let metrics = ReplayEngine.run(trace: self.menuOpenAdvanceTrace(), policy: FixedIntervalPolicy(minutes: 30)) + + #expect(metrics.totalRefreshCount == 0) + #expect(metrics.interactionAdvanceCount == 0) + } + + @Test + func `a recorded timerAdvanced ground-truth event agrees with the engine's own recomputation`() throws { + // The menuOpen ground truth plus a timerAdvanced record for the accepted schedule change. + // The offline audit checks that record against the policy's recomputed candidate. + let menuOpenAt = self.at(50) + let recordedCandidate = self.at(170) // menuOpenAt + recentInteractionDelay (120s) + var trace = self.menuOpenAdvanceTrace() + trace.append(.timerAdvanced( + timestamp: menuOpenAt, + previousScheduledAt: self.at(1800), + candidateScheduledAt: recordedCandidate, + reason: "recentInteraction", + delaySeconds: 120)) + + let policy = AdaptiveReplayPolicy() + let recomputed = policy.decide(ReplayPolicyInput( + now: menuOpenAt, + lastMenuOpenAt: menuOpenAt, + lowPowerModeEnabled: false, + thermalState: .nominal)) + let recomputedCandidate = try menuOpenAt.addingTimeInterval(#require(recomputed.delaySeconds)) + + #expect(recomputedCandidate == recordedCandidate) + + // The recorded event doesn't change the metrics (the engine recomputes advances itself, + // independent of any timerAdvanced lines in the trace); replaying still reproduces the + // same two refreshes as the trace without the extra record. + let metrics = ReplayEngine.run(trace: trace, policy: policy) + #expect(metrics.totalRefreshCount == 2) + #expect(metrics.interactionAdvanceCount == 1) + } + + /// Two menu opens in the same tick window: the second one's candidate is compared against the + /// *already-advanced* schedule from the first, not the original tick schedule — mirroring a + /// real second `noteMenuOpened(at:)` call tightening an already-shortened sleep. + /// + /// - cursor=0: decide -> longIdle, next=1800. menuOpen@50: candidate=170 < 1800 -> next=170 + /// (advance 1). menuOpen@100 also falls in (0, 170]? No — 100 <= 170 is true, so it's still + /// scanned: decide(now:100, lastMenuOpenAt:100) -> recentInteraction, candidate=220. Is 220 < + /// next(170)? No, so this second menu open does *not* further advance the schedule (it would + /// move the refresh *later*, which `shouldAdvanceAdaptiveTimer` never does). next stays 170. + /// - Total: 1 refresh (170), 1 advance (only the first menu open's candidate beat the schedule). + private func twoMenuOpensSameWindowTrace() -> [AdaptiveRefreshTraceRecord] { + [ + .decision( + timestamp: self.at(0), + menuAgeSeconds: nil, + lowPowerModeEnabled: false, + thermalState: .nominal, + reason: "longIdle", + delaySeconds: 1800), + .decision( + timestamp: self.at(170), + menuAgeSeconds: nil, + lowPowerModeEnabled: false, + thermalState: .nominal, + reason: "longIdle", + delaySeconds: 1800), + .menuOpen(timestamp: self.at(50)), + .menuOpen(timestamp: self.at(100)), + ] + } + + @Test + func `a later menu open in the same window cannot postpone an earlier advance`() { + let metrics = ReplayEngine.run(trace: self.twoMenuOpensSameWindowTrace(), policy: AdaptiveReplayPolicy()) + + #expect(metrics.totalRefreshCount == 1) + #expect(metrics.interactionAdvanceCount == 1) + } +} diff --git a/Tests/AdaptiveReplayKitTests/AdaptiveReplayPolicyTests.swift b/Tests/AdaptiveReplayKitTests/AdaptiveReplayPolicyTests.swift new file mode 100644 index 0000000000..7ea090224c --- /dev/null +++ b/Tests/AdaptiveReplayKitTests/AdaptiveReplayPolicyTests.swift @@ -0,0 +1,77 @@ +import AdaptiveReplayKit +import Foundation +import Testing + +struct AdaptiveReplayPolicyTests { + private static let referenceNow = Date(timeIntervalSinceReferenceDate: 800_000_000) + + private func input( + ageSeconds: TimeInterval?, + lowPowerModeEnabled: Bool = false, + thermalState: ReplayThermalState = .nominal) -> ReplayPolicyInput + { + ReplayPolicyInput( + now: Self.referenceNow, + lastMenuOpenAt: ageSeconds.map { Self.referenceNow.addingTimeInterval(-$0) }, + lowPowerModeEnabled: lowPowerModeEnabled, + thermalState: thermalState) + } + + @Test(arguments: [ + (0.0, "recentInteraction", 120.0), + (301.0, "warm", 300.0), + (3601.0, "idle", 900.0), + (14400.0, "longIdle", 1800.0), + ]) + func `replay adapter preserves canonical decisions`( + ageSeconds: TimeInterval, + expectedReason: String, + expectedDelaySeconds: TimeInterval) + { + let decision = AdaptiveReplayPolicy().decide(self.input(ageSeconds: ageSeconds)) + #expect(decision.reason == expectedReason) + #expect(decision.delaySeconds == expectedDelaySeconds) + } + + @Test(arguments: [ReplayThermalState.serious, .critical]) + func `replay adapter maps serious and critical thermal states to constrained`( + thermalState: ReplayThermalState) + { + let decision = AdaptiveReplayPolicy().decide(self.input(ageSeconds: 0, thermalState: thermalState)) + #expect(decision.reason == "constrained") + #expect(decision.delaySeconds == TimeInterval(30 * 60)) + } + + @Test + func `replay adapter preserves low power precedence`() { + let decision = AdaptiveReplayPolicy().decide(self.input( + ageSeconds: 0, + lowPowerModeEnabled: true, + thermalState: .nominal)) + #expect(decision.reason == "constrained") + #expect(decision.delaySeconds == TimeInterval(30 * 60)) + } + + @Test(arguments: [ReplayThermalState.nominal, .fair]) + func `replay adapter maps nominal and fair thermal states to unconstrained`( + thermalState: ReplayThermalState) + { + let decision = AdaptiveReplayPolicy().decide(self.input(ageSeconds: 0, thermalState: thermalState)) + #expect(decision.reason == "recentInteraction") + #expect(decision.delaySeconds == TimeInterval(2 * 60)) + } + + @Test + func `only adaptive replay advances on interaction`() { + #expect(AdaptiveReplayPolicy().advancesOnInteraction) + #expect(!FixedIntervalPolicy(minutes: 5).advancesOnInteraction) + #expect(!ManualPolicy().advancesOnInteraction) + } + + @Test + func `fixed interval conversion cannot overflow integer multiplication`() { + let decision = FixedIntervalPolicy(minutes: Int.max).decide(self.input(ageSeconds: 0)) + #expect(decision.delaySeconds == TimeInterval(Int.max) * 60) + #expect(decision.delaySeconds?.isFinite == true) + } +} diff --git a/Tests/AdaptiveReplayKitTests/AdaptiveReplayTraceParserTests.swift b/Tests/AdaptiveReplayKitTests/AdaptiveReplayTraceParserTests.swift new file mode 100644 index 0000000000..f84dac3a6c --- /dev/null +++ b/Tests/AdaptiveReplayKitTests/AdaptiveReplayTraceParserTests.swift @@ -0,0 +1,322 @@ +import AdaptiveReplayKit +import Foundation +import Testing + +struct AdaptiveReplayTraceParserTests { + private static let referenceNow = Date(timeIntervalSinceReferenceDate: 800_000_000) + + private func encode(_ record: AdaptiveRefreshTraceRecord) throws -> String { + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + let data = try encoder.encode(record) + return try #require(String(data: data, encoding: .utf8)) + } + + @Test + func `parses a well-formed trace and preserves record order`() throws { + let records: [AdaptiveRefreshTraceRecord] = [ + .menuOpen(timestamp: Self.referenceNow), + .decision( + timestamp: Self.referenceNow.addingTimeInterval(1), + menuAgeSeconds: 1, + lowPowerModeEnabled: false, + thermalState: .nominal, + reason: "recentInteraction", + delaySeconds: 120), + .refreshCompleted(timestamp: Self.referenceNow.addingTimeInterval(121)), + ] + let text = try records.map(self.encode).joined(separator: "\n") + + let parsed = try AdaptiveRefreshTraceParser.parse(text) + + #expect(parsed.count == 3) + #expect(parsed[0].kind == .menuOpen) + #expect(parsed[1].kind == .decision) + #expect(parsed[1].reason == "recentInteraction") + #expect(parsed[1].delaySeconds == 120.0) + #expect(parsed[2].kind == .refreshCompleted) + } + + @Test + func `ignores blank lines between records`() throws { + let record = AdaptiveRefreshTraceRecord.menuOpen(timestamp: Self.referenceNow) + let text = try "\n\(self.encode(record))\n\n" + + let parsed = try AdaptiveRefreshTraceParser.parse(text) + + #expect(parsed.count == 1) + } + + @Test + func `strict parsing accepts multiple CRLF records`() throws { + let records: [AdaptiveRefreshTraceRecord] = [ + .menuOpen(timestamp: Self.referenceNow), + .refreshCompleted(timestamp: Self.referenceNow.addingTimeInterval(1)), + ] + let text = try records.map(self.encode).joined(separator: "\r\n") + + let parsed = try AdaptiveRefreshTraceParser.parse(text) + + #expect(parsed.map(\.kind) == [.menuOpen, .refreshCompleted]) + } + + @Test + func `empty trace parses to zero records`() throws { + let parsed = try AdaptiveRefreshTraceParser.parse("") + #expect(parsed.isEmpty) + } + + @Test + func `a malformed line fails the whole parse with a line number`() throws { + let good = try self.encode(.menuOpen(timestamp: Self.referenceNow)) + let text = "\(good)\nnot json\n\(good)" + + #expect(throws: AdaptiveRefreshTraceParseError.self) { + try AdaptiveRefreshTraceParser.parse(text) + } + + do { + _ = try AdaptiveRefreshTraceParser.parse(text) + Issue.record("expected parse to throw") + } catch let error as AdaptiveRefreshTraceParseError { + #expect(error.lineNumber == 2) + #expect(error.content == "not json") + } catch { + Issue.record("unexpected error type: \(error)") + } + } + + @Test + func `tolerant parsing skips malformed lines instead of failing`() throws { + let good = try self.encode(.menuOpen(timestamp: Self.referenceNow)) + let text = "\(good)\nnot json\n\(good)" + + let parsed = AdaptiveRefreshTraceParser.parseTolerantly(text) + + #expect(parsed.count == 2) + } + + @Test + func `tolerant parsing accepts CRLF and skips only malformed records`() throws { + let menuOpen = try self.encode(.menuOpen(timestamp: Self.referenceNow)) + let refresh = try self.encode(.refreshCompleted(timestamp: Self.referenceNow.addingTimeInterval(1))) + let text = [menuOpen, "not json", refresh].joined(separator: "\r\n") + + let parsed = AdaptiveRefreshTraceParser.parseTolerantly(text) + + #expect(parsed.map(\.kind) == [.menuOpen, .refreshCompleted]) + } + + /// `timerAdvanced` round-trips its two extra fields (`previousScheduledAt`, + /// `candidateScheduledAt`) and leaves the signal fields (`menuAgeSeconds`, + /// `lowPowerModeEnabled`, `thermalState`) nil, matching the type's field-presence contract. + @Test + func `parses a timerAdvanced record and preserves its schedule fields`() throws { + let record = AdaptiveRefreshTraceRecord.timerAdvanced( + timestamp: Self.referenceNow, + previousScheduledAt: Self.referenceNow.addingTimeInterval(1800), + candidateScheduledAt: Self.referenceNow.addingTimeInterval(120), + reason: "recentInteraction", + delaySeconds: 120) + let text = try self.encode(record) + + let parsed = try AdaptiveRefreshTraceParser.parse(text) + + #expect(parsed.count == 1) + #expect(parsed[0].kind == .timerAdvanced) + #expect(parsed[0].reason == "recentInteraction") + #expect(parsed[0].delaySeconds == 120.0) + #expect(parsed[0].previousScheduledAt == Self.referenceNow.addingTimeInterval(1800)) + #expect(parsed[0].candidateScheduledAt == Self.referenceNow.addingTimeInterval(120)) + #expect(parsed[0].menuAgeSeconds == nil) + #expect(parsed[0].lowPowerModeEnabled == nil) + #expect(parsed[0].thermalState == nil) + } + + /// A `timerAdvanced` record whose advance had no prior schedule (`previousScheduledAt == nil`) + /// — the "always advance" case `UsageStore.shouldAdvanceAdaptiveTimer` returns for a nil + /// `scheduledAt` — round-trips the nil correctly rather than defaulting to some sentinel date. + @Test + func `a timerAdvanced record with no previous schedule round-trips a nil previousScheduledAt`() throws { + let record = AdaptiveRefreshTraceRecord.timerAdvanced( + timestamp: Self.referenceNow, + previousScheduledAt: nil, + candidateScheduledAt: Self.referenceNow.addingTimeInterval(120), + reason: "recentInteraction", + delaySeconds: 120) + let text = try self.encode(record) + + let parsed = try AdaptiveRefreshTraceParser.parse(text) + + #expect(parsed[0].previousScheduledAt == nil) + } + + /// Backward compatibility: the ~500 pre-existing lines in this machine's live trace were + /// written before `codexActivitySeconds`/`claudeActivitySeconds` existed. A hand-written + /// old-format `decision` line (no activity keys at all) must still decode, with both new + /// fields nil rather than failing to parse. + @Test + func `an old-format decision line without activity fields decodes with nil activity signals`() throws { + let oldFormatLine = """ + {"kind":"decision","timestamp":"2026-01-01T00:00:00Z","menuAgeSeconds":30,\ + "lowPowerModeEnabled":false,"thermalState":"nominal","reason":"longIdle","delaySeconds":1800} + """ + + let parsed = try AdaptiveRefreshTraceParser.parse(oldFormatLine) + + #expect(parsed.count == 1) + #expect(parsed[0].reason == "longIdle") + #expect(parsed[0].codexActivitySeconds == nil) + #expect(parsed[0].claudeActivitySeconds == nil) + } + + /// A `decision` record carrying both activity signals round-trips them exactly. + @Test + func `a decision record with activity signals round-trips both values`() throws { + let record = AdaptiveRefreshTraceRecord.decision( + timestamp: Self.referenceNow, + menuAgeSeconds: 5, + lowPowerModeEnabled: false, + thermalState: .nominal, + reason: "recentInteraction", + delaySeconds: 120, + codexActivitySeconds: 42, + claudeActivitySeconds: 99) + let text = try self.encode(record) + + let parsed = try AdaptiveRefreshTraceParser.parse(text) + + #expect(parsed[0].codexActivitySeconds == 42) + #expect(parsed[0].claudeActivitySeconds == 99) + } + + /// Encoding must omit nil activity fields rather than emitting explicit `null`s, so old + /// tooling and hand-inspection of a trace stay unsurprised by fields it doesn't expect. + @Test + func `encoding a decision with nil activity signals omits both keys entirely`() throws { + let record = AdaptiveRefreshTraceRecord.decision( + timestamp: Self.referenceNow, + menuAgeSeconds: 5, + lowPowerModeEnabled: false, + thermalState: .nominal, + reason: "recentInteraction", + delaySeconds: 120) + let text = try self.encode(record) + + #expect(!text.contains("codexActivitySeconds")) + #expect(!text.contains("claudeActivitySeconds")) + } + + /// Backward compatibility for the "B layer" (session duration / transcript bytes / + /// active-transcript count): an old-format line written before those three fields per CLI + /// existed — including one already carrying the earlier "A layer" activity-seconds fields — + /// must still decode, with all six new fields nil. + @Test + func `an old-format decision line without B-layer fields decodes with nil B-layer signals`() throws { + let oldFormatLine = """ + {"kind":"decision","timestamp":"2026-01-01T00:00:00Z","menuAgeSeconds":30,\ + "lowPowerModeEnabled":false,"thermalState":"nominal","reason":"longIdle","delaySeconds":1800,\ + "codexActivitySeconds":42,"claudeActivitySeconds":99} + """ + + let parsed = try AdaptiveRefreshTraceParser.parse(oldFormatLine) + + #expect(parsed.count == 1) + #expect(parsed[0].codexActivitySeconds == 42) + #expect(parsed[0].claudeActivitySeconds == 99) + #expect(parsed[0].codexSessionDurationSeconds == nil) + #expect(parsed[0].claudeSessionDurationSeconds == nil) + #expect(parsed[0].codexTranscriptBytes == nil) + #expect(parsed[0].claudeTranscriptBytes == nil) + #expect(parsed[0].codexActiveTranscriptCount == nil) + #expect(parsed[0].claudeActiveTranscriptCount == nil) + } + + /// A trace mixing a pre-B-layer line, a pre-A-layer (original phase 1) line, and a full + /// current-format line all parse together — the parser never requires every line in a trace + /// to share the same schema vintage. + @Test + func `a trace mixing old and new format decision lines parses every line`() throws { + let phase1Line = """ + {"kind":"decision","timestamp":"2026-01-01T00:00:00Z","reason":"longIdle","delaySeconds":1800} + """ + let aLayerOnlyLine = """ + {"kind":"decision","timestamp":"2026-01-02T00:00:00Z","reason":"warm","delaySeconds":300,\ + "codexActivitySeconds":10} + """ + let currentLine = try self.encode(.decision( + timestamp: Self.referenceNow, + menuAgeSeconds: nil, + lowPowerModeEnabled: false, + thermalState: .nominal, + reason: "recentInteraction", + delaySeconds: 120, + codexActivitySeconds: 1, + claudeActivitySeconds: 2, + codexSessionDurationSeconds: 3, + claudeSessionDurationSeconds: 4, + codexTranscriptBytes: 5, + claudeTranscriptBytes: 6, + codexActiveTranscriptCount: 7, + claudeActiveTranscriptCount: 8)) + let text = [phase1Line, aLayerOnlyLine, currentLine].joined(separator: "\n") + + let parsed = try AdaptiveRefreshTraceParser.parse(text) + + #expect(parsed.count == 3) + #expect(parsed[0].codexActivitySeconds == nil) + #expect(parsed[1].codexActivitySeconds == 10) + #expect(parsed[1].codexSessionDurationSeconds == nil) + #expect(parsed[2].codexSessionDurationSeconds == 3) + #expect(parsed[2].claudeActiveTranscriptCount == 8) + } + + /// A `decision` record carrying all six B-layer fields round-trips them exactly. + @Test + func `a decision record with B-layer fields round-trips all six values`() throws { + let record = AdaptiveRefreshTraceRecord.decision( + timestamp: Self.referenceNow, + menuAgeSeconds: 5, + lowPowerModeEnabled: false, + thermalState: .nominal, + reason: "recentInteraction", + delaySeconds: 120, + codexSessionDurationSeconds: 600, + claudeSessionDurationSeconds: 900, + codexTranscriptBytes: 12345, + claudeTranscriptBytes: 67890, + codexActiveTranscriptCount: 2, + claudeActiveTranscriptCount: 4) + let text = try self.encode(record) + + let parsed = try AdaptiveRefreshTraceParser.parse(text) + + #expect(parsed[0].codexSessionDurationSeconds == 600) + #expect(parsed[0].claudeSessionDurationSeconds == 900) + #expect(parsed[0].codexTranscriptBytes == 12345) + #expect(parsed[0].claudeTranscriptBytes == 67890) + #expect(parsed[0].codexActiveTranscriptCount == 2) + #expect(parsed[0].claudeActiveTranscriptCount == 4) + } + + /// Encoding must omit nil B-layer fields rather than emitting explicit `null`s, matching the + /// A-layer's contract. + @Test + func `encoding a decision with nil B-layer fields omits all six keys entirely`() throws { + let record = AdaptiveRefreshTraceRecord.decision( + timestamp: Self.referenceNow, + menuAgeSeconds: 5, + lowPowerModeEnabled: false, + thermalState: .nominal, + reason: "recentInteraction", + delaySeconds: 120) + let text = try self.encode(record) + + #expect(!text.contains("codexSessionDurationSeconds")) + #expect(!text.contains("claudeSessionDurationSeconds")) + #expect(!text.contains("codexTranscriptBytes")) + #expect(!text.contains("claudeTranscriptBytes")) + #expect(!text.contains("codexActiveTranscriptCount")) + #expect(!text.contains("claudeActiveTranscriptCount")) + } +} diff --git a/Tests/AdaptiveReplayKitTests/MenuOnlyAdaptivePolicyTests.swift b/Tests/AdaptiveReplayKitTests/MenuOnlyAdaptivePolicyTests.swift new file mode 100644 index 0000000000..bc59d05cc5 --- /dev/null +++ b/Tests/AdaptiveReplayKitTests/MenuOnlyAdaptivePolicyTests.swift @@ -0,0 +1,144 @@ +import AdaptiveReplayKit +import Foundation +import Testing + +struct AdaptiveReplayModeTests { + private static let now = Date(timeIntervalSinceReferenceDate: 10000) + + private func input( + menuAge: TimeInterval?, + activityAge: TimeInterval?, + constrained: Bool = false) -> ReplayPolicyInput + { + ReplayPolicyInput( + now: Self.now, + lastMenuOpenAt: menuAge.map { Self.now.addingTimeInterval(-$0) }, + lastCodingActivityAt: activityAge.map { Self.now.addingTimeInterval(-$0) }, + lowPowerModeEnabled: constrained, + thermalState: .nominal) + } + + @Test + func `agent aware adaptive caps idle and long-idle decisions during coding`() { + let policy = AgentAwareAdaptiveReplayPolicy() + + #expect(policy.name == "adaptive-activity") + #expect(policy.decide(self.input(menuAge: 2 * 3600, activityAge: 10)).delaySeconds == 300) + #expect(policy.decide(self.input(menuAge: nil, activityAge: 10)).delaySeconds == 300) + #expect(policy.decide(self.input(menuAge: nil, activityAge: 10)).reason == "codingActivity") + } + + @Test + func `plain adaptive ignores coding activity`() { + let policy = AdaptiveReplayPolicy() + + #expect(policy.name == "adaptive") + #expect(policy.decide(self.input(menuAge: nil, activityAge: 10)).delaySeconds == 1800) + #expect(policy.decide(self.input(menuAge: nil, activityAge: 10)).reason == "longIdle") + } + + @Test + func `agent aware adaptive preserves recent warm constrained and boundary decisions`() { + let policy = AgentAwareAdaptiveReplayPolicy() + + #expect(policy.decide(self.input(menuAge: 60, activityAge: 10)).delaySeconds == 120) + #expect(policy.decide(self.input(menuAge: 600, activityAge: 10)).delaySeconds == 300) + #expect(policy.decide(self.input(menuAge: nil, activityAge: 10, constrained: true)).delaySeconds == 1800) + #expect(policy.decide(self.input(menuAge: nil, activityAge: 300)).delaySeconds == 1800) + #expect(policy.decide(self.input(menuAge: nil, activityAge: nil)).delaySeconds == 1800) + } + + @Test + func `future activity samples never backfill an earlier replay decision`() { + let trace: [AdaptiveRefreshTraceRecord] = [ + .decision( + timestamp: Self.now, + menuAgeSeconds: nil, + lowPowerModeEnabled: false, + thermalState: .nominal, + reason: "longIdle", + delaySeconds: 1800), + .decision( + timestamp: Self.now.addingTimeInterval(600), + menuAgeSeconds: nil, + lowPowerModeEnabled: false, + thermalState: .nominal, + reason: "longIdle", + delaySeconds: 1800, + codexActivitySeconds: 0), + ] + + let metrics = ReplayEngine.run(trace: trace, policy: AgentAwareAdaptiveReplayPolicy()) + + #expect(metrics.codingActiveDecisionCount == 0) + #expect(metrics.totalRefreshCount == 0) + } + + @Test + func `a newer unavailable observation invalidates older activity`() { + let trace: [AdaptiveRefreshTraceRecord] = [ + .menuOpen(timestamp: Self.now), + .decision( + timestamp: Self.now, + menuAgeSeconds: 0, + lowPowerModeEnabled: false, + thermalState: .nominal, + reason: "recentInteraction", + delaySeconds: 120, + codexActivitySeconds: 0), + .decision( + timestamp: Self.now.addingTimeInterval(100), + menuAgeSeconds: 100, + lowPowerModeEnabled: false, + thermalState: .nominal, + reason: "recentInteraction", + delaySeconds: 120), + .refreshCompleted(timestamp: Self.now.addingTimeInterval(240)), + ] + + let metrics = ReplayEngine.run(trace: trace, policy: AgentAwareAdaptiveReplayPolicy()) + + #expect(metrics.totalRefreshCount == 2) + #expect(metrics.codingActiveDecisionCount == 1) + } + + @Test + func `active compliance denominator excludes constrained decisions`() { + let trace: [AdaptiveRefreshTraceRecord] = [ + .decision( + timestamp: Self.now, + menuAgeSeconds: nil, + lowPowerModeEnabled: true, + thermalState: .nominal, + reason: "constrained", + delaySeconds: 1800, + codexActivitySeconds: 0), + .refreshCompleted(timestamp: Self.now.addingTimeInterval(1800)), + ] + + let metrics = ReplayEngine.run(trace: trace, policy: AgentAwareAdaptiveReplayPolicy()) + + #expect(metrics.codingActiveDecisionCount == 0) + #expect(metrics.codingActiveDelayViolationCount == 0) + } + + @Test + func `manual policy counts as slower than the active freshness cap`() { + let trace: [AdaptiveRefreshTraceRecord] = [ + .decision( + timestamp: Self.now, + menuAgeSeconds: nil, + lowPowerModeEnabled: false, + thermalState: .nominal, + reason: "longIdle", + delaySeconds: 1800, + codexActivitySeconds: 0), + .refreshCompleted(timestamp: Self.now.addingTimeInterval(600)), + ] + + let metrics = ReplayEngine.run(trace: trace, policy: ManualPolicy()) + + #expect(metrics.codingActiveDecisionCount == 1) + #expect(metrics.codingActiveDelayViolationCount == 1) + } +} diff --git a/Tests/AdaptiveReplayKitTests/RecordedScheduleAuditTests.swift b/Tests/AdaptiveReplayKitTests/RecordedScheduleAuditTests.swift new file mode 100644 index 0000000000..fd5431a38c --- /dev/null +++ b/Tests/AdaptiveReplayKitTests/RecordedScheduleAuditTests.swift @@ -0,0 +1,246 @@ +import AdaptiveReplayKit +import Foundation +import Testing + +struct RecordedScheduleAuditTests { + private static let epoch = Date(timeIntervalSinceReferenceDate: 0) + + private func at(_ seconds: TimeInterval) -> Date { + Self.epoch.addingTimeInterval(seconds) + } + + @Test + func `legacy recorded advance validates without evaluation records`() { + let menu = self.at(50) + let trace: [AdaptiveRefreshTraceRecord] = [ + .menuOpen(timestamp: menu), + .timerAdvanced( + timestamp: menu, + previousScheduledAt: self.at(1800), + candidateScheduledAt: self.at(170), + reason: "recentInteraction", + delaySeconds: 120), + ] + + let audit = RecordedScheduleAuditor.audit(trace) + + #expect(audit.isValid) + #expect(audit.recordedAdvanceCount == 1) + #expect(audit.evaluatedCount == 0) + } + + @Test + func `accepted and rejected live evaluations audit independently of replay`() { + let accepted = AdaptiveRefreshTraceRecord.timerAdvanceEvaluated( + timestamp: self.at(50), + previousScheduledAt: self.at(1800), + candidateScheduledAt: self.at(170), + reason: "recentInteraction", + delaySeconds: 120, + accepted: true, + refreshInFlight: false) + let rejected = AdaptiveRefreshTraceRecord.timerAdvanceEvaluated( + timestamp: self.at(100), + previousScheduledAt: self.at(170), + candidateScheduledAt: self.at(220), + reason: "recentInteraction", + delaySeconds: 120, + accepted: false, + refreshInFlight: true) + let trace: [AdaptiveRefreshTraceRecord] = [ + .menuOpen(timestamp: self.at(50)), + accepted, + .timerAdvanced( + timestamp: self.at(50), + previousScheduledAt: self.at(1800), + candidateScheduledAt: self.at(170), + reason: "recentInteraction", + delaySeconds: 120), + .menuOpen(timestamp: self.at(100)), + rejected, + ] + + let audit = RecordedScheduleAuditor.audit(trace) + + #expect(audit.isValid) + #expect(audit.evaluatedCount == 2) + #expect(audit.acceptedEvaluationCount == 1) + #expect(audit.rejectedEvaluationCount == 1) + #expect(audit.ambiguousComparisonCount == 0) + } + + @Test + func `evaluation whose accepted flag disagrees with schedule comparison fails`() { + let trace: [AdaptiveRefreshTraceRecord] = [ + .timerAdvanceEvaluated( + timestamp: self.at(100), + previousScheduledAt: self.at(170), + candidateScheduledAt: self.at(220), + reason: "recentInteraction", + delaySeconds: 120, + accepted: true, + refreshInFlight: false), + ] + + let audit = RecordedScheduleAuditor.audit(trace) + + #expect(!audit.isValid) + #expect(audit.decisionMismatchCount == 1) + #expect(audit.payloadMismatchCount == 1) + } + + @Test + func `unequal schedule dates override a contradictory exact lead`() { + let event = AdaptiveRefreshTraceRecord( + kind: .timerAdvanceEvaluated, + timestamp: self.at(50), + reason: "recentInteraction", + delaySeconds: 120, + previousScheduledAt: self.at(180), + candidateScheduledAt: self.at(170), + timerAdvanceAccepted: false, + scheduleLeadSeconds: -10, + refreshInFlight: false) + + let audit = RecordedScheduleAuditor.audit([.menuOpen(timestamp: self.at(50)), event]) + + #expect(audit.decisionMismatchCount == 1) + #expect(!audit.isValid) + } + + @Test + func `accepted evaluation without a previous schedule remains valid`() { + let event = AdaptiveRefreshTraceRecord.timerAdvanceEvaluated( + timestamp: self.at(50), + previousScheduledAt: nil, + candidateScheduledAt: self.at(170), + reason: "recentInteraction", + delaySeconds: 120, + accepted: true, + refreshInFlight: false) + + let advanced = AdaptiveRefreshTraceRecord.timerAdvanced( + timestamp: self.at(50), + previousScheduledAt: nil, + candidateScheduledAt: self.at(170), + reason: "recentInteraction", + delaySeconds: 120) + let audit = RecordedScheduleAuditor.audit([.menuOpen(timestamp: self.at(50)), event, advanced]) + + #expect(audit.isValid) + } + + @Test + func `fractional live lead survives whole-second date serialization`() throws { + let timestamp = self.at(50.2) + let candidate = self.at(170.2) + let previous = self.at(170.8) + let record = AdaptiveRefreshTraceRecord.timerAdvanceEvaluated( + timestamp: timestamp, + previousScheduledAt: previous, + candidateScheduledAt: candidate, + reason: "recentInteraction", + delaySeconds: 120, + accepted: true, + refreshInFlight: false) + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + let line = try #require(String(data: encoder.encode(record), encoding: .utf8)) + let parsed = try #require(AdaptiveRefreshTraceParser.parse(line).first) + + #expect(parsed.previousScheduledAt == parsed.candidateScheduledAt) + #expect(try abs(#require(parsed.scheduleLeadSeconds) - 0.6) < 0.001) + let advanced = try AdaptiveRefreshTraceRecord.timerAdvanced( + timestamp: parsed.timestamp, + previousScheduledAt: parsed.previousScheduledAt, + candidateScheduledAt: #require(parsed.candidateScheduledAt), + reason: #require(parsed.reason), + delaySeconds: #require(parsed.delaySeconds)) + #expect(RecordedScheduleAuditor.audit([.menuOpen(timestamp: parsed.timestamp), parsed, advanced]).isValid) + } + + @Test + func `legacy equal timestamps are reported as ambiguous instead of mismatched`() { + let event = AdaptiveRefreshTraceRecord( + kind: .timerAdvanceEvaluated, + timestamp: self.at(50), + reason: "recentInteraction", + delaySeconds: 120, + previousScheduledAt: self.at(170), + candidateScheduledAt: self.at(170), + timerAdvanceAccepted: true, + refreshInFlight: false) + + let audit = RecordedScheduleAuditor.audit([.menuOpen(timestamp: self.at(50)), event]) + + #expect(audit.decisionMismatchCount == 0) + #expect(audit.ambiguousComparisonCount == 1) + #expect(!audit.isValid) + } + + @Test + func `evaluation without a menu-open source fails linkage audit`() { + let event = AdaptiveRefreshTraceRecord.timerAdvanceEvaluated( + timestamp: self.at(50), + previousScheduledAt: self.at(1800), + candidateScheduledAt: self.at(170), + reason: "recentInteraction", + delaySeconds: 120, + accepted: true, + refreshInFlight: false) + + let audit = RecordedScheduleAuditor.audit([event]) + + #expect(audit.menuLinkMismatchCount == 1) + #expect(!audit.isValid) + } + + @Test + func `duplicate accepted evaluations require matching advance multiplicity`() { + let evaluation = AdaptiveRefreshTraceRecord.timerAdvanceEvaluated( + timestamp: self.at(50), + previousScheduledAt: self.at(1800), + candidateScheduledAt: self.at(170), + reason: "recentInteraction", + delaySeconds: 120, + accepted: true, + refreshInFlight: false) + let advance = AdaptiveRefreshTraceRecord.timerAdvanced( + timestamp: self.at(50), + previousScheduledAt: self.at(1800), + candidateScheduledAt: self.at(170), + reason: "recentInteraction", + delaySeconds: 120) + + let audit = RecordedScheduleAuditor.audit([ + .menuOpen(timestamp: self.at(50)), + evaluation, + evaluation, + advance, + ]) + + #expect(audit.payloadMismatchCount == 1) + #expect(!audit.isValid) + } + + @Test + func `duplicate rejected evaluations require distinct menu opens`() { + let evaluation = AdaptiveRefreshTraceRecord.timerAdvanceEvaluated( + timestamp: self.at(50), + previousScheduledAt: self.at(170), + candidateScheduledAt: self.at(220), + reason: "recentInteraction", + delaySeconds: 170, + accepted: false, + refreshInFlight: true) + + let audit = RecordedScheduleAuditor.audit([ + .menuOpen(timestamp: self.at(50)), + evaluation, + evaluation, + ]) + + #expect(audit.menuLinkMismatchCount == 1) + #expect(!audit.isValid) + } +} diff --git a/Tests/AdaptiveReplayKitTests/ReplayTraceSegmentationTests.swift b/Tests/AdaptiveReplayKitTests/ReplayTraceSegmentationTests.swift new file mode 100644 index 0000000000..a812265c41 --- /dev/null +++ b/Tests/AdaptiveReplayKitTests/ReplayTraceSegmentationTests.swift @@ -0,0 +1,117 @@ +import AdaptiveReplayKit +import Foundation +import Testing + +struct ReplayTraceSegmentationTests { + private static let epoch = Date(timeIntervalSinceReferenceDate: 0) + + private func at(_ seconds: TimeInterval) -> Date { + Self.epoch.addingTimeInterval(seconds) + } + + private func decision(_ seconds: TimeInterval, delay: TimeInterval = 600) -> AdaptiveRefreshTraceRecord { + .decision( + timestamp: self.at(seconds), + menuAgeSeconds: nil, + lowPowerModeEnabled: false, + thermalState: .nominal, + reason: "longIdle", + delaySeconds: delay) + } + + @Test + func `segmentation excludes only time beyond the expected deadline`() { + let firstRun = stride(from: 0.0, through: 3000.0, by: 600.0).map { self.decision($0) } + let secondRun = stride(from: 68400.0, through: 71400.0, by: 600.0).map { self.decision($0) } + let trace = firstRun + secondRun + [.refreshCompleted(timestamp: self.at(72000))] + + let report = ReplayTraceSegmenter.automatic(trace) + + #expect(report.segments.count == 2) + #expect(report.segments[0].start == self.at(0)) + #expect(report.segments[0].end == self.at(3600)) + #expect(report.segments[1].start == self.at(68400)) + #expect(report.segments[1].end == self.at(72000)) + #expect(report.excludedGapSeconds == 18 * 60 * 60) + #expect(report.includedSpanSeconds == 2 * 60 * 60) + } + + @Test + func `segmented rate uses summed span instead of averaging segment rates`() { + let firstRun = stride(from: 0.0, through: 3000.0, by: 600.0).map { self.decision($0) } + let secondRun = stride(from: 68400.0, through: 71400.0, by: 600.0).map { self.decision($0) } + let trace = firstRun + secondRun + [.refreshCompleted(timestamp: self.at(72000))] + + let metrics = ReplayEngine.runSegmented(trace: trace, policy: FixedIntervalPolicy(minutes: 10)) + + #expect(metrics.totalRefreshCount == 12) + #expect(metrics.simulatedSpanSeconds == 7200) + #expect(metrics.refreshCountPer24h == 144) + #expect(metrics.segmentCount == 2) + #expect(metrics.excludedGapSeconds == 18 * 60 * 60) + } + + @Test + func `a normal scheduled wait remains in the preceding segment`() { + let trace = [ + self.decision(0, delay: 1800), + .menuOpen(timestamp: self.at(1700)), + self.decision(4000, delay: 1800), + ] + + let report = ReplayTraceSegmenter.automatic(trace) + + #expect(report.segments.count == 2) + #expect(report.segments[0].end == self.at(1800)) + #expect(report.excludedGapSeconds == 2200) + } + + @Test + func `menu opens before the first recorded refresh are censored equally`() throws { + let trace = [ + self.decision(0, delay: 600), + .menuOpen(timestamp: self.at(100)), + .refreshCompleted(timestamp: self.at(600)), + self.decision(600, delay: 600), + .menuOpen(timestamp: self.at(700)), + .refreshCompleted(timestamp: self.at(1200)), + ] + + let metrics = ReplayEngine.runSegmented(trace: trace, policy: FixedIntervalPolicy(minutes: 10)) + + #expect(metrics.boundaryCensoredMenuOpenCount == 1) + #expect(try #require(metrics.stalenessAtMenuOpen).sampleCount == 1) + } + + @Test + func `recorded refresh anchors staleness before a policy refresh`() throws { + let trace = [ + self.decision(0, delay: 600), + .refreshCompleted(timestamp: self.at(600)), + .menuOpen(timestamp: self.at(700)), + self.decision(1200, delay: 600), + ] + + let metrics = ReplayEngine.runSegmented(trace: trace, policy: ManualPolicy()) + let staleness = try #require(metrics.stalenessAtMenuOpen) + + #expect(staleness.sampleCount == 1) + #expect(staleness.mean == 100) + } + + @Test + func `recorded refresh supersedes an earlier simulated refresh`() throws { + let trace = [ + self.decision(0, delay: 650), + .refreshCompleted(timestamp: self.at(650)), + .menuOpen(timestamp: self.at(700)), + self.decision(1200, delay: 600), + ] + + let metrics = ReplayEngine.runSegmented(trace: trace, policy: FixedIntervalPolicy(minutes: 10)) + let staleness = try #require(metrics.stalenessAtMenuOpen) + + #expect(staleness.sampleCount == 1) + #expect(staleness.mean == 50) + } +} diff --git a/Tests/CodexBarTests/APITokenFetchStrategyTests.swift b/Tests/CodexBarTests/APITokenFetchStrategyTests.swift new file mode 100644 index 0000000000..1d459b4b73 --- /dev/null +++ b/Tests/CodexBarTests/APITokenFetchStrategyTests.swift @@ -0,0 +1,97 @@ +import Foundation +import Testing +@testable import CodexBarCore + +private enum APITokenStrategyTestError: Error { + case missingCredentials +} + +private struct APITokenStrategyStubClaudeFetcher: ClaudeUsageFetching { + func loadLatestUsage(model _: String) async throws -> ClaudeUsageSnapshot { + throw APITokenStrategyTestError.missingCredentials + } + + func debugRawProbe(model _: String) async -> String { + "stub" + } + + func detectVersion() -> String? { + nil + } +} + +struct APITokenFetchStrategyTests { + @Test + func `missing token is unavailable and preserves provider error`() async { + let strategy = Self.makeStrategy() + let context = Self.makeContext(environment: [:]) + + #expect(await strategy.isAvailable(context) == false) + await #expect(throws: APITokenStrategyTestError.missingCredentials) { + try await strategy.fetch(context) + } + } + + @Test + func `resolved token loads usage and stamps result metadata`() async throws { + let strategy = Self.makeStrategy() + let context = Self.makeContext(environment: ["TEST_API_KEY": "test-token"]) + + #expect(await strategy.isAvailable(context)) + let result = try await strategy.fetch(context) + + #expect(result.strategyID == "test.api") + #expect(result.strategyKind == .apiToken) + #expect(result.sourceLabel == "test-source") + #expect(result.usage.updatedAt == Date(timeIntervalSince1970: 42)) + #expect(strategy.shouldFallback(on: APITokenStrategyTestError.missingCredentials, context: context) == false) + } + + @Test + func `required token strategy surfaces its missing credential error`() async { + let strategy = APITokenFetchStrategy( + id: "test.required-api", + reportsMissingCredentials: true, + resolveToken: { $0["TEST_API_KEY"] }, + missingCredentialsError: { APITokenStrategyTestError.missingCredentials }, + loadUsage: { _, _ in UsageSnapshot(primary: nil, secondary: nil, updatedAt: .now) }) + let context = Self.makeContext(environment: [:]) + + #expect(await strategy.isAvailable(context)) + await #expect(throws: APITokenStrategyTestError.missingCredentials) { + try await strategy.fetch(context) + } + } + + private static func makeStrategy() -> APITokenFetchStrategy { + APITokenFetchStrategy( + id: "test.api", + sourceLabel: "test-source", + resolveToken: { $0["TEST_API_KEY"] }, + missingCredentialsError: { APITokenStrategyTestError.missingCredentials }, + loadUsage: { token, context in + UsageSnapshot( + primary: nil, + secondary: nil, + updatedAt: token == context.env["TEST_API_KEY"] + ? Date(timeIntervalSince1970: 42) + : Date.distantFuture) + }) + } + + private static func makeContext(environment: [String: String]) -> ProviderFetchContext { + let browserDetection = BrowserDetection(cacheTTL: 0) + return ProviderFetchContext( + runtime: .app, + sourceMode: .api, + includeCredits: false, + webTimeout: 1, + webDebugDumpHTML: false, + verbose: false, + env: environment, + settings: nil, + fetcher: UsageFetcher(environment: environment), + claudeFetcher: APITokenStrategyStubClaudeFetcher(), + browserDetection: browserDetection) + } +} diff --git a/Tests/CodexBarTests/AdaptiveRefreshHeuristicsTests.swift b/Tests/CodexBarTests/AdaptiveRefreshHeuristicsTests.swift new file mode 100644 index 0000000000..b60601963c --- /dev/null +++ b/Tests/CodexBarTests/AdaptiveRefreshHeuristicsTests.swift @@ -0,0 +1,201 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +/// Covers `normalRefreshIntervalForHeuristics()` and every consumer that previously read +/// `RefreshFrequency.seconds` directly. That property is nil for both `.manual` and `.adaptive`, +/// so without the helper the interval-derived heuristics (reset-boundary refresh, OpenAI web +/// staleness, persistent-CLI-session idle windows) silently degrade to manual behavior the +/// moment a user picks adaptive. Each consumer has a test here that goes red if its call site +/// is reverted to `.seconds`. +@MainActor +struct AdaptiveRefreshHeuristicsTests { + @Test + func `manual keeps the heuristics interval nil`() { + let store = Self.makeStore(suite: "heuristics-manual-nil", frequency: .manual) + #expect(store.normalRefreshIntervalForHeuristics() == nil) + } + + @Test(arguments: [ + (RefreshFrequency.oneMinute, 60.0), + (.twoMinutes, 120.0), + (.fiveMinutes, 300.0), + (.fifteenMinutes, 900.0), + (.thirtyMinutes, 1800.0) + ]) + func `fixed frequencies pass their configured seconds through`( + frequency: RefreshFrequency, + expectedSeconds: TimeInterval) + { + let store = Self.makeStore(suite: "heuristics-fixed-\(frequency.rawValue)", frequency: frequency) + #expect(store.normalRefreshIntervalForHeuristics() == expectedSeconds) + } + + @Test + func `adaptive resolves to the live adaptive decision delay`() { + let store = Self.makeStore(suite: "heuristics-adaptive-live", frequency: .adaptive) + + // No recorded menu open: the decision is longIdle, or constrained on a low-power/hot + // machine — both are 30 minutes, so this assertion is environment-independent. + #expect(store.normalRefreshIntervalForHeuristics() == 1800.0) + + store.noteMenuOpened() + let expected = TimeInterval(UsageStore.adaptiveRefreshDecision( + now: Date(), + lastMenuOpenAt: store.lastMenuOpenAt, + lowPowerModeEnabled: ProcessInfo.processInfo.isLowPowerModeEnabled, + thermalState: ProcessInfo.processInfo.thermalState).delay.components.seconds) + #expect(store.normalRefreshIntervalForHeuristics() == expected) + if Self.machineIsUnconstrained { + #expect(store.normalRefreshIntervalForHeuristics() == 120.0) + } + } + + @Test + func `adaptive cadence schedules a reset-boundary refresh through the refresh pipeline`() async { + let store = Self.makeStoreWithStubbedCodex(suite: "heuristics-boundary-adaptive", frequency: .adaptive) + + // Goes through the real end-of-refresh scheduling call, which must feed the adaptive + // interval (30 min here — no menu open) rather than the nil `RefreshFrequency.seconds`. + await store.refresh() + defer { store.cancelResetBoundaryRefresh() } + + #expect(store.scheduledResetBoundaryRefreshAt != nil) + } + + @Test + func `manual cadence still never schedules a reset-boundary refresh through the refresh pipeline`() async { + let store = Self.makeStoreWithStubbedCodex(suite: "heuristics-boundary-manual", frequency: .manual) + + await store.refresh() + defer { store.cancelResetBoundaryRefresh() } + + #expect(store.scheduledResetBoundaryRefreshAt == nil) + } + + @Test + func `adaptive mode lifts the openai web refresh interval off the manual floor`() { + let adaptiveStore = Self.makeStore(suite: "heuristics-web-adaptive", frequency: .adaptive) + let manualStore = Self.makeStore(suite: "heuristics-web-manual", frequency: .manual) + + let adaptiveInterval = adaptiveStore.openAIWebRefreshIntervalSeconds() + let manualInterval = manualStore.openAIWebRefreshIntervalSeconds() + + // Manual hits the 120s fallback floor; adaptive with no menu open resolves to 1800s. + // Comparing as a ratio keeps this independent of the web-refresh multiplier. + #expect(manualInterval > 0) + #expect(adaptiveInterval == manualInterval * 15) + } + + @Test + func `registry nominal interval maps adaptive to the policy nominal and keeps manual nil`() { + #expect(ProviderRegistry.nominalRefreshInterval(for: .adaptive) + == AdaptiveRefreshPolicy.nominalIntervalForHeuristics) + #expect(ProviderRegistry.nominalRefreshInterval(for: .manual) == nil) + #expect(ProviderRegistry.nominalRefreshInterval(for: .thirtyMinutes) == 1800.0) + } + + @Test + func `provider specs give adaptive a nominal cli session idle window instead of the floor`() { + let adaptiveStore = Self.makeStore(suite: "heuristics-spec-adaptive", frequency: .adaptive) + let manualStore = Self.makeStore(suite: "heuristics-spec-manual", frequency: .manual) + + // Registry specs have no UsageStore to ask, so adaptive maps to the policy's nominal + // 300s steady-state interval: max(180, 300 + 60) = 360. + let adaptiveWindow = adaptiveStore.providerSpecs[.codex]? + .makeFetchContext().persistentCLISessionIdleWindow + let manualWindow = manualStore.providerSpecs[.codex]? + .makeFetchContext().persistentCLISessionIdleWindow + #expect(adaptiveWindow == 360) + #expect(manualWindow == 180) + } + + @Test + func `account-scoped fetch contexts derive the idle window from the live adaptive interval`() { + let adaptiveStore = Self.makeStore(suite: "heuristics-account-adaptive", frequency: .adaptive) + let manualStore = Self.makeStore(suite: "heuristics-account-manual", frequency: .manual) + + // Unlike registry specs, this path runs inside UsageStore, so adaptive uses the live + // decision: 1800s with no menu open, giving max(180, 1800 + 60) = 1860. + let adaptiveWindow = adaptiveStore + .makeFetchContext(provider: .codex, override: nil).persistentCLISessionIdleWindow + let manualWindow = manualStore + .makeFetchContext(provider: .codex, override: nil).persistentCLISessionIdleWindow + #expect(adaptiveWindow == 1860) + #expect(manualWindow == 180) + } + + private static var machineIsUnconstrained: Bool { + let thermalState = ProcessInfo.processInfo.thermalState + return !ProcessInfo.processInfo.isLowPowerModeEnabled + && (thermalState == .nominal || thermalState == .fair) + } + + private static func makeStore(suite: String, frequency: RefreshFrequency) -> UsageStore { + let settings = testSettingsStore(suiteName: "AdaptiveRefreshHeuristicsTests-\(suite)") + settings.providerDetectionCompleted = true + settings.refreshFrequency = frequency + Self.disableAllProviders(settings: settings) + return UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + startupBehavior: .testing, + environmentBase: [:]) + } + + /// The reset-boundary pipeline tests need `refresh()` to complete with a snapshot still in + /// place, and `clearDisabledProviderRefreshState` wipes snapshots of disabled providers. So + /// codex stays enabled but its fetch is stubbed to return a canned snapshot whose primary + /// window resets 10 minutes out — inside a 30-minute normal-refresh window, outside nothing. + /// The live-system account is pinned and the snapshot carries the same email, so the + /// account-scoped apply guard resolves identically whether or not the machine running the + /// tests has a real `~/.codex` login (CI runners do not). + private static func makeStoreWithStubbedCodex(suite: String, frequency: RefreshFrequency) -> UsageStore { + let store = Self.makeStore(suite: suite, frequency: frequency) + let metadata = ProviderRegistry.shared.metadata[.codex]! + store.settings._test_liveSystemCodexAccount = ObservedSystemCodexAccount( + email: Self.stubbedCodexEmail, + codexHomePath: "/Users/test/.codex", + observedAt: Date(), + identity: .unresolved) + store.settings.codexActiveSource = .liveSystem + store.settings.setProviderEnabled(provider: .codex, metadata: metadata, enabled: true) + store.providerSpecs[.codex] = CodexAccountScopedRefreshTests.makeCodexProviderSpec( + baseSpec: store.providerSpecs[.codex]!) + { + Self.snapshot(updatedAt: Date(), primaryResetsAt: Date().addingTimeInterval(10 * 60)) + } + return store + } + + private nonisolated static let stubbedCodexEmail = "adaptive-heuristics@example.com" + + /// Keeps `refresh()` cheap and deterministic: no provider fetch can replace the snapshot + /// injected by the reset-boundary tests or slow the pipeline tests down. + private static func disableAllProviders(settings: SettingsStore) { + let metadata = ProviderRegistry.shared.metadata + for provider in UsageProvider.allCases { + guard let providerMetadata = metadata[provider] else { continue } + settings.setProviderEnabled(provider: provider, metadata: providerMetadata, enabled: false) + } + } + + private nonisolated static func snapshot(updatedAt: Date, primaryResetsAt: Date) -> UsageSnapshot { + UsageSnapshot( + primary: RateWindow( + usedPercent: 100, + windowMinutes: 300, + resetsAt: primaryResetsAt, + resetDescription: nil), + secondary: nil, + tertiary: nil, + updatedAt: updatedAt, + identity: ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: self.stubbedCodexEmail, + accountOrganization: nil, + loginMethod: "Pro")) + } +} diff --git a/Tests/CodexBarTests/AdaptiveRefreshPerformanceTests.swift b/Tests/CodexBarTests/AdaptiveRefreshPerformanceTests.swift new file mode 100644 index 0000000000..75c9adb26e --- /dev/null +++ b/Tests/CodexBarTests/AdaptiveRefreshPerformanceTests.swift @@ -0,0 +1,113 @@ +import Foundation +import Testing +@testable import CodexBar +@testable import CodexBarCore + +private final class DirectoryEntryVisitCounter: @unchecked Sendable { + private let lock = NSLock() + private var value = 0 + + func increment() { + self.lock.withLock { self.value += 1 } + } + + var count: Int { + self.lock.withLock { self.value } + } +} + +private actor AdaptiveLocalScanSpy { + private(set) var callCount = 0 + + func scan(includeFileOnlySessions _: Bool) -> [AgentSession] { + self.callCount += 1 + return [] + } +} + +@MainActor +struct AdaptiveRefreshPerformanceTests { + @Test + func `agent aware detection stays within the bounded scan budget`() async throws { + let fileManager = FileManager.default + let root = fileManager.temporaryDirectory + .appendingPathComponent("AdaptiveRefreshPerformanceTests-\(UUID().uuidString)", isDirectory: true) + defer { try? fileManager.removeItem(at: root) } + + let config = SessionScanConfig() + #expect(config.maxDirectoryEntryCount == 512) + #expect(config.maxDirectoryDepth == 1) + #expect(config.adaptiveDirectoryScanBudget == 0.15) + + let now = Date() + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.dateFormat = "yyyy/MM/dd" + let codexHome = root.appendingPathComponent("codex-home", isDirectory: true) + let sessionDirectory = codexHome + .appendingPathComponent("sessions", isDirectory: true) + .appendingPathComponent(formatter.string(from: now), isDirectory: true) + try fileManager.createDirectory(at: sessionDirectory, withIntermediateDirectories: true) + + let fixtureURL = try AgentSessionParserTests.fixtureURL("agent-session-rollout", extension: "jsonl") + let fixture = try Data(contentsOf: fixtureURL) + for index in 0.. AdaptiveRefreshPolicy.Decision + { + UsageStore.adaptiveRefreshDecision( + now: Self.referenceNow, + lastMenuOpenAt: ageSeconds.map { Self.referenceNow.addingTimeInterval(-$0) }, + lastCodingActivityAt: codingActivityAgeSeconds.map { Self.referenceNow.addingTimeInterval(-$0) }, + lowPowerModeEnabled: lowPowerModeEnabled, + thermalState: thermalState) + } + + @Test(arguments: [ProcessInfo.ThermalState.nominal, .fair]) + func `app adapter maps nominal and fair thermal states to unconstrained`( + thermalState: ProcessInfo.ThermalState) + { + let decision = self.decision(lowPowerModeEnabled: false, thermalState: thermalState) + #expect(decision.reason == .recentInteraction) + #expect(decision.delay == .seconds(2 * 60)) + } + + @Test(arguments: [ProcessInfo.ThermalState.serious, .critical]) + func `app adapter maps serious and critical thermal states to constrained`( + thermalState: ProcessInfo.ThermalState) + { + let decision = self.decision(lowPowerModeEnabled: false, thermalState: thermalState) + #expect(decision.reason == .constrained) + #expect(decision.delay == .seconds(30 * 60)) + } + + @Test + func `app adapter preserves low power precedence`() { + let decision = self.decision(lowPowerModeEnabled: true, thermalState: .nominal) + #expect(decision.reason == .constrained) + #expect(decision.delay == .seconds(30 * 60)) + } + + @Test + func `app adapter forwards timestamps and nil history`() { + let warm = self.decision( + ageSeconds: 301, + lowPowerModeEnabled: false, + thermalState: .nominal) + #expect(warm.reason == .warm) + #expect(warm.delay == .seconds(5 * 60)) + + let noHistory = self.decision( + ageSeconds: nil, + lowPowerModeEnabled: false, + thermalState: .nominal) + #expect(noHistory.reason == .longIdle) + #expect(noHistory.delay == .seconds(30 * 60)) + } + + @Test + func `app adapter forwards coding activity into the shared core`() { + let decision = self.decision( + ageSeconds: nil, + codingActivityAgeSeconds: 0, + lowPowerModeEnabled: false, + thermalState: .nominal) + #expect(decision.reason == .codingActivity) + #expect(decision.delay == .seconds(5 * 60)) + } +} diff --git a/Tests/CodexBarTests/AdaptiveRefreshTimerTests.swift b/Tests/CodexBarTests/AdaptiveRefreshTimerTests.swift new file mode 100644 index 0000000000..6bb5828436 --- /dev/null +++ b/Tests/CodexBarTests/AdaptiveRefreshTimerTests.swift @@ -0,0 +1,482 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +/// Covers the timer plumbing added on top of the pure `AdaptiveRefreshPolicy` (see +/// `AdaptiveRefreshPolicyTests`): how `UsageStore.startTimer()` wires live signals into the +/// policy, and how manual/fixed/adaptive modes drive (or don't drive) `refresh()` over time. +@MainActor +struct AdaptiveRefreshTimerTests { + @Test + func `launch with no menu history begins at thirty minutes`() { + let settings = Self.makeSettingsStore(suite: "AdaptiveRefreshTimerTests-launch", frequency: .adaptive) + let store = Self.makeUsageStore(settings: settings, startupBehavior: .testing) + + #expect(store.lastMenuOpenAt == nil) + let decision = UsageStore.adaptiveRefreshDecision( + now: Date(), + lastMenuOpenAt: store.lastMenuOpenAt, + lowPowerModeEnabled: false, + thermalState: .nominal) + #expect(decision.reason == .longIdle) + #expect(decision.delay == .seconds(30 * 60)) + } + + @Test + func `menu-open signal changes the next adaptive decision`() { + let now = Date(timeIntervalSinceReferenceDate: 900_000_000) + + let beforeOpen = UsageStore.adaptiveRefreshDecision( + now: now, lastMenuOpenAt: nil, lowPowerModeEnabled: false, thermalState: .nominal) + #expect(beforeOpen.reason == .longIdle) + + let afterOpen = UsageStore.adaptiveRefreshDecision( + now: now, lastMenuOpenAt: now, lowPowerModeEnabled: false, thermalState: .nominal) + #expect(afterOpen.reason == .recentInteraction) + } + + @Test + func `menu open advances a long idle timer during refresh without postponing an earlier tick`() async throws { + let settings = Self.makeSettingsStore(suite: "AdaptiveRefreshTimerTests-advance", frequency: .adaptive) + let store = Self.makeUsageStore(settings: settings, startupBehavior: .testing) + store.restartTimerWithSleepOverrideForTesting(.seconds(10)) + try await Self.waitUntil { store.adaptiveRefreshScheduledAt != nil } + + let longIdleSchedule = try #require(store.adaptiveRefreshScheduledAt) + let now = Date() + store._setSnapshotForTesting( + UsageSnapshot( + primary: RateWindow( + usedPercent: 50, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(30), + resetDescription: nil), + secondary: nil, + updatedAt: now, + identity: nil), + provider: .codex) + store.scheduleResetBoundaryRefreshIfNeeded(normalRefreshInterval: 30 * 60, now: now) + defer { store.cancelResetBoundaryRefresh() } + let resetBoundarySchedule = try #require(store.scheduledResetBoundaryRefreshAt) + + store.isRefreshing = true + defer { store.isRefreshing = false } + store.noteMenuOpened() + try await Self.waitUntil { + guard let scheduledAt = store.adaptiveRefreshScheduledAt else { return false } + return scheduledAt < longIdleSchedule + } + let interactionSchedule = try #require(store.adaptiveRefreshScheduledAt) + #expect(store.isRefreshing) + #expect(store.scheduledResetBoundaryRefreshAt == resetBoundarySchedule) + + store.noteMenuOpened(at: Date().addingTimeInterval(30)) + #expect(store.adaptiveRefreshScheduledAt == interactionSchedule) + } + + @Test + func `coding activity advances a long idle timer without postponing an earlier tick`() async throws { + let settings = Self.makeSettingsStore( + suite: "AdaptiveRefreshTimerTests-activity-advance", + frequency: .adaptiveAgentAware) + settings.adaptiveActivityScanConsent = .allowed + let store = Self.makeUsageStore(settings: settings, startupBehavior: .testing) + store.restartTimerWithSleepOverrideForTesting(.seconds(10)) + try await Self.waitUntil { store.adaptiveRefreshScheduledAt != nil } + + let longIdleSchedule = try #require(store.adaptiveRefreshScheduledAt) + let observedAt = Date() + store.noteCodingActivityObserved(at: observedAt, now: observedAt) + try await Self.waitUntil { + guard let scheduledAt = store.adaptiveRefreshScheduledAt else { return false } + return scheduledAt < longIdleSchedule + } + let activitySchedule = try #require(store.adaptiveRefreshScheduledAt) + #expect(store.lastCodingActivityAt == observedAt) + + // An older observation is ignored. A newer observation is retained, but cannot push an + // already earlier provider refresh later. + store.noteCodingActivityObserved( + at: observedAt.addingTimeInterval(-1), + now: observedAt.addingTimeInterval(30)) + #expect(store.lastCodingActivityAt == observedAt) + store.noteCodingActivityObserved( + at: observedAt.addingTimeInterval(1), + now: observedAt.addingTimeInterval(30)) + #expect(store.lastCodingActivityAt == observedAt.addingTimeInterval(1)) + #expect(store.adaptiveRefreshScheduledAt == activitySchedule) + } + + @Test + func `plain adaptive ignores coding activity`() async throws { + let settings = Self.makeSettingsStore( + suite: "AdaptiveRefreshTimerTests-plain-adaptive-activity", + frequency: .adaptive) + settings.adaptiveActivityScanConsent = .allowed + let store = Self.makeUsageStore(settings: settings, startupBehavior: .testing) + store.restartTimerWithSleepOverrideForTesting(.seconds(10)) + try await Self.waitUntil { store.adaptiveRefreshScheduledAt != nil } + let scheduledAt = try #require(store.adaptiveRefreshScheduledAt) + + store.noteCodingActivityObserved(at: Date()) + + #expect(store.adaptiveRefreshScheduledAt == scheduledAt) + #expect(store.lastCodingActivityAt == nil) + } + + @Test + func `noting a menu open records the signal without starting a refresh`() { + let settings = Self.makeSettingsStore(suite: "AdaptiveRefreshTimerTests-noteMenuOpened", frequency: .manual) + let store = Self.makeUsageStore(settings: settings, startupBehavior: .testing) + + #expect(store.completedRefreshCountForTesting == 0) + #expect(store.isRefreshing == false) + + store.noteMenuOpened() + + #expect(store.lastMenuOpenAt != nil) + #expect(store.completedRefreshCountForTesting == 0) + #expect(store.isRefreshing == false) + } + + @Test + func `noting coding activity outside agent aware mode is a no-op`() { + let settings = Self.makeSettingsStore( + suite: "AdaptiveRefreshTimerTests-noteCodingActivity", + frequency: .fiveMinutes) + let store = Self.makeUsageStore(settings: settings, startupBehavior: .testing) + let observedAt = Date() + + store.noteCodingActivityObserved(at: observedAt) + + #expect(store.lastCodingActivityAt == nil) + #expect(store.adaptiveRefreshScheduledAt == nil) + #expect(store.completedRefreshCountForTesting == 0) + } + + @Test + func `clearing coding activity removes the adaptive input`() { + let settings = Self.makeSettingsStore( + suite: "AdaptiveRefreshTimerTests-clearCodingActivity", + frequency: .adaptiveAgentAware) + settings.adaptiveActivityScanConsent = .allowed + let store = Self.makeUsageStore(settings: settings, startupBehavior: .testing) + store.noteCodingActivityObserved(at: Date(timeIntervalSinceReferenceDate: 100)) + #expect(store.lastCodingActivityAt != nil) + + store.clearCodingActivityObservation() + + #expect(store.lastCodingActivityAt == nil) + } + + @Test + func `opportunistic timer refresh is a no-op while another refresh is already in flight`() async { + let settings = Self.makeSettingsStore(suite: "AdaptiveRefreshTimerTests-coalesce", frequency: .manual) + let store = Self.makeUsageStore(settings: settings, startupBehavior: .testing) + + store.isRefreshing = true + await store.refresh(enrichmentMode: .automatic) + + // The guard at the top of runRefresh() returned immediately: no completion was recorded and the + // flag was left untouched by this call. This is the invariant every timer tick (fixed or + // adaptive) relies on to avoid overlapping with a refresh already in flight. + #expect(store.completedRefreshCountForTesting == 0) + #expect(store.isRefreshing == true) + } + + @Test + func `manual mode performs the initial refresh but no recurring ticks`() async throws { + let settings = Self.makeSettingsStore(suite: "AdaptiveRefreshTimerTests-manual", frequency: .manual) + let store = Self.makeUsageStore(settings: settings, startupBehavior: .full) + try await Self.waitUntil { store.completedRefreshCountForTesting >= 1 } + + // Manual mode never starts a timer, so nothing can push the count past the one launch refresh + // no matter how long we wait; a short settle window is enough to catch a regression. + try await Task.sleep(for: .milliseconds(300)) + #expect(store.completedRefreshCountForTesting == 1) + } + + @Test + func `fixed mode ticks recur at the overridden cadence`() async throws { + let settings = Self.makeSettingsStore(suite: "AdaptiveRefreshTimerTests-fixed", frequency: .oneMinute) + let store = Self.makeUsageStore(settings: settings, startupBehavior: .full) + store.restartTimerWithSleepOverrideForTesting(.milliseconds(20)) + + // 1 initial launch refresh plus at least one 20ms-cadence tick; proves the loop recurs + // rather than sleeping once and stopping. Each refresh cycle here costs low single-digit + // seconds of wall time even with every provider disabled, so the timeout is generous. + try await Self.waitUntil(timeout: .seconds(45)) { store.completedRefreshCountForTesting >= 2 } + #expect(store.completedRefreshCountForTesting >= 2) + } + + @Test + func `fixed cadence advances from scheduled tick instead of refresh completion`() { + let interval = Duration.milliseconds(100) + let start = ContinuousClock.now + let firstScheduledAt = start + interval + + let nextAfterExactTick = UsageStore.nextFixedTimerScheduledAt( + previousScheduledAt: firstScheduledAt, + completedAt: firstScheduledAt, + interval: interval) + #expect(nextAfterExactTick == start + .milliseconds(200)) + + let nextJustBeforeFollowingTick = UsageStore.nextFixedTimerScheduledAt( + previousScheduledAt: firstScheduledAt, + completedAt: firstScheduledAt + .milliseconds(100) - .nanoseconds(1), + interval: interval) + #expect(nextJustBeforeFollowingTick == start + .milliseconds(200)) + + let nextAtFollowingTick = UsageStore.nextFixedTimerScheduledAt( + previousScheduledAt: firstScheduledAt, + completedAt: firstScheduledAt + .milliseconds(100), + interval: interval) + #expect(nextAtFollowingTick == start + .milliseconds(300)) + + let nextAfterSlowRefresh = UsageStore.nextFixedTimerScheduledAt( + previousScheduledAt: firstScheduledAt, + completedAt: firstScheduledAt + .milliseconds(60), + interval: interval) + #expect(nextAfterSlowRefresh == start + .milliseconds(200)) + + let nextAfterMissedTicks = UsageStore.nextFixedTimerScheduledAt( + previousScheduledAt: firstScheduledAt, + completedAt: firstScheduledAt + .milliseconds(260), + interval: interval) + #expect(nextAfterMissedTicks == start + .milliseconds(400)) + } + + @Test + func `fixed timer loop stays interval aligned after a slow refresh`() async { + let harness = FixedTimerLoopHarness() + + await UsageStore.runFixedRefreshTimer( + interval: .milliseconds(100), + now: { await harness.now() }, + sleep: { duration in await harness.sleep(for: duration) }, + refresh: { await harness.refresh() }) + + #expect(await harness.recordedStarts() == [.milliseconds(100), .milliseconds(300)]) + #expect(await harness.maximumConcurrentRefreshes() == 1) + } + + @Test + func `adaptive mode keeps recomputing and refreshing across menu-open changes`() async throws { + let settings = Self.makeSettingsStore(suite: "AdaptiveRefreshTimerTests-adaptive", frequency: .adaptive) + let store = Self.makeUsageStore(settings: settings, startupBehavior: .full) + store.restartTimerWithSleepOverrideForTesting(.milliseconds(20)) + try await Self.waitUntil(timeout: .seconds(45)) { store.completedRefreshCountForTesting >= 1 } + let countBeforeMenuOpen = store.completedRefreshCountForTesting + + store.noteMenuOpened() + + // The loop kept looping (recomputing the decision from a fresh Input) after lastMenuOpenAt + // changed, rather than sleeping once on a captured delay and stopping. + try await Self.waitUntil(timeout: .seconds(45)) { store.completedRefreshCountForTesting > countBeforeMenuOpen } + #expect(store.completedRefreshCountForTesting > countBeforeMenuOpen) + } + + @Test + func `changing frequency away from fixed cancels the pending tick without an extra refresh`() async throws { + let settings = Self.makeSettingsStore(suite: "AdaptiveRefreshTimerTests-cancel", frequency: .oneMinute) + let store = Self.makeUsageStore(settings: settings, startupBehavior: .full) + // Deliberately much longer than anything else in this test: the assertion only needs this + // sleep to still be pending (uncompleted) when we switch away, not to time anything precisely. + store.restartTimerWithSleepOverrideForTesting(.seconds(5)) + // Only the initial launch refresh can land this quickly; the fixed-mode timer's first tick + // needs the full 5s override to elapse, so it cannot have fired yet. + try await Self.waitUntil { store.completedRefreshCountForTesting >= 1 } + let countBeforeSwitch = store.completedRefreshCountForTesting + + settings.refreshFrequency = .manual + + // The settings-change path (outside adaptive-refresh scope) may fire its own refresh(es) for + // reasons unrelated to the timer under test; wait for the count to stop moving rather than + // assuming it fires exactly once. Windows are doubled from an earlier version that flaked once + // under full parallel `make test` load. + let countAfterSettling = try await Self.waitForStableCount(store: store, settleWindow: .milliseconds(800)) + #expect(countAfterSettling > countBeforeSwitch) + + // Settle comfortably within the 5s override window. If the old fixed-mode timer had not been + // canceled, its pending tick would eventually land and push the count past the settled value — + // but not within this window, so any further increase here indicates a real cancellation bug, + // not settings-change noise. + try await Task.sleep(for: .milliseconds(1600)) + #expect(store.completedRefreshCountForTesting == countAfterSettling) + } + + // The test above goes through `settings.refreshFrequency = .manual`, which also triggers the + // settings-observer's own `refreshForSettingsChange()` — a legitimate refresh unrelated to the + // timer. That confound means it cannot, by itself, prove the `guard !Task.isCancelled else { return }` + // after each branch's sleep is load-bearing (deleting either guard still leaves this test green, + // since the settings-observer refresh already accounts for the "count increased" expectation). + // The two tests below isolate `startTimer()`'s cancel-and-replace path directly, by calling + // `restartTimerWithSleepOverrideForTesting` a second time at the *same* frequency — which goes + // straight through `startTimer()` with no settings observation involved — so no refresh is + // legitimately expected at all, and any extra one proves a canceled sleep still ran its body. + + @Test + func `restarting the timer cancels a pending fixed tick without an extra refresh`() async throws { + let settings = Self.makeSettingsStore(suite: "AdaptiveRefreshTimerTests-cancel-fixed", frequency: .oneMinute) + let store = Self.makeUsageStore(settings: settings, startupBehavior: .full) + store.restartTimerWithSleepOverrideForTesting(.seconds(5)) + try await Self.waitUntil { store.completedRefreshCountForTesting >= 1 } + let countBeforeRestart = store.completedRefreshCountForTesting + + // Cancels the pending 5s sleep above and starts a fresh one, still at .oneMinute. No settings + // mutation, so no settings-observer refresh is expected here at all. + store.restartTimerWithSleepOverrideForTesting(.seconds(5)) + + // Neither the old (canceled) timer's tick nor the new timer's first tick can land within this + // window — both need the full 5s override. Any refresh here can only be the canceled sleep's + // body running anyway. + try await Task.sleep(for: .milliseconds(800)) + #expect(store.completedRefreshCountForTesting == countBeforeRestart) + } + + @Test + func `restarting the timer cancels a pending adaptive tick without an extra refresh`() async throws { + let settings = Self.makeSettingsStore(suite: "AdaptiveRefreshTimerTests-cancel-adaptive", frequency: .adaptive) + let store = Self.makeUsageStore(settings: settings, startupBehavior: .full) + store.restartTimerWithSleepOverrideForTesting(.seconds(5)) + try await Self.waitUntil { store.completedRefreshCountForTesting >= 1 } + let countBeforeRestart = store.completedRefreshCountForTesting + + store.restartTimerWithSleepOverrideForTesting(.seconds(5)) + + try await Task.sleep(for: .milliseconds(800)) + #expect(store.completedRefreshCountForTesting == countBeforeRestart) + } + + /// Polls `condition` until it's true or `timeout` elapses, without assuming how long setup or + /// scheduling takes. Throws `CancellationError` (surfaced as a test failure) on timeout. + private static func waitUntil( + timeout: Duration = .seconds(30), + pollInterval: Duration = .milliseconds(20), + _ condition: () -> Bool) async throws + { + let deadline = ContinuousClock.now + timeout + while !condition() { + if ContinuousClock.now >= deadline { + throw CancellationError() + } + try await Task.sleep(for: pollInterval) + } + } + + /// Polls `store.completedRefreshCountForTesting` until it stops changing for `settleWindow`, + /// tolerating an unknown number of in-flight refreshes (e.g. settings-change side effects + /// unrelated to the timer under test) before returning the final, stable count. + private static func waitForStableCount( + store: UsageStore, + settleWindow: Duration, + timeout: Duration = .seconds(30), + pollInterval: Duration = .milliseconds(20)) async throws -> Int + { + let deadline = ContinuousClock.now + timeout + var lastCount = store.completedRefreshCountForTesting + var lastChangedAt = ContinuousClock.now + while true { + try await Task.sleep(for: pollInterval) + let current = store.completedRefreshCountForTesting + let now = ContinuousClock.now + if current != lastCount { + lastCount = current + lastChangedAt = now + } else if now - lastChangedAt >= settleWindow { + return lastCount + } + if now >= deadline { + throw CancellationError() + } + } + } + + private static func makeSettingsStore(suite: String, frequency: RefreshFrequency) -> SettingsStore { + let defaults = UserDefaults(suiteName: suite)! + defaults.removePersistentDomain(forName: suite) + let configStore = testConfigStore(suiteName: suite) + + let settings = SettingsStore( + userDefaults: defaults, + configStore: configStore, + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore(), + codexCookieStore: InMemoryCookieHeaderStore(), + claudeCookieStore: InMemoryCookieHeaderStore(), + cursorCookieStore: InMemoryCookieHeaderStore(), + opencodeCookieStore: InMemoryCookieHeaderStore(), + factoryCookieStore: InMemoryCookieHeaderStore(), + minimaxCookieStore: InMemoryMiniMaxCookieStore(), + minimaxAPITokenStore: InMemoryMiniMaxAPITokenStore(), + kimiTokenStore: InMemoryKimiTokenStore(), + augmentCookieStore: InMemoryCookieHeaderStore(), + ampCookieStore: InMemoryCookieHeaderStore(), + copilotTokenStore: InMemoryCopilotTokenStore(), + tokenAccountStore: InMemoryTokenAccountStore()) + settings.providerDetectionCompleted = true + settings.refreshFrequency = frequency + Self.disableAllProviders(settings: settings) + return settings + } + + /// Codex is enabled by default; disabling every provider (including it) keeps `refresh()` cheap + /// and deterministic in these tests, which care about tick cadence, not provider fetch results. + private static func disableAllProviders(settings: SettingsStore) { + let metadata = ProviderRegistry.shared.metadata + for provider in UsageProvider.allCases { + guard let providerMetadata = metadata[provider] else { continue } + settings.setProviderEnabled(provider: provider, metadata: providerMetadata, enabled: false) + } + } + + private static func makeUsageStore( + settings: SettingsStore, + startupBehavior: UsageStore.StartupBehavior) -> UsageStore + { + UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + startupBehavior: startupBehavior, + environmentBase: [:]) + } +} + +private actor FixedTimerLoopHarness { + private let origin = ContinuousClock.now + private var elapsed = Duration.zero + private var starts: [Duration] = [] + private var activeRefreshes = 0 + private var maximumActiveRefreshes = 0 + + func now() -> ContinuousClock.Instant { + self.origin + self.elapsed + } + + func sleep(for duration: Duration) { + self.elapsed += duration + } + + func refresh() { + self.activeRefreshes += 1 + self.maximumActiveRefreshes = max(self.maximumActiveRefreshes, self.activeRefreshes) + self.starts.append(self.elapsed) + if self.starts.count == 1 { + self.elapsed += .milliseconds(160) + } + self.activeRefreshes -= 1 + if self.starts.count == 2 { + withUnsafeCurrentTask { $0?.cancel() } + } + } + + func recordedStarts() -> [Duration] { + self.starts + } + + func maximumConcurrentRefreshes() -> Int { + self.maximumActiveRefreshes + } +} diff --git a/Tests/CodexBarTests/AdminAPIUsageLocalDaySelectionTests.swift b/Tests/CodexBarTests/AdminAPIUsageLocalDaySelectionTests.swift new file mode 100644 index 0000000000..fb4a530a23 --- /dev/null +++ b/Tests/CodexBarTests/AdminAPIUsageLocalDaySelectionTests.swift @@ -0,0 +1,196 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct AdminAPIUsageLocalDaySelectionTests { + @Test + func `OpenAI current day includes UTC bucket containing positive timezone morning`() throws { + let calendar = try Self.calendar(timeZoneIdentifier: "Australia/Sydney") + let now = try Self.date(year: 2026, month: 5, day: 18, hour: 8, timeZoneIdentifier: "Australia/Sydney") + let staleUTCStart = try Self.date(year: 2026, month: 5, day: 16, hour: 0, timeZoneIdentifier: "UTC") + let overlappingUTCStart = try Self.date(year: 2026, month: 5, day: 17, hour: 0, timeZoneIdentifier: "UTC") + let usage = OpenAIAPIUsageSnapshot( + daily: [ + OpenAIAPIUsageSnapshot.DailyBucket( + day: "2026-05-16", + startTime: staleUTCStart, + endTime: staleUTCStart.addingTimeInterval(86400), + costUSD: 9, + requests: 9, + inputTokens: 900, + cachedInputTokens: 90, + outputTokens: 90, + totalTokens: 990, + lineItems: [], + models: []), + OpenAIAPIUsageSnapshot.DailyBucket( + day: "2026-05-17", + startTime: overlappingUTCStart, + endTime: overlappingUTCStart.addingTimeInterval(86400), + costUSD: 2.5, + requests: 3, + inputTokens: 200, + cachedInputTokens: 20, + outputTokens: 30, + totalTokens: 250, + lineItems: [], + models: []), + ], + updatedAt: now) + + let today = usage.summary(forLocalDayContaining: now, calendar: calendar) + + #expect(today.costUSD == 2.5) + #expect(today.requests == 3) + #expect(today.totalTokens == 250) + } + + @Test + func `OpenAI current day does not sum adjacent UTC buckets after positive timezone UTC rollover`() throws { + let calendar = try Self.calendar(timeZoneIdentifier: "Australia/Sydney") + let now = try Self.date(year: 2026, month: 5, day: 18, hour: 16, timeZoneIdentifier: "Australia/Sydney") + let previousUTCStart = try Self.date(year: 2026, month: 5, day: 17, hour: 0, timeZoneIdentifier: "UTC") + let currentUTCStart = try Self.date(year: 2026, month: 5, day: 18, hour: 0, timeZoneIdentifier: "UTC") + let usage = OpenAIAPIUsageSnapshot( + daily: [ + OpenAIAPIUsageSnapshot.DailyBucket( + day: "2026-05-17", + startTime: previousUTCStart, + endTime: previousUTCStart.addingTimeInterval(86400), + costUSD: 2.5, + requests: 3, + inputTokens: 200, + cachedInputTokens: 20, + outputTokens: 30, + totalTokens: 250, + lineItems: [], + models: []), + OpenAIAPIUsageSnapshot.DailyBucket( + day: "2026-05-18", + startTime: currentUTCStart, + endTime: currentUTCStart.addingTimeInterval(86400), + costUSD: 4.5, + requests: 5, + inputTokens: 400, + cachedInputTokens: 40, + outputTokens: 50, + totalTokens: 490, + lineItems: [], + models: []), + ], + updatedAt: now) + + let today = usage.summary(forLocalDayContaining: now, calendar: calendar) + + #expect(today.costUSD == 4.5) + #expect(today.requests == 5) + #expect(today.totalTokens == 490) + } + + @Test + func `Claude Admin current day includes UTC bucket containing positive timezone morning`() throws { + let calendar = try Self.calendar(timeZoneIdentifier: "Australia/Sydney") + let now = try Self.date(year: 2026, month: 5, day: 18, hour: 8, timeZoneIdentifier: "Australia/Sydney") + let staleUTCStart = try Self.date(year: 2026, month: 5, day: 16, hour: 0, timeZoneIdentifier: "UTC") + let overlappingUTCStart = try Self.date(year: 2026, month: 5, day: 17, hour: 0, timeZoneIdentifier: "UTC") + let usage = ClaudeAdminAPIUsageSnapshot( + daily: [ + ClaudeAdminAPIUsageSnapshot.DailyBucket( + day: "2026-05-16", + startTime: staleUTCStart, + endTime: staleUTCStart.addingTimeInterval(86400), + costUSD: 9, + inputTokens: 900, + cacheCreationInputTokens: 90, + cacheReadInputTokens: 45, + outputTokens: 90, + totalTokens: 1125, + costItems: [], + models: []), + ClaudeAdminAPIUsageSnapshot.DailyBucket( + day: "2026-05-17", + startTime: overlappingUTCStart, + endTime: overlappingUTCStart.addingTimeInterval(86400), + costUSD: 2.5, + inputTokens: 200, + cacheCreationInputTokens: 20, + cacheReadInputTokens: 10, + outputTokens: 30, + totalTokens: 260, + costItems: [], + models: []), + ], + updatedAt: now) + + let today = usage.summary(forLocalDayContaining: now, calendar: calendar) + + #expect(today.costUSD == 2.5) + #expect(today.inputTokens == 200) + #expect(today.totalTokens == 260) + } + + @Test + func `Claude Admin current day does not sum adjacent UTC buckets after negative timezone UTC rollover`() throws { + let calendar = try Self.calendar(timeZoneIdentifier: "America/Los_Angeles") + let now = try Self.date(year: 2026, month: 6, day: 22, hour: 20, timeZoneIdentifier: "America/Los_Angeles") + let previousUTCStart = try Self.date(year: 2026, month: 6, day: 22, hour: 0, timeZoneIdentifier: "UTC") + let currentUTCStart = try Self.date(year: 2026, month: 6, day: 23, hour: 0, timeZoneIdentifier: "UTC") + let usage = ClaudeAdminAPIUsageSnapshot( + daily: [ + ClaudeAdminAPIUsageSnapshot.DailyBucket( + day: "2026-06-22", + startTime: previousUTCStart, + endTime: previousUTCStart.addingTimeInterval(86400), + costUSD: 2.5, + inputTokens: 200, + cacheCreationInputTokens: 20, + cacheReadInputTokens: 10, + outputTokens: 30, + totalTokens: 260, + costItems: [], + models: []), + ClaudeAdminAPIUsageSnapshot.DailyBucket( + day: "2026-06-23", + startTime: currentUTCStart, + endTime: currentUTCStart.addingTimeInterval(86400), + costUSD: 4.5, + inputTokens: 400, + cacheCreationInputTokens: 40, + cacheReadInputTokens: 20, + outputTokens: 50, + totalTokens: 510, + costItems: [], + models: []), + ], + updatedAt: now) + + let today = usage.summary(forLocalDayContaining: now, calendar: calendar) + + #expect(today.costUSD == 4.5) + #expect(today.inputTokens == 400) + #expect(today.totalTokens == 510) + } + + private static func calendar(timeZoneIdentifier: String) throws -> Calendar { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = try #require(TimeZone(identifier: timeZoneIdentifier)) + return calendar + } + + private static func date( + year: Int, + month: Int, + day: Int, + hour: Int, + timeZoneIdentifier: String) throws -> Date + { + var components = DateComponents() + components.calendar = Calendar(identifier: .gregorian) + components.timeZone = TimeZone(identifier: timeZoneIdentifier) + components.year = year + components.month = month + components.day = day + components.hour = hour + return try #require(components.date) + } +} diff --git a/Tests/CodexBarTests/AgentSessionJSONTests.swift b/Tests/CodexBarTests/AgentSessionJSONTests.swift new file mode 100644 index 0000000000..9e3bf75b06 --- /dev/null +++ b/Tests/CodexBarTests/AgentSessionJSONTests.swift @@ -0,0 +1,41 @@ +import CodexBarCore +import Foundation +import Testing + +struct AgentSessionJSONTests { + @Test + func `sessions json round trip preserves stable schema`() throws { + let session = AgentSession( + id: "fixture-session", + provider: .codex, + source: .ide, + state: .active, + pid: 42, + cwd: "/tmp/project", + projectName: "project", + sessionName: "Fix session labels", + startedAt: Date(timeIntervalSince1970: 100), + lastActivityAt: Date(timeIntervalSince1970: 200), + transcriptPath: "/tmp/rollout.jsonl", + host: "local-mac") + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + let data = try encoder.encode([session]) + let object = try #require(JSONSerialization.jsonObject(with: data) as? [[String: Any]]) + let keys = try #require(object.first).keys + #expect(Set(keys) == [ + "id", "provider", "source", "state", "pid", "cwd", "projectName", "sessionName", "startedAt", + "lastActivityAt", "transcriptPath", "host", + ]) + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + #expect(try decoder.decode([AgentSession].self, from: data) == [session]) + + var legacyObject = try #require(object.first) + legacyObject.removeValue(forKey: "sessionName") + let legacyData = try JSONSerialization.data(withJSONObject: [legacyObject]) + let legacySession = try #require(decoder.decode([AgentSession].self, from: legacyData).first) + #expect(legacySession.sessionName == nil) + #expect(legacySession.id == session.id) + } +} diff --git a/Tests/CodexBarTests/AgentSessionMenuDescriptorTests.swift b/Tests/CodexBarTests/AgentSessionMenuDescriptorTests.swift new file mode 100644 index 0000000000..601f115c0c --- /dev/null +++ b/Tests/CodexBarTests/AgentSessionMenuDescriptorTests.swift @@ -0,0 +1,359 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +@MainActor +struct AgentSessionMenuDescriptorTests { + @Test + func `fresh settings omit agent sessions until explicitly enabled`() { + let settings = testSettingsStore(suiteName: "AgentSessionMenuDescriptorTests-default-off") + settings.statusChecksEnabled = false + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings) + let session = Self.session(id: "local", host: "local-mac", activity: Date()) + + let buildDescriptor = { + MenuDescriptor.build( + provider: .codex, + store: store, + settings: settings, + account: AccountInfo(email: nil, plan: nil), + updateReady: false, + agentSessionsEnabled: settings.agentSessionsEnabled, + localAgentSessions: [session]) + } + + let disabledEntries = buildDescriptor().sections.flatMap(\.entries) + #expect(!Self.containsAgentSessions(in: disabledEntries)) + + settings.agentSessionsEnabled = true + + let enabledEntries = buildDescriptor().sections.flatMap(\.entries) + #expect(Self.containsAgentSessions(in: enabledEntries)) + #expect(enabledEntries.contains { entry in + guard case .action(_, .focusAgentSession) = entry else { return false } + return true + }) + } + + @Test + func `adaptive refresh requires consent for local monitoring`() { + let settings = testSettingsStore(suiteName: "AgentSessionMenuDescriptorTests-adaptive-monitoring") + settings.agentSessionsEnabled = false + settings.refreshFrequency = .adaptiveAgentAware + let sessions = AgentSessionsStore(settings: settings) + + #expect(!sessions.localMonitoringEnabled) + settings.adaptiveActivityScanConsent = .allowed + #expect(sessions.localMonitoringEnabled) + #expect(settings.agentSessionsEnabled == false) + + settings.adaptiveActivityScanConsent = .declined + #expect(!sessions.localMonitoringEnabled) + + settings.adaptiveActivityScanConsent = .allowed + settings.refreshFrequency = .adaptive + #expect(!sessions.localMonitoringEnabled) + + settings.agentSessionsEnabled = true + #expect(sessions.localMonitoringEnabled) + } + + @Test + func `adaptive-only scan retains a timestamp but not session details`() { + let settings = testSettingsStore(suiteName: "AgentSessionMenuDescriptorTests-adaptive-projection") + settings.agentSessionsEnabled = false + settings.refreshFrequency = .adaptiveAgentAware + settings.adaptiveActivityScanConsent = .allowed + let store = AgentSessionsStore(settings: settings) + let older = Date(timeIntervalSinceReferenceDate: 100) + let newer = Date(timeIntervalSinceReferenceDate: 200) + let sessions = [ + Self.session(id: "older", host: "local", activity: older), + Self.session(id: "unknown", host: "local", activity: nil), + Self.session(id: "newer", host: "local", activity: newer), + ] + + store.applyLocalScanResult(sessions, updatedAt: newer) + + #expect(store.latestLocalActivityAt == newer) + #expect(store.localSessions.isEmpty) + #expect(store.lastUpdatedAt == newer) + } + + @Test + func `adaptive-only local scan pauses under power and thermal constraints`() { + #expect(AgentSessionsStore.shouldScanLocally( + agentSessionsEnabled: false, + adaptiveActivityScanningEnabled: true, + lowPowerModeEnabled: false, + thermalState: .nominal)) + #expect(!AgentSessionsStore.shouldScanLocally( + agentSessionsEnabled: false, + adaptiveActivityScanningEnabled: true, + lowPowerModeEnabled: true, + thermalState: .nominal)) + #expect(!AgentSessionsStore.shouldScanLocally( + agentSessionsEnabled: false, + adaptiveActivityScanningEnabled: true, + lowPowerModeEnabled: false, + thermalState: .serious)) + #expect(!AgentSessionsStore.shouldScanLocally( + agentSessionsEnabled: false, + adaptiveActivityScanningEnabled: false, + lowPowerModeEnabled: false, + thermalState: .nominal)) + #expect(AgentSessionsStore.shouldScanLocally( + agentSessionsEnabled: true, + adaptiveActivityScanningEnabled: false, + lowPowerModeEnabled: true, + thermalState: .critical)) + } + + @Test + func `adaptive-only metadata reads require a detected agent process`() { + #expect(!LocalAgentSessionScanner.shouldScanSessionMetadata( + hasAgentProcesses: false, + includeFileOnlySessions: false)) + #expect(LocalAgentSessionScanner.shouldScanSessionMetadata( + hasAgentProcesses: true, + includeFileOnlySessions: false)) + #expect(LocalAgentSessionScanner.shouldScanSessionMetadata( + hasAgentProcesses: false, + includeFileOnlySessions: true)) + } + + @Test + func `revoking adaptive consent clears retained activity`() { + let settings = testSettingsStore(suiteName: "AgentSessionMenuDescriptorTests-consent-revoked") + settings.refreshFrequency = .adaptiveAgentAware + settings.adaptiveActivityScanConsent = .allowed + let store = AgentSessionsStore(settings: settings) + store.applyLocalScanResult( + [Self.session(id: "local", host: "local", activity: Date())]) + #expect(store.latestLocalActivityAt != nil) + + settings.adaptiveActivityScanConsent = .declined + store.settingsDidChange(remoteConfigurationChanged: false) + + #expect(store.latestLocalActivityAt == nil) + #expect(store.localSessions.isEmpty) + } + + @Test + func `session section counts groups and renders unreachable hosts`() { + let now = Date(timeIntervalSince1970: 1000) + let local = Self.session(id: "local", host: "local-mac", activity: now.addingTimeInterval(-60)) + let remote = Self.session(id: "remote", host: "clawmac", activity: now.addingTimeInterval(-720)) + let section = MenuDescriptor.agentSessionsSection( + localSessions: [local], + remoteHosts: [ + RemoteSessionHostResult(host: "clawmac", sessions: [remote], error: nil), + RemoteSessionHostResult(host: "offline", sessions: [], error: "Connection timed out"), + ], + now: now) + + guard case let .text(header, .headline) = section.entries[0] else { + Issue.record("Expected session headline") + return + } + #expect(header == "Agent Sessions (2)") + guard case let .action(localTitle, .focusAgentSession(_, remoteHost)) = section.entries[1] else { + Issue.record("Expected local session action") + return + } + #expect(localTitle.contains("alpha — codex · cli · 1m")) + #expect(remoteHost == nil) + guard case let .text(remoteGroup, .secondary) = section.entries[2] else { + Issue.record("Expected remote group") + return + } + #expect(remoteGroup == "clawmac — 1") + guard case let .unavailable(title, tooltip) = section.entries[4] else { + Issue.record("Expected unreachable host") + return + } + #expect(title == "offline — unreachable") + #expect(tooltip == "Connection timed out") + } + + @Test + func `reachable empty remote host keeps zero count section actionable`() { + let section = MenuDescriptor.agentSessionsSection( + localSessions: [], + remoteHosts: [RemoteSessionHostResult(host: "clawmac", sessions: [], error: nil)]) + + #expect(section.entries.contains { entry in + guard case let .unavailable(title, _) = entry else { return false } + return title == "No agent sessions found" + }) + } + + @Test + func `session label style selects project descriptive or combined labels`() { + let now = Date(timeIntervalSince1970: 1000) + let session = Self.session( + id: "local", + host: "local-mac", + activity: now, + sessionName: "Fix Claude reauthorization") + + #expect(Self.actionTitle(for: session, style: .project, now: now).contains("⌘ alpha —")) + #expect(Self.actionTitle(for: session, style: .descriptive, now: now) + .contains("⌘ Fix Claude reauthorization —")) + #expect(Self.actionTitle(for: session, style: .descriptiveAndProject, now: now) + .contains("⌘ Fix Claude reauthorization · alpha —")) + } + + @Test + func `remote refresh gate retries changed settings and rejects stale result`() throws { + var gate = AgentSessionRemoteRefreshGate() + let initialGenerationCandidate = gate.begin() + let initialGeneration = try #require(initialGenerationCandidate) + gate.settingsDidChange() + #expect(gate.begin() == nil) + + let staleOutcome = gate.finish(generation: initialGeneration) + #expect(!staleOutcome.shouldPublish) + #expect(staleOutcome.shouldRetry) + + let currentGenerationCandidate = gate.begin() + let currentGeneration = try #require(currentGenerationCandidate) + let currentOutcome = gate.finish(generation: currentGeneration) + #expect(currentOutcome.shouldPublish) + #expect(!currentOutcome.shouldRetry) + } + + @Test + func `remote refresh gate coalesces ordinary overlaps without retry`() throws { + var gate = AgentSessionRemoteRefreshGate() + let generationCandidate = gate.begin() + let generation = try #require(generationCandidate) + #expect(gate.begin() == nil) + + let outcome = gate.finish(generation: generation) + #expect(outcome.shouldPublish) + #expect(!outcome.shouldRetry) + } + + @Test + func `remote refresh gate coalesces multiple ordinary overlaps into one pass`() throws { + var gate = AgentSessionRemoteRefreshGate() + let generationCandidate = gate.begin() + let generation = try #require(generationCandidate) + for _ in 0..<5 { + #expect(gate.begin() == nil) + } + + let outcome = gate.finish(generation: generation) + #expect(outcome.shouldPublish) + #expect(!outcome.shouldRetry) + #expect(Self.remotePassCount(for: .ordinaryOverlaps(count: 5)) == 1) + } + + @Test + func `remote refresh gate still retries after ordinary overlap then settings change`() throws { + var gate = AgentSessionRemoteRefreshGate() + let staleGenerationCandidate = gate.begin() + let staleGeneration = try #require(staleGenerationCandidate) + #expect(gate.begin() == nil) + gate.settingsDidChange() + + let staleOutcome = gate.finish(generation: staleGeneration) + #expect(!staleOutcome.shouldPublish) + #expect(staleOutcome.shouldRetry) + + let currentGenerationCandidate = gate.begin() + let currentGeneration = try #require(currentGenerationCandidate) + let currentOutcome = gate.finish(generation: currentGeneration) + #expect(currentOutcome.shouldPublish) + #expect(!currentOutcome.shouldRetry) + #expect(Self.remotePassCount(for: .ordinaryOverlapThenSettingsChange) == 2) + } + + @Test + func `remote refresh gate pass counts stay at one for overlap and two for settings change`() { + #expect(Self.remotePassCount(for: .ordinaryOverlaps(count: 1)) == 1) + #expect(Self.remotePassCount(for: .settingsChangeDuringFlight) == 2) + } + + private static func session( + id: String, + host: String, + activity: Date?, + sessionName: String? = nil) -> AgentSession + { + AgentSession( + id: id, + provider: .codex, + source: .cli, + state: .active, + pid: 42, + cwd: "/Users/test/alpha", + projectName: "alpha", + sessionName: sessionName, + startedAt: nil, + lastActivityAt: activity, + transcriptPath: nil, + host: host) + } + + private static func actionTitle( + for session: AgentSession, + style: AgentSessionLabelStyle, + now: Date) -> String + { + let section = MenuDescriptor.agentSessionsSection( + localSessions: [session], + remoteHosts: [], + labelStyle: style, + now: now) + guard case let .action(title, _) = section.entries[1] else { return "" } + return title + } + + private static func containsAgentSessions(in entries: [MenuDescriptor.Entry]) -> Bool { + entries.contains { entry in + guard case let .text(title, .headline) = entry else { return false } + return title.hasPrefix("Agent Sessions (") + } + } + + private enum RemoteRefreshScenario { + case ordinaryOverlaps(count: Int) + case settingsChangeDuringFlight + case ordinaryOverlapThenSettingsChange + } + + /// Pure state-machine pass counter: each successful `begin()`/`finish()` pair is one remote pass. + private static func remotePassCount(for scenario: RemoteRefreshScenario) -> Int { + var gate = AgentSessionRemoteRefreshGate() + var passes = 0 + + guard let generation = gate.begin() else { return 0 } + passes += 1 + + switch scenario { + case let .ordinaryOverlaps(count): + for _ in 0.. URL { + try #require(Bundle.module.url(forResource: name, withExtension: fileExtension, subdirectory: "Fixtures")) + } + + static func fixtureString(_ name: String, extension fileExtension: String) throws -> String { + try String(contentsOf: self.fixtureURL(name, extension: fileExtension), encoding: .utf8) + } +} diff --git a/Tests/CodexBarTests/AiAndProviderTests.swift b/Tests/CodexBarTests/AiAndProviderTests.swift new file mode 100644 index 0000000000..5fe70ee869 --- /dev/null +++ b/Tests/CodexBarTests/AiAndProviderTests.swift @@ -0,0 +1,506 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +struct AiAndProviderTests { + @Test + func `single log page maps to summed spend in the org billing currency`() async throws { + let now = Date(timeIntervalSince1970: 1_800_000_000) + let transport = ProviderHTTPTransportStub { request in + let url = try #require(request.url) + #expect(request.httpMethod == "GET") + #expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer fixture-key") + #expect(request.value(forHTTPHeaderField: "Accept") == "application/json") + #expect(url.absoluteString == "https://api.aiand.com/logs?range=30days&limit=100") + #expect(url.scheme == "https") + #expect(url.host == "api.aiand.com") + #expect(url.user == nil) + #expect(url.password == nil) + #expect(url.fragment == nil) + return Self.response(url: url, body: Self.finalPageFixture) + } + + let usage = try await AiAndUsageFetcher.fetchUsage( + "fixture-key", + transport: transport, + now: now) + let snapshot = usage.toUsageSnapshot() + + // "7.02344000" + "1.10000000"; the null-cost row is skipped. + #expect(usage.last30DaysSpend?.amount == Decimal(string: "8.12344")) + #expect(usage.last30DaysSpend?.currencyCode == "JPY") + #expect(usage.isComplete) + #expect(snapshot.primary == nil) + #expect(snapshot.secondary == nil) + #expect(snapshot.tertiary == nil) + #expect(snapshot.extraRateWindows == nil) + #expect(snapshot.providerCost?.limit == 0) + #expect(snapshot.providerCost?.currencyCode == "JPY") + #expect(snapshot.providerCost?.period == "Last 30 days") + #expect(snapshot.identity == nil) + #expect(snapshot.dataConfidence == .exact) + #expect(snapshot.updatedAt == now) + } + + @Test + func `pagination sends both cursors and sums across pages`() async throws { + let transport = ProviderHTTPTransportStub { request in + let url = try #require(request.url) + let query = url.query ?? "" + if query.contains("after=") { + #expect(url.absoluteString == + "https://api.aiand.com/logs?range=30days&limit=100" + + "&after=2026-07-17%2010:24:30.094374%2B00&after_id=912bf992-0000-4000-8000-000000000002") + return Self.response(url: url, body: Self.finalPageFixture) + } + return Self.response(url: url, body: Self.firstPageFixture) + } + + let usage = try await AiAndUsageFetcher.fetchUsage("fixture-key", transport: transport) + + let requests = await transport.requests() + #expect(requests.count == 2) + let secondQuery = try #require(requests.last?.url?.query) + #expect(secondQuery.contains("after=")) + #expect(secondQuery.contains("after_id=912bf992-0000-4000-8000-000000000002")) + // Page 1: "12.00000000" + "0.50000000"; page 2: "7.02344000" + "1.10000000". + #expect(usage.last30DaysSpend?.amount == Decimal(string: "20.62344")) + #expect(usage.last30DaysSpend?.currencyCode == "JPY") + #expect(usage.isComplete) + } + + @Test + func `hitting the page cap marks the spend partial`() async throws { + let now = Date(timeIntervalSince1970: 1_800_000_000) + let transport = ProviderHTTPTransportStub { request in + let url = try #require(request.url) + return Self.response(url: url, body: Self.firstPageFixture) + } + + let usage = try await AiAndUsageFetcher.fetchUsage( + "fixture-key", + transport: transport, + now: now) + let snapshot = usage.toUsageSnapshot() + + let requests = await transport.requests() + #expect(requests.count == AiAndUsageFetcher.maxPages) + #expect(!usage.isComplete) + #expect(usage.last30DaysSpend?.amount == Decimal(string: "125.0")) + #expect(snapshot.providerCost?.period == "Last 30 days (partial)") + #expect(snapshot.dataConfidence == .estimated) + } + + @Test + func `missing pagination cursor marks the spend partial`() async throws { + let transport = ProviderHTTPTransportStub { request in + let url = try #require(request.url) + return Self.response(url: url, body: #""" + { + "data": [{"cost": "2.50000000", "currency": "jpy"}], + "has_more": true, + "next_after": null, + "next_after_id": null + } + """#) + } + + let usage = try await AiAndUsageFetcher.fetchUsage("fixture-key", transport: transport) + let snapshot = usage.toUsageSnapshot() + + #expect(await transport.requests().count == 1) + #expect(!usage.isComplete) + #expect(snapshot.providerCost?.period == "Last 30 days (partial)") + #expect(snapshot.dataConfidence == .estimated) + } + + @Test + func `mixed currencies keep the newest row's currency and skip the rest`() async throws { + let transport = ProviderHTTPTransportStub { request in + let url = try #require(request.url) + return Self.response(url: url, body: Self.mixedCurrencyFixture) + } + + let usage = try await AiAndUsageFetcher.fetchUsage("fixture-key", transport: transport) + + // The newest row is JPY, so the USD row is not added to the total. + #expect(usage.last30DaysSpend?.currencyCode == "JPY") + #expect(usage.last30DaysSpend?.amount == Decimal(string: "9.5")) + } + + @Test + func `empty window omits the cost snapshot instead of guessing a currency`() async throws { + let transport = ProviderHTTPTransportStub { request in + let url = try #require(request.url) + return Self.response(url: url, body: #"{"data": [], "has_more": false}"#) + } + + let usage = try await AiAndUsageFetcher.fetchUsage("fixture-key", transport: transport) + let snapshot = usage.toUsageSnapshot() + + // The billing currency is only observable from log rows; with none, report + // no cost at all rather than a zero in a guessed currency. + #expect(usage.last30DaysSpend == nil) + #expect(usage.isComplete) + #expect(snapshot.providerCost == nil) + } + + @Test + func `rows without a currency are skipped and alone yield no cost snapshot`() async throws { + let transport = ProviderHTTPTransportStub { request in + let url = try #require(request.url) + return Self.response(url: url, body: Self.missingCurrencyFixture) + } + + let usage = try await AiAndUsageFetcher.fetchUsage("fixture-key", transport: transport) + + #expect(usage.last30DaysSpend == nil) + #expect(usage.toUsageSnapshot().providerCost == nil) + } + + @Test + func `decimal money strings sum exactly`() async throws { + let transport = ProviderHTTPTransportStub { request in + let url = try #require(request.url) + return Self.response(url: url, body: Self.decimalFixture) + } + + let usage = try await AiAndUsageFetcher.fetchUsage("fixture-key", transport: transport) + + // 0.1 + 0.1 + 0.1 must be exactly 0.3 — Double summation would drift. + #expect(usage.last30DaysSpend?.amount == Decimal(string: "0.3")) + } + + @Test + func `credential is only sent as a bearer header`() async throws { + let transport = ProviderHTTPTransportStub { request in + let url = try #require(request.url) + return Self.response(url: url, body: Self.finalPageFixture) + } + + _ = try await AiAndUsageFetcher.fetchUsage("fixture-key", transport: transport) + + let request = try #require(await transport.requests().first) + let url = try #require(request.url) + #expect(!url.absoluteString.contains("fixture-key")) + #expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer fixture-key") + } + + @Test + func `invalid api key maps to an actionable error`() async { + let transport = Self.errorTransport(statusCode: 401, code: "invalid_api_key") + + await #expect { + _ = try await AiAndUsageFetcher.fetchUsage("wrong-key", transport: transport) + } throws: { error in + error as? AiAndUsageError == .authenticationRejected + } + #expect(AiAndUsageError.authenticationRejected.errorDescription?.contains("console.aiand.com") == true) + } + + @Test + func `insufficient credits maps to an actionable error`() async { + let transport = Self.errorTransport(statusCode: 402, code: "insufficient_credits") + + await #expect { + _ = try await AiAndUsageFetcher.fetchUsage("fixture-key", transport: transport) + } throws: { error in + error as? AiAndUsageError == .insufficientCredits + } + #expect(AiAndUsageError.insufficientCredits.errorDescription?.contains("credits") == true) + } + + @Test + func `rate limit is surfaced politely`() async { + let transport = Self.errorTransport(statusCode: 429, code: "rate_limit_exceeded") + + await #expect { + _ = try await AiAndUsageFetcher.fetchUsage("fixture-key", transport: transport) + } throws: { error in + error as? AiAndUsageError == .rateLimited + } + } + + @Test + func `unexpected status is reported with its code`() async { + let transport = Self.errorTransport(statusCode: 500, code: "internal_error") + + await #expect { + _ = try await AiAndUsageFetcher.fetchUsage("fixture-key", transport: transport) + } throws: { error in + error as? AiAndUsageError == .apiError(500) + } + } + + @Test + func `missing or whitespace credential fails clearly`() async { + await #expect { + _ = try await AiAndUsageFetcher.fetchUsage(" ") + } throws: { error in + error as? AiAndUsageError == .notConfigured + } + } + + @Test + func `malformed logs payload fails parsing`() async { + let transport = ProviderHTTPTransportStub { request in + let url = try #require(request.url) + return Self.response(url: url, body: #"{"object":"list"}"#) + } + + await #expect { + _ = try await AiAndUsageFetcher.fetchUsage("fixture-key", transport: transport) + } throws: { error in + guard case .parseFailed = error as? AiAndUsageError else { return false } + return true + } + } + + @Test + func `settings reader trims whitespace and quotes`() { + #expect(AiAndSettingsReader.apiKey(environment: [ + AiAndSettingsReader.apiKeyEnvironmentKey: " 'fixture-key' ", + ]) == "fixture-key") + #expect(AiAndSettingsReader.apiKey(environment: [:]) == nil) + #expect(AiAndSettingsReader.apiKey(environment: [ + AiAndSettingsReader.apiKeyEnvironmentKey: " ", + ]) == nil) + } + + @Test + func `config API key projects into the fetch environment`() { + let env = ProviderConfigEnvironment.applyAPIKeyOverride( + base: [AiAndSettingsReader.apiKeyEnvironmentKey: "environment-key"], + provider: .aiand, + config: ProviderConfig(id: .aiand, apiKey: "test")) + + #expect(AiAndSettingsReader.apiKey(environment: env) == "test") + #expect(ProviderConfigEnvironment.supportsAPIKeyOverride(for: .aiand)) + } + + @Test @MainActor + func `descriptor and app registry include aiand`() throws { + let descriptor = ProviderDescriptorRegistry.descriptor(for: .aiand) + #expect(descriptor.metadata.displayName == "ai&") + #expect(descriptor.metadata.cliName == "aiand") + #expect(descriptor.metadata.defaultEnabled == false) + #expect(!descriptor.metadata.supportsCredits) + #expect(!descriptor.tokenCost.supportsTokenCost) + #expect(descriptor.fetchPlan.sourceModes == [.auto, .api]) + #expect(descriptor.cli.aliases == ["ai&", "ai-and"]) + + let implementation = try #require(ProviderImplementationRegistry.implementation(for: .aiand)) + #expect(implementation is AiAndProviderImplementation) + } + + @Test @MainActor + func `menu card renders spend through the generic API-spend path`() async throws { + let now = Date(timeIntervalSince1970: 1_800_000_000) + let transport = ProviderHTTPTransportStub { request in + let url = try #require(request.url) + return Self.response(url: url, body: Self.finalPageFixture) + } + let usage = try await AiAndUsageFetcher.fetchUsage( + "fixture-key", + transport: transport, + now: now) + let model = UsageMenuCardView.Model.make(.init( + provider: .aiand, + metadata: AiAndProviderDescriptor.descriptor.metadata, + snapshot: usage.toUsageSnapshot(), + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: true, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + + #expect(model.metrics.isEmpty) + #expect(model.creditsText == nil) + #expect(model.providerCost?.title == "API spend") + #expect(model.providerCost?.spendLine == "Last 30 days: ¥8") + #expect(model.providerCost?.percentUsed == nil) + #expect(model.providerCost?.percentLine == nil) + } + + /// Sanitized from a live `/logs` response (2026-07-17); `api_key` arrives pre-masked by the server. + private static let finalPageFixture = #""" + { + "data": [ + { + "id": "cdd2b25d-0000-4000-8000-000000000001", + "model": "zai-org/glm-5.2", + "api_key": "masked", + "status_code": 200, + "ttft_ms": 1449, + "latency_ms": 3163, + "input_tokens": 170569, + "output_tokens": 248, + "cached_tokens": 170240, + "cost": "7.02344000", + "currency": "jpy", + "created_at": "2026-07-17 10:24:30.094374+00" + }, + { + "id": "cdd2b25d-0000-4000-8000-000000000002", + "model": "zai-org/glm-5.2", + "api_key": "masked", + "status_code": 200, + "ttft_ms": 512, + "latency_ms": 1201, + "input_tokens": 1200, + "output_tokens": 90, + "cached_tokens": 0, + "cost": "1.10000000", + "currency": "jpy", + "created_at": "2026-07-17 10:20:00.000000+00" + }, + { + "id": "cdd2b25d-0000-4000-8000-000000000003", + "model": "zai-org/glm-5.2", + "api_key": "masked", + "status_code": 500, + "ttft_ms": 0, + "latency_ms": 42, + "input_tokens": 0, + "output_tokens": 0, + "cached_tokens": null, + "cost": null, + "currency": "jpy", + "created_at": "2026-07-17 10:15:00.000000+00" + } + ], + "has_more": false, + "next_after": null, + "next_after_id": null + } + """# + + private static let firstPageFixture = #""" + { + "data": [ + { + "id": "912bf992-0000-4000-8000-000000000001", + "model": "zai-org/glm-5.2", + "api_key": "masked", + "status_code": 200, + "ttft_ms": 800, + "latency_ms": 2400, + "input_tokens": 52000, + "output_tokens": 700, + "cached_tokens": 0, + "cost": "12.00000000", + "currency": "jpy", + "created_at": "2026-07-17 10:24:30.094374+00" + }, + { + "id": "912bf992-0000-4000-8000-000000000002", + "model": "zai-org/glm-5.2", + "api_key": "masked", + "status_code": 200, + "ttft_ms": 300, + "latency_ms": 900, + "input_tokens": 2100, + "output_tokens": 55, + "cached_tokens": 0, + "cost": "0.50000000", + "currency": "jpy", + "created_at": "2026-07-17 10:24:30.094374+00" + } + ], + "has_more": true, + "next_after": "2026-07-17 10:24:30.094374+00", + "next_after_id": "912bf992-0000-4000-8000-000000000002" + } + """# + + private static let mixedCurrencyFixture = #""" + { + "data": [ + { + "id": "aaaa0000-0000-4000-8000-000000000001", + "cost": "9.50000000", + "currency": "jpy", + "created_at": "2026-07-17 10:24:30.094374+00" + }, + { + "id": "aaaa0000-0000-4000-8000-000000000002", + "cost": "1.25000000", + "currency": "usd", + "created_at": "2026-07-17 10:20:00.000000+00" + } + ], + "has_more": false, + "next_after": null, + "next_after_id": null + } + """# + + private static let missingCurrencyFixture = #""" + { + "data": [ + { + "id": "aaaa0000-0000-4000-8000-000000000003", + "cost": "4.20000000", + "currency": null, + "created_at": "2026-07-17 10:24:30.094374+00" + }, + { + "id": "aaaa0000-0000-4000-8000-000000000004", + "cost": "1.00000000", + "currency": " ", + "created_at": "2026-07-17 10:20:00.000000+00" + } + ], + "has_more": false, + "next_after": null, + "next_after_id": null + } + """# + + private static let decimalFixture = #""" + { + "data": [ + {"id": "bbbb0000-0000-4000-8000-000000000001", "cost": "0.10000000", "currency": "jpy"}, + {"id": "bbbb0000-0000-4000-8000-000000000002", "cost": "0.10000000", "currency": "jpy"}, + {"id": "bbbb0000-0000-4000-8000-000000000003", "cost": "0.10000000", "currency": "jpy"} + ], + "has_more": false, + "next_after": null, + "next_after_id": null + } + """# + + private static func errorTransport(statusCode: Int, code: String) -> ProviderHTTPTransportStub { + ProviderHTTPTransportStub { request in + let url = try #require(request.url) + let body = #""" + {"error":{"message":"fixture error","type":"fixture","param":null,"code":"\#(code)"}} + """# + return Self.response(url: url, body: body, statusCode: statusCode) + } + } + + private static func response( + url: URL, + body: String, + statusCode: Int = 200) -> (Data, URLResponse) + { + let response = HTTPURLResponse( + url: url, + statusCode: statusCode, + httpVersion: "HTTP/1.1", + headerFields: ["Content-Type": "application/json"])! + return (Data(body.utf8), response) + } +} diff --git a/Tests/CodexBarTests/AlibabaCodingPlanCookieImporterTests.swift b/Tests/CodexBarTests/AlibabaCodingPlanCookieImporterTests.swift index 9bf9952ae2..d49e9a93a9 100644 --- a/Tests/CodexBarTests/AlibabaCodingPlanCookieImporterTests.swift +++ b/Tests/CodexBarTests/AlibabaCodingPlanCookieImporterTests.swift @@ -1,11 +1,69 @@ import Foundation +import os.lock import Testing @testable import CodexBarCore #if os(macOS) import SweetCookieKit +@Suite(.serialized) struct AlibabaCodingPlanCookieImporterTests { + @Test(.disabled( + if: ProcessInfo.processInfo.environment[BrowserCookieAccessGate.allowTestCookieAccessEnvironmentKey] == "1", + "Default-home cookie access is explicitly enabled for this test run.")) + func `default home import is suppressed before profile and keychain access`() throws { + let profileProbeCount = OSAllocatedUnfairLock(initialState: 0) + let keychainProbeCount = OSAllocatedUnfairLock(initialState: 0) + let defaultHome = try #require(BrowserCookieClient.defaultHomeDirectories().first) + let detection = BrowserDetection( + homeDirectory: defaultHome.path, + cacheTTL: 0, + fileExists: { _ in + profileProbeCount.withLock { $0 += 1 } + return true + }, + directoryContents: { _ in + profileProbeCount.withLock { $0 += 1 } + return ["Default"] + }) + + _ = KeychainAccessGate.withTaskOverrideForTesting(false) { + KeychainAccessPreflight.withCheckGenericPasswordOverrideForTesting { _, _ in + keychainProbeCount.withLock { $0 += 1 } + return .allowed + } operation: { + #expect(throws: AlibabaCodingPlanSettingsError.self) { + _ = try AlibabaCodingPlanCookieImporter.importSession(browserDetection: detection) + } + } + } + + #expect(profileProbeCount.withLock { $0 } == 0) + #expect(keychainProbeCount.withLock { $0 } == 0) + } + + @Test(.disabled( + if: ProcessInfo.processInfo.environment[BrowserCookieAccessGate.allowTestCookieAccessEnvironmentKey] == "1", + "Default-home cookie access is explicitly enabled for this test run.")) + func `chromium fallback rejects default client before keychain access`() { + let keychainProbeCount = OSAllocatedUnfairLock(initialState: 0) + _ = KeychainAccessGate.withTaskOverrideForTesting(false) { + KeychainAccessPreflight.withCheckGenericPasswordOverrideForTesting { _, _ in + keychainProbeCount.withLock { $0 += 1 } + return .allowed + } operation: { + #expect(throws: BrowserCookieStoreAccessSuppressedError.self) { + _ = try AliyunOneConsoleChromiumCookieFallbackImporter.importSession( + browser: .chrome, + domains: ["example.com"], + isAuthenticatedSession: { _ in false }, + sessionLabel: "Test") + } + } + } + #expect(keychainProbeCount.withLock { $0 } == 0) + } + @Test func `domain matching requires exact or label bounded suffix`() { #expect(AlibabaCodingPlanCookieImporter.matchesCookieDomain("console.aliyun.com")) @@ -34,7 +92,12 @@ struct AlibabaCodingPlanCookieImporterTests { atPath: firefoxProfile.appendingPathComponent("cookies.sqlite").path, contents: Data()) - let detection = BrowserDetection(homeDirectory: temp.path, cacheTTL: 0) + let detection = BrowserDetection( + homeDirectory: temp.path, + cacheTTL: 0, + fileExists: { path in + path == "/Applications/Firefox.app" || FileManager.default.fileExists(atPath: path) + }) let importOrder: BrowserCookieImportOrder = [.firefox, .safari, .chrome] let candidates = AlibabaCodingPlanCookieImporter.cookieImportCandidates( diff --git a/Tests/CodexBarTests/AlibabaCodingPlanMenuCardModelTests.swift b/Tests/CodexBarTests/AlibabaCodingPlanMenuCardModelTests.swift new file mode 100644 index 0000000000..8efabe1221 --- /dev/null +++ b/Tests/CodexBarTests/AlibabaCodingPlanMenuCardModelTests.swift @@ -0,0 +1,128 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +struct AlibabaCodingPlanMenuCardModelTests { + @Test + func `monthly quota shows deficit and run out details`() throws { + let now = Date(timeIntervalSince1970: 10_368_000) // 1970-05-01T00:00:00Z + let reset = now.addingTimeInterval(6 * 24 * 3600) + let snapshot = UsageSnapshot( + primary: RateWindow( + usedPercent: 25, + windowMinutes: 5 * 60, + resetsAt: now.addingTimeInterval(2 * 3600), + resetDescription: "250 / 1000 used"), + secondary: RateWindow( + usedPercent: 40, + windowMinutes: 7 * 24 * 60, + resetsAt: now.addingTimeInterval(4 * 24 * 3600), + resetDescription: "400 / 1000 used"), + tertiary: RateWindow( + usedPercent: 90, + windowMinutes: 30 * 24 * 60, + resetsAt: reset, + resetDescription: "900 / 1000 used"), + updatedAt: now, + identity: nil) + let metadata = try #require(ProviderDefaults.metadata[.alibaba]) + + let model = UsageMenuCardView.Model.make(.init( + provider: .alibaba, + metadata: metadata, + snapshot: snapshot, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + + #expect(model.metrics.map(\.title) == ["5-hour", "Weekly", "Monthly"]) + let monthly = try #require(model.metrics.first { $0.id == "tertiary" }) + #expect(monthly.percentLabel == "10% left") + #expect(monthly.detailText == "900 / 1000 used") + #expect(monthly.detailLeftText == "10% in deficit") + #expect(monthly.detailRightText == "Runs out in 2d 16h") + #expect(monthly.pacePercent == 20) + #expect(monthly.paceOnTop == false) + } + + @Test + func `monthly pace uses thirty one day reset window`() throws { + let now = try Self.date("2026-07-01T23:00:00Z") + let reset = try Self.date("2026-08-01T00:00:00Z") + let model = try Self.model( + now: now, + monthly: RateWindow( + usedPercent: 10, + windowMinutes: 30 * 24 * 60, + resetsAt: reset, + resetDescription: nil)) + + let monthly = try #require(model.metrics.first { $0.id == "tertiary" }) + #expect(monthly.detailLeftText == "7% in deficit") + #expect(monthly.detailRightText == "Runs out in 8d 15h") + } + + @Test + func `monthly pace uses twenty eight day reset window`() throws { + let now = try Self.date("2026-02-02T00:00:00Z") + let reset = try Self.date("2026-03-01T00:00:00Z") + let model = try Self.model( + now: now, + monthly: RateWindow( + usedPercent: 0, + windowMinutes: 30 * 24 * 60, + resetsAt: reset, + resetDescription: nil)) + + let monthly = try #require(model.metrics.first { $0.id == "tertiary" }) + #expect(monthly.detailLeftText == "4% in reserve") + #expect(monthly.detailRightText == "Lasts until reset") + } + + private static func model(now: Date, monthly: RateWindow) throws -> UsageMenuCardView.Model { + let snapshot = UsageSnapshot( + primary: nil, + secondary: nil, + tertiary: monthly, + updatedAt: now, + identity: nil) + let metadata = try #require(ProviderDefaults.metadata[.alibaba]) + + return UsageMenuCardView.Model.make(.init( + provider: .alibaba, + metadata: metadata, + snapshot: snapshot, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + } + + private static func date(_ value: String) throws -> Date { + try #require(ISO8601DateFormatter().date(from: value)) + } +} diff --git a/Tests/CodexBarTests/AlibabaCodingPlanProviderTests.swift b/Tests/CodexBarTests/AlibabaCodingPlanProviderTests.swift index 25dc0537b4..f21d1c33e4 100644 --- a/Tests/CodexBarTests/AlibabaCodingPlanProviderTests.swift +++ b/Tests/CodexBarTests/AlibabaCodingPlanProviderTests.swift @@ -46,6 +46,120 @@ struct AlibabaCodingPlanSettingsReaderTests { #expect(url?.absoluteString == "https://modelstudio.console.alibabacloud.com/data/api.json") } + @Test + func `endpoint overrides allow custom https hosts by default`() { + let env = [ + AlibabaCodingPlanSettingsReader.hostKey: "https://attacker.example", + AlibabaCodingPlanSettingsReader.quotaURLKey: "https://attacker.example/data/api.json", + ] + + #expect(AlibabaCodingPlanSettingsReader.hostOverride(environment: env) == "attacker.example") + #expect(AlibabaCodingPlanSettingsReader.quotaURL(environment: env)?.host == "attacker.example") + #expect(AlibabaCodingPlanSettingsReader.rejectedEndpointOverrideKey(environment: env) == nil) + } + + @Test + func `host endpoint overrides preserve explicit port`() { + let env = [AlibabaCodingPlanSettingsReader.hostKey: "proxy.example.test:8443"] + + #expect(AlibabaCodingPlanSettingsReader.hostOverride(environment: env) == "proxy.example.test:8443") + #expect( + AlibabaCodingPlanUsageFetcher.resolveQuotaURL(region: .international, environment: env).absoluteString == + "https://proxy.example.test:8443/data/api.json?action=zeldaEasy.broadscope-bailian.codingPlan.queryCodingPlanInstanceInfoV2&product=broadscope-bailian&api=queryCodingPlanInstanceInfoV2¤tRegionId=ap-southeast-1") + #expect( + AlibabaCodingPlanUsageFetcher.resolveConsoleDashboardURL(region: .international, environment: env) + .absoluteString + .hasPrefix("https://proxy.example.test:8443/") == true) + } + + @Test + func `endpoint overrides reject encoded host delimiters before suffix matching`() { + let encodedSlash = "https://attacker.example%2f.modelstudio.console.alibabacloud.com" + let doubleEncodedSlash = "https://attacker.example%252f.modelstudio.console.alibabacloud.com" + let env = [ + AlibabaCodingPlanSettingsReader.hostKey: encodedSlash, + AlibabaCodingPlanSettingsReader.quotaURLKey: "\(encodedSlash)/data/api.json", + ] + + #expect(AlibabaCodingPlanSettingsReader.hostOverride(environment: env) == nil) + #expect(AlibabaCodingPlanSettingsReader.quotaURL(environment: env) == nil) + #expect(AlibabaCodingPlanSettingsReader.hostOverride(environment: [ + AlibabaCodingPlanSettingsReader.hostKey: doubleEncodedSlash, + ]) == nil) + } + + @Test + func `endpoint overrides reject whitespace and control characters in hosts`() { + for host in ["https://bad host", "https://bad%20host", "https://bad%09host"] { + #expect(AlibabaCodingPlanSettingsReader.hostOverride(environment: [ + AlibabaCodingPlanSettingsReader.hostKey: host, + ]) == nil) + #expect(AlibabaCodingPlanSettingsReader.quotaURL(environment: [ + AlibabaCodingPlanSettingsReader.quotaURLKey: "\(host)/data/api.json", + ]) == nil) + } + } + + @Test + func `endpoint overrides require https and no userinfo`() { + #expect(AlibabaCodingPlanSettingsReader.hostOverride(environment: [ + AlibabaCodingPlanSettingsReader.hostKey: "http://modelstudio.console.alibabacloud.com", + ]) == nil) + #expect(AlibabaCodingPlanSettingsReader.quotaURL(environment: [ + AlibabaCodingPlanSettingsReader.quotaURLKey: + "https://user:pass@modelstudio.console.alibabacloud.com/data/api.json", + ]) == nil) + } + + @Test + func `strict provider endpoint mode rejects custom hosts`() { + let env = [ + AlibabaCodingPlanSettingsReader.requireProviderEndpointOverridesKey: "true", + AlibabaCodingPlanSettingsReader.hostKey: "proxy.example.test", + AlibabaCodingPlanSettingsReader.quotaURLKey: "https://proxy.example.test/data/api.json", + ] + + #expect(AlibabaCodingPlanSettingsReader.hostOverride(environment: env) == nil) + #expect(AlibabaCodingPlanSettingsReader.quotaURL(environment: env) == nil) + #expect(AlibabaCodingPlanSettingsReader + .rejectedEndpointOverrideKey(environment: env) == AlibabaCodingPlanSettingsReader.hostKey) + } + + @Test + func `strict provider endpoint mode rejects customer controlled Alibaba Cloud hosts`() { + let env = [ + AlibabaCodingPlanSettingsReader.requireProviderEndpointOverridesKey: "true", + AlibabaCodingPlanSettingsReader.hostKey: "tenant.cn-beijing.fc.aliyuncs.com", + ] + + #expect(AlibabaCodingPlanSettingsReader.hostOverride(environment: env) == nil) + #expect(AlibabaCodingPlanSettingsReader + .rejectedEndpointOverrideKey(environment: env) == AlibabaCodingPlanSettingsReader.hostKey) + } + + @Test + func `strict provider endpoint mode accepts known Coding Plan hosts`() { + let env = [ + AlibabaCodingPlanSettingsReader.requireProviderEndpointOverridesKey: "true", + AlibabaCodingPlanSettingsReader.hostKey: "bailian-beijing-cs.aliyuncs.com", + ] + + #expect( + AlibabaCodingPlanSettingsReader.hostOverride(environment: env) == + "bailian-beijing-cs.aliyuncs.com") + #expect(AlibabaCodingPlanSettingsReader.rejectedEndpointOverrideKey(environment: env) == nil) + } + + @Test + func `custom https compatibility mode still rejects http and userinfo`() { + #expect(AlibabaCodingPlanSettingsReader.hostOverride(environment: [ + AlibabaCodingPlanSettingsReader.hostKey: "http://proxy.example.test", + ]) == nil) + #expect(AlibabaCodingPlanSettingsReader.rejectedEndpointOverrideKey(environment: [ + AlibabaCodingPlanSettingsReader.quotaURLKey: "https://user:pass@proxy.example.test/data/api.json", + ]) == AlibabaCodingPlanSettingsReader.quotaURLKey) + } + @Test func `missing cookie error includes access hint when present`() { let error = AlibabaCodingPlanSettingsError @@ -553,7 +667,7 @@ struct AlibabaCodingPlanFallbackTests { } @Test - func `auto mode does not borrow manual cookie authority when browser import fails`() { + func `auto mode does not borrow manual cookie authority when browser import fails`() throws { let strategy = AlibabaCodingPlanWebFetchStrategy() let settings = ProviderSettingsSnapshot.make( alibaba: ProviderSettingsSnapshot.AlibabaCodingPlanProviderSettings( @@ -563,29 +677,26 @@ struct AlibabaCodingPlanFallbackTests { let context = self.makeContext(sourceMode: .auto, settings: settings) CookieHeaderCache.clear(provider: .alibaba) - AlibabaCodingPlanCookieImporter.importSessionOverrideForTesting = { _, _ in + try AlibabaCodingPlanCookieImporter.withImportSessionOverrideForTesting { _, _ in throw AlibabaCodingPlanSettingsError.missingCookie() - } - defer { - AlibabaCodingPlanCookieImporter.importSessionOverrideForTesting = nil - } - - do { - _ = try AlibabaCodingPlanWebFetchStrategy.resolveCookieHeader(context: context, allowCached: false) - Issue.record("Expected auto mode to fail instead of borrowing the manual cookie header") - } catch let error as AlibabaCodingPlanSettingsError { - guard case .missingCookie = error else { - Issue.record("Expected missingCookie, got \(error)") - return + } operation: { + do { + _ = try AlibabaCodingPlanWebFetchStrategy.resolveCookieHeader(context: context, allowCached: false) + Issue.record("Expected auto mode to fail instead of borrowing the manual cookie header") + } catch let error as AlibabaCodingPlanSettingsError { + guard case .missingCookie = error else { + Issue.record("Expected missingCookie, got \(error)") + return + } + #expect(strategy.shouldFallback(on: error, context: context)) + } catch { + Issue.record("Expected AlibabaCodingPlanSettingsError, got \(error)") } - #expect(strategy.shouldFallback(on: error, context: context)) - } catch { - Issue.record("Expected AlibabaCodingPlanSettingsError, got \(error)") } } @Test - func `auto mode skips web when no alibaba session is available`() async { + func `auto mode skips web when no alibaba session is available`() async throws { let strategy = AlibabaCodingPlanWebFetchStrategy() let settings = ProviderSettingsSnapshot.make( alibaba: ProviderSettingsSnapshot.AlibabaCodingPlanProviderSettings( @@ -598,14 +709,11 @@ struct AlibabaCodingPlanFallbackTests { env: [AlibabaCodingPlanSettingsReader.apiTokenKey: "token-abc"]) CookieHeaderCache.clear(provider: .alibaba) - AlibabaCodingPlanCookieImporter.importSessionOverrideForTesting = { _, _ in + try await AlibabaCodingPlanCookieImporter.withImportSessionOverrideForTesting { _, _ in throw AlibabaCodingPlanSettingsError.missingCookie() + } operation: { + #expect(await strategy.isAvailable(context) == false) } - defer { - AlibabaCodingPlanCookieImporter.importSessionOverrideForTesting = nil - } - - #expect(await strategy.isAvailable(context) == false) } } @@ -657,9 +765,53 @@ struct AlibabaCodingPlanRegionTests { @Test func `quota url override beats host`() { - let env = [AlibabaCodingPlanSettingsReader.quotaURLKey: "https://example.com/custom/quota"] + let env = [ + AlibabaCodingPlanSettingsReader.quotaURLKey: + "https://modelstudio.console.alibabacloud.com/custom/quota", + ] + let url = AlibabaCodingPlanUsageFetcher.resolveQuotaURL(region: .international, environment: env) + #expect(url.absoluteString == "https://modelstudio.console.alibabacloud.com/custom/quota") + } + + @Test + func `custom quota url override is preserved by default`() { + let env = [AlibabaCodingPlanSettingsReader.quotaURLKey: "https://attacker.example/custom/quota"] let url = AlibabaCodingPlanUsageFetcher.resolveQuotaURL(region: .international, environment: env) - #expect(url.absoluteString == "https://example.com/custom/quota") + #expect(url.host == "attacker.example") + } + + @Test + func `strict provider endpoint mode falls back to provider endpoint`() { + let env = [ + AlibabaCodingPlanSettingsReader.requireProviderEndpointOverridesKey: "true", + AlibabaCodingPlanSettingsReader.quotaURLKey: "https://attacker.example/custom/quota", + ] + let url = AlibabaCodingPlanUsageFetcher.resolveQuotaURL(region: .international, environment: env) + #expect(url.host == AlibabaCodingPlanAPIRegion.international.quotaURL.host) + } + + @Test + func `explicit endpoint override rejects invalid api scheme before network`() async { + await #expect(throws: ProviderEndpointOverrideError.alibabaCodingPlan( + AlibabaCodingPlanSettingsReader.quotaURLKey)) + { + _ = try await AlibabaCodingPlanUsageFetcher.fetchUsage( + apiKey: "cpk-test", + environment: [AlibabaCodingPlanSettingsReader + .quotaURLKey: "http://modelstudio.console.alibabacloud.com/custom/quota"]) + } + } + + @Test + func `explicit endpoint override rejects invalid cookie scheme before network`() async { + await #expect(throws: ProviderEndpointOverrideError.alibabaCodingPlan( + AlibabaCodingPlanSettingsReader.quotaURLKey)) + { + _ = try await AlibabaCodingPlanUsageFetcher.fetchUsage( + cookieHeader: "login_aliyunid_ticket=ticket; login_aliyunid_pk=user", + environment: [AlibabaCodingPlanSettingsReader + .quotaURLKey: "http://modelstudio.console.alibabacloud.com/custom/quota"]) + } } } @@ -667,24 +819,23 @@ struct AlibabaCodingPlanRegionTests { struct AlibabaCodingPlanUsageFetcherRequestTests { @Test func `api401 maps to invalid credentials`() async throws { - let registered = URLProtocol.registerClass(AlibabaUsageFetcherStubURLProtocol.self) - defer { - if registered { - URLProtocol.unregisterClass(AlibabaUsageFetcherStubURLProtocol.self) - } - AlibabaUsageFetcherStubURLProtocol.handler = nil - } - - AlibabaUsageFetcherStubURLProtocol.handler = { request in + let transport = ProviderHTTPTransportHandler { request in guard let url = request.url else { throw URLError(.badURL) } - return Self.makeResponse(url: url, body: #"{"message":"unauthorized"}"#, statusCode: 401) + let (response, data) = Self.makeResponse( + url: url, + body: #"{"message":"unauthorized"}"#, + statusCode: 401) + return (data, response) } await #expect(throws: AlibabaCodingPlanUsageError.invalidCredentials) { _ = try await AlibabaCodingPlanUsageFetcher.fetchUsage( apiKey: "cpk-test", region: .chinaMainland, - environment: [AlibabaCodingPlanSettingsReader.quotaURLKey: "https://alibaba-api.test/data/api.json"]) + environment: [ + AlibabaCodingPlanSettingsReader.quotaURLKey: "https://bailian.console.aliyun.com/data/api.json", + ], + transport: transport) } } @@ -756,7 +907,7 @@ struct AlibabaCodingPlanUsageFetcherRequestTests { AlibabaConsoleSECTokenStubURLProtocol.handler = { request in guard let url = request.url else { throw URLError(.badURL) } - #expect(url.host == "alibaba-proxy.test") + #expect(url.host == "modelstudio.console.alibabacloud.com") if request.httpMethod == "GET", url.path == AlibabaCodingPlanAPIRegion.international.dashboardURL.path { return Self.makeResponse(url: url, body: "", statusCode: 200) @@ -796,7 +947,7 @@ struct AlibabaCodingPlanUsageFetcherRequestTests { let snapshot = try await AlibabaCodingPlanUsageFetcher.fetchUsage( cookieHeader: "sec_token=cookie-sec-token; login_aliyunid_ticket=ticket; login_aliyunid_pk=user", region: .international, - environment: [AlibabaCodingPlanSettingsReader.hostKey: "https://alibaba-proxy.test"], + environment: [AlibabaCodingPlanSettingsReader.hostKey: "https://modelstudio.console.alibabacloud.com"], now: Date(timeIntervalSince1970: 1_700_000_000)) #expect(snapshot.planName == "Alibaba Coding Plan Pro") @@ -919,10 +1070,14 @@ struct AlibabaCodingPlanUsageFetcherRequestTests { } final class AlibabaUsageFetcherStubURLProtocol: URLProtocol { - nonisolated(unsafe) static var handler: ((URLRequest) throws -> (HTTPURLResponse, Data))? + private static let _handlerBox = LockIsolated<((URLRequest) throws -> (HTTPURLResponse, Data))?>(nil) + static var handler: ((URLRequest) throws -> (HTTPURLResponse, Data))? { + get { Self._handlerBox.value } + set { Self._handlerBox.setValue(newValue) } + } override static func canInit(with request: URLRequest) -> Bool { - request.url?.host == "alibaba-api.test" + request.url?.host == "bailian.console.aliyun.com" } override static func canonicalRequest(for request: URLRequest) -> URLRequest { @@ -949,12 +1104,15 @@ final class AlibabaUsageFetcherStubURLProtocol: URLProtocol { } final class AlibabaConsoleSECTokenStubURLProtocol: URLProtocol { - nonisolated(unsafe) static var handler: ((URLRequest) throws -> (HTTPURLResponse, Data))? + private static let _handlerBox = LockIsolated<((URLRequest) throws -> (HTTPURLResponse, Data))?>(nil) + static var handler: ((URLRequest) throws -> (HTTPURLResponse, Data))? { + get { Self._handlerBox.value } + set { Self._handlerBox.setValue(newValue) } + } override static func canInit(with request: URLRequest) -> Bool { guard let host = request.url?.host else { return false } return [ - "alibaba-proxy.test", "modelstudio.console.alibabacloud.com", "bailian-singapore-cs.alibabacloud.com", "bailian.console.aliyun.com", diff --git a/Tests/CodexBarTests/AlibabaTokenPlanDashboardActionTests.swift b/Tests/CodexBarTests/AlibabaTokenPlanDashboardActionTests.swift new file mode 100644 index 0000000000..e9401ffb03 --- /dev/null +++ b/Tests/CodexBarTests/AlibabaTokenPlanDashboardActionTests.swift @@ -0,0 +1,27 @@ +import CodexBarCore +import Testing +@testable import CodexBar + +@MainActor +@Suite(.serialized) +struct AlibabaTokenPlanDashboardActionTests { + @Test + func `dashboard action follows selected region`() { + let settings = testSettingsStore(suiteName: "AlibabaTokenPlanDashboardActionTests") + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + settings.alibabaTokenPlanAPIRegion = .chinaMainland + + let fetcher = UsageFetcher() + let store = UsageStore( + fetcher: fetcher, + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings) + + withStatusItemControllerForTesting(store: store, settings: settings, fetcher: fetcher) { controller in + #expect(controller.dashboardURL(for: .alibabatokenplan) == + AlibabaTokenPlanAPIRegion.chinaMainland.dashboardURL) + } + } +} diff --git a/Tests/CodexBarTests/AlibabaTokenPlanMenuCardModelTests.swift b/Tests/CodexBarTests/AlibabaTokenPlanMenuCardModelTests.swift new file mode 100644 index 0000000000..cd8b078c50 --- /dev/null +++ b/Tests/CodexBarTests/AlibabaTokenPlanMenuCardModelTests.swift @@ -0,0 +1,87 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +struct AlibabaTokenPlanMenuCardModelTests { + @Test + func `Personal rolling windows use duration labels`() throws { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let snapshot = AlibabaTokenPlanUsageSnapshot( + planName: "Pro", + usedQuota: nil, + totalQuota: nil, + remainingQuota: nil, + resetsAt: nil, + fiveHourUsedPercent: 0, + weeklyUsedPercent: 10, + updatedAt: now) + .toUsageSnapshot() + let metadata = try #require(ProviderDefaults.metadata[.alibabatokenplan]) + + let model = UsageMenuCardView.Model.make(.init( + provider: .alibabatokenplan, + metadata: metadata, + snapshot: snapshot, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: true, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + + #expect(model.metrics.map(\.title) == ["5-hour", "7-day"]) + } + + @Test + func `monthly quota shows deficit and run out details`() throws { + let now = Date(timeIntervalSince1970: 10_368_000) // 1970-05-01T00:00:00Z + let snapshot = AlibabaTokenPlanUsageSnapshot( + planName: "TOKEN PLAN", + usedQuota: 900, + totalQuota: 1000, + remainingQuota: nil, + resetsAt: now.addingTimeInterval(6 * 24 * 3600), + updatedAt: now) + .toUsageSnapshot() + let metadata = try #require(ProviderDefaults.metadata[.alibabatokenplan]) + + let model = UsageMenuCardView.Model.make(.init( + provider: .alibabatokenplan, + metadata: metadata, + snapshot: snapshot, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + + #expect(model.metrics.map(\.title) == ["Credits"]) + let monthly = try #require(model.metrics.first { $0.id == "primary" }) + #expect(monthly.percentLabel == "10% left") + #expect(monthly.detailText == "900 / 1,000 credits used") + #expect(monthly.detailLeftText == "10% in deficit") + #expect(monthly.detailRightText == "Runs out in 2d 16h") + #expect(monthly.pacePercent == 20) + #expect(monthly.paceOnTop == false) + } +} diff --git a/Tests/CodexBarTests/AlibabaTokenPlanProviderTests.swift b/Tests/CodexBarTests/AlibabaTokenPlanProviderTests.swift index 4916dd0e5f..30b0e4f3e8 100644 --- a/Tests/CodexBarTests/AlibabaTokenPlanProviderTests.swift +++ b/Tests/CodexBarTests/AlibabaTokenPlanProviderTests.swift @@ -2,6 +2,14 @@ import Foundation import Testing @testable import CodexBarCore +private func alibabaTokenPlanFixture(_ name: String) throws -> Data { + try Data( + contentsOf: #require(Bundle.module.url( + forResource: name, + withExtension: "json", + subdirectory: "Fixtures/AlibabaTokenPlan"))) +} + struct AlibabaTokenPlanSettingsReaderTests { @Test func `cookie reads from environment`() { @@ -47,22 +55,69 @@ struct AlibabaTokenPlanSettingsReaderTests { ]) #expect(httpHost == nil) - #expect(httpsHost == "https://dashboard.token-plan.test") + #expect(httpsHost == "dashboard.token-plan.test") #expect(bareHost == "dashboard.token-plan.test") } @Test func `default quota URL targets subscription summary API`() { let url = AlibabaTokenPlanUsageFetcher.defaultQuotaURL + #expect(url.host == "modelstudio.console.alibabacloud.com") + #expect(url.absoluteString.contains("GetSubscriptionSummary")) + #expect(url.absoluteString.contains("BssOpenAPI-V3")) + } + + @Test + func `default quota URL for china mainland targets bailian`() { + let url = AlibabaTokenPlanUsageFetcher.defaultQuotaURL(region: .chinaMainland) #expect(url.host == "bailian.console.aliyun.com") #expect(url.absoluteString.contains("GetSubscriptionSummary")) #expect(url.absoluteString.contains("BssOpenAPI-V3")) } + + @Test + func `personal variants target their rolling window hosts without changing Team routes`() { + let internationalTeam = AlibabaTokenPlanUsageFetcher.defaultQuotaURL(region: .international) + let mainlandTeam = AlibabaTokenPlanUsageFetcher.defaultQuotaURL(region: .chinaMainland) + let internationalPersonal = AlibabaTokenPlanUsageFetcher.defaultQuotaURL(region: .internationalPersonal) + let mainlandPersonal = AlibabaTokenPlanUsageFetcher.defaultQuotaURL(region: .chinaMainlandPersonal) + + #expect(internationalTeam.host == "modelstudio.console.alibabacloud.com") + #expect(mainlandTeam.host == "bailian.console.aliyun.com") + #expect(internationalTeam.absoluteString.contains("GetSubscriptionSummary")) + #expect(mainlandTeam.absoluteString.contains("GetSubscriptionSummary")) + #expect(internationalPersonal.host == "bailian-singapore-cs.alibabacloud.com") + #expect(mainlandPersonal.host == "bailian-cs.console.aliyun.com") + #expect(internationalPersonal.absoluteString.removingPercentEncoding?.contains("personal/api/v2/usage") == true) + #expect(mainlandPersonal.absoluteString.removingPercentEncoding?.contains("personal/api/v2/usage") == true) + #expect(!internationalPersonal.absoluteString.contains("GetSubscriptionSummary")) + #expect(!mainlandPersonal.absoluteString.contains("GetSubscriptionSummary")) + } } struct AlibabaTokenPlanCookieHeaderTests { @Test func `builds URL scoped headers for API and dashboard`() throws { + let cookies = [ + self.cookie(name: "login_aliyunid_ticket", value: "ticket", domain: ".alibabacloud.com"), + self.cookie(name: "login_current_pk", value: "account", domain: ".alibabacloud.com"), + self.cookie(name: "sec_token", value: "shared", domain: ".console.alibabacloud.com"), + self.cookie(name: "sec_token", value: "dashboard", domain: "modelstudio.console.alibabacloud.com"), + self.cookie(name: "bailian_only", value: "bailian", domain: "bailian.console.aliyun.com"), + ] + + let headers = try #require(AlibabaTokenPlanCookieHeader.headers(from: cookies)) + + #expect(headers.apiCookieHeader.contains("login_aliyunid_ticket=ticket")) + #expect(headers.apiCookieHeader.contains("login_current_pk=account")) + #expect(headers.apiCookieHeader.contains("sec_token=dashboard")) + #expect(!headers.apiCookieHeader.contains("bailian_only=bailian")) + #expect(headers.dashboardCookieHeader.contains("sec_token=dashboard")) + #expect(!headers.dashboardCookieHeader.contains("bailian_only=bailian")) + } + + @Test + func `builds URL scoped headers for china mainland region`() throws { let cookies = [ self.cookie(name: "login_aliyunid_ticket", value: "ticket", domain: ".aliyun.com"), self.cookie(name: "login_current_pk", value: "account", domain: ".aliyun.com"), @@ -71,7 +126,7 @@ struct AlibabaTokenPlanCookieHeaderTests { self.cookie(name: "modelstudio_only", value: "modelstudio", domain: "modelstudio.console.alibabacloud.com"), ] - let headers = try #require(AlibabaTokenPlanCookieHeader.headers(from: cookies)) + let headers = try #require(AlibabaTokenPlanCookieHeader.headers(from: cookies, region: .chinaMainland)) #expect(headers.apiCookieHeader.contains("login_aliyunid_ticket=ticket")) #expect(headers.apiCookieHeader.contains("login_current_pk=account")) @@ -81,13 +136,64 @@ struct AlibabaTokenPlanCookieHeaderTests { #expect(!headers.dashboardCookieHeader.contains("modelstudio_only=modelstudio")) } + @Test + func `mainland Personal rebuilds cookies for the quota host`() throws { + let cookies = [ + self.cookie(name: "parent", value: "shared", domain: ".console.aliyun.com"), + self.cookie(name: "dashboard_only", value: "dashboard", domain: "bailian.console.aliyun.com"), + self.cookie(name: "quota_only", value: "quota", domain: "bailian-cs.console.aliyun.com"), + self.cookie(name: "collision", value: "dashboard", domain: "bailian.console.aliyun.com"), + self.cookie(name: "collision", value: "quota", domain: "bailian-cs.console.aliyun.com"), + ] + + let headers = try #require(AlibabaTokenPlanCookieHeader.headers( + from: cookies, + region: .chinaMainlandPersonal)) + + #expect(headers.apiCookieHeader.contains("parent=shared")) + #expect(headers.apiCookieHeader.contains("quota_only=quota")) + #expect(headers.apiCookieHeader.contains("collision=quota")) + #expect(!headers.apiCookieHeader.contains("dashboard_only=dashboard")) + #expect(headers.dashboardCookieHeader.contains("parent=shared")) + #expect(headers.dashboardCookieHeader.contains("dashboard_only=dashboard")) + #expect(headers.dashboardCookieHeader.contains("collision=dashboard")) + #expect(!headers.dashboardCookieHeader.contains("quota_only=quota")) + } + + @Test + func `international Personal rebuilds cookies for the quota host`() throws { + let cookies = [ + self.cookie(name: "parent", value: "shared", domain: ".alibabacloud.com"), + self.cookie( + name: "dashboard_only", + value: "dashboard", + domain: "modelstudio.console.alibabacloud.com"), + self.cookie( + name: "quota_only", + value: "quota", + domain: "bailian-singapore-cs.alibabacloud.com"), + ] + + let headers = try #require(AlibabaTokenPlanCookieHeader.headers( + from: cookies, + region: .internationalPersonal)) + + #expect(headers.apiCookieHeader.contains("parent=shared")) + #expect(headers.apiCookieHeader.contains("quota_only=quota")) + #expect(!headers.apiCookieHeader.contains("dashboard_only=dashboard")) + #expect(headers.dashboardCookieHeader.contains("parent=shared")) + #expect(headers.dashboardCookieHeader.contains("dashboard_only=dashboard")) + #expect(!headers.dashboardCookieHeader.contains("quota_only=quota")) + } + @Test func `cached token plan headers preserve URL scoping`() throws { let headers = AlibabaTokenPlanCookieHeaders( apiCookieHeader: "login_aliyunid_ticket=ticket; api_only=api", dashboardCookieHeader: "login_aliyunid_ticket=ticket; dashboard_only=dashboard") - let cached = try #require(AlibabaTokenPlanCookieHeaders(cachedHeader: headers.cacheCookieHeader)) + let cached = try #require( + AlibabaTokenPlanCookieHeaders(alibabaTokenPlanCachedHeader: headers.cacheAlibabaTokenPlanCookieHeader())) #expect(cached.apiCookieHeader.contains("api_only=api")) #expect(!cached.apiCookieHeader.contains("dashboard_only=dashboard")) @@ -101,8 +207,11 @@ struct AlibabaTokenPlanCookieHeaderTests { self.cookie(name: "login_aliyunid_ticket", value: "ticket", domain: ".token-plan.test"), self.cookie(name: "api_only", value: "api", domain: "quota.token-plan.test"), self.cookie(name: "dashboard_only", value: "dashboard", domain: "dashboard.token-plan.test"), - self.cookie(name: "prod_api_only", value: "prod-api", domain: "bailian.console.aliyun.com"), - self.cookie(name: "prod_dashboard_only", value: "prod-dashboard", domain: "bailian.console.aliyun.com"), + self.cookie(name: "prod_api_only", value: "prod-api", domain: "modelstudio.console.alibabacloud.com"), + self.cookie( + name: "prod_dashboard_only", + value: "prod-dashboard", + domain: "modelstudio.console.alibabacloud.com"), ] let headers = try #require(AlibabaTokenPlanCookieHeader.headers( @@ -173,10 +282,106 @@ struct AlibabaTokenPlanUsageSnapshotTests { #expect(usage.primary == nil) #expect(usage.loginMethod(for: .alibabatokenplan) == "TOKEN PLAN") } + + @Test + func `emits a weekly window when 5 hour usage is absent`() { + let snapshot = AlibabaTokenPlanUsageSnapshot( + planName: "Personal", + usedQuota: nil, + totalQuota: nil, + remainingQuota: nil, + resetsAt: nil, + weeklyUsedPercent: 10, + weeklyTotalQuota: 40000, + weeklyResetsAt: Date(timeIntervalSince1970: 1_785_234_900), + updatedAt: Date(timeIntervalSince1970: 1_700_000_000)) + + let usage = snapshot.toUsageSnapshot() + + #expect(usage.primary == nil) + #expect(usage.secondary?.usedPercent == 10) + #expect(usage.secondary?.windowMinutes == 7 * 24 * 60) + #expect(usage.secondary?.resetDescription == "4,000 / 40,000 credits used") + } } @Suite(.serialized) struct AlibabaTokenPlanUsageParsingTests { + @Test + func `shared Personal parser maps issue documented rolling windows and tier quotas`() throws { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let snapshot = try AlibabaTokenPlanPersonalUsageParser.parse( + from: alibabaTokenPlanFixture("personal_usage"), + subscriptionData: alibabaTokenPlanFixture("personal_subscription"), + quotaConfigData: alibabaTokenPlanFixture("personal_quota_config"), + now: now) + let usage = snapshot.toUsageSnapshot() + + #expect(snapshot.planName == "Pro") + #expect(snapshot.fiveHourTotalQuota == 12000) + #expect(snapshot.weeklyTotalQuota == 40000) + #expect(abs((usage.primary?.usedPercent ?? -.infinity) - 0.09973083333333333) < 0.000_000_001) + #expect(usage.primary?.windowMinutes == 5 * 60) + #expect(usage.primary?.resetsAt == Date(timeIntervalSince1970: 1_784_813_220)) + #expect(abs((usage.secondary?.usedPercent ?? -.infinity) - 0.03014725) < 0.000_000_001) + #expect(usage.secondary?.windowMinutes == 7 * 24 * 60) + #expect(usage.secondary?.resetsAt == Date(timeIntervalSince1970: 1_785_234_900)) + #expect(usage.loginMethod(for: .alibabatokenplan) == "Pro") + } + + @Test + func `shared Personal parser accepts weekly only responses`() throws { + let json = """ + { + "data": { + "DataV2": { + "data": { + "success": true, + "data": { + "per1WeekPercentage": 0.10007527475, + "per1WeekResetTime": 1785234900000 + } + } + } + }, + "successResponse": true + } + """ + + let snapshot = try AlibabaTokenPlanPersonalUsageParser.parse( + from: Data(json.utf8), + subscriptionData: nil, + quotaConfigData: nil, + now: Date(timeIntervalSince1970: 1_700_000_000)) + let usage = snapshot.toUsageSnapshot() + + #expect(usage.primary == nil) + #expect(abs((usage.secondary?.usedPercent ?? -.infinity) - 10.007527475) < 0.000_000_001) + #expect(usage.secondary?.resetsAt == Date(timeIntervalSince1970: 1_785_234_900)) + } + + @Test + func `Personal login error maps to login required`() { + let json = """ + { + "data": { + "success": false, + "errorCode": "BailianGateway.Login.NotLogined", + "errorMsg": "BailianGateway.Login.NotLogined" + }, + "httpStatusCode": "200" + } + """ + + #expect(throws: AlibabaTokenPlanUsageError.loginRequired) { + try AlibabaTokenPlanPersonalUsageParser.parse( + from: Data(json.utf8), + subscriptionData: nil, + quotaConfigData: nil, + now: Date(timeIntervalSince1970: 1_700_000_000)) + } + } + @Test func `parses subscription summary payload`() throws { let now = Date(timeIntervalSince1970: 1_700_000_000) @@ -260,6 +465,21 @@ struct AlibabaTokenPlanUsageParsingTests { } } + @Test + func `post only token payload maps to login required`() { + let json = """ + { + "code": "PostonlyOrTokenError", + "message": "Your request has expired. Please refresh the page.", + "successResponse": false + } + """ + + #expect(throws: AlibabaTokenPlanUsageError.loginRequired) { + try AlibabaTokenPlanUsageFetcher.parseUsageSnapshot(from: Data(json.utf8)) + } + } + @Test func `nested unsuccessful subscription summary maps to API error`() throws { let body = """ @@ -290,6 +510,21 @@ struct AlibabaTokenPlanUsageParsingTests { } } + @Test + func `failed forbidden payload maps to invalid credentials`() { + let json = """ + { + "successResponse": false, + "statusCode": 403, + "message": "Forbidden" + } + """ + + #expect(throws: AlibabaTokenPlanUsageError.invalidCredentials) { + try AlibabaTokenPlanUsageFetcher.parseUsageSnapshot(from: Data(json.utf8)) + } + } + @Test func `html login payload maps to login required`() { let html = """ @@ -311,29 +546,112 @@ struct AlibabaTokenPlanUsageParsingTests { } @Test - func `cookie only request continues without SEC token`() async throws { + func `mainland Personal fetch uses quota host cookies without requiring SEC token`() async throws { + defer { + AlibabaTokenPlanStubURLProtocol.handler = nil + } + let usageBody = try #require(String(data: alibabaTokenPlanFixture("personal_usage"), encoding: .utf8)) + let subscriptionBody = try #require( + String(data: alibabaTokenPlanFixture("personal_subscription"), encoding: .utf8)) + let quotaBody = try #require( + String(data: alibabaTokenPlanFixture("personal_quota_config"), encoding: .utf8)) + + AlibabaTokenPlanStubURLProtocol.handler = { request in + guard let url = request.url else { throw URLError(.badURL) } + #expect(url.host == "bailian-cs.console.aliyun.com") + #expect(request.httpMethod == "POST") + #expect(request.value(forHTTPHeaderField: "Cookie") == "quota_only=quota") + #expect(request.value(forHTTPHeaderField: "Origin") == "https://bailian.console.aliyun.com") + let body = Self.requestBodyString(from: request) + #expect(!body.contains("sec_token")) + #expect(body.removingPercentEncoding?.contains("cornerstoneParam") == true) + + let api = URLComponents(url: url, resolvingAgainstBaseURL: false)? + .queryItems? + .first(where: { $0.name == "api" })? + .value + switch api { + case "zeldaHttp.apikeyMgr./tokenplan/personal/api/v2/usage": + return Self.makeResponse(url: url, body: usageBody, statusCode: 200) + case "zeldaHttp.apikeyMgr./tokenplan/personal/api/v2/subscription": + #expect(body.removingPercentEncoding?.contains("sfm_tokenplansolo_public_cn") == true) + return Self.makeResponse(url: url, body: subscriptionBody, statusCode: 200) + case "zeldaHttp.apikeyMgr./tokenplan/personal/api/v2/quota-config": + return Self.makeResponse(url: url, body: quotaBody, statusCode: 200) + default: + throw URLError(.unsupportedURL) + } + } + + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [AlibabaTokenPlanStubURLProtocol.self] + let session = URLSession(configuration: configuration) + let snapshot = try await AlibabaTokenPlanUsageFetcher.fetchUsage( + apiCookieHeader: "quota_only=quota", + dashboardCookieHeader: "dashboard_only=dashboard", + region: .chinaMainlandPersonal, + environment: [:], + session: session) + + #expect(snapshot.planName == "Pro") + #expect(snapshot.toUsageSnapshot().primary != nil) + #expect(snapshot.toUsageSnapshot().secondary != nil) + } + + @Test + func `SEC token preflight falls back to user info`() async throws { defer { AlibabaTokenPlanStubURLProtocol.handler = nil } + let hostOverride = "https://alibaba-token-plan.test:9443" + let environment: [String: String] = [ + AlibabaTokenPlanSettingsReader.hostKey: hostOverride, + ] + let expectedReferer = AlibabaTokenPlanUsageFetcher.dashboardURL( + region: .international, + environment: environment).absoluteString + AlibabaTokenPlanStubURLProtocol.handler = { request in guard let url = request.url else { throw URLError(.badURL) } - if url.host == "alibaba-token-plan.test", request.httpMethod == "GET" { + if url.host == "alibaba-token-plan.test", + url.path == "/ap-southeast-1/", + request.httpMethod == "GET" + { + #expect(url.port == 9443) return Self.makeResponse(url: url, body: "", statusCode: 200) } + if url.host == "alibaba-token-plan.test", + url.path == "/tool/user/info.json", + request.httpMethod == "GET" + { + #expect(url.port == 9443) + #expect(request.value(forHTTPHeaderField: "Cookie") == "login_aliyunid_ticket=ticket; raw_only=keep") + #expect(request.value(forHTTPHeaderField: "Accept") == "application/json, text/plain, */*") + let json = """ + { + "code": "200", + "data": { + "secToken": "user-info-token" + }, + "successResponse": true + } + """ + return Self.makeResponse(url: url, body: json, statusCode: 200) + } + if url.host == "alibaba-token-plan.test", request.httpMethod == "POST" { #expect(request.value(forHTTPHeaderField: "Cookie") == "login_aliyunid_ticket=ticket; raw_only=keep") - #expect(request.value(forHTTPHeaderField: "Origin") == "https://bailian.console.aliyun.com") - #expect(request.value(forHTTPHeaderField: "Referer") == AlibabaTokenPlanUsageFetcher.dashboardURL - .absoluteString) + #expect(request.value(forHTTPHeaderField: "Origin") == "https://modelstudio.console.alibabacloud.com") + #expect(request.value(forHTTPHeaderField: "Referer") == expectedReferer) let body = Self.requestBodyString(from: request) - #expect(!body.contains("sec_token=")) + #expect(body.contains("sec_token=user-info-token")) #expect(body.contains("GetSubscriptionSummary")) #expect(body.contains("BssOpenAPI-V3")) #expect(body.contains("ProductCode")) - #expect(body.contains("sfm_tokenplanteams_dp_cn")) + #expect(body.contains("sfm_tokenplanteams_dp_intl")) let json = """ { "Success": true, @@ -356,7 +674,7 @@ struct AlibabaTokenPlanUsageParsingTests { let snapshot = try await AlibabaTokenPlanUsageFetcher.fetchUsage( apiCookieHeader: "login_aliyunid_ticket=ticket; raw_only=keep", dashboardCookieHeader: "login_aliyunid_ticket=ticket; raw_only=keep", - environment: [AlibabaTokenPlanSettingsReader.hostKey: "https://alibaba-token-plan.test"], + environment: environment, session: session) #expect(snapshot.planName == "TOKEN PLAN") @@ -512,161 +830,294 @@ struct AlibabaTokenPlanWebStrategyTests { } } - @Test - func `auto web strategy surfaces cookie import errors`() async throws { - let strategy = AlibabaTokenPlanWebFetchStrategy() - let settings = ProviderSettingsSnapshot.make( - alibabaTokenPlan: ProviderSettingsSnapshot.AlibabaTokenPlanProviderSettings( - cookieSource: .auto, - manualCookieHeader: nil)) - let context = ProviderFetchContext( - runtime: .cli, - sourceMode: .web, - includeCredits: false, - webTimeout: 1, - webDebugDumpHTML: false, - verbose: false, - env: [:], - settings: settings, - fetcher: UsageFetcher(environment: [:]), - claudeFetcher: StubClaudeFetcher(), - browserDetection: BrowserDetection(cacheTTL: 0)) - + private func clearCookieCaches() { CookieHeaderCache.clear(provider: .alibabatokenplan) - AlibabaCodingPlanCookieImporter.importSessionOverrideForTesting = { _, _ in - throw AlibabaCodingPlanSettingsError.missingCookie( - details: "macOS Keychain denied access to Chrome Safe Storage.") + for region in AlibabaTokenPlanAPIRegion.allCases { + CookieHeaderCache.clear(provider: .alibabatokenplan, scope: region.cookieCacheScope) } - defer { - AlibabaCodingPlanCookieImporter.importSessionOverrideForTesting = nil + } + + @Test + func `auto web strategy surfaces cookie import errors`() async throws { + try await self.withIsolatedCookieCache { + let strategy = AlibabaTokenPlanWebFetchStrategy() + let settings = ProviderSettingsSnapshot.make( + alibabaTokenPlan: ProviderSettingsSnapshot.AlibabaTokenPlanProviderSettings( + cookieSource: .auto, + manualCookieHeader: nil)) + let context = ProviderFetchContext( + runtime: .cli, + sourceMode: .web, + includeCredits: false, + webTimeout: 1, + webDebugDumpHTML: false, + verbose: false, + env: [:], + settings: settings, + fetcher: UsageFetcher(environment: [:]), + claudeFetcher: StubClaudeFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0)) + + self.clearCookieCaches() + defer { self.clearCookieCaches() } + + try await AlibabaCodingPlanCookieImporter.withImportSessionOverrideForTesting { _, _ in + throw AlibabaCodingPlanSettingsError.missingCookie( + details: "macOS Keychain denied access to Chrome Safe Storage.") + } operation: { + #expect(await strategy.isAvailable(context)) + + do { + _ = try AlibabaTokenPlanWebFetchStrategy.resolveCookieHeader(context: context, allowCached: false) + Issue.record("Expected cookie import failure to be surfaced") + } catch let error as AlibabaTokenPlanSettingsError { + guard case let .missingCookie(details) = error else { + Issue.record("Expected missingCookie, got \(error)") + return + } + #expect(details == "macOS Keychain denied access to Chrome Safe Storage.") + #expect(error.localizedDescription.contains("Alibaba Token Plan")) + #expect(!error.localizedDescription.contains("Alibaba Coding Plan")) + } + } } + } - #expect(await strategy.isAvailable(context)) + @Test + func `auto web strategy imports subscription scoped token plan cookies`() throws { + try self.withIsolatedCookieCache { + let strategy = AlibabaTokenPlanWebFetchStrategy() + let settings = ProviderSettingsSnapshot.make( + alibabaTokenPlan: ProviderSettingsSnapshot.AlibabaTokenPlanProviderSettings( + cookieSource: .auto, + manualCookieHeader: nil)) + let context = ProviderFetchContext( + runtime: .cli, + sourceMode: .web, + includeCredits: false, + webTimeout: 1, + webDebugDumpHTML: false, + verbose: false, + env: [:], + settings: settings, + fetcher: UsageFetcher(environment: [:]), + claudeFetcher: StubClaudeFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0)) + + self.clearCookieCaches() + defer { self.clearCookieCaches() } + + let headers = try AlibabaCodingPlanCookieImporter.withImportSessionOverrideForTesting { _, _ in + AlibabaCodingPlanCookieImporter.SessionInfo( + cookies: [ + self.cookie(name: "login_aliyunid_ticket", value: "ticket", domain: ".alibabacloud.com"), + self.cookie(name: "login_current_pk", value: "account", domain: ".alibabacloud.com"), + self.cookie( + name: "dashboard_only", + value: "dashboard", + domain: "modelstudio.console.alibabacloud.com"), + self.cookie( + name: "bailian_only", + value: "bailian", + domain: "bailian.console.aliyun.com"), + self.cookie(name: "aliyun_only", value: "aliyun", domain: ".aliyun.com"), + ], + sourceLabel: "Chrome Default") + } operation: { + try AlibabaTokenPlanWebFetchStrategy.resolveCookieHeaders(context: context, allowCached: false) + } - do { - _ = try AlibabaTokenPlanWebFetchStrategy.resolveCookieHeader(context: context, allowCached: false) - Issue.record("Expected cookie import failure to be surfaced") - } catch let error as AlibabaTokenPlanSettingsError { - guard case let .missingCookie(details) = error else { - Issue.record("Expected missingCookie, got \(error)") - return + #expect(headers.apiCookieHeader == headers.dashboardCookieHeader) + #expect(headers.apiCookieHeader.contains("dashboard_only=dashboard")) + #expect(!headers.apiCookieHeader.contains("bailian_only=bailian")) + #expect(!headers.apiCookieHeader.contains("aliyun_only=aliyun")) + #expect(headers.dashboardCookieHeader.contains("dashboard_only=dashboard")) + #expect(!headers.dashboardCookieHeader.contains("bailian_only=bailian")) + #expect(!headers.dashboardCookieHeader.contains("aliyun_only=aliyun")) + + let cachedHeaders = try AlibabaCodingPlanCookieImporter.withImportSessionOverrideForTesting { _, _ in + throw AlibabaCodingPlanSettingsError.missingCookie(details: "unexpected import") + } operation: { + try AlibabaTokenPlanWebFetchStrategy.resolveCookieHeaders( + context: context, + allowCached: true) } - #expect(details == "macOS Keychain denied access to Chrome Safe Storage.") - #expect(error.localizedDescription.contains("Alibaba Token Plan")) - #expect(!error.localizedDescription.contains("Alibaba Coding Plan")) + #expect(cachedHeaders.apiCookieHeader == headers.apiCookieHeader) + #expect(cachedHeaders.dashboardCookieHeader == headers.dashboardCookieHeader) + #expect(strategy.id == "alibaba-token-plan.web") } } @Test - func `auto web strategy imports subscription scoped token plan cookies`() throws { - let strategy = AlibabaTokenPlanWebFetchStrategy() - let settings = ProviderSettingsSnapshot.make( - alibabaTokenPlan: ProviderSettingsSnapshot.AlibabaTokenPlanProviderSettings( - cookieSource: .auto, - manualCookieHeader: nil)) - let context = ProviderFetchContext( - runtime: .cli, - sourceMode: .web, - includeCredits: false, - webTimeout: 1, - webDebugDumpHTML: false, - verbose: false, - env: [:], - settings: settings, - fetcher: UsageFetcher(environment: [:]), - claudeFetcher: StubClaudeFetcher(), - browserDetection: BrowserDetection(cacheTTL: 0)) + func `auto web strategy scopes imported cookies to environment overrides`() throws { + try self.withIsolatedCookieCache { + let settings = ProviderSettingsSnapshot.make( + alibabaTokenPlan: ProviderSettingsSnapshot.AlibabaTokenPlanProviderSettings( + cookieSource: .auto, + manualCookieHeader: nil)) + let environment = [ + AlibabaTokenPlanSettingsReader.quotaURLKey: "https://quota.token-plan.test/data/api.json", + AlibabaTokenPlanSettingsReader.hostKey: "https://dashboard.token-plan.test", + ] + let context = ProviderFetchContext( + runtime: .cli, + sourceMode: .web, + includeCredits: false, + webTimeout: 1, + webDebugDumpHTML: false, + verbose: false, + env: environment, + settings: settings, + fetcher: UsageFetcher(environment: environment), + claudeFetcher: StubClaudeFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0)) + + self.clearCookieCaches() + defer { self.clearCookieCaches() } + + let headers = try AlibabaCodingPlanCookieImporter.withImportSessionOverrideForTesting { _, _ in + AlibabaCodingPlanCookieImporter.SessionInfo( + cookies: [ + self.cookie(name: "login_aliyunid_ticket", value: "ticket", domain: ".token-plan.test"), + self.cookie(name: "api_only", value: "api", domain: "quota.token-plan.test"), + self.cookie(name: "dashboard_only", value: "dashboard", domain: "dashboard.token-plan.test"), + self.cookie(name: "prod_api_only", value: "prod-api", domain: "bailian.console.aliyun.com"), + self.cookie( + name: "prod_dashboard_only", + value: "prod-dashboard", + domain: "bailian.console.aliyun.com"), + ], + sourceLabel: "Chrome Default") + } operation: { + try AlibabaTokenPlanWebFetchStrategy.resolveCookieHeaders(context: context, allowCached: false) + } - CookieHeaderCache.clear(provider: .alibabatokenplan) - AlibabaCodingPlanCookieImporter.importSessionOverrideForTesting = { _, _ in - AlibabaCodingPlanCookieImporter.SessionInfo( - cookies: [ - self.cookie(name: "login_aliyunid_ticket", value: "ticket", domain: ".aliyun.com"), - self.cookie(name: "login_current_pk", value: "account", domain: ".aliyun.com"), - self.cookie(name: "dashboard_only", value: "dashboard", domain: "bailian.console.aliyun.com"), - self.cookie( - name: "modelstudio_only", - value: "modelstudio", - domain: "modelstudio.console.alibabacloud.com"), - self.cookie(name: "alibabacloud_only", value: "cloud", domain: ".alibabacloud.com"), - ], - sourceLabel: "Chrome Default") + #expect(headers.apiCookieHeader.contains("api_only=api")) + #expect(!headers.apiCookieHeader.contains("prod_api_only=prod-api")) + #expect(headers.dashboardCookieHeader.contains("dashboard_only=dashboard")) + #expect(!headers.dashboardCookieHeader.contains("prod_dashboard_only=prod-dashboard")) } - defer { - AlibabaCodingPlanCookieImporter.importSessionOverrideForTesting = nil - CookieHeaderCache.clear(provider: .alibabatokenplan) + } + + @Test + func `cached browser cookies stay isolated by gateway region`() throws { + try self.withIsolatedCookieCache { + self.clearCookieCaches() + defer { self.clearCookieCaches() } + CookieHeaderCache.store( + provider: .alibabatokenplan, + scope: AlibabaTokenPlanAPIRegion.international.cookieCacheScope, + cookieHeader: "login_aliyunid_ticket=intl-ticket; gateway=intl", + sourceLabel: "International fixture") + CookieHeaderCache.store( + provider: .alibabatokenplan, + scope: AlibabaTokenPlanAPIRegion.chinaMainland.cookieCacheScope, + cookieHeader: "login_aliyunid_ticket=cn-ticket; gateway=cn", + sourceLabel: "China fixture") + try AlibabaCodingPlanCookieImporter.withImportSessionOverrideForTesting { _, _ in + throw AlibabaCodingPlanSettingsError.missingCookie(details: "unexpected import") + } operation: { + let context = self.context(region: .international) + let international = try AlibabaTokenPlanWebFetchStrategy.resolveCookieHeaders( + context: context, + allowCached: true, + region: .international) + let china = try AlibabaTokenPlanWebFetchStrategy.resolveCookieHeaders( + context: context, + allowCached: true, + region: .chinaMainland) + + #expect(international.apiCookieHeader.contains("gateway=intl")) + #expect(!international.apiCookieHeader.contains("gateway=cn")) + #expect(china.apiCookieHeader.contains("gateway=cn")) + #expect(!china.apiCookieHeader.contains("gateway=intl")) + } } + } - let headers = try AlibabaTokenPlanWebFetchStrategy.resolveCookieHeaders(context: context, allowCached: false) + @Test + func `legacy unscoped cache migrates only to China gateway`() throws { + try self.withIsolatedCookieCache { + self.clearCookieCaches() + defer { self.clearCookieCaches() } + CookieHeaderCache.store( + provider: .alibabatokenplan, + cookieHeader: "login_aliyunid_ticket=legacy; gateway=legacy-cn", + sourceLabel: "Legacy fixture") + let context = self.context(region: .international) + let international = try AlibabaCodingPlanCookieImporter.withImportSessionOverrideForTesting { _, _ in + AlibabaCodingPlanCookieImporter.SessionInfo( + cookies: [ + self.cookie( + name: "login_aliyunid_ticket", + value: "intl", + domain: ".alibabacloud.com"), + self.cookie( + name: "gateway", + value: "intl", + domain: "modelstudio.console.alibabacloud.com"), + ], + sourceLabel: "International fixture") + } operation: { + try AlibabaTokenPlanWebFetchStrategy.resolveCookieHeaders( + context: context, + allowCached: true, + region: .international) + } - #expect(headers.apiCookieHeader == headers.dashboardCookieHeader) - #expect(headers.apiCookieHeader.contains("dashboard_only=dashboard")) - #expect(!headers.apiCookieHeader.contains("modelstudio_only=modelstudio")) - #expect(!headers.apiCookieHeader.contains("alibabacloud_only=cloud")) - #expect(headers.dashboardCookieHeader.contains("dashboard_only=dashboard")) - #expect(!headers.dashboardCookieHeader.contains("modelstudio_only=modelstudio")) - #expect(!headers.dashboardCookieHeader.contains("alibabacloud_only=cloud")) + let china = try AlibabaCodingPlanCookieImporter.withImportSessionOverrideForTesting { _, _ in + throw AlibabaCodingPlanSettingsError.missingCookie(details: "unexpected China import") + } operation: { + try AlibabaTokenPlanWebFetchStrategy.resolveCookieHeaders( + context: context, + allowCached: true, + region: .chinaMainland) + } - AlibabaCodingPlanCookieImporter.importSessionOverrideForTesting = { _, _ in - throw AlibabaCodingPlanSettingsError.missingCookie(details: "unexpected import") + #expect(international.apiCookieHeader.contains("gateway=intl")) + #expect(!international.apiCookieHeader.contains("gateway=legacy-cn")) + #expect(china.apiCookieHeader.contains("gateway=legacy-cn")) + #expect(CookieHeaderCache.load(provider: .alibabatokenplan) == nil) + #expect(CookieHeaderCache.load( + provider: .alibabatokenplan, + scope: AlibabaTokenPlanAPIRegion.chinaMainland.cookieCacheScope) != nil) } - let cachedHeaders = try AlibabaTokenPlanWebFetchStrategy.resolveCookieHeaders( - context: context, - allowCached: true) - #expect(cachedHeaders.apiCookieHeader == headers.apiCookieHeader) - #expect(cachedHeaders.dashboardCookieHeader == headers.dashboardCookieHeader) - #expect(strategy.id == "alibaba-token-plan.web") } - @Test - func `auto web strategy scopes imported cookies to environment overrides`() throws { + private func withIsolatedCookieCache(_ operation: () throws -> T) rethrows -> T { + try KeychainCacheStore.withServiceOverrideForTesting( + "alibaba-token-plan-web-strategy-tests-\(UUID().uuidString)", + operation: { + try KeychainCacheStore.withImplicitTestStoreForTesting(operation: operation) + }) + } + + private func withIsolatedCookieCache(_ operation: () async throws -> T) async rethrows -> T { + try await KeychainCacheStore.withServiceOverrideForTesting( + "alibaba-token-plan-web-strategy-tests-\(UUID().uuidString)", + operation: { + try await KeychainCacheStore.withImplicitTestStoreForTesting(operation: operation) + }) + } + + private func context(region: AlibabaTokenPlanAPIRegion) -> ProviderFetchContext { let settings = ProviderSettingsSnapshot.make( alibabaTokenPlan: ProviderSettingsSnapshot.AlibabaTokenPlanProviderSettings( cookieSource: .auto, - manualCookieHeader: nil)) - let environment = [ - AlibabaTokenPlanSettingsReader.quotaURLKey: "https://quota.token-plan.test/data/api.json", - AlibabaTokenPlanSettingsReader.hostKey: "https://dashboard.token-plan.test", - ] - let context = ProviderFetchContext( + manualCookieHeader: nil, + apiRegion: region)) + return ProviderFetchContext( runtime: .cli, sourceMode: .web, includeCredits: false, webTimeout: 1, webDebugDumpHTML: false, verbose: false, - env: environment, + env: [:], settings: settings, - fetcher: UsageFetcher(environment: environment), + fetcher: UsageFetcher(environment: [:]), claudeFetcher: StubClaudeFetcher(), browserDetection: BrowserDetection(cacheTTL: 0)) - - CookieHeaderCache.clear(provider: .alibabatokenplan) - AlibabaCodingPlanCookieImporter.importSessionOverrideForTesting = { _, _ in - AlibabaCodingPlanCookieImporter.SessionInfo( - cookies: [ - self.cookie(name: "login_aliyunid_ticket", value: "ticket", domain: ".token-plan.test"), - self.cookie(name: "api_only", value: "api", domain: "quota.token-plan.test"), - self.cookie(name: "dashboard_only", value: "dashboard", domain: "dashboard.token-plan.test"), - self.cookie(name: "prod_api_only", value: "prod-api", domain: "bailian.console.aliyun.com"), - self.cookie( - name: "prod_dashboard_only", - value: "prod-dashboard", - domain: "bailian.console.aliyun.com"), - ], - sourceLabel: "Chrome Default") - } - defer { - AlibabaCodingPlanCookieImporter.importSessionOverrideForTesting = nil - CookieHeaderCache.clear(provider: .alibabatokenplan) - } - - let headers = try AlibabaTokenPlanWebFetchStrategy.resolveCookieHeaders(context: context, allowCached: false) - - #expect(headers.apiCookieHeader.contains("api_only=api")) - #expect(!headers.apiCookieHeader.contains("prod_api_only=prod-api")) - #expect(headers.dashboardCookieHeader.contains("dashboard_only=dashboard")) - #expect(!headers.dashboardCookieHeader.contains("prod_dashboard_only=prod-dashboard")) } private func cookie( @@ -688,11 +1139,17 @@ struct AlibabaTokenPlanWebStrategyTests { } final class AlibabaTokenPlanStubURLProtocol: URLProtocol { - nonisolated(unsafe) static var handler: ((URLRequest) throws -> (HTTPURLResponse, Data))? + private static let _handlerBox = LockIsolated<((URLRequest) throws -> (HTTPURLResponse, Data))?>(nil) + static var handler: ((URLRequest) throws -> (HTTPURLResponse, Data))? { + get { Self._handlerBox.value } + set { Self._handlerBox.setValue(newValue) } + } override static func canInit(with request: URLRequest) -> Bool { guard let host = request.url?.host else { return false } return host == "bailian.console.aliyun.com" || + host == "bailian-cs.console.aliyun.com" || + host == "bailian-singapore-cs.alibabacloud.com" || host == "alibaba-token-plan.test" || host == "session-token.test" } diff --git a/Tests/CodexBarTests/AmpUsageFetcherTests.swift b/Tests/CodexBarTests/AmpUsageFetcherTests.swift index 135f58ee4f..d7e5c90e03 100644 --- a/Tests/CodexBarTests/AmpUsageFetcherTests.swift +++ b/Tests/CodexBarTests/AmpUsageFetcherTests.swift @@ -2,7 +2,109 @@ import Foundation import Testing @testable import CodexBarCore +@Suite(.serialized) struct AmpUsageFetcherTests { + private func makeContext( + sourceMode: ProviderSourceMode, + env: [String: String] = [:]) -> ProviderFetchContext + { + let browserDetection = BrowserDetection(cacheTTL: 0) + return ProviderFetchContext( + runtime: .app, + sourceMode: sourceMode, + includeCredits: true, + webTimeout: 1, + webDebugDumpHTML: false, + verbose: false, + env: env, + settings: nil, + fetcher: UsageFetcher(), + claudeFetcher: ClaudeUsageFetcher(browserDetection: browserDetection), + browserDetection: browserDetection) + } + + @Test + func `uses amp internal usage endpoint`() { + #expect( + AmpUsageFetcher.usageURL.absoluteString == + "https://ampcode.com/api/internal?userDisplayBalanceInfo") + } + + @Test + func `provider dashboard points to current usage page`() { + #expect(AmpProviderDescriptor.descriptor.metadata.dashboardURL == "https://ampcode.com/settings/usage") + } + + @Test + func `web fallback requires browser import or a manual session cookie`() { + let disabled = ProviderSettingsSnapshot.AmpProviderSettings(cookieSource: .off, manualCookieHeader: nil) + let invalidManual = ProviderSettingsSnapshot.AmpProviderSettings( + cookieSource: .manual, + manualCookieHeader: "other=value") + let validManual = ProviderSettingsSnapshot.AmpProviderSettings( + cookieSource: .manual, + manualCookieHeader: "session=test") + + #expect(AmpStatusFetchStrategy.canUseWebFallback( + settings: nil, + canImportBrowserCookies: false) == false) + #expect(AmpStatusFetchStrategy.canUseWebFallback( + settings: nil, + canImportBrowserCookies: true)) + #expect(AmpStatusFetchStrategy.canUseWebFallback( + settings: disabled, + canImportBrowserCookies: true) == false) + #expect(AmpStatusFetchStrategy.canUseWebFallback( + settings: invalidManual, + canImportBrowserCookies: false) == false) + #expect(AmpStatusFetchStrategy.canUseWebFallback( + settings: validManual, + canImportBrowserCookies: false)) + } + + @Test + func `cli cancellation does not fall back to web`() { + let strategy = AmpCLIFetchStrategy() + let context = self.makeContext(sourceMode: .auto) + + #expect(!strategy.shouldFallback(on: CancellationError(), context: context)) + #expect(!strategy.shouldFallback(on: URLError(.cancelled), context: context)) + #expect(strategy.shouldFallback(on: AmpUsageError.parseFailed("missing"), context: context)) + #expect(!strategy.shouldFallback( + on: AmpUsageError.parseFailed("missing"), + context: self.makeContext(sourceMode: .cli))) + } + + @Test + func `api request uses bearer token without cookies`() throws { + let request = try AmpUsageFetcher.makeUsageAPIRequest(apiToken: "sgamp_test") + + #expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer sgamp_test") + #expect(request.value(forHTTPHeaderField: "Cookie") == nil) + } + + @Test + func `api strategy falls back only from auto mode and preserves cancellation`() { + let strategy = AmpAPIFetchStrategy() + let auto = self.makeContext(sourceMode: .auto) + + #expect(strategy.shouldFallback(on: AmpUsageError.missingAPIToken, context: auto)) + #expect(strategy.shouldFallback(on: AmpUsageError.invalidAPIToken, context: auto)) + #expect(strategy.shouldFallback(on: URLError(.timedOut), context: auto)) + #expect(!strategy.shouldFallback(on: CancellationError(), context: auto)) + #expect(!strategy.shouldFallback(on: URLError(.cancelled), context: auto)) + #expect(!strategy.shouldFallback( + on: AmpUsageError.invalidAPIToken, + context: self.makeContext(sourceMode: .api))) + } + + @Test + func `amp config token resolves through environment`() { + let env = [AmpSettingsReader.apiTokenKey: " 'sgamp_test' "] + + #expect(ProviderTokenResolver.ampToken(environment: env) == "sgamp_test") + } + @Test func `attaches cookie for amp hosts`() { #expect(AmpUsageFetcher.shouldAttachCookie(to: URL(string: "https://ampcode.com/settings"))) @@ -17,11 +119,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)) @@ -30,6 +143,10 @@ struct AmpUsageFetcherTests { let signin = try #require(URL(string: "https://www.ampcode.com/signin")) #expect(AmpUsageFetcher.isLoginRedirect(signin)) + + let hostedAuth = try #require(URL( + string: "https://auth.ampcode.com/?client_id=test&redirect_uri=https%3A%2F%2Fampcode.com%2Fauth%2Fcallback")) + #expect(AmpUsageFetcher.isLoginRedirect(hostedAuth)) } @Test @@ -43,4 +160,146 @@ struct AmpUsageFetcherTests { let evil = try #require(URL(string: "https://ampcode.com.evil.com/auth/sign-in")) #expect(!AmpUsageFetcher.isLoginRedirect(evil)) } + + @Test + func `temporary API session is finished after a successful request`() async throws { + defer { AmpStubURLProtocol.handler = nil } + AmpStubURLProtocol.handler = { request in + let displayText = "Amp Free: $8/$10 remaining (replenishes +$0.5/hour)" + let data = try JSONSerialization.data(withJSONObject: [ + "ok": true, + "result": ["displayText": displayText], + ]) + return try Self.makeResponse(request: request, data: data) + } + let recorder = AmpSessionFinishRecorder() + let fetcher = self.makeFetcher(recorder: recorder) + + _ = try await fetcher.fetch(apiToken: "test") + + #expect(recorder.count == 1) + } + + @Test + func `temporary API session is finished after a transport failure`() async { + defer { AmpStubURLProtocol.handler = nil } + AmpStubURLProtocol.handler = { _ in throw URLError(.notConnectedToInternet) } + let recorder = AmpSessionFinishRecorder() + let fetcher = self.makeFetcher(recorder: recorder) + + await #expect(throws: URLError.self) { + _ = try await fetcher.fetch(apiToken: "test") + } + #expect(recorder.count == 1) + } + + @Test + func `temporary web session is finished after a successful request`() async throws { + defer { AmpStubURLProtocol.handler = nil } + AmpStubURLProtocol.handler = { request in + let html = """ + + """ + return try Self.makeResponse(request: request, data: Data(html.utf8)) + } + let recorder = AmpSessionFinishRecorder() + let fetcher = self.makeFetcher(recorder: recorder) + + _ = try await fetcher.fetch(cookieHeaderOverride: "session=test") + + #expect(recorder.count == 1) + } + + @Test + func `temporary web session is finished after a transport failure`() async { + defer { AmpStubURLProtocol.handler = nil } + AmpStubURLProtocol.handler = { _ in throw URLError(.notConnectedToInternet) } + let recorder = AmpSessionFinishRecorder() + let fetcher = self.makeFetcher(recorder: recorder) + + await #expect(throws: URLError.self) { + _ = try await fetcher.fetch(cookieHeaderOverride: "session=test") + } + #expect(recorder.count == 1) + } + + private func makeFetcher(recorder: AmpSessionFinishRecorder) -> AmpUsageFetcher { + AmpUsageFetcher( + browserDetection: BrowserDetection(cacheTTL: 0), + makeURLSession: { delegate in + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [AmpStubURLProtocol.self] + return URLSession(configuration: configuration, delegate: delegate, delegateQueue: nil) + }, + finishURLSession: { session in + recorder.record(session) + session.finishTasksAndInvalidate() + }) + } + + private static func makeResponse( + request: URLRequest, + data: Data, + statusCode: Int = 200) throws -> (HTTPURLResponse, Data) + { + let url = try #require(request.url) + let response = try #require(HTTPURLResponse( + url: url, + statusCode: statusCode, + httpVersion: "HTTP/1.1", + headerFields: ["Content-Type": "application/json"])) + return (response, data) + } +} + +private final class AmpSessionFinishRecorder: @unchecked Sendable { + private let lock = NSLock() + private var sessions: [URLSession] = [] + + var count: Int { + self.lock.withLock { self.sessions.count } + } + + func record(_ session: URLSession) { + self.lock.withLock { + self.sessions.append(session) + } + } +} + +private final class AmpStubURLProtocol: URLProtocol { + private static let _handlerBox = LockIsolated<((URLRequest) throws -> (HTTPURLResponse, Data))?>(nil) + static var handler: ((URLRequest) throws -> (HTTPURLResponse, Data))? { + get { Self._handlerBox.value } + set { Self._handlerBox.setValue(newValue) } + } + + override static func canInit(with request: URLRequest) -> Bool { + request.url?.host?.hasSuffix("ampcode.com") == true + } + + override static func canonicalRequest(for request: URLRequest) -> URLRequest { + request + } + + override func startLoading() { + guard let handler = Self.handler else { + self.client?.urlProtocol(self, didFailWithError: URLError(.badServerResponse)) + return + } + + do { + let (response, data) = try handler(self.request) + self.client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + self.client?.urlProtocol(self, didLoad: data) + self.client?.urlProtocolDidFinishLoading(self) + } catch { + self.client?.urlProtocol(self, didFailWithError: error) + } + } + + override func stopLoading() {} } diff --git a/Tests/CodexBarTests/AmpUsageParserTests.swift b/Tests/CodexBarTests/AmpUsageParserTests.swift index 380b06e49a..08ae83f933 100644 --- a/Tests/CodexBarTests/AmpUsageParserTests.swift +++ b/Tests/CodexBarTests/AmpUsageParserTests.swift @@ -3,6 +3,259 @@ import Testing @testable import CodexBarCore struct AmpUsageParserTests { + @Test + func `amp cli probe runs usage and parses balances`() async throws { + let script = """ + [ "$1" = "usage" ] || exit 2 + cat <<'EOF' + Signed in as cli@example.com (team) + Amp Free: $6/$10 remaining (replenishes +$0.5/hour) + Individual credits: $12.50 remaining + Workspace Test Team: $7.25 remaining + EOF + """ + + let now = Date(timeIntervalSince1970: 1_700_000_000) + let snapshot = try await AmpCLIProbe(arguments: ["-c", script, "amp", "usage"]).fetch( + environment: ["AMP_CLI_PATH": "/bin/sh"], + now: now) + + #expect(snapshot.freeUsed == 4) + #expect(snapshot.individualCredits == 12.5) + #expect(snapshot.workspaceBalances == [AmpWorkspaceBalance(name: "Test Team", remaining: 7.25)]) + #expect(snapshot.accountEmail == "cli@example.com") + #expect(snapshot.updatedAt == now) + } + + @Test + func `parses current amp usage display text`() throws { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let output = """ + \u{1B}[2mSigned in as ampcode@3kh0.net (echo)\u{1B}[0m + Amp Free: $4.71/$10 remaining (replenishes +$0.42/hour) - https://ampcode.com/settings#amp-free + Individual credits: $25.64 remaining (set up automatic top-up to avoid running out) - https://ampcode.com/settings + Workspace meow: $10.22 remaining (set up automatic top-up to avoid running out) - https://ampcode.com/workspaces/meow + """ + + let snapshot = try AmpUsageParser.parse(displayText: output, now: now) + + #expect(snapshot.freeQuota == 10) + #expect(try abs(#require(snapshot.freeUsed) - 5.29) < 0.001) + #expect(snapshot.hourlyReplenishment == 0.42) + #expect(snapshot.windowHours == 24) + #expect(snapshot.individualCredits == 25.64) + #expect(snapshot.workspaceBalances == [AmpWorkspaceBalance(name: "meow", remaining: 10.22)]) + #expect(snapshot.accountEmail == "ampcode@3kh0.net") + #expect(snapshot.accountOrganization == "echo") + #expect(snapshot.toUsageSnapshot(now: now).ampUsage == AmpUsageDetails( + individualCredits: 25.64, + workspaceBalances: [AmpWorkspaceBalance(name: "meow", remaining: 10.22)])) + + let encoded = try JSONEncoder().encode(snapshot.toUsageSnapshot(now: now)) + let decoded = try JSONDecoder().decode(UsageSnapshot.self, from: encoded) + #expect(decoded.ampUsage == AmpUsageDetails( + individualCredits: 25.64, + workspaceBalances: [AmpWorkspaceBalance(name: "meow", remaining: 10.22)])) + } + + @Test + func `parses percentage based amp free usage`() throws { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let output = """ + Signed in as user@example.com (example) + Amp Free: 61% remaining today (resets daily) - https://ampcode.com/settings#amp-free + Individual credits: $9.86 remaining (set up automatic top-up to avoid running out) + Workspace example: $5.33 remaining (set up automatic top-up to avoid running out) + """ + + let snapshot = try AmpUsageParser.parse(displayText: output, now: now) + let usage = snapshot.toUsageSnapshot(now: now) + + #expect(snapshot.freeQuota == 100) + #expect(snapshot.freeUsed == 39) + #expect(snapshot.hourlyReplenishment == 0) + #expect(snapshot.windowHours == 24) + #expect(snapshot.individualCredits == 9.86) + #expect(snapshot.workspaceBalances == [AmpWorkspaceBalance(name: "example", remaining: 5.33)]) + #expect(snapshot.accountEmail == "user@example.com") + #expect(snapshot.accountOrganization == "example") + #expect(usage.primary?.usedPercent == 39) + #expect(usage.primary?.windowMinutes == 1440) + #expect(usage.primary?.resetsAt == nil) + #expect(usage.primary?.resetDescription == "resets daily") + } + + @Test + func `parses amp subscription usage`() throws { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let output = """ + Signed in as user@example.com (username) + Subscription Megawatt: 97% other usage and 100% orb usage remaining - resets upon renewal in 29 days + """ + + let snapshot = try AmpUsageParser.parse(displayText: output, now: now) + let usage = snapshot.toUsageSnapshot(now: now) + + #expect(snapshot.subscription == AmpSubscriptionUsage( + plan: "Megawatt", + otherUsedPercent: 3, + orbUsedPercent: 0, + resetsAt: now.addingTimeInterval(29 * 24 * 60 * 60), + resetDescription: "renews in 29 days")) + #expect(usage.primary?.usedPercent == 3) + #expect(usage.secondary?.usedPercent == 0) + #expect(usage.primary?.windowMinutes == ProviderPaceCapability.monthlyWindowSentinelMinutes) + #expect(usage.secondary?.resetsAt == now.addingTimeInterval(29 * 24 * 60 * 60)) + #expect(usage.identity?.loginMethod == "Megawatt") + #expect(usage.ampUsage?.subscriptionPlan == "Megawatt") + #expect(AmpProviderDescriptor.primaryLabel(details: usage.ampUsage) == "Other usage") + #expect(AmpProviderDescriptor.secondaryLabel(details: usage.ampUsage) == "Orb usage") + } + + @Test + func `parses amp subscription usage with settings link`() throws { + let output = """ + Subscription Megawatt: 97% other usage and 100% orb usage remaining - resets upon renewal in 29 days \ + - https://ampcode.com/settings#subscription + """ + + let snapshot = try AmpUsageParser.parse(displayText: output) + + #expect(snapshot.subscription?.plan == "Megawatt") + #expect(snapshot.subscription?.otherUsedPercent == 3) + #expect(snapshot.subscription?.orbUsedPercent == 0) + } + + @Test + func `legacy amp free usage keeps replenishment reset when percentage text also exists`() throws { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let output = """ + Signed in as user@example.com + Amp Free: $6/$10 remaining (replenishes +$0.5/hour) + Amp Free: 61% remaining today (resets daily) + """ + + let snapshot = try AmpUsageParser.parse(displayText: output, now: now) + let usage = snapshot.toUsageSnapshot(now: now) + + #expect(snapshot.freeUsed == 4) + #expect(snapshot.freeResetDescription == nil) + #expect(usage.primary?.resetsAt == now.addingTimeInterval(8 * 3600)) + #expect(usage.primary?.resetDescription == nil) + } + + @Test + func `daily amp usage rejects cached rolling reset`() throws { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let legacy = try AmpUsageParser.parse( + displayText: "Signed in as user@example.com\nAmp Free: $6/$10 remaining (replenishes +$0.5/hour)", + now: now).toUsageSnapshot(now: now) + let daily = try AmpUsageParser.parse( + displayText: "Signed in as user@example.com\nAmp Free: 61% remaining today (resets daily)", + now: now).toUsageSnapshot(now: now) + + let published = daily.backfillingResetTimes(from: legacy, now: now) + + #expect(legacy.primary?.resetsAt == now.addingTimeInterval(8 * 3600)) + #expect(published.primary?.resetsAt == nil) + #expect(published.primary?.resetDescription == "resets daily") + } + + @Test + func `parses individual credits without free tier usage`() throws { + let output = """ + Signed in as paid@example.com + Individual credits: $25.64 remaining + """ + + let snapshot = try AmpUsageParser.parse(displayText: output) + let usage = snapshot.toUsageSnapshot() + + #expect(snapshot.freeQuota == nil) + #expect(snapshot.freeUsed == nil) + #expect(snapshot.individualCredits == 25.64) + #expect(usage.primary == nil) + #expect(usage.secondary == nil) + #expect(usage.ampUsage == AmpUsageDetails(individualCredits: 25.64, workspaceBalances: [])) + #expect(usage.identity?.loginMethod == "Amp") + #expect(AmpProviderDescriptor.primaryLabel(details: usage.ampUsage) == nil) + #expect(AmpProviderDescriptor.secondaryLabel(details: usage.ampUsage) == nil) + } + + @Test + func `parses workspace credits without free tier usage`() throws { + let output = """ + Signed in as workspace@example.com (team) + Workspace Alpha Team: $1,234.56 remaining + Workspace Beta: $7 remaining + """ + + let snapshot = try AmpUsageParser.parse(displayText: output) + let usage = snapshot.toUsageSnapshot() + + #expect(snapshot.freeQuota == nil) + #expect(snapshot.workspaceBalances == [ + AmpWorkspaceBalance(name: "Alpha Team", remaining: 1234.56), + AmpWorkspaceBalance(name: "Beta", remaining: 7), + ]) + #expect(usage.primary == nil) + #expect(usage.ampUsage == AmpUsageDetails( + individualCredits: nil, + workspaceBalances: snapshot.workspaceBalances)) + } + + @Test + func `signed in identity can contain login`() throws { + let output = """ + Signed in as login@example.com (login-team) + Amp Free: $6/$10 remaining (replenishes +$0.5/hour) + """ + + let snapshot = try AmpUsageParser.parse(displayText: output) + + #expect(snapshot.accountEmail == "login@example.com") + #expect(snapshot.accountOrganization == "login-team") + } + + @Test + func `parses current usage api response`() throws { + let now = Date(timeIntervalSince1970: 1_700_005_000) + let displayText = """ + Signed in as user@example.com (team) + Amp Free: $8/$10 remaining (replenishes +$0.5/hour) + Individual credits: $12.50 remaining + Workspace Alpha Team: $1,234.56 remaining + Workspace Beta: $7 remaining + """ + let data = try JSONSerialization.data(withJSONObject: [ + "ok": true, + "result": ["displayText": displayText], + ]) + + let snapshot = try AmpUsageFetcher.parseUsageAPIResponse(data, now: now) + + #expect(snapshot.freeUsed == 2) + #expect(snapshot.individualCredits == 12.5) + #expect(snapshot.workspaceBalances == [ + AmpWorkspaceBalance(name: "Alpha Team", remaining: 1234.56), + AmpWorkspaceBalance(name: "Beta", remaining: 7), + ]) + #expect(snapshot.accountEmail == "user@example.com") + #expect(snapshot.accountOrganization == "team") + } + + @Test + func `usage api auth error is invalid API token`() { + let data = Data(#"{"ok":false,"error":{"code":"auth-required","message":"Sign in"}}"#.utf8) + + #expect { + try AmpUsageFetcher.parseUsageAPIResponse(data) + } throws: { error in + guard case AmpUsageError.invalidAPIToken = error else { return false } + return true + } + } + @Test func `parses free tier usage from settings HTML`() throws { let now = Date(timeIntervalSince1970: 1_700_000_000) diff --git a/Tests/CodexBarTests/AntigravityCLIHTTPSFetchStrategyTests.swift b/Tests/CodexBarTests/AntigravityCLIHTTPSFetchStrategyTests.swift new file mode 100644 index 0000000000..298229fa7b --- /dev/null +++ b/Tests/CodexBarTests/AntigravityCLIHTTPSFetchStrategyTests.swift @@ -0,0 +1,903 @@ +import Foundation +import Testing +@testable import CodexBarCore + +private func antigravityBlockingSleep(_ interval: TimeInterval) { + Thread.sleep(forTimeInterval: interval) +} + +private final class AntigravityCLICounter: @unchecked Sendable { + private let lock = NSLock() + private var count = 0 + + @discardableResult + func increment() -> Int { + self.lock.lock() + self.count += 1 + let value = self.count + self.lock.unlock() + return value + } + + var value: Int { + self.lock.lock() + let value = self.count + self.lock.unlock() + return value + } +} + +private final class AntigravityCLIPortRecorder: @unchecked Sendable { + private let lock = NSLock() + private var ports: [[Int]] = [] + + func append(_ value: [Int]) { + self.lock.lock() + self.ports.append(value) + self.lock.unlock() + } + + func snapshot() -> [[Int]] { + self.lock.lock() + let value = self.ports + self.lock.unlock() + return value + } +} + +private final class AntigravityCLITimeoutRecorder: @unchecked Sendable { + private let lock = NSLock() + private var timeouts: [TimeInterval] = [] + + func append(_ value: TimeInterval) { + self.lock.lock() + self.timeouts.append(value) + self.lock.unlock() + } + + func snapshot() -> [TimeInterval] { + self.lock.lock() + let value = self.timeouts + self.lock.unlock() + return value + } +} + +private final class AntigravityCLITestClock: @unchecked Sendable { + private let lock = NSLock() + private var date: Date + + init(date: Date) { + self.date = date + } + + func now() -> Date { + self.lock.lock() + let value = self.date + self.date = self.date.addingTimeInterval(1) + self.lock.unlock() + return value + } +} + +private final class AntigravityCLIOutputSequence: @unchecked Sendable { + private let lock = NSLock() + private var values: [Data] + + init(_ values: [Data]) { + self.values = values + } + + func next() -> Data { + self.lock.lock() + let value = self.values.isEmpty ? Data() : self.values.removeFirst() + self.lock.unlock() + return value + } +} + +struct AntigravityCLIHTTPSFetchStrategyTests { + @Test + func `local strategy falls back to cli HTTPS in cli source mode`() { + let strategy = AntigravityStatusFetchStrategy() + let context = self.makeFetchContext(sourceMode: .cli) + + #expect(strategy.shouldFallback(on: AntigravityStatusProbeError.notRunning, context: context)) + } + + @Test + func `local strategy falls back to cli HTTPS in auto source mode`() { + let strategy = AntigravityStatusFetchStrategy() + let context = self.makeFetchContext(sourceMode: .auto) + + #expect(strategy.shouldFallback(on: AntigravityStatusProbeError.notRunning, context: context)) + } + + @Test + func `local strategy does not fallback for unrelated source modes`() { + let strategy = AntigravityStatusFetchStrategy() + + #expect(!strategy.shouldFallback( + on: AntigravityStatusProbeError.notRunning, + context: self.makeFetchContext(sourceMode: .oauth))) + #expect(!strategy.shouldFallback( + on: AntigravityStatusProbeError.notRunning, + context: self.makeFetchContext(sourceMode: .web))) + #expect(!strategy.shouldFallback( + on: AntigravityStatusProbeError.notRunning, + context: self.makeFetchContext(sourceMode: .api))) + } + + @Test + func `strategy pipeline includes cli HTTPS fallback in cli and auto modes`() async { + let descriptor = ProviderDescriptorRegistry.descriptor(for: .antigravity) + + let cliStrategies = await descriptor.fetchPlan.pipeline.resolveStrategies( + self.makeFetchContext(sourceMode: .cli)) + #expect(cliStrategies.map(\.id) == [ + "antigravity.app-local", + "antigravity.cli-https", + "antigravity.ide-local", + ]) + + let autoStrategies = await descriptor.fetchPlan.pipeline.resolveStrategies( + self.makeFetchContext(sourceMode: .auto)) + #expect(autoStrategies.map(\.id) == [ + "antigravity.app-local", + "antigravity.cli-https", + "antigravity.ide-local", + ]) + } + + @Test + func `strategy pipeline keeps source mode authoritative with selected token account`() async { + let descriptor = ProviderDescriptorRegistry.descriptor(for: .antigravity) + + let accountID = UUID() + let autoStrategies = await descriptor.fetchPlan.pipeline.resolveStrategies( + self.makeFetchContext(sourceMode: .auto, selectedTokenAccountID: accountID)) + let cliStrategies = await descriptor.fetchPlan.pipeline.resolveStrategies( + self.makeFetchContext(sourceMode: .cli, selectedTokenAccountID: accountID)) + let oauthStrategies = await descriptor.fetchPlan.pipeline.resolveStrategies( + self.makeFetchContext(sourceMode: .oauth, selectedTokenAccountID: accountID)) + + #expect(autoStrategies.map(\.id) == [ + "antigravity.app-local", + "antigravity.cli-https", + "antigravity.ide-local", + "antigravity.oauth", + ]) + #expect(cliStrategies.map(\.id) == [ + "antigravity.app-local", + "antigravity.cli-https", + "antigravity.ide-local", + ]) + #expect(oauthStrategies.map(\.id) == ["antigravity.oauth"]) + } + + @Test + func `auto strategy pipeline includes oauth when credentials are injected`() async { + let descriptor = ProviderDescriptorRegistry.descriptor(for: .antigravity) + + let autoStrategies = await descriptor.fetchPlan.pipeline.resolveStrategies( + self.makeFetchContext( + sourceMode: .auto, + env: self.accountEnv(email: "selected@example.com"))) + + #expect(autoStrategies.map(\.id) == [ + "antigravity.app-local", + "antigravity.cli-https", + "antigravity.ide-local", + "antigravity.oauth", + ]) + } + + @Test + func `auto strategy pipeline preserves oauth fallback for shared credentials file`() async throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("antigravity-shared-auto-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let store = AntigravityOAuthCredentialsStore( + fileURL: AntigravityOAuthCredentialsStore.defaultURL(home: root)) + try store.save(AntigravityOAuthCredentials( + accessToken: "access", + refreshToken: "refresh", + expiryDate: Date().addingTimeInterval(3600), + email: "legacy@example.com")) + + let descriptor = ProviderDescriptorRegistry.descriptor(for: .antigravity) + let autoStrategies = await descriptor.fetchPlan.pipeline.resolveStrategies( + self.makeFetchContext(sourceMode: .auto, env: ["HOME": root.path])) + + #expect(autoStrategies.map(\.id) == [ + "antigravity.app-local", + "antigravity.cli-https", + "antigravity.ide-local", + "antigravity.oauth", + ]) + } + + // MARK: - Selected-account guard + + @Test + func `account guard ignores fetches without a selected account`() throws { + let usage = self.makeUsage(accountEmail: "ambient@example.com") + let context = self.makeFetchContext( + sourceMode: .auto, + env: self.accountEnv(email: "selected@example.com")) + + try AntigravitySelectedAccountGuard.validate(usage, context: context) + } + + @Test + func `account guard accepts matching ambient snapshot in auto mode`() throws { + let usage = self.makeUsage(accountEmail: "Selected@Example.com") + let context = self.makeFetchContext( + sourceMode: .auto, + selectedTokenAccountID: UUID(), + env: self.accountEnv(email: "selected@example.com")) + + try AntigravitySelectedAccountGuard.validate(usage, context: context) + } + + @Test + func `account guard rejects mismatched ambient snapshot in auto mode`() { + let usage = self.makeUsage(accountEmail: "ambient@example.com") + let context = self.makeFetchContext( + sourceMode: .auto, + selectedTokenAccountID: UUID(), + env: self.accountEnv(email: "selected@example.com")) + + #expect(throws: AntigravityStatusProbeError.accountMismatch( + expected: "selected@example.com", + found: "ambient@example.com")) + { + try AntigravitySelectedAccountGuard.validate(usage, context: context) + } + } + + @Test + func `account guard rejects snapshot without an identity email`() { + let usage = self.makeUsage(accountEmail: nil) + let context = self.makeFetchContext( + sourceMode: .auto, + selectedTokenAccountID: UUID(), + env: self.accountEnv(email: "selected@example.com")) + + #expect(throws: AntigravityStatusProbeError.accountMismatch( + expected: "selected@example.com", + found: nil)) + { + try AntigravitySelectedAccountGuard.validate(usage, context: context) + } + } + + @Test + func `account guard rejects when selected account email cannot be resolved`() { + let usage = self.makeUsage(accountEmail: "ambient@example.com") + let context = self.makeFetchContext( + sourceMode: .auto, + selectedTokenAccountID: UUID()) + + #expect(throws: AntigravityStatusProbeError.accountMismatch( + expected: nil, + found: "ambient@example.com")) + { + try AntigravitySelectedAccountGuard.validate(usage, context: context) + } + } + + @Test + func `account guard leaves explicit cli source mode authoritative`() throws { + let usage = self.makeUsage(accountEmail: "ambient@example.com") + let context = self.makeFetchContext( + sourceMode: .cli, + selectedTokenAccountID: UUID(), + env: self.accountEnv(email: "selected@example.com")) + + try AntigravitySelectedAccountGuard.validate(usage, context: context) + } + + @Test + func `selected account email resolves from id_token when email field missing`() { + let idToken = Self.makeIDToken(email: "jwt@example.com") + let context = self.makeFetchContext( + sourceMode: .auto, + selectedTokenAccountID: UUID(), + env: self.accountEnv(email: nil, idToken: idToken)) + + #expect(AntigravitySelectedAccountGuard.selectedAccountEmail(context: context) == "jwt@example.com") + } + + @Test + func `selected account email prefers id_token over stored email field`() { + let idToken = Self.makeIDToken(email: "jwt@example.com") + let context = self.makeFetchContext( + sourceMode: .auto, + selectedTokenAccountID: UUID(), + env: self.accountEnv(email: "stored@example.com", idToken: idToken)) + + #expect(AntigravitySelectedAccountGuard.selectedAccountEmail(context: context) == "jwt@example.com") + } + + @Test + func `cli HTTPS resets session only for one-shot CLI runtime`() { + // One-shot CLI invocation: reset after fetch. + #expect(AntigravityCLIHTTPSFetchStrategy.shouldResetSessionAfterFetch(self.makeFetchContext(runtime: .cli))) + // App runtime keeps the warm session. + #expect(!AntigravityCLIHTTPSFetchStrategy.shouldResetSessionAfterFetch(self.makeFetchContext(runtime: .app))) + // Long-lived CLI host (codexbar serve) keeps the warm session even at .cli runtime. + #expect(!AntigravityCLIHTTPSFetchStrategy.shouldResetSessionAfterFetch( + self.makeFetchContext(runtime: .cli, persistsCLISessions: true))) + } + + @Test + func `cli HTTPS reports public source as cli`() { + #expect(AntigravityCLIHTTPSFetchStrategy.sourceLabel == "cli") + } + + @Test + func `cli local strategy availability requires binary`() async throws { + let binaryURL = FileManager.default.temporaryDirectory + .appendingPathComponent("codexbar-antigravity-\(UUID().uuidString)") + try Data("#!/bin/sh\n".utf8).write(to: binaryURL) + try FileManager.default.setAttributes( + [.posixPermissions: 0o755], + ofItemAtPath: binaryURL.path) + defer { try? FileManager.default.removeItem(at: binaryURL) } + + let strategy = AntigravityCLIHTTPSFetchStrategy() + let context = self.makeFetchContext(env: ["ANTIGRAVITY_CLI_PATH": binaryURL.path]) + let isAvailable = await strategy.isAvailable(context) + + #expect(isAvailable) + } + + @Test + func `cli local endpoints remain HTTPS only on macOS`() { + #expect( + AntigravityStatusProbe.cliEndpoints(ports: [55624]) == [ + AntigravityStatusProbe.AntigravityConnectionEndpoint( + scheme: "https", + port: 55624, + csrfToken: "", + source: .cliHTTPS), + ]) + } + + @Test + func `cli HTTPS falls back to command model configs when quota summary and user status fail`() async throws { + let endpoints = [ + AntigravityStatusProbe.AntigravityConnectionEndpoint( + scheme: "https", + port: 50080, + csrfToken: "", + source: .cliHTTPS), + ] + let attempts = AntigravityCLICounter() + + let snapshot = try await AntigravityStatusProbe.fetchSnapshot( + context: AntigravityStatusProbe.RequestContext( + endpoints: endpoints, + timeout: 1, + deadline: Date().addingTimeInterval(2)), + send: { payload, _, _ in + let attempt = attempts.increment() + if attempt == 1 { + #expect(payload.path == "/exa.language_server_pb.LanguageServerService/RetrieveUserQuotaSummary") + throw AntigravityStatusProbeError.apiError("quota summary unavailable") + } + if attempt == 2 { + #expect(payload.path == "/exa.language_server_pb.LanguageServerService/GetUserStatus") + throw AntigravityStatusProbeError.apiError("user status unavailable") + } + #expect(payload.path == "/exa.language_server_pb.LanguageServerService/GetCommandModelConfigs") + return Data(""" + { + "clientModelConfigs": [ + { + "label": "Claude Sonnet", + "modelOrAlias": { "model": "claude-sonnet" }, + "quotaInfo": { "remainingFraction": 0.5 } + } + ] + } + """.utf8) + }) + + #expect(snapshot.modelQuotas.first?.label == "Claude Sonnet") + #expect(attempts.value == 3) + } + + @Test + func `cli HTTPS waits for user status after ports appear`() async throws { + let fetchAttempts = AntigravityCLICounter() + let drainAttempts = AntigravityCLICounter() + let fetchedPorts = AntigravityCLIPortRecorder() + let snapshot = try await AntigravityCLIHTTPSFetchStrategy.waitForSnapshot( + pid: 123, + deadline: Date().addingTimeInterval(5), + dependencies: AntigravityCLIHTTPSFetchStrategy.SnapshotWaitDependencies( + pollIntervalNanoseconds: 0, + listeningPorts: { _, _ in [50080, 50081] }, + drainOutput: { + drainAttempts.increment() + return Data() + }, + fetchSnapshot: { ports in + fetchedPorts.append(ports) + if fetchAttempts.increment() == 1 { + throw AntigravityStatusProbeError.apiError("HTTP 500: GetCascadeModelConfigData() is nil") + } + return AntigravityStatusSnapshot( + modelQuotas: [ + AntigravityModelQuota( + label: "Claude Opus 4.6 (Thinking)", + modelId: "claude-opus-4.6-thinking", + remainingFraction: 1, + resetTime: nil, + resetDescription: nil), + ], + accountEmail: "user@example.com", + accountPlan: "Pro", + source: .local) + })) + + #expect(snapshot.accountEmail == "user@example.com") + #expect(fetchAttempts.value == 2) + #expect(fetchedPorts.snapshot() == [[50080, 50081], [50080, 50081]]) + #expect(drainAttempts.value == 4) + } + + @Test + func `cli HTTPS retries empty quota snapshots until usage is parseable`() async throws { + let fetchAttempts = AntigravityCLICounter() + + let snapshot = try await AntigravityCLIHTTPSFetchStrategy.waitForSnapshot( + pid: 123, + deadline: Date().addingTimeInterval(5), + dependencies: AntigravityCLIHTTPSFetchStrategy.SnapshotWaitDependencies( + pollIntervalNanoseconds: 0, + listeningPorts: { _, _ in [50080] }, + drainOutput: { Data() }, + fetchSnapshot: { _ in + if fetchAttempts.increment() == 1 { + return AntigravityStatusSnapshot( + modelQuotas: [], + accountEmail: nil, + accountPlan: nil, + source: .local) + } + return AntigravityStatusSnapshot( + modelQuotas: [ + AntigravityModelQuota( + label: "Claude Sonnet", + modelId: "claude-sonnet", + remainingFraction: 0.5, + resetTime: nil, + resetDescription: nil), + ], + accountEmail: "user@example.com", + accountPlan: "Pro", + source: .local) + })) + + #expect(fetchAttempts.value == 2) + #expect(snapshot.modelQuotas.first?.modelId == "claude-sonnet") + } + + @Test + func `cli HTTPS drains output before ports appear`() async throws { + let portPolls = AntigravityCLICounter() + let drainAttempts = AntigravityCLICounter() + let snapshot = try await AntigravityCLIHTTPSFetchStrategy.waitForSnapshot( + pid: 123, + deadline: Date().addingTimeInterval(5), + dependencies: AntigravityCLIHTTPSFetchStrategy.SnapshotWaitDependencies( + pollIntervalNanoseconds: 0, + listeningPorts: { _, _ in + portPolls.increment() == 1 ? [] : [50080] + }, + drainOutput: { + drainAttempts.increment() + return Data() + }, + fetchSnapshot: { _ in + AntigravityStatusSnapshot( + modelQuotas: [ + AntigravityModelQuota( + label: "Claude Sonnet", + modelId: "claude-sonnet", + remainingFraction: 1, + resetTime: nil, + resetDescription: nil), + ], + accountEmail: "user@example.com", + accountPlan: "Pro", + source: .local) + })) + + #expect(snapshot.accountEmail == "user@example.com") + #expect(portPolls.value == 2) + #expect(drainAttempts.value == 3) + } + + @Test + func `cli HTTPS stops before probing when signed out prompt spans output chunks`() async { + let output = AntigravityCLIOutputSequence([ + Data("Welcome. You are currently ".utf8), + Data("Welcome. You are currently not signed in.\nSelect login method:".utf8), + ]) + let portPolls = AntigravityCLICounter() + + do { + _ = try await AntigravityCLIHTTPSFetchStrategy.waitForSnapshot( + pid: 123, + deadline: Date().addingTimeInterval(2), + dependencies: AntigravityCLIHTTPSFetchStrategy.SnapshotWaitDependencies( + pollIntervalNanoseconds: 0, + listeningPorts: { _, _ in + portPolls.increment() + return [] + }, + drainOutput: { + output.next() + }, + fetchSnapshot: { _ in + Issue.record("Signed-out helper should not fetch a snapshot") + return AntigravityStatusSnapshot( + modelQuotas: [], + accountEmail: nil, + accountPlan: nil, + source: .local) + })) + Issue.record("Expected authentication failure") + } catch AntigravityStatusProbeError.authenticationRequired { + #expect(portPolls.value == 1) + } catch { + Issue.record("Expected authenticationRequired, got \(error)") + } + } + + @Test + func `cli HTTPS allows transient automatic sign in banner`() async throws { + let output = AntigravityCLIOutputSequence([ + Data("Welcome. You are currently not signed in.\nSigning in...".utf8), + Data("user@example.com\nGemini 3.1 Pro (High)".utf8), + ]) + + let snapshot = try await AntigravityCLIHTTPSFetchStrategy.waitForSnapshot( + pid: 123, + deadline: Date().addingTimeInterval(2), + dependencies: AntigravityCLIHTTPSFetchStrategy.SnapshotWaitDependencies( + pollIntervalNanoseconds: 0, + listeningPorts: { _, _ in [50080] }, + drainOutput: { + output.next() + }, + fetchSnapshot: { _ in + AntigravityStatusSnapshot( + modelQuotas: [ + AntigravityModelQuota( + label: "Claude Sonnet", + modelId: "claude-sonnet", + remainingFraction: 1, + resetTime: nil, + resetDescription: nil), + ], + accountEmail: "user@example.com", + accountPlan: "Pro", + source: .local) + })) + + #expect(snapshot.accountEmail == "user@example.com") + } + + @Test + func `cli HTTPS rechecks signed out prompt after snapshot readiness`() async { + let output = AntigravityCLIOutputSequence([ + Data(), + Data("You are currently not signed in.\nSelect login method:".utf8), + ]) + + do { + _ = try await AntigravityCLIHTTPSFetchStrategy.waitForSnapshot( + pid: 123, + deadline: Date().addingTimeInterval(2), + dependencies: AntigravityCLIHTTPSFetchStrategy.SnapshotWaitDependencies( + pollIntervalNanoseconds: 0, + listeningPorts: { _, _ in [50080] }, + drainOutput: { + output.next() + }, + fetchSnapshot: { _ in + AntigravityStatusSnapshot( + modelQuotas: [ + AntigravityModelQuota( + label: "Claude Sonnet", + modelId: "claude-sonnet", + remainingFraction: 1, + resetTime: nil, + resetDescription: nil), + ], + accountEmail: "user@example.com", + accountPlan: "Pro", + source: .local) + })) + Issue.record("Expected authentication failure") + } catch AntigravityStatusProbeError.authenticationRequired { + // Expected: the late prompt wins over the apparently ready API. + } catch { + Issue.record("Expected authenticationRequired, got \(error)") + } + } + + @Test + func `cli HTTPS treats empty lsof exit as ports not ready`() async throws { + let portPolls = AntigravityCLICounter() + let snapshot = try await AntigravityCLIHTTPSFetchStrategy.waitForSnapshot( + pid: 123, + deadline: Date().addingTimeInterval(5), + dependencies: AntigravityCLIHTTPSFetchStrategy.SnapshotWaitDependencies( + pollIntervalNanoseconds: 0, + listeningPorts: { _, _ in + if portPolls.increment() == 1 { + throw SubprocessRunnerError.nonZeroExit(code: 1, stderr: "") + } + return [50080] + }, + drainOutput: { Data() }, + fetchSnapshot: { _ in + AntigravityStatusSnapshot( + modelQuotas: [ + AntigravityModelQuota( + label: "Claude Sonnet", + modelId: "claude-sonnet", + remainingFraction: 0.5, + resetTime: nil, + resetDescription: nil), + ], + accountEmail: "user@example.com", + accountPlan: "Pro", + source: .local) + })) + + #expect(snapshot.accountEmail == "user@example.com") + #expect(portPolls.value == 2) + } + + @Test + func `parsed requests recompute timeout from shared deadline between endpoints`() async throws { + let timeoutRecorder = AntigravityCLITimeoutRecorder() + let attempts = AntigravityCLICounter() + let endpoints = [ + AntigravityStatusProbe.AntigravityConnectionEndpoint( + scheme: "https", + port: 50080, + csrfToken: "", + source: .cliHTTPS), + AntigravityStatusProbe.AntigravityConnectionEndpoint( + scheme: "https", + port: 50081, + csrfToken: "", + source: .cliHTTPS), + ] + + let result = try await AntigravityStatusProbe.makeParsedRequest( + payload: AntigravityStatusProbe.RequestPayload(path: "/status", body: [:]), + context: AntigravityStatusProbe.RequestContext( + endpoints: endpoints, + timeout: 10, + deadline: Date().addingTimeInterval(10)), + send: { _, _, timeout in + timeoutRecorder.append(timeout) + if attempts.increment() == 1 { + antigravityBlockingSleep(0.1) + throw AntigravityStatusProbeError.apiError("first endpoint failed") + } + return Data("ok".utf8) + }, + parse: { data in + guard let value = String(bytes: data, encoding: .utf8) else { + throw AntigravityStatusProbeError.apiError("invalid test data") + } + return value + }) + + let timeouts = timeoutRecorder.snapshot() + #expect(result == "ok") + #expect(timeouts.count == 2) + #expect(timeouts.allSatisfy { $0 <= 10 }) + #expect((timeouts.last ?? 10) < (timeouts.first ?? 0)) + } + + @Test + func `parsed request reports timeout when shared deadline is already expired`() async { + do { + _ = try await AntigravityStatusProbe.makeParsedRequest( + payload: AntigravityStatusProbe.RequestPayload(path: "/status", body: [:]), + context: AntigravityStatusProbe.RequestContext( + endpoints: [ + AntigravityStatusProbe.AntigravityConnectionEndpoint( + scheme: "https", + port: 50080, + csrfToken: "", + source: .cliHTTPS), + ], + timeout: 10, + deadline: Date().addingTimeInterval(-1)), + send: { _, _, _ in + Issue.record("Expired deadline should not send a request") + return Data() + }, + parse: { _ in "ok" }) + Issue.record("Expected timeout") + } catch AntigravityStatusProbeError.timedOut { + } catch { + Issue.record("Expected timedOut, got \(error)") + } + } + + @Test + func `cli HTTPS reports last readiness error when ports never become usable`() async { + let fetchAttempts = AntigravityCLICounter() + let start = Date(timeIntervalSinceReferenceDate: 0) + let clock = AntigravityCLITestClock(date: start) + + do { + _ = try await AntigravityCLIHTTPSFetchStrategy.waitForSnapshot( + pid: 123, + deadline: start.addingTimeInterval(5), + dependencies: AntigravityCLIHTTPSFetchStrategy.SnapshotWaitDependencies( + pollIntervalNanoseconds: 0, + listeningPorts: { _, _ in [50080] }, + drainOutput: { Data() }, + fetchSnapshot: { _ in + let attempt = fetchAttempts.increment() + throw AntigravityStatusProbeError.apiError("HTTP 500: warming attempt \(attempt)") + }, + now: { clock.now() })) + Issue.record("Expected readiness polling to throw") + } catch let AntigravityStatusProbeError.apiError(message) { + #expect(fetchAttempts.value == 2) + #expect(message == "HTTP 500: warming attempt 2") + } catch { + Issue.record("Expected apiError, got \(error)") + } + } + + @Test + func `cli HTTPS preserves non transient port detection errors`() async { + do { + _ = try await AntigravityCLIHTTPSFetchStrategy.waitForSnapshot( + pid: 123, + deadline: Date().addingTimeInterval(2), + dependencies: AntigravityCLIHTTPSFetchStrategy.SnapshotWaitDependencies( + pollIntervalNanoseconds: 0, + listeningPorts: { _, _ in + throw AntigravityStatusProbeError.portDetectionFailed("lsof not available") + }, + drainOutput: { Data() }, + fetchSnapshot: { _ in + Issue.record("Port detection failure should not fetch a snapshot") + return AntigravityStatusSnapshot( + modelQuotas: [], + accountEmail: nil, + accountPlan: nil, + source: .local) + })) + Issue.record("Expected port detection failure") + } catch let AntigravityStatusProbeError.portDetectionFailed(message) { + #expect(message == "lsof not available") + } catch { + Issue.record("Expected portDetectionFailed, got \(error)") + } + } + + @Test + func `cli HTTPS endpoint does not require CSRF token`() { + let endpoint = AntigravityStatusProbe.AntigravityConnectionEndpoint( + scheme: "https", + port: 55624, + csrfToken: "ignored-by-cli", + source: .cliHTTPS) + #expect(!endpoint.requiresCSRFToken) + } + + @Test + func `languageServer endpoint requires CSRF token`() { + let endpoint = AntigravityStatusProbe.AntigravityConnectionEndpoint( + scheme: "https", + port: 64440, + csrfToken: "", + source: .languageServer) + #expect(endpoint.requiresCSRFToken) + } + + @Test + func `extensionServer endpoint requires CSRF token`() { + let endpoint = AntigravityStatusProbe.AntigravityConnectionEndpoint( + scheme: "http", + port: 64432, + csrfToken: "", + source: .extensionServer) + #expect(endpoint.requiresCSRFToken) + } + + private func makeFetchContext( + runtime: ProviderRuntime = .app, + sourceMode: ProviderSourceMode = .auto, + selectedTokenAccountID: UUID? = nil, + persistsCLISessions: Bool = false, + env: [String: String] = [:]) -> ProviderFetchContext + { + var effectiveEnv = env + effectiveEnv["HOME"] = effectiveEnv["HOME"] ?? + FileManager.default.temporaryDirectory + .appendingPathComponent("codexbar-antigravity-empty-home-\(UUID().uuidString)", isDirectory: true) + .path + return ProviderFetchContext( + runtime: runtime, + sourceMode: sourceMode, + includeCredits: false, + webTimeout: 1, + webDebugDumpHTML: false, + verbose: false, + env: effectiveEnv, + settings: nil, + fetcher: UsageFetcher(environment: effectiveEnv), + claudeFetcher: StubClaudeFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0), + selectedTokenAccountID: selectedTokenAccountID, + persistsCLISessions: persistsCLISessions) + } + + private func makeUsage(accountEmail: String?) -> UsageSnapshot { + UsageSnapshot( + primary: nil, + secondary: nil, + updatedAt: Date(), + identity: ProviderIdentitySnapshot( + providerID: .antigravity, + accountEmail: accountEmail, + accountOrganization: nil, + loginMethod: nil)) + } + + private func accountEnv(email: String?, idToken: String? = nil) -> [String: String] { + let credentials = AntigravityOAuthCredentials( + accessToken: "access", + refreshToken: "refresh", + expiryDate: Date().addingTimeInterval(3600), + idToken: idToken, + email: email) + guard let value = try? AntigravityOAuthCredentialsStore.tokenAccountValue(for: credentials) else { + return [:] + } + return [AntigravityOAuthCredentialsStore.environmentCredentialsKey: value] + } + + private static func makeIDToken(email: String) -> String { + let payload = Data("{\"email\":\"\(email)\"}".utf8) + let encodedPayload = payload.base64EncodedString() + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") + return "header.\(encodedPayload).signature" + } + + private struct StubClaudeFetcher: ClaudeUsageFetching { + func loadLatestUsage(model _: String) async throws -> ClaudeUsageSnapshot { + throw ClaudeUsageError.parseFailed("stub") + } + + func debugRawProbe(model _: String) async -> String { + "stub" + } + + func detectVersion() -> String? { + nil + } + } +} diff --git a/Tests/CodexBarTests/AntigravityCLISessionTests.swift b/Tests/CodexBarTests/AntigravityCLISessionTests.swift new file mode 100644 index 0000000000..9b5ca353cb --- /dev/null +++ b/Tests/CodexBarTests/AntigravityCLISessionTests.swift @@ -0,0 +1,1548 @@ +#if canImport(Darwin) +import Darwin +#else +import Glibc +#endif +import Foundation +import Testing +@testable import CodexBarCore + +private final class FakeAntigravityProcessHandle: AntigravityCLIProcessHandle, @unchecked Sendable { + private let lock = NSLock() + let pid: pid_t + var descendants: [pid_t] + private var running: Bool + private let terminateRootStopsProcess: Bool + private var assignedProcessGroup: pid_t? + private var events: [String] = [] + private var drainOutputChunks: [Data] = [] + + init(pid: pid_t, running: Bool = true, descendants: [pid_t] = [], terminateRootStopsProcess: Bool = true) { + self.pid = pid + self.running = running + self.descendants = descendants + self.terminateRootStopsProcess = terminateRootStopsProcess + } + + var isRunning: Bool { + self.lock.lock() + let value = self.running + self.events.append("isRunning:\(value)") + self.lock.unlock() + return value + } + + var processGroup: pid_t? { + self.lock.lock() + let value = self.assignedProcessGroup + self.lock.unlock() + return value + } + + func assignProcessGroup() -> pid_t? { + self.lock.lock() + self.assignedProcessGroup = self.pid + self.events.append("assignProcessGroup") + self.lock.unlock() + return self.pid + } + + func sendExit() throws { + self.append("sendExit") + } + + func closePTY() { + self.append("closePTY") + } + + func terminateRoot() { + self.lock.lock() + if self.terminateRootStopsProcess { + self.running = false + } + self.events.append("terminateRoot") + self.lock.unlock() + } + + func killRoot() { + self.lock.lock() + self.running = false + self.events.append("killRoot") + self.lock.unlock() + } + + func descendantPIDs() -> [pid_t] { + self.lock.lock() + let value = self.descendants + self.events.append("descendantPIDs") + self.lock.unlock() + return value + } + + func terminateTree(signal: Int32, knownDescendants _: [pid_t]) { + self.lock.lock() + if signal == SIGKILL { + self.running = false + } + self.events.append("terminateTree:\(signal)") + self.lock.unlock() + } + + func killDescendants(_ descendants: [pid_t]) { + self.append("killDescendants:\(descendants.map(String.init).joined(separator: ","))") + } + + func drainOutput() -> Data { + self.lock.lock() + self.events.append("drainOutput") + let output = self.drainOutputChunks.isEmpty ? Data() : self.drainOutputChunks.removeFirst() + self.lock.unlock() + return output + } + + func enqueueDrainOutput(_ output: Data) { + self.lock.lock() + self.drainOutputChunks.append(output) + self.lock.unlock() + } + + func snapshotEvents() -> [String] { + self.lock.lock() + let value = self.events + self.lock.unlock() + return value + } + + private func append(_ event: String) { + self.lock.lock() + self.events.append(event) + self.lock.unlock() + } +} + +private final class FakeAntigravityProcessLauncher: AntigravityCLIProcessLaunching, @unchecked Sendable { + private let lock = NSLock() + private var nextPID: pid_t + private var launchError: Error? + private var launchedBinaries: [String] = [] + private var terminateRootStopsProcess = true + private var handles: [FakeAntigravityProcessHandle] = [] + + init(nextPID: pid_t = 1) { + self.nextPID = nextPID + } + + func launch(binary: String) throws -> any AntigravityCLIProcessHandle { + self.lock.lock() + defer { self.lock.unlock() } + if let launchError { + throw launchError + } + let handle = FakeAntigravityProcessHandle( + pid: self.nextPID, + descendants: [self.nextPID + 100], + terminateRootStopsProcess: self.terminateRootStopsProcess) + self.nextPID += 1 + self.launchedBinaries.append(binary) + self.handles.append(handle) + return handle + } + + func setLaunchError(_ error: Error?) { + self.lock.lock() + self.launchError = error + self.lock.unlock() + } + + func setTerminateRootStopsProcess(_ value: Bool) { + self.lock.lock() + self.terminateRootStopsProcess = value + self.lock.unlock() + } + + func launchedBinarySnapshot() -> [String] { + self.lock.lock() + let value = self.launchedBinaries + self.lock.unlock() + return value + } + + func handleSnapshot() -> [FakeAntigravityProcessHandle] { + self.lock.lock() + let value = self.handles + self.lock.unlock() + return value + } +} + +private final class FakeAntigravityIdentityProvider: AntigravityCLIProcessIdentityProviding, @unchecked Sendable { + private let lock = NSLock() + private var identities: [pid_t: AntigravityCLIProcessIdentity] = [:] + + func setIdentity(pid: pid_t, executablePath: String, startEpoch: TimeInterval) { + self.lock.lock() + self.identities[pid] = AntigravityCLIProcessIdentity(executablePath: executablePath, startEpoch: startEpoch) + self.lock.unlock() + } + + func removeIdentity(pid: pid_t) { + self.lock.lock() + self.identities[pid] = nil + self.lock.unlock() + } + + func identity(for pid: pid_t) -> AntigravityCLIProcessIdentity? { + self.lock.lock() + let value = self.identities[pid] + self.lock.unlock() + return value + } +} + +private final class MemoryAntigravitySessionRecordStore: AntigravityCLISessionRecordStoring, @unchecked Sendable { + private let lock = NSLock() + private var records: [AntigravityCLISessionRecord] + private let failSaves: Bool + private var saves = 0 + private var removes = 0 + + init(record: AntigravityCLISessionRecord? = nil, failSaves: Bool = false) { + self.records = record.map { [$0] } ?? [] + self.failSaves = failSaves + } + + func load() throws -> [AntigravityCLISessionRecord] { + self.lock.lock() + let value = self.records + self.lock.unlock() + return value + } + + func save(_ record: AntigravityCLISessionRecord) throws { + self.lock.lock() + guard !self.failSaves else { + self.lock.unlock() + throw CocoaError(.fileWriteNoPermission) + } + self.records.removeAll { existing in + if let existingOwnerPID = existing.ownerPID, + let recordOwnerPID = record.ownerPID, + let existingOwnerPath = existing.ownerExecutablePath, + let recordOwnerPath = record.ownerExecutablePath, + let existingOwnerStart = existing.ownerStartEpoch, + let recordOwnerStart = record.ownerStartEpoch + { + return existingOwnerPID == recordOwnerPID && + existingOwnerPath == recordOwnerPath && + abs(existingOwnerStart - recordOwnerStart) < 0.001 + } + return existing.pid == record.pid && + existing.executablePath == record.executablePath && + abs(existing.startEpoch - record.startEpoch) < 0.001 + } + self.records.append(record) + self.saves += 1 + self.lock.unlock() + } + + func remove(_ record: AntigravityCLISessionRecord) throws { + self.lock.lock() + self.records.removeAll { + $0.pid == record.pid && + $0.executablePath == record.executablePath && + abs($0.startEpoch - record.startEpoch) < 0.001 + } + self.removes += 1 + self.lock.unlock() + } + + func snapshot() -> AntigravityCLISessionRecord? { + self.lock.lock() + let value = self.records.first + self.lock.unlock() + return value + } + + func snapshots() -> [AntigravityCLISessionRecord] { + self.lock.lock() + let value = self.records + self.lock.unlock() + return value + } + + var saveCount: Int { + self.lock.lock() + let value = self.saves + self.lock.unlock() + return value + } + + var removeCount: Int { + self.lock.lock() + let value = self.removes + self.lock.unlock() + return value + } +} + +private final class MemoryAntigravitySessionLaunchLock: AntigravityCLISessionLaunchLocking, @unchecked Sendable { + private let lock = NSLock() + + func withLock(_ operation: () throws -> T) throws -> T { + self.lock.lock() + defer { self.lock.unlock() } + return try operation() + } +} + +private struct FailingAntigravitySessionLaunchLock: AntigravityCLISessionLaunchLocking { + func withLock(_: () throws -> T) throws -> T { + throw CocoaError(.fileWriteNoPermission) + } +} + +private final class AntigravitySessionTerminationRecorder: @unchecked Sendable { + private let lock = NSLock() + private var events: [(pid: pid_t, group: pid_t?, signal: Int32, descendants: [pid_t])] = [] + + func append(pid: pid_t, group: pid_t?, signal: Int32, descendants: [pid_t]) { + self.lock.lock() + self.events.append((pid: pid, group: group, signal: signal, descendants: descendants)) + self.lock.unlock() + } + + func snapshot() -> [(pid: pid_t, group: pid_t?, signal: Int32, descendants: [pid_t])] { + self.lock.lock() + let value = self.events + self.lock.unlock() + return value + } +} + +private final class AntigravityRegistryRecorder: @unchecked Sendable { + private let lock = NSLock() + private var shouldRegister = true + private var registered: [pid_t] = [] + private var unregistered: [pid_t] = [] + private var groups: [pid_t: pid_t?] = [:] + + func setShouldRegister(_ value: Bool) { + self.lock.lock() + self.shouldRegister = value + self.lock.unlock() + } + + func register(pid: pid_t, _: String) -> Bool { + self.lock.lock() + defer { self.lock.unlock() } + guard self.shouldRegister else { return false } + self.registered.append(pid) + return true + } + + func update(pid: pid_t, group: pid_t?) { + self.lock.lock() + self.groups[pid] = group + self.lock.unlock() + } + + func unregister(pid: pid_t) { + self.lock.lock() + self.unregistered.append(pid) + self.lock.unlock() + } + + func registeredSnapshot() -> [pid_t] { + self.lock.lock() + let value = self.registered + self.lock.unlock() + return value + } + + func unregisteredSnapshot() -> [pid_t] { + self.lock.lock() + let value = self.unregistered + self.lock.unlock() + return value + } +} + +private final class AntigravityLaunchReservationRecorder: @unchecked Sendable { + private let lock = NSLock() + private var beginCount = 0 + private var endCount = 0 + + func begin() -> Bool { + self.lock.lock() + self.beginCount += 1 + self.lock.unlock() + return true + } + + func end() { + self.lock.lock() + self.endCount += 1 + self.lock.unlock() + } + + func counts() -> (begin: Int, end: Int) { + self.lock.lock() + let counts = (begin: self.beginCount, end: self.endCount) + self.lock.unlock() + return counts + } +} + +private final class AntigravityManualSleeper: @unchecked Sendable { + private let lock = NSLock() + private var continuations: [CheckedContinuation] = [] + + func sleep(_: UInt64) async throws { + try await withCheckedThrowingContinuation { continuation in + self.lock.lock() + self.continuations.append(continuation) + self.lock.unlock() + } + } + + func resumeAll() { + self.lock.lock() + let continuations = self.continuations + self.continuations.removeAll() + self.lock.unlock() + + for continuation in continuations { + continuation.resume() + } + } + + func waitForSleeps(_ expectedCount: Int) async { + for _ in 0..<200 { + if self.pendingSleepCount >= expectedCount { return } + try? await Task.sleep(nanoseconds: 1_000_000) + } + Issue.record("Timed out waiting for \(expectedCount) sleep continuation(s)") + } + + private var pendingSleepCount: Int { + self.lock.lock() + let count = self.continuations.count + self.lock.unlock() + return count + } +} + +struct AntigravityCLISessionTests { + @Test + func `reuses alive process for same binary`() async throws { + let fixture = self.makeFixture() + fixture.identity.setIdentity(pid: 10, executablePath: "/bin/agy", startEpoch: 100) + + let firstPID = try await fixture.session.beginProbe(binary: "/bin/agy") + await fixture.session.finishProbe(success: true, resetAfterFetch: false) + let secondPID = try await fixture.session.beginProbe(binary: "/bin/agy") + await fixture.session.finishProbe(success: true, resetAfterFetch: false) + + #expect(firstPID == 10) + #expect(secondPID == 10) + #expect(fixture.launcher.launchedBinarySnapshot() == ["/bin/agy"]) + #expect(fixture.launchReservations.counts().begin == 1) + #expect(fixture.launchReservations.counts().end == 1) + } + + @Test + func `relaunches when binary changes`() async throws { + let fixture = self.makeFixture() + fixture.identity.setIdentity(pid: 10, executablePath: "/bin/agy", startEpoch: 100) + fixture.identity.setIdentity(pid: 11, executablePath: "/new/agy", startEpoch: 101) + + _ = try await fixture.session.beginProbe(binary: "/bin/agy") + await fixture.session.finishProbe(success: true, resetAfterFetch: false) + let secondPID = try await fixture.session.beginProbe(binary: "/new/agy") + await fixture.session.finishProbe(success: true, resetAfterFetch: false) + + #expect(secondPID == 11) + #expect(fixture.launcher.launchedBinarySnapshot() == ["/bin/agy", "/new/agy"]) + #expect(fixture.registry.unregisteredSnapshot() == [10]) + } + + @Test + func `replacement launch waits for in progress teardown`() async throws { + let fixture = self.makeFixture( + manualSleep: true, + terminationGracePeriod: 1, + terminateRootStopsProcess: false) + fixture.identity.setIdentity(pid: 10, executablePath: "/old/agy", startEpoch: 100) + fixture.identity.setIdentity(pid: 11, executablePath: "/new/agy", startEpoch: 101) + + _ = try await fixture.session.beginProbe(binary: "/old/agy") + await fixture.session.finishProbe(success: true, resetAfterFetch: false) + + let firstReplacement = Task { + try await fixture.session.beginProbe(binary: "/new/agy") + } + // Two sleeps register here: the lingering idle-timer sleep (armed by the prior finishProbe; + // the fake sleeper does not honor cancellation) and the teardown grace-period sleep. Wait for + // both before resuming — waiting for only one lets resumeAll() fire before the grace sleep + // parks, stranding it so teardown never completes and the suite hangs to the 120s timeout. + await fixture.sleeper?.waitForSleeps(2) + + let secondReplacement = Task { + try await fixture.session.beginProbe(binary: "/new/agy") + } + await Task.yield() + #expect(fixture.launcher.launchedBinarySnapshot() == ["/old/agy"]) + + fixture.launcher.handleSnapshot().first?.killRoot() + fixture.sleeper?.resumeAll() + let firstPID = try await firstReplacement.value + let secondPID = try await secondReplacement.value + await fixture.session.finishProbe(success: true, resetAfterFetch: false) + await fixture.session.finishProbe(success: true, resetAfterFetch: false) + + #expect(firstPID == 11) + #expect(secondPID == 11) + #expect(fixture.launcher.launchedBinarySnapshot() == ["/old/agy", "/new/agy"]) + } + + @Test + func `replacement waits for active probe before relaunching`() async throws { + let fixture = self.makeFixture() + fixture.identity.setIdentity(pid: 10, executablePath: "/old/agy", startEpoch: 100) + fixture.identity.setIdentity(pid: 11, executablePath: "/new/agy", startEpoch: 101) + + let firstPID = try await fixture.session.beginProbe(binary: "/old/agy") + let replacement = Task { + try await fixture.session.beginProbe(binary: "/new/agy") + } + await Task.yield() + await Task.yield() + + #expect(firstPID == 10) + #expect(fixture.launcher.launchedBinarySnapshot() == ["/old/agy"]) + #expect(fixture.registry.unregisteredSnapshot().isEmpty) + + await fixture.session.finishProbe(success: true, resetAfterFetch: false) + let secondPID = try await replacement.value + await fixture.session.finishProbe(success: true, resetAfterFetch: false) + + #expect(secondPID == 11) + #expect(fixture.launcher.launchedBinarySnapshot() == ["/old/agy", "/new/agy"]) + #expect(fixture.registry.unregisteredSnapshot() == [10]) + } + + @Test + func `queued replacement hard stops a signed out process`() async throws { + let fixture = self.makeFixture() + fixture.identity.setIdentity(pid: 10, executablePath: "/old/agy", startEpoch: 100) + fixture.identity.setIdentity(pid: 11, executablePath: "/new/agy", startEpoch: 101) + + _ = try await fixture.session.beginProbe(binary: "/old/agy") + let replacement = Task { + try await fixture.session.beginProbe(binary: "/new/agy") + } + for _ in 0..<100 where await fixture.session.activeProbeCountForTesting < 2 { + await Task.yield() + } + #expect(await fixture.session.activeProbeCountForTesting == 2) + + await fixture.session.finishProbe(success: false, resetAfterFetch: true, forceTerminate: true) + let replacementPID = try await replacement.value + let oldEvents = try #require(fixture.launcher.handleSnapshot().first).snapshotEvents() + #expect(await fixture.session.lastStopReasonForTesting == "authentication required") + await fixture.session.finishProbe(success: true, resetAfterFetch: true) + + #expect(replacementPID == 11) + #expect(!oldEvents.contains("sendExit")) + #expect(oldEvents.contains("terminateRoot")) + } + + @Test + func `replacement ignores queued starters while waiting for active probe`() async throws { + let fixture = self.makeFixture() + fixture.identity.setIdentity(pid: 10, executablePath: "/old/agy", startEpoch: 100) + fixture.identity.setIdentity(pid: 11, executablePath: "/new/agy", startEpoch: 101) + + _ = try await fixture.session.beginProbe(binary: "/old/agy") + let firstReplacement = Task { + try await fixture.session.beginProbe(binary: "/new/agy") + } + let secondReplacement = Task { + try await fixture.session.beginProbe(binary: "/new/agy") + } + await Task.yield() + await Task.yield() + + #expect(fixture.launcher.launchedBinarySnapshot() == ["/old/agy"]) + + await fixture.session.finishProbe(success: true, resetAfterFetch: false) + let didLaunchReplacement = await self.waitForLaunches(fixture.launcher, count: 2) + #expect(didLaunchReplacement) + if !didLaunchReplacement { + await fixture.session.finishProbe(success: true, resetAfterFetch: false) + } + + let firstPID = try await firstReplacement.value + let secondPID = try await secondReplacement.value + await fixture.session.finishProbe(success: true, resetAfterFetch: false) + await fixture.session.finishProbe(success: true, resetAfterFetch: false) + + #expect(firstPID == 11) + #expect(secondPID == 11) + #expect(fixture.launcher.launchedBinarySnapshot() == ["/old/agy", "/new/agy"]) + } + + @Test + func `relaunches when existing process is dead`() async throws { + let fixture = self.makeFixture() + fixture.identity.setIdentity(pid: 10, executablePath: "/bin/agy", startEpoch: 100) + fixture.identity.setIdentity(pid: 11, executablePath: "/bin/agy", startEpoch: 101) + + _ = try await fixture.session.beginProbe(binary: "/bin/agy") + await fixture.session.finishProbe(success: true, resetAfterFetch: false) + fixture.launcher.handleSnapshot().first?.terminateRoot() + let secondPID = try await fixture.session.beginProbe(binary: "/bin/agy") + await fixture.session.finishProbe(success: true, resetAfterFetch: false) + + #expect(secondPID == 11) + #expect(fixture.launcher.launchedBinarySnapshot() == ["/bin/agy", "/bin/agy"]) + } + + @Test + func `pty launcher creates dedicated process group before returning`() throws { + let launcher = AntigravityPTYProcessLauncher() + let handle = try launcher.launch(binary: "/bin/cat") + defer { + handle.killRoot() + handle.terminateTree(signal: SIGKILL, knownDescendants: []) + handle.closePTY() + } + + #expect(handle.processGroup == handle.pid) + #expect(getpgid(handle.pid) == handle.pid) + } + + @Test + func `pty launcher resets termination signals for child process`() { + var signals = AntigravityPTYProcessLauncher.defaultSignalsForSpawn() + + #expect(sigismember(&signals, SIGINT) == 1) + #expect(sigismember(&signals, SIGTERM) == 1) + #expect(sigismember(&signals, SIGHUP) == 1) + } + + @Test + func `pty launcher retries transient text busy spawn errors`() { + var attempts = 0 + + let result = AntigravityPTYProcessLauncher.spawnWithTextBusyRetry(retryDelay: 0) { + attempts += 1 + return attempts < 3 ? ETXTBSY : 0 + } + + #expect(result == 0) + #expect(attempts == 3) + } + + @Test + func `pty launcher does not retry other spawn errors`() { + var attempts = 0 + + let result = AntigravityPTYProcessLauncher.spawnWithTextBusyRetry(retryDelay: 0) { + attempts += 1 + return EACCES + } + + #expect(result == EACCES) + #expect(attempts == 1) + } + + @Test + func `pty launcher uses home and closes unrelated descriptors`() throws { + let tempDirectory = FileManager.default.temporaryDirectory + .appendingPathComponent("antigravity-spawn-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: tempDirectory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: tempDirectory) } + + let inheritedSourceFD = open("/dev/null", O_RDONLY) + guard inheritedSourceFD >= 0 else { + Issue.record("Failed to open descriptor fixture") + return + } + defer { close(inheritedSourceFD) } + let inheritedFD = fcntl(inheritedSourceFD, F_DUPFD, 200) + guard inheritedFD >= 200 else { + Issue.record("Failed to duplicate descriptor fixture") + return + } + defer { close(inheritedFD) } + + let outputURL = tempDirectory.appendingPathComponent("result.txt") + let script = """ + pwd > \(outputURL.path) + if [ -e /dev/fd/\(inheritedFD) ] || [ -e /proc/self/fd/\(inheritedFD) ]; then + echo inherited >> \(outputURL.path) + else + echo closed >> \(outputURL.path) + fi + """ + + let handle = try AntigravityPTYProcessLauncher().launch( + binary: "/bin/sh", + arguments: ["-c", script]) + defer { + handle.killRoot() + handle.terminateTree(signal: SIGKILL, knownDescendants: []) + handle.closePTY() + } + + for _ in 0..<200 { + if FileManager.default.fileExists(atPath: outputURL.path), + let output = try? String(contentsOf: outputURL, encoding: .utf8) + { + let lines = output + .split(separator: "\n") + .map(String.init) + if lines.count >= 2, output.hasSuffix("\n") { break } + } + Thread.sleep(forTimeInterval: 0.01) + } + let lines = try String(contentsOf: outputURL, encoding: .utf8) + .split(separator: "\n") + .map(String.init) + #expect(lines == [NSHomeDirectory(), "closed"]) + } + + @Test + func `spawned PTY drain is bounded per call`() throws { + let temp = FileManager.default.temporaryDirectory + .appendingPathComponent("antigravity-drain-\(UUID().uuidString)") + defer { try? FileManager.default.removeItem(at: temp) } + try Data(repeating: 1, count: 8192 * 65).write(to: temp) + let primaryFD = open(temp.path, O_RDONLY) + guard primaryFD >= 0 else { + Issue.record("Failed to open temporary drain input") + return + } + let secondaryFD = open("/dev/null", O_RDONLY) + guard secondaryFD >= 0 else { + close(primaryFD) + Issue.record("Failed to open /dev/null") + return + } + let handle = AntigravitySpawnedPTYProcessHandle( + pid: getpid(), + processGroup: getpgrp(), + primaryFD: primaryFD, + primaryHandle: FileHandle(fileDescriptor: primaryFD, closeOnDealloc: true), + secondaryHandle: FileHandle(fileDescriptor: secondaryFD, closeOnDealloc: true)) + defer { handle.closePTY() } + + let output = handle.drainOutput() + + #expect(lseek(primaryFD, 0, SEEK_CUR) == off_t(8192 * 64)) + #expect(output.count == 8192 * 64) + } + + @Test + func `session keeps one rolling PTY buffer across concurrent probes`() async throws { + let fixture = self.makeFixture() + fixture.identity.setIdentity(pid: 10, executablePath: "/bin/agy", startEpoch: 100) + + _ = try await fixture.session.beginProbe(binary: "/bin/agy") + _ = try await fixture.session.beginProbe(binary: "/bin/agy") + let handle = try #require(fixture.launcher.handleSnapshot().first) + handle.enqueueDrainOutput(Data([0xE2, 0x96])) + let first = await fixture.session.drainOutput() + handle.enqueueDrainOutput(Data([0x84]) + Data("Select login method:".utf8)) + let second = await fixture.session.drainOutput() + let third = await fixture.session.drainOutput() + + #expect(first == Data([0xE2, 0x96])) + #expect(AntigravityCLIHTTPSFetchStrategy.containsAuthenticationPrompt(second)) + #expect(third == second) + + await fixture.session.finishProbe(success: false, resetAfterFetch: true, forceTerminate: true) + await fixture.session.finishProbe(success: false, resetAfterFetch: false) + } + + @Test + func `authentication prompt matcher tolerates prompt casing and spacing`() { + #expect(AntigravityCLIHTTPSFetchStrategy.containsAuthenticationPrompt( + Data("select LOGIN\nmethod :".utf8))) + #expect(AntigravityCLIHTTPSFetchStrategy.containsAuthenticationPrompt( + Data("Select login method:".utf8))) + #expect(!AntigravityCLIHTTPSFetchStrategy.containsAuthenticationPrompt( + Data("You are currently not signed in".utf8))) + } + + @Test + func `session returns complete new output before retaining only its tail`() async throws { + let fixture = self.makeFixture() + fixture.identity.setIdentity(pid: 10, executablePath: "/bin/agy", startEpoch: 100) + + _ = try await fixture.session.beginProbe(binary: "/bin/agy") + let handle = try #require(fixture.launcher.handleSnapshot().first) + let prompt = Data("Select login method:".utf8) + let oversizedRedraw = prompt + Data(repeating: 0x20, count: 8192) + handle.enqueueDrainOutput(oversizedRedraw) + + let searchableOutput = await fixture.session.drainOutput() + let retainedTail = await fixture.session.drainOutput() + + #expect(searchableOutput == oversizedRedraw) + #expect(AntigravityCLIHTTPSFetchStrategy.containsAuthenticationPrompt(searchableOutput)) + #expect(AntigravityCLIHTTPSFetchStrategy.containsAuthenticationPrompt(retainedTail)) + + await fixture.session.finishProbe(success: false, resetAfterFetch: true, forceTerminate: true) + } + + @Test + func `registration failure tears down launched process`() async { + let fixture = self.makeFixture() + fixture.registry.setShouldRegister(false) + + await #expect(throws: AntigravityCLISession.SessionError.self) { + _ = try await fixture.session.beginProbe(binary: "/bin/agy") + } + + let handle = fixture.launcher.handleSnapshot().first + #expect(handle?.isRunning == false) + #expect(handle?.snapshotEvents().contains("sendExit") == false) + #expect(handle?.snapshotEvents().contains("closePTY") == true) + #expect(handle?.snapshotEvents().contains("terminateRoot") == true) + #expect(fixture.registry.registeredSnapshot().isEmpty) + } + + @Test + func `launch remains usable when coordination lock is unavailable`() async throws { + let fixture = self.makeFixture(launchLock: FailingAntigravitySessionLaunchLock()) + fixture.identity.setIdentity(pid: 10, executablePath: "/bin/agy", startEpoch: 100) + + let pid = try await fixture.session.beginProbe(binary: "/bin/agy") + await fixture.session.finishProbe(success: true, resetAfterFetch: true) + + #expect(pid == 10) + #expect(fixture.store.snapshot() == nil) + } + + @Test + func `launch remains usable when ownership record cannot be saved`() async throws { + let store = MemoryAntigravitySessionRecordStore(failSaves: true) + let fixture = self.makeFixture(store: store) + fixture.identity.setIdentity(pid: 10, executablePath: "/bin/agy", startEpoch: 100) + + let pid = try await fixture.session.beginProbe(binary: "/bin/agy") + await fixture.session.finishProbe(success: true, resetAfterFetch: true) + + #expect(pid == 10) + #expect(store.snapshot() == nil) + } + + @Test + func `idle window tears down warm process`() async throws { + let fixture = self.makeFixture(idleWindow: 0.05, manualSleep: true) + fixture.identity.setIdentity(pid: 10, executablePath: "/bin/agy", startEpoch: 100) + + _ = try await fixture.session.beginProbe(binary: "/bin/agy") + await fixture.session.finishProbe(success: true, resetAfterFetch: false) + await fixture.sleeper?.waitForSleeps(1) + fixture.sleeper?.resumeAll() + await self.waitUntilStopped(fixture.session) + + #expect(await fixture.session.isRunning == false) + #expect(fixture.registry.unregisteredSnapshot() == [10]) + #expect(fixture.store.snapshot() == nil) + } + + @Test + func `host idle window extends the default session lifetime`() async throws { + let fixture = self.makeFixture(idleWindow: 180) + fixture.identity.setIdentity(pid: 10, executablePath: "/bin/agy", startEpoch: 100) + + _ = try await fixture.session.beginProbe(binary: "/bin/agy", idleWindow: 360) + await fixture.session.finishProbe(success: true, resetAfterFetch: false) + + #expect(await fixture.session.idleWindowForTesting == 360) + } + + @Test + func `active probe prevents idle teardown until finish`() async throws { + let fixture = self.makeFixture(idleWindow: 0.05, manualSleep: true) + fixture.identity.setIdentity(pid: 10, executablePath: "/bin/agy", startEpoch: 100) + + _ = try await fixture.session.beginProbe(binary: "/bin/agy") + await fixture.session.finishProbe(success: true, resetAfterFetch: false) + _ = try await fixture.session.beginProbe(binary: "/bin/agy") + await fixture.sleeper?.waitForSleeps(1) + fixture.sleeper?.resumeAll() + await Task.yield() + + #expect(await fixture.session.isRunning) + await fixture.session.finishProbe(success: true, resetAfterFetch: false) + await fixture.sleeper?.waitForSleeps(1) + fixture.sleeper?.resumeAll() + await self.waitUntilStopped(fixture.session) + #expect(await fixture.session.isRunning == false) + } + + @Test + func `manual reset waits for active probe to finish`() async throws { + let fixture = self.makeFixture() + fixture.identity.setIdentity(pid: 10, executablePath: "/bin/agy", startEpoch: 100) + + _ = try await fixture.session.beginProbe(binary: "/bin/agy") + await fixture.session.reset() + #expect(await fixture.session.isRunning) + await fixture.session.finishProbe(success: true, resetAfterFetch: false) + + #expect(await fixture.session.isRunning == false) + #expect(fixture.registry.unregisteredSnapshot() == [10]) + } + + @Test + func `reset reaps persisted stale session when no in memory process exists`() async { + let store = MemoryAntigravitySessionRecordStore(record: AntigravityCLISessionRecord( + pid: 777, + requestedBinaryPath: "/bin/agy", + executablePath: "/bin/agy", + startEpoch: 42, + processGroup: 777)) + let fixture = self.makeFixture(store: store) + fixture.identity.setIdentity(pid: 777, executablePath: "/bin/agy", startEpoch: 42) + + await fixture.session.reset() + + let terminations = fixture.terminations.snapshot() + #expect(terminations.map(\.signal) == [SIGTERM, SIGKILL]) + #expect(terminations.allSatisfy { $0.pid == 777 && $0.group == 777 }) + #expect(fixture.store.snapshot() == nil) + } + + @Test + func `reset preserves persisted session owned by another live process`() async { + let store = MemoryAntigravitySessionRecordStore(record: AntigravityCLISessionRecord( + pid: 777, + requestedBinaryPath: "/bin/agy", + executablePath: "/bin/agy", + startEpoch: 42, + processGroup: 777, + ownerPID: 900, + ownerExecutablePath: "/Applications/CodexBar.app/Contents/MacOS/CodexBar", + ownerStartEpoch: 10)) + let fixture = self.makeFixture(store: store, currentProcessID: 901) + fixture.identity.setIdentity(pid: 777, executablePath: "/bin/agy", startEpoch: 42) + fixture.identity.setIdentity( + pid: 900, + executablePath: "/Applications/CodexBar.app/Contents/MacOS/CodexBar", + startEpoch: 10) + + await fixture.session.reset() + + #expect(fixture.terminations.snapshot().isEmpty) + #expect(fixture.store.snapshot() != nil) + } + + @Test + func `launch tracks an independent session while another process is live`() async throws { + let protectedRecord = AntigravityCLISessionRecord( + pid: 777, + requestedBinaryPath: "/bin/agy", + executablePath: "/bin/agy", + startEpoch: 42, + processGroup: 777, + ownerPID: 900, + ownerExecutablePath: "/Applications/CodexBar.app/Contents/MacOS/CodexBar", + ownerStartEpoch: 10) + let store = MemoryAntigravitySessionRecordStore(record: protectedRecord) + let fixture = self.makeFixture(store: store, currentProcessID: 901) + fixture.identity.setIdentity(pid: 777, executablePath: "/bin/agy", startEpoch: 42) + fixture.identity.setIdentity( + pid: 900, + executablePath: "/Applications/CodexBar.app/Contents/MacOS/CodexBar", + startEpoch: 10) + fixture.identity.setIdentity(pid: 10, executablePath: "/bin/agy", startEpoch: 100) + fixture.identity.setIdentity(pid: 901, executablePath: "/app/codexbar", startEpoch: 20) + + _ = try await fixture.session.beginProbe(binary: "/bin/agy") + + #expect(fixture.launcher.launchedBinarySnapshot() == ["/bin/agy"]) + #expect(fixture.terminations.snapshot().isEmpty) + #expect(fixture.store.saveCount == 1) + #expect(Set(fixture.store.snapshots().map(\.pid)) == [10, 777]) + + await fixture.session.finishProbe(success: true, resetAfterFetch: true) + #expect(fixture.store.snapshot() == protectedRecord) + } + + @Test + func `different binary tracks an independent session while another process is live`() async throws { + let protectedRecord = AntigravityCLISessionRecord( + pid: 777, + requestedBinaryPath: "/bin/agy", + executablePath: "/bin/agy", + startEpoch: 42, + processGroup: 777, + ownerPID: 900, + ownerExecutablePath: "/Applications/CodexBar.app/Contents/MacOS/CodexBar", + ownerStartEpoch: 10) + let store = MemoryAntigravitySessionRecordStore(record: protectedRecord) + let fixture = self.makeFixture(store: store, currentProcessID: 901) + fixture.identity.setIdentity(pid: 777, executablePath: "/bin/agy", startEpoch: 42) + fixture.identity.setIdentity( + pid: 900, + executablePath: "/Applications/CodexBar.app/Contents/MacOS/CodexBar", + startEpoch: 10) + fixture.identity.setIdentity(pid: 10, executablePath: "/new/agy", startEpoch: 100) + fixture.identity.setIdentity(pid: 901, executablePath: "/app/codexbar", startEpoch: 20) + + _ = try await fixture.session.beginProbe(binary: "/new/agy") + + #expect(fixture.launcher.launchedBinarySnapshot() == ["/new/agy"]) + #expect(fixture.store.saveCount == 1) + #expect(Set(fixture.store.snapshots().map(\.pid)) == [10, 777]) + + await fixture.session.finishProbe(success: true, resetAfterFetch: true) + #expect(fixture.store.snapshot() == protectedRecord) + } + + @Test + func `concurrent hosts atomically track independent sessions`() async { + let store = MemoryAntigravitySessionRecordStore() + let launchLock = MemoryAntigravitySessionLaunchLock() + let identity = FakeAntigravityIdentityProvider() + let firstLauncher = FakeAntigravityProcessLauncher(nextPID: 10) + let secondLauncher = FakeAntigravityProcessLauncher(nextPID: 20) + identity.setIdentity(pid: 10, executablePath: "/bin/agy", startEpoch: 100) + identity.setIdentity(pid: 20, executablePath: "/bin/agy", startEpoch: 200) + identity.setIdentity(pid: 900, executablePath: "/app/CodexBar", startEpoch: 1) + identity.setIdentity(pid: 901, executablePath: "/app/codexbar", startEpoch: 2) + let first = self.makeFixture( + launcher: firstLauncher, + identity: identity, + store: store, + launchLock: launchLock, + currentProcessID: 900) + let second = self.makeFixture( + launcher: secondLauncher, + identity: identity, + store: store, + launchLock: launchLock, + currentProcessID: 901) + + async let firstStarted = Self.beginPersistentSession(first.session) + async let secondStarted = Self.beginPersistentSession(second.session) + let results = await [firstStarted, secondStarted] + + #expect(!results.contains(false)) + #expect(firstLauncher.launchedBinarySnapshot().count + secondLauncher.launchedBinarySnapshot().count == 2) + #expect(Set(store.snapshots().map(\.pid)) == [10, 20]) + + await first.session.reset() + await second.session.reset() + #expect(store.snapshots().isEmpty) + } +} + +extension AntigravityCLISessionTests { + @Test + func `warm reuse reaps a crashed peer session`() async throws { + let store = MemoryAntigravitySessionRecordStore() + let launchLock = MemoryAntigravitySessionLaunchLock() + let identity = FakeAntigravityIdentityProvider() + let firstLauncher = FakeAntigravityProcessLauncher(nextPID: 10) + let secondLauncher = FakeAntigravityProcessLauncher(nextPID: 20) + identity.setIdentity(pid: 10, executablePath: "/bin/agy", startEpoch: 100) + identity.setIdentity(pid: 20, executablePath: "/bin/agy", startEpoch: 200) + identity.setIdentity(pid: 900, executablePath: "/app/CodexBar", startEpoch: 1) + identity.setIdentity(pid: 901, executablePath: "/app/codexbar", startEpoch: 2) + let first = self.makeFixture( + launcher: firstLauncher, + identity: identity, + store: store, + launchLock: launchLock, + currentProcessID: 900) + let second = self.makeFixture( + launcher: secondLauncher, + identity: identity, + store: store, + launchLock: launchLock, + currentProcessID: 901) + #expect(await Self.beginPersistentSession(first.session)) + #expect(await Self.beginPersistentSession(second.session)) + + identity.removeIdentity(pid: 901) + _ = try await first.session.beginProbe(binary: "/bin/agy") + await first.session.finishProbe(success: true, resetAfterFetch: false) + + #expect(first.terminations.snapshot().map(\.pid) == [20, 20]) + #expect(store.snapshots().map(\.pid) == [10]) + + await first.session.reset() + await second.session.reset() + } + + @Test + func `file store migrates legacy record and preserves independent owners`() throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("CodexBarAntigravitySessionTests-\(UUID().uuidString)", isDirectory: true) + let fileURL = directory.appendingPathComponent("agy-session.json") + defer { try? FileManager.default.removeItem(at: directory) } + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + + let legacy = AntigravityCLISessionRecord( + pid: 10, + requestedBinaryPath: "/bin/agy", + executablePath: "/bin/agy", + startEpoch: 100, + processGroup: 10, + ownerPID: 900, + ownerExecutablePath: "/app/CodexBar", + ownerStartEpoch: 1) + try JSONEncoder().encode(legacy).write(to: fileURL) + let store = AntigravityFileCLISessionRecordStore(fileURL: fileURL) + #expect(try store.load() == [legacy]) + + let second = AntigravityCLISessionRecord( + pid: 20, + requestedBinaryPath: "/bin/agy", + executablePath: "/bin/agy", + startEpoch: 200, + processGroup: 20, + ownerPID: 901, + ownerExecutablePath: "/app/codexbar", + ownerStartEpoch: 2) + try store.save(second) + #expect(try Set(store.load().map(\.pid)) == [10, 20]) + + try store.remove(legacy) + #expect(try store.load() == [second]) + + try Data("{".utf8).write(to: fileURL) + try store.save(legacy) + #expect(try store.load() == [legacy]) + } + + @Test + func `session reaps stale owner before launch`() async throws { + let protectedRecord = AntigravityCLISessionRecord( + pid: 777, + requestedBinaryPath: "/bin/agy", + executablePath: "/bin/agy", + startEpoch: 42, + processGroup: 777, + ownerPID: 900, + ownerExecutablePath: "/Applications/CodexBar.app/Contents/MacOS/CodexBar", + ownerStartEpoch: 10) + let store = MemoryAntigravitySessionRecordStore(record: protectedRecord) + let fixture = self.makeFixture(store: store, currentProcessID: 901) + fixture.identity.setIdentity(pid: 777, executablePath: "/bin/agy", startEpoch: 42) + fixture.identity.setIdentity( + pid: 900, + executablePath: "/Applications/CodexBar.app/Contents/MacOS/CodexBar", + startEpoch: 10) + fixture.identity.setIdentity( + pid: 901, + executablePath: "/Applications/CodexBar.app/Contents/MacOS/CodexBar", + startEpoch: 20) + fixture.identity.setIdentity(pid: 10, executablePath: "/bin/agy", startEpoch: 100) + + fixture.identity.removeIdentity(pid: 900) + let secondPID = try await fixture.session.beginProbe(binary: "/bin/agy") + let record = fixture.store.snapshot() + await fixture.session.finishProbe(success: true, resetAfterFetch: true) + #expect(secondPID == 10) + #expect(fixture.launcher.launchedBinarySnapshot() == ["/bin/agy"]) + #expect(fixture.terminations.snapshot().map(\.pid) == [777, 777]) + #expect(fixture.store.saveCount == 1) + #expect(record?.pid == 10) + #expect(record?.ownerPID == 901) + } + + @Test + func `reset rechecks protected persisted session after owner exits`() async { + let store = MemoryAntigravitySessionRecordStore(record: AntigravityCLISessionRecord( + pid: 777, + requestedBinaryPath: "/bin/agy", + executablePath: "/bin/agy", + startEpoch: 42, + processGroup: 777, + ownerPID: 900, + ownerExecutablePath: "/Applications/CodexBar.app/Contents/MacOS/CodexBar", + ownerStartEpoch: 10)) + let fixture = self.makeFixture(store: store, currentProcessID: 901) + fixture.identity.setIdentity(pid: 777, executablePath: "/bin/agy", startEpoch: 42) + fixture.identity.setIdentity( + pid: 900, + executablePath: "/Applications/CodexBar.app/Contents/MacOS/CodexBar", + startEpoch: 10) + + await fixture.session.reset() + fixture.identity.removeIdentity(pid: 900) + await fixture.session.reset() + + let terminations = fixture.terminations.snapshot() + #expect(terminations.map(\.signal) == [SIGTERM, SIGKILL]) + #expect(terminations.allSatisfy { $0.pid == 777 && $0.group == 777 }) + #expect(fixture.store.snapshot() == nil) + } + + @Test + func `teardown preserves record written by another live session`() async throws { + let fixture = self.makeFixture() + fixture.identity.setIdentity(pid: 10, executablePath: "/bin/agy", startEpoch: 100) + + _ = try await fixture.session.beginProbe(binary: "/bin/agy") + let otherRecord = AntigravityCLISessionRecord( + pid: 777, + requestedBinaryPath: "/other/agy", + executablePath: "/other/agy", + startEpoch: 42, + processGroup: 777) + try fixture.store.save(otherRecord) + await fixture.session.finishProbe(success: true, resetAfterFetch: true) + + #expect(fixture.store.snapshot() == otherRecord) + } + + @Test + func `force killed process is polled again so the child can be reaped`() async throws { + let fixture = self.makeFixture(terminateRootStopsProcess: false) + fixture.identity.setIdentity(pid: 10, executablePath: "/bin/agy", startEpoch: 100) + fixture.identity.setIdentity(pid: 900, executablePath: "/app/CodexBar", startEpoch: 1) + + _ = try await fixture.session.beginProbe(binary: "/bin/agy") + await fixture.session.finishProbe(success: true, resetAfterFetch: true) + + let events = fixture.launcher.handleSnapshot().first?.snapshotEvents() ?? [] + guard let killIndex = events.firstIndex(of: "terminateTree:\(SIGKILL)") else { + Issue.record("Expected SIGKILL during teardown") + return + } + #expect(events.dropFirst(killIndex + 1).contains("isRunning:false")) + } + + @Test + func `one shot CLI reset tears down after fetch`() async throws { + let fixture = self.makeFixture() + fixture.identity.setIdentity(pid: 10, executablePath: "/bin/agy", startEpoch: 100) + + _ = try await fixture.session.beginProbe(binary: "/bin/agy") + await fixture.session.finishProbe(success: true, resetAfterFetch: true) + + #expect(await fixture.session.isRunning == false) + #expect(fixture.registry.unregisteredSnapshot() == [10]) + } + + @Test + func `one shot CLI reset is deferred until all active probes finish`() async throws { + let fixture = self.makeFixture() + fixture.identity.setIdentity(pid: 10, executablePath: "/bin/agy", startEpoch: 100) + + _ = try await fixture.session.beginProbe(binary: "/bin/agy") + _ = try await fixture.session.beginProbe(binary: "/bin/agy") + + await fixture.session.finishProbe(success: true, resetAfterFetch: true) + #expect(await fixture.session.isRunning) + + await fixture.session.finishProbe(success: true, resetAfterFetch: false) + + #expect(await fixture.session.isRunning == false) + #expect(fixture.registry.unregisteredSnapshot() == [10]) + } + + @Test + func `authentication reset never writes interactive exit input`() async throws { + let fixture = self.makeFixture() + fixture.identity.setIdentity(pid: 10, executablePath: "/bin/agy", startEpoch: 100) + + _ = try await fixture.session.beginProbe(binary: "/bin/agy") + await fixture.session.finishProbe(success: false, resetAfterFetch: true, forceTerminate: true) + + let events = try #require(fixture.launcher.handleSnapshot().first).snapshotEvents() + #expect(!events.contains("sendExit")) + #expect(events.contains("closePTY")) + #expect(events.contains("terminateRoot")) + #expect(await fixture.session.isRunning == false) + } + + @Test + func `failed reset never writes interactive exit input`() async throws { + let fixture = self.makeFixture() + fixture.identity.setIdentity(pid: 10, executablePath: "/bin/agy", startEpoch: 100) + + _ = try await fixture.session.beginProbe(binary: "/bin/agy") + await fixture.session.finishProbe(success: false, resetAfterFetch: true) + + let events = try #require(fixture.launcher.handleSnapshot().first).snapshotEvents() + #expect(!events.contains("sendExit")) + #expect(events.contains("terminateRoot")) + #expect(await fixture.session.isRunning == false) + } + + @Test + func `concurrent success preserves a failed probes deferred hard reset`() async throws { + let fixture = self.makeFixture() + fixture.identity.setIdentity(pid: 10, executablePath: "/bin/agy", startEpoch: 100) + + _ = try await fixture.session.beginProbe(binary: "/bin/agy") + _ = try await fixture.session.beginProbe(binary: "/bin/agy") + await fixture.session.finishProbe(success: false, resetAfterFetch: true) + #expect(await fixture.session.isRunning) + + await fixture.session.finishProbe(success: true, resetAfterFetch: false) + + let events = try #require(fixture.launcher.handleSnapshot().first).snapshotEvents() + #expect(!events.contains("sendExit")) + #expect(events.contains("terminateRoot")) + #expect(await fixture.session.isRunning == false) + } + + @Test + func `idle timeout hard stops a previously failed process`() async throws { + let fixture = self.makeFixture(idleWindow: 0.05, manualSleep: true) + fixture.identity.setIdentity(pid: 10, executablePath: "/bin/agy", startEpoch: 100) + + _ = try await fixture.session.beginProbe(binary: "/bin/agy") + await fixture.session.finishProbe(success: false, resetAfterFetch: false) + await fixture.sleeper?.waitForSleeps(1) + fixture.sleeper?.resumeAll() + await self.waitUntilStopped(fixture.session) + + let events = try #require(fixture.launcher.handleSnapshot().first).snapshotEvents() + #expect(!events.contains("sendExit")) + #expect(events.contains("terminateRoot")) + } + + @Test + func `repeated probe failures relaunch session`() async throws { + let fixture = self.makeFixture(failureRelaunchThreshold: 2) + fixture.identity.setIdentity(pid: 10, executablePath: "/bin/agy", startEpoch: 100) + fixture.identity.setIdentity(pid: 11, executablePath: "/bin/agy", startEpoch: 101) + + _ = try await fixture.session.beginProbe(binary: "/bin/agy") + await fixture.session.finishProbe(success: false, resetAfterFetch: false) + _ = try await fixture.session.beginProbe(binary: "/bin/agy") + await fixture.session.finishProbe(success: false, resetAfterFetch: false) + let relaunchedPID = try await fixture.session.beginProbe(binary: "/bin/agy") + await fixture.session.finishProbe(success: true, resetAfterFetch: false) + + #expect(relaunchedPID == 11) + #expect(fixture.launcher.launchedBinarySnapshot() == ["/bin/agy", "/bin/agy"]) + #expect(await fixture.session.failureCountForTesting == 0) + } + + @Test + func `session reset reasons distinguish authentication from unhealthy probes`() { + #expect(AntigravityCLISession.resetCause( + authenticationRequired: true, + resetAfterFetch: true, + shouldForceStopUnhealthy: true).message == "authentication required") + #expect(AntigravityCLISession.resetCause( + authenticationRequired: false, + resetAfterFetch: true, + shouldForceStopUnhealthy: true).message == "unhealthy CLI HTTPS session") + #expect(AntigravityCLISession.resetCause( + authenticationRequired: false, + resetAfterFetch: true, + shouldForceStopUnhealthy: false).message == "one-shot CLI fetch") + #expect(AntigravityCLISession.resetCause( + authenticationRequired: false, + resetAfterFetch: false, + shouldForceStopUnhealthy: false).message == "deferred reset") + } + + @Test + func `deferred unhealthy reset preserves its cause after a concurrent success`() async throws { + let fixture = self.makeFixture(failureRelaunchThreshold: 1) + fixture.identity.setIdentity(pid: 10, executablePath: "/bin/agy", startEpoch: 100) + + _ = try await fixture.session.beginProbe(binary: "/bin/agy") + _ = try await fixture.session.beginProbe(binary: "/bin/agy") + await fixture.session.finishProbe(success: false, resetAfterFetch: false) + await fixture.session.finishProbe(success: true, resetAfterFetch: false) + + #expect(await fixture.session.lastStopReasonForTesting == "unhealthy CLI HTTPS session") + } + + @Test + func `deferred authentication reset preserves its cause after a concurrent success`() async throws { + let fixture = self.makeFixture() + fixture.identity.setIdentity(pid: 10, executablePath: "/bin/agy", startEpoch: 100) + + _ = try await fixture.session.beginProbe(binary: "/bin/agy") + _ = try await fixture.session.beginProbe(binary: "/bin/agy") + await fixture.session.finishProbe(success: false, resetAfterFetch: true, forceTerminate: true) + await fixture.session.finishProbe(success: true, resetAfterFetch: false) + + #expect(await fixture.session.lastStopReasonForTesting == "authentication required") + } + + @Test + func `success resets failure counter`() async throws { + let fixture = self.makeFixture(failureRelaunchThreshold: 2) + fixture.identity.setIdentity(pid: 10, executablePath: "/bin/agy", startEpoch: 100) + + _ = try await fixture.session.beginProbe(binary: "/bin/agy") + await fixture.session.finishProbe(success: false, resetAfterFetch: false) + _ = try await fixture.session.beginProbe(binary: "/bin/agy") + await fixture.session.finishProbe(success: true, resetAfterFetch: false) + _ = try await fixture.session.beginProbe(binary: "/bin/agy") + await fixture.session.finishProbe(success: false, resetAfterFetch: false) + + #expect(fixture.launcher.launchedBinarySnapshot() == ["/bin/agy"]) + #expect(await fixture.session.failureCountForTesting == 1) + } + + @Test + func `matching persisted stale process is reaped when resolved binary changed`() async throws { + let store = MemoryAntigravitySessionRecordStore(record: AntigravityCLISessionRecord( + pid: 777, + requestedBinaryPath: "/old/agy", + executablePath: "/old/agy", + startEpoch: 42, + processGroup: 777)) + let fixture = self.makeFixture(store: store) + fixture.identity.setIdentity(pid: 777, executablePath: "/old/agy", startEpoch: 42) + fixture.identity.setIdentity(pid: 10, executablePath: "/new/agy", startEpoch: 100) + + _ = try await fixture.session.beginProbe(binary: "/new/agy") + await fixture.session.finishProbe(success: true, resetAfterFetch: false) + + let terminations = fixture.terminations.snapshot() + #expect(terminations.map(\.signal) == [SIGTERM, SIGKILL]) + #expect(terminations.allSatisfy { $0.pid == 777 && $0.group == 777 }) + } + + @Test + func `matching persisted stale process is reaped before launch`() async throws { + let store = MemoryAntigravitySessionRecordStore(record: AntigravityCLISessionRecord( + pid: 777, + requestedBinaryPath: "/bin/agy", + executablePath: "/bin/agy", + startEpoch: 42, + processGroup: 777)) + let fixture = self.makeFixture(store: store) + fixture.identity.setIdentity(pid: 777, executablePath: "/bin/agy", startEpoch: 42) + fixture.identity.setIdentity(pid: 10, executablePath: "/bin/agy", startEpoch: 100) + + _ = try await fixture.session.beginProbe(binary: "/bin/agy") + await fixture.session.finishProbe(success: true, resetAfterFetch: false) + + let terminations = fixture.terminations.snapshot() + #expect(terminations.map(\.signal) == [SIGTERM, SIGKILL]) + #expect(terminations.allSatisfy { $0.pid == 777 && $0.group == 777 }) + } + + @Test + func `non matching persisted process is not reaped`() async throws { + let store = MemoryAntigravitySessionRecordStore(record: AntigravityCLISessionRecord( + pid: 777, + requestedBinaryPath: "/bin/agy", + executablePath: "/bin/agy", + startEpoch: 42, + processGroup: 777)) + let fixture = self.makeFixture(store: store) + fixture.identity.setIdentity(pid: 777, executablePath: "/usr/bin/other", startEpoch: 42) + fixture.identity.setIdentity(pid: 10, executablePath: "/bin/agy", startEpoch: 100) + + _ = try await fixture.session.beginProbe(binary: "/bin/agy") + await fixture.session.finishProbe(success: true, resetAfterFetch: false) + + #expect(fixture.terminations.snapshot().isEmpty) + } + + private func waitForLaunches(_ launcher: FakeAntigravityProcessLauncher, count: Int) async -> Bool { + for _ in 0..<200 { + if launcher.launchedBinarySnapshot().count >= count { return true } + try? await Task.sleep(nanoseconds: 1_000_000) + } + return false + } + + private func waitUntilStopped(_ session: AntigravityCLISession) async { + for _ in 0..<200 { + let running = await session.isRunning + if !running { return } + await Task.yield() + } + Issue.record("Timed out waiting for Antigravity CLI session to stop") + } + + private static func beginPersistentSession(_ session: AntigravityCLISession) async -> Bool { + do { + _ = try await session.beginProbe(binary: "/bin/agy") + await session.finishProbe(success: true, resetAfterFetch: false) + return true + } catch { + return false + } + } + + private struct Fixture { + let session: AntigravityCLISession + let launcher: FakeAntigravityProcessLauncher + let identity: FakeAntigravityIdentityProvider + let store: MemoryAntigravitySessionRecordStore + let terminations: AntigravitySessionTerminationRecorder + let registry: AntigravityRegistryRecorder + let launchReservations: AntigravityLaunchReservationRecorder + let sleeper: AntigravityManualSleeper? + } + + private func makeFixture( + launcher suppliedLauncher: FakeAntigravityProcessLauncher? = nil, + identity suppliedIdentity: FakeAntigravityIdentityProvider? = nil, + store: MemoryAntigravitySessionRecordStore = MemoryAntigravitySessionRecordStore(), + launchLock: any AntigravityCLISessionLaunchLocking = MemoryAntigravitySessionLaunchLock(), + idleWindow: TimeInterval = 3600, + failureRelaunchThreshold: Int = 2, + manualSleep: Bool = false, + terminationGracePeriod: TimeInterval = 0, + terminateRootStopsProcess: Bool = true, + currentProcessID: pid_t = 900) -> Fixture + { + let launcher = suppliedLauncher ?? FakeAntigravityProcessLauncher(nextPID: 10) + launcher.setTerminateRootStopsProcess(terminateRootStopsProcess) + let identity = suppliedIdentity ?? FakeAntigravityIdentityProvider() + let terminations = AntigravitySessionTerminationRecorder() + let registry = AntigravityRegistryRecorder() + let launchReservations = AntigravityLaunchReservationRecorder() + let sleeper = manualSleep ? AntigravityManualSleeper() : nil + let session = AntigravityCLISession(dependencies: AntigravityCLISession.Dependencies( + launcher: launcher, + identityProvider: identity, + recordStore: store, + launchLock: launchLock, + beginAppShutdownTrackedLaunch: { launchReservations.begin() }, + endAppShutdownTrackedLaunch: { launchReservations.end() }, + registerForAppShutdown: { pid, binary in registry.register(pid: pid, binary) }, + updateAppShutdownProcessGroup: { pid, group in registry.update(pid: pid, group: group) }, + unregisterForAppShutdown: { pid in registry.unregister(pid: pid) }, + descendantPIDs: { pid in [pid + 1, pid + 2] }, + terminateProcessTree: { pid, group, signal, descendants in + terminations.append(pid: pid, group: group, signal: signal, descendants: descendants) + }, + currentProcessID: { currentProcessID }, + now: Date.init, + sleep: { nanoseconds in + if let sleeper { + try await sleeper.sleep(nanoseconds) + } else { + try await Task.sleep(nanoseconds: nanoseconds) + } + }, + idleWindow: idleWindow, + failureRelaunchThreshold: failureRelaunchThreshold, + terminationGracePeriod: terminationGracePeriod)) + return Fixture( + session: session, + launcher: launcher, + identity: identity, + store: store, + terminations: terminations, + registry: registry, + launchReservations: launchReservations, + sleeper: sleeper) + } +} diff --git a/Tests/CodexBarTests/AntigravityCompactFallbackTests.swift b/Tests/CodexBarTests/AntigravityCompactFallbackTests.swift new file mode 100644 index 0000000000..05ba7c7d7d --- /dev/null +++ b/Tests/CodexBarTests/AntigravityCompactFallbackTests.swift @@ -0,0 +1,94 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct AntigravityCompactFallbackTests { + @Test + func `model quota reset proximity does not imply window duration`() throws { + let resetTime = Date().addingTimeInterval(2 * 60 * 60) + let snapshot = AntigravityStatusSnapshot( + modelQuotas: [ + AntigravityModelQuota( + label: "Gemini 3.1 Pro (Low)", + modelId: "MODEL_PLACEHOLDER_M36", + remainingFraction: 0.5, + resetTime: resetTime, + resetDescription: nil), + ], + accountEmail: nil, + accountPlan: nil) + + let usage = try snapshot.toUsageSnapshot() + + #expect(usage.primary?.windowMinutes == nil) + #expect(usage.primary?.resetsAt == resetTime) + } + + @Test + func `local unclassified model remains available as compact fallback`() throws { + let snapshot = AntigravityStatusSnapshot( + modelQuotas: [ + AntigravityModelQuota( + label: "Experimental Model", + modelId: "MODEL_PLACEHOLDER_NEW", + remainingFraction: 0.36, + resetTime: nil, + resetDescription: nil), + ], + accountEmail: nil, + accountPlan: nil, + source: .local) + + let usage = try snapshot.toUsageSnapshot() + + #expect(usage.primary == nil) + #expect(usage.secondary == nil) + #expect(usage.tertiary == nil) + #expect(usage.extraRateWindows?.map(\.id) == ["antigravity-compact-fallback-MODEL_PLACEHOLDER_NEW"]) + #expect(usage.extraRateWindows?.map(\.title) == ["Experimental Model"]) + #expect(usage.extraRateWindows?.map(\.window.usedPercent) == [64]) + } + + @Test + func `remote unclassified model remains detail only`() throws { + let snapshot = AntigravityStatusSnapshot( + modelQuotas: [ + AntigravityModelQuota( + label: "Experimental Model", + modelId: "MODEL_PLACEHOLDER_NEW", + remainingFraction: 0.36, + 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.extraRateWindows?.map(\.id) == ["MODEL_PLACEHOLDER_NEW"]) + } + + @Test + func `fully unused local model remains available as compact fallback`() throws { + let snapshot = AntigravityStatusSnapshot( + modelQuotas: [ + AntigravityModelQuota( + label: "Experimental Model", + modelId: "MODEL_PLACEHOLDER_NEW", + remainingFraction: 1, + resetTime: nil, + resetDescription: nil), + ], + accountEmail: nil, + accountPlan: nil, + source: .local) + + let usage = try snapshot.toUsageSnapshot() + + #expect(usage.extraRateWindows?.map(\.id) == ["antigravity-compact-fallback-MODEL_PLACEHOLDER_NEW"]) + #expect(usage.extraRateWindows?.map(\.window.usedPercent) == [0]) + } +} diff --git a/Tests/CodexBarTests/AntigravityDeadlineTests.swift b/Tests/CodexBarTests/AntigravityDeadlineTests.swift new file mode 100644 index 0000000000..7c9234f3d9 --- /dev/null +++ b/Tests/CodexBarTests/AntigravityDeadlineTests.swift @@ -0,0 +1,199 @@ +import Foundation +import Testing +@testable import CodexBarCore + +private final class AntigravityTimeoutRecorder: @unchecked Sendable { + private let lock = NSLock() + private var timeouts: [TimeInterval] = [] + + func append(_ timeout: TimeInterval) { + self.lock.withLock { + self.timeouts.append(timeout) + } + } + + func snapshot() -> [TimeInterval] { + self.lock.withLock { + self.timeouts + } + } +} + +private final class AntigravityConcurrencyRecorder: @unchecked Sendable { + private let lock = NSLock() + private var activeCount = 0 + private var maximumActiveCount = 0 + + func begin() { + self.lock.withLock { + self.activeCount += 1 + self.maximumActiveCount = max(self.maximumActiveCount, self.activeCount) + } + } + + func end() { + self.lock.withLock { + self.activeCount -= 1 + } + } + + func maximum() -> Int { + self.lock.withLock { + self.maximumActiveCount + } + } +} + +struct AntigravityDeadlineTests { + @Test + func `process candidates probe concurrently while preserving result order`() async throws { + let processInfos = [ + AntigravityStatusProbe.ProcessInfoResult( + pid: 1, + extensionPort: nil, + extensionServerCSRFToken: nil, + csrfToken: "first", + commandLine: "first"), + AntigravityStatusProbe.ProcessInfoResult( + pid: 2, + extensionPort: nil, + extensionServerCSRFToken: nil, + csrfToken: "second", + commandLine: "second"), + ] + let concurrency = AntigravityConcurrencyRecorder() + + let result = try await AntigravityStatusProbe.fetchProcessSnapshots( + processInfos: processInfos) + { processInfo in + concurrency.begin() + defer { concurrency.end() } + if processInfo.pid == 1 { + try await Task.sleep(for: .milliseconds(120)) + } else { + try await Task.sleep(for: .milliseconds(20)) + } + return AntigravityStatusSnapshot( + modelQuotas: [], + accountEmail: "\(processInfo.pid)@example.com", + accountPlan: nil) + } + + #expect(concurrency.maximum() == 2) + #expect(result.snapshots.map(\.accountEmail) == ["1@example.com", "2@example.com"]) + #expect(result.lastError == nil) + } + + @Test + func `process candidate transport error preserves url error identity`() async throws { + let processInfo = AntigravityStatusProbe.ProcessInfoResult( + pid: 1, + extensionPort: nil, + extensionServerCSRFToken: nil, + csrfToken: "token", + commandLine: "command") + + let result = try await AntigravityStatusProbe.fetchProcessSnapshots(processInfos: [processInfo]) { _ in + throw URLError(.cannotConnectToHost) + } + + #expect((result.lastError as? URLError)?.code == .cannotConnectToHost) + } + + @Test + func `process candidate cancellation rejects partial success`() async { + let processInfos = [ + AntigravityStatusProbe.ProcessInfoResult( + pid: 1, + extensionPort: nil, + extensionServerCSRFToken: nil, + csrfToken: "first", + commandLine: "first"), + AntigravityStatusProbe.ProcessInfoResult( + pid: 2, + extensionPort: nil, + extensionServerCSRFToken: nil, + csrfToken: "second", + commandLine: "second"), + ] + + await #expect(throws: CancellationError.self) { + try await AntigravityStatusProbe.fetchProcessSnapshots(processInfos: processInfos) { processInfo in + if processInfo.pid == 2 { + throw CancellationError() + } + return AntigravityStatusSnapshot( + modelQuotas: [], + accountEmail: "partial@example.com", + accountPlan: nil) + } + } + } + + @Test + func `cancelled process request rejects partial success`() async { + let processInfos = [ + AntigravityStatusProbe.ProcessInfoResult( + pid: 1, + extensionPort: nil, + extensionServerCSRFToken: nil, + csrfToken: "first", + commandLine: "first"), + AntigravityStatusProbe.ProcessInfoResult( + pid: 2, + extensionPort: nil, + extensionServerCSRFToken: nil, + csrfToken: "second", + commandLine: "second"), + ] + + await #expect(throws: CancellationError.self) { + try await AntigravityStatusProbe.fetchProcessSnapshots(processInfos: processInfos) { processInfo in + if processInfo.pid == 2 { + throw URLError(.cancelled) + } + return AntigravityStatusSnapshot( + modelQuotas: [], + accountEmail: "partial@example.com", + accountPlan: nil) + } + } + } + + @Test + func `shared deadline reserves time for later endpoint probes`() async throws { + let endpoints = [ + AntigravityStatusProbe.AntigravityConnectionEndpoint( + scheme: "https", + port: 64001, + csrfToken: "token", + source: .languageServer), + AntigravityStatusProbe.AntigravityConnectionEndpoint( + scheme: "https", + port: 64002, + csrfToken: "token", + source: .languageServer), + ] + let recorder = AntigravityTimeoutRecorder() + let deadline = Date().addingTimeInterval(2) + + let resolved = try await AntigravityStatusProbe.resolveWorkingEndpoint( + candidateEndpoints: endpoints, + timeout: 1, + deadline: deadline, + testConnectivity: { endpoint, timeout in + recorder.append(timeout) + if endpoint.port == 64001 { + try? await Task.sleep(for: .seconds(timeout)) + return false + } + return true + }) + + let timeouts = recorder.snapshot() + #expect(resolved.port == 64002) + #expect(timeouts.count == 2) + #expect(timeouts[0] < 1.1) + #expect(timeouts[1] > 0) + } +} diff --git a/Tests/CodexBarTests/AntigravityLocalSnapshotSelectionTests.swift b/Tests/CodexBarTests/AntigravityLocalSnapshotSelectionTests.swift new file mode 100644 index 0000000000..414d26bf82 --- /dev/null +++ b/Tests/CodexBarTests/AntigravityLocalSnapshotSelectionTests.swift @@ -0,0 +1,46 @@ +import Testing +@testable import CodexBarCore + +struct AntigravityLocalSnapshotSelectionTests { + @Test + func `selected account wins before quota richness`() throws { + let selectedAccount = AntigravityStatusSnapshot( + modelQuotas: [ + AntigravityModelQuota( + label: "Gemini 3 Pro Low", + modelId: "gemini-3-pro-low", + remainingFraction: 0.9, + resetTime: nil, + resetDescription: nil), + ], + accountEmail: "selected@example.com", + accountPlan: "Pro", + source: .local) + let richerOtherAccount = AntigravityStatusSnapshot( + quotaSummary: AntigravityQuotaSummary( + description: nil, + groups: [ + AntigravityQuotaSummaryGroup( + displayName: "Gemini Models", + description: nil, + buckets: [ + AntigravityQuotaSummaryBucket( + bucketId: "gemini-5h", + displayName: "Five Hour Limit", + remainingFraction: 0.9, + resetDescription: nil, + disabled: false), + ]), + ]), + accountEmail: "other@example.com", + accountPlan: "Ultra", + source: .local) + + let selected = try #require( + AntigravityStatusProbe.preferredLocalSnapshot( + [richerOtherAccount, selectedAccount], + matchingAccountEmail: " SELECTED@example.com ")) + + #expect(selected.accountEmail == "selected@example.com") + } +} diff --git a/Tests/CodexBarTests/AntigravityModelLabelTests.swift b/Tests/CodexBarTests/AntigravityModelLabelTests.swift new file mode 100644 index 0000000000..78c1932010 --- /dev/null +++ b/Tests/CodexBarTests/AntigravityModelLabelTests.swift @@ -0,0 +1,25 @@ +import Testing +@testable import CodexBarCore + +struct AntigravityModelLabelTests { + @Test + func `humanizes raw model ids when label matches model id`() { + #expect(AntigravityStatusSnapshot.humanizedModelID("gemini-3-pro-preview") == "Gemini 3 Pro Preview") + #expect(AntigravityStatusSnapshot.humanizedModelID("gemini-2.5-flash") == "Gemini 2.5 Flash") + #expect(AntigravityStatusSnapshot.humanizedModelID("example-3-1-pro-low") == "Example 3.1 Pro Low") + #expect(AntigravityStatusSnapshot.humanizedModelID("gpt-api-oss") == "GPT API OSS") + #expect(AntigravityStatusSnapshot.humanizedModelID("").isEmpty) + } + + @Test + func `preserves custom model labels`() { + let quota = AntigravityModelQuota( + label: "Custom enterprise label", + modelId: "gemini-3-pro-preview", + remainingFraction: 1, + resetTime: nil, + resetDescription: nil) + + #expect(AntigravityStatusSnapshot.quotaDisplayLabel(quota) == "Custom enterprise label") + } +} diff --git a/Tests/CodexBarTests/AntigravityQuotaSummaryTests.swift b/Tests/CodexBarTests/AntigravityQuotaSummaryTests.swift new file mode 100644 index 0000000000..f29e408beb --- /dev/null +++ b/Tests/CodexBarTests/AntigravityQuotaSummaryTests.swift @@ -0,0 +1,490 @@ +import Foundation +import Testing +@testable import CodexBarCore + +private final class AntigravityQuotaSummaryPathRecorder: @unchecked Sendable { + private let lock = NSLock() + private var paths: [String] = [] + + func append(_ path: String) { + self.lock.lock() + self.paths.append(path) + self.lock.unlock() + } + + func snapshot() -> [String] { + self.lock.lock() + let snapshot = self.paths + self.lock.unlock() + return snapshot + } +} + +struct AntigravityQuotaSummaryTests { + @Test + func `parses quota summary response into two model groups with session before weekly windows`() throws { + let snapshot = try AntigravityStatusProbe.parseQuotaSummaryResponse( + Data(antigravityQuotaSummaryJSON().utf8)) + + #expect(snapshot.modelQuotas.isEmpty) + let usage = try snapshot.toUsageSnapshot() + let windows = try #require(usage.extraRateWindows) + + #expect(windows.map(\.id) == [ + "antigravity-quota-summary-gemini-5h", + "antigravity-quota-summary-gemini-weekly", + "antigravity-quota-summary-3p-5h", + "antigravity-quota-summary-3p-weekly", + ]) + #expect(windows.map(\.title) == [ + "Gemini 5-hour", + "Gemini weekly", + "Claude/GPT 5-hour", + "Claude/GPT weekly", + ]) + #expect(windows.map(\.window.windowMinutes) == [300, 10080, 300, 10080]) + #expect(windows.map { $0.window.remainingPercent.rounded() } == [91, 82, 73, 64]) + #expect(windows.map(\.usageKnown) == [true, true, true, true]) + + let expectedDates = [ + ISO8601DateFormatter().date(from: "2026-06-15T11:39:34Z"), + ISO8601DateFormatter().date(from: "2026-06-19T08:45:39Z"), + ISO8601DateFormatter().date(from: "2026-06-15T12:52:10Z"), + ISO8601DateFormatter().date(from: "2026-06-20T00:39:54Z"), + ] + #expect(windows.map(\.window.resetsAt) == expectedDates) + + #expect(usage.primary?.remainingPercent.rounded() == 82) + #expect(usage.secondary?.remainingPercent.rounded() == 64) + #expect(usage.tertiary == nil) + } + + @Test + func `parses quota summary oneof remaining value shape`() throws { + let json = """ + { + "groups": [ + { + "displayName": "Gemini Models", + "buckets": [ + { + "bucketId": "gemini-weekly", + "displayName": "Weekly Limit", + "remaining": { "case": "remainingFraction", "value": 0.5 } + } + ] + } + ] + } + """ + + let snapshot = try AntigravityStatusProbe.parseQuotaSummaryResponse(Data(json.utf8)) + let usage = try snapshot.toUsageSnapshot() + + #expect(usage.extraRateWindows?.first?.window.remainingPercent == 50) + } + + @Test(arguments: ["session", "5h", "5-hour", "five hour", "five-hour"]) + func `normalizes supported session cadence aliases without rewriting bucket IDs`(alias: String) throws { + let bucketID = "gemini-\(alias)" + let json = """ + { + "groups": [ + { + "displayName": "Gemini Models", + "buckets": [ + { + "bucketId": "\(bucketID)", + "displayName": "\(alias)", + "remaining": { "remainingFraction": 0.75 } + } + ] + } + ] + } + """ + + let snapshot = try AntigravityStatusProbe.parseQuotaSummaryResponse(Data(json.utf8)) + let window = try #require(snapshot.toUsageSnapshot().extraRateWindows?.first) + + #expect(window.id == "antigravity-quota-summary-\(bucketID)") + #expect(window.title == "Gemini 5-hour") + #expect(window.window.windowMinutes == 300) + #expect(window.window.remainingPercent == 75) + } + + @Test + func `recognizes underscore cadence without rewriting bucket ID`() throws { + let json = """ + { + "groups": [ + { + "displayName": "Gemini Models", + "buckets": [ + { + "bucketId": "gemini_session", + "displayName": "Gemini", + "remaining": { "remainingFraction": 0.75 } + } + ] + } + ] + } + """ + + let snapshot = try AntigravityStatusProbe.parseQuotaSummaryResponse(Data(json.utf8)) + let window = try #require(snapshot.toUsageSnapshot().extraRateWindows?.first) + + #expect(window.id == "antigravity-quota-summary-gemini_session") + #expect(window.title == "Gemini 5-hour") + #expect(window.window.windowMinutes == 300) + } + + @Test + func `recognizes prefixed cadence before limit suffix`() throws { + let json = """ + { + "groups": [ + { + "displayName": "Gemini Models", + "buckets": [ + { + "bucketId": "gemini-5h limit", + "displayName": "Gemini quota", + "remaining": { "remainingFraction": 0.75 } + } + ] + } + ] + } + """ + + let snapshot = try AntigravityStatusProbe.parseQuotaSummaryResponse(Data(json.utf8)) + let window = try #require(snapshot.toUsageSnapshot().extraRateWindows?.first) + + #expect(window.id == "antigravity-quota-summary-gemini-5h limit") + #expect(window.title == "Gemini 5-hour") + #expect(window.window.windowMinutes == 300) + } + + @Test + func `does not classify cadence aliases embedded inside unrelated words`() throws { + let json = """ + { + "groups": [ + { + "displayName": "Gemini Models", + "buckets": [ + { + "bucketId": "gemini-session-history", + "displayName": "Session History", + "remaining": { "remainingFraction": 0.75 } + } + ] + } + ] + } + """ + + let snapshot = try AntigravityStatusProbe.parseQuotaSummaryResponse(Data(json.utf8)) + let window = try #require(snapshot.toUsageSnapshot().extraRateWindows?.first) + + #expect(window.title == "Gemini Session History") + #expect(window.window.windowMinutes == nil) + } + + @Test + func `fetch snapshot prefers quota summary endpoint and merges identity`() async throws { + let endpoint = AntigravityStatusProbe.AntigravityConnectionEndpoint( + scheme: "https", + port: 64440, + csrfToken: "token", + source: .languageServer) + let paths = AntigravityQuotaSummaryPathRecorder() + + let snapshot = try await AntigravityStatusProbe.fetchSnapshot( + context: AntigravityStatusProbe.RequestContext(endpoints: [endpoint], timeout: 1), + send: { payload, _, _ in + paths.append(payload.path) + if payload.path.contains("GetUserStatus") { + return Data(antigravityUserStatusJSON().utf8) + } + return Data(antigravityQuotaSummaryJSON().utf8) + }) + let usage = try snapshot.toUsageSnapshot() + + #expect(paths.snapshot() == [ + "/exa.language_server_pb.LanguageServerService/RetrieveUserQuotaSummary", + "/exa.language_server_pb.LanguageServerService/GetUserStatus", + ]) + #expect(usage.extraRateWindows?.count == 4) + #expect(usage.identity?.accountEmail == "test@example.com") + #expect(usage.identity?.loginMethod == "Pro") + } + + @Test + func `fetch snapshot keeps quota summary when identity endpoint fails`() async throws { + let endpoint = AntigravityStatusProbe.AntigravityConnectionEndpoint( + scheme: "https", + port: 64440, + csrfToken: "token", + source: .languageServer) + let paths = AntigravityQuotaSummaryPathRecorder() + + let snapshot = try await AntigravityStatusProbe.fetchSnapshot( + context: AntigravityStatusProbe.RequestContext(endpoints: [endpoint], timeout: 1), + send: { payload, _, _ in + paths.append(payload.path) + if payload.path.contains("GetUserStatus") { + return Data(#"{"code":16}"#.utf8) + } + return Data(antigravityQuotaSummaryJSON().utf8) + }) + let usage = try snapshot.toUsageSnapshot() + + #expect(paths.snapshot() == [ + "/exa.language_server_pb.LanguageServerService/RetrieveUserQuotaSummary", + "/exa.language_server_pb.LanguageServerService/GetUserStatus", + ]) + #expect(usage.extraRateWindows?.count == 4) + #expect(usage.identity?.accountEmail == nil) + } + + @Test + func `fetch snapshot falls back to user status when quota summary is unavailable`() async throws { + let endpoint = AntigravityStatusProbe.AntigravityConnectionEndpoint( + scheme: "https", + port: 64440, + csrfToken: "token", + source: .languageServer) + let paths = AntigravityQuotaSummaryPathRecorder() + + let snapshot = try await AntigravityStatusProbe.fetchSnapshot( + context: AntigravityStatusProbe.RequestContext(endpoints: [endpoint], timeout: 1), + send: { payload, _, _ in + paths.append(payload.path) + if payload.path.contains("RetrieveUserQuotaSummary") { + return Data(#"{"code":16}"#.utf8) + } + return Data(antigravityUserStatusJSON().utf8) + }) + let usage = try snapshot.toUsageSnapshot() + + #expect(paths.snapshot() == [ + "/exa.language_server_pb.LanguageServerService/RetrieveUserQuotaSummary", + "/exa.language_server_pb.LanguageServerService/GetUserStatus", + ]) + #expect(usage.primary?.remainingPercent.rounded() == 90) + } + + @Test + func `fetch snapshot falls back when quota summary has no known usage buckets`() async throws { + let endpoint = AntigravityStatusProbe.AntigravityConnectionEndpoint( + scheme: "https", + port: 64440, + csrfToken: "token", + source: .languageServer) + let paths = AntigravityQuotaSummaryPathRecorder() + + let snapshot = try await AntigravityStatusProbe.fetchSnapshot( + context: AntigravityStatusProbe.RequestContext(endpoints: [endpoint], timeout: 1), + send: { payload, _, _ in + paths.append(payload.path) + if payload.path.contains("RetrieveUserQuotaSummary") { + return Data(antigravityQuotaSummaryWithoutKnownUsageJSON().utf8) + } + return Data(antigravityUserStatusJSON().utf8) + }) + let usage = try snapshot.toUsageSnapshot() + + #expect(paths.snapshot() == [ + "/exa.language_server_pb.LanguageServerService/RetrieveUserQuotaSummary", + "/exa.language_server_pb.LanguageServerService/GetUserStatus", + ]) + #expect(usage.primary?.remainingPercent.rounded() == 90) + } + + @Test + func `quota summary timeout reserves deadline for legacy fallback`() async throws { + let endpoint = AntigravityStatusProbe.AntigravityConnectionEndpoint( + scheme: "https", + port: 64440, + csrfToken: "token", + source: .languageServer) + let paths = AntigravityQuotaSummaryPathRecorder() + + let snapshot = try await AntigravityStatusProbe.fetchSnapshot( + context: AntigravityStatusProbe.RequestContext( + endpoints: [endpoint], + timeout: 1, + deadline: Date().addingTimeInterval(2)), + send: { payload, _, timeout in + paths.append(payload.path) + if payload.path.contains("RetrieveUserQuotaSummary") { + try await Task.sleep(for: .seconds(timeout)) + throw AntigravityStatusProbeError.timedOut + } + return Data(antigravityUserStatusJSON().utf8) + }) + let usage = try snapshot.toUsageSnapshot() + + #expect(paths.snapshot() == [ + "/exa.language_server_pb.LanguageServerService/RetrieveUserQuotaSummary", + "/exa.language_server_pb.LanguageServerService/GetUserStatus", + ]) + #expect(usage.primary?.remainingPercent.rounded() == 90) + } + + @Test + func `user status timeout reserves deadline for command model fallback`() async throws { + let endpoint = AntigravityStatusProbe.AntigravityConnectionEndpoint( + scheme: "https", + port: 64440, + csrfToken: "token", + source: .languageServer) + let paths = AntigravityQuotaSummaryPathRecorder() + + let snapshot = try await AntigravityStatusProbe.fetchSnapshot( + context: AntigravityStatusProbe.RequestContext( + endpoints: [endpoint], + timeout: 1, + deadline: Date().addingTimeInterval(2)), + send: { payload, _, timeout in + paths.append(payload.path) + if payload.path.contains("RetrieveUserQuotaSummary") { + throw AntigravityStatusProbeError.apiError("unsupported") + } + if payload.path.contains("GetUserStatus") { + try await Task.sleep(for: .seconds(timeout)) + throw AntigravityStatusProbeError.timedOut + } + return Data(antigravityCommandModelConfigJSON().utf8) + }) + let usage = try snapshot.toUsageSnapshot() + + #expect(paths.snapshot() == [ + "/exa.language_server_pb.LanguageServerService/RetrieveUserQuotaSummary", + "/exa.language_server_pb.LanguageServerService/GetUserStatus", + "/exa.language_server_pb.LanguageServerService/GetCommandModelConfigs", + ]) + #expect(usage.primary?.remainingPercent.rounded() == 90) + } +} + +private func antigravityQuotaSummaryJSON() -> String { + """ + { + "response": { + "description": "Within each group, models share a weekly limit and a 5-hour limit.", + "groups": [ + { + "displayName": "Gemini Models", + "description": "Models within this group: Gemini Flash, Gemini Pro", + "buckets": [ + { + "bucketId": "gemini-weekly", + "displayName": "Weekly Limit", + "remaining": { "remainingFraction": 0.82 }, + "description": "You have used some of your weekly limit, it will fully refresh in 5 days, 11 hours.", + "resetTime": "2026-06-19T08:45:39Z" + }, + { + "bucketId": "gemini-5h", + "displayName": "Five Hour Limit", + "remaining": { "remainingFraction": 0.91 }, + "description": "You have used some of your 5-hour limit, it will fully refresh in 4 hours.", + "resetTime": "2026-06-15T11:39:34Z" + } + ] + }, + { + "displayName": "Claude and GPT models", + "description": "Models within this group: Claude Opus, Claude Sonnet, GPT-OSS", + "buckets": [ + { + "bucketId": "3p-weekly", + "displayName": "Weekly Limit", + "remaining": { "remainingFraction": 0.64 }, + "description": "You have used some of your weekly limit, it will fully refresh in 6 days, 22 hours.", + "resetTime": "2026-06-20T00:39:54Z" + }, + { + "bucketId": "3p-5h", + "displayName": "Five Hour Limit", + "remaining": { "remainingFraction": 0.73 }, + "description": "You have used some of your 5-hour limit, it will fully refresh in 3 hours, 38 minutes.", + "resetTime": "2026-06-15T12:52:10Z" + } + ] + } + ] + } + } + """ +} + +private func antigravityQuotaSummaryWithoutKnownUsageJSON() -> String { + """ + { + "response": { + "groups": [ + { + "displayName": "Gemini Models", + "buckets": [ + { + "bucketId": "gemini-weekly", + "displayName": "Weekly Limit", + "description": "Refreshes later." + }, + { + "bucketId": "gemini-5h", + "displayName": "Five Hour Limit", + "disabled": true, + "remaining": { "remainingFraction": 0.5 } + } + ] + } + ] + } + } + """ +} + +private func antigravityUserStatusJSON() -> String { + """ + { + "code": 0, + "userStatus": { + "email": "test@example.com", + "planStatus": { + "planInfo": { + "planName": "Pro" + } + }, + "cascadeModelConfigData": { + "clientModelConfigs": [ + { + "label": "Gemini 3 Pro Low", + "modelOrAlias": { "model": "gemini-3-pro-low" }, + "quotaInfo": { "remainingFraction": 0.9, "resetTime": "2025-12-24T10:00:00Z" } + } + ] + } + } + } + """ +} + +private func antigravityCommandModelConfigJSON() -> String { + """ + { + "clientModelConfigs": [ + { + "label": "Gemini 3 Pro Low", + "modelOrAlias": { "model": "gemini-3-pro-low" }, + "quotaInfo": { "remainingFraction": 0.9, "resetTime": "2025-12-24T10:00:00Z" } + } + ] + } + """ +} diff --git a/Tests/CodexBarTests/AntigravityRemoteUsageFetcherTests.swift b/Tests/CodexBarTests/AntigravityRemoteUsageFetcherTests.swift index 6acc9125cc..d6976c20bf 100644 --- a/Tests/CodexBarTests/AntigravityRemoteUsageFetcherTests.swift +++ b/Tests/CodexBarTests/AntigravityRemoteUsageFetcherTests.swift @@ -1,6 +1,6 @@ -import CodexBarCore import Foundation import Testing +@testable import CodexBarCore private actor AntigravityCredentialUpdateCapture { private var captured: [AntigravityOAuthCredentials] = [] @@ -332,9 +332,9 @@ struct AntigravityRemoteUsageFetcherTests { #expect(snapshot.accountPlan == "Paid") let usage = try snapshot.toUsageSnapshot() - #expect(usage.primary?.remainingPercent.rounded() == 50) - #expect(usage.secondary?.remainingPercent.rounded() == 80) - #expect(usage.tertiary?.remainingPercent.rounded() == 20) + #expect(usage.primary?.remainingPercent.rounded() == 20) + #expect(usage.secondary?.remainingPercent.rounded() == 50) + #expect(usage.tertiary == nil) } @Test @@ -440,13 +440,13 @@ struct AntigravityRemoteUsageFetcherTests { let usage = try snapshot.toUsageSnapshot() #expect(quotaCalls.get() == 1) - #expect(usage.primary?.remainingPercent == 100.0) - #expect(usage.secondary?.remainingPercent == 60.0) - #expect(usage.tertiary?.remainingPercent == 90.0) + #expect(usage.primary?.remainingPercent == 60.0) + #expect(usage.secondary?.remainingPercent == 100.0) + #expect(usage.tertiary == nil) } @Test - func `remote fetch keeps full model quotas when verification has no buckets`() async throws { + func `remote fetch ignores full model availability when verification has no quota data`() async throws { let env = try GeminiTestEnvironment() defer { env.cleanup() } try env.writeAntigravityCredentials( @@ -500,6 +500,157 @@ struct AntigravityRemoteUsageFetcherTests { } } + let snapshot = try await AntigravityRemoteUsageFetcher( + timeout: 1, + homeDirectory: env.homeURL.path, + dataLoader: dataLoader) + .fetch() + + #expect(snapshot.modelQuotas.isEmpty) + #expect(snapshot.accountEmail == "user@example.com") + } + + @Test + func `remote fetch propagates quota verification server errors`() async throws { + let env = try GeminiTestEnvironment() + defer { env.cleanup() } + try env.writeAntigravityCredentials( + accessToken: "token", + refreshToken: nil, + expiry: Date().addingTimeInterval(3600), + idToken: GeminiAPITestHelpers.makeIDToken(email: "user@example.com"), + email: "user@example.com") + + let dataLoader = GeminiAPITestHelpers.dataLoader { request in + guard let url = request.url, let host = url.host else { + throw URLError(.badURL) + } + + switch host { + case "cloudcode-pa.googleapis.com": + if url.path == "/v1internal:loadCodeAssist" { + return GeminiAPITestHelpers.response( + url: url.absoluteString, + status: 200, + body: GeminiAPITestHelpers.loadCodeAssistResponse( + tierId: "standard-tier", + projectId: "managed-project-123")) + } + if url.path == "/v1internal:fetchAvailableModels" { + return GeminiAPITestHelpers.response( + url: url.absoluteString, + status: 200, + body: GeminiAPITestHelpers.jsonData([ + "models": [ + "claude-sonnet-4": [ + "displayName": "Claude Sonnet 4", + "quotaInfo": ["remainingFraction": 1], + ], + "gemini-2.5-pro": [ + "displayName": "Gemini 2.5 Pro", + "quotaInfo": ["remainingFraction": 1], + ], + ], + ])) + } + if url.path == "/v1internal:retrieveUserQuota" { + return GeminiAPITestHelpers.response( + url: url.absoluteString, + status: 503, + body: Data("temporary outage".utf8)) + } + return GeminiAPITestHelpers.response(url: url.absoluteString, status: 404, body: Data()) + default: + return GeminiAPITestHelpers.response(url: url.absoluteString, status: 404, body: Data()) + } + } + + do { + _ = try await AntigravityRemoteUsageFetcher( + timeout: 1, + homeDirectory: env.homeURL.path, + dataLoader: dataLoader) + .fetch() + Issue.record("Expected quota verification server error") + } catch let error as AntigravityRemoteFetchError { + guard case let .apiError(message) = error else { + Issue.record("Unexpected Antigravity error: \(error)") + return + } + #expect(message.contains("HTTP 503")) + } catch { + Issue.record("Unexpected error: \(error)") + } + } + + @Test + func `remote fetch keeps full quotas when verified quota endpoint has fractions`() async throws { + let env = try GeminiTestEnvironment() + defer { env.cleanup() } + try env.writeAntigravityCredentials( + accessToken: "token", + refreshToken: nil, + expiry: Date().addingTimeInterval(3600), + idToken: GeminiAPITestHelpers.makeIDToken(email: "user@example.com"), + email: "user@example.com") + + let dataLoader = GeminiAPITestHelpers.dataLoader { request in + guard let url = request.url, let host = url.host else { + throw URLError(.badURL) + } + + switch host { + case "cloudcode-pa.googleapis.com": + if url.path == "/v1internal:loadCodeAssist" { + return GeminiAPITestHelpers.response( + url: url.absoluteString, + status: 200, + body: GeminiAPITestHelpers.loadCodeAssistResponse( + tierId: "standard-tier", + projectId: "managed-project-123")) + } + if url.path == "/v1internal:fetchAvailableModels" { + return GeminiAPITestHelpers.response( + url: url.absoluteString, + status: 200, + body: GeminiAPITestHelpers.jsonData([ + "models": [ + "claude-sonnet-4": [ + "displayName": "Claude Sonnet 4", + "quotaInfo": ["remainingFraction": 1], + ], + "gemini-2.5-pro": [ + "displayName": "Gemini 2.5 Pro", + "quotaInfo": ["remainingFraction": 1], + ], + ], + ])) + } + if url.path == "/v1internal:retrieveUserQuota" { + return GeminiAPITestHelpers.response( + url: url.absoluteString, + status: 200, + body: GeminiAPITestHelpers.jsonData([ + "buckets": [ + [ + "modelId": "claude-sonnet-4", + "remainingFraction": 1, + "resetTime": "2025-01-01T00:00:00Z", + ], + [ + "modelId": "gemini-2.5-pro", + "remainingFraction": 1, + "resetTime": "2025-01-01T00:00:00Z", + ], + ], + ])) + } + return GeminiAPITestHelpers.response(url: url.absoluteString, status: 404, body: Data()) + default: + return GeminiAPITestHelpers.response(url: url.absoluteString, status: 404, body: Data()) + } + } + let snapshot = try await AntigravityRemoteUsageFetcher( timeout: 1, homeDirectory: env.homeURL.path, @@ -509,6 +660,73 @@ struct AntigravityRemoteUsageFetcherTests { #expect(usage.primary?.remainingPercent == 100.0) #expect(usage.secondary?.remainingPercent == 100.0) + #expect(usage.tertiary == nil) + } + + @Test + func `remote fetch drops full quota rows absent from partial verification`() async throws { + let env = try GeminiTestEnvironment() + defer { env.cleanup() } + try env.writeAntigravityCredentials( + accessToken: "token", + refreshToken: nil, + expiry: Date().addingTimeInterval(3600), + idToken: GeminiAPITestHelpers.makeIDToken(email: "user@example.com"), + email: "user@example.com") + + let dataLoader = GeminiAPITestHelpers.dataLoader { request in + guard let url = request.url else { + throw URLError(.badURL) + } + if url.path == "/v1internal:loadCodeAssist" { + return GeminiAPITestHelpers.response( + url: url.absoluteString, + status: 200, + body: GeminiAPITestHelpers.loadCodeAssistResponse( + tierId: "standard-tier", + projectId: "managed-project-123")) + } + if url.path == "/v1internal:fetchAvailableModels" { + return GeminiAPITestHelpers.response( + url: url.absoluteString, + status: 200, + body: GeminiAPITestHelpers.jsonData([ + "models": [ + "claude-sonnet-4": [ + "displayName": "Claude Sonnet 4", + "quotaInfo": ["remainingFraction": 1], + ], + "gemini-2.5-pro": [ + "displayName": "Gemini 2.5 Pro", + "quotaInfo": ["remainingFraction": 1], + ], + ], + ])) + } + if url.path == "/v1internal:retrieveUserQuota" { + return GeminiAPITestHelpers.response( + url: url.absoluteString, + status: 200, + body: GeminiAPITestHelpers.jsonData([ + "buckets": [ + [ + "modelId": "gemini-2.5-pro", + "remainingFraction": 0.5, + ], + ], + ])) + } + return GeminiAPITestHelpers.response(url: url.absoluteString, status: 404, body: Data()) + } + + let snapshot = try await AntigravityRemoteUsageFetcher( + timeout: 1, + homeDirectory: env.homeURL.path, + dataLoader: dataLoader) + .fetch() + + #expect(snapshot.modelQuotas.map(\.modelId) == ["gemini-2.5-pro"]) + #expect(snapshot.modelQuotas.map(\.remainingFraction) == [0.5]) } @Test @@ -830,8 +1048,9 @@ struct AntigravityRemoteUsageFetcherTests { let usage = try snapshot.toUsageSnapshot() #expect(quotaCalls.get() == 1) - #expect(usage.secondary?.remainingPercent == 60.0) - #expect(usage.tertiary?.remainingPercent == 90.0) + #expect(usage.primary?.remainingPercent == 60.0) + #expect(usage.secondary == nil) + #expect(usage.tertiary == nil) } @Test @@ -890,10 +1109,12 @@ struct AntigravityRemoteUsageFetcherTests { homeDirectory: env.homeURL.path, dataLoader: dataLoader) .fetch() + let usage = try AntigravityOAuthFetchStrategy.usageSnapshot(from: snapshot) #expect(snapshot.modelQuotas.isEmpty) #expect(snapshot.accountEmail == "user@example.com") #expect(snapshot.accountPlan == "Paid") + #expect(usage.rateLimitsUnavailable(for: .antigravity)) } @Test diff --git a/Tests/CodexBarTests/AntigravityStatusProbeTests.swift b/Tests/CodexBarTests/AntigravityStatusProbeTests.swift index f9f0a1cf75..5182cef097 100644 --- a/Tests/CodexBarTests/AntigravityStatusProbeTests.swift +++ b/Tests/CodexBarTests/AntigravityStatusProbeTests.swift @@ -42,6 +42,21 @@ struct AntigravityStatusProbeTests { #expect(AntigravityStatusProbe.isAntigravityLanguageServerCommandLine(command)) } + @Test + func `process detection accepts hyphenated language server from app bundle`() throws { + let command = """ + /Applications/Google Antigravity.app/Contents/Resources/bin/language-server --standalone \ + --csrf_token token --extension_server_port 64123 + """ + + #expect(AntigravityStatusProbe.isAntigravityLanguageServerCommandLine(command)) + + let result = try AntigravityStatusProbe.processInfo(fromProcessListOutput: " 321 \(command)") + #expect(result.pid == 321) + #expect(result.csrfToken == "token") + #expect(result.extensionPort == 64123) + } + @Test func `process detection keeps ignoring non language server antigravity helpers`() { let helper = """ @@ -62,6 +77,272 @@ struct AntigravityStatusProbeTests { #expect(AntigravityStatusProbe.isAntigravityLanguageServerCommandLine(command)) } + @Test + func `process detection accepts platform suffixed antigravity language server`() throws { + let output = """ + 101 /Applications/Antigravity.app/Contents/Resources/bin/language_server_macos_arm \ + --csrf_token ide-token --app_data_dir antigravity --extension_server_port 54977 + """ + + let result = try AntigravityStatusProbe.processInfo(fromProcessListOutput: output, scope: .appOnly) + + #expect(result.pid == 101) + #expect(result.csrfToken == "ide-token") + #expect(result.extensionPort == 54977) + } + + @Test + func `process detection accepts antigravity cli without csrf token`() { + // The CLI launches its language server without a `--csrf_token` flag. + let node = """ + node /Users/test/.gemini/antigravity-cli/build/mcp-server.cjs \ + --app_data_dir /Users/test/.gemini/antigravity + """ + #expect(AntigravityStatusProbe.isAntigravityLanguageServerCommandLine(node)) + + let agy = "/Users/test/.local/bin/agy -p hello" + #expect(AntigravityStatusProbe.isAntigravityLanguageServerCommandLine(agy)) + + let agyUnderscore = "/usr/local/bin/agy --app_data_dir /Users/test/.gemini/antigravity_cli" + #expect(AntigravityStatusProbe.isAntigravityLanguageServerCommandLine(agyUnderscore)) + } + + @Test + func `process detection ignores unrelated binaries containing agy substring`() { + // "agy" must be path-anchored so unrelated commands do not match. + #expect(!AntigravityStatusProbe.isAntigravityLanguageServerCommandLine("/usr/bin/legacy --run")) + #expect(!AntigravityStatusProbe.isAntigravityLanguageServerCommandLine("/opt/imagymagic/bin/tool")) + } + + @Test + func `process detection ignores cli names outside explicit cli path segments`() { + #expect( + !AntigravityStatusProbe.isAntigravityLanguageServerCommandLine( + "/usr/bin/node /tmp/not-antigravity-cli/build/server.js")) + #expect( + !AntigravityStatusProbe.isAntigravityLanguageServerCommandLine( + "/usr/bin/helper --workspace antigravity-cli")) + } + + @Test + func `process kind distinguishes app ide language server and cli`() { + let app = """ + /Applications/Antigravity.app/Contents/Resources/bin/language_server \ + --csrf_token token --app_data_dir antigravity + """ + let ide = """ + /Applications/Antigravity IDE.app/Contents/Resources/app/extensions/antigravity/bin/language_server_macos_arm \ + --csrf_token token --app_data_dir antigravity-ide + """ + #expect(AntigravityStatusProbe.antigravityProcessKind(app) == .app) + #expect(AntigravityStatusProbe.antigravityProcessKind(ide) == .ide) + #expect(AntigravityStatusProbe.antigravityProcessKind("/Users/test/.local/bin/agy -p hi") == .cli) + #expect( + AntigravityStatusProbe.antigravityProcessKind( + "node /x/.gemini/antigravity-cli/build/mcp-server.cjs --app_data_dir /x/.gemini/antigravity") == .cli) + #expect(AntigravityStatusProbe.antigravityProcessKind("/usr/bin/legacy --run") == nil) + } + + @Test + func `csrf token stays required for ide but optional for cli`() { + // Desktop app/IDE with a token returns it. + let appWithToken = """ + /Applications/Antigravity.app/Contents/Resources/bin/language_server \ + --csrf_token ide-token --app_data_dir antigravity + """ + #expect(AntigravityStatusProbe.resolvedCSRFToken(forKind: .app, command: appWithToken) == "ide-token") + + // Tokenless desktop app is skipped (nil) so detection keeps scanning for a valid + // server and preserves the missing-token diagnostic - no empty-token probe. + let appNoToken = """ + /Applications/Antigravity.app/Contents/Resources/bin/language_server \ + --app_data_dir antigravity + """ + #expect(AntigravityStatusProbe.resolvedCSRFToken(forKind: .app, command: appNoToken) == nil) + + // CLI without a token resolves to an empty token (its server needs none). + #expect( + AntigravityStatusProbe.resolvedCSRFToken( + forKind: .cli, command: "/Users/test/.local/bin/agy -p hi")?.isEmpty == true) + + // A CLI that does carry a token still uses it. + #expect( + AntigravityStatusProbe.resolvedCSRFToken( + forKind: .cli, command: "/Users/test/.local/bin/agy --csrf_token cli-token") == "cli-token") + } + + @Test + func `process scan skips tokenless ide before later valid ide`() throws { + let tokenlessIDE = + " 100 /Applications/Antigravity.app/Contents/Resources/bin/language_server --app_data_dir antigravity" + let validIDE = " 101 /Applications/Antigravity.app/Contents/Resources/bin/language_server " + + "--csrf_token ide-token --app_data_dir antigravity " + + "--extension_server_port 64432 --extension_server_csrf_token extension-token" + let output = [tokenlessIDE, validIDE].joined(separator: "\n") + + let result = try AntigravityStatusProbe.processInfo(fromProcessListOutput: output) + + #expect(result.pid == 101) + #expect(result.csrfToken == "ide-token") + #expect(result.extensionPort == 64432) + #expect(result.extensionServerCSRFToken == "extension-token") + } + + @Test + func `process scan returns all valid app candidates`() throws { + let firstApp = " 101 /Applications/Antigravity.app/Contents/Resources/bin/language_server " + + "--csrf_token first-token --app_data_dir antigravity" + let secondApp = " 102 /Applications/Antigravity.app/Contents/Resources/bin/language_server " + + "--csrf_token second-token --app_data_dir antigravity " + + "--extension_server_port 64432 --extension_server_csrf_token extension-token" + let output = [firstApp, secondApp].joined(separator: "\n") + + let results = try AntigravityStatusProbe.processInfos(fromProcessListOutput: output, scope: .appOnly) + + #expect(results.map(\.pid) == [101, 102]) + #expect(results.map(\.csrfToken) == ["first-token", "second-token"]) + #expect(results.last?.extensionPort == 64432) + #expect(results.last?.extensionServerCSRFToken == "extension-token") + } + + @Test + func `local snapshot score prefers quota summary over legacy model quotas`() { + let legacy = AntigravityStatusSnapshot( + modelQuotas: [ + AntigravityModelQuota( + label: "Gemini 3 Pro Low", + modelId: "gemini-3-pro-low", + remainingFraction: 0.9, + resetTime: Date(timeIntervalSince1970: 1_700_000_000), + resetDescription: nil), + AntigravityModelQuota( + label: "Claude Sonnet", + modelId: "claude-sonnet", + remainingFraction: 0.5, + resetTime: Date(timeIntervalSince1970: 1_700_000_000), + resetDescription: nil), + ], + accountEmail: "user@example.com", + accountPlan: "Pro", + source: .local) + let summary = AntigravityStatusSnapshot( + quotaSummary: AntigravityQuotaSummary( + description: nil, + groups: [ + AntigravityQuotaSummaryGroup( + displayName: "Gemini Models", + description: nil, + buckets: [ + AntigravityQuotaSummaryBucket( + bucketId: "gemini-5h", + displayName: "Five Hour Limit", + remainingFraction: 0.9, + resetDescription: nil, + disabled: false), + AntigravityQuotaSummaryBucket( + bucketId: "gemini-weekly", + displayName: "Weekly Limit", + remainingFraction: 0.8, + resetDescription: nil, + disabled: false), + ]), + AntigravityQuotaSummaryGroup( + displayName: "Claude and GPT models", + description: nil, + buckets: [ + AntigravityQuotaSummaryBucket( + bucketId: "3p-5h", + displayName: "Five Hour Limit", + remainingFraction: 0.7, + resetDescription: nil, + disabled: false), + AntigravityQuotaSummaryBucket( + bucketId: "3p-weekly", + displayName: "Weekly Limit", + remainingFraction: 0.6, + resetDescription: nil, + disabled: false), + ]), + ]), + accountEmail: "user@example.com", + accountPlan: "Pro", + source: .local) + + #expect(AntigravityStatusProbe.localSnapshotScore(summary) > AntigravityStatusProbe.localSnapshotScore(legacy)) + } + + @Test + func `process scan reports missing csrf when only tokenless ide matches`() { + let output = """ + 100 /Applications/Antigravity.app/Contents/Resources/bin/language_server --app_data_dir antigravity + """ + + #expect(throws: AntigravityStatusProbeError.missingCSRFToken) { + try AntigravityStatusProbe.processInfo(fromProcessListOutput: output) + } + } + + @Test + func `process scan allows empty csrf only for explicit cli match`() throws { + let output = """ + 200 /Users/test/.local/bin/agy -p hello + """ + + let result = try AntigravityStatusProbe.processInfo(fromProcessListOutput: output) + + #expect(result.pid == 200) + #expect(result.csrfToken.isEmpty) + #expect(result.commandLine == "/Users/test/.local/bin/agy -p hello") + } + + @Test + func `ideOnly scope skips app and cli processes and reports not running`() { + let output = " 200 /Users/test/.local/bin/agy -p hello" + + #expect(throws: AntigravityStatusProbeError.notRunning) { + try AntigravityStatusProbe.processInfo(fromProcessListOutput: output, scope: .ideOnly) + } + + let app = " 101 /Applications/Antigravity.app/Contents/Resources/bin/language_server " + + "--csrf_token app-token --app_data_dir antigravity" + #expect(throws: AntigravityStatusProbeError.notRunning) { + try AntigravityStatusProbe.processInfo(fromProcessListOutput: app, scope: .ideOnly) + } + } + + @Test + func `ideOnly scope still matches ide server listed after cli and app processes`() throws { + let cli = " 200 /Users/test/.local/bin/agy -p hello" + let app = " 101 /Applications/Antigravity.app/Contents/Resources/bin/language_server " + + "--csrf_token app-token --app_data_dir antigravity" + let ide = " 102 /Applications/Antigravity IDE.app/Contents/Resources/app/extensions/antigravity/bin/" + + "language_server_macos_arm " + + "--csrf_token ide-token --app_data_dir antigravity" + let output = cli + "\n" + app + "\n" + ide + + let result = try AntigravityStatusProbe.processInfo(fromProcessListOutput: output, scope: .ideOnly) + + #expect(result.pid == 102) + #expect(result.csrfToken == "ide-token") + } + + @Test + func `appOnly scope skips ide and cli processes`() throws { + let cli = " 200 /Users/test/.local/bin/agy -p hello" + let ide = " 102 /Applications/Antigravity IDE.app/Contents/Resources/app/extensions/antigravity/bin/" + + "language_server_macos_arm --csrf_token ide-token --app_data_dir antigravity-ide" + let app = " 101 /Applications/Antigravity.app/Contents/Resources/bin/language_server " + + "--csrf_token app-token --app_data_dir antigravity" + let output = cli + "\n" + ide + "\n" + app + + let result = try AntigravityStatusProbe.processInfo(fromProcessListOutput: output, scope: .appOnly) + + #expect(result.pid == 101) + #expect(result.csrfToken == "app-token") + } +} + +extension AntigravityStatusProbeTests { @Test func `localhost trust policy only accepts local server trust challenges`() { #expect( @@ -478,9 +759,9 @@ struct AntigravityStatusProbeTests { guard let primary = usage.primary else { return } - #expect(primary.remainingPercent.rounded() == 50) - #expect(usage.secondary?.remainingPercent.rounded() == 80) - #expect(usage.tertiary?.remainingPercent.rounded() == 20) + #expect(primary.remainingPercent.rounded() == 20) + #expect(usage.secondary?.remainingPercent.rounded() == 50) + #expect(usage.tertiary == nil) } @Test @@ -548,7 +829,7 @@ struct AntigravityStatusProbeTests { } @Test - func `claude bar can use thinking variants`() throws { + func `claude gpt pool can use thinking variants`() throws { let snapshot = AntigravityStatusSnapshot( modelQuotas: [ AntigravityModelQuota( @@ -568,11 +849,12 @@ struct AntigravityStatusProbeTests { accountPlan: nil) let usage = try snapshot.toUsageSnapshot() - #expect(usage.primary?.remainingPercent.rounded() == 30) + #expect(usage.primary == nil) + #expect(usage.secondary?.remainingPercent.rounded() == 30) } @Test - func `claude bar uses thinking model when it is the only claude option`() throws { + func `claude gpt pool uses thinking model when it is the only claude option`() throws { let snapshot = AntigravityStatusSnapshot( modelQuotas: [ AntigravityModelQuota( @@ -592,12 +874,12 @@ struct AntigravityStatusProbeTests { accountPlan: nil) let usage = try snapshot.toUsageSnapshot() - #expect(usage.primary?.remainingPercent.rounded() == 70) - #expect(usage.secondary?.remainingPercent.rounded() == 40) + #expect(usage.primary?.remainingPercent.rounded() == 40) + #expect(usage.secondary?.remainingPercent.rounded() == 70) } @Test - func `gemini pro bar unavailable when only excluded variants exist`() throws { + func `gemini pool unavailable when only excluded variants exist`() throws { let snapshot = AntigravityStatusSnapshot( modelQuotas: [ AntigravityModelQuota( @@ -617,12 +899,12 @@ struct AntigravityStatusProbeTests { accountPlan: nil) let usage = try snapshot.toUsageSnapshot() - #expect(usage.secondary == nil) - #expect(usage.primary?.remainingPercent.rounded() == 30) + #expect(usage.primary == nil) + #expect(usage.secondary?.remainingPercent.rounded() == 30) } @Test - func `gemini pro chooses pro low model`() throws { + func `gemini pool chooses most constrained pro variant`() throws { let snapshot = AntigravityStatusSnapshot( modelQuotas: [ AntigravityModelQuota( @@ -642,11 +924,12 @@ struct AntigravityStatusProbeTests { accountPlan: nil) let usage = try snapshot.toUsageSnapshot() - #expect(usage.secondary?.remainingPercent.rounded() == 40) + #expect(usage.primary?.remainingPercent.rounded() == 40) + #expect(usage.secondary == nil) } @Test - func `gemini pro low wins over standard pro when both exist`() throws { + func `gemini pool chooses standard pro when it is more constrained than low variant`() throws { let snapshot = AntigravityStatusSnapshot( modelQuotas: [ AntigravityModelQuota( @@ -666,11 +949,12 @@ struct AntigravityStatusProbeTests { accountPlan: nil) let usage = try snapshot.toUsageSnapshot() - #expect(usage.secondary?.remainingPercent.rounded() == 90) + #expect(usage.primary?.remainingPercent.rounded() == 10) + #expect(usage.secondary == nil) } @Test - func `gemini pro prefers model with remaining data over low priority placeholder`() throws { + func `gemini pool ignores reset only placeholder when remaining data exists`() throws { let snapshot = AntigravityStatusSnapshot( modelQuotas: [ AntigravityModelQuota( @@ -690,11 +974,12 @@ struct AntigravityStatusProbeTests { accountPlan: nil) let usage = try snapshot.toUsageSnapshot() - #expect(usage.secondary?.remainingPercent.rounded() == 100) + #expect(usage.primary?.remainingPercent.rounded() == 100) + #expect(usage.secondary == nil) } @Test - func `gemini flash does not fallback to lite variant`() throws { + func `gemini pool does not fallback to lite flash variant`() throws { let snapshot = AntigravityStatusSnapshot( modelQuotas: [ AntigravityModelQuota( @@ -715,7 +1000,8 @@ struct AntigravityStatusProbeTests { let usage = try snapshot.toUsageSnapshot() #expect(usage.tertiary == nil) - #expect(usage.primary?.remainingPercent.rounded() == 30) + #expect(usage.primary == nil) + #expect(usage.secondary?.remainingPercent.rounded() == 30) } @Test @@ -745,9 +1031,9 @@ struct AntigravityStatusProbeTests { accountPlan: nil) let usage = try snapshot.toUsageSnapshot() - #expect(usage.primary?.remainingPercent.rounded() == 30) - #expect(usage.secondary?.remainingPercent.rounded() == 40) - #expect(usage.tertiary?.remainingPercent.rounded() == 100) + #expect(usage.primary?.remainingPercent.rounded() == 40) + #expect(usage.secondary?.remainingPercent.rounded() == 30) + #expect(usage.tertiary == nil) } @Test @@ -798,14 +1084,14 @@ struct AntigravityStatusProbeTests { let usage = try snapshot.toUsageSnapshot() #expect(usage.primary?.remainingPercent.rounded() == 100) #expect(usage.secondary?.remainingPercent.rounded() == 100) - #expect(usage.tertiary?.remainingPercent.rounded() == 100) + #expect(usage.tertiary == nil) #expect(usage.identity?.accountEmail == "user@example.com") } } extension AntigravityStatusProbeTests { @Test - func `extra rate windows preserve all model quotas in stable label order`() throws { + func `known model quota rows collapse into two usage pools`() throws { let resetTime = Date(timeIntervalSince1970: 1_775_000_000) let snapshot = AntigravityStatusSnapshot( modelQuotas: [ @@ -835,29 +1121,17 @@ extension AntigravityStatusProbeTests { resetDescription: nil), ], accountEmail: nil, - accountPlan: nil) + accountPlan: nil, + source: .local) let usage = try snapshot.toUsageSnapshot() - let extraWindows = try #require(usage.extraRateWindows) - - #expect(extraWindows.map(\.id) == [ - "MODEL_PLACEHOLDER_M50", - "MODEL_PLACEHOLDER_M52", - "MODEL_PLACEHOLDER_M53", - "MODEL_PLACEHOLDER_M55", - ]) - #expect(extraWindows.map(\.title) == [ - "Claude Opus 4.6 (Thinking)", - "Gemini 3 Pro (High)", - "Gemini 3 Pro (Low)", - "GPT-OSS 120B (Medium)", - ]) - #expect(extraWindows.map { $0.window.remainingPercent.rounded() } == [75, 100, 50, 25]) - #expect(extraWindows.last?.window.resetDescription == "tomorrow") + #expect(usage.primary?.remainingPercent.rounded() == 50) + #expect(usage.secondary?.remainingPercent.rounded() == 25) + #expect(usage.extraRateWindows == nil) } @Test - func `model without remaining fraction keeps reset time`() throws { + func `model without remaining fraction stays out of family summary and preserves reset metadata`() throws { let resetTime = Date(timeIntervalSince1970: 1_735_000_000) let snapshot = AntigravityStatusSnapshot( modelQuotas: [ @@ -878,13 +1152,59 @@ extension AntigravityStatusProbeTests { accountPlan: nil) let usage = try snapshot.toUsageSnapshot() - #expect(usage.secondary?.remainingPercent.rounded() == 0) - #expect(usage.secondary?.resetsAt == resetTime) - #expect(usage.tertiary?.remainingPercent.rounded() == 100) + #expect(usage.primary?.remainingPercent.rounded() == 100) + #expect(usage.secondary == nil) + #expect(usage.tertiary == nil) + #expect(usage.extraRateWindows == nil) } @Test - func `filtered variants fall back to a visible primary snapshot`() throws { + func `group without remaining fraction preserves reset metadata as unavailable grouped window`() throws { + let resetTime = Date(timeIntervalSince1970: 1_735_000_000) + let snapshot = AntigravityStatusSnapshot( + modelQuotas: [ + AntigravityModelQuota( + label: "Gemini 3.1 Pro (Low)", + modelId: "MODEL_PLACEHOLDER_M36", + remainingFraction: nil, + resetTime: resetTime, + resetDescription: nil), + ], + accountEmail: nil, + accountPlan: nil) + + let usage = try snapshot.toUsageSnapshot() + #expect(usage.primary == nil) + let modelWindow = try #require(usage.extraRateWindows?.first) + #expect(modelWindow.id == "antigravity-gemini") + #expect(modelWindow.title == "Gemini Models") + #expect(modelWindow.window.resetsAt == resetTime) + #expect(modelWindow.usageKnown == false) + } + + @Test + func `named rate windows default legacy payloads to known usage`() throws { + let json = """ + { + "id": "legacy-window", + "title": "Legacy Window", + "window": { + "usedPercent": 42, + "windowMinutes": null, + "resetsAt": null, + "resetDescription": null, + "nextRegenPercent": null + } + } + """ + + let decoded = try JSONDecoder().decode(NamedRateWindow.self, from: Data(json.utf8)) + + #expect(decoded.usageKnown) + } + + @Test + func `filtered variants stay out of summary but remain distinct extras`() throws { let snapshot = AntigravityStatusSnapshot( modelQuotas: [ AntigravityModelQuota( @@ -907,16 +1227,430 @@ 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) + #expect(usage.primary == nil) #expect(usage.secondary == nil) #expect(usage.tertiary == nil) + #expect(usage.extraRateWindows?.map(\.id) == [ + "gemini-3-pro-lite", + "gemini-3-flash-lite", + "tab_autocomplete_model", + ]) #expect(usage.accountEmail(for: .antigravity) == "test@example.com") #expect(usage.loginMethod(for: .antigravity) == "Pro") } + // MARK: - Source-aware filter + sort tests + + @Test + func `local source collapses opaque model ids into two usage pools`() throws { + // Fixture A: 8 opaque-ID models, source .local -> two grouped quota pools + 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() + #expect(usage.primary?.remainingPercent.rounded() == 30) + #expect(usage.secondary?.remainingPercent.rounded() == 70) + #expect(usage.extraRateWindows == nil) + } + + @Test + func `remote source collapses recognized family models and hides unconsumed junk`() throws { + // Fixture B: verified 13 remote models; recognized text models collapse into Gemini, + // and unconsumed junk stays hidden. + 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() + #expect(usage.primary?.remainingPercent.rounded() == 100) + #expect(usage.secondary == nil) + #expect(usage.extraRateWindows == nil) + } + + @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.primary?.usedPercent == 20) + #expect(usage.secondary == nil) + #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 collapse to most constrained usage pool`() throws { + // Fixture F: local source; known Gemini Pro rows collapse into the Gemini pool + // using the most constrained remaining fraction. + 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() + #expect(usage.primary?.remainingPercent.rounded() == 30) + #expect(usage.secondary?.remainingPercent.rounded() == 90) + #expect(usage.extraRateWindows == nil) + } + + @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 still map to gemini group`() throws { + // When the remote catalog omits displayName/label, the raw hyphenated model id + // becomes the label and still participates in the Gemini group. + let snapshot = AntigravityStatusSnapshot( + modelQuotas: [ + AntigravityModelQuota( + label: "gemini-3-pro-preview", + modelId: "gemini-3-pro-preview", + remainingFraction: 1, + resetTime: nil, + resetDescription: nil), + AntigravityModelQuota( + label: "gemini-2.5-pro", + modelId: "gemini-2.5-pro", + remainingFraction: 1, + resetTime: nil, + resetDescription: nil), + ], + accountEmail: nil, + accountPlan: nil, + source: .remote) + + let usage = try snapshot.toUsageSnapshot() + #expect(usage.primary?.remainingPercent.rounded() == 100) + #expect(usage.extraRateWindows == nil) + } + @Test func `http probe errors still count as reachable`() { #expect( diff --git a/Tests/CodexBarTests/AntigravityWarmAgyReuseTests.swift b/Tests/CodexBarTests/AntigravityWarmAgyReuseTests.swift new file mode 100644 index 0000000000..e8958c29cb --- /dev/null +++ b/Tests/CodexBarTests/AntigravityWarmAgyReuseTests.swift @@ -0,0 +1,468 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct AntigravityWarmAgyReuseTests { + // MARK: - Helper-seam tests (tryWarmAgyFetch) + + @Test + func `warm agy found reuses ports without spawn`() async throws { + let listeningPortsCallCount = AntigravityWarmLockedCounter() + let fetchSnapshotCallCount = AntigravityWarmLockedCounter() + + let result = try await AntigravityCLIHTTPSFetchStrategy.tryWarmAgyFetch( + timeout: 2.0, + dependencies: AntigravityCLIHTTPSFetchStrategy.WarmAgyDependencies( + processInfos: { _ in [Self.cliProcessInfo(pid: 9901)] }, + listeningPorts: { pid, _ in + listeningPortsCallCount.increment() + #expect(pid == 9901) + return [56789] + }, + fetchSnapshot: { ports, _ in + fetchSnapshotCallCount.increment() + #expect(ports == [56789]) + return Self.usableSnapshot(email: "warm@example.com") + })) + + #expect(result?.accountEmail == "warm@example.com") + #expect(result?.modelQuotas.first?.modelId == "gemini-pro") + #expect(listeningPortsCallCount.value == 1) + #expect(fetchSnapshotCallCount.value == 1) + } + + @Test + func `no warm agy returns nil`() async throws { + let result = try await AntigravityCLIHTTPSFetchStrategy.tryWarmAgyFetch( + timeout: 2.0, + dependencies: AntigravityCLIHTTPSFetchStrategy.WarmAgyDependencies( + processInfos: { _ in [] }, + listeningPorts: { _, _ in + Issue.record("listeningPorts must not be called when no warm agy found") + return [] + }, + fetchSnapshot: { _, _ in + Issue.record("fetchSnapshot must not be called when no warm agy found") + throw AntigravityStatusProbeError.notRunning + })) + + #expect(result == nil) + } + + @Test + func `process infos throws returns nil`() async throws { + // detectProcessInfos throws (e.g. .missingCSRFToken / .notRunning) — the + // fast path must swallow it and let the caller fall back to spawning. + let result = try await AntigravityCLIHTTPSFetchStrategy.tryWarmAgyFetch( + timeout: 2.0, + dependencies: AntigravityCLIHTTPSFetchStrategy.WarmAgyDependencies( + processInfos: { _ in throw AntigravityStatusProbeError.missingCSRFToken }, + listeningPorts: { _, _ in + Issue.record("listeningPorts must not be called when discovery throws") + return [] + }, + fetchSnapshot: { _, _ in + Issue.record("fetchSnapshot must not be called when discovery throws") + throw AntigravityStatusProbeError.notRunning + })) + + #expect(result == nil) + } + + @Test + func `warm agy fetch fails returns nil`() async throws { + let fetchSnapshotCallCount = AntigravityWarmLockedCounter() + + let result = try await AntigravityCLIHTTPSFetchStrategy.tryWarmAgyFetch( + timeout: 2.0, + dependencies: AntigravityCLIHTTPSFetchStrategy.WarmAgyDependencies( + processInfos: { _ in [Self.cliProcessInfo(pid: 7701)] }, + listeningPorts: { _, _ in [55555] }, + fetchSnapshot: { _, _ in + fetchSnapshotCallCount.increment() + throw AntigravityStatusProbeError.portDetectionFailed("endpoint not ready") + })) + + // Fetch fails → warm reuse returns nil → caller falls back to spawn + #expect(result == nil) + #expect(fetchSnapshotCallCount.value == 1) + } + + @Test + func `ide process ignored not reuseable as warm CLI`() async throws { + // An IDE language server requires a CSRF token — must NOT be reused via + // the token-less warm path. + let ideProcessInfo = AntigravityStatusProbe.ProcessInfoResult( + pid: 8801, + extensionPort: nil, + extensionServerCSRFToken: nil, + csrfToken: "abc123", + commandLine: + "/Applications/Antigravity IDE.app/Contents/Resources/language_server " + + "--csrf_token abc123 --app_data_dir antigravity-ide") + let fetchSnapshotCallCount = AntigravityWarmLockedCounter() + + let result = try await AntigravityCLIHTTPSFetchStrategy.tryWarmAgyFetch( + timeout: 2.0, + dependencies: AntigravityCLIHTTPSFetchStrategy.WarmAgyDependencies( + processInfos: { _ in [ideProcessInfo] }, + listeningPorts: { _, _ in [44444] }, + fetchSnapshot: { _, _ in + fetchSnapshotCallCount.increment() + return Self.usableSnapshot(email: "ide@example.com") + })) + + #expect(result == nil) + #expect(fetchSnapshotCallCount.value == 0) + } + + @Test + func `owned agy excluded falls back to spawn path`() async throws { + // CodexBar's own managed `agy` (pid 4242) appears in the process scan. + // It must NOT be reused through the warm path — doing so would bypass the + // session lifecycle and let `stopIfIdle` tear it down mid-poll. + let fetchSnapshotCallCount = AntigravityWarmLockedCounter() + + let result = try await AntigravityCLIHTTPSFetchStrategy.tryWarmAgyFetch( + timeout: 2.0, + dependencies: AntigravityCLIHTTPSFetchStrategy.WarmAgyDependencies( + processInfos: { _ in [Self.cliProcessInfo(pid: 4242)] }, + listeningPorts: { _, _ in + Issue.record("listeningPorts must not be called for a CodexBar-owned agy") + return [] + }, + fetchSnapshot: { _, _ in + fetchSnapshotCallCount.increment() + return Self.usableSnapshot(email: "owned@example.com") + }, + ownedPID: { 4242 })) + + #expect(result == nil) + #expect(fetchSnapshotCallCount.value == 0) + } + + @Test + func `external agy reused when owned also present`() async throws { + // With both an owned `agy` (pid 4242) and an external one (pid 7000), only + // the external server is reused; the owned pid is filtered out. + let listeningPortsCallCount = AntigravityWarmLockedCounter() + + let result = try await AntigravityCLIHTTPSFetchStrategy.tryWarmAgyFetch( + timeout: 2.0, + dependencies: AntigravityCLIHTTPSFetchStrategy.WarmAgyDependencies( + processInfos: { _ in [Self.cliProcessInfo(pid: 4242), Self.cliProcessInfo(pid: 7000)] }, + listeningPorts: { pid, _ in + listeningPortsCallCount.increment() + #expect(pid == 7000) + return [50050] + }, + fetchSnapshot: { _, _ in Self.usableSnapshot(email: "external@example.com") }, + ownedPID: { 4242 })) + + #expect(result?.accountEmail == "external@example.com") + #expect(listeningPortsCallCount.value == 1) + } + + @Test + func `other user agy is ignored`() async throws { + let listeningPIDs = AntigravityWarmLockedValues() + + let result = try await AntigravityCLIHTTPSFetchStrategy.tryWarmAgyFetch( + timeout: 2.0, + dependencies: AntigravityCLIHTTPSFetchStrategy.WarmAgyDependencies( + processInfos: { _ in [Self.cliProcessInfo(pid: 6001), Self.cliProcessInfo(pid: 6002)] }, + listeningPorts: { pid, _ in + listeningPIDs.append(pid) + return [pid] + }, + fetchSnapshot: { _, _ in Self.usableSnapshot(email: "same-user@example.com") }, + processOwnerUserID: { pid in pid == 6001 ? 502 : 501 }, + currentUserID: { 501 })) + + #expect(result?.accountEmail == "same-user@example.com") + #expect(listeningPIDs.value == [6002]) + } + + @Test + func `account mismatch tries next warm agy`() async throws { + let listeningPIDs = AntigravityWarmLockedValues() + + let result = try await AntigravityCLIHTTPSFetchStrategy.tryWarmAgyFetch( + timeout: 2.0, + expectedAccountEmail: "selected@example.com", + dependencies: AntigravityCLIHTTPSFetchStrategy.WarmAgyDependencies( + processInfos: { _ in [Self.cliProcessInfo(pid: 6101), Self.cliProcessInfo(pid: 6102)] }, + listeningPorts: { pid, _ in + listeningPIDs.append(pid) + return [pid] + }, + fetchSnapshot: { ports, _ in + let email = ports == [6101] ? "other@example.com" : "SELECTED@example.com" + return Self.usableSnapshot(email: email) + })) + + #expect(result?.accountEmail == "SELECTED@example.com") + #expect(listeningPIDs.value == [6101, 6102]) + } + + @Test + func `binary mismatch tries next warm agy`() async throws { + let listeningPIDs = AntigravityWarmLockedValues() + + let result = try await AntigravityCLIHTTPSFetchStrategy.tryWarmAgyFetch( + timeout: 2.0, + expectedBinaryPath: "/selected/agy", + dependencies: AntigravityCLIHTTPSFetchStrategy.WarmAgyDependencies( + processInfos: { _ in + [ + Self.cliProcessInfo(pid: 6151, binaryPath: "/other/agy"), + Self.cliProcessInfo(pid: 6152, binaryPath: "/selected/agy"), + ] + }, + listeningPorts: { pid, _ in + listeningPIDs.append(pid) + return [pid] + }, + fetchSnapshot: { _, _ in Self.usableSnapshot(email: "selected@example.com") })) + + #expect(result?.accountEmail == "selected@example.com") + #expect(listeningPIDs.value == [6152]) + } + + @Test + func `warm probe deadline is shared across discovery and candidates`() async throws { + let clock = AntigravityWarmTestClock(date: Date(timeIntervalSince1970: 100)) + let listeningPortsCallCount = AntigravityWarmLockedCounter() + let fetchSnapshotCallCount = AntigravityWarmLockedCounter() + + let result = try await AntigravityCLIHTTPSFetchStrategy.tryWarmAgyFetch( + timeout: 2.0, + dependencies: AntigravityCLIHTTPSFetchStrategy.WarmAgyDependencies( + processInfos: { timeout in + #expect(timeout == 2.0) + clock.advance(by: 1.5) + return [Self.cliProcessInfo(pid: 6201), Self.cliProcessInfo(pid: 6202)] + }, + listeningPorts: { _, timeout in + listeningPortsCallCount.increment() + #expect(timeout == 0.5) + clock.advance(by: 0.6) + return [62010] + }, + fetchSnapshot: { _, _ in + fetchSnapshotCallCount.increment() + return Self.usableSnapshot(email: "late@example.com") + }, + now: { clock.now() })) + + #expect(result == nil) + #expect(listeningPortsCallCount.value == 1) + #expect(fetchSnapshotCallCount.value == 0) + } + + // MARK: - Integration: fetchUsingWarmSession fast-path branch + + @Test + func `warm reuse skips spawn path`() async throws { + let spawnCallCount = AntigravityWarmLockedCounter() + let strategy = AntigravityCLIHTTPSFetchStrategy() + + let result = try await strategy.fetchUsingWarmSession( + binary: "/usr/local/bin/agy", + idleWindow: nil, + resetAfterFetch: true, + warmDependencies: AntigravityCLIHTTPSFetchStrategy.WarmAgyDependencies( + processInfos: { _ in [Self.cliProcessInfo(pid: 1234)] }, + listeningPorts: { _, _ in [40000] }, + fetchSnapshot: { _, _ in Self.usableSnapshot(email: "warm@example.com") }), + spawnFetch: { _, _, _ in + spawnCallCount.increment() + Issue.record("spawn path must not run when a warm agy is reused") + throw AntigravityStatusProbeError.notRunning + }) + + #expect(result.usage.identity?.accountEmail == "warm@example.com") + #expect(result.sourceLabel == AntigravityCLIHTTPSFetchStrategy.sourceLabel) + // The warm path never touches AntigravityCLISession: the spawn seam (the + // only place beginProbe/finishProbe run) was never invoked. + #expect(spawnCallCount.value == 0) + } + + @Test + func `no warm agy falls back to spawn path`() async throws { + let spawnCallCount = AntigravityWarmLockedCounter() + let strategy = AntigravityCLIHTTPSFetchStrategy() + + let result = try await strategy.fetchUsingWarmSession( + binary: "/usr/local/bin/agy", + idleWindow: nil, + resetAfterFetch: true, + warmDependencies: AntigravityCLIHTTPSFetchStrategy.WarmAgyDependencies( + processInfos: { _ in [] }, + listeningPorts: { _, _ in [] }, + fetchSnapshot: { _, _ in throw AntigravityStatusProbeError.notRunning }), + spawnFetch: { binary, _, resetAfterFetch in + spawnCallCount.increment() + #expect(binary == "/usr/local/bin/agy") + #expect(resetAfterFetch) + return strategy.makeResult( + usage: Self.usableUsage(email: "spawned@example.com"), + sourceLabel: AntigravityCLIHTTPSFetchStrategy.sourceLabel) + }) + + #expect(result.usage.identity?.accountEmail == "spawned@example.com") + #expect(spawnCallCount.value == 1) + } + + @Test + func `warm probe cancellation does not fall back to spawn`() async { + let spawnCallCount = AntigravityWarmLockedCounter() + let strategy = AntigravityCLIHTTPSFetchStrategy() + + do { + _ = try await strategy.fetchUsingWarmSession( + binary: "/usr/local/bin/agy", + idleWindow: nil, + resetAfterFetch: true, + warmDependencies: AntigravityCLIHTTPSFetchStrategy.WarmAgyDependencies( + processInfos: { _ in throw CancellationError() }, + listeningPorts: { _, _ in [] }, + fetchSnapshot: { _, _ in throw AntigravityStatusProbeError.notRunning }), + spawnFetch: { _, _, _ in + spawnCallCount.increment() + return strategy.makeResult( + usage: Self.usableUsage(email: "spawned@example.com"), + sourceLabel: AntigravityCLIHTTPSFetchStrategy.sourceLabel) + }) + Issue.record("cancellation must be rethrown") + } catch is CancellationError { + // Expected: cancellation must not be downgraded to a warm miss. + } catch { + Issue.record("unexpected error: \(error)") + } + + #expect(spawnCallCount.value == 0) + } + + @Test + func `long lived session skips external warm scan`() async throws { + let spawnCallCount = AntigravityWarmLockedCounter() + let strategy = AntigravityCLIHTTPSFetchStrategy() + + let result = try await strategy.fetchUsingWarmSession( + binary: "/usr/local/bin/agy", + idleWindow: 60, + resetAfterFetch: false, + warmDependencies: AntigravityCLIHTTPSFetchStrategy.WarmAgyDependencies( + processInfos: { _ in + Issue.record("long-lived hosts must use their managed session") + return [Self.cliProcessInfo(pid: 6301)] + }, + listeningPorts: { _, _ in [] }, + fetchSnapshot: { _, _ in throw AntigravityStatusProbeError.notRunning }), + spawnFetch: { _, _, resetAfterFetch in + spawnCallCount.increment() + #expect(!resetAfterFetch) + return strategy.makeResult( + usage: Self.usableUsage(email: "managed@example.com"), + sourceLabel: AntigravityCLIHTTPSFetchStrategy.sourceLabel) + }) + + #expect(result.usage.identity?.accountEmail == "managed@example.com") + #expect(spawnCallCount.value == 1) + } + + // MARK: - Fixtures + + private static func cliProcessInfo( + pid: Int, + binaryPath: String = "/usr/local/bin/agy") -> AntigravityStatusProbe.ProcessInfoResult + { + AntigravityStatusProbe.ProcessInfoResult( + pid: pid, + extensionPort: nil, + extensionServerCSRFToken: nil, + csrfToken: "", + commandLine: binaryPath) + } + + private static func usableSnapshot(email: String) -> AntigravityStatusSnapshot { + AntigravityStatusSnapshot( + modelQuotas: [ + AntigravityModelQuota( + label: "Gemini Pro", + modelId: "gemini-pro", + remainingFraction: 0.8, + resetTime: nil, + resetDescription: nil), + ], + accountEmail: email, + accountPlan: "Pro", + source: .local) + } + + private static func usableUsage(email: String) -> UsageSnapshot { + (try? self.usableSnapshot(email: email).toUsageSnapshot()) + ?? UsageSnapshot( + primary: nil, + secondary: nil, + tertiary: nil, + updatedAt: Date(), + identity: ProviderIdentitySnapshot( + providerID: .antigravity, + accountEmail: email, + accountOrganization: nil, + loginMethod: nil)) + } +} + +private final class AntigravityWarmLockedCounter: @unchecked Sendable { + private let lock = NSLock() + private var count = 0 + + @discardableResult + func increment() -> Int { + self.lock.withLock { + self.count += 1 + return self.count + } + } + + var value: Int { + self.lock.withLock { self.count } + } +} + +private final class AntigravityWarmLockedValues: @unchecked Sendable { + private let lock = NSLock() + private var values: [Value] = [] + + func append(_ value: Value) { + self.lock.withLock { + self.values.append(value) + } + } + + var value: [Value] { + self.lock.withLock { self.values } + } +} + +private final class AntigravityWarmTestClock: @unchecked Sendable { + private let lock = NSLock() + private var date: Date + + init(date: Date) { + self.date = date + } + + func now() -> Date { + self.lock.withLock { self.date } + } + + func advance(by interval: TimeInterval) { + self.lock.withLock { + self.date = self.date.addingTimeInterval(interval) + } + } +} diff --git a/Tests/CodexBarTests/ArkcliBinaryLocatorTests.swift b/Tests/CodexBarTests/ArkcliBinaryLocatorTests.swift new file mode 100644 index 0000000000..5a918d4b1e --- /dev/null +++ b/Tests/CodexBarTests/ArkcliBinaryLocatorTests.swift @@ -0,0 +1,53 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct ArkcliBinaryLocatorTests { + @Test + func `explicit executable override avoids shell lookup`() { + let path = "/trusted/bin/arkcli" + let fileManager = ArkcliFileManager(executables: [path]) + var shellLookupCalled = false + let commandV: (String, String?, TimeInterval, FileManager) -> String? = { _, _, _, _ in + shellLookupCalled = true + return "/untrusted/arkcli" + } + + let resolved = BinaryLocator.resolveArkcliBinary( + env: ["ARKCLI_PATH": path], + loginPATH: nil, + commandV: commandV, + fileManager: fileManager, + home: "/home/test") + + #expect(resolved == path) + #expect(!shellLookupCalled) + } + + @Test + func `path lookup accepts only the arkcli executable name`() { + let fileManager = ArkcliFileManager(executables: ["/tools/bin/not-arkcli"]) + let resolved = BinaryLocator.resolveArkcliBinary( + env: ["PATH": "/tools/bin"], + loginPATH: nil, + commandV: { _, _, _, _ in nil }, + aliasResolver: { _, _, _, _, _ in nil }, + fileManager: fileManager, + home: "/home/test") + + #expect(resolved == nil) + } +} + +private final class ArkcliFileManager: FileManager { + private let executables: Set + + init(executables: Set) { + self.executables = executables + super.init() + } + + override func isExecutableFile(atPath path: String) -> Bool { + self.executables.contains(path) + } +} diff --git a/Tests/CodexBarTests/AuggieCLIProbeParseTests.swift b/Tests/CodexBarTests/AuggieCLIProbeParseTests.swift new file mode 100644 index 0000000000..338d8d2f04 --- /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 58d9b0b6fc..905a8e92ea 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/AugmentProviderRuntimeTests.swift b/Tests/CodexBarTests/AugmentProviderRuntimeTests.swift new file mode 100644 index 0000000000..889da06509 --- /dev/null +++ b/Tests/CodexBarTests/AugmentProviderRuntimeTests.swift @@ -0,0 +1,42 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +@MainActor +@Suite(.serialized) +struct AugmentProviderRuntimeTests { + @Test + func `repeated stop only reports a running keepalive once`() throws { + let suite = "AugmentProviderRuntimeTests-\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + let settings = SettingsStore( + userDefaults: defaults, + configStore: testConfigStore(suiteName: suite), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore(), + augmentCookieStore: InMemoryCookieHeaderStore()) + let metadata = try #require(ProviderRegistry.shared.metadata[.augment]) + settings.setProviderEnabled(provider: .augment, metadata: metadata, enabled: true) + + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + startupBehavior: .testing) + let runtime = AugmentProviderRuntime() + let context = ProviderRuntimeContext(provider: .augment, settings: settings, store: store) + defer { runtime.stop(context: context) } + + runtime.start(context: context) + #expect(runtime._test_isKeepaliveRunning) + runtime.stop(context: context) + settings.setProviderEnabled(provider: .augment, metadata: metadata, enabled: false) + runtime.stop(context: context) + runtime.settingsDidChange(context: context) + + #expect(!runtime._test_isKeepaliveRunning) + #expect(runtime._test_keepaliveStopCount == 1) + } +} diff --git a/Tests/CodexBarTests/AzureOpenAIUsageFetcherTests.swift b/Tests/CodexBarTests/AzureOpenAIUsageFetcherTests.swift index 24b419f2d3..8e93b0d1f0 100644 --- a/Tests/CodexBarTests/AzureOpenAIUsageFetcherTests.swift +++ b/Tests/CodexBarTests/AzureOpenAIUsageFetcherTests.swift @@ -56,6 +56,28 @@ struct AzureOpenAIUsageFetcherTests { #expect(outcome.attempts.map(\.wasAvailable) == [true]) } + @Test + func `invalid endpoint returns precise provider error before fetch`() async { + let descriptor = ProviderDescriptorRegistry.descriptor(for: .azureopenai) + let outcome = await descriptor.fetchPlan.fetchOutcome( + context: self.makeContext(environment: [ + AzureOpenAISettingsReader.apiKeyEnvironmentKey: "AZURE_CANARY_KEY", + AzureOpenAISettingsReader.endpointEnvironmentKey: "http://127.0.0.1:31337", + AzureOpenAISettingsReader.deploymentNameEnvironmentKey: "canary-deployment", + ]), + provider: .azureopenai) + + guard case let .failure(error) = outcome.result else { + Issue.record("Expected invalid endpoint override to fail") + return + } + + #expect(error as? AzureOpenAISettingsError == .invalidEndpointOverride( + AzureOpenAISettingsReader.endpointEnvironmentKey)) + #expect(error.localizedDescription.contains("HTTPS endpoint")) + #expect(outcome.attempts.map(\.wasAvailable) == [true]) + } + @Test func `fetcher validates deployment with chat completions request`() async throws { let endpoint = try #require(URL(string: "https://example-resource.openai.azure.com")) @@ -180,6 +202,39 @@ struct AzureOpenAIUsageFetcherTests { } } +@MainActor +struct AzureOpenAIProviderAvailabilityTests { + @Test + func `configured invalid endpoint remains visible for actionable error`() throws { + let suite = "AzureOpenAIProviderAvailabilityTests-\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + defer { defaults.removePersistentDomain(forName: suite) } + + let settings = SettingsStore( + userDefaults: defaults, + configStore: testConfigStore(suiteName: suite), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + settings.azureOpenAIAPIKey = "AZURE_CANARY_KEY" + settings.azureOpenAIEndpoint = "http://127.0.0.1:31337" + settings.azureOpenAIDeploymentName = "canary-deployment" + + let environment = ProviderRegistry.makeEnvironment( + base: [:], + provider: .azureopenai, + settings: settings, + tokenOverride: nil) + let context = ProviderAvailabilityContext( + provider: .azureopenai, + settings: settings, + environment: environment) + + #expect(AzureOpenAISettingsReader.endpoint(environment: environment) == nil) + #expect(AzureOpenAIProviderImplementation().isAvailable(context: context)) + } +} + @MainActor struct AzureOpenAIMenuDescriptorTests { @Test diff --git a/Tests/CodexBarTests/BedrockMenuCardTests.swift b/Tests/CodexBarTests/BedrockMenuCardTests.swift index 60ca9b9d94..897c4419a5 100644 --- a/Tests/CodexBarTests/BedrockMenuCardTests.swift +++ b/Tests/CodexBarTests/BedrockMenuCardTests.swift @@ -50,4 +50,72 @@ struct BedrockMenuCardTests { #expect(model.tokenUsage?.monthLine == "Last 7 days: $56.78") #expect(model.tokenUsage?.hintLine == "AWS Cost Explorer billing can lag.") } + + @Test + func `bedrock cost section picks latest valid billing day`() throws { + let now = Date() + let metadata = try #require(ProviderDefaults.metadata[.bedrock]) + let tokenSnapshot = CostUsageTokenSnapshot( + sessionTokens: nil, + sessionCostUSD: 23.45, + last30DaysTokens: nil, + last30DaysCostUSD: 56.78, + historyDays: 7, + daily: [ + CostUsageDailyReport.Entry( + date: "not-a-day", + inputTokens: nil, + outputTokens: nil, + totalTokens: 10, + costUSD: 99, + modelsUsed: ["Amazon Bedrock"], + modelBreakdowns: nil), + CostUsageDailyReport.Entry( + date: "2026-06-31", + inputTokens: nil, + outputTokens: nil, + totalTokens: 40, + costUSD: 99, + modelsUsed: ["Amazon Bedrock"], + modelBreakdowns: nil), + CostUsageDailyReport.Entry( + date: "2026-05-12", + inputTokens: nil, + outputTokens: nil, + totalTokens: 20, + costUSD: 12.34, + modelsUsed: ["Amazon Bedrock"], + modelBreakdowns: nil), + CostUsageDailyReport.Entry( + date: "2026-05-13", + inputTokens: nil, + outputTokens: nil, + totalTokens: 30, + costUSD: 23.45, + modelsUsed: ["Amazon Bedrock"], + modelBreakdowns: nil), + ], + updatedAt: now) + let model = UsageMenuCardView.Model.make(.init( + provider: .bedrock, + metadata: metadata, + snapshot: nil, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: tokenSnapshot, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: true, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + + #expect(model.tokenUsage?.sessionLine == "Latest billing day (May 13): $23.45") + } } diff --git a/Tests/CodexBarTests/BedrockUsageStatsTests.swift b/Tests/CodexBarTests/BedrockUsageStatsTests.swift index 44e15913c6..b3fc16a49b 100644 --- a/Tests/CodexBarTests/BedrockUsageStatsTests.swift +++ b/Tests/CodexBarTests/BedrockUsageStatsTests.swift @@ -11,6 +11,7 @@ struct BedrockUsageStatsTests { monthlyBudget: 200, inputTokens: 1_500_000, outputTokens: 500_000, + requestCount: 42, region: "us-east-1", updatedAt: Date(timeIntervalSince1970: 1_739_841_600)) @@ -25,6 +26,8 @@ struct BedrockUsageStatsTests { #expect(usage.providerCost?.period == "Monthly") #expect(usage.identity?.providerID == .bedrock) #expect(usage.identity?.loginMethod?.contains("Spend: $50.00") == true) + #expect(usage.identity?.loginMethod?.contains("Claude 14d: 2.0M tokens") == true) + #expect(usage.identity?.loginMethod?.contains("Requests: 42") == true) } @Test @@ -123,6 +126,96 @@ struct BedrockUsageStatsTests { #expect(usage.region == "us-east-1") } + @Test + func `cost explorer data unavailable response returns zero usage`() async throws { + let registered = URLProtocol.registerClass(BedrockStubURLProtocol.self) + defer { + if registered { + URLProtocol.unregisterClass(BedrockStubURLProtocol.self) + } + BedrockStubURLProtocol.handler = nil + } + + BedrockStubURLProtocol.handler = { request in + guard let url = request.url else { throw URLError(.badURL) } + return Self.makeResponse( + url: url, + body: #"{"__type":"com.amazonaws.ce#DataUnavailableException","message":"Data is not ready"}"#, + statusCode: 400) + } + + let credentials = BedrockAWSSigner.Credentials( + accessKeyID: "AKIATEST", + secretAccessKey: "testSecret", + sessionToken: nil) + + let usage = try await BedrockUsageFetcher.fetchUsage( + credentials: credentials, + region: "us-east-1", + budget: 100, + environment: [BedrockSettingsReader.apiURLKey: "https://bedrock.test"]) + + #expect(usage.monthlySpend == 0) + #expect(usage.monthlyBudget == 100) + } + + @Test + func `cost explorer unrelated bad request remains an API error`() async throws { + let registered = URLProtocol.registerClass(BedrockStubURLProtocol.self) + defer { + if registered { + URLProtocol.unregisterClass(BedrockStubURLProtocol.self) + } + BedrockStubURLProtocol.handler = nil + } + + BedrockStubURLProtocol.handler = { request in + guard let url = request.url else { throw URLError(.badURL) } + return Self.makeResponse( + url: url, + body: #"{"__type":"ValidationException","message":"Invalid request"}"#, + statusCode: 400) + } + + let credentials = BedrockAWSSigner.Credentials( + accessKeyID: "AKIATEST", + secretAccessKey: "testSecret", + sessionToken: nil) + + await #expect(throws: BedrockUsageError.apiError("HTTP 400")) { + try await BedrockUsageFetcher.fetchUsage( + credentials: credentials, + region: "us-east-1", + budget: nil, + environment: [BedrockSettingsReader.apiURLKey: "https://bedrock.test"]) + } + } + + @Test + func `cost explorer rejects remote HTTP override before transport`() async throws { + let registered = URLProtocol.registerClass(BedrockStubURLProtocol.self) + defer { + if registered { + URLProtocol.unregisterClass(BedrockStubURLProtocol.self) + } + BedrockStubURLProtocol.handler = nil + } + let capture = BedrockRequestCapture() + BedrockStubURLProtocol.handler = { request in + capture.append(request) + throw URLError(.badURL) + } + + await #expect(throws: BedrockUsageError.parseFailed("invalid endpoint override")) { + try await BedrockUsageFetcher.fetchUsage( + credentials: Self.testCredentials, + region: "us-east-1", + budget: nil, + environment: [BedrockSettingsReader.apiURLKey: "http://bedrock.test"]) + } + #expect(capture.requests.isEmpty) + } + @Test func `cost explorer pagination aggregates monthly total`() async throws { let registered = URLProtocol.registerClass(BedrockStubURLProtocol.self) @@ -268,6 +361,268 @@ struct BedrockUsageStatsTests { #expect(range.end == "2026-05-11") } + @Test + func `cloudwatch fetch aggregates Claude activity with bounded signed query`() async throws { + let now = try #require(ISO8601DateFormatter().date(from: "2026-06-19T12:00:00Z")) + let capture = BedrockRequestCapture() + let transport = ProviderHTTPTransportHandler { request in + capture.append(request) + let requestURL = try #require(request.url) + let response = try #require(HTTPURLResponse( + url: requestURL, + statusCode: 200, + httpVersion: "HTTP/1.1", + headerFields: ["Content-Type": "application/json"])) + let body = """ + { + "MetricDataResults": [ + {"Id":"inputTokens","StatusCode":"Complete","Values":[1000,2500]}, + {"Id":"outputTokens","StatusCode":"Complete","Values":[400,600]}, + {"Id":"requests","StatusCode":"Complete","Values":[7,8]} + ] + } + """ + return (Data(body.utf8), response) + } + + let activity = try await BedrockCloudWatchUsageFetcher.fetch( + credentials: Self.testCredentials, + region: "us-west-2", + now: now, + endpointOverride: "https://cloudwatch.test", + transport: transport) + + #expect(activity == BedrockClaudeActivity(inputTokens: 3500, outputTokens: 1000, requestCount: 15)) + let request = try #require(capture.requests.first) + #expect(capture.requests.count == 1) + #expect(request.value(forHTTPHeaderField: "X-Amz-Target") == + "GraniteServiceVersion20100801.GetMetricData") + #expect(request.value(forHTTPHeaderField: "Content-Type") == "application/x-amz-json-1.0") + #expect(request.value(forHTTPHeaderField: "Authorization")?.contains( + "/us-west-2/monitoring/aws4_request") == true) + + let requestBody = try #require(request.httpBody) + let payload = try #require(try JSONSerialization.jsonObject(with: requestBody) as? [String: Any]) + #expect(payload["StartTime"] as? Double == now.timeIntervalSince1970 - 14 * 24 * 60 * 60) + #expect(payload["EndTime"] as? Double == now.timeIntervalSince1970) + let queries = try #require(payload["MetricDataQueries"] as? [[String: Any]]) + #expect(queries.count == 3) + #expect(queries.allSatisfy { query in + guard let expression = query["Expression"] as? String else { return false } + return expression.hasPrefix("SUM(SEARCH(") && expression.contains("claude") && + expression.contains("86400") + }) + } + + @Test + func `cloudwatch pagination aggregates pages`() async throws { + let transport = ProviderHTTPTransportHandler { request in + let requestBody = try #require(request.httpBody) + let payload = try #require(JSONSerialization.jsonObject(with: requestBody) as? [String: Any]) + let isSecondPage = payload["NextToken"] as? String == "page-2" + let body = if isSecondPage { + """ + {"MetricDataResults":[{"Id":"inputTokens","StatusCode":"Complete","Values":[3]}]} + """ + } else { + """ + { + "NextToken":"page-2", + "MetricDataResults":[{"Id":"inputTokens","StatusCode":"Complete","Values":[2]}] + } + """ + } + let requestURL = try #require(request.url) + let response = try #require(HTTPURLResponse( + url: requestURL, + statusCode: 200, + httpVersion: "HTTP/1.1", + headerFields: nil)) + return (Data(body.utf8), response) + } + + let activity = try await BedrockCloudWatchUsageFetcher.fetch( + credentials: Self.testCredentials, + region: "us-east-1", + now: Date(timeIntervalSince1970: 1_750_000_000), + endpointOverride: "https://cloudwatch.test", + transport: transport) + + #expect(activity.inputTokens == 5) + #expect(activity.outputTokens == 0) + #expect(activity.requestCount == 0) + } + + @Test + func `cloudwatch rejects incomplete search results`() async throws { + let transport = ProviderHTTPTransportHandler { request in + let requestURL = try #require(request.url) + let response = try #require(HTTPURLResponse( + url: requestURL, + statusCode: 200, + httpVersion: "HTTP/1.1", + headerFields: nil)) + let body = #"{"Messages":[{"Code":"MaxQueryLimit","Value":"Maximum number exceeded"}]}"# + return (Data(body.utf8), response) + } + + await #expect(throws: BedrockUsageError.cloudWatchParseFailed( + "CloudWatch reported incomplete results")) + { + try await BedrockCloudWatchUsageFetcher.fetch( + credentials: Self.testCredentials, + region: "us-east-1", + now: Date(timeIntervalSince1970: 1_750_000_000), + endpointOverride: "https://cloudwatch.test", + transport: transport) + } + } + + @Test + func `cloudwatch permission failure preserves cost explorer usage`() async throws { + let registered = URLProtocol.registerClass(BedrockStubURLProtocol.self) + defer { + if registered { + URLProtocol.unregisterClass(BedrockStubURLProtocol.self) + } + BedrockStubURLProtocol.handler = nil + } + BedrockStubURLProtocol.handler = { request in + guard let url = request.url else { throw URLError(.badURL) } + let body = """ + { + "ResultsByTime": [{ + "Groups": [{ + "Keys": ["Amazon Bedrock"], + "Metrics": {"UnblendedCost": {"Amount": "12.50"}} + }] + }] + } + """ + return Self.makeResponse(url: url, body: body) + } + let cloudWatchTransport = ProviderHTTPTransportHandler { request in + let requestURL = try #require(request.url) + let response = try #require(HTTPURLResponse( + url: requestURL, + statusCode: 403, + httpVersion: "HTTP/1.1", + headerFields: nil)) + return (Data(), response) + } + let now = Date(timeIntervalSince1970: 1_750_000_000) + + let usage = try await BedrockUsageFetcher.fetchUsage( + credentials: Self.testCredentials, + region: "us-east-1", + budget: nil, + environment: [ + BedrockSettingsReader.apiURLKey: "https://bedrock.test", + BedrockSettingsReader.cloudWatchAPIURLKey: "https://cloudwatch.test", + ], + now: now, + cloudWatchTransport: cloudWatchTransport) + + #expect(usage.monthlySpend == 12.5) + #expect(usage.inputTokens == nil) + #expect(usage.outputTokens == nil) + #expect(usage.requestCount == nil) + #expect(usage.updatedAt == now) + } + + @Test + func `cloudwatch invalid override fails closed without transport`() async throws { + let capture = BedrockRequestCapture() + let transport = ProviderHTTPTransportHandler { request in + capture.append(request) + throw URLError(.badURL) + } + + for override in [" ", "not-an-absolute-url", "http://cloudwatch.test"] { + await #expect(throws: BedrockUsageError.cloudWatchParseFailed("invalid endpoint override")) { + try await BedrockCloudWatchUsageFetcher.fetch( + credentials: Self.testCredentials, + region: "us-east-1", + now: Date(timeIntervalSince1970: 1_750_000_000), + endpointOverride: override, + transport: transport) + } + } + #expect(capture.requests.isEmpty) + } + + @Test + func `cloudwatch allows HTTP only for loopback overrides`() async throws { + let capture = BedrockRequestCapture() + let transport = ProviderHTTPTransportHandler { request in + capture.append(request) + let requestURL = try #require(request.url) + let response = try #require(HTTPURLResponse( + url: requestURL, + statusCode: 200, + httpVersion: "HTTP/1.1", + headerFields: nil)) + return (Data(#"{"MetricDataResults":[]}"#.utf8), response) + } + let overrides = [ + "http://localhost:8080", + "http://127.42.0.1:8080", + "http://[::1]:8080", + ] + + for endpointOverride in overrides { + _ = try await BedrockCloudWatchUsageFetcher.fetch( + credentials: Self.testCredentials, + region: "us-east-1", + now: Date(timeIntervalSince1970: 1_750_000_000), + endpointOverride: endpointOverride, + transport: transport) + } + + #expect(capture.requests.compactMap(\.url?.absoluteString) == overrides) + } + + @Test + func `cloudwatch resolves AWS partition endpoints`() async throws { + let capture = BedrockRequestCapture() + let transport = ProviderHTTPTransportHandler { request in + capture.append(request) + let requestURL = try #require(request.url) + let response = try #require(HTTPURLResponse( + url: requestURL, + statusCode: 200, + httpVersion: "HTTP/1.1", + headerFields: nil)) + return (Data(#"{"MetricDataResults":[]}"#.utf8), response) + } + let cases = [ + ("us-east-1", "monitoring.us-east-1.amazonaws.com"), + ("us-gov-west-1", "monitoring.us-gov-west-1.amazonaws.com"), + ("cn-north-1", "monitoring.cn-north-1.amazonaws.com.cn"), + ("eusc-de-east-1", "monitoring.eusc-de-east-1.amazonaws.eu"), + ("us-iso-east-1", "monitoring.us-iso-east-1.c2s.ic.gov"), + ("us-isob-east-1", "monitoring.us-isob-east-1.sc2s.sgov.gov"), + ("eu-isoe-west-1", "monitoring.eu-isoe-west-1.cloud.adc-e.uk"), + ("us-isof-south-1", "monitoring.us-isof-south-1.csp.hci.ic.gov"), + ] + + for (region, _) in cases { + _ = try await BedrockCloudWatchUsageFetcher.fetch( + credentials: Self.testCredentials, + region: region, + now: Date(timeIntervalSince1970: 1_750_000_000), + endpointOverride: nil, + transport: transport) + } + + #expect(capture.requests.compactMap(\.url?.host) == cases.map(\.1)) + } + + private static let testCredentials = BedrockAWSSigner.Credentials( + accessKeyID: "AKIATEST", + secretAccessKey: "testSecret", + sessionToken: nil) + private final class BedrockStubResponseQueue { private let lock = NSLock() private var bodies: [String] @@ -310,8 +665,29 @@ struct BedrockUsageStatsTests { } } +private final class BedrockRequestCapture: @unchecked Sendable { + private let lock = NSLock() + private var storage: [URLRequest] = [] + + var requests: [URLRequest] { + self.lock.lock() + defer { self.lock.unlock() } + return self.storage + } + + func append(_ request: URLRequest) { + self.lock.lock() + self.storage.append(request) + self.lock.unlock() + } +} + final class BedrockStubURLProtocol: URLProtocol { - nonisolated(unsafe) static var handler: ((URLRequest) throws -> (HTTPURLResponse, Data))? + private static let _handlerBox = LockIsolated<((URLRequest) throws -> (HTTPURLResponse, Data))?>(nil) + static var handler: ((URLRequest) throws -> (HTTPURLResponse, Data))? { + get { Self._handlerBox.value } + set { Self._handlerBox.setValue(newValue) } + } override static func canInit(with request: URLRequest) -> Bool { request.url?.host == "bedrock.test" diff --git a/Tests/CodexBarTests/BoundedChildProcessProofTests.swift b/Tests/CodexBarTests/BoundedChildProcessProofTests.swift new file mode 100644 index 0000000000..a060abf50f --- /dev/null +++ b/Tests/CodexBarTests/BoundedChildProcessProofTests.swift @@ -0,0 +1,144 @@ +import Foundation +import Testing +@testable import CodexBarCore + +#if canImport(Darwin) +import Darwin +#elseif canImport(Glibc) +import Glibc +#endif + +@Suite(.serialized) +struct BoundedChildProcessProofTests { + @Test + func `synthetic PTY child overflow propagates and cleans up the process`() throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("CodexBarBoundedProcessProof-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + + let pidURL = directory.appendingPathComponent("child.pid") + let scriptURL = directory.appendingPathComponent("overflow-child.sh") + let script = """ + #!/bin/sh + printf '%s\\n' "$$" > "$CODEXBAR_PROOF_PID_FILE" + /usr/bin/yes x | /usr/bin/head -c 1100000 + /bin/sleep 30 + """ + try script.write(to: scriptURL, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: scriptURL.path) + + var environment = ProcessInfo.processInfo.environment + environment["CODEXBAR_PROOF_PID_FILE"] = pidURL.path + let runner = TTYCommandRunner() + do { + _ = try runner.run( + binary: scriptURL.path, + send: "", + options: .init(timeout: 60, baseEnvironment: environment, initialDelay: 0)) + Issue.record("Expected the synthetic child to exceed the PTY output limit") + } catch TTYCommandRunner.Error.outputTooLarge { + // Expected: the production runner propagated the bounded-output error. + } catch { + Issue.record("Unexpected overflow error: \(error)") + } + + let pidText = try String(contentsOf: pidURL, encoding: .utf8) + .trimmingCharacters(in: .whitespacesAndNewlines) + let pid = try #require(pid_t(pidText)) + #expect(kill(pid, 0) == -1) + #expect(errno == ESRCH) + } + + @Test + func `synthetic Grok RPC child returns a normal framed response`() async throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("CodexBarBoundedRPCProof-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + + let scriptURL = directory.appendingPathComponent("grok-proof.sh") + let script = """ + #!/bin/sh + IFS= read -r initialize_request + printf '%s\\n' '{"jsonrpc":"2.0","id":1,"result":{}}' + IFS= read -r billing_request + printf '%s\\n' '{"jsonrpc":"2.0","id":2,"result":{"monthlyLimit":{"val":100},"usage":{"totalUsed":{"val":25}}}}' + """ + try script.write(to: scriptURL, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: scriptURL.path) + + let client = try GrokRPCClient( + executable: scriptURL.path, + arguments: [], + environment: [ + "PATH": "/usr/bin:/bin", + "GROK_CLI_PATH": scriptURL.path, + ], + initializeTimeoutSeconds: 2, + requestTimeoutSeconds: 2) + defer { client.shutdown() } + + try await client.initialize() + let billing = try await client.fetchBilling() + + #expect(billing.monthlyLimit?.val == 100) + #expect(billing.usage?.totalUsed?.val == 25) + #expect(billing.monthlyUsedPercent == 25) + } + + @Test + func `synthetic Grok RPC child overflow terminates the process`() async throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("CodexBarBoundedRPCOverflowProof-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + + let pidURL = directory.appendingPathComponent("child.pid") + let scriptURL = directory.appendingPathComponent("grok-overflow-proof.sh") + let script = """ + #!/bin/sh + printf '%s\\n' "$$" > "$CODEXBAR_PROOF_PID_FILE" + IFS= read -r initialize_request + block='xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx' + while :; do + printf '%s' "$block" + done + """ + try script.write(to: scriptURL, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: scriptURL.path) + + let client = try GrokRPCClient( + executable: scriptURL.path, + arguments: [], + environment: [ + "PATH": "/usr/bin:/bin", + "GROK_CLI_PATH": scriptURL.path, + "CODEXBAR_PROOF_PID_FILE": pidURL.path, + ], + initializeTimeoutSeconds: 10, + requestTimeoutSeconds: 2) + defer { client.shutdown() } + + let start = ContinuousClock.now + do { + try await client.initialize() + Issue.record("Expected the oversized Grok response to close the stream") + } catch let GrokRPCError.malformed(message) { + #expect(message == "grok agent stdio closed stdout") + } catch { + Issue.record("Unexpected Grok overflow error: \(error)") + } + #expect(start.duration(to: .now) < .seconds(5)) + + let pidText = try String(contentsOf: pidURL, encoding: .utf8) + .trimmingCharacters(in: .whitespacesAndNewlines) + let pid = try #require(pid_t(pidText)) + let deadline = Date().addingTimeInterval(2) + while kill(pid, 0) == 0, Date() < deadline { + try await Task.sleep(for: .milliseconds(20)) + } + #expect(kill(pid, 0) == -1) + #expect(errno == ESRCH) + } +} diff --git a/Tests/CodexBarTests/BoundedOutputBufferTests.swift b/Tests/CodexBarTests/BoundedOutputBufferTests.swift new file mode 100644 index 0000000000..93760327a5 --- /dev/null +++ b/Tests/CodexBarTests/BoundedOutputBufferTests.swift @@ -0,0 +1,65 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct BoundedOutputBufferTests { + @Test + func `output buffer rejects data beyond its byte limit`() { + var buffer = BoundedOutputBuffer(maxBytes: 4) + + let accepted = buffer.append(Data("abcd".utf8)) + let rejected = buffer.append(Data("e".utf8)) + + #expect(accepted) + #expect(!rejected) + #expect(buffer.data == Data("abcd".utf8)) + } + + @Test + func `line buffer rejects an unterminated line beyond its byte limit`() { + let buffer = BoundedLineBuffer(maxBytes: 4) + + let first = buffer.appendAndDrainLines(Data("abcd".utf8)) + let overflow = buffer.appendAndDrainLines(Data("e".utf8)) + + #expect(first.lines.isEmpty) + #expect(!first.didExceedLimit) + #expect(overflow.lines.isEmpty) + #expect(overflow.didExceedLimit) + } + + @Test + func `line buffer frees completed lines before accepting more output`() { + let buffer = BoundedLineBuffer(maxBytes: 4) + + let first = buffer.appendAndDrainLines(Data("a\n".utf8)) + let second = buffer.appendAndDrainLines(Data("bcde".utf8)) + + #expect(first.lines == [Data("a".utf8)]) + #expect(!first.didExceedLimit) + #expect(!second.didExceedLimit) + } + + @Test + func `line buffer drains a completed line before limiting the same chunk tail`() { + let buffer = BoundedLineBuffer(maxBytes: 4) + + let partial = buffer.appendAndDrainLines(Data("abc".utf8)) + let completed = buffer.appendAndDrainLines(Data("d\nxy".utf8)) + + #expect(!partial.didExceedLimit) + #expect(completed.lines == [Data("abcd".utf8)]) + #expect(!completed.didExceedLimit) + } + + @Test + func `line buffer rejects an oversized line even when newline arrives`() { + let buffer = BoundedLineBuffer(maxBytes: 4) + + _ = buffer.appendAndDrainLines(Data("abc".utf8)) + let overflow = buffer.appendAndDrainLines(Data("de\n".utf8)) + + #expect(overflow.lines.isEmpty) + #expect(overflow.didExceedLimit) + } +} diff --git a/Tests/CodexBarTests/BrowserCookieOrderLabelTests.swift b/Tests/CodexBarTests/BrowserCookieOrderLabelTests.swift index ac46fe826b..8e4f33002a 100644 --- a/Tests/CodexBarTests/BrowserCookieOrderLabelTests.swift +++ b/Tests/CodexBarTests/BrowserCookieOrderLabelTests.swift @@ -23,6 +23,16 @@ struct BrowserCookieOrderStatusStringTests { #expect(message.contains(order.loginHint)) } + @Test + func `cursor no session shows full disk access hint before browser list`() throws { + let order = ProviderDefaults.metadata[.cursor]?.browserCookieOrder ?? Browser.defaultImportOrder + let message = try #require(CursorStatusProbeError.noSessionCookie.errorDescription) + let fullDiskAccessRange = try #require(message.range(of: CursorStatusProbeError.safariFullDiskAccessHint)) + let browserListRange = try #require(message.range(of: order.loginHint)) + + #expect(fullDiskAccessRange.lowerBound < browserListRange.lowerBound) + } + @Test func `factory no session includes browser login hint`() { let order = ProviderDefaults.metadata[.factory]?.browserCookieOrder ?? Browser.defaultImportOrder @@ -39,8 +49,68 @@ struct BrowserCookieOrderStatusStringTests { } @Test - func `opencode automatic cookies keep chrome only default`() { - #expect(OpenCodeWebCookieSupport.automaticImportOrder(provider: .opencode) == [.chrome]) + func `opencode automatic cookies only use chrome and dia`() { + let order = OpenCodeWebCookieSupport.automaticImportOrder(provider: .opencode) + #expect(order == ProviderDefaults.metadata[.opencode]?.browserCookieOrder) + #expect(order == ProviderBrowserCookieDefaults.opencodeCookieImportOrder) + #expect(order == [.chrome, .dia]) + } + + @Test + func `opencode automatic cookies bound keychain prompt labels to chrome and dia`() { + let order = OpenCodeWebCookieSupport.automaticImportOrder(provider: .opencode) + let labels = order.flatMap(\.safeStorageLabels).map(\.service) + + #expect(labels == ["Chrome Safe Storage", "Dia Safe Storage"]) + #expect(!order.contains(.safari)) + #expect(!order.contains(.firefox)) + #expect(!order.contains(.edge)) + #expect(!order.contains(.brave)) + #expect(!order.contains(.arc)) + #expect(!order.contains(.chromium)) + } + + @Test + func `mimo cookie import order supports safari firefox and edge`() { + let order = ProviderDefaults.metadata[.mimo]?.browserCookieOrder ?? Browser.defaultImportOrder + #expect(order == ProviderBrowserCookieDefaults.mimoCookieImportOrder) + #expect(order == [.safari, .chrome, .chromeBeta, .chromeCanary, .firefox, .edge]) + #expect(order.first == .safari) + #expect(order.contains(.firefox)) + #expect(order.contains(.edge)) + #expect(!order.contains(.arc)) + } + + @Test + func `copilot cookie imports default to chrome only`() { + #expect(ProviderDefaults.metadata[.copilot]?.browserCookieOrder == [.chrome]) + #expect(ProviderBrowserCookieDefaults.copilotCookieImportOrder == [.chrome]) + } + + @Test + func `mistral cookie import order supports chrome firefox and safari`() { + let order = ProviderDefaults.metadata[.mistral]?.browserCookieOrder ?? Browser.defaultImportOrder + #expect(order == ProviderBrowserCookieDefaults.mistralCookieImportOrder) + #expect(order == [.chrome, .firefox, .safari]) + #expect(order.first == .chrome) + #expect(order.contains(.firefox)) + #expect(!order.contains(.edge)) + #expect(!order.contains(.arc)) + #expect(MistralCookieImporter.resolvedImportOrder(nil) == order) + #expect(MistralCookieImporter.resolvedImportOrder([]) == order) + #expect(MistralCookieImporter.resolvedImportOrder([.firefox]) == [.firefox]) + } + + @Test + func `longcat cookie import order supports chrome and firefox`() { + let metadataOrder = ProviderDefaults.metadata[.longcat]?.browserCookieOrder + let defaultOrder = ProviderBrowserCookieDefaults.longcatCookieImportOrder + + #expect(metadataOrder == [.chrome, .firefox]) + #expect(defaultOrder == [.chrome, .firefox]) + #expect(defaultOrder?.first == .chrome) + #expect(defaultOrder?.contains(.firefox) == true) + #expect(defaultOrder?.contains(.safari) == false) } #endif } diff --git a/Tests/CodexBarTests/BrowserDetectionTests.swift b/Tests/CodexBarTests/BrowserDetectionTests.swift index 4ce346e233..d35cbee849 100644 --- a/Tests/CodexBarTests/BrowserDetectionTests.swift +++ b/Tests/CodexBarTests/BrowserDetectionTests.swift @@ -1,22 +1,141 @@ import Foundation +import os.lock import Testing @testable import CodexBarCore #if os(macOS) import SweetCookieKit +@Suite(.serialized) struct BrowserDetectionTests { + private func detection( + homeDirectory: String, + installedBrowsers: Set) -> BrowserDetection + { + let installedAppPaths = Set(installedBrowsers.map { "/Applications/\($0.appBundleName).app" }) + return BrowserDetection( + homeDirectory: homeDirectory, + cacheTTL: 0, + now: Date.init, + fileExists: { path in + if path.hasSuffix(".app") { + return installedAppPaths.contains(path) + } + return FileManager.default.fileExists(atPath: path) + }, + directoryContents: { path in + try? FileManager.default.contentsOfDirectory(atPath: path) + }, + applicationURLs: { _ in [] }, + profileAccessIssue: { _ in nil }) + } + + private static func labelIDs(for browser: Browser) -> [String] { + browser.safeStorageLabels.map { self.labelID(service: $0.service, account: $0.account) } + } + + private static func labelID(service: String, account: String?) -> String { + "\(service)|\(account ?? "")" + } + + @Test(.disabled( + if: ProcessInfo.processInfo.environment[BrowserCookieAccessGate.allowTestCookieAccessEnvironmentKey] == "1", + "Default-home cookie access is explicitly enabled for this test run.")) + func `default home detection is suppressed before profile probes`() throws { + let probeCount = OSAllocatedUnfairLock(initialState: 0) + let defaultHome = try #require(BrowserCookieClient.defaultHomeDirectories().first) + let detection = BrowserDetection( + homeDirectory: defaultHome.path, + cacheTTL: 0, + fileExists: { _ in + probeCount.withLock { $0 += 1 } + return false + }, + directoryContents: { _ in + probeCount.withLock { $0 += 1 } + return nil + }) + + _ = detection.isCookieSourceAvailable(.chrome) + #expect(probeCount.withLock { $0 } == 0) + } + + @Test(.disabled( + if: ProcessInfo.processInfo.environment[BrowserCookieAccessGate.allowTestCookieAccessEnvironmentKey] == "1", + "Default-home cookie access is explicitly enabled for this test run.")) + func `default client reports structured suppression before store discovery`() { + let client = BrowserCookieClient() + + #expect(throws: BrowserCookieStoreAccessSuppressedError.self) { + _ = try client.codexBarStores(for: .chrome) + } + #expect(throws: BrowserCookieStoreAccessSuppressedError.self) { + _ = try client.codexBarRecords( + matching: BrowserCookieQuery(domains: ["example.com"]), + in: .safari) + } + } + @Test - func `safari always installed`() { + func `cookie store decision allows production and explicit test opt in`() { + let defaultHomes = BrowserCookieClient.defaultHomeDirectories() + let testProcess = "swiftpm-testing-helper" + + #expect(BrowserCookieAccessGate.cookieStoreAccessDecision( + homeDirectories: defaultHomes, + processName: testProcess, + environment: [:]) == .suppressed) + #expect(BrowserCookieAccessGate.cookieStoreAccessDecision( + homeDirectories: defaultHomes, + processName: testProcess, + environment: [BrowserCookieAccessGate.allowTestCookieAccessEnvironmentKey: "1"]) == .allowed) + #expect(BrowserCookieAccessGate.cookieStoreAccessDecision( + homeDirectories: defaultHomes, + processName: "CodexBar", + environment: [:]) == .allowed) + } + + @Test(.disabled( + if: ProcessInfo.processInfo.environment[BrowserCookieAccessGate.allowTestCookieAccessEnvironmentKey] == "1", + "Default-home cookie access is explicitly enabled for this test run.")) + func `safari is installed but default cookie access is disabled during tests`() { #expect(BrowserDetection(cacheTTL: 0).isAppInstalled(.safari) == true) - #expect(BrowserDetection(cacheTTL: 0).isCookieSourceAvailable(.safari) == true) + #expect(BrowserDetection(cacheTTL: 0).isCookieSourceAvailable(.safari) == false) } - @Test - func `filter installed includes safari`() { + @Test(.disabled( + if: ProcessInfo.processInfo.environment[BrowserCookieAccessGate.allowTestCookieAccessEnvironmentKey] == "1", + "Default-home cookie access is explicitly enabled for this test run.")) + func `default cookie candidates exclude safari during tests`() { let detection = BrowserDetection(cacheTTL: 0) let browsers: [Browser] = [.safari, .chrome, .firefox] - #expect(browsers.cookieImportCandidates(using: detection).contains(.safari)) + #expect(browsers.cookieImportCandidates(using: detection).contains(.safari) == false) + } + + @Test + func `explicit isolated home keeps safari cookie source available`() { + let detection = BrowserDetection(homeDirectory: "/tmp/codexbar-browser-detection", cacheTTL: 0) + #expect(detection.isCookieSourceAvailable(.safari)) + } + + @Test + func `cookie client permits isolated chromium stores during tests`() throws { + let temp = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + let profile = temp + .appendingPathComponent("Library/Application Support/Google/Chrome/Default/Network") + try FileManager.default.createDirectory(at: profile, withIntermediateDirectories: true) + FileManager.default.createFile(atPath: profile.appendingPathComponent("Cookies").path, contents: Data()) + defer { try? FileManager.default.removeItem(at: temp) } + + let client = BrowserCookieClient(configuration: .init(homeDirectories: [temp])) + let stores = try KeychainAccessGate.withTaskOverrideForTesting(false) { + try KeychainAccessPreflight.withCheckGenericPasswordOverrideForTesting { _, _ in .allowed } operation: { + try ProviderInteractionContext.$current.withValue(.userInitiated) { + try client.codexBarStores(for: .chrome) + } + } + } + #expect(stores.count == 1) } @Test @@ -38,7 +157,7 @@ struct BrowserDetectionTests { atPath: firefoxProfile.appendingPathComponent("cookies.sqlite").path, contents: Data()) - let detection = BrowserDetection(homeDirectory: temp.path, cacheTTL: 0) + let detection = self.detection(homeDirectory: temp.path, installedBrowsers: [.firefox]) let browsers: [Browser] = [.firefox, .safari, .chrome] // Chrome is filtered out deterministically because it lacks usable on-disk profile/cookie store data. #expect(browsers.cookieImportCandidates(using: detection) == [.firefox, .safari]) @@ -50,7 +169,7 @@ struct BrowserDetectionTests { try FileManager.default.createDirectory(at: temp, withIntermediateDirectories: true) defer { try? FileManager.default.removeItem(at: temp) } - let detection = BrowserDetection(homeDirectory: temp.path, cacheTTL: 0) + let detection = self.detection(homeDirectory: temp.path, installedBrowsers: [.chrome]) #expect(detection.isCookieSourceAvailable(.chrome) == false) let profile = temp @@ -67,6 +186,27 @@ struct BrowserDetectionTests { #expect(detection.isCookieSourceAvailable(.chrome) == true) } + @Test + func `Vivaldi uses its Chromium profile and Safe Storage metadata`() throws { + let temp = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + let profile = temp + .appendingPathComponent("Library") + .appendingPathComponent("Application Support") + .appendingPathComponent("Vivaldi") + .appendingPathComponent("Default") + .appendingPathComponent("Network") + try FileManager.default.createDirectory(at: profile, withIntermediateDirectories: true) + FileManager.default.createFile(atPath: profile.appendingPathComponent("Cookies").path, contents: Data()) + defer { try? FileManager.default.removeItem(at: temp) } + + let detection = self.detection(homeDirectory: temp.path, installedBrowsers: [.vivaldi]) + + #expect(Browser.vivaldi.chromiumProfileRelativePath == "Vivaldi") + #expect(Self.labelIDs(for: .vivaldi).contains("Vivaldi Safe Storage|Vivaldi")) + #expect(Browser.defaultImportOrder.contains(.vivaldi)) + #expect(detection.isCookieSourceAvailable(.vivaldi)) + } + @Test func `process filters chromium candidates despite false global keychain override`() throws { guard ProcessInfo.processInfo.environment["CODEXBAR_ALLOW_TEST_KEYCHAIN_ACCESS"] != "1" else { return } @@ -90,49 +230,255 @@ struct BrowserDetectionTests { try FileManager.default.createDirectory(at: cookiesDir, withIntermediateDirectories: true) FileManager.default.createFile(atPath: cookiesDir.appendingPathComponent("Cookies").path, contents: Data()) - let detection = BrowserDetection(homeDirectory: temp.path, cacheTTL: 0) + let detection = self.detection(homeDirectory: temp.path, installedBrowsers: [.chrome]) let browsers: [Browser] = [.chrome, .safari] #expect(browsers.cookieImportCandidates(using: detection) == [.safari]) } @Test - func `keychain interaction suppresses chromium cookie source during cooldown`() { + func `keychain interaction suppresses chromium family during cooldown`() { BrowserCookieAccessGate.resetForTesting() defer { BrowserCookieAccessGate.resetForTesting() } 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(.dia, 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 skips chromium before keychain preflight`() { + BrowserCookieAccessGate.resetForTesting() + defer { BrowserCookieAccessGate.resetForTesting() } + + var preflightCount = 0 + + KeychainAccessGate.withTaskOverrideForTesting(false) { + KeychainAccessPreflight.withCheckGenericPasswordOverrideForTesting { _, _ in + preflightCount += 1 + return .allowed + } operation: { + ProviderInteractionContext.$current.withValue(.background) { + #expect(BrowserCookieAccessGate.shouldAttempt(.chrome) == false) + #expect(BrowserCookieAccessGate.shouldAttempt(.safari) == true) + } + } + } + + #expect(preflightCount == 0) + } + + @Test + func `background cookie import skips chromium without probing keychain interaction`() { + BrowserCookieAccessGate.resetForTesting() + defer { BrowserCookieAccessGate.resetForTesting() } + + var preflightCount = 0 + KeychainAccessGate.withTaskOverrideForTesting(false) { KeychainAccessPreflight.withCheckGenericPasswordOverrideForTesting { _, _ in preflightCount += 1 return .interactionRequired } operation: { - #expect(BrowserCookieAccessGate.shouldAttempt(.chrome, now: start) == false) + ProviderInteractionContext.$current.withValue(.background) { + #expect(BrowserCookieAccessGate.shouldAttempt(.chrome) == false) + #expect(BrowserCookieAccessGate.shouldAttempt(.safari) == true) + } } + } + + #expect(preflightCount == 0) + } + @Test + func `recorded browser denial suppresses automatic family and permits explicit source retry`() { + BrowserCookieAccessGate.resetForTesting() + defer { BrowserCookieAccessGate.resetForTesting() } + + let start = Date(timeIntervalSince1970: 1500) + var preflightCount = 0 + + KeychainAccessGate.withTaskOverrideForTesting(false) { + BrowserCookieAccessGate.recordIfNeeded( + BrowserCookieError.accessDenied(browser: .arc, details: "denied"), + now: start) 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) + ProviderInteractionContext.$current.withValue(.background) { + #expect(BrowserCookieAccessGate.shouldAttempt(.chrome, now: start.addingTimeInterval(1)) == false) + #expect(BrowserCookieAccessGate.shouldAttempt(.edge, now: start.addingTimeInterval(1)) == false) + #expect(BrowserCookieAccessGate.shouldAttempt(.safari, now: start.addingTimeInterval(1)) == true) + } + BrowserCookieAccessGate.withExplicitRetry { + ProviderInteractionContext.$current.withValue(.userInitiated) { + #expect(BrowserCookieAccessGate + .shouldAttempt(.chrome, now: start.addingTimeInterval(2)) == false) + #expect(BrowserCookieAccessGate.shouldAttempt(.arc, now: start.addingTimeInterval(2)) == true) + #expect(BrowserCookieAccessGate.claimExplicitRetryCookieReadIfNeeded(for: .arc)) + BrowserCookieAccessGate.recordAllowed(for: .arc) + } + } + ProviderInteractionContext.$current.withValue(.userInitiated) { + #expect(BrowserCookieAccessGate.shouldAttempt(.chrome, now: start.addingTimeInterval(3)) == true) + } } } #expect(preflightCount == 2) } + @Test + func `denied explicit cookie read closes retry scope`() { + BrowserCookieAccessGate.resetForTesting() + defer { BrowserCookieAccessGate.resetForTesting() } + + let start = Date(timeIntervalSince1970: 1700) + BrowserCookieAccessGate.recordDenied(for: .arc, now: start) + + KeychainAccessGate.withTaskOverrideForTesting(false) { + BrowserCookieAccessGate.withExplicitRetry { + ProviderInteractionContext.$current.withValue(.userInitiated) { + #expect(BrowserCookieAccessGate.shouldAttempt(.arc, now: start.addingTimeInterval(1))) + #expect(BrowserCookieAccessGate.claimExplicitRetryCookieReadIfNeeded(for: .arc)) + + BrowserCookieAccessGate.recordDenied(for: .arc, now: start.addingTimeInterval(2)) + + #expect(BrowserCookieAccessGate.shouldAttempt(.arc, now: start.addingTimeInterval(3)) == false) + #expect(BrowserCookieAccessGate.shouldAttempt(.edge, now: start.addingTimeInterval(3)) == false) + } + } + } + } + + @Test + func `chrome keychain preflight queries only chrome labels`() { + BrowserCookieAccessGate.resetForTesting() + defer { BrowserCookieAccessGate.resetForTesting() } + + let chromeLabels = Self.labelIDs(for: .chrome) + let chromeLabelSet = Set(chromeLabels) + var queriedLabels: [String] = [] + + KeychainAccessGate.withTaskOverrideForTesting(false) { + KeychainAccessPreflight.withCheckGenericPasswordOverrideForTesting { service, account in + let label = Self.labelID(service: service, account: account) + queriedLabels.append(label) + return .notFound + } operation: { + ProviderInteractionContext.$current.withValue(.userInitiated) { + #expect(BrowserCookieAccessGate.shouldAttempt(.chrome) == true) + } + } + } + + #expect(queriedLabels == chromeLabels) + #expect(queriedLabels.allSatisfy { chromeLabelSet.contains($0) }) + } + + @Test + func `dia keychain preflight queries only dia labels`() { + BrowserCookieAccessGate.resetForTesting() + defer { BrowserCookieAccessGate.resetForTesting() } + + let diaLabels = Self.labelIDs(for: .dia) + let diaLabelSet = Set(diaLabels) + var queriedLabels: [String] = [] + + KeychainAccessGate.withTaskOverrideForTesting(false) { + KeychainAccessPreflight.withCheckGenericPasswordOverrideForTesting { service, account in + let label = Self.labelID(service: service, account: account) + queriedLabels.append(label) + return .notFound + } operation: { + ProviderInteractionContext.$current.withValue(.userInitiated) { + #expect(BrowserCookieAccessGate.shouldAttempt(.dia) == true) + } + } + } + + #expect(queriedLabels == diaLabels) + #expect(queriedLabels.allSatisfy { diaLabelSet.contains($0) }) + } + + @Test + func `browser keychain interaction suppresses family and permits scoped explicit retry`() throws { + BrowserCookieAccessGate.resetForTesting() + defer { BrowserCookieAccessGate.resetForTesting() } + + let start = Date(timeIntervalSince1970: 2000) + let chromeLabels = Self.labelIDs(for: .chrome) + let diaLabels = Self.labelIDs(for: .dia) + let firstChromeLabel = try #require(chromeLabels.first) + let firstDiaLabel = try #require(diaLabels.first) + let allowedLabels = Set(chromeLabels + diaLabels) + var queriedLabels: [String] = [] + + KeychainAccessGate.withTaskOverrideForTesting(false) { + KeychainAccessPreflight.withCheckGenericPasswordOverrideForTesting { service, account in + let label = Self.labelID(service: service, account: account) + queriedLabels.append(label) + if label == firstChromeLabel { + return .allowed + } + if label == firstDiaLabel { + return .interactionRequired + } + return .notFound + } operation: { + ProviderInteractionContext.$current.withValue(.userInitiated) { + #expect(BrowserCookieAccessGate.shouldAttempt(.chrome, now: start) == true) + #expect(BrowserCookieAccessGate.shouldAttempt(.dia, now: start.addingTimeInterval(1)) == false) + #expect(BrowserCookieAccessGate.shouldAttempt(.chrome, now: start.addingTimeInterval(60)) == false) + #expect(BrowserCookieAccessGate.shouldAttempt(.dia, now: start.addingTimeInterval(60)) == false) + } + BrowserCookieAccessGate.withExplicitRetry { + ProviderInteractionContext.$current.withValue(.userInitiated) { + #expect(BrowserCookieAccessGate + .shouldAttempt(.chrome, now: start.addingTimeInterval(61)) == false) + #expect(BrowserCookieAccessGate.shouldAttempt(.dia, now: start.addingTimeInterval(61)) == true) + #expect(BrowserCookieAccessGate + .shouldAttempt(.edge, now: start.addingTimeInterval(61)) == false) + } + } + } + } + + #expect(queriedLabels == [firstChromeLabel, firstDiaLabel, firstDiaLabel]) + #expect(queriedLabels.allSatisfy { allowedLabels.contains($0) }) + } + @Test func `dia requires profile data`() throws { let temp = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) try FileManager.default.createDirectory(at: temp, withIntermediateDirectories: true) defer { try? FileManager.default.removeItem(at: temp) } - let detection = BrowserDetection(homeDirectory: temp.path, cacheTTL: 0) + let detection = self.detection(homeDirectory: temp.path, installedBrowsers: [.dia]) #expect(detection.isCookieSourceAvailable(.dia) == false) let profile = temp @@ -149,6 +495,197 @@ struct BrowserDetectionTests { #expect(detection.isCookieSourceAvailable(.dia) == true) } + @Test + func `removed browser with stale cookies is not a candidate`() throws { + let temp = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + let cookies = temp + .appendingPathComponent("Library/Application Support/Dia/User Data/Default/Network/Cookies") + try FileManager.default.createDirectory( + at: cookies.deletingLastPathComponent(), + withIntermediateDirectories: true) + FileManager.default.createFile(atPath: cookies.path, contents: Data()) + defer { try? FileManager.default.removeItem(at: temp) } + + let detection = self.detection(homeDirectory: temp.path, installedBrowsers: []) + + #expect(detection.hasUsableProfileData(.dia)) + #expect(!detection.isCookieSourceAvailable(.dia)) + #expect([Browser.dia].cookieImportCandidates(using: detection).isEmpty) + } + + @Test + func `browser uninstall invalidates cookie source immediately`() throws { + let temp = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + let cookies = temp + .appendingPathComponent("Library/Application Support/Google/Chrome/Default/Network/Cookies") + try FileManager.default.createDirectory( + at: cookies.deletingLastPathComponent(), + withIntermediateDirectories: true) + FileManager.default.createFile(atPath: cookies.path, contents: Data()) + defer { try? FileManager.default.removeItem(at: temp) } + + let installed = OSAllocatedUnfairLock(initialState: true) + let detection = BrowserDetection( + homeDirectory: temp.path, + cacheTTL: 600, + fileExists: { path in + if path == "/Applications/Google Chrome.app" { + return installed.withLock { $0 } + } + return FileManager.default.fileExists(atPath: path) + }, + directoryContents: { path in + try? FileManager.default.contentsOfDirectory(atPath: path) + }) + + #expect(detection.isCookieSourceAvailable(.chrome)) + installed.withLock { $0 = false } + #expect(!detection.isCookieSourceAvailable(.chrome)) + } + + @Test + func `registered browser outside Applications is a candidate`() throws { + let temp = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + let cookies = temp + .appendingPathComponent("Library/Application Support/Google/Chrome/Default/Network/Cookies") + try FileManager.default.createDirectory( + at: cookies.deletingLastPathComponent(), + withIntermediateDirectories: true) + FileManager.default.createFile(atPath: cookies.path, contents: Data()) + defer { try? FileManager.default.removeItem(at: temp) } + + let appURL = URL(fileURLWithPath: "/Volumes/Tools/Google Chrome.app") + let detection = BrowserDetection( + homeDirectory: temp.path, + cacheTTL: 0, + now: Date.init, + fileExists: { path in + path == appURL.path || FileManager.default.fileExists(atPath: path) + }, + directoryContents: { path in + try? FileManager.default.contentsOfDirectory(atPath: path) + }, + applicationURLs: { appName in + appName == Browser.chrome.appBundleName ? [appURL] : [] + }, + profileAccessIssue: { _ in nil }) + + #expect(detection.isCookieSourceAvailable(.chrome)) + } + + @Test + func `interactive source accepts an installed browser before its cookie store exists`() { + let home = "/tmp/codexbar-fresh-browser-profile" + let profileRoot = "\(home)/Library/Application Support/Google/Chrome" + let applicationPath = "/Applications/Google Chrome.app" + let freshInstall = BrowserDetection( + homeDirectory: home, + cacheTTL: 600, + now: Date.init, + fileExists: { $0 == applicationPath }, + directoryContents: { _ in nil }, + applicationURLs: { _ in [] }, + profileAccessIssue: { _ in nil }) + + #expect(!freshInstall.isCookieSourceAvailable(.chrome)) + #expect(freshInstall.isInteractiveCookieSourceAvailable(.chrome)) + + let inaccessibleProfile = BrowserDetection( + homeDirectory: home, + cacheTTL: 600, + now: Date.init, + fileExists: { $0 == applicationPath }, + directoryContents: { _ in nil }, + applicationURLs: { _ in [] }, + profileAccessIssue: { _ in .accessDenied }) + + #expect(!inaccessibleProfile.isInteractiveCookieSourceAvailable(.chrome)) + + let emptyReadableProfile = BrowserDetection( + homeDirectory: home, + cacheTTL: 600, + now: Date.init, + fileExists: { $0 == applicationPath || $0 == profileRoot }, + directoryContents: { $0 == profileRoot ? [] : nil }, + applicationURLs: { _ in [] }, + profileAccessIssue: { _ in nil }) + + #expect(!emptyReadableProfile.isCookieSourceAvailable(.chrome)) + #expect(emptyReadableProfile.isInteractiveCookieSourceAvailable(.chrome)) + } + + @Test + func `interactive source treats a missing production profile path as fresh`() throws { + let temp = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: temp, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: temp) } + + let detection = BrowserDetection( + homeDirectory: temp.path, + cacheTTL: 0, + fileExists: { path in + path == "/Applications/Google Chrome.app" || FileManager.default.fileExists(atPath: path) + }, + directoryContents: { path in + try? FileManager.default.contentsOfDirectory(atPath: path) + }) + + #expect(detection.isInteractiveCookieSourceAvailable(.chrome)) + } + + @Test + func `stale registered browser outside Applications is not a candidate`() throws { + let temp = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + let cookies = temp + .appendingPathComponent("Library/Application Support/Google/Chrome/Default/Network/Cookies") + try FileManager.default.createDirectory( + at: cookies.deletingLastPathComponent(), + withIntermediateDirectories: true) + FileManager.default.createFile(atPath: cookies.path, contents: Data()) + defer { try? FileManager.default.removeItem(at: temp) } + + let staleAppURL = URL(fileURLWithPath: "/Volumes/Removed/Google Chrome.app") + let detection = BrowserDetection( + homeDirectory: temp.path, + cacheTTL: 0, + now: Date.init, + fileExists: { path in + if path.hasSuffix("/Google Chrome.app") { + return false + } + return FileManager.default.fileExists(atPath: path) + }, + directoryContents: { path in + try? FileManager.default.contentsOfDirectory(atPath: path) + }, + applicationURLs: { appName in + appName == Browser.chrome.appBundleName ? [staleAppURL] : [] + }, + profileAccessIssue: { _ in nil }) + + #expect(!detection.isCookieSourceAvailable(.chrome)) + } + + @Test + func `installed browser reports denied profile access`() { + let home = "/tmp/codexbar-denied-browser-profile" + let profileRoot = "\(home)/Library/Application Support/Google/Chrome" + let detection = BrowserDetection( + homeDirectory: home, + cacheTTL: 0, + now: Date.init, + fileExists: { path in + path == "/Applications/Google Chrome.app" || path == profileRoot + }, + directoryContents: { _ in nil }, + applicationURLs: { _ in [] }, + profileAccessIssue: { _ in .accessDenied }) + + #expect(detection.cookieSourceProfileAccessIssue(.chrome) == .accessDenied) + #expect(!detection.isCookieSourceAvailable(.chrome)) + #expect(!detection.isInteractiveCookieSourceAvailable(.chrome)) + } + @Test func `firefox requires default profile dir`() throws { let temp = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) @@ -162,7 +699,7 @@ struct BrowserDetectionTests { .appendingPathComponent("Profiles") try FileManager.default.createDirectory(at: profiles, withIntermediateDirectories: true) - let detection = BrowserDetection(homeDirectory: temp.path, cacheTTL: 0) + let detection = self.detection(homeDirectory: temp.path, installedBrowsers: [.firefox]) #expect(detection.isCookieSourceAvailable(.firefox) == false) let profile = profiles.appendingPathComponent("abc.default-release") @@ -171,6 +708,29 @@ struct BrowserDetectionTests { #expect(detection.isCookieSourceAvailable(.firefox) == true) } + @Test + func `firefox developer edition unlocks the shared Firefox cookie store`() { + let home = "/tmp/codexbar-firefox-developer-edition" + let profiles = "\(home)/Library/Application Support/Firefox/Profiles" + let cookieDB = "\(profiles)/abc.default-release/cookies.sqlite" + let detection = BrowserDetection( + homeDirectory: home, + cacheTTL: 0, + now: Date.init, + fileExists: { path in + path == "/Applications/Firefox Developer Edition.app" || + path == profiles || + path == cookieDB + }, + directoryContents: { path in + path == profiles ? ["abc.default-release"] : nil + }, + applicationURLs: { _ in [] }, + profileAccessIssue: { _ in nil }) + + #expect(detection.isCookieSourceAvailable(.firefox)) + } + @Test func `zen accepts uppercase default profile dir`() throws { let temp = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) @@ -184,7 +744,7 @@ struct BrowserDetectionTests { .appendingPathComponent("Profiles") try FileManager.default.createDirectory(at: profiles, withIntermediateDirectories: true) - let detection = BrowserDetection(homeDirectory: temp.path, cacheTTL: 0) + let detection = self.detection(homeDirectory: temp.path, installedBrowsers: [.zen]) #expect(detection.isCookieSourceAvailable(.zen) == false) let profile = profiles.appendingPathComponent("abc.Default (release)") diff --git a/Tests/CodexBarTests/CLIArgumentParsingTests.swift b/Tests/CodexBarTests/CLIArgumentParsingTests.swift index 86377e8a0a..c8ace30e22 100644 --- a/Tests/CodexBarTests/CLIArgumentParsingTests.swift +++ b/Tests/CodexBarTests/CLIArgumentParsingTests.swift @@ -1,5 +1,6 @@ import CodexBarCore import Commander +import Foundation import Testing @testable import CodexBarCLI @@ -84,4 +85,45 @@ struct CLIArgumentParsingTests { Issue.record("diagnose should not emit provider logs beside the safe JSON export") } } + + @Test + func `diagnose accepts explicit redact and output path`() throws { + let signature = CodexBarCLI._diagnoseSignatureForTesting() + let parser = CommandParser(signature: signature) + let parsed = try parser.parse(arguments: [ + "--provider", "minimax", + "--format", "json", + "--redact", + "--output", "diagnostic.json", + ]) + + #expect(parsed.flags.contains("redact")) + #expect(parsed.options["output"] == ["diagnostic.json"]) + } + + @Test + func `Claude OAuth usage does not detect CLI version`() { + #expect(!CodexBarCLI.shouldDetectVersion( + provider: .claude, + result: self.makeResult(kind: .oauth))) + #expect(CodexBarCLI.shouldDetectVersion( + provider: .claude, + result: self.makeResult(kind: .cli))) + #expect(CodexBarCLI.shouldDetectVersion( + provider: .codex, + result: self.makeResult(kind: .oauth))) + } + + private func makeResult(kind: ProviderFetchKind) -> ProviderFetchResult { + ProviderFetchResult( + usage: UsageSnapshot( + primary: nil, + secondary: nil, + updatedAt: Date(timeIntervalSince1970: 0)), + credits: nil, + dashboard: nil, + sourceLabel: "test", + strategyID: "test", + strategyKind: kind) + } } diff --git a/Tests/CodexBarTests/CLICardsClaudeSwapTests.swift b/Tests/CodexBarTests/CLICardsClaudeSwapTests.swift new file mode 100644 index 0000000000..78f11e627d --- /dev/null +++ b/Tests/CodexBarTests/CLICardsClaudeSwapTests.swift @@ -0,0 +1,421 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBarCLI + +struct CLICardsClaudeSwapTests { + private actor InvocationCounter { + private(set) var value = 0 + + func increment() { + self.value += 1 + } + } + + private struct AdapterError: LocalizedError, Sendable { + let text: String + var errorDescription: String? { + self.text + } + } + + private func ambientOutput(failed: Bool = false) -> UsageCommandOutput { + var output = UsageCommandOutput() + output.cards = [CLICardModel( + provider: .claude, + title: "Ambient Claude", + sourceLabel: "oauth", + planBadge: "Max", + accountLine: "ambient@example.com", + infoLines: [], + metrics: [CLICardMetric(label: "Session", remainingPercent: 50, resetText: nil)], + extraLines: [], + statusLine: nil)] + if failed { + output.cardFailures = [CLICardFailure(provider: .claude, accountLabel: nil, message: "ambient failed")] + output.exitCode = .failure + } + return output + } + + private func renderOptions(status: ProviderStatusPayload? = nil) -> CLIClaudeSwapCardsRenderOptions { + CLIClaudeSwapCardsRenderOptions( + status: status, + useColor: false, + resetStyle: .countdown, + weeklyWorkDays: nil, + now: Date(timeIntervalSince1970: 1_700_000_000)) + } + + private func row( + number: Int, + active: Bool = false, + status: ClaudeSwapUsageStatus = .ok, + email: String? = nil, + hasUsage: Bool = true) -> ClaudeSwapAccountRow + { + ClaudeSwapAccountRow( + number: number, + email: email ?? "account-\(number)@example.com", + isActive: active, + usageStatus: status, + fiveHour: hasUsage ? ClaudeSwapUsageWindow(usedPercent: Double(number * 10), resetsAt: nil) : nil, + sevenDay: nil) + } + + @Test + func `configured executable path strips surrounding quotes`() { + for rawPath in [" \"/tmp/cswap\" ", " '/tmp/cswap' "] { + let config = ProviderConfig(id: .claude, claudeSwapExecutablePath: rawPath) + #expect(CLIClaudeSwapCards.executablePath(from: config) == "/tmp/cswap") + } + #expect(CLIClaudeSwapCards.executablePath(from: nil).isEmpty) + } + + @Test + func `single account config is backward compatible and round trips opt in`() throws { + let legacyData = Data(#"{"id":"claude"}"#.utf8) + let legacy = try JSONDecoder().decode(ProviderConfig.self, from: legacyData) + #expect(legacy.claudeSwapShowSingleAccount != true) + + let enabled = ProviderConfig(id: .claude, claudeSwapShowSingleAccount: true) + let encoded = try JSONEncoder().encode(enabled) + let decoded = try JSONDecoder().decode(ProviderConfig.self, from: encoded) + #expect(decoded.claudeSwapShowSingleAccount == true) + } + + @Test + func `eligibility preserves explicit account and source intent`() { + let eligibleSourceModes: [ProviderSourceMode?] = [nil, .auto] + for sourceMode in eligibleSourceModes { + #expect(CLIClaudeSwapCards.isEligible( + provider: .claude, + integrationEnabled: true, + hasExplicitAccountSelection: false, + sourceModeOverride: sourceMode)) + } + + for sourceMode in [ProviderSourceMode.web, .cli, .oauth, .api] { + #expect(!CLIClaudeSwapCards.isEligible( + provider: .claude, + integrationEnabled: true, + hasExplicitAccountSelection: false, + sourceModeOverride: sourceMode)) + } + + #expect(!CLIClaudeSwapCards.isEligible( + provider: .claude, + integrationEnabled: false, + hasExplicitAccountSelection: false, + sourceModeOverride: nil)) + #expect(!CLIClaudeSwapCards.isEligible( + provider: .claude, + integrationEnabled: true, + hasExplicitAccountSelection: true, + sourceModeOverride: nil)) + #expect(!CLIClaudeSwapCards.isEligible( + provider: .codex, + integrationEnabled: true, + hasExplicitAccountSelection: false, + sourceModeOverride: nil)) + } + + @Test + func `bypass does not invoke the adapter when single account cards are enabled`() async { + let counter = InvocationCounter() + let ambient = self.ambientOutput() + let output = await CLIClaudeSwapCards.fetch( + eligible: false, + executablePath: "/unused/cswap", + showSingleAccount: true, + renderOptions: self.renderOptions(), + ambientFetch: { ambient }, + accountListReader: { _ in + await counter.increment() + return ClaudeSwapAccountList(activeAccountNumber: nil, accounts: []) + }) + + #expect(await counter.value == 0) + #expect(output.cards == ambient.cards) + } + + @Test + func `zero and one account lists retain ambient output`() async { + let ambientCounter = InvocationCounter() + let ambient = self.ambientOutput() + for accounts in [[], [self.row(number: 1)]] { + let output = await CLIClaudeSwapCards.fetch( + eligible: true, + executablePath: "/fake/cswap", + renderOptions: self.renderOptions(), + ambientFetch: { + await ambientCounter.increment() + return ambient + }, + accountListReader: { _ in + ClaudeSwapAccountList(activeAccountNumber: nil, accounts: accounts) + }) + #expect(output.cards == ambient.cards) + #expect(output.cardFailures.isEmpty) + } + #expect(await ambientCounter.value == 2) + } + + @Test + func `single account option renders sentinel account instead of ambient output`() async { + let ambientCounter = InvocationCounter() + let output = await CLIClaudeSwapCards.fetch( + eligible: true, + executablePath: "/fake/cswap", + showSingleAccount: true, + renderOptions: self.renderOptions(), + ambientFetch: { + await ambientCounter.increment() + return self.ambientOutput(failed: true) + }, + accountListReader: { _ in + ClaudeSwapAccountList(activeAccountNumber: 1, accounts: [ + self.row( + number: 1, + active: true, + status: .tokenExpired, + email: "single@example.com", + hasUsage: false), + ]) + }) + + #expect(await ambientCounter.value == 0) + #expect(output.exitCode == .success) + #expect(output.cards.count == 1) + #expect(output.cards.first?.accountLine == "single@example.com") + #expect(output.cards.first?.isActive == true) + #expect(output.cards.first?.accountProblem == + "Token expired. Switch to this account in claude-swap to refresh it.") + } + + @Test + func `multi account list skips ambient output and renders in active slot order`() async { + let adapterCounter = InvocationCounter() + let ambientCounter = InvocationCounter() + let ambient = self.ambientOutput(failed: true) + let list = ClaudeSwapAccountList(activeAccountNumber: 2, accounts: [ + self.row(number: 3), + self.row(number: 2, active: true), + self.row(number: 1), + ]) + let status = ProviderStatusPayload( + indicator: .minor, + description: "Degraded performance", + updatedAt: Date(timeIntervalSince1970: 0), + url: "https://status.example.com") + + let output = await CLIClaudeSwapCards.fetch( + eligible: true, + executablePath: "/fake/cswap", + renderOptions: self.renderOptions(status: status), + ambientFetch: { + await ambientCounter.increment() + return ambient + }, + accountListReader: { _ in + await adapterCounter.increment() + return list + }) + + #expect(await adapterCounter.value == 1) + #expect(await ambientCounter.value == 0) + #expect(output.cards.map(\.accountLine) == [ + "account-2@example.com", + "account-1@example.com", + "account-3@example.com", + ]) + #expect(output.cards.map(\.isActive) == [true, false, false]) + #expect(output.cards.allSatisfy { $0.sourceLabel == "claude-swap" && $0.planBadge == nil }) + #expect(output.cards.allSatisfy { $0.statusLine == "Status: Partial outage – Degraded performance" }) + #expect(output.cardFailures.isEmpty) + #expect(output.exitCode == .success) + } + + @Test + func `all sentinel rows remain successful metrics less cards`() async { + let statuses: [ClaudeSwapUsageStatus] = [ + .apiKey, + .tokenExpired, + .reloginRequired, + .keychainUnavailable, + .noCredentials, + .unavailable, + .unknown("future_status"), + .ok, + ] + let rows = statuses.enumerated().map { index, status in + self.row(number: index + 1, status: status, hasUsage: false) + } + let output = await CLIClaudeSwapCards.fetch( + eligible: true, + executablePath: "/fake/cswap", + renderOptions: self.renderOptions(), + ambientFetch: { self.ambientOutput(failed: true) }, + accountListReader: { _ in + ClaudeSwapAccountList(activeAccountNumber: nil, accounts: rows) + }) + + #expect(output.exitCode == .success) + #expect(output.cards.count == statuses.count) + #expect(output.cards.allSatisfy { $0.metrics.isEmpty && !$0.isActive }) + #expect(output.cards.map(\.accountProblem) == [ + "API-key account; subscription usage is unavailable.", + "Token expired. Switch to this account in claude-swap to refresh it.", + "Re-login required. Re-authenticate this account in claude-swap.", + "claude-swap could not read the active account's Keychain entry.", + "No stored credentials for this account slot.", + "Usage fetch failed.", + "Unrecognized claude-swap status: future_status", + "No usage windows reported.", + ]) + } + + @Test + func `active sentinel account remains active and metrics less in full and brief cards`() async { + let problem = "Usage fetch failed." + let output = await CLIClaudeSwapCards.fetch( + eligible: true, + executablePath: "/fake/cswap", + renderOptions: self.renderOptions(), + ambientFetch: { self.ambientOutput(failed: true) }, + accountListReader: { _ in + ClaudeSwapAccountList(activeAccountNumber: 1, accounts: [ + self.row( + number: 1, + active: true, + status: .unavailable, + email: "active@example.com", + hasUsage: false), + self.row(number: 2), + ]) + }) + + #expect(output.exitCode == .success) + #expect(output.cardFailures.isEmpty) + #expect(output.cards.count == 2) + let activeCard = output.cards.first + #expect(activeCard?.accountLine == "active@example.com") + #expect(activeCard?.isActive == true) + #expect(activeCard?.accountProblem == problem) + #expect(activeCard?.metrics.isEmpty == true) + + let rows = CLICardsBriefRenderer.makeRows(cards: activeCard.map { [$0] } ?? []) + #expect(rows.count == 1) + #expect(rows.first?.accountLabel == "active@example.com") + #expect(rows.first?.isActive == true) + #expect(rows.first?.accountProblem == problem) + #expect(rows.first?.metricLabel == nil) + #expect(rows.first?.usedPercent == nil) + } + + @Test + func `blank executable path preserves ambient output and fails distinctly`() async { + let ambient = self.ambientOutput() + let output = await CLIClaudeSwapCards.fetch( + eligible: true, + executablePath: " ", + renderOptions: self.renderOptions(), + ambientFetch: { ambient }) + + #expect(output.cards == ambient.cards) + #expect(output.exitCode != .success) + #expect(output.cardFailures == [CLICardFailure( + provider: .claude, + accountLabel: "claude-swap", + message: "No claude-swap executable path is configured.")]) + } + + @Test + func `adapter failures follow ambient failures and are bounded and sanitized`() async { + let raw = "\u{1B}]0;owned\u{07}reader\r\nfailed\u{1B}[31m" + String(repeating: "x", count: 700) + let output = await CLIClaudeSwapCards.fetch( + eligible: true, + executablePath: " ", + renderOptions: self.renderOptions(), + ambientFetch: { self.ambientOutput(failed: true) }, + accountListReader: { _ in throw AdapterError(text: raw) }) + + #expect(output.exitCode != .success) + #expect(output.cards.first?.title == "Ambient Claude") + #expect(output.cardFailures.map(\.accountLabel) == [nil, "claude-swap"]) + let diagnostic = output.cardFailures.last?.message ?? "" + #expect(diagnostic.contains("reader failed")) + #expect(!diagnostic.contains("\u{1B}")) + #expect(diagnostic.unicodeScalars.count == CLIClaudeSwapText.diagnosticScalarLimit) + } + + @Test + func `fake executable receives only one read only list command`() async throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("cards-claude-swap-tests-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let executable = directory.appendingPathComponent("cswap") + let invocationMarker = directory.appendingPathComponent("invoked", isDirectory: true) + let duplicateMarker = directory.appendingPathComponent("duplicate") + let script = """ + #!/bin/sh + mkdir '\(invocationMarker.path)' || { + touch '\(duplicateMarker.path)' + exit 2 + } + [ "$#" -eq 2 ] || exit 2 + [ "$1" = "--list" ] || exit 2 + [ "$2" = "--json" ] || exit 2 + cat <<'JSON' + {"schemaVersion":1,"activeAccountNumber":2,"accounts":[ + {"number":1,"email":"one@example.com","active":false,"usageStatus":"api_key"}, + {"number":2,"email":"two@example.com","active":true,"usageStatus":"unavailable"} + ]} + JSON + """ + try script.write(to: executable, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: executable.path) + + let output = await CLIClaudeSwapCards.fetch( + eligible: true, + executablePath: executable.path, + renderOptions: self.renderOptions(), + ambientFetch: { self.ambientOutput() }) + + #expect(output.exitCode == .success) + #expect(output.cardFailures.isEmpty) + #expect(output.cards.count == 2) + #expect(FileManager.default.fileExists(atPath: invocationMarker.path)) + #expect(!FileManager.default.fileExists(atPath: duplicateMarker.path)) + } + + @Test + func `cancellation drains the adapter child and preserves ambient output`() async { + let cancellationCount = InvocationCounter() + let ambient = self.ambientOutput() + let task = Task { + await CLIClaudeSwapCards.fetch( + eligible: true, + executablePath: "/fake/cswap", + renderOptions: self.renderOptions(), + ambientFetch: { ambient }, + accountListReader: { _ in + do { + try await Task.sleep(for: .seconds(30)) + return ClaudeSwapAccountList(activeAccountNumber: nil, accounts: []) + } catch { + await cancellationCount.increment() + throw error + } + }) + } + await Task.yield() + task.cancel() + let output = await task.value + + #expect(await cancellationCount.value == 1) + #expect(output.cards == ambient.cards) + #expect(output.cardFailures.last?.accountLabel == "claude-swap") + #expect(output.exitCode != .success) + } +} diff --git a/Tests/CodexBarTests/CLICardsRendererTests.swift b/Tests/CodexBarTests/CLICardsRendererTests.swift new file mode 100644 index 0000000000..9ce3b54343 --- /dev/null +++ b/Tests/CodexBarTests/CLICardsRendererTests.swift @@ -0,0 +1,701 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBarCLI + +struct CLICardsRendererTests { + @Test + func `computes column count from terminal width`() { + #expect(CLICardsRenderer.columnCount(terminalWidth: 80) == 2) + #expect(CLICardsRenderer.columnCount(terminalWidth: 120) == 3) + #expect(CLICardsRenderer.columnCount(terminalWidth: 160) == 4) + #expect(CLICardsRenderer.columnCount(terminalWidth: 30) == 1) + } + + @Test + func `renders single codex card without color`() { + let identity = ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: "user@example.com", + accountOrganization: nil, + loginMethod: "pro") + let snapshot = UsageSnapshot( + primary: .init(usedPercent: 12, windowMinutes: 300, resetsAt: nil, resetDescription: "today at 3:00 PM"), + secondary: .init(usedPercent: 25, windowMinutes: 10080, resetsAt: nil, resetDescription: "Fri at 9:00 AM"), + tertiary: nil, + updatedAt: Date(timeIntervalSince1970: 0), + identity: identity) + let card = CLICardsRenderer.makeCard(CLICardBuildInput( + provider: .codex, + snapshot: snapshot, + credits: CreditsSnapshot(remaining: 42, events: [], updatedAt: Date()), + source: "oauth", + status: nil, + notes: [], + useColor: false, + resetStyle: .absolute, + weeklyWorkDays: nil, + now: Date())) + + let output = CLICardsRenderer.render(cards: [card], failures: [], terminalWidth: 80, useColor: false) + + #expect(output.contains("Codex")) + #expect(output.contains("[oauth]")) + #expect(output.contains("PLAN Pro 20x")) + #expect(output.contains("Session")) + #expect(output.contains("88% left")) + #expect(output.contains("[ ")) + #expect(output.contains("━")) + #expect(output.contains("Credits:")) + #expect(output.contains("42 left")) + #expect(output.contains("@ user@example.com")) + #expect(output.contains("╰")) + } + + @Test + func `card includes account line`() { + let identity = ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: "user@example.com", + accountOrganization: nil, + loginMethod: "pro") + let snapshot = UsageSnapshot( + primary: .init(usedPercent: 12, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + secondary: nil, + tertiary: nil, + updatedAt: Date(timeIntervalSince1970: 0), + identity: identity) + let card = CLICardsRenderer.makeCard(CLICardBuildInput( + provider: .codex, + snapshot: snapshot, + credits: nil, + source: "cli", + status: nil, + notes: [], + useColor: false, + resetStyle: .absolute, + weeklyWorkDays: nil, + now: Date())) + + let lines = CLICardsRenderer.renderCard(card, width: 48, useColor: false) + let joined = lines.joined(separator: "\n") + + #expect(joined.contains("@ user@example.com")) + #expect(joined.contains("Session")) + #expect(!joined.contains("Plan: Pro 20x")) + } + + @Test + func `renders two card grid at fixed width`() { + let codex = CLICardModel( + provider: .codex, + title: "Codex", + sourceLabel: "oauth", + planBadge: "Pro", + accountLine: nil, + infoLines: [], + metrics: [CLICardMetric(label: "Session", remainingPercent: 88, resetText: nil)], + extraLines: [], + statusLine: nil) + let claude = CLICardModel( + provider: .claude, + title: "Claude", + sourceLabel: "web", + planBadge: "Max", + accountLine: nil, + infoLines: [], + metrics: [CLICardMetric(label: "Session", remainingPercent: 50, resetText: nil)], + extraLines: [], + statusLine: nil) + + let output = CLICardsRenderer.render(cards: [codex, claude], failures: [], terminalWidth: 120, useColor: false) + + #expect(output.contains("Codex")) + #expect(output.contains("Claude")) + #expect(output.contains("88% left")) + #expect(output.contains("50% left")) + #expect(output.components(separatedBy: "╰").count >= 3) + } + + @Test + func `renders failure footer without cards`() { + let failures = [ + CLICardFailure(provider: .cursor, accountLabel: nil, message: "not configured"), + ] + let output = CLICardsRenderer.render(cards: [], failures: failures, terminalWidth: 80, useColor: false) + + #expect(output.contains("Failed providers:")) + #expect(output.contains("Cursor: not configured")) + } + + @Test + func `appends failure footer after successful cards`() { + let card = CLICardModel( + provider: .codex, + title: "Codex", + sourceLabel: "oauth", + planBadge: nil, + accountLine: nil, + infoLines: [], + metrics: [CLICardMetric(label: "Session", remainingPercent: 88, resetText: nil)], + extraLines: [], + statusLine: nil) + let failures = [ + CLICardFailure(provider: .grok, accountLabel: nil, message: "timeout"), + ] + + let output = CLICardsRenderer.render(cards: [card], failures: failures, terminalWidth: 80, useColor: false) + + #expect(output.contains("88% left")) + #expect(output.contains("Failed providers:")) + #expect(output.contains("Grok: timeout")) + } + + @Test + func `brief mode renders usage table`() { + let card = CLICardModel( + provider: .claude, + title: "Claude", + sourceLabel: "web", + planBadge: "Max", + accountLine: nil, + infoLines: [], + metrics: [CLICardMetric(label: "Session", remainingPercent: 2, resetText: "⏳ Resets in 1h 49m")], + extraLines: [], + statusLine: nil) + let rows = CLICardsBriefRenderer.makeRows(cards: [card]) + let output = CLICardsBriefRenderer.render( + rows: rows, + failures: [], + terminalWidth: 80, + useColor: false, + now: Date(timeIntervalSince1970: 0)) + + #expect(output.contains("codexbar • AI Usage & Limits")) + #expect(output.contains("Provider")) + #expect(output.contains("Claude")) + #expect(output.contains("web")) + #expect(output.contains("Max")) + #expect(output.contains("98%")) + #expect(output.contains("█")) + #expect(output.contains("1h 49m")) + #expect(output.contains("⚠ Warnings:")) + let tableLine = output.split(separator: "\n").first { $0.hasPrefix("┌") } ?? "" + #expect(tableLine.count >= 50) + #expect(tableLine.count <= 72) + } + + @Test + func `synthetic quota lanes do not replace real brief usage`() { + let snapshot = UsageSnapshot( + primary: .init( + usedPercent: 0, + windowMinutes: 300, + resetsAt: nil, + resetDescription: nil, + isSyntheticPlaceholder: true), + secondary: .init( + usedPercent: 20, + windowMinutes: 10080, + resetsAt: nil, + resetDescription: nil), + tertiary: nil, + updatedAt: Date(timeIntervalSince1970: 0)) + let card = CLICardsRenderer.makeCard(CLICardBuildInput( + provider: .claude, + snapshot: snapshot, + credits: nil, + source: "oauth", + status: nil, + notes: [], + useColor: false, + resetStyle: .countdown, + weeklyWorkDays: nil, + now: Date(timeIntervalSince1970: 0))) + let rows = CLICardsBriefRenderer.makeRows(cards: [card]) + + #expect(card.metrics.map(\.label) == ["Weekly"]) + #expect(rows.first?.usedPercent == 20) + } + + @Test + func `brief reset summary wraps to terminal width`() { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let card = CLICardModel( + provider: .alibabatokenplan, + title: "Alibaba Token Plan", + sourceLabel: "web", + planBadge: "International", + accountLine: nil, + infoLines: [], + metrics: [CLICardMetric( + label: "Monthly budget", + remainingPercent: 50, + resetText: "⏳ Resets July 30 at 11:59 PM", + resetAt: now.addingTimeInterval(3600))], + extraLines: [], + statusLine: nil) + + let output = CLICardsBriefRenderer.render( + rows: CLICardsBriefRenderer.makeRows(cards: [card]), + failures: [], + terminalWidth: 40, + useColor: false, + now: now) + let lines = output.split(separator: "\n", omittingEmptySubsequences: false) + + #expect(output.contains("Next reset: Alibaba Token Plan")) + #expect(lines.allSatisfy { $0.count <= 40 }) + } + + @Test + func `detail backed quota descriptions are not rendered as resets`() { + let snapshot = UsageSnapshot( + primary: .init( + usedPercent: 25, + windowMinutes: nil, + resetsAt: nil, + resetDescription: "25/100 credits"), + secondary: nil, + tertiary: nil, + updatedAt: Date(timeIntervalSince1970: 0)) + let card = CLICardsRenderer.makeCard(CLICardBuildInput( + provider: .kilo, + snapshot: snapshot, + credits: nil, + source: "api", + status: nil, + notes: [], + useColor: false, + resetStyle: .countdown, + weeklyWorkDays: nil, + now: Date(timeIntervalSince1970: 0))) + + #expect(card.metrics.first?.resetText == nil) + #expect(card.metrics.first?.detailText == "25/100 credits") + + let output = CLICardsBriefRenderer.render( + rows: CLICardsBriefRenderer.makeRows(cards: [card]), + failures: [], + terminalWidth: 80, + useColor: false, + now: Date(timeIntervalSince1970: 0)) + #expect(!output.contains("Next reset")) + #expect(!output.contains("Reset 25/100 credits")) + } + + @Test + func `card metrics honor reset display style`() { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let snapshot = UsageSnapshot( + primary: .init( + usedPercent: 25, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(3600), + resetDescription: nil), + secondary: nil, + tertiary: nil, + updatedAt: now) + let countdown = CLICardsRenderer.makeCard(CLICardBuildInput( + provider: .codex, + snapshot: snapshot, + credits: nil, + source: "oauth", + status: nil, + notes: [], + useColor: false, + resetStyle: .countdown, + weeklyWorkDays: nil, + now: now)) + let absolute = CLICardsRenderer.makeCard(CLICardBuildInput( + provider: .codex, + snapshot: snapshot, + credits: nil, + source: "oauth", + status: nil, + notes: [], + useColor: false, + resetStyle: .absolute, + weeklyWorkDays: nil, + now: now)) + + #expect(countdown.metrics.first?.resetText != absolute.metrics.first?.resetText) + #expect(countdown.metrics.first?.resetText?.contains("in 1h") == true) + #expect(absolute.metrics.first?.resetAt == now.addingTimeInterval(3600)) + } + + @Test + func `long detail rows stay within card width`() { + let card = CLICardModel( + provider: .clawrouter, + title: "ClawRouter", + sourceLabel: "api", + planBadge: nil, + accountLine: nil, + infoLines: ["Workspace: " + String(repeating: "long-name-", count: 12)], + metrics: [], + extraLines: [], + statusLine: nil) + + let lines = CLICardsRenderer.renderCard(card, width: 38, useColor: true, enhanced: true) + #expect(lines.allSatisfy { TextParsing.stripANSICodes($0).count == 38 }) + } + + @Test + func `brief warnings name the actual quota metric`() { + let card = CLICardModel( + provider: .openrouter, + title: "OpenRouter", + sourceLabel: "api", + planBadge: nil, + accountLine: nil, + infoLines: [], + metrics: [CLICardMetric(label: "Spend", remainingPercent: 10, resetText: nil)], + extraLines: [], + statusLine: nil) + + let output = CLICardsBriefRenderer.render( + rows: CLICardsBriefRenderer.makeRows(cards: [card]), + failures: [], + terminalWidth: 80, + useColor: false, + now: Date(timeIntervalSince1970: 0)) + + #expect(output.contains("OpenRouter Spend: 90% used")) + #expect(!output.contains("session limit")) + } + + @Test + func `brief rows preserve account identity`() { + let cards = [ + CLICardModel( + provider: .codex, + title: "Codex", + sourceLabel: "oauth", + planBadge: "Pro", + accountLine: "one@x.dev", + infoLines: [], + metrics: [CLICardMetric(label: "Session", remainingPercent: 80, resetText: nil)], + extraLines: [], + statusLine: nil), + CLICardModel( + provider: .codex, + title: "Codex", + sourceLabel: "oauth", + planBadge: "Pro", + accountLine: "two@x.dev", + infoLines: [], + metrics: [CLICardMetric(label: "Session", remainingPercent: 60, resetText: nil)], + extraLines: [], + statusLine: nil), + ] + + let output = CLICardsBriefRenderer.render( + rows: CLICardsBriefRenderer.makeRows(cards: cards), + failures: [], + terminalWidth: 80, + useColor: false, + now: Date(timeIntervalSince1970: 0)) + + #expect(output.contains("one@x.dev")) + #expect(output.contains("two@x.dev")) + } + + @Test + func `brief warnings wrap to terminal width`() { + let cards = ["OpenRouter", "Antigravity", "CommandCode"].map { title in + CLICardModel( + provider: .openrouter, + title: title, + sourceLabel: "api", + planBadge: nil, + accountLine: nil, + infoLines: [], + metrics: [CLICardMetric(label: "Monthly budget", remainingPercent: 5, resetText: nil)], + extraLines: [], + statusLine: nil) + } + + let output = CLICardsBriefRenderer.render( + rows: CLICardsBriefRenderer.makeRows(cards: cards), + failures: [], + terminalWidth: 40, + useColor: false, + now: Date(timeIntervalSince1970: 0)) + let warningLines = output.split(separator: "\n").filter { + $0.contains("Warnings:") || $0.contains("% used") + } + + #expect(warningLines.count > 1) + #expect(warningLines.allSatisfy { $0.count <= 40 }) + } + + @Test + func `brief summary ignores unparseable reset labels and fits narrow terminals`() { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let rows = CLICardsBriefRenderer.makeRows(cards: [ + CLICardModel( + provider: .kilo, + title: "Kilo", + sourceLabel: "api", + planBadge: nil, + accountLine: nil, + infoLines: [], + metrics: [CLICardMetric(label: "Credits", remainingPercent: 75, resetText: "Reset Unlimited")], + extraLines: [], + statusLine: nil), + CLICardModel( + provider: .codex, + title: "Codex", + sourceLabel: "oauth", + planBadge: "Pro", + accountLine: nil, + infoLines: [], + metrics: [CLICardMetric( + label: "Session", + remainingPercent: 50, + resetText: "⏳ Resets in 5h", + resetAt: now.addingTimeInterval(5 * 3600))], + extraLines: [], + statusLine: nil), + ]) + + let output = CLICardsBriefRenderer.render( + rows: rows, + failures: [], + terminalWidth: 40, + useColor: false, + now: now) + let lines = output.split(separator: "\n", omittingEmptySubsequences: false) + + #expect(output.contains("Next reset: Codex in 5h")) + #expect(!output.contains("Next reset: Kilo")) + #expect(lines.allSatisfy { $0.count <= 40 }) + } + + @Test + func `enhanced brief mode fills bars from used percentage`() { + let rows = CLICardsBriefRenderer.makeRows(cards: [ + CLICardModel( + provider: .codex, + title: "Unused", + sourceLabel: "oauth", + planBadge: nil, + accountLine: nil, + infoLines: [], + metrics: [CLICardMetric(label: "Session", remainingPercent: 100, resetText: nil)], + extraLines: [], + statusLine: nil), + CLICardModel( + provider: .openrouter, + title: "Exhausted", + sourceLabel: "api", + planBadge: nil, + accountLine: nil, + infoLines: [], + metrics: [CLICardMetric(label: "Session", remainingPercent: 0, resetText: nil)], + extraLines: [], + statusLine: nil), + ]) + let output = CLICardsBriefRenderer.render( + rows: rows, + failures: [], + terminalWidth: 80, + useColor: true, + enhanced: true, + now: Date(timeIntervalSince1970: 0)) + let plainLines = TextParsing.stripANSICodes(output).split(separator: "\n") + let unusedLine = String(plainLines.first { $0.contains("Unused") } ?? "") + let exhaustedLine = String(plainLines.first { $0.contains("Exhausted") } ?? "") + + #expect(unusedLine.contains("0%")) + #expect(unusedLine.filter { $0 == "█" }.isEmpty) + #expect(exhaustedLine.contains("100%")) + #expect(exhaustedLine.filter { $0 == "░" }.isEmpty) + } + + @Test + func `standard brief mode fills bars from used percentage`() { + let rows = CLICardsBriefRenderer.makeRows(cards: [ + CLICardModel( + provider: .codex, + title: "Unused", + sourceLabel: "oauth", + planBadge: nil, + accountLine: nil, + infoLines: [], + metrics: [CLICardMetric(label: "Session", remainingPercent: 100, resetText: nil)], + extraLines: [], + statusLine: nil), + CLICardModel( + provider: .openrouter, + title: "Exhausted", + sourceLabel: "api", + planBadge: nil, + accountLine: nil, + infoLines: [], + metrics: [CLICardMetric(label: "Session", remainingPercent: 0, resetText: nil)], + extraLines: [], + statusLine: nil), + ]) + let output = CLICardsBriefRenderer.render( + rows: rows, + failures: [], + terminalWidth: 80, + useColor: false, + enhanced: false, + now: Date(timeIntervalSince1970: 0)) + let plainLines = output.split(separator: "\n") + let unusedLine = String(plainLines.first { $0.contains("Unused") } ?? "") + let exhaustedLine = String(plainLines.first { $0.contains("Exhausted") } ?? "") + + #expect(unusedLine.contains("0%")) + #expect(unusedLine.filter { $0 == "█" }.isEmpty) + #expect(exhaustedLine.contains("100%")) + #expect(exhaustedLine.filter { $0 == "░" }.isEmpty) + } + + @Test + func `standard card grid shows empty remaining bar at exhaustion`() { + let card = CLICardModel( + provider: .openrouter, + title: "Exhausted", + sourceLabel: "api", + planBadge: nil, + accountLine: nil, + infoLines: [], + metrics: [CLICardMetric(label: "Session", remainingPercent: 0, resetText: nil)], + extraLines: [], + statusLine: nil) + let lines = CLICardsRenderer.renderCard(card, width: 48, useColor: false, enhanced: false) + let barLine = String(lines.first { $0.contains("[ ") && $0.contains("]") } ?? "") + #expect(barLine.filter { $0 == "━" }.isEmpty) + } + + @Test + func `enhanced card grid shows empty remaining bar at exhaustion`() { + let card = CLICardModel( + provider: .openrouter, + title: "Exhausted", + sourceLabel: "api", + planBadge: nil, + accountLine: nil, + infoLines: [], + metrics: [CLICardMetric(label: "Session", remainingPercent: 0, resetText: nil)], + extraLines: [], + statusLine: nil) + let lines = CLICardsRenderer.renderCard(card, width: 48, useColor: true, enhanced: true) + let plainBarLine = TextParsing.stripANSICodes( + String(lines.first { $0.contains("[ ") && $0.contains("]") } ?? "")) + #expect(plainBarLine.filter { !$0.isWhitespace && $0 != "│" && $0 != "[" && $0 != "]" }.isEmpty) + } + + @Test + func `enhanced mode uses truecolor gradient bars`() { + let card = CLICardModel( + provider: .codex, + title: "Codex", + sourceLabel: "oauth", + planBadge: "Pro", + accountLine: nil, + infoLines: [], + metrics: [CLICardMetric(label: "Session", remainingPercent: 50, resetText: nil)], + extraLines: [], + statusLine: nil) + let output = CLICardsRenderer.render( + cards: [card], + failures: [], + terminalWidth: 80, + useColor: true, + enhanced: true) + #expect(output.contains("38;2;")) + #expect(output.contains("48;2;")) + #expect(output.contains("[ ")) + } + + @Test + func `claude swap active account renders without inferred plan`() { + let snapshot = UsageSnapshot( + primary: .init(usedPercent: 25, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + secondary: nil, + tertiary: nil, + updatedAt: Date(timeIntervalSince1970: 0), + identity: ProviderIdentitySnapshot( + providerID: .claude, + accountEmail: "active@example.com", + accountOrganization: nil, + loginMethod: "claude-swap")) + let account = ProviderAccountUsageSnapshot( + id: ProviderAccountIdentity(source: "claude-swap", opaqueID: "2"), + provider: .claude, + displayLabel: "active@example.com", + isActive: true, + snapshot: snapshot, + error: nil, + sourceLabel: "claude-swap") + let card = CLICardsRenderer.makeClaudeSwapCard( + account: account, + renderOptions: CLIClaudeSwapCardsRenderOptions( + status: nil, + useColor: false, + resetStyle: .countdown, + weeklyWorkDays: nil, + now: Date(timeIntervalSince1970: 0))) + let full = CLICardsRenderer.renderCard(card, width: 38, useColor: false).joined(separator: "\n") + let brief = CLICardsBriefRenderer.render( + rows: CLICardsBriefRenderer.makeRows(cards: [card]), + failures: [], + terminalWidth: 40, + useColor: false, + now: Date(timeIntervalSince1970: 0)) + + #expect(card.planBadge == nil) + #expect(full.contains("@ active@example.com [active]")) + #expect(!full.contains("PLAN Claude-Swap")) + #expect(brief.contains("[active]")) + #expect(!brief.contains("Claude-Swap")) + #expect(full.split(separator: "\n").allSatisfy { $0.count == 38 }) + #expect(brief.split(separator: "\n", omittingEmptySubsequences: false).allSatisfy { $0.count <= 40 }) + } + + @Test + func `claude swap sentinel text survives full and brief projections`() { + let account = ProviderAccountUsageSnapshot( + id: ProviderAccountIdentity(source: "claude-swap", opaqueID: "7"), + provider: .claude, + displayLabel: "bad\u{1B}[31m\r\n" + String(repeating: "x", count: 300), + isActive: true, + snapshot: nil, + error: "API-key account; subscription usage is unavailable.", + sourceLabel: "claude-swap") + let card = CLICardsRenderer.makeClaudeSwapCard( + account: account, + renderOptions: CLIClaudeSwapCardsRenderOptions( + status: nil, + useColor: false, + resetStyle: .countdown, + weeklyWorkDays: nil, + now: Date(timeIntervalSince1970: 0))) + let full = CLICardsRenderer.renderCard(card, width: 42, useColor: false).joined(separator: "\n") + let brief = CLICardsBriefRenderer.render( + rows: CLICardsBriefRenderer.makeRows(cards: [card]), + failures: [], + terminalWidth: 80, + useColor: false, + now: Date(timeIntervalSince1970: 0)) + let briefRow = brief.split(separator: "\n").first { $0.contains("API-key") } ?? "" + + #expect(card.accountLine?.unicodeScalars.count == CLIClaudeSwapText.labelScalarLimit) + #expect(card.accountLine?.contains("\u{1B}") == false) + #expect(card.accountLine?.contains("\n") == false) + #expect(card.isActive) + #expect(full.contains("[active]")) + #expect(full.contains("API-key account;")) + #expect(full.contains("subscription usage")) + #expect(full.contains("unavailable.")) + #expect(brief.contains("Claude [active]")) + #expect(brief.contains("API-key account")) + #expect(briefRow.hasSuffix(" — │")) + #expect(card.metrics.isEmpty) + } +} diff --git a/Tests/CodexBarTests/CLIConfigCommandTests.swift b/Tests/CodexBarTests/CLIConfigCommandTests.swift index e3ef3a2c6b..7df448e0c6 100644 --- a/Tests/CodexBarTests/CLIConfigCommandTests.swift +++ b/Tests/CodexBarTests/CLIConfigCommandTests.swift @@ -1,5 +1,6 @@ import CodexBarCore import Commander +import Foundation import Testing @testable import CodexBarCLI @@ -20,6 +21,25 @@ struct CLIConfigCommandTests { #expect(CodexBarCLI._decodeFormatForTesting(from: parsed) == .json) } + @Test + func `config set api key parses zai team account options`() throws { + let parser = CommandParser(signature: CodexBarCLI._configSetAPIKeySignatureForTesting()) + let parsed = try parser.parse(arguments: [ + "--provider", "zai", + "--stdin", + "--label", "Team", + "--usage-scope", "team", + "--organization-id", "org-team", + "--workspace-id", "proj-team", + ]) + + #expect(parsed.options["provider"] == ["zai"]) + #expect(parsed.options["label"] == ["Team"]) + #expect(parsed.options["usageScope"] == ["team"]) + #expect(parsed.options["organizationId"] == ["org-team"]) + #expect(parsed.options["workspaceId"] == ["proj-team"]) + } + @Test func `config set api key stores key and enables provider`() { let config = CodexBarConfig.makeDefault() @@ -34,6 +54,46 @@ struct CLIConfigCommandTests { #expect(provider?.enabled == true) } + @Test + func `config set api key stores zai team token account`() throws { + let config = CodexBarConfig.makeDefault() + let options = try CodexBarCLI.resolveConfigAPIKeyAccountOptions( + provider: .zai, + label: "Team", + usageScope: "team", + organizationID: " org-team ", + workspaceID: " proj-team ") + let updated = CodexBarCLI.configSettingAPIKey( + config, + provider: .zai, + apiKey: "z-token", + enableProvider: true, + accountOptions: options) + let provider = try #require(updated.providerConfig(for: .zai)) + let account = try #require(provider.tokenAccounts?.accounts.first) + + #expect(provider.enabled == true) + #expect(provider.apiKey == nil) + #expect(provider.tokenAccounts?.activeIndex == 0) + #expect(account.label == "Team") + #expect(account.token == "z-token") + #expect(account.usageScope == "team") + #expect(account.organizationID == "org-team") + #expect(account.workspaceID == "proj-team") + } + + @Test + func `config set api key rejects incomplete zai team account options`() { + #expect(throws: CLIArgumentError.self) { + _ = try CodexBarCLI.resolveConfigAPIKeyAccountOptions( + provider: .zai, + label: "Team", + usageScope: "team", + organizationID: "org-team", + workspaceID: nil) + } + } + @Test func `config provider toggle parses provider and json flags`() throws { let parser = CommandParser(signature: CodexBarCLI._configProviderToggleSignatureForTesting()) @@ -79,6 +139,9 @@ struct CLIConfigCommandTests { #expect(ProviderConfigEnvironment.supportsAPIKeyOverride(for: .groq)) #expect(ProviderConfigEnvironment.supportsAPIKeyOverride(for: .llmproxy)) #expect(ProviderConfigEnvironment.supportsAPIKeyOverride(for: .openai)) + #expect(ProviderConfigEnvironment.supportsAPIKeyOverride(for: .amp)) + #expect(ProviderConfigEnvironment.supportsAPIKeyOverride(for: .kimi)) + #expect(ProviderConfigEnvironment.supportsAPIKeyOverride(for: .factory)) #expect(!ProviderConfigEnvironment.supportsAPIKeyOverride(for: .bedrock)) #expect(!ProviderConfigEnvironment.supportsAPIKeyOverride(for: .deepseek)) #expect(!ProviderConfigEnvironment.supportsAPIKeyOverride(for: .cursor)) @@ -116,6 +179,157 @@ struct CLIConfigCommandTests { #expect(help.contains("config enable --provider ")) #expect(help.contains("config disable --provider ")) #expect(help.contains("--stdin")) + #expect(help.contains("--usage-scope team")) #expect(help.contains("enables that provider by default")) + #expect(help.contains("--show-secrets")) + } + + @Test + func `config dump parses show-secrets flag`() throws { + let parser = CommandParser(signature: CodexBarCLI._configDumpSignatureForTesting()) + let parsed = try parser.parse(arguments: ["--show-secrets", "--pretty"]) + + #expect(parsed.flags.contains("showSecrets")) + #expect(parsed.flags.contains("pretty")) + } + + @Test + func `config dump redacts credentials by default`() { + let rawAccount = ProviderTokenAccount( + id: UUID(), + label: "Team", + token: "cb_test_token_123", + addedAt: 1000, + lastUsed: nil, + usageScope: "team", + organizationID: "org-1", + workspaceID: "proj-1") + let provider = ProviderConfig( + id: .zai, + apiKey: "cb_test_api_key_456", + secretKey: "cb_test_secret_key_789", + cookieHeader: "cb_test_cookie_abc", + tokenAccounts: ProviderTokenAccountData(version: 1, accounts: [rawAccount], activeIndex: 0)) + let config = CodexBarConfig(providers: [provider]) + + let redacted = config.sanitizedForDump(showSecrets: false) + let redactedProvider = redacted.providerConfig(for: .zai) + + #expect(redactedProvider?.apiKey == "[REDACTED]") + #expect(redactedProvider?.secretKey == "[REDACTED]") + #expect(redactedProvider?.cookieHeader == "[REDACTED]") + #expect(redactedProvider?.tokenAccounts?.accounts.first?.token == "[REDACTED]") + } + + @Test + func `config dump reveals credentials when show-secrets is true`() { + let rawAccount = ProviderTokenAccount( + id: UUID(), + label: "Team", + token: "cb_test_token_123", + addedAt: 1000, + lastUsed: nil, + usageScope: "team", + organizationID: "org-1", + workspaceID: "proj-1") + let provider = ProviderConfig( + id: .zai, + apiKey: "cb_test_api_key_456", + secretKey: "cb_test_secret_key_789", + cookieHeader: "cb_test_cookie_abc", + tokenAccounts: ProviderTokenAccountData(version: 1, accounts: [rawAccount], activeIndex: 0)) + let config = CodexBarConfig(providers: [provider]) + + let unredacted = config.sanitizedForDump(showSecrets: true) + let unredactedProvider = unredacted.providerConfig(for: .zai) + + #expect(unredactedProvider?.apiKey == "cb_test_api_key_456") + #expect(unredactedProvider?.secretKey == "cb_test_secret_key_789") + #expect(unredactedProvider?.cookieHeader == "cb_test_cookie_abc") + #expect(unredactedProvider?.tokenAccounts?.accounts.first?.token == "cb_test_token_123") + } + + @Test + func `config dump command redacts fixture secrets unless explicitly requested`() throws { + let fixtureDirectory = FileManager.default.temporaryDirectory + .appendingPathComponent("codexbar-config-dump-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: fixtureDirectory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: fixtureDirectory) } + + let secrets = [ + "fixture-api-key-value", + "fixture-secret-key-value", + "fixture-cookie-value", + "fixture-token-account-value", + ] + let account = ProviderTokenAccount( + id: UUID(), + label: "Fixture account", + token: secrets[3], + addedAt: 1000, + lastUsed: nil, + usageScope: "team", + organizationID: "fixture-org", + workspaceID: "fixture-workspace") + let config = CodexBarConfig(providers: [ProviderConfig( + id: .zai, + enabled: true, + apiKey: secrets[0], + secretKey: secrets[1], + cookieHeader: secrets[2], + tokenAccounts: ProviderTokenAccountData(version: 1, accounts: [account], activeIndex: 0))]) + let configURL = fixtureDirectory.appendingPathComponent("config.json") + try CodexBarConfigStore(fileURL: configURL).save(config) + + let redactedData = try Self.runConfigDump(configURL: configURL, showSecrets: false) + let redactedJSON = try JSONSerialization.jsonObject(with: redactedData) + let redactedOutput = try #require(String(data: redactedData, encoding: .utf8)) + #expect(redactedJSON is [String: Any]) + #expect(redactedOutput.contains("[REDACTED]")) + for secret in secrets { + #expect(!redactedOutput.contains(secret)) + } + + let rawData = try Self.runConfigDump(configURL: configURL, showSecrets: true) + let rawJSON = try JSONSerialization.jsonObject(with: rawData) + let rawOutput = try #require(String(data: rawData, encoding: .utf8)) + #expect(rawJSON is [String: Any]) + for secret in secrets { + #expect(rawOutput.contains(secret)) + } + } + + private static func runConfigDump(configURL: URL, showSecrets: Bool) throws -> Data { + let process = Process() + process.executableURL = Self.cliExecutableURL + process.arguments = ["config", "dump"] + (showSecrets ? ["--show-secrets"] : []) + process.environment = ProcessInfo.processInfo.environment.merging([ + CodexBarConfigStore.pathEnvironmentKey: configURL.path, + ]) { _, fixturePath in fixturePath } + + let stdout = Pipe() + let stderr = Pipe() + process.standardOutput = stdout + process.standardError = stderr + try process.run() + process.waitUntilExit() + + let output = stdout.fileHandleForReading.readDataToEndOfFile() + let errorOutput = stderr.fileHandleForReading.readDataToEndOfFile() + guard process.terminationStatus == 0 else { + let message = String(data: errorOutput, encoding: .utf8) ?? "CodexBarCLI exited without an error message" + throw NSError(domain: "CLIConfigCommandTests", code: Int(process.terminationStatus), userInfo: [ + NSLocalizedDescriptionKey: message, + ]) + } + return output + } + + private static var cliExecutableURL: URL { + URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + .appendingPathComponent(".build/debug/CodexBarCLI") } } diff --git a/Tests/CodexBarTests/CLICookieRefreshTests.swift b/Tests/CodexBarTests/CLICookieRefreshTests.swift new file mode 100644 index 0000000000..606a7cf4c2 --- /dev/null +++ b/Tests/CodexBarTests/CLICookieRefreshTests.swift @@ -0,0 +1,318 @@ +import Commander +import Foundation +import Testing +@testable import CodexBarCLI +@testable import CodexBarCore + +@Suite(.serialized) +struct CLICookieRefreshTests { + @Test + func `cookie refresh parses explicit keychain acknowledgement`() throws { + let parser = CommandParser(signature: CommandSignature.describe(CookieOptions())) + let parsed = try parser.parse(arguments: [ + "--provider", "opencodego", "--allow-keychain-prompt", "--json", + ]) + + #expect(parsed.options["provider"] == ["opencodego"]) + #expect(parsed.flags.contains("allowKeychainPrompt")) + #expect(parsed.flags.contains("jsonShortcut")) + } + + #if os(macOS) + @Test + func `all provider selection is descriptor driven`() throws { + let targets = try CodexBarCLI.cookieRefreshTargets(rawProvider: nil, refreshAll: true) + + #expect(targets.count > 2) + #expect(targets.contains(where: { $0.id == .claude })) + #expect(targets.contains(where: { $0.id == .opencode })) + #expect(targets.allSatisfy { $0.metadata.browserCookieOrder != nil }) + #expect(targets.allSatisfy { $0.fetchPlan.sourceModes.contains(.web) }) + } + + @Test + func `prompt capable refresh is gated before provider work`() async { + var operationCalled = false + let descriptor = ProviderDescriptorRegistry.descriptor(for: .opencode) + + let results = await CodexBarCLI.performCookieRefreshes( + targets: [descriptor], + allowKeychainPrompt: false) + { _ in + operationCalled = true + return CookieRefreshResult(provider: "opencode", status: .refreshed, message: "unexpected") + } + + #expect(operationCalled == false) + #expect(results.count == 1) + #expect(results[0].status == .blocked) + #expect(results[0].message.contains("--allow-keychain-prompt")) + } + + @Test + func `preflight skip does not require keychain acknowledgement`() async { + var operationCalled = false + let descriptor = ProviderDescriptorRegistry.descriptor(for: .opencode) + + let results = await CodexBarCLI.performCookieRefreshes( + targets: [descriptor], + allowKeychainPrompt: false, + preflight: { descriptor in + CookieRefreshResult(provider: descriptor.cli.name, status: .skipped, message: "manual") + }, + operation: { _ in + operationCalled = true + return CookieRefreshResult(provider: "opencode", status: .refreshed, message: "unexpected") + }) + + #expect(operationCalled == false) + #expect(results.count == 1) + #expect(results[0].status == .skipped) + } + + @Test + func `failed refresh preserves default cookie and unrelated account scopes`() async { + let provider = UsageProvider.opencode + let accountScope = CookieHeaderCache.Scope.managedAccount(UUID()) + let service = "com.steipete.codexbar.tests.cookie-refresh.\(UUID().uuidString)" + + await KeychainCacheStore.withServiceOverrideForTesting(service) { + await KeychainCacheStore.withImplicitTestStoreForTesting { + CookieHeaderCache.store( + provider: provider, + cookieHeader: "default-test-cookie", + sourceLabel: "Test default") + CookieHeaderCache.store( + provider: provider, + scope: accountScope, + cookieHeader: "account-test-cookie", + sourceLabel: "Test account") + + let result = await CodexBarCLI.withCookieRefreshCacheSuppressed( + provider: provider, + providerName: "opencode") + { + #expect(CookieHeaderCache.load(provider: provider) == nil) + #expect(CookieHeaderCache.loadSerialized(provider: provider) == nil) + #expect(CookieHeaderCache.load(provider: provider, scope: accountScope) == nil) + CookieHeaderCache.store( + provider: provider, + cookieHeader: "unvalidated-test-cookie", + sourceLabel: "Test unvalidated") + #expect(CookieHeaderCache.load(provider: provider)?.cookieHeader == "unvalidated-test-cookie") + return CookieRefreshResult(provider: "opencode", status: .failed, message: "test failure") + } + + #expect(result.status == .failed) + #expect(CookieHeaderCache.load(provider: provider)?.cookieHeader == "default-test-cookie") + #expect(CookieHeaderCache.load(provider: provider, scope: accountScope)?.sourceLabel == "Test account") + } + } + } + + @Test + func `successful refresh keeps replacement cookie`() async { + let provider = UsageProvider.opencode + let service = "com.steipete.codexbar.tests.cookie-refresh.\(UUID().uuidString)" + + await KeychainCacheStore.withServiceOverrideForTesting(service) { + await KeychainCacheStore.withImplicitTestStoreForTesting { + let stored = CookieHeaderCache.storeResult( + provider: provider, + cookieHeader: "old-test-cookie", + sourceLabel: "Test old", + authenticationFailurePolicy: .stopFallback) + #expect(stored) + + let result = await CodexBarCLI.withCookieRefreshCacheSuppressed( + provider: provider, + providerName: "opencode") + { + let observation = CookieHeaderCache.observeForConditionalMutation(provider: provider) + #expect(observation.entry == nil) + let stored = CookieHeaderCache.storeIfObservationCurrent( + provider: provider, + expected: observation, + cookieHeader: "new-test-cookie", + sourceLabel: "Test new") + #expect(stored) + return CookieRefreshResult(provider: "opencode", status: .refreshed, message: "ok") + } + + #expect(result.status == .refreshed) + #expect(CookieHeaderCache.load(provider: provider)?.cookieHeader == "new-test-cookie") + } + } + } + + @Test + func `successful provider result without a staged cookie fails safely`() async { + let provider = UsageProvider.opencode + let service = "com.steipete.codexbar.tests.cookie-refresh.\(UUID().uuidString)" + + await KeychainCacheStore.withServiceOverrideForTesting(service) { + await KeychainCacheStore.withImplicitTestStoreForTesting { + CookieHeaderCache.store( + provider: provider, + cookieHeader: "old-test-cookie", + sourceLabel: "Test old") + + let result = await CodexBarCLI.withCookieRefreshCacheSuppressed( + provider: provider, + providerName: "opencode") + { + CookieRefreshResult(provider: "opencode", status: .refreshed, message: "unexpected") + } + + #expect(result.status == .failed) + #expect(CookieHeaderCache.load(provider: provider)?.cookieHeader == "old-test-cookie") + } + } + } + + @Test + func `multiple staged replacements fail before changing persisted cookies`() async { + let provider = UsageProvider.opencode + let accountScope = CookieHeaderCache.Scope.managedAccount(UUID()) + let service = "com.steipete.codexbar.tests.cookie-refresh.\(UUID().uuidString)" + + await KeychainCacheStore.withServiceOverrideForTesting(service) { + await KeychainCacheStore.withImplicitTestStoreForTesting { + CookieHeaderCache.store( + provider: provider, + cookieHeader: "old-default-cookie", + sourceLabel: "Test old default") + CookieHeaderCache.store( + provider: provider, + scope: accountScope, + cookieHeader: "old-account-cookie", + sourceLabel: "Test old account") + + let result = await CodexBarCLI.withCookieRefreshCacheSuppressed( + provider: provider, + providerName: "opencode") + { + CookieHeaderCache.store( + provider: provider, + cookieHeader: "new-default-cookie", + sourceLabel: "Test new default") + CookieHeaderCache.store( + provider: provider, + scope: accountScope, + cookieHeader: "new-account-cookie", + sourceLabel: "Test new account") + return CookieRefreshResult(provider: "opencode", status: .refreshed, message: "unexpected") + } + + #expect(result.status == .failed) + #expect(CookieHeaderCache.load(provider: provider)?.cookieHeader == "old-default-cookie") + #expect(CookieHeaderCache.load(provider: provider, scope: accountScope)?.cookieHeader == + "old-account-cookie") + } + } + } + + @Test + func `commit detaches the gate before later writes`() { + let provider = UsageProvider.opencode + let service = "com.steipete.codexbar.tests.cookie-refresh.\(UUID().uuidString)" + + KeychainCacheStore.withServiceOverrideForTesting(service) { + KeychainCacheStore.withImplicitTestStoreForTesting { + guard let gate = CookieHeaderCache.beginRefreshReadSuppression(provider: provider) else { + Issue.record("Expected refresh gate") + return + } + CookieHeaderCache.store( + provider: provider, + cookieHeader: "committed-cookie", + sourceLabel: "Test committed") + #expect(CookieHeaderCache.commitRefreshReadSuppression(gate).committedCount == 1) + + CookieHeaderCache.store( + provider: provider, + cookieHeader: "later-cookie", + sourceLabel: "Test later") + CookieHeaderCache.endRefreshReadSuppression(gate) + #expect(CookieHeaderCache.load(provider: provider)?.cookieHeader == "later-cookie") + } + } + } + + @Test + func `explicit acknowledgement is user initiated and is the only cooldown bypass`() async { + BrowserCookieAccessGate.resetForTesting() + defer { BrowserCookieAccessGate.resetForTesting() } + let start = Date(timeIntervalSince1970: 2000) + BrowserCookieAccessGate.recordDenied(for: .chrome, now: start) + let descriptor = ProviderDescriptorRegistry.descriptor(for: .opencode) + var unacknowledgedOperationCalled = false + + var observedInteraction: ProviderInteraction? + var explicitRetryAllowed = false + await KeychainAccessGate.withTaskOverrideForTesting(false) { + await KeychainAccessPreflight.withCheckGenericPasswordOverrideForTesting { _, _ in .allowed } operation: { + _ = await CodexBarCLI.performCookieRefreshes( + targets: [descriptor], + allowKeychainPrompt: false) + { _ in + unacknowledgedOperationCalled = true + return CookieRefreshResult(provider: "opencode", status: .refreshed, message: "unexpected") + } + + _ = await CodexBarCLI.performCookieRefreshes( + targets: [descriptor], + allowKeychainPrompt: true) + { _ in + observedInteraction = ProviderInteractionContext.current + explicitRetryAllowed = BrowserCookieAccessGate.shouldAttempt( + .chrome, + now: start.addingTimeInterval(1)) + return CookieRefreshResult(provider: "opencode", status: .refreshed, message: "ok") + } + } + } + + #expect(unacknowledgedOperationCalled == false) + #expect(observedInteraction == .userInitiated) + #expect(explicitRetryAllowed) + } + + @Test + func `raw provider failures cannot leak cookie values`() { + KeychainAccessGate.withTaskOverrideForTesting(false) { + let privateMarker = "opaque-test-marker" + let error = NSError( + domain: privateMarker, + code: 1, + userInfo: [NSLocalizedDescriptionKey: privateMarker]) + + let result = CodexBarCLI.cookieRefreshFailure(provider: .opencode, error: error) + let text = CodexBarCLI.cookieRefreshText([result]) + let encoded = try? JSONEncoder().encode(result) + let json = encoded.flatMap { String(data: $0, encoding: .utf8) } ?? "" + + #expect(!text.contains(privateMarker)) + #expect(!json.contains(privateMarker)) + #expect(text.contains("six-hour denial cooldown")) + } + } + + @Test + func `keychain failure reuses actionable denial hint`() { + BrowserCookieAccessGate.resetForTesting() + defer { BrowserCookieAccessGate.resetForTesting() } + BrowserCookieAccessGate.recordDenied(for: .chrome) + + KeychainAccessGate.withTaskOverrideForTesting(false) { + let result = CodexBarCLI.cookieRefreshFailure( + provider: .opencode, + error: NSError(domain: "opaque-test-marker", code: 1)) + + #expect(result.message == + "Chrome cookie decryption was declined in Keychain; retry with --allow-keychain-prompt.") + #expect(!result.message.contains("opaque-test-marker")) + } + } + #endif +} diff --git a/Tests/CodexBarTests/CLICostTests.swift b/Tests/CodexBarTests/CLICostTests.swift index 1b223bce56..6e0bfc7e87 100644 --- a/Tests/CodexBarTests/CLICostTests.swift +++ b/Tests/CodexBarTests/CLICostTests.swift @@ -38,6 +38,68 @@ struct CLICostTests { #expect(output.contains("Claude Code /status")) } + @Test + func `renders codex project grouped cost text`() { + let snap = CostUsageTokenSnapshot( + sessionTokens: 1200, + sessionCostUSD: 1.25, + last30DaysTokens: 9000, + last30DaysCostUSD: 9.99, + historyDays: 30, + daily: [], + projects: [ + CostUsageProjectBreakdown( + name: "client-a", + path: "/work/client-a", + totalTokens: 7000, + totalCostUSD: 7.5, + daily: [], + modelBreakdowns: nil, + sources: [ + CostUsageProjectSourceBreakdown( + name: "client-a", + path: "/work/client-a", + totalTokens: 5000, + totalCostUSD: 5.25, + daily: [], + modelBreakdowns: nil), + CostUsageProjectSourceBreakdown( + name: "client-a", + path: "/Users/test/.codex/worktrees/abcd/client-a", + totalTokens: 2000, + totalCostUSD: 2.25, + daily: [], + modelBreakdowns: nil), + ]), + CostUsageProjectBreakdown( + name: CostUsageProjectBreakdown.unknownProjectName, + path: nil, + totalTokens: 2000, + totalCostUSD: 2.49, + daily: [], + modelBreakdowns: nil), + ], + updatedAt: Date(timeIntervalSince1970: 0)) + + let output = CodexBarCLI.renderCostText( + provider: .codex, + snapshot: snap, + groupBy: .project, + useColor: false) + .replacingOccurrences(of: "\u{00A0}", with: " ") + .replacingOccurrences(of: "$ ", with: "$") + + #expect(output.contains("Codex API-equivalent estimate (not billed)")) + #expect(output.contains("Projects (Last 30 days):")) + #expect(output.contains("client-a: $7.50 · 7K tokens")) + #expect(output.contains("/work/client-a")) + #expect(output.contains(" - client-a: $5.25 · 5K tokens")) + #expect(output.contains(" - client-a: $2.25 · 2K tokens")) + #expect(output.contains("/Users/test/.codex/worktrees/abcd/client-a")) + #expect(output.contains("Unknown project: $2.49 · 2K tokens")) + #expect(output.contains("Not a subscription bill or plan value · local usage × public API prices")) + } + @Test func `encodes cost payload JSON`() throws { let payload = CostPayload( @@ -95,6 +157,89 @@ struct CLICostTests { #expect(json.contains("1700000000")) } + @Test + func `codex cost payload includes project rollups`() throws { + let snapshot = CostUsageTokenSnapshot( + sessionTokens: 10, + sessionCostUSD: 0.01, + last30DaysTokens: 40, + last30DaysCostUSD: 0.04, + daily: [ + CostUsageDailyReport.Entry( + date: "2026-04-02", + inputTokens: 30, + outputTokens: 10, + totalTokens: 40, + costUSD: 0.04, + modelsUsed: ["gpt-5.4"], + modelBreakdowns: [ + CostUsageDailyReport.ModelBreakdown( + modelName: "gpt-5.4", + costUSD: 0.04, + totalTokens: 40), + ]), + ], + projects: [ + CostUsageProjectBreakdown( + name: "client-a", + path: "/work/client-a", + totalTokens: 40, + totalCostUSD: 0.04, + daily: [ + CostUsageDailyReport.Entry( + date: "2026-04-02", + inputTokens: 30, + outputTokens: 10, + totalTokens: 40, + costUSD: 0.04, + modelsUsed: ["gpt-5.4"], + modelBreakdowns: nil), + ], + modelBreakdowns: [ + CostUsageDailyReport.ModelBreakdown( + modelName: "gpt-5.4", + costUSD: 0.04, + totalTokens: 40), + ], + sources: [ + CostUsageProjectSourceBreakdown( + name: "client-a", + path: "/work/client-a", + totalTokens: 40, + totalCostUSD: 0.04, + daily: [ + CostUsageDailyReport.Entry( + date: "2026-04-02", + inputTokens: 30, + outputTokens: 10, + totalTokens: 40, + costUSD: 0.04, + modelsUsed: ["gpt-5.4"], + modelBreakdowns: nil), + ], + modelBreakdowns: nil), + ]), + ], + updatedAt: Date(timeIntervalSince1970: 1_700_000_000)) + let payload = CodexBarCLI.makeCostPayload(provider: .codex, snapshot: snapshot, error: nil) + + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .secondsSince1970 + let data = try encoder.encode(payload) + guard let json = String(data: data, encoding: .utf8) else { + Issue.record("Failed to decode cost payload JSON") + return + } + + #expect(json.contains("\"projects\"")) + #expect(json.contains("\"sources\"")) + #expect(json.contains("\"name\":\"client-a\"")) + #expect(json.contains("/work/client-a") || json.contains("\\/work\\/client-a")) + #expect(json.contains("\"totalCost\":0.04")) + #expect(json.contains("\"daily\"")) + #expect(json.contains("\"gpt-5.4\"")) + } + @Test func `encodes exact codex model I ds and zero cost breakdowns`() throws { let payload = CostPayload( @@ -152,4 +297,54 @@ struct CLICostTests { #expect(hint.contains("Estimated")) #expect(UsageFormatter.costEstimateHint(provider: .claude).contains("cache read/write tokens")) } + + @Test + func `cursor cookie source off produces a failed JSON payload`() throws { + let settings = ProviderSettingsSnapshot.CursorProviderSettings( + cookieSource: .off, + manualCookieHeader: nil) + let error = try #require(CodexBarCLI.cursorCostAvailabilityError(.cursor, settings: settings)) + let payload = CodexBarCLI.makeCostPayload(provider: .cursor, snapshot: nil, error: error) + let json = try #require(CodexBarCLI.encodeJSON([payload], pretty: false)) + + #expect(CodexBarCLI.mapError(error) == .failure) + #expect(json.contains("\"provider\":\"cursor\"")) + #expect(json.contains("\"code\":1")) + #expect(json.contains("cookie source is set to Off")) + #expect(CodexBarCLI.cursorCostAvailabilityError(.cursor, settings: nil) == nil) + #expect(CodexBarCLI.cursorCostAvailabilityError(.codex, settings: settings) == nil) + } + + @Test + func `cursor manual cookie source rejects an empty header`() throws { + let settings = ProviderSettingsSnapshot.CursorProviderSettings( + cookieSource: .manual, + manualCookieHeader: " ") + let error = try #require(CodexBarCLI.cursorCostAvailabilityError(.cursor, settings: settings)) + + #expect(CodexBarCLI.mapError(error) == .failure) + #expect(error.localizedDescription.contains("non-empty Manual cookie header")) + #expect(CodexBarCLI.cursorCostHeaderOverride(.cursor, settings: settings) == nil) + } + + @Test + func `cursor settings resolution errors fail closed`() throws { + let resolutionError = CursorCostSettingsTestError() + let error = try #require(CodexBarCLI.cursorCostAvailabilityError( + .cursor, + settings: nil, + resolutionError: resolutionError)) + + #expect(error.localizedDescription == resolutionError.localizedDescription) + #expect(CodexBarCLI.cursorCostAvailabilityError( + .codex, + settings: nil, + resolutionError: resolutionError) == nil) + } +} + +private struct CursorCostSettingsTestError: LocalizedError { + var errorDescription: String? { + "Cursor settings resolution failed." + } } diff --git a/Tests/CodexBarTests/CLIDiagnoseCommandTests.swift b/Tests/CodexBarTests/CLIDiagnoseCommandTests.swift index e2a27a7dd7..fd10e40f28 100644 --- a/Tests/CodexBarTests/CLIDiagnoseCommandTests.swift +++ b/Tests/CodexBarTests/CLIDiagnoseCommandTests.swift @@ -1,4 +1,5 @@ import CodexBarCore +import Foundation import Testing @testable import CodexBarCLI @@ -9,10 +10,25 @@ struct CLIDiagnoseCommandTests { #expect(help.contains("codexbar diagnose --provider --format json")) #expect(help.contains("codexbar diagnose --provider all --format json")) + #expect(help.contains("--redact")) + #expect(help.contains("--output ")) #expect(help.contains("safe JSON export")) #expect(help.contains("raw API tokens")) } + @Test + func `diagnose output writer creates parent directories`() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("CodexBarDiagnoseTests-\(UUID().uuidString)") + defer { try? FileManager.default.removeItem(at: root) } + + let output = root.appendingPathComponent("nested/diagnostic.json") + try CodexBarCLI.writeDiagnosticExport(#"{"provider":"minimax"}"#, to: output.path) + + let contents = try String(contentsOf: output, encoding: .utf8) + #expect(contents == #"{"provider":"minimax"}"#) + } + private func makeSettingsWithMiniMaxCookie(_ manualCookieHeader: String) -> ProviderSettingsSnapshot { ProviderSettingsSnapshot( debugMenuEnabled: false, @@ -86,6 +102,32 @@ struct CLIDiagnoseCommandTests { #expect(summary.modes == ["api"]) } + @Test + func `generic diagnose auth summary detects Chutes environment credentials`() { + let summary = CodexBarCLI._diagnosticAuthSummaryForTesting( + provider: .chutes, + account: nil, + config: nil, + environment: [ChutesSettingsReader.apiKeyEnvironmentKey: "chutes-test"], + settings: nil) + + #expect(summary.configured) + #expect(summary.modes == ["api"]) + } + + @Test + func `generic diagnose auth summary detects Neuralwatt environment credentials`() { + let summary = CodexBarCLI._diagnosticAuthSummaryForTesting( + provider: .neuralwatt, + account: nil, + config: nil, + environment: [NeuralWattSettingsReader.apiKeyEnvironmentKey: "sk-test"], + settings: nil) + + #expect(summary.configured) + #expect(summary.modes == ["api"]) + } + @Test func `generic diagnose auth summary requires complete Bedrock credentials`() { let partial = CodexBarCLI._diagnosticAuthSummaryForTesting( diff --git a/Tests/CodexBarTests/CLIEntryTests.swift b/Tests/CodexBarTests/CLIEntryTests.swift index 59ee685c66..6fa60cdd26 100644 --- a/Tests/CodexBarTests/CLIEntryTests.swift +++ b/Tests/CodexBarTests/CLIEntryTests.swift @@ -92,6 +92,45 @@ final class CLIEntryTests: XCTestCase { try self.expectAdjacentVersionFile(raw: "version-3.2.3\n", expected: "version-3.2.3") } + func test_cliVersionFindsAdjacentVersionWhenInvokedViaRelativePathAndSymlink() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("codexbar-cli-version-invocation-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + + let installURL = root.appendingPathComponent("install/bin", isDirectory: true) + let linksURL = root.appendingPathComponent("links", isDirectory: true) + let workingDirectoryURL = root.appendingPathComponent("work", isDirectory: true) + try FileManager.default.createDirectory(at: installURL, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: linksURL, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: workingDirectoryURL, withIntermediateDirectories: true) + + let executableURL = installURL.appendingPathComponent("CodexBarCLI") + try FileManager.default.copyItem(at: Self.cliExecutableURL, to: executableURL) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: executableURL.path) + try "8.7.6\n".write( + to: installURL.appendingPathComponent("VERSION"), + atomically: false, + encoding: .utf8) + + XCTAssertEqual( + try Self.runVersionCommand( + executableURL: executableURL, + argv0: "install/bin/CodexBarCLI", + currentDirectoryURL: workingDirectoryURL), + "CodexBar 8.7.6\n") + + let symlinkURL = linksURL.appendingPathComponent("codexbar") + try FileManager.default.createSymbolicLink( + atPath: symlinkURL.path, + withDestinationPath: "../install/bin/CodexBarCLI") + XCTAssertEqual( + try Self.runVersionCommand( + executableURL: symlinkURL, + argv0: "codexbar", + currentDirectoryURL: workingDirectoryURL), + "CodexBar 8.7.6\n") + } + func test_cliVersionPrefersAdjacentVersionOverStandaloneBundleName() throws { let root = FileManager.default.temporaryDirectory .appendingPathComponent("codexbar-cli-version-bundle-\(UUID().uuidString)", isDirectory: true) @@ -130,6 +169,54 @@ final class CLIEntryTests: XCTestCase { XCTAssertEqual(CodexBarCLI.currentVersion(bundleVersion: nil, executablePath: helperURL.path), expected) } + private static func runVersionCommand( + executableURL: URL, + argv0: String, + currentDirectoryURL: URL) throws -> String + { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/bin/zsh") + process.arguments = [ + "-c", + "exec -a \"$1\" \"$2\" --version", + "codexbar-version-test", + argv0, + executableURL.path, + ] + process.currentDirectoryURL = currentDirectoryURL + + let stdout = Pipe() + let stderr = Pipe() + process.standardOutput = stdout + process.standardError = stderr + try process.run() + process.waitUntilExit() + + let output = stdout.fileHandleForReading.readDataToEndOfFile() + let errorOutput = stderr.fileHandleForReading.readDataToEndOfFile() + guard process.terminationStatus == 0 else { + let message = String(bytes: errorOutput, encoding: .utf8) + ?? "CodexBarCLI exited without an error message" + throw NSError(domain: "CLIEntryTests", code: Int(process.terminationStatus), userInfo: [ + NSLocalizedDescriptionKey: message, + ]) + } + guard let text = String(bytes: output, encoding: .utf8) else { + throw NSError(domain: "CLIEntryTests", code: -1, userInfo: [ + NSLocalizedDescriptionKey: "CodexBarCLI produced non-UTF-8 output", + ]) + } + return text + } + + private static var cliExecutableURL: URL { + URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + .appendingPathComponent(".build/debug/CodexBarCLI") + } + func test_renderOpenAIWebDashboardTextIncludesSummary() { let event = CreditEvent( date: Date(timeIntervalSince1970: 1_700_000_000), @@ -159,9 +246,33 @@ final class CLIEntryTests: XCTestCase { func test_mapsErrorsToExitCodes() { XCTAssertEqual(CodexBarCLI.mapError(CodexStatusProbeError.codexNotInstalled), ExitCode(2)) XCTAssertEqual(CodexBarCLI.mapError(CodexStatusProbeError.timedOut), ExitCode(4)) + XCTAssertEqual(CodexBarCLI.mapError(ClaudeWebFetchStrategyError.timedOut(seconds: 1)), ExitCode(4)) XCTAssertEqual(CodexBarCLI.mapError(UsageError.noRateLimitsFound), ExitCode(3)) } + func test_antigravityPlanDebugKeepsOneShotHelperAliveUntilDebugFetch() { + XCTAssertTrue(CodexBarCLI.holdsAntigravityCLISessionForPlanDebug( + provider: .antigravity, + planDebugEnabled: true, + jsonOnly: false, + persistsCLISessions: false)) + XCTAssertFalse(CodexBarCLI.holdsAntigravityCLISessionForPlanDebug( + provider: .codex, + planDebugEnabled: true, + jsonOnly: false, + persistsCLISessions: false)) + XCTAssertFalse(CodexBarCLI.holdsAntigravityCLISessionForPlanDebug( + provider: .antigravity, + planDebugEnabled: true, + jsonOnly: true, + persistsCLISessions: false)) + XCTAssertFalse(CodexBarCLI.holdsAntigravityCLISessionForPlanDebug( + provider: .antigravity, + planDebugEnabled: true, + jsonOnly: false, + persistsCLISessions: true)) + } + func test_missingCodexBinaryErrorPayloadUsesInstallGuidance() { let payload = CodexBarCLI.makeErrorPayload(CodexStatusProbeError.codexNotInstalled, kind: .provider) @@ -204,13 +315,20 @@ final class CLIEntryTests: XCTestCase { let signature = CodexBarCLI._usageSignatureForTesting() let parser = CommandParser(signature: signature) let parsed = try parser.parse(arguments: ["--web-timeout", "45", "--source", "oauth"]) - XCTAssertEqual(CodexBarCLI._decodeWebTimeoutForTesting(from: parsed), 45) + XCTAssertEqual(try CodexBarCLI._decodeWebTimeoutForTesting(from: parsed), 45) XCTAssertEqual(CodexBarCLI._decodeSourceModeForTesting(from: parsed), .oauth) let parsedWeb = try parser.parse(arguments: ["--web"]) XCTAssertEqual(CodexBarCLI._decodeSourceModeForTesting(from: parsedWeb), .web) } + func test_rejectsUnsafeWebTimeoutOptions() throws { + for value in ["-1", "nan", "inf", "1e300"] { + let parsed = ParsedValues(positional: [], options: ["webTimeout": [value]], flags: []) + XCTAssertThrowsError(try CodexBarCLI._decodeWebTimeoutForTesting(from: parsed)) + } + } + func test_shouldUseColorRespectsFormatAndFlags() { XCTAssertFalse(CodexBarCLI.shouldUseColor(noColor: true, format: .text)) XCTAssertFalse(CodexBarCLI.shouldUseColor(noColor: false, format: .json)) @@ -276,21 +394,218 @@ final class CLIEntryTests: XCTestCase { attempts: attempts)) } - func test_sourceModeRequiresWebSupportIsProviderAware() { + func test_sourceModeRequiresWebSupportIsProviderAware() throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("mimo-cli-source-mode-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + let validMiMoCache = directory.appendingPathComponent("valid.json") + let invalidMiMoCache = directory.appendingPathComponent("invalid.json") + let payload: [String: Any] = [ + "sessions_scanned": 1, + "windows": [ + "today": [:], + "week": [:], + "all_time": [:], + ], + ] + try JSONSerialization.data(withJSONObject: payload).write(to: validMiMoCache) + try Data("{}".utf8).write(to: invalidMiMoCache) + XCTAssertTrue(CodexBarCLI.sourceModeRequiresWebSupport(.web, provider: .kilo)) - XCTAssertTrue(CodexBarCLI.sourceModeRequiresWebSupport(.auto, provider: .codex)) + XCTAssertFalse(CodexBarCLI.sourceModeRequiresWebSupport(.auto, provider: .codex)) + XCTAssertFalse(CodexBarCLI.sourceModeRequiresWebSupport(.auto, provider: .claude)) + XCTAssertTrue(CodexBarCLI.sourceModeRequiresWebSupport(.web, provider: .claude)) XCTAssertFalse(CodexBarCLI.sourceModeRequiresWebSupport(.auto, provider: .kilo)) XCTAssertFalse(CodexBarCLI.sourceModeRequiresWebSupport(.auto, provider: .grok)) XCTAssertFalse(CodexBarCLI.sourceModeRequiresWebSupport(.web, provider: .grok)) + XCTAssertFalse(CodexBarCLI.sourceModeRequiresWebSupport(.auto, provider: .amp)) XCTAssertFalse(CodexBarCLI.sourceModeRequiresWebSupport(.api, provider: .kilo)) + XCTAssertFalse(CodexBarCLI.sourceModeRequiresWebSupport( + .auto, + provider: .opencodego, + settings: ProviderSettingsSnapshot.make( + opencodego: .init( + cookieSource: .manual, + manualCookieHeader: "auth=manual", + workspaceID: nil)))) + XCTAssertFalse(CodexBarCLI.sourceModeRequiresWebSupport( + .web, + provider: .opencodego, + settings: ProviderSettingsSnapshot.make( + opencodego: .init( + cookieSource: .manual, + manualCookieHeader: "auth=manual", + workspaceID: nil)))) + XCTAssertFalse(CodexBarCLI.sourceModeRequiresWebSupport( + .auto, + provider: .opencodego, + settings: ProviderSettingsSnapshot.make( + opencodego: .init( + cookieSource: .auto, + manualCookieHeader: nil, + workspaceID: nil)))) + XCTAssertTrue(CodexBarCLI.sourceModeRequiresWebSupport( + .web, + provider: .opencodego, + settings: ProviderSettingsSnapshot.make( + opencodego: .init( + cookieSource: .auto, + manualCookieHeader: nil, + workspaceID: nil)))) + XCTAssertFalse(CodexBarCLI.sourceModeRequiresWebSupport( + .auto, + provider: .commandcode, + settings: ProviderSettingsSnapshot.make( + commandcode: .init( + cookieSource: .manual, + manualCookieHeader: "session=manual")))) + XCTAssertFalse(CodexBarCLI.sourceModeRequiresWebSupport( + .web, + provider: .commandcode, + settings: ProviderSettingsSnapshot.make( + commandcode: .init( + cookieSource: .manual, + manualCookieHeader: "session=manual")))) + XCTAssertTrue(CodexBarCLI.sourceModeRequiresWebSupport( + .auto, + provider: .commandcode, + settings: ProviderSettingsSnapshot.make( + commandcode: .init( + cookieSource: .auto, + manualCookieHeader: nil)))) + XCTAssertFalse(CodexBarCLI.sourceModeRequiresWebSupport( + .auto, + provider: .sakana, + environment: ["SAKANA_COOKIE": "session=manual"])) + XCTAssertFalse(CodexBarCLI.sourceModeRequiresWebSupport( + .web, + provider: .sakana, + environment: ["SAKANA_COOKIE": "session=manual"])) + XCTAssertTrue(CodexBarCLI.sourceModeRequiresWebSupport( + .auto, + provider: .sakana, + environment: [:])) + XCTAssertFalse(CodexBarCLI.sourceModeRequiresWebSupport( + .web, + provider: .qoder, + settings: ProviderSettingsSnapshot.make( + qoder: .init( + cookieSource: .manual, + manualCookieHeader: "sid=manual")))) + XCTAssertTrue(CodexBarCLI.sourceModeRequiresWebSupport( + .web, + provider: .qoder, + settings: ProviderSettingsSnapshot.make( + qoder: .init( + cookieSource: .auto, + manualCookieHeader: nil)))) + XCTAssertTrue(CodexBarCLI.sourceModeRequiresWebSupport( + .auto, + provider: .opencode, + settings: ProviderSettingsSnapshot.make( + opencode: .init( + cookieSource: .manual, + manualCookieHeader: "auth=manual", + workspaceID: nil)))) XCTAssertFalse(CodexBarCLI.sourceModeRequiresWebSupport( .auto, provider: .ollama, environment: ["OLLAMA_API_KEY": "ollama-test"])) + XCTAssertFalse(CodexBarCLI.sourceModeRequiresWebSupport( + .auto, + provider: .codex, + environment: ["OLLAMA_API_KEY": "ollama-test"])) XCTAssertFalse(CodexBarCLI.sourceModeRequiresWebSupport( .auto, provider: .ollama, settings: ProviderSettingsSnapshot.make( ollama: .init(cookieSource: .off, manualCookieHeader: nil)))) + XCTAssertFalse(CodexBarCLI.sourceModeRequiresWebSupport( + .auto, + provider: .kimi, + environment: ["KIMI_CODE_API_KEY": "kimi-test"])) + try self.assertKimiCodeCredentialSourceMode(in: directory) + XCTAssertFalse(CodexBarCLI.sourceModeRequiresWebSupport( + .auto, + provider: .mimo, + environment: ["MIMO_LOCAL_USAGE_PATH": validMiMoCache.path])) + XCTAssertTrue(CodexBarCLI.sourceModeRequiresWebSupport( + .web, + provider: .mimo, + environment: ["MIMO_LOCAL_USAGE_PATH": validMiMoCache.path])) + XCTAssertFalse(CodexBarCLI.sourceModeRequiresWebSupport( + .auto, + provider: .mimo, + environment: ["MIMO_LOCAL_USAGE_PATH": invalidMiMoCache.path])) + XCTAssertTrue(CodexBarCLI.sourceModeRequiresWebSupport( + .auto, + provider: .mimo, + environment: ["MIMO_LOCAL_USAGE_PATH": directory.appendingPathComponent("missing.json").path])) + } + + func test_sourceModeRequiresWebSupportAllowsQwenCookiesOnLinuxGate() { + XCTAssertFalse(CodexBarCLI.sourceModeRequiresWebSupport( + .auto, + provider: .qwencloud, + environment: ["QWEN_CLOUD_COOKIE": "login_qwencloud_ticket=test"])) + XCTAssertFalse(CodexBarCLI.sourceModeRequiresWebSupport( + .web, + provider: .qwencloud, + settings: ProviderSettingsSnapshot.make( + qwenCloud: .init( + cookieSource: .manual, + manualCookieHeader: "login_qwencloud_ticket=test")))) + XCTAssertTrue(CodexBarCLI.sourceModeRequiresWebSupport( + .auto, + provider: .qwencloud, + environment: [:])) + XCTAssertTrue(CodexBarCLI.sourceModeRequiresWebSupport( + .web, + provider: .qwencloud, + environment: ["QWEN_CLOUD_COOKIE": "login_qwencloud_ticket=test"], + settings: ProviderSettingsSnapshot.make( + qwenCloud: .init(cookieSource: .off, manualCookieHeader: nil)))) + } + + private func assertKimiCodeCredentialSourceMode(in directory: URL) throws { + let home = directory.appendingPathComponent("kimi-code", isDirectory: true) + let credentials = home.appendingPathComponent("credentials", isDirectory: true) + try FileManager.default.createDirectory(at: credentials, withIntermediateDirectories: true) + let payload: [String: Any] = [ + "access_token": "expired", + "refresh_token": "refresh", + "expires_at": Date().addingTimeInterval(-60).timeIntervalSince1970, + ] + try JSONSerialization.data(withJSONObject: payload) + .write(to: credentials.appendingPathComponent("kimi-code.json")) + + XCTAssertFalse(CodexBarCLI.sourceModeRequiresWebSupport( + .auto, + provider: .kimi, + environment: ["KIMI_CODE_HOME": home.path])) + } + + func test_sourceModeRequiresWebSupportAllowsFactoryAPIKeyOnLinuxGate() { + XCTAssertFalse(CodexBarCLI.sourceModeRequiresWebSupport( + .auto, + provider: .factory, + environment: ["FACTORY_API_KEY": "fk-test"])) + XCTAssertFalse(CodexBarCLI.sourceModeRequiresWebSupport( + .cli, + provider: .factory, + environment: ["FACTORY_API_KEY": "fk-test"])) + XCTAssertTrue(CodexBarCLI.sourceModeRequiresWebSupport( + .auto, + provider: .factory, + environment: [:])) + XCTAssertTrue(CodexBarCLI.sourceModeRequiresWebSupport( + .web, + provider: .factory, + environment: ["FACTORY_API_KEY": "fk-test"])) + XCTAssertFalse(CodexBarCLI.sourceModeRequiresWebSupport( + .api, + provider: .factory, + environment: [:])) } } diff --git a/Tests/CodexBarTests/CLIHooksTests.swift b/Tests/CodexBarTests/CLIHooksTests.swift new file mode 100644 index 0000000000..ea74142613 --- /dev/null +++ b/Tests/CodexBarTests/CLIHooksTests.swift @@ -0,0 +1,38 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBarCLI + +struct CLIHooksTests { + @Test + func `sample quota-low event matches maximum threshold`() { + let event = CodexBarCLI.sampleHookEvent(type: .quotaLow, provider: UsageProvider.codex.rawValue) + let rule = HookRule(event: .quotaLow, threshold: 1, executable: "/bin/echo") + + #expect(event.usagePercent == 1) + #expect(rule.matches(event)) + } + + @Test + func `sample refresh failure uses production status`() { + let event = CodexBarCLI.sampleHookEvent(type: .refreshFailed, provider: UsageProvider.codex.rawValue) + + #expect(event.status == "error") + } + + @Test + func `hook test JSON result is structured`() throws { + let result = HookTestResult( + ruleID: "fixture", + executable: "/bin/echo", + event: "quota_reached", + provider: "codex", + success: true, + stdout: "ok", + error: nil) + let encoded = try #require(CodexBarCLI.encodeJSON([result], pretty: false)) + let decoded = try JSONDecoder().decode([HookTestResult].self, from: Data(encoded.utf8)) + + #expect(decoded == [result]) + } +} diff --git a/Tests/CodexBarTests/CLIOutputTests.swift b/Tests/CodexBarTests/CLIOutputTests.swift index caa5c86d37..fd52513a76 100644 --- a/Tests/CodexBarTests/CLIOutputTests.swift +++ b/Tests/CodexBarTests/CLIOutputTests.swift @@ -64,4 +64,205 @@ struct CLIOutputTests { #expect(text.contains("Usage: 1.2 agent hours · 150 tokens · 1,200 TTS chars")) #expect(text.contains("Period: 2026-05-10 to 2026-05-17")) } + + @Test + func `text renderer includes amp credits without free tier usage`() { + let snapshot = AmpUsageSnapshot( + freeQuota: nil, + freeUsed: nil, + hourlyReplenishment: nil, + windowHours: nil, + individualCredits: 25.64, + workspaceBalances: [ + AmpWorkspaceBalance(name: "Alpha Team", remaining: 1234.56), + ], + accountEmail: "paid@example.com", + updatedAt: Date(timeIntervalSince1970: 0)) + .toUsageSnapshot() + + let text = CLIRenderer.renderText( + provider: .amp, + snapshot: snapshot, + credits: nil, + context: RenderContext( + header: "Amp (cli)", + status: nil, + useColor: false, + resetStyle: .countdown)) + + #expect(text.contains("Individual credits: $25.64")) + #expect(text.contains("Workspace Alpha Team: $1,234.56")) + #expect(text.contains("Account: paid@example.com")) + #expect(!text.contains("Amp Free:")) + } + + @Test + func `text renderer labels amp subscription pools`() { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let snapshot = AmpUsageSnapshot( + freeQuota: nil, + freeUsed: nil, + hourlyReplenishment: nil, + windowHours: nil, + updatedAt: now, + subscription: AmpSubscriptionUsage( + plan: "Megawatt", + otherUsedPercent: 3, + orbUsedPercent: 0, + resetsAt: now.addingTimeInterval(29 * 24 * 60 * 60), + resetDescription: "renews in 29 days")) + .toUsageSnapshot(now: now) + + let text = CLIRenderer.renderText( + provider: .amp, + snapshot: snapshot, + credits: nil, + context: RenderContext( + header: "Amp (cli)", + status: nil, + useColor: false, + resetStyle: .countdown), + now: now) + + #expect(text.contains("Other usage:")) + #expect(text.contains("Orb usage:")) + #expect(!text.contains("Amp Free:")) + #expect(!text.contains("Balance:")) + } + + @Test + func `text renderer shows mimo balance without quota or reset text`() { + let snapshot = MiMoUsageSnapshot( + balance: 25.51, + currency: "USD", + cashBalance: 20, + giftBalance: 5.51, + updatedAt: Date(timeIntervalSince1970: 0)) + .toUsageSnapshot() + + let text = CLIRenderer.renderText( + provider: .mimo, + snapshot: snapshot, + credits: nil, + context: RenderContext( + header: "Xiaomi MiMo (web)", + status: nil, + useColor: false, + resetStyle: .countdown)) + + #expect(text.contains("Balance: $25.51 (Paid: $20.00 / Granted: $5.51)")) + #expect(!text.contains("100%")) + #expect(!text.contains("Resets")) + #expect(!text.contains("Plan: Balance")) + } + + @Test + func `text renderer shows mimo token credits and balance`() { + let snapshot = MiMoUsageSnapshot( + balance: 25.51, + currency: "USD", + planCode: "standard", + tokenUsed: 10, + tokenLimit: 100, + tokenPercent: 0.1, + updatedAt: Date(timeIntervalSince1970: 0)) + .toUsageSnapshot() + + let text = CLIRenderer.renderText( + provider: .mimo, + snapshot: snapshot, + credits: nil, + context: RenderContext( + header: "Xiaomi MiMo (web)", + status: nil, + useColor: false, + resetStyle: .countdown)) + + #expect(text.contains("Credits: 90% left")) + #expect(text.contains("Balance: $25.51")) + #expect(text.contains("Plan: Standard")) + #expect(!text.contains("Window: 100%")) + } + + @Test + func `text renderer preserves compact mimo local summary casing`() { + let summary = "Local · 1.5k total · 42 sessions · stale 34d" + let snapshot = MiMoUsageSnapshot( + balance: 0, + currency: "", + planCode: summary, + updatedAt: Date(timeIntervalSince1970: 0)) + .toUsageSnapshot(includeBalance: false) + + let text = CLIRenderer.renderText( + provider: .mimo, + snapshot: snapshot, + credits: nil, + context: RenderContext( + header: "Xiaomi MiMo (local)", + status: nil, + useColor: false, + resetStyle: .countdown)) + + #expect(CLIRenderer.planBadgeText(provider: .mimo, snapshot: snapshot) == summary) + #expect(text.contains("Plan: \(summary)")) + #expect(!text.contains("Stale 34D")) + } + + @Test + func `text renderer includes Claude extra usage balance`() { + let now = Date(timeIntervalSince1970: 0) + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 10, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + secondary: nil, + providerCost: ProviderCostSnapshot( + used: 5, + limit: 20, + currencyCode: "USD", + period: "Monthly cap", + balance: 100, + updatedAt: now), + updatedAt: now) + + let text = CLIRenderer.renderText( + provider: .claude, + snapshot: snapshot, + credits: nil, + context: RenderContext( + header: "Claude (web)", + status: nil, + useColor: false, + resetStyle: .countdown)) + + #expect(text.contains("Extra usage balance: $100.00")) + } + + @Test + func `text renderer does not show zero cost for Claude balance only snapshot`() { + let now = Date(timeIntervalSince1970: 0) + let snapshot = UsageSnapshot( + primary: nil, + secondary: nil, + providerCost: ProviderCostSnapshot( + used: 0, + limit: 0, + currencyCode: "USD", + period: "Extra usage", + balance: 100, + updatedAt: now), + updatedAt: now) + + let text = CLIRenderer.renderText( + provider: .claude, + snapshot: snapshot, + credits: nil, + context: RenderContext( + header: "Claude (web)", + status: nil, + useColor: false, + resetStyle: .countdown)) + + #expect(text.contains("Extra usage balance: $100.00")) + #expect(!text.contains("Cost: 0.0 / 0.0")) + } } diff --git a/Tests/CodexBarTests/CLIServeAuthTests.swift b/Tests/CodexBarTests/CLIServeAuthTests.swift new file mode 100644 index 0000000000..bb3c9cf93d --- /dev/null +++ b/Tests/CodexBarTests/CLIServeAuthTests.swift @@ -0,0 +1,229 @@ +import Commander +import Foundation +import Testing +@testable import CodexBarCLI +@testable import CodexBarCore + +/// Unit coverage for the dashboard snapshot auth surface: option decoding, token +/// resolution, startup validation, and the constant-time bearer-token gate. +struct CLIServeAuthTests { + @Test + func `serve help documents dashboard token transport honestly`() { + let serve = CodexBarCLI.serveHelp(version: "0.0.0") + let root = CodexBarCLI.rootHelp(version: "0.0.0") + + #expect(serve.contains("--host ")) + #expect(serve.contains("--dashboard-token ")) + #expect(serve.contains("--allow-plain-http")) + #expect(serve.contains("GET /dashboard/v1/snapshot")) + #expect(serve.contains("CODEXBAR_DASHBOARD_TOKEN")) + #expect(serve.contains("cleartext")) + #expect(!serve.contains("never traverses the network")) + #expect(root.contains("--dashboard-token ")) + #expect(root.contains("--allow-plain-http")) + } + + @Test + func `serve host option parses and normalizes`() { + #expect(CodexBarCLI.decodeServeHost(from: ParsedValues( + positional: [], + options: [:], + flags: [])) == "127.0.0.1") + #expect(CodexBarCLI.decodeServeHost(from: ParsedValues( + positional: [], + options: ["host": ["0.0.0.0"]], + flags: [])) == "0.0.0.0") + #expect(CodexBarCLI.decodeServeHost(from: ParsedValues( + positional: [], + options: ["host": [" "]], + flags: [])) == nil) + #expect(CodexBarCLI.decodeServeHost(from: ParsedValues( + positional: [], + options: ["host": ["::1"]], + flags: [])) == nil) + #expect(CodexBarCLI.decodeServeHost(from: ParsedValues( + positional: [], + options: ["host": ["dashboard.local"]], + flags: [])) == nil) + #expect(CodexBarCLI.decodeServeHost(from: ParsedValues( + positional: [], + options: ["host": ["256.1.1.1"]], + flags: [])) == nil) + + #expect(CLIServeSecurity.bindHost("localhost") == "127.0.0.1") + #expect(CLIServeSecurity.bindHost(" LOCALHOST ") == "127.0.0.1") + #expect(CLIServeSecurity.bindHost("0.0.0.0") == "0.0.0.0") + #expect(CLIServeSecurity.bindHost("192.168.1.10") == "192.168.1.10") + #expect(CLIServeSecurity.isSupportedIPv4BindHost("127.0.0.1")) + #expect(CLIServeSecurity.isSupportedIPv4BindHost("0.0.0.0")) + #expect(!CLIServeSecurity.isSupportedIPv4BindHost("::1")) + #expect(!CLIServeSecurity.isSupportedIPv4BindHost("01.2.3.4")) + + #expect(CLIServeSecurity.isLoopbackHost("127.0.0.1")) + #expect(CLIServeSecurity.isLoopbackHost("127.1.2.3")) + #expect(CLIServeSecurity.isLoopbackHost("localhost")) + #expect(CLIServeSecurity.isLoopbackHost("::1")) + #expect(!CLIServeSecurity.isLoopbackHost("0.0.0.0")) + #expect(!CLIServeSecurity.isLoopbackHost("192.168.1.10")) + + #expect(CLIServeSecurity.allowedHosts(forBindHost: "127.0.0.1") == .loopbackOnly) + #expect(CLIServeSecurity.allowedHosts(forBindHost: "127.0.0.2") == .loopbackAnd(["127.0.0.2"])) + #expect(CLIServeSecurity.allowedHosts(forBindHost: "0.0.0.0") == .any) + #expect(CLIServeSecurity.allowedHosts(forBindHost: "192.168.1.10") + == .loopbackAnd(["192.168.1.10"])) + } + + @Test + func `serve flags parse through the real commander signature`() throws { + // Guards the ParsedValues key contract: keys are property names, so a + // mismatch between --allow-plain-http and its decode key would silently + // drop the flag. Parse real argv instead of hand-building ParsedValues. + let parser = CommandParser(signature: CommandSignature.describe(ServeOptions())) + let values = try parser.parse(arguments: [ + "--host", "0.0.0.0", + "--dashboard-token", "secret", + "--allow-plain-http", + ]) + let defaults = try parser.parse(arguments: []) + + #expect(CodexBarCLI.decodeServeHost(from: values) == "0.0.0.0") + #expect(CodexBarCLI.decodeServeAllowPlainHTTP(from: values)) + #expect(CodexBarCLI.resolveDashboardToken(from: values, environment: [:]) == .token("secret")) + #expect(CodexBarCLI.decodeServeHost(from: defaults) == "127.0.0.1") + #expect(!CodexBarCLI.decodeServeAllowPlainHTTP(from: defaults)) + #expect(CodexBarCLI.resolveDashboardToken(from: defaults, environment: [:]) == .absent) + } + + @Test + func `dashboard token resolution prefers the environment and rejects blanks`() { + let flagValues = ParsedValues( + positional: [], + options: ["dashboardBearer": [" flag-token "]], + flags: []) + let emptyValues = ParsedValues(positional: [], options: [:], flags: []) + let envOverride = Dictionary(uniqueKeysWithValues: [ + (CodexBarCLI.dashboardTokenEnvironmentVariable, "ENV_VALUE"), + ]) + + #expect(CodexBarCLI.resolveDashboardToken( + from: emptyValues, + environment: [:]) == .absent) + #expect(CodexBarCLI.resolveDashboardToken( + from: flagValues, + environment: [:]) == .token("flag-token")) + #expect(CodexBarCLI.resolveDashboardToken( + from: flagValues, + environment: envOverride) == .token("ENV_VALUE")) + #expect(CodexBarCLI.resolveDashboardToken( + from: emptyValues, + environment: ["CODEXBAR_DASHBOARD_TOKEN": " "]) + == .empty(source: "CODEXBAR_DASHBOARD_TOKEN")) + #expect(CodexBarCLI.resolveDashboardToken( + from: ParsedValues(positional: [], options: ["dashboardBearer": [""]], flags: []), + environment: [:]) == .empty(source: "--dashboard-token")) + } + + @Test + func `serve startup validation enforces the token and plain-http matrix`() { + // Loopback binds serve regardless of token or acceptance flag. + #expect(CodexBarCLI.validateServeStartup( + host: "127.0.0.1", + hasConfiguredBearer: false, + allowPlainHTTP: false) == nil) + #expect(CodexBarCLI.validateServeStartup( + host: "127.0.0.1", + hasConfiguredBearer: true, + allowPlainHTTP: false) == nil) + + // Non-loopback without a token always errors. + #expect(CodexBarCLI.validateServeStartup( + host: "0.0.0.0", + hasConfiguredBearer: false, + allowPlainHTTP: false) == .missingDashboardToken(host: "0.0.0.0")) + #expect(CodexBarCLI.validateServeStartup( + host: "192.168.1.10", + hasConfiguredBearer: false, + allowPlainHTTP: true) == .missingDashboardToken(host: "192.168.1.10")) + + // Non-loopback with a token requires the explicit plain-HTTP acceptance. + #expect(CodexBarCLI.validateServeStartup( + host: "0.0.0.0", + hasConfiguredBearer: true, + allowPlainHTTP: false) == .plainHTTPNotAccepted(host: "0.0.0.0")) + #expect(CodexBarCLI.validateServeStartup( + host: "0.0.0.0", + hasConfiguredBearer: true, + allowPlainHTTP: true) == nil) + } + + @Test + func `dashboard auth compares constant-time digests and fails closed`() { + let auth = CLIServeDashboardAuth(bearer: "secret") + let unconfigured = CLIServeDashboardAuth(bearer: nil) + + #expect(auth.isConfigured) + #expect(!unconfigured.isConfigured) + #expect(auth.authorize(Self.snapshotRequest(authorization: "Bearer secret"))) + #expect(auth.authorize(Self.snapshotRequest(authorization: " bearer secret "))) + #expect(!auth.authorize(Self.snapshotRequest(authorization: "Bearer wrong"))) + #expect(!auth.authorize(Self.snapshotRequest(authorization: "Bearer secret-longer"))) + #expect(!auth.authorize(Self.snapshotRequest(authorization: "secret"))) + #expect(!auth.authorize(Self.snapshotRequest(authorization: nil))) + // Query strings never carry credentials. + #expect(!auth.authorize(CLILocalHTTPRequest( + method: "GET", + target: "/dashboard/v1/snapshot?token=secret", + host: "localhost", + path: "/dashboard/v1/snapshot", + queryItems: ["token": "secret"], + authorization: nil))) + #expect(!unconfigured.authorize(Self.snapshotRequest(authorization: "Bearer secret"))) + + #expect(CLIServeDashboardAuth.bearerToken(from: "Bearer abc") == "abc") + #expect(CLIServeDashboardAuth.bearerToken(from: "Bearer ") == nil) + #expect(CLIServeDashboardAuth.bearerToken(from: "Basic abc") == nil) + #expect(CLIServeDashboardAuth.bearerToken(from: nil) == nil) + + #expect(CLIServeDashboardAuth.constantTimeEquals([1, 2, 3], [1, 2, 3])) + #expect(!CLIServeDashboardAuth.constantTimeEquals([1, 2, 3], [1, 2, 4])) + #expect(!CLIServeDashboardAuth.constantTimeEquals([1, 2, 3], [1, 2])) + #expect(CLIServeDashboardAuth.constantTimeEquals([], [])) + } + + @Test + func `adding no-store preserves an existing cache-control header`() { + let plain = CLILocalHTTPResponse(status: .ok, body: Data("[]".utf8)) + let declared = CLILocalHTTPResponse( + status: .ok, + body: Data("[]".utf8), + extraHeaders: [("Cache-Control", "no-store")]) + + let annotated = CodexBarCLI.addingNoStore(plain) + let untouched = CodexBarCLI.addingNoStore(declared) + + #expect(annotated.extraHeaders.contains { $0 == ("Cache-Control", "no-store") }) + #expect(untouched.extraHeaders.count == 1) + #expect(CodexBarCLI.addingNoStore(annotated).extraHeaders.count == 1) + } + + @Test + func `unauthorized response advertises bearer challenge and no-store`() { + let response = CodexBarCLI.serveUnauthorizedResponse() + + #expect(response.status == .unauthorized) + #expect(response.extraHeaders.contains { $0 == ("WWW-Authenticate", "Bearer") }) + #expect(response.extraHeaders.contains { $0 == ("Cache-Control", "no-store") }) + let object = try? JSONSerialization.jsonObject(with: response.body) as? [String: Any] + #expect(object?["error"] as? String == "unauthorized") + } + + private static func snapshotRequest(authorization: String?) -> CLILocalHTTPRequest { + CLILocalHTTPRequest( + method: "GET", + target: "/dashboard/v1/snapshot", + host: "localhost", + path: "/dashboard/v1/snapshot", + queryItems: [:], + authorization: authorization) + } +} diff --git a/Tests/CodexBarTests/CLIServeRawHTTPTests.swift b/Tests/CodexBarTests/CLIServeRawHTTPTests.swift new file mode 100644 index 0000000000..771b4c2405 --- /dev/null +++ b/Tests/CodexBarTests/CLIServeRawHTTPTests.swift @@ -0,0 +1,568 @@ +import Foundation +import Testing +#if canImport(Darwin) +import Darwin +#else +import Glibc +#endif +@testable import CodexBarCLI +@testable import CodexBarCore + +/// Raw-socket coverage for `codexbar serve`: boots the real `CLILocalHTTPServer` on an +/// ephemeral port, writes HTTP/1.1 bytes over a plain TCP connection, and asserts on the +/// raw response bytes. This exercises header parsing and response serialization on the +/// wire instead of calling the router or handlers directly. +/// +/// Serialized: every case runs its own server whose accept loop occupies a cooperative +/// thread; running them concurrently starves the pool and stalls unrelated suites. +@Suite(.serialized) +struct CLIServeRawHTTPTests { + @Test + func `raw server serializes status body and extra headers on the wire`() async throws { + try await Self.withServer(handler: { _ in + CLILocalHTTPResponse( + status: .ok, + body: Data(#"{"status":"ok"}"#.utf8), + extraHeaders: [("X-Test", "value")]) + }, body: { port in + let response = try await Self.rawExchange( + port: port, + request: "GET /health HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n") + + #expect(response.statusLine == "HTTP/1.1 200 OK") + #expect(response.headerValue("Content-Type") == "application/json; charset=utf-8") + #expect(response.headerValue("Content-Length") == "15") + #expect(response.headerValue("Connection") == "close") + #expect(response.headerValue("X-Test") == "value") + #expect(response.body == #"{"status":"ok"}"#) + }) + } + + @Test + func `raw server rejects non loopback host headers by default`() async throws { + try await Self.withServer(handler: { _ in Self.okResponse() }, body: { port in + let response = try await Self.rawExchange( + port: port, + request: "GET /health HTTP/1.1\r\nHost: evil.test\r\n\r\n") + + #expect(response.statusLine == "HTTP/1.1 403 Forbidden") + #expect(response.body == #"{"error":"forbidden host"}"#) + }) + } + + @Test + func `raw server accepts a configured non loopback host alongside loopback`() async throws { + try await Self.withServer( + allowedHosts: .loopbackAnd(["dashboard.local"]), + handler: { _ in Self.okResponse() }, + body: { port in + let allowed = try await Self.rawExchange( + port: port, + request: "GET /health HTTP/1.1\r\nHost: dashboard.local:8080\r\n\r\n") + let loopback = try await Self.rawExchange( + port: port, + request: "GET /health HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n") + let disallowed = try await Self.rawExchange( + port: port, + request: "GET /health HTTP/1.1\r\nHost: evil.test\r\n\r\n") + + #expect(allowed.statusLine == "HTTP/1.1 200 OK") + #expect(loopback.statusLine == "HTTP/1.1 200 OK") + #expect(disallowed.statusLine == "HTTP/1.1 403 Forbidden") + }) + } + + @Test + func `raw server rejects duplicate host headers`() async throws { + try await Self.withServer(handler: { _ in Self.okResponse() }, body: { port in + let response = try await Self.rawExchange( + port: port, + request: "GET /health HTTP/1.1\r\nHost: 127.0.0.1\r\nHost: localhost\r\n\r\n") + + #expect(response.statusLine == "HTTP/1.1 400 Bad Request") + #expect(response.headerValue("Cache-Control") == "no-store") + }) + } + + @Test + func `raw server rejects duplicate authorization headers`() async throws { + try await Self.withServer(handler: { _ in Self.okResponse() }, body: { port in + let response = try await Self.rawExchange( + port: port, + request: [ + "GET /health HTTP/1.1", + "Host: 127.0.0.1", + "Authorization: Bearer one", + "Authorization: Bearer two", + "", + "", + ].joined(separator: "\r\n")) + + #expect(response.statusLine == "HTTP/1.1 400 Bad Request") + #expect(response.body == #"{"error":"invalid request"}"#) + #expect(response.headerValue("Cache-Control") == "no-store") + }) + } + + @Test + func `raw server passes the authorization header to the handler`() async throws { + try await Self.withServer(handler: { request in + CLILocalHTTPResponse( + status: .ok, + body: Data((request.authorization ?? "none").utf8), + contentType: "text/plain") + }, body: { port in + let withHeader = try await Self.rawExchange( + port: port, + request: "GET /health HTTP/1.1\r\nHost: 127.0.0.1\r\nAuthorization: Bearer secret\r\n\r\n") + let withoutHeader = try await Self.rawExchange( + port: port, + request: "GET /health HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n") + + #expect(withHeader.body == "Bearer secret") + #expect(withoutHeader.body == "none") + }) + } + + // MARK: - Dashboard snapshot auth (production handler) + + @Test + func `snapshot without credentials returns 401 with challenge and no-store`() async throws { + try await Self.withServeRuntime(token: "secret", body: { port in + let response = try await Self.rawExchange( + port: port, + request: "GET /dashboard/v1/snapshot HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n") + + #expect(response.statusLine == "HTTP/1.1 401 Unauthorized") + #expect(response.headerValue("WWW-Authenticate") == "Bearer") + #expect(response.headerValue("Cache-Control") == "no-store") + #expect(response.body == #"{"error":"unauthorized"}"#) + }) + } + + @Test + func `snapshot with wrong token returns 401`() async throws { + try await Self.withServeRuntime(token: "secret", body: { port in + let response = try await Self.rawExchange( + port: port, + request: "GET /dashboard/v1/snapshot HTTP/1.1\r\nHost: 127.0.0.1\r\n" + + "Authorization: Bearer wrong\r\n\r\n") + + #expect(response.statusLine == "HTTP/1.1 401 Unauthorized") + #expect(response.headerValue("WWW-Authenticate") == "Bearer") + #expect(response.headerValue("Cache-Control") == "no-store") + }) + } + + @Test + func `snapshot with correct token returns decodable JSON with no-store`() async throws { + try await Self.withServeRuntime(token: "secret", body: { port in + let response = try await Self.rawExchange( + port: port, + request: "GET /dashboard/v1/snapshot HTTP/1.1\r\nHost: 127.0.0.1\r\n" + + "Authorization: Bearer secret\r\n\r\n") + + #expect(response.statusLine == "HTTP/1.1 200 OK") + #expect(response.headerValue("Cache-Control") == "no-store") + #expect(response.headerValues("Cache-Control").count == 1) + let object = try #require( + JSONSerialization.jsonObject(with: Data(response.body.utf8)) as? [String: Any]) + #expect(object["schemaVersion"] as? Int == 1) + #expect((object["providers"] as? [Any])?.isEmpty == true) + #expect(object["host"] is [String: Any]) + }) + } + + @Test + func `snapshot never accepts the token from the query string`() async throws { + try await Self.withServeRuntime(token: "secret", body: { port in + let response = try await Self.rawExchange( + port: port, + request: "GET /dashboard/v1/snapshot?token=secret HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n") + + #expect(response.statusLine == "HTTP/1.1 401 Unauthorized") + #expect(response.headerValue("Cache-Control") == "no-store") + }) + } + + @Test + func `snapshot with duplicate authorization headers returns 400`() async throws { + try await Self.withServeRuntime(token: "secret", body: { port in + let response = try await Self.rawExchange( + port: port, + request: [ + "GET /dashboard/v1/snapshot HTTP/1.1", + "Host: 127.0.0.1", + "Authorization: Bearer secret", + "Authorization: Bearer secret", + "", + "", + ].joined(separator: "\r\n")) + + #expect(response.statusLine == "HTTP/1.1 400 Bad Request") + #expect(response.headerValue("Cache-Control") == "no-store") + }) + } + + @Test + func `snapshot rejects non get methods with 405`() async throws { + try await Self.withServeRuntime(token: "secret", body: { port in + let response = try await Self.rawExchange( + port: port, + request: "POST /dashboard/v1/snapshot HTTP/1.1\r\nHost: 127.0.0.1\r\n" + + "Authorization: Bearer secret\r\nContent-Length: 0\r\n\r\n") + + #expect(response.statusLine == "HTTP/1.1 405 Method Not Allowed") + #expect(response.headerValue("Cache-Control") == "no-store") + }) + } + + @Test + func `dashboard missing routes return no-store`() async throws { + try await Self.withServeRuntime(token: "secret", body: { port in + let response = try await Self.rawExchange( + port: port, + request: "GET /dashboard/v1/missing HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n") + + #expect(response.statusLine == "HTTP/1.1 404 Not Found") + #expect(response.headerValue("Cache-Control") == "no-store") + }) + } + + @Test + func `snapshot fails closed when no token is configured`() async throws { + try await Self.withServeRuntime(token: nil, body: { port in + let response = try await Self.rawExchange( + port: port, + request: "GET /dashboard/v1/snapshot HTTP/1.1\r\nHost: 127.0.0.1\r\n" + + "Authorization: Bearer anything\r\n\r\n") + + #expect(response.statusLine == "HTTP/1.1 401 Unauthorized") + #expect(response.headerValue("Cache-Control") == "no-store") + }) + } + + @Test + func `usage and cost responses carry no-store on the wire`() async throws { + try await Self.withServeRuntime(token: nil, body: { port in + let usage = try await Self.rawExchange( + port: port, + request: "GET /usage HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n") + let cost = try await Self.rawExchange( + port: port, + request: "GET /cost HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n") + + #expect(usage.statusLine == "HTTP/1.1 200 OK") + #expect(usage.headerValue("Cache-Control") == "no-store") + #expect(usage.headerValues("Cache-Control").count == 1) + // All providers are disabled in this runtime, so /cost rejects the request, + // but even error responses on account-data routes stay uncacheable. + #expect(cost.statusLine == "HTTP/1.1 400 Bad Request") + #expect(cost.headerValue("Cache-Control") == "no-store") + }) + } + + @Test + func `non-loopback binds gate usage and cost behind the token`() async throws { + try await Self.withServeRuntime(token: "secret", bindHost: "0.0.0.0", body: { port in + let usageDenied = try await Self.rawExchange( + port: port, + request: "GET /usage HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n") + let costDenied = try await Self.rawExchange( + port: port, + request: "GET /cost HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n") + let usageAllowed = try await Self.rawExchange( + port: port, + request: "GET /usage HTTP/1.1\r\nHost: 127.0.0.1\r\n" + + "Authorization: Bearer secret\r\n\r\n") + let health = try await Self.rawExchange( + port: port, + request: "GET /health HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n") + + #expect(usageDenied.statusLine == "HTTP/1.1 401 Unauthorized") + #expect(usageDenied.headerValue("WWW-Authenticate") == "Bearer") + #expect(usageDenied.headerValue("Cache-Control") == "no-store") + #expect(costDenied.statusLine == "HTTP/1.1 401 Unauthorized") + #expect(costDenied.headerValue("Cache-Control") == "no-store") + #expect(usageAllowed.statusLine == "HTTP/1.1 200 OK") + #expect(usageAllowed.headerValue("Cache-Control") == "no-store") + // /health carries no account data and stays open for liveness probes. + #expect(health.statusLine == "HTTP/1.1 200 OK") + }) + } + + @Test + func `dashboard error responses carry no-store`() async throws { + try await Self.withServeRuntime(token: "secret", rawConfigJSON: "{not json", body: { port in + let response = try await Self.rawExchange( + port: port, + request: "GET /dashboard/v1/snapshot HTTP/1.1\r\nHost: 127.0.0.1\r\n" + + "Authorization: Bearer secret\r\n\r\n") + + #expect(response.statusLine == "HTTP/1.1 500 Internal Server Error") + #expect(response.headerValue("Cache-Control") == "no-store") + #expect(response.headerValues("Cache-Control").count == 1) + }) + } + + @Test + func `health stays open when a dashboard token is configured`() async throws { + try await Self.withServeRuntime(token: "secret", body: { port in + let response = try await Self.rawExchange( + port: port, + request: "GET /health HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n") + + #expect(response.statusLine == "HTTP/1.1 200 OK") + let object = try #require( + JSONSerialization.jsonObject(with: Data(response.body.utf8)) as? [String: Any]) + #expect(object["status"] as? String == "ok") + }) + } + + @Test + func `snapshot response preserves usage cache metadata`() async throws { + let store = testConfigStore(suiteName: "CLIServeRawHTTPTests-\(UUID().uuidString)") + defer { try? store.deleteIfPresent() } + try store.save(CodexBarConfig(providers: UsageProvider.allCases.map { + ProviderConfig(id: $0, enabled: false) + })) + let runtime = ServeRuntime( + configStore: store, + cache: CLIServeResponseCache(), + providerOperations: CLIServeOperationCoordinator(), + costOperations: CLIServeOperationCoordinator(), + refreshInterval: 60, + requestTimeout: 5, + healthVersion: "0.0.0-test", + dashboardAuth: CLIServeDashboardAuth(bearer: "secret"), + bindHost: "127.0.0.1") + let request = CLILocalHTTPRequest( + method: "GET", + target: "/dashboard/v1/snapshot", + host: "127.0.0.1", + path: "/dashboard/v1/snapshot", + queryItems: [:], + authorization: "Bearer secret") + + let response = await CodexBarCLI.handleServeRequest(request, runtime: runtime) + + #expect(response.status == .ok) + #expect(response.usageCacheKeys != nil) + #expect(response.usageCacheKeys?.isEmpty == true) + } + + // MARK: - Harness + + /// Boots the production serve handler with an isolated config store whose providers + /// are all disabled, so snapshot fetches stay local while the full route/auth/cache + /// path is exercised end to end. + /// + /// `bindHost` configures the runtime exactly as `runServe` would for that bind + /// host (a non-loopback value gates every data route); the test listener itself + /// always binds loopback. `rawConfigJSON` replaces the stored config with raw + /// bytes to provoke config-load failures. + static func withServeRuntime( + token: String?, + bindHost: String = "127.0.0.1", + rawConfigJSON: String? = nil, + body: (UInt16) async throws -> Void) async throws + { + let store = testConfigStore(suiteName: "CLIServeRawHTTPTests-\(UUID().uuidString)") + defer { try? store.deleteIfPresent() } + try store.save(CodexBarConfig(providers: UsageProvider.allCases.map { + ProviderConfig(id: $0, enabled: false) + })) + if let rawConfigJSON { + try Data(rawConfigJSON.utf8).write(to: store.fileURL) + } + + let runtime = ServeRuntime( + configStore: store, + cache: CLIServeResponseCache(), + providerOperations: CLIServeOperationCoordinator(), + costOperations: CLIServeOperationCoordinator(), + refreshInterval: 60, + requestTimeout: 5, + healthVersion: "0.0.0-test", + dashboardAuth: CLIServeDashboardAuth(bearer: token), + bindHost: bindHost) + try await Self.withServer( + handler: { request in + await CodexBarCLI.handleServeRequest(request, runtime: runtime) + }, + body: body) + } + + static func okResponse() -> CLILocalHTTPResponse { + CLILocalHTTPResponse(status: .ok, body: Data(#"{"status":"ok"}"#.utf8)) + } + + /// Runs `body` against a live server bound to an ephemeral loopback port. + static func withServer( + allowedHosts: CLILocalHTTPAllowedHosts = .loopbackOnly, + handler: @escaping CLILocalHTTPServer.Handler, + body: (UInt16) async throws -> Void) async throws + { + let listening = RawHTTPListeningSignal() + let server = CLILocalHTTPServer( + host: "127.0.0.1", + port: 0, + allowedHosts: allowedHosts, + handler: handler) + let task = Task { + try await server.run { + listening.signal() + } + } + + await listening.wait() + do { + let port = try #require(server.listeningPort) + try await body(port) + } catch { + server.stop() + _ = try? await task.value + throw error + } + server.stop() + try await task.value + } + + struct RawHTTPResponse { + let statusLine: String + let headers: [(String, String)] + let body: String + + func headerValue(_ name: String) -> String? { + self.headers.first { $0.0.lowercased() == name.lowercased() }?.1 + } + + func headerValues(_ name: String) -> [String] { + self.headers.filter { $0.0.lowercased() == name.lowercased() }.map(\.1) + } + } + + enum RawHTTPExchangeError: Error { + case connectFailed + case sendFailed + case malformedResponse + } + + /// Writes `request` bytes over a fresh TCP connection and reads the raw response to EOF. + /// Runs on a Dispatch thread so the blocking socket calls cannot starve the cooperative + /// pool the server's accept loop and handler tasks run on. + static func rawExchange(port: UInt16, request: String) async throws -> RawHTTPResponse { + try await withCheckedThrowingContinuation { continuation in + DispatchQueue.global().async { + continuation.resume(with: Result { + try Self.performRawExchange(port: port, request: request) + }) + } + } + } + + private static func performRawExchange(port: UInt16, request: String) throws -> RawHTTPResponse { + #if canImport(Darwin) + let streamType = SOCK_STREAM + #else + let streamType = Int32(SOCK_STREAM.rawValue) + #endif + let fd = socket(AF_INET, streamType, 0) + guard fd >= 0 else { throw RawHTTPExchangeError.connectFailed } + defer { close(fd) } + + var timeout = timeval(tv_sec: 5, tv_usec: 0) + setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &timeout, socklen_t(MemoryLayout.size)) + setsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, &timeout, socklen_t(MemoryLayout.size)) + + var address = sockaddr_in() + #if canImport(Darwin) + address.sin_len = UInt8(MemoryLayout.size) + #endif + address.sin_family = sa_family_t(AF_INET) + address.sin_port = port.bigEndian + guard inet_pton(AF_INET, "127.0.0.1", &address.sin_addr) == 1 else { + throw RawHTTPExchangeError.connectFailed + } + let connected = withUnsafePointer(to: &address) { pointer in + pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { socketAddress in + connect(fd, socketAddress, socklen_t(MemoryLayout.size)) + } + } + guard connected == 0 else { throw RawHTTPExchangeError.connectFailed } + + let requestData = Data(request.utf8) + let sent = requestData.withUnsafeBytes { rawBuffer -> Int in + guard let base = rawBuffer.baseAddress else { return -1 } + var total = 0 + while total < requestData.count { + let count = send(fd, base.advanced(by: total), requestData.count - total, 0) + guard count > 0 else { return -1 } + total += count + } + return total + } + guard sent == requestData.count else { throw RawHTTPExchangeError.sendFailed } + + var data = Data() + var buffer = [UInt8](repeating: 0, count: 4096) + let bufferSize = buffer.count + while true { + let count = buffer.withUnsafeMutableBytes { rawBuffer in + recv(fd, rawBuffer.baseAddress, bufferSize, 0) + } + guard count > 0 else { break } + data.append(buffer, count: count) + } + + return try Self.parseRawResponse(data) + } + + private static func parseRawResponse(_ data: Data) throws -> RawHTTPResponse { + guard let separator = data.range(of: Data("\r\n\r\n".utf8)), + let head = String(data: data[..? + private var isSignaled = false + + func signal() { + let continuation = self.lock.withLock { + self.isSignaled = true + defer { self.continuation = nil } + return self.continuation + } + continuation?.resume() + } + + func wait() async { + await withCheckedContinuation { continuation in + let shouldResume = self.lock.withLock { + guard !self.isSignaled else { return true } + self.continuation = continuation + return false + } + if shouldResume { + continuation.resume() + } + } + } +} diff --git a/Tests/CodexBarTests/CLIServeRouterTests.swift b/Tests/CodexBarTests/CLIServeRouterTests.swift index 84149ed5ef..0635449461 100644 --- a/Tests/CodexBarTests/CLIServeRouterTests.swift +++ b/Tests/CodexBarTests/CLIServeRouterTests.swift @@ -1,9 +1,53 @@ import Commander import Foundation import Testing +#if canImport(Darwin) +import Darwin +#else +import Glibc +#endif @testable import CodexBarCLI +@testable import CodexBarCore +// Cache state-machine coverage is intentionally kept together for sequence readability. +// swiftlint:disable:next type_body_length struct CLIServeRouterTests { + @Test + func `local HTTP connection gate caps pre-auth clients`() { + let gate = CLILocalHTTPConnectionGate(maximumConnections: 2) + + #expect(gate.tryAcquire()) + #expect(gate.tryAcquire()) + #expect(!gate.tryAcquire()) + #expect(gate.activeCount == 2) + gate.release() + #expect(gate.tryAcquire()) + #expect(gate.activeCount == 2) + gate.release() + gate.release() + #expect(gate.activeCount == 0) + } + + @Test + func `usage operation fingerprint separates dashboard account mode`() { + let allAccounts = CodexBarCLI.serveUsageOperationFingerprint( + configFingerprint: "config", + includeAllCodexAccounts: true) + let selectedAccount = CodexBarCLI.serveUsageOperationFingerprint( + configFingerprint: "config", + includeAllCodexAccounts: false) + + #expect(allAccounts != selectedAccount) + #expect(allAccounts == CodexBarCLI.serveUsageOperationFingerprint( + configFingerprint: "config", + includeAllCodexAccounts: true)) + } + + @Test + func `termination monitor handles interactive and hangup signals`() { + #expect(CLITerminationSignalMonitor.signalNumbers == [SIGINT, SIGTERM, SIGHUP]) + } + @Test func `local http parser accepts only loopback host headers`() throws { let allowedHosts = [ @@ -34,6 +78,55 @@ struct CLIServeRouterTests { .duplicateHost) } + @Test + func `local http parser captures a single authorization header`() throws { + let raw = [ + "GET /usage HTTP/1.1", + "Host: localhost", + "authorization: Bearer token", + "", + "", + ].joined(separator: "\r\n") + let request = try CLILocalHTTPRequest.parse(Data(raw.utf8)).get() + + #expect(request.authorization == "Bearer token") + #expect(try Self.parsedRequest(host: "localhost").authorization == nil) + Self.expectParseFailure( + raw: "GET /usage HTTP/1.1\r\nHost: localhost\r\nAuthorization: a\r\nAuthorization: b\r\n\r\n", + .duplicateAuthorization) + } + + @Test + func `local http parser extends the allowed host set without replacing loopback`() throws { + let raw = "GET /usage HTTP/1.1\r\nHost: 192.168.1.10:8080\r\n\r\n" + + Self.expectParseFailure(raw: raw, .disallowedHost) + + let allowed = CLILocalHTTPAllowedHosts.loopbackAnd(["192.168.1.10"]) + let request = try CLILocalHTTPRequest.parse(Data(raw.utf8), allowedHosts: allowed).get() + #expect(request.host == "192.168.1.10:8080") + #expect(request.path == "/usage") + let loopback = try CLILocalHTTPRequest.parse( + Data("GET /usage HTTP/1.1\r\nHost: localhost\r\n\r\n".utf8), + allowedHosts: allowed).get() + #expect(loopback.host == "localhost") + Self.expectParseFailure( + raw: "GET /usage HTTP/1.1\r\nHost: evil.test\r\n\r\n", + .disallowedHost, + allowedHosts: allowed) + + let wildcard = try CLILocalHTTPRequest.parse(Data(raw.utf8), allowedHosts: .any).get() + #expect(wildcard.host == "192.168.1.10:8080") + let alternateLoopback = try CLILocalHTTPRequest.parse( + Data("GET /usage HTTP/1.1\r\nHost: 127.0.0.2\r\n\r\n".utf8), + allowedHosts: CLIServeSecurity.allowedHosts(forBindHost: "127.0.0.2")).get() + #expect(alternateLoopback.host == "127.0.0.2") + Self.expectParseFailure( + raw: "GET /usage HTTP/1.1\r\nHost: 192.168.1.10, evil.test\r\n\r\n", + .disallowedHost, + allowedHosts: .any) + } + @Test func `routes health usage and cost endpoints`() throws { #expect(try CLIServeRouter.route(method: "GET", path: "/health", queryItems: [:]) == .health) @@ -48,6 +141,11 @@ struct CLIServeRouterTests { method: "GET", path: "/cost", queryItems: ["provider": "codex"]) == .cost(provider: "codex")) + #expect( + try CLIServeRouter.route( + method: "GET", + path: "/dashboard/v1/snapshot", + queryItems: [:]) == .dashboardSnapshot) } @Test @@ -74,6 +172,24 @@ struct CLIServeRouterTests { } } + @Test + func `health response reports ok status and build version`() throws { + let response = CodexBarCLI.serveHealthResponse(version: "1.2.3") + #expect(response.status == .ok) + let object = try JSONSerialization.jsonObject(with: response.body) as? [String: Any] + #expect(object?["status"] as? String == "ok") + #expect(object?["version"] as? String == "1.2.3") + } + + @Test + func `health response omits version detail when unavailable`() throws { + let response = CodexBarCLI.serveHealthResponse(version: nil) + #expect(response.status == .ok) + let object = try JSONSerialization.jsonObject(with: response.body) as? [String: Any] + #expect(object?["status"] as? String == "ok") + #expect(object?.keys.contains("version") == false) + } + @Test func `serve numeric options reject malformed values`() { #expect(CodexBarCLI.decodeServePort(from: ParsedValues( @@ -101,10 +217,82 @@ struct CLIServeRouterTests { positional: [], options: ["refreshInterval": ["-1"]], flags: [])) == nil) + #expect(CodexBarCLI.decodeServeRefreshInterval(from: ParsedValues( + positional: [], + options: ["refreshInterval": ["inf"]], + flags: [])) == nil) + #expect(CodexBarCLI.decodeServeRefreshInterval(from: ParsedValues( + positional: [], + options: ["refreshInterval": ["86401"]], + flags: [])) == 86401) + #expect(CodexBarCLI.decodeServeRefreshInterval(from: ParsedValues( + positional: [], + options: ["refreshInterval": ["86400"]], + flags: [])) == 86400) #expect(CodexBarCLI.decodeServeRefreshInterval(from: ParsedValues( 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": ["inf"]], + 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 + func `serve help documents request timeout option`() { + let serve = CodexBarCLI.serveHelp(version: "0.0.0") + let root = CodexBarCLI.rootHelp(version: "0.0.0") + + #expect(serve.contains("--request-timeout ")) + #expect(serve.contains("codexbar serve --port 8080 --refresh-interval 60 --request-timeout 30")) + #expect(root.contains("--request-timeout ")) + } + + @Test + func `serve config snapshot reflects provider changes`() throws { + let store = testConfigStore(suiteName: "CLIServeRouterTests-serve-config-freshness-\(UUID().uuidString)") + defer { try? store.deleteIfPresent() } + var firstConfig = CodexBarConfig.makeDefault() + firstConfig.setProviderConfig(ProviderConfig(id: .opencodego, enabled: false)) + try store.save(firstConfig) + + let firstSnapshot = try CodexBarCLI.loadServeConfigSnapshot(configStore: store) + + var secondConfig = firstConfig + secondConfig.setProviderConfig(ProviderConfig(id: .opencodego, enabled: true)) + try store.save(secondConfig) + let secondSnapshot = try CodexBarCLI.loadServeConfigSnapshot(configStore: store) + + #expect(!firstSnapshot.config.enabledProviders().contains(.opencodego)) + #expect(secondSnapshot.config.enabledProviders().contains(.opencodego)) + #expect(firstSnapshot.cacheToken != secondSnapshot.cacheToken) + let operationKey = try CodexBarCLI.serveOperationKey(kind: "usage", provider: nil) + #expect(try operationKey == (CodexBarCLI.serveOperationKey(kind: "usage", provider: nil))) + #expect( + CodexBarCLI.serveCacheKey(operationKey: operationKey, configToken: firstSnapshot.cacheToken) != + CodexBarCLI.serveCacheKey(operationKey: operationKey, configToken: secondSnapshot.cacheToken)) } @Test @@ -124,17 +312,1298 @@ struct CLIServeRouterTests { #expect(!CodexBarCLI.shouldCacheServeResponse(routeError)) } + @Test + func `serve provider timeout stays below the request deadline`() throws { + let thirtySecondTimeout = try #require(CodexBarCLI.serveProviderTimeout(requestTimeout: 30)) + let tenSecondTimeout = try #require(CodexBarCLI.serveProviderTimeout(requestTimeout: 10)) + #expect(abs(thirtySecondTimeout - 24) < 1e-9) + #expect(abs(tenSecondTimeout - 8) < 1e-9) + // Outer deadline disabled (0) or non-finite: add no serve-level provider bound. + #expect(CodexBarCLI.serveProviderTimeout(requestTimeout: 0) == nil) + #expect(CodexBarCLI.serveProviderTimeout(requestTimeout: .infinity) == nil) + // Finite deadlines stay strictly below the request timeout at every + // value, including sub-second ones. + let oneSecondTimeout = try #require(CodexBarCLI.serveProviderTimeout(requestTimeout: 1)) + let halfSecondTimeout = try #require(CodexBarCLI.serveProviderTimeout(requestTimeout: 0.5)) + #expect(oneSecondTimeout < 1) + #expect(abs(halfSecondTimeout - 0.4) < 1e-9) + // Oversized finite deadlines share the outer 24-hour cap and cannot + // overflow Duration conversion. + let oversizedTimeout = try #require(CodexBarCLI.serveProviderTimeout( + requestTimeout: .greatestFiniteMagnitude)) + #expect(abs(oversizedTimeout - 69120) < 1e-9) + #expect(oversizedTimeout < 86400) + } + + @Test + func `serve usage collection bounds a hung provider without blocking others`() async { + let providers: [UsageProvider] = [.codex, .claude, .gemini] + let start = Date() + let output = await CodexBarCLI.serveCollectUsageOutputs( + providers: providers, + providerTimeout: 0.1) + { provider in + if provider == .claude { + try? await Task.sleep(for: .seconds(30)) + return UsageCommandOutput(sections: ["late:\(provider.rawValue)"]) + } + return UsageCommandOutput(sections: ["ok:\(provider.rawValue)"]) + } + let elapsed = Date().timeIntervalSince(start) + + // The hung provider must not serialize or stall the others. + #expect(elapsed < 5) + // Fast providers render in caller order; the hung one yields no section. + #expect(output.sections == ["ok:codex", "ok:gemini"]) + // The hung provider degrades to a single provider error row. + #expect(output.payload.count == 1) + #expect(output.payload.first?.provider == UsageProvider.claude.rawValue) + #expect(output.payload.first?.error != nil) + #expect(output.payload.first?.error?.kind == .provider) + // The timeout row is account-agnostic: it carries no cache key, so the + // cache's keyed last-good merge intentionally does not reconstruct it + // (a timeout cannot prove which account is active). + #expect(output.payload.first?.cacheAccountKey == nil) + #expect(output.payload.first?.account == nil) + #expect(output.exitCode == .failure) + } + + @Test + func `serve usage collection adds no join bound when request deadline is disabled`() async { + let output = await CodexBarCLI.serveCollectUsageOutputs( + providers: [.codex, .claude], + providerTimeout: nil) + { provider in + if provider == .codex { + try? await Task.sleep(for: .milliseconds(25)) + } + return UsageCommandOutput(sections: ["ok:\(provider.rawValue)"]) + } + + #expect(output.sections == ["ok:codex", "ok:claude"]) + #expect(output.payload.isEmpty) + #expect(output.exitCode == .success) + } + + @Test + func `serve cache uses stable Codex account identities`() { + let storedID = UUID() + let firstProjection = Self.codexVisibleAccount( + id: "email-shaped-id", + workspaceAccountID: "workspace-1", + authFingerprint: "auth-1", + storedAccountID: storedID) + let reshapedProjection = Self.codexVisibleAccount( + id: "managed:\(storedID.uuidString)", + workspaceAccountID: "workspace-1", + authFingerprint: "auth-1", + storedAccountID: storedID) + let replacement = Self.codexVisibleAccount( + id: "email-shaped-id", + workspaceAccountID: "workspace-2", + authFingerprint: "auth-2", + storedAccountID: UUID()) + let workspacePeer = Self.codexVisibleAccount( + id: "workspace-peer", + email: "other@example.com", + workspaceAccountID: "workspace-1", + authFingerprint: "auth-3", + storedAccountID: UUID()) + let ambiguous = Self.codexVisibleAccount( + id: "email-only", + workspaceAccountID: nil, + authFingerprint: nil, + storedAccountID: nil) + let storedBeforeRefresh = Self.codexVisibleAccount( + id: "stored-before", + workspaceAccountID: nil, + authFingerprint: "old-auth", + storedAccountID: storedID) + let storedAfterRefresh = Self.codexVisibleAccount( + id: "stored-after", + workspaceAccountID: nil, + authFingerprint: "new-auth", + storedAccountID: storedID) + + let firstKey = CodexBarCLI.usageCacheAccountKey( + provider: .codex, + account: nil, + codexVisibleAccount: firstProjection) + let reshapedKey = CodexBarCLI.usageCacheAccountKey( + provider: .codex, + account: nil, + codexVisibleAccount: reshapedProjection) + let replacementKey = CodexBarCLI.usageCacheAccountKey( + provider: .codex, + account: nil, + codexVisibleAccount: replacement) + let workspacePeerKey = CodexBarCLI.usageCacheAccountKey( + provider: .codex, + account: nil, + codexVisibleAccount: workspacePeer) + let storedBeforeKey = CodexBarCLI.usageCacheAccountKey( + provider: .codex, + account: nil, + codexVisibleAccount: storedBeforeRefresh) + let storedAfterKey = CodexBarCLI.usageCacheAccountKey( + provider: .codex, + account: nil, + codexVisibleAccount: storedAfterRefresh) + + #expect(firstKey == reshapedKey) + #expect(firstKey != replacementKey) + #expect(firstKey != workspacePeerKey) + #expect(storedBeforeKey == storedAfterKey) + #expect(CodexBarCLI.usageCacheAccountKey( + provider: .codex, + account: nil, + codexVisibleAccount: ambiguous) == nil) + #expect(CodexBarCLI.usageCacheAccountKey( + provider: .antigravity, + account: nil, + codexVisibleAccount: nil) == nil) + } + + @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 prunes expired config token entries`() async throws { + let cache = CLIServeResponseCache() + + _ = await CodexBarCLI.cachedServeResponse( + key: "usage::old-config", + cache: cache, + refreshInterval: 0.001) + { + Self.response(#"[{"provider":"codex","config":"old"}]"#) + } + #expect(await cache.cachedEntryCount() == 1) + + try await Task.sleep(nanoseconds: 20_000_000) + _ = await CodexBarCLI.cachedServeResponse( + key: "usage::new-config", + cache: cache, + refreshInterval: 60) + { + Self.response(#"[{"provider":"codex","config":"new"}]"#) + } + + #expect(await cache.cachedEntryCount() == 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")) + + // Timeout delivery can win the actor race just before the canceled + // source reports completion. A successor must not start in that gap. + for _ in 0..<1000 { + if await cache.operations.snapshot().operationCount == 0 { + break + } + await Task.yield() + } + #expect(await cache.operations.snapshot().operationCount == 0) + + 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 cache serves last good payload when refresh fails`() async { + let cache = CLIServeResponseCache() + let counter = ServeTestCounter() + + let first = await CodexBarCLI.cachedServeResponse( + key: "usage:antigravity", + cache: cache, + refreshInterval: 0.05, + requestTimeout: 1) + { + let call = await counter.increment() + return Self.response("[{\"provider\":\"antigravity\",\"call\":\(call)}]") + } + #expect(first.status == .ok) + + // Let the fresh cache entry expire so the next request re-fetches. + try? await Task.sleep(nanoseconds: 100_000_000) + + let failed = await CodexBarCLI.cachedServeResponse( + key: "usage:antigravity", + cache: cache, + refreshInterval: 0.05, + requestTimeout: 1) + { + _ = await counter.increment() + return Self.response( + "[{\"provider\":\"antigravity\",\"error\":{\"message\":\"transient\"}}]") + } + + // Transient failure is masked by the last good payload. + #expect(failed.status == .ok) + let failedRows = try? Self.jsonRows(failed) + #expect(failedRows?.first?["call"] as? Int == 1) + #expect(await counter.current() == 2) + + try? await Task.sleep(nanoseconds: 100_000_000) + + let recovered = await CodexBarCLI.cachedServeResponse( + key: "usage:antigravity", + cache: cache, + refreshInterval: 0.05, + requestTimeout: 1) + { + let call = await counter.increment() + return Self.response("[{\"provider\":\"antigravity\",\"call\":\(call)}]") + } + + #expect(recovered.status == .ok) + #expect(Self.bodyString(recovered).contains("\"call\":3")) + } + + @Test + func `cost refresh timeout serves the last good payload`() async throws { + let cache = CLIServeResponseCache() + let counter = ServeTestCounter() + + let first = await CodexBarCLI.cachedServeResponse( + key: "cost:", + cache: cache, + refreshInterval: 0.01, + requestTimeout: 1) + { + let call = await counter.increment() + return Self.response("[{\"provider\":\"codex\",\"call\":\(call)}]") + } + try? await Task.sleep(nanoseconds: 30_000_000) + + let timedOut = await CodexBarCLI.cachedServeResponse( + key: "cost:", + cache: cache, + refreshInterval: 0.01, + requestTimeout: 0.01) + { + _ = await counter.increment() + try? await Task.sleep(nanoseconds: 200_000_000) + return Self.response("[{\"provider\":\"codex\",\"call\":2}]") + } + + #expect(timedOut.status == .ok) + let firstRows = try Self.jsonRows(first) + let timedOutRows = try Self.jsonRows(timedOut) + #expect(firstRows.first?["provider"] as? String == "codex") + #expect(timedOutRows.first?["provider"] as? String == "codex") + #expect(firstRows.first?["call"] as? Int == 1) + #expect(timedOutRows.first?["call"] as? Int == 1) + #expect(await counter.current() == 2) + } + + @Test + func `cost refresh keeps fresh providers while replacing timed out rows`() async throws { + let cache = CLIServeResponseCache() + + _ = await CodexBarCLI.cachedServeResponse( + key: "cost:", + cache: cache, + refreshInterval: 0.01, + requestTimeout: 1) + { + Self.response(""" + [ + {"provider":"codex","call":1}, + {"provider":"claude","call":1} + ] + """) + } + try? await Task.sleep(nanoseconds: 30_000_000) + + let partial = await CodexBarCLI.cachedServeResponse( + key: "cost:", + cache: cache, + refreshInterval: 0.01, + requestTimeout: 1) + { + Self.response(""" + [ + {"provider":"codex","call":2}, + {"provider":"claude","error":{"message":"claude cost refresh timed out"}} + ] + """) + } + let partialRows = try Self.jsonRows(partial) + #expect(Self.row(partialRows, provider: "codex")?["call"] as? Int == 2) + #expect(Self.row(partialRows, provider: "claude")?["call"] as? Int == 1) + #expect(partialRows.allSatisfy { $0["error"] == nil }) + + try? await Task.sleep(nanoseconds: 30_000_000) + let timedOut = await CodexBarCLI.cachedServeResponse( + key: "cost:", + cache: cache, + refreshInterval: 0.01, + requestTimeout: 0.01) + { + try? await Task.sleep(nanoseconds: 200_000_000) + return Self.response(#"[{"provider":"codex","call":3}]"#) + } + let timeoutRows = try Self.jsonRows(timedOut) + #expect(Self.row(timeoutRows, provider: "codex")?["call"] as? Int == 2) + #expect(Self.row(timeoutRows, provider: "claude")?["call"] as? Int == 1) + } + + @Test + func `serve cache replaces only failed provider account rows`() async throws { + let cache = CLIServeResponseCache() + + _ = await CodexBarCLI.cachedServeResponse( + key: "usage:", + cache: cache, + refreshInterval: 0.05, + requestTimeout: 1) + { + Self.response(""" + [ + {"provider":"codex","account":"personal","call":1}, + {"provider":"antigravity","account":"work","call":1}, + {"provider":"antigravity","account":"personal","call":1} + ] + """) + } + + try? await Task.sleep(nanoseconds: 100_000_000) + + let refreshed = await CodexBarCLI.cachedServeResponse( + key: "usage:", + cache: cache, + refreshInterval: 0.05, + requestTimeout: 1) + { + Self.response(""" + [ + {"provider":"codex","account":"personal","call":2}, + {"provider":"antigravity","account":"work","error":{"message":"transient"}}, + {"provider":"antigravity","account":"personal","call":2} + ] + """) + } + let rows = try Self.jsonRows(refreshed) + + #expect(Self.row(rows, provider: "codex", account: "personal")?["call"] as? Int == 2) + #expect(Self.row(rows, provider: "antigravity", account: "work")?["call"] as? Int == 1) + #expect(Self.row(rows, provider: "antigravity", account: "personal")?["call"] as? Int == 2) + #expect(rows.allSatisfy { $0["error"] == nil }) + } + + @Test + func `serve cache retains newer per-row success across all-error refresh`() async throws { + let cache = CLIServeResponseCache() + + _ = await CodexBarCLI.cachedServeResponse( + key: "usage:", + cache: cache, + refreshInterval: 0.05, + requestTimeout: 1) + { + Self.response(""" + [ + {"provider":"codex","call":1}, + {"provider":"antigravity","call":1} + ] + """) + } + try? await Task.sleep(nanoseconds: 100_000_000) + + _ = await CodexBarCLI.cachedServeResponse( + key: "usage:", + cache: cache, + refreshInterval: 0.05, + requestTimeout: 1) + { + Self.response(""" + [ + {"provider":"codex","call":2}, + {"provider":"antigravity","error":{"message":"transient"}} + ] + """) + } + try? await Task.sleep(nanoseconds: 100_000_000) + + let failed = await CodexBarCLI.cachedServeResponse( + key: "usage:", + cache: cache, + refreshInterval: 0.05, + requestTimeout: 1) + { + Self.response(""" + [ + {"provider":"codex","error":{"message":"transient"}}, + {"provider":"antigravity","error":{"message":"transient"}} + ] + """) + } + let rows = try Self.jsonRows(failed) + + #expect(Self.row(rows, provider: "codex")?["call"] as? Int == 2) + #expect(Self.row(rows, provider: "antigravity")?["call"] as? Int == 1) + } + + @Test + func `serve cache fails closed on timeout after merged rows`() async { + let cache = CLIServeResponseCache() + + _ = await CodexBarCLI.cachedServeResponse( + key: "usage:", + cache: cache, + refreshInterval: 0.05, + requestTimeout: 1) + { + Self.response(""" + [ + {"provider":"codex","call":1}, + {"provider":"antigravity","call":1} + ] + """) + } + try? await Task.sleep(nanoseconds: 100_000_000) + + _ = await CodexBarCLI.cachedServeResponse( + key: "usage:", + cache: cache, + refreshInterval: 0.05, + requestTimeout: 1) + { + Self.response(""" + [ + {"provider":"codex","call":2}, + {"provider":"antigravity","error":{"message":"transient"}} + ] + """) + } + try? await Task.sleep(nanoseconds: 100_000_000) + + let timedOut = await CodexBarCLI.cachedServeResponse( + key: "usage:", + cache: cache, + refreshInterval: 0.05, + requestTimeout: 0.01) + { + try? await Task.sleep(nanoseconds: 200_000_000) + return Self.response("[]") + } + #expect(timedOut.status == .gatewayTimeout) + #expect(!Self.bodyString(timedOut).contains("\"call\":1")) + #expect(!Self.bodyString(timedOut).contains("\"call\":2")) + } + + @Test + func `serve cache fails closed on timeout after a partial refresh`() async { + let cache = CLIServeResponseCache() + + _ = await CodexBarCLI.cachedServeResponse( + key: "usage:", + cache: cache, + refreshInterval: 0.05, + requestTimeout: 1) + { + Self.response(#"[{"provider":"codex","call":1}]"#) + } + try? await Task.sleep(nanoseconds: 100_000_000) + + _ = await CodexBarCLI.cachedServeResponse( + key: "usage:", + cache: cache, + refreshInterval: 0.05, + requestTimeout: 1) + { + Self.response(""" + [ + {"provider":"codex","call":2}, + {"provider":"antigravity","error":{"message":"transient"}} + ] + """) + } + try? await Task.sleep(nanoseconds: 100_000_000) + + let timedOut = await CodexBarCLI.cachedServeResponse( + key: "usage:", + cache: cache, + refreshInterval: 0.05, + requestTimeout: 0.01) + { + try? await Task.sleep(nanoseconds: 200_000_000) + return Self.response("[]") + } + #expect(timedOut.status == .gatewayTimeout) + #expect(!Self.bodyString(timedOut).contains("\"call\":2")) + #expect(!Self.bodyString(timedOut).contains("antigravity")) + } + + @Test + func `serve cache does not reconstruct usage rows after timeout`() async { + let cache = CLIServeResponseCache() + let policy = CLIServeResponseCache.CachePolicy(ttl: 0, staleTTL: 10) + let startedAt = Date(timeIntervalSince1970: 1000) + + _ = await cache.completeFetch( + Self.response( + """ + [ + {"provider":"codex","call":1}, + {"provider":"antigravity","call":1} + ] + """), + for: "usage:", + policy: policy, + now: startedAt, + shouldCache: true) + + let partialAt = startedAt.addingTimeInterval(9) + _ = await cache.completeFetch( + Self.response(""" + [ + {"provider":"codex","call":2}, + {"provider":"antigravity","error":{"message":"transient"}} + ] + """), + for: "usage:", + policy: policy, + now: partialAt, + shouldCache: false) + + let timeoutAt = startedAt.addingTimeInterval(11) + let timedOut = await cache.completeFetch( + Self.response(#"{"error":"request timed out"}"#, status: .gatewayTimeout), + for: "usage:", + policy: policy, + now: timeoutAt, + shouldCache: false) + #expect(timedOut.status == .gatewayTimeout) + #expect(Self.bodyString(timedOut).contains("request timed out")) + #expect(!Self.bodyString(timedOut).contains("\"call\":2")) + } + + @Test + func `serve cache preserves newer row when another failed row has no fallback`() async throws { + let cache = CLIServeResponseCache() + + _ = await CodexBarCLI.cachedServeResponse( + key: "usage:", + cache: cache, + refreshInterval: 0.05, + requestTimeout: 1) + { + Self.response(#"[{"provider":"codex","call":1}]"#) + } + try? await Task.sleep(nanoseconds: 100_000_000) + + _ = await CodexBarCLI.cachedServeResponse( + key: "usage:", + cache: cache, + refreshInterval: 0.05, + requestTimeout: 1) + { + Self.response(""" + [ + {"provider":"codex","call":2}, + {"provider":"antigravity","error":{"message":"transient"}} + ] + """) + } + try? await Task.sleep(nanoseconds: 100_000_000) + + let failed = await CodexBarCLI.cachedServeResponse( + key: "usage:", + cache: cache, + refreshInterval: 0.05, + requestTimeout: 1) + { + Self.response(""" + [ + {"provider":"codex","error":{"message":"transient"}}, + {"provider":"antigravity","error":{"message":"transient"}} + ] + """) + } + let rows = try Self.jsonRows(failed) + + #expect(Self.row(rows, provider: "codex")?["call"] as? Int == 2) + #expect(Self.row(rows, provider: "antigravity")?["error"] != nil) + } + + @Test + func `serve cache keeps fresh rows when a failed row has no stale match`() async throws { + let cache = CLIServeResponseCache() + + _ = await CodexBarCLI.cachedServeResponse( + key: "usage:", + cache: cache, + refreshInterval: 0.05, + requestTimeout: 1) + { + Self.response(#"[{"provider":"codex","account":"personal","call":1}]"#) + } + + try? await Task.sleep(nanoseconds: 100_000_000) + + let refreshed = await CodexBarCLI.cachedServeResponse( + key: "usage:", + cache: cache, + refreshInterval: 0.05, + requestTimeout: 1) + { + Self.response(""" + [ + {"provider":"codex","account":"personal","call":2}, + {"provider":"antigravity","account":"work","error":{"message":"transient"}} + ] + """) + } + let rows = try Self.jsonRows(refreshed) + + #expect(Self.row(rows, provider: "codex", account: "personal")?["call"] as? Int == 2) + #expect(Self.row(rows, provider: "antigravity", account: "work")?["error"] != nil) + } + + @Test + func `serve cache does not merge duplicate provider account labels`() async throws { + let cache = CLIServeResponseCache() + + _ = await CodexBarCLI.cachedServeResponse( + key: "usage:", + cache: cache, + refreshInterval: 0.05, + requestTimeout: 1) + { + Self.response(""" + [ + {"provider":"codex","account":"shared","slot":"first","call":1}, + {"provider":"codex","account":"shared","slot":"second","call":1} + ] + """) + } + + try? await Task.sleep(nanoseconds: 100_000_000) + + let refreshed = await CodexBarCLI.cachedServeResponse( + key: "usage:", + cache: cache, + refreshInterval: 0.05, + requestTimeout: 1) + { + Self.response(""" + [ + { + "provider":"codex", + "account":"shared", + "slot":"first", + "error":{"message":"transient"} + }, + {"provider":"codex","account":"shared","slot":"second","call":2} + ] + """) + } + let rows = try Self.jsonRows(refreshed) + let first = rows.first { $0["slot"] as? String == "first" } + let second = rows.first { $0["slot"] as? String == "second" } + + #expect(first?["error"] != nil) + #expect(second?["call"] as? Int == 2) + } + + @Test + func `serve cache follows stable account identity across label changes`() async throws { + let cache = CLIServeResponseCache() + + _ = await CodexBarCLI.cachedServeResponse( + key: "usage:", + cache: cache, + refreshInterval: 0.05, + requestTimeout: 1) + { + Self.response( + #"[{"provider":"codex","account":"old label","call":1}]"#, + usageCacheKeys: ["account-1"]) + } + try? await Task.sleep(nanoseconds: 100_000_000) + + let failed = await CodexBarCLI.cachedServeResponse( + key: "usage:", + cache: cache, + refreshInterval: 0.05, + requestTimeout: 1) + { + Self.response( + #"[{"provider":"codex","account":"new label","error":{"message":"transient"}}]"#, + usageCacheKeys: ["account-1"]) + } + let row = try #require(Self.jsonRows(failed).first) + + #expect(row["account"] as? String == "old label") + #expect(row["call"] as? Int == 1) + } + + @Test + func `serve cache does not reuse a label for a different account identity`() async throws { + let cache = CLIServeResponseCache() + + _ = await CodexBarCLI.cachedServeResponse( + key: "usage:", + cache: cache, + refreshInterval: 0.05, + requestTimeout: 1) + { + Self.response( + #"[{"provider":"codex","account":"shared","call":1}]"#, + usageCacheKeys: ["account-1"]) + } + try? await Task.sleep(nanoseconds: 100_000_000) + + let refreshed = await CodexBarCLI.cachedServeResponse( + key: "usage:", + cache: cache, + refreshInterval: 0.05, + requestTimeout: 1) + { + Self.response( + """ + [ + {"provider":"codex","account":"shared","error":{"message":"transient"}}, + {"provider":"antigravity","account":"work","call":2} + ] + """, + usageCacheKeys: ["account-2", "account-3"]) + } + let rows = try Self.jsonRows(refreshed) + + #expect(Self.row(rows, provider: "codex", account: "shared")?["error"] != nil) + #expect(Self.row(rows, provider: "antigravity", account: "work")?["call"] as? Int == 2) + } + + @Test + func `serve cache does not use whole fallback after an account switch`() async throws { + let cache = CLIServeResponseCache() + + _ = await CodexBarCLI.cachedServeResponse( + key: "usage:", + cache: cache, + refreshInterval: 0.05, + requestTimeout: 1) + { + Self.response( + #"[{"provider":"codex","account":"shared","call":1}]"#, + usageCacheKeys: ["account-1"]) + } + try? await Task.sleep(nanoseconds: 100_000_000) + + let failed = await CodexBarCLI.cachedServeResponse( + key: "usage:", + cache: cache, + refreshInterval: 0.05, + requestTimeout: 1) + { + Self.response( + #"[{"provider":"codex","account":"shared","error":{"message":"transient"}}]"#, + usageCacheKeys: ["account-2"]) + } + let row = try #require(Self.jsonRows(failed).first) + + #expect(row["call"] == nil) + #expect(row["error"] != nil) + + let timedOut = await CodexBarCLI.cachedServeResponse( + key: "usage:", + cache: cache, + refreshInterval: 0.05, + requestTimeout: 0.01) + { + try? await Task.sleep(nanoseconds: 200_000_000) + return Self.response( + #"[{"provider":"codex","account":"shared","call":3}]"#, + usageCacheKeys: ["account-2"]) + } + + #expect(timedOut.status == .gatewayTimeout) + #expect(!Self.bodyString(timedOut).contains("\"call\":1")) + } + + @Test + func `serve cache prunes accounts absent from a successful snapshot`() async throws { + let cache = CLIServeResponseCache() + + _ = await CodexBarCLI.cachedServeResponse( + key: "usage:", + cache: cache, + refreshInterval: 0.05, + requestTimeout: 1) + { + Self.response(#"[{"provider":"codex","account":"shared","call":1}]"#) + } + try? await Task.sleep(nanoseconds: 100_000_000) + + _ = await CodexBarCLI.cachedServeResponse( + key: "usage:", + cache: cache, + refreshInterval: 0.05, + requestTimeout: 1) + { + Self.response(#"[{"provider":"antigravity","account":"work","call":2}]"#) + } + try? await Task.sleep(nanoseconds: 100_000_000) + + let refreshed = await CodexBarCLI.cachedServeResponse( + key: "usage:", + cache: cache, + refreshInterval: 0.05, + requestTimeout: 1) + { + Self.response(""" + [ + {"provider":"codex","account":"shared","error":{"message":"transient"}}, + {"provider":"antigravity","account":"work","call":3} + ] + """) + } + let rows = try Self.jsonRows(refreshed) + + #expect(Self.row(rows, provider: "codex", account: "shared")?["error"] != nil) + #expect(Self.row(rows, provider: "antigravity", account: "work")?["call"] as? Int == 3) + } + + @Test + func `serve cache fails closed when all-error rows have ambiguous identities`() async throws { + let cache = CLIServeResponseCache() + + _ = await CodexBarCLI.cachedServeResponse( + key: "usage:", + cache: cache, + refreshInterval: 0.05, + requestTimeout: 1) + { + Self.response( + """ + [ + {"provider":"codex","account":"shared","slot":"first","call":1}, + {"provider":"codex","account":"shared","slot":"second","call":1}, + {"provider":"antigravity","account":"work","call":1} + ] + """, + usageCacheKeys: [nil, nil, nil]) + } + try? await Task.sleep(nanoseconds: 100_000_000) + + let failed = await CodexBarCLI.cachedServeResponse( + key: "usage:", + cache: cache, + refreshInterval: 0.05, + requestTimeout: 1) + { + Self.response( + """ + [ + { + "provider":"codex", + "account":"shared", + "slot":"first", + "error":{"message":"transient"} + }, + { + "provider":"codex", + "account":"shared", + "slot":"second", + "error":{"message":"transient"} + }, + {"provider":"antigravity","account":"work","error":{"message":"transient"}} + ] + """, + usageCacheKeys: [nil, nil, nil]) + } + let rows = try Self.jsonRows(failed) + + #expect(rows.count == 3) + #expect(rows.allSatisfy { $0["call"] == nil }) + #expect(rows.allSatisfy { $0["error"] != nil }) + } + + @Test + func `serve cache does not whole-fallback ambiguous usage after timeout`() async { + let cache = CLIServeResponseCache() + + _ = await CodexBarCLI.cachedServeResponse( + key: "usage:", + cache: cache, + refreshInterval: 0.05, + requestTimeout: 1) + { + Self.response( + #"[{"provider":"antigravity","account":"first@example.com","call":1}]"#, + usageCacheKeys: [nil]) + } + try? await Task.sleep(nanoseconds: 100_000_000) + + let timedOut = await CodexBarCLI.cachedServeResponse( + key: "usage:", + cache: cache, + refreshInterval: 0.05, + requestTimeout: 0.01) + { + try? await Task.sleep(nanoseconds: 200_000_000) + return Self.response( + #"[{"provider":"antigravity","account":"second@example.com","call":2}]"#, + usageCacheKeys: [nil]) + } + + #expect(timedOut.status == .gatewayTimeout) + #expect(!Self.bodyString(timedOut).contains("first@example.com")) + #expect(!Self.bodyString(timedOut).contains("\"call\":1")) + } + + @Test + func `serve cache mixed identities do not enable timeout fallback`() async { + let cache = CLIServeResponseCache() + + _ = await CodexBarCLI.cachedServeResponse( + key: "usage:", + cache: cache, + refreshInterval: 0.05, + requestTimeout: 1) + { + Self.response( + """ + [ + {"provider":"codex","account":"stable@example.com","call":1}, + {"provider":"antigravity","account":"ambient@example.com","call":1} + ] + """, + usageCacheKeys: ["account-1", nil]) + } + try? await Task.sleep(nanoseconds: 100_000_000) + + let timedOut = await CodexBarCLI.cachedServeResponse( + key: "usage:", + cache: cache, + refreshInterval: 0.05, + requestTimeout: 0.01) + { + try? await Task.sleep(nanoseconds: 200_000_000) + return Self.response("[]", usageCacheKeys: []) + } + #expect(timedOut.status == .gatewayTimeout) + #expect(!Self.bodyString(timedOut).contains("stable@example.com")) + #expect(!Self.bodyString(timedOut).contains("ambient@example.com")) + } + + @Test + func `serve stale ttl is bounded and disabled without caching`() { + #expect(CodexBarCLI.serveStaleTTL(refreshInterval: 0) == 0) + #expect(CodexBarCLI.serveStaleTTL(refreshInterval: 1) == 300) + #expect(CodexBarCLI.serveStaleTTL(refreshInterval: 60) == 600) + #expect(CodexBarCLI.serveStaleTTL(refreshInterval: 1800) == 3600) + #expect(CodexBarCLI.serveStaleTTL(refreshInterval: 86401) == 3600) + #expect(CodexBarCLI.serveStaleTTL(refreshInterval: .infinity) == 3600) + } + + @Test + func `serve cache prunes stale variants from old configurations`() async { + let cache = CLIServeResponseCache() + let startedAt = Date(timeIntervalSince1970: 1000) + let policy = CLIServeResponseCache.CachePolicy( + ttl: 0, + staleTTL: CLIServeResponseCache.maximumStaleTTL) + + _ = await cache.completeFetch( + Self.response(#"{"status":"ok"}"#), + for: "config:old", + policy: policy, + now: startedAt, + shouldCache: true) + + _ = await cache.completeFetch( + Self.response( + #"[{"provider":"codex","call":1}]"#, + usageCacheKeys: ["account-1"]), + for: "usage:old", + policy: policy, + now: startedAt, + shouldCache: true) + #expect(await cache.cachedStaleVariantCount() == 2) + + let expiredAt = startedAt.addingTimeInterval(CLIServeResponseCache.maximumStaleTTL + 1) + _ = await cache.cachedResponse(for: "config:new", now: expiredAt) + #expect(await cache.cachedStaleVariantCount() == 0) + _ = await cache.completeFetch( + Self.response(#"{"status":"ok"}"#), + for: "config:new", + policy: policy, + now: expiredAt, + shouldCache: false) + } + + @Test + func `serve helper idle window outlives the refresh cadence`() { + #expect(CodexBarCLI.serveCLISessionIdleWindow(refreshInterval: 0) == 180) + #expect(CodexBarCLI.serveCLISessionIdleWindow(refreshInterval: 60) == 180) + #expect(CodexBarCLI.serveCLISessionIdleWindow(refreshInterval: 300) == 360) + } + + @Test + func `local HTTP server stops its accept loop`() async throws { + let listening = ServeListeningSignal() + let server = CLILocalHTTPServer(host: "127.0.0.1", port: 0) { _ in + Self.response(#"{"status":"ok"}"#) + } + let task = Task { + try await server.run { + listening.signal() + } + } + + await listening.wait() + server.stop() + try await task.value + } + + @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() } - private static func expectParseFailure(raw: String, _ expected: CLILocalHTTPRequestParseError) { - switch CLILocalHTTPRequest.parse(Data(raw.utf8)) { + private static func expectParseFailure( + raw: String, + _ expected: CLILocalHTTPRequestParseError, + allowedHosts: CLILocalHTTPAllowedHosts = .loopbackOnly) + { + switch CLILocalHTTPRequest.parse(Data(raw.utf8), allowedHosts: allowedHosts) { case .success: Issue.record("Expected \(expected)") case let .failure(error): #expect(error == expected) } } + + private static func response( + _ body: String, + status: CLIHTTPStatus = .ok, + usageCacheKeys: [String?]? = nil) -> CLILocalHTTPResponse + { + let data = Data(body.utf8) + return CLILocalHTTPResponse( + status: status, + body: data, + usageCacheKeys: usageCacheKeys ?? Self.syntheticUsageCacheKeys(data)) + } + + private static func bodyString(_ response: CLILocalHTTPResponse) -> String { + String(data: response.body, encoding: .utf8) ?? "" + } + + private static func jsonRows(_ response: CLILocalHTTPResponse) throws -> [[String: Any]] { + try #require(JSONSerialization.jsonObject(with: response.body) as? [[String: Any]]) + } + + private static func row( + _ rows: [[String: Any]], + provider: String, + account: String) -> [String: Any]? + { + rows.first { + $0["provider"] as? String == provider + && $0["account"] as? String == account + } + } + + private static func row(_ rows: [[String: Any]], provider: String) -> [String: Any]? { + rows.first { $0["provider"] as? String == provider } + } + + private static func syntheticUsageCacheKeys(_ data: Data) -> [String?]? { + guard let rows = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]] else { return nil } + return rows.map { row in + guard let provider = row["provider"] as? String else { return nil } + let account = row["account"] as? String ?? "default" + return "test:\(provider):\(account)" + } + } + + private static func codexVisibleAccount( + id: String, + email: String = "user@example.com", + workspaceAccountID: String?, + authFingerprint: String?, + storedAccountID: UUID?) -> CodexVisibleAccount + { + CodexVisibleAccount( + id: id, + email: email, + workspaceAccountID: workspaceAccountID, + authFingerprint: authFingerprint, + storedAccountID: storedAccountID, + selectionSource: .liveSystem, + isActive: true, + isLive: true, + canReauthenticate: true, + canRemove: false) + } +} + +private actor ServeTestCounter { + private var value = 0 + + func increment() -> Int { + self.value += 1 + return self.value + } + + func current() -> Int { + self.value + } +} + +private final class ServeListeningSignal: @unchecked Sendable { + private let lock = NSLock() + private var continuation: CheckedContinuation? + private var isSignaled = false + + func signal() { + let continuation = self.lock.withLock { + self.isSignaled = true + defer { self.continuation = nil } + return self.continuation + } + continuation?.resume() + } + + func wait() async { + await withCheckedContinuation { continuation in + let shouldResume = self.lock.withLock { + guard !self.isSignaled else { return true } + self.continuation = continuation + return false + } + if shouldResume { + continuation.resume() + } + } + } } diff --git a/Tests/CodexBarTests/CLIServeTimeoutTests.swift b/Tests/CodexBarTests/CLIServeTimeoutTests.swift new file mode 100644 index 0000000000..762c7d2637 --- /dev/null +++ b/Tests/CodexBarTests/CLIServeTimeoutTests.swift @@ -0,0 +1,826 @@ +import Foundation +import Testing +@testable import CodexBarCLI +@testable import CodexBarCore + +struct CLIServeTimeoutTests { + @Test + func `serve cost keeps pricing refresh outside the request deadline`() { + #expect(CodexBarCLI.serveCostRefreshesPricingInBackground) + } + + @Test + func `serve deadlines clamp once from request entry`() throws { + #expect(CodexBarCLI.clampedServeRequestTimeout(.greatestFiniteMagnitude) == 86400) + #expect(CodexBarCLI.clampedServeRequestTimeout(1e308) == 86400) + #expect(CodexBarCLI.clampedServeRequestTimeout(-5) == 0) + + let startedAt = ContinuousClock().now + let deadline = try #require(CodexBarCLI.serveRequestDeadline( + startedAt: startedAt, + requestTimeout: .greatestFiniteMagnitude)) + #expect(startedAt.duration(to: deadline) == .seconds(86400)) + #expect(CodexBarCLI.serveRequestDeadline(startedAt: startedAt, requestTimeout: 0) == nil) + + let requestDeadline = startedAt.advanced(by: .seconds(40)) + #expect(CodexBarCLI.serveCostProviderDeadline( + startedAt: startedAt, + providerTimeout: 30, + requestDeadline: requestDeadline) == startedAt.advanced(by: .seconds(30))) + #expect(CodexBarCLI.serveCostProviderDeadline( + startedAt: startedAt.advanced(by: .seconds(20)), + providerTimeout: 30, + requestDeadline: requestDeadline) == requestDeadline) + #expect(CodexBarCLI.serveCostProviderDeadline( + startedAt: startedAt, + providerTimeout: nil, + requestDeadline: nil) == nil) + } + + @Test + func `timed out source stays owned and later requests never overlap`() async { + let clock = ServeManualDeadlineClock() + let gate = ServeFetchGate() + let coordinator: CLIServeOperationCoordinator = self.makeCoordinator(clock: clock) + let deadline = clock.now().advanced(by: .seconds(30)) + + let first = Task { + await coordinator.value( + for: "usage:", + fingerprint: "config-a", + deadline: deadline, + timeoutValue: -1) + { + await gate.run(1) + } + } + await gate.waitForStarts(1) + await clock.waitForPendingSleeps(1) + await clock.fireAll() + + #expect(await first.value == -1) + #expect(await coordinator.snapshot().operationCount == 1) + #expect(await coordinator.snapshot().timerCount == 0) + + let later = (0..<4).map { _ in + Task { + await coordinator.value( + for: "usage:", + fingerprint: "config-a", + deadline: deadline.advanced(by: .seconds(30)), + timeoutValue: -1) + { + await gate.run(2) + } + } + } + await self.waitForOperationCount(2, coordinator: coordinator) + await clock.waitForPendingSleeps(1) + await clock.fireAll() + for task in later { + #expect(await task.value == -1) + } + #expect(await gate.startCount() == 1) + #expect(await gate.peakCount() == 1) + + await gate.releaseAll() + await gate.waitForActive(0) + await self.waitForOperationCount(0, coordinator: coordinator) + } + + @Test + func `earlier follower tightens the shared absolute budget`() async { + let clock = ServeManualDeadlineClock() + let gate = ServeFetchGate() + let coordinator: CLIServeOperationCoordinator = self.makeCoordinator(clock: clock) + let firstDeadline = clock.now().advanced(by: .seconds(30)) + + let first = Task { + await coordinator.value( + for: "cost:", + fingerprint: "config-a", + deadline: firstDeadline, + timeoutValue: -1) + { + await gate.run(1) + } + } + await gate.waitForStarts(1) + await clock.waitForPendingSleeps(1) + + let shorterFollower = Task { + await coordinator.value( + for: "cost:", + fingerprint: "config-a", + deadline: firstDeadline.advanced(by: .seconds(-1)), + timeoutValue: -2) + { + await gate.run(2) + } + } + await self.waitForWaiterCount(2, coordinator: coordinator) + await clock.waitForCancellations(1) + await clock.waitForPendingSleeps(1) + clock.advance(by: .seconds(29)) + await clock.fireAll() + + #expect(await first.value == -1) + #expect(await shorterFollower.value == -2) + #expect(await gate.startCount() == 1) + + await gate.releaseAll() + await gate.waitForActive(0) + await self.waitForOperationCount(0, coordinator: coordinator) + #expect(await coordinator.snapshot().operationCount == 0) + } + + @Test + func `source completing at an overdue deadline cannot beat a delayed timer`() async { + let clock = ServeManualDeadlineClock() + let gate = ServeFetchGate() + let acceptance = ServeAcceptanceProbe() + let coordinator: CLIServeOperationCoordinator = self.makeCoordinator(clock: clock) + + let result = Task { + await coordinator.value( + for: "usage:", + fingerprint: "config-a", + deadline: clock.now().advanced(by: .seconds(30)), + timeoutValue: -1, + accept: { await acceptance.accept($0) }, + operation: { await gate.run(7) }) + } + await gate.waitForStarts(1) + await clock.waitForPendingSleeps(1) + clock.advance(by: .seconds(30)) + await gate.releaseAll() + + #expect(await result.value == -1) + #expect(await acceptance.callCount() == 0) + await clock.waitForCancellations(1) + await self.waitForOperationCount(0, coordinator: coordinator) + } + + @Test + func `shared deadline returns each waiters own timeout value`() async { + let clock = ServeManualDeadlineClock() + let gate = ServeFetchGate() + let coordinator: CLIServeOperationCoordinator = self.makeCoordinator(clock: clock) + let deadline = clock.now().advanced(by: .seconds(30)) + + let leader = Task { + await coordinator.value( + for: "usage:", + fingerprint: "config-a", + deadline: deadline, + timeoutValue: -1) + { + await gate.run(1) + } + } + await gate.waitForStarts(1) + await clock.waitForPendingSleeps(1) + let follower = Task { + await coordinator.value( + for: "usage:", + fingerprint: "config-a", + deadline: deadline.advanced(by: .seconds(1)), + timeoutValue: -2) + { + await gate.run(2) + } + } + await self.waitForWaiterCount(2, coordinator: coordinator) + await clock.fireAll() + + #expect(await leader.value == -1) + #expect(await follower.value == -2) + await gate.releaseAll() + await gate.waitForActive(0) + await self.waitForOperationCount(0, coordinator: coordinator) + } + + @Test + func `finite follower fails closed behind deadline free source`() async { + let gate = ServeFetchGate() + let coordinator = CLIServeOperationCoordinator() + + let first = Task { + await coordinator.value( + for: "usage:", + fingerprint: "config-a", + deadline: nil, + timeoutValue: -1) + { + await gate.run(1) + } + } + await gate.waitForStarts(1) + + let follower = await coordinator.value( + for: "usage:", + fingerprint: "config-a", + deadline: ContinuousClock().now.advanced(by: .seconds(30)), + timeoutValue: -2) + { + await gate.run(2) + } + #expect(follower == -2) + #expect(await gate.startCount() == 1) + + await gate.releaseAll() + #expect(await first.value == 1) + } + + @Test + func `waiter cancellation unregisters and last waiter cancels source`() async { + let clock = ServeManualDeadlineClock() + let gate = ServeFetchGate() + let coordinator: CLIServeOperationCoordinator = self.makeCoordinator(clock: clock) + let deadline = clock.now().advanced(by: .seconds(30)) + + let leader = Task { + await coordinator.value( + for: "usage:", + fingerprint: "config-a", + deadline: deadline, + timeoutValue: -1) + { + await gate.run(1) + } + } + await gate.waitForStarts(1) + await clock.waitForPendingSleeps(1) + let follower = Task { + await coordinator.value( + for: "usage:", + fingerprint: "config-a", + deadline: deadline.advanced(by: .seconds(1)), + timeoutValue: -2) + { + await gate.run(2) + } + } + await self.waitForWaiterCount(2, coordinator: coordinator) + follower.cancel() + #expect(await follower.value == -2) + await self.waitForWaiterCount(1, coordinator: coordinator) + #expect(await coordinator.snapshot().operationCount == 1) + + leader.cancel() + #expect(await leader.value == -1) + await clock.waitForCancellations(1) + let retained = await coordinator.snapshot() + #expect(retained.operationCount == 1) + #expect(retained.waiterCount == 0) + #expect(retained.timerCount == 0) + + await gate.releaseAll() + await gate.waitForActive(0) + await self.waitForOperationCount(0, coordinator: coordinator) + } + + @Test + func `source completion cancels the operation timer`() async { + let clock = ServeManualDeadlineClock() + let gate = ServeFetchGate() + let coordinator: CLIServeOperationCoordinator = self.makeCoordinator(clock: clock) + + let result = Task { + await coordinator.value( + for: "usage:", + fingerprint: "config-a", + deadline: clock.now().advanced(by: .seconds(30)), + timeoutValue: -1) + { + await gate.run(7) + } + } + await gate.waitForStarts(1) + await clock.waitForPendingSleeps(1) + await gate.releaseAll() + + #expect(await result.value == 7) + await clock.waitForCancellations(1) + #expect(await clock.pendingSleepCount() == 0) + #expect(await coordinator.snapshot() == .init( + operationCount: 0, + waiterCount: 0, + timerCount: 0, + isShutDown: false)) + } + + @Test + func `accepted value stays owned through asynchronous commit`() async { + let source = ServeFetchGate() + let commit = ServeFetchGate() + let coordinator = CLIServeOperationCoordinator() + await source.releaseAll() + + let first = Task { + await coordinator.value( + for: "usage:", + fingerprint: "config-a", + deadline: nil, + timeoutValue: -1, + accept: { await commit.run($0) }, + operation: { await source.run(1) }) + } + await commit.waitForStarts(1) + let follower = Task { + await coordinator.value( + for: "usage:", + fingerprint: "config-a", + deadline: nil, + timeoutValue: -2, + accept: { await commit.run($0) }, + operation: { await source.run(2) }) + } + await self.waitForWaiterCount(2, coordinator: coordinator) + + #expect(await source.startCount() == 1) + #expect(await coordinator.snapshot().operationCount == 1) + await commit.releaseAll() + #expect(await first.value == 1) + #expect(await follower.value == 1) + #expect(await source.startCount() == 1) + await self.waitForOperationCount(0, coordinator: coordinator) + } + + @Test + func `earlier finite follower fails closed during accepted commit`() async { + let source = ServeFetchGate() + let commit = ServeFetchGate() + let coordinator = CLIServeOperationCoordinator() + let leaderDeadline = ContinuousClock().now.advanced(by: .seconds(30)) + await source.releaseAll() + + let leader = Task { + await coordinator.value( + for: "usage:", + fingerprint: "config-a", + deadline: leaderDeadline, + timeoutValue: -1, + accept: { await commit.run($0) }, + operation: { await source.run(1) }) + } + await commit.waitForStarts(1) + let follower = await coordinator.value( + for: "usage:", + fingerprint: "config-a", + deadline: leaderDeadline.advanced(by: .seconds(-1)), + timeoutValue: -2) + { + await source.run(2) + } + + #expect(follower == -2) + #expect(await source.startCount() == 1) + let accepting = await coordinator.snapshot() + #expect(accepting.waiterCount == 1) + #expect(accepting.timerCount == 0) + await commit.releaseAll() + #expect(await leader.value == 1) + await self.waitForOperationCount(0, coordinator: coordinator) + } + + @Test + func `config change queues a nonoverlapping successor without a deadline`() async { + let gate = ServeFetchGate() + let coordinator = CLIServeOperationCoordinator() + + let old = Task { + await coordinator.value( + for: "usage:", + fingerprint: "config-a", + deadline: nil, + timeoutValue: -1) + { + await gate.run(1) + } + } + await gate.waitForStarts(1) + + let successor = Task { + await coordinator.value( + for: "usage:", + fingerprint: "config-b", + deadline: nil, + timeoutValue: -2) + { + await gate.run(2) + } + } + await self.waitForOperationCount(2, coordinator: coordinator) + #expect(await gate.startCount() == 1) + #expect(await gate.peakCount() == 1) + + await gate.releaseAll() + #expect(await old.value == 1) + #expect(await successor.value == 2) + await gate.waitForActive(0) + await self.waitForOperationCount(0, coordinator: coordinator) + #expect(await gate.startCount() == 2) + #expect(await gate.peakCount() == 1) + } + + @Test + func `shutdown cancels owned work and rejects new operations`() async { + let clock = ServeManualDeadlineClock() + let gate = ServeFetchGate() + let coordinator: CLIServeOperationCoordinator = self.makeCoordinator(clock: clock) + + let active = Task { + await coordinator.value( + for: "usage:", + fingerprint: "config-a", + deadline: clock.now().advanced(by: .seconds(30)), + timeoutValue: -1) + { + await gate.run(1) + } + } + await gate.waitForStarts(1) + await clock.waitForPendingSleeps(1) + await coordinator.shutdown() + + #expect(await active.value == -1) + await clock.waitForCancellations(1) + let rejected = await coordinator.value( + for: "cost:", + fingerprint: "config-a", + deadline: nil, + timeoutValue: -2) + { + 2 + } + #expect(rejected == -2) + let retained = await coordinator.snapshot() + #expect(retained.operationCount == 1) + #expect(retained.isShutDown) + + await gate.releaseAll() + await gate.waitForActive(0) + await self.waitForOperationCount(0, coordinator: coordinator) + } + + @Test + func `provider timeout preserves healthy rows and cannot stack provider work`() async { + let clock = ServeManualDeadlineClock() + let blocked = ServeFetchGate() + let healthy = ServeFetchGate() + let operations: CLIServeOperationCoordinator = self.makeCoordinator(clock: clock) + let deadline = clock.now().advanced(by: .seconds(30)) + await healthy.releaseAll() + + let first = Task { + await CodexBarCLI.serveCollectUsageOutputs( + providers: [.claude, .gemini], + configFingerprint: "config-a", + deadline: deadline, + operations: operations) + { provider in + if provider == .claude { + return await blocked.run(UsageCommandOutput(sections: ["late:claude"])) + } + return await healthy.run(UsageCommandOutput(sections: ["ok:gemini"])) + } + } + await blocked.waitForStarts(1) + await healthy.waitForStarts(1) + await self.waitForOperationCount(1, coordinator: operations) + await clock.waitForPendingSleeps(1) + clock.advance(by: .seconds(30)) + await clock.fireAll() + let firstOutput = await first.value + #expect(firstOutput.sections == ["ok:gemini"]) + #expect(firstOutput.payload.count == 1) + #expect(firstOutput.payload.first?.provider == UsageProvider.claude.rawValue) + #expect(firstOutput.payload.first?.error?.kind == .provider) + + let second = Task { + await CodexBarCLI.serveCollectUsageOutputs( + providers: [.claude], + configFingerprint: "config-a", + deadline: deadline.advanced(by: .seconds(30)), + operations: operations) + { _ in + await blocked.run(UsageCommandOutput(sections: ["overlap"])) + } + } + await self.waitForOperationCount(2, coordinator: operations) + await clock.waitForPendingSleeps(1) + clock.advance(by: .seconds(30)) + await clock.fireAll() + let secondOutput = await second.value + #expect(secondOutput.payload.first?.error?.kind == .provider) + #expect(await blocked.startCount() == 1) + #expect(await blocked.peakCount() == 1) + + await blocked.releaseAll() + await blocked.waitForActive(0) + await self.waitForOperationCount(0, coordinator: operations) + } + + @Test + func `cost route variants cannot stack the same provider scan`() async { + let clock = ServeManualDeadlineClock() + let late = CodexBarCLI.makeCostPayload(provider: .claude, snapshot: nil, error: nil) + let blocked = ServeFetchGate() + let operations: CLIServeOperationCoordinator = self.makeCoordinator(clock: clock) + let requestDeadline = clock.now().advanced(by: .seconds(40)) + let firstContext = ServeCostCollectionContext( + configFingerprint: "config-a", + providerTimeout: 30, + requestDeadline: requestDeadline, + now: { clock.now() }, + providerOperations: operations) + + let first = Task { + await CodexBarCLI.serveCollectCostPayloads( + providers: [.claude, .codex], + context: firstContext) + { provider in + if provider == .claude { + return await blocked.run(late) + } + return CodexBarCLI.makeCostPayload(provider: provider, snapshot: nil, error: nil) + } + } + await blocked.waitForStarts(1) + await clock.waitForPendingSleeps(1) + clock.advance(by: .seconds(30)) + await clock.fireAll() + let firstPayload = await first.value + + #expect(firstPayload.map(\.provider) == ["claude", "codex"]) + #expect(firstPayload[0].error?.message == "claude cost refresh timed out") + #expect(firstPayload[1].error == nil) + + let overlappingContext = ServeCostCollectionContext( + configFingerprint: "config-a", + providerTimeout: 30, + requestDeadline: requestDeadline.advanced(by: .seconds(20)), + now: { clock.now() }, + providerOperations: operations) + let overlappingVariant = Task { + await CodexBarCLI.serveCollectCostPayloads( + providers: [.claude], + context: overlappingContext) + { _ in + await blocked.run(late) + } + } + await self.waitForOperationCount(2, coordinator: operations) + await clock.waitForPendingSleeps(1) + clock.advance(by: .seconds(30)) + await clock.fireAll() + let secondPayload = await overlappingVariant.value + + #expect(secondPayload.first?.error?.message == "claude cost refresh timed out") + #expect(await blocked.startCount() == 1) + #expect(await blocked.peakCount() == 1) + + await blocked.releaseAll() + await blocked.waitForActive(0) + await self.waitForOperationCount(0, coordinator: operations) + } + + private func makeCoordinator( + clock: ServeManualDeadlineClock) -> CLIServeOperationCoordinator + { + CLIServeOperationCoordinator( + now: { clock.now() }, + sleepUntil: { deadline in try await clock.sleep(until: deadline) }) + } + + private func waitForOperationCount( + _ expected: Int, + coordinator: CLIServeOperationCoordinator) async + { + for _ in 0..<1000 { + if await coordinator.snapshot().operationCount == expected { + return + } + await Task.yield() + } + Issue.record("operation count did not reach \(expected)") + } + + private func waitForWaiterCount( + _ expected: Int, + coordinator: CLIServeOperationCoordinator) async + { + for _ in 0..<1000 { + if await coordinator.snapshot().waiterCount == expected { + return + } + await Task.yield() + } + Issue.record("waiter count did not reach \(expected)") + } +} + +private actor ServeAcceptanceProbe { + private var calls = 0 + + func accept(_ value: Value) -> Value { + self.calls += 1 + return value + } + + func callCount() -> Int { + self.calls + } +} + +private actor ServeFetchGate { + private var starts = 0 + private var active = 0 + private var peak = 0 + private var released = false + private var releaseContinuations: [CheckedContinuation] = [] + private var startWaiters: [(Int, CheckedContinuation)] = [] + private var activeWaiters: [(Int, CheckedContinuation)] = [] + + func run(_ value: Value) async -> Value { + self.starts += 1 + self.active += 1 + self.peak = max(self.peak, self.active) + self.resumeStartWaiters() + + if !self.released { + await withCheckedContinuation { continuation in + self.releaseContinuations.append(continuation) + } + } + self.active -= 1 + self.resumeActiveWaiters() + return value + } + + func waitForStarts(_ expected: Int) async { + guard self.starts < expected else { return } + await withCheckedContinuation { continuation in + self.startWaiters.append((expected, continuation)) + } + } + + func waitForActive(_ expected: Int) async { + guard self.active != expected else { return } + await withCheckedContinuation { continuation in + self.activeWaiters.append((expected, continuation)) + } + } + + func releaseAll() { + self.released = true + let continuations = self.releaseContinuations + self.releaseContinuations.removeAll() + for continuation in continuations { + continuation.resume() + } + } + + func startCount() -> Int { + self.starts + } + + func peakCount() -> Int { + self.peak + } + + private func resumeStartWaiters() { + let ready = self.startWaiters.filter { self.starts >= $0.0 } + self.startWaiters.removeAll { self.starts >= $0.0 } + for (_, continuation) in ready { + continuation.resume() + } + } + + private func resumeActiveWaiters() { + let ready = self.activeWaiters.filter { self.active == $0.0 } + self.activeWaiters.removeAll { self.active == $0.0 } + for (_, continuation) in ready { + continuation.resume() + } + } +} + +private final class ServeManualDeadlineClock: @unchecked Sendable { + private let lock = NSLock() + private var instant = ContinuousClock().now + private let sleeper = ServeManualSleeper() + + func now() -> ContinuousClock.Instant { + self.lock.lock() + defer { self.lock.unlock() } + return self.instant + } + + func advance(by duration: Duration) { + self.lock.lock() + self.instant = self.instant.advanced(by: duration) + self.lock.unlock() + } + + func sleep(until deadline: ContinuousClock.Instant) async throws { + try await self.sleeper.sleep(until: deadline) + } + + func waitForPendingSleeps(_ expected: Int) async { + await self.sleeper.waitForPendingCount(expected) + } + + func waitForCancellations(_ expected: Int) async { + await self.sleeper.waitForCancellationCount(expected) + } + + func pendingSleepCount() async -> Int { + await self.sleeper.pendingCount() + } + + func fireAll() async { + await self.sleeper.fireAll() + } +} + +private actor ServeManualSleeper { + private typealias SleepContinuation = CheckedContinuation + + private struct Pending { + let id: UUID + let continuation: SleepContinuation + } + + private var pending: [Pending] = [] + private var cancellationCount = 0 + private var pendingWaiters: [(Int, CheckedContinuation)] = [] + private var cancellationWaiters: [(Int, CheckedContinuation)] = [] + + func sleep(until _: ContinuousClock.Instant) async throws { + let id = UUID() + try await withTaskCancellationHandler(operation: { + try await withCheckedThrowingContinuation { (continuation: SleepContinuation) in + guard !Task.isCancelled else { + continuation.resume(throwing: CancellationError()) + return + } + self.pending.append(Pending(id: id, continuation: continuation)) + self.resumePendingWaiters() + } + }, onCancel: { + Task { await self.cancel(id: id) } + }) + } + + func waitForPendingCount(_ expected: Int) async { + guard self.pending.count < expected else { return } + await withCheckedContinuation { continuation in + self.pendingWaiters.append((expected, continuation)) + } + } + + func waitForCancellationCount(_ expected: Int) async { + guard self.cancellationCount < expected else { return } + await withCheckedContinuation { continuation in + self.cancellationWaiters.append((expected, continuation)) + } + } + + func pendingCount() -> Int { + self.pending.count + } + + func fireAll() { + let pending = self.pending + self.pending.removeAll() + for item in pending { + item.continuation.resume() + } + } + + private func cancel(id: UUID) { + guard let index = self.pending.firstIndex(where: { $0.id == id }) else { return } + let item = self.pending.remove(at: index) + self.cancellationCount += 1 + item.continuation.resume(throwing: CancellationError()) + self.resumeCancellationWaiters() + } + + private func resumePendingWaiters() { + let ready = self.pendingWaiters.filter { self.pending.count >= $0.0 } + self.pendingWaiters.removeAll { self.pending.count >= $0.0 } + for (_, continuation) in ready { + continuation.resume() + } + } + + private func resumeCancellationWaiters() { + let ready = self.cancellationWaiters.filter { self.cancellationCount >= $0.0 } + self.cancellationWaiters.removeAll { self.cancellationCount >= $0.0 } + for (_, continuation) in ready { + continuation.resume() + } + } +} diff --git a/Tests/CodexBarTests/CLISnapshotTests.swift b/Tests/CodexBarTests/CLISnapshotTests.swift index 173136e06a..6e2137ba5c 100644 --- a/Tests/CodexBarTests/CLISnapshotTests.swift +++ b/Tests/CodexBarTests/CLISnapshotTests.swift @@ -3,7 +3,38 @@ import Foundation import Testing @testable import CodexBarCLI +// swiftlint:disable:next type_body_length struct CLISnapshotTests { + @Test + func `renders Gemini paid plan without changing acronym casing`() { + let identity = ProviderIdentitySnapshot( + providerID: .gemini, + accountEmail: nil, + accountOrganization: nil, + loginMethod: "Gemini Code Assist in Google One AI Pro") + let snapshot = UsageSnapshot( + primary: nil, + secondary: nil, + tertiary: nil, + updatedAt: Date(timeIntervalSince1970: 0), + identity: identity) + + let output = CLIRenderer.renderText( + provider: .gemini, + snapshot: snapshot, + credits: nil, + context: RenderContext( + header: "Gemini", + status: nil, + useColor: false, + resetStyle: .absolute)) + + #expect(CLIRenderer.planBadgeText(provider: .gemini, snapshot: snapshot) == + "Gemini Code Assist in Google One AI Pro") + #expect(output.contains("Plan: Gemini Code Assist in Google One AI Pro")) + #expect(!output.contains("Google One Ai Pro")) + } + @Test func `renders Factory token rate billing with time window labels`() { let snap = UsageSnapshot( @@ -91,6 +122,56 @@ struct CLISnapshotTests { #expect(output.contains("Plan: Pro 20x")) } + @Test + func `renders Codex limit reset credits`() { + let now = Date() + let expiresAt = now.addingTimeInterval(7200) + let resetCredits = CodexRateLimitResetCreditsSnapshot( + credits: [ + CodexRateLimitResetCredit( + id: "credit-1", + resetType: "codex_rate_limits", + status: .available, + grantedAt: Date(timeIntervalSince1970: 0), + expiresAt: expiresAt, + redeemStartedAt: nil, + redeemedAt: nil, + title: nil, + description: nil), + CodexRateLimitResetCredit( + id: "expired-credit", + resetType: "codex_rate_limits", + status: .available, + grantedAt: Date(timeIntervalSince1970: 0), + expiresAt: now, + redeemStartedAt: nil, + redeemedAt: nil, + title: nil, + description: nil), + ], + availableCount: 99, + updatedAt: Date(timeIntervalSince1970: 0)) + let snapshot = UsageSnapshot( + primary: .init(usedPercent: 10, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + secondary: nil, + tertiary: nil, + codexResetCredits: resetCredits, + updatedAt: Date(timeIntervalSince1970: 0)) + + let output = CLIRenderer.renderText( + provider: .codex, + snapshot: snapshot, + credits: nil, + context: RenderContext( + header: "Codex (oauth)", + status: nil, + useColor: false, + resetStyle: .countdown)) + + #expect(output.contains("Limit Reset Credits: 1 available")) + #expect(output.contains("Next reset credit expires")) + } + @Test func `renders Codex prolite plan with multiplier display name`() { let identity = ProviderIdentitySnapshot( @@ -173,6 +254,34 @@ struct CLISnapshotTests { #expect(!output.contains("Weekly:")) } + @Test + func `renders Claude Max multiplier without uppercasing x`() { + let identity = ProviderIdentitySnapshot( + providerID: .claude, + accountEmail: nil, + accountOrganization: nil, + loginMethod: "Claude Max 5x") + let snapshot = UsageSnapshot( + primary: .init(usedPercent: 2, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + secondary: nil, + tertiary: nil, + updatedAt: Date(timeIntervalSince1970: 0), + identity: identity) + + let output = CLIRenderer.renderText( + provider: .claude, + snapshot: snapshot, + credits: nil, + context: RenderContext( + header: "Claude (oauth)", + status: nil, + useColor: false, + resetStyle: .absolute)) + + #expect(output.contains("Plan: Claude Max 5x")) + #expect(!output.contains("Plan: Claude Max 5X")) + } + @Test func `renders warp unlimited as detail not reset`() { let meta = ProviderDescriptorRegistry.descriptor(for: .warp).metadata @@ -240,6 +349,28 @@ struct CLISnapshotTests { @Test func `renders crof dollar balance as detail not reset`() { let meta = ProviderDescriptorRegistry.descriptor(for: .crof).metadata + let snap = CrofUsageSnapshot( + credits: 9.9999, + updatedAt: Date(timeIntervalSince1970: 0)).toUsageSnapshot() + + let output = CLIRenderer.renderText( + provider: .crof, + snapshot: snap, + credits: nil, + context: RenderContext( + header: "Crof", + status: nil, + useColor: false, + resetStyle: .countdown)) + + #expect(output.contains("\(meta.sessionLabel): 100% left")) + #expect(output.contains("$9.99")) + #expect(!output.contains("Resets $9.99")) + #expect(!output.contains("requests left")) + } + + @Test + func `renders crof request quota when returned`() { let snap = CrofUsageSnapshot( credits: 9.9999, requestsPlan: 1000, @@ -256,10 +387,46 @@ struct CLISnapshotTests { useColor: false, resetStyle: .countdown)) - #expect(output.contains("\(meta.sessionLabel): 99% left")) - #expect(output.contains("\(meta.weeklyLabel): 100% left")) + #expect(output.contains("Requests: 99% left")) + #expect(output.contains("998 requests left")) + #expect(output.contains("Credits: 100% left")) #expect(output.contains("$9.99")) - #expect(!output.contains("Resets $9.99")) + } + + @Test + func `renders qoder reset and credit total separately`() { + let meta = ProviderDescriptorRegistry.descriptor(for: .qoder).metadata + let now = Date(timeIntervalSince1970: 0) + let snap = UsageSnapshot( + primary: .init( + usedPercent: 25, + windowMinutes: nil, + resetsAt: now.addingTimeInterval(3600), + resetDescription: "125 / 500 credits"), + secondary: nil, + tertiary: nil, + updatedAt: now, + identity: ProviderIdentitySnapshot( + providerID: .qoder, + accountEmail: nil, + accountOrganization: nil, + loginMethod: nil)) + + let output = CLIRenderer.renderText( + provider: .qoder, + snapshot: snap, + credits: nil, + context: RenderContext( + header: "Qoder", + status: nil, + useColor: false, + resetStyle: .countdown), + now: now) + + #expect(output.contains("\(meta.sessionLabel): 75% left")) + #expect(output.contains("Resets in 1h")) + #expect(output.contains("125 / 500 credits")) + #expect(!output.contains("Resets 125 / 500 credits")) } @Test @@ -378,6 +545,136 @@ struct CLISnapshotTests { #expect(output.contains("Pace:")) } + @Test + func `configured work days affect weekly text and JSON pace`() throws { + var calendar = Calendar.current + calendar.timeZone = .current + let resetsAt = try #require(calendar.date(from: DateComponents( + calendar: calendar, + timeZone: calendar.timeZone, + year: 2026, + month: 6, + day: 14))) + let now = resetsAt.addingTimeInterval(-72 * 60 * 60) + let snap = UsageSnapshot( + primary: nil, + secondary: .init( + usedPercent: 60, + windowMinutes: 10080, + resetsAt: resetsAt, + resetDescription: nil), + tertiary: nil, + updatedAt: now) + + let output = CLIRenderer.renderText( + provider: .codex, + snapshot: snap, + credits: nil, + context: RenderContext( + header: "Codex 0.0.0 (codex-cli)", + status: nil, + useColor: false, + resetStyle: .countdown, + weeklyWorkDays: 5), + now: now) + #expect(output.contains("Pace: On pace | Expected 60% used | Lasts until reset")) + + let pace = try #require(CLIRenderer.providerPacePayload( + provider: .codex, + snapshot: snap, + weeklyWorkDays: 5, + now: now)?.secondary) + #expect(pace.expectedUsedPercent == 60) + #expect(pace.summary == "On pace | Expected 60% used | Lasts until reset") + } + + @Test + func `Kimi routes inverted quota windows to CLI pace and JSON metadata`() throws { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let snapshot = UsageSnapshot( + primary: .init( + usedPercent: 30, + windowMinutes: KimiProviderDescriptor.weeklyWindowMinutes, + resetsAt: now.addingTimeInterval(4 * 24 * 60 * 60), + resetDescription: "weekly"), + secondary: .init( + usedPercent: 10, + windowMinutes: KimiProviderDescriptor.sessionWindowMinutes, + resetsAt: now.addingTimeInterval(4 * 60 * 60), + resetDescription: "rate limit"), + tertiary: nil, + updatedAt: now) + + let pace = try #require(CLIRenderer.providerPacePayload(provider: .kimi, snapshot: snapshot, now: now)) + #expect(pace.primary?.expectedUsedPercent == 43) + #expect(pace.primary?.summary == "13% in reserve | Expected 43% used | Lasts until reset") + #expect(pace.secondary?.expectedUsedPercent == 20) + #expect(pace.secondary?.summary == "10% in reserve | Expected 20% used | Lasts until reset") + + let output = CLIRenderer.renderText( + provider: .kimi, + snapshot: snapshot, + credits: nil, + context: RenderContext( + header: "Kimi", + status: nil, + useColor: false, + resetStyle: .countdown), + now: now) + #expect(output.split(separator: "\n").count(where: { $0.contains("Pace:") }) == 2) + + let payload = ProviderPayload( + provider: .kimi, + account: nil, + version: nil, + source: "Kimi Code API key", + status: nil, + usage: snapshot, + credits: nil, + antigravityPlanInfo: nil, + openaiDashboard: nil, + error: nil, + pace: pace) + let data = try JSONEncoder().encode(payload) + let root = try #require(try JSONSerialization.jsonObject(with: data) as? [String: Any]) + let usage = try #require(root["usage"] as? [String: Any]) + let primary = try #require(usage["primary"] as? [String: Any]) + let secondary = try #require(usage["secondary"] as? [String: Any]) + #expect(primary["windowMinutes"] as? Int == KimiProviderDescriptor.weeklyWindowMinutes) + #expect(secondary["windowMinutes"] as? Int == KimiProviderDescriptor.sessionWindowMinutes) + let encodedPace = try #require(root["pace"] as? [String: Any]) + #expect(encodedPace["primary"] != nil) + #expect(encodedPace["secondary"] != nil) + } + + @Test + func `Kimi CLI pace rejects missing and unsupported window durations`() { + let now = Date(timeIntervalSince1970: 1_700_000_000) + for duration: Int? in [nil, 24 * 60, 30 * 24 * 60] { + let window = RateWindow( + usedPercent: 25, + windowMinutes: duration, + resetsAt: now.addingTimeInterval(60 * 60), + resetDescription: nil) + let snapshots = [ + UsageSnapshot( + primary: window, + secondary: nil, + tertiary: nil, + updatedAt: now), + UsageSnapshot( + primary: nil, + secondary: window, + tertiary: nil, + updatedAt: now), + ] + + for snapshot in snapshots { + #expect(CLIRenderer.providerPacePayload(provider: .kimi, snapshot: snapshot, now: now) == nil) + } + } + } + @Test func `renders Ollama weekly pace line when weekly window has reset`() { let now = Date() @@ -407,6 +704,7 @@ struct CLISnapshotTests { #expect(output.contains("Weekly: 77% left")) #expect(output.contains("Pace: 6% in reserve | Expected 29% used | Lasts until reset")) + #expect(!output.contains("1.5× headroom")) } @Test @@ -436,6 +734,179 @@ struct CLISnapshotTests { #expect(!output.contains("Pace:")) } + @Test + func `renders session pace line when session window has reset`() { + let now = Date() + let snap = UsageSnapshot( + primary: .init( + usedPercent: 20, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(2 * 60 * 60), + resetDescription: nil), + secondary: nil, + tertiary: nil, + updatedAt: now) + + let output = CLIRenderer.renderText( + provider: .codex, + snapshot: snap, + credits: nil, + context: RenderContext( + header: "Codex 0.0.0 (codex-cli)", + status: nil, + useColor: false, + resetStyle: .countdown), + now: now) + + #expect(output.contains("Session: 80% left")) + // 2h remaining of a 5h window => 3h elapsed => 60% expected; even rate easily lasts to reset. + #expect(output.contains("Pace: 40% in reserve | Expected 60% used | Lasts until reset | 1.5× headroom")) + } + + @Test + func `renders Claude session pace using five hour default window`() { + let now = Date() + let snap = UsageSnapshot( + primary: .init( + usedPercent: 20, + windowMinutes: nil, + resetsAt: now.addingTimeInterval(2 * 60 * 60), + resetDescription: nil), + secondary: nil, + tertiary: nil, + updatedAt: now) + + let output = CLIRenderer.renderText( + provider: .claude, + snapshot: snap, + credits: nil, + context: RenderContext( + header: "Claude Code 2.0.69 (claude)", + status: nil, + useColor: false, + resetStyle: .countdown), + now: now) + + // windowMinutes is nil, so the 5-hour (300 minute) session default must drive the pace. + #expect(output.contains("Pace: 40% in reserve | Expected 60% used | Lasts until reset")) + #expect(!output.contains("1.5× headroom")) + } + + @Test + func `renders session pace deficit with run out estimate`() { + let now = Date() + let snap = UsageSnapshot( + primary: .init( + usedPercent: 50, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(4 * 60 * 60), + resetDescription: nil), + secondary: nil, + tertiary: nil, + updatedAt: now) + + let output = CLIRenderer.renderText( + provider: .codex, + snapshot: snap, + credits: nil, + context: RenderContext( + header: "Codex 0.0.0 (codex-cli)", + status: nil, + useColor: false, + resetStyle: .countdown), + now: now) + + // 1h elapsed of a 5h window => 20% expected vs 50% used => burning ahead of pace. + // Session mirrors the GUI's "Projected empty" wording (weekly uses "Runs out"). + #expect(output.contains("Pace: 30% in deficit | Expected 20% used | Projected empty in")) + #expect(!output.contains("Runs out")) + } + + @Test + func `renders session pace on track and lasts until reset`() { + let now = Date(timeIntervalSince1970: 1_700_000_000) + // Exactly halfway through a 5h window with 50% used => On pace (delta 0); the even rate + // means the quota lasts precisely to the reset. + let snap = UsageSnapshot( + primary: .init( + usedPercent: 50, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(2.5 * 60 * 60), + resetDescription: nil), + secondary: nil, + tertiary: nil, + updatedAt: now) + + let output = CLIRenderer.renderText( + provider: .codex, + snapshot: snap, + credits: nil, + context: RenderContext( + header: "Codex 0.0.0 (codex-cli)", + status: nil, + useColor: false, + resetStyle: .countdown), + now: now) + + #expect(output.contains("Pace: On pace | Expected 50% used | Lasts until reset")) + } + + @Test + func `hides session pace for unsupported provider`() { + let now = Date() + let snap = UsageSnapshot( + primary: .init( + usedPercent: 20, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(2 * 60 * 60), + resetDescription: nil), + secondary: nil, + tertiary: nil, + updatedAt: now) + + let output = CLIRenderer.renderText( + provider: .zai, + snapshot: snap, + credits: nil, + context: RenderContext( + header: "z.ai 0.0.0 (zai)", + status: nil, + useColor: false, + resetStyle: .countdown)) + + #expect(!output.contains("Pace:")) + } + + @Test + func `hides session pace for non-session primary window`() { + let now = Date(timeIntervalSince1970: 1_700_000_000) + // Claude with no 5-hour data falls a 7-day window back into `primary`; it must not be + // paced as a "Session" (that would print "Projected empty …" over a weekly window). + let snap = UsageSnapshot( + primary: .init( + usedPercent: 50, + windowMinutes: 10080, + resetsAt: now.addingTimeInterval(3 * 24 * 60 * 60), + resetDescription: nil), + secondary: nil, + tertiary: nil, + updatedAt: now) + + let output = CLIRenderer.renderText( + provider: .claude, + snapshot: snap, + credits: nil, + context: RenderContext( + header: "Claude Code 2.0.69 (claude)", + status: nil, + useColor: false, + resetStyle: .countdown), + now: now) + + #expect(!output.contains("Pace:")) + #expect(CLIRenderer.providerPacePayload(provider: .claude, snapshot: snap, now: now) == nil) + } + @Test func `renders JSON payload`() throws { let snap = UsageSnapshot( @@ -458,7 +929,8 @@ struct CLISnapshotTests { credits: nil, antigravityPlanInfo: nil, openaiDashboard: nil, - error: nil) + error: nil, + diagnostic: "Grok team usage is unavailable from the current billing surface.") let encoder = JSONEncoder() encoder.dateEncodingStrategy = .secondsSince1970 let data = try encoder.encode(payload) @@ -471,11 +943,171 @@ struct CLISnapshotTests { #expect(json.contains("\"version\":\"1.2.3\"")) #expect(json.contains("\"status\"")) #expect(json.contains("status.example.com")) + #expect(json.contains("Grok team usage is unavailable from the current billing surface.")) #expect(json.contains("\"primary\"")) #expect(json.contains("\"windowMinutes\":300")) #expect(json.contains("1700000000")) } + @Test + func `json pace rounds derived numbers to match usage precision`() throws { + let now = Date(timeIntervalSince1970: 1_700_000_000) + // 13000s elapsed of an 18000s (300m) window => 72.22% expected; used 79 => +6.78 deficit; + // projected empty in ~3455.7s. Derived fields must be emitted as whole numbers (no float noise). + let snap = UsageSnapshot( + primary: .init( + usedPercent: 79, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(5000), + resetDescription: nil), + secondary: nil, + tertiary: nil, + updatedAt: now) + + let payload = ProviderPayload( + provider: .codex, + account: nil, + version: nil, + source: "codex-cli", + status: nil, + usage: snap, + credits: nil, + antigravityPlanInfo: nil, + openaiDashboard: nil, + error: nil, + pace: CLIRenderer.providerPacePayload(provider: .codex, snapshot: snap, now: now)) + + let data = try JSONEncoder().encode(payload) + let root = try #require(try JSONSerialization.jsonObject(with: data) as? [String: Any]) + let primary = try #require((root["pace"] as? [String: Any])?["primary"] as? [String: Any]) + + #expect(primary["expectedUsedPercent"] as? Double == 72) + #expect(primary["deltaPercent"] as? Double == 7) + #expect(primary["etaSeconds"] as? Double == 3456) + // actualUsedPercent is not emitted; consumers read usage.primary.usedPercent. + #expect(primary["actualUsedPercent"] == nil) + } + + @Test + func `json payload includes session and weekly pace with distinct wording`() throws { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let snap = UsageSnapshot( + // 1h elapsed of a 5h window => 20% expected vs 50% used => deficit, runs out in 1h. + primary: .init( + usedPercent: 50, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(4 * 60 * 60), + resetDescription: nil), + // 5d elapsed of a 7d window => ~71% expected vs 90% used => deficit, runs out before reset. + secondary: .init( + usedPercent: 90, + windowMinutes: 10080, + resetsAt: now.addingTimeInterval(2 * 24 * 60 * 60), + resetDescription: nil), + tertiary: nil, + updatedAt: now) + + let payload = ProviderPayload( + provider: .codex, + account: nil, + version: "1.2.3", + source: "codex-cli", + status: nil, + usage: snap, + credits: nil, + antigravityPlanInfo: nil, + openaiDashboard: nil, + error: nil, + pace: CLIRenderer.providerPacePayload(provider: .codex, snapshot: snap, now: now)) + + let data = try JSONEncoder().encode(payload) + let root = try #require(try JSONSerialization.jsonObject(with: data) as? [String: Any]) + let pace = try #require(root["pace"] as? [String: Any]) + + let primary = try #require(pace["primary"] as? [String: Any]) + #expect(primary["stage"] as? String == "farAhead") + #expect(primary["expectedUsedPercent"] as? Double == 20) + #expect(primary["deltaPercent"] as? Double == 30) + #expect(primary["willLastToReset"] as? Bool == false) + #expect(primary["etaSeconds"] as? Double == 3600) + #expect((primary["summary"] as? String)? + .contains("30% in deficit | Expected 20% used | Projected empty in") == true) + // actualUsedPercent is redundant with usage.usedPercent and is not emitted; + // runOutProbability is never set by the CLI, so both keys are omitted. + #expect(primary["actualUsedPercent"] == nil) + #expect(primary["runOutProbability"] == nil) + + let secondary = try #require(pace["secondary"] as? [String: Any]) + #expect(secondary["stage"] as? String == "farAhead") + #expect((secondary["summary"] as? String)?.contains("Runs out in") == true) + #expect((secondary["summary"] as? String)?.contains("Projected empty") == false) + } + + @Test + func `json omits pace when not applicable`() throws { + let now = Date() + let snap = UsageSnapshot( + primary: .init( + usedPercent: 20, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(2 * 60 * 60), + resetDescription: nil), + secondary: nil, + tertiary: nil, + updatedAt: now) + + // z.ai is not a session/weekly pace provider, so no pace should be emitted. + let payload = ProviderPayload( + provider: .zai, + account: nil, + version: nil, + source: "zai", + status: nil, + usage: snap, + credits: nil, + antigravityPlanInfo: nil, + openaiDashboard: nil, + error: nil, + pace: CLIRenderer.providerPacePayload(provider: .zai, snapshot: snap, now: now)) + + let data = try JSONEncoder().encode(payload) + let json = try #require(String(data: data, encoding: .utf8)) + #expect(!json.contains("\"pace\"")) + } + + @Test + func `json includes only session pace when weekly window missing`() throws { + let now = Date() + let snap = UsageSnapshot( + primary: .init( + usedPercent: 50, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(4 * 60 * 60), + resetDescription: nil), + secondary: nil, + tertiary: nil, + updatedAt: now) + + let payload = ProviderPayload( + provider: .codex, + account: nil, + version: nil, + source: "codex-cli", + status: nil, + usage: snap, + credits: nil, + antigravityPlanInfo: nil, + openaiDashboard: nil, + error: nil, + pace: CLIRenderer.providerPacePayload(provider: .codex, snapshot: snap, now: now)) + + let data = try JSONEncoder().encode(payload) + let root = try #require(try JSONSerialization.jsonObject(with: data) as? [String: Any]) + let pace = try #require(root["pace"] as? [String: Any]) + #expect(pace["primary"] is [String: Any]) + #expect(pace["secondary"] == nil) + } + @Test func `encodes JSON with secondary null when missing`() throws { let snap = UsageSnapshot( @@ -640,4 +1272,31 @@ struct CLISnapshotTests { #expect(output.contains("Tokens:")) #expect(output.contains("MCP:")) } + + @Test + func `devin overage balance without primary window omits generic cost line`() { + let snap = UsageSnapshot( + primary: nil, + secondary: .init(usedPercent: 42, windowMinutes: 10080, resetsAt: nil, resetDescription: nil), + tertiary: nil, + providerCost: ProviderCostSnapshot( + used: 48.0, + limit: 0, + currencyCode: "USD", + period: "Extra usage balance", + updatedAt: Date(timeIntervalSince1970: 0)), + updatedAt: Date(timeIntervalSince1970: 0)) + let output = CLIRenderer.renderText( + provider: .devin, + snapshot: snap, + credits: nil, + context: RenderContext( + header: "Devin (devin)", + status: nil, + useColor: false, + resetStyle: .absolute)) + #expect(output.contains("Extra usage: $48.00")) + #expect(!output.contains("Cost:")) + #expect(!output.contains(" / 0.0")) + } } diff --git a/Tests/CodexBarTests/CLIWebFallbackTests.swift b/Tests/CodexBarTests/CLIWebFallbackTests.swift index df93a6f0a5..97c731d7ec 100644 --- a/Tests/CodexBarTests/CLIWebFallbackTests.swift +++ b/Tests/CodexBarTests/CLIWebFallbackTests.swift @@ -70,6 +70,14 @@ struct CLIWebFallbackTests { after: OpenAIDashboardFetcher.FetchError.noDashboardData(body: "missing"))) #expect(!CodexWebDashboardStrategy.shouldRetryWithFreshBrowserImport( after: OpenAIDashboardFetcher.FetchError.loginRequired)) + #expect(!CodexWebDashboardStrategy.shouldRetryWithFreshBrowserImport( + after: OpenAIWebCodexError.timedOut(seconds: 30))) + } + + @Test + func `codex shared deadline timeout has useful error`() { + let error = OpenAIWebCodexError.timedOut(seconds: 30) + #expect(error.localizedDescription == "OpenAI web dashboard fetch timed out after 30 seconds.") } @Test @@ -120,6 +128,33 @@ struct CLIWebFallbackTests { #expect(!available) } + @Test + func `codex web strategy fails closed when profile target is unavailable`() async { + let settings = ProviderSettingsSnapshot.make( + codex: .init( + usageDataSource: .auto, + cookieSource: .auto, + manualCookieHeader: nil, + profileAccountTargetUnavailable: true)) + let strategy = CodexWebDashboardStrategy() + + let autoContext = self.makeContext(sourceMode: .auto, settings: settings) + let autoAvailable = await strategy.isAvailable(autoContext) + #expect(!autoAvailable) + + let explicitWebContext = self.makeContext(sourceMode: .web, settings: settings) + let explicitWebAvailable = await strategy.isAvailable(explicitWebContext) + #expect(explicitWebAvailable) + do { + _ = try await strategy.fetch(explicitWebContext) + Issue.record("Expected unavailable profile target to require login") + } catch OpenAIDashboardFetcher.FetchError.loginRequired { + // Expected before browser import can accept an arbitrary account. + } catch { + Issue.record("Unexpected error: \(error)") + } + } + @Test func `claude falls back when no session key`() { let context = self.makeContext() @@ -130,24 +165,40 @@ struct CLIWebFallbackTests { @Test func `claude CLI fallback is enabled only for app auto`() { - let strategy = ClaudeCLIFetchStrategy( + let webAvailableStrategy = ClaudeCLIFetchStrategy( useWebExtras: false, + includePrepaidBalance: false, manualCookieHeader: nil, - browserDetection: BrowserDetection(cacheTTL: 0)) + browserDetection: BrowserDetection(cacheTTL: 0), + hasWebFallback: true) + let webUnavailableStrategy = ClaudeCLIFetchStrategy( + useWebExtras: false, + includePrepaidBalance: false, + manualCookieHeader: nil, + browserDetection: BrowserDetection(cacheTTL: 0), + hasWebFallback: false) let error = ClaudeUsageError.parseFailed("cli failed") let webAvailableSettings = self.makeClaudeSettingsSnapshot(cookieHeader: "sessionKey=sk-ant-test") let webUnavailableSettings = self.makeClaudeSettingsSnapshot(cookieHeader: "foo=bar") - #expect(strategy.shouldFallback( + #expect(webAvailableStrategy.shouldFallback( on: error, context: self.makeContext(runtime: .app, sourceMode: .auto, settings: webAvailableSettings))) - #expect(!strategy.shouldFallback( + #expect(!webUnavailableStrategy.shouldFallback( on: error, context: self.makeContext(runtime: .app, sourceMode: .auto, settings: webUnavailableSettings))) - #expect(!strategy.shouldFallback(on: error, context: self.makeContext(runtime: .app, sourceMode: .cli))) - #expect(!strategy.shouldFallback(on: error, context: self.makeContext(runtime: .app, sourceMode: .web))) - #expect(!strategy.shouldFallback(on: error, context: self.makeContext(runtime: .app, sourceMode: .oauth))) - #expect(!strategy.shouldFallback(on: error, context: self.makeContext(runtime: .cli, sourceMode: .auto))) + #expect(!webAvailableStrategy.shouldFallback( + on: error, + context: self.makeContext(runtime: .app, sourceMode: .cli))) + #expect(!webAvailableStrategy.shouldFallback( + on: error, + context: self.makeContext(runtime: .app, sourceMode: .web))) + #expect(!webAvailableStrategy.shouldFallback( + on: error, + context: self.makeContext(runtime: .app, sourceMode: .oauth))) + #expect(!webAvailableStrategy.shouldFallback( + on: error, + context: self.makeContext(runtime: .cli, sourceMode: .auto))) } @Test diff --git a/Tests/CodexBarTests/ChartBarHoverSelectionTests.swift b/Tests/CodexBarTests/ChartBarHoverSelectionTests.swift new file mode 100644 index 0000000000..517a496084 --- /dev/null +++ b/Tests/CodexBarTests/ChartBarHoverSelectionTests.swift @@ -0,0 +1,49 @@ +import Foundation +import Testing +@testable import CodexBar + +struct ChartBarHoverSelectionTests { + @Test + func `single selectable bar accepts the full plot`() { + #expect(ChartBarHoverSelection.accepts( + distanceFromBarCenter: 120, + barHalfWidth: 5, + selectableCount: 1)) + } + + @Test + func `multiple selectable bars accept only the bar body`() { + #expect(ChartBarHoverSelection.accepts( + distanceFromBarCenter: 5, + barHalfWidth: 5, + selectableCount: 2)) + #expect(!ChartBarHoverSelection.accepts( + distanceFromBarCenter: 5.1, + barHalfWidth: 5, + selectableCount: 2)) + } + + @Test + func `calendar day spacing follows daylight saving transitions`() throws { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = try #require(TimeZone(identifier: "America/Los_Angeles")) + let springDate = try #require(calendar.date(from: DateComponents( + year: 2026, + month: 3, + day: 7, + hour: 12))) + let fallDate = try #require(calendar.date(from: DateComponents( + year: 2026, + month: 10, + day: 31, + hour: 12))) + + let springNextDay = ChartBarHoverSelection.nextCalendarDay(after: springDate, calendar: calendar) + let fallNextDay = ChartBarHoverSelection.nextCalendarDay(after: fallDate, calendar: calendar) + + #expect(calendar.component(.day, from: springNextDay) == 8) + #expect(springNextDay.timeIntervalSince(springDate) == 23 * 60 * 60) + #expect(calendar.component(.day, from: fallNextDay) == 1) + #expect(fallNextDay.timeIntervalSince(fallDate) == 25 * 60 * 60) + } +} diff --git a/Tests/CodexBarTests/ChutesPresentationTests.swift b/Tests/CodexBarTests/ChutesPresentationTests.swift new file mode 100644 index 0000000000..c7afbe80d6 --- /dev/null +++ b/Tests/CodexBarTests/ChutesPresentationTests.swift @@ -0,0 +1,145 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar +@testable import CodexBarCLI + +@MainActor +struct ChutesPresentationTests { + @Test + func `menu card keeps quota detail separate from reset text`() throws { + let now = Date(timeIntervalSince1970: 1_800_000_000) + let metadata = try #require(ProviderDefaults.metadata[.chutes]) + let model = UsageMenuCardView.Model.make(.init( + provider: .chutes, + metadata: metadata, + snapshot: Self.snapshot(now: now), + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + + let primary = try #require(model.metrics.first { $0.id == "primary" }) + #expect(primary.resetText?.hasPrefix("Resets") == true) + #expect(primary.detailText == "40/100 requests") + + let secondary = try #require(model.metrics.first { $0.id == "secondary" }) + #expect(secondary.resetText == nil) + #expect(secondary.detailText == "250/1000 credits") + } + + @Test + func `CLI keeps quota detail separate from reset text`() throws { + let now = Date(timeIntervalSince1970: 1_800_000_000) + let snapshot = Self.snapshot(now: now) + let metadata = ProviderDescriptorRegistry.descriptor(for: .chutes).metadata + let card = CLICardsRenderer.makeCard(CLICardBuildInput( + provider: .chutes, + snapshot: snapshot, + credits: nil, + source: "api", + status: nil, + notes: [], + useColor: false, + resetStyle: .countdown, + weeklyWorkDays: nil, + now: now)) + + let primary = try #require(card.metrics.first { $0.label == metadata.sessionLabel }) + #expect(primary.resetText?.hasPrefix("⏳ Resets") == true) + #expect(primary.detailText == "40/100 requests") + + let secondary = try #require(card.metrics.first { $0.label == metadata.weeklyLabel }) + #expect(secondary.resetText == nil) + #expect(secondary.detailText == "250/1000 credits") + + let output = CLIRenderer.renderText( + provider: .chutes, + snapshot: snapshot, + credits: nil, + context: RenderContext( + header: "Chutes", + status: nil, + useColor: false, + resetStyle: .countdown), + now: now) + + #expect(output.contains("40/100 requests")) + #expect(output.contains("250/1000 credits")) + #expect(!output.contains("Resets 40/100 requests")) + #expect(!output.contains("Resets 250/1000 credits")) + } + + @Test + func `native menu keeps quota detail separate from reset text`() throws { + let suite = "ChutesPresentationTests-native-menu" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + defer { defaults.removePersistentDomain(forName: suite) } + + let settings = SettingsStore( + userDefaults: defaults, + configStore: testConfigStore(suiteName: suite), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + settings.statusChecksEnabled = false + + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings) + store._setSnapshotForTesting(Self.snapshot(now: Date()), provider: .chutes) + + let descriptor = MenuDescriptor.build( + provider: .chutes, + store: store, + settings: settings, + account: AccountInfo(email: nil, plan: nil), + updateReady: false, + includeContextualActions: false) + let textLines = descriptor.sections + .flatMap(\.entries) + .compactMap { entry -> String? in + guard case let .text(text, _) = entry else { return nil } + return text + } + + #expect(textLines.contains("40/100 requests")) + #expect(textLines.contains("250/1000 credits")) + #expect(textLines.contains { $0.hasPrefix("Resets") }) + #expect(!textLines.contains { $0.contains("Resets 40/100 requests") }) + #expect(!textLines.contains { $0.contains("Resets 250/1000 credits") }) + } + + private static func snapshot(now: Date) -> UsageSnapshot { + UsageSnapshot( + primary: RateWindow( + usedPercent: 40, + windowMinutes: 240, + resetsAt: now.addingTimeInterval(60 * 60), + resetDescription: "40/100 requests"), + secondary: RateWindow( + usedPercent: 25, + windowMinutes: 30 * 24 * 60, + resetsAt: nil, + resetDescription: "250/1000 credits"), + tertiary: nil, + updatedAt: now, + identity: ProviderIdentitySnapshot( + providerID: .chutes, + accountEmail: nil, + accountOrganization: nil, + loginMethod: "Pro")) + } +} diff --git a/Tests/CodexBarTests/ChutesProviderTests.swift b/Tests/CodexBarTests/ChutesProviderTests.swift new file mode 100644 index 0000000000..07f00d1cb9 --- /dev/null +++ b/Tests/CodexBarTests/ChutesProviderTests.swift @@ -0,0 +1,355 @@ +import Foundation +import Testing +@testable import CodexBar +@testable import CodexBarCore + +struct ChutesProviderTests { + @Test + func `settings reader trims quoted API key`() { + let token = ChutesSettingsReader.apiKey(environment: [ + ChutesSettingsReader.apiKeyEnvironmentKey: " 'chutes-test' ", + ]) + + #expect(token == "chutes-test") + } + + @Test + func `config API key projects into Chutes environment`() { + let config = ProviderConfig(id: .chutes, apiKey: "chutes-config-token") + let env = ProviderConfigEnvironment.applyAPIKeyOverride( + base: [:], + provider: .chutes, + config: config) + + #expect(env[ChutesSettingsReader.apiKeyEnvironmentKey] == "chutes-config-token") + #expect(ChutesSettingsReader.apiKey(environment: env) == "chutes-config-token") + #expect(ProviderConfigEnvironment.supportsAPIKeyOverride(for: .chutes)) + } + + @Test + func `fetch usage maps active subscription monthly and rolling windows`() async throws { + let now = Date(timeIntervalSince1970: 1_800_000_000) + let rollingReset = try Self.date("2026-06-13T18:00:00Z") + let monthlyReset = try Self.date("2026-07-01T00:00:00Z") + let transport = ProviderHTTPTransportStub { request in + let url = try #require(request.url) + #expect(url.path == "/users/me/subscription_usage") + #expect(request.httpMethod == "GET") + #expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer chutes-key") + #expect(request.value(forHTTPHeaderField: "Accept") == "application/json") + #expect(request.timeoutInterval == 15) + + let body = #""" + { + "subscription": { + "active": true, + "plan_name": "Pro", + "current_period_end": "2026-07-01T00:00:00Z" + }, + "monthly": { + "used": 250, + "limit": 1000, + "resets_at": "2026-07-01T00:00:00Z", + "unit": "credits" + }, + "rolling_window": { + "requests": 40, + "limit": 100, + "window_minutes": 240, + "reset_at": "2026-06-13T18:00:00Z", + "unit": "requests" + } + } + """# + return Self.makeResponse(url: url, body: body) + } + + let snapshot = try await ChutesUsageFetcher.fetchUsage( + apiKey: " chutes-key ", + environment: [ChutesSettingsReader.apiURLEnvironmentKey: "https://chutes.test"], + transport: transport, + now: now) + let usage = snapshot.toUsageSnapshot() + + #expect(usage.primary?.usedPercent == 40) + #expect(usage.primary?.windowMinutes == 240) + #expect(usage.primary?.resetsAt == rollingReset) + #expect(usage.primary?.resetDescription == "40/100 requests") + #expect(usage.secondary?.usedPercent == 25) + #expect(usage.secondary?.resetsAt == monthlyReset) + #expect(usage.secondary?.resetDescription == "250/1000 credits") + #expect(usage.subscriptionRenewsAt == monthlyReset) + #expect(usage.loginMethod(for: .chutes) == "Pro") + + let requests = await transport.requests() + #expect(requests.count == 1) + } + + @Test + func `no active subscription falls back to quotas endpoint`() async throws { + let now = Date(timeIntervalSince1970: 1_800_000_000) + let transport = ProviderHTTPTransportStub { request in + let url = try #require(request.url) + switch url.path { + case "/users/me/subscription_usage": + return Self.makeResponse(url: url, body: #""" + { + "subscription": { + "active": false, + "status": "free" + } + } + """#) + case "/users/me/quotas": + return Self.makeResponse(url: url, body: #""" + [ + { + "chute_id": "0", + "is_default": true, + "quota": 100 + } + ] + """#) + case "/users/me/quota_usage/0": + return Self.makeResponse(url: url, body: #""" + { + "quota": 100, + "used": 10 + } + """#) + default: + throw URLError(.badURL) + } + } + + let snapshot = try await ChutesUsageFetcher.fetchUsage( + apiKey: "chutes-key", + environment: [ChutesSettingsReader.apiURLEnvironmentKey: "https://chutes.test"], + transport: transport, + now: now) + let usage = snapshot.toUsageSnapshot() + + #expect(usage.primary?.usedPercent == 10) + #expect(usage.primary?.resetDescription == "10/100 credits") + #expect(usage.secondary == nil) + #expect(usage.loginMethod(for: .chutes) == "No active subscription") + + let requests = await transport.requests() + let paths = requests.compactMap { $0.url?.path } + #expect(paths == [ + "/users/me/subscription_usage", + "/users/me/quotas", + "/users/me/quota_usage/0", + ]) + } + + @Test + func `wrapped quota list fetches per quota usage`() async throws { + let transport = ProviderHTTPTransportStub { request in + let url = try #require(request.url) + switch url.path { + case "/users/me/subscription_usage": + return Self.makeResponse(url: url, body: #"{"subscription":{"active":false}}"#) + case "/users/me/quotas": + return Self.makeResponse(url: url, body: #""" + { + "data": [ + { + "chute_id": "wrapped", + "quota": 200 + } + ] + } + """#) + case "/users/me/quota_usage/wrapped": + return Self.makeResponse(url: url, body: #"{"quota":200,"used":50}"#) + default: + throw URLError(.badURL) + } + } + + let snapshot = try await ChutesUsageFetcher.fetchUsage( + apiKey: "chutes-key", + environment: [ChutesSettingsReader.apiURLEnvironmentKey: "https://chutes.test"], + transport: transport) + + #expect(snapshot.toUsageSnapshot().primary?.usedPercent == 25) + let requests = await transport.requests() + #expect(requests.compactMap { $0.url?.path } == [ + "/users/me/subscription_usage", + "/users/me/quotas", + "/users/me/quota_usage/wrapped", + ]) + } + + @Test + func `partial subscription usage fills missing rolling window from quotas`() async throws { + let now = Date(timeIntervalSince1970: 1_800_000_000) + let transport = ProviderHTTPTransportStub { request in + let url = try #require(request.url) + switch url.path { + case "/users/me/subscription_usage": + return Self.makeResponse(url: url, body: #""" + { + "subscription": { + "active": true, + "plan_name": "Pro", + "current_period_end": "2026-07-01T00:00:00Z" + }, + "monthly": { + "used": 250, + "limit": 1000, + "unit": "credits" + } + } + """#) + case "/users/me/quotas": + return Self.makeResponse(url: url, body: #""" + { + "rolling_window": { + "requests": 40, + "limit": 100, + "window_minutes": 240, + "unit": "requests" + } + } + """#) + default: + throw URLError(.badURL) + } + } + + let snapshot = try await ChutesUsageFetcher.fetchUsage( + apiKey: "chutes-key", + environment: [ChutesSettingsReader.apiURLEnvironmentKey: "https://chutes.test"], + transport: transport, + now: now) + let usage = snapshot.toUsageSnapshot() + + #expect(usage.primary?.usedPercent == 40) + #expect(usage.primary?.windowMinutes == 240) + #expect(usage.primary?.resetDescription == "40/100 requests") + #expect(usage.secondary?.usedPercent == 25) + #expect(usage.secondary?.resetDescription == "250/1000 credits") + #expect(usage.loginMethod(for: .chutes) == "Pro") + + let requests = await transport.requests() + let paths = requests.compactMap { $0.url?.path } + #expect(paths == ["/users/me/subscription_usage", "/users/me/quotas"]) + } + + @Test + func `missing usage fields returns no data snapshot without decode failure`() throws { + let data = Data(#"{"subscription":{"active":true},"unexpected":{"nested":true}}"#.utf8) + let snapshot = try ChutesUsageParser.parse(data: data, now: Date(timeIntervalSince1970: 123)) + let usage = snapshot.toUsageSnapshot() + + #expect(!snapshot.hasUsageData) + #expect(usage.primary == nil) + #expect(usage.secondary == nil) + #expect(usage.loginMethod(for: .chutes) == nil) + } + + @Test + func `identical usage values keep distinct quota windows`() throws { + let data = Data(#""" + { + "quotas": [ + { + "used": 0, + "limit": 100, + "window_minutes": 240 + }, + { + "used": 0, + "limit": 100, + "window_minutes": 43200 + } + ] + } + """#.utf8) + + let snapshot = try ChutesUsageParser.parse(data: data, now: Date(timeIntervalSince1970: 123)) + let usage = snapshot.toUsageSnapshot() + + #expect(usage.primary?.usedPercent == 0) + #expect(usage.primary?.windowMinutes == 240) + #expect(usage.secondary?.usedPercent == 0) + #expect(usage.secondary?.windowMinutes == 43200) + } + + @Test + func `exact percent value of one stays one percent`() throws { + let usedData = Data(#""" + { + "rolling_window": { + "usage_percent": 1 + } + } + """#.utf8) + let remainingData = Data(#""" + { + "rolling_window": { + "percent_remaining": 1 + } + } + """#.utf8) + + let usedSnapshot = try ChutesUsageParser.parse( + data: usedData, + now: Date(timeIntervalSince1970: 123)) + let remainingSnapshot = try ChutesUsageParser.parse( + data: remainingData, + now: Date(timeIntervalSince1970: 123)) + + #expect(usedSnapshot.toUsageSnapshot().primary?.usedPercent == 1) + #expect(remainingSnapshot.toUsageSnapshot().primary?.usedPercent == 99) + } + + @Test + func `auth failure surfaces invalid credentials`() async { + let transport = ProviderHTTPTransportStub { request in + let url = try #require(request.url) + return Self.makeResponse(url: url, body: #"{"detail":"unauthorized"}"#, statusCode: 401) + } + + await #expect { + _ = try await ChutesUsageFetcher.fetchUsage( + apiKey: "bad-key", + environment: [ChutesSettingsReader.apiURLEnvironmentKey: "https://chutes.test"], + transport: transport) + } throws: { error in + guard case ChutesUsageError.invalidCredentials = error else { return false } + return true + } + } + + @Test + func `descriptor and app implementation registry include Chutes`() throws { + let descriptor = ProviderDescriptorRegistry.descriptor(for: .chutes) + #expect(descriptor.metadata.displayName == "Chutes") + #expect(ProviderDescriptorRegistry.all.contains { $0.id == .chutes }) + + let implementation = try #require(ProviderImplementationRegistry.implementation(for: .chutes)) + #expect(implementation is ChutesProviderImplementation) + } + + private static func makeResponse( + url: URL, + body: String, + statusCode: Int = 200) -> (Data, URLResponse) + { + let response = HTTPURLResponse( + url: url, + statusCode: statusCode, + httpVersion: "HTTP/1.1", + headerFields: ["Content-Type": "application/json"])! + return (Data(body.utf8), response) + } + + private static func date(_ text: String) throws -> Date { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime] + return try #require(formatter.date(from: text)) + } +} diff --git a/Tests/CodexBarTests/ClaudeAdminAPIInlineDashboardModelTests.swift b/Tests/CodexBarTests/ClaudeAdminAPIInlineDashboardModelTests.swift new file mode 100644 index 0000000000..0c86ea287a --- /dev/null +++ b/Tests/CodexBarTests/ClaudeAdminAPIInlineDashboardModelTests.swift @@ -0,0 +1,71 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +struct ClaudeAdminAPIInlineDashboardModelTests { + @Test + func `claude admin api usage gets inline dashboard`() throws { + let now = try Self.localNoon(year: 2023, month: 11, day: 17) + let bucketDay = try Self.localNoon(year: 2023, month: 11, day: 14) + let metadata = try #require(ProviderDefaults.metadata[.claude]) + let usage = ClaudeAdminAPIUsageSnapshot( + daily: [ + ClaudeAdminAPIUsageSnapshot.DailyBucket( + day: "2023-11-14", + startTime: bucketDay, + endTime: bucketDay.addingTimeInterval(86400), + costUSD: 1.25, + inputTokens: 1000, + cacheCreationInputTokens: 400, + cacheReadInputTokens: 300, + outputTokens: 250, + totalTokens: 1950, + costItems: [ + ClaudeAdminAPIUsageSnapshot.CostBreakdown(name: "Claude Sonnet Usage", costUSD: 1.25), + ], + models: [ + ClaudeAdminAPIUsageSnapshot.ModelBreakdown( + name: "claude-sonnet-4-20250514", + inputTokens: 1000, + cacheCreationInputTokens: 400, + cacheReadInputTokens: 300, + outputTokens: 250, + totalTokens: 1950), + ]), + ], + updatedAt: now) + + let model = UsageMenuCardView.Model.make(.init( + provider: .claude, + metadata: metadata, + snapshot: usage.toUsageSnapshot(), + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + + #expect(model.metrics.isEmpty) + #expect(model.inlineUsageDashboard?.kpis.first?.value == "$0.00") + #expect(model.inlineUsageDashboard?.points.first?.accessibilityValue == "2023-11-14: $1.25") + #expect(model.inlineUsageDashboard?.detailLines + .contains { $0.hasPrefix("30d:") && $0.contains("tokens") } == true) + #expect(model.inlineUsageDashboard?.detailLines.contains("Top model: claude-sonnet-4-20250514") == true) + #expect(model.planText == "Admin API") + } + + private static func localNoon(year: Int, month: Int, day: Int) throws -> Date { + try #require(Calendar.current.date(from: DateComponents(year: year, month: month, day: day, hour: 12))) + } +} diff --git a/Tests/CodexBarTests/ClaudeAdminAPIUsageTests.swift b/Tests/CodexBarTests/ClaudeAdminAPIUsageTests.swift index 6a63827e99..51ccf8b7b1 100644 --- a/Tests/CodexBarTests/ClaudeAdminAPIUsageTests.swift +++ b/Tests/CodexBarTests/ClaudeAdminAPIUsageTests.swift @@ -181,6 +181,33 @@ struct ClaudeAdminAPIUsageTests { #expect(usage.identity?.loginMethod == "Admin API") } + @Test + func `current day summary is zero when Claude admin history is stale`() throws { + let now = try Self.localNoon(year: 2023, month: 11, day: 17) + let bucketDay = try Self.localNoon(year: 2023, month: 11, day: 14) + let apiUsage = ClaudeAdminAPIUsageSnapshot( + daily: [ + ClaudeAdminAPIUsageSnapshot.DailyBucket( + day: "2023-11-14", + startTime: bucketDay, + endTime: bucketDay.addingTimeInterval(86400), + costUSD: 8.5, + inputTokens: 1000, + cacheCreationInputTokens: 400, + cacheReadInputTokens: 300, + outputTokens: 250, + totalTokens: 1950, + costItems: [], + models: []), + ], + updatedAt: now) + + #expect(apiUsage.currentDay.costUSD == 0) + #expect(apiUsage.currentDay.totalTokens == 0) + #expect(apiUsage.latestDay.costUSD == 8.5) + #expect(apiUsage.latestDay.totalTokens == 1950) + } + @Test func `fetch strategy reports admin api source label`() async throws { let strategy = ClaudeAdminAPIFetchStrategy(usageFetcher: { apiKey in @@ -193,4 +220,8 @@ struct ClaudeAdminAPIUsageTests { #expect(result.sourceLabel == "admin-api") #expect(result.usage.identity?.loginMethod == "Admin API") } + + private static func localNoon(year: Int, month: Int, day: Int) throws -> Date { + try #require(Calendar.current.date(from: DateComponents(year: year, month: month, day: day, hour: 12))) + } } diff --git a/Tests/CodexBarTests/ClaudeAllModelsWeeklyDuplicateTests.swift b/Tests/CodexBarTests/ClaudeAllModelsWeeklyDuplicateTests.swift new file mode 100644 index 0000000000..050060c641 --- /dev/null +++ b/Tests/CodexBarTests/ClaudeAllModelsWeeklyDuplicateTests.swift @@ -0,0 +1,153 @@ +import CodexBarCore +import Foundation +import Testing + +/// The Claude CLI renders /usage as a redrawing TUI, so a half-painted capture frame can drop a +/// character from "Current week (all models)" (-> "all modls"). These cover the shapes that matters: +/// the garbled copy must not become a bogus second weekly row, an exact copy must always win the +/// Weekly value regardless of order, the Weekly limit must still be recovered when only the garbled +/// copy survived, genuine model-scoped rows must be preserved, and the edit-distance boundary is +/// pinned so heavier corruption stays a (visible) scoped row rather than being silently absorbed. +struct ClaudeAllModelsWeeklyDuplicateTests { + @Test + func `garbled all-models copy does not duplicate the weekly row`() throws { + let sample = """ + Settings: Status Config Usage (tab to cycle) + + Current session + ▌ 0% used + Resets 1:10pm (Asia/Seoul) + + Current week (all models) + █████████████████████████████████▌ 66% used + Resets Jul 24 at 2pm (Asia/Seoul) + + Current week (all modls) + █████████████████████████████████▌ 67% used + Resets Jul 24 at 1:59pm (Asia/Seoul) + + Current week (Fable) + ████████████████████████████████████ 71% used + Resets Jul 24 at 2pm (Asia/Seoul) + """ + + let snap = try ClaudeStatusProbe.parse(text: sample) + #expect(snap.weeklyPercentLeft == 34) + let titles = snap.extraRateWindows.map(\.title) + #expect(titles == ["Fable only"], "unexpected scoped weekly rows: \(titles)") + } + + @Test + func `exact all-models row wins over an earlier garbled duplicate`() throws { + // Garbled 67% copy appears BEFORE the clean 66% copy; the Weekly value and reset must still + // come from the exact row (34% left, 2pm), not the corrupted one that happens to be first. + let sample = """ + Settings: Status Config Usage (tab to cycle) + + Current session + ▌ 0% used + Resets 1:10pm (Asia/Seoul) + + Current week (all modls) + █████████████████████████████████▌ 67% used + Resets Jul 24 at 1:59pm (Asia/Seoul) + + Current week (all models) + █████████████████████████████████▌ 66% used + Resets Jul 24 at 2pm (Asia/Seoul) + """ + + let snap = try ClaudeStatusProbe.parse(text: sample) + #expect(snap.weeklyPercentLeft == 34) + #expect(snap.secondaryResetDescription == "Resets Jul 24 at 2pm (Asia/Seoul)") + #expect(snap.extraRateWindows.isEmpty, "garbled copy leaked as a scoped row") + } + + @Test + func `garbled all-models line still populates the weekly limit`() throws { + // Only the corrupted all-models label survived this capture; the Weekly quota must be + // recovered from it rather than dropped, and it must not appear as a scoped row. + let sample = """ + Settings: Status Config Usage (tab to cycle) + + Current session + ▌ 0% used + Resets 1:10pm (Asia/Seoul) + + Current week (all modls) + █████████████████████████████████▌ 66% used + Resets Jul 24 at 2pm (Asia/Seoul) + + Current week (Fable) + ████████████████████████████████████ 71% used + Resets Jul 24 at 2pm (Asia/Seoul) + """ + + let snap = try ClaudeStatusProbe.parse(text: sample) + #expect(snap.weeklyPercentLeft == 34) + #expect(snap.secondaryResetDescription == "Resets Jul 24 at 2pm (Asia/Seoul)") + let titles = snap.extraRateWindows.map(\.title) + #expect(titles == ["Fable only"], "unexpected scoped weekly rows: \(titles)") + } + + @Test + func `single-character insertion is treated as all-models`() throws { + // A dropped-then-doubled render ("all modelss", edit distance 1) is still the aggregate row. + let sample = """ + Current session + ▌ 0% used + Resets 1:10pm (Asia/Seoul) + + Current week (all modelss) + █████████████████████████████████▌ 66% used + Resets Jul 24 at 2pm (Asia/Seoul) + + Current week (Fable) + ████████████████████████████████████ 71% used + Resets Jul 24 at 2pm (Asia/Seoul) + """ + + let snap = try ClaudeStatusProbe.parse(text: sample) + #expect(snap.weeklyPercentLeft == 34) + #expect(snap.extraRateWindows.map(\.title) == ["Fable only"]) + } + + @Test + func `genuine scoped model is preserved and leaves the weekly limit unset`() throws { + // No all-models row at all: the Weekly limit must be nil and the scoped Haiku row kept. + // (Sonnet/Opus are folded into opusPercentLeft, so Haiku is used to exercise the extra-row path.) + let sample = """ + Current session + ▌ 0% used + Resets 1:10pm (Asia/Seoul) + + Current week (Haiku) + █▌ 10% used + Resets Jul 24 at 2pm (Asia/Seoul) + """ + + let snap = try ClaudeStatusProbe.parse(text: sample) + #expect(snap.weeklyPercentLeft == nil) + #expect(snap.extraRateWindows.map(\.title) == ["Haiku only"]) + } + + @Test + func `heavier corruption stays a visible scoped row`() throws { + // "almdls" is edit distance 3 from "allmodels" — beyond the tolerance. We intentionally do + // NOT absorb it into Weekly (that would risk swallowing a real future scoped model); instead + // it stays visible as its own row so nothing is silently dropped. + let sample = """ + Current session + ▌ 0% used + Resets 1:10pm (Asia/Seoul) + + Current week (almdls) + █████████████████████████████████▌ 66% used + Resets Jul 24 at 2pm (Asia/Seoul) + """ + + let snap = try ClaudeStatusProbe.parse(text: sample) + #expect(snap.weeklyPercentLeft == nil) + #expect(snap.extraRateWindows.map(\.title) == ["almdls only"]) + } +} diff --git a/Tests/CodexBarTests/ClaudeBaselineCharacterizationTests.swift b/Tests/CodexBarTests/ClaudeBaselineCharacterizationTests.swift index ff3a80c1af..22a3696dce 100644 --- a/Tests/CodexBarTests/ClaudeBaselineCharacterizationTests.swift +++ b/Tests/CodexBarTests/ClaudeBaselineCharacterizationTests.swift @@ -4,7 +4,14 @@ import Testing @Suite(.serialized) struct ClaudeBaselineCharacterizationTests { - private func makeStubClaudeCLI() throws -> String { + private func makeStubClaudeCLI(loggedIn: Bool = true, invocationLog: URL? = nil) throws -> String { + let loggedInJSON = loggedIn ? "true" : "false" + return try self.makeStubClaudeCLI( + authStatusScript: "printf '%s\\n' '{\"loggedIn\":\(loggedInJSON)}'", + invocationLog: invocationLog) + } + + private func makeStubClaudeCLI(authStatusScript: String, invocationLog: URL? = nil) throws -> String { let sample = """ Current session 12% used (Resets 11am) @@ -15,8 +22,14 @@ struct ClaudeBaselineCharacterizationTests { Account: user@example.com Org: Example Org """ + let recordInvocation = invocationLog.map { "printf '%s\\n' \"$*\" >> '\($0.path)'" } ?? "" let script = """ #!/bin/sh + \(recordInvocation) + if [ "$1" = "auth" ] && [ "$2" = "status" ]; then + \(authStatusScript) + exit 0 + fi cat <<'EOF' \(sample) EOF @@ -95,6 +108,12 @@ struct ClaudeBaselineCharacterizationTests { } } + private func withBackgroundKeychainAccess(operation: () async throws -> T) async rethrows -> T { + try await KeychainAccessGate.withTaskOverrideForTesting(false) { + try await operation() + } + } + @Test func `app auto pipeline order is OAuth then CLI then web`() async { let settings = ProviderSettingsSnapshot.make(claude: .init( @@ -176,6 +195,213 @@ struct ClaudeBaselineCharacterizationTests { } } + @Test + func `app background auto does not start Claude CLI before foreground availability`() async throws { + let settings = ProviderSettingsSnapshot.make(claude: .init( + usageDataSource: .auto, + webExtrasEnabled: false, + cookieSource: .off, + manualCookieHeader: nil)) + let invocationLog = FileManager.default.temporaryDirectory + .appendingPathComponent("claude-invocations-\(UUID().uuidString).log") + let stubCLIPath = try self.makeStubClaudeCLI(loggedIn: false, invocationLog: invocationLog) + let env = ["CLAUDE_CLI_PATH": stubCLIPath] + + await ClaudeCLIBackgroundAvailability.withIsolatedStoreForTesting { + await self.withBackgroundKeychainAccess { + await ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.always) { + await self.withNoOAuthCredentials { + let outcome = await self.fetchOutcome( + runtime: .app, + sourceMode: .auto, + env: env, + settings: settings) + + #expect(outcome.attempts.map(\.strategyID) == ["claude.oauth", "claude.cli", "claude.web"]) + #expect(outcome.attempts.map(\.wasAvailable) == [false, false, false]) + } + } + } + } + + #expect(!FileManager.default.fileExists(atPath: invocationLog.path)) + } + + @Test + func `app background auto honors stored user action policy with experimental reader`() async throws { + let settings = ProviderSettingsSnapshot.make(claude: .init( + usageDataSource: .auto, + webExtrasEnabled: false, + cookieSource: .off, + manualCookieHeader: nil)) + let invocationLog = FileManager.default.temporaryDirectory + .appendingPathComponent("claude-invocations-\(UUID().uuidString).log") + let stubCLIPath = try self.makeStubClaudeCLI(invocationLog: invocationLog) + let env = ["CLAUDE_CLI_PATH": stubCLIPath] + + await ClaudeOAuthKeychainReadStrategyPreference.withTaskOverrideForTesting(.securityCLIExperimental) { + await ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.onlyOnUserAction) { + await self.withNoOAuthCredentials { + let outcome = await self.fetchOutcome( + runtime: .app, + sourceMode: .auto, + env: env, + settings: settings) + #expect(outcome.attempts.map(\.strategyID) == ["claude.oauth", "claude.cli", "claude.web"]) + #expect(outcome.attempts.map(\.wasAvailable) == [false, false, false]) + } + } + } + + #expect(!FileManager.default.fileExists(atPath: invocationLog.path)) + } + + @Test + func `app background auto does not launch Claude CLI when Keychain access is disabled`() async throws { + let settings = ProviderSettingsSnapshot.make(claude: .init( + usageDataSource: .auto, + webExtrasEnabled: false, + cookieSource: .off, + manualCookieHeader: nil)) + let invocationLog = FileManager.default.temporaryDirectory + .appendingPathComponent("claude-invocations-\(UUID().uuidString).log") + let stubCLIPath = try self.makeStubClaudeCLI(loggedIn: false, invocationLog: invocationLog) + let env = ["CLAUDE_CLI_PATH": stubCLIPath] + let descriptor = ProviderDescriptorRegistry.descriptor(for: .claude) + let context = self.makeContext(runtime: .app, sourceMode: .auto, env: env, settings: settings) + let strategies = await descriptor.fetchPlan.pipeline.resolveStrategies(context) + let cli = try #require(strategies.first { $0.id == "claude.cli" }) + + let cliAvailable = await ClaudeCLIBackgroundAvailability.withIsolatedStoreForTesting { + await KeychainAccessGate.withTaskOverrideForTesting(true) { + await ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.always) { + await ProviderInteractionContext.$current.withValue(.background) { + await cli.isAvailable(context) + } + } + } + } + + #expect(!cliAvailable) + #expect(!FileManager.default.fileExists(atPath: invocationLog.path)) + } + + @Test + func `app background auto falls back to web without probing Claude CLI`() async throws { + let settings = ProviderSettingsSnapshot.make(claude: .init( + usageDataSource: .auto, + webExtrasEnabled: false, + cookieSource: .manual, + manualCookieHeader: "sessionKey=sk-ant-session-token")) + let invocationLog = FileManager.default.temporaryDirectory + .appendingPathComponent("claude-invocations-\(UUID().uuidString).log") + let stubCLIPath = try self.makeStubClaudeCLI(invocationLog: invocationLog) + let env = ["CLAUDE_CLI_PATH": stubCLIPath] + let usageLoader: ClaudeWebFetchStrategy.UsageLoader = { _ in + ClaudeUsageSnapshot( + primary: RateWindow( + usedPercent: 20, + windowMinutes: 300, + resetsAt: nil, + resetDescription: nil), + secondary: nil, + opus: nil, + updatedAt: Date(timeIntervalSince1970: 1_800_000_100), + accountEmail: nil, + accountOrganization: nil, + loginMethod: nil, + rawText: nil) + } + + let outcome = await ClaudeCLIBackgroundAvailability.withIsolatedStoreForTesting { + await self.withBackgroundKeychainAccess { + await ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.always) { + await self.withNoOAuthCredentials { + await ClaudeWebFetchStrategy.$usageLoaderOverrideForTesting.withValue(usageLoader) { + await self.fetchOutcome(runtime: .app, sourceMode: .auto, env: env, settings: settings) + } + } + } + } + } + let result = try outcome.result.get() + + #expect(outcome.attempts.map(\.strategyID) == ["claude.oauth", "claude.cli", "claude.web"]) + #expect(outcome.attempts.map(\.wasAvailable) == [false, false, true]) + #expect(result.strategyID == "claude.web") + #expect(!FileManager.default.fileExists(atPath: invocationLog.path)) + } + + @Test + func `app user initiated auto preserves CLI fallback without auth preflight`() async throws { + let settings = ProviderSettingsSnapshot.make(claude: .init( + usageDataSource: .auto, + webExtrasEnabled: false, + cookieSource: .off, + manualCookieHeader: nil)) + let invocationLog = FileManager.default.temporaryDirectory + .appendingPathComponent("claude-invocations-\(UUID().uuidString).log") + let stubCLIPath = try self.makeStubClaudeCLI(loggedIn: false, invocationLog: invocationLog) + let env = ["CLAUDE_CLI_PATH": stubCLIPath] + let descriptor = ProviderDescriptorRegistry.descriptor(for: .claude) + let context = self.makeContext(runtime: .app, sourceMode: .auto, env: env, settings: settings) + let strategies = await descriptor.fetchPlan.pipeline.resolveStrategies(context) + let cli = try #require(strategies.first { $0.id == "claude.cli" }) + + let cliAvailable = await ProviderInteractionContext.$current.withValue(.userInitiated) { + await cli.isAvailable(context) + } + + #expect(cliAvailable) + #expect(!FileManager.default.fileExists(atPath: invocationLog.path)) + } + + @Test + func `successful user initiated CLI fetch establishes background availability`() async throws { + let settings = ProviderSettingsSnapshot.make(claude: .init( + usageDataSource: .auto, + webExtrasEnabled: false, + cookieSource: .off, + manualCookieHeader: nil)) + let stubCLIPath = try self.makeStubClaudeCLI() + let env = ["CLAUDE_CLI_PATH": stubCLIPath] + let descriptor = ProviderDescriptorRegistry.descriptor(for: .claude) + let context = self.makeContext(runtime: .app, sourceMode: .auto, env: env, settings: settings) + let strategies = await descriptor.fetchPlan.pipeline.resolveStrategies(context) + let cli = try #require(strategies.first { $0.id == "claude.cli" }) + let fetchOverride: @Sendable (String, TimeInterval, Bool) async throws + -> ClaudeStatusSnapshot = { binary, _, _ in + #expect(binary == stubCLIPath) + return ClaudeStatusSnapshot( + sessionPercentLeft: 88, + weeklyPercentLeft: 60, + opusPercentLeft: 95, + accountEmail: "user@example.com", + accountOrganization: "Example Org", + loginMethod: nil, + primaryResetDescription: "Resets 11am", + secondaryResetDescription: "Resets Nov 21", + opusResetDescription: "Resets Nov 21", + rawText: "") + } + + try await ClaudeCLIBackgroundAvailability.withIsolatedStoreForTesting { + _ = try await ClaudeStatusProbe.$fetchOverride.withValue(fetchOverride) { + try await ProviderInteractionContext.$current.withValue(.userInitiated) { + try await cli.fetch(context) + } + } + let available = await KeychainAccessGate.withTaskOverrideForTesting(true) { + await ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.never) { + await ProviderInteractionContext.$current.withValue(.background) { + await cli.isAvailable(context) + } + } + } + #expect(available) + } + } + @Test func `app auto pipeline retains OAuth bootstrap strategy at startup`() async { let settings = ProviderSettingsSnapshot.make(claude: .init( @@ -220,39 +446,46 @@ struct ClaudeBaselineCharacterizationTests { let stubCLIPath = try self.makeStubClaudeCLI() let env = ["CLAUDE_CLI_PATH": stubCLIPath] - await self.withNoOAuthCredentials { - let fetchOverride: @Sendable (String, TimeInterval, Bool) async throws - -> ClaudeStatusSnapshot = { binary, _, _ in - #expect(binary == stubCLIPath) - return ClaudeStatusSnapshot( - sessionPercentLeft: 88, - weeklyPercentLeft: 60, - opusPercentLeft: 95, - accountEmail: "user@example.com", - accountOrganization: "Example Org", - loginMethod: nil, - primaryResetDescription: "Resets 11am", - secondaryResetDescription: "Resets Nov 21", - opusResetDescription: "Resets Nov 21", - rawText: "stub") - } - let outcome = await ClaudeStatusProbe.$fetchOverride.withValue(fetchOverride) { - await self.fetchOutcome(runtime: .app, sourceMode: .auto, env: env, settings: settings) - } + await ClaudeCLIBackgroundAvailability.withIsolatedStoreForTesting { + ClaudeCLIBackgroundAvailability.establish(binary: stubCLIPath) + await self.withBackgroundKeychainAccess { + await ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.always) { + await self.withNoOAuthCredentials { + let fetchOverride: @Sendable (String, TimeInterval, Bool) async throws + -> ClaudeStatusSnapshot = { binary, _, _ in + #expect(binary == stubCLIPath) + return ClaudeStatusSnapshot( + sessionPercentLeft: 88, + weeklyPercentLeft: 60, + opusPercentLeft: 95, + accountEmail: "user@example.com", + accountOrganization: "Example Org", + loginMethod: nil, + primaryResetDescription: "Resets 11am", + secondaryResetDescription: "Resets Nov 21", + opusResetDescription: "Resets Nov 21", + rawText: "stub") + } + let outcome = await ClaudeStatusProbe.$fetchOverride.withValue(fetchOverride) { + await self.fetchOutcome(runtime: .app, sourceMode: .auto, env: env, settings: settings) + } + + #expect(outcome.attempts.map(\.strategyID) == ["claude.oauth", "claude.cli"]) + #expect(outcome.attempts.map(\.wasAvailable) == [false, true]) - #expect(outcome.attempts.map(\.strategyID) == ["claude.oauth", "claude.cli"]) - #expect(outcome.attempts.map(\.wasAvailable) == [false, true]) - - switch outcome.result { - case let .success(result): - #expect(result.strategyID == "claude.cli") - #expect(result.sourceLabel == "claude") - #expect(result.usage.primary?.usedPercent == 12) - #expect(result.usage.secondary?.usedPercent == 40) - #expect(result.usage.tertiary?.usedPercent == 5) - #expect(result.usage.identity?.accountEmail == "user@example.com") - case let .failure(error): - Issue.record("Unexpected failure: \(error)") + switch outcome.result { + case let .success(result): + #expect(result.strategyID == "claude.cli") + #expect(result.sourceLabel == "claude") + #expect(result.usage.primary?.usedPercent == 12) + #expect(result.usage.secondary?.usedPercent == 40) + #expect(result.usage.tertiary?.usedPercent == 5) + #expect(result.usage.identity?.accountEmail == "user@example.com") + case let .failure(error): + Issue.record("Unexpected failure: \(error)") + } + } + } } } } diff --git a/Tests/CodexBarTests/ClaudeCLIAuthStatusProbeTests.swift b/Tests/CodexBarTests/ClaudeCLIAuthStatusProbeTests.swift new file mode 100644 index 0000000000..ecdca5fca6 --- /dev/null +++ b/Tests/CodexBarTests/ClaudeCLIAuthStatusProbeTests.swift @@ -0,0 +1,16 @@ +import Testing +@testable import CodexBarCore + +struct ClaudeCLIAuthStatusProbeTests { + @Test + func `parses logged in status`() { + #expect(ClaudeCLIAuthStatusProbe.parseLoggedIn(#"{"loggedIn":true,"authMethod":"claude.ai"}"#)) + } + + @Test + func `rejects logged out and malformed status`() { + #expect(!ClaudeCLIAuthStatusProbe.parseLoggedIn(#"{"loggedIn":false,"authMethod":"none"}"#)) + #expect(!ClaudeCLIAuthStatusProbe.parseLoggedIn("not-json")) + #expect(!ClaudeCLIAuthStatusProbe.parseLoggedIn(#"{"authMethod":"none"}"#)) + } +} diff --git a/Tests/CodexBarTests/ClaudeCLIBackgroundAvailabilityTests.swift b/Tests/CodexBarTests/ClaudeCLIBackgroundAvailabilityTests.swift new file mode 100644 index 0000000000..72a2ea0808 --- /dev/null +++ b/Tests/CodexBarTests/ClaudeCLIBackgroundAvailabilityTests.swift @@ -0,0 +1,105 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct ClaudeCLIBackgroundAvailabilityTests { + @Test + func `background Auto CLI is unavailable before a user establishes availability`() async { + let strategy = self.makeStrategy() + let context = self.makeContext() + + await ClaudeCLIBackgroundAvailability.withIsolatedStoreForTesting { + await KeychainAccessGate.withTaskOverrideForTesting(true) { + await ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.never) { + await ClaudeCLIResolver.withResolvedBinaryPathOverrideForTesting("/bin/echo") { + await ProviderInteractionContext.$current.withValue(.background) { + #expect(await !strategy.isAvailable(context)) + } + } + } + } + } + } + + @Test + func `disabled Keychain allows background Auto after foreground availability is established`() async { + let strategy = self.makeStrategy() + let context = self.makeContext() + + await ClaudeCLIBackgroundAvailability.withIsolatedStoreForTesting { + ClaudeCLIBackgroundAvailability.establish(binary: "/bin/echo") + await KeychainAccessGate.withTaskOverrideForTesting(true) { + await ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.never) { + await ClaudeCLIResolver.withResolvedBinaryPathOverrideForTesting("/bin/echo") { + await ProviderInteractionContext.$current.withValue(.background) { + #expect(await strategy.isAvailable(context)) + } + } + } + } + } + } + + @Test + func `background Auto CLI keeps prompt policy after foreground availability is established`() async { + let strategy = self.makeStrategy() + let context = self.makeContext() + + await ClaudeCLIBackgroundAvailability.withIsolatedStoreForTesting { + ClaudeCLIBackgroundAvailability.establish(binary: "/bin/echo") + await KeychainAccessGate.withTaskOverrideForTesting(false) { + await ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.onlyOnUserAction) { + await ClaudeCLIResolver.withResolvedBinaryPathOverrideForTesting("/bin/echo") { + await ProviderInteractionContext.$current.withValue(.background) { + #expect(await !strategy.isAvailable(context)) + } + } + } + } + } + } + + @Test + func `background Auto CLI uses foreground availability with explicit prompt opt in`() async { + let strategy = self.makeStrategy() + let context = self.makeContext() + + await ClaudeCLIBackgroundAvailability.withIsolatedStoreForTesting { + ClaudeCLIBackgroundAvailability.establish(binary: "/bin/echo") + await KeychainAccessGate.withTaskOverrideForTesting(false) { + await ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.always) { + await ClaudeCLIResolver.withResolvedBinaryPathOverrideForTesting("/bin/echo") { + await ProviderInteractionContext.$current.withValue(.background) { + #expect(await strategy.isAvailable(context)) + } + } + } + } + } + } + + private func makeStrategy() -> ClaudeCLIFetchStrategy { + ClaudeCLIFetchStrategy( + useWebExtras: false, + includePrepaidBalance: false, + manualCookieHeader: nil, + browserDetection: BrowserDetection(cacheTTL: 0), + hasWebFallback: false) + } + + private func makeContext() -> ProviderFetchContext { + let browserDetection = BrowserDetection(cacheTTL: 0) + return ProviderFetchContext( + runtime: .app, + sourceMode: .auto, + includeCredits: false, + webTimeout: 1, + webDebugDumpHTML: false, + verbose: false, + env: [:], + settings: nil, + fetcher: UsageFetcher(environment: [:]), + claudeFetcher: ClaudeUsageFetcher(browserDetection: browserDetection), + browserDetection: browserDetection) + } +} diff --git a/Tests/CodexBarTests/ClaudeCLIScopedWeeklyUsageTests.swift b/Tests/CodexBarTests/ClaudeCLIScopedWeeklyUsageTests.swift new file mode 100644 index 0000000000..6f7cd3ce15 --- /dev/null +++ b/Tests/CodexBarTests/ClaudeCLIScopedWeeklyUsageTests.swift @@ -0,0 +1,342 @@ +import Foundation +import Testing +@testable import CodexBar +@testable import CodexBarCore + +struct ClaudeCLIScopedWeeklyUsageTests { + @Test + func `CLI usage surfaces Fable scoped weekly limit`() async throws { + let cliUsage = """ + Settings Status Config Usage Stats + + Current session + 9% used + Resets 2:09pm (Europe/Prague) + + Current week (all models) + 67% used + Resets Jul 10 t 2:59am (Europe/Prague) + + Current week (Fable) + 68% used + Reset Jul 10 at 2:59am (Europe/Prague) + + Current week (Example Model) + 12% used + """ + let status = try ClaudeStatusProbe.parse(text: cliUsage) + let fetcher = ClaudeUsageFetcher( + browserDetection: BrowserDetection(cacheTTL: 0), + environment: [:], + dataSource: .cli) + let fetchOverride: ClaudeStatusProbe.FetchOverride = { _, _, _ in status } + + let snapshot = try await ClaudeCLIResolver.withResolvedBinaryPathOverrideForTesting("/usr/bin/true") { + try await ClaudeStatusProbe.withFetchOverrideForTesting(fetchOverride) { + try await fetcher.loadLatestUsage(model: "sonnet") + } + } + + let fable = try #require(snapshot.extraRateWindows.first { $0.id == "claude-weekly-scoped-fable" }) + #expect(fable.title == "Fable only") + #expect(fable.window.usedPercent == 68) + #expect(fable.window.resetDescription == "Reset Jul 10 at 2:59am (Europe/Prague)") + let example = try #require( + snapshot.extraRateWindows.first { $0.id == "claude-weekly-scoped-example-model" }) + #expect(example.title == "Example Model only") + #expect(example.window.usedPercent == 12) + #expect(example.window.resetDescription == "Resets Jul 10 at 2:59am (Europe/Prague)") + #expect(snapshot.opus == nil) + } + + @Test + func `scoped weekly panel does not become all models weekly usage`() throws { + let cliUsage = """ + Current session + 9% used + + Current week (Fable) + 68% used + """ + + let snapshot = try ClaudeStatusProbe.parse(text: cliUsage) + + #expect(snapshot.weeklyPercentLeft == nil) + #expect(snapshot.secondaryResetDescription == nil) + #expect(snapshot.extraRateWindows.map(\.title) == ["Fable only"]) + } + + @Test + func `compact scoped weekly label is parsed`() throws { + let snapshot = try ClaudeStatusProbe.parse(text: """ + Current session + 9% used + + Currentweek(Fable) + 68% used + """) + + #expect(snapshot.extraRateWindows.map(\.title) == ["Fable only"]) + #expect(snapshot.extraRateWindows.first?.window.usedPercent == 68) + } + + @Test + func `overlapping scoped model names do not cross panel boundaries`() throws { + let snapshot = try ClaudeStatusProbe.parse(text: """ + Current session + 9% used + + Current week (Example Model) + rendering + + Current week (Example Model Plus) + 42% used + """) + + #expect(snapshot.extraRateWindows.map(\.title) == ["Example Model Plus only"]) + #expect(snapshot.extraRateWindows.first?.window.usedPercent == 42) + } + + @Test + func `informational Sonnet prose does not duplicate a scoped limit`() throws { + let snapshot = try ClaudeStatusProbe.parse(text: """ + Current session + 9% used + + Current week (all models) + 20% used + + Current week (Fable) + 42% used + + Sonnet now has its own limit. + """) + + #expect(snapshot.opusPercentLeft == nil) + #expect(snapshot.extraRateWindows.map(\.title) == ["Fable only"]) + #expect(snapshot.extraRateWindows.first?.window.usedPercent == 42) + } + + @Test + func `Sonnet prefixed scoped model does not become legacy quota`() throws { + let snapshot = try ClaudeStatusProbe.parse(text: """ + Current session + 9% used + + Current week (all models) + 20% used + + Current week (Sonnet Test Variant) + 42% used + """) + + #expect(snapshot.opusPercentLeft == nil) + #expect(snapshot.extraRateWindows.map(\.title) == ["Sonnet Test Variant only"]) + #expect(snapshot.extraRateWindows.first?.window.usedPercent == 42) + } + + @Test + func `later complete scoped panel replaces partial redraw`() throws { + let spacer = Array(repeating: "rendering", count: 14).joined(separator: "\n") + let cliUsage = """ + Current session + 9% used + + Current week (all models) + 67% used + + Current week (Fable) + \(spacer) + + Current week (Fable) + 70% used + Reset Jul 10 at 2:59am (Europe/Prague) + """ + + let snapshot = try ClaudeStatusProbe.parse(text: cliUsage) + let fable = try #require(snapshot.extraRateWindows.first) + + #expect(snapshot.extraRateWindows.count == 1) + #expect(fable.window.usedPercent == 70) + #expect(fable.window.resetDescription == "Reset Jul 10 at 2:59am (Europe/Prague)") + } + + @Test + func `incomplete scoped panel stops at session redraw`() throws { + let cliUsage = """ + Current week (Fable) + rendering + + Current session + 9% used + + Current week (all models) + 20% used + """ + + let snapshot = try ClaudeStatusProbe.parse(text: cliUsage) + + #expect(snapshot.sessionPercentLeft == 91) + #expect(snapshot.weeklyPercentLeft == 80) + #expect(snapshot.extraRateWindows.isEmpty) + } + + @Test + func `incomplete all models panel does not consume scoped percentage`() throws { + let cliUsage = """ + Current session + 9% used + + Current week (all models) + rendering + + Current week (Fable) + 42% used + """ + + let snapshot = try ClaudeStatusProbe.parse(text: cliUsage) + + #expect(snapshot.weeklyPercentLeft == nil) + #expect(snapshot.extraRateWindows.map(\.title) == ["Fable only"]) + #expect(snapshot.extraRateWindows.first?.window.usedPercent == 42) + } + + @Test + func `incomplete Opus panel does not consume prefixed scoped percentage`() throws { + let cliUsage = """ + Current session + 9% used + + Current week (all models) + 20% used + + Current week (Opus) + rendering + + Current week (Opus Test Variant) + 42% used + """ + + let snapshot = try ClaudeStatusProbe.parse(text: cliUsage) + + #expect(snapshot.opusPercentLeft == nil) + #expect(snapshot.extraRateWindows.map(\.title) == ["Opus Test Variant only"]) + #expect(snapshot.extraRateWindows.first?.window.usedPercent == 42) + } + + @Test + func `later complete scoped panel replaces earlier complete value`() throws { + let cliUsage = """ + Current session + 9% used + + Current week (all models) + 67% used + + Current week (Fable) + 20% used + + Current week (Fable) + 70% used + """ + + let snapshot = try ClaudeStatusProbe.parse(text: cliUsage) + let fable = try #require(snapshot.extraRateWindows.first) + + #expect(snapshot.extraRateWindows.count == 1) + #expect(fable.window.usedPercent == 70) + } + + @Test + func `web extra windows merge with CLI scoped weekly limits`() throws { + let fable = NamedRateWindow( + id: "claude-weekly-scoped-fable", + title: "Fable only", + window: RateWindow( + usedPercent: 68, + windowMinutes: 7 * 24 * 60, + resetsAt: nil, + resetDescription: "Resets Jul 10 at 2:59am (Europe/Prague)")) + let webFable = try #require(ClaudeScopedWeeklyLimitMapper.extraRateWindows(from: [ + ClaudeScopedWeeklyLimitMapper.Limit( + kind: "weekly_scoped", + group: "weekly", + percent: 70, + resetsAt: nil, + modelID: "test-only-fable-id", + modelName: "Fable"), + ]).first) + let routines = NamedRateWindow( + id: "claude-routines", + title: "Daily Routines", + window: RateWindow( + usedPercent: 11, + windowMinutes: 7 * 24 * 60, + resetsAt: nil, + resetDescription: nil)) + + let merged = ClaudeUsageFetcher._mergeExtraRateWindowsForTesting( + primary: [fable], + web: [webFable, routines]) + + #expect(merged.map(\.id) == ["claude-weekly-scoped-fable", "claude-routines"]) + #expect(webFable.id == "claude-weekly-scoped-test-only-fable-id") + #expect(merged.first?.window.usedPercent == 68) + #expect(merged.last?.title == "Daily Routines") + } + + @Test + func `same title web limits keep distinct stable IDs`() { + let webLimits = ["first-id", "second-id"].map { id in + NamedRateWindow( + id: "claude-weekly-scoped-\(id)", + title: "Example Model only", + window: RateWindow( + usedPercent: 25, + windowMinutes: 7 * 24 * 60, + resetsAt: nil, + resetDescription: nil)) + } + + let merged = ClaudeUsageFetcher._mergeExtraRateWindowsForTesting( + primary: [], + web: webLimits) + + #expect(merged.map(\.id) == [ + "claude-weekly-scoped-first-id", + "claude-weekly-scoped-second-id", + ]) + } + + @Test + func `ambiguous same title web limits survive CLI merge`() { + let cli = NamedRateWindow( + id: "claude-weekly-scoped-example-model", + title: "Example Model only", + window: RateWindow( + usedPercent: 20, + windowMinutes: 7 * 24 * 60, + resetsAt: nil, + resetDescription: nil)) + let webLimits = ["first-id", "second-id"].map { id in + NamedRateWindow( + id: "claude-weekly-scoped-\(id)", + title: "Example Model only", + window: RateWindow( + usedPercent: 25, + windowMinutes: 7 * 24 * 60, + resetsAt: nil, + resetDescription: nil)) + } + + let merged = ClaudeUsageFetcher._mergeExtraRateWindowsForTesting( + primary: [cli], + web: webLimits) + + #expect(merged.map(\.id) == [ + "claude-weekly-scoped-example-model", + "claude-weekly-scoped-first-id", + "claude-weekly-scoped-second-id", + ]) + } +} diff --git a/Tests/CodexBarTests/ClaudeCLISessionTests.swift b/Tests/CodexBarTests/ClaudeCLISessionTests.swift new file mode 100644 index 0000000000..6aa13db62e --- /dev/null +++ b/Tests/CodexBarTests/ClaudeCLISessionTests.swift @@ -0,0 +1,58 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct ClaudeCLISessionTests { + @Test + func `probe launch reuses one persisted session identifier`() throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("codexbar-claude-session-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: directory) } + + let first = ClaudeCLISession.loadOrCreateProbeSessionID(in: directory) + let second = ClaudeCLISession.loadOrCreateProbeSessionID(in: directory) + + #expect(first == second) + #expect(ClaudeCLISession.launchArguments(sessionID: first) == [ + "--allowed-tools", + "", + "--session-id", + first.uuidString.lowercased(), + ]) + + let file = directory.appendingPathComponent(".codexbar-session-id") + let persisted = try String(contentsOf: file, encoding: .utf8) + #expect(persisted == first.uuidString.lowercased()) + #if os(macOS) || os(Linux) + let attributes = try FileManager.default.attributesOfItem(atPath: file.path) + let permissions = try #require(attributes[.posixPermissions] as? NSNumber) + #expect(permissions.intValue == 0o600) + #endif + } + + @Test + func `invalid persisted probe session identifier is replaced`() throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("codexbar-claude-session-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + + let file = directory.appendingPathComponent(".codexbar-session-id") + try "invalid".write(to: file, atomically: true, encoding: .utf8) + + let sessionID = ClaudeCLISession.loadOrCreateProbeSessionID(in: directory) + let persisted = try String(contentsOf: file, encoding: .utf8) + + #expect(persisted == sessionID.uuidString.lowercased()) + } + + @Test + func `unwritable probe directory keeps one process local fallback identifier`() { + let directory = URL(fileURLWithPath: "/dev/null/CodexBar-ClaudeProbe", isDirectory: true) + + let first = ClaudeCLISession.loadOrCreateProbeSessionID(in: directory) + let second = ClaudeCLISession.loadOrCreateProbeSessionID(in: directory) + + #expect(first == second) + } +} diff --git a/Tests/CodexBarTests/ClaudeCLITimeoutRetryTests.swift b/Tests/CodexBarTests/ClaudeCLITimeoutRetryTests.swift index 4f49ed02d7..f201b597bc 100644 --- a/Tests/CodexBarTests/ClaudeCLITimeoutRetryTests.swift +++ b/Tests/CodexBarTests/ClaudeCLITimeoutRetryTests.swift @@ -225,6 +225,108 @@ struct ClaudeCLITimeoutRetryTests { #expect(recorded.timeouts == [24]) } + @Test + func `cli usage records background cooldown after rate limit`() async { + ClaudeCLIRateLimitGate.resetForTesting() + defer { ClaudeCLIRateLimitGate.resetForTesting() } + + let attempts = AttemptRecorder() + let fetcher = ClaudeUsageFetcher( + browserDetection: BrowserDetection(cacheTTL: 0), + environment: [:], + dataSource: .cli) + + let fetchOverride: ClaudeStatusProbe.FetchOverride = { _, timeout, _ in + _ = await attempts.record(timeout: timeout) + throw ClaudeStatusProbeError.parseFailed(ClaudeCLIRateLimitGate.message) + } + + await ProviderInteractionContext.$current.withValue(.background) { + await #expect(throws: ClaudeStatusProbeError.self) { + try await ClaudeCLIResolver.withResolvedBinaryPathOverrideForTesting("/usr/bin/true") { + try await ClaudeStatusProbe.withFetchOverrideForTesting(fetchOverride) { + try await fetcher.loadLatestUsage(model: "sonnet") + } + } + } + } + + let recordedAfterRateLimit = await attempts.snapshot() + #expect(recordedAfterRateLimit.count == 1) + #expect(recordedAfterRateLimit.timeouts == [24]) + #expect(ClaudeCLIRateLimitGate.currentBlockedUntil() != nil) + + await ProviderInteractionContext.$current.withValue(.background) { + await #expect(throws: ClaudeUsageError.self) { + try await ClaudeCLIResolver.withResolvedBinaryPathOverrideForTesting("/usr/bin/true") { + try await ClaudeStatusProbe.withFetchOverrideForTesting(fetchOverride) { + try await fetcher.loadLatestUsage(model: "sonnet") + } + } + } + } + + let recordedAfterBlockedRetry = await attempts.snapshot() + #expect(recordedAfterBlockedRetry.count == 1) + #expect(recordedAfterBlockedRetry.timeouts == [24]) + } + + @Test + func `user initiated cli usage bypasses rate limit cooldown`() async throws { + ClaudeCLIRateLimitGate.resetForTesting() + defer { ClaudeCLIRateLimitGate.resetForTesting() } + ClaudeCLIRateLimitGate.recordRateLimit() + + let attempts = AttemptRecorder() + let fetcher = ClaudeUsageFetcher( + browserDetection: BrowserDetection(cacheTTL: 0), + environment: [:], + dataSource: .cli) + + let fetchOverride: ClaudeStatusProbe.FetchOverride = { _, timeout, _ in + _ = await attempts.record(timeout: timeout) + return ClaudeStatusSnapshot( + sessionPercentLeft: 89, + weeklyPercentLeft: 83, + opusPercentLeft: nil, + accountEmail: "manual-cli@example.com", + accountOrganization: "Manual CLI Org", + loginMethod: "cli", + primaryResetDescription: nil, + secondaryResetDescription: nil, + opusResetDescription: nil, + rawText: "probe raw") + } + + await ProviderInteractionContext.$current.withValue(.background) { + await #expect(throws: ClaudeUsageError.self) { + try await ClaudeCLIResolver.withResolvedBinaryPathOverrideForTesting("/usr/bin/true") { + try await ClaudeStatusProbe.withFetchOverrideForTesting(fetchOverride) { + try await fetcher.loadLatestUsage(model: "sonnet") + } + } + } + } + + #expect(await (attempts.snapshot()).timeouts.isEmpty) + + let snapshot = try await ProviderInteractionContext.$current.withValue(.userInitiated) { + try await ClaudeCLIResolver.withResolvedBinaryPathOverrideForTesting("/usr/bin/true") { + try await ClaudeStatusProbe.withFetchOverrideForTesting(fetchOverride) { + try await fetcher.loadLatestUsage(model: "sonnet") + } + } + } + + let recorded = await attempts.snapshot() + #expect(recorded.count == 1) + #expect(recorded.timeouts == [24]) + #expect(snapshot.primary.usedPercent == 11) + #expect(snapshot.secondary?.usedPercent == 17) + #expect(snapshot.accountEmail == "manual-cli@example.com") + #expect(ClaudeCLIRateLimitGate.currentBlockedUntil() == nil) + } + private func withNoOAuthCredentials(operation: () async throws -> T) async rethrows -> T { let missingCredentialsURL = FileManager.default.temporaryDirectory .appendingPathComponent("missing-claude-creds-\(UUID().uuidString).json") diff --git a/Tests/CodexBarTests/ClaudeConfigPathsTests.swift b/Tests/CodexBarTests/ClaudeConfigPathsTests.swift new file mode 100644 index 0000000000..2ddae6f903 --- /dev/null +++ b/Tests/CodexBarTests/ClaudeConfigPathsTests.swift @@ -0,0 +1,137 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct ClaudeConfigPathsTests { + @Test + func `custom profile prefers config json and otherwise uses local claude json`() throws { + let root = try Self.makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: root) } + let environment = [ClaudeConfigPaths.configDirectoryEnvironmentKey: root.path] + let legacy = root.appendingPathComponent(".claude.json") + let profile = root.appendingPathComponent(".config.json") + + #expect(ClaudeConfigPaths.accountConfigURL(environment: environment) == legacy) + + try Data("{}".utf8).write(to: profile) + #expect(ClaudeConfigPaths.accountConfigURL(environment: environment) == profile) + } + + @Test + func `default profile uses home claude data and home account fallback`() throws { + let home = try Self.makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: home) } + let dataRoot = home.appendingPathComponent(".claude", isDirectory: true) + try FileManager.default.createDirectory(at: dataRoot, withIntermediateDirectories: true) + let environment = ["HOME": home.path] + + #expect(ClaudeConfigPaths.configRoot(environment: environment) == dataRoot) + #expect(ClaudeConfigPaths.accountConfigURL(environment: environment) == home.appendingPathComponent( + ".claude.json")) + + let profile = dataRoot.appendingPathComponent(".config.json") + try Data("{}".utf8).write(to: profile) + #expect(ClaudeConfigPaths.accountConfigURL(environment: environment) == profile) + } + + @Test + func `secure storage root owns credentials independently of config root`() throws { + let root = try Self.makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: root) } + let home = root.appendingPathComponent("home", isDirectory: true) + let config = root.appendingPathComponent("config", isDirectory: true) + let secure = root.appendingPathComponent("secure", isDirectory: true) + let base = [ + "HOME": home.path, + ClaudeConfigPaths.configDirectoryEnvironmentKey: config.path, + ] + + #expect(ClaudeConfigPaths.credentialsURL(environment: base) == config.appendingPathComponent( + ".credentials.json")) + + var explicitSecure = base + explicitSecure[ClaudeConfigPaths.secureStorageDirectoryEnvironmentKey] = secure.path + #expect(ClaudeConfigPaths.credentialsURL(environment: explicitSecure) == secure.appendingPathComponent( + ".credentials.json")) + + var emptySecure = base + emptySecure[ClaudeConfigPaths.secureStorageDirectoryEnvironmentKey] = "" + #expect(ClaudeConfigPaths.credentialsURL(environment: emptySecure) == home + .appendingPathComponent(".claude/.credentials.json")) + } + + @Test + func `config directory is one literal path rather than a list`() throws { + let parent = try Self.makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: parent) } + let literal = parent.appendingPathComponent("first, second", isDirectory: true) + let environment = [ClaudeConfigPaths.configDirectoryEnvironmentKey: literal.path] + + #expect(ClaudeConfigPaths.configRoot(environment: environment) == literal.standardizedFileURL) + #expect(ClaudeConfigPaths.credentialsURL(environment: environment) == literal.appendingPathComponent( + ".credentials.json")) + } + + @Test + func `relative literal roots resolve from the Claude owner working directory`() throws { + let parent = try Self.makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: parent) } + let workingDirectory = parent.appendingPathComponent("probe", isDirectory: true) + let relativeConfig = "first, second" + let configRoot = workingDirectory.appendingPathComponent(relativeConfig, isDirectory: true) + try FileManager.default.createDirectory(at: configRoot, withIntermediateDirectories: true) + let profile = configRoot.appendingPathComponent(".config.json") + try Data("{}".utf8).write(to: profile) + let environment = [ClaudeConfigPaths.configDirectoryEnvironmentKey: relativeConfig] + + #expect(ClaudeConfigPaths.configRoot( + environment: environment, + workingDirectory: workingDirectory) == configRoot.standardizedFileURL) + #expect(ClaudeConfigPaths.accountConfigURL( + environment: environment, + workingDirectory: workingDirectory) == profile.standardizedFileURL) + #expect(ClaudeConfigPaths.credentialsURL( + environment: environment, + workingDirectory: workingDirectory) == configRoot.appendingPathComponent(".credentials.json")) + + let tildeEnvironment = [ClaudeConfigPaths.configDirectoryEnvironmentKey: "~/.claude-profile"] + #expect(ClaudeConfigPaths.configRoot( + environment: tildeEnvironment, + workingDirectory: workingDirectory) == workingDirectory + .appendingPathComponent("~/.claude-profile", isDirectory: true) + .standardizedFileURL) + } + + @Test + func `empty config and secure roots follow relative HOME from the owner working directory`() throws { + let parent = try Self.makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: parent) } + let workingDirectory = parent.appendingPathComponent("probe", isDirectory: true) + let environment = [ + "HOME": "relative-home", + ClaudeConfigPaths.configDirectoryEnvironmentKey: "", + ClaudeConfigPaths.secureStorageDirectoryEnvironmentKey: "", + ] + let home = workingDirectory.appendingPathComponent("relative-home", isDirectory: true) + + #expect(ClaudeConfigPaths.homeDirectory( + environment: environment, + workingDirectory: workingDirectory) == home.standardizedFileURL) + #expect(ClaudeConfigPaths.configRoot( + environment: environment, + workingDirectory: workingDirectory) == home.appendingPathComponent(".claude", isDirectory: true)) + #expect(ClaudeConfigPaths.accountConfigURL( + environment: environment, + workingDirectory: workingDirectory) == home.appendingPathComponent(".claude.json")) + #expect(ClaudeConfigPaths.credentialsURL( + environment: environment, + workingDirectory: workingDirectory) == home.appendingPathComponent(".claude/.credentials.json")) + } + + private static func makeTemporaryDirectory() throws -> URL { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("codexbar-claude-paths-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + return directory + } +} diff --git a/Tests/CodexBarTests/ClaudeDailyRoutinesVisibilityTests.swift b/Tests/CodexBarTests/ClaudeDailyRoutinesVisibilityTests.swift new file mode 100644 index 0000000000..960aafca5a --- /dev/null +++ b/Tests/CodexBarTests/ClaudeDailyRoutinesVisibilityTests.swift @@ -0,0 +1,145 @@ +import CodexBarCore +import Foundation +import Observation +import Testing +@testable import CodexBar + +@Suite(.serialized) +@MainActor +struct ClaudeDailyRoutinesSettingsTests { + private final class ObservationFlag: @unchecked Sendable { + private let lock = NSLock() + private var value = false + + func set() { + self.lock.lock() + self.value = true + self.lock.unlock() + } + + func get() -> Bool { + self.lock.lock() + defer { self.lock.unlock() } + return self.value + } + } + + @Test + func `visibility defaults on persists and refreshes only menus`() async throws { + let suite = "ClaudeDailyRoutinesSettingsTests-visibility" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + let configStore = testConfigStore(suiteName: suite) + let store = SettingsStore( + userDefaults: defaults, + configStore: configStore, + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + + #expect(store.claudeDailyRoutinesUsageVisible) + let backgroundRevision = store.backgroundWorkSettingsRevision + let menuDidChange = ObservationFlag() + withObservationTracking { + _ = store.menuObservationToken + } onChange: { + menuDidChange.set() + } + store.claudeDailyRoutinesUsageVisible = false + try? await Task.sleep(nanoseconds: 50_000_000) + #expect(store.backgroundWorkSettingsRevision == backgroundRevision) + #expect(menuDidChange.get()) + + let reloaded = SettingsStore( + userDefaults: defaults, + configStore: configStore, + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + #expect(reloaded.claudeDailyRoutinesUsageVisible == false) + } +} + +struct ClaudeDailyRoutinesMenuCardTests { + @Test + func `visibility hides only the daily routines bar`() throws { + let now = Date() + let identity = ProviderIdentitySnapshot( + providerID: .claude, + accountEmail: nil, + accountOrganization: nil, + loginMethod: "Max") + let snapshot = UsageSnapshot( + primary: RateWindow( + usedPercent: 2, + windowMinutes: nil, + resetsAt: now.addingTimeInterval(3600), + resetDescription: nil), + secondary: RateWindow( + usedPercent: 8, + windowMinutes: 10080, + resetsAt: now.addingTimeInterval(7200), + resetDescription: nil), + tertiary: RateWindow( + usedPercent: 16, + windowMinutes: 10080, + resetsAt: now.addingTimeInterval(7800), + resetDescription: nil), + extraRateWindows: [ + NamedRateWindow( + id: "claude-weekly-scoped-fable", + title: "Fable only", + window: RateWindow( + usedPercent: 11, + windowMinutes: 10080, + resetsAt: now.addingTimeInterval(8600), + resetDescription: nil)), + NamedRateWindow( + id: "claude-routines", + title: "Daily Routines", + window: RateWindow( + usedPercent: 7, + windowMinutes: 10080, + resetsAt: now.addingTimeInterval(9200), + resetDescription: nil)), + ], + updatedAt: now, + identity: identity) + let metadata = try #require(ProviderDefaults.metadata[.claude]) + func makeModel(showOptionalUsage: Bool, routinesVisible: Bool) -> UsageMenuCardView.Model { + UsageMenuCardView.Model.make(.init( + provider: .claude, + metadata: metadata, + snapshot: snapshot, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: "codex@example.com", plan: "plus"), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: showOptionalUsage, + claudeDailyRoutinesUsageVisible: routinesVisible, + hidePersonalInfo: false, + now: now)) + } + + let visibleModel = makeModel(showOptionalUsage: true, routinesVisible: true) + #expect(visibleModel.metrics.map(\.title) == [ + "Session", + "Weekly", + "Sonnet", + "Fable only", + "Daily Routines", + ]) + + let providerHiddenModel = makeModel(showOptionalUsage: true, routinesVisible: false) + #expect(providerHiddenModel.metrics.map(\.title) == ["Session", "Weekly", "Sonnet", "Fable only"]) + + let globalHiddenModel = makeModel(showOptionalUsage: false, routinesVisible: true) + #expect(globalHiddenModel.metrics.map(\.title) == ["Session", "Weekly", "Sonnet", "Fable only"]) + } +} diff --git a/Tests/CodexBarTests/ClaudeDirectUsageFallbackTests.swift b/Tests/CodexBarTests/ClaudeDirectUsageFallbackTests.swift index 8b2375ac6a..37f729761f 100644 --- a/Tests/CodexBarTests/ClaudeDirectUsageFallbackTests.swift +++ b/Tests/CodexBarTests/ClaudeDirectUsageFallbackTests.swift @@ -18,6 +18,19 @@ struct ClaudeDirectUsageFallbackTests { } } + @Test + func `passive claude probes always disable the cli auto updater`() { + let environment = ClaudeCLISession.launchEnvironment(baseEnv: [ + "DISABLE_AUTOUPDATER": "0", + ClaudeOAuthCredentialsStore.environmentTokenKey: "oauth-token", + "ANTHROPIC_API_KEY": "api-token", + ]) + + #expect(environment["DISABLE_AUTOUPDATER"] == "1") + #expect(environment[ClaudeOAuthCredentialsStore.environmentTokenKey] == nil) + #expect(environment["ANTHROPIC_API_KEY"] == nil) + } + @Test func `cli source falls back to direct usage when pty usage fails to load`() async throws { let cliLogURL = FileManager.default.temporaryDirectory @@ -50,6 +63,8 @@ struct ClaudeDirectUsageFallbackTests { let invocations = log.contents() #expect(invocations.contains("pty-usage")) #expect(invocations.contains("direct-usage")) + #expect(invocations.contains("pty-auto-updater-disabled")) + #expect(invocations.contains("direct-auto-updater-disabled")) #expect(!invocations.contains("pty-secret-env")) #expect(!invocations.contains("direct-secret-env")) } @@ -87,6 +102,9 @@ struct ClaudeDirectUsageFallbackTests { try self.makeClaudeCLI(name: "claude-direct-fallback", logURL: logURL, scriptBody: """ if [ "$1" = "/usage" ]; then printf 'direct-usage\\n' >> "$LOG_FILE" + if [ "$DISABLE_AUTOUPDATER" = "1" ]; then + printf 'direct-auto-updater-disabled\\n' >> "$LOG_FILE" + fi if [ -n "$CODEXBAR_CLAUDE_OAUTH_TOKEN" ] || [ -n "$CODEXBAR_CLAUDE_OAUTH_SCOPES" ] || [ -n "$ANTHROPIC_ADMIN_KEY" ]; then @@ -99,6 +117,9 @@ struct ClaudeDirectUsageFallbackTests { case "$line" in *"/usage"*) printf 'pty-usage\\n' >> "$LOG_FILE" + if [ "$DISABLE_AUTOUPDATER" = "1" ]; then + printf 'pty-auto-updater-disabled\\n' >> "$LOG_FILE" + fi if [ -n "$CODEXBAR_CLAUDE_OAUTH_TOKEN" ] || [ -n "$CODEXBAR_CLAUDE_OAUTH_SCOPES" ] || [ -n "$ANTHROPIC_ADMIN_KEY" ]; then diff --git a/Tests/CodexBarTests/ClaudeEducationAvailabilityTests.swift b/Tests/CodexBarTests/ClaudeEducationAvailabilityTests.swift new file mode 100644 index 0000000000..a74aa9db8c --- /dev/null +++ b/Tests/CodexBarTests/ClaudeEducationAvailabilityTests.swift @@ -0,0 +1,201 @@ +import Foundation +import Testing +@testable import CodexBar +@testable import CodexBarCore + +struct ClaudeEducationAvailabilityTests { + @Test + func `auto CLI subscription notice is terminal before web fallback`() { + let browserDetection = BrowserDetection(cacheTTL: 0) + let strategy = ClaudeCLIFetchStrategy( + useWebExtras: false, + includePrepaidBalance: false, + manualCookieHeader: "sessionKey=test-session", + browserDetection: browserDetection, + hasWebFallback: true) + let context = ProviderFetchContext( + runtime: .app, + sourceMode: .auto, + includeCredits: false, + webTimeout: 1, + webDebugDumpHTML: false, + verbose: false, + env: [:], + settings: nil, + fetcher: UsageFetcher(environment: [:]), + claudeFetcher: ClaudeUsageFetcher(browserDetection: browserDetection), + browserDetection: browserDetection) + + let unavailable = ClaudeStatusProbeError.parseFailed( + ClaudeStatusProbe.subscriptionQuotaUnavailableDescription) + #expect(!strategy.shouldFallback(on: unavailable, context: context)) + #expect(strategy.shouldFallback(on: ClaudeStatusProbeError.timedOut, context: context)) + } + + @Test + func `subscription-only response is informational across Claude surfaces`() 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, tokenSnapshot) = try await MainActor.run { + let settings = testSettingsStore(suiteName: "ClaudeEducationAvailabilityTests") + settings.refreshFrequency = .manual + settings.statusChecksEnabled = false + settings.claudeUsageDataSource = .cli + settings.claudeOAuthKeychainPromptMode = .never + settings.providerDetectionCompleted = true + + 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: [:]) + store._setSnapshotForTesting( + 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: "Education")), + provider: .claude) + let tokenSnapshot = CostUsageTokenSnapshot( + sessionTokens: 4200, + sessionCostUSD: 1.25, + last30DaysTokens: 42000, + last30DaysCostUSD: 12.50, + daily: [], + updatedAt: Date(timeIntervalSince1970: 1_800_000_001)) + store._setTokenSnapshotForTesting(tokenSnapshot, provider: .claude) + try Self.installStrategy(ClaudeSubscriptionOnlyFetchStrategy(), in: store) + return (store, tokenSnapshot) + } + + await store.refreshProvider(.claude) + await MainActor.run { + let pane = ProvidersPane(settings: store.settings, store: store) + let menuModel = pane._test_menuCardModel(for: .claude) + let descriptor = MenuDescriptor.build( + provider: .claude, + store: store, + settings: store.settings, + account: AccountInfo(email: nil, plan: nil), + updateReady: false, + includeContextualActions: false) + let descriptorLines = descriptor.sections + .flatMap(\.entries) + .compactMap { entry -> String? in + guard case let .text(text, _) = entry else { return nil } + return text + } + + #expect(store.snapshot(for: .claude) == nil) + #expect(store.error(for: .claude) == nil) + #expect(store.knownLimitsAvailability(for: .claude) == .unavailable) + #expect(!store.isStale(provider: .claude)) + #expect(store.hasSatisfiedUsageFetch(for: .claude)) + #expect(!store.needsUsageRefreshRetry(for: .claude)) + #expect(store.tokenSnapshot(for: .claude) == tokenSnapshot) + #expect(pane._test_providerErrorDisplay(for: .claude) == nil) + #expect(pane._test_providerSidebarSubtitle(.claude).hasSuffix("\nLimits not available")) + #expect(menuModel.placeholder == "Limits not available") + #expect(descriptorLines.contains("Limits not available")) + #expect(!descriptorLines.contains("No usage yet")) + } + + try await MainActor.run { + try Self.installStrategy(ClaudeAvailabilityTimeoutFetchStrategy(), in: store) + } + await store.refreshProvider(.claude) + + await MainActor.run { + #expect(store.error(for: .claude) == nil) + #expect(store.knownLimitsAvailability(for: .claude) == .unavailable) + #expect(!store.isStale(provider: .claude)) + #expect(store.hasSatisfiedUsageFetch(for: .claude)) + #expect(!store.needsUsageRefreshRetry(for: .claude)) + #expect(store.tokenSnapshot(for: .claude) == tokenSnapshot) + } + } + } + } + + @MainActor + private static func installStrategy( + _ strategy: some ProviderFetchStrategy, + in store: UsageStore) throws + { + let currentSpec = try #require(store.providerSpecs[.claude]) + let currentDescriptor = currentSpec.descriptor + store.providerSpecs[.claude] = ProviderSpec( + style: currentSpec.style, + isEnabled: currentSpec.isEnabled, + descriptor: ProviderDescriptor( + id: .claude, + metadata: currentDescriptor.metadata, + branding: currentDescriptor.branding, + tokenCost: currentDescriptor.tokenCost, + fetchPlan: ProviderFetchPlan( + sourceModes: [.cli], + pipeline: ProviderFetchPipeline { _ in [strategy] }), + cli: currentDescriptor.cli), + makeFetchContext: currentSpec.makeFetchContext) + } +} + +private struct ClaudeAvailabilityTimeoutFetchStrategy: ProviderFetchStrategy { + let id = "test.claude-availability-timeout" + let kind: ProviderFetchKind = .cli + + func isAvailable(_: ProviderFetchContext) async -> Bool { + true + } + + func fetch(_: ProviderFetchContext) async throws -> ProviderFetchResult { + throw ClaudeStatusProbeError.timedOut + } + + func shouldFallback(on _: Error, context _: ProviderFetchContext) -> Bool { + false + } +} + +private struct ClaudeSubscriptionOnlyFetchStrategy: ProviderFetchStrategy { + let id = "test.claude-subscription-only" + let kind: ProviderFetchKind = .cli + + func isAvailable(_: ProviderFetchContext) async -> Bool { + true + } + + func fetch(_: ProviderFetchContext) async throws -> ProviderFetchResult { + throw ClaudeStatusProbeError.parseFailed(ClaudeStatusProbe.subscriptionQuotaUnavailableDescription) + } + + func shouldFallback(on _: Error, context _: ProviderFetchContext) -> Bool { + false + } +} diff --git a/Tests/CodexBarTests/ClaudeExtraWindowQuotaWarningTests.swift b/Tests/CodexBarTests/ClaudeExtraWindowQuotaWarningTests.swift new file mode 100644 index 0000000000..560217e2cf --- /dev/null +++ b/Tests/CodexBarTests/ClaudeExtraWindowQuotaWarningTests.swift @@ -0,0 +1,353 @@ +import Foundation +import Testing +@testable import CodexBar +@testable import CodexBarCore + +@MainActor +struct ClaudeExtraWindowQuotaWarningTests { + private func makeSettings(suiteName: String) -> SettingsStore { + let defaults = UserDefaults(suiteName: suiteName)! + defaults.removePersistentDomain(forName: suiteName) + return SettingsStore( + userDefaults: defaults, + configStore: testConfigStore(suiteName: suiteName), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + } + + @MainActor + final class SessionQuotaNotifierSpy: SessionQuotaNotifying { + private(set) var quotaWarningPosts: [( + event: QuotaWarningEvent, + provider: UsageProvider, + soundEnabled: Bool, + onScreenAlertEnabled: Bool)] = [] + + func post(transition _: SessionQuotaTransition, provider _: UsageProvider, badge _: NSNumber?) {} + + func postQuotaWarning( + event: QuotaWarningEvent, + provider: UsageProvider, + soundEnabled: Bool, + onScreenAlertEnabled: Bool) + { + self.quotaWarningPosts.append(( + event: event, + provider: provider, + soundEnabled: soundEnabled, + onScreenAlertEnabled: onScreenAlertEnabled)) + } + } + + @Test + func `claude scoped weekly and routines extra windows fire independent weekly warnings`() { + let settings = self.makeSettings(suiteName: "ClaudeExtraWindowQuotaWarningTests-independent") + settings.refreshFrequency = .manual + settings.statusChecksEnabled = false + settings.quotaWarningNotificationsEnabled = true + settings.quotaWarningThresholds = [50, 20] + settings.setQuotaWarningWindowEnabled(.session, enabled: true) + settings.setQuotaWarningWindowEnabled(.weekly, enabled: true) + settings.showOptionalCreditsAndExtraUsage = false + settings.claudeDailyRoutinesUsageVisible = false + + let notifier = SessionQuotaNotifierSpy() + let store = UsageStore( + fetcher: UsageFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + sessionQuotaNotifier: notifier) + + store.handleQuotaWarningTransitions( + provider: .claude, + snapshot: self.claudeExtraWindowSnapshot(fableUsed: 40, routinesUsed: 40)) + store.handleQuotaWarningTransitions( + provider: .claude, + snapshot: self.claudeExtraWindowSnapshot(fableUsed: 55, routinesUsed: 55)) + + #expect(notifier.quotaWarningPosts.count == 2) + let fable = notifier.quotaWarningPosts.first { $0.event.windowID == "claude-weekly-scoped-fable" } + let routines = notifier.quotaWarningPosts.first { $0.event.windowID == "claude-routines" } + #expect(fable?.event.window == .weekly) + #expect(fable?.event.threshold == 50) + #expect(fable?.event.windowDisplayLabel == "Fable only") + #expect(routines?.event.threshold == 50) + #expect(routines?.event.windowDisplayLabel == "Daily Routines") + + // Each window keeps independent fired-threshold state instead of clobbering the shared weekly key. + let fableKey = UsageStore.QuotaWarningStateKey( + provider: .claude, + window: .weekly, + accountDiscriminator: nil, + windowID: "claude-weekly-scoped-fable") + let routinesKey = UsageStore.QuotaWarningStateKey( + provider: .claude, + window: .weekly, + accountDiscriminator: nil, + windowID: "claude-routines") + #expect(store.quotaWarningState[fableKey]?.firedThresholds.contains(50) == true) + #expect(store.quotaWarningState[routinesKey]?.firedThresholds.contains(50) == true) + } + + @Test + func `antigravity summary extra windows do not trigger the claude extra-window lane`() { + let settings = self.makeSettings(suiteName: "ClaudeExtraWindowQuotaWarningTests-antigravity-guard") + settings.refreshFrequency = .manual + settings.statusChecksEnabled = false + settings.quotaWarningNotificationsEnabled = true + settings.quotaWarningThresholds = [50, 20] + settings.setQuotaWarningWindowEnabled(.weekly, enabled: true) + + let notifier = SessionQuotaNotifierSpy() + let store = UsageStore( + fetcher: UsageFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + sessionQuotaNotifier: notifier) + + func snapshot(used: Double) -> UsageSnapshot { + UsageSnapshot( + primary: nil, + secondary: nil, + extraRateWindows: [ + NamedRateWindow( + id: "antigravity-quota-summary-model-weekly", + title: "Weekly", + window: RateWindow( + usedPercent: used, + windowMinutes: 7 * 24 * 60, + resetsAt: nil, + resetDescription: nil)), + ], + updatedAt: Date()) + } + store.handleQuotaWarningTransitions(provider: .claude, snapshot: snapshot(used: 40)) + store.handleQuotaWarningTransitions(provider: .claude, snapshot: snapshot(used: 55)) + + #expect(notifier.quotaWarningPosts.isEmpty) + } + + @Test + func `claude scoped weekly window refires after recovering above threshold`() { + let settings = self.makeSettings(suiteName: "ClaudeExtraWindowQuotaWarningTests-refire") + settings.refreshFrequency = .manual + settings.statusChecksEnabled = false + settings.quotaWarningNotificationsEnabled = true + settings.quotaWarningThresholds = [50] + settings.setQuotaWarningWindowEnabled(.weekly, enabled: true) + + let notifier = SessionQuotaNotifierSpy() + let store = UsageStore( + fetcher: UsageFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + sessionQuotaNotifier: notifier) + + // 60% remaining -> 45% (fires 50) -> 60% (clears 50) -> 45% (refires 50). + store.handleQuotaWarningTransitions( + provider: .claude, snapshot: self.claudeExtraWindowSnapshot(fableUsed: 40, routinesUsed: nil)) + store.handleQuotaWarningTransitions( + provider: .claude, snapshot: self.claudeExtraWindowSnapshot(fableUsed: 55, routinesUsed: nil)) + store.handleQuotaWarningTransitions( + provider: .claude, snapshot: self.claudeExtraWindowSnapshot(fableUsed: 40, routinesUsed: nil)) + store.handleQuotaWarningTransitions( + provider: .claude, snapshot: self.claudeExtraWindowSnapshot(fableUsed: 55, routinesUsed: nil)) + + #expect(notifier.quotaWarningPosts.count == 2) + #expect(notifier.quotaWarningPosts.allSatisfy { $0.event.windowID == "claude-weekly-scoped-fable" }) + } + + @Test + func `claude extra-window fired state is pruned when a window disappears but others remain`() { + let settings = self.makeSettings(suiteName: "ClaudeExtraWindowQuotaWarningTests-prune") + settings.refreshFrequency = .manual + settings.statusChecksEnabled = false + settings.quotaWarningNotificationsEnabled = true + settings.quotaWarningThresholds = [50] + settings.setQuotaWarningWindowEnabled(.weekly, enabled: true) + + let notifier = SessionQuotaNotifierSpy() + let store = UsageStore( + fetcher: UsageFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + sessionQuotaNotifier: notifier) + + store.handleQuotaWarningTransitions( + provider: .claude, snapshot: self.claudeExtraWindowSnapshot(fableUsed: 40, routinesUsed: 40)) + store.handleQuotaWarningTransitions( + provider: .claude, snapshot: self.claudeExtraWindowSnapshot(fableUsed: 55, routinesUsed: 55)) + let fableKey = UsageStore.QuotaWarningStateKey( + provider: .claude, + window: .weekly, + accountDiscriminator: nil, + windowID: "claude-weekly-scoped-fable") + let routinesKey = UsageStore.QuotaWarningStateKey( + provider: .claude, + window: .weekly, + accountDiscriminator: nil, + windowID: "claude-routines") + #expect(store.quotaWarningState[fableKey] != nil) + #expect(store.quotaWarningState[routinesKey] != nil) + + // Fable ends while Routines is still present: this refresh carries authoritative extras, so + // Fable's stale state is dropped and Routines is kept. + store.handleQuotaWarningTransitions( + provider: .claude, snapshot: self.claudeExtraWindowSnapshot(fableUsed: nil, routinesUsed: 55)) + #expect(store.quotaWarningState[fableKey] == nil) + #expect(store.quotaWarningState[routinesKey] != nil) + } + + @Test + func `claude extra-window reconciliation preserves sibling account state`() { + let settings = self.makeSettings(suiteName: "ClaudeExtraWindowQuotaWarningTests-account-prune") + settings.refreshFrequency = .manual + settings.statusChecksEnabled = false + settings.quotaWarningNotificationsEnabled = true + settings.quotaWarningThresholds = [50] + settings.setQuotaWarningWindowEnabled(.weekly, enabled: true) + + let notifier = SessionQuotaNotifierSpy() + let store = UsageStore( + fetcher: UsageFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + sessionQuotaNotifier: notifier) + + store.handleQuotaWarningTransitions( + provider: .claude, + snapshot: self.claudeExtraWindowSnapshot(fableUsed: 40, routinesUsed: nil), + accountDiscriminator: "account-a") + store.handleQuotaWarningTransitions( + provider: .claude, + snapshot: self.claudeExtraWindowSnapshot(fableUsed: 55, routinesUsed: nil), + accountDiscriminator: "account-a") + let accountAFableKey = UsageStore.QuotaWarningStateKey( + provider: .claude, + window: .weekly, + accountDiscriminator: "account-a", + windowID: "claude-weekly-scoped-fable") + #expect(store.quotaWarningState[accountAFableKey] != nil) + + store.handleQuotaWarningTransitions( + provider: .claude, + snapshot: self.claudeExtraWindowSnapshot(fableUsed: nil, routinesUsed: 40), + accountDiscriminator: "account-b") + #expect(store.quotaWarningState[accountAFableKey] != nil) + + store.handleQuotaWarningTransitions( + provider: .claude, + snapshot: self.claudeExtraWindowSnapshot(fableUsed: 55, routinesUsed: nil), + accountDiscriminator: "account-a") + + #expect(notifier.quotaWarningPosts.count == 1) + #expect(notifier.quotaWarningPosts.first?.event.windowID == "claude-weekly-scoped-fable") + #expect(notifier.quotaWarningPosts.first?.event.threshold == 50) + } + + @Test + func `disabling weekly warnings clears all account-scoped claude extra-window state`() { + let settings = self.makeSettings(suiteName: "ClaudeExtraWindowQuotaWarningTests-disable") + settings.refreshFrequency = .manual + settings.statusChecksEnabled = false + settings.quotaWarningNotificationsEnabled = true + settings.quotaWarningThresholds = [50] + settings.setQuotaWarningWindowEnabled(.weekly, enabled: true) + + let notifier = SessionQuotaNotifierSpy() + let store = UsageStore( + fetcher: UsageFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + sessionQuotaNotifier: notifier) + + let accountIDs = ["account-a", "account-b"] + for accountID in accountIDs { + store.handleQuotaWarningTransitions( + provider: .claude, + snapshot: self.claudeExtraWindowSnapshot(fableUsed: 40, routinesUsed: 40), + accountDiscriminator: accountID) + } + let seededKeys = accountIDs.flatMap { accountID in + [ + UsageStore.QuotaWarningStateKey( + provider: .claude, + window: .weekly, + accountDiscriminator: accountID, + windowID: "claude-weekly-scoped-fable"), + UsageStore.QuotaWarningStateKey( + provider: .claude, + window: .weekly, + accountDiscriminator: accountID, + windowID: "claude-routines"), + ] + } + #expect(seededKeys.allSatisfy { store.quotaWarningState[$0] != nil }) + + settings.setQuotaWarningWindowEnabled(.weekly, enabled: false) + store.handleQuotaWarningTransitions( + provider: .claude, + snapshot: UsageSnapshot(primary: nil, secondary: nil, extraRateWindows: nil, updatedAt: Date()), + accountDiscriminator: accountIDs[0]) + #expect(seededKeys.allSatisfy { store.quotaWarningState[$0] == nil }) + } + + @Test + func `claude extra-window state survives a transient extras miss without re-posting`() { + let settings = self.makeSettings(suiteName: "ClaudeExtraWindowQuotaWarningTests-transient-miss") + settings.refreshFrequency = .manual + settings.statusChecksEnabled = false + settings.quotaWarningNotificationsEnabled = true + settings.quotaWarningThresholds = [50] + settings.setQuotaWarningWindowEnabled(.weekly, enabled: true) + + let notifier = SessionQuotaNotifierSpy() + let store = UsageStore( + fetcher: UsageFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + sessionQuotaNotifier: notifier) + + // Fable crosses 50% and warns once. + store.handleQuotaWarningTransitions( + provider: .claude, snapshot: self.claudeExtraWindowSnapshot(fableUsed: 40, routinesUsed: nil)) + store.handleQuotaWarningTransitions( + provider: .claude, snapshot: self.claudeExtraWindowSnapshot(fableUsed: 55, routinesUsed: nil)) + #expect(notifier.quotaWarningPosts.count == 1) + let fableKey = UsageStore.QuotaWarningStateKey( + provider: .claude, + window: .weekly, + accountDiscriminator: nil, + windowID: "claude-weekly-scoped-fable") + + // A failed web-extras fetch delivers nil extras while the main snapshot is intact. The fired + // state must persist so the warning is not re-posted when extras recover. + store.handleQuotaWarningTransitions( + provider: .claude, + snapshot: UsageSnapshot(primary: nil, secondary: nil, extraRateWindows: nil, updatedAt: Date())) + #expect(store.quotaWarningState[fableKey] != nil) + + store.handleQuotaWarningTransitions( + provider: .claude, snapshot: self.claudeExtraWindowSnapshot(fableUsed: 55, routinesUsed: nil)) + #expect(notifier.quotaWarningPosts.count == 1) + } + + private func claudeExtraWindowSnapshot(fableUsed: Double?, routinesUsed: Double?) -> UsageSnapshot { + var windows: [NamedRateWindow] = [] + if let fableUsed { + windows.append(NamedRateWindow( + id: "claude-weekly-scoped-fable", + title: "Fable only", + window: RateWindow( + usedPercent: fableUsed, windowMinutes: 7 * 24 * 60, resetsAt: nil, resetDescription: nil))) + } + if let routinesUsed { + windows.append(NamedRateWindow( + id: "claude-routines", + title: "Daily Routines", + window: RateWindow( + usedPercent: routinesUsed, windowMinutes: 7 * 24 * 60, resetsAt: nil, resetDescription: nil))) + } + return UsageSnapshot(primary: nil, secondary: nil, extraRateWindows: windows, updatedAt: Date()) + } +} diff --git a/Tests/CodexBarTests/ClaudeKeychainLiveProofTests.swift b/Tests/CodexBarTests/ClaudeKeychainLiveProofTests.swift new file mode 100644 index 0000000000..507f34db98 --- /dev/null +++ b/Tests/CodexBarTests/ClaudeKeychainLiveProofTests.swift @@ -0,0 +1,57 @@ +import Foundation +import Testing +@testable import CodexBarCore + +@Suite(.serialized) +struct ClaudeKeychainLiveProofTests { + private static var isEnabled: Bool { + ProcessInfo.processInfo.environment["LIVE_CLAUDE_KEYCHAIN_PROOF"] == "1" + } + + private static func allowsLiveKeychainAccess(environment: [String: String]) -> Bool { + environment[KeychainTestSafety.allowAccessEnvironmentKey] == "1" + } + + @Test + func `live proof requires explicit access to real user state`() { + #expect(Self.allowsLiveKeychainAccess(environment: [:]) == false) + #expect(Self.allowsLiveKeychainAccess(environment: [KeychainTestSafety.allowAccessEnvironmentKey: "1"])) + } + + @Test + func `live background Auto skips the opaque Claude Keychain boundary`() async { + guard Self.isEnabled else { return } + guard Self.allowsLiveKeychainAccess(environment: ProcessInfo.processInfo.environment) else { + Issue.record("Live proof requires CODEXBAR_ALLOW_TEST_KEYCHAIN_ACCESS=1 to read the real prompt policy") + return + } + let mode = ClaudeOAuthKeychainPromptPreference.storedMode() + guard mode == .onlyOnUserAction || mode == .never else { + Issue.record("Live proof requires a restrictive stored Claude Keychain prompt mode; found \(mode.rawValue)") + return + } + + let outcome = await ClaudeOAuthDelegatedRefreshCoordinator.withIsolatedStateForTesting { + await ProviderInteractionContext.$current.withValue(.background) { + await ClaudeOAuthDelegatedRefreshCoordinator.attempt(timeout: 8) + } + } + + #expect(outcome == .skippedByPromptPolicy) + } + + @Test + func `live explicit user auth probe reports Claude login`() async throws { + guard Self.isEnabled else { return } + let binary = try #require(TTYCommandRunner.which("claude")) + + let isLoggedIn = await ProviderInteractionContext.$current.withValue(.userInitiated) { + await ClaudeCLIAuthStatusProbe.isLoggedIn( + binary: binary, + environment: ProcessInfo.processInfo.environment, + timeout: 8) + } + + #expect(isLoggedIn) + } +} diff --git a/Tests/CodexBarTests/ClaudeKeychainOverrideIsolationTests.swift b/Tests/CodexBarTests/ClaudeKeychainOverrideIsolationTests.swift new file mode 100644 index 0000000000..169556b669 --- /dev/null +++ b/Tests/CodexBarTests/ClaudeKeychainOverrideIsolationTests.swift @@ -0,0 +1,51 @@ +import Foundation +import Testing +@testable import CodexBarCore + +private actor TwoTaskBarrier { + private var continuations: [CheckedContinuation] = [] + + func wait() async { + await withCheckedContinuation { continuation in + self.continuations.append(continuation) + guard self.continuations.count == 2 else { + return + } + let continuations = self.continuations + self.continuations.removeAll() + continuations.forEach { $0.resume() } + } + } +} + +struct ClaudeKeychainOverrideIsolationTests { + @Test + func `keychain overrides stay isolated across concurrent tasks`() async { + let expected = [Data([0x01]), Data([0x02])] + let barrier = TwoTaskBarrier() + let observed = await withTaskGroup(of: Data?.self, returning: [Data].self) { group in + for data in expected { + group.addTask { + await ClaudeOAuthCredentialsStore.withClaudeKeychainOverridesForTesting( + data: data, + fingerprint: nil) + { + await barrier.wait() + return ClaudeOAuthCredentialsStore.taskClaudeKeychainDataOverride + } + } + } + + var values: [Data] = [] + for await value in group { + if let value { + values.append(value) + } + } + return values + } + + #expect(Set(observed) == Set(expected)) + #expect(ClaudeOAuthCredentialsStore.taskClaudeKeychainDataOverride == nil) + } +} diff --git a/Tests/CodexBarTests/ClaudeLoginFlowPolicyTests.swift b/Tests/CodexBarTests/ClaudeLoginFlowPolicyTests.swift new file mode 100644 index 0000000000..eeba8ccb68 --- /dev/null +++ b/Tests/CodexBarTests/ClaudeLoginFlowPolicyTests.swift @@ -0,0 +1,48 @@ +import CodexBarCore +import Testing +@testable import CodexBar + +@MainActor +@Suite(.serialized) +struct ClaudeLoginFlowTests { + @Test + func `successful Claude login controller flow preserves selected source and enables provider`() async throws { + let registry = ProviderRegistry.shared + let claudeMetadata = try #require(registry.metadata[.claude]) + + for source in ClaudeUsageDataSource.allCases { + let settings = testSettingsStore( + suiteName: "ClaudeLoginFlowTests-controller-\(source.rawValue)") + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.providerDetectionCompleted = true + settings.claudeUsageDataSource = source + settings.setProviderEnabled(provider: .claude, metadata: claudeMetadata, enabled: false) + + let fetcher = UsageFetcher() + let store = UsageStore( + fetcher: fetcher, + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings) + + await withStatusItemControllerForTesting(store: store, settings: settings, fetcher: fetcher) { controller in + let didLogin = await controller.runClaudeLoginFlow { _, onPhaseChange in + onPhaseChange(.requesting) + await Task.yield() + onPhaseChange(.waitingBrowser) + await Task.yield() + return ClaudeLoginRunner.Result( + outcome: .success, + output: "Successfully logged in", + authLink: nil) + } + + #expect(didLogin) + #expect(controller.loginPhase == .idle) + } + + #expect(settings.claudeUsageDataSource == source) + #expect(settings.isProviderEnabledCached(provider: .claude, metadataByProvider: registry.metadata)) + } + } +} diff --git a/Tests/CodexBarTests/ClaudeLoginRunnerTests.swift b/Tests/CodexBarTests/ClaudeLoginRunnerTests.swift new file mode 100644 index 0000000000..86a4c9b6d9 --- /dev/null +++ b/Tests/CodexBarTests/ClaudeLoginRunnerTests.swift @@ -0,0 +1,112 @@ +import Foundation +import Testing +@testable import CodexBar + +@Suite(.serialized) +struct ClaudeLoginRunnerTests { + @Test + func `dedicated auth command opens browser prompt and completes successfully`() async throws { + let fixture = try self.makeFixture(script: """ + #!/bin/sh + printf 'args:%s\\n' "$*" + printf 'Authenticate your account at (press ENTER to open in browser): ' + IFS= read -r _ + printf 'https://claude.ai/oauth/authorize?test=1\\n' + printf 'Successfully logged in\\n' + """) + defer { fixture.remove() } + + let result = await ClaudeLoginRunner.run( + timeout: 10, + binary: fixture.executable.path, + environment: fixture.environment, + onPhaseChange: { _ in }) + + guard case .success = result.outcome else { + Issue.record( + "Expected success, got \(String(describing: result.outcome)); output=\(result.output.debugDescription)") + return + } + #expect(result.output.contains("args:auth login --claudeai")) + #expect(result.authLink == "https://claude.ai/oauth/authorize?test=1") + } + + @Test + func `authorization URL alone is not treated as success`() async throws { + let fixture = try self.makeFixture(script: """ + #!/bin/sh + printf 'https://claude.ai/oauth/authorize?test=1\\n' + /bin/sleep 5 + """) + defer { fixture.remove() } + + let result = await ClaudeLoginRunner.run( + timeout: 3, + binary: fixture.executable.path, + environment: fixture.environment, + onPhaseChange: { _ in }) + + guard case .timedOut = result.outcome else { + let message = "Expected timeout, got \(String(describing: result.outcome)); " + + "output=\(result.output.debugDescription)" + Issue.record(Comment(rawValue: message)) + return + } + #expect(result.authLink == "https://claude.ai/oauth/authorize?test=1") + } + + @Test + func `dedicated auth command preserves failure status`() async throws { + let fixture = try self.makeFixture(script: """ + #!/bin/sh + printf 'login failed\\n' + exit 7 + """) + defer { fixture.remove() } + + let result = await ClaudeLoginRunner.run( + timeout: 10, + binary: fixture.executable.path, + environment: fixture.environment, + onPhaseChange: { _ in }) + + guard case .failed(status: 7) = result.outcome else { + let message = "Expected status 7, got \(String(describing: result.outcome)); " + + "output=\(result.output.debugDescription)" + Issue.record(Comment(rawValue: message)) + return + } + #expect(result.output.contains("login failed")) + } + + private func makeFixture(script: String) throws -> Fixture { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("codexbar-claude-login-\(UUID().uuidString)", isDirectory: true) + let binDirectory = root.appendingPathComponent("bin", isDirectory: true) + let homeDirectory = root.appendingPathComponent("home", isDirectory: true) + try FileManager.default.createDirectory(at: binDirectory, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: homeDirectory, withIntermediateDirectories: true) + + let executable = binDirectory.appendingPathComponent("claude") + try script.write(to: executable, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: executable.path) + + return Fixture( + root: root, + executable: executable, + environment: [ + "HOME": homeDirectory.path, + "PATH": binDirectory.path, + ]) + } + + private struct Fixture { + let root: URL + let executable: URL + let environment: [String: String] + + func remove() { + try? FileManager.default.removeItem(at: self.root) + } + } +} diff --git a/Tests/CodexBarTests/ClaudeMenuCardCostTests.swift b/Tests/CodexBarTests/ClaudeMenuCardCostTests.swift new file mode 100644 index 0000000000..45db95490a --- /dev/null +++ b/Tests/CodexBarTests/ClaudeMenuCardCostTests.swift @@ -0,0 +1,190 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +struct ClaudeMenuCardCostTests { + @Test + func `claude extra usage card shows balance above monthly cap`() throws { + let now = Date() + let metadata = try #require(ProviderDefaults.metadata[.claude]) + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 0, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: nil, + tertiary: nil, + providerCost: ProviderCostSnapshot( + used: 5, + limit: 20, + currencyCode: "USD", + period: "Monthly cap", + balance: 100, + updatedAt: now), + updatedAt: now, + identity: nil) + + let model = UsageMenuCardView.Model.make(.init( + provider: .claude, + metadata: metadata, + snapshot: snapshot, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + + #expect(model.providerCost?.title == "Extra usage") + #expect(model.providerCost?.balanceLine == "Balance: $100.00") + #expect(model.providerCost?.spendLine == "Monthly cap: $5.00 / $20.00") + #expect(model.providerCost?.percentUsed == 25) + #expect(model.providerCost?.percentLine == "25% used") + #expect(model.providerCost?.presentation == .detail) + #expect(model.providerCost?.showsInProviderDetails == false) + } + + @Test + func `claude balance only card stays compact`() throws { + let now = Date() + let metadata = try #require(ProviderDefaults.metadata[.claude]) + let snapshot = UsageSnapshot( + primary: nil, + secondary: nil, + providerCost: ProviderCostSnapshot( + used: 0, + limit: 0, + currencyCode: "USD", + period: "Usage credits", + balance: 100, + updatedAt: now), + updatedAt: now) + + let model = UsageMenuCardView.Model.make(.init( + provider: .claude, + metadata: metadata, + snapshot: snapshot, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + + #expect(model.providerCost?.title == "Credits") + #expect(model.providerCost?.spendLine == "$100.00") + #expect(model.providerCost?.balanceLine == nil) + #expect(model.providerCost?.percentUsed == nil) + #expect(model.providerCost?.percentLine == nil) + #expect(model.providerCost?.presentation == .inlineValue) + #expect(model.providerCost?.showsInProviderDetails == false) + } + + @Test + func `claude admin api spend remains visible without prepaid balance`() throws { + let now = Date() + let metadata = try #require(ProviderDefaults.metadata[.claude]) + let snapshot = UsageSnapshot( + primary: nil, + secondary: nil, + providerCost: ProviderCostSnapshot( + used: 12.34, + limit: 0, + currencyCode: "USD", + period: "Last 30 days", + updatedAt: now), + claudeAdminAPIUsage: ClaudeAdminAPIUsageSnapshot(daily: [], updatedAt: now), + updatedAt: now, + identity: ProviderIdentitySnapshot( + providerID: .claude, + accountEmail: "admin@example.com", + accountOrganization: nil, + loginMethod: "Admin API")) + + let model = UsageMenuCardView.Model.make(.init( + provider: .claude, + metadata: metadata, + snapshot: snapshot, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: false, + hidePersonalInfo: false, + now: now)) + + #expect(model.providerCost?.title == "API spend") + #expect(model.providerCost?.spendLine == "Last 30 days: $12.34") + #expect(model.providerCost?.presentation == .detail) + #expect(model.providerCost?.showsInProviderDetails == true) + } + + @Test + func `claude monthly cap stays visible when prepaid balance is unavailable`() throws { + let now = Date() + let metadata = try #require(ProviderDefaults.metadata[.claude]) + let snapshot = UsageSnapshot( + primary: nil, + secondary: nil, + providerCost: ProviderCostSnapshot( + used: 0.49, + limit: 50, + currencyCode: "USD", + period: "Monthly cap", + updatedAt: now), + updatedAt: now) + + let model = UsageMenuCardView.Model.make(.init( + provider: .claude, + metadata: metadata, + snapshot: snapshot, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + + #expect(model.providerCost?.title == "Extra usage") + #expect(model.providerCost?.balanceLine == nil) + #expect(model.providerCost?.spendLine == "Monthly cap: $0.49 / $50.00") + let percentUsed = try #require(model.providerCost?.percentUsed) + #expect(abs(percentUsed - 0.98) < 0.0001) + #expect(model.providerCost?.percentLine == "1% used") + #expect(model.providerCost?.presentation == .detail) + #expect(model.providerCost?.showsInProviderDetails == false) + } +} diff --git a/Tests/CodexBarTests/ClaudeOAuthCredentialsProfileCacheTests.swift b/Tests/CodexBarTests/ClaudeOAuthCredentialsProfileCacheTests.swift new file mode 100644 index 0000000000..588d8f6b50 --- /dev/null +++ b/Tests/CodexBarTests/ClaudeOAuthCredentialsProfileCacheTests.swift @@ -0,0 +1,599 @@ +import Foundation +import Security +import Testing +@testable import CodexBarCore + +@Suite(.serialized) +struct ClaudeOAuthCredentialsProfileCacheTests { + private struct LegacyCacheEntry: Codable { + let data: Data + let storedAt: Date + let owner: ClaudeOAuthCredentialOwner? + let historyOwnerIdentifier: String? + } + + private func makeCredentialsData( + accessToken: String, + expiresAt: Date = Date(timeIntervalSinceNow: 3600)) -> Data + { + let expiresAt = Int(expiresAt.timeIntervalSince1970 * 1000) + return Data(""" + { + "claudeAiOauth": { + "accessToken": "\(accessToken)", + "expiresAt": \(expiresAt), + "scopes": ["user:profile"] + } + } + """.utf8) + } + + private func withIsolatedCache(_ operation: () throws -> T) throws -> T { + let service = "com.steipete.codexbar.cache.profile-tests.\(UUID().uuidString)" + let pendingStore = ClaudeOAuthCredentialsStore.PendingCacheClearMemoryStore() + return try KeychainCacheStore.withServiceOverrideForTesting(service) { + try KeychainAccessGate.withTaskOverrideForTesting(false) { + KeychainCacheStore.setTestStoreForTesting(true) + defer { KeychainCacheStore.setTestStoreForTesting(false) } + return try ClaudeOAuthCredentialsStore.withPendingCacheClearStoreOverrideForTesting(pendingStore) { + try ClaudeOAuthCredentialsStore.withIsolatedCredentialsFileTrackingForTesting { + try ClaudeOAuthCredentialsStore.withEnvironmentCredentialsURLForTesting { + try ClaudeOAuthCredentialsStore.withIsolatedMemoryCacheForTesting(operation: operation) + } + } + } + } + } + } + + private func profileIdentifier(environment: [String: String]) -> String { + ClaudeOAuthCredentialsStore.withEnvironmentCredentialsURLForTesting { + ClaudeOAuthCredentialsStore.credentialsProfileIdentifier(environment: environment) + } + } + + @Test + func `newer cache from another profile never overrides older credentials file`() throws { + let tempRoot = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + let profileA = tempRoot.appendingPathComponent("profile-a", isDirectory: true) + let profileB = tempRoot.appendingPathComponent("profile-b", isDirectory: true) + try FileManager.default.createDirectory(at: profileA, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: profileB, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: tempRoot) } + + let credentialsA = profileA.appendingPathComponent(".credentials.json") + let credentialsB = profileB.appendingPathComponent(".credentials.json") + try self.makeCredentialsData(accessToken: "profile-a-token").write(to: credentialsA) + try self.makeCredentialsData(accessToken: "profile-b-token").write(to: credentialsB) + try FileManager.default.setAttributes( + [.modificationDate: Date(timeIntervalSinceNow: -3600)], + ofItemAtPath: credentialsB.path) + + let environmentA = ["CLAUDE_CONFIG_DIR": profileA.path] + let environmentB = ["CLAUDE_CONFIG_DIR": profileB.path] + let missingEnvironment = ["CLAUDE_CONFIG_DIR": tempRoot.appendingPathComponent("missing").path] + + try self.withIsolatedCache { + ClaudeOAuthCredentialsStore.invalidateCache() + let first = try ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.onlyOnUserAction) { + try ClaudeOAuthCredentialsStore.load( + environment: environmentA, + allowKeychainPrompt: false) + } + #expect(first.accessToken == "profile-a-token") + + // A's cache is newer than B's file. The profile identity, not freshness, must decide ownership. + let second = try ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.onlyOnUserAction) { + try ClaudeOAuthCredentialsStore.load( + environment: environmentB, + allowKeychainPrompt: false) + } + #expect(second.accessToken == "profile-b-token") + #expect(ClaudeOAuthCredentialsStore.hasCachedCredentials(environment: missingEnvironment) == false) + } + } + + @Test + func `profile caches survive switching Claude config directories`() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + let profileA = root.appendingPathComponent("profile-a", isDirectory: true) + let profileB = root.appendingPathComponent("profile-b", isDirectory: true) + try FileManager.default.createDirectory(at: profileA, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: profileB, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + + let environmentA = ["CLAUDE_CONFIG_DIR": profileA.path] + let environmentB = ["CLAUDE_CONFIG_DIR": profileB.path] + let identifierA = self.profileIdentifier(environment: environmentA) + let identifierB = self.profileIdentifier(environment: environmentB) + + try self.withIsolatedCache { + KeychainCacheStore.store( + key: ClaudeOAuthCredentialsStore.cacheKeyForTesting(profileIdentifier: identifierA), + entry: ClaudeOAuthCredentialsStore.CacheEntry( + data: self.makeCredentialsData(accessToken: "profile-a-refresh-token"), + storedAt: Date(), + owner: .codexbar, + profileIdentifier: identifierA)) + KeychainCacheStore.store( + key: ClaudeOAuthCredentialsStore.cacheKeyForTesting(profileIdentifier: identifierB), + entry: ClaudeOAuthCredentialsStore.CacheEntry( + data: self.makeCredentialsData(accessToken: "profile-b-refresh-token"), + storedAt: Date(), + owner: .codexbar, + profileIdentifier: identifierB)) + + let first = try ClaudeOAuthCredentialsStore.loadRecord( + environment: environmentA, + allowKeychainPrompt: false, + allowClaudeKeychainRepairWithoutPrompt: false) + let second = try ClaudeOAuthCredentialsStore.loadRecord( + environment: environmentB, + allowKeychainPrompt: false, + allowClaudeKeychainRepairWithoutPrompt: false) + let resumed = try ClaudeOAuthCredentialsStore.loadRecord( + environment: environmentA, + allowKeychainPrompt: false, + allowClaudeKeychainRepairWithoutPrompt: false) + + #expect(first.credentials.accessToken == "profile-a-refresh-token") + #expect(second.credentials.accessToken == "profile-b-refresh-token") + #expect(resumed.credentials.accessToken == "profile-a-refresh-token") + } + } + + @Test + func `file fingerprint from one profile does not clear cache only sibling`() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + let profileA = root.appendingPathComponent("profile-a", isDirectory: true) + let profileB = root.appendingPathComponent("profile-b", isDirectory: true) + try FileManager.default.createDirectory(at: profileA, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: profileB, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + + let environmentA = ["CLAUDE_CONFIG_DIR": profileA.path] + let environmentB = ["CLAUDE_CONFIG_DIR": profileB.path] + let identifierB = self.profileIdentifier(environment: environmentB) + try self.makeCredentialsData(accessToken: "profile-a-file-token") + .write(to: profileA.appendingPathComponent(".credentials.json")) + + try self.withIsolatedCache { + KeychainCacheStore.store( + key: ClaudeOAuthCredentialsStore.cacheKeyForTesting(profileIdentifier: identifierB), + entry: ClaudeOAuthCredentialsStore.CacheEntry( + data: self.makeCredentialsData(accessToken: "profile-b-cache-token"), + storedAt: Date(), + owner: .codexbar, + profileIdentifier: identifierB)) + + #expect(ClaudeOAuthCredentialsStore.invalidateCacheIfCredentialsFileChanged( + environment: environmentA)) + #expect(!ClaudeOAuthCredentialsStore.invalidateCacheIfCredentialsFileChanged( + environment: environmentB)) + + let record = try ClaudeOAuthCredentialsStore.loadRecord( + environment: environmentB, + allowKeychainPrompt: false, + allowClaudeKeychainRepairWithoutPrompt: false) + #expect(record.credentials.accessToken == "profile-b-cache-token") + #expect(record.source == .cacheKeychain) + } + } + + @Test + func `invalidating one profile preserves another profile cache`() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + let profileA = root.appendingPathComponent("profile-a", isDirectory: true) + let profileB = root.appendingPathComponent("profile-b", isDirectory: true) + try FileManager.default.createDirectory(at: profileA, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: profileB, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + + let environmentA = ["CLAUDE_CONFIG_DIR": profileA.path] + let environmentB = ["CLAUDE_CONFIG_DIR": profileB.path] + let identifierA = self.profileIdentifier(environment: environmentA) + let identifierB = self.profileIdentifier(environment: environmentB) + + try self.withIsolatedCache { + KeychainCacheStore.store( + key: ClaudeOAuthCredentialsStore.cacheKeyForTesting(profileIdentifier: identifierA), + entry: ClaudeOAuthCredentialsStore.CacheEntry( + data: self.makeCredentialsData(accessToken: "profile-a-token"), + storedAt: Date(), + owner: .codexbar, + profileIdentifier: identifierA)) + KeychainCacheStore.store( + key: ClaudeOAuthCredentialsStore.cacheKeyForTesting(profileIdentifier: identifierB), + entry: ClaudeOAuthCredentialsStore.CacheEntry( + data: self.makeCredentialsData(accessToken: "profile-b-token"), + storedAt: Date(), + owner: .codexbar, + profileIdentifier: identifierB)) + + ClaudeOAuthCredentialsStore.invalidateCache(environment: environmentA) + + #expect(ClaudeOAuthCredentialsStore.hasCachedCredentials(environment: environmentA) == false) + #expect(ClaudeOAuthCredentialsStore.hasCachedCredentials(environment: environmentB) == true) + } + } + + @Test + func `deferred cleanup for one profile does not erase another profile cache`() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + let profileA = root.appendingPathComponent("profile-a", isDirectory: true) + let profileB = root.appendingPathComponent("profile-b", isDirectory: true) + try FileManager.default.createDirectory(at: profileA, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: profileB, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + + let environmentA = ["CLAUDE_CONFIG_DIR": profileA.path] + let environmentB = ["CLAUDE_CONFIG_DIR": profileB.path] + let identifierA = self.profileIdentifier(environment: environmentA) + let identifierB = self.profileIdentifier(environment: environmentB) + + try self.withIsolatedCache { + KeychainCacheStore.store( + key: ClaudeOAuthCredentialsStore.cacheKeyForTesting(profileIdentifier: identifierB), + entry: ClaudeOAuthCredentialsStore.CacheEntry( + data: self.makeCredentialsData(accessToken: "profile-b-token"), + storedAt: Date(), + owner: .codexbar, + profileIdentifier: identifierB)) + + let pendingStore = ClaudeOAuthCredentialsStore.PendingCacheClearMemoryStore() + pendingStore.markPending(profileIdentifier: identifierA) + try ClaudeOAuthCredentialsStore.withPendingCacheClearStoreOverrideForTesting(pendingStore) { + let record = try ClaudeOAuthCredentialsStore.loadRecord( + environment: environmentB, + allowKeychainPrompt: false, + allowClaudeKeychainRepairWithoutPrompt: false) + #expect(record.credentials.accessToken == "profile-b-token") + #expect(pendingStore.isPending(profileIdentifier: identifierA)) + #expect(!pendingStore.isPending(profileIdentifier: identifierB)) + } + } + } + + @Test + func `legacy default profile cache migrates without losing refreshed credentials`() throws { + 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("credentials.json") + let fileModifiedAt = Date(timeIntervalSince1970: 1_700_000_000) + let cacheStoredAt = Date(timeIntervalSince1970: 1_700_000_100) + let fileData = self.makeCredentialsData( + accessToken: "expired-file-token", + expiresAt: Date(timeIntervalSince1970: 1_600_000_000)) + try fileData.write(to: fileURL) + try FileManager.default.setAttributes( + [.modificationDate: fileModifiedAt], + ofItemAtPath: fileURL.path) + let legacyCacheData = self.makeCredentialsData(accessToken: "legacy-refreshed-token") + let historyOwnerIdentifier = String(repeating: "a", count: 64) + let historicalProfileIdentifier = self.profileIdentifier(environment: [:]) + + try self.withIsolatedCache { + try ClaudeOAuthCredentialsStore.withCredentialsProfileIdentifierOverrideForTesting( + historicalProfileIdentifier) + { + try ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(fileURL) { + let environment: [String: String] = [:] + KeychainCacheStore.store( + key: .oauth(provider: .claude), + entry: LegacyCacheEntry( + data: legacyCacheData, + storedAt: cacheStoredAt, + owner: .codexbar, + historyOwnerIdentifier: historyOwnerIdentifier)) + + let record = try ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting( + .onlyOnUserAction) + { + try ClaudeOAuthCredentialsStore.loadRecord( + environment: environment, + allowKeychainPrompt: false, + allowClaudeKeychainRepairWithoutPrompt: false) + } + #expect(record.credentials.accessToken == "legacy-refreshed-token") + #expect(record.source == .cacheKeychain) + + switch KeychainCacheStore.load( + key: ClaudeOAuthCredentialsStore.cacheKeyForTesting( + profileIdentifier: historicalProfileIdentifier), + as: ClaudeOAuthCredentialsStore.CacheEntry.self) + { + case let .found(entry): + #expect(entry.data == legacyCacheData) + #expect(entry.storedAt == cacheStoredAt) + #expect(entry.owner == .codexbar) + #expect(entry.historyOwnerIdentifier == historyOwnerIdentifier) + #expect(entry.profileIdentifier == historicalProfileIdentifier) + case .missing, .invalid, .temporarilyUnavailable: + Issue.record("Expected the legacy default cache to be migrated to its profile key") + } + } + } + } + } + + @Test + func `failed legacy deletion cannot resurrect after profile invalidation`() throws { + 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 missingCredentialsURL = tempDir.appendingPathComponent("missing-credentials.json") + let historicalProfileIdentifier = self.profileIdentifier(environment: [:]) + let pendingStore = ClaudeOAuthCredentialsStore.PendingCacheClearMemoryStore() + + try self.withIsolatedCache { + try ClaudeOAuthCredentialsStore.withPendingCacheClearStoreOverrideForTesting(pendingStore) { + try ClaudeOAuthCredentialsStore.withCredentialsProfileIdentifierOverrideForTesting( + historicalProfileIdentifier) + { + try ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(missingCredentialsURL) { + let legacyKey = KeychainCacheStore.Key.oauth(provider: .claude) + KeychainCacheStore.store( + key: legacyKey, + entry: LegacyCacheEntry( + data: self.makeCredentialsData(accessToken: "stale-legacy-token"), + storedAt: Date(), + owner: .codexbar, + historyOwnerIdentifier: nil)) + + let migrated = try KeychainCacheStore.withClearFailureStatusOverrideForTesting( + errSecInteractionNotAllowed) + { + try ClaudeOAuthCredentialsStore.loadRecord( + environment: [:], + allowKeychainPrompt: false, + allowClaudeKeychainRepairWithoutPrompt: false) + } + #expect(migrated.credentials.accessToken == "stale-legacy-token") + #expect(pendingStore.isPending(profileIdentifier: historicalProfileIdentifier)) + + ClaudeOAuthCredentialsStore.invalidateCache(environment: [:]) + + #expect(!pendingStore.isPending(profileIdentifier: historicalProfileIdentifier)) + do { + _ = try ClaudeOAuthCredentialsStore.loadRecord( + environment: [:], + allowKeychainPrompt: false, + allowClaudeKeychainRepairWithoutPrompt: false) + Issue.record("Expected invalidation to prevent stale legacy cache re-import") + } catch let error as ClaudeOAuthCredentialsError { + guard case .notFound = error else { + Issue.record("Expected .notFound, got \(error)") + return + } + } + + guard case .missing = KeychainCacheStore.load( + key: legacyKey, + as: ClaudeOAuthCredentialsStore.CacheEntry.self) + else { + Issue.record("Expected deferred legacy cleanup to remove the stale cache") + return + } + } + } + } + } + } + + @Test + func `pending legacy cleanup cannot shield migrated cache from file change`() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let credentialsURL = root.appendingPathComponent("credentials.json") + let historicalProfileIdentifier = self.profileIdentifier(environment: [:]) + let pendingStore = ClaudeOAuthCredentialsStore.PendingCacheClearMemoryStore() + + try self.withIsolatedCache { + try ClaudeOAuthCredentialsStore.withPendingCacheClearStoreOverrideForTesting(pendingStore) { + try ClaudeOAuthCredentialsStore.withCredentialsProfileIdentifierOverrideForTesting( + historicalProfileIdentifier) + { + try ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(credentialsURL) { + let legacyKey = KeychainCacheStore.Key.oauth(provider: .claude) + KeychainCacheStore.store( + key: legacyKey, + entry: LegacyCacheEntry( + data: self.makeCredentialsData(accessToken: "stale-legacy-token"), + storedAt: Date(), + owner: .codexbar, + historyOwnerIdentifier: nil)) + + _ = try KeychainCacheStore.withClearFailureStatusOverrideForTesting( + errSecInteractionNotAllowed) + { + try ClaudeOAuthCredentialsStore.loadRecord( + environment: [:], + allowKeychainPrompt: false, + allowClaudeKeychainRepairWithoutPrompt: false) + } + #expect(pendingStore.isPending(profileIdentifier: historicalProfileIdentifier)) + + try self.makeCredentialsData(accessToken: "changed-file-token").write(to: credentialsURL) + #expect(ClaudeOAuthCredentialsStore.invalidateCacheIfCredentialsFileChanged(environment: [:])) + + let record = try ClaudeOAuthCredentialsStore.loadRecord( + environment: [:], + allowKeychainPrompt: false, + allowClaudeKeychainRepairWithoutPrompt: false) + #expect(record.credentials.accessToken == "changed-file-token") + #expect(record.source == .credentialsFile) + } + } + } + } + } + + @Test + func `cache disabled invalidation conditionally retires legacy default entry`() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let missingCredentialsURL = root.appendingPathComponent("missing-credentials.json") + let historicalProfileIdentifier = self.profileIdentifier(environment: [:]) + let pendingStore = ClaudeOAuthCredentialsStore.PendingCacheClearMemoryStore() + + try self.withIsolatedCache { + try ClaudeOAuthCredentialsStore.withPendingCacheClearStoreOverrideForTesting(pendingStore) { + try ClaudeOAuthCredentialsStore.withCredentialsProfileIdentifierOverrideForTesting( + historicalProfileIdentifier) + { + try ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(missingCredentialsURL) { + let legacyKey = KeychainCacheStore.Key.oauth(provider: .claude) + KeychainCacheStore.store( + key: legacyKey, + entry: LegacyCacheEntry( + data: self.makeCredentialsData(accessToken: "invalidated-legacy-token"), + storedAt: Date(), + owner: .codexbar, + historyOwnerIdentifier: nil)) + + ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.never) { + ClaudeOAuthCredentialsStore.invalidateCache(environment: [:]) + } + #expect(pendingStore.isPending(profileIdentifier: historicalProfileIdentifier)) + + do { + _ = try ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.onlyOnUserAction) { + try ClaudeOAuthCredentialsStore.loadRecord( + environment: [:], + allowKeychainPrompt: false, + allowClaudeKeychainRepairWithoutPrompt: false) + } + Issue.record("Expected invalidation to prevent legacy cache migration") + } catch let error as ClaudeOAuthCredentialsError { + guard case .notFound = error else { + Issue.record("Expected .notFound, got \(error)") + return + } + } + + guard case .missing = KeychainCacheStore.load(key: legacyKey, as: LegacyCacheEntry.self) else { + Issue.record("Expected the invalidated legacy cache to be retired") + return + } + } + } + } + } + } + + @Test + func `released fingerprint without path detects removed default credentials file`() throws { + let releasedFingerprintData = Data(#"{"modifiedAtMs":1700000000000,"size":321}"#.utf8) + let releasedFingerprint = try JSONDecoder().decode( + ClaudeOAuthCredentialsStore.CredentialsFileFingerprint.self, + from: releasedFingerprintData) + let historicalCredentialsPath = FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent(".claude", isDirectory: true) + .appendingPathComponent(".credentials.json") + .standardizedFileURL.path + #expect(releasedFingerprint.path == historicalCredentialsPath) + + let root = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let missingCredentialsURL = root.appendingPathComponent("removed-credentials.json") + let historicalProfileIdentifier = self.profileIdentifier(environment: [:]) + let fingerprintStore = ClaudeOAuthCredentialsStore.CredentialsFileFingerprintStore( + fingerprint: releasedFingerprint) + + try self.withIsolatedCache { + ClaudeOAuthCredentialsStore.withCredentialsFileFingerprintStoreOverrideForTesting( + fingerprintStore) + { + ClaudeOAuthCredentialsStore.withCredentialsProfileIdentifierOverrideForTesting( + historicalProfileIdentifier) + { + ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(missingCredentialsURL) { + let profileKey = ClaudeOAuthCredentialsStore.cacheKeyForTesting( + profileIdentifier: historicalProfileIdentifier) + KeychainCacheStore.store( + key: profileKey, + entry: ClaudeOAuthCredentialsStore.CacheEntry( + data: self.makeCredentialsData(accessToken: "removed-file-cache-token"), + storedAt: Date(), + owner: .codexbar, + profileIdentifier: historicalProfileIdentifier)) + + #expect(ClaudeOAuthCredentialsStore.invalidateCacheIfCredentialsFileChanged( + environment: [:])) + guard case .missing = KeychainCacheStore.load( + key: profileKey, + as: ClaudeOAuthCredentialsStore.CacheEntry.self) + else { + Issue.record("Expected the removed default credentials file to invalidate its cache") + return + } + } + } + } + } + } + + @Test + func `legacy cache without profile identity fails closed for custom profile`() throws { + 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(".credentials.json") + let fileData = self.makeCredentialsData(accessToken: "file-token") + try fileData.write(to: fileURL) + try FileManager.default.setAttributes( + [.modificationDate: Date(timeIntervalSinceNow: -3600)], + ofItemAtPath: fileURL.path) + let legacyCacheData = self.makeCredentialsData(accessToken: "legacy-cache-token") + + try self.withIsolatedCache { + let environment = ["CLAUDE_CONFIG_DIR": tempDir.path] + _ = ClaudeOAuthCredentialsStore.invalidateCacheIfCredentialsFileChanged(environment: environment) + KeychainCacheStore.store( + key: .oauth(provider: .claude), + entry: LegacyCacheEntry( + data: legacyCacheData, + storedAt: Date(), + owner: .claudeCLI, + historyOwnerIdentifier: nil)) + + let record = try ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting( + .onlyOnUserAction) + { + try ClaudeOAuthCredentialsStore.loadRecord( + environment: environment, + allowKeychainPrompt: false, + allowClaudeKeychainRepairWithoutPrompt: false) + } + #expect(record.credentials.accessToken == "file-token") + #expect(record.source == .credentialsFile) + + switch KeychainCacheStore.load( + key: ClaudeOAuthCredentialsStore.cacheKeyForTesting( + profileIdentifier: self.profileIdentifier(environment: environment)), + as: ClaudeOAuthCredentialsStore.CacheEntry.self) + { + case let .found(entry): + #expect(entry.profileIdentifier == self.profileIdentifier(environment: environment)) + #expect(try ClaudeOAuthCredentials.parse(data: entry.data).accessToken == "file-token") + case .missing, .invalid, .temporarilyUnavailable: + Issue.record("Expected the custom profile file to populate its own cache entry") + } + } + } +} diff --git a/Tests/CodexBarTests/ClaudeOAuthCredentialsStoreCLIStorageOwnershipTests.swift b/Tests/CodexBarTests/ClaudeOAuthCredentialsStoreCLIStorageOwnershipTests.swift new file mode 100644 index 0000000000..9876f305db --- /dev/null +++ b/Tests/CodexBarTests/ClaudeOAuthCredentialsStoreCLIStorageOwnershipTests.swift @@ -0,0 +1,713 @@ +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) + } + + private func withDeterministicCacheService( + _ service: String, + operation: () throws -> T) rethrows -> T + { + let pendingStore = ClaudeOAuthCredentialsStore.PendingCacheClearMemoryStore() + return try ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.onlyOnUserAction) { + try ClaudeOAuthCredentialsStore.withPendingCacheClearStoreOverrideForTesting(pendingStore) { + try KeychainCacheStore.withServiceOverrideForTesting(service, operation: operation) + } + } + } + + private func withDeterministicCacheService( + _ service: String, + operation: () async throws -> T) async rethrows -> T + { + let pendingStore = ClaudeOAuthCredentialsStore.PendingCacheClearMemoryStore() + return try await ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.onlyOnUserAction) { + try await ClaudeOAuthCredentialsStore.withPendingCacheClearStoreOverrideForTesting(pendingStore) { + try await KeychainCacheStore.withServiceOverrideForTesting(service, operation: operation) + } + } + } + + private func withClaudeOAuthTokenRefreshStub( + handler: @escaping (URLRequest) throws -> (HTTPURLResponse, Data), + operation: () async throws -> T) async rethrows -> T + { + let registered = URLProtocol.registerClass(ClaudeOAuthTokenRefreshStubURLProtocol.self) + ClaudeOAuthTokenRefreshStubURLProtocol.reset() + ClaudeOAuthTokenRefreshStubURLProtocol.handler = handler + defer { + if registered { + URLProtocol.unregisterClass(ClaudeOAuthTokenRefreshStubURLProtocol.self) + } + ClaudeOAuthTokenRefreshStubURLProtocol.reset() + } + return try await operation() + } + + private func requestBodyString(_ request: URLRequest) -> String { + if let body = request.httpBody { + return String(data: body, encoding: .utf8) ?? "" + } + + guard let stream = request.httpBodyStream else { return "" } + stream.open() + defer { stream.close() } + + var data = Data() + let bufferSize = 1024 + let buffer = UnsafeMutablePointer.allocate(capacity: bufferSize) + defer { buffer.deallocate() } + + while stream.hasBytesAvailable { + let count = stream.read(buffer, maxLength: bufferSize) + guard count > 0 else { break } + data.append(buffer, count: count) + } + + return String(data: data, encoding: .utf8) ?? "" + } + + @Test + func `successful codexbar refresh is re-owned when Claude CLI storage appears`() async throws { + let service = "com.steipete.codexbar.cache.tests.\(UUID().uuidString)" + try await self.withDeterministicCacheService(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("credentials.json") + try await ClaudeOAuthCredentialsStore.withIsolatedCredentialsFileTrackingForTesting { + try await ClaudeOAuthCredentialsStore.withIsolatedMemoryCacheForTesting { + try await ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(fileURL) { + try await ClaudeOAuthCredentialsStore.withKeychainAccessOverrideForTesting(true) { + ClaudeOAuthCredentialsStore.invalidateCache() + let cacheKey = ClaudeOAuthCredentialsStore.cacheKeyForTesting( + profileIdentifier: ClaudeOAuthCredentialsStore.credentialsProfileIdentifier( + environment: [:])) + 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)) + + var tokenRefreshRequestCount = 0 + let refreshed = try await self.withClaudeOAuthTokenRefreshStub(handler: { request in + tokenRefreshRequestCount += 1 + #expect(request.url?.host == "platform.claude.com") + #expect(request.url?.path == "/v1/oauth/token") + #expect(request.httpMethod == "POST") + #expect(request.value(forHTTPHeaderField: "Accept") == "application/json") + #expect( + request.value(forHTTPHeaderField: "Content-Type") == + "application/x-www-form-urlencoded") + + let body = self.requestBodyString(request) + #expect(body.contains("grant_type=refresh_token")) + #expect(body.contains("refresh_token=cached-refresh-token")) + #expect(body.contains("client_id=\(ClaudeOAuthCredentialsStore.defaultOAuthClientID)")) + + let response = try HTTPURLResponse( + url: #require(request.url), + statusCode: 200, + httpVersion: "HTTP/1.1", + headerFields: ["Content-Type": "application/json"])! + let json = """ + { + "access_token": "fresh-codexbar-token", + "refresh_token": "fresh-refresh-token", + "expires_in": 3600, + "token_type": "Bearer" + } + """ + return (response, Data(json.utf8)) + }, operation: { + try await ClaudeOAuthRefreshFailureGate.$shouldAttemptOverride.withValue(true) { + try await ClaudeOAuthCredentialsStore.loadWithAutoRefresh( + environment: [:], + allowKeychainPrompt: false, + respectKeychainPromptCooldown: true) + } + }) + + #expect(refreshed.accessToken == "fresh-codexbar-token") + #expect(refreshed.refreshToken == "fresh-refresh-token") + #expect(tokenRefreshRequestCount == 1) + + switch KeychainCacheStore.load( + key: cacheKey, + as: ClaudeOAuthCredentialsStore.CacheEntry.self) + { + case let .found(entry): + #expect(entry.owner == .codexbar) + let parsed = try ClaudeOAuthCredentials.parse(data: entry.data) + #expect(parsed.accessToken == "fresh-codexbar-token") + #expect(parsed.refreshToken == "fresh-refresh-token") + default: + Issue.record("Expected refreshed CodexBar-owned cache entry") + } + + let keychainData = self.makeCredentialsData( + accessToken: "claude-keychain", + expiresAt: Date(timeIntervalSinceNow: 3600), + refreshToken: "keychain-refresh-token") + + let recordAfterCLIStorageAppears = try ClaudeOAuthCredentialsStore + .withClaudeKeychainOverridesForTesting(data: keychainData, fingerprint: nil) { + try ClaudeOAuthCredentialsStore.loadRecord( + environment: [:], + allowKeychainPrompt: false, + respectKeychainPromptCooldown: true, + allowClaudeKeychainRepairWithoutPrompt: false) + } + + #expect(recordAfterCLIStorageAppears.credentials.accessToken == "fresh-codexbar-token") + #expect(recordAfterCLIStorageAppears.owner == .claudeCLI) + #expect(recordAfterCLIStorageAppears.source == .memoryCache) + } + } + } + } + } + } + + @Test + func `rotated refresh token preserves history owner through cache restart`() async throws { + let service = "com.steipete.codexbar.cache.tests.\(UUID().uuidString)" + try await self.withDeterministicCacheService(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("credentials.json") + try await ClaudeOAuthCredentialsStore.withIsolatedCredentialsFileTrackingForTesting { + try await ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(fileURL) { + try await ClaudeOAuthCredentialsStore.withKeychainAccessOverrideForTesting(true) { + let cacheKey = ClaudeOAuthCredentialsStore.cacheKeyForTesting( + profileIdentifier: ClaudeOAuthCredentialsStore.credentialsProfileIdentifier( + environment: [:])) + defer { KeychainCacheStore.clear(key: cacheKey) } + ClaudeOAuthCredentialsStore.invalidateCache() + let expiredData = self.makeCredentialsData( + accessToken: "access-before-rotation", + expiresAt: Date(timeIntervalSinceNow: -3600), + refreshToken: "refresh-before-rotation") + let originalCredentials = try ClaudeOAuthCredentials.parse(data: expiredData) + let originalHistoryOwner = try #require(originalCredentials.historyOwnerIdentifier) + KeychainCacheStore.store( + key: cacheKey, + entry: ClaudeOAuthCredentialsStore.CacheEntry( + data: expiredData, + storedAt: Date(), + owner: .codexbar)) + + let refreshedRecord = try await ClaudeOAuthCredentialsStore + .withIsolatedMemoryCacheForTesting { + try await self.withClaudeOAuthTokenRefreshStub(handler: { request in + let response = try HTTPURLResponse( + url: #require(request.url), + statusCode: 200, + httpVersion: "HTTP/1.1", + headerFields: ["Content-Type": "application/json"])! + let json = """ + { + "access_token": "access-after-rotation", + "refresh_token": "refresh-after-rotation", + "expires_in": 3600, + "token_type": "Bearer" + } + """ + return (response, Data(json.utf8)) + }, operation: { + try await ClaudeOAuthRefreshFailureGate.$shouldAttemptOverride.withValue(true) { + try await ClaudeOAuthCredentialsStore.loadRecordWithAutoRefresh( + environment: [:], + allowKeychainPrompt: false, + respectKeychainPromptCooldown: true) + } + }) + } + + let rotatedCredentialOwner = try #require( + refreshedRecord.credentials.historyOwnerIdentifier) + #expect(rotatedCredentialOwner != originalHistoryOwner) + #expect(refreshedRecord.historyOwnerIdentifier == originalHistoryOwner) + + switch KeychainCacheStore.load( + key: cacheKey, + as: ClaudeOAuthCredentialsStore.CacheEntry.self) + { + case let .found(entry): + #expect(entry.owner == .codexbar) + #expect(entry.historyOwnerIdentifier == originalHistoryOwner) + default: + Issue.record("Expected refreshed cache entry with preserved history lineage") + } + + let restartedRecord = try ClaudeOAuthCredentialsStore.withIsolatedMemoryCacheForTesting { + try ClaudeOAuthCredentialsStore.loadRecord( + environment: [:], + allowKeychainPrompt: false, + respectKeychainPromptCooldown: true, + allowClaudeKeychainRepairWithoutPrompt: false) + } + #expect(restartedRecord.credentials.accessToken == "access-after-rotation") + #expect(restartedRecord.credentials.refreshToken == "refresh-after-rotation") + #expect(restartedRecord.source == .cacheKeychain) + #expect(restartedRecord.historyOwnerIdentifier == originalHistoryOwner) + } + } + } + } + } + + @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 self.withDeterministicCacheService(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 self.withDeterministicCacheService(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 self.withDeterministicCacheService(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 self.withDeterministicCacheService(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(.onlyOnUserAction) { + 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) + } + } + } + } + } + + @Test + func `load record ignores codexbar cache in never prompt mode`() throws { + let service = "com.steipete.codexbar.cache.tests.\(UUID().uuidString)" + try KeychainCacheStore.withServiceOverrideForTesting(service) { + KeychainCacheStore.setTestStoreForTesting(true) + defer { KeychainCacheStore.setTestStoreForTesting(false) } + + 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("credentials.json") + let pendingStore = ClaudeOAuthCredentialsStore.PendingCacheClearMemoryStore() + try ClaudeOAuthCredentialsStore.withPendingCacheClearStoreOverrideForTesting(pendingStore) { + try ClaudeOAuthCredentialsStore.withIsolatedCredentialsFileTrackingForTesting { + try ClaudeOAuthCredentialsStore.withIsolatedMemoryCacheForTesting { + try ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(fileURL) { + 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)) + + do { + _ = try ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.never) { + try ClaudeOAuthCredentialsStore.withClaudeKeychainOverridesForTesting( + data: self.makeCredentialsData( + accessToken: "claude-keychain", + expiresAt: Date(timeIntervalSinceNow: 3600), + refreshToken: "keychain-refresh-token"), + fingerprint: nil) + { + try ClaudeOAuthCredentialsStore.loadRecord( + environment: [:], + allowKeychainPrompt: false, + respectKeychainPromptCooldown: true, + allowClaudeKeychainRepairWithoutPrompt: false) + } + } + Issue.record("Expected ClaudeOAuthCredentialsError.notFound") + } catch let error as ClaudeOAuthCredentialsError { + guard case .notFound = error else { + Issue.record("Expected .notFound, got \(error)") + return + } + } + } + } + } + } + } + } + + @Test + func `expired claude CLI owner blocks background mcp O auth but lets user action delegate`() async throws { + let service = "com.steipete.codexbar.cache.tests.\(UUID().uuidString)" + let mcpOAuthOnly = Data(""" + { + "mcpOAuth": { + "plugin:slack:slack": { "accessToken": "" } + } + } + """.utf8) + + try await self.withDeterministicCacheService(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") + + await KeychainAccessGate.withTaskOverrideForTesting(false) { + await ClaudeOAuthKeychainReadStrategyPreference.withTaskOverrideForTesting( + .securityCLIExperimental, + operation: { + await ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(fileURL) { + await ClaudeOAuthCredentialsStore.withSecurityCLIReadOverrideForTesting( + .data(mcpOAuthOnly)) + { + ClaudeOAuthCredentialsStore.invalidateCache() + let cacheKey = KeychainCacheStore.Key.oauth(provider: .claude) + defer { KeychainCacheStore.clear(key: cacheKey) } + + let expiredData = self.makeCredentialsData( + accessToken: "expired-claude-cli-owner", + expiresAt: Date(timeIntervalSinceNow: -3600), + refreshToken: "refresh-token") + KeychainCacheStore.store( + key: cacheKey, + entry: ClaudeOAuthCredentialsStore.CacheEntry( + data: expiredData, + storedAt: Date(), + owner: .claudeCLI)) + + do { + _ = try await ClaudeOAuthCredentialsStore.loadWithAutoRefresh( + environment: [:], + allowKeychainPrompt: false, + respectKeychainPromptCooldown: true) + Issue.record("Expected mcpOAuth-only keychain error") + } catch let error as ClaudeOAuthCredentialsError { + guard case .mcpOAuthOnlyKeychain = error else { + Issue.record("Expected .mcpOAuthOnlyKeychain, got \(error)") + return + } + } catch { + Issue.record("Expected ClaudeOAuthCredentialsError, got \(error)") + } + + do { + _ = try await ProviderInteractionContext.$current.withValue(.userInitiated) { + try await ClaudeOAuthCredentialsStore.loadWithAutoRefresh( + environment: [:], + allowKeychainPrompt: false, + respectKeychainPromptCooldown: true) + } + Issue.record("Expected delegated refresh on explicit user action") + } catch let error as ClaudeOAuthCredentialsError { + guard case .refreshDelegatedToClaudeCLI = error else { + Issue.record("Expected .refreshDelegatedToClaudeCLI, got \(error)") + return + } + } catch { + Issue.record("Expected ClaudeOAuthCredentialsError, got \(error)") + } + } + } + }) + } + } + } +} + +private final class ClaudeOAuthTokenRefreshStubURLProtocol: URLProtocol { + private static let _handlerBox = LockIsolated<((URLRequest) throws -> (HTTPURLResponse, Data))?>(nil) + static var handler: ((URLRequest) throws -> (HTTPURLResponse, Data))? { + get { Self._handlerBox.value } + set { Self._handlerBox.setValue(newValue) } + } + + static func reset() { + self.handler = nil + } + + override static func canInit(with request: URLRequest) -> Bool { + request.url?.host == "platform.claude.com" && request.url?.path == "/v1/oauth/token" + } + + override static func canonicalRequest(for request: URLRequest) -> URLRequest { + request + } + + override func startLoading() { + guard let handler = Self.handler else { + self.client?.urlProtocol(self, didFailWithError: URLError(.badServerResponse)) + return + } + + do { + let (response, data) = try handler(self.request) + self.client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + self.client?.urlProtocol(self, didLoad: data) + self.client?.urlProtocolDidFinishLoading(self) + } catch { + self.client?.urlProtocol(self, didFailWithError: error) + } + } + + override func stopLoading() {} +} diff --git a/Tests/CodexBarTests/ClaudeOAuthCredentialsStoreIsolatedSecurityCLITests.swift b/Tests/CodexBarTests/ClaudeOAuthCredentialsStoreIsolatedSecurityCLITests.swift new file mode 100644 index 0000000000..44a709b4be --- /dev/null +++ b/Tests/CodexBarTests/ClaudeOAuthCredentialsStoreIsolatedSecurityCLITests.swift @@ -0,0 +1,127 @@ +import Foundation +import Testing +@testable import CodexBarCore + +#if os(macOS) +@Suite(.serialized) +struct ClaudeOAuthCredentialsStoreIsolatedSecurityCLITests { + @Test + func `safety blocks security CLI access to the login keychain`() { + let blockedEnvironment = [KeychainTestSafety.suppressAccessEnvironmentKey: "1"] + #expect(ClaudeOAuthCredentialsStore.securityCLIReadArguments( + account: nil, + environment: blockedEnvironment) == nil) + + let explicitOptIn = [ + KeychainTestSafety.suppressAccessEnvironmentKey: "1", + KeychainTestSafety.allowAccessEnvironmentKey: "1", + ] + let expectedArguments = [ + "find-generic-password", + "-s", + "Claude Code-credentials", + "-w", + ] + #expect(ClaudeOAuthCredentialsStore.securityCLIReadArguments( + account: nil, + environment: explicitOptIn) == expectedArguments) + } + + @Test + func `isolated security CLI keychain requires global keychain disable`() { + let keychainPath = "/tmp/codexbar-fixtures/verify.keychain-db" + let isolatedEnvironment = [ + KeychainAccessGate.disableAccessEnvironmentKey: "1", + ClaudeOAuthCredentialsStore.isolatedSecurityCLIKeychainEnvironmentKey: keychainPath, + ] + let expectedArguments = [ + "find-generic-password", + "-s", + "Claude Code-credentials", + "-w", + keychainPath, + ] + + #expect(KeychainAccessGate.isDisabledByEnvironment(isolatedEnvironment)) + #expect(ClaudeOAuthCredentialsStore.securityCLIReadArguments( + account: nil, + environment: isolatedEnvironment) == expectedArguments) + #expect(ClaudeOAuthCredentialsStore.securityCLIReadArguments( + account: nil, + environment: [ + ClaudeOAuthCredentialsStore.isolatedSecurityCLIKeychainEnvironmentKey: keychainPath, + ]) == nil) + #expect(ClaudeOAuthCredentialsStore.securityCLIReadArguments( + account: nil, + environment: [KeychainAccessGate.disableAccessEnvironmentKey: "1"]) == nil) + #expect(ClaudeOAuthCredentialsStore.securityCLIReadArguments( + account: nil, + environment: [ + KeychainAccessGate.disableAccessEnvironmentKey: "1", + ClaudeOAuthCredentialsStore.isolatedSecurityCLIKeychainEnvironmentKey: "relative.keychain-db", + ]) == nil) + } + + @Test + func `isolated security CLI keychain remains readable while other keychain access is disabled`() { + let mcpOnlyPayload = Data(#"{"mcpOAuth":{"plugin:test":{"accessToken":"synthetic"}}}"#.utf8) + let environment = [ + KeychainAccessGate.disableAccessEnvironmentKey: "1", + ClaudeOAuthCredentialsStore.isolatedSecurityCLIKeychainEnvironmentKey: "/tmp/verify.keychain-db", + ] + + ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.onlyOnUserAction) { + let isMcpOnly = ClaudeOAuthCredentialsStore + .withSecurityCLIReadOverrideForTesting(.data(mcpOnlyPayload)) { + ClaudeOAuthCredentialsStore.isMcpOAuthOnlyClaudeKeychainPayloadPresent( + interaction: .background, + readStrategy: .securityCLIExperimental, + keychainAccessDisabled: true, + environment: environment) + } + #expect(isMcpOnly) + + let blockedWithoutIsolatedKeychain = ClaudeOAuthCredentialsStore + .withSecurityCLIReadOverrideForTesting(.data(mcpOnlyPayload)) { + ClaudeOAuthCredentialsStore.isMcpOAuthOnlyClaudeKeychainPayloadPresent( + interaction: .background, + readStrategy: .securityCLIExperimental, + keychainAccessDisabled: true, + environment: [KeychainAccessGate.disableAccessEnvironmentKey: "1"]) + } + #expect(blockedWithoutIsolatedKeychain == false) + } + } + + @Test + func `never prompt mode still detects MCP-only payload via experimental security CLI reader`() { + let mcpOnlyPayload = Data(#"{"mcpOAuth":{"plugin:test":{"accessToken":"synthetic"}}}"#.utf8) + let environment = [ + KeychainAccessGate.disableAccessEnvironmentKey: "1", + ClaudeOAuthCredentialsStore.isolatedSecurityCLIKeychainEnvironmentKey: "/tmp/verify.keychain-db", + ] + + let isMcpOnly = ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.never) { + ClaudeOAuthCredentialsStore.withSecurityCLIReadOverrideForTesting(.data(mcpOnlyPayload)) { + ClaudeOAuthCredentialsStore.isMcpOAuthOnlyClaudeKeychainPayloadPresent( + interaction: .background, + readStrategy: .securityCLIExperimental, + keychainAccessDisabled: true, + environment: environment) + } + } + #expect(!isMcpOnly) + + let blockedViaSecurityFramework = ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.never) { + ClaudeOAuthCredentialsStore.withSecurityCLIReadOverrideForTesting(.data(mcpOnlyPayload)) { + ClaudeOAuthCredentialsStore.isMcpOAuthOnlyClaudeKeychainPayloadPresent( + interaction: .background, + readStrategy: .securityFramework, + keychainAccessDisabled: false, + environment: [:]) + } + } + #expect(!blockedViaSecurityFramework) + } +} +#endif diff --git a/Tests/CodexBarTests/ClaudeOAuthCredentialsStoreMCPOnlyGuardTests.swift b/Tests/CodexBarTests/ClaudeOAuthCredentialsStoreMCPOnlyGuardTests.swift new file mode 100644 index 0000000000..0c129f2291 --- /dev/null +++ b/Tests/CodexBarTests/ClaudeOAuthCredentialsStoreMCPOnlyGuardTests.swift @@ -0,0 +1,115 @@ +import Foundation +import Testing +@testable import CodexBarCore + +#if os(macOS) +@Suite(.serialized) +struct ClaudeOAuthCredentialsStoreMCPOnlyGuardTests { + @Test + func `standard reader skips MCP keychain probe in background but preserves user refresh`() async throws { + let service = "com.steipete.codexbar.cache.tests.\(UUID().uuidString)" + let mcpOAuthOnly = Data(#"{"mcpOAuth":{"plugin:test":{"accessToken":"synthetic"}}}"#.utf8) + 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 credentialsURL = tempDir.appendingPathComponent("credentials.json") + + await KeychainCacheStore.withServiceOverrideForTesting(service) { + KeychainCacheStore.setTestStoreForTesting(true) + defer { KeychainCacheStore.setTestStoreForTesting(false) } + + ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting() + defer { ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting() } + + await KeychainAccessGate.withTaskOverrideForTesting(false) { + await ClaudeOAuthKeychainReadStrategyPreference.withTaskOverrideForTesting(.securityFramework) { + await ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.onlyOnUserAction) { + await ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(credentialsURL) { + await ClaudeOAuthCredentialsStore.withClaudeKeychainOverridesForTesting( + data: mcpOAuthOnly, + fingerprint: nil) + { + let isMcpOnly = ProviderInteractionContext.$current.withValue(.background) { + ClaudeOAuthCredentialsStore.isMcpOAuthOnlyClaudeKeychainPayloadPresent( + interaction: .background, + readStrategy: .securityFramework, + keychainAccessDisabled: false, + environment: [:]) + } + #expect(!isMcpOnly) + + let userInitiatedIsMcpOnly = ProviderInteractionContext.$current + .withValue(.userInitiated) { + ClaudeOAuthCredentialsStore.isMcpOAuthOnlyClaudeKeychainPayloadPresent( + interaction: .userInitiated, + readStrategy: .securityFramework, + keychainAccessDisabled: false, + environment: [:]) + } + #expect(userInitiatedIsMcpOnly) + + ClaudeOAuthCredentialsStore.invalidateCache() + let cacheKey = KeychainCacheStore.Key.oauth(provider: .claude) + defer { KeychainCacheStore.clear(key: cacheKey) } + KeychainCacheStore.store( + key: cacheKey, + entry: ClaudeOAuthCredentialsStore.CacheEntry( + data: self.expiredCredentialsData, + storedAt: Date(), + owner: .claudeCLI)) + + do { + _ = try await ClaudeOAuthCredentialsStore.loadWithAutoRefresh( + environment: [:], + allowKeychainPrompt: false, + respectKeychainPromptCooldown: true) + Issue.record("Expected background refresh delegation") + } catch let error as ClaudeOAuthCredentialsError { + guard case .refreshDelegatedToClaudeCLI = error else { + Issue.record("Expected .refreshDelegatedToClaudeCLI, got \(error)") + return + } + } catch { + Issue.record("Expected ClaudeOAuthCredentialsError, got \(error)") + } + + do { + _ = try await ProviderInteractionContext.$current.withValue(.userInitiated) { + try await ClaudeOAuthCredentialsStore.loadWithAutoRefresh( + environment: [:], + allowKeychainPrompt: false, + respectKeychainPromptCooldown: true) + } + Issue.record("Expected explicit user Refresh to delegate") + } catch let error as ClaudeOAuthCredentialsError { + guard case .refreshDelegatedToClaudeCLI = error else { + Issue.record("Expected .refreshDelegatedToClaudeCLI, got \(error)") + return + } + } catch { + Issue.record("Expected ClaudeOAuthCredentialsError, got \(error)") + } + } + } + } + } + } + } + } + + private var expiredCredentialsData: Data { + let json = #""" + { + "claudeAiOauth": { + "accessToken": "expired", + "refreshToken": "refresh", + "expiresAt": 1000, + "scopes": ["user:profile"] + } + } + """# + return Data(json.utf8) + } +} +#endif diff --git a/Tests/CodexBarTests/ClaudeOAuthCredentialsStoreNeverPromptCacheTests.swift b/Tests/CodexBarTests/ClaudeOAuthCredentialsStoreNeverPromptCacheTests.swift new file mode 100644 index 0000000000..a7d454294a --- /dev/null +++ b/Tests/CodexBarTests/ClaudeOAuthCredentialsStoreNeverPromptCacheTests.swift @@ -0,0 +1,711 @@ +import Foundation +import Security +import Testing +@testable import CodexBarCore + +@Suite(.serialized) +struct ClaudeOAuthCredentialsStoreNeverPromptCacheTests { + private struct TestState { + let pendingStore: ClaudeOAuthCredentialsStore.PendingCacheClearMemoryStore + let recorder: ClaudeOAuthCredentialsStore.OAuthCacheOperationRecorder + + var cacheKey: KeychainCacheStore.Key { + ClaudeOAuthCredentialsStore.cacheKeyForTesting( + profileIdentifier: ClaudeOAuthCredentialsStore.credentialsProfileIdentifier(environment: [:])) + } + } + + 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) + } + + private func withTestState(_ operation: (TestState) throws -> T) throws -> T { + let service = "com.steipete.codexbar.cache.tests.\(UUID().uuidString)" + let pendingStore = ClaudeOAuthCredentialsStore.PendingCacheClearMemoryStore() + let recorder = ClaudeOAuthCredentialsStore.OAuthCacheOperationRecorder() + let fingerprintStore = ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprintStore() + let state = TestState(pendingStore: pendingStore, recorder: recorder) + + return try KeychainCacheStore.withServiceOverrideForTesting(service) { + KeychainCacheStore.setTestStoreForTesting(true) + defer { KeychainCacheStore.setTestStoreForTesting(false) } + + return try KeychainAccessGate.withTaskOverrideForTesting(false) { + try ClaudeOAuthCredentialsStore.withKeychainAccessOverrideForTesting(false) { + try ClaudeOAuthCredentialsStore.withPendingCacheClearStoreOverrideForTesting(pendingStore) { + try ClaudeOAuthCredentialsStore.withOAuthCacheOperationRecorderForTesting(recorder) { + try ClaudeOAuthCredentialsStore.withIsolatedMemoryCacheForTesting { + try ClaudeOAuthCredentialsStore.withIsolatedCredentialsFileTrackingForTesting { + try ClaudeOAuthCredentialsStore + .withClaudeKeychainFingerprintStoreOverrideForTesting(fingerprintStore) { + try operation(state) + } + } + } + } + } + } + } + } + } + + private func withCredentialsFile( + data: Data?, + operation: (URL) throws -> T) throws -> T + { + let tempDirectory = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: tempDirectory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: tempDirectory) } + + let fileURL = tempDirectory.appendingPathComponent("credentials.json") + if let data { + try data.write(to: fileURL) + } + return try ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(fileURL) { + try operation(fileURL) + } + } + + private func seedCache( + _ state: TestState, + accessToken: String, + storedAt: Date = Date()) + { + let data = self.makeCredentialsData( + accessToken: accessToken, + expiresAt: Date(timeIntervalSinceNow: 3600)) + let stored = ClaudeOAuthCredentialsStore.withOAuthCacheOperationRecorderForTesting(nil) { + KeychainCacheStore.storeResult( + key: state.cacheKey, + entry: ClaudeOAuthCredentialsStore.CacheEntry(data: data, storedAt: storedAt)) + } + #expect(stored) + } + + private func cachedToken(_ state: TestState) throws -> String? { + try ClaudeOAuthCredentialsStore.withOAuthCacheOperationRecorderForTesting(nil) { + switch KeychainCacheStore.load( + key: state.cacheKey, + as: ClaudeOAuthCredentialsStore.CacheEntry.self) + { + case let .found(entry): + return try ClaudeOAuthCredentials.parse(data: entry.data).accessToken + case .missing: + return nil + case .invalid, .temporarilyUnavailable: + Issue.record("Expected a valid or missing test cache entry") + return nil + } + } + } + + private func runDefaults(_ arguments: [String]) throws -> (status: Int32, output: String) { + let process = Process() + let output = Pipe() + process.executableURL = URL(fileURLWithPath: "/usr/bin/defaults") + process.arguments = arguments + process.standardOutput = output + process.standardError = output + try process.run() + process.waitUntilExit() + let data = output.fileHandleForReading.readDataToEndOfFile() + return (process.terminationStatus, String(data: data, encoding: .utf8) ?? "") + } + + @Test + func `never mode loads the credentials file with zero oauth cache IO`() throws { + try self.withTestState { state in + let fileData = self.makeCredentialsData( + accessToken: "file-token", + expiresAt: Date(timeIntervalSinceNow: 3600)) + try self.withCredentialsFile(data: fileData) { _ in + self.seedCache(state, accessToken: "cached-token") + + let credentials = try ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.never) { + try ProviderInteractionContext.$current.withValue(.background) { + try ClaudeOAuthCredentialsStore.load(environment: [:], allowKeychainPrompt: false) + } + } + + #expect(credentials.accessToken == "file-token") + #expect(state.recorder.operations.isEmpty) + #expect(state.pendingStore.isPending) + let cachedToken = try self.cachedToken(state) + #expect(cachedToken == "cached-token") + } + } + } + + @Test + func `never mode file invalidation records a tombstone without oauth cache IO`() throws { + try self.withTestState { state in + let initialData = self.makeCredentialsData( + accessToken: "initial-token", + expiresAt: Date(timeIntervalSinceNow: 3600)) + try self.withCredentialsFile(data: initialData) { fileURL in + self.seedCache(state, accessToken: "cached-token") + + let initialChange = ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.never) { + ClaudeOAuthCredentialsStore.invalidateCacheIfCredentialsFileChanged() + } + #expect(initialChange) + + let updatedData = self.makeCredentialsData( + accessToken: "updated-token-with-a-different-size", + expiresAt: Date(timeIntervalSinceNow: 7200)) + try updatedData.write(to: fileURL) + + let changed = ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.never) { + ClaudeOAuthCredentialsStore.invalidateCacheIfCredentialsFileChanged() + } + let changedAgain = ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.never) { + ClaudeOAuthCredentialsStore.invalidateCacheIfCredentialsFileChanged() + } + + #expect(changed) + #expect(!changedAgain) + #expect(state.recorder.operations.isEmpty) + #expect(state.pendingStore.isPending) + let cachedToken = try self.cachedToken(state) + #expect(cachedToken == "cached-token") + } + } + } + + @Test + func `never mode has cached credentials ignores stale oauth cache with zero IO`() throws { + try self.withTestState { state in + try self.withCredentialsFile(data: nil) { _ in + self.seedCache(state, accessToken: "cached-token") + + let hasCached = ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.never) { + ProviderInteractionContext.$current.withValue(.background) { + ClaudeOAuthCredentialsStore.hasCachedCredentials(environment: [:]) + } + } + + #expect(!hasCached) + #expect(state.recorder.operations.isEmpty) + let cachedToken = try self.cachedToken(state) + #expect(cachedToken == "cached-token") + } + } + } + + @Test + func `has cached credentials ignores stale oauth cache when pending clear fails`() throws { + try self.withTestState { state in + try self.withCredentialsFile(data: nil) { _ in + self.seedCache(state, accessToken: "cached-token") + ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.never) { + ClaudeOAuthCredentialsStore.invalidateCache() + } + + let hasCached = KeychainCacheStore.withClearFailureStatusOverrideForTesting( + errSecInteractionNotAllowed) + { + ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.onlyOnUserAction) { + ProviderInteractionContext.$current.withValue(.background) { + ClaudeOAuthCredentialsStore.hasCachedCredentials(environment: [:]) + } + } + } + + #expect(!hasCached) + #expect(state.pendingStore.isPending) + #expect(state.recorder.operations == [.clear]) + let cachedToken = try self.cachedToken(state) + #expect(cachedToken == "cached-token") + } + } + } + + @Test + func `leaving never mode clears stale oauth cache before repopulating from file`() throws { + try self.withTestState { state in + let fileData = self.makeCredentialsData( + accessToken: "file-token-new", + expiresAt: Date(timeIntervalSinceNow: 3600)) + try self.withCredentialsFile(data: fileData) { _ in + self.seedCache( + state, + accessToken: "cached-token", + storedAt: Date(timeIntervalSince1970: 0)) + + _ = ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.never) { + ClaudeOAuthCredentialsStore.invalidateCacheIfCredentialsFileChanged() + } + #expect(state.pendingStore.isPending) + #expect(state.recorder.operations.isEmpty) + + let credentials = try ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting( + .onlyOnUserAction) + { + try ProviderInteractionContext.$current.withValue(.background) { + try ClaudeOAuthCredentialsStore.load(environment: [:], allowKeychainPrompt: false) + } + } + + #expect(credentials.accessToken == "file-token-new") + #expect(!state.pendingStore.isPending) + #expect(state.recorder.operations == [.clear, .load, .load, .store]) + let cachedToken = try self.cachedToken(state) + #expect(cachedToken == "file-token-new") + } + } + } + + @Test + func `logout under never mode clears stale oauth cache after access is reenabled`() throws { + try self.withTestState { state in + try self.withCredentialsFile(data: nil) { _ in + self.seedCache(state, accessToken: "cached-token") + + ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.never) { + ClaudeOAuthCredentialsStore.invalidateCache() + } + #expect(state.pendingStore.isPending) + #expect(state.recorder.operations.isEmpty) + let staleToken = try self.cachedToken(state) + #expect(staleToken == "cached-token") + + do { + _ = try ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.onlyOnUserAction) { + try ProviderInteractionContext.$current.withValue(.background) { + try ClaudeOAuthCredentialsStore.load(environment: [:], allowKeychainPrompt: false) + } + } + Issue.record("Expected ClaudeOAuthCredentialsError.notFound") + } catch let error as ClaudeOAuthCredentialsError { + guard case .notFound = error else { + Issue.record("Expected .notFound, got \(error)") + return + } + } + + #expect(!state.pendingStore.isPending) + #expect(state.recorder.operations == [.clear, .load, .load]) + let cachedToken = try self.cachedToken(state) + #expect(cachedToken == nil) + } + } + } + + @Test + func `pending oauth cache clear retries after a temporarily unavailable delete`() throws { + try self.withTestState { state in + let fileData = self.makeCredentialsData( + accessToken: "file-token-new", + expiresAt: Date(timeIntervalSinceNow: 3600)) + try self.withCredentialsFile(data: fileData) { _ in + self.seedCache(state, accessToken: "cached-token") + ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.never) { + ClaudeOAuthCredentialsStore.invalidateCache() + } + + let first = try KeychainCacheStore.withClearFailureStatusOverrideForTesting( + errSecInteractionNotAllowed) + { + try ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.onlyOnUserAction) { + try ProviderInteractionContext.$current.withValue(.background) { + try ClaudeOAuthCredentialsStore.load(environment: [:], allowKeychainPrompt: false) + } + } + } + #expect(first.accessToken == "file-token-new") + #expect(state.pendingStore.isPending) + let staleToken = try self.cachedToken(state) + #expect(staleToken == "cached-token") + + let second = try ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.onlyOnUserAction) { + try ProviderInteractionContext.$current.withValue(.background) { + try ClaudeOAuthCredentialsStore.load(environment: [:], allowKeychainPrompt: false) + } + } + #expect(second.accessToken == "file-token-new") + #expect(!state.pendingStore.isPending) + #expect(state.recorder.operations == [ + .clear, + .load, + .clear, + .clear, + .load, + .load, + .load, + .store, + ]) + let refreshedToken = try self.cachedToken(state) + #expect(refreshedToken == "file-token-new") + } + } + } + + @Test + func `replacement store failure after successful clear keeps tombstone and cache missing`() throws { + try self.withTestState { state in + try self.withCredentialsFile(data: nil) { _ in + self.seedCache(state, accessToken: "cached-token") + ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.never) { + ClaudeOAuthCredentialsStore.invalidateCache() + } + + let syncData = self.makeCredentialsData( + accessToken: "sync-token", + expiresAt: Date(timeIntervalSinceNow: 3600), + refreshToken: "sync-refresh-token") + let synced = KeychainCacheStore.withStoreFailureStatusOverrideForTesting( + errSecInteractionNotAllowed) + { + ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.onlyOnUserAction) { + ProviderInteractionContext.$current.withValue(.userInitiated) { + ClaudeOAuthCredentialsStore.withClaudeKeychainOverridesForTesting( + data: syncData, + fingerprint: nil) + { + ClaudeOAuthCredentialsStore.syncFromClaudeKeychainWithoutPrompt() + } + } + } + } + + #expect(synced) + #expect(state.pendingStore.isPending) + #expect(state.recorder.operations == [.clear, .store]) + let cachedToken = try self.cachedToken(state) + #expect(cachedToken == nil) + } + } + } + + @Test + func `replacement store failure after failed clear keeps tombstone and stale cache`() throws { + try self.withTestState { state in + try self.withCredentialsFile(data: nil) { _ in + self.seedCache(state, accessToken: "cached-token") + ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.never) { + ClaudeOAuthCredentialsStore.invalidateCache() + } + + let syncData = self.makeCredentialsData( + accessToken: "sync-token", + expiresAt: Date(timeIntervalSinceNow: 3600), + refreshToken: "sync-refresh-token") + let synced = KeychainCacheStore.withClearFailureStatusOverrideForTesting( + errSecInteractionNotAllowed) + { + KeychainCacheStore.withStoreFailureStatusOverrideForTesting(errSecInteractionNotAllowed) { + ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.onlyOnUserAction) { + ProviderInteractionContext.$current.withValue(.userInitiated) { + ClaudeOAuthCredentialsStore.withClaudeKeychainOverridesForTesting( + data: syncData, + fingerprint: nil) + { + ClaudeOAuthCredentialsStore.syncFromClaudeKeychainWithoutPrompt() + } + } + } + } + } + + #expect(synced) + #expect(state.pendingStore.isPending) + #expect(state.recorder.operations == [.clear]) + let cachedToken = try self.cachedToken(state) + #expect(cachedToken == "cached-token") + } + } + } + + @Test + func `bundled CLI resolves the owning app prompt policy domain`() throws { + let tempDirectory = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + let appURL = tempDirectory.appendingPathComponent("CodexBar.app", isDirectory: true) + let contentsURL = appURL.appendingPathComponent("Contents", isDirectory: true) + let helpersURL = contentsURL.appendingPathComponent("Helpers", isDirectory: true) + let macOSURL = contentsURL.appendingPathComponent("MacOS", isDirectory: true) + let binURL = tempDirectory.appendingPathComponent("bin", isDirectory: true) + try FileManager.default.createDirectory(at: helpersURL, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: macOSURL, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: binURL, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: tempDirectory) } + + let info: [String: Any] = [ + "CFBundleExecutable": "CodexBar", + "CFBundleIdentifier": ClaudeOAuthKeychainPromptPreference.debugApplicationDefaultsDomain, + "CFBundlePackageType": "APPL", + ] + let infoData = try PropertyListSerialization.data( + fromPropertyList: info, + format: .xml, + options: 0) + try infoData.write(to: contentsURL.appendingPathComponent("Info.plist")) + try Data().write(to: macOSURL.appendingPathComponent("CodexBar")) + + let helperURL = helpersURL.appendingPathComponent("CodexBarCLI") + try Data().write(to: helperURL) + let symlinkURL = binURL.appendingPathComponent("codexbar") + try FileManager.default.createSymbolicLink(at: symlinkURL, withDestinationURL: helperURL) + + let bundledCLIDomain = ClaudeOAuthKeychainPromptPreference.resolveApplicationDefaultsDomain( + bundleIdentifier: nil, + bundleURL: nil, + executableURL: nil, + invocationURL: symlinkURL) + #expect(bundledCLIDomain == ClaudeOAuthKeychainPromptPreference.debugApplicationDefaultsDomain) + + let debugWidgetDomain = ClaudeOAuthKeychainPromptPreference.resolveApplicationDefaultsDomain( + bundleIdentifier: "com.steipete.codexbar.debug.widget", + bundleURL: nil, + executableURL: nil, + invocationURL: nil) + #expect(debugWidgetDomain == ClaudeOAuthKeychainPromptPreference.debugApplicationDefaultsDomain) + + let standaloneDomain = ClaudeOAuthKeychainPromptPreference.resolveApplicationDefaultsDomain( + bundleIdentifier: nil, + bundleURL: nil, + executableURL: URL(fileURLWithPath: "/usr/local/bin/codexbar"), + invocationURL: nil) + #expect(standaloneDomain == ClaudeOAuthKeychainPromptPreference.releaseApplicationDefaultsDomain) + + let testProcessDomain = ClaudeOAuthKeychainPromptPreference.resolveApplicationDefaultsDomain( + bundleIdentifier: nil, + bundleURL: Bundle.main.bundleURL, + executableURL: Bundle.main.executableURL, + invocationURL: CommandLine.arguments.first.map(URL.init(fileURLWithPath:)), + bundleIdentifierForApp: { _ in nil }) + #expect(testProcessDomain == ClaudeOAuthKeychainPromptPreference.releaseApplicationDefaultsDomain) + } + + @Test + func `shared tombstone propagates across process boundaries`() throws { + let domain = "ClaudeOAuthPendingCacheTests.\(UUID().uuidString)" + let key = "pending" + let tempDirectory = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + let lockURL = tempDirectory.appendingPathComponent("cache.lock") + let userDefaults = try #require(UserDefaults(suiteName: domain)) + defer { + userDefaults.removePersistentDomain(forName: domain) + userDefaults.synchronize() + try? FileManager.default.removeItem(at: tempDirectory) + } + + let store = ClaudeOAuthPendingCacheClearUserDefaultsStore( + domain: domain, + key: key, + lockURL: lockURL) + store.markPending() + + let childRead = try self.runDefaults(["read", domain, key]) + #expect(childRead.status == 0) + #expect(!childRead.output.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + + let childDelete = try self.runDefaults(["delete", domain, key]) + #expect(childDelete.status == 0) + #expect(!store.isPending) + + let childWrite = try self.runDefaults(["write", domain, key, UUID().uuidString]) + #expect(childWrite.status == 0) + #expect(store.isPending) + + store.withCacheTransaction { pending in + pending = false + } + let childReadAfterResolution = try self.runDefaults(["read", domain, key]) + #expect(childReadAfterResolution.status != 0) + } + + @Test + func `newer tombstone survives an older cache transaction`() throws { + let domain = "ClaudeOAuthPendingCacheRaceTests.\(UUID().uuidString)" + let key = "pending" + let tempDirectory = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + let lockURL = tempDirectory.appendingPathComponent("cache.lock") + let userDefaults = try #require(UserDefaults(suiteName: domain)) + defer { + userDefaults.removePersistentDomain(forName: domain) + userDefaults.synchronize() + try? FileManager.default.removeItem(at: tempDirectory) + } + + let store = ClaudeOAuthPendingCacheClearUserDefaultsStore( + domain: domain, + key: key, + lockURL: lockURL) + store.markPending() + + let newerGeneration = UUID().uuidString + var childWriteStatus: Int32? + store.withCacheTransaction { pending in + childWriteStatus = try? self.runDefaults(["write", domain, key, newerGeneration]).status + pending = false + } + userDefaults.synchronize() + + #expect(childWriteStatus == 0) + #expect(userDefaults.string(forKey: key) == newerGeneration) + #expect(store.isPending) + } + + @Test + func `legacy boolean tombstone remains pending until cache resolution`() throws { + let domain = "ClaudeOAuthPendingCacheLegacyTests.\(UUID().uuidString)" + let key = "pending" + let tempDirectory = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + let userDefaults = try #require(UserDefaults(suiteName: domain)) + defer { + userDefaults.removePersistentDomain(forName: domain) + userDefaults.synchronize() + try? FileManager.default.removeItem(at: tempDirectory) + } + userDefaults.set(true, forKey: key) + userDefaults.synchronize() + + let store = ClaudeOAuthPendingCacheClearUserDefaultsStore( + domain: domain, + key: key, + lockURL: tempDirectory.appendingPathComponent("cache.lock")) + #expect(store.isPending) + store.withCacheTransaction { pending in + pending = false + } + #expect(!store.isPending) + } + + @Test + func `cache transaction fails closed when its lock is unavailable`() throws { + let domain = "ClaudeOAuthPendingCacheLockFailureTests.\(UUID().uuidString)" + let key = "pending" + let tempDirectory = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: tempDirectory, withIntermediateDirectories: true) + let nonDirectoryURL = tempDirectory.appendingPathComponent("not-a-directory") + try Data().write(to: nonDirectoryURL) + let userDefaults = try #require(UserDefaults(suiteName: domain)) + defer { + userDefaults.removePersistentDomain(forName: domain) + userDefaults.synchronize() + try? FileManager.default.removeItem(at: tempDirectory) + } + + let store = ClaudeOAuthPendingCacheClearUserDefaultsStore( + domain: domain, + key: key, + lockURL: nonDirectoryURL.appendingPathComponent("cache.lock")) + var operationCalled = false + store.withCacheTransaction { _ in + operationCalled = true + } + userDefaults.synchronize() + + #expect(!operationCalled) + #expect(userDefaults.string(forKey: key) != nil) + #expect(store.isPending) + } + + @Test + func `never mode bypasses oauth cache while preserving experimental security CLI reader`() throws { + try self.withTestState { state in + try self.withCredentialsFile(data: nil) { _ in + self.seedCache(state, accessToken: "cached-token") + let securityData = self.makeCredentialsData( + accessToken: "security-cli-token", + expiresAt: Date(timeIntervalSinceNow: 3600), + refreshToken: "security-cli-refresh-token") + + let credentials = try ClaudeOAuthKeychainReadStrategyPreference.withTaskOverrideForTesting( + .securityCLIExperimental) + { + try ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.never) { + try ClaudeOAuthCredentialsStore.withSecurityCLIReadOverrideForTesting(.data(securityData)) { + try ClaudeOAuthCredentialsStore.withClaudeKeychainOverridesForTesting( + data: securityData, + fingerprint: nil) + { + try ProviderInteractionContext.$current.withValue(.background) { + try ClaudeOAuthCredentialsStore.load( + environment: [:], + allowKeychainPrompt: false) + } + } + } + } + } + + #expect(credentials.accessToken == "security-cli-token") + #expect(state.recorder.operations.isEmpty) + #expect(state.pendingStore.isPending) + let cachedToken = try self.cachedToken(state) + #expect(cachedToken == "cached-token") + + do { + _ = try ClaudeOAuthCredentialsStore.withIsolatedMemoryCacheForTesting { + try ClaudeOAuthKeychainReadStrategyPreference.withTaskOverrideForTesting( + .securityCLIExperimental) + { + try ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.onlyOnUserAction) { + try ClaudeOAuthCredentialsStore.withSecurityCLIReadOverrideForTesting(.data(nil)) { + try ClaudeOAuthCredentialsStore.withClaudeKeychainOverridesForTesting( + data: Data(), + fingerprint: nil) + { + try ProviderInteractionContext.$current.withValue(.background) { + try ClaudeOAuthCredentialsStore.load( + environment: [:], + allowKeychainPrompt: false) + } + } + } + } + } + } + Issue.record("Expected ClaudeOAuthCredentialsError.notFound") + } catch let error as ClaudeOAuthCredentialsError { + guard case .notFound = error else { + Issue.record("Expected .notFound, got \(error)") + return + } + } + + #expect(!state.pendingStore.isPending) + #expect(state.recorder.operations == [.clear, .load, .load]) + let clearedToken = try self.cachedToken(state) + #expect(clearedToken == nil) + + let mcpOnly = Data(#"{"mcpOAuth":{"plugin:test":{"accessToken":"synthetic"}}}"#.utf8) + let isMcpOnly = ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.never) { + ClaudeOAuthCredentialsStore.withSecurityCLIReadOverrideForTesting(.data(mcpOnly)) { + ClaudeOAuthCredentialsStore.isMcpOAuthOnlyClaudeKeychainPayloadPresent( + interaction: .background, + readStrategy: .securityCLIExperimental, + keychainAccessDisabled: true, + environment: [ + KeychainAccessGate.disableAccessEnvironmentKey: "1", + ClaudeOAuthCredentialsStore.isolatedSecurityCLIKeychainEnvironmentKey: + "/tmp/codexbar-test.keychain-db", + ]) + } + } + #expect(!isMcpOnly) + } + } + } +} diff --git a/Tests/CodexBarTests/ClaudeOAuthCredentialsStorePromptPolicyTests.swift b/Tests/CodexBarTests/ClaudeOAuthCredentialsStorePromptPolicyTests.swift index 341bc033ac..fe56b42776 100644 --- a/Tests/CodexBarTests/ClaudeOAuthCredentialsStorePromptPolicyTests.swift +++ b/Tests/CodexBarTests/ClaudeOAuthCredentialsStorePromptPolicyTests.swift @@ -4,6 +4,42 @@ import Testing @Suite(.serialized) struct ClaudeOAuthCredentialsStorePromptPolicyTests { + @Test + func `keychain prompt notify preserves its void function signature`() { + let notify: (KeychainPromptContext) -> Void = KeychainPromptHandler.notify + _ = notify + } + + @Test + func `safety does not inherit the application prompt preference`() throws { + guard ProcessInfo.processInfo.environment[KeychainTestSafety.allowAccessEnvironmentKey] != "1" else { + return + } + + #expect(ClaudeOAuthKeychainPromptPreference.currentTaskOverrideForTesting == nil) + + let domain = "ClaudeOAuthPromptPolicyIsolationTests.\(UUID().uuidString)" + let key = "claudeOAuthKeychainPromptMode" + let defaults = try #require(UserDefaults(suiteName: domain)) + defer { + defaults.removePersistentDomain(forName: domain) + defaults.synchronize() + } + defaults.set(ClaudeOAuthKeychainPromptMode.never.rawValue, forKey: key) + defaults.synchronize() + + ClaudeOAuthKeychainPromptPreference.withImplicitApplicationUserDefaultsOverrideForTesting(defaults) { + // Isolation must ignore a conflicting value in the implicit application defaults domain. + #expect(ClaudeOAuthKeychainPromptPreference.storedMode() == .onlyOnUserAction) + #expect(ClaudeOAuthKeychainPromptPreference.storedMode(userDefaults: defaults) == .never) + + let explicit = ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.always) { + ClaudeOAuthKeychainPromptPreference.storedMode() + } + #expect(explicit == .always) + } + } + private func makeCredentialsData(accessToken: String, expiresAt: Date, refreshToken: String? = nil) -> Data { let millis = Int(expiresAt.timeIntervalSince1970 * 1000) let refreshField: String = { @@ -127,7 +163,7 @@ struct ClaudeOAuthCredentialsStorePromptPolicyTests { } @Test - func `does not show pre alert when claude keychain readable without interaction`() throws { + func `user initiated claude keychain reads respect pre alert acknowledgement cooldown`() throws { let service = "com.steipete.codexbar.cache.tests.\(UUID().uuidString)" try KeychainCacheStore.withServiceOverrideForTesting(service) { try KeychainAccessGate.withTaskOverrideForTesting(false) { @@ -158,7 +194,7 @@ struct ClaudeOAuthCredentialsStorePromptPolicyTests { let promptHandler: (KeychainPromptContext) -> Void = { _ in preAlertHits += 1 } - let creds = try KeychainAccessPreflight.withCheckGenericPasswordOverrideForTesting( + let credentials = try KeychainAccessPreflight.withCheckGenericPasswordOverrideForTesting( preflightOverride, operation: { try KeychainPromptHandler.withHandlerForTesting(promptHandler, operation: { @@ -170,17 +206,23 @@ struct ClaudeOAuthCredentialsStorePromptPolicyTests { data: keychainData, fingerprint: nil) { - try ClaudeOAuthCredentialsStore.load( + let first = try ClaudeOAuthCredentialsStore.load( + environment: [:], + allowKeychainPrompt: true) + ClaudeOAuthCredentialsStore.invalidateCache() + let second = try ClaudeOAuthCredentialsStore.load( environment: [:], allowKeychainPrompt: true) + return (first, second) } } } }) }) - #expect(creds.accessToken == "keychain-token") - #expect(preAlertHits == 0) + #expect(credentials.0.accessToken == "keychain-token") + #expect(credentials.1.accessToken == "keychain-token") + #expect(preAlertHits == 1) } } } @@ -241,9 +283,7 @@ struct ClaudeOAuthCredentialsStorePromptPolicyTests { }) #expect(creds.accessToken == "keychain-token") - // TODO: tighten this to `== 1` once keychain pre-alert delivery is deduplicated/scoped. - // This path can currently emit more than one pre-alert during a single load attempt. - #expect(preAlertHits >= 1) + #expect(preAlertHits == 1) } } } @@ -304,9 +344,7 @@ struct ClaudeOAuthCredentialsStorePromptPolicyTests { }) #expect(creds.accessToken == "keychain-token") - // TODO: tighten this to `== 1` once keychain pre-alert delivery is deduplicated/scoped. - // This path can currently emit more than one pre-alert during a single load attempt. - #expect(preAlertHits >= 1) + #expect(preAlertHits == 1) } } } @@ -434,7 +472,7 @@ struct ClaudeOAuthCredentialsStorePromptPolicyTests { }) #expect(creds.accessToken == "fallback-token") - #expect(preAlertHits >= 1) + #expect(preAlertHits == 1) } } } @@ -725,7 +763,7 @@ struct ClaudeOAuthCredentialsStorePromptPolicyTests { }) #expect(creds.accessToken == "fallback-token") - #expect(preAlertHits >= 1) + #expect(preAlertHits == 1) } } } diff --git a/Tests/CodexBarTests/ClaudeOAuthCredentialsStoreSecurityCLITests.swift b/Tests/CodexBarTests/ClaudeOAuthCredentialsStoreSecurityCLITests.swift index c9a167f64d..4d12fc58ad 100644 --- a/Tests/CodexBarTests/ClaudeOAuthCredentialsStoreSecurityCLITests.swift +++ b/Tests/CodexBarTests/ClaudeOAuthCredentialsStoreSecurityCLITests.swift @@ -35,8 +35,6 @@ struct ClaudeOAuthCredentialsStoreSecurityCLITests { defer { ClaudeOAuthCredentialsStore.invalidateCache() ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting() - ClaudeOAuthCredentialsStore.setClaudeKeychainDataOverrideForTesting(nil) - ClaudeOAuthCredentialsStore.setClaudeKeychainFingerprintOverrideForTesting(nil) } let tempDir = FileManager.default.temporaryDirectory @@ -89,8 +87,6 @@ struct ClaudeOAuthCredentialsStoreSecurityCLITests { defer { ClaudeOAuthCredentialsStore.invalidateCache() ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting() - ClaudeOAuthCredentialsStore.setClaudeKeychainDataOverrideForTesting(nil) - ClaudeOAuthCredentialsStore.setClaudeKeychainFingerprintOverrideForTesting(nil) } let tempDir = FileManager.default.temporaryDirectory @@ -148,8 +144,6 @@ struct ClaudeOAuthCredentialsStoreSecurityCLITests { defer { ClaudeOAuthCredentialsStore.invalidateCache() ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting() - ClaudeOAuthCredentialsStore.setClaudeKeychainDataOverrideForTesting(nil) - ClaudeOAuthCredentialsStore.setClaudeKeychainFingerprintOverrideForTesting(nil) } let tempDir = FileManager.default.temporaryDirectory @@ -205,8 +199,6 @@ struct ClaudeOAuthCredentialsStoreSecurityCLITests { defer { ClaudeOAuthCredentialsStore.invalidateCache() ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting() - ClaudeOAuthCredentialsStore.setClaudeKeychainDataOverrideForTesting(nil) - ClaudeOAuthCredentialsStore.setClaudeKeychainFingerprintOverrideForTesting(nil) } let tempDir = FileManager.default.temporaryDirectory @@ -372,7 +364,7 @@ struct ClaudeOAuthCredentialsStoreSecurityCLITests { } } - #expect(hasCredentials == true) + #expect(hasCredentials == false) } @Test diff --git a/Tests/CodexBarTests/ClaudeOAuthCredentialsStoreTemporaryKeychainCacheTests.swift b/Tests/CodexBarTests/ClaudeOAuthCredentialsStoreTemporaryKeychainCacheTests.swift index b9e0630f55..900f0e77d0 100644 --- a/Tests/CodexBarTests/ClaudeOAuthCredentialsStoreTemporaryKeychainCacheTests.swift +++ b/Tests/CodexBarTests/ClaudeOAuthCredentialsStoreTemporaryKeychainCacheTests.swift @@ -26,11 +26,23 @@ struct ClaudeOAuthCredentialsStoreTemporaryKeychainCacheTests { return Data(json.utf8) } + private func withDeterministicCacheService( + _ service: String, + operation: () throws -> T) rethrows -> T + { + let pendingStore = ClaudeOAuthCredentialsStore.PendingCacheClearMemoryStore() + return try ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.onlyOnUserAction) { + try ClaudeOAuthCredentialsStore.withPendingCacheClearStoreOverrideForTesting(pendingStore) { + try KeychainCacheStore.withServiceOverrideForTesting(service, operation: operation) + } + } + } + #if os(macOS) @Test func `credentials file invalidation preserves keychain cache when temporarily unavailable`() throws { let service = "com.steipete.codexbar.cache.tests.\(UUID().uuidString)" - try KeychainCacheStore.withServiceOverrideForTesting(service) { + try self.withDeterministicCacheService(service) { KeychainCacheStore.setTestStoreForTesting(true) defer { KeychainCacheStore.setTestStoreForTesting(false) } @@ -102,7 +114,7 @@ struct ClaudeOAuthCredentialsStoreTemporaryKeychainCacheTests { @Test func `temporary keychain cache unavailability does not overwrite cache from credentials file fallback`() throws { let service = "com.steipete.codexbar.cache.tests.\(UUID().uuidString)" - try KeychainCacheStore.withServiceOverrideForTesting(service) { + try self.withDeterministicCacheService(service) { try KeychainAccessGate.withTaskOverrideForTesting(true) { KeychainCacheStore.setTestStoreForTesting(true) defer { KeychainCacheStore.setTestStoreForTesting(false) } @@ -161,7 +173,7 @@ struct ClaudeOAuthCredentialsStoreTemporaryKeychainCacheTests { @Test func `has cached credentials treats temporary keychain cache unavailability as present`() { let service = "com.steipete.codexbar.cache.tests.\(UUID().uuidString)" - KeychainCacheStore.withServiceOverrideForTesting(service) { + self.withDeterministicCacheService(service) { KeychainCacheStore.setTestStoreForTesting(true) defer { KeychainCacheStore.setTestStoreForTesting(false) } @@ -191,7 +203,7 @@ struct ClaudeOAuthCredentialsStoreTemporaryKeychainCacheTests { @Test func `invalid keychain cache is cleared by load`() throws { let service = "com.steipete.codexbar.cache.tests.\(UUID().uuidString)" - try KeychainCacheStore.withServiceOverrideForTesting(service) { + try self.withDeterministicCacheService(service) { try KeychainAccessGate.withTaskOverrideForTesting(true) { KeychainCacheStore.setTestStoreForTesting(true) defer { KeychainCacheStore.setTestStoreForTesting(false) } diff --git a/Tests/CodexBarTests/ClaudeOAuthCredentialsStoreTests.swift b/Tests/CodexBarTests/ClaudeOAuthCredentialsStoreTests.swift index ff4fddba84..663dfe62da 100644 --- a/Tests/CodexBarTests/ClaudeOAuthCredentialsStoreTests.swift +++ b/Tests/CodexBarTests/ClaudeOAuthCredentialsStoreTests.swift @@ -3,6 +3,7 @@ import Testing @testable import CodexBarCore @Suite(.serialized) +// swiftlint:disable:next type_body_length struct ClaudeOAuthCredentialsStoreTests { private func makeCredentialsData(accessToken: String, expiresAt: Date, refreshToken: String? = nil) -> Data { let millis = Int(expiresAt.timeIntervalSince1970 * 1000) @@ -22,6 +23,116 @@ struct ClaudeOAuthCredentialsStoreTests { return Data(json.utf8) } + @Test + func `persistent reference hash stays stable across keychain metadata refresh`() { + let first = ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprint( + modifiedAt: 1, + createdAt: 1, + persistentRefHash: "opaque-ref") + let refreshed = ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprint( + modifiedAt: 2, + createdAt: 1, + persistentRefHash: "opaque-ref") + + let firstHash = ClaudeOAuthCredentialsStore.withClaudeKeychainOverridesForTesting( + data: nil, + fingerprint: first) + { + ClaudeOAuthCredentialsStore.claudeKeychainPersistentRefHashWithoutPrompt() + } + let refreshedHash = ClaudeOAuthCredentialsStore.withClaudeKeychainOverridesForTesting( + data: nil, + fingerprint: refreshed) + { + ClaudeOAuthCredentialsStore.claudeKeychainPersistentRefHashWithoutPrompt() + } + + #expect(firstHash == "opaque-ref") + #expect(refreshedHash == firstHash) + } + + @Test + func `safety isolates the default Claude credentials file`() { + guard ProcessInfo.processInfo.environment[KeychainTestSafety.allowAccessEnvironmentKey] != "1" else { + return + } + + let defaultURL = FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent(".claude/.credentials.json") + #expect(ClaudeOAuthCredentialsStore.resolvedCredentialsURLForTesting != defaultURL) + + let overrideURL = FileManager.default.temporaryDirectory + .appendingPathComponent("explicit-credentials.json") + let resolvedOverride = ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(overrideURL) { + ClaudeOAuthCredentialsStore.resolvedCredentialsURLForTesting + } + #expect(resolvedOverride == overrideURL) + } + + @Test + func `safety isolates pending cache clear from the application suite`() throws { + guard ProcessInfo.processInfo.environment[KeychainTestSafety.allowAccessEnvironmentKey] != "1" else { + return + } + + let domain = "ClaudeOAuthPendingCacheIsolationTests.\(UUID().uuidString)" + let key = "ClaudeOAuthPendingCodexBarOAuthKeychainCacheClearV1" + let tempDirectory = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + let defaults = try #require(UserDefaults(suiteName: domain)) + defer { + defaults.removePersistentDomain(forName: domain) + defaults.synchronize() + try? FileManager.default.removeItem(at: tempDirectory) + ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting() + } + + let sentinel = "isolation-sentinel-\(UUID().uuidString)" + defaults.set(sentinel, forKey: key) + defaults.synchronize() + let implicitStore = ClaudeOAuthPendingCacheClearUserDefaultsStore( + domain: domain, + key: key, + lockURL: tempDirectory.appendingPathComponent("cache.lock")) + + // never-mode cache invalidation marks pending clear without an explicit store override. + ClaudeOAuthCredentialsStore.withImplicitPendingCacheClearStoreOverrideForTesting(implicitStore) { + ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.never) { + ClaudeOAuthCredentialsStore.invalidateCache() + } + } + + defaults.synchronize() + #expect(defaults.string(forKey: key) == sentinel) + } + + @Test + func `safety isolates pending cache clear across isolation scopes`() { + guard ProcessInfo.processInfo.environment[KeychainTestSafety.allowAccessEnvironmentKey] != "1" else { + return + } + + ClaudeOAuthCredentialsStore.withIsolatedMemoryCacheForTesting { + ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.never) { + ClaudeOAuthCredentialsStore.invalidateCache() + } + #expect(ClaudeOAuthCredentialsStore.hasPendingCodexBarOAuthKeychainCacheClearForTesting) + } + + // A fresh isolation scope must not inherit pending state from a prior scope. + ClaudeOAuthCredentialsStore.withIsolatedMemoryCacheForTesting { + #expect(!ClaudeOAuthCredentialsStore.hasPendingCodexBarOAuthKeychainCacheClearForTesting) + } + + // Unscoped never-mode marks must also leave subsequent isolation scopes clean. + ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.never) { + ClaudeOAuthCredentialsStore.invalidateCache() + } + ClaudeOAuthCredentialsStore.withIsolatedMemoryCacheForTesting { + #expect(!ClaudeOAuthCredentialsStore.hasPendingCodexBarOAuthKeychainCacheClearForTesting) + } + } + @Test func `loads from keychain cache before expired file`() throws { let service = "com.steipete.codexbar.cache.tests.\(UUID().uuidString)" @@ -246,7 +357,9 @@ struct ClaudeOAuthCredentialsStoreTests { await ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(fileURL) { await ClaudeOAuthCredentialsStore.withKeychainAccessOverrideForTesting(true) { ClaudeOAuthCredentialsStore.invalidateCache() - let cacheKey = KeychainCacheStore.Key.oauth(provider: .claude) + let cacheKey = ClaudeOAuthCredentialsStore.cacheKeyForTesting( + profileIdentifier: ClaudeOAuthCredentialsStore.credentialsProfileIdentifier( + environment: [:])) defer { KeychainCacheStore.clear(key: cacheKey) } let expiredData = self.makeCredentialsData( @@ -296,7 +409,9 @@ struct ClaudeOAuthCredentialsStoreTests { await ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(fileURL) { await ClaudeOAuthCredentialsStore.withKeychainAccessOverrideForTesting(true) { ClaudeOAuthCredentialsStore.invalidateCache() - let cacheKey = KeychainCacheStore.Key.oauth(provider: .claude) + let cacheKey = ClaudeOAuthCredentialsStore.cacheKeyForTesting( + profileIdentifier: ClaudeOAuthCredentialsStore.credentialsProfileIdentifier( + environment: [:])) defer { KeychainCacheStore.clear(key: cacheKey) } let expiredData = self.makeCredentialsData( @@ -477,14 +592,14 @@ struct ClaudeOAuthCredentialsStoreTests { defer { ClaudeOAuthCredentialsStore.invalidateCache() ClaudeOAuthCredentialsStore._resetClaudeKeychainChangeTrackingForTesting() - ClaudeOAuthCredentialsStore.setClaudeKeychainDataOverrideForTesting(nil) - ClaudeOAuthCredentialsStore.setClaudeKeychainFingerprintOverrideForTesting(nil) } // Avoid cross-suite interference from UserDefaults fingerprint persistence. let fingerprintStore = ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprintStore() - let cacheKey = KeychainCacheStore.Key.oauth(provider: .claude) + let cacheKey = ClaudeOAuthCredentialsStore.cacheKeyForTesting( + profileIdentifier: ClaudeOAuthCredentialsStore.credentialsProfileIdentifier( + environment: [:])) let cachedData = self.makeCredentialsData( accessToken: "cached-token", expiresAt: Date(timeIntervalSinceNow: 3600)) @@ -571,8 +686,6 @@ struct ClaudeOAuthCredentialsStoreTests { ClaudeOAuthCredentialsStore.invalidateCache() ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting() ClaudeOAuthCredentialsStore._resetClaudeKeychainChangeTrackingForTesting() - ClaudeOAuthCredentialsStore.setClaudeKeychainDataOverrideForTesting(nil) - ClaudeOAuthCredentialsStore.setClaudeKeychainFingerprintOverrideForTesting(nil) } let tempDir = FileManager.default.temporaryDirectory @@ -590,7 +703,9 @@ struct ClaudeOAuthCredentialsStoreTests { persistentRefHash: "ref1") fingerprintStore.fingerprint = fingerprint1 - let cacheKey = KeychainCacheStore.Key.oauth(provider: .claude) + let cacheKey = ClaudeOAuthCredentialsStore.cacheKeyForTesting( + profileIdentifier: ClaudeOAuthCredentialsStore.credentialsProfileIdentifier( + environment: [:])) let cachedData = self.makeCredentialsData( accessToken: "cached-token", expiresAt: Date(timeIntervalSinceNow: 3600)) @@ -655,7 +770,9 @@ struct ClaudeOAuthCredentialsStoreTests { ClaudeOAuthCredentialsStore._resetClaudeKeychainChangeTrackingForTesting() } - let cacheKey = KeychainCacheStore.Key.oauth(provider: .claude) + let cacheKey = ClaudeOAuthCredentialsStore.cacheKeyForTesting( + profileIdentifier: ClaudeOAuthCredentialsStore.credentialsProfileIdentifier( + environment: [:])) let cachedData = self.makeCredentialsData( accessToken: "cached-token", expiresAt: Date(timeIntervalSinceNow: 3600)) @@ -712,7 +829,9 @@ struct ClaudeOAuthCredentialsStoreTests { ClaudeOAuthCredentialsStore._resetClaudeKeychainChangeTrackingForTesting() } - let cacheKey = KeychainCacheStore.Key.oauth(provider: .claude) + let cacheKey = ClaudeOAuthCredentialsStore.cacheKeyForTesting( + profileIdentifier: ClaudeOAuthCredentialsStore.credentialsProfileIdentifier( + environment: [:])) let cachedData = self.makeCredentialsData( accessToken: "cached-token", expiresAt: Date(timeIntervalSinceNow: 3600)) @@ -782,7 +901,9 @@ struct ClaudeOAuthCredentialsStoreTests { ClaudeOAuthCredentialsStore._resetClaudeKeychainChangeTrackingForTesting() } - let cacheKey = KeychainCacheStore.Key.oauth(provider: .claude) + let cacheKey = ClaudeOAuthCredentialsStore.cacheKeyForTesting( + profileIdentifier: ClaudeOAuthCredentialsStore.credentialsProfileIdentifier( + environment: [:])) let cachedData = self.makeCredentialsData( accessToken: "cached-token", expiresAt: Date(timeIntervalSinceNow: 3600)) @@ -887,3 +1008,208 @@ struct ClaudeOAuthCredentialsStoreTests { #expect(forwarded == fingerprint) } } + +#if os(macOS) +extension ClaudeOAuthCredentialsStoreTests { + private func withMissingCredentialsFile(operation: () throws -> T) throws -> T { + let tempDirectory = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: tempDirectory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: tempDirectory) } + + // Deliberately leave this URL empty: this is the missing-credentials-file bug trigger. + let fileURL = tempDirectory.appendingPathComponent("credentials.json") + return try ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(fileURL) { + try operation() + } + } + + private func withIsolatedOAuthCache(operation: () throws -> T) throws -> T { + let service = "com.steipete.codexbar.cache.tests.\(UUID().uuidString)" + return try KeychainCacheStore.withServiceOverrideForTesting(service) { + KeychainCacheStore.setTestStoreForTesting(true) + defer { KeychainCacheStore.setTestStoreForTesting(false) } + return try KeychainAccessGate.withTaskOverrideForTesting(false) { + try ClaudeOAuthCredentialsStore.withKeychainAccessOverrideForTesting(false) { + try ClaudeOAuthCredentialsStore.withIsolatedMemoryCacheForTesting { + try ClaudeOAuthCredentialsStore.withIsolatedCredentialsFileTrackingForTesting { + try operation() + } + } + } + } + } + } + + @Test + func `never mode repairs a missing credentials file from a valid no-UI Keychain read`() throws { + try self.withIsolatedOAuthCache { + try self.withMissingCredentialsFile { + let keychainData = self.makeCredentialsData( + accessToken: "test-token-placeholder", + expiresAt: Date(timeIntervalSinceNow: 3600)) + + let record = try ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.never) { + try ProviderInteractionContext.$current.withValue(.background) { + try ClaudeOAuthCredentialsStore.withClaudeKeychainOverridesForTesting( + data: keychainData, + fingerprint: nil) + { + try ClaudeOAuthCredentialsStore.loadRecord( + environment: [:], + allowKeychainPrompt: false, + respectKeychainPromptCooldown: false, + allowClaudeKeychainRepairWithoutPrompt: true) + } + } + } + + #expect(record.credentials.accessToken == "test-token-placeholder") + #expect(record.source == .claudeKeychain) + #expect(record.owner == .claudeCLI) + } + } + } + + @Test + func `never mode skips the experimental security CLI before no-UI Keychain repair`() throws { + try self.withIsolatedOAuthCache { + try self.withMissingCredentialsFile { + let noUIData = self.makeCredentialsData( + accessToken: "test-token-placeholder", + expiresAt: Date(timeIntervalSinceNow: 3600)) + let securityCLIData = self.makeCredentialsData( + accessToken: "decoy-token", + expiresAt: Date(timeIntervalSinceNow: 3600)) + final class ReadCounter: @unchecked Sendable { + var count = 0 + } + let securityCLIReads = ReadCounter() + + let record = try ClaudeOAuthKeychainReadStrategyPreference.withTaskOverrideForTesting( + .securityCLIExperimental) + { + try ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.never) { + try ProviderInteractionContext.$current.withValue(.background) { + try ClaudeOAuthCredentialsStore.withSecurityCLIReadOverrideForTesting( + .dynamic { _ in + securityCLIReads.count += 1 + return securityCLIData + }) { + try ClaudeOAuthCredentialsStore.withClaudeKeychainOverridesForTesting( + data: noUIData, + fingerprint: nil) + { + try ClaudeOAuthCredentialsStore.loadRecord( + environment: [:], + allowKeychainPrompt: false, + respectKeychainPromptCooldown: false, + allowClaudeKeychainRepairWithoutPrompt: true) + } + } + } + } + } + + #expect(record.credentials.accessToken == "test-token-placeholder") + #expect(record.source == .claudeKeychain) + #expect(securityCLIReads.count < 1) + } + } + } + + @Test + func `never mode still blocks an interactive Keychain read even with a valid item present`() throws { + try self.withIsolatedOAuthCache { + try self.withMissingCredentialsFile { + let keychainData = self.makeCredentialsData( + accessToken: "test-token-placeholder", + expiresAt: Date(timeIntervalSinceNow: 3600)) + + let error = #expect(throws: ClaudeOAuthCredentialsError.self) { + try ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.never) { + try ProviderInteractionContext.$current.withValue(.background) { + try ClaudeOAuthCredentialsStore.withClaudeKeychainOverridesForTesting( + data: keychainData, + fingerprint: nil) + { + try ClaudeOAuthCredentialsStore.load(environment: [:], allowKeychainPrompt: true) + } + } + } + } + guard case .notFound = error else { + Issue.record("Expected .notFound, got \(String(describing: error))") + return + } + } + } + } + + @Test + func `never mode without any Keychain item still fails closed`() throws { + try self.withIsolatedOAuthCache { + try self.withMissingCredentialsFile { + // A registered empty override prevents any fallback to real SecItem probes. + let emptyKeychain = ClaudeOAuthCredentialsStore.ClaudeKeychainOverrideStore( + data: nil, + fingerprint: nil) + let error = #expect(throws: ClaudeOAuthCredentialsError.self) { + try ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.never) { + try ProviderInteractionContext.$current.withValue(.background) { + try ClaudeOAuthCredentialsStore + .withMutableClaudeKeychainOverrideStoreForTesting(emptyKeychain) { + try ClaudeOAuthCredentialsStore.loadRecord( + environment: [:], + allowKeychainPrompt: false, + respectKeychainPromptCooldown: false, + allowClaudeKeychainRepairWithoutPrompt: true) + } + } + } + } + guard case .notFound = error else { + Issue.record("Expected .notFound, got \(String(describing: error))") + return + } + } + } + } + + @Test + func `global Keychain disable blocks no-UI repair in never mode`() throws { + try self.withIsolatedOAuthCache { + try self.withMissingCredentialsFile { + let keychainData = self.makeCredentialsData( + accessToken: "test-token-placeholder", + expiresAt: Date(timeIntervalSinceNow: 3600)) + + let error = #expect(throws: ClaudeOAuthCredentialsError.self) { + try KeychainAccessGate.withTaskOverrideForTesting(true) { + try ClaudeOAuthCredentialsStore.withKeychainAccessOverrideForTesting(true) { + try ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.never) { + try ProviderInteractionContext.$current.withValue(.background) { + try ClaudeOAuthCredentialsStore.withClaudeKeychainOverridesForTesting( + data: keychainData, + fingerprint: nil) + { + try ClaudeOAuthCredentialsStore.loadRecord( + environment: [:], + allowKeychainPrompt: false, + respectKeychainPromptCooldown: false, + allowClaudeKeychainRepairWithoutPrompt: true) + } + } + } + } + } + } + guard case .notFound = error else { + Issue.record("Expected .notFound, got \(String(describing: error))") + return + } + } + } + } +} +#endif diff --git a/Tests/CodexBarTests/ClaudeOAuthDelegatedRefreshCoordinatorTests.swift b/Tests/CodexBarTests/ClaudeOAuthDelegatedRefreshCoordinatorTests.swift index d2d139a9a9..c0c0fee393 100644 --- a/Tests/CodexBarTests/ClaudeOAuthDelegatedRefreshCoordinatorTests.swift +++ b/Tests/CodexBarTests/ClaudeOAuthDelegatedRefreshCoordinatorTests.swift @@ -2,6 +2,23 @@ import Foundation import Testing @testable import CodexBarCore +private final class ClaudeDelegatedTouchCounter: @unchecked Sendable { + private let lock = NSLock() + private var value = 0 + + func increment() { + self.lock.lock() + self.value += 1 + self.lock.unlock() + } + + func count() -> Int { + self.lock.lock() + defer { self.lock.unlock() } + return self.value + } +} + @Suite(.serialized) struct ClaudeOAuthDelegatedRefreshCoordinatorTests { private enum StubError: Error, LocalizedError { @@ -32,20 +49,41 @@ struct ClaudeOAuthDelegatedRefreshCoordinatorTests { private func withCoordinatorOverrides( isolateState: Bool = true, cliAvailable: Bool? = nil, + promptMode: ClaudeOAuthKeychainPromptMode = .always, + keychainAccessDisabled: Bool = false, touchAuthPath: (@Sendable (TimeInterval, [String: String]) async throws -> Void)? = nil, keychainFingerprint: (@Sendable () -> ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprint?)? = nil, operation: () async throws -> T) async rethrows -> T { - if isolateState { - return try await ClaudeOAuthDelegatedRefreshCoordinator.withIsolatedStateForTesting { + try await KeychainAccessGate.withTaskOverrideForTesting(keychainAccessDisabled) { + try await ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(promptMode) { + if isolateState { + return try await ClaudeOAuthDelegatedRefreshCoordinator.withIsolatedStateForTesting { + ClaudeOAuthDelegatedRefreshCoordinator.resetForTesting() + defer { ClaudeOAuthDelegatedRefreshCoordinator.resetForTesting() } + return try await ClaudeOAuthDelegatedRefreshCoordinator + .withKeychainFingerprintOverrideForTesting( + keychainFingerprint) + { + try await ClaudeOAuthDelegatedRefreshCoordinator.withCLIAvailableOverrideForTesting( + cliAvailable) + { + try await ClaudeOAuthDelegatedRefreshCoordinator + .withTouchAuthPathOverrideForTesting( + touchAuthPath) + { + try await operation() + } + } + } + } + } ClaudeOAuthDelegatedRefreshCoordinator.resetForTesting() defer { ClaudeOAuthDelegatedRefreshCoordinator.resetForTesting() } return try await ClaudeOAuthDelegatedRefreshCoordinator.withKeychainFingerprintOverrideForTesting( keychainFingerprint) { - try await ClaudeOAuthDelegatedRefreshCoordinator.withCLIAvailableOverrideForTesting( - cliAvailable) - { + try await ClaudeOAuthDelegatedRefreshCoordinator.withCLIAvailableOverrideForTesting(cliAvailable) { try await ClaudeOAuthDelegatedRefreshCoordinator.withTouchAuthPathOverrideForTesting( touchAuthPath) { @@ -55,19 +93,6 @@ struct ClaudeOAuthDelegatedRefreshCoordinatorTests { } } } - ClaudeOAuthDelegatedRefreshCoordinator.resetForTesting() - defer { ClaudeOAuthDelegatedRefreshCoordinator.resetForTesting() } - return try await ClaudeOAuthDelegatedRefreshCoordinator.withKeychainFingerprintOverrideForTesting( - keychainFingerprint) - { - try await ClaudeOAuthDelegatedRefreshCoordinator.withCLIAvailableOverrideForTesting(cliAvailable) { - try await ClaudeOAuthDelegatedRefreshCoordinator.withTouchAuthPathOverrideForTesting( - touchAuthPath) - { - try await operation() - } - } - } } @Test @@ -124,6 +149,81 @@ struct ClaudeOAuthDelegatedRefreshCoordinatorTests { #expect(outcome == .cliUnavailable) } + @Test(arguments: [ + (ClaudeOAuthKeychainPromptMode.onlyOnUserAction, false), + (ClaudeOAuthKeychainPromptMode.never, false), + (ClaudeOAuthKeychainPromptMode.always, true), + ]) + func `background refresh never launches delegated Claude CLI without Keychain opt in`( + promptMode: ClaudeOAuthKeychainPromptMode, + keychainAccessDisabled: Bool) async + { + let touches = ClaudeDelegatedTouchCounter() + let outcome = await self.withCoordinatorOverrides( + cliAvailable: true, + promptMode: promptMode, + keychainAccessDisabled: keychainAccessDisabled, + touchAuthPath: { _, _ in touches.increment() }, + operation: { + await ProviderInteractionContext.$current.withValue(.background) { + await ClaudeOAuthDelegatedRefreshCoordinator.attempt( + now: Date(timeIntervalSince1970: 20001), + timeout: 0.1) + } + }) + + #expect(outcome == .skippedByPromptPolicy) + #expect(touches.count() == 0) + } + + @Test + func `opaque delegated CLI honors stored prompt mode when read strategy effective mode differs`() async { + let touches = ClaudeDelegatedTouchCounter() + let backgroundOutcome = await ClaudeOAuthKeychainReadStrategyPreference.withTaskOverrideForTesting( + .securityCLIExperimental) + { + #expect(ClaudeOAuthKeychainPromptPreference.effectiveMode() == .always) + return await self.withCoordinatorOverrides( + cliAvailable: true, + promptMode: .onlyOnUserAction, + touchAuthPath: { _, _ in touches.increment() }, + operation: { + await ProviderInteractionContext.$current.withValue(.background) { + await ClaudeOAuthDelegatedRefreshCoordinator.attempt( + now: Date(timeIntervalSince1970: 20002), + timeout: 0.1) + } + }) + } + + #expect(backgroundOutcome == .skippedByPromptPolicy) + #expect(touches.count() == 0) + + let userOutcome = await ClaudeOAuthKeychainReadStrategyPreference.withTaskOverrideForTesting( + .securityCLIExperimental) + { + await self.withCoordinatorOverrides( + cliAvailable: true, + promptMode: .onlyOnUserAction, + touchAuthPath: { _, _ in touches.increment() }, + operation: { + await ClaudeOAuthCredentialsStore.withSecurityCLIReadOverrideForTesting(.data(Data("stub".utf8))) { + await ProviderInteractionContext.$current.withValue(.userInitiated) { + await ClaudeOAuthDelegatedRefreshCoordinator.attempt( + now: Date(timeIntervalSince1970: 20003), + timeout: 0.1) + } + } + }) + } + + guard case .attemptedFailed = userOutcome else { + Issue.record("Expected explicit user refresh to launch the delegated CLI") + return + } + #expect(touches.count() == 1) + } + @Test func `successful auth touch reports attempted succeeded`() async { ClaudeOAuthDelegatedRefreshCoordinator.resetForTesting() @@ -334,6 +434,136 @@ struct ClaudeOAuthDelegatedRefreshCoordinatorTests { #expect(counter.count == 1) } + @Test + func `user action retries after joining failed background attempt`() async throws { + ClaudeOAuthDelegatedRefreshCoordinator.resetForTesting() + defer { ClaudeOAuthDelegatedRefreshCoordinator.resetForTesting() } + + actor Gate { + private var releaseContinuation: CheckedContinuation? + private var startedContinuation: CheckedContinuation? + private var joinedContinuation: CheckedContinuation? + private var hasStarted = false + private var isReleased = false + private var hasJoined = false + + func markStarted() { + self.hasStarted = true + self.startedContinuation?.resume() + self.startedContinuation = nil + } + + func waitStarted() async { + if self.hasStarted { return } + await withCheckedContinuation { self.startedContinuation = $0 } + } + + func release() { + self.isReleased = true + self.releaseContinuation?.resume() + self.releaseContinuation = nil + } + + func waitRelease() async { + if self.isReleased { return } + await withCheckedContinuation { self.releaseContinuation = $0 } + } + + func markJoined() { + self.hasJoined = true + self.joinedContinuation?.resume() + self.joinedContinuation = nil + } + + func waitJoined() async { + if self.hasJoined { return } + await withCheckedContinuation { self.joinedContinuation = $0 } + } + } + + final class StateBox: @unchecked Sendable { + private let lock = NSLock() + private var touchCount = 0 + private var fingerprint = ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprint( + modifiedAt: 1, + createdAt: 1, + persistentRefHash: "before") + + func beginTouch() -> Int { + self.lock.lock() + defer { self.lock.unlock() } + self.touchCount += 1 + return self.touchCount + } + + func markChanged() { + self.lock.lock() + self.fingerprint = ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprint( + modifiedAt: 2, + createdAt: 2, + persistentRefHash: "after") + self.lock.unlock() + } + + func snapshot() -> (Int, ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprint) { + self.lock.lock() + defer { self.lock.unlock() } + return (self.touchCount, self.fingerprint) + } + } + + let gate = Gate() + let state = StateBox() + let outcomes = try await KeychainAccessGate.withTaskOverrideForTesting(false) { + try await ClaudeOAuthKeychainReadStrategyPreference.withTaskOverrideForTesting(.securityFramework) { + try await self.withCoordinatorOverrides( + isolateState: false, + cliAvailable: true, + touchAuthPath: { _, _ in + if state.beginTouch() == 1 { + await gate.markStarted() + await gate.waitRelease() + throw StubError.failed + } + state.markChanged() + }, + keychainFingerprint: { state.snapshot().1 }, + operation: { + await ClaudeOAuthDelegatedRefreshCoordinator + .withUserInitiatedBackgroundJoinObserverForTesting { + Task { await gate.markJoined() } + } operation: { + let background = Task { + await ProviderInteractionContext.$current.withValue(.background) { + await ClaudeOAuthDelegatedRefreshCoordinator.attempt( + now: Date(timeIntervalSince1970: 51000), + timeout: 2) + } + } + await gate.waitStarted() + let userInitiated = Task { + await ProviderInteractionContext.$current.withValue(.userInitiated) { + await ClaudeOAuthDelegatedRefreshCoordinator.attempt( + now: Date(timeIntervalSince1970: 51001), + timeout: 2) + } + } + await gate.waitJoined() + await gate.release() + return await (background.value, userInitiated.value) + } + }) + } + } + + guard case .attemptedFailed = outcomes.0 else { + Issue.record("Expected the background attempt to fail") + return + } + #expect(outcomes.1 == .attemptedSucceeded) + #expect(state.snapshot().0 == 2) + } + @Test func `experimental strategy does not use security framework fingerprint observation`() async { ClaudeOAuthDelegatedRefreshCoordinator.resetForTesting() @@ -440,12 +670,15 @@ struct ClaudeOAuthDelegatedRefreshCoordinatorTests { }, operation: { await ClaudeOAuthCredentialsStore.withSecurityCLIReadOverrideForTesting( - .dynamic { _ in dataBox.load() }) - { - await ClaudeOAuthDelegatedRefreshCoordinator.attempt( - now: Date(timeIntervalSince1970: 61000), - timeout: 0.1) - } + .dynamic { _ in + #expect( + ClaudeOAuthKeychainPromptPreference.currentTaskOverrideForTesting == .always) + return dataBox.load() + }) { + await ClaudeOAuthDelegatedRefreshCoordinator.attempt( + now: Date(timeIntervalSince1970: 61000), + timeout: 0.1) + } }) #expect(outcome == .attemptedSucceeded) @@ -563,11 +796,78 @@ struct ClaudeOAuthDelegatedRefreshCoordinatorTests { } }) - guard case .attemptedFailed = outcome else { - Issue.record("Expected .attemptedFailed outcome") - return - } + #expect(outcome == .skippedByPromptPolicy) #expect(securityReadCounter.count < 1) } } + + @Test + func `experimental strategy blocks background mcp O auth but lets user action retry`() async { + ClaudeOAuthDelegatedRefreshCoordinator.resetForTesting() + defer { ClaudeOAuthDelegatedRefreshCoordinator.resetForTesting() } + await KeychainAccessGate.withTaskOverrideForTesting(false) { + await ClaudeOAuthKeychainReadStrategyPreference.withTaskOverrideForTesting( + .securityCLIExperimental) + { + final class StateBox: @unchecked Sendable { + private let lock = NSLock() + private var touchCount = 0 + + func touch() { + self.lock.lock() + self.touchCount += 1 + self.lock.unlock() + } + + func count() -> Int { + self.lock.lock() + defer { self.lock.unlock() } + return self.touchCount + } + } + + let state = StateBox() + let mcpOAuthOnly = Data(""" + { + "mcpOAuth": { + "plugin:slack:slack": { "accessToken": "" } + } + } + """.utf8) + let refreshedCredentials = self.makeCredentialsData( + accessToken: "refreshed-after-user-action", + expiresAt: Date(timeIntervalSinceNow: 3600)) + let outcomes = await self.withCoordinatorOverrides( + cliAvailable: true, + touchAuthPath: { _, _ in state.touch() }, + operation: { + await ClaudeOAuthCredentialsStore.withSecurityCLIReadOverrideForTesting(.dynamic { _ in + state.count() > 0 ? refreshedCredentials : mcpOAuthOnly + }) { + let background = await ProviderInteractionContext.$current.withValue(.background) { + await ClaudeOAuthDelegatedRefreshCoordinator.attempt( + now: Date(timeIntervalSince1970: 63000), + timeout: 0.1) + } + let backgroundTouchCount = state.count() + let userInitiated = await ProviderInteractionContext.$current.withValue(.userInitiated) { + await ClaudeOAuthDelegatedRefreshCoordinator.attempt( + now: Date(timeIntervalSince1970: 63001), + timeout: 0.1) + } + return (background, backgroundTouchCount, userInitiated) + } + }) + + guard case let .attemptedFailed(message) = outcomes.0 else { + Issue.record("Expected background .attemptedFailed outcome") + return + } + #expect(message.contains("MCP OAuth")) + #expect(outcomes.1 == 0) + #expect(outcomes.2 == .attemptedSucceeded) + #expect(state.count() == 1) + } + } + } } diff --git a/Tests/CodexBarTests/ClaudeOAuthDelegatedRefreshEpochTests.swift b/Tests/CodexBarTests/ClaudeOAuthDelegatedRefreshEpochTests.swift new file mode 100644 index 0000000000..ad72109830 --- /dev/null +++ b/Tests/CodexBarTests/ClaudeOAuthDelegatedRefreshEpochTests.swift @@ -0,0 +1,85 @@ +import Foundation +import Testing +@testable import CodexBarCore + +@Suite(.serialized) +struct ClaudeOAuthDelegatedRefreshEpochTests { + private actor LoadState { + private var requestIDs: [UUID?] = [] + + func nextCall(requestID: UUID?) -> Int { + self.requestIDs.append(requestID) + return self.requestIDs.count + } + + func recordedRequestIDs() -> [UUID?] { + self.requestIDs + } + } + + @Test + func `post delegated credential reload starts a new prompt coalescing epoch`() async throws { + let state = LoadState() + let initialRequestID = UUID() + let usageResponse = try ClaudeOAuthUsageFetcher._decodeUsageResponseForTesting(Data(""" + { + "five_hour": { "utilization": 7, "resets_at": "2025-12-23T16:00:00.000Z" }, + "seven_day": { "utilization": 21, "resets_at": "2025-12-29T23:00:00.000Z" } + } + """.utf8)) + let fetcher = ClaudeUsageFetcher( + browserDetection: BrowserDetection(cacheTTL: 0), + environment: [:], + dataSource: .oauth, + oauthKeychainPromptCooldownEnabled: true) + + let loadOverride: (@Sendable ( + [String: String], + Bool, + Bool) async throws -> ClaudeOAuthCredentials)? = { _, _, _ in + let call = await state.nextCall(requestID: ProviderRefreshRequestContext.id) + guard call > 1 else { + throw ClaudeOAuthCredentialsError.refreshDelegatedToClaudeCLI + } + return ClaudeOAuthCredentials( + accessToken: "fresh-token", + refreshToken: "refresh-token", + expiresAt: Date(timeIntervalSinceNow: 3600), + scopes: ["user:profile"], + rateLimitTier: nil) + } + let delegatedOverride: (@Sendable ( + Date, + TimeInterval, + [String: String]) async -> ClaudeOAuthDelegatedRefreshCoordinator.Outcome)? = { _, _, _ in + .attemptedFailed("no-change") + } + let fetchOverride: (@Sendable (String, Bool) async throws -> OAuthUsageResponse)? = { _, _ in + usageResponse + } + + _ = try await ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.onlyOnUserAction) { + try await ProviderInteractionContext.$current.withValue(.userInitiated) { + try await ProviderRefreshRequestContext.$id.withValue(initialRequestID) { + try await ClaudeUsageFetcher.$fetchOAuthUsageOverride.withValue(fetchOverride, operation: { + try await ClaudeUsageFetcher.$delegatedRefreshAttemptOverride.withValue( + delegatedOverride, + operation: { + try await ClaudeUsageFetcher.$loadOAuthCredentialsOverride.withValue( + loadOverride, + operation: { + try await fetcher.loadLatestUsage(model: "sonnet") + }) + }) + }) + } + } + } + + let requestIDs = await state.recordedRequestIDs() + #expect(requestIDs.count == 2) + #expect(requestIDs[0] == initialRequestID) + #expect(requestIDs[1] != nil) + #expect(requestIDs[1] != initialRequestID) + } +} diff --git a/Tests/CodexBarTests/ClaudeOAuthDelegatedRefreshRecoveryTests.swift b/Tests/CodexBarTests/ClaudeOAuthDelegatedRefreshRecoveryTests.swift index e6827b531c..4ffd91580d 100644 --- a/Tests/CodexBarTests/ClaudeOAuthDelegatedRefreshRecoveryTests.swift +++ b/Tests/CodexBarTests/ClaudeOAuthDelegatedRefreshRecoveryTests.swift @@ -115,7 +115,9 @@ struct ClaudeOAuthDelegatedRefreshRecoveryTests { dataSource: .oauth, oauthKeychainPromptCooldownEnabled: true) - let fetchOverride: (@Sendable (String) async throws -> OAuthUsageResponse)? = { token in + let fetchOverride: @Sendable ( + String, + Bool) async throws -> OAuthUsageResponse = { token, _ in await tokenCapture.set(token) return usageResponse } @@ -224,7 +226,9 @@ struct ClaudeOAuthDelegatedRefreshRecoveryTests { dataSource: .oauth, oauthKeychainPromptCooldownEnabled: true) - let fetchOverride: (@Sendable (String) async throws -> OAuthUsageResponse)? = { token in + let fetchOverride: @Sendable ( + String, + Bool) async throws -> OAuthUsageResponse = { token, _ in await tokenCapture.set(token) return usageResponse } diff --git a/Tests/CodexBarTests/ClaudeOAuthFetchStrategyAvailabilityTests.swift b/Tests/CodexBarTests/ClaudeOAuthFetchStrategyAvailabilityTests.swift index 82f4fb58cb..d7b8819a7a 100644 --- a/Tests/CodexBarTests/ClaudeOAuthFetchStrategyAvailabilityTests.swift +++ b/Tests/CodexBarTests/ClaudeOAuthFetchStrategyAvailabilityTests.swift @@ -21,17 +21,21 @@ struct ClaudeOAuthFetchStrategyAvailabilityTests { private func makeContext( sourceMode: ProviderSourceMode, - env: [String: String] = [:]) -> ProviderFetchContext + env: [String: String] = [:], + settings: ProviderSettingsSnapshot? = nil, + includeOptionalUsage: Bool = true, + webTimeout: TimeInterval = 1) -> ProviderFetchContext { ProviderFetchContext( runtime: .app, sourceMode: sourceMode, includeCredits: false, - webTimeout: 1, + includeOptionalUsage: includeOptionalUsage, + webTimeout: webTimeout, webDebugDumpHTML: false, verbose: false, env: env, - settings: nil, + settings: settings, fetcher: UsageFetcher(environment: env), claudeFetcher: StubClaudeFetcher(), browserDetection: BrowserDetection(cacheTTL: 0)) @@ -50,18 +54,410 @@ struct ClaudeOAuthFetchStrategyAvailabilityTests { } @Test - func `auto mode expired creds cli available returns available`() async { + func `O auth strategy enriches usage with web balance when optional usage is enabled`() async throws { + let settings = ProviderSettingsSnapshot.make(claude: .init( + usageDataSource: .auto, + webExtrasEnabled: false, + cookieSource: .manual, + manualCookieHeader: "sessionKey=sk-ant-session-token")) + let context = self.makeContext(sourceMode: .auto, settings: settings) + let credentials = ClaudeOAuthCredentials( + accessToken: "oauth-token", + refreshToken: nil, + expiresAt: Date(timeIntervalSinceNow: 3600), + scopes: ["user:profile"], + rateLimitTier: "claude_pro") + let usageResponse = try ClaudeOAuthUsageFetcher._decodeUsageResponseForTesting(Data(""" + { + "five_hour": { "utilization": 7 }, + "extra_usage": { + "is_enabled": true, + "monthly_limit": 2000, + "used_credits": 500, + "currency": "USD" + } + } + """.utf8)) + let transport = ProviderHTTPTransportHandler { request in + let url = try #require(request.url) + let body: String + let statusCode: Int + switch url.path { + case "/api/organizations": + body = #"[{"uuid":"org-123","name":"Test Org","capabilities":["chat"]}]"# + statusCode = 200 + case "/api/organizations/org-123/usage": + body = #"{"five_hour":{"utilization":44}}"# + statusCode = 200 + case "/api/organizations/org-123/prepaid/credits": + body = #"{"amount":10000,"currency":"USD"}"# + statusCode = 200 + case "/api/account": + body = """ + { + "email_address": "user@example.com", + "memberships": [{"organization": {"uuid": "org-123"}}] + } + """ + statusCode = 200 + default: + body = "{}" + statusCode = 404 + } + let response = try #require(HTTPURLResponse( + url: url, + statusCode: statusCode, + httpVersion: "HTTP/1.1", + headerFields: ["Content-Type": "application/json"])) + return (Data(body.utf8), response) + } + let loadCredentials: @Sendable ( + [String: String], + Bool, + Bool) async throws -> ClaudeOAuthCredentials = { _, _, _ in credentials } + let fetchUsage: @Sendable (String, Bool) async throws -> OAuthUsageResponse = { _, _ in usageResponse } + let fetchProfile: @Sendable (String) async throws -> OAuthProfileResponse = { _ in + OAuthProfileResponse(emailAddress: "user@example.com", organizationUuid: "org-123") + } + + let result = try await ClaudeWebHTTPTransport.$overrideForTesting.withValue(transport) { + try await ClaudeUsageFetcher.$loadOAuthCredentialsOverride.withValue(loadCredentials) { + try await ClaudeUsageFetcher.$fetchOAuthUsageOverride.withValue(fetchUsage) { + try await ClaudeUsageFetcher.$fetchOAuthProfileOverride.withValue(fetchProfile) { + try await ClaudeOAuthFetchStrategy().fetch(context) + } + } + } + } + + #expect(result.usage.primary?.usedPercent == 7) + #expect(result.usage.providerCost?.used == 5) + #expect(result.usage.providerCost?.limit == 20) + #expect(result.usage.providerCost?.balance == 100) + } + + @Test + func `auto without cached web session publishes O auth usage without browser discovery`() async throws { + try await self.withIsolatedCookieCache { + let settings = ProviderSettingsSnapshot.make(claude: .init( + usageDataSource: .auto, + webExtrasEnabled: false, + cookieSource: .auto, + manualCookieHeader: nil)) + let context = self.makeContext(sourceMode: .auto, settings: settings) + let credentials = ClaudeOAuthCredentials( + accessToken: "oauth-token", + refreshToken: nil, + expiresAt: Date(timeIntervalSinceNow: 3600), + scopes: ["user:profile"], + rateLimitTier: "claude_pro") + let usageResponse = try ClaudeOAuthUsageFetcher._decodeUsageResponseForTesting(Data(""" + {"five_hour":{"utilization":7}} + """.utf8)) + let importedSession = ClaudeWebAPIFetcher.SessionKeyInfo( + key: "sk-ant-browser-session", + sourceLabel: "Browser", + cookieCount: 1) + let transport = ProviderHTTPTransportHandler { request in + let url = request.url?.absoluteString ?? "nil" + Issue.record("Unexpected Claude browser-cookie discovery request: \(url)") + throw URLError(.badServerResponse) + } + let loadCredentials: @Sendable ( + [String: String], + Bool, + Bool) async throws -> ClaudeOAuthCredentials = { _, _, _ in credentials } + let fetchUsage: @Sendable (String, Bool) async throws -> OAuthUsageResponse = { _, _ in usageResponse } + let fetchProfile: @Sendable (String) async throws -> OAuthProfileResponse = { _ in + OAuthProfileResponse(emailAddress: "user@example.com", organizationUuid: "org-123") + } + + let result = try await ClaudeWebSessionKeyImport.$overrideForTesting.withValue(importedSession) { + try await ClaudeWebHTTPTransport.$overrideForTesting.withValue(transport) { + try await ClaudeUsageFetcher.$loadOAuthCredentialsOverride.withValue(loadCredentials) { + try await ClaudeUsageFetcher.$fetchOAuthUsageOverride.withValue(fetchUsage) { + try await ClaudeUsageFetcher.$fetchOAuthProfileOverride.withValue(fetchProfile) { + try await ClaudeOAuthFetchStrategy().fetch(context) + } + } + } + } + } + + #expect(result.usage.primary?.usedPercent == 7) + #expect(result.usage.providerCost == nil) + } + } + + @Test + func `blank manual cookie does not fall back to browser enrichment`() async throws { + let settings = ProviderSettingsSnapshot.make(claude: .init( + usageDataSource: .oauth, + webExtrasEnabled: true, + cookieSource: .manual, + manualCookieHeader: " ")) + let context = self.makeContext(sourceMode: .oauth, settings: settings) + let credentials = ClaudeOAuthCredentials( + accessToken: "oauth-token", + refreshToken: nil, + expiresAt: Date(timeIntervalSinceNow: 3600), + scopes: ["user:profile"], + rateLimitTier: "claude_pro") + let usageResponse = try ClaudeOAuthUsageFetcher._decodeUsageResponseForTesting(Data(""" + { + "five_hour": { "utilization": 7 } + } + """.utf8)) + let transport = ProviderHTTPTransportHandler { request in + Issue.record("Unexpected Claude web request: \(request.url?.absoluteString ?? "nil")") + throw URLError(.badServerResponse) + } + let loadCredentials: @Sendable ( + [String: String], + Bool, + Bool) async throws -> ClaudeOAuthCredentials = { _, _, _ in credentials } + let fetchUsage: @Sendable (String, Bool) async throws -> OAuthUsageResponse = { _, _ in usageResponse } + + let result = try await ClaudeWebHTTPTransport.$overrideForTesting.withValue(transport) { + try await ClaudeUsageFetcher.$loadOAuthCredentialsOverride.withValue(loadCredentials) { + try await ClaudeUsageFetcher.$fetchOAuthUsageOverride.withValue(fetchUsage) { + try await ClaudeOAuthFetchStrategy().fetch(context) + } + } + } + + #expect(result.usage.primary?.usedPercent == 7) + #expect(result.usage.providerCost == nil) + } + + @Test + func `O auth web enrichment timeout preserves completed usage`() async throws { + let settings = ProviderSettingsSnapshot.make(claude: .init( + usageDataSource: .oauth, + webExtrasEnabled: false, + cookieSource: .manual, + manualCookieHeader: "sessionKey=sk-ant-session-token")) + let context = self.makeContext( + sourceMode: .oauth, + settings: settings, + webTimeout: 0.02) + let credentials = ClaudeOAuthCredentials( + accessToken: "oauth-token", + refreshToken: nil, + expiresAt: Date(timeIntervalSinceNow: 3600), + scopes: ["user:profile"], + rateLimitTier: "claude_pro") + let usageResponse = try ClaudeOAuthUsageFetcher._decodeUsageResponseForTesting(Data(""" + {"five_hour":{"utilization":7}} + """.utf8)) + let transport = ProviderHTTPTransportHandler { request in + let url = try #require(request.url) + if url.path == "/api/organizations" { + try await Task.sleep(for: .seconds(10)) + throw CancellationError() + } + throw URLError(.badServerResponse) + } + let loadCredentials: @Sendable ( + [String: String], + Bool, + Bool) async throws -> ClaudeOAuthCredentials = { _, _, _ in credentials } + let fetchUsage: @Sendable (String, Bool) async throws -> OAuthUsageResponse = { _, _ in usageResponse } + let fetchProfile: @Sendable (String) async throws -> OAuthProfileResponse = { _ in + OAuthProfileResponse(emailAddress: "user@example.com", organizationUuid: "org-123") + } + + let result = try await ClaudeWebHTTPTransport.$overrideForTesting.withValue(transport) { + try await ClaudeUsageFetcher.$loadOAuthCredentialsOverride.withValue(loadCredentials) { + try await ClaudeUsageFetcher.$fetchOAuthUsageOverride.withValue(fetchUsage) { + try await ClaudeUsageFetcher.$fetchOAuthProfileOverride.withValue(fetchProfile) { + try await ClaudeOAuthFetchStrategy().fetch(context) + } + } + } + } + + #expect(result.usage.primary?.usedPercent == 7) + #expect(result.usage.providerCost?.balance == nil) + } + + @Test + func `auto CLI fallback enriches usage with matching web balance`() async throws { + let cliPath = try Self.makeExecutableClaudeStub() + defer { try? FileManager.default.removeItem(atPath: cliPath) } + let settings = ProviderSettingsSnapshot.make(claude: .init( + usageDataSource: .auto, + webExtrasEnabled: false, + cookieSource: .manual, + manualCookieHeader: "sessionKey=sk-ant-session-token")) + let context = self.makeContext( + sourceMode: .auto, + env: ["CLAUDE_CLI_PATH": cliPath], + settings: settings) + let unavailableOAuthRecord = ClaudeOAuthCredentialRecord( + credentials: ClaudeOAuthCredentials( + accessToken: "oauth-token-without-profile-scope", + refreshToken: nil, + expiresAt: Date(timeIntervalSinceNow: 3600), + scopes: ["user:inference"], + rateLimitTier: nil), + owner: .claudeCLI, + source: .environment) + let transport = ProviderHTTPTransportHandler { request in + let url = try #require(request.url) + let body: String + let statusCode: Int + switch url.path { + case "/api/organizations": + body = #"[{"uuid":"org-123","name":"Test Org","capabilities":["chat"]}]"# + statusCode = 200 + case "/api/organizations/org-123/usage": + body = #"{"five_hour":{"utilization":44}}"# + statusCode = 200 + case "/api/organizations/org-123/prepaid/credits": + body = #"{"amount":10000,"currency":"USD"}"# + statusCode = 200 + case "/api/account": + body = """ + { + "email_address": "user@example.com", + "memberships": [{"organization": {"uuid": "org-123"}}] + } + """ + statusCode = 200 + default: + body = "{}" + statusCode = 404 + } + let response = try #require(HTTPURLResponse( + url: url, + statusCode: statusCode, + httpVersion: "HTTP/1.1", + headerFields: ["Content-Type": "application/json"])) + return (Data(body.utf8), response) + } + let cliFetch: @Sendable (String, TimeInterval, Bool) async throws -> ClaudeStatusSnapshot = { _, _, _ in + ClaudeStatusSnapshot( + sessionPercentLeft: 93, + weeklyPercentLeft: nil, + opusPercentLeft: nil, + accountEmail: "user@example.com", + accountOrganization: "Test Org", + loginMethod: "Pro", + primaryResetDescription: nil, + secondaryResetDescription: nil, + opusResetDescription: nil, + rawText: "stub") + } + + func fetchResult(context: ProviderFetchContext) async throws -> ProviderFetchResult { + let outcome = await ClaudeWebHTTPTransport.$overrideForTesting.withValue(transport) { + await ClaudeOAuthFetchStrategy.$nonInteractiveCredentialRecordOverride.withValue( + unavailableOAuthRecord) + { + await ClaudeOAuthFetchStrategy.$claudeCLIAvailableOverride.withValue(true) { + await ClaudeOAuthKeychainAccessGate.withShouldAllowPromptOverrideForTesting(false) { + await ClaudeStatusProbe.$fetchOverride.withValue(cliFetch) { + await ProviderInteractionContext.$current.withValue(.userInitiated) { + await ClaudeProviderDescriptor.makeDescriptor().fetchPlan.fetchOutcome( + context: context, + provider: .claude) + } + } + } + } + } + } + return try outcome.result.get() + } + + let result = try await fetchResult(context: context) + + #expect(result.strategyID == "claude.cli") + #expect(result.usage.primary?.usedPercent == 7) + #expect(result.usage.providerCost?.balance == 100) + + let hiddenResult = try await fetchResult(context: self.makeContext( + sourceMode: .auto, + env: ["CLAUDE_CLI_PATH": cliPath], + settings: settings, + includeOptionalUsage: false)) + #expect(hiddenResult.strategyID == "claude.cli") + #expect(hiddenResult.usage.primary?.usedPercent == 7) + #expect(hiddenResult.usage.providerCost == nil) + } + + @Test + func `auto mode expired CLI creds remain available after Keychain opt in`() async { let context = self.makeContext(sourceMode: .auto) let strategy = ClaudeOAuthFetchStrategy() - let available = await ClaudeOAuthFetchStrategy.$nonInteractiveCredentialRecordOverride - .withValue(self.expiredRecord()) { - await ClaudeOAuthFetchStrategy.$claudeCLIAvailableOverride.withValue(true) { - await strategy.isAvailable(context) + let available = await KeychainAccessGate.withTaskOverrideForTesting(false) { + await self.withAvailabilityKeychainDoubles { + await ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.always) { + await ClaudeOAuthFetchStrategy.$nonInteractiveCredentialRecordOverride + .withValue(self.expiredRecord()) { + await ClaudeOAuthFetchStrategy.$claudeCLIAvailableOverride.withValue(true) { + await strategy.isAvailable(context) + } + } } } + } #expect(available == true) } + @Test + func `auto mode expired CLI creds with MCP-only keychain returns unavailable in background`() async { + let available = await self.expiredCLIAvailability( + sourceMode: .auto, + interaction: .background, + keychainData: self.mcpOAuthOnlyKeychainPayload) + + #expect(!available) + } + + @Test + func `auto mode expired CLI creds with MCP-only keychain remains available for user action`() async { + let available = await self.expiredCLIAvailability( + sourceMode: .auto, + interaction: .userInitiated, + keychainData: self.mcpOAuthOnlyKeychainPayload) + + #expect(available) + } + + @Test + func `explicit O auth keeps expired CLI credentials available with MCP-only keychain`() async { + let available = await self.expiredCLIAvailability( + sourceMode: .oauth, + interaction: .background, + keychainData: self.mcpOAuthOnlyKeychainPayload) + + #expect(available) + } + + @Test + func `stored user action policy blocks expired CLI credentials with experimental reader`() async { + let available = await self.expiredCLIAvailability( + sourceMode: .auto, + interaction: .background, + keychainData: self.ordinaryOAuthKeychainPayload, + readStrategy: .securityCLIExperimental) + + #expect(!available) + } + + @Test + func `auto mode disables expired Claude CLI credentials when keychain access is disabled`() async { + let available = await self.expiredCLIAvailability( + sourceMode: .auto, + interaction: .background, + keychainData: self.mcpOAuthOnlyKeychainPayload, + keychainAccessDisabled: true) + + #expect(!available) + } + @Test func `auto mode expired creds cli unavailable returns unavailable`() async { let context = self.makeContext(sourceMode: .auto) @@ -144,7 +540,9 @@ struct ClaudeOAuthFetchStrategyAvailabilityTests { _ = await ClaudeOAuthFetchStrategy.$nonInteractiveCredentialRecordOverride .withValue(recordWithoutRequiredScope) { await ProviderInteractionContext.$current.withValue(.userInitiated) { - await strategy.isAvailable(context) + await self.withAvailabilityKeychainDoubles { + await strategy.isAvailable(context) + } } } @@ -153,7 +551,7 @@ struct ClaudeOAuthFetchStrategyAvailabilityTests { } @Test - func `auto mode only on user action background startup without cache is available for bootstrap`() async throws { + func `auto mode only on user action background startup without cache is unavailable`() async throws { let context = self.makeContext(sourceMode: .auto) let strategy = ClaudeOAuthFetchStrategy() let service = "com.steipete.codexbar.cache.tests.\(UUID().uuidString)" @@ -181,9 +579,11 @@ struct ClaudeOAuthFetchStrategyAvailabilityTests { await ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(fileURL) { await ClaudeOAuthKeychainReadStrategyPreference.withTaskOverrideForTesting(.securityFramework) { await ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.onlyOnUserAction) { - await ProviderRefreshContext.$current.withValue(.startup) { - await ProviderInteractionContext.$current.withValue(.background) { - await strategy.isAvailable(context) + await self.withAvailabilityKeychainDoubles { + await ProviderRefreshContext.$current.withValue(.startup) { + await ProviderInteractionContext.$current.withValue(.background) { + await strategy.isAvailable(context) + } } } } @@ -191,7 +591,7 @@ struct ClaudeOAuthFetchStrategyAvailabilityTests { } } - #expect(available == true) + #expect(available == false) } } } @@ -209,16 +609,22 @@ struct ClaudeOAuthFetchStrategyAvailabilityTests { sourceMode: .auto, env: ["CLAUDE_CLI_PATH": cliURL.path]) let strategy = ClaudeOAuthFetchStrategy() - let available = await ClaudeOAuthFetchStrategy.$nonInteractiveCredentialRecordOverride - .withValue(self.expiredRecord()) { - await strategy.isAvailable(context) + let available = await KeychainAccessGate.withTaskOverrideForTesting(false) { + await self.withAvailabilityKeychainDoubles { + await ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.always) { + await ClaudeOAuthFetchStrategy.$nonInteractiveCredentialRecordOverride + .withValue(self.expiredRecord()) { + await strategy.isAvailable(context) + } + } } + } #expect(available == true) } @Test - func `auto mode default reader keeps background startup bootstrap available`() async throws { + func `auto mode default reader does not bypass background startup prompt policy`() async throws { let context = self.makeContext(sourceMode: .auto) let strategy = ClaudeOAuthFetchStrategy() let service = "com.steipete.codexbar.cache.tests.\(UUID().uuidString)" @@ -246,9 +652,11 @@ struct ClaudeOAuthFetchStrategyAvailabilityTests { await ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(fileURL) { await ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.onlyOnUserAction) { await ClaudeOAuthCredentialsStore.withSecurityCLIReadOverrideForTesting(.nonZeroExit) { - await ProviderRefreshContext.$current.withValue(.startup) { - await ProviderInteractionContext.$current.withValue(.background) { - await strategy.isAvailable(context) + await self.withAvailabilityKeychainDoubles { + await ProviderRefreshContext.$current.withValue(.startup) { + await ProviderInteractionContext.$current.withValue(.background) { + await strategy.isAvailable(context) + } } } } @@ -256,7 +664,7 @@ struct ClaudeOAuthFetchStrategyAvailabilityTests { } } - #expect(available == true) + #expect(available == false) } } } @@ -365,5 +773,78 @@ struct ClaudeOAuthFetchStrategyAvailabilityTests { #expect(available == false) } + + private func withAvailabilityKeychainDoubles( + operation: () async throws -> T) async rethrows -> T + { + KeychainCacheStore.setTestStoreForTesting(true) + defer { KeychainCacheStore.setTestStoreForTesting(false) } + return try await ClaudeOAuthCredentialsStore.withKeychainAccessOverrideForTesting( + true, + operation: operation) + } + + private var mcpOAuthOnlyKeychainPayload: Data { + Data(#"{"mcpOAuth":{"plugin:test":{"accessToken":"fixture"}}}"#.utf8) + } + + private var ordinaryOAuthKeychainPayload: Data { + Data(#"{"claudeAiOauth":{"accessToken":"fixture"}}"#.utf8) + } + + private static func makeExecutableClaudeStub() throws -> String { + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("claude-extra-credit-\(UUID().uuidString)") + try Data("#!/bin/sh\nexit 0\n".utf8).write(to: url) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: url.path) + return url.path + } + + private func withIsolatedCookieCache(_ operation: () async throws -> T) async rethrows -> T { + let legacyBase = FileManager.default.temporaryDirectory + .appendingPathComponent("claude-oauth-enrichment-\(UUID().uuidString)", isDirectory: true) + let service = "claude-oauth-enrichment-\(UUID().uuidString)" + return try await KeychainCacheStore.withServiceOverrideForTesting(service) { + try await CookieHeaderCache.withLegacyBaseURLOverrideForTesting(legacyBase) { + KeychainCacheStore.setTestStoreForTesting(true) + defer { KeychainCacheStore.setTestStoreForTesting(false) } + CookieHeaderCache.resetDisplayCacheForTesting() + defer { CookieHeaderCache.resetDisplayCacheForTesting() } + return try await operation() + } + } + } + + private func expiredCLIAvailability( + sourceMode: ProviderSourceMode, + interaction: ProviderInteraction, + keychainData: Data, + keychainAccessDisabled: Bool = false, + promptMode: ClaudeOAuthKeychainPromptMode = .onlyOnUserAction, + readStrategy: ClaudeOAuthKeychainReadStrategy = .securityFramework) async -> Bool + { + let context = self.makeContext(sourceMode: sourceMode) + let strategy = ClaudeOAuthFetchStrategy() + return await ClaudeOAuthFetchStrategy.$nonInteractiveCredentialRecordOverride + .withValue(self.expiredRecord()) { + await ClaudeOAuthFetchStrategy.$claudeCLIAvailableOverride.withValue(true) { + await KeychainAccessGate.withTaskOverrideForTesting(keychainAccessDisabled) { + await ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(promptMode) { + await ClaudeOAuthKeychainReadStrategyPreference + .withTaskOverrideForTesting(readStrategy) { + await ClaudeOAuthCredentialsStore.withClaudeKeychainOverridesForTesting( + data: keychainData, + fingerprint: nil) + { + await ProviderInteractionContext.$current.withValue(interaction) { + await strategy.isAvailable(context) + } + } + } + } + } + } + } + } } #endif diff --git a/Tests/CodexBarTests/ClaudeOAuthHistoryCredentialRoutingTests.swift b/Tests/CodexBarTests/ClaudeOAuthHistoryCredentialRoutingTests.swift new file mode 100644 index 0000000000..15afcf5f66 --- /dev/null +++ b/Tests/CodexBarTests/ClaudeOAuthHistoryCredentialRoutingTests.swift @@ -0,0 +1,169 @@ +import Foundation +import Testing +@testable import CodexBarCore + +@Suite(.serialized) +struct ClaudeOAuthHistoryCredentialRoutingTests { + @Test + func `history keychain reference only matches the credential that won routing`() throws { + let keychainData = self.makeCredentialsData(accessToken: "keychain-token") + let keychainCredentials = try ClaudeOAuthCredentials.parse(data: keychainData) + let differentCredentials = try ClaudeOAuthCredentials.parse( + data: self.makeCredentialsData(accessToken: "different-token")) + let fingerprint = ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprint( + modifiedAt: 1, + createdAt: 1, + persistentRefHash: "opaque-ref") + + let matchingCLIRecord = ClaudeOAuthCredentialRecord( + credentials: keychainCredentials, + owner: .claudeCLI, + source: .memoryCache) + let differentCLIRecord = ClaudeOAuthCredentialRecord( + credentials: differentCredentials, + owner: .claudeCLI, + source: .credentialsFile) + let matchingEnvironmentRecord = ClaudeOAuthCredentialRecord( + credentials: keychainCredentials, + owner: .environment, + source: .environment) + let matchingCodexBarRecord = ClaudeOAuthCredentialRecord( + credentials: keychainCredentials, + owner: .codexbar, + source: .cacheKeychain) + + ProviderInteractionContext.$current.withValue(.userInitiated) { + ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.always) { + ClaudeOAuthCredentialsStore.withClaudeKeychainOverridesForTesting( + data: keychainData, + fingerprint: fingerprint) + { + #expect(ClaudeOAuthCredentialsStore + .matchingClaudeKeychainPersistentRefHashWithoutPrompt(for: matchingCLIRecord) == "opaque-ref") + #expect(ClaudeOAuthCredentialsStore + .matchingClaudeKeychainPersistentRefHashWithoutPrompt(for: differentCLIRecord) == nil) + #expect(ClaudeOAuthCredentialsStore + .matchingClaudeKeychainPersistentRefHashWithoutPrompt(for: matchingEnvironmentRecord) == nil) + #expect(ClaudeOAuthCredentialsStore + .matchingClaudeKeychainPersistentRefHashWithoutPrompt(for: matchingCodexBarRecord) == nil) + #expect(ClaudeOAuthCredentialsStore + .claudeKeychainCredentialMatchWithoutPrompt(for: matchingCLIRecord) == + .matched(persistentRefHash: "opaque-ref")) + #expect(ClaudeOAuthCredentialsStore + .claudeKeychainCredentialMatchWithoutPrompt(for: differentCLIRecord) == .mismatch) + #expect(ClaudeOAuthCredentialsStore + .claudeKeychainCredentialMatchWithoutPrompt(for: matchingEnvironmentRecord) == .notApplicable) + } + } + } + + ClaudeOAuthCredentialsStore.withClaudeKeychainOverridesForTesting( + data: nil, + fingerprint: fingerprint) + { + let unavailable = ClaudeOAuthCredentialsStore + .claudeKeychainCredentialMatchWithoutPrompt(for: matchingCLIRecord) + #expect(unavailable == .unavailable) + #expect(unavailable.isUnavailable) + #expect(!unavailable.isMismatch) + } + + let absentStore = ClaudeOAuthCredentialsStore.ClaudeKeychainOverrideStore() + ClaudeOAuthCredentialsStore.withMutableClaudeKeychainOverrideStoreForTesting(absentStore) { + #expect(ClaudeOAuthCredentialsStore + .claudeKeychainCredentialMatchWithoutPrompt(for: matchingCLIRecord) == .absent) + } + } + + @Test + func `newest duplicate reference cannot label a different winning credential`() throws { + let winningCredentials = try ClaudeOAuthCredentials.parse( + data: self.makeCredentialsData(accessToken: "winning-token")) + let newestCandidateCredentials = try ClaudeOAuthCredentials.parse( + data: self.makeCredentialsData(accessToken: "newest-candidate-token")) + let winningRecord = ClaudeOAuthCredentialRecord( + credentials: winningCredentials, + owner: .claudeCLI, + source: .memoryCache) + let newestCandidateRecord = ClaudeOAuthCredentialRecord( + credentials: newestCandidateCredentials, + owner: .claudeCLI, + source: .claudeKeychain) + + #expect(ClaudeOAuthCredentialsStore._matchingClaudeKeychainPersistentRefHashForTesting( + record: winningRecord, + candidateCredentials: newestCandidateCredentials, + persistentRefHash: "newest-candidate-ref") == nil) + #expect(ClaudeOAuthCredentialsStore._matchingClaudeKeychainPersistentRefHashForTesting( + record: newestCandidateRecord, + candidateCredentials: newestCandidateCredentials, + persistentRefHash: "newest-candidate-ref") == "newest-candidate-ref") + } + + @Test + func `history owner follows refresh credential across access token rotation`() throws { + let beforeRefresh = try ClaudeOAuthCredentials.parse( + data: self.makeCredentialsData(accessToken: "access-before", refreshToken: "stable-refresh")) + let afterRefresh = try ClaudeOAuthCredentials.parse( + data: self.makeCredentialsData(accessToken: "access-after", refreshToken: "stable-refresh")) + + let beforeIdentifier = try #require(beforeRefresh.historyOwnerIdentifier) + let afterIdentifier = try #require(afterRefresh.historyOwnerIdentifier) + #expect(beforeIdentifier == afterIdentifier) + #expect(beforeIdentifier.count == 64) + #expect(!beforeIdentifier.contains("stable-refresh")) + } + + @Test + func `access-only credential replacement rotates history owner`() throws { + let original = try ClaudeOAuthCredentials.parse( + data: self.makeCredentialsData(accessToken: "original-access")) + let replacement = try ClaudeOAuthCredentials.parse( + data: self.makeCredentialsData(accessToken: "replacement-access")) + + let originalIdentifier = try #require(original.historyOwnerIdentifier) + let replacementIdentifier = try #require(replacement.historyOwnerIdentifier) + #expect(originalIdentifier != replacementIdentifier) + #expect(!originalIdentifier.contains("original-access")) + #expect(!replacementIdentifier.contains("replacement-access")) + } + + @Test + func `only an explicit refresh lineage can preserve a rotated credential owner`() throws { + let original = try ClaudeOAuthCredentials.parse( + data: self.makeCredentialsData(accessToken: "access-before", refreshToken: "refresh-before")) + let rotated = try ClaudeOAuthCredentials.parse( + data: self.makeCredentialsData(accessToken: "access-after", refreshToken: "refresh-after")) + let originalIdentifier = try #require(original.historyOwnerIdentifier) + let rotatedIdentifier = try #require(rotated.historyOwnerIdentifier) + #expect(originalIdentifier != rotatedIdentifier) + + let refreshProvenRecord = ClaudeOAuthCredentialRecord( + credentials: rotated, + owner: .codexbar, + source: .memoryCache, + historyOwnerIdentifier: originalIdentifier) + let unrelatedReplacementRecord = ClaudeOAuthCredentialRecord( + credentials: rotated, + owner: .codexbar, + source: .cacheKeychain) + + #expect(refreshProvenRecord.historyOwnerIdentifier == originalIdentifier) + #expect(unrelatedReplacementRecord.historyOwnerIdentifier == rotatedIdentifier) + } + + private func makeCredentialsData(accessToken: String, refreshToken: String? = nil) -> Data { + let expiresAt = Int(Date(timeIntervalSinceNow: 3600).timeIntervalSince1970 * 1000) + let refreshTokenJSON = refreshToken.map { "\n \"refreshToken\": \"\($0)\"," } ?? "" + return Data(""" + { + "claudeAiOauth": { + "accessToken": "\(accessToken)", + \(refreshTokenJSON) + "expiresAt": \(expiresAt), + "scopes": ["user:profile"] + } + } + """.utf8) + } +} diff --git a/Tests/CodexBarTests/ClaudeOAuthKeychainAccessGateTests.swift b/Tests/CodexBarTests/ClaudeOAuthKeychainAccessGateTests.swift index 03e098e2c5..357f93d428 100644 --- a/Tests/CodexBarTests/ClaudeOAuthKeychainAccessGateTests.swift +++ b/Tests/CodexBarTests/ClaudeOAuthKeychainAccessGateTests.swift @@ -4,6 +4,21 @@ import Testing @Suite(.serialized) struct ClaudeOAuthKeychainAccessGateTests { + @Test + func `completed prompt attempt advances generation for queued callers`() { + KeychainAccessGate.withTaskOverrideForTesting(false) { + ClaudeOAuthKeychainAccessGate.resetForTesting() + defer { ClaudeOAuthKeychainAccessGate.resetForTesting() } + + let generation = ClaudeOAuthKeychainAccessGate.promptAttemptGeneration() + + _ = ClaudeOAuthKeychainAccessGate.recordPromptAttemptCompleted() + + #expect(ClaudeOAuthKeychainAccessGate.promptAttemptGeneration() == generation + 1) + #expect(ClaudeOAuthKeychainAccessGate.shouldAllowPrompt()) + } + } + @Test func `blocks until cooldown expires`() { KeychainAccessGate.withTaskOverrideForTesting(false) { @@ -58,6 +73,18 @@ struct ClaudeOAuthKeychainAccessGateTests { #expect(KeychainAccessGate.isDisabled) } + @Test + func `process force disable survives settings override`() { + KeychainAccessGate.resetOverrideForTesting() + defer { KeychainAccessGate.resetOverrideForTesting() } + + KeychainAccessGate.forceDisabledForProcess(reason: "unbundled-executable") + KeychainAccessGate.isDisabled = false + + #expect(KeychainAccessGate.isDisabled) + #expect(KeychainAccessGate.processDisableReason == "unbundled-executable") + } + @Test func `clear denied allows immediate retry`() { KeychainAccessGate.withTaskOverrideForTesting(false) { diff --git a/Tests/CodexBarTests/ClaudeOAuthKeychainPreAlertGateTests.swift b/Tests/CodexBarTests/ClaudeOAuthKeychainPreAlertGateTests.swift new file mode 100644 index 0000000000..4c25ebfa89 --- /dev/null +++ b/Tests/CodexBarTests/ClaudeOAuthKeychainPreAlertGateTests.swift @@ -0,0 +1,96 @@ +import Foundation +import Testing +@testable import CodexBarCore + +#if os(macOS) +@Suite(.serialized) +struct ClaudeOAuthKeychainPreAlertGateTests { + @Test + func `acknowledgement suppresses repeated presentation until cooldown expires`() { + let store = ClaudeOAuthKeychainPreAlertGate.StateStore() + ClaudeOAuthKeychainPreAlertGate.withStateStoreOverrideForTesting(store) { + let now = Date(timeIntervalSince1970: 1000) + #expect(ClaudeOAuthKeychainPreAlertGate.presentIfNeeded(now: now, completedAt: now) { true }) + + #expect( + ClaudeOAuthKeychainPreAlertGate.presentIfNeeded( + now: now.addingTimeInterval(ClaudeOAuthKeychainPreAlertGate.cooldownInterval - 1), + completedAt: now, + present: { true }) == false) + #expect( + ClaudeOAuthKeychainPreAlertGate.presentIfNeeded( + now: now.addingTimeInterval(ClaudeOAuthKeychainPreAlertGate.cooldownInterval + 1), + completedAt: now, + present: { true })) + } + } + + @Test + func `cooldown starts when presentation completes`() { + let store = ClaudeOAuthKeychainPreAlertGate.StateStore() + ClaudeOAuthKeychainPreAlertGate.withStateStoreOverrideForTesting(store) { + let startedAt = Date(timeIntervalSince1970: 1000) + let completedAt = Date(timeIntervalSince1970: 2000) + #expect(ClaudeOAuthKeychainPreAlertGate.presentIfNeeded( + now: startedAt, + completedAt: completedAt, + present: { true })) + + #expect(ClaudeOAuthKeychainPreAlertGate.presentIfNeeded( + now: startedAt.addingTimeInterval(ClaudeOAuthKeychainPreAlertGate.cooldownInterval + 1), + completedAt: completedAt, + present: { true }) == false) + #expect(ClaudeOAuthKeychainPreAlertGate.presentIfNeeded( + now: completedAt.addingTimeInterval(ClaudeOAuthKeychainPreAlertGate.cooldownInterval + 1), + completedAt: completedAt, + present: { true })) + } + } + + @Test + func `missing prompt handler does not consume acknowledgement cooldown`() { + let store = ClaudeOAuthKeychainPreAlertGate.StateStore() + ClaudeOAuthKeychainPreAlertGate.withStateStoreOverrideForTesting(store) { + let now = Date(timeIntervalSince1970: 2000) + #expect(ClaudeOAuthKeychainPreAlertGate.presentIfNeeded(now: now, completedAt: now) { false } == false) + #expect(ClaudeOAuthKeychainPreAlertGate.presentIfNeeded(now: now, completedAt: now) { true }) + } + } + + @Test + func `duplicate while presentation is in flight is suppressed`() { + let store = ClaudeOAuthKeychainPreAlertGate.StateStore() + ClaudeOAuthKeychainPreAlertGate.withStateStoreOverrideForTesting(store) { + let now = Date(timeIntervalSince1970: 3000) + var nestedPresentationRan = false + var nestedResult: Bool? + let outerResult = ClaudeOAuthKeychainPreAlertGate.presentIfNeeded(now: now, completedAt: now) { + nestedResult = ClaudeOAuthKeychainPreAlertGate.presentIfNeeded(now: now) { + nestedPresentationRan = true + return true + } + return true + } + #expect(outerResult) + #expect(nestedResult == false) + #expect(nestedPresentationRan == false) + } + } + + @Test + func `acknowledgement persists across in memory reset`() { + ClaudeOAuthKeychainPreAlertGate.resetForTesting() + defer { ClaudeOAuthKeychainPreAlertGate.resetForTesting() } + + let now = Date(timeIntervalSince1970: 4000) + #expect(ClaudeOAuthKeychainPreAlertGate.presentIfNeeded(now: now, completedAt: now) { true }) + ClaudeOAuthKeychainPreAlertGate.resetInMemoryForTesting() + + #expect( + ClaudeOAuthKeychainPreAlertGate.presentIfNeeded( + now: now.addingTimeInterval(ClaudeOAuthKeychainPreAlertGate.cooldownInterval - 1), + completedAt: now, + present: { true }) == false) + } +} +#endif diff --git a/Tests/CodexBarTests/ClaudeOAuthPendingCacheClearStoreTests.swift b/Tests/CodexBarTests/ClaudeOAuthPendingCacheClearStoreTests.swift new file mode 100644 index 0000000000..7b597afe13 --- /dev/null +++ b/Tests/CodexBarTests/ClaudeOAuthPendingCacheClearStoreTests.swift @@ -0,0 +1,92 @@ +import Foundation +import Testing +@testable import CodexBarCore + +@Suite(.serialized) +struct ClaudeOAuthPendingCacheClearStoreTests { + @Test + func `legacy ownership recheck persists for only its invalidated profile`() throws { + let domain = "ClaudeOAuthPendingLegacyRecheckTests.\(UUID().uuidString)" + let key = "pending" + let tempDirectory = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + let userDefaults = try #require(UserDefaults(suiteName: domain)) + defer { + userDefaults.removePersistentDomain(forName: domain) + userDefaults.synchronize() + try? FileManager.default.removeItem(at: tempDirectory) + } + let lockURL = tempDirectory.appendingPathComponent("cache.lock") + let store = ClaudeOAuthPendingCacheClearUserDefaultsStore( + domain: domain, + key: key, + lockURL: lockURL) + store.withCacheTransaction( + profileIdentifier: "profile-a", + includingLegacyState: { profilePending, legacyCleanupPending, legacyRecheckPending in + #expect(!profilePending) + #expect(!legacyCleanupPending) + #expect(!legacyRecheckPending) + legacyRecheckPending = true + }) + + let reloadedStore = ClaudeOAuthPendingCacheClearUserDefaultsStore( + domain: domain, + key: key, + lockURL: lockURL) + #expect(reloadedStore.isPending(profileIdentifier: "profile-a")) + #expect(!reloadedStore.isPending(profileIdentifier: "profile-b")) + reloadedStore.withCacheTransaction( + profileIdentifier: "profile-a", + includingLegacyState: { profilePending, legacyCleanupPending, legacyRecheckPending in + #expect(!profilePending) + #expect(!legacyCleanupPending) + #expect(legacyRecheckPending) + legacyRecheckPending = false + }) + #expect(!reloadedStore.isPending) + } + + @Test + func `profile lock failure remains owned by the invalidated profile`() throws { + let domain = "ClaudeOAuthPendingProfileLockFailureTests.\(UUID().uuidString)" + let key = "pending" + let tempDirectory = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: tempDirectory, withIntermediateDirectories: true) + let blockedLockDirectory = tempDirectory.appendingPathComponent("not-a-directory") + try Data().write(to: blockedLockDirectory) + let userDefaults = try #require(UserDefaults(suiteName: domain)) + defer { + userDefaults.removePersistentDomain(forName: domain) + userDefaults.synchronize() + try? FileManager.default.removeItem(at: tempDirectory) + } + + let store = ClaudeOAuthPendingCacheClearUserDefaultsStore( + domain: domain, + key: key, + lockURL: blockedLockDirectory.appendingPathComponent("cache.lock")) + store.markPending(profileIdentifier: "profile-a") + userDefaults.synchronize() + + #expect(userDefaults.object(forKey: key) == nil) + + try FileManager.default.removeItem(at: blockedLockDirectory) + try FileManager.default.createDirectory(at: blockedLockDirectory, withIntermediateDirectories: true) + + #expect(store.isPending(profileIdentifier: "profile-a")) + #expect(!store.isPending(profileIdentifier: "profile-b")) + store.withCacheTransaction(profileIdentifier: "profile-b") { pending in + #expect(!pending) + } + #expect(store.isPending(profileIdentifier: "profile-a")) + #expect(!store.isPending(profileIdentifier: "profile-b")) + + store.withCacheTransaction(profileIdentifier: "profile-a") { pending in + #expect(pending) + pending = false + } + #expect(!store.isPending) + } +} diff --git a/Tests/CodexBarTests/ClaudeOAuthPromptCoalescingTests.swift b/Tests/CodexBarTests/ClaudeOAuthPromptCoalescingTests.swift new file mode 100644 index 0000000000..a8d6b2c7fc --- /dev/null +++ b/Tests/CodexBarTests/ClaudeOAuthPromptCoalescingTests.swift @@ -0,0 +1,282 @@ +#if os(macOS) +import Foundation +import Security +import Testing +@testable import CodexBarCore + +@Suite(.serialized) +struct ClaudeOAuthPromptCoalescingTests { + private enum BarrierError: Error { + case timedOut + } + + private enum LoadOutcome: Equatable { + case keychainError(Int) + case notFound + case unexpected(String) + } + + private final class ConcurrentPromptReadState: @unchecked Sendable { + private let condition = NSCondition() + private var entrants = 0 + private var reads = 0 + + func enterPromptPath() { + self.condition.lock() + self.entrants += 1 + self.condition.broadcast() + self.condition.unlock() + } + + func beginRead() throws { + self.condition.lock() + defer { self.condition.unlock() } + self.reads += 1 + guard self.reads == 1 else { return } + + let deadline = Date(timeIntervalSinceNow: 5) + while self.entrants < 2, self.condition.wait(until: deadline) {} + guard self.entrants >= 2 else { throw BarrierError.timedOut } + } + + var readCount: Int { + self.condition.lock() + defer { self.condition.unlock() } + return self.reads + } + } + + @Test + func `concurrent expired credential loads share one interactive keychain read`() async throws { + try await self.verifySuccessfulFanout(expiresIn: -3600) + } + + @Test + func `concurrent valid credential loads replay the exact interactive result`() async throws { + try await self.verifySuccessfulFanout(expiresIn: 3600) + } + + @Test + func `denial is replayed within one request and a new user request retries`() async throws { + let state = ConcurrentPromptReadState() + let deniedStore = ClaudeOAuthKeychainAccessGate.DeniedUntilStore() + let status = Int(errSecUserCanceled) + + let outcomes = try await self.withPromptEnvironment( + state: state, + deniedStore: deniedStore, + read: { + try state.beginRead() + ClaudeOAuthKeychainAccessGate.recordDenied() + throw ClaudeOAuthCredentialsError.keychainError(status) + }, + operation: { loadRecord in + let sameRequest = await ProviderRefreshRequestContext.$id.withValue(UUID()) { + async let first = self.loadOutcome(using: loadRecord) + async let second = self.loadOutcome(using: loadRecord) + let concurrent = await (first, second) + let late = self.loadOutcome(using: loadRecord) + return (concurrent.0, concurrent.1, late) + } + #expect(state.readCount == 1) + #expect(ClaudeOAuthKeychainAccessGate.clearDenied()) + let nextRequest = ProviderRefreshRequestContext.$id.withValue(UUID()) { + self.loadOutcome(using: loadRecord) + } + return (sameRequest.0, sameRequest.1, sameRequest.2, nextRequest) + }) + + let expected = LoadOutcome.keychainError(status) + #expect(outcomes.0 == expected) + #expect(outcomes.1 == expected) + #expect(outcomes.2 == expected) + #expect(outcomes.3 == expected) + #expect(state.readCount == 2) + } + + @Test + func `prompt failure is not replayed after policy changes`() async throws { + let state = ConcurrentPromptReadState() + let deniedStore = ClaudeOAuthKeychainAccessGate.DeniedUntilStore() + let status = Int(errSecUserCanceled) + + let outcomes = try await self.withPromptEnvironment( + state: state, + deniedStore: deniedStore, + read: { + try state.beginRead() + ClaudeOAuthKeychainAccessGate.recordDenied() + throw ClaudeOAuthCredentialsError.keychainError(status) + }, + operation: { loadRecord in + await ProviderRefreshRequestContext.$id.withValue(UUID()) { + async let first = self.loadOutcome(using: loadRecord) + async let second = self.loadOutcome(using: loadRecord) + let concurrent = await (first, second) + #expect(ClaudeOAuthKeychainAccessGate.clearDenied()) + let afterPolicyChange = ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.never) { + self.loadOutcome(using: loadRecord) + } + return (concurrent.0, concurrent.1, afterPolicyChange) + } + }) + + let expected = LoadOutcome.keychainError(status) + #expect(outcomes.0 == expected) + #expect(outcomes.1 == expected) + #expect(outcomes.2 == .notFound) + #expect(state.readCount == 1) + } + + @Test + func `credential invalidation starts a fresh prompt outcome generation`() async throws { + let state = ConcurrentPromptReadState() + let deniedStore = ClaudeOAuthKeychainAccessGate.DeniedUntilStore() + let status = Int(errSecUserCanceled) + let credentialsData = self.makeCredentialsData(expiresIn: 3600) + + let outcomes = try await self.withPromptEnvironment( + state: state, + deniedStore: deniedStore, + read: { + try state.beginRead() + if state.readCount == 1 { + ClaudeOAuthKeychainAccessGate.recordDenied() + throw ClaudeOAuthCredentialsError.keychainError(status) + } + return credentialsData + }, + operation: { loadRecord in + try await ProviderRefreshRequestContext.$id.withValue(UUID()) { + async let first = self.loadOutcome(using: loadRecord) + async let second = self.loadOutcome(using: loadRecord) + let concurrent = await (first, second) + #expect(ClaudeOAuthKeychainAccessGate.clearDenied()) + ClaudeOAuthCredentialsStore.invalidateCache() + let afterInvalidation = try loadRecord() + return (concurrent.0, concurrent.1, afterInvalidation) + } + }) + + let expected = LoadOutcome.keychainError(status) + #expect(outcomes.0 == expected) + #expect(outcomes.1 == expected) + #expect(outcomes.2.credentials.accessToken == "shared-interactive-read") + #expect(outcomes.2.source == .claudeKeychain) + #expect(state.readCount == 2) + } + + private func verifySuccessfulFanout(expiresIn: TimeInterval) async throws { + let state = ConcurrentPromptReadState() + let deniedStore = ClaudeOAuthKeychainAccessGate.DeniedUntilStore() + let credentialsData = self.makeCredentialsData(expiresIn: expiresIn) + + let records = try await self.withPromptEnvironment( + state: state, + deniedStore: deniedStore, + read: { + try state.beginRead() + return credentialsData + }, + operation: { loadRecord in + try await ProviderRefreshRequestContext.$id.withValue(UUID()) { + async let first = loadRecord() + async let second = loadRecord() + let concurrentRecords = try await (first, second) + let lateRecord = try loadRecord() + return (concurrentRecords.0, concurrentRecords.1, lateRecord) + } + }) + + #expect(records.0.credentials.accessToken == "shared-interactive-read") + #expect(records.1.credentials.accessToken == "shared-interactive-read") + #expect(records.2.credentials.accessToken == "shared-interactive-read") + #expect(records.0.source == .claudeKeychain) + #expect(records.1.source == .claudeKeychain) + #expect(records.2.source == .claudeKeychain) + #expect(state.readCount == 1) + } + + private func withPromptEnvironment( + state: ConcurrentPromptReadState, + deniedStore: ClaudeOAuthKeychainAccessGate.DeniedUntilStore, + read: @escaping @Sendable () throws -> Data, + operation: (_ loadRecord: @Sendable () throws -> ClaudeOAuthCredentialRecord) async throws -> T) async throws + -> T + { + let service = "com.steipete.codexbar.cache.tests.\(UUID().uuidString)" + let missingCredentialsURL = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + let beforePromptLock: @Sendable () -> Void = { state.enterPromptPath() } + return try await KeychainCacheStore.withServiceOverrideForTesting(service) { + try await KeychainAccessGate.withTaskOverrideForTesting(false) { + KeychainCacheStore.setTestStoreForTesting(true) + defer { KeychainCacheStore.setTestStoreForTesting(false) } + return try await ClaudeOAuthKeychainAccessGate.withDeniedUntilStoreOverrideForTesting(deniedStore) { + try await ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(missingCredentialsURL) { + try await ClaudeOAuthCredentialsStore.withIsolatedCredentialsFileTrackingForTesting { + try await ClaudeOAuthCredentialsStore.withIsolatedMemoryCacheForTesting { + try await ClaudeOAuthKeychainReadStrategyPreference.withTaskOverrideForTesting( + .securityFramework) + { + try await ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting( + .onlyOnUserAction) + { + try await ProviderInteractionContext.$current.withValue(.userInitiated) { + try await ClaudeOAuthCredentialsStore + .withInteractiveClaudeKeychainReadOverridesForTesting( + beforePromptLock: beforePromptLock, + read: read) + { + try await operation { + try ClaudeOAuthCredentialsStore.loadRecord( + environment: [:], + allowKeychainPrompt: true, + respectKeychainPromptCooldown: true, + allowClaudeKeychainRepairWithoutPrompt: false) + } + } + } + } + } + } + } + } + } + } + } + } + + private func makeCredentialsData(expiresIn: TimeInterval) -> Data { + let expiresAt = Int(Date(timeIntervalSinceNow: expiresIn).timeIntervalSince1970 * 1000) + return Data(""" + { + "claudeAiOauth": { + "accessToken": "shared-interactive-read", + "expiresAt": \(expiresAt), + "scopes": ["user:profile"], + "refreshToken": "refresh" + } + } + """.utf8) + } + + private func loadOutcome( + using loadRecord: @Sendable () throws -> ClaudeOAuthCredentialRecord) -> LoadOutcome + { + do { + _ = try loadRecord() + return .unexpected("record") + } catch let error as ClaudeOAuthCredentialsError { + if case let .keychainError(status) = error { + return .keychainError(status) + } + if case .notFound = error { + return .notFound + } + return .unexpected(String(describing: error)) + } catch { + return .unexpected(String(reflecting: type(of: error))) + } + } +} +#endif diff --git a/Tests/CodexBarTests/ClaudeOAuthRateLimitResilienceTests.swift b/Tests/CodexBarTests/ClaudeOAuthRateLimitResilienceTests.swift new file mode 100644 index 0000000000..33fadf1893 --- /dev/null +++ b/Tests/CodexBarTests/ClaudeOAuthRateLimitResilienceTests.swift @@ -0,0 +1,252 @@ +import Foundation +import Testing +@testable import CodexBar +@testable import CodexBarCore + +@Suite(.serialized) +struct ClaudeOAuthRateLimitResilienceTests { + @Test + func `classifier accepts only the canonical O auth rate limit`() { + let canonical = ClaudeOAuthFetchError.usageRateLimitDescription + + #expect(ClaudeUsageError.isClaudeOAuthUsageRateLimit(ClaudeOAuthFetchError.rateLimited(retryAfter: nil))) + #expect(ClaudeUsageError.isClaudeOAuthUsageRateLimit(ClaudeUsageError.oauthFailed(canonical))) + #expect(!ClaudeUsageError.isClaudeOAuthUsageRateLimit(ClaudeUsageError.oauthFailed(canonical + " extra"))) + #expect(!ClaudeUsageError.isClaudeOAuthUsageRateLimit(ClaudeUsageError.oauthFailed("rate limited"))) + #expect(!ClaudeUsageError.isClaudeOAuthUsageRateLimit(ClaudeUsageError.oauthFailed("HTTP 429"))) + #expect(!ClaudeUsageError.isClaudeOAuthUsageRateLimit(NSError( + domain: "test", + code: 429, + userInfo: [NSLocalizedDescriptionKey: canonical]))) + } + + @MainActor + @Test + func `stable unscoped O auth refresh keeps the prior card on rate limit`() async throws { + let store = try self.makeStore(suite: "ClaudeOAuthRateLimit-unscoped") + let prior = self.snapshot(usedPercent: 28) + store._setSnapshotForTesting(prior, provider: .claude) + store.lastKnownResetSnapshots[.claude] = prior + store.lastSourceLabels[.claude] = "oauth" + try self.installRateLimitDescriptor(store) + + await self.refreshWithStableClaudeCredentials(store) + + #expect(store.snapshot(for: .claude)?.updatedAt == prior.updatedAt) + #expect(store.lastKnownResetSnapshots[.claude]?.updatedAt == prior.updatedAt) + #expect(store.lastSourceLabels[.claude] == "oauth") + #expect(store.error(for: .claude) == nil) + } + + @MainActor + @Test + func `missing prior card surfaces the O auth rate limit`() async throws { + let store = try self.makeStore(suite: "ClaudeOAuthRateLimit-missing") + try self.installRateLimitDescriptor(store) + + await self.refreshWithStableClaudeCredentials(store) + + #expect(store.snapshot(for: .claude) == nil) + #expect(store.error(for: .claude)?.contains("rate limited") == true) + } + + @MainActor + @Test + func `segmented account keeps only its exact O auth cache without recording history`() async throws { + let store = try self.makeStore(suite: "ClaudeOAuthRateLimit-segmented", layout: .segmented) + store.settings.addTokenAccount(provider: .claude, label: "Primary", token: "test-auth-token") + let account = try #require(store.settings.selectedTokenAccount(for: .claude)) + let prior = self.snapshot(usedPercent: 31) + self.seedAccountSnapshot(store: store, account: account, snapshot: prior) + store._setKnownLimitsAvailabilityForTesting(.available, provider: .claude) + try self.installRateLimitDescriptor(store) + + await self.refreshWithStableClaudeCredentials(store) + + let row = try #require(store.accountSnapshots[.claude]?.first) + #expect(store.snapshot(for: .claude)?.updatedAt == prior.updatedAt) + #expect(store.error(for: .claude) == nil) + #expect(row.cacheKey == store.tokenAccountSnapshotCacheKey(provider: .claude, account: account)) + #expect(row.snapshot?.updatedAt == prior.updatedAt) + #expect(row.error == nil) + #expect(store.knownLimitsAvailability(for: .claude) == .available) + #expect(store.planUtilizationHistory[.claude] == nil) + } + + @MainActor + @Test + func `edited account cannot reuse its previous O auth cache`() async throws { + let store = try self.makeStore(suite: "ClaudeOAuthRateLimit-edited", layout: .segmented) + store.settings.addTokenAccount(provider: .claude, label: "Primary", token: "test-auth-token") + let original = try #require(store.settings.selectedTokenAccount(for: .claude)) + self.seedAccountSnapshot(store: store, account: original, snapshot: self.snapshot(usedPercent: 47)) + store.settings.updateTokenAccount( + provider: .claude, + accountID: original.id, + token: "test-token-placeholder") + try self.installRateLimitDescriptor(store) + + await self.refreshWithStableClaudeCredentials(store) + + #expect(store.snapshot(for: .claude) == nil) + #expect(store.error(for: .claude)?.contains("rate limited") == true) + #expect(store.accountSnapshots[.claude]?.first?.snapshot == nil) + } + + @MainActor + @Test + func `stacked accounts keep exact O auth caches without recording cached history`() async throws { + let store = try self.makeStore(suite: "ClaudeOAuthRateLimit-stacked", layout: .stacked) + store.settings.addTokenAccount(provider: .claude, label: "Primary", token: "test-auth-token") + store.settings.addTokenAccount(provider: .claude, label: "Secondary", token: "test-token-placeholder") + let accounts = store.settings.tokenAccounts(for: .claude) + let primary = try #require(accounts.first) + let secondary = try #require(accounts.last) + let selected = try #require(store.settings.selectedTokenAccount(for: .claude)) + let primaryPrior = self.snapshot(usedPercent: 21) + let secondaryPrior = self.snapshot(usedPercent: 64) + let selectedPrior = selected.id == primary.id ? primaryPrior : secondaryPrior + self.seedAccountSnapshots( + store: store, + values: [(primary, primaryPrior), (secondary, secondaryPrior)]) + store._setKnownLimitsAvailabilityForTesting(.available, provider: .claude) + try self.installRateLimitDescriptor(store) + + await self.refreshWithStableClaudeCredentials(store) + + let rows = store.accountSnapshots[.claude] ?? [] + #expect(rows.count == 2) + #expect(rows.first(where: { $0.account.id == primary.id })?.snapshot?.updatedAt == primaryPrior.updatedAt) + #expect(rows.first(where: { $0.account.id == secondary.id })?.snapshot?.updatedAt == secondaryPrior.updatedAt) + #expect(store.snapshot(for: .claude)?.updatedAt == selectedPrior.updatedAt) + #expect(store.error(for: .claude) == nil) + #expect(store.knownLimitsAvailability(for: .claude) == .available) + #expect(store.planUtilizationHistory[.claude] == nil) + } + + @MainActor + private func makeStore( + suite: String, + layout: MultiAccountMenuLayout = .segmented) throws -> UsageStore + { + 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(), + augmentCookieStore: InMemoryCookieHeaderStore(), + ampCookieStore: InMemoryCookieHeaderStore(), + copilotTokenStore: InMemoryCopilotTokenStore(), + tokenAccountStore: InMemoryTokenAccountStore()) + settings.providerDetectionCompleted = true + settings.refreshFrequency = .manual + settings.statusChecksEnabled = false + settings.claudeUsageDataSource = .oauth + settings.claudeOAuthKeychainPromptMode = .never + settings.multiAccountMenuLayout = layout + let metadata = try #require(ProviderRegistry.shared.metadata[.claude]) + settings.setProviderEnabled(provider: .claude, metadata: metadata, enabled: true) + return UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + startupBehavior: .testing, + environmentBase: [:]) + } + + @MainActor + private func installRateLimitDescriptor(_ store: UsageStore) throws { + 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: [.oauth], + pipeline: ProviderFetchPipeline { _ in [ClaudeOAuthRateLimitStrategy()] }), + cli: baseSpec.descriptor.cli) + store.providerSpecs[.claude] = ProviderSpec( + style: baseSpec.style, + isEnabled: baseSpec.isEnabled, + descriptor: descriptor, + makeFetchContext: baseSpec.makeFetchContext) + } + + private func refreshWithStableClaudeCredentials(_ store: UsageStore) async { + await ClaudeOAuthCredentialsStore.withIsolatedCredentialsFileTrackingForTesting { + let missingCredentialsURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString) + await ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(missingCredentialsURL) { + await store.refreshProvider(.claude) + } + } + } + + @MainActor + private func seedAccountSnapshot( + store: UsageStore, + account: ProviderTokenAccount, + snapshot: UsageSnapshot) + { + self.seedAccountSnapshots(store: store, values: [(account, snapshot)]) + } + + @MainActor + private func seedAccountSnapshots( + store: UsageStore, + values: [(ProviderTokenAccount, UsageSnapshot)]) + { + store.accountSnapshots[.claude] = values.map { account, snapshot in + TokenAccountUsageSnapshot( + account: account, + snapshot: snapshot, + error: nil, + sourceLabel: "oauth", + cacheKey: store.tokenAccountSnapshotCacheKey(provider: .claude, account: account)) + } + } + + private func snapshot(usedPercent: Double) -> UsageSnapshot { + UsageSnapshot( + primary: RateWindow( + usedPercent: usedPercent, + windowMinutes: 300, + resetsAt: Date(timeIntervalSince1970: 1_900_000_000 + usedPercent), + resetDescription: nil), + secondary: nil, + updatedAt: Date(timeIntervalSince1970: 1_800_000_000 + usedPercent), + identity: ProviderIdentitySnapshot( + providerID: .claude, + accountEmail: "test@example.com", + accountOrganization: nil, + loginMethod: "OAuth")) + } +} + +private struct ClaudeOAuthRateLimitStrategy: ProviderFetchStrategy { + let id = "test.claude-oauth-rate-limit" + let kind: ProviderFetchKind = .oauth + + func isAvailable(_: ProviderFetchContext) async -> Bool { + true + } + + func fetch(_: ProviderFetchContext) async throws -> ProviderFetchResult { + throw ClaudeUsageError.oauthFailed(ClaudeOAuthFetchError.usageRateLimitDescription) + } + + func shouldFallback(on _: Error, context _: ProviderFetchContext) -> Bool { + false + } +} diff --git a/Tests/CodexBarTests/ClaudeOAuthRefreshFailureGateTests.swift b/Tests/CodexBarTests/ClaudeOAuthRefreshFailureGateTests.swift index 107eae03fe..bb5c43d498 100644 --- a/Tests/CodexBarTests/ClaudeOAuthRefreshFailureGateTests.swift +++ b/Tests/CodexBarTests/ClaudeOAuthRefreshFailureGateTests.swift @@ -23,23 +23,24 @@ struct ClaudeOAuthRefreshFailureGateTests { createdAt: 1, persistentRefHash: "ref1"), credentialsFile: "file1") - ClaudeOAuthRefreshFailureGate.setFingerprintProviderOverrideForTesting { fingerprint } - defer { ClaudeOAuthRefreshFailureGate.setFingerprintProviderOverrideForTesting(nil) } - - let start = Date(timeIntervalSince1970: 1000) - ClaudeOAuthRefreshFailureGate.recordTerminalAuthFailure(now: start) - - #expect(ClaudeOAuthRefreshFailureGate.shouldAttempt(now: start.addingTimeInterval(60)) == false) - - // Ensure we do not get unblocked unless fingerprint changes. - fingerprint = ClaudeOAuthRefreshFailureGate.AuthFingerprint( - keychain: ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprint( - modifiedAt: 1, - createdAt: 1, - persistentRefHash: "ref1"), - credentialsFile: "file1") - #expect(ClaudeOAuthRefreshFailureGate.shouldAttempt(now: start.addingTimeInterval(60 * 4)) == false) - #expect(ClaudeOAuthRefreshFailureGate.shouldAttempt(now: start.addingTimeInterval(60 * 60 * 24)) == false) + ClaudeOAuthRefreshFailureGate.withFingerprintProviderOverrideForTesting { + fingerprint + } operation: { + let start = Date(timeIntervalSince1970: 1000) + ClaudeOAuthRefreshFailureGate.recordTerminalAuthFailure(now: start) + + #expect(ClaudeOAuthRefreshFailureGate.shouldAttempt(now: start.addingTimeInterval(60)) == false) + + // Ensure we do not get unblocked unless fingerprint changes. + fingerprint = ClaudeOAuthRefreshFailureGate.AuthFingerprint( + keychain: ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprint( + modifiedAt: 1, + createdAt: 1, + persistentRefHash: "ref1"), + credentialsFile: "file1") + #expect(ClaudeOAuthRefreshFailureGate.shouldAttempt(now: start.addingTimeInterval(60 * 4)) == false) + #expect(ClaudeOAuthRefreshFailureGate.shouldAttempt(now: start.addingTimeInterval(60 * 60 * 24)) == false) + } } @Test @@ -69,21 +70,22 @@ struct ClaudeOAuthRefreshFailureGateTests { createdAt: 1, persistentRefHash: "ref1"), credentialsFile: "file1") - ClaudeOAuthRefreshFailureGate.setFingerprintProviderOverrideForTesting { fingerprint } - defer { ClaudeOAuthRefreshFailureGate.setFingerprintProviderOverrideForTesting(nil) } - - let legacyBlockedUntil = now.addingTimeInterval(60 * 10) - UserDefaults.standard.set(2, forKey: self.legacyFailureCountKey) - UserDefaults.standard.removeObject(forKey: self.terminalBlockedKey) - UserDefaults.standard.set(legacyBlockedUntil.timeIntervalSince1970, forKey: self.legacyBlockedUntilKey) - let data = try JSONEncoder().encode(fingerprint) - UserDefaults.standard.set(data, forKey: self.legacyFingerprintKey) - - #expect(ClaudeOAuthRefreshFailureGate.shouldAttempt(now: now) == false) - #expect(UserDefaults.standard.bool(forKey: self.terminalBlockedKey) == false) - #expect(UserDefaults.standard.object(forKey: self.legacyBlockedUntilKey) == nil) - #expect(UserDefaults.standard.object(forKey: self.transientBlockedUntilKey) != nil) - #expect(UserDefaults.standard.integer(forKey: self.transientFailureCountKey) == 2) + try ClaudeOAuthRefreshFailureGate.withFingerprintProviderOverrideForTesting { + fingerprint + } operation: { + let legacyBlockedUntil = now.addingTimeInterval(60 * 10) + UserDefaults.standard.set(2, forKey: self.legacyFailureCountKey) + UserDefaults.standard.removeObject(forKey: self.terminalBlockedKey) + UserDefaults.standard.set(legacyBlockedUntil.timeIntervalSince1970, forKey: self.legacyBlockedUntilKey) + let data = try JSONEncoder().encode(fingerprint) + UserDefaults.standard.set(data, forKey: self.legacyFingerprintKey) + + #expect(ClaudeOAuthRefreshFailureGate.shouldAttempt(now: now) == false) + #expect(UserDefaults.standard.bool(forKey: self.terminalBlockedKey) == false) + #expect(UserDefaults.standard.object(forKey: self.legacyBlockedUntilKey) == nil) + #expect(UserDefaults.standard.object(forKey: self.transientBlockedUntilKey) != nil) + #expect(UserDefaults.standard.integer(forKey: self.transientFailureCountKey) == 2) + } } @Test @@ -92,23 +94,24 @@ struct ClaudeOAuthRefreshFailureGateTests { defer { ClaudeOAuthRefreshFailureGate.resetForTesting() } var fingerprint: ClaudeOAuthRefreshFailureGate.AuthFingerprint? - ClaudeOAuthRefreshFailureGate.setFingerprintProviderOverrideForTesting { fingerprint } - defer { ClaudeOAuthRefreshFailureGate.setFingerprintProviderOverrideForTesting(nil) } - - let start = Date(timeIntervalSince1970: 25000) - ClaudeOAuthRefreshFailureGate.recordTerminalAuthFailure(now: start) - - // Still blocked while fingerprint is unavailable. - #expect(ClaudeOAuthRefreshFailureGate.shouldAttempt(now: start.addingTimeInterval(20)) == false) - - // Once fingerprint becomes available, the sentinel differs and we unblock. - fingerprint = ClaudeOAuthRefreshFailureGate.AuthFingerprint( - keychain: ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprint( - modifiedAt: 1, - createdAt: 1, - persistentRefHash: "ref1"), - credentialsFile: "file1") - #expect(ClaudeOAuthRefreshFailureGate.shouldAttempt(now: start.addingTimeInterval(40)) == true) + ClaudeOAuthRefreshFailureGate.withFingerprintProviderOverrideForTesting { + fingerprint + } operation: { + let start = Date(timeIntervalSince1970: 25000) + ClaudeOAuthRefreshFailureGate.recordTerminalAuthFailure(now: start) + + // Still blocked while fingerprint is unavailable. + #expect(ClaudeOAuthRefreshFailureGate.shouldAttempt(now: start.addingTimeInterval(20)) == false) + + // Once fingerprint becomes available, the sentinel differs and we unblock. + fingerprint = ClaudeOAuthRefreshFailureGate.AuthFingerprint( + keychain: ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprint( + modifiedAt: 1, + createdAt: 1, + persistentRefHash: "ref1"), + credentialsFile: "file1") + #expect(ClaudeOAuthRefreshFailureGate.shouldAttempt(now: start.addingTimeInterval(40)) == true) + } } @Test @@ -122,20 +125,21 @@ struct ClaudeOAuthRefreshFailureGateTests { createdAt: 1, persistentRefHash: "ref1"), credentialsFile: "file1") - ClaudeOAuthRefreshFailureGate.setFingerprintProviderOverrideForTesting { fingerprint } - defer { ClaudeOAuthRefreshFailureGate.setFingerprintProviderOverrideForTesting(nil) } - - let start = Date(timeIntervalSince1970: 2000) - ClaudeOAuthRefreshFailureGate.recordTerminalAuthFailure(now: start) - #expect(ClaudeOAuthRefreshFailureGate.shouldAttempt(now: start.addingTimeInterval(60)) == false) - - fingerprint = ClaudeOAuthRefreshFailureGate.AuthFingerprint( - keychain: ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprint( - modifiedAt: 2, - createdAt: 2, - persistentRefHash: "ref2"), - credentialsFile: "file2") - #expect(ClaudeOAuthRefreshFailureGate.shouldAttempt(now: start.addingTimeInterval(60 * 2)) == true) + ClaudeOAuthRefreshFailureGate.withFingerprintProviderOverrideForTesting { + fingerprint + } operation: { + let start = Date(timeIntervalSince1970: 2000) + ClaudeOAuthRefreshFailureGate.recordTerminalAuthFailure(now: start) + #expect(ClaudeOAuthRefreshFailureGate.shouldAttempt(now: start.addingTimeInterval(60)) == false) + + fingerprint = ClaudeOAuthRefreshFailureGate.AuthFingerprint( + keychain: ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprint( + modifiedAt: 2, + createdAt: 2, + persistentRefHash: "ref2"), + credentialsFile: "file2") + #expect(ClaudeOAuthRefreshFailureGate.shouldAttempt(now: start.addingTimeInterval(60 * 2)) == true) + } } @Test @@ -150,27 +154,26 @@ struct ClaudeOAuthRefreshFailureGateTests { createdAt: 1, persistentRefHash: "ref1"), credentialsFile: "file1") - ClaudeOAuthRefreshFailureGate.setFingerprintProviderOverrideForTesting { + ClaudeOAuthRefreshFailureGate.withFingerprintProviderOverrideForTesting { calls += 1 return fingerprint + } operation: { + let start = Date(timeIntervalSince1970: 30000) + ClaudeOAuthRefreshFailureGate.recordTerminalAuthFailure(now: start) + #expect(calls == 1) + + // First blocked check is throttled (we already captured fingerprint at failure). + #expect(ClaudeOAuthRefreshFailureGate.shouldAttempt(now: start.addingTimeInterval(1)) == false) + #expect(calls == 1) + + // After the throttle window, it should re-read. + #expect(ClaudeOAuthRefreshFailureGate.shouldAttempt(now: start.addingTimeInterval(20)) == false) + #expect(calls == 2) + + // Subsequent checks within the throttle window should not re-read again. + #expect(ClaudeOAuthRefreshFailureGate.shouldAttempt(now: start.addingTimeInterval(21)) == false) + #expect(calls == 2) } - defer { ClaudeOAuthRefreshFailureGate.setFingerprintProviderOverrideForTesting(nil) } - - let start = Date(timeIntervalSince1970: 30000) - ClaudeOAuthRefreshFailureGate.recordTerminalAuthFailure(now: start) - #expect(calls == 1) - - // First blocked check is throttled (we already captured fingerprint at failure). - #expect(ClaudeOAuthRefreshFailureGate.shouldAttempt(now: start.addingTimeInterval(1)) == false) - #expect(calls == 1) - - // After the throttle window, it should re-read. - #expect(ClaudeOAuthRefreshFailureGate.shouldAttempt(now: start.addingTimeInterval(20)) == false) - #expect(calls == 2) - - // Subsequent checks within the throttle window should not re-read again. - #expect(ClaudeOAuthRefreshFailureGate.shouldAttempt(now: start.addingTimeInterval(21)) == false) - #expect(calls == 2) } @Test @@ -184,16 +187,17 @@ struct ClaudeOAuthRefreshFailureGateTests { createdAt: 1, persistentRefHash: "ref1"), credentialsFile: "file1") - ClaudeOAuthRefreshFailureGate.setFingerprintProviderOverrideForTesting { fingerprint } - defer { ClaudeOAuthRefreshFailureGate.setFingerprintProviderOverrideForTesting(nil) } - - let start = Date(timeIntervalSince1970: 35000) - ClaudeOAuthRefreshFailureGate.recordTerminalAuthFailure(now: start) - ClaudeOAuthRefreshFailureGate.recordTransientFailure(now: start.addingTimeInterval(1)) - - #expect(ClaudeOAuthRefreshFailureGate.shouldAttempt(now: start.addingTimeInterval(20)) == false) - #expect(UserDefaults.standard.bool(forKey: self.terminalBlockedKey) == true) - #expect(UserDefaults.standard.object(forKey: self.transientBlockedUntilKey) == nil) + ClaudeOAuthRefreshFailureGate.withFingerprintProviderOverrideForTesting { + fingerprint + } operation: { + let start = Date(timeIntervalSince1970: 35000) + ClaudeOAuthRefreshFailureGate.recordTerminalAuthFailure(now: start) + ClaudeOAuthRefreshFailureGate.recordTransientFailure(now: start.addingTimeInterval(1)) + + #expect(ClaudeOAuthRefreshFailureGate.shouldAttempt(now: start.addingTimeInterval(20)) == false) + #expect(UserDefaults.standard.bool(forKey: self.terminalBlockedKey) == true) + #expect(UserDefaults.standard.object(forKey: self.transientBlockedUntilKey) == nil) + } } @Test @@ -207,15 +211,16 @@ struct ClaudeOAuthRefreshFailureGateTests { createdAt: 1, persistentRefHash: "ref1"), credentialsFile: "file1") - ClaudeOAuthRefreshFailureGate.setFingerprintProviderOverrideForTesting { fingerprint } - defer { ClaudeOAuthRefreshFailureGate.setFingerprintProviderOverrideForTesting(nil) } - - let start = Date(timeIntervalSince1970: 5000) - ClaudeOAuthRefreshFailureGate.recordTerminalAuthFailure(now: start) - #expect(ClaudeOAuthRefreshFailureGate.shouldAttempt(now: start.addingTimeInterval(60)) == false) - - ClaudeOAuthRefreshFailureGate.recordSuccess() - #expect(ClaudeOAuthRefreshFailureGate.shouldAttempt(now: start.addingTimeInterval(60)) == true) + ClaudeOAuthRefreshFailureGate.withFingerprintProviderOverrideForTesting { + fingerprint + } operation: { + let start = Date(timeIntervalSince1970: 5000) + ClaudeOAuthRefreshFailureGate.recordTerminalAuthFailure(now: start) + #expect(ClaudeOAuthRefreshFailureGate.shouldAttempt(now: start.addingTimeInterval(60)) == false) + + ClaudeOAuthRefreshFailureGate.recordSuccess() + #expect(ClaudeOAuthRefreshFailureGate.shouldAttempt(now: start.addingTimeInterval(60)) == true) + } } @Test @@ -229,15 +234,16 @@ struct ClaudeOAuthRefreshFailureGateTests { createdAt: 1, persistentRefHash: "ref1"), credentialsFile: "file1") - ClaudeOAuthRefreshFailureGate.setFingerprintProviderOverrideForTesting { fingerprint } - defer { ClaudeOAuthRefreshFailureGate.setFingerprintProviderOverrideForTesting(nil) } - - let start = Date(timeIntervalSince1970: 60000) - ClaudeOAuthRefreshFailureGate.recordTransientFailure(now: start) + ClaudeOAuthRefreshFailureGate.withFingerprintProviderOverrideForTesting { + fingerprint + } operation: { + let start = Date(timeIntervalSince1970: 60000) + ClaudeOAuthRefreshFailureGate.recordTransientFailure(now: start) - #expect(ClaudeOAuthRefreshFailureGate.shouldAttempt(now: start.addingTimeInterval(1)) == false) - #expect(ClaudeOAuthRefreshFailureGate.shouldAttempt(now: start.addingTimeInterval(60 * 5 - 1)) == false) - #expect(ClaudeOAuthRefreshFailureGate.shouldAttempt(now: start.addingTimeInterval(60 * 5 + 1)) == true) + #expect(ClaudeOAuthRefreshFailureGate.shouldAttempt(now: start.addingTimeInterval(1)) == false) + #expect(ClaudeOAuthRefreshFailureGate.shouldAttempt(now: start.addingTimeInterval(60 * 5 - 1)) == false) + #expect(ClaudeOAuthRefreshFailureGate.shouldAttempt(now: start.addingTimeInterval(60 * 5 + 1)) == true) + } } @Test @@ -251,26 +257,28 @@ struct ClaudeOAuthRefreshFailureGateTests { createdAt: 1, persistentRefHash: "ref1"), credentialsFile: "file1") - ClaudeOAuthRefreshFailureGate.setFingerprintProviderOverrideForTesting { fingerprint } - defer { ClaudeOAuthRefreshFailureGate.setFingerprintProviderOverrideForTesting(nil) } - - let start = Date(timeIntervalSince1970: 70000) - ClaudeOAuthRefreshFailureGate.recordTransientFailure(now: start) - // Second failure before the first window expires should double the backoff. - let secondFailureAt = start.addingTimeInterval(1) - ClaudeOAuthRefreshFailureGate.recordTransientFailure(now: secondFailureAt) - #expect(ClaudeOAuthRefreshFailureGate - .shouldAttempt(now: secondFailureAt.addingTimeInterval(60 * 10 - 1)) == false) - #expect(ClaudeOAuthRefreshFailureGate - .shouldAttempt(now: secondFailureAt.addingTimeInterval(60 * 10 + 1)) == true) - - ClaudeOAuthRefreshFailureGate.resetInMemoryStateForTesting() - for _ in 0..<20 { + ClaudeOAuthRefreshFailureGate.withFingerprintProviderOverrideForTesting { + fingerprint + } operation: { + let start = Date(timeIntervalSince1970: 70000) ClaudeOAuthRefreshFailureGate.recordTransientFailure(now: start) + // Second failure before the first window expires should double the backoff. + let secondFailureAt = start.addingTimeInterval(1) + ClaudeOAuthRefreshFailureGate.recordTransientFailure(now: secondFailureAt) + #expect(ClaudeOAuthRefreshFailureGate + .shouldAttempt(now: secondFailureAt.addingTimeInterval(60 * 10 - 1)) == false) + #expect(ClaudeOAuthRefreshFailureGate + .shouldAttempt(now: secondFailureAt.addingTimeInterval(60 * 10 + 1)) == true) + + ClaudeOAuthRefreshFailureGate.resetInMemoryStateForTesting() + for _ in 0..<20 { + ClaudeOAuthRefreshFailureGate.recordTransientFailure(now: start) + } + + #expect(ClaudeOAuthRefreshFailureGate + .shouldAttempt(now: start.addingTimeInterval(60 * 60 * 6 - 1)) == false) + #expect(ClaudeOAuthRefreshFailureGate.shouldAttempt(now: start.addingTimeInterval(60 * 60 * 6 + 1)) == true) } - - #expect(ClaudeOAuthRefreshFailureGate.shouldAttempt(now: start.addingTimeInterval(60 * 60 * 6 - 1)) == false) - #expect(ClaudeOAuthRefreshFailureGate.shouldAttempt(now: start.addingTimeInterval(60 * 60 * 6 + 1)) == true) } @Test @@ -284,24 +292,25 @@ struct ClaudeOAuthRefreshFailureGateTests { createdAt: 1, persistentRefHash: "ref1"), credentialsFile: "file1") - ClaudeOAuthRefreshFailureGate.setFingerprintProviderOverrideForTesting { fingerprint } - defer { ClaudeOAuthRefreshFailureGate.setFingerprintProviderOverrideForTesting(nil) } + ClaudeOAuthRefreshFailureGate.withFingerprintProviderOverrideForTesting { + fingerprint + } operation: { + let start = Date(timeIntervalSince1970: 80000) + ClaudeOAuthRefreshFailureGate.recordTransientFailure(now: start) - let start = Date(timeIntervalSince1970: 80000) - ClaudeOAuthRefreshFailureGate.recordTransientFailure(now: start) + // Still blocked while timer is active and fingerprint unchanged. + #expect(ClaudeOAuthRefreshFailureGate.shouldAttempt(now: start.addingTimeInterval(20)) == false) - // Still blocked while timer is active and fingerprint unchanged. - #expect(ClaudeOAuthRefreshFailureGate.shouldAttempt(now: start.addingTimeInterval(20)) == false) + fingerprint = ClaudeOAuthRefreshFailureGate.AuthFingerprint( + keychain: ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprint( + modifiedAt: 2, + createdAt: 2, + persistentRefHash: "ref2"), + credentialsFile: "file2") - fingerprint = ClaudeOAuthRefreshFailureGate.AuthFingerprint( - keychain: ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprint( - modifiedAt: 2, - createdAt: 2, - persistentRefHash: "ref2"), - credentialsFile: "file2") - - // Even though the 5-minute cooldown window hasn't elapsed, a fingerprint change should unblock. - #expect(ClaudeOAuthRefreshFailureGate.shouldAttempt(now: start.addingTimeInterval(40)) == true) + // Even though the 5-minute cooldown window hasn't elapsed, a fingerprint change should unblock. + #expect(ClaudeOAuthRefreshFailureGate.shouldAttempt(now: start.addingTimeInterval(40)) == true) + } } } #endif diff --git a/Tests/CodexBarTests/ClaudeOAuthTests.swift b/Tests/CodexBarTests/ClaudeOAuthTests.swift index a08fec11b8..c1086f358c 100644 --- a/Tests/CodexBarTests/ClaudeOAuthTests.swift +++ b/Tests/CodexBarTests/ClaudeOAuthTests.swift @@ -2,6 +2,7 @@ import Foundation import Testing @testable import CodexBarCore +@Suite(.serialized) struct ClaudeOAuthTests { @Test func `parses O auth credentials`() throws { @@ -52,6 +53,35 @@ struct ClaudeOAuthTests { } } + @Test + func `mcp O auth only keychain payload throws`() { + let json = """ + { + "mcpOAuth": { + "plugin:slack:slack": { + "accessToken": "" + } + } + } + """ + #expect(throws: ClaudeOAuthCredentialsError.self) { + _ = try ClaudeOAuthCredentials.parse(data: Data(json.utf8)) + } + } + + @Test + func `detects mcp O auth only keychain payload shape`() { + let json = """ + { + "mcpOAuth": { + "craft": { "accessToken": "" } + } + } + """ + let data = Data(json.utf8) + #expect(ClaudeOAuthCredentials.isMcpOAuthOnlyPayload(data: data)) + } + @Test func `treats missing expiry as expired`() { let creds = ClaudeOAuthCredentials( @@ -81,6 +111,43 @@ struct ClaudeOAuthTests { #expect(snap.opus?.usedPercent == 5) #expect(snap.primary.resetsAt != nil) #expect(snap.loginMethod == "Claude Pro") + #expect(snap.oauthHistoryOwnerIdentifier?.count == 64) + } + + @Test + func `O auth profile request decodes nested account identity`() async throws { + let transport = ProviderHTTPTransportStub { request in + let url = try #require(request.url) + let response = try #require(HTTPURLResponse( + url: url, + statusCode: 200, + httpVersion: "HTTP/1.1", + headerFields: ["Content-Type": "application/json"])) + let body = """ + { + "account": { + "uuid": "account-123", + "email": "user@example.com" + }, + "organization": { + "uuid": "org-123" + } + } + """ + return (Data(body.utf8), response) + } + + let profile = try await ClaudeOAuthUsageFetcher.fetchProfile( + accessToken: "oauth-token", + transport: transport) + + #expect(profile.emailAddress == "user@example.com") + #expect(profile.organizationUuid == "org-123") + let request = try #require(await transport.requests().first) + #expect(request.url?.absoluteString == "https://api.anthropic.com/api/oauth/profile") + #expect(request.httpMethod == "GET") + #expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer oauth-token") + #expect(request.value(forHTTPHeaderField: "Accept") == "application/json") } @Test @@ -113,6 +180,81 @@ struct ClaudeOAuthTests { #expect(snap.extraRateWindows.first(where: { $0.id == "claude-routines" })?.window.usedPercent == 18) } + @Test + func `surfaces Fable scoped weekly limit from limits array`() throws { + // Real shape observed 2026-07-03 during Anthropic's Fable 5 promotional access + // window (up to 50% of the weekly limit on Fable 5): weekly caps have moved from + // flat seven_day_* fields (now null) to a `limits` array with `scope.model.display_name`. + let json = """ + { + "five_hour": { "utilization": 11.0, "resets_at": "2026-07-03T00:30:00.282668+00:00" }, + "seven_day": { "utilization": 9.0, "resets_at": "2026-07-08T09:00:00.282694+00:00" }, + "seven_day_opus": null, + "seven_day_sonnet": null, + "limits": [ + { + "kind": "session", "group": "session", "percent": 11, + "resets_at": "2026-07-03T00:30:00.282668+00:00", "scope": null, "is_active": true + }, + { + "kind": "weekly_all", "group": "weekly", "percent": 9, + "resets_at": "2026-07-08T09:00:00.282694+00:00", "scope": null, "is_active": false + }, + { + "kind": "weekly_scoped", "group": "weekly", "percent": 5, + "resets_at": "2026-07-08T09:00:00.283070+00:00", + "scope": { "model": { "id": null, "display_name": "Fable" }, "surface": null }, + "is_active": false + } + ] + } + """ + let snap = try ClaudeUsageFetcher._mapOAuthUsageForTesting(Data(json.utf8)) + let fable = snap.extraRateWindows.first(where: { $0.id == "claude-weekly-scoped-fable" }) + #expect(fable?.title == "Fable only") + #expect(fable?.window.usedPercent == 5) + #expect(fable?.window.resetsAt != nil) + } + + @Test + func `orders O auth scoped weekly windows before daily routines`() throws { + let json = """ + { + "five_hour": { "utilization": 12.5, "resets_at": "2025-12-25T12:00:00.000Z" }, + "seven_day": { "utilization": 30, "resets_at": "2025-12-31T00:00:00.000Z" }, + "seven_day_routines": { "utilization": 18, "resets_at": "2026-01-01T00:00:00.000Z" }, + "limits": [ + { + "kind": "weekly_scoped", "group": "weekly", "percent": 29, + "resets_at": "2025-12-31T00:00:00.000Z", + "scope": { "model": { "id": null, "display_name": "Fable" }, "surface": null }, + "is_active": false + } + ] + } + """ + let snap = try ClaudeUsageFetcher._mapOAuthUsageForTesting(Data(json.utf8)) + #expect(snap.extraRateWindows.map(\.title) == ["Fable only", "Daily Routines"]) + } + + @Test + func `ignores weekly scoped limit without a model display name`() throws { + let json = """ + { + "five_hour": { "utilization": 11.0, "resets_at": "2026-07-03T00:30:00.282668+00:00" }, + "limits": [ + { + "kind": "weekly_scoped", "group": "weekly", "percent": 5, + "resets_at": "2026-07-08T09:00:00.283070+00:00", + "scope": { "model": null, "surface": null }, "is_active": false + } + ] + } + """ + let snap = try ClaudeUsageFetcher._mapOAuthUsageForTesting(Data(json.utf8)) + #expect(snap.extraRateWindows.contains { $0.id.hasPrefix("claude-weekly-scoped-") } == false) + } + @Test func `ignores merged O auth omelette usage window`() throws { let json = """ @@ -129,7 +271,7 @@ struct ClaudeOAuthTests { } @Test - func `maps O auth null cowork as zero routines window`() throws { + func `omits routines window when O auth cowork is null`() throws { let json = """ { "five_hour": { "utilization": 12.5, "resets_at": "2025-12-25T12:00:00.000Z" }, @@ -138,7 +280,7 @@ struct ClaudeOAuthTests { } """ let snap = try ClaudeUsageFetcher._mapOAuthUsageForTesting(Data(json.utf8)) - #expect(snap.extraRateWindows.first(where: { $0.id == "claude-routines" })?.window.usedPercent == 0) + #expect(snap.extraRateWindows.contains { $0.id == "claude-routines" } == false) #expect(snap.extraRateWindows.contains { $0.id == "claude-design" } == false) } @@ -369,7 +511,7 @@ struct ClaudeOAuthTests { scopes: ["user:profile"], rateLimitTier: nil) } - let fetchOverride: (@Sendable (String) async throws -> OAuthUsageResponse)? = { _ in + let fetchOverride: (@Sendable (String, Bool) async throws -> OAuthUsageResponse)? = { _, _ in throw ClaudeOAuthFetchError.rateLimited(retryAfter: nil) } @@ -402,15 +544,174 @@ struct ClaudeOAuthTests { let now = Date(timeIntervalSince1970: 1_700_000_000) let retryAfter = now.addingTimeInterval(120) + let accountA = "test-auth-token" + let accountB = "test-token-placeholder" - #expect(ClaudeOAuthUsageRateLimitGate.currentBlockedUntil(now: now) == nil) - ClaudeOAuthUsageRateLimitGate.recordRateLimit(retryAfter: retryAfter, now: now) + #expect(ClaudeOAuthUsageRateLimitGate.currentBlockedUntil(accessToken: accountA, now: now) == nil) + ClaudeOAuthUsageRateLimitGate.recordRateLimit( + accessToken: accountA, + retryAfter: retryAfter, + now: now) - #expect(ClaudeOAuthUsageRateLimitGate.currentBlockedUntil(now: now) == retryAfter) - #expect(ClaudeOAuthUsageRateLimitGate.blockedUntil(interaction: .background, now: now) == retryAfter) - #expect(ClaudeOAuthUsageRateLimitGate.blockedUntil(interaction: .userInitiated, now: now) == nil) - #expect(ClaudeOAuthUsageRateLimitGate.currentBlockedUntil(now: now.addingTimeInterval(119)) != nil) - #expect(ClaudeOAuthUsageRateLimitGate.currentBlockedUntil(now: now.addingTimeInterval(121)) == nil) + #expect(ClaudeOAuthUsageRateLimitGate.currentBlockedUntil(accessToken: accountA, now: now) == retryAfter) + #expect(ClaudeOAuthUsageRateLimitGate.currentBlockedUntil(accessToken: accountB, now: now) == nil) + #expect( + ClaudeOAuthUsageRateLimitGate.blockedUntil( + accessToken: accountA, + interaction: .background, + now: now) == retryAfter) + #expect( + ClaudeOAuthUsageRateLimitGate.blockedUntil( + accessToken: accountA, + interaction: .userInitiated, + now: now) == nil) + #expect( + ClaudeOAuthUsageRateLimitGate.currentBlockedUntil( + accessToken: accountA, + now: now.addingTimeInterval(119)) != nil) + #expect( + ClaudeOAuthUsageRateLimitGate.currentBlockedUntil( + accessToken: accountA, + now: now.addingTimeInterval(121)) == nil) + } + + @Test + func `O auth cooldown storage is private and cleans stale entries`() { + ClaudeOAuthUsageRateLimitGate.resetForTesting() + defer { ClaudeOAuthUsageRateLimitGate.resetForTesting() } + + let now = Date(timeIntervalSince1970: 1_700_000_000) + let accessToken = "test-auth-token" + let preferenceName = ClaudeOAuthUsageRateLimitGate.storageKeyForTesting(accessToken: accessToken) + let prefix = "claudeOAuthUsageRateLimitBlockedUntilV2." + let legacyKey = "claudeOAuthUsageRateLimitBlockedUntilV1" + let expiredKey = prefix + "expired" + let malformedKey = prefix + "malformed" + UserDefaults.standard.set(now.addingTimeInterval(600).timeIntervalSince1970, forKey: legacyKey) + UserDefaults.standard.set(now.addingTimeInterval(-1).timeIntervalSince1970, forKey: expiredKey) + UserDefaults.standard.set("not-a-date", forKey: malformedKey) + + ClaudeOAuthUsageRateLimitGate.recordRateLimit( + accessToken: accessToken, + retryAfter: now.addingTimeInterval(120), + now: now) + + #expect(!preferenceName.contains(accessToken)) + #expect(String(preferenceName.dropFirst(prefix.count)).count == 64) + #expect(UserDefaults.standard.object(forKey: preferenceName) != nil) + #expect(UserDefaults.standard.object(forKey: legacyKey) == nil) + #expect(UserDefaults.standard.object(forKey: expiredKey) == nil) + #expect(UserDefaults.standard.object(forKey: malformedKey) == nil) + } + + @Test + func `concurrent O auth cooldown writes keep every account and latest deadline`() async { + ClaudeOAuthUsageRateLimitGate.resetForTesting() + defer { ClaudeOAuthUsageRateLimitGate.resetForTesting() } + + let now = Date(timeIntervalSince1970: 1_700_000_000) + let shortDeadline = now.addingTimeInterval(60) + let longDeadline = now.addingTimeInterval(600) + await withTaskGroup(of: Void.self) { group in + for index in 0..<100 { + group.addTask { + ClaudeOAuthUsageRateLimitGate.recordRateLimit( + accessToken: index.isMultiple(of: 2) ? "test-auth-token" : "test-token-placeholder", + retryAfter: index.isMultiple(of: 3) ? longDeadline : shortDeadline, + now: now) + } + } + } + + #expect( + ClaudeOAuthUsageRateLimitGate.currentBlockedUntil( + accessToken: "test-auth-token", + now: now) == longDeadline) + #expect( + ClaudeOAuthUsageRateLimitGate.currentBlockedUntil( + accessToken: "test-token-placeholder", + now: now) == longDeadline) + } + + @Test + func `O auth transport cooldown is isolated and user recovery clears one account`() async throws { + ClaudeOAuthUsageRateLimitGate.resetForTesting() + defer { ClaudeOAuthUsageRateLimitGate.resetForTesting() } + + let accountA = "test-auth-token" + let accountB = "test-token-placeholder" + let recorder = OAuthUsageTransportRecorder() + let transport = ProviderHTTPTransportHandler { request in + let authorization = request.value(forHTTPHeaderField: "Authorization") ?? "" + let bearerValue = authorization.replacingOccurrences(of: "Bearer ", with: "") + let statusCode = await recorder.nextStatusCode(token: bearerValue) + let body = statusCode == 200 + ? #"{"five_hour":{"utilization":12.5,"resets_at":"2026-07-09T18:00:00Z"}}"# + : #"{"type":"rate_limit_error"}"# + let requestURL = try #require(request.url) + let response = try #require(HTTPURLResponse( + url: requestURL, + statusCode: statusCode, + httpVersion: "HTTP/1.1", + headerFields: statusCode == 429 ? ["Retry-After": "300"] : nil)) + return (Data(body.utf8), response) + } + + do { + _ = try await ProviderInteractionContext.$current.withValue(.background) { + try await ClaudeOAuthUsageFetcher.fetchUsage( + accessToken: accountA, + detectClaudeVersion: false, + transport: transport) + } + Issue.record("Expected account A rate limit") + } catch let error as ClaudeOAuthFetchError { + guard case .rateLimited = error else { + Issue.record("Expected account A rate limit, got \(error)") + return + } + } + + do { + _ = try await ProviderInteractionContext.$current.withValue(.background) { + try await ClaudeOAuthUsageFetcher.fetchUsage( + accessToken: accountA, + detectClaudeVersion: false, + transport: transport) + } + Issue.record("Expected account A cooldown") + } catch let error as ClaudeOAuthFetchError { + guard case .rateLimited = error else { + Issue.record("Expected account A cooldown, got \(error)") + return + } + } + #expect(await recorder.requestCount(token: accountA) == 1) + + _ = try await ProviderInteractionContext.$current.withValue(.background) { + try await ClaudeOAuthUsageFetcher.fetchUsage( + accessToken: accountB, + detectClaudeVersion: false, + transport: transport) + } + #expect(await recorder.requestCount(token: accountB) == 1) + + _ = try await ProviderInteractionContext.$current.withValue(.userInitiated) { + try await ClaudeOAuthUsageFetcher.fetchUsage( + accessToken: accountA, + detectClaudeVersion: false, + transport: transport) + } + #expect(await recorder.requestCount(token: accountA) == 2) + #expect(ClaudeOAuthUsageRateLimitGate.currentBlockedUntil(accessToken: accountA) == nil) + + _ = try await ProviderInteractionContext.$current.withValue(.background) { + try await ClaudeOAuthUsageFetcher.fetchUsage( + accessToken: accountA, + detectClaudeVersion: false, + transport: transport) + } + #expect(await recorder.requestCount(token: accountA) == 3) } @Test @@ -451,6 +752,30 @@ struct ClaudeOAuthTests { #expect(ClaudeOAuthUsageFetcher._userAgentForTesting(versionString: nil) == "claude-code/2.1.0") } + @Test + func `oauth usage fallback user agent skips version detector`() { + var detectionCount = 0 + let fallback = ClaudeOAuthUsageFetcher._userAgentForTesting( + detectClaudeVersion: false, + versionDetector: { + detectionCount += 1 + return "2.1.70 (Claude Code)" + }) + + #expect(fallback == "claude-code/2.1.0") + #expect(detectionCount == 0) + + let detected = ClaudeOAuthUsageFetcher._userAgentForTesting( + detectClaudeVersion: true, + versionDetector: { + detectionCount += 1 + return "2.1.70 (Claude Code)" + }) + + #expect(detected == "claude-code/2.1.70") + #expect(detectionCount == 1) + } + @Test func `skips extra usage when disabled`() throws { let json = """ @@ -513,3 +838,17 @@ struct ClaudeOAuthTests { #expect(strategy.dataSource == .cli) } } + +private actor OAuthUsageTransportRecorder { + private var counts: [String: Int] = [:] + + func nextStatusCode(token: String) -> Int { + let count = (self.counts[token] ?? 0) + 1 + self.counts[token] = count + return token == "test-auth-token" && count == 1 ? 429 : 200 + } + + func requestCount(token: String) -> Int { + self.counts[token] ?? 0 + } +} diff --git a/Tests/CodexBarTests/ClaudePlanResolverTests.swift b/Tests/CodexBarTests/ClaudePlanResolverTests.swift index caeb2d17d1..f52c201e73 100644 --- a/Tests/CodexBarTests/ClaudePlanResolverTests.swift +++ b/Tests/CodexBarTests/ClaudePlanResolverTests.swift @@ -5,12 +5,28 @@ import Testing struct ClaudePlanResolverTests { @Test func `oauth rate limit tier maps to branded plan`() { - #expect(ClaudePlan.oauthLoginMethod(rateLimitTier: "default_claude_max_20x") == "Claude Max") #expect(ClaudePlan.oauthLoginMethod(rateLimitTier: "claude_pro") == "Claude Pro") #expect(ClaudePlan.oauthLoginMethod(rateLimitTier: "claude_team") == "Claude Team") #expect(ClaudePlan.oauthLoginMethod(rateLimitTier: "claude_enterprise") == "Claude Enterprise") } + @Test + func `oauth rate limit tier preserves the Max usage multiplier`() { + #expect(ClaudePlan.oauthLoginMethod(rateLimitTier: "default_claude_max_5x") == "Claude Max 5x") + #expect(ClaudePlan.oauthLoginMethod(rateLimitTier: "default_claude_max_20x") == "Claude Max 20x") + #expect(ClaudePlan.oauthLoginMethod(rateLimitTier: "v2_default_claude_max_20x") == "Claude Max 20x") + // A bare Max tier without a multiplier keeps the plain label. + #expect(ClaudePlan.oauthLoginMethod(rateLimitTier: "claude_max") == "Claude Max") + #expect(ClaudePlan.oauthLoginMethod(rateLimitTier: "default_claude_team_5x") == "Claude Team") + // A resolved non-Max plan never inherits a Max multiplier from a disagreeing tier. + #expect( + ClaudePlan.oauthLoginMethod(subscriptionType: "team", rateLimitTier: "default_claude_max_5x") + == "Claude Team") + #expect( + ClaudePlan.webLoginMethod(rateLimitTier: "default_claude_max_20x", billingType: nil) + == "Claude Max 20x") + } + @Test func `oauth subscription type overrides generic rate limit tier`() { #expect( @@ -31,6 +47,82 @@ struct ClaudePlanResolverTests { == "Claude Pro") } + @Test + func `web team seat tiers map to specific labels`() { + #expect( + ClaudePlan.webLoginMethod( + rateLimitTier: "claude_team", + billingType: "stripe_subscription", + seatTier: "team_standard") + == "Claude Team Standard") + #expect( + ClaudePlan.webLoginMethod( + rateLimitTier: "claude_team", + billingType: "stripe_subscription", + seatTier: "team_tier_1") + == "Claude Team Premium") + #expect( + ClaudePlan.webLoginMethod( + rateLimitTier: nil, + billingType: nil, + seatTier: "team_standard") + == "Claude Team Standard") + } + + @Test + func `web team seat tier near misses use existing plan inference`() { + #expect( + ClaudePlan.webLoginMethod( + rateLimitTier: "claude_team", + billingType: "stripe_subscription", + seatTier: "team_premium") + == "Claude Team") + #expect( + ClaudePlan.webLoginMethod( + rateLimitTier: "claude_team", + billingType: "stripe_subscription", + seatTier: "team_standard_plus") + == "Claude Team") + } + + @Test + func `web enterprise seat tiers preserve the enterprise label`() { + #expect( + ClaudePlan.webLoginMethod( + rateLimitTier: "claude_enterprise", + billingType: "stripe_subscription", + seatTier: "team_standard") + == "Claude Enterprise") + #expect( + ClaudePlan.webLoginMethod( + rateLimitTier: "claude_enterprise", + billingType: "stripe_subscription", + seatTier: "team_tier_1") + == "Claude Enterprise") + } + + @Test + func `missing web seat tier preserves existing plan labels`() { + #expect( + ClaudePlan.webLoginMethod( + rateLimitTier: "default_claude_max_20x", + billingType: nil, + seatTier: nil) + == "Claude Max 20x") + #expect( + ClaudePlan.webLoginMethod( + rateLimitTier: "claude_pro", + billingType: "stripe_subscription", + seatTier: nil) + == "Claude Pro") + #expect( + ClaudePlan.webLoginMethod( + rateLimitTier: "claude_team", + billingType: "stripe_subscription", + seatTier: nil) + == "Claude Team") + } + @Test func `compatibility parser understands current labels`() { #expect(ClaudePlan.fromCompatibilityLoginMethod("Claude Max") == .max) diff --git a/Tests/CodexBarTests/ClaudeProbeWorkingDirectoryTests.swift b/Tests/CodexBarTests/ClaudeProbeWorkingDirectoryTests.swift index 3a5f8724d9..722ce155b6 100644 --- a/Tests/CodexBarTests/ClaudeProbeWorkingDirectoryTests.swift +++ b/Tests/CodexBarTests/ClaudeProbeWorkingDirectoryTests.swift @@ -55,6 +55,90 @@ struct ClaudeProbeWorkingDirectoryTests { #expect(settings["disableDeepLinkRegistration"] as? String == "disable") } + @Test + func `probe project directory name matches Claude Code encoding`() { + let cases = [ + ( + "/Users/test/Library/Application Support/CodexBar/ClaudeProbe", + "-Users-test-Library-Application-Support-CodexBar-ClaudeProbe"), + ( + "/Users/test.name/t\u{00E9}st_under/Library/Application Support/CodexBar/ClaudeProbe", + "-Users-test-name-t-st-under-Library-Application-Support-CodexBar-ClaudeProbe"), + ( + "/Users/test/emoji_😀/ClaudeProbe", + "-Users-test-emoji----ClaudeProbe"), + ( + "/tmp/\(String(repeating: "segment_", count: 40))/ClaudeProbe", + "-tmp-segment-segment-segment-segment-segment-segment-segment-segment-segment-segment-segment-" + + "segment-segment-segment-segment-segment-segment-segment-segment-segment-segment-segment-" + + "segment-segment-seg-x9mpdi"), + ] + + for (path, expected) in cases { + let directory = URL(fileURLWithPath: path) + #expect(ClaudeProbeSessionArtifactCleaner.claudeProjectDirectoryName(for: directory) == expected) + } + } + + @Test + func `cleanup removes only probe session jsonl artifacts`() throws { + let probeDirectory = try Self.makeTemporaryDirectory() + let claudeRoot = try Self.makeTemporaryDirectory() + let projectsRoot = claudeRoot.appendingPathComponent("projects", isDirectory: true) + let probeProject = projectsRoot + .appendingPathComponent( + ClaudeProbeSessionArtifactCleaner.claudeProjectDirectoryName(for: probeDirectory), + isDirectory: true) + let unrelatedProject = projectsRoot.appendingPathComponent("unrelated-project", isDirectory: true) + try FileManager.default.createDirectory(at: probeProject, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: unrelatedProject, withIntermediateDirectories: true) + defer { + try? FileManager.default.removeItem(at: probeDirectory) + try? FileManager.default.removeItem(at: claudeRoot) + } + + let probeSession = probeProject.appendingPathComponent("probe-session.jsonl") + let probeNote = probeProject.appendingPathComponent("keep.txt") + let unrelatedSession = unrelatedProject.appendingPathComponent("user-session.jsonl") + try Data("{}\n".utf8).write(to: probeSession) + try Data("keep".utf8).write(to: probeNote) + try Data("{}\n".utf8).write(to: unrelatedSession) + + let removed = ClaudeProbeSessionArtifactCleaner.cleanupProbeSessionArtifacts( + probeDirectory: probeDirectory, + environment: ["CLAUDE_CONFIG_DIR": claudeRoot.path, "HOME": claudeRoot.path]) + + #expect(removed.map(\.lastPathComponent) == ["probe-session.jsonl"]) + #expect(!FileManager.default.fileExists(atPath: probeSession.path)) + #expect(FileManager.default.fileExists(atPath: probeNote.path)) + #expect(FileManager.default.fileExists(atPath: unrelatedSession.path)) + } + + @Test + func `cleanup removes hashed long probe project artifacts`() throws { + let probeDirectory = URL(fileURLWithPath: "/tmp/\(String(repeating: "segment_", count: 40))/ClaudeProbe") + let claudeRoot = try Self.makeTemporaryDirectory() + let projectsRoot = claudeRoot.appendingPathComponent("projects", isDirectory: true) + let probeProject = projectsRoot + .appendingPathComponent( + ClaudeProbeSessionArtifactCleaner.claudeProjectDirectoryName(for: probeDirectory), + isDirectory: true) + try FileManager.default.createDirectory(at: probeProject, withIntermediateDirectories: true) + defer { + try? FileManager.default.removeItem(at: claudeRoot) + } + + let probeSession = probeProject.appendingPathComponent("probe-session.jsonl") + try Data("{}\n".utf8).write(to: probeSession) + + let removed = ClaudeProbeSessionArtifactCleaner.cleanupProbeSessionArtifacts( + probeDirectory: probeDirectory, + environment: ["CLAUDE_CONFIG_DIR": claudeRoot.path, "HOME": claudeRoot.path]) + + #expect(removed.map(\.lastPathComponent) == ["probe-session.jsonl"]) + #expect(!FileManager.default.fileExists(atPath: probeSession.path)) + } + private static func makeTemporaryDirectory() throws -> URL { let directory = FileManager.default.temporaryDirectory .appendingPathComponent("codexbar-claude-probe-\(UUID().uuidString)", isDirectory: true) diff --git a/Tests/CodexBarTests/ClaudeProviderImplementationTests.swift b/Tests/CodexBarTests/ClaudeProviderImplementationTests.swift new file mode 100644 index 0000000000..59ae424a62 --- /dev/null +++ b/Tests/CodexBarTests/ClaudeProviderImplementationTests.swift @@ -0,0 +1,123 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +@MainActor +struct ClaudeProviderImplementationTests { + @Test + func `prepaid balance respects optional usage setting`() throws { + let now = Date(timeIntervalSince1970: 0) + let snapshot = UsageSnapshot( + primary: nil, + secondary: RateWindow( + usedPercent: 20, + windowMinutes: 10080, + resetsAt: nil, + resetDescription: nil), + providerCost: ProviderCostSnapshot( + used: 5, + limit: 20, + currencyCode: "USD", + period: "Monthly cap", + balance: 100, + updatedAt: now), + updatedAt: now) + + var hiddenEntries: [ProviderMenuEntry] = [] + try ClaudeProviderImplementation().appendUsageMenuEntries( + context: Self.context(snapshot: snapshot, showOptionalUsage: false), + entries: &hiddenEntries) + #expect(hiddenEntries.isEmpty) + + var visibleEntries: [ProviderMenuEntry] = [] + try ClaudeProviderImplementation().appendUsageMenuEntries( + context: Self.context(snapshot: snapshot, showOptionalUsage: true), + entries: &visibleEntries) + + guard case let .text(extraUsageTitle, extraUsageStyle) = try #require(visibleEntries.first), + case let .text(balanceTitle, balanceStyle) = try #require(visibleEntries.last) + else { + Issue.record("Expected Claude extra usage and prepaid balance menu text") + return + } + #expect(extraUsageTitle == "Extra usage: $5.00 / $20.00") + #expect(extraUsageStyle == .primary) + #expect(balanceTitle == "Balance: $100.00") + #expect(balanceStyle == .primary) + #expect(visibleEntries.count == 2) + } + + @Test + func `prepaid balance without monthly cap stays a compact credits entry`() throws { + let now = Date(timeIntervalSince1970: 0) + let snapshot = UsageSnapshot( + primary: nil, + secondary: RateWindow( + usedPercent: 20, + windowMinutes: 10080, + resetsAt: nil, + resetDescription: nil), + providerCost: ProviderCostSnapshot( + used: 0, + limit: 0, + currencyCode: "USD", + period: "Usage credits", + balance: 100, + updatedAt: now), + updatedAt: now) + + var entries: [ProviderMenuEntry] = [] + try ClaudeProviderImplementation().appendUsageMenuEntries( + context: Self.context(snapshot: snapshot, showOptionalUsage: true), + entries: &entries) + + guard case let .text(title, style) = try #require(entries.first) else { + Issue.record("Expected Claude prepaid balance menu text") + return + } + #expect(title == "Credits: $100.00") + #expect(style == .primary) + #expect(entries.count == 1) + } + + private static func context( + snapshot: UsageSnapshot, + showOptionalUsage: Bool) throws -> ProviderMenuUsageContext + { + let suite = "ClaudeProviderImplementationTests-\(UUID().uuidString)" + let defaults = try #require(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(), + augmentCookieStore: InMemoryCookieHeaderStore(), + ampCookieStore: InMemoryCookieHeaderStore(), + copilotTokenStore: InMemoryCopilotTokenStore(), + tokenAccountStore: InMemoryTokenAccountStore()) + settings.showOptionalCreditsAndExtraUsage = showOptionalUsage + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + startupBehavior: .testing, + environmentBase: [:]) + + return ProviderMenuUsageContext( + provider: .claude, + store: store, + settings: settings, + metadata: ClaudeProviderDescriptor.descriptor.metadata, + snapshot: snapshot) + } +} diff --git a/Tests/CodexBarTests/ClaudeProviderRuntimeTests.swift b/Tests/CodexBarTests/ClaudeProviderRuntimeTests.swift new file mode 100644 index 0000000000..f9c35ca98f --- /dev/null +++ b/Tests/CodexBarTests/ClaudeProviderRuntimeTests.swift @@ -0,0 +1,259 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +@MainActor +struct ClaudeProviderRuntimeTests { + @Test + func `disabling adapter immediately clears retained accounts`() { + let (settings, store) = self.makeStore() + store.claudeSwapAccountSnapshots = [self.accountSnapshot()] + store.claudeSwapLastRefreshAt = Date() + store.claudeSwapLastError = "stale" + let runtime = ClaudeProviderRuntime() + + runtime.settingsDidChange(context: ProviderRuntimeContext(provider: .claude, settings: settings, store: store)) + + #expect(store.claudeSwapAccountSnapshots.isEmpty) + #expect(store.claudeSwapLastRefreshAt == nil) + #expect(store.claudeSwapLastError == nil) + } + + @Test + func `disabled Claude provider does not restart adapter`() { + let (settings, store) = self.makeStore() + settings.claudeSwapExecutablePath = "/path/to/cswap" + settings.claudeSwapEnabled = true + let runtime = ClaudeProviderRuntime() + let context = ProviderRuntimeContext(provider: .claude, settings: settings, store: store) + + runtime.stop(context: context) + runtime.settingsDidChange(context: context) + + #expect(!store.isEnabled(.claude)) + #expect(store.claudeSwapRefreshTask == nil) + } + + @Test + func `late adapter result is rejected after executable path changes`() async throws { + let (settings, store) = self.makeStore() + let executable = try self.makeFakeExecutable() + let metadata = try #require(ProviderRegistry.shared.metadata[.claude]) + settings.setProviderEnabled(provider: .claude, metadata: metadata, enabled: true) + settings.claudeSwapExecutablePath = executable + settings.claudeSwapEnabled = true + + let refresh = Task { @MainActor in + await store.refreshClaudeSwapAccounts() + } + try await Task.sleep(for: .milliseconds(100)) + settings.claudeSwapExecutablePath = "/new/path/to/cswap" + await refresh.value + + #expect(store.claudeSwapAccountSnapshots.isEmpty) + #expect(store.claudeSwapLastRefreshAt == nil) + } + + @Test + func `explicit account activation is serialized through claude swap and refreshes Claude`() async throws { + let (settings, store) = self.makeStore() + let marker = FileManager.default.temporaryDirectory + .appendingPathComponent("claude-swap-switch-args-\(UUID().uuidString)") + let executable = try self.makeSwitchExecutable(marker: marker) + let metadata = try #require(ProviderRegistry.shared.metadata[.claude]) + settings.setProviderEnabled(provider: .claude, metadata: metadata, enabled: true) + settings.claudeSwapExecutablePath = executable + settings.claudeSwapEnabled = true + let accountID = ProviderAccountIdentity(source: ClaudeSwapAccountProjection.sourceName, opaqueID: "2") + store.claudeSwapAccountSnapshots = [ProviderAccountUsageSnapshot( + id: accountID, + provider: .claude, + displayLabel: "switch@example.com", + isActive: false, + canActivate: true, + snapshot: nil, + error: nil, + sourceLabel: ClaudeSwapAccountProjection.sourceLabel)] + var refreshedProviders: [UsageProvider] = [] + store._test_providerRefreshOverride = { refreshedProviders.append($0) } + defer { store._test_providerRefreshOverride = nil } + + store.switchClaudeSwapAccount(accountID) + let task = try #require(store.claudeSwapTransientState.task) + await task.value + + let arguments = try String(contentsOf: marker, encoding: .utf8) + #expect(arguments == "--switch-to\n2\n--json\n") + #expect(refreshedProviders == [.claude]) + #expect(store.claudeSwapTransientState.task == nil) + #expect(store.claudeSwapTransientState.switchingAccountID == nil) + #expect(store.claudeSwapTransientState.lastError == nil) + #expect(store.claudeSwapTransientState.lastErrorAccountID == nil) + } + + @Test + func `non actionable account cannot start credential transaction`() throws { + let (settings, store) = self.makeStore() + let metadata = try #require(ProviderRegistry.shared.metadata[.claude]) + settings.setProviderEnabled(provider: .claude, metadata: metadata, enabled: true) + settings.claudeSwapExecutablePath = "/path/to/cswap" + settings.claudeSwapEnabled = true + let accountID = ProviderAccountIdentity(source: ClaudeSwapAccountProjection.sourceName, opaqueID: "2") + store.claudeSwapAccountSnapshots = [ProviderAccountUsageSnapshot( + id: accountID, + provider: .claude, + displayLabel: "expired@example.com", + isActive: false, + canActivate: false, + snapshot: nil, + error: "Token expired", + sourceLabel: ClaudeSwapAccountProjection.sourceLabel)] + + store.switchClaudeSwapAccount(accountID) + + #expect(store.claudeSwapTransientState.task == nil) + } + + @Test + func `failed activation stays scoped to its requested account`() async throws { + let (settings, store) = self.makeStore() + let executable = try self.makeFailedSwitchExecutable() + let metadata = try #require(ProviderRegistry.shared.metadata[.claude]) + settings.setProviderEnabled(provider: .claude, metadata: metadata, enabled: true) + settings.claudeSwapExecutablePath = executable + settings.claudeSwapEnabled = true + let accountID = ProviderAccountIdentity(source: ClaudeSwapAccountProjection.sourceName, opaqueID: "2") + store.claudeSwapAccountSnapshots = [ProviderAccountUsageSnapshot( + id: accountID, + provider: .claude, + displayLabel: "switch@example.com", + isActive: false, + canActivate: true, + snapshot: nil, + error: nil, + sourceLabel: ClaudeSwapAccountProjection.sourceLabel)] + store._test_providerRefreshOverride = { _ in } + defer { store._test_providerRefreshOverride = nil } + + store.switchClaudeSwapAccount(accountID) + let task = try #require(store.claudeSwapTransientState.task) + await task.value + + #expect(store.claudeSwapTransientState.lastError?.contains("credentials missing") == true) + #expect(store.claudeSwapTransientState.lastErrorAccountID == accountID) + } + + @Test + func `configuration change during provider refresh discards switch result`() async throws { + let (settings, store) = self.makeStore() + let executable = try self.makeFailedSwitchExecutable() + let metadata = try #require(ProviderRegistry.shared.metadata[.claude]) + settings.setProviderEnabled(provider: .claude, metadata: metadata, enabled: true) + settings.claudeSwapExecutablePath = executable + settings.claudeSwapEnabled = true + let accountID = ProviderAccountIdentity(source: ClaudeSwapAccountProjection.sourceName, opaqueID: "2") + store.claudeSwapAccountSnapshots = [ProviderAccountUsageSnapshot( + id: accountID, + provider: .claude, + displayLabel: "switch@example.com", + isActive: false, + canActivate: true, + snapshot: nil, + error: nil, + sourceLabel: ClaudeSwapAccountProjection.sourceLabel)] + store._test_providerRefreshOverride = { _ in + settings.claudeSwapExecutablePath = "/new/path/to/cswap" + } + defer { store._test_providerRefreshOverride = nil } + + store.switchClaudeSwapAccount(accountID) + let task = try #require(store.claudeSwapTransientState.task) + await task.value + + #expect(store.claudeSwapTransientState.task == nil) + #expect(store.claudeSwapTransientState.switchingAccountID == nil) + #expect(store.claudeSwapTransientState.lastError == nil) + #expect(store.claudeSwapTransientState.lastErrorAccountID == nil) + } + + private func makeStore() -> (SettingsStore, UsageStore) { + let suite = "ClaudeProviderRuntimeTests-\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suite)! + defaults.removePersistentDomain(forName: suite) + let settings = SettingsStore( + userDefaults: defaults, + configStore: testConfigStore(suiteName: suite), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + let store = UsageStore( + fetcher: UsageFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings) + return (settings, store) + } + + private func accountSnapshot() -> ProviderAccountUsageSnapshot { + ProviderAccountUsageSnapshot( + id: ProviderAccountIdentity(source: ClaudeSwapAccountProjection.sourceName, opaqueID: "1"), + provider: .claude, + displayLabel: "account@example.com", + isActive: false, + snapshot: nil, + error: "Token expired", + sourceLabel: ClaudeSwapAccountProjection.sourceLabel) + } + + private func makeFakeExecutable() throws -> String { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("claude-runtime-tests-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let url = directory.appendingPathComponent("cswap") + let script = """ + #!/bin/sh + if [ "$1" = "--version" ]; then + echo 'cswap 0.16.0' + exit 0 + fi + sleep 0.3 + cat <<'EOF' + {"schemaVersion":1,"activeAccountNumber":1,"accounts":[ + {"number":1,"email":"a@b.c","active":true,"usageStatus":"ok","usage":{"fiveHour":{"pct":12.5}}} + ]} + EOF + """ + try script.write(to: url, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: url.path) + return url.path + } + + private func makeSwitchExecutable(marker: URL) throws -> String { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("claude-switch-runtime-tests-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let url = directory.appendingPathComponent("cswap") + let script = """ + #!/bin/sh + printf '%s\n' "$@" > '\(marker.path)' + echo '{"schemaVersion":1,"switched":true,"from":{"number":1},"to":{"number":2},"reason":"switched"}' + """ + try script.write(to: url, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: url.path) + return url.path + } + + private func makeFailedSwitchExecutable() throws -> String { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("claude-failed-switch-runtime-tests-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let url = directory.appendingPathComponent("cswap") + let script = """ + #!/bin/sh + echo '{"schemaVersion":1,"error":{"type":"SwitchError","message":"credentials missing"}}' + exit 1 + """ + try script.write(to: url, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: url.path) + return url.path + } +} diff --git a/Tests/CodexBarTests/ClaudeResetJSONParserTests.swift b/Tests/CodexBarTests/ClaudeResetJSONParserTests.swift new file mode 100644 index 0000000000..8a791c0890 --- /dev/null +++ b/Tests/CodexBarTests/ClaudeResetJSONParserTests.swift @@ -0,0 +1,55 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct ClaudeResetJSONParserTests { + @Test + func `usage JSON parser applies quota horizons from one clock`() throws { + let reset = "Jul 9 at 6am (UTC)" + let json = """ + { + "ok": true, + "session_5h": { "pct_used": 1, "resets": "\(reset)" }, + "week_all_models": { "pct_used": 2, "resets": "\(reset)" }, + "week_sonnet": { "pct_used": 3, "resets": "\(reset)" } + } + """ + let now = try Self.isoDate("2026-07-09T12:00:00Z") + let snapshot = try #require(ClaudeUsageFetcher.parse(json: Data(json.utf8), now: now)) + let futureReset = try Self.isoDate("2027-07-09T06:00:00Z") + let recentReset = try Self.isoDate("2026-07-09T06:00:00Z") + + #expect(snapshot.primary.resetsAt == futureReset) + #expect(snapshot.secondary?.resetsAt == recentReset) + #expect(snapshot.opus?.resetsAt == recentReset) + #expect(snapshot.updatedAt == now) + } + + @Test + func `usage JSON parser supports explicit years and preserves malformed reset text`() throws { + let explicitReset = "Jan 2, 2026, 10:59pm (Europe/Helsinki)" + let malformedReset = "after the next billing sync" + let json = """ + { + "ok": true, + "session_5h": { "pct_used": 1, "resets": "\(explicitReset)" }, + "week_all_models": { "pct_used": 2, "resets": "\(malformedReset)" } + } + """ + let now = try Self.isoDate("2025-01-01T00:00:00Z") + let snapshot = try #require(ClaudeUsageFetcher.parse( + json: Data(json.utf8), + now: now)) + let expectedReset = try Self.isoDate("2026-01-02T20:59:00Z") + + #expect(snapshot.primary.resetsAt == expectedReset) + #expect(snapshot.primary.resetDescription == explicitReset) + #expect(snapshot.secondary?.resetsAt == nil) + #expect(snapshot.secondary?.resetDescription == malformedReset) + } + + private static func isoDate(_ text: String) throws -> Date { + let formatter = ISO8601DateFormatter() + return try #require(formatter.date(from: text)) + } +} diff --git a/Tests/CodexBarTests/ClaudeResetOccurrenceTests.swift b/Tests/CodexBarTests/ClaudeResetOccurrenceTests.swift new file mode 100644 index 0000000000..80ef1754ff --- /dev/null +++ b/Tests/CodexBarTests/ClaudeResetOccurrenceTests.swift @@ -0,0 +1,122 @@ +import CodexBarCore +import Foundation +import Testing + +struct ClaudeResetOccurrenceTests { + @Test + func `parser preserves both repeated daylight saving times`() throws { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = try #require(TimeZone(identifier: "America/New_York")) + let startOfDay = try #require(calendar.date(from: DateComponents( + year: 2026, month: 11, day: 1, hour: 0))) + let searchStart = try #require(calendar.date(byAdding: .second, value: -1, to: startOfDay)) + let matching = DateComponents(hour: 1, minute: 30, second: 0) + let first = try #require(calendar.nextDate( + after: searchStart, + matching: matching, + matchingPolicy: .strict, + repeatedTimePolicy: .first, + direction: .forward)) + let second = try #require(calendar.nextDate( + after: searchStart, + matching: matching, + matchingPolicy: .strict, + repeatedTimePolicy: .last, + direction: .forward)) + let tomorrow = try #require(calendar.date(from: DateComponents( + year: 2026, month: 11, day: 2, hour: 1, minute: 30))) + + let timeOnlyCases = [ + (now: first.addingTimeInterval(-60), expected: first), + (now: first.addingTimeInterval(30 * 60), expected: second), + (now: second.addingTimeInterval(60), expected: tomorrow), + ] + for item in timeOnlyCases { + let parsed = ClaudeStatusProbe.parseResetDate( + from: "Resets 1:30am (America/New_York)", + now: item.now) + #expect(parsed == item.expected) + } + + let betweenOccurrences = first.addingTimeInterval(30 * 60) + #expect(ClaudeStatusProbe.parseResetDate( + from: "Resets Nov 1, 1:30am (America/New_York)", + now: betweenOccurrences) == second) + #expect(ClaudeStatusProbe.parseResetDate( + from: "Resets Nov 1, 1:30am (America/New_York)", + now: second.addingTimeInterval(60), + expectedWindow: 7 * 24 * 60 * 60) == second) + #expect(ClaudeStatusProbe.parseResetDate( + from: "Resets Nov 1, 2026, 1:30am (America/New_York)", + now: betweenOccurrences) == second) + #expect(ClaudeStatusProbe.parseResetDate( + from: "Resets Nov 1, 2026, 1:30am (America/New_York)", + now: second.addingTimeInterval(60)) == second) + } + + @Test + func `parser searches across leap years`() throws { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = try #require(TimeZone(identifier: "UTC")) + let leapReset = try #require(calendar.date(from: DateComponents( + year: 2028, month: 2, day: 29, hour: 9))) + let futureCases = [ + DateComponents(year: 2025, month: 1, day: 1), + DateComponents(year: 2024, month: 3, day: 1), + ] + for nowComponents in futureCases { + let now = try #require(calendar.date(from: nowComponents)) + #expect(ClaudeStatusProbe.parseResetDate( + from: "Resets Feb 29, 9am (UTC)", + now: now) == leapReset) + } + + let currentLeapReset = try #require(calendar.date(from: DateComponents( + year: 2024, month: 2, day: 29, hour: 9))) + let shortlyAfter = try #require(calendar.date(from: DateComponents( + year: 2024, month: 2, day: 29, hour: 10))) + #expect(ClaudeStatusProbe.parseResetDate( + from: "Resets Feb 29, 9am (UTC)", + now: shortlyAfter, + expectedWindow: 7 * 24 * 60 * 60) == currentLeapReset) + } + + @Test + func `parser keeps explicit years authoritative across supported time forms`() throws { + let now = try Self.isoDate("2025-01-01T00:00:00Z") + let cases = [ + ( + text: "Resets Jan 2, 2026, 10:59pm (Europe/Helsinki)", + expected: "2026-01-02T20:59:00Z"), + (text: "Resets Jan 2 2026 10pm (UTC)", expected: "2026-01-02T22:00:00Z"), + (text: "Resets Jan 2, 2026, 22:15 (UTC)", expected: "2026-01-02T22:15:00Z"), + (text: "Resets Jan 2, 2026, 22 (UTC)", expected: "2026-01-02T22:00:00Z"), + ] + + for item in cases { + let expected = try Self.isoDate(item.expected) + #expect(ClaudeStatusProbe.parseResetDate( + from: item.text, + now: now) == expected) + } + + let afterStatedYear = try Self.isoDate("2027-01-01T00:00:00Z") + let statedReset = try Self.isoDate(cases[0].expected) + #expect(ClaudeStatusProbe.parseResetDate( + from: cases[0].text, + now: afterStatedYear) == statedReset) + } + + @Test + func `parser rejects nonexistent explicit local time`() throws { + let now = try Self.isoDate("2026-01-01T00:00:00Z") + #expect(ClaudeStatusProbe.parseResetDate( + from: "Resets Mar 8, 2026, 2:30am (America/New_York)", + now: now) == nil) + } + + private static func isoDate(_ text: String) throws -> Date { + let formatter = ISO8601DateFormatter() + return try #require(formatter.date(from: text)) + } +} diff --git a/Tests/CodexBarTests/ClaudeResilienceTests.swift b/Tests/CodexBarTests/ClaudeResilienceTests.swift index aa2c851d29..83a817ffc9 100644 --- a/Tests/CodexBarTests/ClaudeResilienceTests.swift +++ b/Tests/CodexBarTests/ClaudeResilienceTests.swift @@ -4,6 +4,174 @@ import Testing @testable import CodexBarCore struct ClaudeResilienceTests { + @Test + func `cancelled Claude refresh never publishes an error`() 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 { + let settings = Self.makeSettingsStore(suite: "ClaudeResilienceTests-cancellation") + settings.refreshFrequency = .manual + settings.statusChecksEnabled = false + settings.claudeUsageDataSource = .cli + settings.claudeOAuthKeychainPromptMode = .never + + 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: [:]) + 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: [.cli], + pipeline: ProviderFetchPipeline { _ in [CancellationFetchStrategy()] }), + cli: baseSpec.descriptor.cli) + store.providerSpecs[.claude] = ProviderSpec( + style: baseSpec.style, + isEnabled: baseSpec.isEnabled, + descriptor: descriptor, + makeFetchContext: baseSpec.makeFetchContext) + return store + } + + 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 == nil) + } + } + } + + @Test + func `superseded credential change clears prior Claude state after cancellation`() async throws { + try await KeychainCacheStore.withServiceOverrideForTesting("com.steipete.codexbar.cache.tests.\(UUID())") { + KeychainCacheStore.setTestStoreForTesting(true) + defer { KeychainCacheStore.setTestStoreForTesting(false) } + + 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("credentials.json") + try Data("{}".utf8).write(to: fileURL) + + try await ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(fileURL) { + #expect(ClaudeOAuthCredentialsStore.invalidateCacheIfCredentialsFileChanged()) + let cancellations = CredentialSwapCancellationSequence(credentialsFileURL: fileURL) + + let store = try await MainActor.run { + let settings = Self.makeSettingsStore(suite: "ClaudeResilienceTests-cancelled-auth-change") + settings.refreshFrequency = .manual + settings.statusChecksEnabled = false + settings.claudeUsageDataSource = .cli + settings.claudeOAuthKeychainPromptMode = .never + + 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: [:]) + store._setSnapshotForTesting( + UsageSnapshot( + primary: RateWindow( + usedPercent: 12, + windowMinutes: 300, + resetsAt: nil, + resetDescription: nil), + secondary: nil, + updatedAt: Date(timeIntervalSince1970: 1_800_000_000), + identity: ProviderIdentitySnapshot( + providerID: .claude, + accountEmail: "old@example.com", + accountOrganization: nil, + loginMethod: "Pro")), + provider: .claude) + store._setTokenSnapshotForTesting( + CostUsageTokenSnapshot( + sessionTokens: 4200, + sessionCostUSD: 1.25, + last30DaysTokens: 42000, + last30DaysCostUSD: 12.50, + daily: [], + updatedAt: Date(timeIntervalSince1970: 1_800_000_001)), + 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: [.cli], + pipeline: ProviderFetchPipeline { _ in + [CancellationAfterCredentialSwapFetchStrategy(cancellations: cancellations)] + }), + cli: baseSpec.descriptor.cli) + store.providerSpecs[.claude] = ProviderSpec( + style: baseSpec.style, + isEnabled: baseSpec.isEnabled, + descriptor: descriptor, + makeFetchContext: baseSpec.makeFetchContext) + return store + } + + let olderRefresh = Task { + await store.refreshProvider(.claude) + } + await cancellations.waitUntilStarted(count: 1) + let newerRefresh = Task { + await store.refreshProvider(.claude) + } + await newerRefresh.value + await olderRefresh.value + let result = await MainActor.run { + ( + hasSnapshot: store.snapshot(for: .claude) != nil, + hasTokenSnapshot: store.tokenSnapshot(for: .claude) != nil, + error: store.error(for: .claude)) + } + + #expect(!result.hasSnapshot) + #expect(!result.hasTokenSnapshot) + #expect(result.error == nil) + } + } + } + } + @Test func `suppresses single flake when prior data exists`() { var gate = ConsecutiveFailureGate() @@ -114,6 +282,123 @@ struct ClaudeResilienceTests { } } + @Test + func `CLI parse failures keep prior Claude snapshot but authentication loss clears it`() 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, prior) = try await MainActor.run { + let settings = Self.makeSettingsStore(suite: "ClaudeResilienceTests-cli-parse-cache") + settings.refreshFrequency = .manual + settings.statusChecksEnabled = false + settings.claudeUsageDataSource = .cli + + 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: [:]) + let prior = 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: "Max")) + 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: [.cli], + pipeline: ProviderFetchPipeline { _ in + [CLIParseFailureFetchStrategy(message: "Missing Current session.")] + }), + cli: baseSpec.descriptor.cli) + store.providerSpecs[.claude] = ProviderSpec( + style: baseSpec.style, + isEnabled: baseSpec.isEnabled, + descriptor: descriptor, + makeFetchContext: baseSpec.makeFetchContext) + return (store, 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?.localizedCaseInsensitiveContains("Missing Current session") == true) + + try await MainActor.run { + 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: [.cli], + pipeline: ProviderFetchPipeline { _ in + [CLIAuthenticationFailureFetchStrategy()] + }), + cli: baseSpec.descriptor.cli) + store.providerSpecs[.claude] = ProviderSpec( + style: baseSpec.style, + isEnabled: baseSpec.isEnabled, + descriptor: descriptor, + makeFetchContext: baseSpec.makeFetchContext) + } + + await store.refreshProvider(.claude) + let authenticationResult = await MainActor.run { + ( + hasSnapshot: store.snapshot(for: .claude) != nil, + error: store.error(for: .claude)) + } + + #expect(!authenticationResult.hasSnapshot) + #expect(authenticationResult.error?.localizedCaseInsensitiveContains("token expired") == true) + } + } + } + @Test func `repeated non probe transient failure still surfaces`() async throws { try await ClaudeOAuthCredentialsStore.withIsolatedCredentialsFileTrackingForTesting { @@ -1008,7 +1293,6 @@ extension ClaudeResilienceTests { minimaxCookieStore: InMemoryMiniMaxCookieStore(), minimaxAPITokenStore: InMemoryMiniMaxAPITokenStore(), kimiTokenStore: InMemoryKimiTokenStore(), - kimiK2TokenStore: InMemoryKimiK2TokenStore(), augmentCookieStore: InMemoryCookieHeaderStore(), ampCookieStore: InMemoryCookieHeaderStore(), copilotTokenStore: InMemoryCopilotTokenStore(), @@ -1035,6 +1319,80 @@ private struct TimeoutFetchStrategy: ProviderFetchStrategy { } } +private struct CancellationFetchStrategy: ProviderFetchStrategy { + let id = "test.cancellation" + let kind: ProviderFetchKind = .cli + + func isAvailable(_: ProviderFetchContext) async -> Bool { + true + } + + func fetch(_: ProviderFetchContext) async throws -> ProviderFetchResult { + throw CancellationError() + } + + func shouldFallback(on _: Error, context _: ProviderFetchContext) -> Bool { + false + } +} + +private struct CancellationAfterCredentialSwapFetchStrategy: ProviderFetchStrategy { + let id = "test.cancelled-credential-swap" + let kind: ProviderFetchKind = .cli + let cancellations: CredentialSwapCancellationSequence + + func isAvailable(_: ProviderFetchContext) async -> Bool { + true + } + + func fetch(_: ProviderFetchContext) async throws -> ProviderFetchResult { + try await self.cancellations.fetch() + } + + func shouldFallback(on _: Error, context _: ProviderFetchContext) -> Bool { + false + } +} + +private actor CredentialSwapCancellationSequence { + private struct StartWaiter { + let count: Int + let continuation: CheckedContinuation + } + + private let credentialsFileURL: URL + private var starts = 0 + private var startWaiters: [StartWaiter] = [] + + init(credentialsFileURL: URL) { + self.credentialsFileURL = credentialsFileURL + } + + func fetch() async throws -> ProviderFetchResult { + self.starts += 1 + let call = self.starts + self.resumeReadyStartWaiters() + if call == 1 { + try Data("{\"updated\":true}".utf8).write(to: self.credentialsFileURL) + try await Task.sleep(for: .seconds(60)) + } + throw CancellationError() + } + + func waitUntilStarted(count: Int) async { + guard self.starts < count else { return } + await withCheckedContinuation { continuation in + self.startWaiters.append(StartWaiter(count: count, continuation: continuation)) + } + } + + private func resumeReadyStartWaiters() { + let ready = self.startWaiters.filter { $0.count <= self.starts } + self.startWaiters.removeAll { $0.count <= self.starts } + ready.forEach { $0.continuation.resume() } + } +} + private struct NetworkLostFetchStrategy: ProviderFetchStrategy { let id = "test.network-lost" let kind: ProviderFetchKind = .cli @@ -1052,6 +1410,49 @@ private struct NetworkLostFetchStrategy: ProviderFetchStrategy { } } +private struct CLIParseFailureFetchStrategy: ProviderFetchStrategy { + let id = "test.cli-parse-failure" + let kind: ProviderFetchKind = .cli + let message: String + + func isAvailable(_: ProviderFetchContext) async -> Bool { + true + } + + func fetch(_: ProviderFetchContext) async throws -> ProviderFetchResult { + throw ClaudeStatusProbeError.parseFailed(self.message) + } + + func shouldFallback(on _: Error, context _: ProviderFetchContext) -> Bool { + false + } +} + +private struct CLIAuthenticationFailureFetchStrategy: ProviderFetchStrategy { + let id = "test.cli-authentication-failure" + let kind: ProviderFetchKind = .cli + + func isAvailable(_: ProviderFetchContext) async -> Bool { + true + } + + func fetch(_: ProviderFetchContext) async throws -> ProviderFetchResult { + do { + _ = try ClaudeStatusProbe.parse(text: """ + Error: Failed to load usage data: {"error":{"type":"error",\ + "message":"Claude CLI token expired. Run `claude login` to refresh."}} + """) + } catch { + throw error + } + throw ClaudeStatusProbeError.parseFailed("Expected authentication error") + } + + func shouldFallback(on _: Error, context _: ProviderFetchContext) -> Bool { + false + } +} + private struct AuthFailureFetchStrategy: ProviderFetchStrategy { let id = "test.auth-failure" let kind: ProviderFetchKind = .cli diff --git a/Tests/CodexBarTests/ClaudeScopedWeeklyLimitMapperTests.swift b/Tests/CodexBarTests/ClaudeScopedWeeklyLimitMapperTests.swift new file mode 100644 index 0000000000..b35c6fb1dd --- /dev/null +++ b/Tests/CodexBarTests/ClaudeScopedWeeklyLimitMapperTests.swift @@ -0,0 +1,78 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct ClaudeScopedWeeklyLimitMapperTests { + @Test + func `model id provides a stable safe identifier and duplicate limits collapse`() throws { + let reset = Date(timeIntervalSince1970: 1_783_507_200) + let limits = [ + Self.limit(modelID: "claude/fable.5:promo", modelName: "Fable", resetsAt: reset), + Self.limit(modelID: "claude/fable.5:promo", modelName: "Fable renamed", resetsAt: reset), + ] + + let windows = ClaudeScopedWeeklyLimitMapper.extraRateWindows( + from: limits, + resetDescription: { _ in "Jul 8" }) + let window = try #require(windows.first) + + #expect(windows.count == 1) + #expect(window.id == "claude-weekly-scoped-claude-fable-5-promo") + #expect(window.title == "Fable only") + #expect(window.window.resetsAt == reset) + #expect(window.window.resetDescription == "Jul 8") + } + + @Test + func `display name supplies the identifier when the API omits a model id`() throws { + let windows = ClaudeScopedWeeklyLimitMapper.extraRateWindows(from: [ + Self.limit(modelID: " ", modelName: " Team / Research "), + ]) + + let window = try #require(windows.first) + #expect(window.id == "claude-weekly-scoped-team-research") + #expect(window.title == "Team / Research only") + } + + @Test + func `unrelated malformed and unnamed limits are ignored`() { + let limits = [ + Self.limit(kind: "session", modelName: "Fable"), + Self.limit(group: "monthly", modelName: "Fable"), + Self.limit(percent: .nan, modelName: "Fable"), + Self.limit(modelName: " "), + ] + + #expect(ClaudeScopedWeeklyLimitMapper.extraRateWindows(from: limits).isEmpty) + } + + @Test + func `all models scope stays in the primary weekly lane`() { + let limits = [ + Self.limit(modelID: nil, modelName: "All models"), + Self.limit(modelID: "claude/all_models", modelName: "Weekly"), + Self.limit(modelID: nil, modelName: "Fable"), + ] + + let windows = ClaudeScopedWeeklyLimitMapper.extraRateWindows(from: limits) + + #expect(windows.map(\.title) == ["Fable only"]) + } + + private static func limit( + kind: String = "weekly_scoped", + group: String = "weekly", + percent: Double = 5, + modelID: String? = nil, + modelName: String?, + resetsAt: Date? = nil) -> ClaudeScopedWeeklyLimitMapper.Limit + { + ClaudeScopedWeeklyLimitMapper.Limit( + kind: kind, + group: group, + percent: percent, + resetsAt: resetsAt, + modelID: modelID, + modelName: modelName) + } +} diff --git a/Tests/CodexBarTests/ClaudeSessionMappingTests.swift b/Tests/CodexBarTests/ClaudeSessionMappingTests.swift new file mode 100644 index 0000000000..dd6d76969d --- /dev/null +++ b/Tests/CodexBarTests/ClaudeSessionMappingTests.swift @@ -0,0 +1,84 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct ClaudeSessionMappingTests { + @Test + func `cwd escaping replaces every non alphanumeric ASCII byte`() { + #expect(ClaudeSessionProjectMapper.escapedCWD("/Users/test/My Project_v2") == "-Users-test-My-Project-v2") + } + + @Test + func `newest transcript is selected from mapped project directory`() throws { + let home = FileManager.default.temporaryDirectory + .appendingPathComponent("ClaudeSessionMappingTests-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: home) } + let cwd = "/Users/test/Projects/alpha" + let projectDirectory = home + .appendingPathComponent(".claude/projects", isDirectory: true) + .appendingPathComponent(ClaudeSessionProjectMapper.escapedCWD(cwd), isDirectory: true) + try FileManager.default.createDirectory(at: projectDirectory, withIntermediateDirectories: true) + let older = projectDirectory.appendingPathComponent("older.jsonl") + let newer = projectDirectory.appendingPathComponent("newer.jsonl") + try Data("fixture\n".utf8).write(to: older) + try Data("fixture\n".utf8).write(to: newer) + try FileManager.default.setAttributes( + [.modificationDate: Date(timeIntervalSince1970: 100)], + ofItemAtPath: older.path) + try FileManager.default.setAttributes( + [.modificationDate: Date(timeIntervalSince1970: 200)], + ofItemAtPath: newer.path) + + let match = try #require(ClaudeSessionProjectMapper.newestTranscript(cwd: cwd, homeDirectory: home)) + #expect(match.url.lastPathComponent == "newer.jsonl") + #expect(match.modifiedAt == Date(timeIntervalSince1970: 200)) + + let bounded = ClaudeSessionProjectMapper.transcripts( + cwd: cwd, + homeDirectory: home, + limit: 1, + now: Date(timeIntervalSince1970: 150)) + #expect(bounded.map(\.url.lastPathComponent) == ["newer.jsonl"]) + #expect(bounded.first?.modifiedAt == Date(timeIntervalSince1970: 150)) + } + + @Test + func `directory metadata scan bounds entry count depth and time`() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("ClaudeSessionMappingBoundsTests-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + for name in ["one.jsonl", "two.jsonl", "three.jsonl"] { + try Data("fixture\n".utf8).write(to: root.appendingPathComponent(name)) + } + let nested = root.appendingPathComponent("nested", isDirectory: true) + try FileManager.default.createDirectory(at: nested, withIntermediateDirectories: true) + try Data("fixture\n".utf8).write(to: nested.appendingPathComponent("nested.jsonl")) + + var bounded = DirectoryMetadataScanBudget(maxEntryCount: 2, maxDepth: 1, timeLimit: 60) + let files = bounded.files(in: root) + #expect(files.count <= 2) + #expect(!files.contains { $0.deletingLastPathComponent() == nested }) + + var expired = DirectoryMetadataScanBudget(maxEntryCount: 100, maxDepth: 2, timeLimit: 0) + #expect(expired.files(in: root).isEmpty) + } + + @Test + func `future modification dates use one path free clamp anchor`() { + let url = URL(fileURLWithPath: "/tmp/future-session.jsonl") + let firstNow = Date(timeIntervalSinceReferenceDate: 100) + let clamp = FutureModificationDateClamp(clampDate: firstNow) + let future = firstNow.addingTimeInterval(3600) + + #expect(clamp.clamp(url: url, modifiedAt: future, now: firstNow) == firstNow) + #expect(clamp.clamp( + url: url, + modifiedAt: future, + now: firstNow.addingTimeInterval(30)) == firstNow) + #expect(clamp.clamp( + url: url, + modifiedAt: future.addingTimeInterval(1), + now: firstNow.addingTimeInterval(30)) == firstNow) + } +} diff --git a/Tests/CodexBarTests/ClaudeSwapAccountProjectionTests.swift b/Tests/CodexBarTests/ClaudeSwapAccountProjectionTests.swift new file mode 100644 index 0000000000..567a069eca --- /dev/null +++ b/Tests/CodexBarTests/ClaudeSwapAccountProjectionTests.swift @@ -0,0 +1,200 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct ClaudeSwapAccountProjectionTests { + @Test + func `adapter failures mark retained account snapshots as stale`() { + #expect(ClaudeSwapAccountProjection.displayError( + accountError: nil, + adapterError: "timed out") == "Showing the last successful update: timed out") + #expect(ClaudeSwapAccountProjection.displayError( + accountError: "Token expired.", + adapterError: "timed out") == "Token expired.") + #expect(ClaudeSwapAccountProjection.displayError( + accountError: nil, + adapterError: "timed out", + switchError: "store locked") == "Account switch failed: store locked") + #expect(ClaudeSwapAccountProjection.displayError( + accountError: "API-key account", + adapterError: nil, + switchError: "store locked") == "Account switch failed: store locked") + } + + private let now = Date(timeIntervalSince1970: 1_782_000_000) + + @Test + func `projects rows into provider neutral snapshots with active account first`() throws { + let reset = Date(timeIntervalSince1970: 1_782_170_999) + let list = ClaudeSwapAccountList( + activeAccountNumber: 2, + accounts: [ + ClaudeSwapAccountRow( + number: 1, + email: "work@example.com", + isActive: false, + usageStatus: .ok, + fiveHour: ClaudeSwapUsageWindow(usedPercent: 25, resetsAt: reset), + sevenDay: ClaudeSwapUsageWindow(usedPercent: 16.5, resetsAt: nil)), + ClaudeSwapAccountRow( + number: 2, + email: "personal@example.com", + isActive: true, + usageStatus: .ok, + fiveHour: ClaudeSwapUsageWindow(usedPercent: 80, resetsAt: nil), + sevenDay: nil), + ]) + + let snapshots = ClaudeSwapAccountProjection.accountSnapshots(from: list, now: self.now) + #expect(snapshots.count == 2) + + let active = try #require(snapshots.first) + #expect(active.id == ProviderAccountIdentity(source: "claude-swap", opaqueID: "2")) + #expect(active.provider == .claude) + #expect(active.displayLabel == "personal@example.com") + #expect(active.isActive == true) + #expect(active.canActivate == false) + #expect(active.error == nil) + #expect(active.sourceLabel == "claude-swap") + #expect(active.snapshot?.primary?.usedPercent == 80) + #expect(active.snapshot?.primary?.windowMinutes == 300) + #expect(active.snapshot?.secondary == nil) + #expect(active.snapshot?.updatedAt == self.now) + #expect(active.snapshot?.identity?.accountEmail == "personal@example.com") + #expect(active.snapshot?.identity?.loginMethod == "claude-swap") + + let inactive = try #require(snapshots.last) + #expect(inactive.id.opaqueID == "1") + #expect(inactive.isActive == false) + #expect(inactive.canActivate == true) + #expect(inactive.snapshot?.primary?.resetsAt == reset) + #expect(inactive.snapshot?.secondary?.usedPercent == 16.5) + #expect(inactive.snapshot?.secondary?.windowMinutes == 10080) + } + + @Test + func `maps sentinel statuses to per account errors without usage`() throws { + let rows: [(ClaudeSwapUsageStatus, String)] = [ + (.tokenExpired, "Token expired"), + (.reloginRequired, "Re-login required"), + (.apiKey, "API-key account"), + (.keychainUnavailable, "Keychain"), + (.noCredentials, "No stored credentials"), + (.unavailable, "Usage fetch failed"), + (.unknown("mystery"), "mystery"), + ] + + for (index, entry) in rows.enumerated() { + let list = ClaudeSwapAccountList( + activeAccountNumber: nil, + accounts: [ + ClaudeSwapAccountRow( + number: index + 1, + email: "a@b.c", + isActive: false, + usageStatus: entry.0, + fiveHour: nil, + sevenDay: nil), + ]) + let snapshot = try #require( + ClaudeSwapAccountProjection.accountSnapshots(from: list, now: self.now).first) + #expect(snapshot.snapshot == nil) + let error = try #require(snapshot.error) + #expect(error.contains(entry.1)) + let expectedCanActivate = entry.0 == .apiKey || entry.0 == .unavailable + #expect(snapshot.canActivate == expectedCanActivate) + } + } + + @Test + func `ok row without windows reports missing usage instead of an empty card`() throws { + let list = ClaudeSwapAccountList( + activeAccountNumber: 1, + accounts: [ + ClaudeSwapAccountRow( + number: 1, + email: "a@b.c", + isActive: true, + usageStatus: .ok, + fiveHour: nil, + sevenDay: nil), + ]) + + let snapshot = try #require(ClaudeSwapAccountProjection.accountSnapshots(from: list, now: self.now).first) + #expect(snapshot.snapshot == nil) + #expect(snapshot.error == "No usage windows reported.") + } + + @Test + func `projects model scoped weekly windows through claude usage rows`() throws { + let reset = Date(timeIntervalSince1970: 1_784_620_800) + let list = ClaudeSwapAccountList( + activeAccountNumber: 1, + accounts: [ + ClaudeSwapAccountRow( + number: 1, + email: "a@b.c", + isActive: true, + usageStatus: .ok, + fiveHour: nil, + sevenDay: nil, + scoped: [ + ClaudeSwapScopedUsageWindow(name: "Fable", usedPercent: 33, resetsAt: reset), + ClaudeSwapScopedUsageWindow(name: "All models", usedPercent: 42, resetsAt: reset), + ]), + ]) + + let account = try #require(ClaudeSwapAccountProjection.accountSnapshots(from: list, now: self.now).first) + let snapshot = try #require(account.snapshot) + #expect(account.error == nil) + #expect(snapshot.primary == nil) + #expect(snapshot.secondary == nil) + let scoped = try #require(snapshot.extraRateWindows) + #expect(scoped.count == 1) + #expect(scoped.first?.id == "claude-weekly-scoped-fable") + #expect(scoped.first?.title == "Fable only") + #expect(scoped.first?.window.usedPercent == 33) + #expect(scoped.first?.window.windowMinutes == 10080) + #expect(scoped.first?.window.resetsAt == reset) + } + + @Test + func `filtered generic scope does not hide missing usage error`() throws { + let list = ClaudeSwapAccountList( + activeAccountNumber: 1, + accounts: [ + ClaudeSwapAccountRow( + number: 1, + email: "a@b.c", + isActive: true, + usageStatus: .ok, + fiveHour: nil, + sevenDay: nil, + scoped: [ + ClaudeSwapScopedUsageWindow(name: "All models", usedPercent: 42, resetsAt: nil), + ]), + ]) + + let account = try #require(ClaudeSwapAccountProjection.accountSnapshots(from: list, now: self.now).first) + #expect(account.snapshot == nil) + #expect(account.error == "No usage windows reported.") + } + + @Test + func `falls back to ordinal label when email is empty`() throws { + let list = ClaudeSwapAccountList( + activeAccountNumber: nil, + accounts: [ + ClaudeSwapAccountRow( + number: 3, + email: "", + isActive: false, + usageStatus: .noCredentials, + fiveHour: nil, + sevenDay: nil), + ]) + + let snapshot = try #require(ClaudeSwapAccountProjection.accountSnapshots(from: list, now: self.now).first) + #expect(snapshot.displayLabel == "Account 3") + } +} diff --git a/Tests/CodexBarTests/ClaudeSwapAccountReaderTests.swift b/Tests/CodexBarTests/ClaudeSwapAccountReaderTests.swift new file mode 100644 index 0000000000..f14c2fa83e --- /dev/null +++ b/Tests/CodexBarTests/ClaudeSwapAccountReaderTests.swift @@ -0,0 +1,210 @@ +import Foundation +import Testing +@testable import CodexBarCore + +/// Reader tests use fake executables only: no real +/// claude-swap install, no credentials, no Keychain access. +struct ClaudeSwapAccountReaderTests { + private func makeFakeExecutable(_ script: String) throws -> String { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("claude-swap-reader-tests-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let url = directory.appendingPathComponent("cswap") + try "#!/bin/sh\n\(script)\n".write(to: url, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: url.path) + return url.path + } + + @Test + func `reads and parses a schema v1 list from the executable`() async throws { + let path = try self.makeFakeExecutable(""" + [ "$1" = "--list" ] || exit 2 + [ "$2" = "--json" ] || exit 2 + cat <<'EOF' + {"schemaVersion": 1, "activeAccountNumber": 1, "accounts": [ + {"number": 1, "email": "a@b.c", "active": true, "usageStatus": "ok", + "usage": { + "fiveHour": {"pct": 12.5}, + "scoped": [{"pct": 33.0, "name": "Fable", "resetsAt": "2026-07-21T08:00:00Z"}] + }} + ]} + EOF + """) + + let list = try await ClaudeSwapAccountReader.readAccountList(executablePath: path) + #expect(list.activeAccountNumber == 1) + #expect(list.accounts.first?.fiveHour?.usedPercent == 12.5) + #expect(list.accounts.first?.scoped == [ + ClaudeSwapScopedUsageWindow( + name: "Fable", + usedPercent: 33, + resetsAt: Date(timeIntervalSince1970: 1_784_620_800)), + ]) + } + + @Test + func `surfaces the error envelope from a non zero exit`() async throws { + let path = try self.makeFakeExecutable(""" + echo '{"schemaVersion": 1, "error": {"type": "SwitchError", "message": "store locked"}}' + exit 1 + """) + + await #expect(throws: ClaudeSwapListParserError.reportedError(type: "SwitchError", message: "store locked")) { + try await ClaudeSwapAccountReader.readAccountList(executablePath: path) + } + } + + @Test + func `terminates executables that exceed the timeout`() async throws { + let path = try self.makeFakeExecutable("sleep 30") + + await #expect(throws: (any Error).self) { + try await ClaudeSwapAccountReader.readAccountList(executablePath: path, timeout: 0.5) + } + } + + @Test + func `rejects oversized output before parsing`() async throws { + let path = try self.makeFakeExecutable(""" + i=0 + while [ $i -lt 5000 ]; do + printf '%s' '{"filler": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"}' + i=$((i+1)) + done + """) + + await #expect(throws: (any Error).self) { + try await ClaudeSwapAccountReader.readAccountList(executablePath: path) + } + } + + @Test + func `fails cleanly when the executable is missing`() async throws { + await #expect(throws: (any Error).self) { + try await ClaudeSwapAccountReader.readAccountList( + executablePath: "/nonexistent/path/to/cswap") + } + await #expect(throws: ClaudeSwapAccountReaderError.self) { + try await ClaudeSwapAccountReader.readAccountList(executablePath: " ") + } + } + + @Test + func `reads the executable version`() async throws { + let path = try self.makeFakeExecutable(""" + [ "$1" = "--version" ] || exit 2 + echo 'cswap 0.16.0' + """) + + let version = await ClaudeSwapAccountReader.readVersion(executablePath: path) + #expect(version == "0.16.0") + } + + @Test + func `switches only by validated numeric slot with fixed arguments`() async throws { + let path = try self.makeFakeExecutable(""" + [ "$1" = "--switch-to" ] || exit 2 + [ "$2" = "7" ] || exit 2 + [ "$3" = "--json" ] || exit 2 + [ -z "$4" ] || exit 2 + echo '{"schemaVersion":1,"switched":true,"from":{"number":1},"to":{"number":7},"reason":"switched"}' + """) + + let result = try await ClaudeSwapAccountReader.switchAccount( + executablePath: path, + accountNumber: 7) + + #expect(result.switched) + #expect(result.fromAccountNumber == 1) + #expect(result.toAccountNumber == 7) + } + + @Test + func `rejects switch result for another slot`() async throws { + let path = try self.makeFakeExecutable(""" + echo '{"schemaVersion":1,"switched":true,"from":{"number":1},"to":{"number":8},"reason":"switched"}' + """) + + await #expect(throws: ClaudeSwapSwitchParserError.mismatchedTarget(expected: 7, actual: 8)) { + try await ClaudeSwapAccountReader.switchAccount(executablePath: path, accountNumber: 7) + } + } + + @Test + func `surfaces switch error envelope from non zero exit`() async throws { + let path = try self.makeFakeExecutable(""" + echo '{"schemaVersion":1,"error":{"type":"SwitchError","message":"credentials missing"}}' + exit 1 + """) + + await #expect(throws: ClaudeSwapSwitchParserError.reportedError( + type: "SwitchError", + message: "credentials missing")) + { + try await ClaudeSwapAccountReader.switchAccount(executablePath: path, accountNumber: 2) + } + } + + @Test + func `started credential switch reaches natural exit after caller cancellation`() async throws { + let marker = FileManager.default.temporaryDirectory + .appendingPathComponent("claude-swap-switch-finished-\(UUID().uuidString)") + let path = try self.makeFakeExecutable(""" + sleep 0.3 + touch '\(marker.path)' + echo '{"schemaVersion":1,"switched":true,"from":{"number":1},"to":{"number":2},"reason":"switched"}' + """) + let task = Task { + try await ClaudeSwapAccountReader.switchAccount(executablePath: path, accountNumber: 2) + } + + try await Task.sleep(for: .milliseconds(100)) + task.cancel() + let result = try await task.value + + #expect(result.switched) + #expect(FileManager.default.fileExists(atPath: marker.path)) + } + + @Test + func `version probe returns nil when the executable fails`() async throws { + let path = try self.makeFakeExecutable("exit 3") + + let version = await ClaudeSwapAccountReader.readVersion(executablePath: path) + #expect(version == nil) + } + + @Test + func `cancellation during version probe prevents account list launch`() async throws { + let marker = FileManager.default.temporaryDirectory + .appendingPathComponent("claude-swap-list-launched-\(UUID().uuidString)") + let path = try self.makeFakeExecutable(""" + if [ "$1" = "--version" ]; then + sleep 30 + exit 0 + fi + touch '\(marker.path)' + echo '{"schemaVersion":1,"activeAccountNumber":null,"accounts":[]}' + """) + let task = Task { + _ = await ClaudeSwapAccountReader.readVersion(executablePath: path) + return try await ClaudeSwapAccountReader.readAccountList(executablePath: path) + } + + try await Task.sleep(for: .milliseconds(100)) + task.cancel() + + await #expect(throws: CancellationError.self) { + try await task.value + } + #expect(!FileManager.default.fileExists(atPath: marker.path)) + } + + @Test + func `expands tilde in configured paths`() throws { + let resolved = try ClaudeSwapAccountReader.resolvedExecutablePath("~/bin/cswap") + #expect(resolved.hasPrefix("/")) + #expect(!resolved.contains("~")) + #expect(resolved.hasSuffix("/bin/cswap")) + } +} diff --git a/Tests/CodexBarTests/ClaudeSwapListParserTests.swift b/Tests/CodexBarTests/ClaudeSwapListParserTests.swift new file mode 100644 index 0000000000..d94f484b20 --- /dev/null +++ b/Tests/CodexBarTests/ClaudeSwapListParserTests.swift @@ -0,0 +1,322 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct ClaudeSwapListParserTests { + private func parse(_ json: String) throws -> ClaudeSwapAccountList { + try ClaudeSwapListParser.parse(Data(json.utf8)) + } + + @Test + func `parses schema v1 list payload`() throws { + let json = """ + { + "schemaVersion": 1, + "activeAccountNumber": 2, + "accounts": [ + { + "number": 1, + "email": "work@example.com", + "organizationName": "", + "organizationUuid": "", + "isOrganization": false, + "active": false, + "usageStatus": "ok", + "usage": { + "fiveHour": {"pct": 25.0, "resetsAt": "2026-06-22T23:29:59Z", "countdown": "1h"}, + "sevenDay": {"pct": 16.5, "resetsAt": "2026-06-26T17:59:59Z"}, + "scoped": [ + {"pct": 33.0, "name": "Fable", "resetsAt": "2026-06-26T17:59:59Z"} + ] + }, + "usageFetchedAt": "2026-06-22T20:00:00Z", + "usageAgeSeconds": 42.0 + }, + { + "number": 2, + "email": "personal@example.com", + "active": true, + "usageStatus": "ok", + "usage": {"fiveHour": {"pct": 80}} + } + ] + } + """ + + let list = try self.parse(json) + #expect(list.activeAccountNumber == 2) + #expect(list.accounts.count == 2) + + let first = try #require(list.accounts.first) + #expect(first.number == 1) + #expect(first.email == "work@example.com") + #expect(first.isActive == false) + #expect(first.usageStatus == .ok) + #expect(first.fiveHour?.usedPercent == 25.0) + #expect(first.fiveHour?.resetsAt == Date(timeIntervalSince1970: 1_782_170_999)) + #expect(first.sevenDay?.usedPercent == 16.5) + #expect(first.scoped == [ + ClaudeSwapScopedUsageWindow( + name: "Fable", + usedPercent: 33, + resetsAt: Date(timeIntervalSince1970: 1_782_496_799)), + ]) + + let second = try #require(list.accounts.last) + #expect(second.isActive == true) + #expect(second.fiveHour?.usedPercent == 80) + #expect(second.fiveHour?.resetsAt == nil) + #expect(second.sevenDay == nil) + #expect(second.scoped.isEmpty) + } + + @Test + func `ignores malformed and unknown scoped rows without losing account windows`() throws { + let json = """ + { + "schemaVersion": 1, + "activeAccountNumber": 1, + "accounts": [{ + "number": 1, + "active": true, + "usageStatus": "ok", + "usage": { + "fiveHour": {"pct": 19.0}, + "sevenDay": {"pct": 42.0}, + "scoped": [ + {"pct": 133.0, "name": " Fable ", "resetsAt": "2026-07-21T08:00:00Z"}, + {"pct": 17.0, "name": "All models"}, + {"scope": "future_scope", "pct": 5.0}, + {"pct": "unknown", "name": "Example Model"}, + {"pct": 8.0, "name": "Bad Reset", "resetsAt": "next week"}, + "future-shape" + ] + } + }] + } + """ + + let row = try #require(self.parse(json).accounts.first) + #expect(row.fiveHour?.usedPercent == 19) + #expect(row.sevenDay?.usedPercent == 42) + #expect(row.scoped.map(\.name) == ["Fable", "All models"]) + #expect(row.scoped.map(\.usedPercent) == [100, 17]) + #expect(row.scoped.first?.resetsAt == Date(timeIntervalSince1970: 1_784_620_800)) + } + + @Test + func `parses empty account list without accounts configured`() throws { + let json = """ + {"schemaVersion": 1, "activeAccountNumber": null, "accounts": []} + """ + + let list = try self.parse(json) + #expect(list.activeAccountNumber == nil) + #expect(list.accounts.isEmpty) + } + + @Test + func `maps usage status sentinels including unknown values`() throws { + let json = """ + { + "schemaVersion": 1, + "activeAccountNumber": 1, + "accounts": [ + {"number": 1, "email": "a@b.c", "active": true, "usageStatus": "token_expired", "usage": null}, + {"number": 2, "email": "d@e.f", "active": false, "usageStatus": "api_key", "usage": null}, + {"number": 3, "email": "g@h.i", "active": false, "usageStatus": "keychain_unavailable", "usage": null}, + {"number": 4, "email": "j@k.l", "active": false, "usageStatus": "no_credentials", "usage": null}, + {"number": 5, "email": "m@n.o", "active": false, "usageStatus": "unavailable", "usage": null}, + {"number": 6, "email": "p@q.r", "active": false, "usageStatus": "brand_new_status", "usage": null} + ] + } + """ + + let statuses = try self.parse(json).accounts.map(\.usageStatus) + #expect(statuses == [ + .tokenExpired, + .apiKey, + .keychainUnavailable, + .noCredentials, + .unavailable, + .unknown("brand_new_status"), + ]) + } + + @Test + func `projects relogin required status to recovery guidance`() throws { + let json = """ + { + "schemaVersion": 1, + "activeAccountNumber": null, + "accounts": [ + { + "number": 1, + "email": "expired@example.com", + "active": false, + "usageStatus": "relogin_required", + "usage": null + } + ] + } + """ + + let list = try self.parse(json) + let account = try #require(ClaudeSwapAccountProjection.accountSnapshots(from: list).first) + #expect(account.error == "Re-login required. Re-authenticate this account in claude-swap.") + #expect(account.canActivate == false) + } + + @Test + func `surfaces schema v1 error envelope`() throws { + let json = """ + {"schemaVersion": 1, "error": {"type": "SwitchError", "message": "boom"}} + """ + + #expect(throws: ClaudeSwapListParserError.reportedError(type: "SwitchError", message: "boom")) { + try self.parse(json) + } + } + + @Test + func `rejects unknown schema versions`() throws { + let json = """ + {"schemaVersion": 2, "activeAccountNumber": 1, "accounts": []} + """ + + #expect(throws: ClaudeSwapListParserError.unsupportedSchemaVersion(2)) { + try self.parse(json) + } + } + + @Test + func `rejects payloads without schema version or accounts`() throws { + #expect(throws: ClaudeSwapListParserError.missingSchemaVersion) { + try self.parse(#"{"accounts": []}"#) + } + #expect(throws: ClaudeSwapListParserError.malformedShape("missing accounts array")) { + try self.parse(#"{"schemaVersion": 1}"#) + } + #expect(throws: ClaudeSwapListParserError.malformedShape("missing activeAccountNumber")) { + try self.parse(#"{"schemaVersion": 1, "accounts": []}"#) + } + #expect(throws: ClaudeSwapListParserError.notJSONObject) { + try self.parse("not json at all") + } + #expect(throws: ClaudeSwapListParserError.notJSONObject) { + try self.parse(#"["schemaVersion", 1]"#) + } + } + + @Test + func `rejects invalid or duplicate account slots`() throws { + #expect(throws: ClaudeSwapListParserError.malformedShape("account slot must be positive")) { + try self.parse(""" + {"schemaVersion": 1, "activeAccountNumber": null, "accounts": [ + {"number": 0, "active": false, "usageStatus": "ok"} + ]} + """) + } + #expect(throws: ClaudeSwapListParserError.malformedShape("duplicate account slot 1")) { + try self.parse(""" + {"schemaVersion": 1, "activeAccountNumber": 1, "accounts": [ + {"number": 1, "active": true, "usageStatus": "ok"}, + {"number": 1, "active": false, "usageStatus": "ok"} + ]} + """) + } + #expect(throws: ClaudeSwapListParserError.malformedShape( + "activeAccountNumber is not a numeric slot or null")) + { + try self.parse(#"{"schemaVersion": 1, "activeAccountNumber": "1", "accounts": []}"#) + } + #expect(throws: ClaudeSwapListParserError.malformedShape("active account fields disagree")) { + try self.parse(""" + {"schemaVersion": 1, "activeAccountNumber": 2, "accounts": [ + {"number": 1, "active": true, "usageStatus": "ok"}, + {"number": 2, "active": false, "usageStatus": "ok"} + ]} + """) + } + } + + @Test + func `rejects rows with missing required fields`() throws { + #expect(throws: (any Error).self) { + try self.parse(""" + {"schemaVersion": 1, "activeAccountNumber": 1, "accounts": [ + {"email": "a@b.c", "active": true, "usageStatus": "ok"} + ]} + """) + } + #expect(throws: (any Error).self) { + try self.parse(""" + {"schemaVersion": 1, "activeAccountNumber": 1, "accounts": [ + {"number": 1, "email": "a@b.c", "usageStatus": "ok"} + ]} + """) + } + #expect(throws: (any Error).self) { + try self.parse(""" + {"schemaVersion": 1, "activeAccountNumber": 1, "accounts": [ + {"number": 1, "email": "a@b.c", "active": true} + ]} + """) + } + } + + @Test + func `rejects invalid percentages and timestamps`() throws { + #expect(throws: (any Error).self) { + try self.parse(""" + {"schemaVersion": 1, "activeAccountNumber": 1, "accounts": [ + {"number": 1, "email": "a@b.c", "active": true, "usageStatus": "ok", + "usage": {"fiveHour": {"pct": "not-a-number"}}} + ]} + """) + } + #expect(throws: (any Error).self) { + try self.parse(""" + {"schemaVersion": 1, "activeAccountNumber": 1, "accounts": [ + {"number": 1, "email": "a@b.c", "active": true, "usageStatus": "ok", + "usage": {"fiveHour": {"pct": true}}} + ]} + """) + } + #expect(throws: (any Error).self) { + try self.parse(""" + {"schemaVersion": 1, "activeAccountNumber": 1, "accounts": [ + {"number": 1, "email": "a@b.c", "active": true, "usageStatus": "ok", + "usage": {"fiveHour": {"pct": 10, "resetsAt": "yesterday-ish"}}} + ]} + """) + } + } + + @Test + func `clamps out of range percentages`() throws { + let json = """ + {"schemaVersion": 1, "activeAccountNumber": 1, "accounts": [ + {"number": 1, "email": "a@b.c", "active": true, "usageStatus": "ok", + "usage": {"fiveHour": {"pct": 130.5}, "sevenDay": {"pct": -4}}} + ]} + """ + + let row = try #require(self.parse(json).accounts.first) + #expect(row.fiveHour?.usedPercent == 100) + #expect(row.sevenDay?.usedPercent == 0) + } + + @Test + func `parses fractional second timestamps`() throws { + let json = """ + {"schemaVersion": 1, "activeAccountNumber": 1, "accounts": [ + {"number": 1, "email": "a@b.c", "active": true, "usageStatus": "ok", + "usage": {"fiveHour": {"pct": 10, "resetsAt": "2026-06-22T23:29:59.500Z"}}} + ]} + """ + + let row = try #require(self.parse(json).accounts.first) + #expect(row.fiveHour?.resetsAt == Date(timeIntervalSince1970: 1_782_170_999.5)) + } +} diff --git a/Tests/CodexBarTests/ClaudeSwapMenuPrecedenceTests.swift b/Tests/CodexBarTests/ClaudeSwapMenuPrecedenceTests.swift new file mode 100644 index 0000000000..c4a1d62a23 --- /dev/null +++ b/Tests/CodexBarTests/ClaudeSwapMenuPrecedenceTests.swift @@ -0,0 +1,37 @@ +import CodexBarCore +import Testing +@testable import CodexBar + +struct ClaudeSwapMenuPrecedenceTests { + @Test + func `multiple Claude swap accounts take precedence by default`() { + #expect(ClaudeSwapMenuPrecedence.prefersClaudeSwap( + provider: .claude, + accountCount: 2, + showSingleAccount: false)) + } + + @Test + func `single Claude swap account requires opt in`() { + #expect(!ClaudeSwapMenuPrecedence.prefersClaudeSwap( + provider: .claude, + accountCount: 1, + showSingleAccount: false)) + #expect(ClaudeSwapMenuPrecedence.prefersClaudeSwap( + provider: .claude, + accountCount: 1, + showSingleAccount: true)) + } + + @Test + func `precedence requires Claude and at least one swap account`() { + #expect(!ClaudeSwapMenuPrecedence.prefersClaudeSwap( + provider: .claude, + accountCount: 0, + showSingleAccount: true)) + #expect(!ClaudeSwapMenuPrecedence.prefersClaudeSwap( + provider: .openai, + accountCount: 2, + showSingleAccount: true)) + } +} diff --git a/Tests/CodexBarTests/ClaudeSwapSwitchParserTests.swift b/Tests/CodexBarTests/ClaudeSwapSwitchParserTests.swift new file mode 100644 index 0000000000..b06982d037 --- /dev/null +++ b/Tests/CodexBarTests/ClaudeSwapSwitchParserTests.swift @@ -0,0 +1,92 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct ClaudeSwapSwitchParserTests { + private func parse(_ json: String) throws -> ClaudeSwapAccountSwitchResult { + try ClaudeSwapSwitchParser.parse(Data(json.utf8)) + } + + @Test + func `parses direct switch result without retaining display identity`() throws { + let result = try self.parse(""" + { + "schemaVersion": 1, + "switched": true, + "from": {"number": 1, "email": "old@example.com"}, + "to": {"number": 2, "email": "new@example.com"}, + "strategy": "direct", + "reason": "switched", + "message": "Switched", + "warnings": [] + } + """) + + #expect(result == ClaudeSwapAccountSwitchResult( + switched: true, + fromAccountNumber: 1, + toAccountNumber: 2, + reason: "switched")) + } + + @Test + func `accepts unmanaged source and already active no op`() throws { + let freshActivation = try self.parse(""" + {"schemaVersion":1,"switched":true,"from":null, + "to":{"number":2},"reason":"switched"} + """) + #expect(freshActivation.fromAccountNumber == nil) + #expect(freshActivation.toAccountNumber == 2) + + let unmanaged = try self.parse(""" + {"schemaVersion":1,"switched":true,"from":{"number":null}, + "to":{"number":3},"reason":"switched"} + """) + #expect(unmanaged.fromAccountNumber == nil) + #expect(unmanaged.toAccountNumber == 3) + + let active = try self.parse(""" + {"schemaVersion":1,"switched":false,"from":{"number":3}, + "to":{"number":3},"reason":"already-active"} + """) + #expect(active.switched == false) + #expect(active.reason == "already-active") + } + + @Test + func `surfaces switch error envelope`() { + #expect(throws: ClaudeSwapSwitchParserError.reportedError( + type: "SwitchError", + message: "store locked")) + { + try self.parse(""" + {"schemaVersion":1,"error":{"type":"SwitchError","message":"store locked"}} + """) + } + } + + @Test + func `rejects malformed or unsupported switch results`() { + #expect(throws: ClaudeSwapSwitchParserError.notJSONObject) { + try self.parse("not json") + } + #expect(throws: ClaudeSwapSwitchParserError.missingSchemaVersion) { + try self.parse(#"{"switched":true}"#) + } + #expect(throws: ClaudeSwapSwitchParserError.missingSchemaVersion) { + try self.parse(#"{"schemaVersion":true}"#) + } + #expect(throws: ClaudeSwapSwitchParserError.unsupportedSchemaVersion(2)) { + try self.parse(#"{"schemaVersion":2}"#) + } + #expect(throws: ClaudeSwapSwitchParserError.malformedShape("missing switched flag")) { + try self.parse(#"{"schemaVersion":1}"#) + } + #expect(throws: (any Error).self) { + try self.parse(""" + {"schemaVersion":1,"switched":true,"from":{"number":1}, + "to":{"number":true},"reason":"switched"} + """) + } + } +} diff --git a/Tests/CodexBarTests/ClaudeSyntheticPlaceholderNotificationTests.swift b/Tests/CodexBarTests/ClaudeSyntheticPlaceholderNotificationTests.swift new file mode 100644 index 0000000000..768239db2a --- /dev/null +++ b/Tests/CodexBarTests/ClaudeSyntheticPlaceholderNotificationTests.swift @@ -0,0 +1,257 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +@MainActor +@Suite("Claude synthetic session placeholder notifications") +struct ClaudeSyntheticPlaceholderNotificationTests { + private let start = Date(timeIntervalSince1970: 1_780_000_000) + + @Test + func `placeholder preserves depleted state without a reset boundary`() { + let notifier = NotifierSpy() + let store = self.makeStore( + suiteName: "ClaudeSyntheticPlaceholderNotificationTests-depleted-no-boundary", + notifier: notifier) + + store.handleSessionQuotaTransition(provider: .claude, snapshot: self.snapshot(sessionUsed: 20)) + store.handleSessionQuotaTransition(provider: .claude, snapshot: self.snapshot(sessionUsed: 100)) + store.handleSessionQuotaTransition( + provider: .claude, + snapshot: self.snapshot(sessionUsed: 0, sessionIsSyntheticPlaceholder: true)) + store.handleSessionQuotaTransition(provider: .claude, snapshot: self.snapshot(sessionUsed: 100)) + + #expect(notifier.transitions == [.depleted]) + #expect(store.sessionQuotaTransitionStates[.claude]?.remaining == 0) + } + + @Test + func `placeholder stays non authoritative after the prior boundary elapses`() { + let notifier = NotifierSpy() + let store = self.makeStore( + suiteName: "ClaudeSyntheticPlaceholderNotificationTests-depleted-elapsed-boundary", + notifier: notifier) + let boundary = self.start.addingTimeInterval(5 * 60) + + store.handleSessionQuotaTransition( + provider: .claude, + snapshot: self.snapshot(sessionUsed: 20, sessionReset: boundary)) + store.handleSessionQuotaTransition( + provider: .claude, + snapshot: self.snapshot(sessionUsed: 100, sessionReset: boundary, secondsAfterStart: 60)) + store.handleSessionQuotaTransition( + provider: .claude, + snapshot: self.snapshot( + sessionUsed: 0, + sessionReset: boundary, + sessionIsSyntheticPlaceholder: true, + secondsAfterStart: 6 * 60)) + store.handleSessionQuotaTransition( + provider: .claude, + snapshot: self.snapshot(sessionUsed: 100, sessionReset: boundary, secondsAfterStart: 7 * 60)) + + #expect(notifier.transitions == [.depleted]) + #expect(store.sessionQuotaTransitionStates[.claude]?.remaining == 0) + } + + @Test + func `placeholder cannot rearm depletion while notifications are disabled`() { + let notifier = NotifierSpy() + let store = self.makeStore( + suiteName: "ClaudeSyntheticPlaceholderNotificationTests-disabled", + notifier: notifier) + + store.handleSessionQuotaTransition(provider: .claude, snapshot: self.snapshot(sessionUsed: 20)) + store.handleSessionQuotaTransition(provider: .claude, snapshot: self.snapshot(sessionUsed: 100)) + + store.settings.sessionQuotaNotificationsEnabled = false + store.handleSessionQuotaTransition( + provider: .claude, + snapshot: self.snapshot(sessionUsed: 0, sessionIsSyntheticPlaceholder: true)) + store.settings.sessionQuotaNotificationsEnabled = true + store.handleSessionQuotaTransition(provider: .claude, snapshot: self.snapshot(sessionUsed: 100)) + + #expect(notifier.transitions == [.depleted]) + #expect(store.sessionQuotaTransitionStates[.claude]?.remaining == 0) + } + + @Test + func `real zero usage remains an authoritative restore`() { + let notifier = NotifierSpy() + let store = self.makeStore( + suiteName: "ClaudeSyntheticPlaceholderNotificationTests-real-zero", + notifier: notifier) + + store.handleSessionQuotaTransition(provider: .claude, snapshot: self.snapshot(sessionUsed: 20)) + store.handleSessionQuotaTransition(provider: .claude, snapshot: self.snapshot(sessionUsed: 100)) + store.handleSessionQuotaTransition(provider: .claude, snapshot: self.snapshot(sessionUsed: 0)) + + #expect(notifier.transitions == [.depleted, .restored]) + #expect(store.sessionQuotaTransitionStates[.claude]?.remaining == 100) + } + + @Test + func `placeholder preserves threshold state while weekly warnings continue`() { + let notifier = NotifierSpy() + let store = self.makeStore( + suiteName: "ClaudeSyntheticPlaceholderNotificationTests-threshold", + notifier: notifier) + store.settings.quotaWarningNotificationsEnabled = true + store.settings.quotaWarningThresholds = [50] + store.settings.setQuotaWarningWindowEnabled(.session, enabled: true) + store.settings.setQuotaWarningWindowEnabled(.weekly, enabled: true) + let sessionReset = self.start.addingTimeInterval(2 * 60 * 60) + let weeklyReset = self.start.addingTimeInterval(2 * 24 * 60 * 60) + + store.handleQuotaWarningTransitions( + provider: .claude, + snapshot: self.snapshot( + sessionUsed: 40, + weeklyUsed: 40, + sessionReset: sessionReset, + weeklyReset: weeklyReset)) + store.handleQuotaWarningTransitions( + provider: .claude, + snapshot: self.snapshot( + sessionUsed: 60, + weeklyUsed: 40, + sessionReset: sessionReset, + weeklyReset: weeklyReset, + secondsAfterStart: 60)) + store.handleQuotaWarningTransitions( + provider: .claude, + snapshot: self.snapshot( + sessionUsed: 0, + weeklyUsed: 60, + sessionReset: sessionReset, + weeklyReset: weeklyReset, + sessionIsSyntheticPlaceholder: true, + secondsAfterStart: 120)) + store.handleQuotaWarningTransitions( + provider: .claude, + snapshot: self.snapshot( + sessionUsed: 60, + weeklyUsed: 60, + sessionReset: sessionReset, + weeklyReset: weeklyReset, + secondsAfterStart: 180)) + + #expect(notifier.quotaWarnings.map(\.window) == [.session, .weekly]) + #expect(notifier.quotaWarnings.map(\.threshold) == [50, 50]) + } + + @Test + func `placeholder preserves predictive episode while weekly risk continues`() { + let notifier = NotifierSpy() + let store = self.makeStore( + suiteName: "ClaudeSyntheticPlaceholderNotificationTests-predictive", + notifier: notifier) + store.settings.predictivePaceWarningNotificationsEnabled = true + let sessionReset = self.start.addingTimeInterval(2 * 60 * 60) + let weeklyReset = self.start.addingTimeInterval(2 * 24 * 60 * 60) + + store.handlePredictivePaceWarningTransitions( + provider: .claude, + snapshot: self.snapshot( + sessionUsed: 80, + weeklyUsed: 20, + sessionReset: sessionReset, + weeklyReset: weeklyReset)) + store.handlePredictivePaceWarningTransitions( + provider: .claude, + snapshot: self.snapshot( + sessionUsed: 0, + weeklyUsed: 90, + sessionReset: sessionReset, + weeklyReset: weeklyReset, + sessionIsSyntheticPlaceholder: true, + secondsAfterStart: 60)) + store.handlePredictivePaceWarningTransitions( + provider: .claude, + snapshot: self.snapshot( + sessionUsed: 80, + weeklyUsed: 90, + sessionReset: sessionReset, + weeklyReset: weeklyReset, + secondsAfterStart: 120)) + + #expect(notifier.predictiveWarnings == [.session, .weekly]) + } + + private func makeStore(suiteName: String, notifier: NotifierSpy) -> UsageStore { + let defaults = UserDefaults(suiteName: suiteName)! + defaults.removePersistentDomain(forName: suiteName) + let settings = SettingsStore( + userDefaults: defaults, + configStore: testConfigStore(suiteName: suiteName), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + settings.refreshFrequency = .manual + settings.statusChecksEnabled = false + settings.sessionQuotaNotificationsEnabled = true + return UsageStore( + fetcher: UsageFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + sessionQuotaNotifier: notifier) + } + + private func snapshot( + sessionUsed: Double, + weeklyUsed: Double = 20, + sessionReset: Date? = nil, + weeklyReset: Date? = nil, + sessionIsSyntheticPlaceholder: Bool = false, + secondsAfterStart: TimeInterval = 0) -> UsageSnapshot + { + UsageSnapshot( + primary: RateWindow( + usedPercent: sessionUsed, + windowMinutes: 5 * 60, + resetsAt: sessionReset, + resetDescription: nil, + isSyntheticPlaceholder: sessionIsSyntheticPlaceholder), + secondary: RateWindow( + usedPercent: weeklyUsed, + windowMinutes: 7 * 24 * 60, + resetsAt: weeklyReset, + resetDescription: nil), + updatedAt: self.start.addingTimeInterval(secondsAfterStart), + identity: ProviderIdentitySnapshot( + providerID: .claude, + accountEmail: "placeholder@example.com", + accountOrganization: nil, + loginMethod: "web")) + } +} + +@MainActor +private final class NotifierSpy: SessionQuotaNotifying { + private(set) var transitions: [SessionQuotaTransition] = [] + private(set) var quotaWarnings: [QuotaWarningEvent] = [] + private(set) var predictiveWarnings: [QuotaWarningWindow] = [] + + func post(transition: SessionQuotaTransition, provider _: UsageProvider, badge _: NSNumber?) { + self.transitions.append(transition) + } + + func postQuotaWarning( + event: QuotaWarningEvent, + provider _: UsageProvider, + soundEnabled _: Bool, + onScreenAlertEnabled _: Bool) + { + self.quotaWarnings.append(event) + } + + func postPredictivePaceWarning( + event: PredictivePaceWarningEvent, + provider _: UsageProvider, + soundEnabled _: Bool, + onScreenAlertEnabled _: Bool, + now _: Date) + { + self.predictiveWarnings.append(event.window) + } +} diff --git a/Tests/CodexBarTests/ClaudeSyntheticPlaceholderPlanUtilizationTests.swift b/Tests/CodexBarTests/ClaudeSyntheticPlaceholderPlanUtilizationTests.swift new file mode 100644 index 0000000000..48f9f67b34 --- /dev/null +++ b/Tests/CodexBarTests/ClaudeSyntheticPlaceholderPlanUtilizationTests.swift @@ -0,0 +1,38 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +extension UsageStorePlanUtilizationTests { + @MainActor + @Test + func `Claude placeholder is omitted from session history while weekly history continues`() async { + let store = Self.makeStore() + let now = Date(timeIntervalSince1970: 1_780_000_000) + let snapshot = UsageSnapshot( + primary: RateWindow( + usedPercent: 0, + windowMinutes: 5 * 60, + resetsAt: now.addingTimeInterval(2 * 60 * 60), + resetDescription: nil, + isSyntheticPlaceholder: true), + secondary: RateWindow( + usedPercent: 42, + windowMinutes: 7 * 24 * 60, + resetsAt: now.addingTimeInterval(2 * 24 * 60 * 60), + resetDescription: nil), + updatedAt: now, + identity: ProviderIdentitySnapshot( + providerID: .claude, + accountEmail: "placeholder-history@example.com", + accountOrganization: nil, + loginMethod: "web")) + + await store.recordPlanUtilizationHistorySample(provider: .claude, snapshot: snapshot, now: now) + + let histories = store.planUtilizationHistory(for: .claude) + #expect(findSeries(histories, name: .session, windowMinutes: 5 * 60) == nil) + #expect(findSeries(histories, name: .weekly, windowMinutes: 7 * 24 * 60)? + .entries.map(\.usedPercent) == [42]) + } +} diff --git a/Tests/CodexBarTests/ClaudeUsageDelegatedRefreshEnvironmentTests.swift b/Tests/CodexBarTests/ClaudeUsageDelegatedRefreshEnvironmentTests.swift index 06cb71eba2..c412215101 100644 --- a/Tests/CodexBarTests/ClaudeUsageDelegatedRefreshEnvironmentTests.swift +++ b/Tests/CodexBarTests/ClaudeUsageDelegatedRefreshEnvironmentTests.swift @@ -2,7 +2,58 @@ import Foundation import Testing @testable import CodexBarCore +@Suite(.serialized) struct ClaudeUsageDelegatedRefreshEnvironmentTests { + private typealias CredentialLoader = @Sendable ( + [String: String], + Bool, + Bool) async throws -> ClaudeOAuthCredentials + + private func credentialsData(accessToken: String) -> Data { + Data( + """ + { + "claudeAiOauth": { + "accessToken": "\(accessToken)", + "refreshToken": "refresh-\(accessToken)", + "expiresAt": \(Int(Date(timeIntervalSinceNow: 3600).timeIntervalSince1970 * 1000)), + "scopes": ["user:profile"] + } + } + """.utf8) + } + + private func cacheKey(environment: [String: String]) -> KeychainCacheStore.Key { + ClaudeOAuthCredentialsStore.cacheKeyForTesting( + profileIdentifier: ClaudeOAuthCredentialsStore.credentialsProfileIdentifier(environment: environment)) + } + + private func storeCache(accessToken: String, environment: [String: String]) { + let profileIdentifier = ClaudeOAuthCredentialsStore.credentialsProfileIdentifier(environment: environment) + KeychainCacheStore.store( + key: ClaudeOAuthCredentialsStore.cacheKeyForTesting(profileIdentifier: profileIdentifier), + entry: ClaudeOAuthCredentialsStore.CacheEntry( + data: self.credentialsData(accessToken: accessToken), + storedAt: Date(), + owner: .claudeCLI, + profileIdentifier: profileIdentifier)) + } + + private func cachedToken(environment: [String: String]) throws -> String? { + switch KeychainCacheStore.load( + key: self.cacheKey(environment: environment), + as: ClaudeOAuthCredentialsStore.CacheEntry.self) + { + case let .found(entry): + return try ClaudeOAuthCredentials.parse(data: entry.data).accessToken + case .missing: + return nil + case .invalid, .temporarilyUnavailable: + Issue.record("Expected a valid or missing profile cache entry") + return nil + } + } + @Test func `oauth delegated retry passes fetcher environment to delegated refresh`() async throws { let fetcher = ClaudeUsageFetcher( @@ -50,4 +101,120 @@ struct ClaudeUsageDelegatedRefreshEnvironmentTests { #expect(message.contains("Claude CLI is not available")) } } + + @Test + func `oauth rejection invalidates only the fetcher profile`() async throws { + let service = "com.steipete.codexbar.cache.environment-tests.\(UUID().uuidString)" + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + let environmentA = ["CLAUDE_CONFIG_DIR": root.appendingPathComponent("profile-a").path] + let environmentB = ["CLAUDE_CONFIG_DIR": root.appendingPathComponent("profile-b").path] + let credentials = try ClaudeOAuthCredentials.parse(data: self.credentialsData(accessToken: "request-token")) + let loadOverride: CredentialLoader? = { environment, _, _ in + #expect(environment == environmentA) + return credentials + } + let fetchOverride: (@Sendable (String, Bool) async throws -> OAuthUsageResponse)? = { _, _ in + throw ClaudeOAuthFetchError.serverError(500, "synthetic rejection") + } + let fetcher = ClaudeUsageFetcher( + browserDetection: BrowserDetection(cacheTTL: 0), + environment: environmentA, + dataSource: .oauth, + oauthKeychainPromptCooldownEnabled: true) + + try await KeychainCacheStore.withServiceOverrideForTesting(service) { + KeychainCacheStore.setTestStoreForTesting(true) + defer { KeychainCacheStore.setTestStoreForTesting(false) } + try await KeychainAccessGate.withTaskOverrideForTesting(false) { + try await ClaudeOAuthCredentialsStore.withEnvironmentCredentialsURLForTesting { + try await ClaudeOAuthCredentialsStore.withIsolatedMemoryCacheForTesting { + self.storeCache(accessToken: "profile-a-token", environment: environmentA) + self.storeCache(accessToken: "profile-b-token", environment: environmentB) + + await #expect(throws: ClaudeUsageError.self) { + try await ClaudeUsageFetcher.$hasCachedCredentialsOverride.withValue(true) { + try await ClaudeUsageFetcher.$fetchOAuthUsageOverride.withValue(fetchOverride) { + try await ClaudeUsageFetcher.$loadOAuthCredentialsOverride.withValue( + loadOverride) + { + try await fetcher.loadLatestUsage(model: "sonnet") + } + } + } + } + + let cachedA = try self.cachedToken(environment: environmentA) + let cachedB = try self.cachedToken(environment: environmentB) + #expect(cachedA == nil) + #expect(cachedB == "profile-b-token") + } + } + } + } + } + + @Test + func `delegated recovery syncs only the fetcher profile`() async throws { + let service = "com.steipete.codexbar.cache.environment-tests.\(UUID().uuidString)" + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + let environmentA = ["CLAUDE_CONFIG_DIR": root.appendingPathComponent("profile-a").path] + let environmentB = ["CLAUDE_CONFIG_DIR": root.appendingPathComponent("profile-b").path] + let syncedData = self.credentialsData(accessToken: "synced-profile-a-token") + let loadOverride: CredentialLoader? = { environment, _, _ in + #expect(environment == environmentA) + throw ClaudeOAuthCredentialsError.refreshDelegatedToClaudeCLI + } + let delegatedOverride: (@Sendable (Date, TimeInterval, [String: String]) async + -> ClaudeOAuthDelegatedRefreshCoordinator.Outcome)? = { _, _, environment in + #expect(environment == environmentA) + return .attemptedSucceeded + } + let fetcher = ClaudeUsageFetcher( + browserDetection: BrowserDetection(cacheTTL: 0), + environment: environmentA, + dataSource: .oauth, + oauthKeychainPromptCooldownEnabled: true) + + try await KeychainCacheStore.withServiceOverrideForTesting(service) { + KeychainCacheStore.setTestStoreForTesting(true) + defer { KeychainCacheStore.setTestStoreForTesting(false) } + try await KeychainAccessGate.withTaskOverrideForTesting(false) { + try await ClaudeOAuthCredentialsStore.withEnvironmentCredentialsURLForTesting { + try await ClaudeOAuthCredentialsStore.withIsolatedMemoryCacheForTesting { + self.storeCache(accessToken: "profile-a-old", environment: environmentA) + self.storeCache(accessToken: "profile-b-token", environment: environmentB) + + await #expect(throws: ClaudeUsageError.self) { + try await ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting( + .onlyOnUserAction) + { + try await ProviderInteractionContext.$current.withValue(.userInitiated) { + try await ClaudeOAuthCredentialsStore.withClaudeKeychainOverridesForTesting( + data: syncedData, + fingerprint: nil) + { + try await ClaudeUsageFetcher.$hasCachedCredentialsOverride.withValue(true) { + try await ClaudeUsageFetcher.$delegatedRefreshAttemptOverride.withValue( + delegatedOverride) + { + try await ClaudeUsageFetcher.$loadOAuthCredentialsOverride + .withValue(loadOverride) { + try await fetcher.loadLatestUsage(model: "sonnet") + } + } + } + } + } + } + } + + let cachedA = try self.cachedToken(environment: environmentA) + let cachedB = try self.cachedToken(environment: environmentB) + #expect(cachedA == "synced-profile-a-token") + #expect(cachedB == "profile-b-token") + } + } + } + } + } } diff --git a/Tests/CodexBarTests/ClaudeUsageTests.swift b/Tests/CodexBarTests/ClaudeUsageTests.swift index b52ed973d7..1f23fb2eb0 100644 --- a/Tests/CodexBarTests/ClaudeUsageTests.swift +++ b/Tests/CodexBarTests/ClaudeUsageTests.swift @@ -17,16 +17,6 @@ struct ClaudeUsageTests { } } - private static func makeOAuthUsageResponse() throws -> OAuthUsageResponse { - let json = """ - { - "five_hour": { "utilization": 7, "resets_at": "2025-12-23T16:00:00.000Z" }, - "seven_day": { "utilization": 21, "resets_at": "2025-12-29T23:00:00.000Z" } - } - """ - return try ClaudeOAuthUsageFetcher._decodeUsageResponseForTesting(Data(json.utf8)) - } - @Test func `parses usage JSON with sonnet limit`() { let json = """ @@ -58,7 +48,7 @@ struct ClaudeUsageTests { dataSource: .oauth, oauthKeychainPromptCooldownEnabled: true) - let fetchOverride: (@Sendable (String) async throws -> OAuthUsageResponse)? = { _ in usageResponse } + let fetchOverride: (@Sendable (String, Bool) async throws -> OAuthUsageResponse)? = { _, _ in usageResponse } let delegatedOverride: (@Sendable ( Date, TimeInterval, @@ -228,7 +218,7 @@ struct ClaudeUsageTests { dataSource: .oauth, oauthKeychainPromptCooldownEnabled: true) - let fetchOverride: (@Sendable (String) async throws -> OAuthUsageResponse)? = { _ in usageResponse } + let fetchOverride: (@Sendable (String, Bool) async throws -> OAuthUsageResponse)? = { _, _ in usageResponse } let delegatedOverride: (@Sendable (Date, TimeInterval, [String: String]) async -> ClaudeOAuthDelegatedRefreshCoordinator.Outcome)? = { _, _, _ in _ = await delegatedCounter.increment() @@ -327,6 +317,8 @@ struct ClaudeUsageTests { return } #expect(message.contains("background repair is suppressed")) + #expect(message.contains("Click Refresh in the CodexBar menu")) + #expect(!message.contains("Open the CodexBar menu or")) } catch { Issue.record("Expected ClaudeUsageError, got \(error)") } @@ -396,10 +388,9 @@ struct ClaudeUsageTests { } @Test - func `oauth bootstrap only on user action background startup allows interactive read when no cache`() async throws { + func `oauth bootstrap only on user action background startup does not allow interactive read`() async throws { final class FlagBox: @unchecked Sendable { var allowKeychainPromptFlags: [Bool] = [] - var allowBackgroundPromptBootstrapFlags: [Bool] = [] } let flags = FlagBox() @@ -408,16 +399,14 @@ struct ClaudeUsageTests { browserDetection: BrowserDetection(cacheTTL: 0), environment: [:], dataSource: .oauth, - oauthKeychainPromptCooldownEnabled: true, - allowStartupBootstrapPrompt: true) + oauthKeychainPromptCooldownEnabled: true) - let fetchOverride: (@Sendable (String) async throws -> OAuthUsageResponse)? = { _ in usageResponse } + let fetchOverride: (@Sendable (String, Bool) async throws -> OAuthUsageResponse)? = { _, _ in usageResponse } let loadCredsOverride: (@Sendable ( [String: String], Bool, Bool) async throws -> ClaudeOAuthCredentials)? = { _, allowKeychainPrompt, _ in flags.allowKeychainPromptFlags.append(allowKeychainPrompt) - flags.allowBackgroundPromptBootstrapFlags.append(ClaudeOAuthCredentialsStore.allowBackgroundPromptBootstrap) return ClaudeOAuthCredentials( accessToken: "fresh-token", refreshToken: "refresh-token", @@ -440,8 +429,7 @@ struct ClaudeUsageTests { } } - #expect(flags.allowKeychainPromptFlags == [true]) - #expect(flags.allowBackgroundPromptBootstrapFlags == [true]) + #expect(flags.allowKeychainPromptFlags == [false]) #expect(snapshot.primary.usedPercent == 7) } @@ -463,7 +451,7 @@ struct ClaudeUsageTests { oauthKeychainPromptCooldownEnabled: false, allowBackgroundDelegatedRefresh: true) - let fetchOverride: (@Sendable (String) async throws -> OAuthUsageResponse)? = { _ in usageResponse } + let fetchOverride: (@Sendable (String, Bool) async throws -> OAuthUsageResponse)? = { _, _ in usageResponse } let delegatedOverride: (@Sendable ( Date, TimeInterval, @@ -573,8 +561,12 @@ struct ClaudeUsageTests { "session_5h": ["pct_used": 0, "resets": ""], "week_all_models": ["pct_used": 0, "resets": ""], ] as [String: Any] - if let email = entry["email"] { payload["account_email"] = email } - if let org = entry["org"] { payload["account_org"] = org } + if let email = entry["email"] { + payload["account_email"] = email + } + if let org = entry["org"] { + payload["account_org"] = org + } let data = try JSONSerialization.data(withJSONObject: payload) let snap = ClaudeUsageFetcher.parse(json: data) let emailRaw: String? = entry["email"] ?? String?.none @@ -635,7 +627,9 @@ struct ClaudeUsageTests { try process.run() DispatchQueue.global().asyncAfter(deadline: .now() + timeout) { - if process.isRunning { process.terminate() } + if process.isRunning { + process.terminate() + } } process.waitUntilExit() @@ -802,7 +796,7 @@ struct ClaudeUsageTests { let data = Data(json.utf8) let info = ClaudeWebAPIFetcher._parseAccountInfoForTesting(data, orgId: "org-123") #expect(info?.email == "steipete@gmail.com") - #expect(info?.loginMethod == "Claude Max") + #expect(info?.loginMethod == "Claude Max 20x") } @Test @@ -882,6 +876,18 @@ struct ClaudeUsageTests { } } +extension ClaudeUsageTests { + private static func makeOAuthUsageResponse() throws -> OAuthUsageResponse { + let json = """ + { + "five_hour": { "utilization": 7, "resets_at": "2025-12-23T16:00:00.000Z" }, + "seven_day": { "utilization": 21, "resets_at": "2025-12-29T23:00:00.000Z" } + } + """ + return try ClaudeOAuthUsageFetcher._decodeUsageResponseForTesting(Data(json.utf8)) + } +} + struct ClaudeOAuthUsageMappingTests { @Test func `oauth usage falls back to weekly window when five hour is absent`() throws { @@ -1078,7 +1084,9 @@ struct ClaudeAutoFetcherCharacterizationTests { let url = try #require(request.url) return Self.makeJSONResponse(url: url, body: "{}") }, operation: { - let fetchOverride: @Sendable (String) async throws -> OAuthUsageResponse = { _ in usageResponse } + let fetchOverride: @Sendable (String, Bool) async throws -> OAuthUsageResponse = { _, _ in + usageResponse + } let snapshot = try await ClaudeUsageFetcher.$fetchOAuthUsageOverride.withValue( fetchOverride, operation: { @@ -1289,7 +1297,11 @@ struct ClaudeAutoFetcherCharacterizationTests { } final class ClaudeAutoFetcherStubURLProtocol: URLProtocol { - nonisolated(unsafe) static var handler: ((URLRequest) throws -> (HTTPURLResponse, Data))? + private static let _handlerBox = LockIsolated<((URLRequest) throws -> (HTTPURLResponse, Data))?>(nil) + static var handler: ((URLRequest) throws -> (HTTPURLResponse, Data))? { + get { Self._handlerBox.value } + set { Self._handlerBox.setValue(newValue) } + } override static func canInit(with request: URLRequest) -> Bool { request.url?.host == "claude.ai" @@ -1405,7 +1417,7 @@ extension ClaudeUsageTests { } @Test - func `oauth delegated retry experimental background ignores only on user action suppression`() async throws { + func `oauth delegated retry experimental background respects only on user action suppression`() async throws { let loadCounter = AsyncCounter() let delegatedCounter = AsyncCounter() let usageResponse = try Self.makeOAuthUsageResponse() @@ -1417,7 +1429,7 @@ extension ClaudeUsageTests { oauthKeychainPromptCooldownEnabled: true, allowBackgroundDelegatedRefresh: false) - let fetchOverride: (@Sendable (String) async throws -> OAuthUsageResponse)? = { _ in usageResponse } + let fetchOverride: (@Sendable (String, Bool) async throws -> OAuthUsageResponse)? = { _, _ in usageResponse } let delegatedOverride: (@Sendable ( Date, TimeInterval, @@ -1429,43 +1441,36 @@ extension ClaudeUsageTests { [String: String], Bool, Bool) async throws -> ClaudeOAuthCredentials)? = { _, _, _ in - let call = await loadCounter.increment() - if call == 1 { - throw ClaudeOAuthCredentialsError.refreshDelegatedToClaudeCLI - } - return ClaudeOAuthCredentials( - accessToken: "fresh-token", - refreshToken: "refresh-token", - expiresAt: Date(timeIntervalSinceNow: 3600), - scopes: ["user:profile"], - rateLimitTier: nil) + _ = await loadCounter.increment() + throw ClaudeOAuthCredentialsError.refreshDelegatedToClaudeCLI } - let snapshot = try await ClaudeOAuthKeychainReadStrategyPreference.withTaskOverrideForTesting( - .securityCLIExperimental, - operation: { - try await ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.onlyOnUserAction) { - try await ProviderInteractionContext.$current.withValue(.background) { - try await ClaudeUsageFetcher.$hasCachedCredentialsOverride.withValue(true) { - try await ClaudeUsageFetcher.$fetchOAuthUsageOverride.withValue(fetchOverride) { - try await ClaudeUsageFetcher.$delegatedRefreshAttemptOverride.withValue( - delegatedOverride) - { - try await ClaudeUsageFetcher.$loadOAuthCredentialsOverride.withValue( - loadCredsOverride) + await #expect(throws: ClaudeUsageError.self) { + try await ClaudeOAuthKeychainReadStrategyPreference.withTaskOverrideForTesting( + .securityCLIExperimental, + operation: { + try await ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.onlyOnUserAction) { + try await ProviderInteractionContext.$current.withValue(.background) { + try await ClaudeUsageFetcher.$hasCachedCredentialsOverride.withValue(true) { + try await ClaudeUsageFetcher.$fetchOAuthUsageOverride.withValue(fetchOverride) { + try await ClaudeUsageFetcher.$delegatedRefreshAttemptOverride.withValue( + delegatedOverride) { - try await fetcher.loadLatestUsage(model: "sonnet") + try await ClaudeUsageFetcher.$loadOAuthCredentialsOverride.withValue( + loadCredsOverride) + { + try await fetcher.loadLatestUsage(model: "sonnet") + } } } } } } - } - }) + }) + } - #expect(await loadCounter.current() == 2) - #expect(await delegatedCounter.current() == 1) - #expect(snapshot.primary.usedPercent == 7) + #expect(await loadCounter.current() == 1) + #expect(await delegatedCounter.current() == 0) } @Test diff --git a/Tests/CodexBarTests/ClaudeWebAccountInfoTests.swift b/Tests/CodexBarTests/ClaudeWebAccountInfoTests.swift new file mode 100644 index 0000000000..cd85530cf3 --- /dev/null +++ b/Tests/CodexBarTests/ClaudeWebAccountInfoTests.swift @@ -0,0 +1,61 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct ClaudeWebAccountInfoTests { + @Test + func `selected organization determines the team seat label`() { + let json = """ + { + "email_address": "steipete@gmail.com", + "memberships": [ + { + "seat_tier": "team_standard", + "organization": { + "uuid": "org-standard", + "name": "Standard Org", + "rate_limit_tier": "claude_team", + "billing_type": "stripe_subscription" + } + }, + { + "seat_tier": "team_tier_1", + "organization": { + "uuid": "org-premium", + "name": "Premium Org", + "rate_limit_tier": "claude_team", + "billing_type": "stripe_subscription" + } + } + ] + } + """ + let data = Data(json.utf8) + let premium = ClaudeWebAPIFetcher._parseAccountInfoForTesting(data, orgId: "org-premium") + let standard = ClaudeWebAPIFetcher._parseAccountInfoForTesting(data, orgId: "org-standard") + #expect(premium?.loginMethod == "Claude Team Premium") + #expect(standard?.loginMethod == "Claude Team Standard") + } + + @Test + func `enterprise membership preserves its plan when it has a legacy seat tier`() { + let json = """ + { + "email_address": "enterprise@example.com", + "memberships": [ + { + "seat_tier": "team_tier_1", + "organization": { + "uuid": "org-enterprise", + "name": "Enterprise Org", + "rate_limit_tier": "claude_enterprise", + "billing_type": "stripe_subscription" + } + } + ] + } + """ + let account = ClaudeWebAPIFetcher._parseAccountInfoForTesting(Data(json.utf8), orgId: "org-enterprise") + #expect(account?.loginMethod == "Claude Enterprise") + } +} diff --git a/Tests/CodexBarTests/ClaudeWebCookieRenewalTests.swift b/Tests/CodexBarTests/ClaudeWebCookieRenewalTests.swift new file mode 100644 index 0000000000..b90bb36389 --- /dev/null +++ b/Tests/CodexBarTests/ClaudeWebCookieRenewalTests.swift @@ -0,0 +1,719 @@ +import Foundation +import Testing +@testable import CodexBarCore + +@Suite(.serialized) +struct ClaudeWebCookieRenewalTests { + @Test + func `stalled prepaid credits request does not fail completed usage`() async throws { + let probe = ClaudePrepaidRequestProbe() + let transport = ProviderHTTPTransportHandler { request in + let url = try #require(request.url) + if url.path == "/api/organizations/org-123/prepaid/credits" { + try await probe.stallUntilCancelled() + } + let (response, data) = try Self.response(for: request, setCookie: nil) + return (data, response) + } + let usage = try await ClaudeWebPrepaidCreditsRequest.$timeoutOverrideForTesting.withValue( + .milliseconds(20)) + { + try await ClaudeWebHTTPTransport.$overrideForTesting.withValue(transport) { + try await ClaudeWebAPIFetcher.fetchUsage( + cookieHeader: "sessionKey=sk-ant-manual-token") + } + } + + #expect(usage.sessionPercentUsed == 11) + #expect(usage.extraUsageCost?.balance == nil) + #expect(await probe.waitForCancellation()) + } + + @Test + func `caller cancellation during prepaid credits request is propagated`() async { + let probe = ClaudePrepaidRequestProbe() + let transport = ProviderHTTPTransportHandler { request in + let url = try #require(request.url) + if url.path == "/api/organizations/org-123/prepaid/credits" { + try await probe.stallUntilCancelled() + } + let (response, data) = try Self.response(for: request, setCookie: nil) + return (data, response) + } + let fetchTask = Task { + try await ClaudeWebHTTPTransport.$overrideForTesting.withValue(transport) { + try await ClaudeWebAPIFetcher.fetchUsage( + cookieHeader: "sessionKey=sk-ant-manual-token") + } + } + + await probe.waitUntilStarted() + fetchTask.cancel() + + await #expect(throws: CancellationError.self) { + try await fetchTask.value + } + #expect(await probe.waitForCancellation()) + } + + @Test + func `web fetch merges prepaid credits into Claude extra usage cost`() async throws { + try await self.withClaudeWebStub { request in + let url = try #require(request.url) + if url.path == "/api/organizations/org-123/prepaid/credits" { + return Self.jsonResponse( + url: url, + body: #"{"amount":10000,"currency":"USD"}"#, + setCookie: nil) + } + if url.path == "/api/organizations/org-123/usage" { + return Self.jsonResponse( + url: url, + body: """ + { + "five_hour": { "utilization": 11 }, + "extra_usage": { + "is_enabled": true, + "monthly_limit": 2000, + "used_credits": 500, + "currency": "USD" + } + } + """, + setCookie: nil) + } + return try Self.response(for: request, setCookie: nil) + } operation: { + let usage = try await ClaudeWebAPIFetcher.fetchUsage( + cookieHeader: "sessionKey=sk-ant-manual-token") + + #expect(usage.extraUsageCost?.balance == 100) + #expect(usage.extraUsageCost?.currencyCode == "USD") + } + } + + @Test + func `balance only fetch skips legacy overage request`() async throws { + let paths = RequestHeaderLog() + try await self.withClaudeWebStub { request in + let url = try #require(request.url) + paths.append(url.path) + if url.path == "/api/organizations/org-123/prepaid/credits" { + return Self.jsonResponse( + url: url, + body: #"{"amount":10000,"currency":"USD"}"#, + setCookie: nil) + } + return try Self.response(for: request, setCookie: nil) + } operation: { + let usage = try await ClaudeWebAPIFetcher.fetchUsage( + cookieHeader: "sessionKey=sk-ant-manual-token", + includeUsageDetails: false, + includePrepaidBalance: true) + + #expect(usage.extraUsageCost?.balance == 100) + #expect(usage.extraUsageCost?.used == 0) + #expect(usage.extraUsageCost?.limit == 0) + #expect(!paths.values.contains("/api/organizations/org-123/overage_spend_limit")) + } + } + + @Test + func `balance enrichment preserves extra usage cost from usage response`() async throws { + let paths = RequestHeaderLog() + try await self.withClaudeWebStub { request in + let url = try #require(request.url) + paths.append(url.path) + switch url.path { + case "/api/organizations/org-123/usage": + return Self.jsonResponse( + url: url, + body: """ + { + "five_hour": { "utilization": 11 }, + "extra_usage": { + "is_enabled": true, + "monthly_limit": 10000, + "used_credits": 42, + "currency": "USD" + } + } + """, + setCookie: nil) + case "/api/organizations/org-123/prepaid/credits": + return Self.jsonResponse( + url: url, + body: #"{"amount":9958,"currency":"USD"}"#, + setCookie: nil) + default: + return try Self.response(for: request, setCookie: nil) + } + } operation: { + let usage = try await ClaudeWebAPIFetcher.fetchUsage( + cookieHeader: "sessionKey=sk-ant-manual-token", + includeUsageDetails: false, + includePrepaidBalance: true) + + #expect(usage.extraUsageCost?.used == 0.42) + #expect(usage.extraUsageCost?.limit == 100) + #expect(usage.extraUsageCost?.balance == 99.58) + #expect(!paths.values.contains("/api/organizations/org-123/overage_spend_limit")) + } + } + + @Test + func `web fetch skips prepaid credits when optional usage is disabled`() async throws { + let paths = RequestHeaderLog() + try await self.withClaudeWebStub { request in + paths.append(request.url?.path) + return try Self.response(for: request, setCookie: nil) + } operation: { + let usage = try await ClaudeWebAPIFetcher.fetchUsage( + cookieHeader: "sessionKey=sk-ant-manual-token", + includePrepaidBalance: false) + + #expect(usage.sessionPercentUsed == 11) + #expect(!paths.values.contains("/api/organizations/org-123/prepaid/credits")) + } + } + + @Test + func `cached web session key renews from set cookie after successful fetch`() async throws { + try await self.withIsolatedCookieCache { + CookieHeaderCache.store( + provider: .claude, + cookieHeader: "sessionKey=sk-ant-old-token", + sourceLabel: "Chrome") + defer { CookieHeaderCache.clear(provider: .claude) } + let usageCookies = RequestHeaderLog() + + try await self.withClaudeWebStub { request in + if request.url?.path == "/api/organizations/org-123/usage" { + usageCookies.append(request.value(forHTTPHeaderField: "Cookie")) + } + return try Self.response(for: request, setCookie: Self.renewedSessionCookie) + } operation: { + let usage = try await ClaudeWebAPIFetcher.fetchUsage(browserDetection: BrowserDetection(cacheTTL: 0)) + + #expect(usage.sessionPercentUsed == 11) + #expect(usage.weeklyPercentUsed == 22) + #expect(usageCookies.values == ["sessionKey=sk-ant-renewed-token"]) + let cached = try #require(CookieHeaderCache.load(provider: .claude)) + #expect(cached.cookieHeader == "sessionKey=sk-ant-renewed-token") + #expect(cached.sourceLabel == "Chrome") + } + } + } + + @Test + func `cached fetch without renewal does not block concurrent renewal`() async throws { + try await self.withIsolatedCookieCache { + CookieHeaderCache.store( + provider: .claude, + cookieHeader: "sessionKey=sk-ant-old-token", + sourceLabel: "Chrome", + now: Date(timeIntervalSince1970: 1)) + defer { CookieHeaderCache.clear(provider: .claude) } + let initial = try #require(CookieHeaderCache.load(provider: .claude)) + + try await self.withClaudeWebStub { request in + try Self.response(for: request, setCookie: nil) + } operation: { + _ = try await ClaudeWebAPIFetcher.fetchUsage(browserDetection: BrowserDetection(cacheTTL: 0)) + } + + let renewed = CookieHeaderCache.storeIfCurrent( + provider: .claude, + expected: initial, + cookieHeader: "sessionKey=sk-ant-concurrent-renewal", + sourceLabel: "Chrome") + #expect(renewed) + #expect(CookieHeaderCache.load(provider: .claude)?.cookieHeader == + "sessionKey=sk-ant-concurrent-renewal") + } + } + + @Test + func `browser fallback replaces stale cache when conditional clear fails`() async throws { + try await self.withIsolatedCookieCache { + CookieHeaderCache.store( + provider: .claude, + cookieHeader: "sessionKey=sk-ant-stale-token", + sourceLabel: "Chrome") + defer { CookieHeaderCache.clear(provider: .claude) } + let imported = ClaudeWebAPIFetcher.SessionKeyInfo( + key: "sk-ant-imported-token", + sourceLabel: "Safari", + cookieCount: 1) + + try await KeychainCacheStore.withClearFailureStatusOverrideForTesting(errSecInteractionNotAllowed) { + try await ClaudeWebSessionKeyImport.$overrideForTesting.withValue(imported) { + try await self.withClaudeWebStub { request in + let isStale = request.value(forHTTPHeaderField: "Cookie") == + "sessionKey=sk-ant-stale-token" + if request.url?.path == "/api/organizations", isStale { + let url = try #require(request.url) + return Self.jsonResponse( + url: url, + body: "{}", + statusCode: 401, + setCookie: nil) + } + return try Self.response(for: request, setCookie: nil) + } operation: { + _ = try await ClaudeWebAPIFetcher.fetchUsage( + browserDetection: BrowserDetection(cacheTTL: 0)) + } + } + } + + let cached = try #require(CookieHeaderCache.load(provider: .claude)) + #expect(cached.cookieHeader == "sessionKey=sk-ant-imported-token") + #expect(cached.sourceLabel == "Safari") + } + } + + @Test + func `concurrent cached fetches serialize session key rotations`() async throws { + try await self.withIsolatedCookieCache { + CookieHeaderCache.store( + provider: .claude, + cookieHeader: "sessionKey=sk-ant-initial-token", + sourceLabel: "Chrome") + defer { CookieHeaderCache.clear(provider: .claude) } + let probe = ConcurrentClaudeFetchProbe() + let transport = ProviderHTTPTransportHandler { request in + let setCookie: String? = if request.url?.path == "/api/organizations" { + await probe.organizationSessionCookie( + requestCookie: request.value(forHTTPHeaderField: "Cookie")) + } else { + nil + } + let (response, data) = try Self.response(for: request, setCookie: setCookie) + return (data, response) + } + + try await ClaudeWebHTTPTransport.$overrideForTesting.withValue(transport) { + let first = Task { + try await ClaudeWebAPIFetcher.fetchUsage(browserDetection: BrowserDetection(cacheTTL: 0)) + } + await probe.waitForOrganizationCount(1) + let second = Task { + try await ClaudeWebAPIFetcher.fetchUsage(browserDetection: BrowserDetection(cacheTTL: 0)) + } + for _ in 0..<20 { + await Task.yield() + } + #expect(await probe.organizationRequestCount == 1) + + await probe.releaseFirstRequest() + _ = try await first.value + _ = try await second.value + } + + #expect(await probe.organizationRequestCookies == [ + "sessionKey=sk-ant-initial-token", + "sessionKey=sk-ant-first-rotation", + ]) + #expect(CookieHeaderCache.load(provider: .claude)?.cookieHeader == + "sessionKey=sk-ant-second-rotation") + } + } + + @Test + func `cancelled waiting fetch relinquishes the serialization gate`() async throws { + try await self.withIsolatedCookieCache { + CookieHeaderCache.store( + provider: .claude, + cookieHeader: "sessionKey=sk-ant-initial-token", + sourceLabel: "Chrome") + defer { CookieHeaderCache.clear(provider: .claude) } + let probe = ConcurrentClaudeFetchProbe() + let transport = ProviderHTTPTransportHandler { request in + let setCookie: String? = if request.url?.path == "/api/organizations" { + await probe.organizationSessionCookie( + requestCookie: request.value(forHTTPHeaderField: "Cookie")) + } else { + nil + } + let (response, data) = try Self.response(for: request, setCookie: setCookie) + return (data, response) + } + + try await ClaudeWebHTTPTransport.$overrideForTesting.withValue(transport) { + let first = Task { + try await ClaudeWebAPIFetcher.fetchUsage(browserDetection: BrowserDetection(cacheTTL: 0)) + } + await probe.waitForOrganizationCount(1) + let cancelled = Task { + try await ClaudeWebAPIFetcher.fetchUsage(browserDetection: BrowserDetection(cacheTTL: 0)) + } + for _ in 0..<20 { + await Task.yield() + } + cancelled.cancel() + await #expect(throws: CancellationError.self) { + try await cancelled.value + } + #expect(await probe.organizationRequestCount == 1) + + await probe.releaseFirstRequest() + _ = try await first.value + _ = try await ClaudeWebAPIFetcher.fetchUsage(browserDetection: BrowserDetection(cacheTTL: 0)) + } + + #expect(await probe.organizationRequestCookies == [ + "sessionKey=sk-ant-initial-token", + "sessionKey=sk-ant-first-rotation", + ]) + #expect(CookieHeaderCache.load(provider: .claude)?.cookieHeader == + "sessionKey=sk-ant-second-rotation") + } + } + + @Test + func `manual web session fetch does not rewrite cached cookie`() async throws { + try await self.withIsolatedCookieCache { + CookieHeaderCache.store( + provider: .claude, + cookieHeader: "sessionKey=sk-ant-cache-token", + sourceLabel: "Chrome") + defer { CookieHeaderCache.clear(provider: .claude) } + let usageCookies = RequestHeaderLog() + + try await self.withClaudeWebStub { request in + if request.url?.path == "/api/organizations/org-123/usage" { + usageCookies.append(request.value(forHTTPHeaderField: "Cookie")) + } + return try Self.response(for: request, setCookie: Self.renewedSessionCookie) + } operation: { + let usage = try await ClaudeWebAPIFetcher.fetchUsage(cookieHeader: "sessionKey=sk-ant-manual-token") + + #expect(usage.sessionPercentUsed == 11) + #expect(usageCookies.values == ["sessionKey=sk-ant-renewed-token"]) + let cached = try #require(CookieHeaderCache.load(provider: .claude)) + #expect(cached.cookieHeader == "sessionKey=sk-ant-cache-token") + #expect(cached.sourceLabel == "Chrome") + } + } + } + + @Test + func `usage and prepaid response renewals propagate to later requests and cache`() async throws { + try await self.withIsolatedCookieCache { + CookieHeaderCache.store( + provider: .claude, + cookieHeader: "sessionKey=sk-ant-old-token", + sourceLabel: "Chrome") + defer { CookieHeaderCache.clear(provider: .claude) } + let usageCookies = RequestHeaderLog() + let overageCookies = RequestHeaderLog() + let prepaidCookies = RequestHeaderLog() + let accountCookies = RequestHeaderLog() + + try await self.withClaudeWebStub { request in + let path = request.url?.path + switch path { + case "/api/organizations/org-123/usage": + usageCookies.append(request.value(forHTTPHeaderField: "Cookie")) + case "/api/organizations/org-123/overage_spend_limit": + overageCookies.append(request.value(forHTTPHeaderField: "Cookie")) + case "/api/organizations/org-123/prepaid/credits": + prepaidCookies.append(request.value(forHTTPHeaderField: "Cookie")) + case "/api/account": + accountCookies.append(request.value(forHTTPHeaderField: "Cookie")) + default: + break + } + let setCookie: String? = switch path { + case "/api/organizations/org-123/usage": + Self.renewedSessionCookie + case "/api/organizations/org-123/prepaid/credits": + "sessionKey=sk-ant-prepaid-token; Path=/; HttpOnly" + default: + nil + } + if path == "/api/organizations/org-123/prepaid/credits" { + return try Self.jsonResponse( + url: #require(request.url), + body: #"{"amount":10000,"currency":"USD"}"#, + setCookie: setCookie) + } + return try Self.response( + for: request, + setCookie: setCookie) + } operation: { + _ = try await ClaudeWebAPIFetcher.fetchUsage(browserDetection: BrowserDetection(cacheTTL: 0)) + + #expect(usageCookies.values == ["sessionKey=sk-ant-old-token"]) + #expect(overageCookies.values == ["sessionKey=sk-ant-renewed-token"]) + #expect(prepaidCookies.values == ["sessionKey=sk-ant-renewed-token"]) + #expect(accountCookies.values == ["sessionKey=sk-ant-prepaid-token"]) + let cached = try #require(CookieHeaderCache.load(provider: .claude)) + #expect(cached.cookieHeader == "sessionKey=sk-ant-prepaid-token") + } + } + } + + @Test + func `renewal can return to initial session key`() async throws { + try await self.withIsolatedCookieCache { + CookieHeaderCache.store( + provider: .claude, + cookieHeader: "sessionKey=sk-ant-initial-token", + sourceLabel: "Chrome") + defer { CookieHeaderCache.clear(provider: .claude) } + let usageCookies = RequestHeaderLog() + let overageCookies = RequestHeaderLog() + let accountCookies = RequestHeaderLog() + + try await self.withClaudeWebStub { request in + let path = request.url?.path + switch path { + case "/api/organizations/org-123/usage": + usageCookies.append(request.value(forHTTPHeaderField: "Cookie")) + case "/api/organizations/org-123/overage_spend_limit": + overageCookies.append(request.value(forHTTPHeaderField: "Cookie")) + case "/api/account": + accountCookies.append(request.value(forHTTPHeaderField: "Cookie")) + default: + break + } + let setCookie: String? = switch path { + case "/api/organizations": + "sessionKey=sk-ant-intermediate-token; Path=/; HttpOnly" + case "/api/organizations/org-123/usage": + "sessionKey=sk-ant-initial-token; Path=/; HttpOnly" + default: + nil + } + return try Self.response(for: request, setCookie: setCookie) + } operation: { + _ = try await ClaudeWebAPIFetcher.fetchUsage(browserDetection: BrowserDetection(cacheTTL: 0)) + + #expect(usageCookies.values == ["sessionKey=sk-ant-intermediate-token"]) + #expect(overageCookies.values == ["sessionKey=sk-ant-initial-token"]) + #expect(accountCookies.values == ["sessionKey=sk-ant-initial-token"]) + let cached = try #require(CookieHeaderCache.load(provider: .claude)) + #expect(cached.cookieHeader == "sessionKey=sk-ant-initial-token") + #expect(cached.sourceLabel == "Chrome") + } + } + } + + @Test + func `last session key assignment in one response wins`() async throws { + try await self.withIsolatedCookieCache { + CookieHeaderCache.store( + provider: .claude, + cookieHeader: "sessionKey=sk-ant-old-token", + sourceLabel: "Chrome") + defer { CookieHeaderCache.clear(provider: .claude) } + let usageCookies = RequestHeaderLog() + let overageCookies = RequestHeaderLog() + let accountCookies = RequestHeaderLog() + + try await self.withClaudeWebStub { request in + let path = request.url?.path + switch path { + case "/api/organizations/org-123/usage": + usageCookies.append(request.value(forHTTPHeaderField: "Cookie")) + case "/api/organizations/org-123/overage_spend_limit": + overageCookies.append(request.value(forHTTPHeaderField: "Cookie")) + case "/api/account": + accountCookies.append(request.value(forHTTPHeaderField: "Cookie")) + default: + break + } + let setCookie = path == "/api/organizations" + ? "sessionKey=sk-ant-first-token; Expires=Wed, 21 Oct 2030 07:28:00 GMT; Path=/, " + + "sessionKey=sk-ant-final-token; Path=/; HttpOnly" + : nil + return try Self.response(for: request, setCookie: setCookie) + } operation: { + _ = try await ClaudeWebAPIFetcher.fetchUsage(browserDetection: BrowserDetection(cacheTTL: 0)) + + #expect(usageCookies.values == ["sessionKey=sk-ant-final-token"]) + #expect(overageCookies.values == ["sessionKey=sk-ant-final-token"]) + #expect(accountCookies.values == ["sessionKey=sk-ant-final-token"]) + let cached = try #require(CookieHeaderCache.load(provider: .claude)) + #expect(cached.cookieHeader == "sessionKey=sk-ant-final-token") + #expect(cached.sourceLabel == "Chrome") + } + } + } + + private static let renewedSessionCookie = + "sessionKey=sk-ant-renewed-token; Path=/; HttpOnly; Secure; SameSite=Lax" + + private func withIsolatedCookieCache(_ operation: () async throws -> T) async rethrows -> T { + let legacyBase = FileManager.default.temporaryDirectory + .appendingPathComponent("claude-web-renewal-\(UUID().uuidString)", isDirectory: true) + return try await KeychainCacheStore.withServiceOverrideForTesting("claude-web-renewal-\(UUID().uuidString)") { + try await CookieHeaderCache.withLegacyBaseURLOverrideForTesting(legacyBase) { + KeychainCacheStore.setTestStoreForTesting(true) + defer { KeychainCacheStore.setTestStoreForTesting(false) } + CookieHeaderCache.resetDisplayCacheForTesting() + defer { CookieHeaderCache.resetDisplayCacheForTesting() } + return try await operation() + } + } + } + + private func withClaudeWebStub( + handler: @escaping @Sendable (URLRequest) throws -> (HTTPURLResponse, Data), + operation: () async throws -> T) async rethrows -> T + { + let transport = ProviderHTTPTransportHandler { request in + let (response, data) = try handler(request) + return (data, response) + } + return try await ClaudeWebHTTPTransport.$overrideForTesting.withValue(transport) { + try await operation() + } + } + + private static func response( + for request: URLRequest, + setCookie: String?) throws -> (HTTPURLResponse, Data) + { + let url = try #require(request.url) + switch url.path { + case "/api/organizations": + return self.jsonResponse( + url: url, + body: #"[{"uuid":"org-123","name":"Test Org","capabilities":["chat"]}]"#, + setCookie: setCookie) + case "/api/organizations/org-123/usage": + return self.jsonResponse( + url: url, + body: """ + { + "five_hour": { "utilization": 11 }, + "seven_day": { "utilization": 22 } + } + """, + setCookie: setCookie) + case "/api/account", "/api/organizations/org-123/overage_spend_limit": + return self.jsonResponse(url: url, body: "{}", statusCode: 404, setCookie: setCookie) + default: + return self.jsonResponse(url: url, body: "{}", statusCode: 404, setCookie: setCookie) + } + } + + private static func jsonResponse( + url: URL, + body: String, + statusCode: Int = 200, + setCookie: String?) -> (HTTPURLResponse, Data) + { + var headerFields = ["Content-Type": "application/json"] + if let setCookie { + headerFields["Set-Cookie"] = setCookie + } + let response = HTTPURLResponse( + url: url, + statusCode: statusCode, + httpVersion: "HTTP/1.1", + headerFields: headerFields)! + return (response, Data(body.utf8)) + } +} + +private actor ClaudePrepaidRequestProbe { + private var started = false + private var cancelled = false + private var startedWaiters: [CheckedContinuation] = [] + + func stallUntilCancelled() async throws { + self.started = true + for waiter in self.startedWaiters { + waiter.resume() + } + self.startedWaiters.removeAll() + + do { + try await Task.sleep(for: .seconds(10)) + } catch { + self.cancelled = true + throw error + } + throw CancellationError() + } + + func waitUntilStarted() async { + if self.started { return } + await withCheckedContinuation { continuation in + self.startedWaiters.append(continuation) + } + } + + func waitForCancellation() async -> Bool { + for _ in 0..<100 { + if self.cancelled { return true } + try? await Task.sleep(for: .milliseconds(10)) + } + return self.cancelled + } +} + +private final class RequestHeaderLog: @unchecked Sendable { + private let lock = NSLock() + private var storage: [String?] = [] + + var values: [String?] { + self.lock.lock() + defer { self.lock.unlock() } + return self.storage + } + + func append(_ value: String?) { + self.lock.lock() + self.storage.append(value) + self.lock.unlock() + } +} + +private actor ConcurrentClaudeFetchProbe { + private var requestCookies: [String?] = [] + private var organizationCountWaiters: [(Int, CheckedContinuation)] = [] + private var firstRequestReleased = false + private var firstRequestReleaseWaiters: [CheckedContinuation] = [] + + var organizationRequestCount: Int { + self.requestCookies.count + } + + var organizationRequestCookies: [String?] { + self.requestCookies + } + + func organizationSessionCookie(requestCookie: String?) async -> String { + self.requestCookies.append(requestCookie) + let ordinal = self.requestCookies.count + let readyWaiters = self.organizationCountWaiters.filter { $0.0 <= ordinal } + self.organizationCountWaiters.removeAll { $0.0 <= ordinal } + readyWaiters.forEach { $0.1.resume() } + if ordinal == 1, !self.firstRequestReleased { + await withCheckedContinuation { continuation in + self.firstRequestReleaseWaiters.append(continuation) + } + } + let value = ordinal == 1 ? "sk-ant-first-rotation" : "sk-ant-second-rotation" + return "sessionKey=\(value); Path=/; HttpOnly" + } + + func waitForOrganizationCount(_ count: Int) async { + if self.requestCookies.count >= count { return } + await withCheckedContinuation { continuation in + self.organizationCountWaiters.append((count, continuation)) + } + } + + func releaseFirstRequest() { + self.firstRequestReleased = true + let waiters = self.firstRequestReleaseWaiters + self.firstRequestReleaseWaiters.removeAll() + waiters.forEach { $0.resume() } + } +} diff --git a/Tests/CodexBarTests/ClaudeWebFetchDeadlineTests.swift b/Tests/CodexBarTests/ClaudeWebFetchDeadlineTests.swift new file mode 100644 index 0000000000..2972e61f0e --- /dev/null +++ b/Tests/CodexBarTests/ClaudeWebFetchDeadlineTests.swift @@ -0,0 +1,507 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct ClaudeWebFetchDeadlineTests { + @Test + func `prepaid timeout preserves usage before outer web deadline`() async throws { + let context = Self.makeContext(sourceMode: .web, webTimeout: 0.5) + let transport = ProviderHTTPTransportHandler { request in + let url = try #require(request.url) + let body: String + let statusCode: Int + switch url.path { + case "/api/organizations": + body = #"[{"uuid":"org-123","name":"Test Org","capabilities":["chat"]}]"# + statusCode = 200 + case "/api/organizations/org-123/usage": + body = #"{"five_hour":{"utilization":11}}"# + statusCode = 200 + case "/api/organizations/org-123/prepaid/credits": + try await Task.sleep(for: .seconds(10)) + throw CancellationError() + default: + body = "{}" + statusCode = 404 + } + let response = try #require(HTTPURLResponse( + url: url, + statusCode: statusCode, + httpVersion: "HTTP/1.1", + headerFields: ["Content-Type": "application/json"])) + return (Data(body.utf8), response) + } + let strategy = ClaudeWebFetchStrategy(browserDetection: context.browserDetection) + let result = try await ClaudeWebPrepaidCreditsRequest.$timeoutOverrideForTesting.withValue( + .milliseconds(20)) + { + try await ClaudeWebHTTPTransport.$overrideForTesting.withValue(transport) { + try await strategy.fetch(context) + } + } + + #expect(result.usage.primary?.usedPercent == 11) + #expect(result.usage.providerCost?.balance == nil) + } + + @Test + func `CLI auto descriptor defers browser probe and falls back after web deadline`() async throws { + let planningProbe = ClaudeWebPlanningAvailabilityProbe() + let webProbe = ClaudeWebDeadlineProbe() + let cliPath = try Self.makeLoggedInClaudeCLI() + defer { try? FileManager.default.removeItem(atPath: cliPath) } + let context = Self.makeContext( + sourceMode: .auto, + webTimeout: 0.01, + cookieSource: .auto, + env: ["CLAUDE_CLI_PATH": cliPath]) + let availabilityOverride: @Sendable (ProviderFetchContext, BrowserDetection) -> Bool = { _, _ in + planningProbe.stallAndReportUnavailable() + } + let usageLoader: ClaudeWebFetchStrategy.UsageLoader = { _ in + await webProbe.waitUntilReleased() + return Self.makeClaudeUsage() + } + let cliFetchOverride: @Sendable (String, TimeInterval, Bool) async throws -> ClaudeStatusSnapshot = + { _, _, _ in Self.makeClaudeStatus() } + + let outcome = await ClaudeWebFetchStrategy.$availabilityProbeOverrideForTesting.withValue( + availabilityOverride) + { + await ClaudeWebFetchStrategy.$usageLoaderOverrideForTesting.withValue(usageLoader) { + await ClaudeStatusProbe.$fetchOverride.withValue(cliFetchOverride) { + await ClaudeProviderDescriptor.makeDescriptor().fetchPlan.fetchOutcome( + context: context, + provider: .claude) + } + } + } + await webProbe.release() + let result = try outcome.result.get() + + #expect(!planningProbe.wasInvoked) + #expect(result.strategyID == "claude.cli") + #expect(outcome.attempts.map(\.strategyID) == ["claude.web", "claude.cli"]) + #expect(outcome.attempts.map(\.wasAvailable) == [true, true]) + #expect(outcome.attempts.first?.errorDescription?.contains("Claude web usage fetch timed out") == true) + } + + @Test + func `stalled app auto browser probe does not delay CLI success`() async throws { + let planningProbe = ClaudeWebPlanningAvailabilityProbe() + let cliPath = try Self.makeLoggedInClaudeCLI() + defer { try? FileManager.default.removeItem(atPath: cliPath) } + let context = Self.makeContext( + runtime: .app, + sourceMode: .auto, + webTimeout: 60, + cookieSource: .auto, + env: [ + "CLAUDE_CLI_PATH": cliPath, + ClaudeOAuthCredentialsStore.environmentTokenKey: "oauth-token", + ]) + let availabilityOverride: @Sendable (ProviderFetchContext, BrowserDetection) -> Bool = { _, _ in + planningProbe.stallAndReportUnavailable() + } + let oauthLoadOverride: (@Sendable ( + [String: String], + Bool, + Bool) async throws -> ClaudeOAuthCredentials)? = { _, _, _ in + throw ClaudeUsageError.oauthFailed("stub OAuth failure") + } + let cliFetchOverride: @Sendable (String, TimeInterval, Bool) async throws -> ClaudeStatusSnapshot = + { _, _, _ in Self.makeClaudeStatus() } + + let outcome = await ClaudeCLIBackgroundAvailability.withIsolatedStoreForTesting { + ClaudeCLIBackgroundAvailability.establish(binary: cliPath) + return await KeychainAccessGate.withTaskOverrideForTesting(false) { + await ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.always) { + await ClaudeWebFetchStrategy.$availabilityProbeOverrideForTesting.withValue( + availabilityOverride) + { + await ClaudeUsageFetcher.$loadOAuthCredentialsOverride.withValue(oauthLoadOverride) { + await ClaudeStatusProbe.$fetchOverride.withValue(cliFetchOverride) { + await ClaudeProviderDescriptor.makeDescriptor().fetchPlan.fetchOutcome( + context: context, + provider: .claude) + } + } + } + } + } + } + let result = try outcome.result.get() + + #expect(result.strategyID == "claude.cli") + #expect(outcome.attempts.map(\.strategyID) == ["claude.oauth", "claude.cli"]) + #expect(!planningProbe.wasInvoked) + } + + @Test + func `caller cancellation during deferred app auto browser probe stops web fallback`() async { + let planningProbe = ClaudeWebPlanningAvailabilityProbe() + let webFetchProbe = ClaudeWebPlanningAvailabilityProbe() + let context = Self.makeContext( + runtime: .app, + sourceMode: .auto, + webTimeout: 60, + cookieSource: .auto, + env: [ + "CLAUDE_CLI_PATH": "/usr/bin/true", + ClaudeOAuthCredentialsStore.environmentTokenKey: "oauth-token", + ]) + let availabilityOverride: @Sendable (ProviderFetchContext, BrowserDetection) -> Bool = { _, _ in + planningProbe.stallAndReportUnavailable() + } + let oauthLoadOverride: (@Sendable ( + [String: String], + Bool, + Bool) async throws -> ClaudeOAuthCredentials)? = { _, _, _ in + throw ClaudeUsageError.oauthFailed("stub OAuth failure") + } + let cliFetchOverride: @Sendable (String, TimeInterval, Bool) async throws -> ClaudeStatusSnapshot = { _, _, _ in + throw ClaudeUsageError.parseFailed("stub CLI failure") + } + let usageLoader: ClaudeWebFetchStrategy.UsageLoader = { _ in + webFetchProbe.recordInvocation() + return Self.makeClaudeUsage() + } + + let fetchTask = Task { + await ClaudeWebFetchStrategy.$availabilityProbeOverrideForTesting.withValue(availabilityOverride) { + await ClaudeWebFetchStrategy.$usageLoaderOverrideForTesting.withValue(usageLoader) { + await ClaudeUsageFetcher.$loadOAuthCredentialsOverride.withValue(oauthLoadOverride) { + await ClaudeStatusProbe.$fetchOverride.withValue(cliFetchOverride) { + await ClaudeProviderDescriptor.makeDescriptor().fetchPlan.fetchOutcome( + context: context, + provider: .claude) + } + } + } + } + } + + while !planningProbe.wasInvoked { + await Task.yield() + } + fetchTask.cancel() + let outcome = await fetchTask.value + planningProbe.release() + + switch outcome.result { + case .success: + Issue.record("Expected caller cancellation to stop the deferred web fallback") + case let .failure(error): + #expect(error is CancellationError) + } + #expect(outcome.attempts.map(\.strategyID) == ["claude.oauth", "claude.cli"]) + #expect(webFetchProbe.invocationCount == 0) + } + + @Test + func `app auto availability and fetch share one web deadline`() async { + let deadlineClock = ClaudeWebDeadlineClock() + let usageProbe = ClaudeWebDeadlineProbe() + let context = Self.makeContext( + runtime: .app, + sourceMode: .auto, + webTimeout: 1, + cookieSource: .auto) + let availabilityOverride: @Sendable (ProviderFetchContext, BrowserDetection) -> Bool = { _, _ in + deadlineClock.advance(by: .milliseconds(990)) + return true + } + let strategy = ClaudeWebFetchStrategy( + browserDetection: context.browserDetection, + usageLoader: { _ in + await usageProbe.waitUntilReleased() + return Self.makeClaudeUsage() + }, + deadlineNow: { deadlineClock.now() }) + + let available = await ClaudeWebFetchStrategy.$availabilityProbeOverrideForTesting.withValue( + availabilityOverride) + { + await strategy.isAvailable(context) + } + #expect(available) + + let startedAt = ContinuousClock.now + do { + _ = try await strategy.fetch(context) + Issue.record("Expected the stalled load to consume only the remaining web deadline") + } catch let error as ClaudeWebFetchStrategyError { + #expect(error == .timedOut(seconds: 1)) + } catch { + Issue.record("Unexpected error: \(error)") + } + let elapsed = startedAt.duration(to: ContinuousClock.now) + await usageProbe.release() + + #expect(elapsed < .milliseconds(300)) + } + + @Test + func `CLI auto timeout cancels web and falls back to CLI`() async throws { + let probe = ClaudeWebDeadlineProbe() + let web = Self.makeTimedOutWebStrategy(probe: probe) + let pipeline = ProviderFetchPipeline { _ in [web, ClaudeWebDeadlineCLIStrategy()] } + let context = Self.makeContext(sourceMode: .auto, webTimeout: 0.01) + + let outcome = await pipeline.fetch(context: context, provider: .claude) + await probe.release() + let result = try outcome.result.get() + + #expect(result.strategyID == "claude.cli") + #expect(outcome.attempts.map(\.strategyID) == ["claude.web", "claude.cli"]) + #expect(outcome.attempts.first?.errorDescription?.contains("Claude web usage fetch timed out") == true) + } + + @Test + func `explicit web timeout surfaces without CLI fallback`() async { + let probe = ClaudeWebDeadlineProbe() + let web = Self.makeTimedOutWebStrategy(probe: probe) + let pipeline = ProviderFetchPipeline { _ in [web, ClaudeWebDeadlineCLIStrategy()] } + let context = Self.makeContext(sourceMode: .web, webTimeout: 0.01) + + let outcome = await pipeline.fetch(context: context, provider: .claude) + await probe.release() + + switch outcome.result { + case .success: + Issue.record("Expected the explicit web deadline to fail") + case let .failure(error): + #expect(error as? ClaudeWebFetchStrategyError == .timedOut(seconds: 0.01)) + } + #expect(outcome.attempts.map(\.strategyID) == ["claude.web"]) + } + + @Test + func `caller cancellation does not fall back to CLI`() async { + let probe = ClaudeWebDeadlineProbe() + let web = Self.makeTimedOutWebStrategy(probe: probe) + let pipeline = ProviderFetchPipeline { _ in [web, ClaudeWebDeadlineCLIStrategy()] } + let context = Self.makeContext(sourceMode: .auto, webTimeout: 60) + let fetchTask = Task { + await pipeline.fetch(context: context, provider: .claude) + } + + await probe.waitUntilStarted() + fetchTask.cancel() + let outcome = await fetchTask.value + await probe.release() + + switch outcome.result { + case .success: + Issue.record("Expected caller cancellation to stop the fetch pipeline") + case let .failure(error): + #expect(error is CancellationError) + } + #expect(outcome.attempts.map(\.strategyID) == ["claude.web"]) + } + + @Test + func `unsafe timeout is rejected before starting web work`() async { + let strategy = ClaudeWebFetchStrategy( + browserDetection: BrowserDetection(cacheTTL: 0), + usageLoader: { _ in + Issue.record("Unsafe timeout should be rejected before invoking the loader") + return Self.makeClaudeUsage() + }) + + for timeout in [-1, .nan, .infinity, .greatestFiniteMagnitude] { + do { + _ = try await strategy.fetch(Self.makeContext(sourceMode: .web, webTimeout: timeout)) + Issue.record("Expected timeout \(timeout) to be rejected") + } catch let error as ClaudeWebFetchStrategyError { + #expect(error == .invalidTimeout) + } catch { + Issue.record("Unexpected error: \(error)") + } + } + } + + private static func makeTimedOutWebStrategy(probe: ClaudeWebDeadlineProbe) -> ClaudeWebFetchStrategy { + ClaudeWebFetchStrategy( + browserDetection: BrowserDetection(cacheTTL: 0), + usageLoader: { _ in + await probe.waitUntilReleased() + return self.makeClaudeUsage() + }) + } + + private static func makeLoggedInClaudeCLI() throws -> String { + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("claude-auth-status-\(UUID().uuidString)") + let script = """ + #!/bin/sh + printf '%s\\n' '{"loggedIn":true}' + """ + try Data(script.utf8).write(to: url) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: url.path) + return url.path + } + + private static func makeContext( + runtime: ProviderRuntime = .cli, + sourceMode: ProviderSourceMode, + webTimeout: TimeInterval, + cookieSource: ProviderCookieSource = .manual, + env: [String: String] = [:]) -> ProviderFetchContext + { + let browserDetection = BrowserDetection(cacheTTL: 0) + return ProviderFetchContext( + runtime: runtime, + sourceMode: sourceMode, + includeCredits: false, + webTimeout: webTimeout, + webDebugDumpHTML: false, + verbose: false, + env: env, + settings: ProviderSettingsSnapshot.make(claude: .init( + usageDataSource: sourceMode == .web ? .web : .auto, + webExtrasEnabled: false, + cookieSource: cookieSource, + manualCookieHeader: cookieSource == .manual ? "sessionKey=sk-ant-session-token" : nil)), + fetcher: UsageFetcher(), + claudeFetcher: ClaudeUsageFetcher(browserDetection: browserDetection), + browserDetection: browserDetection) + } + + private static func makeClaudeUsage() -> ClaudeUsageSnapshot { + ClaudeUsageSnapshot( + primary: RateWindow( + usedPercent: 20, + windowMinutes: 300, + resetsAt: nil, + resetDescription: nil), + secondary: nil, + opus: nil, + updatedAt: Date(timeIntervalSince1970: 1_800_000_100), + accountEmail: nil, + accountOrganization: nil, + loginMethod: nil, + rawText: nil) + } + + private static func makeClaudeStatus() -> ClaudeStatusSnapshot { + ClaudeStatusSnapshot( + sessionPercentLeft: 80, + weeklyPercentLeft: nil, + opusPercentLeft: nil, + accountEmail: nil, + accountOrganization: nil, + loginMethod: nil, + primaryResetDescription: nil, + secondaryResetDescription: nil, + opusResetDescription: nil, + rawText: "stub") + } +} + +private final class ClaudeWebPlanningAvailabilityProbe: @unchecked Sendable { + private let lock = NSLock() + private let releaseSemaphore = DispatchSemaphore(value: 0) + private var invocations = 0 + + var invocationCount: Int { + self.lock.lock() + defer { self.lock.unlock() } + return self.invocations + } + + var wasInvoked: Bool { + self.invocationCount > 0 + } + + func stallAndReportUnavailable() -> Bool { + self.recordInvocation() + _ = self.releaseSemaphore.wait(timeout: .now() + 1) + return false + } + + func recordInvocation() { + self.lock.lock() + self.invocations += 1 + self.lock.unlock() + } + + func release() { + self.releaseSemaphore.signal() + } +} + +private final class ClaudeWebDeadlineClock: @unchecked Sendable { + private let lock = NSLock() + private var instant = ContinuousClock.now + + func now() -> ContinuousClock.Instant { + self.lock.lock() + defer { self.lock.unlock() } + return self.instant + } + + func advance(by duration: Duration) { + self.lock.lock() + self.instant = self.instant.advanced(by: duration) + self.lock.unlock() + } +} + +private actor ClaudeWebDeadlineProbe { + private var started = false + private var released = false + private var startWaiter: CheckedContinuation? + private var releaseWaiter: CheckedContinuation? + + func waitUntilReleased() async { + if !self.started { + self.started = true + self.startWaiter?.resume() + self.startWaiter = nil + } + guard !self.released else { return } + await withCheckedContinuation { continuation in + self.releaseWaiter = continuation + } + } + + func waitUntilStarted() async { + guard !self.started else { return } + await withCheckedContinuation { continuation in + self.startWaiter = continuation + } + } + + func release() { + self.released = true + self.releaseWaiter?.resume() + self.releaseWaiter = nil + } +} + +private struct ClaudeWebDeadlineCLIStrategy: ProviderFetchStrategy { + let id = "claude.cli" + let kind: ProviderFetchKind = .cli + + func isAvailable(_: ProviderFetchContext) async -> Bool { + true + } + + func fetch(_: ProviderFetchContext) async throws -> ProviderFetchResult { + self.makeResult( + usage: UsageSnapshot( + primary: RateWindow( + usedPercent: 20, + windowMinutes: 300, + resetsAt: nil, + resetDescription: nil), + secondary: nil, + updatedAt: Date(timeIntervalSince1970: 1_800_000_100)), + sourceLabel: "claude") + } + + func shouldFallback(on _: Error, context _: ProviderFetchContext) -> Bool { + false + } +} diff --git a/Tests/CodexBarTests/ClaudeWebRecoveryMenuTests.swift b/Tests/CodexBarTests/ClaudeWebRecoveryMenuTests.swift new file mode 100644 index 0000000000..0a2e454368 --- /dev/null +++ b/Tests/CodexBarTests/ClaudeWebRecoveryMenuTests.swift @@ -0,0 +1,221 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +@MainActor +@Suite(.serialized) +struct ClaudeWebRecoveryMenuTests { + @Test + func `unauthorized error explains how to restore web usage`() { + #expect( + ClaudeWebAPIFetcher.FetchError.unauthorized.localizedDescription == + "Sign in to claude.ai (or refresh Claude cookies) to load usage data.") + } + + private func makeSettings() -> SettingsStore { + let suite = "ClaudeWebRecoveryMenuTests-\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suite)! + defaults.removePersistentDomain(forName: suite) + return SettingsStore( + userDefaults: defaults, + configStore: testConfigStore(suiteName: suite), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + } + + private func actions( + error: String? = nil, + source: ClaudeUsageDataSource, + cookieSource: ProviderCookieSource = .auto, + selectedSessionKey: Bool = false, + authenticatedAccountEmail: String? = nil, + authenticatedOAuthWithoutEmail: Bool = false, + attempts: [ProviderFetchAttempt] = []) -> [(String, MenuDescriptor.MenuAction)] + { + let settings = self.makeSettings() + settings.claudeUsageDataSource = source + if selectedSessionKey { + settings.addTokenAccount(provider: .claude, label: "Session", token: "sk-ant-session-token") + } + settings.claudeCookieSource = cookieSource + let fetcher = UsageFetcher() + let store = UsageStore( + fetcher: fetcher, + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings) + if authenticatedAccountEmail != nil || authenticatedOAuthWithoutEmail { + store._setSnapshotForTesting( + UsageSnapshot( + primary: authenticatedOAuthWithoutEmail + ? RateWindow( + usedPercent: 25, + windowMinutes: 5 * 60, + resetsAt: nil, + resetDescription: nil) + : nil, + secondary: nil, + updatedAt: Date(), + identity: ProviderIdentitySnapshot( + providerID: .claude, + accountEmail: authenticatedAccountEmail, + accountOrganization: nil, + loginMethod: "Claude Pro")), + provider: .claude) + } + store.errors[.claude] = error + store.lastFetchAttempts[.claude] = attempts + + return MenuDescriptor.build( + provider: .claude, + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updateReady: false) + .sections + .flatMap(\.entries) + .compactMap { entry in + guard case let .action(label, action) = entry else { return nil } + return (label, action) + } + } + + @Test + func `default account action localizes ambient Claude Code sign in`() { + let actions = CodexBarLocalizationOverride.$appLanguage.withValue("zh-Hant") { + self.actions(source: .auto) + } + + #expect(actions.contains { + $0.0 == "使用 Claude Code 登入…" && $0.1 == .switchAccount(.claude) + }) + #expect(!actions.contains { $0.0 == "Add Account..." }) + } + + @Test + func `authenticated Claude account shows switch action instead of sign in`() { + let actions = self.actions( + source: .auto, + authenticatedAccountEmail: "claude@example.com") + + #expect(actions.contains { + $0.0 == "Switch Account..." && $0.1 == .switchAccount(.claude) + }) + #expect(!actions.contains { $0.0 == "Sign in with Claude Code..." }) + } + + @Test + func `email-less Claude OAuth snapshot shows switch action instead of sign in`() { + let actions = self.actions( + source: .oauth, + authenticatedOAuthWithoutEmail: true) + + #expect(actions.contains { + $0.0 == "Switch Account..." && $0.1 == .switchAccount(.claude) + }) + #expect(!actions.contains { $0.0 == "Sign in with Claude Code..." }) + } + + @Test + func `web session errors show claude relogin action`() { + let errors = [ + ClaudeWebAPIFetcher.FetchError.unauthorized.localizedDescription, + ClaudeWebAPIFetcher.FetchError.noSessionKeyFound.localizedDescription, + ClaudeWebAPIFetcher.FetchError.invalidSessionKey.localizedDescription, + ] + + for error in errors { + let actions = self.actions(error: error, source: .web) + #expect(actions.contains { + $0.0 == "Re-login at claude.ai" && + $0.1 == .loginToProvider(url: "https://claude.ai/") + }) + } + } + + @Test + func `auto source shows relogin action for terminal web session error`() { + let actions = self.actions( + error: ClaudeWebAPIFetcher.FetchError.unauthorized.localizedDescription, + source: .auto) + + #expect(actions.contains { + $0.0 == "Re-login at claude.ai" && + $0.1 == .loginToProvider(url: "https://claude.ai/") + }) + } + + @Test + func `non-web source does not replace account action`() { + let actions = self.actions( + error: ClaudeWebAPIFetcher.FetchError.unauthorized.localizedDescription, + source: .oauth) + + #expect(!actions.contains { $0.0 == "Re-login at claude.ai" }) + } + + @Test + func `manual cookies do not show browser relogin action`() { + let actions = self.actions( + error: ClaudeWebAPIFetcher.FetchError.unauthorized.localizedDescription, + source: .web, + cookieSource: .manual) + + #expect(!actions.contains { $0.0 == "Re-login at claude.ai" }) + } + + @Test + func `selected session account does not show browser relogin action`() { + let actions = self.actions( + error: ClaudeWebAPIFetcher.FetchError.unauthorized.localizedDescription, + source: .web, + cookieSource: .auto, + selectedSessionKey: true) + + #expect(!actions.contains { $0.0 == "Re-login at claude.ai" }) + } + + @Test + func `unavailable web strategy shows relogin action`() { + let actions = self.actions( + error: ProviderFetchError.noAvailableStrategy(.claude).localizedDescription, + source: .web, + attempts: [ + ProviderFetchAttempt( + strategyID: "claude.web", + kind: .web, + wasAvailable: false, + errorDescription: nil), + ]) + + #expect(actions.contains { + $0.0 == "Re-login at claude.ai" && + $0.1 == .loginToProvider(url: "https://claude.ai/") + }) + } + + @Test + func `generic unavailable error without web attempt keeps account action`() { + let actions = self.actions( + error: ProviderFetchError.noAvailableStrategy(.claude).localizedDescription, + source: .auto, + attempts: [ + ProviderFetchAttempt( + strategyID: "claude.cli", + kind: .cli, + wasAvailable: false, + errorDescription: nil), + ]) + + #expect(!actions.contains { $0.0 == "Re-login at claude.ai" }) + } + + @Test + func `unrelated web error does not replace account action`() { + let actions = self.actions( + error: ClaudeWebAPIFetcher.FetchError.serverError(statusCode: 500).localizedDescription, + source: .web) + + #expect(!actions.contains { $0.0 == "Re-login at claude.ai" }) + } +} diff --git a/Tests/CodexBarTests/ClaudeWebRefreshResilienceTests.swift b/Tests/CodexBarTests/ClaudeWebRefreshResilienceTests.swift new file mode 100644 index 0000000000..144a6f6e2c --- /dev/null +++ b/Tests/CodexBarTests/ClaudeWebRefreshResilienceTests.swift @@ -0,0 +1,236 @@ +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) + } + } + } + + @Test + func `web parse failure clears prior Claude snapshot when surfaced`() 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-parse", + prior: prior, + strategy: ClaudeWebParseFailureFetchStrategy(message: "Missing Current session.")) + } + + 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 { + ( + hasSnapshot: store.snapshot(for: .claude) != nil, + error: store.error(for: .claude)) + } + + #expect(!secondResult.hasSnapshot) + #expect(secondResult.error?.localizedCaseInsensitiveContains("Missing Current session") == true) + } + } + } + + @MainActor + private static func makeStore( + suite: String, + prior: UsageSnapshot?, + strategy: any ProviderFetchStrategy = ClaudeWebUnauthorizedFetchStrategy()) 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 [strategy] }), + 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(), + 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 + } +} + +private struct ClaudeWebParseFailureFetchStrategy: ProviderFetchStrategy { + let id = "test.claude-web-parse-failure" + let kind: ProviderFetchKind = .web + let message: String + + func isAvailable(_: ProviderFetchContext) async -> Bool { + true + } + + func fetch(_: ProviderFetchContext) async throws -> ProviderFetchResult { + throw ClaudeStatusProbeError.parseFailed(self.message) + } + + func shouldFallback(on _: Error, context _: ProviderFetchContext) -> Bool { + false + } +} diff --git a/Tests/CodexBarTests/ClaudeWebUsageExtraWindowTests.swift b/Tests/CodexBarTests/ClaudeWebUsageExtraWindowTests.swift index b0d0241c3b..b1d53aeb49 100644 --- a/Tests/CodexBarTests/ClaudeWebUsageExtraWindowTests.swift +++ b/Tests/CodexBarTests/ClaudeWebUsageExtraWindowTests.swift @@ -16,6 +16,125 @@ struct ClaudeWebUsageExtraWindowTests { #expect(parsed.opusPercentUsed == 6) } + @Test + func `parses Claude prepaid credits in minor units`() throws { + let data = Data(#"{"amount":10000,"currency":"usd"}"#.utf8) + let cost = try #require(ClaudeWebAPIFetcher._parsePrepaidCreditsForTesting(data)) + + #expect(cost.balance == 100) + #expect(cost.currencyCode == "USD") + #expect(cost.used == 0) + #expect(cost.limit == 0) + } + + @Test + func `rejects invalid Claude prepaid credit balances`() { + let negative = Data(#"{"amount":-1,"currency":"USD"}"#.utf8) + let missingCurrency = Data(#"{"amount":10000}"#.utf8) + let booleanAmount = Data(#"{"amount":true,"currency":"USD"}"#.utf8) + + #expect(ClaudeWebAPIFetcher._parsePrepaidCreditsForTesting(negative) == nil) + #expect(ClaudeWebAPIFetcher._parsePrepaidCreditsForTesting(missingCurrency) == nil) + #expect(ClaudeWebAPIFetcher._parsePrepaidCreditsForTesting(booleanAmount) == nil) + } + + @Test + func `merges Claude prepaid balance into primary O auth cost`() throws { + let now = Date(timeIntervalSince1970: 1_800_000_000) + let primary = ProviderCostSnapshot( + used: 5, + limit: 20, + currencyCode: "USD", + period: "Monthly cap", + updatedAt: now) + let web = ProviderCostSnapshot( + used: 0, + limit: 0, + currencyCode: "usd", + period: "Extra usage", + balance: 100, + updatedAt: now.addingTimeInterval(1)) + + let merged = try #require(ClaudeUsageFetcher._mergeProviderCostForTesting( + primary: primary, + web: web)) + + #expect(merged.used == 5) + #expect(merged.limit == 20) + #expect(merged.period == "Monthly cap") + #expect(merged.balance == 100) + #expect(merged.updatedAt == now) + } + + @Test + func `does not merge Claude prepaid balance with a different currency`() throws { + let now = Date(timeIntervalSince1970: 1_800_000_000) + let primary = ProviderCostSnapshot( + used: 5, + limit: 20, + currencyCode: "USD", + period: "Monthly cap", + updatedAt: now) + let web = ProviderCostSnapshot( + used: 0, + limit: 0, + currencyCode: "EUR", + period: "Extra usage", + balance: 100, + updatedAt: now) + + let merged = try #require(ClaudeUsageFetcher._mergeProviderCostForTesting( + primary: primary, + web: web)) + + #expect(merged == primary) + } + + @Test + func `web extras require the same Claude account and organization`() { + let snapshot = ClaudeUsageSnapshot( + primary: RateWindow(usedPercent: 7, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + secondary: nil, + opus: nil, + providerCost: nil, + updatedAt: Date(), + accountEmail: nil, + accountOrganization: nil, + loginMethod: "Pro", + rawText: nil) + let webData = ClaudeWebAPIFetcher.WebUsageData( + sessionPercentUsed: 7, + sessionResetsAt: nil, + weeklyPercentUsed: nil, + weeklyResetsAt: nil, + opusPercentUsed: nil, + extraRateWindows: [], + extraUsageCost: nil, + accountOrganization: "Test Org", + accountOrganizationID: "org-123", + accountEmail: "user@example.com", + loginMethod: "Pro") + + #expect(ClaudeUsageFetcher.webExtrasAccountMatches( + snapshot: snapshot, + webData: webData, + oauthProfile: OAuthProfileResponse( + emailAddress: "user@example.com", + organizationUuid: "org-123"))) + #expect(!ClaudeUsageFetcher.webExtrasAccountMatches( + snapshot: snapshot, + webData: webData, + oauthProfile: OAuthProfileResponse( + emailAddress: "other@example.com", + organizationUuid: "org-123"))) + #expect(!ClaudeUsageFetcher.webExtrasAccountMatches( + snapshot: snapshot, + webData: webData, + oauthProfile: OAuthProfileResponse( + emailAddress: "user@example.com", + organizationUuid: "org-other"))) + } + @Test func `ignores merged claude web API omelette usage window`() throws { let json = """ @@ -33,7 +152,7 @@ struct ClaudeWebUsageExtraWindowTests { } @Test - func `parses claude web API cowork null as zero routines window`() throws { + func `omits routines window when claude web API cowork is null`() throws { let json = """ { "five_hour": { "utilization": 9, "resets_at": "2025-12-23T16:00:00.000Z" }, @@ -43,7 +162,91 @@ struct ClaudeWebUsageExtraWindowTests { """ let data = Data(json.utf8) let parsed = try ClaudeWebAPIFetcher._parseUsageResponseForTesting(data) - #expect(parsed.extraRateWindows.first(where: { $0.id == "claude-routines" })?.window.usedPercent == 0) + #expect(parsed.extraRateWindows.contains { $0.id == "claude-routines" } == false) #expect(parsed.extraRateWindows.contains { $0.id == "claude-design" } == false) } + + @Test + func `surfaces Fable scoped weekly limit from claude web API limits array`() throws { + // Real shape observed 2026-07-03 from claude.ai/api/organizations/{org}/usage during + // Anthropic's Fable 5 promotional access window (up to 50% of the weekly limit). + let json = """ + { + "five_hour": { "utilization": 16, "resets_at": "2026-07-03T00:30:00.440902+00:00" }, + "seven_day": { "utilization": 10, "resets_at": "2026-07-08T09:00:00.440924+00:00" }, + "limits": [ + { + "kind": "session", "group": "session", "percent": 16, + "resets_at": "2026-07-03T00:30:00.440902+00:00", "scope": null, "is_active": true + }, + { + "kind": "weekly_all", "group": "weekly", "percent": 10, + "resets_at": "2026-07-08T09:00:00.440924+00:00", "scope": null, "is_active": false + }, + { + "kind": "weekly_scoped", "group": "weekly", "percent": 5, + "resets_at": "2026-07-08T09:00:00.441154+00:00", + "scope": { "model": { "id": null, "display_name": "Fable" }, "surface": null }, + "is_active": false + } + ] + } + """ + let data = Data(json.utf8) + let parsed = try ClaudeWebAPIFetcher._parseUsageResponseForTesting(data) + let fable = parsed.extraRateWindows.first(where: { $0.id == "claude-weekly-scoped-fable" }) + #expect(fable?.title == "Fable only") + #expect(fable?.window.usedPercent == 5) + #expect(fable?.window.resetsAt != nil) + } + + @Test + func `orders scoped weekly windows before daily routines`() throws { + let json = """ + { + "five_hour": { "utilization": 9, "resets_at": "2026-07-03T00:30:00.440902+00:00" }, + "seven_day": { "utilization": 20, "resets_at": "2026-07-08T09:00:00.440924+00:00" }, + "seven_day_cowork": { "utilization": 11, "resets_at": "2026-07-08T09:00:00.440924+00:00" }, + "limits": [ + { + "kind": "weekly_scoped", "group": "weekly", "percent": 29, + "resets_at": "2026-07-08T09:00:00.441154+00:00", + "scope": { "model": { "id": null, "display_name": "Fable" }, "surface": null }, + "is_active": false + } + ] + } + """ + let data = Data(json.utf8) + let parsed = try ClaudeWebAPIFetcher._parseUsageResponseForTesting(data) + #expect(parsed.extraRateWindows.map(\.title) == ["Fable only", "Daily Routines"]) + } + + @Test + func `keeps multiple scoped weekly windows in payload order before routines`() throws { + let json = """ + { + "five_hour": { "utilization": 9, "resets_at": "2026-07-03T00:30:00.440902+00:00" }, + "seven_day": { "utilization": 20, "resets_at": "2026-07-08T09:00:00.440924+00:00" }, + "seven_day_cowork": { "utilization": 11, "resets_at": "2026-07-08T09:00:00.440924+00:00" }, + "limits": [ + { + "kind": "weekly_scoped", "group": "weekly", "percent": 12, + "resets_at": "2026-07-08T09:00:00.441154+00:00", + "scope": { "model": { "id": null, "display_name": "Opus" }, "surface": null }, + "is_active": false + }, + { + "kind": "weekly_scoped", "group": "weekly", "percent": 29, + "resets_at": "2026-07-08T09:00:00.441154+00:00", + "scope": { "model": { "id": null, "display_name": "Fable" }, "surface": null }, + "is_active": false + } + ] + } + """ + let data = Data(json.utf8) + let parsed = try ClaudeWebAPIFetcher._parseUsageResponseForTesting(data) + #expect(parsed.extraRateWindows.map(\.title) == ["Opus only", "Fable only", "Daily Routines"]) + } } diff --git a/Tests/CodexBarTests/ClawRouterUsageFetcherTests.swift b/Tests/CodexBarTests/ClawRouterUsageFetcherTests.swift new file mode 100644 index 0000000000..68713024d8 --- /dev/null +++ b/Tests/CodexBarTests/ClawRouterUsageFetcherTests.swift @@ -0,0 +1,274 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar +@testable import CodexBarCLI + +struct ClawRouterUsageFetcherTests { + @Test + func `parses monthly budget and provider agnostic usage`() throws { + let parsed = try ClawRouterUsageFetcher._parseSnapshotForTesting( + Data(Self.budgetedResponse.utf8), + updatedAt: Date(timeIntervalSince1970: 1)) + + #expect(parsed.budgetLimitUSD == 25) + #expect(parsed.budgetSpentUSD == 0.006) + #expect(parsed.budgetRemainingUSD == 24.994) + #expect(parsed.requestCount == 6) + #expect(parsed.totalTokens == 54191) + #expect(parsed.providers.map(\.provider) == ["openai", "anthropic"]) + + let snapshot = parsed.toUsageSnapshot() + #expect(snapshot.identity?.providerID == .clawrouter) + #expect(snapshot.primary?.usedPercent == 0.024) + #expect(snapshot.secondary == nil) + #expect(snapshot.providerCost?.used == 0.006) + #expect(snapshot.providerCost?.limit == 25) + #expect(snapshot.clawRouterUsage?.providers.map(\.provider) == ["openai", "anthropic"]) + #expect(snapshot.dataConfidence == .exact) + + let reset = try #require(snapshot.primary?.resetsAt) + let expected = try #require(DateComponents( + calendar: Calendar(identifier: .gregorian), + timeZone: TimeZone(secondsFromGMT: 0), + year: 2026, + month: 8, + day: 1).date) + #expect(reset == expected) + } + + @Test + func `supports unmetered policies and arbitrary providers`() throws { + let parsed = try ClawRouterUsageFetcher._parseSnapshotForTesting( + Data(Self.unmeteredResponse.utf8), + updatedAt: Date(timeIntervalSince1970: 1)) + let snapshot = parsed.toUsageSnapshot() + + #expect(!parsed.budgetConfigured) + #expect(parsed.providers.map(\.provider) == ["replicate", "tavily"]) + #expect(snapshot.primary == nil) + #expect(snapshot.identity?.loginMethod == "Unmetered") + #expect(snapshot.providerCost?.used == 1.25) + #expect(snapshot.providerCost?.limit == 0) + } + + @Test + func `usage URL accepts root and versioned base URLs`() throws { + #expect( + try ClawRouterUsageFetcher._usageURLForTesting( + baseURL: #require(URL(string: "https://router.example.com"))).absoluteString == + "https://router.example.com/v1/usage") + #expect( + try ClawRouterUsageFetcher._usageURLForTesting( + baseURL: #require(URL(string: "https://router.example.com/v1"))).absoluteString == + "https://router.example.com/v1/usage") + } + + @Test + func `fetch sends bearer key and maps authorization failure`() async throws { + let transport = ProviderHTTPTransportStub { request in + #expect(request.url?.absoluteString == "https://router.example.com/v1/usage") + #expect(request.httpMethod == "GET") + #expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer smoke-key") + let response = try #require(HTTPURLResponse( + url: request.url!, + statusCode: 401, + httpVersion: nil, + headerFields: nil)) + return (Data(), response) + } + + await #expect(throws: ClawRouterUsageError.invalidCredentials) { + _ = try await ClawRouterUsageFetcher.fetchUsage( + apiKey: "smoke-key", + baseURL: #require(URL(string: "https://router.example.com")), + transport: transport) + } + } + + @Test + func `config projects API key and optional base URL`() { + let config = ProviderConfig( + id: .clawrouter, + apiKey: "router-token", + enterpriseHost: "https://router.example.com") + let environment = ProviderConfigEnvironment.applyProviderConfigOverrides( + base: [:], + provider: .clawrouter, + config: config) + + #expect(environment[ClawRouterSettingsReader.apiKeyEnvironmentKey] == "router-token") + #expect(environment[ClawRouterSettingsReader.baseURLEnvironmentKey] == "https://router.example.com") + #expect(ProviderTokenResolver.clawRouterToken(environment: environment) == "router-token") + } + + @Test + func `endpoint override is HTTPS only`() throws { + let key = ClawRouterSettingsReader.baseURLEnvironmentKey + try ClawRouterSettingsReader.validateEndpointOverride(environment: [key: "router.example.com/v1"]) + #expect(ClawRouterSettingsReader.baseURL(environment: [key: "router.example.com/v1"]).absoluteString == + "https://router.example.com/v1") + #expect(throws: ClawRouterSettingsError.invalidEndpointOverride(key)) { + try ClawRouterSettingsReader.validateEndpointOverride(environment: [key: "http://router.example.com"]) + } + } + + @Test + @MainActor + func `descriptor and settings are registered`() throws { + let descriptor = ProviderDescriptorRegistry.descriptor(for: .clawrouter) + #expect(descriptor.metadata.displayName == "ClawRouter") + #expect(descriptor.cli.aliases.contains("claw-router")) + + let implementation = try #require(ProviderImplementationRegistry.implementation(for: .clawrouter)) + #expect(implementation.id == .clawrouter) + } + + @Test + func `usage snapshot preserves ClawRouter detail when cached`() throws { + let parsed = try ClawRouterUsageFetcher._parseSnapshotForTesting( + Data(Self.budgetedResponse.utf8), + updatedAt: Date(timeIntervalSince1970: 1)) + let encoded = try JSONEncoder().encode(parsed.toUsageSnapshot()) + let decoded = try JSONDecoder().decode(UsageSnapshot.self, from: encoded) + + #expect(decoded.clawRouterUsage == parsed) + #expect(decoded.identity?.providerID == .clawrouter) + } + + @Test + func `text CLI renders budgeted spend and routed usage`() throws { + let parsed = try ClawRouterUsageFetcher._parseSnapshotForTesting( + Data(Self.budgetedResponse.utf8), + updatedAt: Date(timeIntervalSince1970: 1)) + + let output = Self.renderText(parsed.toUsageSnapshot()) + + #expect(output.contains("Spend: $0.01 / $25.00")) + #expect(output.contains("Usage: 6 requests · 54K tokens")) + #expect(output.contains("Results: 5 succeeded · 1 failed")) + #expect(output.contains("Routed providers: openai: 4 · anthropic: 2")) + } + + @Test + func `text CLI renders unmetered and zero spend without a zero limit`() throws { + let unmetered = try ClawRouterUsageFetcher._parseSnapshotForTesting( + Data(Self.unmeteredResponse.utf8), + updatedAt: Date(timeIntervalSince1970: 1)) + let zeroSpend = try ClawRouterUsageFetcher._parseSnapshotForTesting( + Data(Self.unmeteredResponse.replacingOccurrences(of: "1250000", with: "0").utf8), + updatedAt: Date(timeIntervalSince1970: 1)) + + let unmeteredOutput = Self.renderText(unmetered.toUsageSnapshot()) + let zeroSpendOutput = Self.renderText(zeroSpend.toUsageSnapshot()) + + #expect(unmeteredOutput.contains("Spend: $1.25")) + #expect(unmeteredOutput.contains("Usage: 3 requests · 0 tokens")) + #expect(!unmeteredOutput.contains(" / 0.0")) + #expect(zeroSpendOutput.contains("Spend: $0.00")) + #expect(zeroSpendOutput.contains("Usage: 3 requests · 0 tokens")) + #expect(!zeroSpendOutput.contains(" / 0.0")) + } + + private static func renderText(_ snapshot: UsageSnapshot) -> String { + CLIRenderer.renderText( + provider: .clawrouter, + snapshot: snapshot, + credits: nil, + context: RenderContext( + header: "ClawRouter (api)", + status: nil, + useColor: false, + resetStyle: .countdown)) + } + + private static let budgetedResponse = """ + { + "policyId": "openclaw-smoke", + "budget": { + "configured": true, + "ledger": "durable_object", + "windowKey": "openclaw/openclaw-smoke/2026-07", + "limitMicros": 25000000, + "spentMicros": 6000, + "remainingMicros": 24994000 + }, + "usage": { + "ledger": "ready", + "summary": { + "requestCount": 6, + "successCount": 5, + "errorCount": 1, + "inputTokens": 50000, + "outputTokens": 4191, + "totalTokens": 54191, + "actualCostMicros": 6000 + }, + "providers": [ + { + "provider": "anthropic", + "requestCount": 2, + "successCount": 2, + "errorCount": 0, + "totalTokens": 12191, + "actualCostMicros": 2000 + }, + { + "provider": "openai", + "requestCount": 4, + "successCount": 3, + "errorCount": 1, + "totalTokens": 42000, + "actualCostMicros": 4000 + } + ], + "events": [] + } + } + """ + + private static let unmeteredResponse = """ + { + "policyId": "any-provider-policy", + "budget": { + "configured": false, + "ledger": "unmetered", + "windowKey": null, + "limitMicros": null, + "spentMicros": null, + "remainingMicros": null + }, + "usage": { + "ledger": "ready", + "summary": { + "requestCount": 3, + "successCount": 3, + "errorCount": 0, + "inputTokens": 0, + "outputTokens": 0, + "totalTokens": 0, + "actualCostMicros": 1250000 + }, + "providers": [ + { + "provider": "tavily", + "requestCount": 2, + "successCount": 2, + "errorCount": 0, + "totalTokens": 0, + "actualCostMicros": 250000 + }, + { + "provider": "replicate", + "requestCount": 1, + "successCount": 1, + "errorCount": 0, + "totalTokens": 0, + "actualCostMicros": 1000000 + } + ], + "events": [] + } + } + """ +} diff --git a/Tests/CodexBarTests/ClickToCopyOverlayTests.swift b/Tests/CodexBarTests/ClickToCopyOverlayTests.swift new file mode 100644 index 0000000000..449e3ad6bb --- /dev/null +++ b/Tests/CodexBarTests/ClickToCopyOverlayTests.swift @@ -0,0 +1,50 @@ +import AppKit +import Testing +@testable import CodexBar + +@MainActor +struct ClickToCopyOverlayTests { + @Test + func `view stores the latest copyText`() { + let view = ClickToCopyView(copyText: "original") + #expect(view.copyText == "original") + view.copyText = "updated" + #expect(view.copyText == "updated") + } + + @Test + func `pasteboard copy waits for deferred scheduler`() { + var pendingAction: (() -> Void)? + var copiedText: String? + var completed = false + + MenuPasteboardCopy.perform( + "copy me", + scheduler: { pendingAction = $0 }, + writer: { copiedText = $0 }, + completion: { completed = true }) + + #expect(copiedText == nil) + #expect(!completed) + pendingAction?() + #expect(copiedText == "copy me") + #expect(completed) + } + + @Test + func `mouseDown forwards the latest copyText`() { + var copiedText: String? + let view = ClickToCopyView(copyText: "original") { copiedText = $0 } + view.copyText = "updated" + + view.mouseDown(with: NSEvent()) + + #expect(copiedText == "updated") + } + + @Test + func `accepts first mouse so error text overlay is clickable on first focus`() { + let view = ClickToCopyView(copyText: "x") + #expect(view.acceptsFirstMouse(for: nil) == true) + } +} diff --git a/Tests/CodexBarTests/ClinePassProviderTests.swift b/Tests/CodexBarTests/ClinePassProviderTests.swift new file mode 100644 index 0000000000..d8b2f9399f --- /dev/null +++ b/Tests/CodexBarTests/ClinePassProviderTests.swift @@ -0,0 +1,111 @@ +import Foundation +import SwiftUI +import Testing +@testable import CodexBar +@testable import CodexBarCore + +@MainActor +struct ClinePassProviderTests { + @Test + func `provider appears in settings with API key field and official icon`() throws { + let suite = "ClinePassProviderTests-settings" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + let settings = SettingsStore( + userDefaults: defaults, + configStore: testConfigStore(suiteName: suite), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings) + let implementation = ClinePassProviderImplementation() + let context = ProviderSettingsContext( + provider: .clinepass, + settings: settings, + store: store, + boolBinding: { keyPath in + Binding( + get: { settings[keyPath: keyPath] }, + set: { settings[keyPath: keyPath] = $0 }) + }, + stringBinding: { keyPath in + Binding( + get: { settings[keyPath: keyPath] }, + set: { settings[keyPath: keyPath] = $0 }) + }, + statusText: { _ in nil }, + setStatusText: { _, _ in }, + lastAppActiveRunAt: { _ in nil }, + setLastAppActiveRunAt: { _, _ in }, + requestConfirmation: { _ in }, + runLoginFlow: {}) + + #expect(settings.orderedProviders().contains(.clinepass)) + #expect(ProviderCatalog.implementation(for: .clinepass)?.id == .clinepass) + #expect(ProviderDescriptorRegistry.descriptor(for: .clinepass).branding.iconResourceName == + "ProviderIcon-clinepass") + #expect(!implementation.isAvailable(context: ProviderAvailabilityContext( + provider: .clinepass, + settings: settings, + environment: [:]))) + + let field = try #require(implementation.settingsFields(context: context).first) + #expect(field.id == "clinepass-api-key") + #expect(field.kind == .secure) + + field.binding.wrappedValue = "clinepass-test-key" + + #expect(settings.clinePassAPIKey == "clinepass-test-key") + #expect(settings.providerConfig(for: .clinepass)?.sanitizedAPIKey == "clinepass-test-key") + #expect(implementation.isAvailable(context: ProviderAvailabilityContext( + provider: .clinepass, + settings: settings, + environment: [:]))) + } +} + +struct ClinePassUsageFetcherTests { + @Test + func `parser ignores unknown limit types without dropping known windows`() throws { + let payload = Data(#""" + { + "success": true, + "data": { + "limits": [ + { + "type": "five_hour", + "percentUsed": 12.5, + "resetsAt": "2026-07-16T15:00:00Z" + }, + { + "type": "experimental_pool", + "percentUsed": 77, + "resetsAt": "2026-07-16T15:00:00Z" + }, + { + "type": "weekly", + "percentUsed": 25, + "resetsAt": "2026-07-20T00:00:00Z" + }, + { + "type": "monthly", + "percentUsed": 40, + "resetsAt": null + } + ] + } + } + """#.utf8) + + let snapshot = try ClinePassUsageFetcher._parseSnapshotForTesting(payload) + + #expect(snapshot.primary?.usedPercent == 12.5) + #expect(snapshot.primary?.windowMinutes == 5 * 60) + #expect(snapshot.secondary?.usedPercent == 25) + #expect(snapshot.secondary?.windowMinutes == 7 * 24 * 60) + #expect(snapshot.tertiary?.usedPercent == 40) + #expect(snapshot.tertiary?.windowMinutes == 30 * 24 * 60) + } +} diff --git a/Tests/CodexBarTests/CodebuffUsageFetcherTests.swift b/Tests/CodexBarTests/CodebuffUsageFetcherTests.swift index f1d4fd258e..138cc622f8 100644 --- a/Tests/CodexBarTests/CodebuffUsageFetcherTests.swift +++ b/Tests/CodexBarTests/CodebuffUsageFetcherTests.swift @@ -81,6 +81,90 @@ struct CodebuffUsageFetcherTests { #expect(CodebuffStubURLProtocol.requests.map(\.url?.path) == ["/api/v1/usage"]) } + @Test + func `subscription grace does not wait for transport that ignores cancellation`() async throws { + let transport = ProviderHTTPTransportStub { request in + let url = try #require(request.url) + if url.path == "/api/v1/usage" { + let response = try Self.makeResponse( + url: url, + body: #"{"usage":25,"quota":100,"remainingBalance":75}"#) + return (response.1, response.0) + } + let response = try Self.makeResponse( + url: url, + body: #"{"subscription":{"displayName":"Pro","status":"active"}}"#) + return await withCheckedContinuation { continuation in + DispatchQueue.global().asyncAfter(deadline: .now() + 0.5) { + continuation.resume(returning: (response.1, response.0)) + } + } + } + + let startedAt = ContinuousClock.now + let snapshot = try await CodebuffUsageFetcher._fetchUsageForTesting( + apiKey: "cb-test", + transport: transport, + subscriptionGrace: .milliseconds(20)) + let elapsed = startedAt.duration(to: .now) + + #expect(snapshot.creditsUsed == 25) + #expect(snapshot.tier == nil) + #expect(elapsed < .milliseconds(300), "Subscription enrichment delayed usage: \(elapsed)") + + // Let the deliberately cancellation-ignoring test task drain before the test exits. + try await Task.sleep(for: .milliseconds(550)) + } + + @Test + func `cancellation stops subscription while usage transport ignores cancellation`() async throws { + let usageStarted = CodebuffRequestGate() + let subscriptionStarted = CodebuffRequestGate() + let subscriptionCancelled = CodebuffRequestGate() + let transport = ProviderHTTPTransportStub { request in + let url = try #require(request.url) + if url.path == "/api/v1/usage" { + await usageStarted.open() + let response = try Self.makeResponse( + url: url, + body: #"{"usage":25,"quota":100,"remainingBalance":75}"#) + return await withCheckedContinuation { continuation in + DispatchQueue.global().asyncAfter(deadline: .now() + 0.5) { + continuation.resume(returning: (response.1, response.0)) + } + } + } + + await subscriptionStarted.open() + do { + try await Task.sleep(for: .seconds(10)) + } catch { + await subscriptionCancelled.open() + throw error + } + let response = try Self.makeResponse( + url: url, + body: #"{"subscription":{"displayName":"Pro","status":"active"}}"#) + return (response.1, response.0) + } + let task = Task { + try await CodebuffUsageFetcher.fetchUsage( + apiKey: "cb-test", + session: transport) + } + + await usageStarted.wait() + await subscriptionStarted.wait() + let cancellationStartedAt = ContinuousClock.now + task.cancel() + + await subscriptionCancelled.wait() + #expect(cancellationStartedAt.duration(to: .now) < .milliseconds(300)) + await #expect(throws: CancellationError.self) { + try await task.value + } + } + @Test func `api strategy only fetches subscription for credentials file tokens`() { let envResolution = ProviderTokenResolution(token: "env-token", source: .environment) @@ -331,8 +415,34 @@ struct CodebuffUsageFetcherTests { } } +private actor CodebuffRequestGate { + private var isOpen = false + private var continuations: [CheckedContinuation] = [] + + func wait() async { + guard !self.isOpen else { return } + await withCheckedContinuation { continuation in + self.continuations.append(continuation) + } + } + + func open() { + self.isOpen = true + let continuations = self.continuations + self.continuations.removeAll() + for continuation in continuations { + continuation.resume() + } + } +} + final class CodebuffStubURLProtocol: URLProtocol { - nonisolated(unsafe) static var handler: ((URLRequest) throws -> (HTTPURLResponse, Data))? + private static let _handlerBox = LockIsolated<((URLRequest) throws -> (HTTPURLResponse, Data))?>(nil) + static var handler: ((URLRequest) throws -> (HTTPURLResponse, Data))? { + get { Self._handlerBox.value } + set { Self._handlerBox.setValue(newValue) } + } + nonisolated(unsafe) static var requests: [URLRequest] = [] nonisolated(unsafe) static var requestBodies: [Data?] = [] diff --git a/Tests/CodexBarTests/CodexAccountAuthFingerprintApplyTests.swift b/Tests/CodexBarTests/CodexAccountAuthFingerprintApplyTests.swift new file mode 100644 index 0000000000..a2cda2410a --- /dev/null +++ b/Tests/CodexBarTests/CodexAccountAuthFingerprintApplyTests.swift @@ -0,0 +1,1499 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +@MainActor +extension CodexAccountScopedRefreshTests { + @Test + func `same account token refresh fingerprint change keeps codex usage success`() async { + SettingsStore.codexAccountReconciliationSnapshotCacheIntervalOverrideForTesting = 60 + let settings = self.makeSettingsStore( + suite: "CodexAccountScopedRefreshTests-token-refresh-fingerprint-change") + defer { + SettingsStore.codexAccountReconciliationSnapshotCacheIntervalOverrideForTesting = nil + settings._test_liveSystemCodexAccount = nil + } + settings.refreshFrequency = .manual + settings._test_liveSystemCodexAccount = ObservedSystemCodexAccount( + email: "alpha@example.com", + authFingerprint: "old-token-material", + codexHomePath: "/Users/test/.codex", + observedAt: Date(), + identity: .providerAccount(id: "acct-alpha")) + let staleReconciliationSnapshot = settings.codexAccountReconciliationSnapshot + + let store = self.makeUsageStore(settings: settings) + let blocker = BlockingCodexFetchStrategy() + self.installBlockingCodexProvider(on: store, blocker: blocker) + + let refreshTask = Task { await store.refreshProvider(.codex, allowDisabled: true) } + await blocker.waitUntilStarted() + settings._test_liveSystemCodexAccount = ObservedSystemCodexAccount( + email: "alpha@example.com", + authFingerprint: "new-token-material", + codexHomePath: "/Users/test/.codex", + observedAt: Date(), + identity: .providerAccount(id: "acct-alpha")) + settings.cachedCodexAccountReconciliationSnapshot = CachedCodexAccountReconciliationSnapshot( + activeSource: .liveSystem, + loadedAt: Date(), + snapshot: staleReconciliationSnapshot) + await blocker.resume(with: .success(self.codexSnapshot(email: "alpha@example.com", usedPercent: 25))) + await refreshTask.value + + #expect(store.snapshots[.codex]?.primary?.usedPercent == 25) + #expect(store.lastCodexAccountScopedRefreshGuard?.authFingerprint == "new-token-material") + #expect(store.errors[.codex] == nil) + } + + @Test + func `same account token refresh fingerprint change keeps reset backfill`() async { + let settings = self.makeSettingsStore( + suite: "CodexAccountScopedRefreshTests-token-refresh-reset-backfill") + defer { + settings._test_liveSystemCodexAccount = nil + } + settings.refreshFrequency = .manual + settings._test_liveSystemCodexAccount = ObservedSystemCodexAccount( + email: "alpha@example.com", + authFingerprint: "old-token-material", + codexHomePath: "/Users/test/.codex", + observedAt: Date(), + identity: .providerAccount(id: "acct-alpha")) + let store = self.makeUsageStore(settings: settings) + let resetsAt = Date().addingTimeInterval(45 * 60) + let publicationGuard = store.freshCodexAccountScopedRefreshGuard() + store.lastCodexUsagePublicationGuard = publicationGuard + store.lastCodexAccountScopedRefreshGuard = publicationGuard + store.lastKnownResetSnapshots[.codex] = UsageSnapshot( + primary: RateWindow( + usedPercent: 10, + windowMinutes: 300, + resetsAt: resetsAt, + resetDescription: "resets soon"), + secondary: nil, + updatedAt: Date(), + identity: ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: "alpha@example.com", + accountOrganization: nil, + loginMethod: "Pro")) + settings._test_liveSystemCodexAccount = ObservedSystemCodexAccount( + email: "alpha@example.com", + authFingerprint: "new-token-material", + codexHomePath: "/Users/test/.codex", + observedAt: Date(), + identity: .providerAccount(id: "acct-alpha")) + self.installImmediateCodexProvider( + on: store, + snapshot: self.codexSnapshot(email: "alpha@example.com", usedPercent: 25)) + + await store.refreshProvider(.codex, allowDisabled: true) + + #expect(store.snapshots[.codex]?.primary?.usedPercent == 25) + #expect(store.snapshots[.codex]?.primary?.resetsAt == resetsAt) + #expect(store.lastKnownResetSnapshots[.codex]?.primary?.resetsAt == resetsAt) + #expect(store.lastCodexAccountScopedRefreshGuard?.authFingerprint == "new-token-material") + } + + @Test + func `same account token refresh fingerprint change keeps scoped state during prepare`() { + let settings = self.makeSettingsStore( + suite: "CodexAccountScopedRefreshTests-token-refresh-prepare") + defer { + settings._test_liveSystemCodexAccount = nil + } + settings.refreshFrequency = .manual + settings._test_liveSystemCodexAccount = ObservedSystemCodexAccount( + email: "alpha@example.com", + authFingerprint: "old-token-material", + codexHomePath: "/Users/test/.codex", + observedAt: Date(), + identity: .providerAccount(id: "acct-alpha")) + let store = self.makeUsageStore(settings: settings) + let resetsAt = Date().addingTimeInterval(45 * 60) + let cached = UsageSnapshot( + primary: RateWindow( + usedPercent: 10, + windowMinutes: 300, + resetsAt: resetsAt, + resetDescription: "resets soon"), + secondary: nil, + updatedAt: Date(), + identity: ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: "alpha@example.com", + accountOrganization: nil, + loginMethod: "Pro")) + store.snapshots[.codex] = cached + store.lastKnownResetSnapshots[.codex] = cached + store.credits = self.credits(remaining: 42) + store.lastCodexAccountScopedRefreshGuard = store.freshCodexAccountScopedRefreshGuard() + settings._test_liveSystemCodexAccount = ObservedSystemCodexAccount( + email: "alpha@example.com", + authFingerprint: "new-token-material", + codexHomePath: "/Users/test/.codex", + observedAt: Date(), + identity: .providerAccount(id: "acct-alpha")) + + let invalidated = store.prepareCodexAccountScopedRefreshIfNeeded() + + #expect(!invalidated) + #expect(store.snapshots[.codex]?.primary?.resetsAt == resetsAt) + #expect(store.lastKnownResetSnapshots[.codex]?.primary?.resetsAt == resetsAt) + #expect(store.credits?.remaining == 42) + #expect(store.lastCodexAccountScopedRefreshGuard?.authFingerprint == "new-token-material") + } + + @Test + func `usage success applies when auth fingerprint appears after refresh starts`() { + let settings = self.makeSettingsStore( + suite: "CodexAccountScopedRefreshTests-auth-fingerprint-appears") + defer { + settings._test_liveSystemCodexAccount = nil + } + settings.refreshFrequency = .manual + settings._test_liveSystemCodexAccount = ObservedSystemCodexAccount( + email: "alpha@example.com", + authFingerprint: nil, + codexHomePath: "/Users/test/.codex", + observedAt: Date(), + identity: .emailOnly(normalizedEmail: "alpha@example.com")) + let store = self.makeUsageStore(settings: settings) + let expectedGuard = store.freshCodexAccountScopedRefreshGuard() + + settings._test_liveSystemCodexAccount = ObservedSystemCodexAccount( + email: "alpha@example.com", + authFingerprint: "new-token-material", + codexHomePath: "/Users/test/.codex", + observedAt: Date(), + identity: .emailOnly(normalizedEmail: "alpha@example.com")) + + #expect(store.shouldApplyCodexUsageResult( + expectedGuard: expectedGuard, + usage: self.codexSnapshot(email: "alpha@example.com", usedPercent: 25))) + } + + @Test + func `same account token refresh fingerprint change discards codex usage failure`() async { + SettingsStore.codexAccountReconciliationSnapshotCacheIntervalOverrideForTesting = 60 + let settings = self.makeSettingsStore( + suite: "CodexAccountScopedRefreshTests-token-refresh-fingerprint-failure") + defer { + SettingsStore.codexAccountReconciliationSnapshotCacheIntervalOverrideForTesting = nil + settings._test_liveSystemCodexAccount = nil + } + settings.refreshFrequency = .manual + settings._test_liveSystemCodexAccount = ObservedSystemCodexAccount( + email: "alpha@example.com", + authFingerprint: "old-token-material", + codexHomePath: "/Users/test/.codex", + observedAt: Date(), + identity: .providerAccount(id: "acct-alpha")) + let staleReconciliationSnapshot = settings.codexAccountReconciliationSnapshot + + let store = self.makeUsageStore(settings: settings) + let blocker = BlockingCodexFetchStrategy() + self.installBlockingCodexProvider(on: store, blocker: blocker) + + let refreshTask = Task { await store.refreshProvider(.codex, allowDisabled: true) } + await blocker.waitUntilStarted() + settings._test_liveSystemCodexAccount = ObservedSystemCodexAccount( + email: "alpha@example.com", + authFingerprint: "new-token-material", + codexHomePath: "/Users/test/.codex", + observedAt: Date(), + identity: .providerAccount(id: "acct-alpha")) + settings.cachedCodexAccountReconciliationSnapshot = CachedCodexAccountReconciliationSnapshot( + activeSource: .liveSystem, + loadedAt: Date(), + snapshot: staleReconciliationSnapshot) + await blocker.resume(with: .failure(TestRefreshError(message: "old token failure"))) + await refreshTask.value + + #expect(store.snapshots[.codex] == nil) + #expect(store.errors[.codex] == nil) + } + + @Test + func `same account token refresh fingerprint change keeps codex credits success`() async { + SettingsStore.codexAccountReconciliationSnapshotCacheIntervalOverrideForTesting = 60 + let settings = self.makeSettingsStore( + suite: "CodexAccountScopedRefreshTests-token-refresh-credits-success") + defer { + SettingsStore.codexAccountReconciliationSnapshotCacheIntervalOverrideForTesting = nil + settings._test_liveSystemCodexAccount = nil + } + settings.refreshFrequency = .manual + settings._test_liveSystemCodexAccount = ObservedSystemCodexAccount( + email: "alpha@example.com", + authFingerprint: "old-token-material", + codexHomePath: "/Users/test/.codex", + observedAt: Date(), + identity: .providerAccount(id: "acct-alpha")) + + let store = self.makeUsageStore(settings: settings) + store._test_codexCreditsLoaderOverride = { + settings._test_liveSystemCodexAccount = ObservedSystemCodexAccount( + email: "alpha@example.com", + authFingerprint: "new-token-material", + codexHomePath: "/Users/test/.codex", + observedAt: Date(), + identity: .providerAccount(id: "acct-alpha")) + return CreditsSnapshot(remaining: 42, events: [], updatedAt: Date()) + } + defer { store._test_codexCreditsLoaderOverride = nil } + + await store.refreshCreditsIfNeeded() + + #expect(store.credits?.remaining == 42) + #expect(store.lastCreditsSnapshotAccountKey == "alpha@example.com") + #expect(store.lastCodexAccountScopedRefreshGuard?.authFingerprint == "new-token-material") + #expect(store.lastCreditsError == nil) + } + + @Test + func `credits refresh key separates same account auth fingerprints`() { + let settings = self.makeSettingsStore( + suite: "CodexAccountScopedRefreshTests-credits-key-auth-fingerprint") + let store = self.makeUsageStore(settings: settings) + let oldGuard = CodexAccountScopedRefreshGuard( + source: .liveSystem, + identity: .providerAccount(id: "acct-alpha"), + accountKey: "alpha@example.com", + authFingerprint: "old-token-material") + let newGuard = CodexAccountScopedRefreshGuard( + source: .liveSystem, + identity: .providerAccount(id: "acct-alpha"), + accountKey: "alpha@example.com", + authFingerprint: "new-token-material") + + #expect(store.codexCreditsRefreshKey(expectedGuard: oldGuard) != + store.codexCreditsRefreshKey(expectedGuard: newGuard)) + } + + @Test + func `same account token refresh fingerprint change keeps dashboard success`() async throws { + let settings = self.makeSettingsStore( + suite: "CodexAccountScopedRefreshTests-token-refresh-dashboard-success") + let codexMetadata = try #require(ProviderDescriptorRegistry.metadata[.codex]) + settings.setProviderEnabled(provider: .codex, metadata: codexMetadata, enabled: true) + settings.refreshFrequency = .manual + settings.openAIWebAccessEnabled = true + settings.codexCookieSource = .auto + settings._test_liveSystemCodexAccount = ObservedSystemCodexAccount( + email: "alpha@example.com", + authFingerprint: "old-token-material", + codexHomePath: "/Users/test/.codex", + observedAt: Date(), + identity: .providerAccount(id: "acct-alpha")) + defer { + settings._test_liveSystemCodexAccount = nil + } + + let store = self.makeUsageStore(settings: settings) + let expectedGuard = store.freshCodexOpenAIWebRefreshGuard() + store._test_openAIDashboardLoaderOverride = { _, _, _, _ in + settings._test_liveSystemCodexAccount = ObservedSystemCodexAccount( + email: "alpha@example.com", + authFingerprint: "new-token-material", + codexHomePath: "/Users/test/.codex", + observedAt: Date(), + identity: .providerAccount(id: "acct-alpha")) + return self.dashboard(email: "alpha@example.com", creditsRemaining: 64, usedPercent: 27) + } + defer { store._test_openAIDashboardLoaderOverride = nil } + + await store.refreshOpenAIDashboardIfNeeded(force: true, expectedGuard: expectedGuard) + + #expect(store.openAIDashboard?.creditsRemaining == 64) + #expect(store.openAIDashboard?.signedInEmail == "alpha@example.com") + #expect(store.lastOpenAIDashboardError == nil) + #expect(store.openAIDashboardRequiresLogin == false) + } + + @Test + func `dashboard refresh key separates same account auth fingerprints`() async throws { + let settings = self.makeSettingsStore( + suite: "CodexAccountScopedRefreshTests-dashboard-key-auth-fingerprint") + let codexMetadata = try #require(ProviderDescriptorRegistry.metadata[.codex]) + settings.setProviderEnabled(provider: .codex, metadata: codexMetadata, enabled: true) + settings.refreshFrequency = .manual + settings.openAIWebAccessEnabled = true + settings.codexCookieSource = .auto + settings._test_liveSystemCodexAccount = ObservedSystemCodexAccount( + email: "alpha@example.com", + authFingerprint: "old-token-material", + codexHomePath: "/Users/test/.codex", + observedAt: Date(), + identity: .providerAccount(id: "acct-alpha")) + defer { + settings._test_liveSystemCodexAccount = nil + } + + let store = self.makeUsageStore(settings: settings) + let blocker = BlockingManagedOpenAIDashboardLoader() + store._test_openAIDashboardLoaderOverride = { _, _, _, _ in + try await blocker.awaitResult() + } + defer { store._test_openAIDashboardLoaderOverride = nil } + + let oldGuard = store.freshCodexOpenAIWebRefreshGuard() + let oldRefreshTask = Task { + await store.refreshOpenAIDashboardIfNeeded(force: true, expectedGuard: oldGuard) + } + await blocker.waitUntilStarted(count: 1) + + settings._test_liveSystemCodexAccount = ObservedSystemCodexAccount( + email: "alpha@example.com", + authFingerprint: "new-token-material", + codexHomePath: "/Users/test/.codex", + observedAt: Date(), + identity: .providerAccount(id: "acct-alpha")) + let newGuard = store.freshCodexOpenAIWebRefreshGuard() + let newRefreshTask = Task { + await store.refreshOpenAIDashboardIfNeeded(force: true, expectedGuard: newGuard) + } + + let didStartFreshRefresh = await blocker.waitUntilStartedWithin(count: 2) + #expect(didStartFreshRefresh) + guard didStartFreshRefresh else { + await blocker.resumeNext(with: .failure(TestRefreshError(message: "stale dashboard failure"))) + await oldRefreshTask.value + await newRefreshTask.value + return + } + await blocker.resumeNext(with: .failure(TestRefreshError(message: "old dashboard failure"))) + await blocker.resumeNext(with: .success(self.dashboard( + email: "alpha@example.com", + creditsRemaining: 64, + usedPercent: 27))) + await oldRefreshTask.value + await newRefreshTask.value + + #expect(store.openAIDashboard?.creditsRemaining == 64) + #expect(store.lastOpenAIDashboardError == nil) + } + + @Test + func `same account token refresh fingerprint change discards dashboard failure`() async throws { + let settings = self.makeSettingsStore( + suite: "CodexAccountScopedRefreshTests-token-refresh-dashboard-failure") + let codexMetadata = try #require(ProviderDescriptorRegistry.metadata[.codex]) + settings.setProviderEnabled(provider: .codex, metadata: codexMetadata, enabled: true) + settings.refreshFrequency = .manual + settings.openAIWebAccessEnabled = true + settings.codexCookieSource = .auto + settings._test_liveSystemCodexAccount = ObservedSystemCodexAccount( + email: "alpha@example.com", + authFingerprint: "old-token-material", + codexHomePath: "/Users/test/.codex", + observedAt: Date(), + identity: .providerAccount(id: "acct-alpha")) + defer { + settings._test_liveSystemCodexAccount = nil + } + + let store = self.makeUsageStore(settings: settings) + let expectedGuard = store.freshCodexOpenAIWebRefreshGuard() + store._test_openAIDashboardLoaderOverride = { _, _, _, _ in + settings._test_liveSystemCodexAccount = ObservedSystemCodexAccount( + email: "alpha@example.com", + authFingerprint: "new-token-material", + codexHomePath: "/Users/test/.codex", + observedAt: Date(), + identity: .providerAccount(id: "acct-alpha")) + throw TestRefreshError(message: "old dashboard failure") + } + defer { store._test_openAIDashboardLoaderOverride = nil } + + await store.refreshOpenAIDashboardIfNeeded(force: true, expectedGuard: expectedGuard) + + #expect(store.openAIDashboard == nil) + #expect(store.lastOpenAIDashboardError == nil) + #expect(store.openAIDashboardRequiresLogin == false) + } + + @Test + func `same account token refresh fingerprint change applies dashboard policy failure`() async throws { + let settings = self.makeSettingsStore( + suite: "CodexAccountScopedRefreshTests-token-refresh-dashboard-policy-failure") + let codexMetadata = try #require(ProviderDescriptorRegistry.metadata[.codex]) + settings.setProviderEnabled(provider: .codex, metadata: codexMetadata, enabled: true) + settings.refreshFrequency = .manual + settings.openAIWebAccessEnabled = true + settings.codexCookieSource = .auto + settings._test_liveSystemCodexAccount = ObservedSystemCodexAccount( + email: "alpha@example.com", + authFingerprint: "old-token-material", + codexHomePath: "/Users/test/.codex", + observedAt: Date(), + identity: .providerAccount(id: "acct-alpha")) + defer { + settings._test_liveSystemCodexAccount = nil + } + + let store = self.makeUsageStore(settings: settings) + let expectedGuard = store.freshCodexOpenAIWebRefreshGuard() + settings._test_liveSystemCodexAccount = ObservedSystemCodexAccount( + email: "alpha@example.com", + authFingerprint: "new-token-material", + codexHomePath: "/Users/test/.codex", + observedAt: Date(), + identity: .providerAccount(id: "acct-alpha")) + + await store.applyOpenAIDashboard( + self.dashboard(email: "other@example.com", creditsRemaining: 64, usedPercent: 27), + targetEmail: "alpha@example.com", + expectedGuard: expectedGuard) + + #expect(store.openAIDashboard == nil) + #expect(store.lastOpenAIDashboardError?.contains("OpenAI dashboard signed in as other@example.com") == true) + #expect(store.openAIDashboardRequiresLogin == true) + } + + @Test + func `stacked visible refresh discards selected failure after managed token fingerprint rotates`() async throws { + let settings = self.makeSettingsStore( + suite: "CodexAccountScopedRefreshTests-selected-managed-token-failure") + settings.refreshFrequency = .manual + settings.multiAccountMenuLayout = .stacked + + let targetID = try #require(UUID(uuidString: "DDDDDDDD-EEEE-FFFF-AAAA-444444444444")) + let siblingID = try #require(UUID(uuidString: "DDDDDDDD-EEEE-FFFF-AAAA-333333333333")) + let targetHome = FileManager.default.temporaryDirectory + .appendingPathComponent("codex-visible-managed-token-\(UUID().uuidString)", isDirectory: true) + let siblingHome = FileManager.default.temporaryDirectory + .appendingPathComponent("codex-visible-managed-token-sibling-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: targetHome, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: siblingHome, withIntermediateDirectories: true) + let targetAccount = ManagedCodexAccount( + id: targetID, + email: "managed-token@example.com", + providerAccountID: "acct-managed-token", + workspaceLabel: "Managed Team", + workspaceAccountID: "acct-managed-token", + authFingerprint: "old-managed-token", + managedHomePath: targetHome.path, + createdAt: 1, + updatedAt: 2, + lastAuthenticatedAt: 2) + let updatedTarget = ManagedCodexAccount( + id: targetID, + email: "managed-token@example.com", + providerAccountID: "acct-managed-token", + workspaceLabel: "Managed Team", + workspaceAccountID: "acct-managed-token", + authFingerprint: "new-managed-token", + managedHomePath: targetHome.path, + createdAt: 1, + updatedAt: 3, + lastAuthenticatedAt: 3) + let siblingAccount = ManagedCodexAccount( + id: siblingID, + email: "managed-token-sibling@example.com", + providerAccountID: "acct-managed-token-sibling", + workspaceLabel: "Sibling Team", + workspaceAccountID: "acct-managed-token-sibling", + authFingerprint: "sibling-managed-token", + managedHomePath: siblingHome.path, + createdAt: 1, + updatedAt: 2, + lastAuthenticatedAt: 2) + let storeURL = try self.makeManagedAccountStoreURL(accounts: [targetAccount, siblingAccount]) + defer { + settings._test_managedCodexAccountStoreURL = nil + try? FileManager.default.removeItem(at: storeURL) + try? FileManager.default.removeItem(at: targetHome) + try? FileManager.default.removeItem(at: siblingHome) + } + settings._test_managedCodexAccountStoreURL = storeURL + settings.codexActiveSource = .managedAccount(id: targetID) + + let snapshotStore = RecordingCodexAccountUsageSnapshotStore(initialSnapshots: []) + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + codexAccountUsageSnapshotStore: snapshotStore, + startupBehavior: .testing) + let blocker = BlockingCodexFetchStrategy() + let targetHomePath = targetHome.path + let now = Date() + self.installContextualCodexProvider(on: store) { context in + if context.env["CODEX_HOME"] == targetHomePath { + return try await blocker.awaitResult() + } + return UsageSnapshot( + primary: RateWindow( + usedPercent: 9, + windowMinutes: 300, + resetsAt: nil, + resetDescription: nil), + secondary: nil, + updatedAt: now, + identity: ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: "managed-token-sibling@example.com", + accountOrganization: nil, + loginMethod: "Sibling Team")) + } + + let refreshTask = Task { await store.refreshCodexVisibleAccountsForMenu() } + await blocker.waitUntilStarted() + try FileManagedCodexAccountStore(fileURL: storeURL).storeAccounts(ManagedCodexAccountSet( + version: FileManagedCodexAccountStore.currentVersion, + accounts: [updatedTarget, siblingAccount])) + await blocker.resume(with: .failure(TestRefreshError(message: "old managed token failure"))) + await refreshTask.value + + #expect(store.snapshots[.codex] == nil) + #expect(store.errors[.codex] == nil) + #expect(!store.codexAccountSnapshots.contains { + $0.account.workspaceAccountID == "acct-managed-token" + }) + #expect(!snapshotStore.storedSnapshots.contains { + $0.account.workspaceAccountID == "acct-managed-token" + }) + } + + @Test + func `stacked visible refresh discards selected failure after managed auth file rotates`() async throws { + let settings = self.makeSettingsStore( + suite: "CodexAccountScopedRefreshTests-selected-managed-auth-file-failure") + settings.refreshFrequency = .manual + settings.multiAccountMenuLayout = .stacked + + let targetID = try #require(UUID(uuidString: "DDDDDDDD-EEEE-FFFF-AAAA-121212121212")) + let siblingID = try #require(UUID(uuidString: "DDDDDDDD-EEEE-FFFF-AAAA-131313131313")) + let targetHome = FileManager.default.temporaryDirectory + .appendingPathComponent("codex-visible-managed-auth-file-\(UUID().uuidString)", isDirectory: true) + let siblingHome = FileManager.default.temporaryDirectory + .appendingPathComponent("codex-visible-managed-auth-file-sibling-\(UUID().uuidString)", isDirectory: true) + try Self.writeCodexAuthFile( + homeURL: targetHome, + email: "managed-file-token@example.com", + plan: "Pro", + accountId: "acct-managed-file-token") + let oldFingerprint = try #require(CodexAuthFingerprint.fingerprint(homePath: targetHome.path)) + let targetAccount = ManagedCodexAccount( + id: targetID, + email: "managed-file-token@example.com", + providerAccountID: "acct-managed-file-token", + workspaceLabel: "Managed Team", + workspaceAccountID: "acct-managed-file-token", + authFingerprint: oldFingerprint, + managedHomePath: targetHome.path, + createdAt: 1, + updatedAt: 2, + lastAuthenticatedAt: 2) + let siblingAccount = ManagedCodexAccount( + id: siblingID, + email: "managed-file-token-sibling@example.com", + providerAccountID: "acct-managed-file-token-sibling", + workspaceLabel: "Sibling Team", + workspaceAccountID: "acct-managed-file-token-sibling", + authFingerprint: "sibling-managed-file-token", + managedHomePath: siblingHome.path, + createdAt: 1, + updatedAt: 2, + lastAuthenticatedAt: 2) + let storeURL = try self.makeManagedAccountStoreURL(accounts: [targetAccount, siblingAccount]) + defer { + settings._test_managedCodexAccountStoreURL = nil + try? FileManager.default.removeItem(at: storeURL) + try? FileManager.default.removeItem(at: targetHome) + try? FileManager.default.removeItem(at: siblingHome) + } + settings._test_managedCodexAccountStoreURL = storeURL + settings.codexActiveSource = .managedAccount(id: targetID) + + let snapshotStore = RecordingCodexAccountUsageSnapshotStore(initialSnapshots: []) + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + codexAccountUsageSnapshotStore: snapshotStore, + startupBehavior: .testing) + let blocker = BlockingCodexFetchStrategy() + let targetHomePath = targetHome.path + let now = Date() + self.installContextualCodexProvider(on: store) { context in + if context.env["CODEX_HOME"] == targetHomePath { + return try await blocker.awaitResult() + } + return UsageSnapshot( + primary: RateWindow( + usedPercent: 9, + windowMinutes: 300, + resetsAt: nil, + resetDescription: nil), + secondary: nil, + updatedAt: now, + identity: ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: "managed-file-token-sibling@example.com", + accountOrganization: nil, + loginMethod: "Sibling Team")) + } + + let refreshTask = Task { await store.refreshCodexVisibleAccountsForMenu() } + await blocker.waitUntilStarted() + try Self.writeCodexAuthFile( + homeURL: targetHome, + email: "managed-file-token@example.com", + plan: "Team", + accountId: "acct-managed-file-token") + let newFingerprint = try #require(CodexAuthFingerprint.fingerprint(homePath: targetHome.path)) + #expect(newFingerprint != oldFingerprint) + await blocker.resume(with: .failure(TestRefreshError(message: "old managed auth file failure"))) + await refreshTask.value + + #expect(store.snapshots[.codex] == nil) + #expect(store.errors[.codex] == nil) + #expect(!store.codexAccountSnapshots.contains { + $0.account.workspaceAccountID == "acct-managed-file-token" + }) + #expect(!snapshotStore.storedSnapshots.contains { + $0.account.workspaceAccountID == "acct-managed-file-token" + }) + } + + @Test + func `stacked visible refresh keeps selected failure when managed auth file rotated before start`() async throws { + let settings = self.makeSettingsStore( + suite: "CodexAccountScopedRefreshTests-selected-managed-auth-file-current-failure") + settings.refreshFrequency = .manual + settings.multiAccountMenuLayout = .stacked + + let targetID = try #require(UUID(uuidString: "DDDDDDDD-EEEE-FFFF-AAAA-161616161616")) + let siblingID = try #require(UUID(uuidString: "DDDDDDDD-EEEE-FFFF-AAAA-171717171717")) + let targetHome = FileManager.default.temporaryDirectory + .appendingPathComponent("codex-visible-managed-current-failure-\(UUID().uuidString)", isDirectory: true) + let siblingHome = FileManager.default.temporaryDirectory + .appendingPathComponent("codex-visible-managed-current-sibling-\(UUID().uuidString)", isDirectory: true) + try Self.writeCodexAuthFile( + homeURL: targetHome, + email: "managed-current-failure@example.com", + plan: "Pro", + accountId: "acct-managed-current-failure") + let oldFingerprint = try #require(CodexAuthFingerprint.fingerprint(homePath: targetHome.path)) + let targetAccount = ManagedCodexAccount( + id: targetID, + email: "managed-current-failure@example.com", + providerAccountID: "acct-managed-current-failure", + workspaceLabel: "Managed Team", + workspaceAccountID: "acct-managed-current-failure", + authFingerprint: oldFingerprint, + managedHomePath: targetHome.path, + createdAt: 1, + updatedAt: 2, + lastAuthenticatedAt: 2) + let siblingAccount = ManagedCodexAccount( + id: siblingID, + email: "managed-current-sibling@example.com", + providerAccountID: "acct-managed-current-sibling", + workspaceLabel: "Sibling Team", + workspaceAccountID: "acct-managed-current-sibling", + authFingerprint: "sibling-managed-current", + managedHomePath: siblingHome.path, + createdAt: 1, + updatedAt: 2, + lastAuthenticatedAt: 2) + let storeURL = try self.makeManagedAccountStoreURL(accounts: [targetAccount, siblingAccount]) + defer { + settings._test_managedCodexAccountStoreURL = nil + try? FileManager.default.removeItem(at: storeURL) + try? FileManager.default.removeItem(at: targetHome) + try? FileManager.default.removeItem(at: siblingHome) + } + settings._test_managedCodexAccountStoreURL = storeURL + settings.codexActiveSource = .managedAccount(id: targetID) + + try Self.writeCodexAuthFile( + homeURL: targetHome, + email: "managed-current-failure@example.com", + plan: "Team", + accountId: "acct-managed-current-failure") + let newFingerprint = try #require(CodexAuthFingerprint.fingerprint(homePath: targetHome.path)) + #expect(newFingerprint != oldFingerprint) + + let snapshotStore = RecordingCodexAccountUsageSnapshotStore(initialSnapshots: []) + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + codexAccountUsageSnapshotStore: snapshotStore, + startupBehavior: .testing) + let targetHomePath = targetHome.path + let now = Date() + self.installContextualCodexProvider(on: store) { context in + if context.env["CODEX_HOME"] == targetHomePath { + throw TestRefreshError(message: "current managed auth file failure") + } + return UsageSnapshot( + primary: RateWindow( + usedPercent: 9, + windowMinutes: 300, + resetsAt: nil, + resetDescription: nil), + secondary: nil, + updatedAt: now, + identity: ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: "managed-current-sibling@example.com", + accountOrganization: nil, + loginMethod: "Sibling Team")) + } + + await store.refreshCodexVisibleAccountsForMenu() + + #expect(store.errors[.codex] == "current managed auth file failure") + let targetSnapshot = try #require(store.codexAccountSnapshots.first { + $0.account.workspaceAccountID == "acct-managed-current-failure" + }) + #expect(targetSnapshot.error == "current managed auth file failure") + #expect(targetSnapshot.account.authFingerprint == newFingerprint) + let persistedTargetSnapshot = try #require(snapshotStore.storedSnapshots.first { + $0.account.workspaceAccountID == "acct-managed-current-failure" + }) + #expect(persistedTargetSnapshot.error == "current managed auth file failure") + #expect(persistedTargetSnapshot.account.authFingerprint == newFingerprint) + } + + @Test + func `stacked visible refresh discards selected success after managed auth file switches accounts`() async throws { + let settings = self.makeSettingsStore( + suite: "CodexAccountScopedRefreshTests-selected-managed-auth-file-success") + settings.refreshFrequency = .manual + settings.multiAccountMenuLayout = .stacked + + let targetID = try #require(UUID(uuidString: "DDDDDDDD-EEEE-FFFF-AAAA-141414141414")) + let siblingID = try #require(UUID(uuidString: "DDDDDDDD-EEEE-FFFF-AAAA-151515151515")) + let targetHome = FileManager.default.temporaryDirectory + .appendingPathComponent("codex-visible-managed-auth-success-\(UUID().uuidString)", isDirectory: true) + let siblingHome = FileManager.default.temporaryDirectory + .appendingPathComponent( + "codex-visible-managed-auth-success-sibling-\(UUID().uuidString)", + isDirectory: true) + try Self.writeCodexAuthFile( + homeURL: targetHome, + email: "managed-old-success@example.com", + plan: "Pro", + accountId: "acct-managed-old-success") + let oldFingerprint = try #require(CodexAuthFingerprint.fingerprint(homePath: targetHome.path)) + let targetAccount = ManagedCodexAccount( + id: targetID, + email: "managed-old-success@example.com", + providerAccountID: "acct-managed-old-success", + workspaceLabel: "Managed Team", + workspaceAccountID: "acct-managed-old-success", + authFingerprint: oldFingerprint, + managedHomePath: targetHome.path, + createdAt: 1, + updatedAt: 2, + lastAuthenticatedAt: 2) + let siblingAccount = ManagedCodexAccount( + id: siblingID, + email: "managed-success-sibling@example.com", + providerAccountID: "acct-managed-success-sibling", + workspaceLabel: "Sibling Team", + workspaceAccountID: "acct-managed-success-sibling", + authFingerprint: "sibling-managed-success", + managedHomePath: siblingHome.path, + createdAt: 1, + updatedAt: 2, + lastAuthenticatedAt: 2) + let storeURL = try self.makeManagedAccountStoreURL(accounts: [targetAccount, siblingAccount]) + defer { + settings._test_managedCodexAccountStoreURL = nil + try? FileManager.default.removeItem(at: storeURL) + try? FileManager.default.removeItem(at: targetHome) + try? FileManager.default.removeItem(at: siblingHome) + } + settings._test_managedCodexAccountStoreURL = storeURL + settings.codexActiveSource = .managedAccount(id: targetID) + + let snapshotStore = RecordingCodexAccountUsageSnapshotStore(initialSnapshots: []) + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + codexAccountUsageSnapshotStore: snapshotStore, + startupBehavior: .testing) + let blocker = BlockingCodexFetchStrategy() + let targetHomePath = targetHome.path + let now = Date() + self.installContextualCodexProvider(on: store) { context in + if context.env["CODEX_HOME"] == targetHomePath { + return try await blocker.awaitResult() + } + return UsageSnapshot( + primary: RateWindow( + usedPercent: 9, + windowMinutes: 300, + resetsAt: nil, + resetDescription: nil), + secondary: nil, + updatedAt: now, + identity: ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: "managed-success-sibling@example.com", + accountOrganization: nil, + loginMethod: "Sibling Team")) + } + + let refreshTask = Task { await store.refreshCodexVisibleAccountsForMenu() } + await blocker.waitUntilStarted() + try Self.writeCodexAuthFile( + homeURL: targetHome, + email: "managed-new-success@example.com", + plan: "Pro", + accountId: "acct-managed-new-success") + let newFingerprint = try #require(CodexAuthFingerprint.fingerprint(homePath: targetHome.path)) + #expect(newFingerprint != oldFingerprint) + await blocker.resume(with: .success(UsageSnapshot( + primary: RateWindow( + usedPercent: 64, + windowMinutes: 300, + resetsAt: nil, + resetDescription: nil), + secondary: nil, + updatedAt: now, + identity: ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: "managed-old-success@example.com", + accountOrganization: nil, + loginMethod: "Managed Team")))) + await refreshTask.value + + #expect(store.snapshots[.codex] == nil) + #expect(store.errors[.codex] == nil) + #expect(!store.codexAccountSnapshots.contains { + $0.account.workspaceAccountID == "acct-managed-old-success" + }) + #expect(!snapshotStore.storedSnapshots.contains { + $0.account.workspaceAccountID == "acct-managed-old-success" + }) + } + + @Test + func `stacked visible refresh keeps migrated managed account after token rotation`() async throws { + let settings = self.makeSettingsStore( + suite: "CodexAccountScopedRefreshTests-migrated-managed-token-rotation") + settings.refreshFrequency = .manual + settings.multiAccountMenuLayout = .stacked + + let targetID = try #require(UUID(uuidString: "DDDDDDDD-EEEE-FFFF-AAAA-171717171717")) + let siblingID = try #require(UUID(uuidString: "DDDDDDDD-EEEE-FFFF-AAAA-181818181818")) + let targetHome = FileManager.default.temporaryDirectory + .appendingPathComponent("codex-visible-migrated-managed-\(UUID().uuidString)", isDirectory: true) + let siblingHome = FileManager.default.temporaryDirectory + .appendingPathComponent( + "codex-visible-migrated-managed-sibling-\(UUID().uuidString)", + isDirectory: true) + try Self.writeCodexAuthFile( + homeURL: targetHome, + email: "migrated-managed@example.com", + plan: "Pro", + accountId: "acct-migrated-managed") + try Self.writeCodexAuthFile( + homeURL: siblingHome, + email: "migrated-sibling@example.com", + plan: "Pro", + accountId: "acct-migrated-sibling") + let oldFingerprint = try #require(CodexAuthFingerprint.fingerprint(homePath: targetHome.path)) + let siblingFingerprint = try #require(CodexAuthFingerprint.fingerprint(homePath: siblingHome.path)) + let targetAccount = ManagedCodexAccount( + id: targetID, + email: "migrated-managed@example.com", + providerAccountID: "acct-migrated-managed", + workspaceLabel: "Managed Team", + authFingerprint: oldFingerprint, + managedHomePath: targetHome.path, + createdAt: 1, + updatedAt: 2, + lastAuthenticatedAt: 2) + let siblingAccount = ManagedCodexAccount( + id: siblingID, + email: "migrated-sibling@example.com", + providerAccountID: "acct-migrated-sibling", + workspaceLabel: "Sibling Team", + authFingerprint: siblingFingerprint, + managedHomePath: siblingHome.path, + createdAt: 1, + updatedAt: 2, + lastAuthenticatedAt: 2) + let storeURL = try self.makeManagedAccountStoreURL(accounts: [targetAccount, siblingAccount]) + defer { + settings._test_managedCodexAccountStoreURL = nil + try? FileManager.default.removeItem(at: storeURL) + try? FileManager.default.removeItem(at: targetHome) + try? FileManager.default.removeItem(at: siblingHome) + } + settings._test_managedCodexAccountStoreURL = storeURL + settings.codexActiveSource = .managedAccount(id: targetID) + + let snapshotStore = RecordingCodexAccountUsageSnapshotStore(initialSnapshots: []) + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + codexAccountUsageSnapshotStore: snapshotStore, + startupBehavior: .testing) + let blocker = BlockingCodexFetchStrategy() + let targetHomePath = targetHome.path + let now = Date() + self.installContextualCodexProvider(on: store) { context in + if context.env["CODEX_HOME"] == targetHomePath { + return try await blocker.awaitResult() + } + return UsageSnapshot( + primary: RateWindow( + usedPercent: 9, + windowMinutes: 300, + resetsAt: nil, + resetDescription: nil), + secondary: nil, + updatedAt: now, + identity: ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: "migrated-sibling@example.com", + accountOrganization: nil, + loginMethod: "Sibling Team")) + } + + let refreshTask = Task { await store.refreshCodexVisibleAccountsForMenu() } + await blocker.waitUntilStarted() + try Self.writeCodexAuthFile( + homeURL: targetHome, + email: "migrated-managed@example.com", + plan: "Team", + accountId: "acct-migrated-managed") + let newFingerprint = try #require(CodexAuthFingerprint.fingerprint(homePath: targetHome.path)) + #expect(newFingerprint != oldFingerprint) + await blocker.resume(with: .success(UsageSnapshot( + primary: RateWindow( + usedPercent: 64, + windowMinutes: 300, + resetsAt: nil, + resetDescription: nil), + secondary: nil, + updatedAt: now, + identity: ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: "migrated-managed@example.com", + accountOrganization: nil, + loginMethod: "Managed Team")))) + await refreshTask.value + + let selectedSnapshot = try #require(store.snapshots[.codex]) + #expect(selectedSnapshot.primary?.usedPercent == 64) + let targetRow = try #require(store.codexAccountSnapshots.first { + $0.account.workspaceAccountID == "acct-migrated-managed" + }) + #expect(targetRow.account.authFingerprint == newFingerprint) + #expect(targetRow.snapshot?.primary?.usedPercent == 64) + let persistedTarget = try #require(snapshotStore.storedSnapshots.first { + $0.account.workspaceAccountID == "acct-migrated-managed" + }) + #expect(persistedTarget.account.authFingerprint == newFingerprint) + #expect(persistedTarget.snapshot?.primary?.usedPercent == 64) + } + + @Test + func `stacked visible refresh discards selected success after managed auth file email changes`() async throws { + let settings = self.makeSettingsStore( + suite: "CodexAccountScopedRefreshTests-selected-managed-auth-email-success") + settings.refreshFrequency = .manual + settings.multiAccountMenuLayout = .stacked + + let targetID = try #require(UUID(uuidString: "DDDDDDDD-EEEE-FFFF-AAAA-191919191919")) + let siblingID = try #require(UUID(uuidString: "DDDDDDDD-EEEE-FFFF-AAAA-202020202020")) + let targetHome = FileManager.default.temporaryDirectory + .appendingPathComponent("codex-visible-managed-auth-email-\(UUID().uuidString)", isDirectory: true) + let siblingHome = FileManager.default.temporaryDirectory + .appendingPathComponent( + "codex-visible-managed-auth-email-sibling-\(UUID().uuidString)", + isDirectory: true) + try Self.writeCodexAuthFile( + homeURL: targetHome, + email: "managed-old-email@example.com", + plan: "Pro", + accountId: "acct-managed-email-same") + try Self.writeCodexAuthFile( + homeURL: siblingHome, + email: "managed-email-sibling@example.com", + plan: "Pro", + accountId: "acct-managed-email-sibling") + let oldFingerprint = try #require(CodexAuthFingerprint.fingerprint(homePath: targetHome.path)) + let siblingFingerprint = try #require(CodexAuthFingerprint.fingerprint(homePath: siblingHome.path)) + let targetAccount = ManagedCodexAccount( + id: targetID, + email: "managed-old-email@example.com", + providerAccountID: "acct-managed-email-same", + workspaceLabel: "Managed Team", + workspaceAccountID: "acct-managed-email-same", + authFingerprint: oldFingerprint, + managedHomePath: targetHome.path, + createdAt: 1, + updatedAt: 2, + lastAuthenticatedAt: 2) + let siblingAccount = ManagedCodexAccount( + id: siblingID, + email: "managed-email-sibling@example.com", + providerAccountID: "acct-managed-email-sibling", + workspaceLabel: "Sibling Team", + workspaceAccountID: "acct-managed-email-sibling", + authFingerprint: siblingFingerprint, + managedHomePath: siblingHome.path, + createdAt: 1, + updatedAt: 2, + lastAuthenticatedAt: 2) + let storeURL = try self.makeManagedAccountStoreURL(accounts: [targetAccount, siblingAccount]) + defer { + settings._test_managedCodexAccountStoreURL = nil + try? FileManager.default.removeItem(at: storeURL) + try? FileManager.default.removeItem(at: targetHome) + try? FileManager.default.removeItem(at: siblingHome) + } + settings._test_managedCodexAccountStoreURL = storeURL + settings.codexActiveSource = .managedAccount(id: targetID) + + let snapshotStore = RecordingCodexAccountUsageSnapshotStore(initialSnapshots: []) + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + codexAccountUsageSnapshotStore: snapshotStore, + startupBehavior: .testing) + let blocker = BlockingCodexFetchStrategy() + let targetHomePath = targetHome.path + let now = Date() + self.installContextualCodexProvider(on: store) { context in + if context.env["CODEX_HOME"] == targetHomePath { + return try await blocker.awaitResult() + } + return UsageSnapshot( + primary: RateWindow( + usedPercent: 9, + windowMinutes: 300, + resetsAt: nil, + resetDescription: nil), + secondary: nil, + updatedAt: now, + identity: ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: "managed-email-sibling@example.com", + accountOrganization: nil, + loginMethod: "Sibling Team")) + } + + let refreshTask = Task { await store.refreshCodexVisibleAccountsForMenu() } + await blocker.waitUntilStarted() + try Self.writeCodexAuthFile( + homeURL: targetHome, + email: "managed-new-email@example.com", + plan: "Pro", + accountId: "acct-managed-email-same") + let newFingerprint = try #require(CodexAuthFingerprint.fingerprint(homePath: targetHome.path)) + #expect(newFingerprint != oldFingerprint) + await blocker.resume(with: .success(UsageSnapshot( + primary: RateWindow( + usedPercent: 64, + windowMinutes: 300, + resetsAt: nil, + resetDescription: nil), + secondary: nil, + updatedAt: now, + identity: ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: "managed-old-email@example.com", + accountOrganization: nil, + loginMethod: "Managed Team")))) + await refreshTask.value + + #expect(store.snapshots[.codex] == nil) + #expect(store.errors[.codex] == nil) + #expect(!store.codexAccountSnapshots.contains { + $0.account.workspaceAccountID == "acct-managed-email-same" + }) + #expect(!snapshotStore.storedSnapshots.contains { + $0.account.workspaceAccountID == "acct-managed-email-same" + }) + } + + @Test + func `managed failure guard reads current auth file fingerprint`() throws { + let settings = self.makeSettingsStore( + suite: "CodexAccountScopedRefreshTests-managed-auth-file-fingerprint") + settings.refreshFrequency = .manual + let accountID = try #require(UUID(uuidString: "DDDDDDDD-EEEE-FFFF-AAAA-555555555555")) + let managedHome = FileManager.default.temporaryDirectory + .appendingPathComponent("codex-managed-auth-file-\(UUID().uuidString)", isDirectory: true) + try Self.writeCodexAuthFile( + homeURL: managedHome, + email: "managed-auth@example.com", + plan: "Pro", + accountId: "acct-managed-auth") + let oldFingerprint = try #require(CodexAuthFingerprint.fingerprint(homePath: managedHome.path)) + let managedAccount = ManagedCodexAccount( + id: accountID, + email: "managed-auth@example.com", + providerAccountID: "acct-managed-auth", + workspaceLabel: "Managed Auth", + workspaceAccountID: "acct-managed-auth", + authFingerprint: oldFingerprint, + managedHomePath: managedHome.path, + createdAt: 1, + updatedAt: 2, + lastAuthenticatedAt: 2) + let storeURL = try self.makeManagedAccountStoreURL(accounts: [managedAccount]) + defer { + settings._test_managedCodexAccountStoreURL = nil + try? FileManager.default.removeItem(at: storeURL) + try? FileManager.default.removeItem(at: managedHome) + } + settings._test_managedCodexAccountStoreURL = storeURL + settings.codexActiveSource = .managedAccount(id: accountID) + + let store = self.makeUsageStore(settings: settings) + let expectedGuard = store.freshCodexAccountScopedRefreshGuard() + #expect(expectedGuard.authFingerprint == oldFingerprint) + + try Self.writeCodexAuthFile( + homeURL: managedHome, + email: "managed-auth@example.com", + plan: "Team", + accountId: "acct-managed-auth") + let newFingerprint = try #require(CodexAuthFingerprint.fingerprint(homePath: managedHome.path)) + #expect(newFingerprint != oldFingerprint) + + #expect(store.freshCodexAccountScopedRefreshGuard().authFingerprint == newFingerprint) + #expect(!store.shouldApplyCodexScopedFailure(expectedGuard: expectedGuard)) + #expect(!store.shouldApplyCodexScopedNonUsageFailure(expectedGuard: expectedGuard)) + + try FileManager.default.removeItem(at: managedHome) + #expect(store.freshCodexAccountScopedRefreshGuard().authFingerprint == nil) + let staleUsage = UsageSnapshot( + primary: RateWindow( + usedPercent: 41, + windowMinutes: 300, + resetsAt: nil, + resetDescription: nil), + secondary: nil, + updatedAt: Date(), + identity: ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: "managed-auth@example.com", + accountOrganization: nil, + loginMethod: "Managed Auth")) + #expect(!store.shouldApplyCodexUsageResult(expectedGuard: expectedGuard, usage: staleUsage)) + #expect(!store.shouldApplyCodexScopedFailure(expectedGuard: expectedGuard)) + #expect(!store.shouldApplyCodexScopedNonUsageFailure(expectedGuard: expectedGuard)) + } + + @Test + func `stale auth fingerprint cache at refresh start keeps current codex usage success`() async { + SettingsStore.codexAccountReconciliationSnapshotCacheIntervalOverrideForTesting = 60 + let settings = self.makeSettingsStore( + suite: "CodexAccountScopedRefreshTests-stale-start-cache-current-auth") + defer { + SettingsStore.codexAccountReconciliationSnapshotCacheIntervalOverrideForTesting = nil + settings._test_liveSystemCodexAccount = nil + } + settings.refreshFrequency = .manual + settings._test_liveSystemCodexAccount = ObservedSystemCodexAccount( + email: "alpha@example.com", + authFingerprint: "old-email-only-auth", + codexHomePath: "/Users/test/.codex", + observedAt: Date(), + identity: .emailOnly(normalizedEmail: "alpha@example.com")) + let staleReconciliationSnapshot = settings.codexAccountReconciliationSnapshot + settings._test_liveSystemCodexAccount = ObservedSystemCodexAccount( + email: "alpha@example.com", + authFingerprint: "new-email-only-auth", + codexHomePath: "/Users/test/.codex", + observedAt: Date(), + identity: .emailOnly(normalizedEmail: "alpha@example.com")) + settings.cachedCodexAccountReconciliationSnapshot = CachedCodexAccountReconciliationSnapshot( + activeSource: .liveSystem, + loadedAt: Date(), + snapshot: staleReconciliationSnapshot) + + let store = self.makeUsageStore(settings: settings) + let blocker = BlockingCodexFetchStrategy() + self.installBlockingCodexProvider(on: store, blocker: blocker) + + let refreshTask = Task { await store.refreshProvider(.codex, allowDisabled: true) } + await blocker.waitUntilStarted() + await blocker.resume(with: .success(self.codexSnapshot(email: "alpha@example.com", usedPercent: 33))) + await refreshTask.value + + #expect(store.snapshots[.codex]?.primary?.usedPercent == 33) + #expect(store.lastCodexAccountScopedRefreshGuard?.authFingerprint == "new-email-only-auth") + #expect(store.errors[.codex] == nil) + } + + @Test + func `same provider account live email change discards stale codex usage success`() async { + SettingsStore.codexAccountReconciliationSnapshotCacheIntervalOverrideForTesting = 60 + let settings = self.makeSettingsStore( + suite: "CodexAccountScopedRefreshTests-provider-email-change") + defer { + SettingsStore.codexAccountReconciliationSnapshotCacheIntervalOverrideForTesting = nil + settings._test_liveSystemCodexAccount = nil + } + settings.refreshFrequency = .manual + settings._test_liveSystemCodexAccount = ObservedSystemCodexAccount( + email: "old@example.com", + authFingerprint: "old-provider-auth", + codexHomePath: "/Users/test/.codex", + observedAt: Date(), + identity: .providerAccount(id: "acct-shared")) + let staleReconciliationSnapshot = settings.codexAccountReconciliationSnapshot + + let store = self.makeUsageStore(settings: settings) + let blocker = BlockingCodexFetchStrategy() + self.installBlockingCodexProvider(on: store, blocker: blocker) + + let refreshTask = Task { await store.refreshProvider(.codex, allowDisabled: true) } + await blocker.waitUntilStarted() + settings._test_liveSystemCodexAccount = ObservedSystemCodexAccount( + email: "new@example.com", + authFingerprint: "new-provider-auth", + codexHomePath: "/Users/test/.codex", + observedAt: Date(), + identity: .providerAccount(id: "acct-shared")) + settings.cachedCodexAccountReconciliationSnapshot = CachedCodexAccountReconciliationSnapshot( + activeSource: .liveSystem, + loadedAt: Date(), + snapshot: staleReconciliationSnapshot) + await blocker.resume(with: .success(self.codexSnapshot(email: "old@example.com", usedPercent: 25))) + await refreshTask.value + + #expect(store.snapshots[.codex] == nil) + #expect(store.errors[.codex] == nil) + } + + @Test + func `same provider account managed email change discards stale codex usage success`() async throws { + let settings = self.makeSettingsStore( + suite: "CodexAccountScopedRefreshTests-managed-provider-email-change") + settings.refreshFrequency = .manual + + let accountID = try #require(UUID(uuidString: "DDDDDDDD-EEEE-FFFF-AAAA-161616161616")) + let managedHome = FileManager.default.temporaryDirectory + .appendingPathComponent("codex-managed-provider-email-\(UUID().uuidString)", isDirectory: true) + try Self.writeCodexAuthFile( + homeURL: managedHome, + email: "old-managed@example.com", + plan: "Pro", + accountId: "acct-managed-shared") + let oldFingerprint = try #require(CodexAuthFingerprint.fingerprint(homePath: managedHome.path)) + let managedAccount = ManagedCodexAccount( + id: accountID, + email: "old-managed@example.com", + providerAccountID: "acct-managed-shared", + workspaceLabel: "Managed Team", + workspaceAccountID: "acct-managed-shared", + authFingerprint: oldFingerprint, + managedHomePath: managedHome.path, + createdAt: 1, + updatedAt: 2, + lastAuthenticatedAt: 2) + let storeURL = try self.makeManagedAccountStoreURL(accounts: [managedAccount]) + defer { + settings._test_managedCodexAccountStoreURL = nil + try? FileManager.default.removeItem(at: storeURL) + try? FileManager.default.removeItem(at: managedHome) + } + settings._test_managedCodexAccountStoreURL = storeURL + settings.codexActiveSource = .managedAccount(id: accountID) + + let store = self.makeUsageStore(settings: settings) + let blocker = BlockingCodexFetchStrategy() + self.installBlockingCodexProvider(on: store, blocker: blocker) + + let refreshTask = Task { await store.refreshProvider(.codex, allowDisabled: true) } + await blocker.waitUntilStarted() + try Self.writeCodexAuthFile( + homeURL: managedHome, + email: "new-managed@example.com", + plan: "Pro", + accountId: "acct-managed-shared") + let newFingerprint = try #require(CodexAuthFingerprint.fingerprint(homePath: managedHome.path)) + #expect(newFingerprint != oldFingerprint) + await blocker.resume(with: .success(self.codexSnapshot(email: "old-managed@example.com", usedPercent: 25))) + await refreshTask.value + + #expect(store.snapshots[.codex] == nil) + #expect(store.errors[.codex] == nil) + } + + @Test + func `managed codex usage success without email applies when auth guard matches`() async throws { + let settings = self.makeSettingsStore( + suite: "CodexAccountScopedRefreshTests-managed-usage-without-email") + settings.refreshFrequency = .manual + + let accountID = try #require(UUID(uuidString: "DDDDDDDD-EEEE-FFFF-AAAA-171717171717")) + let managedHome = FileManager.default.temporaryDirectory + .appendingPathComponent("codex-managed-usage-without-email-\(UUID().uuidString)", isDirectory: true) + try Self.writeCodexAuthFile( + homeURL: managedHome, + email: "email-less-managed@example.com", + plan: "Pro", + accountId: "acct-managed-email-less") + let authFingerprint = try #require(CodexAuthFingerprint.fingerprint(homePath: managedHome.path)) + let managedAccount = ManagedCodexAccount( + id: accountID, + email: "email-less-managed@example.com", + providerAccountID: "acct-managed-email-less", + workspaceLabel: "Managed Team", + workspaceAccountID: "acct-managed-email-less", + authFingerprint: authFingerprint, + managedHomePath: managedHome.path, + createdAt: 1, + updatedAt: 2, + lastAuthenticatedAt: 2) + let storeURL = try self.makeManagedAccountStoreURL(accounts: [managedAccount]) + defer { + settings._test_managedCodexAccountStoreURL = nil + try? FileManager.default.removeItem(at: storeURL) + try? FileManager.default.removeItem(at: managedHome) + } + settings._test_managedCodexAccountStoreURL = storeURL + settings.codexActiveSource = .managedAccount(id: accountID) + + let store = self.makeUsageStore(settings: settings) + let blocker = BlockingCodexFetchStrategy() + self.installBlockingCodexProvider(on: store, blocker: blocker) + + let refreshTask = Task { await store.refreshProvider(.codex, allowDisabled: true) } + await blocker.waitUntilStarted() + await blocker.resume(with: .success(UsageSnapshot( + primary: RateWindow( + usedPercent: 25, + windowMinutes: 300, + resetsAt: nil, + resetDescription: nil), + secondary: nil, + updatedAt: Date()))) + await refreshTask.value + + #expect(store.snapshots[.codex]?.primary?.usedPercent == 25) + #expect(store.snapshots[.codex]?.accountEmail(for: .codex) == "email-less-managed@example.com") + #expect(store.errors[.codex] == nil) + #expect(store.lastCodexAccountScopedRefreshGuard?.accountKey == "email-less-managed@example.com") + #expect(store.codexAccountSnapshots.first?.account.email == "email-less-managed@example.com") + #expect(store.codexAccountSnapshots.first?.snapshot?.accountEmail(for: .codex) == + "email-less-managed@example.com") + } + + @Test + func `same provider account managed email change discards stale codex usage success without email`() async throws { + let settings = self.makeSettingsStore( + suite: "CodexAccountScopedRefreshTests-managed-provider-email-change-without-email") + settings.refreshFrequency = .manual + + let accountID = try #require(UUID(uuidString: "DDDDDDDD-EEEE-FFFF-AAAA-181818181818")) + let managedHome = FileManager.default.temporaryDirectory + .appendingPathComponent( + "codex-managed-provider-email-without-email-\(UUID().uuidString)", + isDirectory: true) + try Self.writeCodexAuthFile( + homeURL: managedHome, + email: "old-managed-empty@example.com", + plan: "Pro", + accountId: "acct-managed-shared-empty") + let oldFingerprint = try #require(CodexAuthFingerprint.fingerprint(homePath: managedHome.path)) + let managedAccount = ManagedCodexAccount( + id: accountID, + email: "old-managed-empty@example.com", + providerAccountID: "acct-managed-shared-empty", + workspaceLabel: "Managed Team", + workspaceAccountID: "acct-managed-shared-empty", + authFingerprint: oldFingerprint, + managedHomePath: managedHome.path, + createdAt: 1, + updatedAt: 2, + lastAuthenticatedAt: 2) + let storeURL = try self.makeManagedAccountStoreURL(accounts: [managedAccount]) + defer { + settings._test_managedCodexAccountStoreURL = nil + try? FileManager.default.removeItem(at: storeURL) + try? FileManager.default.removeItem(at: managedHome) + } + settings._test_managedCodexAccountStoreURL = storeURL + settings.codexActiveSource = .managedAccount(id: accountID) + + let store = self.makeUsageStore(settings: settings) + let blocker = BlockingCodexFetchStrategy() + self.installBlockingCodexProvider(on: store, blocker: blocker) + + let refreshTask = Task { await store.refreshProvider(.codex, allowDisabled: true) } + await blocker.waitUntilStarted() + try Self.writeCodexAuthFile( + homeURL: managedHome, + email: "new-managed-empty@example.com", + plan: "Pro", + accountId: "acct-managed-shared-empty") + let newFingerprint = try #require(CodexAuthFingerprint.fingerprint(homePath: managedHome.path)) + #expect(newFingerprint != oldFingerprint) + await blocker.resume(with: .success(UsageSnapshot( + primary: RateWindow( + usedPercent: 25, + windowMinutes: 300, + resetsAt: nil, + resetDescription: nil), + secondary: nil, + updatedAt: Date()))) + await refreshTask.value + + #expect(store.snapshots[.codex] == nil) + #expect(store.errors[.codex] == nil) + } + + @Test + func `same email email-only auth fingerprint switch discards stale codex usage success`() async { + SettingsStore.codexAccountReconciliationSnapshotCacheIntervalOverrideForTesting = 60 + let settings = self.makeSettingsStore( + suite: "CodexAccountScopedRefreshTests-email-only-fingerprint-switch") + defer { + SettingsStore.codexAccountReconciliationSnapshotCacheIntervalOverrideForTesting = nil + settings._test_liveSystemCodexAccount = nil + } + settings.refreshFrequency = .manual + settings._test_liveSystemCodexAccount = ObservedSystemCodexAccount( + email: "alpha@example.com", + authFingerprint: "old-email-only-auth", + codexHomePath: "/Users/test/.codex", + observedAt: Date(), + identity: .emailOnly(normalizedEmail: "alpha@example.com")) + let staleReconciliationSnapshot = settings.codexAccountReconciliationSnapshot + + let store = self.makeUsageStore(settings: settings) + let blocker = BlockingCodexFetchStrategy() + self.installBlockingCodexProvider(on: store, blocker: blocker) + + let refreshTask = Task { await store.refreshProvider(.codex, allowDisabled: true) } + await blocker.waitUntilStarted() + settings._test_liveSystemCodexAccount = ObservedSystemCodexAccount( + email: "alpha@example.com", + authFingerprint: "new-email-only-auth", + codexHomePath: "/Users/test/.codex", + observedAt: Date(), + identity: .emailOnly(normalizedEmail: "alpha@example.com")) + settings.cachedCodexAccountReconciliationSnapshot = CachedCodexAccountReconciliationSnapshot( + activeSource: .liveSystem, + loadedAt: Date(), + snapshot: staleReconciliationSnapshot) + await blocker.resume(with: .success(self.codexSnapshot(email: "alpha@example.com", usedPercent: 25))) + await refreshTask.value + + #expect(store.snapshots[.codex] == nil) + #expect(store.errors[.codex] == nil) + } +} diff --git a/Tests/CodexBarTests/CodexAccountCompositeGuardTests.swift b/Tests/CodexBarTests/CodexAccountCompositeGuardTests.swift new file mode 100644 index 0000000000..8c9702c2a5 --- /dev/null +++ b/Tests/CodexBarTests/CodexAccountCompositeGuardTests.swift @@ -0,0 +1,145 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +@MainActor +extension CodexAccountScopedRefreshTests { + @Test + func `shared workspace rejects stale member results without fingerprints`() { + self.assertSharedWorkspaceMemberSwitchRejectsStaleResults( + suite: "CodexAccountScopedRefreshTests-shared-workspace-nil-fingerprint", + authFingerprint: nil) + } + + @Test + func `shared workspace rejects stale member results with stable fingerprints`() { + self.assertSharedWorkspaceMemberSwitchRejectsStaleResults( + suite: "CodexAccountScopedRefreshTests-shared-workspace-stable-fingerprint", + authFingerprint: "stable-auth") + } + + @Test + func `provider identity without email fails every scoped guard closed`() { + let settings = self.makeSettingsStore( + suite: "CodexAccountScopedRefreshTests-provider-identity-missing-email") + settings.refreshFrequency = .manual + settings._test_liveSystemCodexAccount = self.providerAccount( + email: " ", + authFingerprint: nil, + workspaceLabel: "Workspace") + + let store = self.makeUsageStore(settings: settings) + let expectedGuard = CodexAccountScopedRefreshGuard( + source: .liveSystem, + identity: .providerAccount(id: "shared-workspace"), + accountKey: nil, + authFingerprint: nil) + + self.expectEveryScopedGuardRejects( + store: store, + expectedGuard: expectedGuard, + staleEmail: nil) + #expect(!UsageStore.codexScopedRefreshGuardsMatchAccount(expectedGuard, expectedGuard)) + } + + @Test + func `same member auth rotation keeps success admission policy`() { + let settings = self.makeSettingsStore( + suite: "CodexAccountScopedRefreshTests-same-member-auth-rotation") + settings.refreshFrequency = .manual + settings._test_liveSystemCodexAccount = self.providerAccount( + email: "Member@Example.com", + authFingerprint: "old-auth", + workspaceLabel: "Old label") + + let store = self.makeUsageStore(settings: settings) + let expectedGuard = store.freshCodexAccountScopedRefreshGuard() + + settings._test_liveSystemCodexAccount = self.providerAccount( + email: " member@example.com ", + authFingerprint: "new-auth", + workspaceLabel: "New label") + + let usage = self.codexSnapshot(email: "member@example.com", usedPercent: 25) + #expect(store.shouldApplyCodexUsageResult(expectedGuard: expectedGuard, usage: usage)) + #expect(store.shouldApplyCodexScopedNonUsageResult(expectedGuard: expectedGuard)) + #expect(store.shouldApplyOpenAIDashboardRefreshGuard( + expectedGuard: expectedGuard, + routingTargetEmail: "member@example.com")) + #expect(store.shouldApplyOpenAIDashboardPolicyResult( + expectedGuard: expectedGuard, + routingTargetEmail: "member@example.com")) + #expect(!store.shouldApplyCodexScopedFailure(expectedGuard: expectedGuard)) + #expect(!store.shouldApplyCodexScopedNonUsageFailure(expectedGuard: expectedGuard)) + #expect(!store.shouldApplyOpenAIWebNonSuccessResult( + expectedGuard: expectedGuard, + routingTargetEmail: "member@example.com")) + } + + private func assertSharedWorkspaceMemberSwitchRejectsStaleResults( + suite: String, + authFingerprint: String?) + { + let settings = self.makeSettingsStore(suite: suite) + settings.refreshFrequency = .manual + settings._test_liveSystemCodexAccount = self.providerAccount( + email: "alpha@example.com", + authFingerprint: authFingerprint, + workspaceLabel: "Alpha") + + let store = self.makeUsageStore(settings: settings) + let expectedGuard = store.freshCodexAccountScopedRefreshGuard() + #expect(expectedGuard.identity == .providerAccount(id: "shared-workspace")) + #expect(expectedGuard.accountKey == "alpha@example.com") + + settings._test_liveSystemCodexAccount = self.providerAccount( + email: "beta@example.com", + authFingerprint: authFingerprint, + workspaceLabel: "Beta") + + self.expectEveryScopedGuardRejects( + store: store, + expectedGuard: expectedGuard, + staleEmail: "alpha@example.com") + #expect(!UsageStore.codexScopedRefreshGuardsMatchAccount( + expectedGuard, + store.freshCodexAccountScopedRefreshGuard())) + } + + private func expectEveryScopedGuardRejects( + store: UsageStore, + expectedGuard: CodexAccountScopedRefreshGuard, + staleEmail: String?) + { + let usage = self.codexSnapshot(email: staleEmail ?? "", usedPercent: 25) + #expect(!store.shouldApplyCodexUsageResult(expectedGuard: expectedGuard, usage: usage)) + #expect(!store.shouldApplyCodexScopedFailure(expectedGuard: expectedGuard)) + #expect(!store.shouldApplyCodexScopedNonUsageResult(expectedGuard: expectedGuard)) + #expect(!store.shouldApplyCodexScopedNonUsageFailure(expectedGuard: expectedGuard)) + #expect(!store.shouldApplyOpenAIDashboardRefreshGuard( + expectedGuard: expectedGuard, + routingTargetEmail: staleEmail)) + #expect(!store.shouldApplyOpenAIWebNonSuccessResult( + expectedGuard: expectedGuard, + routingTargetEmail: staleEmail)) + #expect(!store.shouldApplyOpenAIDashboardPolicyResult( + expectedGuard: expectedGuard, + routingTargetEmail: staleEmail)) + } + + private func providerAccount( + email: String, + authFingerprint: String?, + workspaceLabel: String) -> ObservedSystemCodexAccount + { + ObservedSystemCodexAccount( + email: email, + workspaceLabel: workspaceLabel, + workspaceAccountID: "shared-workspace", + authFingerprint: authFingerprint, + codexHomePath: "/Users/test/.codex", + observedAt: Date(), + identity: .providerAccount(id: "shared-workspace")) + } +} diff --git a/Tests/CodexBarTests/CodexAccountEmailOnlyHistoryBackfillTests.swift b/Tests/CodexBarTests/CodexAccountEmailOnlyHistoryBackfillTests.swift new file mode 100644 index 0000000000..5fe2f8b7b4 --- /dev/null +++ b/Tests/CodexBarTests/CodexAccountEmailOnlyHistoryBackfillTests.swift @@ -0,0 +1,119 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +@MainActor +extension CodexAccountScopedRefreshTests { + @Test + func `email only plan history never backfills quota publication`() async throws { + let settings = self.makeSettingsStore( + suite: "CodexAccountVisibleHistoryBackfillTests-non-active-email-history") + settings.refreshFrequency = .manual + settings.multiAccountMenuLayout = .stacked + + let activeID = try #require(UUID(uuidString: "BBBBBBBB-CCCC-DDDD-EEEE-333333333333")) + let siblingID = try #require(UUID(uuidString: "BBBBBBBB-CCCC-DDDD-EEEE-444444444444")) + let activeHome = FileManager.default.temporaryDirectory + .appendingPathComponent("codex-visible-active-email-\(UUID().uuidString)", isDirectory: true) + let siblingHome = FileManager.default.temporaryDirectory + .appendingPathComponent("codex-visible-sibling-email-\(UUID().uuidString)", isDirectory: true) + try Self.writeCodexAuthFile( + homeURL: activeHome, + email: "active-email-history@example.com", + plan: "Pro") + try Self.writeCodexAuthFile( + homeURL: siblingHome, + email: "sibling-email-history@example.com", + plan: "Pro") + let activeFingerprint = try #require(CodexAuthFingerprint.fingerprint(homePath: activeHome.path)) + let siblingFingerprint = try #require(CodexAuthFingerprint.fingerprint(homePath: siblingHome.path)) + let activeAccount = ManagedCodexAccount( + id: activeID, + email: "active-email-history@example.com", + workspaceLabel: "Active Team", + authFingerprint: activeFingerprint, + managedHomePath: activeHome.path, + createdAt: 1, + updatedAt: 2, + lastAuthenticatedAt: 2) + let siblingAccount = ManagedCodexAccount( + id: siblingID, + email: "sibling-email-history@example.com", + workspaceLabel: "Sibling Team", + authFingerprint: siblingFingerprint, + managedHomePath: siblingHome.path, + createdAt: 1, + updatedAt: 2, + lastAuthenticatedAt: 2) + let storeURL = try self.makeManagedAccountStoreURL(accounts: [activeAccount, siblingAccount]) + defer { + settings._test_managedCodexAccountStoreURL = nil + try? FileManager.default.removeItem(at: storeURL) + try? FileManager.default.removeItem(at: activeHome) + try? FileManager.default.removeItem(at: siblingHome) + } + settings._test_managedCodexAccountStoreURL = storeURL + settings.codexActiveSource = .managedAccount(id: activeID) + + let snapshotStore = RecordingCodexAccountUsageSnapshotStore(initialSnapshots: []) + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + codexAccountUsageSnapshotStore: snapshotStore, + startupBehavior: .testing) + let now = Date() + let sessionReset = now.addingTimeInterval(2 * 60 * 60) + let weeklyReset = now.addingTimeInterval(4 * 24 * 60 * 60) + let siblingHistoryKey = CodexHistoryOwnership.canonicalEmailHashKey(for: "sibling-email-history@example.com") + store.planUtilizationHistory[.codex] = PlanUtilizationHistoryBuckets(accounts: [ + siblingHistoryKey: [ + planSeries(name: .session, windowMinutes: 300, entries: [ + planEntry(at: now.addingTimeInterval(-60), usedPercent: 6, resetsAt: sessionReset), + ]), + planSeries(name: .weekly, windowMinutes: 10080, entries: [ + planEntry(at: now.addingTimeInterval(-60), usedPercent: 36, resetsAt: weeklyReset), + ]), + ], + ]) + store.lastCodexAccountScopedRefreshGuard = CodexAccountScopedRefreshGuard( + source: .managedAccount(id: activeID), + identity: .emailOnly(normalizedEmail: "active-email-history@example.com"), + accountKey: "active-email-history@example.com", + authFingerprint: activeFingerprint) + self.installContextualCodexProvider(on: store) { context in + let isActive = context.env["CODEX_HOME"] == activeHome.path + return UsageSnapshot( + primary: RateWindow( + usedPercent: isActive ? 3 : 6, + windowMinutes: 0, + resetsAt: nil, + resetDescription: nil), + secondary: nil, + updatedAt: now) + } + + await store.refreshCodexVisibleAccountsForMenu() + + let activeSnapshot = try #require(store.codexAccountSnapshots.first { + $0.account.email == "active-email-history@example.com" + }?.snapshot) + #expect(activeSnapshot.primary?.usedPercent == 3) + #expect(activeSnapshot.primary?.windowMinutes == 0) + #expect(activeSnapshot.primary?.resetsAt == nil) + + let siblingSnapshot = try #require(store.codexAccountSnapshots.first { + $0.account.email == "sibling-email-history@example.com" + }?.snapshot) + #expect(siblingSnapshot.primary?.usedPercent == 6) + #expect(siblingSnapshot.primary?.windowMinutes == 0) + #expect(siblingSnapshot.primary?.resetsAt == nil) + #expect(siblingSnapshot.secondary == nil) + let persistedSibling = try #require(snapshotStore.storedSnapshots.first { + $0.account.email == "sibling-email-history@example.com" + }?.snapshot) + #expect(persistedSibling.primary?.resetsAt == nil) + #expect(persistedSibling.secondary == nil) + } +} diff --git a/Tests/CodexBarTests/CodexAccountMenuDisplaySnapshotTests.swift b/Tests/CodexBarTests/CodexAccountMenuDisplaySnapshotTests.swift new file mode 100644 index 0000000000..409e95ed3a --- /dev/null +++ b/Tests/CodexBarTests/CodexAccountMenuDisplaySnapshotTests.swift @@ -0,0 +1,424 @@ +import AppKit +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +@Suite(.serialized) +@MainActor +struct CodexAccountMenuDisplaySnapshotTests { + private func makeSettings() -> SettingsStore { + let suite = "CodexAccountMenuDisplaySnapshotTests-\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suite)! + defaults.removePersistentDomain(forName: suite) + defaults.set(true, forKey: "providerDetectionCompleted") + let settings = SettingsStore( + userDefaults: defaults, + configStore: testConfigStore(suiteName: suite), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + settings.providerDetectionCompleted = true + return settings + } + + private func enableOnlyCodex(_ settings: SettingsStore) { + for provider in UsageProvider.allCases { + guard let metadata = ProviderRegistry.shared.metadata[provider] else { continue } + settings.setProviderEnabled(provider: provider, metadata: metadata, enabled: provider == .codex) + } + } + + private func liveSnapshot(email: String) -> CodexAccountReconciliationSnapshot { + CodexAccountReconciliationSnapshot( + storedAccounts: [], + activeStoredAccount: nil, + liveSystemAccount: ObservedSystemCodexAccount( + email: email, + codexHomePath: "/tmp/\(email)", + observedAt: Date()), + matchingStoredAccountForLiveSystemAccount: nil, + activeSource: .liveSystem, + hasUnreadableAddedAccountStore: false) + } + + private func cachedProjection( + snapshot: CodexAccountReconciliationSnapshot, + loadedAt: Date = Date(timeIntervalSinceNow: -3600)) -> CachedCodexAccountMenuProjection + { + CachedCodexAccountMenuProjection( + activeSource: snapshot.activeSource, + loadedAt: loadedAt, + projection: CodexVisibleAccountProjection.make(from: snapshot)) + } + + @Test + func `cold menu projection read never loads auth state`() async { + let settings = self.makeSettings() + let probe = CodexAccountSnapshotLoaderProbe(snapshot: self.liveSnapshot(email: "loaded@example.com")) + settings._test_codexAccountSnapshotLoader = { _ in probe.load() } + defer { settings._test_codexAccountSnapshotLoader = nil } + + #expect(settings.codexVisibleAccountProjectionForMenuDisplay == nil) + #expect(probe.callCount == 0) + + let result = await settings.revalidateCodexAccountMenuProjection() + + #expect(result == .updated) + #expect(probe.callCount == 1) + #expect(probe.loadedOffMainThread) + #expect( + settings.codexVisibleAccountProjectionForMenuDisplay?.visibleAccounts.first?.email == + "loaded@example.com") + } + + @Test + func `override snapshot load preserves persisted account menu projection`() { + let settings = self.makeSettings() + let activeSnapshot = self.liveSnapshot(email: "active@example.com") + settings.cachedCodexAccountMenuProjection = self.cachedProjection(snapshot: activeSnapshot) + + let otherID = UUID() + let otherAccount = ManagedCodexAccount( + id: otherID, + email: "other@example.com", + managedHomePath: "/tmp/other", + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1) + let overrideSnapshot = CodexAccountReconciliationSnapshot( + storedAccounts: [otherAccount], + activeStoredAccount: otherAccount, + liveSystemAccount: nil, + matchingStoredAccountForLiveSystemAccount: nil, + activeSource: .managedAccount(id: otherID), + hasUnreadableAddedAccountStore: false) + settings._test_codexAccountSnapshotLoader = { _ in overrideSnapshot } + defer { settings._test_codexAccountSnapshotLoader = nil } + + _ = settings.codexAccountReconciliationSnapshot(activeSourceOverride: .managedAccount(id: otherID)) + + #expect( + settings.codexVisibleAccountProjectionForMenuDisplay?.visibleAccounts.first?.email == + "active@example.com") + } + + @Test + func `managed account change refreshes account menu projection`() { + let settings = self.makeSettings() + let activeSnapshot = self.liveSnapshot(email: "active@example.com") + settings.cachedCodexAccountMenuProjection = self.cachedProjection(snapshot: activeSnapshot, loadedAt: Date()) + + let addedAccount = ManagedCodexAccount( + id: UUID(), + email: "added@example.com", + managedHomePath: "/tmp/added", + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1) + let refreshedSnapshot = CodexAccountReconciliationSnapshot( + storedAccounts: [addedAccount], + activeStoredAccount: nil, + liveSystemAccount: activeSnapshot.liveSystemAccount, + matchingStoredAccountForLiveSystemAccount: nil, + activeSource: .liveSystem, + hasUnreadableAddedAccountStore: false) + settings._test_codexAccountSnapshotLoader = { _ in refreshedSnapshot } + defer { settings._test_codexAccountSnapshotLoader = nil } + + settings.refreshCodexAccountReconciliationAfterManagedAccountsDidChange() + + #expect( + settings.codexVisibleAccountProjectionForMenuDisplay?.visibleAccounts.contains { + $0.email == "added@example.com" + } == true) + } + + @Test + func `stacked menu matches runtime enriched snapshot for legacy managed workspace`() throws { + let settings = self.makeSettings() + settings.multiAccountMenuLayout = .stacked + let legacyID = UUID() + let siblingID = UUID() + let legacy = ManagedCodexAccount( + id: legacyID, + email: "legacy@example.com", + workspaceAccountID: nil, + managedHomePath: "/tmp/legacy", + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1) + let sibling = ManagedCodexAccount( + id: siblingID, + email: "sibling@example.com", + workspaceAccountID: "account-sibling", + managedHomePath: "/tmp/sibling", + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1) + let snapshot = CodexAccountReconciliationSnapshot( + storedAccounts: [legacy, sibling], + activeStoredAccount: legacy, + liveSystemAccount: nil, + matchingStoredAccountForLiveSystemAccount: nil, + activeSource: .managedAccount(id: legacyID), + hasUnreadableAddedAccountStore: false, + storedAccountRuntimeIdentities: [ + legacyID: .providerAccount(id: " Account-Runtime "), + siblingID: .providerAccount(id: "account-sibling"), + ]) + let projection = CodexVisibleAccountProjection.make(from: snapshot) + let legacyProjected = try #require(projection.visibleAccounts.first { + $0.selectionSource == .managedAccount(id: legacyID) + }) + let siblingProjected = try #require(projection.visibleAccounts.first { + $0.selectionSource == .managedAccount(id: siblingID) + }) + let runtimeEnrichedLegacy = CodexVisibleAccount( + id: legacyProjected.id, + email: legacyProjected.email, + workspaceLabel: legacyProjected.workspaceLabel, + workspaceAccountID: "account-runtime", + authFingerprint: legacyProjected.authFingerprint, + storedAccountID: legacyProjected.storedAccountID, + selectionSource: legacyProjected.selectionSource, + isActive: legacyProjected.isActive, + isLive: legacyProjected.isLive, + canReauthenticate: legacyProjected.canReauthenticate, + canRemove: legacyProjected.canRemove) + + settings.codexActiveSource = .managedAccount(id: legacyID) + settings.cachedCodexAccountMenuProjection = self.cachedProjection(snapshot: snapshot, loadedAt: Date()) + let store = UsageStore( + fetcher: UsageFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings) + store.codexAccountSnapshots = [runtimeEnrichedLegacy, siblingProjected].map { + CodexAccountUsageSnapshot(account: $0, snapshot: nil, error: nil, sourceLabel: "test") + } + let controller = StatusItemController( + store: store, + settings: settings, + account: AccountInfo(email: nil, plan: nil), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: .system) + defer { controller.releaseStatusItemsForTesting() } + + let display = try #require(controller.codexAccountMenuDisplay(for: .codex)) + + #expect(legacyProjected.workspaceAccountID == "account-runtime") + #expect(display.snapshots.map(\.id).sorted() == [legacyProjected.id, siblingProjected.id].sorted()) + } + + @Test + func `stale menu projection returns immediately then refreshes concurrently`() async { + let settings = self.makeSettings() + let staleSnapshot = self.liveSnapshot(email: "before@example.com") + let probe = CodexAccountSnapshotLoaderProbe(snapshot: self.liveSnapshot(email: "after@example.com")) + settings.cachedCodexAccountMenuProjection = self.cachedProjection(snapshot: staleSnapshot) + settings._test_codexAccountSnapshotLoader = { _ in probe.load() } + SettingsStore.codexAccountReconciliationSnapshotCacheIntervalOverrideForTesting = 60 + defer { + SettingsStore.codexAccountReconciliationSnapshotCacheIntervalOverrideForTesting = nil + settings._test_codexAccountSnapshotLoader = nil + } + + #expect( + settings.codexVisibleAccountProjectionForMenuDisplay?.visibleAccounts.first?.email == + "before@example.com") + #expect(probe.callCount == 0) + #expect(settings.codexAccountMenuProjectionNeedsRevalidation) + + let result = await settings.revalidateCodexAccountMenuProjection() + + #expect(result == .updated) + #expect(probe.callCount == 1) + #expect(probe.loadedOffMainThread) + #expect( + settings.codexVisibleAccountProjectionForMenuDisplay?.visibleAccounts.first?.email == + "after@example.com") + } + + @Test + func `revalidation discards result after reconciliation generation changes`() async { + let settings = self.makeSettings() + let staleSnapshot = self.liveSnapshot(email: "before@example.com") + let probe = CodexAccountSnapshotLoaderProbe( + snapshot: self.liveSnapshot(email: "discarded@example.com"), + blocks: true) + settings.cachedCodexAccountMenuProjection = self.cachedProjection(snapshot: staleSnapshot) + settings._test_codexAccountSnapshotLoader = { _ in probe.load() } + SettingsStore.codexAccountReconciliationSnapshotCacheIntervalOverrideForTesting = 60 + defer { + probe.release() + SettingsStore.codexAccountReconciliationSnapshotCacheIntervalOverrideForTesting = nil + settings._test_codexAccountSnapshotLoader = nil + } + + let task = Task { await settings.revalidateCodexAccountMenuProjection() } + await probe.waitUntilCalled() + settings.invalidateCodexAccountReconciliationSnapshotCache() + probe.release() + + #expect(await task.value == .discarded) + #expect( + settings.codexVisibleAccountProjectionForMenuDisplay?.visibleAccounts.first?.email == + "before@example.com") + } + + @Test + func `fresh menu open coalesces account projection revalidation and identity stays read only`() async throws { + StatusItemController.menuCardRenderingEnabled = false + StatusItemController.setMenuRefreshEnabledForTesting(false) + StatusItemController.setCodexAccountMenuProjectionRevalidationEnabledForTesting(true) + defer { + StatusItemController.resetCodexAccountMenuProjectionRevalidationEnabledForTesting() + StatusItemController.resetMenuRefreshEnabledForTesting() + } + + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + self.enableOnlyCodex(settings) + let store = UsageStore( + fetcher: UsageFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings) + store._cancelPlanUtilizationHistoryLoadForTesting() + let controller = StatusItemController( + store: store, + settings: settings, + account: AccountInfo(email: nil, plan: nil), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: .system) + defer { controller.releaseStatusItemsForTesting() } + + let staleSnapshot = self.liveSnapshot(email: "before@example.com") + let probe = CodexAccountSnapshotLoaderProbe( + snapshot: self.liveSnapshot(email: "after@example.com"), + blocks: true) + settings.cachedCodexAccountMenuProjection = self.cachedProjection(snapshot: staleSnapshot) + settings._test_codexAccountSnapshotLoader = { _ in probe.load() } + SettingsStore.codexAccountReconciliationSnapshotCacheIntervalOverrideForTesting = 60 + defer { + probe.release() + SettingsStore.codexAccountReconciliationSnapshotCacheIntervalOverrideForTesting = nil + settings._test_codexAccountSnapshotLoader = nil + } + + let menu = NSMenu() + controller.menuProviders[ObjectIdentifier(menu)] = .codex + controller.markMenuFresh(menu) + #expect(controller.codexAccountMenuDisplay(for: .codex) == nil) + #expect(probe.callCount == 0) + + let versionBeforeOpen = controller.menuContentVersion + controller.menuWillOpen(menu) + let revalidation = try #require(controller.codexAccountMenuProjectionRevalidationTask) + controller.menuWillOpen(menu) + await probe.waitUntilCalled() + + #expect(probe.callCount == 1) + #expect(probe.loadedOffMainThread) + probe.release() + await revalidation.value + + #expect(controller.codexAccountMenuProjectionRevalidationTask == nil) + #expect(controller.menuContentVersion == versionBeforeOpen + 1) + } + + @Test + func `selecting displayed account uses captured source without reconciliation`() throws { + let settings = self.makeSettings() + let firstID = UUID() + let secondID = UUID() + let first = ManagedCodexAccount( + id: firstID, + email: "first@example.com", + managedHomePath: "/tmp/first", + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1) + let second = ManagedCodexAccount( + id: secondID, + email: "second@example.com", + managedHomePath: "/tmp/second", + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1) + let snapshot = CodexAccountReconciliationSnapshot( + storedAccounts: [first, second], + activeStoredAccount: first, + liveSystemAccount: nil, + matchingStoredAccountForLiveSystemAccount: nil, + activeSource: .managedAccount(id: firstID), + hasUnreadableAddedAccountStore: false) + let projection = CodexVisibleAccountProjection.make(from: snapshot) + let displayedAccount = try #require(projection.visibleAccounts.first { + $0.selectionSource == .managedAccount(id: secondID) + }) + let probe = CodexAccountSnapshotLoaderProbe(snapshot: snapshot) + settings.cachedCodexAccountMenuProjection = self.cachedProjection(snapshot: snapshot) + settings._test_codexAccountSnapshotLoader = { _ in probe.load() } + defer { settings._test_codexAccountSnapshotLoader = nil } + + settings.selectDisplayedCodexVisibleAccount(displayedAccount) + + #expect(probe.callCount == 0) + #expect(settings.codexActiveSource == .managedAccount(id: secondID)) + #expect(settings.cachedCodexAccountMenuProjection == nil) + } +} + +private final class CodexAccountSnapshotLoaderProbe: @unchecked Sendable { + private let lock = NSLock() + private let snapshot: CodexAccountReconciliationSnapshot + private let blocks: Bool + private let releaseSemaphore = DispatchSemaphore(value: 0) + private var _callCount = 0 + private var _loadedOffMainThread = false + private var released = false + + init(snapshot: CodexAccountReconciliationSnapshot, blocks: Bool = false) { + self.snapshot = snapshot + self.blocks = blocks + } + + var callCount: Int { + self.lock.withLock { self._callCount } + } + + var loadedOffMainThread: Bool { + self.lock.withLock { self._loadedOffMainThread } + } + + func load() -> CodexAccountReconciliationSnapshot { + self.lock.withLock { + self._callCount += 1 + self._loadedOffMainThread = self._loadedOffMainThread || !Thread.isMainThread + } + if self.blocks { + self.releaseSemaphore.wait() + } + return self.snapshot + } + + func waitUntilCalled() async { + while self.callCount == 0 { + await Task.yield() + } + } + + func release() { + let shouldSignal = self.lock.withLock { + guard !self.released else { return false } + self.released = true + return true + } + if shouldSignal { + self.releaseSemaphore.signal() + } + } +} diff --git a/Tests/CodexBarTests/CodexAccountReconciliationTests.swift b/Tests/CodexBarTests/CodexAccountReconciliationTests.swift index 438ae652aa..cf53f30e82 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/CodexAccountRefreshProjectionTests.swift b/Tests/CodexBarTests/CodexAccountRefreshProjectionTests.swift new file mode 100644 index 0000000000..5920bdb6a9 --- /dev/null +++ b/Tests/CodexBarTests/CodexAccountRefreshProjectionTests.swift @@ -0,0 +1,282 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +@MainActor +extension CodexAccountScopedRefreshTests { + @Test + func `stale stacked projection collapse runs single codex fetch`() async throws { + SettingsStore.codexAccountReconciliationSnapshotCacheIntervalOverrideForTesting = 60 + let settings = self.makeSettingsStore( + suite: "CodexAccountScopedRefreshTests-stacked-collapse-single-fetch") + defer { + SettingsStore.codexAccountReconciliationSnapshotCacheIntervalOverrideForTesting = nil + settings._test_liveSystemCodexAccount = nil + settings._test_managedCodexAccountStoreURL = nil + } + settings.refreshFrequency = .manual + settings.multiAccountMenuLayout = .stacked + settings.codexActiveSource = .liveSystem + settings._test_liveSystemCodexAccount = self.liveAccount( + email: "live-collapse@example.com", + identity: .providerAccount(id: "acct-live-collapse")) + + let managedAccountID = try #require(UUID(uuidString: "DDDDDDDD-EEEE-FFFF-AAAA-191919191919")) + let managedAccount = ManagedCodexAccount( + id: managedAccountID, + email: "managed-collapse@example.com", + managedHomePath: "/tmp/codex-managed-collapse", + createdAt: 1, + updatedAt: 2, + lastAuthenticatedAt: 2) + let staleStoreURL = try self.makeManagedAccountStoreURL(accounts: [managedAccount]) + let emptyStoreURL = try self.makeManagedAccountStoreURL(accounts: []) + defer { + try? FileManager.default.removeItem(at: staleStoreURL) + try? FileManager.default.removeItem(at: emptyStoreURL) + } + settings._test_managedCodexAccountStoreURL = staleStoreURL + let staleReconciliationSnapshot = settings.codexAccountReconciliationSnapshot + #expect(CodexVisibleAccountProjection.make(from: staleReconciliationSnapshot).visibleAccounts.count == 2) + + settings._test_managedCodexAccountStoreURL = emptyStoreURL + settings.cachedCodexAccountReconciliationSnapshot = CachedCodexAccountReconciliationSnapshot( + activeSource: .liveSystem, + loadedAt: Date(), + snapshot: staleReconciliationSnapshot) + + let store = self.makeUsageStore(settings: settings) + self.installImmediateCodexProvider( + on: store, + snapshot: self.codexSnapshot(email: "live-collapse@example.com", usedPercent: 42)) + + await store.refreshProvider(.codex, allowDisabled: true) + + #expect(store.snapshots[.codex]?.primary?.usedPercent == 42) + #expect(store.codexAccountSnapshots.count == 1) + #expect(store.codexAccountSnapshots.first?.account.email == "live-collapse@example.com") + #expect(store.codexAccountSnapshots.first?.snapshot?.primary?.usedPercent == 42) + } + + @Test + func `stacked visible refresh discards selected success after managed auth file is removed`() async throws { + let settings = self.makeSettingsStore( + suite: "CodexAccountScopedRefreshTests-selected-managed-auth-file-removed") + settings.refreshFrequency = .manual + settings.multiAccountMenuLayout = .stacked + + let targetID = try #require(UUID(uuidString: "DDDDDDDD-EEEE-FFFF-AAAA-202020202020")) + let siblingID = try #require(UUID(uuidString: "DDDDDDDD-EEEE-FFFF-AAAA-212121212121")) + let targetHome = FileManager.default.temporaryDirectory + .appendingPathComponent("codex-visible-managed-auth-removed-\(UUID().uuidString)", isDirectory: true) + let siblingHome = FileManager.default.temporaryDirectory + .appendingPathComponent( + "codex-visible-managed-auth-removed-sibling-\(UUID().uuidString)", + isDirectory: true) + try Self.writeCodexAuthFile( + homeURL: targetHome, + email: "managed-removed@example.com", + plan: "Pro", + accountId: "acct-managed-removed") + let oldFingerprint = try #require(CodexAuthFingerprint.fingerprint(homePath: targetHome.path)) + let targetAccount = ManagedCodexAccount( + id: targetID, + email: "managed-removed@example.com", + providerAccountID: "acct-managed-removed", + workspaceLabel: "Managed Team", + workspaceAccountID: "acct-managed-removed", + authFingerprint: oldFingerprint, + managedHomePath: targetHome.path, + createdAt: 1, + updatedAt: 2, + lastAuthenticatedAt: 2) + let siblingAccount = ManagedCodexAccount( + id: siblingID, + email: "managed-removed-sibling@example.com", + providerAccountID: "acct-managed-removed-sibling", + workspaceLabel: "Sibling Team", + workspaceAccountID: "acct-managed-removed-sibling", + authFingerprint: "sibling-managed-removed", + managedHomePath: siblingHome.path, + createdAt: 1, + updatedAt: 2, + lastAuthenticatedAt: 2) + let storeURL = try self.makeManagedAccountStoreURL(accounts: [targetAccount, siblingAccount]) + defer { + settings._test_managedCodexAccountStoreURL = nil + try? FileManager.default.removeItem(at: storeURL) + try? FileManager.default.removeItem(at: targetHome) + try? FileManager.default.removeItem(at: siblingHome) + } + settings._test_managedCodexAccountStoreURL = storeURL + settings.codexActiveSource = .managedAccount(id: targetID) + + let snapshotStore = RecordingCodexAccountUsageSnapshotStore(initialSnapshots: []) + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + codexAccountUsageSnapshotStore: snapshotStore, + startupBehavior: .testing) + let blocker = BlockingCodexFetchStrategy() + let targetHomePath = targetHome.path + let now = Date() + self.installContextualCodexProvider(on: store) { context in + if context.env["CODEX_HOME"] == targetHomePath { + return try await blocker.awaitResult() + } + return UsageSnapshot( + primary: RateWindow( + usedPercent: 9, + windowMinutes: 300, + resetsAt: nil, + resetDescription: nil), + secondary: nil, + updatedAt: now, + identity: ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: "managed-removed-sibling@example.com", + accountOrganization: nil, + loginMethod: "Sibling Team")) + } + + let refreshTask = Task { await store.refreshCodexVisibleAccountsForMenu() } + await blocker.waitUntilStarted() + try FileManager.default.removeItem(at: targetHome) + await blocker.resume(with: .success(self.codexSnapshot(email: "managed-removed@example.com", usedPercent: 44))) + await refreshTask.value + + #expect(store.snapshots[.codex] == nil) + #expect(store.errors[.codex] == nil) + #expect(!store.codexAccountSnapshots.contains { + $0.account.workspaceAccountID == "acct-managed-removed" + }) + #expect(!snapshotStore.storedSnapshots.contains { + $0.account.workspaceAccountID == "acct-managed-removed" + }) + } + + @Test + func `startup snapshot hydration refreshes managed auth fingerprint with composite disk owner`() throws { + let settings = self.makeSettingsStore( + suite: "CodexAccountScopedRefreshTests-startup-managed-auth-hydration") + settings.refreshFrequency = .manual + settings.multiAccountMenuLayout = .stacked + + let accountID = try #require(UUID(uuidString: "DDDDDDDD-EEEE-FFFF-AAAA-222222222222")) + let managedHome = FileManager.default.temporaryDirectory + .appendingPathComponent("codex-visible-managed-startup-\(UUID().uuidString)", isDirectory: true) + try Self.writeCodexAuthFile( + homeURL: managedHome, + email: "managed-startup@example.com", + plan: "Pro", + accountId: "acct-managed-startup") + let oldFingerprint = try #require(CodexAuthFingerprint.fingerprint(homePath: managedHome.path)) + let managedAccount = ManagedCodexAccount( + id: accountID, + email: "managed-startup@example.com", + authFingerprint: oldFingerprint, + managedHomePath: managedHome.path, + createdAt: 1, + updatedAt: 2, + lastAuthenticatedAt: 2) + let storeURL = try self.makeManagedAccountStoreURL(accounts: [managedAccount]) + let snapshotURL = FileManager.default.temporaryDirectory + .appendingPathComponent("codex-visible-managed-startup-\(UUID().uuidString).json") + defer { + settings._test_managedCodexAccountStoreURL = nil + try? FileManager.default.removeItem(at: storeURL) + try? FileManager.default.removeItem(at: snapshotURL) + try? FileManager.default.removeItem(at: managedHome) + } + settings._test_managedCodexAccountStoreURL = storeURL + settings.codexActiveSource = .managedAccount(id: accountID) + + let staleAccount = try #require(settings.codexVisibleAccountProjection.visibleAccounts + .first { $0.storedAccountID == accountID }) + #expect(staleAccount.authFingerprint == oldFingerprint) + #expect(staleAccount.workspaceAccountID == "acct-managed-startup") + + try Self.writeCodexAuthFile( + homeURL: managedHome, + email: "managed-startup@example.com", + plan: "Team", + accountId: "acct-managed-startup") + let newFingerprint = try #require(CodexAuthFingerprint.fingerprint(homePath: managedHome.path)) + #expect(newFingerprint != oldFingerprint) + + let freshAccount = CodexVisibleAccount( + id: staleAccount.id, + email: staleAccount.email, + workspaceLabel: staleAccount.workspaceLabel, + workspaceAccountID: staleAccount.workspaceAccountID, + authFingerprint: newFingerprint, + storedAccountID: staleAccount.storedAccountID, + selectionSource: staleAccount.selectionSource, + isActive: staleAccount.isActive, + isLive: staleAccount.isLive, + canReauthenticate: staleAccount.canReauthenticate, + canRemove: staleAccount.canRemove) + let snapshotStore = FileCodexAccountUsageSnapshotStore(fileURL: snapshotURL) + snapshotStore.store([ + CodexAccountUsageSnapshot( + account: freshAccount, + snapshot: self.codexSnapshot(email: freshAccount.email, usedPercent: 64), + error: nil, + sourceLabel: "cached"), + ]) + #expect(snapshotStore.load(for: [staleAccount]).count == 1) + + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + codexAccountUsageSnapshotStore: snapshotStore, + startupBehavior: .testing) + + let hydrated = try #require(store.codexAccountSnapshots.first) + #expect(store.codexAccountSnapshots.count == 1) + #expect(hydrated.id == freshAccount.id) + #expect(hydrated.account.authFingerprint == newFingerprint) + #expect(hydrated.snapshot?.primary?.usedPercent == 64) + } + + @Test + func `snapshot hydration never crosses members of the same provider workspace`() { + let snapshotURL = FileManager.default.temporaryDirectory + .appendingPathComponent("codex-provider-member-isolation-\(UUID().uuidString).json") + defer { try? FileManager.default.removeItem(at: snapshotURL) } + + let priorAccount = CodexVisibleAccount( + id: "shared-row-id", + email: "first-member@example.com", + workspaceAccountID: "workspace-shared-by-members", + storedAccountID: nil, + selectionSource: .liveSystem, + isActive: true, + isLive: true, + canReauthenticate: false, + canRemove: false) + let otherMember = CodexVisibleAccount( + id: "shared-row-id", + email: "second-member@example.com", + workspaceAccountID: "workspace-shared-by-members", + storedAccountID: nil, + selectionSource: .liveSystem, + isActive: true, + isLive: true, + canReauthenticate: false, + canRemove: false) + let snapshotStore = FileCodexAccountUsageSnapshotStore(fileURL: snapshotURL) + snapshotStore.store([ + CodexAccountUsageSnapshot( + account: priorAccount, + snapshot: self.codexSnapshot(email: priorAccount.email, usedPercent: 64), + error: nil, + sourceLabel: "cached"), + ]) + + #expect(snapshotStore.load(for: [otherMember]).isEmpty) + } +} diff --git a/Tests/CodexBarTests/CodexAccountScopedRefreshDashboardCleanupTests.swift b/Tests/CodexBarTests/CodexAccountScopedRefreshDashboardCleanupTests.swift index 3e8a519f2b..719b07c97f 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) @@ -119,4 +119,155 @@ extension CodexAccountScopedRefreshTests { #expect(store.openAIDashboardRequiresLogin == true) #expect(store.lastOpenAIDashboardError?.contains("OpenAI dashboard signed in as other@example.com") == true) } + + @Test + func `dashboard fail closed cleanup applies after same account managed token rotation`() async throws { + OpenAIDashboardCacheStore.clear() + defer { OpenAIDashboardCacheStore.clear() } + + let settings = self.makeSettingsStore( + suite: "CodexAccountScopedRefreshTests-dashboard-fail-closed-token-rotation-cleanup") + let managedHome = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: managedHome) } + try Self.writeCodexAuthFile( + homeURL: managedHome, + email: "managed@example.com", + plan: "pro", + accountId: "acct-managed") + let oldFingerprint = try #require(CodexAuthFingerprint.fingerprint(homePath: managedHome.path)) + + let managedAccount = ManagedCodexAccount( + id: UUID(), + email: "managed@example.com", + providerAccountID: "acct-managed", + workspaceLabel: "Managed", + workspaceAccountID: "acct-managed", + authFingerprint: oldFingerprint, + managedHomePath: managedHome.path, + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1) + let managedStoreURL = try self.makeManagedAccountStoreURL(accounts: [managedAccount]) + defer { + settings._test_managedCodexAccountStoreURL = nil + settings._test_activeManagedCodexAccount = nil + try? FileManager.default.removeItem(at: managedStoreURL) + } + + settings.refreshFrequency = .manual + settings.codexCookieSource = .auto + settings._test_managedCodexAccountStoreURL = managedStoreURL + settings._test_activeManagedCodexAccount = managedAccount + settings.codexActiveSource = .managedAccount(id: managedAccount.id) + + let store = self.makeUsageStore(settings: settings) + let expectedGuard = store.freshCodexOpenAIWebRefreshGuard() + #expect(expectedGuard.authFingerprint == oldFingerprint) + store._setSnapshotForTesting( + self.codexSnapshot(email: "managed@example.com", usedPercent: 20), + provider: .codex) + store.lastSourceLabels[.codex] = "openai-web" + let staleCredits = self.credits(remaining: 20) + store.credits = staleCredits + store.lastCreditsSnapshot = staleCredits + store.lastCreditsSnapshotAccountKey = "managed@example.com" + store.lastCreditsSource = .dashboardWeb + store.openAIDashboard = self.dashboard(email: "managed@example.com", creditsRemaining: 20, usedPercent: 20) + store.lastOpenAIDashboardSnapshot = store.openAIDashboard + OpenAIDashboardCacheStore.save(OpenAIDashboardCache( + accountEmail: "managed@example.com", + snapshot: self.dashboard(email: "managed@example.com", creditsRemaining: 20, usedPercent: 20))) + + try Self.writeCodexAuthFile( + homeURL: managedHome, + email: "managed@example.com", + plan: "team", + accountId: "acct-managed") + let newFingerprint = try #require(CodexAuthFingerprint.fingerprint(homePath: managedHome.path)) + #expect(newFingerprint != oldFingerprint) + let currentGuard = store.freshCodexOpenAIWebRefreshGuard() + #expect(currentGuard.identity == expectedGuard.identity) + #expect(currentGuard.accountKey == expectedGuard.accountKey) + #expect(currentGuard.authFingerprint == newFingerprint) + + await store.applyOpenAIDashboard( + self.dashboard(email: "other@example.com", creditsRemaining: 9, usedPercent: 35), + targetEmail: "managed@example.com", + expectedGuard: expectedGuard) + + #expect(store.openAIDashboard == nil) + #expect(store.lastOpenAIDashboardSnapshot == nil) + #expect(store.snapshots[.codex] == nil) + #expect(store.credits == nil) + #expect(store.lastCreditsSource == .none) + #expect(OpenAIDashboardCacheStore.load() == nil) + #expect(store.openAIDashboardRequiresLogin == true) + #expect(store.lastOpenAIDashboardError?.contains("OpenAI dashboard signed in as other@example.com") == true) + } + + @Test + func `dashboard fail closed cleanup applies after same live account email changes during token rotation`() async { + OpenAIDashboardCacheStore.clear() + defer { OpenAIDashboardCacheStore.clear() } + + let settings = self.makeSettingsStore( + suite: "CodexAccountScopedRefreshTests-dashboard-fail-closed-live-email-rotation-cleanup") + settings.refreshFrequency = .manual + settings.codexCookieSource = .auto + settings.codexActiveSource = .liveSystem + settings._test_liveSystemCodexAccount = ObservedSystemCodexAccount( + email: "alpha@example.com", + authFingerprint: "old-token-material", + codexHomePath: "/Users/test/.codex", + observedAt: Date(), + identity: .providerAccount(id: "acct-alpha")) + defer { + settings._test_liveSystemCodexAccount = nil + } + + let store = self.makeUsageStore(settings: settings) + let expectedGuard = store.freshCodexOpenAIWebRefreshGuard() + #expect(expectedGuard.accountKey == "alpha@example.com") + #expect(expectedGuard.authFingerprint == "old-token-material") + store._setSnapshotForTesting( + self.codexSnapshot(email: "alpha@example.com", usedPercent: 20), + provider: .codex) + store.lastSourceLabels[.codex] = "openai-web" + let staleCredits = self.credits(remaining: 20) + store.credits = staleCredits + store.lastCreditsSnapshot = staleCredits + store.lastCreditsSnapshotAccountKey = "alpha@example.com" + store.lastCreditsSource = .dashboardWeb + store.openAIDashboard = self.dashboard(email: "alpha@example.com", creditsRemaining: 20, usedPercent: 20) + store.lastOpenAIDashboardSnapshot = store.openAIDashboard + OpenAIDashboardCacheStore.save(OpenAIDashboardCache( + accountEmail: "alpha@example.com", + snapshot: self.dashboard(email: "alpha@example.com", creditsRemaining: 20, usedPercent: 20))) + + settings._test_liveSystemCodexAccount = ObservedSystemCodexAccount( + email: "beta@example.com", + authFingerprint: "new-token-material", + codexHomePath: "/Users/test/.codex", + observedAt: Date(), + identity: .providerAccount(id: "acct-alpha")) + let currentGuard = store.freshCodexOpenAIWebRefreshGuard() + #expect(currentGuard.identity == expectedGuard.identity) + #expect(currentGuard.accountKey == "beta@example.com") + #expect(currentGuard.authFingerprint == "new-token-material") + + await store.applyOpenAIDashboard( + self.dashboard(email: "alpha@example.com", creditsRemaining: 9, usedPercent: 35), + targetEmail: "alpha@example.com", + expectedGuard: expectedGuard) + + #expect(store.openAIDashboard == nil) + #expect(store.lastOpenAIDashboardSnapshot == nil) + #expect(store.snapshots[.codex] == nil) + #expect(store.credits == nil) + #expect(store.lastCreditsSource == .none) + #expect(OpenAIDashboardCacheStore.load() == nil) + #expect(store.openAIDashboardRequiresLogin == true) + #expect(store.lastOpenAIDashboardError?.contains("OpenAI dashboard signed in as alpha@example.com") == true) + } } diff --git a/Tests/CodexBarTests/CodexAccountScopedRefreshTestSupport.swift b/Tests/CodexBarTests/CodexAccountScopedRefreshTestSupport.swift index e2ee1d4aa3..ccbc9c4d61 100644 --- a/Tests/CodexBarTests/CodexAccountScopedRefreshTestSupport.swift +++ b/Tests/CodexBarTests/CodexAccountScopedRefreshTestSupport.swift @@ -68,17 +68,37 @@ extension CodexAccountScopedRefreshTests { } func makeUsageStore(settings: SettingsStore, environmentBase: [String: String] = [:]) -> UsageStore { - UsageStore( - fetcher: UsageFetcher(environment: [:]), - browserDetection: BrowserDetection(cacheTTL: 0), + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("codexbar-tests", isDirectory: true) + .appendingPathComponent(UUID().uuidString, isDirectory: true) + var environment = [ + "HOME": root.path, + "CODEX_HOME": root.appendingPathComponent(".codex", isDirectory: true).path, + "XDG_CONFIG_HOME": root.appendingPathComponent(".config", isDirectory: true).path, + ] + if let reconciliationEnvironment = settings._test_codexReconciliationEnvironment { + environment.merge(reconciliationEnvironment) { _, override in override } + } + environment.merge(environmentBase) { _, override in override } + settings._test_codexReconciliationEnvironment = environment + return UsageStore( + fetcher: UsageFetcher(environment: environment), + browserDetection: BrowserDetection(homeDirectory: environment["HOME"] ?? root.path, cacheTTL: 0), settings: settings, startupBehavior: .testing, - environmentBase: environmentBase) + environmentBase: environment) } func liveAccount(email: String, identity: CodexIdentity = .unresolved) -> ObservedSystemCodexAccount { - ObservedSystemCodexAccount( + let workspaceAccountID: String? = switch identity { + case let .providerAccount(id): + id + case .emailOnly, .unresolved: + nil + } + return ObservedSystemCodexAccount( email: email, + workspaceAccountID: workspaceAccountID, codexHomePath: "/Users/test/.codex", observedAt: Date(), identity: identity) @@ -199,6 +219,16 @@ extension CodexAccountScopedRefreshTests { } } + func installContextualCodexProvider( + on store: UsageStore, + loader: @escaping @Sendable (ProviderFetchContext) async throws -> UsageSnapshot) + { + let baseSpec = store.providerSpecs[.codex]! + store.providerSpecs[.codex] = Self.makeCodexProviderSpec(baseSpec: baseSpec) { _ in + [ContextualTestCodexFetchStrategy(loader: loader, sourceLabel: "test-codex")] + } + } + static func makeCodexProviderSpec( baseSpec: ProviderSpec, loader: @escaping @Sendable () async throws -> UsageSnapshot) -> ProviderSpec @@ -327,6 +357,30 @@ struct TestCodexFetchStrategy: ProviderFetchStrategy { } } +struct ContextualTestCodexFetchStrategy: ProviderFetchStrategy { + let loader: @Sendable (ProviderFetchContext) async throws -> UsageSnapshot + let sourceLabel: String + + var id = "contextual-test-codex" + var kind: ProviderFetchKind = .cli + + func isAvailable(_: ProviderFetchContext) async -> Bool { + true + } + + func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult { + let snapshot = try await self.loader(context) + return self.makeResult( + usage: snapshot, + credits: nil, + sourceLabel: self.sourceLabel) + } + + func shouldFallback(on _: Error, context _: ProviderFetchContext) -> Bool { + false + } +} + struct ThrowingTestCodexFetchStrategy: ProviderFetchStrategy { let loader: @Sendable () async throws -> UsageSnapshot @@ -368,7 +422,9 @@ actor BlockingCodexFetchStrategy { } func waitUntilStarted() async { - if self.didStart { return } + if self.didStart { + return + } await withCheckedContinuation { continuation in self.startedWaiters.append(continuation) } @@ -380,6 +436,195 @@ actor BlockingCodexFetchStrategy { } } +struct SequencedCodexSnapshotLoadStep: Sendable { + let result: Result + let isGated: Bool + + static func success(_ snapshot: UsageSnapshot, gated: Bool = false) -> Self { + Self(result: .success(snapshot), isGated: gated) + } + + static func failure(_ message: String, gated: Bool = false) -> Self { + Self(result: .failure(TestRefreshError(message: message)), isGated: gated) + } +} + +actor SequencedCodexSnapshotLoader { + private let steps: [SequencedCodexSnapshotLoadStep] + private var completedCallCount = 0 + private var startedCallCount = 0 + private var releasedCalls: Set = [] + private var gateWaiters: [Int: CheckedContinuation] = [:] + + init(steps: [SequencedCodexSnapshotLoadStep]) { + self.steps = steps + } + + var callCount: Int { + self.startedCallCount + } + + func load() async throws -> UsageSnapshot { + let call = self.startedCallCount + 1 + self.startedCallCount = call + + guard self.steps.indices.contains(call - 1) else { + throw TestRefreshError(message: "Unexpected Codex fetch call \(call)") + } + let step = self.steps[call - 1] + if step.isGated, !self.releasedCalls.contains(call) { + await withCheckedContinuation { continuation in + self.gateWaiters[call] = continuation + } + } + self.completedCallCount += 1 + return try step.result.get() + } + + @discardableResult + func waitUntilCallCount(_ count: Int, timeout: Duration = .seconds(5)) async -> Bool { + let startedAt = ContinuousClock.now + while self.startedCallCount < count { + guard startedAt.duration(to: .now) < timeout else { return false } + try? await Task.sleep(for: .milliseconds(10)) + } + return true + } + + func release(call: Int) { + self.releasedCalls.insert(call) + self.gateWaiters.removeValue(forKey: call)?.resume() + } + + @discardableResult + func waitUntilCompletedCallCount(_ count: Int, timeout: Duration = .seconds(5)) async -> Bool { + let startedAt = ContinuousClock.now + while self.completedCallCount < count { + guard startedAt.duration(to: .now) < timeout else { return false } + try? await Task.sleep(for: .milliseconds(10)) + } + return true + } +} + +extension CodexAccountScopedRefreshTests { + func codexWeeklySnapshot( + email: String, + weeklyUsedPercent: Double?, + weeklyReset: Date?, + updatedAt: Date, + sessionUsedPercent: Double = 25) -> UsageSnapshot + { + UsageSnapshot( + primary: RateWindow( + usedPercent: sessionUsedPercent, + windowMinutes: 300, + resetsAt: updatedAt.addingTimeInterval(4 * 60 * 60), + resetDescription: nil), + secondary: weeklyUsedPercent.map { + RateWindow( + usedPercent: $0, + windowMinutes: 10080, + resetsAt: weeklyReset, + resetDescription: nil) + }, + updatedAt: updatedAt, + identity: ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: email, + accountOrganization: nil, + loginMethod: "Pro")) + } + + func makeCodexWeeklyPublicationStore( + settings: SettingsStore, + suite: String, + snapshotStore: (any CodexAccountUsageSnapshotStoring)? = nil) -> UsageStore + { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("codexbar-weekly-publication-tests", isDirectory: true) + .appendingPathComponent(UUID().uuidString, isDirectory: true) + let environment = [ + "HOME": root.path, + "CODEX_HOME": root.appendingPathComponent(".codex", isDirectory: true).path, + "XDG_CONFIG_HOME": root.appendingPathComponent(".config", isDirectory: true).path, + "CODEXBAR_SUPPRESS_TEST_KEYCHAIN_ACCESS": "1", + ] + settings._test_codexReconciliationEnvironment = environment + let store = UsageStore( + fetcher: UsageFetcher(environment: environment), + browserDetection: BrowserDetection(homeDirectory: root.path, cacheTTL: 0), + settings: settings, + planUtilizationHistoryStore: testPlanUtilizationHistoryStore(suiteName: suite), + codexAccountUsageSnapshotStore: snapshotStore, + startupBehavior: .testing, + environmentBase: environment) + store._cancelPlanUtilizationHistoryLoadForTesting() + store._test_codexResetCreditsFetcherOverride = { _ in nil } + return store + } + + func makeManagedCodexWeeklyPublicationAccount( + id: UUID, + email: String, + workspaceID: String, + workspaceLabel: String, + homeURL: URL) throws -> ManagedCodexAccount + { + try Self.writeCodexAuthFile( + homeURL: homeURL, + email: email, + plan: "Pro", + accountId: workspaceID) + let fingerprint = try #require(CodexAuthFingerprint.fingerprint(homePath: homeURL.path)) + return ManagedCodexAccount( + id: id, + email: email, + providerAccountID: workspaceID, + workspaceLabel: workspaceLabel, + workspaceAccountID: workspaceID, + authFingerprint: fingerprint, + managedHomePath: homeURL.path, + createdAt: 1, + updatedAt: 2, + lastAuthenticatedAt: 2) + } + + func seedCodexWeeklyPublicationState( + store: UsageStore, + settings: SettingsStore, + snapshot: UsageSnapshot, + error: String? = "prior error") async -> Int + { + store.snapshots[.codex] = snapshot + store.lastKnownResetSnapshots[.codex] = snapshot + store.lastSourceLabels[.codex] = "prior-source" + if let error { + store.errors[.codex] = error + } else { + store.errors.removeValue(forKey: .codex) + } + store.lastFetchAttempts[.codex] = [ProviderFetchAttempt( + strategyID: "prior-strategy", + kind: .cli, + wasAvailable: true, + errorDescription: "prior diagnostic")] + + let guardValue = store.currentCodexAccountScopedRefreshGuard(preferCurrentSnapshot: false) + store.lastCodexUsagePublicationGuard = guardValue + store.lastCodexAccountScopedRefreshGuard = guardValue + let ownerKey = store.codexLimitResetOwnerKey( + expectedGuard: guardValue, + visibleAccounts: settings.codexVisibleAccountProjection.visibleAccounts) + await store.recordPlanUtilizationHistorySample( + provider: .codex, + snapshot: snapshot, + codexLimitResetOwnerKey: ownerKey, + now: snapshot.updatedAt) + return store.planUtilizationHistoryRevision + } +} + actor BlockingOpenAIDashboardLoader { private var waiters: [CheckedContinuation, Never>] = [] private var startedWaiters: [CheckedContinuation] = [] @@ -396,7 +641,9 @@ actor BlockingOpenAIDashboardLoader { } func waitUntilStarted() async { - if self.didStart { return } + if self.didStart { + return + } await withCheckedContinuation { continuation in self.startedWaiters.append(continuation) } @@ -423,12 +670,25 @@ actor BlockingWidgetSnapshotSaver { } func waitUntilStarted(count: Int) async { - if self.snapshots.count >= count { return } + if self.snapshots.count >= count { + return + } await withCheckedContinuation { continuation in self.startedWaiters.append(continuation) } } + 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 +703,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 6eb3367996..b66985114f 100644 --- a/Tests/CodexBarTests/CodexAccountScopedRefreshTests.swift +++ b/Tests/CodexBarTests/CodexAccountScopedRefreshTests.swift @@ -141,6 +141,9 @@ struct CodexAccountScopedRefreshTests { settings._test_liveSystemCodexAccount = self.liveAccount(email: "beta@example.com") let freshSnapshot = self.codexSnapshot(email: "beta@example.com", usedPercent: 5) store._setSnapshotForTesting(freshSnapshot, provider: .codex) + let betaGuard = store.freshCodexAccountScopedRefreshGuard() + store.lastCodexUsagePublicationGuard = betaGuard + store.lastCodexAccountScopedRefreshGuard = betaGuard await blocker.resume(with: .failure(TestRefreshError(message: "stale failure"))) await refreshTask.value @@ -338,7 +341,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 +382,13 @@ struct CodexAccountScopedRefreshTests { self.codexSnapshot(email: "trusted@example.com", usedPercent: 12), provider: .codex) store.lastSourceLabels[.codex] = "codex-cli" - store._test_openAIDashboardLoaderOverride = { _, _, _ in + let trustedGuard = CodexAccountScopedRefreshGuard( + source: .liveSystem, + identity: .emailOnly(normalizedEmail: "trusted@example.com"), + accountKey: "trusted@example.com") + store.lastCodexUsagePublicationGuard = trustedGuard + store.lastCodexAccountScopedRefreshGuard = trustedGuard + store._test_openAIDashboardLoaderOverride = { _, _, _, _ in self.dashboard(email: "trusted@example.com", creditsRemaining: 33, usedPercent: 12) } defer { store._test_openAIDashboardLoaderOverride = nil } @@ -606,14 +615,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/CodexAccountVisibleHistoryBackfillTests.swift b/Tests/CodexBarTests/CodexAccountVisibleHistoryBackfillTests.swift new file mode 100644 index 0000000000..6ad0c1b7cd --- /dev/null +++ b/Tests/CodexBarTests/CodexAccountVisibleHistoryBackfillTests.swift @@ -0,0 +1,1560 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +@MainActor +extension CodexAccountScopedRefreshTests { + @Test + func `provider only history never backfills account quota publication`() async throws { + let settings = self.makeSettingsStore( + suite: "CodexAccountVisibleHistoryBackfillTests") + settings.refreshFrequency = .manual + settings.multiAccountMenuLayout = .stacked + + let targetID = try #require(UUID(uuidString: "AAAAAAAA-BBBB-CCCC-DDDD-222222222222")) + let siblingID = try #require(UUID(uuidString: "AAAAAAAA-BBBB-CCCC-DDDD-333333333333")) + let targetHome = FileManager.default.temporaryDirectory + .appendingPathComponent("codex-visible-target-\(UUID().uuidString)", isDirectory: true) + let siblingHome = FileManager.default.temporaryDirectory + .appendingPathComponent("codex-visible-sibling-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: targetHome, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: siblingHome, withIntermediateDirectories: true) + let targetAccount = ManagedCodexAccount( + id: targetID, + email: "target@example.com", + providerAccountID: "acct-target", + workspaceLabel: "Target Team", + workspaceAccountID: "acct-target", + managedHomePath: targetHome.path, + createdAt: 1, + updatedAt: 2, + lastAuthenticatedAt: 2) + let siblingAccount = ManagedCodexAccount( + id: siblingID, + email: "sibling@example.com", + providerAccountID: "acct-sibling", + workspaceLabel: "Sibling Team", + workspaceAccountID: "acct-sibling", + managedHomePath: siblingHome.path, + createdAt: 1, + updatedAt: 2, + lastAuthenticatedAt: 2) + let storeURL = try self.makeManagedAccountStoreURL(accounts: [targetAccount, siblingAccount]) + defer { + settings._test_managedCodexAccountStoreURL = nil + try? FileManager.default.removeItem(at: storeURL) + try? FileManager.default.removeItem(at: targetHome) + try? FileManager.default.removeItem(at: siblingHome) + } + settings._test_managedCodexAccountStoreURL = storeURL + settings.codexActiveSource = .managedAccount(id: targetID) + + let snapshotStore = RecordingCodexAccountUsageSnapshotStore(initialSnapshots: []) + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + codexAccountUsageSnapshotStore: snapshotStore, + startupBehavior: .testing) + let now = Date() + self.installContextualCodexProvider(on: store) { context in + let isTarget = context.env["CODEX_HOME"] == targetHome.path + return UsageSnapshot( + primary: RateWindow( + usedPercent: isTarget ? 1 : 22, + windowMinutes: 0, + resetsAt: nil, + resetDescription: nil), + secondary: nil, + updatedAt: now) + } + + let targetHistoryKey = try #require(CodexHistoryOwnership.canonicalKey(for: .providerAccount( + id: "acct-target"))) + let sessionReset = now.addingTimeInterval(4 * 60 * 60) + let weeklyReset = now.addingTimeInterval(4 * 24 * 60 * 60) + store.planUtilizationHistory[.codex] = PlanUtilizationHistoryBuckets(accounts: [ + targetHistoryKey: [ + planSeries(name: .session, windowMinutes: 300, entries: [ + planEntry(at: now.addingTimeInterval(-60), usedPercent: 1, resetsAt: sessionReset), + ]), + planSeries(name: .weekly, windowMinutes: 10080, entries: [ + planEntry(at: now.addingTimeInterval(-60), usedPercent: 13, resetsAt: weeklyReset), + ]), + ], + ]) + + await store.refreshCodexVisibleAccountsForMenu() + + let targetSnapshot = try #require(store.codexAccountSnapshots.first { + $0.account.workspaceAccountID == "acct-target" + }?.snapshot) + #expect(targetSnapshot.primary?.usedPercent == 1) + #expect(targetSnapshot.primary?.windowMinutes == 0) + #expect(targetSnapshot.primary?.resetsAt == nil) + #expect(targetSnapshot.secondary == nil) + + let siblingSnapshot = try #require(store.codexAccountSnapshots.first { + $0.account.workspaceAccountID == "acct-sibling" + }?.snapshot) + #expect(siblingSnapshot.primary?.windowMinutes == 0) + #expect(siblingSnapshot.primary?.resetsAt == nil) + #expect(siblingSnapshot.secondary == nil) + + let persistedTarget = try #require(snapshotStore.storedSnapshots.first { + $0.account.workspaceAccountID == "acct-target" + }?.snapshot) + #expect(persistedTarget.primary?.resetsAt == nil) + #expect(persistedTarget.secondary == nil) + #expect(store.snapshots[.codex]?.primary?.resetsAt == nil) + #expect(store.snapshots[.codex]?.secondary == nil) + #expect(store.planUtilizationHistory[.codex]?.accounts[targetHistoryKey]?.count == 2) + } + + @Test + func `materializes single visible codex account email history into provider account history`() throws { + let settings = self.makeSettingsStore( + suite: "CodexAccountVisibleHistoryBackfillTests-single-account-materialize") + let store = self.makeUsageStore(settings: settings) + let visibleAccount = CodexVisibleAccount( + id: "materialize@example.com", + email: "materialize@example.com", + workspaceLabel: "Target Team", + workspaceAccountID: "acct-materialize", + storedAccountID: nil, + selectionSource: .managedAccount(id: UUID()), + isActive: true, + isLive: false, + canReauthenticate: true, + canRemove: true) + let providerHistoryKey = try #require(CodexHistoryOwnership.canonicalKey(for: .providerAccount( + id: "acct-materialize"))) + let emailHistoryKey = CodexHistoryOwnership.canonicalEmailHashKey(for: "materialize@example.com") + let legacyEmailHistoryKey = UsageStore._codexLegacyPlanUtilizationEmailHashKeyForTesting( + normalizedEmail: "materialize@example.com") + let session = planSeries(name: .session, windowMinutes: 300, entries: [ + planEntry(at: Date(timeIntervalSince1970: 1_800_000_000), usedPercent: 1), + ]) + let weekly = planSeries(name: .weekly, windowMinutes: 10080, entries: [ + planEntry(at: Date(timeIntervalSince1970: 1_800_086_400), usedPercent: 13), + ]) + store.planUtilizationHistory[.codex] = PlanUtilizationHistoryBuckets(accounts: [ + emailHistoryKey: [session], + legacyEmailHistoryKey: [weekly], + ]) + + let histories = store.codexPlanUtilizationHistories(forVisibleAccount: visibleAccount) + + #expect(histories == [session, weekly]) + #expect(store.planUtilizationHistory[.codex]?.accounts[providerHistoryKey] == [session, weekly]) + #expect(store.planUtilizationHistory[.codex]?.accounts[emailHistoryKey] == nil) + #expect(store.planUtilizationHistory[.codex]?.accounts[legacyEmailHistoryKey] == nil) + } + + @Test + func `materializes provider account email history when sibling visible account uses another email`() throws { + let settings = self.makeSettingsStore( + suite: "CodexAccountVisibleHistoryBackfillTests-different-email-materialize") + settings.multiAccountMenuLayout = .stacked + let targetID = try #require(UUID(uuidString: "AAAAAAAA-BBBB-CCCC-DDDD-121212121212")) + let siblingID = try #require(UUID(uuidString: "AAAAAAAA-BBBB-CCCC-DDDD-343434343434")) + let targetAccount = ManagedCodexAccount( + id: targetID, + email: "materialize-stack@example.com", + providerAccountID: "acct-materialize-stack", + workspaceLabel: "Target Team", + workspaceAccountID: "acct-materialize-stack", + managedHomePath: "/tmp/materialize-stack-target", + createdAt: 1, + updatedAt: 2, + lastAuthenticatedAt: 2) + let siblingAccount = ManagedCodexAccount( + id: siblingID, + email: "other-stack@example.com", + providerAccountID: "acct-materialize-other", + workspaceLabel: "Other Team", + workspaceAccountID: "acct-materialize-other", + managedHomePath: "/tmp/materialize-stack-other", + createdAt: 1, + updatedAt: 2, + lastAuthenticatedAt: 2) + let storeURL = try self.makeManagedAccountStoreURL(accounts: [targetAccount, siblingAccount]) + defer { + settings._test_managedCodexAccountStoreURL = nil + try? FileManager.default.removeItem(at: storeURL) + } + settings._test_managedCodexAccountStoreURL = storeURL + settings.codexActiveSource = .managedAccount(id: targetID) + let store = self.makeUsageStore(settings: settings) + let visibleAccount = try #require(settings.codexVisibleAccountProjection.visibleAccounts.first { + $0.workspaceAccountID == "acct-materialize-stack" + }) + let providerHistoryKey = try #require(CodexHistoryOwnership.canonicalKey(for: .providerAccount( + id: "acct-materialize-stack"))) + let emailHistoryKey = CodexHistoryOwnership.canonicalEmailHashKey(for: "materialize-stack@example.com") + let legacyEmailHistoryKey = UsageStore._codexLegacyPlanUtilizationEmailHashKeyForTesting( + normalizedEmail: "materialize-stack@example.com") + let session = planSeries(name: .session, windowMinutes: 300, entries: [ + planEntry(at: Date(timeIntervalSince1970: 1_800_000_000), usedPercent: 1), + ]) + let weekly = planSeries(name: .weekly, windowMinutes: 10080, entries: [ + planEntry(at: Date(timeIntervalSince1970: 1_800_086_400), usedPercent: 13), + ]) + store.planUtilizationHistory[.codex] = PlanUtilizationHistoryBuckets(accounts: [ + emailHistoryKey: [session], + legacyEmailHistoryKey: [weekly], + ]) + + let histories = store.codexPlanUtilizationHistories(forVisibleAccount: visibleAccount) + + #expect(histories == [session, weekly]) + #expect(store.planUtilizationHistory[.codex]?.accounts[providerHistoryKey] == [session, weekly]) + #expect(store.planUtilizationHistory[.codex]?.accounts[emailHistoryKey] == nil) + #expect(store.planUtilizationHistory[.codex]?.accounts[legacyEmailHistoryKey] == nil) + } + + @Test + func `selected codex refresh keeps ambiguous same email history out of provider account`() async throws { + let settings = self.makeSettingsStore( + suite: "CodexAccountVisibleHistoryBackfillTests-selected-ambiguous-history") + settings.refreshFrequency = .manual + settings.multiAccountMenuLayout = .stacked + + let targetID = try #require(UUID(uuidString: "CCCCCCCC-DDDD-EEEE-FFFF-111111111111")) + let siblingID = try #require(UUID(uuidString: "CCCCCCCC-DDDD-EEEE-FFFF-222222222222")) + let targetHome = FileManager.default.temporaryDirectory + .appendingPathComponent("codex-selected-history-target-\(UUID().uuidString)", isDirectory: true) + let siblingHome = FileManager.default.temporaryDirectory + .appendingPathComponent("codex-selected-history-sibling-\(UUID().uuidString)", isDirectory: true) + try Self.writeCodexAuthFile( + homeURL: targetHome, + email: "selected-shared@example.com", + plan: "pro", + accountId: "acct-selected-target") + try Self.writeCodexAuthFile( + homeURL: siblingHome, + email: "selected-shared@example.com", + plan: "pro", + accountId: "acct-selected-sibling") + let targetAccount = ManagedCodexAccount( + id: targetID, + email: "selected-shared@example.com", + providerAccountID: "acct-selected-target", + workspaceLabel: "Target Team", + workspaceAccountID: "acct-selected-target", + managedHomePath: targetHome.path, + createdAt: 1, + updatedAt: 2, + lastAuthenticatedAt: 2) + let siblingAccount = ManagedCodexAccount( + id: siblingID, + email: "selected-shared@example.com", + providerAccountID: "acct-selected-sibling", + workspaceLabel: "Sibling Team", + workspaceAccountID: "acct-selected-sibling", + managedHomePath: siblingHome.path, + createdAt: 1, + updatedAt: 2, + lastAuthenticatedAt: 2) + let storeURL = try self.makeManagedAccountStoreURL(accounts: [targetAccount, siblingAccount]) + defer { + settings._test_managedCodexAccountStoreURL = nil + try? FileManager.default.removeItem(at: storeURL) + try? FileManager.default.removeItem(at: targetHome) + try? FileManager.default.removeItem(at: siblingHome) + } + settings._test_managedCodexAccountStoreURL = storeURL + settings.codexActiveSource = .managedAccount(id: targetID) + + let store = self.makeUsageStore(settings: settings) + let now = Date(timeIntervalSince1970: 1_800_000_000) + let providerHistoryKey = try #require(CodexHistoryOwnership.canonicalKey(for: .providerAccount( + id: "acct-selected-target"))) + let emailHistoryKey = CodexHistoryOwnership.canonicalEmailHashKey(for: "selected-shared@example.com") + let legacyEmailHistoryKey = UsageStore._codexLegacyPlanUtilizationEmailHashKeyForTesting( + normalizedEmail: "selected-shared@example.com") + let staleSession = planSeries(name: .session, windowMinutes: 300, entries: [ + planEntry(at: now.addingTimeInterval(-2 * 60 * 60), usedPercent: 12), + ]) + let staleWeekly = planSeries(name: .weekly, windowMinutes: 10080, entries: [ + planEntry(at: now.addingTimeInterval(-2 * 60 * 60), usedPercent: 24), + ]) + store.planUtilizationHistory[.codex] = PlanUtilizationHistoryBuckets(accounts: [ + emailHistoryKey: [staleSession], + legacyEmailHistoryKey: [staleWeekly], + ]) + let currentSnapshot = UsageSnapshot( + primary: RateWindow( + usedPercent: 4, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(3 * 60 * 60), + resetDescription: nil), + secondary: RateWindow( + usedPercent: 6, + windowMinutes: 10080, + resetsAt: now.addingTimeInterval(3 * 24 * 60 * 60), + resetDescription: nil), + updatedAt: now, + identity: ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: "selected-shared@example.com", + accountOrganization: nil, + loginMethod: "Target Team")) + + await store.recordPlanUtilizationHistorySample( + provider: .codex, + snapshot: currentSnapshot, + now: now) + + let buckets = try #require(store.planUtilizationHistory[.codex]) + let providerHistory = try #require(buckets.accounts[providerHistoryKey]) + #expect(providerHistory.flatMap(\.entries).allSatisfy { $0.capturedAt == now }) + #expect(buckets.accounts[emailHistoryKey] == [staleSession]) + #expect(buckets.accounts[legacyEmailHistoryKey] == [staleWeekly]) + } + + @Test + func `ignores active reset cache from another visible codex workspace`() async throws { + let settings = self.makeSettingsStore( + suite: "CodexAccountVisibleHistoryBackfillTests-stale-active-cache") + settings.refreshFrequency = .manual + settings.multiAccountMenuLayout = .stacked + + let targetID = try #require(UUID(uuidString: "AAAAAAAA-BBBB-CCCC-DDDD-444444444444")) + let siblingID = try #require(UUID(uuidString: "AAAAAAAA-BBBB-CCCC-DDDD-555555555555")) + let targetHome = FileManager.default.temporaryDirectory + .appendingPathComponent("codex-visible-cache-target-\(UUID().uuidString)", isDirectory: true) + let siblingHome = FileManager.default.temporaryDirectory + .appendingPathComponent("codex-visible-cache-sibling-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: targetHome, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: siblingHome, withIntermediateDirectories: true) + let targetAccount = ManagedCodexAccount( + id: targetID, + email: "shared@example.com", + providerAccountID: "acct-cache-target", + workspaceLabel: "Target Team", + workspaceAccountID: "acct-cache-target", + managedHomePath: targetHome.path, + createdAt: 1, + updatedAt: 2, + lastAuthenticatedAt: 2) + let siblingAccount = ManagedCodexAccount( + id: siblingID, + email: "shared@example.com", + providerAccountID: "acct-cache-sibling", + workspaceLabel: "Sibling Team", + workspaceAccountID: "acct-cache-sibling", + managedHomePath: siblingHome.path, + createdAt: 1, + updatedAt: 2, + lastAuthenticatedAt: 2) + let storeURL = try self.makeManagedAccountStoreURL(accounts: [targetAccount, siblingAccount]) + defer { + settings._test_managedCodexAccountStoreURL = nil + try? FileManager.default.removeItem(at: storeURL) + try? FileManager.default.removeItem(at: targetHome) + try? FileManager.default.removeItem(at: siblingHome) + } + settings._test_managedCodexAccountStoreURL = storeURL + settings.codexActiveSource = .managedAccount(id: targetID) + + let snapshotStore = RecordingCodexAccountUsageSnapshotStore(initialSnapshots: []) + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + codexAccountUsageSnapshotStore: snapshotStore, + startupBehavior: .testing) + let now = Date() + let staleReset = now.addingTimeInterval(2 * 60 * 60) + store.lastCodexAccountScopedRefreshGuard = CodexAccountScopedRefreshGuard( + source: .managedAccount(id: siblingID), + identity: .providerAccount(id: "acct-cache-sibling"), + accountKey: "shared@example.com") + store.lastKnownResetSnapshots[.codex] = UsageSnapshot( + primary: RateWindow( + usedPercent: 44, + windowMinutes: 300, + resetsAt: staleReset, + resetDescription: nil), + secondary: RateWindow( + usedPercent: 55, + windowMinutes: 10080, + resetsAt: now.addingTimeInterval(2 * 24 * 60 * 60), + resetDescription: nil), + updatedAt: now, + identity: ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: "shared@example.com", + accountOrganization: nil, + loginMethod: "Sibling Team")) + self.installContextualCodexProvider(on: store) { context in + let isTarget = context.env["CODEX_HOME"] == targetHome.path + return UsageSnapshot( + primary: RateWindow( + usedPercent: isTarget ? 4 : 9, + windowMinutes: 0, + resetsAt: nil, + resetDescription: nil), + secondary: nil, + updatedAt: now.addingTimeInterval(1)) + } + + await store.refreshCodexVisibleAccountsForMenu() + + let targetSnapshot = try #require(store.codexAccountSnapshots.first { + $0.account.workspaceAccountID == "acct-cache-target" + }?.snapshot) + #expect(targetSnapshot.primary?.usedPercent == 4) + #expect(targetSnapshot.primary?.windowMinutes == 0) + #expect(targetSnapshot.primary?.resetsAt == nil) + #expect(targetSnapshot.secondary == nil) + #expect(store.snapshots[.codex]?.primary?.resetsAt == nil) + #expect(store.snapshots[.codex]?.secondary == nil) + #expect(store.lastKnownResetSnapshots[.codex]?.primary?.resetsAt == nil) + #expect(snapshotStore.storedSnapshots.first { + $0.account.workspaceAccountID == "acct-cache-target" + }?.snapshot?.primary?.resetsAt == nil) + } + + @Test + func `uses active reset cache when scoped guard matches codex workspace with plan label`() async throws { + let settings = self.makeSettingsStore( + suite: "CodexAccountVisibleHistoryBackfillTests-current-active-cache") + settings.refreshFrequency = .manual + settings.multiAccountMenuLayout = .stacked + + let targetID = try #require(UUID(uuidString: "AAAAAAAA-BBBB-CCCC-DDDD-666666666666")) + let siblingID = try #require(UUID(uuidString: "AAAAAAAA-BBBB-CCCC-DDDD-777777777777")) + let targetHome = FileManager.default.temporaryDirectory + .appendingPathComponent("codex-visible-current-target-\(UUID().uuidString)", isDirectory: true) + let siblingHome = FileManager.default.temporaryDirectory + .appendingPathComponent("codex-visible-current-sibling-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: targetHome, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: siblingHome, withIntermediateDirectories: true) + let targetAccount = ManagedCodexAccount( + id: targetID, + email: "current@example.com", + providerAccountID: "acct-current-target", + workspaceLabel: "Target Team", + workspaceAccountID: "acct-current-target", + managedHomePath: targetHome.path, + createdAt: 1, + updatedAt: 2, + lastAuthenticatedAt: 2) + let siblingAccount = ManagedCodexAccount( + id: siblingID, + email: "current@example.com", + providerAccountID: "acct-current-sibling", + workspaceLabel: "Sibling Team", + workspaceAccountID: "acct-current-sibling", + managedHomePath: siblingHome.path, + createdAt: 1, + updatedAt: 2, + lastAuthenticatedAt: 2) + let storeURL = try self.makeManagedAccountStoreURL(accounts: [targetAccount, siblingAccount]) + defer { + settings._test_managedCodexAccountStoreURL = nil + try? FileManager.default.removeItem(at: storeURL) + try? FileManager.default.removeItem(at: targetHome) + try? FileManager.default.removeItem(at: siblingHome) + } + settings._test_managedCodexAccountStoreURL = storeURL + settings.codexActiveSource = .managedAccount(id: targetID) + + let now = Date() + let staleSessionReset = now.addingTimeInterval(3 * 60 * 60) + let staleWeeklyReset = now.addingTimeInterval(3 * 24 * 60 * 60) + let priorSnapshots = settings.codexVisibleAccountProjection.visibleAccounts.map { account in + CodexAccountUsageSnapshot( + account: account, + snapshot: account.workspaceAccountID == "acct-current-target" + ? UsageSnapshot( + primary: RateWindow( + usedPercent: 2, + windowMinutes: 300, + resetsAt: staleSessionReset, + resetDescription: nil), + secondary: RateWindow( + usedPercent: 3, + windowMinutes: 10080, + resetsAt: staleWeeklyReset, + resetDescription: nil), + updatedAt: now.addingTimeInterval(-60)) + : nil, + error: nil, + sourceLabel: "cached") + } + let snapshotStore = RecordingCodexAccountUsageSnapshotStore(initialSnapshots: priorSnapshots) + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + codexAccountUsageSnapshotStore: snapshotStore, + startupBehavior: .testing) + let sessionReset = now.addingTimeInterval(2 * 60 * 60) + let weeklyReset = now.addingTimeInterval(2 * 24 * 60 * 60) + let publicationGuard = CodexAccountScopedRefreshGuard( + source: .managedAccount(id: targetID), + identity: .providerAccount(id: "acct-current-target"), + accountKey: "current@example.com") + store.lastCodexUsagePublicationGuard = publicationGuard + store.lastCodexAccountScopedRefreshGuard = publicationGuard + store.lastKnownResetSnapshots[.codex] = UsageSnapshot( + primary: RateWindow( + usedPercent: 44, + windowMinutes: 300, + resetsAt: sessionReset, + resetDescription: nil), + secondary: RateWindow( + usedPercent: 55, + windowMinutes: 10080, + resetsAt: weeklyReset, + resetDescription: nil), + updatedAt: now, + identity: ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: "current@example.com", + accountOrganization: nil, + loginMethod: "Pro")) + self.installContextualCodexProvider(on: store) { context in + let isTarget = context.env["CODEX_HOME"] == targetHome.path + return UsageSnapshot( + primary: RateWindow( + usedPercent: isTarget ? 4 : 9, + windowMinutes: 0, + resetsAt: nil, + resetDescription: nil), + secondary: nil, + updatedAt: now) + } + + await store.refreshCodexVisibleAccountsForMenu() + + let targetSnapshot = try #require(store.codexAccountSnapshots.first { + $0.account.workspaceAccountID == "acct-current-target" + }?.snapshot) + #expect(targetSnapshot.primary?.usedPercent == 4) + #expect(targetSnapshot.primary?.windowMinutes == 300) + #expect(targetSnapshot.primary?.resetsAt == sessionReset) + #expect(targetSnapshot.secondary?.usedPercent == 55) + #expect(targetSnapshot.secondary?.windowMinutes == 10080) + #expect(targetSnapshot.secondary?.resetsAt == weeklyReset) + #expect(store.snapshots[.codex]?.primary?.resetsAt == sessionReset) + #expect(store.snapshots[.codex]?.secondary?.resetsAt == weeklyReset) + #expect(snapshotStore.storedSnapshots.first { + $0.account.workspaceAccountID == "acct-current-target" + }?.snapshot?.secondary?.resetsAt == weeklyReset) + } + + @Test + func `ignores prior snapshot from same email different codex workspace`() async throws { + let settings = self.makeSettingsStore( + suite: "CodexAccountVisibleHistoryBackfillTests-prior-workspace") + settings.refreshFrequency = .manual + settings.multiAccountMenuLayout = .stacked + + let targetID = try #require(UUID(uuidString: "AAAAAAAA-BBBB-CCCC-DDDD-888888888888")) + let oldID = try #require(UUID(uuidString: "AAAAAAAA-BBBB-CCCC-DDDD-999999999999")) + let siblingID = try #require(UUID(uuidString: "AAAAAAAA-BBBB-CCCC-DDDD-AAAAAAAAAAAA")) + let targetHome = FileManager.default.temporaryDirectory + .appendingPathComponent("codex-visible-prior-target-\(UUID().uuidString)", isDirectory: true) + let siblingHome = FileManager.default.temporaryDirectory + .appendingPathComponent("codex-visible-prior-sibling-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: targetHome, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: siblingHome, withIntermediateDirectories: true) + let targetAccount = ManagedCodexAccount( + id: targetID, + email: "prior@example.com", + providerAccountID: "acct-prior-new", + workspaceLabel: "New Team", + workspaceAccountID: "acct-prior-new", + managedHomePath: targetHome.path, + createdAt: 1, + updatedAt: 2, + lastAuthenticatedAt: 2) + let siblingAccount = ManagedCodexAccount( + id: siblingID, + email: "other-prior@example.com", + providerAccountID: "acct-prior-sibling", + workspaceLabel: "Sibling Team", + workspaceAccountID: "acct-prior-sibling", + managedHomePath: siblingHome.path, + createdAt: 1, + updatedAt: 2, + lastAuthenticatedAt: 2) + let oldVisibleAccount = CodexVisibleAccount( + id: "prior@example.com", + email: "prior@example.com", + workspaceLabel: "Old Team", + workspaceAccountID: "acct-prior-old", + storedAccountID: oldID, + selectionSource: .managedAccount(id: oldID), + isActive: false, + isLive: false, + canReauthenticate: false, + canRemove: false) + let storeURL = try self.makeManagedAccountStoreURL(accounts: [targetAccount, siblingAccount]) + defer { + settings._test_managedCodexAccountStoreURL = nil + try? FileManager.default.removeItem(at: storeURL) + try? FileManager.default.removeItem(at: targetHome) + try? FileManager.default.removeItem(at: siblingHome) + } + settings._test_managedCodexAccountStoreURL = storeURL + settings.codexActiveSource = .managedAccount(id: targetID) + + let now = Date() + let staleReset = now.addingTimeInterval(2 * 60 * 60) + let snapshotStore = RecordingCodexAccountUsageSnapshotStore(initialSnapshots: [ + CodexAccountUsageSnapshot( + account: oldVisibleAccount, + snapshot: UsageSnapshot( + primary: RateWindow( + usedPercent: 72, + windowMinutes: 300, + resetsAt: staleReset, + resetDescription: nil), + secondary: nil, + updatedAt: now, + identity: ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: "prior@example.com", + accountOrganization: nil, + loginMethod: "Old Team")), + error: nil, + sourceLabel: "cached"), + ]) + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + codexAccountUsageSnapshotStore: snapshotStore, + startupBehavior: .testing) + self.installContextualCodexProvider(on: store) { context in + let isTarget = context.env["CODEX_HOME"] == targetHome.path + return UsageSnapshot( + primary: RateWindow( + usedPercent: isTarget ? 4 : 9, + windowMinutes: 0, + resetsAt: nil, + resetDescription: nil), + secondary: nil, + updatedAt: now) + } + + await store.refreshCodexVisibleAccountsForMenu() + + let targetSnapshot = try #require(store.codexAccountSnapshots.first { + $0.account.workspaceAccountID == "acct-prior-new" + }?.snapshot) + #expect(targetSnapshot.primary?.usedPercent == 4) + #expect(targetSnapshot.primary?.windowMinutes == 0) + #expect(targetSnapshot.primary?.resetsAt == nil) + #expect(targetSnapshot.secondary == nil) + } + + @Test + func `ignores ambiguous email history for same email codex workspaces`() async throws { + let settings = self.makeSettingsStore( + suite: "CodexAccountVisibleHistoryBackfillTests-ambiguous-email-history") + settings.refreshFrequency = .manual + settings.multiAccountMenuLayout = .stacked + + let targetID = try #require(UUID(uuidString: "BBBBBBBB-CCCC-DDDD-EEEE-111111111111")) + let siblingID = try #require(UUID(uuidString: "BBBBBBBB-CCCC-DDDD-EEEE-222222222222")) + let targetHome = FileManager.default.temporaryDirectory + .appendingPathComponent("codex-visible-history-target-\(UUID().uuidString)", isDirectory: true) + let siblingHome = FileManager.default.temporaryDirectory + .appendingPathComponent("codex-visible-history-sibling-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: targetHome, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: siblingHome, withIntermediateDirectories: true) + let targetAccount = ManagedCodexAccount( + id: targetID, + email: "history-shared@example.com", + providerAccountID: "acct-history-target", + workspaceLabel: "Target Team", + workspaceAccountID: "acct-history-target", + managedHomePath: targetHome.path, + createdAt: 1, + updatedAt: 2, + lastAuthenticatedAt: 2) + let siblingAccount = ManagedCodexAccount( + id: siblingID, + email: "history-shared@example.com", + providerAccountID: "acct-history-sibling", + workspaceLabel: "Sibling Team", + workspaceAccountID: "acct-history-sibling", + managedHomePath: siblingHome.path, + createdAt: 1, + updatedAt: 2, + lastAuthenticatedAt: 2) + let storeURL = try self.makeManagedAccountStoreURL(accounts: [targetAccount, siblingAccount]) + defer { + settings._test_managedCodexAccountStoreURL = nil + try? FileManager.default.removeItem(at: storeURL) + try? FileManager.default.removeItem(at: targetHome) + try? FileManager.default.removeItem(at: siblingHome) + } + settings._test_managedCodexAccountStoreURL = storeURL + settings.codexActiveSource = .managedAccount(id: targetID) + + let snapshotStore = RecordingCodexAccountUsageSnapshotStore(initialSnapshots: []) + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + codexAccountUsageSnapshotStore: snapshotStore, + startupBehavior: .testing) + let now = Date() + let normalizedEmail = try #require(CodexIdentityResolver.normalizeEmail("history-shared@example.com")) + let emailHistoryKey = CodexHistoryOwnership.canonicalEmailHashKey(for: normalizedEmail) + store.planUtilizationHistory[.codex] = PlanUtilizationHistoryBuckets(accounts: [ + emailHistoryKey: [ + planSeries(name: .session, windowMinutes: 300, entries: [ + planEntry(at: now.addingTimeInterval(-60), usedPercent: 2, resetsAt: now.addingTimeInterval(3600)), + ]), + planSeries(name: .weekly, windowMinutes: 10080, entries: [ + planEntry( + at: now.addingTimeInterval(-60), + usedPercent: 33, + resetsAt: now.addingTimeInterval(4 * 24 * 60 * 60)), + ]), + ], + ]) + self.installContextualCodexProvider(on: store) { context in + let isTarget = context.env["CODEX_HOME"] == targetHome.path + return UsageSnapshot( + primary: RateWindow( + usedPercent: isTarget ? 4 : 9, + windowMinutes: 0, + resetsAt: nil, + resetDescription: nil), + secondary: nil, + updatedAt: now) + } + + await store.refreshCodexVisibleAccountsForMenu() + + let targetSnapshot = try #require(store.codexAccountSnapshots.first { + $0.account.workspaceAccountID == "acct-history-target" + }?.snapshot) + #expect(targetSnapshot.primary?.usedPercent == 4) + #expect(targetSnapshot.primary?.windowMinutes == 0) + #expect(targetSnapshot.primary?.resetsAt == nil) + #expect(targetSnapshot.secondary == nil) + #expect(store.planUtilizationHistory[.codex]?.histories(for: emailHistoryKey).isEmpty == false) + } + + @Test + func `email only live codex row does not inherit prior quota windows`() async throws { + let settings = self.makeSettingsStore( + suite: "CodexAccountVisibleHistoryBackfillTests-live-prior") + settings.refreshFrequency = .manual + settings.multiAccountMenuLayout = .stacked + + let managedID = try #require(UUID(uuidString: "DDDDDDDD-EEEE-FFFF-AAAA-111111111111")) + let liveHome = FileManager.default.temporaryDirectory + .appendingPathComponent("codex-visible-live-prior-\(UUID().uuidString)", isDirectory: true) + let managedHome = FileManager.default.temporaryDirectory + .appendingPathComponent("codex-visible-live-prior-managed-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: liveHome, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: managedHome, withIntermediateDirectories: true) + settings._test_liveSystemCodexAccount = ObservedSystemCodexAccount( + email: "live-prior@example.com", + codexHomePath: liveHome.path, + observedAt: Date(), + identity: .emailOnly(normalizedEmail: "live-prior@example.com")) + let managedAccount = ManagedCodexAccount( + id: managedID, + email: "managed-prior@example.com", + providerAccountID: "acct-managed-prior", + workspaceLabel: "Managed Team", + workspaceAccountID: "acct-managed-prior", + managedHomePath: managedHome.path, + createdAt: 1, + updatedAt: 2, + lastAuthenticatedAt: 2) + let storeURL = try self.makeManagedAccountStoreURL(accounts: [managedAccount]) + defer { + settings._test_managedCodexAccountStoreURL = nil + settings._test_liveSystemCodexAccount = nil + try? FileManager.default.removeItem(at: storeURL) + try? FileManager.default.removeItem(at: liveHome) + try? FileManager.default.removeItem(at: managedHome) + } + settings._test_managedCodexAccountStoreURL = storeURL + settings.codexActiveSource = .managedAccount(id: managedID) + + let now = Date() + let priorReset = now.addingTimeInterval(2 * 60 * 60) + let priorSnapshots = settings.codexVisibleAccountProjection.visibleAccounts.map { account in + CodexAccountUsageSnapshot( + account: account, + snapshot: account.selectionSource == .liveSystem + ? UsageSnapshot( + primary: RateWindow( + usedPercent: 18, + windowMinutes: 300, + resetsAt: priorReset, + resetDescription: nil), + secondary: nil, + updatedAt: now.addingTimeInterval(-60)) + : nil, + error: nil, + sourceLabel: "cached") + } + let snapshotStore = RecordingCodexAccountUsageSnapshotStore(initialSnapshots: priorSnapshots) + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + codexAccountUsageSnapshotStore: snapshotStore, + startupBehavior: .testing) + self.installContextualCodexProvider(on: store) { _ in + UsageSnapshot( + primary: RateWindow( + usedPercent: 9, + windowMinutes: 0, + resetsAt: nil, + resetDescription: nil), + secondary: nil, + updatedAt: now) + } + + await store.refreshCodexVisibleAccountsForMenu() + + let liveSnapshot = try #require(store.codexAccountSnapshots.first { + $0.account.selectionSource == .liveSystem + }?.snapshot) + #expect(liveSnapshot.primary?.usedPercent == 9) + #expect(liveSnapshot.primary?.windowMinutes == 0) + #expect(liveSnapshot.primary?.resetsAt == nil) + } + + @Test + func `ignores live codex prior snapshot after auth fingerprint changes`() async throws { + let settings = self.makeSettingsStore( + suite: "CodexAccountVisibleHistoryBackfillTests-live-prior-auth-change") + settings.refreshFrequency = .manual + settings.multiAccountMenuLayout = .stacked + + let managedID = try #require(UUID(uuidString: "DDDDDDDD-EEEE-FFFF-AAAA-222222222222")) + let liveHome = FileManager.default.temporaryDirectory + .appendingPathComponent("codex-visible-live-prior-auth-\(UUID().uuidString)", isDirectory: true) + let managedHome = FileManager.default.temporaryDirectory + .appendingPathComponent("codex-visible-live-prior-auth-managed-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: liveHome, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: managedHome, withIntermediateDirectories: true) + settings._test_liveSystemCodexAccount = ObservedSystemCodexAccount( + email: "live-prior-auth@example.com", + authFingerprint: "current-live-auth-fingerprint", + codexHomePath: liveHome.path, + observedAt: Date(), + identity: .emailOnly(normalizedEmail: "live-prior-auth@example.com")) + let managedAccount = ManagedCodexAccount( + id: managedID, + email: "managed-prior-auth@example.com", + providerAccountID: "acct-managed-prior-auth", + workspaceLabel: "Managed Team", + workspaceAccountID: "acct-managed-prior-auth", + managedHomePath: managedHome.path, + createdAt: 1, + updatedAt: 2, + lastAuthenticatedAt: 2) + let storeURL = try self.makeManagedAccountStoreURL(accounts: [managedAccount]) + defer { + settings._test_managedCodexAccountStoreURL = nil + settings._test_liveSystemCodexAccount = nil + try? FileManager.default.removeItem(at: storeURL) + try? FileManager.default.removeItem(at: liveHome) + try? FileManager.default.removeItem(at: managedHome) + } + settings._test_managedCodexAccountStoreURL = storeURL + settings.codexActiveSource = .managedAccount(id: managedID) + + let now = Date() + let priorReset = now.addingTimeInterval(2 * 60 * 60) + let liveAccount = try #require(settings.codexVisibleAccountProjection.visibleAccounts.first { + $0.selectionSource == .liveSystem + }) + let priorLiveAccount = CodexVisibleAccount( + id: liveAccount.id, + email: liveAccount.email, + workspaceLabel: liveAccount.workspaceLabel, + workspaceAccountID: liveAccount.workspaceAccountID, + authFingerprint: "stale-live-auth-fingerprint", + storedAccountID: liveAccount.storedAccountID, + selectionSource: liveAccount.selectionSource, + isActive: liveAccount.isActive, + isLive: liveAccount.isLive, + canReauthenticate: liveAccount.canReauthenticate, + canRemove: liveAccount.canRemove) + let snapshotStore = RecordingCodexAccountUsageSnapshotStore(initialSnapshots: [ + CodexAccountUsageSnapshot( + account: priorLiveAccount, + snapshot: UsageSnapshot( + primary: RateWindow( + usedPercent: 18, + windowMinutes: 300, + resetsAt: priorReset, + resetDescription: nil), + secondary: nil, + updatedAt: now.addingTimeInterval(-60)), + error: nil, + sourceLabel: "cached"), + ]) + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + codexAccountUsageSnapshotStore: snapshotStore, + startupBehavior: .testing) + self.installContextualCodexProvider(on: store) { _ in + UsageSnapshot( + primary: RateWindow( + usedPercent: 9, + windowMinutes: 0, + resetsAt: nil, + resetDescription: nil), + secondary: nil, + updatedAt: now) + } + + await store.refreshCodexVisibleAccountsForMenu() + + let liveSnapshot = try #require(store.codexAccountSnapshots.first { + $0.account.selectionSource == .liveSystem + }?.snapshot) + #expect(liveSnapshot.primary?.usedPercent == 9) + #expect(liveSnapshot.primary?.windowMinutes == 0) + #expect(liveSnapshot.primary?.resetsAt == nil) + } + + @Test + func `ignores active reset cache and email history after live auth fingerprint changes`() async throws { + let settings = self.makeSettingsStore( + suite: "CodexAccountVisibleHistoryBackfillTests-live-active-auth-change") + settings.refreshFrequency = .manual + settings.multiAccountMenuLayout = .stacked + + let managedID = try #require(UUID(uuidString: "DDDDDDDD-EEEE-FFFF-AAAA-333333333333")) + let liveHome = FileManager.default.temporaryDirectory + .appendingPathComponent("codex-visible-live-active-auth-\(UUID().uuidString)", isDirectory: true) + let managedHome = FileManager.default.temporaryDirectory + .appendingPathComponent("codex-visible-live-active-auth-managed-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: liveHome, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: managedHome, withIntermediateDirectories: true) + settings._test_liveSystemCodexAccount = ObservedSystemCodexAccount( + email: "live-active-auth@example.com", + authFingerprint: "current-live-active-auth", + codexHomePath: liveHome.path, + observedAt: Date(), + identity: .emailOnly(normalizedEmail: "live-active-auth@example.com")) + let managedAccount = ManagedCodexAccount( + id: managedID, + email: "managed-active-auth@example.com", + providerAccountID: "acct-managed-active-auth", + workspaceLabel: "Managed Team", + workspaceAccountID: "acct-managed-active-auth", + managedHomePath: managedHome.path, + createdAt: 1, + updatedAt: 2, + lastAuthenticatedAt: 2) + let storeURL = try self.makeManagedAccountStoreURL(accounts: [managedAccount]) + defer { + settings._test_managedCodexAccountStoreURL = nil + settings._test_liveSystemCodexAccount = nil + try? FileManager.default.removeItem(at: storeURL) + try? FileManager.default.removeItem(at: liveHome) + try? FileManager.default.removeItem(at: managedHome) + } + settings._test_managedCodexAccountStoreURL = storeURL + settings.codexActiveSource = .liveSystem + + let snapshotStore = RecordingCodexAccountUsageSnapshotStore(initialSnapshots: []) + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + codexAccountUsageSnapshotStore: snapshotStore, + startupBehavior: .testing) + let now = Date() + let staleSessionReset = now.addingTimeInterval(2 * 60 * 60) + let staleWeeklyReset = now.addingTimeInterval(2 * 24 * 60 * 60) + store.lastCodexAccountScopedRefreshGuard = CodexAccountScopedRefreshGuard( + source: .liveSystem, + identity: .emailOnly(normalizedEmail: "live-active-auth@example.com"), + accountKey: "live-active-auth@example.com", + authFingerprint: "stale-live-active-auth") + store.lastKnownResetSnapshots[.codex] = UsageSnapshot( + primary: RateWindow( + usedPercent: 44, + windowMinutes: 300, + resetsAt: staleSessionReset, + resetDescription: nil), + secondary: nil, + updatedAt: now.addingTimeInterval(-60), + identity: ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: "live-active-auth@example.com", + accountOrganization: nil, + loginMethod: nil)) + let emailHistoryKey = CodexHistoryOwnership.canonicalEmailHashKey(for: "live-active-auth@example.com") + store.planUtilizationHistory[.codex] = PlanUtilizationHistoryBuckets(accounts: [ + emailHistoryKey: [ + planSeries(name: .session, windowMinutes: 300, entries: [ + planEntry(at: now.addingTimeInterval(-60), usedPercent: 44, resetsAt: staleSessionReset), + ]), + planSeries(name: .weekly, windowMinutes: 10080, entries: [ + planEntry(at: now.addingTimeInterval(-60), usedPercent: 55, resetsAt: staleWeeklyReset), + ]), + ], + ]) + self.installContextualCodexProvider(on: store) { _ in + UsageSnapshot( + primary: RateWindow( + usedPercent: 9, + windowMinutes: 0, + resetsAt: nil, + resetDescription: nil), + secondary: nil, + updatedAt: now) + } + + await store.refreshCodexVisibleAccountsForMenu() + + let liveSnapshot = try #require(store.codexAccountSnapshots.first { + $0.account.selectionSource == .liveSystem + }?.snapshot) + #expect(liveSnapshot.primary?.usedPercent == 9) + #expect(liveSnapshot.primary?.windowMinutes == 0) + #expect(liveSnapshot.primary?.resetsAt == nil) + #expect(liveSnapshot.secondary == nil) + } + + @Test + func `stacked visible refresh skips selected apply after live auth fingerprint changes`() async throws { + SettingsStore.codexAccountReconciliationSnapshotCacheIntervalOverrideForTesting = 60 + let settings = self.makeSettingsStore( + suite: "CodexAccountVisibleHistoryBackfillTests-selected-auth-change") + settings.refreshFrequency = .manual + settings.multiAccountMenuLayout = .stacked + + let managedID = try #require(UUID(uuidString: "DDDDDDDD-EEEE-FFFF-AAAA-444444444444")) + let liveHome = FileManager.default.temporaryDirectory + .appendingPathComponent("codex-visible-selected-auth-\(UUID().uuidString)", isDirectory: true) + let managedHome = FileManager.default.temporaryDirectory + .appendingPathComponent("codex-visible-selected-auth-managed-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: liveHome, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: managedHome, withIntermediateDirectories: true) + settings._test_liveSystemCodexAccount = ObservedSystemCodexAccount( + email: "selected-auth@example.com", + authFingerprint: "old-live-selected-auth", + codexHomePath: liveHome.path, + observedAt: Date(), + identity: .emailOnly(normalizedEmail: "selected-auth@example.com")) + let managedAccount = ManagedCodexAccount( + id: managedID, + email: "managed-selected-auth@example.com", + providerAccountID: "acct-managed-selected-auth", + workspaceLabel: "Managed Team", + workspaceAccountID: "acct-managed-selected-auth", + managedHomePath: managedHome.path, + createdAt: 1, + updatedAt: 2, + lastAuthenticatedAt: 2) + let storeURL = try self.makeManagedAccountStoreURL(accounts: [managedAccount]) + defer { + SettingsStore.codexAccountReconciliationSnapshotCacheIntervalOverrideForTesting = nil + settings._test_managedCodexAccountStoreURL = nil + settings._test_liveSystemCodexAccount = nil + try? FileManager.default.removeItem(at: storeURL) + try? FileManager.default.removeItem(at: liveHome) + try? FileManager.default.removeItem(at: managedHome) + } + settings._test_managedCodexAccountStoreURL = storeURL + settings.codexActiveSource = .liveSystem + let staleReconciliationSnapshot = settings.codexAccountReconciliationSnapshot + + let snapshotStore = RecordingCodexAccountUsageSnapshotStore(initialSnapshots: []) + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + codexAccountUsageSnapshotStore: snapshotStore, + startupBehavior: .testing) + let now = Date() + let staleReset = now.addingTimeInterval(2 * 60 * 60) + let priorDisplayedSnapshot = UsageSnapshot( + primary: RateWindow( + usedPercent: 11, + windowMinutes: 300, + resetsAt: staleReset, + resetDescription: nil), + secondary: nil, + updatedAt: now, + identity: ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: "selected-auth@example.com", + accountOrganization: nil, + loginMethod: nil)) + store._setSnapshotForTesting(priorDisplayedSnapshot, provider: .codex) + store.lastKnownResetSnapshots[.codex] = priorDisplayedSnapshot + let priorGuard = store.currentCodexAccountScopedRefreshGuard( + preferCurrentSnapshot: false) + store.lastCodexUsagePublicationGuard = priorGuard + store.lastCodexAccountScopedRefreshGuard = priorGuard + let blocker = BlockingCodexFetchStrategy() + let liveHomePath = liveHome.path + self.installContextualCodexProvider(on: store) { context in + if context.env["CODEX_HOME"] == liveHomePath { + return try await blocker.awaitResult() + } + return UsageSnapshot( + primary: RateWindow( + usedPercent: 7, + windowMinutes: 300, + resetsAt: nil, + resetDescription: nil), + secondary: nil, + updatedAt: now, + identity: ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: "managed-selected-auth@example.com", + accountOrganization: nil, + loginMethod: "Managed Team")) + } + + let refreshTask = Task { await store.refreshCodexVisibleAccountsForMenu() } + await blocker.waitUntilStarted() + settings._test_liveSystemCodexAccount = ObservedSystemCodexAccount( + email: "selected-auth@example.com", + authFingerprint: "new-live-selected-auth", + codexHomePath: liveHome.path, + observedAt: Date(), + identity: .emailOnly(normalizedEmail: "selected-auth@example.com")) + settings.cachedCodexAccountReconciliationSnapshot = CachedCodexAccountReconciliationSnapshot( + activeSource: .liveSystem, + loadedAt: Date(), + snapshot: staleReconciliationSnapshot) + await blocker.resume(with: .success(UsageSnapshot( + primary: RateWindow( + usedPercent: 77, + windowMinutes: 300, + resetsAt: staleReset, + resetDescription: nil), + secondary: nil, + updatedAt: now, + identity: ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: "selected-auth@example.com", + accountOrganization: nil, + loginMethod: nil)))) + await refreshTask.value + + #expect(store.snapshots[.codex] == nil) + #expect(store.lastKnownResetSnapshots[.codex] == nil) + #expect(!store.codexAccountSnapshots.contains { + $0.account.selectionSource == .liveSystem + }) + #expect(!snapshotStore.storedSnapshots.contains { + $0.account.selectionSource == .liveSystem + }) + } + + @Test + func `stacked visible refresh keeps selected apply after live token fingerprint rotates`() async throws { + let settings = self.makeSettingsStore( + suite: "CodexAccountVisibleHistoryBackfillTests-selected-token-rotation") + settings.refreshFrequency = .manual + settings.multiAccountMenuLayout = .stacked + + let managedID = try #require(UUID(uuidString: "DDDDDDDD-EEEE-FFFF-AAAA-888888888888")) + let liveHome = FileManager.default.temporaryDirectory + .appendingPathComponent("codex-visible-selected-token-\(UUID().uuidString)", isDirectory: true) + let managedHome = FileManager.default.temporaryDirectory + .appendingPathComponent("codex-visible-selected-token-managed-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: liveHome, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: managedHome, withIntermediateDirectories: true) + settings._test_liveSystemCodexAccount = ObservedSystemCodexAccount( + email: "selected-token@example.com", + workspaceLabel: "Live Team", + workspaceAccountID: "acct-selected-token", + authFingerprint: "old-live-selected-token", + codexHomePath: liveHome.path, + observedAt: Date(), + identity: .providerAccount(id: "acct-selected-token")) + let managedAccount = ManagedCodexAccount( + id: managedID, + email: "managed-selected-token@example.com", + providerAccountID: "acct-managed-selected-token", + workspaceLabel: "Managed Team", + workspaceAccountID: "acct-managed-selected-token", + managedHomePath: managedHome.path, + createdAt: 1, + updatedAt: 2, + lastAuthenticatedAt: 2) + let storeURL = try self.makeManagedAccountStoreURL(accounts: [managedAccount]) + defer { + settings._test_managedCodexAccountStoreURL = nil + settings._test_liveSystemCodexAccount = nil + try? FileManager.default.removeItem(at: storeURL) + try? FileManager.default.removeItem(at: liveHome) + try? FileManager.default.removeItem(at: managedHome) + } + settings._test_managedCodexAccountStoreURL = storeURL + settings.codexActiveSource = .liveSystem + + let snapshotStore = RecordingCodexAccountUsageSnapshotStore(initialSnapshots: []) + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + codexAccountUsageSnapshotStore: snapshotStore, + startupBehavior: .testing) + let blocker = BlockingCodexFetchStrategy() + let liveHomePath = liveHome.path + let now = Date() + let reset = now.addingTimeInterval(2 * 60 * 60) + self.installContextualCodexProvider(on: store) { context in + if context.env["CODEX_HOME"] == liveHomePath { + return try await blocker.awaitResult() + } + return UsageSnapshot( + primary: RateWindow( + usedPercent: 7, + windowMinutes: 300, + resetsAt: nil, + resetDescription: nil), + secondary: nil, + updatedAt: now, + identity: ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: "managed-selected-token@example.com", + accountOrganization: nil, + loginMethod: "Managed Team")) + } + + let refreshTask = Task { await store.refreshCodexVisibleAccountsForMenu() } + await blocker.waitUntilStarted() + settings._test_liveSystemCodexAccount = ObservedSystemCodexAccount( + email: "selected-token@example.com", + workspaceLabel: "Live Team", + workspaceAccountID: "acct-selected-token", + authFingerprint: "new-live-selected-token", + codexHomePath: liveHome.path, + observedAt: Date(), + identity: .providerAccount(id: "acct-selected-token")) + await blocker.resume(with: .success(UsageSnapshot( + primary: RateWindow( + usedPercent: 77, + windowMinutes: 300, + resetsAt: reset, + resetDescription: nil), + secondary: nil, + updatedAt: now, + identity: ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: "selected-token@example.com", + accountOrganization: nil, + loginMethod: "Pro")))) + await refreshTask.value + + let selectedSnapshot = try #require(store.snapshots[.codex]) + #expect(selectedSnapshot.primary?.usedPercent == 77) + #expect(selectedSnapshot.accountEmail(for: .codex) == "selected-token@example.com") + #expect(selectedSnapshot.loginMethod(for: .codex) == "Pro") + #expect(store.lastCodexAccountScopedRefreshGuard?.authFingerprint == "new-live-selected-token") + + let liveRow = try #require(store.codexAccountSnapshots.first { + $0.account.selectionSource == .liveSystem + }) + #expect(liveRow.account.authFingerprint == "new-live-selected-token") + #expect(liveRow.snapshot?.primary?.usedPercent == 77) + + let persistedLive = try #require(snapshotStore.storedSnapshots.first { + $0.account.selectionSource == .liveSystem + }) + #expect(persistedLive.account.authFingerprint == "new-live-selected-token") + #expect(persistedLive.snapshot?.primary?.usedPercent == 77) + } + + @Test + func `stacked visible refresh clears selected state after live account email changes`() async throws { + SettingsStore.codexAccountReconciliationSnapshotCacheIntervalOverrideForTesting = 60 + let settings = self.makeSettingsStore( + suite: "CodexAccountVisibleHistoryBackfillTests-selected-email-change") + settings.refreshFrequency = .manual + settings.multiAccountMenuLayout = .stacked + + let managedID = try #require(UUID(uuidString: "DDDDDDDD-EEEE-FFFF-AAAA-777777777777")) + let liveHome = FileManager.default.temporaryDirectory + .appendingPathComponent("codex-visible-selected-email-\(UUID().uuidString)", isDirectory: true) + let managedHome = FileManager.default.temporaryDirectory + .appendingPathComponent("codex-visible-selected-email-managed-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: liveHome, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: managedHome, withIntermediateDirectories: true) + settings._test_liveSystemCodexAccount = ObservedSystemCodexAccount( + email: "old-selected@example.com", + workspaceLabel: "Live Team", + workspaceAccountID: "acct-selected-email", + authFingerprint: "old-live-selected-email", + codexHomePath: liveHome.path, + observedAt: Date(), + identity: .providerAccount(id: "acct-selected-email")) + let managedAccount = ManagedCodexAccount( + id: managedID, + email: "managed-selected-email@example.com", + providerAccountID: "acct-managed-selected-email", + workspaceLabel: "Managed Team", + workspaceAccountID: "acct-managed-selected-email", + managedHomePath: managedHome.path, + createdAt: 1, + updatedAt: 2, + lastAuthenticatedAt: 2) + let storeURL = try self.makeManagedAccountStoreURL(accounts: [managedAccount]) + defer { + SettingsStore.codexAccountReconciliationSnapshotCacheIntervalOverrideForTesting = nil + settings._test_managedCodexAccountStoreURL = nil + settings._test_liveSystemCodexAccount = nil + try? FileManager.default.removeItem(at: storeURL) + try? FileManager.default.removeItem(at: liveHome) + try? FileManager.default.removeItem(at: managedHome) + } + settings._test_managedCodexAccountStoreURL = storeURL + settings.codexActiveSource = .liveSystem + let staleReconciliationSnapshot = settings.codexAccountReconciliationSnapshot + + let snapshotStore = RecordingCodexAccountUsageSnapshotStore(initialSnapshots: []) + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + codexAccountUsageSnapshotStore: snapshotStore, + startupBehavior: .testing) + let now = Date() + let staleReset = now.addingTimeInterval(2 * 60 * 60) + let priorDisplayedSnapshot = UsageSnapshot( + primary: RateWindow( + usedPercent: 11, + windowMinutes: 300, + resetsAt: staleReset, + resetDescription: nil), + secondary: nil, + updatedAt: now, + identity: ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: "old-selected@example.com", + accountOrganization: nil, + loginMethod: nil)) + store._setSnapshotForTesting(priorDisplayedSnapshot, provider: .codex) + store.lastKnownResetSnapshots[.codex] = priorDisplayedSnapshot + store.lastCodexAccountScopedRefreshGuard = store.currentCodexAccountScopedRefreshGuard( + preferCurrentSnapshot: false) + let blocker = BlockingCodexFetchStrategy() + let liveHomePath = liveHome.path + self.installContextualCodexProvider(on: store) { context in + if context.env["CODEX_HOME"] == liveHomePath { + return try await blocker.awaitResult() + } + return UsageSnapshot( + primary: RateWindow( + usedPercent: 7, + windowMinutes: 300, + resetsAt: nil, + resetDescription: nil), + secondary: nil, + updatedAt: now, + identity: ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: "managed-selected-email@example.com", + accountOrganization: nil, + loginMethod: "Managed Team")) + } + + let refreshTask = Task { await store.refreshCodexVisibleAccountsForMenu() } + await blocker.waitUntilStarted() + settings._test_liveSystemCodexAccount = ObservedSystemCodexAccount( + email: "new-selected@example.com", + workspaceLabel: "Live Team", + workspaceAccountID: "acct-selected-email", + authFingerprint: "new-live-selected-email", + codexHomePath: liveHome.path, + observedAt: Date(), + identity: .providerAccount(id: "acct-selected-email")) + settings.cachedCodexAccountReconciliationSnapshot = CachedCodexAccountReconciliationSnapshot( + activeSource: .liveSystem, + loadedAt: Date(), + snapshot: staleReconciliationSnapshot) + await blocker.resume(with: .success(UsageSnapshot( + primary: RateWindow( + usedPercent: 77, + windowMinutes: 300, + resetsAt: staleReset, + resetDescription: nil), + secondary: nil, + updatedAt: now, + identity: ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: "old-selected@example.com", + accountOrganization: nil, + loginMethod: nil)))) + await refreshTask.value + + #expect(store.snapshots[.codex] == nil) + #expect(store.lastKnownResetSnapshots[.codex] == nil) + #expect(!store.codexAccountSnapshots.contains { + $0.account.selectionSource == .liveSystem + }) + #expect(store.codexAccountSnapshots.contains { + $0.account.workspaceAccountID == "acct-managed-selected-email" + }) + #expect(!snapshotStore.storedSnapshots.contains { + $0.account.selectionSource == .liveSystem + }) + #expect(snapshotStore.storedSnapshots.contains { + $0.account.workspaceAccountID == "acct-managed-selected-email" + }) + } + + @Test + func `stacked visible refresh discards selected apply after provider account email changes`() async throws { + let settings = self.makeSettingsStore( + suite: "CodexAccountVisibleHistoryBackfillTests-selected-provider-email-change") + settings.refreshFrequency = .manual + settings.multiAccountMenuLayout = .stacked + + let targetID = try #require(UUID(uuidString: "DDDDDDDD-EEEE-FFFF-AAAA-555555555555")) + let siblingID = try #require(UUID(uuidString: "DDDDDDDD-EEEE-FFFF-AAAA-666666666666")) + let targetHome = FileManager.default.temporaryDirectory + .appendingPathComponent("codex-visible-provider-email-\(UUID().uuidString)", isDirectory: true) + let siblingHome = FileManager.default.temporaryDirectory + .appendingPathComponent("codex-visible-provider-email-sibling-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: targetHome, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: siblingHome, withIntermediateDirectories: true) + let originalTarget = ManagedCodexAccount( + id: targetID, + email: "old-provider@example.com", + providerAccountID: "acct-provider-email", + workspaceLabel: "Provider Team", + workspaceAccountID: "acct-provider-email", + managedHomePath: targetHome.path, + createdAt: 1, + updatedAt: 2, + lastAuthenticatedAt: 2) + let updatedTarget = ManagedCodexAccount( + id: targetID, + email: "new-provider@example.com", + providerAccountID: "acct-provider-email", + workspaceLabel: "Provider Team", + workspaceAccountID: "acct-provider-email", + managedHomePath: targetHome.path, + createdAt: 1, + updatedAt: 3, + lastAuthenticatedAt: 3) + let siblingAccount = ManagedCodexAccount( + id: siblingID, + email: "sibling-provider@example.com", + providerAccountID: "acct-provider-sibling", + workspaceLabel: "Sibling Team", + workspaceAccountID: "acct-provider-sibling", + managedHomePath: siblingHome.path, + createdAt: 1, + updatedAt: 2, + lastAuthenticatedAt: 2) + let storeURL = try self.makeManagedAccountStoreURL(accounts: [originalTarget, siblingAccount]) + defer { + settings._test_managedCodexAccountStoreURL = nil + try? FileManager.default.removeItem(at: storeURL) + try? FileManager.default.removeItem(at: targetHome) + try? FileManager.default.removeItem(at: siblingHome) + } + settings._test_managedCodexAccountStoreURL = storeURL + settings.codexActiveSource = .managedAccount(id: targetID) + + let snapshotStore = RecordingCodexAccountUsageSnapshotStore(initialSnapshots: []) + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + codexAccountUsageSnapshotStore: snapshotStore, + startupBehavior: .testing) + let blocker = BlockingCodexFetchStrategy() + let targetHomePath = targetHome.path + let now = Date() + let reset = now.addingTimeInterval(90 * 60) + let prior = UsageSnapshot( + primary: RateWindow( + usedPercent: 63, + windowMinutes: 300, + resetsAt: reset, + resetDescription: nil), + secondary: nil, + updatedAt: now.addingTimeInterval(-60), + identity: ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: "old-provider@example.com", + accountOrganization: nil, + loginMethod: "Provider Team")) + let priorGuard = CodexAccountScopedRefreshGuard( + source: .managedAccount(id: targetID), + identity: .providerAccount(id: "acct-provider-email"), + accountKey: "old-provider@example.com") + store.snapshots[.codex] = prior + store.lastKnownResetSnapshots[.codex] = prior + store.lastCodexUsagePublicationGuard = priorGuard + store.lastCodexAccountScopedRefreshGuard = priorGuard + self.installContextualCodexProvider(on: store) { context in + if context.env["CODEX_HOME"] == targetHomePath { + return try await blocker.awaitResult() + } + return UsageSnapshot( + primary: RateWindow( + usedPercent: 11, + windowMinutes: 300, + resetsAt: nil, + resetDescription: nil), + secondary: nil, + updatedAt: now, + identity: ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: "sibling-provider@example.com", + accountOrganization: nil, + loginMethod: "Sibling Team")) + } + + let refreshTask = Task { await store.refreshCodexVisibleAccountsForMenu() } + await blocker.waitUntilStarted() + try FileManagedCodexAccountStore(fileURL: storeURL).storeAccounts(ManagedCodexAccountSet( + version: FileManagedCodexAccountStore.currentVersion, + accounts: [updatedTarget, siblingAccount])) + await blocker.resume(with: .success(UsageSnapshot( + primary: RateWindow( + usedPercent: 64, + windowMinutes: 300, + resetsAt: reset, + resetDescription: nil), + secondary: nil, + updatedAt: now, + identity: ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: "old-provider@example.com", + accountOrganization: nil, + loginMethod: "Pro")))) + await refreshTask.value + + #expect(store.snapshots[.codex] == nil) + #expect(store.lastKnownResetSnapshots[.codex] == nil) + #expect(!store.codexAccountSnapshots.contains { + $0.account.workspaceAccountID == "acct-provider-email" + }) + #expect(store.codexAccountSnapshots.contains { + $0.account.workspaceAccountID == "acct-provider-sibling" + }) + #expect(!snapshotStore.storedSnapshots.contains { + $0.account.workspaceAccountID == "acct-provider-email" + }) + } +} diff --git a/Tests/CodexBarTests/CodexAccountsSettingsSectionTests.swift b/Tests/CodexBarTests/CodexAccountsSettingsSectionTests.swift index 7ce1ff29a9..0215fc2f38 100644 --- a/Tests/CodexBarTests/CodexAccountsSettingsSectionTests.swift +++ b/Tests/CodexBarTests/CodexAccountsSettingsSectionTests.swift @@ -322,7 +322,6 @@ struct CodexAccountsSettingsSectionTests { minimaxCookieStore: InMemoryMiniMaxCookieStore(), minimaxAPITokenStore: InMemoryMiniMaxAPITokenStore(), kimiTokenStore: InMemoryKimiTokenStore(), - kimiK2TokenStore: InMemoryKimiK2TokenStore(), augmentCookieStore: InMemoryCookieHeaderStore(), ampCookieStore: InMemoryCookieHeaderStore(), copilotTokenStore: InMemoryCopilotTokenStore(), @@ -330,11 +329,15 @@ struct CodexAccountsSettingsSectionTests { } private static func makeUsageStore(settings: SettingsStore) -> UsageStore { - UsageStore( + let store = UsageStore( fetcher: UsageFetcher(environment: [:]), browserDetection: BrowserDetection(cacheTTL: 0), settings: settings, startupBehavior: .testing) + // Account-selection tests must never trigger a real provider refresh. + store._test_providerRefreshOverride = { _ in } + store._test_codexCreditsLoaderOverride = { throw UsageError.noRateLimitsFound } + return store } } diff --git a/Tests/CodexBarTests/CodexActiveSourceConfigTests.swift b/Tests/CodexBarTests/CodexActiveSourceConfigTests.swift index d9d5f256ec..e333f00e7b 100644 --- a/Tests/CodexBarTests/CodexActiveSourceConfigTests.swift +++ b/Tests/CodexBarTests/CodexActiveSourceConfigTests.swift @@ -111,6 +111,31 @@ struct CodexActiveSourceConfigTests { #expect((activeSource["accountID"] as? String) == accountID.uuidString) } + @Test + func `provider config encodes profile home source in downgrade readable envelope`() throws { + let config = CodexBarConfig( + providers: [ + ProviderConfig( + id: .codex, + codexActiveSource: .profileHome(path: "/Users/test/.codex-work"), + codexProfileHomePaths: ["/Users/test/.codex-work"]), + ]) + + let data = try JSONEncoder().encode(config) + let object = try JSONSerialization.jsonObject(with: data) as? [String: Any] + let providers = try #require(object?["providers"] as? [[String: Any]]) + let provider = try #require(providers.first(where: { $0["id"] as? String == "codex" })) + let activeSource = try #require(provider["codexActiveSource"] as? [String: Any]) + + #expect(activeSource.count == 2) + #expect(activeSource["kind"] as? String == "liveSystem") + #expect(activeSource["homePath"] as? String == "/Users/test/.codex-work") + #expect(provider["codexProfileHomePaths"] as? [String] == ["/Users/test/.codex-work"]) + + let releasedConfig = try JSONDecoder().decode(ReleasedCodexBarConfig.self, from: data) + #expect(releasedConfig.providers.first?.codexActiveSource == .liveSystem) + } + @Test func `provider config round trips live system active source`() throws { let config = CodexBarConfig( @@ -141,4 +166,77 @@ struct CodexActiveSourceConfigTests { #expect(decoded.providerConfig(for: .codex)?.codexActiveSource == .managedAccount(id: accountID)) } + + @Test + func `provider config round trips profile home active source`() throws { + let config = CodexBarConfig( + providers: [ + ProviderConfig( + id: .codex, + codexActiveSource: .profileHome(path: "/Users/test/.codex-work"), + codexProfileHomePaths: ["/Users/test/.codex-work"]), + ]) + + let data = try JSONEncoder().encode(config) + let decoded = try JSONDecoder().decode(CodexBarConfig.self, from: data) + let providerConfig = decoded.providerConfig(for: .codex) + + #expect(providerConfig?.codexActiveSource == .profileHome(path: "/Users/test/.codex-work")) + #expect(providerConfig?.codexProfileHomePaths == ["/Users/test/.codex-work"]) + } + + @Test + func `profile home discriminator written by development builds still decodes`() throws { + let data = Data(#"{"kind":"profileHome","homePath":"/Users/test/.codex-work"}"#.utf8) + + let decoded = try JSONDecoder().decode(CodexActiveSource.self, from: data) + let canonicalData = try JSONEncoder().encode(decoded) + let canonical = try #require(JSONSerialization.jsonObject(with: canonicalData) as? [String: Any]) + + #expect(decoded == .profileHome(path: "/Users/test/.codex-work")) + #expect(canonical["kind"] as? String == "liveSystem") + #expect(canonical["homePath"] as? String == "/Users/test/.codex-work") + } + + @Test + func `blank profile home sentinel falls back to live system`() throws { + let data = Data(#"{"kind":"liveSystem","homePath":" "}"#.utf8) + + let decoded = try JSONDecoder().decode(CodexActiveSource.self, from: data) + + #expect(decoded == .liveSystem) + } +} + +private struct ReleasedCodexBarConfig: Decodable { + let providers: [ReleasedProviderConfig] +} + +private struct ReleasedProviderConfig: Decodable { + let codexActiveSource: ReleasedCodexActiveSource? +} + +private enum ReleasedCodexActiveSource: Decodable, Equatable { + case liveSystem + case managedAccount(id: UUID) + + private enum CodingKeys: String, CodingKey { + case kind + case accountID + } + + private enum Kind: String, Decodable { + case liveSystem + case managedAccount + } + + init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + switch try container.decode(Kind.self, forKey: .kind) { + case .liveSystem: + self = .liveSystem + case .managedAccount: + self = try .managedAccount(id: container.decode(UUID.self, forKey: .accountID)) + } + } } diff --git a/Tests/CodexBarTests/CodexBackgroundRefreshCoalescingTests.swift b/Tests/CodexBarTests/CodexBackgroundRefreshCoalescingTests.swift index bac3cd4001..664e375524 100644 --- a/Tests/CodexBarTests/CodexBackgroundRefreshCoalescingTests.swift +++ b/Tests/CodexBarTests/CodexBackgroundRefreshCoalescingTests.swift @@ -32,15 +32,33 @@ struct CodexBackgroundRefreshCoalescingTests { await store.refresh(forceTokenUsage: false) await firstCompletion.markCompleted() } - await blocker.waitUntilStarted(count: 1) - #expect(await firstCompletion.waitUntilCompleted() == true) + let didStartFirstCreditsRefresh = await blocker.waitUntilStartedWithin(count: 1) + #expect(didStartFirstCreditsRefresh) + guard didStartFirstCreditsRefresh else { + await self.cancelCreditsWork(store: store, blocker: blocker, tasks: [firstRefreshTask]) + return + } + let didCompleteFirstRefresh = await firstCompletion.waitUntilCompleted() + #expect(didCompleteFirstRefresh) + guard didCompleteFirstRefresh else { + await self.cancelCreditsWork(store: store, blocker: blocker, tasks: [firstRefreshTask]) + return + } let secondRefreshTask = Task { await store.refresh(forceTokenUsage: false) await secondCompletion.markCompleted() } - #expect(await secondCompletion.waitUntilCompleted() == true) + let didCompleteSecondRefresh = await secondCompletion.waitUntilCompleted() + #expect(didCompleteSecondRefresh) + guard didCompleteSecondRefresh else { + await self.cancelCreditsWork( + store: store, + blocker: blocker, + tasks: [firstRefreshTask, secondRefreshTask]) + return + } #expect(await blocker.startedCount() == 1) await blocker.resumeNext(with: .success(CreditsSnapshot(remaining: 25, events: [], updatedAt: Date()))) @@ -77,20 +95,44 @@ struct CodexBackgroundRefreshCoalescingTests { let alphaRefreshTask = Task { await store.refresh(forceTokenUsage: false) } - await blocker.waitUntilStarted(count: 1) + let didStartAlphaRefresh = await blocker.waitUntilStartedWithin(count: 1) + #expect(didStartAlphaRefresh) + guard didStartAlphaRefresh else { + await self.cancelCreditsWork(store: store, blocker: blocker, tasks: [alphaRefreshTask]) + return + } + let staleCreditsTask = try #require(store.creditsRefreshTask) settings._test_activeManagedCodexAccount = betaAccount settings.codexActiveSource = .managedAccount(id: betaAccount.id) let betaRefreshTask = Task { await store.refresh(forceTokenUsage: false) } - await blocker.waitUntilStarted(count: 2) + let didStartBetaRefresh = await blocker.waitUntilStartedWithin(count: 2) + #expect(didStartBetaRefresh) + guard didStartBetaRefresh else { + await self.cancelCreditsWork( + store: store, + blocker: blocker, + tasks: [alphaRefreshTask, betaRefreshTask]) + return + } + let didCancelAlphaRefresh = await blocker.waitUntilCancellationCount(1) + #expect(didCancelAlphaRefresh) + guard didCancelAlphaRefresh else { + await self.cancelCreditsWork( + store: store, + blocker: blocker, + tasks: [alphaRefreshTask, betaRefreshTask]) + return + } + await blocker.resumeLast(with: .success(CreditsSnapshot(remaining: 25, events: [], updatedAt: Date()))) await blocker.resumeNext(with: .success(CreditsSnapshot(remaining: 10, events: [], updatedAt: Date()))) - await blocker.resumeNext(with: .success(CreditsSnapshot(remaining: 25, events: [], updatedAt: Date()))) await alphaRefreshTask.value await betaRefreshTask.value + await staleCreditsTask.value await store.creditsRefreshTask?.value #expect(await blocker.startedCount() == 2) @@ -117,25 +159,118 @@ 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) + let didStartRegularCreditsRefresh = await blocker.waitUntilStartedWithin(count: 1) + #expect(didStartRegularCreditsRefresh) + guard didStartRegularCreditsRefresh else { + await self.cancelCreditsWork(store: store, blocker: blocker, tasks: [regularRefreshTask]) + return + } + let didCompleteRegularRefresh = await regularCompletion.waitUntilCompleted() + #expect(didCompleteRegularRefresh) + guard didCompleteRegularRefresh else { + await self.cancelCreditsWork(store: store, blocker: blocker, tasks: [regularRefreshTask]) + return + } + let staleCreditsTask = try #require(store.creditsRefreshTask) let forceRefreshTask = Task { await store.refresh(forceTokenUsage: true) } - await blocker.waitUntilStarted(count: 2) + let didStartForcedCreditsRefresh = await blocker.waitUntilStartedWithin(count: 2) + #expect(didStartForcedCreditsRefresh) + guard didStartForcedCreditsRefresh else { + await self.cancelCreditsWork( + store: store, + blocker: blocker, + tasks: [regularRefreshTask, forceRefreshTask]) + return + } + let didCancelStaleCreditsRefresh = await blocker.waitUntilCancellationCount(1) + #expect(didCancelStaleCreditsRefresh) + guard didCancelStaleCreditsRefresh else { + await self.cancelCreditsWork( + store: store, + blocker: blocker, + tasks: [regularRefreshTask, forceRefreshTask]) + return + } + await blocker.resumeLast(with: .success(CreditsSnapshot(remaining: 25, events: [], updatedAt: Date()))) await blocker.resumeNext(with: .success(CreditsSnapshot(remaining: 10, events: [], updatedAt: Date()))) - await blocker.resumeNext(with: .success(CreditsSnapshot(remaining: 25, events: [], updatedAt: Date()))) await regularRefreshTask.value await forceRefreshTask.value + await staleCreditsTask.value + + #expect(await blocker.startedCount() == 2) + #expect(await blocker.cancellationCount() == 1) + #expect(store.credits?.remaining == 25) + } + + @Test + func `forced background tail replaces stale scheduled Codex credits fetch`() async throws { + let settings = try self.makeSettingsStore( + suite: "CodexBackgroundRefreshCoalescingTests-credits-tail-cancels-background") + settings.statusChecksEnabled = false + settings.costUsageEnabled = false + settings.openAIWebAccessEnabled = false + let managedAccount = try Self.installManagedAccount( + email: "managed@example.com", + settings: settings) + defer { try? FileManager.default.removeItem(atPath: managedAccount.managedHomePath) } + + let store = self.makeStore(settings: settings) + let blocker = BlockingCreditsLoader() + store._test_providerRefreshOverride = { _ in } + store._test_codexCreditsLoaderOverride = { + try await blocker.awaitResult() + } + defer { + store._test_providerRefreshOverride = nil + store._test_codexCreditsLoaderOverride = nil + } + + await store.refresh(forceTokenUsage: false) + let didStartScheduledCreditsRefresh = await blocker.waitUntilStartedWithin(count: 1) + #expect(didStartScheduledCreditsRefresh) + guard didStartScheduledCreditsRefresh else { + await self.cancelCreditsWork(store: store, blocker: blocker, tasks: []) + return + } + let staleCreditsTask = try #require(store.creditsRefreshTask) + + await store.refresh(enrichmentMode: .forcedBackground) + let tailTask = try #require(store.forcedRefreshEnrichmentTask) + let didStartForcedCreditsRefresh = await blocker.waitUntilStartedWithin(count: 2) + #expect(didStartForcedCreditsRefresh) + guard didStartForcedCreditsRefresh else { + tailTask.cancel() + await self.cancelCreditsWork(store: store, blocker: blocker, tasks: [tailTask]) + return + } + + let didCancelStaleCreditsRefresh = await blocker.waitUntilCancellationCount(1) + #expect(didCancelStaleCreditsRefresh) + guard didCancelStaleCreditsRefresh else { + tailTask.cancel() + await self.cancelCreditsWork(store: store, blocker: blocker, tasks: [tailTask]) + return + } + await blocker.resumeLast(with: .success(CreditsSnapshot(remaining: 25, events: [], updatedAt: Date()))) + await blocker.resumeNext(with: .success(CreditsSnapshot(remaining: 10, events: [], updatedAt: Date()))) + await tailTask.value + await staleCreditsTask.value #expect(await blocker.startedCount() == 2) + #expect(await blocker.cancellationCount() == 1) #expect(store.credits?.remaining == 25) + #expect(!store.hasForcedRefreshEnrichmentInFlight) } @Test @@ -158,7 +293,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 } @@ -167,15 +304,33 @@ struct CodexBackgroundRefreshCoalescingTests { await store.refresh(forceTokenUsage: false) await firstCompletion.markCompleted() } - await blocker.waitUntilStarted(count: 1) - #expect(await firstCompletion.waitUntilCompleted() == true) + let didStartDashboardRefresh = await blocker.waitUntilStartedWithin(count: 1) + #expect(didStartDashboardRefresh) + guard didStartDashboardRefresh else { + await self.cancelDashboardWork(store: store, blocker: blocker, tasks: [firstRefreshTask]) + return + } + let didCompleteFirstRefresh = await firstCompletion.waitUntilCompleted() + #expect(didCompleteFirstRefresh) + guard didCompleteFirstRefresh else { + await self.cancelDashboardWork(store: store, blocker: blocker, tasks: [firstRefreshTask]) + return + } let secondRefreshTask = Task { await store.refresh(forceTokenUsage: false) await secondCompletion.markCompleted() } - #expect(await secondCompletion.waitUntilCompleted() == true) + let didCompleteSecondRefresh = await secondCompletion.waitUntilCompleted() + #expect(didCompleteSecondRefresh) + guard didCompleteSecondRefresh else { + await self.cancelDashboardWork( + store: store, + blocker: blocker, + tasks: [firstRefreshTask, secondRefreshTask]) + return + } #expect(await blocker.startedCount() == 1) let backgroundTask = try #require(store.openAIDashboardBackgroundRefreshTask) @@ -219,8 +374,22 @@ struct CodexBackgroundRefreshCoalescingTests { targetEmail: managedAccount.email, force: true) } - await importBlocker.waitUntilStarted() + let didStartImport = await importBlocker.waitUntilStarted() + #expect(didStartImport) + guard didStartImport else { + importTask.cancel() + await importBlocker.cancelAll() + _ = await importTask.value + return + } importTask.cancel() + let didObserveCancellation = await importBlocker.waitUntilCancellationCount(1) + #expect(didObserveCancellation) + guard didObserveCancellation else { + await importBlocker.cancelAll() + _ = await importTask.value + return + } await importBlocker.resumeNext(with: .failure( OpenAIDashboardBrowserCookieImporter.ImportError.noMatchingAccount( found: [.init(sourceLabel: "Chrome", email: "other@example.com")]))) @@ -232,16 +401,786 @@ struct CodexBackgroundRefreshCoalescingTests { #expect(store.openAIDashboardRequiresLogin == false) } - private func makeSettingsStore(suite: String) throws -> SettingsStore { - let defaults = UserDefaults(suiteName: suite)! - defaults.removePersistentDomain(forName: suite) - defaults.set(true, forKey: "providerDetectionCompleted") - let configStore = testConfigStore(suiteName: suite) - let settings = SettingsStore( - userDefaults: defaults, - configStore: configStore, - zaiTokenStore: NoopZaiTokenStore(), - syntheticTokenStore: NoopSyntheticTokenStore()) + @Test + func `settings refresh waits for forced enrichment instead of being dropped`() async throws { + let settings = try self.makeSettingsStore( + suite: "CodexBackgroundRefreshCoalescingTests-settings-waits-for-tail") + settings.statusChecksEnabled = false + settings.costUsageEnabled = true + settings.openAIWebAccessEnabled = false + let managedAccount = try Self.installManagedAccount( + email: "managed@example.com", + settings: settings) + defer { try? FileManager.default.removeItem(atPath: managedAccount.managedHomePath) } + + let store = self.makeStore(settings: settings) + let tokenGate = BlockingForcedTokenRefresh() + var providerInteractions: [ProviderInteraction] = [] + var didObserveWait = false + store._test_providerRefreshOverride = { _ in + providerInteractions.append(ProviderInteractionContext.current) + } + store._test_codexCreditsLoaderOverride = { + CreditsSnapshot(remaining: 25, events: [], updatedAt: Date()) + } + store._test_tokenUsageRefreshOverride = { provider, force in + guard force else { return } + await tokenGate.run( + provider: provider, + force: force, + interaction: ProviderInteractionContext.current, + refreshPhase: ProviderRefreshContext.current, + browserRetryAllowed: false) + } + store._test_forcedRefreshEnrichmentWaitObserver = { + didObserveWait = true + } + defer { + store._test_providerRefreshOverride = nil + store._test_codexCreditsLoaderOverride = nil + store._test_tokenUsageRefreshOverride = nil + store._test_forcedRefreshEnrichmentWaitObserver = nil + } + + await store.refresh(enrichmentMode: .forcedBackground) + let didStartTokenTail = await tokenGate.waitUntilStarted(count: 1) + #expect(didStartTokenTail) + guard didStartTokenTail else { + await self.cancelForcedEnrichmentWork(store: store) + return + } + settings.costUsageEnabled = false + + let completion = RefreshCompletionProbe() + var settingsRefreshes: [Task] = [] + for expectedGeneration in 1...3 { + let task = Task { @MainActor in + await store.refreshForSettingsChange() + await completion.markCompleted() + } + settingsRefreshes.append(task) + for _ in 0..<100 where store.requiredRefreshRequestGeneration < expectedGeneration { + await Task.yield() + } + #expect(store.requiredRefreshRequestGeneration == expectedGeneration) + } + for _ in 0..<100 where !didObserveWait { + await Task.yield() + } + + #expect(didObserveWait) + #expect(providerInteractions == [.background]) + #expect(await completion.isCompleted == false) + + await tokenGate.resumeNext() + for task in settingsRefreshes { + await task.value + } + + #expect(providerInteractions == [.background, .background]) + #expect(await completion.isCompleted) + #expect(store.requiredRefreshCompletedGeneration == 3) + #expect(!store.hasForcedRefreshEnrichmentInFlight) + } + + @Test + func `required post-action refresh waits for forced enrichment`() async throws { + let settings = try self.makeSettingsStore( + suite: "CodexBackgroundRefreshCoalescingTests-required-refresh-waits-for-tail") + settings.statusChecksEnabled = false + settings.costUsageEnabled = true + settings.openAIWebAccessEnabled = false + let managedAccount = try Self.installManagedAccount( + email: "managed@example.com", + settings: settings) + defer { try? FileManager.default.removeItem(atPath: managedAccount.managedHomePath) } + + let store = self.makeStore(settings: settings) + let tokenGate = BlockingForcedTokenRefresh() + var providerInteractions: [ProviderInteraction] = [] + var didObserveWait = false + store._test_providerRefreshOverride = { _ in + providerInteractions.append(ProviderInteractionContext.current) + } + store._test_codexCreditsLoaderOverride = { + CreditsSnapshot(remaining: 25, events: [], updatedAt: Date()) + } + store._test_tokenUsageRefreshOverride = { provider, force in + guard force else { return } + await tokenGate.run( + provider: provider, + force: force, + interaction: ProviderInteractionContext.current, + refreshPhase: ProviderRefreshContext.current, + browserRetryAllowed: false) + } + store._test_forcedRefreshEnrichmentWaitObserver = { + didObserveWait = true + } + defer { + store._test_providerRefreshOverride = nil + store._test_codexCreditsLoaderOverride = nil + store._test_tokenUsageRefreshOverride = nil + store._test_forcedRefreshEnrichmentWaitObserver = nil + } + + await store.refresh(enrichmentMode: .forcedBackground) + let didStartTokenTail = await tokenGate.waitUntilStarted(count: 1) + #expect(didStartTokenTail) + guard didStartTokenTail else { + await self.cancelForcedEnrichmentWork(store: store) + return + } + settings.costUsageEnabled = false + + let completion = RefreshCompletionProbe() + let requiredRefresh = Task { @MainActor in + await ProviderInteractionContext.$current.withValue(.userInitiated) { + await store.refresh() + } + await completion.markCompleted() + } + for _ in 0..<100 where !didObserveWait { + await Task.yield() + } + + #expect(didObserveWait) + #expect(providerInteractions == [.background]) + #expect(await completion.isCompleted == false) + + await tokenGate.resumeNext() + await requiredRefresh.value + + #expect(providerInteractions == [.background, .userInitiated]) + #expect(await completion.isCompleted) + #expect(!store.hasForcedRefreshEnrichmentInFlight) + } + + @Test + func `startup retry waits for forced enrichment and completes its retry pass`() async throws { + let settings = try self.makeSettingsStore( + suite: "CodexBackgroundRefreshCoalescingTests-startup-retry-waits-for-tail") + settings.refreshFrequency = .manual + settings.statusChecksEnabled = true + settings.costUsageEnabled = false + settings.openAIWebAccessEnabled = false + let managedAccount = try Self.installManagedAccount( + email: "managed@example.com", + settings: settings) + defer { try? FileManager.default.removeItem(atPath: managedAccount.managedHomePath) } + + let store = self.makeStore(settings: settings) + let retrySleep = ForcedRefreshRetrySleepGate() + let tokenGate = BlockingForcedTokenRefresh() + var statusAttempts = 0 + var didObserveWait = false + store._test_providerRefreshOverride = { _ in } + store._test_providerStatusFetchOverride = { _ in + statusAttempts += 1 + if statusAttempts == 1 { + throw URLError(.cannotFindHost) + } + return ProviderStatus(indicator: .none, description: "Operational", updatedAt: Date()) + } + store._test_codexCreditsLoaderOverride = { + CreditsSnapshot(remaining: 25, events: [], updatedAt: Date()) + } + store._test_tokenUsageRefreshOverride = { provider, force in + guard force else { return } + await tokenGate.run( + provider: provider, + force: force, + interaction: ProviderInteractionContext.current, + refreshPhase: ProviderRefreshContext.current, + browserRetryAllowed: false) + } + store._test_startupConnectivityRetrySleepOverride = { delay in + try await retrySleep.sleep(delay) + } + store._test_forcedRefreshEnrichmentWaitObserver = { + didObserveWait = true + } + defer { + store._test_providerRefreshOverride = nil + store._test_providerStatusFetchOverride = nil + store._test_codexCreditsLoaderOverride = nil + store._test_tokenUsageRefreshOverride = nil + store._test_startupConnectivityRetrySleepOverride = nil + store._test_forcedRefreshEnrichmentWaitObserver = nil + store.startupConnectivityRetryTask?.cancel() + store.startupConnectivityRetryTask = nil + } + + await store.refresh() + let didStartRetrySleep = await retrySleep.waitUntilSleeping() + #expect(didStartRetrySleep) + guard didStartRetrySleep else { + store.startupConnectivityRetryTask?.cancel() + return + } + let retryTask = try #require(store.startupConnectivityRetryTask) + + settings.costUsageEnabled = true + await ProviderInteractionContext.$current.withValue(.userInitiated) { + await store.refresh(enrichmentMode: .forcedBackground) + } + let didStartTokenTail = await tokenGate.waitUntilStarted(count: 1) + #expect(didStartTokenTail) + guard didStartTokenTail else { + await self.cancelForcedEnrichmentWork(store: store) + retryTask.cancel() + await retryTask.value + return + } + settings.costUsageEnabled = false + + await retrySleep.resume() + for _ in 0..<100 where !didObserveWait { + await Task.yield() + } + + #expect(didObserveWait) + #expect(statusAttempts == 2) + + await tokenGate.resumeNext() + await retryTask.value + + #expect(statusAttempts == 3) + #expect(store.statuses[.codex]?.indicator == ProviderStatusIndicator.none) + #expect(store.startupConnectivityRetryTask == nil) + #expect(!store.hasForcedRefreshEnrichmentInFlight) + } +} + +extension CodexBackgroundRefreshCoalescingTests { + @Test + func `forced enrichment keeps one active and the latest contextual follow-up`() async throws { + let settings = try self.makeSettingsStore( + suite: "CodexBackgroundRefreshCoalescingTests-forced-enrichment-latest") + settings.statusChecksEnabled = false + settings.costUsageEnabled = true + settings.openAIWebAccessEnabled = false + let managedAccount = try Self.installManagedAccount( + email: "managed@example.com", + settings: settings) + defer { try? FileManager.default.removeItem(atPath: managedAccount.managedHomePath) } + + let store = self.makeStore(settings: settings) + let gate = BlockingForcedTokenRefresh() + let deniedAt = Date() + var providerRefreshCount = 0 + store._test_providerRefreshOverride = { _ in + providerRefreshCount += 1 + } + defer { store._test_providerRefreshOverride = nil } + store._test_codexCreditsLoaderOverride = { + CreditsSnapshot(remaining: 25, events: [], updatedAt: Date()) + } + defer { store._test_codexCreditsLoaderOverride = nil } + store._test_tokenUsageRefreshOverride = { provider, force in + let retryAllowed = BrowserCookieAccessGate.shouldAttempt( + .arc, + now: deniedAt.addingTimeInterval(1)) + await gate.run( + provider: provider, + force: force, + interaction: ProviderInteractionContext.current, + refreshPhase: ProviderRefreshContext.current, + browserRetryAllowed: retryAllowed) + } + defer { store._test_tokenUsageRefreshOverride = nil } + + await BrowserCookieAccessGate.withDeniedBrowsersForTesting([.arc]) { + await KeychainAccessGate.withTaskOverrideForTesting(false) { + let firstRefresh = Task { @MainActor in + await ProviderInteractionContext.$current.withValue(.background) { + await store.refresh(enrichmentMode: .forcedBackground) + } + } + let didStartFirstTail = await gate.waitUntilStarted(count: 1) + #expect(didStartFirstTail) + guard didStartFirstTail else { + firstRefresh.cancel() + await self.cancelForcedEnrichmentWork(store: store) + await firstRefresh.value + return + } + await firstRefresh.value + + await store.refresh(enrichmentMode: .automatic) + #expect(providerRefreshCount == 1) + + await ProviderInteractionContext.$current.withValue(.background) { + await store.refresh(enrichmentMode: .forcedBackground) + } + let preflightOverride: (String, String?) -> KeychainAccessPreflight.Outcome = { _, _ in .allowed } + await KeychainAccessPreflight.withCheckGenericPasswordOverrideForTesting(preflightOverride) { + await BrowserCookieAccessGate.withExplicitRetry { + await ProviderInteractionContext.$current.withValue(.userInitiated) { + await store.refresh(enrichmentMode: .forcedBackground) + } + } + } + + #expect(store.forcedRefreshEnrichmentTask != nil) + #expect(store.pendingForcedRefreshEnrichmentTask != nil) + #expect(store.hasForcedRefreshEnrichmentInFlight) + + await gate.resumeNext() + let didStartLatestTail = await gate.waitUntilStarted(count: 2) + #expect(didStartLatestTail) + guard didStartLatestTail else { + await self.cancelForcedEnrichmentWork(store: store) + return + } + + let calls = await gate.recordedCalls() + #expect(calls.map(\.provider) == [.codex, .codex]) + #expect(calls.map(\.force) == [true, true]) + #expect(calls.map(\.interaction) == [.background, .userInitiated]) + #expect(calls.map(\.refreshPhase) == [.startup, .regular]) + #expect(calls.map(\.browserRetryAllowed) == [false, true]) + + await gate.resumeNext() + await store.awaitForcedRefreshEnrichment() + #expect(!store.hasForcedRefreshEnrichmentInFlight) + #expect(store.forcedRefreshEnrichmentTask == nil) + #expect(store.pendingForcedRefreshEnrichmentTask == nil) + } + } + } + + @Test + func `forced background login failure reconciles provider and credits once`() async throws { + let settings = try self.makeSettingsStore( + suite: "CodexBackgroundRefreshCoalescingTests-login-reconciliation") + settings.statusChecksEnabled = false + settings.costUsageEnabled = false + let managedAccount = try Self.installManagedAccount( + email: "managed@example.com", + settings: settings) + defer { try? FileManager.default.removeItem(atPath: managedAccount.managedHomePath) } + + let store = self.makeStore(settings: settings) + var providerRefreshes = 0 + var creditsRefreshes = 0 + var dashboardLoads = 0 + store._test_providerRefreshOverride = { provider in + #expect(provider == .codex) + providerRefreshes += 1 + } + store._test_codexCreditsLoaderOverride = { + creditsRefreshes += 1 + return CreditsSnapshot(remaining: 25, events: [], updatedAt: Date()) + } + store._test_openAIDashboardLoaderOverride = { _, _, _, _ in + dashboardLoads += 1 + throw OpenAIDashboardFetcher.FetchError.loginRequired + } + store._test_openAIDashboardCookieImportOverride = { targetEmail, _, _, _, _ in + OpenAIDashboardBrowserCookieImporter.ImportResult( + sourceLabel: "Fixture", + cookieCount: 2, + signedInEmail: targetEmail, + matchesCodexEmail: true) + } + defer { + store._test_providerRefreshOverride = nil + store._test_codexCreditsLoaderOverride = nil + store._test_openAIDashboardLoaderOverride = nil + store._test_openAIDashboardCookieImportOverride = nil + } + + await store.refresh(enrichmentMode: .forcedBackground) + await store.awaitForcedRefreshEnrichment() + + #expect(dashboardLoads == 2) + #expect(providerRefreshes == 2) + #expect(creditsRefreshes == 2) + #expect(store.openAIDashboardRequiresLogin) + } + + @Test + func `older login reconciliation yields to an already pending forced tail`() async throws { + let settings = try self.makeSettingsStore( + suite: "CodexBackgroundRefreshCoalescingTests-login-pending-generation") + settings.statusChecksEnabled = false + settings.costUsageEnabled = true + let managedAccount = try Self.installManagedAccount( + email: "managed@example.com", + settings: settings) + defer { try? FileManager.default.removeItem(atPath: managedAccount.managedHomePath) } + + let store = self.makeStore(settings: settings) + let tokenGate = BlockingForcedTokenRefresh() + let dashboardLoader = LoginThenSuccessDashboardLoader(email: managedAccount.email) + var providerInteractions: [ProviderInteraction] = [] + var creditsInteractions: [ProviderInteraction] = [] + store._test_providerRefreshOverride = { _ in + providerInteractions.append(ProviderInteractionContext.current) + } + store._test_codexCreditsLoaderOverride = { + creditsInteractions.append(ProviderInteractionContext.current) + return CreditsSnapshot(remaining: 25, events: [], updatedAt: Date()) + } + store._test_tokenUsageRefreshOverride = { provider, force in + await tokenGate.run( + provider: provider, + force: force, + interaction: ProviderInteractionContext.current, + refreshPhase: ProviderRefreshContext.current, + browserRetryAllowed: false) + } + store._test_openAIDashboardLoaderOverride = { _, _, _, _ in + try await dashboardLoader.load() + } + store._test_openAIDashboardCookieImportOverride = { targetEmail, _, _, _, _ in + OpenAIDashboardBrowserCookieImporter.ImportResult( + sourceLabel: "Fixture", + cookieCount: 2, + signedInEmail: targetEmail, + matchesCodexEmail: true) + } + defer { + store._test_providerRefreshOverride = nil + store._test_codexCreditsLoaderOverride = nil + store._test_tokenUsageRefreshOverride = nil + store._test_openAIDashboardLoaderOverride = nil + store._test_openAIDashboardCookieImportOverride = nil + } + + await ProviderInteractionContext.$current.withValue(.background) { + await store.refresh(enrichmentMode: .forcedBackground) + } + let didStartOlderTail = await tokenGate.waitUntilStarted(count: 1) + #expect(didStartOlderTail) + guard didStartOlderTail else { + let activeTask = store.forcedRefreshEnrichmentTask + store.cancelForcedRefreshEnrichment() + await activeTask?.value + return + } + + await ProviderInteractionContext.$current.withValue(.userInitiated) { + await store.refresh(enrichmentMode: .forcedBackground) + } + #expect(store.pendingForcedRefreshEnrichmentTask != nil) + + await tokenGate.resumeNext() + let didStartNewerTail = await tokenGate.waitUntilStarted(count: 2) + #expect(didStartNewerTail) + guard didStartNewerTail else { + let tasks = [ + store.forcedRefreshEnrichmentTask, + store.pendingForcedRefreshEnrichmentTask, + ].compactMap(\.self) + store.cancelForcedRefreshEnrichment() + for task in tasks { + await task.value + } + return + } + + #expect(await dashboardLoader.callCount() == 2) + #expect(store.openAIDashboardRequiresLogin) + #expect(providerInteractions == [.background, .userInitiated]) + #expect(creditsInteractions == [.background, .userInitiated]) + + await tokenGate.resumeNext() + await store.awaitForcedRefreshEnrichment() + + #expect(await dashboardLoader.callCount() == 3) + #expect(!store.openAIDashboardRequiresLogin) + #expect(providerInteractions.count == 2) + #expect(creditsInteractions.count == 2) + #expect(!store.hasForcedRefreshEnrichmentInFlight) + } + + @Test + func `older login reconciliation does not replace newer forced provider work`() async throws { + let settings = try self.makeSettingsStore( + suite: "CodexBackgroundRefreshCoalescingTests-login-inflight-generation") + settings.statusChecksEnabled = false + settings.costUsageEnabled = true + let managedAccount = try Self.installManagedAccount( + email: "managed@example.com", + settings: settings) + defer { try? FileManager.default.removeItem(atPath: managedAccount.managedHomePath) } + + let store = self.makeStore(settings: settings) + let providerGate = BlockingSecondProviderRefresh() + let tokenGate = BlockingForcedTokenRefresh() + let dashboardLoader = LoginThenSuccessDashboardLoader(email: managedAccount.email) + var creditsInteractions: [ProviderInteraction] = [] + store._test_providerRefreshOverride = { _ in + await providerGate.run(interaction: ProviderInteractionContext.current) + } + store._test_codexCreditsLoaderOverride = { + creditsInteractions.append(ProviderInteractionContext.current) + return CreditsSnapshot(remaining: 25, events: [], updatedAt: Date()) + } + store._test_tokenUsageRefreshOverride = { provider, force in + await tokenGate.run( + provider: provider, + force: force, + interaction: ProviderInteractionContext.current, + refreshPhase: ProviderRefreshContext.current, + browserRetryAllowed: false) + } + store._test_openAIDashboardLoaderOverride = { _, _, _, _ in + try await dashboardLoader.load() + } + store._test_openAIDashboardCookieImportOverride = { targetEmail, _, _, _, _ in + OpenAIDashboardBrowserCookieImporter.ImportResult( + sourceLabel: "Fixture", + cookieCount: 2, + signedInEmail: targetEmail, + matchesCodexEmail: true) + } + defer { + store._test_providerRefreshOverride = nil + store._test_codexCreditsLoaderOverride = nil + store._test_tokenUsageRefreshOverride = nil + store._test_openAIDashboardLoaderOverride = nil + store._test_openAIDashboardCookieImportOverride = nil + } + + await ProviderInteractionContext.$current.withValue(.background) { + await store.refresh(enrichmentMode: .forcedBackground) + } + let didStartOlderTail = await tokenGate.waitUntilStarted(count: 1) + #expect(didStartOlderTail) + guard didStartOlderTail else { + let activeTask = store.forcedRefreshEnrichmentTask + store.cancelForcedRefreshEnrichment() + await activeTask?.value + return + } + let olderTail = try #require(store.forcedRefreshEnrichmentTask) + + let newerRefresh = Task { @MainActor in + await ProviderInteractionContext.$current.withValue(.userInitiated) { + await store.refresh(enrichmentMode: .forcedBackground) + } + } + let didStartNewerProviderRefresh = await providerGate.waitUntilStarted(count: 2) + #expect(didStartNewerProviderRefresh) + guard didStartNewerProviderRefresh else { + newerRefresh.cancel() + await providerGate.releaseBlockedCall() + store.cancelForcedRefreshEnrichment() + await tokenGate.resumeNext() + await newerRefresh.value + return + } + + let olderTailCompletion = RefreshCompletionProbe() + let olderTailWaiter = Task { + await olderTail.value + await olderTailCompletion.markCompleted() + } + await tokenGate.resumeNext() + let didCompleteOlderTail = await olderTailCompletion.waitUntilCompleted() + #expect(didCompleteOlderTail) + guard didCompleteOlderTail else { + await providerGate.releaseBlockedCall() + await newerRefresh.value + let tasks = [ + store.forcedRefreshEnrichmentTask, + store.pendingForcedRefreshEnrichmentTask, + ].compactMap(\.self) + store.cancelForcedRefreshEnrichment() + for task in tasks { + await task.value + } + await olderTailWaiter.value + return + } + await olderTailWaiter.value + + #expect(await dashboardLoader.callCount() == 2) + #expect(store.openAIDashboardRequiresLogin) + #expect(await providerGate.wasBlockedCallCancelled() == false) + #expect(await providerGate.recordedInteractions() == [.background, .userInitiated]) + #expect(creditsInteractions == [.background]) + + await providerGate.releaseBlockedCall() + await newerRefresh.value + let didStartNewerTail = await tokenGate.waitUntilStarted(count: 2) + #expect(didStartNewerTail) + guard didStartNewerTail else { + let tasks = [ + store.forcedRefreshEnrichmentTask, + store.pendingForcedRefreshEnrichmentTask, + ].compactMap(\.self) + store.cancelForcedRefreshEnrichment() + for task in tasks { + await task.value + } + return + } + await tokenGate.resumeNext() + await store.awaitForcedRefreshEnrichment() + + #expect(await dashboardLoader.callCount() == 3) + #expect(!store.openAIDashboardRequiresLogin) + #expect(await providerGate.wasBlockedCallCancelled() == false) + #expect(await providerGate.recordedInteractions() == [.background, .userInitiated]) + #expect(creditsInteractions == [.background, .userInitiated]) + #expect(!store.hasForcedRefreshEnrichmentInFlight) + } + + @Test + func `cancelling forced enrichment cancels its real dashboard child`() async throws { + let settings = try self.makeSettingsStore( + suite: "CodexBackgroundRefreshCoalescingTests-dashboard-child-cancellation") + settings.statusChecksEnabled = false + settings.costUsageEnabled = false + let managedAccount = try Self.installManagedAccount( + email: "managed@example.com", + settings: settings) + defer { try? FileManager.default.removeItem(atPath: managedAccount.managedHomePath) } + + let store = self.makeStore(settings: settings) + let dashboardLoader = CancellationAwareOpenAIDashboardLoader(email: managedAccount.email) + store._test_providerRefreshOverride = { _ in } + store._test_codexCreditsLoaderOverride = { + CreditsSnapshot(remaining: 25, events: [], updatedAt: Date()) + } + store._test_openAIDashboardLoaderOverride = { _, _, _, _ in + try await dashboardLoader.load() + } + defer { + store._test_providerRefreshOverride = nil + store._test_codexCreditsLoaderOverride = nil + store._test_openAIDashboardLoaderOverride = nil + } + + await store.refresh(enrichmentMode: .forcedBackground) + let capturedEnrichmentTask = store.forcedRefreshEnrichmentTask + let didStartDashboardRefresh = await dashboardLoader.waitUntilStarted() + #expect(didStartDashboardRefresh) + guard didStartDashboardRefresh else { + store.cancelForcedRefreshEnrichment() + await capturedEnrichmentTask?.value + return + } + let enrichmentTask = try #require(capturedEnrichmentTask) + let dashboardTask = try #require(store.openAIDashboardRefreshTask) + + store.cancelForcedRefreshEnrichment() + await enrichmentTask.value + await dashboardTask.value + + #expect(await dashboardLoader.wasCancelled()) + #expect(dashboardTask.isCancelled) + #expect(store.openAIDashboard == nil) + #expect(store.lastOpenAIDashboardError == nil) + #expect(!store.openAIDashboardRequiresLogin) + #expect(store.openAIDashboardRefreshTask == nil) + #expect(store.openAIDashboardBackgroundRefreshTask == nil) + #expect(store.forcedRefreshEnrichmentTask == nil) + #expect(store.pendingForcedRefreshEnrichmentTask == nil) + #expect(!store.hasForcedRefreshEnrichmentInFlight) + } + + @Test + func `forced background enrichment runs dashboard under battery saver with user context`() async throws { + let settings = try self.makeSettingsStore( + suite: "CodexBackgroundRefreshCoalescingTests-forced-dashboard-battery") + settings.statusChecksEnabled = false + settings.costUsageEnabled = false + settings.openAIWebBatterySaverEnabled = true + let managedAccount = try Self.installManagedAccount( + email: "managed@example.com", + settings: settings) + defer { try? FileManager.default.removeItem(atPath: managedAccount.managedHomePath) } + + let store = self.makeStore(settings: settings) + var dashboardInteractions: [ProviderInteraction] = [] + 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 + dashboardInteractions.append(ProviderInteractionContext.current) + return OpenAIDashboardSnapshot( + signedInEmail: managedAccount.email, + codeReviewRemainingPercent: 95, + creditEvents: [], + dailyBreakdown: [], + usageBreakdown: [], + creditsPurchaseURL: nil, + creditsRemaining: 25, + accountPlan: "Pro", + updatedAt: Date()) + } + defer { store._test_openAIDashboardLoaderOverride = nil } + + await BrowserCookieAccessGate.withExplicitRetry { + await ProviderInteractionContext.$current.withValue(.userInitiated) { + await store.refresh(enrichmentMode: .forcedBackground) + } + } + await store.awaitForcedRefreshEnrichment() + + #expect(dashboardInteractions == [.userInitiated]) + #expect(store.openAIDashboard?.signedInEmail == managedAccount.email) + #expect(!store.hasForcedRefreshEnrichmentInFlight) + } +} + +extension CodexBackgroundRefreshCoalescingTests { + private func cancelForcedEnrichmentWork(store: UsageStore) async { + let tasks = [ + store.forcedRefreshEnrichmentTask, + store.pendingForcedRefreshEnrichmentTask, + ].compactMap(\.self) + store.cancelForcedRefreshEnrichment() + for task in tasks { + await task.value + } + } + + private func cancelCreditsWork( + store: UsageStore, + blocker: BlockingCreditsLoader, + tasks: [Task]) async + { + let creditsTask = store.creditsRefreshTask + tasks.forEach { $0.cancel() } + creditsTask?.cancel() + await blocker.cancelAll() + for task in tasks { + await task.value + } + await creditsTask?.value + } + + private func cancelDashboardWork( + store: UsageStore, + blocker: BlockingManagedOpenAIDashboardLoader, + tasks: [Task]) async + { + let dashboardTasks = [ + store.openAIDashboardBackgroundRefreshTask, + store.openAIDashboardRefreshTask, + ].compactMap(\.self) + tasks.forEach { $0.cancel() } + store.invalidateOpenAIDashboardRefreshTask() + await blocker.cancelAll() + for task in tasks { + await task.value + } + for task in dashboardTasks { + await task.value + } + } + + func makeSettingsStore(suite: String) throws -> SettingsStore { + let settings = testSettingsStore(suiteName: suite) let codexMetadata = try #require(ProviderDescriptorRegistry.metadata[.codex]) settings.setProviderEnabled(provider: .codex, metadata: codexMetadata, enabled: true) settings.providerDetectionCompleted = true @@ -250,15 +1189,24 @@ struct CodexBackgroundRefreshCoalescingTests { return settings } - private func makeStore(settings: SettingsStore) -> UsageStore { - UsageStore( - fetcher: UsageFetcher(environment: [:]), + func makeStore(settings: SettingsStore) -> UsageStore { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("codexbar-tests", isDirectory: true) + .appendingPathComponent(UUID().uuidString, isDirectory: true) + let environment = [ + "HOME": root.path, + "CODEX_HOME": root.appendingPathComponent(".codex", isDirectory: true).path, + "XDG_CONFIG_HOME": root.appendingPathComponent(".config", isDirectory: true).path, + ] + return UsageStore( + fetcher: UsageFetcher(environment: environment), browserDetection: BrowserDetection(cacheTTL: 0), settings: settings, - startupBehavior: .testing) + startupBehavior: .testing, + environmentBase: environment) } - private static func installManagedAccount( + static func installManagedAccount( email: String, settings: SettingsStore) throws -> ManagedCodexAccount { @@ -316,44 +1264,316 @@ struct CodexBackgroundRefreshCoalescingTests { } } +actor BlockingForcedTokenRefresh { + struct Call: Sendable { + let provider: UsageProvider + let force: Bool + let interaction: ProviderInteraction + let refreshPhase: ProviderRefreshPhase + let browserRetryAllowed: Bool + } + + private var calls: [Call] = [] + private var continuations: [(id: UUID, continuation: CheckedContinuation)] = [] + + func run( + provider: UsageProvider, + force: Bool, + interaction: ProviderInteraction, + refreshPhase: ProviderRefreshPhase, + browserRetryAllowed: Bool) async + { + let id = UUID() + self.calls.append(Call( + provider: provider, + force: force, + interaction: interaction, + refreshPhase: refreshPhase, + browserRetryAllowed: browserRetryAllowed)) + await withTaskCancellationHandler { + await withCheckedContinuation { (continuation: CheckedContinuation) in + if Task.isCancelled { + continuation.resume() + } else { + self.continuations.append((id: id, continuation: continuation)) + } + } + } onCancel: { + Task { await self.cancel(id: id) } + } + } + + @discardableResult + func waitUntilStarted(count: Int, timeout: Duration = .seconds(5)) async -> Bool { + let deadline = ContinuousClock.now + timeout + while self.calls.count < count { + if ContinuousClock.now >= deadline { + return false + } + try? await Task.sleep(for: .milliseconds(10)) + } + return true + } + + func resumeNext() { + guard !self.continuations.isEmpty else { return } + self.continuations.removeFirst().continuation.resume() + } + + func recordedCalls() -> [Call] { + self.calls + } + + private func cancel(id: UUID) { + guard let index = self.continuations.firstIndex(where: { $0.id == id }) else { return } + self.continuations.remove(at: index).continuation.resume() + } +} + +private actor LoginThenSuccessDashboardLoader { + private let email: String + private var calls = 0 + + init(email: String) { + self.email = email + } + + func load() throws -> OpenAIDashboardSnapshot { + self.calls += 1 + if self.calls <= 2 { + throw OpenAIDashboardFetcher.FetchError.loginRequired + } + return OpenAIDashboardSnapshot( + signedInEmail: self.email, + codeReviewRemainingPercent: 95, + creditEvents: [], + dailyBreakdown: [], + usageBreakdown: [], + creditsPurchaseURL: nil, + creditsRemaining: 25, + accountPlan: "Pro", + updatedAt: Date()) + } + + func callCount() -> Int { + self.calls + } +} + +private actor ForcedRefreshRetrySleepGate { + private var continuation: CheckedContinuation? + private var cancelled = false + + func sleep(_ delay: TimeInterval) async throws { + #expect(delay == 15) + try await withTaskCancellationHandler(operation: { + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + if self.cancelled || Task.isCancelled { + continuation.resume(throwing: CancellationError()) + } else { + self.continuation = continuation + } + } + }, onCancel: { + Task { await self.cancel() } + }) + } + + @discardableResult + func waitUntilSleeping(timeout: Duration = .seconds(5)) async -> Bool { + let deadline = ContinuousClock.now + timeout + while self.continuation == nil { + if ContinuousClock.now >= deadline { + return false + } + try? await Task.sleep(for: .milliseconds(10)) + } + return true + } + + func resume() { + self.continuation?.resume() + self.continuation = nil + } + + private func cancel() { + self.cancelled = true + self.continuation?.resume(throwing: CancellationError()) + self.continuation = nil + } +} + +private actor BlockingSecondProviderRefresh { + private var interactions: [ProviderInteraction] = [] + private var blockedContinuation: CheckedContinuation? + private var blockedCallCancelled = false + private var blockedCallReleased = false + + func run(interaction: ProviderInteraction) async { + self.interactions.append(interaction) + guard self.interactions.count == 2 else { return } + + await withTaskCancellationHandler { + await withCheckedContinuation { (continuation: CheckedContinuation) in + if self.blockedCallCancelled || self.blockedCallReleased || Task.isCancelled { + if Task.isCancelled { + self.blockedCallCancelled = true + } + continuation.resume() + } else { + self.blockedContinuation = continuation + } + } + } onCancel: { + Task { await self.cancelBlockedCall() } + } + } + + func waitUntilStarted(count: Int, timeout: Duration = .seconds(5)) async -> Bool { + let deadline = ContinuousClock.now + timeout + while self.interactions.count < count { + if ContinuousClock.now >= deadline { + return false + } + try? await Task.sleep(for: .milliseconds(10)) + } + return true + } + + func releaseBlockedCall() { + self.blockedCallReleased = true + self.blockedContinuation?.resume() + self.blockedContinuation = nil + } + + func wasBlockedCallCancelled() -> Bool { + self.blockedCallCancelled + } + + func recordedInteractions() -> [ProviderInteraction] { + self.interactions + } + + private func cancelBlockedCall() { + self.blockedCallCancelled = true + self.blockedContinuation?.resume() + self.blockedContinuation = nil + } +} + +private actor CancellationAwareOpenAIDashboardLoader { + private let email: String + private var started = false + private var cancelled = false + + init(email: String) { + self.email = email + } + + func load() async throws -> OpenAIDashboardSnapshot { + self.started = true + + do { + try await Task.sleep(for: .seconds(30)) + } catch is CancellationError { + self.cancelled = true + throw CancellationError() + } + + return OpenAIDashboardSnapshot( + signedInEmail: self.email, + codeReviewRemainingPercent: 95, + creditEvents: [], + dailyBreakdown: [], + usageBreakdown: [], + creditsPurchaseURL: nil, + creditsRemaining: 25, + accountPlan: "Pro", + updatedAt: Date()) + } + + func waitUntilStarted(timeout: Duration = .seconds(5)) async -> Bool { + let deadline = ContinuousClock.now + timeout + while !self.started { + if ContinuousClock.now >= deadline { + return false + } + try? await Task.sleep(for: .milliseconds(10)) + } + return true + } + + func wasCancelled() -> Bool { + self.cancelled + } +} + private actor BlockingOpenAIDashboardCookieImport { - private var continuations: [ - CheckedContinuation, Never> - ] = [] - private var startWaiters: [(count: Int, continuation: CheckedContinuation)] = [] + private typealias ImportResult = OpenAIDashboardBrowserCookieImporter.ImportResult + private typealias ResultContinuation = CheckedContinuation, Never> + + private var continuations: [(id: UUID, continuation: ResultContinuation)] = [] private var started = 0 + private var cancellations = 0 + private var cancelledIDs: Set = [] + private var rejectsNewCalls = false func awaitResult() async throws -> OpenAIDashboardBrowserCookieImporter.ImportResult { - let result = await withCheckedContinuation { continuation in - self.continuations.append(continuation) - self.started += 1 - self.resumeReadyStartWaiters() + let id = UUID() + let result = await withTaskCancellationHandler { + await withCheckedContinuation { (continuation: ResultContinuation) in + if self.rejectsNewCalls || Task.isCancelled { + continuation.resume(returning: .failure(CancellationError())) + } else { + self.continuations.append((id: id, continuation: continuation)) + self.started += 1 + } + } + } onCancel: { + Task { await self.cancel(id: id) } } return try result.get() } - func waitUntilStarted(count: Int = 1) async { - if self.started >= count { return } - await withCheckedContinuation { continuation in - self.startWaiters.append((count: count, continuation: continuation)) + func waitUntilStarted(count: Int = 1, timeout: Duration = .seconds(5)) async -> Bool { + let deadline = ContinuousClock.now + timeout + while self.started < count { + if ContinuousClock.now >= deadline { + return false + } + try? await Task.sleep(for: .milliseconds(10)) + } + return true + } + + func waitUntilCancellationCount(_ count: Int, timeout: Duration = .seconds(5)) async -> Bool { + let deadline = ContinuousClock.now + timeout + while self.cancellations < count { + if ContinuousClock.now >= deadline { + return false + } + try? await Task.sleep(for: .milliseconds(10)) } + return true } func resumeNext(with result: Result) { guard !self.continuations.isEmpty else { return } - let continuation = self.continuations.removeFirst() - continuation.resume(returning: result) + let record = self.continuations.removeFirst() + self.cancelledIDs.remove(record.id) + record.continuation.resume(returning: result) } - private func resumeReadyStartWaiters() { - var remaining: [(count: Int, continuation: CheckedContinuation)] = [] - for waiter in self.startWaiters { - if self.started >= waiter.count { - waiter.continuation.resume() - } else { - remaining.append(waiter) - } - } - self.startWaiters = remaining + func cancelAll() { + self.rejectsNewCalls = true + let continuations = self.continuations + self.continuations.removeAll() + self.cancelledIDs.removeAll() + continuations.forEach { $0.continuation.resume(returning: .failure(CancellationError())) } + } + + private func cancel(id: UUID) { + guard self.continuations.contains(where: { $0.id == id }), self.cancelledIDs.insert(id).inserted else { return } + self.cancellations += 1 } } diff --git a/Tests/CodexBarTests/CodexBarConfigHooksTests.swift b/Tests/CodexBarTests/CodexBarConfigHooksTests.swift new file mode 100644 index 0000000000..cc4158bff1 --- /dev/null +++ b/Tests/CodexBarTests/CodexBarConfigHooksTests.swift @@ -0,0 +1,28 @@ +import CodexBarCore +import Foundation +import Testing + +struct CodexBarConfigHooksTests { + @Test + func `hooks survive config round trip`() throws { + let hooks = HooksConfig( + enabled: true, + events: [ + HookRule( + id: "quota-low", + event: .quotaLow, + provider: "codex", + threshold: 0.9, + executable: "/usr/bin/true", + timeoutSeconds: 30), + ]) + let config = CodexBarConfig( + providers: [ProviderConfig(id: .codex, enabled: true)], + hooks: hooks) + + let data = try JSONEncoder().encode(config) + let decoded = try JSONDecoder().decode(CodexBarConfig.self, from: data) + + #expect(decoded.hooks == hooks) + } +} diff --git a/Tests/CodexBarTests/CodexBarConfigMigratorTests.swift b/Tests/CodexBarTests/CodexBarConfigMigratorTests.swift index 363341ff01..b935a34291 100644 --- a/Tests/CodexBarTests/CodexBarConfigMigratorTests.swift +++ b/Tests/CodexBarTests/CodexBarConfigMigratorTests.swift @@ -110,7 +110,6 @@ struct CodexBarConfigMigratorTests { minimaxCookieStore: secrets, minimaxAPITokenStore: secrets, kimiTokenStore: secrets, - kimiK2TokenStore: secrets, augmentCookieStore: secrets, ampCookieStore: secrets, copilotTokenStore: secrets, @@ -119,7 +118,7 @@ struct CodexBarConfigMigratorTests { } private final class CountingLegacySecretStore: ZaiTokenStoring, SyntheticTokenStoring, CookieHeaderStoring, - MiniMaxCookieStoring, MiniMaxAPITokenStoring, KimiTokenStoring, KimiK2TokenStoring, CopilotTokenStoring, + MiniMaxCookieStoring, MiniMaxAPITokenStoring, KimiTokenStoring, CopilotTokenStoring, @unchecked Sendable { private let lock = NSLock() diff --git a/Tests/CodexBarTests/CodexBarConfigUnknownProviderTests.swift b/Tests/CodexBarTests/CodexBarConfigUnknownProviderTests.swift new file mode 100644 index 0000000000..ebbdfbc620 --- /dev/null +++ b/Tests/CodexBarTests/CodexBarConfigUnknownProviderTests.swift @@ -0,0 +1,25 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct CodexBarConfigUnknownProviderTests { + @Test + func `removed provider entries do not invalidate persisted config`() throws { + let data = Data(#""" + { + "version": 1, + "providers": [ + {"id": "kimik2", "enabled": true}, + {"id": "crossmodel", "enabled": true}, + {"id": "codex", "enabled": false, "source": "oauth"} + ] + } + """#.utf8) + + let decoded = try JSONDecoder().decode(CodexBarConfig.self, from: data) + + #expect(decoded.providers.map(\.id) == [.codex]) + #expect(decoded.providerConfig(for: .codex)?.enabled == false) + #expect(decoded.providerConfig(for: .codex)?.source == .oauth) + } +} diff --git a/Tests/CodexBarTests/CodexBarLaunchModeTests.swift b/Tests/CodexBarTests/CodexBarLaunchModeTests.swift new file mode 100644 index 0000000000..cad79cb9ca --- /dev/null +++ b/Tests/CodexBarTests/CodexBarLaunchModeTests.swift @@ -0,0 +1,27 @@ +import Testing +@testable import CodexBar + +struct CodexBarLaunchModeTests { + @Test + func `normal launch starts the application`() { + #expect(CodexBarLaunchMode.resolve(arguments: ["/Applications/CodexBar"]) == .application) + } + + @Test + func `hook event launch skips application initialization`() { + #expect(CodexBarLaunchMode.resolve( + arguments: ["/Applications/CodexBar", "--hook-event"]) == .hookEvent) + } + + @Test + func `hook event is recognized among other arguments`() { + #expect(CodexBarLaunchMode.resolve( + arguments: ["/Applications/CodexBar", "--verbose", "--hook-event"]) == .hookEvent) + } + + @Test + func `similar argument still starts the application`() { + #expect(CodexBarLaunchMode.resolve( + arguments: ["/Applications/CodexBar", "--hook-events"]) == .application) + } +} diff --git a/Tests/CodexBarTests/CodexBarWidgetProviderTests.swift b/Tests/CodexBarTests/CodexBarWidgetProviderTests.swift index 8d1754bc2a..058d29b112 100644 --- a/Tests/CodexBarTests/CodexBarWidgetProviderTests.swift +++ b/Tests/CodexBarTests/CodexBarWidgetProviderTests.swift @@ -4,6 +4,251 @@ import Testing @testable import CodexBarWidget struct CodexBarWidgetProviderTests { + @Test + func `widget token counts use compact shared formatting`() { + #expect(WidgetFormat.tokenCount(999) == "999 tokens") + #expect(WidgetFormat.tokenCount(9_400_000) == "9.4M tokens") + #expect(WidgetFormat.tokenCount(94_500_000) == "94M tokens") + #expect(WidgetFormat.tokenCount(10_600_000_000) == "11B tokens") + } + + @Test + func `usage display follows remaining and used preference`() { + #expect(WidgetUsageDisplay.percent(fromRemaining: 48, showUsed: false) == 48) + #expect(WidgetUsageDisplay.percent(fromRemaining: 48, showUsed: true) == 52) + #expect(WidgetUsageDisplay.percent(fromRemaining: nil, showUsed: true) == nil) + } + + @Test + func `small widget falls back to local cost when quota rows are unavailable`() { + let tokenUsage = WidgetSnapshot.TokenUsageSummary( + sessionCostUSD: 1.25, + sessionTokens: 4200, + last30DaysCostUSD: 12.50, + last30DaysTokens: 42000, + currencyCode: "USD", + sessionLabel: "Today", + last30DaysLabel: "30d") + let entry = WidgetSnapshot.ProviderEntry( + provider: .claude, + updatedAt: Date(), + primary: nil, + secondary: nil, + tertiary: nil, + usageRows: [], + creditsRemaining: nil, + codeReviewRemainingPercent: nil, + tokenUsage: tokenUsage, + dailyUsage: []) + let windowedEntry = WidgetSnapshot.ProviderEntry( + provider: .claude, + updatedAt: Date(), + primary: RateWindow(usedPercent: 25, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + secondary: nil, + tertiary: nil, + usageRows: nil, + creditsRemaining: nil, + codeReviewRemainingPercent: nil, + tokenUsage: tokenUsage, + dailyUsage: []) + + #expect(WidgetUsageRow.compactTokenUsage(for: entry)?.sessionTokens == 4200) + #expect(WidgetUsageRow.compactTokenUsage(for: windowedEntry) == nil) + } + + @Test + func `small widget limits custom usage rows`() { + let entry = WidgetSnapshot.ProviderEntry( + provider: .antigravity, + updatedAt: Date(), + primary: nil, + secondary: nil, + tertiary: nil, + usageRows: [ + WidgetSnapshot.WidgetUsageRowSnapshot(id: "one", title: "One", percentLeft: 90), + WidgetSnapshot.WidgetUsageRowSnapshot(id: "two", title: "Two", percentLeft: 80), + WidgetSnapshot.WidgetUsageRowSnapshot(id: "three", title: "Three", percentLeft: 70), + WidgetSnapshot.WidgetUsageRowSnapshot(id: "four", title: "Four", percentLeft: 60), + ], + creditsRemaining: nil, + codeReviewRemainingPercent: nil, + tokenUsage: nil, + dailyUsage: []) + + #expect(WidgetUsageRow.rows(for: entry, limit: 2).map(\.id) == ["one", "two"]) + #expect(WidgetUsageRow.rows(for: entry).count == 4) + } + + @Test + func `small antigravity widget keeps one row per quota family`() { + let entry = WidgetSnapshot.ProviderEntry( + provider: .antigravity, + updatedAt: Date(), + primary: nil, + secondary: nil, + tertiary: nil, + usageRows: [ + WidgetSnapshot.WidgetUsageRowSnapshot( + id: "antigravity-quota-summary-gemini-session", + title: "Gemini Models Five Hour Limit", + percentLeft: 80), + WidgetSnapshot.WidgetUsageRowSnapshot( + id: "antigravity-quota-summary-gemini-weekly", + title: "Gemini Models Weekly Limit", + percentLeft: 20), + WidgetSnapshot.WidgetUsageRowSnapshot( + id: "antigravity-quota-summary-third-party-session", + title: "Claude and GPT models Five Hour Limit", + percentLeft: 5), + WidgetSnapshot.WidgetUsageRowSnapshot( + id: "antigravity-quota-summary-third-party-weekly", + title: "Claude and GPT models Weekly Limit", + percentLeft: 60), + ], + creditsRemaining: nil, + codeReviewRemainingPercent: nil, + tokenUsage: nil, + dailyUsage: []) + + let rows = WidgetUsageRow.rows(for: entry, limit: 2) + + #expect(rows.map(\.title) == ["Gemini Models Weekly Limit", "Claude and GPT models Five Hour Limit"]) + #expect(rows.compactMap(\.percentLeft) == [20, 5]) + #expect(WidgetUsageRow.smallWidgetRowLimit(for: entry) == 2) + #expect(WidgetUsageRow.mediumWidgetRowLimit(for: entry) == 3) + let mediumRows = WidgetUsageRow.rows( + for: entry, + limit: WidgetUsageRow.mediumWidgetRowLimit(for: entry)) + #expect(mediumRows.map(\.title) == [ + "Gemini Models Weekly Limit", + "Claude and GPT models Five Hour Limit", + "Claude and GPT models Weekly Limit", + ]) + } + + @Test + func `small antigravity widget keeps claude gpt family when fallback rows are more constrained`() { + let entry = WidgetSnapshot.ProviderEntry( + provider: .antigravity, + updatedAt: Date(), + primary: nil, + secondary: nil, + tertiary: nil, + usageRows: [ + WidgetSnapshot.WidgetUsageRowSnapshot( + id: "antigravity-quota-summary-gemini-5h", + title: "Gemini Models Five Hour Limit", + percentLeft: 40), + WidgetSnapshot.WidgetUsageRowSnapshot( + id: "antigravity-quota-summary-gemini-weekly", + title: "Gemini Models Weekly Limit", + percentLeft: 70), + WidgetSnapshot.WidgetUsageRowSnapshot( + id: "antigravity-quota-summary-3p-5h", + title: "Claude and GPT models Five Hour Limit", + percentLeft: 60), + WidgetSnapshot.WidgetUsageRowSnapshot( + id: "antigravity-quota-summary-other-5h", + title: "Other Five Hour Limit", + percentLeft: 1), + ], + creditsRemaining: nil, + codeReviewRemainingPercent: nil, + tokenUsage: nil, + dailyUsage: []) + + let rows = WidgetUsageRow.rows(for: entry, limit: 2) + + #expect(rows.map(\.id) == [ + "antigravity-quota-summary-gemini-5h", + "antigravity-quota-summary-3p-5h", + ]) + } + + @Test + func `small widget preserves tertiary rows for other providers`() { + let entry = WidgetSnapshot.ProviderEntry( + provider: .cursor, + updatedAt: Date(), + primary: nil, + secondary: nil, + tertiary: nil, + usageRows: [ + WidgetSnapshot.WidgetUsageRowSnapshot(id: "one", title: "One", percentLeft: 90), + WidgetSnapshot.WidgetUsageRowSnapshot(id: "two", title: "Two", percentLeft: 80), + WidgetSnapshot.WidgetUsageRowSnapshot(id: "three", title: "Three", percentLeft: 70), + ], + creditsRemaining: nil, + codeReviewRemainingPercent: nil, + tokenUsage: nil, + dailyUsage: []) + + let limit = WidgetUsageRow.smallWidgetRowLimit(for: entry) + + #expect(limit == nil) + #expect(WidgetUsageRow.rows(for: entry, limit: limit).map(\.id) == ["one", "two", "three"]) + } + + @Test + func `small antigravity widget prefers known quota rows`() { + let entry = WidgetSnapshot.ProviderEntry( + provider: .antigravity, + updatedAt: Date(), + primary: nil, + secondary: nil, + tertiary: nil, + usageRows: [ + WidgetSnapshot.WidgetUsageRowSnapshot( + id: "antigravity-quota-summary-gemini-session", + title: "Gemini Models Five Hour Limit", + percentLeft: nil), + WidgetSnapshot.WidgetUsageRowSnapshot( + id: "antigravity-quota-summary-gemini-weekly", + title: "Gemini Models Weekly Limit", + percentLeft: 100), + WidgetSnapshot.WidgetUsageRowSnapshot( + id: "antigravity-quota-summary-third-party-session", + title: "Claude and GPT models Five Hour Limit", + percentLeft: 80), + ], + creditsRemaining: nil, + codeReviewRemainingPercent: nil, + tokenUsage: nil, + dailyUsage: []) + + let rows = WidgetUsageRow.rows(for: entry, limit: 2) + + #expect(rows.map(\.title) == ["Gemini Models Weekly Limit", "Claude and GPT models Five Hour Limit"]) + } + + @Test + func `small antigravity widget keeps nonstandard quota groups visible`() { + let entry = WidgetSnapshot.ProviderEntry( + provider: .antigravity, + updatedAt: Date(), + primary: nil, + secondary: nil, + tertiary: nil, + usageRows: [ + WidgetSnapshot.WidgetUsageRowSnapshot( + id: "antigravity-quota-summary-other-session", + title: "Other Session", + percentLeft: 70), + WidgetSnapshot.WidgetUsageRowSnapshot( + id: "antigravity-quota-summary-other-weekly", + title: "Other Weekly", + percentLeft: 40), + ], + creditsRemaining: nil, + codeReviewRemainingPercent: nil, + tokenUsage: nil, + dailyUsage: []) + + let rows = WidgetUsageRow.rows(for: entry, limit: 2) + + #expect(rows.map(\.title) == ["Other Weekly", "Other Session"]) + } + @Test func `provider choice supports alibaba`() { #expect(ProviderChoice(provider: .alibaba) == .alibaba) @@ -22,6 +267,163 @@ struct CodexBarWidgetProviderTests { #expect(ProviderChoice.opencodego.provider == .opencodego) } + @Test + func `provider choice supports devin`() { + #expect(ProviderChoice(provider: .devin) == .devin) + #expect(ProviderChoice.devin.provider == .devin) + } + + @Test + func `widget entry carries devin overage balance through providerCost`() { + let entry = WidgetSnapshot.ProviderEntry( + provider: .devin, + updatedAt: Date(timeIntervalSince1970: 1_700_000_000), + primary: nil, + secondary: nil, + tertiary: nil, + creditsRemaining: nil, + codeReviewRemainingPercent: nil, + tokenUsage: nil, + dailyUsage: [], + providerCost: ProviderCostSnapshot( + used: 48.0, + limit: 0, + currencyCode: "USD", + period: "Extra usage balance", + updatedAt: Date(timeIntervalSince1970: 1_700_000_000))) + let encoded = try? JSONEncoder().encode(entry) + #expect(encoded != nil) + let decoded = encoded.flatMap { try? JSONDecoder().decode(WidgetSnapshot.ProviderEntry.self, from: $0) } + #expect(decoded?.providerCost?.period == "Extra usage balance") + #expect(decoded?.providerCost?.used == 48.0) + #expect(decoded?.providerCost?.limit == 0) + #expect(decoded?.provider == .devin) + } + + @Test + func `widget balance formatter renders devin extra usage balance`() { + let entry = WidgetSnapshot.ProviderEntry( + provider: .devin, + updatedAt: Date(timeIntervalSince1970: 1_700_000_000), + primary: nil, + secondary: nil, + tertiary: nil, + creditsRemaining: nil, + codeReviewRemainingPercent: nil, + tokenUsage: nil, + dailyUsage: [], + providerCost: ProviderCostSnapshot( + used: 48.0, + limit: 0, + currencyCode: "USD", + period: "Extra usage balance", + updatedAt: Date(timeIntervalSince1970: 1_700_000_000))) + let line = WidgetBalanceFormatter.extraUsageBalance(for: entry) + #expect(line?.title == "Extra usage") + #expect(line?.value.hasPrefix("Balance: ") == true) + #expect(line?.value.contains("48") == true) + } + + @Test + func `compact credits render Devin extra usage balance`() { + let entry = WidgetSnapshot.ProviderEntry( + provider: .devin, + updatedAt: Date(timeIntervalSince1970: 1_700_000_000), + primary: nil, + secondary: nil, + tertiary: nil, + creditsRemaining: nil, + codeReviewRemainingPercent: nil, + tokenUsage: nil, + dailyUsage: [], + providerCost: ProviderCostSnapshot( + used: 48.0, + limit: 0, + currencyCode: "USD", + period: "Extra usage balance", + updatedAt: Date(timeIntervalSince1970: 1_700_000_000))) + + let display = CompactMetricFormatter.display(for: entry, metric: .credits) + + #expect(display.value.contains("48")) + #expect(display.label == "Extra usage balance") + #expect(display.detail == nil) + } + + @Test + func `widget balance formatter does not leak another provider balance`() { + let entry = WidgetSnapshot.ProviderEntry( + provider: .factory, + updatedAt: Date(timeIntervalSince1970: 1_700_000_000), + primary: nil, + secondary: nil, + tertiary: nil, + creditsRemaining: nil, + codeReviewRemainingPercent: nil, + tokenUsage: nil, + dailyUsage: [], + providerCost: ProviderCostSnapshot( + used: 12.0, + limit: 100.0, + currencyCode: "USD", + period: "Extra usage balance", + updatedAt: Date(timeIntervalSince1970: 1_700_000_000))) + #expect(WidgetBalanceFormatter.extraUsageBalance(for: entry) == nil) + } + + @Test + func `provider choice supports Mistral`() { + #expect(ProviderChoice(provider: .mistral) == .mistral) + #expect(ProviderChoice.mistral.provider == .mistral) + } + + @Test + func `provider choice supports Kimi`() { + #expect(ProviderChoice(provider: .kimi) == .kimi) + #expect(ProviderChoice.kimi.provider == .kimi) + } + + @Test + func `compact Kimi widgets keep established row fit while large widgets show all quotas`() { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let entry = WidgetSnapshot.ProviderEntry( + provider: .kimi, + updatedAt: now, + primary: RateWindow(usedPercent: 25, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: RateWindow(usedPercent: 50, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + tertiary: nil, + usageRows: [ + WidgetSnapshot.WidgetUsageRowSnapshot(id: "primary", title: "Weekly", percentLeft: 75), + WidgetSnapshot.WidgetUsageRowSnapshot(id: "secondary", title: "Rate Limit", percentLeft: 50), + WidgetSnapshot.WidgetUsageRowSnapshot(id: "kimi-monthly", title: "Monthly", percentLeft: 25), + WidgetSnapshot.WidgetUsageRowSnapshot(id: "kimi-code-7d", title: "Code 7-day", percentLeft: 90), + ], + creditsRemaining: nil, + codeReviewRemainingPercent: nil, + tokenUsage: nil, + dailyUsage: []) + + let smallRows = WidgetUsageRow.rows( + for: entry, + limit: WidgetUsageRow.smallWidgetRowLimit(for: entry)) + let mediumRows = WidgetUsageRow.rows( + for: entry, + limit: WidgetUsageRow.mediumWidgetRowLimit(for: entry)) + let largeRows = WidgetUsageRow.rows(for: entry) + + #expect(WidgetUsageRow.smallWidgetRowLimit(for: entry) == 3) + #expect(WidgetUsageRow.mediumWidgetRowLimit(for: entry) == 3) + #expect(smallRows.map(\.id) == ["primary", "secondary", "kimi-monthly"]) + #expect(mediumRows == smallRows) + #expect(largeRows.map(\.id) == ["primary", "secondary", "kimi-monthly", "kimi-code-7d"]) + } + + @Test + func `provider choice excludes unsupported Chutes widgets`() { + #expect(ProviderChoice(provider: .chutes) == nil) + #expect(ProviderChoice(provider: .sub2api) == nil) + } + @Test func `supported providers fall back to codex when snapshot is empty`() { let snapshot = WidgetSnapshot(entries: [], enabledProviders: [], generatedAt: Date()) @@ -65,6 +467,42 @@ struct CodexBarWidgetProviderTests { #expect(CodexBarSwitcherTimelineProvider.supportedProviders(from: snapshot) == [.alibabatokenplan]) } + @Test + func `supported providers keep Mistral when it is the only enabled provider`() { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let entry = WidgetSnapshot.ProviderEntry( + provider: .mistral, + updatedAt: now, + primary: nil, + secondary: nil, + tertiary: nil, + creditsRemaining: nil, + codeReviewRemainingPercent: nil, + tokenUsage: nil, + dailyUsage: []) + let snapshot = WidgetSnapshot(entries: [entry], enabledProviders: [.mistral], generatedAt: now) + + #expect(CodexBarSwitcherTimelineProvider.supportedProviders(from: snapshot) == [.mistral]) + } + + @Test + func `supported providers keep Kimi when it is the only enabled provider`() { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let entry = WidgetSnapshot.ProviderEntry( + provider: .kimi, + updatedAt: now, + primary: RateWindow(usedPercent: 25, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: RateWindow(usedPercent: 50, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + tertiary: nil, + creditsRemaining: nil, + codeReviewRemainingPercent: nil, + tokenUsage: nil, + dailyUsage: []) + let snapshot = WidgetSnapshot(entries: [entry], enabledProviders: [.kimi], generatedAt: now) + + #expect(CodexBarSwitcherTimelineProvider.supportedProviders(from: snapshot) == [.kimi]) + } + @Test func `codex weekly only widget rows omit session`() { let now = Date(timeIntervalSince1970: 1_700_000_000) @@ -130,7 +568,51 @@ struct CodexBarWidgetProviderTests { } @Test - func `legacy widget usage rows include tertiary slot when supported`() { + func `codex widget session cap lifts at weekly reset without a new snapshot`() { + let now = Date(timeIntervalSince1970: 1_800_000_000) + let weeklyReset = now.addingTimeInterval(3600) + let sessionWindow = RateWindow( + usedPercent: 1, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(1800), + resetDescription: nil) + let weeklyWindow = RateWindow( + usedPercent: 100, + windowMinutes: 10080, + resetsAt: weeklyReset, + resetDescription: nil) + let entry = WidgetSnapshot.ProviderEntry( + provider: .codex, + updatedAt: now.addingTimeInterval(-7200), + primary: sessionWindow, + secondary: weeklyWindow, + tertiary: nil, + usageRows: [ + WidgetSnapshot.WidgetUsageRowSnapshot( + id: "session", + title: "Session", + percentLeft: 99, + window: sessionWindow), + WidgetSnapshot.WidgetUsageRowSnapshot( + id: "weekly", + title: "Weekly", + percentLeft: 0, + window: weeklyWindow), + ], + creditsRemaining: nil, + codeReviewRemainingPercent: nil, + tokenUsage: nil, + dailyUsage: []) + + let capped = WidgetUsageRow.rows(for: entry, now: now) + let reset = WidgetUsageRow.rows(for: entry, now: weeklyReset) + + #expect(capped.map(\.percentLeft) == [0, 0]) + #expect(reset.map(\.percentLeft) == [99, 0]) + } + + @Test + func `legacy widget usage rows use antigravity grouped slots`() { let now = Date(timeIntervalSince1970: 1_700_000_000) let entry = WidgetSnapshot.ProviderEntry( provider: .antigravity, @@ -145,18 +627,376 @@ struct CodexBarWidgetProviderTests { let rows = WidgetUsageRow.rows(for: entry) - #expect(rows.map(\.id) == ["primary", "secondary", "tertiary"]) - #expect(rows.map(\.title) == ["Claude", "Gemini Pro", "Gemini Flash"]) - #expect(rows.compactMap(\.percentLeft) == [90, 80, 70]) + #expect(rows.map(\.id) == ["primary", "secondary"]) + #expect(rows.map(\.title) == ["Gemini Models", "Claude and GPT"]) + #expect(rows.compactMap(\.percentLeft) == [90, 80]) } @Test func `widget configuration intents default to codex and credits`() { let providerIntent = ProviderSelectionIntent() let compactIntent = CompactMetricSelectionIntent() + let burnIntent = BurnDownSelectionIntent() + let combinedBurnIntent = BurnProviderSelectionIntent() #expect(providerIntent.provider == .codex) #expect(compactIntent.provider == .codex) #expect(compactIntent.metric == .credits) + #expect(burnIntent.provider == .codex) + #expect(burnIntent.window == .session) + #expect(combinedBurnIntent.provider == .codex) + } + + @Test + func `burn down uses an exact provider entry`() { + let snapshot = Self.burnSnapshot(provider: .claude, primaryUsed: 20, secondaryUsed: 30) + + #expect(BurnDownState(snapshot: snapshot, provider: .codex, selection: .session) == nil) + #expect(BurnDownState(snapshot: snapshot, provider: .claude, selection: .session) != nil) + } + + @Test + func `codex exhausted weekly cap blocks the session chart until weekly reset`() throws { + let weeklyReset = Date(timeIntervalSince1970: 1_800_000_000) + let snapshot = Self.burnSnapshot( + provider: .codex, + primaryUsed: 80, + secondaryUsed: 100, + primaryReset: weeklyReset.addingTimeInterval(-3600), + secondaryReset: weeklyReset) + let state = try #require(BurnDownState(snapshot: snapshot, provider: .codex, selection: .session)) + + #expect(state.secondaryGloballyCapsPrimary) + #expect(state.primaryWindow?.remainingPercent == 0) + #expect(state.blankPrimaryChart) + #expect(state.selectedResetOverride == weeklyReset) + } + + @Test + func `gemini exhausted secondary window does not block the independent primary`() throws { + let primaryReset = Date(timeIntervalSince1970: 1_800_000_000) + let snapshot = Self.burnSnapshot( + provider: .gemini, + primaryUsed: 20, + secondaryUsed: 100, + primaryReset: primaryReset, + secondaryReset: primaryReset.addingTimeInterval(-3600)) + let state = try #require(BurnDownState(snapshot: snapshot, provider: .gemini, selection: .session)) + + #expect(!state.secondaryGloballyCapsPrimary) + #expect(state.primaryWindow?.remainingPercent == 80) + #expect(!state.blankPrimaryChart) + #expect(state.selectedResetOverride == nil) + } + + @Test + func `independent secondary reset never overrides primary reset`() throws { + let primaryReset = Date(timeIntervalSince1970: 1_800_000_000) + let snapshot = Self.burnSnapshot( + provider: .gemini, + primaryUsed: 20, + secondaryUsed: 30, + primaryReset: primaryReset, + secondaryReset: primaryReset.addingTimeInterval(-3600)) + let state = try #require(BurnDownState(snapshot: snapshot, provider: .gemini, selection: .session)) + + #expect(state.selectedWindow?.resetsAt == primaryReset) + #expect(state.selectedResetOverride == nil) + } + + @Test + func `burn down preview includes session and weekly windows`() throws { + let snapshot = WidgetPreviewData.snapshot() + + let session = try #require(BurnDownState(snapshot: snapshot, provider: .codex, selection: .session)) + let weekly = try #require(BurnDownState(snapshot: snapshot, provider: .codex, selection: .weekly)) + + #expect(session.selectedWindow?.windowMinutes == 300) + #expect(weekly.selectedWindow?.windowMinutes == 10080) + } + + @Test + func `burn down selection does not fall back to another window`() throws { + let weeklyOnly = Self.burnSnapshot(provider: .codex, primaryUsed: nil, secondaryUsed: 30) + let sessionOnly = Self.burnSnapshot(provider: .codex, primaryUsed: 20, secondaryUsed: nil) + let weeklyStoredInPrimary = Self.burnSnapshot( + provider: .claude, + primaryUsed: 30, + secondaryUsed: nil, + primaryWindowMinutes: 7 * 24 * 60) + + let weeklyOnlySession = try #require(BurnDownState( + snapshot: weeklyOnly, + provider: .codex, + selection: .session)) + let weeklyOnlyWeekly = try #require(BurnDownState( + snapshot: weeklyOnly, + provider: .codex, + selection: .weekly)) + let sessionOnlySession = try #require(BurnDownState( + snapshot: sessionOnly, + provider: .codex, + selection: .session)) + let sessionOnlyWeekly = try #require(BurnDownState( + snapshot: sessionOnly, + provider: .codex, + selection: .weekly)) + let weeklyPrimarySession = try #require(BurnDownState( + snapshot: weeklyStoredInPrimary, + provider: .claude, + selection: .session)) + let weeklyPrimaryWeekly = try #require(BurnDownState( + snapshot: weeklyStoredInPrimary, + provider: .claude, + selection: .weekly)) + + #expect(weeklyOnlySession.selectedWindow == nil) + #expect(weeklyOnlyWeekly.selectedWindow == weeklyOnlyWeekly.secondaryWindow) + #expect(sessionOnlySession.selectedWindow == sessionOnlySession.primaryWindow) + #expect(sessionOnlyWeekly.selectedWindow == nil) + #expect(weeklyPrimarySession.selectedWindow == nil) + #expect(weeklyPrimaryWeekly.selectedWindow?.usedPercent == 30) + } + + @Test + func `expired weekly reset no longer blocks the session chart`() throws { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let snapshot = Self.burnSnapshot( + provider: .codex, + primaryUsed: 20, + secondaryUsed: 100, + primaryReset: now.addingTimeInterval(300), + secondaryReset: now.addingTimeInterval(-1)) + let state = try #require(BurnDownState( + snapshot: snapshot, + provider: .codex, + selection: .session, + now: now)) + + #expect(!state.secondaryExhausted) + #expect(state.primaryWindow?.remainingPercent == 80) + #expect(!state.blankPrimaryChart) + #expect(state.selectedResetOverride == nil) + } + + @Test + func `explicit reset takes precedence over estimated reset`() { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let future = now.addingTimeInterval(600) + + #expect(burnEffectiveResetDate( + explicitResetAt: now.addingTimeInterval(-1), + estimatedResetMinutes: 5, + now: now) == nil) + #expect(burnEffectiveResetDate( + explicitResetAt: future, + estimatedResetMinutes: 5, + now: now) == future) + #expect(burnEffectiveResetDate( + explicitResetAt: nil, + estimatedResetMinutes: 5, + now: now) == now.addingTimeInterval(300)) + #expect(burnEffectiveResetDate( + explicitResetAt: nil, + estimatedResetMinutes: nil, + now: now) == nil) + } + + @Test + func `burn down axis shares the effective estimated reset`() throws { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let effectiveReset = try #require(burnEffectiveResetDate( + explicitResetAt: nil, + estimatedResetMinutes: 90, + now: now)) + + let axis = burnAxisDateRange( + effectiveResetAt: effectiveReset, + windowMinutes: 300, + now: now) + + #expect(axis.reset == effectiveReset) + #expect(axis.start == effectiveReset.addingTimeInterval(-5 * 60 * 60)) + } + + @Test + func `burn down refreshes immediately after the earliest future reset`() { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let snapshot = Self.burnSnapshot( + provider: .codex, + primaryUsed: 20, + secondaryUsed: 30, + primaryReset: now.addingTimeInterval(360), + secondaryReset: now.addingTimeInterval(420)) + + #expect(BurnDownRefreshSchedule.nextRefresh(snapshot: snapshot, provider: .codex, now: now) + == now.addingTimeInterval(361)) + } + + @Test + func `burn down refresh clamps to minimum interval`() { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let snapshot = Self.burnSnapshot( + provider: .codex, + primaryUsed: 20, + secondaryUsed: 30, + primaryReset: now.addingTimeInterval(60), + secondaryReset: now.addingTimeInterval(120)) + + #expect(BurnDownRefreshSchedule.nextRefresh(snapshot: snapshot, provider: .codex, now: now) + == now.addingTimeInterval(300)) + } + + @Test + func `burn down refresh ignores past resets and unrelated provider entries`() { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let snapshot = Self.burnSnapshot( + provider: .claude, + primaryUsed: 20, + secondaryUsed: 30, + primaryReset: now.addingTimeInterval(-60), + secondaryReset: now.addingTimeInterval(-30)) + let fallback = now.addingTimeInterval(30 * 60) + + #expect(BurnDownRefreshSchedule.nextRefresh(snapshot: snapshot, provider: .claude, now: now) == fallback) + #expect(BurnDownRefreshSchedule.nextRefresh(snapshot: snapshot, provider: .codex, now: now) == fallback) + } + + private static func burnSnapshot( + provider: UsageProvider, + primaryUsed: Double?, + secondaryUsed: Double?, + primaryReset: Date? = nil, + secondaryReset: Date? = nil, + primaryWindowMinutes: Int = 5 * 60, + secondaryWindowMinutes: Int = 7 * 24 * 60) -> WidgetSnapshot + { + let entry = WidgetSnapshot.ProviderEntry( + provider: provider, + updatedAt: Date(timeIntervalSince1970: 1_700_000_000), + primary: primaryUsed.map { + RateWindow( + usedPercent: $0, + windowMinutes: primaryWindowMinutes, + resetsAt: primaryReset, + resetDescription: nil) + }, + secondary: secondaryUsed.map { + RateWindow( + usedPercent: $0, + windowMinutes: secondaryWindowMinutes, + resetsAt: secondaryReset, + resetDescription: nil) + }, + tertiary: nil, + creditsRemaining: nil, + codeReviewRemainingPercent: nil, + tokenUsage: nil, + dailyUsage: []) + return WidgetSnapshot(entries: [entry], generatedAt: entry.updatedAt) + } +} + +extension CodexBarWidgetProviderTests { + @Test + func `provider choice supports Cursor`() { + #expect(ProviderChoice(provider: .cursor) == .cursor) + #expect(ProviderChoice.cursor.provider == .cursor) + } + + @Test + func `supported providers keep Cursor when it is the only enabled provider`() { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let entry = WidgetSnapshot.ProviderEntry( + provider: .cursor, + updatedAt: now, + primary: RateWindow(usedPercent: 25, windowMinutes: 43200, resetsAt: nil, resetDescription: nil), + secondary: nil, + tertiary: nil, + creditsRemaining: nil, + codeReviewRemainingPercent: nil, + tokenUsage: nil, + dailyUsage: []) + let snapshot = WidgetSnapshot(entries: [entry], enabledProviders: [.cursor], generatedAt: now) + + #expect(CodexBarSwitcherTimelineProvider.supportedProviders(from: snapshot) == [.cursor]) + } + + @Test + func `widget token titles disclose stale age for today and history rows`() { + let entryUpdatedAt = Date() + let staleToken = WidgetSnapshot.TokenUsageSummary( + sessionCostUSD: 1.25, + sessionTokens: 4200, + last30DaysCostUSD: 12.50, + last30DaysTokens: 42000, + updatedAt: entryUpdatedAt.addingTimeInterval(-45 * 60)) + let freshToken = WidgetSnapshot.TokenUsageSummary( + sessionCostUSD: 1.25, + sessionTokens: 4200, + last30DaysCostUSD: 12.50, + last30DaysTokens: 42000, + updatedAt: entryUpdatedAt.addingTimeInterval(-5 * 60)) + + let todayTitle = WidgetFormat.tokenRowTitle( + staleToken.sessionLabel, + summary: staleToken, + entryUpdatedAt: entryUpdatedAt) + let historyTitle = WidgetFormat.tokenRowTitle( + staleToken.last30DaysLabel, + summary: staleToken, + entryUpdatedAt: entryUpdatedAt) + + #expect(todayTitle.hasPrefix("Today · ")) + #expect(historyTitle.hasPrefix("30d · ")) + #expect(WidgetFormat.tokenRowTitle( + freshToken.sessionLabel, + summary: freshToken, + entryUpdatedAt: entryUpdatedAt) == "Today") + + let entry = WidgetSnapshot.ProviderEntry( + provider: .codex, + updatedAt: entryUpdatedAt, + primary: nil, + secondary: nil, + tertiary: nil, + creditsRemaining: nil, + codeReviewRemainingPercent: nil, + tokenUsage: staleToken, + dailyUsage: []) + let todayMetric = CompactMetricFormatter.display(for: entry, metric: .todayCost) + let historyMetric = CompactMetricFormatter.display(for: entry, metric: .last30DaysCost) + + #expect(todayMetric.label.hasPrefix("Today API est. · not billed · ")) + #expect(historyMetric.label.hasPrefix("30d API est. · not billed · ")) + #expect(CompactMetricFormatter.costMetricLabel("7d", provider: .codex) == "7d API est. · not billed") + #expect(CompactMetricFormatter.costMetricLabel("90d", provider: .codex) == "90d API est. · not billed") + #expect(CompactMetricFormatter.costMetricLabel("This month", provider: .codex) == + "This month API est. · not billed") + #expect(CompactMetricFormatter.costMetricLabel( + "This month API est. · not billed", + provider: .codex) == "This month API est. · not billed") + } + + @Test + func `usage history chart mode requires every point to expose cost`() { + let costPoints = [ + WidgetSnapshot.DailyUsagePoint(dayKey: "2026-07-01", totalTokens: 100, costUSD: 1.2), + WidgetSnapshot.DailyUsagePoint(dayKey: "2026-07-02", totalTokens: 200, costUSD: 2.4), + ] + let tokenPoints = [ + WidgetSnapshot.DailyUsagePoint(dayKey: "2026-07-01", totalTokens: 100, costUSD: nil), + WidgetSnapshot.DailyUsagePoint(dayKey: "2026-07-02", totalTokens: 200, costUSD: nil), + ] + let mixedPoints = [ + WidgetSnapshot.DailyUsagePoint(dayKey: "2026-07-01", totalTokens: 100, costUSD: 1.2), + WidgetSnapshot.DailyUsagePoint(dayKey: "2026-07-02", totalTokens: 200, costUSD: nil), + ] + let emptyPoints: [WidgetSnapshot.DailyUsagePoint] = [] + + #expect(UsageHistoryChartMode.isCostMode(costPoints) == true) + #expect(UsageHistoryChartMode.isCostMode(tokenPoints) == false) + #expect(UsageHistoryChartMode.isCostMode(mixedPoints) == false) + #expect(UsageHistoryChartMode.isCostMode(emptyPoints) == false) } } diff --git a/Tests/CodexBarTests/CodexBaselineCharacterizationTests.swift b/Tests/CodexBarTests/CodexBaselineCharacterizationTests.swift index a131db5ab9..fec7fe5c99 100644 --- a/Tests/CodexBarTests/CodexBaselineCharacterizationTests.swift +++ b/Tests/CodexBarTests/CodexBaselineCharacterizationTests.swift @@ -9,9 +9,19 @@ struct CodexBaselineCharacterizationTests { sourceMode: ProviderSourceMode, env: [String: String] = [:], settings: ProviderSettingsSnapshot? = nil, - includeCredits: Bool = false) -> ProviderFetchContext + includeCredits: Bool = false, + codexArguments: [String]? = nil) -> ProviderFetchContext { let browserDetection = BrowserDetection(cacheTTL: 0) + let fetcher = if let codexArguments { + UsageFetcher( + environment: env, + initializeTimeoutSeconds: 20.0, + requestTimeoutSeconds: 3.0, + codexArguments: codexArguments) + } else { + UsageFetcher(environment: env, initializeTimeoutSeconds: 20.0, requestTimeoutSeconds: 3.0) + } return ProviderFetchContext( runtime: runtime, sourceMode: sourceMode, @@ -21,7 +31,7 @@ struct CodexBaselineCharacterizationTests { verbose: false, env: env, settings: settings, - fetcher: UsageFetcher(environment: env, initializeTimeoutSeconds: 20.0, requestTimeoutSeconds: 3.0), + fetcher: fetcher, claudeFetcher: ClaudeUsageFetcher(browserDetection: browserDetection), browserDetection: browserDetection) } @@ -43,7 +53,8 @@ struct CodexBaselineCharacterizationTests { sourceMode: ProviderSourceMode, env: [String: String] = [:], settings: ProviderSettingsSnapshot? = nil, - includeCredits: Bool = false) async -> ProviderFetchOutcome + includeCredits: Bool = false, + codexArguments: [String]? = nil) async -> ProviderFetchOutcome { let descriptor = ProviderDescriptorRegistry.descriptor(for: .codex) let context = self.makeContext( @@ -51,81 +62,56 @@ struct CodexBaselineCharacterizationTests { sourceMode: sourceMode, env: env, settings: settings, - includeCredits: includeCredits) + includeCredits: includeCredits, + codexArguments: codexArguments) return await descriptor.fetchPlan.fetchOutcome(context: context, provider: .codex) } - private func makeStubCodexCLI() throws -> String { + private struct StubCodexCLI { + let executable: String + let arguments: [String] + } + + private func makeStubCodexCLI() -> StubCodexCLI { let script = """ - #!/usr/bin/python3 -S - import json - import os - import sys - - counter = os.environ.get("CODEXBAR_STUB_COUNTER") - if counter: - with open(counter, "a") as f: - f.write("start\\n") - credits_only = os.environ.get("CODEXBAR_STUB_CREDITS_ONLY") == "1" - - for line in sys.stdin: - if not line.strip(): - continue - message = json.loads(line) - method = message.get("method") - if method == "initialized": - continue - - identifier = message.get("id") - if method == "initialize": - payload = {"id": identifier, "result": {}} - elif method == "account/rateLimits/read": - rate_limits = { - "credits": { - "hasCredits": True, - "unlimited": False, - "balance": "7" - } - } - if not credits_only: - rate_limits["primary"] = { - "usedPercent": 12, - "windowDurationMins": 300, - "resetsAt": 1766948068 - } - rate_limits["secondary"] = { - "usedPercent": 43, - "windowDurationMins": 10080, - "resetsAt": 1767407914 - } - payload = { - "id": identifier, - "result": { - "rateLimits": rate_limits - } - } - elif method == "account/read": - payload = { - "id": identifier, - "result": { - "account": { - "type": "chatgpt", - "email": "stub@example.com", - "planType": "pro" - }, - "requiresOpenaiAuth": False - } - } - else: - payload = {"id": identifier, "result": {}} - - print(json.dumps(payload), flush=True) + if [ -n "${CODEXBAR_STUB_COUNTER:-}" ]; then + printf '%s\\n' start >> "$CODEXBAR_STUB_COUNTER" + fi + + while IFS= read -r line; do + case "$line" in + *'"method":"initialized"'*|*'"method": "initialized"'*) + ;; + *'"method":"initialize"'*|*'"method": "initialize"'*) + printf '%s\\n' '{"id":1,"result":{}}' + ;; + *'"method"'*account*rateLimits*read*) + if [ "${CODEXBAR_STUB_CREDITS_ONLY:-}" = "1" ]; then + response='{"id":2,"result":{"rateLimits":{"credits":' + response="${response}"'{"hasCredits":true,"unlimited":false,"balance":"7"}}}}' + printf '%s\\n' "$response" + else + response='{"id":2,"result":{"rateLimits":{"credits":' + response="${response}"'{"hasCredits":true,"unlimited":false,"balance":"7"},' + if [ "${CODEXBAR_STUB_MONTHLY_LIMIT:-}" = "1" ]; then + response="${response}"'"individualLimit":{"limit":100000,"used":7761,' + response="${response}"'"remainingPercent":92.239,"resetsAt":1782864000},' + fi + response="${response}"'"primary":{"usedPercent":12,"windowDurationMins":300,"resetsAt":1766948068},' + response="${response}"'"secondary":{"usedPercent":43,"windowDurationMins":10080,' + response="${response}"'"resetsAt":1767407914}}}}' + printf '%s\\n' "$response" + fi + ;; + *'"method"'*account*read*) + response='{"id":3,"result":{"account":{"type":"chatgpt","email":"stub@example.com",' + response="${response}"'"planType":"pro"},"requiresOpenaiAuth":false}}' + printf '%s\\n' "$response" + ;; + esac + done """ - let url = FileManager.default.temporaryDirectory - .appendingPathComponent("codex-stub-\(UUID().uuidString)", isDirectory: false) - try Data(script.utf8).write(to: url) - try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: url.path) - return url.path + return StubCodexCLI(executable: "/bin/sh", arguments: ["-c", script]) } private func makeEmptyCodexHome() throws -> URL { @@ -161,9 +147,9 @@ struct CodexBaselineCharacterizationTests { } @Test - func `CLI auto pipeline order is web then OAuth then CLI`() async { + func `CLI auto pipeline order is OAuth then CLI without web`() async { let strategyIDs = await self.strategyIDs(runtime: .cli, sourceMode: .auto) - #expect(strategyIDs == ["codex.web.dashboard", "codex.oauth", "codex.cli"]) + #expect(strategyIDs == ["codex.oauth", "codex.cli"]) } @Test @@ -187,15 +173,19 @@ struct CodexBaselineCharacterizationTests { @Test func `app auto records unavailable OAuth before successful CLI fallback`() async throws { - let stubCLIPath = try self.makeStubCodexCLI() + let stubCLI = self.makeStubCodexCLI() let codexHome = try self.makeEmptyCodexHome() defer { try? FileManager.default.removeItem(at: codexHome) } let env = [ - "CODEX_CLI_PATH": stubCLIPath, + "CODEX_CLI_PATH": stubCLI.executable, "CODEX_HOME": codexHome.path, ] - let outcome = await self.fetchOutcome(runtime: .app, sourceMode: .auto, env: env) + let outcome = await self.fetchOutcome( + runtime: .app, + sourceMode: .auto, + env: env, + codexArguments: stubCLI.arguments) #expect(outcome.attempts.map(\.strategyID) == ["codex.oauth", "codex.cli"]) #expect(outcome.attempts.map(\.wasAvailable) == [false, true]) @@ -212,16 +202,20 @@ struct CodexBaselineCharacterizationTests { @Test func `app auto does not fall back from non auth failing OAuth`() async throws { - let stubCLIPath = try self.makeStubCodexCLI() + let stubCLI = self.makeStubCodexCLI() let oauthHome = try self.makeUnavailableOAuthHome() defer { try? FileManager.default.removeItem(at: oauthHome) } let env = [ - "CODEX_CLI_PATH": stubCLIPath, + "CODEX_CLI_PATH": stubCLI.executable, "CODEX_HOME": oauthHome.path, ] - let outcome = await self.fetchOutcome(runtime: .app, sourceMode: .auto, env: env) + let outcome = await self.fetchOutcome( + runtime: .app, + sourceMode: .auto, + env: env, + codexArguments: stubCLI.arguments) #expect(outcome.attempts.map(\.strategyID) == ["codex.oauth"]) #expect(outcome.attempts.map(\.wasAvailable) == [true]) @@ -243,15 +237,14 @@ struct CodexBaselineCharacterizationTests { } @Test - func `Codex CLI strategy fetches usage and credits with one app-server process`() async throws { - let stubCLIPath = try self.makeStubCodexCLI() - defer { try? FileManager.default.removeItem(atPath: stubCLIPath) } + func `Codex CLI strategy fetches usage and credits with one app-server process`() async { + let stubCLI = self.makeStubCodexCLI() let counterURL = FileManager.default.temporaryDirectory .appendingPathComponent("codex-stub-counter-\(UUID().uuidString)", isDirectory: false) defer { try? FileManager.default.removeItem(at: counterURL) } let env = [ - "CODEX_CLI_PATH": stubCLIPath, + "CODEX_CLI_PATH": stubCLI.executable, "CODEXBAR_STUB_COUNTER": counterURL.path, ] @@ -259,7 +252,8 @@ struct CodexBaselineCharacterizationTests { runtime: .app, sourceMode: .cli, env: env, - includeCredits: true) + includeCredits: true, + codexArguments: stubCLI.arguments) switch outcome.result { case let .success(result): @@ -277,24 +271,25 @@ struct CodexBaselineCharacterizationTests { } @Test - func `Codex CLI strategy keeps credits when rate limit windows are absent`() async throws { - let stubCLIPath = try self.makeStubCodexCLI() - defer { try? FileManager.default.removeItem(atPath: stubCLIPath) } + func `Codex CLI strategy keeps credits when rate limit windows are absent`() async { + let stubCLI = self.makeStubCodexCLI() let outcome = await self.fetchOutcome( runtime: .app, sourceMode: .cli, env: [ - "CODEX_CLI_PATH": stubCLIPath, + "CODEX_CLI_PATH": stubCLI.executable, "CODEXBAR_STUB_CREDITS_ONLY": "1", ], - includeCredits: true) + includeCredits: true, + codexArguments: stubCLI.arguments) switch outcome.result { case let .success(result): #expect(result.sourceLabel == "codex-cli") #expect(result.usage.primary == nil) #expect(result.usage.secondary == nil) + #expect(result.usage.accountEmail(for: .codex) == "stub@example.com") #expect(result.credits?.remaining == 7) case let .failure(error): Issue.record("Unexpected failure: \(error)") @@ -302,8 +297,35 @@ struct CodexBaselineCharacterizationTests { } @Test - func `CLI auto records unavailable web and OAuth before successful CLI`() async throws { - let stubCLIPath = try self.makeStubCodexCLI() + func `Codex CLI strategy maps monthly credit limit`() async { + let stubCLI = self.makeStubCodexCLI() + + let outcome = await self.fetchOutcome( + runtime: .app, + sourceMode: .cli, + env: [ + "CODEX_CLI_PATH": stubCLI.executable, + "CODEXBAR_STUB_MONTHLY_LIMIT": "1", + ], + includeCredits: true, + codexArguments: stubCLI.arguments) + + switch outcome.result { + case let .success(result): + let limit = try? #require(result.credits?.codexCreditLimit) + #expect(limit?.limit == 100_000) + #expect(limit?.used == 7761) + #expect(limit?.remaining == 92239) + #expect(limit?.remainingPercent == 92.239) + #expect(limit?.resetsAt == Date(timeIntervalSince1970: 1_782_864_000)) + case let .failure(error): + Issue.record("Unexpected failure: \(error)") + } + } + + @Test + func `CLI auto records unavailable OAuth before successful CLI`() async throws { + let stubCLI = self.makeStubCodexCLI() let codexHome = try self.makeEmptyCodexHome() defer { try? FileManager.default.removeItem(at: codexHome) } let settings = ProviderSettingsSnapshot.make( @@ -317,13 +339,14 @@ struct CodexBaselineCharacterizationTests { runtime: .cli, sourceMode: .auto, env: [ - "CODEX_CLI_PATH": stubCLIPath, + "CODEX_CLI_PATH": stubCLI.executable, "CODEX_HOME": codexHome.path, ], - settings: settings) + settings: settings, + codexArguments: stubCLI.arguments) - #expect(outcome.attempts.map(\.strategyID) == ["codex.web.dashboard", "codex.oauth", "codex.cli"]) - #expect(outcome.attempts.map(\.wasAvailable) == [false, false, true]) + #expect(outcome.attempts.map(\.strategyID) == ["codex.oauth", "codex.cli"]) + #expect(outcome.attempts.map(\.wasAvailable) == [false, true]) switch outcome.result { case let .success(result): @@ -354,8 +377,8 @@ struct CodexBaselineCharacterizationTests { ], settings: settings) - #expect(outcome.attempts.map(\.strategyID) == ["codex.web.dashboard", "codex.oauth"]) - #expect(outcome.attempts.map(\.wasAvailable) == [false, true]) + #expect(outcome.attempts.map(\.strategyID) == ["codex.oauth"]) + #expect(outcome.attempts.map(\.wasAvailable) == [true]) switch outcome.result { case .success: diff --git a/Tests/CodexBarTests/CodexCombinedMetricHighestUsageTests.swift b/Tests/CodexBarTests/CodexCombinedMetricHighestUsageTests.swift new file mode 100644 index 0000000000..31194288bb --- /dev/null +++ b/Tests/CodexBarTests/CodexCombinedMetricHighestUsageTests.swift @@ -0,0 +1,317 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +@MainActor +struct CodexCombinedMetricHighestUsageTests { + @Test + func `combined codex metric uses weekly lane when ranking highest usage`() { + let store = self.makeStore(suiteName: "CodexCombinedMetricHighestUsageTests-weekly-ranking") + + let codexSnapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 12, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + secondary: RateWindow(usedPercent: 91, windowMinutes: 7 * 24 * 60, resetsAt: nil, resetDescription: nil), + updatedAt: Date()) + let claudeSnapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 80, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: nil, + updatedAt: Date()) + + store._setSnapshotForTesting(codexSnapshot, provider: .codex) + store._setSnapshotForTesting(claudeSnapshot, provider: .claude) + + let highest = store.providerWithHighestUsage() + #expect(highest?.provider == .codex) + #expect(highest?.usedPercent == 91) + } + + @Test + func `combined codex metric ignores expired weekly lane when ranking highest usage`() { + let store = self.makeStore(suiteName: "CodexCombinedMetricHighestUsageTests-expired-weekly-ranking") + let now = Date(timeIntervalSince1970: 1_800_000_000) + + store._setSnapshotForTesting( + UsageSnapshot( + primary: RateWindow( + usedPercent: 1, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(3600), + resetDescription: nil), + secondary: RateWindow( + usedPercent: 100, + windowMinutes: 7 * 24 * 60, + resetsAt: now, + resetDescription: nil), + updatedAt: now.addingTimeInterval(-7200)), + provider: .codex) + store._setSnapshotForTesting( + UsageSnapshot( + primary: RateWindow(usedPercent: 80, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: nil, + updatedAt: now), + provider: .claude) + + let highest = store.providerWithHighestUsage(now: now) + #expect(highest?.provider == .claude) + #expect(highest?.usedPercent == 80) + } + + @Test + func `combined codex metric excludes an actively binding weekly cap`() { + let store = self.makeStore(suiteName: "CodexCombinedMetricHighestUsageTests-binding-weekly-cap") + let now = Date(timeIntervalSince1970: 1_800_000_000) + + store._setSnapshotForTesting( + UsageSnapshot( + primary: RateWindow( + usedPercent: 1, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(3600), + resetDescription: nil), + secondary: RateWindow( + usedPercent: 100, + windowMinutes: 7 * 24 * 60, + resetsAt: now.addingTimeInterval(7200), + resetDescription: nil), + updatedAt: now), + provider: .codex) + store._setSnapshotForTesting( + UsageSnapshot( + primary: RateWindow(usedPercent: 80, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: nil, + updatedAt: now), + provider: .claude) + + let highest = store.providerWithHighestUsage(now: now) + #expect(highest?.provider == .claude) + #expect(highest?.usedPercent == 80) + } + + @Test + func `combined codex metric stays eligible when only one lane is exhausted`() { + let store = self.makeStore(suiteName: "CodexCombinedMetricHighestUsageTests-one-exhausted") + + store._setSnapshotForTesting( + UsageSnapshot( + primary: RateWindow(usedPercent: 100, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + secondary: RateWindow( + usedPercent: 30, + windowMinutes: 7 * 24 * 60, + resetsAt: nil, + resetDescription: nil), + updatedAt: Date()), + provider: .codex) + store._setSnapshotForTesting( + UsageSnapshot( + primary: RateWindow(usedPercent: 80, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: nil, + updatedAt: Date()), + provider: .claude) + + let highest = store.providerWithHighestUsage() + #expect(highest?.provider == .codex) + #expect(highest?.usedPercent == 100) + } + + @Test + func `combined codex metric is excluded when both lanes are exhausted`() { + let store = self.makeStore(suiteName: "CodexCombinedMetricHighestUsageTests-both-exhausted") + + store._setSnapshotForTesting( + UsageSnapshot( + primary: RateWindow(usedPercent: 100, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + secondary: RateWindow( + usedPercent: 100, + windowMinutes: 7 * 24 * 60, + resetsAt: nil, + resetDescription: nil), + updatedAt: Date()), + provider: .codex) + store._setSnapshotForTesting( + UsageSnapshot( + primary: RateWindow(usedPercent: 80, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: nil, + updatedAt: Date()), + provider: .claude) + + let highest = store.providerWithHighestUsage() + #expect(highest?.provider == .claude) + #expect(highest?.usedPercent == 80) + } + + @Test + func `combined claude metric stays eligible when only one lane is exhausted`() { + let store = self.makeStore( + suiteName: "CodexCombinedMetricHighestUsageTests-claude-one-exhausted", + claudeCombined: true) + + store._setSnapshotForTesting( + UsageSnapshot( + primary: RateWindow(usedPercent: 50, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + secondary: RateWindow( + usedPercent: 50, + windowMinutes: 7 * 24 * 60, + resetsAt: nil, + resetDescription: nil), + updatedAt: Date()), + provider: .codex) + store._setSnapshotForTesting( + UsageSnapshot( + primary: RateWindow(usedPercent: 100, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + secondary: RateWindow( + usedPercent: 30, + windowMinutes: 7 * 24 * 60, + resetsAt: nil, + resetDescription: nil), + updatedAt: Date()), + provider: .claude) + + // Claude's session lane is exhausted but the weekly lane still has room, so the combined + // metric must keep Claude eligible (mirroring Codex) instead of dropping it from ranking. + let highest = store.providerWithHighestUsage() + #expect(highest?.provider == .claude) + #expect(highest?.usedPercent == 100) + } + + @Test + func `combined claude metric is excluded when both lanes are exhausted`() { + let store = self.makeStore( + suiteName: "CodexCombinedMetricHighestUsageTests-claude-both-exhausted", + claudeCombined: true) + + store._setSnapshotForTesting( + UsageSnapshot( + primary: RateWindow(usedPercent: 80, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + secondary: RateWindow( + usedPercent: 80, + windowMinutes: 7 * 24 * 60, + resetsAt: nil, + resetDescription: nil), + updatedAt: Date()), + provider: .codex) + store._setSnapshotForTesting( + UsageSnapshot( + primary: RateWindow(usedPercent: 100, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + secondary: RateWindow( + usedPercent: 100, + windowMinutes: 7 * 24 * 60, + resetsAt: nil, + resetDescription: nil), + updatedAt: Date()), + provider: .claude) + + // Both Claude lanes are exhausted, so the combined metric must drop Claude from ranking and + // surface Codex instead. + let highest = store.providerWithHighestUsage() + #expect(highest?.provider == .codex) + #expect(highest?.usedPercent == 80) + } + + @Test + func `combined claude metric excludes an exhausted weekly-only account with a synthetic placeholder`() { + let store = self.makeStore( + suiteName: "CodexCombinedMetricHighestUsageTests-claude-placeholder-exhausted", + claudeCombined: true) + + store._setSnapshotForTesting( + UsageSnapshot( + primary: RateWindow(usedPercent: 80, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + secondary: RateWindow( + usedPercent: 80, + windowMinutes: 7 * 24 * 60, + resetsAt: nil, + resetDescription: nil), + updatedAt: Date()), + provider: .codex) + // Claude web weekly-only account: a synthetic 0% session placeholder plus an exhausted weekly lane. + store._setSnapshotForTesting( + UsageSnapshot( + primary: RateWindow( + usedPercent: 0, + windowMinutes: 300, + resetsAt: nil, + resetDescription: nil, + isSyntheticPlaceholder: true), + secondary: RateWindow( + usedPercent: 100, + windowMinutes: 7 * 24 * 60, + resetsAt: nil, + resetDescription: nil), + updatedAt: Date()), + provider: .claude) + + // The placeholder is not a real lane, so the only real Claude lane (weekly) is fully exhausted — + // Claude must be excluded from ranking, not kept eligible by the phantom 0% session. + let highest = store.providerWithHighestUsage() + #expect(highest?.provider == .codex) + #expect(highest?.usedPercent == 80) + } + + @Test + func `combined claude metric excludes an exhausted spend-limit-only account`() { + let store = self.makeStore( + suiteName: "CodexCombinedMetricHighestUsageTests-claude-spend-limit-exhausted", + claudeCombined: true) + + store._setSnapshotForTesting( + UsageSnapshot( + primary: RateWindow(usedPercent: 80, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + secondary: RateWindow( + usedPercent: 80, + windowMinutes: 7 * 24 * 60, + resetsAt: nil, + resetDescription: nil), + updatedAt: Date()), + provider: .codex) + // Claude spend-limit-only account: exhausted providerCost, no secondary/tertiary, and an + // explicitly marked 0% 5h placeholder primary. The metric resolves to the exhausted spend-limit window. + store._setSnapshotForTesting( + UsageSnapshot( + primary: RateWindow( + usedPercent: 0, + windowMinutes: 300, + resetsAt: nil, + resetDescription: nil, + isSyntheticPlaceholder: true), + secondary: nil, + providerCost: ProviderCostSnapshot( + used: 100, + limit: 100, + currencyCode: "USD", + period: "Spend limit", + updatedAt: Date()), + updatedAt: Date()), + provider: .claude) + + // The spend limit is exhausted and there are no real lanes, so Claude must be excluded from + // ranking (the marked 0% placeholder must not keep it eligible); Codex surfaces instead. + let highest = store.providerWithHighestUsage() + #expect(highest?.provider == .codex) + #expect(highest?.usedPercent == 80) + } + + private func makeStore(suiteName: String, claudeCombined: Bool = false) -> UsageStore { + let settings = SettingsStore( + configStore: testConfigStore(suiteName: suiteName), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + settings.refreshFrequency = .manual + settings.statusChecksEnabled = false + settings.setMenuBarMetricPreference(.primaryAndSecondary, for: .codex) + if claudeCombined { + settings.setMenuBarMetricPreference(.primaryAndSecondary, for: .claude) + } + + let registry = ProviderRegistry.shared + if let codexMeta = registry.metadata[.codex] { + settings.setProviderEnabled(provider: .codex, metadata: codexMeta, enabled: true) + } + if let claudeMeta = registry.metadata[.claude] { + settings.setProviderEnabled(provider: .claude, metadata: claudeMeta, enabled: true) + } + + let fetcher = UsageFetcher() + return UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + } +} diff --git a/Tests/CodexBarTests/CodexCompactSubagentAccountingTests.swift b/Tests/CodexBarTests/CodexCompactSubagentAccountingTests.swift new file mode 100644 index 0000000000..3fa37a5bb9 --- /dev/null +++ b/Tests/CodexBarTests/CodexCompactSubagentAccountingTests.swift @@ -0,0 +1,213 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct CodexCompactSubagentAccountingTests { + private typealias Fixture = CodexCompactSubagentFixture + private typealias Usage = Fixture.Usage + + @Test + func `parent-confirmed first turn marker drops a compact copied prefix`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 7, day: 17) + let parentModel = "openai/gpt-5.3" + let leafModel = "openai/gpt-5.4" + let prefix: Usage = (input: 1000, cached: 900, output: 100) + let suffix: Usage = (input: 50, cached: 10, output: 5) + + _ = try env.writeCodexSessionFile( + day: day, + filename: "rollout-parent.jsonl", + contents: Fixture.parentContents( + env: env, + day: day, + sessionID: "compact-parent", + model: parentModel, + totals: prefix)) + _ = try env.writeCodexSessionFile( + day: day, + filename: "rollout-child.jsonl", + contents: Fixture.childContents( + env: env, + day: day, + fixture: Fixture.Child( + sessionID: "compact-child", + parentID: "compact-parent", + leafModel: leafModel, + prefix: prefix, + suffix: suffix, + preBoundaryLast: (input: 7, cached: 3, output: 2)))) + + var options = CostUsageScanner.Options( + codexSessionsRoot: env.codexSessionsRoot, + claudeProjectsRoots: nil, + cacheRoot: env.cacheRoot) + options.refreshMinIntervalSeconds = 0 + + let cold = CostUsageScanner.loadDailyReport( + provider: .codex, + since: day, + until: day, + now: day, + options: options) + let warm = CostUsageScanner.loadDailyReport( + provider: .codex, + since: day, + until: day, + now: day.addingTimeInterval(1), + options: options) + options.forceRescan = true + let forced = CostUsageScanner.loadDailyReport( + provider: .codex, + since: day, + until: day, + now: day.addingTimeInterval(2), + options: options) + + for report in [cold, warm, forced] { + let daily = try #require(report.data.first) + #expect(daily.totalTokens == 1155) + let breakdowns = try #require(daily.modelBreakdowns) + #expect(!breakdowns.contains { $0.modelName == CostUsagePricing.codexUnattributedModel }) + #expect(breakdowns.first { + $0.modelName == CostUsagePricing.normalizeCodexModel(parentModel) + }?.totalTokens == 1100) + #expect(breakdowns.first { + $0.modelName == CostUsagePricing.normalizeCodexModel(leafModel) + }?.totalTokens == 55) + } + + let cache = CostUsageCacheIO.load(provider: .codex, cacheRoot: env.cacheRoot) + let child = try #require(cache.files.values.first { $0.sessionId == "compact-child" }) + #expect(child.days[CostUsageScanner.CostUsageDayRange.dayKey(from: day)]?[ + CostUsagePricing.normalizeCodexModel(leafModel), + ] == [50, 10, 5]) + #expect(child.days.values.allSatisfy { $0[CostUsagePricing.codexUnattributedModel] == nil }) + #expect(child.forkBaselineDependencyKey?.hasPrefix("file|") == true) + } + + @Test + func `parent snapshot change invalidates a cached compact child classification`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 7, day: 17) + let parentModel = "openai/gpt-5.3" + let leafModel = "openai/gpt-5.4" + let prefix: Usage = (input: 1000, cached: 900, output: 100) + let mismatchedParent: Usage = (input: 999, cached: 899, output: 99) + let suffix: Usage = (input: 50, cached: 10, output: 5) + let initialParentContents = try Fixture.parentContents( + env: env, + day: day, + sessionID: "cache-parent", + model: parentModel, + totals: mismatchedParent) + let parentURL = try env.writeCodexSessionFile( + day: day, + filename: "rollout-0-cache-parent.jsonl", + contents: initialParentContents) + _ = try env.writeCodexSessionFile( + day: day, + filename: "rollout-1-cache-child.jsonl", + contents: Fixture.childContents( + env: env, + day: day, + fixture: Fixture.Child( + sessionID: "cache-child", + parentID: "cache-parent", + leafModel: leafModel, + prefix: prefix, + suffix: suffix, + preBoundaryLast: nil))) + + var options = CostUsageScanner.Options( + codexSessionsRoot: env.codexSessionsRoot, + claudeProjectsRoots: nil, + cacheRoot: env.cacheRoot) + options.refreshMinIntervalSeconds = 0 + let before = CostUsageScanner.loadDailyReport( + provider: .codex, + since: day, + until: day, + now: day, + options: options) + let beforeDay = try #require(before.data.first) + #expect(beforeDay.totalTokens == 2253) + #expect(beforeDay.modelBreakdowns?.first { + $0.modelName == CostUsagePricing.codexUnattributedModel + }?.totalTokens == 1100) + let beforeCache = CostUsageCacheIO.load(provider: .codex, cacheRoot: env.cacheRoot) + let beforeChild = try #require(beforeCache.files.values.first { $0.sessionId == "cache-child" }) + let beforeDependency = try #require(beforeChild.forkBaselineDependencyKey) + #expect(beforeDependency.hasPrefix("file|")) + + let appendedParentSnapshot = try env.jsonl([ + Fixture.tokenCount( + timestamp: env.isoString(for: day.addingTimeInterval(-0.5)), + model: parentModel, + total: prefix, + last: (input: 1, cached: 1, output: 1)), + ]) + try (initialParentContents + appendedParentSnapshot) + .write(to: parentURL, atomically: true, encoding: .utf8) + + let after = CostUsageScanner.loadDailyReport( + provider: .codex, + since: day, + until: day, + now: day.addingTimeInterval(1), + options: options) + let afterDay = try #require(after.data.first) + #expect(afterDay.totalTokens == 1155) + #expect(!(afterDay.modelBreakdowns ?? []).contains { + $0.modelName == CostUsagePricing.codexUnattributedModel + }) + let afterCache = CostUsageCacheIO.load(provider: .codex, cacheRoot: env.cacheRoot) + let afterChild = try #require(afterCache.files.values.first { $0.sessionId == "cache-child" }) + #expect(afterChild.forkBaselineDependencyKey?.hasPrefix("file|") == true) + #expect(afterChild.forkBaselineDependencyKey != beforeDependency) + #expect(afterChild.days.values.allSatisfy { $0[CostUsagePricing.codexUnattributedModel] == nil }) + } + + @Test + func `unconfirmed compact prefix stays independent and parent-dependent`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 7, day: 17) + let leafModel = "openai/gpt-5.4" + let prefix: Usage = (input: 1000, cached: 900, output: 100) + let suffix: Usage = (input: 50, cached: 10, output: 5) + let fileURL = try env.writeCodexSessionFile( + day: day, + filename: "rollout-unconfirmed-child.jsonl", + contents: Fixture.childContents( + env: env, + day: day, + fixture: Fixture.Child( + sessionID: "unconfirmed-child", + parentID: "unconfirmed-parent", + leafModel: leafModel, + prefix: prefix, + suffix: suffix, + preBoundaryLast: nil))) + let baselines: [CostUsageScanner.CodexForkBaseline] = [ + .resolved(.init(input: 999, cached: 899, output: 99)), + .unresolved, + ] + + for baseline in baselines { + let parsed = CostUsageScanner.parseCodexFile( + fileURL: fileURL, + range: CostUsageScanner.CostUsageDayRange(since: day, until: day), + inheritedTotalsResolver: { _, _ in baseline }) + let dayKey = CostUsageScanner.CostUsageDayRange.dayKey(from: day) + #expect(parsed.days[dayKey]?[CostUsagePricing.codexUnattributedModel] == [1000, 900, 100]) + #expect(parsed.days[dayKey]?[CostUsagePricing.normalizeCodexModel(leafModel)] == [50, 10, 5]) + #expect(parsed.dependsOnParentTotals) + } + } +} diff --git a/Tests/CodexBarTests/CodexCompactSubagentFixture.swift b/Tests/CodexBarTests/CodexCompactSubagentFixture.swift new file mode 100644 index 0000000000..85855038c3 --- /dev/null +++ b/Tests/CodexBarTests/CodexCompactSubagentFixture.swift @@ -0,0 +1,150 @@ +import Foundation + +enum CodexCompactSubagentFixture { + typealias Usage = (input: Int, cached: Int, output: Int) + + struct Child { + let sessionID: String + let parentID: String + let leafModel: String + let prefix: Usage + let suffix: Usage + let preBoundaryLast: Usage? + } + + static func parentContents( + env: CostUsageTestEnvironment, + day: Date, + sessionID: String, + model: String, + totals: Usage) throws -> String + { + try env.jsonl([ + [ + "type": "session_meta", + "timestamp": env.isoString(for: day.addingTimeInterval(-2)), + "payload": ["id": sessionID], + ], + self.turnContext( + timestamp: env.isoString(for: day.addingTimeInterval(-2)), + model: model), + self.tokenCount( + timestamp: env.isoString(for: day.addingTimeInterval(-1)), + model: model, + total: totals, + last: totals), + ]) + } + + static func childContents( + env: CostUsageTestEnvironment, + day: Date, + fixture: Child) throws -> String + { + let forkTimestamp = env.isoString(for: day) + var lines: [[String: Any]] = [ + [ + "type": "session_meta", + "timestamp": forkTimestamp, + "payload": [ + "id": fixture.sessionID, + "forked_from_id": fixture.parentID, + "timestamp": forkTimestamp, + "source": [ + "subagent": [ + "thread_spawn": ["parent_thread_id": fixture.parentID], + ], + ], + ], + ], + self.tokenCountWithoutModel( + timestamp: env.isoString(for: day.addingTimeInterval(0.1)), + total: fixture.prefix, + last: fixture.prefix), + ] + if let preBoundaryLast = fixture.preBoundaryLast { + lines.append(self.tokenCountWithoutModel( + timestamp: env.isoString(for: day.addingTimeInterval(0.2)), + last: preBoundaryLast)) + } + lines.append(contentsOf: [ + self.turnContext( + timestamp: env.isoString(for: day.addingTimeInterval(1)), + model: fixture.leafModel), + [ + "type": "inter_agent_communication_metadata", + "timestamp": env.isoString(for: day.addingTimeInterval(1)), + "payload": ["trigger_turn": true], + ], + self.tokenCountWithoutModel( + timestamp: env.isoString(for: day.addingTimeInterval(2)), + total: ( + input: fixture.prefix.input + fixture.suffix.input, + cached: fixture.prefix.cached + fixture.suffix.cached, + output: fixture.prefix.output + fixture.suffix.output), + last: fixture.suffix), + ]) + return try env.jsonl(lines) + } + + static func tokenCount( + timestamp: String, + model: String, + total: Usage? = nil, + last: Usage? = nil) -> [String: Any] + { + var info: [String: Any] = ["model": model] + if let total { + info["total_token_usage"] = self.usagePayload(total) + } + if let last { + info["last_token_usage"] = self.usagePayload(last) + } + return [ + "type": "event_msg", + "timestamp": timestamp, + "payload": [ + "type": "token_count", + "info": info, + ], + ] + } + + private static func turnContext(timestamp: String, model: String) -> [String: Any] { + [ + "type": "turn_context", + "timestamp": timestamp, + "payload": ["model": model], + ] + } + + private static func tokenCountWithoutModel( + timestamp: String, + total: Usage? = nil, + last: Usage? = nil) -> [String: Any] + { + var info: [String: Any] = [:] + if let total { + info["total_token_usage"] = self.usagePayload(total) + } + if let last { + info["last_token_usage"] = self.usagePayload(last) + } + return [ + "type": "event_msg", + "timestamp": timestamp, + "payload": [ + "type": "token_count", + "info": info, + ], + ] + } + + private static func usagePayload(_ usage: Usage) -> [String: Any] { + [ + "input_tokens": usage.input, + "cached_input_tokens": usage.cached, + "output_tokens": usage.output, + ] + } +} diff --git a/Tests/CodexBarTests/CodexConsumerProjectionCharacterizationTests.swift b/Tests/CodexBarTests/CodexConsumerProjectionCharacterizationTests.swift index 1eea7faf3f..68fa4888c9 100644 --- a/Tests/CodexBarTests/CodexConsumerProjectionCharacterizationTests.swift +++ b/Tests/CodexBarTests/CodexConsumerProjectionCharacterizationTests.swift @@ -4,25 +4,10 @@ import Testing @testable import CodexBar @MainActor +@Suite(.serialized) struct CodexConsumerProjectionCharacterizationTests { - private func makeStatusBarForTesting() -> NSStatusBar { - let env = ProcessInfo.processInfo.environment - if env["GITHUB_ACTIONS"] == "true" || env["CI"] == "true" { - return .system - } - return NSStatusBar() - } - private func makeSettings() -> SettingsStore { - let suite = "CodexConsumerProjectionCharacterizationTests-\(UUID().uuidString)" - let defaults = UserDefaults(suiteName: suite)! - defaults.removePersistentDomain(forName: suite) - let configStore = testConfigStore(suiteName: suite) - return SettingsStore( - userDefaults: defaults, - configStore: configStore, - zaiTokenStore: NoopZaiTokenStore(), - syntheticTokenStore: NoopSyntheticTokenStore()) + testSettingsStore(suiteName: "CodexConsumerProjectionCharacterizationTests") } private func makeCodexStore(settings: SettingsStore, dashboardAuthorized: Bool) -> UsageStore { @@ -63,6 +48,25 @@ struct CodexConsumerProjectionCharacterizationTests { return store } + private func enableCodexProvider(settings: SettingsStore) { + if let codexMeta = ProviderRegistry.shared.metadata[.codex] { + settings.setProviderEnabled(provider: .codex, metadata: codexMeta, enabled: true) + } + } + + private func makeMenuBarController(settings: SettingsStore) -> (UsageStore, StatusItemController) { + 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: testStatusBar()) + return (store, controller) + } + @Test func `snapshot override menu card stays isolated from live codex extras`() throws { let settings = self.makeSettings() @@ -87,7 +91,8 @@ struct CodexConsumerProjectionCharacterizationTests { account: fetcher.loadAccountInfo(), updater: DisabledUpdaterController(), preferencesSelection: PreferencesSelection(), - statusBar: self.makeStatusBarForTesting()) + statusBar: testStatusBar()) + defer { controller.releaseStatusItemsForTesting() } let overrideSnapshot = UsageSnapshot( primary: RateWindow( @@ -109,7 +114,7 @@ struct CodexConsumerProjectionCharacterizationTests { snapshotOverride: overrideSnapshot, errorOverride: "Override error")) - #expect(model.creditsText == "Credits unavailable; keep Codex running to refresh.") + #expect(model.creditsText == nil) #expect(model.tokenUsage == nil) #expect(model.metrics.contains { $0.id == "code-review" } == false) #expect(model.subtitleText == "Override error") @@ -126,20 +131,10 @@ struct CodexConsumerProjectionCharacterizationTests { settings.usageBarsShowUsed = true settings.setMenuBarMetricPreference(.primary, for: .codex) - let registry = ProviderRegistry.shared - if let codexMeta = registry.metadata[.codex] { - settings.setProviderEnabled(provider: .codex, metadata: codexMeta, enabled: true) - } + self.enableCodexProvider(settings: 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: self.makeStatusBarForTesting()) + let (store, controller) = self.makeMenuBarController(settings: settings) + defer { controller.releaseStatusItemsForTesting() } let snapshot = UsageSnapshot( primary: RateWindow(usedPercent: 100, windowMinutes: nil, resetsAt: nil, resetDescription: nil), @@ -154,4 +149,154 @@ struct CodexConsumerProjectionCharacterizationTests { #expect(displayText == "100%") } + + @Test + func `menu bar percent mode can show codex session and weekly together`() { + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.selectedMenuProvider = .codex + settings.menuBarDisplayMode = .percent + settings.usageBarsShowUsed = false + settings.setMenuBarMetricPreference(.primaryAndSecondary, for: .codex) + + self.enableCodexProvider(settings: settings) + + let (store, controller) = self.makeMenuBarController(settings: settings) + defer { controller.releaseStatusItemsForTesting() } + + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 7, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + secondary: RateWindow(usedPercent: 18, windowMinutes: 10080, resetsAt: nil, resetDescription: nil), + updatedAt: Date()) + + store._setSnapshotForTesting(snapshot, provider: .codex) + store._setErrorForTesting(nil, provider: .codex) + store.credits = CreditsSnapshot(remaining: 42.5, events: [], updatedAt: Date()) + + let displayText = controller.menuBarDisplayText(for: .codex, snapshot: snapshot) + + #expect(displayText == "5h 93% · W 82%") + } + + @Test + func `menu bar combined codex percent keeps available weekly lane when session is unavailable`() { + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.selectedMenuProvider = .codex + settings.menuBarDisplayMode = .percent + settings.usageBarsShowUsed = false + settings.setMenuBarMetricPreference(.primaryAndSecondary, for: .codex) + + self.enableCodexProvider(settings: settings) + + let (store, controller) = self.makeMenuBarController(settings: settings) + defer { controller.releaseStatusItemsForTesting() } + + let snapshot = UsageSnapshot( + primary: nil, + secondary: RateWindow(usedPercent: 18, windowMinutes: 10080, resetsAt: nil, resetDescription: nil), + updatedAt: Date()) + + store._setSnapshotForTesting(snapshot, provider: .codex) + store._setErrorForTesting(nil, provider: .codex) + store.credits = CreditsSnapshot(remaining: 42.5, events: [], updatedAt: Date()) + + let displayText = controller.menuBarDisplayText(for: .codex, snapshot: snapshot) + + #expect(displayText == "W 82%") + } + + @Test + func `menu bar combined codex percent falls back to credits when no percent lanes are available`() { + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.selectedMenuProvider = .codex + settings.menuBarDisplayMode = .percent + settings.usageBarsShowUsed = false + settings.setMenuBarMetricPreference(.primaryAndSecondary, for: .codex) + + self.enableCodexProvider(settings: settings) + + let (store, controller) = self.makeMenuBarController(settings: settings) + defer { controller.releaseStatusItemsForTesting() } + + let snapshot = UsageSnapshot(primary: nil, secondary: nil, updatedAt: Date()) + + store._setSnapshotForTesting(snapshot, provider: .codex) + store._setErrorForTesting(nil, provider: .codex) + store.credits = CreditsSnapshot(remaining: 42.5, events: [], updatedAt: Date()) + + let displayText = controller.menuBarDisplayText(for: .codex, snapshot: snapshot) + + #expect(displayText == "42.5") + } + + @Test + func `menu bar combined codex percent keeps credits fallback when a lane is exhausted`() { + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.selectedMenuProvider = .codex + settings.menuBarDisplayMode = .percent + settings.usageBarsShowUsed = false + settings.setMenuBarMetricPreference(.primaryAndSecondary, for: .codex) + + self.enableCodexProvider(settings: settings) + + let (store, controller) = self.makeMenuBarController(settings: settings) + defer { controller.releaseStatusItemsForTesting() } + + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 100, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + secondary: RateWindow(usedPercent: 18, windowMinutes: 10080, resetsAt: nil, resetDescription: nil), + updatedAt: Date()) + + store._setSnapshotForTesting(snapshot, provider: .codex) + store._setErrorForTesting(nil, provider: .codex) + store.credits = CreditsSnapshot(remaining: 42.5, events: [], updatedAt: Date()) + + let displayText = controller.menuBarDisplayText(for: .codex, snapshot: snapshot) + + #expect(displayText == "42.5") + } + + @Test + func `menu bar combined codex option preserves single metric choices`() { + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.selectedMenuProvider = .codex + settings.menuBarDisplayMode = .percent + settings.usageBarsShowUsed = false + + self.enableCodexProvider(settings: settings) + + let (store, controller) = self.makeMenuBarController(settings: settings) + defer { controller.releaseStatusItemsForTesting() } + + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 7, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + secondary: RateWindow(usedPercent: 18, windowMinutes: 10080, resetsAt: nil, resetDescription: nil), + updatedAt: Date()) + + store._setSnapshotForTesting(snapshot, provider: .codex) + store._setErrorForTesting(nil, provider: .codex) + + settings.setMenuBarMetricPreference(.primary, for: .codex) + let primaryText = controller.menuBarDisplayText(for: .codex, snapshot: snapshot) + + settings.setMenuBarMetricPreference(.secondary, for: .codex) + let secondaryText = controller.menuBarDisplayText(for: .codex, snapshot: snapshot) + + #expect(primaryText == "93%") + #expect(secondaryText == "82%") + } } diff --git a/Tests/CodexBarTests/CodexConsumerProjectionTests.swift b/Tests/CodexBarTests/CodexConsumerProjectionTests.swift index ead6233f38..464096b7da 100644 --- a/Tests/CodexBarTests/CodexConsumerProjectionTests.swift +++ b/Tests/CodexBarTests/CodexConsumerProjectionTests.swift @@ -209,6 +209,267 @@ struct CodexConsumerProjectionTests { #expect(!projection.hasExhaustedRateLane) } + @Test + func `projection prefers monthly credit limit remaining over zero balance`() { + let store = self.makeStore(suite: "CodexConsumerProjectionTests-monthly-credit-limit") + let now = Date(timeIntervalSince1970: 1_700_000_000) + + store._setSnapshotForTesting(nil, provider: .codex) + store.credits = CreditsSnapshot( + remaining: 0, + events: [], + updatedAt: now, + codexCreditLimit: CodexCreditLimitSnapshot( + used: 7761, + limit: 100_000, + remainingPercent: 92.239, + resetsAt: nil, + updatedAt: now)) + + let projection = store.codexConsumerProjection(surface: .widget, now: now) + + #expect(projection.credits?.remaining == 92239) + } + + @Test + func `exhausted weekly lane caps session display until weekly reset`() throws { + let store = self.makeStore(suite: "CodexConsumerProjectionTests-weekly-caps-session") + let now = Date(timeIntervalSince1970: 1_800_000_000) + let sessionReset = now.addingTimeInterval(3 * 3600) + let weeklyReset = now.addingTimeInterval(4 * 24 * 3600) + + store._setSnapshotForTesting( + UsageSnapshot( + primary: RateWindow( + usedPercent: 1, + windowMinutes: 300, + resetsAt: sessionReset, + resetDescription: nil), + secondary: RateWindow( + usedPercent: 157, + windowMinutes: 10080, + resetsAt: weeklyReset, + resetDescription: nil), + updatedAt: now), + provider: .codex) + + let projection = store.codexConsumerProjection(surface: .liveCard, now: now) + let session = try #require(projection.rateWindow(for: .session)) + let weekly = try #require(projection.rateWindow(for: .weekly)) + + #expect(session.remainingPercent == 0) + #expect(session.resetsAt == weeklyReset) + #expect(weekly.remainingPercent == 0) + #expect(weekly.resetsAt == weeklyReset) + #expect(projection.planUtilizationLanes.first?.window.usedPercent == 1) + } + + @Test + func `exhausted weekly lane retargets session reset when session is also exhausted`() throws { + let store = self.makeStore(suite: "CodexConsumerProjectionTests-weekly-caps-both-exhausted") + let now = Date(timeIntervalSince1970: 1_800_000_000) + let sessionReset = now.addingTimeInterval(42 * 60) + let weeklyReset = now.addingTimeInterval(4 * 24 * 3600) + + store._setSnapshotForTesting( + UsageSnapshot( + primary: RateWindow( + usedPercent: 100, + windowMinutes: 300, + resetsAt: sessionReset, + resetDescription: nil), + secondary: RateWindow( + usedPercent: 157, + windowMinutes: 10080, + resetsAt: weeklyReset, + resetDescription: nil), + updatedAt: now), + provider: .codex) + + let projection = store.codexConsumerProjection(surface: .liveCard, now: now) + let session = try #require(projection.rateWindow(for: .session)) + + #expect(session.remainingPercent == 0) + #expect(session.resetsAt == weeklyReset) + #expect(session.resetsAt != sessionReset) + } + + @Test + func `both exhausted lanes use the later session reset`() throws { + let store = self.makeStore(suite: "CodexConsumerProjectionTests-session-reset-binds-later") + let now = Date(timeIntervalSince1970: 1_800_000_000) + let weeklyReset = now.addingTimeInterval(60 * 60) + let sessionReset = now.addingTimeInterval(4 * 60 * 60) + + store._setSnapshotForTesting( + UsageSnapshot( + primary: RateWindow( + usedPercent: 100, + windowMinutes: 300, + resetsAt: sessionReset, + resetDescription: "session reset"), + secondary: RateWindow( + usedPercent: 100, + windowMinutes: 10080, + resetsAt: weeklyReset, + resetDescription: "weekly reset"), + updatedAt: now), + provider: .codex) + + let projection = store.codexConsumerProjection(surface: .liveCard, now: now) + let session = try #require(projection.rateWindow(for: .session)) + + #expect(session.remainingPercent == 0) + #expect(session.resetsAt == sessionReset) + #expect(session.resetDescription == "session reset") + } + + @Test + func `both exhausted lanes keep effective reset unknown when session reset is unknown`() throws { + let store = self.makeStore(suite: "CodexConsumerProjectionTests-session-reset-unknown") + let now = Date(timeIntervalSince1970: 1_800_000_000) + + store._setSnapshotForTesting( + UsageSnapshot( + primary: RateWindow( + usedPercent: 100, + windowMinutes: 300, + resetsAt: nil, + resetDescription: nil), + secondary: RateWindow( + usedPercent: 100, + windowMinutes: 10080, + resetsAt: now.addingTimeInterval(60 * 60), + resetDescription: "weekly reset"), + updatedAt: now), + provider: .codex) + + let projection = store.codexConsumerProjection(surface: .liveCard, now: now) + let session = try #require(projection.rateWindow(for: .session)) + + #expect(session.remainingPercent == 0) + #expect(session.resetsAt == nil) + #expect(session.resetDescription == nil) + } + + @Test + func `exhausted weekly lane leaves session reset unknown when weekly reset is unknown`() throws { + let store = self.makeStore(suite: "CodexConsumerProjectionTests-weekly-caps-unknown-reset") + let now = Date(timeIntervalSince1970: 1_800_000_000) + let sessionReset = now.addingTimeInterval(42 * 60) + + store._setSnapshotForTesting( + UsageSnapshot( + primary: RateWindow( + usedPercent: 1, + windowMinutes: 300, + resetsAt: sessionReset, + resetDescription: "in 42m"), + secondary: RateWindow( + usedPercent: 100, + windowMinutes: 10080, + resetsAt: nil, + resetDescription: nil), + updatedAt: now), + provider: .codex) + + let projection = store.codexConsumerProjection(surface: .liveCard, now: now) + let session = try #require(projection.rateWindow(for: .session)) + + #expect(session.remainingPercent == 0) + #expect(session.resetsAt == nil) + #expect(session.resetDescription == nil) + } + + @Test + func `weekly cap lifts after weekly reset even with stale snapshot timestamp`() throws { + let store = self.makeStore(suite: "CodexConsumerProjectionTests-weekly-cap-stale-snapshot") + let now = Date(timeIntervalSince1970: 1_800_000_000) + let snapshotCapturedAt = now.addingTimeInterval(-2 * 3600) + let sessionReset = now.addingTimeInterval(3 * 3600) + let weeklyReset = now.addingTimeInterval(-3600) + + store._setSnapshotForTesting( + UsageSnapshot( + primary: RateWindow( + usedPercent: 1, + windowMinutes: 300, + resetsAt: sessionReset, + resetDescription: nil), + secondary: RateWindow( + usedPercent: 100, + windowMinutes: 10080, + resetsAt: weeklyReset, + resetDescription: nil), + updatedAt: snapshotCapturedAt), + provider: .codex) + store.credits = CreditsSnapshot(remaining: 80, events: [], updatedAt: now) + + let projection = store.codexConsumerProjection(surface: .menuBar, now: now) + let session = try #require(projection.rateWindow(for: .session)) + + #expect(session.remainingPercent == 99) + #expect(session.resetsAt == sessionReset) + #expect(projection.menuBarFallback == .none) + } + + @Test + func `weekly cap does not alter session display when weekly has reset`() throws { + let store = self.makeStore(suite: "CodexConsumerProjectionTests-weekly-reset-session-uncapped") + let now = Date(timeIntervalSince1970: 1_800_000_000) + let sessionReset = now.addingTimeInterval(3 * 3600) + let weeklyReset = now.addingTimeInterval(-3600) + + store._setSnapshotForTesting( + UsageSnapshot( + primary: RateWindow( + usedPercent: 1, + windowMinutes: 300, + resetsAt: sessionReset, + resetDescription: nil), + secondary: RateWindow( + usedPercent: 100, + windowMinutes: 10080, + resetsAt: weeklyReset, + resetDescription: nil), + updatedAt: now), + provider: .codex) + + let projection = store.codexConsumerProjection(surface: .liveCard, now: now) + let session = try #require(projection.rateWindow(for: .session)) + + #expect(session.remainingPercent == 99) + #expect(session.resetsAt == sessionReset) + } + + @Test + func `weekly cap lifts at the weekly reset boundary`() throws { + let store = self.makeStore(suite: "CodexConsumerProjectionTests-weekly-reset-boundary") + let now = Date(timeIntervalSince1970: 1_800_000_000) + let sessionReset = now.addingTimeInterval(3 * 3600) + + store._setSnapshotForTesting( + UsageSnapshot( + primary: RateWindow( + usedPercent: 1, + windowMinutes: 300, + resetsAt: sessionReset, + resetDescription: nil), + secondary: RateWindow( + usedPercent: 100, + windowMinutes: 10080, + resetsAt: now, + resetDescription: nil), + updatedAt: now.addingTimeInterval(-3600)), + provider: .codex) + + let projection = store.codexConsumerProjection(surface: .liveCard, now: now) + let session = try #require(projection.rateWindow(for: .session)) + + #expect(session.remainingPercent == 99) + #expect(session.resetsAt == sessionReset) + } + private func makeStore(suite: String) -> UsageStore { let defaults = UserDefaults(suiteName: suite)! defaults.removePersistentDomain(forName: suite) @@ -226,7 +487,6 @@ struct CodexConsumerProjectionTests { minimaxCookieStore: InMemoryMiniMaxCookieStore(), minimaxAPITokenStore: InMemoryMiniMaxAPITokenStore(), kimiTokenStore: InMemoryKimiTokenStore(), - kimiK2TokenStore: InMemoryKimiK2TokenStore(), augmentCookieStore: InMemoryCookieHeaderStore(), ampCookieStore: InMemoryCookieHeaderStore(), copilotTokenStore: InMemoryCopilotTokenStore(), diff --git a/Tests/CodexBarTests/CodexDashboardAuthorityTests.swift b/Tests/CodexBarTests/CodexDashboardAuthorityTests.swift index a2805135e9..1c4cff0fdf 100644 --- a/Tests/CodexBarTests/CodexDashboardAuthorityTests.swift +++ b/Tests/CodexBarTests/CodexDashboardAuthorityTests.swift @@ -104,6 +104,91 @@ struct CodexDashboardAuthorityTests { #expect(decision.reason == .exactProviderAccountMatch) } + @Test + func `provider account exact owner ignores duplicate profile isolation`() { + let input = CodexDashboardAuthorityInput( + sourceKind: .liveWeb, + proof: CodexDashboardOwnershipProofContext( + currentIdentity: .providerAccount(id: "acct-owner"), + expectedScopedEmail: "owner@example.com", + trustedCurrentUsageEmail: nil, + dashboardSignedInEmail: "owner@example.com", + knownOwners: [ + CodexDashboardKnownOwnerCandidate( + identity: .providerAccount(id: "acct-owner"), + normalizedEmail: "owner@example.com", + sourceIsolationIdentifier: "profile-a"), + CodexDashboardKnownOwnerCandidate( + identity: .providerAccount(id: "acct-owner"), + normalizedEmail: "owner@example.com", + sourceIsolationIdentifier: "profile-b"), + ]), + routing: CodexDashboardRoutingHints( + targetEmail: "owner@example.com", + lastKnownDashboardRoutingEmail: nil)) + + let decision = CodexDashboardAuthority.evaluate(input) + + #expect(decision.disposition == .attach) + #expect(decision.reason == .exactProviderAccountMatch) + } + + @Test + func `email only owners retain profile isolation ambiguity`() { + let input = CodexDashboardAuthorityInput( + sourceKind: .liveWeb, + proof: CodexDashboardOwnershipProofContext( + currentIdentity: .emailOnly(normalizedEmail: "shared@example.com"), + expectedScopedEmail: "shared@example.com", + trustedCurrentUsageEmail: nil, + dashboardSignedInEmail: "shared@example.com", + knownOwners: [ + CodexDashboardKnownOwnerCandidate( + identity: .emailOnly(normalizedEmail: "shared@example.com"), + normalizedEmail: "shared@example.com", + sourceIsolationIdentifier: "profile-a"), + CodexDashboardKnownOwnerCandidate( + identity: .emailOnly(normalizedEmail: "shared@example.com"), + normalizedEmail: "shared@example.com", + sourceIsolationIdentifier: "profile-b"), + ]), + routing: CodexDashboardRoutingHints( + targetEmail: "shared@example.com", + lastKnownDashboardRoutingEmail: nil)) + + let decision = CodexDashboardAuthority.evaluate(input) + + #expect(decision.disposition == .displayOnly) + #expect(decision.reason == .sameEmailAmbiguity(email: "shared@example.com")) + } + + @Test + func `provider account exact owner stays display only when email has another owner`() { + let input = CodexDashboardAuthorityInput( + sourceKind: .liveWeb, + proof: CodexDashboardOwnershipProofContext( + currentIdentity: .providerAccount(id: "acct-current"), + expectedScopedEmail: "shared@example.com", + trustedCurrentUsageEmail: nil, + dashboardSignedInEmail: "shared@example.com", + knownOwners: [ + CodexDashboardKnownOwnerCandidate( + identity: .providerAccount(id: "acct-current"), + normalizedEmail: "shared@example.com"), + CodexDashboardKnownOwnerCandidate( + identity: .providerAccount(id: "acct-other"), + normalizedEmail: "shared@example.com"), + ]), + routing: CodexDashboardRoutingHints( + targetEmail: "shared@example.com", + lastKnownDashboardRoutingEmail: nil)) + + let decision = CodexDashboardAuthority.evaluate(input) + + #expect(decision.disposition == .displayOnly) + #expect(decision.reason == .sameEmailAmbiguity(email: "shared@example.com")) + } + @Test func `provider account same email ambiguity without exact match returns display only`() { let input = CodexDashboardAuthorityInput( diff --git a/Tests/CodexBarTests/CodexDashboardWeeklyPublicationTests.swift b/Tests/CodexBarTests/CodexDashboardWeeklyPublicationTests.swift new file mode 100644 index 0000000000..8c535b40b9 --- /dev/null +++ b/Tests/CodexBarTests/CodexDashboardWeeklyPublicationTests.swift @@ -0,0 +1,84 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +@MainActor +extension CodexAccountScopedRefreshTests { + @Test + func `dashboard cannot publish an unconfirmed first weekly low`() async { + let settings = self.makeSettingsStore( + suite: "CodexDashboardWeeklyPublicationTests-first-low") + settings.refreshFrequency = .manual + settings.codexCookieSource = .auto + settings._test_liveSystemCodexAccount = self.liveAccount( + email: "dashboard-low@example.com", + identity: .providerAccount(id: "dashboard-low-owner")) + defer { settings._test_liveSystemCodexAccount = nil } + + let now = Date() + let store = self.makeUsageStore(settings: settings) + await store.applyOpenAIDashboard( + OpenAIDashboardSnapshot( + signedInEmail: "dashboard-low@example.com", + codeReviewRemainingPercent: nil, + creditEvents: [], + dailyBreakdown: [], + usageBreakdown: [], + creditsPurchaseURL: nil, + primaryLimit: nil, + secondaryLimit: RateWindow( + usedPercent: 0.2, + windowMinutes: 10080, + resetsAt: now.addingTimeInterval(7 * 24 * 60 * 60), + resetDescription: nil), + creditsRemaining: nil, + accountPlan: "Pro", + updatedAt: now), + targetEmail: "dashboard-low@example.com", + allowCodexUsageBackfill: true) + + #expect(store.openAIDashboard?.signedInEmail == "dashboard-low@example.com") + #expect(store.snapshots[.codex] == nil) + #expect(store.lastKnownResetSnapshots[.codex] == nil) + #expect(store.lastSourceLabels[.codex] == nil) + } + + @Test + func `dashboard publishes an ordinary first weekly observation`() async { + let settings = self.makeSettingsStore( + suite: "CodexDashboardWeeklyPublicationTests-ordinary") + settings.refreshFrequency = .manual + settings.codexCookieSource = .auto + settings._test_liveSystemCodexAccount = self.liveAccount( + email: "dashboard-ordinary@example.com", + identity: .providerAccount(id: "dashboard-ordinary-owner")) + defer { settings._test_liveSystemCodexAccount = nil } + + let now = Date() + let store = self.makeUsageStore(settings: settings) + await store.applyOpenAIDashboard( + OpenAIDashboardSnapshot( + signedInEmail: "dashboard-ordinary@example.com", + codeReviewRemainingPercent: nil, + creditEvents: [], + dailyBreakdown: [], + usageBreakdown: [], + creditsPurchaseURL: nil, + primaryLimit: nil, + secondaryLimit: RateWindow( + usedPercent: 28, + windowMinutes: 10080, + resetsAt: now.addingTimeInterval(7 * 24 * 60 * 60), + resetDescription: nil), + creditsRemaining: nil, + accountPlan: "Pro", + updatedAt: now), + targetEmail: "dashboard-ordinary@example.com", + allowCodexUsageBackfill: true) + + #expect(store.openAIDashboard?.signedInEmail == "dashboard-ordinary@example.com") + #expect(store.snapshots[.codex]?.secondary?.usedPercent == 28) + #expect(store.lastSourceLabels[.codex] == "openai-web") + } +} diff --git a/Tests/CodexBarTests/CodexDashboardWorkedExampleParityTests.swift b/Tests/CodexBarTests/CodexDashboardWorkedExampleParityTests.swift index 6576b62b48..d5f839ec69 100644 --- a/Tests/CodexBarTests/CodexDashboardWorkedExampleParityTests.swift +++ b/Tests/CodexBarTests/CodexDashboardWorkedExampleParityTests.swift @@ -9,331 +9,354 @@ import Testing struct CodexDashboardWorkedExampleParityTests { @Test func `worked example A wrong email app and CLI both reject and retire owned state`() async throws { - OpenAIDashboardCacheStore.clear() - defer { OpenAIDashboardCacheStore.clear() } - - let store = self.makeAppStore(suite: "CodexDashboardWorkedExampleParityTests-example-a") - store.settings._test_liveSystemCodexAccount = self.liveAccount( - email: "work@company.com", - identity: .emailOnly(normalizedEmail: "work@company.com")) - store.settings.codexActiveSource = .liveSystem - - let attachedDashboard = self.makeDashboard( - email: "work@company.com", - creditsRemaining: 42, - usedPercent: 20) - let attachedCredits = self.credits(remaining: 42) - store._setSnapshotForTesting(self.codexSnapshot(email: "work@company.com", usedPercent: 20), provider: .codex) - store.lastSourceLabels[.codex] = "openai-web" - store.credits = attachedCredits - store.lastCreditsSnapshot = attachedCredits - store.lastCreditsSnapshotAccountKey = "work@company.com" - store.lastCreditsSource = .dashboardWeb - store.openAIDashboard = attachedDashboard - store.lastOpenAIDashboardSnapshot = attachedDashboard - store.lastOpenAIDashboardTargetEmail = "work@company.com" - OpenAIDashboardCacheStore.save(OpenAIDashboardCache( - accountEmail: "work@company.com", - snapshot: attachedDashboard)) - - await store.applyOpenAIDashboard( - self.makeDashboard( + try await self.withIsolatedDashboardCache { + OpenAIDashboardCacheStore.clear() + defer { OpenAIDashboardCacheStore.clear() } + + let store = self.makeAppStore(suite: "CodexDashboardWorkedExampleParityTests-example-a") + store.settings._test_liveSystemCodexAccount = self.liveAccount( + email: "work@company.com", + identity: .emailOnly(normalizedEmail: "work@company.com")) + store.settings.codexActiveSource = .liveSystem + + let attachedDashboard = self.makeDashboard( + email: "work@company.com", + creditsRemaining: 42, + usedPercent: 20) + let attachedCredits = self.credits(remaining: 42) + store._setSnapshotForTesting( + self.codexSnapshot(email: "work@company.com", usedPercent: 20), + provider: .codex) + store.lastSourceLabels[.codex] = "openai-web" + store.credits = attachedCredits + store.lastCreditsSnapshot = attachedCredits + store.lastCreditsSnapshotAccountKey = "work@company.com" + store.lastCreditsSource = .dashboardWeb + store.openAIDashboard = attachedDashboard + store.lastOpenAIDashboardSnapshot = attachedDashboard + store.lastOpenAIDashboardTargetEmail = "work@company.com" + OpenAIDashboardCacheStore.save(OpenAIDashboardCache( + accountEmail: "work@company.com", + snapshot: attachedDashboard)) + + await store.applyOpenAIDashboard( + self.makeDashboard( + email: "personal@gmail.com", + creditsRemaining: 9, + usedPercent: 35), + targetEmail: "work@company.com") + + #expect(store.openAIDashboard == nil) + #expect(store.lastOpenAIDashboardSnapshot == nil) + #expect(store.snapshots[.codex] == nil) + #expect(store.credits == nil) + #expect(store.lastCreditsSource == .none) + #expect(OpenAIDashboardCacheStore.load() == nil) + #expect(store.openAIDashboardRequiresLogin == true) + + let authHome = try self.makeAuthHome( + email: "work@company.com", + accountId: "acct-work") + defer { try? FileManager.default.removeItem(at: authHome) } + let cliContext = self.makeCLIContext( + authHome: authHome, + knownOwners: [ + CodexDashboardKnownOwnerCandidate( + identity: .providerAccount(id: "acct-work"), + normalizedEmail: "work@company.com"), + ]) + let wrongEmailDashboard = self.makeDashboard( email: "personal@gmail.com", creditsRemaining: 9, - usedPercent: 35), - targetEmail: "work@company.com") - - #expect(store.openAIDashboard == nil) - #expect(store.lastOpenAIDashboardSnapshot == nil) - #expect(store.snapshots[.codex] == nil) - #expect(store.credits == nil) - #expect(store.lastCreditsSource == .none) - #expect(OpenAIDashboardCacheStore.load() == nil) - #expect(store.openAIDashboardRequiresLogin == true) - - let authHome = try self.makeAuthHome( - email: "work@company.com", - accountId: "acct-work") - defer { try? FileManager.default.removeItem(at: authHome) } - let cliContext = self.makeCLIContext( - authHome: authHome, - knownOwners: [ - CodexDashboardKnownOwnerCandidate( - identity: .providerAccount(id: "acct-work"), - normalizedEmail: "work@company.com"), - ]) - let wrongEmailDashboard = self.makeDashboard( - email: "personal@gmail.com", - creditsRemaining: 9, - usedPercent: 35) - - do { - _ = try CodexWebDashboardStrategy.makeAuthorizedDashboardResultForTesting( - dashboard: wrongEmailDashboard, - context: cliContext, - routingTargetEmail: "work@company.com") - Issue.record("Expected OpenAIWebCodexError.policyRejected") - } catch let error as OpenAIWebCodexError { - if case let .policyRejected(decision) = error { - #expect(decision.reason == .wrongEmail(expected: "work@company.com", actual: "personal@gmail.com")) - } else { - Issue.record("Expected policyRejected, got \(error)") + usedPercent: 35) + + do { + _ = try CodexWebDashboardStrategy.makeAuthorizedDashboardResultForTesting( + dashboard: wrongEmailDashboard, + context: cliContext, + routingTargetEmail: "work@company.com") + Issue.record("Expected OpenAIWebCodexError.policyRejected") + } catch let error as OpenAIWebCodexError { + if case let .policyRejected(decision) = error { + #expect(decision.reason == .wrongEmail(expected: "work@company.com", actual: "personal@gmail.com")) + } else { + Issue.record("Expected policyRejected, got \(error)") + } + } catch { + Issue.record("Expected OpenAIWebCodexError.policyRejected, got \(error)") } - } catch { - Issue.record("Expected OpenAIWebCodexError.policyRejected, got \(error)") - } - OpenAIDashboardCacheStore.save(OpenAIDashboardCache( - accountEmail: "personal@gmail.com", - snapshot: wrongEmailDashboard)) + OpenAIDashboardCacheStore.save(OpenAIDashboardCache( + accountEmail: "personal@gmail.com", + snapshot: wrongEmailDashboard)) - let restored = CodexBarCLI.loadOpenAIDashboardIfAvailable( - usage: self.makeUsage(email: "work@company.com"), - sourceLabel: "codex-cli", - context: cliContext) + let restored = CodexBarCLI.loadOpenAIDashboardIfAvailable( + usage: self.makeUsage(email: "work@company.com"), + sourceLabel: "codex-cli", + context: cliContext) - #expect(restored == nil) - #expect(OpenAIDashboardCacheStore.load() == nil) + #expect(restored == nil) + #expect(OpenAIDashboardCacheStore.load() == nil) + } } @Test func `worked example B same email ambiguity is display only in app and non attach in CLI`() async throws { - OpenAIDashboardCacheStore.clear() - defer { OpenAIDashboardCacheStore.clear() } - - let managedHome = try self.makeAuthHome( - email: "work@company.com", - accountId: "acct-managed") - defer { try? FileManager.default.removeItem(at: managedHome) } - let managedAccount = ManagedCodexAccount( - id: UUID(), - email: "work@company.com", - managedHomePath: managedHome.path, - createdAt: 1, - updatedAt: 1, - lastAuthenticatedAt: 1) - let managedStoreURL = try self.makeManagedAccountStoreURL(accounts: [managedAccount]) - defer { try? FileManager.default.removeItem(at: managedStoreURL) } - - let store = self.makeAppStore(suite: "CodexDashboardWorkedExampleParityTests-example-b") - store.settings._test_managedCodexAccountStoreURL = managedStoreURL - store.settings._test_liveSystemCodexAccount = self.liveAccount( - email: "work@company.com", - identity: .emailOnly(normalizedEmail: "work@company.com")) - store.settings.codexActiveSource = .liveSystem - - await store.applyOpenAIDashboard( - self.makeDashboard( + try await self.withIsolatedDashboardCache { + OpenAIDashboardCacheStore.clear() + defer { OpenAIDashboardCacheStore.clear() } + + let managedHome = try self.makeAuthHome( + email: "work@company.com", + accountId: "acct-managed") + defer { try? FileManager.default.removeItem(at: managedHome) } + let managedAccount = ManagedCodexAccount( + id: UUID(), + email: "work@company.com", + managedHomePath: managedHome.path, + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1) + let managedStoreURL = try self.makeManagedAccountStoreURL(accounts: [managedAccount]) + defer { try? FileManager.default.removeItem(at: managedStoreURL) } + + let store = self.makeAppStore(suite: "CodexDashboardWorkedExampleParityTests-example-b") + store.settings._test_managedCodexAccountStoreURL = managedStoreURL + store.settings._test_liveSystemCodexAccount = self.liveAccount( + email: "work@company.com", + identity: .emailOnly(normalizedEmail: "work@company.com")) + store.settings.codexActiveSource = .liveSystem + + await store.applyOpenAIDashboard( + self.makeDashboard( + email: "work@company.com", + creditsRemaining: 14, + usedPercent: 30, + includeUsageBreakdown: true), + targetEmail: "work@company.com") + try await Task.sleep(for: .milliseconds(250)) + + #expect(store.openAIDashboard?.signedInEmail == "work@company.com") + #expect(store.snapshots[.codex] == nil) + #expect(store.credits == nil) + #expect(OpenAIDashboardCacheStore.load() == nil) + #expect(store.codexHistoricalDataset == nil) + + let cliAuthHome = try self.makeAuthHome(email: "work@company.com") + defer { try? FileManager.default.removeItem(at: cliAuthHome) } + let ambiguousOwners = [ + CodexDashboardKnownOwnerCandidate( + identity: .providerAccount(id: "acct-alpha"), + normalizedEmail: "work@company.com"), + CodexDashboardKnownOwnerCandidate( + identity: .providerAccount(id: "acct-beta"), + normalizedEmail: "work@company.com"), + ] + let cliContext = self.makeCLIContext( + authHome: cliAuthHome, + knownOwners: ambiguousOwners) + let dashboard = self.makeDashboard( email: "work@company.com", creditsRemaining: 14, usedPercent: 30, - includeUsageBreakdown: true), - targetEmail: "work@company.com") - try await Task.sleep(for: .milliseconds(250)) - - #expect(store.openAIDashboard?.signedInEmail == "work@company.com") - #expect(store.snapshots[.codex] == nil) - #expect(store.credits == nil) - #expect(OpenAIDashboardCacheStore.load() == nil) - #expect(store.codexHistoricalDataset == nil) - - let cliAuthHome = try self.makeAuthHome(email: "work@company.com") - defer { try? FileManager.default.removeItem(at: cliAuthHome) } - let ambiguousOwners = [ - CodexDashboardKnownOwnerCandidate( - identity: .providerAccount(id: "acct-alpha"), - normalizedEmail: "work@company.com"), - CodexDashboardKnownOwnerCandidate( - identity: .providerAccount(id: "acct-beta"), - normalizedEmail: "work@company.com"), - ] - let cliContext = self.makeCLIContext( - authHome: cliAuthHome, - knownOwners: ambiguousOwners) - let dashboard = self.makeDashboard( - email: "work@company.com", - creditsRemaining: 14, - usedPercent: 30, - includeUsageBreakdown: true) - let expectedDecision = CodexDashboardAuthority.evaluate( - CodexCLIDashboardAuthorityContext.makeLiveWebInput( - dashboard: dashboard, - context: cliContext, - routingTargetEmail: "work@company.com")) - - do { - _ = try CodexWebDashboardStrategy.makeAuthorizedDashboardResultForTesting( - dashboard: dashboard, - context: cliContext, - routingTargetEmail: "work@company.com") - Issue.record("Expected CodexDashboardPolicyError.displayOnly") - } catch let error as CodexDashboardPolicyError { - #expect(error == .displayOnly(expectedDecision)) - } catch { - Issue.record("Expected CodexDashboardPolicyError.displayOnly, got \(error)") - } + includeUsageBreakdown: true) + let expectedDecision = CodexDashboardAuthority.evaluate( + CodexCLIDashboardAuthorityContext.makeLiveWebInput( + dashboard: dashboard, + context: cliContext, + routingTargetEmail: "work@company.com")) + + do { + _ = try CodexWebDashboardStrategy.makeAuthorizedDashboardResultForTesting( + dashboard: dashboard, + context: cliContext, + routingTargetEmail: "work@company.com") + Issue.record("Expected CodexDashboardPolicyError.displayOnly") + } catch let error as CodexDashboardPolicyError { + #expect(error == .displayOnly(expectedDecision)) + } catch { + Issue.record("Expected CodexDashboardPolicyError.displayOnly, got \(error)") + } - OpenAIDashboardCacheStore.save(OpenAIDashboardCache( - accountEmail: "work@company.com", - snapshot: dashboard)) + OpenAIDashboardCacheStore.save(OpenAIDashboardCache( + accountEmail: "work@company.com", + snapshot: dashboard)) - let restored = CodexBarCLI.loadOpenAIDashboardIfAvailable( - usage: self.makeUsage(email: "work@company.com"), - sourceLabel: "codex-cli", - context: cliContext) + let restored = CodexBarCLI.loadOpenAIDashboardIfAvailable( + usage: self.makeUsage(email: "work@company.com"), + sourceLabel: "codex-cli", + context: cliContext) - #expect(restored == nil) - #expect(OpenAIDashboardCacheStore.load() == nil) + #expect(restored == nil) + #expect(OpenAIDashboardCacheStore.load() == nil) + } } @Test func `worked example C unresolved but proven continuity attaches in app and CLI`() async { - OpenAIDashboardCacheStore.clear() - defer { OpenAIDashboardCacheStore.clear() } - - let emptyHome = self.makeEmptyHome() - defer { try? FileManager.default.removeItem(at: emptyHome) } - - let store = self.makeAppStore(suite: "CodexDashboardWorkedExampleParityTests-example-c") - store.settings._test_codexReconciliationEnvironment = ["CODEX_HOME": emptyHome.path] - store.settings._test_liveSystemCodexAccount = nil - store.settings.codexActiveSource = .liveSystem - store._setSnapshotForTesting(self.codexSnapshot(email: "work@company.com", usedPercent: 12), provider: .codex) - store.lastSourceLabels[.codex] = "codex-cli" - - let dashboard = self.makeDashboard( - email: "work@company.com", - creditsRemaining: 33, - usedPercent: 12) - let appAuthority = store.evaluateCodexDashboardAuthority( - dashboard: dashboard, - sourceKind: .liveWeb, - routingTargetEmail: "work@company.com") - - await store.applyOpenAIDashboard(dashboard, targetEmail: "work@company.com") - - #expect(store.openAIDashboard?.signedInEmail == "work@company.com") - #expect(store.credits?.remaining == 33) - #expect(store.lastCreditsSource == .dashboardWeb) - #expect(store.lastCodexAccountScopedRefreshGuard?.accountKey == "work@company.com") - #expect(store.openAIDashboardRequiresLogin == false) - #expect(store.lastOpenAIDashboardError == nil) - - let cliContext = self.makeCLIContext(authHome: emptyHome, knownOwners: []) - OpenAIDashboardCacheStore.save(OpenAIDashboardCache( - accountEmail: "stale-route@example.com", - snapshot: dashboard)) - - let restored = CodexBarCLI.loadOpenAIDashboardIfAvailable( - usage: self.makeUsage(email: "work@company.com"), - sourceLabel: "codex-cli", - context: cliContext) - let cliInput = CodexCLIDashboardAuthorityContext.makeCachedDashboardInput( - dashboard: dashboard, - cachedAccountEmail: "stale-route@example.com", - usage: self.makeUsage(email: "work@company.com"), - sourceLabel: "codex-cli", - context: cliContext) - let cliDecision = CodexDashboardAuthority.evaluate(cliInput) - - #expect(restored == dashboard) - #expect(appAuthority.decision.disposition == .attach) - #expect(cliDecision.disposition == .attach) - #expect(appAuthority.decision.reason == .trustedContinuityNoCompetingOwner) - #expect(cliDecision.reason == .trustedContinuityNoCompetingOwner) + await self.withIsolatedDashboardCache { + OpenAIDashboardCacheStore.clear() + defer { OpenAIDashboardCacheStore.clear() } + + let emptyHome = self.makeEmptyHome() + defer { try? FileManager.default.removeItem(at: emptyHome) } + + let store = self.makeAppStore(suite: "CodexDashboardWorkedExampleParityTests-example-c") + store.settings._test_codexReconciliationEnvironment = ["CODEX_HOME": emptyHome.path] + store.settings._test_liveSystemCodexAccount = nil + store.settings.codexActiveSource = .liveSystem + store._setSnapshotForTesting( + self.codexSnapshot(email: "work@company.com", usedPercent: 12), + provider: .codex) + store.lastSourceLabels[.codex] = "codex-cli" + + let dashboard = self.makeDashboard( + email: "work@company.com", + creditsRemaining: 33, + usedPercent: 12) + let appAuthority = store.evaluateCodexDashboardAuthority( + dashboard: dashboard, + sourceKind: .liveWeb, + routingTargetEmail: "work@company.com") + + await store.applyOpenAIDashboard(dashboard, targetEmail: "work@company.com") + + #expect(store.openAIDashboard?.signedInEmail == "work@company.com") + #expect(store.credits?.remaining == 33) + #expect(store.lastCreditsSource == .dashboardWeb) + #expect(store.lastCodexAccountScopedRefreshGuard?.accountKey == "work@company.com") + #expect(store.openAIDashboardRequiresLogin == false) + #expect(store.lastOpenAIDashboardError == nil) + + let cliContext = self.makeCLIContext(authHome: emptyHome, knownOwners: []) + OpenAIDashboardCacheStore.save(OpenAIDashboardCache( + accountEmail: "stale-route@example.com", + snapshot: dashboard)) + + let restored = CodexBarCLI.loadOpenAIDashboardIfAvailable( + usage: self.makeUsage(email: "work@company.com"), + sourceLabel: "codex-cli", + context: cliContext) + let cliInput = CodexCLIDashboardAuthorityContext.makeCachedDashboardInput( + dashboard: dashboard, + cachedAccountEmail: "stale-route@example.com", + usage: self.makeUsage(email: "work@company.com"), + sourceLabel: "codex-cli", + context: cliContext) + let cliDecision = CodexDashboardAuthority.evaluate(cliInput) + + #expect(restored == dashboard) + #expect(appAuthority.decision.disposition == .attach) + #expect(cliDecision.disposition == .attach) + #expect(appAuthority.decision.reason == .trustedContinuityNoCompetingOwner) + #expect(cliDecision.reason == .trustedContinuityNoCompetingOwner) + } } @Test func `worked example D prior attach downgrades to ambiguity and retires old owned state`() async throws { - OpenAIDashboardCacheStore.clear() - defer { OpenAIDashboardCacheStore.clear() } - - let store = self.makeAppStore(suite: "CodexDashboardWorkedExampleParityTests-example-d") - store.settings._test_liveSystemCodexAccount = self.liveAccount( - email: "shared@example.com", - identity: .emailOnly(normalizedEmail: "shared@example.com")) - store.settings.codexActiveSource = .liveSystem - - let initialDashboard = self.makeDashboard( - email: "shared@example.com", - creditsRemaining: 21, - usedPercent: 18) - await store.applyOpenAIDashboard(initialDashboard, targetEmail: "shared@example.com") - - #expect(store.openAIDashboard?.signedInEmail == "shared@example.com") - #expect(store.snapshots[.codex]?.accountEmail(for: .codex) == "shared@example.com") - #expect(store.credits?.remaining == 21) - #expect(store.lastSourceLabels[.codex] == "openai-web") - #expect(OpenAIDashboardCacheStore.load()?.accountEmail == "shared@example.com") - - let managedHome = try self.makeAuthHome( - email: "shared@example.com", - accountId: "acct-managed") - defer { try? FileManager.default.removeItem(at: managedHome) } - let managedAccount = ManagedCodexAccount( - id: UUID(), - email: "shared@example.com", - managedHomePath: managedHome.path, - createdAt: 1, - updatedAt: 1, - lastAuthenticatedAt: 1) - let managedStoreURL = try self.makeManagedAccountStoreURL(accounts: [managedAccount]) - defer { try? FileManager.default.removeItem(at: managedStoreURL) } - store.settings._test_managedCodexAccountStoreURL = managedStoreURL - - await store.applyOpenAIDashboard( - self.makeDashboard( + try await self.withIsolatedDashboardCache { + OpenAIDashboardCacheStore.clear() + defer { OpenAIDashboardCacheStore.clear() } + + let store = self.makeAppStore(suite: "CodexDashboardWorkedExampleParityTests-example-d") + store.settings._test_liveSystemCodexAccount = self.liveAccount( email: "shared@example.com", - creditsRemaining: 9, - usedPercent: 35, - includeUsageBreakdown: true), - targetEmail: "shared@example.com") - - #expect(store.openAIDashboard?.signedInEmail == "shared@example.com") - #expect(store.lastOpenAIDashboardSnapshot?.signedInEmail == "shared@example.com") - #expect(store.snapshots[.codex] == nil) - #expect(store.credits == nil) - #expect(store.lastCreditsSource == .none) - #expect(OpenAIDashboardCacheStore.load() == nil) - - OpenAIDashboardCacheStore.clear() - let cliAuthHome = try self.makeAuthHome(email: "shared@example.com") - defer { try? FileManager.default.removeItem(at: cliAuthHome) } - let attachableContext = self.makeCLIContext( - authHome: cliAuthHome, - knownOwners: [ - CodexDashboardKnownOwnerCandidate( - identity: .providerAccount(id: "acct-alpha"), - normalizedEmail: "shared@example.com"), - ]) - OpenAIDashboardCacheStore.save(OpenAIDashboardCache( - accountEmail: "shared@example.com", - snapshot: initialDashboard)) - - let initiallyRestored = CodexBarCLI.loadOpenAIDashboardIfAvailable( - usage: self.makeUsage(email: "shared@example.com"), - sourceLabel: "codex-cli", - context: attachableContext) - #expect(initiallyRestored == initialDashboard) - - let ambiguousContext = self.makeCLIContext( - authHome: cliAuthHome, - knownOwners: [ - CodexDashboardKnownOwnerCandidate( - identity: .providerAccount(id: "acct-alpha"), - normalizedEmail: "shared@example.com"), - CodexDashboardKnownOwnerCandidate( - identity: .providerAccount(id: "acct-beta"), - normalizedEmail: "shared@example.com"), - ]) + identity: .emailOnly(normalizedEmail: "shared@example.com")) + store.settings.codexActiveSource = .liveSystem + + let initialDashboard = self.makeDashboard( + email: "shared@example.com", + creditsRemaining: 21, + usedPercent: 18) + await store.applyOpenAIDashboard(initialDashboard, targetEmail: "shared@example.com") + + #expect(store.openAIDashboard?.signedInEmail == "shared@example.com") + #expect(store.snapshots[.codex]?.accountEmail(for: .codex) == "shared@example.com") + #expect(store.credits?.remaining == 21) + #expect(store.lastSourceLabels[.codex] == "openai-web") + #expect(OpenAIDashboardCacheStore.load()?.accountEmail == "shared@example.com") - let downgradedRestored = CodexBarCLI.loadOpenAIDashboardIfAvailable( - usage: self.makeUsage(email: "shared@example.com"), - sourceLabel: "codex-cli", - context: ambiguousContext) + let managedHome = try self.makeAuthHome( + email: "shared@example.com", + accountId: "acct-managed") + defer { try? FileManager.default.removeItem(at: managedHome) } + let managedAccount = ManagedCodexAccount( + id: UUID(), + email: "shared@example.com", + managedHomePath: managedHome.path, + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1) + let managedStoreURL = try self.makeManagedAccountStoreURL(accounts: [managedAccount]) + defer { try? FileManager.default.removeItem(at: managedStoreURL) } + store.settings._test_managedCodexAccountStoreURL = managedStoreURL + + await store.applyOpenAIDashboard( + self.makeDashboard( + email: "shared@example.com", + creditsRemaining: 9, + usedPercent: 35, + includeUsageBreakdown: true), + targetEmail: "shared@example.com") + + #expect(store.openAIDashboard?.signedInEmail == "shared@example.com") + #expect(store.lastOpenAIDashboardSnapshot?.signedInEmail == "shared@example.com") + #expect(store.snapshots[.codex] == nil) + #expect(store.credits == nil) + #expect(store.lastCreditsSource == .none) + #expect(OpenAIDashboardCacheStore.load() == nil) + + OpenAIDashboardCacheStore.clear() + let cliAuthHome = try self.makeAuthHome(email: "shared@example.com") + defer { try? FileManager.default.removeItem(at: cliAuthHome) } + let attachableContext = self.makeCLIContext( + authHome: cliAuthHome, + knownOwners: [ + CodexDashboardKnownOwnerCandidate( + identity: .providerAccount(id: "acct-alpha"), + normalizedEmail: "shared@example.com"), + ]) + OpenAIDashboardCacheStore.save(OpenAIDashboardCache( + accountEmail: "shared@example.com", + snapshot: initialDashboard)) + + let initiallyRestored = CodexBarCLI.loadOpenAIDashboardIfAvailable( + usage: self.makeUsage(email: "shared@example.com"), + sourceLabel: "codex-cli", + context: attachableContext) + #expect(initiallyRestored == initialDashboard) + + let ambiguousContext = self.makeCLIContext( + authHome: cliAuthHome, + knownOwners: [ + CodexDashboardKnownOwnerCandidate( + identity: .providerAccount(id: "acct-alpha"), + normalizedEmail: "shared@example.com"), + CodexDashboardKnownOwnerCandidate( + identity: .providerAccount(id: "acct-beta"), + normalizedEmail: "shared@example.com"), + ]) + + let downgradedRestored = CodexBarCLI.loadOpenAIDashboardIfAvailable( + usage: self.makeUsage(email: "shared@example.com"), + sourceLabel: "codex-cli", + context: ambiguousContext) + + #expect(downgradedRestored == nil) + #expect(OpenAIDashboardCacheStore.load() == nil) + } + } - #expect(downgradedRestored == nil) - #expect(OpenAIDashboardCacheStore.load() == nil) + private func withIsolatedDashboardCache( + _ operation: () async throws -> T) async rethrows -> T + { + let cacheURL = FileManager.default.temporaryDirectory + .appendingPathComponent("codexbar-dashboard-\(UUID().uuidString).json") + defer { try? FileManager.default.removeItem(at: cacheURL) } + return try await OpenAIDashboardCacheStore.$cacheURLOverride.withValue(cacheURL) { + try await operation() + } } private func makeDashboard( @@ -401,12 +424,14 @@ struct CodexDashboardWorkedExampleParityTests { .appendingPathComponent("usage-history.jsonl") let planStore = testPlanUtilizationHistoryStore( suiteName: "CodexDashboardWorkedExampleParityTests-\(UUID().uuidString)") - return UsageStore( + let store = UsageStore( fetcher: UsageFetcher(environment: [:]), browserDetection: BrowserDetection(cacheTTL: 0), settings: settings, historicalUsageHistoryStore: HistoricalUsageHistoryStore(fileURL: historyURL), planUtilizationHistoryStore: planStore) + store._cancelPlanUtilizationHistoryLoadForTesting() + return store } private func makeCLIContext( diff --git a/Tests/CodexBarTests/CodexExecutableResolverTests.swift b/Tests/CodexBarTests/CodexExecutableResolverTests.swift new file mode 100644 index 0000000000..72117dfd96 --- /dev/null +++ b/Tests/CodexBarTests/CodexExecutableResolverTests.swift @@ -0,0 +1,42 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct CodexExecutableResolverTests { + @Test + func `explicit native override skips login path capture`() { + let resolved = resolveCodexExecutableForRPC( + environment: ["CODEX_CLI_PATH": "/usr/bin/true"], + executable: "codex", + captureLoginPATH: { + Issue.record("Native override should not capture a login-shell PATH") + return nil + }) + + #expect(resolved?.executable == "/usr/bin/true") + #expect(resolved?.loginPATH == nil) + } + + @Test + func `explicit script override captures login path for env based launchers`() throws { + let scriptURL = FileManager.default.temporaryDirectory + .appendingPathComponent("codexbar-script-override-\(UUID().uuidString)") + try Data("#!/usr/bin/env node\n".utf8).write(to: scriptURL) + try FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: scriptURL.path) + defer { try? FileManager.default.removeItem(at: scriptURL) } + + let loginPATH = ["/custom/node/bin", "/usr/bin"] + var captureCount = 0 + let resolved = resolveCodexExecutableForRPC( + environment: ["CODEX_CLI_PATH": scriptURL.path], + executable: "codex", + captureLoginPATH: { + captureCount += 1 + return loginPATH + }) + + #expect(resolved?.executable == scriptURL.path) + #expect(resolved?.loginPATH == loginPATH) + #expect(captureCount == 1) + } +} diff --git a/Tests/CodexBarTests/CodexLegacyWidgetSnapshotTests.swift b/Tests/CodexBarTests/CodexLegacyWidgetSnapshotTests.swift new file mode 100644 index 0000000000..97ad743689 --- /dev/null +++ b/Tests/CodexBarTests/CodexLegacyWidgetSnapshotTests.swift @@ -0,0 +1,54 @@ +import Foundation +import Testing +@testable import CodexBarCore +@testable import CodexBarWidget + +struct CodexLegacyWidgetSnapshotTests { + @Test + func `codex widget caps legacy decoded rows without window metadata`() throws { + let json = """ + { + "entries": [ + { + "provider": "codex", + "updatedAt": "2027-01-15T08:00:00Z", + "primary": { + "usedPercent": 1, + "windowMinutes": 300, + "resetsAt": "2027-01-15T09:00:00Z", + "resetDescription": null + }, + "secondary": { + "usedPercent": 100, + "windowMinutes": 10080, + "resetsAt": "2027-01-15T10:00:00Z", + "resetDescription": null + }, + "tertiary": null, + "usageRows": [ + { "id": "session", "title": "Session", "percentLeft": 99 }, + { "id": "weekly", "title": "Weekly", "percentLeft": 0 } + ], + "creditsRemaining": null, + "codeReviewRemainingPercent": null, + "tokenUsage": null, + "dailyUsage": [] + } + ], + "enabledProviders": ["codex"], + "generatedAt": "2027-01-15T08:00:00Z" + } + """ + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + let snapshot = try decoder.decode(WidgetSnapshot.self, from: Data(json.utf8)) + let entry = try #require(snapshot.entries.first) + let now = try #require(ISO8601DateFormatter().date(from: "2027-01-15T08:30:00Z")) + + let rows = WidgetUsageRow.rows(for: entry, now: now) + + #expect(entry.usageRows?.allSatisfy { $0.window == nil } == true) + #expect(rows.map(\.id) == ["session", "weekly"]) + #expect(rows.map(\.percentLeft) == [0, 0]) + } +} diff --git a/Tests/CodexBarTests/CodexLimitResetOwnerKeyTests.swift b/Tests/CodexBarTests/CodexLimitResetOwnerKeyTests.swift new file mode 100644 index 0000000000..362106e3f5 --- /dev/null +++ b/Tests/CodexBarTests/CodexLimitResetOwnerKeyTests.swift @@ -0,0 +1,215 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +@Suite(.serialized) +@MainActor +struct CodexLimitResetOwnerKeyTests { + @Test + func `limit reset owner stays stable for the same provider workspace and email`() throws { + let store = self.makeLimitResetOwnerStore(suffix: "provider-stability") + let original = self.limitResetVisibleAccount( + id: "original-row", + email: " Person-One@Example.Test ", + workspaceLabel: "Fixture Team", + workspaceAccountID: "workspace-fixture-stable", + authFingerprint: "auth-fixture-old") + let relabeled = self.limitResetVisibleAccount( + id: "relabeled-row", + email: "person-one@example.test", + workspaceLabel: "Renamed Fixture Team", + workspaceAccountID: " workspace-fixture-stable ", + authFingerprint: "auth-fixture-new") + + let originalKey = try #require(store.codexLimitResetOwnerKey( + forVisibleAccount: original, + visibleAccounts: [original])) + let relabeledKey = try #require(store.codexLimitResetOwnerKey( + forVisibleAccount: relabeled, + visibleAccounts: [relabeled])) + + #expect(originalKey == relabeledKey) + self.expectOpaqueLimitResetOwnerKey( + originalKey, + excludes: ["workspace-fixture-stable", "person-one@example.test"]) + } + + @Test + func `different emails in the same provider workspace use different owner keys`() throws { + let store = self.makeLimitResetOwnerStore(suffix: "provider-member-distinct") + let first = self.limitResetVisibleAccount( + id: "first-member", + email: "first-member@example.test", + workspaceAccountID: "workspace-fixture-shared") + let second = self.limitResetVisibleAccount( + id: "second-member", + email: "second-member@example.test", + workspaceAccountID: "workspace-fixture-shared") + + let firstKey = try #require(store.codexLimitResetOwnerKey( + forVisibleAccount: first, + visibleAccounts: [first, second])) + let secondKey = try #require(store.codexLimitResetOwnerKey( + forVisibleAccount: second, + visibleAccounts: [first, second])) + + #expect(firstKey != secondKey) + } + + @Test + func `different provider workspaces with the same email use different owner keys`() throws { + let store = self.makeLimitResetOwnerStore(suffix: "provider-distinct") + let first = self.limitResetVisibleAccount( + id: "first-row", + email: "shared-person@example.test", + workspaceAccountID: "workspace-fixture-one") + let second = self.limitResetVisibleAccount( + id: "second-row", + email: "shared-person@example.test", + workspaceAccountID: "workspace-fixture-two") + let visibleAccounts = [first, second] + + let firstKey = try #require(store.codexLimitResetOwnerKey( + forVisibleAccount: first, + visibleAccounts: visibleAccounts)) + let secondKey = try #require(store.codexLimitResetOwnerKey( + forVisibleAccount: second, + visibleAccounts: visibleAccounts)) + + #expect(firstKey != secondKey) + self.expectOpaqueLimitResetOwnerKey(firstKey, excludes: ["workspace-fixture-one", "shared-person@example.test"]) + self.expectOpaqueLimitResetOwnerKey( + secondKey, + excludes: ["workspace-fixture-two", "shared-person@example.test"]) + } + + @Test + func `email only owner fails closed even for one visible row`() { + let store = self.makeLimitResetOwnerStore(suffix: "email-unique") + let account = self.limitResetVisibleAccount( + id: "email-row-original", + email: " Unique-Person@Example.Test ", + workspaceLabel: "Fixture Personal", + authFingerprint: "auth-fixture-old") + + #expect(store.codexLimitResetOwnerKey(forVisibleAccount: account, visibleAccounts: [account]) == nil) + #expect(CodexLimitResetOwnerKey( + identity: .emailOnly(normalizedEmail: "unique-person@example.test"), + accountEmail: "unique-person@example.test") == nil) + } + + @Test + func `duplicate email only rows fail closed`() { + let store = self.makeLimitResetOwnerStore(suffix: "email-ambiguous") + let first = self.limitResetVisibleAccount( + id: "email-row-one", + email: "ambiguous-person@example.test") + let second = self.limitResetVisibleAccount( + id: "email-row-two", + email: " Ambiguous-Person@Example.Test ") + let visibleAccounts = [first, second] + + #expect(store.codexLimitResetOwnerKey(forVisibleAccount: first, visibleAccounts: visibleAccounts) == nil) + #expect(store.codexLimitResetOwnerKey(forVisibleAccount: second, visibleAccounts: visibleAccounts) == nil) + } + + @Test + func `provider row wins its own identity while same email fallback fails closed`() throws { + let store = self.makeLimitResetOwnerStore(suffix: "mixed-identity") + let providerBacked = self.limitResetVisibleAccount( + id: "provider-row", + email: "mixed-person@example.test", + workspaceAccountID: "workspace-fixture-provider") + let emailOnly = self.limitResetVisibleAccount( + id: "email-row", + email: "mixed-person@example.test") + let visibleAccounts = [providerBacked, emailOnly] + + let providerKey = try #require(store.codexLimitResetOwnerKey( + forVisibleAccount: providerBacked, + visibleAccounts: visibleAccounts)) + + #expect(providerKey == CodexLimitResetOwnerKey( + identity: .providerAccount(id: "workspace-fixture-provider"), + accountEmail: "mixed-person@example.test")) + #expect(store.codexLimitResetOwnerKey(forVisibleAccount: emailOnly, visibleAccounts: visibleAccounts) == nil) + } + + @Test + func `guard and visible row normalize the same provider owner`() throws { + let store = self.makeLimitResetOwnerStore(suffix: "provider-normalization") + let account = self.limitResetVisibleAccount( + id: "provider-row", + email: "provider-person@example.test", + workspaceAccountID: " workspace-fixture-mixed-case ") + let guardValue = CodexAccountScopedRefreshGuard( + source: account.selectionSource, + identity: .providerAccount(id: " WORKSPACE-FIXTURE-MIXED-CASE "), + accountKey: account.email) + + let visibleKey = try #require(store.codexLimitResetOwnerKey( + forVisibleAccount: account, + visibleAccounts: [account])) + let guardKey = try #require(store.codexLimitResetOwnerKey( + expectedGuard: guardValue, + visibleAccounts: [account])) + + #expect(visibleKey == guardKey) + } + + @Test + func `unresolved owner identity fails closed`() { + let store = self.makeLimitResetOwnerStore(suffix: "unresolved") + let guardValue = CodexAccountScopedRefreshGuard( + source: .liveSystem, + identity: .unresolved, + accountKey: nil) + let unresolvedRow = self.limitResetVisibleAccount(id: "unresolved-row", email: " ") + + #expect(store.codexLimitResetOwnerKey(expectedGuard: guardValue, visibleAccounts: []) == nil) + #expect(store.codexLimitResetOwnerKey( + forVisibleAccount: unresolvedRow, + visibleAccounts: [unresolvedRow]) == nil) + } + + private func makeLimitResetOwnerStore(suffix: String) -> UsageStore { + let support = CodexAccountScopedRefreshTests() + let settings = support.makeSettingsStore(suite: "CodexLimitResetOwnerKeyTests-\(suffix)") + return support.makeUsageStore(settings: settings) + } + + private func limitResetVisibleAccount( + id: String, + email: String, + workspaceLabel: String? = nil, + workspaceAccountID: String? = nil, + authFingerprint: String? = nil) -> CodexVisibleAccount + { + CodexVisibleAccount( + id: id, + email: email, + workspaceLabel: workspaceLabel, + workspaceAccountID: workspaceAccountID, + authFingerprint: authFingerprint, + storedAccountID: nil, + selectionSource: .profileHome(path: "/tmp/\(id)"), + isActive: false, + isLive: false, + canReauthenticate: true, + canRemove: true) + } + + private func expectOpaqueLimitResetOwnerKey( + _ key: CodexLimitResetOwnerKey, + excludes cleartextValues: [String], + sourceLocation: SourceLocation = #_sourceLocation) + { + #expect( + key.rawValue.range(of: #"^[0-9a-f]{64}$"#, options: .regularExpression) != nil, + sourceLocation: sourceLocation) + for cleartextValue in cleartextValues { + #expect(!key.rawValue.contains(cleartextValue), sourceLocation: sourceLocation) + } + } +} diff --git a/Tests/CodexBarTests/CodexLocalProjectUsageTests.swift b/Tests/CodexBarTests/CodexLocalProjectUsageTests.swift new file mode 100644 index 0000000000..2127cbe690 --- /dev/null +++ b/Tests/CodexBarTests/CodexLocalProjectUsageTests.swift @@ -0,0 +1,1736 @@ +import Foundation +#if canImport(SQLite3) +import SQLite3 +#endif +import Testing +@testable import CodexBar +@testable import CodexBarCore + +@MainActor +// Shared JSONL/SQLite fixtures make the attribution and sidecar assertions +// readable without duplicating test environments across several files. +// swiftlint:disable file_length +// swiftlint:disable type_body_length +struct CodexLocalProjectUsageTests { + private final class ProgressRecorder: @unchecked Sendable { + private let lock = NSLock() + private var events: [CodexLocalProjectUsageIndexProgress] = [] + + func append(_ progress: CodexLocalProjectUsageIndexProgress) { + self.lock.lock() + defer { self.lock.unlock() } + self.events.append(progress) + } + + var snapshot: [CodexLocalProjectUsageIndexProgress] { + self.lock.lock() + defer { self.lock.unlock() } + return self.events + } + } + + private struct CodexUsageFixture { + var filename: String + var sessionID: String + var cwd: String? + var input: Int + var cached: Int + var output: Int + } + + @Test + func `project root resolver keeps sibling paths distinct`() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("codexbar-project-root-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + let app = root.appendingPathComponent("app", isDirectory: true) + let appOld = root.appendingPathComponent("app-old", isDirectory: true) + let appSource = app.appendingPathComponent("Sources", isDirectory: true) + let appOldSource = appOld.appendingPathComponent("Sources", isDirectory: true) + try FileManager.default.createDirectory( + at: app.appendingPathComponent(".git", isDirectory: true), + withIntermediateDirectories: true) + try FileManager.default.createDirectory( + at: appOld.appendingPathComponent(".git", isDirectory: true), + withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: appSource, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: appOldSource, withIntermediateDirectories: true) + + let appIdentity = CodexLocalProjectRootResolver.projectIdentity(for: appSource.path) + let appOldIdentity = CodexLocalProjectRootResolver.projectIdentity(for: appOldSource.path) + + #expect(appIdentity.path == app.standardizedFileURL.path) + #expect(appOldIdentity.path == appOld.standardizedFileURL.path) + #expect(appIdentity.id != appOldIdentity.id) + } + + @Test + func `local data scope avoids persisting raw Codex home paths`() { + let home = FileManager.default.temporaryDirectory + .appendingPathComponent("workspaces-private-home", isDirectory: true) + let scope = CodexLocalDataScope.resolve(options: CostUsageScanner.Options( + codexSessionsRoot: home.appendingPathComponent("sessions", isDirectory: true))) + + #expect(scope.codexHome == home.standardizedFileURL) + #expect(scope.identifier.hasPrefix("codex-workspaces:")) + #expect(!scope.identifier.contains(home.path)) + } + + @Test + func `v10 cache remains untouched while v11 rebuilds and then refreshes incrementally`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = Date() + let costCacheRoot = env.cacheRoot.appendingPathComponent("cost-usage", isDirectory: true) + try FileManager.default.createDirectory(at: costCacheRoot, withIntermediateDirectories: true) + let v10URL = costCacheRoot.appendingPathComponent("codex-v10.json", isDirectory: false) + let v10Bytes = Data("recoverable-v10-cursor".utf8) + try v10Bytes.write(to: v10URL) + let v11URL = CostUsageCacheIO.cacheFileURL(provider: .codex, cacheRoot: env.cacheRoot) + #expect(!FileManager.default.fileExists(atPath: v11URL.path)) + + try self.writeCodexUsageFile( + env: env, + day: day, + fixture: CodexUsageFixture( + filename: "upgrade-first.jsonl", + sessionID: "upgrade-first", + cwd: env.root.path, + input: 100, + cached: 20, + output: 30)) + var options = CostUsageScanner.Options( + codexSessionsRoot: env.codexSessionsRoot, + cacheRoot: env.cacheRoot) + options.refreshMinIntervalSeconds = 0 + let first = CostUsageScanner.loadDailyReport( + provider: .codex, + since: day, + until: day, + now: day, + options: options) + + #expect(first.data.first?.totalTokens == 130) + #expect(try Data(contentsOf: v10URL) == v10Bytes) + #expect(FileManager.default.fileExists(atPath: v11URL.path)) + #expect(CostUsageCacheIO.load(provider: .codex, cacheRoot: env.cacheRoot).files.count == 1) + + try self.writeCodexUsageFile( + env: env, + day: day, + fixture: CodexUsageFixture( + filename: "upgrade-second.jsonl", + sessionID: "upgrade-second", + cwd: env.root.path, + input: 40, + cached: 5, + output: 10)) + let warmNow = Date().addingTimeInterval(1) + let warm = CostUsageScanner.loadDailyReport( + provider: .codex, + since: day, + until: warmNow, + now: warmNow, + options: options) + + #expect(warm.data.first?.totalTokens == 180) + #expect(CostUsageCacheIO.load(provider: .codex, cacheRoot: env.cacheRoot).files.count == 2) + #expect(try Data(contentsOf: v10URL) == v10Bytes) + } + + @Test + func `project root resolver treats git file worktree as most specific project`() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("codexbar-project-worktree-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + let app = root.appendingPathComponent("app", isDirectory: true) + let worktree = app.appendingPathComponent("worktree", isDirectory: true) + let source = worktree.appendingPathComponent("Sources", isDirectory: true) + try FileManager.default.createDirectory( + at: app.appendingPathComponent(".git", isDirectory: true), + withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: source, withIntermediateDirectories: true) + try "gitdir: ../.git/worktrees/worktree\n".write( + to: worktree.appendingPathComponent(".git", isDirectory: false), + atomically: true, + encoding: .utf8) + + let identity = CodexLocalProjectRootResolver.projectIdentity(for: source.path) + + #expect(identity.path == worktree.standardizedFileURL.path) + #expect(identity.displayName == "worktree") + } + + @Test + func `project root resolver preserves missing logged CWD when no git root exists`() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("codexbar-missing-cwd-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + let loggedCWD = root.appendingPathComponent("deleted-project/Sources", isDirectory: true) + + let identity = CodexLocalProjectRootResolver.projectIdentity(for: loggedCWD.path) + + #expect(identity.path == loggedCWD.standardizedFileURL.path) + #expect(identity.displayName == "Sources") + } + + @Test + func `project root resolver preserves a deleted CWD below an existing repository`() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + let repository = root.appendingPathComponent("project", isDirectory: true) + let deletedCWD = repository.appendingPathComponent("removed-worktree", isDirectory: true) + try FileManager.default.createDirectory( + at: repository.appendingPathComponent(".git", isDirectory: true), + withIntermediateDirectories: true) + + let identity = CodexLocalProjectRootResolver.projectIdentity(for: deletedCWD.path) + + #expect(identity.path == deletedCWD.standardizedFileURL.path) + #expect(identity.displayName == "removed-worktree") + } + + @Test + func `project root resolver canonicalizes a live symlinked repository`() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + let repository = root.appendingPathComponent("project", isDirectory: true) + let source = repository.appendingPathComponent("Sources", isDirectory: true) + let symlink = root.appendingPathComponent("project-link", isDirectory: true) + try FileManager.default.createDirectory( + at: repository.appendingPathComponent(".git", isDirectory: true), + withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: source, withIntermediateDirectories: true) + try FileManager.default.createSymbolicLink(atPath: symlink.path, withDestinationPath: repository.path) + + let direct = CodexLocalProjectRootResolver.projectIdentity(for: source.path) + let linked = CodexLocalProjectRootResolver.projectIdentity( + for: symlink.appendingPathComponent("Sources", isDirectory: true).path) + + #expect(linked.id == direct.id) + #expect(linked.path == repository.standardizedFileURL.path) + } + + @Test + func `project usage index aggregates projects and chats from existing codex scan cache`() async throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = Date() + let project = env.root.appendingPathComponent("CodexBar", isDirectory: true) + let projectSource = project.appendingPathComponent("Sources", isDirectory: true) + try FileManager.default.createDirectory( + at: project.appendingPathComponent(".git", isDirectory: true), + withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: projectSource, withIntermediateDirectories: true) + + try self.writeCodexUsageFile( + env: env, + day: day, + fixture: CodexUsageFixture( + filename: "project.jsonl", + sessionID: "project-session", + cwd: projectSource.path, + input: 100, + cached: 20, + output: 30)) + try self.writeCodexUsageFile( + env: env, + day: day, + fixture: CodexUsageFixture( + filename: "chat.jsonl", + sessionID: "chat-session", + cwd: nil, + input: 50, + cached: 5, + output: 10)) + + let options = CostUsageScanner.Options( + codexSessionsRoot: env.codexSessionsRoot, + cacheRoot: env.cacheRoot) + let snapshot = try await CostUsageFetcher.loadCodexLocalProjectUsageSnapshot( + now: day, + forceRefresh: true, + historyDays: 2, + hidePersonalInfo: false, + scannerOptions: options) + + #expect(snapshot.indexedFileCount == 2) + #expect(snapshot.projects.map(\.displayName) == ["CodexBar", "Chats"]) + #expect(snapshot.projects.first?.totals.totalTokens == 130) + #expect(snapshot.projects.first?.totals.cachedInputTokens == 20) + #expect(snapshot.projects.first?.path == project.standardizedFileURL.path) + #expect(snapshot.projects.first?.estimatedCostUSD != nil) + #expect(snapshot.projects.first?.modelBreakdowns.first?.estimatedCostUSD != nil) + #expect(snapshot.projects.first?.modelBreakdowns.first?.hasUnknownCost == false) + #expect(snapshot.projects.first?.daily.first?.totalTokens == 130) + #expect(snapshot.projects.last?.id == CodexLocalProjectRootResolver.chatsProjectId) + #expect(snapshot.projects.last?.daily.first?.totalTokens == 60) + #expect(snapshot.total.totalTokens == 190) + #expect(snapshot.sessions.count == 2) + #expect(snapshot.daily.first?.totalTokens == 190) + let hiddenSnapshot = try #require(await CostUsageFetcher.loadCachedCodexLocalProjectUsageSnapshot( + now: day, + historyDays: 2, + hidePersonalInfo: true, + scannerOptions: options)) + #expect(hiddenSnapshot.rootsFingerprint.isEmpty) + #expect(hiddenSnapshot.projects.first?.displayName == "Workspace") + #expect(hiddenSnapshot.projects.first?.path == nil) + #expect(hiddenSnapshot.projects.first?.topSessions.first?.displayTitle + == CodexLocalSessionUsage.localChatFallbackTitle) + #expect(hiddenSnapshot.projects.first?.topSessions.first?.cwd == nil) + #expect(hiddenSnapshot.sessions.allSatisfy { + $0.displayTitle == CodexLocalSessionUsage.localChatFallbackTitle && $0.cwd == nil + }) + #expect(snapshot.projects.first?.displayName == "CodexBar") + #expect(snapshot.projects.first?.path == project.standardizedFileURL.path) + let allModels = try #require(snapshot.modelsAnalytics?.allWorkspaces) + #if canImport(SQLite3) || canImport(CSQLite3) + #expect(snapshot.sourceStatus == .catalogMissing) + #expect(allModels.currentIsComplete == false) + #expect(allModels.previousIsComplete == false) + #else + #expect(snapshot.sourceStatus == .complete) + #expect(allModels.currentIsComplete == true) + #expect(allModels.previousIsComplete == true) + #endif + } + + @Test + func `project usage index uses cached codex session metadata without reading jsonl`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = Date() + let project = env.root.appendingPathComponent("CodexBar", isDirectory: true) + try FileManager.default.createDirectory( + at: project.appendingPathComponent(".git", isDirectory: true), + withIntermediateDirectories: true) + let fixture = CodexUsageFixture( + filename: "missing.jsonl", + sessionID: "cached-session", + cwd: project.path, + input: 100, + cached: 20, + output: 30) + let options = CostUsageScanner.Options( + codexSessionsRoot: env.codexSessionsRoot, + cacheRoot: env.cacheRoot) + let dayKey = CostUsageScanner.CostUsageDayRange.dayKey(from: day) + let missingFileURL = env.root.appendingPathComponent("missing-session.jsonl", isDirectory: false) + var cache = CostUsageCache() + cache.scanSinceKey = dayKey + cache.scanUntilKey = dayKey + cache.roots = CostUsageScanner.codexRootsFingerprint(options: options) + cache.files[missingFileURL.path] = self.makeCachedFileUsage( + dayKey: dayKey, + fixture: fixture, + costNanos: 1) + CostUsageCacheIO.save(provider: .codex, cache: cache, cacheRoot: env.cacheRoot) + + let snapshot = try CodexLocalProjectUsageIndexer.buildSnapshotFromCostCache( + now: day, + historyDays: 1, + since: day, + until: day, + options: options) + + #expect(FileManager.default.fileExists(atPath: missingFileURL.path) == false) + #expect(snapshot.projects.count == 1) + #expect(snapshot.projects.first?.displayName == "CodexBar") + #expect(snapshot.projects.first?.path == project.standardizedFileURL.path) + #expect(snapshot.projects.first?.totals.totalTokens == 130) + } + + @Test + func `project usage index uses codex state database catalog when available`() throws { + #if canImport(SQLite3) + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = Date(timeIntervalSince1970: 1_800_000_000) + let project = env.root.appendingPathComponent("CatalogProject", isDirectory: true) + let projectSource = project.appendingPathComponent("Sources", isDirectory: true) + try FileManager.default.createDirectory( + at: project.appendingPathComponent(".git", isDirectory: true), + withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: projectSource, withIntermediateDirectories: true) + + let dayKey = CostUsageScanner.CostUsageDayRange.dayKey(from: day) + let rolloutURL = env.codexSessionsRoot.appendingPathComponent("catalog-session.jsonl", isDirectory: false) + let stateDatabaseURL = env.codexHomeRoot.appendingPathComponent("state_5.sqlite", isDirectory: false) + try self.writeCodexStateDatabase( + at: stateDatabaseURL, + thread: CodexStateThreadFixture( + id: "catalog-session", + rolloutPath: rolloutURL.path, + cwd: projectSource.path, + title: "Catalog title", + preview: "Catalog preview", + model: "openai/gpt-5.4-catalog", + createdAtUnixMs: 1_800_000_000_000, + updatedAtUnixMs: 1_800_000_120_000)) + + var cache = CostUsageCache() + let options = CostUsageScanner.Options( + codexSessionsRoot: env.codexSessionsRoot, + cacheRoot: env.cacheRoot) + cache.scanSinceKey = dayKey + cache.scanUntilKey = dayKey + cache.roots = CostUsageScanner.codexRootsFingerprint(options: options) + cache.files[rolloutURL.path] = self.makeCachedFileUsage( + dayKey: dayKey, + fixture: CodexUsageFixture( + filename: "catalog-session.jsonl", + sessionID: "catalog-session", + cwd: nil, + input: 100, + cached: 10, + output: 25), + costNanos: 1) + CostUsageCacheIO.save(provider: .codex, cache: cache, cacheRoot: env.cacheRoot) + + let snapshot = try CodexLocalProjectUsageIndexer.buildSnapshotFromCostCache( + now: day, + historyDays: 1, + since: day, + until: day, + options: options) + + #expect(snapshot.projects.map(\.displayName) == ["CatalogProject"]) + #expect(snapshot.projects.first?.path == project.standardizedFileURL.path) + #expect(snapshot.sessions.first?.displayTitle == "Catalog title") + #expect(snapshot.sessions.first?.cwd == projectSource.path) + #expect(snapshot.sessions.first?.latestActivity == Date(timeIntervalSince1970: 1_800_000_120)) + #expect(snapshot.sessions.first?.topModel == "openai/gpt-5.4-catalog") + #expect(snapshot.total.totalTokens == 125) + #else + #expect(Bool(true)) + #endif + } + + @Test + func `catalog reader normalizes legacy seconds timestamps to milliseconds`() throws { + #if canImport(SQLite3) + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let rolloutURL = env.codexSessionsRoot.appendingPathComponent("legacy-timestamp.jsonl", isDirectory: false) + try self.writeCodexStateDatabase( + at: env.codexHomeRoot.appendingPathComponent("state_5.sqlite", isDirectory: false), + thread: CodexStateThreadFixture( + id: "legacy-timestamp", + rolloutPath: rolloutURL.path, + cwd: env.root.path, + title: "Legacy timestamp", + preview: "Legacy timestamp preview", + model: "openai/gpt-5.4", + createdAtUnixMs: 1_800_000_000_000, + updatedAtUnixMs: 1_800_000_120_000, + usesLegacyTimestampColumns: true)) + + let options = CostUsageScanner.Options( + codexSessionsRoot: env.codexSessionsRoot, + cacheRoot: env.cacheRoot) + let entry = try #require(CodexThreadCatalogReader.load(options: options).entriesById["legacy-timestamp"]) + + #expect(entry.createdAtUnixMs == 1_800_000_000_000) + #expect(entry.updatedAtUnixMs == 1_800_000_120_000) + #else + #expect(Bool(true)) + #endif + } + + @Test + func `cached refresh surfaces catalog degradation while retaining last good usage`() throws { + #if canImport(SQLite3) + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = Date(timeIntervalSince1970: 1_800_000_000) + let project = env.root.appendingPathComponent("CachedCatalogProject", isDirectory: true) + let source = project.appendingPathComponent("Sources", isDirectory: true) + try FileManager.default.createDirectory( + at: project.appendingPathComponent(".git", isDirectory: true), + withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: source, withIntermediateDirectories: true) + let rolloutURL = env.codexSessionsRoot.appendingPathComponent("cached-catalog.jsonl", isDirectory: false) + let catalogURL = env.codexHomeRoot.appendingPathComponent("state_5.sqlite", isDirectory: false) + try self.writeCodexStateDatabase( + at: catalogURL, + thread: CodexStateThreadFixture( + id: "cached-catalog", + rolloutPath: rolloutURL.path, + cwd: source.path, + title: "Cached catalog title", + preview: "Cached catalog preview", + model: "openai/gpt-5.4-catalog", + createdAtUnixMs: 1_800_000_000_000, + updatedAtUnixMs: 1_800_000_120_000)) + try self.writeCodexUsageFile( + env: env, + day: day, + fixture: CodexUsageFixture( + filename: "cached-catalog.jsonl", + sessionID: "cached-catalog", + cwd: nil, + input: 100, + cached: 10, + output: 25)) + var options = CostUsageScanner.Options( + codexSessionsRoot: env.codexSessionsRoot, + cacheRoot: env.cacheRoot) + options.refreshMinIntervalSeconds = 0 + + let complete = try CodexLocalProjectUsageIndexer.loadSnapshot( + now: day, + historyDays: 1, + forceRefresh: true, + options: .init(scannerOptions: options)) + try FileManager.default.removeItem(at: catalogURL) + let degraded = try CodexLocalProjectUsageIndexer.loadSnapshot( + now: day, + historyDays: 1, + options: .init(scannerOptions: options)) + + #expect(complete.sourceStatus == .complete) + #expect(degraded.sourceStatus == .catalogMissing) + #expect(degraded.total == complete.total) + #expect(degraded.projects == complete.projects) + #expect(degraded.sessions == complete.sessions) + #else + #expect(Bool(true)) + #endif + } + + @Test + func `sidecar retains catalog metadata when a sparse rollout update arrives during catalog failure`() throws { + #if canImport(SQLite3) + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = Date(timeIntervalSince1970: 1_800_000_000) + let dayKey = CostUsageScanner.CostUsageDayRange.dayKey(from: day) + let rolloutURL = env.codexSessionsRoot.appendingPathComponent("retained-metadata.jsonl", isDirectory: false) + let project = env.root.appendingPathComponent("RetainedCatalogProject", isDirectory: true) + try FileManager.default.createDirectory(at: project, withIntermediateDirectories: true) + try self.writeCodexStateDatabase( + at: env.codexHomeRoot.appendingPathComponent("state_5.sqlite", isDirectory: false), + thread: CodexStateThreadFixture( + id: "retained-session", + rolloutPath: rolloutURL.path, + cwd: project.path, + title: "Retained catalog title", + preview: "Retained catalog preview", + model: "openai/gpt-5.4-catalog", + createdAtUnixMs: 1_800_000_000_000, + updatedAtUnixMs: 1_800_000_120_000)) + + let options = CostUsageScanner.Options( + codexSessionsRoot: env.codexSessionsRoot, + cacheRoot: env.cacheRoot) + let sparseFixture = CodexUsageFixture( + filename: "retained-metadata.jsonl", + sessionID: "retained-session", + cwd: nil, + input: 100, + cached: 0, + output: 20) + var cache = CostUsageCache() + cache.roots = CostUsageScanner.codexRootsFingerprint(options: options) + cache.files[rolloutURL.path] = self.makeCachedFileUsage( + dayKey: dayKey, + fixture: sparseFixture, + costNanos: 1) + let sidecar = CodexWorkspaceUsageSidecar(cacheRoot: env.cacheRoot) + try sidecar.synchronizeSources(cache: cache, catalog: CodexThreadCatalogReader.load(options: options)) + + var changedFixture = sparseFixture + changedFixture.input = 110 + var changedUsage = self.makeCachedFileUsage( + dayKey: dayKey, + fixture: changedFixture, + costNanos: 1) + changedUsage.mtimeUnixMs = 1 + cache.files[rolloutURL.path] = changedUsage + try sidecar.synchronizeSources(cache: cache, catalog: .empty, catalogIsComplete: false) + + let rehydrated = try sidecar.usageCache(roots: cache.roots ?? [:]) + let metadata = rehydrated.files[rolloutURL.path]?.codexSession + #expect(metadata?.cwd == project.path) + #expect(metadata?.title == "Retained catalog title") + #expect(rehydrated.files[rolloutURL.path]?.days[dayKey]?["openai/gpt-5.4"]?[0] == 110) + #else + #expect(Bool(true)) + #endif + } + + @Test + func `sidecar prunes catalog metadata absent from a complete generation`() throws { + #if canImport(SQLite3) + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let rolloutURL = env.codexSessionsRoot.appendingPathComponent("pruned-catalog.jsonl", isDirectory: false) + let entry = CodexThreadCatalogEntry( + id: "pruned-catalog", + rolloutPath: rolloutURL.path, + cwd: "/catalog/cwd", + title: "Catalog title", + preview: "Catalog preview", + modelProvider: "openai", + model: "openai/gpt-5.4-catalog", + reasoningEffort: "high", + createdAtUnixMs: 1_800_000_000_000, + updatedAtUnixMs: 1_800_000_120_000, + archived: false) + let catalog = CodexThreadCatalog( + entriesById: [entry.id: entry], + entriesByRolloutPath: [rolloutURL.standardizedFileURL.path: entry], + fingerprint: "complete-generation-1") + var cache = CostUsageCache() + cache.files[rolloutURL.path] = self.makeCachedFileUsage( + dayKey: "2027-01-15", + fixture: CodexUsageFixture( + filename: "pruned-catalog.jsonl", + sessionID: entry.id, + cwd: "/rollout/cwd", + input: 100, + cached: 0, + output: 20), + costNanos: 1) + let sidecar = CodexWorkspaceUsageSidecar(cacheRoot: env.cacheRoot) + try sidecar.synchronizeSources(cache: cache, catalog: catalog) + #expect(try sidecar.usageCache(roots: [:]).files[rolloutURL.path]?.codexSession?.cwd == "/catalog/cwd") + + try sidecar.synchronizeSources(cache: cache, catalog: .empty) + + #expect(try sidecar.usageCache(roots: [:]).files[rolloutURL.path]?.codexSession?.cwd == "/rollout/cwd") + #else + #expect(Bool(true)) + #endif + } + + @Test + func `catalog reader distinguishes missing and corrupt state databases`() throws { + #if canImport(SQLite3) + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + let options = CostUsageScanner.Options( + codexSessionsRoot: env.codexSessionsRoot, + cacheRoot: env.cacheRoot) + + #expect(CodexThreadCatalogReader.loadResult(options: options).completeness == .unavailable(.missing)) + + let databaseURL = env.codexHomeRoot.appendingPathComponent("state_5.sqlite", isDirectory: false) + try "not a SQLite database".write(to: databaseURL, atomically: true, encoding: .utf8) + #expect(CodexThreadCatalogReader.loadResult(options: options).completeness == .unavailable(.corrupt)) + #else + #expect(Bool(true)) + #endif + } + + @Test + func `cached project usage snapshot preserves last complete data when pricing changes`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = Date() + let project = env.root.appendingPathComponent("CodexBar", isDirectory: true) + try FileManager.default.createDirectory( + at: project.appendingPathComponent(".git", isDirectory: true), + withIntermediateDirectories: true) + let fixture = CodexUsageFixture( + filename: "project.jsonl", + sessionID: "cached-session", + cwd: project.path, + input: 100, + cached: 20, + output: 30) + let options = CostUsageScanner.Options( + codexSessionsRoot: env.codexSessionsRoot, + cacheRoot: env.cacheRoot) + let dayKey = CostUsageScanner.CostUsageDayRange.dayKey(from: day) + var cache = CostUsageCache() + cache.scanSinceKey = dayKey + cache.scanUntilKey = dayKey + cache.codexPricingKey = "pricing-a" + cache.roots = CostUsageScanner.codexRootsFingerprint(options: options) + cache.files[env.root.appendingPathComponent("project.jsonl").path] = self.makeCachedFileUsage( + dayKey: dayKey, + fixture: fixture, + costNanos: 1) + CostUsageCacheIO.save(provider: .codex, cache: cache, cacheRoot: env.cacheRoot) + let snapshot = try CodexLocalProjectUsageIndexer.buildSnapshotFromCostCache( + now: day, + historyDays: 1, + since: day, + until: day, + options: options) + let catalog = CodexThreadCatalogReader.load(options: options) + try CodexWorkspaceUsageSidecar(cacheRoot: env.cacheRoot).synchronize( + snapshot: snapshot, + cache: cache, + catalog: catalog, + rootsFingerprint: CostUsageScanner.codexRootsFingerprint(options: options)) + + #expect(CodexLocalProjectUsageIndexer.cachedSnapshot(now: day, historyDays: 1, options: .init( + scannerOptions: options)) != nil) + + cache.codexPricingKey = "pricing-b" + CostUsageCacheIO.save(provider: .codex, cache: cache, cacheRoot: env.cacheRoot) + + #expect(CodexLocalProjectUsageIndexer.cachedSnapshot(now: day, historyDays: 1, options: .init( + scannerOptions: options))?.total.totalTokens == 130) + } + + @Test + func `project usage severity separates high usage from unknown cost coverage`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = Date() + let highProject = env.root.appendingPathComponent("high", isDirectory: true) + let normalProject = env.root.appendingPathComponent("normal", isDirectory: true) + let partialProject = env.root.appendingPathComponent("partial", isDirectory: true) + for project in [highProject, normalProject, partialProject] { + try FileManager.default.createDirectory( + at: project.appendingPathComponent(".git", isDirectory: true), + withIntermediateDirectories: true) + } + let options = CostUsageScanner.Options( + codexSessionsRoot: env.codexSessionsRoot, + cacheRoot: env.cacheRoot) + let dayKey = CostUsageScanner.CostUsageDayRange.dayKey(from: day) + var cache = CostUsageCache() + cache.scanSinceKey = dayKey + cache.scanUntilKey = dayKey + cache.roots = CostUsageScanner.codexRootsFingerprint(options: options) + cache.files[env.root.appendingPathComponent("high.jsonl").path] = self.makeCachedFileUsage( + dayKey: dayKey, + fixture: CodexUsageFixture( + filename: "high.jsonl", + sessionID: "high", + cwd: highProject.path, + input: 800, + cached: 0, + output: 200), + costNanos: 1) + cache.files[env.root.appendingPathComponent("normal.jsonl").path] = self.makeCachedFileUsage( + dayKey: dayKey, + fixture: CodexUsageFixture( + filename: "normal.jsonl", + sessionID: "normal", + cwd: normalProject.path, + input: 80, + cached: 0, + output: 20), + costNanos: 1) + cache.files[env.root.appendingPathComponent("partial.jsonl").path] = self.makeCachedFileUsage( + dayKey: dayKey, + fixture: CodexUsageFixture( + filename: "partial.jsonl", + sessionID: "partial", + cwd: partialProject.path, + input: 80, + cached: 0, + output: 20), + costNanos: nil) + CostUsageCacheIO.save(provider: .codex, cache: cache, cacheRoot: env.cacheRoot) + + let snapshot = try CodexLocalProjectUsageIndexer.buildSnapshotFromCostCache( + now: day, + historyDays: 1, + since: day, + until: day, + options: options) + let projected = CodexLocalProjectUsageProjection( + includesCachedInput: true, + showsEstimatedCost: true) + .rankedProjects(snapshot.projects) + let projectsByName = Dictionary(uniqueKeysWithValues: projected.map { ($0.displayName, $0) }) + + #expect(projectsByName["high"]?.severity == .high) + #expect(projectsByName["normal"]?.severity == .normal) + #expect(projectsByName["partial"]?.hasUnknownCost == true) + #expect(projectsByName["partial"]?.severity == .normal) + #expect(projectsByName["partial"]?.costEstimate.unknownTokens == 100) + } + + @Test + func `project usage index does not downgrade known session project to chats`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = Date() + let project = env.root.appendingPathComponent("CodexBar", isDirectory: true) + let projectSource = project.appendingPathComponent("Sources", isDirectory: true) + try FileManager.default.createDirectory( + at: project.appendingPathComponent(".git", isDirectory: true), + withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: projectSource, withIntermediateDirectories: true) + + let projectFixture = CodexUsageFixture( + filename: "a-project-fragment.jsonl", + sessionID: "split-session", + cwd: projectSource.path, + input: 100, + cached: 20, + output: 30) + let chatFixture = CodexUsageFixture( + filename: "z-chat-fragment.jsonl", + sessionID: "split-session", + cwd: nil, + input: 50, + cached: 5, + output: 10) + let projectFileURL = try self.writeCodexUsageFile( + env: env, + day: day, + fixture: projectFixture) + let chatFileURL = try self.writeCodexUsageFile( + env: env, + day: day, + fixture: chatFixture) + + let options = CostUsageScanner.Options( + codexSessionsRoot: env.codexSessionsRoot, + cacheRoot: env.cacheRoot) + let dayKey = CostUsageScanner.CostUsageDayRange.dayKey(from: day) + var cache = CostUsageCache() + cache.scanSinceKey = dayKey + cache.scanUntilKey = dayKey + cache.roots = CostUsageScanner.codexRootsFingerprint(options: options) + cache.files[projectFileURL.path] = self.makeCachedFileUsage( + dayKey: dayKey, + fixture: projectFixture, + costNanos: 1) + cache.files[chatFileURL.path] = self.makeCachedFileUsage( + dayKey: dayKey, + fixture: chatFixture, + costNanos: 1) + CostUsageCacheIO.save(provider: .codex, cache: cache, cacheRoot: env.cacheRoot) + + let snapshot = try CodexLocalProjectUsageIndexer.buildSnapshotFromCostCache( + now: day, + historyDays: 1, + since: day, + until: day, + options: options) + + #expect(snapshot.projects.count == 1) + #expect(snapshot.projects.first?.displayName == "CodexBar") + #expect(snapshot.projects.first?.path == project.standardizedFileURL.path) + #expect(snapshot.projects.first?.sessionCount == 1) + #expect(snapshot.projects.first?.totals.totalTokens == 190) + #expect(snapshot.sessions.first?.projectId != CodexLocalProjectRootResolver.chatsProjectId) + } + + @Test + func `project usage index reports remaining file progress`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = Date() + let firstFixture = CodexUsageFixture( + filename: "first.jsonl", + sessionID: "first-session", + cwd: nil, + input: 100, + cached: 20, + output: 30) + let secondFixture = CodexUsageFixture( + filename: "second.jsonl", + sessionID: "second-session", + cwd: nil, + input: 50, + cached: 5, + output: 10) + let firstFileURL = try self.writeCodexUsageFile(env: env, day: day, fixture: firstFixture) + let secondFileURL = try self.writeCodexUsageFile(env: env, day: day, fixture: secondFixture) + let options = CostUsageScanner.Options( + codexSessionsRoot: env.codexSessionsRoot, + cacheRoot: env.cacheRoot) + let dayKey = CostUsageScanner.CostUsageDayRange.dayKey(from: day) + var cache = CostUsageCache() + cache.scanSinceKey = dayKey + cache.scanUntilKey = dayKey + cache.roots = CostUsageScanner.codexRootsFingerprint(options: options) + cache.files[firstFileURL.path] = self.makeCachedFileUsage(dayKey: dayKey, fixture: firstFixture, costNanos: 1) + cache.files[secondFileURL.path] = self.makeCachedFileUsage(dayKey: dayKey, fixture: secondFixture, costNanos: 1) + CostUsageCacheIO.save(provider: .codex, cache: cache, cacheRoot: env.cacheRoot) + + let recorder = ProgressRecorder() + _ = try CodexLocalProjectUsageIndexer.buildSnapshotFromCostCache( + now: day, + historyDays: 1, + since: day, + until: day, + options: options, + progress: { progress in + recorder.append(progress) + }) + let events = recorder.snapshot + + #expect(events.first?.phase == .indexingProjects) + #expect(events.first?.processedFileCount == 0) + #expect(events.first?.totalFileCount == 2) + #expect(events.last?.processedFileCount == 2) + #expect(events.last?.totalFileCount == 2) + #expect(events.last?.indexedFileCount == 2) + } + + @discardableResult + private func writeCodexUsageFile( + env: CostUsageTestEnvironment, + day: Date, + fixture: CodexUsageFixture) throws + -> URL + { + var turnPayload: [String: Any] = [ + "model": "openai/gpt-5.4", + ] + if let cwd = fixture.cwd { + turnPayload["cwd"] = cwd + } + let objects: [[String: Any]] = [ + [ + "type": "session_meta", + "timestamp": env.isoString(for: day), + "payload": ["id": fixture.sessionID], + ], + [ + "type": "turn_context", + "timestamp": env.isoString(for: day), + "payload": turnPayload, + ], + [ + "type": "event_msg", + "timestamp": env.isoString(for: day.addingTimeInterval(1)), + "payload": [ + "type": "token_count", + "info": [ + "last_token_usage": [ + "input_tokens": fixture.input, + "cached_input_tokens": fixture.cached, + "output_tokens": fixture.output, + ], + "model": "openai/gpt-5.4", + ], + ], + ], + ] + return try env.writeCodexSessionFile(day: day, filename: fixture.filename, contents: env.jsonl(objects)) + } + + private func makeCachedFileUsage( + dayKey: String, + fixture: CodexUsageFixture, + costNanos: Int64?) -> CostUsageFileUsage + { + let model = "openai/gpt-5.4" + return CostUsageFileUsage( + mtimeUnixMs: 0, + size: 0, + days: [dayKey: [model: [fixture.input, fixture.cached, fixture.output]]], + parsedBytes: nil, + lastModel: model, + lastTotals: nil, + lastCountedTotals: nil, + lastRawTotalsBaseline: nil, + hasDivergentTotals: nil, + lastCodexTurnID: nil, + sessionId: fixture.sessionID, + forkedFromId: nil, + codexSession: CostUsageCodexSessionMetadata( + sessionId: fixture.sessionID, + forkedFromId: nil, + cwd: fixture.cwd, + title: nil, + startedAtUnixMs: nil, + latestActivityUnixMs: nil), + codexCostNanos: costNanos.map { [dayKey: [model: $0]] }, + codexPrioritySurchargeNanos: nil, + codexStandardCostNanos: nil, + codexPriorityCostNanos: nil, + codexStandardTokens: nil, + codexPriorityTokens: nil, + codexTurnIDs: nil, + codexRows: nil, + claudeRows: nil) + } + + @Test + func `workspace fingerprint covers sidecar semantics but not scanner cursors`() throws { + let fixture = CodexUsageFixture( + filename: "rollout.jsonl", + sessionID: "fingerprint-session", + cwd: "/tmp/fingerprint-project", + input: 12, + cached: 3, + output: 4) + let day = "2026-07-25" + let baseline = self.makeCachedFileUsage(dayKey: day, fixture: fixture, costNanos: 42) + .refreshingCodexWorkspaceUsageFingerprint() + let fingerprint = try #require(baseline.codexWorkspaceContentFingerprint) + + var cursorOnly = baseline + cursorOnly.lastRawTotalsWatermark = CostUsageCodexTotals(input: 1000, cached: 900, output: 100) + #expect(cursorOnly.codexWorkspaceUsageFingerprintValue() == fingerprint) + + var changedDaily = baseline + changedDaily.days[day]?["openai/gpt-5.4"] = [24, 6, 8] + changedDaily = changedDaily.refreshingCodexWorkspaceUsageFingerprint() + #expect(changedDaily.codexWorkspaceUsageFingerprintValue() != fingerprint) + + var changedCost = baseline + changedCost.codexCostNanos?[day]?["openai/gpt-5.4"] = 84 + changedCost = changedCost.refreshingCodexWorkspaceUsageFingerprint() + #expect(changedCost.codexWorkspaceUsageFingerprintValue() != fingerprint) + + var changedProject = baseline + changedProject.projectPath = "/tmp/another-project" + changedProject = changedProject.refreshingCodexWorkspaceUsageFingerprint() + #expect(changedProject.codexWorkspaceUsageFingerprintValue() != fingerprint) + } + + #if canImport(SQLite3) + private struct CodexStateThreadFixture { + var id: String + var rolloutPath: String + var cwd: String + var title: String + var preview: String + var model: String + var createdAtUnixMs: Int64 + var updatedAtUnixMs: Int64 + var usesLegacyTimestampColumns = false + } + + private func writeCodexStateDatabase(at url: URL, thread: CodexStateThreadFixture) throws { + try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true) + var db: OpaquePointer? + guard sqlite3_open(url.path, &db) == SQLITE_OK else { + sqlite3_close(db) + throw NSError(domain: "CodexLocalProjectUsageTests", code: 1) + } + defer { sqlite3_close(db) } + try self.execSQLite(db, """ + CREATE TABLE threads ( + id TEXT PRIMARY KEY, + rollout_path TEXT NOT NULL, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + source TEXT NOT NULL, + model_provider TEXT NOT NULL, + cwd TEXT NOT NULL, + title TEXT NOT NULL, + sandbox_policy TEXT NOT NULL, + approval_mode TEXT NOT NULL, + tokens_used INTEGER NOT NULL DEFAULT 0, + archived INTEGER NOT NULL DEFAULT 0, + model TEXT, + reasoning_effort TEXT, + created_at_ms INTEGER, + updated_at_ms INTEGER, + preview TEXT NOT NULL DEFAULT '' + ) + """) + var stmt: OpaquePointer? + let insert = """ + INSERT INTO threads ( + id, rollout_path, created_at, updated_at, source, model_provider, cwd, title, + sandbox_policy, approval_mode, tokens_used, archived, model, reasoning_effort, + created_at_ms, updated_at_ms, preview + ) VALUES (?, ?, ?, ?, 'codex', 'openai', ?, ?, 'workspace-write', 'never', 0, 0, ?, 'high', ?, ?, ?) + """ + guard sqlite3_prepare_v2(db, insert, -1, &stmt, nil) == SQLITE_OK else { + throw NSError(domain: "CodexLocalProjectUsageTests", code: 2) + } + defer { sqlite3_finalize(stmt) } + let transient = unsafeBitCast(-1, to: sqlite3_destructor_type.self) + sqlite3_bind_text(stmt, 1, thread.id, -1, transient) + sqlite3_bind_text(stmt, 2, thread.rolloutPath, -1, transient) + sqlite3_bind_int64(stmt, 3, thread.createdAtUnixMs / 1000) + sqlite3_bind_int64(stmt, 4, thread.updatedAtUnixMs / 1000) + sqlite3_bind_text(stmt, 5, thread.cwd, -1, transient) + sqlite3_bind_text(stmt, 6, thread.title, -1, transient) + sqlite3_bind_text(stmt, 7, thread.model, -1, transient) + if thread.usesLegacyTimestampColumns { + sqlite3_bind_null(stmt, 8) + sqlite3_bind_null(stmt, 9) + } else { + sqlite3_bind_int64(stmt, 8, thread.createdAtUnixMs) + sqlite3_bind_int64(stmt, 9, thread.updatedAtUnixMs) + } + sqlite3_bind_text(stmt, 10, thread.preview, -1, transient) + guard sqlite3_step(stmt) == SQLITE_DONE else { + throw NSError(domain: "CodexLocalProjectUsageTests", code: 3) + } + } + + private func execSQLite(_ db: OpaquePointer?, _ sql: String) throws { + var error: UnsafeMutablePointer? + guard sqlite3_exec(db, sql, nil, nil, &error) == SQLITE_OK else { + sqlite3_free(error) + throw NSError(domain: "CodexLocalProjectUsageTests", code: 4) + } + } + #endif + + private func makeProject( + id: String, + name: String, + input: Int, + cached: Int, + output: Int) -> CodexLocalProjectUsage + { + CodexLocalProjectUsage( + id: id, + displayName: name, + path: "/tmp/\(name)", + totals: CodexLocalUsageTotals( + inputTokens: input, + cachedInputTokens: cached, + outputTokens: output, + totalTokens: input + output), + estimatedCostUSD: Double(input + output) / 1000, + hasUnknownCost: false, + sessionCount: 1, + latestActivity: nil, + topModel: "gpt-5.4", + topSessions: [], + modelBreakdowns: []) + } +} + +extension CodexLocalProjectUsageTests { + @Test + func `daily usage projection follows cached input setting`() { + let point = CodexLocalUsageDailyPoint( + day: "2026-07-12", + totalTokens: 100, + cachedInputTokens: 40, + estimatedCostUSD: 1) + let includeCache = CodexLocalProjectUsageProjection(includesCachedInput: true, showsEstimatedCost: true) + let excludeCache = CodexLocalProjectUsageProjection(includesCachedInput: false, showsEstimatedCost: true) + + #expect(includeCache.displayedTokens( + totalTokens: point.totalTokens, + cachedInputTokens: point.cachedInputTokens) == 100) + #expect(excludeCache.displayedTokens( + totalTokens: point.totalTokens, + cachedInputTokens: point.cachedInputTokens) == 60) + } + + @Test + func `ranking projects preserves daily usage`() throws { + let daily = CodexLocalUsageDailyPoint( + day: "2026-07-12", + totalTokens: 100, + cachedInputTokens: 40, + estimatedCostUSD: 1) + let project = CodexLocalProjectUsage( + id: "project", + displayName: "Project", + path: "/tmp/Project", + totals: CodexLocalUsageTotals( + inputTokens: 80, + cachedInputTokens: 40, + outputTokens: 20, + totalTokens: 100), + costEstimate: CodexLocalCostEstimate(knownUSD: 1, unknownTokens: 0), + sessionCount: 1, + latestActivity: nil, + topModel: "gpt-5.4", + topSessions: [], + modelBreakdowns: [], + daily: [daily]) + let projection = CodexLocalProjectUsageProjection( + includesCachedInput: true, + showsEstimatedCost: true) + + let ranked = try #require(projection.rankedProjects([project]).first) + #expect(ranked.daily == [daily]) + } + + @Test + func `projection derives severity from displayed tokens not price`() { + let projection = CodexLocalProjectUsageProjection( + includesCachedInput: true, + showsEstimatedCost: true) + let dominant = self.makeProject( + id: "dominant", + name: "Dominant", + input: 1000, + cached: 0, + output: 0) + let costlySmall = CodexLocalProjectUsage( + id: "costly-small", + displayName: "CostlySmall", + path: "/tmp/CostlySmall", + totals: CodexLocalUsageTotals( + inputTokens: 1, + cachedInputTokens: 0, + outputTokens: 0, + totalTokens: 1), + costEstimate: CodexLocalCostEstimate(knownUSD: 10000, unknownTokens: 0), + sessionCount: 1, + latestActivity: nil, + topModel: "gpt-5.4", + topSessions: [], + modelBreakdowns: []) + + let ranked = projection.rankedProjects([costlySmall, dominant]) + #expect(ranked.map(\.id) == ["dominant", "costly-small"]) + #expect(ranked.first?.severity == .high) + #expect(ranked.last?.severity == .normal) + } + + @Test + func `cost coverage retains the number of unpriced tokens`() { + let estimate = CodexLocalCostEstimate(knownUSD: 2.5, unknownTokens: 37) + + #expect(estimate.coverage == .partial) + #expect(estimate.knownUSD == 2.5) + #expect(estimate.unknownTokens == 37) + } + + @Test + func `persisted projects exclude display severity`() throws { + let project = CodexLocalProjectUsage( + id: "project", + displayName: "Project", + path: "/tmp/project", + totals: .empty, + costEstimate: CodexLocalCostEstimate(knownUSD: 1, unknownTokens: 0), + sessionCount: 1, + latestActivity: nil, + topModel: nil, + topSessions: [], + modelBreakdowns: [], + usageSeverity: .high) + + let encoded = try JSONEncoder.codexLocalProjectUsageSidecar.encode(project) + let json = try #require(String(data: encoded, encoding: .utf8)) + let decoded = try JSONDecoder.codexLocalProjectUsageSidecar.decode(CodexLocalProjectUsage.self, from: encoded) + + #expect(!json.contains("usageSeverity")) + #expect(decoded.severity == .normal) + } + + @Test + func `snapshot ignores legacy process state without persisting it again`() throws { + let snapshot = CodexLocalProjectUsageSnapshot( + updatedAt: Date(timeIntervalSince1970: 1_700_000_000), + historyDays: 30, + scopeSignature: "scope", + rootsFingerprint: ["sessions": 1], + indexedFileCount: 1, + skippedFileCount: 0, + total: .empty, + projects: [], + daily: []) + + let encoded = try JSONEncoder.codexLocalProjectUsageSidecar.encode(snapshot) + let currentJSON = try #require(String(data: encoded, encoding: .utf8)) + var legacyObject = try #require(JSONSerialization.jsonObject(with: encoded) as? [String: Any]) + legacyObject["stale"] = true + legacyObject["indexing"] = true + legacyObject["errorMessage"] = "previous refresh failed" + let legacyData = try JSONSerialization.data(withJSONObject: legacyObject) + let decoded = try JSONDecoder.codexLocalProjectUsageSidecar.decode( + CodexLocalProjectUsageSnapshot.self, + from: legacyData) + + #expect(decoded == snapshot) + #expect(!currentJSON.contains("\"stale\"")) + #expect(!currentJSON.contains("\"indexing\"")) + #expect(!currentJSON.contains("\"errorMessage\"")) + } + + @Test + func `snapshot persists source completeness and defaults legacy payloads to complete`() throws { + let snapshot = CodexLocalProjectUsageSnapshot( + updatedAt: Date(timeIntervalSince1970: 1_700_000_000), + historyDays: 30, + scopeSignature: "scope", + rootsFingerprint: ["sessions": 1], + indexedFileCount: 1, + skippedFileCount: 0, + total: .empty, + projects: [], + daily: [], + sourceStatus: .catalogLocked) + + let encoded = try JSONEncoder.codexLocalProjectUsageSidecar.encode(snapshot) + let decoded = try JSONDecoder.codexLocalProjectUsageSidecar.decode( + CodexLocalProjectUsageSnapshot.self, + from: encoded) + #expect(decoded.sourceStatus == .catalogLocked) + + var legacyObject = try #require(JSONSerialization.jsonObject(with: encoded) as? [String: Any]) + legacyObject.removeValue(forKey: "sourceStatus") + let legacyData = try JSONSerialization.data(withJSONObject: legacyObject) + let decodedLegacy = try JSONDecoder.codexLocalProjectUsageSidecar.decode( + CodexLocalProjectUsageSnapshot.self, + from: legacyData) + #expect(decodedLegacy.sourceStatus == .complete) + } + + @Test + func `aggregate only snapshots are rejected until inspector detail is available`() { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let dailyPoint = CodexLocalUsageDailyPoint( + day: "2023-11-14", + totalTokens: 100, + estimatedCostUSD: 1) + let totals = CodexLocalUsageTotals( + inputTokens: 80, + cachedInputTokens: 20, + outputTokens: 20, + totalTokens: 100) + let incompleteProject = CodexLocalProjectUsage( + id: "project", + displayName: "Project", + path: "/tmp/project", + totals: totals, + costEstimate: CodexLocalCostEstimate(knownUSD: 1, unknownTokens: 0), + sessionCount: 1, + latestActivity: now, + topModel: "gpt-5.4", + topSessions: [], + modelBreakdowns: []) + let incomplete = CodexLocalProjectUsageSnapshot( + updatedAt: now, + historyDays: 1, + scopeSignature: "scope", + rootsFingerprint: [:], + indexedFileCount: 1, + skippedFileCount: 0, + total: totals, + projects: [incompleteProject], + daily: []) + #expect(!incomplete.hasInspectorDetail) + + let session = CodexLocalSessionUsage( + id: "session", + projectId: "project", + displayTitle: "Project session", + cwd: "/tmp/project", + startedAt: now, + latestActivity: now, + totals: totals, + estimatedCostUSD: 1, + hasUnknownCost: false, + topModel: "gpt-5.4", + daily: [dailyPoint]) + let completeProject = CodexLocalProjectUsage( + id: "project", + displayName: "Project", + path: "/tmp/project", + totals: totals, + costEstimate: CodexLocalCostEstimate(knownUSD: 1, unknownTokens: 0), + sessionCount: 1, + latestActivity: now, + topModel: "gpt-5.4", + topSessions: [session], + modelBreakdowns: [], + daily: [dailyPoint]) + let complete = CodexLocalProjectUsageSnapshot( + updatedAt: now, + historyDays: 1, + scopeSignature: "scope", + rootsFingerprint: [:], + indexedFileCount: 1, + skippedFileCount: 0, + total: totals, + projects: [completeProject], + sessions: [session], + daily: [dailyPoint]) + #expect(complete.hasInspectorDetail) + } + + @Test + func `session daily attribution round trips and legacy payload defaults to empty`() throws { + let daily = [ + CodexLocalUsageDailyPoint( + day: "2026-07-12", + totalTokens: 100, + cachedInputTokens: 20, + estimatedCostUSD: 1.25), + CodexLocalUsageDailyPoint( + day: "2026-07-13", + totalTokens: 250, + cachedInputTokens: 50, + estimatedCostUSD: 2.5), + ] + let session = CodexLocalSessionUsage( + id: "session", + projectId: "project", + displayTitle: "A chat spanning two days", + cwd: "/tmp/project", + startedAt: nil, + latestActivity: nil, + totals: CodexLocalUsageTotals( + inputTokens: 280, + cachedInputTokens: 70, + outputTokens: 70, + totalTokens: 350), + estimatedCostUSD: 3.75, + hasUnknownCost: false, + topModel: "gpt-5.4", + daily: daily) + + let encoded = try JSONEncoder.codexLocalProjectUsageSidecar.encode(session) + let decoded = try JSONDecoder.codexLocalProjectUsageSidecar.decode( + CodexLocalSessionUsage.self, + from: encoded) + #expect(decoded.daily == daily) + + var legacyObject = try #require(JSONSerialization.jsonObject(with: encoded) as? [String: Any]) + legacyObject.removeValue(forKey: "daily") + let legacyData = try JSONSerialization.data(withJSONObject: legacyObject) + let decodedLegacy = try JSONDecoder.codexLocalProjectUsageSidecar.decode( + CodexLocalSessionUsage.self, + from: legacyData) + #expect(decodedLegacy.daily.isEmpty) + } + + @Test + func `sidecar rejects unreleased schema versions`() throws { + #if canImport(SQLite3) + for version in [2, 3, 4] { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + let databaseDirectory = env.cacheRoot.appendingPathComponent("local-usage", isDirectory: true) + try FileManager.default.createDirectory(at: databaseDirectory, withIntermediateDirectories: true) + let databaseURL = databaseDirectory.appendingPathComponent("codex-workspaces-v1.sqlite") + var database: OpaquePointer? + #expect(sqlite3_open(databaseURL.path, &database) == SQLITE_OK) + try self.execSQLite(database, "PRAGMA user_version = \(version);") + sqlite3_close(database) + + #expect(throws: (any Error).self) { + try CodexWorkspaceUsageSidecar(cacheRoot: env.cacheRoot).synchronizeSources( + cache: CostUsageCache(), + catalog: .empty) + } + + database = nil + #expect(sqlite3_open(databaseURL.path, &database) == SQLITE_OK) + var statement: OpaquePointer? + #expect(sqlite3_prepare_v2(database, "PRAGMA user_version", -1, &statement, nil) == SQLITE_OK) + #expect(sqlite3_step(statement) == SQLITE_ROW) + #expect(sqlite3_column_int(statement, 0) == Int32(version)) + sqlite3_finalize(statement) + sqlite3_close(database) + } + #endif + } + + @Test + func `aggregate models analytics preserves priced zero and unavailable coverage`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let now = Date() + let dayKey = CostUsageScanner.CostUsageDayRange.dayKey(from: now) + let project = env.root.appendingPathComponent("Project", isDirectory: true) + try FileManager.default.createDirectory( + at: project.appendingPathComponent(".git", isDirectory: true), + withIntermediateDirectories: true) + let options = CostUsageScanner.Options( + codexSessionsRoot: env.codexSessionsRoot, + cacheRoot: env.cacheRoot) + let pricedFixture = CodexUsageFixture( + filename: "priced.jsonl", + sessionID: "priced-fallback", + cwd: project.path, + input: 10, + cached: 0, + output: 0) + let unavailableFixture = CodexUsageFixture( + filename: "unavailable.jsonl", + sessionID: "unavailable-fallback", + cwd: project.path, + input: 20, + cached: 0, + output: 0) + var unavailableUsage = self.makeCachedFileUsage( + dayKey: dayKey, + fixture: unavailableFixture, + costNanos: nil) + let unavailableModel = "openai/gpt-5.4-mini" + unavailableUsage.days = [dayKey: [unavailableModel: [20, 0, 0]]] + unavailableUsage.lastModel = unavailableModel + + var cache = CostUsageCache() + cache.roots = CostUsageScanner.codexRootsFingerprint(options: options) + cache.files[env.root.appendingPathComponent("priced.jsonl").path] = self.makeCachedFileUsage( + dayKey: dayKey, + fixture: pricedFixture, + costNanos: 0) + cache.files[env.root.appendingPathComponent("unavailable.jsonl").path] = unavailableUsage + + let snapshot = try CodexLocalProjectUsageIndexer.buildSnapshotFromCostCache( + now: now, + historyDays: 1, + since: now, + until: now, + options: options, + cacheOverride: cache, + catalogOverride: .empty) + let analytics = try #require(snapshot.modelsAnalytics?.allWorkspaces) + let priced = try #require(analytics.rows.first { $0.id == "gpt-5.4" }) + let unavailable = try #require(analytics.rows.first { $0.id == "gpt-5.4-mini" }) + + #expect(priced.cost.knownAmount == 0) + #expect(priced.cost.pricedTokens == 10) + #expect(priced.cost.unpricedTokens == 0) + #expect(unavailable.cost.knownAmount == 0) + #expect(unavailable.cost.pricedTokens == 0) + #expect(unavailable.cost.unpricedTokens == 20) + #expect(analytics.cost.knownAmount == 0) + #expect(analytics.cost.pricedTokens == 10) + #expect(analytics.cost.unpricedTokens == 20) + #expect(analytics.diagnostics.isMatched) + } + + @Test + func `sidecar rehydrates cache-backed project aggregates`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let now = Date() + let dayKey = CostUsageScanner.CostUsageDayRange.dayKey(from: now) + let project = env.root.appendingPathComponent("Project", isDirectory: true) + try FileManager.default.createDirectory( + at: project.appendingPathComponent(".git", isDirectory: true), + withIntermediateDirectories: true) + let options = CostUsageScanner.Options( + codexSessionsRoot: env.codexSessionsRoot, + cacheRoot: env.cacheRoot) + let path = env.root.appendingPathComponent("rollout.jsonl").path + let fixture = CodexUsageFixture( + filename: "rollout.jsonl", + sessionID: "sidecar-session", + cwd: project.path, + input: 120, + cached: 20, + output: 30) + var cache = CostUsageCache() + cache.roots = CostUsageScanner.codexRootsFingerprint(options: options) + var cachedUsage = self.makeCachedFileUsage( + dayKey: dayKey, + fixture: fixture, + costNanos: 42) + cachedUsage.codexRows = [CostUsageScanner.CodexUsageRow( + day: dayKey, + model: "openai/gpt-5.4", + rawModel: "GPT-5.4", + turnID: "turn-1", + eventIndex: 0, + timestampUnixMs: Int64(now.timeIntervalSince1970 * 1000), + input: fixture.input, + cached: fixture.cached, + output: fixture.output, + reasoning: 12, + knownCostNanos: 42, + unpricedTokens: 0, + pricingModel: "openai/gpt-5.4", + pricingMode: "standard")] + cache.files[path] = cachedUsage + let catalog = CodexThreadCatalog.empty + let baseline = try CodexLocalProjectUsageIndexer.buildSnapshotFromCostCache( + now: now, + historyDays: 1, + since: now, + until: now, + options: options, + cacheOverride: cache, + catalogOverride: catalog) + let baselineModel = try #require(baseline.modelsAnalytics?.allWorkspaces.rows.first) + let expectedKnownCost = try #require(Decimal(string: "0.000000042")) + #expect(baselineModel.reasoningTokens == 12) + #expect(baselineModel.cost.knownAmount == expectedKnownCost) + #expect(baselineModel.cost.pricedTokens == Int64(fixture.input + fixture.output)) + #expect(baselineModel.cost.unpricedTokens == 0) + #expect(baseline.modelsAnalytics?.allWorkspaces.diagnostics.isMatched == true) + let sidecar = CodexWorkspaceUsageSidecar(cacheRoot: env.cacheRoot) + try sidecar.synchronize( + snapshot: baseline, + cache: cache, + catalog: catalog, + rootsFingerprint: cache.roots ?? [:]) + #if canImport(SQLite3) + let sidecarURL = env.cacheRoot + .appendingPathComponent("local-usage", isDirectory: true) + .appendingPathComponent("codex-workspaces-v1.sqlite", isDirectory: false) + var database: OpaquePointer? + #expect(sqlite3_open(sidecarURL.path, &database) == SQLITE_OK) + defer { sqlite3_close(database) } + var statement: OpaquePointer? + #expect(sqlite3_prepare_v2( + database, + "SELECT payload_format_version, payload FROM snapshot_payloads", + -1, + &statement, + nil) == SQLITE_OK) + #expect(sqlite3_step(statement) == SQLITE_ROW) + #expect(sqlite3_column_int(statement, 0) == 3) + let numericPayloadBytes = try #require(sqlite3_column_blob(statement, 1)) + let numericPayload = Data( + bytes: numericPayloadBytes, + count: Int(sqlite3_column_bytes(statement, 1))) + sqlite3_finalize(statement) + let numericObject = try #require(JSONSerialization.jsonObject(with: numericPayload) as? [String: Any]) + #expect(numericObject["updatedAt"] is NSNumber) + + statement = nil + #expect(sqlite3_prepare_v2( + database, + "UPDATE snapshot_payloads SET payload_format_version = 2 WHERE scope_signature = ? AND history_days = ?", + -1, + &statement, + nil) == SQLITE_OK) + let transient = unsafeBitCast(-1, to: sqlite3_destructor_type.self) + sqlite3_bind_text(statement, 1, baseline.scopeSignature, -1, transient) + sqlite3_bind_int64(statement, 2, Int64(baseline.historyDays)) + #expect(sqlite3_step(statement) == SQLITE_DONE) + sqlite3_finalize(statement) + #expect(sidecar.loadLatestSnapshot( + scopeSignature: baseline.scopeSignature, + historyDays: baseline.historyDays) == nil) + #expect(try sidecar.usageCache(roots: cache.roots ?? [:]).files[path]?.codexRows?.first?.knownCostNanos == 42) + try sidecar.synchronize( + snapshot: baseline, + cache: cache, + catalog: catalog, + rootsFingerprint: cache.roots ?? [:]) + statement = nil + #expect(sqlite3_prepare_v2( + database, + "SELECT payload_format_version FROM snapshot_payloads", + -1, + &statement, + nil) == SQLITE_OK) + #expect(sqlite3_step(statement) == SQLITE_ROW) + #expect(sqlite3_column_int(statement, 0) == 3) + sqlite3_finalize(statement) + #expect(sidecar.loadLatestSnapshot( + scopeSignature: baseline.scopeSignature, + historyDays: baseline.historyDays)?.total == baseline.total) + #endif + let rehydratedCache = try sidecar.usageCache(roots: cache.roots ?? [:]) + let rehydratedEvent = try #require(rehydratedCache.files[path]?.codexRows?.first) + let rehydrated = try CodexLocalProjectUsageIndexer.buildSnapshotFromCostCache( + now: now, + historyDays: 1, + since: now, + until: now, + options: options, + cacheOverride: rehydratedCache, + catalogOverride: catalog) + + #expect(rehydrated.total == baseline.total) + #expect(rehydrated.projects.map(\.id) == baseline.projects.map(\.id)) + #expect(rehydrated.projects.first?.costEstimate == baseline.projects.first?.costEstimate) + #expect(rehydratedEvent.rawModel == "GPT-5.4") + #expect(rehydratedEvent.timestampUnixMs == Int64(now.timeIntervalSince1970 * 1000)) + #expect(rehydratedEvent.reasoning == 12) + #expect(rehydratedEvent.knownCostNanos == 42) + #expect(rehydratedEvent.pricingMode == "standard") + let rehydratedModel = try #require(rehydrated.modelsAnalytics?.allWorkspaces.rows.first) + #expect(rehydratedModel.reasoningTokens == 12) + #expect(rehydratedModel.cost.knownAmount == expectedKnownCost) + #expect(rehydratedModel.cost.pricedTokens == Int64(fixture.input + fixture.output)) + #expect(rehydratedModel.cost.unpricedTokens == 0) + #expect(rehydrated.modelsAnalytics?.allWorkspaces.diagnostics.isMatched == true) + } + + @Test + func `sidecar refreshes changed usage when rollout metadata is unchanged`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let dayKey = CostUsageScanner.CostUsageDayRange.dayKey(from: Date()) + let path = env.root.appendingPathComponent("rollout.jsonl").path + let fixture = CodexUsageFixture( + filename: "rollout.jsonl", + sessionID: "sidecar-content-change", + cwd: env.root.path, + input: 120, + cached: 20, + output: 30) + var cache = CostUsageCache() + let timestampUnixMs = Int64(Date().timeIntervalSince1970 * 1000) + var initialUsage = self.makeCachedFileUsage( + dayKey: dayKey, + fixture: fixture, + costNanos: 42) + initialUsage.codexRows = [CostUsageScanner.CodexUsageRow( + day: dayKey, + model: "openai/gpt-5.4", + rawModel: "GPT-5.4", + turnID: "turn-1", + eventIndex: 0, + timestampUnixMs: timestampUnixMs, + input: 120, + cached: 20, + output: 30, + knownCostNanos: 42, + unpricedTokens: 0, + pricingModel: "openai/gpt-5.4", + pricingMode: "standard")] + initialUsage = initialUsage.refreshingCodexWorkspaceUsageFingerprint() + cache.files[path] = initialUsage + let sidecar = CodexWorkspaceUsageSidecar(cacheRoot: env.cacheRoot) + try sidecar.synchronizeSources(cache: cache, catalog: .empty) + + let model = "openai/gpt-5.4" + var changedUsage = try #require(cache.files[path]) + changedUsage.days[dayKey]?[model] = [240, 40, 60] + changedUsage.codexCostNanos?[dayKey]?[model] = 84 + changedUsage.codexRows = [CostUsageScanner.CodexUsageRow( + day: dayKey, + model: model, + rawModel: "GPT-5.4", + turnID: "turn-1", + eventIndex: 0, + timestampUnixMs: timestampUnixMs, + input: 240, + cached: 40, + output: 60, + knownCostNanos: 84, + unpricedTokens: 0, + pricingModel: model, + pricingMode: "priority")] + changedUsage = changedUsage.refreshingCodexWorkspaceUsageFingerprint() + cache.files[path] = changedUsage + try sidecar.synchronizeSources(cache: cache, catalog: .empty) + + let updated = try #require(sidecar.usageCache(roots: [:]).files[path]) + #expect(updated.days[dayKey]?[model] == [240, 40, 60]) + #expect(updated.codexCostNanos?[dayKey]?[model] == 84) + #expect(updated.codexRows?.first?.input == 240) + #expect(updated.codexRows?.first?.knownCostNanos == 84) + #expect(updated.codexRows?.first?.pricingMode == "priority") + + changedUsage.days = [:] + changedUsage.codexCostNanos = [:] + changedUsage.codexRows = [] + changedUsage = changedUsage.refreshingCodexWorkspaceUsageFingerprint() + cache.files[path] = changedUsage + try sidecar.synchronizeSources(cache: cache, catalog: .empty) + + #expect(try sidecar.usageCache(roots: [:]).files[path] == nil) + } +} + +// swiftlint:enable type_body_length diff --git a/Tests/CodexBarTests/CodexLocalSessionCostSettingsTests.swift b/Tests/CodexBarTests/CodexLocalSessionCostSettingsTests.swift new file mode 100644 index 0000000000..3b86be3845 --- /dev/null +++ b/Tests/CodexBarTests/CodexLocalSessionCostSettingsTests.swift @@ -0,0 +1,139 @@ +import CodexBarCore +import Foundation +import SwiftUI +import Testing +@testable import CodexBar + +@MainActor +struct CodexLocalSessionCostSettingsTests { + @Test + func `codex exposes usage and cookie pickers`() throws { + let fixture = try self.makeSettingsFixture(suite: "CodexLocalSessionCostSettingsTests-codex") + let context = fixture.settingsContext(provider: .codex) + + let pickers = CodexProviderImplementation().settingsPickers(context: context) + let toggles = CodexProviderImplementation().settingsToggles(context: context) + #expect(pickers.contains(where: { $0.id == "codex-usage-source" })) + let usagePicker = try #require(pickers.first(where: { $0.id == "codex-usage-source" })) + #expect(usagePicker.title == "Quota usage source") + #expect(usagePicker.subtitle.contains("Local session cost estimates work independently")) + let cookiePicker = try #require(pickers.first(where: { $0.id == "codex-cookie-source" })) + #expect(cookiePicker.placement == .connection) + let localLedgerToggle = try #require(toggles.first(where: { $0.id == "codex-local-session-cost-ledger" })) + #expect(localLedgerToggle.title == "Local session cost estimates") + #expect(localLedgerToggle.subtitle.contains("organization API keys")) + #expect(!localLedgerToggle.binding.wrappedValue) + localLedgerToggle.binding.wrappedValue = true + #expect(fixture.settings.codexLocalSessionCostLedgerEnabled) + #expect(!fixture.settings.costUsageEnabled) + #expect(fixture.settings.isCostUsageEffectivelyEnabled(for: .codex)) + #expect(!fixture.settings.isCostUsageEffectivelyEnabled(for: .claude)) + #expect(toggles.contains(where: { $0.id == "codex-historical-tracking" })) + let sparkToggle = try #require(toggles.first(where: { $0.id == "codex-spark-usage-visible" })) + #expect(sparkToggle.title == "Show Codex Spark usage") + #expect(sparkToggle.subtitle.contains("menu and provider preview")) + #expect(sparkToggle.binding.wrappedValue) + #expect(sparkToggle.isEnabled?() == true) + + sparkToggle.binding.wrappedValue = false + #expect(fixture.settings.codexSparkUsageVisible == false) + + fixture.settings.showOptionalCreditsAndExtraUsage = false + #expect(sparkToggle.isEnabled?() == false) + } + + @Test + func `codex local ledger ignores the managed account home`() throws { + let fixture = try self.makeSettingsFixture(suite: "CodexLocalSessionCostSettingsTests-local-ledger") + fixture.settings._test_activeManagedCodexRemoteHomePath = "/tmp/managed-codex-home" + fixture.settings.codexActiveSource = .managedAccount(id: UUID()) + defer { fixture.settings._test_activeManagedCodexRemoteHomePath = nil } + + let managedScope = fixture.store.tokenCostScope(for: .codex) + fixture.settings.codexLocalSessionCostLedgerEnabled = true + let localScope = fixture.store.tokenCostScope(for: .codex) + + #expect(managedScope.codexHomePath == "/tmp/managed-codex-home") + #expect(managedScope.signature == "codex:managed:/tmp/managed-codex-home") + #expect(localScope.codexHomePath == nil) + #expect(localScope.signature == "codex:ambient") + } + + @Test + func `unresolved managed cost scope never falls back to ambient sessions`() throws { + let fixture = try self.makeSettingsFixture(suite: "CodexLocalSessionCostSettingsTests-managed-unresolved") + let accountID = UUID() + fixture.settings.codexActiveSource = .managedAccount(id: accountID) + + let scope = fixture.store.tokenCostScope(for: .codex) + + #expect(scope.codexHomePath != nil) + #expect(scope.signature != "codex:ambient") + #expect(scope.signature.hasPrefix("codex:managed:")) + } + + private func makeSettingsFixture(suite: String) throws -> Fixture { + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + let settings = SettingsStore( + userDefaults: defaults, + configStore: testConfigStore(suiteName: suite), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings) + return Fixture(settings: settings, store: store) + } + + private struct Fixture { + let settings: SettingsStore + let store: UsageStore + private let state = ProviderSettingsContextState() + + @MainActor + func settingsContext(provider: UsageProvider) -> ProviderSettingsContext { + let settings = self.settings + let store = self.store + let state = self.state + return ProviderSettingsContext( + provider: provider, + settings: settings, + store: store, + boolBinding: { keyPath in + Binding( + get: { settings[keyPath: keyPath] }, + set: { settings[keyPath: keyPath] = $0 }) + }, + stringBinding: { keyPath in + Binding( + get: { settings[keyPath: keyPath] }, + set: { settings[keyPath: keyPath] = $0 }) + }, + statusText: { id in state.statusByID[id] }, + setStatusText: { id, text in + if let text { + state.statusByID[id] = text + } else { + state.statusByID.removeValue(forKey: id) + } + }, + lastAppActiveRunAt: { id in state.lastRunAtByID[id] }, + setLastAppActiveRunAt: { id, date in + if let date { + state.lastRunAtByID[id] = date + } else { + state.lastRunAtByID.removeValue(forKey: id) + } + }, + requestConfirmation: { _ in }, + runLoginFlow: {}) + } + } + + private final class ProviderSettingsContextState { + var statusByID: [String: String] = [:] + var lastRunAtByID: [String: Date] = [:] + } +} diff --git a/Tests/CodexBarTests/CodexLoginRunnerTests.swift b/Tests/CodexBarTests/CodexLoginRunnerTests.swift new file mode 100644 index 0000000000..062fe13494 --- /dev/null +++ b/Tests/CodexBarTests/CodexLoginRunnerTests.swift @@ -0,0 +1,88 @@ +import Darwin +import Foundation +import Testing +@testable import CodexBar + +struct CodexLoginRunnerTests { + @Test + func `login runner returns timeout before hung codex exits`() async throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("codexbar-login-runner-\(UUID().uuidString)", isDirectory: true) + let binDir = root.appendingPathComponent("bin", isDirectory: true) + let homeDir = root.appendingPathComponent("home", isDirectory: true) + try FileManager.default.createDirectory(at: binDir, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: homeDir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + + let codex = binDir.appendingPathComponent("codex") + let script = """ + #!/usr/bin/python3 + import time + + print("login-started", flush=True) + time.sleep(5) + print("login-finished", flush=True) + """ + try script.write(to: codex, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: codex.path) + + let start = Date() + let result = await CodexLoginRunner.run( + homePath: homeDir.path, + timeout: 0.2, + environment: ["PATH": binDir.path], + loginPATH: nil) + let elapsed = Date().timeIntervalSince(start) + + #expect(result.outcome == .timedOut) + #expect(result.output.contains("login-finished") == false) + #expect(elapsed < 2.0, "Timeout should return promptly, took \(elapsed)s") + } + + @Test + func `login runner bounds output drain when detached child keeps pipes open`() async throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("codexbar-login-drain-\(UUID().uuidString)", isDirectory: true) + let binDir = root.appendingPathComponent("bin", isDirectory: true) + let homeDir = root.appendingPathComponent("home", isDirectory: true) + let childPIDFile = root.appendingPathComponent("child.pid") + try FileManager.default.createDirectory(at: binDir, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: homeDir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + defer { + if let text = try? String(contentsOf: childPIDFile, encoding: .utf8), + let childPID = pid_t(text.trimmingCharacters(in: .whitespacesAndNewlines)) + { + _ = kill(childPID, SIGKILL) + } + } + + let codex = binDir.appendingPathComponent("codex") + let script = """ + #!/bin/sh + /bin/sh -c 'trap "" TERM; /bin/sleep 20' & + child_pid=$! + printf '%s\\n' "$child_pid" > "$CODEXBAR_TEST_CHILD_PID_FILE" + printf 'login-started\\n' + /bin/sleep 20 + """ + try script.write(to: codex, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: codex.path) + + let start = Date() + let result = await CodexLoginRunner.run( + homePath: homeDir.path, + timeout: 5, + outputDrainTimeout: 0.5, + environment: [ + "CODEXBAR_TEST_CHILD_PID_FILE": childPIDFile.path, + "PATH": binDir.path, + ], + loginPATH: nil) + let elapsed = Date().timeIntervalSince(start) + + #expect(result.outcome == .timedOut) + #expect(result.output.contains("login-started")) + #expect(elapsed < 8.0, "Output drain should stay bounded, took \(elapsed)s") + } +} diff --git a/Tests/CodexBarTests/CodexManagedOpenAIWebRefreshTests.swift b/Tests/CodexBarTests/CodexManagedOpenAIWebRefreshTests.swift index a61a5c87f0..c4a6cfe2af 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 } @@ -185,6 +190,9 @@ struct CodexManagedOpenAIWebRefreshTests { settings: settings, startupBehavior: .testing) store.snapshots[.codex] = Self.codexSnapshot(email: managedAccount.email, usedPercent: 18) + let publicationGuard = store.currentCodexAccountScopedRefreshGuard() + store.lastCodexUsagePublicationGuard = publicationGuard + store.lastCodexAccountScopedRefreshGuard = publicationGuard let creditsBlocker = BlockingCreditsLoader() let saver = BlockingWidgetSnapshotSaver() @@ -212,6 +220,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 +258,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 +266,28 @@ 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) + let publicationGuard = store.currentCodexAccountScopedRefreshGuard() + store.lastCodexUsagePublicationGuard = publicationGuard + store.lastCodexAccountScopedRefreshGuard = publicationGuard + 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 +301,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 +358,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 +432,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 +464,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 +503,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 +582,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 +634,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 } @@ -580,15 +654,7 @@ struct CodexManagedOpenAIWebRefreshTests { } private func makeSettingsStore(suite: String) throws -> SettingsStore { - let defaults = UserDefaults(suiteName: suite)! - defaults.removePersistentDomain(forName: suite) - defaults.set(true, forKey: "providerDetectionCompleted") - let configStore = testConfigStore(suiteName: suite) - let settings = SettingsStore( - userDefaults: defaults, - configStore: configStore, - zaiTokenStore: NoopZaiTokenStore(), - syntheticTokenStore: NoopSyntheticTokenStore()) + let settings = testSettingsStore(suiteName: suite) let codexMetadata = try #require(ProviderDescriptorRegistry.metadata[.codex]) settings.setProviderEnabled(provider: .codex, metadata: codexMetadata, enabled: true) settings.providerDetectionCompleted = true @@ -679,21 +745,36 @@ actor RefreshCompletionProbe { } actor BlockingManagedOpenAIDashboardLoader { - private var continuations: [CheckedContinuation, Never>] = [] + private typealias ResultContinuation = CheckedContinuation, Never> + + private var continuations: [(id: UUID, continuation: ResultContinuation)] = [] private var startWaiters: [(count: Int, continuation: CheckedContinuation)] = [] private var started: Int = 0 + private var cancelledIDs: Set = [] + private var rejectsNewCalls = false func awaitResult() async throws -> OpenAIDashboardSnapshot { - let result = await withCheckedContinuation { continuation in - self.continuations.append(continuation) - self.started += 1 - self.resumeReadyStartWaiters() + let id = UUID() + let result = await withTaskCancellationHandler { + await withCheckedContinuation { (continuation: ResultContinuation) in + if self.rejectsNewCalls || Task.isCancelled { + continuation.resume(returning: .failure(CancellationError())) + } else { + self.continuations.append((id: id, continuation: continuation)) + self.started += 1 + self.resumeReadyStartWaiters() + } + } + } onCancel: { + Task { await self.cancel(id: id) } } return try result.get() } func waitUntilStarted(count: Int = 1) async { - if self.started >= count { return } + if self.started >= count { + return + } await withCheckedContinuation { continuation in self.startWaiters.append((count: count, continuation: continuation)) } @@ -716,8 +797,17 @@ actor BlockingManagedOpenAIDashboardLoader { func resumeNext(with result: Result) { guard !self.continuations.isEmpty else { return } - let continuation = self.continuations.removeFirst() - continuation.resume(returning: result) + let record = self.continuations.removeFirst() + self.cancelledIDs.remove(record.id) + record.continuation.resume(returning: result) + } + + func cancelAll() { + self.rejectsNewCalls = true + let continuations = self.continuations + self.continuations.removeAll() + self.cancelledIDs.removeAll() + continuations.forEach { $0.continuation.resume(returning: .failure(CancellationError())) } } private func resumeReadyStartWaiters() { @@ -731,37 +821,100 @@ actor BlockingManagedOpenAIDashboardLoader { } self.startWaiters = remaining } + + private func cancel(id: UUID) { + guard self.continuations.contains(where: { $0.id == id }) else { return } + _ = self.cancelledIDs.insert(id) + } } actor BlockingCreditsLoader { - private var continuations: [CheckedContinuation, Never>] = [] + private typealias ResultContinuation = CheckedContinuation, Never> + + private var continuations: [(id: UUID, continuation: ResultContinuation)] = [] private var startWaiters: [(count: Int, continuation: CheckedContinuation)] = [] private var started = 0 + private var cancellations = 0 + private var cancelledIDs: Set = [] + private var rejectsNewCalls = false func awaitResult() async throws -> CreditsSnapshot { - let result = await withCheckedContinuation { continuation in - self.continuations.append(continuation) - self.started += 1 - self.resumeReadyStartWaiters() + let id = UUID() + let result = await withTaskCancellationHandler { + await withCheckedContinuation { (continuation: ResultContinuation) in + if self.rejectsNewCalls || Task.isCancelled { + continuation.resume(returning: .failure(CancellationError())) + } else { + self.continuations.append((id: id, continuation: continuation)) + self.started += 1 + self.resumeReadyStartWaiters() + } + } + } onCancel: { + Task { await self.cancel(id: id) } } return try result.get() } func waitUntilStarted(count: Int = 1) async { - if self.started >= count { return } + if self.started >= count { + return + } await withCheckedContinuation { continuation in self.startWaiters.append((count: count, continuation: continuation)) } } + func waitUntilStartedWithin(count: Int = 1, timeout: Duration = .seconds(5)) async -> Bool { + let deadline = ContinuousClock.now + timeout + while self.started < count { + if ContinuousClock.now >= deadline { + return false + } + try? await Task.sleep(for: .milliseconds(50)) + } + return true + } + func startedCount() -> Int { self.started } + func cancellationCount() -> Int { + self.cancellations + } + + func waitUntilCancellationCount(_ count: Int, timeout: Duration = .seconds(5)) async -> Bool { + let deadline = ContinuousClock.now + timeout + while self.cancellations < count { + if ContinuousClock.now >= deadline { + return false + } + try? await Task.sleep(for: .milliseconds(50)) + } + return true + } + func resumeNext(with result: Result) { guard !self.continuations.isEmpty else { return } - let continuation = self.continuations.removeFirst() - continuation.resume(returning: result) + let record = self.continuations.removeFirst() + self.cancelledIDs.remove(record.id) + record.continuation.resume(returning: result) + } + + func resumeLast(with result: Result) { + guard !self.continuations.isEmpty else { return } + let record = self.continuations.removeLast() + self.cancelledIDs.remove(record.id) + record.continuation.resume(returning: result) + } + + func cancelAll() { + self.rejectsNewCalls = true + let continuations = self.continuations + self.continuations.removeAll() + self.cancelledIDs.removeAll() + continuations.forEach { $0.continuation.resume(returning: .failure(CancellationError())) } } private func resumeReadyStartWaiters() { @@ -775,6 +928,11 @@ actor BlockingCreditsLoader { } self.startWaiters = remaining } + + private func cancel(id: UUID) { + guard self.continuations.contains(where: { $0.id == id }), self.cancelledIDs.insert(id).inserted else { return } + self.cancellations += 1 + } } private actor OpenAIDashboardImportCallTracker { @@ -788,12 +946,18 @@ private actor OpenAIDashboardImportCallTracker { } func waitUntilCalls(count: Int) async { - if self.calls >= count { return } + if self.calls >= count { + return + } await withCheckedContinuation { continuation in self.waiters.append((count: count, continuation: continuation)) } } + 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 fce3d009f5..921cce7abc 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 da934f23b1..51cd309c14 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/CodexManagedRoutingTests.swift b/Tests/CodexBarTests/CodexManagedRoutingTests.swift index 2a2e2fcec3..7796593fdf 100644 --- a/Tests/CodexBarTests/CodexManagedRoutingTests.swift +++ b/Tests/CodexBarTests/CodexManagedRoutingTests.swift @@ -782,7 +782,6 @@ struct CodexManagedRoutingTests { minimaxCookieStore: InMemoryMiniMaxCookieStore(), minimaxAPITokenStore: InMemoryMiniMaxAPITokenStore(), kimiTokenStore: InMemoryKimiTokenStore(), - kimiK2TokenStore: InMemoryKimiK2TokenStore(), augmentCookieStore: InMemoryCookieHeaderStore(), ampCookieStore: InMemoryCookieHeaderStore(), copilotTokenStore: InMemoryCopilotTokenStore(), diff --git a/Tests/CodexBarTests/CodexModelsAnalyticsParityTests.swift b/Tests/CodexBarTests/CodexModelsAnalyticsParityTests.swift new file mode 100644 index 0000000000..6f6ecd4d01 --- /dev/null +++ b/Tests/CodexBarTests/CodexModelsAnalyticsParityTests.swift @@ -0,0 +1,95 @@ +import Foundation +import Testing +@testable import CodexBarCore + +@Suite("Codex Models analytics parity") +struct CodexModelsAnalyticsParityTests { + @Test + func `per model parity canonicalizes aliases with audited cost`() throws { + let current = DateInterval( + start: Date(timeIntervalSince1970: 1_000_000), + duration: 7 * 24 * 60 * 60) + let previous = DateInterval( + start: current.start.addingTimeInterval(-current.duration), + duration: current.duration) + let currentFragments = [ + self.fragment( + day: current.start, + model: "GPT-5.4", + input: 6, + session: "current", + costNanos: 30), + self.fragment( + day: current.start, + model: "openai/gpt-5.4", + input: 4, + session: "current", + costNanos: 20), + ] + let previousFragments = [self.fragment( + day: previous.start, + model: "gpt-5.4", + input: 5, + session: "previous", + costNanos: 10)] + let currentKnownCost = try #require(Decimal(string: "0.00000005")) + let previousKnownCost = try #require(Decimal(string: "0.00000001")) + let snapshot = CodexModelsAnalyticsBuilder().build(CodexModelsAnalyticsRequest( + source: CodexModelsAnalyticsSource(current: currentFragments, previous: previousFragments), + scopeID: nil, + periods: CodexModelsAnalyticsPeriods(current: current, previous: previous), + revision: CodexModelsAnalyticsRevision(generatedAt: current.end, indexRevision: "fixture"), + legacy: CodexModelsLegacyBaseline( + totalTokens: 10, + modelIDs: ["gpt-5.4"], + knownCost: currentKnownCost, + pricedTokens: 10, + unpricedTokens: 0, + activeModelCount: 1, + topModelID: "gpt-5.4", + sessionReferenceTotal: 1, + previousTotalTokens: 5, + previousKnownCost: previousKnownCost, + previousUnpricedTokens: 0, + previousSessionReferenceTotal: 1, + currentModels: [CodexModelsLegacyModelBaseline( + modelID: "gpt-5.4", + totalTokens: 10, + knownCost: currentKnownCost, + pricedTokens: 10, + unpricedTokens: 0, + sessionReferences: 1)], + previousModels: [CodexModelsLegacyModelBaseline( + modelID: "gpt-5.4", + totalTokens: 5, + knownCost: previousKnownCost, + pricedTokens: 5, + unpricedTokens: 0, + sessionReferences: 1)]))) + + let row = try #require(snapshot.rows.first) + #expect(row.id == "gpt-5.4") + #expect(row.cost.knownAmount == currentKnownCost) + #expect(row.cost.pricedTokens == 10) + #expect(row.cost.unpricedTokens == 0) + #expect(snapshot.diagnostics.isMatched) + } + + private func fragment( + day: Date, + model: String, + input: Int64, + session: String, + costNanos: Int64) -> CodexModelsUsageFragment + { + CodexModelsUsageFragment( + workspaceID: "workspace", + sessionID: session, + day: day, + rawModelID: model, + inputTokens: input, + cachedInputTokens: 0, + outputTokens: 0, + costNanos: costNanos) + } +} diff --git a/Tests/CodexBarTests/CodexModelsAnalyticsTests.swift b/Tests/CodexBarTests/CodexModelsAnalyticsTests.swift new file mode 100644 index 0000000000..568f0da17a --- /dev/null +++ b/Tests/CodexBarTests/CodexModelsAnalyticsTests.swift @@ -0,0 +1,528 @@ +import Foundation +import Testing +@testable import CodexBar +@testable import CodexBarCore + +@Suite("Codex Models analytics") +struct CodexModelsAnalyticsTests { + private let calendar: Calendar = { + var calendar = Calendar(identifier: .gregorian) + calendar.locale = Locale(identifier: "en_US_POSIX") + calendar.timeZone = TimeZone(identifier: "America/Los_Angeles")! + return calendar + }() + + @Test + func `canonical aliases merge while raw aliases remain auditable`() { + let intervals = self.intervals(days: 7) + let current = [ + self.fragment(day: intervals.current.start, model: "GPT-5", input: 10, output: 2, session: "one"), + self.fragment(day: intervals.current.start, model: "gpt-5", input: 7, output: 1, session: "two"), + self.fragment(day: intervals.current.start, model: " OpenAI/GPT-5 ", input: 3, output: 0, session: "three"), + ] + let snapshot = self.build(current: current, previous: [], intervals: intervals, legacyTotal: 23) + + #expect(snapshot.rows.count == 1) + #expect(snapshot.rows[0].id == "gpt-5") + #expect(snapshot.rows[0].rawAliases == [" OpenAI/GPT-5 ", "GPT-5", "gpt-5"]) + #expect(snapshot.rows[0].sessionReferences == 3) + #expect(snapshot.rows[0].associatedSessionIDs == ["one", "three", "two"]) + } + + @Test + func `cached input and reasoning detail are not double counted`() { + let intervals = self.intervals(days: 7) + let fragment = CodexModelsUsageFragment( + workspaceID: "workspace", + sessionID: "one", + day: intervals.current.start, + rawModelID: "model-a", + inputTokens: 100, + cachedInputTokens: 80, + outputTokens: 40, + reasoningTokens: 30, + costNanos: 1_500_000_000) + let snapshot = self.build(current: [fragment], previous: [], intervals: intervals, legacyTotal: 140) + let row = snapshot.rows[0] + + #expect(row.totalTokens == 140) + #expect(row.cachedInputTokens == 80) + #expect(row.reasoningTokens == 30) + #expect(snapshot.invariantViolations().isEmpty) + } + + @Test + func `pricing distinguishes complete partial unavailable and known zero`() throws { + let intervals = self.intervals(days: 7) + let current = [ + self.fragment(day: intervals.current.start, model: "model-a", input: 10, output: 0, costNanos: 0), + self.fragment(day: intervals.current.start, model: "model-b", input: 20, output: 0, costNanos: nil), + CodexModelsUsageFragment( + workspaceID: "workspace", + sessionID: "partial", + day: intervals.current.start, + rawModelID: "model-a", + inputTokens: 25, + cachedInputTokens: 0, + outputTokens: 0, + costNanos: 250_000_000, + unpricedTokens: 20), + ] + let snapshot = self.build(current: current, previous: [], intervals: intervals, legacyTotal: 55) + let priced = try #require(snapshot.rows.first { $0.id == "model-a" }) + let unavailable = try #require(snapshot.rows.first { $0.id == "model-b" }) + + #expect(priced.cost.knownAmount == Decimal(string: "0.25")) + #expect(priced.cost.pricedTokens == 15) + #expect(priced.cost.unpricedTokens == 20) + #expect(unavailable.cost.knownAmount == 0) + #expect(unavailable.cost.pricedTokens == 0) + #expect(unavailable.cost.unpricedTokens == 20) + #expect(snapshot.cost.pricedTokens + snapshot.cost.unpricedTokens == snapshot.totalTokens) + } + + @Test + func `current and previous periods are equal duration across DST`() throws { + let end = try #require(self.calendar.date(from: DateComponents(year: 2026, month: 3, day: 12))) + let currentStart = try #require(self.calendar.date(byAdding: .day, value: -7, to: end)) + let currentInterval = DateInterval(start: currentStart, end: end) + let previousStart = currentStart.addingTimeInterval(-currentInterval.duration) + let intervals = ( + current: currentInterval, + previous: DateInterval(start: previousStart, end: currentStart)) + let current = [self.fragment(day: currentStart, model: "model-a", input: 20, output: 0)] + let previous = [self.fragment(day: previousStart, model: "model-a", input: 10, output: 0)] + let snapshot = self.build(current: current, previous: previous, intervals: intervals, legacyTotal: 20) + + #expect(intervals.current.duration == intervals.previous.duration) + #expect(snapshot.rows[0].tokenComparison == .percent(1)) + } + + @Test + func `indexer periods are adjacent equal seconds across both DST transitions`() throws { + let springSince = try #require(self.calendar.date(from: DateComponents(year: 2026, month: 3, day: 6))) + let springUntil = try #require(self.calendar.date(from: DateComponents(year: 2026, month: 3, day: 12))) + let fallSince = try #require(self.calendar.date(from: DateComponents(year: 2026, month: 10, day: 30))) + let fallUntil = try #require(self.calendar.date(from: DateComponents(year: 2026, month: 11, day: 5))) + + for periods in [ + CodexLocalProjectUsageIndexer.modelsAnalyticsPeriods( + since: springSince, + until: springUntil, + calendar: self.calendar), + CodexLocalProjectUsageIndexer.modelsAnalyticsPeriods( + since: fallSince, + until: fallUntil, + calendar: self.calendar), + ] { + #expect(periods.current.duration == periods.previous.duration) + #expect(periods.previous.end == periods.current.start) + } + + let fallPeriods = CodexLocalProjectUsageIndexer.modelsAnalyticsPeriods( + since: fallSince, + until: fallUntil, + calendar: self.calendar) + let scanStart = CodexLocalProjectUsageIndexer.modelsAnalyticsScanStart( + since: fallSince, + until: fallUntil, + calendar: self.calendar) + let calendarDaySubtraction = try #require( + self.calendar.date(byAdding: .day, value: -7, to: fallPeriods.current.start)) + #expect(calendarDaySubtraction > fallPeriods.previous.start) + #expect(scanStart <= fallPeriods.previous.start) + #expect(fallPeriods.previous.start.timeIntervalSince(scanStart) < 24 * 60 * 60) + } + + @Test + func `timestamp filtering uses half open current and previous boundaries`() { + let intervals = self.intervals(days: 2) + let current = [ + self.fragment( + day: intervals.current.start, + timestamp: intervals.current.start, + model: "model-a", + input: 10, + output: 0), + self.fragment( + day: intervals.current.start, + timestamp: intervals.current.end.addingTimeInterval(-0.001), + model: "model-a", + input: 20, + output: 0), + self.fragment( + day: intervals.current.end, + timestamp: intervals.current.end, + model: "model-a", + input: 40, + output: 0), + ] + let previous = [ + self.fragment( + day: intervals.previous.start, + timestamp: intervals.previous.start, + model: "model-a", + input: 5, + output: 0), + self.fragment( + day: intervals.previous.start, + timestamp: intervals.previous.end.addingTimeInterval(-0.001), + model: "model-a", + input: 7, + output: 0), + self.fragment( + day: intervals.previous.end, + timestamp: intervals.previous.end, + model: "model-a", + input: 9, + output: 0), + ] + let snapshot = self.build(current: current, previous: previous, intervals: intervals, legacyTotal: 30) + + #expect(snapshot.totalTokens == 30) + #expect(snapshot.rows[0].previousTotalTokens == 12) + #expect(snapshot.rows[0].tokenComparison == .percent(1.5)) + } + + @Test + func `session references count distinct session model pairs`() throws { + let intervals = self.intervals(days: 7) + let secondDay = try #require(self.calendar.date(byAdding: .day, value: 1, to: intervals.current.start)) + let current = [ + self.fragment(day: intervals.current.start, model: "model-a", input: 1, output: 0, session: "same"), + self.fragment(day: secondDay, model: "model-a", input: 1, output: 0, session: "same"), + self.fragment(day: secondDay, model: "model-b", input: 1, output: 0, session: "same"), + ] + let snapshot = self.build(current: current, previous: [], intervals: intervals, legacyTotal: 3) + + #expect(snapshot.uniqueSessionCount == 1) + #expect(snapshot.sessionReferenceTotal == 2) + #expect(snapshot.rows.first { $0.id == "model-a" }?.sessionReferences == 1) + #expect(snapshot.rows.first { $0.id == "model-b" }?.sessionReferences == 1) + let allModelsBucket = try #require(snapshot.daily.last) + #expect(allModelsBucket.sessionIDs == ["same"]) + #expect(allModelsBucket.sessionReferenceIDs.count == 2) + #expect(allModelsBucket.sessionReferences == 2) + #expect(snapshot.dailyByModel["model-a"]?.last?.sessionReferences == 1) + #expect(snapshot.dailyByModel["model-b"]?.last?.sessionReferences == 1) + } + + @Test + func `incomplete previous coverage suppresses comparisons and newly active semantics`() { + let intervals = self.intervals(days: 7) + let current = [ + self.fragment(day: intervals.current.start, model: "model-a", input: 20, output: 0, session: "one"), + self.fragment(day: intervals.current.start, model: "model-b", input: 10, output: 0, session: "two"), + ] + let previous = [ + self.fragment(day: intervals.previous.start, model: "model-a", input: 10, output: 0, session: "old"), + ] + let complete = self.build(current: current, previous: previous, intervals: intervals, legacyTotal: 30) + let incomplete = self.build( + current: current, + previous: previous, + intervals: intervals, + legacyTotal: 30, + previousIsComplete: false) + + #expect(complete.previousActiveModelCount == 1) + #expect(complete.newlyActiveModelCount == 1) + #expect(complete.rows.first { $0.id == "model-a" }?.previousTotalTokens == 10) + + #expect(incomplete.currentIsComplete == true) + #expect(incomplete.previousIsComplete == false) + #expect(incomplete.previousActiveModelCount == nil) + #expect(incomplete.newlyActiveModelCount == nil) + #expect(incomplete.previousSessionReferenceTotal == nil) + #expect(incomplete.tokenComparison == .unavailable) + #expect(incomplete.costComparison == .unavailable) + #expect(incomplete.sessionReferenceComparison == .unavailable) + for row in incomplete.rows { + #expect(row.previousTotalTokens == nil) + #expect(row.previousCost == nil) + #expect(row.previousSessionReferences == nil) + #expect(row.tokenComparison == .unavailable) + #expect(row.costComparison == .unavailable) + #expect(row.sessionReferenceComparison == .unavailable) + } + } + + @Test + func `ranking ties are deterministic`() { + let intervals = self.intervals(days: 7) + let current = [ + self.fragment(day: intervals.current.start, model: "model-z", input: 10, output: 0), + self.fragment(day: intervals.current.start, model: "model-a", input: 10, output: 0), + ] + let snapshot = self.build(current: current, previous: [], intervals: intervals, legacyTotal: 20) + #expect(snapshot.rows.map(\.id) == ["model-a", "model-z"]) + } + + @Test + func `dual run diagnostics flag total and identity mismatches`() { + let intervals = self.intervals(days: 7) + let current = [self.fragment(day: intervals.current.start, model: "model-a", input: 10, output: 0)] + let snapshot = CodexModelsAnalyticsBuilder().build(CodexModelsAnalyticsRequest( + source: CodexModelsAnalyticsSource(current: current, previous: []), + scopeID: nil, + periods: CodexModelsAnalyticsPeriods(current: intervals.current, previous: intervals.previous), + revision: CodexModelsAnalyticsRevision(generatedAt: intervals.current.end, indexRevision: "fixture"), + legacy: CodexModelsLegacyBaseline(totalTokens: 11, modelIDs: ["model-b"]))) + + #expect(snapshot.diagnostics.mismatches == ["total_tokens", "model_identities"]) + #expect(snapshot.diagnostics.mismatchDimensions == [.totalTokens, .modelIdentities]) + } + + @Test + func `dual run diagnostics compare current and previous per model raw values`() { + let intervals = self.intervals(days: 7) + let current = [self.fragment( + day: intervals.current.start, + model: "model-a", + input: 10, + output: 0, + session: "current", + costNanos: 100_000_000)] + let previous = [self.fragment( + day: intervals.previous.start, + model: "model-a", + input: 5, + output: 0, + session: "previous", + costNanos: 200_000_000)] + let snapshot = CodexModelsAnalyticsBuilder().build(CodexModelsAnalyticsRequest( + source: CodexModelsAnalyticsSource(current: current, previous: previous), + scopeID: nil, + periods: CodexModelsAnalyticsPeriods(current: intervals.current, previous: intervals.previous), + revision: CodexModelsAnalyticsRevision(generatedAt: intervals.current.end, indexRevision: "fixture"), + legacy: CodexModelsLegacyBaseline( + totalTokens: 10, + modelIDs: ["model-a"], + currentModels: [CodexModelsLegacyModelBaseline( + modelID: "model-a", + totalTokens: 11, + knownCost: Decimal(string: "0.3"), + pricedTokens: 10, + unpricedTokens: 0, + sessionReferences: 1)], + previousModels: [CodexModelsLegacyModelBaseline( + modelID: "model-a", + totalTokens: 5, + knownCost: Decimal(string: "0.2"), + pricedTokens: 0, + unpricedTokens: 5, + sessionReferences: 2)]))) + + #expect(snapshot.diagnostics.mismatchDimensions == [ + .modelTokens, + .modelKnownCost, + .modelPricingCoverage, + .modelSessionReferences, + ]) + } + + @Test + func `per model parity canonicalizes aliases and preserves unavailable pricing`() { + let intervals = self.intervals(days: 7) + let current = [ + self.fragment( + day: intervals.current.start, + model: "GPT-5", + input: 6, + output: 0, + session: "same", + costNanos: nil), + self.fragment( + day: intervals.current.start, + model: "gpt-5", + input: 4, + output: 0, + session: "same", + costNanos: nil), + ] + let previous = [self.fragment( + day: intervals.previous.start, + model: "OpenAI/GPT-5", + input: 5, + output: 0, + session: "earlier", + costNanos: nil)] + let snapshot = CodexModelsAnalyticsBuilder().build(CodexModelsAnalyticsRequest( + source: CodexModelsAnalyticsSource(current: current, previous: previous), + scopeID: nil, + periods: CodexModelsAnalyticsPeriods(current: intervals.current, previous: intervals.previous), + revision: CodexModelsAnalyticsRevision(generatedAt: intervals.current.end, indexRevision: "fixture"), + legacy: CodexModelsLegacyBaseline( + totalTokens: 10, + modelIDs: ["gpt-5"], + currentModels: [CodexModelsLegacyModelBaseline( + modelID: "gpt-5", + totalTokens: 10, + pricedTokens: 0, + unpricedTokens: 10, + sessionReferences: 1)], + previousModels: [CodexModelsLegacyModelBaseline( + modelID: "gpt-5", + totalTokens: 5, + pricedTokens: 0, + unpricedTokens: 5, + sessionReferences: 1)]))) + + #expect(snapshot.diagnostics.isMatched) + #expect(snapshot.rows[0].sessionReferences == 1) + #expect(snapshot.rows[0].cost.pricedTokens == 0) + #expect(snapshot.rows[0].cost.unpricedTokens == 10) + } + + @Test + func `per model parity skips periods whose source coverage is incomplete`() { + let intervals = self.intervals(days: 7) + let current = [self.fragment(day: intervals.current.start, model: "model-a", input: 10, output: 0)] + let snapshot = CodexModelsAnalyticsBuilder().build(CodexModelsAnalyticsRequest( + source: CodexModelsAnalyticsSource(current: current, previous: []), + scopeID: nil, + periods: CodexModelsAnalyticsPeriods(current: intervals.current, previous: intervals.previous), + revision: CodexModelsAnalyticsRevision(generatedAt: intervals.current.end, indexRevision: "fixture"), + legacy: CodexModelsLegacyBaseline( + totalTokens: 10, + modelIDs: ["model-a"], + currentModels: [CodexModelsLegacyModelBaseline( + modelID: "model-a", + totalTokens: 99, + knownCost: 99, + pricedTokens: 0, + unpricedTokens: 99, + sessionReferences: 99)]), + currentIsComplete: false, + previousIsComplete: true)) + + #expect(snapshot.diagnostics.isMatched) + } + + @Test + func `legacy event rows decode without new timestamp or pricing audit fields`() throws { + let data = Data(""" + { + "day": "2026-07-16", + "model": "model-a", + "turnID": "turn-1", + "eventIndex": 4, + "input": 10, + "cached": 3, + "output": 2 + } + """.utf8) + let row = try JSONDecoder().decode(CostUsageScanner.CodexUsageRow.self, from: data) + + #expect(row.rawModel == nil) + #expect(row.timestampUnixMs == nil) + #expect(row.knownCostNanos == nil) + #expect(row.unpricedTokens == nil) + #expect(row.input == 10) + } + + @Test + func `legacy analytics payload decodes without additive comparison and interval fields`() throws { + let intervals = self.intervals(days: 7) + let snapshot = self.build( + current: [self.fragment(day: intervals.current.start, model: "model-a", input: 10, output: 0)], + previous: [], + intervals: intervals, + legacyTotal: 10) + let encoded = try JSONEncoder().encode(snapshot) + var object = try #require(JSONSerialization.jsonObject(with: encoded) as? [String: Any]) + object.removeValue(forKey: "previousActiveModelCount") + object.removeValue(forKey: "currentIsComplete") + object.removeValue(forKey: "previousIsComplete") + object.removeValue(forKey: "newlyActiveModelCount") + object.removeValue(forKey: "previousSessionReferenceTotal") + object.removeValue(forKey: "sessionReferenceComparison") + object["rows"] = try (#require(object["rows"] as? [[String: Any]])).map { value in + var row = value + row.removeValue(forKey: "previousTotalTokens") + row.removeValue(forKey: "previousCost") + row.removeValue(forKey: "previousSessionReferences") + row.removeValue(forKey: "associatedSessionIDs") + return row + } + object["daily"] = try (#require(object["daily"] as? [[String: Any]])).map { value in + var bucket = value + bucket.removeValue(forKey: "interval") + bucket.removeValue(forKey: "sessionReferenceIDs") + return bucket + } + let legacyData = try JSONSerialization.data(withJSONObject: object) + let decoded = try JSONDecoder().decode(CodexModelsAnalyticsSnapshot.self, from: legacyData) + + #expect(decoded.previousActiveModelCount == nil) + #expect(decoded.currentIsComplete == nil) + #expect(decoded.previousIsComplete == nil) + #expect(decoded.newlyActiveModelCount == nil) + #expect(decoded.rows[0].previousTotalTokens == nil) + #expect(decoded.rows[0].associatedSessionIDs == nil) + #expect(decoded.daily[0].interval == nil) + #expect(decoded.daily[0].sessionReferenceIDs == decoded.daily[0].sessionIDs) + #expect(decoded.totalTokens == 10) + } + + @Test + func `feature flag defaults on and supports rollback`() throws { + let name = "CodexModelsAnalyticsTests.featureFlag.\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: name)) + defer { defaults.removePersistentDomain(forName: name) } + + #expect(CodexModelsRollout.isEnabled(defaults: defaults)) + defaults.set(false, forKey: CodexModelsRollout.featureFlagKey) + #expect(!CodexModelsRollout.isEnabled(defaults: defaults)) + } + + private func build( + current: [CodexModelsUsageFragment], + previous: [CodexModelsUsageFragment], + intervals: (current: DateInterval, previous: DateInterval), + legacyTotal: Int64, + currentIsComplete: Bool = true, + previousIsComplete: Bool = true) -> CodexModelsAnalyticsSnapshot + { + CodexModelsAnalyticsBuilder().build(CodexModelsAnalyticsRequest( + source: CodexModelsAnalyticsSource(current: current, previous: previous), + scopeID: nil, + periods: CodexModelsAnalyticsPeriods(current: intervals.current, previous: intervals.previous), + revision: CodexModelsAnalyticsRevision(generatedAt: intervals.current.end, indexRevision: "fixture"), + legacy: CodexModelsLegacyBaseline( + totalTokens: legacyTotal, + modelIDs: Array(Set(current.map(\.rawModelID)))), + currentIsComplete: currentIsComplete, + previousIsComplete: previousIsComplete)) + } + + private func intervals(days: Int) -> (current: DateInterval, previous: DateInterval) { + let end = self.calendar.date(from: DateComponents(year: 2026, month: 7, day: 16))! + let currentStart = self.calendar.date(byAdding: .day, value: -days, to: end)! + let previousStart = self.calendar.date(byAdding: .day, value: -days, to: currentStart)! + return ( + DateInterval(start: currentStart, end: end), + DateInterval(start: previousStart, end: currentStart)) + } + + private func fragment( + day: Date, + timestamp: Date? = nil, + model: String, + input: Int64, + output: Int64, + session: String = "session", + costNanos: Int64? = 100_000_000) -> CodexModelsUsageFragment + { + CodexModelsUsageFragment( + workspaceID: "workspace", + sessionID: session, + day: day, + timestamp: timestamp, + rawModelID: model, + inputTokens: input, + cachedInputTokens: min(3, input), + outputTokens: output, + costNanos: costNanos) + } +} diff --git a/Tests/CodexBarTests/CodexModelsExportFormattingTests.swift b/Tests/CodexBarTests/CodexModelsExportFormattingTests.swift new file mode 100644 index 0000000000..60df959911 --- /dev/null +++ b/Tests/CodexBarTests/CodexModelsExportFormattingTests.swift @@ -0,0 +1,106 @@ +import Foundation +import Testing +@testable import CodexBar +@testable import CodexBarCore + +@Suite("Codex Models export and formatting") +struct CodexModelsExportFormattingTests { + private let calendar: Calendar = { + var calendar = Calendar(identifier: .gregorian) + calendar.locale = Locale(identifier: "en_US_POSIX") + calendar.timeZone = TimeZone(identifier: "America/Los_Angeles")! + return calendar + }() + + @Test + func `CSV export uses raw precision and explicit unknown cost`() { + let intervals = self.intervals(days: 7) + let current = [self.fragment( + day: intervals.current.start, + model: "model,quoted", + input: 7_100_000_000, + costNanos: nil)] + let snapshot = self.build(current: current, previous: [], intervals: intervals, legacyTotal: 7_100_000_000) + let csv = CodexModelsCSVExporter.export(snapshot: snapshot) + + #expect(csv.contains("7100000000")) + #expect(!csv.contains("7.1B")) + #expect(csv.contains(",0,7100000000,")) + #expect(csv.contains("\"model,quoted\"")) + #expect(csv.contains(",unavailable,")) + } + + @Test + func `CSV export keeps previous unavailable pricing blank and auditable`() throws { + let intervals = self.intervals(days: 7) + let snapshot = self.build( + current: [self.fragment( + day: intervals.current.start, + model: "model-a", + input: 10, + costNanos: 1_000_000_000)], + previous: [self.fragment( + day: intervals.previous.start, + model: "model-a", + input: 8, + costNanos: nil)], + intervals: intervals, + legacyTotal: 10) + + let lines = CodexModelsCSVExporter.export(snapshot: snapshot).split(separator: "\n") + let header = try #require(lines.first).split(separator: ",", omittingEmptySubsequences: false).map(String.init) + let values = try #require(lines.dropFirst().first) + .split(separator: ",", omittingEmptySubsequences: false) + .map(String.init) + let fields = Dictionary(uniqueKeysWithValues: zip(header, values)) + + #expect(fields["reasoning_tokens"]?.isEmpty == true) + #expect(fields["previous_known_cost"]?.isEmpty == true) + #expect(fields["previous_cost_status"] == "unavailable") + #expect(fields["previous_cost_coverage"] == "0") + #expect(fields["previous_priced_tokens"] == "0") + #expect(fields["previous_unpriced_tokens"] == "8") + } + + private func build( + current: [CodexModelsUsageFragment], + previous: [CodexModelsUsageFragment], + intervals: (current: DateInterval, previous: DateInterval), + legacyTotal: Int64) -> CodexModelsAnalyticsSnapshot + { + CodexModelsAnalyticsBuilder().build(CodexModelsAnalyticsRequest( + source: CodexModelsAnalyticsSource(current: current, previous: previous), + scopeID: nil, + periods: CodexModelsAnalyticsPeriods(current: intervals.current, previous: intervals.previous), + revision: CodexModelsAnalyticsRevision(generatedAt: intervals.current.end, indexRevision: "fixture"), + legacy: CodexModelsLegacyBaseline( + totalTokens: legacyTotal, + modelIDs: Array(Set(current.map(\.rawModelID)))))) + } + + private func intervals(days: Int) -> (current: DateInterval, previous: DateInterval) { + let end = self.calendar.date(from: DateComponents(year: 2026, month: 7, day: 16))! + let currentStart = self.calendar.date(byAdding: .day, value: -days, to: end)! + let previousStart = self.calendar.date(byAdding: .day, value: -days, to: currentStart)! + return ( + DateInterval(start: currentStart, end: end), + DateInterval(start: previousStart, end: currentStart)) + } + + private func fragment( + day: Date, + model: String, + input: Int64, + costNanos: Int64?) -> CodexModelsUsageFragment + { + CodexModelsUsageFragment( + workspaceID: "workspace", + sessionID: "session", + day: day, + rawModelID: model, + inputTokens: input, + cachedInputTokens: min(3, input), + outputTokens: 0, + costNanos: costNanos) + } +} diff --git a/Tests/CodexBarTests/CodexModelsPerformanceTests.swift b/Tests/CodexBarTests/CodexModelsPerformanceTests.swift new file mode 100644 index 0000000000..4aee7ba344 --- /dev/null +++ b/Tests/CodexBarTests/CodexModelsPerformanceTests.swift @@ -0,0 +1,156 @@ +import Foundation +import Testing +@testable import CodexBar +@testable import CodexBarCore + +@MainActor +@Suite(.serialized) +struct CodexModelsPerformanceTests { + @Test + func `workspace scale snapshot build stays within end to end budget`() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("codexbar-workspace-performance-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + let sessionsRoot = root.appendingPathComponent("sessions", isDirectory: true) + let cacheRoot = root.appendingPathComponent("cache", isDirectory: true) + try FileManager.default.createDirectory(at: sessionsRoot, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: cacheRoot, withIntermediateDirectories: true) + + let options = CostUsageScanner.Options(codexSessionsRoot: sessionsRoot, cacheRoot: cacheRoot) + let end = Date(timeIntervalSince1970: 1_784_160_000) + let currentDay = end.addingTimeInterval(-24 * 60 * 60) + let previousDay = currentDay.addingTimeInterval(-30 * 24 * 60 * 60) + let currentDayKey = CostUsageScanner.CostUsageDayRange.dayKey(from: currentDay) + let previousDayKey = CostUsageScanner.CostUsageDayRange.dayKey(from: previousDay) + var cache = CostUsageCache() + cache.roots = CostUsageScanner.codexRootsFingerprint(options: options) + + for projectIndex in 0..<30 { + let project = root.appendingPathComponent("project-\(projectIndex)", isDirectory: true) + try FileManager.default.createDirectory( + at: project.appendingPathComponent(".git", isDirectory: true), + withIntermediateDirectories: true) + for sessionIndex in 0..<8 { + let sessionID = "project-\(projectIndex)-session-\(sessionIndex)" + cache.files[sessionsRoot.appendingPathComponent("\(sessionID).jsonl").path] = self.workspaceUsage( + sessionID: sessionID, + cwd: project.path, + currentDay: currentDay, + currentDayKey: currentDayKey, + previousDay: previousDay, + previousDayKey: previousDayKey, + seed: projectIndex * 8 + sessionIndex) + } + } + + func build() throws -> CodexLocalProjectUsageSnapshot { + try CodexLocalProjectUsageIndexer.buildSnapshotFromCostCache( + now: end, + historyDays: 30, + since: previousDay.addingTimeInterval(24 * 60 * 60), + until: end, + options: options, + cacheOverride: cache, + catalogOverride: .empty) + } + + _ = try build() + let clock = ContinuousClock() + var durations: [Duration] = [] + var snapshot: CodexLocalProjectUsageSnapshot? + for _ in 0..<3 { + let start = clock.now + snapshot = try build() + durations.append(start.duration(to: clock.now)) + } + + let measured = try #require(snapshot) + let median = durations.sorted()[1] + #expect(median < .seconds(1.5)) + #expect(measured.projects.count == 30) + #expect(measured.sessions.count == 240) + #expect((measured.total.totalTokens ?? 0) > 0) + #expect((measured.modelsAnalytics?.allWorkspaces.rows.count ?? 0) == 4) + #expect((measured.modelsAnalytics?.workspaces.count ?? 0) == 30) + #expect(measured.modelsAnalytics?.workspaces.values.allSatisfy { $0.rows.count == 4 } == true) + #expect(measured.modelsAnalytics?.allWorkspaces.comparison(.tokens) != .unavailable) + } + + // swiftlint:disable:next function_parameter_count + private func workspaceUsage( + sessionID: String, + cwd: String, + currentDay: Date, + currentDayKey: String, + previousDay: Date, + previousDayKey: String, + seed: Int) -> CostUsageFileUsage + { + let models = (0..<4).map { "model-\($0)" } + var days: [String: [String: [Int]]] = [:] + var rows: [CostUsageScanner.CodexUsageRow] = [] + for (index, model) in models.enumerated() { + let input = 100 + seed + index + let cached = input / 4 + let output = input / 5 + days[currentDayKey, default: [:]][model] = [input, cached, output] + days[previousDayKey, default: [:]][model] = [input - 1, cached, output] + rows.append(CostUsageScanner.CodexUsageRow( + day: currentDayKey, + model: model, + turnID: "current-\(index)", + eventIndex: index * 2, + timestampUnixMs: Int64(currentDay.timeIntervalSince1970 * 1000) + Int64(index), + input: input, + cached: cached, + output: output, + knownCostNanos: Int64(input * 1000), + unpricedTokens: 0, + pricingModel: model, + pricingMode: "standard")) + rows.append(CostUsageScanner.CodexUsageRow( + day: previousDayKey, + model: model, + turnID: "previous-\(index)", + eventIndex: index * 2 + 1, + timestampUnixMs: Int64(previousDay.timeIntervalSince1970 * 1000) + Int64(index), + input: input - 1, + cached: cached, + output: output, + knownCostNanos: Int64((input - 1) * 1000), + unpricedTokens: 0, + pricingModel: model, + pricingMode: "standard")) + } + return CostUsageFileUsage( + mtimeUnixMs: 1, + size: 1, + days: days, + parsedBytes: 1, + lastModel: models.last, + lastTotals: nil, + lastCountedTotals: nil, + lastRawTotalsBaseline: nil, + hasDivergentTotals: nil, + lastCodexTurnID: nil, + sessionId: sessionID, + forkedFromId: nil, + codexSession: CostUsageCodexSessionMetadata( + sessionId: sessionID, + forkedFromId: nil, + cwd: cwd, + title: nil, + startedAtUnixMs: Int64(previousDay.timeIntervalSince1970 * 1000), + latestActivityUnixMs: Int64(currentDay.timeIntervalSince1970 * 1000)), + codexCostNanos: nil, + codexPrioritySurchargeNanos: nil, + codexStandardCostNanos: nil, + codexPriorityCostNanos: nil, + codexStandardTokens: nil, + codexPriorityTokens: nil, + codexTurnIDs: nil, + codexRows: rows, + claudeRows: nil) + .refreshingCodexWorkspaceUsageFingerprint() + } +} diff --git a/Tests/CodexBarTests/CodexOAuthCredentialsStorePermissionsTests.swift b/Tests/CodexBarTests/CodexOAuthCredentialsStorePermissionsTests.swift new file mode 100644 index 0000000000..9d8bf4ef2d --- /dev/null +++ b/Tests/CodexBarTests/CodexOAuthCredentialsStorePermissionsTests.swift @@ -0,0 +1,67 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct CodexOAuthCredentialsStorePermissionsTests { + private enum PublishProbeError: Error, Equatable { + case stop + } + + @Test + func `saving O auth credentials keeps auth json private`() throws { + #if os(macOS) || os(Linux) + let codexHome = FileManager.default.temporaryDirectory + .appendingPathComponent("codex-oauth-permissions-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: codexHome) } + + let credentials = CodexOAuthCredentials( + accessToken: "access-token", + refreshToken: "refresh-token", + idToken: "id-token", + accountId: "account-123", + lastRefresh: Date()) + + try CodexOAuthCredentialsStore.save(credentials, env: ["CODEX_HOME": codexHome.path]) + + let authURL = codexHome.appendingPathComponent("auth.json") + let attributes = try FileManager.default.attributesOfItem(atPath: authURL.path) + let permissions = try #require(attributes[.posixPermissions] as? NSNumber) + #expect(permissions.intValue & 0o777 == 0o600) + #else + #expect(Bool(true)) + #endif + } + + @Test + func `auth json is private before atomic publication`() throws { + #if os(macOS) || os(Linux) + let codexHome = FileManager.default.temporaryDirectory + .appendingPathComponent("codex-oauth-staging-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: codexHome) } + try FileManager.default.createDirectory(at: codexHome, withIntermediateDirectories: true) + + let authURL = codexHome.appendingPathComponent("auth.json") + let originalData = Data("original".utf8) + try originalData.write(to: authURL) + + #expect(throws: PublishProbeError.stop) { + try CodexOAuthCredentialsStore._writePrivateFileForTesting( + Data("replacement".utf8), + to: authURL) + { stagedURL in + let attributes = try FileManager.default.attributesOfItem(atPath: stagedURL.path) + let permissions = try #require(attributes[.posixPermissions] as? NSNumber) + #expect(permissions.intValue & 0o777 == 0o600) + #expect(try Data(contentsOf: authURL) == originalData) + throw PublishProbeError.stop + } + } + + #expect(try Data(contentsOf: authURL) == originalData) + let entries = try FileManager.default.contentsOfDirectory(atPath: codexHome.path) + #expect(entries == ["auth.json"]) + #else + #expect(Bool(true)) + #endif + } +} diff --git a/Tests/CodexBarTests/CodexOAuthCreditLimitTests.swift b/Tests/CodexBarTests/CodexOAuthCreditLimitTests.swift new file mode 100644 index 0000000000..39308f377b --- /dev/null +++ b/Tests/CodexBarTests/CodexOAuthCreditLimitTests.swift @@ -0,0 +1,357 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct CodexOAuthCreditLimitTests { + private struct StubFetchStrategy: ProviderFetchStrategy { + let id = "stub.cli" + let kind: ProviderFetchKind = .cli + let available: Bool + let result: ProviderFetchResult? + + func isAvailable(_: ProviderFetchContext) async -> Bool { + self.available + } + + func fetch(_: ProviderFetchContext) async throws -> ProviderFetchResult { + guard let result else { throw UsageError.noRateLimitsFound } + return result + } + + func shouldFallback(on _: Error, context _: ProviderFetchContext) -> Bool { + false + } + } + + private func makeContext( + sourceMode: ProviderSourceMode = .auto, + includeCredits: Bool = true) -> ProviderFetchContext + { + let browserDetection = BrowserDetection(cacheTTL: 0) + return ProviderFetchContext( + runtime: .app, + sourceMode: sourceMode, + includeCredits: includeCredits, + webTimeout: 60, + webDebugDumpHTML: false, + verbose: false, + env: [:], + settings: nil, + fetcher: UsageFetcher(), + claudeFetcher: ClaudeUsageFetcher(browserDetection: browserDetection), + browserDetection: browserDetection) + } + + private func makeCredentials() -> CodexOAuthCredentials { + CodexOAuthCredentials( + accessToken: "access", + refreshToken: "refresh", + idToken: nil, + accountId: nil, + lastRefresh: Date()) + } + + private func makeCLIResult( + credits: CreditsSnapshot?, + email: String? = nil) -> ProviderFetchResult + { + ProviderFetchResult( + usage: UsageSnapshot( + primary: nil, + secondary: nil, + updatedAt: Date(), + identity: email.map { + ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: $0, + accountOrganization: nil, + loginMethod: "enterprise") + }), + credits: credits, + dashboard: nil, + sourceLabel: "codex-cli", + strategyID: "stub.cli", + strategyKind: .cli) + } + + private func replacingIdentity( + _ result: ProviderFetchResult, + email: String) -> ProviderFetchResult + { + ProviderFetchResult( + usage: result.usage.withIdentity(ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: email, + accountOrganization: nil, + loginMethod: "enterprise")), + credits: result.credits, + dashboard: result.dashboard, + sourceLabel: result.sourceLabel, + strategyID: result.strategyID, + strategyKind: result.strategyKind) + } + + private func makeMonthlyLimitCredits() -> CreditsSnapshot { + let now = Date() + let limit = CodexCreditLimitSnapshot( + used: 250, + limit: 1000, + remainingPercent: 75, + resetsAt: nil, + updatedAt: now) + return CreditsSnapshot( + remaining: limit.remaining, + events: [], + updatedAt: now, + codexCreditLimit: limit) + } + + private func oauthZeroCreditRateWindowJSON() -> String { + """ + { + "rate_limit": { + "primary_window": { + "used_percent": 12, + "reset_at": 1766948068, + "limit_window_seconds": 18000 + }, + "secondary_window": null + }, + "credits": { + "has_credits": true, + "unlimited": false, + "balance": "0" + } + } + """ + } + + @Test + func `decodes monthly credit limit from rate limit payload`() throws { + let json = """ + { + "plan_type": "enterprise", + "rate_limit": { + "primary_window": null, + "secondary_window": null, + "individual_limit": { + "limit": 100000, + "used": "7761", + "remaining_percent": 92.239, + "resets_at": 1782864000 + } + }, + "credits": { + "has_credits": true, + "unlimited": false, + "balance": "0" + } + } + """ + let response = try CodexOAuthUsageFetcher._decodeUsageResponseForTesting(Data(json.utf8)) + #expect(response.rateLimit?.individualLimit?.limit == 100_000) + #expect(response.rateLimit?.individualLimit?.used == 7761) + #expect(response.rateLimit?.individualLimit?.remainingPercent == 92.239) + #expect(response.rateLimit?.individualLimit?.resetsAt == 1_782_864_000) + } + + @Test + func `monthly credit limit O auth payload displays limit when balance is zero`() throws { + let json = """ + { + "rate_limit": { + "primary_window": null, + "secondary_window": null, + "individual_limit": { + "limit": 100000, + "used": 7761, + "remaining_percent": 92.239, + "resets_at": 1782864000 + } + }, + "credits": { + "has_credits": true, + "unlimited": false, + "balance": "0" + } + } + """ + let creds = self.makeCredentials() + + let result = try CodexOAuthFetchStrategy._mapResultForTesting(Data(json.utf8), credentials: creds) + + #expect(result.credits?.remaining == 0) + #expect(result.credits?.codexCreditLimit?.remaining == 92239) + #expect(result.credits?.codexCreditLimit?.remainingPercent == 92.239) + #expect(result.sourceLabel == "oauth") + } + + @Test + func `explicit O auth zero credits without monthly limit keeps partial result`() throws { + let json = """ + { + "rate_limit": { + "primary_window": null, + "secondary_window": null + }, + "credits": { + "has_credits": true, + "unlimited": false, + "balance": "0" + } + } + """ + let result = try CodexOAuthFetchStrategy._mapResultForTesting( + Data(json.utf8), + credentials: self.makeCredentials(), + sourceMode: .oauth) + + #expect(result.credits?.remaining == 0) + #expect(result.credits?.codexCreditLimit == nil) + #expect(result.sourceLabel == "oauth") + } + + @Test + func `auto O auth zero credits preserves O auth usage while adding CLI monthly limit`() async throws { + let mappedOAuth = try CodexOAuthFetchStrategy._mapResultForTesting( + Data(self.oauthZeroCreditRateWindowJSON().utf8), + credentials: self.makeCredentials(), + sourceMode: .auto) + let oauthResult = self.replacingIdentity(mappedOAuth, email: "owner@example.com") + let cliResult = self.makeCLIResult( + credits: self.makeMonthlyLimitCredits(), + email: "owner@example.com") + + let result = try await CodexOAuthFetchStrategy._replaceWithCLIMonthlyLimitForTesting( + oauthResult: oauthResult, + context: self.makeContext(sourceMode: .auto), + cliStrategy: StubFetchStrategy(available: true, result: cliResult)) + + #expect(oauthResult.usage.primary != nil) + #expect(CodexOAuthFetchStrategy._shouldTryCLIForMonthlyLimitForTesting(oauthResult)) + #expect(result.sourceLabel == "oauth") + #expect(result.strategyKind == .oauth) + #expect(result.usage.primary == oauthResult.usage.primary) + #expect(result.credits?.remaining == oauthResult.credits?.remaining) + #expect(result.credits?.codexCreditLimit?.remaining == 750) + } + + @Test + func `usage-only O auth refresh does not launch CLI monthly limit enrichment`() async throws { + let mappedOAuth = try CodexOAuthFetchStrategy._mapResultForTesting( + Data(self.oauthZeroCreditRateWindowJSON().utf8), + credentials: self.makeCredentials(), + sourceMode: .auto) + let oauthResult = self.replacingIdentity(mappedOAuth, email: "owner@example.com") + let cliResult = self.makeCLIResult( + credits: self.makeMonthlyLimitCredits(), + email: "owner@example.com") + + let result = try await CodexOAuthFetchStrategy._replaceWithCLIMonthlyLimitForTesting( + oauthResult: oauthResult, + context: self.makeContext(sourceMode: .auto, includeCredits: false), + cliStrategy: StubFetchStrategy(available: true, result: cliResult)) + + #expect(result.sourceLabel == "oauth") + #expect(result.usage.primary == oauthResult.usage.primary) + #expect(result.credits?.codexCreditLimit == nil) + } + + @Test + func `auto O auth zero credits rejects CLI monthly limit without verified identity`() async throws { + let mappedOAuth = try CodexOAuthFetchStrategy._mapResultForTesting( + Data(self.oauthZeroCreditRateWindowJSON().utf8), + credentials: self.makeCredentials(), + sourceMode: .auto) + let oauthResult = self.replacingIdentity(mappedOAuth, email: "owner@example.com") + let cliResult = self.makeCLIResult(credits: self.makeMonthlyLimitCredits()) + + let result = try await CodexOAuthFetchStrategy._replaceWithCLIMonthlyLimitForTesting( + oauthResult: oauthResult, + context: self.makeContext(sourceMode: .auto), + cliStrategy: StubFetchStrategy(available: true, result: cliResult)) + + #expect(result.sourceLabel == "oauth") + #expect(result.usage.identity?.accountEmail == "owner@example.com") + #expect(result.credits?.codexCreditLimit == nil) + } + + @Test + func `auto O auth zero credits rejects CLI monthly limit from another account`() async throws { + let mappedOAuth = try CodexOAuthFetchStrategy._mapResultForTesting( + Data(self.oauthZeroCreditRateWindowJSON().utf8), + credentials: self.makeCredentials(), + sourceMode: .auto) + let oauthResult = self.replacingIdentity(mappedOAuth, email: "owner@example.com") + let cliResult = self.makeCLIResult( + credits: self.makeMonthlyLimitCredits(), + email: "other@example.com") + + let result = try await CodexOAuthFetchStrategy._replaceWithCLIMonthlyLimitForTesting( + oauthResult: oauthResult, + context: self.makeContext(sourceMode: .auto), + cliStrategy: StubFetchStrategy(available: true, result: cliResult)) + + #expect(result.sourceLabel == "oauth") + #expect(result.usage.identity?.accountEmail == "owner@example.com") + #expect(result.credits?.codexCreditLimit == nil) + } + + @Test + func `auto O auth zero credits accepts matching CLI account case insensitively`() async throws { + let mappedOAuth = try CodexOAuthFetchStrategy._mapResultForTesting( + Data(self.oauthZeroCreditRateWindowJSON().utf8), + credentials: self.makeCredentials(), + sourceMode: .auto) + let oauthResult = self.replacingIdentity(mappedOAuth, email: "Owner@Example.com") + let cliResult = self.makeCLIResult( + credits: self.makeMonthlyLimitCredits(), + email: " owner@example.COM ") + + let result = try await CodexOAuthFetchStrategy._replaceWithCLIMonthlyLimitForTesting( + oauthResult: oauthResult, + context: self.makeContext(sourceMode: .auto), + cliStrategy: StubFetchStrategy(available: true, result: cliResult)) + + #expect(result.sourceLabel == "oauth") + #expect(result.usage.identity?.accountEmail == "Owner@Example.com") + #expect(result.credits?.codexCreditLimit?.remaining == 750) + } + + @Test + func `auto O auth zero credits keeps partial result when CLI is unavailable`() async throws { + let oauthResult = try CodexOAuthFetchStrategy._mapResultForTesting( + Data(self.oauthZeroCreditRateWindowJSON().utf8), + credentials: self.makeCredentials(), + sourceMode: .auto) + + let result = try await CodexOAuthFetchStrategy._replaceWithCLIMonthlyLimitForTesting( + oauthResult: oauthResult, + context: self.makeContext(sourceMode: .auto), + cliStrategy: StubFetchStrategy(available: false, result: nil)) + + #expect(result.sourceLabel == "oauth") + #expect(result.credits?.remaining == 0) + #expect(result.usage.primary != nil) + } + + @Test + func `auto O auth zero credits keeps partial result when CLI lacks monthly limit`() async throws { + let oauthResult = try CodexOAuthFetchStrategy._mapResultForTesting( + Data(self.oauthZeroCreditRateWindowJSON().utf8), + credentials: self.makeCredentials(), + sourceMode: .auto) + let cliResult = self.makeCLIResult(credits: CreditsSnapshot( + remaining: 0, + events: [], + updatedAt: Date())) + + let result = try await CodexOAuthFetchStrategy._replaceWithCLIMonthlyLimitForTesting( + oauthResult: oauthResult, + context: self.makeContext(sourceMode: .auto), + cliStrategy: StubFetchStrategy(available: true, result: cliResult)) + + #expect(result.sourceLabel == "oauth") + #expect(result.credits?.codexCreditLimit == nil) + } +} diff --git a/Tests/CodexBarTests/CodexOAuthRequestTests.swift b/Tests/CodexBarTests/CodexOAuthRequestTests.swift new file mode 100644 index 0000000000..717fae0f41 --- /dev/null +++ b/Tests/CodexBarTests/CodexOAuthRequestTests.swift @@ -0,0 +1,242 @@ +import Foundation +import Testing +@testable import CodexBarCore +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +@Suite(.serialized) +struct CodexOAuthRequestTests { + @Test + func `authenticated transport disables shared network state`() { + let configuration = CodexAuthenticatedHTTPTransport.makeConfiguration() + + #expect(configuration.urlCache == nil) + #expect(configuration.requestCachePolicy == .reloadIgnoringLocalCacheData) + #expect(configuration.httpCookieStorage == nil) + #expect(configuration.httpShouldSetCookies == false) + #expect(configuration.urlCredentialStorage == nil) + } + + @Test + func `usage requests fetch distinct cacheable responses for each account`() async throws { + defer { CodexOAuthAccountURLProtocol.reset() } + CodexOAuthAccountURLProtocol.reset() + + let configuration = CodexAuthenticatedHTTPTransport.makeConfiguration() + configuration.protocolClasses = [CodexOAuthAccountURLProtocol.self] + let transport = CodexAuthenticatedHTTPTransport.makeClient(configuration: configuration) + + let (refreshed, depleted) = try await CodexAuthenticatedHTTPTransport.$overrideForTesting + .withValue(transport) { + let refreshed = try await CodexOAuthUsageFetcher.fetchUsage( + accessToken: "token-a", + accountId: "account-a", + env: ["CODEX_HOME": "/tmp/codexbar-oauth-request-test"]) + let depleted = try await CodexOAuthUsageFetcher.fetchUsage( + accessToken: "token-b", + accountId: "account-b", + env: ["CODEX_HOME": "/tmp/codexbar-oauth-request-test"]) + return (refreshed, depleted) + } + + #expect(refreshed.rateLimit?.primaryWindow?.usedPercent == 7) + #expect(refreshed.rateLimit?.secondaryWindow?.usedPercent == 9) + #expect(depleted.rateLimit?.primaryWindow?.usedPercent == 100) + #expect(depleted.rateLimit?.secondaryWindow?.usedPercent == 63) + #expect(depleted.additionalRateLimits?.first?.rateLimit?.primaryWindow?.usedPercent == 4) + + let requests = CodexOAuthAccountURLProtocol.recordedRequests + #expect(requests.count == 2) + #expect(requests.allSatisfy { $0.cachePolicy == .reloadIgnoringLocalCacheData }) + #expect(requests.map { $0.value(forHTTPHeaderField: "ChatGPT-Account-Id") } == ["account-a", "account-b"]) + } + + #if os(macOS) + @MainActor + @Test + func `dashboard cookie requests fetch distinct cacheable responses`() async { + defer { CodexOAuthAccountURLProtocol.reset() } + CodexOAuthAccountURLProtocol.reset() + + let configuration = CodexAuthenticatedHTTPTransport.makeConfiguration() + configuration.protocolClasses = [CodexOAuthAccountURLProtocol.self] + let transport = CodexAuthenticatedHTTPTransport.makeClient(configuration: configuration) + + let (usageA, usageB) = await CodexAuthenticatedHTTPTransport.$overrideForTesting + .withValue(transport) { + let usageA = await OpenAIDashboardFetcher.fetchDashboardUsageAPI( + cookieHeader: "session=a", + deadline: nil, + logger: { _ in }) + let usageB = await OpenAIDashboardFetcher.fetchDashboardUsageAPI( + cookieHeader: "session=b", + deadline: nil, + logger: { _ in }) + return (usageA, usageB) + } + + #expect(usageA?.primaryLimit?.usedPercent == 7) + #expect(usageB?.primaryLimit?.usedPercent == 100) + let requests = CodexOAuthAccountURLProtocol.recordedRequests + #expect(requests.count == 2) + #expect(requests.allSatisfy { $0.cachePolicy == .reloadIgnoringLocalCacheData }) + #expect(requests.map { $0.value(forHTTPHeaderField: "Cookie") } == ["session=a", "session=b"]) + } + + @MainActor + @Test + func `dashboard and cookie importer identity calls use isolated transport`() async throws { + defer { CodexOAuthAccountURLProtocol.reset() } + CodexOAuthAccountURLProtocol.reset() + + let configuration = CodexAuthenticatedHTTPTransport.makeConfiguration() + configuration.protocolClasses = [CodexOAuthAccountURLProtocol.self] + let transport = CodexAuthenticatedHTTPTransport.makeClient(configuration: configuration) + let cookie = try #require(HTTPCookie(properties: [ + .domain: "chatgpt.com", + .path: "/", + .name: "session", + .value: "a", + ])) + + let (dashboardEmail, importerEmail) = try await CodexAuthenticatedHTTPTransport.$overrideForTesting + .withValue(transport) { + let dashboardEmail = await OpenAIDashboardFetcher.fetchSignedInEmailFromAPI( + cookieHeader: "session=a", + deadline: nil, + logger: { _ in }) + let importer = OpenAIDashboardBrowserCookieImporter(browserDetection: BrowserDetection(cacheTTL: 0)) + let importerEmail = try await importer.fetchSignedInEmailFromAPI( + cookies: [cookie], + deadline: nil, + logger: { _ in }) + return (dashboardEmail, importerEmail) + } + + #expect(dashboardEmail == "account-a@example.com") + #expect(importerEmail == "account-a@example.com") + let requests = CodexOAuthAccountURLProtocol.recordedRequests + #expect(requests.count == 2) + #expect(requests.allSatisfy { $0.cachePolicy == .reloadIgnoringLocalCacheData }) + #expect(requests.allSatisfy { $0.url?.path == "/backend-api/me" }) + } + #endif + + @Test + func `token refresh request uses isolated cache policy`() async throws { + let transport = ProviderHTTPTransportStub { request in + #expect(request.url?.absoluteString == "https://auth.openai.com/oauth/token") + #expect(request.httpMethod == "POST") + #expect(request.cachePolicy == .reloadIgnoringLocalCacheData) + let url = try #require(request.url) + let response = try #require(HTTPURLResponse( + url: url, + statusCode: 200, + httpVersion: nil, + headerFields: ["Cache-Control": "public, max-age=300"])) + return (Data(#"{"access_token":"new-a","refresh_token":"new-r"}"#.utf8), response) + } + let credentials = CodexOAuthCredentials( + accessToken: "old-a", + refreshToken: "old-r", + idToken: nil, + accountId: "account-a", + lastRefresh: nil) + + let refreshed = try await CodexAuthenticatedHTTPTransport.$overrideForTesting.withValue(transport) { + try await CodexTokenRefresher.refresh(credentials) + } + + #expect(refreshed.accessToken == "new-a") + #expect(refreshed.refreshToken == "new-r") + #expect(await transport.requests().count == 1) + } +} + +private final class CodexOAuthAccountURLProtocol: URLProtocol { + private(set) nonisolated(unsafe) static var recordedRequests: [URLRequest] = [] + + static func reset() { + self.recordedRequests = [] + } + + override static func canInit(with _: URLRequest) -> Bool { + true + } + + override static func canonicalRequest(for request: URLRequest) -> URLRequest { + request + } + + override func startLoading() { + Self.recordedRequests.append(self.request) + guard let url = self.request.url else { + self.client?.urlProtocol(self, didFailWithError: URLError(.badURL)) + return + } + + let accountKey = self.request.value(forHTTPHeaderField: "ChatGPT-Account-Id") + ?? self.request.value(forHTTPHeaderField: "Cookie") + if self.request.url?.path == "/backend-api/me" { + let email = accountKey == "session=a" ? "account-a@example.com" : "account-b@example.com" + guard let response = HTTPURLResponse( + url: url, + statusCode: 200, + httpVersion: nil, + headerFields: ["Cache-Control": "public, max-age=300"]) + else { + self.client?.urlProtocol(self, didFailWithError: URLError(.badServerResponse)) + return + } + self.client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .allowed) + self.client?.urlProtocol(self, didLoad: Data(#"{"email":"\#(email)"}"#.utf8)) + self.client?.urlProtocolDidFinishLoading(self) + return + } + let payload: String? = switch accountKey { + case "account-a", "session=a": Self.payload(primary: 7, secondary: 9, spark: 2) + case "account-b", "session=b": Self.payload(primary: 100, secondary: 63, spark: 4) + default: nil + } + guard let payload, + let response = HTTPURLResponse( + url: url, + statusCode: 200, + httpVersion: nil, + headerFields: ["Cache-Control": "public, max-age=300"]) + else { + self.client?.urlProtocol(self, didFailWithError: URLError(.userAuthenticationRequired)) + return + } + + self.client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .allowed) + self.client?.urlProtocol(self, didLoad: Data(payload.utf8)) + self.client?.urlProtocolDidFinishLoading(self) + } + + override func stopLoading() {} + + private static func payload(primary: Int, secondary: Int, spark: Int) -> String { + """ + { + "rate_limit": { + "primary_window": { + "used_percent": \(primary), "reset_at": 1766948068, "limit_window_seconds": 18000 + }, + "secondary_window": { + "used_percent": \(secondary), "reset_at": 1767407914, "limit_window_seconds": 604800 + } + }, + "additional_rate_limits": [{ + "limit_name": "GPT-5.3-Codex-Spark", + "rate_limit": { + "primary_window": { + "used_percent": \(spark), "reset_at": 1766948068, "limit_window_seconds": 18000 + } + } + }] + } + """ + } +} diff --git a/Tests/CodexBarTests/CodexOAuthResetCreditFetchTests.swift b/Tests/CodexBarTests/CodexOAuthResetCreditFetchTests.swift new file mode 100644 index 0000000000..356c1a5a50 --- /dev/null +++ b/Tests/CodexBarTests/CodexOAuthResetCreditFetchTests.swift @@ -0,0 +1,161 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct CodexOAuthResetCreditFetchTests { + @Test + func `app enrichment can rescue reset-credit-only O auth usage`() throws { + let json = #"{"rate_limit":{"primary_window":null,"secondary_window":null}}"# + let result = try CodexOAuthFetchStrategy._mapResultForTesting( + Data(json.utf8), + credentials: Self.credentials(), + allowEmptyUsageForResetCreditEnrichment: true) + + #expect(result.usage.primary == nil) + #expect(result.usage.secondary == nil) + #expect(result.usage.codexResetCredits == nil) + #expect(result.credits == nil) + #expect(result.strategyID == "codex.oauth") + } + + @Test + func `app defers reset credit GET while CLI attempts it once on failure`() async throws { + let credentials = Self.credentials() + let recorder = CodexOAuthResetCreditFetchRecorder() + let fetcher: @Sendable (CodexOAuthCredentials) async throws -> CodexRateLimitResetCreditsSnapshot = { _ in + await recorder.recordRequest() + throw CodexOAuthFetchError.serverError(500, nil) + } + + let appResult = try await CodexOAuthFetchStrategy._fetchResetCreditsForTesting( + context: Self.context(runtime: .app), + credentials: credentials, + fetcher: fetcher) + #expect(appResult == nil) + #expect(await recorder.requestCount() == 0) + + let cliResult = try await CodexOAuthFetchStrategy._fetchResetCreditsForTesting( + context: Self.context(runtime: .cli), + credentials: credentials, + fetcher: fetcher) + #expect(cliResult == nil) + #expect(await recorder.requestCount() == 1) + } + + @Test + func `CLI reset credit GET preserves cancellation without retry`() async throws { + let recorder = CodexOAuthResetCreditFetchRecorder() + + await #expect(throws: CancellationError.self) { + _ = try await CodexOAuthFetchStrategy._fetchResetCreditsForTesting( + context: Self.context(runtime: .cli), + credentials: Self.credentials(), + fetcher: { _ in + await recorder.recordRequest() + throw CancellationError() + }) + } + #expect(await recorder.requestCount() == 1) + } + + @Test + func `reset credit inventory only O auth payload still returns usage result`() throws { + let json = #"{"rate_limit":{"primary_window":null,"secondary_window":null}}"# + let now = Date() + let resetCredits = CodexRateLimitResetCreditsSnapshot( + credits: [ + CodexRateLimitResetCredit( + id: "available-no-expiry", + resetType: "codex_rate_limits", + status: .available, + grantedAt: now, + expiresAt: nil, + redeemStartedAt: nil, + redeemedAt: nil, + title: nil, + description: nil), + ], + availableCount: 1, + updatedAt: now) + + let result = try CodexOAuthFetchStrategy._mapResultForTesting( + Data(json.utf8), + credentials: Self.credentials(), + resetCredits: resetCredits) + + #expect(result.usage.primary == nil) + #expect(result.usage.secondary == nil) + #expect(result.usage.codexResetCredits?.availableInventory(at: now).count == 1) + #expect(result.credits == nil) + #expect(result.sourceLabel == "oauth") + } + + @Test + func `empty reset credits do not mask missing O auth usage`() { + let json = #"{"rate_limit":{"primary_window":null,"secondary_window":null}}"# + let resetCredits = CodexRateLimitResetCreditsSnapshot( + credits: [], + availableCount: 0, + updatedAt: Date()) + + #expect(throws: UsageError.self) { + try CodexOAuthFetchStrategy._mapResultForTesting( + Data(json.utf8), + credentials: Self.credentials(), + resetCredits: resetCredits) + } + } + + @Test + func `O auth strategy defers app inventory and CLI follows credits flag`() { + let appContext = Self.context(runtime: .app, includeCredits: false, includeOptionalUsage: false) + let cliNoCreditsContext = Self.context(runtime: .cli, includeCredits: false, includeOptionalUsage: true) + let cliCreditsContext = Self.context(runtime: .cli, includeCredits: true, includeOptionalUsage: false) + + #expect(CodexOAuthFetchStrategy._shouldFetchResetCreditsForTesting(appContext) == false) + #expect(CodexOAuthFetchStrategy._shouldFetchResetCreditsForTesting(cliNoCreditsContext) == false) + #expect(CodexOAuthFetchStrategy._shouldFetchResetCreditsForTesting(cliCreditsContext)) + } + + private static func context( + runtime: ProviderRuntime, + includeCredits: Bool = true, + includeOptionalUsage: Bool = false) -> ProviderFetchContext + { + let browserDetection = BrowserDetection(cacheTTL: 0) + return ProviderFetchContext( + runtime: runtime, + sourceMode: .auto, + includeCredits: includeCredits, + includeOptionalUsage: includeOptionalUsage, + webTimeout: 60, + webDebugDumpHTML: false, + verbose: false, + env: [:], + settings: nil, + fetcher: UsageFetcher(), + claudeFetcher: ClaudeUsageFetcher(browserDetection: browserDetection), + browserDetection: browserDetection) + } + + private static func credentials() -> CodexOAuthCredentials { + CodexOAuthCredentials( + accessToken: "access", + refreshToken: "refresh", + idToken: nil, + accountId: "account-123", + lastRefresh: Date()) + } +} + +private actor CodexOAuthResetCreditFetchRecorder { + private var count = 0 + + func recordRequest() { + self.count += 1 + } + + func requestCount() -> Int { + self.count + } +} diff --git a/Tests/CodexBarTests/CodexOAuthTests.swift b/Tests/CodexBarTests/CodexOAuthTests.swift index 2d3410e847..d6a338b038 100644 --- a/Tests/CodexBarTests/CodexOAuthTests.swift +++ b/Tests/CodexBarTests/CodexOAuthTests.swift @@ -3,12 +3,18 @@ import Testing @testable import CodexBarCore struct CodexOAuthTests { - private func makeContext(sourceMode: ProviderSourceMode = .auto) -> ProviderFetchContext { + private func makeContext( + runtime: ProviderRuntime = .app, + sourceMode: ProviderSourceMode = .auto, + includeCredits: Bool = true, + includeOptionalUsage: Bool = true) -> ProviderFetchContext + { let browserDetection = BrowserDetection(cacheTTL: 0) return ProviderFetchContext( - runtime: .app, + runtime: runtime, sourceMode: sourceMode, - includeCredits: true, + includeCredits: includeCredits, + includeOptionalUsage: includeOptionalUsage, webTimeout: 60, webDebugDumpHTML: false, verbose: false, @@ -77,6 +83,32 @@ struct CodexOAuthTests { #expect(creds.accountId == nil) } + @Test + func `reset-credit token load ignores an API key beside O auth tokens`() throws { + let home = FileManager.default.temporaryDirectory + .appendingPathComponent("codexbar-reset-credit-oauth-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: home, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: home) } + let json = """ + { + "OPENAI_API_KEY": "sk-test", + "tokens": { + "access_token": "oauth-access-token", + "refresh_token": "oauth-refresh-token", + "account_id": "account-123" + }, + "last_refresh": "2026-07-01T12:00:00Z" + } + """ + try Data(json.utf8).write(to: home.appendingPathComponent("auth.json")) + + let credentials = try CodexOAuthCredentialsStore.loadOAuthTokens(env: ["CODEX_HOME": home.path]) + + #expect(credentials.accessToken == "oauth-access-token") + #expect(credentials.refreshToken == "oauth-refresh-token") + #expect(credentials.accountId == "account-123") + } + @Test func `decodes credits balance string`() throws { let json = """ @@ -164,6 +196,73 @@ struct CodexOAuthTests { #expect(snapshot.secondary?.resetsAt != nil) } + @Test + func `O auth response with precise windows maps to exact confidence`() throws { + let json = """ + { + "rate_limit": { + "primary_window": { + "used_percent": 22, + "reset_at": 1766948068, + "limit_window_seconds": 18000 + }, + "secondary_window": { + "used_percent": 43, + "reset_at": 1767407914, + "limit_window_seconds": 604800 + } + } + } + """ + let creds = CodexOAuthCredentials( + accessToken: "access", + refreshToken: "refresh", + idToken: nil, + accountId: nil, + lastRefresh: Date()) + let result = try CodexOAuthFetchStrategy._mapResultForTesting(Data(json.utf8), credentials: creds) + + #expect(result.sourceLabel == "oauth") + #expect(result.usage.dataConfidence == .exact) + #expect(result.usage.primary?.usedPercent == 22) + #expect(result.usage.secondary?.usedPercent == 43) + } + + @Test + func `O auth response with malformed additional window maps to unknown confidence`() throws { + let json = """ + { + "rate_limit": { + "primary_window": { + "used_percent": 22, + "reset_at": 1766948068, + "limit_window_seconds": 18000 + } + }, + "additional_rate_limits": [ + { + "limit_name": "GPT-5.3-Codex-Spark", + "metered_feature": "gpt_5_3_codex_spark", + "rate_limit": { + "primary_window": { "used_percent": "bad" } + } + } + ] + } + """ + let creds = CodexOAuthCredentials( + accessToken: "access", + refreshToken: "refresh", + idToken: nil, + accountId: nil, + lastRefresh: Date()) + let result = try CodexOAuthFetchStrategy._mapResultForTesting(Data(json.utf8), credentials: creds) + + #expect(result.usage.primary?.usedPercent == 22) + #expect(result.usage.extraRateWindows == nil) + #expect(result.usage.dataConfidence == .unknown) + } + @Test func `maps free weekly only window into secondary`() throws { let json = """ @@ -352,6 +451,11 @@ struct CodexOAuthTests { let snapshot = try CodexOAuthFetchStrategy._mapUsageForTesting(Data(json.utf8), credentials: creds) #expect(snapshot?.primary?.usedPercent == 18) #expect(snapshot?.secondary == nil) + + let result = try CodexOAuthFetchStrategy._mapResultForTesting(Data(json.utf8), credentials: creds) + #expect(result.usage.primary?.usedPercent == 18) + #expect(result.usage.secondary == nil) + #expect(result.usage.dataConfidence == .unknown) } @Test diff --git a/Tests/CodexBarTests/CodexOpenAIWorkspaceResolverTests.swift b/Tests/CodexBarTests/CodexOpenAIWorkspaceResolverTests.swift index b0083f1a92..93f733721f 100644 --- a/Tests/CodexBarTests/CodexOpenAIWorkspaceResolverTests.swift +++ b/Tests/CodexBarTests/CodexOpenAIWorkspaceResolverTests.swift @@ -1,6 +1,6 @@ -import CodexBarCore import Foundation import Testing +@testable import CodexBarCore @Suite(.serialized) struct CodexOpenAIWorkspaceResolverTests { @@ -53,6 +53,33 @@ struct CodexOpenAIWorkspaceResolverTests { == "codex-cli") } + @Test + func `resolver default uses isolated authenticated transport`() async throws { + let transport = ProviderHTTPTransportStub { request in + #expect(request.cachePolicy == .reloadIgnoringLocalCacheData) + let url = try #require(request.url) + let response = try #require(HTTPURLResponse( + url: url, + statusCode: 200, + httpVersion: nil, + headerFields: ["Cache-Control": "public, max-age=300"])) + return (Data(#"{"items":[{"id":"account-live","name":"Team Alpha"}]}"#.utf8), response) + } + let credentials = CodexOAuthCredentials( + accessToken: "test-a", + refreshToken: "test-r", + idToken: nil, + accountId: "account-live", + lastRefresh: nil) + + let identity = try await CodexAuthenticatedHTTPTransport.$overrideForTesting.withValue(transport) { + try await CodexOpenAIWorkspaceResolver.resolve(credentials: credentials) + } + + #expect(identity?.workspaceLabel == "Team Alpha") + #expect(await transport.requests().count == 1) + } + @Test func `resolver returns personal when account name is empty`() async throws { defer { @@ -113,7 +140,11 @@ struct CodexOpenAIWorkspaceResolverTests { final class CodexOpenAIWorkspaceStubURLProtocol: URLProtocol { nonisolated(unsafe) static var requests: [URLRequest] = [] - nonisolated(unsafe) static var handler: (@Sendable (URLRequest) throws -> (HTTPURLResponse, Data))? + private static let _handlerBox = LockIsolated<(@Sendable (URLRequest) throws -> (HTTPURLResponse, Data))?>(nil) + static var handler: (@Sendable (URLRequest) throws -> (HTTPURLResponse, Data))? { + get { Self._handlerBox.value } + set { Self._handlerBox.setValue(newValue) } + } override static func canInit(with request: URLRequest) -> Bool { true diff --git a/Tests/CodexBarTests/CodexPresentationCharacterizationTests.swift b/Tests/CodexBarTests/CodexPresentationCharacterizationTests.swift index 670c44cd91..e548571cf5 100644 --- a/Tests/CodexBarTests/CodexPresentationCharacterizationTests.swift +++ b/Tests/CodexBarTests/CodexPresentationCharacterizationTests.swift @@ -95,6 +95,45 @@ struct CodexPresentationCharacterizationTests { #expect(!lines.contains("Plan: Max")) } + @Test + func `Codex menu omits account row when hiding personal info`() { + let settings = self.makeSettingsStore(suite: "CodexPresentationCharacterizationTests-hide-account") + settings.statusChecksEnabled = false + settings.hidePersonalInfo = true + + let fetcher = UsageFetcher(environment: [:]) + let store = UsageStore( + fetcher: fetcher, + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + startupBehavior: .testing) + store._setSnapshotForTesting( + UsageSnapshot( + primary: RateWindow(usedPercent: 12, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + secondary: nil, + updatedAt: Date(), + identity: ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: "codex@example.com", + accountOrganization: nil, + loginMethod: "free")), + provider: .codex) + + let descriptor = MenuDescriptor.build( + provider: .codex, + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updateReady: false, + includeContextualActions: false) + + let lines = self.textLines(from: descriptor) + #expect(!lines.contains(where: { $0.hasPrefix("Account:") })) + #expect(!lines.contains(where: { $0.contains("codex@example.com") })) + #expect(!lines.contains(where: { $0.contains("Hidden") })) + #expect(lines.contains("Plan: Free")) + } + @Test func `Codex menu maps prolite plan to multiplier display name`() { let settings = self.makeSettingsStore(suite: "CodexPresentationCharacterizationTests-prolite") diff --git a/Tests/CodexBarTests/CodexProfileHomeAccountTests.swift b/Tests/CodexBarTests/CodexProfileHomeAccountTests.swift new file mode 100644 index 0000000000..25dc2983a3 --- /dev/null +++ b/Tests/CodexBarTests/CodexProfileHomeAccountTests.swift @@ -0,0 +1,398 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +@Suite(.serialized) +struct CodexProfileHomeAccountTests { + @MainActor + private static func makeSettings(suite: String) throws -> SettingsStore { + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + defaults.set(true, forKey: "providerDetectionCompleted") + let settings = SettingsStore( + userDefaults: defaults, + configStore: testConfigStore(suiteName: suite), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + settings.providerDetectionCompleted = true + return settings + } + + @Test + @MainActor + func `settings store discovers configured codex profile homes`() throws { + let suite = "CodexProfileHomeAccountTests-discovery" + let settings = try Self.makeSettings(suite: suite) + let missingLiveHome = FileManager.default.temporaryDirectory.appendingPathComponent( + UUID().uuidString, + isDirectory: true) + let profileHome = FileManager.default.temporaryDirectory.appendingPathComponent( + UUID().uuidString, + isDirectory: true) + try Self.writeCodexAuthFile( + homeURL: profileHome, + email: "Profile@Example.com", + plan: "pro", + accountID: "acct_profile") + settings._test_codexReconciliationEnvironment = ["CODEX_HOME": missingLiveHome.path] + settings.updateProviderConfig(provider: .codex) { entry in + entry.codexProfileHomePaths = [profileHome.path, profileHome.path] + } + settings.codexActiveSource = .profileHome(path: profileHome.path) + defer { + settings._test_codexReconciliationEnvironment = nil + try? FileManager.default.removeItem(at: missingLiveHome) + try? FileManager.default.removeItem(at: profileHome) + } + + let normalizedProfilePath = try #require(CodexHomeScope.normalizedHomePath(profileHome.path)) + let snapshot = settings.codexAccountReconciliationSnapshot + let projection = settings.codexVisibleAccountProjection + + #expect(settings.codexResolvedActiveSource == .profileHome(path: normalizedProfilePath)) + #expect(snapshot.liveSystemAccount == nil) + #expect(snapshot.profileHomeAccounts.map(\.email) == ["profile@example.com"]) + #expect(snapshot.profileHomeAccounts.map(\.codexHomePath) == [normalizedProfilePath]) + #expect(snapshot.profileHomePaths == [normalizedProfilePath]) + #expect(projection.visibleAccounts.map(\.email) == ["profile@example.com"]) + #expect(projection.activeVisibleAccountID == "profile@example.com") + #expect(projection.liveVisibleAccountID == nil) + #expect(projection.visibleAccounts.first?.selectionSource == .profileHome(path: normalizedProfilePath)) + #expect(projection.visibleAccounts.first?.isLive == false) + #expect(projection.visibleAccounts.first?.canReauthenticate == false) + #expect(projection.visibleAccounts.first?.canRemove == false) + } + + @Test + @MainActor + func `provider registry scopes selected codex profile home`() throws { + let suite = "CodexProfileHomeAccountTests-routing" + let settings = try Self.makeSettings(suite: suite) + let profileHome = FileManager.default.temporaryDirectory.appendingPathComponent( + UUID().uuidString, + isDirectory: true) + try Self.writeCodexAuthFile( + homeURL: profileHome, + email: "profile-route@example.com", + plan: "pro") + settings.updateProviderConfig(provider: .codex) { entry in + entry.codexProfileHomePaths = [profileHome.path] + entry.codexActiveSource = .profileHome(path: profileHome.path) + } + defer { + try? FileManager.default.removeItem(at: profileHome) + } + + let normalizedProfilePath = try #require(CodexHomeScope.normalizedHomePath(profileHome.path)) + let environment = ProviderRegistry.makeEnvironment( + base: ["CODEX_HOME": "/tmp/ambient-codex"], + provider: .codex, + settings: settings, + tokenOverride: nil) + + #expect(environment["CODEX_HOME"] == normalizedProfilePath) + } + + @Test + @MainActor + func `removed profile home falls back without routing stale path`() throws { + let suite = "CodexProfileHomeAccountTests-stale-routing" + let settings = try Self.makeSettings(suite: suite) + let missingLiveHome = FileManager.default.temporaryDirectory.appendingPathComponent( + UUID().uuidString, + isDirectory: true) + let removedProfileHome = FileManager.default.temporaryDirectory.appendingPathComponent( + UUID().uuidString, + isDirectory: true) + settings._test_codexReconciliationEnvironment = ["CODEX_HOME": missingLiveHome.path] + settings.updateProviderConfig(provider: .codex) { entry in + entry.codexProfileHomePaths = [] + entry.codexActiveSource = .profileHome(path: removedProfileHome.path) + } + defer { + settings._test_codexReconciliationEnvironment = nil + try? FileManager.default.removeItem(at: missingLiveHome) + try? FileManager.default.removeItem(at: removedProfileHome) + } + + let environment = ProviderRegistry.makeEnvironment( + base: ["CODEX_HOME": "/tmp/ambient-codex"], + provider: .codex, + settings: settings, + tokenOverride: nil) + + #expect(settings.codexResolvedActiveSource == .liveSystem) + #expect(environment["CODEX_HOME"] == "/tmp/ambient-codex") + let staleOverrideEnvironment = ProviderRegistry.makeEnvironment( + base: ["CODEX_HOME": "/tmp/ambient-codex"], + provider: .codex, + settings: settings, + tokenOverride: nil, + codexActiveSourceOverride: .profileHome(path: removedProfileHome.path)) + #expect(staleOverrideEnvironment["CODEX_HOME"] == "/tmp/ambient-codex") + #expect(settings.persistResolvedCodexActiveSourceCorrectionIfNeeded()) + #expect(settings.codexActiveSource == .liveSystem) + } + + @Test + @MainActor + func `external config removal immediately invalidates profile routing caches`() throws { + let suite = "CodexProfileHomeAccountTests-external-removal" + let settings = try Self.makeSettings(suite: suite) + let missingLiveHome = FileManager.default.temporaryDirectory.appendingPathComponent( + UUID().uuidString, + isDirectory: true) + let profileHome = FileManager.default.temporaryDirectory.appendingPathComponent( + UUID().uuidString, + isDirectory: true) + try Self.writeCodexAuthFile( + homeURL: profileHome, + email: "profile-cache@example.com", + plan: "pro") + let previousInterval = SettingsStore.codexAccountReconciliationSnapshotCacheIntervalOverrideForTesting + SettingsStore.codexAccountReconciliationSnapshotCacheIntervalOverrideForTesting = 60 + settings._test_codexReconciliationEnvironment = ["CODEX_HOME": missingLiveHome.path] + defer { + SettingsStore.codexAccountReconciliationSnapshotCacheIntervalOverrideForTesting = previousInterval + settings._test_codexReconciliationEnvironment = nil + try? FileManager.default.removeItem(at: missingLiveHome) + try? FileManager.default.removeItem(at: profileHome) + } + + settings.updateProviderConfig(provider: .codex) { entry in + entry.codexProfileHomePaths = [profileHome.path] + entry.codexActiveSource = .profileHome(path: profileHome.path) + } + _ = settings.codexAccountReconciliationSnapshot + #expect(settings.cachedCodexAccountReconciliationSnapshot != nil) + #expect(settings.cachedCodexAccountMenuProjection != nil) + + settings.applyExternalConfig( + CodexBarConfig(providers: [ + ProviderConfig( + id: .codex, + codexActiveSource: .profileHome(path: profileHome.path), + codexProfileHomePaths: []), + ]), + reason: "profile-removed") + + #expect(settings.cachedCodexAccountReconciliationSnapshot == nil) + #expect(settings.cachedCodexAccountMenuProjection == nil) + let environment = ProviderRegistry.makeEnvironment( + base: ["CODEX_HOME": "/tmp/ambient-codex"], + provider: .codex, + settings: settings, + tokenOverride: nil) + #expect(settings.codexResolvedActiveSource == .liveSystem) + #expect(environment["CODEX_HOME"] == "/tmp/ambient-codex") + } + + @Test + @MainActor + func `relative profile homes are ignored by app routing`() throws { + let settings = try Self.makeSettings(suite: "CodexProfileHomeAccountTests-relative") + settings.updateProviderConfig(provider: .codex) { entry in + entry.codexProfileHomePaths = ["relative-codex-home", "~someone/.codex"] + entry.codexActiveSource = .profileHome(path: "relative-codex-home") + } + + #expect(CodexHomeScope.normalizedHomePath("relative-codex-home") == nil) + #expect(CodexHomeScope.normalizedHomePath("~someone/.codex") == nil) + #expect(settings.codexProfileHomePaths.isEmpty) + let environment = ProviderRegistry.makeEnvironment( + base: ["CODEX_HOME": "/tmp/ambient-codex"], + provider: .codex, + settings: settings, + tokenOverride: nil, + codexActiveSourceOverride: .profileHome(path: "relative-codex-home")) + #expect(environment["CODEX_HOME"] == "/tmp/ambient-codex") + } + + @Test + @MainActor + func `unreadable configured profile home remains selected and routed`() throws { + let suite = "CodexProfileHomeAccountTests-unreadable-routing" + let settings = try Self.makeSettings(suite: suite) + let missingLiveHome = FileManager.default.temporaryDirectory.appendingPathComponent( + UUID().uuidString, + isDirectory: true) + let unreadableProfileHome = FileManager.default.temporaryDirectory.appendingPathComponent( + UUID().uuidString, + isDirectory: true) + settings._test_codexReconciliationEnvironment = ["CODEX_HOME": missingLiveHome.path] + settings.updateProviderConfig(provider: .codex) { entry in + entry.codexProfileHomePaths = [unreadableProfileHome.path] + entry.codexActiveSource = .profileHome(path: unreadableProfileHome.path) + } + defer { + settings._test_codexReconciliationEnvironment = nil + try? FileManager.default.removeItem(at: missingLiveHome) + try? FileManager.default.removeItem(at: unreadableProfileHome) + } + + let normalizedProfilePath = try #require(CodexHomeScope.normalizedHomePath(unreadableProfileHome.path)) + let environment = ProviderRegistry.makeEnvironment( + base: ["CODEX_HOME": "/tmp/ambient-codex"], + provider: .codex, + settings: settings, + tokenOverride: nil) + + #expect(settings.codexResolvedActiveSource == .profileHome(path: normalizedProfilePath)) + #expect(settings.codexAccountReconciliationSnapshot.profileHomeAccounts.isEmpty) + #expect(environment["CODEX_HOME"] == normalizedProfilePath) + #expect(!settings.persistResolvedCodexActiveSourceCorrectionIfNeeded()) + #expect(settings.codexActiveSource == .profileHome(path: normalizedProfilePath)) + } + + @Test + @MainActor + func `profile without verified email refuses open A I cookie import`() async throws { + let suite = "CodexProfileHomeAccountTests-missing-web-email" + let settings = try Self.makeSettings(suite: suite) + let missingLiveHome = FileManager.default.temporaryDirectory.appendingPathComponent( + UUID().uuidString, + isDirectory: true) + let unreadableProfileHome = FileManager.default.temporaryDirectory.appendingPathComponent( + UUID().uuidString, + isDirectory: true) + settings._test_codexReconciliationEnvironment = ["CODEX_HOME": missingLiveHome.path] + settings.updateProviderConfig(provider: .codex) { entry in + entry.codexProfileHomePaths = [unreadableProfileHome.path] + entry.codexActiveSource = .profileHome(path: unreadableProfileHome.path) + } + defer { + settings._test_codexReconciliationEnvironment = nil + try? FileManager.default.removeItem(at: missingLiveHome) + try? FileManager.default.removeItem(at: unreadableProfileHome) + } + + let store = UsageStore( + fetcher: UsageFetcher(environment: ["CODEX_HOME": missingLiveHome.path]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + startupBehavior: .testing) + var importAttempts = 0 + store._test_openAIDashboardCookieImportOverride = { _, _, _, _, _ in + importAttempts += 1 + return OpenAIDashboardBrowserCookieImporter.ImportResult( + sourceLabel: "test", + cookieCount: 1, + signedInEmail: "other@example.com", + matchesCodexEmail: false) + } + + let imported = await store.importOpenAIDashboardCookiesIfNeeded(targetEmail: nil, force: true) + + #expect(imported == nil) + #expect(importAttempts == 0) + #expect(store.openAIDashboardRequiresLogin) + #expect(store.openAIDashboardCookieImportStatus?.contains("no verified account email") == true) + } + + @Test + @MainActor + func `profile home matching live home resolves to visible live account`() throws { + let suite = "CodexProfileHomeAccountTests-live-duplicate" + let settings = try Self.makeSettings(suite: suite) + let liveHome = FileManager.default.temporaryDirectory.appendingPathComponent( + UUID().uuidString, + isDirectory: true) + let liveAccount = ObservedSystemCodexAccount( + email: "live@example.com", + codexHomePath: liveHome.path, + observedAt: Date()) + settings._test_liveSystemCodexAccount = liveAccount + settings.updateProviderConfig(provider: .codex) { entry in + entry.codexProfileHomePaths = [liveHome.path] + entry.codexActiveSource = .profileHome(path: liveHome.path) + } + defer { + settings._test_liveSystemCodexAccount = nil + } + + let projection = settings.codexVisibleAccountProjection + + #expect(settings.codexResolvedActiveSource == .liveSystem) + #expect(projection.visibleAccounts.map(\.email) == ["live@example.com"]) + #expect(projection.activeVisibleAccountID == "live@example.com") + #expect(settings.persistResolvedCodexActiveSourceCorrectionIfNeeded()) + #expect(settings.codexActiveSource == .liveSystem) + } + + @Test + @MainActor + func `profile home matching managed home resolves to visible managed account`() throws { + let suite = "CodexProfileHomeAccountTests-managed-duplicate" + let settings = try Self.makeSettings(suite: suite) + let managedHome = FileManager.default.temporaryDirectory.appendingPathComponent( + UUID().uuidString, + isDirectory: true) + let managedAccount = ManagedCodexAccount( + id: UUID(), + email: "managed@example.com", + managedHomePath: managedHome.path, + createdAt: 1, + updatedAt: 2, + lastAuthenticatedAt: 3) + settings._test_activeManagedCodexAccount = managedAccount + settings.updateProviderConfig(provider: .codex) { entry in + entry.codexProfileHomePaths = [managedHome.path] + entry.codexActiveSource = .profileHome(path: managedHome.path) + } + defer { + settings._test_activeManagedCodexAccount = nil + } + + let projection = settings.codexVisibleAccountProjection + + #expect(settings.codexResolvedActiveSource == .managedAccount(id: managedAccount.id)) + #expect(projection.visibleAccounts.map(\.email) == ["managed@example.com"]) + #expect(projection.activeVisibleAccountID == "managed@example.com") + #expect(settings.persistResolvedCodexActiveSourceCorrectionIfNeeded()) + #expect(settings.codexActiveSource == .managedAccount(id: managedAccount.id)) + } + + private static func writeCodexAuthFile( + homeURL: URL, + email: String, + plan: String, + accountID: String? = nil) throws + { + try FileManager.default.createDirectory(at: homeURL, withIntermediateDirectories: true) + var tokens: [String: Any] = [ + "accessToken": "access-token", + "refreshToken": "refresh-token", + "idToken": Self.fakeJWT(email: email, plan: plan, accountID: accountID), + ] + if let accountID { + tokens["account_id"] = accountID + } + let auth = ["tokens": tokens] + let data = try JSONSerialization.data(withJSONObject: auth) + try data.write(to: homeURL.appendingPathComponent("auth.json")) + } + + private static func fakeJWT(email: String, plan: String, accountID: String? = nil) -> String { + let header = (try? JSONSerialization.data(withJSONObject: ["alg": "none"])) ?? Data() + var payloadObject: [String: Any] = [ + "email": email, + "chatgpt_plan_type": plan, + ] + if let accountID { + payloadObject["https://api.openai.com/auth"] = [ + "chatgpt_account_id": accountID, + ] + } + let payload = (try? JSONSerialization.data(withJSONObject: payloadObject)) ?? Data() + + func base64URL(_ data: Data) -> String { + data.base64EncodedString() + .replacingOccurrences(of: "=", with: "") + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + } + + return "\(base64URL(header)).\(base64URL(payload))." + } +} diff --git a/Tests/CodexBarTests/CodexProviderSettingsBuilderTests.swift b/Tests/CodexBarTests/CodexProviderSettingsBuilderTests.swift index d2f2afb228..ba85f3aaa9 100644 --- a/Tests/CodexBarTests/CodexProviderSettingsBuilderTests.swift +++ b/Tests/CodexBarTests/CodexProviderSettingsBuilderTests.swift @@ -91,6 +91,30 @@ struct CodexProviderSettingsBuilderTests { #expect(settings.managedAccountTargetUnavailable == true) } + @Test + func `builder marks profile without observed account as unavailable`() { + let profilePath = "/tmp/codex-profile-missing-auth" + let snapshot = CodexAccountReconciliationSnapshot( + storedAccounts: [], + activeStoredAccount: nil, + liveSystemAccount: nil, + profileHomeAccounts: [], + profileHomePaths: [profilePath], + matchingStoredAccountForLiveSystemAccount: nil, + activeSource: .profileHome(path: profilePath), + hasUnreadableAddedAccountStore: false) + + let settings = CodexProviderSettingsBuilder.make(input: CodexProviderSettingsBuilderInput( + usageDataSource: .auto, + cookieSource: .auto, + manualCookieHeader: nil, + reconciliationSnapshot: snapshot, + resolvedActiveSource: CodexActiveSourceResolver.resolve(from: snapshot))) + + #expect(settings.profileAccountTargetUnavailable) + #expect(settings.openAIWebCacheScope == .profileHome(profilePath)) + } + @Test func `known owner catalog includes runtime managed and live identities`() { let storedAccount = ManagedCodexAccount( @@ -125,4 +149,52 @@ struct CodexProviderSettingsBuilderTests { identity: .providerAccount(id: "acct-live"), normalizedEmail: "live@example.com"))) } + + @Test + func `builder preserves same email profile owners and scopes web cache`() { + let profileA = ObservedSystemCodexAccount( + email: "shared@example.com", + codexHomePath: "/tmp/codex-profile-a", + observedAt: Date(), + identity: .emailOnly(normalizedEmail: "shared@example.com")) + let profileB = ObservedSystemCodexAccount( + email: "shared@example.com", + codexHomePath: "/tmp/codex-profile-b", + observedAt: Date(), + identity: .emailOnly(normalizedEmail: "shared@example.com")) + let snapshot = CodexAccountReconciliationSnapshot( + storedAccounts: [], + activeStoredAccount: nil, + liveSystemAccount: nil, + profileHomeAccounts: [profileA, profileB], + matchingStoredAccountForLiveSystemAccount: nil, + activeSource: .profileHome(path: profileA.codexHomePath), + hasUnreadableAddedAccountStore: false) + + let settings = CodexProviderSettingsBuilder.make(input: CodexProviderSettingsBuilderInput( + usageDataSource: .auto, + cookieSource: .auto, + manualCookieHeader: nil, + reconciliationSnapshot: snapshot, + resolvedActiveSource: CodexActiveSourceResolver.resolve(from: snapshot))) + + #expect(settings.openAIWebCacheScope == .profileHome(profileA.codexHomePath)) + #expect(!settings.profileAccountTargetUnavailable) + #expect(settings.dashboardAuthorityKnownOwners.count == 2) + #expect(Set(settings.dashboardAuthorityKnownOwners.map(\.sourceIsolationIdentifier)).count == 2) + + let decision = CodexDashboardAuthority.evaluate(CodexDashboardAuthorityInput( + sourceKind: .liveWeb, + proof: CodexDashboardOwnershipProofContext( + currentIdentity: .emailOnly(normalizedEmail: "shared@example.com"), + expectedScopedEmail: "shared@example.com", + trustedCurrentUsageEmail: nil, + dashboardSignedInEmail: "shared@example.com", + knownOwners: settings.dashboardAuthorityKnownOwners), + routing: CodexDashboardRoutingHints( + targetEmail: "shared@example.com", + lastKnownDashboardRoutingEmail: nil))) + #expect(decision.disposition == .displayOnly) + #expect(decision.reason == .sameEmailAmbiguity(email: "shared@example.com")) + } } diff --git a/Tests/CodexBarTests/CodexRateLimitResetCreditsTests.swift b/Tests/CodexBarTests/CodexRateLimitResetCreditsTests.swift new file mode 100644 index 0000000000..f87394d4c6 --- /dev/null +++ b/Tests/CodexBarTests/CodexRateLimitResetCreditsTests.swift @@ -0,0 +1,253 @@ +import Foundation +import Testing +@testable import CodexBarCore +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +struct CodexRateLimitResetCreditsTests { + @Test + func `resolves URL from chat GPT config`() { + let config = "chatgpt_base_url = \"https://chatgpt.com/backend-api/\"\n" + let url = CodexOAuthUsageFetcher._resolveRateLimitResetCreditsURLForTesting(configContents: config) + #expect(url.absoluteString == "https://chatgpt.com/backend-api/wham/rate-limit-reset-credits") + } + + @Test + func `request scopes auth and account with bounded timeout`() async throws { + let transport = ProviderHTTPTransportStub { request in + #expect(request.url?.absoluteString == "https://chatgpt.com/backend-api/wham/rate-limit-reset-credits") + #expect(request.httpMethod == "GET") + #expect(request.timeoutInterval == 4) + #expect(request.cachePolicy == .reloadIgnoringLocalCacheData) + #expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer test-token") + #expect(request.value(forHTTPHeaderField: "ChatGPT-Account-ID") == "account-123") + #expect(request.value(forHTTPHeaderField: "OpenAI-Beta") == "codex-1") + #expect(request.value(forHTTPHeaderField: "originator") == "Codex Desktop") + let url = try #require(request.url) + let response = try #require(HTTPURLResponse( + url: url, + statusCode: 200, + httpVersion: nil, + headerFields: nil)) + return (Data(#"{"credits":[],"available_count":0}"#.utf8), response) + } + + let snapshot = try await CodexAuthenticatedHTTPTransport.$overrideForTesting.withValue(transport) { + try await CodexOAuthUsageFetcher.fetchRateLimitResetCredits( + accessToken: "test-token", + accountId: "account-123", + env: ["CODEX_HOME": "/tmp/codexbar-reset-credit-request-test"]) + } + + #expect(snapshot.availableCount == 0) + #expect(await transport.requests().count == 1) + } + + @Test + func `rejects negative available count`() async throws { + let transport = ProviderHTTPTransportStub { request in + let url = try #require(request.url) + let response = try #require(HTTPURLResponse( + url: url, + statusCode: 200, + httpVersion: nil, + headerFields: nil)) + return (Data(#"{"credits":[],"available_count":-1}"#.utf8), response) + } + + do { + _ = try await CodexOAuthUsageFetcher.fetchRateLimitResetCredits( + accessToken: "test-token", + accountId: nil, + env: ["CODEX_HOME": "/tmp/codexbar-negative-reset-credit-test"], + session: transport) + Issue.record("Expected invalid response") + } catch CodexOAuthFetchError.invalidResponse { + // Expected. + } catch { + Issue.record("Unexpected error: \(error)") + } + } + + @Test + func `decodes credits and skips stale available expiry`() throws { + let json = """ + { + "credits": [ + { + "id": "RateLimitResetCredit_expired_available", + "reset_type": "codex_rate_limits", + "status": "available", + "granted_at": "2026-05-18T00:39:53Z", + "expires_at": "2026-06-17T00:39:53Z" + }, + { + "id": "RateLimitResetCredit_later", + "reset_type": "codex_rate_limits", + "status": "available", + "granted_at": "2026-06-18T00:39:53.731630Z", + "expires_at": "2026-07-18T00:39:53.731630Z", + "redeem_started_at": null, + "redeemed_at": null, + "profile_image_url": "https://example.com/codex.png", + "profile_user_id": "Codex Team", + "title": "One free rate limit reset", + "description": "Thanks for using Codex!" + }, + { + "id": "RateLimitResetCredit_earlier", + "reset_type": "codex_rate_limits", + "status": "available", + "granted_at": "2026-06-12T04:03:43.263391Z", + "expires_at": "2026-07-12T04:03:43.263391Z", + "redeem_started_at": null, + "redeemed_at": null, + "title": "One free rate limit reset", + "description": "Thanks for using Codex!" + }, + { + "id": "RateLimitResetCredit_future_status", + "reset_type": "codex_rate_limits", + "status": "future_status", + "granted_at": "2026-06-12T04:03:43Z", + "expires_at": "2026-07-10T04:03:43Z", + "redeem_started_at": null, + "redeemed_at": null, + "title": "One free rate limit reset", + "description": "Thanks for using Codex!" + } + ], + "available_count": 2 + } + """ + + let now = try #require(ISO8601DateFormatter().date(from: "2026-07-01T00:00:00Z")) + let snapshot = try CodexOAuthUsageFetcher._decodeRateLimitResetCreditsForTesting( + Data(json.utf8), + now: now) + + #expect(snapshot.availableCount == 2) + #expect(snapshot.credits.count == 4) + #expect(snapshot.credits[0].resetType == "codex_rate_limits") + #expect(snapshot.credits[3].status == .unknown("future_status")) + #expect(snapshot.nextExpiringAvailableCredit?.id == CodexRateLimitResetCredit.stableID( + forProviderID: "RateLimitResetCredit_earlier")) + #expect(snapshot.credits.allSatisfy { !$0.id.contains("RateLimitResetCredit_") }) + + let usage = UsageSnapshot( + primary: nil, + secondary: nil, + codexResetCredits: snapshot, + updatedAt: now) + let encoded = try JSONEncoder().encode(usage) + let encodedText = try #require(String(data: encoded, encoding: .utf8)) + #expect(!encodedText.contains("RateLimitResetCredit_earlier")) + #expect(!String(reflecting: usage).contains("RateLimitResetCredit_earlier")) + + let roundTripped = try JSONDecoder().decode(UsageSnapshot.self, from: encoded) + #expect(roundTripped.codexResetCredits?.credits.map(\.id) == snapshot.credits.map(\.id)) + } + + @Test + func `available inventory keeps no-expiry credits and sorts deterministically`() { + let now = Date(timeIntervalSince1970: 1_788_134_400) + let tiedExpiry = now.addingTimeInterval(3600) + let snapshot = CodexRateLimitResetCreditsSnapshot( + credits: [ + Self.credit(id: "nil-b", status: .available, expiresAt: nil), + Self.credit(id: "expired", status: .available, expiresAt: now), + Self.credit(id: "finite-b", status: .available, expiresAt: tiedExpiry), + Self.credit(id: "redeemed", status: .redeemed, expiresAt: now.addingTimeInterval(7200)), + Self.credit(id: "nil-a", status: .available, expiresAt: nil), + Self.credit(id: "finite-a", status: .available, expiresAt: tiedExpiry), + ], + availableCount: 99, + updatedAt: now) + + let inventory = snapshot.availableInventory(at: now) + + #expect(inventory.count == 4) + let expectedFiniteIDs = ["finite-a", "finite-b"] + .map(CodexRateLimitResetCredit.stableID(forProviderID:)) + .sorted() + let expectedNoExpiryIDs = ["nil-a", "nil-b"] + .map(CodexRateLimitResetCredit.stableID(forProviderID:)) + .sorted() + #expect(inventory.credits.map(\.id) == expectedFiniteIDs + expectedNoExpiryIDs) + #expect(inventory.nextExpiringCredit?.id == expectedFiniteIDs.first) + } + + @Test + func `provider IDs always hash even when shaped like persisted stable IDs`() throws { + let canonicalLookingRawID = "codex-reset-credit-v1-" + String(repeating: "a", count: 64) + let json = """ + { + "credits": [{ + "id": "\(canonicalLookingRawID)", + "reset_type": "codex_rate_limits", + "status": "available", + "granted_at": "2026-06-18T00:39:53Z", + "expires_at": null + }], + "available_count": 1 + } + """ + let now = Date(timeIntervalSince1970: 1_788_134_400) + + let decoded = try CodexOAuthUsageFetcher._decodeRateLimitResetCreditsForTesting( + Data(json.utf8), + now: now) + let decodedID = try #require(decoded.credits.first?.id) + let expectedID = CodexRateLimitResetCredit.stableID(forProviderID: canonicalLookingRawID) + + #expect(decodedID == expectedID) + #expect(decodedID != canonicalLookingRawID) + + let publicModel = Self.credit(id: canonicalLookingRawID, status: .available, expiresAt: nil) + #expect(publicModel.id == expectedID) + #expect(publicModel.id != canonicalLookingRawID) + + let encoded = try JSONEncoder().encode(publicModel) + let roundTripped = try JSONDecoder().decode(CodexRateLimitResetCredit.self, from: encoded) + #expect(roundTripped.id == expectedID) + + let ordinaryFirst = Self.credit(id: "ordinary-provider-id", status: .available, expiresAt: nil) + let ordinarySecond = Self.credit(id: "ordinary-provider-id", status: .available, expiresAt: nil) + #expect(ordinaryFirst.id == ordinarySecond.id) + #expect(ordinaryFirst.id != "ordinary-provider-id") + } + + @Test + func `reset credit GET preserves transport cancellation`() async throws { + let transport = ProviderHTTPTransportStub { request in + #expect(request.httpMethod == "GET") + throw URLError(.cancelled) + } + + await #expect(throws: CancellationError.self) { + _ = try await CodexOAuthUsageFetcher.fetchRateLimitResetCredits( + accessToken: "test-token", + accountId: "account-123", + env: ["CODEX_HOME": "/tmp/codexbar-reset-credit-cancellation-test"], + session: transport) + } + } + + private static func credit( + id: String, + status: CodexRateLimitResetCreditStatus, + expiresAt: Date?) -> CodexRateLimitResetCredit + { + CodexRateLimitResetCredit( + id: id, + resetType: "codex_rate_limits", + status: status, + grantedAt: Date(timeIntervalSince1970: 1_788_000_000), + expiresAt: expiresAt, + redeemStartedAt: nil, + redeemedAt: nil, + title: nil, + description: nil) + } +} diff --git a/Tests/CodexBarTests/CodexResetBackfillSemanticsTests.swift b/Tests/CodexBarTests/CodexResetBackfillSemanticsTests.swift new file mode 100644 index 0000000000..892ba10da1 --- /dev/null +++ b/Tests/CodexBarTests/CodexResetBackfillSemanticsTests.swift @@ -0,0 +1,213 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +struct CodexResetBackfillSemanticsTests { + @Test + func `merged reset cache preserves semantic lanes across swapped snapshots`() throws { + let now = Date(timeIntervalSince1970: 1_800_000_000) + let sessionReset = now.addingTimeInterval(4 * 60 * 60) + let weeklyReset = now.addingTimeInterval(4 * 24 * 60 * 60) + let canonicalSessionCache = UsageSnapshot( + primary: RateWindow( + usedPercent: 17, + windowMinutes: 300, + resetsAt: sessionReset, + resetDescription: nil), + secondary: nil, + updatedAt: now.addingTimeInterval(-20)) + let swappedWeeklyCache = UsageSnapshot( + primary: RateWindow( + usedPercent: 63, + windowMinutes: 10080, + resetsAt: weeklyReset, + resetDescription: nil), + secondary: nil, + updatedAt: now.addingTimeInterval(-10)) + + let merged = try #require(UsageStore.codexMergedResetBackfillSnapshot( + [canonicalSessionCache, swappedWeeklyCache], + now: now)) + + #expect(merged.primary?.usedPercent == 17) + #expect(merged.primary?.windowMinutes == 300) + #expect(merged.primary?.resetsAt == sessionReset) + #expect(merged.secondary?.usedPercent == 63) + #expect(merged.secondary?.windowMinutes == 10080) + #expect(merged.secondary?.resetsAt == weeklyReset) + } +} + +extension CodexAccountScopedRefreshTests { + @Test + func `stacked email only rows use neither prior baselines nor reset backfills`() async throws { + let fixture = try self.makeEmailOnlyStackedFixture( + suite: "CodexResetBackfillSemanticsTests-email-only-stacked") + defer { fixture.cleanup() } + + let now = Date() + let weeklyReset = now.addingTimeInterval(3 * 24 * 60 * 60) + let targetPrior = self.codexWeeklySnapshot( + email: fixture.target.email, + weeklyUsedPercent: 74, + weeklyReset: weeklyReset, + updatedAt: now.addingTimeInterval(-60)) + let siblingPrior = self.codexWeeklySnapshot( + email: fixture.sibling.email, + weeklyUsedPercent: 28, + weeklyReset: weeklyReset, + updatedAt: now.addingTimeInterval(-60)) + let priorRows = [ + CodexAccountUsageSnapshot( + account: fixture.target, + snapshot: targetPrior, + error: nil, + sourceLabel: "cached-target"), + CodexAccountUsageSnapshot( + account: fixture.sibling, + snapshot: siblingPrior, + error: nil, + sourceLabel: "cached-sibling"), + ] + let snapshotStore = RecordingCodexAccountUsageSnapshotStore(initialSnapshots: priorRows) + let store = self.makeCodexWeeklyPublicationStore( + settings: fixture.settings, + suite: "CodexResetBackfillSemanticsTests-email-only-stacked", + snapshotStore: snapshotStore) + #expect(store.codexLimitResetOwnerKey( + forVisibleAccount: fixture.target, + visibleAccounts: [fixture.target, fixture.sibling]) == nil) + + let initialLow = self.codexWeeklySnapshot( + email: fixture.target.email, + weeklyUsedPercent: 0.2, + weeklyReset: weeklyReset, + updatedAt: now.addingTimeInterval(-40)) + let confirmedLow = self.codexWeeklySnapshot( + email: fixture.target.email, + weeklyUsedPercent: 0.4, + weeklyReset: weeklyReset, + updatedAt: now.addingTimeInterval(-30)) + let partial = self.codexWeeklySnapshot( + email: fixture.target.email, + weeklyUsedPercent: nil, + weeklyReset: nil, + updatedAt: now.addingTimeInterval(-20), + sessionUsedPercent: 31) + let targetLoader = SequencedCodexSnapshotLoader(steps: [ + .success(initialLow), + .success(confirmedLow), + .success(partial), + ]) + let siblingCurrent = self.codexWeeklySnapshot( + email: fixture.sibling.email, + weeklyUsedPercent: 29, + weeklyReset: weeklyReset, + updatedAt: now.addingTimeInterval(-10)) + let targetHomePath = fixture.targetHome.path + let siblingHomePath = fixture.siblingHome.path + self.installContextualCodexProvider(on: store) { context in + switch context.env["CODEX_HOME"] { + case targetHomePath: + try await targetLoader.load() + case siblingHomePath: + siblingCurrent + default: + throw TestRefreshError(message: "Unexpected CODEX_HOME routing") + } + } + + await store.refreshCodexVisibleAccountsForMenu() + + let confirmedTarget = try #require(store.codexAccountSnapshots.first { + $0.account.storedAccountID == fixture.target.storedAccountID + }?.snapshot) + #expect(confirmedTarget.updatedAt == confirmedLow.updatedAt) + #expect(confirmedTarget.secondary?.usedPercent == 0.4) + + await store.refreshCodexVisibleAccountsForMenu() + + let partialTarget = try #require(store.codexAccountSnapshots.first { + $0.account.storedAccountID == fixture.target.storedAccountID + }?.snapshot) + #expect(await targetLoader.callCount == 3) + #expect(partialTarget.updatedAt == partial.updatedAt) + #expect(partialTarget.primary?.usedPercent == 31) + #expect(partialTarget.secondary == nil) + } + + private func makeEmailOnlyStackedFixture(suite: String) throws -> EmailOnlyStackedFixture { + let settings = self.makeSettingsStore(suite: suite) + settings.refreshFrequency = .manual + settings.codexCookieSource = .off + settings.multiAccountMenuLayout = .stacked + + let targetID = try #require(UUID(uuidString: "AAAAAAAA-BBBB-CCCC-DDDD-424242424242")) + let siblingID = try #require(UUID(uuidString: "AAAAAAAA-BBBB-CCCC-DDDD-434343434343")) + let targetHome = FileManager.default.temporaryDirectory + .appendingPathComponent("codex-email-only-target-\(UUID().uuidString)", isDirectory: true) + let siblingHome = FileManager.default.temporaryDirectory + .appendingPathComponent("codex-email-only-sibling-\(UUID().uuidString)", isDirectory: true) + try Self.writeCodexAuthFile( + homeURL: targetHome, + email: "email-only-target@example.com", + plan: "Pro") + try Self.writeCodexAuthFile( + homeURL: siblingHome, + email: "email-only-sibling@example.com", + plan: "Pro") + let targetFingerprint = try #require(CodexAuthFingerprint.fingerprint(homePath: targetHome.path)) + let siblingFingerprint = try #require(CodexAuthFingerprint.fingerprint(homePath: siblingHome.path)) + let targetAccount = ManagedCodexAccount( + id: targetID, + email: "email-only-target@example.com", + authFingerprint: targetFingerprint, + managedHomePath: targetHome.path, + createdAt: 1, + updatedAt: 2, + lastAuthenticatedAt: 2) + let siblingAccount = ManagedCodexAccount( + id: siblingID, + email: "email-only-sibling@example.com", + authFingerprint: siblingFingerprint, + managedHomePath: siblingHome.path, + createdAt: 1, + updatedAt: 2, + lastAuthenticatedAt: 2) + let accountStoreURL = try self.makeManagedAccountStoreURL(accounts: [targetAccount, siblingAccount]) + settings._test_managedCodexAccountStoreURL = accountStoreURL + settings.codexActiveSource = .managedAccount(id: targetID) + + let projection = settings.codexVisibleAccountProjection + let target = try #require(projection.visibleAccounts.first { $0.storedAccountID == targetID }) + let sibling = try #require(projection.visibleAccounts.first { $0.storedAccountID == siblingID }) + #expect(target.workspaceAccountID == nil) + #expect(sibling.workspaceAccountID == nil) + + return EmailOnlyStackedFixture( + settings: settings, + target: target, + sibling: sibling, + targetHome: targetHome, + siblingHome: siblingHome, + accountStoreURL: accountStoreURL) + } +} + +private struct EmailOnlyStackedFixture { + let settings: SettingsStore + let target: CodexVisibleAccount + let sibling: CodexVisibleAccount + let targetHome: URL + let siblingHome: URL + let accountStoreURL: URL + + @MainActor + func cleanup() { + self.settings._test_managedCodexAccountStoreURL = nil + try? FileManager.default.removeItem(at: self.accountStoreURL) + try? FileManager.default.removeItem(at: self.targetHome) + try? FileManager.default.removeItem(at: self.siblingHome) + } +} diff --git a/Tests/CodexBarTests/CodexResetCreditExpiryNotifierTests.swift b/Tests/CodexBarTests/CodexResetCreditExpiryNotifierTests.swift new file mode 100644 index 0000000000..688a1de8db --- /dev/null +++ b/Tests/CodexBarTests/CodexResetCreditExpiryNotifierTests.swift @@ -0,0 +1,107 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +@MainActor +struct CodexResetCreditExpiryNotifierTests { + @Test + func `posts one bounded summary without persisting or logging raw credit IDs`() throws { + let suite = "CodexResetCreditExpiryNotifierTests-\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suite)) + defer { defaults.removePersistentDomain(forName: suite) } + let now = Date(timeIntervalSince1970: 1_781_726_400) + let rawID = "private-provider-credit-id" + var posts: [(prefix: String, title: String, body: String)] = [] + let notifier = CodexResetCreditExpiryNotifier(userDefaults: defaults) { prefix, title, body in + posts.append((prefix, title, body)) + } + let snapshot = CodexRateLimitResetCreditsSnapshot( + credits: [ + Self.credit(id: rawID, expiresAt: now.addingTimeInterval(86400)), + Self.credit(id: "no-expiry-private-id", expiresAt: nil), + ], + availableCount: 2, + updatedAt: now) + + notifier.postExpiringCreditsIfNeeded(snapshot: snapshot, resetStyle: .countdown, now: now) + notifier.postExpiringCreditsIfNeeded(snapshot: snapshot, resetStyle: .countdown, now: now) + + #expect(posts.count == 1) + #expect(posts[0].prefix == CodexResetCreditExpiryNotifier.notificationPrefix) + #expect(posts[0].title == "Limit Reset Credits") + #expect(posts[0].body == "1. Expires in 1d") + #expect(!posts[0].prefix.contains(rawID)) + #expect(!posts[0].body.contains(rawID)) + let fingerprints = try #require(defaults.stringArray( + forKey: CodexResetCreditExpiryNotifier.summaryFingerprintsKey)) + let fingerprint = try #require(fingerprints.first) + #expect(fingerprints.count == 1) + #expect(fingerprint.count == 64) + #expect(!fingerprint.contains(rawID)) + } + + @Test + func `switching account inventories does not repeat either notification`() throws { + let suite = "CodexResetCreditExpiryNotifierAccountTests-\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suite)) + defer { defaults.removePersistentDomain(forName: suite) } + let now = Date(timeIntervalSince1970: 1_781_726_400) + var postCount = 0 + let notifier = CodexResetCreditExpiryNotifier(userDefaults: defaults) { _, _, _ in + postCount += 1 + } + let firstAccount = CodexRateLimitResetCreditsSnapshot( + credits: [Self.credit(id: "first-account-credit", expiresAt: now.addingTimeInterval(86400))], + availableCount: 1, + updatedAt: now) + let secondAccount = CodexRateLimitResetCreditsSnapshot( + credits: [Self.credit(id: "second-account-credit", expiresAt: now.addingTimeInterval(172_800))], + availableCount: 1, + updatedAt: now) + + notifier.postExpiringCreditsIfNeeded(snapshot: firstAccount, resetStyle: .countdown, now: now) + notifier.postExpiringCreditsIfNeeded(snapshot: secondAccount, resetStyle: .countdown, now: now) + notifier.postExpiringCreditsIfNeeded(snapshot: firstAccount, resetStyle: .countdown, now: now) + notifier.postExpiringCreditsIfNeeded(snapshot: secondAccount, resetStyle: .countdown, now: now) + + #expect(postCount == 2) + #expect(defaults.stringArray(forKey: CodexResetCreditExpiryNotifier.summaryFingerprintsKey)?.count == 2) + } + + @Test + func `no-expiry inventory does not trigger an expiry notification`() throws { + let suite = "CodexResetCreditExpiryNotifierNoExpiryTests-\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suite)) + defer { defaults.removePersistentDomain(forName: suite) } + let now = Date(timeIntervalSince1970: 1_781_726_400) + var postCount = 0 + let notifier = CodexResetCreditExpiryNotifier(userDefaults: defaults) { _, _, _ in + postCount += 1 + } + + notifier.postExpiringCreditsIfNeeded( + snapshot: CodexRateLimitResetCreditsSnapshot( + credits: [Self.credit(id: "no-expiry", expiresAt: nil)], + availableCount: 1, + updatedAt: now), + resetStyle: .countdown, + now: now) + + #expect(postCount == 0) + #expect(defaults.stringArray(forKey: CodexResetCreditExpiryNotifier.summaryFingerprintsKey) == nil) + } + + private static func credit(id: String, expiresAt: Date?) -> CodexRateLimitResetCredit { + CodexRateLimitResetCredit( + id: id, + resetType: "codex_rate_limits", + status: .available, + grantedAt: Date(timeIntervalSince1970: 1_781_700_000), + expiresAt: expiresAt, + redeemStartedAt: nil, + redeemedAt: nil, + title: nil, + description: nil) + } +} diff --git a/Tests/CodexBarTests/CodexResetCreditOutcomeTests.swift b/Tests/CodexBarTests/CodexResetCreditOutcomeTests.swift new file mode 100644 index 0000000000..6aae6f23e2 --- /dev/null +++ b/Tests/CodexBarTests/CodexResetCreditOutcomeTests.swift @@ -0,0 +1,271 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +struct CodexResetCreditOutcomeTests { + @Test + func `supplemental inventory skips stale credentials without issuing a request`() async throws { + let recorder = ResetCreditRequestRecorder() + let result = try await UsageStore._fetchCodexResetCreditsForTesting( + credentials: Self.credentials(lastRefresh: .distantPast), + request: { accessToken, accountID, environment in + await recorder.record(accessToken: accessToken, accountID: accountID, environment: environment) + return Self.resetSnapshot(id: "unexpected", now: Date()) + }) + + #expect(result == nil) + #expect(await recorder.count() == 0) + } + + @Test + func `supplemental inventory uses fresh credentials for one read only request`() async throws { + let recorder = ResetCreditRequestRecorder() + let now = Date() + let expected = Self.resetSnapshot(id: "fresh", now: now) + let result = try await UsageStore._fetchCodexResetCreditsForTesting( + credentials: Self.credentials(lastRefresh: now), + env: ["CODEX_HOME": "/tmp/account-a"], + request: { accessToken, accountID, environment in + await recorder.record(accessToken: accessToken, accountID: accountID, environment: environment) + return expected + }) + + #expect(result == expected) + #expect(await recorder.count() == 1) + #expect(await recorder.lastAccessToken() == "access") + #expect(await recorder.lastAccountID() == "account-123") + #expect(await recorder.lastEnvironment()["CODEX_HOME"] == "/tmp/account-a") + } + + @Test + func `embedded OAuth inventory prevents a duplicate supplemental GET`() async throws { + let now = Date(timeIntervalSince1970: 1_781_726_400) + let embedded = Self.resetSnapshot(id: "embedded", now: now) + let recorder = ResetCreditFetchRecorder() + + let outcome = await UsageStore.attachingCodexResetCreditsIfNeeded( + to: Self.outcome(resetCredits: embedded, now: now), + env: ["CODEX_HOME": "/tmp/account-a"], + fetcher: { env in + await recorder.record(env) + return Self.resetSnapshot(id: "supplemental", now: now) + }) + + #expect(try Self.usage(from: outcome).codexResetCredits == embedded) + #expect(await recorder.environments().isEmpty) + } + + @Test + func `supplemental inventory uses each scoped account environment once`() async throws { + let now = Date(timeIntervalSince1970: 1_781_726_400) + let recorder = ResetCreditFetchRecorder() + let fetcher: UsageStore.CodexResetCreditsFetcher = { env in + await recorder.record(env) + let home = env["CODEX_HOME"] ?? "missing" + return Self.resetSnapshot(id: home, now: now) + } + + let first = await UsageStore.attachingCodexResetCreditsIfNeeded( + to: Self.outcome(resetCredits: nil, now: now), + env: ["CODEX_HOME": "/tmp/account-a"], + fetcher: fetcher) + let second = await UsageStore.attachingCodexResetCreditsIfNeeded( + to: Self.outcome(resetCredits: nil, now: now), + env: ["CODEX_HOME": "/tmp/account-b"], + fetcher: fetcher) + + #expect(try Self.usage(from: first).codexResetCredits?.credits.first?.id == + Self.resetSnapshot(id: "/tmp/account-a", now: now).credits.first?.id) + #expect(try Self.usage(from: second).codexResetCredits?.credits.first?.id == + Self.resetSnapshot(id: "/tmp/account-b", now: now).credits.first?.id) + #expect(await recorder.environments().compactMap { $0["CODEX_HOME"] } == [ + "/tmp/account-a", + "/tmp/account-b", + ]) + } + + @Test + func `failed supplemental GET clears inventory on a successful usage refresh`() async throws { + let now = Date(timeIntervalSince1970: 1_781_726_400) + let recorder = ResetCreditFetchRecorder() + let outcome = await UsageStore.attachingCodexResetCreditsIfNeeded( + to: Self.outcome(resetCredits: nil, now: now), + env: ["CODEX_HOME": "/tmp/account-a"], + fetcher: { env in + await recorder.record(env) + throw ResetCreditFetchTestError.failed + }) + + #expect(try Self.usage(from: outcome).codexResetCredits == nil) + #expect(await recorder.environments().count == 1) + } + + @Test + func `single failed GET restores failure for reset-credit-only O auth usage`() async { + let now = Date(timeIntervalSince1970: 1_781_726_400) + let recorder = ResetCreditFetchRecorder() + let outcome = await UsageStore.attachingCodexResetCreditsIfNeeded( + to: Self.outcome(resetCredits: nil, now: now, primary: nil, strategyID: "codex.oauth"), + env: ["CODEX_HOME": "/tmp/account-a"], + fetcher: { env in + await recorder.record(env) + throw ResetCreditFetchTestError.failed + }) + + guard case let .failure(error) = outcome.result else { + Issue.record("Expected no-data failure") + return + } + #expect(error is UsageError) + #expect(await recorder.environments().count == 1) + } + + @Test + func `single GET rescues reset-credit-only O auth usage`() async throws { + let now = Date(timeIntervalSince1970: 1_781_726_400) + let recorder = ResetCreditFetchRecorder() + let resetCredits = Self.resetSnapshot(id: "rescued", now: now) + let outcome = await UsageStore.attachingCodexResetCreditsIfNeeded( + to: Self.outcome(resetCredits: nil, now: now, primary: nil, strategyID: "codex.oauth"), + env: ["CODEX_HOME": "/tmp/account-a"], + fetcher: { env in + await recorder.record(env) + return resetCredits + }) + + #expect(try Self.usage(from: outcome).codexResetCredits == resetCredits) + #expect(await recorder.environments().count == 1) + } + + @Test + func `supplemental GET cancellation remains a cancelled provider outcome`() async { + let now = Date(timeIntervalSince1970: 1_781_726_400) + let outcome = await UsageStore.attachingCodexResetCreditsIfNeeded( + to: Self.outcome(resetCredits: nil, now: now), + env: ["CODEX_HOME": "/tmp/account-a"], + fetcher: { _ in throw CancellationError() }) + + guard case let .failure(error) = outcome.result else { + Issue.record("Expected cancellation failure") + return + } + #expect(error is CancellationError) + } + + @Test + func `display preference does not strip embedded inventory or issue a duplicate GET`() async throws { + let now = Date(timeIntervalSince1970: 1_781_726_400) + let recorder = ResetCreditFetchRecorder() + let outcome = await UsageStore.attachingCodexResetCreditsIfNeeded( + to: Self.outcome(resetCredits: Self.resetSnapshot(id: "embedded", now: now), now: now), + env: ["CODEX_HOME": "/tmp/account-a"], + fetcher: { env in + await recorder.record(env) + return Self.resetSnapshot(id: "supplemental", now: now) + }) + + #expect(try Self.usage(from: outcome).codexResetCredits == Self.resetSnapshot(id: "embedded", now: now)) + #expect(await recorder.environments().isEmpty) + } + + private static func outcome( + resetCredits: CodexRateLimitResetCreditsSnapshot?, + now: Date, + primary: RateWindow? = nil, + strategyID: String = "test") -> ProviderFetchOutcome + { + let resolvedPrimary = strategyID == "codex.oauth" ? primary : primary ?? RateWindow( + usedPercent: 25, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(3600), + resetDescription: nil) + return ProviderFetchOutcome( + result: .success(ProviderFetchResult( + usage: UsageSnapshot( + primary: resolvedPrimary, + secondary: nil, + codexResetCredits: resetCredits, + updatedAt: now), + credits: nil, + dashboard: nil, + sourceLabel: "test", + strategyID: strategyID, + strategyKind: .cli)), + attempts: []) + } + + private static func resetSnapshot(id: String, now: Date) -> CodexRateLimitResetCreditsSnapshot { + CodexRateLimitResetCreditsSnapshot( + credits: [CodexRateLimitResetCredit( + id: id, + resetType: "codex_rate_limits", + status: .available, + grantedAt: now, + expiresAt: now.addingTimeInterval(86400), + redeemStartedAt: nil, + redeemedAt: nil, + title: nil, + description: nil)], + availableCount: 1, + updatedAt: now) + } + + private static func credentials(lastRefresh: Date?) -> CodexOAuthCredentials { + CodexOAuthCredentials( + accessToken: "access", + refreshToken: "refresh", + idToken: nil, + accountId: "account-123", + lastRefresh: lastRefresh) + } + + private static func usage(from outcome: ProviderFetchOutcome) throws -> UsageSnapshot { + switch outcome.result { + case let .success(result): + result.usage + case let .failure(error): + throw error + } + } +} + +private actor ResetCreditRequestRecorder { + private var requests: [(accessToken: String, accountID: String?, environment: [String: String])] = [] + + func record(accessToken: String, accountID: String?, environment: [String: String]) { + self.requests.append((accessToken, accountID, environment)) + } + + func count() -> Int { + self.requests.count + } + + func lastAccessToken() -> String? { + self.requests.last?.accessToken + } + + func lastAccountID() -> String? { + self.requests.last?.accountID + } + + func lastEnvironment() -> [String: String] { + self.requests.last?.environment ?? [:] + } +} + +private actor ResetCreditFetchRecorder { + private var capturedEnvironments: [[String: String]] = [] + + func record(_ env: [String: String]) { + self.capturedEnvironments.append(env) + } + + func environments() -> [[String: String]] { + self.capturedEnvironments + } +} + +private enum ResetCreditFetchTestError: Error { + case failed +} diff --git a/Tests/CodexBarTests/CodexResetCreditsMenuCardTests.swift b/Tests/CodexBarTests/CodexResetCreditsMenuCardTests.swift new file mode 100644 index 0000000000..c288149d44 --- /dev/null +++ b/Tests/CodexBarTests/CodexResetCreditsMenuCardTests.swift @@ -0,0 +1,178 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +struct CodexResetCreditsMenuCardTests { + @Test + func `presentation shows only available inventory in stable expiry order`() throws { + let now = Date(timeIntervalSince1970: 1_781_726_400) + let snapshot = Self.snapshot( + now: now, + credits: [ + Self.credit(id: "no-expiry", status: .available, now: now, expiresIn: nil), + Self.credit(id: "late", status: .available, now: now, expiresIn: 172_800), + Self.credit(id: "redeemed", status: .redeemed, now: now, expiresIn: 43200), + Self.credit(id: "expired", status: .available, now: now, expiresIn: -1), + Self.credit(id: "early", status: .available, now: now, expiresIn: 86400), + ], + availableCount: 99) + + let model = try Self.model(snapshot: snapshot, now: now) + let presentation = try #require(model.codexResetCredits) + + #expect(presentation.text == "3 available") + #expect(presentation.items.map(\.expiryText) == ["Expires in 1d", "Expires in 2d", "No expiry"]) + #expect(presentation.expirySummaryText == "1d · 2d · No expiry") + #expect(presentation.helpText == "1. Expires in 1d\n2. Expires in 2d\n3. No expiry") + #expect(presentation.accessibilityLabel.contains(presentation.helpText)) + } + + @Test + func `no-expiry reset remains visible without a next-expiry date`() throws { + let now = Date(timeIntervalSince1970: 1_781_726_400) + let model = try Self.model( + snapshot: Self.snapshot( + now: now, + credits: [Self.credit(id: "no-expiry", status: .available, now: now, expiresIn: nil)]), + now: now) + let presentation = try #require(model.codexResetCredits) + + #expect(presentation.text == "1 available") + #expect(presentation.items.map(\.expiryText) == ["No expiry"]) + #expect(presentation.expirySummaryText == "No expiry") + #expect(model.hasUsageContent) + } + + @Test + func `inventory respects absolute reset-time style`() throws { + let now = Date(timeIntervalSince1970: 1_781_726_400) + let expiresAt = now.addingTimeInterval(86400) + let model = try Self.model( + snapshot: Self.snapshot( + now: now, + credits: [Self.credit(id: "finite", status: .available, now: now, expiresIn: 86400)]), + resetStyle: .absolute, + now: now) + let presentation = try #require(model.codexResetCredits) + let formatted = UsageFormatter.resetDescription(from: expiresAt, now: now) + + #expect(presentation.items.map(\.expiryText) == ["Expires \(formatted)"]) + #expect(presentation.expirySummaryText == formatted) + } + + @Test + func `optional usage preference does not hide reset inventory`() throws { + let now = Date(timeIntervalSince1970: 1_781_726_400) + let model = try Self.model( + snapshot: Self.snapshot( + now: now, + credits: [Self.credit(id: "finite", status: .available, now: now, expiresIn: 86400)]), + showOptionalUsage: false, + now: now) + + #expect(model.codexResetCredits?.text == "1 available") + #expect(model.codexResetCredits?.expirySummaryText == "1d") + } + + @Test + func `compact expiry summary caps visible dates`() throws { + let now = Date(timeIntervalSince1970: 1_781_726_400) + let credits = (1...6).map { day in + Self.credit(id: "day-\(day)", status: .available, now: now, expiresIn: Double(day * 86400)) + } + let model = try Self.model(snapshot: Self.snapshot(now: now, credits: credits), now: now) + + let presentation = try #require(model.codexResetCredits) + #expect(presentation.expirySummaryText == "1d · 2d · 3d · 4d · +2") + #expect(presentation.helpText.split(separator: "\n").count == 6) + } + + @Test + func `hosted usage model keeps reset inventory compatible with live refresh`() throws { + let now = Date(timeIntervalSince1970: 1_781_726_400) + let model = try Self.model( + snapshot: Self.snapshot( + now: now, + credits: [Self.credit(id: "finite", status: .available, now: now, expiresIn: 86400)]), + now: now) + + #expect(model.codexResetCredits != nil) + #expect(model.hasCompatibleTrackedLayout(with: model)) + } + + @Test + func `empty filtered inventory does not create hosted reset rows`() throws { + let now = Date(timeIntervalSince1970: 1_781_726_400) + let model = try Self.model( + snapshot: Self.snapshot( + now: now, + credits: [Self.credit(id: "expired", status: .available, now: now, expiresIn: -1)], + availableCount: 1), + now: now) + + #expect(model.codexResetCredits == nil) + #expect(model.hasCompatibleTrackedLayout(with: model)) + } + + private static func model( + snapshot: UsageSnapshot, + showOptionalUsage: Bool = true, + resetStyle: ResetTimeDisplayStyle = .countdown, + now: Date) throws -> UsageMenuCardView.Model + { + let metadata = try #require(ProviderDefaults.metadata[.codex]) + return UsageMenuCardView.Model.make(UsageMenuCardView.Model.Input( + provider: .codex, + metadata: metadata, + snapshot: snapshot, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: false, + resetTimeDisplayStyle: resetStyle, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: showOptionalUsage, + hidePersonalInfo: false, + now: now)) + } + + private static func snapshot( + now: Date, + credits: [CodexRateLimitResetCredit], + availableCount: Int? = nil) -> UsageSnapshot + { + UsageSnapshot( + primary: nil, + secondary: nil, + codexResetCredits: CodexRateLimitResetCreditsSnapshot( + credits: credits, + availableCount: availableCount ?? credits.count, + updatedAt: now), + updatedAt: now) + } + + private static func credit( + id: String, + status: CodexRateLimitResetCreditStatus, + now: Date, + expiresIn: TimeInterval?) -> CodexRateLimitResetCredit + { + CodexRateLimitResetCredit( + id: id, + resetType: "codex_rate_limits", + status: status, + grantedAt: now.addingTimeInterval(-3600), + expiresAt: expiresIn.map(now.addingTimeInterval), + redeemStartedAt: nil, + redeemedAt: nil, + title: nil, + description: nil) + } +} diff --git a/Tests/CodexBarTests/CodexSessionQuotaFalseRestoreTests.swift b/Tests/CodexBarTests/CodexSessionQuotaFalseRestoreTests.swift new file mode 100644 index 0000000000..174049e49b --- /dev/null +++ b/Tests/CodexBarTests/CodexSessionQuotaFalseRestoreTests.swift @@ -0,0 +1,1079 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +@MainActor +@Suite("Codex session restore notifications") +struct CodexSessionQuotaFalseRestoreTests { + private let start = Date(timeIntervalSince1970: 1_700_000_000) + + @Test + func `same future boundary suppresses restore and duplicate depletion`() throws { + let owner = try self.owner("same-boundary") + let boundary = self.start.addingTimeInterval(5 * 3600) + let notifier = SessionQuotaNotifierSpy() + let store = Self.makeStore(notifier: notifier) + + self.observe(store, used: 20, boundary: boundary, at: self.start, owner: owner) + self.observe(store, used: 100, boundary: boundary, at: self.start.addingTimeInterval(60), owner: owner) + self.observe(store, used: 0, boundary: boundary, at: self.start.addingTimeInterval(120), owner: owner) + self.observe(store, used: 100, boundary: boundary, at: self.start.addingTimeInterval(180), owner: owner) + + #expect(notifier.transitions == [.depleted]) + #expect(store.sessionQuotaTransitionStates[.codex]?.remaining == 0) + } + + @Test + func `advanced boundary before trusted expiry stays suppressed`() throws { + let owner = try self.owner("early-advanced") + let boundary = self.start.addingTimeInterval(5 * 3600) + let advanced = boundary.addingTimeInterval(5 * 3600) + let notifier = SessionQuotaNotifierSpy() + let store = Self.makeStore(notifier: notifier) + + self.observe(store, used: 20, boundary: boundary, at: self.start, owner: owner) + self.observe(store, used: 100, boundary: boundary, at: self.start.addingTimeInterval(60), owner: owner) + self.observe(store, used: 0, boundary: advanced, at: self.start.addingTimeInterval(120), owner: owner) + self.observe(store, used: 10, boundary: advanced, at: self.start.addingTimeInterval(180), owner: owner) + + #expect(notifier.transitions == [.depleted]) + #expect(store.sessionQuotaTransitionStates[.codex]?.trustedResetBoundary == boundary) + } + + @Test + func `depleted boundary stays frozen until its reset can be proven`() throws { + let owner = try self.owner("depleted-advanced") + let boundary = self.start.addingTimeInterval(5 * 3600) + let advanced = boundary.addingTimeInterval(5 * 3600) + let notifier = SessionQuotaNotifierSpy() + let store = Self.makeStore(notifier: notifier) + + self.observe(store, used: 20, boundary: boundary, at: self.start, owner: owner) + self.observe(store, used: 100, boundary: boundary, at: self.start.addingTimeInterval(60), owner: owner) + self.observe(store, used: 100, boundary: advanced, at: self.start.addingTimeInterval(120), owner: owner) + #expect(store.sessionQuotaTransitionStates[.codex]?.trustedResetBoundary == boundary) + self.observe(store, used: 100, boundary: advanced, at: boundary.addingTimeInterval(60), owner: owner) + #expect(store.sessionQuotaTransitionStates[.codex]?.trustedResetBoundary == boundary) + self.observe(store, used: 0, boundary: advanced, at: boundary.addingTimeInterval(120), owner: owner) + + #expect(notifier.transitions == [.depleted, .restored]) + #expect(store.sessionQuotaTransitionStates[.codex]?.trustedResetBoundary == advanced) + } + + @Test + func `depleted observation recovers a missing trusted boundary`() throws { + let owner = try self.owner("depleted-recovered-boundary") + let boundary = self.start.addingTimeInterval(5 * 3600) + let notifier = SessionQuotaNotifierSpy() + let store = Self.makeStore(notifier: notifier) + + self.observe(store, used: 20, boundary: nil, at: self.start, owner: owner) + self.observe(store, used: 100, boundary: nil, at: self.start.addingTimeInterval(60), owner: owner) + self.observe(store, used: 100, boundary: boundary, at: self.start.addingTimeInterval(120), owner: owner) + #expect(store.sessionQuotaTransitionStates[.codex]?.trustedResetBoundary == boundary) + + self.observe(store, used: 20, boundary: boundary, at: self.start.addingTimeInterval(180), owner: owner) + self.observe(store, used: 10, boundary: boundary, at: self.start.addingTimeInterval(240), owner: owner) + + #expect(notifier.transitions == [.depleted]) + #expect(store.sessionQuotaTransitionStates[.codex]?.pendingCodexRestoreObservationAt == nil) + } + + @Test + func `expired baseline boundary cannot produce a single sample restore`() throws { + let owner = try self.owner("expired-baseline") + let expired = self.start.addingTimeInterval(-60) + let future = self.start.addingTimeInterval(5 * 3600) + let notifier = SessionQuotaNotifierSpy() + let store = Self.makeStore(notifier: notifier) + + self.observe(store, used: 100, boundary: expired, at: self.start, owner: owner) + #expect(store.sessionQuotaTransitionStates[.codex]?.trustedResetBoundary == nil) + + let firstPositive = self.start.addingTimeInterval(60) + self.observe(store, used: 20, boundary: future, at: firstPositive, owner: owner) + + #expect(notifier.transitions == [.depleted]) + #expect(store.sessionQuotaTransitionStates[.codex]?.pendingCodexRestoreObservationAt == firstPositive) + + self.observe(store, used: 10, boundary: future, at: self.start.addingTimeInterval(120), owner: owner) + + #expect(notifier.transitions == [.depleted, .restored]) + #expect(store.sessionQuotaTransitionStates[.codex]?.trustedResetBoundary == future) + } + + @Test + func `boundary expired before evaluation is not trusted`() throws { + let owner = try self.owner("expired-before-evaluation") + let observedAt = self.start.addingTimeInterval(60) + let boundary = self.start.addingTimeInterval(120) + let evaluatedAt = self.start.addingTimeInterval(180) + let store = Self.makeStore(notifier: SessionQuotaNotifierSpy()) + + self.observe( + store, + used: 100, + boundary: boundary, + at: observedAt, + evaluatedAt: evaluatedAt, + owner: owner) + + #expect(store.sessionQuotaTransitionStates[.codex]?.trustedResetBoundary == nil) + } + + @Test + func `depletion cannot advance a still future trusted boundary`() throws { + let owner = try self.owner("depletion-advanced") + let boundary = self.start.addingTimeInterval(5 * 3600) + let advanced = boundary.addingTimeInterval(5 * 3600) + let notifier = SessionQuotaNotifierSpy() + let store = Self.makeStore(notifier: notifier) + + self.observe(store, used: 20, boundary: boundary, at: self.start, owner: owner) + self.observe(store, used: 100, boundary: advanced, at: self.start.addingTimeInterval(60), owner: owner) + #expect(store.sessionQuotaTransitionStates[.codex]?.trustedResetBoundary == boundary) + + self.observe(store, used: 20, boundary: advanced, at: boundary.addingTimeInterval(60), owner: owner) + + #expect(notifier.transitions == [.depleted, .restored]) + #expect(store.sessionQuotaTransitionStates[.codex]?.trustedResetBoundary == advanced) + } + + @Test + func `pre boundary observation cannot advance metadata when processed later`() throws { + let owner = try self.owner("delayed-observation") + let boundary = self.start.addingTimeInterval(5 * 3600) + let advanced = boundary.addingTimeInterval(5 * 3600) + let store = Self.makeStore(notifier: SessionQuotaNotifierSpy()) + + self.observe(store, used: 20, boundary: boundary, at: self.start, owner: owner) + self.observe( + store, + used: 30, + boundary: advanced, + at: self.start.addingTimeInterval(60), + evaluatedAt: boundary.addingTimeInterval(60), + owner: owner) + + #expect(store.sessionQuotaTransitionStates[.codex]?.trustedResetBoundary == boundary) + } + + @Test(arguments: [false, true]) + func `ambiguous post expiry restore requires two fresh observations`(boundaryPresent: Bool) throws { + let owner = try self.owner(boundaryPresent ? "expired-equivalent" : "expired-missing") + let boundary = self.start.addingTimeInterval(5 * 3600) + let notifier = SessionQuotaNotifierSpy() + let store = Self.makeStore(notifier: notifier) + + self.observe(store, used: 20, boundary: boundary, at: self.start, owner: owner) + self.observe(store, used: 100, boundary: boundary, at: self.start.addingTimeInterval(60), owner: owner) + let first = boundary.addingTimeInterval(60) + self.observe(store, used: 20, boundary: boundaryPresent ? boundary : nil, at: first, owner: owner) + #expect(notifier.transitions == [.depleted]) + #expect(store.sessionQuotaTransitionStates[.codex]?.pendingCodexRestoreObservationAt == first) + + self.observe( + store, + used: 10, + boundary: boundaryPresent ? boundary : nil, + at: boundary.addingTimeInterval(120), + owner: owner) + self.observe( + store, + used: 5, + boundary: boundaryPresent ? boundary : nil, + at: boundary.addingTimeInterval(180), + owner: owner) + + #expect(notifier.transitions == [.depleted, .restored]) + } + + @Test + func `boundaryless restore requires two fresh observations`() throws { + let owner = try self.owner("boundaryless") + let notifier = SessionQuotaNotifierSpy() + let store = Self.makeStore(notifier: notifier) + + self.observe(store, used: 20, boundary: nil, at: self.start, owner: owner) + self.observe(store, used: 100, boundary: nil, at: self.start.addingTimeInterval(60), owner: owner) + self.observe(store, used: 20, boundary: nil, at: self.start.addingTimeInterval(120), owner: owner) + #expect(notifier.transitions == [.depleted]) + #expect(store.sessionQuotaTransitionStates[.codex]?.pendingCodexRestoreObservationAt != nil) + + self.observe(store, used: 10, boundary: nil, at: self.start.addingTimeInterval(180), owner: owner) + + #expect(notifier.transitions == [.depleted, .restored]) + } + + @Test + func `advanced post expiry boundary restores exactly once`() throws { + let owner = try self.owner("post-expiry-advanced") + let boundary = self.start.addingTimeInterval(5 * 3600) + let advanced = boundary.addingTimeInterval(5 * 3600) + let notifier = SessionQuotaNotifierSpy() + let store = Self.makeStore(notifier: notifier) + + self.observe(store, used: 20, boundary: boundary, at: self.start, owner: owner) + self.observe(store, used: 100, boundary: boundary, at: self.start.addingTimeInterval(60), owner: owner) + self.observe(store, used: 20, boundary: advanced, at: boundary.addingTimeInterval(60), owner: owner) + self.observe(store, used: 10, boundary: advanced, at: boundary.addingTimeInterval(120), owner: owner) + + #expect(notifier.transitions == [.depleted, .restored]) + #expect(store.sessionQuotaTransitionStates[.codex]?.trustedResetBoundary == advanced) + } + + @Test + func `advanced boundary expired at observation time requires confirmation`() throws { + let owner = try self.owner("advanced-expired-at-observation") + let boundary = self.start.addingTimeInterval(100) + let candidate = self.start.addingTimeInterval(250) + let previous = SessionQuotaTransitionState( + remaining: 0, + source: .primary, + observedAt: self.start, + codexOwnerKey: owner, + trustedResetBoundary: boundary, + pendingCodexRestoreObservationAt: nil) + + let evaluation = SessionQuotaTransitionReducer.evaluate( + previous: previous, + observation: SessionQuotaTransitionObservation( + provider: .codex, + remaining: 80, + source: .primary, + resetBoundary: candidate, + observedAt: self.start.addingTimeInterval(300), + evaluationTime: self.start.addingTimeInterval(200), + codexOwnerKey: owner), + notificationsEnabled: true) + + #expect(evaluation.outcome == .awaitingCodexRestoreConfirmation) + #expect(evaluation.state.trustedResetBoundary == boundary) + } + + @Test + func `regressed post expiry boundary requires two fresh observations`() throws { + let owner = try self.owner("regressed") + let boundary = self.start.addingTimeInterval(5 * 3600) + let regressed = self.start.addingTimeInterval(10 * 60) + let notifier = SessionQuotaNotifierSpy() + let store = Self.makeStore(notifier: notifier) + + self.observe(store, used: 20, boundary: boundary, at: self.start, owner: owner) + self.observe(store, used: 100, boundary: boundary, at: self.start.addingTimeInterval(60), owner: owner) + self.observe(store, used: 20, boundary: regressed, at: boundary.addingTimeInterval(60), owner: owner) + #expect(notifier.transitions == [.depleted]) + #expect(store.sessionQuotaTransitionStates[.codex]?.pendingCodexRestoreObservationAt != nil) + + self.observe(store, used: 10, boundary: regressed, at: boundary.addingTimeInterval(120), owner: owner) + self.observe(store, used: 5, boundary: regressed, at: boundary.addingTimeInterval(180), owner: owner) + + #expect(notifier.transitions == [.depleted, .restored]) + #expect(store.sessionQuotaTransitionStates[.codex]?.pendingCodexRestoreObservationAt == nil) + } + + @Test + func `older and equal observations cannot change the depleted baseline`() throws { + let owner = try self.owner("observation-order") + let boundary = self.start.addingTimeInterval(5 * 3600) + let notifier = SessionQuotaNotifierSpy() + let store = Self.makeStore(notifier: notifier) + let depletedAt = self.start.addingTimeInterval(120) + + self.observe(store, used: 20, boundary: boundary, at: self.start, owner: owner) + self.observe(store, used: 100, boundary: boundary, at: depletedAt, owner: owner) + self.observe(store, used: 0, boundary: boundary, at: self.start.addingTimeInterval(60), owner: owner) + self.observe(store, used: 0, boundary: boundary, at: depletedAt, owner: owner) + + #expect(notifier.transitions == [.depleted]) + #expect(store.sessionQuotaTransitionStates[.codex]?.remaining == 0) + #expect(store.sessionQuotaTransitionStates[.codex]?.observedAt == depletedAt) + } + + @Test + func `owner change establishes a new baseline without restoring`() throws { + let ownerA = try self.owner("owner-a") + let ownerB = try self.owner("owner-b") + let boundary = self.start.addingTimeInterval(5 * 3600) + let notifier = SessionQuotaNotifierSpy() + let store = Self.makeStore(notifier: notifier) + + self.observe(store, used: 20, boundary: boundary, at: self.start, owner: ownerA) + self.observe(store, used: 100, boundary: boundary, at: self.start.addingTimeInterval(60), owner: ownerA) + self.observe(store, used: 0, boundary: boundary, at: self.start.addingTimeInterval(120), owner: ownerB) + + #expect(notifier.transitions == [.depleted]) + #expect(store.sessionQuotaTransitionStates[.codex]?.codexOwnerKey == ownerB) + #expect(store.sessionQuotaTransitionStates[.codex]?.remaining == 100) + } + + @Test + func `source change establishes a new reducer baseline`() throws { + let owner = try self.owner("source-change") + let boundary = self.start.addingTimeInterval(5 * 3600) + let previous = SessionQuotaTransitionState( + remaining: 0, + source: .primary, + observedAt: self.start, + codexOwnerKey: owner, + trustedResetBoundary: boundary, + pendingCodexRestoreObservationAt: nil) + + let evaluation = SessionQuotaTransitionReducer.evaluate( + previous: previous, + observation: SessionQuotaTransitionObservation( + provider: .codex, + remaining: 100, + source: .copilotSecondaryFallback, + resetBoundary: boundary, + observedAt: self.start.addingTimeInterval(60), + evaluationTime: self.start.addingTimeInterval(60), + codexOwnerKey: owner), + notificationsEnabled: true) + + #expect(evaluation.outcome == .baselineChanged) + #expect(evaluation.state.remaining == 100) + #expect(evaluation.state.source == .copilotSecondaryFallback) + } + + @Test + func `missing owner fails closed and clears prior state`() throws { + let owner = try self.owner("missing-owner") + let boundary = self.start.addingTimeInterval(5 * 3600) + let notifier = SessionQuotaNotifierSpy() + let store = Self.makeStore(notifier: notifier) + + self.observe(store, used: 20, boundary: boundary, at: self.start, owner: owner) + store.handleSessionQuotaTransition( + provider: .codex, + snapshot: self.snapshot(used: 100, resetBoundary: boundary, updatedAt: self.start.addingTimeInterval(60)), + codexOwnerKey: nil, + now: self.start.addingTimeInterval(60)) + + #expect(store.sessionQuotaTransitionStates[.codex] == nil) + #expect(store.codexSessionQuotaBaselineRequired) + #expect(notifier.transitions.isEmpty) + + self.observe( + store, + used: 100, + boundary: boundary, + at: self.start.addingTimeInterval(120), + owner: owner) + + #expect(notifier.transitions.isEmpty) + #expect(store.sessionQuotaTransitionStates[.codex]?.remaining == 0) + #expect(!store.codexSessionQuotaBaselineRequired) + } +} + +extension CodexSessionQuotaFalseRestoreTests { + @Test + func `missing owner keeps stale observations behind the fresh baseline barrier`() throws { + let owner = try self.owner("missing-owner-watermark") + let notifier = SessionQuotaNotifierSpy() + let store = Self.makeStore(notifier: notifier) + let invalidatedAt = self.start.addingTimeInterval(120) + + self.observe(store, used: 20, boundary: nil, at: self.start, owner: owner) + store.handleSessionQuotaTransition( + provider: .codex, + snapshot: self.snapshot(used: 100, resetBoundary: nil, updatedAt: invalidatedAt), + codexOwnerKey: nil, + now: invalidatedAt) + + self.observe(store, used: 100, boundary: nil, at: self.start.addingTimeInterval(60), owner: owner) + self.observe(store, used: 20, boundary: nil, at: self.start.addingTimeInterval(90), owner: owner) + self.observe(store, used: 10, boundary: nil, at: self.start.addingTimeInterval(100), owner: owner) + + #expect(notifier.transitions.isEmpty) + #expect(store.sessionQuotaTransitionStates[.codex] == nil) + #expect(store.codexSessionQuotaBaselineRequirement?.observedAtWatermark == invalidatedAt) + + self.observe(store, used: 100, boundary: nil, at: self.start.addingTimeInterval(121), owner: owner) + + #expect(notifier.transitions.isEmpty) + #expect(store.sessionQuotaTransitionStates[.codex]?.remaining == 0) + #expect(!store.codexSessionQuotaBaselineRequired) + } + + @Test + func `windowless Codex result advances a matching depleted baseline watermark`() throws { + let owner = try self.owner("windowless-partial") + let boundary = self.start.addingTimeInterval(5 * 3600) + let notifier = SessionQuotaNotifierSpy() + let store = Self.makeStore(notifier: notifier) + + self.observe(store, used: 20, boundary: boundary, at: self.start, owner: owner) + self.observe(store, used: 100, boundary: boundary, at: self.start.addingTimeInterval(60), owner: owner) + store.handleSessionQuotaTransition( + provider: .codex, + snapshot: UsageSnapshot( + primary: nil, + secondary: nil, + updatedAt: self.start.addingTimeInterval(120), + identity: ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: "session-fixture@example.test", + accountOrganization: nil, + loginMethod: "test")), + codexOwnerKey: owner, + now: self.start.addingTimeInterval(120)) + + self.observe(store, used: 20, boundary: boundary, at: self.start.addingTimeInterval(90), owner: owner) + self.observe(store, used: 10, boundary: boundary, at: self.start.addingTimeInterval(100), owner: owner) + + #expect(store.sessionQuotaTransitionStates[.codex]?.remaining == 0) + #expect(store.sessionQuotaTransitionStates[.codex]?.observedAt == self.start.addingTimeInterval(120)) + #expect(store.sessionQuotaTransitionStates[.codex]?.trustedResetBoundary == boundary) + #expect(notifier.transitions == [.depleted]) + + self.observe(store, used: 100, boundary: boundary, at: self.start.addingTimeInterval(180), owner: owner) + + #expect(notifier.transitions == [.depleted]) + } + + @Test + func `windowless Codex result blocks stale boundaryless restore confirmation`() throws { + let owner = try self.owner("windowless-boundaryless") + let notifier = SessionQuotaNotifierSpy() + let store = Self.makeStore(notifier: notifier) + + self.observe(store, used: 20, boundary: nil, at: self.start, owner: owner) + self.observe(store, used: 100, boundary: nil, at: self.start.addingTimeInterval(60), owner: owner) + store.handleSessionQuotaTransition( + provider: .codex, + snapshot: UsageSnapshot( + primary: nil, + secondary: nil, + updatedAt: self.start.addingTimeInterval(120), + identity: ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: "session-fixture@example.test", + accountOrganization: nil, + loginMethod: "test")), + codexOwnerKey: owner, + now: self.start.addingTimeInterval(120)) + + self.observe(store, used: 20, boundary: nil, at: self.start.addingTimeInterval(90), owner: owner) + self.observe(store, used: 10, boundary: nil, at: self.start.addingTimeInterval(100), owner: owner) + + #expect(store.sessionQuotaTransitionStates[.codex]?.remaining == 0) + #expect(store.sessionQuotaTransitionStates[.codex]?.observedAt == self.start.addingTimeInterval(120)) + #expect(store.sessionQuotaTransitionStates[.codex]?.pendingCodexRestoreObservationAt == nil) + #expect(notifier.transitions == [.depleted]) + + self.observe(store, used: 100, boundary: nil, at: self.start.addingTimeInterval(180), owner: owner) + + #expect(notifier.transitions == [.depleted]) + } + + @Test + func `disabled provider cleanup does not refire Codex depletion`() throws { + let owner = try self.owner("disabled-provider-cleanup") + let boundary = self.start.addingTimeInterval(5 * 3600) + let notifier = SessionQuotaNotifierSpy() + let store = Self.makeStore(notifier: notifier) + + self.observe(store, used: 20, boundary: boundary, at: self.start, owner: owner) + self.observe(store, used: 100, boundary: boundary, at: self.start.addingTimeInterval(60), owner: owner) + store.clearDisabledProviderState(enabledProviders: []) + + #expect(store.sessionQuotaTransitionStates[.codex] == nil) + #expect(store.codexSessionQuotaBaselineRequired) + + self.observe(store, used: 100, boundary: boundary, at: self.start.addingTimeInterval(120), owner: owner) + + #expect(notifier.transitions == [.depleted]) + #expect(store.sessionQuotaTransitionStates[.codex]?.remaining == 0) + #expect(!store.codexSessionQuotaBaselineRequired) + } + + @Test + func `unavailable provider cleanup does not refire Codex depletion`() throws { + let owner = try self.owner("unavailable-provider-cleanup") + let boundary = self.start.addingTimeInterval(5 * 3600) + let notifier = SessionQuotaNotifierSpy() + let store = Self.makeStore(notifier: notifier) + + self.observe(store, used: 20, boundary: boundary, at: self.start, owner: owner) + self.observe(store, used: 100, boundary: boundary, at: self.start.addingTimeInterval(60), owner: owner) + store.clearUnavailableProviderState( + displayEnabledProviders: [.codex], + availableProviders: []) + + #expect(store.sessionQuotaTransitionStates[.codex] == nil) + #expect(store.codexSessionQuotaBaselineRequired) + + self.observe(store, used: 100, boundary: boundary, at: self.start.addingTimeInterval(120), owner: owner) + + #expect(notifier.transitions == [.depleted]) + #expect(store.sessionQuotaTransitionStates[.codex]?.remaining == 0) + #expect(!store.codexSessionQuotaBaselineRequired) + } + + @Test + func `cleanup after a positive Codex baseline still reports depletion on recovery`() throws { + let owner = try self.owner("positive-provider-cleanup") + let boundary = self.start.addingTimeInterval(5 * 3600) + let notifier = SessionQuotaNotifierSpy() + let store = Self.makeStore(notifier: notifier) + + self.observe(store, used: 20, boundary: boundary, at: self.start, owner: owner) + store.clearDisabledProviderState(enabledProviders: []) + + #expect(store.sessionQuotaTransitionStates[.codex] == nil) + #expect(!store.codexSessionQuotaBaselineRequired) + + self.observe(store, used: 100, boundary: boundary, at: self.start.addingTimeInterval(120), owner: owner) + + #expect(notifier.transitions == [.depleted]) + #expect(store.sessionQuotaTransitionStates[.codex]?.remaining == 0) + } + + @Test + func `cleanup without a prior Codex baseline keeps startup depletion semantics`() throws { + let owner = try self.owner("startup-cleanup") + let notifier = SessionQuotaNotifierSpy() + let store = Self.makeStore(notifier: notifier) + + store.clearDisabledProviderState(enabledProviders: []) + + #expect(!store.codexSessionQuotaBaselineRequired) + + self.observe(store, used: 100, boundary: nil, at: self.start, owner: owner) + + #expect(notifier.transitions == [.depleted]) + #expect(store.sessionQuotaTransitionStates[.codex]?.remaining == 0) + } + + @Test + func `notifications disabled keep stale observations behind the fresh baseline barrier`() throws { + let owner = try self.owner("disabled-watermark") + let notifier = SessionQuotaNotifierSpy() + let store = Self.makeStore(notifier: notifier) + let invalidatedAt = self.start.addingTimeInterval(120) + + self.observe(store, used: 20, boundary: nil, at: self.start, owner: owner) + store.settings.sessionQuotaNotificationsEnabled = false + self.observe(store, used: 100, boundary: nil, at: invalidatedAt, owner: owner) + + #expect(notifier.transitions.isEmpty) + #expect(store.sessionQuotaTransitionStates[.codex] == nil) + #expect(store.codexSessionQuotaBaselineRequired) + #expect(store.codexSessionQuotaBaselineRequirement?.observedAtWatermark == invalidatedAt) + + store.settings.sessionQuotaNotificationsEnabled = true + self.observe(store, used: 100, boundary: nil, at: self.start.addingTimeInterval(60), owner: owner) + self.observe(store, used: 20, boundary: nil, at: self.start.addingTimeInterval(90), owner: owner) + self.observe(store, used: 10, boundary: nil, at: self.start.addingTimeInterval(100), owner: owner) + self.observe(store, used: 20, boundary: nil, at: invalidatedAt, owner: owner) + + #expect(notifier.transitions.isEmpty) + #expect(store.sessionQuotaTransitionStates[.codex] == nil) + #expect(store.codexSessionQuotaBaselineRequirement?.observedAtWatermark == invalidatedAt) + + self.observe(store, used: 100, boundary: nil, at: self.start.addingTimeInterval(121), owner: owner) + + #expect(notifier.transitions.isEmpty) + #expect(store.sessionQuotaTransitionStates[.codex]?.remaining == 0) + #expect(!store.codexSessionQuotaBaselineRequired) + + self.observe(store, used: 20, boundary: nil, at: self.start.addingTimeInterval(122), owner: owner) + self.observe(store, used: 10, boundary: nil, at: self.start.addingTimeInterval(123), owner: owner) + + #expect(notifier.transitions == [.restored]) + + self.observe(store, used: 100, boundary: nil, at: self.start.addingTimeInterval(124), owner: owner) + + #expect(notifier.transitions == [.restored, .depleted]) + } + + @Test + func `non Codex providers preserve immediate restore semantics`() { + let boundary = self.start.addingTimeInterval(5 * 3600) + let notifier = SessionQuotaNotifierSpy() + let store = Self.makeStore(notifier: notifier) + + self.observe(store, provider: .claude, used: 20, boundary: boundary, at: self.start, owner: nil) + self.observe( + store, + provider: .claude, + used: 100, + boundary: boundary, + at: self.start.addingTimeInterval(60), + owner: nil) + self.observe( + store, + provider: .claude, + used: 0, + boundary: boundary, + at: self.start.addingTimeInterval(120), + owner: nil) + + #expect(notifier.transitions == [.depleted, .restored]) + } + + @Test + func `non Codex providers preserve disabled baseline tracking`() { + let notifier = SessionQuotaNotifierSpy() + let store = Self.makeStore(notifier: notifier) + store.settings.sessionQuotaNotificationsEnabled = false + + self.observe(store, provider: .claude, used: 20, boundary: nil, at: self.start, owner: nil) + self.observe( + store, + provider: .claude, + used: 100, + boundary: nil, + at: self.start.addingTimeInterval(60), + owner: nil) + #expect(notifier.transitions.isEmpty) + + store.settings.sessionQuotaNotificationsEnabled = true + self.observe( + store, + provider: .claude, + used: 20, + boundary: nil, + at: self.start.addingTimeInterval(120), + owner: nil) + #expect(notifier.transitions == [.restored]) + } + + @Test + func `selected Codex account caller forwards its stable owner`() async throws { + let expectedOwner = try self.owner("selected-caller") + let limitResetOwner = try #require(CodexLimitResetOwnerKey( + identity: .providerAccount(id: "workspace-fixture-selected-caller"), + accountEmail: "session-fixture@example.test")) + let now = self.start + let snapshot = self.snapshot( + used: 20, + resetBoundary: now.addingTimeInterval(5 * 3600), + updatedAt: now) + let account = CodexVisibleAccount( + id: "live:selected-caller", + email: "session-fixture@example.test", + workspaceAccountID: "workspace-fixture-selected-caller", + storedAccountID: nil, + selectionSource: .liveSystem, + isActive: true, + isLive: true, + canReauthenticate: false, + canRemove: false) + let result = ProviderFetchResult( + usage: snapshot, + credits: nil, + dashboard: nil, + sourceLabel: "fixture", + strategyID: "fixture.oauth", + strategyKind: .oauth) + let store = Self.makeStore(notifier: SessionQuotaNotifierSpy()) + + await store.applySelectedCodexVisibleAccountOutcome( + ProviderFetchOutcome(result: .success(result), attempts: []), + account: account, + snapshot: snapshot, + sourceLabel: "fixture", + limitResetOwnerKey: limitResetOwner) + + #expect(store.sessionQuotaTransitionStates[.codex]?.codexOwnerKey == expectedOwner) + } + + @Test + func `selected Codex accounts keep independent quota warning episodes`() async throws { + let managedAccountID = try #require(UUID(uuidString: "00000000-0000-0000-0000-000000000002")) + let firstAccount = CodexVisibleAccount( + id: "live:first-quota-account", + email: "first-quota@example.test", + workspaceAccountID: "workspace-first-quota-account", + storedAccountID: nil, + selectionSource: .liveSystem, + isActive: true, + isLive: true, + canReauthenticate: false, + canRemove: false) + let secondAccount = CodexVisibleAccount( + id: "managed:\(managedAccountID.uuidString.lowercased())", + email: "second-quota@example.test", + workspaceAccountID: "workspace-second-quota-account", + storedAccountID: managedAccountID, + selectionSource: .managedAccount(id: managedAccountID), + isActive: false, + isLive: false, + canReauthenticate: true, + canRemove: true) + let notifier = SessionQuotaNotifierSpy() + let store = Self.makeStore(notifier: notifier) + let isolatedCodexHome = FileManager.default.temporaryDirectory + .appendingPathComponent("CodexSessionQuotaFalseRestoreTests-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: isolatedCodexHome, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: isolatedCodexHome) } + store.settings._test_codexReconciliationEnvironment = ["CODEX_HOME": isolatedCodexHome.path] + defer { store.settings._test_codexReconciliationEnvironment = nil } + store.settings.sessionQuotaNotificationsEnabled = false + store.settings.quotaWarningNotificationsEnabled = true + store.settings.quotaWarningThresholds = [50] + store.settings.setQuotaWarningWindowEnabled(.session, enabled: true) + store.settings.setQuotaWarningWindowEnabled(.weekly, enabled: false) + + for (account, usedPercent) in [ + (firstAccount, 40.0), + (secondAccount, 40.0), + (firstAccount, 55.0), + (secondAccount, 30.0), + (firstAccount, 55.0), + (secondAccount, 55.0), + ] { + let snapshot = self.snapshot( + used: usedPercent, + resetBoundary: nil, + updatedAt: self.start, + email: account.email) + let result = ProviderFetchResult( + usage: snapshot, + credits: nil, + dashboard: nil, + sourceLabel: "fixture", + strategyID: "fixture.oauth", + strategyKind: .oauth) + await store.applySelectedCodexVisibleAccountOutcome( + ProviderFetchOutcome(result: .success(result), attempts: []), + account: account, + snapshot: snapshot, + sourceLabel: "fixture", + limitResetOwnerKey: nil) + } + + #expect(notifier.quotaWarningPosts.map(\.accountDisplayName) == [ + "first-quota@example.test", + "second-quota@example.test", + ]) + #expect(notifier.quotaWarningPosts.allSatisfy { $0.threshold == 50 }) + } + + @Test + func `selected email only Codex account keeps session notifications`() async { + let email = "email-only-session@example.test" + let account = CodexVisibleAccount( + id: "live:email-only-session", + email: email, + workspaceAccountID: nil, + authFingerprint: "fixture-auth-fingerprint", + storedAccountID: nil, + selectionSource: .liveSystem, + isActive: true, + isLive: true, + canReauthenticate: false, + canRemove: false) + let notifier = SessionQuotaNotifierSpy() + let store = Self.makeStore(notifier: notifier) + let boundary = self.start.addingTimeInterval(5 * 3600) + + let advancedBoundary = boundary.addingTimeInterval(5 * 3600) + for (used, observedAt, resetBoundary) in [ + (20.0, self.start, boundary), + (100.0, self.start.addingTimeInterval(60), boundary), + (20.0, boundary.addingTimeInterval(60), advancedBoundary), + (10.0, boundary.addingTimeInterval(120), advancedBoundary), + ] { + let snapshot = self.snapshot( + used: used, + resetBoundary: resetBoundary, + updatedAt: observedAt, + email: email) + let result = ProviderFetchResult( + usage: snapshot, + credits: nil, + dashboard: nil, + sourceLabel: "fixture", + strategyID: "fixture.oauth", + strategyKind: .oauth) + await store.applySelectedCodexVisibleAccountOutcome( + ProviderFetchOutcome(result: .success(result), attempts: []), + account: account, + snapshot: snapshot, + sourceLabel: "fixture", + limitResetOwnerKey: nil) + } + + #expect(store.sessionQuotaTransitionStates[.codex]?.codexOwnerKey != nil) + #expect(notifier.transitions == [.depleted, .restored]) + } + + @Test + func `email only notification owners isolate source and credential rotation`() throws { + let email = "email-only-owner@example.test" + let identity = CodexIdentity.emailOnly(normalizedEmail: email) + let liveA = try #require(CodexSessionQuotaOwnerKey(refreshGuard: CodexAccountScopedRefreshGuard( + source: .liveSystem, + identity: identity, + accountKey: email, + authFingerprint: "fixture-a"))) + let liveB = try #require(CodexSessionQuotaOwnerKey(refreshGuard: CodexAccountScopedRefreshGuard( + source: .liveSystem, + identity: identity, + accountKey: email, + authFingerprint: "fixture-b"))) + let profile = try #require(CodexSessionQuotaOwnerKey(refreshGuard: CodexAccountScopedRefreshGuard( + source: .profileHome(path: "/tmp/codex-email-only-owner"), + identity: identity, + accountKey: email, + authFingerprint: "fixture-a"))) + let providerIdentity = CodexIdentity.providerAccount(id: "workspace-email-only-owner") + let providerA = try #require(CodexSessionQuotaOwnerKey(refreshGuard: CodexAccountScopedRefreshGuard( + source: .liveSystem, + identity: providerIdentity, + accountKey: email, + authFingerprint: "fixture-a"))) + let providerB = try #require(CodexSessionQuotaOwnerKey(refreshGuard: CodexAccountScopedRefreshGuard( + source: .profileHome(path: "/tmp/codex-email-only-owner"), + identity: providerIdentity, + accountKey: email, + authFingerprint: "fixture-b"))) + + #expect(liveA != liveB) + #expect(liveA != profile) + #expect(providerA == providerB) + #expect(CodexLimitResetOwnerKey(identity: identity, accountEmail: email) == nil) + #expect(CodexSessionQuotaOwnerKey(refreshGuard: CodexAccountScopedRefreshGuard( + source: .liveSystem, + identity: identity, + accountKey: email, + authFingerprint: nil)) == nil) + } + + @Test + func `email only credential rotation establishes a new baseline`() throws { + let email = "rotating-email-only-owner@example.test" + let identity = CodexIdentity.emailOnly(normalizedEmail: email) + let oldGuard = CodexAccountScopedRefreshGuard( + source: .liveSystem, + identity: identity, + accountKey: email, + authFingerprint: "fixture-old") + let newGuard = CodexAccountScopedRefreshGuard( + source: .liveSystem, + identity: identity, + accountKey: email, + authFingerprint: "fixture-new") + let oldOwner = try #require(UsageStore.codexSessionQuotaOwnerKey(for: oldGuard)) + let newOwner = try #require(UsageStore.codexSessionQuotaOwnerKey(for: newGuard)) + let notifier = SessionQuotaNotifierSpy() + let store = Self.makeStore(notifier: notifier) + + self.observe(store, used: 20, boundary: nil, at: self.start, owner: oldOwner) + self.observe(store, used: 100, boundary: nil, at: self.start.addingTimeInterval(60), owner: oldOwner) + store.snapshots[.codex] = self.snapshot( + used: 100, + resetBoundary: nil, + updatedAt: self.start.addingTimeInterval(60), + email: email) + store.lastCodexUsagePublicationGuard = oldGuard + store.lastCodexAccountScopedRefreshGuard = oldGuard + + store.reconcileCodexAccountStateForUsageOwner(newGuard) + + #expect(store.snapshots[.codex] == nil) + #expect(store.sessionQuotaTransitionStates[.codex] == nil) + #expect(store.codexSessionQuotaBaselineRequired) + self.observe( + store, + used: 100, + boundary: nil, + at: self.start.addingTimeInterval(120), + owner: newOwner) + + #expect(notifier.transitions == [.depleted]) + #expect(store.sessionQuotaTransitionStates[.codex]?.codexOwnerKey == newOwner) + #expect(store.sessionQuotaTransitionStates[.codex]?.remaining == 0) + #expect(!store.codexSessionQuotaBaselineRequired) + + self.observe( + store, + used: 20, + boundary: nil, + at: self.start.addingTimeInterval(180), + owner: newOwner) + self.observe( + store, + used: 10, + boundary: nil, + at: self.start.addingTimeInterval(240), + owner: newOwner) + + #expect(notifier.transitions == [.depleted, .restored]) + } + + @Test + func `provider owner survives source and credential changes`() throws { + let email = "provider-source-owner@example.test" + let identity = CodexIdentity.providerAccount(id: "workspace-provider-source-owner") + let oldGuard = CodexAccountScopedRefreshGuard( + source: .liveSystem, + identity: identity, + accountKey: email, + authFingerprint: "fixture-old") + let newGuard = CodexAccountScopedRefreshGuard( + source: .profileHome(path: "/tmp/codex-provider-source-owner"), + identity: identity, + accountKey: email, + authFingerprint: "fixture-new") + let oldOwner = try #require(UsageStore.codexSessionQuotaOwnerKey(for: oldGuard)) + let newOwner = try #require(UsageStore.codexSessionQuotaOwnerKey(for: newGuard)) + let notifier = SessionQuotaNotifierSpy() + let store = Self.makeStore(notifier: notifier) + + #expect(oldOwner == newOwner) + self.observe(store, used: 20, boundary: nil, at: self.start, owner: oldOwner) + self.observe(store, used: 100, boundary: nil, at: self.start.addingTimeInterval(60), owner: oldOwner) + store.snapshots[.codex] = self.snapshot( + used: 100, + resetBoundary: nil, + updatedAt: self.start.addingTimeInterval(60), + email: email) + store.lastCodexUsagePublicationGuard = oldGuard + store.lastCodexAccountScopedRefreshGuard = oldGuard + + store.reconcileCodexAccountStateForUsageOwner(newGuard) + + #expect(store.snapshots[.codex] == nil) + #expect(store.sessionQuotaTransitionStates[.codex]?.remaining == 0) + self.observe( + store, + used: 20, + boundary: nil, + at: self.start.addingTimeInterval(120), + owner: newOwner) + self.observe( + store, + used: 10, + boundary: nil, + at: self.start.addingTimeInterval(180), + owner: newOwner) + + #expect(notifier.transitions == [.depleted, .restored]) + _ = store.prepareCodexAccountScopedRefreshIfNeeded( + forceInvalidation: true, + currentGuardOverride: newGuard) + #expect(store.sessionQuotaTransitionStates[.codex] == nil) + #expect(store.codexSessionQuotaBaselineRequired) + } + + @Test + func `regular refresh owner builder supports email only identity`() throws { + let email = "regular-email-only-owner@example.test" + let refreshGuard = CodexAccountScopedRefreshGuard( + source: .liveSystem, + identity: .emailOnly(normalizedEmail: email), + accountKey: email, + authFingerprint: "fixture-regular") + let owner = try #require(UsageStore.codexSessionQuotaOwnerKey(for: refreshGuard)) + + #expect(!owner.rawValue.isEmpty) + } + + @Test + func `clearing published Codex usage clears typed transition state`() throws { + let owner = try self.owner("cleanup") + let boundary = self.start.addingTimeInterval(5 * 3600) + let store = Self.makeStore(notifier: SessionQuotaNotifierSpy()) + + self.observe(store, used: 20, boundary: boundary, at: self.start, owner: owner) + #expect(store.sessionQuotaTransitionStates[.codex] != nil) + + store.clearCodexPublishedUsageState() + + #expect(store.sessionQuotaTransitionStates[.codex] == nil) + #expect(store.codexSessionQuotaBaselineRequired) + } + + private func observe( + _ store: UsageStore, + provider: UsageProvider = .codex, + used: Double, + boundary: Date?, + at: Date, + evaluatedAt: Date? = nil, + owner: CodexSessionQuotaOwnerKey?) + { + store.handleSessionQuotaTransition( + provider: provider, + snapshot: self.snapshot( + provider: provider, + used: used, + resetBoundary: boundary, + updatedAt: at), + codexOwnerKey: owner, + now: evaluatedAt ?? at) + } + + private func snapshot( + provider: UsageProvider = .codex, + used: Double, + resetBoundary: Date?, + updatedAt: Date, + email: String = "session-fixture@example.test") -> UsageSnapshot + { + UsageSnapshot( + primary: RateWindow( + usedPercent: used, + windowMinutes: 300, + resetsAt: resetBoundary, + resetDescription: nil), + secondary: nil, + updatedAt: updatedAt, + identity: ProviderIdentitySnapshot( + providerID: provider, + accountEmail: email, + accountOrganization: nil, + loginMethod: "test")) + } + + private func owner(_ suffix: String) throws -> CodexSessionQuotaOwnerKey { + try #require(CodexSessionQuotaOwnerKey(refreshGuard: CodexAccountScopedRefreshGuard( + source: .liveSystem, + identity: .providerAccount(id: "workspace-fixture-\(suffix)"), + accountKey: "session-fixture@example.test"))) + } + + private static func makeStore(notifier: SessionQuotaNotifierSpy) -> UsageStore { + let suiteName = "CodexSessionQuotaFalseRestoreTests-\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suiteName)! + defaults.removePersistentDomain(forName: suiteName) + let settings = SettingsStore( + userDefaults: defaults, + configStore: testConfigStore(suiteName: suiteName), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + settings.refreshFrequency = .manual + settings.statusChecksEnabled = false + settings.sessionQuotaNotificationsEnabled = true + return UsageStore( + fetcher: UsageFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + sessionQuotaNotifier: notifier) + } +} + +@MainActor +private final class SessionQuotaNotifierSpy: SessionQuotaNotifying { + private(set) var transitions: [SessionQuotaTransition] = [] + private(set) var quotaWarningPosts: [QuotaWarningEvent] = [] + + func post(transition: SessionQuotaTransition, provider _: UsageProvider, badge _: NSNumber?) { + self.transitions.append(transition) + } + + func postQuotaWarning( + event: QuotaWarningEvent, + provider _: UsageProvider, + soundEnabled _: Bool, + onScreenAlertEnabled _: Bool) + { + self.quotaWarningPosts.append(event) + } +} diff --git a/Tests/CodexBarTests/CodexSessionRolloutTests.swift b/Tests/CodexBarTests/CodexSessionRolloutTests.swift new file mode 100644 index 0000000000..87335f4ec2 --- /dev/null +++ b/Tests/CodexBarTests/CodexSessionRolloutTests.swift @@ -0,0 +1,317 @@ +import Foundation +#if canImport(SQLite3) +import SQLite3 +#elseif canImport(CSQLite3) +import CSQLite3 +#endif +import Testing +@testable import CodexBarCore + +struct CodexSessionRolloutTests { + @Test + func `first rollout line maps to file only agent session`() throws { + let url = try AgentSessionParserTests.fixtureURL("agent-session-rollout", extension: "jsonl") + let metadata = try #require(CodexRolloutFirstLineParser.read(from: url)) + let now = Date(timeIntervalSince1970: 10000) + let modifiedAt = now.addingTimeInterval(-60) + let session = try #require(CodexRolloutFirstLineParser.makeSession( + metadata: metadata, + transcriptURL: url, + modifiedAt: modifiedAt, + host: "local-mac", + now: now)) + + #expect(session.id == "019f-session-fixture") + #expect(session.cwd == "/Users/test/Projects/alpha") + #expect(session.projectName == "alpha") + #expect(session.source == .cli) + #expect(session.state == .active) + #expect(session.pid == nil) + } + + @Test + func `file only rollout outside window is excluded while live process remains`() throws { + let url = try AgentSessionParserTests.fixtureURL("agent-session-rollout", extension: "jsonl") + let metadata = try #require(CodexRolloutFirstLineParser.read(from: url)) + let now = Date(timeIntervalSince1970: 10000) + let modifiedAt = now.addingTimeInterval(-1801) + + #expect(CodexRolloutFirstLineParser.makeSession( + metadata: metadata, + transcriptURL: url, + modifiedAt: modifiedAt, + host: "local-mac", + now: now) == nil) + #expect(CodexRolloutFirstLineParser.makeSession( + metadata: metadata, + transcriptURL: url, + modifiedAt: modifiedAt, + pid: 42, + host: "local-mac", + now: now)?.state == .idle) + } + + @Test + func `app server presence classifies unknown file only rollout as desktop`() { + #expect(AgentSessionCorrelation.fileOnlyCodexSource( + metadataSource: .unknown, + appServerPresent: true) == .desktopApp) + #expect(AgentSessionCorrelation.fileOnlyCodexSource( + metadataSource: .unknown, + appServerPresent: false) == .unknown) + } + + @Test + func `codex cwd matching rejects missing paths`() { + #expect(AgentSessionCorrelation.codexWorkingDirectoriesMatch("/repo/alpha", "/repo/./alpha")) + #expect(!AgentSessionCorrelation.codexWorkingDirectoriesMatch(nil, nil)) + #expect(!AgentSessionCorrelation.codexWorkingDirectoriesMatch("/repo/alpha", nil)) + } + + @Test + func `local scanner parses only its newest configured rollout candidates`() async throws { + let fileManager = FileManager.default + let temporaryRoot = fileManager.temporaryDirectory + .appendingPathComponent("CodexSessionRolloutTests-\(UUID().uuidString)", isDirectory: true) + defer { try? fileManager.removeItem(at: temporaryRoot) } + + let now = Date() + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.dateFormat = "yyyy/MM/dd" + let codexHome = temporaryRoot.appendingPathComponent("codex-home", isDirectory: true) + let sessionDirectory = codexHome + .appendingPathComponent("sessions", isDirectory: true) + .appendingPathComponent(formatter.string(from: now), isDirectory: true) + try fileManager.createDirectory(at: sessionDirectory, withIntermediateDirectories: true) + + let fixtureURL = try AgentSessionParserTests.fixtureURL("agent-session-rollout", extension: "jsonl") + let fixture = try String(contentsOf: fixtureURL, encoding: .utf8) + for (index, age) in [30.0, 20.0, -3600.0].enumerated() { + let id = "bounded-rollout-\(index)" + let url = sessionDirectory.appendingPathComponent("rollout-bounded-\(index).jsonl") + try fixture + .replacingOccurrences(of: "019f-session-fixture", with: id) + .write(to: url, atomically: true, encoding: .utf8) + try fileManager.setAttributes( + [.modificationDate: now.addingTimeInterval(-age)], + ofItemAtPath: url.path) + } + + let scanner = LocalAgentSessionScanner(config: SessionScanConfig( + fileOnlyWindow: 60 * 60, + maxProcessCount: 0, + maxCodexRolloutCount: 2, + maxClaudeTranscriptCountPerProject: 0)) + let sessions = await scanner.scan(now: now, environment: [ + "CODEX_HOME": codexHome.path, + "HOME": temporaryRoot.path, + "PATH": "/usr/bin:/bin:/usr/sbin:/sbin", + ]) + + #expect(Set(sessions.map(\.id)) == ["bounded-rollout-1", "bounded-rollout-2"]) + #expect(sessions.first(where: { $0.id == "bounded-rollout-2" })?.lastActivityAt == now) + + let rescanned = await scanner.scan( + now: now.addingTimeInterval(30), + environment: [ + "CODEX_HOME": codexHome.path, + "HOME": temporaryRoot.path, + "PATH": "/usr/bin:/bin:/usr/sbin:/sbin", + ]) + #expect(rescanned.first(where: { $0.id == "bounded-rollout-2" })?.lastActivityAt == now) + } + + @Test + func `subagent and guardian rollout metadata produce descriptive names`() throws { + let subagentLine = + "{\"type\":\"session_meta\",\"payload\":{\"id\":\"subagent\",\"cwd\":\"/repo\"," + + "\"originator\":\"codex_vscode\",\"source\":{\"subagent\":{\"thread_spawn\":{\"agent_path\":" + + "\"/root/neon_patch_review2\"}}}}}" + let guardianLine = + "{\"type\":\"session_meta\",\"payload\":{\"id\":\"guardian\",\"cwd\":\"/repo\"," + + "\"originator\":\"codex_vscode\",\"source\":{\"subagent\":{\"other\":\"guardian\"}}}}" + + let subagent = try #require(CodexRolloutFirstLineParser.parse(subagentLine)) + let guardian = try #require(CodexRolloutFirstLineParser.parse(guardianLine)) + + #expect(subagent.agentPath == "/root/neon_patch_review2") + #expect(subagent.descriptiveName(threadMetadata: nil) == "Neon patch review 2") + #expect(guardian.isGuardian) + #expect(guardian.descriptiveName(threadMetadata: nil) == "Approval review") + } + + @Test + func `current rollout agent path produces a descriptive subagent name without sqlite`() throws { + let line = + "{\"type\":\"session_meta\",\"payload\":{\"id\":\"subagent\",\"cwd\":\"/repo\"," + + "\"originator\":\"codex_vscode\",\"source\":\"subagent\"," + + "\"agent_path\":\"/root/config_audit3\"}}" + + let metadata = try #require(CodexRolloutFirstLineParser.parse(line)) + + #expect(metadata.agentPath == "/root/config_audit3") + #expect(metadata.descriptiveName(threadMetadata: nil) == "Config audit 3") + } + + @Test + func `thread titles skip command preambles and stay menu sized`() { + let metadata = CodexRolloutMetadata( + sessionID: "main", + cwd: "/repo", + originator: "codex_vscode", + source: "vscode") + let title = """ + /brain-orient + + Continue work on the Concrete Authority website and compare every current source before changing anything. + """ + + let name = metadata.descriptiveName(threadMetadata: CodexThreadMetadata( + title: title, + agentPath: nil)) + #expect(name == "Continue work on the Concrete Authority website and compare eve…") + #expect(name?.count == 64) + } + + @Test + func `live scanner suppresses descriptive names for ambiguous same project processes`() async throws { + let fileManager = FileManager.default + let root = fileManager.temporaryDirectory + .appendingPathComponent("CodexSessionRolloutTests-ambiguous-\(UUID().uuidString)", isDirectory: true) + defer { try? fileManager.removeItem(at: root) } + + let now = Date() + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.dateFormat = "yyyy/MM/dd" + let codexHome = root.appendingPathComponent("codex-home", isDirectory: true) + let sessionDirectory = codexHome + .appendingPathComponent("sessions", isDirectory: true) + .appendingPathComponent(formatter.string(from: now), isDirectory: true) + try fileManager.createDirectory(at: sessionDirectory, withIntermediateDirectories: true) + + for (index, name) in ["recent_activity", "older_activity"].enumerated() { + let line = + "{\"type\":\"session_meta\",\"payload\":{\"id\":\"session-\(index)\",\"cwd\":\"/repo\"," + + "\"originator\":\"codex_cli\",\"source\":{\"subagent\":{\"thread_spawn\":{\"agent_path\":" + + "\"/root/\(name)\"}}}}}" + let url = sessionDirectory.appendingPathComponent("rollout-ambiguous-\(index).jsonl") + try line.write(to: url, atomically: true, encoding: .utf8) + try fileManager.setAttributes( + [.modificationDate: now.addingTimeInterval(TimeInterval(-index * 30))], + ofItemAtPath: url.path) + } + + let scanner = LocalAgentSessionScanner( + processOutputProvider: { _ in + """ + 201 1 Mon Jul 6 09:03:00 2026 /usr/local/bin/codex exec + 202 1 Tue Jul 7 09:03:00 2026 /usr/local/bin/codex exec + """ + }, + cwdProvider: { _, _ in [201: "/repo", 202: "/repo"] }) + let sessions = await scanner.scan( + now: now, + environment: [ + "CODEX_HOME": codexHome.path, + "HOME": root.path, + "PATH": "/usr/bin:/bin:/usr/sbin:/sbin", + ], + includeFileOnlySessions: false) + + #expect(sessions.count == 2) + #expect(sessions.allSatisfy { $0.projectName == "repo" }) + #expect(sessions.allSatisfy { $0.sessionName == nil }) + } + + #if canImport(SQLite3) || canImport(CSQLite3) + @Test + func `scanner resolves relative sqlite homes for multiple session projects`() async throws { + let fileManager = FileManager.default + let root = fileManager.temporaryDirectory + .appendingPathComponent("CodexSessionRolloutTests-relative-sqlite-\(UUID().uuidString)", isDirectory: true) + defer { try? fileManager.removeItem(at: root) } + let now = Date() + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.dateFormat = "yyyy/MM/dd" + let codexHome = root.appendingPathComponent("codex-home", isDirectory: true) + let sessionDirectory = codexHome + .appendingPathComponent("sessions", isDirectory: true) + .appendingPathComponent(formatter.string(from: now), isDirectory: true) + try fileManager.createDirectory(at: sessionDirectory, withIntermediateDirectories: true) + + for index in 0..<2 { + let project = root.appendingPathComponent("project-\(index)", isDirectory: true) + let sqliteHome = project.appendingPathComponent("relative-state", isDirectory: true) + try fileManager.createDirectory(at: sqliteHome, withIntermediateDirectories: true) + let sessionID = "relative-session-\(index)" + let line = + "{\"type\":\"session_meta\",\"payload\":{\"id\":\"\(sessionID)\",\"cwd\":\"\(project.path)\"," + + "\"originator\":\"codex_cli\",\"source\":\"cli\"}}" + try line.write( + to: sessionDirectory.appendingPathComponent("rollout-relative-\(index).jsonl"), + atomically: true, + encoding: .utf8) + try Self.createThreadDatabase( + at: sqliteHome.appendingPathComponent("state_5.sqlite"), + sessionID: sessionID, + title: "Project \(index) title") + } + + let scanner = LocalAgentSessionScanner(config: SessionScanConfig( + maxProcessCount: 0, + maxClaudeTranscriptCountPerProject: 0)) + let sessions = await scanner.scan(now: now, environment: [ + "CODEX_HOME": codexHome.path, + "CODEX_SQLITE_HOME": "relative-state", + "HOME": root.path, + "PATH": "/usr/bin:/bin:/usr/sbin:/sbin", + ]) + + #expect(Dictionary(uniqueKeysWithValues: sessions.map { ($0.id, $0.sessionName) }) == [ + "relative-session-0": "Project 0 title", + "relative-session-1": "Project 1 title", + ]) + } + + private static func createThreadDatabase( + at url: URL, + sessionID: String, + title: String) throws + { + var database: OpaquePointer? + guard sqlite3_open(url.path, &database) == SQLITE_OK, let database else { + throw SQLiteFixtureError.open + } + defer { sqlite3_close(database) } + guard sqlite3_exec( + database, + "CREATE TABLE threads (id TEXT PRIMARY KEY, title TEXT, agent_path TEXT);", + nil, + nil, + nil) == SQLITE_OK + else { throw SQLiteFixtureError.exec } + var statement: OpaquePointer? + guard sqlite3_prepare_v2( + database, + "INSERT INTO threads (id, title, agent_path) VALUES (?1, ?2, NULL);", + -1, + &statement, + nil) == SQLITE_OK, + let statement + else { throw SQLiteFixtureError.exec } + defer { sqlite3_finalize(statement) } + let transient = unsafeBitCast(-1, to: sqlite3_destructor_type.self) + sqlite3_bind_text(statement, 1, sessionID, -1, transient) + sqlite3_bind_text(statement, 2, title, -1, transient) + guard sqlite3_step(statement) == SQLITE_DONE else { throw SQLiteFixtureError.exec } + } + + private enum SQLiteFixtureError: Error { + case open + case exec + } + #endif +} diff --git a/Tests/CodexBarTests/CodexSubagentAccountingIntegrationTests.swift b/Tests/CodexBarTests/CodexSubagentAccountingIntegrationTests.swift new file mode 100644 index 0000000000..91eeebb957 --- /dev/null +++ b/Tests/CodexBarTests/CodexSubagentAccountingIntegrationTests.swift @@ -0,0 +1,625 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct CodexSubagentAccountingIntegrationTests { + private typealias Usage = (input: Int, cached: Int, output: Int) + + @Test + func `copied parent prefix keeps the inherited baseline after late lineage metadata`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 7, day: 16) + let forkTimestamp = env.isoString(for: day) + let parentModel = "openai/gpt-5.3" + let leafModel = "openai/gpt-5.4" + let fileURL = try env.writeCodexSessionFile( + day: day, + filename: "rollout-\(forkTimestamp)-child-session.jsonl", + contents: env.jsonl([ + [ + "type": "session_meta", + "timestamp": forkTimestamp, + "payload": [ + "id": "child-session", + "timestamp": forkTimestamp, + "source": [ + "subagent": [ + "thread_spawn": ["parent_thread_id": "parent-session"], + ], + ], + ], + ], + self.turnContext(timestamp: forkTimestamp, model: parentModel), + self.tokenCount( + timestamp: env.isoString(for: day.addingTimeInterval(1)), + model: parentModel, + total: (input: 1000, cached: 900, output: 100), + last: (input: 50, cached: 10, output: 5)), + [ + "type": "session_meta", + "timestamp": forkTimestamp, + "payload": [ + "id": "child-session", + "forked_from_id": "parent-session", + "timestamp": forkTimestamp, + ], + ], + [ + "type": "session_meta", + "timestamp": forkTimestamp, + "payload": [ + "id": "parent-session", + "timestamp": forkTimestamp, + ], + ], + self.turnContext(timestamp: forkTimestamp, model: leafModel), + self.tokenCount( + timestamp: env.isoString(for: day.addingTimeInterval(2)), + model: leafModel, + total: (input: 1050, cached: 910, output: 105), + last: (input: 50, cached: 10, output: 5)), + ])) + + var resolvedParentBaseline = false + let parsed = CostUsageScanner.parseCodexFile( + fileURL: fileURL, + range: CostUsageScanner.CostUsageDayRange(since: day, until: day), + inheritedTotalsResolver: { parentSessionID, _ in + resolvedParentBaseline = true + #expect(parentSessionID == "parent-session") + return .resolved(.init(input: 1000, cached: 900, output: 100)) + }) + + let dayKey = CostUsageScanner.CostUsageDayRange.dayKey(from: day) + let normalizedLeafModel = CostUsagePricing.normalizeCodexModel(leafModel) + #expect(parsed.days[dayKey]?[normalizedLeafModel] == [50, 10, 5]) + #expect(parsed.days[dayKey]?[CostUsagePricing.normalizeCodexModel(parentModel)] == nil) + #expect(resolvedParentBaseline) + #expect(parsed.dependsOnParentTotals) + } + + @Test + func `local marker owns only its suffix and persists lineage-only cache mode`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 7, day: 16) + let forkTimestamp = env.isoString(for: day) + let parentModel = "openai/gpt-5.3" + let leafModel = "openai/gpt-5.4" + let fastContents = try env.jsonl([ + [ + "type": "session_meta", + "timestamp": forkTimestamp, + "payload": [ + "id": "marker-child", + "timestamp": forkTimestamp, + "source": [ + "subagent": [ + "thread_spawn": ["parent_thread_id": "parent-session"], + ], + ], + ], + ], + self.turnContext(timestamp: forkTimestamp, model: parentModel), + self.tokenCount( + timestamp: env.isoString(for: day.addingTimeInterval(1)), + model: parentModel, + total: (input: 1000, cached: 900, output: 100), + last: (input: 50, cached: 10, output: 5)), + [ + "type": "session_meta", + "timestamp": forkTimestamp, + "payload": [ + "id": "marker-child", + "forked_from_id": "parent-session", + "timestamp": forkTimestamp, + ], + ], + [ + "type": "session_meta", + "timestamp": forkTimestamp, + "payload": ["id": "ancestor-session"], + ], + self.turnContext(timestamp: env.isoString(for: day.addingTimeInterval(2)), model: leafModel), + [ + "type": "inter_agent_communication_metadata", + "timestamp": env.isoString(for: day.addingTimeInterval(2)), + "payload": ["trigger_turn": true], + ], + self.tokenCount( + timestamp: env.isoString(for: day.addingTimeInterval(2.5)), + model: parentModel, + total: (input: 1000, cached: 900, output: 100), + last: (input: 50, cached: 10, output: 5)), + self.tokenCount( + timestamp: env.isoString(for: day.addingTimeInterval(3)), + model: leafModel, + total: (input: 1050, cached: 910, output: 105), + last: (input: 50, cached: 10, output: 5)), + ]) + let fastFileURL = try env.writeCodexSessionFile( + day: day, + filename: "rollout-\(forkTimestamp)-marker-child.jsonl", + contents: fastContents) + let fallbackFileURL = try env.writeCodexSessionFile( + day: day, + filename: "rollout-\(forkTimestamp)-marker-child-fallback.jsonl", + contents: fastContents + .replacingOccurrences(of: "marker-child", with: "marker-child-fallback") + .replacingOccurrences( + of: "\"type\":\"session_meta\"", + with: "\"ty\\u0070e\":\"session_meta\"") + .replacingOccurrences( + of: "\"type\":\"turn_context\"", + with: "\"ty\\u0070e\":\"turn_context\"") + .replacingOccurrences( + of: "\"type\":\"inter_agent_communication_metadata\"", + with: "\"ty\\u0070e\":\"inter_agent_communication_metadata\"")) + let escapedTimestampFileURL = try env.writeCodexSessionFile( + day: day, + filename: "rollout-\(forkTimestamp)-marker-child-escaped-timestamp.jsonl", + contents: fastContents + .replacingOccurrences(of: "marker-child", with: "marker-child-escaped-timestamp") + .replacingOccurrences(of: "\"timestamp\":", with: "\"time\\u0073tamp\":")) + + let dayKey = CostUsageScanner.CostUsageDayRange.dayKey(from: day) + let normalizedLeafModel = CostUsagePricing.normalizeCodexModel(leafModel) + for fileURL in [fastFileURL, fallbackFileURL, escapedTimestampFileURL] { + var resolvedParentBaseline = false + let parsed = CostUsageScanner.parseCodexFile( + fileURL: fileURL, + range: CostUsageScanner.CostUsageDayRange(since: day, until: day), + inheritedTotalsResolver: { _, _ in + resolvedParentBaseline = true + return .resolved(.init(input: 10, cached: 0, output: 0)) + }) + #expect(parsed.days[dayKey]?[normalizedLeafModel] == [50, 10, 5]) + #expect(parsed.days[dayKey]?[CostUsagePricing.normalizeCodexModel(parentModel)] == nil) + #expect(!parsed.dependsOnParentTotals) + #expect(!resolvedParentBaseline) + } + + var options = CostUsageScanner.Options( + codexSessionsRoot: env.codexSessionsRoot, + claudeProjectsRoots: nil, + cacheRoot: env.cacheRoot) + options.refreshMinIntervalSeconds = 0 + let report = CostUsageScanner.loadDailyReport( + provider: .codex, + since: day, + until: day, + now: day, + options: options) + #expect(report.data.first?.totalTokens == 165) + + let cache = CostUsageCacheIO.load(provider: .codex, cacheRoot: env.cacheRoot) + let childUsages = cache.files.values.filter { $0.sessionId?.hasPrefix("marker-child") == true } + #expect(childUsages.count == 3) + #expect(childUsages.allSatisfy { + $0.forkBaselineDependencyKey == CostUsageScanner.codexForkDependencyNotRequiredKey + }) + let sessions = CostUsageScanner.buildCodexSessionBreakdownsFromCache( + cache: cache, + range: CostUsageScanner.CostUsageDayRange(since: day, until: day)) + #expect(sessions.count == 3) + #expect(sessions.allSatisfy { $0.totalTokens == 55 }) + } + + @Test + func `copied prefix infers its parent and ignores a spoofed trigger outside the payload`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 7, day: 16) + let forkTimestamp = env.isoString(for: day) + let fileURL = try env.writeCodexSessionFile( + day: day, + filename: "rollout-\(forkTimestamp)-inferred-parent.jsonl", + contents: env.jsonl([ + [ + "type": "session_meta", + "timestamp": forkTimestamp, + "payload": [ + "id": "inferred-child", + "timestamp": forkTimestamp, + "source": ["subagent": ["thread_spawn": [:]]], + ], + ], + self.tokenCount( + timestamp: env.isoString(for: day.addingTimeInterval(1)), + model: "openai/gpt-5.3", + total: (input: 1000, cached: 900, output: 100), + last: (input: 50, cached: 10, output: 5)), + [ + "type": "session_meta", + "timestamp": forkTimestamp, + "payload": ["id": "inferred-parent"], + ], + self.turnContext( + timestamp: env.isoString(for: day.addingTimeInterval(2)), + model: "openai/gpt-5.4"), + [ + "type": "inter_agent_communication_metadata", + "timestamp": env.isoString(for: day.addingTimeInterval(2)), + "trigger_turn": true, + "payload": ["trigger_turn": false], + ], + self.tokenCount( + timestamp: env.isoString(for: day.addingTimeInterval(3)), + model: "openai/gpt-5.4", + total: (input: 1050, cached: 910, output: 105), + last: (input: 50, cached: 10, output: 5)), + ])) + + var resolvedParentBaseline = false + let parsed = CostUsageScanner.parseCodexFile( + fileURL: fileURL, + range: CostUsageScanner.CostUsageDayRange(since: day, until: day), + inheritedTotalsResolver: { parentSessionID, _ in + resolvedParentBaseline = true + #expect(parentSessionID == "inferred-parent") + return .resolved(.init(input: 1000, cached: 900, output: 100)) + }) + + let dayKey = CostUsageScanner.CostUsageDayRange.dayKey(from: day) + let model = CostUsagePricing.normalizeCodexModel("openai/gpt-5.4") + #expect(parsed.days[dayKey]?[model] == [50, 10, 5]) + #expect(parsed.forkedFromId == "inferred-parent") + #expect(parsed.dependsOnParentTotals) + #expect(resolvedParentBaseline) + } + + @Test + func `oversized ancestor metadata remains conservative copied-prefix evidence`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 7, day: 16) + let timestamp = env.isoString(for: day) + let opening = try env.jsonl([ + [ + "type": "session_meta", + "timestamp": timestamp, + "payload": [ + "id": "oversized-child", + "source": ["subagent": ["thread_spawn": [:]]], + ], + ], + self.tokenCount( + timestamp: env.isoString(for: day.addingTimeInterval(1)), + model: "openai/gpt-5.3", + total: (input: 1000, cached: 900, output: 100), + last: (input: 50, cached: 10, output: 5)), + ]) + let oversizedAncestor = "{\"type\":\"session_meta\",\"timestamp\":\"\(timestamp)\"," + + "\"payload\":{\"id\":\"oversized-parent\",\"padding\":\"" + + String(repeating: "x", count: 300_000) + "\"}}\n" + let tail = try env.jsonl([ + self.tokenCount( + timestamp: env.isoString(for: day.addingTimeInterval(2)), + model: "openai/gpt-5.4", + total: (input: 1050, cached: 910, output: 105), + last: (input: 50, cached: 10, output: 5)), + ]) + let fileURL = try env.writeCodexSessionFile( + day: day, + filename: "rollout-\(timestamp)-oversized-ancestor.jsonl", + contents: opening + oversizedAncestor + tail) + + var resolvedParentBaseline = false + let parsed = CostUsageScanner.parseCodexFile( + fileURL: fileURL, + range: CostUsageScanner.CostUsageDayRange(since: day, until: day), + inheritedTotalsResolver: { parentSessionID, _ in + resolvedParentBaseline = true + #expect(parentSessionID == "oversized-parent") + return .resolved(.init(input: 1000, cached: 900, output: 100)) + }) + + let dayKey = CostUsageScanner.CostUsageDayRange.dayKey(from: day) + let model = CostUsagePricing.normalizeCodexModel("openai/gpt-5.4") + #expect(parsed.days[dayKey]?[model] == [50, 10, 5]) + #expect(parsed.forkedFromId == "oversized-parent") + #expect(parsed.dependsOnParentTotals) + #expect(resolvedParentBaseline) + } + + @Test + func `invalid timestamp suffix markers preserve parent dependency on both parser paths`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 7, day: 16) + let timestamp = env.isoString(for: day) + let contents = try env.jsonl([ + [ + "type": "session_meta", + "timestamp": timestamp, + "payload": [ + "id": "invalid-marker-child", + "source": ["subagent": ["thread_spawn": [:]]], + ], + ], + self.tokenCount( + timestamp: env.isoString(for: day.addingTimeInterval(1)), + model: "openai/gpt-5.3", + total: (input: 1000, cached: 900, output: 100), + last: (input: 50, cached: 10, output: 5)), + [ + "type": "session_meta", + "timestamp": timestamp, + "payload": ["id": "invalid-marker-parent"], + ], + [ + "type": "turn_context", + "payload": ["model": "openai/gpt-5.4"], + ], + [ + "type": "inter_agent_communication_metadata", + "payload": ["trigger_turn": true], + ], + self.tokenCount( + timestamp: env.isoString(for: day.addingTimeInterval(2)), + model: "openai/gpt-5.4", + total: (input: 1050, cached: 910, output: 105), + last: (input: 50, cached: 10, output: 5)), + ]) + let fastFileURL = try env.writeCodexSessionFile( + day: day, + filename: "rollout-\(timestamp)-invalid-marker.jsonl", + contents: contents) + let fallbackFileURL = try env.writeCodexSessionFile( + day: day, + filename: "rollout-\(timestamp)-invalid-marker-fallback.jsonl", + contents: contents + .replacingOccurrences(of: "invalid-marker-child", with: "invalid-marker-child-fallback") + .replacingOccurrences(of: "\"type\":\"turn_context\"", with: "\"ty\\u0070e\":\"turn_context\"") + .replacingOccurrences( + of: "\"type\":\"inter_agent_communication_metadata\"", + with: "\"ty\\u0070e\":\"inter_agent_communication_metadata\"")) + + let dayKey = CostUsageScanner.CostUsageDayRange.dayKey(from: day) + let model = CostUsagePricing.normalizeCodexModel("openai/gpt-5.4") + for fileURL in [fastFileURL, fallbackFileURL] { + var resolvedParentBaseline = false + let parsed = CostUsageScanner.parseCodexFile( + fileURL: fileURL, + range: CostUsageScanner.CostUsageDayRange(since: day, until: day), + inheritedTotalsResolver: { parentSessionID, _ in + resolvedParentBaseline = true + #expect(parentSessionID == "invalid-marker-parent") + return .resolved(.init(input: 1000, cached: 900, output: 100)) + }) + #expect(parsed.days[dayKey]?[model] == [50, 10, 5]) + #expect(parsed.dependsOnParentTotals) + #expect(resolvedParentBaseline) + } + } + + @Test + func `oversized invalid suffix markers preserve parent dependency`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 7, day: 16) + let timestamp = env.isoString(for: day) + let opening = try env.jsonl([ + [ + "type": "session_meta", + "timestamp": timestamp, + "payload": [ + "id": "oversized-marker-child", + "source": ["subagent": ["thread_spawn": [:]]], + ], + ], + self.tokenCount( + timestamp: env.isoString(for: day.addingTimeInterval(1)), + model: "openai/gpt-5.3", + total: (input: 1000, cached: 900, output: 100), + last: (input: 50, cached: 10, output: 5)), + [ + "type": "session_meta", + "timestamp": timestamp, + "payload": ["id": "oversized-marker-parent"], + ], + ]) + let padding = String(repeating: "x", count: 300_000) + let invalidTimestamp = "{\"type\":\"turn_context\",\"timestamp\":\"invalid\"," + + "\"payload\":{\"model\":\"openai/gpt-5.4\",\"padding\":\"\(padding)\"}}\n" + let nestedType = "{\"type\":\"event_msg\",\"timestamp\":\"\(timestamp)\"," + + "\"payload\":{\"type\":\"turn_context\",\"padding\":\"\(padding)\"}}\n" + let tail = try env.jsonl([ + [ + "type": "inter_agent_communication_metadata", + "timestamp": env.isoString(for: day.addingTimeInterval(2)), + "payload": ["trigger_turn": true], + ], + self.tokenCount( + timestamp: env.isoString(for: day.addingTimeInterval(3)), + model: "openai/gpt-5.4", + total: (input: 1050, cached: 910, output: 105), + last: (input: 50, cached: 10, output: 5)), + ]) + + let files = try [invalidTimestamp, nestedType].enumerated().map { index, marker in + try env.writeCodexSessionFile( + day: day, + filename: "rollout-\(timestamp)-oversized-invalid-marker-\(index).jsonl", + contents: opening + marker + tail) + } + let dayKey = CostUsageScanner.CostUsageDayRange.dayKey(from: day) + let model = CostUsagePricing.normalizeCodexModel("openai/gpt-5.4") + for fileURL in files { + var resolvedParentBaseline = false + let parsed = CostUsageScanner.parseCodexFile( + fileURL: fileURL, + range: CostUsageScanner.CostUsageDayRange(since: day, until: day), + inheritedTotalsResolver: { parentSessionID, _ in + resolvedParentBaseline = true + #expect(parentSessionID == "oversized-marker-parent") + return .resolved(.init(input: 1000, cached: 900, output: 100)) + }) + #expect(parsed.days[dayKey]?[model] == [50, 10, 5]) + #expect(parsed.dependsOnParentTotals) + #expect(resolvedParentBaseline) + } + } + + @Test + func `idless copied prefix without a parent or local marker is suppressed`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 7, day: 16) + let timestamp = env.isoString(for: day) + let fileURL = try env.writeCodexSessionFile( + day: day, + filename: "rollout-\(timestamp)-ambiguous-prefix.jsonl", + contents: env.jsonl([ + [ + "type": "session_meta", + "timestamp": timestamp, + "payload": [ + "id": "ambiguous-child", + "source": ["subagent": ["thread_spawn": [:]]], + ], + ], + self.tokenCount( + timestamp: env.isoString(for: day.addingTimeInterval(1)), + model: "openai/gpt-5.3", + total: (input: 1000, cached: 900, output: 100)), + ["type": "session_meta", "timestamp": timestamp, "payload": [:]], + self.tokenCount( + timestamp: env.isoString(for: day.addingTimeInterval(2)), + model: "openai/gpt-5.4", + total: (input: 1050, cached: 910, output: 105)), + ])) + + let parsed = CostUsageScanner.parseCodexFile( + fileURL: fileURL, + range: CostUsageScanner.CostUsageDayRange(since: day, until: day)) + + #expect(parsed.days.isEmpty) + #expect(parsed.rows.isEmpty) + } + + @Test + func `bounded append fallback reclassifies the complete subagent rollout`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 7, day: 16) + let timestamp = env.isoString(for: day) + let fileURL = try env.writeCodexSessionFile( + day: day, + filename: "rollout-\(timestamp)-growing-subagent.jsonl", + contents: env.jsonl([ + [ + "type": "session_meta", + "timestamp": timestamp, + "payload": [ + "id": "growing-child", + "source": ["subagent": ["thread_spawn": [:]]], + ], + ], + self.turnContext(timestamp: timestamp, model: "openai/gpt-5.3"), + self.tokenCount( + timestamp: env.isoString(for: day.addingTimeInterval(1)), + model: "openai/gpt-5.3", + total: (input: 1000, cached: 900, output: 100)), + ])) + + var options = CostUsageScanner.Options( + codexSessionsRoot: env.codexSessionsRoot, + claudeProjectsRoots: nil, + cacheRoot: env.cacheRoot, + maxCodexSessionFileBytes: 4096, + maxCodexScanBytesPerRefresh: 4096) + options.refreshMinIntervalSeconds = 0 + let first = CostUsageScanner.loadDailyReport( + provider: .codex, + since: day, + until: day, + now: day, + options: options) + #expect(first.data.first?.totalTokens == 1100) + + let appended = try env.jsonl([ + ["type": "session_meta", "timestamp": timestamp, "payload": ["id": "growing-parent"]], + self.turnContext( + timestamp: env.isoString(for: day.addingTimeInterval(2)), + model: "openai/gpt-5.4"), + [ + "type": "inter_agent_communication_metadata", + "timestamp": env.isoString(for: day.addingTimeInterval(2)), + "payload": ["trigger_turn": true], + ], + self.tokenCount( + timestamp: env.isoString(for: day.addingTimeInterval(3)), + model: "openai/gpt-5.4", + total: (input: 1050, cached: 910, output: 105), + last: (input: 50, cached: 10, output: 5)), + ]) + let handle = try FileHandle(forWritingTo: fileURL) + try handle.seekToEnd() + try handle.write(contentsOf: Data(appended.utf8)) + try handle.close() + + let second = CostUsageScanner.loadDailyReport( + provider: .codex, + since: day, + until: day, + now: day.addingTimeInterval(1), + options: options) + #expect(second.data.first?.totalTokens == 55) + + let cache = CostUsageCacheIO.load(provider: .codex, cacheRoot: env.cacheRoot) + let usage = try #require(cache.files.values.first { $0.sessionId == "growing-child" }) + #expect(usage.sessionId == "growing-child") + #expect(usage.forkedFromId == "growing-parent") + #expect(usage.forkBaselineDependencyKey == CostUsageScanner.codexForkDependencyNotRequiredKey) + #expect(usage.codexScanComplete == true) + } + + private func turnContext(timestamp: String, model: String) -> [String: Any] { + [ + "type": "turn_context", + "timestamp": timestamp, + "payload": ["model": model], + ] + } + + private func tokenCount( + timestamp: String, + model: String, + total: Usage? = nil, + last: Usage? = nil) -> [String: Any] + { + var info: [String: Any] = ["model": model] + if let total { + info["total_token_usage"] = [ + "input_tokens": total.input, + "cached_input_tokens": total.cached, + "output_tokens": total.output, + ] + } + if let last { + info["last_token_usage"] = [ + "input_tokens": last.input, + "cached_input_tokens": last.cached, + "output_tokens": last.output, + ] + } + return [ + "type": "event_msg", + "timestamp": timestamp, + "payload": [ + "type": "token_count", + "info": info, + ], + ] + } +} diff --git a/Tests/CodexBarTests/CodexSubagentRolloutShapeTests.swift b/Tests/CodexBarTests/CodexSubagentRolloutShapeTests.swift new file mode 100644 index 0000000000..f7e8cdab69 --- /dev/null +++ b/Tests/CodexBarTests/CodexSubagentRolloutShapeTests.swift @@ -0,0 +1,278 @@ +import Testing +@testable import CodexBarCore + +struct CodexSubagentRolloutShapeTests { + @Test + func `single leaf metadata means an independent counter`() { + let shape = CostUsageScanner.CodexSubagentRolloutShape.classify( + leafSessionID: "leaf", + observedSessionIDs: ["leaf"]) + + #expect(shape.counterSemantics == .independent) + } + + @Test + func `single leaf first turn marker proposes a parent-confirmed suffix`() throws { + let baseline = CostUsageCodexTotals(input: 1000, cached: 900, output: 100) + let shape = CostUsageScanner.CodexSubagentRolloutShape.classify( + leafSessionID: "leaf", + observations: [ + .init(lineIndex: 0, kind: .sessionMetadata(id: "leaf")), + .init(lineIndex: 1, kind: .tokenCount(total: baseline, last: baseline)), + .init(lineIndex: 3, kind: .turnContext), + .init(lineIndex: 4, kind: .interAgentCommunication(triggerTurn: true)), + ], + hasExplicitParent: true) + + let candidate = try #require(shape.ownedSuffixCandidate) + #expect(shape.counterSemantics == .independent) + #expect(shape.ownedSuffix == nil) + #expect(candidate.ownedSuffix.startLineIndex == 3) + #expect(candidate.parentTotalsAtBoundary.input == baseline.input) + #expect(candidate.parentTotalsAtBoundary.cached == baseline.cached) + #expect(candidate.parentTotalsAtBoundary.output == baseline.output) + } + + @Test + func `single leaf marker without an explicit parent stays independent`() { + let baseline = CostUsageCodexTotals(input: 1000, cached: 900, output: 100) + let shape = CostUsageScanner.CodexSubagentRolloutShape.classify( + leafSessionID: "leaf", + observations: [ + .init(lineIndex: 0, kind: .sessionMetadata(id: "leaf")), + .init(lineIndex: 1, kind: .tokenCount(total: baseline, last: baseline)), + .init(lineIndex: 3, kind: .turnContext), + .init(lineIndex: 4, kind: .interAgentCommunication(triggerTurn: true)), + ]) + + #expect(shape.counterSemantics == .independent) + #expect(shape.ownedSuffixCandidate == nil) + } + + @Test + func `later marker after an earlier turn does not propose a suffix`() { + let baseline = CostUsageCodexTotals(input: 1000, cached: 900, output: 100) + let shape = CostUsageScanner.CodexSubagentRolloutShape.classify( + leafSessionID: "leaf", + observations: [ + .init(lineIndex: 0, kind: .sessionMetadata(id: "leaf")), + .init(lineIndex: 1, kind: .turnContext), + .init(lineIndex: 2, kind: .tokenCount(total: baseline, last: baseline)), + .init(lineIndex: 3, kind: .turnContext), + .init(lineIndex: 4, kind: .interAgentCommunication(triggerTurn: true)), + ], + hasExplicitParent: true) + + #expect(shape.counterSemantics == .independent) + #expect(shape.ownedSuffixCandidate == nil) + } + + @Test + func `zero pre-turn totals do not propose a suffix`() { + let zero = CostUsageCodexTotals(input: 0, cached: 0, output: 0) + let shape = CostUsageScanner.CodexSubagentRolloutShape.classify( + leafSessionID: "leaf", + observations: [ + .init(lineIndex: 0, kind: .sessionMetadata(id: "leaf")), + .init(lineIndex: 1, kind: .tokenCount(total: zero, last: zero)), + .init(lineIndex: 2, kind: .turnContext), + .init(lineIndex: 3, kind: .interAgentCommunication(triggerTurn: true)), + ], + hasExplicitParent: true) + + #expect(shape.ownedSuffixCandidate == nil) + } + + @Test + func `nonadjacent first-turn trigger does not propose a suffix`() { + let baseline = CostUsageCodexTotals(input: 1000, cached: 900, output: 100) + let shape = CostUsageScanner.CodexSubagentRolloutShape.classify( + leafSessionID: "leaf", + observations: [ + .init(lineIndex: 0, kind: .sessionMetadata(id: "leaf")), + .init(lineIndex: 1, kind: .tokenCount(total: baseline, last: baseline)), + .init(lineIndex: 2, kind: .turnContext), + .init(lineIndex: 4, kind: .interAgentCommunication(triggerTurn: true)), + ], + hasExplicitParent: true) + + #expect(shape.ownedSuffixCandidate == nil) + } + + @Test + func `embedded ancestor metadata means a copied prefix`() { + let shape = CostUsageScanner.CodexSubagentRolloutShape.classify( + leafSessionID: "leaf", + observedSessionIDs: ["leaf", "parent"]) + + #expect(shape.counterSemantics == .copiedPrefix) + #expect(shape.inferredParentSessionID == "parent") + } + + @Test + func `multiple ancestors do not infer an ambiguous parent`() { + let shape = CostUsageScanner.CodexSubagentRolloutShape.classify( + leafSessionID: "leaf", + observedSessionIDs: ["leaf", "parent", "grandparent"]) + + #expect(shape.counterSemantics == .copiedPrefix) + #expect(shape.inferredParentSessionID == nil) + } + + @Test + func `repeated leaf metadata does not invent an ancestor`() { + let shape = CostUsageScanner.CodexSubagentRolloutShape.classify( + leafSessionID: "leaf", + observedSessionIDs: ["leaf", "leaf"]) + + #expect(shape.counterSemantics == .independent) + } + + @Test + func `unknown leaf followed by a concrete metadata id is copied`() { + let shape = CostUsageScanner.CodexSubagentRolloutShape.classify( + leafSessionID: nil, + observedSessionIDs: [nil, "parent"]) + + #expect(shape.counterSemantics == .copiedPrefix) + #expect(shape.inferredParentSessionID == "parent") + } + + @Test + func `idless metadata after a known leaf is conservatively copied`() { + let shape = CostUsageScanner.CodexSubagentRolloutShape.classify( + leafSessionID: "leaf", + observedSessionIDs: ["leaf", nil]) + + #expect(shape.counterSemantics == .copiedPrefix) + #expect(shape.inferredParentSessionID == nil) + } + + @Test + func `only concrete normalized ids identify the same leaf`() { + #expect(CostUsageScanner.CodexSubagentRolloutShape.sameConcreteSessionID(" leaf ", "leaf")) + #expect(!CostUsageScanner.CodexSubagentRolloutShape.sameConcreteSessionID(nil, nil)) + #expect(!CostUsageScanner.CodexSubagentRolloutShape.sameConcreteSessionID("", "")) + } + + @Test + func `adjacent trigger after the final ancestor opens an owned suffix`() throws { + let baseline = CostUsageCodexTotals(input: 1000, cached: 900, output: 100) + let shape = CostUsageScanner.CodexSubagentRolloutShape.classify( + leafSessionID: "leaf", + observations: [ + .init(lineIndex: 0, kind: .sessionMetadata(id: "leaf")), + .init(lineIndex: 4, kind: .tokenCount(total: baseline, last: nil)), + .init(lineIndex: 5, kind: .sessionMetadata(id: "parent")), + .init(lineIndex: 8, kind: .turnContext), + .init(lineIndex: 9, kind: .interAgentCommunication(triggerTurn: true)), + ]) + + let suffix = try #require(shape.ownedSuffix) + #expect(shape.counterSemantics == .copiedPrefix) + #expect(suffix.startLineIndex == 8) + #expect(suffix.rawTotalsBaseline.input == 1000) + #expect(suffix.rawTotalsBaseline.cached == 900) + #expect(suffix.rawTotalsBaseline.output == 100) + } + + @Test + func `nonadjacent trigger does not invent an owned suffix`() { + let shape = CostUsageScanner.CodexSubagentRolloutShape.classify( + leafSessionID: "leaf", + observations: [ + .init( + lineIndex: 0, + kind: .tokenCount( + total: .init(input: 1000, cached: 900, output: 100), + last: nil)), + .init(lineIndex: 1, kind: .sessionMetadata(id: "parent")), + .init(lineIndex: 3, kind: .turnContext), + .init(lineIndex: 5, kind: .interAgentCommunication(triggerTurn: true)), + ]) + + #expect(shape.counterSemantics == .copiedPrefix) + #expect(shape.ownedSuffix == nil) + } + + @Test + func `copied prefix can restart only with strong reset evidence`() throws { + let shape = CostUsageScanner.CodexSubagentRolloutShape.classify( + leafSessionID: "leaf", + observations: [ + .init(lineIndex: 0, kind: .sessionMetadata(id: "leaf")), + .init( + lineIndex: 2, + kind: .tokenCount( + total: .init(input: 1000, cached: 900, output: 100), + last: nil)), + .init(lineIndex: 3, kind: .sessionMetadata(id: "parent")), + .init(lineIndex: 5, kind: .turnContext), + .init(lineIndex: 6, kind: .interAgentCommunication(triggerTurn: true)), + .init( + lineIndex: 7, + kind: .tokenCount( + total: .init(input: 50, cached: 10, output: 5), + last: .init(input: 50, cached: 10, output: 5))), + ]) + + let suffix = try #require(shape.ownedSuffix) + #expect(suffix.rawTotalsBaseline.input == 0) + #expect(suffix.rawTotalsBaseline.cached == 0) + #expect(suffix.rawTotalsBaseline.output == 0) + } + + @Test + func `first valid leaf marker owns later leaf turns`() throws { + let firstBaseline = CostUsageCodexTotals(input: 1000, cached: 900, output: 100) + let shape = CostUsageScanner.CodexSubagentRolloutShape.classify( + leafSessionID: "leaf", + observations: [ + .init(lineIndex: 0, kind: .sessionMetadata(id: "leaf")), + .init(lineIndex: 1, kind: .sessionMetadata(id: "parent")), + .init(lineIndex: 2, kind: .tokenCount(total: firstBaseline, last: nil)), + .init(lineIndex: 4, kind: .turnContext), + .init(lineIndex: 5, kind: .interAgentCommunication(triggerTurn: true)), + .init( + lineIndex: 6, + kind: .tokenCount( + total: .init(input: 1050, cached: 910, output: 105), + last: nil)), + .init(lineIndex: 8, kind: .turnContext), + .init(lineIndex: 9, kind: .interAgentCommunication(triggerTurn: true)), + ]) + + let suffix = try #require(shape.ownedSuffix) + #expect(suffix.startLineIndex == 4) + #expect(suffix.rawTotalsBaseline.input == firstBaseline.input) + } + + @Test + func `later ancestor invalidates a tentative marker`() throws { + let shape = CostUsageScanner.CodexSubagentRolloutShape.classify( + leafSessionID: "leaf", + observations: [ + .init(lineIndex: 0, kind: .sessionMetadata(id: "leaf")), + .init(lineIndex: 1, kind: .sessionMetadata(id: "parent")), + .init( + lineIndex: 2, + kind: .tokenCount( + total: .init(input: 1000, cached: 900, output: 100), + last: nil)), + .init(lineIndex: 3, kind: .turnContext), + .init(lineIndex: 4, kind: .interAgentCommunication(triggerTurn: true)), + .init(lineIndex: 5, kind: .sessionMetadata(id: "grandparent")), + .init( + lineIndex: 6, + kind: .tokenCount( + total: .init(input: 2000, cached: 1800, output: 200), + last: nil)), + .init(lineIndex: 8, kind: .turnContext), + .init(lineIndex: 9, kind: .interAgentCommunication(triggerTurn: true)), + ]) + + let suffix = try #require(shape.ownedSuffix) + #expect(suffix.startLineIndex == 8) + #expect(suffix.rawTotalsBaseline.input == 2000) + } +} diff --git a/Tests/CodexBarTests/CodexThreadMetadataReaderTests.swift b/Tests/CodexBarTests/CodexThreadMetadataReaderTests.swift new file mode 100644 index 0000000000..3fcb5924f2 --- /dev/null +++ b/Tests/CodexBarTests/CodexThreadMetadataReaderTests.swift @@ -0,0 +1,190 @@ +import CodexBarCore +import Foundation +#if canImport(SQLite3) +import SQLite3 +#elseif canImport(CSQLite3) +import CSQLite3 +#endif +import Testing + +#if canImport(SQLite3) || canImport(CSQLite3) +struct CodexThreadMetadataReaderTests { + @Test + func `reader loads titles and agent paths without writing to codex state`() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("codex-thread-metadata-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let databaseURL = root.appendingPathComponent("state_5.sqlite") + try Self.createDatabase(at: databaseURL) + + let metadata = CodexThreadMetadataReader(databaseURL: databaseURL).metadata(for: ["main", "subagent"]) + + #expect(metadata["main"] == CodexThreadMetadata(title: "Fix Claude reauthorization", agentPath: nil)) + #expect(metadata["subagent"] == CodexThreadMetadata( + title: "Inherited parent title", + agentPath: "/root/neon_patch_review2")) + } + + @Test + func `reader honors configured sqlite home before the environment`() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("codex-thread-metadata-config-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let codexHome = root.appendingPathComponent("codex", isDirectory: true) + let configuredHome = root.appendingPathComponent("configured-sqlite", isDirectory: true) + let environmentHome = root.appendingPathComponent("environment-sqlite", isDirectory: true) + try FileManager.default.createDirectory(at: codexHome, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: configuredHome, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: environmentHome, withIntermediateDirectories: true) + let config = """ + developer_instructions = ""\" + [not_a_real_table] + sqlite_home = '/not/the/real/path' + ""\" + sqlite_home = '\(configuredHome.path)' + + """ + try config + .write(to: codexHome.appendingPathComponent("config.toml"), atomically: true, encoding: .utf8) + let databaseURL = configuredHome.appendingPathComponent("state_9.sqlite") + try Self.createDatabase(at: databaseURL) + + let reader = CodexThreadMetadataReader( + codexHomeDirectory: codexHome, + environment: ["CODEX_SQLITE_HOME": environmentHome.path]) + + #expect(reader.databaseURL.resolvingSymlinksInPath() == databaseURL.resolvingSymlinksInPath()) + #expect(reader.metadata(for: ["main"])["main"]?.title == "Fix Claude reauthorization") + } + + @Test + func `reader accepts quoted sqlite key and multiline path`() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("codex-thread-metadata-multiline-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let codexHome = root.appendingPathComponent("codex", isDirectory: true) + let sqliteHome = root.appendingPathComponent("configured-sqlite", isDirectory: true) + try FileManager.default.createDirectory(at: codexHome, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: sqliteHome, withIntermediateDirectories: true) + let escapedPath = sqliteHome.path.replacingOccurrences(of: "configured", with: "config\\u0075red") + try "\"sqlite_home\" = \"\"\"\\\n \(escapedPath)\"\"\"\n" + .write(to: codexHome.appendingPathComponent("config.toml"), atomically: true, encoding: .utf8) + let databaseURL = sqliteHome.appendingPathComponent("state_8.sqlite") + try Self.createDatabase(at: databaseURL) + + let reader = CodexThreadMetadataReader(codexHomeDirectory: codexHome) + + #expect(reader.databaseURL.resolvingSymlinksInPath() == databaseURL.resolvingSymlinksInPath()) + } + + @Test + func `reader preserves parent traversal after a symlink`() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("codex-thread-metadata-symlink-\(UUID().uuidString)", isDirectory: true) + let target = root.appendingPathComponent("target/project", isDirectory: true) + let state = root.appendingPathComponent("target/state", isDirectory: true) + let link = root.appendingPathComponent("project-link", isDirectory: true) + try FileManager.default.createDirectory(at: target, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: state, withIntermediateDirectories: true) + try FileManager.default.createSymbolicLink(at: link, withDestinationURL: target) + defer { try? FileManager.default.removeItem(at: root) } + let databaseURL = state.appendingPathComponent("state_6.sqlite") + try Self.createDatabase(at: databaseURL) + + let reader = CodexThreadMetadataReader( + codexHomeDirectory: root.appendingPathComponent("codex", isDirectory: true), + environment: ["CODEX_SQLITE_HOME": link.path + "/../state"]) + + #expect(reader.databaseURL.resolvingSymlinksInPath() == databaseURL.resolvingSymlinksInPath()) + } + + @Test + func `reader resolves relative sqlite environment against the session cwd`() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("codex-thread-metadata-env-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let codexHome = root.appendingPathComponent("codex", isDirectory: true) + let workingDirectory = root.appendingPathComponent("project", isDirectory: true) + let sqliteHome = workingDirectory.appendingPathComponent("relative-sqlite", isDirectory: true) + try FileManager.default.createDirectory(at: codexHome, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: sqliteHome, withIntermediateDirectories: true) + let databaseURL = sqliteHome.appendingPathComponent("state_7.sqlite") + try Self.createDatabase(at: databaseURL) + + let reader = CodexThreadMetadataReader( + codexHomeDirectory: codexHome, + environment: ["CODEX_SQLITE_HOME": "relative-sqlite"], + resolvedWorkingDirectory: workingDirectory) + + #expect(reader.databaseURL.resolvingSymlinksInPath() == databaseURL.resolvingSymlinksInPath()) + } + + @Test + func `reader prefers the latest explicit thread name over the sqlite title`() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("codex-thread-metadata-name-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + try Self.createDatabase(at: root.appendingPathComponent("state_5.sqlite")) + let index = """ + {"id":"main","thread_name":"Initial name","updated_at":"2026-01-01T00:00:00Z"} + not-json + {"id":"main","thread_name":"Chosen name","updated_at":"2026-01-02T00:00:00Z"} + {"id":"other","thread_name":"Other name","updated_at":"2026-01-03T00:00:00Z"} + + """ + try index.write( + to: root.appendingPathComponent("session_index.jsonl"), + atomically: true, + encoding: .utf8) + + let metadata = CodexThreadMetadataReader(codexHomeDirectory: root).metadata(for: ["main", "subagent"]) + + #expect(metadata["main"]?.title == "Chosen name") + #expect(metadata["subagent"]?.title == "Inherited parent title") + #expect(metadata["subagent"]?.agentPath == "/root/neon_patch_review2") + } + + @Test + func `reader returns an explicit thread name without sqlite state`() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("codex-thread-metadata-index-only-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + try "{\"id\":\"main\",\"thread_name\":\"Chosen name\",\"updated_at\":\"now\"}\n" + .write( + to: root.appendingPathComponent("session_index.jsonl"), + atomically: true, + encoding: .utf8) + + let metadata = CodexThreadMetadataReader(codexHomeDirectory: root).metadata(for: ["main"]) + + #expect(metadata["main"] == CodexThreadMetadata(title: "Chosen name", agentPath: nil)) + } + + private static func createDatabase(at url: URL) throws { + var database: OpaquePointer? + guard sqlite3_open(url.path, &database) == SQLITE_OK, let database else { + throw SQLiteError.open + } + defer { sqlite3_close(database) } + let sql = """ + CREATE TABLE threads (id TEXT PRIMARY KEY, title TEXT, agent_path TEXT); + INSERT INTO threads VALUES ('main', 'Fix Claude reauthorization', NULL); + INSERT INTO threads VALUES ('subagent', 'Inherited parent title', '/root/neon_patch_review2'); + """ + guard sqlite3_exec(database, sql, nil, nil, nil) == SQLITE_OK else { + throw SQLiteError.exec + } + } + + private enum SQLiteError: Error { + case open + case exec + } +} +#endif diff --git a/Tests/CodexBarTests/CodexUsageFetcherFallbackTests.swift b/Tests/CodexBarTests/CodexUsageFetcherFallbackTests.swift index 4ce184b6c8..e61d9b735b 100644 --- a/Tests/CodexBarTests/CodexUsageFetcherFallbackTests.swift +++ b/Tests/CodexBarTests/CodexUsageFetcherFallbackTests.swift @@ -156,7 +156,7 @@ struct CodexUsageFetcherFallbackTests { let fetcher = UsageFetcher( environment: ["CODEX_CLI_PATH": stubCLIPath], - initializeTimeoutSeconds: 2.0, + initializeTimeoutSeconds: 20.0, requestTimeoutSeconds: 0.2) let started = Date() @@ -174,7 +174,7 @@ struct CodexUsageFetcherFallbackTests { } let elapsed = Date().timeIntervalSince(started) - #expect(elapsed < 3.0, "Hung RPC request must fail fast, took \(elapsed)s") + #expect(elapsed < 5.0, "Hung RPC request must fail fast, took \(elapsed)s") } @Test @@ -184,7 +184,7 @@ struct CodexUsageFetcherFallbackTests { let fetcher = UsageFetcher( environment: ["CODEX_CLI_PATH": stubCLIPath], - initializeTimeoutSeconds: 2.0, + initializeTimeoutSeconds: 20.0, requestTimeoutSeconds: 0.2) for attempt in 1...2 { @@ -202,7 +202,7 @@ struct CodexUsageFetcherFallbackTests { } let elapsed = Date().timeIntervalSince(started) - #expect(elapsed < 3.0, "Hung RPC request \(attempt) must fail fast, took \(elapsed)s") + #expect(elapsed < 5.0, "Hung RPC request \(attempt) must fail fast, took \(elapsed)s") } } diff --git a/Tests/CodexBarTests/CodexUsageOwnerRaceTests.swift b/Tests/CodexBarTests/CodexUsageOwnerRaceTests.swift new file mode 100644 index 0000000000..281c1eacc4 --- /dev/null +++ b/Tests/CodexBarTests/CodexUsageOwnerRaceTests.swift @@ -0,0 +1,465 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +@MainActor +extension CodexAccountScopedRefreshTests { + @Test + func `credits completion retires usage from another workspace member`() async { + let suite = "CodexUsageOwnerRaceTests-credits-first" + let settings = self.makeSettingsStore(suite: suite) + settings.refreshFrequency = .manual + settings.codexCookieSource = .off + settings._test_liveSystemCodexAccount = self.liveAccount( + email: "member-a@example.com", + identity: .providerAccount(id: "shared-workspace")) + defer { settings._test_liveSystemCodexAccount = nil } + + let now = Date() + let prior = self.codexWeeklySnapshot( + email: "member-a@example.com", + weeklyUsedPercent: 72, + weeklyReset: now.addingTimeInterval(2 * 24 * 60 * 60), + updatedAt: now.addingTimeInterval(-60)) + let store = self.makeCodexWeeklyPublicationStore(settings: settings, suite: suite) + _ = await self.seedCodexWeeklyPublicationState( + store: store, + settings: settings, + snapshot: prior, + error: nil) + + settings._test_liveSystemCodexAccount = self.liveAccount( + email: "member-b@example.com", + identity: .providerAccount(id: "shared-workspace")) + store._test_codexCreditsLoaderOverride = { self.credits(remaining: 23) } + defer { store._test_codexCreditsLoaderOverride = nil } + + await store.refreshCreditsIfNeeded() + + #expect(store.snapshots[.codex] == nil) + #expect(store.lastKnownResetSnapshots[.codex] == nil) + #expect(store.lastCodexUsagePublicationGuard == nil) + #expect(store.lastCodexAccountScopedRefreshGuard?.accountKey == "member-b@example.com") + #expect(store.credits?.remaining == 23) + + let nextReset = now.addingTimeInterval(9 * 24 * 60 * 60) + let loader = SequencedCodexSnapshotLoader(steps: [ + .success(self.codexWeeklySnapshot( + email: "member-b@example.com", + weeklyUsedPercent: 0.2, + weeklyReset: nextReset, + updatedAt: now.addingTimeInterval(-30))), + .failure("confirmation unavailable"), + ]) + self.installContextualCodexProvider(on: store) { _ in try await loader.load() } + + await store.refreshProvider(.codex, allowDisabled: true) + + #expect(await loader.callCount == 2) + #expect(store.snapshots[.codex] == nil) + #expect(store.lastKnownResetSnapshots[.codex] == nil) + #expect(store.credits?.remaining == 23) + } + + @Test + func `dashboard cleanup retires cli usage from another workspace member`() async { + let suite = "CodexUsageOwnerRaceTests-dashboard-cleanup" + let settings = self.makeSettingsStore(suite: suite) + settings.refreshFrequency = .manual + settings.codexCookieSource = .auto + settings._test_liveSystemCodexAccount = self.liveAccount( + email: "member-a@example.com", + identity: .providerAccount(id: "shared-workspace")) + defer { settings._test_liveSystemCodexAccount = nil } + + let prior = self.codexWeeklySnapshot( + email: "member-a@example.com", + weeklyUsedPercent: 72, + weeklyReset: Date().addingTimeInterval(2 * 24 * 60 * 60), + updatedAt: Date().addingTimeInterval(-60)) + let store = self.makeCodexWeeklyPublicationStore(settings: settings, suite: suite) + _ = await self.seedCodexWeeklyPublicationState( + store: store, + settings: settings, + snapshot: prior, + error: nil) + + settings._test_liveSystemCodexAccount = self.liveAccount( + email: "member-b@example.com", + identity: .providerAccount(id: "shared-workspace")) + await store.applyOpenAIDashboard( + self.dashboard(email: "member-a@example.com", creditsRemaining: 8, usedPercent: 40), + targetEmail: "member-b@example.com") + + #expect(store.snapshots[.codex] == nil) + #expect(store.lastKnownResetSnapshots[.codex] == nil) + #expect(store.lastCodexUsagePublicationGuard == nil) + #expect(store.lastCodexAccountScopedRefreshGuard?.accountKey == "member-b@example.com") + } + + @Test + func `stale usage rejection preserves newer owner credits`() async { + let suite = "CodexUsageOwnerRaceTests-stale-usage-new-credits" + let settings = self.makeSettingsStore(suite: suite) + settings.refreshFrequency = .manual + settings.codexCookieSource = .off + settings._test_liveSystemCodexAccount = self.liveAccount( + email: "owner-a@example.com", + identity: .providerAccount(id: "owner-a")) + defer { settings._test_liveSystemCodexAccount = nil } + + let store = self.makeCodexWeeklyPublicationStore(settings: settings, suite: suite) + let prior = self.codexWeeklySnapshot( + email: "owner-a@example.com", + weeklyUsedPercent: 61, + weeklyReset: Date().addingTimeInterval(2 * 24 * 60 * 60), + updatedAt: Date().addingTimeInterval(-60)) + _ = await self.seedCodexWeeklyPublicationState( + store: store, + settings: settings, + snapshot: prior, + error: nil) + let blocker = BlockingCodexFetchStrategy() + self.installBlockingCodexProvider(on: store, blocker: blocker) + + let refreshTask = Task { await store.refreshProvider(.codex, allowDisabled: true) } + await blocker.waitUntilStarted() + settings._test_liveSystemCodexAccount = self.liveAccount( + email: "owner-b@example.com", + identity: .providerAccount(id: "owner-b")) + store._test_codexCreditsLoaderOverride = { self.credits(remaining: 31) } + defer { store._test_codexCreditsLoaderOverride = nil } + await store.refreshCreditsIfNeeded() + #expect(store.credits?.remaining == 31) + #expect(store.lastCodexAccountScopedRefreshGuard?.accountKey == "owner-b@example.com") + + await blocker.resume(with: .success(self.codexSnapshot(email: "owner-a@example.com", usedPercent: 62))) + await refreshTask.value + + #expect(store.snapshots[.codex] == nil) + #expect(store.lastKnownResetSnapshots[.codex] == nil) + #expect(store.credits?.remaining == 31) + #expect(store.lastCodexAccountScopedRefreshGuard?.accountKey == "owner-b@example.com") + } + + @Test + func `stacked stale selection preserves newer selected account credits`() async throws { + let suite = "CodexUsageOwnerRaceTests-stacked-stale-selection" + let settings = self.makeSettingsStore(suite: suite) + settings.refreshFrequency = .manual + settings.codexCookieSource = .off + settings.multiAccountMenuLayout = .stacked + settings._test_liveSystemCodexAccount = self.liveAccount( + email: "stacked-a@example.com", + identity: .providerAccount(id: "stacked-owner-a")) + let managedID = try #require(UUID(uuidString: "AAAAAAAA-BBBB-CCCC-DDDD-515151515151")) + let managedHome = FileManager.default.temporaryDirectory + .appendingPathComponent("codex-stacked-owner-race-\(UUID().uuidString)", isDirectory: true) + let managedAccount = try self.makeManagedCodexWeeklyPublicationAccount( + id: managedID, + email: "stacked-b@example.com", + workspaceID: "stacked-owner-b", + workspaceLabel: "Team B", + homeURL: managedHome) + let accountStoreURL = try self.makeManagedAccountStoreURL(accounts: [managedAccount]) + defer { + settings._test_liveSystemCodexAccount = nil + settings._test_managedCodexAccountStoreURL = nil + try? FileManager.default.removeItem(at: accountStoreURL) + try? FileManager.default.removeItem(at: managedHome) + } + settings._test_managedCodexAccountStoreURL = accountStoreURL + settings.codexActiveSource = .liveSystem + + let now = Date() + let prior = self.codexWeeklySnapshot( + email: "stacked-a@example.com", + weeklyUsedPercent: 61, + weeklyReset: now.addingTimeInterval(2 * 24 * 60 * 60), + updatedAt: now.addingTimeInterval(-60)) + let store = self.makeCodexWeeklyPublicationStore(settings: settings, suite: suite) + _ = await self.seedCodexWeeklyPublicationState( + store: store, + settings: settings, + snapshot: prior, + error: nil) + let blocker = BlockingCodexFetchStrategy() + let managedHomePath = managedHome.path + let managedSnapshot = self.codexSnapshot(email: "stacked-b@example.com", usedPercent: 33) + self.installContextualCodexProvider(on: store) { context in + if context.env["CODEX_HOME"] == managedHomePath { + return managedSnapshot + } + return try await blocker.awaitResult() + } + + let refreshTask = Task { await store.refreshCodexVisibleAccountsForMenu() } + await blocker.waitUntilStarted() + settings.codexActiveSource = .managedAccount(id: managedID) + store._test_codexCreditsLoaderOverride = { self.credits(remaining: 37) } + defer { store._test_codexCreditsLoaderOverride = nil } + await store.refreshCreditsIfNeeded() + #expect(store.credits?.remaining == 37) + #expect(store.lastCodexAccountScopedRefreshGuard?.accountKey == "stacked-b@example.com") + + await blocker.resume(with: .success(self.codexSnapshot(email: "stacked-a@example.com", usedPercent: 62))) + await refreshTask.value + + #expect(store.snapshots[.codex] == nil) + #expect(store.lastKnownResetSnapshots[.codex] == nil) + #expect(store.credits?.remaining == 37) + #expect(store.lastCodexAccountScopedRefreshGuard?.accountKey == "stacked-b@example.com") + } + + @Test + func `in flight success retires prior usage after owner switch`() async { + let suite = "CodexUsageOwnerRaceTests-in-flight-success" + let settings = self.makeSettingsStore(suite: suite) + settings.refreshFrequency = .manual + settings.codexCookieSource = .off + settings._test_liveSystemCodexAccount = self.liveAccount( + email: "owner-a@example.com", + identity: .providerAccount(id: "owner-a")) + defer { settings._test_liveSystemCodexAccount = nil } + + let store = self.makeCodexWeeklyPublicationStore(settings: settings, suite: suite) + let prior = self.codexWeeklySnapshot( + email: "owner-a@example.com", + weeklyUsedPercent: 61, + weeklyReset: Date().addingTimeInterval(2 * 24 * 60 * 60), + updatedAt: Date().addingTimeInterval(-60)) + _ = await self.seedCodexWeeklyPublicationState( + store: store, + settings: settings, + snapshot: prior, + error: nil) + let blocker = BlockingCodexFetchStrategy() + self.installBlockingCodexProvider(on: store, blocker: blocker) + + let refreshTask = Task { await store.refreshProvider(.codex, allowDisabled: true) } + await blocker.waitUntilStarted() + settings._test_liveSystemCodexAccount = self.liveAccount( + email: "owner-b@example.com", + identity: .providerAccount(id: "owner-b")) + await blocker.resume(with: .success(self.codexSnapshot(email: "owner-a@example.com", usedPercent: 62))) + await refreshTask.value + + #expect(store.snapshots[.codex] == nil) + #expect(store.lastKnownResetSnapshots[.codex] == nil) + #expect(store.lastCodexUsagePublicationGuard == nil) + } + + @Test + func `in flight failure retires prior usage after owner switch`() async { + let suite = "CodexUsageOwnerRaceTests-in-flight-failure" + let settings = self.makeSettingsStore(suite: suite) + settings.refreshFrequency = .manual + settings.codexCookieSource = .off + settings._test_liveSystemCodexAccount = self.liveAccount( + email: "owner-a@example.com", + identity: .providerAccount(id: "owner-a")) + defer { settings._test_liveSystemCodexAccount = nil } + + let store = self.makeCodexWeeklyPublicationStore(settings: settings, suite: suite) + let prior = self.codexWeeklySnapshot( + email: "owner-a@example.com", + weeklyUsedPercent: 61, + weeklyReset: Date().addingTimeInterval(2 * 24 * 60 * 60), + updatedAt: Date().addingTimeInterval(-60)) + _ = await self.seedCodexWeeklyPublicationState( + store: store, + settings: settings, + snapshot: prior, + error: nil) + let blocker = BlockingCodexFetchStrategy() + self.installBlockingCodexProvider(on: store, blocker: blocker) + + let refreshTask = Task { await store.refreshProvider(.codex, allowDisabled: true) } + await blocker.waitUntilStarted() + settings._test_liveSystemCodexAccount = self.liveAccount( + email: "owner-b@example.com", + identity: .providerAccount(id: "owner-b")) + await blocker.resume(with: .failure(TestRefreshError(message: "old owner failure"))) + await refreshTask.value + + #expect(store.snapshots[.codex] == nil) + #expect(store.lastKnownResetSnapshots[.codex] == nil) + #expect(store.lastCodexUsagePublicationGuard == nil) + } + + @Test + func `in flight confirmation retires prior usage after owner switch`() async { + let suite = "CodexUsageOwnerRaceTests-in-flight-confirmation" + let settings = self.makeSettingsStore(suite: suite) + settings.refreshFrequency = .manual + settings.codexCookieSource = .off + settings._test_liveSystemCodexAccount = self.liveAccount( + email: "owner-a@example.com", + identity: .providerAccount(id: "owner-a")) + defer { settings._test_liveSystemCodexAccount = nil } + + let now = Date() + let priorReset = now.addingTimeInterval(2 * 24 * 60 * 60) + let nextReset = priorReset.addingTimeInterval(7 * 24 * 60 * 60) + let prior = self.codexWeeklySnapshot( + email: "owner-a@example.com", + weeklyUsedPercent: 61, + weeklyReset: priorReset, + updatedAt: now.addingTimeInterval(-60)) + let loader = SequencedCodexSnapshotLoader(steps: [ + .success(self.codexWeeklySnapshot( + email: "owner-a@example.com", + weeklyUsedPercent: 0.2, + weeklyReset: nextReset, + updatedAt: now.addingTimeInterval(-40))), + .success(self.codexWeeklySnapshot( + email: "owner-a@example.com", + weeklyUsedPercent: 60, + weeklyReset: priorReset, + updatedAt: now.addingTimeInterval(-20)), gated: true), + ]) + let store = self.makeCodexWeeklyPublicationStore(settings: settings, suite: suite) + _ = await self.seedCodexWeeklyPublicationState( + store: store, + settings: settings, + snapshot: prior, + error: nil) + self.installContextualCodexProvider(on: store) { _ in try await loader.load() } + + let refreshTask = Task { await store.refreshProvider(.codex, allowDisabled: true) } + #expect(await loader.waitUntilCallCount(2)) + settings._test_liveSystemCodexAccount = self.liveAccount( + email: "owner-b@example.com", + identity: .providerAccount(id: "owner-b")) + await loader.release(call: 2) + await refreshTask.value + + #expect(store.snapshots[.codex] == nil) + #expect(store.lastKnownResetSnapshots[.codex] == nil) + #expect(store.lastCodexUsagePublicationGuard == nil) + } + + @Test + func `unresolved live publication remains stable across the next failed refresh`() async throws { + let suite = "CodexUsageOwnerRaceTests-unresolved-continuity" + let settings = self.makeSettingsStore(suite: suite) + settings.refreshFrequency = .manual + settings.codexCookieSource = .off + settings.codexActiveSource = .liveSystem + settings._test_liveSystemCodexAccount = nil + + let store = self.makeCodexWeeklyPublicationStore(settings: settings, suite: suite) + self.installImmediateCodexProvider( + on: store, + snapshot: self.codexSnapshot(email: "discovered@example.com", usedPercent: 27)) + + await store.refreshProvider(.codex, allowDisabled: true) + + let publishedAt = try #require(store.snapshots[.codex]?.updatedAt) + #expect(store.lastCodexUsagePublicationGuard?.identity == .emailOnly( + normalizedEmail: "discovered@example.com")) + self.installFailingCodexProvider( + on: store, + error: TestRefreshError(message: "temporary failure")) + + await store.refreshProvider(.codex, allowDisabled: true) + + #expect(store.snapshots[.codex]?.updatedAt == publishedAt) + #expect(store.lastCodexUsagePublicationGuard?.accountKey == "discovered@example.com") + } + + @Test + func `fresh failure is retired before attaching credits to another owner`() async { + let suite = "CodexUsageOwnerRaceTests-fresh-failure-then-credits" + let settings = self.makeSettingsStore(suite: suite) + settings.refreshFrequency = .manual + settings.codexCookieSource = .off + settings._test_liveSystemCodexAccount = self.liveAccount( + email: "owner-a@example.com", + identity: .providerAccount(id: "owner-a")) + defer { settings._test_liveSystemCodexAccount = nil } + + let store = self.makeCodexWeeklyPublicationStore(settings: settings, suite: suite) + self.installFailingCodexProvider( + on: store, + error: TestRefreshError(message: "owner A unavailable")) + + await store.refreshProvider(.codex, allowDisabled: true) + + #expect(store.snapshots[.codex] == nil) + #expect(store.errors[.codex] == "owner A unavailable") + #expect(store.lastFetchAttempts[.codex]?.isEmpty == false) + #expect(store.lastCodexUsagePublicationGuard?.accountKey == "owner-a@example.com") + + settings._test_liveSystemCodexAccount = self.liveAccount( + email: "owner-b@example.com", + identity: .providerAccount(id: "owner-b")) + store._test_codexCreditsLoaderOverride = { self.credits(remaining: 29) } + defer { store._test_codexCreditsLoaderOverride = nil } + + await store.refreshCreditsIfNeeded() + + #expect(store.errors[.codex] == nil) + #expect(store.lastFetchAttempts[.codex] == nil) + #expect(store.lastSourceLabels[.codex] == nil) + #expect(store.credits?.remaining == 29) + #expect(store.lastCodexAccountScopedRefreshGuard?.accountKey == "owner-b@example.com") + } + + @Test + func `stacked fresh failure follows its owner across credits attachment`() async { + let suite = "CodexUsageOwnerRaceTests-stacked-failure-then-credits" + let settings = self.makeSettingsStore(suite: suite) + settings.refreshFrequency = .manual + settings.codexCookieSource = .off + settings._test_liveSystemCodexAccount = self.liveAccount( + email: "owner-a@example.com", + identity: .providerAccount(id: "owner-a")) + defer { settings._test_liveSystemCodexAccount = nil } + + let store = self.makeCodexWeeklyPublicationStore(settings: settings, suite: suite) + let ownerA = CodexVisibleAccount( + id: "live:owner-a", + email: "owner-a@example.com", + workspaceAccountID: "owner-a", + storedAccountID: nil, + selectionSource: .liveSystem, + isActive: true, + isLive: true, + canReauthenticate: false, + canRemove: false) + let failure = ProviderFetchOutcome( + result: .failure(TestRefreshError(message: "owner A unavailable")), + attempts: [ProviderFetchAttempt( + strategyID: "stacked-test", + kind: .cli, + wasAvailable: true, + errorDescription: "owner A unavailable")]) + + await store.applySelectedCodexVisibleAccountOutcome( + failure, + account: ownerA, + snapshot: nil, + sourceLabel: nil, + limitResetOwnerKey: nil) + + store._test_codexCreditsLoaderOverride = { self.credits(remaining: 19) } + defer { store._test_codexCreditsLoaderOverride = nil } + await store.refreshCreditsIfNeeded() + + #expect(store.errors[.codex] == "owner A unavailable") + #expect(store.lastFetchAttempts[.codex]?.first?.strategyID == "stacked-test") + #expect(store.credits?.remaining == 19) + + settings._test_liveSystemCodexAccount = self.liveAccount( + email: "owner-b@example.com", + identity: .providerAccount(id: "owner-b")) + await store.refreshCreditsIfNeeded() + + #expect(store.errors[.codex] == nil) + #expect(store.lastFetchAttempts[.codex] == nil) + #expect(store.credits?.remaining == 19) + #expect(store.lastCodexAccountScopedRefreshGuard?.accountKey == "owner-b@example.com") + } +} diff --git a/Tests/CodexBarTests/CodexUserFacingErrorTests.swift b/Tests/CodexBarTests/CodexUserFacingErrorTests.swift index 4fb88fc3c0..5110f11f55 100644 --- a/Tests/CodexBarTests/CodexUserFacingErrorTests.swift +++ b/Tests/CodexBarTests/CodexUserFacingErrorTests.swift @@ -84,6 +84,20 @@ struct CodexUserFacingErrorTests { "Codex usage is temporarily unavailable. Try refreshing. Cached values from 2m ago.") } + @Test + func `localized cached credits failure preserves cached suffix while sanitizing body`() { + let result = CodexBarLocalizationOverride.$appLanguage.withValue("zh-Hant") { + let store = self.makeUsageStore(suite: "CodexUserFacingErrorTests-localized-cached-credits") + store.lastCreditsError = + "Last Codex credits refresh failed: Codex connection failed: failed to fetch codex rate limits: " + + "GET https://chatgpt.com/backend-api/wham/usage failed: 500 Cached values from 2m ago." + + return store.userFacingLastCreditsError + } + + #expect(result == "Codex 使用量暫時無法取得。請嘗試重新整理。 使用 2m ago 的快取值。") + } + @Test func `cached missing codex CLI failure preserves cached suffix`() { let store = self.makeUsageStore(suite: "CodexUserFacingErrorTests-cached-missing-cli") @@ -129,6 +143,23 @@ struct CodexUserFacingErrorTests { "OpenAI web refresh timed out. Refresh OpenAI cookies and try again.") } + @Test + func `localized cached open A I web timeout preserves cached suffix`() { + let result = CodexBarLocalizationOverride.$appLanguage.withValue("zh-Hant") { + let store = self.makeUsageStore(suite: "CodexUserFacingErrorTests-localized-openai-web-timeout") + store.lastOpenAIDashboardError = + "Last OpenAI dashboard refresh failed: " + + "The operation couldn’t be completed. (NSURLErrorDomain error -1001.). " + + "Cached values from 2m ago." + + return store.userFacingLastOpenAIDashboardError + } + + #expect( + result == + "OpenAI Web 重新整理逾時。請重新整理 OpenAI Cookie 後再試一次。 使用 2m ago 的快取值。") + } + @Test func `open A I web network error becomes connection guidance`() { let store = self.makeUsageStore(suite: "CodexUserFacingErrorTests-openai-web-network") @@ -149,6 +180,21 @@ struct CodexUserFacingErrorTests { #expect(store.userFacingError(for: .claude) == "Claude probe failed with debug detail") } + @Test + func `successful provider diagnostic does not make usage stale`() { + let settings = self.makeSettingsStore(suite: "CodexUserFacingErrorTests-success-diagnostic") + let store = self.makeUsageStore(settings: settings) + store.diagnostics[.grok] = GrokStatusProbe.teamUsageUnavailableMessage + + #expect(store.userFacingError(for: .grok) == GrokStatusProbe.teamUsageUnavailableMessage) + #expect(!store.isStale(provider: .grok)) + + let pane = ProvidersPane(settings: settings, store: store) + let display = pane._test_providerErrorDisplay(for: .grok) + #expect(display?.preview == GrokStatusProbe.teamUsageUnavailableMessage) + #expect(display?.full == GrokStatusProbe.teamUsageUnavailableMessage) + } + @Test func `providers pane codex model uses sanitized values`() { let settings = self.makeSettingsStore(suite: "CodexUserFacingErrorTests-pane-model") @@ -176,6 +222,38 @@ struct CodexUserFacingErrorTests { model.creditsText == "Codex usage is temporarily unavailable. Try refreshing. Cached values from 1m ago.") } + @Test + func `menu card hides optional codex setup diagnostics kept by providers pane`() throws { + let settings = self.makeSettingsStore(suite: "CodexUserFacingErrorTests-menu-diagnostics") + let store = self.makeUsageStore(settings: settings) + store.lastCreditsError = UsageError.noRateLimitsFound.errorDescription + store.lastOpenAIDashboardError = + "No matching OpenAI web session found. Sign in to chatgpt.com, then refresh OpenAI cookies." + + let fetcher = UsageFetcher(environment: [:]) + let menuModel = try withStatusItemControllerForTesting( + store: store, + settings: settings, + fetcher: fetcher) + { controller in + try #require(controller.menuCardModel(for: .codex)) + } + let pane = ProvidersPane(settings: settings, store: store) + let settingsModel = pane._test_menuCardModel(for: .codex) + let settingsDiagnostic = pane._test_openAIWebDiagnostic(for: .codex) + let settingsInfoRows = ProviderMetricsInlineView.infoRows( + for: settingsModel, + openAIWebDiagnostic: settingsDiagnostic) + + #expect(menuModel.creditsText == nil) + #expect(menuModel.creditsHintText == nil) + #expect(settingsModel.creditsText == UsageError.noRateLimitsFound.errorDescription) + #expect(settingsModel.creditsHintText?.contains("No matching OpenAI web session found") == true) + #expect(settingsInfoRows.contains { row in + row.id == .openAIWeb && row.value.contains("No matching OpenAI web session found") + }) + } + @Test func `providers pane codex error display keeps raw full text for copy`() { let settings = self.makeSettingsStore(suite: "CodexUserFacingErrorTests-pane-error-display") @@ -222,7 +300,6 @@ struct CodexUserFacingErrorTests { minimaxCookieStore: InMemoryMiniMaxCookieStore(), minimaxAPITokenStore: InMemoryMiniMaxAPITokenStore(), kimiTokenStore: InMemoryKimiTokenStore(), - kimiK2TokenStore: InMemoryKimiK2TokenStore(), augmentCookieStore: InMemoryCookieHeaderStore(), ampCookieStore: InMemoryCookieHeaderStore(), copilotTokenStore: InMemoryCopilotTokenStore(), diff --git a/Tests/CodexBarTests/CodexVisibleAccountProjectionRuntimeIdentityTests.swift b/Tests/CodexBarTests/CodexVisibleAccountProjectionRuntimeIdentityTests.swift new file mode 100644 index 0000000000..7f978d9608 --- /dev/null +++ b/Tests/CodexBarTests/CodexVisibleAccountProjectionRuntimeIdentityTests.swift @@ -0,0 +1,31 @@ +import CodexBarCore +import Foundation +import Testing + +struct CodexVisibleAccountProjectionRuntimeIdentityTests { + @Test + func `runtime provider identity supplies missing managed workspace id`() throws { + let accountID = UUID() + let storedAccount = ManagedCodexAccount( + id: accountID, + email: "user@example.com", + workspaceAccountID: nil, + managedHomePath: "/tmp/managed-a", + createdAt: 1, + updatedAt: 2, + lastAuthenticatedAt: 3) + let snapshot = CodexAccountReconciliationSnapshot( + storedAccounts: [storedAccount], + activeStoredAccount: storedAccount, + liveSystemAccount: nil, + matchingStoredAccountForLiveSystemAccount: nil, + activeSource: .managedAccount(id: accountID), + hasUnreadableAddedAccountStore: false, + storedAccountRuntimeIdentities: [accountID: .providerAccount(id: " Account-Live ")]) + + let account = try #require(CodexVisibleAccountProjection.make(from: snapshot).visibleAccounts.first) + + #expect(account.workspaceAccountID == "account-live") + #expect(account.selectionSource == .managedAccount(id: accountID)) + } +} diff --git a/Tests/CodexBarTests/CodexWebDashboardStrategyAuthorityTests.swift b/Tests/CodexBarTests/CodexWebDashboardStrategyAuthorityTests.swift index 631b8cbf89..90d150594e 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/CodexWeeklyCapSurfaceTests.swift b/Tests/CodexBarTests/CodexWeeklyCapSurfaceTests.swift new file mode 100644 index 0000000000..9f46cb6e63 --- /dev/null +++ b/Tests/CodexBarTests/CodexWeeklyCapSurfaceTests.swift @@ -0,0 +1,219 @@ +import AppKit +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +@MainActor +@Suite(.serialized) +struct CodexWeeklyCapSurfaceTests { + @Test + func `menu card session metric shows weekly cap and reset`() throws { + let now = Date(timeIntervalSince1970: 1_800_000_000) + let metadata = try #require(ProviderDefaults.metadata[.codex]) + let weeklyReset = now.addingTimeInterval(4 * 24 * 60 * 60) + let snapshot = UsageSnapshot( + primary: RateWindow( + usedPercent: 1, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(3 * 60 * 60), + resetDescription: nil), + secondary: RateWindow( + usedPercent: 100, + windowMinutes: 10080, + resetsAt: weeklyReset, + resetDescription: nil), + updatedAt: now.addingTimeInterval(-2 * 60 * 60)) + let projection = CodexConsumerProjection.make( + surface: .liveCard, + context: CodexConsumerProjection.Context( + snapshot: snapshot, + rawUsageError: nil, + liveCredits: nil, + rawCreditsError: nil, + liveDashboard: nil, + rawDashboardError: nil, + dashboardAttachmentAuthorized: false, + dashboardRequiresLogin: false, + now: now)) + + let model = UsageMenuCardView.Model.make(.init( + provider: .codex, + metadata: metadata, + snapshot: snapshot, + codexProjection: projection, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + + let session = try #require(model.metrics.first { $0.id == "primary" }) + let weekly = try #require(model.metrics.first { $0.id == "secondary" }) + #expect(session.percent == 0) + #expect(session.resetText == weekly.resetText) + #expect(session.resetText != nil) + } + + @Test + func `primary menu bar metric and credits follow binding weekly reset`() { + let settings = SettingsStore( + configStore: testConfigStore(suiteName: "CodexWeeklyCapSurfaceTests-menu-bar"), + zaiTokenStore: NoopZaiTokenStore()) + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.selectedMenuProvider = .codex + settings.setMenuBarMetricPreference(.primary, for: .codex) + + if let codexMeta = ProviderRegistry.shared.metadata[.codex] { + settings.setProviderEnabled(provider: .codex, metadata: codexMeta, 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: .system) + defer { controller.releaseStatusItemsForTesting() } + + let now = Date(timeIntervalSince1970: 1_800_000_000) + let weeklyReset = now.addingTimeInterval(3600) + let sessionReset = now.addingTimeInterval(1800) + let snapshot = UsageSnapshot( + primary: RateWindow( + usedPercent: 1, + windowMinutes: 300, + resetsAt: sessionReset, + resetDescription: nil), + secondary: RateWindow( + usedPercent: 100, + windowMinutes: 10080, + resetsAt: weeklyReset, + resetDescription: nil), + updatedAt: now.addingTimeInterval(-7200)) + store.credits = CreditsSnapshot(remaining: 80, events: [], updatedAt: now) + + let capped = controller.menuBarMetricWindow(for: .codex, snapshot: snapshot, now: now) + let reset = controller.menuBarMetricWindow(for: .codex, snapshot: snapshot, now: weeklyReset) + let cappedCredits = controller.menuBarCreditsRemainingForIcon( + provider: .codex, + snapshot: snapshot, + now: now) + let resetCredits = controller.menuBarCreditsRemainingForIcon( + provider: .codex, + snapshot: snapshot, + now: weeklyReset) + + #expect(capped?.remainingPercent == 0) + #expect(capped?.resetsAt == weeklyReset) + #expect(reset?.remainingPercent == 99) + #expect(reset?.resetsAt == sessionReset) + #expect(cappedCredits == 80) + #expect(resetCredits == nil) + } + + @Test + func `combined menu bar modes ignore exhausted weekly lane after its reset`() throws { + let settings = SettingsStore( + configStore: testConfigStore(suiteName: "CodexWeeklyCapSurfaceTests-combined-reset"), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.selectedMenuProvider = .codex + settings.usageBarsShowUsed = false + settings.resetTimesShowAbsolute = false + settings.setMenuBarMetricPreference(.primaryAndSecondary, for: .codex) + + if let codexMeta = ProviderRegistry.shared.metadata[.codex] { + settings.setProviderEnabled(provider: .codex, metadata: codexMeta, 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: .system) + defer { controller.releaseStatusItemsForTesting() } + + let now = Date(timeIntervalSince1970: 1_800_000_000) + let sessionReset = now.addingTimeInterval(3600) + let snapshot = UsageSnapshot( + primary: RateWindow( + usedPercent: 1, + windowMinutes: 300, + resetsAt: sessionReset, + resetDescription: nil), + secondary: RateWindow( + usedPercent: 100, + windowMinutes: 10080, + resetsAt: now, + resetDescription: nil), + updatedAt: now.addingTimeInterval(-7200)) + store._setSnapshotForTesting(snapshot, provider: .codex) + + let selected = try #require(controller.menuBarMetricWindow(for: .codex, snapshot: snapshot, now: now)) + #expect(selected.remainingPercent == 99) + #expect(selected.resetsAt == sessionReset) + + settings.menuBarDisplayMode = .percent + #expect(controller.menuBarDisplayText(for: .codex, snapshot: snapshot, now: now) == "5h 99%") + settings.menuBarDisplayMode = .pace + #expect(controller.menuBarDisplayText(for: .codex, snapshot: snapshot, now: now) == "99%") + settings.menuBarDisplayMode = .both + #expect(controller.menuBarDisplayText(for: .codex, snapshot: snapshot, now: now) == "99%") + settings.menuBarDisplayMode = .resetTime + #expect(controller.menuBarDisplayText(for: .codex, snapshot: snapshot, now: now) == "↻ in 1h") + + settings.setMenuBarMetricPreference(.primary, for: .codex) + settings.menuBarDisplayMode = .percent + let expiredSessionSnapshot = UsageSnapshot( + primary: RateWindow( + usedPercent: 100, + windowMinutes: 300, + resetsAt: now, + resetDescription: nil), + secondary: RateWindow( + usedPercent: 40, + windowMinutes: 10080, + resetsAt: now.addingTimeInterval(7 * 24 * 60 * 60), + resetDescription: nil), + updatedAt: now.addingTimeInterval(-7200)) + store._setSnapshotForTesting(expiredSessionSnapshot, provider: .codex) + store.credits = CreditsSnapshot(remaining: 80, events: [], updatedAt: now) + + let resetPrimary = try #require(controller.menuBarMetricWindow( + for: .codex, + snapshot: expiredSessionSnapshot, + now: now)) + let resetIcon = IconRemainingResolver.resolvedRemaining( + snapshot: expiredSessionSnapshot, + style: .codex, + now: now) + #expect(resetPrimary.remainingPercent == 60) + #expect(controller.menuBarDisplayText(for: .codex, snapshot: expiredSessionSnapshot, now: now) == "60%") + #expect(resetIcon.primary == 60) + #expect(resetIcon.secondary == nil) + #expect(store.codexConsumerProjection(surface: .menuBar, now: now).menuBarFallback == .none) + } +} diff --git a/Tests/CodexBarTests/CodexWeeklyResetConfirmationTests.swift b/Tests/CodexBarTests/CodexWeeklyResetConfirmationTests.swift new file mode 100644 index 0000000000..98164f3f16 --- /dev/null +++ b/Tests/CodexBarTests/CodexWeeklyResetConfirmationTests.swift @@ -0,0 +1,405 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +struct CodexWeeklyResetConfirmationTests { + private let capturedAt = Date(timeIntervalSince1970: 1_800_000_000) + private let resetAt = Date(timeIntervalSince1970: 1_800_500_000) + + @Test + func `ordinary observations publish while stale initial observations preserve`() { + let previous = self.snapshot(offset: 0, weeklyUsed: 70, weeklyReset: self.resetAt) + let previousWithoutWeekly = self.snapshot(offset: 0, weeklyUsed: nil, weeklyReset: nil) + let newer = self.snapshot(offset: 1, weeklyUsed: 71, weeklyReset: self.resetAt) + let stale = self.snapshot(offset: 0, weeklyUsed: 72, weeklyReset: self.resetAt) + + #expect(CodexWeeklyResetConfirmation.initialDecision(previous: nil, initial: newer) == .publishInitial) + #expect( + CodexWeeklyResetConfirmation.initialDecision(previous: previousWithoutWeekly, initial: newer) + == .publishInitial) + #expect( + CodexWeeklyResetConfirmation.initialDecision(previous: previous, initial: newer) == .publishInitial) + #expect( + CodexWeeklyResetConfirmation.initialDecision(previous: previous, initial: stale) == .preservePrevious) + } + + @Test + func `first low observation requires matching confirmation without prior state`() { + let reset = self.resetAt.addingTimeInterval(7 * 24 * 60 * 60) + let previousWithoutWeekly = self.snapshot(offset: 0, weeklyUsed: nil, weeklyReset: nil) + let initial = self.snapshot(offset: 1, weeklyUsed: 0.2, weeklyReset: reset) + let matching = self.snapshot(offset: 2, weeklyUsed: 0.7, weeklyReset: reset.addingTimeInterval(30)) + let rebound = self.snapshot(offset: 2, weeklyUsed: 42, weeklyReset: reset) + + #expect( + CodexWeeklyResetConfirmation.initialDecision(previous: nil, initial: initial) + == .requiresConfirmation) + #expect( + CodexWeeklyResetConfirmation.initialDecision( + previous: previousWithoutWeekly, + initial: initial) + == .requiresConfirmation) + #expect( + CodexWeeklyResetConfirmation.initialDecision( + previous: previousWithoutWeekly, + initial: self.snapshot(offset: 1, weeklyUsed: 0.2, weeklyReset: nil)) + == .preservePrevious) + #expect( + CodexWeeklyResetConfirmation.confirmationDecision( + previous: nil, + initial: initial, + confirmation: matching) + == .publishConfirmation) + #expect( + CodexWeeklyResetConfirmation.confirmationDecision( + previous: nil, + initial: initial, + confirmation: rebound) + == .publishConfirmation) + } + + @Test + func `reset backfill follows semantic lanes when cached positions are swapped`() { + let sessionReset = self.resetAt.addingTimeInterval(60 * 60) + let weeklyReset = self.resetAt.addingTimeInterval(7 * 24 * 60 * 60) + let partial = UsageSnapshot( + primary: RateWindow( + usedPercent: 9, + windowMinutes: 300, + resetsAt: nil, + resetDescription: nil), + secondary: nil, + updatedAt: self.capturedAt) + let swappedCache = UsageSnapshot( + primary: RateWindow( + usedPercent: 55, + windowMinutes: 10080, + resetsAt: weeklyReset, + resetDescription: nil), + secondary: RateWindow( + usedPercent: 44, + windowMinutes: 300, + resetsAt: sessionReset, + resetDescription: nil), + updatedAt: self.capturedAt.addingTimeInterval(-1)) + + let backfilled = UsageStore.codexBackfillingResetWindows(partial, from: swappedCache) + + #expect(backfilled.primary?.usedPercent == 9) + #expect(backfilled.primary?.windowMinutes == 300) + #expect(backfilled.primary?.resetsAt == sessionReset) + #expect(backfilled.secondary?.usedPercent == 55) + #expect(backfilled.secondary?.windowMinutes == 10080) + #expect(backfilled.secondary?.resetsAt == weeklyReset) + } + + @Test + func `semantic weekly lookup handles swapped snapshot lanes`() { + let nextReset = self.resetAt.addingTimeInterval(7 * 24 * 60 * 60) + let previous = self.snapshot( + offset: 0, + weeklyUsed: 50, + weeklyReset: self.resetAt, + weeklyInPrimary: true) + let initial = self.snapshot( + offset: 1, + weeklyUsed: 0, + weeklyReset: nextReset, + weeklyInPrimary: true) + let confirmation = self.snapshot( + offset: 2, + weeklyUsed: 0.5, + weeklyReset: nextReset.addingTimeInterval(60), + weeklyInPrimary: true) + + #expect( + CodexWeeklyResetConfirmation.initialDecision(previous: previous, initial: initial) + == .requiresConfirmation) + #expect( + CodexWeeklyResetConfirmation.confirmationDecision( + previous: previous, + initial: initial, + confirmation: confirmation) + == .publishConfirmation) + } + + @Test + func `missing candidate weekly data and reset boundaries fail closed`() { + let previous = self.snapshot(offset: 0, weeklyUsed: 50, weeklyReset: self.resetAt) + let missingWeekly = self.snapshot(offset: 1, weeklyUsed: nil, weeklyReset: nil) + let initialWithoutBoundary = self.snapshot(offset: 1, weeklyUsed: 0, weeklyReset: nil) + + #expect( + CodexWeeklyResetConfirmation.initialDecision(previous: previous, initial: missingWeekly) + == .preservePrevious) + #expect( + CodexWeeklyResetConfirmation.initialDecision(previous: previous, initial: initialWithoutBoundary) + == .preservePrevious) + + let initial = self.snapshot( + offset: 1, + weeklyUsed: 0, + weeklyReset: self.resetAt.addingTimeInterval(7 * 24 * 60 * 60)) + #expect( + CodexWeeklyResetConfirmation.confirmationDecision( + previous: previous, + initial: initial, + confirmation: missingWeekly) + == .preservePrevious) + } + + @Test + func `two valid lows establish a reset when the previous boundary is unavailable`() { + let nextReset = self.resetAt.addingTimeInterval(7 * 24 * 60 * 60) + let initial = self.snapshot(offset: 1, weeklyUsed: 0.2, weeklyReset: nextReset) + let confirmation = self.snapshot( + offset: 2, + weeklyUsed: 0.7, + weeklyReset: nextReset.addingTimeInterval(30)) + let unavailablePreviousBoundaries: [Date?] = [ + nil, + self.capturedAt.addingTimeInterval(-1), + Date(timeIntervalSinceReferenceDate: .infinity), + ] + + for previousBoundary in unavailablePreviousBoundaries { + let previous = self.snapshot(offset: 0, weeklyUsed: 50, weeklyReset: previousBoundary) + #expect( + CodexWeeklyResetConfirmation.initialDecision(previous: previous, initial: initial) + == .requiresConfirmation) + #expect( + CodexWeeklyResetConfirmation.confirmationDecision( + previous: previous, + initial: initial, + confirmation: confirmation) + == .publishConfirmation) + } + } + + @Test + func `first ordinary high accepts a missing boundary but rejects explicit invalid boundaries`() { + let missingBoundary = self.snapshot(offset: 1, weeklyUsed: 42, weeklyReset: nil) + let elapsedBoundary = self.snapshot( + offset: 1, + weeklyUsed: 42, + weeklyReset: self.capturedAt) + let nonfiniteBoundary = self.snapshot( + offset: 1, + weeklyUsed: 42, + weeklyReset: Date(timeIntervalSinceReferenceDate: .infinity)) + + #expect( + CodexWeeklyResetConfirmation.initialDecision(previous: nil, initial: missingBoundary) + == .publishInitial) + #expect( + CodexWeeklyResetConfirmation.initialDecision(previous: nil, initial: elapsedBoundary) + == .preservePrevious) + #expect( + CodexWeeklyResetConfirmation.initialDecision(previous: nil, initial: nonfiniteBoundary) + == .preservePrevious) + } + + @Test + func `newer rebound publishes instead of accepting the transient low`() { + let nextReset = self.resetAt.addingTimeInterval(7 * 24 * 60 * 60) + let previous = self.snapshot(offset: 0, weeklyUsed: 50, weeklyReset: self.resetAt) + let initial = self.snapshot(offset: 1, weeklyUsed: 0, weeklyReset: nextReset) + let confirmation = self.snapshot(offset: 2, weeklyUsed: 49, weeklyReset: self.resetAt) + + #expect( + CodexWeeklyResetConfirmation.confirmationDecision( + previous: previous, + initial: initial, + confirmation: confirmation) + == .publishConfirmation) + } + + @Test + func `two low observations publish only for an advanced equivalent boundary`() { + let nextReset = self.resetAt.addingTimeInterval(7 * 24 * 60 * 60) + let previous = self.snapshot(offset: 0, weeklyUsed: 50, weeklyReset: self.resetAt) + let initial = self.snapshot(offset: 1, weeklyUsed: 0, weeklyReset: nextReset) + let confirmation = self.snapshot( + offset: 2, + weeklyUsed: 0.5, + weeklyReset: nextReset.addingTimeInterval(119)) + + #expect( + CodexWeeklyResetConfirmation.confirmationDecision( + previous: previous, + initial: initial, + confirmation: confirmation) + == .publishConfirmation) + } + + @Test + func `unchanged regressed and mismatched reset boundaries preserve the previous snapshot`() { + let previous = self.snapshot(offset: 0, weeklyUsed: 50, weeklyReset: self.resetAt) + let unchanged = self.snapshot(offset: 1, weeklyUsed: 0, weeklyReset: self.resetAt) + let regressed = self.snapshot( + offset: 1, + weeklyUsed: 0, + weeklyReset: self.resetAt.addingTimeInterval(-1)) + let advanced = self.resetAt.addingTimeInterval(7 * 24 * 60 * 60) + let initial = self.snapshot(offset: 1, weeklyUsed: 0, weeklyReset: advanced) + let mismatched = self.snapshot( + offset: 2, + weeklyUsed: 0, + weeklyReset: advanced.addingTimeInterval(120)) + let jitteredInitial = self.snapshot( + offset: 1, + weeklyUsed: 0, + weeklyReset: self.resetAt.addingTimeInterval(60)) + let jitteredConfirmation = self.snapshot( + offset: 2, + weeklyUsed: 0.5, + weeklyReset: self.resetAt.addingTimeInterval(90)) + + for candidate in [unchanged, regressed] { + #expect( + CodexWeeklyResetConfirmation.initialDecision(previous: previous, initial: candidate) + == .requiresConfirmation) + #expect( + CodexWeeklyResetConfirmation.confirmationDecision( + previous: previous, + initial: candidate, + confirmation: self.snapshot(offset: 2, weeklyUsed: 50, weeklyReset: self.resetAt)) + == .publishConfirmation) + #expect( + CodexWeeklyResetConfirmation.confirmationDecision( + previous: previous, + initial: candidate, + confirmation: self.snapshot( + offset: 2, + weeklyUsed: 0, + weeklyReset: candidate.secondary?.resetsAt)) + == .preservePrevious) + } + #expect( + CodexWeeklyResetConfirmation.confirmationDecision( + previous: previous, + initial: initial, + confirmation: mismatched) + == .preservePrevious) + #expect( + CodexWeeklyResetConfirmation.confirmationDecision( + previous: previous, + initial: jitteredInitial, + confirmation: jitteredConfirmation) + == .preservePrevious) + } + + @Test + func `stale confirmations preserve the previous snapshot`() { + let nextReset = self.resetAt.addingTimeInterval(7 * 24 * 60 * 60) + let previous = self.snapshot(offset: 0, weeklyUsed: 50, weeklyReset: self.resetAt) + let initial = self.snapshot(offset: 2, weeklyUsed: 0, weeklyReset: nextReset) + let stale = self.snapshot(offset: 2, weeklyUsed: 50, weeklyReset: self.resetAt) + + #expect( + CodexWeeklyResetConfirmation.confirmationDecision( + previous: previous, + initial: initial, + confirmation: stale) + == .preservePrevious) + } + + @Test + func `elapsed and materially regressed boundaries preserve the previous snapshot`() { + let nextReset = self.resetAt.addingTimeInterval(7 * 24 * 60 * 60) + let high = self.snapshot(offset: 0, weeklyUsed: 50, weeklyReset: self.resetAt) + let elapsedLow = self.snapshot( + capturedAt: self.resetAt.addingTimeInterval(1), + weeklyUsed: 0, + weeklyReset: self.resetAt) + let confirmedReset = self.snapshot(offset: 2, weeklyUsed: 0, weeklyReset: nextReset) + let stalePreReset = self.snapshot(offset: 3, weeklyUsed: 50, weeklyReset: self.resetAt) + let elapsedConfirmation = self.snapshot( + capturedAt: nextReset.addingTimeInterval(1), + weeklyUsed: 0, + weeklyReset: nextReset) + + #expect( + CodexWeeklyResetConfirmation.initialDecision(previous: high, initial: elapsedLow) + == .preservePrevious) + #expect( + CodexWeeklyResetConfirmation.initialDecision(previous: confirmedReset, initial: stalePreReset) + == .preservePrevious) + #expect( + CodexWeeklyResetConfirmation.confirmationDecision( + previous: high, + initial: self.snapshot(offset: 1, weeklyUsed: 0, weeklyReset: nextReset), + confirmation: elapsedConfirmation) + == .preservePrevious) + } + + @Test + func `nonfinite percentages timestamps and boundaries fail closed`() { + let previous = self.snapshot(offset: 0, weeklyUsed: 50, weeklyReset: self.resetAt) + let initial = self.snapshot(offset: 1, weeklyUsed: 0, weeklyReset: self.resetAt.addingTimeInterval(100)) + let nonfiniteBoundary = Date(timeIntervalSinceReferenceDate: .infinity) + + #expect( + CodexWeeklyResetConfirmation.initialDecision( + previous: previous, + initial: self.snapshot(offset: 1, weeklyUsed: .nan, weeklyReset: self.resetAt)) + == .preservePrevious) + #expect( + CodexWeeklyResetConfirmation.initialDecision( + previous: previous, + initial: self.snapshot(offset: 1, weeklyUsed: 0, weeklyReset: nonfiniteBoundary)) + == .preservePrevious) + #expect( + CodexWeeklyResetConfirmation.initialDecision( + previous: previous, + initial: self.snapshot( + capturedAt: Date(timeIntervalSinceReferenceDate: .infinity), + weeklyUsed: 0, + weeklyReset: self.resetAt)) + == .preservePrevious) + #expect( + CodexWeeklyResetConfirmation.confirmationDecision( + previous: previous, + initial: initial, + confirmation: self.snapshot(offset: 2, weeklyUsed: .infinity, weeklyReset: self.resetAt)) + == .preservePrevious) + } + + private func snapshot( + offset: TimeInterval, + weeklyUsed: Double?, + weeklyReset: Date?, + weeklyInPrimary: Bool = false) -> UsageSnapshot + { + self.snapshot( + capturedAt: self.capturedAt.addingTimeInterval(offset), + weeklyUsed: weeklyUsed, + weeklyReset: weeklyReset, + weeklyInPrimary: weeklyInPrimary) + } + + private func snapshot( + capturedAt: Date, + weeklyUsed: Double?, + weeklyReset: Date?, + weeklyInPrimary: Bool = false) -> UsageSnapshot + { + let weekly = weeklyUsed.map { + RateWindow( + usedPercent: $0, + windowMinutes: 10080, + resetsAt: weeklyReset, + resetDescription: nil) + } + let session = RateWindow( + usedPercent: 25, + windowMinutes: 300, + resetsAt: self.resetAt, + resetDescription: nil) + return UsageSnapshot( + primary: weeklyInPrimary ? weekly : session, + secondary: weeklyInPrimary ? session : weekly, + updatedAt: capturedAt) + } +} diff --git a/Tests/CodexBarTests/CodexWeeklyResetFailureBaselineTests.swift b/Tests/CodexBarTests/CodexWeeklyResetFailureBaselineTests.swift new file mode 100644 index 0000000000..0bbb0723c2 --- /dev/null +++ b/Tests/CodexBarTests/CodexWeeklyResetFailureBaselineTests.swift @@ -0,0 +1,90 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +@MainActor +extension CodexAccountScopedRefreshTests { + @Test + func `hard failure keeps trusted weekly baseline for later low observations`() async { + let suite = "CodexWeeklyResetFailureBaselineTests-hard-failure" + let email = "failure-baseline@example.com" + let settings = self.makeSettingsStore(suite: suite) + settings.refreshFrequency = .manual + settings.codexCookieSource = .off + settings._test_liveSystemCodexAccount = self.liveAccount( + email: email, + identity: .providerAccount(id: "failure-baseline-owner")) + defer { settings._test_liveSystemCodexAccount = nil } + + let now = Date() + let boundary = now.addingTimeInterval(2 * 24 * 60 * 60) + let prior = self.codexWeeklySnapshot( + email: email, + weeklyUsedPercent: 73, + weeklyReset: boundary, + updatedAt: now.addingTimeInterval(-60)) + let store = self.makeCodexWeeklyPublicationStore(settings: settings, suite: suite) + _ = await self.seedCodexWeeklyPublicationState( + store: store, + settings: settings, + snapshot: prior, + error: nil) + self.installFailingCodexProvider( + on: store, + error: TestRefreshError(message: "non-preservable failure")) + + await store.refreshProvider(.codex, allowDisabled: true) + await store.refreshProvider(.codex, allowDisabled: true) + + #expect(store.snapshots[.codex] == nil) + #expect(store.lastKnownResetSnapshots[.codex]?.updatedAt == prior.updatedAt) + #expect(store.lastCodexUsagePublicationGuard?.accountKey == email) + + let primaryOnly = OpenAIDashboardSnapshot( + signedInEmail: email, + codeReviewRemainingPercent: nil, + creditEvents: [], + dailyBreakdown: [], + usageBreakdown: [], + creditsPurchaseURL: nil, + primaryLimit: RateWindow( + usedPercent: 18, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(4 * 60 * 60), + resetDescription: nil), + secondaryLimit: nil, + creditsRemaining: nil, + accountPlan: "Pro", + updatedAt: now.addingTimeInterval(-40)) + await store.applyOpenAIDashboard( + primaryOnly, + targetEmail: email, + allowCodexUsageBackfill: true) + let primaryOnlyPublishedAt = store.snapshots[.codex]?.updatedAt + #expect(primaryOnlyPublishedAt == primaryOnly.updatedAt) + #expect(store.snapshots[.codex]?.secondary == nil) + + let loader = SequencedCodexSnapshotLoader(steps: [ + .success(self.codexWeeklySnapshot( + email: email, + weeklyUsedPercent: 0.2, + weeklyReset: boundary, + updatedAt: now.addingTimeInterval(-30))), + .success(self.codexWeeklySnapshot( + email: email, + weeklyUsedPercent: 0.5, + weeklyReset: boundary, + updatedAt: now.addingTimeInterval(-20))), + ]) + self.installContextualCodexProvider(on: store) { _ in try await loader.load() } + + await store.refreshProvider(.codex, allowDisabled: true) + + #expect(await loader.callCount == 2) + #expect(store.snapshots[.codex]?.updatedAt == primaryOnlyPublishedAt) + #expect(store.snapshots[.codex]?.secondary == nil) + #expect(store.lastKnownResetSnapshots[.codex]?.updatedAt == prior.updatedAt) + #expect(store.lastKnownResetSnapshots[.codex]?.secondary?.usedPercent == 73) + } +} diff --git a/Tests/CodexBarTests/CodexWeeklyResetOwnerTransitionTests.swift b/Tests/CodexBarTests/CodexWeeklyResetOwnerTransitionTests.swift new file mode 100644 index 0000000000..efdea77c6a --- /dev/null +++ b/Tests/CodexBarTests/CodexWeeklyResetOwnerTransitionTests.swift @@ -0,0 +1,130 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +@MainActor +extension CodexAccountScopedRefreshTests { + @Test(arguments: StableEmailOnlyRefreshFailureCase.allCases) + func `stable email only refresh failures preserve public account state`( + failure: StableEmailOnlyRefreshFailureCase) async throws + { + let suite = "CodexWeeklyResetOwnerTransitionTests-stable-email-only-\(failure.rawValue)" + let email = "stable-email-only@example.com" + let settings = self.makeSettingsStore(suite: suite) + settings.refreshFrequency = .manual + settings.codexCookieSource = .off + settings._test_liveSystemCodexAccount = self.liveAccount( + email: email, + identity: .emailOnly(normalizedEmail: email)) + defer { settings._test_liveSystemCodexAccount = nil } + + let now = Date() + let prior = self.codexWeeklySnapshot( + email: email, + weeklyUsedPercent: 64, + weeklyReset: now.addingTimeInterval(2 * 24 * 60 * 60), + updatedAt: now.addingTimeInterval(-60)) + let credits = self.credits(remaining: 17) + let dashboard = self.dashboard(email: email, creditsRemaining: 17, usedPercent: 64) + let store = self.makeCodexWeeklyPublicationStore(settings: settings, suite: suite) + _ = await self.seedCodexWeeklyPublicationState( + store: store, + settings: settings, + snapshot: prior, + error: nil) + store.credits = credits + store.lastCreditsSnapshot = credits + store.lastCreditsSnapshotAccountKey = email + store.openAIDashboard = dashboard + store.lastOpenAIDashboardSnapshot = dashboard + let refreshGuard = try #require(store.lastCodexAccountScopedRefreshGuard) + #expect(store.codexLimitResetOwnerKey( + expectedGuard: refreshGuard, + visibleAccounts: settings.codexVisibleAccountProjection.visibleAccounts) == nil) + self.installFailingCodexProvider(on: store, error: failure.error) + + await store.refreshProvider(.codex, allowDisabled: true) + + #expect(store.snapshots[.codex]?.updatedAt == prior.updatedAt) + #expect(store.snapshots[.codex]?.secondary?.usedPercent == 64) + #expect(store.lastKnownResetSnapshots[.codex]?.updatedAt == prior.updatedAt) + #expect(store.lastKnownResetSnapshots[.codex]?.secondary?.usedPercent == 64) + #expect(store.lastSourceLabels[.codex] == "prior-source") + #expect(store.credits == credits) + #expect(store.lastCreditsSnapshot == credits) + #expect(store.openAIDashboard == dashboard) + #expect(store.lastOpenAIDashboardSnapshot == dashboard) + } + + @Test + func `rejected reset confirmation never leaves the previous owner public`() async { + let suite = "CodexWeeklyResetOwnerTransitionTests-rejected-confirmation" + let email = "owner-transition@example.com" + let settings = self.makeSettingsStore(suite: suite) + settings.refreshFrequency = .manual + settings.codexCookieSource = .off + settings._test_liveSystemCodexAccount = self.liveAccount( + email: email, + identity: .providerAccount(id: "owner-before")) + defer { settings._test_liveSystemCodexAccount = nil } + + let now = Date() + let previousReset = now.addingTimeInterval(2 * 24 * 60 * 60) + let previous = self.codexWeeklySnapshot( + email: email, + weeklyUsedPercent: 82, + weeklyReset: previousReset, + updatedAt: now.addingTimeInterval(-60)) + let suspiciousLow = self.codexWeeklySnapshot( + email: email, + weeklyUsedPercent: 0.2, + weeklyReset: previousReset.addingTimeInterval(7 * 24 * 60 * 60), + updatedAt: now.addingTimeInterval(-20)) + let loader = SequencedCodexSnapshotLoader(steps: [ + .success(suspiciousLow), + .failure("confirmation unavailable", gated: true), + ]) + let store = self.makeCodexWeeklyPublicationStore(settings: settings, suite: suite) + _ = await self.seedCodexWeeklyPublicationState( + store: store, + settings: settings, + snapshot: previous) + settings._test_liveSystemCodexAccount = self.liveAccount( + email: email, + identity: .providerAccount(id: "owner-after")) + self.installContextualCodexProvider(on: store) { _ in try await loader.load() } + + let refreshTask = Task { await store.refreshProvider(.codex, allowDisabled: true) } + #expect(await loader.waitUntilCallCount(2)) + + #expect(store.snapshots[.codex] == nil) + #expect(store.lastKnownResetSnapshots[.codex] == nil) + #expect(store.errors[.codex] == nil) + #expect(store.lastSourceLabels[.codex] == nil) + #expect(store.codexAccountSnapshots.isEmpty) + + await loader.release(call: 2) + await refreshTask.value + + #expect(await loader.callCount == 2) + #expect(store.snapshots[.codex] == nil) + #expect(store.lastKnownResetSnapshots[.codex] == nil) + #expect(store.errors[.codex] == nil) + #expect(store.lastSourceLabels[.codex] == nil) + } +} + +enum StableEmailOnlyRefreshFailureCase: String, CaseIterable, Sendable { + case failure + case cancellation + + var error: any Error { + switch self { + case .failure: + TestRefreshError(message: "stable account refresh failed") + case .cancellation: + CancellationError() + } + } +} diff --git a/Tests/CodexBarTests/CodexWeeklyResetPublicationTests.swift b/Tests/CodexBarTests/CodexWeeklyResetPublicationTests.swift new file mode 100644 index 0000000000..c96bf7fba3 --- /dev/null +++ b/Tests/CodexBarTests/CodexWeeklyResetPublicationTests.swift @@ -0,0 +1,933 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +@MainActor +extension CodexAccountScopedRefreshTests { + @Test + func `single refresh persists provider snapshot for startup confirmation`() async throws { + let suite = "CodexWeeklyResetPublicationTests-single-startup-hydration" + let email = "startup-hydrated@example.com" + let settings = self.makeSettingsStore(suite: suite) + settings.refreshFrequency = .manual + settings.codexCookieSource = .off + settings._test_liveSystemCodexAccount = self.liveAccount( + email: email, + identity: .providerAccount(id: "acct-startup-hydrated")) + defer { settings._test_liveSystemCodexAccount = nil } + + let now = Date() + let priorBoundary = now.addingTimeInterval(2 * 24 * 60 * 60) + let nextBoundary = priorBoundary.addingTimeInterval(7 * 24 * 60 * 60) + let prior = self.codexWeeklySnapshot( + email: email, + weeklyUsedPercent: 69, + weeklyReset: priorBoundary, + updatedAt: now.addingTimeInterval(-60)) + let initialLow = self.codexWeeklySnapshot( + email: email, + weeklyUsedPercent: 0.2, + weeklyReset: nextBoundary, + updatedAt: now.addingTimeInterval(-40)) + let rebound = self.codexWeeklySnapshot( + email: email, + weeklyUsedPercent: 68, + weeklyReset: priorBoundary, + updatedAt: now.addingTimeInterval(-20)) + let snapshotURL = FileManager.default.temporaryDirectory + .appendingPathComponent("codex-weekly-round-trip-\(UUID().uuidString).json") + defer { try? FileManager.default.removeItem(at: snapshotURL) } + let snapshotStore = FileCodexAccountUsageSnapshotStore(fileURL: snapshotURL) + let firstStore = self.makeCodexWeeklyPublicationStore( + settings: settings, + suite: suite, + snapshotStore: snapshotStore) + self.installContextualCodexProvider(on: firstStore) { _ in prior } + + await firstStore.refreshProvider(.codex, allowDisabled: true) + + let persistedSnapshots = snapshotStore.load( + for: settings.codexVisibleAccountProjection.visibleAccounts) + let persisted = try #require(persistedSnapshots.first) + #expect(persistedSnapshots.count == 1) + #expect(firstStore.codexAccountSnapshots.count == 1) + #expect(firstStore.codexAccountSnapshots.first?.id == persisted.id) + #expect(persisted.account.workspaceAccountID == "acct-startup-hydrated") + #expect(persisted.account.email == email) + #expect(persisted.snapshot?.updatedAt == prior.updatedAt) + #expect(persisted.snapshot?.accountEmail(for: .codex) == email) + #expect(persisted.sourceLabel == "test-codex") + + let loader = SequencedCodexSnapshotLoader(steps: [ + .success(initialLow), + .success(rebound, gated: true), + ]) + let store = self.makeCodexWeeklyPublicationStore( + settings: settings, + suite: suite, + snapshotStore: snapshotStore) + #expect(store.snapshots[.codex] == nil) + #expect(store.codexAccountSnapshots.first?.snapshot?.updatedAt == prior.updatedAt) + self.installContextualCodexProvider(on: store) { _ in try await loader.load() } + let recorder = CodexWeeklyPublicationEventRecorder(email: email) + defer { recorder.invalidate() } + + let refreshTask = Task { await store.refreshProvider(.codex, allowDisabled: true) } + #expect(await loader.waitUntilCallCount(2)) + + #expect(store.snapshots[.codex]?.updatedAt == prior.updatedAt) + #expect(store.snapshots[.codex]?.secondary?.usedPercent == 69) + #expect(store.lastKnownResetSnapshots[.codex]?.updatedAt == prior.updatedAt) + #expect(store.lastSourceLabels[.codex] == "test-codex") + #expect(recorder.usedPercents.isEmpty) + + await loader.release(call: 2) + await refreshTask.value + + #expect(await loader.callCount == 2) + #expect(store.snapshots[.codex]?.updatedAt == rebound.updatedAt) + #expect(store.snapshots[.codex]?.secondary?.usedPercent == 68) + #expect(store.lastCodexAccountScopedRefreshGuard?.identity == .providerAccount(id: "acct-startup-hydrated")) + #expect(recorder.usedPercents.isEmpty) + } + + @Test + func `single persistence rejects another member in the same workspace`() async { + let suite = "CodexWeeklyResetPublicationTests-single-persistence-member-isolation" + let email = "current-member@example.com" + let settings = self.makeSettingsStore(suite: suite) + settings.refreshFrequency = .manual + settings.codexCookieSource = .off + settings._test_liveSystemCodexAccount = self.liveAccount( + email: email, + identity: .providerAccount(id: "acct-shared-workspace")) + defer { settings._test_liveSystemCodexAccount = nil } + + let snapshotStore = RecordingCodexAccountUsageSnapshotStore(initialSnapshots: []) + let otherMember = self.codexWeeklySnapshot( + email: "other-member@example.com", + weeklyUsedPercent: 42, + weeklyReset: Date().addingTimeInterval(2 * 24 * 60 * 60), + updatedAt: Date()) + let store = self.makeCodexWeeklyPublicationStore( + settings: settings, + suite: suite, + snapshotStore: snapshotStore) + self.installContextualCodexProvider(on: store) { _ in otherMember } + + await store.refreshProvider(.codex, allowDisabled: true) + + #expect(store.snapshots[.codex] == nil) + #expect(store.lastKnownResetSnapshots[.codex] == nil) + #expect(store.codexAccountSnapshots.isEmpty) + #expect(snapshotStore.storedSnapshots.isEmpty) + } + + @Test + func `single startup rejects hydrated snapshot from another workspace`() async throws { + let suite = "CodexWeeklyResetPublicationTests-single-startup-workspace-isolation" + let email = "workspace-isolation@example.com" + let settings = self.makeSettingsStore(suite: suite) + settings.refreshFrequency = .manual + settings.codexCookieSource = .off + settings._test_liveSystemCodexAccount = self.liveAccount( + email: email, + identity: .providerAccount(id: "acct-current-workspace")) + defer { settings._test_liveSystemCodexAccount = nil } + + let currentAccount = try #require(settings.codexVisibleAccountProjection.visibleAccounts.first) + let otherWorkspaceAccount = CodexVisibleAccount( + id: currentAccount.id, + email: currentAccount.email, + workspaceLabel: currentAccount.workspaceLabel, + workspaceAccountID: "acct-other-workspace", + authFingerprint: currentAccount.authFingerprint, + storedAccountID: currentAccount.storedAccountID, + selectionSource: currentAccount.selectionSource, + isActive: currentAccount.isActive, + isLive: currentAccount.isLive, + canReauthenticate: currentAccount.canReauthenticate, + canRemove: currentAccount.canRemove) + let now = Date() + let priorBoundary = now.addingTimeInterval(2 * 24 * 60 * 60) + let otherWorkspacePrior = self.codexWeeklySnapshot( + email: email, + weeklyUsedPercent: 71, + weeklyReset: priorBoundary, + updatedAt: now.addingTimeInterval(-60)) + let currentSnapshot = self.codexWeeklySnapshot( + email: email, + weeklyUsedPercent: 42, + weeklyReset: priorBoundary, + updatedAt: now.addingTimeInterval(-20)) + let snapshotStore = RecordingCodexAccountUsageSnapshotStore(initialSnapshots: [ + CodexAccountUsageSnapshot( + account: otherWorkspaceAccount, + snapshot: otherWorkspacePrior, + error: nil, + sourceLabel: "wrong-workspace"), + ]) + let loader = SequencedCodexSnapshotLoader(steps: [.success(currentSnapshot, gated: true)]) + let store = self.makeCodexWeeklyPublicationStore( + settings: settings, + suite: suite, + snapshotStore: snapshotStore) + self.installContextualCodexProvider(on: store) { _ in try await loader.load() } + + let refreshTask = Task { await store.refreshProvider(.codex, allowDisabled: true) } + #expect(await loader.waitUntilCallCount(1)) + + #expect(store.snapshots[.codex] == nil) + #expect(store.lastKnownResetSnapshots[.codex] == nil) + + await loader.release(call: 1) + await refreshTask.value + + #expect(await loader.callCount == 1) + #expect(store.snapshots[.codex]?.updatedAt == currentSnapshot.updatedAt) + #expect(store.snapshots[.codex]?.secondary?.usedPercent == 42) + let persisted = try #require(snapshotStore.storedSnapshots.first) + #expect(snapshotStore.storedSnapshots.count == 1) + #expect(store.codexAccountSnapshots.count == 1) + #expect(store.codexAccountSnapshots.first?.id == persisted.id) + #expect(persisted.account.workspaceAccountID == "acct-current-workspace") + #expect(persisted.account.email == email) + #expect(persisted.snapshot?.updatedAt == currentSnapshot.updatedAt) + } + + @Test + func `single refresh retains the weekly lane when a source omits it`() async { + let suite = "CodexWeeklyResetPublicationTests-single-missing-weekly" + let email = "missing-weekly@example.com" + let settings = self.makeSettingsStore(suite: suite) + settings.refreshFrequency = .manual + settings.codexCookieSource = .off + settings._test_liveSystemCodexAccount = self.liveAccount( + email: email, + identity: .providerAccount(id: "acct-missing-weekly")) + defer { settings._test_liveSystemCodexAccount = nil } + + let now = Date() + let priorBoundary = now.addingTimeInterval(2 * 24 * 60 * 60) + let prior = self.codexWeeklySnapshot( + email: email, + weeklyUsedPercent: 57, + weeklyReset: priorBoundary, + updatedAt: now.addingTimeInterval(-60)) + let partial = self.codexWeeklySnapshot( + email: email, + weeklyUsedPercent: nil, + weeklyReset: nil, + updatedAt: now.addingTimeInterval(-20), + sessionUsedPercent: 31) + let loader = SequencedCodexSnapshotLoader(steps: [.success(partial)]) + let store = self.makeCodexWeeklyPublicationStore(settings: settings, suite: suite) + _ = await self.seedCodexWeeklyPublicationState( + store: store, + settings: settings, + snapshot: prior, + error: nil) + self.installContextualCodexProvider(on: store) { _ in try await loader.load() } + + await store.refreshProvider(.codex, allowDisabled: true) + + #expect(await loader.callCount == 1) + #expect(store.snapshots[.codex]?.updatedAt == partial.updatedAt) + #expect(store.snapshots[.codex]?.primary?.usedPercent == 31) + #expect(store.snapshots[.codex]?.secondary?.usedPercent == 57) + #expect(store.snapshots[.codex]?.secondary?.resetsAt == priorBoundary) + #expect(store.lastKnownResetSnapshots[.codex]?.secondary?.usedPercent == 57) + } + + @Test + func `single refresh keeps prior state private until rebound confirmation publishes`() async { + let suite = "CodexWeeklyResetPublicationTests-single-gated-rebound" + let email = "gated-rebound@example.com" + let settings = self.makeSettingsStore(suite: suite) + settings.refreshFrequency = .manual + settings.codexCookieSource = .off + settings._test_liveSystemCodexAccount = self.liveAccount( + email: email, + identity: .providerAccount(id: "acct-gated-rebound")) + defer { settings._test_liveSystemCodexAccount = nil } + + let now = Date() + let priorBoundary = now.addingTimeInterval(3 * 24 * 60 * 60) + let nextBoundary = priorBoundary.addingTimeInterval(7 * 24 * 60 * 60) + let prior = self.codexWeeklySnapshot( + email: email, + weeklyUsedPercent: 64, + weeklyReset: priorBoundary, + updatedAt: now.addingTimeInterval(-60)) + let initialLow = self.codexWeeklySnapshot( + email: email, + weeklyUsedPercent: 0.2, + weeklyReset: nextBoundary, + updatedAt: now.addingTimeInterval(-40)) + let rebound = self.codexWeeklySnapshot( + email: email, + weeklyUsedPercent: 63, + weeklyReset: priorBoundary, + updatedAt: now.addingTimeInterval(-20)) + let loader = SequencedCodexSnapshotLoader(steps: [ + .success(initialLow), + .success(rebound, gated: true), + ]) + let store = self.makeCodexWeeklyPublicationStore(settings: settings, suite: suite) + let priorRevision = await self.seedCodexWeeklyPublicationState( + store: store, + settings: settings, + snapshot: prior) + self.installContextualCodexProvider(on: store) { _ in try await loader.load() } + let recorder = CodexWeeklyPublicationEventRecorder(email: email) + defer { recorder.invalidate() } + + let refreshTask = Task { await store.refreshProvider(.codex, allowDisabled: true) } + #expect(await loader.waitUntilCallCount(2)) + + #expect(store.snapshots[.codex]?.updatedAt == prior.updatedAt) + #expect(store.snapshots[.codex]?.secondary?.usedPercent == 64) + #expect(store.lastKnownResetSnapshots[.codex]?.updatedAt == prior.updatedAt) + #expect(store.lastKnownResetSnapshots[.codex]?.secondary?.usedPercent == 64) + #expect(store.errors[.codex] == "prior error") + #expect(store.lastSourceLabels[.codex] == "prior-source") + #expect(store.lastFetchAttempts[.codex]?.count == 1) + #expect(store.lastFetchAttempts[.codex]?.first?.strategyID == "prior-strategy") + #expect(store.lastFetchAttempts[.codex]?.first?.errorDescription == "prior diagnostic") + #expect(store.planUtilizationHistoryRevision == priorRevision) + #expect(recorder.usedPercents.isEmpty) + + await loader.release(call: 2) + await refreshTask.value + + #expect(await loader.callCount == 2) + #expect(store.snapshots[.codex]?.updatedAt == rebound.updatedAt) + #expect(store.snapshots[.codex]?.secondary?.usedPercent == 63) + #expect(store.lastKnownResetSnapshots[.codex]?.updatedAt == rebound.updatedAt) + #expect(store.lastKnownResetSnapshots[.codex]?.secondary?.usedPercent == 63) + #expect(store.errors[.codex] == nil) + #expect(store.lastSourceLabels[.codex] == "test-codex") + #expect(store.lastFetchAttempts[.codex]?.count == 1) + #expect(store.lastFetchAttempts[.codex]?.first?.strategyID == "contextual-test-codex") + #expect(recorder.usedPercents.isEmpty) + } + + @Test + func `single refresh publishes the second matching low observation only`() async { + let suite = "CodexWeeklyResetPublicationTests-single-confirmed-low" + let email = "confirmed-low@example.com" + let settings = self.makeSettingsStore(suite: suite) + settings.refreshFrequency = .manual + settings.codexCookieSource = .off + settings._test_liveSystemCodexAccount = self.liveAccount( + email: email, + identity: .providerAccount(id: "acct-confirmed-low")) + defer { settings._test_liveSystemCodexAccount = nil } + + let now = Date() + let priorBoundary = now.addingTimeInterval(2 * 24 * 60 * 60) + let nextBoundary = priorBoundary.addingTimeInterval(7 * 24 * 60 * 60) + let prior = self.codexWeeklySnapshot( + email: email, + weeklyUsedPercent: 72, + weeklyReset: priorBoundary, + updatedAt: now.addingTimeInterval(-60)) + let initialLow = self.codexWeeklySnapshot( + email: email, + weeklyUsedPercent: 0.2, + weeklyReset: nextBoundary, + updatedAt: now.addingTimeInterval(-40)) + let confirmedLow = self.codexWeeklySnapshot( + email: email, + weeklyUsedPercent: 0.7, + weeklyReset: nextBoundary.addingTimeInterval(30), + updatedAt: now.addingTimeInterval(-20)) + let loader = SequencedCodexSnapshotLoader(steps: [ + .success(initialLow), + .success(confirmedLow), + ]) + let store = self.makeCodexWeeklyPublicationStore(settings: settings, suite: suite) + _ = await self.seedCodexWeeklyPublicationState( + store: store, + settings: settings, + snapshot: prior, + error: nil) + self.installContextualCodexProvider(on: store) { _ in try await loader.load() } + let recorder = CodexWeeklyPublicationEventRecorder(email: email) + defer { recorder.invalidate() } + + await store.refreshProvider(.codex, allowDisabled: true) + + #expect(await loader.callCount == 2) + #expect(store.snapshots[.codex]?.updatedAt == confirmedLow.updatedAt) + #expect(store.snapshots[.codex]?.secondary?.usedPercent == 0.7) + #expect(store.snapshots[.codex]?.secondary?.usedPercent != initialLow.secondary?.usedPercent) + #expect(store.lastKnownResetSnapshots[.codex]?.updatedAt == confirmedLow.updatedAt) + #expect(recorder.count == 1) + #expect(recorder.usedPercents == [0.7]) + } + + @Test(arguments: CodexRejectedConfirmationCase.allCases) + func `rejected single confirmation preserves every prior public surface`( + rejection: CodexRejectedConfirmationCase) async + { + let suite = "CodexWeeklyResetPublicationTests-rejected-\(rejection.rawValue)" + let email = "rejected-\(rejection.rawValue)@example.com" + let settings = self.makeSettingsStore(suite: suite) + settings.refreshFrequency = .manual + settings.codexCookieSource = .off + settings._test_liveSystemCodexAccount = self.liveAccount( + email: email, + identity: .providerAccount(id: "acct-rejected-\(rejection.rawValue)")) + defer { settings._test_liveSystemCodexAccount = nil } + + let now = Date() + let priorBoundary = now.addingTimeInterval(2 * 24 * 60 * 60) + let nextBoundary = priorBoundary.addingTimeInterval(7 * 24 * 60 * 60) + let prior = self.codexWeeklySnapshot( + email: email, + weeklyUsedPercent: 81, + weeklyReset: priorBoundary, + updatedAt: now.addingTimeInterval(-60)) + let initialLow = self.codexWeeklySnapshot( + email: email, + weeklyUsedPercent: 0.1, + weeklyReset: nextBoundary, + updatedAt: now.addingTimeInterval(-40)) + let confirmationStep: SequencedCodexSnapshotLoadStep = switch rejection { + case .error: + .failure("soft confirmation failure") + case .missingBoundary: + .success(self.codexWeeklySnapshot( + email: email, + weeklyUsedPercent: 0.4, + weeklyReset: nil, + updatedAt: now.addingTimeInterval(-20))) + case .mismatchedBoundary: + .success(self.codexWeeklySnapshot( + email: email, + weeklyUsedPercent: 0.4, + weeklyReset: nextBoundary.addingTimeInterval(3 * 60), + updatedAt: now.addingTimeInterval(-20))) + case .differentMember: + .success(self.codexWeeklySnapshot( + email: "another-member@example.com", + weeklyUsedPercent: 0.4, + weeklyReset: nextBoundary.addingTimeInterval(30), + updatedAt: now.addingTimeInterval(-20))) + } + let loader = SequencedCodexSnapshotLoader(steps: [.success(initialLow), confirmationStep]) + let store = self.makeCodexWeeklyPublicationStore(settings: settings, suite: suite) + let priorRevision = await self.seedCodexWeeklyPublicationState( + store: store, + settings: settings, + snapshot: prior, + error: nil) + self.installContextualCodexProvider(on: store) { _ in try await loader.load() } + let recorder = CodexWeeklyPublicationEventRecorder(email: email) + defer { recorder.invalidate() } + + await store.refreshProvider(.codex, allowDisabled: true) + + #expect(await loader.callCount == 2) + #expect(store.snapshots[.codex]?.updatedAt == prior.updatedAt) + #expect(store.snapshots[.codex]?.secondary?.usedPercent == 81) + #expect(store.lastKnownResetSnapshots[.codex]?.updatedAt == prior.updatedAt) + #expect(store.lastKnownResetSnapshots[.codex]?.secondary?.usedPercent == 81) + #expect(store.errors[.codex] == nil) + #expect(store.lastSourceLabels[.codex] == "prior-source") + #expect(store.lastFetchAttempts[.codex]?.count == 1) + #expect(store.lastFetchAttempts[.codex]?.first?.strategyID == "prior-strategy") + #expect(store.lastFetchAttempts[.codex]?.first?.errorDescription == "prior diagnostic") + #expect(store.planUtilizationHistoryRevision == priorRevision) + #expect(recorder.usedPercents.isEmpty) + } + + @Test + func `confirmed reset resists a later stale pre reset observation`() async { + let suite = "CodexWeeklyResetPublicationTests-post-reset-stale" + let email = "post-reset-stale@example.com" + let settings = self.makeSettingsStore(suite: suite) + settings.refreshFrequency = .manual + settings.codexCookieSource = .off + settings._test_liveSystemCodexAccount = self.liveAccount( + email: email, + identity: .providerAccount(id: "acct-post-reset-stale")) + defer { settings._test_liveSystemCodexAccount = nil } + + let now = Date() + let priorBoundary = now.addingTimeInterval(2 * 24 * 60 * 60) + let nextBoundary = priorBoundary.addingTimeInterval(7 * 24 * 60 * 60) + let prior = self.codexWeeklySnapshot( + email: email, + weeklyUsedPercent: 78, + weeklyReset: priorBoundary, + updatedAt: now.addingTimeInterval(-60)) + let initialLow = self.codexWeeklySnapshot( + email: email, + weeklyUsedPercent: 0.2, + weeklyReset: nextBoundary, + updatedAt: now.addingTimeInterval(-40)) + let confirmedLow = self.codexWeeklySnapshot( + email: email, + weeklyUsedPercent: 0.6, + weeklyReset: nextBoundary.addingTimeInterval(30), + updatedAt: now.addingTimeInterval(-20)) + let stalePreReset = self.codexWeeklySnapshot( + email: email, + weeklyUsedPercent: 77, + weeklyReset: priorBoundary, + updatedAt: now.addingTimeInterval(-10)) + let loader = SequencedCodexSnapshotLoader(steps: [ + .success(initialLow), + .success(confirmedLow), + .success(stalePreReset), + ]) + let store = self.makeCodexWeeklyPublicationStore(settings: settings, suite: suite) + _ = await self.seedCodexWeeklyPublicationState( + store: store, + settings: settings, + snapshot: prior, + error: nil) + self.installContextualCodexProvider(on: store) { _ in try await loader.load() } + let recorder = CodexWeeklyPublicationEventRecorder(email: email) + defer { recorder.invalidate() } + + await store.refreshProvider(.codex, allowDisabled: true) + let acceptedRevision = store.planUtilizationHistoryRevision + #expect(store.snapshots[.codex]?.updatedAt == confirmedLow.updatedAt) + #expect(recorder.usedPercents == [0.6]) + + await store.refreshProvider(.codex, allowDisabled: true) + + #expect(await loader.callCount == 3) + #expect(store.snapshots[.codex]?.updatedAt == confirmedLow.updatedAt) + #expect(store.snapshots[.codex]?.secondary?.usedPercent == 0.6) + #expect(store.lastKnownResetSnapshots[.codex]?.updatedAt == confirmedLow.updatedAt) + #expect(store.planUtilizationHistoryRevision == acceptedRevision) + #expect(recorder.usedPercents == [0.6]) + } + + @Test + func `single refresh never compares a prior snapshot across provider owners`() async { + let suite = "CodexWeeklyResetPublicationTests-owner-transition" + let email = "owner-transition@example.com" + let settings = self.makeSettingsStore(suite: suite) + settings.refreshFrequency = .manual + settings.codexCookieSource = .off + settings._test_liveSystemCodexAccount = self.liveAccount( + email: email, + identity: .providerAccount(id: "acct-owner-transition-a")) + defer { settings._test_liveSystemCodexAccount = nil } + + let now = Date() + let priorBoundary = now.addingTimeInterval(2 * 24 * 60 * 60) + let nextBoundary = priorBoundary.addingTimeInterval(7 * 24 * 60 * 60) + let prior = self.codexWeeklySnapshot( + email: email, + weeklyUsedPercent: 82, + weeklyReset: priorBoundary, + updatedAt: now.addingTimeInterval(-60)) + let newOwnerLow = self.codexWeeklySnapshot( + email: email, + weeklyUsedPercent: 0.3, + weeklyReset: nextBoundary, + updatedAt: now.addingTimeInterval(-20)) + let confirmedNewOwnerLow = self.codexWeeklySnapshot( + email: email, + weeklyUsedPercent: 0.6, + weeklyReset: nextBoundary.addingTimeInterval(30), + updatedAt: now.addingTimeInterval(-10)) + let loader = SequencedCodexSnapshotLoader(steps: [ + .success(newOwnerLow), + .success(confirmedNewOwnerLow), + ]) + let store = self.makeCodexWeeklyPublicationStore(settings: settings, suite: suite) + _ = await self.seedCodexWeeklyPublicationState( + store: store, + settings: settings, + snapshot: prior, + error: nil) + settings._test_liveSystemCodexAccount = self.liveAccount( + email: email, + identity: .providerAccount(id: "acct-owner-transition-b")) + self.installContextualCodexProvider(on: store) { _ in try await loader.load() } + let recorder = CodexWeeklyPublicationEventRecorder(email: email) + defer { recorder.invalidate() } + + await store.refreshProvider(.codex, allowDisabled: true) + + #expect(await loader.callCount == 2) + #expect(store.snapshots[.codex]?.updatedAt == confirmedNewOwnerLow.updatedAt) + #expect(store.snapshots[.codex]?.secondary?.usedPercent == 0.6) + #expect(store.lastKnownResetSnapshots[.codex]?.updatedAt == confirmedNewOwnerLow.updatedAt) + #expect(recorder.usedPercents.isEmpty) + } + + @Test + func `stacked refresh rejects a response explicitly owned by another member`() async throws { + let suite = "CodexWeeklyResetPublicationTests-stacked-response-email-mismatch" + let targetID = try #require(UUID(uuidString: "11111111-2222-3333-4444-555555555555")) + let siblingID = try #require(UUID(uuidString: "66666666-7777-8888-9999-AAAAAAAAAAAA")) + let targetHome = FileManager.default.temporaryDirectory + .appendingPathComponent("codex-stacked-mismatch-target-\(UUID().uuidString)", isDirectory: true) + let siblingHome = FileManager.default.temporaryDirectory + .appendingPathComponent("codex-stacked-mismatch-sibling-\(UUID().uuidString)", isDirectory: true) + let target = try self.makeManagedCodexWeeklyPublicationAccount( + id: targetID, + email: "target-member@example.com", + workspaceID: "shared-provider-workspace", + workspaceLabel: "Target Member", + homeURL: targetHome) + let sibling = try self.makeManagedCodexWeeklyPublicationAccount( + id: siblingID, + email: "sibling-member@example.com", + workspaceID: "sibling-provider-workspace", + workspaceLabel: "Sibling Member", + homeURL: siblingHome) + let settings = self.makeSettingsStore(suite: suite) + settings.refreshFrequency = .manual + settings.multiAccountMenuLayout = .stacked + let storeURL = try self.makeManagedAccountStoreURL(accounts: [target, sibling]) + defer { + settings._test_managedCodexAccountStoreURL = nil + try? FileManager.default.removeItem(at: storeURL) + try? FileManager.default.removeItem(at: targetHome) + try? FileManager.default.removeItem(at: siblingHome) + } + settings._test_managedCodexAccountStoreURL = storeURL + settings.codexActiveSource = .managedAccount(id: targetID) + + let snapshotStore = RecordingCodexAccountUsageSnapshotStore(initialSnapshots: []) + let store = self.makeCodexWeeklyPublicationStore( + settings: settings, + suite: suite, + snapshotStore: snapshotStore) + let now = Date() + let targetMismatch = self.codexWeeklySnapshot( + email: "another-member@example.com", + weeklyUsedPercent: 64, + weeklyReset: now.addingTimeInterval(2 * 24 * 60 * 60), + updatedAt: now) + let siblingSnapshot = self.codexWeeklySnapshot( + email: sibling.email, + weeklyUsedPercent: 22, + weeklyReset: now.addingTimeInterval(2 * 24 * 60 * 60), + updatedAt: now) + self.installContextualCodexProvider(on: store) { context in + let isTarget = context.env["CODEX_HOME"] == targetHome.path + return isTarget ? targetMismatch : siblingSnapshot + } + + await store.refreshCodexVisibleAccountsForMenu() + + #expect(store.snapshots[.codex] == nil) + #expect(!store.codexAccountSnapshots.contains { $0.account.storedAccountID == targetID }) + #expect(store.codexAccountSnapshots.contains { $0.account.storedAccountID == siblingID }) + #expect(!snapshotStore.storedSnapshots.contains { $0.account.storedAccountID == targetID }) + #expect(snapshotStore.storedSnapshots.contains { $0.account.storedAccountID == siblingID }) + } + + @Test + func `stacked refresh never publishes or persists an unconfirmed account reset`() async throws { + let suite = "CodexWeeklyResetPublicationTests-stacked" + let email = "shared-stacked@example.com" + let settings = self.makeSettingsStore(suite: suite) + settings.refreshFrequency = .manual + settings.codexCookieSource = .off + settings.multiAccountMenuLayout = .stacked + + let suspiciousID = try #require(UUID(uuidString: "AAAAAAAA-BBBB-CCCC-DDDD-303030303030")) + let siblingID = try #require(UUID(uuidString: "AAAAAAAA-BBBB-CCCC-DDDD-313131313131")) + let suspiciousHome = FileManager.default.temporaryDirectory + .appendingPathComponent("codex-weekly-suspicious-\(UUID().uuidString)", isDirectory: true) + let siblingHome = FileManager.default.temporaryDirectory + .appendingPathComponent("codex-weekly-sibling-\(UUID().uuidString)", isDirectory: true) + let suspiciousAccount = try self.makeManagedCodexWeeklyPublicationAccount( + id: suspiciousID, + email: email, + workspaceID: "acct-stacked-suspicious", + workspaceLabel: "Suspicious Workspace", + homeURL: suspiciousHome) + let siblingAccount = try self.makeManagedCodexWeeklyPublicationAccount( + id: siblingID, + email: email, + workspaceID: "acct-stacked-sibling", + workspaceLabel: "Sibling Workspace", + homeURL: siblingHome) + let managedStoreURL = try self.makeManagedAccountStoreURL(accounts: [suspiciousAccount, siblingAccount]) + defer { + settings._test_managedCodexAccountStoreURL = nil + try? FileManager.default.removeItem(at: managedStoreURL) + try? FileManager.default.removeItem(at: suspiciousHome) + try? FileManager.default.removeItem(at: siblingHome) + } + settings._test_managedCodexAccountStoreURL = managedStoreURL + settings.codexActiveSource = .managedAccount(id: suspiciousID) + + let visibleAccounts = settings.codexVisibleAccountProjection.visibleAccounts + #expect(visibleAccounts.count == 2) + let suspiciousVisible = try #require(visibleAccounts.first { + $0.workspaceAccountID == "acct-stacked-suspicious" + }) + let siblingVisible = try #require(visibleAccounts.first { + $0.workspaceAccountID == "acct-stacked-sibling" + }) + let now = Date() + let priorBoundary = now.addingTimeInterval(3 * 24 * 60 * 60) + let nextBoundary = priorBoundary.addingTimeInterval(7 * 24 * 60 * 60) + let suspiciousPriorAccount = CodexVisibleAccount( + id: "prior-email-derived-row", + email: email, + workspaceLabel: suspiciousVisible.workspaceLabel, + workspaceAccountID: suspiciousVisible.workspaceAccountID, + authFingerprint: "prior-auth-fingerprint", + storedAccountID: suspiciousVisible.storedAccountID, + selectionSource: suspiciousVisible.selectionSource, + isActive: suspiciousVisible.isActive, + isLive: suspiciousVisible.isLive, + canReauthenticate: suspiciousVisible.canReauthenticate, + canRemove: suspiciousVisible.canRemove) + let suspiciousPrior = self.codexWeeklySnapshot( + email: email, + weeklyUsedPercent: 84, + weeklyReset: priorBoundary, + updatedAt: now.addingTimeInterval(-60)) + let siblingPrior = self.codexWeeklySnapshot( + email: email, + weeklyUsedPercent: 62, + weeklyReset: priorBoundary, + updatedAt: now.addingTimeInterval(-60)) + let priorRows = [ + CodexAccountUsageSnapshot( + account: suspiciousPriorAccount, + snapshot: suspiciousPrior, + error: nil, + sourceLabel: "cached-suspicious"), + CodexAccountUsageSnapshot( + account: siblingVisible, + snapshot: siblingPrior, + error: nil, + sourceLabel: "cached-sibling"), + ] + let snapshotStore = RecordingCodexAccountUsageSnapshotStore(initialSnapshots: priorRows) + let store = self.makeCodexWeeklyPublicationStore( + settings: settings, + suite: suite, + snapshotStore: snapshotStore) + store.codexAccountSnapshots = priorRows + let priorRevision = await self.seedCodexWeeklyPublicationState( + store: store, + settings: settings, + snapshot: suspiciousPrior, + error: nil) + + let suspiciousInitial = self.codexWeeklySnapshot( + email: email, + weeklyUsedPercent: 0.1, + weeklyReset: nextBoundary, + updatedAt: now.addingTimeInterval(-40)) + let suspiciousRejected = self.codexWeeklySnapshot( + email: email, + weeklyUsedPercent: 0.4, + weeklyReset: nil, + updatedAt: now.addingTimeInterval(-20)) + let siblingUpdated = self.codexWeeklySnapshot( + email: email, + weeklyUsedPercent: 63, + weeklyReset: priorBoundary, + updatedAt: now.addingTimeInterval(-20)) + let suspiciousLoader = SequencedCodexSnapshotLoader(steps: [ + .success(suspiciousInitial), + .success(suspiciousRejected, gated: true), + ]) + let siblingLoader = SequencedCodexSnapshotLoader(steps: [.success(siblingUpdated)]) + let suspiciousHomePath = suspiciousHome.path + let siblingHomePath = siblingHome.path + self.installContextualCodexProvider(on: store) { context in + switch context.env["CODEX_HOME"] { + case suspiciousHomePath: + try await suspiciousLoader.load() + case siblingHomePath: + try await siblingLoader.load() + default: + throw TestRefreshError(message: "Unexpected CODEX_HOME routing") + } + } + let currentRecorder = CodexWeeklyPublicationEventRecorder(email: email) + defer { currentRecorder.invalidate() } + + let refreshTask = Task { await store.refreshCodexVisibleAccountsForMenu() } + #expect(await suspiciousLoader.waitUntilCallCount(2)) + #expect(await siblingLoader.waitUntilCompletedCallCount(1)) + + try self.expectBlockedStackedResetState( + store: store, + snapshotStore: snapshotStore, + prior: suspiciousPrior, + historyRevision: priorRevision, + recorder: currentRecorder) + + await suspiciousLoader.release(call: 2) + await refreshTask.value + + #expect(await suspiciousLoader.callCount == 2) + #expect(await siblingLoader.callCount == 1) + try self.expectFinalStackedResetState( + store: store, + snapshotStore: snapshotStore, + expectation: FinalStackedResetExpectation( + targetAccount: suspiciousVisible, + siblingAccount: siblingVisible, + targetPrior: suspiciousPrior, + siblingUpdated: siblingUpdated, + historyRevision: priorRevision), + recorder: currentRecorder) + } + + private func expectBlockedStackedResetState( + store: UsageStore, + snapshotStore: RecordingCodexAccountUsageSnapshotStore, + prior: UsageSnapshot, + historyRevision: Int, + recorder: CodexWeeklyPublicationEventRecorder) throws + { + let target = try #require(store.codexAccountSnapshots.first { + $0.account.workspaceAccountID == "acct-stacked-suspicious" + }) + #expect(target.snapshot?.updatedAt == prior.updatedAt) + #expect(target.snapshot?.secondary?.usedPercent == 84) + #expect(target.sourceLabel == "cached-suspicious") + #expect(snapshotStore.storedSnapshots.isEmpty) + #expect(store.snapshots[.codex]?.updatedAt == prior.updatedAt) + #expect(store.snapshots[.codex]?.secondary?.usedPercent == 84) + #expect(store.lastKnownResetSnapshots[.codex]?.updatedAt == prior.updatedAt) + #expect(store.errors[.codex] == nil) + #expect(store.lastSourceLabels[.codex] == "prior-source") + #expect(store.lastFetchAttempts[.codex]?.first?.strategyID == "prior-strategy") + #expect(store.planUtilizationHistoryRevision == historyRevision) + #expect(recorder.usedPercents.isEmpty) + } + + private func expectFinalStackedResetState( + store: UsageStore, + snapshotStore: RecordingCodexAccountUsageSnapshotStore, + expectation: FinalStackedResetExpectation, + recorder: CodexWeeklyPublicationEventRecorder) throws + { + let target = try #require(store.codexAccountSnapshots.first { + $0.account.workspaceAccountID == "acct-stacked-suspicious" + }) + #expect(target.account.id == expectation.targetAccount.id) + #expect(target.account.email == expectation.targetAccount.email) + #expect(target.snapshot?.accountEmail(for: .codex) == expectation.targetAccount.email) + #expect(target.snapshot?.updatedAt == expectation.targetPrior.updatedAt) + #expect(target.snapshot?.secondary?.usedPercent == 84) + #expect(target.error == nil) + #expect(target.sourceLabel == "cached-suspicious") + #expect(!store.codexAccountSnapshots.contains { + $0.account.workspaceAccountID == "acct-stacked-suspicious" + && $0.snapshot?.secondary?.usedPercent == 0.1 + }) + let persistedTarget = try #require(snapshotStore.storedSnapshots.first { + $0.account.workspaceAccountID == "acct-stacked-suspicious" + }) + #expect(persistedTarget.snapshot?.updatedAt == expectation.targetPrior.updatedAt) + #expect(persistedTarget.snapshot?.secondary?.usedPercent == 84) + #expect(persistedTarget.error == nil) + #expect(persistedTarget.sourceLabel == "cached-suspicious") + let sibling = try #require(store.codexAccountSnapshots.first { + $0.account.workspaceAccountID == "acct-stacked-sibling" + }) + #expect(sibling.snapshot?.updatedAt == expectation.siblingUpdated.updatedAt) + #expect(sibling.snapshot?.secondary?.usedPercent == 63) + #expect(snapshotStore.storedSnapshots.count == 2) + #expect(Set(snapshotStore.storedSnapshots.map(\.id)) == Set([ + expectation.targetAccount.id, + expectation.siblingAccount.id, + ])) + #expect(!snapshotStore.storedSnapshots.contains { + $0.account.workspaceAccountID == "acct-stacked-suspicious" + && $0.snapshot?.secondary?.usedPercent == 0.1 + }) + #expect(store.snapshots[.codex]?.updatedAt == expectation.targetPrior.updatedAt) + #expect(store.snapshots[.codex]?.secondary?.usedPercent == 84) + #expect(store.snapshots[.codex]?.accountEmail(for: .codex) == expectation.targetAccount.email) + #expect(store.lastKnownResetSnapshots[.codex]?.updatedAt == expectation.targetPrior.updatedAt) + #expect( + store.lastKnownResetSnapshots[.codex]?.accountEmail(for: .codex) == expectation.targetAccount.email) + #expect(store.errors[.codex] == nil) + #expect(store.lastSourceLabels[.codex] == "prior-source") + #expect(store.lastFetchAttempts[.codex]?.first?.strategyID == "prior-strategy") + #expect(store.planUtilizationHistoryRevision == expectation.historyRevision) + #expect(recorder.usedPercents.isEmpty) + } +} + +private struct FinalStackedResetExpectation { + let targetAccount: CodexVisibleAccount + let siblingAccount: CodexVisibleAccount + let targetPrior: UsageSnapshot + let siblingUpdated: UsageSnapshot + let historyRevision: Int +} + +enum CodexRejectedConfirmationCase: String, CaseIterable, Sendable { + case differentMember + case error + case missingBoundary + case mismatchedBoundary +} + +private final class CodexWeeklyPublicationEventRecorder: @unchecked Sendable { + private let email: String + private let lock = NSLock() + private var observations: [Double] = [] + private var token: NSObjectProtocol? + + init(email: String) { + self.email = email + self.token = NotificationCenter.default.addObserver( + forName: .codexbarWeeklyLimitReset, + object: nil, + queue: nil) + { [weak self] notification in + guard let self, + let event = notification.object as? WeeklyLimitResetEvent + else { + return + } + let usedPercent = MainActor.assumeIsolated { () -> Double? in + guard event.provider == .codex, event.accountLabel == self.email else { return nil } + return event.usedPercent + } + guard let usedPercent else { return } + self.lock.lock() + self.observations.append(usedPercent) + self.lock.unlock() + } + } + + var count: Int { + self.lock.lock() + defer { self.lock.unlock() } + return self.observations.count + } + + var usedPercents: [Double] { + self.lock.lock() + defer { self.lock.unlock() } + return self.observations + } + + func invalidate() { + guard let token else { return } + NotificationCenter.default.removeObserver(token) + self.token = nil + } + + deinit { + self.invalidate() + } +} diff --git a/Tests/CodexBarTests/CodexbarTests.swift b/Tests/CodexBarTests/CodexbarTests.swift index 4a907a53cc..7502ab9155 100644 --- a/Tests/CodexBarTests/CodexbarTests.swift +++ b/Tests/CodexBarTests/CodexbarTests.swift @@ -49,29 +49,265 @@ struct CodexBarTests { } @Test - func `antigravity icon falls back to tertiary when leading lanes are missing`() { + func `antigravity icon ignores legacy model quota lanes`() { let snapshot = UsageSnapshot( - primary: nil, - secondary: nil, + primary: RateWindow(usedPercent: 30, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: RateWindow(usedPercent: 60, windowMinutes: nil, resetsAt: nil, resetDescription: nil), tertiary: RateWindow(usedPercent: 80, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + extraRateWindows: [ + NamedRateWindow( + id: "antigravity-compact-fallback-model", + title: "New Model", + window: RateWindow( + usedPercent: 64, + windowMinutes: nil, + resetsAt: nil, + resetDescription: nil)), + ], updatedAt: Date()) let remaining = IconRemainingResolver.resolvedRemaining(snapshot: snapshot, style: .antigravity) - #expect(remaining.primary == 20) + + #expect(remaining.primary == nil) #expect(remaining.secondary == nil) } @Test - func `antigravity icon uses next distinct fallback lane`() { + func `antigravity quota summary icon shows session on top and weekly on bottom`() { + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 84, windowMinutes: 10080, resetsAt: nil, resetDescription: nil), + secondary: RateWindow(usedPercent: 99, windowMinutes: 10080, resetsAt: nil, resetDescription: nil), + tertiary: nil, + extraRateWindows: [ + NamedRateWindow( + id: "antigravity-quota-summary-gemini-weekly", + title: "Gemini Weekly", + window: RateWindow(usedPercent: 84, windowMinutes: 10080, resetsAt: nil, resetDescription: nil)), + NamedRateWindow( + id: "antigravity-quota-summary-gemini-5h", + title: "Gemini Session", + window: RateWindow(usedPercent: 97, windowMinutes: 300, resetsAt: nil, resetDescription: nil)), + NamedRateWindow( + id: "antigravity-quota-summary-3p-weekly", + title: "Claude + GPT Weekly", + window: RateWindow(usedPercent: 99, windowMinutes: 10080, resetsAt: nil, resetDescription: nil)), + NamedRateWindow( + id: "antigravity-quota-summary-3p-5h", + title: "Claude + GPT Session", + window: RateWindow(usedPercent: 98, windowMinutes: 300, resetsAt: nil, resetDescription: nil)), + ], + updatedAt: Date()) + + let windows = IconRemainingResolver.resolvedWindows(snapshot: snapshot, style: .antigravity) + + #expect(windows.primary?.windowMinutes == 300) + #expect(windows.primary?.remainingPercent == 2) + #expect(windows.secondary?.windowMinutes == 10080) + #expect(windows.secondary?.remainingPercent == 1) + } + + @Test + func `antigravity renderer draws primary above secondary`() throws { + let image = IconRenderer.makeIcon( + primaryRemaining: 100, + weeklyRemaining: 10, + creditsRemaining: nil, + stale: false, + style: .antigravity) + let bitmapReps = image.representations.compactMap { $0 as? NSBitmapImageRep } + let matchingRep = bitmapReps.first { rep in + rep.pixelsWide == 36 && rep.pixelsHigh == 36 + } + let rep = try #require(matchingRep) + + func averageAlpha(xRange: ClosedRange, yRange: ClosedRange) -> CGFloat { + var total: CGFloat = 0 + var count: CGFloat = 0 + for y in yRange { + for x in xRange { + total += (rep.colorAt(x: x, y: y) ?? .clear).alphaComponent + count += 1 + } + } + return total / count + } + + let visualTopRightAlpha = averageAlpha(xRange: 24...30, yRange: 7...10) + let visualBottomRightAlpha = averageAlpha(xRange: 24...30, yRange: 22...28) + + #expect(visualTopRightAlpha > visualBottomRightAlpha + 0.2) + } + + @Test + func `antigravity quota summary icon uses most constrained quota summary lanes`() { let snapshot = UsageSnapshot( primary: nil, - secondary: RateWindow(usedPercent: 30, windowMinutes: nil, resetsAt: nil, resetDescription: nil), - tertiary: RateWindow(usedPercent: 60, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: nil, + tertiary: nil, + extraRateWindows: [ + NamedRateWindow( + id: "antigravity-quota-summary-gemini-weekly", + title: "Renamed Weekly", + window: RateWindow(usedPercent: 30, windowMinutes: 10080, resetsAt: nil, resetDescription: nil)), + NamedRateWindow( + id: "antigravity-quota-summary-gemini-5h", + title: "Renamed Session", + window: RateWindow(usedPercent: 40, windowMinutes: 300, resetsAt: nil, resetDescription: nil)), + NamedRateWindow( + id: "antigravity-quota-summary-3p-weekly", + title: "Gemini Weekly", + window: RateWindow(usedPercent: 99, windowMinutes: 10080, resetsAt: nil, resetDescription: nil)), + NamedRateWindow( + id: "antigravity-quota-summary-3p-5h", + title: "Gemini Session", + window: RateWindow(usedPercent: 98, windowMinutes: 300, resetsAt: nil, resetDescription: nil)), + ], updatedAt: Date()) - let remaining = IconRemainingResolver.resolvedRemaining(snapshot: snapshot, style: .antigravity) - #expect(remaining.primary == 70) - #expect(remaining.secondary == 40) + let windows = IconRemainingResolver.resolvedWindows(snapshot: snapshot, style: .antigravity) + + #expect(windows.primary?.remainingPercent == 2) + #expect(windows.secondary?.remainingPercent == 1) + } + + @Test + func `antigravity quota summary icon can pair gemini session with claude gpt weekly`() { + let snapshot = UsageSnapshot( + primary: nil, + secondary: nil, + tertiary: nil, + extraRateWindows: [ + NamedRateWindow( + id: "antigravity-quota-summary-gemini-5h", + title: "Gemini Session", + window: RateWindow(usedPercent: 40, windowMinutes: 300, resetsAt: nil, resetDescription: nil)), + NamedRateWindow( + id: "antigravity-quota-summary-3p-weekly", + title: "Claude + GPT Weekly", + window: RateWindow(usedPercent: 99, windowMinutes: 10080, resetsAt: nil, resetDescription: nil)), + ], + updatedAt: Date()) + + let windows = IconRemainingResolver.resolvedWindows(snapshot: snapshot, style: .antigravity) + + #expect(windows.primary?.remainingPercent == 60) + #expect(windows.secondary?.remainingPercent == 1) + } + + @Test + func `antigravity quota summary icon ignores unknown rows while ranking known lanes`() { + let snapshot = UsageSnapshot( + primary: nil, + secondary: nil, + tertiary: nil, + extraRateWindows: [ + NamedRateWindow( + id: "antigravity-quota-summary-gemini-weekly", + title: "Gemini Weekly", + window: RateWindow(usedPercent: 100, windowMinutes: 10080, resetsAt: nil, resetDescription: nil), + usageKnown: false), + NamedRateWindow( + id: "antigravity-quota-summary-3p-weekly", + title: "Claude + GPT Weekly", + window: RateWindow(usedPercent: 99, windowMinutes: 10080, resetsAt: nil, resetDescription: nil)), + ], + updatedAt: Date()) + + let windows = IconRemainingResolver.resolvedWindows(snapshot: snapshot, style: .antigravity) + + #expect(windows.primary == nil) + #expect(windows.secondary?.remainingPercent == 1) + } + + @Test + func `antigravity used icon percent matches constrained claude gpt lane`() { + let snapshot = UsageSnapshot( + primary: nil, + secondary: nil, + tertiary: nil, + extraRateWindows: [ + NamedRateWindow( + id: "antigravity-quota-summary-gemini-5h", + title: "Gemini Session", + window: RateWindow(usedPercent: 20, windowMinutes: 300, resetsAt: nil, resetDescription: nil)), + NamedRateWindow( + id: "antigravity-quota-summary-gemini-weekly", + title: "Gemini Weekly", + window: RateWindow(usedPercent: 30, windowMinutes: 10080, resetsAt: nil, resetDescription: nil)), + NamedRateWindow( + id: "antigravity-quota-summary-3p-5h", + title: "Claude + GPT Session", + window: RateWindow(usedPercent: 95, windowMinutes: 300, resetsAt: nil, resetDescription: nil)), + NamedRateWindow( + id: "antigravity-quota-summary-3p-weekly", + title: "Claude + GPT Weekly", + window: RateWindow(usedPercent: 40, windowMinutes: 10080, resetsAt: nil, resetDescription: nil)), + ], + updatedAt: Date()) + + let percents = IconRemainingResolver.resolvedPercents( + snapshot: snapshot, + style: .antigravity, + showUsed: true) + + #expect(percents.primary == 95) + #expect(percents.secondary == 40) + } + + @Test + func `antigravity quota summary icon falls back when gemini rows are absent`() { + let snapshot = UsageSnapshot( + primary: nil, + secondary: nil, + tertiary: nil, + extraRateWindows: [ + NamedRateWindow( + id: "antigravity-quota-summary-3p-5h", + title: "Claude + GPT Session", + window: RateWindow(usedPercent: 75, windowMinutes: 300, resetsAt: nil, resetDescription: nil)), + NamedRateWindow( + id: "antigravity-quota-summary-3p-weekly", + title: "Claude + GPT Weekly", + window: RateWindow(usedPercent: 88, windowMinutes: 10080, resetsAt: nil, resetDescription: nil)), + ], + updatedAt: Date()) + + let windows = IconRemainingResolver.resolvedWindows(snapshot: snapshot, style: .antigravity) + + #expect(windows.primary?.remainingPercent == 25) + #expect(windows.secondary?.remainingPercent == 12) + } + + @Test + func `antigravity quota summary icon tie break is stable`() { + let snapshot = UsageSnapshot( + primary: nil, + secondary: nil, + tertiary: nil, + extraRateWindows: [ + NamedRateWindow( + id: "antigravity-quota-summary-gemini-z-5h", + title: "Gemini Session", + window: RateWindow( + usedPercent: 50, + windowMinutes: 300, + resetsAt: nil, + resetDescription: "second-by-id")), + NamedRateWindow( + id: "antigravity-quota-summary-gemini-a-5h", + title: "Gemini Session", + window: RateWindow( + usedPercent: 50, + windowMinutes: 300, + resetsAt: nil, + resetDescription: "first-by-id")), + ], + updatedAt: Date()) + + let windows = IconRemainingResolver.resolvedWindows(snapshot: snapshot, style: .antigravity) + + #expect(windows.primary?.resetDescription == "first-by-id") + #expect(windows.secondary == nil) } @Test @@ -163,6 +399,217 @@ struct CodexBarTests { #expect(regionHasFill(xRange: 3...33, yRange: 19...31)) } + @Test + func `copilot icon can use selected budget as secondary lane`() { + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 20, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: RateWindow(usedPercent: 30, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + extraRateWindows: [ + NamedRateWindow( + id: "copilot-budget-agent", + title: "Budget - Copilot Agent Premium Requests", + window: RateWindow(usedPercent: 65, windowMinutes: nil, resetsAt: nil, resetDescription: nil)), + ], + updatedAt: Date()) + + let remaining = IconRemainingResolver.resolvedRemaining( + snapshot: snapshot, + style: .copilot, + secondaryOverrideWindowID: "copilot-budget-agent") + + #expect(remaining.primary == 80) + #expect(remaining.secondary == 35) + } + + @Test + func `copying extra rate windows preserves subscription dates`() { + let expiresAt = Date(timeIntervalSince1970: 1_810_656_000) + let renewsAt = Date(timeIntervalSince1970: 1_810_569_600) + let ampUsage = AmpUsageDetails( + individualCredits: 12.5, + workspaceBalances: [AmpWorkspaceBalance(name: "Team", remaining: 7.25)]) + let snapshot = UsageSnapshot( + primary: nil, + secondary: nil, + ampUsage: ampUsage, + subscriptionExpiresAt: expiresAt, + subscriptionRenewsAt: renewsAt, + updatedAt: Date(timeIntervalSince1970: 1_800_000_000)) + + let copied = snapshot.with(extraRateWindows: []) + + #expect(copied.subscriptionExpiresAt == expiresAt) + #expect(copied.subscriptionRenewsAt == renewsAt) + #expect(copied.ampUsage == ampUsage) + } + + @Test + func `copying rate windows preserves provider payloads`() { + let updatedAt = Date(timeIntervalSince1970: 1_800_000_000) + let mimoUsage = MiMoUsageSnapshot( + balance: 12.5, + currency: "USD", + tokenUsed: 25, + tokenLimit: 100, + tokenPercent: 0.25, + updatedAt: updatedAt) + let identity = ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: "test@example.com", + accountOrganization: "Example", + loginMethod: "OAuth") + let snapshot = UsageSnapshot( + primary: nil, + secondary: nil, + tertiary: RateWindow(usedPercent: 30, windowMinutes: 60, resetsAt: nil, resetDescription: nil), + mimoUsage: mimoUsage, + cursorRequests: CursorRequestUsage(used: 10, limit: 50), + subscriptionExpiresAt: updatedAt.addingTimeInterval(100), + subscriptionRenewsAt: updatedAt.addingTimeInterval(200), + updatedAt: updatedAt, + identity: identity) + + let copied = snapshot.with( + primary: RateWindow(usedPercent: 40, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + secondary: RateWindow(usedPercent: 50, windowMinutes: 10080, resetsAt: nil, resetDescription: nil)) + + #expect(copied.primary?.usedPercent == 40) + #expect(copied.secondary?.usedPercent == 50) + #expect(copied.tertiary?.usedPercent == 30) + #expect(copied.mimoUsage?.balance == 12.5) + #expect(copied.cursorRequests?.used == 10) + #expect(copied.subscriptionExpiresAt == updatedAt.addingTimeInterval(100)) + #expect(copied.subscriptionRenewsAt == updatedAt.addingTimeInterval(200)) + #expect(copied.identity?.accountOrganization == "Example") + } + + @Test + func `copying identity preserves provider payloads`() { + let updatedAt = Date(timeIntervalSince1970: 1_800_000_000) + let ampUsage = AmpUsageDetails( + individualCredits: 12.5, + workspaceBalances: [AmpWorkspaceBalance(name: "Team", remaining: 7.25)]) + let snapshot = UsageSnapshot( + primary: nil, + secondary: nil, + ampUsage: ampUsage, + cursorRequests: CursorRequestUsage(used: 10, limit: 50), + subscriptionExpiresAt: updatedAt.addingTimeInterval(100), + subscriptionRenewsAt: updatedAt.addingTimeInterval(200), + updatedAt: updatedAt) + let identity = ProviderIdentitySnapshot( + providerID: .kilo, + accountEmail: "test@example.com", + accountOrganization: "Example", + loginMethod: "API") + + let copied = snapshot.withIdentity(identity) + + #expect(copied.ampUsage == ampUsage) + #expect(copied.cursorRequests?.used == 10) + #expect(copied.subscriptionExpiresAt == updatedAt.addingTimeInterval(100)) + #expect(copied.subscriptionRenewsAt == updatedAt.addingTimeInterval(200)) + #expect(copied.identity?.accountOrganization == "Example") + } + + @Test + func `copilot icon falls back to chat lane when selected budget is unavailable`() { + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 20, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: RateWindow(usedPercent: 30, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + extraRateWindows: nil, + updatedAt: Date()) + + let remaining = IconRemainingResolver.resolvedRemaining( + snapshot: snapshot, + style: .copilot, + secondaryOverrideWindowID: "copilot-budget-agent") + + #expect(remaining.primary == 80) + #expect(remaining.secondary == 70) + } + + @Test + func `copilot icon uses selected budget in show used mode`() { + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 20, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: RateWindow(usedPercent: 30, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + extraRateWindows: [ + NamedRateWindow( + id: "copilot-budget-agent", + title: "Budget - Copilot Agent Premium Requests", + window: RateWindow(usedPercent: 65, windowMinutes: nil, resetsAt: nil, resetDescription: nil)), + ], + updatedAt: Date()) + + let percents = IconRemainingResolver.resolvedPercents( + snapshot: snapshot, + style: .copilot, + showUsed: true, + secondaryOverrideWindowID: "copilot-budget-agent") + + #expect(percents.primary == 20) + #expect(percents.secondary == 65) + } + + @Test + func `warp icon preserves exhausted bonus layout in show used mode`() { + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 10, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: RateWindow(usedPercent: 100, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + updatedAt: Date()) + + let percents = IconRemainingResolver.resolvedPercents( + snapshot: snapshot, + style: .warp, + showUsed: true) + + #expect(percents.primary == 10) + #expect(percents.secondary == 0) + } + + @Test + func `warp icon keeps unused bonus lane visible in show used mode`() { + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 10, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: RateWindow(usedPercent: 0, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + updatedAt: Date()) + + let percents = IconRemainingResolver.resolvedPercents( + snapshot: snapshot, + style: .warp, + showUsed: true) + + #expect(percents.primary == 10) + #expect(percents.secondary != nil) + #expect(percents.secondary ?? 1 < 0.01) + } + + @Test + func `merged icon keeps exhausted warp bonus fully used`() { + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 10, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: RateWindow(usedPercent: 100, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + updatedAt: Date()) + + let percents = IconRemainingResolver.resolvedPercents( + snapshot: snapshot, + style: .warp, + showUsed: true, + renderingStyle: .combined) + + #expect(percents.primary == 10) + #expect(percents.secondary == 100) + } + + @Test + @MainActor + func `status icon accessibility uses percentage scale`() { + #expect( + StatusIconView.accessibilityPercentRemaining(50) == + String(format: L("%d percent remaining"), 50)) + } + @Test func `codex icon promotes weekly only window into primary display lane`() { let snapshot = UsageSnapshot( @@ -189,6 +636,84 @@ struct CodexBarTests { #expect(remaining.secondary == nil) } + @Test + func `codex icon caps session only until exhausted weekly lane resets`() { + let now = Date(timeIntervalSince1970: 1_800_000_000) + let weeklyReset = now.addingTimeInterval(3600) + let snapshot = UsageSnapshot( + primary: RateWindow( + usedPercent: 1, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(1800), + resetDescription: nil), + secondary: RateWindow( + usedPercent: 100, + windowMinutes: 10080, + resetsAt: weeklyReset, + resetDescription: nil), + updatedAt: now.addingTimeInterval(-7200)) + + let capped = IconRemainingResolver.resolvedRemaining(snapshot: snapshot, style: .codex, now: now) + let reset = IconRemainingResolver.resolvedRemaining( + snapshot: snapshot, + style: .codex, + now: weeklyReset) + + #expect(capped.primary == 0) + #expect(capped.secondary == 0) + #expect(reset.primary == 99) + #expect(reset.secondary == nil) + } + + @Test + func `status overlays cut halos through the quota bar and keep glyphs visible`() throws { + let plain = IconRenderer.makeIcon( + primaryRemaining: 100, + weeklyRemaining: 100, + creditsRemaining: nil, + stale: false, + style: .combined, + statusIndicator: .none) + let plainRep = try #require(plain.representations.compactMap { $0 as? NSBitmapImageRep }.first { + $0.pixelsWide == 36 && $0.pixelsHigh == 36 + }) + + func alpha(_ rep: NSBitmapImageRep, x: Int, y: Int) -> CGFloat { + (rep.colorAt(x: x, y: y) ?? .clear).alphaComponent + } + + for indicator in [ProviderStatusIndicator.minor, .major] { + let marked = IconRenderer.makeIcon( + primaryRemaining: 100, + weeklyRemaining: 100, + creditsRemaining: nil, + stale: false, + style: .combined, + statusIndicator: indicator) + let markedRep = try #require(marked.representations.compactMap { $0 as? NSBitmapImageRep }.first { + $0.pixelsWide == 36 && $0.pixelsHigh == 36 + }) + + var cutoutPixels = 0 + var glyphPixels = 0 + for y in 0.. 0.5, markedAlpha < 0.05 { + cutoutPixels += 1 + } + if plainAlpha < 0.05, markedAlpha > 0.5 { + glyphPixels += 1 + } + } + } + + #expect(cutoutPixels >= 8, "Expected halo cutout pixels for \(indicator)") + #expect(glyphPixels >= 4, "Expected visible glyph pixels for \(indicator)") + } + } + @Test func `icon renderer codex eyes punch through when unknown`() { // Regression: when remaining is nil, CoreGraphics inherits the previous fill alpha which caused diff --git a/Tests/CodexBarTests/CommandCodeProviderTests.swift b/Tests/CodexBarTests/CommandCodeProviderTests.swift index 5273b244cf..a54fa53e39 100644 --- a/Tests/CodexBarTests/CommandCodeProviderTests.swift +++ b/Tests/CodexBarTests/CommandCodeProviderTests.swift @@ -1,23 +1,123 @@ -import CodexBarCore +import Foundation import Testing @testable import CodexBar +@testable import CodexBarCore struct CommandCodeProviderTests { + private final class CookieAttemptRecorder: @unchecked Sendable { + private let lock = NSLock() + private var cookieHeaders: [String] = [] + + func append(_ cookieHeader: String) { + self.lock.withLock { + self.cookieHeaders.append(cookieHeader) + } + } + + func snapshot() -> [String] { + self.lock.withLock { self.cookieHeaders } + } + } + @Test func `descriptor metadata is correct`() { let descriptor = ProviderDescriptorRegistry.descriptor(for: .commandcode) #expect(descriptor.metadata.displayName == "Command Code") #expect(descriptor.metadata.dashboardURL == "https://commandcode.ai/studio") - #expect(descriptor.metadata.subscriptionDashboardURL == "https://commandcode.ai/sixhobbits/settings/billing") + #expect(descriptor.metadata.subscriptionDashboardURL == "https://commandcode.ai/settings/billing") #expect(descriptor.metadata.cliName == "commandcode") #expect(descriptor.branding.iconResourceName == "ProviderIcon-commandcode") #expect(descriptor.branding.iconStyle == .commandcode) } + @Test + func `manual cookie makes web strategy available`() async { + let context = self.makeContext(cookieSource: .manual, manualCookieHeader: "session=manual") + + #expect(await CommandCodeWebFetchStrategy().isAvailable(context)) + } + + @Test + func `automatic cookie fetch retries Vivaldi after stale earlier browser session`() async throws { + let recorder = CookieAttemptRecorder() + let strategy = CommandCodeWebFetchStrategy( + usageLoader: { cookieHeader in + recorder.append(cookieHeader) + guard cookieHeader == "session=vivaldi" else { + throw CommandCodeUsageError.invalidCredentials + } + return Self.snapshot() + }, + sessionLoader: { + [ + CommandCodeResolvedSession(cookieHeader: "session=stale", sourceLabel: "Chrome Default"), + CommandCodeResolvedSession(cookieHeader: "session=vivaldi", sourceLabel: "Vivaldi Default"), + ] + }) + + let result = try await strategy.fetch(self.makeContext(cookieSource: .auto)) + + #expect(recorder.snapshot() == ["session=stale", "session=vivaldi"]) + #expect(result.sourceLabel == "Vivaldi Default") + } + + @Test + func `automatic cookie fetch does not hide non-auth failure with later session`() async { + let recorder = CookieAttemptRecorder() + let strategy = CommandCodeWebFetchStrategy( + usageLoader: { cookieHeader in + recorder.append(cookieHeader) + throw CommandCodeUsageError.networkError("offline") + }, + sessionLoader: { + [ + CommandCodeResolvedSession(cookieHeader: "session=first", sourceLabel: "Chrome Default"), + CommandCodeResolvedSession(cookieHeader: "session=vivaldi", sourceLabel: "Vivaldi Default"), + ] + }) + + await #expect(throws: CommandCodeUsageError.networkError("offline")) { + try await strategy.fetch(self.makeContext(cookieSource: .auto)) + } + #expect(recorder.snapshot() == ["session=first"]) + } + @MainActor @Test func `implementation is registered`() { #expect(ProviderCatalog.implementation(for: .commandcode) != nil) } + + private static func snapshot() -> CommandCodeUsageSnapshot { + CommandCodeUsageSnapshot( + monthlyCreditsRemaining: 10, + purchasedCredits: 0, + premiumMonthlyCredits: 0, + opensourceMonthlyCredits: 0, + plan: nil, + billingPeriodEnd: nil, + subscriptionStatus: nil) + } + + private func makeContext( + cookieSource: ProviderCookieSource, + manualCookieHeader: String? = nil) -> ProviderFetchContext + { + let browserDetection = BrowserDetection(cacheTTL: 0) + let settings = ProviderSettingsSnapshot.make( + commandcode: .init(cookieSource: cookieSource, manualCookieHeader: manualCookieHeader)) + return ProviderFetchContext( + runtime: .cli, + sourceMode: .web, + includeCredits: true, + webTimeout: 1, + webDebugDumpHTML: false, + verbose: false, + env: [:], + settings: settings, + fetcher: UsageFetcher(), + claudeFetcher: ClaudeUsageFetcher(browserDetection: browserDetection), + browserDetection: browserDetection) + } } diff --git a/Tests/CodexBarTests/CommandCodeQuotaTransitionTests.swift b/Tests/CodexBarTests/CommandCodeQuotaTransitionTests.swift new file mode 100644 index 0000000000..618aa5281f --- /dev/null +++ b/Tests/CodexBarTests/CommandCodeQuotaTransitionTests.swift @@ -0,0 +1,175 @@ +import Foundation +import Testing +@testable import CodexBar +@testable import CodexBarCore + +@MainActor +struct CommandCodeQuotaTransitionTests { + @Test + func `display keeps prior primary only during subscription enrichment failure`() throws { + let plan = try #require(CommandCodePlanCatalog.plans.first { $0.monthlyCreditsUSD > 0 }) + let availableWithPlan = self.snapshot(remaining: 6, plan: plan) + let missingSubscription = self.snapshot( + remaining: 0, + purchasedCredits: 5, + plan: nil, + subscriptionUnavailable: true) + let freeTier = self.snapshot(remaining: 0, plan: nil) + let freeTierWithPurchasedCredits = self.snapshot(remaining: 0, purchasedCredits: 5, plan: nil) + + #expect(missingSubscription.primary?.usedPercent == 0) + #expect(freeTierWithPurchasedCredits.primary?.usedPercent == 0) + + let stabilized = UsageStore.commandCodeSnapshotResolvingDepletionOnEnrichmentFailure( + current: missingSubscription, + previous: availableWithPlan) + #expect(stabilized.primary?.usedPercent == 100) + + let stabilizedAgain = UsageStore.commandCodeSnapshotResolvingDepletionOnEnrichmentFailure( + current: missingSubscription, + previous: stabilized) + #expect(stabilizedAgain.primary?.usedPercent == 100) + + let startupFailure = UsageStore.commandCodeSnapshotResolvingDepletionOnEnrichmentFailure( + current: missingSubscription, + previous: nil) + #expect(startupFailure.primary?.usedPercent == 0) + + let freeTierFailure = UsageStore.commandCodeSnapshotResolvingDepletionOnEnrichmentFailure( + current: missingSubscription, + previous: freeTierWithPurchasedCredits) + #expect(freeTierFailure.primary?.usedPercent == 0) + + let validFreeTier = UsageStore.commandCodeSnapshotResolvingDepletionOnEnrichmentFailure( + current: freeTier, + previous: availableWithPlan) + #expect(validFreeTier.primary == nil) + } + + @Test + func `depleted notification does not refire across missing subscription window`() throws { + let settings = self.makeSettings(suiteName: "CommandCodeDepletedNoRefire") + settings.sessionQuotaNotificationsEnabled = true + let notifier = NotifierSpy() + let store = self.makeStore(settings: settings, notifier: notifier) + let plan = try #require(CommandCodePlanCatalog.plans.first { $0.monthlyCreditsUSD > 0 }) + let depletedWithPlan = self.snapshot(remaining: 0, plan: plan) + let freeTier = self.snapshot(remaining: 0, plan: nil) + let missingSubscription = self.snapshot( + remaining: 0, + purchasedCredits: 5, + plan: nil, + subscriptionUnavailable: true) + + store.handleSessionQuotaTransition(provider: .commandcode, snapshot: freeTier) + #expect(notifier.posts.isEmpty) + + store.handleSessionQuotaTransition(provider: .commandcode, snapshot: depletedWithPlan) + let stabilizedFailure = UsageStore.commandCodeSnapshotResolvingDepletionOnEnrichmentFailure( + current: missingSubscription, + previous: depletedWithPlan) + store.handleSessionQuotaTransition(provider: .commandcode, snapshot: stabilizedFailure) + let repeatedFailure = UsageStore.commandCodeSnapshotResolvingDepletionOnEnrichmentFailure( + current: missingSubscription, + previous: stabilizedFailure) + store.handleSessionQuotaTransition(provider: .commandcode, snapshot: repeatedFailure) + store.handleSessionQuotaTransition(provider: .commandcode, snapshot: depletedWithPlan) + + #expect(notifier.posts.count(where: { $0.transition == .depleted }) == 1) + + store.handleSessionQuotaTransition(provider: .commandcode, snapshot: freeTier) + store.handleSessionQuotaTransition(provider: .commandcode, snapshot: depletedWithPlan) + #expect(notifier.posts.count(where: { $0.transition == .depleted }) == 2) + } + + @Test + func `quota warning does not refire across missing subscription window`() throws { + let settings = self.makeSettings(suiteName: "CommandCodeWarningNoRefire") + settings.quotaWarningNotificationsEnabled = true + settings.quotaWarningThresholds = [50] + let notifier = NotifierSpy() + let store = self.makeStore(settings: settings, notifier: notifier) + let plan = try #require(CommandCodePlanCatalog.plans.first { $0.monthlyCreditsUSD > 0 }) + + store.handleQuotaWarningTransitions(provider: .commandcode, snapshot: self.snapshot(remaining: 6, plan: plan)) + store.handleQuotaWarningTransitions(provider: .commandcode, snapshot: self.snapshot(remaining: 4, plan: plan)) + let availableWithPlan = self.snapshot(remaining: 4, plan: plan) + let missingSubscription = self.snapshot( + remaining: 0, + purchasedCredits: 5, + plan: nil, + subscriptionUnavailable: true) + let stabilizedFailure = UsageStore.commandCodeSnapshotResolvingDepletionOnEnrichmentFailure( + current: missingSubscription, + previous: availableWithPlan) + store.handleQuotaWarningTransitions(provider: .commandcode, snapshot: stabilizedFailure) + let repeatedFailure = UsageStore.commandCodeSnapshotResolvingDepletionOnEnrichmentFailure( + current: missingSubscription, + previous: stabilizedFailure) + store.handleQuotaWarningTransitions(provider: .commandcode, snapshot: repeatedFailure) + store.handleQuotaWarningTransitions(provider: .commandcode, snapshot: self.snapshot(remaining: 4, plan: plan)) + + #expect(notifier.quotaWarningPosts.count == 1) + + store.handleQuotaWarningTransitions(provider: .commandcode, snapshot: self.snapshot(remaining: 0, plan: nil)) + store.handleQuotaWarningTransitions(provider: .commandcode, snapshot: self.snapshot(remaining: 4, plan: plan)) + #expect(notifier.quotaWarningPosts.count == 2) + } + + private func makeSettings(suiteName: String) -> SettingsStore { + let defaults = UserDefaults(suiteName: suiteName)! + defaults.removePersistentDomain(forName: suiteName) + let settings = SettingsStore( + userDefaults: defaults, + configStore: testConfigStore(suiteName: suiteName), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + settings.refreshFrequency = .manual + settings.statusChecksEnabled = false + return settings + } + + private func makeStore(settings: SettingsStore, notifier: NotifierSpy) -> UsageStore { + UsageStore( + fetcher: UsageFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + sessionQuotaNotifier: notifier) + } + + private func snapshot( + remaining: Double, + purchasedCredits: Double = 0, + plan: CommandCodePlanCatalog.Plan?, + subscriptionUnavailable: Bool = false) -> UsageSnapshot + { + CommandCodeUsageSnapshot( + monthlyCreditsRemaining: remaining, + purchasedCredits: purchasedCredits, + premiumMonthlyCredits: 0, + opensourceMonthlyCredits: 0, + plan: plan, + billingPeriodEnd: nil, + subscriptionStatus: plan == nil ? nil : "active", + subscriptionEnrichmentUnavailable: subscriptionUnavailable) + .toUsageSnapshot() + } + + private final class NotifierSpy: SessionQuotaNotifying { + private(set) var posts: [(transition: SessionQuotaTransition, provider: UsageProvider)] = [] + private(set) var quotaWarningPosts: [QuotaWarningEvent] = [] + + func post(transition: SessionQuotaTransition, provider: UsageProvider, badge _: NSNumber?) { + self.posts.append((transition, provider)) + } + + func postQuotaWarning( + event: QuotaWarningEvent, + provider _: UsageProvider, + soundEnabled _: Bool, + onScreenAlertEnabled _: Bool) + { + self.quotaWarningPosts.append(event) + } + } +} diff --git a/Tests/CodexBarTests/CommandCodeUsageFetcherTests.swift b/Tests/CodexBarTests/CommandCodeUsageFetcherTests.swift index f8425f97ec..5e76290e27 100644 --- a/Tests/CodexBarTests/CommandCodeUsageFetcherTests.swift +++ b/Tests/CodexBarTests/CommandCodeUsageFetcherTests.swift @@ -1,6 +1,9 @@ import Foundation import Testing @testable import CodexBarCore +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif /// Tests for `CommandCodeUsageFetcher` parsers and the cookie/snapshot derivation, /// using real responses captured from api.commandcode.ai for an active "individual-go" plan. @@ -49,6 +52,257 @@ struct CommandCodeUsageFetcherTests { #expect(payload == nil) } + @Test + func `successful free tier lookup has no usage window`() async throws { + let transport = ProviderHTTPTransportStub { request in + let path = try #require(request.url?.path) + let body = if path.hasSuffix("/credits") { + """ + {"credits":{"monthlyCredits":0,"purchasedCredits":0, + "premiumMonthlyCredits":0,"opensourceMonthlyCredits":0}} + """ + } else { + #"{"success":true,"data":null}"# + } + return try Self.response(request: request, statusCode: 200, body: body) + } + + let snapshot = try await CommandCodeUsageFetcher.fetchUsage( + cookieHeader: "session=valid", + session: transport) + + #expect(snapshot.subscriptionEnrichmentUnavailable == false) + #expect(snapshot.toUsageSnapshot().primary == nil) + } + + @Test + func `subscription failure envelope preserves required credits`() async throws { + let transport = ProviderHTTPTransportStub { request in + let path = try #require(request.url?.path) + if path.hasSuffix("/credits") { + return try Self.response(request: request, statusCode: 200, body: Self.creditsJSON) + } + return try Self.response( + request: request, + statusCode: 200, + body: #"{"success":false,"error":"temporarily unavailable"}"#) + } + + let snapshot = try await CommandCodeUsageFetcher.fetchUsage( + cookieHeader: "session=valid", + session: transport, + now: Date(timeIntervalSince1970: 123)) + + #expect(snapshot.monthlyCreditsRemaining == 8.7784) + #expect(snapshot.plan == nil) + #expect(snapshot.subscriptionEnrichmentUnavailable) + #expect(snapshot.updatedAt == Date(timeIntervalSince1970: 123)) + } + + @Test + func `successful subscription envelope requires explicit data`() throws { + let data = Data(#"{"success":true}"#.utf8) + + #expect(throws: CommandCodeUsageError.self) { + try CommandCodeUsageFetcher.parseSubscription(data: data) + } + } + + @Test + func `subscription failure preserves required credits`() async throws { + let transport = ProviderHTTPTransportStub { request in + let path = try #require(request.url?.path) + if path.hasSuffix("/credits") { + return try Self.response(request: request, statusCode: 200, body: Self.creditsJSON) + } + return try Self.response(request: request, statusCode: 503, body: #"{"error":"unavailable"}"#) + } + + let snapshot = try await CommandCodeUsageFetcher.fetchUsage( + cookieHeader: "session=valid", + session: transport, + now: Date(timeIntervalSince1970: 123)) + + #expect(snapshot.monthlyCreditsRemaining == 8.7784) + #expect(snapshot.plan == nil) + #expect(snapshot.billingPeriodEnd == nil) + #expect(snapshot.subscriptionEnrichmentUnavailable) + #expect(snapshot.updatedAt == Date(timeIntervalSince1970: 123)) + } + + @Test + func `subscription timeout does not hold credits for full request timeout`() async throws { + let transport = ProviderHTTPTransportStub { request in + let path = try #require(request.url?.path) + if path.hasSuffix("/credits") { + return try Self.response(request: request, statusCode: 200, body: Self.creditsJSON) + } + try await Task.sleep(for: .seconds(10)) + return try Self.response(request: request, statusCode: 200, body: Self.subscriptionJSON) + } + + let startedAt = ContinuousClock.now + let snapshot = try await CommandCodeUsageFetcher.fetchUsage( + cookieHeader: "session=valid", + session: transport) + let elapsed = startedAt.duration(to: .now) + + #expect(snapshot.monthlyCreditsRemaining == 8.7784) + #expect(snapshot.plan == nil) + #expect(snapshot.subscriptionEnrichmentUnavailable) + #expect(elapsed < .seconds(3), "Subscription enrichment delayed credits: \(elapsed)") + } + + @Test + func `subscription grace does not wait for transport that ignores cancellation`() async throws { + let transport = ProviderHTTPTransportStub { request in + let path = try #require(request.url?.path) + if path.hasSuffix("/credits") { + return try Self.response(request: request, statusCode: 200, body: Self.creditsJSON) + } + let response = try Self.response(request: request, statusCode: 200, body: Self.subscriptionJSON) + return await withCheckedContinuation { continuation in + DispatchQueue.global().asyncAfter(deadline: .now() + 0.5) { + continuation.resume(returning: response) + } + } + } + + let startedAt = ContinuousClock.now + let snapshot = try await CommandCodeUsageFetcher._fetchUsageForTesting( + cookieHeader: "session=valid", + transport: transport, + subscriptionGrace: .milliseconds(20)) + let elapsed = startedAt.duration(to: .now) + + #expect(snapshot.monthlyCreditsRemaining == 8.7784) + #expect(snapshot.plan == nil) + #expect(snapshot.subscriptionEnrichmentUnavailable) + #expect(elapsed < .milliseconds(300), "Subscription enrichment delayed credits: \(elapsed)") + + // Let the deliberately cancellation-ignoring test task drain before the test exits. + try await Task.sleep(for: .milliseconds(550)) + } + + @Test + func `cancellation after credits complete does not return partial snapshot`() async throws { + let subscriptionStarted = CommandCodeRequestGate() + let transport = ProviderHTTPTransportStub { request in + let path = try #require(request.url?.path) + if path.hasSuffix("/credits") { + return try Self.response(request: request, statusCode: 200, body: Self.creditsJSON) + } + await subscriptionStarted.open() + try await Task.sleep(for: .seconds(10)) + return try Self.response(request: request, statusCode: 200, body: Self.subscriptionJSON) + } + let task = Task { + try await CommandCodeUsageFetcher.fetchUsage( + cookieHeader: "session=valid", + session: transport) + } + + await subscriptionStarted.wait() + try await Task.sleep(for: .milliseconds(50)) + task.cancel() + + await #expect(throws: CancellationError.self) { + try await task.value + } + } + + @Test + func `cancellation cleans up subscription when credits transport ignores cancellation`() async throws { + let creditsStarted = CommandCodeRequestGate() + let subscriptionStarted = CommandCodeRequestGate() + let subscriptionCancelled = CommandCodeRequestGate() + let transport = ProviderHTTPTransportStub { request in + let path = try #require(request.url?.path) + if path.hasSuffix("/credits") { + await creditsStarted.open() + let response = try Self.response(request: request, statusCode: 200, body: Self.creditsJSON) + return await withCheckedContinuation { continuation in + DispatchQueue.global().asyncAfter(deadline: .now() + 0.5) { + continuation.resume(returning: response) + } + } + } + await subscriptionStarted.open() + do { + try await Task.sleep(for: .seconds(10)) + } catch { + await subscriptionCancelled.open() + throw error + } + return try Self.response(request: request, statusCode: 200, body: Self.subscriptionJSON) + } + let task = Task { + try await CommandCodeUsageFetcher.fetchUsage( + cookieHeader: "session=valid", + session: transport) + } + + await creditsStarted.wait() + await subscriptionStarted.wait() + let cancellationStartedAt = ContinuousClock.now + task.cancel() + + await subscriptionCancelled.wait() + #expect(cancellationStartedAt.duration(to: .now) < .milliseconds(300)) + await #expect(throws: CancellationError.self) { + try await task.value + } + } + + @Test + func `cancellation wins when optional transport ignores cancellation then fails`() async throws { + let subscriptionStarted = CommandCodeRequestGate() + let transport = ProviderHTTPTransportStub { request in + let path = try #require(request.url?.path) + if path.hasSuffix("/credits") { + return try Self.response(request: request, statusCode: 200, body: Self.creditsJSON) + } + await subscriptionStarted.open() + do { + try await Task.sleep(for: .seconds(10)) + } catch { + // Simulate a transport that converts cancellation into an ordinary endpoint failure. + } + return try Self.response(request: request, statusCode: 503, body: #"{"error":"unavailable"}"#) + } + let task = Task { + try await CommandCodeUsageFetcher.fetchUsage( + cookieHeader: "session=valid", + session: transport) + } + + await subscriptionStarted.wait() + try await Task.sleep(for: .milliseconds(50)) + task.cancel() + + await #expect(throws: CancellationError.self) { + try await task.value + } + } + + @Test + func `successful unknown active subscription still fails explicitly`() async { + let unknownPlanJSON = Self.subscriptionJSON.replacingOccurrences( + of: #""planId":"individual-go""#, + with: #""planId":"individual-future""#) + let transport = ProviderHTTPTransportStub { request in + let path = try #require(request.url?.path) + let body = path.hasSuffix("/credits") ? Self.creditsJSON : unknownPlanJSON + return try Self.response(request: request, statusCode: 200, body: body) + } + + await #expect(throws: CommandCodeUsageError.unknownPlan("individual-future")) { + try await CommandCodeUsageFetcher.fetchUsage( + cookieHeader: "session=valid", + session: transport) + } + } + @Test func `snapshot derives used and total from plan catalog`() throws { let plan = try #require(CommandCodePlanCatalog.plan(forID: "individual-go")) @@ -71,6 +325,20 @@ struct CommandCodeUsageFetcherTests { #expect(usage.identity?.loginMethod == "Go · $1.22 of $10.00") } + @Test + func `free tier with no allowance has no usage window`() { + let snapshot = CommandCodeUsageSnapshot( + monthlyCreditsRemaining: 0, + purchasedCredits: 0, + premiumMonthlyCredits: 0, + opensourceMonthlyCredits: 0, + plan: nil, + billingPeriodEnd: nil, + subscriptionStatus: nil) + + #expect(snapshot.toUsageSnapshot().primary == nil) + } + @Test func `plan catalog covers known plans`() { #expect(CommandCodePlanCatalog.plan(forID: "individual-go")?.monthlyCreditsUSD == 10) @@ -110,4 +378,39 @@ struct CommandCodeUsageFetcherTests { #expect(CommandCodeCookieHeader.override(from: "") == nil) #expect(CommandCodeCookieHeader.override(from: " ") == nil) } + + private static func response( + request: URLRequest, + statusCode: Int, + body: String) throws -> (Data, URLResponse) + { + let url = try #require(request.url) + let response = try #require(HTTPURLResponse( + url: url, + statusCode: statusCode, + httpVersion: nil, + headerFields: nil)) + return (Data(body.utf8), response) + } +} + +private actor CommandCodeRequestGate { + private var isOpen = false + private var continuations: [CheckedContinuation] = [] + + func wait() async { + guard !self.isOpen else { return } + await withCheckedContinuation { continuation in + self.continuations.append(continuation) + } + } + + func open() { + self.isOpen = true + let continuations = self.continuations + self.continuations.removeAll() + for continuation in continuations { + continuation.resume() + } + } } diff --git a/Tests/CodexBarTests/ConfigValidationTests.swift b/Tests/CodexBarTests/ConfigValidationTests.swift index da42392cc2..7c22a6a791 100644 --- a/Tests/CodexBarTests/ConfigValidationTests.swift +++ b/Tests/CodexBarTests/ConfigValidationTests.swift @@ -3,6 +3,84 @@ import Foundation import Testing struct ConfigValidationTests { + @Test + func `reports unsafe hook rule fields`() { + let invalidRules = [ + HookRule(id: "duplicate", event: .quotaLow, provider: "unknown", threshold: 1.1, executable: "echo"), + HookRule( + id: "duplicate", + event: .quotaReached, + executable: "/bin/echo", + timeoutSeconds: 301), + ] + let config = CodexBarConfig( + providers: [ProviderConfig(id: .codex)], + hooks: HooksConfig(enabled: true, events: invalidRules)) + let codes = Set(CodexBarConfigValidator.validate(config).map(\.code)) + + #expect(codes.contains("invalid_hook_executable")) + #expect(codes.contains("invalid_hook_provider")) + #expect(codes.contains("invalid_hook_threshold")) + #expect(codes.contains("invalid_hook_timeout")) + #expect(codes.contains("duplicate_hook_id")) + } + + @Test + func `reports hook workload limits`() { + let oversized = HookRule( + id: String(repeating: "i", count: HookRule.maximumIDBytes + 1), + event: .quotaReached, + executable: "/bin/echo", + arguments: Array(repeating: "x", count: HookRule.maximumArgumentCount + 1)) + let rules = Array(repeating: oversized, count: HooksConfig.maximumRuleCount + 1) + let config = CodexBarConfig(providers: [], hooks: HooksConfig(enabled: true, events: rules)) + let codes = Set(CodexBarConfigValidator.validate(config).map(\.code)) + + #expect(codes.contains("too_many_hook_rules")) + #expect(codes.contains("invalid_hook_command_size")) + } + + @Test + func `fresh config defaults Alibaba Token Plan to International`() throws { + let config = CodexBarConfig.makeDefault() + let provider = try #require(config.providerConfig(for: .alibabatokenplan)) + let issues = CodexBarConfigValidator.validate(config) + + #expect(provider.region == AlibabaTokenPlanAPIRegion.international.rawValue) + #expect(!issues.contains(where: { $0.provider == .alibabatokenplan })) + } + + @Test + func `normalization preserves legacy Alibaba Token Plan region`() throws { + let config = CodexBarConfig(providers: [ + ProviderConfig(id: .alibabatokenplan, region: nil), + ]).normalized() + let provider = try #require(config.providerConfig(for: .alibabatokenplan)) + + #expect(provider.region == nil) + } + + @Test + func `normalization adds missing Alibaba Token Plan as China mainland`() throws { + let config = CodexBarConfig(providers: [ + ProviderConfig(id: .codex), + ]).normalized() + let provider = try #require(config.providerConfig(for: .alibabatokenplan)) + + #expect(provider.region == AlibabaTokenPlanAPIRegion.chinaMainland.rawValue) + } + + @Test + func `reports invalid Alibaba Token Plan region`() { + var config = CodexBarConfig.makeDefault() + config.setProviderConfig(ProviderConfig(id: .alibabatokenplan, region: "nowhere")) + let issues = CodexBarConfigValidator.validate(config) + + #expect(issues.contains(where: { + $0.provider == .alibabatokenplan && $0.code == "invalid_region" + })) + } + @Test func `reports unsupported source`() { var config = CodexBarConfig.makeDefault() @@ -11,6 +89,17 @@ struct ConfigValidationTests { #expect(issues.contains(where: { $0.code == "unsupported_source" })) } + @Test + func `accepts legacy factory cli source as compatibility alias`() { + var config = CodexBarConfig.makeDefault() + config.setProviderConfig(ProviderConfig(id: .factory, source: .cli)) + let issues = CodexBarConfigValidator.validate(config) + #expect(!issues.contains(where: { + $0.provider == .factory && $0.code == "unsupported_source" + })) + #expect(FactoryProviderDescriptor.descriptor.fetchPlan.sourceModes.contains(.cli)) + } + @Test func `reports missing API key when source API`() { var config = CodexBarConfig.makeDefault() @@ -19,6 +108,108 @@ struct ConfigValidationTests { #expect(issues.contains(where: { $0.code == "api_key_missing" })) } + @Test + func `allows credentialless Wayfinder API source`() { + var config = CodexBarConfig.makeDefault() + config.setProviderConfig(ProviderConfig( + id: .wayfinder, + source: .api, + enterpriseHost: "http://127.0.0.1:9191")) + let issues = CodexBarConfigValidator.validate(config) + + #expect(!issues.contains(where: { $0.provider == .wayfinder && $0.code == "api_key_missing" })) + } + + @Test + func `sub2api token accounts satisfy API credentials`() { + let accounts = ProviderTokenAccountData( + version: 1, + accounts: [ + ProviderTokenAccount( + id: UUID(), + label: "Primary", + token: "fixture", + addedAt: 0, + lastUsed: nil), + ], + activeIndex: 0) + var config = CodexBarConfig.makeDefault() + config.setProviderConfig(ProviderConfig( + id: .sub2api, + source: .api, + enterpriseHost: "https://sub2api.example.com", + tokenAccounts: accounts)) + let issues = CodexBarConfigValidator.validate(config) + + #expect(!issues.contains(where: { $0.provider == .sub2api && $0.code == "api_key_missing" })) + } + + @Test + func `sub2api accepts HTTPS and loopback HTTP base URLs`() { + for host in ["https://sub2api.example.com", "http://127.0.0.1:8080"] { + var config = CodexBarConfig.makeDefault() + config.setProviderConfig(ProviderConfig( + id: .sub2api, + source: .api, + apiKey: "fixture", + enterpriseHost: host)) + let invalidHostIssue = CodexBarConfigValidator.validate(config).first { issue in + issue.provider == .sub2api && issue.code == "invalid_enterprise_host" + } + + #expect(invalidHostIssue == nil) + } + } + + @Test + func `sub2api rejects unsafe base URLs`() { + let invalidHosts = [ + "http://sub2api.example.com", + "https://user:pass@sub2api.example.com", + "https://sub2api.example.com?token=secret", + "https://sub2api.example.com#fragment", + ] + for host in invalidHosts { + var config = CodexBarConfig.makeDefault() + config.setProviderConfig(ProviderConfig( + id: .sub2api, + source: .api, + apiKey: "fixture", + enterpriseHost: host)) + let invalidHostIssue = CodexBarConfigValidator.validate(config).first { issue in + issue.provider == .sub2api && + issue.field == "enterpriseHost" && + issue.code == "invalid_enterprise_host" + } + + #expect(invalidHostIssue != nil) + } + } + + @Test + func `sub2api rejects blank token accounts as API credentials`() { + let accounts = ProviderTokenAccountData( + version: 1, + accounts: [ + ProviderTokenAccount( + id: UUID(), + label: "Blank", + token: " ", + addedAt: 0, + lastUsed: nil), + ], + activeIndex: 0) + var config = CodexBarConfig.makeDefault() + config.setProviderConfig(ProviderConfig( + id: .sub2api, + source: .api, + enterpriseHost: "https://sub2api.example.com", + tokenAccounts: accounts)) + let issues = CodexBarConfigValidator.validate(config) + + #expect(issues.contains(where: { $0.provider == .sub2api && $0.code == "api_key_missing" })) + } + @Test func `reports invalid region`() { var config = CodexBarConfig.makeDefault() @@ -80,6 +271,32 @@ struct ConfigValidationTests { #expect(!issues.contains(where: { $0.provider == .azureopenai && $0.code == "enterprise_host_unused" })) } + @Test + func `allows LiteLLM endpoint`() { + var config = CodexBarConfig.makeDefault() + config.setProviderConfig(ProviderConfig( + id: .litellm, + apiKey: "sk-test", + enterpriseHost: "https://litellm.example.com")) + let issues = CodexBarConfigValidator.validate(config) + + #expect(!issues.contains(where: { $0.provider == .litellm && $0.code == "enterprise_host_unused" })) + } + + @Test + func `unsupported enterprise host warning lists every supported provider`() throws { + var config = CodexBarConfig.makeDefault() + config.setProviderConfig(ProviderConfig(id: .gemini, enterpriseHost: "https://example.com")) + let issue = try #require(CodexBarConfigValidator.validate(config).first(where: { + $0.provider == .gemini && $0.code == "enterprise_host_unused" + })) + + #expect(issue.message == + "enterpriseHost is set but only azureopenai, clawrouter, copilot, kimi, litellm, llmproxy, sub2api, and " + + "wayfinder " + + "support enterpriseHost.") + } + @Test func `allows OpenAI API project workspace ID`() { var config = CodexBarConfig.makeDefault() @@ -89,6 +306,42 @@ struct ConfigValidationTests { #expect(!issues.contains(where: { $0.provider == .openai && $0.code == "workspace_unused" })) } + @Test + func `allows doubao coding plan credential fields`() { + var config = CodexBarConfig.makeDefault() + config.setProviderConfig(ProviderConfig( + id: .doubao, + apiKey: "AKLT-config", + secretKey: "sk-config", + region: "cn-shanghai")) + let issues = CodexBarConfigValidator.validate(config) + + #expect(!issues.contains(where: { $0.provider == .doubao && $0.code == "secret_key_unused" })) + #expect(!issues.contains(where: { $0.provider == .doubao && $0.code == "region_unused" })) + } + + @Test + func `warns when zai team token account is missing BigModel context`() { + let accounts = ProviderTokenAccountData( + version: 1, + accounts: [ + ProviderTokenAccount( + id: UUID(), + label: "Team", + token: "token", + addedAt: 0, + lastUsed: nil, + usageScope: "team", + organizationID: "org_abc"), + ], + activeIndex: 0) + var config = CodexBarConfig.makeDefault() + config.setProviderConfig(ProviderConfig(id: .zai, tokenAccounts: accounts)) + let issues = CodexBarConfigValidator.validate(config) + + #expect(issues.contains(where: { $0.provider == .zai && $0.code == "zai_team_context_missing" })) + } + @Test func `warns on unsupported workspace ID`() { var config = CodexBarConfig.makeDefault() @@ -110,4 +363,102 @@ struct ConfigValidationTests { #expect(url.path.hasSuffix("/tmp/codexbar-test-config.json")) } + + @Test + func `config store default url honors xdg config home`() throws { + let fileManager = FileManager.default + let home = try Self.makeTemporaryHome() + defer { try? fileManager.removeItem(at: home) } + let xdgHome = home.appendingPathComponent("custom-config", isDirectory: true) + + let url = CodexBarConfigStore.defaultURL( + home: home, + environment: [ + CodexBarConfigStore.xdgConfigHomeEnvironmentKey: xdgHome.path, + ], + fileManager: fileManager) + + #expect(url == Self.configURL(in: xdgHome)) + } + + @Test + func `config store default url ignores relative xdg config home`() throws { + let fileManager = FileManager.default + let home = try Self.makeTemporaryHome() + defer { try? fileManager.removeItem(at: home) } + let legacy = Self.legacyConfigURL(in: home) + try Self.touch(legacy, fileManager: fileManager) + + let url = CodexBarConfigStore.defaultURL( + home: home, + environment: [ + CodexBarConfigStore.xdgConfigHomeEnvironmentKey: "relative-config", + ], + fileManager: fileManager) + + #expect(url == legacy) + } + + @Test + func `config store default url creates in xdg default for new installs`() throws { + let fileManager = FileManager.default + let home = try Self.makeTemporaryHome() + defer { try? fileManager.removeItem(at: home) } + + let url = CodexBarConfigStore.defaultURL(home: home, environment: [:], fileManager: fileManager) + + #expect(url == Self.configURL(in: home.appendingPathComponent(".config", isDirectory: true))) + } + + @Test + func `config store default url keeps existing legacy config`() throws { + let fileManager = FileManager.default + let home = try Self.makeTemporaryHome() + defer { try? fileManager.removeItem(at: home) } + let legacy = Self.legacyConfigURL(in: home) + try Self.touch(legacy, fileManager: fileManager) + + let url = CodexBarConfigStore.defaultURL(home: home, environment: [:], fileManager: fileManager) + + #expect(url == legacy) + } + + @Test + func `config store default url prefers existing xdg default over legacy config`() throws { + let fileManager = FileManager.default + let home = try Self.makeTemporaryHome() + defer { try? fileManager.removeItem(at: home) } + let xdgDefault = Self.configURL(in: home.appendingPathComponent(".config", isDirectory: true)) + let legacy = Self.legacyConfigURL(in: home) + try Self.touch(legacy, fileManager: fileManager) + try Self.touch(xdgDefault, fileManager: fileManager) + + let url = CodexBarConfigStore.defaultURL(home: home, environment: [:], fileManager: fileManager) + + #expect(url == xdgDefault) + } + + private static func makeTemporaryHome() throws -> URL { + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("CodexBarConfigStoreTests-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) + return url + } + + private static func touch(_ url: URL, fileManager: FileManager) throws { + try fileManager.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true) + try Data().write(to: url) + } + + private static func configURL(in directory: URL) -> URL { + directory + .appendingPathComponent("codexbar", isDirectory: true) + .appendingPathComponent("config.json") + } + + private static func legacyConfigURL(in home: URL) -> URL { + home + .appendingPathComponent(".codexbar", isDirectory: true) + .appendingPathComponent("config.json") + } } diff --git a/Tests/CodexBarTests/ConfigurationDocsProviderIDTests.swift b/Tests/CodexBarTests/ConfigurationDocsProviderIDTests.swift new file mode 100644 index 0000000000..ea14250b4f --- /dev/null +++ b/Tests/CodexBarTests/ConfigurationDocsProviderIDTests.swift @@ -0,0 +1,38 @@ +import CodexBarCore +import Foundation +import Testing + +struct ConfigurationDocsProviderIDTests { + @Test + func `configuration docs list every provider id in enum order`() throws { + let rootURL = try Self.repoRoot() + let docsURL = rootURL.appending(path: "docs/configuration.md") + let docs = try String(contentsOf: docsURL, encoding: .utf8) + + let marker = "## Provider IDs" + let sectionStart = try #require(docs.range(of: marker)?.upperBound) + let section = docs[sectionStart...] + let idsLine = try #require(section.split(separator: "\n").first { $0.hasPrefix("`") }) + + let documentedIDs = idsLine + .split(separator: ",") + .map { $0.trimmingCharacters(in: CharacterSet(charactersIn: " `.")) } + let expectedIDs = UsageProvider.allCases.map(\.rawValue) + + #expect(documentedIDs == expectedIDs) + } + + private static func repoRoot() throws -> URL { + var directory = URL(filePath: #filePath).deletingLastPathComponent() + for _ in 0..<12 { + let packageManifest = directory.appending(path: "Package.swift") + if FileManager.default.fileExists(atPath: packageManifest.path(percentEncoded: false)) { + return directory + } + directory.deleteLastPathComponent() + } + throw NSError(domain: "ConfigurationDocsProviderIDTests", code: 1, userInfo: [ + NSLocalizedDescriptionKey: "Could not locate repo root (Package.swift) from \(#filePath)", + ]) + } +} diff --git a/Tests/CodexBarTests/CookieHeaderCacheConditionalMutationTests.swift b/Tests/CodexBarTests/CookieHeaderCacheConditionalMutationTests.swift new file mode 100644 index 0000000000..ab07f9302a --- /dev/null +++ b/Tests/CodexBarTests/CookieHeaderCacheConditionalMutationTests.swift @@ -0,0 +1,320 @@ +import Foundation +import Testing +@testable import CodexBarCore + +@Suite(.serialized) +struct CookieHeaderCacheConditionalMutationTests { + #if os(macOS) + @Test + func `temporary keychain read permits fresh replacement when legacy state is unchanged`() { + self.withIsolatedCookieCache { + let legacy = CookieHeaderCache.Entry( + cookieHeader: "sessionKey=sk-ant-legacy", + storedAt: Date(timeIntervalSince1970: 1), + sourceLabel: "Legacy") + CookieHeaderCache.store(legacy, to: CookieHeaderCache.legacyURLForTesting(provider: .claude)) + + let observation = KeychainCacheStore.withLoadFailureStatusOverrideForTesting(errSecInteractionNotAllowed) { + CookieHeaderCache.observeForConditionalMutation(provider: .claude) + } + let replaced = CookieHeaderCache.storeIfObservationCurrent( + provider: .claude, + expected: observation, + cookieHeader: "sessionKey=sk-ant-fresh", + sourceLabel: "Safari") + + #expect(observation.entry == nil) + #expect(replaced) + #expect(CookieHeaderCache.load(provider: .claude)?.cookieHeader == "sessionKey=sk-ant-fresh") + #expect(!CookieHeaderCache.hasLegacyEntryForTesting(provider: .claude)) + } + } + + @Test + func `temporary keychain read does not overwrite a concurrent keychain entry`() { + self.withIsolatedCookieCache { + let observation = KeychainCacheStore.withLoadFailureStatusOverrideForTesting(errSecInteractionNotAllowed) { + CookieHeaderCache.observeForConditionalMutation(provider: .claude) + } + CookieHeaderCache.store( + provider: .claude, + cookieHeader: "sessionKey=sk-ant-concurrent", + sourceLabel: "Chrome") + + let replaced = CookieHeaderCache.storeIfObservationCurrent( + provider: .claude, + expected: observation, + cookieHeader: "sessionKey=sk-ant-fresh", + sourceLabel: "Safari") + + #expect(!replaced) + #expect(CookieHeaderCache.load(provider: .claude)?.cookieHeader == "sessionKey=sk-ant-concurrent") + } + } + + @Test + func `observable store failure preserves the current cookie entry`() { + self.withIsolatedCookieCache { + let initiallyStored = CookieHeaderCache.storeResult( + provider: .cursor, + cookieHeader: "WorkosCursorSessionToken=existing", + sourceLabel: "Chrome") + + let replaced = KeychainCacheStore.withStoreFailureStatusOverrideForTesting(errSecInteractionNotAllowed) { + CookieHeaderCache.storeResult( + provider: .cursor, + cookieHeader: "WorkosCursorSessionToken=replacement", + sourceLabel: "Comet") + } + + #expect(initiallyStored) + #expect(!replaced) + #expect(CookieHeaderCache.load(provider: .cursor)?.cookieHeader == + "WorkosCursorSessionToken=existing") + } + } + #endif + + @Test + func `legacy clear failure still permits replacing the keychain entry`() { + self.withIsolatedCookieCache { + CookieHeaderCache.store( + provider: .claude, + cookieHeader: "sessionKey=sk-ant-stale", + sourceLabel: "Chrome") + let stale = CookieHeaderCache.load(provider: .claude) + #expect(stale != nil) + guard let stale else { return } + + CookieHeaderCache.store( + CookieHeaderCache.Entry( + cookieHeader: "sessionKey=sk-ant-legacy", + storedAt: Date(timeIntervalSince1970: 1), + sourceLabel: "Legacy"), + to: CookieHeaderCache.legacyURLForTesting(provider: .claude)) + + let cleared = CookieHeaderCache.withLegacyRemovalFailureForTesting { + CookieHeaderCache.clearIfCurrent(provider: .claude, expected: stale) + } + let replaced = CookieHeaderCache.storeIfCurrent( + provider: .claude, + expected: stale, + cookieHeader: "sessionKey=sk-ant-fresh", + sourceLabel: "Safari") + + #expect(!cleared) + #expect(replaced) + #expect(CookieHeaderCache.load(provider: .claude)?.cookieHeader == "sessionKey=sk-ant-fresh") + #expect(!CookieHeaderCache.hasLegacyEntryForTesting(provider: .claude)) + } + } + + @Test + func `interactive mutation gate invalidates an earlier background observation`() { + self.withIsolatedCookieCache { + let scope = CookieHeaderCache.Scope.providerVariant(UUID().uuidString) + CookieHeaderCache.store( + provider: .cursor, + scope: scope, + cookieHeader: "fixtureSession=original", + sourceLabel: "Original") + let observation = CookieHeaderCache.observeForConditionalMutation(provider: .cursor, scope: scope) + let gate = CookieHeaderCache.beginConditionalMutationGate(provider: .cursor, scope: scope) + + #expect(!CookieHeaderCache.storeIfObservationCurrent( + provider: .cursor, + scope: scope, + expected: observation, + cookieHeader: "fixtureSession=background-during-login", + sourceLabel: "Background")) + #expect(CookieHeaderCache.storeResult( + provider: .cursor, + scope: scope, + cookieHeader: "fixtureSession=selected", + sourceLabel: "Interactive login")) + CookieHeaderCache.endConditionalMutationGate(gate) + + #expect(!CookieHeaderCache.storeIfObservationCurrent( + provider: .cursor, + scope: scope, + expected: observation, + cookieHeader: "fixtureSession=background-after-login", + sourceLabel: "Background")) + #expect(CookieHeaderCache.load(provider: .cursor, scope: scope)?.cookieHeader == "fixtureSession=selected") + } + } + + @Test + func `owned clear observation accepts fallback but preserves gate generation`() { + self.withIsolatedCookieCache { + let scope = CookieHeaderCache.Scope.providerVariant(UUID().uuidString) + CookieHeaderCache.store( + provider: .cursor, + scope: scope, + cookieHeader: "fixtureSession=stale", + sourceLabel: "Stale") + let stale = CookieHeaderCache.load(provider: .cursor, scope: scope) + let observation = CookieHeaderCache.observeForConditionalMutation(provider: .cursor, scope: scope) + + #expect(CookieHeaderCache.clearIfCurrent(provider: .cursor, scope: scope, expected: stale)) + let afterClear = observation.afterOwnedClear() + #expect(CookieHeaderCache.storeIfObservationCurrent( + provider: .cursor, + scope: scope, + expected: afterClear, + cookieHeader: "fixtureSession=browser-fallback", + sourceLabel: "Browser fallback")) + + let nextObservation = CookieHeaderCache.observeForConditionalMutation(provider: .cursor, scope: scope) + let fallback = CookieHeaderCache.load(provider: .cursor, scope: scope) + #expect(CookieHeaderCache.clearIfCurrent(provider: .cursor, scope: scope, expected: fallback)) + let gate = CookieHeaderCache.beginConditionalMutationGate(provider: .cursor, scope: scope) + CookieHeaderCache.endConditionalMutationGate(gate) + + #expect(!CookieHeaderCache.storeIfObservationCurrent( + provider: .cursor, + scope: scope, + expected: nextObservation.afterOwnedClear(), + cookieHeader: "fixtureSession=late-background", + sourceLabel: "Background")) + } + } + + @Test + func `observation captured during cancelled interactive mutation remains stale`() { + self.withIsolatedCookieCache { + let scope = CookieHeaderCache.Scope.providerVariant(UUID().uuidString) + CookieHeaderCache.store( + provider: .cursor, + scope: scope, + cookieHeader: "fixtureSession=original", + sourceLabel: "Original") + let gate = CookieHeaderCache.beginConditionalMutationGate(provider: .cursor, scope: scope) + let observation = CookieHeaderCache.observeForConditionalMutation(provider: .cursor, scope: scope) + + #expect(!CookieHeaderCache.storeIfObservationCurrent( + provider: .cursor, + scope: scope, + expected: observation, + cookieHeader: "fixtureSession=background-during-login", + sourceLabel: "Background")) + CookieHeaderCache.endConditionalMutationGate(gate) + + #expect(!CookieHeaderCache.storeIfObservationCurrent( + provider: .cursor, + scope: scope, + expected: observation, + cookieHeader: "fixtureSession=background-after-cancel", + sourceLabel: "Background")) + #expect(CookieHeaderCache.load(provider: .cursor, scope: scope)?.cookieHeader == "fixtureSession=original") + } + } + + @Test + func `nested interactive mutation gate blocks until outer flow ends`() { + self.withIsolatedCookieCache { + let scope = CookieHeaderCache.Scope.providerVariant(UUID().uuidString) + CookieHeaderCache.store( + provider: .cursor, + scope: scope, + cookieHeader: "fixtureSession=original", + sourceLabel: "Original") + let outerGate = CookieHeaderCache.beginConditionalMutationGate(provider: .cursor, scope: scope) + let runnerGate = CookieHeaderCache.beginConditionalMutationGate(provider: .cursor, scope: scope) + CookieHeaderCache.endConditionalMutationGate(runnerGate) + + let whileOuterGateIsActive = CookieHeaderCache.observeForConditionalMutation( + provider: .cursor, + scope: scope) + #expect(!CookieHeaderCache.storeIfObservationCurrent( + provider: .cursor, + scope: scope, + expected: whileOuterGateIsActive, + cookieHeader: "fixtureSession=background", + sourceLabel: "Background")) + CookieHeaderCache.endConditionalMutationGate(outerGate) + + let afterOuterGateEnds = CookieHeaderCache.observeForConditionalMutation(provider: .cursor, scope: scope) + #expect(CookieHeaderCache.storeIfObservationCurrent( + provider: .cursor, + scope: scope, + expected: afterOuterGateEnds, + cookieHeader: "fixtureSession=late-background", + sourceLabel: "Background")) + #expect(CookieHeaderCache.load(provider: .cursor, scope: scope)?.cookieHeader == + "fixtureSession=late-background") + } + } + + @Test + func `cookie cache persists while Keychain access is disabled`() { + KeychainCacheStore.resetDisabledAccessMemoryStoreForTesting() + defer { + KeychainCacheStore.resetDisabledAccessMemoryStoreForTesting() + KeychainAccessGate.resetOverrideForTesting() + } + + KeychainAccessGate.withTaskOverrideForTesting(true) { + KeychainCacheStore.withDisabledAccessMemoryStoreForTesting(true) { + KeychainCacheStore.withServiceOverrideForTesting("cookie-disabled-\(UUID().uuidString)") { + let observation = CookieHeaderCache.observeForConditionalMutation(provider: .cursor) + #expect(CookieHeaderCache.storeIfObservationCurrent( + provider: .cursor, + expected: observation, + cookieHeader: "WorkosCursorSessionToken=disabled-keychain", + sourceLabel: "Safari")) + #expect(CookieHeaderCache.load(provider: .cursor)?.cookieHeader == + "WorkosCursorSessionToken=disabled-keychain") + } + } + } + } + + @Test + func `interactive mutation gate still blocks stores while Keychain access is disabled`() { + KeychainCacheStore.resetDisabledAccessMemoryStoreForTesting() + defer { + KeychainCacheStore.resetDisabledAccessMemoryStoreForTesting() + KeychainAccessGate.resetOverrideForTesting() + } + + KeychainAccessGate.withTaskOverrideForTesting(true) { + KeychainCacheStore.withDisabledAccessMemoryStoreForTesting(true) { + KeychainCacheStore.withServiceOverrideForTesting("cookie-disabled-gate-\(UUID().uuidString)") { + let scope = CookieHeaderCache.Scope.providerVariant(UUID().uuidString) + CookieHeaderCache.store( + provider: .cursor, + scope: scope, + cookieHeader: "fixtureSession=original", + sourceLabel: "Original") + let observation = CookieHeaderCache.observeForConditionalMutation( + provider: .cursor, + scope: scope) + let gate = CookieHeaderCache.beginConditionalMutationGate(provider: .cursor, scope: scope) + + #expect(!CookieHeaderCache.storeIfObservationCurrent( + provider: .cursor, + scope: scope, + expected: observation, + cookieHeader: "fixtureSession=background-during-login", + sourceLabel: "Background")) + #expect(CookieHeaderCache.load(provider: .cursor, scope: scope)?.cookieHeader == + "fixtureSession=original") + CookieHeaderCache.endConditionalMutationGate(gate) + } + } + } + } + + private func withIsolatedCookieCache(_ operation: () -> T) -> T { + KeychainCacheStore.withServiceOverrideForTesting("cookie-conditional-\(UUID().uuidString)") { + let legacyBase = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + return CookieHeaderCache.withLegacyBaseURLOverrideForTesting(legacyBase) { + KeychainCacheStore.setTestStoreForTesting(true) + defer { KeychainCacheStore.setTestStoreForTesting(false) } + return operation() + } + } + } +} diff --git a/Tests/CodexBarTests/CookieHeaderCacheTests.swift b/Tests/CodexBarTests/CookieHeaderCacheTests.swift index d5ba8856d0..71eb919aa3 100644 --- a/Tests/CodexBarTests/CookieHeaderCacheTests.swift +++ b/Tests/CodexBarTests/CookieHeaderCacheTests.swift @@ -29,6 +29,90 @@ struct CookieHeaderCacheTests { #expect(loaded?.storedAt == storedAt) } + @Test + func `conditional mutation does not overwrite or clear a newer entry`() { + self.withIsolatedCookieCache { + CookieHeaderCache.store( + provider: .claude, + cookieHeader: "sessionKey=sk-ant-initial", + sourceLabel: "Chrome") + let loaded = CookieHeaderCache.load(provider: .claude) + #expect(loaded != nil) + guard let initial = loaded else { return } + + let renewed = CookieHeaderCache.storeIfCurrent( + provider: .claude, + expected: initial, + cookieHeader: "sessionKey=sk-ant-newer", + sourceLabel: "Chrome") + let staleStore = CookieHeaderCache.storeIfCurrent( + provider: .claude, + expected: initial, + cookieHeader: "sessionKey=sk-ant-older", + sourceLabel: "Chrome") + let staleClear = CookieHeaderCache.clearIfCurrent(provider: .claude, expected: initial) + + #expect(renewed) + #expect(!staleStore) + #expect(!staleClear) + #expect(CookieHeaderCache.load(provider: .claude)?.cookieHeader == "sessionKey=sk-ant-newer") + } + } + + @Test + func `conditional clear failure still permits replacing the same entry`() { + self.withIsolatedCookieCache { + CookieHeaderCache.store( + provider: .claude, + cookieHeader: "sessionKey=sk-ant-stale", + sourceLabel: "Chrome") + let loaded = CookieHeaderCache.load(provider: .claude) + #expect(loaded != nil) + guard let stale = loaded else { return } + + let cleared = KeychainCacheStore.withClearFailureStatusOverrideForTesting(errSecInteractionNotAllowed) { + CookieHeaderCache.clearIfCurrent(provider: .claude, expected: stale) + } + let replaced = CookieHeaderCache.storeIfCurrent( + provider: .claude, + expected: stale, + cookieHeader: "sessionKey=sk-ant-fresh", + sourceLabel: "Safari") + + #expect(!cleared) + #expect(replaced) + #expect(CookieHeaderCache.load(provider: .claude)?.cookieHeader == "sessionKey=sk-ant-fresh") + } + } + + @Test + func `conditional mutation recognizes a legacy entry after migration failure`() { + self.withIsolatedCookieCache { + let legacy = CookieHeaderCache.Entry( + cookieHeader: "sessionKey=sk-ant-legacy", + storedAt: Date(timeIntervalSince1970: 1), + sourceLabel: "Chrome") + CookieHeaderCache.store(legacy, to: CookieHeaderCache.legacyURLForTesting(provider: .claude)) + + let loaded = KeychainCacheStore.withStoreFailureStatusOverrideForTesting(errSecInteractionNotAllowed) { + CookieHeaderCache.load(provider: .claude) + } + #expect(loaded?.cookieHeader == legacy.cookieHeader) + guard let loaded else { return } + + let cleared = CookieHeaderCache.clearIfCurrent(provider: .claude, expected: loaded) + let replaced = CookieHeaderCache.storeIfCurrent( + provider: .claude, + expected: nil, + cookieHeader: "sessionKey=sk-ant-fresh", + sourceLabel: "Safari") + + #expect(cleared) + #expect(replaced) + #expect(CookieHeaderCache.load(provider: .claude)?.cookieHeader == "sessionKey=sk-ant-fresh") + } + } + @Test func `stores separate codex entries per managed account scope`() { KeychainCacheStore.setTestStoreForTesting(true) @@ -60,6 +144,36 @@ struct CookieHeaderCacheTests { #expect(CookieHeaderCache.load(provider: provider)?.cookieHeader == nil) } + @Test + func `profile home scopes isolate same email sessions without exposing paths`() { + KeychainCacheStore.setTestStoreForTesting(true) + defer { KeychainCacheStore.setTestStoreForTesting(false) } + + let provider: UsageProvider = .codex + let profileA = CookieHeaderCache.Scope.profileHome("/tmp/codex-profile-a") + let profileB = CookieHeaderCache.Scope.profileHome("/tmp/codex-profile-b") + CookieHeaderCache.store( + provider: provider, + scope: profileA, + cookieHeader: "auth=profile-a", + sourceLabel: "Chrome") + CookieHeaderCache.store( + provider: provider, + scope: profileB, + cookieHeader: "auth=profile-b", + sourceLabel: "Chrome") + defer { + CookieHeaderCache.clear(provider: provider, scope: profileA) + CookieHeaderCache.clear(provider: provider, scope: profileB) + } + + #expect(CookieHeaderCache.load(provider: provider, scope: profileA)?.cookieHeader == "auth=profile-a") + #expect(CookieHeaderCache.load(provider: provider, scope: profileB)?.cookieHeader == "auth=profile-b") + #expect(CookieHeaderCache.load(provider: provider) == nil) + #expect(profileA.isolationIdentifier != profileB.isolationIdentifier) + #expect(!profileA.isolationIdentifier.contains("codex-profile-a")) + } + @Test func `provider global scope remains available without managed account`() { KeychainCacheStore.setTestStoreForTesting(true) @@ -77,6 +191,119 @@ struct CookieHeaderCacheTests { #expect(CookieHeaderCache.load(provider: provider, scope: .managedAccount(UUID())) == nil) } + @Test + func `claude cookie scopes isolate browser cache from managed accounts`() { + self.withIsolatedCookieCache { + let accountA = UUID() + let accountB = UUID() + + CookieHeaderCache.store( + provider: .claude, + cookieHeader: "sessionKey=sk-ant-browser", + sourceLabel: "Safari") + CookieHeaderCache.store( + provider: .claude, + scope: .managedAccount(accountA), + cookieHeader: "sessionKey=sk-ant-account-a", + sourceLabel: "Chrome") + CookieHeaderCache.store( + provider: .claude, + scope: .managedAccount(accountB), + cookieHeader: "sessionKey=sk-ant-account-b", + sourceLabel: "Edge") + + #expect(CookieHeaderCache.load(provider: .claude)?.cookieHeader == "sessionKey=sk-ant-browser") + #expect(CookieHeaderCache.load(provider: .claude, scope: .managedAccount(accountA))? + .cookieHeader == "sessionKey=sk-ant-account-a") + #expect(CookieHeaderCache.load(provider: .claude, scope: .managedAccount(accountB))? + .cookieHeader == "sessionKey=sk-ant-account-b") + + CookieHeaderCache.clear(provider: .claude) + #expect(CookieHeaderCache.load(provider: .claude) == nil) + #expect(CookieHeaderCache.load(provider: .claude, scope: .managedAccount(accountA))? + .cookieHeader == "sessionKey=sk-ant-account-a") + #expect(CookieHeaderCache.load(provider: .claude, scope: .managedAccount(accountB))? + .cookieHeader == "sessionKey=sk-ant-account-b") + + CookieHeaderCache.clear(provider: .claude, scope: .managedAccount(accountA)) + #expect(CookieHeaderCache.load(provider: .claude, scope: .managedAccount(accountA)) == nil) + #expect(CookieHeaderCache.load(provider: .claude, scope: .managedAccount(accountB))? + .cookieHeader == "sessionKey=sk-ant-account-b") + } + } + + @Test + func `claude unreadable managed store sentinel is isolated from account cookies`() { + self.withIsolatedCookieCache { + let accountID = UUID() + + CookieHeaderCache.store( + provider: .claude, + cookieHeader: "sessionKey=sk-ant-global", + sourceLabel: "Safari") + CookieHeaderCache.store( + provider: .claude, + scope: .managedAccount(accountID), + cookieHeader: "sessionKey=sk-ant-account", + sourceLabel: "Chrome") + CookieHeaderCache.store( + provider: .claude, + scope: .managedStoreUnreadable, + cookieHeader: "sessionKey=sk-ant-unreadable-store", + sourceLabel: "Unreadable managed account store") + + CookieHeaderCache.clear(provider: .claude, scope: .managedAccount(accountID)) + #expect(CookieHeaderCache.load(provider: .claude, scope: .managedAccount(accountID)) == nil) + #expect(CookieHeaderCache.load(provider: .claude)?.cookieHeader == "sessionKey=sk-ant-global") + #expect(CookieHeaderCache.load(provider: .claude, scope: .managedStoreUnreadable)? + .cookieHeader == "sessionKey=sk-ant-unreadable-store") + + CookieHeaderCache.clear(provider: .claude) + #expect(CookieHeaderCache.load(provider: .claude) == nil) + #expect(CookieHeaderCache.load(provider: .claude, scope: .managedStoreUnreadable)? + .cookieHeader == "sessionKey=sk-ant-unreadable-store") + + let cleared = CookieHeaderCache.clearAllScopesDetailed(provider: .claude) + #expect(cleared == CookieHeaderCache.ClearSummary(clearedCount: 1, failedCount: 0)) + #expect(CookieHeaderCache.load(provider: .claude, scope: .managedStoreUnreadable) == nil) + } + } + + @Test + func `claude clear all scopes does not remove other provider cookie caches`() { + self.withIsolatedCookieCache { + let claudeAccount = UUID() + let codexAccount = UUID() + + CookieHeaderCache.store( + provider: .claude, + cookieHeader: "sessionKey=sk-ant-claude-global", + sourceLabel: "Safari") + CookieHeaderCache.store( + provider: .claude, + scope: .managedAccount(claudeAccount), + cookieHeader: "sessionKey=sk-ant-claude-account", + sourceLabel: "Chrome") + CookieHeaderCache.store(provider: .codex, cookieHeader: "auth=codex-global", sourceLabel: "Chrome") + CookieHeaderCache.store( + provider: .codex, + scope: .managedAccount(codexAccount), + cookieHeader: "auth=codex-account", + sourceLabel: "Safari") + CookieHeaderCache.store(provider: .perplexity, cookieHeader: "pplx=web", sourceLabel: "Chrome") + + let cleared = CookieHeaderCache.clearAllScopesDetailed(provider: .claude) + + #expect(cleared == CookieHeaderCache.ClearSummary(clearedCount: 2, failedCount: 0)) + #expect(CookieHeaderCache.load(provider: .claude) == nil) + #expect(CookieHeaderCache.load(provider: .claude, scope: .managedAccount(claudeAccount)) == nil) + #expect(CookieHeaderCache.load(provider: .codex)?.cookieHeader == "auth=codex-global") + #expect(CookieHeaderCache.load(provider: .codex, scope: .managedAccount(codexAccount))? + .cookieHeader == "auth=codex-account") + #expect(CookieHeaderCache.load(provider: .perplexity)?.cookieHeader == "pplx=web") + } + } + @Test func `migrates legacy file to keychain`() { KeychainCacheStore.setTestStoreForTesting(true) @@ -84,30 +311,55 @@ struct CookieHeaderCacheTests { let legacyBase = FileManager.default.temporaryDirectory .appendingPathComponent(UUID().uuidString, isDirectory: true) - CookieHeaderCache.setLegacyBaseURLOverrideForTesting(legacyBase) - defer { CookieHeaderCache.setLegacyBaseURLOverrideForTesting(nil) } + CookieHeaderCache.withLegacyBaseURLOverrideForTesting(legacyBase) { + let provider: UsageProvider = .codex + let storedAt = Date(timeIntervalSince1970: 0) + let entry = CookieHeaderCache.Entry( + cookieHeader: "auth=legacy", + storedAt: storedAt, + sourceLabel: "Legacy") + let legacyURL = legacyBase.appendingPathComponent("\(provider.rawValue)-cookie.json") - let provider: UsageProvider = .codex - let storedAt = Date(timeIntervalSince1970: 0) - let entry = CookieHeaderCache.Entry( - cookieHeader: "auth=legacy", - storedAt: storedAt, - sourceLabel: "Legacy") - let legacyURL = legacyBase.appendingPathComponent("\(provider.rawValue)-cookie.json") + CookieHeaderCache.store(entry, to: legacyURL) + #expect(FileManager.default.fileExists(atPath: legacyURL.path) == true) - CookieHeaderCache.store(entry, to: legacyURL) - #expect(FileManager.default.fileExists(atPath: legacyURL.path) == true) + let loaded = CookieHeaderCache.load(provider: provider) + defer { CookieHeaderCache.clear(provider: provider) } - let loaded = CookieHeaderCache.load(provider: provider) - defer { CookieHeaderCache.clear(provider: provider) } + #expect(loaded?.cookieHeader == "auth=legacy") + #expect(loaded?.sourceLabel == "Legacy") + #expect(loaded?.storedAt == storedAt) + #expect(FileManager.default.fileExists(atPath: legacyURL.path) == false) - #expect(loaded?.cookieHeader == "auth=legacy") - #expect(loaded?.sourceLabel == "Legacy") - #expect(loaded?.storedAt == storedAt) - #expect(FileManager.default.fileExists(atPath: legacyURL.path) == false) + let loadedAgain = CookieHeaderCache.load(provider: provider) + #expect(loadedAgain?.cookieHeader == "auth=legacy") + } + } + + @Test + func `serialized load migrates legacy file to keychain`() { + KeychainCacheStore.setTestStoreForTesting(true) + defer { KeychainCacheStore.setTestStoreForTesting(false) } - let loadedAgain = CookieHeaderCache.load(provider: provider) - #expect(loadedAgain?.cookieHeader == "auth=legacy") + let legacyBase = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + CookieHeaderCache.withLegacyBaseURLOverrideForTesting(legacyBase) { + let provider: UsageProvider = .codex + let legacyURL = legacyBase.appendingPathComponent("\(provider.rawValue)-cookie.json") + CookieHeaderCache.store( + CookieHeaderCache.Entry( + cookieHeader: "auth=legacy", + storedAt: Date(timeIntervalSince1970: 0), + sourceLabel: "Legacy"), + to: legacyURL) + + let loaded = CookieHeaderCache.loadSerialized(provider: provider) + defer { CookieHeaderCache.clear(provider: provider) } + + #expect(loaded?.cookieHeader == "auth=legacy") + #expect(FileManager.default.fileExists(atPath: legacyURL.path) == false) + #expect(CookieHeaderCache.load(provider: provider)?.cookieHeader == "auth=legacy") + } } #if os(macOS) @@ -118,94 +370,520 @@ struct CookieHeaderCacheTests { let legacyBase = FileManager.default.temporaryDirectory .appendingPathComponent(UUID().uuidString, isDirectory: true) - CookieHeaderCache.setLegacyBaseURLOverrideForTesting(legacyBase) - defer { CookieHeaderCache.setLegacyBaseURLOverrideForTesting(nil) } + CookieHeaderCache.withLegacyBaseURLOverrideForTesting(legacyBase) { + let provider: UsageProvider = .codex + let legacyURL = legacyBase.appendingPathComponent("\(provider.rawValue)-cookie.json") + CookieHeaderCache.store( + CookieHeaderCache.Entry( + cookieHeader: "auth=legacy", + storedAt: Date(timeIntervalSince1970: 0), + sourceLabel: "Legacy"), + to: legacyURL) + #expect(FileManager.default.fileExists(atPath: legacyURL.path) == true) + + let loaded = KeychainCacheStore.withLoadFailureStatusOverrideForTesting(errSecInteractionNotAllowed) { + CookieHeaderCache.load(provider: provider) + } + + #expect(loaded == nil) + #expect(FileManager.default.fileExists(atPath: legacyURL.path) == true) + + switch KeychainCacheStore.load(key: .cookie(provider: provider), as: CookieHeaderCache.Entry.self) { + case .missing: + #expect(true) + case .found, .temporarilyUnavailable, .invalid: + #expect(Bool(false), "Expected temporary miss not to migrate legacy cache") + } + } + } + #endif - let provider: UsageProvider = .codex - let legacyURL = legacyBase.appendingPathComponent("\(provider.rawValue)-cookie.json") - CookieHeaderCache.store( - CookieHeaderCache.Entry( - cookieHeader: "auth=legacy", - storedAt: Date(timeIntervalSince1970: 0), - sourceLabel: "Legacy"), - to: legacyURL) - #expect(FileManager.default.fileExists(atPath: legacyURL.path) == true) + @Test + func `invalid keychain cache is cleared`() { + KeychainCacheStore.setTestStoreForTesting(true) + defer { KeychainCacheStore.setTestStoreForTesting(false) } - let loaded = KeychainCacheStore.withLoadFailureStatusOverrideForTesting(errSecInteractionNotAllowed) { - CookieHeaderCache.load(provider: provider) + let legacyBase = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + CookieHeaderCache.withLegacyBaseURLOverrideForTesting(legacyBase) { + let provider: UsageProvider = .codex + let key = KeychainCacheStore.Key.cookie(provider: provider) + KeychainCacheStore.store(key: key, entry: WrongEntry(value: "not-a-cookie-entry")) + + #expect(CookieHeaderCache.load(provider: provider) == nil) + + switch KeychainCacheStore.load(key: key, as: CookieHeaderCache.Entry.self) { + case .missing: + #expect(true) + case .found, .temporarilyUnavailable, .invalid: + #expect(Bool(false), "Expected invalid cookie cache to be cleared") + } } + } - #expect(loaded == nil) - #expect(FileManager.default.fileExists(atPath: legacyURL.path) == true) + @Test + func `clear all scopes removes global scoped invalid and legacy cookie entries`() { + KeychainCacheStore.setTestStoreForTesting(true) + defer { KeychainCacheStore.setTestStoreForTesting(false) } - switch KeychainCacheStore.load(key: .cookie(provider: provider), as: CookieHeaderCache.Entry.self) { - case .missing: - #expect(true) - case .found, .temporarilyUnavailable, .invalid: - #expect(Bool(false), "Expected temporary miss not to migrate legacy cache") + let legacyBase = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + CookieHeaderCache.withLegacyBaseURLOverrideForTesting(legacyBase) { + let provider: UsageProvider = .codex + let accountID = UUID() + CookieHeaderCache.store(provider: provider, cookieHeader: "auth=global", sourceLabel: "Chrome") + CookieHeaderCache.store( + provider: provider, + scope: .managedAccount(accountID), + cookieHeader: "auth=scoped", + sourceLabel: "Chrome") + KeychainCacheStore.store( + key: .cookie(provider: provider, scopeIdentifier: "managed-store-unreadable"), + entry: WrongEntry(value: "invalid")) + CookieHeaderCache.store( + CookieHeaderCache.Entry( + cookieHeader: "auth=legacy", + storedAt: Date(timeIntervalSince1970: 0), + sourceLabel: "Legacy"), + to: CookieHeaderCache.legacyURLForTesting(provider: provider)) + + let cleared = CookieHeaderCache.clearAllScopesDetailed(provider: provider) + + #expect(cleared == CookieHeaderCache.ClearSummary(clearedCount: 4, failedCount: 0)) + #expect(!CookieHeaderCache.hasKeychainEntryForTesting(provider: provider)) + #expect(!CookieHeaderCache.hasKeychainEntryForTesting( + provider: provider, + scope: .managedAccount(accountID))) + #expect(!CookieHeaderCache.hasKeychainEntryForTesting(provider: provider, scope: .managedStoreUnreadable)) + #expect(!CookieHeaderCache.hasLegacyEntryForTesting(provider: provider)) } } - #endif @Test - func `invalid keychain cache is cleared`() { + func `loadForDisplay memoizes keychain lookups`() { + KeychainCacheStore.setTestStoreForTesting(true) + defer { KeychainCacheStore.setTestStoreForTesting(false) } + CookieHeaderCache.resetDisplayCacheForTesting() + defer { CookieHeaderCache.resetDisplayCacheForTesting() } + + let provider: UsageProvider = .codex + CookieHeaderCache.store(provider: provider, cookieHeader: "auth=abc", sourceLabel: "Chrome") + + #expect(CookieHeaderCache.loadForDisplay(provider: provider)?.cookieHeader == "auth=abc") + + // Remove the backing entry without going through CookieHeaderCache: the strict load + // sees the change, the display path keeps serving the memoized snapshot. + KeychainCacheStore.clear(key: .cookie(provider: provider)) + #expect(CookieHeaderCache.load(provider: provider) == nil) + #expect(CookieHeaderCache.loadForDisplay(provider: provider)?.cookieHeader == "auth=abc") + } + + @Test + func `loadForDisplay memoizes missing entries`() { + KeychainCacheStore.setTestStoreForTesting(true) + defer { KeychainCacheStore.setTestStoreForTesting(false) } + CookieHeaderCache.resetDisplayCacheForTesting() + defer { CookieHeaderCache.resetDisplayCacheForTesting() } + + let provider: UsageProvider = .codex + #expect(CookieHeaderCache.loadForDisplay(provider: provider) == nil) + + KeychainCacheStore.store( + key: .cookie(provider: provider), + entry: CookieHeaderCache.Entry( + cookieHeader: "auth=behind-the-back", + storedAt: Date(timeIntervalSince1970: 0), + sourceLabel: "Chrome")) + defer { KeychainCacheStore.clear(key: .cookie(provider: provider)) } + #expect(CookieHeaderCache.loadForDisplay(provider: provider) == nil) + } + + @Test + func `loadForDisplay migrates legacy cache asynchronously`() async throws { + KeychainCacheStore.setTestStoreForTesting(true) + defer { KeychainCacheStore.setTestStoreForTesting(false) } + CookieHeaderCache.resetDisplayCacheForTesting() + defer { CookieHeaderCache.resetDisplayCacheForTesting() } + + let legacyBase = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try await CookieHeaderCache.withLegacyBaseURLOverrideForTesting(legacyBase) { + let provider: UsageProvider = .codex + CookieHeaderCache.store( + CookieHeaderCache.Entry( + cookieHeader: "auth=legacy-display", + storedAt: Date(timeIntervalSince1970: 0), + sourceLabel: "Legacy"), + to: CookieHeaderCache.legacyURLForTesting(provider: provider)) + + #expect(CookieHeaderCache.loadForDisplay(provider: provider)?.cookieHeader == "auth=legacy-display") + for _ in 0..<500 { + if !CookieHeaderCache.hasLegacyEntryForTesting(provider: provider), + CookieHeaderCache.hasKeychainEntryForTesting(provider: provider) + { + break + } + try await Task.sleep(for: .milliseconds(10)) + } + #expect(!CookieHeaderCache.hasLegacyEntryForTesting(provider: provider)) + #expect(CookieHeaderCache.hasKeychainEntryForTesting(provider: provider)) + CookieHeaderCache.clear(provider: provider) + } + } + + @Test + func `delayed legacy migration cannot restore a cleared cache`() { KeychainCacheStore.setTestStoreForTesting(true) defer { KeychainCacheStore.setTestStoreForTesting(false) } + CookieHeaderCache.resetDisplayCacheForTesting() + defer { CookieHeaderCache.resetDisplayCacheForTesting() } let legacyBase = FileManager.default.temporaryDirectory .appendingPathComponent(UUID().uuidString, isDirectory: true) - CookieHeaderCache.setLegacyBaseURLOverrideForTesting(legacyBase) - defer { CookieHeaderCache.setLegacyBaseURLOverrideForTesting(nil) } + CookieHeaderCache.withLegacyBaseURLOverrideForTesting(legacyBase) { + let provider: UsageProvider = .codex + CookieHeaderCache.store( + CookieHeaderCache.Entry( + cookieHeader: "auth=legacy-display", + storedAt: Date(timeIntervalSince1970: 0), + sourceLabel: "Legacy"), + to: CookieHeaderCache.legacyURLForTesting(provider: provider)) + + CookieHeaderCache.clear(provider: provider) + #expect(CookieHeaderCache.migrateLegacyEntryIfNeededForTesting(provider: provider) == nil) + #expect(!CookieHeaderCache.hasLegacyEntryForTesting(provider: provider)) + #expect(!CookieHeaderCache.hasKeychainEntryForTesting(provider: provider)) + } + } + + @Test + func `legacy URL override supports concurrent teardown reads`() { + let legacyBases = [ + FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true), + FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true), + ] + CookieHeaderCache.withLegacyBaseURLOverrideForTesting(legacyBases[0]) { + DispatchQueue.concurrentPerform(iterations: 5000) { index in + if index.isMultiple(of: 3) { + CookieHeaderCache.withLegacyBaseURLOverrideForTesting(legacyBases[index % legacyBases.count]) { + _ = CookieHeaderCache.legacyURLForTesting(provider: .codex) + } + } else if index.isMultiple(of: 5) { + CookieHeaderCache.withLegacyBaseURLOverrideForTesting(nil) { + _ = CookieHeaderCache.legacyURLForTesting(provider: .codex) + } + } else { + _ = CookieHeaderCache.legacyURLForTesting(provider: .codex) + } + } + + #expect( + CookieHeaderCache.legacyURLForTesting(provider: .codex) + == legacyBases[0].appendingPathComponent("codex-cookie.json")) + } + } + + #if os(macOS) + @Test + func `loadForDisplay throttles temporary keychain unavailability then retries`() async throws { + KeychainCacheStore.setTestStoreForTesting(true) + defer { KeychainCacheStore.setTestStoreForTesting(false) } + CookieHeaderCache.resetDisplayCacheForTesting() + defer { CookieHeaderCache.resetDisplayCacheForTesting() } + try await CookieHeaderCache.withDisplayUnavailableRetryIntervalOverrideForTesting(0.05) { + let provider: UsageProvider = .codex + KeychainCacheStore.store( + key: .cookie(provider: provider), + entry: CookieHeaderCache.Entry( + cookieHeader: "auth=available-after-retry", + storedAt: Date(timeIntervalSince1970: 0), + sourceLabel: "Chrome")) + + let unavailable = KeychainCacheStore.withLoadFailureStatusOverrideForTesting(errSecInteractionNotAllowed) { + CookieHeaderCache.loadForDisplay(provider: provider) + } + + #expect(unavailable == nil) + #expect(CookieHeaderCache.loadForDisplay(provider: provider) == nil) + + try await Task.sleep(for: .milliseconds(60)) + var retried: CookieHeaderCache.Entry? + for _ in 0..<500 { + retried = CookieHeaderCache.loadForDisplay(provider: provider) + if retried != nil { break } + try await Task.sleep(for: .milliseconds(10)) + } + #expect(retried?.cookieHeader == "auth=available-after-retry") + } + } + + @Test + func `temporary first display read returns a concurrent store`() { + KeychainCacheStore.setTestStoreForTesting(true) + defer { KeychainCacheStore.setTestStoreForTesting(false) } + CookieHeaderCache.resetDisplayCacheForTesting() + defer { CookieHeaderCache.resetDisplayCacheForTesting() } let provider: UsageProvider = .codex - let key = KeychainCacheStore.Key.cookie(provider: provider) - KeychainCacheStore.store(key: key, entry: WrongEntry(value: "not-a-cookie-entry")) + _ = CookieHeaderCache.beginDisplayReadGenerationForTesting(provider: provider) + CookieHeaderCache.store(provider: provider, cookieHeader: "auth=concurrent", sourceLabel: "Chrome") - #expect(CookieHeaderCache.load(provider: provider) == nil) + #expect(CookieHeaderCache.currentDisplayEntryForTesting(provider: provider)? + .cookieHeader == "auth=concurrent") + } + + @Test + func `failed keychain mutations preserve the display snapshot`() { + KeychainCacheStore.setTestStoreForTesting(true) + defer { KeychainCacheStore.setTestStoreForTesting(false) } + CookieHeaderCache.resetDisplayCacheForTesting() + defer { CookieHeaderCache.resetDisplayCacheForTesting() } + + let provider: UsageProvider = .codex + CookieHeaderCache.store(provider: provider, cookieHeader: "auth=old", sourceLabel: "Chrome") + #expect(CookieHeaderCache.loadForDisplay(provider: provider)?.cookieHeader == "auth=old") + + KeychainCacheStore.withStoreFailureStatusOverrideForTesting(errSecInteractionNotAllowed) { + CookieHeaderCache.store(provider: provider, cookieHeader: "auth=new", sourceLabel: "Safari") + } + #expect(CookieHeaderCache.loadForDisplay(provider: provider)?.cookieHeader == "auth=old") - switch KeychainCacheStore.load(key: key, as: CookieHeaderCache.Entry.self) { - case .missing: - #expect(true) - case .found, .temporarilyUnavailable, .invalid: - #expect(Bool(false), "Expected invalid cookie cache to be cleared") + let cleared = KeychainCacheStore.withClearFailureStatusOverrideForTesting(errSecInteractionNotAllowed) { + CookieHeaderCache.clearDetailed(provider: provider) } + #expect(cleared == CookieHeaderCache.ClearSummary(clearedCount: 0, failedCount: 1)) + #expect(CookieHeaderCache.loadForDisplay(provider: provider)?.cookieHeader == "auth=old") + #expect(CookieHeaderCache.load(provider: provider)?.cookieHeader == "auth=old") } @Test - func `clear all scopes removes global scoped invalid and legacy cookie entries`() { + func `legacy removal invalidates a snapshot after failed keychain clear`() { + KeychainCacheStore.setTestStoreForTesting(true) + defer { KeychainCacheStore.setTestStoreForTesting(false) } + CookieHeaderCache.resetDisplayCacheForTesting() + defer { CookieHeaderCache.resetDisplayCacheForTesting() } + + KeychainCacheStore.withServiceOverrideForTesting("legacy-clear-\(UUID().uuidString)") { + let legacyBase = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + CookieHeaderCache.withLegacyBaseURLOverrideForTesting(legacyBase) { + let provider: UsageProvider = .codex + CookieHeaderCache.store( + CookieHeaderCache.Entry( + cookieHeader: "auth=legacy", + storedAt: Date(timeIntervalSince1970: 0), + sourceLabel: "Legacy"), + to: CookieHeaderCache.legacyURLForTesting(provider: provider)) + + let displayed = KeychainCacheStore + .withStoreFailureStatusOverrideForTesting(errSecInteractionNotAllowed) { + CookieHeaderCache.loadForDisplay(provider: provider) + } + #expect(displayed?.cookieHeader == "auth=legacy") + + let cleared = KeychainCacheStore.withClearFailureStatusOverrideForTesting(errSecInteractionNotAllowed) { + CookieHeaderCache.clearDetailed(provider: provider) + } + + #expect(cleared == CookieHeaderCache.ClearSummary(clearedCount: 1, failedCount: 1)) + #expect(!CookieHeaderCache.hasLegacyEntryForTesting(provider: provider)) + #expect(CookieHeaderCache.loadForDisplay(provider: provider) == nil) + } + } + } + + @Test + func `clear all reports keychain enumeration failure`() { + KeychainCacheStore.setTestStoreForTesting(true) + defer { KeychainCacheStore.setTestStoreForTesting(false) } + + let summary = KeychainCacheStore.withKeysFailureStatusOverrideForTesting(errSecInteractionNotAllowed) { + CookieHeaderCache.clearAllDetailed() + } + + #expect(summary.failedCount >= 1) + } + + @Test + func `clear reports legacy file deletion failure`() { KeychainCacheStore.setTestStoreForTesting(true) defer { KeychainCacheStore.setTestStoreForTesting(false) } let legacyBase = FileManager.default.temporaryDirectory .appendingPathComponent(UUID().uuidString, isDirectory: true) - CookieHeaderCache.setLegacyBaseURLOverrideForTesting(legacyBase) - defer { CookieHeaderCache.setLegacyBaseURLOverrideForTesting(nil) } + CookieHeaderCache.withLegacyBaseURLOverrideForTesting(legacyBase) { + let provider: UsageProvider = .codex + CookieHeaderCache.store( + CookieHeaderCache.Entry( + cookieHeader: "auth=legacy", + storedAt: Date(timeIntervalSince1970: 0), + sourceLabel: "Legacy"), + to: CookieHeaderCache.legacyURLForTesting(provider: provider)) + + let summary = CookieHeaderCache.withLegacyRemovalFailureForTesting { + CookieHeaderCache.clearDetailed(provider: provider) + } + + #expect(summary.failedCount == 1) + #expect(CookieHeaderCache.hasLegacyEntryForTesting(provider: provider)) + } + } + #endif + + @Test + func `store and clear update the display snapshot immediately`() { + KeychainCacheStore.setTestStoreForTesting(true) + defer { KeychainCacheStore.setTestStoreForTesting(false) } + CookieHeaderCache.resetDisplayCacheForTesting() + defer { CookieHeaderCache.resetDisplayCacheForTesting() } let provider: UsageProvider = .codex - let accountID = UUID() - CookieHeaderCache.store(provider: provider, cookieHeader: "auth=global", sourceLabel: "Chrome") - CookieHeaderCache.store( + CookieHeaderCache.store(provider: provider, cookieHeader: "auth=first", sourceLabel: "Chrome") + #expect(CookieHeaderCache.loadForDisplay(provider: provider)?.cookieHeader == "auth=first") + + CookieHeaderCache.store(provider: provider, cookieHeader: "auth=second", sourceLabel: "Safari") + #expect(CookieHeaderCache.loadForDisplay(provider: provider)?.cookieHeader == "auth=second") + + CookieHeaderCache.clear(provider: provider) + #expect(CookieHeaderCache.loadForDisplay(provider: provider) == nil) + } + + @Test + func `stale refresh cannot overwrite a newer store`() { + KeychainCacheStore.setTestStoreForTesting(true) + defer { KeychainCacheStore.setTestStoreForTesting(false) } + CookieHeaderCache.resetDisplayCacheForTesting() + defer { CookieHeaderCache.resetDisplayCacheForTesting() } + + let provider: UsageProvider = .codex + CookieHeaderCache.store(provider: provider, cookieHeader: "auth=old", sourceLabel: "Chrome") + #expect(CookieHeaderCache.loadForDisplay(provider: provider)?.cookieHeader == "auth=old") + + // A refresh scheduled now races with a store that lands before it commits. + let staleGeneration = CookieHeaderCache.beginDisplayReadGenerationForTesting(provider: provider) + let staleEntry = CookieHeaderCache.load(provider: provider) + CookieHeaderCache.store(provider: provider, cookieHeader: "auth=new", sourceLabel: "Safari") + + let committed = CookieHeaderCache.commitDisplaySnapshotIfCurrentForTesting( provider: provider, - scope: .managedAccount(accountID), - cookieHeader: "auth=scoped", - sourceLabel: "Chrome") + entry: staleEntry, + generation: staleGeneration) + + #expect(committed?.cookieHeader == "auth=new") + #expect(CookieHeaderCache.loadForDisplay(provider: provider)?.cookieHeader == "auth=new") + } + + @Test + func `stale refresh cannot resurrect a cleared snapshot`() { + KeychainCacheStore.setTestStoreForTesting(true) + defer { KeychainCacheStore.setTestStoreForTesting(false) } + CookieHeaderCache.resetDisplayCacheForTesting() + defer { CookieHeaderCache.resetDisplayCacheForTesting() } + + let provider: UsageProvider = .codex + CookieHeaderCache.store(provider: provider, cookieHeader: "auth=secret", sourceLabel: "Chrome") + #expect(CookieHeaderCache.loadForDisplay(provider: provider)?.cookieHeader == "auth=secret") + + let staleGeneration = CookieHeaderCache.beginDisplayReadGenerationForTesting(provider: provider) + let staleEntry = CookieHeaderCache.load(provider: provider) + CookieHeaderCache.clear(provider: provider) + + let committed = CookieHeaderCache.commitDisplaySnapshotIfCurrentForTesting( + provider: provider, + entry: staleEntry, + generation: staleGeneration) + + #expect(committed == nil) + #expect(CookieHeaderCache.loadForDisplay(provider: provider) == nil) + } + + @Test + func `stale refresh cannot survive clear all`() { + KeychainCacheStore.setTestStoreForTesting(true) + defer { KeychainCacheStore.setTestStoreForTesting(false) } + CookieHeaderCache.resetDisplayCacheForTesting() + defer { CookieHeaderCache.resetDisplayCacheForTesting() } + + let provider: UsageProvider = .codex + CookieHeaderCache.store(provider: provider, cookieHeader: "auth=secret", sourceLabel: "Chrome") + #expect(CookieHeaderCache.loadForDisplay(provider: provider)?.cookieHeader == "auth=secret") + + let staleGeneration = CookieHeaderCache.beginDisplayReadGenerationForTesting(provider: provider) + let staleEntry = CookieHeaderCache.load(provider: provider) + CookieHeaderCache.clearAll() + + CookieHeaderCache.commitDisplaySnapshotIfCurrentForTesting( + provider: provider, + entry: staleEntry, + generation: staleGeneration) + + #expect(CookieHeaderCache.loadForDisplay(provider: provider) == nil) + } + + @Test + func `clear all invalidates an in flight first display population`() { + KeychainCacheStore.setTestStoreForTesting(true) + defer { KeychainCacheStore.setTestStoreForTesting(false) } + CookieHeaderCache.resetDisplayCacheForTesting() + defer { CookieHeaderCache.resetDisplayCacheForTesting() } + + let provider: UsageProvider = .codex KeychainCacheStore.store( - key: .cookie(provider: provider, scopeIdentifier: "managed-store-unreadable"), - entry: WrongEntry(value: "invalid")) - CookieHeaderCache.store( - CookieHeaderCache.Entry( - cookieHeader: "auth=legacy", + key: .cookie(provider: provider), + entry: CookieHeaderCache.Entry( + cookieHeader: "auth=secret", storedAt: Date(timeIntervalSince1970: 0), - sourceLabel: "Legacy"), - to: CookieHeaderCache.legacyURLForTesting(provider: provider)) + sourceLabel: "Chrome")) + + // A first display load registers its key, then reads the Keychain outside the lock. + let staleGeneration = CookieHeaderCache.beginDisplayReadGenerationForTesting(provider: provider) + let staleEntry = CookieHeaderCache.load(provider: provider) + CookieHeaderCache.clearAll() - let cleared = CookieHeaderCache.clearAllScopes(provider: provider) + let committed = CookieHeaderCache.commitDisplaySnapshotIfCurrentForTesting( + provider: provider, + entry: staleEntry, + generation: staleGeneration) - #expect(cleared == 4) - #expect(!CookieHeaderCache.hasKeychainEntryForTesting(provider: provider)) - #expect(!CookieHeaderCache.hasKeychainEntryForTesting(provider: provider, scope: .managedAccount(accountID))) - #expect(!CookieHeaderCache.hasKeychainEntryForTesting(provider: provider, scope: .managedStoreUnreadable)) - #expect(!CookieHeaderCache.hasLegacyEntryForTesting(provider: provider)) + #expect(committed == nil) + #expect(CookieHeaderCache.loadForDisplay(provider: provider) == nil) + } + + @Test + func `stale display snapshot revalidates off the calling path`() async throws { + KeychainCacheStore.setTestStoreForTesting(true) + defer { KeychainCacheStore.setTestStoreForTesting(false) } + CookieHeaderCache.resetDisplayCacheForTesting() + defer { CookieHeaderCache.resetDisplayCacheForTesting() } + try await CookieHeaderCache.withDisplayStalenessIntervalOverrideForTesting(0) { + let provider: UsageProvider = .codex + KeychainCacheStore.store( + key: .cookie(provider: provider), + entry: CookieHeaderCache.Entry( + cookieHeader: "auth=old", + storedAt: Date(timeIntervalSince1970: 0), + sourceLabel: "Chrome")) + #expect(CookieHeaderCache.loadForDisplay(provider: provider)?.cookieHeader == "auth=old") + + KeychainCacheStore.store( + key: .cookie(provider: provider), + entry: CookieHeaderCache.Entry( + cookieHeader: "auth=new", + storedAt: Date(timeIntervalSince1970: 1), + sourceLabel: "Chrome")) + + // The stale lookup returns the old snapshot and schedules a revalidation. + _ = CookieHeaderCache.loadForDisplay(provider: provider) + var refreshed = false + for _ in 0..<200 { + if CookieHeaderCache.loadForDisplay(provider: provider)?.cookieHeader == "auth=new" { + refreshed = true + break + } + try await Task.sleep(nanoseconds: 10_000_000) + } + #expect(refreshed) + } } @Test @@ -224,10 +902,25 @@ struct CookieHeaderCacheTests { key: .cookie(provider: .cursor), entry: WrongEntry(value: "invalid")) - let cleared = CookieHeaderCache.clearAll() + let cleared = CookieHeaderCache.clearAllDetailed() - #expect(cleared >= 3) + #expect(cleared.clearedCount >= 3) + #expect(cleared.failedCount == 0) #expect(KeychainCacheStore.keys(category: "cookie").isEmpty) } } + + private func withIsolatedCookieCache(_ operation: () -> T) -> T { + KeychainCacheStore.withServiceOverrideForTesting("cookie-isolation-\(UUID().uuidString)") { + let legacyBase = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + return CookieHeaderCache.withLegacyBaseURLOverrideForTesting(legacyBase) { + KeychainCacheStore.setTestStoreForTesting(true) + defer { KeychainCacheStore.setTestStoreForTesting(false) } + CookieHeaderCache.resetDisplayCacheForTesting() + defer { CookieHeaderCache.resetDisplayCacheForTesting() } + return operation() + } + } + } } diff --git a/Tests/CodexBarTests/CookieImporterOverrideIsolationTests.swift b/Tests/CodexBarTests/CookieImporterOverrideIsolationTests.swift new file mode 100644 index 0000000000..ed1f059c80 --- /dev/null +++ b/Tests/CodexBarTests/CookieImporterOverrideIsolationTests.swift @@ -0,0 +1,73 @@ +#if DEBUG && os(macOS) +import Foundation +import Testing +@testable import CodexBarCore + +private actor CookieImporterOverrideBarrier { + private var continuations: [CheckedContinuation] = [] + + func wait() async { + await withCheckedContinuation { continuation in + self.continuations.append(continuation) + guard self.continuations.count == 2 else { return } + + let continuations = self.continuations + self.continuations.removeAll() + continuations.forEach { $0.resume() } + } + } +} + +struct CookieImporterOverrideIsolationTests { + @Test + func `cookie importer overrides stay isolated across concurrent tasks`() async throws { + let expectedLabels = ["first", "second"] + let barrier = CookieImporterOverrideBarrier() + + let observedLabels = try await withThrowingTaskGroup( + of: String.self, + returning: Set.self) + { group in + for label in expectedLabels { + group.addTask { + try await AlibabaCodingPlanCookieImporter.withImportSessionOverrideForTesting { _, _ in + AlibabaCodingPlanCookieImporter.SessionInfo(cookies: [], sourceLabel: label) + } operation: { + await barrier.wait() + return try AlibabaCodingPlanCookieImporter.importSession( + browserDetection: BrowserDetection()).sourceLabel + } + } + } + + var labels: Set = [] + for try await label in group { + labels.insert(label) + } + return labels + } + + #expect(observedLabels == Set(expectedLabels)) + } + + @Test + func `perplexity overrides bypass the shared import cache`() async throws { + PerplexityCookieImporter.invalidateImportSessionCache() + defer { PerplexityCookieImporter.invalidateImportSessionCache() } + + let first = try await PerplexityCookieImporter.withImportSessionsOverrideForTesting { _, _ in + [PerplexityCookieImporter.SessionInfo(cookies: [], sourceLabel: "first")] + } operation: { + try PerplexityCookieImporter.importSessions().map(\.sourceLabel) + } + let second = try await PerplexityCookieImporter.withImportSessionsOverrideForTesting { _, _ in + [PerplexityCookieImporter.SessionInfo(cookies: [], sourceLabel: "second")] + } operation: { + try PerplexityCookieImporter.importSessions().map(\.sourceLabel) + } + + #expect(first == ["first"]) + #expect(second == ["second"]) + } +} +#endif diff --git a/Tests/CodexBarTests/CopilotBudgetCookieRoutingTests.swift b/Tests/CodexBarTests/CopilotBudgetCookieRoutingTests.swift new file mode 100644 index 0000000000..1518c40bb1 --- /dev/null +++ b/Tests/CodexBarTests/CopilotBudgetCookieRoutingTests.swift @@ -0,0 +1,44 @@ +import Testing +@testable import CodexBarCore + +struct CopilotBudgetCookieRoutingTests { + @Test + func `auto budget cookies ignore stale manual header`() { + let settings = ProviderSettingsSnapshot.CopilotProviderSettings( + budgetExtrasEnabled: true, + budgetCookieSource: .auto, + manualBudgetCookieHeader: "user_session=stale") + + #expect(CopilotAPIFetchStrategy.budgetCookieHeaderOverride(from: settings) == nil) + } + + @Test + func `manual budget cookies use trimmed manual header`() { + let settings = ProviderSettingsSnapshot.CopilotProviderSettings( + budgetExtrasEnabled: true, + budgetCookieSource: .manual, + manualBudgetCookieHeader: " user_session=manual ") + + #expect(CopilotAPIFetchStrategy.budgetCookieHeaderOverride(from: settings) == "user_session=manual") + } + + @Test + func `manual budget cookies require non-empty header`() { + let settings = ProviderSettingsSnapshot.CopilotProviderSettings( + budgetExtrasEnabled: true, + budgetCookieSource: .manual, + manualBudgetCookieHeader: " ") + + #expect(CopilotAPIFetchStrategy.budgetCookieHeaderOverride(from: settings) == nil) + } + + @Test + func `invalid manual budget cookies do not fall back to browser import`() { + let settings = ProviderSettingsSnapshot.CopilotProviderSettings( + budgetExtrasEnabled: true, + budgetCookieSource: .manual, + manualBudgetCookieHeader: "Cookie:") + + #expect(CopilotAPIFetchStrategy.budgetCookieHeaderOverride(from: settings) == nil) + } +} diff --git a/Tests/CodexBarTests/CopilotBudgetWebFetcherTests.swift b/Tests/CodexBarTests/CopilotBudgetWebFetcherTests.swift new file mode 100644 index 0000000000..c4ebf9e7d7 --- /dev/null +++ b/Tests/CodexBarTests/CopilotBudgetWebFetcherTests.swift @@ -0,0 +1,726 @@ +import Foundation +import Testing +@testable import CodexBarCore + +@Suite(.serialized) +struct CopilotBudgetWebFetcherTests { + @Test + func `maps positive copilot budgets to extra rate windows`() { + let budgets: [CopilotBudgetWebFetcher.Budget] = [ + .init( + id: "product-budget", + budgetProductSkus: ["copilot"], + budgetAmount: 100, + currentAmount: 15), + .init( + id: "agent-budget", + budgetProductSkus: ["copilot_agent_premium_request"], + budgetAmount: 20, + currentAmount: 5), + .init( + id: "zero-budget", + budgetProductSkus: ["spark_premium_request"], + budgetAmount: 0, + currentAmount: 0), + ] + + let windows = CopilotBudgetWebFetcher.extraRateWindows( + from: budgets, + now: Date(timeIntervalSince1970: 1_780_358_400)) + + #expect(windows.map(\.id) == ["copilot-budget-product-budget", "copilot-budget-agent-budget"]) + #expect(windows.map(\.title) == ["Budget - Copilot", "Budget - Copilot Agent Premium Requests"]) + #expect(windows[0].window.usedPercent == 15) + #expect(windows[1].window.usedPercent == 25) + #expect(windows.allSatisfy { $0.window.resetsAt != nil }) + } + + @Test + func `decodes github web budget response shape`() throws { + let data = Data(""" + { + "payload": { + "budgets": [ + { + "uuid": "budget-1", + "targetName": "Example", + "pricingTargetType": "BundlePricing", + "pricingTargetId": "premium_requests", + "targetAmount": 30.0, + "currentAmount": 0.0 + } + ], + "has_next_page": false + } + } + """.utf8) + + let response = try JSONDecoder().decode(CopilotBudgetWebFetcher.BudgetResponse.self, from: data) + let budget = try #require(response.budgets.first) + #expect(response.hasNextPage == false) + #expect(budget.id == "budget-1") + #expect(budget.budgetEntityName == "Example") + #expect(budget.budgetAmount == 30) + #expect(budget.currentAmount == 0) + + let windows = CopilotBudgetWebFetcher.extraRateWindows( + from: response.budgets, + now: Date(timeIntervalSince1970: 1_780_358_400)) + #expect(windows.map(\.title) == ["Budget - All Premium Request SKUs"]) + #expect(windows.first?.window.usedPercent == 0) + } + + @Test + func `ignores malformed embedded minus amounts`() throws { + let data = Data(""" + { + "budgets": [ + { + "uuid": "budget-1", + "pricingTargetId": "premium_requests", + "targetAmount": "1-5", + "currentAmount": "$5.00" + }, + { + "uuid": "budget-2", + "pricingTargetId": "premium_requests", + "targetAmount": "-$15.00", + "currentAmount": "$5.00" + } + ] + } + """.utf8) + + let response = try JSONDecoder().decode(CopilotBudgetWebFetcher.BudgetResponse.self, from: data) + + #expect(response.budgets.map(\.budgetAmount) == [0, -15]) + #expect(CopilotBudgetWebFetcher.extraRateWindows( + from: response.budgets, + now: Date(timeIntervalSince1970: 1_780_358_400)).isEmpty) + } + + @Test + func `normalizes documented copilot billing names`() { + #expect(CopilotBudgetWebFetcher.normalizedBillingIdentifier("Copilot") == "copilot") + #expect( + CopilotBudgetWebFetcher.normalizedBillingIdentifier("Copilot Premium Request") == + "copilot_premium_request") + #expect( + CopilotBudgetWebFetcher.normalizedBillingIdentifier("Copilot Agent Premium Request") == + "copilot_agent_premium_request") + #expect( + CopilotBudgetWebFetcher.normalizedBillingIdentifier("Spark Premium Request") == + "spark_premium_request") + #expect( + CopilotBudgetWebFetcher.normalizedBillingIdentifier("Premium requests") == + "copilot_premium_request") + #expect( + CopilotBudgetWebFetcher.normalizedBillingIdentifier("Bundled premium request budget") == + "copilot_premium_request") + #expect( + CopilotBudgetWebFetcher.normalizedBillingIdentifier("Copilot cloud agent premium requests") == + "copilot_agent_premium_request") + #expect( + CopilotBudgetWebFetcher.normalizedBillingIdentifier("coding_agent_premium_request") == + "copilot_agent_premium_request") + } + + @Test + func `extracts github fetch nonce from html`() { + let html = #""# + #expect(CopilotBudgetWebFetcher.extractFetchNonce(from: html) == "v2:abc-123") + } + + @Test + func `extracts github web identity from html`() throws { + let html = """ + + + """ + + let identity = try #require(CopilotBudgetWebFetcher.extractGitHubWebIdentity(from: html)) + + #expect(identity.id == "123") + #expect(identity.login == "octocat") + #expect(CopilotBudgetWebFetcher.webIdentity(identity, matches: "github:user:123")) + #expect(CopilotBudgetWebFetcher.webIdentity(identity, matches: "OctoCat")) + #expect(!CopilotBudgetWebFetcher.webIdentity(identity, matches: "github:user:456")) + } + + @Test + func `missing github web identity with expected account maps to unknown account mismatch`() async throws { + let transport = ProviderHTTPTransportStub { request in + guard let url = request.url, + let response = HTTPURLResponse( + url: url, + statusCode: 200, + httpVersion: "HTTP/1.1", + headerFields: nil) + else { + throw URLError(.badServerResponse) + } + if request.url?.query?.contains("page=") == true { + Issue.record("Missing identity should not reach budget JSON endpoint") + return (Data(#"{"budgets":[],"has_next_page":false}"#.utf8), response) + } + return (Data(#""#.utf8), response) + } + let fetcher = CopilotBudgetWebFetcher( + cookieHeaderOverride: "user_session=missing-identity", + expectedGitHubAccountIdentifier: "github:user:123", + transport: transport) + + do { + _ = try await fetcher.fetchBudgetWindows() + Issue.record("Expected account mismatch") + } catch let error as CopilotBudgetWebFetcher.Error { + #expect(error == .accountMismatch(expected: "github:user:123", actual: nil)) + } + + #expect(await transport.requests().count == 1) + } + + @Test + func `invalid github budget page html encoding maps to invalid response`() async throws { + let transport = ProviderHTTPTransportStub { request in + guard let url = request.url, + let response = HTTPURLResponse( + url: url, + statusCode: 200, + httpVersion: "HTTP/1.1", + headerFields: nil) + else { + throw URLError(.badServerResponse) + } + if request.url?.query?.contains("page=") == true { + Issue.record("Invalid HTML encoding should not reach budget JSON endpoint") + return (Data(#"{"budgets":[],"has_next_page":false}"#.utf8), response) + } + return (Data([0xC3, 0x28]), response) + } + let fetcher = CopilotBudgetWebFetcher( + cookieHeaderOverride: "user_session=invalid-html", + expectedGitHubAccountIdentifier: "github:user:123", + transport: transport) + + do { + _ = try await fetcher.fetchBudgetWindows() + Issue.record("Expected invalid response") + } catch let error as CopilotBudgetWebFetcher.Error { + #expect(error == .invalidResponse) + } + + #expect(await transport.requests().count == 1) + } + + @Test + func `manual budget cookie for different github account is ignored before budget request`() async throws { + let transport = ProviderHTTPTransportStub { request in + guard let url = request.url, + let response = HTTPURLResponse( + url: url, + statusCode: 200, + httpVersion: "HTTP/1.1", + headerFields: nil) + else { + throw URLError(.badServerResponse) + } + if request.url?.query?.contains("page=") == true { + Issue.record("Mismatched cookie should not reach budget JSON endpoint") + return (Data(#"{"budgets":[],"has_next_page":false}"#.utf8), response) + } + return ( + Data(""" + + + + """.utf8), + response) + } + let fetcher = CopilotBudgetWebFetcher( + cookieHeaderOverride: "user_session=other", + expectedGitHubAccountIdentifier: "github:user:123", + transport: transport) + + do { + _ = try await fetcher.fetchBudgetWindows() + Issue.record("Expected account mismatch") + } catch let error as CopilotBudgetWebFetcher.Error { + #expect(error == .accountMismatch(expected: "github:user:123", actual: "otheruser")) + } + + #expect(await transport.requests().count == 1) + } + + @Test + func `manual budget cookie with matching github account appends budget windows`() async throws { + let transport = ProviderHTTPTransportStub { request in + guard let url = request.url, + let response = HTTPURLResponse( + url: url, + statusCode: 200, + httpVersion: "HTTP/1.1", + headerFields: nil) + else { + throw URLError(.badServerResponse) + } + if request.url?.query?.contains("page=") == true { + return ( + Data(""" + { + "budgets": [ + { + "uuid": "budget-1", + "pricingTargetId": "premium_requests", + "targetAmount": 100.0, + "currentAmount": 40.0 + } + ], + "has_next_page": false + } + """.utf8), + response) + } + return ( + Data(""" + + + + """.utf8), + response) + } + let fetcher = CopilotBudgetWebFetcher( + cookieHeaderOverride: "user_session=matching", + expectedGitHubAccountIdentifier: "github:user:123", + transport: transport, + now: { Date(timeIntervalSince1970: 1_780_358_400) }) + + let windows = try await fetcher.fetchBudgetWindows() + + #expect(windows.map(\.id) == ["copilot-budget-budget-1"]) + #expect(windows.first?.window.usedPercent == 40) + #expect(await transport.requests().count == 2) + } + + @Test + func `mismatched manual budget cookie leaves normal copilot usage unchanged`() async { + let registered = URLProtocol.registerClass(CopilotBudgetBindingStubURLProtocol.self) + defer { + if registered { + URLProtocol.unregisterClass(CopilotBudgetBindingStubURLProtocol.self) + } + CopilotBudgetBindingStubURLProtocol.reset() + } + CopilotBudgetBindingStubURLProtocol.reset() + CopilotBudgetBindingStubURLProtocol.handler = { request in + guard let url = request.url else { + throw URLError(.badURL) + } + if url.host == "api.github.com", url.path == "/copilot_internal/user" { + return Self.stubResponse( + url: url, + data: Data(""" + { + "quota_snapshots": { + "premium_interactions": { + "entitlement": 300, + "remaining": 240, + "percent_remaining": 80, + "quota_id": "premium" + } + }, + "copilot_plan": "pro" + } + """.utf8)) + } + if url.host == "api.github.com", url.path == "/user" { + return Self.stubResponse( + url: url, + data: Data(#"{"id":123,"login":"expecteduser"}"#.utf8)) + } + if url.host == "github.com", url.path == "/settings/billing/budgets", url.query == nil { + return Self.stubResponse( + url: url, + data: Data(""" + + + + """.utf8)) + } + Issue.record("Unexpected request: \(url.absoluteString)") + return Self.stubResponse(url: url, data: Data("{}".utf8), statusCode: 404) + } + let descriptor = ProviderDescriptorRegistry.descriptor(for: .copilot) + let settings = ProviderSettingsSnapshot.make(copilot: .init( + apiToken: "selected-token", + selectedAccountExternalIdentifier: "github:user:123", + budgetExtrasEnabled: true, + budgetCookieSource: .manual, + manualBudgetCookieHeader: "user_session=other")) + let context = Self.makeFetchContext(settings: settings) + + let outcome = await descriptor.fetchPlan.fetchOutcome(context: context, provider: .copilot) + + guard case let .success(result) = outcome.result else { + Issue.record("Expected Copilot usage fetch to succeed") + return + } + #expect(result.usage.primary?.usedPercent == 20) + #expect(result.usage.extraRateWindows == nil) + #expect(CopilotBudgetBindingStubURLProtocol.requests().contains { + $0.url?.query?.contains("page=") == true + } == false) + } + + @Test + func `stale selected account identifier is ignored for budget cookie binding`() async { + let registered = URLProtocol.registerClass(CopilotBudgetBindingStubURLProtocol.self) + defer { + if registered { + URLProtocol.unregisterClass(CopilotBudgetBindingStubURLProtocol.self) + } + CopilotBudgetBindingStubURLProtocol.reset() + } + CopilotBudgetBindingStubURLProtocol.reset() + CopilotBudgetBindingStubURLProtocol.handler = { request in + guard let url = request.url else { + throw URLError(.badURL) + } + if url.host == "api.github.com", url.path == "/copilot_internal/user" { + return Self.stubResponse( + url: url, + data: Data(""" + { + "quota_snapshots": { + "premium_interactions": { + "entitlement": 300, + "remaining": 240, + "percent_remaining": 80, + "quota_id": "premium" + } + }, + "copilot_plan": "pro" + } + """.utf8)) + } + if url.host == "api.github.com", url.path == "/user" { + return Self.stubResponse( + url: url, + data: Data(#"{"id":999,"login":"newuser"}"#.utf8)) + } + if url.host == "github.com", url.path == "/settings/billing/budgets", url.query == nil { + return Self.stubResponse( + url: url, + data: Data(""" + + + + """.utf8)) + } + Issue.record("Unexpected request: \(url.absoluteString)") + return Self.stubResponse(url: url, data: Data("{}".utf8), statusCode: 404) + } + let descriptor = ProviderDescriptorRegistry.descriptor(for: .copilot) + let settings = ProviderSettingsSnapshot.make(copilot: .init( + apiToken: "new-selected-token", + selectedAccountExternalIdentifier: "github:user:123", + budgetExtrasEnabled: true, + budgetCookieSource: .manual, + manualBudgetCookieHeader: "user_session=old-browser-account")) + let context = Self.makeFetchContext(settings: settings) + + let outcome = await descriptor.fetchPlan.fetchOutcome(context: context, provider: .copilot) + + guard case let .success(result) = outcome.result else { + Issue.record("Expected Copilot usage fetch to succeed") + return + } + #expect(result.usage.primary?.usedPercent == 20) + #expect(result.usage.extraRateWindows == nil) + #expect(CopilotBudgetBindingStubURLProtocol.requests().contains { + $0.url?.path == "/user" + }) + #expect(CopilotBudgetBindingStubURLProtocol.requests().contains { + $0.url?.query?.contains("page=") == true + } == false) + } + + @Test + func `invalid github budget JSON maps to invalid response`() async throws { + let transport = ProviderHTTPTransportStub { request in + guard let url = request.url, + let response = HTTPURLResponse( + url: url, + statusCode: 200, + httpVersion: "HTTP/1.1", + headerFields: nil) + else { + throw URLError(.badServerResponse) + } + if request.url?.query?.contains("page=") == true { + return (Data("{".utf8), response) + } + return (Data(#""#.utf8), response) + } + let fetcher = CopilotBudgetWebFetcher( + transport: transport, + now: { Date(timeIntervalSince1970: 1_780_358_400) }) + + do { + _ = try await fetcher.fetchBudgetWindows(cookieHeader: "user_session=abc") + Issue.record("Expected invalidResponse") + } catch let error as CopilotBudgetWebFetcher.Error { + #expect(error == .invalidResponse) + } + } + + @Test + func `cached cookie non auth errors do not fall back to browser import`() async throws { + KeychainCacheStore.setTestStoreForTesting(true) + defer { KeychainCacheStore.setTestStoreForTesting(false) } + CookieHeaderCache.store(provider: .copilot, cookieHeader: "user_session=cached", sourceLabel: "Chrome") + defer { CookieHeaderCache.clear(provider: .copilot) } + + let transport = ProviderHTTPTransportStub { request in + guard let url = request.url, + let response = HTTPURLResponse( + url: url, + statusCode: 500, + httpVersion: "HTTP/1.1", + headerFields: nil) + else { + throw URLError(.badServerResponse) + } + return (Data("{}".utf8), response) + } + let fetcher = CopilotBudgetWebFetcher(transport: transport) + + do { + _ = try await fetcher.fetchBudgetWindows() + Issue.record("Expected badStatus") + } catch let error as CopilotBudgetWebFetcher.Error { + #expect(error == .badStatus(500)) + } + + #expect(await transport.requests().count == 2) + #expect(CookieHeaderCache.load(provider: .copilot)?.cookieHeader == "user_session=cached") + } + + @Test + func `cached cookie account mismatch clears cache before browser fallback`() async throws { + KeychainCacheStore.setTestStoreForTesting(true) + defer { KeychainCacheStore.setTestStoreForTesting(false) } + CookieHeaderCache.store(provider: .copilot, cookieHeader: "user_session=cached", sourceLabel: "Chrome") + defer { CookieHeaderCache.clear(provider: .copilot) } + + let temp = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: temp, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: temp) } + + let transport = ProviderHTTPTransportStub { request in + guard let url = request.url, + let response = HTTPURLResponse( + url: url, + statusCode: 200, + httpVersion: "HTTP/1.1", + headerFields: nil) + else { + throw URLError(.badServerResponse) + } + if request.url?.query?.contains("page=") == true { + Issue.record("Mismatched cached cookie should not reach budget JSON endpoint") + return (Data(#"{"budgets":[],"has_next_page":false}"#.utf8), response) + } + return ( + Data(""" + + + + """.utf8), + response) + } + let fetcher = CopilotBudgetWebFetcher( + expectedGitHubAccountIdentifier: "github:user:123", + browserDetection: BrowserDetection(homeDirectory: temp.path, cacheTTL: 0), + transport: transport) + + do { + _ = try await fetcher.fetchBudgetWindows() + Issue.record("Expected browser fallback to exhaust without a session") + } catch let error as CopilotBudgetWebFetcher.Error { + #expect(error == .noSessionCookie) + } + + #expect(await transport.requests().count == 1) + #expect(CookieHeaderCache.load(provider: .copilot) == nil) + } + + @Test + func `cached cookie missing identity clears cache before browser fallback`() async throws { + KeychainCacheStore.setTestStoreForTesting(true) + defer { KeychainCacheStore.setTestStoreForTesting(false) } + CookieHeaderCache.store(provider: .copilot, cookieHeader: "user_session=cached", sourceLabel: "Chrome") + defer { CookieHeaderCache.clear(provider: .copilot) } + + let temp = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: temp, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: temp) } + + let transport = ProviderHTTPTransportStub { request in + guard let url = request.url, + let response = HTTPURLResponse( + url: url, + statusCode: 200, + httpVersion: "HTTP/1.1", + headerFields: nil) + else { + throw URLError(.badServerResponse) + } + if request.url?.query?.contains("page=") == true { + Issue.record("Unverifiable cached cookie should not reach budget JSON endpoint") + return (Data(#"{"budgets":[],"has_next_page":false}"#.utf8), response) + } + return (Data(#""#.utf8), response) + } + let fetcher = CopilotBudgetWebFetcher( + expectedGitHubAccountIdentifier: "github:user:123", + browserDetection: BrowserDetection(homeDirectory: temp.path, cacheTTL: 0), + transport: transport) + + do { + _ = try await fetcher.fetchBudgetWindows() + Issue.record("Expected browser fallback to exhaust without a session") + } catch let error as CopilotBudgetWebFetcher.Error { + #expect(error == .noSessionCookie) + } + + #expect(await transport.requests().count == 1) + #expect(CookieHeaderCache.load(provider: .copilot) == nil) + } + + @Test + func `budget page request omits content type on get`() async throws { + let transport = ProviderHTTPTransportStub { request in + guard let url = request.url, + let response = HTTPURLResponse( + url: url, + statusCode: 200, + httpVersion: "HTTP/1.1", + headerFields: nil) + else { + throw URLError(.badServerResponse) + } + if request.url?.query?.contains("page=") == true { + return (Data(#"{"budgets":[],"has_next_page":false}"#.utf8), response) + } + return (Data(#""#.utf8), response) + } + let fetcher = CopilotBudgetWebFetcher(transport: transport) + + _ = try await fetcher.fetchBudgetWindows(cookieHeader: "user_session=abc") + + let pageRequest = try #require(await transport.requests().first { $0.url?.query?.contains("page=") == true }) + #expect(pageRequest.value(forHTTPHeaderField: "Content-Type") == nil) + } + + private static func makeFetchContext(settings: ProviderSettingsSnapshot) -> ProviderFetchContext { + let browserDetection = BrowserDetection(cacheTTL: 0) + return ProviderFetchContext( + runtime: .app, + sourceMode: .api, + includeCredits: false, + webTimeout: 1, + webDebugDumpHTML: false, + verbose: false, + env: [:], + settings: settings, + fetcher: UsageFetcher(environment: [:]), + claudeFetcher: ClaudeUsageFetcher(browserDetection: browserDetection), + browserDetection: browserDetection) + } + + private static func stubResponse( + url: URL, + data: Data, + statusCode: Int = 200) -> (Data, URLResponse) + { + let response = HTTPURLResponse( + url: url, + statusCode: statusCode, + httpVersion: "HTTP/1.1", + headerFields: ["Content-Type": "application/json"])! + return (data, response) + } +} + +final class CopilotBudgetBindingStubURLProtocol: URLProtocol { + private static let lock = NSLock() + private static let _handlerBox = LockIsolated<(@Sendable (URLRequest) throws -> (Data, URLResponse))?>(nil) + static var handler: (@Sendable (URLRequest) throws -> (Data, URLResponse))? { + get { Self._handlerBox.value } + set { Self._handlerBox.setValue(newValue) } + } + + private nonisolated(unsafe) static var recordedRequests: [URLRequest] = [] + + static func reset() { + self.lock.lock() + defer { self.lock.unlock() } + self.handler = nil + self.recordedRequests = [] + } + + static func requests() -> [URLRequest] { + self.lock.lock() + defer { self.lock.unlock() } + return self.recordedRequests + } + + override static func canInit(with request: URLRequest) -> Bool { + guard self.hasHandler else { return false } + guard request.url?.scheme == "https" else { return false } + switch (request.url?.host, request.url?.path) { + case ("api.github.com", "/copilot_internal/user"), + ("api.github.com", "/user"), + ("github.com", "/settings/billing/budgets"): + return true + default: + return false + } + } + + override static func canonicalRequest(for request: URLRequest) -> URLRequest { + request + } + + override func startLoading() { + Self.lock.lock() + Self.recordedRequests.append(self.request) + let handler = Self.handler + Self.lock.unlock() + + guard let handler else { + self.client?.urlProtocol(self, didFailWithError: URLError(.badServerResponse)) + return + } + + do { + let (data, response) = try handler(self.request) + self.client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + self.client?.urlProtocol(self, didLoad: data) + self.client?.urlProtocolDidFinishLoading(self) + } catch { + self.client?.urlProtocol(self, didFailWithError: error) + } + } + + override func stopLoading() {} +} + +extension CopilotBudgetBindingStubURLProtocol { + fileprivate static var hasHandler: Bool { + self.lock.lock() + defer { self.lock.unlock() } + return self.handler != nil + } +} diff --git a/Tests/CodexBarTests/CopilotMenuCardModelTests.swift b/Tests/CodexBarTests/CopilotMenuCardModelTests.swift new file mode 100644 index 0000000000..235b7106e1 --- /dev/null +++ b/Tests/CodexBarTests/CopilotMenuCardModelTests.swift @@ -0,0 +1,137 @@ +import CodexBarCore +import Foundation +import SwiftUI +import Testing +@testable import CodexBar + +struct CopilotMenuCardModelTests { + @Test + func `hides copilot budget bars when budget extras are disabled`() throws { + let now = Date() + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 20, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: RateWindow(usedPercent: 30, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + extraRateWindows: [ + NamedRateWindow( + id: "copilot-budget-agent", + title: "Budget - Copilot Agent Premium Requests", + window: RateWindow(usedPercent: 65, windowMinutes: nil, resetsAt: nil, resetDescription: nil)), + ], + updatedAt: now) + let metadata = try #require(ProviderDefaults.metadata[.copilot]) + let model = UsageMenuCardView.Model.make(.init( + provider: .copilot, + metadata: metadata, + snapshot: snapshot, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + + #expect(model.metrics.map(\.title) == ["Premium", "Chat"]) + #expect(model.metrics.allSatisfy { $0.detailLeftText == nil }) + #expect(model.metrics.allSatisfy { $0.detailRightText == nil }) + #expect(model.metrics.allSatisfy { $0.pacePercent == nil }) + } + + @Test + func `monthly quotas show projections and pace markers`() throws { + let now = try Self.date("2026-07-16T12:00:00Z") + let reset = try Self.date("2026-08-01T00:00:00Z") + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 70, windowMinutes: nil, resetsAt: reset, resetDescription: nil), + secondary: RateWindow(usedPercent: 30, windowMinutes: nil, resetsAt: reset, resetDescription: nil), + updatedAt: now) + + let model = try Self.model(snapshot: snapshot, now: now) + + let premium = try #require(model.metrics.first { $0.id == "primary" }) + #expect(premium.resetText == "Resets in 15d 12h") + #expect(premium.detailLeftText == "20% in deficit") + #expect(premium.detailRightText == "Runs out in 6d 15h") + #expect(try #require(premium.pacePercent) == 50) + #expect(premium.paceOnTop == false) + + let chat = try #require(model.metrics.first { $0.id == "secondary" }) + #expect(chat.resetText == "Resets in 15d 12h") + #expect(chat.detailLeftText == "20% in reserve") + #expect(chat.detailRightText == "Lasts until reset") + #expect(try #require(chat.pacePercent) == 50) + #expect(chat.paceOnTop == true) + } + + @Test + func `monthly projection uses the calendar month ending at reset`() throws { + let now = try Self.date("2026-02-15T00:00:00Z") + let reset = try Self.date("2026-03-01T00:00:00Z") + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 70, windowMinutes: nil, resetsAt: reset, resetDescription: nil), + secondary: nil, + updatedAt: now) + + let model = try Self.model(snapshot: snapshot, now: now) + + let premium = try #require(model.metrics.first) + #expect(premium.detailLeftText == "20% in deficit") + #expect(try #require(premium.pacePercent) == 50) + } + + @Test + func `over quota usage keeps raw detail when reset is known`() throws { + let now = try Self.date("2026-07-16T12:00:00Z") + let reset = try Self.date("2026-08-01T00:00:00Z") + let snapshot = UsageSnapshot( + primary: RateWindow( + usedPercent: 115, + windowMinutes: nil, + resetsAt: reset, + resetDescription: "115% used"), + secondary: nil, + updatedAt: now) + + let model = try Self.model(snapshot: snapshot, now: now) + + let premium = try #require(model.metrics.first) + #expect(premium.detailLeftText == "115% used") + #expect(premium.detailRightText == nil) + #expect(premium.pacePercent == nil) + } + + private static func model(snapshot: UsageSnapshot, now: Date) throws -> UsageMenuCardView.Model { + let metadata = try #require(ProviderDefaults.metadata[.copilot]) + return UsageMenuCardView.Model.make(.init( + provider: .copilot, + metadata: metadata, + snapshot: snapshot, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + } + + private static func date(_ value: String) throws -> Date { + try #require(ISO8601DateFormatter().date(from: value)) + } +} diff --git a/Tests/CodexBarTests/CopilotMultiAccountTests.swift b/Tests/CodexBarTests/CopilotMultiAccountTests.swift index 0dc6ae673d..7593f1fd59 100644 --- a/Tests/CodexBarTests/CopilotMultiAccountTests.swift +++ b/Tests/CodexBarTests/CopilotMultiAccountTests.swift @@ -93,7 +93,6 @@ struct CopilotAPIKeyFallbackTests { minimaxCookieStore: InMemoryMiniMaxCookieStore(), minimaxAPITokenStore: InMemoryMiniMaxAPITokenStore(), kimiTokenStore: InMemoryKimiTokenStore(), - kimiK2TokenStore: InMemoryKimiK2TokenStore(), augmentCookieStore: InMemoryCookieHeaderStore(), ampCookieStore: InMemoryCookieHeaderStore(), copilotTokenStore: InMemoryCopilotTokenStore(), @@ -164,7 +163,6 @@ struct CopilotEnvironmentPrecedenceTests { minimaxCookieStore: InMemoryMiniMaxCookieStore(), minimaxAPITokenStore: InMemoryMiniMaxAPITokenStore(), kimiTokenStore: InMemoryKimiTokenStore(), - kimiK2TokenStore: InMemoryKimiK2TokenStore(), augmentCookieStore: InMemoryCookieHeaderStore(), ampCookieStore: InMemoryCookieHeaderStore(), copilotTokenStore: InMemoryCopilotTokenStore(), @@ -341,7 +339,6 @@ struct CopilotExternalIdentifierTests { minimaxCookieStore: InMemoryMiniMaxCookieStore(), minimaxAPITokenStore: InMemoryMiniMaxAPITokenStore(), kimiTokenStore: InMemoryKimiTokenStore(), - kimiK2TokenStore: InMemoryKimiK2TokenStore(), augmentCookieStore: InMemoryCookieHeaderStore(), ampCookieStore: InMemoryCookieHeaderStore(), copilotTokenStore: InMemoryCopilotTokenStore(), @@ -396,7 +393,6 @@ struct TokenAccountSnapshotErrorMessageTests { minimaxCookieStore: InMemoryMiniMaxCookieStore(), minimaxAPITokenStore: InMemoryMiniMaxAPITokenStore(), kimiTokenStore: InMemoryKimiTokenStore(), - kimiK2TokenStore: InMemoryKimiK2TokenStore(), augmentCookieStore: InMemoryCookieHeaderStore(), ampCookieStore: InMemoryCookieHeaderStore(), copilotTokenStore: InMemoryCopilotTokenStore(), diff --git a/Tests/CodexBarTests/CopilotUsageFetcherTests.swift b/Tests/CodexBarTests/CopilotUsageFetcherTests.swift index 3ae186a653..d590632e69 100644 --- a/Tests/CodexBarTests/CopilotUsageFetcherTests.swift +++ b/Tests/CodexBarTests/CopilotUsageFetcherTests.swift @@ -6,7 +6,7 @@ struct CopilotUsageFetcherTests { @Test func `fetchGitHubIdentity uses shared client`() async throws { let transport = ProviderHTTPTransportStub { request in - guard request.value(forHTTPHeaderField: "Authorization") == "token abc123" else { + guard request.value(forHTTPHeaderField: "Authorization") == "token test-token-placeholder" else { throw URLError(.userAuthenticationRequired) } let response = try HTTPURLResponse( @@ -17,7 +17,9 @@ struct CopilotUsageFetcherTests { return (Data(#"{"login":"testuser","id":123}"#.utf8), response) } - let identity = try await CopilotUsageFetcher.fetchGitHubIdentity(token: "abc123", transport: transport) + let identity = try await CopilotUsageFetcher.fetchGitHubIdentity( + token: "test-token-placeholder", + transport: transport) #expect(identity.login == "testuser") #expect(identity.id == 123) @@ -25,4 +27,269 @@ 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 test-token-placeholder") + 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: "test-token-placeholder", transport: transport) + + let snapshot = try await fetcher.fetch() + + #expect(snapshot.primary == nil) + #expect(snapshot.secondary == nil) + #expect(snapshot.identity?.loginMethod == "Business") + } + + @Test + func `fetch omits explicitly unlimited only chat quota without failing`() async throws { + let transport = ProviderHTTPTransportStub { request in + #expect(request.value(forHTTPHeaderField: "Authorization") == "token test-token-placeholder") + let response = try HTTPURLResponse( + url: #require(request.url), + statusCode: 200, + httpVersion: "HTTP/1.1", + headerFields: ["Content-Type": "application/json"])! + let data = Data( + """ + { + "copilot_plan": "individual", + "quota_reset_date": "2026-07-01", + "quota_snapshots": { + "chat_messages": { + "entitlement": 0, + "remaining": 0, + "quota_id": "chat_messages", + "unlimited": true + } + } + } + """.utf8) + return (data, response) + } + let fetcher = CopilotUsageFetcher(token: "test-token-placeholder", transport: transport) + + let snapshot = try await fetcher.fetch() + + #expect(snapshot.primary == nil) + #expect(snapshot.secondary == nil) + #expect(snapshot.identity?.loginMethod == "Individual") + } + + @Test + func `fetch keeps finite premium quota and omits unlimited chat quota`() async throws { + let transport = ProviderHTTPTransportStub { request in + #expect(request.value(forHTTPHeaderField: "Authorization") == "token test-token-placeholder") + let response = try HTTPURLResponse( + url: #require(request.url), + statusCode: 200, + httpVersion: "HTTP/1.1", + headerFields: ["Content-Type": "application/json"])! + let data = Data( + """ + { + "copilot_plan": "individual", + "quota_reset_date": "2026-08-01T00:00:00Z", + "quota_snapshots": { + "premium_interactions": { + "entitlement": 200, + "remaining": 156.2, + "percent_remaining": 78.1, + "quota_id": "premium_interactions" + }, + "chat_messages": { + "entitlement": 0, + "remaining": 0, + "quota_id": "chat_messages", + "unlimited": true + } + } + } + """.utf8) + return (data, response) + } + let fetcher = CopilotUsageFetcher(token: "test-token-placeholder", transport: transport) + let expectedReset = try #require(CopilotUsageFetcher.parseQuotaResetDate("2026-08-01T00:00:00Z")) + + let snapshot = try await fetcher.fetch() + + let usedPercent = try #require(snapshot.primary?.usedPercent) + #expect(abs(usedPercent - 21.9) < 0.0001) + #expect(snapshot.primary?.resetsAt == expectedReset) + #expect(snapshot.secondary == nil) + } + + @Test + func `fetch uses finite monthly chat quota when direct chat quota is unlimited`() async throws { + let transport = ProviderHTTPTransportStub { request in + #expect(request.value(forHTTPHeaderField: "Authorization") == "token test-token-placeholder") + let response = try HTTPURLResponse( + url: #require(request.url), + statusCode: 200, + httpVersion: "HTTP/1.1", + headerFields: ["Content-Type": "application/json"])! + let data = Data( + """ + { + "copilot_plan": "individual", + "quota_snapshots": { + "chat_messages": { + "entitlement": 0, + "remaining": 0, + "quota_id": "chat_messages", + "unlimited": true + } + }, + "monthly_quotas": { + "chat": 100 + }, + "limited_user_quotas": { + "chat": 60 + } + } + """.utf8) + return (data, response) + } + let fetcher = CopilotUsageFetcher(token: "test-token-placeholder", transport: transport) + + let snapshot = try await fetcher.fetch() + + #expect(snapshot.primary == nil) + #expect(snapshot.secondary?.usedPercent == 40) + } + + @Test + func `fetch attaches quota reset date to copilot windows`() async throws { + let transport = ProviderHTTPTransportStub { request in + #expect(request.value(forHTTPHeaderField: "Authorization") == "token test-token-placeholder") + let response = try HTTPURLResponse( + url: #require(request.url), + statusCode: 200, + httpVersion: "HTTP/1.1", + headerFields: ["Content-Type": "application/json"])! + let data = Data( + """ + { + "copilot_plan": "individual", + "quota_reset_date": "2026-07-01", + "quota_snapshots": { + "premium_interactions": { + "entitlement": 500, + "remaining": 125, + "percent_remaining": 25, + "quota_id": "premium_interactions" + }, + "chat": { + "entitlement": 300, + "remaining": 240, + "percent_remaining": 80, + "quota_id": "chat" + } + } + } + """.utf8) + return (data, response) + } + let fetcher = CopilotUsageFetcher(token: "test-token-placeholder", transport: transport) + let expectedReset = try #require(CopilotUsageFetcher.parseQuotaResetDate("2026-07-01")) + + let snapshot = try await fetcher.fetch() + + #expect(snapshot.primary?.usedPercent == 75) + #expect(snapshot.primary?.resetsAt == expectedReset) + #expect(snapshot.secondary?.usedPercent == 20) + #expect(snapshot.secondary?.resetsAt == expectedReset) + } + + @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 drops unlimited quota`() { + let unlimited = CopilotUsageResponse.QuotaSnapshot( + entitlement: 0, + remaining: 0, + percentRemaining: 0, + quotaId: "chat_messages", + unlimited: true) + + #expect(CopilotUsageFetcher.makeRateWindow(from: unlimited) == 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) + } + + @Test + func `makeRateWindow carries reset date`() { + let resetDate = Date(timeIntervalSince1970: 1_783_468_800) + let real = CopilotUsageResponse.QuotaSnapshot( + entitlement: 500, + remaining: 125, + percentRemaining: 25, + quotaId: "premium_interactions") + + let window = CopilotUsageFetcher.makeRateWindow(from: real, resetsAt: resetDate) + + #expect(window?.usedPercent == 75) + #expect(window?.resetsAt == resetDate) + } + + @Test + func `parseQuotaResetDate supports date only and ISO timestamps`() throws { + let dateOnly = try #require(ISO8601DateFormatter().date(from: "2026-07-01T00:00:00Z")) + let iso = try #require(ISO8601DateFormatter().date(from: "2026-07-01T08:30:45Z")) + let fractionalFormatter = ISO8601DateFormatter() + fractionalFormatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + let fractionalISO = try #require(fractionalFormatter.date(from: "2026-07-01T08:30:45.123Z")) + + #expect(CopilotUsageFetcher.parseQuotaResetDate("2026-07-01") == dateOnly) + #expect(CopilotUsageFetcher.parseQuotaResetDate("2026-07-01T08:30:45Z") == iso) + #expect(CopilotUsageFetcher.parseQuotaResetDate("2026-07-01T08:30:45.123Z") == fractionalISO) + #expect(CopilotUsageFetcher.parseQuotaResetDate(" ") == nil) + } } diff --git a/Tests/CodexBarTests/CopilotUsageModelsTests.swift b/Tests/CodexBarTests/CopilotUsageModelsTests.swift index e59d2ad9eb..4d42656c77 100644 --- a/Tests/CodexBarTests/CopilotUsageModelsTests.swift +++ b/Tests/CodexBarTests/CopilotUsageModelsTests.swift @@ -437,6 +437,140 @@ 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 `keeps unlimited chat fallback quota without percent remaining`() throws { + let response = try Self.decodeFixture( + """ + { + "copilot_plan": "individual", + "quota_snapshots": { + "premium_interactions": { + "entitlement": 200, + "remaining": 191, + "percent_remaining": 95.5, + "quota_id": "premium_interactions" + }, + "chat_messages": { + "entitlement": 0, + "remaining": 0, + "quota_id": "chat_messages", + "unlimited": true + } + } + } + """) + + #expect(response.quotaSnapshots.premiumInteractions?.quotaId == "premium_interactions") + #expect(response.quotaSnapshots.chat?.quotaId == "chat_messages") + #expect(response.quotaSnapshots.chat?.unlimited == true) + #expect(response.quotaSnapshots.chat?.usedPercent == 0) + } + + @Test + func `unlimited quota overrides placeholder percent remaining`() throws { + let response = try Self.decodeFixture( + """ + { + "copilot_plan": "individual", + "quota_snapshots": { + "chat": { + "entitlement": 0, + "remaining": 0, + "percent_remaining": 0, + "quota_id": "chat", + "unlimited": true + } + } + } + """) + + let chat = try #require(response.quotaSnapshots.chat) + #expect(chat.percentRemaining == 100) + #expect(chat.usedPercent == 0) + #expect(!chat.isPlaceholder) + } + + @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/CostHistoryChartMenuViewTests.swift b/Tests/CodexBarTests/CostHistoryChartMenuViewTests.swift index f1c98f03a9..d7d4b24f00 100644 --- a/Tests/CodexBarTests/CostHistoryChartMenuViewTests.swift +++ b/Tests/CodexBarTests/CostHistoryChartMenuViewTests.swift @@ -1,12 +1,899 @@ +import CodexBarCore +import SwiftUI import Testing @testable import CodexBar +@MainActor struct CostHistoryChartMenuViewTests { + @Test + func `Codex chart explains that its token estimate is not a subscription bill`() { + #expect( + CostHistoryChartMenuView.estimateDisclaimer(provider: .codex) + == "Estimated from token usage · not a subscription bill") + #expect(CostHistoryChartMenuView.estimateDisclaimer(provider: .claude) == nil) + } + + @Test + @MainActor + func `model breakdown keeps every item behind a bounded scrolling viewport`() { + let breakdown = (1...6).map { index in + CostUsageDailyReport.ModelBreakdown( + modelName: "model-\(index)", + costUSD: Double(index), + totalTokens: index * 100) + } + + let ordered = CostHistoryChartMenuView.orderedBreakdownItems(breakdown) + + #expect(ordered.map(\.modelName) == [ + "model-6", + "model-5", + "model-4", + "model-3", + "model-2", + "model-1", + ]) + #expect(CostHistoryChartMenuView.detailViewportRowCount(itemCount: ordered.count) == 4) + #expect(CostHistoryChartMenuView.detailRowsNeedScrolling(itemCount: ordered.count)) + #expect(CostHistoryChartMenuView.detailOverflowHint(itemCount: ordered.count) == "Scroll to see more models") + #expect(CostHistoryChartMenuView.detailOverflowHint(itemCount: 4) == nil) + } + + @Test + @MainActor + func `menu hosting view publishes measured height through intrinsic size`() { + let hosting = MenuHostingView(rootView: EmptyView()) + hosting.frame = CGRect(x: 0, y: 0, width: 320, height: 1) + + hosting.applyMeasuredHeight(width: 320, height: 123.2) + + #expect(hosting.frame.size == CGSize(width: 320, height: 124)) + #expect(hosting.intrinsicContentSize.height == 124) + } + + @Test + @MainActor + func `cost history defaults selection to latest day`() { + let daily = [ + CostUsageDailyReport.Entry( + date: "2026-06-07", + inputTokens: 100, + outputTokens: 50, + totalTokens: 150, + costUSD: 1.25, + modelsUsed: ["gpt-5.5"], + modelBreakdowns: nil), + CostUsageDailyReport.Entry( + date: "2026-06-09", + inputTokens: 200, + outputTokens: 75, + totalTokens: 275, + costUSD: 2.5, + modelsUsed: ["gpt-5.5"], + modelBreakdowns: nil), + ] + + #expect( + CostHistoryChartMenuView._defaultSelectedDateKeyForTesting( + provider: .codex, + daily: daily) == "2026-06-09") + } + + @Test + @MainActor + func `cost history sizes its viewport to the largest breakdown in the range`() { + let threeRows = CostHistoryChartMenuView._detailViewportConfigurationForTesting( + provider: .codex, + daily: [Self.entry(date: "2026-06-07", modelCount: 1), Self.entry(date: "2026-06-08", modelCount: 3)]) + let cappedRows = CostHistoryChartMenuView._detailViewportConfigurationForTesting( + provider: .codex, + daily: [Self.entry(date: "2026-06-07", modelCount: 6)]) + let mixedRows = CostHistoryChartMenuView._detailViewportConfigurationForTesting( + provider: .codex, + daily: [Self.entry(date: "2026-06-07", modelCount: 6), Self.entry(date: "2026-06-08", modelCount: 1)]) + let noRows = CostHistoryChartMenuView._detailViewportConfigurationForTesting( + provider: .codex, + daily: [Self.entry(date: "2026-06-07", modelCount: 0)]) + + #expect(threeRows.rowCount == 3) + #expect(!threeRows.hasOverflow) + #expect(threeRows.rowHeight == 36) + #expect(cappedRows.rowCount == 4) + #expect(cappedRows.hasOverflow) + #expect(mixedRows.rowCount == 4) + #expect(mixedRows.hasOverflow) + #expect(noRows.rowCount == 0) + #expect(!noRows.hasOverflow) + } + + @Test + @MainActor + func `cost history expands every row only when the range contains mode details`() { + let compact = CostHistoryChartMenuView._detailViewportConfigurationForTesting( + provider: .codex, + daily: [Self.entry(date: "2026-06-07", modelCount: 2)]) + let expanded = CostHistoryChartMenuView._detailViewportConfigurationForTesting( + provider: .codex, + daily: [ + Self.entry(date: "2026-06-07", modelCount: 2), + Self.entry(date: "2026-06-08", modelCount: 1, hasModeDetails: true), + ]) + + #expect(compact.rowHeight == 36) + #expect(expanded.rowHeight == 44) + #expect(compact.rowCount == expanded.rowCount) + } + + @Test + @MainActor + func `axis dates span first to last for multi-day data`() { + let daily = [ + CostUsageDailyReport.Entry( + date: "2026-05-21", + inputTokens: nil, + outputTokens: nil, + totalTokens: nil, + costUSD: 1.0, + modelsUsed: nil, + modelBreakdowns: nil), + CostUsageDailyReport.Entry( + date: "2026-06-17", + inputTokens: nil, + outputTokens: nil, + totalTokens: nil, + costUSD: 2.0, + modelsUsed: nil, + modelBreakdowns: nil), + ] + let dates = CostHistoryChartMenuView._axisDatesForTesting(provider: .codex, daily: daily) + let cal = Calendar.current + #expect(dates.count == 2) + #expect(cal.component(.month, from: dates[0]) == 5) + #expect(cal.component(.day, from: dates[0]) == 21) + #expect(cal.component(.month, from: dates[1]) == 6) + #expect(cal.component(.day, from: dates[1]) == 17) + #expect( + CostHistoryChartMenuView._axisLabelPlacementForTesting( + provider: .codex, + daily: daily) == .edges) + } + + @Test + @MainActor + func `axis dates collapse to one for single-day data`() { + let daily = [ + CostUsageDailyReport.Entry( + date: "2026-06-17", + inputTokens: nil, + outputTokens: nil, + totalTokens: nil, + costUSD: 1.0, + modelsUsed: nil, + modelBreakdowns: nil), + ] + let dates = CostHistoryChartMenuView._axisDatesForTesting(provider: .codex, daily: daily) + #expect(dates.count == 1) + #expect( + CostHistoryChartMenuView._axisLabelPlacementForTesting( + provider: .codex, + daily: daily) == .centered) + } + + @Test + @MainActor + func `axis dates are empty when there is no cost data`() { + let daily = [ + CostUsageDailyReport.Entry( + date: "2026-06-17", + inputTokens: nil, + outputTokens: nil, + totalTokens: nil, + costUSD: nil, + modelsUsed: nil, + modelBreakdowns: nil), + ] + let dates = CostHistoryChartMenuView._axisDatesForTesting(provider: .codex, daily: daily) + #expect(dates.isEmpty) + #expect( + CostHistoryChartMenuView._axisLabelPlacementForTesting( + provider: .codex, + daily: daily) == .hidden) + } + + @Test + @MainActor + func `y-axis tick values are empty for flat or no data`() { + #expect(CostHistoryChartMenuView._yAxisTickValuesForTesting(maxCostUSD: 0).isEmpty) + #expect(CostHistoryChartMenuView._yAxisTickValuesForTesting(maxCostUSD: -1).isEmpty) + } + + @Test + @MainActor + func `y-axis tick values use two ticks for small ranges`() { + let ticks = CostHistoryChartMenuView._yAxisTickValuesForTesting(maxCostUSD: 0.50) + #expect(ticks == [0, 0.50]) + } + + @Test + @MainActor + func `y-axis tick values use three ticks for ranges at or above one dollar`() { + let ticks = CostHistoryChartMenuView._yAxisTickValuesForTesting(maxCostUSD: 12.0) + #expect(ticks == [0, 6.0, 12.0]) + + let large = CostHistoryChartMenuView._yAxisTickValuesForTesting(maxCostUSD: 1000.0) + #expect(large == [0, 500.0, 1000.0]) + } + + @Test(arguments: [ + (0.0, "$0"), + (12.56, "$13"), + (0.50, "$0.50"), + ]) + @MainActor + func `y-axis cost labels preserve cents only for nonzero sub-dollar values`( + value: Double, + expected: String) + { + #expect(CostHistoryChartMenuView._yAxisCostStringForTesting(value) == expected) + } + + @Test + @MainActor + func `cost history fitting height stays stable across compact overflow and mode selections`() { + let compactLatestHasOneModel = [ + Self.entry(date: "2026-06-07", modelCount: 3), + Self.entry(date: "2026-06-08", modelCount: 1), + ] + let compactLatestHasThreeModels = [ + Self.entry(date: "2026-06-07", modelCount: 1), + Self.entry(date: "2026-06-08", modelCount: 3), + ] + let overflowLatestHasFourModels = [ + Self.entry(date: "2026-06-07", modelCount: 6), + Self.entry(date: "2026-06-08", modelCount: 4), + ] + let overflowLatestHasSixModels = [ + Self.entry(date: "2026-06-07", modelCount: 4), + Self.entry(date: "2026-06-08", modelCount: 6), + ] + let modeLatestHasOneModel = [ + Self.entry(date: "2026-06-07", modelCount: 6), + Self.entry(date: "2026-06-08", modelCount: 1, hasModeDetails: true), + ] + let modeLatestHasSixModels = [ + Self.entry(date: "2026-06-07", modelCount: 1, hasModeDetails: true), + Self.entry(date: "2026-06-08", modelCount: 6), + ] + + let compactHeight = Self.renderedHeight(daily: compactLatestHasOneModel) + let overflowHeight = Self.renderedHeight(daily: overflowLatestHasFourModels) + let modeHeight = Self.renderedHeight(daily: modeLatestHasOneModel) + + #expect(compactHeight == Self.renderedHeight(daily: compactLatestHasThreeModels)) + #expect(overflowHeight == Self.renderedHeight(daily: overflowLatestHasSixModels)) + #expect(modeHeight == Self.renderedHeight(daily: modeLatestHasSixModels)) + #expect(compactHeight < overflowHeight) + #expect(overflowHeight < modeHeight) + } + + @Test + @MainActor + func `cost history without model breakdown stays compact`() { + let noBreakdown = [Self.entry(date: "2026-06-07", modelCount: 0)] + let withBreakdown = [Self.entry(date: "2026-06-07", modelCount: 1)] + + #expect(Self.renderedHeight(daily: noBreakdown) < Self.renderedHeight(daily: withBreakdown)) + } + + @Test + @MainActor + func `single differing project source remains visible`() { + let matching = Self.project(path: "/tmp/main", sourcePath: "/tmp/main") + let differing = Self.project(path: "/tmp/main", sourcePath: "/tmp/worktree") + + #expect(CostHistoryChartMenuView.visibleProjectSources(matching).isEmpty) + #expect(CostHistoryChartMenuView.visibleProjectSources(differing).compactMap(\.path) == ["/tmp/worktree"]) + } + + @Test + @MainActor + func `render fingerprint is stable for identical snapshots`() { + let snapshot = Self.makeSnapshot(dailyCost: 1.23, projectCount: 5) + let first = CostHistoryChartMenuView.renderFingerprint(from: snapshot, provider: .codex) + let second = CostHistoryChartMenuView.renderFingerprint(from: snapshot, provider: .codex) + + #expect(first == second) + #expect(first.projects.count == 5) + #expect(first.projects.allSatisfy { $0.sources.count <= 2 }) + } + + @Test + @MainActor + func `render fingerprint changes when daily cost changes`() { + let before = CostHistoryChartMenuView.renderFingerprint( + from: Self.makeSnapshot(dailyCost: 1.0), + provider: .codex) + let after = CostHistoryChartMenuView.renderFingerprint( + from: Self.makeSnapshot(dailyCost: 2.0), + provider: .codex) + + #expect(before != after) + } + + @Test + @MainActor + func `render fingerprint changes for total currency history window and label`() { + let base = Self.makeSnapshot(dailyCost: 1.0) + #expect( + CostHistoryChartMenuView.renderFingerprint(from: base, provider: .codex) + != CostHistoryChartMenuView.renderFingerprint(from: Self.makeSnapshot( + dailyCost: 1.0, + totalCostUSD: 9.99), provider: .codex)) + #expect( + CostHistoryChartMenuView.renderFingerprint(from: base, provider: .codex) + != CostHistoryChartMenuView.renderFingerprint(from: Self.makeSnapshot( + dailyCost: 1.0, + currencyCode: "EUR"), provider: .codex)) + #expect( + CostHistoryChartMenuView.renderFingerprint(from: base, provider: .codex) + != CostHistoryChartMenuView.renderFingerprint(from: Self.makeSnapshot( + dailyCost: 1.0, + historyDays: 7), provider: .codex)) + #expect( + CostHistoryChartMenuView.renderFingerprint(from: base, provider: .codex) + != CostHistoryChartMenuView.renderFingerprint(from: Self.makeSnapshot( + dailyCost: 1.0, + historyLabel: "Last week"), provider: .codex)) + } + + @Test + @MainActor + func `render fingerprint tracks daily token request and model breakdown fields`() { + let baseDaily = [Self.entry(date: "2026-06-07", modelCount: 1)] + let base = Self.fingerprint(dailyCost: 1.0, daily: baseDaily, projects: []) + + var changedTokens = baseDaily + changedTokens[0] = CostUsageDailyReport.Entry( + date: "2026-06-07", + inputTokens: 100, + outputTokens: 50, + totalTokens: 999, + costUSD: 1, + modelsUsed: ["model-0"], + modelBreakdowns: changedTokens[0].modelBreakdowns) + #expect(base != Self.fingerprint(dailyCost: 1.0, daily: changedTokens, projects: [])) + + var changedRequests = baseDaily + changedRequests[0] = CostUsageDailyReport.Entry( + date: "2026-06-07", + inputTokens: 100, + outputTokens: 50, + totalTokens: 150, + requestCount: 42, + costUSD: 1, + modelsUsed: ["model-0"], + modelBreakdowns: changedRequests[0].modelBreakdowns) + #expect(base != Self.fingerprint(dailyCost: 1.0, daily: changedRequests, projects: [])) + + let changedModel = [ + Self.entry(date: "2026-06-07", modelCount: 1, modelNamePrefix: "other"), + ] + #expect(base != Self.fingerprint(dailyCost: 1.0, daily: changedModel, projects: [])) + + let changedMode = [ + Self.entry(date: "2026-06-07", modelCount: 1, hasModeDetails: true), + ] + #expect(base != Self.fingerprint(dailyCost: 1.0, daily: changedMode, projects: [])) + + let reorderedDaily = [ + Self.entry(date: "2026-06-08", modelCount: 1), + Self.entry(date: "2026-06-07", modelCount: 1), + ] + #expect(base != Self.fingerprint(dailyCost: 1.0, daily: reorderedDaily, projects: [])) + } + + @Test + @MainActor + func `render fingerprint ignores hidden daily accounting fields and source order`() { + let visibleModel = CostUsageDailyReport.ModelBreakdown( + modelName: "model-visible", + costUSD: 0.75, + totalTokens: 120, + requestCount: 1, + standardCostUSD: 0.5, + priorityCostUSD: 0.25, + standardTokens: 80, + priorityTokens: 40) + let base = CostUsageDailyReport.Entry( + date: "2026-06-07", + inputTokens: 100, + outputTokens: 50, + cacheReadTokens: 20, + cacheCreationTokens: 10, + totalTokens: 150, + requestCount: 2, + costUSD: 1, + modelsUsed: ["model-visible"], + modelBreakdowns: [visibleModel]) + let hiddenFieldsChanged = CostUsageDailyReport.Entry( + date: base.date, + inputTokens: 999, + outputTokens: 888, + cacheReadTokens: 777, + cacheCreationTokens: 666, + totalTokens: base.totalTokens, + requestCount: base.requestCount, + costUSD: base.costUSD, + modelsUsed: ["unused-model-name"], + modelBreakdowns: [CostUsageDailyReport.ModelBreakdown( + modelName: visibleModel.modelName, + costUSD: visibleModel.costUSD, + totalTokens: visibleModel.totalTokens, + requestCount: 999, + standardCostUSD: visibleModel.standardCostUSD, + priorityCostUSD: visibleModel.priorityCostUSD, + standardTokens: visibleModel.standardTokens, + priorityTokens: visibleModel.priorityTokens)]) + + #expect(Self.fingerprint(daily: [base]) == Self.fingerprint(daily: [hiddenFieldsChanged])) + + let secondDay = Self.entry(date: "2026-06-08", modelCount: 1) + #expect( + Self.fingerprint(daily: [base, secondDay]) + == Self.fingerprint(daily: [secondDay, base])) + + let hiddenModeTokens = CostUsageDailyReport.ModelBreakdown( + modelName: visibleModel.modelName, + costUSD: visibleModel.costUSD, + totalTokens: visibleModel.totalTokens, + standardTokens: 1, + priorityTokens: 2) + let changedHiddenModeTokens = CostUsageDailyReport.ModelBreakdown( + modelName: visibleModel.modelName, + costUSD: visibleModel.costUSD, + totalTokens: visibleModel.totalTokens, + standardTokens: 999, + priorityTokens: 888) + #expect( + Self.fingerprint(daily: [Self.entry(modelBreakdowns: [hiddenModeTokens])]) + == Self.fingerprint(daily: [Self.entry(modelBreakdowns: [changedHiddenModeTokens])])) + } + + @Test + @MainActor + func `render fingerprint excludes invalid daily rows that the chart drops`() { + let invalidRows = [ + Self.dailyEntry(date: "2026-06-07", costUSD: nil), + Self.dailyEntry(date: "2026-06-08", costUSD: -1), + Self.dailyEntry(date: "not-a-date", costUSD: 1), + ] + let differentInvalidRows = [ + Self.dailyEntry(date: "2026-06-09", costUSD: nil), + Self.dailyEntry(date: "2026-06-10", costUSD: -99), + Self.dailyEntry(date: "still-not-a-date", costUSD: 99), + ] + let empty = Self.fingerprint(daily: []) + + #expect(Self.fingerprint(daily: invalidRows) == Self.fingerprint(daily: differentInvalidRows)) + #expect(Self.fingerprint(daily: invalidRows) != empty) + #expect(Self.fingerprint(daily: [Self.dailyEntry(date: "2026-06-07", costUSD: 1)]) != empty) + } + + @Test + @MainActor + func `render fingerprint tracks every visible model breakdown field`() { + let base = CostUsageDailyReport.ModelBreakdown( + modelName: "model-visible", + costUSD: 1, + totalTokens: 100, + standardCostUSD: 0.75, + priorityCostUSD: 0.25, + standardTokens: 75, + priorityTokens: 25) + let variants = [ + CostUsageDailyReport.ModelBreakdown( + modelName: "model-renamed", + costUSD: base.costUSD, + totalTokens: base.totalTokens, + standardCostUSD: base.standardCostUSD, + priorityCostUSD: base.priorityCostUSD, + standardTokens: base.standardTokens, + priorityTokens: base.priorityTokens), + CostUsageDailyReport.ModelBreakdown( + modelName: base.modelName, + costUSD: 2, + totalTokens: base.totalTokens, + standardCostUSD: base.standardCostUSD, + priorityCostUSD: base.priorityCostUSD, + standardTokens: base.standardTokens, + priorityTokens: base.priorityTokens), + CostUsageDailyReport.ModelBreakdown( + modelName: base.modelName, + costUSD: base.costUSD, + totalTokens: 200, + standardCostUSD: base.standardCostUSD, + priorityCostUSD: base.priorityCostUSD, + standardTokens: base.standardTokens, + priorityTokens: base.priorityTokens), + CostUsageDailyReport.ModelBreakdown( + modelName: base.modelName, + costUSD: base.costUSD, + totalTokens: base.totalTokens, + standardCostUSD: 0.5, + priorityCostUSD: base.priorityCostUSD, + standardTokens: base.standardTokens, + priorityTokens: base.priorityTokens), + CostUsageDailyReport.ModelBreakdown( + modelName: base.modelName, + costUSD: base.costUSD, + totalTokens: base.totalTokens, + standardCostUSD: base.standardCostUSD, + priorityCostUSD: 0.5, + standardTokens: base.standardTokens, + priorityTokens: base.priorityTokens), + CostUsageDailyReport.ModelBreakdown( + modelName: base.modelName, + costUSD: base.costUSD, + totalTokens: base.totalTokens, + standardCostUSD: base.standardCostUSD, + priorityCostUSD: base.priorityCostUSD, + standardTokens: 50, + priorityTokens: base.priorityTokens), + CostUsageDailyReport.ModelBreakdown( + modelName: base.modelName, + costUSD: base.costUSD, + totalTokens: base.totalTokens, + standardCostUSD: base.standardCostUSD, + priorityCostUSD: base.priorityCostUSD, + standardTokens: base.standardTokens, + priorityTokens: 50), + ] + let baseFingerprint = Self.fingerprint(daily: [Self.entry(modelBreakdowns: [base])]) + + for variant in variants { + #expect(baseFingerprint != Self.fingerprint(daily: [Self.entry(modelBreakdowns: [variant])])) + } + } + + @Test + @MainActor + func `render fingerprint excludes projects hidden for non-codex providers`() { + let first = Self.fingerprint( + projects: [Self.makeProject(index: 0, sourceCount: 2)], + provider: .claude) + let changed = Self.fingerprint( + projects: [Self.makeProject(index: 0, sourceCount: 2, totalCostUSD: 99)], + provider: .claude) + + #expect(first.projects.isEmpty) + #expect(first == changed) + } + + @Test + @MainActor + func `render fingerprint tracks visible project and source fields only`() { + let daily = [Self.entry(date: "2026-06-07", modelCount: 1)] + let projects = Self.makeProjects(count: 6, sourcesPerProject: 3) + let base = Self.fingerprint(totalCostUSD: 6.0, daily: daily, projects: projects) + + var sixthProjectNestedDaily = projects + sixthProjectNestedDaily[5] = Self.makeProject( + index: 5, + sourceCount: 3, + nestedDailyCost: 99.0) + #expect(base == Self.fingerprint(totalCostUSD: 6.0, daily: daily, projects: sixthProjectNestedDaily)) + + var topProjectNestedDaily = projects + topProjectNestedDaily[0] = Self.makeProject( + index: 0, + sourceCount: 3, + nestedDailyCost: 99.0) + #expect(base == Self.fingerprint(totalCostUSD: 6.0, daily: daily, projects: topProjectNestedDaily)) + + var renamedTopProject = projects + renamedTopProject[0] = Self.makeProject(index: 0, sourceCount: 3, nameSuffix: "-renamed") + #expect(base != Self.fingerprint(totalCostUSD: 6.0, daily: daily, projects: renamedTopProject)) + + var changedTopProjectPath = projects + changedTopProjectPath[0] = Self.makeProject(index: 0, sourceCount: 3, pathSuffix: "-renamed") + #expect(base != Self.fingerprint(totalCostUSD: 6.0, daily: daily, projects: changedTopProjectPath)) + + var changedTopProjectTotals = projects + changedTopProjectTotals[0] = Self.makeProject(index: 0, sourceCount: 3, totalCostUSD: 42.0, totalTokens: 9999) + #expect(base != Self.fingerprint(totalCostUSD: 6.0, daily: daily, projects: changedTopProjectTotals)) + + let reorderedProjects = Array(projects.reversed()) + #expect(base != Self.fingerprint(totalCostUSD: 6.0, daily: daily, projects: reorderedProjects)) + + var promotedHiddenProject = projects + let promoted = Self.makeProject( + index: 5, + sourceCount: 3, + totalCostUSD: 1000.0) + promotedHiddenProject.remove(at: 5) + promotedHiddenProject.insert(promoted, at: 0) + #expect(base != Self.fingerprint(totalCostUSD: 6.0, daily: daily, projects: promotedHiddenProject)) + } + @Test @MainActor - func `window label keeps today for one day and dynamic labels otherwise`() { - #expect(CostHistoryChartMenuView.windowLabel(days: 1) == "Today") - #expect(CostHistoryChartMenuView.windowLabel(days: 7) == "Last 7 days") - #expect(CostHistoryChartMenuView.windowLabel(days: 30) == "Last 30 days") + func `render fingerprint tracks source visibility and overflow count`() { + let daily = [Self.entry(date: "2026-06-07", modelCount: 1)] + let twoSources = [ + Self.makeProject(index: 0, sourceCount: 2), + ] + let threeSources = [ + Self.makeProject(index: 0, sourceCount: 3), + ] + let base = Self.fingerprint(dailyCost: 1.0, daily: daily, projects: twoSources) + + #expect(base != Self.fingerprint(dailyCost: 1.0, daily: daily, projects: threeSources)) + + var thirdSourceRenamed = threeSources + thirdSourceRenamed[0] = Self.makeProject(index: 0, sourceCount: 3, renameThirdSource: true) + #expect(Self.fingerprint(dailyCost: 1.0, daily: daily, projects: threeSources) + == Self.fingerprint(dailyCost: 1.0, daily: daily, projects: thirdSourceRenamed)) + + var firstSourceRenamed = twoSources + firstSourceRenamed[0] = Self.makeProject(index: 0, sourceCount: 2, renameFirstSource: true) + #expect(base != Self.fingerprint(dailyCost: 1.0, daily: daily, projects: firstSourceRenamed)) + + var firstSourceTotals = twoSources + firstSourceTotals[0] = Self.makeProject( + index: 0, + sourceCount: 2, + firstSourceCostUSD: 9.99, + firstSourceTokens: 8888) + #expect(base != Self.fingerprint(dailyCost: 1.0, daily: daily, projects: firstSourceTotals)) + + let hiddenSingleSource = [ + Self.project(path: "/tmp/main", sourcePath: "/tmp/main"), + ] + let visibleSingleSource = [ + Self.project(path: "/tmp/main", sourcePath: "/tmp/worktree"), + ] + #expect( + Self.fingerprint(dailyCost: 1.0, daily: daily, projects: hiddenSingleSource) + != Self.fingerprint(dailyCost: 1.0, daily: daily, projects: visibleSingleSource)) + } + + private static func project(path: String, sourcePath: String) -> CostUsageProjectBreakdown { + CostUsageProjectBreakdown( + name: "Project", + path: path, + totalTokens: 10, + totalCostUSD: 0.1, + daily: [], + modelBreakdowns: nil, + sources: [ + CostUsageProjectSourceBreakdown( + name: "Source", + path: sourcePath, + totalTokens: 10, + totalCostUSD: 0.1, + daily: [], + modelBreakdowns: nil), + ]) + } + + @MainActor + private static func renderedHeight(daily: [CostUsageDailyReport.Entry]) -> CGFloat { + let hosting = MenuHostingView(rootView: CostHistoryChartMenuView( + provider: .codex, + daily: daily, + totalCostUSD: nil, + width: 320)) + hosting.frame = CGRect(x: 0, y: 0, width: 320, height: 1) + hosting.layoutSubtreeIfNeeded() + return ceil(hosting.fittingSize.height) + } + + private static func entry( + date: String, + modelCount: Int, + hasModeDetails: Bool = false, + modelNamePrefix: String = "model") -> CostUsageDailyReport.Entry + { + CostUsageDailyReport.Entry( + date: date, + inputTokens: 100, + outputTokens: 50, + totalTokens: 150, + costUSD: 1, + modelsUsed: modelCount > 0 ? (0.. 0 + ? (0.. CostUsageDailyReport.Entry + { + CostUsageDailyReport.Entry( + date: "2026-06-07", + inputTokens: 100, + outputTokens: 50, + totalTokens: 150, + costUSD: 1, + modelsUsed: modelBreakdowns.map(\.modelName), + modelBreakdowns: modelBreakdowns) + } + + private static func dailyEntry(date: String, costUSD: Double?) -> CostUsageDailyReport.Entry { + CostUsageDailyReport.Entry( + date: date, + inputTokens: 100, + outputTokens: 50, + totalTokens: 150, + costUSD: costUSD, + modelsUsed: nil, + modelBreakdowns: nil) + } + + private static func makeSnapshot( + dailyCost: Double = 1.0, + projectCount: Int = 0, + totalCostUSD: Double? = nil, + currencyCode: String = "USD", + historyDays: Int = 30, + historyLabel: String? = nil, + daily: [CostUsageDailyReport.Entry]? = nil, + projects: [CostUsageProjectBreakdown]? = nil, + sessions: [CostUsageSessionBreakdown] = []) -> CostUsageTokenSnapshot + { + CostUsageTokenSnapshot( + sessionTokens: 123, + sessionCostUSD: 0.12, + last30DaysTokens: 123, + last30DaysCostUSD: totalCostUSD ?? dailyCost, + currencyCode: currencyCode, + historyDays: historyDays, + historyLabel: historyLabel, + daily: daily ?? [ + CostUsageDailyReport.Entry( + date: "2025-12-23", + inputTokens: nil, + outputTokens: nil, + totalTokens: 123, + costUSD: dailyCost, + modelsUsed: nil, + modelBreakdowns: nil), + ], + projects: projects ?? self.makeProjects(count: projectCount, sourcesPerProject: 1), + sessions: sessions, + updatedAt: Date()) + } + + private static func fingerprint( + dailyCost: Double = 1.0, + totalCostUSD: Double? = nil, + currencyCode: String = "USD", + historyDays: Int = 30, + historyLabel: String? = nil, + daily: [CostUsageDailyReport.Entry]? = nil, + projects: [CostUsageProjectBreakdown]? = nil, + sessions: [CostUsageSessionBreakdown] = [], + provider: UsageProvider = .codex) -> CostHistoryChartMenuView.RenderFingerprint + { + CostHistoryChartMenuView.renderFingerprint(from: self.makeSnapshot( + dailyCost: dailyCost, + totalCostUSD: totalCostUSD, + currencyCode: currencyCode, + historyDays: historyDays, + historyLabel: historyLabel, + daily: daily, + projects: projects, + sessions: sessions), provider: provider) + } + + private static func makeProjects(count: Int, sourcesPerProject: Int) -> [CostUsageProjectBreakdown] { + (0.. CostUsageProjectBreakdown + { + let nestedDaily = [ + CostUsageDailyReport.Entry( + date: "2025-12-23", + inputTokens: 1, + outputTokens: 1, + totalTokens: 10, + costUSD: nestedDailyCost, + modelsUsed: ["nested"], + modelBreakdowns: [ + CostUsageDailyReport.ModelBreakdown( + modelName: "nested-model", + costUSD: nestedDailyCost, + totalTokens: 10), + ]), + ] + let sources = (0.. CostUsageSessionBreakdown { + CostUsageSessionBreakdown( + sessionID: "session-1", + lastActivity: Date(timeIntervalSince1970: 100), + inputTokens: input, + cachedInputTokens: cached, + outputTokens: output, + totalTokens: 110, + requestCount: 1, + costUSD: 0.01, + modelBreakdowns: []) + } + + let base = Self.fingerprint(sessions: [session(input: 100, cached: 20, output: 10)]) + #expect(base != Self.fingerprint(sessions: [session(input: 90, cached: 20, output: 10)])) + #expect(base != Self.fingerprint(sessions: [session(input: 100, cached: 10, output: 10)])) + #expect(base != Self.fingerprint(sessions: [session(input: 100, cached: 20, output: 20)])) } } diff --git a/Tests/CodexBarTests/CostSummarySettingsSectionTests.swift b/Tests/CodexBarTests/CostSummarySettingsSectionTests.swift new file mode 100644 index 0000000000..ad305b2834 --- /dev/null +++ b/Tests/CodexBarTests/CostSummarySettingsSectionTests.swift @@ -0,0 +1,13 @@ +import Testing +@testable import CodexBar + +@MainActor +struct CostSummarySettingsSectionTests { + @Test + func `cost settings explain reported and estimated sources`() { + #expect( + CostSummarySettingsSection.costDataExplanation() + == "Costs may be provider-reported or estimated from token usage at public API prices. " + + "Estimates are not subscription charges.") + } +} diff --git a/Tests/CodexBarTests/CostUsageCacheTests.swift b/Tests/CodexBarTests/CostUsageCacheTests.swift index f71c60e250..d06d66c09c 100644 --- a/Tests/CodexBarTests/CostUsageCacheTests.swift +++ b/Tests/CodexBarTests/CostUsageCacheTests.swift @@ -4,14 +4,109 @@ import Testing struct CostUsageCacheTests { @Test - func `cache file URL uses codex specific artifact version`() { + func `legacy codex token cache decodes without reasoning while current rows round trip it`() throws { + let legacyTotals = try JSONDecoder().decode( + CostUsageCodexTotals.self, + from: Data(#"{"input":10,"cached":2,"output":4}"#.utf8)) + #expect(legacyTotals.reasoning == nil) + + let legacyRow = try JSONDecoder().decode( + CostUsageScanner.CodexUsageRow.self, + from: Data(#"{"day":"2026-07-17","model":"gpt-5.5","input":10,"cached":2,"output":4}"#.utf8)) + #expect(legacyRow.reasoning == nil) + + let currentRow = CostUsageScanner.CodexUsageRow( + day: "2026-07-17", + model: "gpt-5.5", + turnID: "turn", + eventIndex: 1, + input: 10, + cached: 2, + output: 4, + reasoning: 3) + let roundTripped = try JSONDecoder().decode( + CostUsageScanner.CodexUsageRow.self, + from: JSONEncoder().encode(currentRow)) + #expect(roundTripped.reasoning == 3) + } + + @Test + func `cache file URL uses provider artifact versions`() { let root = URL(fileURLWithPath: "/tmp/codexbar-cost-cache", isDirectory: true) let codexURL = CostUsageCacheIO.cacheFileURL(provider: .codex, cacheRoot: root) let claudeURL = CostUsageCacheIO.cacheFileURL(provider: .claude, cacheRoot: root) + let vertexURL = CostUsageCacheIO.cacheFileURL(provider: .vertexai, cacheRoot: root) - #expect(codexURL.lastPathComponent == "codex-v8.json") - #expect(claudeURL.lastPathComponent == "claude-v2.json") + #expect(codexURL.lastPathComponent == "codex-v11.json") + #expect(claudeURL.lastPathComponent == "claude-v6.json") + #expect(vertexURL.lastPathComponent == "vertexai-v6.json") + } + + @Test + func `cost cache ignores predecessor artifact with persisted offset`() throws { + let root = try self.makeTemporaryCacheRoot() + defer { try? FileManager.default.removeItem(at: root) } + + let legacyURL = root + .appendingPathComponent("cost-usage", isDirectory: true) + .appendingPathComponent("codex-v9.json", isDirectory: false) + try FileManager.default.createDirectory( + at: legacyURL.deletingLastPathComponent(), + withIntermediateDirectories: true) + let producerKey = try #require(CostUsageCacheIO.currentProducerKey(provider: .codex)) + let legacy = """ + { + "version": 1, + "producerKey": "\(producerKey)", + "lastScanUnixMs": 999, + "files": { + "/tmp/session.jsonl": { + "mtimeUnixMs": 1, + "size": 100, + "days": {}, + "parsedBytes": 100 + } + }, + "days": {} + } + """ + try legacy.write(to: legacyURL, atomically: false, encoding: .utf8) + + let loaded = CostUsageCacheIO.load(provider: .codex, cacheRoot: root) + + #expect(loaded.lastScanUnixMs == 0) + #expect(loaded.files.isEmpty) + } + + @Test + func `Pi session cache ignores predecessor artifact with persisted offset`() throws { + let root = try self.makeTemporaryCacheRoot() + defer { try? FileManager.default.removeItem(at: root) } + + let legacyURL = root + .appendingPathComponent("cost-usage", isDirectory: true) + .appendingPathComponent("pi-sessions-v7.json", isDirectory: false) + try FileManager.default.createDirectory( + at: legacyURL.deletingLastPathComponent(), + withIntermediateDirectories: true) + var legacy = PiSessionCostCache(version: 7) + legacy.lastScanUnixMs = 999 + legacy.files = [ + "/tmp/session.jsonl": PiSessionFileUsage( + mtimeUnixMs: 1, + size: 100, + parsedBytes: 100, + lastModelContext: nil, + contributions: [:]), + ] + try JSONEncoder().encode(legacy).write(to: legacyURL) + + let loaded = PiSessionCostCacheIO.load(cacheRoot: root) + + #expect(loaded.version == 8) + #expect(loaded.lastScanUnixMs == 0) + #expect(loaded.files.isEmpty) } @Test @@ -78,6 +173,30 @@ struct CostUsageCacheTests { #expect(loaded.days.isEmpty) } + @Test + func `current codex cache rejects pre interleave containment producers`() throws { + // Interleave containment (#2037) changed cumulative delta semantics, so caches from + // previously compatible parser hashes must be rebuilt instead of reused. + let root = try self.makeTemporaryCacheRoot() + defer { try? FileManager.default.removeItem(at: root) } + + for legacyProducerKey in ["codex:cu:p3c27f997569eb3c5", "codex:cu:pc54070a94f6419ea"] { + var cache = CostUsageCache() + cache.lastScanUnixMs = 123 + cache.days = ["2026-05-18": ["gpt-5.5": [1, 2, 3]]] + CostUsageCacheIO.save( + provider: .codex, + cache: cache, + cacheRoot: root, + producerKey: legacyProducerKey) + + let loaded = CostUsageCacheIO.load(provider: .codex, cacheRoot: root) + + #expect(loaded.lastScanUnixMs == 0) + #expect(loaded.days.isEmpty) + } + } + @Test func `non codex cache does not require producer key`() throws { let root = try self.makeTemporaryCacheRoot() diff --git a/Tests/CodexBarTests/CostUsageCalendarTests.swift b/Tests/CodexBarTests/CostUsageCalendarTests.swift new file mode 100644 index 0000000000..5ac618a7ff --- /dev/null +++ b/Tests/CodexBarTests/CostUsageCalendarTests.swift @@ -0,0 +1,465 @@ +import Foundation +import Testing +@testable import CodexBarCore + +@Suite(.serialized) +struct CostUsageCalendarTests { + @Test + func `day keys remain Gregorian under a Buddhist calendar`() throws { + let bangkok = try #require(TimeZone(identifier: "Asia/Bangkok")) + var gregorian = Calendar(identifier: .gregorian) + gregorian.timeZone = bangkok + let date = try #require(gregorian.date(from: DateComponents( + timeZone: bangkok, + year: 2026, + month: 7, + day: 23, + hour: 12))) + + var buddhist = Calendar(identifier: .buddhist) + buddhist.timeZone = bangkok + #expect(buddhist.component(.year, from: date) == 2569) + + let range = CostUsageScanner.CostUsageDayRange(since: date, until: date, calendar: buddhist) + #expect(range.sinceKey == "2026-07-23") + #expect(range.untilKey == "2026-07-23") + #expect(range.scanSinceKey == "2026-07-22") + #expect(range.scanUntilKey == "2026-07-24") + #expect(CostUsageScanner.dayKeyFromTimestamp( + "2026-07-23T05:00:00Z", + calendar: buddhist) == "2026-07-23") + #expect(CostUsageScanner.dayKeyFromParsedISO( + "2026-07-23T05:00:00Z", + calendar: buddhist) == "2026-07-23") + + let parsed = try #require(CostUsageScanner.parseDayKey("2026-07-23", calendar: buddhist)) + #expect(CostUsageScanner.CostUsageDayRange.dayKey( + from: parsed, + calendar: buddhist) == "2026-07-23") + } + + @Test + func `warm cache discovers a new Gregorian partition under a Buddhist calendar`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let bangkok = try #require(TimeZone(identifier: "Asia/Bangkok")) + var gregorian = Calendar(identifier: .gregorian) + gregorian.timeZone = bangkok + let firstDay = try #require(gregorian.date(from: DateComponents( + timeZone: bangkok, + year: 2026, + month: 7, + day: 22, + hour: 12))) + let secondDay = try #require(gregorian.date(byAdding: .day, value: 1, to: firstDay)) + var buddhist = Calendar(identifier: .buddhist) + buddhist.timeZone = bangkok + #expect(buddhist.component(.year, from: secondDay) == 2569) + + let firstURL = try Self.writeCodexSession( + env: env, + day: firstDay, + partitionCalendar: gregorian, + filename: "first.jsonl", + tokens: 10) + var options = CostUsageScanner.Options( + codexSessionsRoot: env.codexSessionsRoot, + cacheRoot: env.cacheRoot, + codexTraceDatabaseURL: env.root.appendingPathComponent("missing-traces.sqlite"), + calendar: buddhist) + options.refreshMinIntervalSeconds = 0 + + let firstReport = CostUsageScanner.loadDailyReport( + provider: .codex, + since: firstDay, + until: firstDay, + now: firstDay, + options: options) + #expect(firstReport.data.map(\.date) == ["2026-07-22"]) + #expect(firstReport.data.first?.totalTokens == 10) + + let secondURL = try Self.writeCodexSession( + env: env, + day: secondDay, + partitionCalendar: gregorian, + filename: "second.jsonl", + tokens: 20) + let secondReport = CostUsageScanner.loadDailyReport( + provider: .codex, + since: secondDay, + until: secondDay, + now: secondDay, + options: options) + let cache = CostUsageCacheIO.load(provider: .codex, cacheRoot: env.cacheRoot) + + #expect(secondReport.data.map(\.date) == ["2026-07-23"]) + #expect(secondReport.data.first?.totalTokens == 20) + #expect(cache.scanSinceKey == "2026-07-21") + #expect(cache.scanUntilKey == "2026-07-24") + #expect(Set(cache.files.keys.map { URL(fileURLWithPath: $0).standardizedFileURL.path }) == Set([ + firstURL.standardizedFileURL.path, + secondURL.standardizedFileURL.path, + ])) + } + + @Test + func `codex cache re-buckets unchanged files when the time zone changes`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let utc = try Self.calendar(timeZoneIdentifier: "UTC") + let bangkok = try Self.calendar(timeZoneIdentifier: "Asia/Bangkok") + let boundary = try Self.date("2026-07-22T18:00:00Z") + let windowStart = try Self.date("2026-07-20T12:00:00Z") + let windowEnd = try Self.date("2026-07-24T12:00:00Z") + _ = try Self.writeCodexSession( + env: env, + day: boundary, + partitionCalendar: utc, + filename: "time-zone-change.jsonl", + tokens: 10) + + let utcReport = CostUsageScanner.loadDailyReport( + provider: .codex, + since: windowStart, + until: windowEnd, + now: windowEnd, + options: Self.codexOptions(env: env, calendar: utc)) + let utcCache = CostUsageCacheIO.load(provider: .codex, cacheRoot: env.cacheRoot) + #expect(utcReport.data.map(\.date) == ["2026-07-22"]) + #expect(utcCache.timeZoneIdentifier == utc.timeZone.identifier) + #expect(utcCache.files.values.compactMap(\.sessionId) == ["calendar-time-zone-change.jsonl"]) + + let bangkokReport = CostUsageScanner.loadDailyReport( + provider: .codex, + since: windowStart, + until: windowEnd, + now: windowEnd, + options: Self.codexOptions(env: env, calendar: bangkok)) + let bangkokCache = CostUsageCacheIO.load(provider: .codex, cacheRoot: env.cacheRoot) + #expect(bangkokReport.data.map(\.date) == ["2026-07-23"]) + #expect(bangkokReport.data.first?.totalTokens == 10) + #expect(bangkokCache.timeZoneIdentifier == "Asia/Bangkok") + } + + @Test + func `codex cache does not mix old and new zones after an append`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let utc = try Self.calendar(timeZoneIdentifier: "UTC") + let bangkok = try Self.calendar(timeZoneIdentifier: "Asia/Bangkok") + let boundary = try Self.date("2026-07-22T18:00:00Z") + let windowStart = try Self.date("2026-07-20T12:00:00Z") + let windowEnd = try Self.date("2026-07-24T12:00:00Z") + let fileURL = try Self.writeCodexSession( + env: env, + day: boundary, + partitionCalendar: utc, + filename: "time-zone-append.jsonl", + tokens: 10) + + let utcReport = CostUsageScanner.loadDailyReport( + provider: .codex, + since: windowStart, + until: windowEnd, + now: windowEnd, + options: Self.codexOptions(env: env, calendar: utc)) + #expect(utcReport.data.map(\.date) == ["2026-07-22"]) + + let handle = try FileHandle(forWritingTo: fileURL) + defer { try? handle.close() } + try handle.seekToEnd() + let appended = try env.jsonl([ + Self.codexTokenCount( + timestamp: env.isoString(for: boundary.addingTimeInterval(2)), + tokens: 30), + ]) + try handle.write(contentsOf: Data(appended.utf8)) + try handle.close() + + let bangkokReport = CostUsageScanner.loadDailyReport( + provider: .codex, + since: windowStart, + until: windowEnd, + now: windowEnd, + options: Self.codexOptions(env: env, calendar: bangkok)) + #expect(bangkokReport.data.map(\.date) == ["2026-07-23"]) + #expect(bangkokReport.data.first?.totalTokens == 30) + } + + @Test + func `fetcher keeps daily project session and cached ranges in the injected zone`() async throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let utc = try Self.calendar(timeZoneIdentifier: "UTC") + let bangkok = try Self.calendar(timeZoneIdentifier: "Asia/Bangkok") + let boundary = try Self.date("2026-07-22T18:00:00Z") + _ = try Self.writeCodexSession( + env: env, + day: boundary, + partitionCalendar: utc, + filename: "fetcher-time-zone.jsonl", + tokens: 10) + + try await Self.expectFetcherSnapshot( + env: env, + now: boundary, + calendar: utc, + expectedDay: "2026-07-22") + try await Self.expectFetcherSnapshot( + env: env, + now: boundary, + calendar: bangkok, + expectedDay: "2026-07-23") + } + + @Test + func `claude and pi caches re-bucket unchanged files when the time zone changes`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let utc = try Self.calendar(timeZoneIdentifier: "UTC") + let bangkok = try Self.calendar(timeZoneIdentifier: "Asia/Bangkok") + let boundary = try Self.date("2026-07-22T18:00:00Z") + let windowStart = try Self.date("2026-07-20T12:00:00Z") + let windowEnd = try Self.date("2026-07-24T12:00:00Z") + _ = try env.writeClaudeProjectFile( + relativePath: "calendar/session.jsonl", + contents: env.jsonl([ + [ + "type": "assistant", + "timestamp": env.isoString(for: boundary), + "sessionId": "claude-calendar-session", + "requestId": "claude-calendar-request", + "message": [ + "id": "claude-calendar-message", + "model": "claude-sonnet-4-20250514", + "usage": [ + "input_tokens": 10, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 0, + "output_tokens": 2, + ], + ], + ], + ])) + _ = try env.writePiSessionFile( + relativePath: "calendar/2026-07-22T18-00-00-000Z_session.jsonl", + contents: env.jsonl([ + [ + "type": "message", + "timestamp": env.isoString(for: boundary), + "message": [ + "role": "assistant", + "provider": "openai-codex", + "model": "openai/gpt-5.4", + "timestamp": Int(boundary.timeIntervalSince1970 * 1000), + "usage": [ + "input": 10, + "output": 2, + "cacheRead": 0, + "cacheWrite": 0, + "totalTokens": 12, + ], + ], + ], + ])) + + let utcClaude = CostUsageScanner.loadDailyReport( + provider: .claude, + since: windowStart, + until: windowEnd, + now: windowEnd, + options: Self.claudeOptions(env: env, calendar: utc)) + let bangkokClaude = CostUsageScanner.loadDailyReport( + provider: .claude, + since: windowStart, + until: windowEnd, + now: windowEnd, + options: Self.claudeOptions(env: env, calendar: bangkok)) + #expect(utcClaude.data.map(\.date) == ["2026-07-22"]) + #expect(bangkokClaude.data.map(\.date) == ["2026-07-23"]) + #expect(CostUsageCacheIO.load( + provider: .claude, + cacheRoot: env.cacheRoot).timeZoneIdentifier == "Asia/Bangkok") + + let utcPi = PiSessionCostScanner.loadDailyReport( + provider: .codex, + since: windowStart, + until: windowEnd, + now: windowEnd, + options: Self.piOptions(env: env, calendar: utc)) + let bangkokPi = PiSessionCostScanner.loadDailyReport( + provider: .codex, + since: windowStart, + until: windowEnd, + now: windowEnd, + options: Self.piOptions(env: env, calendar: bangkok)) + #expect(utcPi.data.map(\.date) == ["2026-07-22"]) + #expect(bangkokPi.data.map(\.date) == ["2026-07-23"]) + #expect(PiSessionCostCacheIO.load(cacheRoot: env.cacheRoot).timeZoneIdentifier == "Asia/Bangkok") + } + + private static func expectFetcherSnapshot( + env: CostUsageTestEnvironment, + now: Date, + calendar: Calendar, + expectedDay: String) async throws + { + let options = Self.codexOptions(env: env, calendar: calendar) + let snapshot = try await CostUsageFetcher.loadTokenSnapshot( + provider: .codex, + now: now, + historyDays: 1, + allowPricingRefresh: false, + includePiSessions: false, + scannerOptions: options) + #expect(snapshot.daily.map(\.date) == [expectedDay]) + #expect(snapshot.projects.flatMap(\.daily).map(\.date) == [expectedDay]) + #expect(snapshot.sessions.map(\.sessionID) == ["calendar-fetcher-time-zone.jsonl"]) + #expect(snapshot.sessionTokens == 10) + + let cached = await CostUsageFetcher.loadCachedCodexTokenSnapshot( + now: now, + historyDays: 1, + scannerOptions: options) + #expect(cached?.daily.map(\.date) == [expectedDay]) + #expect(cached?.projects.flatMap(\.daily).map(\.date) == [expectedDay]) + #expect(cached?.sessions.map(\.sessionID) == ["calendar-fetcher-time-zone.jsonl"]) + #expect(cached?.sessionTokens == 10) + + let staleProjectSnapshot = await CostUsageFetcher.loadCachedCodexLocalProjectUsageSnapshot( + now: now, + historyDays: 1, + hidePersonalInfo: false, + scannerOptions: options) + #expect(staleProjectSnapshot == nil) + + let projectSnapshot = try await CostUsageFetcher.loadCodexLocalProjectUsageSnapshot( + now: now, + forceRefresh: true, + historyDays: 1, + hidePersonalInfo: false, + scannerOptions: options) + #expect(projectSnapshot.daily.map(\.day) == [expectedDay]) + #expect(projectSnapshot.projects.flatMap(\.daily).map(\.day) == [expectedDay]) + #expect(projectSnapshot.sessions.flatMap(\.daily).map(\.day) == [expectedDay]) + #expect(projectSnapshot.scopeSignature.contains("timeZone=\(calendar.timeZone.identifier)")) + + let cachedProjectSnapshot = await CostUsageFetcher.loadCachedCodexLocalProjectUsageSnapshot( + now: now, + historyDays: 1, + hidePersonalInfo: false, + scannerOptions: options) + #expect(cachedProjectSnapshot?.daily.map(\.day) == [expectedDay]) + } + + private static func codexOptions( + env: CostUsageTestEnvironment, + calendar: Calendar) -> CostUsageScanner.Options + { + var options = CostUsageScanner.Options( + codexSessionsRoot: env.codexSessionsRoot, + cacheRoot: env.cacheRoot, + codexTraceDatabaseURL: env.root.appendingPathComponent("missing-traces.sqlite"), + calendar: calendar) + options.refreshMinIntervalSeconds = 0 + return options + } + + private static func claudeOptions( + env: CostUsageTestEnvironment, + calendar: Calendar) -> CostUsageScanner.Options + { + var options = CostUsageScanner.Options( + claudeProjectsRoots: [env.claudeProjectsRoot], + cacheRoot: env.cacheRoot, + calendar: calendar) + options.refreshMinIntervalSeconds = 0 + return options + } + + private static func piOptions( + env: CostUsageTestEnvironment, + calendar: Calendar) -> PiSessionCostScanner.Options + { + PiSessionCostScanner.Options( + piSessionsRoot: env.piSessionsRoot, + cacheRoot: env.cacheRoot, + calendar: calendar, + refreshMinIntervalSeconds: 0) + } + + private static func calendar(timeZoneIdentifier: String) throws -> Calendar { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = try #require(TimeZone(identifier: timeZoneIdentifier)) + return calendar + } + + private static func date(_ text: String) throws -> Date { + try #require(ISO8601DateFormatter().date(from: text)) + } + + private static func codexTokenCount( + timestamp: String, + tokens: Int, + model: String = "openai/gpt-5.4") -> [String: Any] + { + [ + "type": "event_msg", + "timestamp": timestamp, + "payload": [ + "type": "token_count", + "info": [ + "total_token_usage": [ + "input_tokens": tokens, + "cached_input_tokens": 0, + "output_tokens": 0, + ], + "model": model, + ], + ], + ] + } + + private static func writeCodexSession( + env: CostUsageTestEnvironment, + day: Date, + partitionCalendar: Calendar, + filename: String, + tokens: Int) throws -> URL + { + let components = partitionCalendar.dateComponents([.year, .month, .day], from: day) + let directory = env.codexSessionsRoot + .appendingPathComponent(String(format: "%04d", components.year ?? 1970), isDirectory: true) + .appendingPathComponent(String(format: "%02d", components.month ?? 1), isDirectory: true) + .appendingPathComponent(String(format: "%02d", components.day ?? 1), isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + + let model = "openai/gpt-5.4" + let url = directory.appendingPathComponent(filename, isDirectory: false) + try env.jsonl([ + [ + "type": "session_meta", + "timestamp": env.isoString(for: day), + "payload": [ + "id": "calendar-\(filename)", + "cwd": env.root.appendingPathComponent("calendar-project", isDirectory: true).path, + ], + ], + [ + "type": "turn_context", + "timestamp": env.isoString(for: day), + "payload": ["model": model], + ], + Self.codexTokenCount( + timestamp: env.isoString(for: day.addingTimeInterval(1)), + tokens: tokens, + model: model), + ]).write(to: url, atomically: true, encoding: .utf8) + return url + } +} diff --git a/Tests/CodexBarTests/CostUsageDecodingTests.swift b/Tests/CodexBarTests/CostUsageDecodingTests.swift index 5175a0343d..acf4f65ea0 100644 --- a/Tests/CodexBarTests/CostUsageDecodingTests.swift +++ b/Tests/CodexBarTests/CostUsageDecodingTests.swift @@ -332,7 +332,56 @@ struct CostUsageDecodingTests { } @Test - func `token snapshot selects most recent day`() throws { + func `selects most recent supported month format`() throws { + let json = """ + { + "type": "monthly", + "data": [ + { "month": "Dec 2025", "totalTokens": 100, "costUSD": 1.00 }, + { "month": "January 2026", "totalTokens": 200, "costUSD": 2.00 }, + { "month": "2026-02", "totalTokens": 300, "costUSD": 3.00 } + ] + } + """ + + let report = try JSONDecoder().decode(CostUsageMonthlyReport.self, from: Data(json.utf8)) + let selected = CostUsageFetcher.selectMostRecentMonth(from: report.data) + #expect(selected?.month == "2026-02") + #expect(selected?.totalTokens == 300) + } + + @Test + func `date parsers handle concurrent mixed formats`() async { + let dateInputs = [ + "2026-02-03T04:05:06.789Z", + "2026-02-03T04:05:06Z", + "2026-02-03", + "Feb 3, 2026", + ] + let monthInputs = ["Feb 2026", "February 2026", "2026-02"] + + await withTaskGroup(of: Bool.self) { group in + for _ in 0..<32 { + group.addTask { + for _ in 0..<250 { + guard dateInputs.allSatisfy({ CostUsageDateParser.parse($0) != nil }), + monthInputs.allSatisfy({ CostUsageDateParser.parseMonth($0) != nil }) + else { + return false + } + } + return true + } + } + + for await succeeded in group { + #expect(succeeded) + } + } + } + + @Test + func `token snapshot selects current local day`() throws { let json = """ { "type": "daily", @@ -355,7 +404,7 @@ struct CostUsageDecodingTests { """ let report = try JSONDecoder().decode(CostUsageDailyReport.self, from: Data(json.utf8)) - let now = Date(timeIntervalSince1970: 1_766_275_200) // 2025-12-21 + let now = try Self.localNoon(year: 2025, month: 12, day: 21) let snapshot = CostUsageFetcher.tokenSnapshot(from: report, now: now) #expect(snapshot.sessionTokens == 10) #expect(snapshot.sessionCostUSD == 4.56) @@ -364,6 +413,36 @@ struct CostUsageDecodingTests { #expect(snapshot.updatedAt == now) } + @Test + func `token snapshot rejects impossible later calendar day`() throws { + let json = """ + { + "type": "daily", + "data": [ + { + "date": "2026-05-13", + "totalTokens": 30, + "costUSD": 23.45 + }, + { + "date": "2026-06-31", + "totalTokens": 40, + "costUSD": 99.00 + } + ] + } + """ + + let report = try JSONDecoder().decode(CostUsageDailyReport.self, from: Data(json.utf8)) + let snapshot = CostUsageFetcher.tokenSnapshot( + from: report, + now: Date(), + useCurrentLocalDayForSession: false) + + #expect(snapshot.sessionTokens == 30) + #expect(snapshot.sessionCostUSD == 23.45) + } + @Test func `token snapshot uses summary total cost when available`() throws { let json = """ @@ -417,4 +496,8 @@ struct CostUsageDecodingTests { let snapshot = CostUsageFetcher.tokenSnapshot(from: report, now: Date()) #expect(snapshot.last30DaysCostUSD == nil) } + + private static func localNoon(year: Int, month: Int, day: Int) throws -> Date { + try #require(Calendar.current.date(from: DateComponents(year: year, month: month, day: day, hour: 12))) + } } diff --git a/Tests/CodexBarTests/CostUsageFetcherCacheSnapshotTests.swift b/Tests/CodexBarTests/CostUsageFetcherCacheSnapshotTests.swift new file mode 100644 index 0000000000..bcf9d25777 --- /dev/null +++ b/Tests/CodexBarTests/CostUsageFetcherCacheSnapshotTests.swift @@ -0,0 +1,499 @@ +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 keeps the cache scan time as updatedAt`() 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 cache = CostUsageCacheIO.load(provider: .codex, cacheRoot: env.cacheRoot) + #expect(cache.lastScanUnixMs > 0) + let scanTime = Date(timeIntervalSince1970: TimeInterval(cache.lastScanUnixMs) / 1000) + + let hydratedAt = day.addingTimeInterval(50 * 60) + let cached = await CostUsageFetcher.loadCachedCodexTokenSnapshotResult( + now: hydratedAt, + historyDays: 1, + scannerOptions: options) + + #expect(cached?.snapshot.updatedAt == scanTime) + #expect(cached?.snapshot.updatedAt != hydratedAt) + #expect(cached?.lastRefreshAt == scanTime) + } + + @Test + func `cached codex token snapshot keeps the oldest scan time when pi sessions merge`() 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 nativeCache = CostUsageCacheIO.load(provider: .codex, cacheRoot: env.cacheRoot) + var piCache = PiSessionCostCacheIO.load(cacheRoot: env.cacheRoot) + #expect(nativeCache.lastScanUnixMs > 0) + #expect(piCache.lastScanUnixMs > 0) + piCache.lastScanUnixMs = nativeCache.lastScanUnixMs - 30 * 60 * 1000 + PiSessionCostCacheIO.save(cache: piCache, cacheRoot: env.cacheRoot) + let oldestScanTime = Date(timeIntervalSince1970: TimeInterval(piCache.lastScanUnixMs) / 1000) + + let hydratedAt = day.addingTimeInterval(50 * 60) + let cached = await CostUsageFetcher.loadCachedCodexTokenSnapshotResult( + now: hydratedAt, + historyDays: 1, + scannerOptions: options) + + #expect(cached?.snapshot.sessionTokens == 207) + #expect(cached?.snapshot.updatedAt == oldestScanTime) + #expect(cached?.snapshot.updatedAt != hydratedAt) + #expect(cached?.lastRefreshAt == nil) + } + + @Test + func `cached codex token snapshot keeps pi scan time when only pi sessions exist`() 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 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 piCache = PiSessionCostCacheIO.load(cacheRoot: env.cacheRoot) + #expect(piCache.lastScanUnixMs > 0) + let piScanTime = Date(timeIntervalSince1970: TimeInterval(piCache.lastScanUnixMs) / 1000) + + let cached = await CostUsageFetcher.loadCachedCodexTokenSnapshotResult( + now: day.addingTimeInterval(50 * 60), + historyDays: 1, + scannerOptions: options) + + #expect(cached?.snapshot.sessionTokens == 165) + #expect(cached?.snapshot.updatedAt == piScanTime) + #expect(cached?.lastRefreshAt == nil) + } + + @Test + func `cached codex token snapshot keeps native scan time when pi cache lacks one`() 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 piCache = PiSessionCostCacheIO.load(cacheRoot: env.cacheRoot) + piCache.lastScanUnixMs = 0 + PiSessionCostCacheIO.save(cache: piCache, cacheRoot: env.cacheRoot) + + let nativeCache = CostUsageCacheIO.load(provider: .codex, cacheRoot: env.cacheRoot) + #expect(nativeCache.lastScanUnixMs > 0) + let nativeScanTime = Date( + timeIntervalSince1970: TimeInterval(nativeCache.lastScanUnixMs) / 1000) + + let hydratedAt = day.addingTimeInterval(50 * 60) + let cached = await CostUsageFetcher.loadCachedCodexTokenSnapshot( + now: hydratedAt, + historyDays: 1, + scannerOptions: options) + + #expect(cached?.sessionTokens == 207) + #expect(cached?.updatedAt == nativeScanTime) + #expect(cached?.updatedAt != hydratedAt) + } + + @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 omits projects until metadata migration`() 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 current = await CostUsageFetcher.loadCachedCodexTokenSnapshot( + now: day, + historyDays: 1, + scannerOptions: options) + #expect(current?.projects.count == 1) + + var cache = CostUsageCacheIO.load(provider: .codex, cacheRoot: env.cacheRoot) + cache.codexProjectMetadataVersion = nil + CostUsageCacheIO.save(provider: .codex, cache: cache, cacheRoot: env.cacheRoot) + + let legacy = await CostUsageFetcher.loadCachedCodexTokenSnapshot( + now: day, + historyDays: 1, + scannerOptions: options) + #expect(legacy?.sessionTokens == 42) + #expect(legacy?.projects.isEmpty == true) + } + + @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) + #expect(cached?.sessions.isEmpty == true) + } + + @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/CostUsageFetcherTests.swift b/Tests/CodexBarTests/CostUsageFetcherTests.swift index d58c63a378..5db8108693 100644 --- a/Tests/CodexBarTests/CostUsageFetcherTests.swift +++ b/Tests/CodexBarTests/CostUsageFetcherTests.swift @@ -2,6 +2,7 @@ import Foundation import Testing @testable import CodexBarCore +@Suite(.serialized) struct CostUsageFetcherTests { @Test func `fetcher scopes codex history to selected codex home`() async throws { @@ -17,23 +18,44 @@ struct CostUsageFetcherTests { filename: "ambient.jsonl", tokens: 100) try Self.writeCodexSessionFile(homeRoot: otherHome, env: env, day: day, filename: "managed.jsonl", tokens: 10) + _ = try env.writePiSessionFile( + relativePath: "2026-04-08T10-00-00-000Z_ambient.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": 50, "output": 5, "totalTokens": 55], + ], + ]])) let options = CostUsageScanner.Options(cacheRoot: env.cacheRoot) + let piOptions = PiSessionCostScanner.Options( + piSessionsRoot: env.piSessionsRoot, + cacheRoot: env.cacheRoot, + refreshMinIntervalSeconds: 0) let ambient = try await CostUsageFetcher.loadTokenSnapshot( provider: .codex, now: day, codexHomePath: env.codexHomeRoot.path, - scannerOptions: options) + scannerOptions: options, + piScannerOptions: piOptions) let managed = try await CostUsageFetcher.loadTokenSnapshot( provider: .codex, now: day, codexHomePath: otherHome.path, - scannerOptions: options) + scannerOptions: options, + piScannerOptions: piOptions) #expect(ambient.sessionTokens == 100) #expect(managed.sessionTokens == 10) } +} +extension CostUsageFetcherTests { @Test func `fetcher refreshes codex cache when legacy roots metadata is missing`() async throws { let env = try CostUsageTestEnvironment() @@ -555,6 +577,12 @@ struct CostUsageFetcherTests { now: day, scannerOptions: nativeOptions, piScannerOptions: piOptions) + let withoutPi = try await CostUsageFetcher.loadTokenSnapshot( + provider: .codex, + now: day, + includePiSessions: false, + scannerOptions: nativeOptions, + piScannerOptions: piOptions) let nativeCost = CostUsagePricing.codexCostUSD( model: "gpt-5.4", @@ -570,13 +598,13 @@ struct CostUsageFetcherTests { #expect(snapshot.daily.count == 1) #expect(snapshot.daily.first?.date == "2026-04-08") #expect(snapshot.daily.first?.totalTokens == 170) + #expect(withoutPi.daily.first?.totalTokens == 110) #expect(abs((snapshot.daily.first?.costUSD ?? 0) - (nativeCost + piCost)) < 0.000001) - #expect(snapshot.daily.first?.modelBreakdowns == [ - CostUsageDailyReport.ModelBreakdown( - modelName: "gpt-5.4", - costUSD: nativeCost + piCost, - totalTokens: 170), - ]) + let breakdown = try #require(snapshot.daily.first?.modelBreakdowns?.first) + #expect(breakdown.modelName == "gpt-5.4") + #expect(abs((breakdown.costUSD ?? 0) - (nativeCost + piCost)) < 0.000001) + #expect(breakdown.totalTokens == 170) + #expect(snapshot.sessions.isEmpty) } @Test @@ -737,16 +765,14 @@ struct CostUsageFetcherTests { cachedInputTokens: 20, outputTokens: 10) ?? 0 - #expect(snapshot.daily.first?.modelBreakdowns == [ - CostUsageDailyReport.ModelBreakdown( - modelName: "gpt-5.4", - costUSD: cost, - totalTokens: 110), - ]) + let breakdown = try #require(snapshot.daily.first?.modelBreakdowns?.first) + #expect(breakdown.modelName == "gpt-5.4") + #expect(abs((breakdown.costUSD ?? 0) - cost) < 0.000001) + #expect(breakdown.totalTokens == 110) } @Test - func `force refresh keeps incremental cost cache`() async throws { + func `app refresh bypasses scanner debounce without changing direct callers`() async throws { let env = try CostUsageTestEnvironment() defer { env.cleanup() } @@ -814,10 +840,17 @@ struct CostUsageFetcherTests { try env.jsonl([turnContext, firstTokenCount, appendedTokenCount]) .write(to: fileURL, atomically: true, encoding: .utf8) + let debounced = try await CostUsageFetcher.loadTokenSnapshot( + provider: .codex, + now: day, + scannerOptions: nativeOptions, + piScannerOptions: piOptions) + #expect(debounced.daily.first?.totalTokens == 110) + let refreshed = try await CostUsageFetcher.loadTokenSnapshot( provider: .codex, now: day, - forceRefresh: true, + bypassScannerDebounce: true, scannerOptions: nativeOptions, piScannerOptions: piOptions) @@ -865,3 +898,108 @@ struct CostUsageFetcherTests { ]).write(to: url, atomically: true, encoding: .utf8) } } + +extension CostUsageFetcherTests { + @Test + func `fetcher returns individual codex conversations for the selected history window`() async throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 4, day: 8) + let firstURL = try env.writeCodexSessionFile( + day: day, + filename: "first.jsonl", + contents: env.jsonl([ + [ + "type": "session_meta", + "timestamp": env.isoString(for: day), + "payload": ["session_id": "first-session"], + ], + [ + "type": "event_msg", + "timestamp": env.isoString(for: day.addingTimeInterval(1)), + "payload": [ + "type": "token_count", + "info": [ + "model": "openai/gpt-5.4", + "last_token_usage": [ + "input_tokens": 100, + "cached_input_tokens": 20, + "output_tokens": 10, + ], + ], + ], + ], + ])) + let secondURL = try env.writeCodexSessionFile( + day: day, + filename: "second.jsonl", + contents: env.jsonl([ + [ + "type": "session_meta", + "timestamp": env.isoString(for: day), + "payload": ["session_id": "second-session"], + ], + [ + "type": "event_msg", + "timestamp": env.isoString(for: day.addingTimeInterval(1)), + "payload": [ + "type": "token_count", + "info": [ + "model": "openai/gpt-5.4", + "last_token_usage": [ + "input_tokens": 40, + "cached_input_tokens": 5, + "output_tokens": 5, + ], + ], + ], + ], + ])) + try FileManager.default.setAttributes( + [.modificationDate: day.addingTimeInterval(10)], + ofItemAtPath: firstURL.path) + try FileManager.default.setAttributes( + [.modificationDate: day.addingTimeInterval(20)], + ofItemAtPath: secondURL.path) + + let options = CostUsageScanner.Options( + codexSessionsRoot: env.codexSessionsRoot, + claudeProjectsRoots: [env.claudeProjectsRoot], + cacheRoot: env.cacheRoot) + let piOptions = PiSessionCostScanner.Options( + piSessionsRoot: env.piSessionsRoot, + cacheRoot: env.cacheRoot, + refreshMinIntervalSeconds: 0) + let snapshot = try await CostUsageFetcher.loadTokenSnapshot( + provider: .codex, + now: day, + historyDays: 1, + allowPricingRefresh: false, + scannerOptions: options, + piScannerOptions: piOptions) + + #expect(snapshot.sessions.map(\.sessionID) == ["second-session", "first-session"]) + let first = try #require(snapshot.sessions.first(where: { $0.sessionID == "first-session" })) + #expect(first.inputTokens == 100) + #expect(first.cachedInputTokens == 20) + #expect(first.outputTokens == 10) + #expect(first.totalTokens == 110) + #expect(first.requestCount == nil) + #expect(first.modelBreakdowns.map(\.modelName) == ["gpt-5.4"]) + #expect(first.costUSD != nil) + + let cache = CostUsageCacheIO.load(provider: .codex, cacheRoot: env.cacheRoot) + let range = CostUsageScanner.CostUsageDayRange(since: day, until: day) + let unrelatedRoot = env.root.appendingPathComponent("unrelated/sessions", isDirectory: true) + let filtered = CostUsageScanner.buildCodexSessionBreakdownsFromCache( + cache: cache, + range: range, + modelsDevCacheRoot: env.cacheRoot, + sessionRoots: [unrelatedRoot]) + #expect(filtered.isEmpty) + let scopedCache = CostUsageScanner.codexCache(cache, scopedTo: [unrelatedRoot]) + #expect(scopedCache.files.isEmpty) + #expect(scopedCache.days.isEmpty) + } +} diff --git a/Tests/CodexBarTests/CostUsageFetcherUnknownModelPricingTests.swift b/Tests/CodexBarTests/CostUsageFetcherUnknownModelPricingTests.swift new file mode 100644 index 0000000000..4ea858f6c4 --- /dev/null +++ b/Tests/CodexBarTests/CostUsageFetcherUnknownModelPricingTests.swift @@ -0,0 +1,349 @@ +import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif +import Testing +@testable import CodexBarCore + +struct CostUsageFetcherUnknownModelPricingTests { + @Test + func `fetcher reprices an unknown model after an on demand catalog refresh`() async throws { + let fixture = try UnknownModelPricingFixture() + defer { fixture.environment.cleanup() } + + let snapshot = try await CostUsageFetcher.loadTokenSnapshot( + provider: .codex, + now: fixture.day, + refreshPricingInBackground: false, + scannerOptions: fixture.options, + modelsDevClient: ModelsDevClient(transport: CostUsageFetcherModelsDevTransport( + data: fixture.refreshedCatalog))) + + let breakdown = try #require(snapshot.daily.first?.modelBreakdowns?.first) + #expect(breakdown.modelName == "gpt-new") + #expect(abs((breakdown.costUSD ?? 0) - 0.00028) < 0.0000001) + } + + @Test + func `pricing retry preserves disabled pi session merging`() async throws { + let fixture = try UnknownModelPricingFixture() + defer { fixture.environment.cleanup() } + let piAssistant: [String: Any] = [ + "type": "message", + "timestamp": fixture.environment.isoString(for: fixture.day), + "message": [ + "role": "assistant", + "provider": "openai-codex", + "model": "openai/gpt-5.4", + "timestamp": Int(fixture.day.timeIntervalSince1970 * 1000), + "usage": ["input": 50, "output": 10, "totalTokens": 60], + ], + ] + _ = try fixture.environment.writePiSessionFile( + relativePath: "2026-04-12T12-00-00-000Z_retry.jsonl", + contents: fixture.environment.jsonl([piAssistant])) + let piOptions = PiSessionCostScanner.Options( + piSessionsRoot: fixture.environment.piSessionsRoot, + cacheRoot: fixture.environment.cacheRoot, + refreshMinIntervalSeconds: 0) + + let snapshot = try await CostUsageFetcher.loadTokenSnapshot( + provider: .codex, + now: fixture.day, + refreshPricingInBackground: false, + includePiSessions: false, + scannerOptions: fixture.options, + piScannerOptions: piOptions, + modelsDevClient: ModelsDevClient(transport: CostUsageFetcherModelsDevTransport( + data: fixture.refreshedCatalog))) + + #expect(snapshot.daily.first?.totalTokens == 110) + #expect(snapshot.daily.first?.modelBreakdowns?.map(\.modelName) == ["gpt-new"]) + } + + @Test + func `background pricing refresh returns unpriced usage before catalog download finishes`() async throws { + let fixture = try UnknownModelPricingFixture() + defer { fixture.environment.cleanup() } + let gate = UnknownModelPricingTransportGate() + let completion = UnknownModelPricingCompletionProbe() + let task = Task { + let snapshot = try await CostUsageFetcher.loadTokenSnapshot( + provider: .codex, + now: fixture.day, + refreshPricingInBackground: true, + scannerOptions: fixture.options, + modelsDevClient: ModelsDevClient(transport: CostUsageFetcherGatedModelsDevTransport( + data: fixture.refreshedCatalog, + gate: gate))) + await completion.markCompleted() + return snapshot + } + + await gate.waitUntilStarted() + let clock = ContinuousClock() + let deadline = clock.now.advanced(by: .seconds(1)) + while await !(completion.isCompleted), clock.now < deadline { + try await Task.sleep(for: .milliseconds(10)) + } + let returnedBeforeRelease = await completion.isCompleted + await gate.release() + let snapshot = try await task.value + + #expect(returnedBeforeRelease) + let breakdown = try #require(snapshot.daily.first?.modelBreakdowns?.first) + #expect(breakdown.modelName == "gpt-new") + #expect(breakdown.totalTokens == 110) + #expect(breakdown.costUSD == nil) + + let refreshDeadline = clock.now.advanced(by: .seconds(1)) + while ModelsDevPricingPipeline.lookup( + providerID: "openai", + modelID: "gpt-new", + cacheRoot: fixture.environment.cacheRoot) == nil, + clock.now < refreshDeadline + { + try await Task.sleep(for: .milliseconds(10)) + } + #expect(ModelsDevPricingPipeline.lookup( + providerID: "openai", + modelID: "gpt-new", + cacheRoot: fixture.environment.cacheRoot) != nil) + } + + @Test + func `unattributed codex usage does not request a pricing refresh`() async throws { + let environment = try CostUsageTestEnvironment() + defer { environment.cleanup() } + let day = try environment.makeLocalNoon(year: 2026, month: 4, day: 12) + let staleCatalog = try JSONDecoder().decode(ModelsDevCatalog.self, from: Data(""" + { + "openai": { + "id": "openai", + "models": { "known-test-model": { "id": "known-test-model", "cost": { "input": 1, "output": 4 } } } + } + } + """.utf8)) + ModelsDevCache.save( + catalog: staleCatalog, + fetchedAt: day.addingTimeInterval(-901), + cacheRoot: environment.cacheRoot) + let tokenCount: [String: Any] = [ + "type": "event_msg", + "timestamp": environment.isoString(for: day), + "payload": [ + "type": "token_count", + "info": [ + "last_token_usage": [ + "input_tokens": 100, + "cached_input_tokens": 20, + "output_tokens": 10, + ], + ], + ], + ] + _ = try environment.writeCodexSessionFile( + day: day, + filename: "unattributed-model.jsonl", + contents: environment.jsonl([tokenCount])) + let options = CostUsageScanner.Options( + codexSessionsRoot: environment.codexSessionsRoot, + claudeProjectsRoots: [environment.claudeProjectsRoot], + cacheRoot: environment.cacheRoot) + let counter = UnknownModelPricingRequestCounter() + + let snapshot = try await CostUsageFetcher.loadTokenSnapshot( + provider: .codex, + now: day, + refreshPricingInBackground: false, + scannerOptions: options, + modelsDevClient: ModelsDevClient(transport: CostUsageFetcherCountingModelsDevTransport(counter: counter))) + + let breakdown = try #require(snapshot.daily.first?.modelBreakdowns?.first) + let requestCount = await counter.requestCount + #expect(breakdown.modelName == CostUsagePricing.codexUnattributedModel) + #expect(breakdown.totalTokens == 110) + #expect(breakdown.costUSD == nil) + #expect(requestCount == 0) + } + + @Test + func `local only fetch skips every pricing network refresh`() async throws { + let fixture = try UnknownModelPricingFixture() + defer { fixture.environment.cleanup() } + let counter = UnknownModelPricingRequestCounter() + + let snapshot = try await CostUsageFetcher.loadTokenSnapshot( + provider: .codex, + now: fixture.day, + allowPricingRefresh: false, + refreshPricingInBackground: false, + scannerOptions: fixture.options, + modelsDevClient: ModelsDevClient( + transport: CostUsageFetcherCountingModelsDevTransport(counter: counter))) + + let breakdown = try #require(snapshot.daily.first?.modelBreakdowns?.first) + #expect(breakdown.modelName == "gpt-new") + #expect(breakdown.costUSD == nil) + #expect(await counter.requestCount == 0) + } +} + +private struct UnknownModelPricingFixture { + let environment: CostUsageTestEnvironment + let day: Date + let options: CostUsageScanner.Options + let refreshedCatalog: Data + + init() throws { + let environment = try CostUsageTestEnvironment() + self.environment = environment + self.day = try environment.makeLocalNoon(year: 2026, month: 4, day: 12) + let oldCatalog = try JSONDecoder().decode(ModelsDevCatalog.self, from: Data(""" + { + "openai": { + "id": "openai", + "models": { "gpt-old": { "id": "gpt-old", "cost": { "input": 1, "output": 4 } } } + }, + "anthropic": { + "id": "anthropic", + "models": { "claude-old": { "id": "claude-old", "cost": { "input": 3, "output": 15 } } } + } + } + """.utf8)) + ModelsDevCache.save( + catalog: oldCatalog, + fetchedAt: self.day.addingTimeInterval(-901), + cacheRoot: environment.cacheRoot) + + self.refreshedCatalog = Data(""" + { + "openai": { + "id": "openai", + "models": { "gpt-new": { "id": "gpt-new", "cost": { "input": 2, "output": 8 } } } + }, + "anthropic": { + "id": "anthropic", + "models": { "claude-new": { "id": "claude-new", "cost": { "input": 3, "output": 15 } } } + } + } + """.utf8) + let turnContext: [String: Any] = [ + "type": "turn_context", + "timestamp": environment.isoString(for: self.day), + "payload": ["model": "gpt-new"], + ] + let tokenCount: [String: Any] = [ + "type": "event_msg", + "timestamp": environment.isoString(for: self.day.addingTimeInterval(1)), + "payload": [ + "type": "token_count", + "info": [ + "total_token_usage": [ + "input_tokens": 100, + "cached_input_tokens": 20, + "output_tokens": 10, + ], + ], + ], + ] + _ = try environment.writeCodexSessionFile( + day: self.day, + filename: "unknown-model.jsonl", + contents: environment.jsonl([turnContext, tokenCount])) + self.options = CostUsageScanner.Options( + codexSessionsRoot: environment.codexSessionsRoot, + claudeProjectsRoots: [environment.claudeProjectsRoot], + cacheRoot: environment.cacheRoot) + } +} + +private struct CostUsageFetcherModelsDevTransport: ModelsDevHTTPTransport { + let data: Data + + func data(for request: URLRequest) async throws -> (Data, URLResponse) { + let response = HTTPURLResponse( + url: request.url!, + statusCode: 200, + httpVersion: nil, + headerFields: nil)! + return (self.data, response) + } +} + +private struct CostUsageFetcherGatedModelsDevTransport: ModelsDevHTTPTransport { + let data: Data + let gate: UnknownModelPricingTransportGate + + func data(for request: URLRequest) async throws -> (Data, URLResponse) { + await self.gate.markStartedAndWaitForRelease() + let response = HTTPURLResponse( + url: request.url!, + statusCode: 200, + httpVersion: nil, + headerFields: nil)! + return (self.data, response) + } +} + +private actor UnknownModelPricingTransportGate { + private var started = false + private var released = false + private var startWaiters: [CheckedContinuation] = [] + private var releaseWaiters: [CheckedContinuation] = [] + + func markStartedAndWaitForRelease() async { + self.started = true + let startWaiters = self.startWaiters + self.startWaiters.removeAll() + startWaiters.forEach { $0.resume() } + guard !self.released else { return } + await withCheckedContinuation { continuation in + self.releaseWaiters.append(continuation) + } + } + + func waitUntilStarted() async { + guard !self.started else { return } + await withCheckedContinuation { continuation in + self.startWaiters.append(continuation) + } + } + + func release() { + self.released = true + let releaseWaiters = self.releaseWaiters + self.releaseWaiters.removeAll() + releaseWaiters.forEach { $0.resume() } + } +} + +private actor UnknownModelPricingCompletionProbe { + private(set) var isCompleted = false + + func markCompleted() { + self.isCompleted = true + } +} + +private actor UnknownModelPricingRequestCounter { + private(set) var requestCount = 0 + + func recordRequest() { + self.requestCount += 1 + } +} + +private struct CostUsageFetcherCountingModelsDevTransport: ModelsDevHTTPTransport { + let counter: UnknownModelPricingRequestCounter + + func data(for request: URLRequest) async throws -> (Data, URLResponse) { + await self.counter.recordRequest() + let response = HTTPURLResponse( + url: request.url!, + statusCode: 200, + httpVersion: nil, + headerFields: nil)! + return (Data(#"{"openai":{"id":"openai","models":{}}}"#.utf8), response) + } +} diff --git a/Tests/CodexBarTests/CostUsageJsonlScannerTests.swift b/Tests/CodexBarTests/CostUsageJsonlScannerTests.swift index 0bf27a4cfa..926b623b02 100644 --- a/Tests/CodexBarTests/CostUsageJsonlScannerTests.swift +++ b/Tests/CodexBarTests/CostUsageJsonlScannerTests.swift @@ -58,6 +58,504 @@ struct CostUsageJsonlScannerTests { #expect(scanned[1].wasTruncated == true) } + @Test + func `jsonl scanner retries an incomplete final record after append`() throws { + let root = try self.makeTemporaryRoot() + defer { try? FileManager.default.removeItem(at: root) } + + let fileURL = root.appendingPathComponent("appending.jsonl", isDirectory: false) + let initial = #"{"type":"message","id":"partial"# + try initial.write(to: fileURL, atomically: true, encoding: .utf8) + + var firstPass: [String?] = [] + let resumeOffset = try CostUsageJsonl.scan( + fileURL: fileURL, + maxLineBytes: 1024, + prefixBytes: 1024) + { line in + firstPass.append(String(bytes: line.bytes, encoding: .utf8)) + } + + #expect(firstPass.isEmpty) + #expect(resumeOffset == 0) + + let completion = #""}"# + "\n" + let handle = try FileHandle(forWritingTo: fileURL) + defer { try? handle.close() } + try handle.seekToEnd() + try handle.write(contentsOf: Data(completion.utf8)) + + var secondPass: [String?] = [] + let endOffset = try CostUsageJsonl.scan( + fileURL: fileURL, + offset: resumeOffset, + maxLineBytes: 1024, + prefixBytes: 1024) + { line in + secondPass.append(String(bytes: line.bytes, encoding: .utf8)) + } + + #expect(secondPass == [initial + String(completion.dropLast())]) + #expect(endOffset == Int64(Data((initial + completion).utf8).count)) + } + + @Test + func `jsonl scanner accepts a complete final record without newline`() throws { + let root = try self.makeTemporaryRoot() + defer { try? FileManager.default.removeItem(at: root) } + + let fileURL = root.appendingPathComponent("final-record.jsonl", isDirectory: false) + let record = #"{"type":"message","id":"complete"}"# + try record.write(to: fileURL, atomically: true, encoding: .utf8) + + var scanned: [String?] = [] + let endOffset = try CostUsageJsonl.scan( + fileURL: fileURL, + maxLineBytes: 1024, + prefixBytes: 1024) + { line in + scanned.append(String(bytes: line.bytes, encoding: .utf8)) + } + + #expect(scanned == [record]) + #expect(endOffset == Int64(Data(record.utf8).count)) + } + + @Test + func `jsonl scanner preserves a truncated final record`() throws { + let root = try self.makeTemporaryRoot() + defer { try? FileManager.default.removeItem(at: root) } + + let fileURL = root.appendingPathComponent("truncated-final-record.jsonl", isDirectory: false) + let record = #"{"message":"\#(String(repeating: "x", count: 256))"}"# + try record.write(to: fileURL, atomically: true, encoding: .utf8) + + var scanned: [CostUsageJsonl.Line] = [] + let endOffset = try CostUsageJsonl.scan( + fileURL: fileURL, + maxLineBytes: 64, + prefixBytes: 64) + { line in + scanned.append(line) + } + + #expect(scanned.count == 1) + #expect(scanned[0].wasTruncated) + #expect(endOffset == Int64(Data(record.utf8).count)) + } + + @Test + func `jsonl scanner retries a truncated incomplete final record after append`() throws { + let root = try self.makeTemporaryRoot() + defer { try? FileManager.default.removeItem(at: root) } + + let fileURL = root.appendingPathComponent("truncated-appending.jsonl", isDirectory: false) + let initial = #"{"message":"\#(String(repeating: "x", count: 256))"# + try initial.write(to: fileURL, atomically: true, encoding: .utf8) + + var firstPass: [CostUsageJsonl.Line] = [] + let resumeOffset = try CostUsageJsonl.scan( + fileURL: fileURL, + maxLineBytes: 64, + prefixBytes: 64) + { line in + firstPass.append(line) + } + + #expect(firstPass.isEmpty) + #expect(resumeOffset == 0) + + let completion = #""}"# + "\n" + let handle = try FileHandle(forWritingTo: fileURL) + defer { try? handle.close() } + try handle.seekToEnd() + try handle.write(contentsOf: Data(completion.utf8)) + + var secondPass: [CostUsageJsonl.Line] = [] + let endOffset = try CostUsageJsonl.scan( + fileURL: fileURL, + offset: resumeOffset, + maxLineBytes: 64, + prefixBytes: 64) + { line in + secondPass.append(line) + } + + #expect(secondPass.count == 1) + #expect(secondPass[0].wasTruncated) + #expect(endOffset == Int64(Data((initial + completion).utf8).count)) + } + + @Test + func `jsonl scanner retries a truncated escape sequence`() throws { + let root = try self.makeTemporaryRoot() + defer { try? FileManager.default.removeItem(at: root) } + + let fileURL = root.appendingPathComponent("truncated-escape.jsonl", isDirectory: false) + let initial = #"{"message":""# + String(repeating: "x", count: 256) + #"\u12"# + try initial.write(to: fileURL, atomically: true, encoding: .utf8) + + var firstPass: [CostUsageJsonl.Line] = [] + let resumeOffset = try CostUsageJsonl.scan( + fileURL: fileURL, + maxLineBytes: 64, + prefixBytes: 64) + { line in + firstPass.append(line) + } + + #expect(firstPass.isEmpty) + #expect(resumeOffset == 0) + + let completion = #"34"}"# + "\n" + let handle = try FileHandle(forWritingTo: fileURL) + defer { try? handle.close() } + try handle.seekToEnd() + try handle.write(contentsOf: Data(completion.utf8)) + + var secondPass: [CostUsageJsonl.Line] = [] + let endOffset = try CostUsageJsonl.scan( + fileURL: fileURL, + offset: resumeOffset, + maxLineBytes: 64, + prefixBytes: 64) + { line in + secondPass.append(line) + } + + #expect(secondPass.count == 1) + #expect(secondPass[0].wasTruncated) + #expect(endOffset == Int64(Data((initial + completion).utf8).count)) + } + + @Test + func `jsonl scanner ignores nested delimiters inside strings`() throws { + let root = try self.makeTemporaryRoot() + defer { try? FileManager.default.removeItem(at: root) } + + let fileURL = root.appendingPathComponent("string-delimiters.jsonl", isDirectory: false) + let message = String(repeating: "{[", count: 64) + #""nested""# + let recordData = try JSONEncoder().encode(["message": message]) + try recordData.write(to: fileURL) + + var scanned: [CostUsageJsonl.Line] = [] + let endOffset = try CostUsageJsonl.scan( + fileURL: fileURL, + maxLineBytes: 64, + prefixBytes: 64) + { line in + scanned.append(line) + } + + #expect(scanned.count == 1) + #expect(scanned[0].wasTruncated) + #expect(scanned[0].bytes.count == 64) + #expect(endOffset == Int64(recordData.count)) + } + + @Test + func `jsonl scanner commits only complete CRLF records`() throws { + let root = try self.makeTemporaryRoot() + defer { try? FileManager.default.removeItem(at: root) } + + let fileURL = root.appendingPathComponent("crlf.jsonl", isDirectory: false) + let firstRecord = #"{"id":1}"# + let partialRecord = #"{"id":"par"# + let initial = firstRecord + "\r\n" + partialRecord + try initial.write(to: fileURL, atomically: true, encoding: .utf8) + + var firstPass: [String?] = [] + let resumeOffset = try CostUsageJsonl.scan( + fileURL: fileURL, + maxLineBytes: 1024, + prefixBytes: 1024) + { line in + firstPass.append(String(bytes: line.bytes, encoding: .utf8)) + } + + #expect(firstPass == [firstRecord + "\r"]) + #expect(resumeOffset == Int64(Data((firstRecord + "\r\n").utf8).count)) + + let completion = #"tial"}"# + "\r\n" + let handle = try FileHandle(forWritingTo: fileURL) + defer { try? handle.close() } + try handle.seekToEnd() + try handle.write(contentsOf: Data(completion.utf8)) + + var secondPass: [String?] = [] + let endOffset = try CostUsageJsonl.scan( + fileURL: fileURL, + offset: resumeOffset, + maxLineBytes: 1024, + prefixBytes: 1024) + { line in + secondPass.append(String(bytes: line.bytes, encoding: .utf8)) + } + + let completedRecord = partialRecord + #"tial"}"# + "\r" + #expect(secondPass == [completedRecord]) + #expect(endOffset == Int64(Data((initial + completion).utf8).count)) + } + + @Test + func `jsonl scanner tracks an incomplete record across read chunks`() throws { + let root = try self.makeTemporaryRoot() + defer { try? FileManager.default.removeItem(at: root) } + + let fileURL = root.appendingPathComponent("multi-chunk-tail.jsonl", isDirectory: false) + let initial = #"{"message":""# + String(repeating: "x", count: 300_000) + try initial.write(to: fileURL, atomically: true, encoding: .utf8) + + var firstPass: [CostUsageJsonl.Line] = [] + let resumeOffset = try CostUsageJsonl.scan( + fileURL: fileURL, + maxLineBytes: 64, + prefixBytes: 64) + { line in + firstPass.append(line) + } + + #expect(firstPass.isEmpty) + #expect(resumeOffset == 0) + + let completion = #""}"# + "\n" + let handle = try FileHandle(forWritingTo: fileURL) + defer { try? handle.close() } + try handle.seekToEnd() + try handle.write(contentsOf: Data(completion.utf8)) + + var secondPass: [CostUsageJsonl.Line] = [] + let endOffset = try CostUsageJsonl.scan( + fileURL: fileURL, + offset: resumeOffset, + maxLineBytes: 64, + prefixBytes: 64) + { line in + secondPass.append(line) + } + + #expect(secondPass.count == 1) + #expect(secondPass[0].wasTruncated) + #expect(secondPass[0].bytes.count == 64) + #expect(endOffset == Int64(Data((initial + completion).utf8).count)) + } + + @Test + func `jsonl scanner retries truncated literal prefixes`() throws { + let root = try self.makeTemporaryRoot() + defer { try? FileManager.default.removeItem(at: root) } + + let cases = [("tru", "e"), ("fals", "e"), ("nul", "l")] + for (index, testCase) in cases.enumerated() { + let fileURL = root.appendingPathComponent("literal-\(index).jsonl", isDirectory: false) + let initial = String(repeating: " ", count: 128) + testCase.0 + try initial.write(to: fileURL, atomically: true, encoding: .utf8) + + var firstPass: [CostUsageJsonl.Line] = [] + let resumeOffset = try CostUsageJsonl.scan( + fileURL: fileURL, + maxLineBytes: 64, + prefixBytes: 64) + { line in + firstPass.append(line) + } + + #expect(firstPass.isEmpty) + #expect(resumeOffset == 0) + + let completion = testCase.1 + "\n" + let handle = try FileHandle(forWritingTo: fileURL) + try handle.seekToEnd() + try handle.write(contentsOf: Data(completion.utf8)) + try handle.close() + + var secondPass: [CostUsageJsonl.Line] = [] + let endOffset = try CostUsageJsonl.scan( + fileURL: fileURL, + offset: resumeOffset, + maxLineBytes: 64, + prefixBytes: 64) + { line in + secondPass.append(line) + } + + #expect(secondPass.count == 1) + #expect(secondPass[0].wasTruncated) + #expect(endOffset == Int64(Data((initial + completion).utf8).count)) + } + } + + @Test + func `jsonl scanner retries a truncated number exponent`() throws { + let root = try self.makeTemporaryRoot() + defer { try? FileManager.default.removeItem(at: root) } + + let fileURL = root.appendingPathComponent("number-exponent.jsonl", isDirectory: false) + let initial = String(repeating: "9", count: 300_000) + "e-" + try initial.write(to: fileURL, atomically: true, encoding: .utf8) + + var firstPass: [CostUsageJsonl.Line] = [] + let resumeOffset = try CostUsageJsonl.scan( + fileURL: fileURL, + maxLineBytes: 64, + prefixBytes: 64) + { line in + firstPass.append(line) + } + + #expect(firstPass.isEmpty) + #expect(resumeOffset == 0) + + let completion = "2\n" + let handle = try FileHandle(forWritingTo: fileURL) + defer { try? handle.close() } + try handle.seekToEnd() + try handle.write(contentsOf: Data(completion.utf8)) + + var secondPass: [CostUsageJsonl.Line] = [] + let endOffset = try CostUsageJsonl.scan( + fileURL: fileURL, + offset: resumeOffset, + maxLineBytes: 64, + prefixBytes: 64) + { line in + secondPass.append(line) + } + + #expect(secondPass.count == 1) + #expect(secondPass[0].wasTruncated) + #expect(endOffset == Int64(Data((initial + completion).utf8).count)) + } + + @Test + func `jsonl scanner retries a complete numeric prefix`() throws { + let root = try self.makeTemporaryRoot() + defer { try? FileManager.default.removeItem(at: root) } + + let fileURL = root.appendingPathComponent("numeric-prefix.jsonl", isDirectory: false) + let initial = "1" + try initial.write(to: fileURL, atomically: true, encoding: .utf8) + + var firstPass: [String?] = [] + let resumeOffset = try CostUsageJsonl.scan( + fileURL: fileURL, + maxLineBytes: 64, + prefixBytes: 64) + { line in + firstPass.append(String(bytes: line.bytes, encoding: .utf8)) + } + + #expect(firstPass.isEmpty) + #expect(resumeOffset == 0) + + let completion = "2\n" + let handle = try FileHandle(forWritingTo: fileURL) + defer { try? handle.close() } + try handle.seekToEnd() + try handle.write(contentsOf: Data(completion.utf8)) + + var secondPass: [String?] = [] + let endOffset = try CostUsageJsonl.scan( + fileURL: fileURL, + offset: resumeOffset, + maxLineBytes: 64, + prefixBytes: 64) + { line in + secondPass.append(String(bytes: line.bytes, encoding: .utf8)) + } + + #expect(secondPass == ["12"]) + #expect(endOffset == Int64(Data((initial + completion).utf8).count)) + } + + @Test + func `jsonl scanner retries a truncated complete numeric prefix`() throws { + let root = try self.makeTemporaryRoot() + defer { try? FileManager.default.removeItem(at: root) } + + let fileURL = root.appendingPathComponent("truncated-numeric-prefix.jsonl", isDirectory: false) + let initial = String(repeating: " ", count: 128) + "1" + try initial.write(to: fileURL, atomically: true, encoding: .utf8) + + var firstPass: [CostUsageJsonl.Line] = [] + let resumeOffset = try CostUsageJsonl.scan( + fileURL: fileURL, + maxLineBytes: 64, + prefixBytes: 64) + { line in + firstPass.append(line) + } + + #expect(firstPass.isEmpty) + #expect(resumeOffset == 0) + + let completion = "2\n" + let handle = try FileHandle(forWritingTo: fileURL) + defer { try? handle.close() } + try handle.seekToEnd() + try handle.write(contentsOf: Data(completion.utf8)) + + var secondPass: [CostUsageJsonl.Line] = [] + let endOffset = try CostUsageJsonl.scan( + fileURL: fileURL, + offset: resumeOffset, + maxLineBytes: 64, + prefixBytes: 64) + { line in + secondPass.append(line) + } + + #expect(secondPass.count == 1) + #expect(secondPass[0].wasTruncated) + #expect(endOffset == Int64(Data((initial + completion).utf8).count)) + } + + @Test + func `jsonl scanner accepts a number terminated by trailing whitespace`() throws { + let root = try self.makeTemporaryRoot() + defer { try? FileManager.default.removeItem(at: root) } + + let fileURL = root.appendingPathComponent("terminated-number.jsonl", isDirectory: false) + let record = "12 " + try record.write(to: fileURL, atomically: true, encoding: .utf8) + + var scanned: [String?] = [] + let endOffset = try CostUsageJsonl.scan( + fileURL: fileURL, + maxLineBytes: 64, + prefixBytes: 64) + { line in + scanned.append(String(bytes: line.bytes, encoding: .utf8)) + } + + #expect(scanned == [record]) + #expect(endOffset == Int64(Data(record.utf8).count)) + } + + @Test + func `jsonl scanner commits complete EOF record larger than retained prefix`() throws { + let root = try self.makeTemporaryRoot() + defer { try? FileManager.default.removeItem(at: root) } + + let fileURL = root.appendingPathComponent("short-prefix.jsonl", isDirectory: false) + let record = #"{"message":"\#(String(repeating: "x", count: 128))"}"# + try record.write(to: fileURL, atomically: true, encoding: .utf8) + + var scanned: [CostUsageJsonl.Line] = [] + let endOffset = try CostUsageJsonl.scan( + fileURL: fileURL, + maxLineBytes: 1024, + prefixBytes: 64) + { line in + scanned.append(line) + } + + #expect(scanned.count == 1) + #expect(scanned[0].wasTruncated) + #expect(scanned[0].bytes.count == 64) + #expect(endOffset == Int64(Data(record.utf8).count)) + } + private func makeTemporaryRoot() throws -> URL { let root = FileManager.default.temporaryDirectory.appendingPathComponent( "codexbar-cost-usage-jsonl-\(UUID().uuidString)", diff --git a/Tests/CodexBarTests/CostUsagePerformanceGateTests.swift b/Tests/CodexBarTests/CostUsagePerformanceGateTests.swift new file mode 100644 index 0000000000..c202d20157 --- /dev/null +++ b/Tests/CodexBarTests/CostUsagePerformanceGateTests.swift @@ -0,0 +1,718 @@ +import Foundation +#if canImport(SQLite3) +import SQLite3 +import Testing +@testable import CodexBarCore + +/// Regression gates for the two cost-usage scan-storm classes that have shipped before: +/// re-parsing unchanged session files on every refresh (#1387, #1392) and re-running the +/// full trace-database scan on every refresh (#1392, the pre-memo priority-turns path). +@Suite(.serialized) +struct CostUsagePerformanceGateTests { + @Test + func `warm codex refresh over an unchanged session corpus must not re-parse it`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + let day = try env.makeLocalNoon(year: 2026, month: 5, day: 10) + let fileURLs = try Self.writeSyntheticCodexCorpus(env: env, day: day, files: 2, turnsPerFile: 4) + + var options = CostUsageScanner.Options( + codexSessionsRoot: env.codexSessionsRoot, + claudeProjectsRoots: nil, + cacheRoot: env.cacheRoot, + codexTraceDatabaseURL: env.root.appendingPathComponent("missing.sqlite")) + options.refreshMinIntervalSeconds = 0 + + let cold = CostUsageScanner.loadDailyReport( + provider: .codex, + since: day, + until: day, + now: day, + options: options) + + let changedFile = try #require(fileURLs.first) + let originalAttributes = try FileManager.default.attributesOfItem(atPath: changedFile.path) + let originalModificationDate = try #require(originalAttributes[.modificationDate] as? Date) + let original = try String(contentsOf: changedFile, encoding: .utf8) + let modified = original.replacingOccurrences( + of: #""input_tokens":100,"#, + with: #""input_tokens":900,"#) + #expect(modified != original) + #expect(modified.utf8.count == original.utf8.count) + try modified.write(to: changedFile, atomically: false, encoding: .utf8) + try FileManager.default.setAttributes( + [.modificationDate: originalModificationDate], + ofItemAtPath: changedFile.path) + + let warm = CostUsageScanner.loadDailyReport( + provider: .codex, + since: day, + until: day, + now: day, + options: options) + + #expect(cold.data.count == 1) + #expect(warm.data.first?.totalTokens == cold.data.first?.totalTokens) + } + + @Test + func `priority turns refresh must scan only appended trace rows`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + let dbURL = env.root.appendingPathComponent("logs_2.sqlite") + try CostUsageScannerCodexPriorityTests.createTestLogsDatabase(at: dbURL) + + let epoch: Int64 = 1_760_000_000 + var rows: [(epochSeconds: Int64, body: String)] = (0..<50).map { index in + (epochSeconds: epoch, body: "thread_id=t-\(index) turn.id=u-\(index) routine trace row") + } + rows.append(( + epochSeconds: epoch, + body: "thread_id=thread-a turn.id=turn-a websocket request: " + + #"{"type":"response.create","model":"gpt-5.5","service_tier":"priority"}"#)) + try CostUsageScannerCodexPriorityTests.insertTestLogs(dbURL: dbURL, rows: rows) + + let full = CostUsageScanner.codexPriorityTurns(databaseURL: dbURL) + #expect(full.keys.sorted() == ["turn-a"]) + let scanned = try #require(CostUsageScanner._test_codexPriorityTurnsMemoState(forPath: dbURL.path)) + + try Self.replaceTraceBody( + dbURL: dbURL, + rowID: 1, + body: "thread_id=mutated turn.id=mutated-old websocket request: " + + #"{"type":"response.create","model":"gpt-5.5","service_tier":"priority"}"#) + try CostUsageScannerCodexPriorityTests.insertTestLogs(dbURL: dbURL, rows: [( + epochSeconds: epoch, + body: "thread_id=thread-b turn.id=turn-b websocket request: " + + #"{"type":"response.create","model":"gpt-5.5","service_tier":"priority"}"#)]) + + let refreshed = CostUsageScanner.codexPriorityTurns(databaseURL: dbURL) + + #expect(refreshed.keys.sorted() == ["turn-a", "turn-b"]) + let advanced = try #require(CostUsageScanner._test_codexPriorityTurnsMemoState(forPath: dbURL.path)) + #expect(advanced.lastRowID == scanned.lastRowID + 1) + } + + @Test + func `cached daily report resolves and uses the pricing catalog once`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + let day = try env.makeLocalNoon(year: 2026, month: 5, day: 10) + let model = "perf-custom-model" + _ = try Self.writeSyntheticCodexCorpus( + env: env, + day: day, + files: 3, + turnsPerFile: 4, + model: model) + + var options = CostUsageScanner.Options( + codexSessionsRoot: env.codexSessionsRoot, + claudeProjectsRoots: nil, + cacheRoot: env.cacheRoot, + codexTraceDatabaseURL: env.root.appendingPathComponent("missing.sqlite")) + options.refreshMinIntervalSeconds = 0 + _ = CostUsageScanner.loadDailyReport( + provider: .codex, + since: day, + until: day, + now: day, + options: options) + + let catalogJSON = """ + { + "openai": { + "id": "openai", + "models": { + "\(model)": { + "id": "\(model)", + "cost": { "input": 10, "output": 50, "cache_read": 1 } + } + } + } + } + """ + let catalog = try JSONDecoder().decode(ModelsDevCatalog.self, from: Data(catalogJSON.utf8)) + let cache = CostUsageCacheIO.load(provider: .codex, cacheRoot: env.cacheRoot) + let cachedUsage = try #require(cache.files.values.first { !($0.codexRows?.isEmpty ?? true) }) + let range = CostUsageScanner.CostUsageDayRange(since: day, until: day) + #expect(!CostUsageScanner.needsCodexCostCache(cachedUsage, range: range)) + var catalogLoadCount = 0 + let report = CostUsageScanner.buildCodexReportFromCache( + cache: cache, + range: range, + modelsDevCacheRoot: env.cacheRoot, + modelsDevCatalogLoader: { _ in + catalogLoadCount += 1 + return catalog + }) + + #expect(report.summary?.totalCostUSD != nil) + #expect(catalogLoadCount == 1) + } + + @Test + func `cached daily report uses complete aggregates without loading pricing`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + let day = try env.makeLocalNoon(year: 2026, month: 5, day: 10) + _ = try Self.writeSyntheticCodexCorpus(env: env, day: day, files: 3, turnsPerFile: 4) + + var options = CostUsageScanner.Options( + codexSessionsRoot: env.codexSessionsRoot, + claudeProjectsRoots: nil, + cacheRoot: env.cacheRoot, + codexTraceDatabaseURL: env.root.appendingPathComponent("missing.sqlite")) + options.refreshMinIntervalSeconds = 0 + let scanned = CostUsageScanner.loadDailyReport( + provider: .codex, + since: day, + until: day, + now: day, + options: options) + + let cache = CostUsageCacheIO.load(provider: .codex, cacheRoot: env.cacheRoot) + var catalogLoadCount = 0 + let cached = CostUsageScanner.buildCodexReportFromCache( + cache: cache, + range: CostUsageScanner.CostUsageDayRange(since: day, until: day), + modelsDevCacheRoot: env.cacheRoot, + modelsDevCatalogLoader: { _ in + catalogLoadCount += 1 + return ModelsDevCatalog(providers: [:]) + }) + + #expect(cached.data.map(\.totalTokens) == scanned.data.map(\.totalTokens)) + #expect(cached.summary?.totalTokens == scanned.summary?.totalTokens) + #expect(abs((cached.summary?.totalCostUSD ?? 0) - (scanned.summary?.totalCostUSD ?? 0)) < 0.000000001) + #expect(catalogLoadCount == 0) + } + + @Test + func `legacy missing aggregate cost backfills rows before threshold pricing`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + let day = try env.makeLocalNoon(year: 2026, month: 5, day: 10) + _ = try Self.writeSyntheticCodexCorpus( + env: env, + day: day, + files: 2, + turnsPerFile: 1, + model: "openai/gpt-5.5", + inputTokensPerTurn: 200_000) + + var options = CostUsageScanner.Options( + codexSessionsRoot: env.codexSessionsRoot, + claudeProjectsRoots: nil, + cacheRoot: env.cacheRoot, + codexTraceDatabaseURL: env.root.appendingPathComponent("missing.sqlite")) + options.refreshMinIntervalSeconds = 0 + let scanned = CostUsageScanner.loadDailyReport( + provider: .codex, + since: day, + until: day, + now: day, + options: options) + + var legacy = CostUsageCacheIO.load(provider: .codex, cacheRoot: env.cacheRoot) + for path in legacy.files.keys { + legacy.files[path]?.codexCostCacheComplete = nil + legacy.files[path]?.codexCostNanos = nil + legacy.files[path]?.codexStandardCostNanos = nil + legacy.files[path]?.codexPriorityCostNanos = nil + } + let range = CostUsageScanner.CostUsageDayRange(since: day, until: day) + #expect(legacy.files.values.allSatisfy { CostUsageScanner.needsCodexCostCache($0, range: range) }) + + let backfilled = CostUsageScanner.buildCodexReportFromCache(cache: legacy, range: range) + + #expect(abs((backfilled.summary?.totalCostUSD ?? 0) - (scanned.summary?.totalCostUSD ?? 0)) < 0.000000001) + + var mixed = CostUsageCacheIO.load(provider: .codex, cacheRoot: env.cacheRoot) + let mixedPaths = mixed.files.keys.sorted() + let legacyPath = try #require(mixedPaths.first) + let rowlessPath = try #require(mixedPaths.last) + #expect(legacyPath != rowlessPath) + mixed.files[legacyPath]?.codexCostCacheComplete = nil + mixed.files[legacyPath]?.codexCostNanos = nil + mixed.files[legacyPath]?.codexStandardCostNanos = nil + mixed.files[legacyPath]?.codexPriorityCostNanos = nil + mixed.files[rowlessPath]?.codexRows = nil + + let mixedBackfilled = CostUsageScanner.buildCodexReportFromCache(cache: mixed, range: range) + #expect(abs((mixedBackfilled.summary?.totalCostUSD ?? 0) - (scanned.summary?.totalCostUSD ?? 0)) < 0.000000001) + + let aggregateCost = CostUsagePricing.codexCostUSD( + model: "gpt-5.5", + inputTokens: 400_000, + cachedInputTokens: 0, + outputTokens: 20) + #expect(abs((backfilled.summary?.totalCostUSD ?? 0) - (aggregateCost ?? 0)) > 0.1) + } + + @Test + func `project rollups resolve the pricing catalog once per build`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + let day = try env.makeLocalNoon(year: 2026, month: 5, day: 10) + _ = try Self.writeSyntheticCodexCorpus(env: env, day: day, files: 3, turnsPerFile: 4) + + var options = CostUsageScanner.Options( + codexSessionsRoot: env.codexSessionsRoot, + claudeProjectsRoots: nil, + cacheRoot: env.cacheRoot, + codexTraceDatabaseURL: env.root.appendingPathComponent("missing.sqlite")) + options.refreshMinIntervalSeconds = 0 + _ = CostUsageScanner.loadDailyReport( + provider: .codex, + since: day, + until: day, + now: day, + options: options) + + let cache = CostUsageCacheIO.load(provider: .codex, cacheRoot: env.cacheRoot) + var catalogLoadCount = 0 + let projects = CostUsageScanner.buildCodexProjectBreakdownsFromCache( + cache: cache, + range: CostUsageScanner.CostUsageDayRange(since: day, until: day), + modelsDevCacheRoot: env.cacheRoot, + modelsDevCatalogLoader: { _ in + catalogLoadCount += 1 + return ModelsDevCatalog(providers: [:]) + }) + + #expect(!projects.isEmpty) + #expect(catalogLoadCount == 1) + } + + @Test + func `oversized codex session is fully accounted across bounded refreshes`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + let day = try env.makeLocalNoon(year: 2026, month: 5, day: 10) + let files = try Self.writeSyntheticCodexCorpus(env: env, day: day, files: 1, turnsPerFile: 8) + let oversizedURL = try #require(files.first) + let metadata = CostUsageScanner.codexFileMetadata(fileURL: oversizedURL) + + var baselineOptions = CostUsageScanner.Options( + codexSessionsRoot: env.codexSessionsRoot, + claudeProjectsRoots: nil, + cacheRoot: env.root.appendingPathComponent("baseline-cache"), + codexTraceDatabaseURL: env.root.appendingPathComponent("missing.sqlite"), + maxCodexSessionFileBytes: 0, + maxCodexScanBytesPerRefresh: 0) + baselineOptions.refreshMinIntervalSeconds = 0 + let baseline = CostUsageScanner.loadDailyReport( + provider: .codex, + since: day, + until: day, + now: day, + options: baselineOptions) + + var options = CostUsageScanner.Options( + codexSessionsRoot: env.codexSessionsRoot, + claudeProjectsRoots: nil, + cacheRoot: env.cacheRoot, + codexTraceDatabaseURL: env.root.appendingPathComponent("missing.sqlite"), + maxCodexSessionFileBytes: max(1, metadata.size / 4), + maxCodexScanBytesPerRefresh: max(1, metadata.size / 4)) + options.refreshMinIntervalSeconds = 0 + + var offsets: [Int64] = [] + var report: CostUsageDailyReport? + for _ in 0..<12 { + report = CostUsageScanner.loadDailyReport( + provider: .codex, + since: day, + until: day, + now: day, + options: options) + let cached = try #require(CostUsageCacheIO.load( + provider: .codex, + cacheRoot: env.cacheRoot).files.values.first) + offsets.append(cached.parsedBytes ?? 0) + if cached.codexScanComplete == true { + break + } + } + + #expect(offsets.count > 1) + #expect(zip(offsets, offsets.dropFirst()).allSatisfy { $0 <= $1 }) + #expect(offsets.last == metadata.size) + #expect(report?.summary?.totalTokens == baseline.summary?.totalTokens) + #expect(report?.data.map(\.totalTokens) == baseline.data.map(\.totalTokens)) + } + + @Test + func `oversized codex progress survives cache round trip`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + let day = try env.makeLocalNoon(year: 2026, month: 5, day: 10) + let files = try Self.writeSyntheticCodexCorpus(env: env, day: day, files: 1, turnsPerFile: 8) + let fileURL = try #require(files.first) + let metadata = CostUsageScanner.codexFileMetadata(fileURL: fileURL) + let slice = max(1, metadata.size / 4) + var options = CostUsageScanner.Options( + codexSessionsRoot: env.codexSessionsRoot, + claudeProjectsRoots: nil, + cacheRoot: env.cacheRoot, + codexTraceDatabaseURL: env.root.appendingPathComponent("missing.sqlite"), + maxCodexSessionFileBytes: slice, + maxCodexScanBytesPerRefresh: slice) + options.refreshMinIntervalSeconds = 0 + + _ = CostUsageScanner.loadDailyReport( + provider: .codex, + since: day, + until: day, + now: day, + options: options) + let cacheData = try Data(contentsOf: CostUsageCacheIO.cacheFileURL(provider: .codex, cacheRoot: env.cacheRoot)) + let roundTripped = try JSONDecoder().decode(CostUsageCache.self, from: cacheData) + let first = try #require(roundTripped.files.values.first) + let firstOffset = try #require(first.parsedBytes) + #expect(first.codexScanFileId == metadata.fileId) + #expect(first.codexScanTargetSize == metadata.size) + #expect(first.codexScanComplete == false) + + _ = CostUsageScanner.loadDailyReport( + provider: .codex, + since: day, + until: day, + now: day, + options: options) + let second = try #require(CostUsageCacheIO.load( + provider: .codex, + cacheRoot: env.cacheRoot).files.values.first) + #expect((second.parsedBytes ?? 0) > firstOffset) + #expect(second.codexScanFileId == metadata.fileId) + #expect(second.codexScanTargetSize == metadata.size) + } + + @Test + func `oversized codex progress restarts when the target size changes`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + let day = try env.makeLocalNoon(year: 2026, month: 5, day: 10) + let files = try Self.writeSyntheticCodexCorpus(env: env, day: day, files: 1, turnsPerFile: 8) + let fileURL = try #require(files.first) + let originalMetadata = CostUsageScanner.codexFileMetadata(fileURL: fileURL) + let slice = max(1, originalMetadata.size / 4) + var options = CostUsageScanner.Options( + codexSessionsRoot: env.codexSessionsRoot, + claudeProjectsRoots: nil, + cacheRoot: env.cacheRoot, + codexTraceDatabaseURL: env.root.appendingPathComponent("missing.sqlite"), + maxCodexSessionFileBytes: slice, + maxCodexScanBytesPerRefresh: slice) + options.refreshMinIntervalSeconds = 0 + + _ = CostUsageScanner.loadDailyReport( + provider: .codex, + since: day, + until: day, + now: day, + options: options) + let first = try #require(CostUsageCacheIO.load( + provider: .codex, + cacheRoot: env.cacheRoot).files.values.first) + #expect(first.parsedBytes == slice) + #expect(first.codexScanComplete == false) + + let original = try String(contentsOf: fileURL, encoding: .utf8) + try (original + String(repeating: " ", count: 512)).write(to: fileURL, atomically: false, encoding: .utf8) + try FileManager.default.setAttributes( + [.modificationDate: day.addingTimeInterval(60)], + ofItemAtPath: fileURL.path) + let changedMetadata = CostUsageScanner.codexFileMetadata(fileURL: fileURL) + #expect(changedMetadata.size != originalMetadata.size) + + _ = CostUsageScanner.loadDailyReport( + provider: .codex, + since: day, + until: day, + now: day, + options: options) + let restarted = try #require(CostUsageCacheIO.load( + provider: .codex, + cacheRoot: env.cacheRoot).files.values.first) + #expect(restarted.parsedBytes == slice) + #expect(restarted.codexScanTargetSize == changedMetadata.size) + #expect(restarted.codexScanFileId == changedMetadata.fileId) + #expect(restarted.codexScanComplete == false) + } + + @Test + func `single oversized jsonl record resumes without stalling`() 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 model = "openai/gpt-5.2-codex" + let contents = [ + #"{"type":"session_meta","timestamp":"\#(iso)","payload":{"session_id":"long-record"}}"#, + #"{"type":"turn_context","timestamp":"\#(iso)","payload":{"model":"\#(model)"}}"#, + #"{"type":"event_msg","timestamp":"\#(iso)","payload":{"type":"token_count","padding":""# + + String(repeating: "x", count: 4096) + + + #"","info":{"total_token_usage":{"input_tokens":500,"cached_input_tokens":50,"# + + #""output_tokens":25},"model":"\#(model)"}}}"#, + ].joined(separator: "\n") + "\n" + _ = try env.writeCodexSessionFile(day: day, filename: "long-record.jsonl", contents: contents) + + var baselineOptions = CostUsageScanner.Options( + codexSessionsRoot: env.codexSessionsRoot, + claudeProjectsRoots: nil, + cacheRoot: env.root.appendingPathComponent("baseline-cache"), + codexTraceDatabaseURL: env.root.appendingPathComponent("missing.sqlite"), + maxCodexSessionFileBytes: 0, + maxCodexScanBytesPerRefresh: 0) + baselineOptions.refreshMinIntervalSeconds = 0 + let baseline = CostUsageScanner.loadDailyReport( + provider: .codex, + since: day, + until: day, + now: day, + options: baselineOptions) + + var options = CostUsageScanner.Options( + codexSessionsRoot: env.codexSessionsRoot, + claudeProjectsRoots: nil, + cacheRoot: env.cacheRoot, + codexTraceDatabaseURL: env.root.appendingPathComponent("missing.sqlite"), + maxCodexSessionFileBytes: 256, + maxCodexScanBytesPerRefresh: 256) + options.refreshMinIntervalSeconds = 0 + + var offsets: [Int64] = [] + var sawPartialRecord = false + var report: CostUsageDailyReport? + for _ in 0..<24 { + report = CostUsageScanner.loadDailyReport( + provider: .codex, + since: day, + until: day, + now: day, + options: options) + let cached = try #require(CostUsageCacheIO.load( + provider: .codex, + cacheRoot: env.cacheRoot).files.values.first) + offsets.append(cached.parsedBytes ?? 0) + sawPartialRecord = sawPartialRecord || cached.codexJSONLResumeState != nil + if cached.codexScanComplete == true { + break + } + } + + #expect(sawPartialRecord) + #expect(zip(offsets, offsets.dropFirst()).allSatisfy { $0 < $1 }) + #expect(report?.summary?.totalTokens == baseline.summary?.totalTokens) + } + + @Test + func `codex scan budget never admits more than its remaining allowance`() { + let budget = CostUsageScanner.CodexScanBudget(maxFileBytes: 100, maxBytesPerRefresh: 150) + guard case let .allow(first) = budget.admit(workBytes: 1000) else { + Issue.record("expected first bounded admission") + return + } + #expect(first == 100) + budget.consume(workBytes: first) + + guard case let .allow(second) = budget.admit(workBytes: 1000) else { + Issue.record("expected remaining-budget admission") + return + } + #expect(second == 50) + budget.consume(workBytes: second) + guard case .deferBudget = budget.admit(workBytes: 1) else { + Issue.record("expected exhausted budget to defer") + return + } + #expect(budget.bytesConsumed == 150) + } + + @Test + func `per refresh byte budget defers later dirty files`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + let day = try env.makeLocalNoon(year: 2026, month: 5, day: 10) + let urls = try Self.writeSyntheticCodexCorpus(env: env, day: day, files: 3, turnsPerFile: 3) + // Make deterministic order by newest-first: touch later files later. + let older = try #require(urls.first) + let middle = try #require(urls.dropFirst().first) + let newer = try #require(urls.last) + let olderDate = day.addingTimeInterval(-3600) + let middleDate = day.addingTimeInterval(-1800) + let newerDate = day + try FileManager.default.setAttributes([.modificationDate: olderDate], ofItemAtPath: older.path) + try FileManager.default.setAttributes([.modificationDate: middleDate], ofItemAtPath: middle.path) + try FileManager.default.setAttributes([.modificationDate: newerDate], ofItemAtPath: newer.path) + + let newestMeta = CostUsageScanner.codexFileMetadata(fileURL: newer) + var options = CostUsageScanner.Options( + codexSessionsRoot: env.codexSessionsRoot, + claudeProjectsRoots: nil, + cacheRoot: env.cacheRoot, + codexTraceDatabaseURL: env.root.appendingPathComponent("missing.sqlite"), + maxCodexSessionFileBytes: 64 * 1024 * 1024, + // Enough for the newest file only; remaining dirty files defer. + maxCodexScanBytesPerRefresh: max(1, newestMeta.size), + preferNewestCodexSessionsFirst: true) + options.refreshMinIntervalSeconds = 0 + + _ = CostUsageScanner.loadDailyReport( + provider: .codex, + since: day, + until: day, + now: day, + options: options) + let cache = CostUsageCacheIO.load(provider: .codex, cacheRoot: env.cacheRoot) + let cachedNames = Set(cache.files.keys.map { URL(fileURLWithPath: $0).lastPathComponent }) + + #expect(cachedNames.contains(newer.lastPathComponent)) + #expect(!cachedNames.contains(older.lastPathComponent)) + } + + @Test + func `pending work bytes treat fork files as full rescan work`() { + let metadata = CostUsageScanner.CodexFileMetadata( + path: "/tmp/forked.jsonl", + mtimeUnixMs: 2, + size: 1000, + fileId: "1:2") + let cached = CostUsageFileUsage( + mtimeUnixMs: 1, + size: 400, + days: [:], + parsedBytes: 400, + forkedFromId: "parent-session") + #expect(CostUsageScanner.pendingCodexScanWorkBytes(metadata: metadata, cached: cached) == 1000) + } + + @Test + func `pending work bytes charge full file for forced rescans of unchanged cache entries`() { + let metadata = CostUsageScanner.CodexFileMetadata( + path: "/tmp/unchanged.jsonl", + mtimeUnixMs: 42, + size: 2_000_000_000, + fileId: "9:9") + let cached = CostUsageFileUsage( + mtimeUnixMs: 42, + size: 2_000_000_000, + days: ["2026-05-10": ["gpt-5.2-codex": [100, 20, 10]]], + parsedBytes: 2_000_000_000, + sessionId: "session-unchanged") + // keepCached can still reject this (forceFullScan / priority / fork dependency). + // Budget must not report zero pending work or multi-GB forced rescans slip through. + #expect(CostUsageScanner.pendingCodexScanWorkBytes(metadata: metadata, cached: cached) == 2_000_000_000) + } + + @Test + func `oversized parent baseline reads defer for small fork children`() 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) + + // Parent is intentionally larger than the per-file budget. + let parentBody = ([ + #"{"type":"session_meta","timestamp":"\#(iso)","payload":{"session_id":"parent-giant"}}"#, + #"{"type":"turn_context","timestamp":"\#(iso)","payload":{"model":"openai/gpt-5.2-codex"}}"#, + #"{"type":"event_msg","timestamp":"\#(iso)","payload":{"type":"token_count","info":"# + + #"{"total_token_usage":{"input_tokens":500,"cached_input_tokens":50,"output_tokens":25},"# + + #""model":"openai/gpt-5.2-codex"}}}"#, + ] + Array(repeating: "x", count: 4096)).joined(separator: "\n") + "\n" + _ = try env.writeCodexSessionFile(day: day, filename: "parent-giant.jsonl", contents: parentBody) + + let childBody = [ + #"{"type":"session_meta","timestamp":"\#(iso)","payload":{"session_id":"child-small","# + + #""forked_from_id":"parent-giant"}}"#, + #"{"type":"turn_context","timestamp":"\#(iso)","payload":{"model":"openai/gpt-5.2-codex"}}"#, + #"{"type":"event_msg","timestamp":"\#(iso)","payload":{"type":"token_count","info":"# + + #"{"total_token_usage":{"input_tokens":600,"cached_input_tokens":60,"output_tokens":30},"# + + #""model":"openai/gpt-5.2-codex"}}}"#, + ].joined(separator: "\n") + "\n" + let childURL = try env.writeCodexSessionFile(day: day, filename: "child-small.jsonl", contents: childBody) + + var options = CostUsageScanner.Options( + codexSessionsRoot: env.codexSessionsRoot, + claudeProjectsRoots: nil, + cacheRoot: env.cacheRoot, + codexTraceDatabaseURL: env.root.appendingPathComponent("missing.sqlite"), + maxCodexSessionFileBytes: 1024, + maxCodexScanBytesPerRefresh: 64 * 1024 * 1024) + options.refreshMinIntervalSeconds = 0 + + let started = Date() + let report = CostUsageScanner.loadDailyReport( + provider: .codex, + since: day, + until: day, + now: day, + options: options) + let elapsed = Date().timeIntervalSince(started) + let cache = CostUsageCacheIO.load(provider: .codex, cacheRoot: env.cacheRoot) + + #expect(elapsed < 2.0) + #expect(cache.files.keys.contains { URL(fileURLWithPath: $0).lastPathComponent == childURL.lastPathComponent }) + // Child still contributes local tokens while the parent baseline is unresolved. + #expect((report.summary?.totalTokens ?? 0) > 0) + } + + private static func writeSyntheticCodexCorpus( + env: CostUsageTestEnvironment, + day: Date, + files: Int, + turnsPerFile: Int, + model: String = "openai/gpt-5.2-codex", + inputTokensPerTurn: Int = 100) throws -> [URL] + { + let baseISO = env.isoString(for: day) + var fileURLs: [URL] = [] + for fileIndex in 0..272K) rates apply to the entire request. Total input contains 10 cached, + // 20 cache-write, and 271,971 ordinary input tokens. + #expect(sol == (271_971.0 * 1e-5) + (10.0 * 1e-6) + (20.0 * 1.25e-5) + (10.0 * 4.5e-5)) + #expect(terra == (271_971.0 * 5e-6) + (10.0 * 5e-7) + (20.0 * 6.25e-6) + (10.0 * 2.25e-5)) + #expect(luna == (271_971.0 * 2e-6) + (10.0 * 2e-7) + (20.0 * 2.5e-6) + (10.0 * 9e-6)) + } + + @Test + func `codex cost bills gpt56 cache writes at one point two five x input`() throws { + let root = try Self.cacheRoot() + // Total prompt 100: 70 uncached + 20 cache-write + 10 cache-read. + let sol = CostUsagePricing.codexCostUSD( + model: "gpt-5.6-sol", + inputTokens: 100, + cachedInputTokens: 10, + outputTokens: 5, + cacheWriteInputTokens: 20, + modelsDevCacheRoot: root) + + let expected = (70.0 * 5e-6) + (10.0 * 5e-7) + (20.0 * 6.25e-6) + (5.0 * 3e-5) + #expect(sol == expected) + } + + @Test + func `codex priority cost supports gpt56 tiers`() { + let sol = CostUsagePricing.codexPriorityCostUSD( + model: "gpt-5.6-sol", + inputTokens: 100, + cachedInputTokens: 20, + outputTokens: 10) + let terra = CostUsagePricing.codexPriorityCostUSD( + model: "gpt-5.6-terra", + inputTokens: 100, + cachedInputTokens: 20, + outputTokens: 10) + let luna = CostUsagePricing.codexPriorityCostUSD( + model: "gpt-5.6-luna", + inputTokens: 100, + cachedInputTokens: 20, + outputTokens: 10) + + // Priority is 2x short-context rates (Sol input $10/1M, etc.). + #expect(sol == (80.0 * 1e-5) + (20.0 * 1e-6) + (10.0 * 6e-5)) + #expect(terra == (80.0 * 5e-6) + (20.0 * 5e-7) + (10.0 * 3e-5)) + #expect(luna == (80.0 * 2e-6) + (20.0 * 2e-7) + (10.0 * 1.2e-5)) + } + + @Test + func `codex priority cost uses explicit cache write rates`() { + let sol = CostUsagePricing.codexPriorityCostUSD( + model: "gpt-5.6-sol", + inputTokens: 100, + cachedInputTokens: 10, + cacheWriteInputTokens: 20, + outputTokens: 5) + let terra = CostUsagePricing.codexPriorityCostUSD( + model: "gpt-5.6-terra", + inputTokens: 100, + cachedInputTokens: 10, + cacheWriteInputTokens: 20, + outputTokens: 5) + let luna = CostUsagePricing.codexPriorityCostUSD( + model: "gpt-5.6-luna", + inputTokens: 100, + cachedInputTokens: 10, + cacheWriteInputTokens: 20, + outputTokens: 5) + let modelWithoutCacheWriteSupport = CostUsagePricing.codexPriorityCostUSD( + model: "gpt-5.5", + inputTokens: 100, + cachedInputTokens: 10, + cacheWriteInputTokens: 20, + outputTokens: 5) + + #expect(sol == (70.0 * 1e-5) + (10.0 * 1e-6) + (20.0 * 1.25e-5) + (5.0 * 6e-5)) + #expect(terra == (70.0 * 5e-6) + (10.0 * 5e-7) + (20.0 * 6.25e-6) + (5.0 * 3e-5)) + #expect(luna == (70.0 * 2e-6) + (10.0 * 2e-7) + (20.0 * 2.5e-6) + (5.0 * 1.2e-5)) + // A model without an explicit Priority cache-write price keeps the legacy input-rate fold. + #expect( + modelWithoutCacheWriteSupport == + (90.0 * 1.25e-5) + (10.0 * 1.25e-6) + (5.0 * 7.5e-5)) + } + @Test func `codex cost applies gpt54 and gpt55 long context rates to full session`() throws { let root = try Self.cacheRoot() @@ -111,6 +386,7 @@ struct CostUsagePricingTests { outputTokens: 10, modelsDevCacheRoot: root) + // 200K cached reads are a subset of the 300K input, leaving 100K non-cached input. let cached = 200_000.0 * 1e-6 let nonCached = 100_000.0 * 1e-5 let output = 10.0 * 4.5e-5 @@ -118,6 +394,40 @@ struct CostUsagePricingTests { #expect(gpt55 == cached + nonCached + output) } + @Test + func `codex cost clamps cache reads to input tokens`() throws { + // `cached_input_tokens` can never exceed `input_tokens` in real Codex data; if it does, + // clamp cached to input so the surplus is not invented and input is never double-billed. + let root = try Self.cacheRoot() + let gpt55 = CostUsagePricing.codexCostUSD( + model: "gpt-5.5", + inputTokens: 20, + cachedInputTokens: 500, + outputTokens: 5, + modelsDevCacheRoot: root) + + let expected = (20.0 * 5e-7) + (5.0 * 3e-5) + + #expect(gpt55 == expected) + } + + @Test + func `codex cost does not double bill cached input tokens`() throws { + // Regression for the cached double-count: input_tokens includes cached reads, so a turn + // with 1000 input / 900 cached must bill 100 tokens at the input rate and 900 at the + // cache rate — not the full 1000 at the input rate plus 900 again at the cache rate. + let root = try Self.cacheRoot() + let cost = CostUsagePricing.codexCostUSD( + model: "gpt-5-codex", + inputTokens: 1000, + cachedInputTokens: 900, + outputTokens: 10, + modelsDevCacheRoot: root) + + let expected = (100.0 * 1.25e-6) + (900.0 * 1.25e-7) + (10.0 * 1e-5) + #expect(cost == expected) + } + @Test func `codex priority cost applies model specific fast rates`() { let gpt54 = CostUsagePricing.codexPriorityCostUSD( @@ -148,6 +458,21 @@ struct CostUsagePricingTests { inputTokens: 272_001, cachedInputTokens: 0, outputTokens: 10) + let gpt56Sol = CostUsagePricing.codexPriorityCostUSD( + model: "gpt-5.6-sol", + inputTokens: 272_001, + cachedInputTokens: 0, + outputTokens: 10) + let gpt56Terra = CostUsagePricing.codexPriorityCostUSD( + model: "gpt-5.6-terra", + inputTokens: 272_001, + cachedInputTokens: 0, + outputTokens: 10) + let gpt56Luna = CostUsagePricing.codexPriorityCostUSD( + model: "gpt-5.6-luna", + inputTokens: 272_001, + cachedInputTokens: 0, + outputTokens: 10) let gpt54Mini = CostUsagePricing.codexPriorityCostUSD( model: "gpt-5.4-mini", inputTokens: 272_001, @@ -155,9 +480,35 @@ struct CostUsagePricingTests { outputTokens: 10) #expect(gpt55 == nil) + #expect(gpt56Sol == nil) + #expect(gpt56Terra == nil) + #expect(gpt56Luna == nil) #expect(gpt54Mini == nil) } + @Test + func `codex priority cost counts only input tokens toward the limit`() { + let eligible = CostUsagePricing.codexPriorityCostUSD( + model: "gpt-5.5", + inputTokens: 200_000, + cachedInputTokens: 100_000, + outputTokens: 10) + let boundary = CostUsagePricing.codexPriorityCostUSD( + model: "gpt-5.5", + inputTokens: 272_000, + cachedInputTokens: 0, + outputTokens: 10) + let overLimit = CostUsagePricing.codexPriorityCostUSD( + model: "gpt-5.5", + inputTokens: 272_001, + cachedInputTokens: 0, + outputTokens: 10) + + #expect(eligible == (100_000.0 * 1.25e-5) + (100_000.0 * 1.25e-6) + (10.0 * 7.5e-5)) + #expect(boundary != nil) + #expect(overLimit == nil) + } + @Test func `codex priority cost remains available at priority input boundary`() { let gpt55 = CostUsagePricing.codexPriorityCostUSD( @@ -211,6 +562,168 @@ struct CostUsagePricingTests { #expect(aboveBoundary == (272_001.0 * 1e-5) + (10.0 * 4.5e-5)) } + @Test + func `codex models dev cached fallback uses long context input rate when cache read is absent`() throws { + let root = try Self.seedModelsDevCache(""" + { + "openai": { + "id": "openai", + "models": { + "gpt-5.5": { + "id": "gpt-5.5", + "cost": { + "input": 5, + "output": 30, + "context_over_200k": { + "input": 10, + "output": 45 + } + } + } + } + } + } + """) + + let cost = CostUsagePricing.codexCostUSD( + model: "gpt-5.5", + inputTokens: 300_000, + cachedInputTokens: 200_000, + outputTokens: 10, + modelsDevCacheRoot: root) + + // The catalog has a long-context block but omits cache_read, so preserve its omission + // semantics: cached tokens fall back to the long-context input rate rather than mixing in + // one field from the bundled table. + let expected = (100_000.0 * 10e-6) + (200_000.0 * 10e-6) + (10.0 * 45e-6) + #expect(cost == expected) + } +} + +extension CostUsagePricingTests { + @Test + func `codex models dev uses bundled short cache rates only when catalog omits them`() throws { + let missingRoot = try Self.seedModelsDevCache(""" + { + "openai": { + "id": "openai", + "models": { + "gpt-5.6-sol": { + "id": "gpt-5.6-sol", + "cost": { "input": 5, "output": 30 } + } + } + } + } + """) + let explicitZeroRoot = try Self.seedModelsDevCache(""" + { + "openai": { + "id": "openai", + "models": { + "gpt-5.6-sol": { + "id": "gpt-5.6-sol", + "cost": { "input": 5, "output": 30, "cache_read": 0, "cache_write": 0 } + } + } + } + } + """) + + let missing = CostUsagePricing.codexCostUSD( + model: "gpt-5.6-sol", + inputTokens: 100, + cachedInputTokens: 10, + outputTokens: 0, + cacheWriteInputTokens: 20, + modelsDevCacheRoot: missingRoot) + let explicitZero = CostUsagePricing.codexCostUSD( + model: "gpt-5.6-sol", + inputTokens: 100, + cachedInputTokens: 10, + outputTokens: 0, + cacheWriteInputTokens: 20, + modelsDevCacheRoot: explicitZeroRoot) + + #expect(missing == (70.0 * 5e-6) + (10.0 * 5e-7) + (20.0 * 6.25e-6)) + #expect(explicitZero == 70.0 * 5e-6) + } + + @Test + func `codex models dev falls back bundled long context rates when catalog omits them`() throws { + // Catalog has short-context rates only; bundled table supplies the 272K threshold + rates. + let root = try Self.seedModelsDevCache(""" + { + "openai": { + "id": "openai", + "models": { + "gpt-5.6-sol": { + "id": "gpt-5.6-sol", + "cost": { + "input": 5, + "output": 30, + "cache_read": 0.5 + } + } + } + } + } + """) + + let cost = CostUsagePricing.codexCostUSD( + model: "gpt-5.6-sol", + inputTokens: 272_001, + cachedInputTokens: 0, + outputTokens: 10, + modelsDevCacheRoot: root) + + // Without bundled above-threshold fallback this would bill short rates ($5/$30) despite + // entering long-context mode via the bundled threshold. + #expect(cost == (272_001.0 * 1e-5) + (10.0 * 4.5e-5)) + } + + @Test + func `codex models dev overrides every gpt56 long context token bucket`() throws { + let root = try Self.seedModelsDevCache(""" + { + "openai": { + "id": "openai", + "models": { + "gpt-5.6-sol": { + "id": "gpt-5.6-sol", + "cost": { + "input": 1, + "output": 2, + "cache_read": 0.1, + "cache_write": 1.25, + "context_over_200k": { + "input": 11, + "output": 22, + "cache_read": 1.1, + "cache_write": 13.75 + } + } + } + } + } + } + """) + + let cost = CostUsagePricing.codexCostUSD( + model: "gpt-5.6-sol", + inputTokens: 272_001, + cachedInputTokens: 100_000, + outputTokens: 10, + cacheWriteInputTokens: 50000, + modelsDevCacheRoot: root) + + let expected = (122_001.0 * 11e-6) + + (100_000.0 * 1.1e-6) + + (50000.0 * 13.75e-6) + + (10.0 * 22e-6) + #expect(cost == expected) + } + @Test func `codex cost supports gpt55 pro bundled fallback`() throws { let root = try Self.cacheRoot() @@ -221,6 +734,8 @@ struct CostUsagePricingTests { outputTokens: 5, modelsDevCacheRoot: root) + // gpt-5.5-pro has no cache-read rate, so cached falls back to the input rate; with 90 + // non-cached + 10 cached priced at the same rate this is 100 tokens at 3e-5. let expected = (100.0 * 3e-5) + (5.0 * 1.8e-4) #expect(cost == expected) } @@ -360,6 +875,169 @@ 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 supports fable5 bundled fallback`() throws { + let emptyCacheRoot = try Self.cacheRoot() + let cost = CostUsagePricing.claudeCostUSD( + model: "claude-fable-5", + inputTokens: 100, + cacheReadInputTokens: 20, + cacheCreationInputTokens: 10, + outputTokens: 5, + modelsDevCacheRoot: emptyCacheRoot) + let expected = (100.0 * 1e-5) + (20.0 * 1e-6) + (10.0 * 1.25e-5) + (5.0 * 5e-5) + #expect(cost == expected) + } + + @Test + func `claude cost preserves historical sonnet46 long context pricing`() throws { + let emptyCacheRoot = try Self.cacheRoot() + let historical = CostUsagePricing.claudeCostUSD( + model: "claude-sonnet-4-6", + inputTokens: 240_000, + cacheReadInputTokens: 0, + cacheCreationInputTokens: 0, + outputTokens: 0, + pricingDate: Date(timeIntervalSince1970: 1_773_359_999), + modelsDevCacheRoot: emptyCacheRoot) + let current = CostUsagePricing.claudeCostUSD( + model: "claude-sonnet-4-6", + inputTokens: 240_000, + cacheReadInputTokens: 0, + cacheCreationInputTokens: 0, + outputTokens: 0, + pricingDate: Date(timeIntervalSince1970: 1_773_360_000), + modelsDevCacheRoot: emptyCacheRoot) + + #expect(historical == 1.44) + #expect(current == 0.72) + } + + @Test + func `claude cost ignores stale sonnet46 threshold catalog after cutover`() throws { + let cacheRoot = try Self.seedModelsDevCache(""" + { + "anthropic": { + "id": "anthropic", + "models": { + "claude-sonnet-4-6": { + "id": "claude-sonnet-4-6", + "cost": { + "input": 3, + "output": 15, + "cache_read": 0.3, + "cache_write": 3.75, + "context_over_200k": { + "input": 6, + "output": 22.5, + "cache_read": 0.6, + "cache_write": 7.5 + } + } + } + } + } + } + """) + let cost = CostUsagePricing.claudeCostUSD( + model: "claude-sonnet-4-6", + inputTokens: 240_000, + cacheReadInputTokens: 0, + cacheCreationInputTokens: 0, + outputTokens: 0, + pricingDate: Date(timeIntervalSince1970: 1_773_360_000), + modelsDevCacheRoot: cacheRoot) + + #expect(cost == 0.72) + } + + @Test + func `claude cost prices one hour cache writes separately`() throws { + let emptyCacheRoot = try Self.cacheRoot() + let cost = CostUsagePricing.claudeCostUSD( + model: "claude-fable-5", + inputTokens: 100, + cacheReadInputTokens: 20, + cacheCreationInputTokens: 30, + cacheCreationInputTokens1h: 20, + outputTokens: 5, + modelsDevCacheRoot: emptyCacheRoot) + let expected = (100.0 * 1e-5) + + (20.0 * 1e-6) + + (10.0 * 1.25e-5) + + (20.0 * 2e-5) + + (5.0 * 5e-5) + #expect(cost == expected) + } + + @Test + func `claude cost applies long context rates across cache write durations`() throws { + let cacheRoot = try Self.seedModelsDevCache(""" + { + "anthropic": { + "id": "anthropic", + "models": { + "claude-threshold-model": { + "id": "claude-threshold-model", + "cost": { + "input": 3, + "output": 15, + "cache_read": 0.3, + "cache_write": 3.75, + "context_over_200k": { + "input": 6, + "output": 22.5, + "cache_read": 0.6, + "cache_write": 7.5 + } + } + } + } + } + } + """) + let cost = CostUsagePricing.claudeCostUSD( + model: "claude-threshold-model", + inputTokens: 0, + cacheReadInputTokens: 0, + cacheCreationInputTokens: 240_000, + cacheCreationInputTokens1h: 120_000, + outputTokens: 0, + modelsDevCacheRoot: cacheRoot) + let expected = (120_000.0 * 12e-6) + + (120_000.0 * 7.5e-6) + #expect(cost == expected) + } + + @Test + func `claude sonnet46 uses standard pricing across full context`() throws { + let emptyCacheRoot = try Self.cacheRoot() + let cost = CostUsagePricing.claudeCostUSD( + model: "claude-sonnet-4-6", + inputTokens: 0, + cacheReadInputTokens: 0, + cacheCreationInputTokens: 240_000, + outputTokens: 0, + modelsDevCacheRoot: emptyCacheRoot) + #expect(cost == 240_000.0 * 3.75e-6) + } + @Test func `claude cost returns nil for unknown models`() { let cost = CostUsagePricing.claudeCostUSD( @@ -406,11 +1084,10 @@ struct CostUsagePricingTests { outputTokens: 5, modelsDevCacheRoot: root) - let expected = (200_000.0 * 3e-6) - + (10.0 * 6e-6) - + (5.0 * 0.3e-6) - + (5.0 * 3.75e-6) - + (5.0 * 15e-6) + let expected = (200_010.0 * 6e-6) + + (5.0 * 0.6e-6) + + (5.0 * 7.5e-6) + + (5.0 * 22.5e-6) #expect(cost == expected) } @@ -421,6 +1098,14 @@ struct CostUsagePricingTests { return root } + private static func modelsDevArtifact(_ json: String) throws -> ModelsDevCacheArtifact { + let catalog = try JSONDecoder().decode(ModelsDevCatalog.self, from: Data(json.utf8)) + return ModelsDevCacheArtifact( + version: ModelsDevCache.artifactVersion, + fetchedAt: Date(timeIntervalSince1970: 0), + catalog: catalog) + } + private static func cacheRoot() throws -> URL { let root = FileManager.default.temporaryDirectory .appendingPathComponent("codexbar-pricing-tests-\(UUID().uuidString)", isDirectory: true) diff --git a/Tests/CodexBarTests/CostUsageScanExecutorTests.swift b/Tests/CodexBarTests/CostUsageScanExecutorTests.swift new file mode 100644 index 0000000000..8ea5a03b44 --- /dev/null +++ b/Tests/CodexBarTests/CostUsageScanExecutorTests.swift @@ -0,0 +1,156 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct CostUsageScanExecutorTests { + @Test + func `runs work on the dedicated scan queue and returns its value`() async throws { + let queue = self.makeQueue() + let label = try await CostUsageScanExecutor.run(on: queue) { _ in + String(cString: __dispatch_queue_get_label(nil)) + } + #expect(label == queue.label) + } + + @Test + func `propagates thrown errors`() async { + struct ScanFailure: Error {} + let queue = self.makeQueue() + await #expect(throws: ScanFailure.self) { + try await CostUsageScanExecutor.run(on: queue) { _ -> Int in + throw ScanFailure() + } + } + } + + @Test + func `serializes overlapping scans`() async throws { + let queue = self.makeQueue() + let state = LockedValue((active: 0, maxActive: 0)) + try await withThrowingTaskGroup(of: Void.self) { group in + for _ in 0..<4 { + group.addTask { + try await CostUsageScanExecutor.run(on: queue) { _ in + state.update { + $0.active += 1 + $0.maxActive = max($0.maxActive, $0.active) + } + Thread.sleep(forTimeInterval: 0.02) + state.update { $0.active -= 1 } + } + } + } + try await group.waitForAll() + } + #expect(state.read { $0.maxActive } == 1) + } + + @Test + func `cancellation reaches in-flight work through checkCancellation`() async { + let queue = self.makeQueue() + let workStarted = LockedValue(false) + let task = Task { + try await CostUsageScanExecutor.run(on: queue) { checkCancellation in + workStarted.set(true) + while true { + try checkCancellation() + Thread.sleep(forTimeInterval: 0.005) + } + } + } + #expect(await self.waitUntil { workStarted.value }) + task.cancel() + await #expect(throws: CancellationError.self) { + try await task.value + } + } + + @Test + func `work cancelled while queued resumes with CancellationError`() async { + let queue = self.makeQueue() + let blockerStarted = LockedValue(false) + let releaseBlocker = LockedValue(false) + let blocker = Task { + try await CostUsageScanExecutor.run(on: queue) { _ in + blockerStarted.set(true) + while !releaseBlocker.value { + Thread.sleep(forTimeInterval: 0.002) + } + } + } + #expect(await self.waitUntil { blockerStarted.value }) + + let queuedWorkStarted = LockedValue(false) + let queued = Task { + try await CostUsageScanExecutor.run(on: queue) { _ in + queuedWorkStarted.set(true) + Issue.record("queued work should not run after cancellation") + } + } + try? await Task.sleep(for: .milliseconds(50)) + + let cancellationObserved = LockedValue(nil) + let observer = Task { + do { + try await queued.value + cancellationObserved.set(false) + } catch is CancellationError { + cancellationObserved.set(true) + } catch { + cancellationObserved.set(false) + } + } + queued.cancel() + + #expect(await self.waitUntil { cancellationObserved.value != nil }) + #expect(cancellationObserved.value == true) + #expect(!queuedWorkStarted.value) + #expect(!releaseBlocker.value) + + releaseBlocker.set(true) + await observer.value + _ = try? await blocker.value + } + + private func makeQueue() -> DispatchQueue { + DispatchQueue(label: "\(CostUsageScanExecutor.queueLabel).tests.\(UUID().uuidString)") + } + + private func waitUntil( + timeout: Duration = .seconds(1), + condition: @escaping @Sendable () -> Bool) async -> Bool + { + let clock = ContinuousClock() + let deadline = clock.now.advanced(by: timeout) + while clock.now < deadline { + if condition() { return true } + try? await Task.sleep(for: .milliseconds(5)) + } + return condition() + } +} + +private final class LockedValue: @unchecked Sendable { + private let lock = NSLock() + private var storage: Value + + init(_ value: Value) { + self.storage = value + } + + var value: Value { + self.lock.withLock { self.storage } + } + + func read(_ body: (Value) -> Result) -> Result { + self.lock.withLock { body(self.storage) } + } + + func set(_ value: Value) { + self.lock.withLock { self.storage = value } + } + + func update(_ body: (inout Value) -> Void) { + self.lock.withLock { body(&self.storage) } + } +} diff --git a/Tests/CodexBarTests/CostUsageScannerBreakdownTests.swift b/Tests/CodexBarTests/CostUsageScannerBreakdownTests.swift index bb77320af6..547cf7fc0e 100644 --- a/Tests/CodexBarTests/CostUsageScannerBreakdownTests.swift +++ b/Tests/CodexBarTests/CostUsageScannerBreakdownTests.swift @@ -1,3 +1,4 @@ +import CryptoKit import Foundation import Testing @testable import CodexBarCore @@ -17,28 +18,45 @@ struct CostUsageScannerBreakdownTests { ] } + private func codexSessionMeta(timestamp: String, id: String, cwd: String) -> [String: Any] { + [ + "type": "session_meta", + "timestamp": timestamp, + "payload": [ + "id": id, + "cwd": cwd, + ], + ] + } + private func codexTokenCount( timestamp: String, model: String, total: Usage? = nil, - last: Usage? = nil) -> [String: Any] + last: Usage? = nil, + totalReasoning: Int? = nil, + lastReasoning: Int? = nil) -> [String: Any] { var info: [String: Any] = [ "model": model, ] if let total { - info["total_token_usage"] = [ + var usage: [String: Any] = [ "input_tokens": total.input, "cached_input_tokens": total.cached, "output_tokens": total.output, ] + usage["reasoning_output_tokens"] = totalReasoning + info["total_token_usage"] = usage } if let last { - info["last_token_usage"] = [ + var usage: [String: Any] = [ "input_tokens": last.input, "cached_input_tokens": last.cached, "output_tokens": last.output, ] + usage["reasoning_output_tokens"] = lastReasoning + info["last_token_usage"] = usage } return [ "type": "event_msg", @@ -89,6 +107,35 @@ struct CostUsageScannerBreakdownTests { + #""}}"# } + private func oversizedCodexTurnContextBlankFallbackLine(timestamp: String, model: String) -> String { + let largeInstructions = String(repeating: "x", count: 300 * 1024) + return #"{"type":"turn_context","timestamp":""# + + timestamp + + #"","payload":{"model":" ","model_name":"","info":{"model":" ","model_name":""# + + model + + #""},"instructions":""# + + largeInstructions + + #""}}"# + } + + private func oversizedCodexTurnContextAllBlankLine(timestamp: String) -> String { + let largeInstructions = String(repeating: "x", count: 300 * 1024) + return #"{"type":"turn_context","timestamp":""# + + timestamp + + #"","payload":{"model":"","model_name":" ","info":{"model":" ","model_name":""},"instructions":""# + + largeInstructions + + #""}}"# + } + + private func oversizedCodexTurnContextClosedBlankPayloadLine(timestamp: String) -> String { + let largeInstructions = String(repeating: "x", count: 300 * 1024) + return #"{"type":"turn_context","timestamp":""# + + timestamp + + #"","payload":{"model":"","model_name":" ","info":{"model":" ","model_name":""}},"instructions":""# + + largeInstructions + + #""}"# + } + private func oversizedCodexTurnContextPromptOnlyLine(timestamp: String, promptModel: String) -> String { let prompt = #"example: {\"type\":\"turn_context\",\"payload\":{\"model\":\"\#(promptModel)\"}}"# + String(repeating: "x", count: 300 * 1024) @@ -194,6 +241,188 @@ struct CostUsageScannerBreakdownTests { #expect((second.data[0].costUSD ?? 0) > (first.data[0].costUSD ?? 0)) } + @Test + func `codex project breakdowns group by cwd and preserve daily totals`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 4, day: 2) + let iso0 = env.isoString(for: day) + let iso1 = env.isoString(for: day.addingTimeInterval(1)) + let iso2 = env.isoString(for: day.addingTimeInterval(2)) + let model = "gpt-5.4" + let projectA = env.root.appendingPathComponent("client-a", isDirectory: true).path + let projectB = env.root.appendingPathComponent("client-b", isDirectory: true).path + let projectAWorktree = env.root + .appendingPathComponent(".codex/worktrees/abcd/client-a", isDirectory: true) + .path + + try self.makeGitRepositoryWithWorktree(projectPath: projectA, worktreePath: projectAWorktree) + + func sessionMeta(id: String, cwd: String?) -> [String: Any] { + var payload: [String: Any] = ["id": id] + if let cwd { + payload["cwd"] = cwd + } + return [ + "type": "session_meta", + "timestamp": iso0, + "payload": payload, + ] + } + + let firstA = try env.writeCodexSessionFile( + day: day, + filename: "client-a-1.jsonl", + contents: env.jsonl([ + sessionMeta(id: "client-a-1", cwd: projectA), + self.codexTurnContext(timestamp: iso0, model: model), + self.codexTokenCount( + timestamp: iso1, + model: model, + total: (input: 10, cached: 0, output: 1)), + ])) + _ = try env.writeCodexSessionFile( + day: day, + filename: "client-a-2.jsonl", + contents: env.jsonl([ + sessionMeta(id: "client-a-2", cwd: projectA + "/."), + self.codexTurnContext(timestamp: iso0, model: model), + self.codexTokenCount( + timestamp: iso1, + model: model, + total: (input: 20, cached: 0, output: 2)), + ])) + _ = try env.writeCodexSessionFile( + day: day, + filename: "client-a-worktree.jsonl", + contents: env.jsonl([ + sessionMeta(id: "client-a-worktree", cwd: projectAWorktree), + self.codexTurnContext(timestamp: iso0, model: model), + self.codexTokenCount( + timestamp: iso1, + model: model, + total: (input: 12, cached: 0, output: 1)), + ])) + _ = try env.writeCodexSessionFile( + day: day, + filename: "client-b.jsonl", + contents: env.jsonl([ + sessionMeta(id: "client-b", cwd: projectB), + self.codexTurnContext(timestamp: iso0, model: model), + self.codexTokenCount( + timestamp: iso1, + model: model, + total: (input: 5, cached: 0, output: 5)), + ])) + _ = try env.writeCodexSessionFile( + day: day, + filename: "unknown.jsonl", + contents: env.jsonl([ + sessionMeta(id: "unknown", cwd: nil), + self.codexTurnContext(timestamp: iso0, model: model), + self.codexTokenCount( + timestamp: iso1, + model: model, + total: (input: 7, cached: 0, output: 3)), + ])) + + var options = CostUsageScanner.Options( + codexSessionsRoot: env.codexSessionsRoot, + claudeProjectsRoots: nil, + cacheRoot: env.cacheRoot, + codexTraceDatabaseURL: env.root.appendingPathComponent("missing-traces.sqlite")) + options.refreshMinIntervalSeconds = 0 + + let report = CostUsageScanner.loadDailyReport( + provider: .codex, + since: day, + until: day, + now: day, + options: options) + #expect(report.summary?.totalTokens == 66) + + var cache = CostUsageCacheIO.load(provider: .codex, cacheRoot: env.cacheRoot) + var projects = CostUsageScanner.buildCodexProjectBreakdownsFromCache( + cache: cache, + range: CostUsageScanner.CostUsageDayRange(since: day, until: day), + modelsDevCacheRoot: env.cacheRoot) + let projectABreakdown = projects.first { $0.path == projectA } + #expect(projectABreakdown?.totalTokens == 46) + #expect(projectABreakdown?.sources.count == 2) + #expect(projectABreakdown?.sources.first(where: { $0.path == projectA })?.totalTokens == 33) + #expect(projectABreakdown?.sources.first(where: { $0.path == projectAWorktree })?.totalTokens == 13) + #expect(projects.first(where: { $0.path == projectB })?.totalTokens == 10) + #expect(projects.first(where: { $0.path == nil })?.name == CostUsageProjectBreakdown.unknownProjectName) + #expect(projects.first(where: { $0.path == nil })?.totalTokens == 10) + #expect(cache.files.values.first(where: { $0.projectPath == projectAWorktree })? + .canonicalProjectPath == projectA) + #expect(cache.codexProjectMetadataVersion == 1) + + let appended = try "\n" + env.jsonl([ + self.codexTokenCount( + timestamp: iso2, + model: model, + total: (input: 15, cached: 0, output: 2)), + ]) + let handle = try FileHandle(forWritingTo: firstA) + try handle.seekToEnd() + try handle.write(contentsOf: Data(appended.utf8)) + try handle.close() + + _ = CostUsageScanner.loadDailyReport( + provider: .codex, + since: day, + until: day, + now: day, + options: options) + cache = CostUsageCacheIO.load(provider: .codex, cacheRoot: env.cacheRoot) + projects = CostUsageScanner.buildCodexProjectBreakdownsFromCache( + cache: cache, + range: CostUsageScanner.CostUsageDayRange(since: day, until: day), + modelsDevCacheRoot: env.cacheRoot) + #expect(projects.first(where: { $0.path == projectA })?.totalTokens == 52) + } + + private func makeGitRepositoryWithWorktree(projectPath: String, worktreePath: String) throws { + try FileManager.default.createDirectory( + at: URL(fileURLWithPath: projectPath, isDirectory: true), + withIntermediateDirectories: true) + try self.runGit(["init", projectPath]) + try self.runGit(["-C", projectPath, "config", "user.email", "codexbar-test@example.com"]) + try self.runGit(["-C", projectPath, "config", "user.name", "CodexBar Test"]) + try self.runGit(["-C", projectPath, "config", "commit.gpgsign", "false"]) + try "test\n".write( + to: URL(fileURLWithPath: projectPath).appendingPathComponent("README.md"), + atomically: false, + encoding: .utf8) + try self.runGit(["-C", projectPath, "add", "README.md"]) + try self.runGit(["-C", projectPath, "commit", "-m", "init"]) + try FileManager.default.createDirectory( + at: URL(fileURLWithPath: worktreePath).deletingLastPathComponent(), + withIntermediateDirectories: true) + try self.runGit(["-C", projectPath, "worktree", "add", "-b", "codex-test", worktreePath]) + } + + private func runGit(_ arguments: [String]) throws { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/env") + process.arguments = ["git"] + arguments + let output = Pipe() + process.standardOutput = output + process.standardError = output + try process.run() + process.waitUntilExit() + if process.terminationStatus != 0 { + let data = output.fileHandleForReading.readDataToEndOfFile() + let message = String(data: data, encoding: .utf8) ?? "" + throw NSError( + domain: "CodexBarTests.Git", + code: Int(process.terminationStatus), + userInfo: [NSLocalizedDescriptionKey: message]) + } + } + @Test func `codex incremental append falls back to rescan when fork metadata appears late`() throws { let env = try CostUsageTestEnvironment() @@ -316,7 +545,8 @@ struct CostUsageScannerBreakdownTests { options: options) let oldDailyCost = (80.0 / 1_000_000.0) + (20.0 * 0.5 / 1_000_000.0) + (10.0 * 2.0 / 1_000_000.0) - #expect(first.summary?.totalCostUSD == oldDailyCost * 2) + let costTolerance = 0.000000001 + #expect(abs((first.summary?.totalCostUSD ?? 0) - (oldDailyCost * 2)) < costTolerance) try ModelsDevCache.save( catalog: Self.modelsDevCatalog(model: model, input: 1, output: 2, cacheRead: 0.5), @@ -331,7 +561,7 @@ struct CostUsageScannerBreakdownTests { now: day.addingTimeInterval(1), options: options) let samePricingCache = CostUsageCacheIO.load(provider: .codex, cacheRoot: env.cacheRoot) - #expect(samePricing.summary?.totalCostUSD == oldDailyCost) + #expect(abs((samePricing.summary?.totalCostUSD ?? 0) - oldDailyCost) < costTolerance) #expect(samePricingCache.scanSinceKey == "2026-05-04") try ModelsDevCache.save( @@ -349,7 +579,7 @@ struct CostUsageScannerBreakdownTests { let newDailyCost = (80.0 * 10.0 / 1_000_000.0) + (20.0 * 5.0 / 1_000_000.0) + (10.0 * 20.0 / 1_000_000.0) - #expect(narrowRepriced.summary?.totalCostUSD == newDailyCost) + #expect(abs((narrowRepriced.summary?.totalCostUSD ?? 0) - newDailyCost) < costTolerance) let wideRepriced = CostUsageScanner.loadDailyReport( provider: .codex, @@ -358,7 +588,78 @@ struct CostUsageScannerBreakdownTests { now: day.addingTimeInterval(3), options: options) - #expect(wideRepriced.summary?.totalCostUSD == newDailyCost * 2) + #expect(abs((wideRepriced.summary?.totalCostUSD ?? 0) - (newDailyCost * 2)) < costTolerance) + } + + @Test + func `codex daily report reprices cached costs when cost formula version changes`() throws { + // Costs are persisted per file as precomputed nanos and only recomputed when the pricing + // key changes. A formula-only fix (rates unchanged) must still invalidate caches written + // by an older formula, otherwise stale (e.g. inflated) costs would be reused indefinitely. + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 5, day: 10) + let iso0 = env.isoString(for: day) + let iso1 = env.isoString(for: day.addingTimeInterval(1)) + let model = "gpt-5.5" + _ = try env.writeCodexSessionFile( + day: day, + filename: "session.jsonl", + contents: env.jsonl([ + self.codexTurnContext(timestamp: iso0, model: model), + self.codexTokenCount(timestamp: iso1, model: model, last: (input: 100, cached: 20, output: 10)), + ])) + + var options = CostUsageScanner.Options( + codexSessionsRoot: env.codexSessionsRoot, + claudeProjectsRoots: nil, + cacheRoot: env.cacheRoot, + codexTraceDatabaseURL: env.root.appendingPathComponent("missing-traces.sqlite")) + options.refreshMinIntervalSeconds = 0 + + let first = CostUsageScanner.loadDailyReport( + provider: .codex, + since: day, + until: day, + now: day, + options: options) + // gpt-5.5 built-in: only the 80 non-cached input tokens bill at the input rate. + let correctCost = (80.0 * 5e-6) + (20.0 * 5e-7) + (10.0 * 3e-5) + let tolerance = 0.000000001 + #expect(abs((first.summary?.totalCostUSD ?? 0) - correctCost) < tolerance) + + // Simulate a cache written by the previous formula. Its key hashed only the rates, so + // derive that exact legacy key and verify the formula version makes the current key differ. + var cache = CostUsageCacheIO.load(provider: .codex, cacheRoot: env.cacheRoot) + let legacyPricingKey = "builtin-\(Self.sha256Hex(CostUsagePricing.codexBuiltInPricingFingerprint()))" + let currentPricingKey = try #require(cache.codexPricingKey) + #expect(currentPricingKey != legacyPricingKey) + cache.codexPricingKey = legacyPricingKey + for (path, usage) in cache.files { + guard let costNanos = usage.codexCostNanos else { continue } + var inflated = costNanos + for (dayKey, models) in costNanos { + for (modelKey, value) in models { + inflated[dayKey]?[modelKey] = value * 10 + } + } + var updated = usage + updated.codexCostNanos = inflated + cache.files[path] = updated + } + CostUsageCacheIO.save(provider: .codex, cache: cache, cacheRoot: env.cacheRoot) + + // A time-only refresh is suppressed (interval 60s), so repricing here is driven solely by + // the pricing-key mismatch from the formula version bump. + options.refreshMinIntervalSeconds = 60 + let repriced = CostUsageScanner.loadDailyReport( + provider: .codex, + since: day, + until: day, + now: day.addingTimeInterval(1), + options: options) + #expect(abs((repriced.summary?.totalCostUSD ?? 0) - correctCost) < tolerance) } @Test @@ -479,6 +780,7 @@ struct CostUsageScannerBreakdownTests { day: olderDayKey, model: CostUsagePricing.normalizeCodexModel(model), turnID: nil, + eventIndex: 0, input: 20, cached: 0, output: 0), @@ -486,6 +788,7 @@ struct CostUsageScannerBreakdownTests { day: dayKey, model: CostUsagePricing.normalizeCodexModel(model), turnID: nil, + eventIndex: 1, input: 10, cached: 0, output: 0), @@ -518,7 +821,8 @@ struct CostUsageScannerBreakdownTests { var migratedCache = CostUsageCacheIO.load(provider: .codex, cacheRoot: env.cacheRoot) let migratedUsage = try #require(migratedCache.files[path]) - #expect(migratedUsage.codexRows?.map(\.day) == [olderDayKey]) + #expect(migratedUsage.codexRows?.map(\.day) == [olderDayKey, dayKey, dayKey]) + #expect(migratedUsage.codexRows?.map(\.eventIndex) == [0, 1, 2]) #expect(migratedUsage.codexCostNanos?[dayKey] != nil) let parsedBytes = migratedUsage.parsedBytes @@ -534,6 +838,86 @@ struct CostUsageScannerBreakdownTests { #expect(migratedCache.files[path]?.parsedBytes == parsedBytes) } + @Test + func `codex incremental cost migration retains row identities for archive dedupe`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 5, day: 18) + let dayKey = CostUsageScanner.CostUsageDayRange.dayKey(from: day) + let iso0 = env.isoString(for: day) + let iso1 = env.isoString(for: day.addingTimeInterval(1)) + let iso2 = env.isoString(for: day.addingTimeInterval(2)) + let model = "gpt-5.4" + let sessionMeta: [String: Any] = [ + "type": "session_meta", + "timestamp": iso0, + "payload": ["session_id": "incremental-migration-overlap"], + ] + let turnContext = self.codexTurnContext(timestamp: iso0, model: model) + let firstTokenCount = self.codexTokenCount( + timestamp: iso1, + model: model, + total: (input: 10, cached: 0, output: 0)) + let secondTokenCount = self.codexTokenCount( + timestamp: iso2, + model: model, + total: (input: 15, cached: 0, output: 0)) + let fileURL = try env.writeCodexSessionFile( + day: day, + filename: "incremental-migration-overlap.jsonl", + contents: env.jsonl([sessionMeta, turnContext, firstTokenCount])) + + var options = CostUsageScanner.Options( + codexSessionsRoot: env.codexSessionsRoot, + claudeProjectsRoots: nil, + cacheRoot: env.cacheRoot, + codexTraceDatabaseURL: env.root.appendingPathComponent("missing-traces.sqlite")) + options.refreshMinIntervalSeconds = 0 + + _ = CostUsageScanner.loadDailyReport( + provider: .codex, + since: day, + until: day, + now: day, + options: options) + + var cache = CostUsageCacheIO.load(provider: .codex, cacheRoot: env.cacheRoot) + let path = try #require(cache.files.keys.first) + cache.files[path]?.codexCostNanos = nil + CostUsageCacheIO.save(provider: .codex, cache: cache, cacheRoot: env.cacheRoot) + + let appended = try "\n" + env.jsonl([secondTokenCount]) + let handle = try FileHandle(forWritingTo: fileURL) + try handle.seekToEnd() + try handle.write(contentsOf: Data(appended.utf8)) + try handle.close() + + let appendedReport = CostUsageScanner.loadDailyReport( + provider: .codex, + since: day, + until: day, + now: day.addingTimeInterval(1), + options: options) + #expect(appendedReport.summary?.totalTokens == 15) + + cache = CostUsageCacheIO.load(provider: .codex, cacheRoot: env.cacheRoot) + let activeRows = try #require(cache.files[path]?.codexRows) + #expect(activeRows.map(\.eventIndex) == [0, 1]) + + _ = try env.writeCodexArchivedSessionFile( + filename: "rollout-\(dayKey)T12-00-00-incremental-migration-overlap.jsonl", + contents: env.jsonl([sessionMeta, turnContext, firstTokenCount, secondTokenCount])) + + let overlapReport = CostUsageScanner.loadDailyReport( + provider: .codex, + since: day, + until: day, + now: day.addingTimeInterval(2), + options: options) + #expect(overlapReport.summary?.totalTokens == 15) + } + @Test func `codex split cache migration does not double count existing cost maps`() throws { let env = try CostUsageTestEnvironment() @@ -585,6 +969,7 @@ struct CostUsageScannerBreakdownTests { day: dayKey, model: normalizedModel, turnID: nil, + eventIndex: 0, input: 10, cached: 0, output: 0), @@ -592,6 +977,7 @@ struct CostUsageScannerBreakdownTests { day: dayKey, model: addedModel, turnID: nil, + eventIndex: 1, input: 10, cached: 0, output: 0), @@ -614,7 +1000,7 @@ struct CostUsageScannerBreakdownTests { let expectedCost = 10.0 * 2.5e-6 #expect(abs((report.summary?.totalCostUSD ?? 0) - expectedCost) < 0.000_000_001) let migratedUsage = try #require(CostUsageCacheIO.load(provider: .codex, cacheRoot: env.cacheRoot).files[path]) - #expect(migratedUsage.codexRows == nil) + #expect(migratedUsage.codexRows?.map(\.eventIndex) == [0, 1]) #expect(migratedUsage.codexCostNanos?[dayKey]?[normalizedModel] == originalCostNanos) #expect(migratedUsage.codexCostNanos?[dayKey]?[addedModel] == Int64((10.0 * 5e-6 * 1_000_000_000).rounded())) #expect(migratedUsage.codexStandardTokens?[dayKey]?[normalizedModel] == 10) @@ -771,31 +1157,124 @@ struct CostUsageScannerBreakdownTests { } @Test - func `codex long turn context preserves model attribution`() throws { + func `codex project metadata migration drops unscanned legacy files`() throws { let env = try CostUsageTestEnvironment() defer { env.cleanup() } + let olderDay = try env.makeLocalNoon(year: 2026, month: 5, day: 10) let day = try env.makeLocalNoon(year: 2026, month: 5, day: 18) - let iso0 = env.isoString(for: day) - let iso1 = env.isoString(for: day.addingTimeInterval(1)) - let model = "openai/gpt-5.5" - let turnContext: [String: Any] = [ - "type": "turn_context", - "timestamp": iso0, - "payload": [ - "model": model, - "instructions": String(repeating: "x", count: 40 * 1024), - ], - ] - let tokenCount: [String: Any] = [ - "type": "event_msg", - "timestamp": iso1, - "payload": [ - "type": "token_count", - "info": [ - "last_token_usage": [ - "input_tokens": 100, - "cached_input_tokens": 40, + let model = "gpt-5.4" + let olderProject = env.root.appendingPathComponent("older-project", isDirectory: true).path + let currentProject = env.root.appendingPathComponent("current-project", isDirectory: true).path + let olderFile = try env.writeCodexSessionFile( + day: olderDay, + filename: "older-project.jsonl", + contents: env.jsonl([ + self.codexSessionMeta(timestamp: env.isoString(for: olderDay), id: "older", cwd: olderProject), + self.codexTurnContext(timestamp: env.isoString(for: olderDay), model: model), + self.codexTokenCount( + timestamp: env.isoString(for: olderDay.addingTimeInterval(1)), + model: model, + last: (input: 20, cached: 0, output: 0)), + ])) + _ = try env.writeCodexSessionFile( + day: day, + filename: "current-project.jsonl", + contents: env.jsonl([ + self.codexSessionMeta(timestamp: env.isoString(for: day), id: "current", cwd: currentProject), + self.codexTurnContext(timestamp: env.isoString(for: day), model: model), + self.codexTokenCount( + timestamp: env.isoString(for: day.addingTimeInterval(1)), + model: model, + last: (input: 10, cached: 0, output: 0)), + ])) + + var options = CostUsageScanner.Options( + codexSessionsRoot: env.codexSessionsRoot, + claudeProjectsRoots: nil, + cacheRoot: env.cacheRoot, + codexTraceDatabaseURL: env.root.appendingPathComponent("missing-traces.sqlite")) + options.refreshMinIntervalSeconds = 0 + + let wide = CostUsageScanner.loadDailyReport( + provider: .codex, + since: olderDay, + until: day, + now: day, + options: options) + #expect(wide.summary?.totalTokens == 30) + + var cache = CostUsageCacheIO.load(provider: .codex, cacheRoot: env.cacheRoot) + cache.codexProjectMetadataVersion = nil + for key in cache.files.keys { + cache.files[key]?.projectPath = nil + cache.files[key]?.canonicalProjectPath = nil + } + CostUsageCacheIO.save(provider: .codex, cache: cache, cacheRoot: env.cacheRoot) + + options.refreshMinIntervalSeconds = 60 + let narrow = CostUsageScanner.loadDailyReport( + provider: .codex, + since: day, + until: day, + now: day.addingTimeInterval(1), + options: options) + #expect(narrow.summary?.totalTokens == 10) + + cache = CostUsageCacheIO.load(provider: .codex, cacheRoot: env.cacheRoot) + #expect(cache.codexProjectMetadataVersion == 1) + #expect(cache.scanSinceKey == "2026-05-17") + #expect(cache.scanUntilKey == "2026-05-19") + #expect(cache.files[olderFile.path] == nil) + let migratedProjects = CostUsageScanner.buildCodexProjectBreakdownsFromCache( + cache: cache, + range: CostUsageScanner.CostUsageDayRange(since: olderDay, until: day), + modelsDevCacheRoot: env.cacheRoot) + #expect(!migratedProjects.contains(where: { $0.path == olderProject })) + #expect(migratedProjects.first(where: { $0.path == currentProject })?.totalTokens == 10) + + let repeatedWide = CostUsageScanner.loadDailyReport( + provider: .codex, + since: olderDay, + until: day, + now: day.addingTimeInterval(2), + options: options) + #expect(repeatedWide.summary?.totalTokens == 30) + cache = CostUsageCacheIO.load(provider: .codex, cacheRoot: env.cacheRoot) + let rescannedProjects = CostUsageScanner.buildCodexProjectBreakdownsFromCache( + cache: cache, + range: CostUsageScanner.CostUsageDayRange(since: olderDay, until: day), + modelsDevCacheRoot: env.cacheRoot) + #expect(rescannedProjects.first(where: { $0.path == olderProject })?.totalTokens == 20) + #expect(rescannedProjects.first(where: { $0.path == currentProject })?.totalTokens == 10) + } + + @Test + func `codex long turn context preserves model attribution`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 5, day: 18) + let iso0 = env.isoString(for: day) + let iso1 = env.isoString(for: day.addingTimeInterval(1)) + let model = "openai/gpt-5.5" + let turnContext: [String: Any] = [ + "type": "turn_context", + "timestamp": iso0, + "payload": [ + "model": model, + "instructions": String(repeating: "x", count: 40 * 1024), + ], + ] + let tokenCount: [String: Any] = [ + "type": "event_msg", + "timestamp": iso1, + "payload": [ + "type": "token_count", + "info": [ + "last_token_usage": [ + "input_tokens": 100, + "cached_input_tokens": 40, "output_tokens": 10, ], ], @@ -931,360 +1410,2314 @@ struct CostUsageScannerBreakdownTests { } @Test - func `codex daily report writes corrected cache artifact for oversized turn context`() throws { + func `codex token count without model remains explicitly unknown`() throws { let env = try CostUsageTestEnvironment() defer { env.cleanup() } let day = try env.makeLocalNoon(year: 2026, month: 5, day: 18) - let dayKey = CostUsageScanner.CostUsageDayRange.dayKey(from: day) - let iso0 = env.isoString(for: day) - let iso1 = env.isoString(for: day.addingTimeInterval(1)) - let model = "openai/gpt-5.5" - let turnContextLine = self.oversizedCodexTurnContextLine(timestamp: iso0, model: model) - let tokenCountLine = try env.jsonl([ - self.codexTokenCountWithoutModel(timestamp: iso1, last: (input: 120, cached: 30, output: 12)), + let contents = try env.jsonl([ + self.codexTokenCountWithoutModel( + timestamp: env.isoString(for: day), + last: (input: 50, cached: 10, output: 5)), ]) - - _ = try env.writeCodexSessionFile( + let fileURL = try env.writeCodexSessionFile( day: day, - filename: "cached-oversized-turn-context.jsonl", - contents: turnContextLine + "\n" + tokenCountLine) + filename: "token-count-without-model.jsonl", + contents: contents) - let oldCacheDir = env.cacheRoot.appendingPathComponent("cost-usage", isDirectory: true) - try FileManager.default.createDirectory(at: oldCacheDir, withIntermediateDirectories: true) - let oldCacheURL = oldCacheDir.appendingPathComponent("codex-v7.json", isDirectory: false) - let oldCache = #"{"version":1,"lastScanUnixMs":9999999999999,"files":{},"days":{"\#(dayKey)":"# - + #"{"gpt-5":[999,0,0]}}}"# - try oldCache.write(to: oldCacheURL, atomically: true, encoding: .utf8) + let parsed = CostUsageScanner.parseCodexFile( + fileURL: fileURL, + range: CostUsageScanner.CostUsageDayRange(since: day, until: day)) + let dayKey = CostUsageScanner.CostUsageDayRange.dayKey(from: day) - var options = CostUsageScanner.Options( - codexSessionsRoot: env.codexSessionsRoot, - claudeProjectsRoots: nil, - cacheRoot: env.cacheRoot) - options.refreshMinIntervalSeconds = 3600 + #expect(parsed.days[dayKey]?[CostUsagePricing.codexUnattributedModel] == [50, 10, 5]) + #expect(parsed.days[dayKey]?["gpt-5"] == nil) + } - let first = CostUsageScanner.loadDailyReport( - provider: .codex, - since: day, - until: day, - now: day, - options: options) - #expect(first.data.count == 1) - #expect(first.data[0].modelsUsed == ["gpt-5.5"]) - #expect(first.data[0].modelBreakdowns?.map(\.modelName) == ["gpt-5.5"]) - #expect(first.data[0].totalTokens == 132) + @Test + func `codex turn context remains authoritative over conflicting token model`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } - let newCacheURL = CostUsageCacheIO.cacheFileURL(provider: .codex, cacheRoot: env.cacheRoot) - #expect(newCacheURL.lastPathComponent == "codex-v8.json") - #expect(FileManager.default.fileExists(atPath: newCacheURL.path)) - #expect(FileManager.default.fileExists(atPath: oldCacheURL.path)) + let day = try env.makeLocalNoon(year: 2026, month: 5, day: 18) + let contents = try env.jsonl([ + self.codexTurnContext(timestamp: env.isoString(for: day), model: "openai/gpt-5.5"), + self.codexTokenCount( + timestamp: env.isoString(for: day.addingTimeInterval(1)), + model: "openai/gpt-5.6-sol", + last: (input: 50, cached: 10, output: 5)), + ]) + let fileURL = try env.writeCodexSessionFile( + day: day, + filename: "token-count-model-override.jsonl", + contents: contents) - let second = CostUsageScanner.loadDailyReport( - provider: .codex, - since: day, - until: day, - now: day.addingTimeInterval(60), - options: options) - #expect(second.data.count == 1) - #expect(second.data[0].modelsUsed == ["gpt-5.5"]) - #expect(second.data[0].totalTokens == 132) + let parsed = CostUsageScanner.parseCodexFile( + fileURL: fileURL, + range: CostUsageScanner.CostUsageDayRange(since: day, until: day)) + let dayKey = CostUsageScanner.CostUsageDayRange.dayKey(from: day) + + #expect(parsed.days[dayKey]?["gpt-5.5"] == [50, 10, 5]) + #expect(parsed.days[dayKey]?["gpt-5.6-sol"] == nil) } @Test - func `codex daily report prefers last token usage over divergent totals`() throws { + func `codex turn context blank model falls through to model name`() throws { let env = try CostUsageTestEnvironment() defer { env.cleanup() } - - let day = try env.makeLocalNoon(year: 2026, month: 5, day: 15) - let iso0 = env.isoString(for: day) - let model = "openai/gpt-5.5" - let turnContext = self.codexTurnContext(timestamp: iso0, model: model) - - _ = try env.writeCodexSessionFile( + let day = try env.makeLocalNoon(year: 2026, month: 5, day: 18) + let contextModel = "codexbar-test-context-model-name" + let eventModel = "codexbar-test-event-model" + let turnContext: [String: Any] = [ + "type": "turn_context", + "timestamp": env.isoString(for: day), + "payload": [ + "model": " ", + "model_name": contextModel, + ], + ] + let fileURL = try env.writeCodexSessionFile( day: day, - filename: "session.jsonl", + filename: "blank-context-model.jsonl", contents: env.jsonl([ turnContext, self.codexTokenCount( timestamp: env.isoString(for: day.addingTimeInterval(1)), - model: model, - total: (input: 100, cached: 20, output: 10), - last: (input: 100, cached: 20, output: 10)), - self.codexTokenCount( - timestamp: env.isoString(for: day.addingTimeInterval(2)), - model: model, - total: (input: 160, cached: 40, output: 16), - last: (input: 60, cached: 20, output: 6)), - self.codexTokenCount( - timestamp: env.isoString(for: day.addingTimeInterval(3)), - model: model, - total: (input: 1000, cached: 900, output: 100), - last: (input: 40, cached: 30, output: 5)), - self.codexTokenCount( - timestamp: env.isoString(for: day.addingTimeInterval(4)), - model: model, - total: (input: 1050, cached: 930, output: 110), - last: (input: 50, cached: 30, output: 10)), + model: eventModel, + last: (input: 50, cached: 10, output: 5)), ])) - var options = CostUsageScanner.Options( - codexSessionsRoot: env.codexSessionsRoot, - claudeProjectsRoots: nil, - cacheRoot: env.cacheRoot) - options.refreshMinIntervalSeconds = 0 - options.forceRescan = true - - let report = CostUsageScanner.loadDailyReport( - provider: .codex, - since: day, - until: day, - now: day, - options: options) - let expectedCost = CostUsagePricing.codexCostUSD( - model: model, - inputTokens: 250, - cachedInputTokens: 100, - outputTokens: 31) + let parsed = CostUsageScanner.parseCodexFile( + fileURL: fileURL, + range: CostUsageScanner.CostUsageDayRange(since: day, until: day)) + let dayKey = CostUsageScanner.CostUsageDayRange.dayKey(from: day) - #expect(report.data.count == 1) - #expect(report.data[0].inputTokens == 250) - #expect(report.data[0].outputTokens == 31) - #expect(report.data[0].totalTokens == 281) - #expect(abs((report.data[0].costUSD ?? 0) - (expectedCost ?? 0)) < 0.000001) + #expect(parsed.days[dayKey]?[contextModel] == [50, 10, 5]) + #expect(parsed.days[dayKey]?[eventModel] == nil) } @Test - func `codex repeated total token snapshots do not recount last usage`() throws { + func `codex turn context blank payload fields fall through to nested model name`() throws { let env = try CostUsageTestEnvironment() defer { env.cleanup() } - - let day = try env.makeLocalNoon(year: 2026, month: 5, day: 20) - let model = "openai/gpt-5.5" + let day = try env.makeLocalNoon(year: 2026, month: 5, day: 18) + let contextModel = "codexbar-test-nested-context-model" + let eventModel = "codexbar-test-event-model" + let turnContext: [String: Any] = [ + "type": "turn_context", + "timestamp": env.isoString(for: day), + "payload": [ + "model": " ", + "model_name": " ", + "info": [ + "model": "", + "model_name": contextModel, + ], + ], + ] let fileURL = try env.writeCodexSessionFile( day: day, - filename: "repeated-total-snapshot.jsonl", + filename: "blank-nested-context-model.jsonl", contents: env.jsonl([ - self.codexTurnContext(timestamp: env.isoString(for: day), model: model), + turnContext, self.codexTokenCount( timestamp: env.isoString(for: day.addingTimeInterval(1)), - model: model, - total: (input: 100, cached: 20, output: 10), - last: (input: 100, cached: 20, output: 10)), - self.codexTokenCount( - timestamp: env.isoString(for: day.addingTimeInterval(2)), - model: model, - total: (input: 100, cached: 20, output: 10), - last: (input: 100, cached: 20, output: 10)), - self.codexTokenCount( - timestamp: env.isoString(for: day.addingTimeInterval(3)), - model: model, - total: (input: 130, cached: 20, output: 12), - last: (input: 100, cached: 20, output: 10)), + model: eventModel, + last: (input: 50, cached: 10, output: 5)), ])) let parsed = CostUsageScanner.parseCodexFile( fileURL: fileURL, range: CostUsageScanner.CostUsageDayRange(since: day, until: day)) let dayKey = CostUsageScanner.CostUsageDayRange.dayKey(from: day) - let packed = parsed.days[dayKey]?["gpt-5.5"] ?? [] - #expect(packed[safe: 0] == 130) - #expect(packed[safe: 1] == 20) - #expect(packed[safe: 2] == 12) - #expect(parsed.rows.count == 2) + #expect(parsed.days[dayKey]?[contextModel] == [50, 10, 5]) + #expect(parsed.days[dayKey]?[eventModel] == nil) } @Test - func `codex total only after divergent totals uses raw delta when it continues`() throws { + func `codex oversized turn context blank fields fall through to nested model name`() throws { let env = try CostUsageTestEnvironment() defer { env.cleanup() } - - let day = try env.makeLocalNoon(year: 2026, month: 5, day: 15) - let model = "openai/gpt-5.5" + let day = try env.makeLocalNoon(year: 2026, month: 5, day: 18) + let contextModel = "codexbar-test-oversized-context-model" + let eventModel = "codexbar-test-event-model" + let turnContextLine = self.oversizedCodexTurnContextBlankFallbackLine( + timestamp: env.isoString(for: day), + model: contextModel) + let tokenCountLine = try env.jsonl([ + self.codexTokenCount( + timestamp: env.isoString(for: day.addingTimeInterval(1)), + model: eventModel, + last: (input: 50, cached: 10, output: 5)), + ]) let fileURL = try env.writeCodexSessionFile( day: day, - filename: "mixed-raw-continuing.jsonl", + filename: "oversized-blank-context-model.jsonl", + contents: turnContextLine + "\n" + tokenCountLine) + + let parsed = CostUsageScanner.parseCodexFile( + fileURL: fileURL, + range: CostUsageScanner.CostUsageDayRange(since: day, until: day)) + let dayKey = CostUsageScanner.CostUsageDayRange.dayKey(from: day) + + #expect(parsed.days[dayKey]?[contextModel] == [50, 10, 5]) + #expect(parsed.days[dayKey]?[eventModel] == nil) + } + + @Test + func `codex all blank turn context clears stale model for event evidence`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + let day = try env.makeLocalNoon(year: 2026, month: 5, day: 18) + let staleModel = "codexbar-test-stale-context-model" + let eventModel = "codexbar-test-event-model" + let blankContext: [String: Any] = [ + "type": "turn_context", + "timestamp": env.isoString(for: day.addingTimeInterval(1)), + "payload": [ + "model": "", + "model_name": " ", + "info": [ + "model": " ", + "model_name": "", + ], + ], + ] + let fileURL = try env.writeCodexSessionFile( + day: day, + filename: "all-blank-context-model.jsonl", contents: env.jsonl([ - self.codexTurnContext(timestamp: env.isoString(for: day), model: model), - self.codexTokenCount( - timestamp: env.isoString(for: day.addingTimeInterval(1)), - model: model, - total: (input: 100, cached: 0, output: 0), - last: (input: 100, cached: 0, output: 0)), + self.codexTurnContext(timestamp: env.isoString(for: day), model: staleModel), + blankContext, self.codexTokenCount( timestamp: env.isoString(for: day.addingTimeInterval(2)), - model: model, - total: (input: 1000, cached: 0, output: 0), - last: (input: 40, cached: 0, output: 0)), - self.codexTokenCount( - timestamp: env.isoString(for: day.addingTimeInterval(3)), - model: model, - total: (input: 1050, cached: 0, output: 0)), + model: eventModel, + last: (input: 50, cached: 10, output: 5)), ])) let parsed = CostUsageScanner.parseCodexFile( fileURL: fileURL, range: CostUsageScanner.CostUsageDayRange(since: day, until: day)) let dayKey = CostUsageScanner.CostUsageDayRange.dayKey(from: day) - let packed = parsed.days[dayKey]?["gpt-5.5"] ?? [] - #expect(packed[safe: 0] == 190) - #expect(parsed.lastTotals == nil) + #expect(parsed.days[dayKey]?[eventModel] == [50, 10, 5]) + #expect(parsed.days[dayKey]?[staleModel] == nil) } @Test - func `codex total only after divergent totals preserves zero raw dimensions`() throws { + func `codex all blank turn context clears stale model to unattributed`() throws { let env = try CostUsageTestEnvironment() defer { env.cleanup() } - - let day = try env.makeLocalNoon(year: 2026, month: 5, day: 15) - let model = "openai/gpt-5.5" + let day = try env.makeLocalNoon(year: 2026, month: 5, day: 18) + let staleModel = "codexbar-test-stale-context-model" + let blankContext: [String: Any] = [ + "type": "turn_context", + "timestamp": env.isoString(for: day.addingTimeInterval(1)), + "payload": [ + "model": "", + "model_name": " ", + ], + ] let fileURL = try env.writeCodexSessionFile( day: day, - filename: "mixed-stale-dimension.jsonl", + filename: "all-blank-context-unattributed.jsonl", contents: env.jsonl([ - self.codexTurnContext(timestamp: env.isoString(for: day), model: model), - self.codexTokenCount( - timestamp: env.isoString(for: day.addingTimeInterval(1)), - model: model, - total: (input: 100, cached: 0, output: 0), - last: (input: 100, cached: 0, output: 0)), - self.codexTokenCount( + self.codexTurnContext(timestamp: env.isoString(for: day), model: staleModel), + blankContext, + self.codexTokenCountWithoutModel( timestamp: env.isoString(for: day.addingTimeInterval(2)), - model: model, - total: (input: 1000, cached: 900, output: 0), - last: (input: 40, cached: 0, output: 0)), - self.codexTokenCount( - timestamp: env.isoString(for: day.addingTimeInterval(3)), - model: model, - total: (input: 1050, cached: 900, output: 0)), + last: (input: 50, cached: 10, output: 5)), ])) let parsed = CostUsageScanner.parseCodexFile( fileURL: fileURL, range: CostUsageScanner.CostUsageDayRange(since: day, until: day)) let dayKey = CostUsageScanner.CostUsageDayRange.dayKey(from: day) - let packed = parsed.days[dayKey]?["gpt-5.5"] ?? [] - #expect(packed[safe: 0] == 190) - #expect(packed[safe: 1] == 0) - #expect(parsed.lastTotals == nil) + #expect(parsed.days[dayKey]?[CostUsagePricing.codexUnattributedModel] == [50, 10, 5]) + #expect(parsed.days[dayKey]?[staleModel] == nil) } @Test - func `codex total only after divergent totals can resume from counted baseline`() throws { + func `codex incomplete oversized blank context preserves stale model`() throws { let env = try CostUsageTestEnvironment() defer { env.cleanup() } + let day = try env.makeLocalNoon(year: 2026, month: 5, day: 18) + let staleModel = "codexbar-test-stale-context-model" + let eventModel = "codexbar-test-event-model" + let blankContextLine = self.oversizedCodexTurnContextAllBlankLine( + timestamp: env.isoString(for: day.addingTimeInterval(1))) + let tokenCountLine = try env.jsonl([ + self.codexTokenCount( + timestamp: env.isoString(for: day.addingTimeInterval(2)), + model: eventModel, + last: (input: 50, cached: 10, output: 5)), + ]) + let fileURL = try env.writeCodexSessionFile( + day: day, + filename: "oversized-all-blank-context-model.jsonl", + contents: env.jsonl([ + self.codexTurnContext(timestamp: env.isoString(for: day), model: staleModel), + ]) + blankContextLine + "\n" + tokenCountLine) - let day = try env.makeLocalNoon(year: 2026, month: 5, day: 15) - let model = "openai/gpt-5.5" + let parsed = CostUsageScanner.parseCodexFile( + fileURL: fileURL, + range: CostUsageScanner.CostUsageDayRange(since: day, until: day)) + let dayKey = CostUsageScanner.CostUsageDayRange.dayKey(from: day) + + #expect(parsed.days[dayKey]?[staleModel] == [50, 10, 5]) + #expect(parsed.days[dayKey]?[eventModel] == nil) + } + + @Test + func `codex oversized closed blank payload clears stale model`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + let day = try env.makeLocalNoon(year: 2026, month: 5, day: 18) + let staleModel = "codexbar-test-stale-context-model" + let eventModel = "codexbar-test-event-model" + let blankContextLine = self.oversizedCodexTurnContextClosedBlankPayloadLine( + timestamp: env.isoString(for: day.addingTimeInterval(1))) + let tokenCountLine = try env.jsonl([ + self.codexTokenCount( + timestamp: env.isoString(for: day.addingTimeInterval(2)), + model: eventModel, + last: (input: 50, cached: 10, output: 5)), + ]) let fileURL = try env.writeCodexSessionFile( day: day, - filename: "mixed-counted-resume.jsonl", + filename: "oversized-closed-blank-context-model.jsonl", contents: env.jsonl([ - self.codexTurnContext(timestamp: env.isoString(for: day), model: model), - self.codexTokenCount( - timestamp: env.isoString(for: day.addingTimeInterval(1)), - model: model, - total: (input: 100, cached: 0, output: 0), - last: (input: 100, cached: 0, output: 0)), - self.codexTokenCount( - timestamp: env.isoString(for: day.addingTimeInterval(2)), - model: model, - total: (input: 1000, cached: 0, output: 0), - last: (input: 40, cached: 0, output: 0)), - self.codexTokenCount( - timestamp: env.isoString(for: day.addingTimeInterval(3)), - model: model, - total: (input: 180, cached: 0, output: 0)), - ])) + self.codexTurnContext(timestamp: env.isoString(for: day), model: staleModel), + ]) + blankContextLine + "\n" + tokenCountLine) let parsed = CostUsageScanner.parseCodexFile( fileURL: fileURL, range: CostUsageScanner.CostUsageDayRange(since: day, until: day)) let dayKey = CostUsageScanner.CostUsageDayRange.dayKey(from: day) - let packed = parsed.days[dayKey]?["gpt-5.5"] ?? [] - #expect(packed[safe: 0] == 180) - #expect(parsed.lastTotals?.input == 180) + #expect(parsed.days[dayKey]?[eventModel] == [50, 10, 5]) + #expect(parsed.days[dayKey]?[staleModel] == nil) } @Test - func `codex total only after last only counts from last based baseline`() throws { + func `codex foundation fallback skips blank turn context candidates`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + let day = try env.makeLocalNoon(year: 2026, month: 5, day: 18) + let contextModel = "codexbar-test-foundation-context-model" + let eventModel = "codexbar-test-event-model" + // The escaped root key bypasses the byte-fast parser. The nested marker admits the line + // through the cheap prefilter so JSONSerialization exercises the Foundation fallback. + let turnContextLine = #"{"\u0074ype":"turn_context","marker":{"type":"turn_context"},"timestamp":""# + + env.isoString(for: day) + + #"","payload":{"model":" ","model_name":"","info":{"model":" ","model_name":""# + + contextModel + + #""}}}"# + let tokenCountLine = try env.jsonl([ + self.codexTokenCount( + timestamp: env.isoString(for: day.addingTimeInterval(1)), + model: eventModel, + last: (input: 50, cached: 10, output: 5)), + ]) + let fileURL = try env.writeCodexSessionFile( + day: day, + filename: "foundation-blank-context-model.jsonl", + contents: turnContextLine + "\n" + tokenCountLine) + + let parsed = CostUsageScanner.parseCodexFile( + fileURL: fileURL, + range: CostUsageScanner.CostUsageDayRange(since: day, until: day)) + let dayKey = CostUsageScanner.CostUsageDayRange.dayKey(from: day) + + #expect(parsed.days[dayKey]?[contextModel] == [50, 10, 5]) + #expect(parsed.days[dayKey]?[eventModel] == nil) + } + + @Test + func `codex foundation fallback preserves reasoning output tokens`() throws { let env = try CostUsageTestEnvironment() defer { env.cleanup() } + let day = try env.makeLocalNoon(year: 2026, month: 5, day: 18) + let timestamp = env.isoString(for: day) + // Escaping the root type key bypasses the byte-fast parser. The literal nested marker + // keeps the line eligible for the Foundation fallback prefilter. + let line = #"{"\u0074ype":"event_msg","marker":{"type":"event_msg"},"timestamp":""# + + timestamp + + #"","payload":{"type":"token_count","info":{"model":"gpt-5.5","last_token_usage":{"# + + #""input_tokens":10,"cached_input_tokens":2,"output_tokens":7,"reasoning_output_tokens":4}}}}"# + let fileURL = try env.writeCodexSessionFile( + day: day, + filename: "foundation-reasoning.jsonl", + contents: line) - let day = try env.makeLocalNoon(year: 2026, month: 5, day: 15) - let model = "openai/gpt-5.5" + let parsed = CostUsageScanner.parseCodexFile( + fileURL: fileURL, + range: CostUsageScanner.CostUsageDayRange(since: day, until: day)) + + #expect(parsed.rows.count == 1) + #expect(parsed.rows.first?.output == 7) + #expect(parsed.rows.first?.reasoning == 4) + } + + @Test + func `codex foundation fallback all blank context clears stale model`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + let day = try env.makeLocalNoon(year: 2026, month: 5, day: 18) + let staleModel = "codexbar-test-stale-context-model" + let eventModel = "codexbar-test-event-model" + let blankContextLine = #"{"\u0074ype":"turn_context","marker":{"type":"turn_context"},"timestamp":""# + + env.isoString(for: day.addingTimeInterval(1)) + + #"","payload":{"model":"","model_name":" ","info":{"model":" ","model_name":""}}}"# + let tokenCountLine = try env.jsonl([ + self.codexTokenCount( + timestamp: env.isoString(for: day.addingTimeInterval(2)), + model: eventModel, + last: (input: 50, cached: 10, output: 5)), + ]) let fileURL = try env.writeCodexSessionFile( day: day, - filename: "last-then-total.jsonl", + filename: "foundation-all-blank-context-model.jsonl", contents: env.jsonl([ - self.codexTurnContext(timestamp: env.isoString(for: day), model: model), - self.codexTokenCount( - timestamp: env.isoString(for: day.addingTimeInterval(1)), - model: model, - last: (input: 100, cached: 0, output: 0)), - self.codexTokenCount( - timestamp: env.isoString(for: day.addingTimeInterval(2)), - model: model, - total: (input: 150, cached: 0, output: 0)), - ])) + self.codexTurnContext(timestamp: env.isoString(for: day), model: staleModel), + ]) + blankContextLine + "\n" + tokenCountLine) let parsed = CostUsageScanner.parseCodexFile( fileURL: fileURL, range: CostUsageScanner.CostUsageDayRange(since: day, until: day)) let dayKey = CostUsageScanner.CostUsageDayRange.dayKey(from: day) - let packed = parsed.days[dayKey]?["gpt-5.5"] ?? [] - #expect(packed[safe: 0] == 150) - #expect(parsed.lastTotals?.input == 150) + #expect(parsed.days[dayKey]?[eventModel] == [50, 10, 5]) + #expect(parsed.days[dayKey]?[staleModel] == nil) } @Test - func `codex daily report includes archived sessions and dedupes`() throws { + func `codex blank token count model preserves turn context`() throws { let env = try CostUsageTestEnvironment() defer { env.cleanup() } - let day = try env.makeLocalNoon(year: 2025, month: 12, day: 22) - let iso0 = env.isoString(for: day) - let iso1 = env.isoString(for: day.addingTimeInterval(1)) + let day = try env.makeLocalNoon(year: 2026, month: 5, day: 18) + let contents = try env.jsonl([ + self.codexTurnContext(timestamp: env.isoString(for: day), model: "openai/gpt-5.5"), + self.codexTokenCount( + timestamp: env.isoString(for: day.addingTimeInterval(1)), + model: " ", + last: (input: 50, cached: 10, output: 5)), + ]) + let fileURL = try env.writeCodexSessionFile( + day: day, + filename: "blank-token-count-model.jsonl", + contents: contents) - let model = "openai/gpt-5.2-codex" - let sessionMeta: [String: Any] = [ - "type": "session_meta", - "payload": [ - "session_id": "sess-archived-1", - ], - ] - let turnContext: [String: Any] = [ - "type": "turn_context", - "timestamp": iso0, - "payload": [ - "model": model, - ], - ] - let tokenCount: [String: Any] = [ + let parsed = CostUsageScanner.parseCodexFile( + fileURL: fileURL, + range: CostUsageScanner.CostUsageDayRange(since: day, until: day)) + let dayKey = CostUsageScanner.CostUsageDayRange.dayKey(from: day) + + #expect(parsed.days[dayKey]?["gpt-5.5"] == [50, 10, 5]) + #expect(parsed.days[dayKey]?[""] == nil) + } + + @Test + func `codex blank model falls through to model name`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 5, day: 18) + let event: [String: Any] = [ "type": "event_msg", - "timestamp": iso1, + "timestamp": env.isoString(for: day), "payload": [ "type": "token_count", "info": [ - "total_token_usage": [ - "input_tokens": 100, - "cached_input_tokens": 20, - "output_tokens": 10, + "model": "", + "model_name": " openai/gpt-5.6-sol ", + "last_token_usage": [ + "input_tokens": 50, + "cached_input_tokens": 10, + "output_tokens": 5, ], - "model": model, ], ], ] + let contents = try env.jsonl([event]) + let fileURL = try env.writeCodexSessionFile( + day: day, + filename: "blank-model-valid-model-name.jsonl", + contents: contents) + + let parsed = CostUsageScanner.parseCodexFile( + fileURL: fileURL, + range: CostUsageScanner.CostUsageDayRange(since: day, until: day)) + let dayKey = CostUsageScanner.CostUsageDayRange.dayKey(from: day) + + #expect(parsed.days[dayKey]?["gpt-5.6-sol"] == [50, 10, 5]) + #expect(parsed.days[dayKey]?[""] == nil) + } + + @Test + func `codex daily report writes corrected cache artifact for oversized turn context`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 5, day: 18) + let dayKey = CostUsageScanner.CostUsageDayRange.dayKey(from: day) + let iso0 = env.isoString(for: day) + let iso1 = env.isoString(for: day.addingTimeInterval(1)) + let model = "openai/gpt-5.5" + let turnContextLine = self.oversizedCodexTurnContextLine(timestamp: iso0, model: model) + let tokenCountLine = try env.jsonl([ + self.codexTokenCountWithoutModel(timestamp: iso1, last: (input: 120, cached: 30, output: 12)), + ]) + + _ = try env.writeCodexSessionFile( + day: day, + filename: "cached-oversized-turn-context.jsonl", + contents: turnContextLine + "\n" + tokenCountLine) + + let oldCacheDir = env.cacheRoot.appendingPathComponent("cost-usage", isDirectory: true) + try FileManager.default.createDirectory(at: oldCacheDir, withIntermediateDirectories: true) + let oldCacheURL = oldCacheDir.appendingPathComponent("codex-v7.json", isDirectory: false) + let oldCache = #"{"version":1,"lastScanUnixMs":9999999999999,"files":{},"days":{"\#(dayKey)":"# + + #"{"gpt-5":[999,0,0]}}}"# + try oldCache.write(to: oldCacheURL, atomically: true, encoding: .utf8) + + var options = CostUsageScanner.Options( + codexSessionsRoot: env.codexSessionsRoot, + claudeProjectsRoots: nil, + cacheRoot: env.cacheRoot) + options.refreshMinIntervalSeconds = 3600 + + let first = CostUsageScanner.loadDailyReport( + provider: .codex, + since: day, + until: day, + now: day, + options: options) + #expect(first.data.count == 1) + #expect(first.data[0].modelsUsed == ["gpt-5.5"]) + #expect(first.data[0].modelBreakdowns?.map(\.modelName) == ["gpt-5.5"]) + #expect(first.data[0].totalTokens == 132) + + let newCacheURL = CostUsageCacheIO.cacheFileURL(provider: .codex, cacheRoot: env.cacheRoot) + #expect(newCacheURL.lastPathComponent == "codex-v11.json") + #expect(FileManager.default.fileExists(atPath: newCacheURL.path)) + #expect(FileManager.default.fileExists(atPath: oldCacheURL.path)) + + let second = CostUsageScanner.loadDailyReport( + provider: .codex, + since: day, + until: day, + now: day.addingTimeInterval(60), + options: options) + #expect(second.data.count == 1) + #expect(second.data[0].modelsUsed == ["gpt-5.5"]) + #expect(second.data[0].totalTokens == 132) + } + + @Test + func `codex daily report prefers last token usage over divergent totals`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 5, day: 15) + let iso0 = env.isoString(for: day) + let model = "openai/gpt-5.5" + let turnContext = self.codexTurnContext(timestamp: iso0, model: model) + + _ = try env.writeCodexSessionFile( + day: day, + filename: "session.jsonl", + contents: env.jsonl([ + turnContext, + self.codexTokenCount( + timestamp: env.isoString(for: day.addingTimeInterval(1)), + model: model, + total: (input: 100, cached: 20, output: 10), + last: (input: 100, cached: 20, output: 10)), + self.codexTokenCount( + timestamp: env.isoString(for: day.addingTimeInterval(2)), + model: model, + total: (input: 160, cached: 40, output: 16), + last: (input: 60, cached: 20, output: 6)), + self.codexTokenCount( + timestamp: env.isoString(for: day.addingTimeInterval(3)), + model: model, + total: (input: 1000, cached: 900, output: 100), + last: (input: 40, cached: 30, output: 5)), + self.codexTokenCount( + timestamp: env.isoString(for: day.addingTimeInterval(4)), + model: model, + total: (input: 1050, cached: 930, output: 110), + last: (input: 50, cached: 30, output: 10)), + ])) + + var options = CostUsageScanner.Options( + codexSessionsRoot: env.codexSessionsRoot, + claudeProjectsRoots: nil, + cacheRoot: env.cacheRoot) + options.refreshMinIntervalSeconds = 0 + options.forceRescan = true + + let report = CostUsageScanner.loadDailyReport( + provider: .codex, + since: day, + until: day, + now: day, + options: options) + let expectedCost = CostUsagePricing.codexCostUSD( + model: model, + inputTokens: 250, + cachedInputTokens: 100, + outputTokens: 31) + + #expect(report.data.count == 1) + #expect(report.data[0].inputTokens == 250) + #expect(report.data[0].outputTokens == 31) + #expect(report.data[0].totalTokens == 281) + #expect(abs((report.data[0].costUSD ?? 0) - (expectedCost ?? 0)) < 0.000001) + } + + @Test + func `codex repeated total token snapshots do not recount last usage`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 5, day: 20) + let model = "openai/gpt-5.5" + let fileURL = try env.writeCodexSessionFile( + day: day, + filename: "repeated-total-snapshot.jsonl", + contents: env.jsonl([ + self.codexTurnContext(timestamp: env.isoString(for: day), model: model), + self.codexTokenCount( + timestamp: env.isoString(for: day.addingTimeInterval(1)), + model: model, + total: (input: 100, cached: 20, output: 10), + last: (input: 100, cached: 20, output: 10)), + self.codexTokenCount( + timestamp: env.isoString(for: day.addingTimeInterval(2)), + model: model, + total: (input: 100, cached: 20, output: 10), + last: (input: 100, cached: 20, output: 10)), + self.codexTokenCount( + timestamp: env.isoString(for: day.addingTimeInterval(3)), + model: model, + total: (input: 130, cached: 20, output: 12), + last: (input: 100, cached: 20, output: 10)), + ])) + + let parsed = CostUsageScanner.parseCodexFile( + fileURL: fileURL, + range: CostUsageScanner.CostUsageDayRange(since: day, until: day)) + let dayKey = CostUsageScanner.CostUsageDayRange.dayKey(from: day) + let packed = parsed.days[dayKey]?["gpt-5.5"] ?? [] + + #expect(packed[safe: 0] == 130) + #expect(packed[safe: 1] == 20) + #expect(packed[safe: 2] == 12) + #expect(parsed.rows.count == 2) + } + + @Test + func `codex repeated divergent snapshots do not recount last usage`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 5, day: 20) + let model = "openai/gpt-5.5" + let repeated = (1...3).map { offset in + self.codexTokenCount( + timestamp: env.isoString(for: day.addingTimeInterval(TimeInterval(offset))), + model: model, + total: (input: 50, cached: 0, output: 0), + last: (input: 100, cached: 0, output: 0)) + } + let fileURL = try env.writeCodexSessionFile( + day: day, + filename: "repeated-divergent-snapshot.jsonl", + contents: env.jsonl([ + self.codexTurnContext(timestamp: env.isoString(for: day), model: model), + ] + repeated)) + + let parsed = CostUsageScanner.parseCodexFile( + fileURL: fileURL, + range: CostUsageScanner.CostUsageDayRange(since: day, until: day)) + let dayKey = CostUsageScanner.CostUsageDayRange.dayKey(from: day) + let packed = parsed.days[dayKey]?["gpt-5.5"] ?? [] + + #expect(packed[safe: 0] == 100) + #expect(parsed.rows.count == 1) + } + + @Test + func `codex total only after divergent totals uses raw delta when it continues`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 5, day: 15) + let model = "openai/gpt-5.5" + let fileURL = try env.writeCodexSessionFile( + day: day, + filename: "mixed-raw-continuing.jsonl", + contents: env.jsonl([ + self.codexTurnContext(timestamp: env.isoString(for: day), model: model), + self.codexTokenCount( + timestamp: env.isoString(for: day.addingTimeInterval(1)), + model: model, + total: (input: 100, cached: 0, output: 0), + last: (input: 100, cached: 0, output: 0)), + self.codexTokenCount( + timestamp: env.isoString(for: day.addingTimeInterval(2)), + model: model, + total: (input: 1000, cached: 0, output: 0), + last: (input: 40, cached: 0, output: 0)), + self.codexTokenCount( + timestamp: env.isoString(for: day.addingTimeInterval(3)), + model: model, + total: (input: 1050, cached: 0, output: 0)), + ])) + + let parsed = CostUsageScanner.parseCodexFile( + fileURL: fileURL, + range: CostUsageScanner.CostUsageDayRange(since: day, until: day)) + let dayKey = CostUsageScanner.CostUsageDayRange.dayKey(from: day) + let packed = parsed.days[dayKey]?["gpt-5.5"] ?? [] + + #expect(packed[safe: 0] == 190) + #expect(parsed.lastTotals == nil) + } + + @Test + func `codex total only after divergent totals preserves zero raw dimensions`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 5, day: 15) + let model = "openai/gpt-5.5" + let fileURL = try env.writeCodexSessionFile( + day: day, + filename: "mixed-stale-dimension.jsonl", + contents: env.jsonl([ + self.codexTurnContext(timestamp: env.isoString(for: day), model: model), + self.codexTokenCount( + timestamp: env.isoString(for: day.addingTimeInterval(1)), + model: model, + total: (input: 100, cached: 0, output: 0), + last: (input: 100, cached: 0, output: 0)), + self.codexTokenCount( + timestamp: env.isoString(for: day.addingTimeInterval(2)), + model: model, + total: (input: 1000, cached: 900, output: 0), + last: (input: 40, cached: 0, output: 0)), + self.codexTokenCount( + timestamp: env.isoString(for: day.addingTimeInterval(3)), + model: model, + total: (input: 1050, cached: 900, output: 0)), + ])) + + let parsed = CostUsageScanner.parseCodexFile( + fileURL: fileURL, + range: CostUsageScanner.CostUsageDayRange(since: day, until: day)) + let dayKey = CostUsageScanner.CostUsageDayRange.dayKey(from: day) + let packed = parsed.days[dayKey]?["gpt-5.5"] ?? [] + + #expect(packed[safe: 0] == 190) + #expect(packed[safe: 1] == 0) + #expect(parsed.lastTotals == nil) + } + + @Test + func `codex total only after divergent totals can resume from counted baseline`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 5, day: 15) + let model = "openai/gpt-5.5" + let fileURL = try env.writeCodexSessionFile( + day: day, + filename: "mixed-counted-resume.jsonl", + contents: env.jsonl([ + self.codexTurnContext(timestamp: env.isoString(for: day), model: model), + self.codexTokenCount( + timestamp: env.isoString(for: day.addingTimeInterval(1)), + model: model, + total: (input: 100, cached: 0, output: 0), + last: (input: 100, cached: 0, output: 0)), + self.codexTokenCount( + timestamp: env.isoString(for: day.addingTimeInterval(2)), + model: model, + total: (input: 1000, cached: 0, output: 0), + last: (input: 40, cached: 0, output: 0)), + self.codexTokenCount( + timestamp: env.isoString(for: day.addingTimeInterval(3)), + model: model, + total: (input: 180, cached: 0, output: 0)), + ])) + + let parsed = CostUsageScanner.parseCodexFile( + fileURL: fileURL, + range: CostUsageScanner.CostUsageDayRange(since: day, until: day)) + let dayKey = CostUsageScanner.CostUsageDayRange.dayKey(from: day) + let packed = parsed.days[dayKey]?["gpt-5.5"] ?? [] + + #expect(packed[safe: 0] == 180) + #expect(parsed.lastTotals?.input == 180) + } + + @Test + func `codex total only after last only counts from last based baseline`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 5, day: 15) + let model = "openai/gpt-5.5" + let fileURL = try env.writeCodexSessionFile( + day: day, + filename: "last-then-total.jsonl", + contents: env.jsonl([ + self.codexTurnContext(timestamp: env.isoString(for: day), model: model), + self.codexTokenCount( + timestamp: env.isoString(for: day.addingTimeInterval(1)), + model: model, + last: (input: 100, cached: 0, output: 0)), + self.codexTokenCount( + timestamp: env.isoString(for: day.addingTimeInterval(2)), + model: model, + total: (input: 150, cached: 0, output: 0)), + ])) + + let parsed = CostUsageScanner.parseCodexFile( + fileURL: fileURL, + range: CostUsageScanner.CostUsageDayRange(since: day, until: day)) + let dayKey = CostUsageScanner.CostUsageDayRange.dayKey(from: day) + let packed = parsed.days[dayKey]?["gpt-5.5"] ?? [] + + #expect(packed[safe: 0] == 150) + #expect(parsed.lastTotals?.input == 150) + } + + @Test + func `codex interleaved cumulative lineages do not recount the gap`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 6, day: 10) + let model = "openai/gpt-5.5" + // Two interleaved totals-only lineages in one file (Ultra sub-agents, #2037). The old + // single-baseline logic recounted the A/B gap on every flip (100k + 96k + 96k = 292k). + let fileURL = try env.writeCodexSessionFile( + day: day, + filename: "interleaved-lineages.jsonl", + contents: env.jsonl([ + self.codexTurnContext(timestamp: env.isoString(for: day), model: model), + self.codexTokenCount( + timestamp: env.isoString(for: day.addingTimeInterval(1)), + model: model, + total: (input: 100_000, cached: 0, output: 0)), + self.codexTokenCount( + timestamp: env.isoString(for: day.addingTimeInterval(2)), + model: model, + total: (input: 5000, cached: 0, output: 0)), + self.codexTokenCount( + timestamp: env.isoString(for: day.addingTimeInterval(3)), + model: model, + total: (input: 101_000, cached: 0, output: 0)), + self.codexTokenCount( + timestamp: env.isoString(for: day.addingTimeInterval(4)), + model: model, + total: (input: 6000, cached: 0, output: 0)), + self.codexTokenCount( + timestamp: env.isoString(for: day.addingTimeInterval(5)), + model: model, + total: (input: 102_000, cached: 0, output: 0)), + ])) + + let parsed = CostUsageScanner.parseCodexFile( + fileURL: fileURL, + range: CostUsageScanner.CostUsageDayRange(since: day, until: day)) + let dayKey = CostUsageScanner.CostUsageDayRange.dayKey(from: day) + let packed = parsed.days[dayKey]?["gpt-5.5"] ?? [] + + #expect(packed[safe: 0] == 102_000) + #expect(parsed.rows.count == 3) + #expect(parsed.hasInterleavedTotals) + } + + @Test + func `codex resolved fork subtracts inherited reasoning baseline`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 6, day: 10) + let timestamp = env.isoString(for: day) + let model = "openai/gpt-5.5" + let fileURL = try env.writeCodexSessionFile( + day: day, + filename: "resolved-fork-reasoning.jsonl", + contents: env.jsonl([ + [ + "type": "session_meta", + "timestamp": timestamp, + "payload": [ + "id": "child-session", + "forked_from_id": "parent-session", + "timestamp": timestamp, + ], + ], + self.codexTurnContext(timestamp: timestamp, model: model), + self.codexTokenCount( + timestamp: env.isoString(for: day.addingTimeInterval(1)), + model: model, + total: (input: 110, cached: 0, output: 60), + totalReasoning: 24), + ])) + + let parsed = CostUsageScanner.parseCodexFile( + fileURL: fileURL, + range: CostUsageScanner.CostUsageDayRange(since: day, until: day), + inheritedTotalsResolver: { parentSessionID, _ in + #expect(parentSessionID == "parent-session") + return .resolved(.init(input: 100, cached: 0, output: 50, reasoning: 20)) + }) + + #expect(parsed.rows.count == 1) + #expect(parsed.rows.first?.input == 10) + #expect(parsed.rows.first?.output == 10) + #expect(parsed.rows.first?.reasoning == 4) + } + + @Test + func `codex interleaved containment carries reasoning without adding it to output`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 6, day: 10) + let model = "openai/gpt-5.5" + let fileURL = try env.writeCodexSessionFile( + day: day, + filename: "interleaved-reasoning.jsonl", + contents: env.jsonl([ + self.codexTurnContext(timestamp: env.isoString(for: day), model: model), + self.codexTokenCount( + timestamp: env.isoString(for: day.addingTimeInterval(1)), + model: model, + total: (input: 0, cached: 0, output: 100), + totalReasoning: 60), + self.codexTokenCount( + timestamp: env.isoString(for: day.addingTimeInterval(2)), + model: model, + total: (input: 0, cached: 0, output: 50), + totalReasoning: 30), + self.codexTokenCount( + timestamp: env.isoString(for: day.addingTimeInterval(3)), + model: model, + total: (input: 0, cached: 0, output: 105), + totalReasoning: 63), + self.codexTokenCount( + timestamp: env.isoString(for: day.addingTimeInterval(4)), + model: model, + total: (input: 0, cached: 0, output: 55), + totalReasoning: 33), + self.codexTokenCount( + timestamp: env.isoString(for: day.addingTimeInterval(5)), + model: model, + total: (input: 0, cached: 0, output: 110), + totalReasoning: 66), + ])) + + let parsed = CostUsageScanner.parseCodexFile( + fileURL: fileURL, + range: CostUsageScanner.CostUsageDayRange(since: day, until: day)) + + #expect(parsed.rows.map(\.output) == [100, 5, 5]) + #expect(parsed.rows.compactMap(\.reasoning) == [60, 3, 3]) + #expect(parsed.rows.reduce(0) { $0 + $1.output } == 110) + #expect(parsed.rows.compactMap(\.reasoning).reduce(0, +) == 66) + #expect(parsed.hasInterleavedTotals) + } + + @Test + func `codex alternating repeated snapshots count zero`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 6, day: 10) + let model = "openai/gpt-5.5" + // Alternating re-emissions with fat `last` on every row. Post-latch containment caps + // `last` by the contained totals delta (zero on lineage flips), so repeats cannot inflate + // even without relying on the seen-set FIFO. + let fileURL = try env.writeCodexSessionFile( + day: day, + filename: "alternating-repeats.jsonl", + contents: env.jsonl([ + self.codexTurnContext(timestamp: env.isoString(for: day), model: model), + self.codexTokenCount( + timestamp: env.isoString(for: day.addingTimeInterval(1)), + model: model, + total: (input: 1000, cached: 0, output: 0), + last: (input: 1000, cached: 0, output: 0)), + self.codexTokenCount( + timestamp: env.isoString(for: day.addingTimeInterval(2)), + model: model, + total: (input: 50, cached: 0, output: 0), + last: (input: 50, cached: 0, output: 0)), + self.codexTokenCount( + timestamp: env.isoString(for: day.addingTimeInterval(3)), + model: model, + total: (input: 1000, cached: 0, output: 0), + last: (input: 1000, cached: 0, output: 0)), + self.codexTokenCount( + timestamp: env.isoString(for: day.addingTimeInterval(4)), + model: model, + total: (input: 50, cached: 0, output: 0), + last: (input: 50, cached: 0, output: 0)), + self.codexTokenCount( + timestamp: env.isoString(for: day.addingTimeInterval(5)), + model: model, + total: (input: 1000, cached: 0, output: 0), + last: (input: 1000, cached: 0, output: 0)), + ])) + + let parsed = CostUsageScanner.parseCodexFile( + fileURL: fileURL, + range: CostUsageScanner.CostUsageDayRange(since: day, until: day)) + let dayKey = CostUsageScanner.CostUsageDayRange.dayKey(from: day) + let packed = parsed.days[dayKey]?["gpt-5.5"] ?? [] + + // Phase 1: smaller lineage below the watermark is dropped (50 never counted). + #expect(packed[safe: 0] == 1000) + #expect(parsed.rows.count == 1) + #expect(parsed.hasInterleavedTotals) + } + + @Test + func `codex totals only growth below watermark is conservatively dropped`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 6, day: 10) + let model = "openai/gpt-5.5" + // Accepted Phase 1 limitation: a totals-only lineage growing beneath another lineage's + // watermark (5000 -> 7000) contributes nothing. Undercount, never inflate. + let fileURL = try env.writeCodexSessionFile( + day: day, + filename: "below-watermark-growth.jsonl", + contents: env.jsonl([ + self.codexTurnContext(timestamp: env.isoString(for: day), model: model), + self.codexTokenCount( + timestamp: env.isoString(for: day.addingTimeInterval(1)), + model: model, + total: (input: 100_000, cached: 0, output: 0)), + self.codexTokenCount( + timestamp: env.isoString(for: day.addingTimeInterval(2)), + model: model, + total: (input: 5000, cached: 0, output: 0)), + self.codexTokenCount( + timestamp: env.isoString(for: day.addingTimeInterval(3)), + model: model, + total: (input: 7000, cached: 0, output: 0)), + self.codexTokenCount( + timestamp: env.isoString(for: day.addingTimeInterval(4)), + model: model, + total: (input: 100_500, cached: 0, output: 0)), + ])) + + let parsed = CostUsageScanner.parseCodexFile( + fileURL: fileURL, + range: CostUsageScanner.CostUsageDayRange(since: day, until: day)) + let dayKey = CostUsageScanner.CostUsageDayRange.dayKey(from: day) + let packed = parsed.days[dayKey]?["gpt-5.5"] ?? [] + + #expect(packed[safe: 0] == 100_500) + #expect(parsed.rows.count == 2) + } + + @Test + func `codex single lineage counter reset undercounts but never inflates`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 6, day: 10) + let model = "openai/gpt-5.5" + // A genuine counter reset latches interleaved mode; totals-only growth below the old + // peak is dropped and counting resumes once the counter passes the watermark. + let fileURL = try env.writeCodexSessionFile( + day: day, + filename: "counter-reset.jsonl", + contents: env.jsonl([ + self.codexTurnContext(timestamp: env.isoString(for: day), model: model), + self.codexTokenCount( + timestamp: env.isoString(for: day.addingTimeInterval(1)), + model: model, + total: (input: 1000, cached: 0, output: 0)), + self.codexTokenCount( + timestamp: env.isoString(for: day.addingTimeInterval(2)), + model: model, + total: (input: 1200, cached: 0, output: 0)), + self.codexTokenCount( + timestamp: env.isoString(for: day.addingTimeInterval(3)), + model: model, + total: (input: 300, cached: 0, output: 0)), + self.codexTokenCount( + timestamp: env.isoString(for: day.addingTimeInterval(4)), + model: model, + total: (input: 800, cached: 0, output: 0)), + self.codexTokenCount( + timestamp: env.isoString(for: day.addingTimeInterval(5)), + model: model, + total: (input: 1500, cached: 0, output: 0)), + ])) + + let parsed = CostUsageScanner.parseCodexFile( + fileURL: fileURL, + range: CostUsageScanner.CostUsageDayRange(since: day, until: day)) + let dayKey = CostUsageScanner.CostUsageDayRange.dayKey(from: day) + let packed = parsed.days[dayKey]?["gpt-5.5"] ?? [] + + #expect(packed[safe: 0] == 1500) + #expect(parsed.rows.count == 3) + } + + @Test + func `codex interleaved fork child caps last by contained total delta`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 6, day: 10) + let iso0 = env.isoString(for: day) + let model = "openai/gpt-5.5" + // Phase 1: after latch, min(last, containedTotalDelta). The mid-row last=5 is dropped + // because contained delta is 0 below the watermark; only watermark advances count. + let fileURL = try env.writeCodexSessionFile( + day: day, + filename: "rollout-\(iso0)-interleaved-fork-child.jsonl", + contents: env.jsonl([ + [ + "type": "session_meta", + "timestamp": iso0, + "payload": [ + "id": "child-session", + "forked_from_id": "parent-session", + "timestamp": iso0, + ], + ], + self.codexTurnContext(timestamp: iso0, model: model), + self.codexTokenCount( + timestamp: env.isoString(for: day.addingTimeInterval(1)), + model: model, + total: (input: 1010, cached: 0, output: 0), + last: (input: 10, cached: 0, output: 0)), + self.codexTokenCount( + timestamp: env.isoString(for: day.addingTimeInterval(2)), + model: model, + total: (input: 505, cached: 0, output: 0), + last: (input: 5, cached: 0, output: 0)), + self.codexTokenCount( + timestamp: env.isoString(for: day.addingTimeInterval(3)), + model: model, + total: (input: 1020, cached: 0, output: 0), + last: (input: 10, cached: 0, output: 0)), + ])) + + let parsed = CostUsageScanner.parseCodexFile( + fileURL: fileURL, + range: CostUsageScanner.CostUsageDayRange(since: day, until: day), + inheritedTotalsResolver: { parentSessionId, _ in + #expect(parentSessionId == "parent-session") + return .resolved(.init(input: 1000, cached: 0, output: 0)) + }) + let dayKey = CostUsageScanner.CostUsageDayRange.dayKey(from: day) + let packed = parsed.days[dayKey]?["gpt-5.5"] ?? [] + + #expect(packed[safe: 0] == 20) + #expect(parsed.rows.count == 2) + #expect(parsed.hasInterleavedTotals) + } + + @Test + func `codex root interleaved caps last much larger than watermark delta`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 6, day: 10) + let model = "openai/gpt-5.5" + // After latch, a tiny watermark advance with a huge replayed/status `last` must count + // only the contained totals delta (1000), not the full last (100_000). + let fileURL = try env.writeCodexSessionFile( + day: day, + filename: "root-last-cap.jsonl", + contents: env.jsonl([ + self.codexTurnContext(timestamp: env.isoString(for: day), model: model), + self.codexTokenCount( + timestamp: env.isoString(for: day.addingTimeInterval(1)), + model: model, + total: (input: 100_000, cached: 0, output: 0), + last: (input: 100_000, cached: 0, output: 0)), + self.codexTokenCount( + timestamp: env.isoString(for: day.addingTimeInterval(2)), + model: model, + total: (input: 5000, cached: 0, output: 0), + last: (input: 5000, cached: 0, output: 0)), + self.codexTokenCount( + timestamp: env.isoString(for: day.addingTimeInterval(3)), + model: model, + total: (input: 101_000, cached: 0, output: 0), + last: (input: 100_000, cached: 0, output: 0)), + ])) + + let parsed = CostUsageScanner.parseCodexFile( + fileURL: fileURL, + range: CostUsageScanner.CostUsageDayRange(since: day, until: day)) + let dayKey = CostUsageScanner.CostUsageDayRange.dayKey(from: day) + let packed = parsed.days[dayKey]?["gpt-5.5"] ?? [] + + #expect(packed[safe: 0] == 101_000) + #expect(parsed.rows.count == 2) + #expect(parsed.hasInterleavedTotals) + } + + @Test + func `codex fork interleaved caps last much larger than watermark delta`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 6, day: 10) + let iso0 = env.isoString(for: day) + let model = "openai/gpt-5.5" + let fileURL = try env.writeCodexSessionFile( + day: day, + filename: "rollout-\(iso0)-fork-last-cap.jsonl", + contents: env.jsonl([ + [ + "type": "session_meta", + "timestamp": iso0, + "payload": [ + "id": "child-session", + "forked_from_id": "parent-session", + "timestamp": iso0, + ], + ], + self.codexTurnContext(timestamp: iso0, model: model), + self.codexTokenCount( + timestamp: env.isoString(for: day.addingTimeInterval(1)), + model: model, + total: (input: 2000, cached: 0, output: 0), + last: (input: 1000, cached: 0, output: 0)), + self.codexTokenCount( + timestamp: env.isoString(for: day.addingTimeInterval(2)), + model: model, + total: (input: 100, cached: 0, output: 0), + last: (input: 100, cached: 0, output: 0)), + self.codexTokenCount( + timestamp: env.isoString(for: day.addingTimeInterval(3)), + model: model, + total: (input: 2100, cached: 0, output: 0), + last: (input: 50000, cached: 0, output: 0)), + ])) + + let parsed = CostUsageScanner.parseCodexFile( + fileURL: fileURL, + range: CostUsageScanner.CostUsageDayRange(since: day, until: day), + inheritedTotalsResolver: { _, _ in + .resolved(.init(input: 1000, cached: 0, output: 0)) + }) + let dayKey = CostUsageScanner.CostUsageDayRange.dayKey(from: day) + let packed = parsed.days[dayKey]?["gpt-5.5"] ?? [] + + // adjusted: 1000, then 0 (latch), then 1100 → contained deltas 1000 + 0 + 100 = 1100 + #expect(packed[safe: 0] == 1100) + #expect(parsed.hasInterleavedTotals) + } + + @Test + func `codex interleaved replay after sixty five unique snapshots stays contained`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 6, day: 10) + let model = "openai/gpt-5.5" + var events: [[String: Any]] = [ + self.codexTurnContext(timestamp: env.isoString(for: day), model: model), + self.codexTokenCount( + timestamp: env.isoString(for: day.addingTimeInterval(1)), + model: model, + total: (input: 100_000, cached: 0, output: 0), + last: (input: 100_000, cached: 0, output: 0)), + // Latch interleaved mode with a second lineage. + self.codexTokenCount( + timestamp: env.isoString(for: day.addingTimeInterval(2)), + model: model, + total: (input: 5000, cached: 0, output: 0), + last: (input: 5000, cached: 0, output: 0)), + ] + // 65 unique advances of lineage A — enough to FIFO-evict the B=5000 snapshot. + for index in 0..<65 { + let total = 100_001 + index + events.append(self.codexTokenCount( + timestamp: env.isoString(for: day.addingTimeInterval(TimeInterval(3 + index))), + model: model, + total: (input: total, cached: 0, output: 0), + last: (input: 1, cached: 0, output: 0))) + } + // Re-emit the evicted B snapshot with a fat last; containment must keep it at zero. + events.append(self.codexTokenCount( + timestamp: env.isoString(for: day.addingTimeInterval(70)), + model: model, + total: (input: 5000, cached: 0, output: 0), + last: (input: 5000, cached: 0, output: 0))) + + let fileURL = try env.writeCodexSessionFile( + day: day, + filename: "eviction-replay.jsonl", + contents: env.jsonl(events)) + + let parsed = CostUsageScanner.parseCodexFile( + fileURL: fileURL, + range: CostUsageScanner.CostUsageDayRange(since: day, until: day)) + let dayKey = CostUsageScanner.CostUsageDayRange.dayKey(from: day) + let packed = parsed.days[dayKey]?["gpt-5.5"] ?? [] + + #expect(packed[safe: 0] == 100_065) + #expect(parsed.hasInterleavedTotals) + } + + @Test + func `codex interleaved totals only sequences stay within containment bound`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 6, day: 11) + let model = "openai/gpt-5.5" + // Property-style: many interleaved totals-only sequences must never exceed the max + // observed cumulative total (the Phase 1 never-inflates bound for totals-only streams). + for seed in 0..<40 { + var a = 10000 + seed * 17 + var b = 100 + seed * 3 + var maxObserved = 0 + var events: [[String: Any]] = [ + self.codexTurnContext(timestamp: env.isoString(for: day), model: model), + ] + for step in 0..<30 { + let useA = (step + seed) % 3 != 0 + if useA { + a += 1 + (step % 5) + maxObserved = max(maxObserved, a) + events.append(self.codexTokenCount( + timestamp: env.isoString(for: day.addingTimeInterval(TimeInterval(step + 1))), + model: model, + total: (input: a, cached: 0, output: 0))) + } else { + b += 1 + (step % 3) + maxObserved = max(maxObserved, b) + events.append(self.codexTokenCount( + timestamp: env.isoString(for: day.addingTimeInterval(TimeInterval(step + 1))), + model: model, + total: (input: b, cached: 0, output: 0))) + } + } + + let fileURL = try env.writeCodexSessionFile( + day: day, + filename: "property-\(seed).jsonl", + contents: env.jsonl(events)) + let parsed = CostUsageScanner.parseCodexFile( + fileURL: fileURL, + range: CostUsageScanner.CostUsageDayRange(since: day, until: day)) + let dayKey = CostUsageScanner.CostUsageDayRange.dayKey(from: day) + let counted = parsed.days[dayKey]?["gpt-5.5"]?[safe: 0] ?? 0 + #expect(counted <= maxObserved) + #expect(counted >= 10000 + seed * 17) + } + } + + @Test + func `codex incremental append preserves interleave containment across boundary`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 6, day: 12) + let iso0 = env.isoString(for: day) + let model = "openai/gpt-5.5" + let sessionMeta: [String: Any] = [ + "type": "session_meta", + "timestamp": iso0, + "payload": ["session_id": "interleaved-incremental"], + ] + let turnContext = self.codexTurnContext(timestamp: iso0, model: model) + let initialEvents: [[String: Any]] = [ + self.codexTokenCount( + timestamp: env.isoString(for: day.addingTimeInterval(1)), + model: model, + total: (input: 100_000, cached: 0, output: 0), + last: (input: 100_000, cached: 0, output: 0)), + self.codexTokenCount( + timestamp: env.isoString(for: day.addingTimeInterval(2)), + model: model, + total: (input: 5000, cached: 0, output: 0), + last: (input: 5000, cached: 0, output: 0)), + ] + let fileURL = try env.writeCodexSessionFile( + day: day, + filename: "session.jsonl", + contents: env.jsonl([sessionMeta, turnContext] + initialEvents)) + + var options = CostUsageScanner.Options( + codexSessionsRoot: env.codexSessionsRoot, + claudeProjectsRoots: nil, + cacheRoot: env.cacheRoot, + codexTraceDatabaseURL: env.root.appendingPathComponent("missing-traces.sqlite")) + options.refreshMinIntervalSeconds = 0 + + let first = CostUsageScanner.loadDailyReport( + provider: .codex, + since: day, + until: day, + now: day, + options: options) + #expect(first.data.first?.totalTokens == 100_000) + + let appendedEvents: [[String: Any]] = [ + self.codexTokenCount( + timestamp: env.isoString(for: day.addingTimeInterval(3)), + model: model, + total: (input: 100_000, cached: 0, output: 0), + last: (input: 100_000, cached: 0, output: 0)), + self.codexTokenCount( + timestamp: env.isoString(for: day.addingTimeInterval(4)), + model: model, + total: (input: 5000, cached: 0, output: 0), + last: (input: 5000, cached: 0, output: 0)), + self.codexTokenCount( + timestamp: env.isoString(for: day.addingTimeInterval(5)), + model: model, + total: (input: 101_000, cached: 0, output: 0), + last: (input: 1000, cached: 0, output: 0)), + ] + try env.jsonl([sessionMeta, turnContext] + initialEvents + appendedEvents) + .write(to: fileURL, atomically: true, encoding: .utf8) + + let second = CostUsageScanner.loadDailyReport( + provider: .codex, + since: day, + until: day, + now: day.addingTimeInterval(1), + options: options) + #expect(second.data.first?.totalTokens == 101_000) + + let cache = CostUsageCacheIO.load(provider: .codex, cacheRoot: env.cacheRoot) + let usage = cache.files.first { URL(fileURLWithPath: $0.key).lastPathComponent == fileURL.lastPathComponent }? + .value + #expect(usage?.hasInterleavedTotals == true) + #expect(usage?.lastRawTotalsWatermark?.input == 101_000) + #expect(usage?.lastCountedTotals?.input == 101_000) + + options.forceRescan = true + let rescanned = CostUsageScanner.loadDailyReport( + provider: .codex, + since: day, + until: day, + now: day.addingTimeInterval(2), + options: options) + #expect(rescanned.data.first?.totalTokens == 101_000) + + let rescannedCache = CostUsageCacheIO.load(provider: .codex, cacheRoot: env.cacheRoot) + let rescannedUsage = rescannedCache.files + .first { URL(fileURLWithPath: $0.key).lastPathComponent == fileURL.lastPathComponent }? + .value + #expect(rescannedUsage?.hasInterleavedTotals == usage?.hasInterleavedTotals) + #expect(rescannedUsage?.lastRawTotalsWatermark == usage?.lastRawTotalsWatermark) + #expect(rescannedUsage?.lastCountedTotals == usage?.lastCountedTotals) + #expect(rescannedUsage?.hasDivergentTotals == usage?.hasDivergentTotals) + #expect(rescannedUsage?.codexCostNanos == usage?.codexCostNanos) + #expect(rescanned.data.first?.totalTokens == second.data.first?.totalTokens) + } + + @Test + func `codex missing watermark or interleaved flag forces full rescan`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 6, day: 12) + let iso0 = env.isoString(for: day) + let model = "openai/gpt-5.5" + let sessionMeta: [String: Any] = [ + "type": "session_meta", + "timestamp": iso0, + "payload": ["session_id": "incomplete-interleave-critical"], + ] + let turnContext = self.codexTurnContext(timestamp: iso0, model: model) + let initialEvents: [[String: Any]] = [ + self.codexTokenCount( + timestamp: env.isoString(for: day.addingTimeInterval(1)), + model: model, + total: (input: 100_000, cached: 0, output: 0), + last: (input: 100_000, cached: 0, output: 0)), + self.codexTokenCount( + timestamp: env.isoString(for: day.addingTimeInterval(2)), + model: model, + total: (input: 5000, cached: 0, output: 0), + last: (input: 5000, cached: 0, output: 0)), + ] + let fileURL = try env.writeCodexSessionFile( + day: day, + filename: "session.jsonl", + contents: env.jsonl([sessionMeta, turnContext] + initialEvents)) + + var options = CostUsageScanner.Options( + codexSessionsRoot: env.codexSessionsRoot, + claudeProjectsRoots: nil, + cacheRoot: env.cacheRoot, + codexTraceDatabaseURL: env.root.appendingPathComponent("missing-traces.sqlite")) + options.refreshMinIntervalSeconds = 0 + + let replayedSnapshot = self.codexTokenCount( + timestamp: env.isoString(for: day.addingTimeInterval(3)), + model: model, + total: (input: 100_000, cached: 0, output: 0), + last: (input: 100_000, cached: 0, output: 0)) + + // Correctness-critical fields: missing either forces a full rescan rather than an unsafe + // incremental resume. + let mutations: [(String, (inout CostUsageFileUsage) -> Void)] = [ + ("watermark", { $0.lastRawTotalsWatermark = nil }), + ("interleaved flag", { $0.hasInterleavedTotals = nil }), + ] + + for (label, mutate) in mutations { + try env.jsonl([sessionMeta, turnContext] + initialEvents) + .write(to: fileURL, atomically: true, encoding: .utf8) + options.forceRescan = true + let baseline = CostUsageScanner.loadDailyReport( + provider: .codex, + since: day, + until: day, + now: day, + options: options) + #expect(baseline.data.first?.totalTokens == 100_000, "baseline failed for \(label)") + options.forceRescan = false + + var cache = CostUsageCacheIO.load(provider: .codex, cacheRoot: env.cacheRoot) + for (path, usage) in cache.files { + var stripped = usage + mutate(&stripped) + cache.files[path] = stripped + } + CostUsageCacheIO.save(provider: .codex, cache: cache, cacheRoot: env.cacheRoot) + + try env.jsonl([sessionMeta, turnContext] + initialEvents + [replayedSnapshot]) + .write(to: fileURL, atomically: true, encoding: .utf8) + + let second = CostUsageScanner.loadDailyReport( + provider: .codex, + since: day, + until: day, + now: day.addingTimeInterval(1), + options: options) + #expect(second.data.first?.totalTokens == 100_000, "failed for missing \(label)") + + let healed = CostUsageCacheIO.load(provider: .codex, cacheRoot: env.cacheRoot) + let usage = healed.files + .first { URL(fileURLWithPath: $0.key).lastPathComponent == fileURL.lastPathComponent }? + .value + #expect(usage?.lastRawTotalsWatermark != nil, "healed watermark missing after \(label)") + #expect(usage?.hasInterleavedTotals == true, "healed interleaved flag missing after \(label)") + } + } + + @Test + func `codex missing optional seen set keeps incremental resume safe`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 6, day: 12) + let iso0 = env.isoString(for: day) + let model = "openai/gpt-5.5" + let sessionMeta: [String: Any] = [ + "type": "session_meta", + "timestamp": iso0, + "payload": ["session_id": "optional-seen-set"], + ] + let turnContext = self.codexTurnContext(timestamp: iso0, model: model) + let initialEvents: [[String: Any]] = [ + self.codexTokenCount( + timestamp: env.isoString(for: day.addingTimeInterval(1)), + model: model, + total: (input: 100_000, cached: 0, output: 0), + last: (input: 100_000, cached: 0, output: 0)), + self.codexTokenCount( + timestamp: env.isoString(for: day.addingTimeInterval(2)), + model: model, + total: (input: 5000, cached: 0, output: 0), + last: (input: 5000, cached: 0, output: 0)), + ] + let fileURL = try env.writeCodexSessionFile( + day: day, + filename: "session.jsonl", + contents: env.jsonl([sessionMeta, turnContext] + initialEvents)) + + var options = CostUsageScanner.Options( + codexSessionsRoot: env.codexSessionsRoot, + claudeProjectsRoots: nil, + cacheRoot: env.cacheRoot, + codexTraceDatabaseURL: env.root.appendingPathComponent("missing-traces.sqlite")) + options.refreshMinIntervalSeconds = 0 + + let first = CostUsageScanner.loadDailyReport( + provider: .codex, + since: day, + until: day, + now: day, + options: options) + #expect(first.data.first?.totalTokens == 100_000) + + var cache = CostUsageCacheIO.load(provider: .codex, cacheRoot: env.cacheRoot) + let path = try #require(cache.files.keys.first { + URL(fileURLWithPath: $0).lastPathComponent == fileURL.lastPathComponent + }) + var usage = try #require(cache.files[path]) + let parsedBytesBeforeAppend = usage.parsedBytes ?? usage.size + #expect(usage.hasInterleavedTotals == true) + #expect(usage.lastRawTotalsWatermark != nil) + // Optional precision only: stripping the seen-set must not block incremental resume. + usage.seenRawTotals = nil + cache.files[path] = usage + CostUsageCacheIO.save(provider: .codex, cache: cache, cacheRoot: env.cacheRoot) + + let appendedEvents: [[String: Any]] = [ + self.codexTokenCount( + timestamp: env.isoString(for: day.addingTimeInterval(3)), + model: model, + total: (input: 100_000, cached: 0, output: 0), + last: (input: 100_000, cached: 0, output: 0)), + self.codexTokenCount( + timestamp: env.isoString(for: day.addingTimeInterval(4)), + model: model, + total: (input: 101_000, cached: 0, output: 0), + last: (input: 1000, cached: 0, output: 0)), + ] + try env.jsonl([sessionMeta, turnContext] + initialEvents + appendedEvents) + .write(to: fileURL, atomically: true, encoding: .utf8) + + let second = CostUsageScanner.loadDailyReport( + provider: .codex, + since: day, + until: day, + now: day.addingTimeInterval(1), + options: options) + #expect(second.data.first?.totalTokens == 101_000) + + let after = CostUsageCacheIO.load(provider: .codex, cacheRoot: env.cacheRoot) + let afterUsage = try #require(after.files[path]) + #expect(afterUsage.hasInterleavedTotals == true) + #expect(afterUsage.lastRawTotalsWatermark?.input == 101_000) + #expect((afterUsage.parsedBytes ?? afterUsage.size) > parsedBytesBeforeAppend) + } + + @Test + func `codex divergent cache entry without watermark forces full rescan`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 6, day: 12) + let iso0 = env.isoString(for: day) + let model = "openai/gpt-5.5" + let sessionMeta: [String: Any] = [ + "type": "session_meta", + "timestamp": iso0, + "payload": ["session_id": "legacy-divergent"], + ] + let turnContext = self.codexTurnContext(timestamp: iso0, model: model) + let initialEvents: [[String: Any]] = [ + self.codexTokenCount( + timestamp: env.isoString(for: day.addingTimeInterval(1)), + model: model, + total: (input: 100_000, cached: 0, output: 0), + last: (input: 100_000, cached: 0, output: 0)), + self.codexTokenCount( + timestamp: env.isoString(for: day.addingTimeInterval(2)), + model: model, + total: (input: 5000, cached: 0, output: 0), + last: (input: 5000, cached: 0, output: 0)), + ] + let fileURL = try env.writeCodexSessionFile( + day: day, + filename: "session.jsonl", + contents: env.jsonl([sessionMeta, turnContext] + initialEvents)) + + var options = CostUsageScanner.Options( + codexSessionsRoot: env.codexSessionsRoot, + claudeProjectsRoots: nil, + cacheRoot: env.cacheRoot, + codexTraceDatabaseURL: env.root.appendingPathComponent("missing-traces.sqlite")) + options.refreshMinIntervalSeconds = 0 + + let first = CostUsageScanner.loadDailyReport( + provider: .codex, + since: day, + until: day, + now: day, + options: options) + #expect(first.data.first?.totalTokens == 100_000) + + // Simulate a cache entry written before the interleave tracker existed: divergent totals + // but no watermark. Resuming incrementally from it would be unsafe. + var cache = CostUsageCacheIO.load(provider: .codex, cacheRoot: env.cacheRoot) + for (path, usage) in cache.files { + var stripped = usage + stripped.lastRawTotalsWatermark = nil + stripped.seenRawTotals = nil + stripped.hasInterleavedTotals = nil + cache.files[path] = stripped + } + CostUsageCacheIO.save(provider: .codex, cache: cache, cacheRoot: env.cacheRoot) + + let replayedSnapshot = self.codexTokenCount( + timestamp: env.isoString(for: day.addingTimeInterval(3)), + model: model, + total: (input: 100_000, cached: 0, output: 0), + last: (input: 100_000, cached: 0, output: 0)) + try env.jsonl([sessionMeta, turnContext] + initialEvents + [replayedSnapshot]) + .write(to: fileURL, atomically: true, encoding: .utf8) + + let second = CostUsageScanner.loadDailyReport( + provider: .codex, + since: day, + until: day, + now: day.addingTimeInterval(1), + options: options) + #expect(second.data.first?.totalTokens == 100_000) + + let healed = CostUsageCacheIO.load(provider: .codex, cacheRoot: env.cacheRoot) + let usage = healed.files.first { URL(fileURLWithPath: $0.key).lastPathComponent == fileURL.lastPathComponent }? + .value + #expect(usage?.lastRawTotalsWatermark != nil) + #expect(usage?.hasInterleavedTotals == true) + } + + @Test + func `codex daily report includes archived sessions and dedupes`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2025, month: 12, day: 22) + let iso0 = env.isoString(for: day) + let iso1 = env.isoString(for: day.addingTimeInterval(1)) + + let model = "openai/gpt-5.2-codex" + let sessionMeta: [String: Any] = [ + "type": "session_meta", + "payload": [ + "session_id": "sess-archived-1", + ], + ] + let turnContext: [String: Any] = [ + "type": "turn_context", + "timestamp": iso0, + "payload": [ + "model": model, + ], + ] + let tokenCount: [String: Any] = [ + "type": "event_msg", + "timestamp": iso1, + "payload": [ + "type": "token_count", + "info": [ + "total_token_usage": [ + "input_tokens": 100, + "cached_input_tokens": 20, + "output_tokens": 10, + ], + "model": model, + ], + ], + ] + + let comps = Calendar.current.dateComponents([.year, .month, .day], from: day) + let dayKey = String(format: "%04d-%02d-%02d", comps.year ?? 1970, comps.month ?? 1, comps.day ?? 1) + let archivedName = "rollout-\(dayKey)T12-00-00-archived.jsonl" + let contents = try env.jsonl([sessionMeta, turnContext, tokenCount]) + _ = try env.writeCodexArchivedSessionFile(filename: archivedName, contents: contents) + + var options = CostUsageScanner.Options( + codexSessionsRoot: env.codexSessionsRoot, + claudeProjectsRoots: nil, + cacheRoot: env.cacheRoot) + options.refreshMinIntervalSeconds = 0 + + let first = CostUsageScanner.loadDailyReport( + provider: .codex, + since: day, + until: day, + now: day, + options: options) + #expect(first.data.count == 1) + #expect(first.data[0].totalTokens == 110) + + _ = try env.writeCodexSessionFile(day: day, filename: "session.jsonl", contents: contents) + let second = CostUsageScanner.loadDailyReport( + provider: .codex, + since: day, + until: day, + now: day, + options: options) + #expect(second.data.count == 1) + #expect(second.data[0].totalTokens == 110) + } + + @Test + func `codex active session stub does not hide archived usage`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 6, day: 25) + let iso0 = env.isoString(for: day) + let iso1 = env.isoString(for: day.addingTimeInterval(1)) + let model = "openai/gpt-5.5" + let sessionMeta: [String: Any] = [ + "type": "session_meta", + "payload": [ + "session_id": "sess-shared-active-archive", + ], + ] + let turnContext = self.codexTurnContext(timestamp: iso0, model: model) + + _ = try env.writeCodexSessionFile( + day: day, + filename: "active-stub.jsonl", + contents: env.jsonl([sessionMeta, turnContext])) + + let dayKey = CostUsageScanner.CostUsageDayRange.dayKey(from: day) + _ = try env.writeCodexArchivedSessionFile( + filename: "rollout-\(dayKey)T12-00-00-shared.jsonl", + contents: env.jsonl([ + sessionMeta, + turnContext, + self.codexTokenCount( + timestamp: iso1, + model: model, + last: (input: 20, cached: 500, output: 5)), + ])) + + var options = CostUsageScanner.Options( + codexSessionsRoot: env.codexSessionsRoot, + claudeProjectsRoots: nil, + cacheRoot: env.cacheRoot) + options.refreshMinIntervalSeconds = 0 + + let report = CostUsageScanner.loadDailyReport( + provider: .codex, + since: day, + until: day, + now: day, + options: options) + let expectedCost = CostUsagePricing.codexCostUSD( + model: model, + inputTokens: 20, + cachedInputTokens: 500, + outputTokens: 5) + + #expect(report.data.count == 1) + #expect(report.data[0].inputTokens == 20) + #expect(report.data[0].outputTokens == 5) + #expect(report.data[0].totalTokens == 25) + #expect(report.data[0].modelBreakdowns?.first?.totalTokens == 25) + #expect(abs((report.data[0].costUSD ?? 0) - (expectedCost ?? 0)) < 0.000001) + } + + @Test + func `codex active session partial file keeps distinct archived rows`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 6, day: 26) + let iso0 = env.isoString(for: day) + let iso1 = env.isoString(for: day.addingTimeInterval(1)) + let iso2 = env.isoString(for: day.addingTimeInterval(2)) + let model = "openai/gpt-5.5" + let sessionMeta: [String: Any] = [ + "type": "session_meta", + "payload": [ + "session_id": "sess-partial-active-archive", + ], + ] + let turnContext = self.codexTurnContext(timestamp: iso0, model: model) + let firstTurn: [String: Any] = [ + "type": "event_msg", + "timestamp": iso1, + "payload": [ + "type": "task_started", + "turn_id": "turn-a", + ], + ] + let firstUsage = self.codexTokenCount( + timestamp: iso1, + model: model, + last: (input: 20, cached: 0, output: 5)) + let secondTurn: [String: Any] = [ + "type": "event_msg", + "timestamp": iso2, + "payload": [ + "type": "task_started", + "turn_id": "turn-b", + ], + ] + let secondUsage = self.codexTokenCount( + timestamp: iso2, + model: model, + last: (input: 30, cached: 500, output: 7)) + + _ = try env.writeCodexSessionFile( + day: day, + filename: "active-partial.jsonl", + contents: env.jsonl([sessionMeta, turnContext, firstTurn, firstUsage])) + + let dayKey = CostUsageScanner.CostUsageDayRange.dayKey(from: day) + _ = try env.writeCodexArchivedSessionFile( + filename: "rollout-\(dayKey)T12-00-00-partial.jsonl", + contents: env.jsonl([sessionMeta, turnContext, firstTurn, firstUsage, secondTurn, secondUsage])) + + var options = CostUsageScanner.Options( + codexSessionsRoot: env.codexSessionsRoot, + claudeProjectsRoots: nil, + cacheRoot: env.cacheRoot) + options.refreshMinIntervalSeconds = 0 + + let report = CostUsageScanner.loadDailyReport( + provider: .codex, + since: day, + until: day, + now: day, + options: options) + let expectedCost = (CostUsagePricing.codexCostUSD( + model: model, + inputTokens: 20, + cachedInputTokens: 0, + outputTokens: 5) ?? 0) + + (CostUsagePricing.codexCostUSD( + model: model, + inputTokens: 30, + cachedInputTokens: 500, + outputTokens: 7) ?? 0) + + #expect(report.data.count == 1) + #expect(report.data[0].inputTokens == 50) + #expect(report.data[0].cacheReadTokens == 500) + #expect(report.data[0].outputTokens == 12) + #expect(report.data[0].totalTokens == 62) + #expect(report.data[0].modelBreakdowns?.first?.totalTokens == 62) + #expect(abs((report.data[0].costUSD ?? 0) - expectedCost) < 0.000001) + + let repeated = CostUsageScanner.loadDailyReport( + provider: .codex, + since: day, + until: day, + now: day.addingTimeInterval(1), + options: options) + #expect(repeated.data.count == 1) + #expect(repeated.data[0].inputTokens == 50) + #expect(repeated.data[0].cacheReadTokens == 500) + #expect(repeated.data[0].outputTokens == 12) + #expect(repeated.data[0].totalTokens == 62) + } + + @Test + func `codex active archive dedupe preserves identical same turn deltas`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 6, day: 27) + let iso0 = env.isoString(for: day) + let iso1 = env.isoString(for: day.addingTimeInterval(1)) + let iso2 = env.isoString(for: day.addingTimeInterval(2)) + let model = "openai/gpt-5.5" + let sessionMeta: [String: Any] = [ + "type": "session_meta", + "payload": [ + "session_id": "sess-identical-delta-active-archive", + ], + ] + let turnContext = self.codexTurnContext(timestamp: iso0, model: model) + let firstTurn: [String: Any] = [ + "type": "event_msg", + "timestamp": iso1, + "payload": [ + "type": "task_started", + "turn_id": "turn-a", + ], + ] + let firstUsage = self.codexTokenCount( + timestamp: iso1, + model: model, + last: (input: 20, cached: 0, output: 5)) + let repeatedUsage = self.codexTokenCount( + timestamp: iso2, + model: model, + last: (input: 20, cached: 0, output: 5)) + + _ = try env.writeCodexSessionFile( + day: day, + filename: "active-identical-delta.jsonl", + contents: env.jsonl([sessionMeta, turnContext, firstTurn, firstUsage])) + + let dayKey = CostUsageScanner.CostUsageDayRange.dayKey(from: day) + _ = try env.writeCodexArchivedSessionFile( + filename: "rollout-\(dayKey)T12-00-00-identical-delta.jsonl", + contents: env.jsonl([sessionMeta, turnContext, firstTurn, firstUsage, repeatedUsage])) + + var options = CostUsageScanner.Options( + codexSessionsRoot: env.codexSessionsRoot, + claudeProjectsRoots: nil, + cacheRoot: env.cacheRoot) + options.refreshMinIntervalSeconds = 0 + + let report = CostUsageScanner.loadDailyReport( + provider: .codex, + since: day, + until: day, + now: day, + options: options) + + #expect(report.data.count == 1) + #expect(report.data[0].inputTokens == 40) + #expect(report.data[0].outputTokens == 10) + #expect(report.data[0].totalTokens == 50) + + let repeated = CostUsageScanner.loadDailyReport( + provider: .codex, + since: day, + until: day, + now: day.addingTimeInterval(1), + options: options) + #expect(repeated.data.count == 1) + #expect(repeated.data[0].inputTokens == 40) + #expect(repeated.data[0].outputTokens == 10) + #expect(repeated.data[0].totalTokens == 50) + } + + @Test + func `codex files without session metadata do not dedupe each other`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 6, day: 27) + let model = "openai/gpt-5.5" + let contents = try env.jsonl([ + self.codexTurnContext(timestamp: env.isoString(for: day), model: model), + self.codexTokenCount( + timestamp: env.isoString(for: day.addingTimeInterval(1)), + model: model, + last: (input: 10, cached: 100, output: 1)), + ]) + + _ = try env.writeCodexSessionFile(day: day, filename: "legacy-a.jsonl", contents: contents) + _ = try env.writeCodexSessionFile(day: day, filename: "legacy-b.jsonl", contents: contents) + + var options = CostUsageScanner.Options( + codexSessionsRoot: env.codexSessionsRoot, + claudeProjectsRoots: nil, + cacheRoot: env.cacheRoot) + options.refreshMinIntervalSeconds = 0 + + let report = CostUsageScanner.loadDailyReport( + provider: .codex, + since: day, + until: day, + now: day, + options: options) + + #expect(report.data.count == 1) + #expect(report.data[0].inputTokens == 20) + #expect(report.data[0].cacheReadTokens == 200) + #expect(report.data[0].outputTokens == 2) + #expect(report.data[0].totalTokens == 22) + } + + @Test + func `codex warm cache rechecks active archive row overlap`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 6, day: 28) + let iso0 = env.isoString(for: day) + let iso1 = env.isoString(for: day.addingTimeInterval(1)) + let iso2 = env.isoString(for: day.addingTimeInterval(2)) + let iso3 = env.isoString(for: day.addingTimeInterval(3)) + let model = "openai/gpt-5.5" + let sessionMeta: [String: Any] = [ + "type": "session_meta", + "payload": [ + "session_id": "sess-warm-cache-active-archive", + ], + ] + let turnContext = self.codexTurnContext(timestamp: iso0, model: model) + let firstTurn: [String: Any] = [ + "type": "event_msg", + "timestamp": iso1, + "payload": ["type": "task_started", "turn_id": "turn-a"], + ] + let firstUsage = self.codexTokenCount( + timestamp: iso1, + model: model, + last: (input: 10, cached: 100, output: 1)) + let secondTurn: [String: Any] = [ + "type": "event_msg", + "timestamp": iso2, + "payload": ["type": "task_started", "turn_id": "turn-b"], + ] + let secondUsage = self.codexTokenCount( + timestamp: iso2, + model: model, + last: (input: 20, cached: 500, output: 5)) + let thirdTurn: [String: Any] = [ + "type": "event_msg", + "timestamp": iso3, + "payload": ["type": "task_started", "turn_id": "turn-c"], + ] + let thirdUsage = self.codexTokenCount( + timestamp: iso3, + model: model, + last: (input: 5, cached: 50, output: 2)) + + _ = try env.writeCodexSessionFile( + day: day, + filename: "active-warm-cache.jsonl", + contents: env.jsonl([ + sessionMeta, + turnContext, + firstTurn, + firstUsage, + ])) + + var options = CostUsageScanner.Options( + codexSessionsRoot: env.codexSessionsRoot, + claudeProjectsRoots: nil, + cacheRoot: env.cacheRoot) + options.refreshMinIntervalSeconds = 0 + + let first = CostUsageScanner.loadDailyReport( + provider: .codex, + since: day, + until: day, + now: day, + options: options) + #expect(first.data.first?.inputTokens == 10) + #expect(first.data.first?.cacheReadTokens == 100) + #expect(first.data.first?.outputTokens == 1) + + _ = try env.writeCodexSessionFile( + day: day, + filename: "active-warm-cache.jsonl", + contents: env.jsonl([ + sessionMeta, + turnContext, + firstTurn, + firstUsage, + secondTurn, + secondUsage, + ])) + + let dayKey = CostUsageScanner.CostUsageDayRange.dayKey(from: day) + _ = try env.writeCodexArchivedSessionFile( + filename: "rollout-\(dayKey)T12-00-00-warm-cache.jsonl", + contents: env.jsonl([ + sessionMeta, + turnContext, + firstTurn, + firstUsage, + secondTurn, + secondUsage, + thirdTurn, + thirdUsage, + ])) + + let second = CostUsageScanner.loadDailyReport( + provider: .codex, + since: day, + until: day, + now: day.addingTimeInterval(1), + options: options) + + #expect(second.data.count == 1) + #expect(second.data[0].inputTokens == 35) + #expect(second.data[0].cacheReadTokens == 650) + #expect(second.data[0].outputTokens == 8) + #expect(second.data[0].totalTokens == 43) + + var cache = CostUsageCacheIO.load(provider: .codex, cacheRoot: env.cacheRoot) + for path in cache.files.keys where cache.files[path]?.sessionId == "sess-warm-cache-active-archive" { + cache.files[path]?.codexRows = nil + } + CostUsageCacheIO.save(provider: .codex, cache: cache, cacheRoot: env.cacheRoot) + + let rowlessWarm = CostUsageScanner.loadDailyReport( + provider: .codex, + since: day, + until: day, + now: day.addingTimeInterval(2), + options: options) + + #expect(rowlessWarm.data.count == 1) + #expect(rowlessWarm.data[0].inputTokens == 35) + #expect(rowlessWarm.data[0].cacheReadTokens == 650) + #expect(rowlessWarm.data[0].outputTokens == 8) + #expect(rowlessWarm.data[0].totalTokens == 43) + } + + @Test + func `codex narrow warm overlap does not duplicate cached days outside scan window`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let olderDay = try env.makeLocalNoon(year: 2026, month: 6, day: 10) + let day = try env.makeLocalNoon(year: 2026, month: 6, day: 28) + let model = "openai/gpt-5.5" + let sessionMeta: [String: Any] = [ + "type": "session_meta", + "payload": ["session_id": "sess-narrow-warm-overlap"], + ] + let turnContext = self.codexTurnContext(timestamp: env.isoString(for: day), model: model) + let sharedTurn: [String: Any] = [ + "type": "event_msg", + "timestamp": env.isoString(for: day), + "payload": ["type": "task_started", "turn_id": "turn-shared"], + ] + let sharedUsage = self.codexTokenCount( + timestamp: env.isoString(for: day.addingTimeInterval(1)), + model: model, + last: (input: 10, cached: 0, output: 1)) + let olderTurn: [String: Any] = [ + "type": "event_msg", + "timestamp": env.isoString(for: olderDay), + "payload": ["type": "task_started", "turn_id": "turn-older"], + ] + let olderUsage = self.codexTokenCount( + timestamp: env.isoString(for: olderDay.addingTimeInterval(1)), + model: model, + last: (input: 20, cached: 0, output: 2)) + let currentTurn: [String: Any] = [ + "type": "event_msg", + "timestamp": env.isoString(for: day.addingTimeInterval(2)), + "payload": ["type": "task_started", "turn_id": "turn-current"], + ] + let currentUsage = self.codexTokenCount( + timestamp: env.isoString(for: day.addingTimeInterval(3)), + model: model, + last: (input: 5, cached: 0, output: 1)) + + _ = try env.writeCodexSessionFile( + day: day, + filename: "active-narrow-warm-overlap.jsonl", + contents: env.jsonl([sessionMeta, turnContext, sharedTurn, sharedUsage])) + let dayKey = CostUsageScanner.CostUsageDayRange.dayKey(from: day) + let archiveRows = [ + sessionMeta, + turnContext, + sharedTurn, + sharedUsage, + olderTurn, + olderUsage, + currentTurn, + currentUsage, + ] + let archiveURL = try env.writeCodexArchivedSessionFile( + filename: "rollout-\(dayKey)T12-00-00-narrow-warm-overlap.jsonl", + contents: env.jsonl(archiveRows)) + + var options = CostUsageScanner.Options( + codexSessionsRoot: env.codexSessionsRoot, + claudeProjectsRoots: nil, + cacheRoot: env.cacheRoot) + options.refreshMinIntervalSeconds = 0 + + let wide = CostUsageScanner.loadDailyReport( + provider: .codex, + since: olderDay, + until: day, + now: day, + options: options) + #expect(wide.summary?.totalTokens == 39) + + let appendedTurnWithoutUsage: [String: Any] = [ + "type": "event_msg", + "timestamp": env.isoString(for: day.addingTimeInterval(4)), + "payload": ["type": "task_started", "turn_id": "turn-without-usage"], + ] + try env.jsonl(archiveRows + [appendedTurnWithoutUsage]) + .write(to: archiveURL, atomically: true, encoding: .utf8) + + let narrow = CostUsageScanner.loadDailyReport( + provider: .codex, + since: day, + until: day, + now: day.addingTimeInterval(1), + options: options) + #expect(narrow.summary?.totalTokens == 17) + + let cache = CostUsageCacheIO.load(provider: .codex, cacheRoot: env.cacheRoot) + let archiveEntry = cache.files.first { + URL(fileURLWithPath: $0.key).lastPathComponent == archiveURL.lastPathComponent + } + let archiveUsage = try #require( + archiveEntry?.value, + "cache keys: \(cache.files.keys.sorted())") + let olderDayKey = CostUsageScanner.CostUsageDayRange.dayKey(from: olderDay) + let olderPacked = try #require(archiveUsage.days[olderDayKey]?.values.first) + #expect(olderPacked == [20, 0, 2]) + } + + @Test + func `codex narrow rowless rescan retains cached days outside scan window`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let olderDay = try env.makeLocalNoon(year: 2026, month: 6, day: 10) + let day = try env.makeLocalNoon(year: 2026, month: 6, day: 28) + let model = "openai/gpt-5.5" + let sessionMeta: [String: Any] = [ + "type": "session_meta", + "payload": ["session_id": "sess-narrow-rowless-rescan"], + ] + let currentContext = self.codexTurnContext(timestamp: env.isoString(for: day), model: model) + let currentTurn: [String: Any] = [ + "type": "event_msg", + "timestamp": env.isoString(for: day), + "payload": ["type": "task_started", "turn_id": "turn-current"], + ] + let currentUsage = self.codexTokenCount( + timestamp: env.isoString(for: day.addingTimeInterval(1)), + model: model, + last: (input: 10, cached: 0, output: 1)) + let olderContext = self.codexTurnContext(timestamp: env.isoString(for: olderDay), model: model) + let olderTurn: [String: Any] = [ + "type": "event_msg", + "timestamp": env.isoString(for: olderDay), + "payload": ["type": "task_started", "turn_id": "turn-older"], + ] + let olderUsage = self.codexTokenCount( + timestamp: env.isoString(for: olderDay.addingTimeInterval(1)), + model: model, + last: (input: 20, cached: 0, output: 2)) - let comps = Calendar.current.dateComponents([.year, .month, .day], from: day) - let dayKey = String(format: "%04d-%02d-%02d", comps.year ?? 1970, comps.month ?? 1, comps.day ?? 1) - let archivedName = "rollout-\(dayKey)T12-00-00-archived.jsonl" - let contents = try env.jsonl([sessionMeta, turnContext, tokenCount]) - _ = try env.writeCodexArchivedSessionFile(filename: archivedName, contents: contents) + _ = try env.writeCodexSessionFile( + day: day, + filename: "active-narrow-rowless-rescan.jsonl", + contents: env.jsonl([sessionMeta, currentContext, currentTurn, currentUsage])) + let dayKey = CostUsageScanner.CostUsageDayRange.dayKey(from: day) + let archiveURL = try env.writeCodexArchivedSessionFile( + filename: "rollout-\(dayKey)T12-00-00-narrow-rowless-rescan.jsonl", + contents: env.jsonl([ + sessionMeta, + currentContext, + currentTurn, + currentUsage, + olderContext, + olderTurn, + olderUsage, + ])) var options = CostUsageScanner.Options( codexSessionsRoot: env.codexSessionsRoot, @@ -1292,24 +3725,34 @@ struct CostUsageScannerBreakdownTests { cacheRoot: env.cacheRoot) options.refreshMinIntervalSeconds = 0 - let first = CostUsageScanner.loadDailyReport( + let wide = CostUsageScanner.loadDailyReport( provider: .codex, - since: day, + since: olderDay, until: day, now: day, options: options) - #expect(first.data.count == 1) - #expect(first.data[0].totalTokens == 110) + #expect(wide.summary?.totalTokens == 33) - _ = try env.writeCodexSessionFile(day: day, filename: "session.jsonl", contents: contents) - let second = CostUsageScanner.loadDailyReport( + var cache = CostUsageCacheIO.load(provider: .codex, cacheRoot: env.cacheRoot) + let archivePath = try #require(cache.files.keys.first { + URL(fileURLWithPath: $0).lastPathComponent == archiveURL.lastPathComponent + }) + cache.files[archivePath]?.codexRows = nil + CostUsageCacheIO.save(provider: .codex, cache: cache, cacheRoot: env.cacheRoot) + + let narrow = CostUsageScanner.loadDailyReport( provider: .codex, since: day, until: day, - now: day, + now: day.addingTimeInterval(1), options: options) - #expect(second.data.count == 1) - #expect(second.data[0].totalTokens == 110) + #expect(narrow.summary?.totalTokens == 11) + + cache = CostUsageCacheIO.load(provider: .codex, cacheRoot: env.cacheRoot) + let archiveUsage = try #require(cache.files[archivePath]) + let olderDayKey = CostUsageScanner.CostUsageDayRange.dayKey(from: olderDay) + let olderPacked = try #require(archiveUsage.days[olderDayKey]?.values.first) + #expect(olderPacked == [20, 0, 2]) } @Test @@ -1622,6 +4065,390 @@ struct CostUsageScannerBreakdownTests { #expect(abs((report.data[0].costUSD ?? 0) - (expectedCost ?? 0)) < 0.000001) } + @Test + func `codex warm cache invalidates fork when parent baseline and child file change`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let parentDay = try env.makeLocalNoon(year: 2026, month: 2, day: 1) + let childDay = try env.makeLocalNoon(year: 2026, month: 3, day: 11) + let model = "openai/gpt-5.2-codex" + let parentSessionId = "sess-parent-growing" + let childSessionId = "sess-child-cached" + let forkTimestamp = env.isoString(for: parentDay.addingTimeInterval(3)) + let parentMetadata: [String: Any] = [ + "type": "session_meta", + "payload": ["id": parentSessionId], + ] + let firstParentUsage = self.codexTokenCount( + timestamp: env.isoString(for: parentDay.addingTimeInterval(1)), + model: model, + total: (input: 20, cached: 5, output: 2)) + + let parentURL = try env.writeCodexSessionFile( + day: parentDay, + filename: "rollout-2026-02-01T12-00-00-\(parentSessionId).jsonl", + contents: env.jsonl([ + parentMetadata, + self.codexTurnContext(timestamp: env.isoString(for: parentDay), model: model), + firstParentUsage, + ])) + try FileManager.default.setAttributes([.modificationDate: parentDay], ofItemAtPath: parentURL.path) + + let childURL = try env.writeCodexSessionFile( + day: childDay, + filename: "rollout-2026-03-11T12-00-00-\(childSessionId).jsonl", + contents: env.jsonl([ + [ + "type": "session_meta", + "payload": [ + "id": childSessionId, + "forked_from_id": parentSessionId, + "timestamp": forkTimestamp, + ], + ], + self.codexTurnContext(timestamp: env.isoString(for: childDay), model: model), + self.codexTokenCount( + timestamp: env.isoString(for: childDay.addingTimeInterval(1)), + model: model, + total: (input: 20, cached: 5, output: 2)), + self.codexTokenCount( + timestamp: env.isoString(for: childDay.addingTimeInterval(2)), + model: model, + total: (input: 30, cached: 8, output: 3)), + self.codexTokenCount( + timestamp: env.isoString(for: childDay.addingTimeInterval(3)), + model: model, + total: (input: 37, cached: 10, output: 5)), + ])) + + var options = CostUsageScanner.Options( + codexSessionsRoot: env.codexSessionsRoot, + claudeProjectsRoots: nil, + cacheRoot: env.cacheRoot) + options.refreshMinIntervalSeconds = 0 + + let first = CostUsageScanner.loadDailyReport( + provider: .codex, + since: childDay, + until: childDay, + now: childDay, + options: options) + #expect(first.data.first?.inputTokens == 17) + #expect(first.data.first?.cacheReadTokens == 5) + #expect(first.data.first?.outputTokens == 3) + + try env.jsonl([ + parentMetadata, + self.codexTurnContext(timestamp: env.isoString(for: parentDay), model: model), + firstParentUsage, + self.codexTokenCount( + timestamp: env.isoString(for: parentDay.addingTimeInterval(2)), + model: model, + total: (input: 30, cached: 8, output: 3)), + ]).write(to: parentURL, atomically: true, encoding: .utf8) + let childHandle = try FileHandle(forWritingTo: childURL) + try childHandle.seekToEnd() + try childHandle.write(contentsOf: Data(env.jsonl([ + self.codexTokenCount( + timestamp: env.isoString(for: childDay.addingTimeInterval(4)), + model: model, + total: (input: 40, cached: 12, output: 6)), + ]).utf8)) + try childHandle.close() + + let second = CostUsageScanner.loadDailyReport( + provider: .codex, + since: childDay, + until: childDay, + now: childDay.addingTimeInterval(1), + options: options) + + #expect(second.data.count == 1) + #expect(second.data[0].inputTokens == 10) + #expect(second.data[0].cacheReadTokens == 4) + #expect(second.data[0].outputTokens == 3) + } + + @Test + func `codex warm cache invalidates fork when missing parent appears`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let parentDay = try env.makeLocalNoon(year: 2026, month: 2, day: 1) + let childDay = try env.makeLocalNoon(year: 2026, month: 3, day: 11) + let model = "openai/gpt-5.2-codex" + let parentSessionId = "sess-parent-appears" + let childSessionId = "sess-child-waiting" + let forkTimestamp = env.isoString(for: parentDay.addingTimeInterval(2)) + + _ = try env.writeCodexSessionFile( + day: childDay, + filename: "rollout-2026-03-11T12-00-00-\(childSessionId).jsonl", + contents: env.jsonl([ + [ + "type": "session_meta", + "payload": [ + "id": childSessionId, + "forked_from_id": parentSessionId, + "timestamp": forkTimestamp, + ], + ], + self.codexTurnContext(timestamp: env.isoString(for: childDay), model: model), + self.codexTokenCount( + timestamp: env.isoString(for: childDay.addingTimeInterval(1)), + model: model, + total: (input: 20, cached: 5, output: 2)), + self.codexTokenCount( + timestamp: env.isoString(for: childDay.addingTimeInterval(2)), + model: model, + total: (input: 30, cached: 8, output: 3)), + self.codexTokenCount( + timestamp: env.isoString(for: childDay.addingTimeInterval(3)), + model: model, + total: (input: 37, cached: 10, output: 5), + last: (input: 7, cached: 2, output: 2)), + ])) + + var options = CostUsageScanner.Options( + codexSessionsRoot: env.codexSessionsRoot, + claudeProjectsRoots: nil, + cacheRoot: env.cacheRoot) + options.refreshMinIntervalSeconds = 0 + + let withoutParent = CostUsageScanner.loadDailyReport( + provider: .codex, + since: childDay, + until: childDay, + now: childDay, + options: options) + #expect(withoutParent.data.first?.inputTokens == 7) + #expect(withoutParent.data.first?.cacheReadTokens == 2) + #expect(withoutParent.data.first?.outputTokens == 2) + + _ = try env.writeCodexSessionFile( + day: parentDay, + filename: "rollout-2026-02-01T12-00-00-\(parentSessionId).jsonl", + contents: env.jsonl([ + ["type": "session_meta", "payload": ["id": parentSessionId]], + self.codexTurnContext(timestamp: env.isoString(for: parentDay), model: model), + self.codexTokenCount( + timestamp: env.isoString(for: parentDay.addingTimeInterval(1)), + model: model, + total: (input: 20, cached: 5, output: 2)), + ])) + + let withParent = CostUsageScanner.loadDailyReport( + provider: .codex, + since: childDay, + until: childDay, + now: childDay.addingTimeInterval(1), + options: options) + #expect(withParent.data.first?.inputTokens == 17) + #expect(withParent.data.first?.cacheReadTokens == 5) + #expect(withParent.data.first?.outputTokens == 3) + } + + @Test + func `codex warm cache invalidates fork when parent file selection changes`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let parentDay = try env.makeLocalNoon(year: 2026, month: 2, day: 1) + let childDay = try env.makeLocalNoon(year: 2026, month: 3, day: 11) + let model = "openai/gpt-5.2-codex" + let parentSessionId = "sess-parent-replaced" + let childSessionId = "sess-child-rebased" + let forkTimestamp = env.isoString(for: parentDay.addingTimeInterval(3)) + let parentMetadata: [String: Any] = [ + "type": "session_meta", + "payload": ["id": parentSessionId], + ] + + let firstParentURL = try env.writeCodexSessionFile( + day: parentDay, + filename: "rollout-2026-02-01T11-00-00-\(parentSessionId).jsonl", + contents: env.jsonl([ + parentMetadata, + self.codexTurnContext(timestamp: env.isoString(for: parentDay), model: model), + self.codexTokenCount( + timestamp: env.isoString(for: parentDay.addingTimeInterval(1)), + model: model, + total: (input: 20, cached: 5, output: 2)), + ])) + + _ = try env.writeCodexSessionFile( + day: childDay, + filename: "rollout-2026-03-11T12-00-00-\(childSessionId).jsonl", + contents: env.jsonl([ + [ + "type": "session_meta", + "payload": [ + "id": childSessionId, + "forked_from_id": parentSessionId, + "timestamp": forkTimestamp, + ], + ], + self.codexTurnContext(timestamp: env.isoString(for: childDay), model: model), + self.codexTokenCount( + timestamp: env.isoString(for: childDay.addingTimeInterval(1)), + model: model, + total: (input: 20, cached: 5, output: 2)), + self.codexTokenCount( + timestamp: env.isoString(for: childDay.addingTimeInterval(2)), + model: model, + total: (input: 30, cached: 8, output: 3)), + self.codexTokenCount( + timestamp: env.isoString(for: childDay.addingTimeInterval(3)), + model: model, + total: (input: 37, cached: 10, output: 5)), + ])) + + var options = CostUsageScanner.Options( + codexSessionsRoot: env.codexSessionsRoot, + claudeProjectsRoots: nil, + cacheRoot: env.cacheRoot) + options.refreshMinIntervalSeconds = 0 + + let first = CostUsageScanner.loadDailyReport( + provider: .codex, + since: childDay, + until: childDay, + now: childDay, + options: options) + #expect(first.data.first?.inputTokens == 17) + #expect(first.data.first?.cacheReadTokens == 5) + #expect(first.data.first?.outputTokens == 3) + + try FileManager.default.removeItem(at: firstParentURL) + _ = try env.writeCodexSessionFile( + day: parentDay, + filename: "rollout-2026-02-01T12-00-00-\(parentSessionId).jsonl", + contents: env.jsonl([ + parentMetadata, + self.codexTurnContext(timestamp: env.isoString(for: parentDay), model: model), + self.codexTokenCount( + timestamp: env.isoString(for: parentDay.addingTimeInterval(1)), + model: model, + total: (input: 30, cached: 8, output: 3)), + ])) + + let second = CostUsageScanner.loadDailyReport( + provider: .codex, + since: childDay, + until: childDay, + now: childDay.addingTimeInterval(1), + options: options) + #expect(second.data.first?.inputTokens == 7) + #expect(second.data.first?.cacheReadTokens == 2) + #expect(second.data.first?.outputTokens == 2) + } + + @Test + func `codex parent dependency key stays bound to parsed snapshots`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let parentDay = try env.makeLocalNoon(year: 2026, month: 2, day: 1) + let model = "openai/gpt-5.2-codex" + let parentSessionId = "sess-parent-key-binding" + let metadata: [String: Any] = [ + "type": "session_meta", + "payload": ["id": parentSessionId], + ] + let parentURL = try env.writeCodexSessionFile( + day: parentDay, + filename: "rollout-2026-02-01T12-00-00-\(parentSessionId).jsonl", + contents: env.jsonl([ + metadata, + self.codexTurnContext(timestamp: env.isoString(for: parentDay), model: model), + self.codexTokenCount( + timestamp: env.isoString(for: parentDay.addingTimeInterval(1)), + model: model, + total: (input: 20, cached: 5, output: 2)), + ])) + let fileIndex = CostUsageScanner.CodexSessionFileIndex(files: [parentURL], roots: []) + let resolver = CostUsageScanner.CodexInheritedTotalsResolver( + fileIndex: fileIndex, + checkCancellation: nil) + + _ = try resolver.inheritedTotals( + for: parentSessionId, + atOrBefore: env.isoString(for: parentDay.addingTimeInterval(2))) + let parsedDependencyKey = resolver.dependencyKeyUsed(for: parentSessionId) + + try env.jsonl([ + metadata, + self.codexTurnContext(timestamp: env.isoString(for: parentDay), model: model), + self.codexTokenCount( + timestamp: env.isoString(for: parentDay.addingTimeInterval(1)), + model: model, + total: (input: 30, cached: 8, output: 3)), + ]).write(to: parentURL, atomically: true, encoding: .utf8) + + #expect(parsedDependencyKey != nil) + #expect(try resolver.currentDependencyKey(for: parentSessionId) != parsedDependencyKey) + #expect(resolver.dependencyKeyUsed(for: parentSessionId) == parsedDependencyKey) + } + + @Test + func `codex unstable parent snapshot keeps fork dependency uncached`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let parentDay = try env.makeLocalNoon(year: 2026, month: 2, day: 1) + let parentSessionId = "sess-parent-unstable" + let model = "openai/gpt-5.2-codex" + let metadata: [String: Any] = [ + "type": "session_meta", + "payload": ["id": parentSessionId], + ] + let firstContents = try env.jsonl([ + metadata, + self.codexTurnContext(timestamp: env.isoString(for: parentDay), model: model), + self.codexTokenCount( + timestamp: env.isoString(for: parentDay.addingTimeInterval(1)), + model: model, + total: (input: 20, cached: 5, output: 2)), + ]) + let secondContents = try env.jsonl([ + metadata, + self.codexTurnContext(timestamp: env.isoString(for: parentDay), model: model), + self.codexTokenCount( + timestamp: env.isoString(for: parentDay.addingTimeInterval(1)), + model: model, + total: (input: 21, cached: 5, output: 2)), + ]) + let parentURL = try env.writeCodexSessionFile( + day: parentDay, + filename: "rollout-2026-02-01T12-00-00-\(parentSessionId).jsonl", + contents: firstContents) + let fileIndex = CostUsageScanner.CodexSessionFileIndex( + files: [parentURL], + roots: [], + cachedSessionFiles: [parentSessionId: parentURL]) + var mutationCount = 0 + let resolver = CostUsageScanner.CodexInheritedTotalsResolver( + fileIndex: fileIndex, + checkCancellation: { + mutationCount += 1 + let contents = mutationCount.isMultiple(of: 2) ? firstContents : secondContents + try contents.write(to: parentURL, atomically: true, encoding: .utf8) + }) + + let baseline = try resolver.inheritedTotals( + for: parentSessionId, + atOrBefore: env.isoString(for: parentDay.addingTimeInterval(2))) + if case .resolved = baseline { + Issue.record("Expected an unstable parent snapshot to stay unresolved") + } + #expect(resolver.dependencyKeyUsed(for: parentSessionId) == nil) + #expect(CostUsageScanner.codexForkBaselineDependencyKey( + parentSessionId: parentSessionId, + dependsOnParentTotals: true, + inheritedResolver: resolver) == nil) + } + @Test func `codex forked child skips cumulative totals when parent session is missing`() throws { let env = try CostUsageTestEnvironment() @@ -1744,6 +4571,444 @@ struct CostUsageScannerBreakdownTests { #expect(parsed.rows.first?.output == 10) } + @Test + func `codex subagent with restarted totals counts its full usage`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 7, day: 14) + let forkTimestamp = env.isoString(for: day) + let model = "openai/gpt-5.4" + let fileURL = try env.writeCodexSessionFile( + day: day, + filename: "rollout-\(forkTimestamp)-child-session.jsonl", + contents: env.jsonl([ + [ + "type": "session_meta", + "timestamp": forkTimestamp, + "payload": [ + "id": "child-session", + "forked_from_id": "parent-session", + "parent_thread_id": "parent-session", + "source": [ + "subagent": [ + "thread_spawn": ["parent_thread_id": "parent-session"], + ], + ], + ], + ], + self.codexTurnContext(timestamp: forkTimestamp, model: model), + self.codexTokenCount( + timestamp: env.isoString(for: day.addingTimeInterval(1)), + model: model, + total: (input: 14700, cached: 12000, output: 700), + last: (input: 14700, cached: 12000, output: 700)), + self.codexTokenCount( + timestamp: env.isoString(for: day.addingTimeInterval(2)), + model: model, + total: (input: 62200, cached: 51000, output: 3200), + last: (input: 47500, cached: 39000, output: 2500)), + ])) + let range = CostUsageScanner.CostUsageDayRange(since: day, until: day) + + let parsed = CostUsageScanner.parseCodexFile( + fileURL: fileURL, + range: range, + inheritedTotalsResolver: { parentSessionId, forkedAt in + #expect(parentSessionId == "parent-session") + #expect(forkedAt == forkTimestamp) + return .resolved(.init(input: 60_000_000, cached: 48_000_000, output: 3_000_000)) + }) + + let dayKey = CostUsageScanner.CostUsageDayRange.dayKey(from: day) + let normalized = CostUsagePricing.normalizeCodexModel(model) + let packed = parsed.days[dayKey]?[normalized] ?? [] + #expect(packed == [62200, 51000, 3200]) + } + + @Test + func `codex metadata lookahead recognizes a total-only explicit subagent counter`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 7, day: 14) + let forkTimestamp = env.isoString(for: day) + let model = "openai/gpt-5.4" + let fileURL = try env.writeCodexSessionFile( + day: day, + filename: "rollout-\(forkTimestamp)-late-child-session.jsonl", + contents: env.jsonl([ + self.codexTurnContext(timestamp: forkTimestamp, model: model), + self.codexTokenCount( + timestamp: env.isoString(for: day.addingTimeInterval(1)), + model: model, + total: (input: 14700, cached: 12000, output: 700)), + [ + "type": "session_meta", + "timestamp": forkTimestamp, + "payload": [ + "id": "child-session", + "forked_from_id": "parent-session", + "timestamp": forkTimestamp, + "source": [ + "subagent": [ + "thread_spawn": ["parent_thread_id": "parent-session"], + ], + ], + ], + ], + self.codexTokenCount( + timestamp: env.isoString(for: day.addingTimeInterval(2)), + model: model, + total: (input: 62200, cached: 51000, output: 3200), + last: (input: 47500, cached: 39000, output: 2500)), + ])) + let range = CostUsageScanner.CostUsageDayRange(since: day, until: day) + + let parsed = CostUsageScanner.parseCodexFile( + fileURL: fileURL, + range: range, + inheritedTotalsResolver: { parentSessionId, forkedAt in + #expect(parentSessionId == "parent-session") + #expect(forkedAt == forkTimestamp) + return .resolved(.init(input: 60_000_000, cached: 48_000_000, output: 3_000_000)) + }) + + let dayKey = CostUsageScanner.CostUsageDayRange.dayKey(from: day) + let normalized = CostUsagePricing.normalizeCodexModel(model) + #expect(parsed.days[dayKey]?[normalized] == [62200, 51000, 3200]) + } + + @Test + func `codex bare parent thread id with continuing totals keeps fork baseline`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 7, day: 14) + let forkTimestamp = env.isoString(for: day) + let model = "openai/gpt-5.4" + let fileURL = try env.writeCodexSessionFile( + day: day, + filename: "rollout-\(forkTimestamp)-continued-child-session.jsonl", + contents: env.jsonl([ + [ + "type": "session_meta", + "timestamp": forkTimestamp, + "payload": [ + "id": "child-session", + "forked_from_id": "parent-session", + "parent_thread_id": "parent-session", + "timestamp": forkTimestamp, + ], + ], + self.codexTurnContext(timestamp: forkTimestamp, model: model), + self.codexTokenCount( + timestamp: env.isoString(for: day.addingTimeInterval(1)), + model: model, + total: (input: 1001, cached: 900, output: 101), + last: (input: 1, cached: 0, output: 1)), + ])) + let range = CostUsageScanner.CostUsageDayRange(since: day, until: day) + + var resolvedParentBaseline = false + let parsed = CostUsageScanner.parseCodexFile( + fileURL: fileURL, + range: range, + inheritedTotalsResolver: { _, _ in + resolvedParentBaseline = true + return .resolved(.init(input: 1000, cached: 900, output: 100)) + }) + + let dayKey = CostUsageScanner.CostUsageDayRange.dayKey(from: day) + let normalized = CostUsagePricing.normalizeCodexModel(model) + #expect(parsed.days[dayKey]?[normalized] == [1, 0, 1]) + #expect(resolvedParentBaseline) + } + + @Test + func `codex subagent provenance matrix preserves explicit source and parser parity`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 7, day: 14) + let forkTimestamp = env.isoString(for: day) + let model = "openai/gpt-5.4" + typealias ProvenanceCase = ( + input: (name: String, source: Any, parentThreadId: String?, forceFallback: Bool), + expected: (tokens: [Int], resolvesParent: Bool)) + let cases: [ProvenanceCase] = [ + (("explicit-cli", "cli", "parent-session", false), ([50, 10, 5], true)), + (("bare-subagent", "subagent", nil, false), ([1050, 910, 105], false)), + (("fast-unit-subagent", ["subagent": "review"], nil, false), ([1050, 910, 105], false)), + (("fallback-unit-subagent", ["subagent": "review"], nil, true), ([1050, 910, 105], false)), + ] + + for testCase in cases { + var payload: [String: Any] = [ + "id": "child-\(testCase.input.name)", + "forked_from_id": "parent-session", + "timestamp": forkTimestamp, + "source": testCase.input.source, + ] + if let parentThreadId = testCase.input.parentThreadId { + payload["parent_thread_id"] = parentThreadId + } + var metadata = try env.jsonl([[ + "type": "session_meta", + "timestamp": forkTimestamp, + "payload": payload, + ]]) + if testCase.input.forceFallback { + metadata = metadata.replacingOccurrences(of: "\"type\"", with: "\"ty\\u0070e\"") + } + let events = try env.jsonl([ + self.codexTurnContext(timestamp: forkTimestamp, model: model), + self.codexTokenCount( + timestamp: env.isoString(for: day.addingTimeInterval(1)), + model: model, + total: (input: 1050, cached: 910, output: 105), + last: (input: 50, cached: 10, output: 5)), + ]) + let fileURL = try env.writeCodexSessionFile( + day: day, + filename: "rollout-\(forkTimestamp)-\(testCase.input.name).jsonl", + contents: metadata + "\n" + events) + + var resolvedParent = false + let parsed = CostUsageScanner.parseCodexFile( + fileURL: fileURL, + range: CostUsageScanner.CostUsageDayRange(since: day, until: day), + inheritedTotalsResolver: { parentSessionId, _ in + resolvedParent = true + #expect(parentSessionId == "parent-session") + return .resolved(.init(input: 1000, cached: 900, output: 100)) + }) + + let dayKey = CostUsageScanner.CostUsageDayRange.dayKey(from: day) + let normalized = CostUsagePricing.normalizeCodexModel(model) + #expect(parsed.days[dayKey]?[normalized] == testCase.expected.tokens) + #expect(resolvedParent == testCase.expected.resolvesParent) + } + } + + @Test + func `codex nested source subagent counts without resolving a missing parent`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 7, day: 14) + let forkTimestamp = env.isoString(for: day) + let model = "openai/gpt-5.4" + let fileURL = try env.writeCodexSessionFile( + day: day, + filename: "rollout-\(forkTimestamp)-nested-source-child.jsonl", + contents: env.jsonl([ + [ + "type": "session_meta", + "timestamp": forkTimestamp, + "payload": [ + "id": "child-session", + "forked_from_id": "missing-parent", + "timestamp": forkTimestamp, + "source": [ + "subagent": [ + "thread_spawn": ["parent_thread_id": "missing-parent"], + ], + ], + ], + ], + self.codexTurnContext(timestamp: forkTimestamp, model: model), + self.codexTokenCount( + timestamp: env.isoString(for: day.addingTimeInterval(1)), + model: model, + total: (input: 1000, cached: 900, output: 100), + last: (input: 1000, cached: 900, output: 100)), + ])) + + var resolvedParentBaseline = false + let parsed = CostUsageScanner.parseCodexFile( + fileURL: fileURL, + range: CostUsageScanner.CostUsageDayRange(since: day, until: day), + inheritedTotalsResolver: { _, _ in + resolvedParentBaseline = true + return .unresolved + }) + + let dayKey = CostUsageScanner.CostUsageDayRange.dayKey(from: day) + let normalized = CostUsagePricing.normalizeCodexModel(model) + #expect(parsed.days[dayKey]?[normalized] == [1000, 900, 100]) + #expect(!resolvedParentBaseline) + } + + @Test + func `codex subagent with only last-token records counts full usage`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 7, day: 14) + let forkTimestamp = env.isoString(for: day) + let model = "openai/gpt-5.4" + let fileURL = try env.writeCodexSessionFile( + day: day, + filename: "rollout-\(forkTimestamp)-last-only-child.jsonl", + contents: env.jsonl([ + [ + "type": "session_meta", + "timestamp": forkTimestamp, + "payload": [ + "id": "child-session", + "forked_from_id": "parent-session", + "timestamp": forkTimestamp, + "source": [ + "subagent": [ + "thread_spawn": ["parent_thread_id": "parent-session"], + ], + ], + ], + ], + self.codexTurnContext(timestamp: forkTimestamp, model: model), + self.codexTokenCount( + timestamp: env.isoString(for: day.addingTimeInterval(1)), + model: model, + last: (input: 10, cached: 2, output: 1)), + ])) + + let parsed = CostUsageScanner.parseCodexFile( + fileURL: fileURL, + range: CostUsageScanner.CostUsageDayRange(since: day, until: day), + inheritedTotalsResolver: { _, _ in + .resolved(.init(input: 1000, cached: 900, output: 100)) + }) + + let dayKey = CostUsageScanner.CostUsageDayRange.dayKey(from: day) + let normalized = CostUsagePricing.normalizeCodexModel(model) + #expect(parsed.days[dayKey]?[normalized] == [10, 2, 1]) + } + + @Test + func `codex daily report sums parent and restarted subagent totals`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 7, day: 14) + let parentTimestamp = env.isoString(for: day) + let forkDate = day.addingTimeInterval(3) + let forkTimestamp = env.isoString(for: forkDate) + let model = "openai/gpt-5.4" + let projectPath = "/tmp/codexbar-2193-project" + + _ = try env.writeCodexSessionFile( + day: day, + filename: "rollout-\(parentTimestamp)-parent-session.jsonl", + contents: env.jsonl([ + [ + "type": "session_meta", + "id": "parent-session", + "timestamp": parentTimestamp, + "payload": [ + "session_id": "shared-agent-tree", + "timestamp": parentTimestamp, + "cwd": projectPath, + ], + ], + self.codexTurnContext(timestamp: parentTimestamp, model: model), + self.codexTokenCount( + timestamp: env.isoString(for: day.addingTimeInterval(1)), + model: model, + total: (input: 60_000_000, cached: 48_000_000, output: 3_000_000), + last: (input: 60_000_000, cached: 48_000_000, output: 3_000_000)), + ])) + _ = try env.writeCodexSessionFile( + day: day, + filename: "rollout-\(forkTimestamp)-child-session.jsonl", + contents: env.jsonl([ + [ + "type": "session_meta", + "id": "child-session", + "timestamp": forkTimestamp, + "payload": [ + "session_id": "shared-agent-tree", + "forked_from_id": "parent-session", + "parent_thread_id": "parent-session", + "timestamp": forkTimestamp, + "cwd": projectPath, + "source": [ + "subagent": [ + "thread_spawn": ["parent_thread_id": "parent-session"], + ], + ], + ], + ], + self.codexTurnContext(timestamp: forkTimestamp, model: model), + self.codexTokenCount( + timestamp: env.isoString(for: forkDate.addingTimeInterval(1)), + model: model, + total: (input: 14700, cached: 12000, output: 700), + last: (input: 14700, cached: 12000, output: 700)), + self.codexTokenCount( + timestamp: env.isoString(for: forkDate.addingTimeInterval(2)), + model: model, + total: (input: 62200, cached: 51000, output: 3200), + last: (input: 47500, cached: 39000, output: 2500)), + ])) + + var options = CostUsageScanner.Options( + codexSessionsRoot: env.codexSessionsRoot, + claudeProjectsRoots: nil, + cacheRoot: env.cacheRoot) + options.refreshMinIntervalSeconds = 0 + let coldReport = CostUsageScanner.loadDailyReport( + provider: .codex, + since: day, + until: day, + now: forkDate.addingTimeInterval(3), + options: options) + let warmReport = CostUsageScanner.loadDailyReport( + provider: .codex, + since: day, + until: day, + now: forkDate.addingTimeInterval(4), + options: options) + options.forceRescan = true + let report = CostUsageScanner.loadDailyReport( + provider: .codex, + since: day, + until: day, + now: forkDate.addingTimeInterval(5), + options: options) + + #expect(coldReport.data.first?.totalTokens == 63_065_400) + #expect(warmReport.data.first?.totalTokens == 63_065_400) + #expect(report.data.count == 1) + #expect(report.data[0].inputTokens == 60_062_200) + #expect(report.data[0].cacheReadTokens == 48_051_000) + #expect(report.data[0].outputTokens == 3_003_200) + #expect(report.data[0].totalTokens == 63_065_400) + let parentCost = CostUsagePricing.codexCostUSD( + model: model, + inputTokens: 60_000_000, + cachedInputTokens: 48_000_000, + outputTokens: 3_000_000) ?? 0 + let childCost = CostUsagePricing.codexCostUSD( + model: model, + inputTokens: 62200, + cachedInputTokens: 51000, + outputTokens: 3200) ?? 0 + let expectedCost = parentCost + childCost + #expect(abs((report.data[0].costUSD ?? 0) - expectedCost) < 0.000001) + + let cache = CostUsageCacheIO.load(provider: .codex, cacheRoot: env.cacheRoot) + let childUsage = try #require(cache.files.values.first(where: { $0.sessionId == "child-session" })) + #expect(childUsage.forkBaselineDependencyKey == CostUsageScanner.codexForkDependencyNotRequiredKey) + let projects = CostUsageScanner.buildCodexProjectBreakdownsFromCache( + cache: cache, + range: CostUsageScanner.CostUsageDayRange(since: day, until: day), + modelsDevCacheRoot: env.cacheRoot) + let project = try #require(projects.first(where: { $0.path == projectPath })) + #expect(project.totalTokens == 63_065_400) + #expect(abs((project.totalCostUSD ?? 0) - expectedCost) < 0.000001) + } + @Test func `codex fork skips last usage when parent baseline is unresolved`() throws { let env = try CostUsageTestEnvironment() @@ -3473,6 +6738,10 @@ struct CostUsageScannerBreakdownTests { """ return try JSONDecoder().decode(ModelsDevCatalog.self, from: Data(json.utf8)) } + + private static func sha256Hex(_ value: String) -> String { + SHA256.hash(data: Data(value.utf8)).map { String(format: "%02x", $0) }.joined() + } } // swiftlint:enable type_body_length diff --git a/Tests/CodexBarTests/CostUsageScannerClaudeDesktopTests.swift b/Tests/CodexBarTests/CostUsageScannerClaudeDesktopTests.swift new file mode 100644 index 0000000000..3db56c7272 --- /dev/null +++ b/Tests/CodexBarTests/CostUsageScannerClaudeDesktopTests.swift @@ -0,0 +1,161 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct CostUsageScannerClaudeDesktopTests { + @Test + func `claude daily report includes nested desktop local agent projects`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 7, day: 5) + let iso0 = env.isoString(for: day) + let assistant: [String: Any] = [ + "type": "assistant", + "timestamp": iso0, + "message": [ + "model": "claude-test-model", + "usage": [ + "input_tokens": 120, + "cache_creation_input_tokens": 30, + "cache_read_input_tokens": 20, + "output_tokens": 40, + ], + ], + ] + let nestedAssistant: [String: Any] = [ + "type": "assistant", + "timestamp": iso0, + "message": [ + "model": "claude-test-model", + "usage": [ + "input_tokens": 10, + "output_tokens": 5, + ], + ], + ] + let currentAssistant: [String: Any] = [ + "type": "assistant", + "timestamp": iso0, + "message": [ + "model": "claude-test-model", + "usage": [ + "input_tokens": 7, + "output_tokens": 3, + ], + ], + ] + let decoyAssistant: [String: Any] = [ + "type": "assistant", + "timestamp": iso0, + "message": [ + "model": "claude-test-model", + "usage": [ + "input_tokens": 999, + "output_tokens": 999, + ], + ], + ] + let projectsRoot = try env.writeClaudeDesktopLocalAgentProjectFile( + relativePath: "project-a/session-a.jsonl", + contents: env.jsonl([assistant])) + .deletingLastPathComponent() + .deletingLastPathComponent() + let nestedProjectsRoot = try env.writeNestedClaudeDesktopLocalAgentProjectFile( + relativePath: "project-b/session-b.jsonl", + contents: env.jsonl([nestedAssistant])) + .deletingLastPathComponent() + .deletingLastPathComponent() + let currentProjectsRoot = try env.writeClaudeDesktopCodeSessionProjectFile( + relativePath: "project-c/session-c.jsonl", + contents: env.jsonl([currentAssistant])) + .deletingLastPathComponent() + .deletingLastPathComponent() + let decoyProjectsRoot = try env.writeClaudeDesktopLocalAgentFile( + relativePath: "outputs/node_modules/package/.claude/projects/project-decoy/session-decoy.jsonl", + contents: env.jsonl([decoyAssistant])) + .deletingLastPathComponent() + .deletingLastPathComponent() + + let discovered = CostUsageScanner.defaultClaudeProjectsRoots( + options: CostUsageScanner.Options(cacheRoot: env.cacheRoot), + environment: [:], + homeDirectory: env.root) + #expect(discovered.contains(projectsRoot.standardizedFileURL)) + #expect(discovered.contains(nestedProjectsRoot.standardizedFileURL)) + #expect(discovered.contains(currentProjectsRoot.standardizedFileURL)) + #expect(!discovered.contains(decoyProjectsRoot.standardizedFileURL)) + + var options = CostUsageScanner.Options( + codexSessionsRoot: nil, + claudeProjectsRoots: discovered, + cacheRoot: env.cacheRoot) + options.refreshMinIntervalSeconds = 0 + + let report = CostUsageScanner.loadDailyReport( + provider: .claude, + since: day, + until: day, + now: day, + options: options) + + #expect(report.data.count == 1) + #expect(report.data[0].inputTokens == 137) + #expect(report.data[0].cacheCreationTokens == 30) + #expect(report.data[0].cacheReadTokens == 20) + #expect(report.data[0].outputTokens == 48) + #expect(report.data[0].totalTokens == 235) + } + + @Test + func `current desktop shared claude projects root remains discovered`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 7, day: 5) + let sessionID = "desktop-cli-session" + let assistant: [String: Any] = [ + "type": "assistant", + "timestamp": env.isoString(for: day), + "message": [ + "model": "claude-test-model", + "usage": [ + "input_tokens": 11, + "cache_read_input_tokens": 13, + "output_tokens": 4, + ], + ], + ] + // Current Desktop's cliSessionId points to the matching JSONL in this shared root. + let sharedProjectsRoot = try env.writeClaudeDesktopSharedProjectFile( + relativePath: "desktop-project/\(sessionID).jsonl", + contents: env.jsonl([assistant])) + .deletingLastPathComponent() + .deletingLastPathComponent() + + let discovered = CostUsageScanner.defaultClaudeProjectsRoots( + options: CostUsageScanner.Options(cacheRoot: env.cacheRoot), + environment: [:], + homeDirectory: env.root) + #expect(discovered.contains(sharedProjectsRoot.standardizedFileURL)) + + var options = CostUsageScanner.Options( + codexSessionsRoot: nil, + claudeProjectsRoots: discovered, + cacheRoot: env.cacheRoot) + options.refreshMinIntervalSeconds = 0 + + let report = CostUsageScanner.loadDailyReport( + provider: .claude, + since: day, + until: day, + now: day, + options: options) + + #expect(report.data.count == 1) + #expect(report.data[0].inputTokens == 11) + #expect(report.data[0].cacheReadTokens == 13) + #expect(report.data[0].outputTokens == 4) + #expect(report.data[0].totalTokens == 28) + } +} diff --git a/Tests/CodexBarTests/CostUsageScannerClaudeFableTests.swift b/Tests/CodexBarTests/CostUsageScannerClaudeFableTests.swift new file mode 100644 index 0000000000..0d8beb4b75 --- /dev/null +++ b/Tests/CodexBarTests/CostUsageScannerClaudeFableTests.swift @@ -0,0 +1,457 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct CostUsageScannerClaudeFableTests { + @Test + func `claude fable 5 issue row gets priced`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 6, day: 9) + let fileURL = try env.writeClaudeProjectFile( + relativePath: "project-a/fable-5.jsonl", + contents: env.jsonl([ + [ + "message": [ + "model": "claude-fable-5", + "id": "msg_fable_5", + "type": "message", + "role": "assistant", + "usage": [ + "input_tokens": 100, + "cache_creation_input_tokens": 10, + "cache_read_input_tokens": 20, + "output_tokens": 5, + ], + ], + "requestId": "req_fable_5", + "type": "assistant", + "timestamp": "2026-06-09T12:00:00.000Z", + "sessionId": "session_fable_5", + ], + ])) + + 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-fable-5") + let expected = 0.001395 + #expect(abs((Double(parsed.rows[0].costNanos) / 1_000_000_000) - expected) < 0.000000001) + } + + @Test + func `claude transcript refusal remains priced without billing provenance`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 6, day: 9) + let fileURL = try env.writeClaudeProjectFile( + relativePath: "project-a/fable-5-refusal.jsonl", + contents: env.jsonl([ + [ + "message": [ + "model": "claude-fable-5", + "id": "msg_fable_5_refusal", + "type": "message", + "role": "assistant", + "stop_reason": "refusal", + "usage": [ + "input_tokens": 100, + "cache_creation_input_tokens": 10, + "cache_read_input_tokens": 20, + "output_tokens": 0, + ], + ], + "requestId": "req_fable_5_refusal", + "type": "assistant", + "timestamp": "2026-06-09T12:00:00.000Z", + "sessionId": "session_fable_5_refusal", + ], + ])) + + let parsed = CostUsageScanner.parseClaudeFile( + fileURL: fileURL, + range: CostUsageScanner.CostUsageDayRange(since: day, until: day), + providerFilter: .all) + + #expect(parsed.rows.count == 1) + #expect(parsed.rows[0].input == 100) + #expect(parsed.rows[0].cacheCreate == 10) + #expect(parsed.rows[0].cacheRead == 20) + #expect(parsed.rows[0].output == 0) + let expected = 0.001145 + #expect(abs((Double(parsed.rows[0].costNanos) / 1_000_000_000) - expected) < 0.000000001) + #expect(parsed.rows[0].costPriced == true) + } + + @Test + func `claude fable 5 prices one hour cache creation tokens`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 6, day: 9) + let fileURL = try env.writeClaudeProjectFile( + relativePath: "project-a/fable-5-cache-ttl.jsonl", + contents: env.jsonl([ + [ + "message": [ + "model": "claude-fable-5", + "id": "msg_fable_5_cache_ttl", + "type": "message", + "role": "assistant", + "usage": [ + "input_tokens": 100, + "cache_creation_input_tokens": 30, + "cache_creation": [ + "ephemeral_5m_input_tokens": 10, + "ephemeral_1h_input_tokens": 20, + ], + "cache_read_input_tokens": 20, + "output_tokens": 5, + ], + ], + "requestId": "req_fable_5_cache_ttl", + "type": "assistant", + "timestamp": "2026-06-09T12:00:00.000Z", + "sessionId": "session_fable_5_cache_ttl", + ], + ])) + + let parsed = CostUsageScanner.parseClaudeFile( + fileURL: fileURL, + range: CostUsageScanner.CostUsageDayRange(since: day, until: day), + providerFilter: .all) + + #expect(parsed.rows.count == 1) + #expect(parsed.rows[0].cacheCreate == 30) + #expect(parsed.rows[0].cacheCreate1h == 20) + let expected = 0.001795 + #expect(abs((Double(parsed.rows[0].costNanos) / 1_000_000_000) - expected) < 0.000000001) + } + + @Test + func `claude cached rows preserve one hour writes for deferred pricing`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 6, day: 9) + _ = try env.writeClaudeProjectFile( + relativePath: "project-a/custom-cache-ttl.jsonl", + contents: env.jsonl([ + [ + "message": [ + "model": "claude-custom-cache-model", + "id": "msg_custom_cache_ttl", + "type": "message", + "role": "assistant", + "usage": [ + "input_tokens": 100, + "cache_creation_input_tokens": 30, + "cache_creation": [ + "ephemeral_5m_input_tokens": 10, + "ephemeral_1h_input_tokens": 20, + ], + "cache_read_input_tokens": 20, + "output_tokens": 5, + ], + ], + "requestId": "req_custom_cache_ttl", + "type": "assistant", + "timestamp": "2026-06-09T12:00:00.000Z", + "sessionId": "session_custom_cache_ttl", + ], + ])) + + var options = CostUsageScanner.Options( + claudeProjectsRoots: [env.claudeProjectsRoot], + cacheRoot: env.cacheRoot) + options.refreshMinIntervalSeconds = 0 + + let unpriced = CostUsageScanner.loadDailyReport( + provider: .claude, + since: day, + until: day, + now: day, + options: options) + #expect(unpriced.summary?.totalCostUSD == nil) + + let cached = CostUsageCacheIO.load(provider: .claude, cacheRoot: env.cacheRoot) + #expect(cached.days["2026-06-09"]?["claude-custom-cache-model"]?[safe: 7] == 20) + + try ModelsDevCache.save( + catalog: Self.anthropicModelsDevCatalog(model: "claude-custom-cache-model"), + fetchedAt: day, + cacheRoot: env.cacheRoot) + options.refreshMinIntervalSeconds = 3600 + + let repriced = CostUsageScanner.loadDailyReport( + provider: .claude, + since: day, + until: day, + now: day.addingTimeInterval(1), + options: options) + + let expected = 0.001795 + #expect(abs((repriced.summary?.totalCostUSD ?? 0) - expected) < 0.000000001) + } + + @Test + func `claude deferred pricing preserves request long context boundaries`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 6, day: 9) + _ = try env.writeClaudeProjectFile( + relativePath: "project-a/custom-threshold.jsonl", + contents: env.jsonl([ + Self.claudeUsageEvent( + model: "claude-custom-threshold-model", + messageID: "msg_custom_threshold_1", + requestID: "req_custom_threshold_1", + inputTokens: 150_000), + Self.claudeUsageEvent( + model: "claude-custom-threshold-model", + messageID: "msg_custom_threshold_2", + requestID: "req_custom_threshold_2", + inputTokens: 150_000), + ])) + + var options = CostUsageScanner.Options( + claudeProjectsRoots: [env.claudeProjectsRoot], + cacheRoot: env.cacheRoot) + options.refreshMinIntervalSeconds = 0 + + let unpriced = CostUsageScanner.loadDailyReport( + provider: .claude, + since: day, + until: day, + now: day, + options: options) + #expect(unpriced.summary?.totalCostUSD == nil) + + try ModelsDevCache.save( + catalog: Self.anthropicThresholdModelsDevCatalog(model: "claude-custom-threshold-model"), + fetchedAt: day, + cacheRoot: env.cacheRoot) + options.refreshMinIntervalSeconds = 3600 + + let repriced = CostUsageScanner.loadDailyReport( + provider: .claude, + since: day, + until: day, + now: day.addingTimeInterval(1), + options: options) + + #expect(abs((repriced.summary?.totalCostUSD ?? 0) - 3) < 0.000000001) + } + + @Test + func `claude cached rows reprice after models dev catalog changes`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 6, day: 9) + let model = "claude-custom-repricing-model" + _ = try env.writeClaudeProjectFile( + relativePath: "project-a/custom-repricing.jsonl", + contents: env.jsonl([ + Self.claudeUsageEvent( + model: model, + messageID: "msg_custom_repricing", + requestID: "req_custom_repricing", + inputTokens: 240_000), + ])) + try ModelsDevCache.save( + catalog: Self.anthropicThresholdModelsDevCatalog(model: model), + fetchedAt: day, + cacheRoot: env.cacheRoot) + + var options = CostUsageScanner.Options( + claudeProjectsRoots: [env.claudeProjectsRoot], + cacheRoot: env.cacheRoot) + options.refreshMinIntervalSeconds = 0 + + let premium = CostUsageScanner.loadDailyReport( + provider: .claude, + since: day, + until: day, + now: day, + options: options) + #expect(abs((premium.summary?.totalCostUSD ?? 0) - 4.8) < 0.000000001) + + try ModelsDevCache.save( + catalog: Self.anthropicModelsDevCatalog(model: model), + fetchedAt: day.addingTimeInterval(1), + cacheRoot: env.cacheRoot) + options.refreshMinIntervalSeconds = 3600 + + let repriced = CostUsageScanner.loadDailyReport( + provider: .claude, + since: day, + until: day, + now: day.addingTimeInterval(2), + options: options) + + #expect(abs((repriced.summary?.totalCostUSD ?? 0) - 2.4) < 0.000000001) + } + + @Test + func `claude cached historical rows keep original tariff`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 3, day: 12) + _ = try env.writeClaudeProjectFile( + relativePath: "project-a/sonnet-46-historical.jsonl", + contents: env.jsonl([ + [ + "message": [ + "model": "claude-sonnet-4-6", + "id": "msg_sonnet_46_historical", + "type": "message", + "role": "assistant", + "usage": [ + "input_tokens": 240_000, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 0, + "output_tokens": 0, + ], + ], + "requestId": "req_sonnet_46_historical", + "type": "assistant", + "timestamp": "2026-03-12T12:00:00.000Z", + "sessionId": "session_sonnet_46_historical", + ], + ])) + + var options = CostUsageScanner.Options( + claudeProjectsRoots: [env.claudeProjectsRoot], + cacheRoot: env.cacheRoot) + options.refreshMinIntervalSeconds = 0 + + let initial = CostUsageScanner.loadDailyReport( + provider: .claude, + since: day, + until: day, + now: day, + options: options) + #expect(abs((initial.summary?.totalCostUSD ?? 0) - 1.44) < 0.000000001) + + try ModelsDevCache.save( + catalog: Self.anthropicSonnet46StandardCatalog(), + fetchedAt: day, + cacheRoot: env.cacheRoot) + options.refreshMinIntervalSeconds = 3600 + + let cached = CostUsageScanner.loadDailyReport( + provider: .claude, + since: day, + until: day, + now: day.addingTimeInterval(1), + options: options) + + #expect(abs((cached.summary?.totalCostUSD ?? 0) - 1.44) < 0.000000001) + } + + private static func anthropicModelsDevCatalog(model: String) throws -> ModelsDevCatalog { + let json = """ + { + "anthropic": { + "id": "anthropic", + "models": { + "\(model)": { + "id": "\(model)", + "cost": { + "input": 10, + "output": 50, + "cache_read": 1, + "cache_write": 12.5 + } + } + } + } + } + """ + return try JSONDecoder().decode(ModelsDevCatalog.self, from: Data(json.utf8)) + } + + private static func anthropicThresholdModelsDevCatalog(model: String) throws -> ModelsDevCatalog { + let json = """ + { + "anthropic": { + "id": "anthropic", + "models": { + "\(model)": { + "id": "\(model)", + "cost": { + "input": 10, + "output": 50, + "cache_read": 1, + "cache_write": 12.5, + "context_over_200k": { + "input": 20, + "output": 75, + "cache_read": 2, + "cache_write": 25 + } + } + } + } + } + } + """ + return try JSONDecoder().decode(ModelsDevCatalog.self, from: Data(json.utf8)) + } + + private static func anthropicSonnet46StandardCatalog() throws -> ModelsDevCatalog { + let json = """ + { + "anthropic": { + "id": "anthropic", + "models": { + "claude-sonnet-4-6": { + "id": "claude-sonnet-4-6", + "cost": { + "input": 3, + "output": 15, + "cache_read": 0.3, + "cache_write": 3.75 + } + } + } + } + } + """ + return try JSONDecoder().decode(ModelsDevCatalog.self, from: Data(json.utf8)) + } + + private static func claudeUsageEvent( + model: String, + messageID: String, + requestID: String, + inputTokens: Int) -> [String: Any] + { + [ + "message": [ + "model": model, + "id": messageID, + "type": "message", + "role": "assistant", + "usage": [ + "input_tokens": inputTokens, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 0, + "output_tokens": 0, + ], + ], + "requestId": requestID, + "type": "assistant", + "timestamp": "2026-06-09T12:00:00.000Z", + "sessionId": "session_\(requestID)", + ] + } +} diff --git a/Tests/CodexBarTests/CostUsageScannerClaudeRegressionTests.swift b/Tests/CodexBarTests/CostUsageScannerClaudeRegressionTests.swift index 7600dfc09a..5cacd77a4e 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/CostUsageScannerCodexPriorityTests.swift b/Tests/CodexBarTests/CostUsageScannerCodexPriorityTests.swift index de842296fc..65f8698850 100644 --- a/Tests/CodexBarTests/CostUsageScannerCodexPriorityTests.swift +++ b/Tests/CodexBarTests/CostUsageScannerCodexPriorityTests.swift @@ -9,13 +9,14 @@ struct CostUsageScannerCodexPriorityTests { func `parses priority turn metadata without exposing request body`() { let body = "INFO thread_id=11111111-1111-1111-1111-111111111111 " + "turn.id=22222222-2222-2222-2222-222222222222 websocket request: " - + #"{"type":"response.create","model":"gpt-5.5","service_tier":"priority","instructions":"secret prompt"}"# + + #"{"type":"response.create","model":"request-model","service_tier":"priority","# + + #""instructions":"secret prompt"}"# let parsed = CostUsageScanner.parseCodexPriorityTraceRow(timestamp: "2026-05-10T12:00:00Z", body: body) #expect(parsed?.threadID == "11111111-1111-1111-1111-111111111111") #expect(parsed?.turnID == "22222222-2222-2222-2222-222222222222") - #expect(parsed?.model == "gpt-5.5") + #expect(parsed?.model == "request-model") #expect(parsed?.timestamp == "2026-05-10T12:00:00Z") } @@ -40,12 +41,12 @@ struct CostUsageScannerCodexPriorityTests { @Test func `parses completed response model without exposing response body`() { let body = "INFO thread_id=thread turn.id=turn websocket event: " - + #"{"type":"response.completed","response":{"model":"gpt-5.4","output":[{"content":"private"}]}}"# + + #"{"type":"response.completed","response":{"model":"completed-model","output":[{"content":"private"}]}}"# let parsed = CostUsageScanner.parseCodexCompletedTraceRow(body: body) #expect(parsed?.turnID == "turn") - #expect(parsed?.model == "gpt-5.4") + #expect(parsed?.model == "completed-model") } @Test @@ -58,19 +59,58 @@ struct CostUsageScannerCodexPriorityTests { dbURL: dbURL, timestamp: "2026-05-10T12:00:00Z", body: "thread_id=thread-a turn.id=turn-a websocket request: " - + #"{"type":"response.create","model":"gpt-5.5","service_tier":"priority","input":"private"}"#) + + #"{"type":"response.create","model":"request-model","service_tier":"priority","input":"private"}"#) try Self.insertTestLog( dbURL: dbURL, timestamp: "2026-05-10T12:01:00Z", body: """ - thread_id=thread-b turn.id=turn-b websocket request: {"type":"response.create","model":"gpt-5.5"} + thread_id=thread-b turn.id=turn-b websocket request: {"type":"response.create","model":"request-model"} """) let turns = CostUsageScanner.codexPriorityTurns(databaseURL: dbURL) #expect(turns.keys.sorted() == ["turn-a"]) #expect(turns["turn-a"]?.threadID == "thread-a") - #expect(turns["turn-a"]?.model == "gpt-5.5") + #expect(turns["turn-a"]?.model == "request-model") + } + + @Test + func `cold scan uses timestamp index and warm scan uses rowid cursor`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + let dbURL = env.root.appendingPathComponent("logs_2.sqlite") + try Self.createTestLogsDatabase(at: dbURL) + + var db: OpaquePointer? + guard sqlite3_open_v2(dbURL.path, &db, SQLITE_OPEN_READONLY, nil) == SQLITE_OK else { + throw SQLiteTestError.open + } + defer { sqlite3_close(db) } + + let coldQuery = CostUsageScanner._test_codexPriorityAccumulationQuery( + db, + lastRowID: 0, + coverageSinceEpoch: 1) + let coldPlan = try Self.queryPlan(db: db, query: coldQuery, bindings: [1]) + #expect(coldPlan.contains { $0.contains("USING INDEX idx_logs_ts") }) + + let unboundedColdQuery = CostUsageScanner._test_codexPriorityAccumulationQuery( + db, + lastRowID: 0, + coverageSinceEpoch: 0) + let unboundedColdPlan = try Self.queryPlan( + db: db, + query: unboundedColdQuery, + bindings: [0, 0]) + #expect(unboundedColdPlan.contains { $0.contains("USING INTEGER PRIMARY KEY") }) + #expect(!unboundedColdPlan.contains { $0.contains("USE TEMP B-TREE") }) + + let warmQuery = CostUsageScanner._test_codexPriorityAccumulationQuery( + db, + lastRowID: 1, + coverageSinceEpoch: 0) + let warmPlan = try Self.queryPlan(db: db, query: warmQuery, bindings: [1, 0]) + #expect(warmPlan.contains { $0.contains("USING INTEGER PRIMARY KEY") }) } @Test @@ -83,16 +123,16 @@ struct CostUsageScannerCodexPriorityTests { dbURL: dbURL, timestamp: "2026-05-10T12:00:00Z", body: "thread_id=thread turn.id=turn websocket request: " - + #"{"type":"response.create","model":"codex-auto-review","service_tier":"priority"}"#) + + #"{"type":"response.create","model":"request-alias","service_tier":"priority"}"#) try Self.insertTestLog( dbURL: dbURL, timestamp: "2026-05-10T12:00:01Z", body: "thread_id=thread turn.id=turn websocket event: " - + #"{"type":"response.completed","response":{"model":"gpt-5.4","input":"private"}}"#) + + #"{"type":"response.completed","response":{"model":"completed-model","input":"private"}}"#) let turns = CostUsageScanner.codexPriorityTurns(databaseURL: dbURL) - #expect(turns["turn"]?.model == "gpt-5.4") + #expect(turns["turn"]?.model == "completed-model") } @Test @@ -105,16 +145,16 @@ struct CostUsageScannerCodexPriorityTests { dbURL: dbURL, timestamp: "2026-05-10T12:00:00Z", body: "thread_id=thread turn.id=turn websocket request: " - + #"{"type":"response.create","model":"codex-auto-review","service_tier":"priority"}"#) + + #"{"type":"response.create","model":"request-alias","service_tier":"priority"}"#) try Self.insertTestLog( dbURL: dbURL, timestamp: "2026-05-10T12:00:01Z", body: "thread_id=thread turn.id=turn websocket event: " - + #"{"type": "response.completed", "response": {"model": "gpt-5.4"}}"#) + + #"{"type": "response.completed", "response": {"model": "completed-model"}}"#) let turns = CostUsageScanner.codexPriorityTurns(databaseURL: dbURL) - #expect(turns["turn"]?.model == "gpt-5.4") + #expect(turns["turn"]?.model == "completed-model") } @Test @@ -130,12 +170,12 @@ struct CostUsageScannerCodexPriorityTests { dbURL: dbURL, timestamp: env.isoString(for: previousDay), body: "thread_id=thread-old turn.id=turn-old websocket request: " - + #"{"type":"response.create","model":"gpt-5.5","service_tier":"priority"}"#) + + #"{"type":"response.create","model":"request-model","service_tier":"priority"}"#) try Self.insertTestLog( dbURL: dbURL, timestamp: env.isoString(for: day), body: "thread_id=thread-new turn.id=turn-new websocket request: " - + #"{"type":"response.create","model":"gpt-5.5","service_tier":"priority"}"#) + + #"{"type":"response.create","model":"request-model","service_tier":"priority"}"#) let turns = CostUsageScanner.codexPriorityTurns( databaseURL: dbURL, @@ -166,12 +206,12 @@ struct CostUsageScannerCodexPriorityTests { dbURL: dbURL, epochSeconds: Int64(previousSecond.timeIntervalSince1970), body: "thread_id=thread-before turn.id=turn-before websocket request: " - + #"{"type":"response.create","model":"gpt-5.5","service_tier":"priority"}"#) + + #"{"type":"response.create","model":"request-model","service_tier":"priority"}"#) try Self.insertTestLog( dbURL: dbURL, epochSeconds: Int64(nextSecond.timeIntervalSince1970), body: "thread_id=thread-after turn.id=turn-after websocket request: " - + #"{"type":"response.create","model":"gpt-5.5","service_tier":"priority"}"#) + + #"{"type":"response.create","model":"request-model","service_tier":"priority"}"#) let turns = CostUsageScanner.codexPriorityTurns( databaseURL: dbURL, @@ -181,11 +221,422 @@ struct CostUsageScannerCodexPriorityTests { #expect(turns.keys.sorted() == ["turn-after"]) } + @Test + func `incremental memo picks up rows appended after the first query`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + let dbURL = env.root.appendingPathComponent("logs_2.sqlite") + try Self.createTestLogsDatabase(at: dbURL) + try Self.insertTestLog( + dbURL: dbURL, + timestamp: "2026-05-10T12:00:00Z", + body: "thread_id=thread-a turn.id=turn-a websocket request: " + + #"{"type":"response.create","model":"request-model","service_tier":"priority"}"#) + + #expect(CostUsageScanner.codexPriorityTurns(databaseURL: dbURL).keys.sorted() == ["turn-a"]) + + try Self.insertTestLog( + dbURL: dbURL, + timestamp: "2026-05-10T12:05:00Z", + body: "thread_id=thread-b turn.id=turn-b websocket request: " + + #"{"type":"response.create","model":"request-model","service_tier":"priority"}"#) + try Self.insertTestLog( + dbURL: dbURL, + timestamp: "2026-05-10T12:05:01Z", + body: "thread_id=thread-a turn.id=turn-a websocket event: " + + #"{"type":"response.completed","response":{"model":"resolved-model"}}"#) + + let merged = CostUsageScanner.codexPriorityTurns(databaseURL: dbURL) + #expect(merged.keys.sorted() == ["turn-a", "turn-b"]) + // A completed event appended later still upgrades the model of a turn accumulated earlier. + #expect(merged["turn-a"]?.model == "resolved-model") + } + + @Test + func `memo drops pruned requests while ids keep increasing`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + let dbURL = env.root.appendingPathComponent("logs_2.sqlite") + try Self.createTestLogsDatabase(at: dbURL) + try Self.insertTestLog( + dbURL: dbURL, + timestamp: "2026-05-10T12:00:00Z", + body: "thread_id=thread-a turn.id=turn-a websocket request: " + + #"{"type":"response.create","model":"request-model","service_tier":"priority"}"#) + try Self.insertTestLog( + dbURL: dbURL, + timestamp: "2026-05-10T12:01:00Z", + body: "thread_id=thread-b turn.id=turn-b websocket request: " + + #"{"type":"response.create","model":"request-model","service_tier":"priority"}"#) + #expect(CostUsageScanner.codexPriorityTurns(databaseURL: dbURL).keys.sorted() == ["turn-a", "turn-b"]) + + try Self.execDatabase(dbURL: dbURL, sql: "delete from logs where rowid = 1") + try Self.insertTestLog( + dbURL: dbURL, + timestamp: "2026-05-10T12:02:00Z", + body: "thread_id=thread-c turn.id=turn-c websocket request: " + + #"{"type":"response.create","model":"request-model","service_tier":"priority"}"#) + + let rebuilt = CostUsageScanner.codexPriorityTurns(databaseURL: dbURL) + #expect(rebuilt.keys.sorted() == ["turn-b", "turn-c"]) + } + + @Test + func `memo drops a pruned completion model without losing its request`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + let dbURL = env.root.appendingPathComponent("logs_2.sqlite") + try Self.createTestLogsDatabase(at: dbURL) + try Self.insertTestLog( + dbURL: dbURL, + timestamp: "2026-05-10T12:00:00Z", + body: "thread_id=thread-a turn.id=turn-a websocket request: " + + #"{"type":"response.create","model":"request-alias","service_tier":"priority"}"#) + try Self.insertTestLog( + dbURL: dbURL, + timestamp: "2026-05-10T12:00:01Z", + body: "thread_id=thread-a turn.id=turn-a websocket event: " + + #"{"type":"response.completed","response":{"model":"resolved-model"}}"#) + #expect(CostUsageScanner.codexPriorityTurns(databaseURL: dbURL)["turn-a"]?.model == "resolved-model") + + try Self.execDatabase(dbURL: dbURL, sql: "delete from logs where rowid = 2") + try Self.insertTestLog( + dbURL: dbURL, + timestamp: "2026-05-10T12:01:00Z", + body: "thread_id=thread-b turn.id=turn-b websocket request: " + + #"{"type":"response.create","model":"request-model","service_tier":"priority"}"#) + + let pruned = CostUsageScanner.codexPriorityTurns(databaseURL: dbURL) + #expect(pruned["turn-a"]?.model == "request-alias") + #expect(pruned["turn-b"]?.model == "request-model") + } + + @Test + func `memo falls back to retained duplicate request and completion rows`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + let dbURL = env.root.appendingPathComponent("logs_2.sqlite") + try Self.createTestLogsDatabase(at: dbURL) + try Self.insertTestLog( + dbURL: dbURL, + timestamp: "2026-05-10T12:00:00Z", + body: "thread_id=thread-old turn.id=turn-a websocket request: " + + #"{"type":"response.create","model":"request-old","service_tier":"priority"}"#) + try Self.insertTestLog( + dbURL: dbURL, + timestamp: "2026-05-10T12:00:01Z", + body: "thread_id=thread-new turn.id=turn-a websocket request: " + + #"{"type":"response.create","model":"request-new","service_tier":"priority"}"#) + try Self.insertTestLog( + dbURL: dbURL, + timestamp: "2026-05-10T12:00:02Z", + body: "thread_id=thread-old turn.id=turn-a websocket event: " + + #"{"type":"response.completed","response":{"model":"completed-old"}}"#) + try Self.insertTestLog( + dbURL: dbURL, + timestamp: "2026-05-10T12:00:03Z", + body: "thread_id=thread-new turn.id=turn-a websocket event: " + + #"{"type":"response.completed","response":{"model":"completed-new"}}"#) + + let initial = CostUsageScanner.codexPriorityTurns(databaseURL: dbURL) + #expect(initial["turn-a"]?.threadID == "thread-new") + #expect(initial["turn-a"]?.model == "completed-new") + + try Self.execDatabase(dbURL: dbURL, sql: "delete from logs where rowid in (2, 4)") + try Self.insertTestLog( + dbURL: dbURL, + timestamp: "2026-05-10T12:01:00Z", + body: "thread_id=thread-b turn.id=turn-b websocket request: " + + #"{"type":"response.create","model":"request-model","service_tier":"priority"}"#) + + let pruned = CostUsageScanner.codexPriorityTurns(databaseURL: dbURL) + #expect(pruned["turn-a"]?.threadID == "thread-old") + #expect(pruned["turn-a"]?.model == "completed-old") + #expect(pruned["turn-b"]?.model == "request-model") + } + + @Test + func `failed incremental scan does not report completion`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + let dbURL = env.root.appendingPathComponent("logs_2.sqlite") + try Self.createTestLogsDatabase(at: dbURL) + try Self.insertTestLog( + dbURL: dbURL, + timestamp: "2026-05-10T12:00:00Z", + body: "thread_id=thread-a turn.id=turn-a websocket request: " + + #"{"type":"response.create","model":"request-model","service_tier":"priority"}"#) + + var db: OpaquePointer? + guard sqlite3_open_v2(dbURL.path, &db, SQLITE_OPEN_READONLY, nil) == SQLITE_OK else { + throw SQLiteTestError.open + } + defer { sqlite3_close(db) } + sqlite3_progress_handler(db, 1, { _ in 1 }, nil) + + var state = CostUsageScanner.CodexPriorityTurnsMemoState( + observationID: 1, + coverageSinceEpoch: 0, + lastRowID: 0, + fileIdentity: nil, + turns: [:], + requestSourcesByTurnID: [:], + priorityCompletedModelsByTurnID: [:], + completedModelsByTurnID: [:], + completedTurnIDInsertionOrder: [], + completedTurnIDInsertionOrderStartIndex: 0) + + #expect(!CostUsageScanner._test_accumulateCodexPriorityTurns(db, into: &state)) + } + + @Test + func `memo rescans when requested window expands earlier than accumulated coverage`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + let dbURL = env.root.appendingPathComponent("logs_2.sqlite") + try Self.createTestLogsDatabase(at: dbURL) + // Live refreshes always query through today, which is the memoized path. + let today = Date() + let yesterday = try #require(Calendar.current.date(byAdding: .day, value: -1, to: today)) + let todayKey = CostUsageScanner.CostUsageDayRange.dayKey(from: today) + let yesterdayKey = CostUsageScanner.CostUsageDayRange.dayKey(from: yesterday) + let formatter = ISO8601DateFormatter() + try Self.insertTestLog( + dbURL: dbURL, + timestamp: formatter.string(from: yesterday), + body: "thread_id=thread-old turn.id=turn-old websocket request: " + + #"{"type":"response.create","model":"request-model","service_tier":"priority"}"#) + try Self.insertTestLog( + dbURL: dbURL, + timestamp: formatter.string(from: today), + body: "thread_id=thread-new turn.id=turn-new websocket request: " + + #"{"type":"response.create","model":"request-model","service_tier":"priority"}"#) + + let narrow = CostUsageScanner.codexPriorityTurns( + databaseURL: dbURL, + sinceDayKey: todayKey, + untilDayKey: todayKey) + #expect(narrow.keys.sorted() == ["turn-new"]) + + let expanded = CostUsageScanner.codexPriorityTurns( + databaseURL: dbURL, + sinceDayKey: yesterdayKey, + untilDayKey: todayKey) + #expect(expanded.keys.sorted() == ["turn-new", "turn-old"]) + } + + @Test + func `memo rescans when the database shrinks or is replaced`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + let dbURL = env.root.appendingPathComponent("logs_2.sqlite") + try Self.createTestLogsDatabase(at: dbURL) + try Self.insertTestLog( + dbURL: dbURL, + timestamp: "2026-05-10T12:00:00Z", + body: "thread_id=thread-a turn.id=turn-a websocket request: " + + #"{"type":"response.create","model":"request-model","service_tier":"priority"}"#) + try Self.insertTestLog( + dbURL: dbURL, + timestamp: "2026-05-10T12:01:00Z", + body: "thread_id=thread-b turn.id=turn-b websocket request: " + + #"{"type":"response.create","model":"request-model","service_tier":"priority"}"#) + #expect(CostUsageScanner.codexPriorityTurns(databaseURL: dbURL).count == 2) + + try FileManager.default.removeItem(at: dbURL) + try Self.createTestLogsDatabase(at: dbURL) + try Self.insertTestLog( + dbURL: dbURL, + timestamp: "2026-05-11T09:00:00Z", + body: "thread_id=thread-c turn.id=turn-c websocket request: " + + #"{"type":"response.create","model":"request-model","service_tier":"priority"}"#) + + let replaced = CostUsageScanner.codexPriorityTurns(databaseURL: dbURL) + #expect(replaced.keys.sorted() == ["turn-c"]) + } + + @Test + func `database replacement during open is rejected`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + let dbURL = env.root.appendingPathComponent("logs_2.sqlite") + let oldURL = env.root.appendingPathComponent("logs-old.sqlite") + try Self.createTestLogsDatabase(at: dbURL) + var replacementError: Error? + + let opened = CostUsageScanner.openCodexPriorityDatabase(at: dbURL) { + do { + try FileManager.default.moveItem(at: dbURL, to: oldURL) + try Self.createTestLogsDatabase(at: dbURL) + } catch { + replacementError = error + } + } + if let opened { + sqlite3_close(opened.db) + } + + #expect(replacementError == nil) + #expect(opened == nil) + } + + @Test + func `overlapping refresh writeback cannot replace newer memo state`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + let dbURL = env.root.appendingPathComponent("logs_2.sqlite") + try Self.createTestLogsDatabase(at: dbURL) + try Self.insertTestLog( + dbURL: dbURL, + timestamp: "2026-05-10T12:00:00Z", + body: "thread_id=thread-a turn.id=turn-a websocket request: " + + #"{"type":"response.create","model":"request-model","service_tier":"priority"}"#) + try Self.insertTestLog( + dbURL: dbURL, + timestamp: "2026-05-10T12:01:00Z", + body: "thread_id=thread-b turn.id=turn-b websocket request: " + + #"{"type":"response.create","model":"request-model","service_tier":"priority"}"#) + #expect(CostUsageScanner.codexPriorityTurns(databaseURL: dbURL).count == 2) + let stored = try #require(CostUsageScanner._test_codexPriorityTurnsMemoState(forPath: dbURL.path)) + + // A slower overlapping refresh writes back a snapshot read before the second row was + // appended: an older cursor that only observed the first turn. It must not win. + var stale = stored + stale.lastRowID -= 1 + stale.turns = stored.turns.filter { $0.key == "turn-a" } + CostUsageScanner.storeCodexPriorityTurnsMemoIfNewer(stale, forPath: dbURL.path) + + let retained = try #require(CostUsageScanner._test_codexPriorityTurnsMemoState(forPath: dbURL.path)) + #expect(retained.lastRowID == stored.lastRowID) + #expect(retained.turns.keys.sorted() == ["turn-a", "turn-b"]) + + // A snapshot with a newer cursor still replaces the stored state. + var newer = stored + newer.lastRowID += 1 + CostUsageScanner.storeCodexPriorityTurnsMemoIfNewer(newer, forPath: dbURL.path) + #expect( + CostUsageScanner._test_codexPriorityTurnsMemoState(forPath: dbURL.path)? + .lastRowID == stored.lastRowID + 1) + + // A full rescan that expanded coverage earlier than the stored window also replaces, + // even when its cursor is not ahead, so broader history is never discarded. + var broader = stored + broader.coverageSinceEpoch -= 1 + CostUsageScanner.storeCodexPriorityTurnsMemoIfNewer(broader, forPath: dbURL.path) + #expect( + CostUsageScanner._test_codexPriorityTurnsMemoState(forPath: dbURL.path)? + .coverageSinceEpoch == broader.coverageSinceEpoch) + } + + @Test + func `memo bounds retained completion metadata for non-priority turns`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + let dbURL = env.root.appendingPathComponent("logs_2.sqlite") + try Self.createTestLogsDatabase(at: dbURL) + let limit = CostUsageScanner.codexPriorityCompletedModelRetentionLimit + let overflow = limit + 8 + let epoch = Self.epochSeconds("2026-05-10T12:00:00Z") + + // A known priority turn keeps its resolved completion outside the bounded pending + // cache while thousands of unrelated completions flow through the process. + var rows = [ + ( + epochSeconds: epoch, + body: "thread_id=priority turn.id=priority websocket request: " + + #"{"type":"response.create","model":"priority-alias","service_tier":"priority"}"#), + ( + epochSeconds: epoch, + body: "thread_id=priority turn.id=priority websocket event: " + + #"{"type":"response.completed","response":{"model":"resolved-model"}}"#), + ] + rows.append(contentsOf: (0..<(limit + overflow)).map { index in + ( + epochSeconds: epoch, + body: "thread_id=thread-\(index) turn.id=turn-\(index) websocket event: " + + #"{"type":"response.completed","response":{"model":"completed-model"}}"#) + }) + rows.append(( + epochSeconds: epoch, + body: "thread_id=thread-0 turn.id=turn-0 websocket request: " + + #"{"type":"response.create","model":"alias-evicted","service_tier":"priority"}"#)) + let newest = limit + overflow - 1 + rows.append(( + epochSeconds: epoch, + body: "thread_id=thread-\(newest) turn.id=turn-\(newest) " + + "websocket request: " + + #"{"type":"response.create","model":"alias-retained","service_tier":"priority"}"#)) + try Self.insertTestLogs(dbURL: dbURL, rows: rows) + + let turns = CostUsageScanner.codexPriorityTurns(databaseURL: dbURL) + + let memo = try #require(CostUsageScanner._test_codexPriorityTurnsMemoState(forPath: dbURL.path)) + #expect(memo.completedModelsByTurnID.count == limit - 1) + #expect( + memo.completedTurnIDInsertionOrder.count + - memo.completedTurnIDInsertionOrderStartIndex == limit - 1) + #expect(memo.completedTurnIDInsertionOrder.count < limit * 2) + #expect(memo.priorityCompletedModelsByTurnID.count == 2) + // The oldest completions were evicted, so the early request keeps its alias; the + // recent completion is still retained and upgrades its request. + #expect(turns["priority"]?.model == "resolved-model") + #expect(turns["turn-0"]?.model == "alias-evicted") + #expect(turns["turn-\(newest)"]?.model == "completed-model") + } + + static func insertTestLogs(dbURL: URL, rows: [(epochSeconds: Int64, body: String)]) throws { + var db: OpaquePointer? + guard sqlite3_open(dbURL.path, &db) == SQLITE_OK else { throw SQLiteTestError.open } + defer { sqlite3_close(db) } + + var stmt: OpaquePointer? + guard sqlite3_prepare_v2(db, "insert into logs (ts, feedback_log_body) values (?, ?)", -1, &stmt, nil) + == SQLITE_OK + else { throw SQLiteTestError.prepare } + defer { sqlite3_finalize(stmt) } + + try self.exec(db, "begin transaction") + let transient = unsafeBitCast(-1, to: sqlite3_destructor_type.self) + for row in rows { + sqlite3_bind_int64(stmt, 1, row.epochSeconds) + sqlite3_bind_text(stmt, 2, row.body, -1, transient) + guard sqlite3_step(stmt) == SQLITE_DONE else { throw SQLiteTestError.step } + sqlite3_reset(stmt) + } + try self.exec(db, "commit") + } + static func createTestLogsDatabase(at dbURL: URL) throws { var db: OpaquePointer? guard sqlite3_open(dbURL.path, &db) == SQLITE_OK else { throw SQLiteTestError.open } defer { sqlite3_close(db) } - try self.exec(db, "create table logs (ts integer not null, feedback_log_body text)") + try self.exec( + db, + "create table logs (id integer primary key autoincrement, ts integer not null, feedback_log_body text)") + try self.exec(db, "create index idx_logs_ts on logs(ts desc, id desc)") + } + + static func queryPlan( + db: OpaquePointer?, + query: String, + bindings: [Int64]) throws -> [String] + { + var stmt: OpaquePointer? + guard sqlite3_prepare_v2(db, "explain query plan \(query)", -1, &stmt, nil) == SQLITE_OK else { + throw SQLiteTestError.prepare + } + defer { sqlite3_finalize(stmt) } + for (offset, value) in bindings.enumerated() { + sqlite3_bind_int64(stmt, Int32(offset + 1), value) + } + + var details: [String] = [] + while sqlite3_step(stmt) == SQLITE_ROW { + if let detail = sqlite3_column_text(stmt, 3) { + details.append(String(cString: detail)) + } + } + return details } static func insertTestLog(dbURL: URL, timestamp: String, body: String) throws { @@ -209,6 +660,13 @@ struct CostUsageScannerCodexPriorityTests { guard sqlite3_step(stmt) == SQLITE_DONE else { throw SQLiteTestError.step } } + private static func execDatabase(dbURL: URL, sql: String) throws { + var db: OpaquePointer? + guard sqlite3_open(dbURL.path, &db) == SQLITE_OK else { throw SQLiteTestError.open } + defer { sqlite3_close(db) } + try self.exec(db, sql) + } + private static func epochSeconds(_ timestamp: String) -> Int64 { let formatter = ISO8601DateFormatter() formatter.formatOptions = [.withInternetDateTime] diff --git a/Tests/CodexBarTests/CostUsageScannerPriorityTests.swift b/Tests/CodexBarTests/CostUsageScannerPriorityTests.swift index 8bdaaf5e26..2db80641cd 100644 --- a/Tests/CodexBarTests/CostUsageScannerPriorityTests.swift +++ b/Tests/CodexBarTests/CostUsageScannerPriorityTests.swift @@ -43,11 +43,11 @@ struct CostUsageScannerPriorityTests { let standardCost = (80.0 * 5e-6) + (20.0 * 5e-7) + (10.0 * 3e-5) let priorityCost = (80.0 * 1.25e-5) + (20.0 * 1.25e-6) + (10.0 * 7.5e-5) - #expect(report.summary?.totalCostUSD == standardCost + priorityCost) + #expect(abs((report.summary?.totalCostUSD ?? 0) - (standardCost + priorityCost)) < 0.000_000_001) let breakdown = try #require(report.data.first?.modelBreakdowns?.first) - #expect(breakdown.costUSD == standardCost + priorityCost) - #expect(breakdown.standardCostUSD == standardCost) - #expect(breakdown.priorityCostUSD == priorityCost) + #expect(abs((breakdown.costUSD ?? 0) - (standardCost + priorityCost)) < 0.000_000_001) + #expect(abs((breakdown.standardCostUSD ?? 0) - standardCost) < 0.000_000_001) + #expect(abs((breakdown.priorityCostUSD ?? 0) - priorityCost) < 0.000_000_001) #expect(breakdown.standardTokens == 110) #expect(breakdown.priorityTokens == 110) } @@ -98,9 +98,9 @@ struct CostUsageScannerPriorityTests { options: cachedOptions) let priorityCost = (80.0 * 1.25e-5) + (20.0 * 1.25e-6) + (10.0 * 7.5e-5) - #expect(cached.summary?.totalCostUSD == priorityCost) + #expect(abs((cached.summary?.totalCostUSD ?? 0) - priorityCost) < 0.000_000_001) let breakdown = try #require(cached.data.first?.modelBreakdowns?.first) - #expect(breakdown.priorityCostUSD == priorityCost) + #expect(abs((breakdown.priorityCostUSD ?? 0) - priorityCost) < 0.000_000_001) #expect(breakdown.priorityTokens == 110) } @@ -133,7 +133,7 @@ struct CostUsageScannerPriorityTests { now: day, options: missingOptions) let baseCost = (80.0 * 5e-6) + (20.0 * 5e-7) + (10.0 * 3e-5) - #expect(first.summary?.totalCostUSD == baseCost) + #expect(abs((first.summary?.totalCostUSD ?? 0) - baseCost) < 0.000_000_001) try CostUsageScannerCodexPriorityTests.createTestLogsDatabase(at: dbURL) try self.insertPriorityTrace(dbURL: dbURL, timestamp: iso1) @@ -148,7 +148,7 @@ struct CostUsageScannerPriorityTests { options: liveOptions) let priorityCost = (80.0 * 1.25e-5) + (20.0 * 1.25e-6) + (10.0 * 7.5e-5) - #expect(rescanned.summary?.totalCostUSD == priorityCost) + #expect(abs((rescanned.summary?.totalCostUSD ?? 0) - priorityCost) < 0.000_000_001) } @Test @@ -182,7 +182,7 @@ struct CostUsageScannerPriorityTests { now: day, options: options) let baseCost = (80.0 * 5e-6) + (20.0 * 5e-7) + (10.0 * 3e-5) - #expect(first.summary?.totalCostUSD == baseCost) + #expect(abs((first.summary?.totalCostUSD ?? 0) - baseCost) < 0.000_000_001) let walURL = URL(fileURLWithPath: dbURL.path + "-wal") try Data("wal-changed".utf8).write(to: walURL) @@ -195,7 +195,7 @@ struct CostUsageScannerPriorityTests { now: day.addingTimeInterval(1), options: options) - #expect(cached.summary?.totalCostUSD == baseCost) + #expect(abs((cached.summary?.totalCostUSD ?? 0) - baseCost) < 0.000_000_001) } @Test @@ -229,7 +229,7 @@ struct CostUsageScannerPriorityTests { now: day, options: options) let baseCost = (80.0 * 5e-6) + (20.0 * 5e-7) + (10.0 * 3e-5) - #expect(first.summary?.totalCostUSD == baseCost) + #expect(abs((first.summary?.totalCostUSD ?? 0) - baseCost) < 0.000_000_001) try self.insertPriorityTrace(dbURL: dbURL, timestamp: iso1) @@ -242,7 +242,7 @@ struct CostUsageScannerPriorityTests { options: options) let priorityCost = (80.0 * 1.25e-5) + (20.0 * 1.25e-6) + (10.0 * 7.5e-5) - #expect(repriced.summary?.totalCostUSD == priorityCost) + #expect(abs((repriced.summary?.totalCostUSD ?? 0) - priorityCost) < 0.000_000_001) } @Test @@ -284,7 +284,7 @@ struct CostUsageScannerPriorityTests { let standardCost = (80.0 * 2.5e-6) + (20.0 * 2.5e-7) + (10.0 * 1.5e-5) let priorityCost = (80.0 * 5e-6) + (20.0 * 5e-7) + (10.0 * 3e-5) - #expect(report.summary?.totalCostUSD == standardCost + priorityCost) + #expect(abs((report.summary?.totalCostUSD ?? 0) - (standardCost + priorityCost)) < 0.000_000_001) } @Test @@ -325,9 +325,9 @@ struct CostUsageScannerPriorityTests { options: options) let priorityCost = (80.0 * 5e-6) + (20.0 * 5e-7) + (10.0 * 3e-5) - #expect(report.summary?.totalCostUSD == priorityCost) + #expect(abs((report.summary?.totalCostUSD ?? 0) - priorityCost) < 0.000_000_001) let breakdown = try #require(report.data.first?.modelBreakdowns?.first) - #expect(breakdown.priorityCostUSD == priorityCost) + #expect(abs((breakdown.priorityCostUSD ?? 0) - priorityCost) < 0.000_000_001) #expect(breakdown.priorityTokens == 110) } @@ -369,10 +369,10 @@ struct CostUsageScannerPriorityTests { options: options) let priorityCost = (80.0 * 1.25e-5) + (20.0 * 1.25e-6) + (10.0 * 7.5e-5) - #expect(report.summary?.totalCostUSD == priorityCost) + #expect(abs((report.summary?.totalCostUSD ?? 0) - priorityCost) < 0.000_000_001) let breakdown = try #require(report.data.first?.modelBreakdowns?.first) - #expect(breakdown.costUSD == priorityCost) - #expect(breakdown.priorityCostUSD == priorityCost) + #expect(abs((breakdown.costUSD ?? 0) - priorityCost) < 0.000_000_001) + #expect(abs((breakdown.priorityCostUSD ?? 0) - priorityCost) < 0.000_000_001) } @Test @@ -423,9 +423,9 @@ struct CostUsageScannerPriorityTests { options: options) let priorityCost = (80.0 * 5e-6) + (20.0 * 5e-7) + (10.0 * 3e-5) - #expect(repriced.summary?.totalCostUSD == priorityCost) + #expect(abs((repriced.summary?.totalCostUSD ?? 0) - priorityCost) < 0.000_000_001) let breakdown = try #require(repriced.data.first?.modelBreakdowns?.first) - #expect(breakdown.priorityCostUSD == priorityCost) + #expect(abs((breakdown.priorityCostUSD ?? 0) - priorityCost) < 0.000_000_001) #expect(breakdown.priorityTokens == 110) } @@ -462,9 +462,9 @@ struct CostUsageScannerPriorityTests { options: options) let priorityCost = (80.0 * 5e-6) + (20.0 * 5e-7) + (10.0 * 3e-5) - #expect(report.summary?.totalCostUSD == priorityCost) + #expect(abs((report.summary?.totalCostUSD ?? 0) - priorityCost) < 0.000_000_001) let breakdown = try #require(report.data.first?.modelBreakdowns?.first) - #expect(breakdown.priorityCostUSD == priorityCost) + #expect(abs((breakdown.priorityCostUSD ?? 0) - priorityCost) < 0.000_000_001) #expect(breakdown.priorityTokens == 110) } @@ -497,9 +497,9 @@ struct CostUsageScannerPriorityTests { options: options) let expected = (80.0 * 5e-6) + (20.0 * 5e-7) + (10.0 * 3e-5) - #expect(report.summary?.totalCostUSD == expected) + #expect(abs((report.summary?.totalCostUSD ?? 0) - expected) < 0.000_000_001) let breakdown = try #require(report.data.first?.modelBreakdowns?.first) - #expect(breakdown.costUSD == expected) + #expect(abs((breakdown.costUSD ?? 0) - expected) < 0.000_000_001) #expect(breakdown.standardCostUSD == nil) #expect(breakdown.priorityCostUSD == nil) #expect(breakdown.standardTokens == nil) @@ -592,6 +592,88 @@ struct CostUsageScannerPriorityTests { #expect(abs((report.summary?.totalCostUSD ?? 0) - expected) < 0.000_000_001) } + @Test + func `codex gpt56 long context rows keep base cost in priority bucket`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 5, day: 10) + let iso0 = env.isoString(for: day) + let iso1 = env.isoString(for: day.addingTimeInterval(1)) + let entries: [[String: Any]] = [ + ["type": "turn_context", "timestamp": iso0, "payload": ["model": "gpt-5.6-sol"]], + ["type": "event_msg", "timestamp": iso1, "payload": ["type": "task_started", "turn_id": "priority-turn"]], + self.tokenCount(timestamp: iso1, input: 272_001, cached: 100_000, output: 5), + ] + _ = try env.writeCodexSessionFile(day: day, filename: "session.jsonl", contents: env.jsonl(entries)) + + let dbURL = env.root.appendingPathComponent("logs_2.sqlite") + try CostUsageScannerCodexPriorityTests.createTestLogsDatabase(at: dbURL) + try self.insertPriorityTrace(dbURL: dbURL, timestamp: iso1, model: "gpt-5.6-sol") + + var options = CostUsageScanner.Options( + codexSessionsRoot: env.codexSessionsRoot, + cacheRoot: env.cacheRoot, + codexTraceDatabaseURL: dbURL) + options.refreshMinIntervalSeconds = 0 + + let report = CostUsageScanner.loadDailyReport( + provider: .codex, + since: day, + until: day, + now: day, + options: options) + let expected = (172_001.0 * 1e-5) + (100_000.0 * 1e-6) + (5.0 * 4.5e-5) + + #expect(abs((report.summary?.totalCostUSD ?? 0) - expected) < 0.000_000_001) + let breakdown = try #require(report.data.first?.modelBreakdowns?.first) + #expect(abs((breakdown.priorityCostUSD ?? 0) - expected) < 0.000_000_001) + #expect(breakdown.standardCostUSD == nil) + #expect(breakdown.priorityTokens == 272_006) + } + + @Test + func `codex pricing applies priority surcharge when cached reads exceed limit but input stays under it`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 5, day: 10) + let iso0 = env.isoString(for: day) + let iso1 = env.isoString(for: day.addingTimeInterval(1)) + let entries: [[String: Any]] = [ + ["type": "turn_context", "timestamp": iso0, "payload": ["model": "gpt-5.5"]], + ["type": "event_msg", "timestamp": iso1, "payload": ["type": "task_started", "turn_id": "priority-turn"]], + self.tokenCount(timestamp: iso1, input: 200_000, cached: 100_000, output: 5), + ] + _ = try env.writeCodexSessionFile(day: day, filename: "session.jsonl", contents: env.jsonl(entries)) + + let dbURL = env.root.appendingPathComponent("logs_2.sqlite") + try CostUsageScannerCodexPriorityTests.createTestLogsDatabase(at: dbURL) + try self.insertPriorityTrace(dbURL: dbURL, timestamp: iso1) + + var options = CostUsageScanner.Options( + codexSessionsRoot: env.codexSessionsRoot, + cacheRoot: env.cacheRoot, + codexTraceDatabaseURL: dbURL) + options.refreshMinIntervalSeconds = 0 + + let report = CostUsageScanner.loadDailyReport( + provider: .codex, + since: day, + until: day, + now: day, + options: options) + // cached input is a subset of input, so the 272K priority limit applies to the 200K + // input alone (not input+cached). Input stays under the limit, so the priority surcharge + // applies at priority rates, and only the 100K non-cached input is billed at the input rate. + let expected = (100_000.0 * 1.25e-5) + (100_000.0 * 1.25e-6) + (5.0 * 7.5e-5) + + #expect(abs((report.summary?.totalCostUSD ?? 0) - expected) < 0.000_000_001) + let breakdown = try #require(report.data.first?.modelBreakdowns?.first) + #expect(abs((breakdown.priorityCostUSD ?? 0) - expected) < 0.000_000_001) + #expect(breakdown.priorityTokens == 200_005) + } + @Test func `codex cumulative totals do not trigger long context pricing`() throws { let env = try CostUsageTestEnvironment() @@ -629,7 +711,8 @@ struct CostUsageScannerPriorityTests { now: day, options: options) let standardRow = (Double(60000) * 5e-6) + (Double(60000) * 5e-7) + (Double(100) * 3e-5) - let priorityRow = (Double(60000) * 1.25e-5) + (Double(60000) * 1.25e-6) + (Double(100) * 7.5e-5) + let priorityRow = (Double(60000) * 1.25e-5) + (Double(60000) * 1.25e-6) + + (Double(100) * 7.5e-5) let expected = standardRow + standardRow + priorityRow #expect(abs((report.summary?.totalCostUSD ?? 0) - expected) < 0.000_000_001) diff --git a/Tests/CodexBarTests/CostUsageScannerTests.swift b/Tests/CodexBarTests/CostUsageScannerTests.swift index 2b9dd7abe2..3776baa84b 100644 --- a/Tests/CodexBarTests/CostUsageScannerTests.swift +++ b/Tests/CodexBarTests/CostUsageScannerTests.swift @@ -2,7 +2,95 @@ import Foundation import Testing @testable import CodexBarCore +// swiftlint:disable:next type_body_length struct CostUsageScannerTests { + @Test + func `codex session metadata skips an oversized line without retaining it`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let fileURL = env.root.appendingPathComponent("oversized-session-meta.jsonl") + FileManager.default.createFile(atPath: fileURL.path, contents: nil) + let handle = try FileHandle(forWritingTo: fileURL) + defer { try? handle.close() } + + let oversizedPrefix = "{\"type\":\"session_meta\",\"payload\":{\"id\":\"too-large\",\"padding\":\"" + try handle.write(contentsOf: Data(oversizedPrefix.utf8)) + let chunk = Data(repeating: 0x78, count: 64 * 1024) + for _ in 0..<128 { + try handle.write(contentsOf: chunk) + } + let expectedLine = #"{"type":"session_meta","payload":{"id":"expected-session"}}"# + try handle.write(contentsOf: Data((#""}}"# + "\n" + expectedLine).utf8)) + try handle.close() + + let sessionID = try CostUsageScanner.parseCodexSessionIdentifier(fileURL: fileURL) + #expect(sessionID == "expected-session") + } + + @Test + func `codex session metadata accepts a line exactly at the byte limit`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let prefix = "{\"type\":\"session_meta\",\"payload\":{\"id\":\"limit-session\",\"padding\":\"" + let suffix = "\"}}" + let paddingCount = CostUsageScanner.codexSessionMetadataMaxLineBytes + - prefix.utf8.count + - suffix.utf8.count + var line = Data(prefix.utf8) + line.append(Data(repeating: 0x78, count: paddingCount)) + line.append(contentsOf: suffix.utf8) + #expect(line.count == CostUsageScanner.codexSessionMetadataMaxLineBytes) + + let fileURL = env.root.appendingPathComponent("max-size-session-meta.jsonl") + try line.write(to: fileURL) + #expect(try (JSONSerialization.jsonObject(with: line)) is [String: Any]) + + let sessionID = try CostUsageScanner.parseCodexSessionIdentifier(fileURL: fileURL) + #expect(sessionID == "limit-session") + } + + @Test + func `codex file metadata detects append truncation and replacement`() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("codexbar-codex-metadata-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let fileURL = root.appendingPathComponent("session.jsonl") + try Data("abc".utf8).write(to: fileURL) + + let initial = CostUsageScanner.codexFileMetadata(fileURL: fileURL) + #expect(initial.size == 3) + #expect(initial.fileId != nil) + let linkURL = root.appendingPathComponent("linked-session.jsonl") + try FileManager.default.createSymbolicLink(at: linkURL, withDestinationURL: fileURL) + let linked = CostUsageScanner.codexFileMetadata(fileURL: linkURL) + #expect(linked.size == initial.size) + #expect(linked.fileId == initial.fileId) + + let handle = try FileHandle(forWritingTo: fileURL) + try handle.seekToEnd() + try handle.write(contentsOf: Data("def".utf8)) + try handle.close() + let appended = CostUsageScanner.codexFileMetadata(fileURL: fileURL) + #expect(appended.size == 6) + #expect(appended.fileId == initial.fileId) + + let truncateHandle = try FileHandle(forWritingTo: fileURL) + try truncateHandle.truncate(atOffset: 2) + try truncateHandle.close() + let truncated = CostUsageScanner.codexFileMetadata(fileURL: fileURL) + #expect(truncated.size == 2) + #expect(truncated.fileId == initial.fileId) + + try FileManager.default.removeItem(at: fileURL) + try Data("replacement".utf8).write(to: fileURL) + let replaced = CostUsageScanner.codexFileMetadata(fileURL: fileURL) + #expect(replaced.size == 11) + #expect(replaced.fileId != initial.fileId) + } + @Test func `vertex daily report filters claude logs`() throws { let env = try CostUsageTestEnvironment() @@ -158,7 +246,7 @@ struct CostUsageScannerTests { let day = try env.makeLocalNoon(year: 2026, month: 5, day: 9) let first = env.isoString(for: day) let second = env.isoString(for: day.addingTimeInterval(1)) - let model = "claude-sonnet-4-6" + let model = "claude-sonnet-4-5" let firstEntry: [String: Any] = [ "type": "assistant", "timestamp": first, @@ -365,6 +453,7 @@ struct CostUsageScannerTests { "input_tokens": 100, "cached_input_tokens": 20, "output_tokens": 10, + "reasoning_output_tokens": 4, ], "model": model, ], @@ -382,6 +471,8 @@ struct CostUsageScannerTests { #expect(first.lastTotals?.input == 100) #expect(first.lastTotals?.cached == 20) #expect(first.lastTotals?.output == 10) + #expect(first.lastTotals?.reasoning == 4) + #expect(first.rows.first?.reasoning == 4) let secondTokenCount: [String: Any] = [ "type": "event_msg", @@ -393,6 +484,7 @@ struct CostUsageScannerTests { "input_tokens": 160, "cached_input_tokens": 40, "output_tokens": 16, + "reasoning_output_tokens": 7, ], "model": model, ], @@ -413,6 +505,7 @@ struct CostUsageScannerTests { #expect(packed[0] == 60) #expect(packed[1] == 20) #expect(packed[2] == 6) + #expect(delta.rows.first?.reasoning == 3) } @Test @@ -500,6 +593,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() @@ -874,6 +993,71 @@ struct CostUsageTestEnvironment { return url } + func writeClaudeDesktopLocalAgentFile(relativePath: String, contents: String) throws -> URL { + let localAgentRoot = self.root + .appendingPathComponent("Library", isDirectory: true) + .appendingPathComponent("Application Support", isDirectory: true) + .appendingPathComponent("Claude", isDirectory: true) + .appendingPathComponent("local-agent-mode-sessions", isDirectory: true) + .appendingPathComponent("workspace-id", isDirectory: true) + .appendingPathComponent("session-id", isDirectory: true) + .appendingPathComponent("local_agent", isDirectory: true) + let url = localAgentRoot.appendingPathComponent(relativePath, isDirectory: false) + try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true) + try contents.write(to: url, atomically: true, encoding: .utf8) + return url + } + + func writeClaudeDesktopLocalAgentProjectFile(relativePath: String, contents: String) throws -> URL { + try self.writeClaudeDesktopLocalAgentFile( + relativePath: ".claude/projects/\(relativePath)", + contents: contents) + } + + func writeClaudeDesktopCodeSessionProjectFile(relativePath: String, contents: String) throws -> URL { + let projectsRoot = self.root + .appendingPathComponent("Library", isDirectory: true) + .appendingPathComponent("Application Support", isDirectory: true) + .appendingPathComponent("Claude", isDirectory: true) + .appendingPathComponent("claude-code-sessions", isDirectory: true) + .appendingPathComponent("account-id", isDirectory: true) + .appendingPathComponent("org-id", isDirectory: true) + .appendingPathComponent(".claude", isDirectory: true) + .appendingPathComponent("projects", isDirectory: true) + let url = projectsRoot.appendingPathComponent(relativePath, isDirectory: false) + try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true) + try contents.write(to: url, atomically: true, encoding: .utf8) + return url + } + + func writeClaudeDesktopSharedProjectFile(relativePath: String, contents: String) throws -> URL { + let projectsRoot = self.root + .appendingPathComponent(".claude", isDirectory: true) + .appendingPathComponent("projects", isDirectory: true) + let url = projectsRoot.appendingPathComponent(relativePath, isDirectory: false) + try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true) + try contents.write(to: url, atomically: true, encoding: .utf8) + return url + } + + func writeNestedClaudeDesktopLocalAgentProjectFile(relativePath: String, contents: String) throws -> URL { + let projectsRoot = self.root + .appendingPathComponent("Library", isDirectory: true) + .appendingPathComponent("Application Support", isDirectory: true) + .appendingPathComponent("Claude", isDirectory: true) + .appendingPathComponent("local-agent-mode-sessions", isDirectory: true) + .appendingPathComponent("workspace-id", isDirectory: true) + .appendingPathComponent("session-id", isDirectory: true) + .appendingPathComponent("agent", isDirectory: true) + .appendingPathComponent("local_agent", isDirectory: true) + .appendingPathComponent(".claude", isDirectory: true) + .appendingPathComponent("projects", isDirectory: true) + let url = projectsRoot.appendingPathComponent(relativePath, isDirectory: false) + try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true) + try contents.write(to: url, atomically: true, encoding: .utf8) + return url + } + func writeCodexArchivedSessionFile(filename: String, contents: String) throws -> URL { let url = self.codexArchivedSessionsRoot.appendingPathComponent(filename, isDirectory: false) try contents.write(to: url, atomically: true, encoding: .utf8) diff --git a/Tests/CodexBarTests/CostUsageTokenSnapshotDaySelectionTests.swift b/Tests/CodexBarTests/CostUsageTokenSnapshotDaySelectionTests.swift new file mode 100644 index 0000000000..a69abb8f5c --- /dev/null +++ b/Tests/CodexBarTests/CostUsageTokenSnapshotDaySelectionTests.swift @@ -0,0 +1,173 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct CostUsageTokenSnapshotDaySelectionTests { + @Test + func `token snapshot reports zero today when latest history row is stale`() throws { + let now = try Self.localNoon(year: 2026, month: 5, day: 18) + let report = CostUsageDailyReport( + data: [ + CostUsageDailyReport.Entry( + date: "2026-05-15", + inputTokens: 200, + outputTokens: 100, + totalTokens: 300, + costUSD: 1.5, + modelsUsed: nil, + modelBreakdowns: nil), + ], + summary: nil) + + let snapshot = CostUsageFetcher.tokenSnapshot(from: report, now: now) + + #expect(snapshot.sessionCostUSD == 0) + #expect(snapshot.sessionTokens == 0) + #expect(snapshot.last30DaysCostUSD == 1.5) + #expect(snapshot.last30DaysTokens == 300) + #expect(snapshot.currentDayEntry() == nil) + } + + @Test + func `token snapshot uses current local day instead of newest historical row`() throws { + let now = try Self.localNoon(year: 2026, month: 5, day: 18) + let report = CostUsageDailyReport( + data: [ + CostUsageDailyReport.Entry( + date: "2026-05-17", + inputTokens: 200, + outputTokens: 100, + totalTokens: 300, + costUSD: 1.5, + modelsUsed: nil, + modelBreakdowns: nil), + CostUsageDailyReport.Entry( + date: "2026-05-18", + inputTokens: 20, + outputTokens: 10, + totalTokens: 30, + costUSD: 0.15, + modelsUsed: nil, + modelBreakdowns: nil), + ], + summary: nil) + + let snapshot = CostUsageFetcher.tokenSnapshot(from: report, now: now) + + #expect(snapshot.sessionCostUSD == 0.15) + #expect(snapshot.sessionTokens == 30) + #expect(snapshot.last30DaysCostUSD == 1.65) + #expect(snapshot.last30DaysTokens == 330) + } + + @Test + func `token snapshot can preserve latest bucket semantics`() throws { + let now = try Self.localNoon(year: 2026, month: 5, day: 18) + let report = CostUsageDailyReport( + data: [ + CostUsageDailyReport.Entry( + date: "2026-05-15", + inputTokens: 200, + outputTokens: 100, + totalTokens: 300, + costUSD: 1.5, + modelsUsed: nil, + modelBreakdowns: nil), + ], + summary: nil) + + let snapshot = CostUsageFetcher.tokenSnapshot( + from: report, + now: now, + useCurrentLocalDayForSession: false) + + #expect(snapshot.sessionCostUSD == 1.5) + #expect(snapshot.sessionTokens == 300) + } + + @Test + func `cursor window start snaps to the local day boundary`() throws { + let calendar = Calendar.current + + // historyDays > 1: a midday instant several days back snaps to that day's 00:00. + let midday = try Self.localNoon(year: 2026, month: 5, day: 15) + let snapped = try #require(CostUsageFetcher.cursorWindowStart(midday, calendar: calendar)) + #expect(snapped == calendar.startOfDay(for: midday)) + #expect(snapped <= midday) + + // historyDays == 1: `since` is `now`, so the window must still cover all of today (00:00 today), + // not collapse to the current instant. + let now = try Self.localNoon(year: 2026, month: 5, day: 18) + let today = try #require(CostUsageFetcher.cursorWindowStart(now, calendar: calendar)) + #expect(today == calendar.startOfDay(for: now)) + #expect(calendar.isDate(today, inSameDayAs: now)) + #expect(today <= now) + + #expect(CostUsageFetcher.cursorWindowStart(nil, calendar: calendar) == nil) + } + + @Test + func `token snapshot distinguishes omitted and explicitly unknown currency`() { + let omitted = CostUsageTokenSnapshot( + sessionTokens: nil, + sessionCostUSD: nil, + last30DaysTokens: nil, + last30DaysCostUSD: nil, + daily: [], + updatedAt: Date()) + let blank = CostUsageTokenSnapshot( + sessionTokens: nil, + sessionCostUSD: nil, + last30DaysTokens: nil, + last30DaysCostUSD: nil, + currencyCode: " ", + daily: [], + updatedAt: Date()) + let euro = CostUsageTokenSnapshot( + sessionTokens: nil, + sessionCostUSD: nil, + last30DaysTokens: nil, + last30DaysCostUSD: nil, + currencyCode: " eur ", + daily: [], + updatedAt: Date()) + + #expect(omitted.currencyCode == "USD") + #expect(blank.currencyCode == "XXX") + #expect(euro.currencyCode == "EUR") + } + + @Test + func `latest entry ignores invalid calendar dates`() { + let latest = CostUsageTokenSnapshot.latestEntry(in: [ + CostUsageDailyReport.Entry( + date: "2026-06-31", + inputTokens: nil, + outputTokens: nil, + totalTokens: 999, + costUSD: 9.99, + modelsUsed: nil, + modelBreakdowns: nil), + CostUsageDailyReport.Entry( + date: "2026-06-30", + inputTokens: nil, + outputTokens: nil, + totalTokens: 100, + costUSD: 1, + modelsUsed: nil, + modelBreakdowns: nil), + ]) + + #expect(latest?.date == "2026-06-30") + } + + private static func localNoon(year: Int, month: Int, day: Int) throws -> Date { + var components = DateComponents() + components.calendar = Calendar.current + components.year = year + components.month = month + components.day = day + components.hour = 12 + return try #require(components.date) + } +} diff --git a/Tests/CodexBarTests/CostUsageWindowSummaryTests.swift b/Tests/CodexBarTests/CostUsageWindowSummaryTests.swift new file mode 100644 index 0000000000..e7b150ec9b --- /dev/null +++ b/Tests/CodexBarTests/CostUsageWindowSummaryTests.swift @@ -0,0 +1,122 @@ +import CodexBarCore +import Foundation +import Testing + +struct CostUsageWindowSummaryTests { + @Test + func `summaries use calendar windows instead of the last nonempty rows`() { + let snapshot = Self.snapshot(historyDays: 90) + let summary = snapshot.summary(forLastDays: 7, calendar: Self.utcCalendar) + + #expect(summary.days == 7) + #expect(summary.entryCount == 2) + #expect(summary.totalCostUSD == 9) + #expect(summary.totalTokens == 900) + #expect(summary.totalRequests == 9) + } + + @Test + func `comparison periods are unique sorted and bounded by scanned history`() { + let snapshot = Self.snapshot(historyDays: 90) + + #expect(snapshot.comparisonSummaries(periods: [30, 7, 90, 7], calendar: Self.utcCalendar).map(\.days) == [ + 7, + 30, + ]) + } + + @Test + func `summary preserves unavailable totals as nil`() { + let snapshot = CostUsageTokenSnapshot( + sessionTokens: nil, + sessionCostUSD: nil, + last30DaysTokens: nil, + last30DaysCostUSD: nil, + historyDays: 30, + daily: [Self.entry(day: "2026-07-01", cost: nil, tokens: nil, requests: nil)], + updatedAt: Self.now) + + let summary = snapshot.summary(forLastDays: 7, calendar: Self.utcCalendar) + #expect(summary.totalCostUSD == nil) + #expect(summary.totalTokens == nil) + #expect(summary.totalRequests == nil) + } + + @Test + func `comparison summaries keep Gregorian entries under a Buddhist calendar`() throws { + let bangkok = try #require(TimeZone(identifier: "Asia/Bangkok")) + var gregorian = Calendar(identifier: .gregorian) + gregorian.timeZone = bangkok + let now = try #require(gregorian.date(from: DateComponents( + timeZone: bangkok, + year: 2026, + month: 7, + day: 23, + hour: 12))) + var buddhist = Calendar(identifier: .buddhist) + buddhist.timeZone = bangkok + #expect(buddhist.component(.year, from: now) == 2569) + + let snapshot = CostUsageTokenSnapshot( + sessionTokens: nil, + sessionCostUSD: nil, + last30DaysTokens: 900, + last30DaysCostUSD: 9, + historyDays: 90, + daily: [ + Self.entry(day: "2026-06-23", cost: 1, tokens: 100, requests: 1), + Self.entry(day: "2026-06-24", cost: 4, tokens: 400, requests: 4), + Self.entry(day: "2026-07-16", cost: 1, tokens: 100, requests: 1), + Self.entry(day: "2026-07-17", cost: 2, tokens: 200, requests: 2), + Self.entry(day: "2026-07-23", cost: 3, tokens: 300, requests: 3), + ], + updatedAt: now) + + let summaries = snapshot.comparisonSummaries(periods: [7, 30], calendar: buddhist) + let sevenDays = try #require(summaries.first { $0.days == 7 }) + let thirtyDays = try #require(summaries.first { $0.days == 30 }) + + #expect(sevenDays.entryCount == 2) + #expect(sevenDays.totalCostUSD == 5) + #expect(sevenDays.totalTokens == 500) + #expect(thirtyDays.entryCount == 4) + #expect(thirtyDays.totalCostUSD == 10) + #expect(thirtyDays.totalTokens == 1000) + } + + private static func snapshot(historyDays: Int) -> CostUsageTokenSnapshot { + CostUsageTokenSnapshot( + sessionTokens: 500, + sessionCostUSD: 5, + last30DaysTokens: 1000, + last30DaysCostUSD: 10, + historyDays: historyDays, + daily: [ + self.entry(day: "2026-06-01", cost: 1, tokens: 100, requests: 1), + self.entry(day: "2026-06-25", cost: 4, tokens: 400, requests: 4), + self.entry(day: "2026-07-01", cost: 5, tokens: 500, requests: 5), + ], + updatedAt: self.now) + } + + private static func entry(day: String, cost: Double?, tokens: Int?, requests: Int?) + -> CostUsageDailyReport.Entry + { + CostUsageDailyReport.Entry( + date: day, + inputTokens: nil, + outputTokens: nil, + totalTokens: tokens, + requestCount: requests, + costUSD: cost, + modelsUsed: nil, + modelBreakdowns: nil) + } + + private static let now = Date(timeIntervalSince1970: 1_782_864_000) // 2026-07-01 00:00:00 UTC + private static var utcCalendar: Calendar { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(secondsFromGMT: 0)! + return calendar + } +} diff --git a/Tests/CodexBarTests/CrofMenuCardTests.swift b/Tests/CodexBarTests/CrofMenuCardTests.swift index ade932a3a2..4e48ddeb54 100644 --- a/Tests/CodexBarTests/CrofMenuCardTests.swift +++ b/Tests/CodexBarTests/CrofMenuCardTests.swift @@ -5,7 +5,43 @@ import Testing struct CrofMenuCardTests { @Test - func `model shows request count and avoids duplicate credits section`() throws { + func `model shows credit balance without request quota`() throws { + let now = Date() + let metadata = try #require(ProviderDefaults.metadata[.crof]) + let snapshot = CrofUsageSnapshot( + credits: 10, + updatedAt: now).toUsageSnapshot() + + let model = UsageMenuCardView.Model.make(.init( + provider: .crof, + metadata: metadata, + snapshot: snapshot, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + + #expect(model.creditsText == nil) + #expect(model.metrics.map(\.title) == ["Credits"]) + #expect(model.metrics.first?.percent == 100) + #expect(model.metrics.first?.resetText == nil) + #expect(model.metrics.first?.statusText == "$10.00") + #expect(model.metrics.first?.detailRightText == nil) + } + + @Test + func `model keeps request quota rows when the API returns them`() throws { let now = Date() let metadata = try #require(ProviderDefaults.metadata[.crof]) let snapshot = CrofUsageSnapshot( diff --git a/Tests/CodexBarTests/CrofUsageFetcherTests.swift b/Tests/CodexBarTests/CrofUsageFetcherTests.swift index 43ae3d99d9..f7c7e64474 100644 --- a/Tests/CodexBarTests/CrofUsageFetcherTests.swift +++ b/Tests/CodexBarTests/CrofUsageFetcherTests.swift @@ -10,7 +10,32 @@ struct CrofUsageFetcherTests { } @Test - func `usage response parses credits and request quota`() throws { + func `usage response parses credits with null request quota fields`() throws { + let json = """ + { + "credits":9.0441, + "requests_plan":null, + "usable_requests":null, + "usage":{ + "deepseek-v4-flash":{ + "cached_tokens":0, + "input_tokens":23, + "output_tokens":132, + "total_tokens":155 + } + } + } + """ + + let snapshot = try CrofUsageFetcher._parseSnapshotForTesting(Data(json.utf8)) + + #expect(snapshot.credits == 9.0441) + #expect(snapshot.requestsPlan == nil) + #expect(snapshot.usableRequests == nil) + } + + @Test + func `usage response preserves request quota fields when present`() throws { let json = """ {"credits":10.0,"requests_plan":1000,"usable_requests":998} """ @@ -23,76 +48,53 @@ struct CrofUsageFetcherTests { } @Test - func `usage snapshot maps usable requests to remaining quota`() { + func `usage snapshot maps credit balance as primary window`() { let snapshot = CrofUsageSnapshot( credits: 10, - requestsPlan: 1000, - usableRequests: 998, updatedAt: Date(timeIntervalSince1970: 1_777_800_000)) let usage = snapshot.toUsageSnapshot() - #expect(usage.primary?.usedPercent == 1) - #expect(usage.primary?.windowMinutes == 1440) - #expect(usage.primary?.resetDescription == "998 requests left") - #expect(usage.secondary?.usedPercent == 0) - #expect(usage.secondary?.resetDescription == "$10.00") + #expect(usage.primary?.usedPercent == 0) + #expect(usage.primary?.windowMinutes == nil) + #expect(usage.primary?.resetsAt == nil) + #expect(usage.primary?.resetDescription == "$10.00") + #expect(usage.secondary == nil) #expect(usage.identity?.providerID == .crof) #expect(usage.identity?.loginMethod == "API key") } @Test - func `usage snapshot floors credit balance to cents`() { - let snapshot = CrofUsageSnapshot( - credits: 9.9999, - requestsPlan: 1000, - usableRequests: 998) - - #expect(snapshot.toUsageSnapshot().secondary?.resetDescription == "$9.99") - } - - @Test - func `usage snapshot resets requests at next America Chicago midnight`() throws { - var utc = Calendar(identifier: .gregorian) - utc.timeZone = try #require(TimeZone(secondsFromGMT: 0)) - let updatedAt = try #require(utc.date(from: DateComponents( - year: 2026, - month: 5, - day: 8, - hour: 18, - minute: 30))) - let expectedReset = try #require(utc.date(from: DateComponents( - year: 2026, - month: 5, - day: 9, - hour: 5))) + func `usage snapshot prefers request quota when present`() { let snapshot = CrofUsageSnapshot( credits: 10, requestsPlan: 1000, usableRequests: 998, - updatedAt: updatedAt) + updatedAt: Date(timeIntervalSince1970: 1_777_800_000)) - #expect(snapshot.toUsageSnapshot().primary?.resetsAt == expectedReset) + let usage = snapshot.toUsageSnapshot() + + #expect(usage.primary?.usedPercent == 1) + #expect(usage.primary?.windowMinutes == 1440) + #expect(usage.primary?.resetsAt != nil) + #expect(usage.primary?.resetDescription == "998 requests left") + #expect(usage.secondary?.usedPercent == 0) + #expect(usage.secondary?.resetDescription == "$10.00") } @Test - func `usage snapshot clamps overreported usable requests`() { - let snapshot = CrofUsageSnapshot( - credits: 0, - requestsPlan: 1000, - usableRequests: 1200) + func `usage snapshot floors credit balance to cents`() { + let snapshot = CrofUsageSnapshot(credits: 9.9999) - #expect(snapshot.toUsageSnapshot().primary?.usedPercent == 0) + #expect(snapshot.toUsageSnapshot().primary?.resetDescription == "$9.99") } @Test - func `usage snapshot treats zero plan as exhausted`() { - let snapshot = CrofUsageSnapshot( - credits: 0, - requestsPlan: 0, - usableRequests: 0) + func `usage snapshot treats zero credits as exhausted`() { + let snapshot = CrofUsageSnapshot(credits: 0) #expect(snapshot.toUsageSnapshot().primary?.usedPercent == 100) + #expect(snapshot.toUsageSnapshot().primary?.resetDescription == "$0.00") } @Test @@ -108,12 +110,14 @@ struct CrofUsageFetcherTests { #expect(request.value(forHTTPHeaderField: "Accept") == "application/json") return try Self.makeResponse( url: url, - body: #"{"credits":10.0,"requests_plan":1000,"usable_requests":998}"#) + body: #"{"credits":9.0441,"requests_plan":null,"usable_requests":null,"usage":{}}"#) } let snapshot = try await CrofUsageFetcher.fetchUsage(apiKey: "crof-test", session: Self.makeSession()) - #expect(snapshot.usableRequests == 998) + #expect(snapshot.credits == 9.0441) + #expect(snapshot.requestsPlan == nil) + #expect(snapshot.usableRequests == nil) #expect(CrofStubURLProtocol.requests.map(\.url?.absoluteString) == ["https://crof.ai/usage_api/"]) } @@ -122,6 +126,7 @@ struct CrofUsageFetcherTests { let descriptor = ProviderDescriptorRegistry.descriptor(for: .crof) #expect(descriptor.metadata.displayName == "Crof") #expect(descriptor.metadata.dashboardURL == "https://crof.ai/dashboard") + #expect(descriptor.metadata.sessionLabel == "Credits") #expect(descriptor.fetchPlan.sourceModes == [.auto, .api]) #expect(descriptor.branding.iconResourceName == "ProviderIcon-crof") } @@ -205,7 +210,12 @@ struct CrofUsageFetcherTests { } final class CrofStubURLProtocol: URLProtocol { - nonisolated(unsafe) static var handler: ((URLRequest) throws -> (HTTPURLResponse, Data))? + private static let _handlerBox = LockIsolated<((URLRequest) throws -> (HTTPURLResponse, Data))?>(nil) + static var handler: ((URLRequest) throws -> (HTTPURLResponse, Data))? { + get { Self._handlerBox.value } + set { Self._handlerBox.setValue(newValue) } + } + nonisolated(unsafe) static var requests: [URLRequest] = [] override static func canInit(with request: URLRequest) -> Bool { diff --git a/Tests/CodexBarTests/CurlCaptureParserTests.swift b/Tests/CodexBarTests/CurlCaptureParserTests.swift new file mode 100644 index 0000000000..b77cbe1821 --- /dev/null +++ b/Tests/CodexBarTests/CurlCaptureParserTests.swift @@ -0,0 +1,79 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct CurlCaptureParserTests { + @Test + func `extracts request URL from standard DevTools capture`() { + let curl = "curl 'https://example.com/api/usage' -H 'Authorization: Bearer fake-token'" + #expect(CurlCaptureParser.requestURL(from: curl)?.absoluteString == "https://example.com/api/usage") + } + + @Test + func `extracts request URL from double quoted and bare captures`() { + #expect(CurlCaptureParser.requestURL(from: "curl \"https://example.com/double\"")?.path == "/double") + #expect(CurlCaptureParser.requestURL(from: "curl https://example.com/bare")?.path == "/bare") + } + + @Test + func `request URL returns nil for option first or malformed captures`() { + #expect(CurlCaptureParser.requestURL(from: "curl --location 'https://example.com'") == nil) + #expect(CurlCaptureParser.requestURL(from: "not-curl 'https://example.com'") == nil) + } + + @Test + func `extracts header fields from double quoted headers`() { + let curl = """ + curl 'https://example.com' --header "Authorization: Bearer fake-token" --header "Cookie: session=abc" + """ + let fields = CurlCaptureParser.headerFields(from: curl) + + #expect(fields.contains("Authorization: Bearer fake-token")) + #expect(fields.contains("Cookie: session=abc")) + } + + @Test + func `extracts header fields from single quoted -H flags`() { + let curl = "curl 'https://example.com' -H 'Authorization: Bearer fake-token'" + let fields = CurlCaptureParser.headerFields(from: curl) + + #expect(fields == ["Authorization: Bearer fake-token"]) + } + + @Test + func `header value lookup is case insensitive`() { + let fields = ["AUTHORIZATION: Bearer fake-token"] + #expect(CurlCaptureParser.headerValue(named: "authorization", in: fields) == "Bearer fake-token") + } + + @Test + func `header value lookup returns nil for missing header`() { + let fields = ["Cookie: session=abc"] + #expect(CurlCaptureParser.headerValue(named: "Authorization", in: fields) == nil) + } + + @Test + func `forwarded headers respects allowlist and drops unlisted headers`() { + let fields = [ + "Authorization: Bearer fake-token", + "Cookie: session=abc", + "X-Not-Allowed: nope", + ] + let allowlist = ["authorization": "Authorization", "cookie": "Cookie"] + let headers = CurlCaptureParser.forwardedHeaders(from: fields, allowlist: allowlist) + + #expect(headers["Authorization"] == "Bearer fake-token") + #expect(headers["Cookie"] == "session=abc") + #expect(headers["X-Not-Allowed"] == nil) + } + + @Test + func `forwarded headers can include authorization when allowlisted`() { + // ZoomMate's allowlist differs from T3 Chat's by deliberately including authorization (design D2). + let fields = ["authorization: Bearer fake-token"] + let allowlist = ["authorization": "Authorization"] + let headers = CurlCaptureParser.forwardedHeaders(from: fields, allowlist: allowlist) + + #expect(headers["Authorization"] == "Bearer fake-token") + } +} diff --git a/Tests/CodexBarTests/CursorAccountSwitchBrowserScanTests.swift b/Tests/CodexBarTests/CursorAccountSwitchBrowserScanTests.swift new file mode 100644 index 0000000000..3fabf18fed --- /dev/null +++ b/Tests/CodexBarTests/CursorAccountSwitchBrowserScanTests.swift @@ -0,0 +1,437 @@ +import Foundation +import SweetCookieKit +import Testing +@testable import CodexBarCore + +struct CursorAccountSwitchBrowserScanTests { + private final class LockedArray: @unchecked Sendable { + private let lock = NSLock() + private var values: [Element] = [] + + func append(_ value: Element) { + self.lock.lock() + defer { self.lock.unlock() } + self.values.append(value) + } + + func snapshot() -> [Element] { + self.lock.lock() + defer { self.lock.unlock() } + return self.values + } + } + + @Test + func `interactive browser mapping recognizes Comet and Chrome and rejects unknown apps`() { + let comet = CursorStatusProbe.interactiveBrowser(bundleIdentifier: "ai.perplexity.comet") + let chrome = CursorStatusProbe.interactiveBrowser(bundleIdentifier: "com.google.Chrome") + let unknown = CursorStatusProbe.interactiveBrowser(bundleIdentifier: "com.example.unknown") + let unverifiedArcChannel = CursorStatusProbe.interactiveBrowser( + bundleIdentifier: "company.thebrowser.Browser.beta") + let ambiguousYandexChannel = CursorStatusProbe.interactiveBrowser( + bundleIdentifier: "ru.yandex.desktop.yandex-browser") + + #expect(comet == .comet) + #expect(chrome == .chrome) + #expect(unknown == nil) + #expect(unverifiedArcChannel == nil) + #expect(ambiguousYandexChannel == nil) + } + + @Test + func `interactive browser mapping covers every unambiguous SweetCookieKit browser`() { + let mapping = CursorStatusProbe.interactiveBrowserByBundleIdentifier + let mappedBrowsers = Set(mapping.values) + let deliberatelyUnsupported: Set = [.arcBeta, .arcCanary, .yandex] + + #expect(mapping.count == mappedBrowsers.count) + #expect(mappedBrowsers.isDisjoint(with: deliberatelyUnsupported)) + #expect(mappedBrowsers.union(deliberatelyUnsupported) == Set(Browser.allCases)) + #expect(mapping.keys.allSatisfy { !$0.isEmpty && $0 == $0.lowercased() }) + } + + @Test + func `interactive browser support requires a readable cookie source`() throws { + let temp = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + let applicationURL = temp.appendingPathComponent("Firefox.app", isDirectory: true) + let contentsURL = applicationURL.appendingPathComponent("Contents", isDirectory: true) + try FileManager.default.createDirectory(at: contentsURL, withIntermediateDirectories: true) + let info = try PropertyListSerialization.data( + fromPropertyList: [ + "CFBundleIdentifier": "org.mozilla.firefox", + "CFBundleName": "Firefox", + ], + format: .xml, + options: 0) + try info.write(to: contentsURL.appendingPathComponent("Info.plist")) + defer { try? FileManager.default.removeItem(at: temp) } + + let profileRoot = temp + .appendingPathComponent("Library/Application Support/Firefox/Profiles", isDirectory: true) + .path + let cookieStore = "\(profileRoot)/profile.default-release/cookies.sqlite" + let makeDetection: (Bool) -> BrowserDetection = { readable in + BrowserDetection( + homeDirectory: temp.path, + cacheTTL: 0, + now: Date.init, + fileExists: { path in + path == applicationURL.path || path == profileRoot || path == cookieStore + }, + directoryContents: { path in + path == profileRoot && readable ? ["profile.default-release"] : nil + }, + applicationURLs: { _ in [applicationURL] }, + profileAccessIssue: { _ in readable ? nil : .unreadable }) + } + + #expect(CursorStatusProbe.supportsInteractiveLoginBrowser( + applicationURL: applicationURL, + browserDetection: makeDetection(true))) + let unreadableDetection = makeDetection(false) + #expect(unreadableDetection.cookieSourceProfileAccessIssue(.firefox) == .unreadable) + #expect(!CursorStatusProbe.supportsInteractiveLoginBrowser( + applicationURL: applicationURL, + browserDetection: unreadableDetection)) + } + + @Test + func `interactive browser support accepts a renamed installed application`() throws { + let temp = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + let applicationURL = try Self.makeBrowserApplication( + in: temp, + name: "Work Browser", + bundleIdentifier: "org.mozilla.firefox") + defer { try? FileManager.default.removeItem(at: temp) } + + let profileRoot = "\(temp.path)/Library/Application Support/Firefox/Profiles" + let profileName = "profile.default-release" + let cookieStore = "\(profileRoot)/\(profileName)/cookies.sqlite" + let detection = BrowserDetection( + homeDirectory: temp.path, + cacheTTL: 0, + now: Date.init, + fileExists: { path in + path == applicationURL.path || path == profileRoot || path == cookieStore + }, + directoryContents: { path in path == profileRoot ? [profileName] : nil }, + applicationURLs: { _ in [] }, + profileAccessIssue: { path in path == profileRoot ? nil : .unreadable }) + + #expect(CursorStatusProbe.interactiveBrowser(forApplicationURL: applicationURL) == .firefox) + #expect(!detection.isAppInstalled(.firefox)) + #expect(!CursorCookieImporter.isCookieSourceAvailable( + browser: .firefox, + browserDetection: detection)) + #expect(CursorCookieImporter.isCookieSourceAvailable( + browser: .firefox, + applicationURL: applicationURL, + browserDetection: detection)) + #expect(detection.isInteractiveCookieSourceAvailable(.firefox, applicationURL: applicationURL)) + #expect(CursorStatusProbe.supportsInteractiveLoginBrowser( + applicationURL: applicationURL, + browserDetection: detection)) + + let canonicalApplicationPath = "/Applications/Firefox.app" + let canonicalDetection = BrowserDetection( + homeDirectory: temp.path, + cacheTTL: 0, + now: Date.init, + fileExists: { path in path == canonicalApplicationPath || path == profileRoot }, + directoryContents: { _ in [] }, + applicationURLs: { _ in [] }, + profileAccessIssue: { path in path == profileRoot ? nil : .unreadable }) + let missingApplicationURL = temp.appendingPathComponent("Removed Browser.app", isDirectory: true) + + #expect(canonicalDetection.isAppInstalled(.firefox)) + #expect(!canonicalDetection.isInteractiveCookieSourceAvailable( + .firefox, + applicationURL: missingApplicationURL)) + } + + @Test + func `interactive Safari support accepts any existing readable source`() throws { + let temp = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + let applicationURL = try Self.makeBrowserApplication( + in: temp, + name: "Safari", + bundleIdentifier: "com.apple.Safari") + defer { try? FileManager.default.removeItem(at: temp) } + + let legacyRoot = "\(temp.path)/Library/Cookies" + let containerRoot = "\(temp.path)/Library/Containers/com.apple.Safari/Data/Library/Cookies" + let detection = BrowserDetection( + homeDirectory: temp.path, + cacheTTL: 0, + now: Date.init, + fileExists: { path in path == legacyRoot || path == containerRoot }, + directoryContents: { _ in nil }, + applicationURLs: { _ in [applicationURL] }, + profileAccessIssue: { path in path == legacyRoot ? .accessDenied : nil }) + + #expect(detection.isCookieSourceAvailable(.safari)) + #expect(CursorStatusProbe.supportsInteractiveLoginBrowser( + applicationURL: applicationURL, + browserDetection: detection)) + } + + @Test + func `interactive Safari support rejects missing and denied sources while imports remain eligible`() throws { + let temp = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + let applicationURL = try Self.makeBrowserApplication( + in: temp, + name: "Safari", + bundleIdentifier: "com.apple.Safari") + defer { try? FileManager.default.removeItem(at: temp) } + + let noRootProbeCalls = LockedArray() + let noRootDetection = BrowserDetection( + homeDirectory: temp.path, + cacheTTL: 0, + now: Date.init, + fileExists: { _ in false }, + directoryContents: { _ in nil }, + applicationURLs: { _ in [applicationURL] }, + profileAccessIssue: { path in + noRootProbeCalls.append(path) + return nil + }) + #expect(noRootDetection.isCookieSourceAvailable(.safari)) + #expect(!CursorStatusProbe.supportsInteractiveLoginBrowser( + applicationURL: applicationURL, + browserDetection: noRootDetection)) + #expect(noRootProbeCalls.snapshot().isEmpty) + + let legacyRoot = "\(temp.path)/Library/Cookies" + let deniedDetection = BrowserDetection( + homeDirectory: temp.path, + cacheTTL: 0, + now: Date.init, + fileExists: { $0 == legacyRoot }, + directoryContents: { _ in nil }, + applicationURLs: { _ in [applicationURL] }, + profileAccessIssue: { _ in .accessDenied }) + #expect(deniedDetection.isCookieSourceAvailable(.safari)) + #expect(!CursorStatusProbe.supportsInteractiveLoginBrowser( + applicationURL: applicationURL, + browserDetection: deniedDetection)) + } + + @Test + func `interactive scan refreshes a cookie store created after browser launch`() async throws { + let temp = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + let applicationURL = try Self.makeBrowserApplication( + in: temp, + name: "Firefox", + bundleIdentifier: "org.mozilla.firefox") + let profile = temp + .appendingPathComponent("Library/Application Support/Firefox/Profiles/profile.default-release") + try FileManager.default.createDirectory(at: profile, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: temp) } + + let detection = BrowserDetection( + homeDirectory: temp.path, + cacheTTL: 600, + now: Date.init, + fileExists: { path in + path == applicationURL.path || FileManager.default.fileExists(atPath: path) + }, + directoryContents: { path in + try? FileManager.default.contentsOfDirectory(atPath: path) + }, + applicationURLs: { _ in [applicationURL] }, + profileAccessIssue: { _ in nil }) + + #expect(!detection.isCookieSourceAvailable(.firefox)) + #expect(CursorStatusProbe.supportsInteractiveLoginBrowser( + applicationURL: applicationURL, + browserDetection: detection)) + FileManager.default.createFile( + atPath: profile.appendingPathComponent("cookies.sqlite").path, + contents: Data()) + #expect(!detection.isCookieSourceAvailable(.firefox)) + + let probe = CursorStatusProbe(browserDetection: detection) + do { + _ = try await probe.fetchBrowserLoginCandidates( + browserApplicationURL: applicationURL, + timeout: 1) + Issue.record("Expected the isolated browser store to contain no real Cursor session") + } catch let error as CursorStatusProbeError { + guard case .noSessionCookie = error else { + Issue.record("Expected no-session error, got \(error)") + return + } + } + + #expect(detection.isCookieSourceAvailable(.firefox)) + } + + @Test + func `interactive Comet candidate scan ignores valid Safari account and returns only Comet account`() async throws { + let probe = CursorStatusProbe(browserDetection: BrowserDetection(cacheTTL: 0)) + let safari = Self.makeSessionInfo(sourceLabel: "Safari Personal") + let comet = Self.makeSessionInfo(sourceLabel: "Comet Work") + let fixtures = [ + safari.cookieHeader: Self.snapshot(accountID: "personal-id", email: "personal@example.com"), + comet.cookieHeader: Self.snapshot(accountID: "work-id", email: "work@example.com"), + ] + let importedBrowsers = LockedArray() + let attemptedHeaders = LockedArray() + + let results = try await probe.fetchBrowserLoginCandidates( + browser: .comet, + importSessions: { candidate in + importedBrowsers.append("strict:\(candidate.displayName)") + return switch candidate { + case .safari: [safari] + case .comet: [comet] + default: [] + } + }, + importDomainSessions: { candidate in + importedBrowsers.append("domain:\(candidate.displayName)") + return [] + }, + fetchSnapshot: { cookieHeader in + attemptedHeaders.append(cookieHeader) + guard let snapshot = fixtures[cookieHeader] else { + throw URLError(.badServerResponse) + } + return snapshot + }) + + #expect(results.map(\.snapshot.accountID) == ["work-id"]) + #expect(results.map(\.sourceLabel) == ["Comet Work"]) + #expect(importedBrowsers.snapshot() == ["strict:Comet", "domain:Comet"]) + #expect(attemptedHeaders.snapshot() == [comet.cookieHeader]) + } + + @Test + func `interactive Comet login with no session does not fall back to valid Safari account`() async throws { + let probe = CursorStatusProbe(browserDetection: BrowserDetection(cacheTTL: 0)) + let safari = Self.makeSessionInfo(sourceLabel: "Safari Personal") + let importedBrowsers = LockedArray() + + do { + _ = try await probe.fetchBrowserLoginCandidates( + browser: .comet, + importSessions: { candidate in + importedBrowsers.append("strict:\(candidate.displayName)") + return candidate == .safari ? [safari] : [] + }, + importDomainSessions: { candidate in + importedBrowsers.append("domain:\(candidate.displayName)") + return candidate == .safari ? [safari] : [] + }, + fetchSnapshot: { _ in + Issue.record("No session should be attempted when Comet has no Cursor cookies") + throw CursorStatusProbeError.parseFailed("unexpected session") + }) + Issue.record("Expected the Comet-only scan to remain unresolved") + } catch let error as CursorStatusProbeError { + guard case .noSessionCookie = error else { + Issue.record("Expected no-session error, got \(error)") + return + } + } catch { + Issue.record("Expected Cursor no-session error, got \(error)") + } + #expect(importedBrowsers.snapshot() == ["strict:Comet", "domain:Comet"]) + } + + @Test + func `browser scan skips rejected old account and caches only accepted new account`() async { + let probe = CursorStatusProbe(browserDetection: BrowserDetection(cacheTTL: 0)) + let safari = Self.makeSessionInfo(sourceLabel: "Safari") + let chrome = Self.makeSessionInfo(sourceLabel: "Chrome") + let fixtures = [ + safari.cookieHeader: Self.snapshot(accountID: "old-id", email: "old@example.com"), + chrome.cookieHeader: Self.snapshot(accountID: "new-id", email: "new@example.com"), + ] + let attemptedHeaders = LockedArray() + let cachedSources = LockedArray() + + let result = await probe.scanBrowsers( + [.safari, .chrome], + importSessions: { browser in + switch browser { + case .safari: [safari] + case .chrome: [chrome] + default: [] + } + }, + attemptFetch: { session in + await probe.fetchIfSessionAccepted( + session, + log: { _ in }, + acceptSnapshot: { $0.accountID == "new-id" }, + fetchSnapshot: { cookieHeader in + attemptedHeaders.append(cookieHeader) + guard let snapshot = fixtures[cookieHeader] else { + throw URLError(.badServerResponse) + } + return snapshot + }, + cacheAcceptedSession: { cachedSources.append($0.sourceLabel) }) + }) + + switch result { + case let .succeeded(snapshot): + #expect(snapshot.accountID == "new-id") + case .exhausted: + Issue.record("Expected the later Chrome account to be accepted") + } + #expect(attemptedHeaders.snapshot() == [safari.cookieHeader, chrome.cookieHeader]) + #expect(cachedSources.snapshot() == ["Chrome"]) + } + + private static func makeSessionInfo(sourceLabel: String) -> CursorCookieImporter.SessionInfo { + let cookieProps: [HTTPCookiePropertyKey: Any] = [ + .name: "WorkosCursorSessionToken", + .value: sourceLabel.lowercased(), + .domain: "cursor.com", + .path: "/", + .secure: true, + ] + + let cookie = HTTPCookie(properties: cookieProps)! + return CursorCookieImporter.SessionInfo(cookies: [cookie], sourceLabel: sourceLabel) + } + + private static func makeBrowserApplication( + in root: URL, + name: String, + bundleIdentifier: String) throws -> URL + { + let applicationURL = root.appendingPathComponent("\(name).app", isDirectory: true) + let contentsURL = applicationURL.appendingPathComponent("Contents", isDirectory: true) + try FileManager.default.createDirectory(at: contentsURL, withIntermediateDirectories: true) + let info = try PropertyListSerialization.data( + fromPropertyList: [ + "CFBundleIdentifier": bundleIdentifier, + "CFBundleName": name, + ], + format: .xml, + options: 0) + try info.write(to: contentsURL.appendingPathComponent("Info.plist")) + return applicationURL + } + + private static func snapshot(accountID: String, email: String) -> CursorStatusSnapshot { + CursorStatusSnapshot( + planPercentUsed: 12, + planUsedUSD: 1, + planLimitUSD: 20, + onDemandUsedUSD: 0, + onDemandLimitUSD: nil, + teamOnDemandUsedUSD: nil, + teamOnDemandLimitUSD: nil, + billingCycleEnd: nil, + membershipType: "pro", + accountEmail: email, + accountID: accountID, + accountName: nil, + rawJSON: nil) + } +} diff --git a/Tests/CodexBarTests/CursorEnterpriseUsageTests.swift b/Tests/CodexBarTests/CursorEnterpriseUsageTests.swift index c6b5dd19bc..8e55b9f6aa 100644 --- a/Tests/CodexBarTests/CursorEnterpriseUsageTests.swift +++ b/Tests/CodexBarTests/CursorEnterpriseUsageTests.swift @@ -4,6 +4,26 @@ import Testing @Suite(.serialized) struct CursorEnterpriseUsageTests { + @Test + func `legacy provider cost snapshot decodes without personal spend`() throws { + let json = """ + { + "used": 12.5, + "limit": 100, + "currencyCode": "USD", + "period": "Monthly", + "resetsAt": null, + "nextRegenAmount": null, + "updatedAt": 0 + } + """ + + let snapshot = try JSONDecoder().decode(ProviderCostSnapshot.self, from: Data(json.utf8)) + + #expect(snapshot.used == 12.5) + #expect(snapshot.personalUsed == nil) + } + @Test func `parses enterprise overall and pooled usage summary`() throws { // Live Cursor Enterprise payload (sanitized). The Pro/Hobby `plan` block is absent; @@ -126,6 +146,75 @@ struct CursorEnterpriseUsageTests { #expect(snapshot.planLimitUSD == 281_220.0) } + @Test + func `team on-demand pool is the budget and personal spend rides along`() { + // Live team-plan payload (sanitized): the user's own on-demand spend has no personal limit, + // so the team pool is the headline budget. The personal spend must still be surfaced. + let snapshot = CursorStatusProbe(browserDetection: BrowserDetection(cacheTTL: 0)) + .parseUsageSummary( + CursorUsageSummary( + billingCycleStart: "2026-06-01T00:00:00.000Z", + billingCycleEnd: "2026-07-01T00:00:00.000Z", + membershipType: "enterprise", + limitType: "team", + isUnlimited: false, + autoModelSelectedDisplayMessage: nil, + namedModelSelectedDisplayMessage: nil, + individualUsage: CursorIndividualUsage( + plan: CursorPlanUsage( + enabled: true, + used: 2000, + limit: 2000, + remaining: 0, + breakdown: nil, + autoPercentUsed: 0, + apiPercentUsed: 100, + totalPercentUsed: 100), + onDemand: CursorOnDemandUsage(enabled: true, used: 4471, limit: nil, remaining: nil), + overall: nil), + teamUsage: CursorTeamUsage( + onDemand: CursorOnDemandUsage( + enabled: true, + used: 1_311_125, + limit: 2_000_000, + remaining: 688_875), + pooled: nil)), + userInfo: nil, + rawJSON: nil) + + let cost = snapshot.toUsageSnapshot().providerCost + #expect(cost?.used == 13111.25) // team pool used + #expect(cost?.limit == 20000.0) // team pool limit + #expect(cost?.personalUsed == 44.71) // this account's own on-demand spend + } + + @Test + func `personal on-demand limit keeps personal budget with no rider`() { + // When the user has their own on-demand limit, that is the budget and there is no separate rider. + let snapshot = CursorStatusProbe(browserDetection: BrowserDetection(cacheTTL: 0)) + .parseUsageSummary( + CursorUsageSummary( + billingCycleStart: nil, + billingCycleEnd: nil, + membershipType: "pro", + limitType: "user", + isUnlimited: false, + autoModelSelectedDisplayMessage: nil, + namedModelSelectedDisplayMessage: nil, + individualUsage: CursorIndividualUsage( + plan: nil, + onDemand: CursorOnDemandUsage(enabled: true, used: 4471, limit: 10000, remaining: 5529), + overall: nil), + teamUsage: nil), + userInfo: nil, + rawJSON: nil) + + let cost = snapshot.toUsageSnapshot().providerCost + #expect(cost?.used == 44.71) + #expect(cost?.limit == 100.0) + #expect(cost?.personalUsed == nil) + } + @Test func `existing plan block still wins over overall and pooled`() { // Guard against future drift: when Cursor sends both legacy `plan` and the newer `overall` diff --git a/Tests/CodexBarTests/CursorImportedSessionScanningTests.swift b/Tests/CodexBarTests/CursorImportedSessionScanningTests.swift new file mode 100644 index 0000000000..ab6a4f7714 --- /dev/null +++ b/Tests/CodexBarTests/CursorImportedSessionScanningTests.swift @@ -0,0 +1,574 @@ +import Foundation +import Testing +@testable import CodexBarCore + +@Suite(.serialized) +struct CursorImportedSessionScanningTests { + private final class LockedArray: @unchecked Sendable { + private let lock = NSLock() + private var values: [Element] = [] + + func append(_ value: Element) { + self.lock.lock() + defer { self.lock.unlock() } + self.values.append(value) + } + + func snapshot() -> [Element] { + self.lock.lock() + defer { self.lock.unlock() } + return self.values + } + } + + @Test + func `resolved browser scan stops on non authentication data failure`() async { + let probe = CursorStatusProbe(browserDetection: BrowserDetection(cacheTTL: 0)) + let attempts = LockedArray() + let paginationError = CostUsageError.cursorPaginationIncomplete(expected: 10, received: 5) + + let error = await #expect(throws: CostUsageError.self) { + _ = try await probe.scanResolvedBrowsers( + [.chrome], + importSessions: { _ in + [ + Self.makeSessionInfo(sourceLabel: "Account A"), + Self.makeSessionInfo(sourceLabel: "Account B"), + ] + }, + attemptFetch: { session in + attempts.append(session.sourceLabel) + if session.sourceLabel == "Account A" { + throw paginationError + } + return .succeeded("wrong account") + }) + } + + guard case let .cursorPaginationIncomplete(expected, received) = error else { + Issue.record("Expected the original pagination error") + return + } + #expect(expected == 10) + #expect(received == 5) + #expect(attempts.snapshot() == ["Account A"]) + } + + @Test + func `browser login candidates return every valid unique session without committing cache`() async throws { + let probe = CursorStatusProbe(browserDetection: BrowserDetection(cacheTTL: 0)) + let strictPersonal = Self.makeSessionInfo(sourceLabel: "Comet Default", cookieValue: "personal") + let strictTeam = Self.makeSessionInfo(sourceLabel: "Comet Profile 1", cookieValue: "team") + let duplicatePersonal = Self.makeSessionInfo( + sourceLabel: "Comet Alternate Personal Label", + cookieValue: "personal") + let domainValid = Self.makeSessionInfo( + sourceLabel: "Comet Profile 2 (domain cookies)", + cookieValue: "domain") + var importPhases: [String] = [] + let validatedHeaders = LockedArray() + let cacheOperations = KeychainCacheStore.OperationRecorder() + + let results = try await KeychainCacheStore.withOperationRecorderForTesting(cacheOperations) { + try await probe.fetchBrowserLoginCandidates( + browser: .comet, + importSessions: { browser in + #expect(browser == .comet) + importPhases.append("strict") + return [strictPersonal, strictTeam] + }, + importDomainSessions: { browser in + #expect(browser == .comet) + importPhases.append("domain") + return [duplicatePersonal, domainValid] + }, + fetchSnapshot: { cookieHeader in + validatedHeaders.append(cookieHeader) + switch cookieHeader { + case strictPersonal.cookieHeader: + return Self.makeBrowserLoginSnapshot( + accountID: "personal-id", + email: "personal@example.com") + case strictTeam.cookieHeader: + return Self.makeBrowserLoginSnapshot(accountID: "team-id", email: "team@example.com") + case domainValid.cookieHeader: + return Self.makeBrowserLoginSnapshot(accountID: "domain-id", email: "domain@example.com") + default: + throw CursorStatusProbeError.parseFailed("unexpected test session") + } + }) + } + + #expect(importPhases == ["strict", "domain"]) + #expect(results.map(\.sourceLabel) == [ + strictPersonal.sourceLabel, + strictTeam.sourceLabel, + domainValid.sourceLabel, + ]) + #expect(results.map(\.snapshot.accountID) == ["personal-id", "team-id", "domain-id"]) + #expect(validatedHeaders.snapshot() == [ + strictPersonal.cookieHeader, + strictTeam.cookieHeader, + domainValid.cookieHeader, + ]) + #expect(cacheOperations.operations.isEmpty) + } + + @Test + func `browser login candidates keep valid results when another profile has a transient failure`() async throws { + let probe = CursorStatusProbe(browserDetection: BrowserDetection(cacheTTL: 0)) + let valid = Self.makeSessionInfo(sourceLabel: "Comet Default", cookieValue: "valid") + let transient = Self.makeSessionInfo(sourceLabel: "Comet Profile 1", cookieValue: "transient") + let authRejected = Self.makeSessionInfo(sourceLabel: "Comet Profile 2", cookieValue: "auth-rejected") + + let results = try await probe.fetchBrowserLoginCandidates( + browser: .comet, + importSessions: { _ in [valid, transient] }, + importDomainSessions: { _ in [authRejected] }, + fetchSnapshot: { cookieHeader in + switch cookieHeader { + case valid.cookieHeader: + return Self.makeBrowserLoginSnapshot(accountID: "valid-id", email: "valid@example.com") + case transient.cookieHeader: + throw CursorStatusProbeError.networkError("transient failure") + case authRejected.cookieHeader: + throw CursorStatusProbeError.notLoggedIn + default: + throw CursorStatusProbeError.parseFailed("unexpected test session") + } + }) + + #expect(results.map(\.snapshot.accountID) == ["valid-id"]) + } + + @Test + func `browser login candidates return earlier result when later profile reaches deadline`() async throws { + let probe = CursorStatusProbe(browserDetection: BrowserDetection(cacheTTL: 0)) + let valid = Self.makeSessionInfo(sourceLabel: "Comet Default", cookieValue: "valid") + let slow = Self.makeSessionInfo(sourceLabel: "Comet Profile 1", cookieValue: "slow") + let validatedHeaders = LockedArray() + + let results = try await probe.fetchBrowserLoginCandidates( + browser: .comet, + importSessions: { _ in [valid, slow] }, + importDomainSessions: { _ in [] }, + fetchSnapshot: { cookieHeader in + validatedHeaders.append(cookieHeader) + if cookieHeader == slow.cookieHeader { + try await Task.sleep(nanoseconds: 200_000_000) + return Self.makeBrowserLoginSnapshot(accountID: "slow-id", email: "slow@example.com") + } + return Self.makeBrowserLoginSnapshot( + accountID: "valid-id", + email: "valid@example.com") + }, + deadline: Date().addingTimeInterval(0.1)) + + #expect(results.map(\.snapshot.accountID) == ["valid-id"]) + #expect(validatedHeaders.snapshot() == [valid.cookieHeader, slow.cookieHeader]) + } + + @Test + func `browser login candidates skip identity-less success when another profile is valid`() async throws { + let probe = CursorStatusProbe(browserDetection: BrowserDetection(cacheTTL: 0)) + let incomplete = Self.makeSessionInfo(sourceLabel: "Comet Default", cookieValue: "incomplete") + let valid = Self.makeSessionInfo(sourceLabel: "Comet Profile 1", cookieValue: "valid") + + let results = try await probe.fetchBrowserLoginCandidates( + browser: .comet, + importSessions: { _ in [incomplete, valid] }, + importDomainSessions: { _ in [] }, + fetchSnapshot: { cookieHeader in + switch cookieHeader { + case incomplete.cookieHeader: + return Self.makeBrowserLoginSnapshot(accountID: " ", email: "\n") + case valid.cookieHeader: + return Self.makeBrowserLoginSnapshot(accountID: "valid-id", email: "valid@example.com") + default: + throw CursorStatusProbeError.parseFailed("unexpected test session") + } + }) + + #expect(results.map(\.snapshot.accountID) == ["valid-id"]) + } + + @Test + func `browser login candidate deadline fails closed before validating later profiles`() async { + let probe = CursorStatusProbe(browserDetection: BrowserDetection(cacheTTL: 0)) + let first = Self.makeSessionInfo(sourceLabel: "Comet Default", cookieValue: "first") + let second = Self.makeSessionInfo(sourceLabel: "Comet Profile 1", cookieValue: "second") + let validatedHeaders = LockedArray() + + do { + _ = try await probe.fetchBrowserLoginCandidates( + browser: .comet, + importSessions: { _ in [first, second] }, + importDomainSessions: { _ in [] }, + fetchSnapshot: { cookieHeader in + validatedHeaders.append(cookieHeader) + try await Task.sleep(nanoseconds: 20_000_000) + return Self.makeBrowserLoginSnapshot( + accountID: "first-id", + email: "first@example.com") + }, + deadline: Date().addingTimeInterval(0.01)) + Issue.record("Expected browser candidate validation to time out") + } catch let error as CursorStatusProbeError { + guard case let .networkError(message) = error else { + Issue.record("Expected deadline network error, got \(error)") + return + } + #expect(message.contains("Timed out")) + } catch { + Issue.record("Expected Cursor deadline error, got \(error)") + } + + #expect(validatedHeaders.snapshot() == [first.cookieHeader]) + } + + @Test + func `browser fallback cannot publish or overwrite a login committed during an earlier request`() async { + let probe = CursorStatusProbe(browserDetection: BrowserDetection(cacheTTL: 0)) + let background = Self.makeSessionInfo(sourceLabel: "Background", cookieValue: "background") + let service = "cursor-login-race-\(UUID().uuidString)" + let legacyBase = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + + await KeychainCacheStore.withServiceOverrideForTesting(service) { + await CookieHeaderCache.withLegacyBaseURLOverrideForTesting(legacyBase) { + KeychainCacheStore.setTestStoreForTesting(true) + defer { KeychainCacheStore.setTestStoreForTesting(false) } + + // A refresh captures this before its cached-session request. Model that request suspending before + // the refresh reaches browser fallback and the user commits a newly selected account meanwhile. + let observation = CookieHeaderCache.observeForConditionalMutation(provider: .cursor) + await Task.yield() + #expect(CookieHeaderCache.storeResult( + provider: .cursor, + cookieHeader: "fixtureSession=selected", + sourceLabel: "Interactive login")) + + let outcome = await probe.fetchIfSessionAccepted( + background, + log: { _ in }, + fetchSnapshot: { _ in + Self.makeBrowserLoginSnapshot( + accountID: "background-id", + email: "background@example.com") + }, + cacheObservation: observation) + + guard case .tryNextBrowser = outcome else { + Issue.record("Expected the stale background fetch snapshot to be discarded") + return + } + #expect(CookieHeaderCache.load(provider: .cursor)?.cookieHeader == "fixtureSession=selected") + } + } + } + + @Test + func `resolved session accepts result when the same credential is cached concurrently`() async throws { + let probe = CursorStatusProbe(browserDetection: BrowserDetection(cacheTTL: 0)) + let session = Self.makeSessionInfo(sourceLabel: "Background", cookieValue: "background") + let service = "cursor-login-same-credential-race-\(UUID().uuidString)" + let legacyBase = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + + try await KeychainCacheStore.withServiceOverrideForTesting(service) { + try await CookieHeaderCache.withLegacyBaseURLOverrideForTesting(legacyBase) { + KeychainCacheStore.setTestStoreForTesting(true) + defer { KeychainCacheStore.setTestStoreForTesting(false) } + + let observation = CookieHeaderCache.observeForConditionalMutation(provider: .cursor) + let outcome = try await probe.resolveImportedSession( + session, + perform: { cookieHeader, _ in + #expect(CookieHeaderCache.storeResult( + provider: .cursor, + cookieHeader: cookieHeader, + sourceLabel: "Interactive login")) + return cookieHeader + }, + log: { _ in }, + cacheObservation: observation) + + guard case let .succeeded(cookieHeader) = outcome else { + Issue.record("Expected the matching concurrent credential result") + return + } + #expect(cookieHeader == session.cookieHeader) + #expect(CookieHeaderCache.load(provider: .cursor)?.cookieHeader == session.cookieHeader) + } + } + } + + @Test + func `resolved session retries a different credential cached concurrently`() async throws { + let probe = CursorStatusProbe(browserDetection: BrowserDetection(cacheTTL: 0)) + let session = Self.makeSessionInfo(sourceLabel: "Background", cookieValue: "background") + let replacement = "fixtureSession=replacement" + let attempts = LockedArray() + let service = "cursor-login-replacement-race-\(UUID().uuidString)" + let legacyBase = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + + try await KeychainCacheStore.withServiceOverrideForTesting(service) { + try await CookieHeaderCache.withLegacyBaseURLOverrideForTesting(legacyBase) { + KeychainCacheStore.setTestStoreForTesting(true) + defer { KeychainCacheStore.setTestStoreForTesting(false) } + + let observation = CookieHeaderCache.observeForConditionalMutation(provider: .cursor) + let outcome = try await probe.resolveImportedSession( + session, + perform: { cookieHeader, _ in + attempts.append(cookieHeader) + if cookieHeader == session.cookieHeader { + #expect(CookieHeaderCache.storeResult( + provider: .cursor, + cookieHeader: replacement, + sourceLabel: "Interactive login")) + } + return cookieHeader + }, + log: { _ in }, + cacheObservation: observation) + + guard case let .succeeded(cookieHeader) = outcome else { + Issue.record("Expected the replacement credential result") + return + } + #expect(cookieHeader == replacement) + #expect(attempts.snapshot() == [session.cookieHeader, replacement]) + #expect(CookieHeaderCache.load(provider: .cursor)?.cookieHeader == replacement) + } + } + } + + @Test + func `imported session scan continues after non auth failure until later success`() async { + let probe = CursorStatusProbe(browserDetection: BrowserDetection(cacheTTL: 0)) + let expected = CursorStatusSnapshot( + planPercentUsed: 0.441025641025641, + autoPercentUsed: 0.36, + apiPercentUsed: 0.7111111111111111, + planUsedUSD: 0.86, + planLimitUSD: 20.0, + onDemandUsedUSD: 0, + onDemandLimitUSD: nil, + teamOnDemandUsedUSD: nil, + teamOnDemandLimitUSD: nil, + billingCycleEnd: nil, + membershipType: "pro", + accountEmail: nil, + accountName: nil, + rawJSON: nil) + + let result = await probe.scanImportedSessions([ + Self.makeSessionInfo(sourceLabel: "Chrome"), + Self.makeSessionInfo(sourceLabel: "Safari"), + ]) { session in + switch session.sourceLabel { + case "Chrome": + .failed(.networkError("HTTP 500")) + case "Safari": + .succeeded(expected) + default: + .tryNextBrowser + } + } + + switch result { + case let .succeeded(snapshot): + #expect(snapshot.planPercentUsed == expected.planPercentUsed) + #expect(snapshot.autoPercentUsed == expected.autoPercentUsed) + #expect(snapshot.apiPercentUsed == expected.apiPercentUsed) + case .exhausted: + Issue.record("Expected scan to continue to the later successful browser session") + } + } + + @Test + func `imported session scan preserves first non auth failure after exhausting sessions`() async { + let probe = CursorStatusProbe(browserDetection: BrowserDetection(cacheTTL: 0)) + + let result = await probe.scanImportedSessions([ + Self.makeSessionInfo(sourceLabel: "Chrome"), + Self.makeSessionInfo(sourceLabel: "Safari"), + Self.makeSessionInfo(sourceLabel: "Arc"), + ]) { session in + switch session.sourceLabel { + case "Chrome": + .failed(.networkError("HTTP 500")) + case "Safari": + .tryNextBrowser + case "Arc": + .failed(.parseFailed("bad payload")) + default: + .tryNextBrowser + } + } + + switch result { + case .succeeded: + Issue.record("Expected scan to report the first recoverable error after exhausting sessions") + case let .exhausted(error): + guard let error else { + Issue.record("Expected first recoverable error to be preserved") + return + } + guard case let .networkError(message) = error else { + Issue.record("Expected first recoverable error to be the Chrome network failure") + return + } + #expect(message == "HTTP 500") + } + } + + @Test + func `browser scan stops importing after later browser succeeds`() async { + let probe = CursorStatusProbe(browserDetection: BrowserDetection(cacheTTL: 0)) + let expected = CursorStatusSnapshot( + planPercentUsed: 42, + autoPercentUsed: 12, + apiPercentUsed: 85, + planUsedUSD: 8.4, + planLimitUSD: 20, + onDemandUsedUSD: 0, + onDemandLimitUSD: nil, + teamOnDemandUsedUSD: nil, + teamOnDemandLimitUSD: nil, + billingCycleEnd: nil, + membershipType: "pro", + accountEmail: nil, + accountName: nil, + rawJSON: nil) + var importedLabels: [String] = [] + + let result = await probe.scanBrowsers( + [.chrome, .safari, .chromeBeta], + importSessions: { browser in + importedLabels.append(browser.displayName) + switch browser { + case .chrome: + return [Self.makeSessionInfo(sourceLabel: "Chrome")] + case .safari: + return [Self.makeSessionInfo(sourceLabel: "Safari")] + case .chromeBeta: + return [Self.makeSessionInfo(sourceLabel: "Chrome Beta")] + default: + return [] + } + }, + attemptFetch: { session in + switch session.sourceLabel { + case "Chrome": + .failed(.networkError("HTTP 500")) + case "Safari": + .succeeded(expected) + default: + .tryNextBrowser + } + }) + + switch result { + case let .succeeded(snapshot): + #expect(snapshot.planPercentUsed == expected.planPercentUsed) + #expect(importedLabels == ["Chrome", "Safari"]) + case .exhausted: + Issue.record("Expected browser scan to stop after the later successful browser") + } + } + + @Test + func `browser scan keeps trying later sources within the same browser`() async { + let probe = CursorStatusProbe(browserDetection: BrowserDetection(cacheTTL: 0)) + let expected = CursorStatusSnapshot( + planPercentUsed: 12, + autoPercentUsed: 3, + apiPercentUsed: 45, + planUsedUSD: 2.4, + planLimitUSD: 20, + onDemandUsedUSD: 0, + onDemandLimitUSD: nil, + teamOnDemandUsedUSD: nil, + teamOnDemandLimitUSD: nil, + billingCycleEnd: nil, + membershipType: "pro", + accountEmail: nil, + accountName: nil, + rawJSON: nil) + var attemptedSources: [String] = [] + + let result = await probe.scanBrowsers( + [.chrome, .safari], + importSessions: { browser in + switch browser { + case .chrome: + [ + Self.makeSessionInfo(sourceLabel: "Chrome Profile 1"), + Self.makeSessionInfo(sourceLabel: "Chrome Profile 2 (domain cookies)"), + ] + case .safari: + [Self.makeSessionInfo(sourceLabel: "Safari")] + default: + [] + } + }, + attemptFetch: { session in + attemptedSources.append(session.sourceLabel) + switch session.sourceLabel { + case "Chrome Profile 1": + return CursorStatusProbe.ImportedSessionFetchOutcome.failed(.networkError("HTTP 500")) + case "Chrome Profile 2 (domain cookies)": + return CursorStatusProbe.ImportedSessionFetchOutcome.succeeded(expected) + default: + return CursorStatusProbe.ImportedSessionFetchOutcome.tryNextBrowser + } + }) + + switch result { + case let .succeeded(snapshot): + #expect(snapshot.planPercentUsed == expected.planPercentUsed) + #expect(attemptedSources == ["Chrome Profile 1", "Chrome Profile 2 (domain cookies)"]) + case .exhausted: + Issue.record("Expected browser scan to continue to later sources within the same browser") + } + } + + private static func makeSessionInfo( + sourceLabel: String, + cookieValue: String? = nil) -> CursorCookieImporter.SessionInfo + { + let cookieProps: [HTTPCookiePropertyKey: Any] = [ + .name: "WorkosCursorSessionToken", + .value: cookieValue ?? sourceLabel.lowercased(), + .domain: "cursor.com", + .path: "/", + .secure: true, + ] + + let cookie = HTTPCookie(properties: cookieProps)! + return CursorCookieImporter.SessionInfo(cookies: [cookie], sourceLabel: sourceLabel) + } + + private static func makeBrowserLoginSnapshot( + accountID: String?, + email: String?) -> CursorStatusSnapshot + { + CursorStatusSnapshot( + planPercentUsed: 12, + planUsedUSD: 1, + planLimitUSD: 20, + onDemandUsedUSD: 0, + onDemandLimitUSD: nil, + teamOnDemandUsedUSD: nil, + teamOnDemandLimitUSD: nil, + billingCycleEnd: nil, + membershipType: "pro", + accountEmail: email, + accountID: accountID, + accountName: nil, + rawJSON: nil) + } +} diff --git a/Tests/CodexBarTests/CursorLegacyRequestProjectionTests.swift b/Tests/CodexBarTests/CursorLegacyRequestProjectionTests.swift new file mode 100644 index 0000000000..3ee0cf47ed --- /dev/null +++ b/Tests/CodexBarTests/CursorLegacyRequestProjectionTests.swift @@ -0,0 +1,59 @@ +import Testing +@testable import CodexBarCore + +struct CursorLegacyRequestProjectionTests { + @Test + func `legacy plan hides token-based auto and api bars`() { + let snapshot = Self.snapshot(requestsUsed: 347, requestsLimit: 500) + + let usageSnapshot = snapshot.toUsageSnapshot() + + #expect(abs((usageSnapshot.primary?.usedPercent ?? 0) - 69.4) < 0.01) + #expect(usageSnapshot.cursorRequests?.used == 347) + #expect(usageSnapshot.cursorRequests?.limit == 500) + #expect(usageSnapshot.secondary == nil) + #expect(usageSnapshot.tertiary == nil) + } + + @Test + func `unusable legacy request quota preserves token bars`() { + let requestCases: [(used: Int?, limit: Int?)] = [ + (nil, 500), + (12, 0), + ] + + for requestCase in requestCases { + let usageSnapshot = Self.snapshot( + requestsUsed: requestCase.used, + requestsLimit: requestCase.limit).toUsageSnapshot() + + #expect(usageSnapshot.primary?.usedPercent == 7.0) + #expect(usageSnapshot.cursorRequests == nil) + #expect(usageSnapshot.secondary?.usedPercent == 11.0) + #expect(usageSnapshot.tertiary?.usedPercent == 22.0) + } + } + + private static func snapshot( + requestsUsed: Int?, + requestsLimit: Int?) -> CursorStatusSnapshot + { + CursorStatusSnapshot( + planPercentUsed: 7.0, + autoPercentUsed: 11.0, + apiPercentUsed: 22.0, + planUsedUSD: 1.4, + planLimitUSD: 20.0, + onDemandUsedUSD: 0, + onDemandLimitUSD: nil, + teamOnDemandUsedUSD: nil, + teamOnDemandLimitUSD: nil, + billingCycleEnd: nil, + membershipType: "pro", + accountEmail: "user@example.com", + accountName: nil, + rawJSON: nil, + requestsUsed: requestsUsed, + requestsLimit: requestsLimit) + } +} diff --git a/Tests/CodexBarTests/CursorLoginAccountSelectorTests.swift b/Tests/CodexBarTests/CursorLoginAccountSelectorTests.swift new file mode 100644 index 0000000000..58494d57b8 --- /dev/null +++ b/Tests/CodexBarTests/CursorLoginAccountSelectorTests.swift @@ -0,0 +1,135 @@ +import Testing +@testable import CodexBar + +struct CursorLoginAccountSelectorTests { + @Test + func `labels include available identity metadata and always include the source`() { + let choices = CursorLoginAccountSelector.choices(for: [ + .init( + selectionID: "name-and-email", + name: "Example Team", + email: "team@example.com", + sourceLabel: "Comet · Work"), + .init( + selectionID: "email-only", + name: nil, + email: "personal@example.com", + sourceLabel: "Safari"), + .init( + selectionID: "source-only", + name: nil, + email: nil, + sourceLabel: "Chrome · Profile 2"), + ]) + + #expect(Set(choices.map(\.displayLabel)) == [ + "Example Team · team@example.com · Comet · Work", + "personal@example.com · Safari", + "\(L("Account")) · Chrome · Profile 2", + ]) + } + + @Test + func `same email candidates from different sources remain separate choices`() { + let candidates: [CursorLoginAccountSelector.Candidate] = [ + .init( + selectionID: "account-comet", + name: nil, + email: "same@example.com", + sourceLabel: "Comet"), + .init( + selectionID: "account-safari", + name: nil, + email: "same@example.com", + sourceLabel: "Safari"), + ] + + let choices = CursorLoginAccountSelector.choices(for: candidates) + + #expect(choices.map(\.selectionID) == ["account-comet", "account-safari"]) + #expect(choices.map(\.displayLabel) == [ + "same@example.com · Comet", + "same@example.com · Safari", + ]) + } + + @Test + func `identical account labels use human ordinals while stable IDs remain mapping only`() { + let choices = CursorLoginAccountSelector.choices(for: [ + .init( + selectionID: "stable-b", + name: nil, + email: "same@example.com", + sourceLabel: "Comet"), + .init( + selectionID: "stable-a", + name: nil, + email: "same@example.com", + sourceLabel: "Comet"), + ]) + + #expect(choices == [ + .init(selectionID: "stable-a", displayLabel: "same@example.com · Comet · 1"), + .init(selectionID: "stable-b", displayLabel: "same@example.com · Comet · 2"), + ]) + #expect(choices.allSatisfy { !$0.displayLabel.contains("stable-") }) + } + + @Test + func `choice ordering is deterministic regardless of candidate order`() { + let candidates: [CursorLoginAccountSelector.Candidate] = [ + .init(selectionID: "z", name: "Zed", email: nil, sourceLabel: "Safari"), + .init(selectionID: "a", name: "Alpha", email: nil, sourceLabel: "Comet"), + ] + + #expect(CursorLoginAccountSelector.choices(for: candidates) == + CursorLoginAccountSelector.choices(for: Array(candidates.reversed()))) + } + + @Test + func `non UI selection helper maps confirmation and cancellation`() { + let choices: [CursorLoginAccountSelector.Choice] = [ + .init(selectionID: "first", displayLabel: "First · Safari"), + .init(selectionID: "second", displayLabel: "Second · Comet"), + ] + + #expect(CursorLoginAccountSelector.selectedCandidateID( + from: choices, + selectedIndex: 1, + confirmed: true) == "second") + #expect(CursorLoginAccountSelector.selectedCandidateID( + from: choices, + selectedIndex: 1, + confirmed: false) == nil) + #expect(CursorLoginAccountSelector.selectedCandidateID( + from: choices, + selectedIndex: nil, + confirmed: true) == nil) + #expect(CursorLoginAccountSelector.selectedCandidateID( + from: choices, + selectedIndex: 2, + confirmed: true) == nil) + } + + @Test + @MainActor + func `injected chooser maps only a presented stable selection ID`() { + let candidates: [CursorLoginAccountSelector.Candidate] = [ + .init(selectionID: "first", name: nil, email: "a@example.com", sourceLabel: "Safari"), + .init(selectionID: "second", name: nil, email: "b@example.com", sourceLabel: "Comet"), + ] + var presentedChoices: [CursorLoginAccountSelector.Choice] = [] + + let selectedID = CursorLoginAccountSelector.selectCandidateID(from: candidates) { + presentedChoices = $0 + return "second" + } + let cancelledID = CursorLoginAccountSelector.selectCandidateID(from: candidates) { _ in nil } + let unknownID = CursorLoginAccountSelector.selectCandidateID(from: candidates) { _ in "unknown" } + + #expect(presentedChoices == CursorLoginAccountSelector.choices(for: candidates)) + #expect(selectedID == "second") + #expect(cancelledID == nil) + #expect(unknownID == nil) + } +} diff --git a/Tests/CodexBarTests/CursorLoginBrowserRoutingTests.swift b/Tests/CodexBarTests/CursorLoginBrowserRoutingTests.swift new file mode 100644 index 0000000000..66f9575eaf --- /dev/null +++ b/Tests/CodexBarTests/CursorLoginBrowserRoutingTests.swift @@ -0,0 +1,191 @@ +import Foundation +import Testing +@testable import CodexBar + +@MainActor +struct CursorLoginBrowserRoutingTests { + private static let authURL = URL(string: "https://authenticator.cursor.sh/")! + private static let cometApplicationURL = URL(fileURLWithPath: "/Applications/Comet.app") + private static let chromeApplicationURL = URL(fileURLWithPath: "/Applications/Google Chrome.app") + private static let handlerApplicationURL = URL(fileURLWithPath: "/Applications/Link Router.app") + + @Test + func `supported handler is pinned for launch and polling`() { + let loginURL = Self.authURL + var discoveryURLs: [URL] = [] + var chooserCalls = 0 + + let resolution = CursorLoginBrowserRouter.resolve( + loginURL: loginURL, + handlerApplicationURL: Self.cometApplicationURL, + applicationURLs: { + discoveryURLs.append($0) + return [Self.chromeApplicationURL] + }, + chooseApplication: { _ in + chooserCalls += 1 + return Self.chromeApplicationURL + }, + supportsBrowser: Self.supportsFixtureBrowser) + + #expect(resolution == .route(.init( + launchURL: loginURL, + browserApplicationURL: Self.cometApplicationURL))) + #expect(discoveryURLs.isEmpty) + #expect(chooserCalls == 0) + } + + @Test + func `known handler with unavailable cookie source falls back to browser chooser`() { + var chooserCandidates: [URL] = [] + let resolution = CursorLoginBrowserRouter.resolve( + loginURL: Self.authURL, + handlerApplicationURL: Self.cometApplicationURL, + applicationURLs: { _ in [Self.cometApplicationURL, Self.chromeApplicationURL] }, + chooseApplication: { candidates in + chooserCandidates = candidates + return Self.chromeApplicationURL + }, + supportsBrowser: { applicationURL in + applicationURL == Self.chromeApplicationURL + }) + + #expect(chooserCandidates == [Self.chromeApplicationURL]) + #expect(resolution == .route(.init( + launchURL: Self.authURL, + browserApplicationURL: Self.chromeApplicationURL))) + } + + @Test + func `unsupported handler asks for explicit selection of the sole supported application`() { + let loginURL = Self.authURL + var discoveryURLs: [URL] = [] + var chooserCalls = 0 + + let resolution = CursorLoginBrowserRouter.resolve( + loginURL: loginURL, + handlerApplicationURL: Self.handlerApplicationURL, + applicationURLs: { + discoveryURLs.append($0) + return [ + URL(fileURLWithPath: "/Applications/Unsupported.app"), + Self.cometApplicationURL, + ] + }, + chooseApplication: { candidates in + chooserCalls += 1 + #expect(candidates == [Self.cometApplicationURL]) + return Self.cometApplicationURL + }, + supportsBrowser: Self.supportsFixtureBrowser) + + #expect(resolution == .route(.init( + launchURL: loginURL, + browserApplicationURL: Self.cometApplicationURL))) + #expect(discoveryURLs == [loginURL]) + #expect(chooserCalls == 1) + } + + @Test + func `missing handler asks for explicit selection of a sole supported application`() { + var chooserCandidates: [URL] = [] + let resolution = CursorLoginBrowserRouter.resolve( + loginURL: Self.authURL, + handlerApplicationURL: nil, + applicationURLs: { _ in [Self.chromeApplicationURL] }, + chooseApplication: { + chooserCandidates = $0 + return Self.chromeApplicationURL + }, + supportsBrowser: Self.supportsFixtureBrowser) + + #expect(chooserCandidates == [Self.chromeApplicationURL]) + #expect(resolution == .route(.init( + launchURL: Self.authURL, + browserApplicationURL: Self.chromeApplicationURL))) + } + + @Test + func `multiple supported applications use the explicit selection`() { + var chooserCandidates: [URL] = [] + let resolution = CursorLoginBrowserRouter.resolve( + loginURL: Self.authURL, + handlerApplicationURL: Self.handlerApplicationURL, + applicationURLs: { _ in [ + Self.chromeApplicationURL, + Self.cometApplicationURL, + URL(fileURLWithPath: "/Applications/Unsupported.app"), + Self.cometApplicationURL, + ] }, + chooseApplication: { + chooserCandidates = $0 + return Self.chromeApplicationURL + }, + supportsBrowser: Self.supportsFixtureBrowser) + + #expect(chooserCandidates == [Self.cometApplicationURL, Self.chromeApplicationURL]) + #expect(resolution == .route(.init( + launchURL: Self.authURL, + browserApplicationURL: Self.chromeApplicationURL))) + } + + @Test + func `cancelling the explicit chooser with one candidate is distinct from unavailable`() { + let resolution = CursorLoginBrowserRouter.resolve( + loginURL: Self.authURL, + handlerApplicationURL: Self.handlerApplicationURL, + applicationURLs: { _ in [Self.cometApplicationURL] }, + chooseApplication: { _ in nil }, + supportsBrowser: Self.supportsFixtureBrowser) + + #expect(resolution == .cancelled) + } + + @Test + func `no supported application is unavailable without showing a chooser`() { + var chooserCalls = 0 + let resolution = CursorLoginBrowserRouter.resolve( + loginURL: Self.authURL, + handlerApplicationURL: Self.handlerApplicationURL, + applicationURLs: { _ in [URL(fileURLWithPath: "/Applications/Unsupported.app")] }, + chooseApplication: { _ in + chooserCalls += 1 + return Self.cometApplicationURL + }, + supportsBrowser: Self.supportsFixtureBrowser) + + #expect(resolution == .unavailable) + #expect(chooserCalls == 0) + } + + @Test + func `chooser cannot return an application outside the supported candidates`() { + let resolution = CursorLoginBrowserRouter.resolve( + loginURL: Self.authURL, + handlerApplicationURL: Self.handlerApplicationURL, + applicationURLs: { _ in [Self.cometApplicationURL, Self.chromeApplicationURL] }, + chooseApplication: { _ in URL(fileURLWithPath: "/Applications/Safari.app") }, + supportsBrowser: Self.supportsFixtureBrowser) + + #expect(resolution == .unavailable) + } + + @Test + func `candidate labels are stable and disambiguate duplicate application names`() { + let applications = [ + URL(fileURLWithPath: "/Applications/Comet.app"), + URL(fileURLWithPath: "/Volumes/Tools/Comet.app"), + Self.chromeApplicationURL, + ] + + #expect(CursorLoginBrowserRouter.applicationLabels(applications) == [ + "Comet (/Applications)", + "Comet (/Volumes/Tools)", + "Google Chrome", + ]) + } + + private static func supportsFixtureBrowser(_ applicationURL: URL?) -> Bool { + applicationURL == self.cometApplicationURL || applicationURL == self.chromeApplicationURL + } +} diff --git a/Tests/CodexBarTests/CursorLoginRunnerTests.swift b/Tests/CodexBarTests/CursorLoginRunnerTests.swift index 16632ebe0b..04fc69f66d 100644 --- a/Tests/CodexBarTests/CursorLoginRunnerTests.swift +++ b/Tests/CodexBarTests/CursorLoginRunnerTests.swift @@ -1,10 +1,12 @@ -import CodexBarCore import Foundation import Testing @testable import CodexBar +@testable import CodexBarCore @MainActor struct CursorLoginRunnerTests { + private static let cometApplicationURL = URL(fileURLWithPath: "/Applications/Comet.app") + private final class LockedArray: @unchecked Sendable { private let lock = NSLock() private var values: [Element] = [] @@ -22,36 +24,59 @@ struct CursorLoginRunnerTests { } } + private final class SnapshotSequence: @unchecked Sendable { + private let lock = NSLock() + private let snapshots: [CursorStatusSnapshot] + private var index = 0 + + init(_ snapshots: [CursorStatusSnapshot]) { + self.snapshots = snapshots + } + + func next() -> CursorStatusSnapshot { + self.lock.lock() + defer { self.lock.unlock() } + let snapshot = self.snapshots[min(self.index, self.snapshots.count - 1)] + self.index += 1 + return snapshot + } + + func count() -> Int { + self.lock.lock() + defer { self.lock.unlock() } + return self.index + } + } + @Test - func `login opens Cursor auth URL in browser before polling cookies`() async { - var openedURLs: [URL] = [] + func `add account opens Cursor auth URL in browser before polling cookies`() async { + var launchedRoutes: [CursorLoginBrowserRouter.Route] = [] + var resolvedURLs: [URL] = [] var phases: [String] = [] + var chooserCalls = 0 let runner = CursorLoginRunner( browserDetection: BrowserDetection(cacheTTL: 0), timeout: 1, pollInterval: 0.01, - openURL: { url in - openedURLs.append(url) + launchRoute: { route in + launchedRoutes.append(route) return true }, - loadSnapshot: { - CursorStatusSnapshot( - planPercentUsed: 12, - planUsedUSD: 1, - planLimitUSD: 20, - onDemandUsedUSD: 0, - onDemandLimitUSD: nil, - teamOnDemandUsedUSD: nil, - teamOnDemandLimitUSD: nil, - billingCycleEnd: nil, - membershipType: "pro", - accountEmail: "cursor@example.com", - accountName: nil, - rawJSON: nil) - }, - sleeper: { _ in }, - resetSessionCache: {}) + loadSnapshot: { Self.snapshot(email: "cursor@example.com") }, + sleeper: { _ in }, + browserApplicationResolver: { + resolvedURLs.append($0) + return Self.cometApplicationURL + }, + routeResolver: Self.fixtureRouteResolver, + accountChooser: { _ in + chooserCalls += 1 + return nil + }, + replaceSessionCache: { _ in true }) + + #expect(resolvedURLs.isEmpty) let result = await runner.run { phase in switch phase { @@ -62,47 +87,506 @@ struct CursorLoginRunnerTests { } } - #expect(openedURLs == [CursorLoginRunner.authURL]) + #expect(launchedRoutes.map(\.launchURL) == [CursorLoginRunner.authURL]) + #expect(launchedRoutes.map(\.browserApplicationURL) == [Self.cometApplicationURL]) + #expect(resolvedURLs == [CursorLoginRunner.authURL]) #expect(phases == ["loading", "waitingLogin", "success"]) + #expect(chooserCalls == 0) + #expect(result.email == "cursor@example.com") + } + + @Test + func `cancellation during browser selection does not launch the browser`() async { + let launchedRoutes = LockedArray() + let runner = CursorLoginRunner( + browserDetection: BrowserDetection(cacheTTL: 0), + timeout: 1, + pollInterval: 0.01, + launchRoute: { route in + launchedRoutes.append(route) + return true + }, + loadSnapshot: { + Issue.record("Cancelled login should not poll for an account") + return Self.snapshot(email: "cursor@example.com") + }, + sleeper: { _ in }, + browserApplicationResolver: { _ in Self.cometApplicationURL }, + routeResolver: { loginURL, browserApplicationURL in + withUnsafeCurrentTask { $0?.cancel() } + return .route(CursorLoginBrowserRouter.Route( + launchURL: loginURL, + browserApplicationURL: browserApplicationURL ?? Self.cometApplicationURL)) + }, + replaceSessionCache: { _ in true }) + + let result = await Task { await runner.run { _ in } }.value + + guard case .cancelled = result.outcome else { + Issue.record("Expected cancellation before browser launch") + return + } + #expect(launchedRoutes.snapshot().isEmpty) + } + + @Test + func `interactive login allows an explicit cookie retry in user initiated context`() async { + var observedInteraction: ProviderInteraction? + var retryAllowed = false + let runner = CursorLoginRunner( + browserDetection: BrowserDetection(cacheTTL: 0), + timeout: 1, + pollInterval: 0.01, + launchRoute: { _ in true }, + loadSnapshot: { Self.snapshot(email: "cursor@example.com") }, + sleeper: { _ in }, + browserApplicationResolver: { _ in Self.cometApplicationURL }, + routeResolver: { loginURL, browserApplicationURL in + observedInteraction = ProviderInteractionContext.current + retryAllowed = BrowserCookieAccessGate.shouldAttempt(.chrome) + return .route(CursorLoginBrowserRouter.Route( + launchURL: loginURL, + browserApplicationURL: browserApplicationURL ?? Self.cometApplicationURL)) + }, + replaceSessionCache: { _ in true }) + + let result = await KeychainAccessGate.withTaskOverrideForTesting(false) { + await BrowserCookieAccessGate.withDeniedBrowsersForTesting([.chrome]) { + await runner.run { _ in } + } + } + + #expect(observedInteraction == .userInitiated) + #expect(retryAllowed) + guard case .success = result.outcome else { + Issue.record("Expected a successful login") + return + } + } + + @Test + func `manual cookie identity allows the same browser account after confirmation`() async { + let identity = ProviderIdentitySnapshot( + providerID: .cursor, + accountEmail: "same@example.com", + accountOrganization: nil, + loginMethod: "Pro", + accountID: "same-account") + let manualPolicy = CursorLoginRunner.accountPolicy( + configuredSource: .manual, + identity: identity, + hasPriorSnapshot: true) + let automaticPolicy = CursorLoginRunner.accountPolicy( + configuredSource: .auto, + identity: identity, + hasPriorSnapshot: true) + let unknownAutomaticPolicy = CursorLoginRunner.accountPolicy( + configuredSource: .auto, + identity: nil, + hasPriorSnapshot: true) + let absentAutomaticPolicy = CursorLoginRunner.accountPolicy( + configuredSource: .auto, + identity: nil, + hasPriorSnapshot: false) + var presentedChoices: [CursorLoginAccountSelector.Choice] = [] + let runner = CursorLoginRunner( + browserDetection: BrowserDetection(cacheTTL: 0), + priorAccount: manualPolicy.priorAccount, + requiresAccountConfirmation: manualPolicy.requiresConfirmation, + timeout: 1, + pollInterval: 0.01, + launchRoute: { _ in true }, + loadSnapshot: { Self.snapshot(id: "same-account", email: "same@example.com") }, + sleeper: { _ in }, + browserApplicationResolver: { _ in Self.cometApplicationURL }, + routeResolver: Self.fixtureRouteResolver, + accountChooser: { choices in + presentedChoices = choices + return choices.first?.selectionID + }, + replaceSessionCache: { _ in true }) + + let result = await runner.run { _ in } + + #expect(manualPolicy.priorAccount == nil) + #expect(manualPolicy.requiresConfirmation) + #expect(automaticPolicy.priorAccount == .init(accountID: "same-account", email: "same@example.com")) + #expect(automaticPolicy.requiresConfirmation) + #expect(unknownAutomaticPolicy.priorAccount == .init(accountID: nil, email: nil)) + #expect(unknownAutomaticPolicy.requiresConfirmation) + #expect(absentAutomaticPolicy.priorAccount == nil) + #expect(!absentAutomaticPolicy.requiresConfirmation) + #expect(presentedChoices.map(\.displayLabel) == ["same@example.com · Browser"]) + guard case .success = result.outcome else { + Issue.record("Expected the same browser account to replace Manual mode") + return + } + } + + @Test + func `add account ignores identity-less snapshots`() async { + let sequence = SnapshotSequence([ + Self.snapshot(email: nil), + Self.snapshot(email: "cursor@example.com"), + ]) + let runner = Self.runner(loadSnapshot: { sequence.next() }) + + let result = await runner.run { _ in } + + #expect(sequence.count() == 2) #expect(result.email == "cursor@example.com") } @Test - func `login clears stale session state before opening auth URL`() async { + func `switch account opens Cursor auth URL and waits for a different normalized email`() async { + var launchedRoutes: [CursorLoginBrowserRouter.Route] = [] + var resolvedURLs: [URL] = [] + var presentedChoices: [CursorLoginAccountSelector.Choice] = [] + let sequence = SnapshotSequence([ + Self.snapshot(email: " CURRENT@example.com "), + Self.snapshot(email: nil), + Self.snapshot(email: "different@example.com"), + ]) + let runner = Self.runner( + priorAccount: .init(email: "current@example.com"), + launchRoute: { + launchedRoutes.append($0) + return true + }, + browserApplicationResolver: { + resolvedURLs.append($0) + return Self.cometApplicationURL + }, + accountChooser: { choices in + presentedChoices = choices + return choices.first?.selectionID + }, + loadSnapshot: { sequence.next() }) + + let result = await runner.run { _ in } + + #expect(launchedRoutes.map(\.launchURL) == [CursorLoginRunner.authURL]) + #expect(launchedRoutes.map(\.browserApplicationURL) == [Self.cometApplicationURL]) + #expect(resolvedURLs == [CursorLoginRunner.authURL]) + #expect(sequence.count() == 3) + #expect(presentedChoices.map(\.displayLabel) == ["different@example.com · Browser"]) + #expect(result.email == "different@example.com") + } +} + +extension CursorLoginRunnerTests { + @Test + func `switch account accepts the same email when stable account ID changes`() async { + let committedHeaders = LockedArray() + var presentedChoices: [CursorLoginAccountSelector.Choice] = [] + let runner = CursorLoginRunner( + browserDetection: BrowserDetection(cacheTTL: 0), + priorAccount: .init(accountID: " account-a ", email: " SAME@example.com "), + timeout: 1, + pollInterval: 0.001, + launchRoute: { _ in true }, + loadBrowserLoginCandidates: { _, _ in [ + Self.browserCandidate( + id: "account-a", + email: "same@example.com", + cookieValue: "fixture-current", + source: "Work"), + Self.browserCandidate( + id: "account-b", + email: "same@example.com", + cookieValue: "fixture-different", + source: "Personal"), + ] }, + sleeper: { _ in }, + browserApplicationResolver: { _ in Self.cometApplicationURL }, + routeResolver: Self.fixtureRouteResolver, + accountChooser: { choices in + presentedChoices = choices + return choices.first?.selectionID + }, + replaceSessionCache: { session in + committedHeaders.append(session.cookieHeader) + return true + }) + + let result = await runner.run { _ in } + + guard case .success = result.outcome else { + Issue.record("Expected a successful switch") + return + } + #expect(presentedChoices.map(\.displayLabel) == ["same@example.com · Personal"]) + #expect(committedHeaders.snapshot() == [Self.cursorCookieHeader("fixture-different")]) + } + + @Test + func `switch account falls back to normalized email when stable IDs are absent`() async { + let sequence = SnapshotSequence([ + Self.snapshot(id: nil, email: " CURRENT@example.com "), + Self.snapshot(id: nil, email: "different@example.com"), + ]) + let runner = Self.runner( + priorAccount: .init(accountID: nil, email: "current@example.com"), + accountChooser: { choices in choices.first?.selectionID }, + loadSnapshot: { sequence.next() }) + + let result = await runner.run { _ in } + + #expect(sequence.count() == 2) + #expect(result.email == "different@example.com") + } + + @Test + func `switch account cancellation with a sole candidate commits no session`() async { + let committedHeaders = LockedArray() + var presentedChoices: [CursorLoginAccountSelector.Choice] = [] + let runner = CursorLoginRunner( + browserDetection: BrowserDetection(cacheTTL: 0), + priorAccount: .init(email: "current@example.com"), + timeout: 1, + pollInterval: 0.001, + launchRoute: { _ in true }, + loadBrowserLoginCandidates: { _, _ in [ + Self.browserCandidate( + id: "different-account", + email: "different@example.com", + cookieValue: "fixture-different", + source: "Comet"), + ] }, + sleeper: { _ in }, + browserApplicationResolver: { _ in Self.cometApplicationURL }, + routeResolver: Self.fixtureRouteResolver, + accountChooser: { choices in + presentedChoices = choices + return nil + }, + replaceSessionCache: { session in + committedHeaders.append(session.cookieHeader) + return true + }) + + let result = await runner.run { _ in } + + guard case .cancelled = result.outcome else { + Issue.record("Expected account selection cancellation") + return + } + #expect(presentedChoices.map(\.displayLabel) == ["different@example.com · Comet"]) + #expect(committedHeaders.snapshot().isEmpty) + } + + @Test + func `switch with unknown prior identity still requires candidate confirmation`() async { + let committedHeaders = LockedArray() + var presentedChoices: [CursorLoginAccountSelector.Choice] = [] + let runner = CursorLoginRunner( + browserDetection: BrowserDetection(cacheTTL: 0), + priorAccount: .init(email: nil), + timeout: 1, + pollInterval: 0.001, + launchRoute: { _ in true }, + loadBrowserLoginCandidates: { _, _ in [ + Self.browserCandidate( + id: "candidate-account", + email: "candidate@example.com", + cookieValue: "fixture-candidate", + source: "Comet"), + ] }, + sleeper: { _ in }, + browserApplicationResolver: { _ in Self.cometApplicationURL }, + routeResolver: Self.fixtureRouteResolver, + accountChooser: { choices in + presentedChoices = choices + return nil + }, + replaceSessionCache: { session in + committedHeaders.append(session.cookieHeader) + return true + }) + + let result = await runner.run { _ in } + + guard case .cancelled = result.outcome else { + Issue.record("Expected explicit candidate confirmation to remain cancellable") + return + } + #expect(presentedChoices.map(\.displayLabel) == ["candidate@example.com · Comet"]) + #expect(committedHeaders.snapshot().isEmpty) + } + + @Test + func `switch account accepts a different stable ID with the same email`() async { + let sequence = SnapshotSequence([ + Self.snapshot(id: "current-id", email: "same@example.com"), + Self.snapshot(id: "next-id", email: "same@example.com"), + ]) + let runner = Self.runner( + priorAccount: .init(accountID: "current-id", email: "same@example.com"), + accountChooser: { $0.first?.selectionID }, + loadSnapshot: { sequence.next() }) + + let result = await runner.run { _ in } + + guard case .success = result.outcome else { + Issue.record("Expected stable account ID change to complete the switch") + return + } + #expect(sequence.count() == 2) + #expect(result.email == "same@example.com") + } + + @Test + func `switch account accepts an ID only target`() async { + let sequence = SnapshotSequence([ + Self.snapshot(id: "current-id", email: nil), + Self.snapshot(id: "next-id", email: nil), + ]) + let runner = Self.runner( + priorAccount: .init(accountID: "current-id", email: nil), + accountChooser: { $0.first?.selectionID }, + loadSnapshot: { sequence.next() }) + + let result = await runner.run { _ in } + + guard case .success = result.outcome else { + Issue.record("Expected ID-only account change to complete the switch") + return + } + #expect(sequence.count() == 2) + #expect(result.email == nil) + } + + @Test + func `Cursor usage identity preserves stable account ID`() { + let usage = Self.snapshot(id: "stable-id", email: "cursor@example.com").toUsageSnapshot() + + #expect(usage.identity(for: .cursor)?.accountID == "stable-id") + } + + @Test + func `late cancellation still finalizes a committed login`() { + let success = CursorLoginRunner.Result(outcome: .success, email: "cursor@example.com") + let cancelled = CursorLoginRunner.Result(outcome: .cancelled, email: nil) + + #expect(StatusItemController.shouldFinalizeCursorLoginResult(success, taskIsCancelled: true)) + #expect(!StatusItemController.shouldFinalizeCursorLoginResult(cancelled, taskIsCancelled: true)) + #expect(StatusItemController.shouldFinalizeCursorLoginResult(cancelled, taskIsCancelled: false)) + } + + @Test + func `switch timeout preserves existing session and explains that a different account is required`() async { + let replacementEvents = LockedArray() + let runner = CursorLoginRunner( + browserDetection: BrowserDetection(cacheTTL: 0), + priorAccount: .init(email: "current@example.com"), + timeout: 0, + pollInterval: 0.01, + launchRoute: { _ in true }, + loadSnapshot: { Self.snapshot(email: "current@example.com") }, + sleeper: { _ in }, + browserApplicationResolver: { _ in Self.cometApplicationURL }, + routeResolver: Self.fixtureRouteResolver, + replaceSessionCache: { _ in + replacementEvents.append("replace") + return true + }) + + let result = await runner.run { _ in } + + guard case let .failed(message) = result.outcome else { + Issue.record("Expected failed outcome") + return + } + #expect(message.contains("different Cursor account")) + #expect(replacementEvents.snapshot().isEmpty) + } + + @Test + func `accepted login replaces stale session after selecting candidate`() async { let events = LockedArray() let runner = CursorLoginRunner( browserDetection: BrowserDetection(cacheTTL: 0), timeout: 1, pollInterval: 0.01, - openURL: { _ in + launchRoute: { _ in events.append("open") return true }, - loadSnapshot: { + loadBrowserLoginCandidates: { _, _ in events.append("poll") - throw CursorStatusProbeError.noSessionCookie + return [Self.browserCandidate( + id: "accepted-account", + email: "cursor@example.com", + cookieValue: "fixture-accepted", + source: "Comet")] }, sleeper: { _ in }, - resetSessionCache: { - events.append("reset") + browserApplicationResolver: { _ in Self.cometApplicationURL }, + routeResolver: Self.fixtureRouteResolver, + replaceSessionCache: { _ in + events.append("replace") + return true }) _ = await runner.run { _ in } - #expect(Array(events.snapshot().prefix(2)) == ["reset", "open"]) + #expect(events.snapshot() == ["open", "poll", "replace"]) + } + + @Test + func `accepted login reports failure when the replacement is not durable`() async { + var phases: [String] = [] + let runner = CursorLoginRunner( + browserDetection: BrowserDetection(cacheTTL: 0), + timeout: 1, + pollInterval: 0.01, + launchRoute: { _ in true }, + loadBrowserLoginCandidates: { _, _ in [ + Self.browserCandidate( + id: "accepted-account", + email: "cursor@example.com", + cookieValue: "fixture-accepted", + source: "Comet"), + ] }, + sleeper: { _ in }, + browserApplicationResolver: { _ in Self.cometApplicationURL }, + routeResolver: Self.fixtureRouteResolver, + replaceSessionCache: { _ in false }) + + let result = await runner.run { phase in + switch phase { + case .loading: phases.append("loading") + case .waitingLogin: phases.append("waitingLogin") + case .success: phases.append("success") + case .failed: phases.append("failed") + } + } + + guard case .failed = result.outcome else { + Issue.record("Expected failed outcome") + return + } + #expect(result.email == nil) + #expect(phases == ["loading", "waitingLogin", "failed"]) } @Test - func `login reports launch failure when browser cannot open`() async { + func `login launch failure preserves existing session`() async { + let replacementEvents = LockedArray() let runner = CursorLoginRunner( browserDetection: BrowserDetection(cacheTTL: 0), - openURL: { _ in false }, + launchRoute: { _ in false }, loadSnapshot: { Issue.record("Should not poll cookies when browser launch fails") throw CursorStatusProbeError.noSessionCookie }, sleeper: { _ in }, - resetSessionCache: {}) + browserApplicationResolver: { _ in Self.cometApplicationURL }, + routeResolver: Self.fixtureRouteResolver, + replaceSessionCache: { _ in + replacementEvents.append("replace") + return true + }) let result = await runner.run { _ in } @@ -111,5 +595,433 @@ struct CursorLoginRunnerTests { return } #expect(message.contains("Could not open Cursor login")) + #expect(replacementEvents.snapshot().isEmpty) + } + + @Test + func `login cancellation while waiting preserves existing session`() async { + let events = LockedArray() + let runner = CursorLoginRunner( + browserDetection: BrowserDetection(cacheTTL: 0), + timeout: 10, + pollInterval: 0.01, + launchRoute: { _ in + events.append("open") + return true + }, + loadBrowserLoginCandidates: { _, _ in + events.append("poll") + return [] + }, + sleeper: { _ in + events.append("sleep") + try await Task.sleep(nanoseconds: .max) + }, + browserApplicationResolver: { _ in Self.cometApplicationURL }, + routeResolver: Self.fixtureRouteResolver, + replaceSessionCache: { _ in + events.append("replace") + return true + }) + + let task = Task { + await runner.run { _ in } + } + while !events.snapshot().contains("sleep") { + await Task.yield() + } + task.cancel() + let result = await task.value + + guard case .cancelled = result.outcome else { + Issue.record("Expected cancelled outcome") + return + } + #expect(!events.snapshot().contains("replace")) + } + + @Test + func `unsupported default browser fails before opening or polling`() async { + var launchedRoutes: [CursorLoginBrowserRouter.Route] = [] + let pollEvents = LockedArray() + let replacementEvents = LockedArray() + let runner = CursorLoginRunner( + browserDetection: BrowserDetection(cacheTTL: 0), + launchRoute: { + launchedRoutes.append($0) + return true + }, + loadSnapshot: { + pollEvents.append("poll") + return Self.snapshot(email: "wrong@example.com") + }, + sleeper: { _ in }, + browserApplicationResolver: { _ in + URL(fileURLWithPath: "/Applications/Unsupported Browser.app") + }, + routeResolver: { _, _ in .unavailable }, + replaceSessionCache: { _ in + replacementEvents.append("replace") + return true + }) + + let result = await runner.run { _ in } + + guard case let .failed(message) = result.outcome else { + Issue.record("Expected unsupported-browser failure") + return + } + #expect(message.contains("Unsupported Browser")) + #expect(message.contains("Cookie header")) + #expect(launchedRoutes.isEmpty) + #expect(pollEvents.snapshot().isEmpty) + #expect(replacementEvents.snapshot().isEmpty) + } + + @Test + func `unresolved default browser fails before opening or polling`() async { + var launchedRoutes: [CursorLoginBrowserRouter.Route] = [] + let pollEvents = LockedArray() + let replacementEvents = LockedArray() + let runner = CursorLoginRunner( + browserDetection: BrowserDetection(cacheTTL: 0), + launchRoute: { + launchedRoutes.append($0) + return true + }, + loadSnapshot: { + pollEvents.append("poll") + return Self.snapshot(email: "wrong@example.com") + }, + sleeper: { _ in }, + browserApplicationResolver: { _ in nil }, + routeResolver: { _, _ in .unavailable }, + replaceSessionCache: { _ in + replacementEvents.append("replace") + return true + }) + + let result = await runner.run { _ in } + + guard case let .failed(message) = result.outcome else { + Issue.record("Expected unresolved-browser failure") + return + } + #expect(message.contains("Browser cookies")) + #expect(message.contains("Cookie header")) + #expect(launchedRoutes.isEmpty) + #expect(pollEvents.snapshot().isEmpty) + #expect(replacementEvents.snapshot().isEmpty) + } + + @Test + func `browser chooser cancellation happens before replacement launch and polling`() async { + let events = LockedArray() + let runner = CursorLoginRunner( + browserDetection: BrowserDetection(cacheTTL: 0), + launchRoute: { _ in + events.append("launch") + return true + }, + loadSnapshot: { + events.append("poll") + return Self.snapshot(email: "unexpected@example.com") + }, + browserApplicationResolver: { _ in + URL(fileURLWithPath: "/Applications/Link Router.app") + }, + routeResolver: { _, _ in .cancelled }, + replaceSessionCache: { _ in + events.append("replace") + return true + }) + + let result = await runner.run { _ in } + + guard case .cancelled = result.outcome else { + Issue.record("Expected browser selection cancellation") + return + } + #expect(events.snapshot().isEmpty) + } + + @Test + func `production candidate loader receives the exact pinned browser URL`() async { + let loadedBrowserURLs = LockedArray() + let candidateTimeouts = LockedArray() + var launchedRoutes: [CursorLoginBrowserRouter.Route] = [] + let runner = CursorLoginRunner( + browserDetection: BrowserDetection(cacheTTL: 0), + timeout: 1, + pollInterval: 0.001, + launchRoute: { + launchedRoutes.append($0) + return true + }, + loadBrowserLoginCandidates: { browserApplicationURL, timeout in + loadedBrowserURLs.append(browserApplicationURL) + candidateTimeouts.append(timeout) + return [Self.browserCandidate( + id: "account", + email: "cursor@example.com", + cookieValue: "fixture-single", + source: "Comet")] + }, + sleeper: { _ in }, + browserApplicationResolver: { _ in + URL(fileURLWithPath: "/Applications/Link Router.app") + }, + routeResolver: { _, _ in + .route(.init( + launchURL: URL(string: "https://example.invalid/intermediary")!, + browserApplicationURL: Self.cometApplicationURL)) + }, + replaceSessionCache: { _ in true }) + + _ = await runner.run { _ in } + + #expect(launchedRoutes.map(\.launchURL) == [CursorLoginRunner.authURL]) + #expect(launchedRoutes.map(\.browserApplicationURL) == [Self.cometApplicationURL]) + #expect(loadedBrowserURLs.snapshot() == [Self.cometApplicationURL]) + let passedTimeout = candidateTimeouts.snapshot().first + #expect(passedTimeout.map { $0 > 0 && $0 <= 1 } == true) + } + + @Test + func `account chooser cancel and forged result commit no session`() async { + for chosenID in [String?.none, "forged-selection"] { + let committedHeaders = LockedArray() + var presentedChoices: [CursorLoginAccountSelector.Choice] = [] + let runner = CursorLoginRunner( + browserDetection: BrowserDetection(cacheTTL: 0), + timeout: 1, + pollInterval: 0.001, + launchRoute: { _ in true }, + loadBrowserLoginCandidates: { _, _ in [ + Self.browserCandidate( + id: "account-a", + email: "a@example.com", + cookieValue: "fixture-a", + source: "Work"), + Self.browserCandidate( + id: "account-b", + email: "b@example.com", + cookieValue: "fixture-b", + source: "Personal"), + ] }, + sleeper: { _ in }, + browserApplicationResolver: { _ in Self.cometApplicationURL }, + routeResolver: Self.fixtureRouteResolver, + accountChooser: { choices in + presentedChoices = choices + return chosenID + }, + replaceSessionCache: { session in + committedHeaders.append(session.cookieHeader) + return true + }) + + let result = await runner.run { _ in } + + guard case .cancelled = result.outcome else { + Issue.record("Expected account selection cancellation") + continue + } + #expect(presentedChoices.count == 2) + #expect(Set(presentedChoices.map(\.selectionID)) == [ + "cursor-candidate-0", + "cursor-candidate-1", + ]) + #expect(committedHeaders.snapshot().isEmpty) + } + } + + @Test + func `account candidates dedupe by stable ID and preserve distinct IDs with the same email`() async { + var presentedChoices: [CursorLoginAccountSelector.Choice] = [] + let committedHeaders = LockedArray() + let runner = CursorLoginRunner( + browserDetection: BrowserDetection(cacheTTL: 0), + timeout: 1, + pollInterval: 0.001, + launchRoute: { _ in true }, + loadBrowserLoginCandidates: { _, _ in [ + Self.browserCandidate( + id: " account-a ", + email: "same@example.com", + cookieValue: "fixture-first-a", + source: "Work"), + Self.browserCandidate( + id: "account-a", + email: "other@example.com", + cookieValue: "fixture-duplicate-a", + source: "Work Network"), + Self.browserCandidate( + id: "account-b", + email: "same@example.com", + cookieValue: "fixture-b", + source: "Personal"), + ] }, + sleeper: { _ in }, + browserApplicationResolver: { _ in Self.cometApplicationURL }, + routeResolver: Self.fixtureRouteResolver, + accountChooser: { choices in + presentedChoices = choices + return choices.first(where: { $0.displayLabel.contains("Personal") })?.selectionID + }, + replaceSessionCache: { session in + committedHeaders.append(session.cookieHeader) + return true + }) + + _ = await runner.run { _ in } + + #expect(presentedChoices.count == 2) + #expect(committedHeaders.snapshot() == [Self.cursorCookieHeader("fixture-b")]) + } + + @Test + func `account candidates use normalized email only when stable ID is absent`() async { + var chooserCalls = 0 + let committedHeaders = LockedArray() + let runner = CursorLoginRunner( + browserDetection: BrowserDetection(cacheTTL: 0), + timeout: 1, + pollInterval: 0.001, + launchRoute: { _ in true }, + loadBrowserLoginCandidates: { _, _ in [ + Self.browserCandidate( + id: nil, + email: " SAME@example.com ", + cookieValue: "fixture-first", + source: "Work"), + Self.browserCandidate( + id: nil, + email: "same@example.com", + cookieValue: "fixture-second", + source: "Personal"), + ] }, + sleeper: { _ in }, + browserApplicationResolver: { _ in Self.cometApplicationURL }, + routeResolver: Self.fixtureRouteResolver, + accountChooser: { _ in + chooserCalls += 1 + return nil + }, + replaceSessionCache: { session in + committedHeaders.append(session.cookieHeader) + return true + }) + + _ = await runner.run { _ in } + + #expect(chooserCalls == 0) + #expect(committedHeaders.snapshot() == [Self.cursorCookieHeader("fixture-first")]) + } + + @Test + func `identified candidate replaces an earlier email only candidate`() async { + var chooserCalls = 0 + let committedHeaders = LockedArray() + let runner = CursorLoginRunner( + browserDetection: BrowserDetection(cacheTTL: 0), + timeout: 1, + pollInterval: 0.001, + launchRoute: { _ in true }, + loadBrowserLoginCandidates: { _, _ in [ + Self.browserCandidate( + id: nil, + email: "same@example.com", + cookieValue: "fixture-email-only", + source: "Work"), + Self.browserCandidate( + id: "stable-account", + email: " SAME@example.com ", + cookieValue: "fixture-identified", + source: "Personal"), + ] }, + sleeper: { _ in }, + browserApplicationResolver: { _ in Self.cometApplicationURL }, + routeResolver: Self.fixtureRouteResolver, + accountChooser: { _ in + chooserCalls += 1 + return nil + }, + replaceSessionCache: { session in + committedHeaders.append(session.cookieHeader) + return true + }) + + _ = await runner.run { _ in } + + #expect(chooserCalls == 0) + #expect(committedHeaders.snapshot() == [Self.cursorCookieHeader("fixture-identified")]) + } + + private static func runner( + priorAccount: CursorLoginRunner.AccountIdentity? = nil, + launchRoute: @escaping CursorLoginRunner.RouteLauncher = { _ in true }, + browserApplicationResolver: @escaping CursorLoginRunner.BrowserApplicationResolver = { _ in + Self.cometApplicationURL + }, + accountChooser: CursorLoginRunner.AccountChooser? = nil, + loadSnapshot: @escaping CursorLoginRunner.SnapshotLoader) -> CursorLoginRunner + { + CursorLoginRunner( + browserDetection: BrowserDetection(cacheTTL: 0), + priorAccount: priorAccount, + timeout: 1, + pollInterval: 0.001, + launchRoute: launchRoute, + loadSnapshot: loadSnapshot, + sleeper: { _ in }, + browserApplicationResolver: browserApplicationResolver, + routeResolver: self.fixtureRouteResolver, + accountChooser: accountChooser, + replaceSessionCache: { _ in true }) + } + + private static func fixtureRouteResolver( + loginURL: URL, + handlerApplicationURL: URL?) -> CursorLoginBrowserRouter.Resolution + { + guard let handlerApplicationURL else { return .unavailable } + return .route(.init( + launchURL: loginURL, + browserApplicationURL: handlerApplicationURL)) + } + + private nonisolated static func snapshot(id: String? = nil, email: String?) -> CursorStatusSnapshot { + CursorStatusSnapshot( + planPercentUsed: 12, + planUsedUSD: 1, + planLimitUSD: 20, + onDemandUsedUSD: 0, + onDemandLimitUSD: nil, + teamOnDemandUsedUSD: nil, + teamOnDemandLimitUSD: nil, + billingCycleEnd: nil, + membershipType: "pro", + accountEmail: email, + accountID: id, + accountName: nil, + rawJSON: nil) + } + + private nonisolated static func browserCandidate( + id: String?, + email: String?, + cookieValue: String, + source: String) -> CursorStatusProbe.BrowserLoginResult + { + CursorStatusProbe.BrowserLoginResult( + snapshot: self.snapshot(id: id, email: email), + session: .init( + cookieHeader: self.cursorCookieHeader(cookieValue), + sourceLabel: source)) + } + + private nonisolated static func cursorCookieHeader(_ value: String) -> String { + ["WorkosCursorSessionToken", value].joined(separator: "=") } } diff --git a/Tests/CodexBarTests/CursorMenuCardModelTests.swift b/Tests/CodexBarTests/CursorMenuCardModelTests.swift new file mode 100644 index 0000000000..bb0c4b8293 --- /dev/null +++ b/Tests/CodexBarTests/CursorMenuCardModelTests.swift @@ -0,0 +1,191 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +struct CursorMenuCardModelTests { + @Test + func `team pool shows personal spend and changes height fingerprint`() throws { + let now = Date(timeIntervalSince1970: 0) + let metadata = try #require(ProviderDefaults.metadata[.cursor]) + + func makeModel(personalUsed: Double?) -> UsageMenuCardView.Model { + let snapshot = UsageSnapshot( + primary: nil, + secondary: nil, + tertiary: nil, + providerCost: ProviderCostSnapshot( + used: 13111.25, + limit: 20000, + currencyCode: "USD", + period: "Monthly", + personalUsed: personalUsed, + updatedAt: now), + updatedAt: now, + identity: nil) + return UsageMenuCardView.Model.make(.init( + provider: .cursor, + metadata: metadata, + snapshot: snapshot, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + } + + let personal = makeModel(personalUsed: 44.71) + let absent = makeModel(personalUsed: nil) + let zero = makeModel(personalUsed: 0) + + #expect(personal.providerCost?.personalSpendLine == "Your spend: $44.71") + #expect(absent.providerCost?.personalSpendLine == nil) + #expect(zero.providerCost?.personalSpendLine == nil) + #expect(personal.heightFingerprint(section: "card") != absent.heightFingerprint(section: "card")) + #expect(!personal.hasCompatibleTrackedLayout(with: absent)) + #expect(!absent.hasCompatibleTrackedLayout(with: personal)) + } + + @Test + func `cursor billing cycle metrics show deficit and run out details`() throws { + let now = Date(timeIntervalSince1970: 0) + let reset = now.addingTimeInterval(6 * 24 * 3600) + let cycleMinutes = 30 * 24 * 60 + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 90, windowMinutes: cycleMinutes, resetsAt: reset, resetDescription: nil), + secondary: RateWindow(usedPercent: 90, windowMinutes: cycleMinutes, resetsAt: reset, resetDescription: nil), + tertiary: RateWindow(usedPercent: 90, windowMinutes: cycleMinutes, resetsAt: reset, resetDescription: nil), + updatedAt: now, + identity: nil) + let metadata = try #require(ProviderDefaults.metadata[.cursor]) + + let model = UsageMenuCardView.Model.make(.init( + provider: .cursor, + metadata: metadata, + snapshot: snapshot, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + + #expect(model.metrics.map(\.title) == ["Total", "Auto", "API"]) + for metric in model.metrics { + #expect(metric.percentLabel == "10% left") + #expect(metric.detailLeftText == "10% in deficit") + #expect(metric.detailRightText == "Runs out in 2d 16h") + #expect(metric.pacePercent == 20) + #expect(metric.paceOnTop == false) + } + } + + @Test + func `cursor billing cycle metrics hide pace when quota is depleted`() throws { + let now = Date(timeIntervalSince1970: 0) + let reset = now.addingTimeInterval(6 * 24 * 3600) + let cycleMinutes = 30 * 24 * 60 + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 100, windowMinutes: cycleMinutes, resetsAt: reset, resetDescription: nil), + secondary: RateWindow( + usedPercent: 100, + windowMinutes: cycleMinutes, + resetsAt: reset, + resetDescription: nil), + tertiary: RateWindow(usedPercent: 100, windowMinutes: cycleMinutes, resetsAt: reset, resetDescription: nil), + updatedAt: now, + identity: nil) + let metadata = try #require(ProviderDefaults.metadata[.cursor]) + + let model = UsageMenuCardView.Model.make(.init( + provider: .cursor, + metadata: metadata, + snapshot: snapshot, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + + #expect(model.metrics.map(\.title) == ["Total", "Auto", "API"]) + for metric in model.metrics { + #expect(metric.percentLabel == "0% left") + #expect(metric.detailLeftText == nil) + #expect(metric.detailRightText == nil) + #expect(metric.pacePercent == nil) + } + } + + @Test + func `legacy request plan shows single requests bar with count`() throws { + let now = Date(timeIntervalSince1970: 0) + let reset = now.addingTimeInterval(6 * 24 * 3600) + let cycleMinutes = 30 * 24 * 60 + // A legacy snapshot, as produced by CursorStatusSnapshot.toUsageSnapshot(): only the request + // window survives, Auto/API are dropped, and the request count rides along. + let snapshot = UsageSnapshot( + primary: RateWindow( + usedPercent: 69.4, + windowMinutes: cycleMinutes, + resetsAt: reset, + resetDescription: nil), + secondary: nil, + tertiary: nil, + cursorRequests: CursorRequestUsage(used: 347, limit: 500), + updatedAt: now, + identity: nil) + let metadata = try #require(ProviderDefaults.metadata[.cursor]) + + let model = UsageMenuCardView.Model.make(.init( + provider: .cursor, + metadata: metadata, + snapshot: snapshot, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + + #expect(model.metrics.map(\.title) == ["Requests"]) + #expect(model.metrics.first?.detailText == "Request quota: 347 / 500") + } +} diff --git a/Tests/CodexBarTests/CursorStatusProbeTests.swift b/Tests/CodexBarTests/CursorStatusProbeTests.swift index e34277f9fb..c501d1975f 100644 --- a/Tests/CodexBarTests/CursorStatusProbeTests.swift +++ b/Tests/CodexBarTests/CursorStatusProbeTests.swift @@ -1,4 +1,5 @@ import Foundation +import SQLite3 import Testing @testable import CodexBarCore @@ -150,6 +151,39 @@ struct CursorStatusProbeTests { #expect(snapshot.planPercentUsed == 0.40625) } + @Test + func `plan ratio caps at 100 percent when usage exceeds the limit`() { + // Usage-based plan reporting only used/limit (no precomputed percent lanes), with the plan + // cap exceeded (on-demand billing engaged). The headline percent must stay within [0, 100] + // like every other planPercentUsed branch — overage is surfaced separately via on-demand USD. + let snapshot = CursorStatusProbe(browserDetection: BrowserDetection(cacheTTL: 0)) + .parseUsageSummary( + CursorUsageSummary( + billingCycleStart: nil, + billingCycleEnd: nil, + membershipType: "pro", + limitType: nil, + isUnlimited: false, + autoModelSelectedDisplayMessage: nil, + namedModelSelectedDisplayMessage: nil, + individualUsage: CursorIndividualUsage( + plan: CursorPlanUsage( + enabled: true, + used: 15000, + limit: 10000, + remaining: nil, + breakdown: nil, + autoPercentUsed: nil, + apiPercentUsed: nil, + totalPercentUsed: nil), + onDemand: nil), + teamUsage: nil), + userInfo: nil, + rawJSON: nil) + + #expect(snapshot.planPercentUsed == 100) + } + @Test func `uses percent field when limit missing`() { let snapshot = CursorStatusProbe(browserDetection: BrowserDetection(cacheTTL: 0)) @@ -314,11 +348,16 @@ struct CursorStatusProbeTests { #expect(snapshot.planPercentUsed == 0.441025641025641) #expect(snapshot.autoPercentUsed == 0.36) #expect(snapshot.apiPercentUsed == 0.7111111111111111) - #expect(snapshot.toUsageSnapshot().primary?.remainingPercent == 99.55897435897436) + #expect(snapshot.billingCycleStart != nil) + let usageSnapshot = snapshot.toUsageSnapshot() + #expect(usageSnapshot.primary?.remainingPercent == 99.55897435897436) + #expect(usageSnapshot.primary?.windowMinutes == 44640) + #expect(usageSnapshot.secondary?.windowMinutes == 44640) + #expect(usageSnapshot.tertiary?.windowMinutes == 44640) } @Test - func `converts snapshot to usage snapshot`() { + func `converts snapshot to usage snapshot`() throws { let snapshot = CursorStatusSnapshot( planPercentUsed: 45.0, autoPercentUsed: 5.0, @@ -329,9 +368,11 @@ struct CursorStatusProbeTests { onDemandLimitUSD: 100.0, teamOnDemandUsedUSD: 25.0, teamOnDemandLimitUSD: 500.0, + billingCycleStart: Date(timeIntervalSince1970: 1_735_689_600), // Jan 1, 2025 billingCycleEnd: Date(timeIntervalSince1970: 1_738_368_000), // Feb 1, 2025 membershipType: "pro", accountEmail: "user@example.com", + accountID: "auth0|12345", accountName: "Test User", rawJSON: nil) @@ -339,12 +380,20 @@ struct CursorStatusProbeTests { #expect(usageSnapshot.primary?.usedPercent == 45.0) #expect(usageSnapshot.accountEmail(for: .cursor) == "user@example.com") + #expect(usageSnapshot.identity(for: .cursor)?.accountID == "auth0|12345") #expect(usageSnapshot.loginMethod(for: .cursor) == "Cursor Pro") #expect(usageSnapshot.secondary != nil) #expect(usageSnapshot.secondary?.usedPercent == 5.0) + #expect(usageSnapshot.primary?.windowMinutes == 44640) + #expect(usageSnapshot.secondary?.windowMinutes == 44640) #expect(usageSnapshot.providerCost?.used == 5.0) #expect(usageSnapshot.providerCost?.limit == 100.0) #expect(usageSnapshot.providerCost?.currencyCode == "USD") + + let roundTripped = try JSONDecoder().decode( + UsageSnapshot.self, + from: JSONEncoder().encode(usageSnapshot)) + #expect(roundTripped.identity(for: .cursor)?.accountID == "auth0|12345") } @Test @@ -398,6 +447,31 @@ struct CursorStatusProbeTests { #expect(usageSnapshot.providerCost?.limit == 60.0) } + @Test + func `uses team on demand budget when individual usage has no cap`() { + let snapshot = CursorStatusSnapshot( + planPercentUsed: 0, + autoPercentUsed: 0, + apiPercentUsed: 0, + planUsedUSD: 0, + planLimitUSD: 20, + onDemandUsedUSD: 0, + onDemandLimitUSD: nil, + teamOnDemandUsedUSD: 0, + teamOnDemandLimitUSD: 2349, + billingCycleEnd: nil, + membershipType: "enterprise", + accountEmail: nil, + accountName: nil, + rawJSON: nil) + + let usageSnapshot = snapshot.toUsageSnapshot() + + #expect(usageSnapshot.providerCost?.used == 0) + #expect(usageSnapshot.providerCost?.limit == 2349) + #expect(usageSnapshot.providerCost?.currencyCode == "USD") + } + @Test func `formats membership types`() { let testCases: [(input: String, expected: String)] = [ @@ -549,199 +623,6 @@ struct CursorStatusProbeTests { #expect(snapshot.requestsLimit == 500) } - // MARK: - Imported Session Scanning - - @Test - func `imported session scan continues after non auth failure until later success`() async { - let probe = CursorStatusProbe(browserDetection: BrowserDetection(cacheTTL: 0)) - let expected = CursorStatusSnapshot( - planPercentUsed: 0.441025641025641, - autoPercentUsed: 0.36, - apiPercentUsed: 0.7111111111111111, - planUsedUSD: 0.86, - planLimitUSD: 20.0, - onDemandUsedUSD: 0, - onDemandLimitUSD: nil, - teamOnDemandUsedUSD: nil, - teamOnDemandLimitUSD: nil, - billingCycleEnd: nil, - membershipType: "pro", - accountEmail: nil, - accountName: nil, - rawJSON: nil) - - let result = await probe.scanImportedSessions([ - Self.makeSessionInfo(sourceLabel: "Chrome"), - Self.makeSessionInfo(sourceLabel: "Safari"), - ]) { session in - switch session.sourceLabel { - case "Chrome": - .failed(.networkError("HTTP 500")) - case "Safari": - .succeeded(expected) - default: - .tryNextBrowser - } - } - - switch result { - case let .succeeded(snapshot): - #expect(snapshot.planPercentUsed == expected.planPercentUsed) - #expect(snapshot.autoPercentUsed == expected.autoPercentUsed) - #expect(snapshot.apiPercentUsed == expected.apiPercentUsed) - case .exhausted: - Issue.record("Expected scan to continue to the later successful browser session") - } - } - - @Test - func `imported session scan preserves first non auth failure after exhausting sessions`() async { - let probe = CursorStatusProbe(browserDetection: BrowserDetection(cacheTTL: 0)) - - let result = await probe.scanImportedSessions([ - Self.makeSessionInfo(sourceLabel: "Chrome"), - Self.makeSessionInfo(sourceLabel: "Safari"), - Self.makeSessionInfo(sourceLabel: "Arc"), - ]) { session in - switch session.sourceLabel { - case "Chrome": - .failed(.networkError("HTTP 500")) - case "Safari": - .tryNextBrowser - case "Arc": - .failed(.parseFailed("bad payload")) - default: - .tryNextBrowser - } - } - - switch result { - case .succeeded: - Issue.record("Expected scan to report the first recoverable error after exhausting sessions") - case let .exhausted(error): - guard let error else { - Issue.record("Expected first recoverable error to be preserved") - return - } - guard case let .networkError(message) = error else { - Issue.record("Expected first recoverable error to be the Chrome network failure") - return - } - #expect(message == "HTTP 500") - } - } - - @Test - func `browser scan stops importing after later browser succeeds`() async { - let probe = CursorStatusProbe(browserDetection: BrowserDetection(cacheTTL: 0)) - let expected = CursorStatusSnapshot( - planPercentUsed: 42, - autoPercentUsed: 12, - apiPercentUsed: 85, - planUsedUSD: 8.4, - planLimitUSD: 20, - onDemandUsedUSD: 0, - onDemandLimitUSD: nil, - teamOnDemandUsedUSD: nil, - teamOnDemandLimitUSD: nil, - billingCycleEnd: nil, - membershipType: "pro", - accountEmail: nil, - accountName: nil, - rawJSON: nil) - var importedLabels: [String] = [] - - let result = await probe.scanBrowsers( - [.chrome, .safari, .chromeBeta], - importSessions: { browser in - importedLabels.append(browser.displayName) - switch browser { - case .chrome: - return [Self.makeSessionInfo(sourceLabel: "Chrome")] - case .safari: - return [Self.makeSessionInfo(sourceLabel: "Safari")] - case .chromeBeta: - return [Self.makeSessionInfo(sourceLabel: "Chrome Beta")] - default: - return [] - } - }, - attemptFetch: { session in - switch session.sourceLabel { - case "Chrome": - .failed(.networkError("HTTP 500")) - case "Safari": - .succeeded(expected) - default: - .tryNextBrowser - } - }) - - switch result { - case let .succeeded(snapshot): - #expect(snapshot.planPercentUsed == expected.planPercentUsed) - #expect(importedLabels == ["Chrome", "Safari"]) - case .exhausted: - Issue.record("Expected browser scan to stop after the later successful browser") - } - } - - @Test - func `browser scan keeps trying later sources within the same browser`() async { - let probe = CursorStatusProbe(browserDetection: BrowserDetection(cacheTTL: 0)) - let expected = CursorStatusSnapshot( - planPercentUsed: 12, - autoPercentUsed: 3, - apiPercentUsed: 45, - planUsedUSD: 2.4, - planLimitUSD: 20, - onDemandUsedUSD: 0, - onDemandLimitUSD: nil, - teamOnDemandUsedUSD: nil, - teamOnDemandLimitUSD: nil, - billingCycleEnd: nil, - membershipType: "pro", - accountEmail: nil, - accountName: nil, - rawJSON: nil) - var attemptedSources: [String] = [] - - let result = await probe.scanBrowsers( - [.chrome, .safari], - importSessions: { browser in - switch browser { - case .chrome: - [ - Self.makeSessionInfo(sourceLabel: "Chrome Profile 1"), - Self.makeSessionInfo(sourceLabel: "Chrome Profile 2 (domain cookies)"), - ] - case .safari: - [Self.makeSessionInfo(sourceLabel: "Safari")] - default: - [] - } - }, - attemptFetch: { session in - attemptedSources.append(session.sourceLabel) - switch session.sourceLabel { - case "Chrome Profile 1": - return CursorStatusProbe.ImportedSessionFetchOutcome.failed(.networkError("HTTP 500")) - case "Chrome Profile 2 (domain cookies)": - return CursorStatusProbe.ImportedSessionFetchOutcome.succeeded(expected) - default: - return CursorStatusProbe.ImportedSessionFetchOutcome.tryNextBrowser - } - }) - - switch result { - case let .succeeded(snapshot): - #expect(snapshot.planPercentUsed == expected.planPercentUsed) - #expect(attemptedSources == ["Chrome Profile 1", "Chrome Profile 2 (domain cookies)"]) - case .exhausted: - Issue.record("Expected browser scan to continue to later sources within the same browser") - } - } - @Test func `detects non legacy plan`() { let snapshot = CursorStatusSnapshot( @@ -860,25 +741,36 @@ struct CursorStatusProbeTests { await store.clearCookies() } +} - private static func makeSessionInfo(sourceLabel: String) -> CursorCookieImporter.SessionInfo { - let cookieProps: [HTTPCookiePropertyKey: Any] = [ - .name: "WorkosCursorSessionToken", - .value: sourceLabel.lowercased(), - .domain: "cursor.com", - .path: "/", - .secure: true, - ] +private final class CursorStatusProbeTestSession { + let urlSession: URLSession + private let sessionID: String - let cookie = HTTPCookie(properties: cookieProps)! - return CursorCookieImporter.SessionInfo(cookies: [cookie], sourceLabel: sourceLabel) + init(handler: @escaping @Sendable (URLRequest) throws -> (HTTPURLResponse, Data)) { + let config = URLSessionConfiguration.ephemeral + config.protocolClasses = [CursorStatusProbeStubURLProtocol.self] + self.sessionID = CursorStatusProbeStubURLProtocol.configure(config, handler: handler) + self.urlSession = URLSession(configuration: config) + } + + deinit { + self.urlSession.invalidateAndCancel() + CursorStatusProbeStubURLProtocol.removeSession(self.sessionID) + } + + var requestCount: Int { + CursorStatusProbeStubURLProtocol.requests(for: self.sessionID).count } -} -private func makeCursorStatusProbeSession() -> URLSession { - let config = URLSessionConfiguration.ephemeral - config.protocolClasses = [CursorStatusProbeStubURLProtocol.self] - return URLSession(configuration: config) + var requestPaths: [String] { + CursorStatusProbeStubURLProtocol.requests(for: self.sessionID).compactMap { $0.url?.path } + } + + var requestCookies: [String] { + CursorStatusProbeStubURLProtocol.requests(for: self.sessionID) + .compactMap { $0.value(forHTTPHeaderField: "Cookie") } + } } private func makeCursorStatusProbeResponse( @@ -897,13 +789,30 @@ private func makeCursorStatusProbeResponse( extension CursorStatusProbeTests { @Test - func `fetch ignores user info failure when usage summary succeeds`() async throws { - defer { - CursorStatusProbeStubURLProtocol.reset() - } - CursorStatusProbeStubURLProtocol.reset() + func `app auth store reads Cursor global state database`() throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("cursor-app-auth-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + + let dbURL = directory.appendingPathComponent("state.vscdb") + var db: OpaquePointer? + try #require(sqlite3_open(dbURL.path, &db) == SQLITE_OK) + defer { sqlite3_close(db) } + + let sql = """ + CREATE TABLE ItemTable(key TEXT PRIMARY KEY, value BLOB); + INSERT INTO ItemTable VALUES('cursorAuth/accessToken', 'app-token'); + """ + try #require(sqlite3_exec(db, sql, nil, nil, nil) == SQLITE_OK) + + let session = try #require(try CursorAppAuthStore(dbPath: dbURL.path).loadSession()) + #expect(session == CursorAppAuthSession(accessToken: "app-token")) + } - CursorStatusProbeStubURLProtocol.setHandler { request in + @Test + func `fetch ignores user info failure when usage summary succeeds`() async throws { + let testSession = CursorStatusProbeTestSession { request in let requestURL = try #require(request.url) switch requestURL.path { @@ -937,21 +846,16 @@ extension CursorStatusProbeTests { let snapshot = try await CursorStatusProbe( baseURL: baseURL, browserDetection: BrowserDetection(cacheTTL: 0), - urlSession: makeCursorStatusProbeSession()).fetchWithManualCookies("auth=test") + urlSession: testSession.urlSession).fetchWithManualCookies("auth=test") #expect(snapshot.planPercentUsed == 30.0) #expect(snapshot.accountEmail == nil) - #expect(CursorStatusProbeStubURLProtocol.requestCount == 2) + #expect(testSession.requestCount == 2) } @Test func `fetch fails cleanly when usage summary fails`() async { - defer { - CursorStatusProbeStubURLProtocol.reset() - } - CursorStatusProbeStubURLProtocol.reset() - - CursorStatusProbeStubURLProtocol.setHandler { request in + let testSession = CursorStatusProbeTestSession { request in let requestURL = try #require(request.url) switch requestURL.path { @@ -982,7 +886,7 @@ extension CursorStatusProbeTests { _ = try await CursorStatusProbe( baseURL: baseURL, browserDetection: BrowserDetection(cacheTTL: 0), - urlSession: makeCursorStatusProbeSession()).fetchWithManualCookies("auth=test") + urlSession: testSession.urlSession).fetchWithManualCookies("auth=test") Issue.record("Expected usage summary failure to be surfaced") } catch let error as CursorStatusProbeError { guard case let .networkError(message) = error else { @@ -990,44 +894,545 @@ extension CursorStatusProbeTests { return } #expect(message == "HTTP 500") - #expect(CursorStatusProbeStubURLProtocol.requestPaths.contains("/api/usage-summary")) + #expect(testSession.requestPaths.contains("/api/usage-summary")) } catch { Issue.record("Expected CursorStatusProbeError, got: \(error)") } } + + @Test + func `fetch uses Cursor app local auth when browser cookies are unavailable`() async throws { + let accessToken = try makeCursorAppAuthToken() + let expectedCookie = "WorkosCursorSessionToken=user_test%3A%3A\(accessToken)" + let testSession = CursorStatusProbeTestSession { request in + let requestURL = try #require(request.url) + #expect(request.value(forHTTPHeaderField: "Authorization") == nil) + #expect(request.value(forHTTPHeaderField: "Cookie") == expectedCookie) + #expect(request.httpMethod == "GET") + + switch requestURL.path { + case "/api/usage-summary": + return makeCursorStatusProbeResponse( + url: requestURL, + body: """ + { + "membershipType": "pro", + "billingCycleStart": "2026-05-23T10:27:04.000Z", + "billingCycleEnd": "2026-06-23T10:27:04.000Z", + "individualUsage": { + "plan": { + "used": 388, + "limit": 2000, + "totalPercentUsed": 19.4 + }, + "onDemand": { + "used": 450, + "limit": 1000 + } + } + } + """, + statusCode: 200) + case "/api/auth/me": + return makeCursorStatusProbeResponse( + url: requestURL, + body: #"{"email":"user@example.com","name":"Test User","sub":"auth0|user_test"}"#, + statusCode: 200) + case "/api/usage": + return makeCursorStatusProbeResponse( + url: requestURL, + body: #"{"gpt-4":{},"startOfMonth":"2026-05-23"}"#, + statusCode: 200) + default: + throw URLError(.badURL) + } + } + + CookieHeaderCache.clear(provider: .cursor) + defer { CookieHeaderCache.clear(provider: .cursor) } + let baseURL = try #require(URL(string: "https://cursor-web.test")) + let snapshot = try await CursorStatusProbe( + baseURL: baseURL, + browserDetection: BrowserDetection(cacheTTL: 0), + browserCookieImportOrder: [], + urlSession: testSession.urlSession, + appAuthStore: CursorAppAuthSessionProviderStub(session: CursorAppAuthSession( + accessToken: accessToken))).fetch(allowCachedSessions: false) + + #expect(abs(snapshot.planPercentUsed - 19.4) < 0.0001) + #expect(snapshot.planUsedUSD == 3.88) + #expect(snapshot.planLimitUSD == 20.0) + #expect(snapshot.onDemandUsedUSD == 4.5) + #expect(snapshot.onDemandLimitUSD == 10.0) + #expect(snapshot.membershipType == "pro") + #expect(snapshot.accountID == "auth0|user_test") + #expect(snapshot.accountEmail == "user@example.com") + #expect(snapshot.accountName == "Test User") + #expect(testSession.requestPaths.sorted() == [ + "/api/auth/me", + "/api/usage", + "/api/usage-summary", + ]) + } + + @Test + func `fetch can disable Cursor app auth during browser login verification`() async throws { + let testSession = CursorStatusProbeTestSession { request in + Issue.record("Disabled app auth unexpectedly requested \(request.url?.path ?? "")") + throw URLError(.badURL) + } + + let baseURL = try #require(URL(string: "https://cursor-web.test")) + let accessToken = try makeCursorAppAuthToken() + let probe = CursorStatusProbe( + baseURL: baseURL, + browserDetection: BrowserDetection(cacheTTL: 0), + browserCookieImportOrder: [], + urlSession: testSession.urlSession, + appAuthStore: CursorAppAuthSessionProviderStub(session: CursorAppAuthSession( + accessToken: accessToken))) + + await #expect(throws: CursorStatusProbeError.self) { + _ = try await probe.fetch( + allowCachedSessions: false, + allowAppAuthFallback: false) + } + #expect(testSession.requestCount == 0) + } + + @Test + func `fetch prefers stored session cookies before Cursor app auth fallback`() async throws { + let store = CursorSessionStore.shared + await store.clearCookies() + defer { + Task { await store.clearCookies() } + } + + guard let cookie = HTTPCookie(properties: [ + .name: "WorkosCursorSessionToken", + .value: "stored-session", + .domain: "cursor.com", + .path: "/", + .secure: true, + ]) else { + Issue.record("Failed to create stored Cursor session cookie") + return + } + await store.setCookies([cookie]) + + let testSession = CursorStatusProbeTestSession { request in + let requestURL = try #require(request.url) + #expect(request.value(forHTTPHeaderField: "Authorization") == nil) + #expect(request.value(forHTTPHeaderField: "Cookie") == "WorkosCursorSessionToken=stored-session") + + switch requestURL.path { + case "/api/usage-summary": + return makeCursorStatusProbeResponse( + url: requestURL, + body: """ + { + "membershipType": "pro", + "individualUsage": { + "plan": { + "used": 1500, + "limit": 5000, + "totalPercentUsed": 30.0 + } + } + } + """, + statusCode: 200) + case "/api/auth/me": + return makeCursorStatusProbeResponse( + url: requestURL, + body: #"{"email":"stored@example.com","name":"Stored User"}"#, + statusCode: 200) + default: + Issue.record("Stored-session precedence test unexpectedly requested \(requestURL.path)") + throw URLError(.badURL) + } + } + + CookieHeaderCache.clear(provider: .cursor) + defer { CookieHeaderCache.clear(provider: .cursor) } + let baseURL = try #require(URL(string: "https://cursor.test")) + let accessToken = try makeCursorAppAuthToken() + let snapshot = try await CursorStatusProbe( + baseURL: baseURL, + browserDetection: BrowserDetection(cacheTTL: 0), + browserCookieImportOrder: [], + urlSession: testSession.urlSession, + appAuthStore: CursorAppAuthSessionProviderStub(session: CursorAppAuthSession( + accessToken: accessToken))).fetch() + + #expect(snapshot.planPercentUsed == 30.0) + #expect(snapshot.accountEmail == "stored@example.com") + #expect(testSession.requestPaths.sorted() == [ + "/api/auth/me", + "/api/usage-summary", + ]) + } + + @Test + func `fetch with Cursor app auth preserves legacy request quotas`() async throws { + let accessToken = try makeCursorAppAuthToken() + let expectedCookie = "WorkosCursorSessionToken=user_test%3A%3A\(accessToken)" + let testSession = CursorStatusProbeTestSession { request in + let requestURL = try #require(request.url) + #expect(request.value(forHTTPHeaderField: "Cookie") == expectedCookie) + + switch requestURL.path { + case "/api/usage-summary": + return makeCursorStatusProbeResponse( + url: requestURL, + body: """ + { + "membershipType": "enterprise", + "individualUsage": {} + } + """, + statusCode: 200) + case "/api/auth/me": + return makeCursorStatusProbeResponse( + url: requestURL, + body: #"{"error":"temporary"}"#, + statusCode: 500) + case "/api/usage": + #expect(URLComponents(url: requestURL, resolvingAgainstBaseURL: false)? + .queryItems?.first(where: { $0.name == "user" })?.value == "user_test") + return makeCursorStatusProbeResponse( + url: requestURL, + body: """ + { + "gpt-4": { + "numRequests": 200, + "numRequestsTotal": 240, + "maxRequestUsage": 500 + } + } + """, + statusCode: 200) + default: + throw URLError(.badURL) + } + } + + let baseURL = try #require(URL(string: "https://cursor.test")) + let probe = CursorStatusProbe( + baseURL: baseURL, + browserDetection: BrowserDetection(cacheTTL: 0), + urlSession: testSession.urlSession) + + let snapshot = try await probe.fetchWithAppAuthSession(CursorAppAuthSession(accessToken: accessToken)) + #expect(snapshot.requestsUsed == 240) + #expect(snapshot.requestsLimit == 500) + #expect(snapshot.toUsageSnapshot().primary?.usedPercent == 48) + #expect(snapshot.accountEmail == nil) + #expect(testSession.requestPaths.sorted() == [ + "/api/auth/me", + "/api/usage", + "/api/usage-summary", + ]) + } + + @Test + func `malformed Cursor app auth token is rejected before network access`() { + let session = CursorAppAuthSession(accessToken: "not-a-jwt") + #expect(throws: CursorStatusProbeError.self) { + _ = try session.cookieHeader() + } + #expect(!session.isUsable) + } + + @Test + func `expired Cursor app auth token is skipped before network access`() async throws { + let testSession = CursorStatusProbeTestSession { request in + Issue.record("Expired app auth unexpectedly requested \(request.url?.path ?? "")") + throw URLError(.badURL) + } + + let accessToken = try makeCursorAppAuthToken(expiration: Date(timeIntervalSinceNow: -60)) + let baseURL = try #require(URL(string: "https://cursor-web.test")) + let probe = CursorStatusProbe( + baseURL: baseURL, + browserDetection: BrowserDetection(cacheTTL: 0), + browserCookieImportOrder: [], + urlSession: testSession.urlSession, + appAuthStore: CursorAppAuthSessionProviderStub(session: CursorAppAuthSession( + accessToken: accessToken))) + + await #expect(throws: CursorStatusProbeError.self) { + _ = try await probe.fetch(allowCachedSessions: false) + } + #expect(testSession.requestCount == 0) + } + + @Test + func `Cursor app auth transient failure is preserved`() async throws { + let testSession = CursorStatusProbeTestSession { request in + let requestURL = try #require(request.url) + return makeCursorStatusProbeResponse( + url: requestURL, + body: #"{"error":"temporary"}"#, + statusCode: 500) + } + + let accessToken = try makeCursorAppAuthToken() + let baseURL = try #require(URL(string: "https://cursor-web.test")) + let probe = CursorStatusProbe( + baseURL: baseURL, + browserDetection: BrowserDetection(cacheTTL: 0), + browserCookieImportOrder: [], + urlSession: testSession.urlSession, + appAuthStore: CursorAppAuthSessionProviderStub(session: CursorAppAuthSession( + accessToken: accessToken))) + + do { + _ = try await probe.fetch(allowCachedSessions: false) + Issue.record("Expected Cursor.app auth request to fail") + } catch let error as CursorStatusProbeError { + guard case .networkError = error else { + Issue.record("Expected network error, got \(error)") + return + } + } + } + + @Test + func `cached session transient failure does not switch to Cursor app auth`() async throws { + CookieHeaderCache.store(provider: .cursor, cookieHeader: "cached=bad", sourceLabel: "test") + defer { + CookieHeaderCache.clear(provider: .cursor) + } + + let accessToken = try makeCursorAppAuthToken() + let appCookie = "WorkosCursorSessionToken=user_test%3A%3A\(accessToken)" + let testSession = CursorStatusProbeTestSession { request in + let requestURL = try #require(request.url) + let cookie = request.value(forHTTPHeaderField: "Cookie") + switch requestURL.path { + case "/api/usage-summary" where cookie == "cached=bad": + return makeCursorStatusProbeResponse( + url: requestURL, + body: #"{"error":"temporary"}"#, + statusCode: 500) + case _ where cookie == appCookie: + Issue.record("Transient cached-session failure unexpectedly switched to Cursor.app auth") + throw URLError(.userAuthenticationRequired) + default: + throw URLError(.badURL) + } + } + + let baseURL = try #require(URL(string: "https://cursor-web.test")) + let probe = CursorStatusProbe( + baseURL: baseURL, + browserDetection: BrowserDetection(cacheTTL: 0), + browserCookieImportOrder: [], + urlSession: testSession.urlSession, + appAuthStore: CursorAppAuthSessionProviderStub(session: CursorAppAuthSession( + accessToken: accessToken))) + + await #expect(throws: CursorStatusProbeError.self) { + _ = try await probe.fetch() + } + #expect(testSession.requestCookies.contains("cached=bad")) + #expect(!testSession.requestCookies.contains(appCookie)) + } + + @Test + func `rejected selected session does not fall back to another account`() async throws { + let selectedSession = CursorStatusProbe.BrowserLoginSession( + cookieHeader: "selected=expired", + sourceLabel: "Selected browser") + #expect(CursorStatusProbe.commitBrowserLoginSession(selectedSession)) + defer { CookieHeaderCache.clear(provider: .cursor) } + + let accessToken = try makeCursorAppAuthToken() + let appSession = CursorAppAuthSession(accessToken: accessToken) + let appCookie = try appSession.cookieHeader() + let testSession = CursorStatusProbeTestSession { request in + let requestURL = try #require(request.url) + let cookie = request.value(forHTTPHeaderField: "Cookie") + if cookie == appCookie { + Issue.record("Rejected selected session unexpectedly switched to Cursor.app auth") + throw URLError(.userAuthenticationRequired) + } + return makeCursorStatusProbeResponse( + url: requestURL, + body: #"{"error":"unauthorized"}"#, + statusCode: 401) + } + + let baseURL = try #require(URL(string: "https://cursor-web.test")) + let probe = CursorStatusProbe( + baseURL: baseURL, + browserDetection: BrowserDetection(cacheTTL: 0), + browserCookieImportOrder: [], + urlSession: testSession.urlSession, + appAuthStore: CursorAppAuthSessionProviderStub(session: appSession)) + + await #expect(throws: CursorStatusProbeError.self) { + _ = try await probe.fetch() + } + await #expect(throws: CursorStatusProbeError.self) { + _ = try await probe.fetch() + } + #expect(testSession.requestCookies.contains("selected=expired")) + #expect(!testSession.requestCookies.contains(appCookie)) + #expect(CookieHeaderCache.load(provider: .cursor)?.authenticationFailurePolicy == .stopFallback) + } + + @Test + func `rejected stale request retries a concurrently selected session`() async throws { + let staleSession = CursorStatusProbe.BrowserLoginSession( + cookieHeader: "selected=stale", + sourceLabel: "Stale browser") + let replacementSession = CursorStatusProbe.BrowserLoginSession( + cookieHeader: "selected=replacement", + sourceLabel: "Replacement browser") + #expect(CursorStatusProbe.commitBrowserLoginSession(staleSession)) + defer { CookieHeaderCache.clear(provider: .cursor) } + + let testSession = CursorStatusProbeTestSession { request in + let requestURL = try #require(request.url) + let cookie = request.value(forHTTPHeaderField: "Cookie") + if cookie == staleSession.cookieHeader { + #expect(CursorStatusProbe.commitBrowserLoginSession(replacementSession)) + return makeCursorStatusProbeResponse( + url: requestURL, + body: #"{"error":"unauthorized"}"#, + statusCode: 401) + } + #expect(cookie == replacementSession.cookieHeader) + switch requestURL.path { + case "/api/usage-summary": + return makeCursorStatusProbeResponse( + url: requestURL, + body: #"{"membershipType":"pro","individualUsage":{}}"#, + statusCode: 200) + case "/api/auth/me": + return makeCursorStatusProbeResponse( + url: requestURL, + body: #"{"email":"replacement@example.com","sub":"auth0|replacement"}"#, + statusCode: 200) + default: + throw URLError(.badURL) + } + } + + let baseURL = try #require(URL(string: "https://cursor-web.test")) + let snapshot = try await CursorStatusProbe( + baseURL: baseURL, + browserDetection: BrowserDetection(cacheTTL: 0), + browserCookieImportOrder: [], + urlSession: testSession.urlSession, + appAuthStore: CursorAppAuthSessionProviderStub(session: nil)).fetch() + + #expect(snapshot.accountEmail == "replacement@example.com") + #expect(CookieHeaderCache.load(provider: .cursor)?.cookieHeader == replacementSession.cookieHeader) + #expect(CookieHeaderCache.load(provider: .cursor)?.authenticationFailurePolicy == .stopFallback) + } + + @Test + func `rejected selected session ignores an unselected cache replacement`() async throws { + let selectedSession = CursorStatusProbe.BrowserLoginSession( + cookieHeader: "selected=stale", + sourceLabel: "Selected browser") + #expect(CursorStatusProbe.commitBrowserLoginSession(selectedSession)) + defer { CookieHeaderCache.clear(provider: .cursor) } + + let testSession = CursorStatusProbeTestSession { request in + let requestURL = try #require(request.url) + let cookie = request.value(forHTTPHeaderField: "Cookie") + if cookie == selectedSession.cookieHeader { + #expect(!CookieHeaderCache.storeResult( + provider: .cursor, + cookieHeader: "background=replacement", + sourceLabel: "Background refresh")) + } else { + Issue.record("Rejected selected session unexpectedly switched to \(cookie ?? "")") + } + return makeCursorStatusProbeResponse( + url: requestURL, + body: #"{"error":"unauthorized"}"#, + statusCode: 401) + } + + let baseURL = try #require(URL(string: "https://cursor-web.test")) + let probe = CursorStatusProbe( + baseURL: baseURL, + browserDetection: BrowserDetection(cacheTTL: 0), + browserCookieImportOrder: [], + urlSession: testSession.urlSession, + appAuthStore: CursorAppAuthSessionProviderStub(session: nil)) + + await #expect(throws: CursorStatusProbeError.self) { + _ = try await probe.fetch() + } + #expect(!testSession.requestCookies.contains("background=replacement")) + #expect(CookieHeaderCache.load(provider: .cursor)?.cookieHeader == selectedSession.cookieHeader) + #expect(CookieHeaderCache.load(provider: .cursor)?.authenticationFailurePolicy == .stopFallback) + } +} + +private func makeCursorAppAuthToken( + subject: String = "auth0|user_test", + expiration: Date = Date(timeIntervalSinceNow: 3600)) throws -> String +{ + let payload = try JSONSerialization.data( + withJSONObject: [ + "exp": Int(expiration.timeIntervalSince1970), + "sub": subject, + ], + options: [.sortedKeys]) + let encodedPayload = payload.base64EncodedString() + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") + return "header.\(encodedPayload).signature" +} + +private struct CursorAppAuthSessionProviderStub: CursorAppAuthSessionProviding { + let session: CursorAppAuthSession? + + func loadSession() throws -> CursorAppAuthSession? { + self.session + } } final class CursorStatusProbeStubURLProtocol: URLProtocol { - private struct State { + private struct SessionState { var requests: [URLRequest] = [] - var handler: (@Sendable (URLRequest) throws -> (HTTPURLResponse, Data))? + let handler: @Sendable (URLRequest) throws -> (HTTPURLResponse, Data) } + private static let sessionHeader = "X-CodexBar-Cursor-Test-Session" private static let lock = NSLock() - private nonisolated(unsafe) static var state = State() + private nonisolated(unsafe) static var sessions: [String: SessionState] = [:] - static func setHandler(_ handler: @escaping @Sendable (URLRequest) throws -> (HTTPURLResponse, Data)) { + static func configure( + _ configuration: URLSessionConfiguration, + handler: @escaping @Sendable (URLRequest) throws -> (HTTPURLResponse, Data)) -> String + { + let sessionID = UUID().uuidString self.lock.lock() - self.state.handler = handler + self.sessions[sessionID] = SessionState(handler: handler) self.lock.unlock() + configuration.httpAdditionalHeaders = [self.sessionHeader: sessionID] + return sessionID } - static func reset() { + static func removeSession(_ sessionID: String) { self.lock.lock() - self.state = State() + self.sessions.removeValue(forKey: sessionID) self.lock.unlock() } - static var requestCount: Int { - lock.lock() - defer { Self.lock.unlock() } - return state.requests.count - } - - static var requestPaths: [String] { - lock.lock() + static func requests(for sessionID: String) -> [URLRequest] { + self.lock.lock() defer { Self.lock.unlock() } - return state.requests.compactMap { $0.url?.path } + return self.sessions[sessionID]?.requests ?? [] } override static func canInit(with request: URLRequest) -> Bool { @@ -1040,9 +1445,15 @@ final class CursorStatusProbeStubURLProtocol: URLProtocol { override func startLoading() { let handler: (@Sendable (URLRequest) throws -> (HTTPURLResponse, Data))? + let sessionID = self.request.value(forHTTPHeaderField: Self.sessionHeader) Self.lock.lock() - Self.state.requests.append(self.request) - handler = Self.state.handler + if let sessionID, var state = Self.sessions[sessionID] { + state.requests.append(self.request) + handler = state.handler + Self.sessions[sessionID] = state + } else { + handler = nil + } Self.lock.unlock() do { diff --git a/Tests/CodexBarTests/CursorUsageEventsFetcherTests.swift b/Tests/CodexBarTests/CursorUsageEventsFetcherTests.swift new file mode 100644 index 0000000000..2573d238eb --- /dev/null +++ b/Tests/CodexBarTests/CursorUsageEventsFetcherTests.swift @@ -0,0 +1,690 @@ +import Foundation +import Testing +@testable import CodexBarCore + +#if os(macOS) +@Suite(.serialized) +struct CursorUsageEventsFetcherTests { + // MARK: - Helpers + + private static let baseURL = URL(string: "https://cursor.test")! + + /// Calendar pinned to UTC so timestamp-to-day grouping is deterministic across machines. + private static var utcCalendar: Calendar { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(identifier: "UTC")! + return calendar + } + + /// Cost math runs through `cents / 100`, so compare with a tolerance rather than `==`. + private static func approxEqual(_ actual: Double?, _ expected: Double, tolerance: Double = 1e-9) -> Bool { + guard let actual else { return false } + return abs(actual - expected) < tolerance + } + + private static func httpResponse(_ body: String, statusCode: Int = 200) -> (Data, URLResponse) { + let response = HTTPURLResponse( + url: baseURL, + statusCode: statusCode, + httpVersion: nil, + headerFields: ["Content-Type": "application/json"])! + return (Data(body.utf8), response) + } + + private static func event( + timestampMS: Int64, + model: String, + input: Int = 0, + output: Int = 0, + cacheWrite: Int = 0, + cacheRead: Int = 0, + totalCents: Double?, + isChargeable: Bool? = nil, + chargedCents: Double? = nil) -> CursorUsageEvent + { + CursorUsageEvent( + timestampMS: timestampMS, + model: model, + tokenUsage: CursorEventTokenUsage( + inputTokens: input, + outputTokens: output, + cacheWriteTokens: cacheWrite, + cacheReadTokens: cacheRead, + totalCents: totalCents), + isChargeable: isChargeable, + chargedCents: chargedCents) + } + + /// Reads the `page` field from a stubbed request body so the handler can return pages. + private struct PageProbe: Decodable { + let page: Int? + } + + private static func requestedPage(_ request: URLRequest) -> Int { + guard let body = request.httpBody, + let probe = try? JSONDecoder().decode(PageProbe.self, from: body) + else { return 1 } + return probe.page ?? 1 + } + + // MARK: - Mapping + + @Test + func `makeDailyReport groups events by local day and model with cents converted to USD`() { + // 2023-11-14T22:13:20Z and one hour later share a UTC day; the third event is two days later. + let day1 = Int64(1_700_000_000_000) + let day1Later = day1 + 3_600_000 + let day3 = day1 + 172_800_000 + + let events = [ + Self.event(timestampMS: day1, model: "claude-4.5-sonnet", input: 100, output: 50, totalCents: 100), + Self.event(timestampMS: day1Later, model: "claude-4.5-sonnet", input: 10, output: 5, totalCents: 23), + Self.event(timestampMS: day1, model: "gpt-5", input: 200, output: 20, totalCents: 500), + Self.event(timestampMS: day3, model: "claude-4.5-sonnet", input: 1, output: 1, totalCents: 9), + ] + + let report = CursorUsageEventsFetcher.makeDailyReport(from: events, calendar: Self.utcCalendar) + + #expect(report.data.count == 2) + + let firstDay = report.data[0] + #expect(firstDay.date == "2023-11-14") + // Two models on day one; the gpt-5 row is more expensive so it sorts first. + #expect(firstDay.modelBreakdowns?.count == 2) + #expect(firstDay.modelBreakdowns?.first?.modelName == "gpt-5") + #expect(firstDay.modelsUsed == ["claude-4.5-sonnet", "gpt-5"]) + // claude rows merge: (100 + 23) cents, gpt-5 row: 500 cents -> $6.23 total for the day. + #expect(Self.approxEqual(firstDay.costUSD, 6.23)) + #expect(firstDay.requestCount == 3) + #expect(firstDay.totalTokens == 100 + 50 + 10 + 5 + 200 + 20) + + let claudeBreakdown = firstDay.modelBreakdowns?.first { $0.modelName == "claude-4.5-sonnet" } + #expect(Self.approxEqual(claudeBreakdown?.costUSD, 1.23)) + #expect(claudeBreakdown?.requestCount == 2) + + let lastDay = report.data[1] + #expect(lastDay.date == "2023-11-16") + #expect(Self.approxEqual(lastDay.costUSD, 0.09)) + + // Summary aggregates every day. + #expect(Self.approxEqual(report.summary?.totalCostUSD, 6.32)) + } + + @Test + func `makeDailyReport skips events without token usage`() { + let events = [ + Self.event(timestampMS: 1_700_000_000_000, model: "claude-4.5-sonnet", totalCents: 0), + Self.event(timestampMS: 1_700_000_000_000, model: "claude-4.5-sonnet", input: 5, totalCents: 12), + ] + + let report = CursorUsageEventsFetcher.makeDailyReport(from: events, calendar: Self.utcCalendar) + + #expect(report.data.count == 1) + #expect(report.data[0].requestCount == 1) + #expect(Self.approxEqual(report.data[0].costUSD, 0.12)) + } + + @Test + func `meteredCostUSD rejects a partial sum when an event omits chargedCents`() { + let events = [ + Self.event(timestampMS: 1_700_000_000_000, model: "claude", input: 5, totalCents: 994, chargedCents: 4), + Self.event(timestampMS: 1_700_000_001_000, model: "gpt-5", input: 5, totalCents: 500, chargedCents: 8), + Self.event(timestampMS: 1_700_000_002_000, model: "default", input: 5, totalCents: 12), + ] + + #expect(CursorUsageEventsFetcher.meteredCostUSD(from: events) == nil) + } + + @Test + func `meteredCostUSD returns nil when no event reports chargedCents`() { + let events = [ + Self.event(timestampMS: 1_700_000_000_000, model: "claude", input: 5, totalCents: 994), + ] + + #expect(CursorUsageEventsFetcher.meteredCostUSD(from: events) == nil) + } + + @Test + func `meteredCostUSD includes plan consumption not marked additionally chargeable`() { + let events = [ + Self.event( + timestampMS: 1_700_000_000_000, + model: "claude", + input: 5, + totalCents: 994, + isChargeable: false, + chargedCents: 40), + Self.event( + timestampMS: 1_700_000_001_000, + model: "gpt-5", + input: 5, + totalCents: 500, + isChargeable: true, + chargedCents: 8), + Self.event( + timestampMS: 1_700_000_002_000, + model: "legacy", + input: 5, + totalCents: 100, + chargedCents: 4), + ] + + // Cursor's dashboard reconciliation sums chargedCents even for included-plan events. + #expect(Self.approxEqual(CursorUsageEventsFetcher.meteredCostUSD(from: events), 0.52)) + } + + // MARK: - Snapshot + + @Test + func `session cost tracks the current local day, not the latest entry`() throws { + // Cursor labels the session line "Today", so a stale latest day must not leak into it. This + // mirrors loadCursorTokenSnapshot, which builds the snapshot with current-local-day semantics. + let calendar = Calendar.current + let now = try #require(calendar.date(from: DateComponents(year: 2026, month: 5, day: 18, hour: 12))) + let twoDaysAgo = try #require(calendar.date(byAdding: .day, value: -2, to: now)) + let event = Self.event( + timestampMS: Int64(twoDaysAgo.timeIntervalSince1970 * 1000), + model: "claude-4.5-sonnet", + input: 100, + output: 50, + totalCents: 150) + + let report = CursorUsageEventsFetcher.makeDailyReport(from: [event], calendar: calendar) + let snapshot = CostUsageFetcher.tokenSnapshot(from: report, now: now, useCurrentLocalDayForSession: true) + + // No usage today -> session is zero, while the window total still reflects the older day. + #expect(snapshot.sessionCostUSD == 0) + #expect(snapshot.sessionTokens == 0) + #expect(Self.approxEqual(snapshot.last30DaysCostUSD, 1.5)) + } + + // MARK: - Decoding + + @Test + func `decodes string-encoded numbers leniently`() throws { + let json = """ + { + "totalUsageEventsCount": "2", + "usageEventsDisplay": [ + { + "timestamp": "1700000000000", + "model": "claude-4.5-sonnet", + "tokenUsage": { + "inputTokens": "100", + "outputTokens": 50, + "cacheWriteTokens": "10", + "cacheReadTokens": "5", + "totalCents": "12.5" + } + } + ] + } + """ + let page = try JSONDecoder().decode(CursorUsageEventsPage.self, from: Data(json.utf8)) + + #expect(page.totalUsageEventsCount == 2) + let event = try #require(page.usageEventsDisplay.first) + #expect(event.timestampMS == 1_700_000_000_000) + #expect(event.tokenUsage?.inputTokens == 100) + #expect(event.tokenUsage?.cacheWriteTokens == 10) + #expect(Self.approxEqual(event.tokenUsage?.totalCents, 12.5)) + } + + @Test(arguments: [ + #"{"totalUsageEventsCount":0}"#, + #"{"totalUsageEventsCount":0,"usageEventsDisplay":{}}"#, + #"{"error":"temporarily unavailable"}"#, + ]) + func `page decoding rejects missing or malformed event arrays`(json: String) { + #expect(throws: DecodingError.self) { + _ = try JSONDecoder().decode(CursorUsageEventsPage.self, from: Data(json.utf8)) + } + } + + @Test(arguments: ["-1", String(Int.min)]) + func `page decoding rejects negative event counts`(count: String) { + let json = #"{"totalUsageEventsCount":\#(count),"usageEventsDisplay":[]}"# + + #expect(throws: DecodingError.self) { + _ = try JSONDecoder().decode(CursorUsageEventsPage.self, from: Data(json.utf8)) + } + } + + @Test + func `invalid and out of range numeric fields fail closed without trapping`() throws { + let json = """ + { + "totalUsageEventsCount": "Infinity", + "usageEventsDisplay": [ + { + "timestamp": "Infinity", + "model": "fixture-model", + "chargedCents": "NaN", + "tokenUsage": { + "inputTokens": "Infinity", + "outputTokens": "1e999", + "cacheWriteTokens": "-Infinity", + "cacheReadTokens": "NaN", + "totalCents": "Infinity" + } + } + ] + } + """ + let page = try JSONDecoder().decode(CursorUsageEventsPage.self, from: Data(json.utf8)) + let event = try #require(page.usageEventsDisplay.first) + + #expect(page.totalUsageEventsCount == nil) + #expect(event.timestampMS == nil) + #expect(event.chargedCents == nil) + #expect(event.tokenUsage?.inputTokens == 0) + #expect(event.tokenUsage?.outputTokens == 0) + #expect(event.tokenUsage?.cacheWriteTokens == 0) + #expect(event.tokenUsage?.cacheReadTokens == 0) + #expect(event.tokenUsage?.totalCents == nil) + } + + @Test + func `reports skip events without a valid timestamp`() { + let event = CursorUsageEvent( + timestampMS: nil, + model: "fixture-model", + tokenUsage: CursorEventTokenUsage( + inputTokens: 10, + outputTokens: 5, + cacheWriteTokens: 0, + cacheReadTokens: 0, + totalCents: 100), + chargedCents: 25) + + let report = CursorUsageEventsFetcher.makeDailyReport(from: [event], calendar: Self.utcCalendar) + + #expect(report.data.isEmpty) + #expect(report.summary?.totalCostUSD == 0) + #expect(CursorUsageEventsFetcher.meteredCostUSD(from: [event]) == nil) + } + + @Test + func `token totals fail closed on overflow`() { + let usage = CursorEventTokenUsage( + inputTokens: Int.max, + outputTokens: 1, + cacheWriteTokens: 0, + cacheReadTokens: 0, + totalCents: nil) + + #expect(usage.totalTokens == 0) + #expect(!usage.hasTokens) + } + + @Test + func `reports preserve unknown cost when a token event omits total cents`() { + let event = Self.event( + timestampMS: 1_700_000_000_000, + model: "fixture-model", + input: 5, + totalCents: nil) + + let report = CursorUsageEventsFetcher.makeDailyReport(from: [event], calendar: Self.utcCalendar) + + #expect(report.data.count == 1) + #expect(report.data[0].inputTokens == 5) + #expect(report.data[0].costUSD == nil) + #expect(report.data[0].modelBreakdowns?.first?.costUSD == nil) + #expect(report.summary?.totalCostUSD == nil) + } + + @Test + func `reports preserve unknown aggregate tokens on cross event overflow`() { + let events = [ + Self.event( + timestampMS: 1_700_000_000_000, + model: "fixture-model", + input: Int.max, + totalCents: 1), + Self.event( + timestampMS: 1_700_000_001_000, + model: "fixture-model", + input: Int.max, + totalCents: 1), + ] + + let report = CursorUsageEventsFetcher.makeDailyReport(from: events, calendar: Self.utcCalendar) + + #expect(report.data.count == 1) + #expect(report.data[0].inputTokens == nil) + #expect(report.data[0].totalTokens == nil) + #expect(report.data[0].requestCount == 2) + #expect(report.data[0].modelBreakdowns?.first?.totalTokens == nil) + #expect(report.summary?.totalInputTokens == nil) + #expect(report.summary?.totalTokens == nil) + #expect(Self.approxEqual(report.summary?.totalCostUSD, 0.02)) + } + + @Test + func `metered totals fail closed on overflow`() { + let events = [ + Self.event( + timestampMS: 1_700_000_000_000, + model: "fixture-model", + input: 1, + totalCents: 1, + chargedCents: Double.greatestFiniteMagnitude), + Self.event( + timestampMS: 1_700_000_001_000, + model: "fixture-model", + input: 1, + totalCents: 1, + chargedCents: Double.greatestFiniteMagnitude), + ] + + #expect(CursorUsageEventsFetcher.meteredCostUSD(from: events) == nil) + } + + // MARK: - Fetching + + @Test + func `fetchUsage paginates, dedupes, sums metered cents, and sends Origin and Cookie headers`() async throws { + // swiftlint:disable line_length + let firstEvent = #""" + {"timestamp":"1700000000000","model":"claude-4.5-sonnet","tokenUsage":{"inputTokens":100,"outputTokens":50,"cacheWriteTokens":0,"cacheReadTokens":0,"totalCents":100},"chargedCents":4} + """# + let secondEvent = #""" + {"timestamp":"1700003600000","model":"gpt-5","tokenUsage":{"inputTokens":10,"outputTokens":5,"cacheWriteTokens":0,"cacheReadTokens":0,"totalCents":50},"chargedCents":4} + """# + // 1_700_005_400_000 is 2023-11-14T23:43:20Z: a distinct event still inside the same UTC day. + let thirdEvent = #""" + {"timestamp":"1700005400000","model":"gpt-5","tokenUsage":{"inputTokens":1,"outputTokens":1,"cacheWriteTokens":0,"cacheReadTokens":0,"totalCents":25},"chargedCents":8} + """# + // swiftlint:enable line_length + + let transport = ProviderHTTPTransportStub { request in + switch Self.requestedPage(request) { + case 1: + // Full page of two distinct events; total signals one more remains. + Self.httpResponse(""" + {"totalUsageEventsCount":3,"usageEventsDisplay":[\(firstEvent),\(secondEvent)]} + """) + case 2: + // Second event repeats (must dedupe) alongside one new event. + Self.httpResponse(""" + {"totalUsageEventsCount":3,"usageEventsDisplay":[\(secondEvent),\(thirdEvent)]} + """) + default: + Self.httpResponse(#"{"totalUsageEventsCount":3,"usageEventsDisplay":[]}"#) + } + } + + let fetcher = CursorUsageEventsFetcher( + baseURL: Self.baseURL, + transport: transport, + pageSize: 2) + let result = try await fetcher.fetchUsage( + cookieHeader: "WorkosCursorSessionToken=abc", + since: nil, + until: nil, + calendar: Self.utcCalendar) + + // Three unique events across one UTC day -> one entry with two models. + #expect(result.daily.data.count == 1) + #expect(result.daily.data[0].requestCount == 3) + #expect(Self.approxEqual(result.daily.data[0].costUSD, 1.75)) + // Metered total dedupes the same way: (4 + 4 + 8) cents -> $0.16. + #expect(Self.approxEqual(result.meteredCostUSD, 0.16)) + + let requests = await transport.requests() + #expect(requests.count == 3) + for request in requests { + #expect(request.httpMethod == "POST") + #expect(request.url?.path == "/api/dashboard/get-filtered-usage-events") + #expect(request.value(forHTTPHeaderField: "Origin") == "https://cursor.test") + #expect(request.value(forHTTPHeaderField: "Cookie") == "WorkosCursorSessionToken=abc") + let body = try #require(request.httpBody) + let fields = try #require(JSONSerialization.jsonObject(with: body) as? [String: Any]) + #expect(fields["teamId"] == nil) + } + } + + @Test + func `pagination preserves rows with matching tokens but distinct billing fields`() async throws { + // swiftlint:disable line_length + let first = #"{"timestamp":"1700000000000","model":"gpt-5","kind":"USAGE_EVENT_KIND_USAGE_BASED","owningUser":"42","tokenUsage":{"inputTokens":10,"outputTokens":5,"cacheWriteTokens":0,"cacheReadTokens":0,"totalCents":50},"chargedCents":4}"# + let second = #"{"timestamp":"1700000000000","model":"gpt-5","kind":"USAGE_EVENT_KIND_USAGE_BASED","owningUser":"42","tokenUsage":{"inputTokens":10,"outputTokens":5,"cacheWriteTokens":0,"cacheReadTokens":0,"totalCents":75},"chargedCents":8}"# + // swiftlint:enable line_length + let transport = ProviderHTTPTransportStub { request in + switch Self.requestedPage(request) { + case 1: + Self.httpResponse("{\"totalUsageEventsCount\":2,\"usageEventsDisplay\":[\(first)]}") + case 2: + Self.httpResponse("{\"totalUsageEventsCount\":2,\"usageEventsDisplay\":[\(second)]}") + default: + Self.httpResponse(#"{"totalUsageEventsCount":2,"usageEventsDisplay":[]}"#) + } + } + let fetcher = CursorUsageEventsFetcher( + baseURL: Self.baseURL, + transport: transport, + pageSize: 1, + maxPages: 3) + + let result = try await fetcher.fetchUsage( + cookieHeader: "WorkosCursorSessionToken=abc", + since: nil, + until: nil, + calendar: Self.utcCalendar) + + #expect(result.daily.data.first?.requestCount == 2) + #expect(Self.approxEqual(result.daily.data.first?.costUSD, 1.25)) + #expect(Self.approxEqual(result.meteredCostUSD, 0.12)) + } + + @Test + func `pagination preserves identical rows when the reported count includes both`() async throws { + // swiftlint:disable line_length + let event = #"{"timestamp":"1700000000000","model":"gpt-5","tokenUsage":{"inputTokens":10,"outputTokens":5,"cacheWriteTokens":0,"cacheReadTokens":0,"totalCents":50},"chargedCents":4}"# + // swiftlint:enable line_length + let transport = ProviderHTTPTransportStub { request in + if Self.requestedPage(request) <= 2 { + return Self.httpResponse("{\"totalUsageEventsCount\":2,\"usageEventsDisplay\":[\(event)]}") + } + return Self.httpResponse(#"{"totalUsageEventsCount":2,"usageEventsDisplay":[]}"#) + } + let fetcher = CursorUsageEventsFetcher( + baseURL: Self.baseURL, + transport: transport, + pageSize: 1, + maxPages: 3) + + let result = try await fetcher.fetchUsage( + cookieHeader: "WorkosCursorSessionToken=abc", + since: nil, + until: nil, + calendar: Self.utcCalendar) + + #expect(result.daily.data.first?.requestCount == 2) + #expect(Self.approxEqual(result.daily.data.first?.costUSD, 1.0)) + #expect(Self.approxEqual(result.meteredCostUSD, 0.08)) + } + + @Test + func `pagination fails closed when a full safety cap page reaches the raw total`() async { + // swiftlint:disable line_length + let first = #"{"timestamp":"1700000000000","model":"gpt-5","tokenUsage":{"inputTokens":10,"outputTokens":5,"cacheWriteTokens":0,"cacheReadTokens":0,"totalCents":50},"chargedCents":4}"# + let second = #"{"timestamp":"1700000001000","model":"gpt-5","tokenUsage":{"inputTokens":20,"outputTokens":5,"cacheWriteTokens":0,"cacheReadTokens":0,"totalCents":75},"chargedCents":8}"# + let third = #"{"timestamp":"1700000002000","model":"gpt-5","tokenUsage":{"inputTokens":30,"outputTokens":5,"cacheWriteTokens":0,"cacheReadTokens":0,"totalCents":100},"chargedCents":12}"# + // swiftlint:enable line_length + let transport = ProviderHTTPTransportStub { request in + switch Self.requestedPage(request) { + case 1: + Self.httpResponse("{\"totalUsageEventsCount\":4,\"usageEventsDisplay\":[\(first),\(second)]}") + default: + Self.httpResponse("{\"totalUsageEventsCount\":4,\"usageEventsDisplay\":[\(second),\(third)]}") + } + } + let fetcher = CursorUsageEventsFetcher( + baseURL: Self.baseURL, + transport: transport, + pageSize: 2, + maxPages: 2) + + let error = await #expect(throws: CostUsageError.self) { + _ = try await fetcher.fetchUsage( + cookieHeader: "WorkosCursorSessionToken=abc", + since: nil, + until: nil, + calendar: Self.utcCalendar) + } + guard case let .cursorPaginationIncomplete(expected, received) = error else { + Issue.record("Expected cursorPaginationIncomplete") + return + } + #expect(expected == 4) + #expect(received == 4) + } + + @Test + func `pagination fails closed when the reported total changes between pages`() async { + // swiftlint:disable line_length + let first = #"{"timestamp":"1700000000000","model":"gpt-5","tokenUsage":{"inputTokens":10,"outputTokens":5,"cacheWriteTokens":0,"cacheReadTokens":0,"totalCents":50},"chargedCents":4}"# + let second = #"{"timestamp":"1700000001000","model":"gpt-5","tokenUsage":{"inputTokens":20,"outputTokens":5,"cacheWriteTokens":0,"cacheReadTokens":0,"totalCents":75},"chargedCents":8}"# + // swiftlint:enable line_length + let transport = ProviderHTTPTransportStub { request in + if Self.requestedPage(request) == 1 { + return Self.httpResponse("{\"totalUsageEventsCount\":1,\"usageEventsDisplay\":[\(first)]}") + } + return Self.httpResponse("{\"totalUsageEventsCount\":2,\"usageEventsDisplay\":[\(second)]}") + } + let fetcher = CursorUsageEventsFetcher( + baseURL: Self.baseURL, + transport: transport, + pageSize: 1, + maxPages: 2) + + let error = await #expect(throws: CostUsageError.self) { + _ = try await fetcher.fetchUsage( + cookieHeader: "WorkosCursorSessionToken=abc", + since: nil, + until: nil, + calendar: Self.utcCalendar) + } + guard case let .cursorPaginationInconsistent(expected, received) = error else { + Issue.record("Expected cursorPaginationInconsistent") + return + } + #expect(expected == 1) + #expect(received == 2) + } + + @Test + func `cost report carries the exact fetched credential scope`() async throws { + let transport = ProviderHTTPTransportStub { _ in + Self.httpResponse(#"{"totalUsageEventsCount":0,"usageEventsDisplay":[]}"#) + } + let probe = CursorStatusProbe( + baseURL: Self.baseURL, + timeout: 1, + browserDetection: BrowserDetection(cacheTTL: 0), + urlSession: transport) + let cookie = "WorkosCursorSessionToken=abc" + + let report = try await probe.fetchCostReport( + since: nil, + until: nil, + cookieHeaderOverride: cookie) + + #expect(report.credentialScopeFingerprint == CookieHeaderCache.credentialFingerprint(cookie)) + } + + @Test + func `fetchUsage fails instead of publishing a truncated pagination window`() async { + // swiftlint:disable line_length + let event = #"{"timestamp":"1700000000000","model":"gpt-5","tokenUsage":{"inputTokens":10,"outputTokens":5,"cacheWriteTokens":0,"cacheReadTokens":0,"totalCents":50},"chargedCents":4}"# + // swiftlint:enable line_length + let transport = ProviderHTTPTransportStub { _ in + Self.httpResponse("{\"totalUsageEventsCount\":2,\"usageEventsDisplay\":[\(event)]}") + } + let fetcher = CursorUsageEventsFetcher( + baseURL: Self.baseURL, + transport: transport, + pageSize: 1, + maxPages: 1) + + let error = await #expect(throws: CostUsageError.self) { + _ = try await fetcher.fetchUsage( + cookieHeader: "WorkosCursorSessionToken=abc", + since: nil, + until: nil, + calendar: Self.utcCalendar) + } + guard case let .cursorPaginationIncomplete(expected, received) = error else { + Issue.record("Expected cursorPaginationIncomplete") + return + } + #expect(expected == 2) + #expect(received == 1) + } + + @Test + func `fetchUsage reports nil metered total when events omit chargedCents`() async throws { + // swiftlint:disable line_length + let event = #""" + {"timestamp":"1700000000000","model":"gpt-5","tokenUsage":{"inputTokens":10,"outputTokens":5,"cacheWriteTokens":0,"cacheReadTokens":0,"totalCents":50}} + """# + // swiftlint:enable line_length + let transport = ProviderHTTPTransportStub { _ in + Self.httpResponse("{\"totalUsageEventsCount\":1,\"usageEventsDisplay\":[\(event)]}") + } + + let fetcher = CursorUsageEventsFetcher(baseURL: Self.baseURL, transport: transport, pageSize: 2) + let result = try await fetcher.fetchUsage( + cookieHeader: "WorkosCursorSessionToken=abc", + since: nil, + until: nil, + calendar: Self.utcCalendar) + + #expect(result.meteredCostUSD == nil) + #expect(Self.approxEqual(result.daily.data.first?.costUSD, 0.50)) + } + + @Test + func `fetchUsage surfaces not logged in on 401`() async { + let transport = ProviderHTTPTransportStub { _ in + Self.httpResponse(#"{"error":"unauthorized"}"#, statusCode: 401) + } + let fetcher = CursorUsageEventsFetcher(baseURL: Self.baseURL, transport: transport) + + let error = await #expect(throws: CursorStatusProbeError.self) { + _ = try await fetcher.fetchUsage(cookieHeader: "x=y", since: nil, until: nil) + } + let isNotLoggedIn = error.map { thrown in + if case .notLoggedIn = thrown { + return true + } + return false + } ?? false + #expect(isNotLoggedIn) + } + + @Test + func `fetchUsage preserves a 403 as a non authentication failure`() async { + let transport = ProviderHTTPTransportStub { _ in + Self.httpResponse(#"{"error":"forbidden"}"#, statusCode: 403) + } + let fetcher = CursorUsageEventsFetcher(baseURL: Self.baseURL, transport: transport) + + let error = await #expect(throws: CursorStatusProbeError.self) { + _ = try await fetcher.fetchUsage(cookieHeader: "x=y", since: nil, until: nil) + } + guard case let .networkError(message) = error else { + Issue.record("Expected networkError") + return + } + #expect(message == "HTTP 403") + } + + @Test + func `cost fetcher reports Cursor as a supported token-snapshot provider`() { + #expect(CostUsageFetcher.supportsTokenSnapshot(.cursor)) + } +} +#endif diff --git a/Tests/CodexBarTests/DashboardSnapshotBuilderTests.swift b/Tests/CodexBarTests/DashboardSnapshotBuilderTests.swift new file mode 100644 index 0000000000..60751dbbce --- /dev/null +++ b/Tests/CodexBarTests/DashboardSnapshotBuilderTests.swift @@ -0,0 +1,449 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBarCLI + +struct DashboardSnapshotBuilderTests { + @Test + func `builds stable display-oriented dashboard snapshot`() throws { + let generatedAt = Date(timeIntervalSince1970: 1_800_000_000) + let updatedAt = Date(timeIntervalSince1970: 1_800_000_010) + let costUpdatedAt = Date(timeIntervalSince1970: 1_800_000_020) + let resetAt = Date(timeIntervalSince1970: 1_800_003_600) + let generatedDay = self.gregorianDayKey(generatedAt) + let usage = UsageSnapshot( + primary: RateWindow( + usedPercent: 28, + windowMinutes: 300, + resetsAt: resetAt, + resetDescription: nil), + secondary: RateWindow( + usedPercent: 59, + windowMinutes: 10080, + resetsAt: nil, + resetDescription: nil), + tertiary: nil, + updatedAt: updatedAt, + identity: ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: "user@example.com", + accountOrganization: nil, + loginMethod: "pro")) + + let payload = ProviderPayload( + provider: .codex, + account: nil, + version: nil, + source: "oauth", + status: ProviderStatusPayload( + indicator: .none, + description: "Operational", + updatedAt: updatedAt, + url: "https://status.example.com"), + usage: usage, + credits: CreditsSnapshot(remaining: 112.4, events: [], updatedAt: updatedAt), + antigravityPlanInfo: nil, + openaiDashboard: nil, + error: nil) + let cost = CostPayload( + provider: "codex", + source: "local", + updatedAt: costUpdatedAt, + sessionTokens: 1000, + sessionCostUSD: 1.04, + historyDays: 30, + last30DaysTokens: 30000, + last30DaysCostUSD: 18.22, + daily: [CostDailyEntryPayload( + date: generatedDay, + inputTokens: nil, + outputTokens: nil, + cacheReadTokens: nil, + cacheCreationTokens: nil, + totalTokens: 1000, + costUSD: 1.04, + modelsUsed: nil, + modelBreakdowns: nil)], + totals: nil, + error: nil) + let config = CodexBarConfig(providers: [ + ProviderConfig(id: .codex, enabled: true), + ProviderConfig(id: .claude, enabled: false), + ]) + + let snapshot = DashboardSnapshotBuilder.makeSnapshot( + usagePayloads: [payload], + costPayloads: [cost], + config: config, + identityMode: .redacted, + generatedAt: generatedAt, + refreshInterval: 60, + codexBarVersion: "9.8.7") + let object = try self.jsonObject(snapshot) + let provider = try #require((object["providers"] as? [[String: Any]])?.first) + let host = try #require(object["host"] as? [String: Any]) + let identity = try #require(provider["identity"] as? [String: Any]) + let status = try #require(provider["status"] as? [String: Any]) + let windows = try #require(provider["windows"] as? [[String: Any]]) + let credits = try #require(provider["credits"] as? [String: Any]) + let costObject = try #require(provider["cost"] as? [String: Any]) + let display = try #require(provider["display"] as? [String: Any]) + + #expect(object["schemaVersion"] as? Int == 1) + #expect(object["staleAfterSeconds"] as? Int == 180) + #expect(host["codexBarVersion"] as? String == "9.8.7") + #expect(host["refreshIntervalSeconds"] as? Int == 60) + + #expect(provider["id"] as? String == "codex") + #expect(provider["name"] as? String == "Codex") + #expect(provider["enabled"] as? Bool == true) + #expect(provider["source"] as? String == "oauth") + #expect(provider["error"] is NSNull) + #expect(provider["updatedAt"] as? String == "2027-01-15T08:00:20Z") + + #expect(status["level"] as? String == "ok") + #expect(status["label"] as? String == "Operational") + #expect(identity["accountEmail"] as? String == "redacted@example.com") + #expect(identity["plan"] as? String == "Pro 20x") + + #expect(windows.count == 2) + #expect(windows[0]["kind"] as? String == "session") + #expect(windows[0]["label"] as? String == "Session") + #expect(windows[0]["usedPercent"] as? Double == 28) + #expect(windows[0]["remainingPercent"] as? Double == 72) + #expect(windows[0]["resetAt"] as? String == "2027-01-15T09:00:00Z") + #expect(windows[1]["kind"] as? String == "weekly") + #expect(windows[1]["label"] as? String == "Weekly") + + #expect(credits["remaining"] as? Double == 112.4) + #expect(credits["unit"] as? String == "credits") + #expect(costObject["todayUSD"] as? Double == 1.04) + #expect(costObject["last30DaysUSD"] as? Double == 18.22) + #expect(display["accentColor"] as? String == "#49A3B0") + #expect(display["sortKey"] as? Int == 0) + #expect(display["priority"] as? String == "normal") + } + + @Test + func `dashboard identity mode none emits null identity`() throws { + let usage = UsageSnapshot( + primary: nil, + secondary: nil, + tertiary: nil, + updatedAt: Date(timeIntervalSince1970: 0), + identity: ProviderIdentitySnapshot( + providerID: .claude, + accountEmail: "user@example.com", + accountOrganization: nil, + loginMethod: "pro")) + let payload = ProviderPayload( + provider: .claude, + account: nil, + version: nil, + source: "web", + status: nil, + usage: usage, + credits: nil, + antigravityPlanInfo: nil, + openaiDashboard: nil, + error: nil) + + let snapshot = DashboardSnapshotBuilder.makeSnapshot( + usagePayloads: [payload], + costPayloads: [], + config: CodexBarConfig(providers: [ProviderConfig(id: .claude, enabled: true)]), + identityMode: .none, + generatedAt: Date(timeIntervalSince1970: 0), + refreshInterval: 60, + codexBarVersion: nil) + let object = try self.jsonObject(snapshot) + let provider = try #require((object["providers"] as? [[String: Any]])?.first) + + #expect(provider["identity"] is NSNull) + #expect(provider["status"] is NSNull) + #expect(provider["credits"] is NSNull) + #expect(provider["cost"] is NSNull) + } + + @Test + func `dashboard labels amp subscription pools as provider specific windows`() throws { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let usage = AmpUsageSnapshot( + freeQuota: nil, + freeUsed: nil, + hourlyReplenishment: nil, + windowHours: nil, + updatedAt: now, + subscription: AmpSubscriptionUsage( + plan: "Megawatt", + otherUsedPercent: 3, + orbUsedPercent: 0, + resetsAt: now.addingTimeInterval(29 * 24 * 60 * 60), + resetDescription: "renews in 29 days")) + .toUsageSnapshot(now: now) + let payload = ProviderPayload( + provider: .amp, + account: nil, + version: nil, + source: "cli", + status: nil, + usage: usage, + credits: nil, + antigravityPlanInfo: nil, + openaiDashboard: nil, + error: nil) + + let snapshot = DashboardSnapshotBuilder.makeSnapshot( + usagePayloads: [payload], + costPayloads: [], + config: CodexBarConfig(providers: [ProviderConfig(id: .amp, enabled: true)]), + identityMode: .redacted, + generatedAt: now, + refreshInterval: 60, + codexBarVersion: nil) + let object = try self.jsonObject(snapshot) + let provider = try #require((object["providers"] as? [[String: Any]])?.first) + let windows = try #require(provider["windows"] as? [[String: Any]]) + + #expect(windows.map { $0["kind"] as? String } == ["other", "orb"]) + #expect(windows.map { $0["label"] as? String } == ["Other usage", "Orb usage"]) + } + + @Test + func `dashboard identity mode redacted hides local part but keeps domain`() throws { + let snapshot = DashboardSnapshotBuilder.makeSnapshot( + usagePayloads: [self.identityPayload(email: "user@example.com")], + costPayloads: [], + config: CodexBarConfig(providers: [ProviderConfig(id: .claude, enabled: true)]), + identityMode: .redacted, + generatedAt: Date(timeIntervalSince1970: 0), + refreshInterval: 60, + codexBarVersion: nil) + let domainless = DashboardSnapshotBuilder.makeSnapshot( + usagePayloads: [self.identityPayload(email: "not-an-email")], + costPayloads: [], + config: CodexBarConfig(providers: [ProviderConfig(id: .claude, enabled: true)]), + identityMode: .redacted, + generatedAt: Date(timeIntervalSince1970: 0), + refreshInterval: 60, + codexBarVersion: nil) + + let identity = try #require(self.firstIdentity(snapshot)) + let domainlessIdentity = try #require(self.firstIdentity(domainless)) + #expect(identity["accountEmail"] as? String == "redacted@example.com") + #expect(domainlessIdentity["accountEmail"] as? String == "redacted") + } + + @Test + func `dashboard redaction keeps only the final email domain`() throws { + let snapshot = DashboardSnapshotBuilder.makeSnapshot( + usagePayloads: [self.identityPayload(email: #""foo@bar"@example.com"#)], + costPayloads: [], + config: CodexBarConfig(providers: [ProviderConfig(id: .claude, enabled: true)]), + identityMode: .redacted, + generatedAt: Date(timeIntervalSince1970: 0), + refreshInterval: 60, + codexBarVersion: nil) + + let identity = try #require(self.firstIdentity(snapshot)) + #expect(identity["accountEmail"] as? String == "redacted@example.com") + } + + @Test + func `dashboard provider errors are projected without raw usage internals`() throws { + let payload = ProviderPayload( + provider: .codex, + account: nil, + version: nil, + source: "auto", + status: nil, + usage: nil, + credits: nil, + antigravityPlanInfo: nil, + openaiDashboard: nil, + error: ProviderErrorPayload(code: 1, message: "temporary failure", kind: .provider)) + + let snapshot = DashboardSnapshotBuilder.makeSnapshot( + usagePayloads: [payload], + costPayloads: [], + config: CodexBarConfig(providers: [ProviderConfig(id: .codex, enabled: true)]), + identityMode: .redacted, + generatedAt: Date(timeIntervalSince1970: 0), + refreshInterval: 60, + codexBarVersion: nil) + let object = try self.jsonObject(snapshot) + let provider = try #require((object["providers"] as? [[String: Any]])?.first) + let error = try #require(provider["error"] as? [String: Any]) + + #expect((provider["windows"] as? [Any])?.isEmpty == true) + #expect(error["message"] as? String == "temporary failure") + #expect(provider["usage"] == nil) + #expect(provider["openaiDashboard"] == nil) + } + + @Test + func `dashboard surfaces cost failures when usage succeeds`() throws { + let usage = self.identityPayload(email: "user@example.com") + let cost = CostPayload( + provider: "claude", + source: "local", + updatedAt: Date(timeIntervalSince1970: 10), + sessionTokens: nil, + sessionCostUSD: nil, + historyDays: nil, + last30DaysTokens: nil, + last30DaysCostUSD: nil, + daily: [], + totals: nil, + error: ProviderErrorPayload(code: 1, message: "cost unavailable", kind: .provider)) + + let snapshot = DashboardSnapshotBuilder.makeSnapshot( + usagePayloads: [usage], + costPayloads: [cost], + config: CodexBarConfig(providers: [ProviderConfig(id: .claude, enabled: true)]), + identityMode: .redacted, + generatedAt: Date(timeIntervalSince1970: 20), + refreshInterval: 60, + codexBarVersion: nil) + let object = try self.jsonObject(snapshot) + let provider = try #require((object["providers"] as? [[String: Any]])?.first) + let error = try #require(provider["error"] as? [String: Any]) + + #expect(error["message"] as? String == "cost unavailable") + #expect(provider["updatedAt"] as? String == "1970-01-01T00:00:10Z") + } + + @Test + func `dashboard provider freshness includes status updates`() throws { + let payload = ProviderPayload( + provider: .claude, + account: nil, + version: nil, + source: "status", + status: ProviderStatusPayload( + indicator: .none, + description: "Operational", + updatedAt: Date(timeIntervalSince1970: 30), + url: "https://status.anthropic.com"), + usage: nil, + credits: nil, + antigravityPlanInfo: nil, + openaiDashboard: nil, + error: nil) + + let snapshot = DashboardSnapshotBuilder.makeSnapshot( + usagePayloads: [payload], + costPayloads: [], + config: CodexBarConfig(providers: [ProviderConfig(id: .claude, enabled: true)]), + identityMode: .redacted, + generatedAt: Date(timeIntervalSince1970: 40), + refreshInterval: 60, + codexBarVersion: nil) + let object = try self.jsonObject(snapshot) + let provider = try #require((object["providers"] as? [[String: Any]])?.first) + + #expect(provider["updatedAt"] as? String == "1970-01-01T00:00:30Z") + } + + @Test + func `dashboard safely clamps extreme refresh intervals`() { + let snapshot = DashboardSnapshotBuilder.makeSnapshot( + usagePayloads: [], + costPayloads: [], + config: CodexBarConfig(providers: []), + identityMode: .redacted, + generatedAt: Date(timeIntervalSince1970: 0), + refreshInterval: .greatestFiniteMagnitude, + codexBarVersion: nil) + + #expect(snapshot.host.refreshIntervalSeconds == Int.max / 3) + #expect(snapshot.staleAfterSeconds == (Int.max / 3) * 3) + } + + @Test + func `dashboard daily cost uses generation day without update metadata`() throws { + let generatedAt = Date(timeIntervalSince1970: 1_800_000_000) + let usage = self.identityPayload(email: "user@example.com") + let cost = CostPayload( + provider: "claude", + source: "local", + updatedAt: nil, + sessionTokens: nil, + sessionCostUSD: nil, + historyDays: 1, + last30DaysTokens: nil, + last30DaysCostUSD: nil, + daily: [CostDailyEntryPayload( + date: self.gregorianDayKey(generatedAt), + inputTokens: nil, + outputTokens: nil, + cacheReadTokens: nil, + cacheCreationTokens: nil, + totalTokens: nil, + costUSD: 2.5, + modelsUsed: nil, + modelBreakdowns: nil)], + totals: nil, + error: nil) + + let snapshot = DashboardSnapshotBuilder.makeSnapshot( + usagePayloads: [usage], + costPayloads: [cost], + config: CodexBarConfig(providers: [ProviderConfig(id: .claude, enabled: true)]), + identityMode: .redacted, + generatedAt: generatedAt, + refreshInterval: 60, + codexBarVersion: nil) + let object = try self.jsonObject(snapshot) + let provider = try #require((object["providers"] as? [[String: Any]])?.first) + let costObject = try #require(provider["cost"] as? [String: Any]) + + #expect(costObject["todayUSD"] as? Double == 2.5) + } + + private func identityPayload(email: String) -> ProviderPayload { + ProviderPayload( + provider: .claude, + account: nil, + version: nil, + source: "web", + status: nil, + usage: UsageSnapshot( + primary: nil, + secondary: nil, + tertiary: nil, + updatedAt: Date(timeIntervalSince1970: 0), + identity: ProviderIdentitySnapshot( + providerID: .claude, + accountEmail: email, + accountOrganization: nil, + loginMethod: "pro")), + credits: nil, + antigravityPlanInfo: nil, + openaiDashboard: nil, + error: nil) + } + + private func firstIdentity(_ snapshot: DashboardSnapshotPayload) -> [String: Any]? { + guard let object = try? self.jsonObject(snapshot) else { return nil } + let provider = (object["providers"] as? [[String: Any]])?.first + return provider?["identity"] as? [String: Any] + } + + private func gregorianDayKey(_ date: Date) -> String { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = .current + let components = calendar.dateComponents([.year, .month, .day], from: date) + return String( + format: "%04d-%02d-%02d", + components.year ?? 0, + components.month ?? 0, + components.day ?? 0) + } + + private func jsonObject(_ payload: some Encodable) throws -> [String: Any] { + let json = try #require(CodexBarCLI.encodeJSON(payload, pretty: false)) + let data = try #require(json.data(using: .utf8)) + return try #require(JSONSerialization.jsonObject(with: data) as? [String: Any]) + } +} diff --git a/Tests/CodexBarTests/DeepInfraSettingsReaderTests.swift b/Tests/CodexBarTests/DeepInfraSettingsReaderTests.swift new file mode 100644 index 0000000000..e9cb6e52f1 --- /dev/null +++ b/Tests/CodexBarTests/DeepInfraSettingsReaderTests.swift @@ -0,0 +1,62 @@ +import CodexBarCore +import Testing + +struct DeepInfraSettingsReaderTests { + @Test + func `reads DEEPINFRA_API_KEY`() { + let env = ["DEEPINFRA_API_KEY": "di-primary"] + #expect(DeepInfraSettingsReader.apiKey(environment: env) == "di-primary") + } + + @Test + func `falls back to DEEPINFRA_TOKEN`() { + let env = ["DEEPINFRA_TOKEN": "di-fallback"] + #expect(DeepInfraSettingsReader.apiKey(environment: env) == "di-fallback") + } + + @Test + func `primary key takes precedence and is cleaned`() { + let env = [ + "DEEPINFRA_API_KEY": " \"di-primary\" ", + "DEEPINFRA_TOKEN": "di-fallback", + ] + #expect(DeepInfraSettingsReader.apiKey(environment: env) == "di-primary") + } + + @Test + func `returns nil when keys are empty`() { + let env = ["DEEPINFRA_API_KEY": " ", "DEEPINFRA_TOKEN": ""] + #expect(DeepInfraSettingsReader.apiKey(environment: env) == nil) + } +} + +struct DeepInfraProviderTokenResolverTests { + @Test + func `resolves DeepInfra key from environment`() { + let resolution = ProviderTokenResolver.deepInfraResolution( + environment: ["DEEPINFRA_API_KEY": "di-resolve"]) + #expect(resolution?.token == "di-resolve") + #expect(resolution?.source == .environment) + } + + @Test + func `descriptor registers API strategy and branding`() { + let descriptor = ProviderDescriptorRegistry.descriptor(for: .deepinfra) + #expect(descriptor.metadata.displayName == "DeepInfra") + #expect(descriptor.metadata.dashboardURL == "https://deepinfra.com/dash") + #expect(descriptor.metadata.statusLinkURL == "https://status.deepinfra.com") + #expect(descriptor.branding.iconResourceName == "ProviderIcon-deepinfra") + #expect(descriptor.branding.confettiPalette.count == 3) + #expect(descriptor.branding.confettiPalette[0] != descriptor.branding.confettiPalette[1]) + #expect(descriptor.fetchPlan.sourceModes == Set([.auto, .api])) + } + + @Test + func `provider config projects API key into environment`() { + let environment = ProviderConfigEnvironment.applyAPIKeyOverride( + base: [:], + provider: .deepinfra, + config: ProviderConfig(id: .deepinfra, apiKey: "config-token")) + #expect(environment[DeepInfraSettingsReader.apiKeyEnvironmentKey] == "config-token") + } +} diff --git a/Tests/CodexBarTests/DeepInfraUsageFetcherTests.swift b/Tests/CodexBarTests/DeepInfraUsageFetcherTests.swift new file mode 100644 index 0000000000..8b0369b492 --- /dev/null +++ b/Tests/CodexBarTests/DeepInfraUsageFetcherTests.swift @@ -0,0 +1,182 @@ +import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif +import Testing +@testable import CodexBarCore + +struct DeepInfraUsageFetcherTests { + @Test + func `converts monthly cents and deducts recent usage from prepaid balance`() throws { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let snapshot = try DeepInfraUsageFetcher._parseSnapshotForTesting( + checklistData: Self.checklistData( + stripeBalance: -99.75, + recent: 3.94, + limit: 20), + usageData: Self.usageData(totalCostCents: 394), + now: now) + + #expect(abs(snapshot.availableBalanceUSD - 95.81) < 0.000_001) + #expect(snapshot.amountOwedUSD == 0) + #expect(abs(snapshot.currentMonthCostUSD - 3.94) < 0.000_001) + #expect(snapshot.recentCostUSD == 3.94) + #expect(snapshot.spendingLimitUSD == 20) + #expect(snapshot.updatedAt == now) + + let usage = snapshot.toUsageSnapshot() + #expect(usage.primary?.usedPercent == 0) + #expect(usage.primary?.remainingPercent == 100) + #expect(usage.primary?.resetDescription == "$95.81 available · $3.94 spent this month") + #expect(usage.providerCost?.used == 3.94) + #expect(usage.providerCost?.limit == 20) + #expect(usage.providerCost?.period == "Billing cycle") + #expect(usage.identity?.providerID == .deepinfra) + #expect(usage.dataConfidence == .exact) + } + + @Test + func `positive Stripe balance is reported as amount owed`() throws { + let snapshot = try DeepInfraUsageFetcher._parseSnapshotForTesting( + checklistData: Self.checklistData( + stripeBalance: 2.75, + recent: 7, + limit: -1), + usageData: Self.usageData(totalCostCents: 650)) + + #expect(snapshot.availableBalanceUSD == 0) + #expect(snapshot.amountOwedUSD == 9.75) + #expect(snapshot.spendingLimitUSD == nil) + #expect(snapshot.toUsageSnapshot().primary?.remainingPercent == 0) + #expect(snapshot.toUsageSnapshot().primary?.resetDescription == "$9.75 owed · $6.50 spent this month") + #expect(snapshot.toUsageSnapshot().providerCost == nil) + } + + @Test + func `suspended account is marked exhausted`() throws { + let snapshot = try DeepInfraUsageFetcher._parseSnapshotForTesting( + checklistData: Self.checklistData( + stripeBalance: -5, + recent: 1, + limit: nil, + suspended: true, + suspendReason: "Payment review"), + usageData: Self.usageData(totalCostCents: 100)) + .toUsageSnapshot() + + #expect(snapshot.primary?.usedPercent == 100) + #expect(snapshot.primary?.resetDescription?.hasPrefix("Suspended: Payment review") == true) + } + + @Test + func `fetches checklist then current usage with bearer token`() async throws { + let recorder = RequestRecorder() + let transport = ProviderHTTPTransportHandler { request in + await recorder.append(request) + let path = request.url?.path + let data = if path == "/payment/checklist" { + Self.checklistData(stripeBalance: -9, recent: 2, limit: 10) + } else { + Self.usageData(totalCostCents: 150) + } + let response = try HTTPURLResponse( + url: #require(request.url), + statusCode: 200, + httpVersion: nil, + headerFields: nil)! + return (data, response) + } + + let snapshot = try await DeepInfraUsageFetcher._fetchUsageForTesting( + apiKey: "fixture-token", + transport: transport) + let requests = await recorder.values + + #expect(snapshot.availableBalanceUSD == 7) + #expect(requests.map(\.url?.path) == ["/payment/checklist", "/payment/usage"]) + #expect(requests.allSatisfy { $0.url?.scheme == "https" }) + #expect(requests.allSatisfy { $0.url?.host == "api.deepinfra.com" }) + #expect(requests[0].url?.query == "compute_owed=true") + #expect(requests[1].url?.query == "from=current") + #expect(requests.allSatisfy { $0.value(forHTTPHeaderField: "Authorization") == "Bearer fixture-token" }) + #expect(requests.allSatisfy { $0.timeoutInterval == 30 }) + } + + @Test + func `surfaces rejected API key as provider error`() async { + let transport = ProviderHTTPTransportHandler { request in + let response = try HTTPURLResponse( + url: #require(request.url), + statusCode: 401, + httpVersion: nil, + headerFields: nil)! + return (Data(), response) + } + + await #expect { + _ = try await DeepInfraUsageFetcher._fetchUsageForTesting( + apiKey: "rejected-token", + transport: transport) + } throws: { error in + guard case let DeepInfraUsageError.apiError(message) = error else { return false } + return message.contains("401") + } + } + + @Test + func `rejects malformed billing response`() { + #expect { + _ = try DeepInfraUsageFetcher._parseSnapshotForTesting( + checklistData: Data("{}".utf8), + usageData: Self.usageData(totalCostCents: 100)) + } throws: { error in + guard case DeepInfraUsageError.parseFailed = error else { return false } + return true + } + } + + private static func checklistData( + stripeBalance: Double, + recent: Double, + limit: Double?, + suspended: Bool = false, + suspendReason: String? = nil) -> Data + { + let limitJSON = limit.map { Swift.String($0) } ?? "null" + let reasonJSON = suspendReason.map { "\"\($0)\"" } ?? "null" + return Data( + """ + { + "stripe_balance": \(stripeBalance), + "recent": \(recent), + "limit": \(limitJSON), + "suspended": \(suspended), + "suspend_reason": \(reasonJSON) + } + """.utf8) + } + + private static func usageData(totalCostCents: Double) -> Data { + Data( + """ + { + "months": [ + { + "period": "2026.07", + "items": [], + "total_cost": \(totalCostCents) + } + ], + "initial_month": "2026.07" + } + """.utf8) + } +} + +private actor RequestRecorder { + private(set) var values: [URLRequest] = [] + + func append(_ request: URLRequest) { + self.values.append(request) + } +} diff --git a/Tests/CodexBarTests/DeepSeekPlatformTokenImporterTests.swift b/Tests/CodexBarTests/DeepSeekPlatformTokenImporterTests.swift new file mode 100644 index 0000000000..9c4f33cdcb --- /dev/null +++ b/Tests/CodexBarTests/DeepSeekPlatformTokenImporterTests.swift @@ -0,0 +1,281 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct DeepSeekPlatformTokenImporterTests { + @Test + func `extracts plain user token`() { + let token = "browser-user-token-1234567890" + #expect(DeepSeekPlatformTokenImporter._extractUserTokenForTesting(token) == token) + } + + @Test + func `extracts JSON encoded user token`() { + let token = "browser-user-token-abcdefghij" + let value = "{\"userToken\":\"\(token)\"}" + #expect(DeepSeekPlatformTokenImporter._extractUserTokenForTesting(value) == token) + } + + @Test + func `extracts DeepSeek value wrapped user token`() { + let token = "browser-user-token-value-wrapped" + let value = "{\"value\":\"\(token)\",\"expiresAt\":1234567890}" + #expect(DeepSeekPlatformTokenImporter._extractUserTokenForTesting(value) == token) + } + + @Test + func `does not treat an unrecognized JSON object as a token`() { + #expect(DeepSeekPlatformTokenImporter._extractUserTokenForTesting("{\"expiresAt\":1234567890}") == nil) + } + + @Test + func `rejects short or whitespace values`() { + #expect(DeepSeekPlatformTokenImporter._extractUserTokenForTesting("short") == nil) + #expect(DeepSeekPlatformTokenImporter._extractUserTokenForTesting("token with embedded spaces 12345") == nil) + } + + #if os(macOS) + @Test + func `imports platform token through browser local storage host API`() { + let localStorage = BrowserLocalStorageAPI { _, _, _, _ in + [ + BrowserLocalStorageAPI.Profile( + id: "chrome:Profile 2", + label: "Chrome — Work", + entries: [ + BrowserLocalStorageAPI.Entry( + key: "userToken", + value: "browser-user-token-through-host-api"), + ]), + ] + } + + let tokens = DeepSeekPlatformTokenImporter.importTokens( + browserDetection: BrowserDetection(cacheTTL: 0), + localStorage: localStorage) + + #expect(tokens.map(\.id) == ["chrome:Profile 2"]) + #expect(tokens.map(\.sourceLabel) == ["Chrome — Work"]) + } + #endif + + @Test + func `multiple profiles expose only server accepted sessions`() async { + let candidates = [ + Self.candidate(id: "profile-1", token: "valid-1"), + Self.candidate(id: "profile-2", token: "expired"), + Self.candidate(id: "profile-3", token: "valid-3"), + ] + + let resolution = await DeepSeekPlatformTokenImporter._resolveForTesting( + candidates: candidates, + selectedProfileID: nil, + validate: { token in + guard token != "expired" else { throw DeepSeekUsageError.invalidPlatformToken } + return Self.summary(marker: token == "valid-1" ? 1 : 3) + }) + + #expect(resolution.profiles.map(\.id) == ["profile-1", "profile-3"]) + #expect(resolution.selectedSummary == nil) + #expect(resolution.detailedUsageState == .profileSelectionRequired) + } + + @Test + func `single accepted profile is selected automatically`() async { + let candidates = [ + Self.candidate(id: "profile-1", token: "expired-1"), + Self.candidate(id: "profile-2", token: "valid-2"), + Self.candidate(id: "profile-3", token: "expired-3"), + ] + + let resolution = await DeepSeekPlatformTokenImporter._resolveForTesting( + candidates: candidates, + selectedProfileID: nil, + validate: { token in + guard token == "valid-2" else { throw DeepSeekUsageError.invalidPlatformToken } + return Self.summary(marker: 2) + }) + + #expect(resolution.profiles.map(\.id) == ["profile-2"]) + #expect(resolution.selectedSummary?.todayTokens == 2) + #expect(resolution.detailedUsageState == .available) + } + + @Test + func `selected profile preserves its detailed usage state`() async { + let resolution = await DeepSeekPlatformTokenImporter._resolveForTesting( + candidates: [Self.candidate(id: "profile-1", token: "valid-1")], + selectedProfileID: nil, + detailedUsageState: .notRequested, + validate: { _ in Self.summary(marker: 1) }) + + #expect(resolution.selectedSummary?.todayTokens == 1) + #expect(resolution.detailedUsageState == .notRequested) + } + + @Test + func `explicit selection requirement does not auto select a single accepted profile`() async { + let resolution = await DeepSeekPlatformTokenImporter._resolveForTesting( + candidates: [Self.candidate(id: "profile-1", token: "valid-1")], + selectedProfileID: nil, + requiresExplicitSelection: true, + validate: { _ in Self.summary(marker: 1) }) + + #expect(resolution.profiles.map(\.id) == ["profile-1"]) + #expect(resolution.selectedSummary == nil) + #expect(resolution.detailedUsageState == .profileSelectionRequired) + } + + @Test + func `stored selection chooses one of multiple accepted profiles`() async { + let candidates = [ + Self.candidate(id: "profile-1", token: "valid-1"), + Self.candidate(id: "profile-2", token: "valid-2"), + ] + let cache = DeepSeekPlatformValidationCache() + _ = await DeepSeekPlatformTokenImporter._resolveForTesting( + candidates: candidates, + selectedProfileID: nil, + cache: cache, + validate: { token in + Self.summary(marker: token == "valid-1" ? 1 : 2) + }) + + let resolution = await DeepSeekPlatformTokenImporter._resolveForTesting( + candidates: candidates, + selectedProfileID: "profile-2", + cache: cache, + validate: { token in + Self.summary(marker: token == "valid-1" ? 1 : 2) + }) + + #expect(resolution.profiles.map(\.id) == ["profile-1", "profile-2"]) + #expect(resolution.selectedSummary?.todayTokens == 2) + #expect(resolution.detailedUsageState == .available) + } + + @Test + func `stored selection does not wait for unrelated profile validation`() async { + let gate = DeepSeekPlatformValidationGate() + let fallbackRelease = Task { + try? await Task.sleep(for: .seconds(1)) + await gate.open() + } + let startedAt = ContinuousClock.now + + let resolution = await DeepSeekPlatformTokenImporter._resolveForTesting( + candidates: [ + Self.candidate(id: "profile-1", token: "selected"), + Self.candidate(id: "profile-2", token: "unselected"), + ], + selectedProfileID: "profile-1", + validate: { token in + if token == "unselected" { + await gate.wait() + } + return Self.summary(marker: token == "selected" ? 1 : 2) + }) + + let elapsed = startedAt.duration(to: .now) + await gate.open() + fallbackRelease.cancel() + + #expect(elapsed < .milliseconds(500)) + #expect(resolution.profiles.map(\.id) == ["profile-1"]) + #expect(resolution.selectedSummary?.todayTokens == 1) + #expect(resolution.detailedUsageState == .available) + } + + @Test + func `expired stored selection does not silently switch to another profile`() async { + let candidates = [ + Self.candidate(id: "profile-1", token: "expired"), + Self.candidate(id: "profile-2", token: "valid-2"), + ] + + let resolution = await DeepSeekPlatformTokenImporter._resolveForTesting( + candidates: candidates, + selectedProfileID: "profile-1", + validate: { token in + guard token == "valid-2" else { throw DeepSeekUsageError.invalidPlatformToken } + return Self.summary(marker: 2) + }) + + #expect(resolution.profiles.map(\.id) == ["profile-2"]) + #expect(resolution.selectedSummary == nil) + #expect(resolution.detailedUsageState == .profileSelectionRequired) + } + + @Test + func `temporary validation failure is unavailable rather than signed out`() async { + let resolution = await DeepSeekPlatformTokenImporter._resolveForTesting( + candidates: [Self.candidate(id: "profile-1", token: "maybe-valid")], + selectedProfileID: nil, + validate: { _ in throw DeepSeekUsageError.networkError("offline") }) + + #expect(resolution.profiles.isEmpty) + #expect(resolution.selectedSummary == nil) + #expect(resolution.detailedUsageState == .unavailable) + } + + @Test + func `temporary validation failure keeps a previously accepted profile`() async { + let candidate = Self.candidate(id: "profile-1", token: "valid-1") + let cache = DeepSeekPlatformValidationCache(validityTTL: 0) + _ = await DeepSeekPlatformTokenImporter._resolveForTesting( + candidates: [candidate], + selectedProfileID: nil, + cache: cache, + validate: { _ in Self.summary(marker: 1) }) + + let resolution = await DeepSeekPlatformTokenImporter._resolveForTesting( + candidates: [candidate], + selectedProfileID: nil, + cache: cache, + validate: { _ in throw DeepSeekUsageError.networkError("offline") }) + + #expect(resolution.profiles.map(\.id) == ["profile-1"]) + #expect(resolution.selectedSummary == nil) + #expect(resolution.detailedUsageState == .unavailable) + } + + private static func candidate(id: String, token: String) -> DeepSeekPlatformTokenImporter.TokenInfo { + DeepSeekPlatformTokenImporter.TokenInfo(id: id, token: token, sourceLabel: "Chrome \(id)") + } + + private static func summary(marker: Int) -> DeepSeekUsageSummary { + DeepSeekUsageSummary( + todayTokens: marker, + currentMonthTokens: marker, + todayCost: nil, + currentMonthCost: nil, + requestCount: marker, + currentMonthRequestCount: marker, + topModel: nil, + categoryBreakdown: [], + daily: [], + currency: "USD", + updatedAt: Date(timeIntervalSince1970: 0)) + } +} + +private actor DeepSeekPlatformValidationGate { + private var isOpen = false + private var continuations: [CheckedContinuation] = [] + + func wait() async { + guard !self.isOpen else { return } + await withCheckedContinuation { continuation in + self.continuations.append(continuation) + } + } + + func open() { + self.isOpen = true + let continuations = self.continuations + self.continuations.removeAll() + for continuation in continuations { + continuation.resume() + } + } +} diff --git a/Tests/CodexBarTests/DeepSeekProfileTransitionTests.swift b/Tests/CodexBarTests/DeepSeekProfileTransitionTests.swift new file mode 100644 index 0000000000..c6d8e45a20 --- /dev/null +++ b/Tests/CodexBarTests/DeepSeekProfileTransitionTests.swift @@ -0,0 +1,84 @@ +import CodexBarCore +import Foundation +import SwiftUI +import Testing +@testable import CodexBar + +@MainActor +struct DeepSeekProfileTransitionTests { + @Test(arguments: [false, true]) + func `forced web profile transition clears stale balance with an api key`( + isCancellation: Bool) async throws + { + let apiKey = "test-deepseek-api-key" + let suite = "DeepSeekProfileTransitionTests-forced-web-\(isCancellation)" + 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.updateProviderConfig(provider: .deepseek) { $0.source = .web } + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + environmentBase: [DeepSeekSettingsReader.apiKeyEnvironmentKey: apiKey]) + store.snapshots[.deepseek] = UsageSnapshot( + primary: RateWindow( + usedPercent: 0, + windowMinutes: nil, + resetsAt: nil, + resetDescription: "$8.06 from previous profile"), + secondary: nil, + deepseekPlatformProfiles: [ + DeepSeekPlatformProfile(id: "chrome:Default", name: "Chrome — Personal"), + DeepSeekPlatformProfile(id: "chrome:Profile 2", name: "Chrome — Work"), + ], + updatedAt: Date()) + + let context = Self.settingsContext(settings: settings, store: store) + let picker = try #require(DeepSeekProviderImplementation().settingsPickers(context: context).first) + picker.binding.wrappedValue = "chrome:Profile 2" + + #expect(store.deepseekProfileTransitionSnapshot?.primary?.resetDescription == "Refreshing") + #expect(store.deepseekProfileTransitionSnapshot?.primary?.resetDescription?.contains("$8.06") == false) + + let outcome = if isCancellation { + ProviderFetchOutcome(result: .failure(CancellationError()), attempts: []) + } else { + ProviderFetchOutcome(result: .failure(DeepSeekUsageError.apiError("offline")), attempts: []) + } + await store.applySelectedOutcome(outcome, provider: .deepseek, account: nil, fallbackSnapshot: nil) + + #expect(store.deepseekProfileTransitionSnapshot?.primary?.resetDescription == "Unavailable") + #expect(store.deepseekProfileTransitionSnapshot?.primary?.resetDescription?.contains("$8.06") == false) + } + + private static func settingsContext( + settings: SettingsStore, + store: UsageStore) -> ProviderSettingsContext + { + ProviderSettingsContext( + provider: .deepseek, + settings: settings, + store: store, + boolBinding: { keyPath in + Binding( + get: { settings[keyPath: keyPath] }, + set: { settings[keyPath: keyPath] = $0 }) + }, + stringBinding: { keyPath in + Binding( + get: { settings[keyPath: keyPath] }, + set: { settings[keyPath: keyPath] = $0 }) + }, + statusText: { _ in nil }, + setStatusText: { _, _ in }, + lastAppActiveRunAt: { _ in nil }, + setLastAppActiveRunAt: { _, _ in }, + requestConfirmation: { _ in }, + runLoginFlow: {}) + } +} diff --git a/Tests/CodexBarTests/DeepSeekProviderDescriptorTests.swift b/Tests/CodexBarTests/DeepSeekProviderDescriptorTests.swift new file mode 100644 index 0000000000..570db4a534 --- /dev/null +++ b/Tests/CodexBarTests/DeepSeekProviderDescriptorTests.swift @@ -0,0 +1,500 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct DeepSeekProviderDescriptorTests { + private actor CancellationProbe { + private(set) var wasCancelled = false + + func markCancelled() { + self.wasCancelled = true + } + } + + private actor ResolutionInputProbe { + private(set) var profileID: String? + private(set) var requiresExplicitSelection = false + private(set) var includesPlatformBalance = false + private(set) var includesOptionalUsage = true + + func record( + profileID: String?, + requiresExplicitSelection: Bool, + includesPlatformBalance: Bool = false, + includesOptionalUsage: Bool = true) + { + self.profileID = profileID + self.requiresExplicitSelection = requiresExplicitSelection + self.includesPlatformBalance = includesPlatformBalance + self.includesOptionalUsage = includesOptionalUsage + } + } + + private actor UsageInputProbe { + private(set) var platformTokens: [String?] = [] + + func record(platformToken: String?) { + self.platformTokens.append(platformToken) + } + } + + @Test + func `balance failure cancels automatic session resolution promptly`() async { + let probe = CancellationProbe() + let operations = DeepSeekProviderDescriptor.FetchOperations( + fetchUsage: { _, _, _ in + throw DeepSeekUsageError.apiError("invalid key") + }, + resolveAutomaticSession: { _, _, _, _, _, _ in + do { + try await Task.sleep(for: .seconds(10)) + } catch { + await probe.markCancelled() + } + return Self.unavailableResolution + }) + let startedAt = ContinuousClock.now + + await #expect { + _ = try await DeepSeekProviderDescriptor._loadUsageForTesting( + apiKey: "invalid", + context: Self.makeContext(), + optionalResolutionJoinGrace: .seconds(5), + operations: operations) + } throws: { error in + error as? DeepSeekUsageError == .apiError("invalid key") + } + + #expect(startedAt.duration(to: .now) < .seconds(1)) + let cancellationDeadline = ContinuousClock.now.advanced(by: .milliseconds(200)) + while await !(probe.wasCancelled), ContinuousClock.now < cancellationDeadline { + await Task.yield() + } + #expect(await probe.wasCancelled) + } + + @Test + func `automatic session resolution cannot hold balance past its grace`() async throws { + let probe = CancellationProbe() + let operations = DeepSeekProviderDescriptor.FetchOperations( + fetchUsage: { _, _, _ in Self.balance }, + resolveAutomaticSession: { _, _, _, _, _, _ in + do { + try await Task.sleep(for: .seconds(10)) + } catch { + await probe.markCancelled() + } + return Self.unavailableResolution + }) + let startedAt = ContinuousClock.now + + let snapshot = try await DeepSeekProviderDescriptor._loadUsageForTesting( + apiKey: "valid", + context: Self.makeContext(), + optionalResolutionJoinGrace: .milliseconds(20), + operations: operations) + + #expect(snapshot.primary?.resetDescription?.contains("$8.06") == true) + #expect(snapshot.deepseekUsage == nil) + #expect(snapshot.deepseekDetailedUsageState == .unavailable) + #expect(startedAt.duration(to: .now) < .seconds(1)) + let cancellationDeadline = ContinuousClock.now.advanced(by: .milliseconds(200)) + while await !(probe.wasCancelled), ContinuousClock.now < cancellationDeadline { + await Task.yield() + } + #expect(await probe.wasCancelled) + } + + @Test + func `automatic session result enriches the required balance`() async throws { + let summary = DeepSeekUsageSummary( + todayTokens: 123, + currentMonthTokens: 456, + todayCost: 0.1, + currentMonthCost: 0.2, + requestCount: 3, + currentMonthRequestCount: 4, + topModel: "deepseek-chat", + categoryBreakdown: [], + daily: [], + currency: "USD", + updatedAt: Date(timeIntervalSince1970: 1)) + let operations = DeepSeekProviderDescriptor.FetchOperations( + fetchUsage: { _, _, _ in Self.balance }, + resolveAutomaticSession: { _, _, _, _, _, _ in + DeepSeekPlatformTokenImporter.Resolution( + profiles: [DeepSeekPlatformProfile(id: "chrome:Default", name: "Chrome — Personal")], + selectedSummary: summary, + detailedUsageState: .available) + }) + + let snapshot = try await DeepSeekProviderDescriptor._loadUsageForTesting( + apiKey: "valid", + context: Self.makeContext(), + optionalResolutionJoinGrace: .seconds(1), + operations: operations) + + #expect(snapshot.primary?.resetDescription?.contains("$8.06") == true) + #expect(snapshot.deepseekUsage?.todayTokens == 123) + #expect(snapshot.deepseekDetailedUsageState == .available) + #expect(snapshot.deepseekPlatformProfiles.map(\.id) == ["chrome:Default"]) + } + + @Test + func `automatic resolution timeout is hard when the resolver ignores cancellation`() async throws { + let operations = DeepSeekProviderDescriptor.FetchOperations( + fetchUsage: { _, _, _ in Self.balance }, + resolveAutomaticSession: { _, _, _, _, _, _ in + let deadline = ContinuousClock.now.advanced(by: .milliseconds(500)) + while ContinuousClock.now < deadline { + await Task.yield() + } + return Self.unavailableResolution + }) + let startedAt = ContinuousClock.now + + let snapshot = try await DeepSeekProviderDescriptor._loadUsageForTesting( + apiKey: "valid", + context: Self.makeContext(), + optionalResolutionJoinGrace: .milliseconds(20), + operations: operations) + + #expect(snapshot.primary?.resetDescription?.contains("$8.06") == true) + #expect(startedAt.duration(to: .now) < .milliseconds(200)) + } + + @Test + func `profile selection from another api account requires explicit replacement`() async throws { + let probe = ResolutionInputProbe() + let selectedAccountID = UUID() + let otherAccountID = UUID() + let operations = DeepSeekProviderDescriptor.FetchOperations( + fetchUsage: { _, _, _ in Self.balance }, + resolveAutomaticSession: { profileID, requiresExplicitSelection, _, _, _, _ in + await probe.record( + profileID: profileID, + requiresExplicitSelection: requiresExplicitSelection) + return Self.unavailableResolution + }) + let otherAccountScope = try #require(DeepSeekSettingsReader.profileScope( + selectedTokenAccountID: otherAccountID, + apiKey: "valid")) + let environment = [ + DeepSeekSettingsReader.profileIDEnvironmentKey: "chrome:Default", + DeepSeekSettingsReader.profileScopeEnvironmentKey: otherAccountScope, + ] + + _ = try await DeepSeekProviderDescriptor._loadUsageForTesting( + apiKey: "valid", + context: Self.makeContext(environment: environment, selectedTokenAccountID: selectedAccountID), + optionalResolutionJoinGrace: .seconds(1), + operations: operations) + + #expect(await probe.profileID == nil) + #expect(await probe.requiresExplicitSelection) + } + + @Test + func `replacing an api key in the same account requires explicit profile replacement`() async throws { + let probe = ResolutionInputProbe() + let selectedAccountID = UUID() + let operations = DeepSeekProviderDescriptor.FetchOperations( + fetchUsage: { _, _, _ in Self.balance }, + resolveAutomaticSession: { profileID, requiresExplicitSelection, _, _, _, _ in + await probe.record(profileID: profileID, requiresExplicitSelection: requiresExplicitSelection) + return Self.unavailableResolution + }) + let oldScope = try #require(DeepSeekSettingsReader.profileScope( + selectedTokenAccountID: selectedAccountID, + apiKey: "old-key")) + let environment = [ + DeepSeekSettingsReader.profileIDEnvironmentKey: "chrome:Default", + DeepSeekSettingsReader.profileScopeEnvironmentKey: oldScope, + ] + + _ = try await DeepSeekProviderDescriptor._loadUsageForTesting( + apiKey: "new-key", + context: Self.makeContext(environment: environment, selectedTokenAccountID: selectedAccountID), + optionalResolutionJoinGrace: .seconds(1), + operations: operations) + + #expect(await probe.profileID == nil) + #expect(await probe.requiresExplicitSelection) + } + + @Test + func `changing the environment api key requires explicit profile replacement`() async throws { + let probe = ResolutionInputProbe() + let operations = DeepSeekProviderDescriptor.FetchOperations( + fetchUsage: { _, _, _ in Self.balance }, + resolveAutomaticSession: { profileID, requiresExplicitSelection, _, _, _, _ in + await probe.record(profileID: profileID, requiresExplicitSelection: requiresExplicitSelection) + return Self.unavailableResolution + }) + let oldScope = try #require(DeepSeekSettingsReader.profileScope( + selectedTokenAccountID: nil, + apiKey: "old-key")) + let environment = [ + DeepSeekSettingsReader.profileIDEnvironmentKey: "chrome:Default", + DeepSeekSettingsReader.profileScopeEnvironmentKey: oldScope, + ] + + _ = try await DeepSeekProviderDescriptor._loadUsageForTesting( + apiKey: "new-key", + context: Self.makeContext(environment: environment), + optionalResolutionJoinGrace: .seconds(1), + operations: operations) + + #expect(await probe.profileID == nil) + #expect(await probe.requiresExplicitSelection) + } + + @Test + func `platform session from another account does not enrich api balance`() async throws { + let probe = UsageInputProbe() + let activeAccountID = UUID() + let credential = "api-key-value" + let otherAccountScope = try #require(DeepSeekSettingsReader.profileScope( + selectedTokenAccountID: UUID(), + apiKey: credential)) + let environment = [ + DeepSeekSettingsReader.platformTokenEnvironmentKey: "platform-session", + DeepSeekSettingsReader.profileScopeEnvironmentKey: otherAccountScope, + ] + let operations = DeepSeekProviderDescriptor.FetchOperations( + fetchUsage: { _, platformToken, _ in + await probe.record(platformToken: platformToken) + return Self.balance + }, + resolveAutomaticSession: { _, _, _, _, _, _ in Self.unavailableResolution }) + + _ = try await DeepSeekProviderDescriptor._loadUsageForTesting( + apiKey: credential, + context: Self.makeContext( + environment: environment, + selectedTokenAccountID: activeAccountID), + optionalResolutionJoinGrace: .seconds(1), + operations: operations) + + #expect(await probe.platformTokens == [nil]) + } + + @Test + func `browser only mode returns Platform balance and usage without an API key`() async throws { + let probe = ResolutionInputProbe() + let summary = DeepSeekUsageSummary( + todayTokens: 123, + currentMonthTokens: 456, + todayCost: 0.1, + currentMonthCost: 0.2, + requestCount: 3, + currentMonthRequestCount: 4, + topModel: "deepseek-chat", + categoryBreakdown: [], + daily: [], + currency: "USD", + updatedAt: Date(timeIntervalSince1970: 1)) + let operations = DeepSeekProviderDescriptor.FetchOperations( + fetchUsage: { _, _, _ in + throw DeepSeekUsageError.missingCredentials + }, + resolveAutomaticSession: { profileID, explicit, includeBalance, includeOptional, _, _ in + await probe.record( + profileID: profileID, + requiresExplicitSelection: explicit, + includesPlatformBalance: includeBalance, + includesOptionalUsage: includeOptional) + return DeepSeekPlatformTokenImporter.Resolution( + profiles: [DeepSeekPlatformProfile(id: "chrome:Default", name: "Chrome — Yuqing")], + selectedSummary: summary, + selectedBalance: Self.balance, + detailedUsageState: .available) + }) + + let snapshot = try await DeepSeekProviderDescriptor._loadPlatformUsageForTesting( + context: Self.makeContext(sourceMode: .auto), + operations: operations) + + #expect(snapshot.primary?.resetDescription?.contains("$8.06") == true) + #expect(snapshot.deepseekUsage == summary) + #expect(snapshot.deepseekDetailedUsageState == .available) + #expect(snapshot.deepseekPlatformProfiles.map(\.id) == ["chrome:Default"]) + #expect(await probe.profileID == nil) + #expect(await probe.requiresExplicitSelection == false) + #expect(await probe.includesPlatformBalance) + #expect(await probe.includesOptionalUsage) + } + + @Test + func `forced web mode preserves the active credential profile scope`() async throws { + let probe = ResolutionInputProbe() + let selectedAccountID = UUID() + let credential = "api-key-value" + let scope = try #require(DeepSeekSettingsReader.profileScope( + selectedTokenAccountID: selectedAccountID, + apiKey: credential)) + let environment = [ + DeepSeekSettingsReader.apiKeyEnvironmentKey: credential, + DeepSeekSettingsReader.profileIDEnvironmentKey: "chrome:Profile 2", + DeepSeekSettingsReader.profileScopeEnvironmentKey: scope, + ] + let operations = DeepSeekProviderDescriptor.FetchOperations( + fetchUsage: { _, _, _ in + throw DeepSeekUsageError.missingCredentials + }, + resolveAutomaticSession: { profileID, explicit, includeBalance, includeOptional, _, _ in + await probe.record( + profileID: profileID, + requiresExplicitSelection: explicit, + includesPlatformBalance: includeBalance, + includesOptionalUsage: includeOptional) + return DeepSeekPlatformTokenImporter.Resolution( + profiles: [DeepSeekPlatformProfile(id: "chrome:Profile 2", name: "Chrome — Work")], + selectedSummary: nil, + selectedBalance: Self.balance, + detailedUsageState: .available) + }) + + _ = try await DeepSeekProviderDescriptor._loadPlatformUsageForTesting( + context: Self.makeContext( + environment: environment, + selectedTokenAccountID: selectedAccountID, + sourceMode: .web), + operations: operations) + + #expect(await probe.profileID == "chrome:Profile 2") + #expect(await probe.requiresExplicitSelection == false) + #expect(await probe.includesPlatformBalance) + } + + @Test + func `browser only mode skips optional usage when extras are disabled`() async throws { + let probe = ResolutionInputProbe() + let operations = DeepSeekProviderDescriptor.FetchOperations( + fetchUsage: { _, _, _ in + throw DeepSeekUsageError.missingCredentials + }, + resolveAutomaticSession: { profileID, explicit, includeBalance, includeOptional, _, _ in + await probe.record( + profileID: profileID, + requiresExplicitSelection: explicit, + includesPlatformBalance: includeBalance, + includesOptionalUsage: includeOptional) + return DeepSeekPlatformTokenImporter.Resolution( + profiles: [DeepSeekPlatformProfile(id: "chrome:Default", name: "Chrome — Yuqing")], + selectedSummary: nil, + selectedBalance: Self.balance, + detailedUsageState: .notRequested) + }) + + let snapshot = try await DeepSeekProviderDescriptor._loadPlatformUsageForTesting( + context: Self.makeContext(sourceMode: .auto, includeOptionalUsage: false), + operations: operations) + + #expect(snapshot.primary != nil) + #expect(snapshot.deepseekUsage == nil) + #expect(snapshot.deepseekDetailedUsageState == .notRequested) + #expect(await probe.includesPlatformBalance) + #expect(await probe.includesOptionalUsage == false) + } + + @Test + func `browser only resolution timeout is hard when Chrome ignores cancellation`() async throws { + let operations = DeepSeekProviderDescriptor.FetchOperations( + fetchUsage: { _, _, _ in Self.balance }, + resolveAutomaticSession: { _, _, _, _, _, _ in + let deadline = ContinuousClock.now.advanced(by: .milliseconds(500)) + while ContinuousClock.now < deadline { + await Task.yield() + } + return Self.unavailableResolution + }) + let startedAt = ContinuousClock.now + + await #expect { + _ = try await DeepSeekProviderDescriptor._loadPlatformUsageForTesting( + context: Self.makeContext(sourceMode: .auto), + resolutionJoinGrace: .milliseconds(20), + operations: operations) + } throws: { error in + guard case let DeepSeekUsageError.networkError(message) = error else { return false } + return message.contains("timed out") + } + #expect(startedAt.duration(to: .now) < .milliseconds(200)) + } + + @Test + func `browser only mode asks for Chrome sign in instead of an API key`() async throws { + let operations = DeepSeekProviderDescriptor.FetchOperations( + fetchUsage: { _, _, _ in + throw DeepSeekUsageError.missingCredentials + }, + resolveAutomaticSession: { _, _, _, _, _, _ in + DeepSeekPlatformTokenImporter.Resolution( + profiles: [], + selectedSummary: nil, + detailedUsageState: .webSessionRequired) + }) + + let snapshot = try await DeepSeekProviderDescriptor._loadPlatformUsageForTesting( + context: Self.makeContext(sourceMode: .auto), + operations: operations) + + #expect(snapshot.primary == nil) + #expect(snapshot.deepseekDetailedUsageState == .webSessionRequired) + } + + @Test + func `automatic source uses Chrome session when API key is absent`() async { + let strategies = await DeepSeekProviderDescriptor.descriptor.fetchPlan.pipeline.resolveStrategies( + Self.makeContext(sourceMode: .auto)) + + #expect(strategies.map(\.id) == ["deepseek.web"]) + } + + @Test + func `automatic source keeps API path when API key is present`() async { + let strategies = await DeepSeekProviderDescriptor.descriptor.fetchPlan.pipeline.resolveStrategies( + Self.makeContext( + environment: [DeepSeekSettingsReader.apiKeyEnvironmentKey: "test-api-key"], + sourceMode: .auto)) + + #expect(strategies.map(\.id) == ["deepseek.api"]) + } + + private static let balance = DeepSeekUsageSnapshot( + isAvailable: true, + currency: "USD", + totalBalance: 8.06, + grantedBalance: 0, + toppedUpBalance: 8.06, + updatedAt: Date(timeIntervalSince1970: 1)) + + private static let unavailableResolution = DeepSeekPlatformTokenImporter.Resolution( + profiles: [], + selectedSummary: nil, + detailedUsageState: .unavailable) + + private static func makeContext( + environment: [String: String] = [:], + selectedTokenAccountID: UUID? = nil, + sourceMode: ProviderSourceMode = .api, + includeOptionalUsage: Bool = true) -> ProviderFetchContext + { + let browserDetection = BrowserDetection(cacheTTL: 0) + return ProviderFetchContext( + runtime: .app, + sourceMode: sourceMode, + includeCredits: false, + includeOptionalUsage: includeOptionalUsage, + webTimeout: 60, + webDebugDumpHTML: false, + verbose: false, + env: environment, + settings: nil, + fetcher: UsageFetcher(environment: [:]), + claudeFetcher: ClaudeUsageFetcher(browserDetection: browserDetection), + browserDetection: browserDetection, + selectedTokenAccountID: selectedTokenAccountID) + } +} diff --git a/Tests/CodexBarTests/DeepSeekSettingsReaderTests.swift b/Tests/CodexBarTests/DeepSeekSettingsReaderTests.swift index 0aba8b4a83..f10d965d0a 100644 --- a/Tests/CodexBarTests/DeepSeekSettingsReaderTests.swift +++ b/Tests/CodexBarTests/DeepSeekSettingsReaderTests.swift @@ -1,5 +1,6 @@ -import CodexBarCore +import Foundation import Testing +@testable import CodexBarCore struct DeepSeekSettingsReaderTests { @Test @@ -54,6 +55,95 @@ struct DeepSeekSettingsReaderTests { let env = ["DEEPSEEK_API_KEY": " "] #expect(DeepSeekSettingsReader.apiKey(environment: env) == nil) } + + @Test + func `reads separate platform session token`() { + let env = ["DEEPSEEK_PLATFORM_TOKEN": " browser-session-token "] + #expect(DeepSeekSettingsReader.platformToken(environment: env) == "browser-session-token") + } + + @Test + func `falls back to DeepSeek user token environment key`() { + let env = ["DEEPSEEK_USER_TOKEN": "browser-user-token"] + #expect(DeepSeekSettingsReader.platformToken(environment: env) == "browser-user-token") + } + + @Test + func `platform session token requires the active credential scope`() throws { + let accountID = UUID() + let credential = "api-key-value" + let scope = try #require(DeepSeekSettingsReader.profileScope( + selectedTokenAccountID: accountID, + apiKey: credential)) + let environment = [ + DeepSeekSettingsReader.platformTokenEnvironmentKey: "platform-session", + DeepSeekSettingsReader.profileScopeEnvironmentKey: scope, + ] + + #expect(DeepSeekSettingsReader.scopedPlatformToken( + environment: environment, + selectedTokenAccountID: accountID, + apiKey: credential) == "platform-session") + #expect(DeepSeekSettingsReader.scopedPlatformToken( + environment: environment, + selectedTokenAccountID: UUID(), + apiKey: credential) == nil) + #expect(DeepSeekSettingsReader.scopedPlatformToken( + environment: [DeepSeekSettingsReader.platformTokenEnvironmentKey: "platform-session"], + selectedTokenAccountID: accountID, + apiKey: credential) == nil) + #expect(DeepSeekSettingsReader.scopedPlatformToken( + environment: [DeepSeekSettingsReader.platformTokenEnvironmentKey: "platform-session"], + selectedTokenAccountID: nil, + apiKey: nil) == "platform-session") + } + + @Test + func `reads selected Chrome profile id`() { + let env = [DeepSeekSettingsReader.profileIDEnvironmentKey: " /profiles/Profile 2 "] + #expect(DeepSeekSettingsReader.profileID(environment: env) == "chrome:Profile 2") + } + + @Test + func `migrates an absolute Chrome profile path to a stable identifier`() { + let environment = [ + DeepSeekSettingsReader.profileIDEnvironmentKey: + "/Users/example/Library/Application Support/Google/Chrome/Profile 2", + ] + + #expect(DeepSeekSettingsReader.profileID(environment: environment) == "chrome:Profile 2") + } + + @Test + func `profile scope fingerprints the api credential without storing it`() throws { + let accountID = UUID() + let first = try #require(DeepSeekSettingsReader.profileScope( + selectedTokenAccountID: accountID, + apiKey: "secret-api-key")) + let repeated = try #require(DeepSeekSettingsReader.profileScope( + selectedTokenAccountID: accountID, + apiKey: "secret-api-key")) + let replacedKey = try #require(DeepSeekSettingsReader.profileScope( + selectedTokenAccountID: accountID, + apiKey: "replacement-api-key")) + let otherAccount = try #require(DeepSeekSettingsReader.profileScope( + selectedTokenAccountID: UUID(), + apiKey: "secret-api-key")) + + #expect(first == repeated) + #expect(first != replacedKey) + #expect(first != otherAccount) + #expect(!first.contains("secret-api-key")) + } + + @Test + func `browser only profile scope persists without an API key`() throws { + let scope = try #require(DeepSeekSettingsReader.profileScope( + selectedTokenAccountID: nil, + apiKey: nil)) + + #expect(!scope.isEmpty) + } } struct DeepSeekProviderTokenResolverTests { diff --git a/Tests/CodexBarTests/DeepSeekUsageCostParserTests.swift b/Tests/CodexBarTests/DeepSeekUsageCostParserTests.swift index c7cc32b925..e9d67a5d05 100644 --- a/Tests/CodexBarTests/DeepSeekUsageCostParserTests.swift +++ b/Tests/CodexBarTests/DeepSeekUsageCostParserTests.swift @@ -853,3 +853,150 @@ struct DeepSeekUsageCostParserTests { #expect(summary.todayTokens == 450) // 150 + 300 } } + +struct DeepSeekUsageCostParserAuthorizationTests { + private static let emptyCostJSON = """ + { + "code": 0, + "msg": "", + "data": { + "biz_code": 0, + "biz_msg": "", + "biz_data": [] + } + } + """ + + @Test + func `invalid platform token code requests a new web session`() { + let amountJSON = """ + { + "code": 40003, + "msg": "Authorization Failed (invalid token)", + "data": null + } + """ + let costJSON = """ + { + "code": 0, + "msg": "", + "data": { + "biz_code": 0, + "biz_msg": "", + "biz_data": [] + } + } + """ + + #expect { + _ = try DeepSeekUsageFetcher._parseUsageSummaryForTesting( + amountData: Data(amountJSON.utf8), + costData: Data(costJSON.utf8)) + } throws: { error in + error as? DeepSeekUsageError == .invalidPlatformToken + } + } + + @Test + func `nested invalid platform token code requests a new web session`() { + let amountJSON = """ + { + "code": 0, + "msg": "", + "data": { + "biz_code": 40002, + "biz_msg": "Authorization Failed", + "biz_data": null + } + } + """ + let costJSON = """ + { + "code": 0, + "msg": "", + "data": { + "biz_code": 0, + "biz_msg": "", + "biz_data": [] + } + } + """ + + #expect { + _ = try DeepSeekUsageFetcher._parseUsageSummaryForTesting( + amountData: Data(amountJSON.utf8), + costData: Data(costJSON.utf8)) + } throws: { error in + error as? DeepSeekUsageError == .invalidPlatformToken + } + } + + @Test + func `top level authentication error survives an unexpected data shape`() { + let amountJSON = """ + { + "code": 40003, + "msg": "Authorization Failed", + "data": "unexpected" + } + """ + + #expect { + _ = try DeepSeekUsageFetcher._parseUsageSummaryForTesting( + amountData: Data(amountJSON.utf8), + costData: Data(Self.emptyCostJSON.utf8)) + } throws: { error in + error as? DeepSeekUsageError == .invalidPlatformToken + } + } + + @Test + func `nested authentication error survives an unexpected biz data shape`() { + let amountJSON = """ + { + "code": 0, + "msg": "", + "data": { + "biz_code": 40002, + "biz_msg": "Authorization Failed", + "biz_data": "unexpected" + } + } + """ + + #expect { + _ = try DeepSeekUsageFetcher._parseUsageSummaryForTesting( + amountData: Data(amountJSON.utf8), + costData: Data(Self.emptyCostJSON.utf8)) + } throws: { error in + error as? DeepSeekUsageError == .invalidPlatformToken + } + } + + @Test + func `successful malformed payload reports its decoding path`() { + let amountJSON = """ + { + "code": 0, + "msg": "", + "data": { + "biz_code": 0, + "biz_msg": "", + "biz_data": { + "total": "unexpected", + "days": [] + } + } + } + """ + + #expect { + _ = try DeepSeekUsageFetcher._parseUsageSummaryForTesting( + amountData: Data(amountJSON.utf8), + costData: Data(Self.emptyCostJSON.utf8)) + } throws: { error in + guard case let DeepSeekUsageError.parseFailed(message) = error else { return false } + return message.contains("total") && message.contains("typeMismatch") + } + } +} diff --git a/Tests/CodexBarTests/DeepSeekUsageFetcherTests.swift b/Tests/CodexBarTests/DeepSeekUsageFetcherTests.swift index e4d60078e8..f348d76939 100644 --- a/Tests/CodexBarTests/DeepSeekUsageFetcherTests.swift +++ b/Tests/CodexBarTests/DeepSeekUsageFetcherTests.swift @@ -9,6 +9,7 @@ struct DeepSeekUsageFetcherTests { private var started = false private var cancelled = false private var startedWaiters: [CheckedContinuation] = [] + private var cancelledWaiters: [CheckedContinuation] = [] func markStarted() { self.started = true @@ -27,6 +28,17 @@ struct DeepSeekUsageFetcherTests { func markCancelled() { self.cancelled = true + for waiter in self.cancelledWaiters { + waiter.resume() + } + self.cancelledWaiters.removeAll() + } + + func waitUntilCancelled() async { + if self.cancelled { return } + await withCheckedContinuation { continuation in + self.cancelledWaiters.append(continuation) + } } func wasCancelled() -> Bool { @@ -34,6 +46,34 @@ struct DeepSeekUsageFetcherTests { } } + private actor ConcurrentFetchGate { + private var arrivalCount = 0 + private var waiters: [CheckedContinuation] = [] + + func arriveAndWait() async { + self.arrivalCount += 1 + if self.arrivalCount == 2 { + for waiter in self.waiters { + waiter.resume() + } + self.waiters.removeAll() + return + } + + await withCheckedContinuation { continuation in + self.waiters.append(continuation) + } + } + } + + private actor SummaryCallCounter { + private(set) var value = 0 + + func increment() { + self.value += 1 + } + } + private static func withTimeout( _ timeout: Duration, operation: @escaping @Sendable () async throws -> T) async throws -> T @@ -54,6 +94,14 @@ struct DeepSeekUsageFetcherTests { } } + private static func waitForCancellation(_ probe: SummaryCancellationProbe) async -> Bool { + for _ in 0..<100 { + if await probe.wasCancelled() { return true } + try? await Task.sleep(for: .milliseconds(20)) + } + return await probe.wasCancelled() + } + private static let sampleBalanceJSON = """ { "is_available": true, @@ -108,6 +156,90 @@ struct DeepSeekUsageFetcherTests { #expect(snapshot.toppedUpBalance == 40.0) } + @Test + func `parses paid and granted balances from Platform session summary`() throws { + let json = """ + { + "code": 0, + "data": { + "biz_code": 0, + "biz_data": { + "normal_wallets": [ + {"balance": "7.97", "currency": "USD"} + ], + "bonus_wallets": [ + {"balance": 0.50, "currency": "USD"} + ] + } + } + } + """ + + let snapshot = try DeepSeekUsageFetcher._parsePlatformBalanceForTesting(Data(json.utf8)) + + #expect(snapshot.hasBalance) + #expect(snapshot.isAvailable) + #expect(snapshot.currency == "USD") + #expect(abs(snapshot.totalBalance - 8.47) < 0.000_001) + #expect(snapshot.toppedUpBalance == 7.97) + #expect(snapshot.grantedBalance == 0.50) + } + + @Test + func `Platform session summary rejects malformed balance`() { + let json = """ + { + "code": 0, + "data": { + "biz_code": 0, + "biz_data": { + "normal_wallets": [{"balance": "not-a-number", "currency": "USD"}], + "bonus_wallets": [] + } + } + } + """ + + #expect(throws: DeepSeekUsageError.self) { + try DeepSeekUsageFetcher._parsePlatformBalanceForTesting(Data(json.utf8)) + } + } + + @Test + func `Platform session summary maps top level auth envelopes before decoding data`() { + let json = """ + { + "code": 40003, + "data": "unexpected" + } + """ + + #expect { + try DeepSeekUsageFetcher._parsePlatformBalanceForTesting(Data(json.utf8)) + } throws: { error in + error as? DeepSeekUsageError == .invalidPlatformToken + } + } + + @Test + func `Platform session summary maps nested auth envelopes before decoding wallets`() { + let json = """ + { + "code": 0, + "data": { + "biz_code": 40002, + "biz_data": "unexpected" + } + } + """ + + #expect { + try DeepSeekUsageFetcher._parsePlatformBalanceForTesting(Data(json.utf8)) + } throws: { error in + error as? DeepSeekUsageError == .invalidPlatformToken + } + } + @Test func `parses CNY balance response`() throws { let json = """ @@ -340,12 +472,32 @@ struct DeepSeekUsageFetcherTests { #expect(usage.deepseekUsage == nil) } + @Test + func `usage amount and cost fetch concurrently`() async throws { + let gate = ConcurrentFetchGate() + let payloads = try await Self.withTimeout(.seconds(1)) { + try await DeepSeekUsageFetcher._fetchUsagePayloadsForTesting( + fetchAmount: { + await gate.arriveAndWait() + return Data("amount".utf8) + }, + fetchCost: { + await gate.arriveAndWait() + return Data("cost".utf8) + }) + } + + #expect(String(bytes: payloads.amount, encoding: .utf8) == "amount") + #expect(String(bytes: payloads.cost, encoding: .utf8) == "cost") + } + @Test func `balance returns promptly when optional usage summary is slow`() async throws { let probe = SummaryCancellationProbe() let snapshot = try await Self.withTimeout(.seconds(10)) { try await DeepSeekUsageFetcher._fetchUsageForTesting( apiKey: "test-key", + platformToken: "platform-token", includeOptionalUsage: true, optionalSummaryJoinGrace: .milliseconds(50), fetchBalanceData: { _ in @@ -365,13 +517,42 @@ struct DeepSeekUsageFetcherTests { #expect(snapshot.totalBalance == 50.0) #expect(snapshot.usageSummary == nil) - #expect(await probe.wasCancelled()) + #expect(await Self.waitForCancellation(probe)) + } + + @Test + func `balance grace does not wait for optional summary that ignores cancellation`() async throws { + let startedAt = ContinuousClock.now + let snapshot = try await DeepSeekUsageFetcher._fetchUsageForTesting( + apiKey: "test-key", + platformToken: "platform-token", + includeOptionalUsage: true, + optionalSummaryJoinGrace: .milliseconds(20), + fetchBalanceData: { _ in + Data(Self.sampleBalanceJSON.utf8) + }, + fetchSummary: { _ in + await withCheckedContinuation { continuation in + DispatchQueue.global().asyncAfter(deadline: .now() + 0.5) { + continuation.resume(returning: Self.sampleSummary()) + } + } + }) + let elapsed = startedAt.duration(to: .now) + + #expect(snapshot.totalBalance == 50.0) + #expect(snapshot.usageSummary == nil) + #expect(elapsed < .milliseconds(300), "Optional summary delayed balance: \(elapsed)") + + // Let the deliberately cancellation-ignoring test task drain before the test exits. + try await Task.sleep(for: .milliseconds(550)) } @Test func `balance returns when optional usage summary fails closed`() async throws { let snapshot = try await DeepSeekUsageFetcher._fetchUsageForTesting( apiKey: "test-key", + platformToken: "platform-token", includeOptionalUsage: true, optionalSummaryJoinGrace: .seconds(2), fetchBalanceData: { _ in @@ -385,6 +566,54 @@ struct DeepSeekUsageFetcherTests { #expect(snapshot.usageSummary == nil) } + @Test + func `Platform balance returns when optional usage summary fails`() async throws { + let snapshot = try await DeepSeekUsageFetcher._fetchPlatformUsageForTesting( + includeOptionalUsage: true, + optionalSummaryJoinGrace: .seconds(2), + fetchBalance: { + DeepSeekUsageSnapshot( + isAvailable: true, + currency: "USD", + totalBalance: 8.06, + grantedBalance: 0, + toppedUpBalance: 8.06, + updatedAt: Date()) + }, + fetchSummary: { + throw DeepSeekUsageError.networkError("simulated failure") + }) + + #expect(snapshot.totalBalance == 8.06) + #expect(snapshot.usageSummary == nil) + #expect(snapshot.detailedUsageState == .unavailable) + } + + @Test + func `Platform balance skips detailed endpoints when optional usage is disabled`() async throws { + let counter = SummaryCallCounter() + let snapshot = try await DeepSeekUsageFetcher._fetchPlatformUsageForTesting( + includeOptionalUsage: false, + fetchBalance: { + DeepSeekUsageSnapshot( + isAvailable: true, + currency: "USD", + totalBalance: 8.06, + grantedBalance: 0, + toppedUpBalance: 8.06, + updatedAt: Date()) + }, + fetchSummary: { + await counter.increment() + return Self.sampleSummary() + }) + + #expect(snapshot.totalBalance == 8.06) + #expect(snapshot.usageSummary == nil) + #expect(snapshot.detailedUsageState == .notRequested) + #expect(await counter.value == 0) + } + @Test func `cancels optional usage summary when balance fetch fails`() async throws { let probe = SummaryCancellationProbe() @@ -392,6 +621,7 @@ struct DeepSeekUsageFetcherTests { do { _ = try await DeepSeekUsageFetcher._fetchUsageForTesting( apiKey: "test-key", + platformToken: "platform-token", includeOptionalUsage: true, optionalSummaryJoinGrace: .seconds(2), fetchBalanceData: { _ in @@ -410,8 +640,7 @@ struct DeepSeekUsageFetcherTests { }) Issue.record("Expected balance failure") } catch DeepSeekUsageError.networkError { - try await Task.sleep(for: .milliseconds(100)) - #expect(await probe.wasCancelled()) + #expect(await Self.waitForCancellation(probe)) } } @@ -422,6 +651,7 @@ struct DeepSeekUsageFetcherTests { do { _ = try await DeepSeekUsageFetcher._fetchUsageForTesting( apiKey: "test-key", + platformToken: "platform-token", includeOptionalUsage: true, optionalSummaryJoinGrace: .seconds(2), fetchBalanceData: { _ in @@ -440,8 +670,7 @@ struct DeepSeekUsageFetcherTests { }) Issue.record("Expected balance parse failure") } catch DeepSeekUsageError.parseFailed { - try await Task.sleep(for: .milliseconds(100)) - #expect(await probe.wasCancelled()) + #expect(await Self.waitForCancellation(probe)) } } @@ -451,6 +680,7 @@ struct DeepSeekUsageFetcherTests { let task = Task { try await DeepSeekUsageFetcher._fetchUsageForTesting( apiKey: "test-key", + platformToken: "platform-token", includeOptionalUsage: true, optionalSummaryJoinGrace: .seconds(30), fetchBalanceData: { _ in @@ -477,7 +707,50 @@ struct DeepSeekUsageFetcherTests { } Issue.record("Expected cancellation") } catch is CancellationError { - #expect(await probe.wasCancelled()) + #expect(await Self.waitForCancellation(probe)) + } + } + + @Test + func `parent cancellation stops summary while balance transport ignores cancellation`() async throws { + let balanceStarted = AsyncStream.makeStream(of: Void.self) + let probe = SummaryCancellationProbe() + let task = Task { + try await DeepSeekUsageFetcher._fetchUsageForTesting( + apiKey: "test-key", + platformToken: "platform-token", + includeOptionalUsage: true, + optionalSummaryJoinGrace: .seconds(30), + fetchBalanceData: { _ in + balanceStarted.continuation.yield(()) + return await withCheckedContinuation { continuation in + DispatchQueue.global().asyncAfter(deadline: .now() + 0.5) { + continuation.resume(returning: Data(Self.sampleBalanceJSON.utf8)) + } + } + }, + fetchSummary: { _ in + await probe.markStarted() + do { + try await Task.sleep(for: .seconds(60)) + return Self.sampleSummary() + } catch is CancellationError { + await probe.markCancelled() + throw CancellationError() + } + }) + } + + var balanceIterator = balanceStarted.stream.makeAsyncIterator() + _ = await balanceIterator.next() + await probe.waitUntilStarted() + let cancellationStartedAt = ContinuousClock.now + task.cancel() + + await probe.waitUntilCancelled() + #expect(cancellationStartedAt.duration(to: .now) < .milliseconds(300)) + await #expect(throws: CancellationError.self) { + try await task.value } } @@ -506,6 +779,7 @@ struct DeepSeekUsageFetcherTests { let expected = Self.sampleSummary() let snapshot = try await DeepSeekUsageFetcher._fetchUsageForTesting( apiKey: "test-key", + platformToken: "platform-token", includeOptionalUsage: true, optionalSummaryJoinGrace: .seconds(2), fetchBalanceData: { _ in @@ -517,6 +791,68 @@ struct DeepSeekUsageFetcherTests { #expect(snapshot.totalBalance == 50.0) #expect(snapshot.usageSummary == expected) + #expect(snapshot.detailedUsageState == .available) + } + + @Test + func `API key alone reports that a web session is required`() async throws { + let summaryCalls = SummaryCallCounter() + let snapshot = try await DeepSeekUsageFetcher._fetchUsageForTesting( + apiKey: "test-key", + platformToken: nil, + includeOptionalUsage: true, + optionalSummaryJoinGrace: .seconds(1), + fetchBalanceData: { _ in + Data(Self.sampleBalanceJSON.utf8) + }, + fetchSummary: { _ in + await summaryCalls.increment() + return Self.sampleSummary() + }) + + #expect(snapshot.totalBalance == 50.0) + #expect(snapshot.usageSummary == nil) + #expect(snapshot.detailedUsageState == .webSessionRequired) + #expect(await summaryCalls.value == 0) + } + + @Test + func `platform token is separate from the balance API key`() async throws { + let snapshot = try await DeepSeekUsageFetcher._fetchUsageForTesting( + apiKey: "balance-api-key", + platformToken: "browser-user-token", + includeOptionalUsage: true, + optionalSummaryJoinGrace: .seconds(1), + fetchBalanceData: { key in + #expect(key == "balance-api-key") + return Data(Self.sampleBalanceJSON.utf8) + }, + fetchSummary: { token in + #expect(token == "browser-user-token") + return Self.sampleSummary() + }) + + #expect(snapshot.usageSummary != nil) + #expect(snapshot.detailedUsageState == .available) + } + + @Test + func `invalid platform token preserves balance and requests sign in`() async throws { + let snapshot = try await DeepSeekUsageFetcher._fetchUsageForTesting( + apiKey: "balance-api-key", + platformToken: "expired-browser-token", + includeOptionalUsage: true, + optionalSummaryJoinGrace: .seconds(1), + fetchBalanceData: { _ in + Data(Self.sampleBalanceJSON.utf8) + }, + fetchSummary: { _ in + throw DeepSeekUsageError.invalidPlatformToken + }) + + #expect(snapshot.totalBalance == 50.0) + #expect(snapshot.usageSummary == nil) + #expect(snapshot.detailedUsageState == .webSessionRequired) } private static func utcDate(year: Int, month: Int, day: Int) -> Date? { diff --git a/Tests/CodexBarTests/DeferredMenuInteractionRefreshTailTests.swift b/Tests/CodexBarTests/DeferredMenuInteractionRefreshTailTests.swift new file mode 100644 index 0000000000..11cc484a06 --- /dev/null +++ b/Tests/CodexBarTests/DeferredMenuInteractionRefreshTailTests.swift @@ -0,0 +1,177 @@ +import AppKit +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +@MainActor +@Suite(.serialized) +struct DeferredMenuInteractionRefreshTailTests { + @Test + func `repeated scheduling during forced enrichment produces one deferred refresh`() async { + let settings = testSettingsStore( + suiteName: "DeferredMenuInteractionRefreshTailTests-forced-tail") + settings.providerDetectionCompleted = true + settings.refreshFrequency = .manual + settings.statusChecksEnabled = false + settings.costUsageEnabled = true + settings.openAIWebAccessEnabled = false + settings.codexCookieSource = .off + for provider in UsageProvider.allCases { + guard let metadata = ProviderRegistry.shared.metadata[provider] else { continue } + settings.setProviderEnabled( + provider: provider, + metadata: metadata, + enabled: provider == .codex) + } + + let isolatedRoot = FileManager.default.temporaryDirectory + .appendingPathComponent("codexbar-deferred-refresh-tests", isDirectory: true) + .appendingPathComponent(UUID().uuidString, isDirectory: true) + let environment = [ + "HOME": isolatedRoot.path, + "CODEX_HOME": isolatedRoot.appendingPathComponent(".codex", isDirectory: true).path, + ] + let fetcher = UsageFetcher(environment: environment) + let store = UsageStore( + fetcher: fetcher, + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + startupBehavior: .testing, + environmentBase: environment) + let tokenTail = DeferredMenuRefreshTokenTailBlocker() + var providerRefreshCount = 0 + store._test_providerRefreshOverride = { provider in + guard provider == .codex else { return } + providerRefreshCount += 1 + } + store._test_codexCreditsLoaderOverride = { + CreditsSnapshot(remaining: 25, events: [], updatedAt: Date()) + } + store._test_tokenUsageRefreshOverride = { provider, force in + guard provider == .codex, force else { return } + await tokenTail.run() + } + defer { + store._test_providerRefreshOverride = nil + store._test_codexCreditsLoaderOverride = nil + store._test_tokenUsageRefreshOverride = nil + } + + let controller = StatusItemController( + store: store, + settings: settings, + account: AccountInfo(email: nil, plan: nil), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: testStatusBar(), + menuCardRenderingEnabled: false, + menuRefreshEnabled: false) + defer { + controller.cancelDeferredMenuInteractionRefreshTask() + controller.releaseStatusItemsForTesting() + } + var deferredRefreshCount = 0 + controller.onDeferredMenuInteractionRefreshForTesting = { + deferredRefreshCount += 1 + } + + await store.refresh(enrichmentMode: .forcedBackground) + let enrichmentTask = store.forcedRefreshEnrichmentTask + let didStartTail = await tokenTail.waitUntilStarted() + #expect(didStartTail) + guard didStartTail else { + store.cancelForcedRefreshEnrichment() + await enrichmentTask?.value + return + } + #expect(providerRefreshCount == 1) + #expect(store.hasForcedRefreshEnrichmentInFlight) + + // The follow-up automatic refresh should not start another token-cost tail. + settings.costUsageEnabled = false + controller.deferMenuInteractionRefreshIfNeeded(providers: [.codex]) + for _ in 0..<3 { + controller.scheduleDeferredMenuInteractionRefreshIfNeeded(delay: .zero) + try? await Task.sleep(for: .milliseconds(30)) + } + + #expect(deferredRefreshCount == 0) + #expect(providerRefreshCount == 1) + #expect(controller.deferredMenuInteractionRefreshProviders == [.codex]) + #expect(controller.deferredMenuInteractionRefreshTask != nil) + + await tokenTail.release() + await enrichmentTask?.value + controller.scheduleDeferredMenuInteractionRefreshIfNeeded(delay: .zero) + + let completedExactlyOnce = await Self.waitUntil { + deferredRefreshCount == 1 && + providerRefreshCount == 2 && + !controller.deferredMenuInteractionRefreshPending + } + #expect(completedExactlyOnce) + #expect(deferredRefreshCount == 1) + #expect(providerRefreshCount == 2) + #expect(controller.deferredMenuInteractionRefreshProviders.isEmpty) + } + + private static func waitUntil( + timeout: Duration = .seconds(2), + condition: @escaping @MainActor () -> Bool) async -> Bool + { + let deadline = ContinuousClock.now + timeout + while !condition() { + if ContinuousClock.now >= deadline { + return false + } + try? await Task.sleep(for: .milliseconds(10)) + } + return true + } +} + +private actor DeferredMenuRefreshTokenTailBlocker { + private var started = 0 + private var released = false + private var waiter: (id: UUID, continuation: CheckedContinuation)? + + func run() async { + let id = UUID() + self.started += 1 + await withTaskCancellationHandler { + await withCheckedContinuation { continuation in + if self.released || Task.isCancelled { + continuation.resume() + } else { + self.waiter = (id: id, continuation: continuation) + } + } + } onCancel: { + Task { await self.cancel(id: id) } + } + } + + func waitUntilStarted(timeout: Duration = .seconds(2)) async -> Bool { + let deadline = ContinuousClock.now + timeout + while self.started == 0 { + if ContinuousClock.now >= deadline { + return false + } + try? await Task.sleep(for: .milliseconds(10)) + } + return true + } + + func release() { + self.released = true + self.waiter?.continuation.resume() + self.waiter = nil + } + + private func cancel(id: UUID) { + guard self.waiter?.id == id else { return } + self.waiter?.continuation.resume() + self.waiter = nil + } +} diff --git a/Tests/CodexBarTests/DevinUsageFetcherTests.swift b/Tests/CodexBarTests/DevinUsageFetcherTests.swift new file mode 100644 index 0000000000..0937751746 --- /dev/null +++ b/Tests/CodexBarTests/DevinUsageFetcherTests.swift @@ -0,0 +1,507 @@ +import Foundation +import Testing +@testable import CodexBarCore + +@Suite(.serialized) +struct DevinUsageFetcherTests { + private static let now = Date(timeIntervalSince1970: 1_780_000_000) + + @Test + func `parses quota usage response into daily and weekly windows`() throws { + let response: [String: Any] = [ + "plan_name": "pro", + "quota_usage": [ + "daily_quota": [ + "used": 3, + "limit": 10, + "reset_at": "2026-06-01T08:00:00Z", + ], + "weekly_quota": [ + "remaining_percent": 0.25, + "next_reset_at": 1_780_560_000, + ], + ], + ] + + let snapshot = try DevinUsageParser.parse(response, organization: "org/example-org", now: Self.now) + + #expect(snapshot.daily?.usedPercent == 30) + #expect(snapshot.weekly?.usedPercent == 75) + #expect(snapshot.daily?.resetsAt?.timeIntervalSince1970 == 1_780_300_800) + #expect(snapshot.weekly?.resetsAt?.timeIntervalSince1970 == 1_780_560_000) + #expect(snapshot.planName == "Pro") + #expect(snapshot.organization == "example-org") + } + + @Test + func `parses current Devin quota response with reset timestamps`() throws { + let response: [String: Any] = [ + "is_quota_plan": true, + "has_quota_allocation": true, + "daily_percentage": 0.12, + "weekly_percentage": 42, + "daily_reset_at": "2026-06-11T00:00:00-08:00", + "weekly_reset_at": "2026-06-14T00:00:00-08:00", + "hide_daily_quota": false, + ] + + let snapshot = try DevinUsageParser.parse(response, organization: "org/example-org", now: Self.now) + + #expect(snapshot.daily?.usedPercent == 12) + #expect(snapshot.weekly?.usedPercent == 42) + #expect(snapshot.daily?.resetsAt?.timeIntervalSince1970 == 1_781_164_800) + #expect(snapshot.weekly?.resetsAt?.timeIntervalSince1970 == 1_781_424_000) + } + + @Test + func `parses overage balance into extra usage provider cost`() throws { + let response: [String: Any] = [ + "daily_percentage": 12, + "weekly_percentage": 42, + "overage_balance": 70.87, + ] + + let snapshot = try DevinUsageParser.parse(response, organization: nil, now: Self.now) + + #expect(snapshot.overageBalance == 70.87) + let cost = try #require(snapshot.toUsageSnapshot().providerCost) + #expect(cost.used == 70.87) + #expect(cost.limit == 0) + #expect(cost.currencyCode == "USD") + #expect(cost.period == "Extra usage balance") + #expect(cost.updatedAt == Self.now) + } + + @Test + func `parses overage balance cents into extra usage provider cost`() throws { + let response: [String: Any] = [ + "daily_percentage": 12, + "weekly_percentage": 42, + "overage_balance_cents": 7087, + ] + + let snapshot = try DevinUsageParser.parse(response, organization: nil, now: Self.now) + + #expect(snapshot.overageBalance == 70.87) + #expect(snapshot.toUsageSnapshot().providerCost?.period == "Extra usage balance") + } + + @Test + func `omits provider cost when overage balance is absent`() throws { + let response: [String: Any] = [ + "daily_percentage": 12, + "weekly_percentage": 42, + ] + + let snapshot = try DevinUsageParser.parse(response, organization: nil, now: Self.now) + + #expect(snapshot.overageBalance == nil) + #expect(snapshot.toUsageSnapshot().providerCost == nil) + } + + @Test(arguments: ["-1", "Infinity", "NaN"]) + func `omits invalid overage balances`(_ balance: String) throws { + let response: [String: Any] = [ + "daily_percentage": 12, + "weekly_percentage": 42, + "overage_balance": balance, + ] + + let snapshot = try DevinUsageParser.parse(response, organization: nil, now: Self.now) + + #expect(snapshot.overageBalance == nil) + #expect(snapshot.toUsageSnapshot().providerCost == nil) + } + + @Test(arguments: ["-1", "Infinity", "NaN"]) + func `omits invalid overage balance cents`(_ balance: String) throws { + let response: [String: Any] = [ + "daily_percentage": 12, + "weekly_percentage": 42, + "overage_balance_cents": balance, + ] + + let snapshot = try DevinUsageParser.parse(response, organization: nil, now: Self.now) + + #expect(snapshot.overageBalance == nil) + #expect(snapshot.toUsageSnapshot().providerCost == nil) + } + + @Test + func `keeps weekly quota when current plan hides daily quota`() throws { + let response: [String: Any] = [ + "weekly_percentage": 25, + "weekly_reset_at": "2026-06-14T00:00:00-08:00", + "hide_daily_quota": true, + ] + + let usage = try DevinUsageParser.parse(response, organization: nil, now: Self.now).toUsageSnapshot() + + #expect(usage.primary == nil) + #expect(usage.secondary?.usedPercent == 25) + } + + @Test + func `normalizes mixed-scale current percentages at the one-percent boundary`() throws { + let cases: [(input: Double, expected: Double)] = [ + (0.5, 50), + (1, 1), + (1.5, 1.5), + ] + + for value in cases { + let response: [String: Any] = [ + "daily_percentage": value.input, + "weekly_percentage": value.input, + ] + let snapshot = try DevinUsageParser.parse(response, organization: nil, now: Self.now) + + #expect(snapshot.daily?.usedPercent == value.expected) + #expect(snapshot.weekly?.usedPercent == value.expected) + } + } + + @Test + func `preserves fractional boundaries for fallback quota percentages`() throws { + let response: [String: Any] = [ + "quota_usage": [ + "daily_quota": [ + "used_percent": 1, + "reset_at": "2026-06-01T08:00:00Z", + ], + "weekly_quota": [ + "remaining_percent": 1, + "next_reset_at": 1_780_560_000, + ], + ], + ] + + let snapshot = try DevinUsageParser.parse(response, organization: nil, now: Self.now) + + #expect(snapshot.daily?.usedPercent == 100) + #expect(snapshot.weekly?.usedPercent == 0) + } + + @Test + func `parses zero percentages from JSON response`() throws { + let data = Data(""" + { + "daily_percentage": 0, + "weekly_percentage": 0, + "daily_reset_at": "2026-06-11T00:00:00-08:00", + "weekly_reset_at": "2026-06-14T00:00:00-08:00" + } + """.utf8) + + let snapshot = try DevinUsageParser.parse(data, organization: nil, now: Self.now) + + #expect(snapshot.daily?.usedPercent == 0) + #expect(snapshot.weekly?.usedPercent == 0) + } + + @Test + func `usage snapshot maps Devin quotas to primary and secondary windows`() { + let snapshot = DevinUsageSnapshot( + daily: DevinQuotaWindow(usedPercent: 12), + weekly: DevinQuotaWindow(usedPercent: 42), + planName: "Free", + organization: "example-org", + updatedAt: Self.now) + + let usage = snapshot.toUsageSnapshot() + + #expect(usage.primary?.usedPercent == 12) + #expect(usage.primary?.windowMinutes == 1440) + #expect(usage.primary?.resetDescription == "Daily") + #expect(usage.secondary?.usedPercent == 42) + #expect(usage.secondary?.windowMinutes == 10080) + #expect(usage.secondary?.resetDescription == "Weekly") + #expect(usage.identity?.providerID == .devin) + #expect(usage.identity?.accountOrganization == "example-org") + #expect(usage.identity?.loginMethod == "Free") + } + + @Test + func `fetch sends bearer token and organization header`() async throws { + let auth = DevinUsageFetcher.RequestAuth( + bearerToken: "secret-token", + organization: "org/example-org", + internalOrganizationID: "org_GQ6LhcfkW1TSinM6", + sourceLabel: "test") + let stub = ProviderHTTPTransportStub { request in + #expect(request.url?.host == "app.devin.ai") + #expect(request.url?.path == "/api/org_GQ6LhcfkW1TSinM6/billing/quota/usage") + #expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer secret-token") + #expect(request.value(forHTTPHeaderField: "x-cog-org-id") == "org_GQ6LhcfkW1TSinM6") + let body = """ + {"daily":{"used_percent":10},"weekly":{"used_percent":20},"plan":"free"} + """ + let response = HTTPURLResponse( + url: request.url!, + statusCode: 200, + httpVersion: nil, + headerFields: nil)! + return (Data(body.utf8), response) + } + + let snapshot = try await DevinUsageFetcher.fetchQuotaUsage( + auth: auth, + now: Self.now, + transport: stub) + + #expect(snapshot.daily?.usedPercent == 10) + #expect(snapshot.weekly?.usedPercent == 20) + #expect(snapshot.planName == "Free") + } + + @Test + func `fetch does not mask parser failure with fallback endpoint errors`() async { + let auth = DevinUsageFetcher.RequestAuth( + bearerToken: "secret-token", + organization: "org/example-org", + internalOrganizationID: "org_GQ6LhcfkW1TSinM6", + sourceLabel: "test") + let stub = ProviderHTTPTransportStub { request in + let response = HTTPURLResponse( + url: request.url!, + statusCode: request.url?.path == "/api/org_GQ6LhcfkW1TSinM6/billing/quota/usage" ? 200 : 404, + httpVersion: nil, + headerFields: nil)! + return (Data("{}".utf8), response) + } + + do { + _ = try await DevinUsageFetcher.fetchQuotaUsage( + auth: auth, + now: Self.now, + transport: stub) + Issue.record("Expected quota parsing to fail") + } catch let error as DevinUsageError { + guard case .parseFailed = error else { + Issue.record("Expected parseFailed, got \(error)") + return + } + } catch { + Issue.record("Expected DevinUsageError, got \(error)") + } + + #expect(await stub.requests().count == 1) + } + + @Test + func `normalizes organization inputs`() { + #expect(DevinUsageFetcher.normalizedOrganization("example-org") == "org/example-org") + #expect(DevinUsageFetcher.normalizedOrganization("org/example-org") == "org/example-org") + #expect(DevinUsageFetcher.normalizedOrganization("org_GQ6LhcfkW1TSinM6") == + "organizations/org_GQ6LhcfkW1TSinM6") + #expect(DevinUsageFetcher.normalizedOrganization("org-b31f951cd01d4c6da84991cf5b970cfb") == + "organizations/org-b31f951cd01d4c6da84991cf5b970cfb") + #expect(DevinUsageFetcher.normalizedOrganization("https://app.devin.ai/org/example-org/settings/usage") == + "org/example-org") + } + + @Test + func `manual auth strips Authorization and Bearer prefixes`() throws { + let auth = try #require(DevinUsageFetcher.manualAuth( + from: "Authorization: Bearer secret-token", + organization: "example-org")) + + #expect(auth.bearerToken == "secret-token") + #expect(auth.organization == "org/example-org") + #expect(auth.sourceLabel == "manual") + } + + #if os(macOS) + @Test + func `empty app organization setting preserves imported organization`() async throws { + try await DevinSessionImporter.withImportSessionOverrideForTesting { _, organizationOverride, _ in + #expect(organizationOverride == nil) + return DevinSessionImporter.SessionInfo( + accessToken: "test-access-token", + organization: "org/example-org", + internalOrganizationID: "org_GQ6LhcfkW1TSinM6", + sourceLabel: "Chrome Default") + } operation: { + let stub = ProviderHTTPTransportStub { request in + #expect(request.url?.path == "/api/org_GQ6LhcfkW1TSinM6/billing/quota/usage") + let response = HTTPURLResponse( + url: request.url!, + statusCode: 200, + httpVersion: nil, + headerFields: nil)! + return (Data(#"{"daily_percentage":0,"weekly_percentage":0}"#.utf8), response) + } + + let snapshot = try await DevinUsageFetcher(browserDetection: BrowserDetection(cacheTTL: 0)).fetch( + organizationOverride: "", + now: Self.now, + transport: stub) + + #expect(snapshot.organization == "example-org") + #expect(snapshot.daily?.usedPercent == 0) + #expect(snapshot.weekly?.usedPercent == 0) + } + } + + @Test + func `session importer extracts current auth1 token and matching org`() throws { + let accessToken = "auth1_abcdefghijklmnopqrstuvwxyz0123456789" + let storage = [ + "_https://app.devin.ai\u{0000}\u{0001}auth1_session": + #"{"token":"\#(accessToken)","userId":"github|123"}"#, + "_https://app.devin.ai\u{0000}\u{0001}last-internal-org-for-external-org-v1-example-org": + "\"org_GQ6LhcfkW1TSinM6\"", + ] + + let session = try #require(DevinSessionImporter.session( + from: storage, + organizationOverride: "example-org", + sourceLabel: "Chrome Default")) + + #expect(session.accessToken == accessToken) + #expect(session.organization == "org/example-org") + #expect(session.internalOrganizationID == "org_GQ6LhcfkW1TSinM6") + #expect(session.sourceLabel == "Chrome Default") + } + + @Test + func `session importer infers organization from post auth storage`() throws { + let accessToken = "eyJhbGciOiJub25lIn0.eyJpc3MiOiJodHRwczovL2F1dGguZGV2aW4uYWkvIn0.signature" + let storage = [ + "_https://app.devin.ai\u{0000}\u{0001}@@auth0spajs@@::client::audience::scope": + #"{"body":{"access_token":"\#(accessToken)"}}"#, + "_https://app.devin.ai\u{0000}\u{0001}post-auth-v3-null-github|123-org_name-example-org": """ + { + "externalOrgId": null, + "userId": "github|123", + "internalOrgId": "org_GQ6LhcfkW1TSinM6", + "orgName": "example-org" + } + """, + ] + + let session = try #require(DevinSessionImporter.session( + from: storage, + organizationOverride: nil, + sourceLabel: "Brave Default")) + + #expect(session.organization == "org/example-org") + #expect(session.internalOrganizationID == "org_GQ6LhcfkW1TSinM6") + } + + @Test + func `session importer infers organization from member info storage`() throws { + let accessToken = "eyJhbGciOiJub25lIn0.eyJpc3MiOiJodHRwczovL2F1dGguZGV2aW4uYWkvIn0.signature" + let storage = [ + "_https://app.devin.ai\u{0000}\u{0001}@@auth0spajs@@::client::audience::scope": + #"{"body":{"access_token":"\#(accessToken)"}}"#, + "_https://app.devin.ai\u{0000}\u{0001}member-info-v1-org-github|123": """ + { + "value": { + "org_id": "org_GQ6LhcfkW1TSinM6", + "org_name": "example-org" + } + } + """, + ] + + let session = try #require(DevinSessionImporter.session( + from: storage, + organizationOverride: nil, + sourceLabel: "Brave Default")) + + #expect(session.organization == "org/example-org") + #expect(session.internalOrganizationID == "org_GQ6LhcfkW1TSinM6") + } + + @Test + func `session importer falls back to internal organization id`() { + let result = DevinSessionImporter.organizationInfo( + from: [ + "_https://app.devin.ai\u{0000}\u{0001}feature-flags-cache:org_GQ6LhcfkW1TSinM6": "{}", + "_https://app.devin.ai\u{0000}\u{0001}member-info-v1-org-github|123": """ + {"value":{"org_id":"org_GQ6LhcfkW1TSinM6"}} + """, + ], + organizationOverride: nil) + + #expect(result.organization == "organizations/org_GQ6LhcfkW1TSinM6") + #expect(result.internalOrganizationID == "org_GQ6LhcfkW1TSinM6") + } + + @Test + func `session importer ignores org words inside storage key names`() { + let result = DevinSessionImporter.organizationInfo( + from: [ + "_https://app.devin.ai\u{0000}\u{0001}last-internal-org-for-external-org-v1-null": "\"null\"", + "_https://app.devin.ai\u{0000}\u{0001}feature-flags-cache:org_GQ6LhcfkW1TSinM6": "{}", + ], + organizationOverride: nil) + + #expect(result.organization == "organizations/org_GQ6LhcfkW1TSinM6") + #expect(result.internalOrganizationID == "org_GQ6LhcfkW1TSinM6") + } + + @Test + func `session importer deduplicates repeated browser tokens using richest organization metadata`() { + let sessions = [ + DevinSessionImporter.SessionInfo( + accessToken: "auth1_abcdefghijklmnopqrstuvwxyz0123456789", + organization: nil, + internalOrganizationID: nil, + sourceLabel: "Chrome Default"), + DevinSessionImporter.SessionInfo( + accessToken: "auth1_abcdefghijklmnopqrstuvwxyz0123456789", + organization: "org/example", + internalOrganizationID: "org_GQ6LhcfkW1TSinM6", + sourceLabel: "Chrome Profile 1"), + ] + + let deduplicated = DevinSessionImporter.deduplicateSessions(sessions) + + #expect(deduplicated.count == 1) + #expect(deduplicated.first?.sourceLabel == "Chrome Profile 1") + #expect(deduplicated.first?.organization == "org/example") + #expect(deduplicated.first?.internalOrganizationID == "org_GQ6LhcfkW1TSinM6") + } + + @Test + func `session importer ranks organization aware profiles first`() { + let incomplete = DevinSessionImporter.SessionInfo( + accessToken: "auth1_incomplete", + organization: nil, + internalOrganizationID: nil, + sourceLabel: "Chrome Default") + let complete = DevinSessionImporter.SessionInfo( + accessToken: "auth1_complete", + organization: "org/example", + internalOrganizationID: "org_GQ6LhcfkW1TSinM6", + sourceLabel: "Chrome Profile 1") + + let ranked = DevinSessionImporter.rankSessions([incomplete, complete]) + + #expect(ranked.map(\.sourceLabel) == ["Chrome Profile 1", "Chrome Default"]) + } + + @Test + func `missing organization retries the next browser profile`() { + #expect(DevinUsageFetcher.shouldTryNextSession(after: DevinUsageError.missingOrganization)) + #expect(!DevinUsageFetcher.shouldTryNextSession(after: DevinUsageError.parseFailed("invalid response"))) + } + + @Test + func `automatic local storage import does not fall back beyond Chrome`() throws { + let temp = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: temp) } + + let braveRoot = temp + .appendingPathComponent("Library/Application Support/BraveSoftware/Brave-Browser/Default") + try FileManager.default.createDirectory(at: braveRoot, withIntermediateDirectories: true) + let detection = BrowserDetection(homeDirectory: temp.path, cacheTTL: 0) + + #expect(detection.hasUsableProfileData(.brave)) + #expect(!detection.hasUsableProfileData(.chrome)) + #expect(DevinSessionImporter.localStorageBrowsers(browserDetection: detection).isEmpty) + } + #endif +} diff --git a/Tests/CodexBarTests/DisplayIntervalOverrideConcurrencyTests.swift b/Tests/CodexBarTests/DisplayIntervalOverrideConcurrencyTests.swift new file mode 100644 index 0000000000..bfeb914f1f --- /dev/null +++ b/Tests/CodexBarTests/DisplayIntervalOverrideConcurrencyTests.swift @@ -0,0 +1,65 @@ +import Foundation +import Testing +@testable import CodexBarCore + +private actor DisplayIntervalOverrideBarrier { + private var continuations: [CheckedContinuation] = [] + + func wait() async { + await withCheckedContinuation { continuation in + self.continuations.append(continuation) + guard self.continuations.count == 2 else { return } + + let continuations = self.continuations + self.continuations.removeAll() + continuations.forEach { $0.resume() } + } + } +} + +private struct ObservedDisplayIntervals: Hashable { + let staleness: TimeInterval + let unavailableRetry: TimeInterval +} + +struct DisplayIntervalOverrideConcurrencyTests { + @Test + func `concurrent display interval override scopes remain isolated`() async { + let expected = [ + ObservedDisplayIntervals(staleness: 0.1, unavailableRetry: 0.2), + ObservedDisplayIntervals(staleness: 0.3, unavailableRetry: 0.4), + ] + let barrier = DisplayIntervalOverrideBarrier() + + let observed = await withTaskGroup( + of: ObservedDisplayIntervals.self, + returning: Set.self) + { group in + for intervals in expected { + group.addTask { + await CookieHeaderCache.withDisplayStalenessIntervalOverrideForTesting(intervals.staleness) { + await CookieHeaderCache.withDisplayUnavailableRetryIntervalOverrideForTesting( + intervals.unavailableRetry) + { + await barrier.wait() + return await Task { + let current = CookieHeaderCache.displayIntervalsForTesting() + return ObservedDisplayIntervals( + staleness: current.staleness, + unavailableRetry: current.unavailableRetry) + }.value + } + } + } + } + + var values: Set = [] + for await value in group { + values.insert(value) + } + return values + } + + #expect(observed == Set(expected)) + } +} diff --git a/Tests/CodexBarTests/DocumentationLinkTests.swift b/Tests/CodexBarTests/DocumentationLinkTests.swift new file mode 100644 index 0000000000..f19414a323 --- /dev/null +++ b/Tests/CodexBarTests/DocumentationLinkTests.swift @@ -0,0 +1,379 @@ +import Foundation +import Testing + +struct DocumentationLinkTests { + private enum DocumentationLinkError: Error, Equatable { + case invalidURL(String) + case missingAnchor(String) + case missingTarget(String) + case outsideDocumentationRoot(String) + } + + @Test + func `readme local documentation destinations resolve`() throws { + let root = try Self.repoRoot() + let readme = try String(contentsOf: root.appending(path: "README.md"), encoding: .utf8) + let links = try ( + Self.markdownLinks(in: readme) + + Self.markdownImageLinks(in: readme) + + Self.htmlLinks(in: readme)) + .filter(Self.isRepositoryDocReference) + + #expect(!links.isEmpty) + for link in links { + try Self.validateLocalDocLink(link, existsUnder: root) + } + } + + @Test + func `provider overview detail docs resolve`() throws { + let root = try Self.repoRoot() + let providers = try String( + contentsOf: root.appending(path: "docs/providers.md"), + encoding: .utf8) + let links = Self.inlineCodeDocLinks(in: providers) + + #expect(!links.isEmpty) + for link in links { + try Self.validateLocalDocLink(link, existsUnder: root) + } + } + + @Test + func `markdown links support standard destination syntax`() throws { + let markdown = [ + "[fragment](#section)", + "[query](docs/guide%20name.md?mode=print#topic)", + #"[title](docs/title.md "Title")"#, + "[angle]()", + "[reference][guide]", + "", + "[guide]: docs/reference.md?view=1#top", + "`[code](docs/not-a-link.md)`", + "![image](docs/image.png)", + "[external](https://example.com/docs/remote.md)", + ].joined(separator: "\n") + + let links = try Self.markdownLinks(in: markdown) + + #expect(links == [ + "#section", + "docs/guide%20name.md?mode=print#topic", + "docs/title.md", + "docs/with%20space.md", + "docs/reference.md?view=1#top", + "https://example.com/docs/remote.md", + ]) + #expect(links.filter(Self.isRepositoryDocReference).count == 4) + } + + @Test + func `markdown images support standard inline destination syntax`() { + let markdown = """ + ![simple](docs/simple.png) + ![query](docs/query.png?raw=1#preview) + ![title](docs/title.png "Title") + ![angle]() + `![inline code](docs/not-an-image.png)` + ~~~markdown + ![fenced code](docs/not-an-image-either.png) + ~~~ + """ + + #expect(Self.markdownImageLinks(in: markdown) == [ + "docs/simple.png", + "docs/query.png?raw=1#preview", + "docs/title.png", + "docs/with space.png", + ]) + } + + @Test + func `html links support quoted and unquoted destinations`() { + let html = """ + double + single + unquoted + external + `` + ~~~html + + ~~~ + """ + + #expect(Self.htmlLinks(in: html) == [ + "docs/double.png", + "docs/single.md#section", + "docs/unquoted.png", + "https://example.com/docs/remote.md", + ]) + } + + @Test + func `local documentation paths normalize safely`() throws { + let root = URL(filePath: "/tmp/CodexBar-documentation-links", directoryHint: .isDirectory) + + let target = try Self.localDocURL( + for: "./docs/guide%20name.md?mode=print#topic", + repositoryRoot: root) + #expect(target.path == "/tmp/CodexBar-documentation-links/docs/guide name.md") + + #expect(throws: DocumentationLinkError.outsideDocumentationRoot("docs/../README.md")) { + try Self.localDocURL(for: "docs/%2E%2E/README.md", repositoryRoot: root) + } + #expect(!Self.isRepositoryDocReference("#section")) + #expect(!Self.isRepositoryDocReference("https://example.com/docs/remote.md")) + } + + @Test + func `markdown fragments resolve to rendered heading anchors`() throws { + let root = FileManager.default.temporaryDirectory + .appending(path: "DocumentationLinkTests-\(UUID().uuidString)", directoryHint: .isDirectory) + let docs = root.appending(path: "docs", directoryHint: .isDirectory) + try FileManager.default.createDirectory(at: docs, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + + let guide = docs.appending(path: "guide.md") + try """ + # Guide + ## T3 Chat + ## CLI default selection (`--source auto`) + ## Repeated + ## Repeated + ~~~markdown + ## Code Only + ~~~ + """.write(to: guide, atomically: true, encoding: .utf8) + + try Self.validateLocalDocLink("docs/guide.md#t3-chat", existsUnder: root) + try Self.validateLocalDocLink("docs/guide.md#cli-default-selection---source-auto", existsUnder: root) + try Self.validateLocalDocLink("docs/guide.md#repeated-1", existsUnder: root) + #expect(throws: DocumentationLinkError.missingAnchor("docs/guide.md#cli-default-selection")) { + try Self.validateLocalDocLink("docs/guide.md#cli-default-selection", existsUnder: root) + } + #expect(throws: DocumentationLinkError.missingAnchor("docs/guide.md#renamed")) { + try Self.validateLocalDocLink("docs/guide.md#renamed", existsUnder: root) + } + #expect(throws: DocumentationLinkError.missingAnchor("docs/guide.md#code-only")) { + try Self.validateLocalDocLink("docs/guide.md#code-only", existsUnder: root) + } + } + + @Test + func `provider detail extraction ignores unrelated inline code`() { + let markdown = """ + - Details: `docs/first.md#section`. + - Example: `docs/not-a-detail.md`. + - Details: `docs/second%20guide.md?mode=print`. + See also: `docs/not-a-detail-either.md`. + """ + + #expect(Self.inlineCodeDocLinks(in: markdown) == [ + "docs/first.md#section", + "docs/second%20guide.md?mode=print", + ]) + } + + private static func markdownLinks(in text: String) throws -> [String] { + let markdown = try AttributedString(markdown: text) + return markdown.runs.compactMap { $0.link?.relativeString } + } + + private static func markdownImageLinks(in text: String) -> [String] { + let pattern = + #"\!\[(?:\\.|[^\]\\])*\]\(\s*(?:<([^>\n]+)>|([^\s)]+))"# + + #"(?:\s+(?:"[^"\n]*"|'[^'\n]*'|\([^)\n]*\)))?\s*\)"# + guard let regex = try? NSRegularExpression(pattern: pattern) else { return [] } + let source = Self.markdownTextOutsideCode(in: text) + let range = NSRange(source.startIndex.. [String] { + let pattern = + #"<\s*(?:a|img)\b[^>]*?\b(?:href|src)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+))"# + guard let regex = try? NSRegularExpression(pattern: pattern, options: .caseInsensitive) else { return [] } + let source = Self.markdownTextOutsideCode(in: text) + let range = NSRange(source.startIndex.. [String] { + text.split(separator: "\n").compactMap { line in + let trimmed = line.trimmingCharacters(in: .whitespaces) + let prefix = "- Details: `" + guard trimmed.hasPrefix(prefix) else { return nil } + let valueStart = trimmed.index(trimmed.startIndex, offsetBy: prefix.count) + guard let valueEnd = trimmed[valueStart...].firstIndex(of: "`") else { return nil } + return String(trimmed[valueStart.. Bool { + guard let components = URLComponents(string: rawLink), + components.scheme == nil, + components.host == nil + else { + return false + } + var path = components.path[...] + while path.hasPrefix("./") { + path = path.dropFirst(2) + } + return path == "docs" || path.hasPrefix("docs/") + } + + private static func localDocURL(for rawLink: String, repositoryRoot root: URL) throws -> URL { + guard let components = URLComponents(string: rawLink), + components.scheme == nil, + components.host == nil, + !components.path.isEmpty + else { + throw DocumentationLinkError.invalidURL(rawLink) + } + + let target = root.appending(path: components.path).standardizedFileURL + let docsRoot = root.appending(path: "docs", directoryHint: .isDirectory).standardizedFileURL + guard target.path == docsRoot.path || target.path.hasPrefix(docsRoot.path + "/") else { + throw DocumentationLinkError.outsideDocumentationRoot(components.path) + } + return target + } + + private static func markdownHeadingAnchors(in markdown: String) -> Set { + var occurrences: [String: Int] = [:] + var anchors: Set = [] + let source = Self.markdownTextOutsideFencedCode(in: markdown) + for line in source.split(separator: "\n", omittingEmptySubsequences: false) { + let trimmed = line.drop(while: { $0 == " " || $0 == "\t" }) + let markerCount = trimmed.prefix(while: { $0 == "#" }).count + guard (1...6).contains(markerCount), + trimmed.dropFirst(markerCount).first?.isWhitespace == true + else { + continue + } + let heading = trimmed.dropFirst(markerCount).trimmingCharacters(in: .whitespaces) + guard let base = Self.markdownHeadingSlug(heading), !base.isEmpty else { continue } + let occurrence = occurrences[base, default: 0] + anchors.insert(occurrence == 0 ? base : "\(base)-\(occurrence)") + occurrences[base] = occurrence + 1 + } + return anchors + } + + private static func markdownHeadingSlug(_ heading: String) -> String? { + guard let rendered = try? AttributedString(markdown: heading) else { return nil } + var slug = "" + for scalar in String(rendered.characters).lowercased().unicodeScalars { + if CharacterSet.alphanumerics.contains(scalar) || scalar == "-" || scalar == "_" { + slug.unicodeScalars.append(scalar) + } else if CharacterSet.whitespacesAndNewlines.contains(scalar) { + slug.append("-") + } + } + return slug + } + + private static func markdownTextOutsideCode(in markdown: String) -> String { + self.markdownTextOutsideFencedCode(in: markdown) + .split(separator: "\n", omittingEmptySubsequences: false) + .map { self.removingInlineCode(from: String($0)) } + .joined(separator: "\n") + } + + private static func markdownTextOutsideFencedCode(in markdown: String) -> String { + var fence: (marker: Character, count: Int)? + return markdown.split(separator: "\n", omittingEmptySubsequences: false).map { line in + if let activeFence = fence { + if Self.isClosingFence(line, marker: activeFence.marker, minimumCount: activeFence.count) { + fence = nil + } + return "" + } + if let openingFence = Self.openingFence(in: line) { + fence = openingFence + return "" + } + return String(line) + }.joined(separator: "\n") + } + + private static func openingFence(in line: Substring) -> (marker: Character, count: Int)? { + let leadingSpaces = line.prefix(while: { $0 == " " }).count + guard leadingSpaces <= 3 else { return nil } + let candidate = line.dropFirst(leadingSpaces) + guard let marker = candidate.first, marker == "`" || marker == "~" else { return nil } + let count = candidate.prefix(while: { $0 == marker }).count + guard count >= 3 else { return nil } + let suffix = candidate.dropFirst(count) + guard marker != "`" || !suffix.contains("`") else { return nil } + return (marker, count) + } + + private static func isClosingFence( + _ line: Substring, + marker: Character, + minimumCount: Int) -> Bool + { + let leadingSpaces = line.prefix(while: { $0 == " " }).count + guard leadingSpaces <= 3 else { return false } + let candidate = line.dropFirst(leadingSpaces) + let count = candidate.prefix(while: { $0 == marker }).count + return count >= minimumCount && candidate.dropFirst(count).allSatisfy(\.isWhitespace) + } + + private static func removingInlineCode(from line: String) -> String { + let pattern = #"(? URL { + var dir = URL(filePath: #filePath).deletingLastPathComponent() + while true { + let candidate = dir.appending(path: "Package.swift") + if FileManager.default.fileExists(atPath: candidate.path(percentEncoded: false)) { + return dir + } + let parent = dir.deletingLastPathComponent() + guard parent != dir else { break } + dir = parent + } + throw NSError(domain: "DocumentationLinkTests", code: 2, userInfo: [ + NSLocalizedDescriptionKey: "Could not locate repo root (Package.swift) from \(#filePath)", + ]) + } +} diff --git a/Tests/CodexBarTests/DoubaoMenuCardModelTests.swift b/Tests/CodexBarTests/DoubaoMenuCardModelTests.swift new file mode 100644 index 0000000000..cccc0c1ab0 --- /dev/null +++ b/Tests/CodexBarTests/DoubaoMenuCardModelTests.swift @@ -0,0 +1,143 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +struct DoubaoMenuCardModelTests { + @Test + @MainActor + func `team plan metric title discloses its edition`() throws { + let now = Date(timeIntervalSince1970: 1_742_771_200) + let snapshot = UsageSnapshot( + primary: nil, + secondary: nil, + tertiary: nil, + extraRateWindows: [ + NamedRateWindow( + id: "doubao-coding-team-session", + title: "5-hour", + window: RateWindow( + usedPercent: 25, + windowMinutes: 300, + resetsAt: nil, + resetDescription: nil)), + ], + updatedAt: now, + identity: nil) + let metadata = try #require(ProviderDefaults.metadata[.doubao]) + let model = UsageMenuCardView.Model.make(.init( + provider: .doubao, + metadata: metadata, + snapshot: snapshot, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + + let metric = try #require(model.metrics.first) + #expect(metric.id == "doubao-coding-team-session") + #expect(UsageMenuCardView.popupMetricTitle(provider: .doubao, metric: metric) == "5-hour (Team)") + } + + @Test + func `coding plan monthly quota shows deficit and run out details`() throws { + let now = Date(timeIntervalSince1970: 10_368_000) // 1970-05-01T00:00:00Z + let reset = now.addingTimeInterval(6 * 24 * 3600) + let snapshot = UsageSnapshot( + primary: RateWindow( + usedPercent: 25, + windowMinutes: 5 * 60, + resetsAt: now.addingTimeInterval(2 * 3600), + resetDescription: nil), + secondary: RateWindow( + usedPercent: 40, + windowMinutes: 7 * 24 * 60, + resetsAt: now.addingTimeInterval(4 * 24 * 3600), + resetDescription: nil), + tertiary: RateWindow( + usedPercent: 90, + windowMinutes: 30 * 24 * 60, + resetsAt: reset, + resetDescription: nil), + updatedAt: now, + identity: nil) + let metadata = try #require(ProviderDefaults.metadata[.doubao]) + + let model = UsageMenuCardView.Model.make(.init( + provider: .doubao, + metadata: metadata, + snapshot: snapshot, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + + #expect(model.metrics.map(\.title) == ["5-hour", "Weekly", "Monthly"]) + let monthly = try #require(model.metrics.first { $0.id == "tertiary" }) + #expect(monthly.percentLabel == "10% left") + #expect(monthly.detailLeftText == "10% in deficit") + #expect(monthly.detailRightText == "Runs out in 2d 16h") + #expect(monthly.pacePercent == 20) + #expect(monthly.paceOnTop == false) + } + + @Test + func `unknown request limit renders unavailable instead of full quota`() throws { + let now = Date(timeIntervalSince1970: 1_742_771_200) + let metadata = try #require(ProviderDefaults.metadata[.doubao]) + let snapshot = DoubaoUsageSnapshot( + remainingRequests: 0, + limitRequests: 1000, + resetTime: nil, + updatedAt: now, + apiKeyValid: true, + requestLimitsReliable: false) + .toUsageSnapshot() + + let model = UsageMenuCardView.Model.make(.init( + provider: .doubao, + metadata: metadata, + snapshot: snapshot, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + + #expect(model.metrics.isEmpty) + #expect(model.placeholder == "Limits not available") + #expect(model.subtitleStyle == .info) + } +} diff --git a/Tests/CodexBarTests/DoubaoProviderTests.swift b/Tests/CodexBarTests/DoubaoProviderTests.swift index 9ce12c25eb..dccf046895 100644 --- a/Tests/CodexBarTests/DoubaoProviderTests.swift +++ b/Tests/CodexBarTests/DoubaoProviderTests.swift @@ -1,6 +1,25 @@ -import CodexBarCore import Foundation import Testing +@testable import CodexBarCore + +private enum DoubaoProviderTestError: Error { + case signedFailed + case arkShouldNotRun +} + +private struct DoubaoProviderTestClaudeFetcher: ClaudeUsageFetching { + func loadLatestUsage(model _: String) async throws -> ClaudeUsageSnapshot { + throw DoubaoProviderTestError.signedFailed + } + + func debugRawProbe(model _: String) async -> String { + "stub" + } + + func detectVersion() -> String? { + nil + } +} struct DoubaoProviderTests { @Test @@ -22,7 +41,7 @@ struct DoubaoProviderTests { } @Test - func `usage snapshot shows active key when headers are absent`() { + func `usage snapshot omits unknown request limit when headers are absent`() { let now = Date(timeIntervalSince1970: 1_742_771_200) let snapshot = DoubaoUsageSnapshot( remainingRequests: 0, @@ -33,7 +52,327 @@ struct DoubaoProviderTests { let usage = snapshot.toUsageSnapshot() - #expect(usage.primary?.usedPercent == 0) - #expect(usage.primary?.resetDescription == "Active - check dashboard for details") + #expect(usage.primary == nil) + #expect(usage.rateLimitsUnavailable(for: .doubao)) + } + + @Test + func `primary label preserves ark request windows`() { + let arkWindow = RateWindow( + usedPercent: 30, + windowMinutes: nil, + resetsAt: nil, + resetDescription: "3/10 requests") + let codingPlanWindow = RateWindow( + usedPercent: 30, + windowMinutes: 5 * 60, + resetsAt: nil, + resetDescription: "30% used") + let unavailableWindow = RateWindow( + usedPercent: 0, + windowMinutes: nil, + resetsAt: nil, + resetDescription: "No usage data") + + #expect(DoubaoProviderDescriptor.primaryLabel(window: arkWindow) == "Requests") + #expect(DoubaoProviderDescriptor.primaryLabel(window: codingPlanWindow) == nil) + #expect(DoubaoProviderDescriptor.primaryLabel(window: unavailableWindow) == nil) + } + + // MARK: - CLI strategy tests + + @Test + func `cli strategy returns usage from arkcli`() async throws { + let expectedDate = Date(timeIntervalSince1970: 42) + let context = Self.makeContext( + sourceMode: .cli, + environment: ["ARKCLI_PATH": "/trusted/arkcli"]) + let strategy = DoubaoCLIFetchStrategy( + cliUsageLoader: { environment in + #expect(environment["ARKCLI_PATH"] == "/trusted/arkcli") + return DoubaoUsageSnapshot( + remainingRequests: 0, + limitRequests: 0, + resetTime: nil, + updatedAt: expectedDate, + apiKeyValid: true, + codingPlanUsage: DoubaoCodingPlanUsage( + status: "subscribed", + updateTime: expectedDate, + quotas: [ + DoubaoCodingPlanUsage.Quota(level: "session", percent: 42.0, resetTime: nil), + ])) + }) + + let result = try await strategy.fetch(context) + + #expect(result.sourceLabel == "cli") + #expect(result.strategyID == "doubao.cli") + #expect(result.strategyKind == .cli) + #expect(result.usage.primary?.usedPercent == 42.0) + } + + @Test + func `cli strategy does not cross authentication sources on failure`() { + let context = Self.makeContext(sourceMode: .auto) + let strategy = DoubaoCLIFetchStrategy( + cliUsageLoader: { _ in + throw DoubaoProviderTestError.signedFailed + }) + + #expect(strategy.shouldFallback(on: DoubaoProviderTestError.signedFailed, context: context) == false) + } + + @Test + func `cli strategy does not fall back in explicit cli mode`() { + let context = Self.makeContext(sourceMode: .cli) + let strategy = DoubaoCLIFetchStrategy( + cliUsageLoader: { _ in + throw DoubaoProviderTestError.signedFailed + }) + + #expect(strategy.shouldFallback(on: DoubaoProviderTestError.signedFailed, context: context) == false) + } + + @Test + func `cli cancellation does not fall back to api`() { + let context = Self.makeContext(sourceMode: .auto) + let strategy = DoubaoCLIFetchStrategy( + cliUsageLoader: { _ in + throw CancellationError() + }) + + #expect(strategy.shouldFallback(on: CancellationError(), context: context) == false) + } + + // MARK: - API strategy tests + + @Test + func `api strategy uses ak/sk signed credentials when available`() async throws { + let expectedDate = Date(timeIntervalSince1970: 99) + let context = Self.makeContext( + sourceMode: .api, + environment: [ + DoubaoSettingsReader.accessKeyIDEnvironmentKeys[0]: "AKLTtest", + DoubaoSettingsReader.secretAccessKeyEnvironmentKeys[0]: "secret123", + ]) + let strategy = DoubaoAPIFetchStrategy( + signedUsageLoader: { credentials in + #expect(credentials.accessKeyID == "AKLTtest") + #expect(credentials.secretAccessKey == "secret123") + return DoubaoUsageSnapshot( + remainingRequests: 0, + limitRequests: 0, + resetTime: nil, + updatedAt: expectedDate, + apiKeyValid: true, + codingPlanUsage: DoubaoCodingPlanUsage( + status: "subscribed", + updateTime: expectedDate, + quotas: [ + DoubaoCodingPlanUsage.Quota(level: "session", percent: 15.0, resetTime: nil), + ])) + }, + arkUsageLoader: { _ in + Issue.record("Ark probe should not run when signed credentials succeed") + throw DoubaoProviderTestError.arkShouldNotRun + }) + + let result = try await strategy.fetch(context) + + #expect(result.sourceLabel == "api") + #expect(result.strategyID == "doubao.api") + #expect(result.strategyKind == .apiToken) + #expect(result.usage.primary?.usedPercent == 15.0) + } + + @Test + func `api strategy falls back to ark key probe when signed credentials fail`() async throws { + let expectedDate = Date(timeIntervalSince1970: 42) + let context = Self.makeContext( + sourceMode: .api, + environment: [ + DoubaoSettingsReader.accessKeyIDEnvironmentKeys[0]: "AKLTtest", + DoubaoSettingsReader.secretAccessKeyEnvironmentKeys[0]: "secret123", + DoubaoSettingsReader.apiKeyEnvironmentKeys[0]: "ark-env", + ]) + let strategy = DoubaoAPIFetchStrategy( + signedUsageLoader: { _ in + throw DoubaoProviderTestError.signedFailed + }, + arkUsageLoader: { apiKey in + #expect(apiKey == "ark-env") + return DoubaoUsageSnapshot( + remainingRequests: 7, + limitRequests: 10, + resetTime: expectedDate, + updatedAt: expectedDate, + apiKeyValid: true) + }) + + let result = try await strategy.fetch(context) + + #expect(result.sourceLabel == "api") + #expect(result.usage.primary?.usedPercent == 30) + #expect(DoubaoProviderDescriptor.primaryLabel(window: result.usage.primary) == "Requests") + } + + @Test + func `api strategy does not fall back to cli on failure`() { + let context = Self.makeContext(sourceMode: .api) + let strategy = DoubaoAPIFetchStrategy( + signedUsageLoader: { _ in + throw DoubaoProviderTestError.signedFailed + }, + arkUsageLoader: { _ in + throw DoubaoProviderTestError.signedFailed + }) + + #expect(strategy.shouldFallback(on: DoubaoProviderTestError.signedFailed, context: context) == false) + } + + @Test + func `api strategy uses ark key probe when no ak/sk credentials`() async throws { + let expectedDate = Date(timeIntervalSince1970: 42) + let context = Self.makeContext( + sourceMode: .api, + environment: [ + DoubaoSettingsReader.apiKeyEnvironmentKeys[0]: "ark-env", + ]) + let strategy = DoubaoAPIFetchStrategy( + signedUsageLoader: { _ in + Issue.record("Signed loader should not run without AK/SK credentials") + throw DoubaoProviderTestError.signedFailed + }, + arkUsageLoader: { apiKey in + #expect(apiKey == "ark-env") + return DoubaoUsageSnapshot( + remainingRequests: 7, + limitRequests: 10, + resetTime: expectedDate, + updatedAt: expectedDate, + apiKeyValid: true) + }) + + let result = try await strategy.fetch(context) + + #expect(result.sourceLabel == "api") + #expect(result.usage.primary?.usedPercent == 30) + } + + @Test + func `api strategy cancellation does not fall back to ark key`() async { + let context = Self.makeContext( + sourceMode: .api, + environment: [ + DoubaoSettingsReader.accessKeyIDEnvironmentKeys[0]: "AKLTtest", + DoubaoSettingsReader.secretAccessKeyEnvironmentKeys[0]: "secret123", + ]) + let strategy = DoubaoAPIFetchStrategy( + signedUsageLoader: { _ in + throw CancellationError() + }, + arkUsageLoader: { _ in + Issue.record("Ark fallback should not run after cancellation") + throw DoubaoProviderTestError.arkShouldNotRun + }) + + await #expect(throws: CancellationError.self) { + try await strategy.fetch(context) + } + } + + @Test + func `api strategy surfaces signed error when no api key available`() async { + // AK/SK credentials present but signed request fails, and no Ark API key + // is configured. The signed error (not a generic "missing key") should surface. + let context = Self.makeContext( + sourceMode: .api, + environment: [ + DoubaoSettingsReader.accessKeyIDEnvironmentKeys[0]: "AKLTtest", + DoubaoSettingsReader.secretAccessKeyEnvironmentKeys[0]: "secret123", + ]) + let strategy = DoubaoAPIFetchStrategy( + signedUsageLoader: { _ in + throw DoubaoUsageError.apiError(403, "SignatureExpired") + }, + arkUsageLoader: { _ in + Issue.record("Ark probe should not run when no API key is configured") + throw DoubaoProviderTestError.arkShouldNotRun + }) + + await #expect { + try await strategy.fetch(context) + } throws: { error in + guard case let DoubaoUsageError.apiError(code, _) = error else { return false } + return code == 403 + } + } + + // MARK: - resolveStrategies routing tests + + @Test + func `auto mode uses cli when api credentials are absent`() async { + let context = Self.makeContext(sourceMode: .auto) + let strategies = await DoubaoProviderDescriptor.resolveStrategies(context: context) + + #expect(strategies.count == 1) + #expect(strategies[0].id == "doubao.cli") + #expect(strategies[0].kind == .cli) + } + + @Test + func `auto mode preserves configured api account over ambient cli`() async { + let context = Self.makeContext( + sourceMode: .auto, + environment: [ + DoubaoSettingsReader.apiKeyEnvironmentKeys[0]: "ark-configured-account", + "ARKCLI_PATH": "/ambient/other-account/arkcli", + ]) + let strategies = await DoubaoProviderDescriptor.resolveStrategies(context: context) + + #expect(strategies.count == 1) + #expect(strategies[0].id == "doubao.api") + #expect(strategies[0].kind == .apiToken) + } + + @Test + func `explicit cli mode returns only cli strategy`() async { + let context = Self.makeContext(sourceMode: .cli) + let strategies = await DoubaoProviderDescriptor.resolveStrategies(context: context) + + #expect(strategies.count == 1) + #expect(strategies[0].id == "doubao.cli") + #expect(strategies[0].kind == .cli) + } + + @Test + func `explicit api mode returns only api strategy`() async { + let context = Self.makeContext(sourceMode: .api) + let strategies = await DoubaoProviderDescriptor.resolveStrategies(context: context) + + #expect(strategies.count == 1) + #expect(strategies[0].id == "doubao.api") + #expect(strategies[0].kind == .apiToken) + } + + private static func makeContext( + sourceMode: ProviderSourceMode = .api, + environment: [String: String] = [:]) + -> ProviderFetchContext + { + let browserDetection = BrowserDetection(cacheTTL: 0) + return ProviderFetchContext( + runtime: .app, + sourceMode: sourceMode, + includeCredits: false, + webTimeout: 1, + webDebugDumpHTML: false, + verbose: false, + env: environment, + settings: nil, + fetcher: UsageFetcher(environment: environment), + claudeFetcher: DoubaoProviderTestClaudeFetcher(), + browserDetection: browserDetection) } } diff --git a/Tests/CodexBarTests/DoubaoUsageFetcherTests.swift b/Tests/CodexBarTests/DoubaoUsageFetcherTests.swift new file mode 100644 index 0000000000..cd913e6001 --- /dev/null +++ b/Tests/CodexBarTests/DoubaoUsageFetcherTests.swift @@ -0,0 +1,1272 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct DoubaoUsageSnapshotTests { + @Test + func `normal usage with both headers present and non-empty reports correct percent`() { + let snapshot = DoubaoUsageSnapshot( + remainingRequests: 750, + limitRequests: 1000, + resetTime: nil, + updatedAt: Date(), + apiKeyValid: true) + let usage = snapshot.toUsageSnapshot() + #expect(usage.primary?.usedPercent == 25) + #expect(usage.primary?.resetDescription == "250/1000 requests") + } + + @Test + func `boundary normal usage at near-full reports correct percent`() { + let snapshot = DoubaoUsageSnapshot( + remainingRequests: 1, + limitRequests: 1000, + resetTime: nil, + updatedAt: Date(), + apiKeyValid: true) + let usage = snapshot.toUsageSnapshot() + #expect(usage.primary?.usedPercent == 99.9) + #expect(usage.primary?.resetDescription == "999/1000 requests") + } + + @Test + func `unreliable headers omit the request limit window`() { + let snapshot = DoubaoUsageSnapshot( + remainingRequests: 0, + limitRequests: 1000, + resetTime: nil, + updatedAt: Date(), + apiKeyValid: true, + requestLimitsReliable: false) + let usage = snapshot.toUsageSnapshot() + #expect(usage.primary == nil) + #expect(usage.rateLimitsUnavailable(for: .doubao)) + } + + @Test + func `explicit rate limit with zero remaining reports exhausted quota`() { + let snapshot = DoubaoUsageSnapshot( + remainingRequests: 0, + limitRequests: 1000, + resetTime: nil, + updatedAt: Date(), + apiKeyValid: true) + let usage = snapshot.toUsageSnapshot() + #expect(usage.primary?.usedPercent == 100) + #expect(usage.primary?.resetDescription == "1000/1000 requests") + } + + @Test + func `both headers missing but key valid omit the request limit window`() { + let snapshot = DoubaoUsageSnapshot( + remainingRequests: 0, + limitRequests: 0, + resetTime: nil, + updatedAt: Date(), + apiKeyValid: true) + let usage = snapshot.toUsageSnapshot() + #expect(usage.primary == nil) + #expect(usage.rateLimitsUnavailable(for: .doubao)) + } + + @Test + func `invalid key with no headers reports No usage data`() { + let snapshot = DoubaoUsageSnapshot( + remainingRequests: 0, + limitRequests: 0, + resetTime: nil, + updatedAt: Date(), + apiKeyValid: false) + let usage = snapshot.toUsageSnapshot() + #expect(usage.primary?.usedPercent == 0) + #expect(usage.primary?.resetDescription == "No usage data") + } + + @Test + func `provider identity is correctly tagged as doubao`() { + let snapshot = DoubaoUsageSnapshot( + remainingRequests: 500, + limitRequests: 1000, + resetTime: nil, + updatedAt: Date(), + apiKeyValid: true) + let usage = snapshot.toUsageSnapshot() + #expect(usage.identity?.providerID == .doubao) + #expect(usage.identity?.accountEmail == nil) + } +} + +struct DoubaoUsageFetcherTests { + @Test + func `coding plan response maps session weekly and monthly windows`() throws { + let data = Data( + """ + { + "ResponseMetadata": { + "Action": "GetCodingPlanUsage", + "Version": "2024-01-01", + "Service": "ark", + "Region": "cn-beijing" + }, + "Result": { + "Status": "Running", + "UpdateTimestamp": 1782226444, + "QuotaUsage": [ + {"Level":"session","Percent":0.116,"ResetTimestamp":1782226478}, + {"Level":"weekly","Percent":3.182143,"ResetTimestamp":1782662400}, + {"Level":"monthly","Percent":7.5730535,"ResetTimestamp":1782403199} + ] + } + } + """.utf8) + + let usage = try DoubaoUsageFetcher.decodeCodingPlanUsage(from: data).toUsageSnapshot( + updatedAt: Date(timeIntervalSince1970: 0)) + + #expect(usage.primary?.usedPercent == 0.116) + #expect(usage.primary?.windowMinutes == 300) + #expect(usage.primary?.resetsAt == Date(timeIntervalSince1970: 1_782_226_478)) + #expect(usage.primary?.resetDescription == nil) + #expect(usage.secondary?.usedPercent == 3.182143) + #expect(usage.secondary?.windowMinutes == 10080) + #expect(usage.tertiary?.usedPercent == 7.5730535) + #expect(usage.tertiary?.windowMinutes == 43200) + #expect(usage.identity?.providerID == .doubao) + #expect(usage.identity?.loginMethod == "Running") + } + + @Test + func `coding plan response ignores missing reset sentinels`() throws { + let fallbackUpdatedAt = Date(timeIntervalSince1970: 42) + let data = Data( + """ + { + "Result": { + "Status": "Running", + "UpdateTimestamp": 0, + "QuotaUsage": [ + {"Level":"session","Percent":12.5,"ResetTimestamp":0}, + {"Level":"weekly","Percent":24,"ResetTimestamp":-1} + ] + } + } + """.utf8) + + let usage = try DoubaoUsageFetcher.decodeCodingPlanUsage(from: data).toUsageSnapshot( + updatedAt: fallbackUpdatedAt) + + #expect(usage.updatedAt == fallbackUpdatedAt) + #expect(usage.primary?.usedPercent == 12.5) + #expect(usage.primary?.resetsAt == nil) + #expect(usage.primary?.resetDescription == nil) + #expect(usage.secondary?.usedPercent == 24) + #expect(usage.secondary?.resetsAt == nil) + #expect(usage.secondary?.resetDescription == nil) + } + + @Test + func `coding plan fetch signs volcengine request`() async throws { + let transport = DoubaoScriptedTransport(results: [ + .rawResponse( + statusCode: 200, + body: """ + { + "Result": { + "Status": "Running", + "UpdateTimestamp": 1782226444, + "QuotaUsage": [ + {"Level":"session","Percent":12.5,"ResetTimestamp":1782226478} + ] + } + } + """), + ]) + let credentials = DoubaoCodingPlanCredentials( + accessKeyID: "AKLTTEST", + secretAccessKey: "secret", + region: "cn-beijing") + let date = Date(timeIntervalSince1970: 1_781_654_400) + + let snapshot = try await DoubaoUsageFetcher.fetchCodingPlanUsage( + credentials: credentials, + session: transport, + date: date) + let request = await transport.lastCapturedRequest() + + #expect(snapshot.toUsageSnapshot().primary?.usedPercent == 12.5) + #expect(request?.method == "POST") + #expect(request?.url == "https://open.volcengineapi.com/?Action=GetCodingPlanUsage&Version=2024-01-01") + #expect(request?.host == "open.volcengineapi.com") + #expect(request?.date == "20260617T000000Z") + #expect(request?.contentSHA256 == + "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855") + #expect(request?.authorization?.contains( + "HMAC-SHA256 Credential=AKLTTEST/20260617/cn-beijing/ark/request") == true) + #expect(request?.authorization?.contains( + "SignedHeaders=content-type;host;x-content-sha256;x-date") == true) + } + + @Test + func `coding plan fetch surfaces volcengine access denied error`() async { + let transport = DoubaoScriptedTransport(results: [ + .rawResponse( + statusCode: 403, + body: """ + { + "ResponseMetadata": { + "Action": "GetCodingPlanUsage", + "Error": { + "CodeN": 100013, + "Code": "AccessDenied", + "Message": "User is not authorized to perform: ark:GetCodingPlanUsage" + } + } + } + """), + ]) + let credentials = DoubaoCodingPlanCredentials( + accessKeyID: "AKLTTEST", + secretAccessKey: "secret", + region: "cn-beijing") + + await #expect { + _ = try await DoubaoUsageFetcher.fetchCodingPlanUsage( + credentials: credentials, + session: transport, + date: Date(timeIntervalSince1970: 1_781_654_400)) + } throws: { error in + guard case let DoubaoUsageError.apiError(code, message) = error else { return false } + return code == 403 + && message.contains("AccessDenied") + && message.contains("ark:GetCodingPlanUsage") + && !message.contains("bytes") + } + } + + @Test + func `arkcli response maps coding plan and agent plan windows`() throws { + let data = Data( + """ + { + "viewer": { + "auth_method": "sso", + "profile": "agent-plan_cn-beijing_personal" + }, + "items": [ + { + "product": "agent-plan", + "subscribed": true, + "periods": [ + {"label": "5h", "total": 2000, "percent": 0}, + { + "label": "weekly", "used": 2009.33, "total": 7000, "percent": 28.7, + "reset_at": "2026-07-20T00:00:00+08:00" + }, + { + "label": "monthly", "used": 2009.33, "total": 20000, "percent": 10.05, + "reset_at": "2026-08-14T23:59:59+08:00" + } + ] + }, + { + "product": "coding-plan", + "subscribed": true, + "periods": [ + {"label": "session", "percent": 7.48, "reset_at": "2026-07-16T19:12:07+08:00"}, + {"label": "weekly", "percent": 2.71, "reset_at": "2026-07-20T00:00:00+08:00"}, + {"label": "monthly", "percent": 1.36, "reset_at": "2026-08-15T23:59:59+08:00"} + ], + "updated_at": 1784191193000 + } + ] + } + """.utf8) + + let usage = try DoubaoUsageFetcher.decodeArkcliUsage(from: data).toUsageSnapshot( + updatedAt: Date(timeIntervalSince1970: 0)) + + // Coding plan should be primary/secondary/tertiary + #expect(usage.primary?.usedPercent == 7.48) + #expect(usage.primary?.windowMinutes == 300) + #expect(usage.secondary?.usedPercent == 2.71) + #expect(usage.secondary?.windowMinutes == 10080) + #expect(usage.tertiary?.usedPercent == 1.36) + #expect(usage.tertiary?.windowMinutes == 43200) + + // Agent plan should appear as extra rate windows + let agentWindows = usage.extraRateWindows ?? [] + #expect(agentWindows.count == 3) + #expect(agentWindows[0].title == "5-hour") + #expect(agentWindows[0].window.usedPercent == 0) + #expect(agentWindows[1].title == "Weekly") + #expect(agentWindows[1].window.usedPercent == 28.7) + #expect(agentWindows[2].title == "Monthly") + #expect(agentWindows[2].window.usedPercent == 10.05) + + // Update time from coding-plan's updated_at + #expect(usage.updatedAt == Date(timeIntervalSince1970: 1_784_191_193)) + #expect(usage.identity?.providerID == .doubao) + #expect(usage.identity?.loginMethod == "sso") + } + + @Test + func `arkcli response handles missing reset_at fields`() throws { + let data = Data( + """ + { + "items": [ + { + "product": "coding-plan", + "periods": [ + {"label": "session", "percent": 12.5}, + {"label": "weekly", "percent": 24.0, "reset_at": "2026-07-20T00:00:00+08:00"} + ] + } + ] + } + """.utf8) + + let usage = try DoubaoUsageFetcher.decodeArkcliUsage(from: data).toUsageSnapshot( + updatedAt: Date(timeIntervalSince1970: 42)) + + #expect(usage.primary?.usedPercent == 12.5) + #expect(usage.primary?.resetsAt == nil) + #expect(usage.secondary?.usedPercent == 24.0) + #expect(usage.secondary?.resetsAt != nil) + } + + @Test + func `arkcli response with only agent plan preserves agent window identity`() throws { + let data = Data( + """ + { + "items": [ + { + "product": "agent-plan", + "subscribed": true, + "periods": [ + {"label": "5h", "total": 2000, "percent": 5.0, "reset_at": "2026-07-16T19:12:07+08:00"}, + {"label": "weekly", "percent": 15.0, "reset_at": "2026-07-20T00:00:00+08:00"}, + {"label": "monthly", "percent": 25.0, "reset_at": "2026-08-15T23:59:59+08:00"} + ] + } + ] + } + """.utf8) + + let usage = try DoubaoUsageFetcher.decodeArkcliUsage(from: data).toUsageSnapshot( + updatedAt: Date(timeIntervalSince1970: 0)) + + #expect(usage.primary == nil) + let agentWindows = try #require(usage.extraRateWindows) + #expect(agentWindows.map(\.id) == [ + "doubao-agent-session", + "doubao-agent-weekly", + "doubao-agent-monthly", + ]) + #expect(agentWindows.map(\.window.usedPercent) == [5.0, 15.0, 25.0]) + } + + @Test + func `arkcli team-only plans preserve product identities`() throws { + let data = Data( + """ + { + "items": [ + { + "product": "agent-plan-team", + "edition": "team", + "subscribed": true, + "periods": [ + {"label": "5h", "percent": 5.0}, + {"label": "weekly", "percent": 15.0} + ] + }, + { + "product": "coding-plan-team", + "edition": "team", + "subscribed": true, + "periods": [ + {"label": "session", "percent": 7.48}, + {"label": "monthly", "percent": 25.0} + ] + } + ] + } + """.utf8) + + let usage = try DoubaoUsageFetcher.decodeArkcliUsage(from: data).toUsageSnapshot( + updatedAt: Date(timeIntervalSince1970: 0)) + + #expect(usage.primary == nil) + let windows = try #require(usage.extraRateWindows) + #expect(windows.map(\.id) == [ + "doubao-coding-team-session", + "doubao-coding-team-monthly", + "doubao-agent-team-session", + "doubao-agent-team-weekly", + ]) + #expect(windows.map(\.window.usedPercent) == [7.48, 25.0, 5.0, 15.0]) + } + + @Test + func `arkcli mixed personal and team plans keep every bucket`() throws { + let data = Data( + """ + { + "items": [ + {"product":"coding-plan","periods":[{"label":"session","percent":1}]}, + {"product":"coding-plan-team","periods":[{"label":"session","percent":2}]}, + {"product":"agent-plan","periods":[{"label":"5h","percent":3}]}, + {"product":"agent-plan-team","periods":[{"label":"5h","percent":4}]} + ] + } + """.utf8) + + let usage = try DoubaoUsageFetcher.decodeArkcliUsage(from: data).toUsageSnapshot( + updatedAt: Date(timeIntervalSince1970: 0)) + + #expect(usage.primary?.usedPercent == 1) + let windows = try #require(usage.extraRateWindows) + #expect(windows.map(\.id) == [ + "doubao-agent-session", + "doubao-coding-team-session", + "doubao-agent-team-session", + ]) + #expect(windows.map(\.window.usedPercent) == [3, 2, 4]) + } + + @Test + func `arkcli response with an error-only item still decodes valid buckets`() throws { + let data = Data( + """ + { + "items": [ + { + "product": "coding-plan", + "error": "failed to query usage", + "subscribed": false + }, + { + "product": "agent-plan", + "subscribed": true, + "periods": [ + {"label": "5h", "percent": 5.0, "reset_at": "2026-07-16T19:12:07+08:00"} + ] + } + ] + } + """.utf8) + + let usage = try DoubaoUsageFetcher.decodeArkcliUsage(from: data).toUsageSnapshot( + updatedAt: Date(timeIntervalSince1970: 0)) + + // The error-only coding item is skipped; the agent item still decodes. + #expect(usage.primary == nil) + #expect(usage.extraRateWindows?.first?.window.usedPercent == 5.0) + #expect(usage.identity?.loginMethod == nil) + } + + @Test + func `arkcli explicitly unsubscribed bucket does not contribute stale periods`() throws { + let data = Data( + """ + {"items":[ + { + "product":"coding-plan", "subscribed":false, "updated_at":1784199993, + "periods":[{"label":"session","percent":99}] + }, + { + "product":"agent-plan", "subscribed":true, "updated_at":1784191193, + "periods":[{"label":"5h","percent":5}] + } + ]} + """.utf8) + + let usage = try DoubaoUsageFetcher.decodeArkcliUsage(from: data).toUsageSnapshot( + updatedAt: Date(timeIntervalSince1970: 0)) + + #expect(usage.primary == nil) + #expect(usage.extraRateWindows?.first?.window.usedPercent == 5) + #expect(usage.updatedAt == Date(timeIntervalSince1970: 1_784_191_193)) + } + + @Test + func `arkcli subscribed bucket failure does not silently return partial usage`() { + let data = Data( + """ + { + "items": [ + { + "product": "coding-plan", + "subscribed": true, + "periods": [{"label": "session", "percent": 5}] + }, + { + "product": "agent-plan-team", + "subscribed": true, + "error": "no seat bound to caller" + } + ] + } + """.utf8) + + #expect { + _ = try DoubaoUsageFetcher.decodeArkcliUsage(from: data) + } throws: { error in + guard case let DoubaoUsageError.incompletePlanUsage(message) = error else { return false } + return message == "no seat bound to caller" + } + } + + @Test + func `arkcli active empty bucket without error does not silently return partial usage`() { + let data = Data( + """ + { + "items": [ + { + "product": "coding-plan", + "periods": [{"label": "session", "percent": 5}] + }, + { + "product": "agent-plan", + "subscribed": true, + "periods": [] + } + ] + } + """.utf8) + + #expect { + _ = try DoubaoUsageFetcher.decodeArkcliUsage(from: data) + } throws: { error in + guard case let DoubaoUsageError.incompletePlanUsage(message) = error else { return false } + return message == "agent-plan has no usage periods" + } + } + + @Test + func `arkcli viewer with no authentication requires login`() { + let data = Data( + """ + { + "viewer": {"auth_method": "none"}, + "items": [ + {"product": "coding-plan", "periods": [{"label": "session", "percent": 5}]} + ] + } + """.utf8) + + #expect { + _ = try DoubaoUsageFetcher.decodeArkcliUsage(from: data) + } throws: { error in + guard case DoubaoUsageError.arkcliAuthenticationRequired = error else { return false } + return true + } + } + + @Test + func `arkcli response with only a failed bucket surfaces its error`() { + let data = Data( + """ + { + "items": [ + { + "product": "coding-plan", + "error": "failed to query usage", + "subscribed": false + } + ] + } + """.utf8) + + #expect { + _ = try DoubaoUsageFetcher.decodeArkcliUsage(from: data) + } throws: { error in + guard case let DoubaoUsageError.noPlanUsage(message) = error else { return false } + return message == "failed to query usage" + } + } + + @Test + func `arkcli response with no plan items is not treated as valid usage`() { + #expect { + _ = try DoubaoUsageFetcher.decodeArkcliUsage(from: Data(#"{"items":[]}"#.utf8)) + } throws: { error in + guard case DoubaoUsageError.noPlanUsage(nil) = error else { return false } + return true + } + } + + @Test + func `arkcli response ignores unrelated product buckets`() { + let data = Data( + """ + { + "items": [ + { + "product": "unrelated-plan", + "periods": [{"label": "session", "percent": 99}] + } + ] + } + """.utf8) + + #expect { + _ = try DoubaoUsageFetcher.decodeArkcliUsage(from: data) + } throws: { error in + guard case DoubaoUsageError.noPlanUsage(nil) = error else { return false } + return true + } + } + + @Test + func `arkcli unrelated product failure does not poison valid plan usage`() throws { + let data = Data( + """ + {"items":[ + { + "product":"future-plan", "subscribed":true, + "error":"future product unavailable" + }, + { + "product":"coding-plan", "subscribed":true, + "periods":[{"label":"session","percent":7}] + } + ]} + """.utf8) + + let usage = try DoubaoUsageFetcher.decodeArkcliUsage(from: data).toUsageSnapshot( + updatedAt: Date(timeIntervalSince1970: 0)) + + #expect(usage.primary?.usedPercent == 7) + } + + @Test + func `arkcli response accepts updated_at in seconds`() throws { + // Real arkcli output (0.1.x) emits `updated_at` in epoch seconds, not + // milliseconds. Verify the auto-detection picks the right unit so the + // menu doesn't show a 1970 timestamp. + let data = Data( + """ + { + "items": [ + { + "product": "coding-plan", + "subscribed": true, + "periods": [ + {"label": "session", "percent": 27.3, "reset_at": "2026-07-17T19:22:45+08:00"} + ], + "updated_at": 1784270829 + } + ] + } + """.utf8) + + let usage = try DoubaoUsageFetcher.decodeArkcliUsage(from: data).toUsageSnapshot( + updatedAt: Date(timeIntervalSince1970: 0)) + + #expect(usage.updatedAt == Date(timeIntervalSince1970: 1_784_270_829)) + } + + @Test + func `arkcli response accepts numeric reset timestamps and sentinels`() throws { + let data = Data( + """ + { + "items": [ + { + "product": "coding-plan", + "periods": [ + {"label": "session", "percent": 10, "reset_at": 1784192000}, + {"label": "weekly", "percent": 20, "reset_at": 1784534400000}, + {"label": "monthly", "percent": 30, "reset_at": -1} + ] + } + ] + } + """.utf8) + + let usage = try DoubaoUsageFetcher.decodeArkcliUsage(from: data).toUsageSnapshot( + updatedAt: Date(timeIntervalSince1970: 0)) + + #expect(usage.primary?.resetsAt == Date(timeIntervalSince1970: 1_784_192_000)) + #expect(usage.secondary?.resetsAt == Date(timeIntervalSince1970: 1_784_534_400)) + #expect(usage.tertiary?.resetsAt == nil) + } + + @Test + func `arkcli fetch via injected runner returns parsed snapshot`() async throws { + let jsonData = Data( + """ + { + "items": [ + { + "product": "coding-plan", + "subscribed": true, + "periods": [ + {"label": "session", "percent": 42.0, "reset_at": "2026-07-16T19:12:07+08:00"} + ], + "updated_at": 1784191193000 + } + ] + } + """.utf8) + + let snapshot = try await DoubaoUsageFetcher.fetchCodingPlanUsage( + runArkcli: { jsonData }, + date: Date(timeIntervalSince1970: 0)) + + let usage = snapshot.toUsageSnapshot() + #expect(usage.primary?.usedPercent == 42.0) + #expect(usage.primary?.windowMinutes == 300) + #expect(usage.updatedAt == Date(timeIntervalSince1970: 1_784_191_193)) + } + + @Test + func `arkcli aggregate freshness uses newest contributing bucket`() throws { + let olderFirst = Data( + """ + {"items":[ + { + "product":"coding-plan", "updated_at":1784191193, + "periods":[{"label":"session","percent":1}] + }, + { + "product":"agent-plan", "updated_at":1784191293000, + "periods":[{"label":"5h","percent":2}] + } + ]} + """.utf8) + let newerFirst = Data( + """ + {"items":[ + { + "product":"agent-plan", "updated_at":1784191293000, + "periods":[{"label":"5h","percent":2}] + }, + { + "product":"coding-plan", "updated_at":1784191193, + "periods":[{"label":"session","percent":1}] + } + ]} + """.utf8) + + let expected = Date(timeIntervalSince1970: 1_784_191_293) + #expect(try DoubaoUsageFetcher.decodeArkcliUsage(from: olderFirst).updateTime == expected) + #expect(try DoubaoUsageFetcher.decodeArkcliUsage(from: newerFirst).updateTime == expected) + } + + @Test + func `arkcli subprocess explicitly requests JSON output`() async throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("codexbar-arkcli-arguments-\(UUID().uuidString)", isDirectory: true) + let executable = root.appendingPathComponent("arkcli") + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + try """ + #!/bin/sh + if [ "$*" != "usage plan --format json" ]; then + printf '%s\n' "unexpected arguments: $*" >&2 + exit 2 + fi + printf '%s\n' '{"items":[{"product":"coding-plan","periods":[{"label":"session","percent":42}]}]}' + """.write(to: executable, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: executable.path) + + let snapshot = try await DoubaoUsageFetcher.fetchCodingPlanUsage( + environment: ["ARKCLI_PATH": executable.path]) + + #expect(snapshot.codingPlanUsage?.quotas.first?.percent == 42) + } + + @Test + func `arkcli subprocess uses discovery path for node interpreter`() async throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("codexbar-arkcli-node-path-\(UUID().uuidString)", isDirectory: true) + let executable = root.appendingPathComponent("arkcli") + let node = root.appendingPathComponent("node") + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + try "#!/usr/bin/env node\n".write(to: executable, atomically: true, encoding: .utf8) + try """ + #!/bin/sh + printf '%s\n' '{"items":[{"product":"coding-plan","periods":[{"label":"session","percent":42}]}]}' + """.write(to: node, atomically: true, encoding: .utf8) + for path in [executable.path, node.path] { + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: path) + } + + let data = try await DoubaoUsageFetcher.runArkcliUsagePlan( + environment: ["PATH": "/usr/bin:/bin"], + loginPATH: [root.path]) + let usage = try DoubaoUsageFetcher.decodeArkcliUsage(from: data) + + #expect(usage.quotas.first?.percent == 42) + } + + @Test + func `arkcli fetch surfaces parse error for invalid JSON`() async { + await #expect { + _ = try await DoubaoUsageFetcher.fetchCodingPlanUsage( + runArkcli: { Data("not json".utf8) }) + } throws: { error in + guard case DoubaoUsageError.parseFailed = error else { return false } + return true + } + } + + @Test + func `arkcli nonzero login error surfaces authentication guidance`() async throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("codexbar-arkcli-login-\(UUID().uuidString)", isDirectory: true) + let executable = root.appendingPathComponent("arkcli") + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + try """ + #!/bin/sh + printf '%s\n' 'not logged in; run arkcli auth login' >&2 + exit 1 + """.write(to: executable, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: executable.path) + + await #expect { + _ = try await DoubaoUsageFetcher.fetchCodingPlanUsage( + environment: ["ARKCLI_PATH": executable.path]) + } throws: { error in + guard case DoubaoUsageError.arkcliAuthenticationRequired = error else { return false } + return error.localizedDescription.contains("arkcli auth login") + } + } + + @Test + func `arkcli oversized stdout fails closed before JSON parsing`() async throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("codexbar-arkcli-output-\(UUID().uuidString)", isDirectory: true) + let executable = root.appendingPathComponent("arkcli") + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + try """ + #!/bin/sh + /usr/bin/head -c 300000 /dev/zero + """.write(to: executable, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: executable.path) + + await #expect { + _ = try await DoubaoUsageFetcher.fetchCodingPlanUsage( + environment: ["ARKCLI_PATH": executable.path]) + } throws: { error in + guard case DoubaoUsageError.arkcliOutputTooLarge = error else { return false } + return true + } + } + + @Test + func `missing arkcli error gives setup guidance`() { + let message = DoubaoUsageError.arkcliNotFound.localizedDescription + #expect(message.contains("Install arkcli")) + #expect(message.contains("arkcli auth login")) + } + + @Test + func `repeated successful zero remaining responses omit unknown request limit`() async throws { + let transport = DoubaoScriptedTransport(results: [ + .response(statusCode: 200, limit: 1000, remaining: 0), + .response(statusCode: 200, limit: 1000, remaining: 0), + ]) + + let snapshot = try await DoubaoUsageFetcher.fetchUsage(apiKey: "test-key", session: transport) + let usage = snapshot.toUsageSnapshot() + + #expect(usage.primary == nil) + #expect(usage.rateLimitsUnavailable(for: .doubao)) + #expect(await transport.requestCount() == 2) + } + + @Test + func `successful final request followed by rate limit reports exhausted quota`() async throws { + let transport = DoubaoScriptedTransport(results: [ + .response(statusCode: 200, limit: 1000, remaining: 0), + .response(statusCode: 429, limit: 1000, remaining: 0), + ]) + + let snapshot = try await DoubaoUsageFetcher.fetchUsage(apiKey: "test-key", session: transport) + let usage = snapshot.toUsageSnapshot() + + #expect(usage.primary?.usedPercent == 100) + #expect(usage.primary?.resetDescription == "1000/1000 requests") + #expect(await transport.requestCount() == 2) + } + + @Test + func `headerless rate limit confirmation preserves exhausted quota`() async throws { + let transport = DoubaoScriptedTransport(results: [ + .response(statusCode: 200, limit: 1000, remaining: 0), + .response(statusCode: 429, limit: nil, remaining: nil), + ]) + + let snapshot = try await DoubaoUsageFetcher.fetchUsage(apiKey: "test-key", session: transport) + let usage = snapshot.toUsageSnapshot() + + #expect(usage.primary?.usedPercent == 100) + #expect(usage.primary?.resetDescription == "1000/1000 requests") + #expect(await transport.requestCount() == 2) + } + + @Test + func `rate limit with request limit header reports exhausted quota`() async throws { + let transport = DoubaoScriptedTransport(results: [ + .response(statusCode: 429, limit: 1000, remaining: nil), + ]) + + let snapshot = try await DoubaoUsageFetcher.fetchUsage(apiKey: "test-key", session: transport) + let usage = snapshot.toUsageSnapshot() + + #expect(usage.primary?.usedPercent == 100) + #expect(usage.primary?.resetDescription == "1000/1000 requests") + #expect(await transport.requestCount() == 1) + } + + @Test + func `bare rate limit omits unknown request limit`() async throws { + let transport = DoubaoScriptedTransport(results: [ + .response(statusCode: 429, limit: nil, remaining: nil), + ]) + + let snapshot = try await DoubaoUsageFetcher.fetchUsage(apiKey: "test-key", session: transport) + let usage = snapshot.toUsageSnapshot() + + #expect(usage.primary == nil) + #expect(usage.rateLimitsUnavailable(for: .doubao)) + #expect(await transport.requestCount() == 1) + } + + @Test + func `failed zero remaining confirmation preserves exhausted quota`() async throws { + let transport = DoubaoScriptedTransport(results: [ + .response(statusCode: 200, limit: 1000, remaining: 0), + .failure(URLError(.timedOut)), + ]) + + let snapshot = try await DoubaoUsageFetcher.fetchUsage(apiKey: "test-key", session: transport) + let usage = snapshot.toUsageSnapshot() + + #expect(usage.primary?.usedPercent == 100) + #expect(usage.primary?.resetDescription == "1000/1000 requests") + #expect(await transport.requestCount() == 2) + } + + @Test + func `task cancellation during confirmation propagates`() async { + let transport = DoubaoScriptedTransport(results: [ + .response(statusCode: 200, limit: 1000, remaining: 0), + .cancellation, + ]) + + await #expect(throws: CancellationError.self) { + _ = try await DoubaoUsageFetcher.fetchUsage(apiKey: "test-key", session: transport) + } + #expect(await transport.requestCount() == 2) + } + + @Test + func `url cancellation during confirmation propagates`() async { + let transport = DoubaoScriptedTransport(results: [ + .response(statusCode: 200, limit: 1000, remaining: 0), + .failure(URLError(.cancelled)), + ]) + + await #expect { + _ = try await DoubaoUsageFetcher.fetchUsage(apiKey: "test-key", session: transport) + } throws: { error in + (error as? URLError)?.code == .cancelled + } + #expect(await transport.requestCount() == 2) + } +} + +private actor DoubaoScriptedTransport: ProviderHTTPTransport { + enum Result { + case response(statusCode: Int, limit: Int?, remaining: Int?) + case rawResponse(statusCode: Int, body: String) + case failure(URLError) + case cancellation + } + + struct CapturedRequest { + let url: String? + let method: String? + let host: String? + let date: String? + let contentSHA256: String? + let authorization: String? + } + + private var results: [Result] + private var requests = 0 + private var capturedRequest: CapturedRequest? + + init(results: [Result]) { + self.results = results + } + + func requestCount() -> Int { + self.requests + } + + func lastCapturedRequest() -> CapturedRequest? { + self.capturedRequest + } + + func data(for request: URLRequest) throws -> (Data, URLResponse) { + self.requests += 1 + self.capturedRequest = CapturedRequest( + url: request.url?.absoluteString, + method: request.httpMethod, + host: request.value(forHTTPHeaderField: "Host"), + date: request.value(forHTTPHeaderField: "X-Date"), + contentSHA256: request.value(forHTTPHeaderField: "X-Content-Sha256"), + authorization: request.value(forHTTPHeaderField: "Authorization")) + let result = self.results.removeFirst() + switch result { + case let .response(statusCode, limit, remaining): + var headers: [String: String] = [:] + if let limit { + headers["x-ratelimit-limit-requests"] = String(limit) + } + if let remaining { + headers["x-ratelimit-remaining-requests"] = String(remaining) + } + let response = HTTPURLResponse( + url: request.url!, + statusCode: statusCode, + httpVersion: "HTTP/1.1", + headerFields: headers)! + return (Data(#"{"usage":{"total_tokens":1}}"#.utf8), response) + case let .rawResponse(statusCode, body): + let response = HTTPURLResponse( + url: request.url!, + statusCode: statusCode, + httpVersion: "HTTP/1.1", + headerFields: [:])! + return (Data(body.utf8), response) + case let .failure(error): + throw error + case .cancellation: + throw CancellationError() + } + } +} + +struct DoubaoAgentPlanUsageTests { + @Test + func `reclaimed coding plan decodes as empty quotas rather than throwing`() throws { + // An account that switched from Coding Plan to Agent Plan returns Status only, with no + // `QuotaUsage` key. That must not be a decode failure (it previously surfaced as + // "Failed to parse Doubao response") so the Agent Plan fallback can run. + let data = Data(#"{"Result":{"Status":"Reclaimed","UpdateTimestamp":1785322689}}"#.utf8) + let usage = try DoubaoUsageFetcher.decodeCodingPlanUsage(from: data) + #expect(usage.quotas.isEmpty) + #expect(usage.status == "Reclaimed") + } + + @Test + func `agent plan usage maps AFP windows onto agent rate windows`() throws { + // Real GetAFPUsage body for an Agent Plan "medium" account. ResetTime is epoch + // milliseconds; -1 marks a window with no active reset (zero usage). + let data = Data( + """ + { + "Result": { + "PlanType": "medium", + "AFPFiveHour": {"Quota": 10000, "Used": 0, "ResetTime": -1}, + "AFPWeekly": {"Quota": 35000, "Used": 8750, "ResetTime": 1785686400000}, + "AFPMonthly": {"Quota": 100000, "Used": 25000, "ResetTime": 1787846399000}, + "AFPDaily": {"Quota": 50000, "Used": 0, "ResetTime": 1785340800000} + } + } + """.utf8) + let usage = try DoubaoUsageFetcher.decodeAgentPlanUsage(from: data) + let snapshot = usage.toUsageSnapshot(updatedAt: Date(timeIntervalSince1970: 1_785_300_000)) + + // Coding Plan windows stay empty; the Agent Plan renders through the extra windows, + // matching the arkcli (`.cli`) path so both sources look identical. + #expect(snapshot.primary == nil) + #expect(snapshot.secondary == nil) + #expect(snapshot.tertiary == nil) + + let extra = snapshot.extraRateWindows ?? [] + let fiveHour = extra.first { $0.id == "doubao-agent-session" } + let weekly = extra.first { $0.id == "doubao-agent-weekly" } + let monthly = extra.first { $0.id == "doubao-agent-monthly" } + + #expect(fiveHour?.title == "5-hour") + #expect(fiveHour?.window.usedPercent == 0) + #expect(fiveHour?.window.resetsAt == nil) + + #expect(weekly?.title == "Weekly") + #expect(weekly?.window.usedPercent == 25) // 8750 / 35000 + #expect(weekly?.window.resetsAt == Date(timeIntervalSince1970: 1_785_686_400)) + + #expect(monthly?.title == "Monthly") + #expect(monthly?.window.usedPercent == 25) // 25000 / 100000 + #expect(monthly?.window.resetsAt == Date(timeIntervalSince1970: 1_787_846_399)) + + // `AFPDaily` has no renderer slot and must not leak into the windows. + #expect(extra.count == 3) + } + + @Test + func `coding plan fetch falls back to agent plan when coding plan is reclaimed`() async throws { + let transport = DoubaoScriptedTransport(results: [ + .rawResponse( + statusCode: 200, + body: #"{"Result":{"Status":"Reclaimed","UpdateTimestamp":1785322689}}"#), + .rawResponse( + statusCode: 200, + body: """ + { + "Result": { + "PlanType": "medium", + "AFPFiveHour": {"Quota": 10000, "Used": 0, "ResetTime": -1}, + "AFPWeekly": {"Quota": 35000, "Used": 0, "ResetTime": 1785686400000}, + "AFPMonthly": {"Quota": 100000, "Used": 0, "ResetTime": 1787846399000} + } + } + """), + ]) + let credentials = DoubaoCodingPlanCredentials( + accessKeyID: "AKLTTEST", + secretAccessKey: "secret", + region: "cn-beijing") + + let snapshot = try await DoubaoUsageFetcher.fetchCodingPlanUsage( + credentials: credentials, + session: transport, + date: Date(timeIntervalSince1970: 1_781_654_400)) + + // Both the Coding Plan probe and the Agent Plan fallback were issued, and the + // fallback's second request targeted the GetAFPUsage action. + #expect(await transport.requestCount() == 2) + let request = await transport.lastCapturedRequest() + #expect(request?.url == "https://open.volcengineapi.com/?Action=GetAFPUsage&Version=2024-01-01") + + let usage = snapshot.toUsageSnapshot() + let extra = usage.extraRateWindows ?? [] + #expect(extra.contains { $0.id == "doubao-agent-weekly" && $0.window.usedPercent == 0 }) + #expect(extra.contains { $0.id == "doubao-agent-monthly" }) + } + + @Test + func `coding plan fetch keeps active coding plan without probing agent plan`() async throws { + let transport = DoubaoScriptedTransport(results: [ + .rawResponse( + statusCode: 200, + body: """ + { + "Result": { + "Status": "Running", + "UpdateTimestamp": 1782226444, + "QuotaUsage": [ + {"Level": "session", "Percent": 12.5, "ResetTimestamp": 1782226478} + ] + } + } + """), + ]) + let credentials = DoubaoCodingPlanCredentials( + accessKeyID: "AKLTTEST", + secretAccessKey: "secret", + region: "cn-beijing") + + let snapshot = try await DoubaoUsageFetcher.fetchCodingPlanUsage( + credentials: credentials, + session: transport, + date: Date(timeIntervalSince1970: 1_781_654_400)) + + // An active Coding Plan is returned as-is; no second (Agent Plan) request is made. + #expect(await transport.requestCount() == 1) + #expect(snapshot.toUsageSnapshot().primary?.usedPercent == 12.5) + } + + @Test + func `agent plan fallback surfaces access denied`() async { + let transport = DoubaoScriptedTransport(results: [ + .rawResponse( + statusCode: 200, + body: #"{"Result":{"Status":"Reclaimed"}}"#), + .rawResponse( + statusCode: 403, + body: #"{"ResponseMetadata":{"Error":{"Code":"AccessDenied","Message":"not authorized"}}}"#), + ]) + + await #expect { + _ = try await DoubaoUsageFetcher.fetchCodingPlanUsage( + credentials: Self.credentials, + session: transport) + } throws: { error in + guard case let DoubaoUsageError.apiError(code, message) = error else { return false } + return code == 403 && message.contains("AccessDenied") + } + } + + @Test + func `agent plan fallback surfaces malformed response`() async { + let transport = DoubaoScriptedTransport(results: [ + .rawResponse( + statusCode: 200, + body: #"{"Result":{"Status":"Reclaimed"}}"#), + .rawResponse(statusCode: 200, body: #"{"Result":null}"#), + ]) + + await #expect { + _ = try await DoubaoUsageFetcher.fetchCodingPlanUsage( + credentials: Self.credentials, + session: transport) + } throws: { error in + guard case .parseFailed = error as? DoubaoUsageError else { return false } + return true + } + } + + @Test + func `agent plan fallback surfaces transport failure`() async { + let transport = DoubaoScriptedTransport(results: [ + .rawResponse( + statusCode: 200, + body: #"{"Result":{"Status":"Reclaimed"}}"#), + .failure(URLError(.timedOut)), + ]) + + await #expect { + _ = try await DoubaoUsageFetcher.fetchCodingPlanUsage( + credentials: Self.credentials, + session: transport) + } throws: { error in + guard case let DoubaoUsageError.networkError(message) = error else { return false } + return !message.isEmpty + } + } + + @Test + func `agent plan fallback propagates cancellation`() async { + let transport = DoubaoScriptedTransport(results: [ + .rawResponse( + statusCode: 200, + body: #"{"Result":{"Status":"Reclaimed"}}"#), + .cancellation, + ]) + + await #expect(throws: CancellationError.self) { + _ = try await DoubaoUsageFetcher.fetchCodingPlanUsage( + credentials: Self.credentials, + session: transport) + } + } + + private static let credentials = DoubaoCodingPlanCredentials( + accessKeyID: "AKLTTEST", + secretAccessKey: "secret", + region: "cn-beijing") +} diff --git a/Tests/CodexBarTests/ElevenLabsUsageFetcherTests.swift b/Tests/CodexBarTests/ElevenLabsUsageFetcherTests.swift index 26ce7014af..a178439aa2 100644 --- a/Tests/CodexBarTests/ElevenLabsUsageFetcherTests.swift +++ b/Tests/CodexBarTests/ElevenLabsUsageFetcherTests.swift @@ -149,7 +149,11 @@ struct ElevenLabsUsageFetcherTests { } final class ElevenLabsStubURLProtocol: URLProtocol { - nonisolated(unsafe) static var handler: ((URLRequest) throws -> (HTTPURLResponse, Data))? + private static let _handlerBox = LockIsolated<((URLRequest) throws -> (HTTPURLResponse, Data))?>(nil) + static var handler: ((URLRequest) throws -> (HTTPURLResponse, Data))? { + get { Self._handlerBox.value } + set { Self._handlerBox.setValue(newValue) } + } override static func canInit(with request: URLRequest) -> Bool { request.url?.host == "elevenlabs.test" diff --git a/Tests/CodexBarTests/FactoryAPIKeyUsageTests.swift b/Tests/CodexBarTests/FactoryAPIKeyUsageTests.swift new file mode 100644 index 0000000000..bf11acfd6b --- /dev/null +++ b/Tests/CodexBarTests/FactoryAPIKeyUsageTests.swift @@ -0,0 +1,461 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct FactorySettingsReaderTests { + @Test + func `reads FACTORY_API_KEY from environment`() { + let key = FactorySettingsReader.apiKey( + environment: [FactorySettingsReader.apiTokenKey: " fk-env-key "]) + #expect(key == "fk-env-key") + } + + @Test + func `strips quotes from environment API key`() { + let key = FactorySettingsReader.apiKey( + environment: [FactorySettingsReader.apiTokenKey: "\"fk-quoted\""]) + #expect(key == "fk-quoted") + } + + @Test + func `falls back to factory dot env file`() throws { + let home = FileManager.default.temporaryDirectory + .appendingPathComponent("codexbar-factory-home-\(UUID().uuidString)", isDirectory: true) + let factoryDir = home.appendingPathComponent(".factory", isDirectory: true) + try FileManager.default.createDirectory(at: factoryDir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: home) } + + let envFile = factoryDir.appendingPathComponent(".env") + try "export FACTORY_API_KEY=fk-from-dotenv\n".write(to: envFile, atomically: true, encoding: .utf8) + + let key = FactorySettingsReader.apiKey( + environment: ["HOME": home.path]) + #expect(key == "fk-from-dotenv") + } + + @Test + func `environment wins over factory dot env`() throws { + let home = FileManager.default.temporaryDirectory + .appendingPathComponent("codexbar-factory-home-\(UUID().uuidString)", isDirectory: true) + let factoryDir = home.appendingPathComponent(".factory", isDirectory: true) + try FileManager.default.createDirectory(at: factoryDir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: home) } + + let envFile = factoryDir.appendingPathComponent(".env") + try "FACTORY_API_KEY=fk-from-dotenv\n".write(to: envFile, atomically: true, encoding: .utf8) + + let key = FactorySettingsReader.apiKey( + environment: [ + FactorySettingsReader.apiTokenKey: "fk-env", + "HOME": home.path, + ]) + #expect(key == "fk-env") + } + + @Test + func `skips dotenv when HOME is absent`() { + let key = FactorySettingsReader.apiKey(environment: [:]) + #expect(key == nil) + } + + @Test + func `parses factory dotenv variants`() { + #expect( + FactorySettingsReader.parseFactoryAPIKey(fromDotEnv: "FACTORY_API_KEY=fk-plain") == "fk-plain") + #expect( + FactorySettingsReader.parseFactoryAPIKey(fromDotEnv: "export FACTORY_API_KEY='fk-single'") + == "fk-single") + #expect( + FactorySettingsReader.parseFactoryAPIKey(fromDotEnv: "# comment\nFACTORY_API_KEY=\"fk-double\"") + == "fk-double") + #expect(FactorySettingsReader.parseFactoryAPIKey(fromDotEnv: "OTHER=1\n") == nil) + } +} + +struct FactoryAPIFetchStrategyTests { + private struct StubClaudeFetcher: ClaudeUsageFetching { + func loadLatestUsage(model _: String) async throws -> ClaudeUsageSnapshot { + throw ClaudeUsageError.parseFailed("stub") + } + + func debugRawProbe(model _: String) async -> String { + "stub" + } + + func detectVersion() -> String? { + nil + } + } + + private func makeContext( + env: [String: String] = [:], + sourceMode: ProviderSourceMode = .api) -> ProviderFetchContext + { + ProviderFetchContext( + runtime: .cli, + sourceMode: sourceMode, + includeCredits: false, + webTimeout: 1, + webDebugDumpHTML: false, + verbose: false, + env: env, + settings: nil, + fetcher: UsageFetcher(environment: env), + claudeFetcher: StubClaudeFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0)) + } + + @Test + func `descriptor prefers api then web in auto mode`() async { + let strategies = await FactoryProviderDescriptor.descriptor.fetchPlan.pipeline + .resolveStrategies(self.makeContext(sourceMode: .auto)) + #expect(strategies.map(\.id) == ["factory.api", "factory.web"]) + } + + @Test + func `legacy cli source aliases auto strategies`() async { + let modes = FactoryProviderDescriptor.descriptor.fetchPlan.sourceModes + #expect(modes.contains(.cli)) + #expect(modes.contains(.api)) + #expect(modes.contains(.web)) + + let cli = await FactoryProviderDescriptor.descriptor.fetchPlan.pipeline + .resolveStrategies(self.makeContext(sourceMode: .cli)) + let auto = await FactoryProviderDescriptor.descriptor.fetchPlan.pipeline + .resolveStrategies(self.makeContext(sourceMode: .auto)) + #expect(cli.map(\.id) == auto.map(\.id)) + #expect(cli.map(\.id) == ["factory.api", "factory.web"]) + } + + @Test + func `descriptor isolates api and web source modes`() async { + let api = await FactoryProviderDescriptor.descriptor.fetchPlan.pipeline + .resolveStrategies(self.makeContext(sourceMode: .api)) + let web = await FactoryProviderDescriptor.descriptor.fetchPlan.pipeline + .resolveStrategies(self.makeContext(sourceMode: .web)) + #expect(api.map(\.id) == ["factory.api"]) + #expect(web.map(\.id) == ["factory.web"]) + } + + @Test + func `api strategy available in api mode without key`() async { + let strategy = FactoryAPIFetchStrategy() + #expect(await strategy.isAvailable(self.makeContext(sourceMode: .api))) + } + + @Test + func `api strategy skipped in auto mode without key`() async { + let strategy = FactoryAPIFetchStrategy() + #expect(await !strategy.isAvailable(self.makeContext(sourceMode: .auto))) + } + + @Test + func `api strategy available in auto mode when key present`() async { + let strategy = FactoryAPIFetchStrategy() + let context = self.makeContext( + env: [FactorySettingsReader.apiTokenKey: "fk-test"], + sourceMode: .auto) + #expect(await strategy.isAvailable(context)) + } + + @Test + func `api strategy falls back in auto and cli but not explicit api mode`() { + let strategy = FactoryAPIFetchStrategy() + #expect(strategy.shouldFallback( + on: FactoryStatusProbeError.unauthorizedAPIKey, + context: self.makeContext(sourceMode: .auto))) + #expect(strategy.shouldFallback( + on: FactoryStatusProbeError.unauthorizedAPIKey, + context: self.makeContext(sourceMode: .cli))) + #expect(!strategy.shouldFallback( + on: FactoryStatusProbeError.unauthorizedAPIKey, + context: self.makeContext(sourceMode: .api))) + } + + @Test + func `api strategy surfaces missing key in api mode`() async { + let strategy = FactoryAPIFetchStrategy() + do { + _ = try await strategy.fetch(self.makeContext(env: [:], sourceMode: .api)) + Issue.record("Expected missingAPIKey") + } catch let error as FactoryStatusProbeError { + #expect(error == .missingAPIKey) + } catch { + Issue.record("Unexpected error: \(error)") + } + } +} + +struct FactoryAPIKeyProbeFetchTests { + @Test + func `fetch with api key uses bearer billing limits path`() async throws { + let transport = FactoryAPIKeyStubTransport() + transport.handler = { request in + guard let url = request.url else { throw URLError(.badURL) } + if url.host == "api.factory.ai", url.path == "/api/app/auth/me" { + let body = """ + { + "organization": { + "id": "org_1", + "name": "Acme", + "subscription": { + "factoryTier": "team", + "orbSubscription": { + "plan": { "name": "Team", "id": "plan_1" }, + "status": "active" + } + } + } + } + """ + return Self.makeResponse(url: url, body: body) + } + if url.host == "api.factory.ai", url.path == "/api/billing/limits" { + let body = """ + { + "usesTokenRateLimitsBilling": true, + "limits": { + "standard": { + "fiveHour": { "usedPercent": 12, "secondsRemaining": 3600 }, + "weekly": { "usedPercent": 34, "secondsRemaining": 86400 }, + "monthly": { "usedPercent": 56, "secondsRemaining": 604800 } + } + }, + "extraUsageBalanceCents": 0, + "extraUsageAllowed": false, + "tokenRateLimitsRolloutEligible": true + } + """ + return Self.makeResponse(url: url, body: body) + } + return Self.makeResponse(url: url, body: "{}", statusCode: 404) + } + + let probe = FactoryStatusProbe( + timeout: 1.0, + browserDetection: BrowserDetection( + homeDirectory: "/tmp/codexbar-empty-browser-home", + cacheTTL: 0, + fileExists: { _ in false }, + directoryContents: { _ in nil }), + transport: transport) + + let snapshot = try await probe.fetch(apiKey: "fk-test-key") + let usage = snapshot.toUsageSnapshot() + #expect(usage.primary?.usedPercent == 12) + #expect(usage.secondary?.usedPercent == 34) + #expect(usage.tertiary?.usedPercent == 56) + #expect(transport.requests.contains { request in + request.url?.path == "/api/billing/limits" + && request.value(forHTTPHeaderField: "Authorization") == "Bearer fk-test-key" + }) + } + + @Test + func `fetch with api key maps 401 to notLoggedIn for strategy remapping`() async { + let transport = FactoryAPIKeyStubTransport() + transport.handler = { request in + guard let url = request.url else { throw URLError(.badURL) } + if url.path == "/api/app/auth/me" { + return Self.makeResponse(url: url, body: "{}", statusCode: 401) + } + return Self.makeResponse(url: url, body: "{}", statusCode: 404) + } + + let probe = FactoryStatusProbe( + timeout: 1.0, + browserDetection: BrowserDetection( + homeDirectory: "/tmp/codexbar-empty-browser-home", + cacheTTL: 0, + fileExists: { _ in false }, + directoryContents: { _ in nil }), + transport: transport) + + do { + _ = try await probe.fetch(apiKey: "fk-bad") + Issue.record("Expected notLoggedIn") + } catch let error as FactoryStatusProbeError { + #expect(error == .notLoggedIn) + } catch { + Issue.record("Unexpected error: \(error)") + } + } + + @Test + func `preserves api host 401 over app host 404 for unauthorized mapping`() async { + let transport = FactoryAPIKeyStubTransport() + transport.handler = { request in + guard let url = request.url else { throw URLError(.badURL) } + if url.host == "api.factory.ai", url.path == "/api/app/auth/me" { + return Self.makeResponse(url: url, body: "{}", statusCode: 401) + } + if url.host == "app.factory.ai", url.path == "/api/app/auth/me" { + return Self.makeResponse(url: url, body: "{}", statusCode: 404) + } + return Self.makeResponse(url: url, body: "{}", statusCode: 404) + } + + let probe = FactoryStatusProbe( + timeout: 1.0, + browserDetection: BrowserDetection( + homeDirectory: "/tmp/codexbar-empty-browser-home", + cacheTTL: 0, + fileExists: { _ in false }, + directoryContents: { _ in nil }), + transport: transport) + + do { + _ = try await probe.fetch(apiKey: "fk-bad") + Issue.record("Expected notLoggedIn") + } catch let error as FactoryStatusProbeError { + #expect(error == .notLoggedIn) + } catch { + Issue.record("Unexpected error: \(error)") + } + } + + @Test + func `preserves api host 403 over app host 404 for unauthorized mapping`() async { + let transport = FactoryAPIKeyStubTransport() + transport.handler = { request in + guard let url = request.url else { throw URLError(.badURL) } + if url.host == "api.factory.ai", url.path == "/api/app/auth/me" { + return Self.makeResponse(url: url, body: #"{"detail":"forbidden"}"#, statusCode: 403) + } + if url.host == "app.factory.ai", url.path == "/api/app/auth/me" { + return Self.makeResponse(url: url, body: "{}", statusCode: 404) + } + return Self.makeResponse(url: url, body: "{}", statusCode: 404) + } + + let probe = FactoryStatusProbe( + timeout: 1.0, + browserDetection: BrowserDetection( + homeDirectory: "/tmp/codexbar-empty-browser-home", + cacheTTL: 0, + fileExists: { _ in false }, + directoryContents: { _ in nil }), + transport: transport) + + do { + _ = try await probe.fetch(apiKey: "fk-bad") + Issue.record("Expected networkError HTTP 403") + } catch let error as FactoryStatusProbeError { + switch error { + case let .networkError(message): + #expect(message.contains("HTTP 403")) + default: + Issue.record("Expected networkError, got \(error)") + } + } catch { + Issue.record("Unexpected error: \(error)") + } + } + + @Test + func `api strategy falls back on recoverable api failures in auto mode`() { + let strategy = FactoryAPIFetchStrategy() + let autoContext = ProviderFetchContext( + runtime: .cli, + sourceMode: .auto, + includeCredits: false, + webTimeout: 1, + webDebugDumpHTML: false, + verbose: false, + env: [:], + settings: nil, + fetcher: UsageFetcher(environment: [:]), + claudeFetcher: StubClaudeFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0)) + let apiContext = ProviderFetchContext( + runtime: .cli, + sourceMode: .api, + includeCredits: false, + webTimeout: 1, + webDebugDumpHTML: false, + verbose: false, + env: [:], + settings: nil, + fetcher: UsageFetcher(environment: [:]), + claudeFetcher: StubClaudeFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0)) + + #expect(strategy.shouldFallback( + on: FactoryStatusProbeError.networkError("HTTP 500"), + context: autoContext)) + #expect(strategy.shouldFallback( + on: FactoryStatusProbeError.parseFailed("bad json"), + context: autoContext)) + #expect(strategy.shouldFallback( + on: FactoryStatusProbeError.unauthorizedAPIKey, + context: autoContext)) + #expect(strategy.shouldFallback( + on: URLError(.timedOut), + context: autoContext)) + #expect(!strategy.shouldFallback( + on: CancellationError(), + context: autoContext)) + #expect(!strategy.shouldFallback( + on: FactoryStatusProbeError.networkError("HTTP 500"), + context: apiContext)) + #expect(!strategy.shouldFallback( + on: FactoryStatusProbeError.parseFailed("bad json"), + context: apiContext)) + } + + @Test + func `empty api key throws missingAPIKey`() async { + let probe = FactoryStatusProbe( + browserDetection: BrowserDetection(cacheTTL: 0), + transport: FactoryAPIKeyStubTransport()) + do { + _ = try await probe.fetch(apiKey: " ") + Issue.record("Expected missingAPIKey") + } catch let error as FactoryStatusProbeError { + #expect(error == .missingAPIKey) + } catch { + Issue.record("Unexpected error: \(error)") + } + } + + private struct StubClaudeFetcher: ClaudeUsageFetching { + func loadLatestUsage(model _: String) async throws -> ClaudeUsageSnapshot { + throw ClaudeUsageError.parseFailed("stub") + } + + func debugRawProbe(model _: String) async -> String { + "stub" + } + + func detectVersion() -> String? { + nil + } + } + + private static func makeResponse( + url: URL, + body: String, + statusCode: Int = 200) -> (HTTPURLResponse, Data) + { + let response = HTTPURLResponse( + url: url, + statusCode: statusCode, + httpVersion: "HTTP/1.1", + headerFields: ["Content-Type": "application/json"])! + return (response, Data(body.utf8)) + } +} + +private final class FactoryAPIKeyStubTransport: ProviderHTTPTransport, @unchecked Sendable { + var handler: ((URLRequest) throws -> (HTTPURLResponse, Data))? + var requests: [URLRequest] = [] + + func data(for request: URLRequest) async throws -> (Data, URLResponse) { + self.requests.append(request) + guard let handler else { + throw URLError(.badServerResponse) + } + let (response, data) = try handler(request) + return (data, response) + } +} diff --git a/Tests/CodexBarTests/FactoryProviderImplementationTests.swift b/Tests/CodexBarTests/FactoryProviderImplementationTests.swift index f37f194461..36f3d4d8a8 100644 --- a/Tests/CodexBarTests/FactoryProviderImplementationTests.swift +++ b/Tests/CodexBarTests/FactoryProviderImplementationTests.swift @@ -59,7 +59,6 @@ struct FactoryProviderImplementationTests { minimaxCookieStore: InMemoryMiniMaxCookieStore(), minimaxAPITokenStore: InMemoryMiniMaxAPITokenStore(), kimiTokenStore: InMemoryKimiTokenStore(), - kimiK2TokenStore: InMemoryKimiK2TokenStore(), augmentCookieStore: InMemoryCookieHeaderStore(), ampCookieStore: InMemoryCookieHeaderStore(), copilotTokenStore: InMemoryCopilotTokenStore(), diff --git a/Tests/CodexBarTests/FactoryStatusProbeFetchTests.swift b/Tests/CodexBarTests/FactoryStatusProbeFetchTests.swift index c1d8e2cf41..b39ba9749d 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() @@ -758,7 +760,12 @@ struct FactoryStatusProbeFetchTests { } final class FactoryStubURLProtocol: URLProtocol { - nonisolated(unsafe) static var handler: ((URLRequest) throws -> (HTTPURLResponse, Data))? + private static let _handlerBox = LockIsolated<((URLRequest) throws -> (HTTPURLResponse, Data))?>(nil) + static var handler: ((URLRequest) throws -> (HTTPURLResponse, Data))? { + get { Self._handlerBox.value } + set { Self._handlerBox.setValue(newValue) } + } + nonisolated(unsafe) static var requests: [URLRequest] = [] override static func canInit(with request: URLRequest) -> Bool { @@ -788,3 +795,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/Fixtures/AlibabaTokenPlan/personal_quota_config.json b/Tests/CodexBarTests/Fixtures/AlibabaTokenPlan/personal_quota_config.json new file mode 100644 index 0000000000..ff9d973a86 --- /dev/null +++ b/Tests/CodexBarTests/Fixtures/AlibabaTokenPlan/personal_quota_config.json @@ -0,0 +1,27 @@ +{ + "code": "200", + "data": { + "DataV2": { + "data": { + "success": true, + "data": { + "lite": { + "five_hour": 700, + "weekly": 2500 + }, + "standard": { + "five_hour": 3000, + "weekly": 10000 + }, + "pro": { + "five_hour": 12000, + "weekly": 40000 + } + } + }, + "success": true, + "httpStatus": 200 + } + }, + "successResponse": true +} diff --git a/Tests/CodexBarTests/Fixtures/AlibabaTokenPlan/personal_subscription.json b/Tests/CodexBarTests/Fixtures/AlibabaTokenPlan/personal_subscription.json new file mode 100644 index 0000000000..1f0c689c9a --- /dev/null +++ b/Tests/CodexBarTests/Fixtures/AlibabaTokenPlan/personal_subscription.json @@ -0,0 +1,22 @@ +{ + "code": "200", + "data": { + "DataV2": { + "data": { + "success": true, + "data": { + "instanceCode": "sfm_tokenplansolo_public_cn-redacted", + "specCode": "pro", + "status": "VALID", + "remainingDays": 29, + "autoRenewFlag": false, + "startTime": 1784622404000, + "endTime": 1787328000000 + } + }, + "success": true, + "httpStatus": 200 + } + }, + "successResponse": true +} diff --git a/Tests/CodexBarTests/Fixtures/AlibabaTokenPlan/personal_usage.json b/Tests/CodexBarTests/Fixtures/AlibabaTokenPlan/personal_usage.json new file mode 100644 index 0000000000..a7f05b3d56 --- /dev/null +++ b/Tests/CodexBarTests/Fixtures/AlibabaTokenPlan/personal_usage.json @@ -0,0 +1,19 @@ +{ + "code": "200", + "data": { + "DataV2": { + "data": { + "success": true, + "data": { + "per5HourPercentage": 0.0009973083333333333, + "per5HourResetTime": 1784813220000, + "per1WeekPercentage": 0.0003014725, + "per1WeekResetTime": 1785234900000 + } + }, + "success": true, + "httpStatus": 200 + } + }, + "successResponse": true +} diff --git a/Tests/CodexBarTests/Fixtures/CostUsage/Issue2037/archived-fork-33ce-3869/ORACLE.md b/Tests/CodexBarTests/Fixtures/CostUsage/Issue2037/archived-fork-33ce-3869/ORACLE.md new file mode 100644 index 0000000000..d2c81404b2 --- /dev/null +++ b/Tests/CodexBarTests/Fixtures/CostUsage/Issue2037/archived-fork-33ce-3869/ORACLE.md @@ -0,0 +1,46 @@ +# Hand oracle: archived ordinary fork + +This is a sanitized local Codex archived family. IDs, timestamps, paths, and +the model label are synthetic aliases. The JSONL contains only `session_meta`, +minimal `turn_context`, and `token_count` usage objects; it contains no message +content, tool output, cwd, credentials, or diffs. + +The unit below is the stored `last_token_usage.total_tokens` field. Do **not** +reconstruct it by adding `cached_input_tokens`: cached input is represented +inside the provider's reported total and is not additive here. + +| Stream | Token rows | Sum of `last.total_tokens` | +|---|---:|---:| +| Parent | 135 | 13,432,621 | +| Child | 158 | 15,352,834 | + +The longest contiguous normalized `(last_token_usage, total_token_usage)` +prefix is **N = 135**. Every parent row is copied into the child's first 135 +rows. The remaining 23 child rows are unique: + +```text +copied child prefix = 13,432,621 +child unique suffix = 15,352,834 - 13,432,621 = 1,920,213 + +naive parent + child = 13,432,621 + 15,352,834 = 28,785,455 +parent-owns-prefix = 13,432,621 + 1,920,213 = 15,352,834 +overcount removed = 13,432,621 +``` + +All 135 matched copied rows deliberately have different synthetic event +timestamps between parent and child. Therefore timestamp equality is neither +required nor used by the prefix matcher. Neither file has a decrease in its +stored `total_token_usage.total_tokens` sequence. + +Fixture event timestamps use midday UTC on `2030-01-01` (parent) / +`2030-01-01` fork + `2030-01-02` unique child work so local timezones do not +map parent rows outside a Jan 1–2 report window. + +**Scanner note:** with the parent file present in-window, current `#1164` +inherited-totals accounting already matches the parent-owns-prefix +`scannerUnits` oracle (`Issue2037ScannerIntegrationTests`). This golden locks +that regression. It does **not** cover missing-parent sibling families or +intra-file interleaved Ultra drops. + +This is an ordinary cross-file fork golden, not an Ultra/interleaved golden. It +is P0 evidence and must not be used to claim that #2037 is fixed or closed. diff --git a/Tests/CodexBarTests/Fixtures/CostUsage/Issue2037/archived-fork-33ce-3869/codex-home/archived_sessions/child.jsonl b/Tests/CodexBarTests/Fixtures/CostUsage/Issue2037/archived-fork-33ce-3869/codex-home/archived_sessions/child.jsonl new file mode 100644 index 0000000000..484a281739 --- /dev/null +++ b/Tests/CodexBarTests/Fixtures/CostUsage/Issue2037/archived-fork-33ce-3869/codex-home/archived_sessions/child.jsonl @@ -0,0 +1,160 @@ +{"type":"session_meta","timestamp":"2030-01-01T15:00:00Z","payload":{"id":"child-session","forked_from_id":"parent-session","timestamp":"2030-01-01T15:00:00Z"}} +{"type":"turn_context","timestamp":"2030-01-01T15:00:00Z","payload":{"model":"fixture-model","multi_agent_version":"v1","multi_agent_mode":"fixture-mode"}} +{"type":"event_msg","timestamp":"2030-01-01T15:00:01Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":20273,"cached_input_tokens":4992,"output_tokens":406,"reasoning_output_tokens":172,"total_tokens":20679},"total_token_usage":{"input_tokens":20273,"cached_input_tokens":4992,"output_tokens":406,"reasoning_output_tokens":172,"total_tokens":20679}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:00:02Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":41538,"cached_input_tokens":4992,"output_tokens":427,"reasoning_output_tokens":102,"total_tokens":41965},"total_token_usage":{"input_tokens":61811,"cached_input_tokens":9984,"output_tokens":833,"reasoning_output_tokens":274,"total_tokens":62644}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:00:03Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":46041,"cached_input_tokens":41344,"output_tokens":366,"reasoning_output_tokens":25,"total_tokens":46407},"total_token_usage":{"input_tokens":107852,"cached_input_tokens":51328,"output_tokens":1199,"reasoning_output_tokens":299,"total_tokens":109051}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:00:04Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":51733,"cached_input_tokens":45952,"output_tokens":375,"reasoning_output_tokens":12,"total_tokens":52108},"total_token_usage":{"input_tokens":159585,"cached_input_tokens":97280,"output_tokens":1574,"reasoning_output_tokens":311,"total_tokens":161159}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:00:05Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":62672,"cached_input_tokens":19840,"output_tokens":272,"reasoning_output_tokens":0,"total_tokens":62944},"total_token_usage":{"input_tokens":222257,"cached_input_tokens":117120,"output_tokens":1846,"reasoning_output_tokens":311,"total_tokens":224103}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:00:06Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":77059,"cached_input_tokens":62336,"output_tokens":371,"reasoning_output_tokens":16,"total_tokens":77430},"total_token_usage":{"input_tokens":299316,"cached_input_tokens":179456,"output_tokens":2217,"reasoning_output_tokens":327,"total_tokens":301533}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:00:07Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":87381,"cached_input_tokens":76672,"output_tokens":397,"reasoning_output_tokens":50,"total_tokens":87778},"total_token_usage":{"input_tokens":386697,"cached_input_tokens":256128,"output_tokens":2614,"reasoning_output_tokens":377,"total_tokens":389311}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:00:08Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":91474,"cached_input_tokens":86912,"output_tokens":471,"reasoning_output_tokens":233,"total_tokens":91945},"total_token_usage":{"input_tokens":478171,"cached_input_tokens":343040,"output_tokens":3085,"reasoning_output_tokens":610,"total_tokens":481256}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:00:09Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":99980,"cached_input_tokens":91008,"output_tokens":347,"reasoning_output_tokens":0,"total_tokens":100327},"total_token_usage":{"input_tokens":578151,"cached_input_tokens":434048,"output_tokens":3432,"reasoning_output_tokens":610,"total_tokens":581583}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:00:10Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":104328,"cached_input_tokens":99712,"output_tokens":490,"reasoning_output_tokens":155,"total_tokens":104818},"total_token_usage":{"input_tokens":682479,"cached_input_tokens":533760,"output_tokens":3922,"reasoning_output_tokens":765,"total_tokens":686401}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:00:11Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":109064,"cached_input_tokens":104320,"output_tokens":309,"reasoning_output_tokens":63,"total_tokens":109373},"total_token_usage":{"input_tokens":791543,"cached_input_tokens":638080,"output_tokens":4231,"reasoning_output_tokens":828,"total_tokens":795774}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:00:12Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":111249,"cached_input_tokens":108928,"output_tokens":275,"reasoning_output_tokens":0,"total_tokens":111524},"total_token_usage":{"input_tokens":902792,"cached_input_tokens":747008,"output_tokens":4506,"reasoning_output_tokens":828,"total_tokens":907298}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:00:13Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":114730,"cached_input_tokens":51584,"output_tokens":301,"reasoning_output_tokens":55,"total_tokens":115031},"total_token_usage":{"input_tokens":1017522,"cached_input_tokens":798592,"output_tokens":4807,"reasoning_output_tokens":883,"total_tokens":1022329}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:00:14Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":117769,"cached_input_tokens":110976,"output_tokens":241,"reasoning_output_tokens":93,"total_tokens":118010},"total_token_usage":{"input_tokens":1135291,"cached_input_tokens":909568,"output_tokens":5048,"reasoning_output_tokens":976,"total_tokens":1140339}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:00:15Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":119430,"cached_input_tokens":117632,"output_tokens":981,"reasoning_output_tokens":679,"total_tokens":120411},"total_token_usage":{"input_tokens":1254721,"cached_input_tokens":1027200,"output_tokens":6029,"reasoning_output_tokens":1655,"total_tokens":1260750}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:00:16Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":123097,"cached_input_tokens":119168,"output_tokens":218,"reasoning_output_tokens":0,"total_tokens":123315},"total_token_usage":{"input_tokens":1377818,"cached_input_tokens":1146368,"output_tokens":6247,"reasoning_output_tokens":1655,"total_tokens":1384065}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:00:17Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":126343,"cached_input_tokens":122752,"output_tokens":208,"reasoning_output_tokens":0,"total_tokens":126551},"total_token_usage":{"input_tokens":1504161,"cached_input_tokens":1269120,"output_tokens":6455,"reasoning_output_tokens":1655,"total_tokens":1510616}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:00:18Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":127901,"cached_input_tokens":126336,"output_tokens":438,"reasoning_output_tokens":235,"total_tokens":128339},"total_token_usage":{"input_tokens":1632062,"cached_input_tokens":1395456,"output_tokens":6893,"reasoning_output_tokens":1890,"total_tokens":1638955}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:00:19Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":128401,"cached_input_tokens":127872,"output_tokens":518,"reasoning_output_tokens":258,"total_tokens":128919},"total_token_usage":{"input_tokens":1760463,"cached_input_tokens":1523328,"output_tokens":7411,"reasoning_output_tokens":2148,"total_tokens":1767874}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:00:20Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":130226,"cached_input_tokens":128384,"output_tokens":143,"reasoning_output_tokens":0,"total_tokens":130369},"total_token_usage":{"input_tokens":1890689,"cached_input_tokens":1651712,"output_tokens":7554,"reasoning_output_tokens":2148,"total_tokens":1898243}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:00:21Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":133103,"cached_input_tokens":129920,"output_tokens":66,"reasoning_output_tokens":0,"total_tokens":133169},"total_token_usage":{"input_tokens":2023792,"cached_input_tokens":1781632,"output_tokens":7620,"reasoning_output_tokens":2148,"total_tokens":2031412}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:00:22Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":133538,"cached_input_tokens":132992,"output_tokens":982,"reasoning_output_tokens":612,"total_tokens":134520},"total_token_usage":{"input_tokens":2157330,"cached_input_tokens":1914624,"output_tokens":8602,"reasoning_output_tokens":2760,"total_tokens":2165932}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:00:23Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":134570,"cached_input_tokens":133504,"output_tokens":814,"reasoning_output_tokens":0,"total_tokens":135384},"total_token_usage":{"input_tokens":2291900,"cached_input_tokens":2048128,"output_tokens":9416,"reasoning_output_tokens":2760,"total_tokens":2301316}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:00:24Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":135434,"cached_input_tokens":134528,"output_tokens":151,"reasoning_output_tokens":40,"total_tokens":135585},"total_token_usage":{"input_tokens":2427334,"cached_input_tokens":2182656,"output_tokens":9567,"reasoning_output_tokens":2800,"total_tokens":2436901}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:00:25Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":136816,"cached_input_tokens":135040,"output_tokens":201,"reasoning_output_tokens":35,"total_tokens":137017},"total_token_usage":{"input_tokens":2564150,"cached_input_tokens":2317696,"output_tokens":9768,"reasoning_output_tokens":2835,"total_tokens":2573918}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:00:26Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":139802,"cached_input_tokens":136576,"output_tokens":85,"reasoning_output_tokens":11,"total_tokens":139887},"total_token_usage":{"input_tokens":2703952,"cached_input_tokens":2454272,"output_tokens":9853,"reasoning_output_tokens":2846,"total_tokens":2713805}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:00:27Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":143646,"cached_input_tokens":139648,"output_tokens":82,"reasoning_output_tokens":7,"total_tokens":143728},"total_token_usage":{"input_tokens":2847598,"cached_input_tokens":2593920,"output_tokens":9935,"reasoning_output_tokens":2853,"total_tokens":2857533}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:00:28Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":147052,"cached_input_tokens":143232,"output_tokens":90,"reasoning_output_tokens":9,"total_tokens":147142},"total_token_usage":{"input_tokens":2994650,"cached_input_tokens":2737152,"output_tokens":10025,"reasoning_output_tokens":2862,"total_tokens":3004675}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:00:29Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":148945,"cached_input_tokens":146816,"output_tokens":72,"reasoning_output_tokens":6,"total_tokens":149017},"total_token_usage":{"input_tokens":3143595,"cached_input_tokens":2883968,"output_tokens":10097,"reasoning_output_tokens":2868,"total_tokens":3153692}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:00:30Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":151268,"cached_input_tokens":148864,"output_tokens":76,"reasoning_output_tokens":6,"total_tokens":151344},"total_token_usage":{"input_tokens":3294863,"cached_input_tokens":3032832,"output_tokens":10173,"reasoning_output_tokens":2874,"total_tokens":3305036}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:00:31Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":158145,"cached_input_tokens":150912,"output_tokens":143,"reasoning_output_tokens":33,"total_tokens":158288},"total_token_usage":{"input_tokens":3453008,"cached_input_tokens":3183744,"output_tokens":10316,"reasoning_output_tokens":2907,"total_tokens":3463324}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:00:32Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":170316,"cached_input_tokens":158080,"output_tokens":224,"reasoning_output_tokens":59,"total_tokens":170540},"total_token_usage":{"input_tokens":3623324,"cached_input_tokens":3341824,"output_tokens":10540,"reasoning_output_tokens":2966,"total_tokens":3633864}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:00:33Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":182556,"cached_input_tokens":169856,"output_tokens":505,"reasoning_output_tokens":345,"total_tokens":183061},"total_token_usage":{"input_tokens":3805880,"cached_input_tokens":3511680,"output_tokens":11045,"reasoning_output_tokens":3311,"total_tokens":3816925}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:00:34Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":192775,"cached_input_tokens":182144,"output_tokens":106,"reasoning_output_tokens":29,"total_tokens":192881},"total_token_usage":{"input_tokens":3998655,"cached_input_tokens":3693824,"output_tokens":11151,"reasoning_output_tokens":3340,"total_tokens":4009806}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:00:35Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":204282,"cached_input_tokens":192384,"output_tokens":89,"reasoning_output_tokens":7,"total_tokens":204371},"total_token_usage":{"input_tokens":4202937,"cached_input_tokens":3886208,"output_tokens":11240,"reasoning_output_tokens":3347,"total_tokens":4214177}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:00:36Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":217219,"cached_input_tokens":204160,"output_tokens":85,"reasoning_output_tokens":10,"total_tokens":217304},"total_token_usage":{"input_tokens":4420156,"cached_input_tokens":4090368,"output_tokens":11325,"reasoning_output_tokens":3357,"total_tokens":4431481}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:00:37Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":228774,"cached_input_tokens":216960,"output_tokens":101,"reasoning_output_tokens":20,"total_tokens":228875},"total_token_usage":{"input_tokens":4648930,"cached_input_tokens":4307328,"output_tokens":11426,"reasoning_output_tokens":3377,"total_tokens":4660356}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:00:38Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":238310,"cached_input_tokens":228736,"output_tokens":79,"reasoning_output_tokens":7,"total_tokens":238389},"total_token_usage":{"input_tokens":4887240,"cached_input_tokens":4536064,"output_tokens":11505,"reasoning_output_tokens":3384,"total_tokens":4898745}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:00:39Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":0,"cached_input_tokens":0,"output_tokens":0,"reasoning_output_tokens":0,"total_tokens":16650},"total_token_usage":{"input_tokens":4887240,"cached_input_tokens":4536064,"output_tokens":11505,"reasoning_output_tokens":3384,"total_tokens":4898745}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:00:40Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":21273,"cached_input_tokens":19840,"output_tokens":85,"reasoning_output_tokens":0,"total_tokens":21358},"total_token_usage":{"input_tokens":4908513,"cached_input_tokens":4555904,"output_tokens":11590,"reasoning_output_tokens":3384,"total_tokens":4920103}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:00:41Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":31745,"cached_input_tokens":18304,"output_tokens":151,"reasoning_output_tokens":63,"total_tokens":31896},"total_token_usage":{"input_tokens":4940258,"cached_input_tokens":4574208,"output_tokens":11741,"reasoning_output_tokens":3447,"total_tokens":4951999}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:00:42Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":42752,"cached_input_tokens":31616,"output_tokens":96,"reasoning_output_tokens":10,"total_tokens":42848},"total_token_usage":{"input_tokens":4983010,"cached_input_tokens":4605824,"output_tokens":11837,"reasoning_output_tokens":3457,"total_tokens":4994847}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:00:43Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":47233,"cached_input_tokens":42368,"output_tokens":386,"reasoning_output_tokens":141,"total_tokens":47619},"total_token_usage":{"input_tokens":5030243,"cached_input_tokens":4648192,"output_tokens":12223,"reasoning_output_tokens":3598,"total_tokens":5042466}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:00:44Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":49371,"cached_input_tokens":46976,"output_tokens":317,"reasoning_output_tokens":112,"total_tokens":49688},"total_token_usage":{"input_tokens":5079614,"cached_input_tokens":4695168,"output_tokens":12540,"reasoning_output_tokens":3710,"total_tokens":5092154}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:00:45Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":49856,"cached_input_tokens":4992,"output_tokens":392,"reasoning_output_tokens":162,"total_tokens":50248},"total_token_usage":{"input_tokens":5129470,"cached_input_tokens":4700160,"output_tokens":12932,"reasoning_output_tokens":3872,"total_tokens":5142402}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:00:46Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":51404,"cached_input_tokens":31616,"output_tokens":520,"reasoning_output_tokens":131,"total_tokens":51924},"total_token_usage":{"input_tokens":5180874,"cached_input_tokens":4731776,"output_tokens":13452,"reasoning_output_tokens":4003,"total_tokens":5194326}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:00:47Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":55494,"cached_input_tokens":51072,"output_tokens":283,"reasoning_output_tokens":22,"total_tokens":55777},"total_token_usage":{"input_tokens":5236368,"cached_input_tokens":4782848,"output_tokens":13735,"reasoning_output_tokens":4025,"total_tokens":5250103}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:00:48Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":57511,"cached_input_tokens":55168,"output_tokens":390,"reasoning_output_tokens":0,"total_tokens":57901},"total_token_usage":{"input_tokens":5293879,"cached_input_tokens":4838016,"output_tokens":14125,"reasoning_output_tokens":4025,"total_tokens":5308004}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:00:49Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":65745,"cached_input_tokens":57216,"output_tokens":341,"reasoning_output_tokens":0,"total_tokens":66086},"total_token_usage":{"input_tokens":5359624,"cached_input_tokens":4895232,"output_tokens":14466,"reasoning_output_tokens":4025,"total_tokens":5374090}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:00:50Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":70383,"cached_input_tokens":65408,"output_tokens":611,"reasoning_output_tokens":453,"total_tokens":70994},"total_token_usage":{"input_tokens":5430007,"cached_input_tokens":4960640,"output_tokens":15077,"reasoning_output_tokens":4478,"total_tokens":5445084}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:00:51Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":75664,"cached_input_tokens":70016,"output_tokens":149,"reasoning_output_tokens":15,"total_tokens":75813},"total_token_usage":{"input_tokens":5505671,"cached_input_tokens":5030656,"output_tokens":15226,"reasoning_output_tokens":4493,"total_tokens":5520897}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:00:52Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":76399,"cached_input_tokens":75648,"output_tokens":2441,"reasoning_output_tokens":2070,"total_tokens":78840},"total_token_usage":{"input_tokens":5582070,"cached_input_tokens":5106304,"output_tokens":17667,"reasoning_output_tokens":6563,"total_tokens":5599737}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:00:53Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":77169,"cached_input_tokens":20864,"output_tokens":514,"reasoning_output_tokens":196,"total_tokens":77683},"total_token_usage":{"input_tokens":5659239,"cached_input_tokens":5127168,"output_tokens":18181,"reasoning_output_tokens":6759,"total_tokens":5677420}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:00:54Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":89835,"cached_input_tokens":76672,"output_tokens":364,"reasoning_output_tokens":30,"total_tokens":90199},"total_token_usage":{"input_tokens":5749074,"cached_input_tokens":5203840,"output_tokens":18545,"reasoning_output_tokens":6789,"total_tokens":5767619}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:00:55Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":94325,"cached_input_tokens":89472,"output_tokens":186,"reasoning_output_tokens":101,"total_tokens":94511},"total_token_usage":{"input_tokens":5843399,"cached_input_tokens":5293312,"output_tokens":18731,"reasoning_output_tokens":6890,"total_tokens":5862130}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:00:56Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":95463,"cached_input_tokens":51072,"output_tokens":67,"reasoning_output_tokens":0,"total_tokens":95530},"total_token_usage":{"input_tokens":5938862,"cached_input_tokens":5344384,"output_tokens":18798,"reasoning_output_tokens":6890,"total_tokens":5957660}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:00:57Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":96285,"cached_input_tokens":95104,"output_tokens":792,"reasoning_output_tokens":516,"total_tokens":97077},"total_token_usage":{"input_tokens":6035147,"cached_input_tokens":5439488,"output_tokens":19590,"reasoning_output_tokens":7406,"total_tokens":6054737}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:00:58Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":100088,"cached_input_tokens":96128,"output_tokens":123,"reasoning_output_tokens":0,"total_tokens":100211},"total_token_usage":{"input_tokens":6135235,"cached_input_tokens":5535616,"output_tokens":19713,"reasoning_output_tokens":7406,"total_tokens":6154948}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:00:59Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":101331,"cached_input_tokens":99712,"output_tokens":2255,"reasoning_output_tokens":770,"total_tokens":103586},"total_token_usage":{"input_tokens":6236566,"cached_input_tokens":5635328,"output_tokens":21968,"reasoning_output_tokens":8176,"total_tokens":6258534}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:01:00Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":103675,"cached_input_tokens":94080,"output_tokens":622,"reasoning_output_tokens":516,"total_tokens":104297},"total_token_usage":{"input_tokens":6340241,"cached_input_tokens":5729408,"output_tokens":22590,"reasoning_output_tokens":8692,"total_tokens":6362831}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:01:01Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":105530,"cached_input_tokens":101248,"output_tokens":155,"reasoning_output_tokens":9,"total_tokens":105685},"total_token_usage":{"input_tokens":6445771,"cached_input_tokens":5830656,"output_tokens":22745,"reasoning_output_tokens":8701,"total_tokens":6468516}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:01:02Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":105946,"cached_input_tokens":105344,"output_tokens":83,"reasoning_output_tokens":7,"total_tokens":106029},"total_token_usage":{"input_tokens":6551717,"cached_input_tokens":5936000,"output_tokens":22828,"reasoning_output_tokens":8708,"total_tokens":6574545}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:01:03Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":118412,"cached_input_tokens":103296,"output_tokens":212,"reasoning_output_tokens":114,"total_tokens":118624},"total_token_usage":{"input_tokens":6670129,"cached_input_tokens":6039296,"output_tokens":23040,"reasoning_output_tokens":8822,"total_tokens":6693169}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:01:04Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":130657,"cached_input_tokens":118144,"output_tokens":148,"reasoning_output_tokens":16,"total_tokens":130805},"total_token_usage":{"input_tokens":6800786,"cached_input_tokens":6157440,"output_tokens":23188,"reasoning_output_tokens":8838,"total_tokens":6823974}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:01:05Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":142816,"cached_input_tokens":130432,"output_tokens":323,"reasoning_output_tokens":175,"total_tokens":143139},"total_token_usage":{"input_tokens":6943602,"cached_input_tokens":6287872,"output_tokens":23511,"reasoning_output_tokens":9013,"total_tokens":6967113}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:01:06Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":152606,"cached_input_tokens":142720,"output_tokens":95,"reasoning_output_tokens":16,"total_tokens":152701},"total_token_usage":{"input_tokens":7096208,"cached_input_tokens":6430592,"output_tokens":23606,"reasoning_output_tokens":9029,"total_tokens":7119814}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:01:07Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":162280,"cached_input_tokens":152448,"output_tokens":106,"reasoning_output_tokens":26,"total_tokens":162386},"total_token_usage":{"input_tokens":7258488,"cached_input_tokens":6583040,"output_tokens":23712,"reasoning_output_tokens":9055,"total_tokens":7282200}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:01:08Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":174122,"cached_input_tokens":162176,"output_tokens":91,"reasoning_output_tokens":14,"total_tokens":174213},"total_token_usage":{"input_tokens":7432610,"cached_input_tokens":6745216,"output_tokens":23803,"reasoning_output_tokens":9069,"total_tokens":7456413}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:01:09Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":185148,"cached_input_tokens":173952,"output_tokens":94,"reasoning_output_tokens":12,"total_tokens":185242},"total_token_usage":{"input_tokens":7617758,"cached_input_tokens":6919168,"output_tokens":23897,"reasoning_output_tokens":9081,"total_tokens":7641655}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:01:10Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":194748,"cached_input_tokens":184704,"output_tokens":83,"reasoning_output_tokens":11,"total_tokens":194831},"total_token_usage":{"input_tokens":7812506,"cached_input_tokens":7103872,"output_tokens":23980,"reasoning_output_tokens":9092,"total_tokens":7836486}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:01:11Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":203640,"cached_input_tokens":194432,"output_tokens":96,"reasoning_output_tokens":20,"total_tokens":203736},"total_token_usage":{"input_tokens":8016146,"cached_input_tokens":7298304,"output_tokens":24076,"reasoning_output_tokens":9112,"total_tokens":8040222}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:01:12Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":217640,"cached_input_tokens":203136,"output_tokens":86,"reasoning_output_tokens":14,"total_tokens":217726},"total_token_usage":{"input_tokens":8233786,"cached_input_tokens":7501440,"output_tokens":24162,"reasoning_output_tokens":9126,"total_tokens":8257948}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:01:13Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":230527,"cached_input_tokens":217472,"output_tokens":96,"reasoning_output_tokens":14,"total_tokens":230623},"total_token_usage":{"input_tokens":8464313,"cached_input_tokens":7718912,"output_tokens":24258,"reasoning_output_tokens":9140,"total_tokens":8488571}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:01:14Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":0,"cached_input_tokens":0,"output_tokens":0,"reasoning_output_tokens":0,"total_tokens":17858},"total_token_usage":{"input_tokens":8464313,"cached_input_tokens":7718912,"output_tokens":24258,"reasoning_output_tokens":9140,"total_tokens":8488571}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:01:15Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":22167,"cached_input_tokens":4992,"output_tokens":238,"reasoning_output_tokens":0,"total_tokens":22405},"total_token_usage":{"input_tokens":8486480,"cached_input_tokens":7723904,"output_tokens":24496,"reasoning_output_tokens":9140,"total_tokens":8510976}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:01:16Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":22911,"cached_input_tokens":21888,"output_tokens":805,"reasoning_output_tokens":476,"total_tokens":23716},"total_token_usage":{"input_tokens":8509391,"cached_input_tokens":7745792,"output_tokens":25301,"reasoning_output_tokens":9616,"total_tokens":8534692}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:01:17Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":24270,"cached_input_tokens":10624,"output_tokens":661,"reasoning_output_tokens":501,"total_tokens":24931},"total_token_usage":{"input_tokens":8533661,"cached_input_tokens":7756416,"output_tokens":25962,"reasoning_output_tokens":10117,"total_tokens":8559623}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:01:18Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":25184,"cached_input_tokens":22400,"output_tokens":99,"reasoning_output_tokens":0,"total_tokens":25283},"total_token_usage":{"input_tokens":8558845,"cached_input_tokens":7778816,"output_tokens":26061,"reasoning_output_tokens":10117,"total_tokens":8584906}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:01:19Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":25344,"cached_input_tokens":23936,"output_tokens":142,"reasoning_output_tokens":0,"total_tokens":25486},"total_token_usage":{"input_tokens":8584189,"cached_input_tokens":7802752,"output_tokens":26203,"reasoning_output_tokens":10117,"total_tokens":8610392}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:01:20Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":25558,"cached_input_tokens":24960,"output_tokens":198,"reasoning_output_tokens":0,"total_tokens":25756},"total_token_usage":{"input_tokens":8609747,"cached_input_tokens":7827712,"output_tokens":26401,"reasoning_output_tokens":10117,"total_tokens":8636148}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:01:21Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":25800,"cached_input_tokens":24960,"output_tokens":158,"reasoning_output_tokens":11,"total_tokens":25958},"total_token_usage":{"input_tokens":8635547,"cached_input_tokens":7852672,"output_tokens":26559,"reasoning_output_tokens":10128,"total_tokens":8662106}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:01:22Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":26276,"cached_input_tokens":25472,"output_tokens":82,"reasoning_output_tokens":0,"total_tokens":26358},"total_token_usage":{"input_tokens":8661823,"cached_input_tokens":7878144,"output_tokens":26641,"reasoning_output_tokens":10128,"total_tokens":8688464}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:01:23Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":26443,"cached_input_tokens":25984,"output_tokens":160,"reasoning_output_tokens":13,"total_tokens":26603},"total_token_usage":{"input_tokens":8688266,"cached_input_tokens":7904128,"output_tokens":26801,"reasoning_output_tokens":10141,"total_tokens":8715067}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:01:24Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":26708,"cached_input_tokens":25472,"output_tokens":230,"reasoning_output_tokens":95,"total_tokens":26938},"total_token_usage":{"input_tokens":8714974,"cached_input_tokens":7929600,"output_tokens":27031,"reasoning_output_tokens":10236,"total_tokens":8742005}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:01:25Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":28685,"cached_input_tokens":4992,"output_tokens":738,"reasoning_output_tokens":516,"total_tokens":29423},"total_token_usage":{"input_tokens":8743659,"cached_input_tokens":7934592,"output_tokens":27769,"reasoning_output_tokens":10752,"total_tokens":8771428}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:01:26Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":29652,"cached_input_tokens":28544,"output_tokens":173,"reasoning_output_tokens":79,"total_tokens":29825},"total_token_usage":{"input_tokens":8773311,"cached_input_tokens":7963136,"output_tokens":27942,"reasoning_output_tokens":10831,"total_tokens":8801253}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:01:27Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":29896,"cached_input_tokens":29568,"output_tokens":128,"reasoning_output_tokens":0,"total_tokens":30024},"total_token_usage":{"input_tokens":8803207,"cached_input_tokens":7992704,"output_tokens":28070,"reasoning_output_tokens":10831,"total_tokens":8831277}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:01:28Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":30083,"cached_input_tokens":29568,"output_tokens":176,"reasoning_output_tokens":53,"total_tokens":30259},"total_token_usage":{"input_tokens":8833290,"cached_input_tokens":8022272,"output_tokens":28246,"reasoning_output_tokens":10884,"total_tokens":8861536}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:01:29Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":30365,"cached_input_tokens":30080,"output_tokens":133,"reasoning_output_tokens":45,"total_tokens":30498},"total_token_usage":{"input_tokens":8863655,"cached_input_tokens":8052352,"output_tokens":28379,"reasoning_output_tokens":10929,"total_tokens":8892034}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:01:30Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":30560,"cached_input_tokens":30080,"output_tokens":109,"reasoning_output_tokens":0,"total_tokens":30669},"total_token_usage":{"input_tokens":8894215,"cached_input_tokens":8082432,"output_tokens":28488,"reasoning_output_tokens":10929,"total_tokens":8922703}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:01:31Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":30741,"cached_input_tokens":30080,"output_tokens":276,"reasoning_output_tokens":115,"total_tokens":31017},"total_token_usage":{"input_tokens":8924956,"cached_input_tokens":8112512,"output_tokens":28764,"reasoning_output_tokens":11044,"total_tokens":8953720}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:01:32Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":31183,"cached_input_tokens":30592,"output_tokens":147,"reasoning_output_tokens":0,"total_tokens":31330},"total_token_usage":{"input_tokens":8956139,"cached_input_tokens":8143104,"output_tokens":28911,"reasoning_output_tokens":11044,"total_tokens":8985050}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:01:33Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":31376,"cached_input_tokens":31104,"output_tokens":74,"reasoning_output_tokens":11,"total_tokens":31450},"total_token_usage":{"input_tokens":8987515,"cached_input_tokens":8174208,"output_tokens":28985,"reasoning_output_tokens":11055,"total_tokens":9016500}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:01:34Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":31621,"cached_input_tokens":31104,"output_tokens":159,"reasoning_output_tokens":10,"total_tokens":31780},"total_token_usage":{"input_tokens":9019136,"cached_input_tokens":8205312,"output_tokens":29144,"reasoning_output_tokens":11065,"total_tokens":9048280}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:01:35Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":31893,"cached_input_tokens":31616,"output_tokens":558,"reasoning_output_tokens":353,"total_tokens":32451},"total_token_usage":{"input_tokens":9051029,"cached_input_tokens":8236928,"output_tokens":29702,"reasoning_output_tokens":11418,"total_tokens":9080731}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:01:36Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":31182,"cached_input_tokens":4992,"output_tokens":338,"reasoning_output_tokens":107,"total_tokens":31520},"total_token_usage":{"input_tokens":9082211,"cached_input_tokens":8241920,"output_tokens":30040,"reasoning_output_tokens":11525,"total_tokens":9112251}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:01:37Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":31422,"cached_input_tokens":10624,"output_tokens":383,"reasoning_output_tokens":242,"total_tokens":31805},"total_token_usage":{"input_tokens":9113633,"cached_input_tokens":8252544,"output_tokens":30423,"reasoning_output_tokens":11767,"total_tokens":9144056}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:01:38Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":32571,"cached_input_tokens":31104,"output_tokens":146,"reasoning_output_tokens":66,"total_tokens":32717},"total_token_usage":{"input_tokens":9146204,"cached_input_tokens":8283648,"output_tokens":30569,"reasoning_output_tokens":11833,"total_tokens":9176773}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:01:39Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":34260,"cached_input_tokens":32128,"output_tokens":213,"reasoning_output_tokens":54,"total_tokens":34473},"total_token_usage":{"input_tokens":9180464,"cached_input_tokens":8315776,"output_tokens":30782,"reasoning_output_tokens":11887,"total_tokens":9211246}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:01:40Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":34550,"cached_input_tokens":34176,"output_tokens":160,"reasoning_output_tokens":79,"total_tokens":34710},"total_token_usage":{"input_tokens":9215014,"cached_input_tokens":8349952,"output_tokens":30942,"reasoning_output_tokens":11966,"total_tokens":9245956}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:01:41Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":37512,"cached_input_tokens":34176,"output_tokens":189,"reasoning_output_tokens":84,"total_tokens":37701},"total_token_usage":{"input_tokens":9252526,"cached_input_tokens":8384128,"output_tokens":31131,"reasoning_output_tokens":12050,"total_tokens":9283657}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:01:42Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":41747,"cached_input_tokens":37248,"output_tokens":146,"reasoning_output_tokens":52,"total_tokens":41893},"total_token_usage":{"input_tokens":9294273,"cached_input_tokens":8421376,"output_tokens":31277,"reasoning_output_tokens":12102,"total_tokens":9325550}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:01:43Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":42288,"cached_input_tokens":4992,"output_tokens":343,"reasoning_output_tokens":54,"total_tokens":42631},"total_token_usage":{"input_tokens":9336561,"cached_input_tokens":8426368,"output_tokens":31620,"reasoning_output_tokens":12156,"total_tokens":9368181}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:01:44Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":47980,"cached_input_tokens":41856,"output_tokens":466,"reasoning_output_tokens":180,"total_tokens":48446},"total_token_usage":{"input_tokens":9384541,"cached_input_tokens":8468224,"output_tokens":32086,"reasoning_output_tokens":12336,"total_tokens":9416627}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:01:45Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":48728,"cached_input_tokens":47488,"output_tokens":270,"reasoning_output_tokens":60,"total_tokens":48998},"total_token_usage":{"input_tokens":9433269,"cached_input_tokens":8515712,"output_tokens":32356,"reasoning_output_tokens":12396,"total_tokens":9465625}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:01:46Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":49417,"cached_input_tokens":48512,"output_tokens":382,"reasoning_output_tokens":80,"total_tokens":49799},"total_token_usage":{"input_tokens":9482686,"cached_input_tokens":8564224,"output_tokens":32738,"reasoning_output_tokens":12476,"total_tokens":9515424}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:01:47Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":51478,"cached_input_tokens":49024,"output_tokens":533,"reasoning_output_tokens":364,"total_tokens":52011},"total_token_usage":{"input_tokens":9534164,"cached_input_tokens":8613248,"output_tokens":33271,"reasoning_output_tokens":12840,"total_tokens":9567435}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:01:48Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":52090,"cached_input_tokens":41344,"output_tokens":310,"reasoning_output_tokens":28,"total_tokens":52400},"total_token_usage":{"input_tokens":9586254,"cached_input_tokens":8654592,"output_tokens":33581,"reasoning_output_tokens":12868,"total_tokens":9619835}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:01:49Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":66881,"cached_input_tokens":51584,"output_tokens":91,"reasoning_output_tokens":16,"total_tokens":66972},"total_token_usage":{"input_tokens":9653135,"cached_input_tokens":8706176,"output_tokens":33672,"reasoning_output_tokens":12884,"total_tokens":9686807}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:01:50Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":68134,"cached_input_tokens":66432,"output_tokens":663,"reasoning_output_tokens":448,"total_tokens":68797},"total_token_usage":{"input_tokens":9721269,"cached_input_tokens":8772608,"output_tokens":34335,"reasoning_output_tokens":13332,"total_tokens":9755604}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:01:51Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":68845,"cached_input_tokens":67968,"output_tokens":337,"reasoning_output_tokens":0,"total_tokens":69182},"total_token_usage":{"input_tokens":9790114,"cached_input_tokens":8840576,"output_tokens":34672,"reasoning_output_tokens":13332,"total_tokens":9824786}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:01:52Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":69232,"cached_input_tokens":68480,"output_tokens":610,"reasoning_output_tokens":0,"total_tokens":69842},"total_token_usage":{"input_tokens":9859346,"cached_input_tokens":8909056,"output_tokens":35282,"reasoning_output_tokens":13332,"total_tokens":9894628}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:01:53Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":69892,"cached_input_tokens":68992,"output_tokens":382,"reasoning_output_tokens":244,"total_tokens":70274},"total_token_usage":{"input_tokens":9929238,"cached_input_tokens":8978048,"output_tokens":35664,"reasoning_output_tokens":13576,"total_tokens":9964902}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:01:54Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":74724,"cached_input_tokens":51072,"output_tokens":75,"reasoning_output_tokens":7,"total_tokens":74799},"total_token_usage":{"input_tokens":10003962,"cached_input_tokens":9029120,"output_tokens":35739,"reasoning_output_tokens":13583,"total_tokens":10039701}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:01:55Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":76542,"cached_input_tokens":74624,"output_tokens":66,"reasoning_output_tokens":7,"total_tokens":76608},"total_token_usage":{"input_tokens":10080504,"cached_input_tokens":9103744,"output_tokens":35805,"reasoning_output_tokens":13590,"total_tokens":10116309}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:01:56Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":79951,"cached_input_tokens":69504,"output_tokens":78,"reasoning_output_tokens":10,"total_tokens":80029},"total_token_usage":{"input_tokens":10160455,"cached_input_tokens":9173248,"output_tokens":35883,"reasoning_output_tokens":13600,"total_tokens":10196338}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:01:57Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":83234,"cached_input_tokens":76160,"output_tokens":75,"reasoning_output_tokens":6,"total_tokens":83309},"total_token_usage":{"input_tokens":10243689,"cached_input_tokens":9249408,"output_tokens":35958,"reasoning_output_tokens":13606,"total_tokens":10279647}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:01:58Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":89587,"cached_input_tokens":82816,"output_tokens":79,"reasoning_output_tokens":6,"total_tokens":89666},"total_token_usage":{"input_tokens":10333276,"cached_input_tokens":9332224,"output_tokens":36037,"reasoning_output_tokens":13612,"total_tokens":10369313}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:01:59Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":102007,"cached_input_tokens":89472,"output_tokens":153,"reasoning_output_tokens":22,"total_tokens":102160},"total_token_usage":{"input_tokens":10435283,"cached_input_tokens":9421696,"output_tokens":36190,"reasoning_output_tokens":13634,"total_tokens":10471473}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:02:00Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":114177,"cached_input_tokens":79744,"output_tokens":144,"reasoning_output_tokens":20,"total_tokens":114321},"total_token_usage":{"input_tokens":10549460,"cached_input_tokens":9501440,"output_tokens":36334,"reasoning_output_tokens":13654,"total_tokens":10585794}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:02:01Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":123786,"cached_input_tokens":114048,"output_tokens":88,"reasoning_output_tokens":17,"total_tokens":123874},"total_token_usage":{"input_tokens":10673246,"cached_input_tokens":9615488,"output_tokens":36422,"reasoning_output_tokens":13671,"total_tokens":10709668}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:02:02Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":134637,"cached_input_tokens":101760,"output_tokens":72,"reasoning_output_tokens":7,"total_tokens":134709},"total_token_usage":{"input_tokens":10807883,"cached_input_tokens":9717248,"output_tokens":36494,"reasoning_output_tokens":13678,"total_tokens":10844377}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:02:03Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":146641,"cached_input_tokens":134528,"output_tokens":87,"reasoning_output_tokens":13,"total_tokens":146728},"total_token_usage":{"input_tokens":10954524,"cached_input_tokens":9851776,"output_tokens":36581,"reasoning_output_tokens":13691,"total_tokens":10991105}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:02:04Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":158050,"cached_input_tokens":123776,"output_tokens":67,"reasoning_output_tokens":6,"total_tokens":158117},"total_token_usage":{"input_tokens":11112574,"cached_input_tokens":9975552,"output_tokens":36648,"reasoning_output_tokens":13697,"total_tokens":11149222}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:02:05Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":167477,"cached_input_tokens":146304,"output_tokens":68,"reasoning_output_tokens":9,"total_tokens":167545},"total_token_usage":{"input_tokens":11280051,"cached_input_tokens":10121856,"output_tokens":36716,"reasoning_output_tokens":13706,"total_tokens":11316767}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:02:06Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":177612,"cached_input_tokens":167296,"output_tokens":78,"reasoning_output_tokens":10,"total_tokens":177690},"total_token_usage":{"input_tokens":11457663,"cached_input_tokens":10289152,"output_tokens":36794,"reasoning_output_tokens":13716,"total_tokens":11494457}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:02:07Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":189809,"cached_input_tokens":177536,"output_tokens":70,"reasoning_output_tokens":9,"total_tokens":189879},"total_token_usage":{"input_tokens":11647472,"cached_input_tokens":10466688,"output_tokens":36864,"reasoning_output_tokens":13725,"total_tokens":11684336}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:02:08Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":202957,"cached_input_tokens":189312,"output_tokens":70,"reasoning_output_tokens":7,"total_tokens":203027},"total_token_usage":{"input_tokens":11850429,"cached_input_tokens":10656000,"output_tokens":36934,"reasoning_output_tokens":13732,"total_tokens":11887363}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:02:09Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":213681,"cached_input_tokens":202624,"output_tokens":258,"reasoning_output_tokens":20,"total_tokens":213939},"total_token_usage":{"input_tokens":12064110,"cached_input_tokens":10858624,"output_tokens":37192,"reasoning_output_tokens":13752,"total_tokens":12101302}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:02:10Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":215284,"cached_input_tokens":213376,"output_tokens":272,"reasoning_output_tokens":111,"total_tokens":215556},"total_token_usage":{"input_tokens":12279394,"cached_input_tokens":11072000,"output_tokens":37464,"reasoning_output_tokens":13863,"total_tokens":12316858}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:02:11Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":215602,"cached_input_tokens":214912,"output_tokens":83,"reasoning_output_tokens":0,"total_tokens":215685},"total_token_usage":{"input_tokens":12494996,"cached_input_tokens":11286912,"output_tokens":37547,"reasoning_output_tokens":13863,"total_tokens":12532543}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:02:12Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":215763,"cached_input_tokens":215424,"output_tokens":106,"reasoning_output_tokens":0,"total_tokens":215869},"total_token_usage":{"input_tokens":12710759,"cached_input_tokens":11502336,"output_tokens":37653,"reasoning_output_tokens":13863,"total_tokens":12748412}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:02:13Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":215917,"cached_input_tokens":215424,"output_tokens":57,"reasoning_output_tokens":0,"total_tokens":215974},"total_token_usage":{"input_tokens":12926676,"cached_input_tokens":11717760,"output_tokens":37710,"reasoning_output_tokens":13863,"total_tokens":12964386}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:02:14Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":216066,"cached_input_tokens":215424,"output_tokens":316,"reasoning_output_tokens":112,"total_tokens":216382},"total_token_usage":{"input_tokens":13142742,"cached_input_tokens":11933184,"output_tokens":38026,"reasoning_output_tokens":13975,"total_tokens":13180768}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:02:15Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":216957,"cached_input_tokens":215936,"output_tokens":388,"reasoning_output_tokens":185,"total_tokens":217345},"total_token_usage":{"input_tokens":13359699,"cached_input_tokens":12149120,"output_tokens":38414,"reasoning_output_tokens":14160,"total_tokens":13398113}}}} +{"type":"event_msg","timestamp":"2030-01-02T12:00:00Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":0,"cached_input_tokens":0,"output_tokens":0,"reasoning_output_tokens":0,"total_tokens":9148},"total_token_usage":{"input_tokens":13359699,"cached_input_tokens":12149120,"output_tokens":38414,"reasoning_output_tokens":14160,"total_tokens":13398113}}}} +{"type":"event_msg","timestamp":"2030-01-02T12:00:01Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":22553,"cached_input_tokens":10624,"output_tokens":493,"reasoning_output_tokens":303,"total_tokens":23046},"total_token_usage":{"input_tokens":13382252,"cached_input_tokens":12159744,"output_tokens":38907,"reasoning_output_tokens":14463,"total_tokens":13421159}}}} +{"type":"event_msg","timestamp":"2030-01-02T12:00:02Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":95811,"cached_input_tokens":22400,"output_tokens":1680,"reasoning_output_tokens":1433,"total_tokens":97491},"total_token_usage":{"input_tokens":13478063,"cached_input_tokens":12182144,"output_tokens":40587,"reasoning_output_tokens":15896,"total_tokens":13518650}}}} +{"type":"event_msg","timestamp":"2030-01-02T12:00:03Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":89508,"cached_input_tokens":33664,"output_tokens":371,"reasoning_output_tokens":21,"total_tokens":89879},"total_token_usage":{"input_tokens":13567571,"cached_input_tokens":12215808,"output_tokens":40958,"reasoning_output_tokens":15917,"total_tokens":13608529}}}} +{"type":"event_msg","timestamp":"2030-01-02T12:00:04Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":113851,"cached_input_tokens":89472,"output_tokens":2273,"reasoning_output_tokens":1978,"total_tokens":116124},"total_token_usage":{"input_tokens":13681422,"cached_input_tokens":12305280,"output_tokens":43231,"reasoning_output_tokens":17895,"total_tokens":13724653}}}} +{"type":"event_msg","timestamp":"2030-01-02T12:00:05Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":118098,"cached_input_tokens":94592,"output_tokens":1626,"reasoning_output_tokens":1034,"total_tokens":119724},"total_token_usage":{"input_tokens":13799520,"cached_input_tokens":12399872,"output_tokens":44857,"reasoning_output_tokens":18929,"total_tokens":13844377}}}} +{"type":"event_msg","timestamp":"2030-01-02T12:00:06Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":116068,"cached_input_tokens":22400,"output_tokens":671,"reasoning_output_tokens":412,"total_tokens":116739},"total_token_usage":{"input_tokens":13915588,"cached_input_tokens":12422272,"output_tokens":45528,"reasoning_output_tokens":19341,"total_tokens":13961116}}}} +{"type":"event_msg","timestamp":"2030-01-02T12:00:07Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":121279,"cached_input_tokens":115584,"output_tokens":478,"reasoning_output_tokens":241,"total_tokens":121757},"total_token_usage":{"input_tokens":14036867,"cached_input_tokens":12537856,"output_tokens":46006,"reasoning_output_tokens":19582,"total_tokens":14082873}}}} +{"type":"event_msg","timestamp":"2030-01-02T12:00:08Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":123051,"cached_input_tokens":121216,"output_tokens":1537,"reasoning_output_tokens":1034,"total_tokens":124588},"total_token_usage":{"input_tokens":14159918,"cached_input_tokens":12659072,"output_tokens":47543,"reasoning_output_tokens":20616,"total_tokens":14207461}}}} +{"type":"event_msg","timestamp":"2030-01-02T12:00:09Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":122928,"cached_input_tokens":4992,"output_tokens":773,"reasoning_output_tokens":516,"total_tokens":123701},"total_token_usage":{"input_tokens":14282846,"cached_input_tokens":12664064,"output_tokens":48316,"reasoning_output_tokens":21132,"total_tokens":14331162}}}} +{"type":"event_msg","timestamp":"2030-01-02T12:00:10Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":59120,"cached_input_tokens":4992,"output_tokens":584,"reasoning_output_tokens":431,"total_tokens":59704},"total_token_usage":{"input_tokens":14341966,"cached_input_tokens":12669056,"output_tokens":48900,"reasoning_output_tokens":21563,"total_tokens":14390866}}}} +{"type":"event_msg","timestamp":"2030-01-02T12:00:11Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":60082,"cached_input_tokens":58752,"output_tokens":378,"reasoning_output_tokens":182,"total_tokens":60460},"total_token_usage":{"input_tokens":14402048,"cached_input_tokens":12727808,"output_tokens":49278,"reasoning_output_tokens":21745,"total_tokens":14451326}}}} +{"type":"event_msg","timestamp":"2030-01-02T12:00:12Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":60616,"cached_input_tokens":59776,"output_tokens":243,"reasoning_output_tokens":43,"total_tokens":60859},"total_token_usage":{"input_tokens":14462664,"cached_input_tokens":12787584,"output_tokens":49521,"reasoning_output_tokens":21788,"total_tokens":14512185}}}} +{"type":"event_msg","timestamp":"2030-01-02T12:00:13Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":61155,"cached_input_tokens":60288,"output_tokens":321,"reasoning_output_tokens":78,"total_tokens":61476},"total_token_usage":{"input_tokens":14523819,"cached_input_tokens":12847872,"output_tokens":49842,"reasoning_output_tokens":21866,"total_tokens":14573661}}}} +{"type":"event_msg","timestamp":"2030-01-02T12:00:14Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":73915,"cached_input_tokens":60800,"output_tokens":325,"reasoning_output_tokens":10,"total_tokens":74240},"total_token_usage":{"input_tokens":14597734,"cached_input_tokens":12908672,"output_tokens":50167,"reasoning_output_tokens":21876,"total_tokens":14647901}}}} +{"type":"event_msg","timestamp":"2030-01-02T12:00:15Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":75516,"cached_input_tokens":73600,"output_tokens":464,"reasoning_output_tokens":145,"total_tokens":75980},"total_token_usage":{"input_tokens":14673250,"cached_input_tokens":12982272,"output_tokens":50631,"reasoning_output_tokens":22021,"total_tokens":14723881}}}} +{"type":"event_msg","timestamp":"2030-01-02T12:00:16Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":79574,"cached_input_tokens":75136,"output_tokens":626,"reasoning_output_tokens":262,"total_tokens":80200},"total_token_usage":{"input_tokens":14752824,"cached_input_tokens":13057408,"output_tokens":51257,"reasoning_output_tokens":22283,"total_tokens":14804081}}}} +{"type":"event_msg","timestamp":"2030-01-02T12:00:17Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":80602,"cached_input_tokens":79232,"output_tokens":1309,"reasoning_output_tokens":814,"total_tokens":81911},"total_token_usage":{"input_tokens":14833426,"cached_input_tokens":13136640,"output_tokens":52566,"reasoning_output_tokens":23097,"total_tokens":14885992}}}} +{"type":"event_msg","timestamp":"2030-01-02T12:00:18Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":82258,"cached_input_tokens":80256,"output_tokens":590,"reasoning_output_tokens":248,"total_tokens":82848},"total_token_usage":{"input_tokens":14915684,"cached_input_tokens":13216896,"output_tokens":53156,"reasoning_output_tokens":23345,"total_tokens":14968840}}}} +{"type":"event_msg","timestamp":"2030-01-02T12:00:19Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":83280,"cached_input_tokens":81792,"output_tokens":332,"reasoning_output_tokens":123,"total_tokens":83612},"total_token_usage":{"input_tokens":14998964,"cached_input_tokens":13298688,"output_tokens":53488,"reasoning_output_tokens":23468,"total_tokens":15052452}}}} +{"type":"event_msg","timestamp":"2030-01-02T12:00:20Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":83775,"cached_input_tokens":82816,"output_tokens":1197,"reasoning_output_tokens":993,"total_tokens":84972},"total_token_usage":{"input_tokens":15082739,"cached_input_tokens":13381504,"output_tokens":54685,"reasoning_output_tokens":24461,"total_tokens":15137424}}}} +{"type":"event_msg","timestamp":"2030-01-02T12:00:21Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":85116,"cached_input_tokens":83328,"output_tokens":188,"reasoning_output_tokens":40,"total_tokens":85304},"total_token_usage":{"input_tokens":15167855,"cached_input_tokens":13464832,"output_tokens":54873,"reasoning_output_tokens":24501,"total_tokens":15222728}}}} +{"type":"event_msg","timestamp":"2030-01-02T12:00:22Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":85428,"cached_input_tokens":84864,"output_tokens":1022,"reasoning_output_tokens":516,"total_tokens":86450},"total_token_usage":{"input_tokens":15253283,"cached_input_tokens":13549696,"output_tokens":55895,"reasoning_output_tokens":25017,"total_tokens":15309178}}}} diff --git a/Tests/CodexBarTests/Fixtures/CostUsage/Issue2037/archived-fork-33ce-3869/codex-home/archived_sessions/parent.jsonl b/Tests/CodexBarTests/Fixtures/CostUsage/Issue2037/archived-fork-33ce-3869/codex-home/archived_sessions/parent.jsonl new file mode 100644 index 0000000000..1e31ac486e --- /dev/null +++ b/Tests/CodexBarTests/Fixtures/CostUsage/Issue2037/archived-fork-33ce-3869/codex-home/archived_sessions/parent.jsonl @@ -0,0 +1,137 @@ +{"timestamp":"2030-01-01T12:00:00Z","type":"session_meta","payload":{"id":"parent-session","forked_from_id":null,"timestamp":"2030-01-01T12:00:00Z"}} +{"timestamp":"2030-01-01T12:00:00Z","type":"turn_context","payload":{"model":"fixture-model","multi_agent_version":"v1"}} +{"timestamp":"2030-01-01T12:00:02Z","type":"event_msg","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":4992,"input_tokens":20273,"output_tokens":406,"reasoning_output_tokens":172,"total_tokens":20679},"total_token_usage":{"cached_input_tokens":4992,"input_tokens":20273,"output_tokens":406,"reasoning_output_tokens":172,"total_tokens":20679}}}} +{"timestamp":"2030-01-01T12:00:03Z","type":"event_msg","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":4992,"input_tokens":41538,"output_tokens":427,"reasoning_output_tokens":102,"total_tokens":41965},"total_token_usage":{"cached_input_tokens":9984,"input_tokens":61811,"output_tokens":833,"reasoning_output_tokens":274,"total_tokens":62644}}}} +{"timestamp":"2030-01-01T12:00:04Z","type":"event_msg","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":41344,"input_tokens":46041,"output_tokens":366,"reasoning_output_tokens":25,"total_tokens":46407},"total_token_usage":{"cached_input_tokens":51328,"input_tokens":107852,"output_tokens":1199,"reasoning_output_tokens":299,"total_tokens":109051}}}} +{"timestamp":"2030-01-01T12:00:05Z","type":"event_msg","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":45952,"input_tokens":51733,"output_tokens":375,"reasoning_output_tokens":12,"total_tokens":52108},"total_token_usage":{"cached_input_tokens":97280,"input_tokens":159585,"output_tokens":1574,"reasoning_output_tokens":311,"total_tokens":161159}}}} +{"timestamp":"2030-01-01T12:00:06Z","type":"event_msg","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":19840,"input_tokens":62672,"output_tokens":272,"reasoning_output_tokens":0,"total_tokens":62944},"total_token_usage":{"cached_input_tokens":117120,"input_tokens":222257,"output_tokens":1846,"reasoning_output_tokens":311,"total_tokens":224103}}}} +{"timestamp":"2030-01-01T12:00:07Z","type":"event_msg","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":62336,"input_tokens":77059,"output_tokens":371,"reasoning_output_tokens":16,"total_tokens":77430},"total_token_usage":{"cached_input_tokens":179456,"input_tokens":299316,"output_tokens":2217,"reasoning_output_tokens":327,"total_tokens":301533}}}} +{"timestamp":"2030-01-01T12:00:08Z","type":"event_msg","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":76672,"input_tokens":87381,"output_tokens":397,"reasoning_output_tokens":50,"total_tokens":87778},"total_token_usage":{"cached_input_tokens":256128,"input_tokens":386697,"output_tokens":2614,"reasoning_output_tokens":377,"total_tokens":389311}}}} +{"timestamp":"2030-01-01T12:00:09Z","type":"event_msg","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":86912,"input_tokens":91474,"output_tokens":471,"reasoning_output_tokens":233,"total_tokens":91945},"total_token_usage":{"cached_input_tokens":343040,"input_tokens":478171,"output_tokens":3085,"reasoning_output_tokens":610,"total_tokens":481256}}}} +{"timestamp":"2030-01-01T12:00:10Z","type":"event_msg","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":91008,"input_tokens":99980,"output_tokens":347,"reasoning_output_tokens":0,"total_tokens":100327},"total_token_usage":{"cached_input_tokens":434048,"input_tokens":578151,"output_tokens":3432,"reasoning_output_tokens":610,"total_tokens":581583}}}} +{"timestamp":"2030-01-01T12:00:11Z","type":"event_msg","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":99712,"input_tokens":104328,"output_tokens":490,"reasoning_output_tokens":155,"total_tokens":104818},"total_token_usage":{"cached_input_tokens":533760,"input_tokens":682479,"output_tokens":3922,"reasoning_output_tokens":765,"total_tokens":686401}}}} +{"timestamp":"2030-01-01T12:00:12Z","type":"event_msg","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":104320,"input_tokens":109064,"output_tokens":309,"reasoning_output_tokens":63,"total_tokens":109373},"total_token_usage":{"cached_input_tokens":638080,"input_tokens":791543,"output_tokens":4231,"reasoning_output_tokens":828,"total_tokens":795774}}}} +{"timestamp":"2030-01-01T12:00:13Z","type":"event_msg","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":108928,"input_tokens":111249,"output_tokens":275,"reasoning_output_tokens":0,"total_tokens":111524},"total_token_usage":{"cached_input_tokens":747008,"input_tokens":902792,"output_tokens":4506,"reasoning_output_tokens":828,"total_tokens":907298}}}} +{"timestamp":"2030-01-01T12:00:14Z","type":"event_msg","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":51584,"input_tokens":114730,"output_tokens":301,"reasoning_output_tokens":55,"total_tokens":115031},"total_token_usage":{"cached_input_tokens":798592,"input_tokens":1017522,"output_tokens":4807,"reasoning_output_tokens":883,"total_tokens":1022329}}}} +{"timestamp":"2030-01-01T12:00:15Z","type":"event_msg","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":110976,"input_tokens":117769,"output_tokens":241,"reasoning_output_tokens":93,"total_tokens":118010},"total_token_usage":{"cached_input_tokens":909568,"input_tokens":1135291,"output_tokens":5048,"reasoning_output_tokens":976,"total_tokens":1140339}}}} +{"timestamp":"2030-01-01T12:00:16Z","type":"event_msg","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":117632,"input_tokens":119430,"output_tokens":981,"reasoning_output_tokens":679,"total_tokens":120411},"total_token_usage":{"cached_input_tokens":1027200,"input_tokens":1254721,"output_tokens":6029,"reasoning_output_tokens":1655,"total_tokens":1260750}}}} +{"timestamp":"2030-01-01T12:00:17Z","type":"event_msg","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":119168,"input_tokens":123097,"output_tokens":218,"reasoning_output_tokens":0,"total_tokens":123315},"total_token_usage":{"cached_input_tokens":1146368,"input_tokens":1377818,"output_tokens":6247,"reasoning_output_tokens":1655,"total_tokens":1384065}}}} +{"timestamp":"2030-01-01T12:00:18Z","type":"event_msg","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":122752,"input_tokens":126343,"output_tokens":208,"reasoning_output_tokens":0,"total_tokens":126551},"total_token_usage":{"cached_input_tokens":1269120,"input_tokens":1504161,"output_tokens":6455,"reasoning_output_tokens":1655,"total_tokens":1510616}}}} +{"timestamp":"2030-01-01T12:00:19Z","type":"event_msg","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":126336,"input_tokens":127901,"output_tokens":438,"reasoning_output_tokens":235,"total_tokens":128339},"total_token_usage":{"cached_input_tokens":1395456,"input_tokens":1632062,"output_tokens":6893,"reasoning_output_tokens":1890,"total_tokens":1638955}}}} +{"timestamp":"2030-01-01T12:00:20Z","type":"event_msg","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":127872,"input_tokens":128401,"output_tokens":518,"reasoning_output_tokens":258,"total_tokens":128919},"total_token_usage":{"cached_input_tokens":1523328,"input_tokens":1760463,"output_tokens":7411,"reasoning_output_tokens":2148,"total_tokens":1767874}}}} +{"timestamp":"2030-01-01T12:00:21Z","type":"event_msg","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":128384,"input_tokens":130226,"output_tokens":143,"reasoning_output_tokens":0,"total_tokens":130369},"total_token_usage":{"cached_input_tokens":1651712,"input_tokens":1890689,"output_tokens":7554,"reasoning_output_tokens":2148,"total_tokens":1898243}}}} +{"timestamp":"2030-01-01T12:00:22Z","type":"event_msg","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":129920,"input_tokens":133103,"output_tokens":66,"reasoning_output_tokens":0,"total_tokens":133169},"total_token_usage":{"cached_input_tokens":1781632,"input_tokens":2023792,"output_tokens":7620,"reasoning_output_tokens":2148,"total_tokens":2031412}}}} +{"timestamp":"2030-01-01T12:00:23Z","type":"event_msg","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":132992,"input_tokens":133538,"output_tokens":982,"reasoning_output_tokens":612,"total_tokens":134520},"total_token_usage":{"cached_input_tokens":1914624,"input_tokens":2157330,"output_tokens":8602,"reasoning_output_tokens":2760,"total_tokens":2165932}}}} +{"timestamp":"2030-01-01T12:00:24Z","type":"event_msg","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":133504,"input_tokens":134570,"output_tokens":814,"reasoning_output_tokens":0,"total_tokens":135384},"total_token_usage":{"cached_input_tokens":2048128,"input_tokens":2291900,"output_tokens":9416,"reasoning_output_tokens":2760,"total_tokens":2301316}}}} +{"timestamp":"2030-01-01T12:00:25Z","type":"event_msg","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":134528,"input_tokens":135434,"output_tokens":151,"reasoning_output_tokens":40,"total_tokens":135585},"total_token_usage":{"cached_input_tokens":2182656,"input_tokens":2427334,"output_tokens":9567,"reasoning_output_tokens":2800,"total_tokens":2436901}}}} +{"timestamp":"2030-01-01T12:00:26Z","type":"event_msg","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":135040,"input_tokens":136816,"output_tokens":201,"reasoning_output_tokens":35,"total_tokens":137017},"total_token_usage":{"cached_input_tokens":2317696,"input_tokens":2564150,"output_tokens":9768,"reasoning_output_tokens":2835,"total_tokens":2573918}}}} +{"timestamp":"2030-01-01T12:00:27Z","type":"event_msg","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":136576,"input_tokens":139802,"output_tokens":85,"reasoning_output_tokens":11,"total_tokens":139887},"total_token_usage":{"cached_input_tokens":2454272,"input_tokens":2703952,"output_tokens":9853,"reasoning_output_tokens":2846,"total_tokens":2713805}}}} +{"timestamp":"2030-01-01T12:00:28Z","type":"event_msg","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":139648,"input_tokens":143646,"output_tokens":82,"reasoning_output_tokens":7,"total_tokens":143728},"total_token_usage":{"cached_input_tokens":2593920,"input_tokens":2847598,"output_tokens":9935,"reasoning_output_tokens":2853,"total_tokens":2857533}}}} +{"timestamp":"2030-01-01T12:00:29Z","type":"event_msg","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":143232,"input_tokens":147052,"output_tokens":90,"reasoning_output_tokens":9,"total_tokens":147142},"total_token_usage":{"cached_input_tokens":2737152,"input_tokens":2994650,"output_tokens":10025,"reasoning_output_tokens":2862,"total_tokens":3004675}}}} +{"timestamp":"2030-01-01T12:00:30Z","type":"event_msg","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":146816,"input_tokens":148945,"output_tokens":72,"reasoning_output_tokens":6,"total_tokens":149017},"total_token_usage":{"cached_input_tokens":2883968,"input_tokens":3143595,"output_tokens":10097,"reasoning_output_tokens":2868,"total_tokens":3153692}}}} +{"timestamp":"2030-01-01T12:00:31Z","type":"event_msg","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":148864,"input_tokens":151268,"output_tokens":76,"reasoning_output_tokens":6,"total_tokens":151344},"total_token_usage":{"cached_input_tokens":3032832,"input_tokens":3294863,"output_tokens":10173,"reasoning_output_tokens":2874,"total_tokens":3305036}}}} +{"timestamp":"2030-01-01T12:00:32Z","type":"event_msg","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":150912,"input_tokens":158145,"output_tokens":143,"reasoning_output_tokens":33,"total_tokens":158288},"total_token_usage":{"cached_input_tokens":3183744,"input_tokens":3453008,"output_tokens":10316,"reasoning_output_tokens":2907,"total_tokens":3463324}}}} +{"timestamp":"2030-01-01T12:00:33Z","type":"event_msg","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":158080,"input_tokens":170316,"output_tokens":224,"reasoning_output_tokens":59,"total_tokens":170540},"total_token_usage":{"cached_input_tokens":3341824,"input_tokens":3623324,"output_tokens":10540,"reasoning_output_tokens":2966,"total_tokens":3633864}}}} +{"timestamp":"2030-01-01T12:00:34Z","type":"event_msg","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":169856,"input_tokens":182556,"output_tokens":505,"reasoning_output_tokens":345,"total_tokens":183061},"total_token_usage":{"cached_input_tokens":3511680,"input_tokens":3805880,"output_tokens":11045,"reasoning_output_tokens":3311,"total_tokens":3816925}}}} +{"timestamp":"2030-01-01T12:00:35Z","type":"event_msg","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":182144,"input_tokens":192775,"output_tokens":106,"reasoning_output_tokens":29,"total_tokens":192881},"total_token_usage":{"cached_input_tokens":3693824,"input_tokens":3998655,"output_tokens":11151,"reasoning_output_tokens":3340,"total_tokens":4009806}}}} +{"timestamp":"2030-01-01T12:00:36Z","type":"event_msg","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":192384,"input_tokens":204282,"output_tokens":89,"reasoning_output_tokens":7,"total_tokens":204371},"total_token_usage":{"cached_input_tokens":3886208,"input_tokens":4202937,"output_tokens":11240,"reasoning_output_tokens":3347,"total_tokens":4214177}}}} +{"timestamp":"2030-01-01T12:00:37Z","type":"event_msg","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":204160,"input_tokens":217219,"output_tokens":85,"reasoning_output_tokens":10,"total_tokens":217304},"total_token_usage":{"cached_input_tokens":4090368,"input_tokens":4420156,"output_tokens":11325,"reasoning_output_tokens":3357,"total_tokens":4431481}}}} +{"timestamp":"2030-01-01T12:00:38Z","type":"event_msg","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":216960,"input_tokens":228774,"output_tokens":101,"reasoning_output_tokens":20,"total_tokens":228875},"total_token_usage":{"cached_input_tokens":4307328,"input_tokens":4648930,"output_tokens":11426,"reasoning_output_tokens":3377,"total_tokens":4660356}}}} +{"timestamp":"2030-01-01T12:00:39Z","type":"event_msg","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":228736,"input_tokens":238310,"output_tokens":79,"reasoning_output_tokens":7,"total_tokens":238389},"total_token_usage":{"cached_input_tokens":4536064,"input_tokens":4887240,"output_tokens":11505,"reasoning_output_tokens":3384,"total_tokens":4898745}}}} +{"timestamp":"2030-01-01T12:00:40Z","type":"event_msg","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":0,"input_tokens":0,"output_tokens":0,"reasoning_output_tokens":0,"total_tokens":16650},"total_token_usage":{"cached_input_tokens":4536064,"input_tokens":4887240,"output_tokens":11505,"reasoning_output_tokens":3384,"total_tokens":4898745}}}} +{"timestamp":"2030-01-01T12:00:41Z","type":"event_msg","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":19840,"input_tokens":21273,"output_tokens":85,"reasoning_output_tokens":0,"total_tokens":21358},"total_token_usage":{"cached_input_tokens":4555904,"input_tokens":4908513,"output_tokens":11590,"reasoning_output_tokens":3384,"total_tokens":4920103}}}} +{"timestamp":"2030-01-01T12:00:42Z","type":"event_msg","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":18304,"input_tokens":31745,"output_tokens":151,"reasoning_output_tokens":63,"total_tokens":31896},"total_token_usage":{"cached_input_tokens":4574208,"input_tokens":4940258,"output_tokens":11741,"reasoning_output_tokens":3447,"total_tokens":4951999}}}} +{"timestamp":"2030-01-01T12:00:43Z","type":"event_msg","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":31616,"input_tokens":42752,"output_tokens":96,"reasoning_output_tokens":10,"total_tokens":42848},"total_token_usage":{"cached_input_tokens":4605824,"input_tokens":4983010,"output_tokens":11837,"reasoning_output_tokens":3457,"total_tokens":4994847}}}} +{"timestamp":"2030-01-01T12:00:44Z","type":"event_msg","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":42368,"input_tokens":47233,"output_tokens":386,"reasoning_output_tokens":141,"total_tokens":47619},"total_token_usage":{"cached_input_tokens":4648192,"input_tokens":5030243,"output_tokens":12223,"reasoning_output_tokens":3598,"total_tokens":5042466}}}} +{"timestamp":"2030-01-01T12:00:45Z","type":"event_msg","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":46976,"input_tokens":49371,"output_tokens":317,"reasoning_output_tokens":112,"total_tokens":49688},"total_token_usage":{"cached_input_tokens":4695168,"input_tokens":5079614,"output_tokens":12540,"reasoning_output_tokens":3710,"total_tokens":5092154}}}} +{"timestamp":"2030-01-01T12:00:46Z","type":"event_msg","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":4992,"input_tokens":49856,"output_tokens":392,"reasoning_output_tokens":162,"total_tokens":50248},"total_token_usage":{"cached_input_tokens":4700160,"input_tokens":5129470,"output_tokens":12932,"reasoning_output_tokens":3872,"total_tokens":5142402}}}} +{"timestamp":"2030-01-01T12:00:47Z","type":"event_msg","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":31616,"input_tokens":51404,"output_tokens":520,"reasoning_output_tokens":131,"total_tokens":51924},"total_token_usage":{"cached_input_tokens":4731776,"input_tokens":5180874,"output_tokens":13452,"reasoning_output_tokens":4003,"total_tokens":5194326}}}} +{"timestamp":"2030-01-01T12:00:48Z","type":"event_msg","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":51072,"input_tokens":55494,"output_tokens":283,"reasoning_output_tokens":22,"total_tokens":55777},"total_token_usage":{"cached_input_tokens":4782848,"input_tokens":5236368,"output_tokens":13735,"reasoning_output_tokens":4025,"total_tokens":5250103}}}} +{"timestamp":"2030-01-01T12:00:49Z","type":"event_msg","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":55168,"input_tokens":57511,"output_tokens":390,"reasoning_output_tokens":0,"total_tokens":57901},"total_token_usage":{"cached_input_tokens":4838016,"input_tokens":5293879,"output_tokens":14125,"reasoning_output_tokens":4025,"total_tokens":5308004}}}} +{"timestamp":"2030-01-01T12:00:50Z","type":"event_msg","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":57216,"input_tokens":65745,"output_tokens":341,"reasoning_output_tokens":0,"total_tokens":66086},"total_token_usage":{"cached_input_tokens":4895232,"input_tokens":5359624,"output_tokens":14466,"reasoning_output_tokens":4025,"total_tokens":5374090}}}} +{"timestamp":"2030-01-01T12:00:51Z","type":"event_msg","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":65408,"input_tokens":70383,"output_tokens":611,"reasoning_output_tokens":453,"total_tokens":70994},"total_token_usage":{"cached_input_tokens":4960640,"input_tokens":5430007,"output_tokens":15077,"reasoning_output_tokens":4478,"total_tokens":5445084}}}} +{"timestamp":"2030-01-01T12:00:52Z","type":"event_msg","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":70016,"input_tokens":75664,"output_tokens":149,"reasoning_output_tokens":15,"total_tokens":75813},"total_token_usage":{"cached_input_tokens":5030656,"input_tokens":5505671,"output_tokens":15226,"reasoning_output_tokens":4493,"total_tokens":5520897}}}} +{"timestamp":"2030-01-01T12:00:53Z","type":"event_msg","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":75648,"input_tokens":76399,"output_tokens":2441,"reasoning_output_tokens":2070,"total_tokens":78840},"total_token_usage":{"cached_input_tokens":5106304,"input_tokens":5582070,"output_tokens":17667,"reasoning_output_tokens":6563,"total_tokens":5599737}}}} +{"timestamp":"2030-01-01T12:00:54Z","type":"event_msg","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":20864,"input_tokens":77169,"output_tokens":514,"reasoning_output_tokens":196,"total_tokens":77683},"total_token_usage":{"cached_input_tokens":5127168,"input_tokens":5659239,"output_tokens":18181,"reasoning_output_tokens":6759,"total_tokens":5677420}}}} +{"timestamp":"2030-01-01T12:00:55Z","type":"event_msg","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":76672,"input_tokens":89835,"output_tokens":364,"reasoning_output_tokens":30,"total_tokens":90199},"total_token_usage":{"cached_input_tokens":5203840,"input_tokens":5749074,"output_tokens":18545,"reasoning_output_tokens":6789,"total_tokens":5767619}}}} +{"timestamp":"2030-01-01T12:00:56Z","type":"event_msg","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":89472,"input_tokens":94325,"output_tokens":186,"reasoning_output_tokens":101,"total_tokens":94511},"total_token_usage":{"cached_input_tokens":5293312,"input_tokens":5843399,"output_tokens":18731,"reasoning_output_tokens":6890,"total_tokens":5862130}}}} +{"timestamp":"2030-01-01T12:00:57Z","type":"event_msg","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":51072,"input_tokens":95463,"output_tokens":67,"reasoning_output_tokens":0,"total_tokens":95530},"total_token_usage":{"cached_input_tokens":5344384,"input_tokens":5938862,"output_tokens":18798,"reasoning_output_tokens":6890,"total_tokens":5957660}}}} +{"timestamp":"2030-01-01T12:00:58Z","type":"event_msg","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":95104,"input_tokens":96285,"output_tokens":792,"reasoning_output_tokens":516,"total_tokens":97077},"total_token_usage":{"cached_input_tokens":5439488,"input_tokens":6035147,"output_tokens":19590,"reasoning_output_tokens":7406,"total_tokens":6054737}}}} +{"timestamp":"2030-01-01T12:00:59Z","type":"event_msg","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":96128,"input_tokens":100088,"output_tokens":123,"reasoning_output_tokens":0,"total_tokens":100211},"total_token_usage":{"cached_input_tokens":5535616,"input_tokens":6135235,"output_tokens":19713,"reasoning_output_tokens":7406,"total_tokens":6154948}}}} +{"timestamp":"2030-01-01T12:01:00Z","type":"event_msg","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":99712,"input_tokens":101331,"output_tokens":2255,"reasoning_output_tokens":770,"total_tokens":103586},"total_token_usage":{"cached_input_tokens":5635328,"input_tokens":6236566,"output_tokens":21968,"reasoning_output_tokens":8176,"total_tokens":6258534}}}} +{"timestamp":"2030-01-01T12:01:01Z","type":"event_msg","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":94080,"input_tokens":103675,"output_tokens":622,"reasoning_output_tokens":516,"total_tokens":104297},"total_token_usage":{"cached_input_tokens":5729408,"input_tokens":6340241,"output_tokens":22590,"reasoning_output_tokens":8692,"total_tokens":6362831}}}} +{"timestamp":"2030-01-01T12:01:02Z","type":"event_msg","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":101248,"input_tokens":105530,"output_tokens":155,"reasoning_output_tokens":9,"total_tokens":105685},"total_token_usage":{"cached_input_tokens":5830656,"input_tokens":6445771,"output_tokens":22745,"reasoning_output_tokens":8701,"total_tokens":6468516}}}} +{"timestamp":"2030-01-01T12:01:03Z","type":"event_msg","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":105344,"input_tokens":105946,"output_tokens":83,"reasoning_output_tokens":7,"total_tokens":106029},"total_token_usage":{"cached_input_tokens":5936000,"input_tokens":6551717,"output_tokens":22828,"reasoning_output_tokens":8708,"total_tokens":6574545}}}} +{"timestamp":"2030-01-01T12:01:04Z","type":"event_msg","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":103296,"input_tokens":118412,"output_tokens":212,"reasoning_output_tokens":114,"total_tokens":118624},"total_token_usage":{"cached_input_tokens":6039296,"input_tokens":6670129,"output_tokens":23040,"reasoning_output_tokens":8822,"total_tokens":6693169}}}} +{"timestamp":"2030-01-01T12:01:05Z","type":"event_msg","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":118144,"input_tokens":130657,"output_tokens":148,"reasoning_output_tokens":16,"total_tokens":130805},"total_token_usage":{"cached_input_tokens":6157440,"input_tokens":6800786,"output_tokens":23188,"reasoning_output_tokens":8838,"total_tokens":6823974}}}} +{"timestamp":"2030-01-01T12:01:06Z","type":"event_msg","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":130432,"input_tokens":142816,"output_tokens":323,"reasoning_output_tokens":175,"total_tokens":143139},"total_token_usage":{"cached_input_tokens":6287872,"input_tokens":6943602,"output_tokens":23511,"reasoning_output_tokens":9013,"total_tokens":6967113}}}} +{"timestamp":"2030-01-01T12:01:07Z","type":"event_msg","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":142720,"input_tokens":152606,"output_tokens":95,"reasoning_output_tokens":16,"total_tokens":152701},"total_token_usage":{"cached_input_tokens":6430592,"input_tokens":7096208,"output_tokens":23606,"reasoning_output_tokens":9029,"total_tokens":7119814}}}} +{"timestamp":"2030-01-01T12:01:08Z","type":"event_msg","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":152448,"input_tokens":162280,"output_tokens":106,"reasoning_output_tokens":26,"total_tokens":162386},"total_token_usage":{"cached_input_tokens":6583040,"input_tokens":7258488,"output_tokens":23712,"reasoning_output_tokens":9055,"total_tokens":7282200}}}} +{"timestamp":"2030-01-01T12:01:09Z","type":"event_msg","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":162176,"input_tokens":174122,"output_tokens":91,"reasoning_output_tokens":14,"total_tokens":174213},"total_token_usage":{"cached_input_tokens":6745216,"input_tokens":7432610,"output_tokens":23803,"reasoning_output_tokens":9069,"total_tokens":7456413}}}} +{"timestamp":"2030-01-01T12:01:10Z","type":"event_msg","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":173952,"input_tokens":185148,"output_tokens":94,"reasoning_output_tokens":12,"total_tokens":185242},"total_token_usage":{"cached_input_tokens":6919168,"input_tokens":7617758,"output_tokens":23897,"reasoning_output_tokens":9081,"total_tokens":7641655}}}} +{"timestamp":"2030-01-01T12:01:11Z","type":"event_msg","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":184704,"input_tokens":194748,"output_tokens":83,"reasoning_output_tokens":11,"total_tokens":194831},"total_token_usage":{"cached_input_tokens":7103872,"input_tokens":7812506,"output_tokens":23980,"reasoning_output_tokens":9092,"total_tokens":7836486}}}} +{"timestamp":"2030-01-01T12:01:12Z","type":"event_msg","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":194432,"input_tokens":203640,"output_tokens":96,"reasoning_output_tokens":20,"total_tokens":203736},"total_token_usage":{"cached_input_tokens":7298304,"input_tokens":8016146,"output_tokens":24076,"reasoning_output_tokens":9112,"total_tokens":8040222}}}} +{"timestamp":"2030-01-01T12:01:13Z","type":"event_msg","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":203136,"input_tokens":217640,"output_tokens":86,"reasoning_output_tokens":14,"total_tokens":217726},"total_token_usage":{"cached_input_tokens":7501440,"input_tokens":8233786,"output_tokens":24162,"reasoning_output_tokens":9126,"total_tokens":8257948}}}} +{"timestamp":"2030-01-01T12:01:14Z","type":"event_msg","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":217472,"input_tokens":230527,"output_tokens":96,"reasoning_output_tokens":14,"total_tokens":230623},"total_token_usage":{"cached_input_tokens":7718912,"input_tokens":8464313,"output_tokens":24258,"reasoning_output_tokens":9140,"total_tokens":8488571}}}} +{"timestamp":"2030-01-01T12:01:15Z","type":"event_msg","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":0,"input_tokens":0,"output_tokens":0,"reasoning_output_tokens":0,"total_tokens":17858},"total_token_usage":{"cached_input_tokens":7718912,"input_tokens":8464313,"output_tokens":24258,"reasoning_output_tokens":9140,"total_tokens":8488571}}}} +{"timestamp":"2030-01-01T12:01:16Z","type":"event_msg","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":4992,"input_tokens":22167,"output_tokens":238,"reasoning_output_tokens":0,"total_tokens":22405},"total_token_usage":{"cached_input_tokens":7723904,"input_tokens":8486480,"output_tokens":24496,"reasoning_output_tokens":9140,"total_tokens":8510976}}}} +{"timestamp":"2030-01-01T12:01:17Z","type":"event_msg","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":21888,"input_tokens":22911,"output_tokens":805,"reasoning_output_tokens":476,"total_tokens":23716},"total_token_usage":{"cached_input_tokens":7745792,"input_tokens":8509391,"output_tokens":25301,"reasoning_output_tokens":9616,"total_tokens":8534692}}}} +{"timestamp":"2030-01-01T12:01:18Z","type":"event_msg","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":10624,"input_tokens":24270,"output_tokens":661,"reasoning_output_tokens":501,"total_tokens":24931},"total_token_usage":{"cached_input_tokens":7756416,"input_tokens":8533661,"output_tokens":25962,"reasoning_output_tokens":10117,"total_tokens":8559623}}}} +{"timestamp":"2030-01-01T12:01:19Z","type":"event_msg","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":22400,"input_tokens":25184,"output_tokens":99,"reasoning_output_tokens":0,"total_tokens":25283},"total_token_usage":{"cached_input_tokens":7778816,"input_tokens":8558845,"output_tokens":26061,"reasoning_output_tokens":10117,"total_tokens":8584906}}}} +{"timestamp":"2030-01-01T12:01:20Z","type":"event_msg","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":23936,"input_tokens":25344,"output_tokens":142,"reasoning_output_tokens":0,"total_tokens":25486},"total_token_usage":{"cached_input_tokens":7802752,"input_tokens":8584189,"output_tokens":26203,"reasoning_output_tokens":10117,"total_tokens":8610392}}}} +{"timestamp":"2030-01-01T12:01:21Z","type":"event_msg","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":24960,"input_tokens":25558,"output_tokens":198,"reasoning_output_tokens":0,"total_tokens":25756},"total_token_usage":{"cached_input_tokens":7827712,"input_tokens":8609747,"output_tokens":26401,"reasoning_output_tokens":10117,"total_tokens":8636148}}}} +{"timestamp":"2030-01-01T12:01:22Z","type":"event_msg","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":24960,"input_tokens":25800,"output_tokens":158,"reasoning_output_tokens":11,"total_tokens":25958},"total_token_usage":{"cached_input_tokens":7852672,"input_tokens":8635547,"output_tokens":26559,"reasoning_output_tokens":10128,"total_tokens":8662106}}}} +{"timestamp":"2030-01-01T12:01:23Z","type":"event_msg","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":25472,"input_tokens":26276,"output_tokens":82,"reasoning_output_tokens":0,"total_tokens":26358},"total_token_usage":{"cached_input_tokens":7878144,"input_tokens":8661823,"output_tokens":26641,"reasoning_output_tokens":10128,"total_tokens":8688464}}}} +{"timestamp":"2030-01-01T12:01:24Z","type":"event_msg","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":25984,"input_tokens":26443,"output_tokens":160,"reasoning_output_tokens":13,"total_tokens":26603},"total_token_usage":{"cached_input_tokens":7904128,"input_tokens":8688266,"output_tokens":26801,"reasoning_output_tokens":10141,"total_tokens":8715067}}}} +{"timestamp":"2030-01-01T12:01:25Z","type":"event_msg","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":25472,"input_tokens":26708,"output_tokens":230,"reasoning_output_tokens":95,"total_tokens":26938},"total_token_usage":{"cached_input_tokens":7929600,"input_tokens":8714974,"output_tokens":27031,"reasoning_output_tokens":10236,"total_tokens":8742005}}}} +{"timestamp":"2030-01-01T12:01:26Z","type":"event_msg","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":4992,"input_tokens":28685,"output_tokens":738,"reasoning_output_tokens":516,"total_tokens":29423},"total_token_usage":{"cached_input_tokens":7934592,"input_tokens":8743659,"output_tokens":27769,"reasoning_output_tokens":10752,"total_tokens":8771428}}}} +{"timestamp":"2030-01-01T12:01:27Z","type":"event_msg","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":28544,"input_tokens":29652,"output_tokens":173,"reasoning_output_tokens":79,"total_tokens":29825},"total_token_usage":{"cached_input_tokens":7963136,"input_tokens":8773311,"output_tokens":27942,"reasoning_output_tokens":10831,"total_tokens":8801253}}}} +{"timestamp":"2030-01-01T12:01:28Z","type":"event_msg","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":29568,"input_tokens":29896,"output_tokens":128,"reasoning_output_tokens":0,"total_tokens":30024},"total_token_usage":{"cached_input_tokens":7992704,"input_tokens":8803207,"output_tokens":28070,"reasoning_output_tokens":10831,"total_tokens":8831277}}}} +{"timestamp":"2030-01-01T12:01:29Z","type":"event_msg","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":29568,"input_tokens":30083,"output_tokens":176,"reasoning_output_tokens":53,"total_tokens":30259},"total_token_usage":{"cached_input_tokens":8022272,"input_tokens":8833290,"output_tokens":28246,"reasoning_output_tokens":10884,"total_tokens":8861536}}}} +{"timestamp":"2030-01-01T12:01:30Z","type":"event_msg","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":30080,"input_tokens":30365,"output_tokens":133,"reasoning_output_tokens":45,"total_tokens":30498},"total_token_usage":{"cached_input_tokens":8052352,"input_tokens":8863655,"output_tokens":28379,"reasoning_output_tokens":10929,"total_tokens":8892034}}}} +{"timestamp":"2030-01-01T12:01:31Z","type":"event_msg","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":30080,"input_tokens":30560,"output_tokens":109,"reasoning_output_tokens":0,"total_tokens":30669},"total_token_usage":{"cached_input_tokens":8082432,"input_tokens":8894215,"output_tokens":28488,"reasoning_output_tokens":10929,"total_tokens":8922703}}}} +{"timestamp":"2030-01-01T12:01:32Z","type":"event_msg","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":30080,"input_tokens":30741,"output_tokens":276,"reasoning_output_tokens":115,"total_tokens":31017},"total_token_usage":{"cached_input_tokens":8112512,"input_tokens":8924956,"output_tokens":28764,"reasoning_output_tokens":11044,"total_tokens":8953720}}}} +{"timestamp":"2030-01-01T12:01:33Z","type":"event_msg","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":30592,"input_tokens":31183,"output_tokens":147,"reasoning_output_tokens":0,"total_tokens":31330},"total_token_usage":{"cached_input_tokens":8143104,"input_tokens":8956139,"output_tokens":28911,"reasoning_output_tokens":11044,"total_tokens":8985050}}}} +{"timestamp":"2030-01-01T12:01:34Z","type":"event_msg","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":31104,"input_tokens":31376,"output_tokens":74,"reasoning_output_tokens":11,"total_tokens":31450},"total_token_usage":{"cached_input_tokens":8174208,"input_tokens":8987515,"output_tokens":28985,"reasoning_output_tokens":11055,"total_tokens":9016500}}}} +{"timestamp":"2030-01-01T12:01:35Z","type":"event_msg","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":31104,"input_tokens":31621,"output_tokens":159,"reasoning_output_tokens":10,"total_tokens":31780},"total_token_usage":{"cached_input_tokens":8205312,"input_tokens":9019136,"output_tokens":29144,"reasoning_output_tokens":11065,"total_tokens":9048280}}}} +{"timestamp":"2030-01-01T12:01:36Z","type":"event_msg","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":31616,"input_tokens":31893,"output_tokens":558,"reasoning_output_tokens":353,"total_tokens":32451},"total_token_usage":{"cached_input_tokens":8236928,"input_tokens":9051029,"output_tokens":29702,"reasoning_output_tokens":11418,"total_tokens":9080731}}}} +{"timestamp":"2030-01-01T12:01:37Z","type":"event_msg","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":4992,"input_tokens":31182,"output_tokens":338,"reasoning_output_tokens":107,"total_tokens":31520},"total_token_usage":{"cached_input_tokens":8241920,"input_tokens":9082211,"output_tokens":30040,"reasoning_output_tokens":11525,"total_tokens":9112251}}}} +{"timestamp":"2030-01-01T12:01:38Z","type":"event_msg","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":10624,"input_tokens":31422,"output_tokens":383,"reasoning_output_tokens":242,"total_tokens":31805},"total_token_usage":{"cached_input_tokens":8252544,"input_tokens":9113633,"output_tokens":30423,"reasoning_output_tokens":11767,"total_tokens":9144056}}}} +{"timestamp":"2030-01-01T12:01:39Z","type":"event_msg","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":31104,"input_tokens":32571,"output_tokens":146,"reasoning_output_tokens":66,"total_tokens":32717},"total_token_usage":{"cached_input_tokens":8283648,"input_tokens":9146204,"output_tokens":30569,"reasoning_output_tokens":11833,"total_tokens":9176773}}}} +{"timestamp":"2030-01-01T12:01:40Z","type":"event_msg","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":32128,"input_tokens":34260,"output_tokens":213,"reasoning_output_tokens":54,"total_tokens":34473},"total_token_usage":{"cached_input_tokens":8315776,"input_tokens":9180464,"output_tokens":30782,"reasoning_output_tokens":11887,"total_tokens":9211246}}}} +{"timestamp":"2030-01-01T12:01:41Z","type":"event_msg","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":34176,"input_tokens":34550,"output_tokens":160,"reasoning_output_tokens":79,"total_tokens":34710},"total_token_usage":{"cached_input_tokens":8349952,"input_tokens":9215014,"output_tokens":30942,"reasoning_output_tokens":11966,"total_tokens":9245956}}}} +{"timestamp":"2030-01-01T12:01:42Z","type":"event_msg","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":34176,"input_tokens":37512,"output_tokens":189,"reasoning_output_tokens":84,"total_tokens":37701},"total_token_usage":{"cached_input_tokens":8384128,"input_tokens":9252526,"output_tokens":31131,"reasoning_output_tokens":12050,"total_tokens":9283657}}}} +{"timestamp":"2030-01-01T12:01:43Z","type":"event_msg","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":37248,"input_tokens":41747,"output_tokens":146,"reasoning_output_tokens":52,"total_tokens":41893},"total_token_usage":{"cached_input_tokens":8421376,"input_tokens":9294273,"output_tokens":31277,"reasoning_output_tokens":12102,"total_tokens":9325550}}}} +{"timestamp":"2030-01-01T12:01:44Z","type":"event_msg","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":4992,"input_tokens":42288,"output_tokens":343,"reasoning_output_tokens":54,"total_tokens":42631},"total_token_usage":{"cached_input_tokens":8426368,"input_tokens":9336561,"output_tokens":31620,"reasoning_output_tokens":12156,"total_tokens":9368181}}}} +{"timestamp":"2030-01-01T12:01:45Z","type":"event_msg","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":41856,"input_tokens":47980,"output_tokens":466,"reasoning_output_tokens":180,"total_tokens":48446},"total_token_usage":{"cached_input_tokens":8468224,"input_tokens":9384541,"output_tokens":32086,"reasoning_output_tokens":12336,"total_tokens":9416627}}}} +{"timestamp":"2030-01-01T12:01:46Z","type":"event_msg","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":47488,"input_tokens":48728,"output_tokens":270,"reasoning_output_tokens":60,"total_tokens":48998},"total_token_usage":{"cached_input_tokens":8515712,"input_tokens":9433269,"output_tokens":32356,"reasoning_output_tokens":12396,"total_tokens":9465625}}}} +{"timestamp":"2030-01-01T12:01:47Z","type":"event_msg","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":48512,"input_tokens":49417,"output_tokens":382,"reasoning_output_tokens":80,"total_tokens":49799},"total_token_usage":{"cached_input_tokens":8564224,"input_tokens":9482686,"output_tokens":32738,"reasoning_output_tokens":12476,"total_tokens":9515424}}}} +{"timestamp":"2030-01-01T12:01:48Z","type":"event_msg","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":49024,"input_tokens":51478,"output_tokens":533,"reasoning_output_tokens":364,"total_tokens":52011},"total_token_usage":{"cached_input_tokens":8613248,"input_tokens":9534164,"output_tokens":33271,"reasoning_output_tokens":12840,"total_tokens":9567435}}}} +{"timestamp":"2030-01-01T12:01:49Z","type":"event_msg","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":41344,"input_tokens":52090,"output_tokens":310,"reasoning_output_tokens":28,"total_tokens":52400},"total_token_usage":{"cached_input_tokens":8654592,"input_tokens":9586254,"output_tokens":33581,"reasoning_output_tokens":12868,"total_tokens":9619835}}}} +{"timestamp":"2030-01-01T12:01:50Z","type":"event_msg","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":51584,"input_tokens":66881,"output_tokens":91,"reasoning_output_tokens":16,"total_tokens":66972},"total_token_usage":{"cached_input_tokens":8706176,"input_tokens":9653135,"output_tokens":33672,"reasoning_output_tokens":12884,"total_tokens":9686807}}}} +{"timestamp":"2030-01-01T12:01:51Z","type":"event_msg","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":66432,"input_tokens":68134,"output_tokens":663,"reasoning_output_tokens":448,"total_tokens":68797},"total_token_usage":{"cached_input_tokens":8772608,"input_tokens":9721269,"output_tokens":34335,"reasoning_output_tokens":13332,"total_tokens":9755604}}}} +{"timestamp":"2030-01-01T12:01:52Z","type":"event_msg","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":67968,"input_tokens":68845,"output_tokens":337,"reasoning_output_tokens":0,"total_tokens":69182},"total_token_usage":{"cached_input_tokens":8840576,"input_tokens":9790114,"output_tokens":34672,"reasoning_output_tokens":13332,"total_tokens":9824786}}}} +{"timestamp":"2030-01-01T12:01:53Z","type":"event_msg","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":68480,"input_tokens":69232,"output_tokens":610,"reasoning_output_tokens":0,"total_tokens":69842},"total_token_usage":{"cached_input_tokens":8909056,"input_tokens":9859346,"output_tokens":35282,"reasoning_output_tokens":13332,"total_tokens":9894628}}}} +{"timestamp":"2030-01-01T12:01:54Z","type":"event_msg","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":68992,"input_tokens":69892,"output_tokens":382,"reasoning_output_tokens":244,"total_tokens":70274},"total_token_usage":{"cached_input_tokens":8978048,"input_tokens":9929238,"output_tokens":35664,"reasoning_output_tokens":13576,"total_tokens":9964902}}}} +{"timestamp":"2030-01-01T12:01:55Z","type":"event_msg","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":51072,"input_tokens":74724,"output_tokens":75,"reasoning_output_tokens":7,"total_tokens":74799},"total_token_usage":{"cached_input_tokens":9029120,"input_tokens":10003962,"output_tokens":35739,"reasoning_output_tokens":13583,"total_tokens":10039701}}}} +{"timestamp":"2030-01-01T12:01:56Z","type":"event_msg","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":74624,"input_tokens":76542,"output_tokens":66,"reasoning_output_tokens":7,"total_tokens":76608},"total_token_usage":{"cached_input_tokens":9103744,"input_tokens":10080504,"output_tokens":35805,"reasoning_output_tokens":13590,"total_tokens":10116309}}}} +{"timestamp":"2030-01-01T12:01:57Z","type":"event_msg","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":69504,"input_tokens":79951,"output_tokens":78,"reasoning_output_tokens":10,"total_tokens":80029},"total_token_usage":{"cached_input_tokens":9173248,"input_tokens":10160455,"output_tokens":35883,"reasoning_output_tokens":13600,"total_tokens":10196338}}}} +{"timestamp":"2030-01-01T12:01:58Z","type":"event_msg","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":76160,"input_tokens":83234,"output_tokens":75,"reasoning_output_tokens":6,"total_tokens":83309},"total_token_usage":{"cached_input_tokens":9249408,"input_tokens":10243689,"output_tokens":35958,"reasoning_output_tokens":13606,"total_tokens":10279647}}}} +{"timestamp":"2030-01-01T12:01:59Z","type":"event_msg","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":82816,"input_tokens":89587,"output_tokens":79,"reasoning_output_tokens":6,"total_tokens":89666},"total_token_usage":{"cached_input_tokens":9332224,"input_tokens":10333276,"output_tokens":36037,"reasoning_output_tokens":13612,"total_tokens":10369313}}}} +{"timestamp":"2030-01-01T12:02:00Z","type":"event_msg","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":89472,"input_tokens":102007,"output_tokens":153,"reasoning_output_tokens":22,"total_tokens":102160},"total_token_usage":{"cached_input_tokens":9421696,"input_tokens":10435283,"output_tokens":36190,"reasoning_output_tokens":13634,"total_tokens":10471473}}}} +{"timestamp":"2030-01-01T12:02:01Z","type":"event_msg","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":79744,"input_tokens":114177,"output_tokens":144,"reasoning_output_tokens":20,"total_tokens":114321},"total_token_usage":{"cached_input_tokens":9501440,"input_tokens":10549460,"output_tokens":36334,"reasoning_output_tokens":13654,"total_tokens":10585794}}}} +{"timestamp":"2030-01-01T12:02:02Z","type":"event_msg","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":114048,"input_tokens":123786,"output_tokens":88,"reasoning_output_tokens":17,"total_tokens":123874},"total_token_usage":{"cached_input_tokens":9615488,"input_tokens":10673246,"output_tokens":36422,"reasoning_output_tokens":13671,"total_tokens":10709668}}}} +{"timestamp":"2030-01-01T12:02:03Z","type":"event_msg","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":101760,"input_tokens":134637,"output_tokens":72,"reasoning_output_tokens":7,"total_tokens":134709},"total_token_usage":{"cached_input_tokens":9717248,"input_tokens":10807883,"output_tokens":36494,"reasoning_output_tokens":13678,"total_tokens":10844377}}}} +{"timestamp":"2030-01-01T12:02:04Z","type":"event_msg","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":134528,"input_tokens":146641,"output_tokens":87,"reasoning_output_tokens":13,"total_tokens":146728},"total_token_usage":{"cached_input_tokens":9851776,"input_tokens":10954524,"output_tokens":36581,"reasoning_output_tokens":13691,"total_tokens":10991105}}}} +{"timestamp":"2030-01-01T12:02:05Z","type":"event_msg","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":123776,"input_tokens":158050,"output_tokens":67,"reasoning_output_tokens":6,"total_tokens":158117},"total_token_usage":{"cached_input_tokens":9975552,"input_tokens":11112574,"output_tokens":36648,"reasoning_output_tokens":13697,"total_tokens":11149222}}}} +{"timestamp":"2030-01-01T12:02:06Z","type":"event_msg","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":146304,"input_tokens":167477,"output_tokens":68,"reasoning_output_tokens":9,"total_tokens":167545},"total_token_usage":{"cached_input_tokens":10121856,"input_tokens":11280051,"output_tokens":36716,"reasoning_output_tokens":13706,"total_tokens":11316767}}}} +{"timestamp":"2030-01-01T12:02:07Z","type":"event_msg","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":167296,"input_tokens":177612,"output_tokens":78,"reasoning_output_tokens":10,"total_tokens":177690},"total_token_usage":{"cached_input_tokens":10289152,"input_tokens":11457663,"output_tokens":36794,"reasoning_output_tokens":13716,"total_tokens":11494457}}}} +{"timestamp":"2030-01-01T12:02:08Z","type":"event_msg","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":177536,"input_tokens":189809,"output_tokens":70,"reasoning_output_tokens":9,"total_tokens":189879},"total_token_usage":{"cached_input_tokens":10466688,"input_tokens":11647472,"output_tokens":36864,"reasoning_output_tokens":13725,"total_tokens":11684336}}}} +{"timestamp":"2030-01-01T12:02:09Z","type":"event_msg","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":189312,"input_tokens":202957,"output_tokens":70,"reasoning_output_tokens":7,"total_tokens":203027},"total_token_usage":{"cached_input_tokens":10656000,"input_tokens":11850429,"output_tokens":36934,"reasoning_output_tokens":13732,"total_tokens":11887363}}}} +{"timestamp":"2030-01-01T12:02:10Z","type":"event_msg","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":202624,"input_tokens":213681,"output_tokens":258,"reasoning_output_tokens":20,"total_tokens":213939},"total_token_usage":{"cached_input_tokens":10858624,"input_tokens":12064110,"output_tokens":37192,"reasoning_output_tokens":13752,"total_tokens":12101302}}}} +{"timestamp":"2030-01-01T12:02:11Z","type":"event_msg","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":213376,"input_tokens":215284,"output_tokens":272,"reasoning_output_tokens":111,"total_tokens":215556},"total_token_usage":{"cached_input_tokens":11072000,"input_tokens":12279394,"output_tokens":37464,"reasoning_output_tokens":13863,"total_tokens":12316858}}}} +{"timestamp":"2030-01-01T12:02:12Z","type":"event_msg","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":214912,"input_tokens":215602,"output_tokens":83,"reasoning_output_tokens":0,"total_tokens":215685},"total_token_usage":{"cached_input_tokens":11286912,"input_tokens":12494996,"output_tokens":37547,"reasoning_output_tokens":13863,"total_tokens":12532543}}}} +{"timestamp":"2030-01-01T12:02:13Z","type":"event_msg","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":215424,"input_tokens":215763,"output_tokens":106,"reasoning_output_tokens":0,"total_tokens":215869},"total_token_usage":{"cached_input_tokens":11502336,"input_tokens":12710759,"output_tokens":37653,"reasoning_output_tokens":13863,"total_tokens":12748412}}}} +{"timestamp":"2030-01-01T12:02:14Z","type":"event_msg","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":215424,"input_tokens":215917,"output_tokens":57,"reasoning_output_tokens":0,"total_tokens":215974},"total_token_usage":{"cached_input_tokens":11717760,"input_tokens":12926676,"output_tokens":37710,"reasoning_output_tokens":13863,"total_tokens":12964386}}}} +{"timestamp":"2030-01-01T12:02:15Z","type":"event_msg","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":215424,"input_tokens":216066,"output_tokens":316,"reasoning_output_tokens":112,"total_tokens":216382},"total_token_usage":{"cached_input_tokens":11933184,"input_tokens":13142742,"output_tokens":38026,"reasoning_output_tokens":13975,"total_tokens":13180768}}}} +{"timestamp":"2030-01-01T12:02:16Z","type":"event_msg","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":215936,"input_tokens":216957,"output_tokens":388,"reasoning_output_tokens":185,"total_tokens":217345},"total_token_usage":{"cached_input_tokens":12149120,"input_tokens":13359699,"output_tokens":38414,"reasoning_output_tokens":14160,"total_tokens":13398113}}}} diff --git a/Tests/CodexBarTests/Fixtures/CostUsage/Issue2037/archived-fork-33ce-3869/manifest.json b/Tests/CodexBarTests/Fixtures/CostUsage/Issue2037/archived-fork-33ce-3869/manifest.json new file mode 100644 index 0000000000..59798c5b1a --- /dev/null +++ b/Tests/CodexBarTests/Fixtures/CostUsage/Issue2037/archived-fork-33ce-3869/manifest.json @@ -0,0 +1,41 @@ +{ + "schemaVersion": 1, + "redactionVersion": 1, + "familyAlias": "archived-fork-33ce-3869", + "files": [ + { + "alias": "parent", + "relativePath": "codex-home/archived_sessions/parent.jsonl", + "sourceRole": "archive", + "leafSessionAlias": "parent-session", + "parentSessionAlias": null + }, + { + "alias": "child", + "relativePath": "codex-home/archived_sessions/child.jsonl", + "sourceRole": "archive", + "leafSessionAlias": "child-session", + "parentSessionAlias": "parent-session" + } + ], + "copiedPrefixes": [ + { + "parentAlias": "parent", + "childAlias": "child", + "length": 135 + } + ], + "oracle": { + "parentEventCount": 135, + "childEventCount": 158, + "copiedPrefixLength": 135, + "parentLastTokens": 13432621, + "childLastTokens": 15352834, + "copiedPrefixLastTokens": 13432621, + "naiveLastTokens": 28785455, + "dedupedLastTokens": 15352834, + "copiedPrefixTimestampMismatches": 135, + "parentHasTotalTokenUsageDrop": false, + "childHasTotalTokenUsageDrop": false + } +} diff --git a/Tests/CodexBarTests/Fixtures/CostUsage/Issue2037/harness-smoke/codex-home/archived_sessions/child.jsonl b/Tests/CodexBarTests/Fixtures/CostUsage/Issue2037/harness-smoke/codex-home/archived_sessions/child.jsonl new file mode 100644 index 0000000000..9212ebc4f8 --- /dev/null +++ b/Tests/CodexBarTests/Fixtures/CostUsage/Issue2037/harness-smoke/codex-home/archived_sessions/child.jsonl @@ -0,0 +1,4 @@ +{"type":"session_meta","timestamp":"2026-07-11T12:00:02Z","payload":{"id":"child-session","forked_from_id":"parent-session"}} +{"type":"turn_context","timestamp":"2026-07-11T12:00:02Z","payload":{"model":"openai/gpt-5.5"}} +{"type":"event_msg","timestamp":"2026-07-11T12:00:03Z","payload":{"type":"token_count","info":{"model":"openai/gpt-5.5","last_token_usage":{"input_tokens":10,"cached_input_tokens":0,"output_tokens":1},"total_token_usage":{"input_tokens":10,"cached_input_tokens":0,"output_tokens":1}}}} +{"type":"event_msg","timestamp":"2026-07-11T12:00:04Z","payload":{"type":"token_count","info":{"model":"openai/gpt-5.5","last_token_usage":{"input_tokens":5,"cached_input_tokens":0,"output_tokens":1},"total_token_usage":{"input_tokens":15,"cached_input_tokens":0,"output_tokens":2}}}} diff --git a/Tests/CodexBarTests/Fixtures/CostUsage/Issue2037/harness-smoke/codex-home/sessions/2026/07/11/parent.jsonl b/Tests/CodexBarTests/Fixtures/CostUsage/Issue2037/harness-smoke/codex-home/sessions/2026/07/11/parent.jsonl new file mode 100644 index 0000000000..a763e3f35e --- /dev/null +++ b/Tests/CodexBarTests/Fixtures/CostUsage/Issue2037/harness-smoke/codex-home/sessions/2026/07/11/parent.jsonl @@ -0,0 +1,3 @@ +{"type":"session_meta","timestamp":"2026-07-11T12:00:00Z","payload":{"id":"parent-session"}} +{"type":"turn_context","timestamp":"2026-07-11T12:00:00Z","payload":{"model":"openai/gpt-5.5"}} +{"type":"event_msg","timestamp":"2026-07-11T12:00:01Z","payload":{"type":"token_count","info":{"model":"openai/gpt-5.5","last_token_usage":{"input_tokens":10,"cached_input_tokens":0,"output_tokens":1},"total_token_usage":{"input_tokens":10,"cached_input_tokens":0,"output_tokens":1}}}} diff --git a/Tests/CodexBarTests/Fixtures/CostUsage/Issue2037/harness-smoke/manifest.json b/Tests/CodexBarTests/Fixtures/CostUsage/Issue2037/harness-smoke/manifest.json new file mode 100644 index 0000000000..0d4e7c199e --- /dev/null +++ b/Tests/CodexBarTests/Fixtures/CostUsage/Issue2037/harness-smoke/manifest.json @@ -0,0 +1,28 @@ +{ + "schemaVersion": 1, + "redactionVersion": 1, + "familyAlias": "harness-smoke", + "files": [ + { + "alias": "parent", + "relativePath": "codex-home/sessions/2026/07/11/parent.jsonl", + "sourceRole": "active", + "leafSessionAlias": "parent-session", + "parentSessionAlias": null + }, + { + "alias": "child", + "relativePath": "codex-home/archived_sessions/child.jsonl", + "sourceRole": "archive", + "leafSessionAlias": "child-session", + "parentSessionAlias": "parent-session" + } + ], + "copiedPrefixes": [ + { + "parentAlias": "parent", + "childAlias": "child", + "length": 1 + } + ] +} diff --git a/Tests/CodexBarTests/Fixtures/CostUsage/Issue2037/live-fork-4d90-52bf/ORACLE.md b/Tests/CodexBarTests/Fixtures/CostUsage/Issue2037/live-fork-4d90-52bf/ORACLE.md new file mode 100644 index 0000000000..dc90414a18 --- /dev/null +++ b/Tests/CodexBarTests/Fixtures/CostUsage/Issue2037/live-fork-4d90-52bf/ORACLE.md @@ -0,0 +1,37 @@ +# Hand oracle: live fork 4d90→52bf (sanitized) + +Derived from a local Sol/Terra Ultra-adjacent fork (`019f4d90` → `019f52bf`). +Message bodies, paths, and real session IDs are stripped/aliased. Parent file is +**truncated to the copied prefix** (N=180) so this is a clean resolved-fork golden; +the live parent continued after the fork and is not fully represented here. + +| Stream | Token rows | Sum of `last.total_tokens` | +|---|---:|---:| +| Parent (prefix only) | 180 | 25,129,283 | +| Child all | 196 | 26,938,802 | +| Child unique suffix | 16 | 1,809,519 | + +```text +naive parent+child (last.total_tokens) = 52,068,085 +deduped parent-owns-prefix (last.total_tokens) = 26,938,802 +N = 180 +``` + +## Scanner units (integration) + +CostUsageScanner follows **`total_token_usage` deltas**, not `sum(last)`. +Parent ordinal **120** has `last` scanner units 225,513 with **Δtotal = 0**, so +`sum(last)` overcounts the parent stream vs the scanner. + +| Metric | Scanner units (`input+cached+output`) | +|---|---:| +| Parent final totals | 48,730,248 | +| Child unique (Δ totals) | 3,455,599 | +| Deduped family (`#1164`) | 52,185,847 | +| Naive both finals | 100,916,095 | + +With parent present, `#1164` should match `deduped` scanner units +(`Issue2037ScannerIntegrationTests`). Because the parent is truncated to the +copied prefix, that family total equals the child's final cumulative totals. + +Not an Ultra interleaved golden. Not a claim that #2037 is closed. diff --git a/Tests/CodexBarTests/Fixtures/CostUsage/Issue2037/live-fork-4d90-52bf/codex-home/archived_sessions/child.jsonl b/Tests/CodexBarTests/Fixtures/CostUsage/Issue2037/live-fork-4d90-52bf/codex-home/archived_sessions/child.jsonl new file mode 100644 index 0000000000..1ccfbbb77e --- /dev/null +++ b/Tests/CodexBarTests/Fixtures/CostUsage/Issue2037/live-fork-4d90-52bf/codex-home/archived_sessions/child.jsonl @@ -0,0 +1,198 @@ +{"type":"session_meta","timestamp":"2030-01-01T15:00:00Z","payload":{"id":"child-session","timestamp":"2030-01-01T15:00:00Z","forked_from_id":"parent-session"}} +{"type":"turn_context","timestamp":"2030-01-01T15:00:00Z","payload":{"model":"fixture-model","multi_agent_version":"v2","multi_agent_mode":"explicitRequestOnly"}} +{"type":"event_msg","timestamp":"2030-01-01T15:00:01Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":21090,"cached_input_tokens":9984,"output_tokens":236,"reasoning_output_tokens":74,"total_tokens":21326},"total_token_usage":{"input_tokens":21090,"cached_input_tokens":9984,"output_tokens":236,"reasoning_output_tokens":74,"total_tokens":21326}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:00:02Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":22299,"cached_input_tokens":20224,"output_tokens":103,"reasoning_output_tokens":61,"total_tokens":22402},"total_token_usage":{"input_tokens":43389,"cached_input_tokens":30208,"output_tokens":339,"reasoning_output_tokens":135,"total_tokens":43728}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:00:03Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":32596,"cached_input_tokens":21248,"output_tokens":66,"reasoning_output_tokens":16,"total_tokens":32662},"total_token_usage":{"input_tokens":75985,"cached_input_tokens":51456,"output_tokens":405,"reasoning_output_tokens":151,"total_tokens":76390}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:00:04Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":32747,"cached_input_tokens":31488,"output_tokens":74,"reasoning_output_tokens":7,"total_tokens":32821},"total_token_usage":{"input_tokens":108732,"cached_input_tokens":82944,"output_tokens":479,"reasoning_output_tokens":158,"total_tokens":109211}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:00:05Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":33461,"cached_input_tokens":32512,"output_tokens":178,"reasoning_output_tokens":32,"total_tokens":33639},"total_token_usage":{"input_tokens":142193,"cached_input_tokens":115456,"output_tokens":657,"reasoning_output_tokens":190,"total_tokens":142850}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:00:06Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":37948,"cached_input_tokens":32512,"output_tokens":267,"reasoning_output_tokens":139,"total_tokens":38215},"total_token_usage":{"input_tokens":180141,"cached_input_tokens":147968,"output_tokens":924,"reasoning_output_tokens":329,"total_tokens":181065}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:00:07Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":38264,"cached_input_tokens":37632,"output_tokens":115,"reasoning_output_tokens":17,"total_tokens":38379},"total_token_usage":{"input_tokens":218405,"cached_input_tokens":185600,"output_tokens":1039,"reasoning_output_tokens":346,"total_tokens":219444}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:00:08Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":38421,"cached_input_tokens":37632,"output_tokens":114,"reasoning_output_tokens":52,"total_tokens":38535},"total_token_usage":{"input_tokens":256826,"cached_input_tokens":223232,"output_tokens":1153,"reasoning_output_tokens":398,"total_tokens":257979}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:00:09Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":38634,"cached_input_tokens":37632,"output_tokens":152,"reasoning_output_tokens":46,"total_tokens":38786},"total_token_usage":{"input_tokens":295460,"cached_input_tokens":260864,"output_tokens":1305,"reasoning_output_tokens":444,"total_tokens":296765}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:00:10Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":39259,"cached_input_tokens":37632,"output_tokens":116,"reasoning_output_tokens":23,"total_tokens":39375},"total_token_usage":{"input_tokens":334719,"cached_input_tokens":298496,"output_tokens":1421,"reasoning_output_tokens":467,"total_tokens":336140}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:00:11Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":47563,"cached_input_tokens":38656,"output_tokens":158,"reasoning_output_tokens":16,"total_tokens":47721},"total_token_usage":{"input_tokens":382282,"cached_input_tokens":337152,"output_tokens":1579,"reasoning_output_tokens":483,"total_tokens":383861}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:00:12Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":54051,"cached_input_tokens":46848,"output_tokens":189,"reasoning_output_tokens":46,"total_tokens":54240},"total_token_usage":{"input_tokens":436333,"cached_input_tokens":384000,"output_tokens":1768,"reasoning_output_tokens":529,"total_tokens":438101}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:00:13Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":60672,"cached_input_tokens":52992,"output_tokens":156,"reasoning_output_tokens":24,"total_tokens":60828},"total_token_usage":{"input_tokens":497005,"cached_input_tokens":436992,"output_tokens":1924,"reasoning_output_tokens":553,"total_tokens":498929}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:00:14Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":60942,"cached_input_tokens":60160,"output_tokens":126,"reasoning_output_tokens":0,"total_tokens":61068},"total_token_usage":{"input_tokens":557947,"cached_input_tokens":497152,"output_tokens":2050,"reasoning_output_tokens":553,"total_tokens":559997}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:00:15Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":65844,"cached_input_tokens":60160,"output_tokens":174,"reasoning_output_tokens":32,"total_tokens":66018},"total_token_usage":{"input_tokens":623791,"cached_input_tokens":557312,"output_tokens":2224,"reasoning_output_tokens":585,"total_tokens":626015}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:00:16Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":67233,"cached_input_tokens":65280,"output_tokens":221,"reasoning_output_tokens":24,"total_tokens":67454},"total_token_usage":{"input_tokens":691024,"cached_input_tokens":622592,"output_tokens":2445,"reasoning_output_tokens":609,"total_tokens":693469}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:00:17Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":74643,"cached_input_tokens":66304,"output_tokens":1186,"reasoning_output_tokens":822,"total_tokens":75829},"total_token_usage":{"input_tokens":765667,"cached_input_tokens":688896,"output_tokens":3631,"reasoning_output_tokens":1431,"total_tokens":769298}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:00:18Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":79611,"cached_input_tokens":74496,"output_tokens":347,"reasoning_output_tokens":143,"total_tokens":79958},"total_token_usage":{"input_tokens":845278,"cached_input_tokens":763392,"output_tokens":3978,"reasoning_output_tokens":1574,"total_tokens":849256}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:00:19Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":80506,"cached_input_tokens":78592,"output_tokens":539,"reasoning_output_tokens":184,"total_tokens":81045},"total_token_usage":{"input_tokens":925784,"cached_input_tokens":841984,"output_tokens":4517,"reasoning_output_tokens":1758,"total_tokens":930301}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:00:20Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":88475,"cached_input_tokens":79616,"output_tokens":1556,"reasoning_output_tokens":186,"total_tokens":90031},"total_token_usage":{"input_tokens":1014259,"cached_input_tokens":921600,"output_tokens":6073,"reasoning_output_tokens":1944,"total_tokens":1020332}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:00:21Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":91050,"cached_input_tokens":87808,"output_tokens":236,"reasoning_output_tokens":103,"total_tokens":91286},"total_token_usage":{"input_tokens":1105309,"cached_input_tokens":1009408,"output_tokens":6309,"reasoning_output_tokens":2047,"total_tokens":1111618}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:00:22Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":91482,"cached_input_tokens":90880,"output_tokens":143,"reasoning_output_tokens":7,"total_tokens":91625},"total_token_usage":{"input_tokens":1196791,"cached_input_tokens":1100288,"output_tokens":6452,"reasoning_output_tokens":2054,"total_tokens":1203243}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:00:23Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":91708,"cached_input_tokens":90880,"output_tokens":1001,"reasoning_output_tokens":9,"total_tokens":92709},"total_token_usage":{"input_tokens":1288499,"cached_input_tokens":1191168,"output_tokens":7453,"reasoning_output_tokens":2063,"total_tokens":1295952}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:00:24Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":93284,"cached_input_tokens":90880,"output_tokens":272,"reasoning_output_tokens":180,"total_tokens":93556},"total_token_usage":{"input_tokens":1381783,"cached_input_tokens":1282048,"output_tokens":7725,"reasoning_output_tokens":2243,"total_tokens":1389508}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:00:25Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":95924,"cached_input_tokens":92928,"output_tokens":104,"reasoning_output_tokens":10,"total_tokens":96028},"total_token_usage":{"input_tokens":1477707,"cached_input_tokens":1374976,"output_tokens":7829,"reasoning_output_tokens":2253,"total_tokens":1485536}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:00:26Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":102511,"cached_input_tokens":94976,"output_tokens":937,"reasoning_output_tokens":504,"total_tokens":103448},"total_token_usage":{"input_tokens":1580218,"cached_input_tokens":1469952,"output_tokens":8766,"reasoning_output_tokens":2757,"total_tokens":1588984}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:00:27Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":112826,"cached_input_tokens":102144,"output_tokens":234,"reasoning_output_tokens":101,"total_tokens":113060},"total_token_usage":{"input_tokens":1693044,"cached_input_tokens":1572096,"output_tokens":9000,"reasoning_output_tokens":2858,"total_tokens":1702044}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:00:28Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":116136,"cached_input_tokens":112384,"output_tokens":415,"reasoning_output_tokens":232,"total_tokens":116551},"total_token_usage":{"input_tokens":1809180,"cached_input_tokens":1684480,"output_tokens":9415,"reasoning_output_tokens":3090,"total_tokens":1818595}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:00:29Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":116579,"cached_input_tokens":115456,"output_tokens":40,"reasoning_output_tokens":7,"total_tokens":116619},"total_token_usage":{"input_tokens":1925759,"cached_input_tokens":1799936,"output_tokens":9455,"reasoning_output_tokens":3097,"total_tokens":1935214}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:00:30Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":116647,"cached_input_tokens":115456,"output_tokens":47,"reasoning_output_tokens":14,"total_tokens":116694},"total_token_usage":{"input_tokens":2042406,"cached_input_tokens":1915392,"output_tokens":9502,"reasoning_output_tokens":3111,"total_tokens":2051908}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:00:31Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":116722,"cached_input_tokens":116480,"output_tokens":39,"reasoning_output_tokens":6,"total_tokens":116761},"total_token_usage":{"input_tokens":2159128,"cached_input_tokens":2031872,"output_tokens":9541,"reasoning_output_tokens":3117,"total_tokens":2168669}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:00:32Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":119471,"cached_input_tokens":116480,"output_tokens":142,"reasoning_output_tokens":94,"total_tokens":119613},"total_token_usage":{"input_tokens":2278599,"cached_input_tokens":2148352,"output_tokens":9683,"reasoning_output_tokens":3211,"total_tokens":2288282}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:00:33Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":119641,"cached_input_tokens":118528,"output_tokens":41,"reasoning_output_tokens":8,"total_tokens":119682},"total_token_usage":{"input_tokens":2398240,"cached_input_tokens":2266880,"output_tokens":9724,"reasoning_output_tokens":3219,"total_tokens":2407964}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:00:34Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":119710,"cached_input_tokens":118528,"output_tokens":39,"reasoning_output_tokens":6,"total_tokens":119749},"total_token_usage":{"input_tokens":2517950,"cached_input_tokens":2385408,"output_tokens":9763,"reasoning_output_tokens":3225,"total_tokens":2527713}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:00:35Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":119826,"cached_input_tokens":119552,"output_tokens":58,"reasoning_output_tokens":10,"total_tokens":119884},"total_token_usage":{"input_tokens":2637776,"cached_input_tokens":2504960,"output_tokens":9821,"reasoning_output_tokens":3235,"total_tokens":2647597}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:00:36Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":119912,"cached_input_tokens":119552,"output_tokens":39,"reasoning_output_tokens":6,"total_tokens":119951},"total_token_usage":{"input_tokens":2757688,"cached_input_tokens":2624512,"output_tokens":9860,"reasoning_output_tokens":3241,"total_tokens":2767548}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:00:37Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":119979,"cached_input_tokens":119552,"output_tokens":39,"reasoning_output_tokens":6,"total_tokens":120018},"total_token_usage":{"input_tokens":2877667,"cached_input_tokens":2744064,"output_tokens":9899,"reasoning_output_tokens":3247,"total_tokens":2887566}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:00:38Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":123376,"cached_input_tokens":119552,"output_tokens":57,"reasoning_output_tokens":9,"total_tokens":123433},"total_token_usage":{"input_tokens":3001043,"cached_input_tokens":2863616,"output_tokens":9956,"reasoning_output_tokens":3256,"total_tokens":3010999}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:00:39Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":123461,"cached_input_tokens":122624,"output_tokens":31,"reasoning_output_tokens":0,"total_tokens":123492},"total_token_usage":{"input_tokens":3124504,"cached_input_tokens":2986240,"output_tokens":9987,"reasoning_output_tokens":3256,"total_tokens":3134491}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:00:40Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":123520,"cached_input_tokens":122624,"output_tokens":31,"reasoning_output_tokens":0,"total_tokens":123551},"total_token_usage":{"input_tokens":3248024,"cached_input_tokens":3108864,"output_tokens":10018,"reasoning_output_tokens":3256,"total_tokens":3258042}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:00:41Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":126725,"cached_input_tokens":122624,"output_tokens":57,"reasoning_output_tokens":9,"total_tokens":126782},"total_token_usage":{"input_tokens":3374749,"cached_input_tokens":3231488,"output_tokens":10075,"reasoning_output_tokens":3265,"total_tokens":3384824}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:00:42Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":126810,"cached_input_tokens":125696,"output_tokens":31,"reasoning_output_tokens":0,"total_tokens":126841},"total_token_usage":{"input_tokens":3501559,"cached_input_tokens":3357184,"output_tokens":10106,"reasoning_output_tokens":3265,"total_tokens":3511665}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:00:43Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":126869,"cached_input_tokens":125696,"output_tokens":31,"reasoning_output_tokens":0,"total_tokens":126900},"total_token_usage":{"input_tokens":3628428,"cached_input_tokens":3482880,"output_tokens":10137,"reasoning_output_tokens":3265,"total_tokens":3638565}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:00:44Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":127424,"cached_input_tokens":126720,"output_tokens":54,"reasoning_output_tokens":6,"total_tokens":127478},"total_token_usage":{"input_tokens":3755852,"cached_input_tokens":3609600,"output_tokens":10191,"reasoning_output_tokens":3271,"total_tokens":3766043}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:00:45Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":127506,"cached_input_tokens":126720,"output_tokens":31,"reasoning_output_tokens":0,"total_tokens":127537},"total_token_usage":{"input_tokens":3883358,"cached_input_tokens":3736320,"output_tokens":10222,"reasoning_output_tokens":3271,"total_tokens":3893580}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:00:46Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":127565,"cached_input_tokens":126720,"output_tokens":31,"reasoning_output_tokens":0,"total_tokens":127596},"total_token_usage":{"input_tokens":4010923,"cached_input_tokens":3863040,"output_tokens":10253,"reasoning_output_tokens":3271,"total_tokens":4021176}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:00:47Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":134061,"cached_input_tokens":126720,"output_tokens":56,"reasoning_output_tokens":8,"total_tokens":134117},"total_token_usage":{"input_tokens":4144984,"cached_input_tokens":3989760,"output_tokens":10309,"reasoning_output_tokens":3279,"total_tokens":4155293}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:00:48Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":134476,"cached_input_tokens":133888,"output_tokens":215,"reasoning_output_tokens":22,"total_tokens":134691},"total_token_usage":{"input_tokens":4279460,"cached_input_tokens":4123648,"output_tokens":10524,"reasoning_output_tokens":3301,"total_tokens":4289984}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:00:49Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":137655,"cached_input_tokens":133888,"output_tokens":169,"reasoning_output_tokens":28,"total_tokens":137824},"total_token_usage":{"input_tokens":4417115,"cached_input_tokens":4257536,"output_tokens":10693,"reasoning_output_tokens":3329,"total_tokens":4427808}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:00:50Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":139085,"cached_input_tokens":136960,"output_tokens":1039,"reasoning_output_tokens":590,"total_tokens":140124},"total_token_usage":{"input_tokens":4556200,"cached_input_tokens":4394496,"output_tokens":11732,"reasoning_output_tokens":3919,"total_tokens":4567932}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:00:51Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":149841,"cached_input_tokens":137984,"output_tokens":2471,"reasoning_output_tokens":1886,"total_tokens":152312},"total_token_usage":{"input_tokens":4706041,"cached_input_tokens":4532480,"output_tokens":14203,"reasoning_output_tokens":5805,"total_tokens":4720244}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:00:52Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":152806,"cached_input_tokens":9984,"output_tokens":367,"reasoning_output_tokens":211,"total_tokens":153173},"total_token_usage":{"input_tokens":4858847,"cached_input_tokens":4542464,"output_tokens":14570,"reasoning_output_tokens":6016,"total_tokens":4873417}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:00:53Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":153190,"cached_input_tokens":152320,"output_tokens":881,"reasoning_output_tokens":514,"total_tokens":154071},"total_token_usage":{"input_tokens":5012037,"cached_input_tokens":4694784,"output_tokens":15451,"reasoning_output_tokens":6530,"total_tokens":5027488}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:00:54Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":154599,"cached_input_tokens":9984,"output_tokens":172,"reasoning_output_tokens":48,"total_tokens":154771},"total_token_usage":{"input_tokens":5166636,"cached_input_tokens":4704768,"output_tokens":15623,"reasoning_output_tokens":6578,"total_tokens":5182259}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:00:55Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":154836,"cached_input_tokens":154368,"output_tokens":113,"reasoning_output_tokens":15,"total_tokens":154949},"total_token_usage":{"input_tokens":5321472,"cached_input_tokens":4859136,"output_tokens":15736,"reasoning_output_tokens":6593,"total_tokens":5337208}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:00:56Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":159174,"cached_input_tokens":154368,"output_tokens":2870,"reasoning_output_tokens":2102,"total_tokens":162044},"total_token_usage":{"input_tokens":5480646,"cached_input_tokens":5013504,"output_tokens":18606,"reasoning_output_tokens":8695,"total_tokens":5499252}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:00:57Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":169086,"cached_input_tokens":158464,"output_tokens":204,"reasoning_output_tokens":71,"total_tokens":169290},"total_token_usage":{"input_tokens":5649732,"cached_input_tokens":5171968,"output_tokens":18810,"reasoning_output_tokens":8766,"total_tokens":5668542}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:00:58Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":170322,"cached_input_tokens":168704,"output_tokens":1547,"reasoning_output_tokens":1422,"total_tokens":171869},"total_token_usage":{"input_tokens":5820054,"cached_input_tokens":5340672,"output_tokens":20357,"reasoning_output_tokens":10188,"total_tokens":5840411}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:00:59Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":172875,"cached_input_tokens":169728,"output_tokens":2843,"reasoning_output_tokens":1482,"total_tokens":175718},"total_token_usage":{"input_tokens":5992929,"cached_input_tokens":5510400,"output_tokens":23200,"reasoning_output_tokens":11670,"total_tokens":6016129}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:01:00Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":172711,"cached_input_tokens":9984,"output_tokens":252,"reasoning_output_tokens":72,"total_tokens":172963},"total_token_usage":{"input_tokens":6165640,"cached_input_tokens":5520384,"output_tokens":23452,"reasoning_output_tokens":11742,"total_tokens":6189092}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:01:01Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":182718,"cached_input_tokens":171776,"output_tokens":243,"reasoning_output_tokens":131,"total_tokens":182961},"total_token_usage":{"input_tokens":6348358,"cached_input_tokens":5692160,"output_tokens":23695,"reasoning_output_tokens":11873,"total_tokens":6372053}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:01:02Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":190342,"cached_input_tokens":182016,"output_tokens":1688,"reasoning_output_tokens":1374,"total_tokens":192030},"total_token_usage":{"input_tokens":6538700,"cached_input_tokens":5874176,"output_tokens":25383,"reasoning_output_tokens":13247,"total_tokens":6564083}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:01:03Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":202821,"cached_input_tokens":190208,"output_tokens":157,"reasoning_output_tokens":25,"total_tokens":202978},"total_token_usage":{"input_tokens":6741521,"cached_input_tokens":6064384,"output_tokens":25540,"reasoning_output_tokens":13272,"total_tokens":6767061}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:01:04Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":212387,"cached_input_tokens":202496,"output_tokens":345,"reasoning_output_tokens":236,"total_tokens":212732},"total_token_usage":{"input_tokens":6953908,"cached_input_tokens":6266880,"output_tokens":25885,"reasoning_output_tokens":13508,"total_tokens":6979793}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:01:05Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":213742,"cached_input_tokens":211712,"output_tokens":183,"reasoning_output_tokens":115,"total_tokens":213925},"total_token_usage":{"input_tokens":7167650,"cached_input_tokens":6478592,"output_tokens":26068,"reasoning_output_tokens":13623,"total_tokens":7193718}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:01:06Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":216932,"cached_input_tokens":212736,"output_tokens":2642,"reasoning_output_tokens":2578,"total_tokens":219574},"total_token_usage":{"input_tokens":7384582,"cached_input_tokens":6691328,"output_tokens":28710,"reasoning_output_tokens":16201,"total_tokens":7413292}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:01:07Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":221647,"cached_input_tokens":215808,"output_tokens":40,"reasoning_output_tokens":7,"total_tokens":221687},"total_token_usage":{"input_tokens":7606229,"cached_input_tokens":6907136,"output_tokens":28750,"reasoning_output_tokens":16208,"total_tokens":7634979}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:01:08Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":236703,"cached_input_tokens":220928,"output_tokens":278,"reasoning_output_tokens":101,"total_tokens":236981},"total_token_usage":{"input_tokens":7842932,"cached_input_tokens":7128064,"output_tokens":29028,"reasoning_output_tokens":16309,"total_tokens":7871960}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:01:09Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":243552,"cached_input_tokens":236288,"output_tokens":4352,"reasoning_output_tokens":3920,"total_tokens":247904},"total_token_usage":{"input_tokens":8086484,"cached_input_tokens":7364352,"output_tokens":33380,"reasoning_output_tokens":20229,"total_tokens":8119864}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:01:10Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":251791,"cached_input_tokens":242432,"output_tokens":1213,"reasoning_output_tokens":0,"total_tokens":253004},"total_token_usage":{"input_tokens":8338275,"cached_input_tokens":7606784,"output_tokens":34593,"reasoning_output_tokens":20229,"total_tokens":8372868}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:01:11Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":254769,"cached_input_tokens":251648,"output_tokens":1038,"reasoning_output_tokens":516,"total_tokens":255807},"total_token_usage":{"input_tokens":8593044,"cached_input_tokens":7858432,"output_tokens":35631,"reasoning_output_tokens":20745,"total_tokens":8628675}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:01:12Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":257629,"cached_input_tokens":253696,"output_tokens":4045,"reasoning_output_tokens":3322,"total_tokens":261674},"total_token_usage":{"input_tokens":8850673,"cached_input_tokens":8112128,"output_tokens":39676,"reasoning_output_tokens":24067,"total_tokens":8890349}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:01:13Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":264619,"cached_input_tokens":256768,"output_tokens":288,"reasoning_output_tokens":84,"total_tokens":264907},"total_token_usage":{"input_tokens":9115292,"cached_input_tokens":8368896,"output_tokens":39964,"reasoning_output_tokens":24151,"total_tokens":9155256}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:01:14Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":267930,"cached_input_tokens":263936,"output_tokens":187,"reasoning_output_tokens":18,"total_tokens":268117},"total_token_usage":{"input_tokens":9383222,"cached_input_tokens":8632832,"output_tokens":40151,"reasoning_output_tokens":24169,"total_tokens":9423373}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:01:15Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":277102,"cached_input_tokens":267008,"output_tokens":219,"reasoning_output_tokens":21,"total_tokens":277321},"total_token_usage":{"input_tokens":9660324,"cached_input_tokens":8899840,"output_tokens":40370,"reasoning_output_tokens":24190,"total_tokens":9700694}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:01:16Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":281697,"cached_input_tokens":276224,"output_tokens":944,"reasoning_output_tokens":588,"total_tokens":282641},"total_token_usage":{"input_tokens":9942021,"cached_input_tokens":9176064,"output_tokens":41314,"reasoning_output_tokens":24778,"total_tokens":9983335}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:01:17Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":286158,"cached_input_tokens":281344,"output_tokens":74,"reasoning_output_tokens":8,"total_tokens":286232},"total_token_usage":{"input_tokens":10228179,"cached_input_tokens":9457408,"output_tokens":41388,"reasoning_output_tokens":24786,"total_tokens":10269567}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:01:18Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":286821,"cached_input_tokens":285440,"output_tokens":1057,"reasoning_output_tokens":776,"total_tokens":287878},"total_token_usage":{"input_tokens":10515000,"cached_input_tokens":9742848,"output_tokens":42445,"reasoning_output_tokens":25562,"total_tokens":10557445}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:01:19Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":293437,"cached_input_tokens":286464,"output_tokens":804,"reasoning_output_tokens":288,"total_tokens":294241},"total_token_usage":{"input_tokens":10808437,"cached_input_tokens":10029312,"output_tokens":43249,"reasoning_output_tokens":25850,"total_tokens":10851686}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:01:20Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":296500,"cached_input_tokens":292608,"output_tokens":180,"reasoning_output_tokens":120,"total_tokens":296680},"total_token_usage":{"input_tokens":11104937,"cached_input_tokens":10321920,"output_tokens":43429,"reasoning_output_tokens":25970,"total_tokens":11148366}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:01:21Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":296708,"cached_input_tokens":295680,"output_tokens":39,"reasoning_output_tokens":6,"total_tokens":296747},"total_token_usage":{"input_tokens":11401645,"cached_input_tokens":10617600,"output_tokens":43468,"reasoning_output_tokens":25976,"total_tokens":11445113}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:01:22Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":0,"cached_input_tokens":0,"output_tokens":0,"reasoning_output_tokens":0,"total_tokens":19666},"total_token_usage":{"input_tokens":11401645,"cached_input_tokens":10617600,"output_tokens":43468,"reasoning_output_tokens":25976,"total_tokens":11445113}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:01:23Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":24864,"cached_input_tokens":9984,"output_tokens":572,"reasoning_output_tokens":0,"total_tokens":25436},"total_token_usage":{"input_tokens":11426509,"cached_input_tokens":10627584,"output_tokens":44040,"reasoning_output_tokens":25976,"total_tokens":11470549}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:01:24Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":26522,"cached_input_tokens":24320,"output_tokens":230,"reasoning_output_tokens":0,"total_tokens":26752},"total_token_usage":{"input_tokens":11453031,"cached_input_tokens":10651904,"output_tokens":44270,"reasoning_output_tokens":25976,"total_tokens":11497301}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:01:25Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":27842,"cached_input_tokens":26368,"output_tokens":200,"reasoning_output_tokens":79,"total_tokens":28042},"total_token_usage":{"input_tokens":11480873,"cached_input_tokens":10678272,"output_tokens":44470,"reasoning_output_tokens":26055,"total_tokens":11525343}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:01:26Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":28052,"cached_input_tokens":27392,"output_tokens":105,"reasoning_output_tokens":0,"total_tokens":28157},"total_token_usage":{"input_tokens":11508925,"cached_input_tokens":10705664,"output_tokens":44575,"reasoning_output_tokens":26055,"total_tokens":11553500}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:01:27Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":29374,"cached_input_tokens":27392,"output_tokens":93,"reasoning_output_tokens":13,"total_tokens":29467},"total_token_usage":{"input_tokens":11538299,"cached_input_tokens":10733056,"output_tokens":44668,"reasoning_output_tokens":26068,"total_tokens":11582967}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:01:28Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":31587,"cached_input_tokens":28416,"output_tokens":246,"reasoning_output_tokens":115,"total_tokens":31833},"total_token_usage":{"input_tokens":11569886,"cached_input_tokens":10761472,"output_tokens":44914,"reasoning_output_tokens":26183,"total_tokens":11614800}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:01:29Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":31900,"cached_input_tokens":30464,"output_tokens":146,"reasoning_output_tokens":36,"total_tokens":32046},"total_token_usage":{"input_tokens":11601786,"cached_input_tokens":10791936,"output_tokens":45060,"reasoning_output_tokens":26219,"total_tokens":11646846}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:01:30Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":32963,"cached_input_tokens":31488,"output_tokens":147,"reasoning_output_tokens":56,"total_tokens":33110},"total_token_usage":{"input_tokens":11634749,"cached_input_tokens":10823424,"output_tokens":45207,"reasoning_output_tokens":26275,"total_tokens":11679956}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:01:31Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":33206,"cached_input_tokens":32512,"output_tokens":92,"reasoning_output_tokens":10,"total_tokens":33298},"total_token_usage":{"input_tokens":11667955,"cached_input_tokens":10855936,"output_tokens":45299,"reasoning_output_tokens":26285,"total_tokens":11713254}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:01:32Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":34299,"cached_input_tokens":32512,"output_tokens":93,"reasoning_output_tokens":11,"total_tokens":34392},"total_token_usage":{"input_tokens":11702254,"cached_input_tokens":10888448,"output_tokens":45392,"reasoning_output_tokens":26296,"total_tokens":11747646}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:01:33Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":34726,"cached_input_tokens":33536,"output_tokens":195,"reasoning_output_tokens":112,"total_tokens":34921},"total_token_usage":{"input_tokens":11736980,"cached_input_tokens":10921984,"output_tokens":45587,"reasoning_output_tokens":26408,"total_tokens":11782567}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:01:34Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":35784,"cached_input_tokens":34560,"output_tokens":2117,"reasoning_output_tokens":137,"total_tokens":37901},"total_token_usage":{"input_tokens":11772764,"cached_input_tokens":10956544,"output_tokens":47704,"reasoning_output_tokens":26545,"total_tokens":11820468}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:01:35Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":37925,"cached_input_tokens":35584,"output_tokens":170,"reasoning_output_tokens":29,"total_tokens":38095},"total_token_usage":{"input_tokens":11810689,"cached_input_tokens":10992128,"output_tokens":47874,"reasoning_output_tokens":26574,"total_tokens":11858563}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:01:36Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":38178,"cached_input_tokens":37632,"output_tokens":141,"reasoning_output_tokens":56,"total_tokens":38319},"total_token_usage":{"input_tokens":11848867,"cached_input_tokens":11029760,"output_tokens":48015,"reasoning_output_tokens":26630,"total_tokens":11896882}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:01:37Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":40051,"cached_input_tokens":37632,"output_tokens":450,"reasoning_output_tokens":271,"total_tokens":40501},"total_token_usage":{"input_tokens":11888918,"cached_input_tokens":11067392,"output_tokens":48465,"reasoning_output_tokens":26901,"total_tokens":11937383}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:01:38Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":40525,"cached_input_tokens":39680,"output_tokens":180,"reasoning_output_tokens":35,"total_tokens":40705},"total_token_usage":{"input_tokens":11929443,"cached_input_tokens":11107072,"output_tokens":48645,"reasoning_output_tokens":26936,"total_tokens":11978088}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:01:39Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":40744,"cached_input_tokens":39680,"output_tokens":158,"reasoning_output_tokens":0,"total_tokens":40902},"total_token_usage":{"input_tokens":11970187,"cached_input_tokens":11146752,"output_tokens":48803,"reasoning_output_tokens":26936,"total_tokens":12018990}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:01:40Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":41993,"cached_input_tokens":39680,"output_tokens":749,"reasoning_output_tokens":516,"total_tokens":42742},"total_token_usage":{"input_tokens":12012180,"cached_input_tokens":11186432,"output_tokens":49552,"reasoning_output_tokens":27452,"total_tokens":12061732}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:01:41Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":43908,"cached_input_tokens":41728,"output_tokens":601,"reasoning_output_tokens":227,"total_tokens":44509},"total_token_usage":{"input_tokens":12056088,"cached_input_tokens":11228160,"output_tokens":50153,"reasoning_output_tokens":27679,"total_tokens":12106241}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:01:42Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":44549,"cached_input_tokens":43776,"output_tokens":172,"reasoning_output_tokens":12,"total_tokens":44721},"total_token_usage":{"input_tokens":12100637,"cached_input_tokens":11271936,"output_tokens":50325,"reasoning_output_tokens":27691,"total_tokens":12150962}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:01:43Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":45694,"cached_input_tokens":43776,"output_tokens":118,"reasoning_output_tokens":78,"total_tokens":45812},"total_token_usage":{"input_tokens":12146331,"cached_input_tokens":11315712,"output_tokens":50443,"reasoning_output_tokens":27769,"total_tokens":12196774}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:01:44Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":55850,"cached_input_tokens":44800,"output_tokens":92,"reasoning_output_tokens":16,"total_tokens":55942},"total_token_usage":{"input_tokens":12202181,"cached_input_tokens":11360512,"output_tokens":50535,"reasoning_output_tokens":27785,"total_tokens":12252716}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:01:45Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":56317,"cached_input_tokens":55040,"output_tokens":63,"reasoning_output_tokens":18,"total_tokens":56380},"total_token_usage":{"input_tokens":12258498,"cached_input_tokens":11415552,"output_tokens":50598,"reasoning_output_tokens":27803,"total_tokens":12309096}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:01:46Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":57557,"cached_input_tokens":56064,"output_tokens":178,"reasoning_output_tokens":33,"total_tokens":57735},"total_token_usage":{"input_tokens":12316055,"cached_input_tokens":11471616,"output_tokens":50776,"reasoning_output_tokens":27836,"total_tokens":12366831}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:01:47Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":59286,"cached_input_tokens":57088,"output_tokens":305,"reasoning_output_tokens":53,"total_tokens":59591},"total_token_usage":{"input_tokens":12375341,"cached_input_tokens":11528704,"output_tokens":51081,"reasoning_output_tokens":27889,"total_tokens":12426422}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:01:48Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":69853,"cached_input_tokens":59136,"output_tokens":305,"reasoning_output_tokens":63,"total_tokens":70158},"total_token_usage":{"input_tokens":12445194,"cached_input_tokens":11587840,"output_tokens":51386,"reasoning_output_tokens":27952,"total_tokens":12496580}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:01:49Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":71305,"cached_input_tokens":69376,"output_tokens":129,"reasoning_output_tokens":43,"total_tokens":71434},"total_token_usage":{"input_tokens":12516499,"cached_input_tokens":11657216,"output_tokens":51515,"reasoning_output_tokens":27995,"total_tokens":12568014}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:01:50Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":71711,"cached_input_tokens":70400,"output_tokens":431,"reasoning_output_tokens":336,"total_tokens":72142},"total_token_usage":{"input_tokens":12588210,"cached_input_tokens":11727616,"output_tokens":51946,"reasoning_output_tokens":28331,"total_tokens":12640156}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:01:51Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":72962,"cached_input_tokens":71424,"output_tokens":187,"reasoning_output_tokens":65,"total_tokens":73149},"total_token_usage":{"input_tokens":12661172,"cached_input_tokens":11799040,"output_tokens":52133,"reasoning_output_tokens":28396,"total_tokens":12713305}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:01:52Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":75357,"cached_input_tokens":72448,"output_tokens":94,"reasoning_output_tokens":12,"total_tokens":75451},"total_token_usage":{"input_tokens":12736529,"cached_input_tokens":11871488,"output_tokens":52227,"reasoning_output_tokens":28408,"total_tokens":12788756}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:01:53Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":75831,"cached_input_tokens":74496,"output_tokens":250,"reasoning_output_tokens":58,"total_tokens":76081},"total_token_usage":{"input_tokens":12812360,"cached_input_tokens":11945984,"output_tokens":52477,"reasoning_output_tokens":28466,"total_tokens":12864837}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:01:54Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":86095,"cached_input_tokens":75520,"output_tokens":375,"reasoning_output_tokens":90,"total_tokens":86470},"total_token_usage":{"input_tokens":12898455,"cached_input_tokens":12021504,"output_tokens":52852,"reasoning_output_tokens":28556,"total_tokens":12951307}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:01:55Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":96684,"cached_input_tokens":85760,"output_tokens":59,"reasoning_output_tokens":18,"total_tokens":96743},"total_token_usage":{"input_tokens":12995139,"cached_input_tokens":12107264,"output_tokens":52911,"reasoning_output_tokens":28574,"total_tokens":13048050}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:01:56Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":96922,"cached_input_tokens":96000,"output_tokens":59,"reasoning_output_tokens":0,"total_tokens":96981},"total_token_usage":{"input_tokens":13092061,"cached_input_tokens":12203264,"output_tokens":52970,"reasoning_output_tokens":28574,"total_tokens":13145031}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:01:57Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":103446,"cached_input_tokens":96000,"output_tokens":1755,"reasoning_output_tokens":870,"total_tokens":105201},"total_token_usage":{"input_tokens":13195507,"cached_input_tokens":12299264,"output_tokens":54725,"reasoning_output_tokens":29444,"total_tokens":13250232}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:01:58Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":106981,"cached_input_tokens":103168,"output_tokens":539,"reasoning_output_tokens":272,"total_tokens":107520},"total_token_usage":{"input_tokens":13302488,"cached_input_tokens":12402432,"output_tokens":55264,"reasoning_output_tokens":29716,"total_tokens":13357752}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:01:59Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":108617,"cached_input_tokens":106240,"output_tokens":440,"reasoning_output_tokens":236,"total_tokens":109057},"total_token_usage":{"input_tokens":13411105,"cached_input_tokens":12508672,"output_tokens":55704,"reasoning_output_tokens":29952,"total_tokens":13466809}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:02:00Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":117001,"cached_input_tokens":108288,"output_tokens":224,"reasoning_output_tokens":82,"total_tokens":117225},"total_token_usage":{"input_tokens":13528106,"cached_input_tokens":12616960,"output_tokens":55928,"reasoning_output_tokens":30034,"total_tokens":13584034}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:02:01Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":117001,"cached_input_tokens":108288,"output_tokens":224,"reasoning_output_tokens":82,"total_tokens":117225},"total_token_usage":{"input_tokens":13528106,"cached_input_tokens":12616960,"output_tokens":55928,"reasoning_output_tokens":30034,"total_tokens":13584034}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:02:02Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":124096,"cached_input_tokens":103168,"output_tokens":196,"reasoning_output_tokens":53,"total_tokens":124292},"total_token_usage":{"input_tokens":13652202,"cached_input_tokens":12720128,"output_tokens":56124,"reasoning_output_tokens":30087,"total_tokens":13708326}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:02:03Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":127681,"cached_input_tokens":123648,"output_tokens":148,"reasoning_output_tokens":9,"total_tokens":127829},"total_token_usage":{"input_tokens":13779883,"cached_input_tokens":12843776,"output_tokens":56272,"reasoning_output_tokens":30096,"total_tokens":13836155}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:02:04Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":130071,"cached_input_tokens":126720,"output_tokens":1081,"reasoning_output_tokens":906,"total_tokens":131152},"total_token_usage":{"input_tokens":13909954,"cached_input_tokens":12970496,"output_tokens":57353,"reasoning_output_tokens":31002,"total_tokens":13967307}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:02:05Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":133032,"cached_input_tokens":129792,"output_tokens":116,"reasoning_output_tokens":17,"total_tokens":133148},"total_token_usage":{"input_tokens":14042986,"cached_input_tokens":13100288,"output_tokens":57469,"reasoning_output_tokens":31019,"total_tokens":14100455}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:02:06Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":135265,"cached_input_tokens":132864,"output_tokens":2792,"reasoning_output_tokens":1676,"total_tokens":138057},"total_token_usage":{"input_tokens":14178251,"cached_input_tokens":13233152,"output_tokens":60261,"reasoning_output_tokens":32695,"total_tokens":14238512}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:02:07Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":139420,"cached_input_tokens":134912,"output_tokens":153,"reasoning_output_tokens":11,"total_tokens":139573},"total_token_usage":{"input_tokens":14317671,"cached_input_tokens":13368064,"output_tokens":60414,"reasoning_output_tokens":32706,"total_tokens":14378085}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:02:08Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":142946,"cached_input_tokens":139008,"output_tokens":78,"reasoning_output_tokens":0,"total_tokens":143024},"total_token_usage":{"input_tokens":14460617,"cached_input_tokens":13507072,"output_tokens":60492,"reasoning_output_tokens":32706,"total_tokens":14521109}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:02:09Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":143285,"cached_input_tokens":142080,"output_tokens":1378,"reasoning_output_tokens":962,"total_tokens":144663},"total_token_usage":{"input_tokens":14603902,"cached_input_tokens":13649152,"output_tokens":61870,"reasoning_output_tokens":33668,"total_tokens":14665772}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:02:10Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":145837,"cached_input_tokens":143104,"output_tokens":2354,"reasoning_output_tokens":1108,"total_tokens":148191},"total_token_usage":{"input_tokens":14749739,"cached_input_tokens":13792256,"output_tokens":64224,"reasoning_output_tokens":34776,"total_tokens":14813963}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:02:11Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":150101,"cached_input_tokens":145152,"output_tokens":191,"reasoning_output_tokens":33,"total_tokens":150292},"total_token_usage":{"input_tokens":14899840,"cached_input_tokens":13937408,"output_tokens":64415,"reasoning_output_tokens":34809,"total_tokens":14964255}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:02:12Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":154769,"cached_input_tokens":149248,"output_tokens":1676,"reasoning_output_tokens":1460,"total_tokens":156445},"total_token_usage":{"input_tokens":15054609,"cached_input_tokens":14086656,"output_tokens":66091,"reasoning_output_tokens":36269,"total_tokens":15120700}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:02:13Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":160473,"cached_input_tokens":154368,"output_tokens":1623,"reasoning_output_tokens":88,"total_tokens":162096},"total_token_usage":{"input_tokens":15215082,"cached_input_tokens":14241024,"output_tokens":67714,"reasoning_output_tokens":36357,"total_tokens":15282796}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:02:14Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":163425,"cached_input_tokens":159488,"output_tokens":218,"reasoning_output_tokens":16,"total_tokens":163643},"total_token_usage":{"input_tokens":15378507,"cached_input_tokens":14400512,"output_tokens":67932,"reasoning_output_tokens":36373,"total_tokens":15446439}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:02:15Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":170331,"cached_input_tokens":162560,"output_tokens":3805,"reasoning_output_tokens":2584,"total_tokens":174136},"total_token_usage":{"input_tokens":15548838,"cached_input_tokens":14563072,"output_tokens":71737,"reasoning_output_tokens":38957,"total_tokens":15620575}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:02:16Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":176208,"cached_input_tokens":169728,"output_tokens":1655,"reasoning_output_tokens":508,"total_tokens":177863},"total_token_usage":{"input_tokens":15725046,"cached_input_tokens":14732800,"output_tokens":73392,"reasoning_output_tokens":39465,"total_tokens":15798438}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:02:17Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":179198,"cached_input_tokens":175872,"output_tokens":3200,"reasoning_output_tokens":1602,"total_tokens":182398},"total_token_usage":{"input_tokens":15904244,"cached_input_tokens":14908672,"output_tokens":76592,"reasoning_output_tokens":41067,"total_tokens":15980836}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:02:18Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":183214,"cached_input_tokens":178944,"output_tokens":1460,"reasoning_output_tokens":13,"total_tokens":184674},"total_token_usage":{"input_tokens":16087458,"cached_input_tokens":15087616,"output_tokens":78052,"reasoning_output_tokens":41080,"total_tokens":16165510}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:02:19Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":184698,"cached_input_tokens":183040,"output_tokens":747,"reasoning_output_tokens":9,"total_tokens":185445},"total_token_usage":{"input_tokens":16272156,"cached_input_tokens":15270656,"output_tokens":78799,"reasoning_output_tokens":41089,"total_tokens":16350955}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:02:20Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":185469,"cached_input_tokens":184064,"output_tokens":2001,"reasoning_output_tokens":198,"total_tokens":187470},"total_token_usage":{"input_tokens":16457625,"cached_input_tokens":15454720,"output_tokens":80800,"reasoning_output_tokens":41287,"total_tokens":16538425}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:02:21Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":187588,"cached_input_tokens":185088,"output_tokens":345,"reasoning_output_tokens":167,"total_tokens":187933},"total_token_usage":{"input_tokens":16645213,"cached_input_tokens":15639808,"output_tokens":81145,"reasoning_output_tokens":41454,"total_tokens":16726358}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:02:22Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":192325,"cached_input_tokens":187136,"output_tokens":198,"reasoning_output_tokens":55,"total_tokens":192523},"total_token_usage":{"input_tokens":16837538,"cached_input_tokens":15826944,"output_tokens":81343,"reasoning_output_tokens":41509,"total_tokens":16918881}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:02:23Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":192547,"cached_input_tokens":191232,"output_tokens":424,"reasoning_output_tokens":201,"total_tokens":192971},"total_token_usage":{"input_tokens":17030085,"cached_input_tokens":16018176,"output_tokens":81767,"reasoning_output_tokens":41710,"total_tokens":17111852}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:02:24Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":199925,"cached_input_tokens":192256,"output_tokens":414,"reasoning_output_tokens":176,"total_tokens":200339},"total_token_usage":{"input_tokens":17230010,"cached_input_tokens":16210432,"output_tokens":82181,"reasoning_output_tokens":41886,"total_tokens":17312191}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:02:25Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":200417,"cached_input_tokens":199424,"output_tokens":268,"reasoning_output_tokens":49,"total_tokens":200685},"total_token_usage":{"input_tokens":17430427,"cached_input_tokens":16409856,"output_tokens":82449,"reasoning_output_tokens":41935,"total_tokens":17512876}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:02:26Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":200709,"cached_input_tokens":199424,"output_tokens":266,"reasoning_output_tokens":53,"total_tokens":200975},"total_token_usage":{"input_tokens":17631136,"cached_input_tokens":16609280,"output_tokens":82715,"reasoning_output_tokens":41988,"total_tokens":17713851}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:02:27Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":202366,"cached_input_tokens":200448,"output_tokens":150,"reasoning_output_tokens":69,"total_tokens":202516},"total_token_usage":{"input_tokens":17833502,"cached_input_tokens":16809728,"output_tokens":82865,"reasoning_output_tokens":42057,"total_tokens":17916367}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:02:28Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":203153,"cached_input_tokens":201472,"output_tokens":630,"reasoning_output_tokens":17,"total_tokens":203783},"total_token_usage":{"input_tokens":18036655,"cached_input_tokens":17011200,"output_tokens":83495,"reasoning_output_tokens":42074,"total_tokens":18120150}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:02:29Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":203807,"cached_input_tokens":202496,"output_tokens":178,"reasoning_output_tokens":7,"total_tokens":203985},"total_token_usage":{"input_tokens":18240462,"cached_input_tokens":17213696,"output_tokens":83673,"reasoning_output_tokens":42081,"total_tokens":18324135}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:02:30Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":204024,"cached_input_tokens":203520,"output_tokens":280,"reasoning_output_tokens":55,"total_tokens":204304},"total_token_usage":{"input_tokens":18444486,"cached_input_tokens":17417216,"output_tokens":83953,"reasoning_output_tokens":42136,"total_tokens":18528439}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:02:31Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":205479,"cached_input_tokens":203520,"output_tokens":227,"reasoning_output_tokens":37,"total_tokens":205706},"total_token_usage":{"input_tokens":18649965,"cached_input_tokens":17620736,"output_tokens":84180,"reasoning_output_tokens":42173,"total_tokens":18734145}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:02:32Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":206922,"cached_input_tokens":204544,"output_tokens":2442,"reasoning_output_tokens":880,"total_tokens":209364},"total_token_usage":{"input_tokens":18856887,"cached_input_tokens":17825280,"output_tokens":86622,"reasoning_output_tokens":43053,"total_tokens":18943509}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:02:33Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":201852,"cached_input_tokens":129792,"output_tokens":1297,"reasoning_output_tokens":492,"total_tokens":203149},"total_token_usage":{"input_tokens":19058739,"cached_input_tokens":17955072,"output_tokens":87919,"reasoning_output_tokens":43545,"total_tokens":19146658}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:02:34Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":203173,"cached_input_tokens":201472,"output_tokens":715,"reasoning_output_tokens":9,"total_tokens":203888},"total_token_usage":{"input_tokens":19261912,"cached_input_tokens":18156544,"output_tokens":88634,"reasoning_output_tokens":43554,"total_tokens":19350546}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:02:35Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":203912,"cached_input_tokens":202496,"output_tokens":440,"reasoning_output_tokens":16,"total_tokens":204352},"total_token_usage":{"input_tokens":19465824,"cached_input_tokens":18359040,"output_tokens":89074,"reasoning_output_tokens":43570,"total_tokens":19554898}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:02:36Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":204376,"cached_input_tokens":203520,"output_tokens":515,"reasoning_output_tokens":34,"total_tokens":204891},"total_token_usage":{"input_tokens":19670200,"cached_input_tokens":18562560,"output_tokens":89589,"reasoning_output_tokens":43604,"total_tokens":19759789}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:02:37Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":204915,"cached_input_tokens":203520,"output_tokens":339,"reasoning_output_tokens":38,"total_tokens":205254},"total_token_usage":{"input_tokens":19875115,"cached_input_tokens":18766080,"output_tokens":89928,"reasoning_output_tokens":43642,"total_tokens":19965043}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:02:38Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":205278,"cached_input_tokens":204544,"output_tokens":378,"reasoning_output_tokens":37,"total_tokens":205656},"total_token_usage":{"input_tokens":20080393,"cached_input_tokens":18970624,"output_tokens":90306,"reasoning_output_tokens":43679,"total_tokens":20170699}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:02:39Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":205680,"cached_input_tokens":204544,"output_tokens":435,"reasoning_output_tokens":18,"total_tokens":206115},"total_token_usage":{"input_tokens":20286073,"cached_input_tokens":19175168,"output_tokens":90741,"reasoning_output_tokens":43697,"total_tokens":20376814}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:02:40Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":206139,"cached_input_tokens":204544,"output_tokens":423,"reasoning_output_tokens":25,"total_tokens":206562},"total_token_usage":{"input_tokens":20492212,"cached_input_tokens":19379712,"output_tokens":91164,"reasoning_output_tokens":43722,"total_tokens":20583376}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:02:41Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":206586,"cached_input_tokens":205568,"output_tokens":1380,"reasoning_output_tokens":233,"total_tokens":207966},"total_token_usage":{"input_tokens":20698798,"cached_input_tokens":19585280,"output_tokens":92544,"reasoning_output_tokens":43955,"total_tokens":20791342}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:02:42Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":207990,"cached_input_tokens":205568,"output_tokens":205,"reasoning_output_tokens":53,"total_tokens":208195},"total_token_usage":{"input_tokens":20906788,"cached_input_tokens":19790848,"output_tokens":92749,"reasoning_output_tokens":44008,"total_tokens":20999537}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:02:43Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":208671,"cached_input_tokens":207616,"output_tokens":720,"reasoning_output_tokens":312,"total_tokens":209391},"total_token_usage":{"input_tokens":21115459,"cached_input_tokens":19998464,"output_tokens":93469,"reasoning_output_tokens":44320,"total_tokens":21208928}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:02:44Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":209415,"cached_input_tokens":207616,"output_tokens":251,"reasoning_output_tokens":9,"total_tokens":209666},"total_token_usage":{"input_tokens":21324874,"cached_input_tokens":20206080,"output_tokens":93720,"reasoning_output_tokens":44329,"total_tokens":21418594}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:02:45Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":209690,"cached_input_tokens":208640,"output_tokens":217,"reasoning_output_tokens":12,"total_tokens":209907},"total_token_usage":{"input_tokens":21534564,"cached_input_tokens":20414720,"output_tokens":93937,"reasoning_output_tokens":44341,"total_tokens":21628501}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:02:46Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":210456,"cached_input_tokens":208640,"output_tokens":151,"reasoning_output_tokens":23,"total_tokens":210607},"total_token_usage":{"input_tokens":21745020,"cached_input_tokens":20623360,"output_tokens":94088,"reasoning_output_tokens":44364,"total_tokens":21839108}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:02:47Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":218759,"cached_input_tokens":209664,"output_tokens":662,"reasoning_output_tokens":151,"total_tokens":219421},"total_token_usage":{"input_tokens":21963779,"cached_input_tokens":20833024,"output_tokens":94750,"reasoning_output_tokens":44515,"total_tokens":22058529}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:02:48Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":219445,"cached_input_tokens":217856,"output_tokens":188,"reasoning_output_tokens":14,"total_tokens":219633},"total_token_usage":{"input_tokens":22183224,"cached_input_tokens":21050880,"output_tokens":94938,"reasoning_output_tokens":44529,"total_tokens":22278162}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:02:49Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":219690,"cached_input_tokens":218880,"output_tokens":230,"reasoning_output_tokens":19,"total_tokens":219920},"total_token_usage":{"input_tokens":22402914,"cached_input_tokens":21269760,"output_tokens":95168,"reasoning_output_tokens":44548,"total_tokens":22498082}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:02:50Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":221775,"cached_input_tokens":218880,"output_tokens":1236,"reasoning_output_tokens":516,"total_tokens":223011},"total_token_usage":{"input_tokens":22624689,"cached_input_tokens":21488640,"output_tokens":96404,"reasoning_output_tokens":45064,"total_tokens":22721093}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:02:51Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":224098,"cached_input_tokens":220928,"output_tokens":572,"reasoning_output_tokens":103,"total_tokens":224670},"total_token_usage":{"input_tokens":22848787,"cached_input_tokens":21709568,"output_tokens":96976,"reasoning_output_tokens":45167,"total_tokens":22945763}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:02:52Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":224694,"cached_input_tokens":222976,"output_tokens":487,"reasoning_output_tokens":12,"total_tokens":225181},"total_token_usage":{"input_tokens":23073481,"cached_input_tokens":21932544,"output_tokens":97463,"reasoning_output_tokens":45179,"total_tokens":23170944}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:02:53Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":225205,"cached_input_tokens":224000,"output_tokens":266,"reasoning_output_tokens":13,"total_tokens":225471},"total_token_usage":{"input_tokens":23298686,"cached_input_tokens":22156544,"output_tokens":97729,"reasoning_output_tokens":45192,"total_tokens":23396415}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:02:54Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":225495,"cached_input_tokens":225024,"output_tokens":254,"reasoning_output_tokens":15,"total_tokens":225749},"total_token_usage":{"input_tokens":23524181,"cached_input_tokens":22381568,"output_tokens":97983,"reasoning_output_tokens":45207,"total_tokens":23622164}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:02:55Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":225773,"cached_input_tokens":225024,"output_tokens":809,"reasoning_output_tokens":111,"total_tokens":226582},"total_token_usage":{"input_tokens":23749954,"cached_input_tokens":22606592,"output_tokens":98792,"reasoning_output_tokens":45318,"total_tokens":23848746}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:02:56Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":226606,"cached_input_tokens":225024,"output_tokens":333,"reasoning_output_tokens":11,"total_tokens":226939},"total_token_usage":{"input_tokens":23976560,"cached_input_tokens":22831616,"output_tokens":99125,"reasoning_output_tokens":45329,"total_tokens":24075685}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:02:57Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":226963,"cached_input_tokens":226048,"output_tokens":257,"reasoning_output_tokens":56,"total_tokens":227220},"total_token_usage":{"input_tokens":24203523,"cached_input_tokens":23057664,"output_tokens":99382,"reasoning_output_tokens":45385,"total_tokens":24302905}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:02:58Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":227860,"cached_input_tokens":226048,"output_tokens":260,"reasoning_output_tokens":63,"total_tokens":228120},"total_token_usage":{"input_tokens":24431383,"cached_input_tokens":23283712,"output_tokens":99642,"reasoning_output_tokens":45448,"total_tokens":24531025}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:02:59Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":228143,"cached_input_tokens":227072,"output_tokens":942,"reasoning_output_tokens":449,"total_tokens":229085},"total_token_usage":{"input_tokens":24659526,"cached_input_tokens":23510784,"output_tokens":100584,"reasoning_output_tokens":45897,"total_tokens":24760110}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:03:00Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":230188,"cached_input_tokens":227072,"output_tokens":2094,"reasoning_output_tokens":1554,"total_tokens":232282},"total_token_usage":{"input_tokens":24889714,"cached_input_tokens":23737856,"output_tokens":102678,"reasoning_output_tokens":47451,"total_tokens":24992392}}}} +{"type":"event_msg","timestamp":"2030-01-02T12:00:00Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":56227,"cached_input_tokens":10496,"output_tokens":282,"reasoning_output_tokens":125,"total_tokens":56509},"total_token_usage":{"input_tokens":24945941,"cached_input_tokens":23748352,"output_tokens":102960,"reasoning_output_tokens":47576,"total_tokens":25048901}}}} +{"type":"event_msg","timestamp":"2030-01-02T12:00:01Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":66725,"cached_input_tokens":56064,"output_tokens":158,"reasoning_output_tokens":17,"total_tokens":66883},"total_token_usage":{"input_tokens":25012666,"cached_input_tokens":23804416,"output_tokens":103118,"reasoning_output_tokens":47593,"total_tokens":25115784}}}} +{"type":"event_msg","timestamp":"2030-01-02T12:00:02Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":75242,"cached_input_tokens":66304,"output_tokens":149,"reasoning_output_tokens":38,"total_tokens":75391},"total_token_usage":{"input_tokens":25087908,"cached_input_tokens":23870720,"output_tokens":103267,"reasoning_output_tokens":47631,"total_tokens":25191175}}}} +{"type":"event_msg","timestamp":"2030-01-02T12:00:03Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":85073,"cached_input_tokens":75008,"output_tokens":109,"reasoning_output_tokens":20,"total_tokens":85182},"total_token_usage":{"input_tokens":25172981,"cached_input_tokens":23945728,"output_tokens":103376,"reasoning_output_tokens":47651,"total_tokens":25276357}}}} +{"type":"event_msg","timestamp":"2030-01-02T12:00:04Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":89732,"cached_input_tokens":84736,"output_tokens":94,"reasoning_output_tokens":0,"total_tokens":89826},"total_token_usage":{"input_tokens":25262713,"cached_input_tokens":24030464,"output_tokens":103470,"reasoning_output_tokens":47651,"total_tokens":25366183}}}} +{"type":"event_msg","timestamp":"2030-01-02T12:00:05Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":94539,"cached_input_tokens":89344,"output_tokens":142,"reasoning_output_tokens":0,"total_tokens":94681},"total_token_usage":{"input_tokens":25357252,"cached_input_tokens":24119808,"output_tokens":103612,"reasoning_output_tokens":47651,"total_tokens":25460864}}}} +{"type":"event_msg","timestamp":"2030-01-02T12:00:06Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":104837,"cached_input_tokens":93952,"output_tokens":179,"reasoning_output_tokens":17,"total_tokens":105016},"total_token_usage":{"input_tokens":25462089,"cached_input_tokens":24213760,"output_tokens":103791,"reasoning_output_tokens":47668,"total_tokens":25565880}}}} +{"type":"event_msg","timestamp":"2030-01-02T12:00:07Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":110741,"cached_input_tokens":104704,"output_tokens":173,"reasoning_output_tokens":14,"total_tokens":110914},"total_token_usage":{"input_tokens":25572830,"cached_input_tokens":24318464,"output_tokens":103964,"reasoning_output_tokens":47682,"total_tokens":25676794}}}} +{"type":"event_msg","timestamp":"2030-01-02T12:00:08Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":120256,"cached_input_tokens":110336,"output_tokens":1245,"reasoning_output_tokens":592,"total_tokens":121501},"total_token_usage":{"input_tokens":25693086,"cached_input_tokens":24428800,"output_tokens":105209,"reasoning_output_tokens":48274,"total_tokens":25798295}}}} +{"type":"event_msg","timestamp":"2030-01-02T12:00:09Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":125844,"cached_input_tokens":120064,"output_tokens":161,"reasoning_output_tokens":14,"total_tokens":126005},"total_token_usage":{"input_tokens":25818930,"cached_input_tokens":24548864,"output_tokens":105370,"reasoning_output_tokens":48288,"total_tokens":25924300}}}} +{"type":"event_msg","timestamp":"2030-01-02T12:00:10Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":128832,"cached_input_tokens":125696,"output_tokens":187,"reasoning_output_tokens":21,"total_tokens":129019},"total_token_usage":{"input_tokens":25947762,"cached_input_tokens":24674560,"output_tokens":105557,"reasoning_output_tokens":48309,"total_tokens":26053319}}}} +{"type":"event_msg","timestamp":"2030-01-02T12:00:11Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":132962,"cached_input_tokens":128256,"output_tokens":258,"reasoning_output_tokens":119,"total_tokens":133220},"total_token_usage":{"input_tokens":26080724,"cached_input_tokens":24802816,"output_tokens":105815,"reasoning_output_tokens":48428,"total_tokens":26186539}}}} +{"type":"event_msg","timestamp":"2030-01-02T12:00:12Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":137801,"cached_input_tokens":132352,"output_tokens":1620,"reasoning_output_tokens":1384,"total_tokens":139421},"total_token_usage":{"input_tokens":26218525,"cached_input_tokens":24935168,"output_tokens":107435,"reasoning_output_tokens":49812,"total_tokens":26325960}}}} +{"type":"event_msg","timestamp":"2030-01-02T12:00:13Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":150954,"cached_input_tokens":137472,"output_tokens":153,"reasoning_output_tokens":60,"total_tokens":151107},"total_token_usage":{"input_tokens":26369479,"cached_input_tokens":25072640,"output_tokens":107588,"reasoning_output_tokens":49872,"total_tokens":26477067}}}} +{"type":"event_msg","timestamp":"2030-01-02T12:00:14Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":161023,"cached_input_tokens":150784,"output_tokens":689,"reasoning_output_tokens":145,"total_tokens":161712},"total_token_usage":{"input_tokens":26530502,"cached_input_tokens":25223424,"output_tokens":108277,"reasoning_output_tokens":50017,"total_tokens":26638779}}}} +{"type":"event_msg","timestamp":"2030-01-02T12:00:15Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":161772,"cached_input_tokens":160512,"output_tokens":1360,"reasoning_output_tokens":438,"total_tokens":163132},"total_token_usage":{"input_tokens":26692274,"cached_input_tokens":25383936,"output_tokens":109637,"reasoning_output_tokens":50455,"total_tokens":26801911}}}} diff --git a/Tests/CodexBarTests/Fixtures/CostUsage/Issue2037/live-fork-4d90-52bf/codex-home/archived_sessions/parent.jsonl b/Tests/CodexBarTests/Fixtures/CostUsage/Issue2037/live-fork-4d90-52bf/codex-home/archived_sessions/parent.jsonl new file mode 100644 index 0000000000..14f2ac3e0f --- /dev/null +++ b/Tests/CodexBarTests/Fixtures/CostUsage/Issue2037/live-fork-4d90-52bf/codex-home/archived_sessions/parent.jsonl @@ -0,0 +1,182 @@ +{"type":"session_meta","timestamp":"2030-01-01T12:00:00Z","payload":{"id":"parent-session","timestamp":"2030-01-01T12:00:00Z","forked_from_id":null}} +{"type":"turn_context","timestamp":"2030-01-01T12:00:00Z","payload":{"model":"fixture-model","multi_agent_version":"v2","multi_agent_mode":"explicitRequestOnly"}} +{"type":"event_msg","timestamp":"2030-01-01T12:00:02Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":21090,"cached_input_tokens":9984,"output_tokens":236,"reasoning_output_tokens":74,"total_tokens":21326},"total_token_usage":{"input_tokens":21090,"cached_input_tokens":9984,"output_tokens":236,"reasoning_output_tokens":74,"total_tokens":21326}}}} +{"type":"event_msg","timestamp":"2030-01-01T12:00:03Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":22299,"cached_input_tokens":20224,"output_tokens":103,"reasoning_output_tokens":61,"total_tokens":22402},"total_token_usage":{"input_tokens":43389,"cached_input_tokens":30208,"output_tokens":339,"reasoning_output_tokens":135,"total_tokens":43728}}}} +{"type":"event_msg","timestamp":"2030-01-01T12:00:04Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":32596,"cached_input_tokens":21248,"output_tokens":66,"reasoning_output_tokens":16,"total_tokens":32662},"total_token_usage":{"input_tokens":75985,"cached_input_tokens":51456,"output_tokens":405,"reasoning_output_tokens":151,"total_tokens":76390}}}} +{"type":"event_msg","timestamp":"2030-01-01T12:00:05Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":32747,"cached_input_tokens":31488,"output_tokens":74,"reasoning_output_tokens":7,"total_tokens":32821},"total_token_usage":{"input_tokens":108732,"cached_input_tokens":82944,"output_tokens":479,"reasoning_output_tokens":158,"total_tokens":109211}}}} +{"type":"event_msg","timestamp":"2030-01-01T12:00:06Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":33461,"cached_input_tokens":32512,"output_tokens":178,"reasoning_output_tokens":32,"total_tokens":33639},"total_token_usage":{"input_tokens":142193,"cached_input_tokens":115456,"output_tokens":657,"reasoning_output_tokens":190,"total_tokens":142850}}}} +{"type":"event_msg","timestamp":"2030-01-01T12:00:07Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":37948,"cached_input_tokens":32512,"output_tokens":267,"reasoning_output_tokens":139,"total_tokens":38215},"total_token_usage":{"input_tokens":180141,"cached_input_tokens":147968,"output_tokens":924,"reasoning_output_tokens":329,"total_tokens":181065}}}} +{"type":"event_msg","timestamp":"2030-01-01T12:00:08Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":38264,"cached_input_tokens":37632,"output_tokens":115,"reasoning_output_tokens":17,"total_tokens":38379},"total_token_usage":{"input_tokens":218405,"cached_input_tokens":185600,"output_tokens":1039,"reasoning_output_tokens":346,"total_tokens":219444}}}} +{"type":"event_msg","timestamp":"2030-01-01T12:00:09Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":38421,"cached_input_tokens":37632,"output_tokens":114,"reasoning_output_tokens":52,"total_tokens":38535},"total_token_usage":{"input_tokens":256826,"cached_input_tokens":223232,"output_tokens":1153,"reasoning_output_tokens":398,"total_tokens":257979}}}} +{"type":"event_msg","timestamp":"2030-01-01T12:00:10Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":38634,"cached_input_tokens":37632,"output_tokens":152,"reasoning_output_tokens":46,"total_tokens":38786},"total_token_usage":{"input_tokens":295460,"cached_input_tokens":260864,"output_tokens":1305,"reasoning_output_tokens":444,"total_tokens":296765}}}} +{"type":"event_msg","timestamp":"2030-01-01T12:00:11Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":39259,"cached_input_tokens":37632,"output_tokens":116,"reasoning_output_tokens":23,"total_tokens":39375},"total_token_usage":{"input_tokens":334719,"cached_input_tokens":298496,"output_tokens":1421,"reasoning_output_tokens":467,"total_tokens":336140}}}} +{"type":"event_msg","timestamp":"2030-01-01T12:00:12Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":47563,"cached_input_tokens":38656,"output_tokens":158,"reasoning_output_tokens":16,"total_tokens":47721},"total_token_usage":{"input_tokens":382282,"cached_input_tokens":337152,"output_tokens":1579,"reasoning_output_tokens":483,"total_tokens":383861}}}} +{"type":"event_msg","timestamp":"2030-01-01T12:00:13Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":54051,"cached_input_tokens":46848,"output_tokens":189,"reasoning_output_tokens":46,"total_tokens":54240},"total_token_usage":{"input_tokens":436333,"cached_input_tokens":384000,"output_tokens":1768,"reasoning_output_tokens":529,"total_tokens":438101}}}} +{"type":"event_msg","timestamp":"2030-01-01T12:00:14Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":60672,"cached_input_tokens":52992,"output_tokens":156,"reasoning_output_tokens":24,"total_tokens":60828},"total_token_usage":{"input_tokens":497005,"cached_input_tokens":436992,"output_tokens":1924,"reasoning_output_tokens":553,"total_tokens":498929}}}} +{"type":"event_msg","timestamp":"2030-01-01T12:00:15Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":60942,"cached_input_tokens":60160,"output_tokens":126,"reasoning_output_tokens":0,"total_tokens":61068},"total_token_usage":{"input_tokens":557947,"cached_input_tokens":497152,"output_tokens":2050,"reasoning_output_tokens":553,"total_tokens":559997}}}} +{"type":"event_msg","timestamp":"2030-01-01T12:00:16Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":65844,"cached_input_tokens":60160,"output_tokens":174,"reasoning_output_tokens":32,"total_tokens":66018},"total_token_usage":{"input_tokens":623791,"cached_input_tokens":557312,"output_tokens":2224,"reasoning_output_tokens":585,"total_tokens":626015}}}} +{"type":"event_msg","timestamp":"2030-01-01T12:00:17Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":67233,"cached_input_tokens":65280,"output_tokens":221,"reasoning_output_tokens":24,"total_tokens":67454},"total_token_usage":{"input_tokens":691024,"cached_input_tokens":622592,"output_tokens":2445,"reasoning_output_tokens":609,"total_tokens":693469}}}} +{"type":"event_msg","timestamp":"2030-01-01T12:00:18Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":74643,"cached_input_tokens":66304,"output_tokens":1186,"reasoning_output_tokens":822,"total_tokens":75829},"total_token_usage":{"input_tokens":765667,"cached_input_tokens":688896,"output_tokens":3631,"reasoning_output_tokens":1431,"total_tokens":769298}}}} +{"type":"event_msg","timestamp":"2030-01-01T12:00:19Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":79611,"cached_input_tokens":74496,"output_tokens":347,"reasoning_output_tokens":143,"total_tokens":79958},"total_token_usage":{"input_tokens":845278,"cached_input_tokens":763392,"output_tokens":3978,"reasoning_output_tokens":1574,"total_tokens":849256}}}} +{"type":"event_msg","timestamp":"2030-01-01T12:00:20Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":80506,"cached_input_tokens":78592,"output_tokens":539,"reasoning_output_tokens":184,"total_tokens":81045},"total_token_usage":{"input_tokens":925784,"cached_input_tokens":841984,"output_tokens":4517,"reasoning_output_tokens":1758,"total_tokens":930301}}}} +{"type":"event_msg","timestamp":"2030-01-01T12:00:21Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":88475,"cached_input_tokens":79616,"output_tokens":1556,"reasoning_output_tokens":186,"total_tokens":90031},"total_token_usage":{"input_tokens":1014259,"cached_input_tokens":921600,"output_tokens":6073,"reasoning_output_tokens":1944,"total_tokens":1020332}}}} +{"type":"event_msg","timestamp":"2030-01-01T12:00:22Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":91050,"cached_input_tokens":87808,"output_tokens":236,"reasoning_output_tokens":103,"total_tokens":91286},"total_token_usage":{"input_tokens":1105309,"cached_input_tokens":1009408,"output_tokens":6309,"reasoning_output_tokens":2047,"total_tokens":1111618}}}} +{"type":"event_msg","timestamp":"2030-01-01T12:00:23Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":91482,"cached_input_tokens":90880,"output_tokens":143,"reasoning_output_tokens":7,"total_tokens":91625},"total_token_usage":{"input_tokens":1196791,"cached_input_tokens":1100288,"output_tokens":6452,"reasoning_output_tokens":2054,"total_tokens":1203243}}}} +{"type":"event_msg","timestamp":"2030-01-01T12:00:24Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":91708,"cached_input_tokens":90880,"output_tokens":1001,"reasoning_output_tokens":9,"total_tokens":92709},"total_token_usage":{"input_tokens":1288499,"cached_input_tokens":1191168,"output_tokens":7453,"reasoning_output_tokens":2063,"total_tokens":1295952}}}} +{"type":"event_msg","timestamp":"2030-01-01T12:00:25Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":93284,"cached_input_tokens":90880,"output_tokens":272,"reasoning_output_tokens":180,"total_tokens":93556},"total_token_usage":{"input_tokens":1381783,"cached_input_tokens":1282048,"output_tokens":7725,"reasoning_output_tokens":2243,"total_tokens":1389508}}}} +{"type":"event_msg","timestamp":"2030-01-01T12:00:26Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":95924,"cached_input_tokens":92928,"output_tokens":104,"reasoning_output_tokens":10,"total_tokens":96028},"total_token_usage":{"input_tokens":1477707,"cached_input_tokens":1374976,"output_tokens":7829,"reasoning_output_tokens":2253,"total_tokens":1485536}}}} +{"type":"event_msg","timestamp":"2030-01-01T12:00:27Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":102511,"cached_input_tokens":94976,"output_tokens":937,"reasoning_output_tokens":504,"total_tokens":103448},"total_token_usage":{"input_tokens":1580218,"cached_input_tokens":1469952,"output_tokens":8766,"reasoning_output_tokens":2757,"total_tokens":1588984}}}} +{"type":"event_msg","timestamp":"2030-01-01T12:00:28Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":112826,"cached_input_tokens":102144,"output_tokens":234,"reasoning_output_tokens":101,"total_tokens":113060},"total_token_usage":{"input_tokens":1693044,"cached_input_tokens":1572096,"output_tokens":9000,"reasoning_output_tokens":2858,"total_tokens":1702044}}}} +{"type":"event_msg","timestamp":"2030-01-01T12:00:29Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":116136,"cached_input_tokens":112384,"output_tokens":415,"reasoning_output_tokens":232,"total_tokens":116551},"total_token_usage":{"input_tokens":1809180,"cached_input_tokens":1684480,"output_tokens":9415,"reasoning_output_tokens":3090,"total_tokens":1818595}}}} +{"type":"event_msg","timestamp":"2030-01-01T12:00:30Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":116579,"cached_input_tokens":115456,"output_tokens":40,"reasoning_output_tokens":7,"total_tokens":116619},"total_token_usage":{"input_tokens":1925759,"cached_input_tokens":1799936,"output_tokens":9455,"reasoning_output_tokens":3097,"total_tokens":1935214}}}} +{"type":"event_msg","timestamp":"2030-01-01T12:00:31Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":116647,"cached_input_tokens":115456,"output_tokens":47,"reasoning_output_tokens":14,"total_tokens":116694},"total_token_usage":{"input_tokens":2042406,"cached_input_tokens":1915392,"output_tokens":9502,"reasoning_output_tokens":3111,"total_tokens":2051908}}}} +{"type":"event_msg","timestamp":"2030-01-01T12:00:32Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":116722,"cached_input_tokens":116480,"output_tokens":39,"reasoning_output_tokens":6,"total_tokens":116761},"total_token_usage":{"input_tokens":2159128,"cached_input_tokens":2031872,"output_tokens":9541,"reasoning_output_tokens":3117,"total_tokens":2168669}}}} +{"type":"event_msg","timestamp":"2030-01-01T12:00:33Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":119471,"cached_input_tokens":116480,"output_tokens":142,"reasoning_output_tokens":94,"total_tokens":119613},"total_token_usage":{"input_tokens":2278599,"cached_input_tokens":2148352,"output_tokens":9683,"reasoning_output_tokens":3211,"total_tokens":2288282}}}} +{"type":"event_msg","timestamp":"2030-01-01T12:00:34Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":119641,"cached_input_tokens":118528,"output_tokens":41,"reasoning_output_tokens":8,"total_tokens":119682},"total_token_usage":{"input_tokens":2398240,"cached_input_tokens":2266880,"output_tokens":9724,"reasoning_output_tokens":3219,"total_tokens":2407964}}}} +{"type":"event_msg","timestamp":"2030-01-01T12:00:35Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":119710,"cached_input_tokens":118528,"output_tokens":39,"reasoning_output_tokens":6,"total_tokens":119749},"total_token_usage":{"input_tokens":2517950,"cached_input_tokens":2385408,"output_tokens":9763,"reasoning_output_tokens":3225,"total_tokens":2527713}}}} +{"type":"event_msg","timestamp":"2030-01-01T12:00:36Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":119826,"cached_input_tokens":119552,"output_tokens":58,"reasoning_output_tokens":10,"total_tokens":119884},"total_token_usage":{"input_tokens":2637776,"cached_input_tokens":2504960,"output_tokens":9821,"reasoning_output_tokens":3235,"total_tokens":2647597}}}} +{"type":"event_msg","timestamp":"2030-01-01T12:00:37Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":119912,"cached_input_tokens":119552,"output_tokens":39,"reasoning_output_tokens":6,"total_tokens":119951},"total_token_usage":{"input_tokens":2757688,"cached_input_tokens":2624512,"output_tokens":9860,"reasoning_output_tokens":3241,"total_tokens":2767548}}}} +{"type":"event_msg","timestamp":"2030-01-01T12:00:38Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":119979,"cached_input_tokens":119552,"output_tokens":39,"reasoning_output_tokens":6,"total_tokens":120018},"total_token_usage":{"input_tokens":2877667,"cached_input_tokens":2744064,"output_tokens":9899,"reasoning_output_tokens":3247,"total_tokens":2887566}}}} +{"type":"event_msg","timestamp":"2030-01-01T12:00:39Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":123376,"cached_input_tokens":119552,"output_tokens":57,"reasoning_output_tokens":9,"total_tokens":123433},"total_token_usage":{"input_tokens":3001043,"cached_input_tokens":2863616,"output_tokens":9956,"reasoning_output_tokens":3256,"total_tokens":3010999}}}} +{"type":"event_msg","timestamp":"2030-01-01T12:00:40Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":123461,"cached_input_tokens":122624,"output_tokens":31,"reasoning_output_tokens":0,"total_tokens":123492},"total_token_usage":{"input_tokens":3124504,"cached_input_tokens":2986240,"output_tokens":9987,"reasoning_output_tokens":3256,"total_tokens":3134491}}}} +{"type":"event_msg","timestamp":"2030-01-01T12:00:41Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":123520,"cached_input_tokens":122624,"output_tokens":31,"reasoning_output_tokens":0,"total_tokens":123551},"total_token_usage":{"input_tokens":3248024,"cached_input_tokens":3108864,"output_tokens":10018,"reasoning_output_tokens":3256,"total_tokens":3258042}}}} +{"type":"event_msg","timestamp":"2030-01-01T12:00:42Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":126725,"cached_input_tokens":122624,"output_tokens":57,"reasoning_output_tokens":9,"total_tokens":126782},"total_token_usage":{"input_tokens":3374749,"cached_input_tokens":3231488,"output_tokens":10075,"reasoning_output_tokens":3265,"total_tokens":3384824}}}} +{"type":"event_msg","timestamp":"2030-01-01T12:00:43Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":126810,"cached_input_tokens":125696,"output_tokens":31,"reasoning_output_tokens":0,"total_tokens":126841},"total_token_usage":{"input_tokens":3501559,"cached_input_tokens":3357184,"output_tokens":10106,"reasoning_output_tokens":3265,"total_tokens":3511665}}}} +{"type":"event_msg","timestamp":"2030-01-01T12:00:44Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":126869,"cached_input_tokens":125696,"output_tokens":31,"reasoning_output_tokens":0,"total_tokens":126900},"total_token_usage":{"input_tokens":3628428,"cached_input_tokens":3482880,"output_tokens":10137,"reasoning_output_tokens":3265,"total_tokens":3638565}}}} +{"type":"event_msg","timestamp":"2030-01-01T12:00:45Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":127424,"cached_input_tokens":126720,"output_tokens":54,"reasoning_output_tokens":6,"total_tokens":127478},"total_token_usage":{"input_tokens":3755852,"cached_input_tokens":3609600,"output_tokens":10191,"reasoning_output_tokens":3271,"total_tokens":3766043}}}} +{"type":"event_msg","timestamp":"2030-01-01T12:00:46Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":127506,"cached_input_tokens":126720,"output_tokens":31,"reasoning_output_tokens":0,"total_tokens":127537},"total_token_usage":{"input_tokens":3883358,"cached_input_tokens":3736320,"output_tokens":10222,"reasoning_output_tokens":3271,"total_tokens":3893580}}}} +{"type":"event_msg","timestamp":"2030-01-01T12:00:47Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":127565,"cached_input_tokens":126720,"output_tokens":31,"reasoning_output_tokens":0,"total_tokens":127596},"total_token_usage":{"input_tokens":4010923,"cached_input_tokens":3863040,"output_tokens":10253,"reasoning_output_tokens":3271,"total_tokens":4021176}}}} +{"type":"event_msg","timestamp":"2030-01-01T12:00:48Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":134061,"cached_input_tokens":126720,"output_tokens":56,"reasoning_output_tokens":8,"total_tokens":134117},"total_token_usage":{"input_tokens":4144984,"cached_input_tokens":3989760,"output_tokens":10309,"reasoning_output_tokens":3279,"total_tokens":4155293}}}} +{"type":"event_msg","timestamp":"2030-01-01T12:00:49Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":134476,"cached_input_tokens":133888,"output_tokens":215,"reasoning_output_tokens":22,"total_tokens":134691},"total_token_usage":{"input_tokens":4279460,"cached_input_tokens":4123648,"output_tokens":10524,"reasoning_output_tokens":3301,"total_tokens":4289984}}}} +{"type":"event_msg","timestamp":"2030-01-01T12:00:50Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":137655,"cached_input_tokens":133888,"output_tokens":169,"reasoning_output_tokens":28,"total_tokens":137824},"total_token_usage":{"input_tokens":4417115,"cached_input_tokens":4257536,"output_tokens":10693,"reasoning_output_tokens":3329,"total_tokens":4427808}}}} +{"type":"event_msg","timestamp":"2030-01-01T12:00:51Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":139085,"cached_input_tokens":136960,"output_tokens":1039,"reasoning_output_tokens":590,"total_tokens":140124},"total_token_usage":{"input_tokens":4556200,"cached_input_tokens":4394496,"output_tokens":11732,"reasoning_output_tokens":3919,"total_tokens":4567932}}}} +{"type":"event_msg","timestamp":"2030-01-01T12:00:52Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":149841,"cached_input_tokens":137984,"output_tokens":2471,"reasoning_output_tokens":1886,"total_tokens":152312},"total_token_usage":{"input_tokens":4706041,"cached_input_tokens":4532480,"output_tokens":14203,"reasoning_output_tokens":5805,"total_tokens":4720244}}}} +{"type":"event_msg","timestamp":"2030-01-01T12:00:53Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":152806,"cached_input_tokens":9984,"output_tokens":367,"reasoning_output_tokens":211,"total_tokens":153173},"total_token_usage":{"input_tokens":4858847,"cached_input_tokens":4542464,"output_tokens":14570,"reasoning_output_tokens":6016,"total_tokens":4873417}}}} +{"type":"event_msg","timestamp":"2030-01-01T12:00:54Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":153190,"cached_input_tokens":152320,"output_tokens":881,"reasoning_output_tokens":514,"total_tokens":154071},"total_token_usage":{"input_tokens":5012037,"cached_input_tokens":4694784,"output_tokens":15451,"reasoning_output_tokens":6530,"total_tokens":5027488}}}} +{"type":"event_msg","timestamp":"2030-01-01T12:00:55Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":154599,"cached_input_tokens":9984,"output_tokens":172,"reasoning_output_tokens":48,"total_tokens":154771},"total_token_usage":{"input_tokens":5166636,"cached_input_tokens":4704768,"output_tokens":15623,"reasoning_output_tokens":6578,"total_tokens":5182259}}}} +{"type":"event_msg","timestamp":"2030-01-01T12:00:56Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":154836,"cached_input_tokens":154368,"output_tokens":113,"reasoning_output_tokens":15,"total_tokens":154949},"total_token_usage":{"input_tokens":5321472,"cached_input_tokens":4859136,"output_tokens":15736,"reasoning_output_tokens":6593,"total_tokens":5337208}}}} +{"type":"event_msg","timestamp":"2030-01-01T12:00:57Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":159174,"cached_input_tokens":154368,"output_tokens":2870,"reasoning_output_tokens":2102,"total_tokens":162044},"total_token_usage":{"input_tokens":5480646,"cached_input_tokens":5013504,"output_tokens":18606,"reasoning_output_tokens":8695,"total_tokens":5499252}}}} +{"type":"event_msg","timestamp":"2030-01-01T12:00:58Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":169086,"cached_input_tokens":158464,"output_tokens":204,"reasoning_output_tokens":71,"total_tokens":169290},"total_token_usage":{"input_tokens":5649732,"cached_input_tokens":5171968,"output_tokens":18810,"reasoning_output_tokens":8766,"total_tokens":5668542}}}} +{"type":"event_msg","timestamp":"2030-01-01T12:00:59Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":170322,"cached_input_tokens":168704,"output_tokens":1547,"reasoning_output_tokens":1422,"total_tokens":171869},"total_token_usage":{"input_tokens":5820054,"cached_input_tokens":5340672,"output_tokens":20357,"reasoning_output_tokens":10188,"total_tokens":5840411}}}} +{"type":"event_msg","timestamp":"2030-01-01T12:01:00Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":172875,"cached_input_tokens":169728,"output_tokens":2843,"reasoning_output_tokens":1482,"total_tokens":175718},"total_token_usage":{"input_tokens":5992929,"cached_input_tokens":5510400,"output_tokens":23200,"reasoning_output_tokens":11670,"total_tokens":6016129}}}} +{"type":"event_msg","timestamp":"2030-01-01T12:01:01Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":172711,"cached_input_tokens":9984,"output_tokens":252,"reasoning_output_tokens":72,"total_tokens":172963},"total_token_usage":{"input_tokens":6165640,"cached_input_tokens":5520384,"output_tokens":23452,"reasoning_output_tokens":11742,"total_tokens":6189092}}}} +{"type":"event_msg","timestamp":"2030-01-01T12:01:02Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":182718,"cached_input_tokens":171776,"output_tokens":243,"reasoning_output_tokens":131,"total_tokens":182961},"total_token_usage":{"input_tokens":6348358,"cached_input_tokens":5692160,"output_tokens":23695,"reasoning_output_tokens":11873,"total_tokens":6372053}}}} +{"type":"event_msg","timestamp":"2030-01-01T12:01:03Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":190342,"cached_input_tokens":182016,"output_tokens":1688,"reasoning_output_tokens":1374,"total_tokens":192030},"total_token_usage":{"input_tokens":6538700,"cached_input_tokens":5874176,"output_tokens":25383,"reasoning_output_tokens":13247,"total_tokens":6564083}}}} +{"type":"event_msg","timestamp":"2030-01-01T12:01:04Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":202821,"cached_input_tokens":190208,"output_tokens":157,"reasoning_output_tokens":25,"total_tokens":202978},"total_token_usage":{"input_tokens":6741521,"cached_input_tokens":6064384,"output_tokens":25540,"reasoning_output_tokens":13272,"total_tokens":6767061}}}} +{"type":"event_msg","timestamp":"2030-01-01T12:01:05Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":212387,"cached_input_tokens":202496,"output_tokens":345,"reasoning_output_tokens":236,"total_tokens":212732},"total_token_usage":{"input_tokens":6953908,"cached_input_tokens":6266880,"output_tokens":25885,"reasoning_output_tokens":13508,"total_tokens":6979793}}}} +{"type":"event_msg","timestamp":"2030-01-01T12:01:06Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":213742,"cached_input_tokens":211712,"output_tokens":183,"reasoning_output_tokens":115,"total_tokens":213925},"total_token_usage":{"input_tokens":7167650,"cached_input_tokens":6478592,"output_tokens":26068,"reasoning_output_tokens":13623,"total_tokens":7193718}}}} +{"type":"event_msg","timestamp":"2030-01-01T12:01:07Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":216932,"cached_input_tokens":212736,"output_tokens":2642,"reasoning_output_tokens":2578,"total_tokens":219574},"total_token_usage":{"input_tokens":7384582,"cached_input_tokens":6691328,"output_tokens":28710,"reasoning_output_tokens":16201,"total_tokens":7413292}}}} +{"type":"event_msg","timestamp":"2030-01-01T12:01:08Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":221647,"cached_input_tokens":215808,"output_tokens":40,"reasoning_output_tokens":7,"total_tokens":221687},"total_token_usage":{"input_tokens":7606229,"cached_input_tokens":6907136,"output_tokens":28750,"reasoning_output_tokens":16208,"total_tokens":7634979}}}} +{"type":"event_msg","timestamp":"2030-01-01T12:01:09Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":236703,"cached_input_tokens":220928,"output_tokens":278,"reasoning_output_tokens":101,"total_tokens":236981},"total_token_usage":{"input_tokens":7842932,"cached_input_tokens":7128064,"output_tokens":29028,"reasoning_output_tokens":16309,"total_tokens":7871960}}}} +{"type":"event_msg","timestamp":"2030-01-01T12:01:10Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":243552,"cached_input_tokens":236288,"output_tokens":4352,"reasoning_output_tokens":3920,"total_tokens":247904},"total_token_usage":{"input_tokens":8086484,"cached_input_tokens":7364352,"output_tokens":33380,"reasoning_output_tokens":20229,"total_tokens":8119864}}}} +{"type":"event_msg","timestamp":"2030-01-01T12:01:11Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":251791,"cached_input_tokens":242432,"output_tokens":1213,"reasoning_output_tokens":0,"total_tokens":253004},"total_token_usage":{"input_tokens":8338275,"cached_input_tokens":7606784,"output_tokens":34593,"reasoning_output_tokens":20229,"total_tokens":8372868}}}} +{"type":"event_msg","timestamp":"2030-01-01T12:01:12Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":254769,"cached_input_tokens":251648,"output_tokens":1038,"reasoning_output_tokens":516,"total_tokens":255807},"total_token_usage":{"input_tokens":8593044,"cached_input_tokens":7858432,"output_tokens":35631,"reasoning_output_tokens":20745,"total_tokens":8628675}}}} +{"type":"event_msg","timestamp":"2030-01-01T12:01:13Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":257629,"cached_input_tokens":253696,"output_tokens":4045,"reasoning_output_tokens":3322,"total_tokens":261674},"total_token_usage":{"input_tokens":8850673,"cached_input_tokens":8112128,"output_tokens":39676,"reasoning_output_tokens":24067,"total_tokens":8890349}}}} +{"type":"event_msg","timestamp":"2030-01-01T12:01:14Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":264619,"cached_input_tokens":256768,"output_tokens":288,"reasoning_output_tokens":84,"total_tokens":264907},"total_token_usage":{"input_tokens":9115292,"cached_input_tokens":8368896,"output_tokens":39964,"reasoning_output_tokens":24151,"total_tokens":9155256}}}} +{"type":"event_msg","timestamp":"2030-01-01T12:01:15Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":267930,"cached_input_tokens":263936,"output_tokens":187,"reasoning_output_tokens":18,"total_tokens":268117},"total_token_usage":{"input_tokens":9383222,"cached_input_tokens":8632832,"output_tokens":40151,"reasoning_output_tokens":24169,"total_tokens":9423373}}}} +{"type":"event_msg","timestamp":"2030-01-01T12:01:16Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":277102,"cached_input_tokens":267008,"output_tokens":219,"reasoning_output_tokens":21,"total_tokens":277321},"total_token_usage":{"input_tokens":9660324,"cached_input_tokens":8899840,"output_tokens":40370,"reasoning_output_tokens":24190,"total_tokens":9700694}}}} +{"type":"event_msg","timestamp":"2030-01-01T12:01:17Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":281697,"cached_input_tokens":276224,"output_tokens":944,"reasoning_output_tokens":588,"total_tokens":282641},"total_token_usage":{"input_tokens":9942021,"cached_input_tokens":9176064,"output_tokens":41314,"reasoning_output_tokens":24778,"total_tokens":9983335}}}} +{"type":"event_msg","timestamp":"2030-01-01T12:01:18Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":286158,"cached_input_tokens":281344,"output_tokens":74,"reasoning_output_tokens":8,"total_tokens":286232},"total_token_usage":{"input_tokens":10228179,"cached_input_tokens":9457408,"output_tokens":41388,"reasoning_output_tokens":24786,"total_tokens":10269567}}}} +{"type":"event_msg","timestamp":"2030-01-01T12:01:19Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":286821,"cached_input_tokens":285440,"output_tokens":1057,"reasoning_output_tokens":776,"total_tokens":287878},"total_token_usage":{"input_tokens":10515000,"cached_input_tokens":9742848,"output_tokens":42445,"reasoning_output_tokens":25562,"total_tokens":10557445}}}} +{"type":"event_msg","timestamp":"2030-01-01T12:01:20Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":293437,"cached_input_tokens":286464,"output_tokens":804,"reasoning_output_tokens":288,"total_tokens":294241},"total_token_usage":{"input_tokens":10808437,"cached_input_tokens":10029312,"output_tokens":43249,"reasoning_output_tokens":25850,"total_tokens":10851686}}}} +{"type":"event_msg","timestamp":"2030-01-01T12:01:21Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":296500,"cached_input_tokens":292608,"output_tokens":180,"reasoning_output_tokens":120,"total_tokens":296680},"total_token_usage":{"input_tokens":11104937,"cached_input_tokens":10321920,"output_tokens":43429,"reasoning_output_tokens":25970,"total_tokens":11148366}}}} +{"type":"event_msg","timestamp":"2030-01-01T12:01:22Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":296708,"cached_input_tokens":295680,"output_tokens":39,"reasoning_output_tokens":6,"total_tokens":296747},"total_token_usage":{"input_tokens":11401645,"cached_input_tokens":10617600,"output_tokens":43468,"reasoning_output_tokens":25976,"total_tokens":11445113}}}} +{"type":"event_msg","timestamp":"2030-01-01T12:01:23Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":0,"cached_input_tokens":0,"output_tokens":0,"reasoning_output_tokens":0,"total_tokens":19666},"total_token_usage":{"input_tokens":11401645,"cached_input_tokens":10617600,"output_tokens":43468,"reasoning_output_tokens":25976,"total_tokens":11445113}}}} +{"type":"event_msg","timestamp":"2030-01-01T12:01:24Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":24864,"cached_input_tokens":9984,"output_tokens":572,"reasoning_output_tokens":0,"total_tokens":25436},"total_token_usage":{"input_tokens":11426509,"cached_input_tokens":10627584,"output_tokens":44040,"reasoning_output_tokens":25976,"total_tokens":11470549}}}} +{"type":"event_msg","timestamp":"2030-01-01T12:01:25Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":26522,"cached_input_tokens":24320,"output_tokens":230,"reasoning_output_tokens":0,"total_tokens":26752},"total_token_usage":{"input_tokens":11453031,"cached_input_tokens":10651904,"output_tokens":44270,"reasoning_output_tokens":25976,"total_tokens":11497301}}}} +{"type":"event_msg","timestamp":"2030-01-01T12:01:26Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":27842,"cached_input_tokens":26368,"output_tokens":200,"reasoning_output_tokens":79,"total_tokens":28042},"total_token_usage":{"input_tokens":11480873,"cached_input_tokens":10678272,"output_tokens":44470,"reasoning_output_tokens":26055,"total_tokens":11525343}}}} +{"type":"event_msg","timestamp":"2030-01-01T12:01:27Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":28052,"cached_input_tokens":27392,"output_tokens":105,"reasoning_output_tokens":0,"total_tokens":28157},"total_token_usage":{"input_tokens":11508925,"cached_input_tokens":10705664,"output_tokens":44575,"reasoning_output_tokens":26055,"total_tokens":11553500}}}} +{"type":"event_msg","timestamp":"2030-01-01T12:01:28Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":29374,"cached_input_tokens":27392,"output_tokens":93,"reasoning_output_tokens":13,"total_tokens":29467},"total_token_usage":{"input_tokens":11538299,"cached_input_tokens":10733056,"output_tokens":44668,"reasoning_output_tokens":26068,"total_tokens":11582967}}}} +{"type":"event_msg","timestamp":"2030-01-01T12:01:29Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":31587,"cached_input_tokens":28416,"output_tokens":246,"reasoning_output_tokens":115,"total_tokens":31833},"total_token_usage":{"input_tokens":11569886,"cached_input_tokens":10761472,"output_tokens":44914,"reasoning_output_tokens":26183,"total_tokens":11614800}}}} +{"type":"event_msg","timestamp":"2030-01-01T12:01:30Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":31900,"cached_input_tokens":30464,"output_tokens":146,"reasoning_output_tokens":36,"total_tokens":32046},"total_token_usage":{"input_tokens":11601786,"cached_input_tokens":10791936,"output_tokens":45060,"reasoning_output_tokens":26219,"total_tokens":11646846}}}} +{"type":"event_msg","timestamp":"2030-01-01T12:01:31Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":32963,"cached_input_tokens":31488,"output_tokens":147,"reasoning_output_tokens":56,"total_tokens":33110},"total_token_usage":{"input_tokens":11634749,"cached_input_tokens":10823424,"output_tokens":45207,"reasoning_output_tokens":26275,"total_tokens":11679956}}}} +{"type":"event_msg","timestamp":"2030-01-01T12:01:32Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":33206,"cached_input_tokens":32512,"output_tokens":92,"reasoning_output_tokens":10,"total_tokens":33298},"total_token_usage":{"input_tokens":11667955,"cached_input_tokens":10855936,"output_tokens":45299,"reasoning_output_tokens":26285,"total_tokens":11713254}}}} +{"type":"event_msg","timestamp":"2030-01-01T12:01:33Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":34299,"cached_input_tokens":32512,"output_tokens":93,"reasoning_output_tokens":11,"total_tokens":34392},"total_token_usage":{"input_tokens":11702254,"cached_input_tokens":10888448,"output_tokens":45392,"reasoning_output_tokens":26296,"total_tokens":11747646}}}} +{"type":"event_msg","timestamp":"2030-01-01T12:01:34Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":34726,"cached_input_tokens":33536,"output_tokens":195,"reasoning_output_tokens":112,"total_tokens":34921},"total_token_usage":{"input_tokens":11736980,"cached_input_tokens":10921984,"output_tokens":45587,"reasoning_output_tokens":26408,"total_tokens":11782567}}}} +{"type":"event_msg","timestamp":"2030-01-01T12:01:35Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":35784,"cached_input_tokens":34560,"output_tokens":2117,"reasoning_output_tokens":137,"total_tokens":37901},"total_token_usage":{"input_tokens":11772764,"cached_input_tokens":10956544,"output_tokens":47704,"reasoning_output_tokens":26545,"total_tokens":11820468}}}} +{"type":"event_msg","timestamp":"2030-01-01T12:01:36Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":37925,"cached_input_tokens":35584,"output_tokens":170,"reasoning_output_tokens":29,"total_tokens":38095},"total_token_usage":{"input_tokens":11810689,"cached_input_tokens":10992128,"output_tokens":47874,"reasoning_output_tokens":26574,"total_tokens":11858563}}}} +{"type":"event_msg","timestamp":"2030-01-01T12:01:37Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":38178,"cached_input_tokens":37632,"output_tokens":141,"reasoning_output_tokens":56,"total_tokens":38319},"total_token_usage":{"input_tokens":11848867,"cached_input_tokens":11029760,"output_tokens":48015,"reasoning_output_tokens":26630,"total_tokens":11896882}}}} +{"type":"event_msg","timestamp":"2030-01-01T12:01:38Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":40051,"cached_input_tokens":37632,"output_tokens":450,"reasoning_output_tokens":271,"total_tokens":40501},"total_token_usage":{"input_tokens":11888918,"cached_input_tokens":11067392,"output_tokens":48465,"reasoning_output_tokens":26901,"total_tokens":11937383}}}} +{"type":"event_msg","timestamp":"2030-01-01T12:01:39Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":40525,"cached_input_tokens":39680,"output_tokens":180,"reasoning_output_tokens":35,"total_tokens":40705},"total_token_usage":{"input_tokens":11929443,"cached_input_tokens":11107072,"output_tokens":48645,"reasoning_output_tokens":26936,"total_tokens":11978088}}}} +{"type":"event_msg","timestamp":"2030-01-01T12:01:40Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":40744,"cached_input_tokens":39680,"output_tokens":158,"reasoning_output_tokens":0,"total_tokens":40902},"total_token_usage":{"input_tokens":11970187,"cached_input_tokens":11146752,"output_tokens":48803,"reasoning_output_tokens":26936,"total_tokens":12018990}}}} +{"type":"event_msg","timestamp":"2030-01-01T12:01:41Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":41993,"cached_input_tokens":39680,"output_tokens":749,"reasoning_output_tokens":516,"total_tokens":42742},"total_token_usage":{"input_tokens":12012180,"cached_input_tokens":11186432,"output_tokens":49552,"reasoning_output_tokens":27452,"total_tokens":12061732}}}} +{"type":"event_msg","timestamp":"2030-01-01T12:01:42Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":43908,"cached_input_tokens":41728,"output_tokens":601,"reasoning_output_tokens":227,"total_tokens":44509},"total_token_usage":{"input_tokens":12056088,"cached_input_tokens":11228160,"output_tokens":50153,"reasoning_output_tokens":27679,"total_tokens":12106241}}}} +{"type":"event_msg","timestamp":"2030-01-01T12:01:43Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":44549,"cached_input_tokens":43776,"output_tokens":172,"reasoning_output_tokens":12,"total_tokens":44721},"total_token_usage":{"input_tokens":12100637,"cached_input_tokens":11271936,"output_tokens":50325,"reasoning_output_tokens":27691,"total_tokens":12150962}}}} +{"type":"event_msg","timestamp":"2030-01-01T12:01:44Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":45694,"cached_input_tokens":43776,"output_tokens":118,"reasoning_output_tokens":78,"total_tokens":45812},"total_token_usage":{"input_tokens":12146331,"cached_input_tokens":11315712,"output_tokens":50443,"reasoning_output_tokens":27769,"total_tokens":12196774}}}} +{"type":"event_msg","timestamp":"2030-01-01T12:01:45Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":55850,"cached_input_tokens":44800,"output_tokens":92,"reasoning_output_tokens":16,"total_tokens":55942},"total_token_usage":{"input_tokens":12202181,"cached_input_tokens":11360512,"output_tokens":50535,"reasoning_output_tokens":27785,"total_tokens":12252716}}}} +{"type":"event_msg","timestamp":"2030-01-01T12:01:46Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":56317,"cached_input_tokens":55040,"output_tokens":63,"reasoning_output_tokens":18,"total_tokens":56380},"total_token_usage":{"input_tokens":12258498,"cached_input_tokens":11415552,"output_tokens":50598,"reasoning_output_tokens":27803,"total_tokens":12309096}}}} +{"type":"event_msg","timestamp":"2030-01-01T12:01:47Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":57557,"cached_input_tokens":56064,"output_tokens":178,"reasoning_output_tokens":33,"total_tokens":57735},"total_token_usage":{"input_tokens":12316055,"cached_input_tokens":11471616,"output_tokens":50776,"reasoning_output_tokens":27836,"total_tokens":12366831}}}} +{"type":"event_msg","timestamp":"2030-01-01T12:01:48Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":59286,"cached_input_tokens":57088,"output_tokens":305,"reasoning_output_tokens":53,"total_tokens":59591},"total_token_usage":{"input_tokens":12375341,"cached_input_tokens":11528704,"output_tokens":51081,"reasoning_output_tokens":27889,"total_tokens":12426422}}}} +{"type":"event_msg","timestamp":"2030-01-01T12:01:49Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":69853,"cached_input_tokens":59136,"output_tokens":305,"reasoning_output_tokens":63,"total_tokens":70158},"total_token_usage":{"input_tokens":12445194,"cached_input_tokens":11587840,"output_tokens":51386,"reasoning_output_tokens":27952,"total_tokens":12496580}}}} +{"type":"event_msg","timestamp":"2030-01-01T12:01:50Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":71305,"cached_input_tokens":69376,"output_tokens":129,"reasoning_output_tokens":43,"total_tokens":71434},"total_token_usage":{"input_tokens":12516499,"cached_input_tokens":11657216,"output_tokens":51515,"reasoning_output_tokens":27995,"total_tokens":12568014}}}} +{"type":"event_msg","timestamp":"2030-01-01T12:01:51Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":71711,"cached_input_tokens":70400,"output_tokens":431,"reasoning_output_tokens":336,"total_tokens":72142},"total_token_usage":{"input_tokens":12588210,"cached_input_tokens":11727616,"output_tokens":51946,"reasoning_output_tokens":28331,"total_tokens":12640156}}}} +{"type":"event_msg","timestamp":"2030-01-01T12:01:52Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":72962,"cached_input_tokens":71424,"output_tokens":187,"reasoning_output_tokens":65,"total_tokens":73149},"total_token_usage":{"input_tokens":12661172,"cached_input_tokens":11799040,"output_tokens":52133,"reasoning_output_tokens":28396,"total_tokens":12713305}}}} +{"type":"event_msg","timestamp":"2030-01-01T12:01:53Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":75357,"cached_input_tokens":72448,"output_tokens":94,"reasoning_output_tokens":12,"total_tokens":75451},"total_token_usage":{"input_tokens":12736529,"cached_input_tokens":11871488,"output_tokens":52227,"reasoning_output_tokens":28408,"total_tokens":12788756}}}} +{"type":"event_msg","timestamp":"2030-01-01T12:01:54Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":75831,"cached_input_tokens":74496,"output_tokens":250,"reasoning_output_tokens":58,"total_tokens":76081},"total_token_usage":{"input_tokens":12812360,"cached_input_tokens":11945984,"output_tokens":52477,"reasoning_output_tokens":28466,"total_tokens":12864837}}}} +{"type":"event_msg","timestamp":"2030-01-01T12:01:55Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":86095,"cached_input_tokens":75520,"output_tokens":375,"reasoning_output_tokens":90,"total_tokens":86470},"total_token_usage":{"input_tokens":12898455,"cached_input_tokens":12021504,"output_tokens":52852,"reasoning_output_tokens":28556,"total_tokens":12951307}}}} +{"type":"event_msg","timestamp":"2030-01-01T12:01:56Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":96684,"cached_input_tokens":85760,"output_tokens":59,"reasoning_output_tokens":18,"total_tokens":96743},"total_token_usage":{"input_tokens":12995139,"cached_input_tokens":12107264,"output_tokens":52911,"reasoning_output_tokens":28574,"total_tokens":13048050}}}} +{"type":"event_msg","timestamp":"2030-01-01T12:01:57Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":96922,"cached_input_tokens":96000,"output_tokens":59,"reasoning_output_tokens":0,"total_tokens":96981},"total_token_usage":{"input_tokens":13092061,"cached_input_tokens":12203264,"output_tokens":52970,"reasoning_output_tokens":28574,"total_tokens":13145031}}}} +{"type":"event_msg","timestamp":"2030-01-01T12:01:58Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":103446,"cached_input_tokens":96000,"output_tokens":1755,"reasoning_output_tokens":870,"total_tokens":105201},"total_token_usage":{"input_tokens":13195507,"cached_input_tokens":12299264,"output_tokens":54725,"reasoning_output_tokens":29444,"total_tokens":13250232}}}} +{"type":"event_msg","timestamp":"2030-01-01T12:01:59Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":106981,"cached_input_tokens":103168,"output_tokens":539,"reasoning_output_tokens":272,"total_tokens":107520},"total_token_usage":{"input_tokens":13302488,"cached_input_tokens":12402432,"output_tokens":55264,"reasoning_output_tokens":29716,"total_tokens":13357752}}}} +{"type":"event_msg","timestamp":"2030-01-01T12:02:00Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":108617,"cached_input_tokens":106240,"output_tokens":440,"reasoning_output_tokens":236,"total_tokens":109057},"total_token_usage":{"input_tokens":13411105,"cached_input_tokens":12508672,"output_tokens":55704,"reasoning_output_tokens":29952,"total_tokens":13466809}}}} +{"type":"event_msg","timestamp":"2030-01-01T12:02:01Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":117001,"cached_input_tokens":108288,"output_tokens":224,"reasoning_output_tokens":82,"total_tokens":117225},"total_token_usage":{"input_tokens":13528106,"cached_input_tokens":12616960,"output_tokens":55928,"reasoning_output_tokens":30034,"total_tokens":13584034}}}} +{"type":"event_msg","timestamp":"2030-01-01T12:02:02Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":117001,"cached_input_tokens":108288,"output_tokens":224,"reasoning_output_tokens":82,"total_tokens":117225},"total_token_usage":{"input_tokens":13528106,"cached_input_tokens":12616960,"output_tokens":55928,"reasoning_output_tokens":30034,"total_tokens":13584034}}}} +{"type":"event_msg","timestamp":"2030-01-01T12:02:03Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":124096,"cached_input_tokens":103168,"output_tokens":196,"reasoning_output_tokens":53,"total_tokens":124292},"total_token_usage":{"input_tokens":13652202,"cached_input_tokens":12720128,"output_tokens":56124,"reasoning_output_tokens":30087,"total_tokens":13708326}}}} +{"type":"event_msg","timestamp":"2030-01-01T12:02:04Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":127681,"cached_input_tokens":123648,"output_tokens":148,"reasoning_output_tokens":9,"total_tokens":127829},"total_token_usage":{"input_tokens":13779883,"cached_input_tokens":12843776,"output_tokens":56272,"reasoning_output_tokens":30096,"total_tokens":13836155}}}} +{"type":"event_msg","timestamp":"2030-01-01T12:02:05Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":130071,"cached_input_tokens":126720,"output_tokens":1081,"reasoning_output_tokens":906,"total_tokens":131152},"total_token_usage":{"input_tokens":13909954,"cached_input_tokens":12970496,"output_tokens":57353,"reasoning_output_tokens":31002,"total_tokens":13967307}}}} +{"type":"event_msg","timestamp":"2030-01-01T12:02:06Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":133032,"cached_input_tokens":129792,"output_tokens":116,"reasoning_output_tokens":17,"total_tokens":133148},"total_token_usage":{"input_tokens":14042986,"cached_input_tokens":13100288,"output_tokens":57469,"reasoning_output_tokens":31019,"total_tokens":14100455}}}} +{"type":"event_msg","timestamp":"2030-01-01T12:02:07Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":135265,"cached_input_tokens":132864,"output_tokens":2792,"reasoning_output_tokens":1676,"total_tokens":138057},"total_token_usage":{"input_tokens":14178251,"cached_input_tokens":13233152,"output_tokens":60261,"reasoning_output_tokens":32695,"total_tokens":14238512}}}} +{"type":"event_msg","timestamp":"2030-01-01T12:02:08Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":139420,"cached_input_tokens":134912,"output_tokens":153,"reasoning_output_tokens":11,"total_tokens":139573},"total_token_usage":{"input_tokens":14317671,"cached_input_tokens":13368064,"output_tokens":60414,"reasoning_output_tokens":32706,"total_tokens":14378085}}}} +{"type":"event_msg","timestamp":"2030-01-01T12:02:09Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":142946,"cached_input_tokens":139008,"output_tokens":78,"reasoning_output_tokens":0,"total_tokens":143024},"total_token_usage":{"input_tokens":14460617,"cached_input_tokens":13507072,"output_tokens":60492,"reasoning_output_tokens":32706,"total_tokens":14521109}}}} +{"type":"event_msg","timestamp":"2030-01-01T12:02:10Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":143285,"cached_input_tokens":142080,"output_tokens":1378,"reasoning_output_tokens":962,"total_tokens":144663},"total_token_usage":{"input_tokens":14603902,"cached_input_tokens":13649152,"output_tokens":61870,"reasoning_output_tokens":33668,"total_tokens":14665772}}}} +{"type":"event_msg","timestamp":"2030-01-01T12:02:11Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":145837,"cached_input_tokens":143104,"output_tokens":2354,"reasoning_output_tokens":1108,"total_tokens":148191},"total_token_usage":{"input_tokens":14749739,"cached_input_tokens":13792256,"output_tokens":64224,"reasoning_output_tokens":34776,"total_tokens":14813963}}}} +{"type":"event_msg","timestamp":"2030-01-01T12:02:12Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":150101,"cached_input_tokens":145152,"output_tokens":191,"reasoning_output_tokens":33,"total_tokens":150292},"total_token_usage":{"input_tokens":14899840,"cached_input_tokens":13937408,"output_tokens":64415,"reasoning_output_tokens":34809,"total_tokens":14964255}}}} +{"type":"event_msg","timestamp":"2030-01-01T12:02:13Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":154769,"cached_input_tokens":149248,"output_tokens":1676,"reasoning_output_tokens":1460,"total_tokens":156445},"total_token_usage":{"input_tokens":15054609,"cached_input_tokens":14086656,"output_tokens":66091,"reasoning_output_tokens":36269,"total_tokens":15120700}}}} +{"type":"event_msg","timestamp":"2030-01-01T12:02:14Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":160473,"cached_input_tokens":154368,"output_tokens":1623,"reasoning_output_tokens":88,"total_tokens":162096},"total_token_usage":{"input_tokens":15215082,"cached_input_tokens":14241024,"output_tokens":67714,"reasoning_output_tokens":36357,"total_tokens":15282796}}}} +{"type":"event_msg","timestamp":"2030-01-01T12:02:15Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":163425,"cached_input_tokens":159488,"output_tokens":218,"reasoning_output_tokens":16,"total_tokens":163643},"total_token_usage":{"input_tokens":15378507,"cached_input_tokens":14400512,"output_tokens":67932,"reasoning_output_tokens":36373,"total_tokens":15446439}}}} +{"type":"event_msg","timestamp":"2030-01-01T12:02:16Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":170331,"cached_input_tokens":162560,"output_tokens":3805,"reasoning_output_tokens":2584,"total_tokens":174136},"total_token_usage":{"input_tokens":15548838,"cached_input_tokens":14563072,"output_tokens":71737,"reasoning_output_tokens":38957,"total_tokens":15620575}}}} +{"type":"event_msg","timestamp":"2030-01-01T12:02:17Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":176208,"cached_input_tokens":169728,"output_tokens":1655,"reasoning_output_tokens":508,"total_tokens":177863},"total_token_usage":{"input_tokens":15725046,"cached_input_tokens":14732800,"output_tokens":73392,"reasoning_output_tokens":39465,"total_tokens":15798438}}}} +{"type":"event_msg","timestamp":"2030-01-01T12:02:18Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":179198,"cached_input_tokens":175872,"output_tokens":3200,"reasoning_output_tokens":1602,"total_tokens":182398},"total_token_usage":{"input_tokens":15904244,"cached_input_tokens":14908672,"output_tokens":76592,"reasoning_output_tokens":41067,"total_tokens":15980836}}}} +{"type":"event_msg","timestamp":"2030-01-01T12:02:19Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":183214,"cached_input_tokens":178944,"output_tokens":1460,"reasoning_output_tokens":13,"total_tokens":184674},"total_token_usage":{"input_tokens":16087458,"cached_input_tokens":15087616,"output_tokens":78052,"reasoning_output_tokens":41080,"total_tokens":16165510}}}} +{"type":"event_msg","timestamp":"2030-01-01T12:02:20Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":184698,"cached_input_tokens":183040,"output_tokens":747,"reasoning_output_tokens":9,"total_tokens":185445},"total_token_usage":{"input_tokens":16272156,"cached_input_tokens":15270656,"output_tokens":78799,"reasoning_output_tokens":41089,"total_tokens":16350955}}}} +{"type":"event_msg","timestamp":"2030-01-01T12:02:21Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":185469,"cached_input_tokens":184064,"output_tokens":2001,"reasoning_output_tokens":198,"total_tokens":187470},"total_token_usage":{"input_tokens":16457625,"cached_input_tokens":15454720,"output_tokens":80800,"reasoning_output_tokens":41287,"total_tokens":16538425}}}} +{"type":"event_msg","timestamp":"2030-01-01T12:02:22Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":187588,"cached_input_tokens":185088,"output_tokens":345,"reasoning_output_tokens":167,"total_tokens":187933},"total_token_usage":{"input_tokens":16645213,"cached_input_tokens":15639808,"output_tokens":81145,"reasoning_output_tokens":41454,"total_tokens":16726358}}}} +{"type":"event_msg","timestamp":"2030-01-01T12:02:23Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":192325,"cached_input_tokens":187136,"output_tokens":198,"reasoning_output_tokens":55,"total_tokens":192523},"total_token_usage":{"input_tokens":16837538,"cached_input_tokens":15826944,"output_tokens":81343,"reasoning_output_tokens":41509,"total_tokens":16918881}}}} +{"type":"event_msg","timestamp":"2030-01-01T12:02:24Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":192547,"cached_input_tokens":191232,"output_tokens":424,"reasoning_output_tokens":201,"total_tokens":192971},"total_token_usage":{"input_tokens":17030085,"cached_input_tokens":16018176,"output_tokens":81767,"reasoning_output_tokens":41710,"total_tokens":17111852}}}} +{"type":"event_msg","timestamp":"2030-01-01T12:02:25Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":199925,"cached_input_tokens":192256,"output_tokens":414,"reasoning_output_tokens":176,"total_tokens":200339},"total_token_usage":{"input_tokens":17230010,"cached_input_tokens":16210432,"output_tokens":82181,"reasoning_output_tokens":41886,"total_tokens":17312191}}}} +{"type":"event_msg","timestamp":"2030-01-01T12:02:26Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":200417,"cached_input_tokens":199424,"output_tokens":268,"reasoning_output_tokens":49,"total_tokens":200685},"total_token_usage":{"input_tokens":17430427,"cached_input_tokens":16409856,"output_tokens":82449,"reasoning_output_tokens":41935,"total_tokens":17512876}}}} +{"type":"event_msg","timestamp":"2030-01-01T12:02:27Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":200709,"cached_input_tokens":199424,"output_tokens":266,"reasoning_output_tokens":53,"total_tokens":200975},"total_token_usage":{"input_tokens":17631136,"cached_input_tokens":16609280,"output_tokens":82715,"reasoning_output_tokens":41988,"total_tokens":17713851}}}} +{"type":"event_msg","timestamp":"2030-01-01T12:02:28Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":202366,"cached_input_tokens":200448,"output_tokens":150,"reasoning_output_tokens":69,"total_tokens":202516},"total_token_usage":{"input_tokens":17833502,"cached_input_tokens":16809728,"output_tokens":82865,"reasoning_output_tokens":42057,"total_tokens":17916367}}}} +{"type":"event_msg","timestamp":"2030-01-01T12:02:29Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":203153,"cached_input_tokens":201472,"output_tokens":630,"reasoning_output_tokens":17,"total_tokens":203783},"total_token_usage":{"input_tokens":18036655,"cached_input_tokens":17011200,"output_tokens":83495,"reasoning_output_tokens":42074,"total_tokens":18120150}}}} +{"type":"event_msg","timestamp":"2030-01-01T12:02:30Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":203807,"cached_input_tokens":202496,"output_tokens":178,"reasoning_output_tokens":7,"total_tokens":203985},"total_token_usage":{"input_tokens":18240462,"cached_input_tokens":17213696,"output_tokens":83673,"reasoning_output_tokens":42081,"total_tokens":18324135}}}} +{"type":"event_msg","timestamp":"2030-01-01T12:02:31Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":204024,"cached_input_tokens":203520,"output_tokens":280,"reasoning_output_tokens":55,"total_tokens":204304},"total_token_usage":{"input_tokens":18444486,"cached_input_tokens":17417216,"output_tokens":83953,"reasoning_output_tokens":42136,"total_tokens":18528439}}}} +{"type":"event_msg","timestamp":"2030-01-01T12:02:32Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":205479,"cached_input_tokens":203520,"output_tokens":227,"reasoning_output_tokens":37,"total_tokens":205706},"total_token_usage":{"input_tokens":18649965,"cached_input_tokens":17620736,"output_tokens":84180,"reasoning_output_tokens":42173,"total_tokens":18734145}}}} +{"type":"event_msg","timestamp":"2030-01-01T12:02:33Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":206922,"cached_input_tokens":204544,"output_tokens":2442,"reasoning_output_tokens":880,"total_tokens":209364},"total_token_usage":{"input_tokens":18856887,"cached_input_tokens":17825280,"output_tokens":86622,"reasoning_output_tokens":43053,"total_tokens":18943509}}}} +{"type":"event_msg","timestamp":"2030-01-01T12:02:34Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":201852,"cached_input_tokens":129792,"output_tokens":1297,"reasoning_output_tokens":492,"total_tokens":203149},"total_token_usage":{"input_tokens":19058739,"cached_input_tokens":17955072,"output_tokens":87919,"reasoning_output_tokens":43545,"total_tokens":19146658}}}} +{"type":"event_msg","timestamp":"2030-01-01T12:02:35Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":203173,"cached_input_tokens":201472,"output_tokens":715,"reasoning_output_tokens":9,"total_tokens":203888},"total_token_usage":{"input_tokens":19261912,"cached_input_tokens":18156544,"output_tokens":88634,"reasoning_output_tokens":43554,"total_tokens":19350546}}}} +{"type":"event_msg","timestamp":"2030-01-01T12:02:36Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":203912,"cached_input_tokens":202496,"output_tokens":440,"reasoning_output_tokens":16,"total_tokens":204352},"total_token_usage":{"input_tokens":19465824,"cached_input_tokens":18359040,"output_tokens":89074,"reasoning_output_tokens":43570,"total_tokens":19554898}}}} +{"type":"event_msg","timestamp":"2030-01-01T12:02:37Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":204376,"cached_input_tokens":203520,"output_tokens":515,"reasoning_output_tokens":34,"total_tokens":204891},"total_token_usage":{"input_tokens":19670200,"cached_input_tokens":18562560,"output_tokens":89589,"reasoning_output_tokens":43604,"total_tokens":19759789}}}} +{"type":"event_msg","timestamp":"2030-01-01T12:02:38Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":204915,"cached_input_tokens":203520,"output_tokens":339,"reasoning_output_tokens":38,"total_tokens":205254},"total_token_usage":{"input_tokens":19875115,"cached_input_tokens":18766080,"output_tokens":89928,"reasoning_output_tokens":43642,"total_tokens":19965043}}}} +{"type":"event_msg","timestamp":"2030-01-01T12:02:39Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":205278,"cached_input_tokens":204544,"output_tokens":378,"reasoning_output_tokens":37,"total_tokens":205656},"total_token_usage":{"input_tokens":20080393,"cached_input_tokens":18970624,"output_tokens":90306,"reasoning_output_tokens":43679,"total_tokens":20170699}}}} +{"type":"event_msg","timestamp":"2030-01-01T12:02:40Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":205680,"cached_input_tokens":204544,"output_tokens":435,"reasoning_output_tokens":18,"total_tokens":206115},"total_token_usage":{"input_tokens":20286073,"cached_input_tokens":19175168,"output_tokens":90741,"reasoning_output_tokens":43697,"total_tokens":20376814}}}} +{"type":"event_msg","timestamp":"2030-01-01T12:02:41Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":206139,"cached_input_tokens":204544,"output_tokens":423,"reasoning_output_tokens":25,"total_tokens":206562},"total_token_usage":{"input_tokens":20492212,"cached_input_tokens":19379712,"output_tokens":91164,"reasoning_output_tokens":43722,"total_tokens":20583376}}}} +{"type":"event_msg","timestamp":"2030-01-01T12:02:42Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":206586,"cached_input_tokens":205568,"output_tokens":1380,"reasoning_output_tokens":233,"total_tokens":207966},"total_token_usage":{"input_tokens":20698798,"cached_input_tokens":19585280,"output_tokens":92544,"reasoning_output_tokens":43955,"total_tokens":20791342}}}} +{"type":"event_msg","timestamp":"2030-01-01T12:02:43Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":207990,"cached_input_tokens":205568,"output_tokens":205,"reasoning_output_tokens":53,"total_tokens":208195},"total_token_usage":{"input_tokens":20906788,"cached_input_tokens":19790848,"output_tokens":92749,"reasoning_output_tokens":44008,"total_tokens":20999537}}}} +{"type":"event_msg","timestamp":"2030-01-01T12:02:44Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":208671,"cached_input_tokens":207616,"output_tokens":720,"reasoning_output_tokens":312,"total_tokens":209391},"total_token_usage":{"input_tokens":21115459,"cached_input_tokens":19998464,"output_tokens":93469,"reasoning_output_tokens":44320,"total_tokens":21208928}}}} +{"type":"event_msg","timestamp":"2030-01-01T12:02:45Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":209415,"cached_input_tokens":207616,"output_tokens":251,"reasoning_output_tokens":9,"total_tokens":209666},"total_token_usage":{"input_tokens":21324874,"cached_input_tokens":20206080,"output_tokens":93720,"reasoning_output_tokens":44329,"total_tokens":21418594}}}} +{"type":"event_msg","timestamp":"2030-01-01T12:02:46Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":209690,"cached_input_tokens":208640,"output_tokens":217,"reasoning_output_tokens":12,"total_tokens":209907},"total_token_usage":{"input_tokens":21534564,"cached_input_tokens":20414720,"output_tokens":93937,"reasoning_output_tokens":44341,"total_tokens":21628501}}}} +{"type":"event_msg","timestamp":"2030-01-01T12:02:47Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":210456,"cached_input_tokens":208640,"output_tokens":151,"reasoning_output_tokens":23,"total_tokens":210607},"total_token_usage":{"input_tokens":21745020,"cached_input_tokens":20623360,"output_tokens":94088,"reasoning_output_tokens":44364,"total_tokens":21839108}}}} +{"type":"event_msg","timestamp":"2030-01-01T12:02:48Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":218759,"cached_input_tokens":209664,"output_tokens":662,"reasoning_output_tokens":151,"total_tokens":219421},"total_token_usage":{"input_tokens":21963779,"cached_input_tokens":20833024,"output_tokens":94750,"reasoning_output_tokens":44515,"total_tokens":22058529}}}} +{"type":"event_msg","timestamp":"2030-01-01T12:02:49Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":219445,"cached_input_tokens":217856,"output_tokens":188,"reasoning_output_tokens":14,"total_tokens":219633},"total_token_usage":{"input_tokens":22183224,"cached_input_tokens":21050880,"output_tokens":94938,"reasoning_output_tokens":44529,"total_tokens":22278162}}}} +{"type":"event_msg","timestamp":"2030-01-01T12:02:50Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":219690,"cached_input_tokens":218880,"output_tokens":230,"reasoning_output_tokens":19,"total_tokens":219920},"total_token_usage":{"input_tokens":22402914,"cached_input_tokens":21269760,"output_tokens":95168,"reasoning_output_tokens":44548,"total_tokens":22498082}}}} +{"type":"event_msg","timestamp":"2030-01-01T12:02:51Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":221775,"cached_input_tokens":218880,"output_tokens":1236,"reasoning_output_tokens":516,"total_tokens":223011},"total_token_usage":{"input_tokens":22624689,"cached_input_tokens":21488640,"output_tokens":96404,"reasoning_output_tokens":45064,"total_tokens":22721093}}}} +{"type":"event_msg","timestamp":"2030-01-01T12:02:52Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":224098,"cached_input_tokens":220928,"output_tokens":572,"reasoning_output_tokens":103,"total_tokens":224670},"total_token_usage":{"input_tokens":22848787,"cached_input_tokens":21709568,"output_tokens":96976,"reasoning_output_tokens":45167,"total_tokens":22945763}}}} +{"type":"event_msg","timestamp":"2030-01-01T12:02:53Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":224694,"cached_input_tokens":222976,"output_tokens":487,"reasoning_output_tokens":12,"total_tokens":225181},"total_token_usage":{"input_tokens":23073481,"cached_input_tokens":21932544,"output_tokens":97463,"reasoning_output_tokens":45179,"total_tokens":23170944}}}} +{"type":"event_msg","timestamp":"2030-01-01T12:02:54Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":225205,"cached_input_tokens":224000,"output_tokens":266,"reasoning_output_tokens":13,"total_tokens":225471},"total_token_usage":{"input_tokens":23298686,"cached_input_tokens":22156544,"output_tokens":97729,"reasoning_output_tokens":45192,"total_tokens":23396415}}}} +{"type":"event_msg","timestamp":"2030-01-01T12:02:55Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":225495,"cached_input_tokens":225024,"output_tokens":254,"reasoning_output_tokens":15,"total_tokens":225749},"total_token_usage":{"input_tokens":23524181,"cached_input_tokens":22381568,"output_tokens":97983,"reasoning_output_tokens":45207,"total_tokens":23622164}}}} +{"type":"event_msg","timestamp":"2030-01-01T12:02:56Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":225773,"cached_input_tokens":225024,"output_tokens":809,"reasoning_output_tokens":111,"total_tokens":226582},"total_token_usage":{"input_tokens":23749954,"cached_input_tokens":22606592,"output_tokens":98792,"reasoning_output_tokens":45318,"total_tokens":23848746}}}} +{"type":"event_msg","timestamp":"2030-01-01T12:02:57Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":226606,"cached_input_tokens":225024,"output_tokens":333,"reasoning_output_tokens":11,"total_tokens":226939},"total_token_usage":{"input_tokens":23976560,"cached_input_tokens":22831616,"output_tokens":99125,"reasoning_output_tokens":45329,"total_tokens":24075685}}}} +{"type":"event_msg","timestamp":"2030-01-01T12:02:58Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":226963,"cached_input_tokens":226048,"output_tokens":257,"reasoning_output_tokens":56,"total_tokens":227220},"total_token_usage":{"input_tokens":24203523,"cached_input_tokens":23057664,"output_tokens":99382,"reasoning_output_tokens":45385,"total_tokens":24302905}}}} +{"type":"event_msg","timestamp":"2030-01-01T12:02:59Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":227860,"cached_input_tokens":226048,"output_tokens":260,"reasoning_output_tokens":63,"total_tokens":228120},"total_token_usage":{"input_tokens":24431383,"cached_input_tokens":23283712,"output_tokens":99642,"reasoning_output_tokens":45448,"total_tokens":24531025}}}} +{"type":"event_msg","timestamp":"2030-01-01T12:03:00Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":228143,"cached_input_tokens":227072,"output_tokens":942,"reasoning_output_tokens":449,"total_tokens":229085},"total_token_usage":{"input_tokens":24659526,"cached_input_tokens":23510784,"output_tokens":100584,"reasoning_output_tokens":45897,"total_tokens":24760110}}}} +{"type":"event_msg","timestamp":"2030-01-01T12:03:01Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":230188,"cached_input_tokens":227072,"output_tokens":2094,"reasoning_output_tokens":1554,"total_tokens":232282},"total_token_usage":{"input_tokens":24889714,"cached_input_tokens":23737856,"output_tokens":102678,"reasoning_output_tokens":47451,"total_tokens":24992392}}}} diff --git a/Tests/CodexBarTests/Fixtures/CostUsage/Issue2037/live-fork-4d90-52bf/manifest.json b/Tests/CodexBarTests/Fixtures/CostUsage/Issue2037/live-fork-4d90-52bf/manifest.json new file mode 100644 index 0000000000..587bc552df --- /dev/null +++ b/Tests/CodexBarTests/Fixtures/CostUsage/Issue2037/live-fork-4d90-52bf/manifest.json @@ -0,0 +1,51 @@ +{ + "schemaVersion": 1, + "redactionVersion": 1, + "familyAlias": "live-fork-4d90-52bf", + "files": [ + { + "alias": "parent", + "relativePath": "codex-home/archived_sessions/parent.jsonl", + "sourceRole": "archive", + "leafSessionAlias": "parent-session", + "parentSessionAlias": null + }, + { + "alias": "child", + "relativePath": "codex-home/archived_sessions/child.jsonl", + "sourceRole": "archive", + "leafSessionAlias": "child-session", + "parentSessionAlias": "parent-session" + } + ], + "copiedPrefixes": [ + { + "parentAlias": "parent", + "childAlias": "child", + "length": 180 + } + ], + "oracle": { + "parentEventCount": 180, + "childEventCount": 196, + "copiedPrefixLength": 180, + "parentLastTokens": 25129283, + "childLastTokens": 26938802, + "copiedPrefixLastTokens": 25129283, + "naiveLastTokens": 52068085, + "dedupedLastTokens": 26938802, + "copiedPrefixTimestampMismatches": 180, + "parentHasTotalTokenUsageDrop": false, + "childHasTotalTokenUsageDrop": false + }, + "scannerOracle": { + "naiveScannerUnits": 100916095, + "dedupedScannerUnits": 52185847, + "prefixScannerUnits": 48730248, + "siblingAUniqueScannerUnits": null, + "siblingBUniqueScannerUnits": null, + "unresolvedForkSkippedFirstEventScannerUnits": null + }, + "sourceNote": "Sanitized from local 019f4d90\u2192019f52bf; parent truncated to copied prefix for clean resolved-fork golden.", + "scannerOracleNote": "Scanner units follow total_token_usage deltas. Parent ordinal 120 has last=225513 with \u0394total=0; sum(last) overcounts vs scanner." +} diff --git a/Tests/CodexBarTests/Fixtures/CostUsage/Issue2037/missing-parent-siblings/ORACLE.md b/Tests/CodexBarTests/Fixtures/CostUsage/Issue2037/missing-parent-siblings/ORACLE.md new file mode 100644 index 0000000000..45b5fef158 --- /dev/null +++ b/Tests/CodexBarTests/Fixtures/CostUsage/Issue2037/missing-parent-siblings/ORACLE.md @@ -0,0 +1,30 @@ +# Hand oracle: missing-parent siblings + +Two children reference `missing-parent-session`, which is **not** present in the +fixture. Both carry the same 135-event normalized usage prefix; each then has a +distinct unique suffix. + +| Stream | Token rows | Scanner units `sum(last.input+cached+output)` | +|---|---:|---:| +| Shared prefix (once) | 135 | 25,547,233 | +| Sibling A unique | 23 | 3,311,641 | +| Sibling B unique | 3 | 3,396 | + +```text +naive = sibling-a all + sibling-b all = 54,409,503 +ideal prefix-once dedupe = 28,862,270 +unresolved-fork first-event skip on owner (#1164) = 25,671 +scanner deduped oracle = 28,836,599 +``` + +Desired billable prefix owner: **sibling-a** (deterministic: earliest fork +timestamp, then session id). This is a hand oracle for a future provenance +ledger, not authorization for token-only runtime suppression. + +`#1164` alone cannot fix this: there is no parent file to inherit from, so each +child bills nearly the full prefix. Runtime cross-file dedupe intentionally +fails open because distinct sibling events can have equal token vectors. The +unresolved-fork path still skips the first totals row (pre-existing); the target +scanner oracle subtracts one owner skip from the ideal prefix-once total. + +Not an Ultra interleaved golden. Not a claim that #2037 is closed. diff --git a/Tests/CodexBarTests/Fixtures/CostUsage/Issue2037/missing-parent-siblings/codex-home/archived_sessions/sibling-a.jsonl b/Tests/CodexBarTests/Fixtures/CostUsage/Issue2037/missing-parent-siblings/codex-home/archived_sessions/sibling-a.jsonl new file mode 100644 index 0000000000..fa7b34d1d8 --- /dev/null +++ b/Tests/CodexBarTests/Fixtures/CostUsage/Issue2037/missing-parent-siblings/codex-home/archived_sessions/sibling-a.jsonl @@ -0,0 +1,160 @@ +{"type":"session_meta","timestamp":"2030-01-01T15:00:00Z","payload":{"id":"sibling-a-session","forked_from_id":"missing-parent-session","timestamp":"2030-01-01T15:00:00Z"}} +{"type":"turn_context","timestamp":"2030-01-01T15:00:00Z","payload":{"model":"fixture-model","multi_agent_version":"v2","multi_agent_mode":"explicitRequestOnly"}} +{"type":"event_msg","timestamp":"2030-01-01T15:00:01Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":4992,"input_tokens":20273,"output_tokens":406,"reasoning_output_tokens":172,"total_tokens":20679},"total_token_usage":{"cached_input_tokens":4992,"input_tokens":20273,"output_tokens":406,"reasoning_output_tokens":172,"total_tokens":20679}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:00:02Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":4992,"input_tokens":41538,"output_tokens":427,"reasoning_output_tokens":102,"total_tokens":41965},"total_token_usage":{"cached_input_tokens":9984,"input_tokens":61811,"output_tokens":833,"reasoning_output_tokens":274,"total_tokens":62644}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:00:03Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":41344,"input_tokens":46041,"output_tokens":366,"reasoning_output_tokens":25,"total_tokens":46407},"total_token_usage":{"cached_input_tokens":51328,"input_tokens":107852,"output_tokens":1199,"reasoning_output_tokens":299,"total_tokens":109051}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:00:04Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":45952,"input_tokens":51733,"output_tokens":375,"reasoning_output_tokens":12,"total_tokens":52108},"total_token_usage":{"cached_input_tokens":97280,"input_tokens":159585,"output_tokens":1574,"reasoning_output_tokens":311,"total_tokens":161159}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:00:05Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":19840,"input_tokens":62672,"output_tokens":272,"reasoning_output_tokens":0,"total_tokens":62944},"total_token_usage":{"cached_input_tokens":117120,"input_tokens":222257,"output_tokens":1846,"reasoning_output_tokens":311,"total_tokens":224103}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:00:06Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":62336,"input_tokens":77059,"output_tokens":371,"reasoning_output_tokens":16,"total_tokens":77430},"total_token_usage":{"cached_input_tokens":179456,"input_tokens":299316,"output_tokens":2217,"reasoning_output_tokens":327,"total_tokens":301533}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:00:07Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":76672,"input_tokens":87381,"output_tokens":397,"reasoning_output_tokens":50,"total_tokens":87778},"total_token_usage":{"cached_input_tokens":256128,"input_tokens":386697,"output_tokens":2614,"reasoning_output_tokens":377,"total_tokens":389311}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:00:08Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":86912,"input_tokens":91474,"output_tokens":471,"reasoning_output_tokens":233,"total_tokens":91945},"total_token_usage":{"cached_input_tokens":343040,"input_tokens":478171,"output_tokens":3085,"reasoning_output_tokens":610,"total_tokens":481256}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:00:09Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":91008,"input_tokens":99980,"output_tokens":347,"reasoning_output_tokens":0,"total_tokens":100327},"total_token_usage":{"cached_input_tokens":434048,"input_tokens":578151,"output_tokens":3432,"reasoning_output_tokens":610,"total_tokens":581583}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:00:10Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":99712,"input_tokens":104328,"output_tokens":490,"reasoning_output_tokens":155,"total_tokens":104818},"total_token_usage":{"cached_input_tokens":533760,"input_tokens":682479,"output_tokens":3922,"reasoning_output_tokens":765,"total_tokens":686401}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:00:11Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":104320,"input_tokens":109064,"output_tokens":309,"reasoning_output_tokens":63,"total_tokens":109373},"total_token_usage":{"cached_input_tokens":638080,"input_tokens":791543,"output_tokens":4231,"reasoning_output_tokens":828,"total_tokens":795774}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:00:12Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":108928,"input_tokens":111249,"output_tokens":275,"reasoning_output_tokens":0,"total_tokens":111524},"total_token_usage":{"cached_input_tokens":747008,"input_tokens":902792,"output_tokens":4506,"reasoning_output_tokens":828,"total_tokens":907298}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:00:13Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":51584,"input_tokens":114730,"output_tokens":301,"reasoning_output_tokens":55,"total_tokens":115031},"total_token_usage":{"cached_input_tokens":798592,"input_tokens":1017522,"output_tokens":4807,"reasoning_output_tokens":883,"total_tokens":1022329}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:00:14Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":110976,"input_tokens":117769,"output_tokens":241,"reasoning_output_tokens":93,"total_tokens":118010},"total_token_usage":{"cached_input_tokens":909568,"input_tokens":1135291,"output_tokens":5048,"reasoning_output_tokens":976,"total_tokens":1140339}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:00:15Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":117632,"input_tokens":119430,"output_tokens":981,"reasoning_output_tokens":679,"total_tokens":120411},"total_token_usage":{"cached_input_tokens":1027200,"input_tokens":1254721,"output_tokens":6029,"reasoning_output_tokens":1655,"total_tokens":1260750}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:00:16Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":119168,"input_tokens":123097,"output_tokens":218,"reasoning_output_tokens":0,"total_tokens":123315},"total_token_usage":{"cached_input_tokens":1146368,"input_tokens":1377818,"output_tokens":6247,"reasoning_output_tokens":1655,"total_tokens":1384065}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:00:17Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":122752,"input_tokens":126343,"output_tokens":208,"reasoning_output_tokens":0,"total_tokens":126551},"total_token_usage":{"cached_input_tokens":1269120,"input_tokens":1504161,"output_tokens":6455,"reasoning_output_tokens":1655,"total_tokens":1510616}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:00:18Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":126336,"input_tokens":127901,"output_tokens":438,"reasoning_output_tokens":235,"total_tokens":128339},"total_token_usage":{"cached_input_tokens":1395456,"input_tokens":1632062,"output_tokens":6893,"reasoning_output_tokens":1890,"total_tokens":1638955}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:00:19Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":127872,"input_tokens":128401,"output_tokens":518,"reasoning_output_tokens":258,"total_tokens":128919},"total_token_usage":{"cached_input_tokens":1523328,"input_tokens":1760463,"output_tokens":7411,"reasoning_output_tokens":2148,"total_tokens":1767874}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:00:20Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":128384,"input_tokens":130226,"output_tokens":143,"reasoning_output_tokens":0,"total_tokens":130369},"total_token_usage":{"cached_input_tokens":1651712,"input_tokens":1890689,"output_tokens":7554,"reasoning_output_tokens":2148,"total_tokens":1898243}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:00:21Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":129920,"input_tokens":133103,"output_tokens":66,"reasoning_output_tokens":0,"total_tokens":133169},"total_token_usage":{"cached_input_tokens":1781632,"input_tokens":2023792,"output_tokens":7620,"reasoning_output_tokens":2148,"total_tokens":2031412}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:00:22Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":132992,"input_tokens":133538,"output_tokens":982,"reasoning_output_tokens":612,"total_tokens":134520},"total_token_usage":{"cached_input_tokens":1914624,"input_tokens":2157330,"output_tokens":8602,"reasoning_output_tokens":2760,"total_tokens":2165932}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:00:23Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":133504,"input_tokens":134570,"output_tokens":814,"reasoning_output_tokens":0,"total_tokens":135384},"total_token_usage":{"cached_input_tokens":2048128,"input_tokens":2291900,"output_tokens":9416,"reasoning_output_tokens":2760,"total_tokens":2301316}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:00:24Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":134528,"input_tokens":135434,"output_tokens":151,"reasoning_output_tokens":40,"total_tokens":135585},"total_token_usage":{"cached_input_tokens":2182656,"input_tokens":2427334,"output_tokens":9567,"reasoning_output_tokens":2800,"total_tokens":2436901}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:00:25Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":135040,"input_tokens":136816,"output_tokens":201,"reasoning_output_tokens":35,"total_tokens":137017},"total_token_usage":{"cached_input_tokens":2317696,"input_tokens":2564150,"output_tokens":9768,"reasoning_output_tokens":2835,"total_tokens":2573918}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:00:26Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":136576,"input_tokens":139802,"output_tokens":85,"reasoning_output_tokens":11,"total_tokens":139887},"total_token_usage":{"cached_input_tokens":2454272,"input_tokens":2703952,"output_tokens":9853,"reasoning_output_tokens":2846,"total_tokens":2713805}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:00:27Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":139648,"input_tokens":143646,"output_tokens":82,"reasoning_output_tokens":7,"total_tokens":143728},"total_token_usage":{"cached_input_tokens":2593920,"input_tokens":2847598,"output_tokens":9935,"reasoning_output_tokens":2853,"total_tokens":2857533}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:00:28Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":143232,"input_tokens":147052,"output_tokens":90,"reasoning_output_tokens":9,"total_tokens":147142},"total_token_usage":{"cached_input_tokens":2737152,"input_tokens":2994650,"output_tokens":10025,"reasoning_output_tokens":2862,"total_tokens":3004675}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:00:29Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":146816,"input_tokens":148945,"output_tokens":72,"reasoning_output_tokens":6,"total_tokens":149017},"total_token_usage":{"cached_input_tokens":2883968,"input_tokens":3143595,"output_tokens":10097,"reasoning_output_tokens":2868,"total_tokens":3153692}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:00:30Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":148864,"input_tokens":151268,"output_tokens":76,"reasoning_output_tokens":6,"total_tokens":151344},"total_token_usage":{"cached_input_tokens":3032832,"input_tokens":3294863,"output_tokens":10173,"reasoning_output_tokens":2874,"total_tokens":3305036}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:00:31Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":150912,"input_tokens":158145,"output_tokens":143,"reasoning_output_tokens":33,"total_tokens":158288},"total_token_usage":{"cached_input_tokens":3183744,"input_tokens":3453008,"output_tokens":10316,"reasoning_output_tokens":2907,"total_tokens":3463324}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:00:32Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":158080,"input_tokens":170316,"output_tokens":224,"reasoning_output_tokens":59,"total_tokens":170540},"total_token_usage":{"cached_input_tokens":3341824,"input_tokens":3623324,"output_tokens":10540,"reasoning_output_tokens":2966,"total_tokens":3633864}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:00:33Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":169856,"input_tokens":182556,"output_tokens":505,"reasoning_output_tokens":345,"total_tokens":183061},"total_token_usage":{"cached_input_tokens":3511680,"input_tokens":3805880,"output_tokens":11045,"reasoning_output_tokens":3311,"total_tokens":3816925}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:00:34Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":182144,"input_tokens":192775,"output_tokens":106,"reasoning_output_tokens":29,"total_tokens":192881},"total_token_usage":{"cached_input_tokens":3693824,"input_tokens":3998655,"output_tokens":11151,"reasoning_output_tokens":3340,"total_tokens":4009806}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:00:35Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":192384,"input_tokens":204282,"output_tokens":89,"reasoning_output_tokens":7,"total_tokens":204371},"total_token_usage":{"cached_input_tokens":3886208,"input_tokens":4202937,"output_tokens":11240,"reasoning_output_tokens":3347,"total_tokens":4214177}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:00:36Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":204160,"input_tokens":217219,"output_tokens":85,"reasoning_output_tokens":10,"total_tokens":217304},"total_token_usage":{"cached_input_tokens":4090368,"input_tokens":4420156,"output_tokens":11325,"reasoning_output_tokens":3357,"total_tokens":4431481}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:00:37Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":216960,"input_tokens":228774,"output_tokens":101,"reasoning_output_tokens":20,"total_tokens":228875},"total_token_usage":{"cached_input_tokens":4307328,"input_tokens":4648930,"output_tokens":11426,"reasoning_output_tokens":3377,"total_tokens":4660356}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:00:38Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":228736,"input_tokens":238310,"output_tokens":79,"reasoning_output_tokens":7,"total_tokens":238389},"total_token_usage":{"cached_input_tokens":4536064,"input_tokens":4887240,"output_tokens":11505,"reasoning_output_tokens":3384,"total_tokens":4898745}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:00:39Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":0,"input_tokens":0,"output_tokens":0,"reasoning_output_tokens":0,"total_tokens":16650},"total_token_usage":{"cached_input_tokens":4536064,"input_tokens":4887240,"output_tokens":11505,"reasoning_output_tokens":3384,"total_tokens":4898745}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:00:40Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":19840,"input_tokens":21273,"output_tokens":85,"reasoning_output_tokens":0,"total_tokens":21358},"total_token_usage":{"cached_input_tokens":4555904,"input_tokens":4908513,"output_tokens":11590,"reasoning_output_tokens":3384,"total_tokens":4920103}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:00:41Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":18304,"input_tokens":31745,"output_tokens":151,"reasoning_output_tokens":63,"total_tokens":31896},"total_token_usage":{"cached_input_tokens":4574208,"input_tokens":4940258,"output_tokens":11741,"reasoning_output_tokens":3447,"total_tokens":4951999}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:00:42Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":31616,"input_tokens":42752,"output_tokens":96,"reasoning_output_tokens":10,"total_tokens":42848},"total_token_usage":{"cached_input_tokens":4605824,"input_tokens":4983010,"output_tokens":11837,"reasoning_output_tokens":3457,"total_tokens":4994847}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:00:43Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":42368,"input_tokens":47233,"output_tokens":386,"reasoning_output_tokens":141,"total_tokens":47619},"total_token_usage":{"cached_input_tokens":4648192,"input_tokens":5030243,"output_tokens":12223,"reasoning_output_tokens":3598,"total_tokens":5042466}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:00:44Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":46976,"input_tokens":49371,"output_tokens":317,"reasoning_output_tokens":112,"total_tokens":49688},"total_token_usage":{"cached_input_tokens":4695168,"input_tokens":5079614,"output_tokens":12540,"reasoning_output_tokens":3710,"total_tokens":5092154}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:00:45Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":4992,"input_tokens":49856,"output_tokens":392,"reasoning_output_tokens":162,"total_tokens":50248},"total_token_usage":{"cached_input_tokens":4700160,"input_tokens":5129470,"output_tokens":12932,"reasoning_output_tokens":3872,"total_tokens":5142402}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:00:46Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":31616,"input_tokens":51404,"output_tokens":520,"reasoning_output_tokens":131,"total_tokens":51924},"total_token_usage":{"cached_input_tokens":4731776,"input_tokens":5180874,"output_tokens":13452,"reasoning_output_tokens":4003,"total_tokens":5194326}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:00:47Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":51072,"input_tokens":55494,"output_tokens":283,"reasoning_output_tokens":22,"total_tokens":55777},"total_token_usage":{"cached_input_tokens":4782848,"input_tokens":5236368,"output_tokens":13735,"reasoning_output_tokens":4025,"total_tokens":5250103}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:00:48Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":55168,"input_tokens":57511,"output_tokens":390,"reasoning_output_tokens":0,"total_tokens":57901},"total_token_usage":{"cached_input_tokens":4838016,"input_tokens":5293879,"output_tokens":14125,"reasoning_output_tokens":4025,"total_tokens":5308004}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:00:49Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":57216,"input_tokens":65745,"output_tokens":341,"reasoning_output_tokens":0,"total_tokens":66086},"total_token_usage":{"cached_input_tokens":4895232,"input_tokens":5359624,"output_tokens":14466,"reasoning_output_tokens":4025,"total_tokens":5374090}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:00:50Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":65408,"input_tokens":70383,"output_tokens":611,"reasoning_output_tokens":453,"total_tokens":70994},"total_token_usage":{"cached_input_tokens":4960640,"input_tokens":5430007,"output_tokens":15077,"reasoning_output_tokens":4478,"total_tokens":5445084}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:00:51Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":70016,"input_tokens":75664,"output_tokens":149,"reasoning_output_tokens":15,"total_tokens":75813},"total_token_usage":{"cached_input_tokens":5030656,"input_tokens":5505671,"output_tokens":15226,"reasoning_output_tokens":4493,"total_tokens":5520897}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:00:52Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":75648,"input_tokens":76399,"output_tokens":2441,"reasoning_output_tokens":2070,"total_tokens":78840},"total_token_usage":{"cached_input_tokens":5106304,"input_tokens":5582070,"output_tokens":17667,"reasoning_output_tokens":6563,"total_tokens":5599737}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:00:53Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":20864,"input_tokens":77169,"output_tokens":514,"reasoning_output_tokens":196,"total_tokens":77683},"total_token_usage":{"cached_input_tokens":5127168,"input_tokens":5659239,"output_tokens":18181,"reasoning_output_tokens":6759,"total_tokens":5677420}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:00:54Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":76672,"input_tokens":89835,"output_tokens":364,"reasoning_output_tokens":30,"total_tokens":90199},"total_token_usage":{"cached_input_tokens":5203840,"input_tokens":5749074,"output_tokens":18545,"reasoning_output_tokens":6789,"total_tokens":5767619}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:00:55Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":89472,"input_tokens":94325,"output_tokens":186,"reasoning_output_tokens":101,"total_tokens":94511},"total_token_usage":{"cached_input_tokens":5293312,"input_tokens":5843399,"output_tokens":18731,"reasoning_output_tokens":6890,"total_tokens":5862130}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:00:56Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":51072,"input_tokens":95463,"output_tokens":67,"reasoning_output_tokens":0,"total_tokens":95530},"total_token_usage":{"cached_input_tokens":5344384,"input_tokens":5938862,"output_tokens":18798,"reasoning_output_tokens":6890,"total_tokens":5957660}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:00:57Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":95104,"input_tokens":96285,"output_tokens":792,"reasoning_output_tokens":516,"total_tokens":97077},"total_token_usage":{"cached_input_tokens":5439488,"input_tokens":6035147,"output_tokens":19590,"reasoning_output_tokens":7406,"total_tokens":6054737}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:00:58Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":96128,"input_tokens":100088,"output_tokens":123,"reasoning_output_tokens":0,"total_tokens":100211},"total_token_usage":{"cached_input_tokens":5535616,"input_tokens":6135235,"output_tokens":19713,"reasoning_output_tokens":7406,"total_tokens":6154948}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:00:59Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":99712,"input_tokens":101331,"output_tokens":2255,"reasoning_output_tokens":770,"total_tokens":103586},"total_token_usage":{"cached_input_tokens":5635328,"input_tokens":6236566,"output_tokens":21968,"reasoning_output_tokens":8176,"total_tokens":6258534}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:01:00Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":94080,"input_tokens":103675,"output_tokens":622,"reasoning_output_tokens":516,"total_tokens":104297},"total_token_usage":{"cached_input_tokens":5729408,"input_tokens":6340241,"output_tokens":22590,"reasoning_output_tokens":8692,"total_tokens":6362831}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:01:01Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":101248,"input_tokens":105530,"output_tokens":155,"reasoning_output_tokens":9,"total_tokens":105685},"total_token_usage":{"cached_input_tokens":5830656,"input_tokens":6445771,"output_tokens":22745,"reasoning_output_tokens":8701,"total_tokens":6468516}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:01:02Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":105344,"input_tokens":105946,"output_tokens":83,"reasoning_output_tokens":7,"total_tokens":106029},"total_token_usage":{"cached_input_tokens":5936000,"input_tokens":6551717,"output_tokens":22828,"reasoning_output_tokens":8708,"total_tokens":6574545}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:01:03Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":103296,"input_tokens":118412,"output_tokens":212,"reasoning_output_tokens":114,"total_tokens":118624},"total_token_usage":{"cached_input_tokens":6039296,"input_tokens":6670129,"output_tokens":23040,"reasoning_output_tokens":8822,"total_tokens":6693169}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:01:04Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":118144,"input_tokens":130657,"output_tokens":148,"reasoning_output_tokens":16,"total_tokens":130805},"total_token_usage":{"cached_input_tokens":6157440,"input_tokens":6800786,"output_tokens":23188,"reasoning_output_tokens":8838,"total_tokens":6823974}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:01:05Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":130432,"input_tokens":142816,"output_tokens":323,"reasoning_output_tokens":175,"total_tokens":143139},"total_token_usage":{"cached_input_tokens":6287872,"input_tokens":6943602,"output_tokens":23511,"reasoning_output_tokens":9013,"total_tokens":6967113}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:01:06Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":142720,"input_tokens":152606,"output_tokens":95,"reasoning_output_tokens":16,"total_tokens":152701},"total_token_usage":{"cached_input_tokens":6430592,"input_tokens":7096208,"output_tokens":23606,"reasoning_output_tokens":9029,"total_tokens":7119814}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:01:07Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":152448,"input_tokens":162280,"output_tokens":106,"reasoning_output_tokens":26,"total_tokens":162386},"total_token_usage":{"cached_input_tokens":6583040,"input_tokens":7258488,"output_tokens":23712,"reasoning_output_tokens":9055,"total_tokens":7282200}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:01:08Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":162176,"input_tokens":174122,"output_tokens":91,"reasoning_output_tokens":14,"total_tokens":174213},"total_token_usage":{"cached_input_tokens":6745216,"input_tokens":7432610,"output_tokens":23803,"reasoning_output_tokens":9069,"total_tokens":7456413}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:01:09Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":173952,"input_tokens":185148,"output_tokens":94,"reasoning_output_tokens":12,"total_tokens":185242},"total_token_usage":{"cached_input_tokens":6919168,"input_tokens":7617758,"output_tokens":23897,"reasoning_output_tokens":9081,"total_tokens":7641655}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:01:10Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":184704,"input_tokens":194748,"output_tokens":83,"reasoning_output_tokens":11,"total_tokens":194831},"total_token_usage":{"cached_input_tokens":7103872,"input_tokens":7812506,"output_tokens":23980,"reasoning_output_tokens":9092,"total_tokens":7836486}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:01:11Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":194432,"input_tokens":203640,"output_tokens":96,"reasoning_output_tokens":20,"total_tokens":203736},"total_token_usage":{"cached_input_tokens":7298304,"input_tokens":8016146,"output_tokens":24076,"reasoning_output_tokens":9112,"total_tokens":8040222}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:01:12Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":203136,"input_tokens":217640,"output_tokens":86,"reasoning_output_tokens":14,"total_tokens":217726},"total_token_usage":{"cached_input_tokens":7501440,"input_tokens":8233786,"output_tokens":24162,"reasoning_output_tokens":9126,"total_tokens":8257948}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:01:13Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":217472,"input_tokens":230527,"output_tokens":96,"reasoning_output_tokens":14,"total_tokens":230623},"total_token_usage":{"cached_input_tokens":7718912,"input_tokens":8464313,"output_tokens":24258,"reasoning_output_tokens":9140,"total_tokens":8488571}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:01:14Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":0,"input_tokens":0,"output_tokens":0,"reasoning_output_tokens":0,"total_tokens":17858},"total_token_usage":{"cached_input_tokens":7718912,"input_tokens":8464313,"output_tokens":24258,"reasoning_output_tokens":9140,"total_tokens":8488571}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:01:15Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":4992,"input_tokens":22167,"output_tokens":238,"reasoning_output_tokens":0,"total_tokens":22405},"total_token_usage":{"cached_input_tokens":7723904,"input_tokens":8486480,"output_tokens":24496,"reasoning_output_tokens":9140,"total_tokens":8510976}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:01:16Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":21888,"input_tokens":22911,"output_tokens":805,"reasoning_output_tokens":476,"total_tokens":23716},"total_token_usage":{"cached_input_tokens":7745792,"input_tokens":8509391,"output_tokens":25301,"reasoning_output_tokens":9616,"total_tokens":8534692}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:01:17Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":10624,"input_tokens":24270,"output_tokens":661,"reasoning_output_tokens":501,"total_tokens":24931},"total_token_usage":{"cached_input_tokens":7756416,"input_tokens":8533661,"output_tokens":25962,"reasoning_output_tokens":10117,"total_tokens":8559623}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:01:18Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":22400,"input_tokens":25184,"output_tokens":99,"reasoning_output_tokens":0,"total_tokens":25283},"total_token_usage":{"cached_input_tokens":7778816,"input_tokens":8558845,"output_tokens":26061,"reasoning_output_tokens":10117,"total_tokens":8584906}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:01:19Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":23936,"input_tokens":25344,"output_tokens":142,"reasoning_output_tokens":0,"total_tokens":25486},"total_token_usage":{"cached_input_tokens":7802752,"input_tokens":8584189,"output_tokens":26203,"reasoning_output_tokens":10117,"total_tokens":8610392}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:01:20Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":24960,"input_tokens":25558,"output_tokens":198,"reasoning_output_tokens":0,"total_tokens":25756},"total_token_usage":{"cached_input_tokens":7827712,"input_tokens":8609747,"output_tokens":26401,"reasoning_output_tokens":10117,"total_tokens":8636148}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:01:21Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":24960,"input_tokens":25800,"output_tokens":158,"reasoning_output_tokens":11,"total_tokens":25958},"total_token_usage":{"cached_input_tokens":7852672,"input_tokens":8635547,"output_tokens":26559,"reasoning_output_tokens":10128,"total_tokens":8662106}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:01:22Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":25472,"input_tokens":26276,"output_tokens":82,"reasoning_output_tokens":0,"total_tokens":26358},"total_token_usage":{"cached_input_tokens":7878144,"input_tokens":8661823,"output_tokens":26641,"reasoning_output_tokens":10128,"total_tokens":8688464}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:01:23Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":25984,"input_tokens":26443,"output_tokens":160,"reasoning_output_tokens":13,"total_tokens":26603},"total_token_usage":{"cached_input_tokens":7904128,"input_tokens":8688266,"output_tokens":26801,"reasoning_output_tokens":10141,"total_tokens":8715067}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:01:24Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":25472,"input_tokens":26708,"output_tokens":230,"reasoning_output_tokens":95,"total_tokens":26938},"total_token_usage":{"cached_input_tokens":7929600,"input_tokens":8714974,"output_tokens":27031,"reasoning_output_tokens":10236,"total_tokens":8742005}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:01:25Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":4992,"input_tokens":28685,"output_tokens":738,"reasoning_output_tokens":516,"total_tokens":29423},"total_token_usage":{"cached_input_tokens":7934592,"input_tokens":8743659,"output_tokens":27769,"reasoning_output_tokens":10752,"total_tokens":8771428}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:01:26Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":28544,"input_tokens":29652,"output_tokens":173,"reasoning_output_tokens":79,"total_tokens":29825},"total_token_usage":{"cached_input_tokens":7963136,"input_tokens":8773311,"output_tokens":27942,"reasoning_output_tokens":10831,"total_tokens":8801253}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:01:27Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":29568,"input_tokens":29896,"output_tokens":128,"reasoning_output_tokens":0,"total_tokens":30024},"total_token_usage":{"cached_input_tokens":7992704,"input_tokens":8803207,"output_tokens":28070,"reasoning_output_tokens":10831,"total_tokens":8831277}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:01:28Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":29568,"input_tokens":30083,"output_tokens":176,"reasoning_output_tokens":53,"total_tokens":30259},"total_token_usage":{"cached_input_tokens":8022272,"input_tokens":8833290,"output_tokens":28246,"reasoning_output_tokens":10884,"total_tokens":8861536}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:01:29Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":30080,"input_tokens":30365,"output_tokens":133,"reasoning_output_tokens":45,"total_tokens":30498},"total_token_usage":{"cached_input_tokens":8052352,"input_tokens":8863655,"output_tokens":28379,"reasoning_output_tokens":10929,"total_tokens":8892034}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:01:30Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":30080,"input_tokens":30560,"output_tokens":109,"reasoning_output_tokens":0,"total_tokens":30669},"total_token_usage":{"cached_input_tokens":8082432,"input_tokens":8894215,"output_tokens":28488,"reasoning_output_tokens":10929,"total_tokens":8922703}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:01:31Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":30080,"input_tokens":30741,"output_tokens":276,"reasoning_output_tokens":115,"total_tokens":31017},"total_token_usage":{"cached_input_tokens":8112512,"input_tokens":8924956,"output_tokens":28764,"reasoning_output_tokens":11044,"total_tokens":8953720}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:01:32Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":30592,"input_tokens":31183,"output_tokens":147,"reasoning_output_tokens":0,"total_tokens":31330},"total_token_usage":{"cached_input_tokens":8143104,"input_tokens":8956139,"output_tokens":28911,"reasoning_output_tokens":11044,"total_tokens":8985050}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:01:33Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":31104,"input_tokens":31376,"output_tokens":74,"reasoning_output_tokens":11,"total_tokens":31450},"total_token_usage":{"cached_input_tokens":8174208,"input_tokens":8987515,"output_tokens":28985,"reasoning_output_tokens":11055,"total_tokens":9016500}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:01:34Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":31104,"input_tokens":31621,"output_tokens":159,"reasoning_output_tokens":10,"total_tokens":31780},"total_token_usage":{"cached_input_tokens":8205312,"input_tokens":9019136,"output_tokens":29144,"reasoning_output_tokens":11065,"total_tokens":9048280}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:01:35Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":31616,"input_tokens":31893,"output_tokens":558,"reasoning_output_tokens":353,"total_tokens":32451},"total_token_usage":{"cached_input_tokens":8236928,"input_tokens":9051029,"output_tokens":29702,"reasoning_output_tokens":11418,"total_tokens":9080731}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:01:36Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":4992,"input_tokens":31182,"output_tokens":338,"reasoning_output_tokens":107,"total_tokens":31520},"total_token_usage":{"cached_input_tokens":8241920,"input_tokens":9082211,"output_tokens":30040,"reasoning_output_tokens":11525,"total_tokens":9112251}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:01:37Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":10624,"input_tokens":31422,"output_tokens":383,"reasoning_output_tokens":242,"total_tokens":31805},"total_token_usage":{"cached_input_tokens":8252544,"input_tokens":9113633,"output_tokens":30423,"reasoning_output_tokens":11767,"total_tokens":9144056}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:01:38Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":31104,"input_tokens":32571,"output_tokens":146,"reasoning_output_tokens":66,"total_tokens":32717},"total_token_usage":{"cached_input_tokens":8283648,"input_tokens":9146204,"output_tokens":30569,"reasoning_output_tokens":11833,"total_tokens":9176773}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:01:39Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":32128,"input_tokens":34260,"output_tokens":213,"reasoning_output_tokens":54,"total_tokens":34473},"total_token_usage":{"cached_input_tokens":8315776,"input_tokens":9180464,"output_tokens":30782,"reasoning_output_tokens":11887,"total_tokens":9211246}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:01:40Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":34176,"input_tokens":34550,"output_tokens":160,"reasoning_output_tokens":79,"total_tokens":34710},"total_token_usage":{"cached_input_tokens":8349952,"input_tokens":9215014,"output_tokens":30942,"reasoning_output_tokens":11966,"total_tokens":9245956}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:01:41Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":34176,"input_tokens":37512,"output_tokens":189,"reasoning_output_tokens":84,"total_tokens":37701},"total_token_usage":{"cached_input_tokens":8384128,"input_tokens":9252526,"output_tokens":31131,"reasoning_output_tokens":12050,"total_tokens":9283657}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:01:42Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":37248,"input_tokens":41747,"output_tokens":146,"reasoning_output_tokens":52,"total_tokens":41893},"total_token_usage":{"cached_input_tokens":8421376,"input_tokens":9294273,"output_tokens":31277,"reasoning_output_tokens":12102,"total_tokens":9325550}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:01:43Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":4992,"input_tokens":42288,"output_tokens":343,"reasoning_output_tokens":54,"total_tokens":42631},"total_token_usage":{"cached_input_tokens":8426368,"input_tokens":9336561,"output_tokens":31620,"reasoning_output_tokens":12156,"total_tokens":9368181}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:01:44Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":41856,"input_tokens":47980,"output_tokens":466,"reasoning_output_tokens":180,"total_tokens":48446},"total_token_usage":{"cached_input_tokens":8468224,"input_tokens":9384541,"output_tokens":32086,"reasoning_output_tokens":12336,"total_tokens":9416627}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:01:45Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":47488,"input_tokens":48728,"output_tokens":270,"reasoning_output_tokens":60,"total_tokens":48998},"total_token_usage":{"cached_input_tokens":8515712,"input_tokens":9433269,"output_tokens":32356,"reasoning_output_tokens":12396,"total_tokens":9465625}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:01:46Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":48512,"input_tokens":49417,"output_tokens":382,"reasoning_output_tokens":80,"total_tokens":49799},"total_token_usage":{"cached_input_tokens":8564224,"input_tokens":9482686,"output_tokens":32738,"reasoning_output_tokens":12476,"total_tokens":9515424}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:01:47Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":49024,"input_tokens":51478,"output_tokens":533,"reasoning_output_tokens":364,"total_tokens":52011},"total_token_usage":{"cached_input_tokens":8613248,"input_tokens":9534164,"output_tokens":33271,"reasoning_output_tokens":12840,"total_tokens":9567435}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:01:48Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":41344,"input_tokens":52090,"output_tokens":310,"reasoning_output_tokens":28,"total_tokens":52400},"total_token_usage":{"cached_input_tokens":8654592,"input_tokens":9586254,"output_tokens":33581,"reasoning_output_tokens":12868,"total_tokens":9619835}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:01:49Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":51584,"input_tokens":66881,"output_tokens":91,"reasoning_output_tokens":16,"total_tokens":66972},"total_token_usage":{"cached_input_tokens":8706176,"input_tokens":9653135,"output_tokens":33672,"reasoning_output_tokens":12884,"total_tokens":9686807}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:01:50Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":66432,"input_tokens":68134,"output_tokens":663,"reasoning_output_tokens":448,"total_tokens":68797},"total_token_usage":{"cached_input_tokens":8772608,"input_tokens":9721269,"output_tokens":34335,"reasoning_output_tokens":13332,"total_tokens":9755604}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:01:51Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":67968,"input_tokens":68845,"output_tokens":337,"reasoning_output_tokens":0,"total_tokens":69182},"total_token_usage":{"cached_input_tokens":8840576,"input_tokens":9790114,"output_tokens":34672,"reasoning_output_tokens":13332,"total_tokens":9824786}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:01:52Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":68480,"input_tokens":69232,"output_tokens":610,"reasoning_output_tokens":0,"total_tokens":69842},"total_token_usage":{"cached_input_tokens":8909056,"input_tokens":9859346,"output_tokens":35282,"reasoning_output_tokens":13332,"total_tokens":9894628}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:01:53Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":68992,"input_tokens":69892,"output_tokens":382,"reasoning_output_tokens":244,"total_tokens":70274},"total_token_usage":{"cached_input_tokens":8978048,"input_tokens":9929238,"output_tokens":35664,"reasoning_output_tokens":13576,"total_tokens":9964902}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:01:54Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":51072,"input_tokens":74724,"output_tokens":75,"reasoning_output_tokens":7,"total_tokens":74799},"total_token_usage":{"cached_input_tokens":9029120,"input_tokens":10003962,"output_tokens":35739,"reasoning_output_tokens":13583,"total_tokens":10039701}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:01:55Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":74624,"input_tokens":76542,"output_tokens":66,"reasoning_output_tokens":7,"total_tokens":76608},"total_token_usage":{"cached_input_tokens":9103744,"input_tokens":10080504,"output_tokens":35805,"reasoning_output_tokens":13590,"total_tokens":10116309}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:01:56Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":69504,"input_tokens":79951,"output_tokens":78,"reasoning_output_tokens":10,"total_tokens":80029},"total_token_usage":{"cached_input_tokens":9173248,"input_tokens":10160455,"output_tokens":35883,"reasoning_output_tokens":13600,"total_tokens":10196338}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:01:57Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":76160,"input_tokens":83234,"output_tokens":75,"reasoning_output_tokens":6,"total_tokens":83309},"total_token_usage":{"cached_input_tokens":9249408,"input_tokens":10243689,"output_tokens":35958,"reasoning_output_tokens":13606,"total_tokens":10279647}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:01:58Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":82816,"input_tokens":89587,"output_tokens":79,"reasoning_output_tokens":6,"total_tokens":89666},"total_token_usage":{"cached_input_tokens":9332224,"input_tokens":10333276,"output_tokens":36037,"reasoning_output_tokens":13612,"total_tokens":10369313}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:01:59Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":89472,"input_tokens":102007,"output_tokens":153,"reasoning_output_tokens":22,"total_tokens":102160},"total_token_usage":{"cached_input_tokens":9421696,"input_tokens":10435283,"output_tokens":36190,"reasoning_output_tokens":13634,"total_tokens":10471473}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:02:00Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":79744,"input_tokens":114177,"output_tokens":144,"reasoning_output_tokens":20,"total_tokens":114321},"total_token_usage":{"cached_input_tokens":9501440,"input_tokens":10549460,"output_tokens":36334,"reasoning_output_tokens":13654,"total_tokens":10585794}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:02:01Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":114048,"input_tokens":123786,"output_tokens":88,"reasoning_output_tokens":17,"total_tokens":123874},"total_token_usage":{"cached_input_tokens":9615488,"input_tokens":10673246,"output_tokens":36422,"reasoning_output_tokens":13671,"total_tokens":10709668}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:02:02Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":101760,"input_tokens":134637,"output_tokens":72,"reasoning_output_tokens":7,"total_tokens":134709},"total_token_usage":{"cached_input_tokens":9717248,"input_tokens":10807883,"output_tokens":36494,"reasoning_output_tokens":13678,"total_tokens":10844377}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:02:03Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":134528,"input_tokens":146641,"output_tokens":87,"reasoning_output_tokens":13,"total_tokens":146728},"total_token_usage":{"cached_input_tokens":9851776,"input_tokens":10954524,"output_tokens":36581,"reasoning_output_tokens":13691,"total_tokens":10991105}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:02:04Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":123776,"input_tokens":158050,"output_tokens":67,"reasoning_output_tokens":6,"total_tokens":158117},"total_token_usage":{"cached_input_tokens":9975552,"input_tokens":11112574,"output_tokens":36648,"reasoning_output_tokens":13697,"total_tokens":11149222}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:02:05Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":146304,"input_tokens":167477,"output_tokens":68,"reasoning_output_tokens":9,"total_tokens":167545},"total_token_usage":{"cached_input_tokens":10121856,"input_tokens":11280051,"output_tokens":36716,"reasoning_output_tokens":13706,"total_tokens":11316767}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:02:06Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":167296,"input_tokens":177612,"output_tokens":78,"reasoning_output_tokens":10,"total_tokens":177690},"total_token_usage":{"cached_input_tokens":10289152,"input_tokens":11457663,"output_tokens":36794,"reasoning_output_tokens":13716,"total_tokens":11494457}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:02:07Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":177536,"input_tokens":189809,"output_tokens":70,"reasoning_output_tokens":9,"total_tokens":189879},"total_token_usage":{"cached_input_tokens":10466688,"input_tokens":11647472,"output_tokens":36864,"reasoning_output_tokens":13725,"total_tokens":11684336}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:02:08Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":189312,"input_tokens":202957,"output_tokens":70,"reasoning_output_tokens":7,"total_tokens":203027},"total_token_usage":{"cached_input_tokens":10656000,"input_tokens":11850429,"output_tokens":36934,"reasoning_output_tokens":13732,"total_tokens":11887363}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:02:09Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":202624,"input_tokens":213681,"output_tokens":258,"reasoning_output_tokens":20,"total_tokens":213939},"total_token_usage":{"cached_input_tokens":10858624,"input_tokens":12064110,"output_tokens":37192,"reasoning_output_tokens":13752,"total_tokens":12101302}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:02:10Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":213376,"input_tokens":215284,"output_tokens":272,"reasoning_output_tokens":111,"total_tokens":215556},"total_token_usage":{"cached_input_tokens":11072000,"input_tokens":12279394,"output_tokens":37464,"reasoning_output_tokens":13863,"total_tokens":12316858}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:02:11Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":214912,"input_tokens":215602,"output_tokens":83,"reasoning_output_tokens":0,"total_tokens":215685},"total_token_usage":{"cached_input_tokens":11286912,"input_tokens":12494996,"output_tokens":37547,"reasoning_output_tokens":13863,"total_tokens":12532543}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:02:12Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":215424,"input_tokens":215763,"output_tokens":106,"reasoning_output_tokens":0,"total_tokens":215869},"total_token_usage":{"cached_input_tokens":11502336,"input_tokens":12710759,"output_tokens":37653,"reasoning_output_tokens":13863,"total_tokens":12748412}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:02:13Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":215424,"input_tokens":215917,"output_tokens":57,"reasoning_output_tokens":0,"total_tokens":215974},"total_token_usage":{"cached_input_tokens":11717760,"input_tokens":12926676,"output_tokens":37710,"reasoning_output_tokens":13863,"total_tokens":12964386}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:02:14Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":215424,"input_tokens":216066,"output_tokens":316,"reasoning_output_tokens":112,"total_tokens":216382},"total_token_usage":{"cached_input_tokens":11933184,"input_tokens":13142742,"output_tokens":38026,"reasoning_output_tokens":13975,"total_tokens":13180768}}}} +{"type":"event_msg","timestamp":"2030-01-01T15:02:15Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":215936,"input_tokens":216957,"output_tokens":388,"reasoning_output_tokens":185,"total_tokens":217345},"total_token_usage":{"cached_input_tokens":12149120,"input_tokens":13359699,"output_tokens":38414,"reasoning_output_tokens":14160,"total_tokens":13398113}}}} +{"type":"event_msg","timestamp":"2030-01-02T12:00:00Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":0,"cached_input_tokens":0,"output_tokens":0,"reasoning_output_tokens":0,"total_tokens":9148},"total_token_usage":{"input_tokens":13359699,"cached_input_tokens":12149120,"output_tokens":38414,"reasoning_output_tokens":14160,"total_tokens":13398113}}}} +{"type":"event_msg","timestamp":"2030-01-02T12:00:01Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":22553,"cached_input_tokens":10624,"output_tokens":493,"reasoning_output_tokens":303,"total_tokens":23046},"total_token_usage":{"input_tokens":13382252,"cached_input_tokens":12159744,"output_tokens":38907,"reasoning_output_tokens":14463,"total_tokens":13421159}}}} +{"type":"event_msg","timestamp":"2030-01-02T12:00:02Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":95811,"cached_input_tokens":22400,"output_tokens":1680,"reasoning_output_tokens":1433,"total_tokens":97491},"total_token_usage":{"input_tokens":13478063,"cached_input_tokens":12182144,"output_tokens":40587,"reasoning_output_tokens":15896,"total_tokens":13518650}}}} +{"type":"event_msg","timestamp":"2030-01-02T12:00:03Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":89508,"cached_input_tokens":33664,"output_tokens":371,"reasoning_output_tokens":21,"total_tokens":89879},"total_token_usage":{"input_tokens":13567571,"cached_input_tokens":12215808,"output_tokens":40958,"reasoning_output_tokens":15917,"total_tokens":13608529}}}} +{"type":"event_msg","timestamp":"2030-01-02T12:00:04Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":113851,"cached_input_tokens":89472,"output_tokens":2273,"reasoning_output_tokens":1978,"total_tokens":116124},"total_token_usage":{"input_tokens":13681422,"cached_input_tokens":12305280,"output_tokens":43231,"reasoning_output_tokens":17895,"total_tokens":13724653}}}} +{"type":"event_msg","timestamp":"2030-01-02T12:00:05Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":118098,"cached_input_tokens":94592,"output_tokens":1626,"reasoning_output_tokens":1034,"total_tokens":119724},"total_token_usage":{"input_tokens":13799520,"cached_input_tokens":12399872,"output_tokens":44857,"reasoning_output_tokens":18929,"total_tokens":13844377}}}} +{"type":"event_msg","timestamp":"2030-01-02T12:00:06Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":116068,"cached_input_tokens":22400,"output_tokens":671,"reasoning_output_tokens":412,"total_tokens":116739},"total_token_usage":{"input_tokens":13915588,"cached_input_tokens":12422272,"output_tokens":45528,"reasoning_output_tokens":19341,"total_tokens":13961116}}}} +{"type":"event_msg","timestamp":"2030-01-02T12:00:07Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":121279,"cached_input_tokens":115584,"output_tokens":478,"reasoning_output_tokens":241,"total_tokens":121757},"total_token_usage":{"input_tokens":14036867,"cached_input_tokens":12537856,"output_tokens":46006,"reasoning_output_tokens":19582,"total_tokens":14082873}}}} +{"type":"event_msg","timestamp":"2030-01-02T12:00:08Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":123051,"cached_input_tokens":121216,"output_tokens":1537,"reasoning_output_tokens":1034,"total_tokens":124588},"total_token_usage":{"input_tokens":14159918,"cached_input_tokens":12659072,"output_tokens":47543,"reasoning_output_tokens":20616,"total_tokens":14207461}}}} +{"type":"event_msg","timestamp":"2030-01-02T12:00:09Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":122928,"cached_input_tokens":4992,"output_tokens":773,"reasoning_output_tokens":516,"total_tokens":123701},"total_token_usage":{"input_tokens":14282846,"cached_input_tokens":12664064,"output_tokens":48316,"reasoning_output_tokens":21132,"total_tokens":14331162}}}} +{"type":"event_msg","timestamp":"2030-01-02T12:00:10Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":59120,"cached_input_tokens":4992,"output_tokens":584,"reasoning_output_tokens":431,"total_tokens":59704},"total_token_usage":{"input_tokens":14341966,"cached_input_tokens":12669056,"output_tokens":48900,"reasoning_output_tokens":21563,"total_tokens":14390866}}}} +{"type":"event_msg","timestamp":"2030-01-02T12:00:11Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":60082,"cached_input_tokens":58752,"output_tokens":378,"reasoning_output_tokens":182,"total_tokens":60460},"total_token_usage":{"input_tokens":14402048,"cached_input_tokens":12727808,"output_tokens":49278,"reasoning_output_tokens":21745,"total_tokens":14451326}}}} +{"type":"event_msg","timestamp":"2030-01-02T12:00:12Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":60616,"cached_input_tokens":59776,"output_tokens":243,"reasoning_output_tokens":43,"total_tokens":60859},"total_token_usage":{"input_tokens":14462664,"cached_input_tokens":12787584,"output_tokens":49521,"reasoning_output_tokens":21788,"total_tokens":14512185}}}} +{"type":"event_msg","timestamp":"2030-01-02T12:00:13Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":61155,"cached_input_tokens":60288,"output_tokens":321,"reasoning_output_tokens":78,"total_tokens":61476},"total_token_usage":{"input_tokens":14523819,"cached_input_tokens":12847872,"output_tokens":49842,"reasoning_output_tokens":21866,"total_tokens":14573661}}}} +{"type":"event_msg","timestamp":"2030-01-02T12:00:14Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":73915,"cached_input_tokens":60800,"output_tokens":325,"reasoning_output_tokens":10,"total_tokens":74240},"total_token_usage":{"input_tokens":14597734,"cached_input_tokens":12908672,"output_tokens":50167,"reasoning_output_tokens":21876,"total_tokens":14647901}}}} +{"type":"event_msg","timestamp":"2030-01-02T12:00:15Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":75516,"cached_input_tokens":73600,"output_tokens":464,"reasoning_output_tokens":145,"total_tokens":75980},"total_token_usage":{"input_tokens":14673250,"cached_input_tokens":12982272,"output_tokens":50631,"reasoning_output_tokens":22021,"total_tokens":14723881}}}} +{"type":"event_msg","timestamp":"2030-01-02T12:00:16Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":79574,"cached_input_tokens":75136,"output_tokens":626,"reasoning_output_tokens":262,"total_tokens":80200},"total_token_usage":{"input_tokens":14752824,"cached_input_tokens":13057408,"output_tokens":51257,"reasoning_output_tokens":22283,"total_tokens":14804081}}}} +{"type":"event_msg","timestamp":"2030-01-02T12:00:17Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":80602,"cached_input_tokens":79232,"output_tokens":1309,"reasoning_output_tokens":814,"total_tokens":81911},"total_token_usage":{"input_tokens":14833426,"cached_input_tokens":13136640,"output_tokens":52566,"reasoning_output_tokens":23097,"total_tokens":14885992}}}} +{"type":"event_msg","timestamp":"2030-01-02T12:00:18Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":82258,"cached_input_tokens":80256,"output_tokens":590,"reasoning_output_tokens":248,"total_tokens":82848},"total_token_usage":{"input_tokens":14915684,"cached_input_tokens":13216896,"output_tokens":53156,"reasoning_output_tokens":23345,"total_tokens":14968840}}}} +{"type":"event_msg","timestamp":"2030-01-02T12:00:19Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":83280,"cached_input_tokens":81792,"output_tokens":332,"reasoning_output_tokens":123,"total_tokens":83612},"total_token_usage":{"input_tokens":14998964,"cached_input_tokens":13298688,"output_tokens":53488,"reasoning_output_tokens":23468,"total_tokens":15052452}}}} +{"type":"event_msg","timestamp":"2030-01-02T12:00:20Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":83775,"cached_input_tokens":82816,"output_tokens":1197,"reasoning_output_tokens":993,"total_tokens":84972},"total_token_usage":{"input_tokens":15082739,"cached_input_tokens":13381504,"output_tokens":54685,"reasoning_output_tokens":24461,"total_tokens":15137424}}}} +{"type":"event_msg","timestamp":"2030-01-02T12:00:21Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":85116,"cached_input_tokens":83328,"output_tokens":188,"reasoning_output_tokens":40,"total_tokens":85304},"total_token_usage":{"input_tokens":15167855,"cached_input_tokens":13464832,"output_tokens":54873,"reasoning_output_tokens":24501,"total_tokens":15222728}}}} +{"type":"event_msg","timestamp":"2030-01-02T12:00:22Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":85428,"cached_input_tokens":84864,"output_tokens":1022,"reasoning_output_tokens":516,"total_tokens":86450},"total_token_usage":{"input_tokens":15253283,"cached_input_tokens":13549696,"output_tokens":55895,"reasoning_output_tokens":25017,"total_tokens":15309178}}}} diff --git a/Tests/CodexBarTests/Fixtures/CostUsage/Issue2037/missing-parent-siblings/codex-home/archived_sessions/sibling-b.jsonl b/Tests/CodexBarTests/Fixtures/CostUsage/Issue2037/missing-parent-siblings/codex-home/archived_sessions/sibling-b.jsonl new file mode 100644 index 0000000000..fd4701df2b --- /dev/null +++ b/Tests/CodexBarTests/Fixtures/CostUsage/Issue2037/missing-parent-siblings/codex-home/archived_sessions/sibling-b.jsonl @@ -0,0 +1,140 @@ +{"type":"session_meta","timestamp":"2030-01-01T15:00:00Z","payload":{"id":"sibling-b-session","forked_from_id":"missing-parent-session","timestamp":"2030-01-01T15:00:00Z"}} +{"type":"turn_context","timestamp":"2030-01-01T15:00:00Z","payload":{"model":"fixture-model","multi_agent_version":"v2","multi_agent_mode":"explicitRequestOnly"}} +{"type":"event_msg","timestamp":"2030-01-01T17:00:01Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":4992,"input_tokens":20273,"output_tokens":406,"reasoning_output_tokens":172,"total_tokens":20679},"total_token_usage":{"cached_input_tokens":4992,"input_tokens":20273,"output_tokens":406,"reasoning_output_tokens":172,"total_tokens":20679}}}} +{"type":"event_msg","timestamp":"2030-01-01T17:00:02Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":4992,"input_tokens":41538,"output_tokens":427,"reasoning_output_tokens":102,"total_tokens":41965},"total_token_usage":{"cached_input_tokens":9984,"input_tokens":61811,"output_tokens":833,"reasoning_output_tokens":274,"total_tokens":62644}}}} +{"type":"event_msg","timestamp":"2030-01-01T17:00:03Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":41344,"input_tokens":46041,"output_tokens":366,"reasoning_output_tokens":25,"total_tokens":46407},"total_token_usage":{"cached_input_tokens":51328,"input_tokens":107852,"output_tokens":1199,"reasoning_output_tokens":299,"total_tokens":109051}}}} +{"type":"event_msg","timestamp":"2030-01-01T17:00:04Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":45952,"input_tokens":51733,"output_tokens":375,"reasoning_output_tokens":12,"total_tokens":52108},"total_token_usage":{"cached_input_tokens":97280,"input_tokens":159585,"output_tokens":1574,"reasoning_output_tokens":311,"total_tokens":161159}}}} +{"type":"event_msg","timestamp":"2030-01-01T17:00:05Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":19840,"input_tokens":62672,"output_tokens":272,"reasoning_output_tokens":0,"total_tokens":62944},"total_token_usage":{"cached_input_tokens":117120,"input_tokens":222257,"output_tokens":1846,"reasoning_output_tokens":311,"total_tokens":224103}}}} +{"type":"event_msg","timestamp":"2030-01-01T17:00:06Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":62336,"input_tokens":77059,"output_tokens":371,"reasoning_output_tokens":16,"total_tokens":77430},"total_token_usage":{"cached_input_tokens":179456,"input_tokens":299316,"output_tokens":2217,"reasoning_output_tokens":327,"total_tokens":301533}}}} +{"type":"event_msg","timestamp":"2030-01-01T17:00:07Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":76672,"input_tokens":87381,"output_tokens":397,"reasoning_output_tokens":50,"total_tokens":87778},"total_token_usage":{"cached_input_tokens":256128,"input_tokens":386697,"output_tokens":2614,"reasoning_output_tokens":377,"total_tokens":389311}}}} +{"type":"event_msg","timestamp":"2030-01-01T17:00:08Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":86912,"input_tokens":91474,"output_tokens":471,"reasoning_output_tokens":233,"total_tokens":91945},"total_token_usage":{"cached_input_tokens":343040,"input_tokens":478171,"output_tokens":3085,"reasoning_output_tokens":610,"total_tokens":481256}}}} +{"type":"event_msg","timestamp":"2030-01-01T17:00:09Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":91008,"input_tokens":99980,"output_tokens":347,"reasoning_output_tokens":0,"total_tokens":100327},"total_token_usage":{"cached_input_tokens":434048,"input_tokens":578151,"output_tokens":3432,"reasoning_output_tokens":610,"total_tokens":581583}}}} +{"type":"event_msg","timestamp":"2030-01-01T17:00:10Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":99712,"input_tokens":104328,"output_tokens":490,"reasoning_output_tokens":155,"total_tokens":104818},"total_token_usage":{"cached_input_tokens":533760,"input_tokens":682479,"output_tokens":3922,"reasoning_output_tokens":765,"total_tokens":686401}}}} +{"type":"event_msg","timestamp":"2030-01-01T17:00:11Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":104320,"input_tokens":109064,"output_tokens":309,"reasoning_output_tokens":63,"total_tokens":109373},"total_token_usage":{"cached_input_tokens":638080,"input_tokens":791543,"output_tokens":4231,"reasoning_output_tokens":828,"total_tokens":795774}}}} +{"type":"event_msg","timestamp":"2030-01-01T17:00:12Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":108928,"input_tokens":111249,"output_tokens":275,"reasoning_output_tokens":0,"total_tokens":111524},"total_token_usage":{"cached_input_tokens":747008,"input_tokens":902792,"output_tokens":4506,"reasoning_output_tokens":828,"total_tokens":907298}}}} +{"type":"event_msg","timestamp":"2030-01-01T17:00:13Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":51584,"input_tokens":114730,"output_tokens":301,"reasoning_output_tokens":55,"total_tokens":115031},"total_token_usage":{"cached_input_tokens":798592,"input_tokens":1017522,"output_tokens":4807,"reasoning_output_tokens":883,"total_tokens":1022329}}}} +{"type":"event_msg","timestamp":"2030-01-01T17:00:14Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":110976,"input_tokens":117769,"output_tokens":241,"reasoning_output_tokens":93,"total_tokens":118010},"total_token_usage":{"cached_input_tokens":909568,"input_tokens":1135291,"output_tokens":5048,"reasoning_output_tokens":976,"total_tokens":1140339}}}} +{"type":"event_msg","timestamp":"2030-01-01T17:00:15Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":117632,"input_tokens":119430,"output_tokens":981,"reasoning_output_tokens":679,"total_tokens":120411},"total_token_usage":{"cached_input_tokens":1027200,"input_tokens":1254721,"output_tokens":6029,"reasoning_output_tokens":1655,"total_tokens":1260750}}}} +{"type":"event_msg","timestamp":"2030-01-01T17:00:16Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":119168,"input_tokens":123097,"output_tokens":218,"reasoning_output_tokens":0,"total_tokens":123315},"total_token_usage":{"cached_input_tokens":1146368,"input_tokens":1377818,"output_tokens":6247,"reasoning_output_tokens":1655,"total_tokens":1384065}}}} +{"type":"event_msg","timestamp":"2030-01-01T17:00:17Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":122752,"input_tokens":126343,"output_tokens":208,"reasoning_output_tokens":0,"total_tokens":126551},"total_token_usage":{"cached_input_tokens":1269120,"input_tokens":1504161,"output_tokens":6455,"reasoning_output_tokens":1655,"total_tokens":1510616}}}} +{"type":"event_msg","timestamp":"2030-01-01T17:00:18Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":126336,"input_tokens":127901,"output_tokens":438,"reasoning_output_tokens":235,"total_tokens":128339},"total_token_usage":{"cached_input_tokens":1395456,"input_tokens":1632062,"output_tokens":6893,"reasoning_output_tokens":1890,"total_tokens":1638955}}}} +{"type":"event_msg","timestamp":"2030-01-01T17:00:19Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":127872,"input_tokens":128401,"output_tokens":518,"reasoning_output_tokens":258,"total_tokens":128919},"total_token_usage":{"cached_input_tokens":1523328,"input_tokens":1760463,"output_tokens":7411,"reasoning_output_tokens":2148,"total_tokens":1767874}}}} +{"type":"event_msg","timestamp":"2030-01-01T17:00:20Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":128384,"input_tokens":130226,"output_tokens":143,"reasoning_output_tokens":0,"total_tokens":130369},"total_token_usage":{"cached_input_tokens":1651712,"input_tokens":1890689,"output_tokens":7554,"reasoning_output_tokens":2148,"total_tokens":1898243}}}} +{"type":"event_msg","timestamp":"2030-01-01T17:00:21Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":129920,"input_tokens":133103,"output_tokens":66,"reasoning_output_tokens":0,"total_tokens":133169},"total_token_usage":{"cached_input_tokens":1781632,"input_tokens":2023792,"output_tokens":7620,"reasoning_output_tokens":2148,"total_tokens":2031412}}}} +{"type":"event_msg","timestamp":"2030-01-01T17:00:22Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":132992,"input_tokens":133538,"output_tokens":982,"reasoning_output_tokens":612,"total_tokens":134520},"total_token_usage":{"cached_input_tokens":1914624,"input_tokens":2157330,"output_tokens":8602,"reasoning_output_tokens":2760,"total_tokens":2165932}}}} +{"type":"event_msg","timestamp":"2030-01-01T17:00:23Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":133504,"input_tokens":134570,"output_tokens":814,"reasoning_output_tokens":0,"total_tokens":135384},"total_token_usage":{"cached_input_tokens":2048128,"input_tokens":2291900,"output_tokens":9416,"reasoning_output_tokens":2760,"total_tokens":2301316}}}} +{"type":"event_msg","timestamp":"2030-01-01T17:00:24Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":134528,"input_tokens":135434,"output_tokens":151,"reasoning_output_tokens":40,"total_tokens":135585},"total_token_usage":{"cached_input_tokens":2182656,"input_tokens":2427334,"output_tokens":9567,"reasoning_output_tokens":2800,"total_tokens":2436901}}}} +{"type":"event_msg","timestamp":"2030-01-01T17:00:25Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":135040,"input_tokens":136816,"output_tokens":201,"reasoning_output_tokens":35,"total_tokens":137017},"total_token_usage":{"cached_input_tokens":2317696,"input_tokens":2564150,"output_tokens":9768,"reasoning_output_tokens":2835,"total_tokens":2573918}}}} +{"type":"event_msg","timestamp":"2030-01-01T17:00:26Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":136576,"input_tokens":139802,"output_tokens":85,"reasoning_output_tokens":11,"total_tokens":139887},"total_token_usage":{"cached_input_tokens":2454272,"input_tokens":2703952,"output_tokens":9853,"reasoning_output_tokens":2846,"total_tokens":2713805}}}} +{"type":"event_msg","timestamp":"2030-01-01T17:00:27Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":139648,"input_tokens":143646,"output_tokens":82,"reasoning_output_tokens":7,"total_tokens":143728},"total_token_usage":{"cached_input_tokens":2593920,"input_tokens":2847598,"output_tokens":9935,"reasoning_output_tokens":2853,"total_tokens":2857533}}}} +{"type":"event_msg","timestamp":"2030-01-01T17:00:28Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":143232,"input_tokens":147052,"output_tokens":90,"reasoning_output_tokens":9,"total_tokens":147142},"total_token_usage":{"cached_input_tokens":2737152,"input_tokens":2994650,"output_tokens":10025,"reasoning_output_tokens":2862,"total_tokens":3004675}}}} +{"type":"event_msg","timestamp":"2030-01-01T17:00:29Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":146816,"input_tokens":148945,"output_tokens":72,"reasoning_output_tokens":6,"total_tokens":149017},"total_token_usage":{"cached_input_tokens":2883968,"input_tokens":3143595,"output_tokens":10097,"reasoning_output_tokens":2868,"total_tokens":3153692}}}} +{"type":"event_msg","timestamp":"2030-01-01T17:00:30Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":148864,"input_tokens":151268,"output_tokens":76,"reasoning_output_tokens":6,"total_tokens":151344},"total_token_usage":{"cached_input_tokens":3032832,"input_tokens":3294863,"output_tokens":10173,"reasoning_output_tokens":2874,"total_tokens":3305036}}}} +{"type":"event_msg","timestamp":"2030-01-01T17:00:31Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":150912,"input_tokens":158145,"output_tokens":143,"reasoning_output_tokens":33,"total_tokens":158288},"total_token_usage":{"cached_input_tokens":3183744,"input_tokens":3453008,"output_tokens":10316,"reasoning_output_tokens":2907,"total_tokens":3463324}}}} +{"type":"event_msg","timestamp":"2030-01-01T17:00:32Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":158080,"input_tokens":170316,"output_tokens":224,"reasoning_output_tokens":59,"total_tokens":170540},"total_token_usage":{"cached_input_tokens":3341824,"input_tokens":3623324,"output_tokens":10540,"reasoning_output_tokens":2966,"total_tokens":3633864}}}} +{"type":"event_msg","timestamp":"2030-01-01T17:00:33Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":169856,"input_tokens":182556,"output_tokens":505,"reasoning_output_tokens":345,"total_tokens":183061},"total_token_usage":{"cached_input_tokens":3511680,"input_tokens":3805880,"output_tokens":11045,"reasoning_output_tokens":3311,"total_tokens":3816925}}}} +{"type":"event_msg","timestamp":"2030-01-01T17:00:34Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":182144,"input_tokens":192775,"output_tokens":106,"reasoning_output_tokens":29,"total_tokens":192881},"total_token_usage":{"cached_input_tokens":3693824,"input_tokens":3998655,"output_tokens":11151,"reasoning_output_tokens":3340,"total_tokens":4009806}}}} +{"type":"event_msg","timestamp":"2030-01-01T17:00:35Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":192384,"input_tokens":204282,"output_tokens":89,"reasoning_output_tokens":7,"total_tokens":204371},"total_token_usage":{"cached_input_tokens":3886208,"input_tokens":4202937,"output_tokens":11240,"reasoning_output_tokens":3347,"total_tokens":4214177}}}} +{"type":"event_msg","timestamp":"2030-01-01T17:00:36Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":204160,"input_tokens":217219,"output_tokens":85,"reasoning_output_tokens":10,"total_tokens":217304},"total_token_usage":{"cached_input_tokens":4090368,"input_tokens":4420156,"output_tokens":11325,"reasoning_output_tokens":3357,"total_tokens":4431481}}}} +{"type":"event_msg","timestamp":"2030-01-01T17:00:37Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":216960,"input_tokens":228774,"output_tokens":101,"reasoning_output_tokens":20,"total_tokens":228875},"total_token_usage":{"cached_input_tokens":4307328,"input_tokens":4648930,"output_tokens":11426,"reasoning_output_tokens":3377,"total_tokens":4660356}}}} +{"type":"event_msg","timestamp":"2030-01-01T17:00:38Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":228736,"input_tokens":238310,"output_tokens":79,"reasoning_output_tokens":7,"total_tokens":238389},"total_token_usage":{"cached_input_tokens":4536064,"input_tokens":4887240,"output_tokens":11505,"reasoning_output_tokens":3384,"total_tokens":4898745}}}} +{"type":"event_msg","timestamp":"2030-01-01T17:00:39Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":0,"input_tokens":0,"output_tokens":0,"reasoning_output_tokens":0,"total_tokens":16650},"total_token_usage":{"cached_input_tokens":4536064,"input_tokens":4887240,"output_tokens":11505,"reasoning_output_tokens":3384,"total_tokens":4898745}}}} +{"type":"event_msg","timestamp":"2030-01-01T17:00:40Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":19840,"input_tokens":21273,"output_tokens":85,"reasoning_output_tokens":0,"total_tokens":21358},"total_token_usage":{"cached_input_tokens":4555904,"input_tokens":4908513,"output_tokens":11590,"reasoning_output_tokens":3384,"total_tokens":4920103}}}} +{"type":"event_msg","timestamp":"2030-01-01T17:00:41Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":18304,"input_tokens":31745,"output_tokens":151,"reasoning_output_tokens":63,"total_tokens":31896},"total_token_usage":{"cached_input_tokens":4574208,"input_tokens":4940258,"output_tokens":11741,"reasoning_output_tokens":3447,"total_tokens":4951999}}}} +{"type":"event_msg","timestamp":"2030-01-01T17:00:42Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":31616,"input_tokens":42752,"output_tokens":96,"reasoning_output_tokens":10,"total_tokens":42848},"total_token_usage":{"cached_input_tokens":4605824,"input_tokens":4983010,"output_tokens":11837,"reasoning_output_tokens":3457,"total_tokens":4994847}}}} +{"type":"event_msg","timestamp":"2030-01-01T17:00:43Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":42368,"input_tokens":47233,"output_tokens":386,"reasoning_output_tokens":141,"total_tokens":47619},"total_token_usage":{"cached_input_tokens":4648192,"input_tokens":5030243,"output_tokens":12223,"reasoning_output_tokens":3598,"total_tokens":5042466}}}} +{"type":"event_msg","timestamp":"2030-01-01T17:00:44Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":46976,"input_tokens":49371,"output_tokens":317,"reasoning_output_tokens":112,"total_tokens":49688},"total_token_usage":{"cached_input_tokens":4695168,"input_tokens":5079614,"output_tokens":12540,"reasoning_output_tokens":3710,"total_tokens":5092154}}}} +{"type":"event_msg","timestamp":"2030-01-01T17:00:45Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":4992,"input_tokens":49856,"output_tokens":392,"reasoning_output_tokens":162,"total_tokens":50248},"total_token_usage":{"cached_input_tokens":4700160,"input_tokens":5129470,"output_tokens":12932,"reasoning_output_tokens":3872,"total_tokens":5142402}}}} +{"type":"event_msg","timestamp":"2030-01-01T17:00:46Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":31616,"input_tokens":51404,"output_tokens":520,"reasoning_output_tokens":131,"total_tokens":51924},"total_token_usage":{"cached_input_tokens":4731776,"input_tokens":5180874,"output_tokens":13452,"reasoning_output_tokens":4003,"total_tokens":5194326}}}} +{"type":"event_msg","timestamp":"2030-01-01T17:00:47Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":51072,"input_tokens":55494,"output_tokens":283,"reasoning_output_tokens":22,"total_tokens":55777},"total_token_usage":{"cached_input_tokens":4782848,"input_tokens":5236368,"output_tokens":13735,"reasoning_output_tokens":4025,"total_tokens":5250103}}}} +{"type":"event_msg","timestamp":"2030-01-01T17:00:48Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":55168,"input_tokens":57511,"output_tokens":390,"reasoning_output_tokens":0,"total_tokens":57901},"total_token_usage":{"cached_input_tokens":4838016,"input_tokens":5293879,"output_tokens":14125,"reasoning_output_tokens":4025,"total_tokens":5308004}}}} +{"type":"event_msg","timestamp":"2030-01-01T17:00:49Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":57216,"input_tokens":65745,"output_tokens":341,"reasoning_output_tokens":0,"total_tokens":66086},"total_token_usage":{"cached_input_tokens":4895232,"input_tokens":5359624,"output_tokens":14466,"reasoning_output_tokens":4025,"total_tokens":5374090}}}} +{"type":"event_msg","timestamp":"2030-01-01T17:00:50Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":65408,"input_tokens":70383,"output_tokens":611,"reasoning_output_tokens":453,"total_tokens":70994},"total_token_usage":{"cached_input_tokens":4960640,"input_tokens":5430007,"output_tokens":15077,"reasoning_output_tokens":4478,"total_tokens":5445084}}}} +{"type":"event_msg","timestamp":"2030-01-01T17:00:51Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":70016,"input_tokens":75664,"output_tokens":149,"reasoning_output_tokens":15,"total_tokens":75813},"total_token_usage":{"cached_input_tokens":5030656,"input_tokens":5505671,"output_tokens":15226,"reasoning_output_tokens":4493,"total_tokens":5520897}}}} +{"type":"event_msg","timestamp":"2030-01-01T17:00:52Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":75648,"input_tokens":76399,"output_tokens":2441,"reasoning_output_tokens":2070,"total_tokens":78840},"total_token_usage":{"cached_input_tokens":5106304,"input_tokens":5582070,"output_tokens":17667,"reasoning_output_tokens":6563,"total_tokens":5599737}}}} +{"type":"event_msg","timestamp":"2030-01-01T17:00:53Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":20864,"input_tokens":77169,"output_tokens":514,"reasoning_output_tokens":196,"total_tokens":77683},"total_token_usage":{"cached_input_tokens":5127168,"input_tokens":5659239,"output_tokens":18181,"reasoning_output_tokens":6759,"total_tokens":5677420}}}} +{"type":"event_msg","timestamp":"2030-01-01T17:00:54Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":76672,"input_tokens":89835,"output_tokens":364,"reasoning_output_tokens":30,"total_tokens":90199},"total_token_usage":{"cached_input_tokens":5203840,"input_tokens":5749074,"output_tokens":18545,"reasoning_output_tokens":6789,"total_tokens":5767619}}}} +{"type":"event_msg","timestamp":"2030-01-01T17:00:55Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":89472,"input_tokens":94325,"output_tokens":186,"reasoning_output_tokens":101,"total_tokens":94511},"total_token_usage":{"cached_input_tokens":5293312,"input_tokens":5843399,"output_tokens":18731,"reasoning_output_tokens":6890,"total_tokens":5862130}}}} +{"type":"event_msg","timestamp":"2030-01-01T17:00:56Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":51072,"input_tokens":95463,"output_tokens":67,"reasoning_output_tokens":0,"total_tokens":95530},"total_token_usage":{"cached_input_tokens":5344384,"input_tokens":5938862,"output_tokens":18798,"reasoning_output_tokens":6890,"total_tokens":5957660}}}} +{"type":"event_msg","timestamp":"2030-01-01T17:00:57Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":95104,"input_tokens":96285,"output_tokens":792,"reasoning_output_tokens":516,"total_tokens":97077},"total_token_usage":{"cached_input_tokens":5439488,"input_tokens":6035147,"output_tokens":19590,"reasoning_output_tokens":7406,"total_tokens":6054737}}}} +{"type":"event_msg","timestamp":"2030-01-01T17:00:58Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":96128,"input_tokens":100088,"output_tokens":123,"reasoning_output_tokens":0,"total_tokens":100211},"total_token_usage":{"cached_input_tokens":5535616,"input_tokens":6135235,"output_tokens":19713,"reasoning_output_tokens":7406,"total_tokens":6154948}}}} +{"type":"event_msg","timestamp":"2030-01-01T17:00:59Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":99712,"input_tokens":101331,"output_tokens":2255,"reasoning_output_tokens":770,"total_tokens":103586},"total_token_usage":{"cached_input_tokens":5635328,"input_tokens":6236566,"output_tokens":21968,"reasoning_output_tokens":8176,"total_tokens":6258534}}}} +{"type":"event_msg","timestamp":"2030-01-01T17:01:00Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":94080,"input_tokens":103675,"output_tokens":622,"reasoning_output_tokens":516,"total_tokens":104297},"total_token_usage":{"cached_input_tokens":5729408,"input_tokens":6340241,"output_tokens":22590,"reasoning_output_tokens":8692,"total_tokens":6362831}}}} +{"type":"event_msg","timestamp":"2030-01-01T17:01:01Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":101248,"input_tokens":105530,"output_tokens":155,"reasoning_output_tokens":9,"total_tokens":105685},"total_token_usage":{"cached_input_tokens":5830656,"input_tokens":6445771,"output_tokens":22745,"reasoning_output_tokens":8701,"total_tokens":6468516}}}} +{"type":"event_msg","timestamp":"2030-01-01T17:01:02Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":105344,"input_tokens":105946,"output_tokens":83,"reasoning_output_tokens":7,"total_tokens":106029},"total_token_usage":{"cached_input_tokens":5936000,"input_tokens":6551717,"output_tokens":22828,"reasoning_output_tokens":8708,"total_tokens":6574545}}}} +{"type":"event_msg","timestamp":"2030-01-01T17:01:03Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":103296,"input_tokens":118412,"output_tokens":212,"reasoning_output_tokens":114,"total_tokens":118624},"total_token_usage":{"cached_input_tokens":6039296,"input_tokens":6670129,"output_tokens":23040,"reasoning_output_tokens":8822,"total_tokens":6693169}}}} +{"type":"event_msg","timestamp":"2030-01-01T17:01:04Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":118144,"input_tokens":130657,"output_tokens":148,"reasoning_output_tokens":16,"total_tokens":130805},"total_token_usage":{"cached_input_tokens":6157440,"input_tokens":6800786,"output_tokens":23188,"reasoning_output_tokens":8838,"total_tokens":6823974}}}} +{"type":"event_msg","timestamp":"2030-01-01T17:01:05Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":130432,"input_tokens":142816,"output_tokens":323,"reasoning_output_tokens":175,"total_tokens":143139},"total_token_usage":{"cached_input_tokens":6287872,"input_tokens":6943602,"output_tokens":23511,"reasoning_output_tokens":9013,"total_tokens":6967113}}}} +{"type":"event_msg","timestamp":"2030-01-01T17:01:06Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":142720,"input_tokens":152606,"output_tokens":95,"reasoning_output_tokens":16,"total_tokens":152701},"total_token_usage":{"cached_input_tokens":6430592,"input_tokens":7096208,"output_tokens":23606,"reasoning_output_tokens":9029,"total_tokens":7119814}}}} +{"type":"event_msg","timestamp":"2030-01-01T17:01:07Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":152448,"input_tokens":162280,"output_tokens":106,"reasoning_output_tokens":26,"total_tokens":162386},"total_token_usage":{"cached_input_tokens":6583040,"input_tokens":7258488,"output_tokens":23712,"reasoning_output_tokens":9055,"total_tokens":7282200}}}} +{"type":"event_msg","timestamp":"2030-01-01T17:01:08Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":162176,"input_tokens":174122,"output_tokens":91,"reasoning_output_tokens":14,"total_tokens":174213},"total_token_usage":{"cached_input_tokens":6745216,"input_tokens":7432610,"output_tokens":23803,"reasoning_output_tokens":9069,"total_tokens":7456413}}}} +{"type":"event_msg","timestamp":"2030-01-01T17:01:09Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":173952,"input_tokens":185148,"output_tokens":94,"reasoning_output_tokens":12,"total_tokens":185242},"total_token_usage":{"cached_input_tokens":6919168,"input_tokens":7617758,"output_tokens":23897,"reasoning_output_tokens":9081,"total_tokens":7641655}}}} +{"type":"event_msg","timestamp":"2030-01-01T17:01:10Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":184704,"input_tokens":194748,"output_tokens":83,"reasoning_output_tokens":11,"total_tokens":194831},"total_token_usage":{"cached_input_tokens":7103872,"input_tokens":7812506,"output_tokens":23980,"reasoning_output_tokens":9092,"total_tokens":7836486}}}} +{"type":"event_msg","timestamp":"2030-01-01T17:01:11Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":194432,"input_tokens":203640,"output_tokens":96,"reasoning_output_tokens":20,"total_tokens":203736},"total_token_usage":{"cached_input_tokens":7298304,"input_tokens":8016146,"output_tokens":24076,"reasoning_output_tokens":9112,"total_tokens":8040222}}}} +{"type":"event_msg","timestamp":"2030-01-01T17:01:12Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":203136,"input_tokens":217640,"output_tokens":86,"reasoning_output_tokens":14,"total_tokens":217726},"total_token_usage":{"cached_input_tokens":7501440,"input_tokens":8233786,"output_tokens":24162,"reasoning_output_tokens":9126,"total_tokens":8257948}}}} +{"type":"event_msg","timestamp":"2030-01-01T17:01:13Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":217472,"input_tokens":230527,"output_tokens":96,"reasoning_output_tokens":14,"total_tokens":230623},"total_token_usage":{"cached_input_tokens":7718912,"input_tokens":8464313,"output_tokens":24258,"reasoning_output_tokens":9140,"total_tokens":8488571}}}} +{"type":"event_msg","timestamp":"2030-01-01T17:01:14Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":0,"input_tokens":0,"output_tokens":0,"reasoning_output_tokens":0,"total_tokens":17858},"total_token_usage":{"cached_input_tokens":7718912,"input_tokens":8464313,"output_tokens":24258,"reasoning_output_tokens":9140,"total_tokens":8488571}}}} +{"type":"event_msg","timestamp":"2030-01-01T17:01:15Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":4992,"input_tokens":22167,"output_tokens":238,"reasoning_output_tokens":0,"total_tokens":22405},"total_token_usage":{"cached_input_tokens":7723904,"input_tokens":8486480,"output_tokens":24496,"reasoning_output_tokens":9140,"total_tokens":8510976}}}} +{"type":"event_msg","timestamp":"2030-01-01T17:01:16Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":21888,"input_tokens":22911,"output_tokens":805,"reasoning_output_tokens":476,"total_tokens":23716},"total_token_usage":{"cached_input_tokens":7745792,"input_tokens":8509391,"output_tokens":25301,"reasoning_output_tokens":9616,"total_tokens":8534692}}}} +{"type":"event_msg","timestamp":"2030-01-01T17:01:17Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":10624,"input_tokens":24270,"output_tokens":661,"reasoning_output_tokens":501,"total_tokens":24931},"total_token_usage":{"cached_input_tokens":7756416,"input_tokens":8533661,"output_tokens":25962,"reasoning_output_tokens":10117,"total_tokens":8559623}}}} +{"type":"event_msg","timestamp":"2030-01-01T17:01:18Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":22400,"input_tokens":25184,"output_tokens":99,"reasoning_output_tokens":0,"total_tokens":25283},"total_token_usage":{"cached_input_tokens":7778816,"input_tokens":8558845,"output_tokens":26061,"reasoning_output_tokens":10117,"total_tokens":8584906}}}} +{"type":"event_msg","timestamp":"2030-01-01T17:01:19Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":23936,"input_tokens":25344,"output_tokens":142,"reasoning_output_tokens":0,"total_tokens":25486},"total_token_usage":{"cached_input_tokens":7802752,"input_tokens":8584189,"output_tokens":26203,"reasoning_output_tokens":10117,"total_tokens":8610392}}}} +{"type":"event_msg","timestamp":"2030-01-01T17:01:20Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":24960,"input_tokens":25558,"output_tokens":198,"reasoning_output_tokens":0,"total_tokens":25756},"total_token_usage":{"cached_input_tokens":7827712,"input_tokens":8609747,"output_tokens":26401,"reasoning_output_tokens":10117,"total_tokens":8636148}}}} +{"type":"event_msg","timestamp":"2030-01-01T17:01:21Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":24960,"input_tokens":25800,"output_tokens":158,"reasoning_output_tokens":11,"total_tokens":25958},"total_token_usage":{"cached_input_tokens":7852672,"input_tokens":8635547,"output_tokens":26559,"reasoning_output_tokens":10128,"total_tokens":8662106}}}} +{"type":"event_msg","timestamp":"2030-01-01T17:01:22Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":25472,"input_tokens":26276,"output_tokens":82,"reasoning_output_tokens":0,"total_tokens":26358},"total_token_usage":{"cached_input_tokens":7878144,"input_tokens":8661823,"output_tokens":26641,"reasoning_output_tokens":10128,"total_tokens":8688464}}}} +{"type":"event_msg","timestamp":"2030-01-01T17:01:23Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":25984,"input_tokens":26443,"output_tokens":160,"reasoning_output_tokens":13,"total_tokens":26603},"total_token_usage":{"cached_input_tokens":7904128,"input_tokens":8688266,"output_tokens":26801,"reasoning_output_tokens":10141,"total_tokens":8715067}}}} +{"type":"event_msg","timestamp":"2030-01-01T17:01:24Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":25472,"input_tokens":26708,"output_tokens":230,"reasoning_output_tokens":95,"total_tokens":26938},"total_token_usage":{"cached_input_tokens":7929600,"input_tokens":8714974,"output_tokens":27031,"reasoning_output_tokens":10236,"total_tokens":8742005}}}} +{"type":"event_msg","timestamp":"2030-01-01T17:01:25Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":4992,"input_tokens":28685,"output_tokens":738,"reasoning_output_tokens":516,"total_tokens":29423},"total_token_usage":{"cached_input_tokens":7934592,"input_tokens":8743659,"output_tokens":27769,"reasoning_output_tokens":10752,"total_tokens":8771428}}}} +{"type":"event_msg","timestamp":"2030-01-01T17:01:26Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":28544,"input_tokens":29652,"output_tokens":173,"reasoning_output_tokens":79,"total_tokens":29825},"total_token_usage":{"cached_input_tokens":7963136,"input_tokens":8773311,"output_tokens":27942,"reasoning_output_tokens":10831,"total_tokens":8801253}}}} +{"type":"event_msg","timestamp":"2030-01-01T17:01:27Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":29568,"input_tokens":29896,"output_tokens":128,"reasoning_output_tokens":0,"total_tokens":30024},"total_token_usage":{"cached_input_tokens":7992704,"input_tokens":8803207,"output_tokens":28070,"reasoning_output_tokens":10831,"total_tokens":8831277}}}} +{"type":"event_msg","timestamp":"2030-01-01T17:01:28Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":29568,"input_tokens":30083,"output_tokens":176,"reasoning_output_tokens":53,"total_tokens":30259},"total_token_usage":{"cached_input_tokens":8022272,"input_tokens":8833290,"output_tokens":28246,"reasoning_output_tokens":10884,"total_tokens":8861536}}}} +{"type":"event_msg","timestamp":"2030-01-01T17:01:29Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":30080,"input_tokens":30365,"output_tokens":133,"reasoning_output_tokens":45,"total_tokens":30498},"total_token_usage":{"cached_input_tokens":8052352,"input_tokens":8863655,"output_tokens":28379,"reasoning_output_tokens":10929,"total_tokens":8892034}}}} +{"type":"event_msg","timestamp":"2030-01-01T17:01:30Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":30080,"input_tokens":30560,"output_tokens":109,"reasoning_output_tokens":0,"total_tokens":30669},"total_token_usage":{"cached_input_tokens":8082432,"input_tokens":8894215,"output_tokens":28488,"reasoning_output_tokens":10929,"total_tokens":8922703}}}} +{"type":"event_msg","timestamp":"2030-01-01T17:01:31Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":30080,"input_tokens":30741,"output_tokens":276,"reasoning_output_tokens":115,"total_tokens":31017},"total_token_usage":{"cached_input_tokens":8112512,"input_tokens":8924956,"output_tokens":28764,"reasoning_output_tokens":11044,"total_tokens":8953720}}}} +{"type":"event_msg","timestamp":"2030-01-01T17:01:32Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":30592,"input_tokens":31183,"output_tokens":147,"reasoning_output_tokens":0,"total_tokens":31330},"total_token_usage":{"cached_input_tokens":8143104,"input_tokens":8956139,"output_tokens":28911,"reasoning_output_tokens":11044,"total_tokens":8985050}}}} +{"type":"event_msg","timestamp":"2030-01-01T17:01:33Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":31104,"input_tokens":31376,"output_tokens":74,"reasoning_output_tokens":11,"total_tokens":31450},"total_token_usage":{"cached_input_tokens":8174208,"input_tokens":8987515,"output_tokens":28985,"reasoning_output_tokens":11055,"total_tokens":9016500}}}} +{"type":"event_msg","timestamp":"2030-01-01T17:01:34Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":31104,"input_tokens":31621,"output_tokens":159,"reasoning_output_tokens":10,"total_tokens":31780},"total_token_usage":{"cached_input_tokens":8205312,"input_tokens":9019136,"output_tokens":29144,"reasoning_output_tokens":11065,"total_tokens":9048280}}}} +{"type":"event_msg","timestamp":"2030-01-01T17:01:35Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":31616,"input_tokens":31893,"output_tokens":558,"reasoning_output_tokens":353,"total_tokens":32451},"total_token_usage":{"cached_input_tokens":8236928,"input_tokens":9051029,"output_tokens":29702,"reasoning_output_tokens":11418,"total_tokens":9080731}}}} +{"type":"event_msg","timestamp":"2030-01-01T17:01:36Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":4992,"input_tokens":31182,"output_tokens":338,"reasoning_output_tokens":107,"total_tokens":31520},"total_token_usage":{"cached_input_tokens":8241920,"input_tokens":9082211,"output_tokens":30040,"reasoning_output_tokens":11525,"total_tokens":9112251}}}} +{"type":"event_msg","timestamp":"2030-01-01T17:01:37Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":10624,"input_tokens":31422,"output_tokens":383,"reasoning_output_tokens":242,"total_tokens":31805},"total_token_usage":{"cached_input_tokens":8252544,"input_tokens":9113633,"output_tokens":30423,"reasoning_output_tokens":11767,"total_tokens":9144056}}}} +{"type":"event_msg","timestamp":"2030-01-01T17:01:38Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":31104,"input_tokens":32571,"output_tokens":146,"reasoning_output_tokens":66,"total_tokens":32717},"total_token_usage":{"cached_input_tokens":8283648,"input_tokens":9146204,"output_tokens":30569,"reasoning_output_tokens":11833,"total_tokens":9176773}}}} +{"type":"event_msg","timestamp":"2030-01-01T17:01:39Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":32128,"input_tokens":34260,"output_tokens":213,"reasoning_output_tokens":54,"total_tokens":34473},"total_token_usage":{"cached_input_tokens":8315776,"input_tokens":9180464,"output_tokens":30782,"reasoning_output_tokens":11887,"total_tokens":9211246}}}} +{"type":"event_msg","timestamp":"2030-01-01T17:01:40Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":34176,"input_tokens":34550,"output_tokens":160,"reasoning_output_tokens":79,"total_tokens":34710},"total_token_usage":{"cached_input_tokens":8349952,"input_tokens":9215014,"output_tokens":30942,"reasoning_output_tokens":11966,"total_tokens":9245956}}}} +{"type":"event_msg","timestamp":"2030-01-01T17:01:41Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":34176,"input_tokens":37512,"output_tokens":189,"reasoning_output_tokens":84,"total_tokens":37701},"total_token_usage":{"cached_input_tokens":8384128,"input_tokens":9252526,"output_tokens":31131,"reasoning_output_tokens":12050,"total_tokens":9283657}}}} +{"type":"event_msg","timestamp":"2030-01-01T17:01:42Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":37248,"input_tokens":41747,"output_tokens":146,"reasoning_output_tokens":52,"total_tokens":41893},"total_token_usage":{"cached_input_tokens":8421376,"input_tokens":9294273,"output_tokens":31277,"reasoning_output_tokens":12102,"total_tokens":9325550}}}} +{"type":"event_msg","timestamp":"2030-01-01T17:01:43Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":4992,"input_tokens":42288,"output_tokens":343,"reasoning_output_tokens":54,"total_tokens":42631},"total_token_usage":{"cached_input_tokens":8426368,"input_tokens":9336561,"output_tokens":31620,"reasoning_output_tokens":12156,"total_tokens":9368181}}}} +{"type":"event_msg","timestamp":"2030-01-01T17:01:44Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":41856,"input_tokens":47980,"output_tokens":466,"reasoning_output_tokens":180,"total_tokens":48446},"total_token_usage":{"cached_input_tokens":8468224,"input_tokens":9384541,"output_tokens":32086,"reasoning_output_tokens":12336,"total_tokens":9416627}}}} +{"type":"event_msg","timestamp":"2030-01-01T17:01:45Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":47488,"input_tokens":48728,"output_tokens":270,"reasoning_output_tokens":60,"total_tokens":48998},"total_token_usage":{"cached_input_tokens":8515712,"input_tokens":9433269,"output_tokens":32356,"reasoning_output_tokens":12396,"total_tokens":9465625}}}} +{"type":"event_msg","timestamp":"2030-01-01T17:01:46Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":48512,"input_tokens":49417,"output_tokens":382,"reasoning_output_tokens":80,"total_tokens":49799},"total_token_usage":{"cached_input_tokens":8564224,"input_tokens":9482686,"output_tokens":32738,"reasoning_output_tokens":12476,"total_tokens":9515424}}}} +{"type":"event_msg","timestamp":"2030-01-01T17:01:47Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":49024,"input_tokens":51478,"output_tokens":533,"reasoning_output_tokens":364,"total_tokens":52011},"total_token_usage":{"cached_input_tokens":8613248,"input_tokens":9534164,"output_tokens":33271,"reasoning_output_tokens":12840,"total_tokens":9567435}}}} +{"type":"event_msg","timestamp":"2030-01-01T17:01:48Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":41344,"input_tokens":52090,"output_tokens":310,"reasoning_output_tokens":28,"total_tokens":52400},"total_token_usage":{"cached_input_tokens":8654592,"input_tokens":9586254,"output_tokens":33581,"reasoning_output_tokens":12868,"total_tokens":9619835}}}} +{"type":"event_msg","timestamp":"2030-01-01T17:01:49Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":51584,"input_tokens":66881,"output_tokens":91,"reasoning_output_tokens":16,"total_tokens":66972},"total_token_usage":{"cached_input_tokens":8706176,"input_tokens":9653135,"output_tokens":33672,"reasoning_output_tokens":12884,"total_tokens":9686807}}}} +{"type":"event_msg","timestamp":"2030-01-01T17:01:50Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":66432,"input_tokens":68134,"output_tokens":663,"reasoning_output_tokens":448,"total_tokens":68797},"total_token_usage":{"cached_input_tokens":8772608,"input_tokens":9721269,"output_tokens":34335,"reasoning_output_tokens":13332,"total_tokens":9755604}}}} +{"type":"event_msg","timestamp":"2030-01-01T17:01:51Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":67968,"input_tokens":68845,"output_tokens":337,"reasoning_output_tokens":0,"total_tokens":69182},"total_token_usage":{"cached_input_tokens":8840576,"input_tokens":9790114,"output_tokens":34672,"reasoning_output_tokens":13332,"total_tokens":9824786}}}} +{"type":"event_msg","timestamp":"2030-01-01T17:01:52Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":68480,"input_tokens":69232,"output_tokens":610,"reasoning_output_tokens":0,"total_tokens":69842},"total_token_usage":{"cached_input_tokens":8909056,"input_tokens":9859346,"output_tokens":35282,"reasoning_output_tokens":13332,"total_tokens":9894628}}}} +{"type":"event_msg","timestamp":"2030-01-01T17:01:53Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":68992,"input_tokens":69892,"output_tokens":382,"reasoning_output_tokens":244,"total_tokens":70274},"total_token_usage":{"cached_input_tokens":8978048,"input_tokens":9929238,"output_tokens":35664,"reasoning_output_tokens":13576,"total_tokens":9964902}}}} +{"type":"event_msg","timestamp":"2030-01-01T17:01:54Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":51072,"input_tokens":74724,"output_tokens":75,"reasoning_output_tokens":7,"total_tokens":74799},"total_token_usage":{"cached_input_tokens":9029120,"input_tokens":10003962,"output_tokens":35739,"reasoning_output_tokens":13583,"total_tokens":10039701}}}} +{"type":"event_msg","timestamp":"2030-01-01T17:01:55Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":74624,"input_tokens":76542,"output_tokens":66,"reasoning_output_tokens":7,"total_tokens":76608},"total_token_usage":{"cached_input_tokens":9103744,"input_tokens":10080504,"output_tokens":35805,"reasoning_output_tokens":13590,"total_tokens":10116309}}}} +{"type":"event_msg","timestamp":"2030-01-01T17:01:56Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":69504,"input_tokens":79951,"output_tokens":78,"reasoning_output_tokens":10,"total_tokens":80029},"total_token_usage":{"cached_input_tokens":9173248,"input_tokens":10160455,"output_tokens":35883,"reasoning_output_tokens":13600,"total_tokens":10196338}}}} +{"type":"event_msg","timestamp":"2030-01-01T17:01:57Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":76160,"input_tokens":83234,"output_tokens":75,"reasoning_output_tokens":6,"total_tokens":83309},"total_token_usage":{"cached_input_tokens":9249408,"input_tokens":10243689,"output_tokens":35958,"reasoning_output_tokens":13606,"total_tokens":10279647}}}} +{"type":"event_msg","timestamp":"2030-01-01T17:01:58Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":82816,"input_tokens":89587,"output_tokens":79,"reasoning_output_tokens":6,"total_tokens":89666},"total_token_usage":{"cached_input_tokens":9332224,"input_tokens":10333276,"output_tokens":36037,"reasoning_output_tokens":13612,"total_tokens":10369313}}}} +{"type":"event_msg","timestamp":"2030-01-01T17:01:59Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":89472,"input_tokens":102007,"output_tokens":153,"reasoning_output_tokens":22,"total_tokens":102160},"total_token_usage":{"cached_input_tokens":9421696,"input_tokens":10435283,"output_tokens":36190,"reasoning_output_tokens":13634,"total_tokens":10471473}}}} +{"type":"event_msg","timestamp":"2030-01-01T17:02:00Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":79744,"input_tokens":114177,"output_tokens":144,"reasoning_output_tokens":20,"total_tokens":114321},"total_token_usage":{"cached_input_tokens":9501440,"input_tokens":10549460,"output_tokens":36334,"reasoning_output_tokens":13654,"total_tokens":10585794}}}} +{"type":"event_msg","timestamp":"2030-01-01T17:02:01Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":114048,"input_tokens":123786,"output_tokens":88,"reasoning_output_tokens":17,"total_tokens":123874},"total_token_usage":{"cached_input_tokens":9615488,"input_tokens":10673246,"output_tokens":36422,"reasoning_output_tokens":13671,"total_tokens":10709668}}}} +{"type":"event_msg","timestamp":"2030-01-01T17:02:02Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":101760,"input_tokens":134637,"output_tokens":72,"reasoning_output_tokens":7,"total_tokens":134709},"total_token_usage":{"cached_input_tokens":9717248,"input_tokens":10807883,"output_tokens":36494,"reasoning_output_tokens":13678,"total_tokens":10844377}}}} +{"type":"event_msg","timestamp":"2030-01-01T17:02:03Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":134528,"input_tokens":146641,"output_tokens":87,"reasoning_output_tokens":13,"total_tokens":146728},"total_token_usage":{"cached_input_tokens":9851776,"input_tokens":10954524,"output_tokens":36581,"reasoning_output_tokens":13691,"total_tokens":10991105}}}} +{"type":"event_msg","timestamp":"2030-01-01T17:02:04Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":123776,"input_tokens":158050,"output_tokens":67,"reasoning_output_tokens":6,"total_tokens":158117},"total_token_usage":{"cached_input_tokens":9975552,"input_tokens":11112574,"output_tokens":36648,"reasoning_output_tokens":13697,"total_tokens":11149222}}}} +{"type":"event_msg","timestamp":"2030-01-01T17:02:05Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":146304,"input_tokens":167477,"output_tokens":68,"reasoning_output_tokens":9,"total_tokens":167545},"total_token_usage":{"cached_input_tokens":10121856,"input_tokens":11280051,"output_tokens":36716,"reasoning_output_tokens":13706,"total_tokens":11316767}}}} +{"type":"event_msg","timestamp":"2030-01-01T17:02:06Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":167296,"input_tokens":177612,"output_tokens":78,"reasoning_output_tokens":10,"total_tokens":177690},"total_token_usage":{"cached_input_tokens":10289152,"input_tokens":11457663,"output_tokens":36794,"reasoning_output_tokens":13716,"total_tokens":11494457}}}} +{"type":"event_msg","timestamp":"2030-01-01T17:02:07Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":177536,"input_tokens":189809,"output_tokens":70,"reasoning_output_tokens":9,"total_tokens":189879},"total_token_usage":{"cached_input_tokens":10466688,"input_tokens":11647472,"output_tokens":36864,"reasoning_output_tokens":13725,"total_tokens":11684336}}}} +{"type":"event_msg","timestamp":"2030-01-01T17:02:08Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":189312,"input_tokens":202957,"output_tokens":70,"reasoning_output_tokens":7,"total_tokens":203027},"total_token_usage":{"cached_input_tokens":10656000,"input_tokens":11850429,"output_tokens":36934,"reasoning_output_tokens":13732,"total_tokens":11887363}}}} +{"type":"event_msg","timestamp":"2030-01-01T17:02:09Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":202624,"input_tokens":213681,"output_tokens":258,"reasoning_output_tokens":20,"total_tokens":213939},"total_token_usage":{"cached_input_tokens":10858624,"input_tokens":12064110,"output_tokens":37192,"reasoning_output_tokens":13752,"total_tokens":12101302}}}} +{"type":"event_msg","timestamp":"2030-01-01T17:02:10Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":213376,"input_tokens":215284,"output_tokens":272,"reasoning_output_tokens":111,"total_tokens":215556},"total_token_usage":{"cached_input_tokens":11072000,"input_tokens":12279394,"output_tokens":37464,"reasoning_output_tokens":13863,"total_tokens":12316858}}}} +{"type":"event_msg","timestamp":"2030-01-01T17:02:11Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":214912,"input_tokens":215602,"output_tokens":83,"reasoning_output_tokens":0,"total_tokens":215685},"total_token_usage":{"cached_input_tokens":11286912,"input_tokens":12494996,"output_tokens":37547,"reasoning_output_tokens":13863,"total_tokens":12532543}}}} +{"type":"event_msg","timestamp":"2030-01-01T17:02:12Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":215424,"input_tokens":215763,"output_tokens":106,"reasoning_output_tokens":0,"total_tokens":215869},"total_token_usage":{"cached_input_tokens":11502336,"input_tokens":12710759,"output_tokens":37653,"reasoning_output_tokens":13863,"total_tokens":12748412}}}} +{"type":"event_msg","timestamp":"2030-01-01T17:02:13Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":215424,"input_tokens":215917,"output_tokens":57,"reasoning_output_tokens":0,"total_tokens":215974},"total_token_usage":{"cached_input_tokens":11717760,"input_tokens":12926676,"output_tokens":37710,"reasoning_output_tokens":13863,"total_tokens":12964386}}}} +{"type":"event_msg","timestamp":"2030-01-01T17:02:14Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":215424,"input_tokens":216066,"output_tokens":316,"reasoning_output_tokens":112,"total_tokens":216382},"total_token_usage":{"cached_input_tokens":11933184,"input_tokens":13142742,"output_tokens":38026,"reasoning_output_tokens":13975,"total_tokens":13180768}}}} +{"type":"event_msg","timestamp":"2030-01-01T17:02:15Z","payload":{"type":"token_count","info":{"last_token_usage":{"cached_input_tokens":215936,"input_tokens":216957,"output_tokens":388,"reasoning_output_tokens":185,"total_tokens":217345},"total_token_usage":{"cached_input_tokens":12149120,"input_tokens":13359699,"output_tokens":38414,"reasoning_output_tokens":14160,"total_tokens":13398113}}}} +{"type":"event_msg","timestamp":"2030-01-02T13:00:00Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":1000,"cached_input_tokens":100,"output_tokens":20,"reasoning_output_tokens":5,"total_tokens":1020},"total_token_usage":{"input_tokens":13360699,"cached_input_tokens":12149220,"output_tokens":38434,"reasoning_output_tokens":14165,"total_tokens":13399133}}}} +{"type":"event_msg","timestamp":"2030-01-02T13:00:01Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":1010,"cached_input_tokens":101,"output_tokens":21,"reasoning_output_tokens":5,"total_tokens":1031},"total_token_usage":{"input_tokens":13361709,"cached_input_tokens":12149321,"output_tokens":38455,"reasoning_output_tokens":14170,"total_tokens":13400164}}}} +{"type":"event_msg","timestamp":"2030-01-02T13:00:02Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":1020,"cached_input_tokens":102,"output_tokens":22,"reasoning_output_tokens":5,"total_tokens":1042},"total_token_usage":{"input_tokens":13362729,"cached_input_tokens":12149423,"output_tokens":38477,"reasoning_output_tokens":14175,"total_tokens":13401206}}}} diff --git a/Tests/CodexBarTests/Fixtures/CostUsage/Issue2037/missing-parent-siblings/manifest.json b/Tests/CodexBarTests/Fixtures/CostUsage/Issue2037/missing-parent-siblings/manifest.json new file mode 100644 index 0000000000..28e0af9393 --- /dev/null +++ b/Tests/CodexBarTests/Fixtures/CostUsage/Issue2037/missing-parent-siblings/manifest.json @@ -0,0 +1,51 @@ +{ + "schemaVersion": 1, + "redactionVersion": 1, + "familyAlias": "missing-parent-siblings", + "files": [ + { + "alias": "sibling-a", + "relativePath": "codex-home/archived_sessions/sibling-a.jsonl", + "sourceRole": "archive", + "leafSessionAlias": "sibling-a-session", + "parentSessionAlias": "missing-parent-session" + }, + { + "alias": "sibling-b", + "relativePath": "codex-home/archived_sessions/sibling-b.jsonl", + "sourceRole": "archive", + "leafSessionAlias": "sibling-b-session", + "parentSessionAlias": "missing-parent-session" + } + ], + "copiedPrefixes": [ + { + "parentAlias": "sibling-a", + "childAlias": "sibling-b", + "length": 135 + } + ], + "billablePrefixOwnerAlias": "sibling-a", + "missingParentSessionId": "missing-parent-session", + "oracle": { + "parentEventCount": 135, + "childEventCount": 158, + "copiedPrefixLength": 135, + "parentLastTokens": 13432621, + "childLastTokens": 28788548, + "copiedPrefixLastTokens": 13432621, + "naiveLastTokens": 28788548, + "dedupedLastTokens": 15355927, + "copiedPrefixTimestampMismatches": 135, + "parentHasTotalTokenUsageDrop": false, + "childHasTotalTokenUsageDrop": false + }, + "scannerOracle": { + "naiveScannerUnits": 54409503, + "dedupedScannerUnits": 28836599, + "prefixScannerUnits": 25547233, + "siblingAUniqueScannerUnits": 3311641, + "siblingBUniqueScannerUnits": 3396, + "unresolvedForkSkippedFirstEventScannerUnits": 25671 + } +} diff --git a/Tests/CodexBarTests/Fixtures/Providers/Claude/weekly-limit.json b/Tests/CodexBarTests/Fixtures/Providers/Claude/weekly-limit.json new file mode 100644 index 0000000000..89beecb8e3 --- /dev/null +++ b/Tests/CodexBarTests/Fixtures/Providers/Claude/weekly-limit.json @@ -0,0 +1,8 @@ +{ + "ok": true, + "account_email": "user@example.com", + "login_method": "Claude Max", + "session_5h": { "pct_used": 7, "resets": "11am (Europe/Vienna)" }, + "week_all_models": { "pct_used": 21, "resets": "Nov 21 at 5am (Europe/Vienna)" }, + "week_sonnet": { "pct_used": 3, "resets": "Nov 21 at 5am (Europe/Vienna)" } +} diff --git a/Tests/CodexBarTests/Fixtures/Providers/MiniMax/token-plan-missing-reset.json b/Tests/CodexBarTests/Fixtures/Providers/MiniMax/token-plan-missing-reset.json new file mode 100644 index 0000000000..dd055a6b42 --- /dev/null +++ b/Tests/CodexBarTests/Fixtures/Providers/MiniMax/token-plan-missing-reset.json @@ -0,0 +1,17 @@ +{ + "base_resp": { "status_code": 0, "status_msg": "success" }, + "data": { + "current_subscribe_title": "Token Plan Plus", + "model_remains": [ + { + "model_name": "general", + "current_interval_total_count": 100, + "current_interval_usage_count": 25, + "current_interval_remaining_percent": 75, + "current_weekly_total_count": 100, + "current_weekly_usage_count": 40, + "current_weekly_remaining_percent": 60 + } + ] + } +} diff --git a/Tests/CodexBarTests/Fixtures/Providers/MiniMax/token-plan-normal.json b/Tests/CodexBarTests/Fixtures/Providers/MiniMax/token-plan-normal.json new file mode 100644 index 0000000000..1401c2386a --- /dev/null +++ b/Tests/CodexBarTests/Fixtures/Providers/MiniMax/token-plan-normal.json @@ -0,0 +1,22 @@ +{ + "base_resp": { "status_code": 0, "status_msg": "success" }, + "data": { + "current_subscribe_title": "Token Plan Plus", + "points_balance": "14000", + "model_remains": [ + { + "model_name": "general", + "current_interval_total_count": 0, + "current_interval_usage_count": 0, + "current_interval_remaining_percent": "96", + "start_time": 1780279200000, + "end_time": 1780297200000, + "current_weekly_total_count": 0, + "current_weekly_usage_count": 0, + "current_weekly_remaining_percent": "99", + "weekly_start_time": 1780243200000, + "weekly_end_time": 1780848000000 + } + ] + } +} diff --git a/Tests/CodexBarTests/Fixtures/Providers/OpenAI/pro-normal.html b/Tests/CodexBarTests/Fixtures/Providers/OpenAI/pro-normal.html new file mode 100644 index 0000000000..0e05f39c78 --- /dev/null +++ b/Tests/CodexBarTests/Fixtures/Providers/OpenAI/pro-normal.html @@ -0,0 +1,16 @@ + + + +
    +Usage limits +5h limit +72% remaining +Resets today at 2:15 PM +Weekly limit +41% remaining +Resets Fri at 9:00 AM +
    + + diff --git a/Tests/CodexBarTests/Fixtures/QwenCloud/flat_subscription_summary.json b/Tests/CodexBarTests/Fixtures/QwenCloud/flat_subscription_summary.json new file mode 100644 index 0000000000..c771170b5f --- /dev/null +++ b/Tests/CodexBarTests/Fixtures/QwenCloud/flat_subscription_summary.json @@ -0,0 +1,8 @@ +{ + "Success": true, + "Data": { + "TotalCount": 1, + "TotalValue": 2000, + "TotalSurplusValue": 1500 + } +} diff --git a/Tests/CodexBarTests/Fixtures/QwenCloud/forbidden.json b/Tests/CodexBarTests/Fixtures/QwenCloud/forbidden.json new file mode 100644 index 0000000000..61fe920e00 --- /dev/null +++ b/Tests/CodexBarTests/Fixtures/QwenCloud/forbidden.json @@ -0,0 +1,4 @@ +{ + "statusCode": 403, + "message": "Forbidden" +} diff --git a/Tests/CodexBarTests/Fixtures/QwenCloud/login_required.json b/Tests/CodexBarTests/Fixtures/QwenCloud/login_required.json new file mode 100644 index 0000000000..41c6363392 --- /dev/null +++ b/Tests/CodexBarTests/Fixtures/QwenCloud/login_required.json @@ -0,0 +1,5 @@ +{ + "code": "ConsoleNeedLogin", + "message": "You need to log in.", + "successResponse": false +} diff --git a/Tests/CodexBarTests/Fixtures/QwenCloud/nested_equity_list.json b/Tests/CodexBarTests/Fixtures/QwenCloud/nested_equity_list.json new file mode 100644 index 0000000000..cdf0d48954 --- /dev/null +++ b/Tests/CodexBarTests/Fixtures/QwenCloud/nested_equity_list.json @@ -0,0 +1,21 @@ +{ + "code": "200", + "successResponse": true, + "data": { + "TotalCount": 1, + "Data": [ + { + "InstanceCode": "qwen-token-plan", + "Status": "NORMAL", + "EndTime": 1.701e12, + "EquityList": [ + { + "Type": "CREDITS", + "CycleTotalValue": "1000", + "CycleSurplusValue": "875" + } + ] + } + ] + } +} diff --git a/Tests/CodexBarTests/Fixtures/QwenCloud/no_active_subscription.json b/Tests/CodexBarTests/Fixtures/QwenCloud/no_active_subscription.json new file mode 100644 index 0000000000..1708cf20da --- /dev/null +++ b/Tests/CodexBarTests/Fixtures/QwenCloud/no_active_subscription.json @@ -0,0 +1,24 @@ +{ + "requestId": "00000000-0000-4000-8000-000000000001", + "code": "200", + "message": null, + "action": null, + "apiName": null, + "data": { + "RequestId": "00000000-0000-4000-8000-000000000001", + "Message": "Successful!", + "Data": { + "Uid": 7, + "TotalSurplusValue": "0", + "TotalCount": 0, + "TotalValue": "0", + "ProductCode": "sfm_tokenplansolo_public_intl" + }, + "Code": "Success", + "Success": true + }, + "httpStatusCode": "200", + "accessDeniedDetail": null, + "extendedCode": null, + "successResponse": true +} diff --git a/Tests/CodexBarTests/Fixtures/agent-session-rollout.jsonl b/Tests/CodexBarTests/Fixtures/agent-session-rollout.jsonl new file mode 100644 index 0000000000..9f71dd8588 --- /dev/null +++ b/Tests/CodexBarTests/Fixtures/agent-session-rollout.jsonl @@ -0,0 +1,2 @@ +{"timestamp":"2026-07-06T16:00:00Z","type":"session_meta","payload":{"session_id":"019f-session-fixture","cwd":"/Users/test/Projects/alpha","originator":"codex_exec","source":"exec"}} +{"this":"second line must never be read by the session parser"} diff --git a/Tests/CodexBarTests/Fixtures/agent-sessions-lsof.txt b/Tests/CodexBarTests/Fixtures/agent-sessions-lsof.txt new file mode 100644 index 0000000000..7b9e2b42a9 --- /dev/null +++ b/Tests/CodexBarTests/Fixtures/agent-sessions-lsof.txt @@ -0,0 +1,4 @@ +p102 +n/Users/test/Projects/alpha +p201 +n/Users/test/Projects/project with spaces diff --git a/Tests/CodexBarTests/Fixtures/agent-sessions-ps.txt b/Tests/CodexBarTests/Fixtures/agent-sessions-ps.txt new file mode 100644 index 0000000000..53c2f31ee2 --- /dev/null +++ b/Tests/CodexBarTests/Fixtures/agent-sessions-ps.txt @@ -0,0 +1,9 @@ + 101 1 Mon Jul 6 09:00:00 2026 /Applications/Claude.app/Contents/Resources/disclaimer /Users/test/Library/Application Support/Claude/claude-code/claude --dangerously-skip-permissions + 102 101 Mon Jul 6 09:00:01 2026 /Users/test/Library/Application Support/Claude/claude-code/claude --dangerously-skip-permissions + 201 1 Mon Jul 6 09:01:00 2026 /opt/homebrew/bin/codex exec --full-auto strange argv here + 202 1 Mon Jul 6 09:02:00 2026 /Applications/Codex.app/Contents/Resources/codex app-server --listen stdio + 203 1 Mon Jul 6 09:03:00 2026 /usr/local/bin/codex --help + 301 1 Mon Jul 6 09:04:00 2026 /Users/test/.local/bin/claude-code-acp --stdio + 401 1 Mon Jul 6 09:05:00 2026 /Applications/Codex.app/Contents/Frameworks/Codex Framework.framework/Helpers/Codex (Renderer) --type=renderer + 402 1 Mon Jul 6 09:06:00 2026 /Applications/Claude.app/Contents/MacOS/Claude + 403 1 Mon Jul 6 09:07:00 2026 ./Codex Computer Use.app/Contents/MacOS/helper mcp diff --git a/Tests/CodexBarTests/Fixtures/agent-sessions-tailscale.json b/Tests/CodexBarTests/Fixtures/agent-sessions-tailscale.json new file mode 100644 index 0000000000..63d612a7c8 --- /dev/null +++ b/Tests/CodexBarTests/Fixtures/agent-sessions-tailscale.json @@ -0,0 +1,10 @@ +{ + "Self": {"DNSName": "local-mac.example.ts.net.", "HostName": "local-mac"}, + "Peer": { + "node-1": {"DNSName": "clawmac.example.ts.net.", "OS": "macOS", "Online": true}, + "node-2": {"DNSName": "linuxbox.example.ts.net.", "OS": "linux", "Online": true}, + "node-3": {"DNSName": "phone.example.ts.net.", "OS": "iOS", "Online": true}, + "node-4": {"DNSName": "offline.example.ts.net.", "OS": "macOS", "Online": false}, + "node-5": {"DNSName": "local-mac.example.ts.net.", "OS": "macOS", "Online": true} + } +} diff --git a/Tests/CodexBarTests/Fixtures/models-dev-subset.json b/Tests/CodexBarTests/Fixtures/models-dev-subset.json index 312b0bab0f..f2a0c0857e 100644 --- a/Tests/CodexBarTests/Fixtures/models-dev-subset.json +++ b/Tests/CodexBarTests/Fixtures/models-dev-subset.json @@ -70,8 +70,8 @@ "id": "google-vertex-anthropic", "name": "Vertex (Anthropic)", "models": { - "claude-sonnet-4-6@default": { - "id": "claude-sonnet-4-6@default", + "claude-sonnet-4-6": { + "id": "claude-sonnet-4-6", "name": "Claude Sonnet 4.6", "cost": { "input": 3.1, diff --git a/Tests/CodexBarTests/GeminiAPITestHelpers.swift b/Tests/CodexBarTests/GeminiAPITestHelpers.swift index b243f22ab7..19843ea182 100644 --- a/Tests/CodexBarTests/GeminiAPITestHelpers.swift +++ b/Tests/CodexBarTests/GeminiAPITestHelpers.swift @@ -76,19 +76,36 @@ enum GeminiAPITestHelpers { return "header.\(encoded).sig" } - static func loadCodeAssistResponse(tierId: String, projectId: String? = nil) -> Data { - var payload: [String: Any] = [ - "currentTier": [ + static func loadCodeAssistResponse( + tierId: String?, + projectId: String? = nil, + paidTierName: String? = nil) -> Data + { + var payload: [String: Any] = [:] + if let tierId { + payload["currentTier"] = [ "id": tierId, "name": tierId.replacingOccurrences(of: "-tier", with: ""), - ], - ] + ] + } if let projectId { payload["cloudaicompanionProject"] = projectId } + if let paidTierName { + payload["paidTier"] = [ + "name": paidTierName, + ] + } return self.jsonData(payload) } + static func loadCodeAssistConsumerPlusResponse(projectId: String? = "cloudaicompanion-123") -> Data { + self.loadCodeAssistResponse( + tierId: "free-tier", + projectId: projectId, + paidTierName: "Plus") + } + static func loadCodeAssistFreeTierResponse() -> Data { self.loadCodeAssistResponse(tierId: "free-tier") } @@ -97,7 +114,28 @@ enum GeminiAPITestHelpers { self.loadCodeAssistResponse(tierId: "standard-tier") } + static func loadCodeAssistGoogleOneProResponse(projectId: String? = "cloudaicompanion-123") -> Data { + self.loadCodeAssistResponse( + tierId: "standard-tier", + projectId: projectId, + paidTierName: "Gemini Code Assist in Google One AI Pro") + } + static func loadCodeAssistLegacyTierResponse() -> Data { self.loadCodeAssistResponse(tierId: "legacy-tier") } + + static func consumerTierDeprecationResponse() -> Data { + self.jsonData([ + "error": [ + "code": 403, + "message": """ + IneligibleTierError / UNSUPPORTED_CLIENT: This client is no longer supported for \ + Gemini Code Assist for individuals. To continue using Gemini, please migrate to the \ + Antigravity suite of products. + """, + "status": "PERMISSION_DENIED", + ], + ]) + } } diff --git a/Tests/CodexBarTests/GeminiConsumerTierMigrationTests.swift b/Tests/CodexBarTests/GeminiConsumerTierMigrationTests.swift new file mode 100644 index 0000000000..a223f49ee8 --- /dev/null +++ b/Tests/CodexBarTests/GeminiConsumerTierMigrationTests.swift @@ -0,0 +1,163 @@ +import CodexBarCore +import Foundation +import Testing +#if canImport(Darwin) +import Darwin +#else +import Glibc +#endif + +@Suite(.serialized) +struct GeminiConsumerTierMigrationTests { + @Test(arguments: [ + "UNSUPPORTED_CLIENT", + "IneligibleTierError", + "no longer supported for Gemini Code Assist for individuals", + "please migrate Gemini to the Antigravity suite", + ]) + func `detects consumer tier deprecation signals`(signal: String) { + #expect(GeminiStatusProbeError.isConsumerTierDeprecationSignal(signal)) + } + + @Test(arguments: [ + "UNAUTHENTICATED", + "HTTP 500", + "quota bucket missing", + ]) + func `ignores unrelated api errors`(signal: String) { + #expect(!GeminiStatusProbeError.isConsumerTierDeprecationSignal(signal)) + } + + @Test + func `reports consumer tier deprecation from loadCodeAssist`() async throws { + let env = try GeminiTestEnvironment() + defer { env.cleanup() } + try env.writeCredentials( + accessToken: "token", + refreshToken: nil, + expiry: Date().addingTimeInterval(3600), + idToken: nil) + + let dataLoader = GeminiAPITestHelpers.dataLoader { request in + guard let url = request.url, let host = url.host else { + throw URLError(.badURL) + } + switch host { + case "cloudcode-pa.googleapis.com": + if url.path == "/v1internal:loadCodeAssist" { + return GeminiAPITestHelpers.response( + url: url.absoluteString, + status: 403, + body: GeminiAPITestHelpers.consumerTierDeprecationResponse()) + } + return GeminiAPITestHelpers.response(url: url.absoluteString, status: 404, body: Data()) + default: + return GeminiAPITestHelpers.response(url: url.absoluteString, status: 404, body: Data()) + } + } + + let probe = GeminiStatusProbe(timeout: 1, homeDirectory: env.homeURL.path, dataLoader: dataLoader) + await Self.expectError(.consumerTierDeprecated) { + _ = try await probe.fetch() + } + } + + @Test + func `reports consumer tier deprecation from quota api`() async throws { + let env = try GeminiTestEnvironment() + defer { env.cleanup() } + try env.writeCredentials( + accessToken: "token", + refreshToken: nil, + expiry: Date().addingTimeInterval(3600), + idToken: nil) + + let dataLoader = GeminiAPITestHelpers.dataLoader { request in + guard let url = request.url, let host = url.host else { + throw URLError(.badURL) + } + switch host { + case "cloudresourcemanager.googleapis.com": + return GeminiAPITestHelpers.response( + url: url.absoluteString, + status: 200, + body: GeminiAPITestHelpers.jsonData(["projects": []])) + case "cloudcode-pa.googleapis.com": + if url.path == "/v1internal:loadCodeAssist" { + return GeminiAPITestHelpers.response( + url: url.absoluteString, + status: 200, + body: GeminiAPITestHelpers.loadCodeAssistStandardTierResponse()) + } + if url.path == "/v1internal:retrieveUserQuota" { + return GeminiAPITestHelpers.response( + url: url.absoluteString, + status: 403, + body: GeminiAPITestHelpers.consumerTierDeprecationResponse()) + } + return GeminiAPITestHelpers.response(url: url.absoluteString, status: 404, body: Data()) + default: + return GeminiAPITestHelpers.response(url: url.absoluteString, status: 404, body: Data()) + } + } + + let probe = GeminiStatusProbe(timeout: 1, homeDirectory: env.homeURL.path, dataLoader: dataLoader) + await Self.expectError(.consumerTierDeprecated) { + _ = try await probe.fetch() + } + } + + @Test + func `reports consumer tier deprecation from token refresh`() async throws { + let env = try GeminiTestEnvironment() + defer { env.cleanup() } + try env.writeCredentials( + accessToken: "old-token", + refreshToken: "refresh", + expiry: Date().addingTimeInterval(-3600), + idToken: nil) + + let binURL = try env.writeFakeGeminiCLI() + let previousValue = ProcessInfo.processInfo.environment["GEMINI_CLI_PATH"] + setenv("GEMINI_CLI_PATH", binURL.path, 1) + defer { + if let previousValue { + setenv("GEMINI_CLI_PATH", previousValue, 1) + } else { + unsetenv("GEMINI_CLI_PATH") + } + } + + let dataLoader = GeminiAPITestHelpers.dataLoader { request in + guard let url = request.url, let host = url.host else { + throw URLError(.badURL) + } + switch host { + case "oauth2.googleapis.com": + return GeminiAPITestHelpers.response( + url: url.absoluteString, + status: 400, + body: GeminiAPITestHelpers.consumerTierDeprecationResponse()) + default: + return GeminiAPITestHelpers.response(url: url.absoluteString, status: 404, body: Data()) + } + } + + let probe = GeminiStatusProbe(timeout: 1, homeDirectory: env.homeURL.path, dataLoader: dataLoader) + await Self.expectError(.consumerTierDeprecated) { + _ = try await probe.fetch() + } + } + + private static func expectError( + _ expected: GeminiStatusProbeError, + operation: () async throws -> Void) async + { + do { + try await operation() + #expect(Bool(false)) + } catch { + #expect(error as? GeminiStatusProbeError == expected) + } + } +} diff --git a/Tests/CodexBarTests/GeminiMenuCardTests.swift b/Tests/CodexBarTests/GeminiMenuCardTests.swift index bf7e89afff..fd21703c24 100644 --- a/Tests/CodexBarTests/GeminiMenuCardTests.swift +++ b/Tests/CodexBarTests/GeminiMenuCardTests.swift @@ -4,6 +4,44 @@ import Testing @testable import CodexBar struct GeminiMenuCardTests { + @Test + func `gemini plan preserves upstream acronym casing`() throws { + let identity = ProviderIdentitySnapshot( + providerID: .gemini, + accountEmail: nil, + accountOrganization: nil, + loginMethod: "Gemini Code Assist in Google One AI Pro") + let snapshot = UsageSnapshot( + primary: nil, + secondary: nil, + tertiary: nil, + updatedAt: Date(timeIntervalSince1970: 0), + identity: identity) + let metadata = try #require(ProviderDefaults.metadata[.gemini]) + + let model = UsageMenuCardView.Model.make(.init( + provider: .gemini, + metadata: metadata, + snapshot: snapshot, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: Date(timeIntervalSince1970: 0))) + + #expect(model.planText == "Gemini Code Assist in Google One AI Pro") + } + @Test func `gemini model uses flash lite title for tertiary metric`() throws { let now = Date() diff --git a/Tests/CodexBarTests/GeminiOAuthConfigTests.swift b/Tests/CodexBarTests/GeminiOAuthConfigTests.swift new file mode 100644 index 0000000000..25ee303418 --- /dev/null +++ b/Tests/CodexBarTests/GeminiOAuthConfigTests.swift @@ -0,0 +1,26 @@ +import CodexBarCore +import Foundation +import Testing + +@Suite(.serialized) +struct GeminiOAuthConfigTests { + @Test + func `environment client requires both id and secret`() { + let values = GeminiOAuthConfig.EnvironmentValues(clientID: "env-id", clientSecret: nil) + GeminiOAuthConfig.$environmentOverride.withValue(values) { + #expect(GeminiOAuthConfig.environmentClient() == nil) + } + } + + @Test + func `environment client returns configured credentials`() { + let values = GeminiOAuthConfig.EnvironmentValues( + clientID: "env-id", + clientSecret: "env-secret") + GeminiOAuthConfig.$environmentOverride.withValue(values) { + let resolved = GeminiOAuthConfig.environmentClient() + #expect(resolved?.clientID == "env-id") + #expect(resolved?.clientSecret == "env-secret") + } + } +} diff --git a/Tests/CodexBarTests/GeminiOAuthRecoveryAPITests.swift b/Tests/CodexBarTests/GeminiOAuthRecoveryAPITests.swift new file mode 100644 index 0000000000..b99b24efbf --- /dev/null +++ b/Tests/CodexBarTests/GeminiOAuthRecoveryAPITests.swift @@ -0,0 +1,249 @@ +import CodexBarCore +import Foundation +import Testing + +@Suite(.serialized) +struct GeminiOAuthRecoveryAPITests { + @Test + func `explicit oauth2 js path overrides installed gemini cli`() async throws { + let env = try GeminiTestEnvironment() + defer { env.cleanup() } + try env.writeCredentials( + accessToken: "old-token", + refreshToken: "refresh-token", + expiry: Date().addingTimeInterval(-3600), + idToken: GeminiAPITestHelpers.makeIDToken(email: "user@example.com")) + + let oauthURL = env.homeURL.appendingPathComponent("oauth2.js") + try """ + const OAUTH_CLIENT_ID = 'path-client-id'; + const OAUTH_CLIENT_SECRET = 'path-client-secret'; + """.write(to: oauthURL, atomically: true, encoding: .utf8) + + let binURL = try env.writeFakeGeminiCLI() + let oauthEnv = GeminiOAuthConfig.EnvironmentValues(oauth2JSPath: oauthURL.path) + let dataLoader = GeminiAPITestHelpers.dataLoader { request in + guard let url = request.url, let host = url.host else { + throw URLError(.badURL) + } + + switch host { + case "oauth2.googleapis.com": + let body = request.httpBody.flatMap { String(data: $0, encoding: .utf8) } ?? "" + guard body.contains("client_id=path-client-id") else { + return GeminiAPITestHelpers.response(url: url.absoluteString, status: 400, body: Data()) + } + let json = GeminiAPITestHelpers.jsonData([ + "access_token": "new-token", + "expires_in": 3600, + "id_token": GeminiAPITestHelpers.makeIDToken(email: "user@example.com"), + ]) + return GeminiAPITestHelpers.response(url: url.absoluteString, status: 200, body: json) + case "cloudresourcemanager.googleapis.com": + return GeminiAPITestHelpers.response( + url: url.absoluteString, + status: 200, + body: GeminiAPITestHelpers.jsonData(["projects": []])) + case "cloudcode-pa.googleapis.com": + if url.path == "/v1internal:loadCodeAssist" { + return GeminiAPITestHelpers.response( + url: url.absoluteString, + status: 200, + body: GeminiAPITestHelpers.loadCodeAssistStandardTierResponse()) + } + if url.path != "/v1internal:retrieveUserQuota" { + return GeminiAPITestHelpers.response(url: url.absoluteString, status: 404, body: Data()) + } + return GeminiAPITestHelpers.response( + url: url.absoluteString, + status: 200, + body: GeminiAPITestHelpers.sampleQuotaResponse()) + default: + return GeminiAPITestHelpers.response(url: url.absoluteString, status: 404, body: Data()) + } + } + + let probe = GeminiStatusProbe(timeout: 2, homeDirectory: env.homeURL.path, dataLoader: dataLoader) + let snapshot = try await BinaryLocator.$geminiBinaryPathOverrideForTesting.withValue(binURL.path) { + try await GeminiOAuthConfig.$environmentOverride.withValue(oauthEnv) { + try await probe.fetch() + } + } + #expect(snapshot.accountPlan == "Paid") + } + + @Test + func `prefers environment oauth client over installed gemini cli`() async throws { + let env = try GeminiTestEnvironment() + defer { env.cleanup() } + try env.writeCredentials( + accessToken: "old-token", + refreshToken: "refresh-token", + expiry: Date().addingTimeInterval(-3600), + idToken: nil) + + let binURL = try env.writeFakeGeminiCLI() + let oauthEnv = GeminiOAuthConfig.EnvironmentValues( + clientID: "env-client-id", + clientSecret: "env-client-secret") + let dataLoader = GeminiAPITestHelpers.dataLoader { request in + guard let url = request.url, let host = url.host else { + throw URLError(.badURL) + } + switch host { + case "oauth2.googleapis.com": + let body = request.httpBody.flatMap { String(data: $0, encoding: .utf8) } ?? "" + guard body.contains("client_id=env-client-id"), + body.contains("client_secret=env-client-secret") + else { + return GeminiAPITestHelpers.response(url: url.absoluteString, status: 400, body: Data()) + } + let json = GeminiAPITestHelpers.jsonData([ + "access_token": "new-token", + "expires_in": 3600, + ]) + return GeminiAPITestHelpers.response(url: url.absoluteString, status: 200, body: json) + case "cloudresourcemanager.googleapis.com": + return GeminiAPITestHelpers.response( + url: url.absoluteString, + status: 200, + body: GeminiAPITestHelpers.jsonData(["projects": []])) + case "cloudcode-pa.googleapis.com": + if url.path == "/v1internal:loadCodeAssist" { + return GeminiAPITestHelpers.response( + url: url.absoluteString, + status: 200, + body: GeminiAPITestHelpers.loadCodeAssistFreeTierResponse()) + } + if url.path != "/v1internal:retrieveUserQuota" { + return GeminiAPITestHelpers.response(url: url.absoluteString, status: 404, body: Data()) + } + return GeminiAPITestHelpers.response( + url: url.absoluteString, + status: 200, + body: GeminiAPITestHelpers.sampleFlashQuotaResponse()) + default: + return GeminiAPITestHelpers.response(url: url.absoluteString, status: 404, body: Data()) + } + } + + let probe = GeminiStatusProbe(timeout: 2, homeDirectory: env.homeURL.path, dataLoader: dataLoader) + _ = try await BinaryLocator.$geminiBinaryPathOverrideForTesting.withValue(binURL.path) { + try await GeminiOAuthConfig.$environmentOverride.withValue(oauthEnv) { + try await probe.fetch() + } + } + } + + @Test + func `refreshes via known Homebrew Cellar libexec path without gemini binary`() async throws { + let env = try GeminiTestEnvironment() + defer { env.cleanup() } + try env.writeCredentials( + accessToken: "old-token", + refreshToken: "refresh-token", + expiry: Date().addingTimeInterval(-3600), + idToken: GeminiAPITestHelpers.makeIDToken(email: "user@example.com")) + + // Resolvable gemini binary exists but omits OAuth config; Cellar package root + // under a synthetic Homebrew prefix holds the credentials instead. + let binURL = try env.writeFakeGeminiCLI(includeOAuth: false, layout: .npmNested) + let homebrewPrefix = env.homeURL.appendingPathComponent("homebrew-prefix") + try Self.plantHomebrewCellarGeminiPackage( + under: homebrewPrefix, + clientID: "cellar-client-id", + clientSecret: "cellar-client-secret") + + let clearOAuthEnv = GeminiOAuthConfig.EnvironmentValues() + let dataLoader = GeminiAPITestHelpers.dataLoader { request in + guard let url = request.url, let host = url.host else { + throw URLError(.badURL) + } + + switch host { + case "oauth2.googleapis.com": + let body = request.httpBody.flatMap { String(data: $0, encoding: .utf8) } ?? "" + guard body.contains("client_id=cellar-client-id") else { + return GeminiAPITestHelpers.response(url: url.absoluteString, status: 400, body: Data()) + } + let json = GeminiAPITestHelpers.jsonData([ + "access_token": "new-token", + "expires_in": 3600, + "id_token": GeminiAPITestHelpers.makeIDToken(email: "user@example.com"), + ]) + return GeminiAPITestHelpers.response(url: url.absoluteString, status: 200, body: json) + case "cloudresourcemanager.googleapis.com": + return GeminiAPITestHelpers.response( + url: url.absoluteString, + status: 200, + body: GeminiAPITestHelpers.jsonData(["projects": []])) + case "cloudcode-pa.googleapis.com": + guard request.value(forHTTPHeaderField: "Authorization") == "Bearer new-token" else { + return GeminiAPITestHelpers.response(url: url.absoluteString, status: 401, body: Data()) + } + if url.path == "/v1internal:loadCodeAssist" { + return GeminiAPITestHelpers.response( + url: url.absoluteString, + status: 200, + body: GeminiAPITestHelpers.loadCodeAssistStandardTierResponse()) + } + if url.path != "/v1internal:retrieveUserQuota" { + return GeminiAPITestHelpers.response(url: url.absoluteString, status: 404, body: Data()) + } + return GeminiAPITestHelpers.response( + url: url.absoluteString, + status: 200, + body: GeminiAPITestHelpers.sampleQuotaResponse()) + default: + return GeminiAPITestHelpers.response(url: url.absoluteString, status: 404, body: Data()) + } + } + + let probe = GeminiStatusProbe(timeout: 2, homeDirectory: env.homeURL.path, dataLoader: dataLoader) + let snapshot = try await BinaryLocator.$geminiBinaryPathOverrideForTesting.withValue(binURL.path) { + try await GeminiOAuthConfig.$environmentOverride.withValue(clearOAuthEnv) { + try await GeminiStatusProbe.$knownInstallPrefixesForTesting.withValue([homebrewPrefix.path]) { + try await probe.fetch() + } + } + } + #expect(snapshot.accountPlan == "Paid") + } + + private static func plantHomebrewCellarGeminiPackage( + under prefix: URL, + clientID: String, + clientSecret: String) throws + { + let packageRoot = prefix + .appendingPathComponent("Cellar") + .appendingPathComponent("gemini-cli") + .appendingPathComponent("0.41.2") + .appendingPathComponent("libexec") + .appendingPathComponent("lib") + .appendingPathComponent("node_modules") + .appendingPathComponent("@google") + .appendingPathComponent("gemini-cli") + let bundleDir = packageRoot.appendingPathComponent("bundle") + try FileManager.default.createDirectory(at: bundleDir, withIntermediateDirectories: true) + try """ + { + "name": "@google/gemini-cli" + } + """.write( + to: packageRoot.appendingPathComponent("package.json"), + atomically: true, + encoding: .utf8) + try "#!/usr/bin/env node\nawait import('./chunk-OAUTH.js');\n".write( + to: bundleDir.appendingPathComponent("gemini.js"), + atomically: true, + encoding: .utf8) + try """ + var OAUTH_CLIENT_ID = "\(clientID)"; + var OAUTH_CLIENT_SECRET = "\(clientSecret)"; + """.write( + to: bundleDir.appendingPathComponent("chunk-OAUTH.js"), + atomically: true, + encoding: .utf8) + } +} diff --git a/Tests/CodexBarTests/GeminiPrimaryWindowTests.swift b/Tests/CodexBarTests/GeminiPrimaryWindowTests.swift new file mode 100644 index 0000000000..105d15438e --- /dev/null +++ b/Tests/CodexBarTests/GeminiPrimaryWindowTests.swift @@ -0,0 +1,41 @@ +import Foundation +import Testing +@testable import CodexBarCore + +@Suite(.serialized) +struct GeminiPrimaryWindowTests { + @Test + func `flash-only account does not fabricate a phantom 0% primary window`() { + let snapshot = GeminiStatusSnapshot( + modelQuotas: [ + GeminiModelQuota(modelId: "gemini-2.5-flash", percentLeft: 5, resetTime: nil, resetDescription: nil), + GeminiModelQuota( + modelId: "gemini-2.5-flash-lite", percentLeft: 60, resetTime: nil, resetDescription: nil), + ], + rawText: "", + accountEmail: nil, + accountPlan: nil) + + let usage = snapshot.toUsageSnapshot() + + #expect(usage.primary == nil) + #expect(usage.secondary?.usedPercent == 95) + #expect(usage.tertiary?.usedPercent == 40) + } + + @Test + func `pro quota still populates the primary window`() { + let snapshot = GeminiStatusSnapshot( + modelQuotas: [ + GeminiModelQuota(modelId: "gemini-2.5-pro", percentLeft: 30, resetTime: nil, resetDescription: nil), + GeminiModelQuota(modelId: "gemini-2.5-flash", percentLeft: 70, resetTime: nil, resetDescription: nil), + ], + rawText: "", + accountEmail: nil, + accountPlan: nil) + + let usage = snapshot.toUsageSnapshot() + #expect(usage.primary?.usedPercent == 70) + #expect(usage.secondary?.usedPercent == 30) + } +} diff --git a/Tests/CodexBarTests/GeminiProviderMigrationSettingsTests.swift b/Tests/CodexBarTests/GeminiProviderMigrationSettingsTests.swift new file mode 100644 index 0000000000..c7f84b12bb --- /dev/null +++ b/Tests/CodexBarTests/GeminiProviderMigrationSettingsTests.swift @@ -0,0 +1,134 @@ +import CodexBarCore +import SwiftUI +import Testing +@testable import CodexBar + +@MainActor +@Suite(.serialized) +struct GeminiProviderMigrationSettingsTests { + private func makeSettings() -> SettingsStore { + let suite = "GeminiProviderMigrationSettingsTests-\(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.providerDetectionCompleted = true + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + return settings + } + + private func makeStore(settings: SettingsStore) -> UsageStore { + UsageStore( + fetcher: UsageFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings) + } + + private func makeContext(settings: SettingsStore, store: UsageStore) -> ProviderSettingsContext { + ProviderSettingsContext( + provider: .gemini, + settings: settings, + store: store, + boolBinding: { keyPath in + Binding( + get: { settings[keyPath: keyPath] }, + set: { settings[keyPath: keyPath] = $0 }) + }, + stringBinding: { keyPath in + Binding( + get: { settings[keyPath: keyPath] }, + set: { settings[keyPath: keyPath] = $0 }) + }, + statusText: { _ in nil }, + setStatusText: { _, _ in }, + lastAppActiveRunAt: { _ in nil }, + setLastAppActiveRunAt: { _, _ in }, + requestConfirmation: { _ in }, + runLoginFlow: {}) + } + + @Test + func `typed predicate accepts consumer tier deprecated only`() { + #expect(UsageStore.isGeminiConsumerTierDeprecationError(GeminiStatusProbeError.consumerTierDeprecated)) + #expect(!UsageStore.isGeminiConsumerTierDeprecationError(GeminiStatusProbeError.notLoggedIn)) + #expect(!UsageStore.isGeminiConsumerTierDeprecationError(nil)) + } + + @Test + func `ordinary auth errors do not set migration observation`() { + let settings = self.makeSettings() + let store = self.makeStore(settings: settings) + + store.observeGeminiConsumerTierDeprecation(from: GeminiStatusProbeError.notLoggedIn) + + #expect(!store.geminiObservedConsumerTierDeprecation) + } + + @Test + func `settings action appears when deprecation was observed`() { + let settings = self.makeSettings() + let store = self.makeStore(settings: settings) + store.observeGeminiConsumerTierDeprecation(from: GeminiStatusProbeError.consumerTierDeprecated) + + let impl = GeminiProviderImplementation() + let antigravity = ProviderDescriptorRegistry.descriptor(for: .antigravity).metadata + let wasEnabled = settings.isProviderEnabled(provider: .antigravity, metadata: antigravity) + let actions = impl.settingsActions(context: self.makeContext(settings: settings, store: store)) + + #expect(actions.map(\.id) == ["gemini-antigravity-migration"]) + #expect(settings.isProviderEnabled(provider: .antigravity, metadata: antigravity) == wasEnabled) + } + + @Test + func `settings action hidden for ordinary not logged in errors`() { + let settings = self.makeSettings() + let store = self.makeStore(settings: settings) + store.errors[.gemini] = GeminiStatusProbeError.notLoggedIn.errorDescription + store.observeGeminiConsumerTierDeprecation(from: GeminiStatusProbeError.notLoggedIn) + + let impl = GeminiProviderImplementation() + let actions = impl.settingsActions(context: self.makeContext(settings: settings, store: store)) + + #expect(actions.isEmpty) + #expect(!store.geminiObservedConsumerTierDeprecation) + } + + @Test + func `settings action hidden for unauthenticated401 style failures`() { + let unauthenticatedBody = """ + {"error":{"code":401,"message":"Request had invalid authentication credentials.","status":"UNAUTHENTICATED"}} + """ + #expect(!GeminiStatusProbeError.isConsumerTierDeprecationSignal(unauthenticatedBody)) + + let settings = self.makeSettings() + let store = self.makeStore(settings: settings) + store.errors[.gemini] = GeminiStatusProbeError.notLoggedIn.errorDescription + store.observeGeminiConsumerTierDeprecation(from: GeminiStatusProbeError.notLoggedIn) + + let impl = GeminiProviderImplementation() + let actions = impl.settingsActions(context: self.makeContext(settings: settings, store: store)) + + #expect(actions.isEmpty) + } + + @Test + func `migration observation is store scoped and survives unrelated failures`() { + let firstSettings = self.makeSettings() + let firstStore = self.makeStore(settings: firstSettings) + let secondSettings = self.makeSettings() + let secondStore = self.makeStore(settings: secondSettings) + + firstStore.observeGeminiConsumerTierDeprecation(from: GeminiStatusProbeError.consumerTierDeprecated) + firstStore.observeGeminiConsumerTierDeprecation(from: GeminiStatusProbeError.notLoggedIn) + + #expect(firstStore.geminiObservedConsumerTierDeprecation) + #expect(!secondStore.geminiObservedConsumerTierDeprecation) + + firstStore.clearGeminiConsumerTierDeprecationObservation() + #expect(!firstStore.geminiObservedConsumerTierDeprecation) + } +} diff --git a/Tests/CodexBarTests/GeminiStatusProbeAPITests.swift b/Tests/CodexBarTests/GeminiStatusProbeAPITests.swift index 976ac72b9f..3fcb60ade9 100644 --- a/Tests/CodexBarTests/GeminiStatusProbeAPITests.swift +++ b/Tests/CodexBarTests/GeminiStatusProbeAPITests.swift @@ -1,6 +1,11 @@ import CodexBarCore import Foundation import Testing +#if canImport(Darwin) +import Darwin +#else +import Glibc +#endif @Suite(.serialized) struct GeminiStatusProbeAPITests { @@ -15,11 +20,11 @@ struct GeminiStatusProbeAPITests { } } - @Test - func `rejects api key auth type`() async throws { + @Test(arguments: ["gemini-api-key", "api-key"]) + func `rejects api key auth types`(authType: String) async throws { let env = try GeminiTestEnvironment() defer { env.cleanup() } - try env.writeSettings(authType: "api-key") + try env.writeSettings(authType: authType) let probe = GeminiStatusProbe(timeout: 1, homeDirectory: env.homeURL.path) await Self.expectError(.unsupportedAuthType("API key")) { @@ -266,9 +271,17 @@ struct GeminiStatusProbeAPITests { } @Test - func `refreshes expired token with fnm bundle layout`() async throws { + func `refreshes expired token with fnm bundle layout when fnm keeps stdout open`() async throws { let env = try GeminiTestEnvironment() defer { env.cleanup() } + let childPIDFile = env.homeURL.appendingPathComponent("fnm-child.pid") + defer { + if let text = try? String(contentsOf: childPIDFile, encoding: .utf8), + let childPID = pid_t(text.trimmingCharacters(in: .whitespacesAndNewlines)) + { + _ = kill(childPID, SIGKILL) + } + } try env.writeCredentials( accessToken: "old-token", refreshToken: "refresh-token", @@ -290,9 +303,13 @@ struct GeminiStatusProbeAPITests { .deletingLastPathComponent() .deletingLastPathComponent() .path - _ = try env.writeFakeFnm(npmRoot: npmRoot, geminiPackageJSONPath: packageJSONPath.path) + _ = try env.writeFakeFnm( + npmRoot: npmRoot, + geminiPackageJSONPath: packageJSONPath.path, + holdNpmRootStdoutOpen: true) let previousPath = ProcessInfo.processInfo.environment["PATH"] + let previousPIDFile = ProcessInfo.processInfo.environment["CODEXBAR_TEST_CHILD_PID_FILE"] let fakeBinDir = env.homeURL.appendingPathComponent("bin").path let pathValue = if let previousPath, !previousPath.isEmpty { "\(fakeBinDir):\(binURL.deletingLastPathComponent().path):\(previousPath)" @@ -300,6 +317,7 @@ struct GeminiStatusProbeAPITests { "\(fakeBinDir):\(binURL.deletingLastPathComponent().path)" } setenv("PATH", pathValue, 1) + setenv("CODEXBAR_TEST_CHILD_PID_FILE", childPIDFile.path, 1) let previousGeminiPath = ProcessInfo.processInfo.environment["GEMINI_CLI_PATH"] setenv("GEMINI_CLI_PATH", binURL.path, 1) @@ -310,6 +328,12 @@ struct GeminiStatusProbeAPITests { unsetenv("PATH") } + if let previousPIDFile { + setenv("CODEXBAR_TEST_CHILD_PID_FILE", previousPIDFile, 1) + } else { + unsetenv("CODEXBAR_TEST_CHILD_PID_FILE") + } + if let previousGeminiPath { setenv("GEMINI_CLI_PATH", previousGeminiPath, 1) } else { @@ -370,11 +394,90 @@ struct GeminiStatusProbeAPITests { let probe = GeminiStatusProbe(timeout: 2, homeDirectory: env.homeURL.path, dataLoader: dataLoader) let snapshot = try await probe.fetch() #expect(snapshot.accountPlan == "Paid") + let childPIDText = try String(contentsOf: childPIDFile, encoding: .utf8) + let childPID = try #require(pid_t(childPIDText.trimmingCharacters(in: .whitespacesAndNewlines))) + #expect(kill(childPID, 0) == 0, "package discovery should return while the stdout-holding child is alive") let updated = try env.readCredentials() #expect(updated["access_token"] as? String == "new-token") } + @Test + func `fnm helper timeout hard stops a process that ignores SIGTERM`() throws { + let env = try GeminiTestEnvironment() + defer { env.cleanup() } + let pidFile = env.homeURL.appendingPathComponent("fnm-timeout.pid") + let helper = env.homeURL.appendingPathComponent("fnm-timeout") + try """ + #!/bin/sh + printf '%s\\n' "$$" > "$1" + trap '' TERM + while true; do sleep 1; done + """.write(to: helper, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: helper.path) + + let clock = ContinuousClock() + let start = clock.now + let result = GeminiStatusProbe.runProcess( + executable: helper.path, + arguments: [pidFile.path], + environment: [:], + timeout: 5) + let elapsed = start.duration(to: clock.now) + let text = try String(contentsOf: pidFile, encoding: .utf8) + let processID = try #require(pid_t(text.trimmingCharacters(in: .whitespacesAndNewlines))) + defer { _ = kill(processID, SIGKILL) } + + #expect(result == nil) + #expect(kill(processID, 0) == -1) + #expect(elapsed < .seconds(7.5), "Ignored SIGTERM should escalate to SIGKILL, took \(elapsed)") + } + + @Test + func `fnm helper completed no-output failure returns before deadline`() throws { + let env = try GeminiTestEnvironment() + defer { env.cleanup() } + let helper = env.homeURL.appendingPathComponent("fnm-failure") + try """ + #!/bin/sh + exit 23 + """.write(to: helper, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: helper.path) + + let clock = ContinuousClock() + let start = clock.now + let result = GeminiStatusProbe.runProcess( + executable: helper.path, + arguments: [], + environment: [:], + timeout: 10) + + #expect(result == nil) + #expect(start.duration(to: clock.now) < .seconds(5)) + } + + @Test + func `fnm helper successful output returns first line`() throws { + let env = try GeminiTestEnvironment() + defer { env.cleanup() } + let helper = env.homeURL.appendingPathComponent("fnm-success") + try """ + #!/bin/sh + sleep 0.05 + printf '%s\n' '/tmp/gemini-package' + printf '%s\n' 'ignored trailing output' + """.write(to: helper, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: helper.path) + + let result = GeminiStatusProbe.runProcess( + executable: helper.path, + arguments: [], + environment: [:], + timeout: 2) + + #expect(result == "/tmp/gemini-package") + } + @Test func `refreshes expired token with homebrew bundle layout`() async throws { let env = try GeminiTestEnvironment() @@ -576,33 +679,6 @@ struct GeminiStatusProbeAPITests { #expect(counts.fallback == 0) } - @Test - func `fails refresh when O auth config missing`() async throws { - let env = try GeminiTestEnvironment() - defer { env.cleanup() } - try env.writeCredentials( - accessToken: "old-token", - refreshToken: "refresh-token", - expiry: Date().addingTimeInterval(-3600), - idToken: nil) - - let binURL = try env.writeFakeGeminiCLI(includeOAuth: false) - let previousValue = ProcessInfo.processInfo.environment["GEMINI_CLI_PATH"] - setenv("GEMINI_CLI_PATH", binURL.path, 1) - defer { - if let previousValue { - setenv("GEMINI_CLI_PATH", previousValue, 1) - } else { - unsetenv("GEMINI_CLI_PATH") - } - } - - let probe = GeminiStatusProbe(timeout: 1, homeDirectory: env.homeURL.path) - await Self.expectError(.apiError("Could not find Gemini CLI OAuth configuration")) { - _ = try await probe.fetch() - } - } - @Test func `reports api errors`() async throws { let env = try GeminiTestEnvironment() diff --git a/Tests/CodexBarTests/GeminiStatusProbePlanTests.swift b/Tests/CodexBarTests/GeminiStatusProbePlanTests.swift index 9e38fb8534..0b89647f7b 100644 --- a/Tests/CodexBarTests/GeminiStatusProbePlanTests.swift +++ b/Tests/CodexBarTests/GeminiStatusProbePlanTests.swift @@ -252,6 +252,125 @@ struct GeminiStatusProbePlanTests { #expect(snapshot.accountPlan == "Workspace") } + @Test + func `detects consumer plus from free tier with paid tier name`() async throws { + let env = try GeminiTestEnvironment() + defer { env.cleanup() } + let idToken = GeminiAPITestHelpers.makeIDToken(email: "user@gmail.com") + try env.writeCredentials( + accessToken: "token", + refreshToken: nil, + expiry: Date().addingTimeInterval(3600), + idToken: idToken) + + let dataLoader = GeminiAPITestHelpers.dataLoader { request in + guard let url = request.url, let host = url.host else { + throw URLError(.badURL) + } + switch host { + case "cloudresourcemanager.googleapis.com": + return GeminiAPITestHelpers.response( + url: url.absoluteString, + status: 200, + body: GeminiAPITestHelpers.jsonData(["projects": []])) + case "cloudcode-pa.googleapis.com": + if url.path == "/v1internal:loadCodeAssist" { + return GeminiAPITestHelpers.response( + url: url.absoluteString, + status: 200, + body: GeminiAPITestHelpers.loadCodeAssistConsumerPlusResponse()) + } + if url.path != "/v1internal:retrieveUserQuota" { + return GeminiAPITestHelpers.response(url: url.absoluteString, status: 404, body: Data()) + } + return GeminiAPITestHelpers.response( + url: url.absoluteString, + status: 200, + body: GeminiAPITestHelpers.sampleQuotaResponse()) + default: + return GeminiAPITestHelpers.response(url: url.absoluteString, status: 404, body: Data()) + } + } + + let probe = GeminiStatusProbe(timeout: 1, homeDirectory: env.homeURL.path, dataLoader: dataLoader) + let snapshot = try await probe.fetch() + #expect(snapshot.accountPlan == "Plus") + } + + @Test + func `uses paid tier name for standard tier subscriptions`() async throws { + let env = try GeminiTestEnvironment() + defer { env.cleanup() } + try env.writeCredentials( + accessToken: "token", + refreshToken: nil, + expiry: Date().addingTimeInterval(3600), + idToken: nil) + + let dataLoader = GeminiAPITestHelpers.dataLoader { request in + guard let url = request.url, let host = url.host else { + throw URLError(.badURL) + } + switch host { + case "cloudresourcemanager.googleapis.com": + return GeminiAPITestHelpers.response( + url: url.absoluteString, + status: 200, + body: GeminiAPITestHelpers.jsonData(["projects": []])) + case "cloudcode-pa.googleapis.com": + if url.path == "/v1internal:loadCodeAssist" { + return GeminiAPITestHelpers.response( + url: url.absoluteString, + status: 200, + body: GeminiAPITestHelpers.loadCodeAssistGoogleOneProResponse()) + } + if url.path != "/v1internal:retrieveUserQuota" { + return GeminiAPITestHelpers.response(url: url.absoluteString, status: 404, body: Data()) + } + return GeminiAPITestHelpers.response( + url: url.absoluteString, + status: 200, + body: GeminiAPITestHelpers.sampleQuotaResponse()) + default: + return GeminiAPITestHelpers.response(url: url.absoluteString, status: 404, body: Data()) + } + } + + let probe = GeminiStatusProbe(timeout: 1, homeDirectory: env.homeURL.path, dataLoader: dataLoader) + let snapshot = try await probe.fetch() + #expect(snapshot.accountPlan == "Gemini Code Assist in Google One AI Pro") + } + + @Test + func `paid tier name overrides workspace fallback`() async throws { + let plan = try await Self.fetchPlan( + tierId: "free-tier", + hostedDomain: "example.com", + paidTierName: "Plus") + + #expect(plan == "Plus") + } + + @Test + func `paid tier name survives unknown current tier`() async throws { + let plan = try await Self.fetchPlan( + tierId: "future-tier", + hostedDomain: nil, + paidTierName: "Gemini Code Assist in Google One AI Pro") + + #expect(plan == "Gemini Code Assist in Google One AI Pro") + } + + @Test + func `paid tier name survives missing current tier`() async throws { + let plan = try await Self.fetchPlan( + tierId: nil, + hostedDomain: nil, + paidTierName: "Plus") + + #expect(plan == "Plus") + } + @Test func `detects free from free tier without hosted domain`() async throws { let env = try GeminiTestEnvironment() @@ -384,4 +503,55 @@ struct GeminiStatusProbePlanTests { let snapshot = try await probe.fetch() #expect(snapshot.accountPlan == nil) } + + private static func fetchPlan( + tierId: String?, + hostedDomain: String?, + paidTierName: String) async throws -> String? + { + let env = try GeminiTestEnvironment() + defer { env.cleanup() } + let idToken = GeminiAPITestHelpers.makeIDToken( + email: "user@example.com", + hostedDomain: hostedDomain) + try env.writeCredentials( + accessToken: "token", + refreshToken: nil, + expiry: Date().addingTimeInterval(3600), + idToken: idToken) + + let dataLoader = GeminiAPITestHelpers.dataLoader { request in + guard let url = request.url, let host = url.host else { + throw URLError(.badURL) + } + switch host { + case "cloudresourcemanager.googleapis.com": + return GeminiAPITestHelpers.response( + url: url.absoluteString, + status: 200, + body: GeminiAPITestHelpers.jsonData(["projects": []])) + case "cloudcode-pa.googleapis.com": + if url.path == "/v1internal:loadCodeAssist" { + return GeminiAPITestHelpers.response( + url: url.absoluteString, + status: 200, + body: GeminiAPITestHelpers.loadCodeAssistResponse( + tierId: tierId, + paidTierName: paidTierName)) + } + if url.path == "/v1internal:retrieveUserQuota" { + return GeminiAPITestHelpers.response( + url: url.absoluteString, + status: 200, + body: GeminiAPITestHelpers.sampleQuotaResponse()) + } + return GeminiAPITestHelpers.response(url: url.absoluteString, status: 404, body: Data()) + default: + return GeminiAPITestHelpers.response(url: url.absoluteString, status: 404, body: Data()) + } + } + + let probe = GeminiStatusProbe(timeout: 1, homeDirectory: env.homeURL.path, dataLoader: dataLoader) + return try await probe.fetch().accountPlan + } } diff --git a/Tests/CodexBarTests/GeminiTestEnvironment.swift b/Tests/CodexBarTests/GeminiTestEnvironment.swift index 6e7ce8d8a7..b22cc6e610 100644 --- a/Tests/CodexBarTests/GeminiTestEnvironment.swift +++ b/Tests/CodexBarTests/GeminiTestEnvironment.swift @@ -300,12 +300,31 @@ struct GeminiTestEnvironment { func writeFakeFnm( currentVersion: String = "v24.6.0", npmRoot: String? = nil, - geminiPackageJSONPath: String) throws -> URL + geminiPackageJSONPath: String, + holdNpmRootStdoutOpen: Bool = false) throws -> URL { let binDir = self.homeURL.appendingPathComponent("bin") try FileManager.default.createDirectory(at: binDir, withIntermediateDirectories: true) let fnmPath = binDir.appendingPathComponent("fnm") + let stdoutHolder = holdNpmRootStdoutOpen + ? #""" + python3 - <<'PY' + import os + import subprocess + import sys + + child = subprocess.Popen( + [sys.executable, "-c", "import time; time.sleep(30)"], + stdin=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + start_new_session=True, + ) + with open(os.environ["CODEXBAR_TEST_CHILD_PID_FILE"], "w") as handle: + handle.write(str(child.pid)) + PY + """# + : ":" let script = if let npmRoot { """ #!/bin/bash @@ -315,6 +334,7 @@ struct GeminiTestEnvironment { fi if [ "$1" = "exec" ] && [ "$4" = "npm" ] && [ "$5" = "root" ] && [ "$6" = "-g" ]; then + \(stdoutHolder) printf '%s\n' "\(npmRoot)" exit 0 fi diff --git a/Tests/CodexBarTests/GoogleWorkspaceStatusNetworkTests.swift b/Tests/CodexBarTests/GoogleWorkspaceStatusNetworkTests.swift index 9266127293..1d51d3dfa2 100644 --- a/Tests/CodexBarTests/GoogleWorkspaceStatusNetworkTests.swift +++ b/Tests/CodexBarTests/GoogleWorkspaceStatusNetworkTests.swift @@ -1,4 +1,5 @@ import Foundation +import os import Testing @testable import CodexBar @@ -41,4 +42,29 @@ struct GoogleWorkspaceStatusNetworkTests { #expect(requests.count == 1) #expect(requests.first?.url?.host == "www.google.com") } + + @Test + func `fetchWorkspaceStatus decodes off the main thread when called from the main actor`() async throws { + // The incidents feed can run to hundreds of kilobytes; decoding it on the main + // actor stalls the UI for 150-340ms per Google-status provider per refresh (#1399). + let decodedOffMainThread = OSAllocatedUnfairLock(initialState: false) + let transport = ProviderHTTPTransportStub { request in + let response = try HTTPURLResponse( + url: #require(request.url), + statusCode: 200, + httpVersion: "HTTP/1.1", + headerFields: ["Content-Type": "application/json"])! + return (Data("[]".utf8), response) + } + + let status = try await UsageStore.fetchWorkspaceStatus( + productID: "npdyhgECDJ6tB66MxXyo", + transport: transport, + beforeDecoding: { + decodedOffMainThread.withLock { $0 = !Thread.isMainThread } + }) + + #expect(status.indicator == .none) + #expect(decodedOffMainThread.withLock { $0 }) + } } diff --git a/Tests/CodexBarTests/GrokAuthTests.swift b/Tests/CodexBarTests/GrokAuthTests.swift index a2ed52e554..d37b74dc64 100644 --- a/Tests/CodexBarTests/GrokAuthTests.swift +++ b/Tests/CodexBarTests/GrokAuthTests.swift @@ -16,6 +16,7 @@ struct GrokAuthTests { "first_name": "Ada", "last_name": "Lovelace", "team_id": "team-uuid", + "principal_type": "Team", "refresh_token": "refresh-secret", "expires_at": "2026-05-22T19:31:33.384327Z", "oidc_issuer": "https://auth.x.ai", @@ -30,6 +31,8 @@ struct GrokAuthTests { #expect(creds.refreshToken == "refresh-secret") #expect(creds.email == "user@example.com") #expect(creds.teamId == "team-uuid") + #expect(creds.principalType == "Team") + #expect(creds.isTeamPrincipal) #expect(creds.authMode == "oidc") #expect(creds.displayName == "Ada Lovelace") #expect(creds.loginMethod == "SuperGrok") @@ -143,6 +146,66 @@ struct GrokAuthTests { #expect(!GrokStatusProbe.shouldSurfaceRemoteAuthError(GrokWebBillingError.parseFailed)) } + @Test + func `team method unavailable is classified without broadening other rpc failures`() { + #expect(GrokStatusProbe.isBillingMethodUnavailable( + GrokRPCError.requestFailed("Method not found"))) + #expect(GrokStatusProbe.isBillingMethodUnavailable( + GrokRPCError.requestFailed("Method not found: x.ai/billing"))) + #expect(!GrokStatusProbe.isBillingMethodUnavailable( + GrokRPCError.requestFailed("Authentication required"))) + #expect(!GrokStatusProbe.isBillingMethodUnavailable(nil)) + } + + @Test + func `team identity fallback requires an attempted billing call`() throws { + let json = #"{"https://auth.x.ai::client":{"key":"token","principal_type":"Team"}}"# + let credentials = try GrokCredentialsStore.parse(data: Data(json.utf8)) + let methodNotFound = GrokRPCError.requestFailed("Method not found") + + #expect(GrokStatusProbe.shouldUseIdentityOnlyFallback( + credentials: credentials, + billingAttempted: true, + error: methodNotFound)) + #expect(!GrokStatusProbe.shouldUseIdentityOnlyFallback( + credentials: credentials, + billingAttempted: false, + error: methodNotFound)) + } + + @Test + func `principal type matching is case and whitespace insensitive`() throws { + let json = #"{"https://auth.x.ai::client":{"key":"token","principal_type":" team "}}"# + let credentials = try GrokCredentialsStore.parse(data: Data(json.utf8)) + #expect(credentials.isTeamPrincipal) + } + + @Test + func `identity-only team snapshot retains identity and diagnostic`() throws { + let json = #""" + { + "https://auth.x.ai::client": { + "key": "token", + "email": "team@example.com", + "team_id": "team-123", + "principal_type": "Team" + } + } + """# + let credentials = try GrokCredentialsStore.parse(data: Data(json.utf8)) + let snapshot = GrokStatusProbe.identityOnlySnapshot( + credentials: credentials, + localSummary: nil, + cliVersion: "0.1.210", + updatedAt: Date(timeIntervalSince1970: 1_800_000_000)) + + let usage = snapshot.toUsageSnapshot() + #expect(usage.primary == nil) + #expect(usage.accountEmail(for: .grok) == "team@example.com") + #expect(usage.accountOrganization(for: .grok) == "team-123") + #expect(snapshot.diagnostic == GrokStatusProbe.teamUsageUnavailableMessage) + } + @Test func `falls back to legacy when OIDC entry has no key`() throws { // A stale/partial OIDC record must not shadow a healthy legacy session. diff --git a/Tests/CodexBarTests/GrokMenuCardModelTests.swift b/Tests/CodexBarTests/GrokMenuCardModelTests.swift new file mode 100644 index 0000000000..009ef45e8d --- /dev/null +++ b/Tests/CodexBarTests/GrokMenuCardModelTests.swift @@ -0,0 +1,127 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +struct GrokMenuCardModelTests { + @Test + func `weekly CLI quota shows projection and pace marker`() throws { + let now = Date(timeIntervalSince1970: 0) + let model = try Self.model( + now: now, + window: RateWindow( + usedPercent: 50, + windowMinutes: 7 * 24 * 60, + resetsAt: now.addingTimeInterval(4 * 24 * 3600), + resetDescription: nil)) + + let metric = try #require(model.metrics.first { $0.id == "primary" }) + #expect(metric.title == "Weekly") + #expect(metric.detailLeftText == "7% in deficit") + #expect(metric.detailRightText == "Runs out in 3d") + #expect(metric.pacePercent != nil) + #expect(metric.paceOnTop == false) + } + + @Test + func `weekly web quota infers projection from reset date`() throws { + let now = Date(timeIntervalSince1970: 0) + let model = try Self.model( + now: now, + window: RateWindow( + usedPercent: 50, + windowMinutes: nil, + resetsAt: now.addingTimeInterval(4 * 24 * 3600), + resetDescription: nil)) + + let metric = try #require(model.metrics.first { $0.id == "primary" }) + #expect(metric.title == "Weekly") + #expect(metric.detailLeftText == "7% in deficit") + #expect(metric.detailRightText == "Runs out in 3d") + #expect(metric.pacePercent != nil) + #expect(metric.paceOnTop == false) + } + + @Test + func `weekly web quota beyond default duration does not show projection`() throws { + let now = Date(timeIntervalSince1970: 0) + let model = try Self.model( + now: now, + window: RateWindow( + usedPercent: 50, + windowMinutes: nil, + resetsAt: now.addingTimeInterval(8 * 24 * 3600), + resetDescription: nil)) + + let metric = try #require(model.metrics.first { $0.id == "primary" }) + #expect(metric.title == "Weekly") + #expect(metric.detailLeftText == nil) + #expect(metric.detailRightText == nil) + #expect(metric.pacePercent == nil) + } + + @Test + func `monthly quota does not show weekly projection`() throws { + let now = Date(timeIntervalSince1970: 0) + let model = try Self.model( + now: now, + window: RateWindow( + usedPercent: 50, + windowMinutes: 30 * 24 * 60, + resetsAt: now.addingTimeInterval(20 * 24 * 3600), + resetDescription: nil)) + + let metric = try #require(model.metrics.first { $0.id == "primary" }) + #expect(metric.title == "Monthly") + #expect(metric.detailLeftText == nil) + #expect(metric.detailRightText == nil) + #expect(metric.pacePercent == nil) + } + + @Test + func `unclassified quota does not show weekly projection`() throws { + let now = Date(timeIntervalSince1970: 0) + let model = try Self.model( + now: now, + window: RateWindow( + usedPercent: 50, + windowMinutes: nil, + resetsAt: now.addingTimeInterval(2 * 24 * 3600), + resetDescription: nil)) + + let metric = try #require(model.metrics.first { $0.id == "primary" }) + #expect(metric.title == "Credits") + #expect(metric.detailLeftText == nil) + #expect(metric.detailRightText == nil) + #expect(metric.pacePercent == nil) + } + + private static func model(now: Date, window: RateWindow) throws -> UsageMenuCardView.Model { + let metadata = try #require(ProviderDefaults.metadata[.grok]) + let snapshot = UsageSnapshot( + primary: window, + secondary: nil, + tertiary: nil, + updatedAt: now, + identity: nil) + return UsageMenuCardView.Model.make(.init( + provider: .grok, + metadata: metadata, + snapshot: snapshot, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + } +} diff --git a/Tests/CodexBarTests/GrokWebBillingFetcherTests.swift b/Tests/CodexBarTests/GrokWebBillingFetcherTests.swift index 38b19a81d6..afe19ac254 100644 --- a/Tests/CodexBarTests/GrokWebBillingFetcherTests.swift +++ b/Tests/CodexBarTests/GrokWebBillingFetcherTests.swift @@ -54,15 +54,6 @@ struct GrokWebBillingFetcherTests { #expect(GrokProviderDescriptor.primaryLabel(resetsAt: nil) == nil) } - @Test - func `cli runtime does not import browser cookies unless explicitly enabled`() { - #expect(GrokWebFetchStrategy.canImportBrowserCookies(runtime: .app, env: [:])) - #expect(!GrokWebFetchStrategy.canImportBrowserCookies(runtime: .cli, env: [:])) - #expect(GrokWebFetchStrategy.canImportBrowserCookies( - runtime: .cli, - env: ["CODEXBAR_ALLOW_BROWSER_COOKIE_IMPORT": "1"])) - } - @Test func `web strategy tries later browser session when first cookie is stale`() async throws { let stale = try #require(Self.cookie(name: "sso", value: "stale")) @@ -73,7 +64,7 @@ struct GrokWebBillingFetcherTests { ] var attemptedHeaders: [String] = [] - let result = try await GrokWebFetchStrategy.fetchFirstValidCookieSession(sessions) { cookieHeader in + let result = try await GrokWebFetchStrategy.fetchFirstValidCookieSession(sessions) { cookieHeader, _ in attemptedHeaders.append(cookieHeader) guard cookieHeader.contains("valid") else { throw GrokWebBillingError.requestFailed(401, "stale") @@ -113,6 +104,295 @@ struct GrokWebBillingFetcherTests { #expect(snapshot.resetsAt == Date(timeIntervalSince1970: TimeInterval(reset))) } + @Test + func `parses unframed grok billing protobuf payload`() throws { + let hex = + "0a3f0d7f6a9c3f12001a002206088097f3d0062a060880b191d2063a07080215a9389b3f3a07080115d6ea183c" + + "421208011206088097f3d0061a060880b191d206" + let data = try #require(Self.data(hexString: hex)) + + let snapshot = try GrokWebBillingFetcher.parseGRPCWebResponse( + data, + now: Date(timeIntervalSince1970: 1_780_000_000)) + + #expect(snapshot.usedPercent == 1.222000002861023) + #expect(snapshot.resetsAt == Date(timeIntervalSince1970: 1_782_864_000)) + } + + @Test + func `parses unframed zero percent payload that resembles an empty grpc frame`() throws { + let reset = UInt64(1_800_000_000) + let payload = Self.protobufPayload(usedPercent: 0, resetEpoch: reset) + + #expect(GrokWebBillingFetcher.grpcWebDataFrames(from: payload).isEmpty) + + let snapshot = try GrokWebBillingFetcher.parseGRPCWebResponse( + payload, + now: Date(timeIntervalSince1970: 1_799_000_000)) + + #expect(snapshot.usedPercent == 0) + #expect(snapshot.resetsAt == Date(timeIntervalSince1970: TimeInterval(reset))) + } + + @Test + func `does not treat grpc frame prefix as raw protobuf`() { + #expect(!GrokWebBillingFetcher.looksLikeProtobufPayload(Data([0, 0, 0, 0, 10]))) + } + + @Test + func `web strategy tries cookie plus bearer before cookie only`() async throws { + let cookie = try #require(Self.cookie(name: "sso", value: "session")) + let sessions = [GrokCookieImporter.SessionInfo(cookies: [cookie], sourceLabel: "Chrome")] + var attempts: [String] = [] + + let result = try await GrokWebFetchStrategy.fetchFirstValidCookieSession( + sessions, + credentials: Self.credentials) + { _, authCredentials in + let mode = authCredentials == nil ? "cookie-only" : "cookie+bearer" + attempts.append(mode) + guard mode == "cookie+bearer" else { + throw GrokWebBillingError.requestFailed(401, "needs bearer") + } + return GrokWebBillingSnapshot(usedPercent: 9, resetsAt: nil) + } + + #expect(attempts == ["cookie+bearer"]) + #expect(result.0.usedPercent == 9) + } + + @Test + func `cookie session loop preserves team unsupported billing`() async throws { + let cookie = try #require(Self.cookie(name: "sso", value: "team-session")) + let sessions = [GrokCookieImporter.SessionInfo(cookies: [cookie], sourceLabel: "Chrome")] + + await #expect { + _ = try await GrokWebFetchStrategy.fetchFirstValidCookieSession( + sessions, + credentials: Self.credentials) + { _, authCredentials in + if authCredentials != nil { + throw GrokWebBillingError.teamUsageUnsupported + } + throw GrokWebBillingError.rpcFailed(9, "No personal team") + } + } throws: { error in + guard case GrokWebBillingError.teamUsageUnsupported = error else { return false } + return true + } + } + + @Test + func `web strategy skips expired bearer for browser cookies`() async throws { + let cookie = try #require(Self.cookie(name: "sso", value: "session")) + let sessions = [GrokCookieImporter.SessionInfo(cookies: [cookie], sourceLabel: "Chrome")] + let expired = GrokCredentials( + accessToken: "expired-token", + refreshToken: nil, + scope: "https://auth.x.ai::client", + authMode: "oidc", + userId: nil, + email: nil, + firstName: nil, + lastName: nil, + teamId: nil, + oidcIssuer: nil, + oidcClientId: nil, + expiresAt: .distantPast, + createTime: nil) + var attempts: [String] = [] + + _ = try await GrokWebFetchStrategy.fetchFirstValidCookieSession( + sessions, + credentials: expired) + { _, authCredentials in + attempts.append(authCredentials == nil ? "cookie-only" : "cookie+bearer") + return GrokWebBillingSnapshot(usedPercent: 9, resetsAt: nil) + } + + #expect(attempts == ["cookie-only"]) + } + + @Test + func `web strategy preserves malformed auth file error`() async throws { + let grokHome = FileManager.default.temporaryDirectory + .appendingPathComponent("CodexBar-GrokWebBilling-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: grokHome, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: grokHome) } + try Data("not-json".utf8).write(to: grokHome.appendingPathComponent("auth.json")) + + let browserDetection = BrowserDetection(cacheTTL: 0) + let context = ProviderFetchContext( + runtime: .cli, + sourceMode: .web, + includeCredits: true, + webTimeout: 1, + webDebugDumpHTML: false, + verbose: false, + env: ["GROK_HOME": grokHome.path], + settings: nil, + fetcher: UsageFetcher(), + claudeFetcher: ClaudeUsageFetcher(browserDetection: browserDetection), + browserDetection: browserDetection) + + await #expect { + _ = try await GrokWebFetchStrategy().fetch(context) + } throws: { error in + guard case GrokCredentialsError.decodeFailed = error else { return false } + return true + } + } + + @Test + func `status seven scope failure is not classified as bad credentials`() { + #expect(!GrokWebBillingError.isAuthenticationFailure( + status: 7, + message: "OAuth2 access token lacks the required billing scope")) + } + + @Test + func `only a team principal with no personal team gets unsupported billing guidance`() { + #expect(GrokWebBillingFetcher.isTeamBillingUnavailable( + status: 9, + message: "No personal team")) + #expect(GrokWebBillingFetcher.isTeamBillingUnavailable( + status: 9, + message: " no PERSONAL team ")) + #expect(GrokWebBillingFetcher.isTeamBillingUnavailable( + status: 9, + message: "No personal team.")) + #expect(!GrokWebBillingFetcher.isTeamBillingUnavailable( + status: 9, + message: "Permission denied")) + #expect(!GrokWebBillingFetcher.isTeamBillingUnavailable( + status: 7, + message: "No personal team")) + #expect(GrokWebBillingError.teamUsageUnsupported.errorDescription?.contains("identity") == false) + } + + @Test + func `team principal status nine response is classified as unsupported billing`() async throws { + defer { + GrokWebBillingStubURLProtocol.requests = [] + GrokWebBillingStubURLProtocol.requestBodies = [] + GrokWebBillingStubURLProtocol.handler = nil + } + + let config = URLSessionConfiguration.ephemeral + config.protocolClasses = [GrokWebBillingStubURLProtocol.self] + let session = URLSession(configuration: config) + let endpoint = try #require(URL(string: "https://grok.test/grok_api_v2.GrokBuildBilling/GetGrokCreditsConfig")) + let message = "No personal team.".addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) + ?? "No personal team." + let body = Self.grpcFrame( + Data("grpc-status: 9\r\ngrpc-message: \(message)\r\n".utf8), + flags: 0x80) + + GrokWebBillingStubURLProtocol.handler = { request in + let url = try #require(request.url) + let response = HTTPURLResponse( + url: url, + statusCode: 200, + httpVersion: nil, + headerFields: ["Content-Type": "application/grpc-web+proto"])! + return (response, body) + } + + await #expect { + _ = try await GrokWebBillingFetcher.fetch( + credentials: Self.credentials, + session: session, + endpoint: endpoint) + } throws: { error in + guard case GrokWebBillingError.teamUsageUnsupported = error else { return false } + return true + } + + let expiredCredentials = GrokCredentials( + accessToken: "expired-token", + refreshToken: nil, + scope: Self.credentials.scope, + authMode: Self.credentials.authMode, + userId: Self.credentials.userId, + email: Self.credentials.email, + firstName: Self.credentials.firstName, + lastName: Self.credentials.lastName, + teamId: Self.credentials.teamId, + principalType: Self.credentials.principalType, + oidcIssuer: Self.credentials.oidcIssuer, + oidcClientId: Self.credentials.oidcClientId, + expiresAt: .distantPast, + createTime: Self.credentials.createTime) + + await #expect { + _ = try await GrokWebBillingFetcher.fetch( + cookieHeader: "sso=team-session", + credentials: expiredCredentials, + session: session, + endpoint: endpoint) + } throws: { error in + guard case let GrokWebBillingError.rpcFailed(status, message) = error else { return false } + return status == 9 && message == "No personal team." + } + + await #expect { + _ = try await GrokWebBillingFetcher.fetch( + credentials: expiredCredentials, + session: session, + endpoint: endpoint) + } throws: { error in + guard case let GrokWebBillingError.rpcFailed(status, message) = error else { return false } + return status == 9 && message == "No personal team." + } + } + + @Test + func `web strategy publishes identity-only result for team billing`() async throws { + let grokHome = FileManager.default.temporaryDirectory + .appendingPathComponent("CodexBar-GrokTeamFallback-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: grokHome, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: grokHome) } + let auth = #""" + { + "https://auth.x.ai::client": { + "key": "team-token", + "email": "team@example.com", + "team_id": "team-123", + "principal_type": "Team" + } + } + """# + try Data(auth.utf8).write(to: grokHome.appendingPathComponent("auth.json")) + + let browserDetection = BrowserDetection(cacheTTL: 0) + let context = ProviderFetchContext( + runtime: .cli, + sourceMode: .web, + includeCredits: true, + webTimeout: 1, + webDebugDumpHTML: false, + verbose: false, + env: [ + "GROK_HOME": grokHome.path, + "GROK_CLI_PATH": grokHome.appendingPathComponent("missing-grok").path, + "PATH": grokHome.path, + ], + settings: nil, + fetcher: UsageFetcher(), + claudeFetcher: ClaudeUsageFetcher(browserDetection: browserDetection), + browserDetection: browserDetection) + + let result = try await GrokWebFetchStrategy().fetch(context) { + throw GrokWebBillingError.teamUsageUnsupported + } + + #expect(result.sourceLabel == "grok-web") + #expect(result.diagnostic == GrokStatusProbe.teamUsageUnavailableMessage) + #expect(result.usage.primary == nil) + #expect(result.usage.accountEmail(for: .grok) == "team@example.com") + #expect(result.usage.accountOrganization(for: .grok) == "team-123") + } + @Test func `ignores grpc web trailer frames`() { let payload = Self.protobufPayload(usedPercent: 12.25, resetEpoch: 1_800_000_001) @@ -201,6 +481,48 @@ struct GrokWebBillingFetcherTests { } } + @Test + func `web fetch turns grpc permission denied bad credentials into reauth guidance`() async throws { + defer { + GrokWebBillingStubURLProtocol.requests = [] + GrokWebBillingStubURLProtocol.requestBodies = [] + GrokWebBillingStubURLProtocol.handler = nil + } + + let config = URLSessionConfiguration.ephemeral + config.protocolClasses = [GrokWebBillingStubURLProtocol.self] + let session = URLSession(configuration: config) + let endpoint = try #require(URL(string: "https://grok.test/grok_api_v2.GrokBuildBilling/GetGrokCreditsConfig")) + let message = "The OAuth2 access token could not be validated. [WKE=unauthenticated:bad-credentials]" + let encodedMessage = message.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? message + let body = Self.grpcFrame( + Data("grpc-status: 7\r\ngrpc-message: \(encodedMessage)\r\n".utf8), + flags: 0x80) + + GrokWebBillingStubURLProtocol.requests = [] + GrokWebBillingStubURLProtocol.requestBodies = [] + GrokWebBillingStubURLProtocol.handler = { request in + let url = try #require(request.url) + let response = HTTPURLResponse( + url: url, + statusCode: 200, + httpVersion: nil, + headerFields: ["Content-Type": "application/grpc-web+proto"])! + return (response, body) + } + + await #expect { + _ = try await GrokWebBillingFetcher.fetch( + credentials: Self.credentials, + session: session, + endpoint: endpoint) + } throws: { error in + error.localizedDescription.contains("grok.com") && + error.localizedDescription.contains("grok login") && + !error.localizedDescription.contains("status 7") + } + } + @Test func `rejects reset only billing because it cannot render usage`() { var payload = Data() @@ -238,6 +560,28 @@ struct GrokWebBillingFetcherTests { #expect(snapshot.resetsAt == Date(timeIntervalSince1970: 1_780_272_000)) } + @Test + func `parses omitted zero percent with current billing period`() throws { + let data = Data([ + 0x00, 0x00, 0x00, 0x00, 0x2A, 0x0A, 0x28, 0x12, + 0x00, 0x1A, 0x00, 0x22, 0x06, 0x08, 0x80, 0x97, + 0xF3, 0xD0, 0x06, 0x2A, 0x06, 0x08, 0x80, 0xB1, + 0x91, 0xD2, 0x06, 0x42, 0x12, 0x08, 0x01, 0x12, + 0x06, 0x08, 0x80, 0x97, 0xF3, 0xD0, 0x06, 0x1A, + 0x06, 0x08, 0x80, 0xB1, 0x91, 0xD2, 0x06, 0x80, + 0x00, 0x00, 0x00, 0x0F, 0x67, 0x72, 0x70, 0x63, + 0x2D, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x3A, + 0x30, 0x0D, 0x0A, + ]) + + let snapshot = try GrokWebBillingFetcher.parseGRPCWebResponse( + data, + now: Date(timeIntervalSince1970: 1_781_000_000)) + + #expect(snapshot.usedPercent == 0) + #expect(snapshot.resetsAt == Date(timeIntervalSince1970: 1_782_864_000)) + } + @Test func `uses billing field one instead of earlier unrelated float`() throws { var payload = Data() @@ -446,7 +790,9 @@ struct GrokWebBillingFetcherTests { #expect(attempts.current() == 2) #expect(snapshot.usedPercent == 25) } +} +extension GrokWebBillingFetcherTests { @Test func `web fetch can authenticate with browser cookies`() async throws { defer { @@ -483,6 +829,41 @@ struct GrokWebBillingFetcherTests { #expect(snapshot.usedPercent == 9) } + @Test + func `web fetch sends browser cookies with bearer credentials`() async throws { + defer { + GrokWebBillingStubURLProtocol.requests = [] + GrokWebBillingStubURLProtocol.requestBodies = [] + GrokWebBillingStubURLProtocol.handler = nil + } + + let config = URLSessionConfiguration.ephemeral + config.protocolClasses = [GrokWebBillingStubURLProtocol.self] + let session = URLSession(configuration: config) + let endpoint = try #require(URL(string: "https://grok.test/grok_api_v2.GrokBuildBilling/GetGrokCreditsConfig")) + + GrokWebBillingStubURLProtocol.requests = [] + GrokWebBillingStubURLProtocol.requestBodies = [] + GrokWebBillingStubURLProtocol.handler = { request in + #expect(request.value(forHTTPHeaderField: "Cookie") == "sso=session") + #expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer token-123") + let response = HTTPURLResponse( + url: endpoint, + statusCode: 200, + httpVersion: nil, + headerFields: ["Content-Type": "application/grpc-web+proto"])! + return (response, Self.protobufPayload(usedPercent: 9, resetEpoch: 1_800_000_004)) + } + + let snapshot = try await GrokWebBillingFetcher.fetch( + cookieHeader: "sso=session", + credentials: Self.credentials, + session: session, + endpoint: endpoint) + + #expect(snapshot.usedPercent == 9) + } + @Test func `web fetch turns unauthorized response into reauth guidance`() async throws { defer { @@ -549,6 +930,7 @@ struct GrokWebBillingFetcherTests { firstName: "G", lastName: "Rok", teamId: "team-123", + principalType: "Team", oidcIssuer: "https://auth.x.ai", oidcClientId: "client", expiresAt: Date(timeIntervalSince1970: 1_900_000_000), @@ -578,7 +960,9 @@ struct GrokWebBillingFetcherTests { repeat { var byte = UInt8(remaining & 0x7F) remaining >>= 7 - if remaining != 0 { byte |= 0x80 } + if remaining != 0 { + byte |= 0x80 + } bytes.append(byte) } while remaining != 0 return bytes @@ -592,12 +976,164 @@ struct GrokWebBillingFetcherTests { .value: value, ]) } + + private static func data(hexString: String) -> Data? { + var data = Data() + var index = hexString.startIndex + while index < hexString.endIndex { + let next = hexString.index(index, offsetBy: 2, limitedBy: hexString.endIndex) ?? hexString.endIndex + guard let byte = UInt8(hexString[index.. (HTTPURLResponse, Data))? + private static let _handlerBox = LockIsolated<(@Sendable (URLRequest) throws -> (HTTPURLResponse, Data))?>(nil) + static var handler: (@Sendable (URLRequest) throws -> (HTTPURLResponse, Data))? { + get { Self._handlerBox.value } + set { Self._handlerBox.setValue(newValue) } + } override static func canInit(with _: URLRequest) -> Bool { true @@ -628,7 +1164,9 @@ final class GrokWebBillingStubURLProtocol: URLProtocol { override func stopLoading() {} private static func readBody(from request: URLRequest) -> Data? { - if let body = request.httpBody { return body } + if let body = request.httpBody { + return body + } guard let stream = request.httpBodyStream else { return nil } stream.open() defer { stream.close() } diff --git a/Tests/CodexBarTests/GroqConsoleFetcherTests.swift b/Tests/CodexBarTests/GroqConsoleFetcherTests.swift new file mode 100644 index 0000000000..ca2c616048 --- /dev/null +++ b/Tests/CodexBarTests/GroqConsoleFetcherTests.swift @@ -0,0 +1,117 @@ +import CodexBarCore +import Foundation +import Testing + +struct GroqConsoleFetcherTests { + /// A JWT whose payload carries the Groq organization claim. Signature is a + /// placeholder — only the (unverified) payload segment is read. + private static func makeJWT(orgID: String) -> String { + let payload = "{\"https://groq.com/organization\":{\"id\":\"\(orgID)\"}}" + let encoded = Data(payload.utf8).base64EncodedString() + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") + return "header.\(encoded).signature" + } + + @Test + func `decodes organization id from jwt claim`() { + let jwt = Self.makeJWT(orgID: "org_abc123") + #expect(GroqConsoleFetcher.organizationID(fromJWT: jwt) == "org_abc123") + } + + @Test + func `falls back to stytch slug when groq claim absent`() { + let payload = "{\"https://stytch.com/organization\":{\"slug\":\"org_slug9\"}}" + let encoded = Data(payload.utf8).base64EncodedString() + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") + let jwt = "h.\(encoded).s" + #expect(GroqConsoleFetcher.organizationID(fromJWT: jwt) == "org_slug9") + } + + @Test + func `returns nil for malformed jwt`() { + #expect(GroqConsoleFetcher.organizationID(fromJWT: "not-a-jwt") == nil) + #expect(GroqConsoleFetcher.organizationID(fromJWT: "only.two") == nil) + } + + @Test + func `aggregates activity rows into daily buckets`() throws { + // Two models on the same UTC day plus one on the next day. + let json = """ + {"object":"list","data":[ + {"organization_name":"Personal","model":"llama-3.1-8b-instant","timestamp":1783900800, + "num_requests":3,"n_context_tokens_total":100,"n_non_cached_context_tokens_total":80, + "n_generated_tokens_total":40,"cost":0.01}, + {"organization_name":"Personal","model":"openai/gpt-oss-120b","timestamp":1783901000, + "num_requests":2,"n_context_tokens_total":50,"n_non_cached_context_tokens_total":50, + "n_generated_tokens_total":10,"cost":0.02}, + {"organization_name":"Personal","model":"llama-3.1-8b-instant","timestamp":1783987200, + "num_requests":1,"n_context_tokens_total":10,"n_non_cached_context_tokens_total":10, + "n_generated_tokens_total":5,"cost":0.005} + ]} + """ + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = try #require(TimeZone(identifier: "UTC")) + + let snapshot = try GroqConsoleFetcher._makeSnapshotForTesting( + activityJSON: Data(json.utf8), + historyDays: 30, + updatedAt: Date(timeIntervalSince1970: 1_783_987_200), + calendar: calendar) + + #expect(snapshot.daily.count == 2) + #expect(snapshot.organizationName == "Personal") + + // Day one merges the two models. + let dayOne = snapshot.daily.first + #expect(dayOne?.requests == 5) + #expect(dayOne?.inputTokens == 130) // 80 + 50 non-cached + #expect(dayOne?.cachedInputTokens == 20) // (100-80) + (50-50) + #expect(dayOne?.outputTokens == 50) // 40 + 10 + #expect(dayOne?.totalTokens == 200) // (100+40) + (50+10) + #expect((dayOne?.costUSD ?? 0) == 0.03) + #expect(dayOne?.models.count == 2) + + // Window totals surface via the cost-history projection. + let projected = snapshot.toCostUsageTokenSnapshot() + #expect(projected.last30DaysRequests == 6) + #expect(abs((projected.last30DaysCostUSD ?? 0) - 0.035) < 1e-9) + } + + @Test + func `parses session and jwt from cookie header`() { + let header = "stytch_session=opaque123; stytch_session_jwt=jwt.abc.def; other=x" + let session = GroqConsoleSession.session(fromCookieHeader: header) + #expect(session?.sessionToken == "opaque123") + #expect(session?.directJWT == "jwt.abc.def") + } + + @Test + func `usage snapshot exposes provider cost and console usage`() { + let bucket = GroqConsoleUsageSnapshot.DailyBucket( + day: "2026-07-13", + startTime: Date(timeIntervalSince1970: 1_783_900_800), + endTime: Date(timeIntervalSince1970: 1_783_987_200), + costUSD: 0.5, + requests: 10, + inputTokens: 100, + cachedInputTokens: 0, + outputTokens: 50, + totalTokens: 150, + models: []) + let snapshot = GroqConsoleUsageSnapshot( + daily: [bucket], + updatedAt: Date(timeIntervalSince1970: 1_783_987_200), + historyDays: 30, + organizationName: "Personal") + .toUsageSnapshot() + + #expect(snapshot.identity?.providerID == .groq) + #expect(snapshot.identity?.loginMethod == "Console") + #expect(snapshot.providerCost?.used == 0.5) + #expect(snapshot.groqConsoleUsage?.daily.count == 1) + } +} diff --git a/Tests/CodexBarTests/GroqMenuCardModelTests.swift b/Tests/CodexBarTests/GroqMenuCardModelTests.swift new file mode 100644 index 0000000000..8a903934ed --- /dev/null +++ b/Tests/CodexBarTests/GroqMenuCardModelTests.swift @@ -0,0 +1,65 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +extension StatusMenuTests { + @Test + func `groq cost data stays reachable via inline dashboard regardless of cost row gating`() throws { + StatusItemController.menuCardRenderingEnabled = true + StatusItemController.setMenuRefreshEnabledForTesting(false) + defer { self.disableMenuCardsForTesting() } + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.selectedMenuProvider = .groq + settings.costUsageEnabled = true + settings.costSummaryDisplayStyle = .costSubmenu + + let metadata = try #require(ProviderRegistry.shared.metadata[.groq]) + settings.setProviderEnabled(provider: .groq, metadata: metadata, enabled: true) + + let fetcher = UsageFetcher() + let store = UsageStore( + fetcher: fetcher, + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings) + let now = Date(timeIntervalSince1970: 1_700_179_200) + let usage = GroqConsoleUsageSnapshot( + daily: [ + GroqConsoleUsageSnapshot.DailyBucket( + day: "2023-11-14", + startTime: now.addingTimeInterval(-86400), + endTime: now, + costUSD: 1.5, + requests: 10, + inputTokens: 100, + cachedInputTokens: 0, + outputTokens: 50, + totalTokens: 150, + models: []), + ], + updatedAt: now) + store._setSnapshotForTesting(usage.toUsageSnapshot(), provider: .groq) + + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + + // Groq's descriptor sets `tokenCost.supportsTokenCost = false`, so the generic Cost + // row/submenu is unreachable regardless of display style or `tokenCostMenuSectionEnabled` + // (that guard runs first in `tokenUsageSection`) — Groq relies solely on the inline + // dashboard for its cost data, same as openai/mistral. This locks in that Groq's absence + // from the "Cost" row is unaffected by which provider set gates that row, so a future + // predicate change there can't silently break Groq the way it silently broke when this + // row's gate briefly reused `usesProviderCostHistoryAsPrimaryDashboard`. + let model = try #require(controller.menuCardModel(for: .groq)) + #expect(model.tokenUsage == nil) + #expect(model.inlineUsageDashboard != nil) + } +} diff --git a/Tests/CodexBarTests/HistoricalUsagePaceBackfillAuthorityTests.swift b/Tests/CodexBarTests/HistoricalUsagePaceBackfillAuthorityTests.swift index 4696130715..5accc8449f 100644 --- a/Tests/CodexBarTests/HistoricalUsagePaceBackfillAuthorityTests.swift +++ b/Tests/CodexBarTests/HistoricalUsagePaceBackfillAuthorityTests.swift @@ -286,7 +286,7 @@ extension HistoricalUsagePaceTests { @MainActor @Test - func `usage store falls back to linear when history disabled or insufficient`() throws { + func `usage store falls back to linear when Codex history is insufficient`() throws { let suite = "HistoricalUsagePaceTests-usage-store" let defaults = try #require(UserDefaults(suiteName: suite)) defaults.removePersistentDomain(forName: suite) @@ -303,12 +303,12 @@ extension HistoricalUsagePaceTests { minimaxCookieStore: InMemoryMiniMaxCookieStore(), minimaxAPITokenStore: InMemoryMiniMaxAPITokenStore(), kimiTokenStore: InMemoryKimiTokenStore(), - kimiK2TokenStore: InMemoryKimiK2TokenStore(), augmentCookieStore: InMemoryCookieHeaderStore(), ampCookieStore: InMemoryCookieHeaderStore(), copilotTokenStore: InMemoryCopilotTokenStore(), tokenAccountStore: InMemoryTokenAccountStore()) settings.historicalTrackingEnabled = true + settings.weeklyProgressWorkDays = nil let planHistoryStore = testPlanUtilizationHistoryStore( suiteName: "HistoricalUsagePaceTests-\(UUID().uuidString)") @@ -318,6 +318,7 @@ extension HistoricalUsagePaceTests { settings: settings, historicalUsageHistoryStore: HistoricalUsageHistoryStore(fileURL: Self.makeTempURL()), planUtilizationHistoryStore: planHistoryStore) + store._cancelPlanUtilizationHistoryLoadForTesting() let now = Date(timeIntervalSince1970: 0) let window = RateWindow( @@ -339,11 +340,118 @@ extension HistoricalUsagePaceTests { store._setCodexHistoricalDatasetForTesting(twoWeeksDataset) let computed = store.weeklyPace(provider: .codex, window: window, now: now) - let linear = UsagePace.weekly(window: window, now: now, defaultWindowMinutes: 10080) + let linear = UsagePace.weekly( + window: window, + now: now, + defaultWindowMinutes: 10080, + workDays: nil) #expect(computed != nil) #expect(abs((computed?.deltaPercent ?? 0) - (linear?.deltaPercent ?? 0)) < 0.001) } + @MainActor + @Test + func `usage store preserves historical Codex pace in Automatic mode`() throws { + let suite = "HistoricalUsagePaceTests-workdays-automatic-preserve-history-\(UUID().uuidString)" + let store = try Self.makeUsageStoreForBackfillTests( + suite: suite, + historyFileURL: Self.makeTempURL()) + store.settings.weeklyProgressWorkDays = nil + + let now = Date(timeIntervalSince1970: 0) + let duration = TimeInterval(10080 * 60) + let resetsAt = now.addingTimeInterval(duration / 2) + let window = RateWindow( + usedPercent: 60, + windowMinutes: 10080, + resetsAt: resetsAt, + resetDescription: nil) + let dataset = CodexHistoricalDataset(weeks: (0..<5).map { index in + HistoricalWeekProfile( + resetsAt: resetsAt.addingTimeInterval(-duration * Double(index + 1)), + windowMinutes: 10080, + curve: Self.linearCurve(end: 80)) + }) + let expected = try #require(CodexHistoricalPaceEvaluator.evaluate( + window: window, + now: now, + dataset: dataset)) + let linear = try #require(UsagePace.weekly(window: window, now: now, workDays: nil)) + store._setCodexHistoricalDatasetForTesting( + dataset, + accountKey: store.codexOwnershipContext().canonicalKey) + + let computed = try #require(store.weeklyPace(provider: .codex, window: window, now: now)) + + #expect(abs(expected.expectedUsedPercent - linear.expectedUsedPercent) > 0.001) + #expect(expected.runOutProbability != nil) + #expect(abs(computed.expectedUsedPercent - expected.expectedUsedPercent) < 0.001) + #expect(abs(computed.deltaPercent - expected.deltaPercent) < 0.001) + #expect(computed.etaSeconds == expected.etaSeconds) + #expect(computed.willLastToReset == expected.willLastToReset) + #expect(computed.runOutProbability == expected.runOutProbability) + } + + @MainActor + @Test(arguments: [4, 5, 7]) + func `explicit work day schedule overrides historical Codex pace`(workDays: Int) throws { + let suite = "HistoricalUsagePaceTests-workdays-override-history-\(workDays)-\(UUID().uuidString)" + let store = try Self.makeUsageStoreForBackfillTests( + suite: suite, + historyFileURL: Self.makeTempURL()) + store.settings.weeklyProgressWorkDays = workDays + + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = .current + let resetsAt = try #require(calendar.date(from: DateComponents( + calendar: calendar, + timeZone: calendar.timeZone, + year: 2026, + month: 6, + day: 14))) + let now = try #require(calendar.date(from: DateComponents( + calendar: calendar, + timeZone: calendar.timeZone, + year: 2026, + month: 6, + day: 11))) + let duration = TimeInterval(10080 * 60) + let window = RateWindow( + usedPercent: 60, + windowMinutes: 10080, + resetsAt: resetsAt, + resetDescription: nil) + let weeks = (0..<5).map { index in + HistoricalWeekProfile( + resetsAt: resetsAt.addingTimeInterval(-duration * Double(index + 1)), + windowMinutes: 10080, + curve: Self.linearCurve(end: 100)) + } + let dataset = CodexHistoricalDataset(weeks: weeks) + let historical = try #require(CodexHistoricalPaceEvaluator.evaluate( + window: window, + now: now, + dataset: dataset)) + let scheduled = try #require(UsagePace.weekly( + window: window, + now: now, + workDays: workDays, + calendar: calendar)) + store._setCodexHistoricalDatasetForTesting( + dataset, + accountKey: store.codexOwnershipContext().canonicalKey) + + let computed = try #require(store.weeklyPace(provider: .codex, window: window, now: now)) + + #expect(historical.runOutProbability != nil) + #expect(abs(computed.expectedUsedPercent - scheduled.expectedUsedPercent) < 0.001) + #expect(abs(computed.deltaPercent - scheduled.deltaPercent) < 0.001) + #expect(computed.etaSeconds == scheduled.etaSeconds) + #expect(computed.willLastToReset == scheduled.willLastToReset) + #expect(computed.runOutProbability == nil) + #expect(computed.speedMultiplierToReset == scheduled.speedMultiplierToReset) + } + @MainActor @Test func `usage store computes linear pace for providers with quota windows`() throws { @@ -365,6 +473,41 @@ extension HistoricalUsagePaceTests { #expect(abs((pace?.deltaPercent ?? 0) - (40 - (3.0 / 7.0 * 100.0))) < 0.001) } + @MainActor + @Test + func `usage store applies configured work days to generic weekly pace`() throws { + let suite = "HistoricalUsagePaceTests-generic-workdays-\(UUID().uuidString)" + let store = try Self.makeUsageStoreForBackfillTests( + suite: suite, + historyFileURL: Self.makeTempURL()) + store.settings.weeklyProgressWorkDays = 5 + + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = .current + let resetsAt = try #require(calendar.date(from: DateComponents( + calendar: calendar, + timeZone: calendar.timeZone, + year: 2026, + month: 6, + day: 14))) + let now = try #require(calendar.date(from: DateComponents( + calendar: calendar, + timeZone: calendar.timeZone, + year: 2026, + month: 6, + day: 11))) + let window = RateWindow( + usedPercent: 60, + windowMinutes: 10080, + resetsAt: resetsAt, + resetDescription: nil) + + let pace = try #require(store.weeklyPace(provider: .zai, window: window, now: now)) + + #expect(abs(pace.expectedUsedPercent - 60) < 0.001) + #expect(abs(pace.deltaPercent) < 0.001) + } + @MainActor @Test func `usage store returns nil pace when generic window lacks explicit duration`() throws { diff --git a/Tests/CodexBarTests/HistoricalUsagePaceTestSupport.swift b/Tests/CodexBarTests/HistoricalUsagePaceTestSupport.swift index bb6102c7d2..cf455e9fe7 100644 --- a/Tests/CodexBarTests/HistoricalUsagePaceTestSupport.swift +++ b/Tests/CodexBarTests/HistoricalUsagePaceTestSupport.swift @@ -291,7 +291,6 @@ extension HistoricalUsagePaceTests { minimaxCookieStore: InMemoryMiniMaxCookieStore(), minimaxAPITokenStore: InMemoryMiniMaxAPITokenStore(), kimiTokenStore: InMemoryKimiTokenStore(), - kimiK2TokenStore: InMemoryKimiK2TokenStore(), augmentCookieStore: InMemoryCookieHeaderStore(), ampCookieStore: InMemoryCookieHeaderStore(), copilotTokenStore: InMemoryCopilotTokenStore(), @@ -299,12 +298,14 @@ extension HistoricalUsagePaceTests { settings.historicalTrackingEnabled = true let planHistoryStore = testPlanUtilizationHistoryStore( suiteName: "HistoricalUsagePaceTests-\(UUID().uuidString)") - return UsageStore( + let store = UsageStore( fetcher: UsageFetcher(environment: [:]), browserDetection: BrowserDetection(cacheTTL: 0), settings: settings, historicalUsageHistoryStore: historicalUsageHistoryStore, planUtilizationHistoryStore: planHistoryStore) + store._cancelPlanUtilizationHistoryLoadForTesting() + return store } @MainActor diff --git a/Tests/CodexBarTests/HistoricalUsagePaceTests.swift b/Tests/CodexBarTests/HistoricalUsagePaceTests.swift index 63cecbfe45..e294baa6e7 100644 --- a/Tests/CodexBarTests/HistoricalUsagePaceTests.swift +++ b/Tests/CodexBarTests/HistoricalUsagePaceTests.swift @@ -749,4 +749,75 @@ struct HistoricalUsagePaceTests { try await Task.sleep(for: .milliseconds(250)) #expect(store.codexHistoricalDataset == nil) } + + @Test + func `exhausted historical weeks extend linearly and don't flatline at 100`() throws { + // Build a historical dataset where a week reaches 100% at u = 0.5 + var earlyExhaustedCurve = [Double]() + for i in 0.. 0) + #expect(pace.deltaPercent > 0) + + let detail = UsagePaceText.weeklyDetail(provider: .codex, pace: pace, now: now) + #expect(detail.leftLabel == "5% in deficit") + #expect(detail.rightLabel?.contains("Lasts until reset") == false) + } + + @Test + func `exhausted actual returns zero eta`() throws { + let now = Date() + let resetsAt = now.addingTimeInterval(3600) + let week = HistoricalWeekProfile( + resetsAt: now.addingTimeInterval(-10080 * 60), + windowMinutes: 10080, + curve: Array(repeating: 100.0, count: 169)) + let dataset = CodexHistoricalDataset(weeks: [week, week, week]) + + for usedPercent in [100.0, 120.0] { + let window = RateWindow( + usedPercent: usedPercent, + windowMinutes: 10080, + resetsAt: resetsAt, + resetDescription: nil, + nextRegenPercent: nil) + + let pace = try #require(CodexHistoricalPaceEvaluator.evaluate( + window: window, + now: now, + dataset: dataset)) + #expect(pace.willLastToReset == false) + #expect(pace.etaSeconds == 0) + #expect(pace.runOutProbability == 1) + + let detail = UsagePaceText.weeklyDetail(provider: .codex, pace: pace, now: now) + #expect(detail.rightLabel == "Runs out now · ≈ 100% run-out risk") + } + } } diff --git a/Tests/CodexBarTests/HookEditorValidationTests.swift b/Tests/CodexBarTests/HookEditorValidationTests.swift new file mode 100644 index 0000000000..f79d3a110a --- /dev/null +++ b/Tests/CodexBarTests/HookEditorValidationTests.swift @@ -0,0 +1,26 @@ +import CodexBarCore +import Testing +@testable import CodexBar + +struct HookEditorValidationTests { + @Test + func `rule creation stops at runtime limit`() { + #expect(HookEditorValidation.canAddRule(count: HooksConfig.maximumRuleCount - 1)) + #expect(!HookEditorValidation.canAddRule(count: HooksConfig.maximumRuleCount)) + } + + @Test + func `argument creation stops at runtime limit`() { + #expect(HookEditorValidation.canAddArgument(count: HookRule.maximumArgumentCount - 1)) + #expect(!HookEditorValidation.canAddArgument(count: HookRule.maximumArgumentCount)) + } + + @Test + func `quota threshold stays in runtime valid range`() { + #expect(HookEditorValidation.thresholdFraction(percent: nil) == nil) + #expect(HookEditorValidation.thresholdFraction(percent: 0) == 0.01) + #expect(HookEditorValidation.thresholdFraction(percent: -5) == 0.01) + #expect(HookEditorValidation.thresholdFraction(percent: 50) == 0.5) + #expect(HookEditorValidation.thresholdFraction(percent: 120) == 1) + } +} diff --git a/Tests/CodexBarTests/IconRendererHideCrittersTests.swift b/Tests/CodexBarTests/IconRendererHideCrittersTests.swift new file mode 100644 index 0000000000..eb879f1b2c --- /dev/null +++ b/Tests/CodexBarTests/IconRendererHideCrittersTests.swift @@ -0,0 +1,79 @@ +import AppKit +import CodexBarCore +import Testing +@testable import CodexBar + +@MainActor +@Suite(.serialized) +struct IconRendererHideCrittersTests { + private func pixels(_ image: NSImage) throws -> Data { + try #require(image.tiffRepresentation) + } + + private func icon(style: IconStyle, weeklyRemaining: Double? = 40, hideCritters: Bool) -> NSImage { + IconRenderer.makeIcon( + primaryRemaining: 60, + weeklyRemaining: weeklyRemaining, + creditsRemaining: nil, + stale: false, + style: style, + hideCritters: hideCritters) + } + + @Test(arguments: [ + IconStyle.codex, + .claude, + .gemini, + .antigravity, + .factory, + .warp, + ]) + func `hiding critters removes every decorated style twist`(style: IconStyle) throws { + let decorated = self.icon(style: style, hideCritters: false) + let plain = self.icon(style: style, hideCritters: true) + + #expect(try self.pixels(decorated) != self.pixels(plain)) + } + + @Test(arguments: [ + IconStyle.codex, + .claude, + .gemini, + .antigravity, + .factory, + .warp, + ]) + func `hidden decorated styles match plain capsule bars`(style: IconStyle) throws { + let hidden = self.icon(style: style, hideCritters: true) + let reference = self.icon(style: .cursor, hideCritters: true) + + #expect(try self.pixels(hidden) == self.pixels(reference)) + } + + @Test + func `hiding critters removes warp eyes without weekly quota`() throws { + let decorated = self.icon(style: .warp, weeklyRemaining: nil, hideCritters: false) + let plain = self.icon(style: .warp, weeklyRemaining: nil, hideCritters: true) + + #expect(try self.pixels(decorated) != self.pixels(plain)) + } + + @Test + func `hiding critters is a no-op for an undecorated style`() throws { + // Cursor has no critter twist, so the flag must not alter its bars. + let withFlag = self.icon(style: .cursor, hideCritters: true) + let withoutFlag = self.icon(style: .cursor, hideCritters: false) + + #expect(try self.pixels(withFlag) == self.pixels(withoutFlag)) + } + + @Test + func `morph icon honors hide critters at full progress`() throws { + // At full progress the morph cross-fades into the bar icon, which carries + // the Codex face. A distinct cache key must keep the two renders separate. + let decorated = IconRenderer.makeMorphIcon(progress: 1, style: .codex, hideCritters: false) + let plain = IconRenderer.makeMorphIcon(progress: 1, style: .codex, hideCritters: true) + + #expect(try self.pixels(decorated) != self.pixels(plain)) + } +} diff --git a/Tests/CodexBarTests/IconRendererUsageTintTests.swift b/Tests/CodexBarTests/IconRendererUsageTintTests.swift new file mode 100644 index 0000000000..3d55dec706 --- /dev/null +++ b/Tests/CodexBarTests/IconRendererUsageTintTests.swift @@ -0,0 +1,78 @@ +import AppKit +import CodexBarCore +import Testing +@testable import CodexBar + +@MainActor +@Suite(.serialized) +struct IconRendererUsageTintTests { + private func pixels(_ image: NSImage) throws -> Data { + try #require(image.tiffRepresentation) + } + + private func icon(tint: NSColor?, hideCritters: Bool = false) -> NSImage { + IconRenderer.makeIcon( + primaryRemaining: 60, + weeklyRemaining: 40, + creditsRemaining: nil, + stale: false, + style: .codex, + hideCritters: hideCritters, + tint: tint) + } + + @Test + func `untinted icons stay template images`() { + #expect(self.icon(tint: nil).isTemplate) + } + + /// A template image keeps only its alpha mask, so AppKit recolors it and the RGB never reaches the menu bar. + /// Baking the tint into a non-template bitmap is what makes color survive on macOS 26. + @Test + func `tinted icons are baked as non template images`() { + #expect(self.icon(tint: .systemRed).isTemplate == false) + } + + @Test + func `omitting the tint reproduces the untinted render exactly`() throws { + let explicitNil = self.icon(tint: nil) + let omitted = IconRenderer.makeIcon( + primaryRemaining: 60, + weeklyRemaining: 40, + creditsRemaining: nil, + stale: false, + style: .codex) + + #expect(try self.pixels(explicitNil) == self.pixels(omitted)) + } + + @Test + func `a tint changes the rendered pixels`() throws { + #expect(try self.pixels(self.icon(tint: nil)) != self.pixels(self.icon(tint: .systemRed))) + } + + /// Guards the icon cache key: two different tints must not collide on one entry. + @Test + func `distinct tints render distinctly`() throws { + let low = try #require(MenuBarUsageTint.color(forUsedPercent: 10)) + let high = try #require(MenuBarUsageTint.color(forUsedPercent: 95)) + + #expect(try self.pixels(self.icon(tint: low)) != self.pixels(self.icon(tint: high))) + } + + @Test + func `tint and hide critters stay independent`() throws { + let tint = try #require(MenuBarUsageTint.color(forUsedPercent: 80)) + let decorated = self.icon(tint: tint, hideCritters: false) + let plain = self.icon(tint: tint, hideCritters: true) + + #expect(try self.pixels(decorated) != self.pixels(plain)) + #expect(decorated.isTemplate == false) + #expect(plain.isTemplate == false) + } + + @Test + func `morph icons ignore the tint and stay template`() { + #expect(IconRenderer.makeMorphIcon(progress: 0.5, style: .codex).isTemplate) + } +} diff --git a/Tests/CodexBarTests/InlineCostHistoryDashboardLabelTests.swift b/Tests/CodexBarTests/InlineCostHistoryDashboardLabelTests.swift index 2584e5dfed..f023ad440d 100644 --- a/Tests/CodexBarTests/InlineCostHistoryDashboardLabelTests.swift +++ b/Tests/CodexBarTests/InlineCostHistoryDashboardLabelTests.swift @@ -4,6 +4,112 @@ import Testing @testable import CodexBar struct InlineCostHistoryDashboardLabelTests { + @Test + func `local cost history Today KPI uses current day session value`() throws { + let now = Date(timeIntervalSince1970: 1_700_179_200) + let metadata = try #require(ProviderDefaults.metadata[.claude]) + let tokenSnapshot = CostUsageTokenSnapshot( + sessionTokens: 0, + sessionCostUSD: 0, + last30DaysTokens: 275, + last30DaysCostUSD: 0.25, + daily: [ + CostUsageDailyReport.Entry( + date: "2023-11-15", + inputTokens: 200, + outputTokens: 75, + totalTokens: 275, + costUSD: 0.25, + modelsUsed: nil, + modelBreakdowns: nil), + ], + updatedAt: now) + + let model = UsageMenuCardView.Model.make(.init( + provider: .claude, + metadata: metadata, + snapshot: UsageSnapshot( + primary: nil, + secondary: nil, + updatedAt: now), + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: tokenSnapshot, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: true, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + + #expect(model.inlineUsageDashboard?.kpis.first?.title == "Today") + #expect(model.inlineUsageDashboard?.kpis.first?.value == "$0.00") + #expect(model.inlineUsageDashboard?.points.first?.accessibilityValue == "2023-11-15: $0.25") + } + + @Test + func `local cost history converts from snapshot currency into preferred currency`() throws { + let now = Date(timeIntervalSince1970: 1_700_179_200) + let metadata = try #require(ProviderDefaults.metadata[.claude]) + let tokenSnapshot = CostUsageTokenSnapshot( + sessionTokens: 100, + sessionCostUSD: 10, + last30DaysTokens: 100, + last30DaysCostUSD: 10, + currencyCode: "EUR", + daily: [ + CostUsageDailyReport.Entry( + date: "2023-11-15", + inputTokens: 75, + outputTokens: 25, + totalTokens: 100, + costUSD: 10, + modelsUsed: nil, + modelBreakdowns: nil), + ], + updatedAt: now) + + let model = UsageMenuCardView.Model.make(.init( + provider: .claude, + metadata: metadata, + snapshot: UsageSnapshot(primary: nil, secondary: nil, updatedAt: now), + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: tokenSnapshot, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: true, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + preferredCurrencyCode: "USD", + now: now)) + + let expected = UsageFormatter.convertedCostString( + 10, + preferredCurrency: "USD", + providerCurrency: "EUR") + let expectedValue = UsageFormatter.convertedCost( + 10, + preferredCurrency: "USD", + providerCurrency: "EUR").value + #expect(model.inlineUsageDashboard?.currencyCode == "USD") + #expect(model.inlineUsageDashboard?.kpis.first?.value == expected) + #expect(model.inlineUsageDashboard?.points.first?.value == expectedValue) + #expect(model.inlineUsageDashboard?.points.first?.accessibilityValue == "2023-11-15: \(expected)") + } + @Test func `local cost history KPI titles preserve one day and dynamic windows`() throws { let now = Date(timeIntervalSince1970: 1_700_179_200) @@ -61,16 +167,19 @@ struct InlineCostHistoryDashboardLabelTests { } let oneDay = makeModel(historyDays: 1) - #expect(oneDay.inlineUsageDashboard?.kpis[1].title == "Today") - #expect(oneDay.inlineUsageDashboard?.kpis[2].title == "Today tokens") + #expect(oneDay.inlineUsageDashboard?.kpis.map(\.title) == [ + "Today", "Today", "Latest tokens", "Today tokens", + ]) let sevenDays = makeModel(historyDays: 7) - #expect(sevenDays.inlineUsageDashboard?.kpis[1].title == "Last 7 days Cost") - #expect(sevenDays.inlineUsageDashboard?.kpis[2].title == "Last 7 days tokens") + #expect(sevenDays.inlineUsageDashboard?.kpis.map(\.title) == [ + "Today", "Last 7 days Cost", "Latest tokens", "Last 7 days tokens", + ]) let thirtyDays = makeModel(historyDays: 30) - #expect(thirtyDays.inlineUsageDashboard?.kpis[1].title == "30d cost") - #expect(thirtyDays.inlineUsageDashboard?.kpis[2].title == "30d tokens") + #expect(thirtyDays.inlineUsageDashboard?.kpis.map(\.title) == [ + "Today", "30d cost", "Latest tokens", "30d tokens", + ]) } @Test @@ -118,7 +227,193 @@ struct InlineCostHistoryDashboardLabelTests { hidePersonalInfo: false, now: now)) - #expect(model.inlineUsageDashboard?.kpis[1].title == "This month") - #expect(model.inlineUsageDashboard?.kpis[2].title == "This month tokens") + #expect(model.inlineUsageDashboard?.kpis.map(\.title) == [ + "Today", "This month", "Latest tokens", "This month tokens", + ]) + } + + @Test + func `costHistoryInlineDashboard sets currencyCode from snapshot`() throws { + let now = Date(timeIntervalSince1970: 1_700_179_200) + let metadata = try #require(ProviderDefaults.metadata[.codex]) + let tokenSnapshot = CostUsageTokenSnapshot( + sessionTokens: 275, + sessionCostUSD: 0.25, + last30DaysTokens: 425, + last30DaysCostUSD: 0.37, + currencyCode: "USD", + daily: [ + CostUsageDailyReport.Entry( + date: "2023-11-15", + inputTokens: 200, + outputTokens: 75, + totalTokens: 275, + costUSD: 0.25, + modelsUsed: ["test-model"], + modelBreakdowns: [ + CostUsageDailyReport.ModelBreakdown( + modelName: "test-model", + costUSD: 0.25, + totalTokens: 275), + ]), + ], + updatedAt: now) + + let model = UsageMenuCardView.Model.make(.init( + provider: .codex, + metadata: metadata, + snapshot: UsageSnapshot( + primary: nil, + secondary: nil, + updatedAt: now), + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: tokenSnapshot, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: true, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + + let dashboard = try #require(model.inlineUsageDashboard) + #expect(dashboard.currencyCode == "USD") + #expect(dashboard.accessibilityLabel == "Codex: 30d cost") + #expect(dashboard.kpis.map(\.title) == [ + "Today", + "30d", + "Latest tokens", + "30d tokens", + ]) + #expect(dashboard.detailLines == [ + "Top model: test-model", + "Estimated from token usage · not a subscription bill", + ]) + + let japaneseAccessibilityLabels = CodexBarLocalizationOverride.$appLanguage.withValue("ja") { + [7, 30].map { historyDays in + UsageMenuCardView.Model.make(.init( + provider: .codex, + metadata: metadata, + snapshot: UsageSnapshot(primary: nil, secondary: nil, updatedAt: now), + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: CostUsageTokenSnapshot( + sessionTokens: 275, + sessionCostUSD: 0.25, + last30DaysTokens: 425, + last30DaysCostUSD: 0.37, + historyDays: historyDays, + daily: tokenSnapshot.daily, + updatedAt: now), + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: true, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)).inlineUsageDashboard?.accessibilityLabel + } + } + #expect(japaneseAccessibilityLabels == ["Codex: 過去7日間のコスト", "Codex: 過去30日間のコスト"]) + } + + @Test + func `cursor metered-only snapshot remains visible in inline dashboard`() throws { + let now = Date(timeIntervalSince1970: 1_700_179_200) + let metadata = try #require(ProviderDefaults.metadata[.cursor]) + let tokenSnapshot = CostUsageTokenSnapshot( + sessionTokens: nil, + sessionCostUSD: nil, + last30DaysTokens: nil, + last30DaysCostUSD: nil, + historyDays: 30, + meteredCostUSD: 1.25, + daily: [], + updatedAt: now) + let model = UsageMenuCardView.Model.make(.init( + provider: .cursor, + metadata: metadata, + snapshot: UsageSnapshot(primary: nil, secondary: nil, updatedAt: now), + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: tokenSnapshot, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: true, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + + let dashboard = try #require(model.inlineUsageDashboard) + #expect(dashboard.kpis.first?.title == "Cursor-metered") + #expect(dashboard.kpis.first?.value == "$1.25") + #expect(dashboard.points.isEmpty) + } + + @Test + func `token-only inline dashboard leaves currencyCode nil`() throws { + let now = Date(timeIntervalSince1970: 1_700_179_200) + let metadata = try #require(ProviderDefaults.metadata[.zai]) + let modelUsage = ZaiModelUsageData( + xTime: ["2023-11-17 00:00"], + modelDataList: [ + ZaiModelDataItem(modelName: "glm-test", tokensUsage: [123]), + ]) + let snapshot = UsageSnapshot( + primary: nil, + secondary: nil, + tertiary: nil, + zaiUsage: ZaiUsageSnapshot( + tokenLimit: nil, + timeLimit: nil, + planName: nil, + modelUsage: modelUsage, + updatedAt: now), + updatedAt: now, + identity: ProviderIdentitySnapshot( + providerID: .zai, + accountEmail: nil, + accountOrganization: nil, + loginMethod: nil)) + + let model = UsageMenuCardView.Model.make(.init( + provider: .zai, + metadata: metadata, + snapshot: snapshot, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: true, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + let dashboard = try #require(model.inlineUsageDashboard) + #expect(dashboard.currencyCode == nil) } } diff --git a/Tests/CodexBarTests/InlineUsageDashboardBarColorTests.swift b/Tests/CodexBarTests/InlineUsageDashboardBarColorTests.swift new file mode 100644 index 0000000000..78b34b2c14 --- /dev/null +++ b/Tests/CodexBarTests/InlineUsageDashboardBarColorTests.swift @@ -0,0 +1,88 @@ +import CodexBarCore +import Foundation +import SwiftUI +import Testing +@testable import CodexBar + +struct InlineUsageDashboardBarColorTests { + /// The inline usage bars must be tinted with each provider's branding color (the same color + /// used by the switcher tab and the detailed cost-history chart) rather than a fixed palette. + @Test + func `bar color matches branding for every provider`() { + for provider in UsageProvider.allCases { + let branding = ProviderDescriptorRegistry.descriptor(for: provider).branding.color + let expected = Color(red: branding.red, green: branding.green, blue: branding.blue) + #expect( + UsageMenuCardView.Model.inlineDashboardBarColor(for: provider) == expected, + "inline bar color did not match branding for \(provider.rawValue)") + } + } + + /// The resolved dashboard model must actually carry the provider's branding color, and two + /// providers with different branding must end up with different bar colors. + @Test + func `resolved dashboard carries provider branding color`() throws { + let now = Date(timeIntervalSince1970: 1_700_179_200) + let daily = [ + CostUsageDailyReport.Entry( + date: "2023-11-14", + inputTokens: 100, + outputTokens: 50, + totalTokens: 150, + costUSD: 0.12, + modelsUsed: ["gpt-5"], + modelBreakdowns: nil), + CostUsageDailyReport.Entry( + date: "2023-11-15", + inputTokens: 200, + outputTokens: 75, + totalTokens: 275, + costUSD: 0.25, + modelsUsed: ["gpt-5"], + modelBreakdowns: nil), + ] + + func makeModel(provider: UsageProvider) throws -> UsageMenuCardView.Model { + let metadata = try #require(ProviderDefaults.metadata[provider]) + let tokenSnapshot = CostUsageTokenSnapshot( + sessionTokens: 275, + sessionCostUSD: 0.25, + last30DaysTokens: 425, + last30DaysCostUSD: 0.37, + historyDays: 30, + daily: daily, + updatedAt: now) + return UsageMenuCardView.Model.make(.init( + provider: provider, + metadata: metadata, + snapshot: UsageSnapshot( + primary: RateWindow(usedPercent: 10, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: nil, + updatedAt: now), + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: tokenSnapshot, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: true, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + } + + let codex = try makeModel(provider: .codex) + let claude = try makeModel(provider: .claude) + + #expect(codex.inlineUsageDashboard?.barColor + == UsageMenuCardView.Model.inlineDashboardBarColor(for: .codex)) + #expect(claude.inlineUsageDashboard?.barColor + == UsageMenuCardView.Model.inlineDashboardBarColor(for: .claude)) + #expect(codex.inlineUsageDashboard?.barColor != claude.inlineUsageDashboard?.barColor) + } +} diff --git a/Tests/CodexBarTests/Issue2037FixtureHarnessTests.swift b/Tests/CodexBarTests/Issue2037FixtureHarnessTests.swift new file mode 100644 index 0000000000..4806aaeec3 --- /dev/null +++ b/Tests/CodexBarTests/Issue2037FixtureHarnessTests.swift @@ -0,0 +1,30 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct Issue2037FixtureHarnessTests { + @Test + func `issue 2037 fixture harness installs a sanitized family into an isolated codex home`() throws { + let fixture = try Issue2037FixtureHarness.load(named: "harness-smoke") + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + try Issue2037FixtureHarness.install(fixture, into: env) + + #expect(fixture.manifest.schemaVersion == 1) + #expect(fixture.manifest.redactionVersion == 1) + #expect(fixture.manifest.familyAlias == "harness-smoke") + #expect(fixture.manifest.files.map(\.alias) == ["parent", "child"]) + #expect(fixture.manifest.copiedPrefixes == [ + .init(parentAlias: "parent", childAlias: "child", length: 1), + ]) + + for file in fixture.manifest.files { + let installed = env.root.appendingPathComponent(file.relativePath, isDirectory: false) + #expect(FileManager.default.fileExists(atPath: installed.path), "missing \(file.alias)") + let contents = try String(contentsOf: installed, encoding: .utf8) + #expect(contents.contains("\"session_meta\"")) + #expect(contents.contains("\"token_count\"")) + } + } +} diff --git a/Tests/CodexBarTests/Issue2037FixtureSupport.swift b/Tests/CodexBarTests/Issue2037FixtureSupport.swift new file mode 100644 index 0000000000..a48c221d2f --- /dev/null +++ b/Tests/CodexBarTests/Issue2037FixtureSupport.swift @@ -0,0 +1,359 @@ +import Foundation +import Testing +@testable import CodexBarCore + +enum Issue2037FixtureHarness { + struct Fixture { + let root: URL + let manifest: Manifest + } + + struct Manifest: Decodable, Equatable { + struct File: Decodable, Equatable { + let alias: String + let relativePath: String + let sourceRole: String + let leafSessionAlias: String + let parentSessionAlias: String? + } + + struct CopiedPrefix: Decodable, Equatable { + let parentAlias: String + let childAlias: String + let length: Int + } + + struct Oracle: Decodable, Equatable { + let parentEventCount: Int + let childEventCount: Int + let copiedPrefixLength: Int + let parentLastTokens: Int + let childLastTokens: Int + let copiedPrefixLastTokens: Int + let naiveLastTokens: Int + let dedupedLastTokens: Int + let copiedPrefixTimestampMismatches: Int + let parentHasTotalTokenUsageDrop: Bool + let childHasTotalTokenUsageDrop: Bool + } + + struct ScannerOracle: Decodable, Equatable { + let naiveScannerUnits: Int + let dedupedScannerUnits: Int + let prefixScannerUnits: Int + let siblingAUniqueScannerUnits: Int? + let siblingBUniqueScannerUnits: Int? + let unresolvedForkSkippedFirstEventScannerUnits: Int? + } + + let schemaVersion: Int + let redactionVersion: Int + let familyAlias: String + let files: [File] + let copiedPrefixes: [CopiedPrefix] + let billablePrefixOwnerAlias: String? + let missingParentSessionId: String? + let oracle: Oracle? + let scannerOracle: ScannerOracle? + } + + static func load(named name: String) throws -> Fixture { + let root = try #require(Bundle.module.url( + forResource: name, + withExtension: nil, + subdirectory: "Fixtures/CostUsage/Issue2037")) + let manifestURL = root.appendingPathComponent("manifest.json", isDirectory: false) + let manifest = try JSONDecoder().decode(Manifest.self, from: Data(contentsOf: manifestURL)) + try self.validate(manifest) + return Fixture(root: root, manifest: manifest) + } + + static func install(_ fixture: Fixture, into environment: CostUsageTestEnvironment) throws { + for file in fixture.manifest.files { + let source = fixture.root.appendingPathComponent(file.relativePath, isDirectory: false) + let destination = environment.root.appendingPathComponent(file.relativePath, isDirectory: false) + try FileManager.default.createDirectory( + at: destination.deletingLastPathComponent(), + withIntermediateDirectories: true) + if FileManager.default.fileExists(atPath: destination.path) { + try FileManager.default.removeItem(at: destination) + } + try FileManager.default.copyItem(at: source, to: destination) + } + } + + private static func validate(_ manifest: Manifest) throws { + guard manifest.schemaVersion == 1 else { + throw FixtureError.unsupportedSchema(manifest.schemaVersion) + } + guard manifest.redactionVersion == 1 else { + throw FixtureError.unsupportedRedaction(manifest.redactionVersion) + } + guard !manifest.familyAlias.isEmpty, !manifest.files.isEmpty else { + throw FixtureError.emptyManifest + } + + let aliases = Set(manifest.files.map(\.alias)) + guard aliases.count == manifest.files.count else { + throw FixtureError.duplicateFileAlias + } + + for file in manifest.files { + guard !file.alias.isEmpty, + !file.leafSessionAlias.isEmpty, + file.relativePath.hasPrefix("codex-home/") + else { + throw FixtureError.invalidFileEntry(file.alias) + } + let components = file.relativePath.split(separator: "/") + guard !components.contains(".."), !components.contains("") else { + throw FixtureError.invalidFileEntry(file.alias) + } + if let parent = file.parentSessionAlias { + guard !parent.isEmpty else { + throw FixtureError.invalidFileEntry(file.alias) + } + } + } + + for prefix in manifest.copiedPrefixes { + guard aliases.contains(prefix.parentAlias), + aliases.contains(prefix.childAlias), + prefix.parentAlias != prefix.childAlias, + prefix.length >= 0 + else { + throw FixtureError.invalidCopiedPrefix + } + } + } + + enum FixtureError: Error { + case unsupportedSchema(Int) + case unsupportedRedaction(Int) + case emptyManifest + case duplicateFileAlias + case invalidFileEntry(String) + case invalidCopiedPrefix + } +} + +enum SanitizedForkFamilyFixture { + struct Fixture { + let root: URL + let manifest: Manifest + + func sessionMetadata(named alias: String) throws -> SessionMetadata { + let file = try #require(self.manifest.files.first { $0.alias == alias }) + let url = self.root.appendingPathComponent(file.relativePath, isDirectory: false) + let text = try String(contentsOf: url, encoding: .utf8) + let record = try #require(text + .split(whereSeparator: \.isNewline) + .lazy + .compactMap { line in + try? JSONDecoder().decode(Record.self, from: Data(line.utf8)) + } + .first { $0.type == "session_meta" }) + let payload = try #require(record.payload) + return try #require(payload.sessionMetadata) + } + + func events(named alias: String) throws -> [TokenEvent] { + let file = try #require(self.manifest.files.first { $0.alias == alias }) + let url = self.root.appendingPathComponent(file.relativePath, isDirectory: false) + let text = try String(contentsOf: url, encoding: .utf8) + return try text + .split(whereSeparator: \.isNewline) + .compactMap { line in + let record = try JSONDecoder().decode(Record.self, from: Data(line.utf8)) + guard record.type == "event_msg", + record.payload?.type == "token_count", + let info = record.payload?.info, + let last = info.last, + let total = info.total + else { + return nil + } + return TokenEvent(timestamp: record.timestamp, last: last, total: total) + } + } + + func jsonObjects(named alias: String) throws -> [[String: Any]] { + let file = try #require(self.manifest.files.first { $0.alias == alias }) + let url = self.root.appendingPathComponent(file.relativePath, isDirectory: false) + let text = try String(contentsOf: url, encoding: .utf8) + return try text + .split(whereSeparator: \.isNewline) + .map { line in + guard let object = try JSONSerialization.jsonObject(with: Data(line.utf8)) as? [String: Any] + else { + throw FixtureError.invalidJSONLine + } + return object + } + } + } + + struct Manifest: Decodable { + struct File: Decodable { + let alias: String + let relativePath: String + } + + struct CopiedPrefix: Decodable { + let parentAlias: String + let childAlias: String + let length: Int + } + + struct Oracle: Decodable { + let parentEventCount: Int + let childEventCount: Int + let copiedPrefixLength: Int + let parentLastTokens: Int + let childLastTokens: Int + let copiedPrefixLastTokens: Int + let naiveLastTokens: Int + let dedupedLastTokens: Int + let copiedPrefixTimestampMismatches: Int + let parentHasTotalTokenUsageDrop: Bool + let childHasTotalTokenUsageDrop: Bool + } + + let files: [File] + let copiedPrefixes: [CopiedPrefix] + let oracle: Oracle + } + + struct TokenEvent: Equatable { + struct Fingerprint: Equatable { + let last: TokenUsage + let total: TokenUsage + } + + let timestamp: String + let last: TokenUsage + let total: TokenUsage + + var fingerprint: Fingerprint { + .init(last: self.last, total: self.total) + } + } + + struct Record: Decodable { + struct Payload: Decodable { + struct SessionMetadata: Decodable { + let id: String + let forkedFromID: String? + let timestamp: String + + enum CodingKeys: String, CodingKey { + case id + case forkedFromID = "forked_from_id" + case timestamp + } + } + + struct Info: Decodable { + let last: TokenUsage? + let total: TokenUsage? + + enum CodingKeys: String, CodingKey { + case last = "last_token_usage" + case total = "total_token_usage" + } + } + + let type: String? + let info: Info? + let sessionMetadata: SessionMetadata? + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: DynamicCodingKey.self) + self.type = try container.decodeIfPresent(String.self, forKey: .init("type")) + self.info = try container.decodeIfPresent(Info.self, forKey: .init("info")) + self.sessionMetadata = try? SessionMetadata(from: decoder) + } + } + + let type: String + let timestamp: String + let payload: Payload? + } + + struct TokenUsage: Decodable, Equatable { + let inputTokens: Int + let cachedInputTokens: Int + let outputTokens: Int + let reasoningOutputTokens: Int + private let recordedTotalTokens: Int? + + var totalTokens: Int { + self.recordedTotalTokens ?? self.inputTokens + self.outputTokens + } + + /// Scanner-priced token units (input + cached + output). + var scannerUnits: Int { + self.inputTokens + self.cachedInputTokens + self.outputTokens + } + + enum CodingKeys: String, CodingKey { + case inputTokens = "input_tokens" + case cachedInputTokens = "cached_input_tokens" + case cacheReadInputTokens = "cache_read_input_tokens" + case outputTokens = "output_tokens" + case reasoningOutputTokens = "reasoning_output_tokens" + case totalTokens = "total_tokens" + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.inputTokens = try max(0, container.decodeIfPresent(Int.self, forKey: .inputTokens) ?? 0) + self.cachedInputTokens = try max( + 0, + container.decodeIfPresent(Int.self, forKey: .cachedInputTokens) + ?? container.decodeIfPresent(Int.self, forKey: .cacheReadInputTokens) + ?? 0) + self.outputTokens = try max(0, container.decodeIfPresent(Int.self, forKey: .outputTokens) ?? 0) + self.reasoningOutputTokens = try max( + 0, + container.decodeIfPresent(Int.self, forKey: .reasoningOutputTokens) ?? 0) + self.recordedTotalTokens = try container.decodeIfPresent(Int.self, forKey: .totalTokens).map { max(0, $0) } + } + } + + typealias SessionMetadata = Record.Payload.SessionMetadata + + enum FixtureError: Error { + case invalidJSONLine + case unexpectedRecordType + } + + private struct DynamicCodingKey: CodingKey { + let stringValue: String + let intValue: Int? + + init(_ stringValue: String) { + self.stringValue = stringValue + self.intValue = nil + } + + init?(stringValue: String) { + self.init(stringValue) + } + + init?(intValue: Int) { + nil + } + } + + static func load(named name: String) throws -> Fixture { + let root = try #require(Bundle.module.url( + forResource: name, + withExtension: nil, + subdirectory: "Fixtures/CostUsage/Issue2037")) + let manifestURL = root.appendingPathComponent("manifest.json", isDirectory: false) + return try Fixture( + root: root, + manifest: JSONDecoder().decode(Manifest.self, from: Data(contentsOf: manifestURL))) + } +} diff --git a/Tests/CodexBarTests/Issue2037ProvenanceFixtureTests.swift b/Tests/CodexBarTests/Issue2037ProvenanceFixtureTests.swift new file mode 100644 index 0000000000..fd4a522901 --- /dev/null +++ b/Tests/CodexBarTests/Issue2037ProvenanceFixtureTests.swift @@ -0,0 +1,163 @@ +import Foundation +import Testing + +struct Issue2037ProvenanceFixtureTests { + @Test + func `archived fork fixture preserves the copied normalized prefix and hand oracle`() throws { + let fixture = try SanitizedForkFamilyFixture.load(named: "archived-fork-33ce-3869") + let parentMetadata = try fixture.sessionMetadata(named: "parent") + let childMetadata = try fixture.sessionMetadata(named: "child") + let parent = try fixture.events(named: "parent") + let child = try fixture.events(named: "child") + let prefix = try #require(fixture.manifest.copiedPrefixes.first { prefix in + prefix.parentAlias == "parent" && prefix.childAlias == "child" + }) + + let actualPrefixLength = Self.longestCommonNormalizedPrefix(parent, child) + let copiedParent = Array(parent.prefix(prefix.length)) + let copiedChild = Array(child.prefix(prefix.length)) + let parentLastTokens = parent.map(\.last.totalTokens).reduce(0, +) + let childLastTokens = child.map(\.last.totalTokens).reduce(0, +) + let copiedLastTokens = copiedChild.map(\.last.totalTokens).reduce(0, +) + let naiveLastTokens = parentLastTokens + childLastTokens + let dedupedLastTokens = parentLastTokens + child.dropFirst(prefix.length) + .map(\.last.totalTokens) + .reduce(0, +) + let timestampMismatches = zip(copiedParent, copiedChild) + .count(where: { $0.timestamp != $1.timestamp }) + + #expect(parent.count == fixture.manifest.oracle.parentEventCount) + #expect(child.count == fixture.manifest.oracle.childEventCount) + #expect(parentMetadata.id == "parent-session") + #expect(parentMetadata.forkedFromID == nil) + #expect(!parentMetadata.timestamp.isEmpty) + #expect(childMetadata.id == "child-session") + #expect(childMetadata.forkedFromID == "parent-session") + #expect(!childMetadata.timestamp.isEmpty) + #expect(actualPrefixLength == prefix.length) + #expect(prefix.length == fixture.manifest.oracle.copiedPrefixLength) + #expect(parent.prefix(prefix.length).map(\.fingerprint) == child.prefix(prefix.length).map(\.fingerprint)) + + #expect(parentLastTokens == fixture.manifest.oracle.parentLastTokens) + #expect(childLastTokens == fixture.manifest.oracle.childLastTokens) + #expect(copiedLastTokens == fixture.manifest.oracle.copiedPrefixLastTokens) + #expect(naiveLastTokens == fixture.manifest.oracle.naiveLastTokens) + #expect(dedupedLastTokens == fixture.manifest.oracle.dedupedLastTokens) + #expect(naiveLastTokens > dedupedLastTokens) + #expect(timestampMismatches == fixture.manifest.oracle.copiedPrefixTimestampMismatches) + #expect(timestampMismatches > 0) + + #expect(Self.hasTotalTokenUsageDrop(parent) == fixture.manifest.oracle.parentHasTotalTokenUsageDrop) + #expect(Self.hasTotalTokenUsageDrop(child) == fixture.manifest.oracle.childHasTotalTokenUsageDrop) + } + + @Test + func `live fork 4d90 fixture preserves the copied normalized prefix and hand oracle`() throws { + let fixture = try SanitizedForkFamilyFixture.load(named: "live-fork-4d90-52bf") + let parentMetadata = try fixture.sessionMetadata(named: "parent") + let childMetadata = try fixture.sessionMetadata(named: "child") + let parent = try fixture.events(named: "parent") + let child = try fixture.events(named: "child") + let prefix = try #require(fixture.manifest.copiedPrefixes.first { prefix in + prefix.parentAlias == "parent" && prefix.childAlias == "child" + }) + + let actualPrefixLength = Self.longestCommonNormalizedPrefix(parent, child) + let copiedParent = Array(parent.prefix(prefix.length)) + let copiedChild = Array(child.prefix(prefix.length)) + let parentLastTokens = parent.map(\.last.totalTokens).reduce(0, +) + let childLastTokens = child.map(\.last.totalTokens).reduce(0, +) + let copiedLastTokens = copiedChild.map(\.last.totalTokens).reduce(0, +) + let naiveLastTokens = parentLastTokens + childLastTokens + let dedupedLastTokens = parentLastTokens + child.dropFirst(prefix.length) + .map(\.last.totalTokens) + .reduce(0, +) + let timestampMismatches = zip(copiedParent, copiedChild) + .count(where: { $0.timestamp != $1.timestamp }) + + #expect(parent.count == fixture.manifest.oracle.parentEventCount) + #expect(child.count == fixture.manifest.oracle.childEventCount) + #expect(parentMetadata.id == "parent-session") + #expect(parentMetadata.forkedFromID == nil) + #expect(childMetadata.id == "child-session") + #expect(childMetadata.forkedFromID == "parent-session") + #expect(actualPrefixLength == prefix.length) + #expect(prefix.length == fixture.manifest.oracle.copiedPrefixLength) + #expect(parent.prefix(prefix.length).map(\.fingerprint) == child.prefix(prefix.length).map(\.fingerprint)) + + #expect(parentLastTokens == fixture.manifest.oracle.parentLastTokens) + #expect(childLastTokens == fixture.manifest.oracle.childLastTokens) + #expect(copiedLastTokens == fixture.manifest.oracle.copiedPrefixLastTokens) + #expect(naiveLastTokens == fixture.manifest.oracle.naiveLastTokens) + #expect(dedupedLastTokens == fixture.manifest.oracle.dedupedLastTokens) + #expect(naiveLastTokens > dedupedLastTokens) + #expect(timestampMismatches == fixture.manifest.oracle.copiedPrefixTimestampMismatches) + #expect(timestampMismatches > 0) + #expect(Self.hasTotalTokenUsageDrop(parent) == false) + #expect(Self.hasTotalTokenUsageDrop(child) == false) + } + + @Test + func `archived fork fixture admits only provenance safe fields`() throws { + try Self.assertProvenanceSafeFields(named: "archived-fork-33ce-3869") + } + + @Test + func `live fork 4d90 fixture admits only provenance safe fields`() throws { + try Self.assertProvenanceSafeFields(named: "live-fork-4d90-52bf") + } + + private static func assertProvenanceSafeFields(named name: String) throws { + let fixture = try SanitizedForkFamilyFixture.load(named: name) + let usageKeys: Set = [ + "cached_input_tokens", + "input_tokens", + "output_tokens", + "reasoning_output_tokens", + "total_tokens", + ] + + for alias in ["parent", "child"] { + for record in try fixture.jsonObjects(named: alias) { + #expect(Set(record.keys) == ["payload", "timestamp", "type"]) + let type = try #require(record["type"] as? String) + let payload = try #require(record["payload"] as? [String: Any]) + + switch type { + case "session_meta": + #expect(Set(payload.keys) == ["forked_from_id", "id", "timestamp"]) + + case "turn_context": + #expect(Set(payload.keys).isSubset(of: ["model", "multi_agent_mode", "multi_agent_version"])) + #expect(payload["model"] as? String == "fixture-model") + + case "event_msg": + #expect(Set(payload.keys) == ["info", "type"]) + #expect(payload["type"] as? String == "token_count") + let info = try #require(payload["info"] as? [String: Any]) + #expect(Set(info.keys) == ["last_token_usage", "total_token_usage"]) + for usageName in ["last_token_usage", "total_token_usage"] { + let usage = try #require(info[usageName] as? [String: Any]) + #expect(Set(usage.keys) == usageKeys) + } + + default: + throw SanitizedForkFamilyFixture.FixtureError.unexpectedRecordType + } + } + } + } + + private static func longestCommonNormalizedPrefix( + _ parent: [SanitizedForkFamilyFixture.TokenEvent], + _ child: [SanitizedForkFamilyFixture.TokenEvent]) -> Int + { + zip(parent, child).prefix { $0.fingerprint == $1.fingerprint }.count + } + + private static func hasTotalTokenUsageDrop(_ events: [SanitizedForkFamilyFixture.TokenEvent]) -> Bool { + zip(events, events.dropFirst()).contains { previous, next in + next.total.totalTokens < previous.total.totalTokens + } + } +} diff --git a/Tests/CodexBarTests/Issue2037ScannerIntegrationTests.swift b/Tests/CodexBarTests/Issue2037ScannerIntegrationTests.swift new file mode 100644 index 0000000000..17339b8b28 --- /dev/null +++ b/Tests/CodexBarTests/Issue2037ScannerIntegrationTests.swift @@ -0,0 +1,181 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct Issue2037ScannerIntegrationTests { + /// Locks that `#1164` inherited-totals accounting matches parent-owns-prefix + /// scanner units for the sanitized ordinary fork family when the parent file + /// is present in the scan window. Missing-parent / interleaved Ultra shapes + /// need separate goldens. + @Test + func `archived fork family scanner matches parent-owns-prefix oracle`() throws { + let fixture = try Issue2037FixtureHarness.load(named: "archived-fork-33ce-3869") + let sanitized = try SanitizedForkFamilyFixture.load(named: "archived-fork-33ce-3869") + let oracle = sanitized.manifest.oracle + let prefixLength = try #require(sanitized.manifest.copiedPrefixes.first).length + + let parentEvents = try sanitized.events(named: "parent") + let childEvents = try sanitized.events(named: "child") + let expectedScannerUnits = parentEvents.map(\.last.scannerUnits).reduce(0, +) + + childEvents.dropFirst(prefixLength).map(\.last.scannerUnits).reduce(0, +) + let naiveScannerUnits = parentEvents.map(\.last.scannerUnits).reduce(0, +) + + childEvents.map(\.last.scannerUnits).reduce(0, +) + + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + try Issue2037FixtureHarness.install(fixture, into: env) + + let since = try env.makeLocalNoon(year: 2030, month: 1, day: 1) + let until = try env.makeLocalNoon(year: 2030, month: 1, day: 2) + var options = CostUsageScanner.Options( + codexSessionsRoot: env.codexSessionsRoot, + cacheRoot: env.cacheRoot) + options.forceRescan = true + options.refreshMinIntervalSeconds = 0 + + let report = CostUsageScanner.loadDailyReport( + provider: .codex, + since: since, + until: until, + now: until, + options: options) + + var scannedUnits = 0 + var dayKeys: [String] = [] + for day in report.data { + dayKeys.append(day.date) + scannedUnits += day.inputTokens ?? 0 + scannedUnits += day.cacheReadTokens ?? 0 + scannedUnits += day.outputTokens ?? 0 + } + + #expect(!report.data.isEmpty) + #expect(naiveScannerUnits > expectedScannerUnits) + #expect(oracle.naiveLastTokens > oracle.dedupedLastTokens) + #expect( + scannedUnits == expectedScannerUnits, + """ + scanned=\(scannedUnits) expectedDeduped=\(expectedScannerUnits) \ + naive=\(naiveScannerUnits) days=\(dayKeys) + """) + } + + /// Second parent-present golden from a local Sol/Terra-adjacent fork + /// (`4d90→52bf`). Parent is truncated to the copied prefix so `#1164` + /// inheritance has a clean resolved-fork baseline. + /// + /// Scanner units follow `total_token_usage` deltas (not `sum(last)`): this + /// corpus has a flat-total row with non-zero `last` at parent ordinal 120. + @Test + func `live fork 4d90 family scanner matches parent-owns-prefix oracle`() throws { + let fixture = try Issue2037FixtureHarness.load(named: "live-fork-4d90-52bf") + let sanitized = try SanitizedForkFamilyFixture.load(named: "live-fork-4d90-52bf") + let scannerOracle = try #require(fixture.manifest.scannerOracle) + let prefixLength = try #require(sanitized.manifest.copiedPrefixes.first).length + + let parentEvents = try sanitized.events(named: "parent") + let childEvents = try sanitized.events(named: "child") + let parentTotalUnits = try #require(parentEvents.last).total.scannerUnits + let prefixEndTotalUnits = try #require(childEvents.dropFirst(prefixLength - 1).first).total.scannerUnits + let childEndTotalUnits = try #require(childEvents.last).total.scannerUnits + let expectedScannerUnits = parentTotalUnits + max(0, childEndTotalUnits - prefixEndTotalUnits) + + #expect(parentTotalUnits == prefixEndTotalUnits) + #expect(expectedScannerUnits == childEndTotalUnits) + #expect(expectedScannerUnits == scannerOracle.dedupedScannerUnits) + #expect(scannerOracle.naiveScannerUnits > scannerOracle.dedupedScannerUnits) + // Corpus anomaly: sum(last) overcounts vs total-delta scanner units. + #expect(parentEvents.map(\.last.scannerUnits).reduce(0, +) > parentTotalUnits) + + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + try Issue2037FixtureHarness.install(fixture, into: env) + + let since = try env.makeLocalNoon(year: 2030, month: 1, day: 1) + let until = try env.makeLocalNoon(year: 2030, month: 1, day: 2) + var options = CostUsageScanner.Options( + codexSessionsRoot: env.codexSessionsRoot, + cacheRoot: env.cacheRoot) + options.forceRescan = true + options.refreshMinIntervalSeconds = 0 + + let report = CostUsageScanner.loadDailyReport( + provider: .codex, + since: since, + until: until, + now: until, + options: options) + + var scannedUnits = 0 + var dayKeys: [String] = [] + for day in report.data { + dayKeys.append(day.date) + scannedUnits += day.inputTokens ?? 0 + scannedUnits += day.cacheReadTokens ?? 0 + scannedUnits += day.outputTokens ?? 0 + } + + #expect(!report.data.isEmpty) + #expect( + scannedUnits == scannerOracle.dedupedScannerUnits, + """ + scanned=\(scannedUnits) expectedDeduped=\(scannerOracle.dedupedScannerUnits) \ + naive=\(scannerOracle.naiveScannerUnits) days=\(dayKeys) + """) + } + + @Test + func `missing parent equal counter siblings fail open`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2030, month: 7, day: 1) + let firstTimestamp = env.isoString(for: day) + let secondTimestamp = env.isoString(for: day.addingTimeInterval(3600)) + + func siblingContents(id: String, model: String, timestamp: String) -> String { + let metadata = "{\"type\":\"session_meta\",\"timestamp\":\"\(timestamp)\",\"payload\":{" + + "\"id\":\"\(id)\",\"forked_from_id\":\"missing-parent\"," + + "\"timestamp\":\"\(timestamp)\"}}" + let context = "{\"type\":\"turn_context\",\"timestamp\":\"\(timestamp)\"," + + "\"payload\":{\"model\":\"\(model)\"}}" + let first = "{\"type\":\"event_msg\",\"timestamp\":\"\(timestamp)\",\"payload\":{" + + "\"type\":\"token_count\",\"info\":{" + + "\"last_token_usage\":{\"input_tokens\":10,\"cached_input_tokens\":0," + + "\"output_tokens\":0},\"total_token_usage\":{\"input_tokens\":10," + + "\"cached_input_tokens\":0,\"output_tokens\":0}}}}" + let second = "{\"type\":\"event_msg\",\"timestamp\":\"\(timestamp)\",\"payload\":{" + + "\"type\":\"token_count\",\"info\":{" + + "\"last_token_usage\":{\"input_tokens\":5,\"cached_input_tokens\":0," + + "\"output_tokens\":0},\"total_token_usage\":{\"input_tokens\":15," + + "\"cached_input_tokens\":0,\"output_tokens\":0}}}}" + return [metadata, context, first, second].joined(separator: "\n") + "\n" + } + + _ = try env.writeCodexArchivedSessionFile( + filename: "sibling-a.jsonl", + contents: siblingContents(id: "sibling-a", model: "fixture-model-a", timestamp: firstTimestamp)) + _ = try env.writeCodexArchivedSessionFile( + filename: "sibling-b.jsonl", + contents: siblingContents(id: "sibling-b", model: "fixture-model-b", timestamp: secondTimestamp)) + + var options = CostUsageScanner.Options( + codexSessionsRoot: env.codexSessionsRoot, + cacheRoot: env.cacheRoot) + options.forceRescan = true + options.refreshMinIntervalSeconds = 0 + let report = CostUsageScanner.loadDailyReport( + provider: .codex, + since: day, + until: day, + now: day, + options: options) + let scannedUnits = report.data.reduce(0) { partial, row in + partial + (row.inputTokens ?? 0) + (row.cacheReadTokens ?? 0) + (row.outputTokens ?? 0) + } + + // Each unresolved child skips its first cumulative snapshot, then independently bills + // five input tokens. Equal token vectors are not sufficient cross-file identity. + #expect(scannedUnits == 10) + } +} diff --git a/Tests/CodexBarTests/KeyboardShortcutsBundleTests.swift b/Tests/CodexBarTests/KeyboardShortcutsBundleTests.swift index f31aadae56..2541001d17 100644 --- a/Tests/CodexBarTests/KeyboardShortcutsBundleTests.swift +++ b/Tests/CodexBarTests/KeyboardShortcutsBundleTests.swift @@ -1,9 +1,19 @@ import KeyboardShortcuts import Testing +@testable import CodexBar @MainActor struct KeyboardShortcutsBundleTests { @Test func `recorder initializes without crashing`() { _ = KeyboardShortcuts.RecorderCocoa(for: .init("test.keyboardshortcuts.bundle")) } + + @Test func `open menu recorder expands beyond dependency intrinsic width`() { + let recorder = KeyboardShortcuts.RecorderCocoa(for: .init("test.keyboardshortcuts.width")) + let size = OpenMenuShortcutRecorder.fittedSize(intrinsicHeight: recorder.intrinsicContentSize.height) + + #expect(size.width == OpenMenuShortcutRecorder.preferredWidth) + #expect(size.width > recorder.intrinsicContentSize.width) + #expect(size.height == recorder.intrinsicContentSize.height) + } } diff --git a/Tests/CodexBarTests/KeychainAccessGateConcurrencyTests.swift b/Tests/CodexBarTests/KeychainAccessGateConcurrencyTests.swift new file mode 100644 index 0000000000..dbca48de8c --- /dev/null +++ b/Tests/CodexBarTests/KeychainAccessGateConcurrencyTests.swift @@ -0,0 +1,42 @@ +import Foundation +import Testing +@testable import CodexBarCore + +/// Regression test for the data race on `KeychainAccessGate`'s override statics and the +/// mirrored `BrowserCookieKeychainAccessGate.isDisabled` write. Before the fix, the +/// `isDisabled` setter and `resetOverrideForTesting` wrote these shared statics with no +/// synchronization, so hammering them from concurrent threads is a data race that +/// `swift test --sanitize=thread` reports deterministically (it was flaky in the normal +/// suite because real tests only occasionally overlap). With the lock the accesses are +/// serialized. ThreadSanitizer is the oracle here — there is no value to `#expect`; the +/// pass condition is simply that no data race is reported while the lanes run. +@Suite(.serialized) +struct KeychainAccessGateConcurrencyTests { + /// Opt-in only: this case mutates the process-wide `KeychainAccessGate` override, which other + /// suites read (e.g. `keychainAccessAllowed`) — `@Suite(.serialized)` serializes this suite but + /// not the whole `swift test --parallel` process, so running it alongside other suites could flake + /// them. It exists to trip ThreadSanitizer deterministically; run it in isolation via + /// `CODEXBAR_TSAN_STRESS=1 swift test --sanitize=thread --filter KeychainAccessGateConcurrencyTests`. + @Test(.enabled(if: ProcessInfo.processInfo.environment["CODEXBAR_TSAN_STRESS"] == "1")) + func `concurrent override writes, resets, and reads are race-free`() { + let iterations = 5000 + let lanes = 4 + let group = DispatchGroup() + let queue = DispatchQueue(label: "keychain-access-gate.concurrency", attributes: .concurrent) + for lane in 0.. TestEntry? { + guard case let .found(entry) = KeychainCacheStore.load(key: key, as: TestEntry.self) else { return nil } + return entry + } + @Test func `stores and loads entry`() { KeychainCacheStore.setTestStoreForTesting(true) @@ -146,6 +182,14 @@ 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) == .failed) + } + @Test func `load failure override bypasses test store without affecting store or clear`() { KeychainCacheStore.setTestStoreForTesting(true) @@ -173,6 +217,93 @@ struct KeychainCacheStoreTests { } } + @Test + func `disabled keychain access keeps an in process memory cache`() { + KeychainCacheStore.resetDisabledAccessMemoryStoreForTesting() + defer { + KeychainCacheStore.resetDisabledAccessMemoryStoreForTesting() + KeychainAccessGate.resetOverrideForTesting() + } + + let service = "disabled-memory-\(UUID().uuidString)" + let key = KeychainCacheStore.Key(category: "cookie", identifier: "cursor") + let entry = TestEntry(value: "WorkosCursorSessionToken=memory", storedAt: Date(timeIntervalSince1970: 3)) + + KeychainAccessGate.withTaskOverrideForTesting(true) { + KeychainCacheStore.withDisabledAccessMemoryStoreForTesting(true) { + KeychainCacheStore.withServiceOverrideForTesting(service) { + #expect(KeychainCacheStore.storeResult(key: key, entry: entry)) + switch KeychainCacheStore.load(key: key, as: TestEntry.self) { + case let .found(loaded): + #expect(loaded == entry) + case .missing, .temporarilyUnavailable, .invalid: + #expect(Bool(false), "Expected in-process memory cache entry") + } + #expect(KeychainCacheStore.keys(category: "cookie").contains(key)) + #expect(KeychainCacheStore.clearResult(key: key) == .removed) + switch KeychainCacheStore.load(key: key, as: TestEntry.self) { + case .missing: + break + case .found, .temporarilyUnavailable, .invalid: + #expect(Bool(false), "Expected memory cache entry to be cleared") + } + } + } + } + } + + @Test + func `disabled keychain access does not retain OAuth entries in memory`() { + KeychainCacheStore.resetDisabledAccessMemoryStoreForTesting() + defer { + KeychainCacheStore.resetDisabledAccessMemoryStoreForTesting() + KeychainAccessGate.resetOverrideForTesting() + } + + let service = "disabled-memory-oauth-\(UUID().uuidString)" + let key = KeychainCacheStore.Key.oauth(provider: .claude) + let entry = TestEntry(value: "synthetic-oauth-credential", storedAt: Date(timeIntervalSince1970: 4)) + + KeychainAccessGate.withTaskOverrideForTesting(true) { + KeychainCacheStore.withDisabledAccessMemoryStoreForTesting(true) { + KeychainCacheStore.withServiceOverrideForTesting(service) { + #expect(!KeychainCacheStore.storeResult(key: key, entry: entry)) + #expect(self.loadedEntry(for: key) == nil) + #expect(KeychainCacheStore.keysResult(category: "oauth") == .failed) + } + } + } + } + + @Test + func `toggling Keychain access clears the disabled access memory cache`() { + KeychainCacheStore.resetDisabledAccessMemoryStoreForTesting() + defer { + KeychainCacheStore.resetDisabledAccessMemoryStoreForTesting() + KeychainAccessGate.resetOverrideForTesting() + } + + let service = "disabled-memory-toggle-\(UUID().uuidString)" + let key = KeychainCacheStore.Key(category: "cookie", identifier: "cursor") + let entry = TestEntry(value: "WorkosCursorSessionToken=stale", storedAt: Date(timeIntervalSince1970: 4)) + + KeychainAccessGate.isDisabled = true + KeychainCacheStore.withDisabledAccessMemoryStoreForTesting(true) { + KeychainCacheStore.withServiceOverrideForTesting(service) { + #expect(KeychainCacheStore.storeResult(key: key, entry: entry)) + #expect(self.loadedEntry(for: key) == entry) + } + } + + KeychainAccessGate.isDisabled = false + + KeychainCacheStore.withDisabledAccessMemoryStoreForTesting(true) { + KeychainCacheStore.withServiceOverrideForTesting(service) { + #expect(self.loadedEntry(for: key) == nil) + } + } + } + @Test func `cache ACL trusts bundled app and CLI helper`() { let root = URL(fileURLWithPath: "/Applications/CodexBar.app") diff --git a/Tests/CodexBarTests/KeychainNoUIQueryTests.swift b/Tests/CodexBarTests/KeychainNoUIQueryTests.swift index 58591543f0..436d9ea5cb 100644 --- a/Tests/CodexBarTests/KeychainNoUIQueryTests.swift +++ b/Tests/CodexBarTests/KeychainNoUIQueryTests.swift @@ -58,5 +58,50 @@ struct KeychainNoUIQueryTests { let status = SecItemCopyMatching(query as CFDictionary, &result) #expect(status == errSecItemNotFound || status == errSecInteractionNotAllowed) } + + @Test + func `processes block every Security item operation before system access`() { + guard ProcessInfo.processInfo.environment[KeychainTestSafety.allowAccessEnvironmentKey] != "1" else { + return + } + + #expect(KeychainTestSafety.shouldBlockRealKeychainAccess()) + + let empty = [:] as CFDictionary + var result: CFTypeRef? + #expect(KeychainSecurity.copyMatching(empty, &result) == errSecInteractionNotAllowed) + #expect(KeychainSecurity.update(empty, empty) == errSecInteractionNotAllowed) + #expect(KeychainSecurity.add(empty, nil) == errSecInteractionNotAllowed) + #expect(KeychainSecurity.delete(empty) == errSecInteractionNotAllowed) + } + + @Test + func `safety recognizes runner variants and explicit controls`() { + #expect(KeychainTestSafety.shouldBlockRealKeychainAccess( + processName: "swiftpm-testing-helper", + environment: [:])) + #expect(KeychainTestSafety.shouldBlockRealKeychainAccess( + processName: "CodexBarPackageTests.xctest", + environment: [:])) + #expect(KeychainTestSafety.shouldBlockRealKeychainAccess( + processName: "future-test-runner", + environment: [KeychainTestSafety.suppressAccessEnvironmentKey: "1"])) + #expect(KeychainTestSafety.shouldBlockRealKeychainAccess( + processName: "CodexBar", + environment: [:]) == false) + #expect(KeychainTestSafety.shouldBlockRealKeychainAccess( + processName: "swiftpm-testing-helper", + environment: [KeychainTestSafety.allowAccessEnvironmentKey: "1"]) == false) + + #expect(KeychainTestSafety.shouldIsolateUserStateUnderTests( + processName: "swiftpm-testing-helper", + environment: [:])) + #expect(KeychainTestSafety.shouldIsolateUserStateUnderTests( + processName: "CodexBar", + environment: [KeychainAccessGate.disableAccessEnvironmentKey: "1"]) == false) + #expect(KeychainTestSafety.shouldIsolateUserStateUnderTests( + processName: "swiftpm-testing-helper", + environment: [KeychainTestSafety.allowAccessEnvironmentKey: "1"]) == false) + } } #endif diff --git a/Tests/CodexBarTests/KeychainPromptCoordinatorTests.swift b/Tests/CodexBarTests/KeychainPromptCoordinatorTests.swift new file mode 100644 index 0000000000..cfaeeaaebe --- /dev/null +++ b/Tests/CodexBarTests/KeychainPromptCoordinatorTests.swift @@ -0,0 +1,68 @@ +import CodexBarCore +import Testing +@testable import CodexBar + +struct KeychainPromptCoordinatorTests { + @Test + func `detects raw SwiftPM debug executable`() { + #expect(KeychainPromptCoordinator.isUnbundledCodexBarExecutable( + "/Users/me/CodexBar/.build/arm64-apple-macosx/debug/CodexBar")) + #expect(KeychainPromptCoordinator.isUnbundledCodexBarExecutable( + "/Users/me/CodexBar/.build/debug/CodexBar")) + } + + @Test + func `detects raw SwiftPM release executable`() { + #expect(KeychainPromptCoordinator.isUnbundledCodexBarExecutable( + "/Users/me/CodexBar/.build/arm64-apple-macosx/release/CodexBar")) + } + + @Test + func `detects custom SwiftPM scratch path`() { + #expect(KeychainPromptCoordinator.isUnbundledCodexBarExecutable( + "/tmp/codexbar-build/arm64-apple-macosx/debug/CodexBar")) + } + + @Test + func `keeps packaged app keychain behavior`() { + #expect(!KeychainPromptCoordinator.isUnbundledCodexBarExecutable( + "/Applications/CodexBar.app/Contents/MacOS/CodexBar")) + #expect(!KeychainPromptCoordinator.isUnbundledCodexBarExecutable( + "/Users/me/CodexBar/.build/package/CodexBar.app/Contents/MacOS/CodexBar")) + } + + @Test + func `ignores unrelated executable paths`() { + #expect(!KeychainPromptCoordinator.isUnbundledCodexBarExecutable( + "/Users/me/CodexBar/.build/debug/CodexBarCLI")) + #expect(!KeychainPromptCoordinator.isUnbundledCodexBarExecutable("")) + #expect(!KeychainPromptCoordinator.isUnbundledCodexBarExecutable("CodexBar")) + } + + @Test + func `browser cookie alert explains password handling and opt out`() { + let model = KeychainPromptCoordinator.browserCookieAlertModel(label: "Chrome Safe Storage") + + #expect(model.title == "Keychain Access Required") + #expect(model.message.contains("Chrome Safe Storage")) + #expect(model.message.contains("macOS—not CodexBar—handles any Mac login password entry")) + #expect(model.message.contains("Settings → Advanced")) + #expect(model.primaryButtonTitle == "OK") + #expect(model.learnMoreButtonTitle == "Learn More…") + #expect(model.documentationURL.hasSuffix("/docs/keychain-prompts.md")) + } + + @Test + func `provider alert preserves the requested keychain purpose`() { + let context = KeychainPromptContext( + kind: .claudeOAuth, + service: "Claude Code-credentials", + account: nil) + + let model = KeychainPromptCoordinator.alertModel(for: context) + + #expect(model.message.contains("Claude Code OAuth token")) + #expect(model.message.contains("fetch your Claude usage")) + #expect(model.learnMoreButtonTitle == "Learn More…") + } +} diff --git a/Tests/CodexBarTests/KeychainPromptSafetyAuditTests.swift b/Tests/CodexBarTests/KeychainPromptSafetyAuditTests.swift index fb00d1ae64..9493abdd99 100644 --- a/Tests/CodexBarTests/KeychainPromptSafetyAuditTests.swift +++ b/Tests/CodexBarTests/KeychainPromptSafetyAuditTests.swift @@ -10,6 +10,14 @@ struct KeychainPromptSafetyAuditTests { #expect(agents.contains("use parser tests, stubs, test stores, or `KeychainNoUIQuery`")) } + @Test + func `default test runner explicitly suppresses real keychain access`() throws { + let script = try Self.readRepoFile("Scripts/test.sh") + + #expect(script.contains("CODEXBAR_ALLOW_TEST_KEYCHAIN_ACCESS")) + #expect(script.contains("export CODEXBAR_SUPPRESS_TEST_KEYCHAIN_ACCESS=1")) + } + @Test func `live TTY integration tests are opt in`() throws { let ttyTests = try Self.readRepoFile("Tests/CodexBarTests/TTYIntegrationTests.swift") @@ -42,15 +50,96 @@ struct KeychainPromptSafetyAuditTests { } @Test - func `tests do not call SecItemCopyMatching except no UI query coverage`() throws { + func `claude availability tests with keychain enabled use test doubles`() throws { + let file = Self.repoRoot().appendingPathComponent( + "Tests/CodexBarTests/ClaudeOAuthFetchStrategyAvailabilityTests.swift") + let lines = try Self.lines(in: file) + let callSites = lines.enumerated().compactMap { lineNumber, line -> PromptCallSite? in + guard line.contains("strategy.isAvailable(context)") else { return nil } + let oneBasedLineNumber = lineNumber + 1 + guard Self.hasOpenScope( + containing: "KeychainAccessGate.withTaskOverrideForTesting(false)", + lines: lines, + before: oneBasedLineNumber) + else { + return nil + } + return PromptCallSite(file: file, lineNumber: oneBasedLineNumber) + } + + #expect(callSites.isEmpty == false) + for callSite in callSites { + let failureMessage = "\(callSite.file.path):\(callSite.lineNumber) calls strategy.isAvailable(context) " + + "with test keychain access enabled and incomplete scoped keychain isolation" + #expect( + Self.hasOpenAvailabilityKeychainIsolation(lines: lines, before: callSite.lineNumber), + "\(failureMessage)") + } + } + + @Test + func `availability audit rejects a Claude-only keychain override`() { + let lines: [Substring] = [ + "KeychainAccessGate.withTaskOverrideForTesting(false) {", + "ClaudeOAuthCredentialsStore.withKeychainAccessOverrideForTesting(true) {", + "strategy.isAvailable(context)", + "}", + "}", + ] + + #expect(Self.hasOpenAvailabilityKeychainIsolation(lines: lines, before: 3) == false) + } + + @Test + func `availability audit accepts combined cache and Claude keychain doubles`() { + let lines: [Substring] = [ + "KeychainAccessGate.withTaskOverrideForTesting(false) {", + "self.withAvailabilityKeychainDoubles {", + "strategy.isAvailable(context)", + "}", + "}", + ] + + #expect(Self.hasOpenAvailabilityKeychainIsolation(lines: lines, before: 3)) + } + + @Test + func `prompt audit accepts interactive Claude keychain read double`() { + let lines: [Substring] = [ + "ClaudeOAuthCredentialsStore.withInteractiveClaudeKeychainReadOverridesForTesting(", + " operation: {", + " allowKeychainPrompt: true", + " })", + ] + + #expect(Self.hasOpenKeychainTestDouble(lines: lines, before: 3)) + } + + @Test + func `tests do not call Security item APIs except no UI query coverage`() throws { + let securityItemCalls = ["SecItemCopyMatching", "SecItemUpdate", "SecItemAdd", "SecItemDelete"] let offenders = try Self.swiftTestFiles().filter { file in let text = try Self.readFile(file) - return text.contains("SecItemCopyMatching") + return securityItemCalls.contains(where: text.contains) && !file.path.hasSuffix("Tests/CodexBarTests/KeychainNoUIQueryTests.swift") && !file.path.hasSuffix("Tests/CodexBarTests/KeychainPromptSafetyAuditTests.swift") } - #expect(offenders.isEmpty, "Unexpected direct SecItemCopyMatching in tests: \(offenders.map(\.path))") + #expect(offenders.isEmpty, "Unexpected direct Security item access in tests: \(offenders.map(\.path))") + } + + @Test + func `production source routes Security item APIs through the test safety gateway`() throws { + let securityItemCalls = ["SecItemCopyMatching", "SecItemUpdate", "SecItemAdd", "SecItemDelete"] + let offenders = try Self.swiftFiles( + under: Self.repoRoot().appendingPathComponent("Sources", isDirectory: true)) + .filter { file in + guard !file.path.hasSuffix("Sources/CodexBarCore/KeychainSecurity.swift") else { return false } + let text = try Self.readFile(file) + return securityItemCalls.contains(where: text.contains) + } + + #expect(offenders.isEmpty, "Security item access bypasses KeychainSecurity: \(offenders.map(\.path))") } private static func repoRoot() -> URL { @@ -74,8 +163,14 @@ struct KeychainPromptSafetyAuditTests { private static func swiftTestFiles(excludingSelf: Bool = false) throws -> [URL] { let testsRoot = self.repoRoot().appendingPathComponent("Tests/CodexBarTests", isDirectory: true) + return try self.swiftFiles(under: testsRoot).filter { file in + !(excludingSelf && file.path.hasSuffix("Tests/CodexBarTests/KeychainPromptSafetyAuditTests.swift")) + } + } + + private static func swiftFiles(under root: URL) throws -> [URL] { guard let enumerator = FileManager.default.enumerator( - at: testsRoot, + at: root, includingPropertiesForKeys: [.isRegularFileKey], options: [.skipsHiddenFiles]) else { return [] } @@ -84,9 +179,6 @@ struct KeychainPromptSafetyAuditTests { for case let file as URL in enumerator where file.pathExtension == "swift" { let values = try file.resourceValues(forKeys: [.isRegularFileKey]) if values.isRegularFile == true { - if excludingSelf, file.path.hasSuffix("Tests/CodexBarTests/KeychainPromptSafetyAuditTests.swift") { - continue - } files.append(file) } } @@ -96,13 +188,45 @@ struct KeychainPromptSafetyAuditTests { private static func hasOpenKeychainTestDouble(lines: [Substring], before oneBasedLineNumber: Int) -> Bool { let helperNames = [ "withClaudeKeychainOverridesForTesting", + "withInteractiveClaudeKeychainReadOverridesForTesting", + "withKeychainAccessOverrideForTesting(true)", "withSecurityCLIReadOverrideForTesting", "KeychainAccessPreflight.withCheckGenericPasswordOverrideForTesting", ] + return helperNames.contains { helperName in + self.hasOpenScope(containing: helperName, lines: lines, before: oneBasedLineNumber) + } + } + + private static func hasOpenAvailabilityKeychainIsolation( + lines: [Substring], + before oneBasedLineNumber: Int) -> Bool + { + if self.hasOpenScope( + containing: "withAvailabilityKeychainDoubles", + lines: lines, + before: oneBasedLineNumber) + { + return true + } + + let bypassesCacheKeychain = self.hasOpenScope( + containing: "nonInteractiveCredentialRecordOverride", + lines: lines, + before: oneBasedLineNumber) + return bypassesCacheKeychain + && self.hasOpenKeychainTestDouble(lines: lines, before: oneBasedLineNumber) + } + + private static func hasOpenScope( + containing needle: String, + lines: [Substring], + before oneBasedLineNumber: Int) -> Bool + { let targetIndex = oneBasedLineNumber - 1 let lineRange = lines.indices.prefix(through: targetIndex) return lineRange.contains { index in - helperNames.contains { lines[index].contains($0) } + lines[index].contains(needle) && self.hasOpenBraceScope(lines: lines, from: index, through: targetIndex) } } @@ -110,7 +234,8 @@ struct KeychainPromptSafetyAuditTests { private static func hasOpenBraceScope(lines: [Substring], from startIndex: Int, through endIndex: Int) -> Bool { var balance = 0 var sawOpeningBrace = false - for line in lines[startIndex...endIndex] { + for index in startIndex...endIndex { + let line = lines[index] for character in line { switch character { case "{": @@ -122,6 +247,9 @@ struct KeychainPromptSafetyAuditTests { continue } } + if index < endIndex, sawOpeningBrace, balance <= 0 { + return false + } } return sawOpeningBrace && balance > 0 } diff --git a/Tests/CodexBarTests/KimiK2SettingsReaderTests.swift b/Tests/CodexBarTests/KimiK2SettingsReaderTests.swift deleted file mode 100644 index 32d311704a..0000000000 --- a/Tests/CodexBarTests/KimiK2SettingsReaderTests.swift +++ /dev/null @@ -1,26 +0,0 @@ -import CodexBarCore -import Testing - -struct KimiK2SettingsReaderTests { - @Test - func `api key is trimmed`() { - let env = ["KIMI_API_KEY": " key-123 "] - #expect(KimiK2SettingsReader.apiKey(environment: env) == "key-123") - } - - @Test - func `api key strips quotes`() { - let env = ["KIMI_KEY": "\"quoted-456\""] - #expect(KimiK2SettingsReader.apiKey(environment: env) == "quoted-456") - } -} - -struct KimiK2ProviderTokenResolverTests { - @Test - func `resolves from environment`() { - let env = ["KIMI_API_KEY": "env-token"] - let resolution = ProviderTokenResolver.kimiK2Resolution(environment: env) - #expect(resolution?.token == "env-token") - #expect(resolution?.source == .environment) - } -} diff --git a/Tests/CodexBarTests/KimiK2TokenStoreTestSupport.swift b/Tests/CodexBarTests/KimiK2TokenStoreTestSupport.swift deleted file mode 100644 index bf5f2177d3..0000000000 --- a/Tests/CodexBarTests/KimiK2TokenStoreTestSupport.swift +++ /dev/null @@ -1,9 +0,0 @@ -@testable import CodexBar - -struct NoopKimiK2TokenStore: KimiK2TokenStoring { - func loadToken() throws -> String? { - nil - } - - func storeToken(_: String?) throws {} -} diff --git a/Tests/CodexBarTests/KimiK2UsageFetcherTests.swift b/Tests/CodexBarTests/KimiK2UsageFetcherTests.swift deleted file mode 100644 index 9b549bfbf7..0000000000 --- a/Tests/CodexBarTests/KimiK2UsageFetcherTests.swift +++ /dev/null @@ -1,101 +0,0 @@ -import Foundation -import Testing -@testable import CodexBarCore - -struct KimiK2UsageFetcherTests { - @Test - func `parses usage from nested usage`() throws { - let json = """ - { - "data": { - "usage": { - "total": 120, - "credits_remaining": 30, - "average_tokens": 42, - "updated_at": "2024-01-02T03:04:05Z" - } - } - } - """ - - let summary = try KimiK2UsageFetcher._parseSummaryForTesting(Data(json.utf8)) - let expectedDate = Date(timeIntervalSince1970: 1_704_164_645) - - #expect(summary.consumed == 120) - #expect(summary.remaining == 30) - #expect(summary.averageTokens == 42) - #expect(abs(summary.updatedAt.timeIntervalSince1970 - expectedDate.timeIntervalSince1970) < 0.5) - } - - @Test - func `uses header fallback for remaining credits`() throws { - let json = """ - { "total_credits_consumed": 50 } - """ - let headers: [AnyHashable: Any] = ["X-Credits-Remaining": "25"] - - let summary = try KimiK2UsageFetcher._parseSummaryForTesting(Data(json.utf8), headers: headers) - - #expect(summary.consumed == 50) - #expect(summary.remaining == 25) - } - - @Test - func `parses numeric timestamp seconds`() throws { - let json = """ - { - "timestamp": 1700000000, - "credits_remaining": 10, - "total_credits_consumed": 5 - } - """ - - let summary = try KimiK2UsageFetcher._parseSummaryForTesting(Data(json.utf8)) - let expected = Date(timeIntervalSince1970: 1_700_000_000) - - #expect(abs(summary.updatedAt.timeIntervalSince1970 - expected.timeIntervalSince1970) < 0.5) - } - - @Test - func `parses numeric timestamp milliseconds`() throws { - let json = """ - { - "timestamp": 1700000000000, - "credits_remaining": 10, - "total_credits_consumed": 5 - } - """ - - let summary = try KimiK2UsageFetcher._parseSummaryForTesting(Data(json.utf8)) - let expected = Date(timeIntervalSince1970: 1_700_000_000) - - #expect(abs(summary.updatedAt.timeIntervalSince1970 - expected.timeIntervalSince1970) < 0.5) - } - - @Test - func `invalid root returns parse error`() { - let json = """ - [{ "total": 1 }] - """ - - #expect { - _ = try KimiK2UsageFetcher._parseSummaryForTesting(Data(json.utf8)) - } throws: { error in - guard case let KimiK2UsageError.parseFailed(message) = error else { return false } - return message == "Root JSON is not an object." - } - } - - @Test - func `converts api key credits into text only snapshot`() { - let usage = KimiK2UsageSummary( - consumed: 10, - remaining: 25, - averageTokens: nil, - updatedAt: Date()).toUsageSnapshot() - - #expect(usage.primary == nil) - #expect(usage.identity?.providerID == .kimik2) - #expect(usage.identity?.loginMethod == "Credits: 25 left") - } -} diff --git a/Tests/CodexBarTests/KimiProviderTests.swift b/Tests/CodexBarTests/KimiProviderTests.swift index bcac5c943f..912ecf7c48 100644 --- a/Tests/CodexBarTests/KimiProviderTests.swift +++ b/Tests/CodexBarTests/KimiProviderTests.swift @@ -2,6 +2,101 @@ import Foundation import Testing @testable import CodexBarCore +private struct KimiStubClaudeFetcher: ClaudeUsageFetching { + func loadLatestUsage(model _: String) async throws -> ClaudeUsageSnapshot { + throw ClaudeUsageError.parseFailed("stub") + } + + func debugRawProbe(model _: String) async -> String { + "stub" + } + + func detectVersion() -> String? { + nil + } +} + +private func makeKimiFetchContext( + sourceMode: ProviderSourceMode, + environment: [String: String] = [:]) -> ProviderFetchContext +{ + let env = environment + return ProviderFetchContext( + runtime: .app, + sourceMode: sourceMode, + includeCredits: false, + webTimeout: 1, + webDebugDumpHTML: false, + verbose: false, + env: env, + settings: nil, + fetcher: UsageFetcher(environment: env), + claudeFetcher: KimiStubClaudeFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0)) +} + +private func makeTemporaryKimiCodeHome() throws -> URL { + let home = FileManager.default.temporaryDirectory + .appendingPathComponent("CodexBar-KimiCode-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory( + at: home, + withIntermediateDirectories: true, + attributes: [.posixPermissions: 0o700]) + return home +} + +private func writeKimiCodeCredential( + home: URL, + accessToken: String, + refreshToken: String = "refresh", + expiresAt: Any?) throws -> URL +{ + let credentials = home.appendingPathComponent("credentials", isDirectory: true) + try FileManager.default.createDirectory(at: credentials, withIntermediateDirectories: true) + var payload: [String: Any] = [ + "access_token": accessToken, + "refresh_token": refreshToken, + ] + if let expiresAt { + payload["expires_at"] = expiresAt + } + let url = credentials.appendingPathComponent("kimi-code.json") + try JSONSerialization.data(withJSONObject: payload, options: [.prettyPrinted, .sortedKeys]).write(to: url) + return url +} + +private actor KimiOrderedCredentialTransport: ProviderHTTPTransport { + private var headers: [String] = [] + + func authorizationHeaders() -> [String] { + self.headers + } + + func data(for request: URLRequest) async throws -> (Data, URLResponse) { + let authorization = request.value(forHTTPHeaderField: "Authorization") ?? "" + self.headers.append(authorization) + let statusCode: Int + let body: String + switch authorization { + case "Bearer api-bad": + statusCode = 401 + body = #"{"error":"unauthorized"}"# + case "Bearer cli-ok": + statusCode = 200 + body = #"{"usage":{"limit":"100","used":"25","remaining":"75"},"limits":[]}"# + default: + throw URLError(.userAuthenticationRequired) + } + let url = try #require(request.url) + let response = try #require(HTTPURLResponse( + url: url, + statusCode: statusCode, + httpVersion: nil, + headerFields: ["Content-Type": "application/json"])) + return (Data(body.utf8), response) + } +} + struct KimiSettingsReaderTests { @Test func `reads token from environment variable`() { @@ -10,6 +105,154 @@ struct KimiSettingsReaderTests { #expect(token == "test.jwt.token") } + @Test + func `reads API key from preferred environment variable`() { + let env = ["KIMI_CODE_API_KEY": "kimi-code-token"] + let token = KimiSettingsReader.apiKey(environment: env) + #expect(token == "kimi-code-token") + } + + @Test + func `does not consume generic Kimi API key environment variable`() { + let env = ["KIMI_API_KEY": "'kimi-api-token'"] + let token = KimiSettingsReader.apiKey(environment: env) + #expect(token == nil) + } + + @Test + func `uses code specific API key when generic Kimi API key also exists`() { + let env = [ + "KIMI_API_KEY": "generic-kimi-token", + "KIMI_CODE_API_KEY": "kimi-code-token", + ] + let token = KimiSettingsReader.apiKey(environment: env) + #expect(token == "kimi-code-token") + } + + @Test + func `reuses fresh CLI credential without modifying it`() throws { + let home = try makeTemporaryKimiCodeHome() + defer { try? FileManager.default.removeItem(at: home) } + let now = Date(timeIntervalSince1970: 1_800_000_000) + let credentialURL = try writeKimiCodeCredential( + home: home, + accessToken: "oauth", + expiresAt: now.addingTimeInterval(3600).timeIntervalSince1970) + let originalData = try Data(contentsOf: credentialURL) + let originalModificationDate = try #require( + FileManager.default.attributesOfItem(atPath: credentialURL.path)[.modificationDate] as? Date) + let environment = ["KIMI_CODE_HOME": home.path] + + let token = KimiSettingsReader.kimiCodeAccessToken(environment: environment, now: now) + let headers = KimiSettingsReader.kimiCodeIdentityHeaders(environment: environment) + + #expect(token == "oauth") + #expect(headers["X-Msh-Platform"] == "kimi_code_cli") + #expect(headers["X-Msh-Device-Id"]?.isEmpty == false) + #expect(try Data(contentsOf: credentialURL) == originalData) + let finalModificationDate = try #require( + FileManager.default.attributesOfItem(atPath: credentialURL.path)[.modificationDate] as? Date) + #expect(finalModificationDate == originalModificationDate) + + let deviceURL = home.appendingPathComponent("device_id") + let permissions = try #require( + FileManager.default.attributesOfItem(atPath: deviceURL.path)[.posixPermissions] as? NSNumber) + #expect(permissions.intValue & 0o777 == 0o600) + } + + @Test + func `rejects expired or missing-expiry CLI credentials`() throws { + let now = Date() + for expiresAt: Any? in [now.addingTimeInterval(30).timeIntervalSince1970, nil, "not-a-time"] { + let home = try makeTemporaryKimiCodeHome() + defer { try? FileManager.default.removeItem(at: home) } + _ = try writeKimiCodeCredential( + home: home, + accessToken: "oauth", + expiresAt: expiresAt) + let environment = ["KIMI_CODE_HOME": home.path] + + #expect(KimiSettingsReader.hasKimiCodeCredential(environment: environment)) + #expect(KimiSettingsReader.kimiCodeAccessToken(environment: environment, now: now) == nil) + } + } + + @Test + func `keeps explicit key separate and isolates CLI credential from endpoint overrides`() throws { + let home = try makeTemporaryKimiCodeHome() + defer { try? FileManager.default.removeItem(at: home) } + _ = try writeKimiCodeCredential( + home: home, + accessToken: "oauth", + expiresAt: Date().addingTimeInterval(3600).timeIntervalSince1970) + + let explicit = ProviderTokenResolver.kimiAPIResolution(environment: [ + "KIMI_CODE_API_KEY": "explicit", + "KIMI_CODE_HOME": home.path, + ]) + #expect(explicit?.token == "explicit") + #expect(explicit?.source == .environment) + #expect(KimiSettingsReader.kimiCodeAccessToken(environment: [ + "KIMI_CODE_API_KEY": "explicit", + "KIMI_CODE_HOME": home.path, + ]) == "oauth") + + for key in ["KIMI_CODE_BASE_URL", "KIMI_CODE_OAUTH_HOST", "KIMI_OAUTH_HOST"] { + let environment = [ + "KIMI_CODE_HOME": home.path, + key: "https://proxy.example.com", + ] + #expect(KimiSettingsReader.hasKimiCodeCredential(environment: environment) == false) + #expect(ProviderTokenResolver.kimiAPIResolution(environment: environment) == nil) + } + } + + @Test + func `uses default code API base URL when override is absent`() throws { + let url = try KimiSettingsReader.codeAPIBaseURL(environment: [:]) + #expect(url == KimiSettingsReader.defaultCodeAPIBaseURL) + } + + @Test + func `uses custom code API base URL when valid`() throws { + let env = ["KIMI_CODE_BASE_URL": "https://proxy.example.com/kimi"] + let url = try KimiSettingsReader.codeAPIBaseURL(environment: env) + #expect(url.absoluteString == "https://proxy.example.com/kimi") + } + + @Test + func `rejects invalid code API base URL`() { + let env = ["KIMI_CODE_BASE_URL": "not a url"] + + #expect(throws: KimiAPIError.invalidRequest( + "Kimi Code API base URL must use HTTPS without user info")) + { + try KimiSettingsReader.codeAPIBaseURL(environment: env) + } + } + + @Test + func `rejects insecure code API base URL`() { + let env = ["KIMI_CODE_BASE_URL": "http://proxy.example.com/kimi"] + + #expect(throws: KimiAPIError.invalidRequest( + "Kimi Code API base URL must use HTTPS without user info")) + { + try KimiSettingsReader.codeAPIBaseURL(environment: env) + } + } + + @Test + func `rejects code API base URL containing user info`() { + let env = ["KIMI_CODE_BASE_URL": "https://api.kimi.com@proxy.example.com/kimi"] + + #expect(throws: KimiAPIError.invalidRequest( + "Kimi Code API base URL must use HTTPS without user info")) + { + try KimiSettingsReader.codeAPIBaseURL(environment: env) + } + } + @Test func `normalizes quoted token`() { let env = ["KIMI_AUTH_TOKEN": "\"test.jwt.token\""] @@ -39,6 +282,143 @@ struct KimiSettingsReaderTests { } } +struct KimiAPIFetchStrategyTests { + @Test + func `auto mode accepts CLI credential and reports expired remediation`() async throws { + let home = try makeTemporaryKimiCodeHome() + defer { try? FileManager.default.removeItem(at: home) } + _ = try writeKimiCodeCredential( + home: home, + accessToken: "expired", + expiresAt: Date().addingTimeInterval(-60).timeIntervalSince1970) + let strategy = KimiCLICredentialFetchStrategy() + let context = makeKimiFetchContext( + sourceMode: .auto, + environment: ["KIMI_CODE_HOME": home.path]) + + #expect(await strategy.isAvailable(context)) + await #expect(throws: KimiAPIError.expiredCodeCredential) { + try await strategy.fetch(context) + } + #expect(strategy.shouldFallback(on: KimiAPIError.expiredCodeCredential, context: context)) + } + + @Test + func `explicit API mode ignores fresh CLI credential`() async throws { + let home = try makeTemporaryKimiCodeHome() + defer { try? FileManager.default.removeItem(at: home) } + _ = try writeKimiCodeCredential( + home: home, + accessToken: "oauth", + expiresAt: Date().addingTimeInterval(3600).timeIntervalSince1970) + let strategy = KimiAPIFetchStrategy() + let context = makeKimiFetchContext( + sourceMode: .api, + environment: ["KIMI_CODE_HOME": home.path]) + + await #expect(throws: KimiAPIError.missingAPIKey) { + try await strategy.fetch(context) + } + } + + @Test + func `rejected CLI credential keeps CLI remediation`() { + let cliError = KimiCLICredentialFetchStrategy.normalizedCodeAPIError(KimiAPIError.invalidAPIKey) + let keyError = KimiCLICredentialFetchStrategy.normalizedCodeAPIError(KimiAPIError.apiError("failed")) + + #expect(cliError as? KimiAPIError == .invalidCodeCredential) + #expect(keyError as? KimiAPIError == .apiError("failed")) + } + + @Test + func `auto retries fresh CLI credential after rejected API key`() async throws { + let home = try makeTemporaryKimiCodeHome() + defer { try? FileManager.default.removeItem(at: home) } + _ = try writeKimiCodeCredential( + home: home, + accessToken: "cli-ok", + expiresAt: Date().addingTimeInterval(3600).timeIntervalSince1970) + let transport = KimiOrderedCredentialTransport() + let pipeline = ProviderFetchPipeline { _ in + [ + KimiAPIFetchStrategy(transport: transport), + KimiCLICredentialFetchStrategy(transport: transport), + ] + } + let context = makeKimiFetchContext( + sourceMode: .auto, + environment: [ + "KIMI_CODE_API_KEY": "api-bad", + "KIMI_CODE_HOME": home.path, + ]) + + let outcome = await pipeline.fetch(context: context, provider: .kimi) + let result = try outcome.result.get() + + #expect(result.sourceLabel == "Kimi Code CLI") + #expect(outcome.attempts.map(\.strategyID) == ["kimi.api", "kimi.cli"]) + #expect(await transport.authorizationHeaders() == [ + "Bearer api-bad", + "Bearer cli-ok", + ]) + } + + @Test + func `auto mode falls back from invalid API key to web cookies`() { + let strategy = KimiAPIFetchStrategy() + let context = makeKimiFetchContext(sourceMode: .auto) + + #expect(strategy.shouldFallback(on: KimiAPIError.invalidAPIKey, context: context)) + } + + @Test + func `explicit API mode does not fall back from invalid API key`() { + let strategy = KimiAPIFetchStrategy() + let context = makeKimiFetchContext(sourceMode: .api) + + #expect(strategy.shouldFallback(on: KimiAPIError.invalidAPIKey, context: context) == false) + } + + @Test + func `explicit API mode reports API key remediation when key is missing`() async { + let strategy = KimiAPIFetchStrategy() + let context = makeKimiFetchContext(sourceMode: .api) + + await #expect(throws: KimiAPIError.missingAPIKey) { + try await strategy.fetch(context) + } + } + + @Test + func `auto mode falls back from API response decoding failure`() { + let strategy = KimiAPIFetchStrategy() + let context = makeKimiFetchContext(sourceMode: .auto) + let error = DecodingError.dataCorrupted( + DecodingError.Context(codingPath: [], debugDescription: "Unexpected Kimi payload")) + + #expect(strategy.shouldFallback(on: error, context: context)) + } + + @Test + func `explicit API mode surfaces response decoding failure`() { + let strategy = KimiAPIFetchStrategy() + let context = makeKimiFetchContext(sourceMode: .api) + let error = DecodingError.dataCorrupted( + DecodingError.Context(codingPath: [], debugDescription: "Unexpected Kimi payload")) + + #expect(strategy.shouldFallback(on: error, context: context) == false) + } + + @Test + func `auto mode does not start web fallback after cancellation`() { + let strategy = KimiAPIFetchStrategy() + let context = makeKimiFetchContext(sourceMode: .auto) + + #expect(strategy.shouldFallback(on: CancellationError(), context: context) == false) + #expect(strategy.shouldFallback(on: URLError(.cancelled), context: context) == false) + } +} + struct KimiUsageResponseParsingTests { @Test func `parses valid response`() throws { @@ -135,6 +515,416 @@ struct KimiUsageResponseParsingTests { #expect(response.usages.first?.limits == nil) } + @Test + func `parses code API usage response`() throws { + let json = """ + { + "usage": { + "limit": "2048", + "used": "375", + "remaining": "1673", + "resetTime": "2026-01-09T15:23:13.373329235Z" + }, + "limits": [ + { + "window": { + "duration": 300, + "timeUnit": "TIME_UNIT_MINUTE" + }, + "detail": { + "limit": "200", + "used": "19", + "remaining": "181", + "resetTime": "2026-01-06T15:05:24.374187075Z" + } + } + ] + } + """ + + let snapshot = try KimiUsageFetcher._parseCodeAPIUsageForTesting(Data(json.utf8)) + #expect(snapshot.weekly.limit == "2048") + #expect(snapshot.weekly.used == "375") + #expect(snapshot.rateLimit?.limit == "200") + #expect(snapshot.rateLimit?.used == "19") + + let usage = snapshot.toUsageSnapshot() + #expect(abs((usage.primary?.usedPercent ?? -1) - 18.3105) < 0.001) + #expect(usage.primary?.resetDescription == "375/2048 requests") + #expect(abs((usage.secondary?.usedPercent ?? -1) - 9.5) < 0.001) + #expect(usage.secondary?.windowMinutes == 300) + #expect(usage.secondary?.resetDescription == "Rate: 19/200 per 5 hours") + } + + @Test + func `sends CLI identity headers on the existing usage request`() async throws { + let baseURL = try #require(URL(string: "https://api.kimi.com")) + let identityHeaders = [ + "User-Agent": "CodexBar/test", + "X-Msh-Platform": "kimi_code_cli", + "X-Msh-Device-Id": "test-device-id", + ] + let transport = ProviderHTTPTransportHandler { request in + #expect(request.url?.path == "/coding/v1/usages") + #expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer oauth-token") + for (name, value) in identityHeaders { + #expect(request.value(forHTTPHeaderField: name) == value) + } + let response = try #require(HTTPURLResponse( + url: request.url ?? baseURL, + statusCode: 200, + httpVersion: nil, + headerFields: ["Content-Type": "application/json"])) + let data = Data(""" + { + "usage": {"limit": "100", "used": "25", "remaining": "75"}, + "limits": [] + } + """.utf8) + return (data, response) + } + + let snapshot = try await KimiUsageFetcher.fetchCodeAPIUsage( + apiKey: "oauth-token", + baseURL: baseURL, + identityHeaders: identityHeaders, + transport: transport) + + #expect(snapshot.toUsageSnapshot().primary?.usedPercent == 25) + } + + @Test + func `converts weekly-only usage into primary quota lane`() { + let snapshot = KimiUsageSnapshot( + weekly: KimiUsageDetail( + limit: "2048", + used: "512", + remaining: "1536", + resetTime: "2026-01-09T15:23:13Z"), + rateLimit: nil, + updatedAt: Date(timeIntervalSince1970: 1_800_000_000)) + + let usage = snapshot.toUsageSnapshot() + + #expect(usage.primary?.usedPercent == 25) + #expect(usage.primary?.resetDescription == "512/2048 requests") + #expect(usage.secondary == nil) + } + + @Test + func `parses official numeric values and reset key variants`() throws { + let json = """ + { + "usage": { + "limit": 1000, + "used": 40, + "remaining": 960, + "resetAt": "2026-01-09T15:23:13Z" + }, + "limits": [ + { + "window": { + "duration": 300, + "timeUnit": "TIME_UNIT_MINUTE" + }, + "detail": { + "limit": 100, + "remaining": 99, + "reset_at": "2026-01-06T13:33:02Z" + } + } + ] + } + """ + + let snapshot = try KimiUsageFetcher._parseCodeAPIUsageForTesting(Data(json.utf8)) + + #expect(snapshot.weekly.limit == "1000") + #expect(snapshot.weekly.used == "40") + #expect(snapshot.weekly.remaining == "960") + #expect(snapshot.weekly.resetTime == "2026-01-09T15:23:13Z") + #expect(snapshot.rateLimit?.limit == "100") + #expect(snapshot.rateLimit?.used == nil) + #expect(snapshot.rateLimit?.remaining == "99") + #expect(snapshot.rateLimit?.resetTime == "2026-01-06T13:33:02Z") + #expect(snapshot.toUsageSnapshot().primary?.windowMinutes == KimiProviderDescriptor.weeklyWindowMinutes) + #expect(snapshot.toUsageSnapshot().secondary?.windowMinutes == 300) + #expect(snapshot.toUsageSnapshot().secondary?.resetDescription == "Rate: 1/100 per 5 hours") + } + + @Test + func `derives rate window duration from API units`() throws { + let json = """ + { + "usage": {"limit": 1000, "used": 40, "remaining": 960}, + "limits": [ + { + "window": {"duration": 2, "timeUnit": "TIME_UNIT_HOUR"}, + "detail": {"limit": 100, "used": 25, "remaining": 75} + } + ] + } + """ + + let usage = try KimiUsageFetcher._parseCodeAPIUsageForTesting(Data(json.utf8)).toUsageSnapshot() + + #expect(usage.secondary?.windowMinutes == 120) + #expect(usage.secondary?.resetDescription == "Rate: 25/100 per 2 hours") + } + + @Test + func `converts supported window units and rejects invalid durations`() { + #expect(KimiWindow(duration: 300, timeUnit: "TIME_UNIT_MINUTE").durationMinutes == 300) + #expect(KimiWindow(duration: 5, timeUnit: "TIME_UNIT_HOUR").durationMinutes == 300) + #expect(KimiWindow(duration: 7, timeUnit: "TIME_UNIT_DAY").durationMinutes == 10080) + #expect(KimiWindow(duration: 0, timeUnit: "TIME_UNIT_HOUR").durationMinutes == nil) + #expect(KimiWindow(duration: -1, timeUnit: "TIME_UNIT_DAY").durationMinutes == nil) + #expect(KimiWindow(duration: Int.max, timeUnit: "TIME_UNIT_HOUR").durationMinutes == nil) + #expect(KimiWindow(duration: 5, timeUnit: "TIME_UNIT_UNKNOWN").durationMinutes == nil) + } + + @Test + func `unknown rate window unit does not fabricate a duration`() throws { + let json = """ + { + "usage": {"limit": 1000, "used": 40, "remaining": 960}, + "limits": [ + { + "window": {"duration": 5, "timeUnit": "TIME_UNIT_UNKNOWN"}, + "detail": {"limit": 100, "used": 25, "remaining": 75} + } + ] + } + """ + + let usage = try KimiUsageFetcher._parseCodeAPIUsageForTesting(Data(json.utf8)).toUsageSnapshot() + + #expect(usage.secondary?.windowMinutes == nil) + #expect(usage.secondary?.resetDescription == "Rate: 25/100") + } + + @Test + func `parses subscription stat response`() throws { + let json = """ + { + "ratelimitCode5h": { + "ratio": 0.4689, + "enabled": true, + "resetTime": "2026-07-02T11:56:36.876796734Z" + }, + "ratelimitCode7d": { + "ratio": 0.0946, + "enabled": true, + "resetTime": "2026-07-09T06:56:36.876796734Z" + }, + "subscriptionBalance": { + "id": "19eee1de-9092-8315-8000-0000e4e34d79", + "feature": "FEATURE_OMNI", + "type": "SUBSCRIPTION", + "unit": "UNIT_CREDIT", + "amountUsedRatio": 1, + "kimiCodeUsedRatio": 0.2854, + "expireTime": "2026-07-23T00:00:00Z" + }, + "giftBalances": [ + { + "id": "19efdb95-e082-804c-9ecd-978b7ab37d36", + "feature": "FEATURE_OMNI", + "type": "GIFT", + "unit": "UNIT_CREDIT", + "amountUsedRatio": 1, + "kimiCodeUsedRatio": 1, + "expireTime": "2026-07-31T15:59:59Z" + } + ] + } + """ + + let response = try JSONDecoder().decode(KimiSubscriptionStatsResponse.self, from: Data(json.utf8)) + + #expect(response.subscriptionBalance?.feature == "FEATURE_OMNI") + #expect(response.subscriptionBalance?.type == "SUBSCRIPTION") + #expect(response.subscriptionBalance?.amountUsedRatio == 1) + #expect(response.subscriptionBalance?.expireTime == "2026-07-23T00:00:00Z") + #expect(response.ratelimitCode7d?.ratio == 0.0946) + #expect(response.ratelimitCode7d?.enabled == true) + #expect(response.ratelimitCode7d?.resetTime == "2026-07-09T06:56:36.876796734Z") + } + + @Test + func `subscription grace is a total budget for existing usage windows`() async throws { + let usageJSON = """ + { + "usages": [ + { + "scope": "FEATURE_CODING", + "detail": { "limit": "100", "used": "25", "remaining": "75" }, + "limits": [ + { + "window": { "duration": 300, "timeUnit": "TIME_UNIT_MINUTE" }, + "detail": { "limit": "20", "used": "5", "remaining": "15" } + } + ] + } + ] + } + """ + let transport = ProviderHTTPTransportHandler { request in + let url = try #require(request.url) + let response = try #require(HTTPURLResponse( + url: url, + statusCode: 200, + httpVersion: nil, + headerFields: nil)) + if url.path.hasSuffix("/GetUsages") { + return await withCheckedContinuation { continuation in + DispatchQueue.global().asyncAfter(deadline: .now() + 0.1) { + continuation.resume(returning: (Data(usageJSON.utf8), response)) + } + } + } + + return await withCheckedContinuation { continuation in + DispatchQueue.global().asyncAfter(deadline: .now() + 0.5) { + continuation.resume(returning: (Data("{}".utf8), response)) + } + } + } + + let startedAt = ContinuousClock.now + let snapshot = try await KimiUsageFetcher._fetchUsageForTesting( + authToken: "test-token", + transport: transport, + subscriptionGrace: .milliseconds(20)) + let elapsed = startedAt.duration(to: .now) + let usage = snapshot.toUsageSnapshot() + + #expect(usage.primary?.usedPercent == 25) + #expect(usage.primary?.windowMinutes == KimiProviderDescriptor.weeklyWindowMinutes) + #expect(usage.secondary?.usedPercent == 25) + #expect(usage.extraRateWindows == nil) + #expect(elapsed < .milliseconds(250), "Subscription enrichment outlived its total budget: \(elapsed)") + + // Drain the deliberately cancellation-ignoring test request before the test exits. + try await Task.sleep(for: .milliseconds(550)) + } + + @Test + func `subscription stat enriches usage when it finishes within the total budget`() async throws { + let usageJSON = """ + { + "usages": [ + { + "scope": "FEATURE_CODING", + "detail": { "limit": "100", "used": "25", "remaining": "75" }, + "limits": [] + } + ] + } + """ + let subscriptionJSON = """ + { + "subscriptionBalance": { + "feature": "FEATURE_OMNI", + "type": "SUBSCRIPTION", + "amountUsedRatio": 0.42, + "expireTime": "2026-07-23T00:00:00Z" + }, + "ratelimitCode7d": { + "ratio": 0.17, + "enabled": true, + "resetTime": "2026-07-13T15:28:00Z" + } + } + """ + let transport = ProviderHTTPTransportHandler { request in + let url = try #require(request.url) + let response = try #require(HTTPURLResponse( + url: url, + statusCode: 200, + httpVersion: nil, + headerFields: nil)) + if url.path.hasSuffix("/GetUsages") { + return (Data(usageJSON.utf8), response) + } + #expect(url.path.hasSuffix("/GetSubscriptionStats")) + return (Data(subscriptionJSON.utf8), response) + } + + let snapshot = try await KimiUsageFetcher._fetchUsageForTesting( + authToken: "test-token", + transport: transport, + subscriptionGrace: .seconds(1)) + let windows = try #require(snapshot.toUsageSnapshot().extraRateWindows) + let monthly = try #require(windows.first { $0.id == "kimi-monthly" }) + let weeklyCode = try #require(windows.first { $0.id == "kimi-code-7d" }) + + #expect(monthly.id == "kimi-monthly") + #expect(monthly.window.usedPercent == 42) + #expect(weeklyCode.title == "Code 7-day") + #expect(weeklyCode.window.usedPercent == 17) + #expect(weeklyCode.window.windowMinutes == 7 * 24 * 60) + } + + @Test + func `builds default code API usage endpoint`() throws { + let baseURL = try #require(URL(string: "https://api.kimi.com")) + let endpoint = KimiUsageFetcher._codeAPIUsageEndpointForTesting(baseURL: baseURL) + + #expect(endpoint.absoluteString == "https://api.kimi.com/coding/v1/usages") + } + + @Test + func `appends code API path to custom proxy root`() throws { + let baseURL = try #require(URL(string: "https://proxy.example.com/kimi")) + let endpoint = KimiUsageFetcher._codeAPIUsageEndpointForTesting(baseURL: baseURL) + + #expect(endpoint.absoluteString == "https://proxy.example.com/kimi/coding/v1/usages") + } + + @Test + func `does not duplicate code API path when base URL already includes it`() throws { + let baseURL = try #require(URL(string: "https://api.kimi.com/coding/v1")) + let endpoint = KimiUsageFetcher._codeAPIUsageEndpointForTesting(baseURL: baseURL) + + #expect(endpoint.absoluteString == "https://api.kimi.com/coding/v1/usages") + } + + @Test + func `does not duplicate code API path with trailing slash`() throws { + let baseURL = try #require(URL(string: "https://proxy.example.com/kimi/coding/v1/")) + let endpoint = KimiUsageFetcher._codeAPIUsageEndpointForTesting(baseURL: baseURL) + + #expect(endpoint.absoluteString == "https://proxy.example.com/kimi/coding/v1/usages") + } + + @Test + func `does not duplicate coding path prefix`() throws { + let baseURL = try #require(URL(string: "https://proxy.example.com/kimi/coding/")) + let endpoint = KimiUsageFetcher._codeAPIUsageEndpointForTesting(baseURL: baseURL) + + #expect(endpoint.absoluteString == "https://proxy.example.com/kimi/coding/v1/usages") + } + + @Test + func `rejects insecure code API base URL before sending bearer token`() async throws { + let baseURL = try #require(URL(string: "http://proxy.example.com/kimi")) + + await #expect(throws: KimiAPIError.invalidRequest( + "Kimi Code API base URL must use HTTPS without user info")) + { + _ = try await KimiUsageFetcher.fetchCodeAPIUsage(apiKey: "secret-token", baseURL: baseURL) + } + } + + @Test + func `maps code API authentication and permission errors separately`() { + #expect(KimiUsageFetcher._codeAPIErrorForTesting(statusCode: 401) == .invalidAPIKey) + #expect( + KimiUsageFetcher._codeAPIErrorForTesting(statusCode: 403) + == .apiError("HTTP 403 (permission or quota denied)")) + } + @Test func `throws on invalid json`() { let invalidJson = "{ invalid json }" @@ -170,7 +960,7 @@ struct KimiUsageResponseParsingTests { struct KimiUsageSnapshotConversionTests { @Test - func `converts to usage snapshot with both windows`() { + func `converts to usage snapshot with both windows`() throws { let now = Date() let weeklyDetail = KimiUsageDetail( limit: "2048", @@ -190,22 +980,139 @@ struct KimiUsageSnapshotConversionTests { let usageSnapshot = snapshot.toUsageSnapshot() - #expect(usageSnapshot.primary != nil) + let primary = try #require(usageSnapshot.primary) let weeklyExpected = 375.0 / 2048.0 * 100.0 - #expect(abs((usageSnapshot.primary?.usedPercent ?? 0.0) - weeklyExpected) < 0.01) - #expect(usageSnapshot.primary?.resetDescription == "375/2048 requests") - #expect(usageSnapshot.primary?.windowMinutes == nil) + #expect(abs(primary.usedPercent - weeklyExpected) < 0.01) + #expect(primary.resetDescription == "375/2048 requests") + #expect(primary.windowMinutes == KimiProviderDescriptor.weeklyWindowMinutes) + #expect(KimiProviderDescriptor.descriptor.pace.supportsResetWindowPace(window: primary, now: now)) - #expect(usageSnapshot.secondary != nil) + let secondary = try #require(usageSnapshot.secondary) let rateExpected = 200.0 / 200.0 * 100.0 - #expect(abs((usageSnapshot.secondary?.usedPercent ?? 0.0) - rateExpected) < 0.01) - #expect(usageSnapshot.secondary?.windowMinutes == 300) // 5 hours - #expect(usageSnapshot.secondary?.resetDescription == "Rate: 200/200 per 5 hours") + #expect(abs(secondary.usedPercent - rateExpected) < 0.01) + #expect(secondary.windowMinutes == KimiProviderDescriptor.sessionWindowMinutes) + #expect(secondary.resetDescription == "Rate: 200/200 per 5 hours") + #expect(!KimiProviderDescriptor.descriptor.pace.supportsResetWindowPace(window: secondary, now: now)) #expect(usageSnapshot.tertiary == nil) #expect(usageSnapshot.updatedAt == now) } + @Test + func `converts subscription balance to monthly extra window`() throws { + let now = Date() + let weeklyDetail = KimiUsageDetail( + limit: "2048", + used: "375", + remaining: "1673", + resetTime: "2026-01-09T15:23:13.373329235Z") + let subscriptionBalance = KimiSubscriptionBalance( + feature: "FEATURE_OMNI", + type: "SUBSCRIPTION", + amountUsedRatio: 1, + expireTime: "2026-07-23T00:00:00Z") + + let snapshot = KimiUsageSnapshot( + weekly: weeklyDetail, + rateLimit: nil, + subscriptionBalance: subscriptionBalance, + updatedAt: now) + let usageSnapshot = snapshot.toUsageSnapshot() + + let monthly = try #require(usageSnapshot.extraRateWindows?.first) + #expect(monthly.id == "kimi-monthly") + #expect(monthly.title == "Monthly") + #expect(monthly.window.usedPercent == 100) + #expect(monthly.window.windowMinutes == nil) + #expect(monthly.window.resetsAt == Self.date("2026-07-23T00:00:00Z")) + } + + @Test + func `reflects partial subscription usage in monthly window`() throws { + let now = Date() + let weeklyDetail = KimiUsageDetail( + limit: "2048", + used: "375", + remaining: "1673", + resetTime: "2026-01-09T15:23:13.373329235Z") + // A live, partially-used balance (not the fully-exhausted 1.0 fixture): amountUsedRatio is a + // real consumption ratio, so the Monthly window must track it rather than pin to 100%. + let subscriptionBalance = KimiSubscriptionBalance( + feature: "FEATURE_OMNI", + type: "SUBSCRIPTION", + amountUsedRatio: 0.7716, + expireTime: "2026-07-23T00:00:00Z") + + let snapshot = KimiUsageSnapshot( + weekly: weeklyDetail, + rateLimit: nil, + subscriptionBalance: subscriptionBalance, + updatedAt: now) + let usageSnapshot = snapshot.toUsageSnapshot() + + let monthly = try #require(usageSnapshot.extraRateWindows?.first) + #expect(monthly.id == "kimi-monthly") + #expect(abs(monthly.window.usedPercent - 77.16) < 0.0001) + } + + @Test + func `converts subscription code weekly limit to extra window`() throws { + let now = Date() + let weeklyDetail = KimiUsageDetail( + limit: "2048", + used: "375", + remaining: "1673", + resetTime: "2026-01-09T15:23:13.373329235Z") + let subscriptionCodeWeeklyLimit = KimiSubscriptionRateLimit( + ratio: 0.0946, + enabled: true, + resetTime: "2026-07-13T15:28:00Z") + + let snapshot = KimiUsageSnapshot( + weekly: weeklyDetail, + rateLimit: nil, + subscriptionBalance: nil, + subscriptionCodeWeeklyLimit: subscriptionCodeWeeklyLimit, + updatedAt: now) + let usageSnapshot = snapshot.toUsageSnapshot() + + let weeklyCode = try #require(usageSnapshot.extraRateWindows?.first) + #expect(weeklyCode.id == "kimi-code-7d") + #expect(weeklyCode.title == "Code 7-day") + #expect(abs(weeklyCode.window.usedPercent - 9.46) < 0.0001) + #expect(weeklyCode.window.windowMinutes == 7 * 24 * 60) + #expect(weeklyCode.window.resetsAt == Self.date("2026-07-13T15:28:00Z")) + } + + @Test + func `omits disabled and nonfinite subscription quota ratios`() { + let weeklyDetail = KimiUsageDetail( + limit: "2048", + used: "375", + remaining: "1673", + resetTime: "2026-01-09T15:23:13.373329235Z") + let invalidLimits = [ + KimiSubscriptionRateLimit(ratio: 0.25, enabled: false, resetTime: nil), + KimiSubscriptionRateLimit(ratio: .nan, enabled: true, resetTime: nil), + KimiSubscriptionRateLimit(ratio: .infinity, enabled: true, resetTime: nil), + ] + + for limit in invalidLimits { + let snapshot = KimiUsageSnapshot( + weekly: weeklyDetail, + rateLimit: nil, + subscriptionBalance: KimiSubscriptionBalance( + feature: "FEATURE_OMNI", + type: "SUBSCRIPTION", + amountUsedRatio: .nan, + expireTime: nil), + subscriptionCodeWeeklyLimit: limit, + updatedAt: Date()) + + #expect(snapshot.toUsageSnapshot().extraRateWindows == nil) + } + } + @Test func `converts to usage snapshot without rate limit`() { let now = Date() @@ -229,6 +1136,109 @@ struct KimiUsageSnapshotConversionTests { #expect(usageSnapshot.tertiary == nil) } + @Test + func `converts invalid rate limit as unavailable`() { + let now = Date() + let weeklyDetail = KimiUsageDetail( + limit: "2048", + used: "375", + remaining: "1673", + resetTime: "2026-01-09T15:23:13.373329235Z") + let invalidRateLimit = KimiUsageDetail( + limit: "0", + used: "0", + remaining: "0", + resetTime: "2026-01-06T15:05:24.374187075Z") + + let snapshot = KimiUsageSnapshot( + weekly: weeklyDetail, + rateLimit: invalidRateLimit, + updatedAt: now) + + let usageSnapshot = snapshot.toUsageSnapshot() + + #expect(usageSnapshot.primary?.resetDescription == "375/2048 requests") + #expect(usageSnapshot.secondary == nil) + } + + @Test + func `malformed limits do not fabricate pace metadata`() { + let now = Date() + let weekly = KimiUsageDetail(limit: "100", used: "25", remaining: "75", resetTime: nil) + let invalid = KimiUsageDetail(limit: "invalid", used: "5", remaining: "15", resetTime: nil) + let zero = KimiUsageDetail(limit: "0", used: "0", remaining: "0", resetTime: nil) + + #expect(KimiUsageSnapshot(weekly: invalid, rateLimit: nil, updatedAt: now).toUsageSnapshot().primary == nil) + #expect(KimiUsageSnapshot(weekly: zero, rateLimit: nil, updatedAt: now).toUsageSnapshot().primary == nil) + #expect(KimiUsageSnapshot(weekly: weekly, rateLimit: invalid, updatedAt: now) + .toUsageSnapshot().secondary == nil) + } + + @Test + func `derives invalid or missing used counts from valid remaining counts`() throws { + let now = Date() + let snapshot = KimiUsageSnapshot( + weekly: KimiUsageDetail(limit: "100", used: "invalid", remaining: "75", resetTime: nil), + rateLimit: KimiUsageDetail(limit: "20", used: nil, remaining: "15", resetTime: nil), + updatedAt: now) + + let usage = snapshot.toUsageSnapshot() + #expect(try #require(usage.primary).usedPercent == 25) + #expect(try #require(usage.secondary).usedPercent == 25) + } + + @Test + func `over quota used count wins over contradictory remaining`() throws { + let now = Date() + let snapshot = KimiUsageSnapshot( + weekly: KimiUsageDetail(limit: "100", used: "125", remaining: "25", resetTime: nil), + rateLimit: nil, + updatedAt: now) + + let primary = try #require(snapshot.toUsageSnapshot().primary) + #expect(primary.usedPercent == 100) + #expect(primary.resetDescription == "125/100 requests") + #expect(primary.windowMinutes == KimiProviderDescriptor.weeklyWindowMinutes) + } + + @Test + func `negative used count falls back to valid remaining`() throws { + let now = Date() + let snapshot = KimiUsageSnapshot( + weekly: KimiUsageDetail(limit: "100", used: "-1", remaining: "75", resetTime: nil), + rateLimit: nil, + updatedAt: now) + + #expect(try #require(snapshot.toUsageSnapshot().primary).usedPercent == 25) + } + + @Test + func `invalid remaining does not synthesize a quota`() { + let now = Date() + for remaining in ["-1", "101", "invalid"] { + let snapshot = KimiUsageSnapshot( + weekly: KimiUsageDetail(limit: "100", used: nil, remaining: remaining, resetTime: nil), + rateLimit: nil, + updatedAt: now) + + #expect(snapshot.toUsageSnapshot().primary?.usedPercent == 0) + #expect(snapshot.toUsageSnapshot().primary?.windowMinutes == nil) + } + } + + @Test + func `missing counters preserve gauge without pace metadata`() throws { + let snapshot = KimiUsageSnapshot( + weekly: KimiUsageDetail(limit: "100", used: nil, remaining: nil, resetTime: nil), + rateLimit: nil, + updatedAt: Date()) + + let primary = try #require(snapshot.toUsageSnapshot().primary) + #expect(primary.usedPercent == 0) + #expect(primary.windowMinutes == nil) + #expect(primary.resetDescription == "0/100 requests") + } + @Test func `handles zero values correctly`() { let now = Date() @@ -245,6 +1255,7 @@ struct KimiUsageSnapshotConversionTests { let usageSnapshot = snapshot.toUsageSnapshot() #expect(usageSnapshot.primary?.usedPercent == 0.0) + #expect(usageSnapshot.secondary == nil) } @Test @@ -263,6 +1274,13 @@ struct KimiUsageSnapshotConversionTests { let usageSnapshot = snapshot.toUsageSnapshot() #expect(usageSnapshot.primary?.usedPercent == 100.0) + #expect(usageSnapshot.secondary == nil) + } + + private static func date(_ text: String) -> Date? { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime] + return formatter.date(from: text) } } @@ -303,6 +1321,11 @@ struct KimiAPIErrorTests { func `error descriptions are helpful`() { #expect(KimiAPIError.missingToken.errorDescription?.contains("missing") == true) #expect(KimiAPIError.invalidToken.errorDescription?.contains("invalid") == true) + #expect(KimiAPIError.missingAPIKey.errorDescription?.contains("Settings > Providers > Kimi") == true) + #expect(KimiAPIError.missingAPIKey.errorDescription?.contains("KIMI_CODE_API_KEY") == true) + #expect(KimiAPIError.expiredCodeCredential.errorDescription?.contains("does not refresh") == true) + #expect(KimiAPIError.invalidCodeCredential.errorDescription?.contains("Sign in again") == true) + #expect(KimiAPIError.invalidAPIKey.errorDescription?.contains("API key") == true) #expect(KimiAPIError.invalidRequest("Bad request").errorDescription?.contains("Bad request") == true) #expect(KimiAPIError.networkError("Timeout").errorDescription?.contains("Timeout") == true) #expect(KimiAPIError.apiError("HTTP 500").errorDescription?.contains("HTTP 500") == true) diff --git a/Tests/CodexBarTests/KiroStatusProbeTestSupport.swift b/Tests/CodexBarTests/KiroStatusProbeTestSupport.swift new file mode 100644 index 0000000000..7fcf8150b7 --- /dev/null +++ b/Tests/CodexBarTests/KiroStatusProbeTestSupport.swift @@ -0,0 +1,83 @@ +import Foundation +@testable import CodexBarCore +#if canImport(Darwin) +import Darwin +#else +import Glibc +#endif + +final class KiroTestProcessRegistry: @unchecked Sendable { + private struct Record { + var processGroup: pid_t? + } + + private let lock = NSLock() + private let blockOnUnregister: Int? + private let blockStartedURL: URL? + private let unblock: DispatchSemaphore? + private var records: [pid_t: Record] = [:] + private var unregisteredPIDs: Set = [] + private var unregisterCount = 0 + + init( + blockOnUnregister: Int? = nil, + blockStartedURL: URL? = nil, + unblock: DispatchSemaphore? = nil) + { + self.blockOnUnregister = blockOnUnregister + self.blockStartedURL = blockStartedURL + self.unblock = unblock + } + + var dependencies: KiroStatusProbe.PipeProcessRegistry { + .init( + beginLaunch: { true }, + endLaunch: {}, + register: { pid, _ in + self.lock.withLock { + self.records[pid] = Record(processGroup: nil) + } + return true + }, + updateProcessGroup: { pid, processGroup in + self.lock.withLock { + guard self.records[pid] != nil else { return } + self.records[pid]?.processGroup = processGroup + } + }, + unregister: { pid in + let shouldBlock = self.lock.withLock { + self.records.removeValue(forKey: pid) + self.unregisteredPIDs.insert(pid) + self.unregisterCount += 1 + return self.unregisterCount == self.blockOnUnregister + } + if shouldBlock { + if let blockStartedURL = self.blockStartedURL { + _ = FileManager.default.createFile(atPath: blockStartedURL.path, contents: Data()) + } + self.unblock?.wait() + } + }) + } + + func isRegistered(_ pid: pid_t) -> Bool { + self.lock.withLock { self.records[pid] != nil } + } + + func didUnregister(_ pid: pid_t) -> Bool { + self.lock.withLock { self.unregisteredPIDs.contains(pid) } + } + + func terminate(_ pid: pid_t) { + let processGroup = self.lock.withLock { () -> pid_t? in + self.records[pid]?.processGroup + } + if let processGroup, processGroup > 0, processGroup != getpgrp() { + _ = kill(-processGroup, SIGKILL) + } + if pid > 0 { + _ = kill(pid, SIGKILL) + } + } +} diff --git a/Tests/CodexBarTests/KiroStatusProbeTests.swift b/Tests/CodexBarTests/KiroStatusProbeTests.swift index f56ab0b399..b17a2d9192 100644 --- a/Tests/CodexBarTests/KiroStatusProbeTests.swift +++ b/Tests/CodexBarTests/KiroStatusProbeTests.swift @@ -1,8 +1,1166 @@ import Foundation import Testing @testable import CodexBarCore +#if canImport(Darwin) +import Darwin +#else +import Glibc +#endif + +private func waitForFile(_ url: URL) async throws { + for _ in 0..<100 where !FileManager.default.fileExists(atPath: url.path) { + try await Task.sleep(for: .milliseconds(20)) + } + #expect(FileManager.default.fileExists(atPath: url.path)) +} +@Suite(.serialized) struct KiroStatusProbeTests { + @Test + func `fetch returns usage when account probe times out`() async throws { + let cliURL = try self.makeCLI( + """ + #!/bin/sh + if [ "$1" = "whoami" ]; then + sleep 5 + printf 'Logged in with Google\\nEmail: person@example.com\\n' + exit 0 + fi + + if [ "$1" = "chat" ] && [ "$3" = "/usage" ]; then + printf 'Estimated Usage | resets on 2026-06-01 | KIRO FREE\\n' + printf 'Credits (12.50 of 50 covered in plan)\\n' + printf '████████████████████ 25%%\\n' + exit 0 + fi + + if [ "$1" = "chat" ] && [ "$3" = "/context" ]; then + exit 0 + fi + + exit 1 + """) + defer { try? FileManager.default.removeItem(at: cliURL.deletingLastPathComponent()) } + + let probe = KiroStatusProbe(cliBinaryResolver: { cliURL.path }, accountProbeTimeout: 0.2) + let snapshot = try await probe.fetch() + + #expect(snapshot.planName == "KIRO FREE") + #expect(snapshot.creditsUsed == 12.50) + #expect(snapshot.accountEmail == nil) + #expect(snapshot.authMethod == nil) + } + + @Test + func `pipe and PTY share the account deadline`() async throws { + let cliURL = try self.makeCLI( + """ + #!/bin/sh + if [ "$1" = "whoami" ]; then + if [ ! -t 1 ]; then + sleep 5 + exit 1 + fi + sleep 0.45 + printf 'Logged in with Google\nEmail: person@example.com\n' + exit 0 + fi + if [ "$1" = "chat" ] && [ "$3" = "/usage" ]; then + printf 'Estimated Usage | resets on 2026-06-01 | KIRO FREE\n' + printf 'Credits (12.50 of 50 covered in plan)\n' + printf '████████████████████ 25%%\n' + exit 0 + fi + if [ "$1" = "chat" ] && [ "$3" = "/context" ]; then + exit 0 + fi + exit 1 + """) + defer { try? FileManager.default.removeItem(at: cliURL.deletingLastPathComponent()) } + + let snapshot = try await KiroStatusProbe( + cliBinaryResolver: { cliURL.path }, + accountProbeTimeout: 0.8, + pipeTimeoutCap: 0.4).fetch() + + #expect(snapshot.planName == "KIRO FREE") + #expect(snapshot.accountEmail == nil) + #expect(snapshot.authMethod == nil) + } + + @Test + func `accepted pipe output cannot overrun the usage deadline`() async throws { + let pipePIDFile = FileManager.default.temporaryDirectory + .appendingPathComponent("codexbar-kiro-deadline-\(UUID().uuidString).pid") + let ptyMarker = FileManager.default.temporaryDirectory + .appendingPathComponent("codexbar-kiro-deadline-\(UUID().uuidString).pty") + let cliURL = try self.makeCLI( + """ + #!/bin/sh + if [ "$1" = "whoami" ]; then + printf 'Logged in with Google\nEmail: person@example.com\n' + exit 0 + fi + if [ "$1" = "chat" ] && [ "$3" = "/usage" ]; then + if [ ! -t 1 ]; then + printf '%s\n' "$$" > '\(pipePIDFile.path)' + printf 'Estimated Usage | resets on 2026-06-01 | KIRO FREE\n' + printf 'Credits (12.50 of 50 covered in plan)\n' + printf '████████████████████ 25%%\n' + trap '' TERM + while true; do sleep 1; done + fi + : > '\(ptyMarker.path)' + printf 'Estimated Usage | resets on 2026-06-01 | KIRO FREE\n' + printf 'Credits (12.50 of 50 covered in plan)\n' + printf '████████████████████ 25%%\n' + exit 0 + fi + if [ "$1" = "chat" ] && [ "$3" = "/context" ]; then + exit 0 + fi + exit 1 + """) + defer { + if let text = try? String(contentsOf: pipePIDFile, encoding: .utf8), + let pipePID = pid_t(text.trimmingCharacters(in: .whitespacesAndNewlines)) + { + _ = kill(pipePID, SIGKILL) + } + try? FileManager.default.removeItem(at: pipePIDFile) + try? FileManager.default.removeItem(at: ptyMarker) + try? FileManager.default.removeItem(at: cliURL.deletingLastPathComponent()) + } + + let clock = ContinuousClock() + let startedAt = clock.now + let probe = KiroStatusProbe( + cliBinaryResolver: { cliURL.path }, + usageProbeTimeout: 4, + pipeTimeoutCap: 2) + + await #expect { + _ = try await probe.fetch() + } throws: { error in + guard case KiroStatusProbeError.timeout = error else { return false } + return true + } + + #expect(startedAt.duration(to: clock.now) < .seconds(7)) + #expect(!FileManager.default.fileExists(atPath: ptyMarker.path)) + let pipePIDText = try String(contentsOf: pipePIDFile, encoding: .utf8) + let pipePID = try #require(pid_t(pipePIDText.trimmingCharacters(in: .whitespacesAndNewlines))) + #expect(kill(pipePID, 0) == -1) + } + + @Test + func `fetch preserves account info when account probe succeeds`() async throws { + let cliURL = try self.makeCLI( + """ + #!/bin/sh + if [ "$1" = "whoami" ]; then + printf 'Logged in with Google\\nEmail: person@example.com\\n' + exit 0 + fi + + if [ "$1" = "chat" ] && [ "$3" = "/usage" ]; then + printf 'Estimated Usage | resets on 2026-06-01 | KIRO FREE\\n' + printf 'Credits (12.50 of 50 covered in plan)\\n' + printf '████████████████████ 25%%\\n' + exit 0 + fi + + if [ "$1" = "chat" ] && [ "$3" = "/context" ]; then + exit 0 + fi + + exit 1 + """) + defer { try? FileManager.default.removeItem(at: cliURL.deletingLastPathComponent()) } + + let probe = KiroStatusProbe(cliBinaryResolver: { cliURL.path }) + let snapshot = try await probe.fetch() + + #expect(snapshot.accountEmail == "person@example.com") + #expect(snapshot.authMethod == "Google") + } +} + +extension KiroStatusProbeTests { + @Test + func `fetch supports kiro cli that only completes through pipes`() async throws { + let cliURL = try self.makeCLI( + """ + #!/bin/sh + if [ -t 1 ]; then + sleep 30 + exit 1 + fi + + if [ "$1" = "whoami" ]; then + printf 'Logged in with Google\nEmail: person@example.com\n' + exit 0 + fi + + if [ "$1" = "chat" ] && [ "$3" = "/usage" ]; then + printf 'Estimated Usage | resets on 2026-06-01 | KIRO FREE\n' + printf 'Credits (12.50 of 50 covered in plan)\n' + printf '████████████████████ 25%%\n' + exit 0 + fi + + if [ "$1" = "chat" ] && [ "$3" = "/context" ]; then + exit 0 + fi + + exit 1 + """) + defer { try? FileManager.default.removeItem(at: cliURL.deletingLastPathComponent()) } + + let probe = KiroStatusProbe(cliBinaryResolver: { cliURL.path }) + let snapshot = try await probe.fetch() + + #expect(snapshot.planName == "KIRO FREE") + #expect(snapshot.creditsUsed == 12.50) + #expect(snapshot.accountEmail == "person@example.com") + } + + @Test + func `slow pipe remains viable after PTY fallback starts`() async throws { + let cliURL = try self.makeCLI( + """ + #!/bin/sh + if [ -t 1 ]; then + exit 97 + fi + if [ "$1" = "whoami" ]; then + printf 'Logged in with Google\nEmail: person@example.com\n' + exit 0 + fi + if [ "$1" = "chat" ] && [ "$3" = "/usage" ]; then + sleep 1 + printf 'Estimated Usage | resets on 2026-06-01 | KIRO FREE\n' + printf 'Credits (12.50 of 50 covered in plan)\n' + printf '████████████████████ 25%%\n' + exit 0 + fi + if [ "$1" = "chat" ] && [ "$3" = "/context" ]; then + exit 0 + fi + exit 1 + """) + defer { try? FileManager.default.removeItem(at: cliURL.deletingLastPathComponent()) } + + let snapshot = try await KiroStatusProbe( + cliBinaryResolver: { cliURL.path }, + usageProbeTimeout: 2, + pipeTimeoutCap: 0.2).fetch() + + #expect(snapshot.planName == "KIRO FREE") + } + + @Test + func `fetch falls back to PTY for older kiro cli`() async throws { + let cliURL = try self.makeCLI( + """ + #!/bin/sh + if [ ! -t 1 ]; then + sleep 30 + exit 1 + fi + + if [ "$1" = "whoami" ]; then + printf 'Logged in with Google\nEmail: person@example.com\n' + exit 0 + fi + + if [ "$1" = "chat" ] && [ "$3" = "/usage" ]; then + printf 'Estimated Usage | resets on 2026-06-01 | KIRO FREE\n' + printf 'Credits (12.50 of 50 covered in plan)\n' + printf '████████████████████ 25%%\n' + exit 0 + fi + + if [ "$1" = "chat" ] && [ "$3" = "/context" ]; then + exit 0 + fi + + exit 1 + """) + defer { try? FileManager.default.removeItem(at: cliURL.deletingLastPathComponent()) } + + let probe = KiroStatusProbe( + cliBinaryResolver: { cliURL.path }, + pipeTimeoutCap: 0.2) + let snapshot = try await probe.fetch() + + #expect(snapshot.planName == "KIRO FREE") + #expect(snapshot.creditsUsed == 12.50) + #expect(snapshot.accountEmail == "person@example.com") + } + + @Test + func `fetch falls back to PTY after incomplete pipe output`() async throws { + let cliURL = try self.makeCLI( + """ + #!/bin/sh + if [ ! -t 1 ]; then + if [ "$1" = "whoami" ]; then + printf 'Logged in with Google\nEmail: person@example.com\n' + exit 0 + fi + if [ "$1" = "chat" ] && [ "$3" = "/usage" ]; then + printf 'Plan: loading...\n' + exit 0 + fi + if [ "$1" = "chat" ] && [ "$3" = "/context" ]; then + exit 0 + fi + fi + + if [ "$1" = "whoami" ]; then + printf 'Logged in with Google\nEmail: person@example.com\n' + exit 0 + fi + if [ "$1" = "chat" ] && [ "$3" = "/usage" ]; then + printf 'Estimated Usage | resets on 2026-06-01 | KIRO FREE\n' + printf 'Credits (12.50 of 50 covered in plan)\n' + printf '████████████████████ 25%%\n' + exit 0 + fi + if [ "$1" = "chat" ] && [ "$3" = "/context" ]; then + exit 0 + fi + exit 1 + """) + defer { try? FileManager.default.removeItem(at: cliURL.deletingLastPathComponent()) } + + let snapshot = try await KiroStatusProbe(cliBinaryResolver: { cliURL.path }).fetch() + + #expect(snapshot.planName == "KIRO FREE") + #expect(snapshot.creditsUsed == 12.50) + } + + @Test + func `pipe cleanup finishes before PTY fallback starts`() async throws { + let childPIDFile = FileManager.default.temporaryDirectory + .appendingPathComponent("codexbar-kiro-pipe-child-\(UUID().uuidString).pid") + let ptyMarker = FileManager.default.temporaryDirectory + .appendingPathComponent("codexbar-kiro-pty-fallback-\(UUID().uuidString).started") + let cliURL = try self.makeCLI( + """ + #!/bin/sh + if [ "$1" = "whoami" ]; then + printf 'Logged in with Google\nEmail: person@example.com\n' + exit 0 + fi + if [ "$1" = "chat" ] && [ "$3" = "/usage" ]; then + if [ ! -t 1 ]; then + (trap '' TERM; while true; do sleep 1; done) & + child=$! + printf '%s\n' "$child" > '\(childPIDFile.path)' + printf 'Plan: loading...\n' + exit 0 + fi + if test -s '\(childPIDFile.path)' && kill -0 "$(cat '\(childPIDFile.path)')" 2>/dev/null; then + printf 'pipe child still running\n' >&2 + exit 97 + fi + : > '\(ptyMarker.path)' + printf 'Estimated Usage | resets on 2026-06-01 | KIRO FREE\n' + printf 'Credits (12.50 of 50 covered in plan)\n' + printf '████████████████████ 25%%\n' + exit 0 + fi + if [ "$1" = "chat" ] && [ "$3" = "/context" ]; then + exit 0 + fi + exit 1 + """) + defer { + if let text = try? String(contentsOf: childPIDFile, encoding: .utf8), + let childPID = pid_t(text.trimmingCharacters(in: .whitespacesAndNewlines)) + { + _ = kill(childPID, SIGKILL) + } + try? FileManager.default.removeItem(at: childPIDFile) + try? FileManager.default.removeItem(at: ptyMarker) + try? FileManager.default.removeItem(at: cliURL.deletingLastPathComponent()) + } + + let snapshot = try await KiroStatusProbe(cliBinaryResolver: { cliURL.path }).fetch() + + #expect(snapshot.planName == "KIRO FREE") + #expect(FileManager.default.fileExists(atPath: ptyMarker.path)) + let childPIDText = try String(contentsOf: childPIDFile, encoding: .utf8) + let childPID = try #require(pid_t(childPIDText.trimmingCharacters(in: .whitespacesAndNewlines))) + #expect(kill(childPID, 0) == -1) + } + + @Test + func `shutdown registry terminates an active pipe probe`() async throws { + let pipePIDFile = FileManager.default.temporaryDirectory + .appendingPathComponent("codexbar-kiro-shutdown-\(UUID().uuidString).pid") + let cliURL = try self.makeCLI( + """ + #!/bin/sh + if [ "$1" = "whoami" ]; then + printf 'Logged in with Google\nEmail: person@example.com\n' + exit 0 + fi + if [ "$1" = "chat" ] && [ "$3" = "/usage" ]; then + if [ -t 1 ]; then + exit 97 + fi + printf '%s\n' "$$" > '\(pipePIDFile.path)' + printf 'Plan: loading...\n' + trap '' TERM + while true; do sleep 1; done + fi + if [ "$1" = "chat" ] && [ "$3" = "/context" ]; then + exit 0 + fi + exit 1 + """) + + let registry = KiroTestProcessRegistry() + let task = Task { + try await KiroStatusProbe( + cliBinaryResolver: { cliURL.path }, + pipeProcessRegistry: registry.dependencies).fetch() + } + defer { + task.cancel() + if let text = try? String(contentsOf: pipePIDFile, encoding: .utf8), + let pipePID = pid_t(text.trimmingCharacters(in: .whitespacesAndNewlines)) + { + _ = kill(pipePID, SIGKILL) + } + try? FileManager.default.removeItem(at: pipePIDFile) + try? FileManager.default.removeItem(at: cliURL.deletingLastPathComponent()) + } + + try await waitForFile(pipePIDFile) + let pipePIDText = try String(contentsOf: pipePIDFile, encoding: .utf8) + let pipePID = try #require(pid_t(pipePIDText.trimmingCharacters(in: .whitespacesAndNewlines))) + #expect(registry.isRegistered(pipePID)) + + let clock = ContinuousClock() + let shutdownStartedAt = clock.now + registry.terminate(pipePID) + await #expect(throws: (any Error).self) { + _ = try await task.value + } + + #expect(shutdownStartedAt.duration(to: clock.now) < .seconds(2)) + #expect(!registry.isRegistered(pipePID)) + #expect(registry.didUnregister(pipePID)) + #expect(kill(pipePID, 0) == -1) + } + + @Test + func `fetch combines pipe stdout with stderr warnings`() async throws { + let cliURL = try self.makeCLI( + """ + #!/bin/sh + if [ -t 1 ]; then + exit 91 + fi + if [ "$1" = "whoami" ]; then + printf 'Logged in with Google\nEmail: person@example.com\n' + printf 'warning: cached session\n' >&2 + exit 0 + fi + if [ "$1" = "chat" ] && [ "$3" = "/usage" ]; then + printf 'Estimated Usage | resets on 2026-06-01 | KIRO FREE\n' + printf 'Credits (12.50 of 50 covered in plan)\n' + printf '████████████████████ 25%%\n' + printf 'warning: telemetry unavailable\n' >&2 + exit 0 + fi + if [ "$1" = "chat" ] && [ "$3" = "/context" ]; then + exit 0 + fi + exit 1 + """) + defer { try? FileManager.default.removeItem(at: cliURL.deletingLastPathComponent()) } + + let snapshot = try await KiroStatusProbe(cliBinaryResolver: { cliURL.path }).fetch() + + #expect(snapshot.planName == "KIRO FREE") + #expect(snapshot.accountEmail == "person@example.com") + #expect(snapshot.authMethod == "Google") + } + + @Test + func `fetch falls back to PTY after pipe requires a terminal`() async throws { + let cliURL = try self.makeCLI( + """ + #!/bin/sh + if [ ! -t 1 ]; then + printf 'terminal required\n' >&2 + exit 2 + fi + if [ "$1" = "whoami" ]; then + printf 'Logged in with Google\nEmail: person@example.com\n' + exit 0 + fi + if [ "$1" = "chat" ] && [ "$3" = "/usage" ]; then + printf 'Estimated Usage | resets on 2026-06-01 | KIRO FREE\n' + printf 'Credits (12.50 of 50 covered in plan)\n' + printf '████████████████████ 25%%\n' + exit 0 + fi + if [ "$1" = "chat" ] && [ "$3" = "/context" ]; then + printf 'Context window: 7.5%% used (estimated)\n' + printf '█ Context files 2.5%% (estimated)\n' + exit 0 + fi + exit 1 + """) + defer { try? FileManager.default.removeItem(at: cliURL.deletingLastPathComponent()) } + + let snapshot = try await KiroStatusProbe(cliBinaryResolver: { cliURL.path }).fetch() + + #expect(snapshot.planName == "KIRO FREE") + #expect(snapshot.accountEmail == "person@example.com") + #expect(snapshot.contextUsage?.totalPercentUsed == 7.5) + #expect(snapshot.contextUsage?.contextFilesPercent == 2.5) + } + + @Test + func `pipe auth failure on stderr remains authoritative`() async throws { + let cliURL = try self.makeCLI( + """ + #!/bin/sh + if [ ! -t 1 ]; then + printf 'Opening browser...\n' + printf 'Not logged in\n' >&2 + exit 1 + fi + if [ "$1" = "whoami" ]; then + printf 'Logged in with Google\nEmail: person@example.com\n' + exit 0 + fi + if [ "$1" = "chat" ] && [ "$3" = "/usage" ]; then + printf 'Estimated Usage | resets on 2026-06-01 | KIRO FREE\n' + printf 'Credits (12.50 of 50 covered in plan)\n' + printf '████████████████████ 25%%\n' + exit 0 + fi + if [ "$1" = "chat" ] && [ "$3" = "/context" ]; then + exit 0 + fi + exit 1 + """) + defer { try? FileManager.default.removeItem(at: cliURL.deletingLastPathComponent()) } + + await #expect { + _ = try await KiroStatusProbe(cliBinaryResolver: { cliURL.path }).fetch() + } throws: { error in + guard case KiroStatusProbeError.notLoggedIn = error else { return false } + return true + } + } + + @Test + func `fetch rejects account markers from failed whoami`() async throws { + let cliURL = try self.makeCLI( + """ + #!/bin/sh + if [ "$1" = "whoami" ]; then + printf 'Logged in with Google\nEmail: person@example.com\n' + exit 23 + fi + + if [ "$1" = "chat" ] && [ "$3" = "/usage" ]; then + printf 'Estimated Usage | resets on 2026-06-01 | KIRO FREE\n' + printf 'Credits (12.50 of 50 covered in plan)\n' + printf '████████████████████ 25%%\n' + exit 0 + fi + + if [ "$1" = "chat" ] && [ "$3" = "/context" ]; then + exit 0 + fi + + exit 1 + """) + defer { try? FileManager.default.removeItem(at: cliURL.deletingLastPathComponent()) } + + let snapshot = try await KiroStatusProbe(cliBinaryResolver: { cliURL.path }).fetch() + + #expect(snapshot.accountEmail == nil) + #expect(snapshot.authMethod == nil) + } + + @Test + func `fetch rejects valid-looking usage from failed command`() async throws { + let cliURL = try self.makeCLI( + """ + #!/bin/sh + if [ -t 1 ]; then + if [ "$1" = "whoami" ]; then + printf 'Logged in with Google\nEmail: person@example.com\n' + exit 0 + fi + if [ "$1" = "chat" ] && [ "$3" = "/usage" ]; then + printf 'Estimated Usage | resets on 2026-06-01 | KIRO FREE\n' + printf 'Credits (12.50 of 50 covered in plan)\n' + printf '████████████████████ 25%%\n' + exit 0 + fi + fi + + if [ "$1" = "whoami" ]; then + printf 'Logged in with Google\nEmail: person@example.com\n' + exit 0 + fi + + if [ "$1" = "chat" ] && [ "$3" = "/usage" ]; then + printf 'Estimated Usage | resets on 2026-06-01 | KIRO FREE\n' + printf 'Credits (12.50 of 50 covered in plan)\n' + printf '████████████████████ 25%%\n' + exit 23 + fi + + if [ "$1" = "chat" ] && [ "$3" = "/context" ]; then + exit 0 + fi + + exit 1 + """) + defer { try? FileManager.default.removeItem(at: cliURL.deletingLastPathComponent()) } + + let probe = KiroStatusProbe(cliBinaryResolver: { cliURL.path }) + await #expect { + _ = try await probe.fetch() + } throws: { error in + guard case KiroStatusProbeError.cliFailed = error else { return false } + return true + } + } + + @Test + func `fetch preserves not logged in when usage fails without auth detail`() async throws { + let cliURL = try self.makeCLI( + """ + #!/bin/sh + if [ "$1" = "whoami" ]; then + printf 'Not logged in\\n' + exit 1 + fi + + if [ "$1" = "chat" ] && [ "$3" = "/usage" ]; then + exit 1 + fi + + if [ "$1" = "chat" ] && [ "$3" = "/context" ]; then + exit 0 + fi + + exit 1 + """) + defer { try? FileManager.default.removeItem(at: cliURL.deletingLastPathComponent()) } + + let probe = KiroStatusProbe(cliBinaryResolver: { cliURL.path }) + await #expect { + _ = try await probe.fetch() + } throws: { error in + guard case KiroStatusProbeError.notLoggedIn = error else { return false } + return true + } + } + + @Test + func `fetch preserves not logged in when whoami idles after login marker`() async throws { + let cliURL = try self.makeCLI( + """ + #!/bin/sh + if [ "$1" = "whoami" ]; then + printf 'Not logged in\n' + sleep 5 + exit 1 + fi + + if [ "$1" = "chat" ] && [ "$3" = "/usage" ]; then + exit 1 + fi + + if [ "$1" = "chat" ] && [ "$3" = "/context" ]; then + exit 0 + fi + + exit 1 + """) + defer { try? FileManager.default.removeItem(at: cliURL.deletingLastPathComponent()) } + + let probe = KiroStatusProbe(cliBinaryResolver: { cliURL.path }, accountProbeTimeout: 2) + await #expect { + _ = try await probe.fetch() + } throws: { error in + guard case KiroStatusProbeError.notLoggedIn = error else { return false } + return true + } + } + + @Test + func `fetch preserves not logged in when usage output cannot be parsed`() async throws { + let cliURL = try self.makeCLI( + """ + #!/bin/sh + if [ "$1" = "whoami" ]; then + printf 'Not logged in\\n' + exit 1 + fi + + if [ "$1" = "chat" ] && [ "$3" = "/usage" ]; then + exit 0 + fi + + if [ "$1" = "chat" ] && [ "$3" = "/context" ]; then + exit 0 + fi + + exit 1 + """) + defer { try? FileManager.default.removeItem(at: cliURL.deletingLastPathComponent()) } + + let probe = KiroStatusProbe(cliBinaryResolver: { cliURL.path }) + await #expect { + _ = try await probe.fetch() + } throws: { error in + guard case KiroStatusProbeError.notLoggedIn = error else { return false } + return true + } + } + + @Test + func `fetch cancellation during context probe is preserved`() async throws { + let contextStarted = FileManager.default.temporaryDirectory + .appendingPathComponent("codexbar-kiro-context-\(UUID().uuidString).started") + let cliURL = try self.makeCLI( + """ + #!/bin/sh + if [ "$1" = "whoami" ]; then + printf 'Logged in with Google\nEmail: person@example.com\n' + exit 0 + fi + + if [ "$1" = "chat" ] && [ "$3" = "/usage" ]; then + printf 'Estimated Usage | resets on 2026-06-01 | KIRO FREE\n' + printf 'Credits (12.50 of 50 covered in plan)\n' + printf '████████████████████ 25%%\n' + exit 0 + fi + + if [ "$1" = "chat" ] && [ "$3" = "/context" ]; then + : > '\(contextStarted.path)' + trap '' TERM + while true; do sleep 1; done + fi + + exit 1 + """) + defer { + try? FileManager.default.removeItem(at: cliURL.deletingLastPathComponent()) + try? FileManager.default.removeItem(at: contextStarted) + } + + let probe = KiroStatusProbe(cliBinaryResolver: { cliURL.path }) + let task = Task { try await probe.fetch() } + defer { task.cancel() } + + try await waitForFile(contextStarted) + + let cancelledAt = Date() + task.cancel() + await #expect(throws: CancellationError.self) { + try await task.value + } + #expect(Date().timeIntervalSince(cancelledAt) < 4) + } + + @Test + func `cancellation during pipe cleanup wins over an expired context deadline`() async throws { + let cleanupStarted = FileManager.default.temporaryDirectory + .appendingPathComponent("codexbar-kiro-cleanup-\(UUID().uuidString).started") + let unblockCleanup = DispatchSemaphore(value: 0) + let registry = KiroTestProcessRegistry( + blockOnUnregister: 3, + blockStartedURL: cleanupStarted, + unblock: unblockCleanup) + let cliURL = try self.makeCLI( + """ + #!/bin/sh + if [ "$1" = "whoami" ]; then + printf 'Logged in with Google\nEmail: person@example.com\n' + exit 0 + fi + if [ "$1" = "chat" ] && [ "$3" = "/usage" ]; then + printf 'Estimated Usage | resets on 2026-06-01 | KIRO FREE\n' + printf 'Credits (12.50 of 50 covered in plan)\n' + printf '████████████████████ 25%%\n' + exit 0 + fi + if [ "$1" = "chat" ] && [ "$3" = "/context" ]; then + exit 0 + fi + exit 1 + """) + defer { + unblockCleanup.signal() + try? FileManager.default.removeItem(at: cleanupStarted) + try? FileManager.default.removeItem(at: cliURL.deletingLastPathComponent()) + } + + let probe = KiroStatusProbe( + cliBinaryResolver: { cliURL.path }, + contextProbeTimeout: 0.2, + pipeProcessRegistry: registry.dependencies) + let task = Task { try await probe.fetch() } + defer { task.cancel() } + + try await waitForFile(cleanupStarted) + task.cancel() + try await Task.sleep(for: .milliseconds(250)) + unblockCleanup.signal() + + await #expect(throws: CancellationError.self) { + try await task.value + } + } + + @Test + func `fetch cancellation while waiting for account probe is preserved`() async throws { + let accountStarted = FileManager.default.temporaryDirectory + .appendingPathComponent("codexbar-kiro-account-\(UUID().uuidString).started") + let cliURL = try self.makeCLI( + """ + #!/bin/sh + if [ "$1" = "whoami" ]; then + : > '\(accountStarted.path)' + trap '' TERM + while true; do sleep 1; done + fi + + if [ "$1" = "chat" ] && [ "$3" = "/usage" ]; then + printf 'Estimated Usage | resets on 2026-06-01 | KIRO FREE\\n' + printf 'Credits (12.50 of 50 covered in plan)\\n' + printf '████████████████████ 25%%\\n' + exit 0 + fi + + if [ "$1" = "chat" ] && [ "$3" = "/context" ]; then + exit 0 + fi + + exit 1 + """) + defer { + try? FileManager.default.removeItem(at: cliURL.deletingLastPathComponent()) + try? FileManager.default.removeItem(at: accountStarted) + } + + let probe = KiroStatusProbe(cliBinaryResolver: { cliURL.path }) + let task = Task { try await probe.fetch() } + defer { task.cancel() } + + try await waitForFile(accountStarted) + + let cancelledAt = Date() + task.cancel() + await #expect(throws: CancellationError.self) { + try await task.value + } + #expect(Date().timeIntervalSince(cancelledAt) < 4) + } + + @Test + func `fetch returns promptly when usage helper spawns a detached child`() async throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("codexbar-kiro-pipe-\(UUID().uuidString)", isDirectory: true) + let childPIDFile = root.appendingPathComponent("child.pid") + let cliURL = root.appendingPathComponent("kiro-cli") + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + defer { + if let text = try? String(contentsOf: childPIDFile, encoding: .utf8), + let childPID = pid_t(text.trimmingCharacters(in: .whitespacesAndNewlines)) + { + _ = kill(childPID, SIGKILL) + } + } + + let script = """ + #!/bin/bash + set -e + if [ "$1" = "whoami" ]; then + printf 'Logged in with Google\\nEmail: person@example.com\\n' + exit 0 + fi + + if [ "$1" = "chat" ] && [ "$3" = "/usage" ]; then + /usr/bin/python3 -c ' + import os + import subprocess + import sys + + ready_read, ready_write = os.pipe() + child = subprocess.Popen( + [ + sys.executable, + "-c", + "import os,signal,sys,time; " + "signal.signal(signal.SIGHUP, signal.SIG_IGN); " + "signal.signal(signal.SIGTERM, signal.SIG_IGN); " + "handle=open(sys.argv[1], \\\"w\\\"); handle.write(str(os.getpid())); handle.close(); " + "os.write(int(sys.argv[2]), b\\\"1\\\"); os.close(int(sys.argv[2])); time.sleep(60)", + os.environ["CODEXBAR_TEST_CHILD_PID_FILE"], + str(ready_write), + ], + start_new_session=True, + pass_fds=(ready_write,), + ) + os.close(ready_write) + if os.read(ready_read, 1) != b"1": + raise RuntimeError("detached helper exited before signaling readiness") + os.close(ready_read) + ' + test -s "$CODEXBAR_TEST_CHILD_PID_FILE" + printf 'Estimated Usage | resets on 2026-06-01 | KIRO FREE\\n' + printf 'Credits (12.50 of 50 covered in plan)\\n' + printf '████████████████████ 25%%\\n' + exit 0 + fi + + if [ "$1" = "chat" ] && [ "$3" = "/context" ]; then + printf 'Context window: 40%% used\\n'; exit 0 + fi + + exit 1 + """ + try script.write(to: cliURL, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: cliURL.path) + + let previousPIDFile = ProcessInfo.processInfo.environment["CODEXBAR_TEST_CHILD_PID_FILE"] + setenv("CODEXBAR_TEST_CHILD_PID_FILE", childPIDFile.path, 1) + defer { + if let previousPIDFile { + setenv("CODEXBAR_TEST_CHILD_PID_FILE", previousPIDFile, 1) + } else { + unsetenv("CODEXBAR_TEST_CHILD_PID_FILE") + } + } + + let probe = KiroStatusProbe(cliBinaryResolver: { cliURL.path }) + let start = Date() + let snapshot = try await probe.fetch() + let elapsed = Date().timeIntervalSince(start) + + let childPIDText = try String(contentsOf: childPIDFile, encoding: .utf8) + let childPID = try #require(pid_t(childPIDText.trimmingCharacters(in: .whitespacesAndNewlines))) + for _ in 0..<50 where kill(childPID, 0) == 0 { + try await Task.sleep(for: .milliseconds(20)) + } + + // Keep the optional context probe parseable so this timing check covers detached-child cleanup. + #expect(snapshot.planName == "KIRO FREE") + #expect(snapshot.creditsUsed == 12.50 && snapshot.contextUsage?.totalPercentUsed == 40) + // The detached child sleeps 60s; if the probe waited for it, elapsed would be >= 60. The generous + // ceiling only guards against that regression while tolerating heavily loaded CI runners + // (observed 12.6s on a shared GitHub macOS runner for what is normally a sub-second fetch). + #expect(elapsed < 20, "Kiro usage capture should return promptly even with a detached child, took \(elapsed)s") + #expect(kill(childPID, 0) == -1) + } + + @Test + func `tty runner hard stops a process that ignores SIGTERM`() throws { + let cliURL = try self.makeCLI( + """ + #!/bin/sh + trap '' TERM + printf 'partial output\\n' + while true; do sleep 1; done + """) + defer { try? FileManager.default.removeItem(at: cliURL.deletingLastPathComponent()) } + + let start = Date() + let result = try TTYCommandRunner().run( + binary: cliURL.path, + send: "", + options: .init(timeout: 2, idleTimeout: 0.1)) + let elapsed = Date().timeIntervalSince(start) + + #expect(result.completion == .idleTimeout) + #expect(result.text.contains("partial output")) + #expect(elapsed < 3, "Ignored SIGTERM should escalate to SIGKILL, took \(elapsed)s") + } + + @Test + func `tty runner kills a pipe holder that escapes the process group`() async throws { + let childPIDFile = FileManager.default.temporaryDirectory + .appendingPathComponent("codexbar-kiro-escaped-\(UUID().uuidString).pid") + let cliURL = try self.makeCLI( + """ + #!/usr/bin/python3 + import subprocess + import sys + import time + + child = subprocess.Popen( + [ + sys.executable, + "-c", + "import signal,time; signal.signal(signal.SIGTERM, signal.SIG_IGN); time.sleep(30)", + ], + start_new_session=True, + ) + with open(sys.argv[1], "w") as handle: + handle.write(str(child.pid)) + print("partial output", flush=True) + time.sleep(30) + """) + defer { + try? FileManager.default.removeItem(at: cliURL.deletingLastPathComponent()) + try? FileManager.default.removeItem(at: childPIDFile) + } + + let result = try TTYCommandRunner().run( + binary: cliURL.path, + send: "", + options: .init( + timeout: 2, + idleTimeout: 0.1, + extraArgs: [childPIDFile.path])) + + #expect(result.completion == .idleTimeout) + #expect(result.text.contains("partial output")) + + let childPIDText = try String(contentsOf: childPIDFile, encoding: .utf8) + let childPID = try #require(pid_t(childPIDText.trimmingCharacters(in: .whitespacesAndNewlines))) + defer { _ = kill(childPID, SIGKILL) } + + let cleanupDeadline = Date().addingTimeInterval(1) + while kill(childPID, 0) == 0, Date() < cleanupDeadline { + try await Task.sleep(for: .milliseconds(20)) + } + #expect(kill(childPID, 0) == -1) + } + + @Test + func `tty runner cleans a same group helper after normal exit`() async throws { + let childPIDFile = FileManager.default.temporaryDirectory + .appendingPathComponent("codexbar-kiro-normal-exit-\(UUID().uuidString).pid") + let cliURL = try self.makeCLI( + """ + #!/usr/bin/python3 + import os + import signal + import sys + import time + + child = os.fork() + if child == 0: + os.close(1) + os.close(2) + signal.signal(signal.SIGTERM, signal.SIG_IGN) + with open(sys.argv[1], "w") as handle: + handle.write(str(os.getpid())) + time.sleep(30) + os._exit(0) + + while not os.path.exists(sys.argv[1]): + time.sleep(0.01) + print("parent complete", flush=True) + os._exit(0) + """) + defer { + try? FileManager.default.removeItem(at: cliURL.deletingLastPathComponent()) + try? FileManager.default.removeItem(at: childPIDFile) + } + + let result = try TTYCommandRunner().run( + binary: cliURL.path, + send: "", + options: .init(timeout: 2, extraArgs: [childPIDFile.path])) + + #expect(result.completion == .processExited(status: 0)) + #expect(result.text.contains("parent complete")) + + let childPIDText = try String(contentsOf: childPIDFile, encoding: .utf8) + let childPID = try #require(pid_t(childPIDText.trimmingCharacters(in: .whitespacesAndNewlines))) + defer { _ = kill(childPID, SIGKILL) } + + let cleanupDeadline = Date().addingTimeInterval(1) + while kill(childPID, 0) == 0, Date() < cleanupDeadline { + try await Task.sleep(for: .milliseconds(20)) + } + #expect(kill(childPID, 0) == -1) + } + + @Test + func `tty runner preserves completed no-output failure status`() throws { + let cliURL = try self.makeCLI( + """ + #!/bin/sh + exit 23 + """) + defer { try? FileManager.default.removeItem(at: cliURL.deletingLastPathComponent()) } + + let result = try TTYCommandRunner().run( + binary: cliURL.path, + send: "", + options: .init(timeout: 2, returnOnEmptyProcessExit: true)) + + #expect(result.text.isEmpty) + #expect(result.completion == .processExited(status: 23)) + } + + @Test + func `tty runner cancellation terminates the process`() async throws { + let pidFile = FileManager.default.temporaryDirectory + .appendingPathComponent("codexbar-kiro-cancel-\(UUID().uuidString).pid") + let cliURL = try self.makeCLI( + """ + #!/bin/sh + printf '%s\\n' "$$" > "$1" + trap '' TERM + while true; do sleep 1; done + """) + defer { + try? FileManager.default.removeItem(at: cliURL.deletingLastPathComponent()) + try? FileManager.default.removeItem(at: pidFile) + } + + let task = Task { + try TTYCommandRunner().run( + binary: cliURL.path, + send: "", + options: .init(timeout: 20, extraArgs: [pidFile.path])) + } + defer { task.cancel() } + + var capturedProcessID: pid_t? + for _ in 0..<100 { + if let text = try? String(contentsOf: pidFile, encoding: .utf8) { + capturedProcessID = pid_t(text.trimmingCharacters(in: .whitespacesAndNewlines)) + break + } + try await Task.sleep(for: .milliseconds(20)) + } + let processID = try #require(capturedProcessID) + defer { _ = kill(processID, SIGKILL) } + + task.cancel() + await #expect(throws: CancellationError.self) { + try await task.value + } + #expect(kill(processID, 0) == -1) + } +} + +extension KiroStatusProbeTests { // MARK: - Happy Path Parsing @Test @@ -27,6 +1185,16 @@ struct KiroStatusProbeTests { #expect(snapshot.resetsAt != nil) } + private func makeCLI(_ script: String) throws -> URL { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("codexbar-kiro-cli-\(UUID().uuidString)", isDirectory: true) + let cliURL = root.appendingPathComponent("kiro-cli") + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + try script.write(to: cliURL, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: cliURL.path) + return cliURL + } + @Test func `parses output with bonus credits`() throws { let output = """ @@ -477,3 +1645,42 @@ struct KiroStatusProbeTests { #expect(account.email == "user@example.com") } } + +extension KiroStatusProbeTests { + @Test + func `fetch cancellation while joining account after usage failure is preserved`() async throws { + let accountStarted = FileManager.default.temporaryDirectory + .appendingPathComponent("codexbar-kiro-failed-usage-account-\(UUID().uuidString).started") + let cliURL = try self.makeCLI( + """ + #!/bin/sh + if [ "$1" = "whoami" ]; then + : > '\(accountStarted.path)' + trap '' TERM + while true; do sleep 1; done + fi + + if [ "$1" = "chat" ] && [ "$3" = "/usage" ]; then + exit 1 + fi + + exit 1 + """) + defer { + try? FileManager.default.removeItem(at: cliURL.deletingLastPathComponent()) + try? FileManager.default.removeItem(at: accountStarted) + } + + let probe = KiroStatusProbe(cliBinaryResolver: { cliURL.path }, accountProbeTimeout: 2.0) + let task = Task { try await probe.fetch() } + defer { task.cancel() } + + try await waitForFile(accountStarted) + try await Task.sleep(for: .milliseconds(300)) + + task.cancel() + await #expect(throws: CancellationError.self) { + try await task.value + } + } +} diff --git a/Tests/CodexBarTests/KiroTransportRaceTests.swift b/Tests/CodexBarTests/KiroTransportRaceTests.swift new file mode 100644 index 0000000000..ff2b18a279 --- /dev/null +++ b/Tests/CodexBarTests/KiroTransportRaceTests.swift @@ -0,0 +1,119 @@ +import Foundation +import Testing +@testable import CodexBarCore + +@Suite(.serialized) +struct KiroTransportRaceTests { + @Test + func `empty nonzero pipe exit falls back to PTY`() async throws { + let cliURL = try self.makeCLI( + """ + #!/bin/sh + if [ ! -t 1 ]; then + exit 42 + fi + if [ "$1" = "whoami" ]; then + printf 'Logged in with Google\nEmail: person@example.com\n' + exit 0 + fi + if [ "$1" = "chat" ] && [ "$3" = "/usage" ]; then + printf 'Estimated Usage | resets on 2026-06-01 | KIRO FREE\n' + printf 'Credits (12.50 of 50 covered in plan)\n' + printf '████████████████████ 25%%\n' + exit 0 + fi + if [ "$1" = "chat" ] && [ "$3" = "/context" ]; then + exit 0 + fi + exit 1 + """) + defer { try? FileManager.default.removeItem(at: cliURL.deletingLastPathComponent()) } + + let snapshot = try await KiroStatusProbe( + cliBinaryResolver: { cliURL.path }, + pipeTimeoutCap: 0.2).fetch() + + #expect(snapshot.planName == "KIRO FREE") + #expect(snapshot.accountEmail == "person@example.com") + } + + @Test + func `failed PTY cannot preempt a valid slow pipe`() async throws { + let cliURL = try self.makeCLI( + """ + #!/bin/sh + if [ "$1" = "whoami" ]; then + printf 'Logged in with Google\nEmail: person@example.com\n' + exit 0 + fi + if [ "$1" = "chat" ] && [ "$3" = "/usage" ]; then + if [ -t 1 ]; then + printf 'Estimated Usage | resets on 2026-06-01 | KIRO FREE\n' + printf 'Credits (49 of 50 covered in plan)\n' + printf '████████████████████ 98%%\n' + exit 91 + fi + sleep 1 + printf 'Estimated Usage | resets on 2026-06-01 | KIRO FREE\n' + printf 'Credits (12.50 of 50 covered in plan)\n' + printf '████████████████████ 25%%\n' + exit 0 + fi + if [ "$1" = "chat" ] && [ "$3" = "/context" ]; then + exit 0 + fi + exit 1 + """) + defer { try? FileManager.default.removeItem(at: cliURL.deletingLastPathComponent()) } + + let snapshot = try await KiroStatusProbe( + cliBinaryResolver: { cliURL.path }, + usageProbeTimeout: 2, + pipeTimeoutCap: 0.2).fetch() + + #expect(snapshot.creditsUsed == 12.50) + } + + @Test + func `pending failed PTY cannot escape the shared deadline`() async throws { + let cliURL = try self.makeCLI( + """ + #!/bin/sh + if [ "$1" = "whoami" ]; then + printf 'Logged in with Google\nEmail: person@example.com\n' + exit 0 + fi + if [ "$1" = "chat" ] && [ "$3" = "/usage" ]; then + if [ -t 1 ]; then + exit 91 + fi + sleep 5 + exit 0 + fi + exit 0 + """) + defer { try? FileManager.default.removeItem(at: cliURL.deletingLastPathComponent()) } + + let probe = KiroStatusProbe( + cliBinaryResolver: { cliURL.path }, + usageProbeTimeout: 0.6, + pipeTimeoutCap: 0.1) + + await #expect { + try await probe.fetch() + } throws: { error in + guard case KiroStatusProbeError.timeout = error else { return false } + return true + } + } + + private func makeCLI(_ script: String) throws -> URL { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("codexbar-kiro-race-\(UUID().uuidString)", isDirectory: true) + let cliURL = root.appendingPathComponent("kiro-cli") + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + try script.write(to: cliURL, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: cliURL.path) + return cliURL + } +} diff --git a/Tests/CodexBarTests/LaunchAtLoginManagerTests.swift b/Tests/CodexBarTests/LaunchAtLoginManagerTests.swift new file mode 100644 index 0000000000..6248e07472 --- /dev/null +++ b/Tests/CodexBarTests/LaunchAtLoginManagerTests.swift @@ -0,0 +1,126 @@ +import ServiceManagement +import Testing +@testable import CodexBar + +@MainActor +struct LaunchAtLoginManagerTests { + @Test + func `set enabled skips registration when service is already enabled`() { + var registerCalls = 0 + var unregisterCalls = 0 + + LaunchAtLoginManager.setEnabled( + true, + status: { .enabled }, + register: { registerCalls += 1 }, + unregister: { unregisterCalls += 1 }) + + #expect(registerCalls == 0) + #expect(unregisterCalls == 0) + } + + @Test + func `set enabled registers when service is not registered`() { + var registerCalls = 0 + var unregisterCalls = 0 + + LaunchAtLoginManager.setEnabled( + true, + status: { .notRegistered }, + register: { registerCalls += 1 }, + unregister: { unregisterCalls += 1 }) + + #expect(registerCalls == 1) + #expect(unregisterCalls == 0) + } + + @Test + func `set enabled skips registration when service requires approval`() { + var registerCalls = 0 + var unregisterCalls = 0 + + LaunchAtLoginManager.setEnabled( + true, + status: { .requiresApproval }, + register: { registerCalls += 1 }, + unregister: { unregisterCalls += 1 }) + + #expect(registerCalls == 0) + #expect(unregisterCalls == 0) + } + + @Test + func `set enabled registers when service is not found`() { + var registerCalls = 0 + var unregisterCalls = 0 + + LaunchAtLoginManager.setEnabled( + true, + status: { .notFound }, + register: { registerCalls += 1 }, + unregister: { unregisterCalls += 1 }) + + #expect(registerCalls == 1) + #expect(unregisterCalls == 0) + } + + @Test + func `set disabled unregisters when service is enabled`() { + var registerCalls = 0 + var unregisterCalls = 0 + + LaunchAtLoginManager.setEnabled( + false, + status: { .enabled }, + register: { registerCalls += 1 }, + unregister: { unregisterCalls += 1 }) + + #expect(registerCalls == 0) + #expect(unregisterCalls == 1) + } + + @Test + func `set disabled unregisters when service requires approval`() { + var registerCalls = 0 + var unregisterCalls = 0 + + LaunchAtLoginManager.setEnabled( + false, + status: { .requiresApproval }, + register: { registerCalls += 1 }, + unregister: { unregisterCalls += 1 }) + + #expect(registerCalls == 0) + #expect(unregisterCalls == 1) + } + + @Test + func `set disabled skips unregister when service is not registered`() { + var registerCalls = 0 + var unregisterCalls = 0 + + LaunchAtLoginManager.setEnabled( + false, + status: { .notRegistered }, + register: { registerCalls += 1 }, + unregister: { unregisterCalls += 1 }) + + #expect(registerCalls == 0) + #expect(unregisterCalls == 0) + } + + @Test + func `set disabled skips unregister when service is not found`() { + var registerCalls = 0 + var unregisterCalls = 0 + + LaunchAtLoginManager.setEnabled( + false, + status: { .notFound }, + register: { registerCalls += 1 }, + unregister: { unregisterCalls += 1 }) + + #expect(registerCalls == 0) + #expect(unregisterCalls == 0) + } +} diff --git a/Tests/CodexBarTests/LiteLLMMenuCardModelTests.swift b/Tests/CodexBarTests/LiteLLMMenuCardModelTests.swift new file mode 100644 index 0000000000..668ee31c16 --- /dev/null +++ b/Tests/CodexBarTests/LiteLLMMenuCardModelTests.swift @@ -0,0 +1,235 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +struct LiteLLMMenuCardModelTests { + @Test + func `litellm budget rows show spend detail with reset time`() throws { + let now = Date(timeIntervalSince1970: 0) + let metadata = try #require(ProviderDefaults.metadata[.litellm]) + let json = """ + { + "user_id": "user-123", + "user_info": { + "user_id": "user-123", + "max_budget": 900.0, + "spend": 403.99, + "budget_reset_at": "1970-01-07T00:00:00Z" + }, + "teams": [ + { + "team_alias": "Platform", + "team_id": "team-123", + "max_budget": 1000.0, + "spend": 70.0, + "budget_duration": "30d", + "budget_reset_at": "1970-01-07T00:00:00Z" + } + ] + } + """ + let snapshot = try LiteLLMUsageFetcher._parseUserInfoForTesting( + Data(json.utf8), + keyInfo: LiteLLMKeyInfoSnapshot( + userID: "user-123", + teamID: "team-123", + keyName: nil, + spendUSD: 403.99, + expiresAt: nil), + updatedAt: now) + .toUsageSnapshot() + + let model = UsageMenuCardView.Model.make(.init( + provider: .litellm, + metadata: metadata, + snapshot: snapshot, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + + let personal = try #require(model.metrics.first { $0.id == "primary" }) + #expect(personal.title == "Personal budget") + #expect(personal.percentLabel == "55% left") + #expect(personal.resetText?.hasPrefix("Resets") == true) + #expect(personal.detailText == "$403.99 / $900.00") + + let team = try #require(model.metrics.first { $0.id == "secondary" }) + #expect(team.title == "Team budget") + #expect(team.percentLabel == "93% left") + #expect(team.resetText?.hasPrefix("Resets") == true) + #expect(team.detailText == "Team Platform: $70.00 / $1,000.00") + + #expect(model.providerCost == nil) + } + + @Test + func `litellm budget row details redact team aliases when hiding personal info`() throws { + let teamAlias = "Private Workspace" + let model = try self.redactedTeamAliasModel(teamAlias) + + let team = try #require(model.metrics.first { $0.id == "secondary" }) + #expect(team.detailText == "Team: $70.00 / $1,000.00") + #expect(team.detailText?.contains(teamAlias) == false) + } + + @Test + func `litellm budget row details redact email team aliases when hiding personal info`() throws { + let teamAlias = "workspace@example.com" + let model = try self.redactedTeamAliasModel(teamAlias) + + let team = try #require(model.metrics.first { $0.id == "secondary" }) + #expect(team.detailText == "Team: $70.00 / $1,000.00") + #expect(team.detailText?.contains(teamAlias) == false) + #expect(team.detailText?.contains("Hidden") == false) + } + + @Test + func `litellm team-only budget stays on the team row`() throws { + let now = Date(timeIntervalSince1970: 0) + let metadata = try #require(ProviderDefaults.metadata[.litellm]) + let snapshot = UsageSnapshot( + primary: nil, + secondary: RateWindow( + usedPercent: 25, + windowMinutes: nil, + resetsAt: now.addingTimeInterval(7 * 24 * 60 * 60), + resetDescription: "Team Platform: $250.00 / $1,000.00"), + providerCost: ProviderCostSnapshot( + used: 250, + limit: 1000, + currencyCode: "USD", + period: "Team budget", + updatedAt: now), + updatedAt: now) + + let model = UsageMenuCardView.Model.make(.init( + provider: .litellm, + metadata: metadata, + snapshot: snapshot, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + + #expect(model.metrics.map(\.id) == ["secondary"]) + #expect(model.metrics.first?.detailText == "Team Platform: $250.00 / $1,000.00") + #expect(model.providerCost == nil) + } + + @Test + func `litellm spend without budget remains visible`() throws { + let now = Date() + let metadata = try #require(ProviderDefaults.metadata[.litellm]) + let snapshot = UsageSnapshot( + primary: nil, + secondary: nil, + providerCost: ProviderCostSnapshot( + used: 12.5, + limit: 0, + currencyCode: "USD", + period: "Personal spend", + updatedAt: now), + updatedAt: now) + + let model = UsageMenuCardView.Model.make(.init( + provider: .litellm, + metadata: metadata, + snapshot: snapshot, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + + #expect(model.providerCost?.title == "API spend") + #expect(model.providerCost?.spendLine == "Personal spend: $12.50") + #expect(model.providerCost?.percentUsed == nil) + } + + private func redactedTeamAliasModel(_ teamAlias: String) throws -> UsageMenuCardView.Model { + let now = Date(timeIntervalSince1970: 0) + let metadata = try #require(ProviderDefaults.metadata[.litellm]) + let json = """ + { + "user_id": "user-123", + "user_info": { + "user_id": "user-123", + "max_budget": 900.0, + "spend": 403.99 + }, + "teams": [ + { + "team_alias": "\(teamAlias)", + "team_id": "team-123", + "max_budget": 1000.0, + "spend": 70.0 + } + ] + } + """ + let snapshot = try LiteLLMUsageFetcher._parseUserInfoForTesting( + Data(json.utf8), + keyInfo: LiteLLMKeyInfoSnapshot( + userID: "user-123", + teamID: "team-123", + keyName: nil, + spendUSD: 403.99, + expiresAt: nil), + updatedAt: now) + .toUsageSnapshot() + + return UsageMenuCardView.Model.make(.init( + provider: .litellm, + metadata: metadata, + snapshot: snapshot, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: true, + now: now)) + } +} diff --git a/Tests/CodexBarTests/LiteLLMUsageFetcherTests.swift b/Tests/CodexBarTests/LiteLLMUsageFetcherTests.swift new file mode 100644 index 0000000000..d21cf30ec5 --- /dev/null +++ b/Tests/CodexBarTests/LiteLLMUsageFetcherTests.swift @@ -0,0 +1,364 @@ +import CodexBarCore +import Foundation +import Testing + +struct LiteLLMUsageFetcherTests { + @Test + func `parses user usage with personal and team budgets`() throws { + let json = """ + { + "user_id": "user-123", + "user_info": { + "user_id": "user-123", + "user_alias": "litellm-user@example.com", + "max_budget": 300.0, + "spend": 212.3537162499998, + "user_email": "litellm-user@example.com", + "budget_reset_at": null, + "teams": ["team-456"], + "metadata": { + "source": "keycloak", + "preferred_username": "litellm-user@example.com", + "budget": 300, + "flags": { + "keycloak": true + } + } + }, + "keys": [ + { + "key_name": "sk-...OTHER", + "user_id": "user-123", + "team_id": "team-other" + }, + { + "key_name": "sk-...IAAw", + "spend": 212.3537162499998, + "expires": "2026-09-11T00:12:55.950000+00:00", + "user_id": "user-123", + "team_id": "team-456" + } + ], + "teams": [ + { + "team_alias": "unrelated", + "team_id": "team-other", + "max_budget": 5.0, + "spend": 4.0 + }, + { + "team_alias": "ai", + "team_id": "team-456", + "max_budget": 1000.0, + "spend": 215.3245658499998, + "budget_duration": "7d", + "budget_reset_at": "2026-06-15T00:00:00Z" + } + ] + } + """ + + let parsed = try LiteLLMUsageFetcher._parseUserInfoForTesting( + Data(json.utf8), + keyInfo: LiteLLMKeyInfoSnapshot( + userID: "user-123", + teamID: "team-456", + keyName: "sk-...IAAw", + spendUSD: 212.3537162499998, + expiresAt: Date(timeIntervalSince1970: 2)), + updatedAt: Date(timeIntervalSince1970: 1)) + + #expect(parsed.userID == "user-123") + #expect(parsed.accountEmail == "litellm-user@example.com") + #expect(abs(parsed.personalSpendUSD - 212.3537162499998) < 0.000001) + #expect(parsed.personalBudgetUSD == 300) + #expect(parsed.teamUsage?.alias == "ai") + #expect(parsed.teamUsage?.spendUSD == 215.3245658499998) + #expect(parsed.teamUsage?.budgetUSD == 1000) + #expect(parsed.keyName == "sk-...IAAw") + #expect(parsed.keyExpiresAt == Date(timeIntervalSince1970: 2)) + + let snapshot = parsed.toUsageSnapshot() + #expect(snapshot.identity?.providerID == .litellm) + #expect(snapshot.identity?.accountEmail == "litellm-user@example.com") + let primary = try #require(snapshot.primary) + #expect(abs(primary.usedPercent - 70.78457208333327) < 0.000001) + #expect(primary.resetDescription == "$212.35 / $300.00") + let secondary = try #require(snapshot.secondary) + #expect(abs(secondary.usedPercent - 21.53245658499998) < 0.000001) + #expect(secondary.resetDescription == "Team ai: $215.32 / $1,000.00") + #expect(snapshot.providerCost?.used == 212.3537162499998) + #expect(snapshot.providerCost?.limit == 300) + #expect(snapshot.providerCost?.period == "Personal budget") + } + + @Test + func `preserves personal spend when no budget is configured`() throws { + let json = """ + { + "user_id": "user-123", + "user_info": { + "user_id": "user-123", + "max_budget": null, + "spend": 12.5 + } + } + """ + + let parsed = try LiteLLMUsageFetcher._parseUserInfoForTesting( + Data(json.utf8), + keyInfo: LiteLLMKeyInfoSnapshot( + userID: "user-123", + teamID: nil, + keyName: "personal-key", + spendUSD: 12.5, + expiresAt: nil), + updatedAt: Date(timeIntervalSince1970: 1)) + + let snapshot = parsed.toUsageSnapshot() + #expect(snapshot.primary == nil) + #expect(snapshot.providerCost?.used == 12.5) + #expect(snapshot.providerCost?.limit == 0) + #expect(snapshot.providerCost?.period == "Personal spend") + } + + @Test + func `parses key info identity for user lookup`() throws { + let json = """ + { + "key": "sk-redacted", + "info": { + "key_name": "sk-...IAAw", + "spend": 212.3537162499998, + "expires": "2026-09-11T00:12:55.950000+00:00", + "user_id": "user-123", + "team_id": "team-456", + "max_budget": null + } + } + """ + + let parsed = try LiteLLMUsageFetcher._parseKeyInfoForTesting(Data(json.utf8)) + + #expect(parsed.userID == "user-123") + #expect(parsed.teamID == "team-456") + #expect(parsed.keyName == "sk-...IAAw") + #expect(parsed.spendUSD == 212.3537162499998) + } + + @Test + func `parses team-only key info without user identity`() throws { + let json = """ + { + "info": { + "key_name": "team-service-key", + "spend": 25.0, + "team_id": "team-456" + } + } + """ + + let parsed = try LiteLLMUsageFetcher._parseKeyInfoForTesting(Data(json.utf8)) + + #expect(parsed.userID == nil) + #expect(parsed.teamID == "team-456") + #expect(parsed.keyName == "team-service-key") + } + + @Test + func `management urls accept root or v1 base urls`() throws { + let root = try #require(URL(string: "https://litellm.example.com")) + let versioned = try #require(URL(string: "https://litellm.example.com/v1")) + let nestedVersioned = try #require(URL(string: "https://gateway.example.com/litellm/v1/")) + + #expect( + LiteLLMUsageFetcher + ._keyInfoURLForTesting(baseURL: root) + .absoluteString == "https://litellm.example.com/key/info") + #expect( + LiteLLMUsageFetcher + ._keyInfoURLForTesting(baseURL: versioned) + .absoluteString == "https://litellm.example.com/key/info") + #expect( + LiteLLMUsageFetcher + ._userInfoURLForTesting(baseURL: nestedVersioned, userID: "user-123") + .absoluteString == "https://gateway.example.com/litellm/user/info?user_id=user-123") + #expect( + LiteLLMUsageFetcher + ._teamInfoURLForTesting(baseURL: nestedVersioned, teamID: "team-456") + .absoluteString == "https://gateway.example.com/litellm/team/info?team_id=team-456") + } + + @Test + func `settings reader trims quoted environment values`() { + let environment = [ + "LITELLM_API_KEY": " 'sk-test' ", + "LITELLM_BASE_URL": #" "https://litellm.example.com/v1" "#, + ] + + #expect(LiteLLMSettingsReader.apiKey(environment: environment) == "sk-test") + #expect(LiteLLMSettingsReader.baseURL(environment: environment)? + .absoluteString == "https://litellm.example.com/v1") + } + + @Test + func `fetch trims api key before sending management requests`() async throws { + let baseURL = try #require(URL(string: "https://litellm.example.com/v1")) + let transport = ProviderHTTPTransportStub { request in + #expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer sk-test") + + let path = request.url?.path + let query = request.url?.query + let body: String + switch path { + case "/key/info": + #expect(query == nil) + body = """ + { + "info": { + "user_id": "user-123", + "team_id": "team-456", + "spend": 1 + } + } + """ + case "/user/info": + #expect(query == "user_id=user-123") + body = """ + { + "user_id": "user-123", + "user_info": { + "user_id": "user-123", + "max_budget": 10, + "spend": 1 + } + } + """ + default: + Issue.record("unexpected LiteLLM request path: \(path ?? "nil")") + body = "{}" + } + + let url = try #require(request.url) + let response = try #require(HTTPURLResponse( + url: url, + statusCode: 200, + httpVersion: nil, + headerFields: nil)) + return (Data(body.utf8), response) + } + + let snapshot = try await LiteLLMUsageFetcher.fetchUsage( + apiKey: " sk-test\n", + baseURL: baseURL, + transport: transport) + + #expect(snapshot.userID == "user-123") + let requests = await transport.requests() + #expect(requests.count == 2) + } + + @Test + func `fetches team usage for team-only virtual keys`() async throws { + let baseURL = try #require(URL(string: "https://litellm.example.com/v1")) + let transport = ProviderHTTPTransportStub { request in + #expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer sk-team") + + let path = request.url?.path + let query = request.url?.query + let body: String + switch path { + case "/key/info": + #expect(query == nil) + body = """ + { + "info": { + "key_name": "team-service-key", + "team_id": "team-456", + "spend": 25 + } + } + """ + case "/team/info": + #expect(query == "team_id=team-456") + body = """ + { + "team_id": "team-456", + "team_info": { + "team_id": "team-456", + "team_alias": "platform", + "max_budget": 100, + "spend": 25, + "budget_duration": "30d", + "budget_reset_at": "2026-07-01T00:00:00Z" + } + } + """ + default: + Issue.record("unexpected LiteLLM request path: \(path ?? "nil")") + body = "{}" + } + + let url = try #require(request.url) + let response = try #require(HTTPURLResponse( + url: url, + statusCode: 200, + httpVersion: nil, + headerFields: nil)) + return (Data(body.utf8), response) + } + + let snapshot = try await LiteLLMUsageFetcher.fetchUsage( + apiKey: "sk-team", + baseURL: baseURL, + transport: transport, + updatedAt: Date(timeIntervalSince1970: 1)) + + #expect(snapshot.userID == nil) + #expect(snapshot.teamUsage?.id == "team-456") + #expect(snapshot.teamUsage?.alias == "platform") + #expect(snapshot.teamUsage?.spendUSD == 25) + #expect(snapshot.teamUsage?.budgetUSD == 100) + + let usage = snapshot.toUsageSnapshot() + #expect(usage.primary == nil) + #expect(usage.secondary?.usedPercent == 25) + #expect(usage.providerCost?.used == 25) + #expect(usage.providerCost?.limit == 100) + #expect(usage.providerCost?.period == "Team budget") + + let requests = await transport.requests() + #expect(requests.count == 2) + } + + @Test + func `fetch surfaces rejected virtual key`() async throws { + let baseURL = try #require(URL(string: "https://litellm.example.com")) + let transport = ProviderHTTPTransportStub { request in + #expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer sk-target") + let url = try #require(request.url) + let response = try #require(HTTPURLResponse( + url: url, + statusCode: 401, + httpVersion: nil, + headerFields: nil)) + return (Data(#"{"detail":"Unauthorized"}"#.utf8), response) + } + + do { + _ = try await LiteLLMUsageFetcher.fetchUsage( + apiKey: "sk-target", + baseURL: baseURL, + transport: transport) + Issue.record("expected LiteLLMUsageError.apiError") + } catch let LiteLLMUsageError.apiError(message) { + #expect(message.contains("HTTP 401")) + #expect(message.contains("Unauthorized")) + } catch { + Issue.record("expected LiteLLMUsageError.apiError, got \(error)") + } + + let requests = await transport.requests() + #expect(requests.count == 1) + } +} diff --git a/Tests/CodexBarTests/LocalizationBundleCacheTests.swift b/Tests/CodexBarTests/LocalizationBundleCacheTests.swift new file mode 100644 index 0000000000..1d4e2f7b98 --- /dev/null +++ b/Tests/CodexBarTests/LocalizationBundleCacheTests.swift @@ -0,0 +1,96 @@ +import Foundation +import Testing +@testable import CodexBar + +/// Regression coverage for the localized-bundle caching added for #1347. +/// +/// The cache is process-global and these tests run in a parallel suite, so identity (`===`) assertions +/// would race against any other test that resolves a different language. Instead these assert the +/// concurrency-safe property that matters for correctness: every call resolves to the right `.lproj` +/// regardless of what is currently cached, so a language switch (and switch-back) is always honored and +/// the cache can never serve a stale localization. +struct LocalizationBundleCacheTests { + @Test + func `resolves the correct lproj per language and re-resolves on switch`() { + resetCodexBarLocalizationCacheForTesting() + + let fr = CodexBarLocalizationOverride.$appLanguage.withValue("fr") { + codexBarLocalizedBundleForTesting() + } + #expect(fr.bundleURL.lastPathComponent == "fr.lproj") + + // Switching language must re-resolve rather than return the cached French bundle. + let es = CodexBarLocalizationOverride.$appLanguage.withValue("es") { + codexBarLocalizedBundleForTesting() + } + #expect(es.bundleURL.lastPathComponent == "es.lproj") + + // Switching back must still produce the French bundle (cache key is the language). + let frAgain = CodexBarLocalizationOverride.$appLanguage.withValue("fr") { + codexBarLocalizedBundleForTesting() + } + #expect(frAgain.bundleURL.lastPathComponent == "fr.lproj") + } + + @Test + func `repeated same-language calls keep resolving the same lproj`() { + resetCodexBarLocalizationCacheForTesting() + + for _ in 0..<5 { + let bundle = CodexBarLocalizationOverride.$appLanguage.withValue("es") { + codexBarLocalizedBundleForTesting() + } + #expect(bundle.bundleURL.lastPathComponent == "es.lproj") + } + } + + @Test + func `unknown language falls back to en lproj`() { + resetCodexBarLocalizationCacheForTesting() + + let bundle = CodexBarLocalizationOverride.$appLanguage.withValue("zz-unknown") { + codexBarLocalizedBundleForTesting() + } + #expect(bundle.bundleURL.lastPathComponent == "en.lproj") + } + + @Test + func `format locale follows the resolved resource bundle`() { + let english = CodexBarLocalizationOverride.$appLanguage.withValue("en") { + codexBarLocalizedResourceLocale() + } + #expect(english.language.languageCode?.identifier == "en") + + let fallback = CodexBarLocalizationOverride.$appLanguage.withValue("zz-unknown") { + codexBarLocalizedResourceLocale() + } + #expect(fallback.language.languageCode?.identifier == "en") + } + + @Test + func `resource locale expands English stringsdict singular forms`() { + let rendered = CodexBarLocalizationOverride.$appLanguage.withValue("en") { + String( + format: L("≈%d full 5h windows of weekly left · %d windows until reset"), + locale: codexBarLocalizedResourceLocale(), + arguments: [1, 1]) + } + + #expect(rendered == "≈1 full 5h window of weekly left · 1 window until reset") + } + + @Test + func `resolution survives an explicit cache reset`() { + let first = CodexBarLocalizationOverride.$appLanguage.withValue("uk") { + codexBarLocalizedBundleForTesting() + } + #expect(first.bundleURL.lastPathComponent == "uk.lproj") + + resetCodexBarLocalizationCacheForTesting() + + let afterReset = CodexBarLocalizationOverride.$appLanguage.withValue("uk") { + codexBarLocalizedBundleForTesting() + } + #expect(afterReset.bundleURL.lastPathComponent == "uk.lproj") + } +} diff --git a/Tests/CodexBarTests/LocalizationLanguageCatalogTests.swift b/Tests/CodexBarTests/LocalizationLanguageCatalogTests.swift new file mode 100644 index 0000000000..27a81f1d63 --- /dev/null +++ b/Tests/CodexBarTests/LocalizationLanguageCatalogTests.swift @@ -0,0 +1,673 @@ +import Foundation +import Testing +@testable import CodexBar + +struct LocalizationLanguageCatalogTests { + private let languageKeys = [ + "language_system", + "language_english", + "language_german", + "language_spanish", + "language_catalan", + "language_chinese_simplified", + "language_chinese_traditional", + "language_portuguese_brazilian", + "language_swedish", + "language_french", + "language_dutch", + "language_ukrainian", + "language_russian", + "language_italian", + "language_vietnamese", + "language_japanese", + "language_korean", + "language_turkish", + "language_indonesian", + "language_polish", + "language_arabic", + "language_persian", + "language_thai", + "language_galician", + ] + + @Test + func `app language catalog includes Ukrainian`() { + #expect(AppLanguage.allCases.contains(.ukrainian)) + #expect(AppLanguage.ukrainian.rawValue == "uk") + } + + @Test + func `app language catalog includes Russian`() { + #expect(AppLanguage.allCases.contains(.russian)) + #expect(AppLanguage.russian.rawValue == "ru") + } + + @Test + func `app language catalog includes Korean`() { + #expect(AppLanguage.allCases.contains(.korean)) + #expect(AppLanguage.korean.rawValue == "ko") + } + + @Test + func `app language catalog includes Turkish`() { + #expect(AppLanguage.allCases.contains(.turkish)) + #expect(AppLanguage.turkish.rawValue == "tr") + } + + @Test + func `app language catalog includes Italian`() { + #expect(AppLanguage.allCases.contains(.italian)) + #expect(AppLanguage.italian.rawValue == "it") + } + + @Test + func `app language catalog includes Indonesian`() { + #expect(AppLanguage.allCases.contains(.indonesian)) + #expect(AppLanguage.indonesian.rawValue == "id") + } + + @Test + func `app language catalog includes Polish`() { + #expect(AppLanguage.allCases.contains(.polish)) + #expect(AppLanguage.polish.rawValue == "pl") + } + + @Test + func `app language catalog includes Arabic Persian and Thai`() { + #expect(AppLanguage.arabic.rawValue == "ar") + #expect(AppLanguage.persian.rawValue == "fa") + #expect(AppLanguage.thai.rawValue == "th") + } + + @Test + func `app language catalog includes Galician`() { + #expect(AppLanguage.allCases.contains(.galician)) + #expect(AppLanguage.galician.rawValue == "gl") + } + + @Test + func `adaptive activity consent is localized in every app language`() throws { + let root = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + let resourcesURL = root.appendingPathComponent("Sources/CodexBar/Resources") + let keys = [ + "refresh_adaptive_agent_aware", + "adaptive_activity_consent_title", + "adaptive_activity_consent_message", + "adaptive_activity_consent_allow", + "adaptive_activity_consent_decline", + ] + + for language in AppLanguage.allCases where language != .system { + let url = resourcesURL.appendingPathComponent("\(language.rawValue).lproj/Localizable.strings") + let catalog = try #require(NSDictionary(contentsOf: url) as? [String: String]) + for key in keys { + #expect(catalog[key]?.isEmpty == false, "\(language.rawValue).\(key)") + } + } + } + + @Test + func `language picker labels use stable native names`() { + let expected: [AppLanguage: String] = [ + .system: "System", + .english: "English", + .chineseSimplified: "简体中文", + .chineseTraditional: "繁體中文", + .japanese: "日本語", + .spanish: "Español", + .portugueseBrazilian: "Português (Brasil)", + .korean: "한국어", + .german: "Deutsch", + .french: "Français", + .arabic: "العربية", + .italian: "Italiano", + .vietnamese: "Tiếng Việt", + .dutch: "Nederlands", + .turkish: "Türkçe", + .ukrainian: "Українська", + .russian: "Русский", + .indonesian: "Bahasa Indonesia", + .polish: "Polski", + .persian: "فارسی", + .thai: "ไทย", + .galician: "Galego", + .catalan: "Català", + .swedish: "Svenska", + ] + + #expect(expected.count == AppLanguage.allCases.count) + + let japaneseLabels = CodexBarLocalizationOverride.$appLanguage.withValue("ja") { + Dictionary(uniqueKeysWithValues: AppLanguage.allCases.map { ($0, $0.label) }) + } + let arabicLabels = CodexBarLocalizationOverride.$appLanguage.withValue("ar") { + Dictionary(uniqueKeysWithValues: AppLanguage.allCases.map { ($0, $0.label) }) + } + + #expect(japaneseLabels == expected) + #expect(arabicLabels == expected) + } + + @Test + func `system language preserves an external Apple Languages override`() { + Self.withTemporaryDefaults(for: #function) { defaults, _ in + defaults.set(["de"], forKey: "AppleLanguages") + + AppLanguagePreferenceMigration.clearLegacyOverrideIfOwned( + storedAppLanguage: "", + defaults: defaults) + + #expect(defaults.stringArray(forKey: "AppleLanguages") == ["de"]) + } + } + + @Test + func `matching legacy language override is cleared`() { + Self.withTemporaryDefaults(for: #function) { defaults, suiteName in + defaults.set(["ja"], forKey: "AppleLanguages") + + AppLanguagePreferenceMigration.clearLegacyOverrideIfOwned( + storedAppLanguage: "ja", + defaults: defaults) + + #expect(defaults.persistentDomain(forName: suiteName)?["AppleLanguages"] == nil) + } + } + + @Test + func `unrelated external language override is preserved`() { + Self.withTemporaryDefaults(for: #function) { defaults, _ in + defaults.set(["de"], forKey: "AppleLanguages") + + AppLanguagePreferenceMigration.clearLegacyOverrideIfOwned( + storedAppLanguage: "ja", + defaults: defaults) + + #expect(defaults.stringArray(forKey: "AppleLanguages") == ["de"]) + } + } + + @Test + func `new language bundles include representative native labels`() throws { + let root = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + let resourcesURL = root.appendingPathComponent("Sources/CodexBar/Resources") + let expectations: [String: [String: String]] = [ + "ar": [ + "language_arabic": "العربية", + "tab_general": "عام", + "quit_app": "إنهاء CodexBar", + "usage_percent_suffix_left": "متبقٍ", + ], + "fa": [ + "language_persian": "فارسی", + "tab_general": "عمومی", + "quit_app": "خروج از CodexBar", + "usage_percent_suffix_left": "باقی مانده", + ], + "th": [ + "language_thai": "ไทย", + "tab_general": "ทั่วไป", + "quit_app": "ออกจาก CodexBar", + "usage_percent_suffix_left": "คงเหลือ", + ], + "ru": [ + "language_russian": "Русский", + "tab_general": "Общие", + "quit_app": "Выйти из CodexBar", + "usage_percent_suffix_left": "осталось", + ], + "gl": [ + "language_galician": "Galego", + "tab_general": "Xeral", + "quit_app": "Saír de CodexBar", + "terminal_app_title": "Terminal predeterminado", + "terminal_app_subtitle": "Terminal usado pola acción Abrir terminal", + ], + "ca": [ + "A managed Codex login is already running. Wait for it to finish before adding ": + "Ja hi ha un inici de sessió gestionat de Codex en curs. Espereu que acabi abans d'afegir ", + "%@: %@": "%@: %@", + "Sign in with Claude Code...": "Inicia sessió amb Claude Code...", + "keychain_access_caption": + "Desactiveu totes les lectures i escriptures del Clauer. " + + "Feu-ho si macOS continua mostrant sol·licituds de «Chrome/Brave/Edge Safe Storage» " + + "fins i tot després de triar «Permet sempre». La importació de galetes del navegador no " + + "estarà disponible mentre aquesta opció estigui activada; enganxeu manualment les " + + "capçaleres Cookie a Proveïdors. L'OAuth de Claude/Codex mitjançant la CLI continuarà funcionant.", + "language_catalan": "Català", + "menu_bar_metric_subtitle_mistral": + "Trieu entre la despesa de l'API de Mistral i l'ús del Monthly Plan per a la barra de menús.", + "quota_warning_notifications_subtitle": + "Avisa quan la quota restant de sessió o setmanal baixa per sota dels llindars configurats.", + "refresh_on_open_subtitle": + "Obté l'ús més recent de cada proveïdor cada vegada que obriu el menú.", + ], + ] + + for (locale, expectedValues) in expectations { + let url = resourcesURL.appendingPathComponent("\(locale).lproj/Localizable.strings") + let catalog = try #require(NSDictionary(contentsOf: url) as? [String: String]) + for (key, expectedValue) in expectedValues { + #expect(catalog[key] == expectedValue, "\(locale).\(key)") + } + } + } + + @Test + func `german manual action labels do not describe a handbook`() throws { + let root = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + let germanURL = root.appendingPathComponent("Sources/CodexBar/Resources/de.lproj/Localizable.strings") + let german = try #require(NSDictionary(contentsOf: germanURL) as? [String: String]) + + #expect(german["Manual"] == "Manuell") + #expect(german["refresh_manual"] == "Manuell") + } + + @Test + func `galician localization matches the English catalog`() throws { + let root = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + let resourcesURL = root.appendingPathComponent("Sources/CodexBar/Resources") + let englishURL = resourcesURL.appendingPathComponent("en.lproj/Localizable.strings") + let galicianURL = resourcesURL.appendingPathComponent("gl.lproj/Localizable.strings") + let english = try #require(NSDictionary(contentsOf: englishURL) as? [String: String]) + let galician = try #require(NSDictionary(contentsOf: galicianURL) as? [String: String]) + + #expect(Set(galician.keys) == Set(english.keys)) + } + + @Test + func `model breakdown unavailable exists in every app catalog`() throws { + let root = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + let resourcesURL = root.appendingPathComponent("Sources/CodexBar/Resources") + let catalogs = try FileManager.default.contentsOfDirectory( + at: resourcesURL, + includingPropertiesForKeys: nil) + .filter { $0.pathExtension == "lproj" } + + #expect(catalogs.count == 23) + for catalogURL in catalogs { + let stringsURL = catalogURL.appendingPathComponent("Localizable.strings") + let catalog = try #require(NSDictionary(contentsOf: stringsURL) as? [String: String]) + let value = try #require(catalog["Model breakdown unavailable"]) + #expect(!value.isEmpty, "\(catalogURL.lastPathComponent)") + #expect(!value.contains("%"), "\(catalogURL.lastPathComponent)") + if catalogURL.lastPathComponent == "en.lproj" { + #expect(value == "Model breakdown unavailable") + } + } + } + + @Test + func `catalan localization matches the English catalog`() throws { + let root = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + let resourcesURL = root.appendingPathComponent("Sources/CodexBar/Resources") + let englishURL = resourcesURL.appendingPathComponent("en.lproj/Localizable.strings") + let catalanURL = resourcesURL.appendingPathComponent("ca.lproj/Localizable.strings") + let english = try #require(NSDictionary(contentsOf: englishURL) as? [String: String]) + let catalan = try #require(NSDictionary(contentsOf: catalanURL) as? [String: String]) + + #expect(Set(catalan.keys) == Set(english.keys)) + let statusFormat = try #require(catalan["%@: %@"]) + #expect(String(format: statusFormat, "Quota", "42") == "Quota: 42") + } + + @Test + func `localized catalogs include every app language label`() throws { + #expect(self.languageKeys.count == AppLanguage.allCases.count) + + let root = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + let resourcesURL = root.appendingPathComponent("Sources/CodexBar/Resources") + let catalogs = try FileManager.default.contentsOfDirectory( + at: resourcesURL, + includingPropertiesForKeys: nil) + .filter { $0.pathExtension == "lproj" } + + for catalogURL in catalogs { + let stringsURL = catalogURL.appendingPathComponent("Localizable.strings") + let contents = try String(contentsOf: stringsURL, encoding: .utf8) + for key in self.languageKeys { + #expect(contents.contains("\"\(key)\""), "Missing \(key) in \(catalogURL.lastPathComponent)") + } + } + } + + @Test + func `localized catalogs include workday pace setting copy`() throws { + let root = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + let resourcesURL = root.appendingPathComponent("Sources/CodexBar/Resources") + let catalogs = try FileManager.default.contentsOfDirectory( + at: resourcesURL, + includingPropertiesForKeys: nil) + .filter { $0.pathExtension == "lproj" } + + for catalogURL in catalogs { + let stringsURL = catalogURL.appendingPathComponent("Localizable.strings") + let catalog = try #require(NSDictionary(contentsOf: stringsURL) as? [String: String]) + let title = catalog["weekly_progress_work_days_title"]?.trimmingCharacters(in: .whitespacesAndNewlines) + let subtitle = catalog["weekly_progress_work_days_subtitle"]? + .trimmingCharacters(in: .whitespacesAndNewlines) + + #expect(title?.isEmpty == false, "Missing workday title in \(catalogURL.lastPathComponent)") + #expect(subtitle?.isEmpty == false, "Missing workday subtitle in \(catalogURL.lastPathComponent)") + } + } + + @Test + func `localized catalogs include default terminal setting copy`() throws { + let root = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + let resourcesURL = root.appendingPathComponent("Sources/CodexBar/Resources") + let catalogs = try FileManager.default.contentsOfDirectory( + at: resourcesURL, + includingPropertiesForKeys: nil) + .filter { $0.pathExtension == "lproj" } + + for catalogURL in catalogs { + let stringsURL = catalogURL.appendingPathComponent("Localizable.strings") + let catalog = try #require(NSDictionary(contentsOf: stringsURL) as? [String: String]) + let title = catalog["terminal_app_title"]?.trimmingCharacters(in: .whitespacesAndNewlines) + let subtitle = catalog["terminal_app_subtitle"]?.trimmingCharacters(in: .whitespacesAndNewlines) + + #expect(title?.isEmpty == false, "Missing default terminal title in \(catalogURL.lastPathComponent)") + #expect(subtitle?.isEmpty == false, "Missing default terminal subtitle in \(catalogURL.lastPathComponent)") + } + } + + @Test + func `ukrainian localization bundle exists and contains key UI labels`() throws { + let root = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + let ukURL = root.appendingPathComponent("Sources/CodexBar/Resources/uk.lproj/Localizable.strings") + let contents = try String(contentsOf: ukURL, encoding: .utf8) + + let requiredKeys = [ + "\"language_title\"", + "\"language_subtitle\"", + "\"language_system\"", + "\"language_ukrainian\"", + "\"tab_general\"", + "\"quit_app\"", + ] + for key in requiredKeys { + #expect(contents.contains(key), "Missing localization key: \(key)") + } + } + + @Test + func `korean localization bundle includes representative native labels`() throws { + let root = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + let koURL = root.appendingPathComponent("Sources/CodexBar/Resources/ko.lproj/Localizable.strings") + let catalog = try #require(NSDictionary(contentsOf: koURL) as? [String: String]) + + #expect(catalog["language_korean"] == "한국어") + #expect(catalog["tab_general"] == "일반") + #expect(catalog["quota_warning_session"] == "세션") + #expect(catalog["quota_warning_warn_at"] == "경고 기준") + #expect(catalog["quit_app"] == "CodexBar 종료") + } + + @Test + func `turkish localization matches English catalog and preserves format placeholders`() throws { + let root = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + let resourcesURL = root.appendingPathComponent("Sources/CodexBar/Resources") + let enURL = resourcesURL.appendingPathComponent("en.lproj/Localizable.strings") + let trURL = resourcesURL.appendingPathComponent("tr.lproj/Localizable.strings") + let english = try #require(NSDictionary(contentsOf: enURL) as? [String: String]) + let turkish = try #require(NSDictionary(contentsOf: trURL) as? [String: String]) + + #expect(Set(turkish.keys) == Set(english.keys)) + #expect(turkish["language_turkish"] == "Türkçe") + #expect(turkish["tab_general"] == "Genel") + #expect(turkish["quit_app"] == "CodexBar'dan Çık") + #expect(turkish["display_mode_percent_desc"]?.contains("%45") == true) + #expect(turkish["session_depleted_notification_body"]?.hasPrefix("0% kaldı.") == true) + + let format = try #require(turkish["quota_warning_notification_body"]) + let rendered = String( + format: format, + locale: Locale(identifier: "tr_TR"), + arguments: ["%20", 15, "oturum"]) + #expect(rendered.contains("15%")) + #expect(!rendered.contains("%2$d")) + + let historyFormat = try #require(turkish["%@: %@%% used"]) + let historyLabel = String( + format: historyFormat, + locale: Locale(identifier: "tr_TR"), + arguments: ["12 Haz", "45"]) + #expect(historyLabel == "12 Haz: 45% kullanıldı") + + let miniMaxFormat = try #require(turkish["minimax_used_percent_format"]) + let miniMaxLabel = String( + format: miniMaxFormat, + locale: Locale(identifier: "tr_TR"), + arguments: ["45%"]) + #expect(miniMaxLabel == "45% kullanıldı") + } + + @Test + func `italian localization matches English catalog and includes current UI labels`() throws { + let root = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + let resourcesURL = root.appendingPathComponent("Sources/CodexBar/Resources") + let enURL = resourcesURL.appendingPathComponent("en.lproj/Localizable.strings") + let itURL = resourcesURL.appendingPathComponent("it.lproj/Localizable.strings") + let english = try #require(NSDictionary(contentsOf: enURL) as? [String: String]) + let italian = try #require(NSDictionary(contentsOf: itURL) as? [String: String]) + + #expect(Set(italian.keys) == Set(english.keys)) + #expect(italian["Individual credits"] == "Crediti individuali") + #expect(italian["Workspace"] == "Spazio di lavoro") + #expect(italian["display_mode_reset_time"] == "Ora di reimpostazione") + #expect(italian["display_mode_reset_time_desc"]?.contains("↻ 15:56") == true) + #expect(italian["ory_session_…=…; csrftoken=…"] == "ory_session_…=…; csrftoken=…") + #expect(italian["quota_warning_notifications_subtitle"]?.contains("scende sotto") == true) + #expect(italian["metric_mistral_payg"] == "A consumo") + #expect(italian["metric_mistral_monthly_plan"] == "Piano mensile") + + let intentionallyUnchanged: Set = [ + "Account", + "Build", + "Chrome", + "Cookie: ...", + "Cookie: …", + "Deployment", + "Email", + "Endpoint", + "Gemini Flash", + "GitHub", + "Google OAuth", + "No", + "Oasis-Token", + "Password", + "Provider", + "Token", + "%@ %@", + "%@: %@", + "byte_unit_byte", + "byte_unit_gigabyte", + "byte_unit_kilobyte", + "byte_unit_megabyte", + "hooks_executable_placeholder", + "hooks_provider", + "hooks_threshold_placeholder", + "language_arabic", + "language_galician", + "language_italian", + "language_persian", + "language_russian", + "language_thai", + "link_email", + "link_github", + "menu_bar_layout_sample_account", + "menu_bar_layout_token_account", + "ory_session_…=…; csrftoken=…", + "section_privacy", + "session_quota_estimate_value_format", + "tab_menu", + ] + let unchanged = Set(english.keys.filter { italian[$0] == english[$0] }) + #expect(unchanged == intentionallyUnchanged) + + let warningFormat = try #require(italian["quota_warning_notification_body"]) + let warning = String( + format: warningFormat, + locale: Locale(identifier: "it_IT"), + arguments: ["20%", 15, "settimanale"]) + #expect(warning == "Rimane 20%. Hai raggiunto la soglia di avviso del 15% per la quota settimanale.") + + let titleFormat = try #require(italian["quota_warning_notification_title"]) + let title = String( + format: titleFormat, + locale: Locale(identifier: "it_IT"), + arguments: ["Codex", "settimanale"]) + #expect(title == "Quota settimanale di Codex quasi esaurita") + } + + @Test + func `indonesian localization matches English catalog and preserves format placeholders`() throws { + let root = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + let resourcesURL = root.appendingPathComponent("Sources/CodexBar/Resources") + let enURL = resourcesURL.appendingPathComponent("en.lproj/Localizable.strings") + let idURL = resourcesURL.appendingPathComponent("id.lproj/Localizable.strings") + let english = try #require(NSDictionary(contentsOf: enURL) as? [String: String]) + let indonesian = try #require(NSDictionary(contentsOf: idURL) as? [String: String]) + + #expect(Set(indonesian.keys) == Set(english.keys)) + #expect(indonesian["language_indonesian"] == "Bahasa Indonesia") + #expect(indonesian["tab_general"] == "Umum") + #expect(indonesian["quit_app"] == "Keluar CodexBar") + #expect(indonesian["30d"] == "30 hari") + #expect(indonesian["On"] == "Aktif") + #expect(indonesian["Off"] == "Nonaktif") + + let warningFormat = try #require(indonesian["quota_warning_notification_body"]) + let warning = String( + format: warningFormat, + locale: Locale(identifier: "id_ID"), + arguments: ["20%", 15, "sesi"]) + #expect(warning.contains("15%")) + #expect(!warning.contains("%2$d")) + + let historyFormat = try #require(indonesian["%@: %@%% used"]) + let historyLabel = String( + format: historyFormat, + locale: Locale(identifier: "id_ID"), + arguments: ["12 Jun", "45"]) + #expect(historyLabel == "12 Jun: 45% terpakai") + + let daysFormat = try #require(indonesian["%dd"]) + let daysLabel = String( + format: daysFormat, + locale: Locale(identifier: "id_ID"), + arguments: [30]) + #expect(daysLabel == "30 hari") + } + + @Test + func `polish localization matches English catalog and includes current UI labels`() throws { + let root = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + let resourcesURL = root.appendingPathComponent("Sources/CodexBar/Resources") + let enURL = resourcesURL.appendingPathComponent("en.lproj/Localizable.strings") + let plURL = resourcesURL.appendingPathComponent("pl.lproj/Localizable.strings") + let english = try #require(NSDictionary(contentsOf: enURL) as? [String: String]) + let polish = try #require(NSDictionary(contentsOf: plURL) as? [String: String]) + + #expect(Set(polish.keys) == Set(english.keys)) + #expect(polish["Individual credits"] == "Kredyty indywidualne") + #expect(polish["Workspace"] == "Obszar roboczy") + #expect(polish["display_mode_reset_time"] == "Godzina resetu") + #expect(polish["display_mode_reset_time_desc"]?.contains("↻ 15:56") == true) + } + + @Test + func `japanese usage chart accessibility text preserves argument meanings`() throws { + let root = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + let jaURL = root.appendingPathComponent("Sources/CodexBar/Resources/ja.lproj/Localizable.strings") + let catalog = try #require(NSDictionary(contentsOf: jaURL) as? [String: String]) + let format = try #require(catalog["%d days of usage data across %d services"]) + + let rendered = String( + format: format, + locale: Locale(identifier: "ja_JP"), + arguments: [7, 3]) + + #expect(rendered.contains("7日間")) + #expect(rendered.contains("3サービス")) + } + + @Test + func `korean usage chart accessibility text preserves argument meanings`() throws { + let root = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + let koURL = root.appendingPathComponent("Sources/CodexBar/Resources/ko.lproj/Localizable.strings") + let catalog = try #require(NSDictionary(contentsOf: koURL) as? [String: String]) + let format = try #require(catalog["%d days of usage data across %d services"]) + + let rendered = String( + format: format, + locale: Locale(identifier: "ko_KR"), + arguments: [7, 3]) + + #expect(rendered.contains("7일간")) + #expect(rendered.contains("3개 서비스")) + } + + private static func withTemporaryDefaults( + for testName: String, + _ body: (UserDefaults, String) -> Void) + { + let suiteName = "LocalizationLanguageCatalogTests.\(testName).\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suiteName)! + defaults.removePersistentDomain(forName: suiteName) + defer { defaults.removePersistentDomain(forName: suiteName) } + body(defaults, suiteName) + } +} diff --git a/Tests/CodexBarTests/LockIsolated.swift b/Tests/CodexBarTests/LockIsolated.swift new file mode 100644 index 0000000000..69c11aa54a --- /dev/null +++ b/Tests/CodexBarTests/LockIsolated.swift @@ -0,0 +1,30 @@ +import Foundation + +/// A minimal `NSLock`-backed thread-safe box used by the `URLProtocol` test stubs to hold +/// their per-test `handler` closure. +/// +/// The stubs' `handler` is read on URLSession's background thread (`canInit` / `startLoading`) +/// while tests assign it from another thread. Storing it here and exposing `handler` as a +/// computed property over the box serializes both the read and the write without changing any +/// call site. Before this, the stubs used an unsynchronized `nonisolated(unsafe) static var`, +/// which ThreadSanitizer reports as a data race under parallel Swift Testing. +final class LockIsolated: @unchecked Sendable { + private let lock = NSLock() + private var storage: Value + + init(_ value: Value) { + self.storage = value + } + + var value: Value { + self.lock.lock() + defer { self.lock.unlock() } + return self.storage + } + + func setValue(_ value: Value) { + self.lock.lock() + defer { self.lock.unlock() } + self.storage = value + } +} diff --git a/Tests/CodexBarTests/LongCatCLISettingsTests.swift b/Tests/CodexBarTests/LongCatCLISettingsTests.swift new file mode 100644 index 0000000000..1e68b65870 --- /dev/null +++ b/Tests/CodexBarTests/LongCatCLISettingsTests.swift @@ -0,0 +1,38 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBarCLI + +struct LongCatCLISettingsTests { + @Test + func `manual config is carried into CLI settings snapshot`() throws { + let config = CodexBarConfig(providers: [ + ProviderConfig( + id: .longcat, + cookieHeader: "passport_token=manual-token", + cookieSource: .manual), + ]) + let selection = TokenAccountCLISelection(label: nil, index: nil, allAccounts: false) + let tokenContext = try TokenAccountCLIContext(selection: selection, config: config, verbose: false) + let settings = try #require(tokenContext.settingsSnapshot(for: .longcat, account: nil)?.longcat) + + #expect(settings.cookieSource == .manual) + #expect(settings.manualCookieHeader == "passport_token=manual-token") + } + + @Test + func `off config is carried into CLI settings snapshot`() throws { + let config = CodexBarConfig(providers: [ + ProviderConfig( + id: .longcat, + cookieHeader: "passport_token=ignored-token", + cookieSource: .off), + ]) + let selection = TokenAccountCLISelection(label: nil, index: nil, allAccounts: false) + let tokenContext = try TokenAccountCLIContext(selection: selection, config: config, verbose: false) + let settings = try #require(tokenContext.settingsSnapshot(for: .longcat, account: nil)?.longcat) + + #expect(settings.cookieSource == .off) + #expect(settings.manualCookieHeader == "passport_token=ignored-token") + } +} diff --git a/Tests/CodexBarTests/LongCatProviderTests.swift b/Tests/CodexBarTests/LongCatProviderTests.swift new file mode 100644 index 0000000000..be778b70b2 --- /dev/null +++ b/Tests/CodexBarTests/LongCatProviderTests.swift @@ -0,0 +1,413 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct LongCatProviderTests { + // MARK: - Settings reader + + @Test + func `reads LONGCAT_MANUAL_COOKIE`() { + let env = ["LONGCAT_MANUAL_COOKIE": "passport_token=abc; uid=42"] + #expect(LongCatSettingsReader.cookieHeader(environment: env) == "passport_token=abc; uid=42") + } + + @Test + func `reads LONGCAT_API_KEY and trims quotes`() { + #expect(LongCatSettingsReader.apiKey(environment: ["LONGCAT_API_KEY": " \"ak_x\" "]) == "ak_x") + } + + @Test + func `missing env returns nil`() { + #expect(LongCatSettingsReader.cookieHeader(environment: [:]) == nil) + #expect(LongCatSettingsReader.apiKey(environment: [:]) == nil) + } + + @Test + func `cookieHeader reads lowercase alias and trims quotes`() { + // The env path routes through this reader, so the lower-case alias and + // quote-trimming must apply (regression for the env-bypass fix). + #expect(LongCatSettingsReader.cookieHeader(environment: ["longcat_manual_cookie": "'a=b; c=d'"]) == "a=b; c=d") + } + + // MARK: - Cookie header override + + @Test + func `override accepts bare cookie pair string`() { + let override = LongCatCookieHeader.override(from: "passport_token=abc; uid=42") + #expect(override?.cookieHeader == "passport_token=abc; uid=42") + } + + @Test + func `override extracts from a curl Cookie header`() { + let raw = "curl 'https://longcat.chat/api/v1/user-current' -H 'Cookie: passport_token=abc; uid=42'" + let override = LongCatCookieHeader.override(from: raw) + #expect(override?.cookieHeader == "passport_token=abc; uid=42") + } + + @Test + func `override rejects a token-less string`() { + #expect(LongCatCookieHeader.override(from: "not a cookie") == nil) + #expect(LongCatCookieHeader.override(from: " ") == nil) + } + + @Test + func `imported cookies honor request host path secure and expiry scope`() throws { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let cookies = try [ + self.cookie(name: "root", value: "1", domain: "longcat.chat", path: "/"), + self.cookie(name: "scoped", value: "2", domain: ".longcat.chat", path: "/api/v1"), + self.cookie(name: "www", value: "3", domain: "www.longcat.chat", path: "/"), + self.cookie(name: "other", value: "4", domain: "longcat.chat", path: "/platform"), + self.cookie(name: "expired", value: "5", domain: "longcat.chat", path: "/", expires: now - 1), + self.cookie(name: "secure", value: "6", domain: "longcat.chat", path: "/", secure: true), + ] + let secureURL = try #require(URL(string: "https://longcat.chat/api/v1/user-current")) + let insecureURL = try #require(URL(string: "http://longcat.chat/api/v1/user-current")) + + #expect(LongCatCookieHeader.header(from: cookies, for: secureURL, now: now) == "scoped=2; root=1; secure=6") + #expect(LongCatCookieHeader.header(from: cookies, for: insecureURL, now: now) == "scoped=2; root=1") + } + + // MARK: - Snapshot mapping + + @Test + func `total quota maps to primary used percent`() { + let snapshot = LongCatUsageSnapshot(totalQuota: 1000, usedQuota: 250) + let usage = snapshot.toUsageSnapshot() + #expect(usage.identity?.providerID == .longcat) + #expect(abs((usage.primary?.usedPercent ?? 0) - 25) < 0.001) + } + + @Test + func `remaining quota infers used when used is absent`() { + let snapshot = LongCatUsageSnapshot(totalQuota: 1000, remainingQuota: 400) + #expect(abs((snapshot.toUsageSnapshot().primary?.usedPercent ?? 0) - 60) < 0.001) + } + + @Test + func `missing quota data omits primary window`() { + let usage = LongCatUsageSnapshot(fuelPackTotal: 500, fuelPackRemaining: 200).toUsageSnapshot() + #expect(usage.primary == nil) + #expect(usage.secondary != nil) + } + + @Test + func `fuel pack populates secondary window`() { + let snapshot = LongCatUsageSnapshot(fuelPackTotal: 500, fuelPackRemaining: 200) + let usage = snapshot.toUsageSnapshot() + #expect(usage.secondary != nil) + #expect(abs((usage.secondary?.usedPercent ?? 0) - 60) < 0.001) + } + + // MARK: - buildSnapshot against captured live response shapes + + private func object(_ json: String) throws -> [String: Any] { + let parsed = try JSONSerialization.jsonObject(with: Data(json.utf8)) + return try #require(parsed as? [String: Any]) + } + + @Test + func `buildSnapshot maps live tokenUsage and account fields`() throws { + // Shapes captured from longcat.chat console (values neutralised). + let account = try self.object(#"{"userId":1,"name":"LongCat User","phone":"x","token":"secret"}"#) + let tokenUsage = try self.object(#""" + {"usage":{"totalToken":500000,"usedToken":120000,"availableToken":380000,"freeAvailableToken":380000}, + "extData":{"LongCat-Flash-Lite":{"totalToken":50000000,"usedToken":0}}} + """#) + let fuel = try self.object(#"{"totalQuota":0,"list":[]}"#) + + let snapshot = LongCatUsageFetcher.buildSnapshot(account: account, tokenUsage: tokenUsage, pendingFuel: fuel) + #expect(snapshot.accountName == "LongCat User") + #expect(snapshot.totalQuota == 500_000) + #expect(snapshot.usedQuota == 120_000) + #expect(snapshot.remainingQuota == 380_000) + #expect(snapshot.fuelPackTotal == nil) // empty fuel list + + let usage = snapshot.toUsageSnapshot() + #expect(abs((usage.primary?.usedPercent ?? 0) - 24) < 0.001) + #expect(usage.secondary == nil) + } + + @Test + func `buildSnapshot sums active fuel packages`() throws { + let fuel = try self.object(#""" + {"totalQuota":1000,"list":[{"availableToken":600,"expireTime":1750000000000}, + {"availableToken":150,"expireTime":1760000000000}]} + """#) + let snapshot = LongCatUsageFetcher.buildSnapshot(account: nil, tokenUsage: nil, pendingFuel: fuel) + #expect(snapshot.fuelPackTotal == 1000) + #expect(snapshot.fuelPackRemaining == 750) + #expect(snapshot.nearestFuelExpiry != nil) + #expect(snapshot.toUsageSnapshot().primary == nil) + } + + // MARK: - Envelope + + @Test + func `envelope surfaces invalid session on auth code`() { + #expect(throws: LongCatAPIError.invalidSession) { + try LongCatEnvelope.unwrap(["code": 401, "message": "unauthorized"]) + } + } + + @Test + func `envelope unwraps data on success`() throws { + let data = try LongCatEnvelope.unwrap(["code": 0, "data": ["x": 1]]) as? [String: Any] + #expect(data?["x"] as? Int == 1) + } + + // MARK: - Cookie source semantics + + private func context( + env: [String: String], + cookieSource: ProviderCookieSource, + runtime: ProviderRuntime = .app) -> ProviderFetchContext + { + let browserDetection = BrowserDetection(cacheTTL: 0) + return ProviderFetchContext( + runtime: runtime, + sourceMode: .web, + includeCredits: false, + webTimeout: 1, + webDebugDumpHTML: false, + verbose: false, + env: env, + settings: ProviderSettingsSnapshot.make( + longcat: .init(cookieSource: cookieSource, manualCookieHeader: nil)), + fetcher: UsageFetcher(environment: [:]), + claudeFetcher: ClaudeUsageFetcher(browserDetection: browserDetection), + browserDetection: browserDetection) + } + + @Test + func `off source disables env cookie override`() { + let ctx = self.context(env: ["LONGCAT_MANUAL_COOKIE": "a=b"], cookieSource: .off) + #expect(LongCatCookieHeader.resolveCookieOverride(context: ctx) == nil) + } + + @Test + func `auto source allows env cookie override`() { + let ctx = self.context(env: ["LONGCAT_MANUAL_COOKIE": "a=b"], cookieSource: .auto) + #expect(LongCatCookieHeader.resolveCookieOverride(context: ctx)?.cookieHeader == "a=b") + } + + @Test + func `browser import is user initiated app auto only`() { + let appAuto = self.context(env: [:], cookieSource: .auto) + let cliAuto = self.context(env: [:], cookieSource: .auto, runtime: .cli) + let appManual = self.context(env: [:], cookieSource: .manual) + let appOff = self.context(env: [:], cookieSource: .off) + + #expect(LongCatWebFetchStrategy.allowsBrowserImport(context: appAuto) == false) + #expect(LongCatWebFetchStrategy.allowsBrowserImport(context: cliAuto) == false) + + ProviderInteractionContext.$current.withValue(.userInitiated) { + #expect(LongCatWebFetchStrategy.allowsBrowserImport(context: appAuto)) + #expect(LongCatWebFetchStrategy.allowsBrowserImport(context: cliAuto) == false) + #expect(LongCatWebFetchStrategy.allowsBrowserImport(context: appManual) == false) + #expect(LongCatWebFetchStrategy.allowsBrowserImport(context: appOff) == false) + } + } + + #if os(macOS) + @Test + func `browser import tries later profiles after credential failure`() async throws { + let cookie = try self.cookie(name: "session", value: "x", domain: "longcat.chat", path: "/") + let sessions = [ + LongCatCookieImporter.SessionInfo(cookies: [cookie], sourceLabel: "Chrome Profile 1"), + LongCatCookieImporter.SessionInfo(cookies: [cookie], sourceLabel: "Chrome Profile 2"), + ] + var attempts: [String] = [] + + let snapshot = try await LongCatWebFetchStrategy.fetchImportedSessions(sessions) { session in + attempts.append(session.sourceLabel) + if session.sourceLabel == "Chrome Profile 1" { + throw LongCatAPIError.invalidSession + } + return LongCatUsageSnapshot(totalQuota: 100, usedQuota: 10) + } + + #expect(attempts == ["Chrome Profile 1", "Chrome Profile 2"]) + #expect(snapshot.totalQuota == 100) + } + + @Test + func `browser import stops on non-credential failure`() async throws { + let cookie = try self.cookie(name: "session", value: "x", domain: "longcat.chat", path: "/") + let sessions = [ + LongCatCookieImporter.SessionInfo(cookies: [cookie], sourceLabel: "Chrome Profile 1"), + LongCatCookieImporter.SessionInfo(cookies: [cookie], sourceLabel: "Chrome Profile 2"), + ] + var attempts = 0 + + await #expect(throws: LongCatAPIError.apiError("HTTP 500")) { + _ = try await LongCatWebFetchStrategy.fetchImportedSessions(sessions) { _ in + attempts += 1 + throw LongCatAPIError.apiError("HTTP 500") + } + } + #expect(attempts == 1) + } + #endif + + // MARK: - HTTP status handling (fetchUsage over an injected transport) + + @Test + func `fetch surfaces invalid session on 401`() async { + let transport = LongCatScriptedTransport(results: [.status(401)]) + await #expect(throws: LongCatAPIError.invalidSession) { + _ = try await LongCatUsageFetcher.fetchUsage(cookieHeader: "session=x", transport: transport) + } + } + + @Test + func `fetch surfaces invalid session on 403`() async { + let transport = LongCatScriptedTransport(results: [.status(403)]) + await #expect(throws: LongCatAPIError.invalidSession) { + _ = try await LongCatUsageFetcher.fetchUsage(cookieHeader: "session=x", transport: transport) + } + } + + @Test + func `fetch treats a blocked login redirect as invalid session`() async { + // The shared transport's redirect guard drops the cross-origin login hop, so an + // expired cookie surfaces here as a raw 3xx; it must still read as invalid-session. + let transport = LongCatScriptedTransport(results: [.status(302)]) + await #expect(throws: LongCatAPIError.invalidSession) { + _ = try await LongCatUsageFetcher.fetchUsage(cookieHeader: "session=x", transport: transport) + } + } + + @Test + func `fetch maps a full live response over the transport`() async throws { + let transport = LongCatScriptedTransport(results: [ + .body(#"{"code":0,"data":{"name":"Leo"}}"#), + .body(#"{"code":0,"data":{"usage":{"totalToken":500000,"usedToken":120000,"availableToken":380000}}}"#), + .body(#"{"code":0,"data":{"totalQuota":1000,"list":[{"availableToken":600,"expireTime":1750000000000}]}}"#), + ]) + let snapshot = try await LongCatUsageFetcher.fetchUsage(cookieHeader: "session=x", transport: transport) + #expect(snapshot.accountName == "Leo") + #expect(snapshot.totalQuota == 500_000) + #expect(snapshot.usedQuota == 120_000) + #expect(snapshot.fuelPackTotal == 1000) + #expect(snapshot.fuelPackRemaining == 600) + } + + @Test + func `fetch requires the canonical token usage response`() async { + let transport = LongCatScriptedTransport(results: [ + .body(#"{"code":0,"data":{"name":"Leo"}}"#), + .status(500), + ]) + await #expect(throws: LongCatAPIError.apiError("HTTP 500 for /api/lc-platform/v1/tokenUsage")) { + _ = try await LongCatUsageFetcher.fetchUsage(cookieHeader: "session=x", transport: transport) + } + } + + @Test + func `fetch rejects malformed canonical token usage data`() async { + let transport = LongCatScriptedTransport(results: [ + .body(#"{"code":0,"data":{"name":"Leo"}}"#), + .body(#"{"code":0,"data":[]}"#), + ]) + await #expect(throws: LongCatAPIError.parseFailed("tokenUsage data was not an object")) { + _ = try await LongCatUsageFetcher.fetchUsage(cookieHeader: "session=x", transport: transport) + } + } + + @Test + func `fetch rejects canonical token usage without quota fields`() async { + let transport = LongCatScriptedTransport(results: [ + .body(#"{"code":0,"data":{"name":"Leo"}}"#), + .body(#"{"code":0,"data":{"usage":{"usedToken":120000}}}"#), + ]) + await #expect(throws: LongCatAPIError.parseFailed("tokenUsage data was missing totalToken")) { + _ = try await LongCatUsageFetcher.fetchUsage(cookieHeader: "session=x", transport: transport) + } + } + + @Test + func `supplemental fuel failures do not erase primary quota`() async throws { + let transport = LongCatScriptedTransport(results: [ + .body(#"{"code":0,"data":{"name":"Leo"}}"#), + .body(#"{"code":0,"data":{"usage":{"totalToken":500000,"usedToken":120000}}}"#), + .status(500), + ]) + let snapshot = try await LongCatUsageFetcher.fetchUsage(cookieHeader: "session=x", transport: transport) + #expect(snapshot.totalQuota == 500_000) + #expect(snapshot.usedQuota == 120_000) + #expect(snapshot.fuelPackTotal == nil) + } + + @Test + func `supplemental fuel auth failure does not erase primary quota`() async throws { + let transport = LongCatScriptedTransport(results: [ + .body(#"{"code":0,"data":{"name":"Leo"}}"#), + .body(#"{"code":0,"data":{"usage":{"totalToken":500000,"usedToken":120000}}}"#), + .status(401), + ]) + let snapshot = try await LongCatUsageFetcher.fetchUsage(cookieHeader: "session=x", transport: transport) + #expect(snapshot.totalQuota == 500_000) + #expect(snapshot.usedQuota == 120_000) + #expect(snapshot.fuelPackTotal == nil) + } + + private func cookie( + name: String, + value: String, + domain: String, + path: String, + expires: Date? = nil, + secure: Bool = false) throws -> HTTPCookie + { + var properties: [HTTPCookiePropertyKey: Any] = [ + .name: name, + .value: value, + .domain: domain, + .path: path, + ] + if let expires { + properties[.expires] = expires + } + if secure { + properties[.secure] = "TRUE" + } + return try #require(HTTPCookie(properties: properties)) + } +} + +/// Scripted transport for exercising `LongCatUsageFetcher.fetchUsage` HTTP paths +/// without a network. Returns the given results in order; an exhausted script +/// yields an empty 200 so best-effort follow-up probes decode to nil. +private actor LongCatScriptedTransport: ProviderHTTPTransport { + enum Result { + case status(Int) + case body(String) + } + + private var results: [Result] + + init(results: [Result]) { + self.results = results + } + + func data(for request: URLRequest) throws -> (Data, URLResponse) { + let result = self.results.isEmpty ? .status(200) : self.results.removeFirst() + let statusCode: Int + let body: String + switch result { + case let .status(code): + statusCode = code + body = "" + case let .body(text): + statusCode = 200 + body = text + } + let response = HTTPURLResponse( + url: request.url!, + statusCode: statusCode, + httpVersion: nil, + headerFields: nil)! + return (Data(body.utf8), response) + } +} diff --git a/Tests/CodexBarTests/MainThreadHangWatchdogTests.swift b/Tests/CodexBarTests/MainThreadHangWatchdogTests.swift new file mode 100644 index 0000000000..ed18749e94 --- /dev/null +++ b/Tests/CodexBarTests/MainThreadHangWatchdogTests.swift @@ -0,0 +1,196 @@ +import Foundation +import Testing +@testable import CodexBar + +#if DEBUG +@Suite(.serialized) +struct MainThreadHangWatchdogTests { + @MainActor + @Test + func `breadcrumb tracks nested activity`() { + #expect(MainThreadActivityBreadcrumb.current == nil) + MainThreadActivityBreadcrumb.push("outer") + MainThreadActivityBreadcrumb.push("inner") + #expect(MainThreadActivityBreadcrumb.current == "inner") + MainThreadActivityBreadcrumb.pop() + #expect(MainThreadActivityBreadcrumb.current == "outer") + MainThreadActivityBreadcrumb.pop() + #expect(MainThreadActivityBreadcrumb.current == nil) + } + + @Test + func `watchdog reports a breadcrumb for a delayed main thread response`() throws { + let watchdog = MainThreadHangWatchdog( + pingInterval: 0.01, + hangThreshold: 0.05, + sampleThreshold: 60, + sampleCooldown: 3600) + + let reported = OSAllocatedBox<[(TimeInterval, [String])]>([]) + watchdog.onHangForTesting = { duration, activities in + reported.append((duration, activities)) + } + + MainThreadActivityBreadcrumb.push("testStall") + watchdog.traceHangForTesting(responseDelay: 0.25) + MainThreadActivityBreadcrumb.pop() + + let report = try #require(reported.get().first) + #expect(report.1.contains("testStall")) + #expect(report.0 >= 0.05) + } + + @Test + func `watchdog polling loop reports a delayed ping response`() { + let pingScheduled = DispatchSemaphore(value: 0) + let hangDetected = DispatchSemaphore(value: 0) + let reported = DispatchSemaphore(value: 0) + let pendingResponse = OSAllocatedBox<(@Sendable () -> Void)?>(nil) + let watchdog = MainThreadHangWatchdog( + pingInterval: 1, + hangThreshold: 0.02, + sampleThreshold: 60, + sampleCooldown: 3600, + schedulePing: { response in + pendingResponse.set(response) + pingScheduled.signal() + }) + watchdog.onHangForTesting = { _, _ in + reported.signal() + } + watchdog.onHangDetectionForTesting = { + hangDetected.signal() + } + + watchdog.start() + defer { watchdog.stop() } + + #expect(pingScheduled.wait(timeout: .now() + 10) == .success) + #expect(hangDetected.wait(timeout: .now() + 10) == .success) + pendingResponse.get()?() + #expect(reported.wait(timeout: .now() + 10) == .success) + } + + @Test + func `sample capture cannot inflate reported hang duration`() throws { + let sampleRequested = OSAllocatedBox(false) + let watchdog = MainThreadHangWatchdog( + pingInterval: 0.01, + hangThreshold: 0.03, + sampleThreshold: 0.05, + sampleCooldown: 3600, + sampleCaptureOverride: { + sampleRequested.set(true) + Thread.sleep(forTimeInterval: 1) + return "/tmp/codexbar-watchdog-test-sample.txt" + }) + + let reported = OSAllocatedBox<[(TimeInterval, [String])]>([]) + watchdog.onHangForTesting = { duration, activities in + reported.append((duration, activities)) + } + + MainThreadActivityBreadcrumb.push("sampledStall") + watchdog.traceHangForTesting(responseDelay: 0.05, waitForSampleAttempt: true) + MainThreadActivityBreadcrumb.pop() + + let report = try #require(reported.get().first) + #expect(sampleRequested.get()) + #expect(report.1.contains("sampledStall")) + #expect(report.0 >= 0.03) + #expect(report.0 < 0.75) + } + + @Test + func `failed sample capture is attempted once per hang`() { + let attempts = OSAllocatedBox(0) + let watchdog = MainThreadHangWatchdog( + pingInterval: 0.01, + hangThreshold: 0.01, + sampleThreshold: 0, + sampleCooldown: 3600, + sampleCaptureOverride: { + attempts.withValue { $0 += 1 } + return nil + }) + + watchdog.traceHangForTesting(responseDelay: 0.05, waitForSampleAttempt: true) + + #expect(attempts.get() == 1) + } + + @Test + func `missed sample window does not consume cooldown`() { + let attempts = OSAllocatedBox(0) + let watchdog = MainThreadHangWatchdog( + pingInterval: 0.01, + hangThreshold: 0.01, + sampleThreshold: 0.02, + sampleCooldown: 3600, + sampleCaptureOverride: { + attempts.withValue { $0 += 1 } + return nil + }) + + watchdog.traceHangForTesting(responseDelay: 0.05, responseBeforeTrace: true) + #expect(attempts.get() == 0) + + watchdog.traceHangForTesting(responseDelay: 0.05, waitForSampleAttempt: true) + #expect(attempts.get() == 1) + } + + @Test + func `cooldown blocked hang samples when cooldown expires`() { + let attempts = OSAllocatedBox(0) + let watchdog = MainThreadHangWatchdog( + pingInterval: 0.01, + hangThreshold: 0.01, + sampleThreshold: 0, + sampleCooldown: 0.2, + sampleCaptureOverride: { + attempts.withValue { $0 += 1 } + return nil + }) + + watchdog.traceHangForTesting(responseDelay: 0.05, waitForSampleAttempt: true) + watchdog.traceHangForTesting(responseDelay: 1) + + #expect(attempts.get() == 2) + } +} +#endif + +private final class OSAllocatedBox: @unchecked Sendable { + private let lock = NSLock() + private var value: T + + init(_ value: T) { + self.value = value + } + + func set(_ newValue: T) { + self.lock.lock() + self.value = newValue + self.lock.unlock() + } + + func get() -> T { + self.lock.lock() + defer { self.lock.unlock() } + return self.value + } +} + +extension OSAllocatedBox { + func append(_ element: Element) where T == [Element] { + self.lock.lock() + self.value.append(element) + self.lock.unlock() + } + + func withValue(_ body: (inout T) -> Void) { + self.lock.lock() + body(&self.value) + self.lock.unlock() + } +} diff --git a/Tests/CodexBarTests/ManagedCodexAccountCoordinatorTests.swift b/Tests/CodexBarTests/ManagedCodexAccountCoordinatorTests.swift index 5841ace2ed..9e042d7ebb 100644 --- a/Tests/CodexBarTests/ManagedCodexAccountCoordinatorTests.swift +++ b/Tests/CodexBarTests/ManagedCodexAccountCoordinatorTests.swift @@ -38,6 +38,34 @@ struct ManagedCodexAccountCoordinatorTests { #expect(coordinator.isAuthenticatingManagedAccount == false) #expect(coordinator.authenticatingManagedAccountID == nil) } + + @Test + func `coordinator clears in flight state after managed login timeout`() async throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + + let loginResult = CodexLoginRunner.Result(outcome: .timedOut, output: "timed out") + let existingAccountID = try #require(UUID(uuidString: "AAAAAAAA-BBBB-CCCC-DDDD-222222222222")) + let service = ManagedCodexAccountService( + store: InMemoryManagedCodexAccountStoreForCoordinatorTests( + accounts: ManagedCodexAccountSet(version: 1, accounts: [])), + homeFactory: CoordinatorTestManagedCodexHomeFactory(root: root), + loginRunner: TimedOutManagedCodexLoginRunner(result: loginResult), + identityReader: CoordinatorStubManagedCodexIdentityReader(email: "user@example.com")) + let coordinator = ManagedCodexAccountCoordinator(service: service) + + do { + _ = try await coordinator.authenticateManagedAccount(existingAccountID: existingAccountID, timeout: 0.2) + Issue.record("Expected managed login timeout to throw") + } catch let error as ManagedCodexAccountServiceError { + #expect(error == .loginFailed(loginResult)) + } catch { + Issue.record("Expected ManagedCodexAccountServiceError.loginFailed, got \(error)") + } + + #expect(coordinator.isAuthenticatingManagedAccount == false) + #expect(coordinator.authenticatingManagedAccountID == nil) + } } private actor BlockingManagedCodexLoginRunner: ManagedCodexLoginRunning { @@ -68,6 +96,14 @@ private actor BlockingManagedCodexLoginRunner: ManagedCodexLoginRunning { } } +private struct TimedOutManagedCodexLoginRunner: ManagedCodexLoginRunning { + let result: CodexLoginRunner.Result + + func run(homePath _: String, timeout _: TimeInterval) async -> CodexLoginRunner.Result { + self.result + } +} + private final class InMemoryManagedCodexAccountStoreForCoordinatorTests: ManagedCodexAccountStoring, @unchecked Sendable { var snapshot: ManagedCodexAccountSet diff --git a/Tests/CodexBarTests/ManusProviderTests.swift b/Tests/CodexBarTests/ManusProviderTests.swift index be69fdf519..6085ee344e 100644 --- a/Tests/CodexBarTests/ManusProviderTests.swift +++ b/Tests/CodexBarTests/ManusProviderTests.swift @@ -123,35 +123,35 @@ struct ManusProviderTests { @Test func `environment token does not populate browser cache`() async throws { try await self.withIsolatedCacheStore { + let operation: () async throws -> Void = { + let strategy = ManusWebFetchStrategy() + let settings = ProviderSettingsSnapshot.make( + manus: ProviderSettingsSnapshot.ManusProviderSettings( + cookieSource: .auto, + manualCookieHeader: nil)) + let context = self.makeContext( + settings: settings, + env: ["MANUS_SESSION_TOKEN": "env-token"]) + let fetchOverride: @Sendable (String, Date) async throws -> ManusCreditsResponse = { token, _ in + #expect(token == "env-token") + return self.stubResponse() + } + + _ = try await ManusUsageFetcher.$fetchCreditsOverride.withValue(fetchOverride, operation: { + try await strategy.fetch(context) + }) + + #expect(CookieHeaderCache.load(provider: .manus) == nil) + } #if os(macOS) - ManusCookieImporter.importSessionsOverrideForTesting = { _, _ in + try await ManusCookieImporter.withImportSessionsOverrideForTesting { _, _ in throw ManusCookieImportError.noCookies + } operation: { + try await operation() } - ManusCookieImporter.importSessionOverrideForTesting = nil - defer { - ManusCookieImporter.importSessionsOverrideForTesting = nil - ManusCookieImporter.importSessionOverrideForTesting = nil - } + #else + try await operation() #endif - - let strategy = ManusWebFetchStrategy() - let settings = ProviderSettingsSnapshot.make( - manus: ProviderSettingsSnapshot.ManusProviderSettings( - cookieSource: .auto, - manualCookieHeader: nil)) - let context = self.makeContext( - settings: settings, - env: ["MANUS_SESSION_TOKEN": "env-token"]) - let fetchOverride: @Sendable (String, Date) async throws -> ManusCreditsResponse = { token, _ in - #expect(token == "env-token") - return self.stubResponse() - } - - _ = try await ManusUsageFetcher.$fetchCreditsOverride.withValue(fetchOverride, operation: { - try await strategy.fetch(context) - }) - - #expect(CookieHeaderCache.load(provider: .manus) == nil) } } @@ -166,39 +166,34 @@ struct ManusProviderTests { .value: "browser-token", .secure: "TRUE", ])) - ManusCookieImporter.importSessionOverrideForTesting = { _, _ in + try await ManusCookieImporter.withImportSessionOverrideForTesting { _, _ in ManusCookieImporter.SessionInfo(cookies: [browserCookie], sourceLabel: "Chrome") - } - ManusCookieImporter.importSessionsOverrideForTesting = nil - defer { - ManusCookieImporter.importSessionOverrideForTesting = nil - ManusCookieImporter.importSessionsOverrideForTesting = nil - } - - let attempts = LockedArray() - let strategy = ManusWebFetchStrategy() - let settings = ProviderSettingsSnapshot.make( - manus: ProviderSettingsSnapshot.ManusProviderSettings( - cookieSource: .auto, - manualCookieHeader: nil)) - let context = self.makeContext( - settings: settings, - env: ["MANUS_SESSION_TOKEN": "env-token"]) - let fetchOverride: @Sendable (String, Date) async throws -> ManusCreditsResponse = { token, _ in - attempts.append(token) - if token == "browser-token" { - throw ManusAPIError.invalidToken + } operation: { + let attempts = LockedArray() + let strategy = ManusWebFetchStrategy() + let settings = ProviderSettingsSnapshot.make( + manus: ProviderSettingsSnapshot.ManusProviderSettings( + cookieSource: .auto, + manualCookieHeader: nil)) + let context = self.makeContext( + settings: settings, + env: ["MANUS_SESSION_TOKEN": "env-token"]) + let fetchOverride: @Sendable (String, Date) async throws -> ManusCreditsResponse = { token, _ in + attempts.append(token) + if token == "browser-token" { + throw ManusAPIError.invalidToken + } + #expect(token == "env-token") + return self.stubResponse() } - #expect(token == "env-token") - return self.stubResponse() - } - _ = try await ManusUsageFetcher.$fetchCreditsOverride.withValue(fetchOverride, operation: { - try await strategy.fetch(context) - }) + _ = try await ManusUsageFetcher.$fetchCreditsOverride.withValue(fetchOverride, operation: { + try await strategy.fetch(context) + }) - #expect(attempts.snapshot() == ["browser-token", "env-token"]) - #expect(CookieHeaderCache.load(provider: .manus) == nil) + #expect(attempts.snapshot() == ["browser-token", "env-token"]) + #expect(CookieHeaderCache.load(provider: .manus) == nil) + } } } @@ -212,32 +207,27 @@ struct ManusProviderTests { .value: "browser-token", .secure: "TRUE", ])) - ManusCookieImporter.importSessionOverrideForTesting = { _, _ in + try await ManusCookieImporter.withImportSessionOverrideForTesting { _, _ in ManusCookieImporter.SessionInfo(cookies: [browserCookie], sourceLabel: "Chrome") - } - ManusCookieImporter.importSessionsOverrideForTesting = nil - defer { - ManusCookieImporter.importSessionOverrideForTesting = nil - ManusCookieImporter.importSessionsOverrideForTesting = nil - } - - let strategy = ManusWebFetchStrategy() - let settings = ProviderSettingsSnapshot.make( - manus: ProviderSettingsSnapshot.ManusProviderSettings( - cookieSource: .auto, - manualCookieHeader: nil)) - let context = self.makeContext(settings: settings) - let fetchOverride: @Sendable (String, Date) async throws -> ManusCreditsResponse = { token, _ in - #expect(token == "browser-token") - return self.stubResponse() - } + } operation: { + let strategy = ManusWebFetchStrategy() + let settings = ProviderSettingsSnapshot.make( + manus: ProviderSettingsSnapshot.ManusProviderSettings( + cookieSource: .auto, + manualCookieHeader: nil)) + let context = self.makeContext(settings: settings) + let fetchOverride: @Sendable (String, Date) async throws -> ManusCreditsResponse = { token, _ in + #expect(token == "browser-token") + return self.stubResponse() + } - _ = try await ManusUsageFetcher.$fetchCreditsOverride.withValue(fetchOverride, operation: { - try await strategy.fetch(context) - }) + _ = try await ManusUsageFetcher.$fetchCreditsOverride.withValue(fetchOverride, operation: { + try await strategy.fetch(context) + }) - let cached = CookieHeaderCache.load(provider: .manus) - #expect(cached?.cookieHeader == "session_id=browser-token") + let cached = CookieHeaderCache.load(provider: .manus) + #expect(cached?.cookieHeader == "session_id=browser-token") + } } } #endif diff --git a/Tests/CodexBarTests/MemoryPressureCacheTrimTests.swift b/Tests/CodexBarTests/MemoryPressureCacheTrimTests.swift new file mode 100644 index 0000000000..1d908e6942 --- /dev/null +++ b/Tests/CodexBarTests/MemoryPressureCacheTrimTests.swift @@ -0,0 +1,251 @@ +import AppKit +import CodexBarCore +import Dispatch +import Testing +@testable import CodexBar + +@MainActor +struct MemoryPressureCacheTrimTests { + @Test + func `memory pressure monitor invokes app cache trim and allocator relief handlers`() async { + var handlerCalls = 0 + let releaseProbe = MemoryPressureReleaseProbe() + let monitor = MemoryPressureMonitor( + trimAppCaches: { + handlerCalls += 1 + return MemoryPressureCacheTrimSummary(menuCardHeights: 1) + }, + releaseFreeMallocPages: { + releaseProbe.signal() + }) + + monitor.handleMemoryPressureForTesting(isWarning: true, isCritical: false) + + #expect(handlerCalls == 1) + let releaseCompleted = await Task.detached { + releaseProbe.wait(timeout: .now() + 2) + }.value + #expect(releaseCompleted) + } + + @Test + func `memory pressure event handler runs from utility queue and hops to main actor`() async { + let probe = MemoryPressureEventHandlerProbe() + let handler = MemoryPressureMonitor.makeEventHandler( + eventReader: { [.warning] }, + handle: { isWarning, isCritical in + probe.record( + isWarning: isWarning, + isCritical: isCritical, + handledOnMainThread: Thread.isMainThread) + }) + + DispatchQueue.global(qos: .utility).async { + probe.recordInvocationThread(isMainThread: Thread.isMainThread) + handler() + } + + let completed = await Task.detached { + probe.wait(timeout: .now() + 2) + }.value + #expect(completed) + + let snapshot = probe.snapshot() + #expect(snapshot.invokedOnMainThread == false) + #expect(snapshot.isWarning) + #expect(!snapshot.isCritical) + #expect(snapshot.handledOnMainThread) + } + + @Test + func `memory pressure source event handler can read source data from utility queue`() async { + let source = DispatchSource.makeMemoryPressureSource( + eventMask: [.warning, .critical], + queue: .global(qos: .utility)) + source.setEventHandler {} + source.resume() + defer { source.cancel() } + + let probe = MemoryPressureEventHandlerProbe() + let handler = MemoryPressureMonitor.makeEventHandler( + source: source, + handle: { isWarning, isCritical in + probe.record( + isWarning: isWarning, + isCritical: isCritical, + handledOnMainThread: Thread.isMainThread) + }) + + DispatchQueue.global(qos: .utility).async { + probe.recordInvocationThread(isMainThread: Thread.isMainThread) + handler() + } + + let completed = await Task.detached { + probe.wait(timeout: .now() + 2) + }.value + #expect(completed) + + let snapshot = probe.snapshot() + #expect(snapshot.invokedOnMainThread == false) + #expect(snapshot.handledOnMainThread) + } + + @Test + func `status controller trims rebuildable menu caches on memory pressure`() { + let controller = self.makeController() + defer { controller.releaseStatusItemsForTesting() } + + let key = StatusItemController.MenuCardHeightCacheKey( + id: "card", + scope: UsageProvider.codex.rawValue, + width: 30000, + textScale: StatusItemController.menuCardHeightTextScaleToken(), + fingerprint: "content:stable") + let menu = NSMenu() + let entry = CachedMergedSwitcherMenuContent( + requiredMenuContentVersion: 0, + menuWidth: 300, + codexAccountDisplay: nil, + tokenAccountDisplay: nil, + localizationSignature: "", + items: []) + + controller.menuCardHeightCache[key] = 42 + controller.measuredStandardMenuWidthCache["width"] = 300 + controller.mergedSwitcherContentCaches[ObjectIdentifier(menu)] = [ + .overview: entry, + .provider(.codex): entry, + ] + controller.menuCardViewRecyclePool["card"] = NSView() + + let summary = controller.trimRebuildableCachesForMemoryPressure() + + #expect(summary.menuCardHeights == 1) + #expect(summary.menuWidths == 1) + #expect(summary.mergedSwitcherSelections == 2) + #expect(summary.recycledMenuCardViews == 1) + #expect(controller.menuCardHeightCache.isEmpty) + #expect(controller.measuredStandardMenuWidthCache.isEmpty) + #expect(controller.mergedSwitcherContentCaches.isEmpty) + #expect(controller.menuCardViewRecyclePool.isEmpty) + } + + @Test + func `usage store trims OpenAI web debug cache without interrupting active refresh state`() { + let store = self.makeStore() + let taskToken = UUID() + + store.openAIWebDebugLines = ["line 1", "line 2", "line 3"] + store.openAIDashboardCookieImportDebugLog = "line 1\nline 2\nline 3" + store.isRefreshing = true + store.refreshingProviders = [.codex] + store.tokenRefreshInFlight = [.codex] + store.openAIDashboardRefreshTaskKey = "codex@example.com:manual" + store.openAIDashboardRefreshTaskToken = taskToken + + let summary = store.trimRebuildableCachesForMemoryPressure() + + #expect(summary.openAIWebDebugLines == 3) + #expect(store.openAIWebDebugLines.isEmpty) + #expect(store.openAIDashboardCookieImportDebugLog == nil) + #expect(store.isRefreshing) + #expect(store.refreshingProviders == [.codex]) + #expect(store.tokenRefreshInFlight == [.codex]) + #expect(store.openAIDashboardRefreshTaskKey == "codex@example.com:manual") + #expect(store.openAIDashboardRefreshTaskToken == taskToken) + } + + private func makeController() -> StatusItemController { + let settings = self.makeSettings() + let store = self.makeStore(settings: settings) + return StatusItemController( + store: store, + settings: settings, + account: AccountInfo(email: nil, plan: nil), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: .system) + } + + private func makeStore(settings: SettingsStore? = nil) -> UsageStore { + let resolvedSettings = settings ?? self.makeSettings() + return UsageStore( + fetcher: UsageFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: resolvedSettings) + } + + private func makeSettings() -> SettingsStore { + let suite = "MemoryPressureCacheTrimTests-\(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.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + settings.providerDetectionCompleted = true + return settings + } +} + +private final class MemoryPressureReleaseProbe: @unchecked Sendable { + private let semaphore = DispatchSemaphore(value: 0) + + func signal() { + self.semaphore.signal() + } + + func wait(timeout: DispatchTime) -> Bool { + self.semaphore.wait(timeout: timeout) == .success + } +} + +private final class MemoryPressureEventHandlerProbe: @unchecked Sendable { + struct Snapshot { + let invokedOnMainThread: Bool? + let isWarning: Bool + let isCritical: Bool + let handledOnMainThread: Bool + } + + private let lock = NSLock() + private let semaphore = DispatchSemaphore(value: 0) + private var invokedOnMainThread: Bool? + private var isWarning = false + private var isCritical = false + private var handledOnMainThread = false + + func recordInvocationThread(isMainThread: Bool) { + self.lock.withLock { + self.invokedOnMainThread = isMainThread + } + } + + func record(isWarning: Bool, isCritical: Bool, handledOnMainThread: Bool) { + self.lock.withLock { + self.isWarning = isWarning + self.isCritical = isCritical + self.handledOnMainThread = handledOnMainThread + } + self.semaphore.signal() + } + + func wait(timeout: DispatchTime) -> Bool { + self.semaphore.wait(timeout: timeout) == .success + } + + func snapshot() -> Snapshot { + self.lock.withLock { + Snapshot( + invokedOnMainThread: self.invokedOnMainThread, + isWarning: self.isWarning, + isCritical: self.isCritical, + handledOnMainThread: self.handledOnMainThread) + } + } +} diff --git a/Tests/CodexBarTests/MenuBarCountdownRefreshTests.swift b/Tests/CodexBarTests/MenuBarCountdownRefreshTests.swift new file mode 100644 index 0000000000..8ec324ea14 --- /dev/null +++ b/Tests/CodexBarTests/MenuBarCountdownRefreshTests.swift @@ -0,0 +1,572 @@ +import AppKit +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +@MainActor +@Suite(.serialized) +struct MenuBarCountdownRefreshTests { + @Test + func `countdown refresh delay follows the next displayed minute boundary`() { + let now = Date(timeIntervalSince1970: 1_800_000_000) + + let delay = StatusItemController.menuBarCountdownRefreshDelay( + resetDates: [ + now.addingTimeInterval(2 * 3600 + 15 * 60 + 30), + now.addingTimeInterval(45), + ], + now: now) + + #expect(abs((delay ?? 0) - 30.05) < 0.001) + } + + @Test + func `countdown refresh ignores elapsed reset dates`() { + let now = Date(timeIntervalSince1970: 1_800_000_000) + + let delay = StatusItemController.menuBarCountdownRefreshDelay( + resetDates: [now.addingTimeInterval(-1)], + now: now) + + #expect(delay == nil) + } + + @Test + func `absolute refresh observes local midnight before the reset`() throws { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = try #require(TimeZone(identifier: "America/Los_Angeles")) + let now = try #require(calendar.date(from: DateComponents( + year: 2026, + month: 7, + day: 10, + hour: 23, + minute: 59))) + let reset = try #require(calendar.date(byAdding: .hour, value: 2, to: now)) + + let delay = StatusItemController.menuBarAbsoluteRefreshDelay( + resetDates: [reset], + now: now, + calendar: calendar) + + #expect(abs((delay ?? 0) - 60.05) < 0.001) + } + + @Test + func `absolute refresh observes midnight after a skipped day start`() throws { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = try #require(TimeZone(identifier: "America/Santiago")) + let now = try #require(calendar.date(from: DateComponents( + year: 2024, + month: 9, + day: 8, + hour: 23))) + let reset = try #require(calendar.date(byAdding: .hour, value: 3, to: now)) + + let delay = StatusItemController.menuBarAbsoluteRefreshDelay( + resetDates: [reset], + now: now, + calendar: calendar) + + #expect(abs((delay ?? 0) - 3600.05) < 0.001) + } + + @Test + func `status item schedules countdown and exhausted lane refreshes`() { + let settings = testSettingsStore(suiteName: "MenuBarCountdownRefreshTests-scheduling") + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.menuBarShowsBrandIconWithPercent = true + settings.menuBarDisplayMode = .resetTime + settings.resetTimesShowAbsolute = false + if let metadata = ProviderRegistry.shared.metadata[.codex] { + settings.setProviderEnabled(provider: .codex, metadata: metadata, enabled: true) + } + + let fetcher = UsageFetcher() + let store = UsageStore( + fetcher: fetcher, + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings) + store._setSnapshotForTesting( + UsageSnapshot( + primary: RateWindow( + usedPercent: 42, + windowMinutes: 300, + resetsAt: Date().addingTimeInterval(90), + resetDescription: nil), + secondary: nil, + updatedAt: Date()), + provider: .codex) + + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: .system) + defer { controller.releaseStatusItemsForTesting() } + + #expect(controller._test_isMenuBarCountdownRefreshScheduled()) + + settings.resetTimesShowAbsolute = true + controller.updateIcons() + #expect(controller._test_isMenuBarCountdownRefreshScheduled()) + + settings.menuBarShowsBrandIconWithPercent = false + controller.updateIcons() + #expect(!controller._test_isMenuBarCountdownRefreshScheduled()) + settings.menuBarShowsBrandIconWithPercent = true + + let now = Date() + store._setSnapshotForTesting( + UsageSnapshot( + primary: RateWindow( + usedPercent: 1, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(60), + resetDescription: nil), + secondary: RateWindow( + usedPercent: 100, + windowMinutes: 10080, + resetsAt: now.addingTimeInterval(90), + resetDescription: nil), + updatedAt: now), + provider: .codex) + controller.updateIcons() + #expect(controller._test_isMenuBarCountdownRefreshScheduled()) + + store._setSnapshotForTesting( + UsageSnapshot( + primary: RateWindow( + usedPercent: 1, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(60), + resetDescription: nil), + secondary: RateWindow( + usedPercent: 100, + windowMinutes: 10080, + resetsAt: now.addingTimeInterval(-1), + resetDescription: nil), + updatedAt: now), + provider: .codex) + controller.updateIcons() + // The elapsed weekly cap falls out of the projection; absolute reset-time mode now observes the + // still-future session reset instead of leaving its label stale. + #expect(controller._test_isMenuBarCountdownRefreshScheduled()) + + store._setSnapshotForTesting( + UsageSnapshot( + primary: RateWindow( + usedPercent: 100, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(90), + resetDescription: nil), + secondary: RateWindow( + usedPercent: 40, + windowMinutes: 10080, + resetsAt: now.addingTimeInterval(3600), + resetDescription: nil), + updatedAt: now), + provider: .codex) + controller.updateIcons() + #expect(controller._test_isMenuBarCountdownRefreshScheduled()) + + settings.resetTimesShowAbsolute = false + store._setSnapshotForTesting(nil, provider: .codex) + controller.updateIcons() + #expect(!controller._test_isMenuBarCountdownRefreshScheduled()) + + store._setSnapshotForTesting( + UsageSnapshot( + primary: RateWindow( + usedPercent: 42, + windowMinutes: 300, + resetsAt: Date().addingTimeInterval(90), + resetDescription: nil), + secondary: nil, + updatedAt: Date()), + provider: .codex) + controller.updateIcons() + #expect(controller._test_isMenuBarCountdownRefreshScheduled()) + + controller.prepareForAppShutdown() + #expect(!controller._test_isMenuBarCountdownRefreshScheduled()) + } + + @Test + func `custom countdown schedules independently of legacy reset settings`() throws { + try self.expectCustomResetTokenSchedules( + .resetCountdown, + legacyAbsoluteReset: true, + suiteName: "MenuBarCountdownRefreshTests-custom-countdown") + } + + @Test + func `custom absolute reset schedules independently of legacy reset settings`() throws { + try self.expectCustomResetTokenSchedules( + .resetAbsolute, + legacyAbsoluteReset: false, + suiteName: "MenuBarCountdownRefreshTests-custom-absolute") + } + + @Test + func `absolute clock smart mode schedules the exhausted reset boundary`() { + // Isolated defaults: this test enables the smart option, which must not leak into `.standard` + // and flip other suites' exhausted-lane expectations. + let settings = testSettingsStore(suiteName: "MenuBarCountdownRefreshTests-absolute-smart") + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.menuBarShowsBrandIconWithPercent = true + settings.menuBarDisplayMode = .percent + settings.menuBarShowsResetTimeWhenExhausted = true + // Absolute clock style: the per-minute countdown scheduler is skipped, but a smart-exhausted + // lane still needs a boundary refresh so it falls back to the percentage once the reset passes. + settings.resetTimesShowAbsolute = true + if let metadata = ProviderRegistry.shared.metadata[.codex] { + settings.setProviderEnabled(provider: .codex, metadata: metadata, enabled: true) + } + + let fetcher = UsageFetcher() + let store = UsageStore( + fetcher: fetcher, + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings) + let now = Date() + + // Exhausted lane with a future reset → schedule a boundary refresh even in absolute mode. + store._setSnapshotForTesting( + UsageSnapshot( + primary: RateWindow( + usedPercent: 100, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(90), + resetDescription: nil), + secondary: nil, + updatedAt: now), + provider: .codex) + + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: .system) + defer { controller.releaseStatusItemsForTesting() } + + #expect(controller._test_isMenuBarCountdownRefreshScheduled()) + + // Elapsed reset → nothing to schedule (the lane already falls back to the percentage). + store._setSnapshotForTesting( + UsageSnapshot( + primary: RateWindow( + usedPercent: 100, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(-1), + resetDescription: nil), + secondary: nil, + updatedAt: now), + provider: .codex) + controller.updateIcons() + #expect(!controller._test_isMenuBarCountdownRefreshScheduled()) + + // Healthy quota → smart replacement inactive, so no boundary refresh. + store._setSnapshotForTesting( + UsageSnapshot( + primary: RateWindow( + usedPercent: 40, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(90), + resetDescription: nil), + secondary: nil, + updatedAt: now), + provider: .codex) + controller.updateIcons() + #expect(!controller._test_isMenuBarCountdownRefreshScheduled()) + } + + @Test(arguments: [MenuBarDisplayMode.percent, .pace, .both]) + func `combined metric schedules every displayed exhausted reset lane`(mode: MenuBarDisplayMode) { + for usesAbsoluteClock in [false, true] { + // Isolated defaults: enabling the smart option must not leak into `.standard`. + let settings = testSettingsStore( + suiteName: "MenuBarCountdownRefreshTests-combined-lanes-\(mode.rawValue)-\(usesAbsoluteClock)") + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.menuBarShowsBrandIconWithPercent = true + settings.menuBarDisplayMode = mode + settings.menuBarShowsResetTimeWhenExhausted = true + settings.resetTimesShowAbsolute = usesAbsoluteClock + settings.mergeIcons = false + settings.selectedMenuProvider = .claude + settings.setMenuBarMetricPreference(.primaryAndSecondary, for: .claude) + if let metadata = ProviderRegistry.shared.metadata[.claude] { + settings.setProviderEnabled(provider: .claude, metadata: metadata, enabled: true) + } + + let fetcher = UsageFetcher() + let store = UsageStore( + fetcher: fetcher, + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings) + let now = Date() + // Both combined lanes exhausted: the session (5h) reset has already elapsed, the weekly (7d) + // reset is still ahead. The scheduler must consider the weekly lane, not just the icon-metric + // lane, so the still-displayed weekly countdown or absolute clock reaches its reset boundary. + let sessionReset = now.addingTimeInterval(-60) + let weeklyReset = now.addingTimeInterval(3600) + store._setSnapshotForTesting( + UsageSnapshot( + primary: RateWindow( + usedPercent: 100, + windowMinutes: 300, + resetsAt: sessionReset, + resetDescription: nil), + secondary: RateWindow( + usedPercent: 100, + windowMinutes: 10080, + resetsAt: weeklyReset, + resetDescription: nil), + updatedAt: now), + provider: .claude) + + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: .system) + controller.updateIcons() + + let dates = controller.menuBarDisplayedResetDates(for: .claude, now: now) + switch mode { + case .percent: + // Percent displays both lane values independently. + #expect(dates == [sessionReset, weeklyReset]) + case .pace, .both: + // Pace/both surface the exhausted weekly lane, not the session lane that wins the 100/100 + // icon-metric tie. + #expect(dates == [weeklyReset]) + case .resetTime: + Issue.record("reset-time mode is not an argument for this smart-reset test") + } + // The future weekly boundary stays scheduled for countdown and absolute-clock styles even + // though the session lane elapsed. + #expect(controller._test_isMenuBarCountdownRefreshScheduled()) + controller.releaseStatusItemsForTesting() + } + } + + @Test + func `combined metric falls through to a nonstandard exhausted fallback lane`() { + let settings = testSettingsStore(suiteName: "MenuBarCountdownRefreshTests-combined-fallback") + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.menuBarShowsBrandIconWithPercent = true + settings.menuBarDisplayMode = .percent + settings.menuBarShowsResetTimeWhenExhausted = true + settings.resetTimesShowAbsolute = false + settings.mergeIcons = false + settings.selectedMenuProvider = .claude + settings.setMenuBarMetricPreference(.primaryAndSecondary, for: .claude) + if let metadata = ProviderRegistry.shared.metadata[.claude] { + settings.setProviderEnabled(provider: .claude, metadata: metadata, enabled: true) + } + + let fetcher = UsageFetcher() + let store = UsageStore( + fetcher: fetcher, + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings) + let now = Date() + let reset = now.addingTimeInterval(3600) + let snapshot = UsageSnapshot( + primary: RateWindow( + usedPercent: 100, + windowMinutes: 60, + resetsAt: reset, + resetDescription: nil), + secondary: nil, + updatedAt: now) + store._setSnapshotForTesting(snapshot, provider: .claude) + + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: .system) + defer { controller.releaseStatusItemsForTesting() } + + #expect(controller.menuBarDisplayText(for: .claude, snapshot: snapshot, now: now) == "↻ in 1h") + #expect(controller.menuBarDisplayedResetDates(for: .claude, now: now) == [reset]) + #expect(controller._test_isMenuBarCountdownRefreshScheduled()) + } + + @Test + func `time environment change reschedules an absolute reset label`() { + let settings = testSettingsStore(suiteName: "MenuBarCountdownRefreshTests-time-environment") + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.menuBarShowsBrandIconWithPercent = true + settings.menuBarDisplayMode = .resetTime + settings.resetTimesShowAbsolute = true + if let metadata = ProviderRegistry.shared.metadata[.codex] { + settings.setProviderEnabled(provider: .codex, metadata: metadata, 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: .system) + defer { controller.releaseStatusItemsForTesting() } + + #expect(!controller._test_isMenuBarCountdownRefreshScheduled()) + let now = Date() + store._setSnapshotForTesting( + UsageSnapshot( + primary: RateWindow( + usedPercent: 42, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(3600), + resetDescription: nil), + secondary: nil, + updatedAt: now), + provider: .codex) + + controller.handleMenuBarTimeEnvironmentChange() + + #expect(controller._test_isMenuBarCountdownRefreshScheduled()) + } + + @Test + func `merged highest usage observes reset for noncurrent Codex candidate`() throws { + let settings = testSettingsStore(suiteName: "MenuBarCountdownRefreshTests-merged-highest") + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.menuBarShowsHighestUsage = true + settings.menuBarShowsBrandIconWithPercent = true + settings.menuBarDisplayMode = .percent + settings.resetTimesShowAbsolute = true + + let registry = ProviderRegistry.shared + try settings.setProviderEnabled( + provider: .codex, + metadata: #require(registry.metadata[.codex]), + enabled: true) + try settings.setProviderEnabled( + provider: .claude, + metadata: #require(registry.metadata[.claude]), + enabled: true) + + let fetcher = UsageFetcher() + let store = UsageStore( + fetcher: fetcher, + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings) + let now = Date() + store._setSnapshotForTesting( + UsageSnapshot( + primary: RateWindow( + usedPercent: 1, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(60), + resetDescription: nil), + secondary: RateWindow( + usedPercent: 100, + windowMinutes: 10080, + resetsAt: now.addingTimeInterval(90), + resetDescription: nil), + updatedAt: now), + provider: .codex) + store._setSnapshotForTesting( + UsageSnapshot( + primary: RateWindow(usedPercent: 80, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + secondary: nil, + updatedAt: now), + provider: .claude) + + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: .system) + defer { controller.releaseStatusItemsForTesting() } + + controller.updateIcons() + #expect(controller.primaryProviderForUnifiedIcon() == .claude) + #expect(controller._test_isMenuBarCountdownRefreshScheduled()) + } + + private func expectCustomResetTokenSchedules( + _ layoutElement: MenuBarLayoutToken, + legacyAbsoluteReset: Bool, + suiteName: String) throws + { + let settings = testSettingsStore(suiteName: suiteName) + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + settings.selectedMenuProvider = .claude + settings.menuBarIconStyle = .iconAndPercent + settings.menuBarDisplayMode = .percent + settings.menuBarShowsResetTimeWhenExhausted = false + settings.resetTimesShowAbsolute = legacyAbsoluteReset + settings.setMenuBarLayout(MenuBarLayout(lines: [[.icon, layoutElement]]), for: .claude) + + let registry = ProviderRegistry.shared + try settings.setProviderEnabled( + provider: .codex, + metadata: #require(registry.metadata[.codex]), + enabled: false) + try settings.setProviderEnabled( + provider: .claude, + metadata: #require(registry.metadata[.claude]), + enabled: true) + + let fetcher = UsageFetcher() + let store = UsageStore( + fetcher: fetcher, + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings) + let now = Date() + let reset = now.addingTimeInterval(90) + store._setSnapshotForTesting( + UsageSnapshot( + primary: RateWindow( + usedPercent: 42, + windowMinutes: 300, + resetsAt: reset, + resetDescription: nil), + secondary: nil, + updatedAt: now), + provider: .claude) + + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: .system) + defer { controller.releaseStatusItemsForTesting() } + + #expect(controller.menuBarDisplayedResetDates(for: .claude, now: now) == [reset]) + #expect(controller._test_isMenuBarCountdownRefreshScheduled()) + } +} diff --git a/Tests/CodexBarTests/MenuBarLayoutEditorTests.swift b/Tests/CodexBarTests/MenuBarLayoutEditorTests.swift new file mode 100644 index 0000000000..f238ca9d57 --- /dev/null +++ b/Tests/CodexBarTests/MenuBarLayoutEditorTests.swift @@ -0,0 +1,157 @@ +import CoreTransferable +import Foundation +import Testing +import UniformTypeIdentifiers +@testable import CodexBar + +struct MenuBarLayoutEditorTests { + @Test + func `palette tokens append and insert at a drop index`() { + let initial = MenuBarLayout(lines: [[.icon, .resetCountdown]]) + + let appended = MenuBarLayoutEditorMutations.append(.space, to: initial) + #expect(appended.lines == [[.icon, .resetCountdown, .space]]) + + let inserted = MenuBarLayoutEditorMutations.insert( + .palette(.percent(window: .weekly)), + at: MenuBarLayoutPosition(line: 0, index: 1), + in: initial) + #expect(inserted.lines == [[.icon, .percent(window: .weekly), .resetCountdown]]) + } + + @Test + func `dragging within a line reorders without duplicating`() { + let initial = MenuBarLayout(lines: [[.icon, .providerName, .resetCountdown]]) + let dragged = MenuBarLayoutDragItem.placed( + .icon, + at: MenuBarLayoutPosition(line: 0, index: 0), + in: initial) + + let reordered = MenuBarLayoutEditorMutations.insert( + dragged, + at: MenuBarLayoutPosition(line: 0, index: 3), + in: initial) + + #expect(reordered.lines == [[.providerName, .resetCountdown, .icon]]) + + let unchanged = MenuBarLayoutEditorMutations.insert( + .placed(.providerName, at: MenuBarLayoutPosition(line: 0, index: 1), in: initial), + at: MenuBarLayoutPosition(line: 0, index: 1), + in: initial) + #expect(unchanged == initial) + } + + @Test + func `dragging between lines moves the token`() { + let initial = MenuBarLayout(lines: [[.icon, .providerName], [.percent(window: .weekly)]]) + let dragged = MenuBarLayoutDragItem.placed( + .providerName, + at: MenuBarLayoutPosition(line: 0, index: 1), + in: initial) + + let reordered = MenuBarLayoutEditorMutations.insert( + dragged, + at: MenuBarLayoutPosition(line: 1, index: 0), + in: initial) + + #expect(reordered.lines == [[.icon], [.providerName, .percent(window: .weekly)]]) + } + + @Test + func `stale drag source leaves the layout unchanged`() { + let initial = MenuBarLayout(lines: [[.icon, .providerName]]) + let stale = MenuBarLayoutDragItem.placed( + .icon, + at: MenuBarLayoutPosition(line: 0, index: 1), + in: initial) + + let result = MenuBarLayoutEditorMutations.insert( + stale, + at: MenuBarLayoutPosition(line: 0, index: 2), + in: initial) + + #expect(result == initial) + } + + @Test + func `line break splits and rejoins the strip`() { + let initial = MenuBarLayout(lines: [[.icon, .providerName, .percent(window: .automatic)]]) + + let split = MenuBarLayoutEditorMutations.addLineBreak(to: initial, at: 2) + #expect(split.lines == [[.icon, .providerName], [.percent(window: .automatic)]]) + #expect(MenuBarLayoutEditorMutations.removeLineBreak(from: split) == initial) + } + + @Test + func `line break preserves an empty second line until a token is dropped`() { + let initial = MenuBarLayout(lines: [[.icon]]) + let split = MenuBarLayoutEditorMutations.addLineBreak(to: initial) + #expect(split.lines == [[.icon], []]) + + let inserted = MenuBarLayoutEditorMutations.insert( + .palette(.percent(window: .session)), + at: MenuBarLayoutPosition(line: 1, index: 0), + in: split) + #expect(inserted.lines == [[.icon], [.percent(window: .session)]]) + } + + @Test + func `delete and drag out keep at least one token`() { + let initial = MenuBarLayout(lines: [[.icon, .providerName]]) + let deleted = MenuBarLayoutEditorMutations.remove( + at: MenuBarLayoutPosition(line: 0, index: 0), + from: initial) + #expect(deleted.lines == [[.providerName]]) + + let lastToken = MenuBarLayoutDragItem.placed( + .providerName, + at: MenuBarLayoutPosition(line: 0, index: 0), + in: deleted) + #expect(MenuBarLayoutEditorMutations.remove(lastToken, from: deleted) == deleted) + + let staleToken = MenuBarLayoutDragItem.placed( + .icon, + at: MenuBarLayoutPosition(line: 0, index: 1), + in: initial) + #expect(MenuBarLayoutEditorMutations.remove(staleToken, from: initial) == initial) + + let changedDuringDrag = MenuBarLayout(lines: [[.icon, .resetCountdown]]) + let oldPayload = MenuBarLayoutDragItem.placed( + .icon, + at: MenuBarLayoutPosition(line: 0, index: 0), + in: initial) + #expect(MenuBarLayoutEditorMutations.remove(oldPayload, from: changedDuringDrag) == changedDuringDrag) + + let iconAndSpace = MenuBarLayout(lines: [[.icon, .space]]) + #expect(MenuBarLayoutEditorMutations.remove( + at: MenuBarLayoutPosition(line: 0, index: 0), + from: iconAndSpace) == iconAndSpace) + } + + @Test + func `drag payload codable round trips`() throws { + let layout = MenuBarLayout(lines: [[.icon], [.providerName, .space, .percent(window: .automatic)]]) + let payload = MenuBarLayoutDragItem.placed( + .percent(window: .automatic), + at: MenuBarLayoutPosition(line: 1, index: 2), + in: layout) + + let data = try JSONEncoder().encode(payload) + #expect(try JSONDecoder().decode(MenuBarLayoutDragItem.self, from: data) == payload) + } + + @Test + @available(macOS 15.2, *) + func `palette drag transfer representation round trips`() async throws { + let payload = MenuBarLayoutDragItem.palette(.percent(window: .weekly)) + + #expect(MenuBarLayoutDragItem.exportedContentTypes() == [.codexBarMenuLayoutItem]) + #expect(MenuBarLayoutDragItem.importedContentTypes() == [.codexBarMenuLayoutItem]) + + let data = try await payload.exported(as: .codexBarMenuLayoutItem) + let decoded = try await MenuBarLayoutDragItem( + importing: data, + contentType: .codexBarMenuLayoutItem) + #expect(decoded == payload) + } +} diff --git a/Tests/CodexBarTests/MenuBarLayoutRendererTests.swift b/Tests/CodexBarTests/MenuBarLayoutRendererTests.swift new file mode 100644 index 0000000000..fc3babad7f --- /dev/null +++ b/Tests/CodexBarTests/MenuBarLayoutRendererTests.swift @@ -0,0 +1,340 @@ +import AppKit +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +@MainActor +@Suite(.serialized) +struct MenuBarLayoutRendererTests { + private let now = Date(timeIntervalSince1970: 1_752_768_000) + + @Test + func `renderer composes every token with live values`() { + let renderer = MenuBarLayoutRenderer() + let icon = NSImage(size: NSSize(width: 16, height: 16)) + let data = self.data() + let expected: [(MenuBarLayoutToken, String)] = [ + (.providerName, "Codex"), + (.accountLabel, "user@example.com"), + (.percent(window: .session), "5h 25%"), + (.percent(window: .weekly), "W 60%"), + (.percent(window: .automatic), "50%"), + (.usageBar, "▮▮▯"), + (.resetCountdown, "in 2h"), + (.runsOut, "Runs out tomorrow"), + (.costToday, "$1.25"), + (.cost30d, "$20.00"), + (.separatorDot, "·"), + (.space, " "), + ] + + for (token, value) in expected { + let output = renderer.render( + layout: MenuBarLayout(lines: [[token]]), + data: data, + icon: icon, + options: self.options()) + #expect(output.attributedTitle.string == value) + } + + let iconOutput = renderer.render( + layout: MenuBarLayout(lines: [[.icon]]), + data: data, + icon: icon, + options: self.options()) + #expect(iconOutput.attributedTitle.attribute(.attachment, at: 0, effectiveRange: nil) is NSTextAttachment) + + let absoluteOutput = renderer.render( + layout: MenuBarLayout(lines: [[.resetAbsolute]]), + data: data, + icon: icon, + options: self.options()) + #expect(absoluteOutput.attributedTitle.string != "–") + } + + @Test + func `icon attachment matches the default template size and appearance`() throws { + let renderer = MenuBarLayoutRenderer() + let icon = NSImage(size: NSSize(width: 16, height: 16)) + icon.lockFocus() + NSColor.white.setFill() + NSBezierPath(ovalIn: NSRect(x: 1, y: 1, width: 14, height: 14)).fill() + icon.unlockFocus() + icon.isTemplate = true + + let output = renderer.render( + layout: MenuBarLayout(lines: [[.icon]]), + data: self.data(), + icon: icon, + options: self.options()) + let attachment = try #require( + output.attributedTitle.attribute(.attachment, at: 0, effectiveRange: nil) as? NSTextAttachment) + let attachmentImage = try #require(attachment.image) + + #expect(attachment.bounds.size == NSSize(width: 16, height: 16)) + #expect(attachmentImage.isTemplate) + #expect(try self.averageBrightness(of: output.attributedTitle, appearance: .aqua) < 0.25) + #expect(try self.averageBrightness(of: output.attributedTitle, appearance: .darkAqua) > 0.75) + } + + @Test + func `missing token data keeps every sibling visible as a placeholder`() { + let renderer = MenuBarLayoutRenderer() + let missingData = MenuBarLayoutRenderData( + iconKey: "missing", + providerName: nil, + accountLabel: nil, + session: nil, + weekly: nil, + automatic: nil, + runsOut: nil, + costToday: nil, + cost30d: nil) + let layout = MenuBarLayout(lines: [[ + .icon, + .providerName, + .accountLabel, + .percent(window: .session), + .percent(window: .weekly), + .percent(window: .automatic), + .usageBar, + .resetCountdown, + .resetAbsolute, + .runsOut, + .costToday, + .cost30d, + ]]) + + let output = renderer.render(layout: layout, data: missingData, icon: nil, options: self.options()) + + #expect(output.attributedTitle.string.count(where: { $0 == "–" }) == 12) + #expect(output.accessibilityLabel.contains("unavailable")) + } + + @Test + func `two line title stays within menu bar height`() throws { + let renderer = MenuBarLayoutRenderer() + let output = try renderer.render( + layout: #require(MenuBarLayoutPreset.compactStacked.layout), + data: self.data(), + icon: nil, + options: self.options()) + let bounds = output.attributedTitle.boundingRect( + with: NSSize(width: 200, height: CGFloat.greatestFiniteMagnitude), + options: [.usesLineFragmentOrigin, .usesFontLeading]) + + #expect(output.attributedTitle.string == "5h 25%\nW 60%") + #expect(output.accessibilityLabel.contains(L("menu_bar_layout_line", 2))) + #expect(bounds.height <= 22) + } + + @Test + func `stacked titles apply a vertical centering offset`() throws { + let renderer = MenuBarLayoutRenderer() + let stacked = renderer.render( + layout: MenuBarLayout(lines: [ + [.percent(window: .automatic)], + [.resetCountdown], + ]), + data: self.data(), + icon: nil, + options: self.options()) + let resetIndex = (stacked.attributedTitle.string as NSString).range(of: "in 2h").location + let singleLine = renderer.render( + layout: MenuBarLayout(lines: [[.percent(window: .automatic), .resetCountdown]]), + data: self.data(), + icon: nil, + options: self.options()) + + #expect(try #require(self.baselineOffset(in: stacked.attributedTitle, at: 0)) == -3) + #expect(try #require(self.baselineOffset(in: stacked.attributedTitle, at: resetIndex)) == -3) + #expect(self.baselineOffset(in: singleLine.attributedTitle, at: 0) == nil) + } + + @Test + func `two line icon uses compact paragraph metrics`() { + let renderer = MenuBarLayoutRenderer() + let icon = NSImage(size: NSSize(width: 16, height: 16)) + let output = renderer.render( + layout: MenuBarLayout(lines: [ + [.icon, .percent(window: .session)], + [.percent(window: .weekly)], + ]), + data: self.data(), + icon: icon, + options: self.options()) + let bounds = output.attributedTitle.boundingRect( + with: NSSize(width: 200, height: CGFloat.greatestFiniteMagnitude), + options: [.usesLineFragmentOrigin, .usesFontLeading]) + + #expect(output.attributedTitle.attribute(.paragraphStyle, at: 0, effectiveRange: nil) is NSParagraphStyle) + #expect(bounds.height <= 22) + } + + @Test + func `cached path renders one thousand titles under budget`() { + let renderer = MenuBarLayoutRenderer() + let layout = MenuBarLayout(lines: [[.icon, .percent(window: .automatic), .separatorDot, .resetCountdown]]) + let icon = NSImage(size: NSSize(width: 16, height: 16)) + let first = renderer.render(layout: layout, data: self.data(), icon: icon, options: self.options()) + var last = first + var fastest = Duration.seconds(10) + + // Best-of-three keeps the frozen 50 ms budget while ignoring one-off CI preemption. + for _ in 0..<3 { + let startedAt = ContinuousClock.now + for _ in 0..<1000 { + last = renderer.render(layout: layout, data: self.data(), icon: icon, options: self.options()) + } + fastest = min(fastest, ContinuousClock.now - startedAt) + } + + #expect(first.attributedTitle === last.attributedTitle) + #expect(fastest < .milliseconds(50), "Fastest cached batch took \(fastest)") + } + + @Test + func `usage bar follows remaining display direction`() { + let renderer = MenuBarLayoutRenderer() + let output = renderer.render( + layout: MenuBarLayout(lines: [[.usageBar]]), + data: self.data(automaticUsedPercent: 10), + icon: nil, + options: MenuBarLayoutRenderOptions( + size: .regular, + highContrast: false, + showUsed: false, + appearanceName: "aqua", + isDebugApp: false, + now: self.now)) + + #expect(output.attributedTitle.string == "▮▮▮") + } + + @Test + func `absolute reset falls back to provider text`() { + let renderer = MenuBarLayoutRenderer() + let textOnlyWindow = MenuBarLayoutRenderWindow(RateWindow( + usedPercent: 20, + windowMinutes: nil, + resetsAt: nil, + resetDescription: "Friday at 10:00")) + let data = MenuBarLayoutRenderData( + iconKey: "codex", + providerName: "Codex", + accountLabel: nil, + session: nil, + weekly: nil, + automatic: textOnlyWindow, + runsOut: nil, + costToday: nil, + cost30d: nil) + + let output = renderer.render( + layout: MenuBarLayout(lines: [[.resetAbsolute]]), + data: data, + icon: nil, + options: self.options()) + + #expect(output.attributedTitle.string == "Friday at 10:00") + } + + @Test + func `high contrast title keeps icon and text in one attributed path`() { + let renderer = MenuBarLayoutRenderer() + let icon = NSImage(size: NSSize(width: 16, height: 16)) + var options = self.options() + options = MenuBarLayoutRenderOptions( + size: options.size, + highContrast: true, + showUsed: options.showUsed, + appearanceName: options.appearanceName, + isDebugApp: options.isDebugApp, + now: options.now) + let output = renderer.render( + layout: MenuBarLayout(lines: [[.icon, .percent(window: .automatic)]]), + data: self.data(), + icon: icon, + options: options) + + #expect(output.attributedTitle.attribute(.attachment, at: 0, effectiveRange: nil) is NSTextAttachment) + let textIndex = (output.attributedTitle.string as NSString).range(of: "50%").location + #expect(output.attributedTitle + .attribute(.foregroundColor, at: textIndex, effectiveRange: nil) as? NSColor == .labelColor) + } + + private func data(automaticUsedPercent: Double = 50) -> MenuBarLayoutRenderData { + MenuBarLayoutRenderData( + iconKey: "codex", + providerName: "Codex", + accountLabel: "user@example.com", + session: MenuBarLayoutRenderWindow(RateWindow( + usedPercent: 25, + windowMinutes: 300, + resetsAt: self.now.addingTimeInterval(60 * 60), + resetDescription: nil)), + weekly: MenuBarLayoutRenderWindow(RateWindow( + usedPercent: 60, + windowMinutes: 10080, + resetsAt: self.now.addingTimeInterval(3 * 24 * 60 * 60), + resetDescription: nil)), + automatic: MenuBarLayoutRenderWindow(RateWindow( + usedPercent: automaticUsedPercent, + windowMinutes: 300, + resetsAt: self.now.addingTimeInterval(2 * 60 * 60), + resetDescription: nil)), + runsOut: "Runs out tomorrow", + costToday: "$1.25", + cost30d: "$20.00") + } + + private func options() -> MenuBarLayoutRenderOptions { + MenuBarLayoutRenderOptions( + size: .regular, + highContrast: false, + showUsed: true, + appearanceName: "aqua", + isDebugApp: false, + now: self.now) + } + + private func averageBrightness( + of title: NSAttributedString, + appearance: NSAppearance.Name) throws + -> CGFloat + { + let canvas = NSImage(size: NSSize(width: 24, height: 24)) + try #require(NSAppearance(named: appearance)).performAsCurrentDrawingAppearance { + canvas.lockFocus() + NSColor.clear.setFill() + NSRect(origin: .zero, size: canvas.size).fill() + title.draw(at: NSPoint(x: 4, y: 4)) + canvas.unlockFocus() + } + + let data = try #require(canvas.tiffRepresentation) + let bitmap = try #require(NSBitmapImageRep(data: data)) + var totalBrightness: CGFloat = 0 + var visiblePixelCount = 0 + for y in 0.. 0.1 else { continue } + totalBrightness += color.brightnessComponent + visiblePixelCount += 1 + } + } + return try totalBrightness / CGFloat(#require(visiblePixelCount > 0 ? visiblePixelCount : nil)) + } + + private func baselineOffset(in title: NSAttributedString, at index: Int) -> CGFloat? { + let value = title.attribute(.baselineOffset, at: index, effectiveRange: nil) + if let value = value as? CGFloat { + return value + } + if let value = value as? NSNumber { + return CGFloat(truncating: value) + } + return nil + } +} diff --git a/Tests/CodexBarTests/MenuBarLayoutTests.swift b/Tests/CodexBarTests/MenuBarLayoutTests.swift new file mode 100644 index 0000000000..8a93e6c7dc --- /dev/null +++ b/Tests/CodexBarTests/MenuBarLayoutTests.swift @@ -0,0 +1,292 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +struct MenuBarLayoutTests { + private struct UnnormalizedLayout: Encodable { + let lines: [[MenuBarLayoutToken]] + } + + @Test + func `every token codable round trips`() throws { + let layout = MenuBarLayout(lines: [ + [ + .icon, + .providerName, + .accountLabel, + .percent(window: .session), + .percent(window: .weekly), + .percent(window: .automatic), + .usageBar, + ], + [ + .resetCountdown, + .resetAbsolute, + .runsOut, + .costToday, + .cost30d, + .separatorDot, + .space, + ], + ]) + + let data = try JSONEncoder().encode(layout) + let decoded = try JSONDecoder().decode(MenuBarLayout.self, from: data) + + #expect(decoded == layout) + } + + @Test + func `decoding normalizes empty and extra lines`() throws { + let emptyData = try JSONEncoder().encode(UnnormalizedLayout(lines: [])) + #expect(try JSONDecoder().decode(MenuBarLayout.self, from: emptyData) == .defaultLayout) + + let extraData = try JSONEncoder().encode(UnnormalizedLayout(lines: [ + [], + [.icon], + [.providerName], + [.accountLabel], + ])) + #expect(try JSONDecoder().decode(MenuBarLayout.self, from: extraData) == MenuBarLayout(lines: [ + [.icon], + [.providerName], + ])) + + let trailingEmptyData = try JSONEncoder().encode(UnnormalizedLayout(lines: [[.icon], []])) + #expect(try JSONDecoder().decode(MenuBarLayout.self, from: trailingEmptyData).lines == [[.icon], []]) + } + + @Test + func `semantic windows map Kimi weekly and short cadence lanes`() { + let primary = RateWindow(usedPercent: 25, windowMinutes: nil, resetsAt: nil, resetDescription: nil) + let secondary = RateWindow(usedPercent: 50, windowMinutes: 300, resetsAt: nil, resetDescription: nil) + let windows = MenuBarLayoutSemanticWindowResolver.windows( + provider: .kimi, + snapshot: UsageSnapshot(primary: primary, secondary: secondary, updatedAt: Date())) + + #expect(windows.session == secondary) + #expect(windows.weekly == primary) + } + + @Test + func `semantic windows leave unsupported lanes missing`() { + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 25, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: RateWindow(usedPercent: 50, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + updatedAt: Date()) + let windows = MenuBarLayoutSemanticWindowResolver.windows( + provider: .zai, + snapshot: snapshot) + + #expect(windows.session == nil) + #expect(windows.weekly == nil) + } + + @Test + func `cost today resolves the current calendar day aggregate`() { + let now = Date(timeIntervalSince1970: 1_752_768_000) + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = .gmt + let snapshot = CostUsageTokenSnapshot( + sessionTokens: nil, + sessionCostUSD: 99, + last30DaysTokens: nil, + last30DaysCostUSD: 9, + daily: [ + CostUsageDailyReport.Entry( + date: "2025-07-16", + inputTokens: nil, + outputTokens: nil, + totalTokens: nil, + costUSD: 6.25, + modelsUsed: nil, + modelBreakdowns: nil), + CostUsageDailyReport.Entry( + date: "2025-07-17", + inputTokens: nil, + outputTokens: nil, + totalTokens: nil, + costUSD: 2.75, + modelsUsed: nil, + modelBreakdowns: nil), + ], + updatedAt: now) + + #expect(MenuBarLayoutCostResolver.todayCostUSD( + snapshot: snapshot, + now: now, + calendar: calendar) == 2.75) + } + + @Test + func `migration maps every legacy style mode metric and reset combination`() { + var visited = 0 + for style in MenuBarIconStyle.allCases { + for mode in MenuBarDisplayMode.allCases { + for metric in MenuBarMetricPreference.allCases { + for resetStyle in [ResetTimeDisplayStyle.countdown, .absolute] { + let resolution = MenuBarLayoutResolution.legacy( + iconStyle: style, + displayMode: mode, + metricPreference: metric, + resetTimeDisplayStyle: resetStyle) + let layout = resolution.layout + #expect((1...2).contains(layout.lines.count)) + #expect(layout.lines.allSatisfy { !$0.isEmpty }) + #expect(resolution.legacySettings == MenuBarLayoutResolution.LegacySettings( + iconStyle: style, + displayMode: mode, + metricPreference: metric, + resetTimeDisplayStyle: resetStyle)) + #expect(resolution.usesLegacyRendering) + visited += 1 + } + } + } + } + + #expect(visited == MenuBarIconStyle.allCases.count * MenuBarDisplayMode.allCases.count + * MenuBarMetricPreference.allCases.count * 2) + } + + @Test + func `migration preserves combined and reset intent`() { + let combinedLayout = MenuBarLayout(lines: [ + [ + .icon, + .percent(window: .session), + .separatorDot, + .percent(window: .weekly), + ], + ]) + #expect(MenuBarLayout.migrated( + iconStyle: .iconAndPercent, + displayMode: .percent, + metricPreference: .primaryAndSecondary, + resetTimeDisplayStyle: .countdown) == combinedLayout) + #expect(MenuBarLayout.migrated( + iconStyle: .iconAndPercent, + displayMode: .resetTime, + metricPreference: .automatic, + resetTimeDisplayStyle: .absolute) == MenuBarLayout(lines: [[.icon, .resetAbsolute]])) + } + + @Test + func `migration preserves Kimi primary and secondary lane identity`() { + #expect(MenuBarLayout.migrated( + iconStyle: .iconAndPercent, + displayMode: .percent, + metricPreference: .primary, + resetTimeDisplayStyle: .countdown, + provider: .kimi) == MenuBarLayout(lines: [[.icon, .percent(window: .weekly)]])) + #expect(MenuBarLayout.migrated( + iconStyle: .iconAndPercent, + displayMode: .percent, + metricPreference: .secondary, + resetTimeDisplayStyle: .countdown, + provider: .kimi) == MenuBarLayout(lines: [[.icon, .percent(window: .session)]])) + } + + @Test + @MainActor + func `global editing seeds the representative provider legacy layout`() throws { + let settings = testSettingsStore(suiteName: "MenuBarLayoutTests-global-editor-migration") + settings.setMenuBarMetricPreference(.primary, for: .kimi) + let expected = MenuBarLayout(lines: [[.icon, .percent(window: .weekly)]]) + + #expect(!settings.hasStoredMenuBarLayout) + #expect(settings.menuBarLayoutForGlobalEditing(representativeProvider: .kimi) == expected) + + let stored = try #require(MenuBarLayoutPreset.iconOnly.layout) + settings.setMenuBarLayout(stored, for: nil) + #expect(settings.menuBarLayoutForGlobalEditing(representativeProvider: .kimi) == stored) + } + + @Test + @MainActor + func `size and gap changes activate the edited layout`() throws { + let globalSettings = testSettingsStore(suiteName: "MenuBarLayoutTests-size-activation") + let globalLayout = try #require(MenuBarLayoutPreset.compactStacked.layout) + MenuBarLayoutEditorPersistence.setSize( + .small, + activating: globalLayout, + for: nil, + settings: globalSettings) + + #expect(globalSettings.menuBarLayoutSize == .small) + #expect(globalSettings.hasStoredMenuBarLayout) + #expect(globalSettings.menuBarLayout == globalLayout) + + let providerSettings = testSettingsStore(suiteName: "MenuBarLayoutTests-gap-activation") + let providerLayout = try #require(MenuBarLayoutPreset.percentAndReset.layout) + MenuBarLayoutEditorPersistence.setGap( + .tight, + activating: providerLayout, + for: .kimi, + settings: providerSettings) + + #expect(providerSettings.menuBarLayoutGap == .tight) + #expect(providerSettings.menuBarLayoutOverrides[.kimi] == providerLayout) + } + + @Test + @MainActor + func `provider override and display options persist across reload`() throws { + let suite = "MenuBarLayoutTests-provider-override" + let settings = testSettingsStore(suiteName: suite) + let global = try #require(MenuBarLayoutPreset.iconOnly.layout) + let provider = try #require(MenuBarLayoutPreset.compactStacked.layout) + + settings.setMenuBarLayout(global, for: nil) + settings.setMenuBarLayout(provider, for: .claude) + settings.menuBarLayoutSize = .small + settings.menuBarLayoutGap = .tight + + #expect(settings.menuBarLayout(for: .codex) == global) + #expect(settings.menuBarLayout(for: .claude) == provider) + #expect(!settings.menuBarLayoutResolution(for: .codex).usesLegacyRendering) + + let reloaded = Self.reloadSettingsStore(settings) + #expect(reloaded.menuBarLayout(for: .codex) == global) + #expect(reloaded.menuBarLayout(for: .claude) == provider) + #expect(reloaded.menuBarLayoutSize == .small) + #expect(reloaded.menuBarLayoutGap == .tight) + + reloaded.removeMenuBarLayoutOverride(for: .claude) + let afterRemoval = Self.reloadSettingsStore(reloaded) + #expect(afterRemoval.menuBarLayoutOverrides[.claude] == nil) + #expect(afterRemoval.menuBarLayout(for: .claude) == global) + } + + @Test + func `preset application matches and manual edit becomes custom`() throws { + let preset = MenuBarLayoutPreset.percentAndReset + let layout = try #require(preset.layout) + #expect(MenuBarLayoutPreset.matching(layout) == preset) + + let edited = MenuBarLayout(lines: [[.icon, .providerName, .percent(window: .automatic)]]) + #expect(MenuBarLayoutPreset.matching(edited) == .custom) + } + + @MainActor + private static func reloadSettingsStore(_ settings: SettingsStore) -> SettingsStore { + SettingsStore( + userDefaults: settings.userDefaults, + configStore: settings.configStore, + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore(), + codexCookieStore: InMemoryCookieHeaderStore(), + claudeCookieStore: InMemoryCookieHeaderStore(), + cursorCookieStore: InMemoryCookieHeaderStore(), + opencodeCookieStore: InMemoryCookieHeaderStore(), + factoryCookieStore: InMemoryCookieHeaderStore(), + minimaxCookieStore: InMemoryMiniMaxCookieStore(), + minimaxAPITokenStore: InMemoryMiniMaxAPITokenStore(), + kimiTokenStore: InMemoryKimiTokenStore(), + augmentCookieStore: InMemoryCookieHeaderStore(), + ampCookieStore: InMemoryCookieHeaderStore(), + copilotTokenStore: InMemoryCopilotTokenStore(), + tokenAccountStore: InMemoryTokenAccountStore()) + } +} diff --git a/Tests/CodexBarTests/MenuBarMetricWindowResolverTests.swift b/Tests/CodexBarTests/MenuBarMetricWindowResolverTests.swift index 267f3098b3..a755e36384 100644 --- a/Tests/CodexBarTests/MenuBarMetricWindowResolverTests.swift +++ b/Tests/CodexBarTests/MenuBarMetricWindowResolverTests.swift @@ -4,6 +4,25 @@ import Testing @testable import CodexBar struct MenuBarMetricWindowResolverTests { + @Test + func `gemini metrics fall back to Flash when Pro is unavailable`() { + let snapshot = UsageSnapshot( + primary: nil, + secondary: RateWindow(usedPercent: 95, windowMinutes: 1440, resetsAt: nil, resetDescription: nil), + tertiary: RateWindow(usedPercent: 40, windowMinutes: 1440, resetsAt: nil, resetDescription: nil), + updatedAt: Date()) + + for preference in [MenuBarMetricPreference.automatic, .primary, .average] { + let window = MenuBarMetricWindowResolver.rateWindow( + preference: preference, + provider: .gemini, + snapshot: snapshot, + supportsAverage: true) + + #expect(window?.usedPercent == 95, "Failed preference: \(preference)") + } + } + @Test func `automatic metric uses zai 5-hour token lane when it is most constrained`() { let snapshot = UsageSnapshot( @@ -21,6 +40,701 @@ struct MenuBarMetricWindowResolverTests { #expect(window?.usedPercent == 92) } + @Test + func `automatic metric uses minimax weekly token lane when it is most constrained`() { + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 0, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + secondary: RateWindow(usedPercent: 97, windowMinutes: 7 * 24 * 60, resetsAt: nil, resetDescription: nil), + updatedAt: Date()) + + let window = MenuBarMetricWindowResolver.rateWindow( + preference: .automatic, + provider: .minimax, + snapshot: snapshot, + supportsAverage: false) + + #expect(window?.usedPercent == 97) + #expect(window?.windowMinutes == 7 * 24 * 60) + } + + @Test + func `combined primary and secondary metric uses the most constrained lane`() { + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 12, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + secondary: RateWindow(usedPercent: 91, windowMinutes: 7 * 24 * 60, resetsAt: nil, resetDescription: nil), + updatedAt: Date()) + + let window = MenuBarMetricWindowResolver.rateWindow( + preference: .primaryAndSecondary, + provider: .codex, + snapshot: snapshot, + supportsAverage: false) + + #expect(window?.usedPercent == 91) + #expect(window?.windowMinutes == 7 * 24 * 60) + } + + @Test + func `automatic metric skips exhausted cursor subquota when total remains usable`() { + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 67, windowMinutes: 30 * 24 * 60, resetsAt: nil, resetDescription: "Total"), + secondary: RateWindow( + usedPercent: 34, + windowMinutes: 30 * 24 * 60, + resetsAt: nil, + resetDescription: "Auto"), + tertiary: RateWindow(usedPercent: 100, windowMinutes: 30 * 24 * 60, resetsAt: nil, resetDescription: "API"), + updatedAt: Date()) + + let window = MenuBarMetricWindowResolver.rateWindow( + preference: .automatic, + provider: .cursor, + snapshot: snapshot, + supportsAverage: false) + + #expect(window?.remainingPercent == 33) + #expect(window?.resetDescription == "Total") + } + + @Test + func `automatic metric still reports cursor exhausted when every subquota is exhausted`() { + let snapshot = UsageSnapshot( + primary: RateWindow( + usedPercent: 100, + windowMinutes: 30 * 24 * 60, + resetsAt: nil, + resetDescription: "Total"), + secondary: RateWindow( + usedPercent: 100, + windowMinutes: 30 * 24 * 60, + resetsAt: nil, + resetDescription: "Auto"), + tertiary: RateWindow(usedPercent: 100, windowMinutes: 30 * 24 * 60, resetsAt: nil, resetDescription: "API"), + updatedAt: Date()) + + let window = MenuBarMetricWindowResolver.rateWindow( + preference: .automatic, + provider: .cursor, + snapshot: snapshot, + supportsAverage: false) + + #expect(window?.remainingPercent == 0) + } + + @Test + func `automatic metric keeps exhausted cursor total when a subquota remains usable`() { + let snapshot = UsageSnapshot( + primary: RateWindow( + usedPercent: 100, + windowMinutes: 30 * 24 * 60, + resetsAt: nil, + resetDescription: "Total"), + secondary: RateWindow( + usedPercent: 60, + windowMinutes: 30 * 24 * 60, + resetsAt: nil, + resetDescription: "Auto"), + tertiary: nil, + updatedAt: Date()) + + let window = MenuBarMetricWindowResolver.rateWindow( + preference: .automatic, + provider: .cursor, + snapshot: snapshot, + supportsAverage: false) + + #expect(window?.remainingPercent == 0) + #expect(window?.resetDescription == "Total") + } + + @Test + func `automatic metric reports cursor exhausted when all present subquotas are exhausted`() { + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 67, windowMinutes: 30 * 24 * 60, resetsAt: nil, resetDescription: "Total"), + secondary: RateWindow( + usedPercent: 100, + windowMinutes: 30 * 24 * 60, + resetsAt: nil, + resetDescription: "Auto"), + tertiary: RateWindow(usedPercent: 100, windowMinutes: 30 * 24 * 60, resetsAt: nil, resetDescription: "API"), + updatedAt: Date()) + + let window = MenuBarMetricWindowResolver.rateWindow( + preference: .automatic, + provider: .cursor, + snapshot: snapshot, + supportsAverage: false) + + #expect(window?.remainingPercent == 0) + } + + @Test + func `automatic metric preserves exhausted minimax session lane`() { + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 100, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + secondary: RateWindow(usedPercent: 97, windowMinutes: 7 * 24 * 60, resetsAt: nil, resetDescription: nil), + updatedAt: Date()) + + let window = MenuBarMetricWindowResolver.rateWindow( + preference: .automatic, + provider: .minimax, + snapshot: snapshot, + supportsAverage: false) + + #expect(window?.usedPercent == 100) + #expect(window?.windowMinutes == 300) + } + + @Test + func `automatic metric uses team budget for team-bound LiteLLM keys`() { + let snapshot = UsageSnapshot( + primary: RateWindow( + usedPercent: 10, + windowMinutes: nil, + resetsAt: nil, + resetDescription: "Personal"), + secondary: RateWindow( + usedPercent: 80, + windowMinutes: nil, + resetsAt: nil, + resetDescription: "Team"), + updatedAt: Date()) + + let window = MenuBarMetricWindowResolver.rateWindow( + preference: .automatic, + provider: .litellm, + snapshot: snapshot, + supportsAverage: false) + + #expect(window?.usedPercent == 80) + #expect(window?.resetDescription == "Team") + } + + @Test + func `automatic metric prioritizes exhausted litellm personal budget over active team budget`() { + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 100, windowMinutes: nil, resetsAt: nil, resetDescription: "Personal"), + secondary: RateWindow(usedPercent: 10, windowMinutes: nil, resetsAt: nil, resetDescription: "Team"), + updatedAt: Date()) + + let window = MenuBarMetricWindowResolver.rateWindow( + preference: .automatic, + provider: .litellm, + snapshot: snapshot, + supportsAverage: false) + + #expect(window?.resetDescription == "Personal") + #expect(window?.usedPercent == 100) + } + + @Test + func `automatic metric prioritizes exhausted litellm team budget over active personal budget`() { + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 10, windowMinutes: nil, resetsAt: nil, resetDescription: "Personal"), + secondary: RateWindow(usedPercent: 100, windowMinutes: nil, resetsAt: nil, resetDescription: "Team"), + updatedAt: Date()) + + let window = MenuBarMetricWindowResolver.rateWindow( + preference: .automatic, + provider: .litellm, + snapshot: snapshot, + supportsAverage: false) + + #expect(window?.resetDescription == "Team") + #expect(window?.usedPercent == 100) + } + + @Test + func `automatic metric prioritizes exhausted default secondary window`() { + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 42, windowMinutes: 300, resetsAt: nil, resetDescription: "Primary"), + secondary: RateWindow(usedPercent: 100, windowMinutes: 10080, resetsAt: nil, resetDescription: "Secondary"), + updatedAt: Date()) + + let window = MenuBarMetricWindowResolver.rateWindow( + preference: .automatic, + provider: .opencode, + snapshot: snapshot, + supportsAverage: false) + + #expect(window?.resetDescription == "Secondary") + #expect(window?.usedPercent == 100) + } + + @Test + func `automatic metric prioritizes exhausted default tertiary window`() { + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 42, windowMinutes: 300, resetsAt: nil, resetDescription: "Primary"), + secondary: RateWindow(usedPercent: 55, windowMinutes: 10080, resetsAt: nil, resetDescription: "Secondary"), + tertiary: RateWindow(usedPercent: 100, windowMinutes: 43200, resetsAt: nil, resetDescription: "Tertiary"), + updatedAt: Date()) + + let window = MenuBarMetricWindowResolver.rateWindow( + preference: .automatic, + provider: .opencode, + snapshot: snapshot, + supportsAverage: false) + + #expect(window?.resetDescription == "Tertiary") + #expect(window?.usedPercent == 100) + } + + @Test + func `automatic metric keeps default primary order when no window is exhausted`() { + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 42, windowMinutes: 300, resetsAt: nil, resetDescription: "Primary"), + secondary: RateWindow(usedPercent: 99, windowMinutes: 10080, resetsAt: nil, resetDescription: "Secondary"), + updatedAt: Date()) + + let window = MenuBarMetricWindowResolver.rateWindow( + preference: .automatic, + provider: .opencode, + snapshot: snapshot, + supportsAverage: false) + + #expect(window?.resetDescription == "Primary") + #expect(window?.usedPercent == 42) + } + + @Test + func `automatic metric uses constrained antigravity family lane`() { + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 0, windowMinutes: nil, resetsAt: nil, resetDescription: "Claude"), + secondary: RateWindow(usedPercent: 100, windowMinutes: nil, resetsAt: nil, resetDescription: "Gemini Pro"), + tertiary: RateWindow(usedPercent: 40, windowMinutes: nil, resetsAt: nil, resetDescription: "Gemini Flash"), + updatedAt: Date()) + + let window = MenuBarMetricWindowResolver.rateWindow( + preference: .automatic, + provider: .antigravity, + snapshot: snapshot, + supportsAverage: false) + + #expect(window?.usedPercent == 100) + #expect(window?.resetDescription == "Gemini Pro") + } + + @Test + func `automatic metric preserves usable first by default and prioritizes exhausted lane when enabled`() { + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 30, windowMinutes: 10080, resetsAt: nil, resetDescription: nil), + secondary: RateWindow(usedPercent: 67, windowMinutes: 10080, resetsAt: nil, resetDescription: nil), + extraRateWindows: [ + NamedRateWindow( + id: "antigravity-quota-summary-gemini-5h", + title: "Gemini Models Five Hour Limit", + window: RateWindow(usedPercent: 71, windowMinutes: 300, resetsAt: nil, resetDescription: nil)), + NamedRateWindow( + id: "antigravity-quota-summary-gemini-weekly", + title: "Gemini Models Weekly Limit", + window: RateWindow(usedPercent: 30, windowMinutes: 10080, resetsAt: nil, resetDescription: nil)), + NamedRateWindow( + id: "antigravity-quota-summary-3p-5h", + title: "Claude and GPT models Five Hour Limit", + window: RateWindow(usedPercent: 100, windowMinutes: 300, resetsAt: nil, resetDescription: nil)), + NamedRateWindow( + id: "antigravity-quota-summary-3p-weekly", + title: "Claude and GPT models Weekly Limit", + window: RateWindow(usedPercent: 67, windowMinutes: 10080, resetsAt: nil, resetDescription: nil)), + ], + updatedAt: Date()) + + let defaultWindow = MenuBarMetricWindowResolver.rateWindow( + preference: .automatic, + provider: .antigravity, + snapshot: snapshot, + supportsAverage: false) + let optInWindow = MenuBarMetricWindowResolver.rateWindow( + preference: .automatic, + provider: .antigravity, + snapshot: snapshot, + supportsAverage: false, + antigravityPrioritizeExhaustedQuotas: true) + + #expect(defaultWindow?.remainingPercent == 29) + #expect(defaultWindow?.windowMinutes == 300) + #expect(optInWindow?.remainingPercent == 0) + #expect(optInWindow?.windowMinutes == 300) + } + + @Test + func `automatic metric uses recognized antigravity gemini pool when claude gpt is reset only`() throws { + let resetOnlyReset = Date(timeIntervalSince1970: 1000) + let exhaustedReset = Date(timeIntervalSince1970: 2000) + let antigravitySnapshot = AntigravityStatusSnapshot( + modelQuotas: [ + AntigravityModelQuota( + label: "Claude Sonnet 4.6", + modelId: "claude-sonnet-4-6", + remainingFraction: nil, + resetTime: resetOnlyReset, + resetDescription: nil), + AntigravityModelQuota( + label: "Gemini 3.1 Pro", + modelId: "gemini-3-1-pro", + remainingFraction: 0, + resetTime: exhaustedReset, + resetDescription: nil), + ], + accountEmail: nil, + accountPlan: nil, + source: .local) + let snapshot = try antigravitySnapshot.toUsageSnapshot() + #expect(snapshot.primary?.usedPercent == 100) + #expect(snapshot.primary?.resetsAt == exhaustedReset) + #expect(snapshot.secondary == nil) + + let window = MenuBarMetricWindowResolver.rateWindow( + preference: .automatic, + provider: .antigravity, + snapshot: snapshot, + supportsAverage: false) + + #expect(window?.usedPercent == 100) + #expect(window?.resetsAt == exhaustedReset) + } + + @Test + func `automatic metric uses unclassified antigravity compact fallback`() throws { + let antigravitySnapshot = AntigravityStatusSnapshot( + modelQuotas: [ + AntigravityModelQuota( + label: "Experimental Model", + modelId: "MODEL_PLACEHOLDER_NEW", + remainingFraction: 0.36, + resetTime: nil, + resetDescription: nil), + ], + accountEmail: nil, + accountPlan: nil, + source: .local) + let snapshot = try antigravitySnapshot.toUsageSnapshot() + + let window = MenuBarMetricWindowResolver.rateWindow( + preference: .automatic, + provider: .antigravity, + snapshot: snapshot, + supportsAverage: false) + + #expect(window?.usedPercent == 64) + } + + @Test + func `automatic metric keeps legacy antigravity compact fallback usable first semantics`() { + let snapshot = UsageSnapshot( + primary: nil, + secondary: nil, + extraRateWindows: [ + NamedRateWindow( + id: "antigravity-compact-fallback-exhausted", + title: "Exhausted", + window: RateWindow( + usedPercent: 100, + windowMinutes: nil, + resetsAt: nil, + resetDescription: nil)), + NamedRateWindow( + id: "antigravity-compact-fallback-usable", + title: "Usable", + window: RateWindow( + usedPercent: 64, + windowMinutes: nil, + resetsAt: nil, + resetDescription: nil)), + ], + updatedAt: Date()) + + let window = MenuBarMetricWindowResolver.rateWindow( + preference: .automatic, + provider: .antigravity, + snapshot: snapshot, + supportsAverage: false) + + #expect(window?.usedPercent == 64) + } + + @Test + func `antigravity quota ranking filters unknown and unsupported lanes`() { + let now = Date(timeIntervalSince1970: 100_000) + let expectedReset = now.addingTimeInterval(120) + let snapshot = UsageSnapshot( + primary: nil, + secondary: nil, + extraRateWindows: [ + NamedRateWindow( + id: "antigravity-quota-summary-gemini-session", + title: "Gemini Session", + window: RateWindow( + usedPercent: 85, + windowMinutes: 300, + resetsAt: expectedReset, + resetDescription: nil)), + NamedRateWindow( + id: "antigravity-quota-summary-gemini-daily", + title: "Gemini Daily", + window: RateWindow( + usedPercent: 100, + windowMinutes: 1440, + resetsAt: now.addingTimeInterval(60), + resetDescription: nil)), + NamedRateWindow( + id: "antigravity-quota-summary-gemini-weekly", + title: "Gemini Weekly", + window: RateWindow( + usedPercent: 99, + windowMinutes: 10080, + resetsAt: now.addingTimeInterval(30), + resetDescription: nil), + usageKnown: false), + NamedRateWindow( + id: "antigravity-quota-summary-invalid-session", + title: "Invalid Session", + window: RateWindow( + usedPercent: .nan, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(10), + resetDescription: nil)), + ], + updatedAt: now) + + let window = MenuBarMetricWindowResolver.antigravityQuotaSummaryRankingWindow( + snapshot: snapshot, + now: now) + + #expect(window?.usedPercent == 85) + #expect(window?.resetsAt == expectedReset) + } + + @Test + func `antigravity quota ranking breaks usage ties by valid nearest reset`() { + let now = Date(timeIntervalSince1970: 100_000) + let nearestFutureReset = now.addingTimeInterval(60) + let snapshot = UsageSnapshot( + primary: nil, + secondary: nil, + extraRateWindows: [ + NamedRateWindow( + id: "antigravity-quota-summary-gemini-session", + title: "Gemini Session", + window: RateWindow( + usedPercent: 90, + windowMinutes: 300, + resetsAt: nil, + resetDescription: nil)), + NamedRateWindow( + id: "antigravity-quota-summary-claude-session", + title: "Claude Session", + window: RateWindow( + usedPercent: 90, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(-60), + resetDescription: nil)), + NamedRateWindow( + id: "antigravity-quota-summary-gpt-session", + title: "GPT Session", + window: RateWindow( + usedPercent: 90, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(120), + resetDescription: nil)), + NamedRateWindow( + id: "antigravity-quota-summary-other-session", + title: "Other Session", + window: RateWindow( + usedPercent: 90, + windowMinutes: 300, + resetsAt: nearestFutureReset, + resetDescription: nil)), + ], + updatedAt: now) + + let window = MenuBarMetricWindowResolver.antigravityQuotaSummaryRankingWindow( + snapshot: snapshot, + now: now) + + #expect(window?.resetsAt == nearestFutureReset) + } + + @Test + func `antigravity quota ranking breaks complete ties by stable row ID`() { + let now = Date(timeIntervalSince1970: 100_000) + let rows = [ + NamedRateWindow( + id: "antigravity-quota-summary-a-weekly", + title: "A Weekly", + window: RateWindow( + usedPercent: 90, + windowMinutes: 10080, + resetsAt: nil, + resetDescription: "a")), + NamedRateWindow( + id: "antigravity-quota-summary-z-session", + title: "Z Session", + window: RateWindow( + usedPercent: 90, + windowMinutes: 300, + resetsAt: nil, + resetDescription: "z")), + ] + + for orderedRows in [rows, Array(rows.reversed())] { + let snapshot = UsageSnapshot( + primary: nil, + secondary: nil, + extraRateWindows: orderedRows, + updatedAt: now) + let window = MenuBarMetricWindowResolver.antigravityQuotaSummaryRankingWindow( + snapshot: snapshot, + now: now) + + #expect(window?.resetDescription == "z") + } + } + + @Test + func `antigravity families are blocked only when every understood family has an exhausted lane`() { + let snapshot = Self.antigravitySummarySnapshot(rows: [ + ("gemini-session", 300, 100, true), + ("gemini-weekly", 10080, 20, true), + ("3p-5-hour", 300, 10, true), + ("3p-weekly", 10080, 100, true), + ]) + + #expect(MenuBarMetricWindowResolver.antigravityQuotaSummaryFamiliesAreAllBlocked(snapshot: snapshot)) + + let availableFamily = Self.antigravitySummarySnapshot(rows: [ + ("gemini-session", 300, 100, true), + ("3p-session", 300, 99, true), + ]) + #expect(!MenuBarMetricWindowResolver.antigravityQuotaSummaryFamiliesAreAllBlocked(snapshot: availableFamily)) + } + + @Test + func `antigravity family blocking accepts underscore cadence delimiters`() { + let snapshot = Self.antigravitySummarySnapshot(rows: [ + ("gemini_session", 300, 100, true), + ("gemini_weekly", 10080, 20, true), + ("third_party_five_hour", 300, 100, true), + ]) + + #expect(MenuBarMetricWindowResolver.antigravityQuotaSummaryFamiliesAreAllBlocked(snapshot: snapshot)) + } + + @Test + func `antigravity family blocking accepts limit suffixed cadence`() { + let snapshot = Self.antigravitySummarySnapshot(rows: [ + ("gemini-5h limit", 300, 100, true), + ("gemini-weekly limit", 10080, 20, true), + ("third-party-session limit", 300, 100, true), + ]) + + #expect(MenuBarMetricWindowResolver.antigravityQuotaSummaryFamiliesAreAllBlocked(snapshot: snapshot)) + } + + @Test(arguments: [ + ("gemini-session", 300, 100.0, false), + ("gemini-daily", 1440, 100.0, true), + ("-session", 300, 100.0, true), + ("gemini-daily", 300, 100.0, true), + ("gem ini-session", 300, 100.0, true), + ("invalid-session", 300, Double.nan, true), + ]) + func `antigravity family blocking fails open for incomplete summary rows`( + idSuffix: String, + windowMinutes: Int, + usedPercent: Double, + usageKnown: Bool) + { + let snapshot = Self.antigravitySummarySnapshot(rows: [ + ("safe-session", 300, 100, true), + (idSuffix, windowMinutes, usedPercent, usageKnown), + ]) + + #expect(!MenuBarMetricWindowResolver.antigravityQuotaSummaryFamiliesAreAllBlocked(snapshot: snapshot)) + } + + @Test + func `antigravity family blocking fails open without quota summary rows`() { + let snapshot = UsageSnapshot(primary: nil, secondary: nil, updatedAt: Date()) + + #expect(!MenuBarMetricWindowResolver.antigravityQuotaSummaryFamiliesAreAllBlocked(snapshot: snapshot)) + } + + private static func antigravitySummarySnapshot( + rows: [(idSuffix: String, windowMinutes: Int, usedPercent: Double, usageKnown: Bool)]) + -> UsageSnapshot + { + UsageSnapshot( + primary: nil, + secondary: nil, + extraRateWindows: rows.map { row in + NamedRateWindow( + id: "antigravity-quota-summary-\(row.idSuffix)", + title: row.idSuffix, + window: RateWindow( + usedPercent: row.usedPercent, + windowMinutes: row.windowMinutes, + resetsAt: nil, + resetDescription: nil), + usageKnown: row.usageKnown) + }, + updatedAt: Date()) + } + + @Test + func `explicit antigravity metric keeps requested family lane`() { + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 0, windowMinutes: nil, resetsAt: nil, resetDescription: "Claude"), + secondary: RateWindow(usedPercent: 100, windowMinutes: nil, resetsAt: nil, resetDescription: "Gemini Pro"), + tertiary: RateWindow(usedPercent: 40, windowMinutes: nil, resetsAt: nil, resetDescription: "Gemini Flash"), + updatedAt: Date()) + + let primary = MenuBarMetricWindowResolver.rateWindow( + preference: .primary, + provider: .antigravity, + snapshot: snapshot, + supportsAverage: false, + antigravityPrioritizeExhaustedQuotas: true) + let secondary = MenuBarMetricWindowResolver.rateWindow( + preference: .secondary, + provider: .antigravity, + snapshot: snapshot, + supportsAverage: false, + antigravityPrioritizeExhaustedQuotas: true) + let tertiary = MenuBarMetricWindowResolver.rateWindow( + preference: .tertiary, + provider: .antigravity, + snapshot: snapshot, + supportsAverage: false, + antigravityPrioritizeExhaustedQuotas: true) + + #expect(primary?.resetDescription == "Claude") + #expect(secondary?.resetDescription == "Gemini Pro") + #expect(tertiary?.resetDescription == "Gemini Flash") + } + + @Test + func `monthly plan metric selects Mistral subscription window`() { + let snapshot = UsageSnapshot( + primary: nil, + secondary: nil, + extraRateWindows: [ + NamedRateWindow( + id: "mistral-monthly-plan", + title: "Monthly Plan", + window: RateWindow(usedPercent: 42, windowMinutes: nil, resetsAt: nil, resetDescription: nil)), + ], + updatedAt: Date()) + + let window = MenuBarMetricWindowResolver.rateWindow( + preference: .monthlyPlan, + provider: .mistral, + snapshot: snapshot, + supportsAverage: false) + + #expect(window?.usedPercent == 42) + } + @Test func `extra usage metric maps provider cost into a menu bar window`() { let snapshot = UsageSnapshot( @@ -65,9 +779,14 @@ struct MenuBarMetricWindowResolverTests { } @Test - func `automatic metric uses claude web spend limit placeholder`() { + func `automatic metric uses marked claude web spend limit placeholder`() { let snapshot = UsageSnapshot( - primary: RateWindow(usedPercent: 0, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + primary: RateWindow( + usedPercent: 0, + windowMinutes: 300, + resetsAt: nil, + resetDescription: nil, + isSyntheticPlaceholder: true), secondary: nil, providerCost: ProviderCostSnapshot( used: 67.03, @@ -86,6 +805,52 @@ struct MenuBarMetricWindowResolverTests { #expect(abs((window?.usedPercent ?? 0) - 6.703) < 0.0001) } + @Test + func `combined metric keeps real zero claude session when spend limit exists`() { + let primary = RateWindow(usedPercent: 0, windowMinutes: 300, resetsAt: nil, resetDescription: nil) + let snapshot = UsageSnapshot( + primary: primary, + secondary: nil, + providerCost: ProviderCostSnapshot( + used: 67.03, + limit: 1000, + currencyCode: "USD", + period: "Monthly", + updatedAt: Date()), + updatedAt: Date()) + + let window = MenuBarMetricWindowResolver.rateWindow( + preference: .primaryAndSecondary, + provider: .claude, + snapshot: snapshot, + supportsAverage: false) + + #expect(window == primary) + } + + @Test + func `automatic metric keeps real zero claude session when spend limit exists`() { + let primary = RateWindow(usedPercent: 0, windowMinutes: 300, resetsAt: nil, resetDescription: nil) + let snapshot = UsageSnapshot( + primary: primary, + secondary: nil, + providerCost: ProviderCostSnapshot( + used: 67.03, + limit: 1000, + currencyCode: "USD", + period: "Monthly", + updatedAt: Date()), + updatedAt: Date()) + + let window = MenuBarMetricWindowResolver.rateWindow( + preference: .automatic, + provider: .claude, + snapshot: snapshot, + supportsAverage: false) + + #expect(window == primary) + } + @Test func `automatic metric keeps claude quota window when extra usage is optional`() { let snapshot = UsageSnapshot( diff --git a/Tests/CodexBarTests/MenuBarPaceTextTests.swift b/Tests/CodexBarTests/MenuBarPaceTextTests.swift new file mode 100644 index 0000000000..8300c6e2db --- /dev/null +++ b/Tests/CodexBarTests/MenuBarPaceTextTests.swift @@ -0,0 +1,32 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +struct MenuBarPaceTextTests { + private static func pace(deltaPercent: Double, stage: UsagePace.Stage) -> UsagePace { + UsagePace( + stage: stage, + deltaPercent: deltaPercent, + expectedUsedPercent: 50, + actualUsedPercent: 50 + deltaPercent, + etaSeconds: nil, + willLastToReset: true) + } + + @Test + func `paceText drops the sign when the rounded delta is zero`() { + let slightlyAhead = Self.pace(deltaPercent: 0.3, stage: .onTrack) + let slightlyBehind = Self.pace(deltaPercent: -0.3, stage: .onTrack) + + // A sub-half-percent delta rounds to 0; "+0%" / "-0%" is a nonsensical signed zero. + #expect(MenuBarDisplayText.paceText(pace: slightlyAhead) == "0%") + #expect(MenuBarDisplayText.paceText(pace: slightlyBehind) == "0%") + } + + @Test + func `paceText keeps the sign for non-zero deltas`() { + #expect(MenuBarDisplayText.paceText(pace: Self.pace(deltaPercent: 3, stage: .ahead)) == "+3%") + #expect(MenuBarDisplayText.paceText(pace: Self.pace(deltaPercent: -3, stage: .behind)) == "-3%") + } +} diff --git a/Tests/CodexBarTests/MenuBarResetTimeDisplayTests.swift b/Tests/CodexBarTests/MenuBarResetTimeDisplayTests.swift new file mode 100644 index 0000000000..4a2a7e4c24 --- /dev/null +++ b/Tests/CodexBarTests/MenuBarResetTimeDisplayTests.swift @@ -0,0 +1,365 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +struct MenuBarResetTimeDisplayTests { + @Test + func `reset time mode formats the selected window reset`() { + let now = Date(timeIntervalSince1970: 1_800_000_000) + let resetsAt = now.addingTimeInterval(2 * 3600) + let window = RateWindow( + usedPercent: 42, + windowMinutes: 300, + resetsAt: resetsAt, + resetDescription: nil) + + let text = MenuBarDisplayText.displayText( + mode: .resetTime, + percentWindow: window, + showUsed: true, + resetTimeDisplayStyle: .absolute, + now: now) + + #expect(text == "↻ \(UsageFormatter.resetDescription(from: resetsAt, now: now))") + } + + @Test + func `reset time mode uses countdown preference`() { + let now = Date(timeIntervalSince1970: 1_800_000_000) + let resetsAt = now.addingTimeInterval(2 * 3600 + 15 * 60) + let window = RateWindow( + usedPercent: 42, + windowMinutes: 300, + resetsAt: resetsAt, + resetDescription: nil) + + let text = MenuBarDisplayText.displayText( + mode: .resetTime, + percentWindow: window, + showUsed: true, + resetTimeDisplayStyle: .countdown, + now: now) + + #expect(text == "↻ in 2h 15m") + } + + @Test + func `reset time mode falls back to used percent without reset metadata`() { + let window = RateWindow( + usedPercent: 42, + windowMinutes: 300, + resetsAt: nil, + resetDescription: nil) + + let text = MenuBarDisplayText.displayText( + mode: .resetTime, + percentWindow: window, + showUsed: true) + + #expect(text == "42%") + } + + @Test + func `reset time mode uses text reset metadata`() { + let window = RateWindow( + usedPercent: 42, + windowMinutes: 300, + resetsAt: nil, + resetDescription: "in 2h 15m") + + let text = MenuBarDisplayText.displayText( + mode: .resetTime, + percentWindow: window, + showUsed: true) + + #expect(text == "↻ in 2h 15m") + } + + @Test + func `reset time mode surfaces daily reset metadata`() { + let window = RateWindow( + usedPercent: 39, + windowMinutes: 1440, + resetsAt: nil, + resetDescription: "resets daily") + + let text = MenuBarDisplayText.displayText( + mode: .resetTime, + percentWindow: window, + showUsed: true) + + #expect(text == "↻ resets daily") + } + + @Test(arguments: [ + "Resets in 2h", + "tomorrow, 3:00 PM", + "next week", + "expires in 4d", + ]) + func `reset time mode accepts reset timing phrases`(_ description: String) { + let window = RateWindow( + usedPercent: 42, + windowMinutes: 300, + resetsAt: nil, + resetDescription: description) + + let text = MenuBarDisplayText.displayText( + mode: .resetTime, + percentWindow: window, + showUsed: true) + + #expect(text == "↻ \(description)") + } + + @Test(arguments: [ + "250/1000 requests", + "160 requests", + "5 hours window", + "$10.00 available", + ]) + func `reset time mode rejects non-reset provider summaries`(_ description: String) { + let window = RateWindow( + usedPercent: 42, + windowMinutes: 300, + resetsAt: nil, + resetDescription: description) + + let text = MenuBarDisplayText.displayText( + mode: .resetTime, + percentWindow: window, + showUsed: true) + + #expect(text == "42%") + } + + @Test + func `reset time mode falls back to remaining percent without reset metadata`() { + let window = RateWindow( + usedPercent: 42, + windowMinutes: 300, + resetsAt: nil, + resetDescription: nil) + + let text = MenuBarDisplayText.displayText( + mode: .resetTime, + percentWindow: window, + showUsed: false) + + #expect(text == "58%") + } + + @Test(arguments: [MenuBarDisplayMode.percent, .pace, .both]) + func `smart reset shows countdown when the quota is exhausted`(_ mode: MenuBarDisplayMode) { + let now = Date(timeIntervalSince1970: 1_800_000_000) + let window = RateWindow( + usedPercent: 100, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(2 * 3600 + 15 * 60), + resetDescription: nil) + + let text = MenuBarDisplayText.displayText( + mode: mode, + percentWindow: window, + showUsed: false, + resetTimeDisplayStyle: .countdown, + showsResetTimeWhenExhausted: true, + now: now) + + #expect(text == "↻ in 2h 15m") + } + + @Test + func `smart reset honors the absolute clock preference`() { + let now = Date(timeIntervalSince1970: 1_800_000_000) + let resetsAt = now.addingTimeInterval(2 * 3600) + let window = RateWindow( + usedPercent: 100, + windowMinutes: 300, + resetsAt: resetsAt, + resetDescription: nil) + + let text = MenuBarDisplayText.displayText( + mode: .percent, + percentWindow: window, + showUsed: false, + resetTimeDisplayStyle: .absolute, + showsResetTimeWhenExhausted: true, + now: now) + + #expect(text == "↻ \(UsageFormatter.resetDescription(from: resetsAt, now: now))") + } + + @Test + func `smart reset leaves a non-exhausted quota untouched`() { + let now = Date(timeIntervalSince1970: 1_800_000_000) + let window = RateWindow( + usedPercent: 42, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(3600), + resetDescription: nil) + + let text = MenuBarDisplayText.displayText( + mode: .percent, + percentWindow: window, + showUsed: false, + showsResetTimeWhenExhausted: true, + now: now) + + #expect(text == "58%") + } + + @Test + func `smart reset disabled keeps the exhausted percent`() { + let window = RateWindow( + usedPercent: 100, + windowMinutes: 300, + resetsAt: Date(timeIntervalSince1970: 1_800_000_000), + resetDescription: nil) + + let text = MenuBarDisplayText.displayText( + mode: .percent, + percentWindow: window, + showUsed: false) + + #expect(text == "0%") + } + + @Test + func `smart reset falls back to percent once the reset has elapsed`() { + let now = Date(timeIntervalSince1970: 1_800_000_000) + // Exhausted window whose reset moment is already in the past (e.g. snapshot lingering at 100% + // before the next provider refresh). Showing "↻ now" here would be stale and could stick. + let window = RateWindow( + usedPercent: 100, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(-60), + resetDescription: nil) + + let text = MenuBarDisplayText.displayText( + mode: .percent, + percentWindow: window, + showUsed: false, + resetTimeDisplayStyle: .countdown, + showsResetTimeWhenExhausted: true, + now: now) + + #expect(text == "0%") + } + + @Test + func `smart reset ignores textual reset metadata without a concrete reset time`() { + // Provider supplies only a textual resetDescription (no resetsAt). The smart option can't hand + // that to the refresh scheduler, so it keeps the percent rather than freezing on "↻ in 2h". + let window = RateWindow( + usedPercent: 100, + windowMinutes: 300, + resetsAt: nil, + resetDescription: "in 2h") + + let text = MenuBarDisplayText.displayText( + mode: .percent, + percentWindow: window, + showUsed: false, + showsResetTimeWhenExhausted: true) + + #expect(text == "0%") + // Reset-time mode still surfaces the textual metadata (unchanged behavior). + let resetTimeText = MenuBarDisplayText.displayText( + mode: .resetTime, + percentWindow: window, + showUsed: false) + #expect(resetTimeText == "↻ in 2h") + } + + @Test + func `smart reset falls back to percent without reset metadata`() { + let window = RateWindow( + usedPercent: 100, + windowMinutes: 300, + resetsAt: nil, + resetDescription: nil) + + let text = MenuBarDisplayText.displayText( + mode: .percent, + percentWindow: window, + showUsed: false, + showsResetTimeWhenExhausted: true) + + #expect(text == "0%") + } + + @Test(arguments: [MenuBarDisplayMode.pace, .both]) + func `smart reset keeps exhausted percent when pace exists but reset is unusable`(_ mode: MenuBarDisplayMode) { + let now = Date(timeIntervalSince1970: 1_800_000_000) + let window = RateWindow( + usedPercent: 100, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(-60), + resetDescription: nil) + let pace = UsagePace( + stage: .ahead, + deltaPercent: 12, + expectedUsedPercent: 40, + actualUsedPercent: 52, + etaSeconds: nil, + willLastToReset: true) + + let text = MenuBarDisplayText.displayText( + mode: mode, + percentWindow: window, + pace: pace, + showUsed: false, + showsResetTimeWhenExhausted: true, + now: now) + + #expect(text == "0%") + } + + @Test + func `smart reset does not alter reset time mode`() { + let now = Date(timeIntervalSince1970: 1_800_000_000) + let resetsAt = now.addingTimeInterval(3600) + let window = RateWindow( + usedPercent: 42, + windowMinutes: 300, + resetsAt: resetsAt, + resetDescription: nil) + + let text = MenuBarDisplayText.displayText( + mode: .resetTime, + percentWindow: window, + showUsed: false, + resetTimeDisplayStyle: .countdown, + showsResetTimeWhenExhausted: true, + now: now) + + #expect(text == "↻ in 1h") + } + + @Test + func `smart reset replaces only the exhausted lane in combined text`() { + let now = Date(timeIntervalSince1970: 1_800_000_000) + let session = RateWindow( + usedPercent: 100, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(2 * 3600 + 15 * 60), + resetDescription: nil) + let weekly = RateWindow( + usedPercent: 55, + windowMinutes: 10080, + resetsAt: now.addingTimeInterval(3 * 86400), + resetDescription: nil) + + let text = MenuBarDisplayText.combinedSessionWeeklyPercentText( + sessionWindow: session, + weeklyWindow: weekly, + showUsed: false, + resetTimeDisplayStyle: .countdown, + showsResetTimeWhenExhausted: true, + now: now) + + #expect(text == "5h ↻ in 2h 15m · W 45%") + } +} diff --git a/Tests/CodexBarTests/MenuBarSeparatorStyleTests.swift b/Tests/CodexBarTests/MenuBarSeparatorStyleTests.swift deleted file mode 100644 index e1414c47d8..0000000000 --- a/Tests/CodexBarTests/MenuBarSeparatorStyleTests.swift +++ /dev/null @@ -1,30 +0,0 @@ -import Foundation -import Testing -@testable import CodexBar - -struct MenuBarSeparatorStyleTests { - @Test - func separatorCharacters() { - #expect(MenuBarSeparatorStyle.dot.separator == " · ") - #expect(MenuBarSeparatorStyle.pipe.separator == " | ") - } - - @Test - func idMatchesRawValue() { - for style in MenuBarSeparatorStyle.allCases { - #expect(style.id == style.rawValue) - } - } - - @Test - func allCasesCoverDotAndPipe() { - #expect(MenuBarSeparatorStyle.allCases == [.dot, .pipe]) - } - - @Test - func rawValueRoundTripFallsBackToDot() { - #expect(MenuBarSeparatorStyle(rawValue: "dot") == .dot) - #expect(MenuBarSeparatorStyle(rawValue: "pipe") == .pipe) - #expect(MenuBarSeparatorStyle(rawValue: "garbage") == nil) - } -} diff --git a/Tests/CodexBarTests/MenuBarSmartResetIntegrationTests.swift b/Tests/CodexBarTests/MenuBarSmartResetIntegrationTests.swift new file mode 100644 index 0000000000..1670580381 --- /dev/null +++ b/Tests/CodexBarTests/MenuBarSmartResetIntegrationTests.swift @@ -0,0 +1,63 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +@MainActor +@Suite(.serialized) +struct MenuBarSmartResetIntegrationTests { + @Test(arguments: [MenuBarDisplayMode.pace, .both]) + func `combined smart reset keeps exhausted session percent without a reset`(mode: MenuBarDisplayMode) { + let settings = testSettingsStore( + suiteName: "MenuBarSmartResetIntegrationTests-combined-fallback-\(mode.rawValue)") + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.selectedMenuProvider = .claude + settings.menuBarShowsBrandIconWithPercent = true + settings.menuBarDisplayMode = mode + settings.menuBarShowsResetTimeWhenExhausted = true + settings.usageBarsShowUsed = false + settings.setMenuBarMetricPreference(.primaryAndSecondary, for: .claude) + + if let metadata = ProviderRegistry.shared.metadata[.claude] { + settings.setProviderEnabled(provider: .claude, metadata: metadata, 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: .system) + defer { controller.releaseStatusItemsForTesting() } + + let now = Date() + let snapshot = UsageSnapshot( + primary: RateWindow( + usedPercent: 100, + windowMinutes: 300, + resetsAt: nil, + resetDescription: nil), + secondary: RateWindow( + usedPercent: 50, + windowMinutes: 7 * 24 * 60, + resetsAt: now.addingTimeInterval(60 * 60), + resetDescription: nil), + updatedAt: now) + store._setSnapshotForTesting(snapshot, provider: .claude) + store._setErrorForTesting(nil, provider: .claude) + + // Weekly pace remains available, but it must not hide that the displayed session is exhausted. + // Without a concrete future session reset there is no reset text or timer to surface instead. + #expect(controller.menuBarDisplayText(for: .claude, snapshot: snapshot, now: now) == "0%") + #expect(controller.menuBarDisplayedResetDates(for: .claude, now: now).isEmpty) + #expect(!controller._test_isMenuBarCountdownRefreshScheduled()) + } +} diff --git a/Tests/CodexBarTests/MenuBarUsageTintTests.swift b/Tests/CodexBarTests/MenuBarUsageTintTests.swift new file mode 100644 index 0000000000..d489e746a3 --- /dev/null +++ b/Tests/CodexBarTests/MenuBarUsageTintTests.swift @@ -0,0 +1,66 @@ +import AppKit +import Testing +@testable import CodexBar + +struct MenuBarUsageTintTests { + private func components(_ color: NSColor) throws -> (red: Double, green: Double, blue: Double) { + let srgb = try #require(color.usingColorSpace(.sRGB)) + return (srgb.redComponent, srgb.greenComponent, srgb.blueComponent) + } + + @Test + func `unknown usage yields no tint`() { + #expect(MenuBarUsageTint.color(forUsedPercent: nil) == nil) + } + + @Test + func `tint shifts from green toward red as usage rises`() throws { + let samples = [0.0, 20, 40, 60, 70, 80, 90, 100] + let ramp = try samples.map { try self.components(#require(MenuBarUsageTint.color(forUsedPercent: $0))) } + + for (lower, higher) in zip(ramp, ramp.dropFirst()) { + #expect(higher.red >= lower.red) + #expect(higher.green <= lower.green) + } + let lowest = try #require(ramp.first) + let highest = try #require(ramp.last) + #expect(highest.red > lowest.red) + #expect(highest.green < lowest.green) + } + + @Test(arguments: [90.0, 95, 100, 250]) + func `usage at or past the critical threshold clamps to one color`(usedPercent: Double) throws { + let critical = try self.components(#require(MenuBarUsageTint.color(forUsedPercent: 90))) + let sampled = try self.components(#require(MenuBarUsageTint.color(forUsedPercent: usedPercent))) + + #expect(sampled == critical) + } + + @Test + func `negative usage clamps to the low end`() throws { + let floorColor = try self.components(#require(MenuBarUsageTint.color(forUsedPercent: 0))) + let belowFloor = try self.components(#require(MenuBarUsageTint.color(forUsedPercent: -25))) + + #expect(belowFloor == floorColor) + } + + /// Tints are baked into non-template bitmaps, so they must not be dynamic colors: a dynamic color would be + /// resolved at render time and go stale when the system appearance changes. This is what lets the renderer + /// skip an `effectiveAppearance` observer. + @Test(arguments: [0.0, 50, 95]) + func `tints resolve identically regardless of appearance`(usedPercent: Double) throws { + let color = try #require(MenuBarUsageTint.color(forUsedPercent: usedPercent)) + var light: (red: Double, green: Double, blue: Double)? + var dark: (red: Double, green: Double, blue: Double)? + + NSAppearance(named: .aqua)?.performAsCurrentDrawingAppearance { + light = try? self.components(color) + } + NSAppearance(named: .darkAqua)?.performAsCurrentDrawingAppearance { + dark = try? self.components(color) + } + + #expect(light != nil) + #expect(light == dark) + } +} diff --git a/Tests/CodexBarTests/MenuBarVisibilityWatcherTests.swift b/Tests/CodexBarTests/MenuBarVisibilityWatcherTests.swift index ce47ef1fda..cd100a2b49 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,88 @@ 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 `window probe identifies Tahoe Control Center blocked proxy geometry`() { + let snapshot = MenuBarStatusItemWindowSnapshot( + name: "codexbar-merged", + ownerName: "Control Center", + bounds: CGRect(x: 0, y: -22, width: 76, height: 22), + isOnscreen: true, + displayBounds: nil) + + #expect(snapshot.isTahoeBlockedProxy) + } + + @Test + func `window probe does not classify generic offscreen manager placement as Tahoe proxy`() { + let snapshot = MenuBarStatusItemWindowSnapshot( + name: "codexbar-merged", + ownerName: "Control Center", + bounds: CGRect(x: 2023, y: 0, width: 71, height: 24), + isOnscreen: true, + displayBounds: nil) + + #expect(!snapshot.isTahoeBlockedProxy) + } + + @Test + func `window probe does not classify stale hidden Control Center record as Tahoe proxy`() { + let snapshot = MenuBarStatusItemWindowSnapshot( + name: "codexbar-merged", + ownerName: "Control Center", + bounds: CGRect(x: 0, y: -22, width: 76, height: 22), + isOnscreen: false, + displayBounds: nil) + + #expect(!snapshot.isTahoeBlockedProxy) + } + @Test func `allows visible item attached to a detached screen`() { let snapshot = StatusItemVisibilitySnapshot( @@ -138,6 +221,207 @@ struct MenuBarVisibilityWatcherTests { snapshots: [blocked])) } + @Test + func `startup recovery retries detached Tahoe proxy corroborated by Control Center geometry`() { + let launchedAt = Date(timeIntervalSince1970: 1000) + let detachedProxy = StatusItemVisibilitySnapshot( + isVisible: true, + hasButton: true, + hasWindow: true, + hasScreen: false, + isOnCurrentScreen: false, + buttonWidth: 76) + let blockedWindow = MenuBarStatusItemWindowSnapshot( + name: "codexbar-merged", + ownerName: "Control Center", + bounds: CGRect(x: 0, y: -22, width: 76, height: 22), + isOnscreen: true, + displayBounds: nil) + + #expect(!MenuBarVisibilityWatcher.isBlockedSnapshot(snapshot: detachedProxy)) + #expect(MenuBarVisibilityWatcher.shouldAttemptStartupRecovery( + appLaunchedAt: launchedAt, + now: launchedAt.addingTimeInterval(2), + snapshots: [detachedProxy], + windowSnapshots: [blockedWindow], + detectTahoeBlockedStatusItem: true)) + } + + @Test + func `startup recovery retries expected hidden Tahoe item with enabled default and no window`() { + let launchedAt = Date(timeIntervalSince1970: 1000) + let hidden = StatusItemVisibilitySnapshot( + isVisible: false, + hasButton: true, + hasWindow: false, + hasScreen: false, + buttonWidth: 76) + let evidence = StatusItemStartupVisibilityEvidence( + autosaveName: "codexbar-merged", + expectsVisibility: true, + visibilityDefault: true, + snapshot: hidden) + + #expect(MenuBarVisibilityWatcher.shouldAttemptStartupRecovery( + appLaunchedAt: launchedAt, + now: launchedAt.addingTimeInterval(2), + snapshots: [hidden], + evidence: [evidence], + detectTahoeBlockedStatusItem: true)) + } + + @Test + func `startup recovery ignores hidden Tahoe item without app and defaults visibility agreement`() { + let launchedAt = Date(timeIntervalSince1970: 1000) + let hidden = StatusItemVisibilitySnapshot( + isVisible: false, + hasButton: true, + hasWindow: false, + hasScreen: false, + buttonWidth: 76) + let intentionallyHidden = StatusItemStartupVisibilityEvidence( + autosaveName: "codexbar-merged", + expectsVisibility: false, + visibilityDefault: true, + snapshot: hidden) + let disabledByUser = StatusItemStartupVisibilityEvidence( + autosaveName: "codexbar-merged", + expectsVisibility: true, + visibilityDefault: false, + snapshot: hidden) + let unknownDefault = StatusItemStartupVisibilityEvidence( + autosaveName: "codexbar-merged", + expectsVisibility: true, + visibilityDefault: nil, + snapshot: hidden) + + for evidence in [intentionallyHidden, disabledByUser, unknownDefault] { + #expect(!MenuBarVisibilityWatcher.shouldAttemptStartupRecovery( + appLaunchedAt: launchedAt, + now: launchedAt.addingTimeInterval(2), + snapshots: [hidden], + evidence: [evidence], + detectTahoeBlockedStatusItem: true)) + } + } + + @Test + func `startup recovery ignores hidden item when matching window still exists`() { + let launchedAt = Date(timeIntervalSince1970: 1000) + let hidden = StatusItemVisibilitySnapshot( + isVisible: false, + hasButton: true, + hasWindow: false, + hasScreen: false, + buttonWidth: 76) + let evidence = StatusItemStartupVisibilityEvidence( + autosaveName: "codexbar-merged", + expectsVisibility: true, + visibilityDefault: true, + snapshot: hidden) + let existingWindow = MenuBarStatusItemWindowSnapshot( + name: "codexbar-merged", + ownerName: "Control Center", + bounds: CGRect(x: 1500, y: 0, width: 76, height: 24), + isOnscreen: true, + displayBounds: CGRect(x: 0, y: 0, width: 2056, height: 1329)) + + #expect(!MenuBarVisibilityWatcher.shouldAttemptStartupRecovery( + appLaunchedAt: launchedAt, + now: launchedAt.addingTimeInterval(2), + snapshots: [hidden], + evidence: [evidence], + windowSnapshots: [existingWindow], + detectTahoeBlockedStatusItem: true)) + } + + @Test + func `startup recovery ignores stale hidden matching window record`() { + let launchedAt = Date(timeIntervalSince1970: 1000) + let hidden = StatusItemVisibilitySnapshot( + isVisible: false, + hasButton: true, + hasWindow: false, + hasScreen: false, + buttonWidth: 76) + let evidence = StatusItemStartupVisibilityEvidence( + autosaveName: "codexbar-merged", + expectsVisibility: true, + visibilityDefault: true, + snapshot: hidden) + let staleWindow = MenuBarStatusItemWindowSnapshot( + name: "codexbar-merged", + ownerName: "Control Center", + bounds: CGRect(x: 1500, y: 0, width: 76, height: 24), + isOnscreen: false, + displayBounds: CGRect(x: 0, y: 0, width: 2056, height: 1329)) + + #expect(MenuBarVisibilityWatcher.shouldAttemptStartupRecovery( + appLaunchedAt: launchedAt, + now: launchedAt.addingTimeInterval(2), + snapshots: [hidden], + evidence: [evidence], + windowSnapshots: [staleWindow], + detectTahoeBlockedStatusItem: true)) + } + + @Test + func `startup recovery keeps hidden no-window detection Tahoe only`() { + let launchedAt = Date(timeIntervalSince1970: 1000) + let hidden = StatusItemVisibilitySnapshot( + isVisible: false, + hasButton: true, + hasWindow: false, + hasScreen: false, + buttonWidth: 76) + let evidence = StatusItemStartupVisibilityEvidence( + autosaveName: "codexbar-merged", + expectsVisibility: true, + visibilityDefault: true, + snapshot: hidden) + + #expect(!MenuBarVisibilityWatcher.shouldAttemptStartupRecovery( + appLaunchedAt: launchedAt, + now: launchedAt.addingTimeInterval(2), + snapshots: [hidden], + evidence: [evidence])) + } + + @Test + func `startup recovery ignores detached live item without Tahoe proxy corroboration`() { + let launchedAt = Date(timeIntervalSince1970: 1000) + let managed = StatusItemVisibilitySnapshot( + isVisible: true, + hasButton: true, + hasWindow: true, + hasScreen: false, + isOnCurrentScreen: false, + buttonWidth: 18) + + #expect(!MenuBarVisibilityWatcher.shouldAttemptStartupRecovery( + appLaunchedAt: launchedAt, + now: launchedAt.addingTimeInterval(2), + snapshots: [managed], + detectTahoeBlockedStatusItem: true)) + } + + @Test + func `startup recovery ignores live item attached to a stale screen`() { + let launchedAt = Date(timeIntervalSince1970: 1000) + let managed = StatusItemVisibilitySnapshot( + isVisible: true, + hasButton: true, + hasWindow: true, + hasScreen: true, + isOnCurrentScreen: false, + buttonWidth: 18) + + #expect(!MenuBarVisibilityWatcher.shouldAttemptStartupRecovery( + appLaunchedAt: launchedAt, + now: launchedAt.addingTimeInterval(2), + snapshots: [managed])) + } + @Test func `startup recovery triggers when one split status item is blocked`() { let launchedAt = Date(timeIntervalSince1970: 1000) diff --git a/Tests/CodexBarTests/MenuCardAntigravityTests.swift b/Tests/CodexBarTests/MenuCardAntigravityTests.swift index 3f7037c0d1..b07ef767c6 100644 --- a/Tests/CodexBarTests/MenuCardAntigravityTests.swift +++ b/Tests/CodexBarTests/MenuCardAntigravityTests.swift @@ -5,7 +5,48 @@ import Testing struct MenuCardAntigravityTests { @Test - func `antigravity metrics show zero percent for missing families`() throws { + func `antigravity identity only snapshot shows limits unavailable`() throws { + let now = Date(timeIntervalSince1970: 1_742_771_200) + let metadata = try #require(ProviderDefaults.metadata[.antigravity]) + let snapshot = UsageSnapshot( + primary: nil, + secondary: nil, + updatedAt: now, + identity: ProviderIdentitySnapshot( + providerID: .antigravity, + accountEmail: "user@example.com", + accountOrganization: nil, + loginMethod: "Paid")) + + let model = UsageMenuCardView.Model.make(.init( + provider: .antigravity, + metadata: metadata, + snapshot: snapshot, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + + #expect(model.metrics.isEmpty) + #expect(model.placeholder == "Limits not available") + #expect(model.subtitleStyle == .info) + #expect(model.email == "user@example.com") + #expect(model.planText == "Paid") + } + + @Test + func `antigravity metrics omit missing groups`() throws { let now = Date() let identity = ProviderIdentitySnapshot( providerID: .antigravity, @@ -44,20 +85,61 @@ struct MenuCardAntigravityTests { hidePersonalInfo: false, now: now)) - #expect(model.metrics.count == 3) - #expect(model.metrics.map(\.title) == ["Claude", "Gemini Pro", "Gemini Flash"]) - #expect(model.metrics[1].percent == 0) - #expect(model.metrics[1].percentLabel == "0% left") - #expect(model.metrics[1].statusText == nil) - #expect(model.metrics[1].detailText == nil) - #expect(model.metrics[2].percent == 0) - #expect(model.metrics[2].percentLabel == "0% left") - #expect(model.metrics[2].statusText == nil) - #expect(model.metrics[2].detailText == nil) + #expect(model.metrics.count == 1) + #expect(model.metrics.map(\.title) == ["Gemini Models"]) + #expect(model.metrics[0].percent == 95) + #expect(model.metrics[0].percentLabel == "95% left") } @Test - func `antigravity zero percent metric still shows reset text`() throws { + func `legacy antigravity family row renders session pace without mutating duration`() throws { + let now = Date(timeIntervalSince1970: 0) + let window = RateWindow( + usedPercent: 80, + windowMinutes: nil, + resetsAt: now.addingTimeInterval(2 * 3600), + resetDescription: nil) + let snapshot = UsageSnapshot( + primary: window, + secondary: nil, + tertiary: nil, + updatedAt: now, + identity: ProviderIdentitySnapshot( + providerID: .antigravity, + accountEmail: nil, + accountOrganization: nil, + loginMethod: "Pro")) + let metadata = try #require(ProviderDefaults.metadata[.antigravity]) + + let model = UsageMenuCardView.Model.make(.init( + provider: .antigravity, + metadata: metadata, + snapshot: snapshot, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + + #expect(snapshot.primary?.windowMinutes == nil) + #expect(model.metrics.map(\.detailLeftText) == ["20% in deficit"]) + #expect(model.metrics.map(\.detailRightText) == ["Projected empty in 45m"]) + #expect(model.metrics[0].pacePercent == 40) + #expect(model.metrics[0].paceOnTop == false) + } + + @Test + func `antigravity untracked known row does not duplicate grouped summary`() throws { let now = Date(timeIntervalSince1970: 1_735_000_000) let resetTime = now.addingTimeInterval(3600) let antigravitySnapshot = AntigravityStatusSnapshot( @@ -106,13 +188,12 @@ struct MenuCardAntigravityTests { hidePersonalInfo: false, now: now)) - #expect(model.metrics[1].percent == 0) - #expect(model.metrics[1].percentLabel == "0% left") - #expect(model.metrics[1].resetText != nil) + #expect(model.metrics.map(\.title) == ["Gemini Models", "Claude and GPT"]) + #expect(!model.metrics.contains { $0.title == "Gemini 3.1 Pro (Low)" }) } @Test - func `antigravity metrics include complete per model quota windows`() throws { + func `antigravity metrics collapse complete per model quota windows`() throws { let now = Date(timeIntervalSince1970: 1_735_000_000) let resetTime = now.addingTimeInterval(3600) let antigravitySnapshot = AntigravityStatusSnapshot( @@ -143,7 +224,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]) @@ -168,34 +250,27 @@ struct MenuCardAntigravityTests { now: now)) #expect(model.metrics.map(\.title) == [ - "Claude", - "Gemini Pro", - "Gemini Flash", - "Claude Opus 4.6 (Thinking)", - "Gemini 3 Pro (High)", - "Gemini 3 Pro (Low)", - "GPT-OSS 120B (Medium)", + "Gemini Models", + "Claude and GPT", ]) - #expect(model.metrics.suffix(4).map(\.percentLabel) == [ - "75% left", - "100% left", + #expect(model.metrics.map(\.percentLabel) == [ "50% left", "25% left", ]) } @Test - func `antigravity per model extra windows still render when optional extras are disabled`() throws { + func `antigravity distinct extra windows still render when optional extras are disabled`() throws { // Regression: the optional-credits/extra-usage setting is Codex-specific and must NOT hide - // other providers' core extra windows (here Antigravity per-model quotas). + // other providers' core extra windows. let now = Date(timeIntervalSince1970: 1_735_000_000) let resetTime = now.addingTimeInterval(3600) let antigravitySnapshot = AntigravityStatusSnapshot( modelQuotas: [ AntigravityModelQuota( - label: "Claude Opus 4.6 (Thinking)", - modelId: "MODEL_PLACEHOLDER_M50", - remainingFraction: 0.75, + label: "Experimental Tool", + modelId: "MODEL_PLACEHOLDER_UNKNOWN", + remainingFraction: 0.5, resetTime: resetTime, resetDescription: nil), AntigravityModelQuota( @@ -230,13 +305,179 @@ struct MenuCardAntigravityTests { hidePersonalInfo: false, now: now)) - // Per-model extra windows remain visible even with optional extras disabled. - #expect(model.metrics.contains { $0.title == "Claude Opus 4.6 (Thinking)" }) - #expect(model.metrics.contains { $0.title == "Gemini 3 Pro (High)" }) + // Distinct extra windows remain visible even with optional extras disabled. + #expect(model.metrics.contains { $0.title == "Experimental Tool" }) + #expect(model.metrics.contains { $0.title == "Gemini Models" }) + } + + @Test + func `antigravity quota summary renders named session and weekly rows`() throws { + let now = Date(timeIntervalSince1970: 1_735_000_000) + let snapshot = UsageSnapshot( + primary: RateWindow( + usedPercent: 27, + windowMinutes: 300, + resetsAt: nil, + resetDescription: "You have used some of your 5-hour limit, it will fully refresh in 3 hours."), + secondary: RateWindow( + usedPercent: 18, + windowMinutes: 10080, + resetsAt: nil, + resetDescription: "You have used some of your weekly limit, it will fully refresh in 5 days."), + tertiary: nil, + extraRateWindows: [ + NamedRateWindow( + id: "antigravity-quota-summary-gemini-5h", + title: "Gemini Models Five Hour Limit", + window: RateWindow( + usedPercent: 9, + windowMinutes: 300, + resetsAt: nil, + resetDescription: "You have used some of your 5-hour limit, it will fully refresh in " + + "4 hours.")), + NamedRateWindow( + id: "antigravity-quota-summary-gemini-weekly", + title: "Gemini Models Weekly Limit", + window: RateWindow( + usedPercent: 18, + windowMinutes: 10080, + resetsAt: nil, + resetDescription: "You have used some of your weekly limit, it will fully refresh in 5 days.")), + NamedRateWindow( + id: "antigravity-quota-summary-3p-5h", + title: "Claude and GPT models Five Hour Limit", + window: RateWindow( + usedPercent: 27, + windowMinutes: 300, + resetsAt: nil, + resetDescription: "You have used some of your 5-hour limit, it will fully refresh in " + + "3 hours.")), + NamedRateWindow( + id: "antigravity-quota-summary-3p-weekly", + title: "Claude and GPT models Weekly Limit", + window: RateWindow( + usedPercent: 36, + windowMinutes: 10080, + resetsAt: nil, + resetDescription: "You have used some of your weekly limit, it will fully refresh in 6 days.")), + ], + updatedAt: now, + identity: ProviderIdentitySnapshot( + providerID: .antigravity, + accountEmail: nil, + accountOrganization: nil, + loginMethod: "Pro")) + let metadata = try #require(ProviderDefaults.metadata[.antigravity]) + + let model = UsageMenuCardView.Model.make(.init( + provider: .antigravity, + metadata: metadata, + snapshot: snapshot, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: false, + hidePersonalInfo: false, + now: now)) + + #expect(model.metrics.map(\.id) == [ + "antigravity-quota-summary-gemini-5h", + "antigravity-quota-summary-gemini-weekly", + "antigravity-quota-summary-3p-5h", + "antigravity-quota-summary-3p-weekly", + ]) + #expect(model.metrics.map(\.title) == [ + "Gemini Models Five Hour Limit", + "Gemini Models Weekly Limit", + "Claude and GPT models Five Hour Limit", + "Claude and GPT models Weekly Limit", + ]) + #expect(model.metrics.map(\.percentLabel) == [ + "91% left", + "82% left", + "73% left", + "64% left", + ]) + #expect(model.metrics[2].resetText == "Resets in 3 hours") + } + + @Test + func `antigravity quota summary rows render pace details`() throws { + let now = Date(timeIntervalSince1970: 0) + let snapshot = UsageSnapshot( + primary: nil, + secondary: nil, + tertiary: nil, + extraRateWindows: [ + NamedRateWindow( + id: "antigravity-quota-summary-gemini-5h", + title: "Gemini Models Five Hour Limit", + window: RateWindow( + usedPercent: 80, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(2 * 3600), + resetDescription: nil)), + NamedRateWindow( + id: "antigravity-quota-summary-gemini-weekly", + title: "Gemini Models Weekly Limit", + window: RateWindow( + usedPercent: 50, + windowMinutes: 10080, + resetsAt: now.addingTimeInterval(4 * 24 * 3600), + resetDescription: nil)), + ], + updatedAt: now, + identity: ProviderIdentitySnapshot( + providerID: .antigravity, + accountEmail: nil, + accountOrganization: nil, + loginMethod: "Pro")) + let metadata = try #require(ProviderDefaults.metadata[.antigravity]) + + let model = UsageMenuCardView.Model.make(.init( + provider: .antigravity, + metadata: metadata, + snapshot: snapshot, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: false, + hidePersonalInfo: false, + now: now)) + + #expect(model.metrics.map(\.detailLeftText) == [ + "20% in deficit", + "7% in deficit", + ]) + #expect(model.metrics.map(\.detailRightText) == [ + "Projected empty in 45m", + "Runs out in 3d", + ]) + #expect(model.metrics[0].pacePercent == 40) + #expect(abs((model.metrics[1].pacePercent ?? 0) - (400.0 / 7.0)) < 0.01) + #expect(model.metrics.map(\.paceOnTop) == [false, false]) } @Test - func `antigravity missing families show full usage in used mode`() throws { + func `antigravity missing groups are omitted in used mode`() throws { let now = Date() let identity = ProviderIdentitySnapshot( providerID: .antigravity, @@ -275,9 +516,9 @@ struct MenuCardAntigravityTests { hidePersonalInfo: false, now: now)) - #expect(model.metrics[1].percent == 100) - #expect(model.metrics[1].percentLabel == "100% used") - #expect(model.metrics[2].percent == 100) - #expect(model.metrics[2].percentLabel == "100% used") + #expect(model.metrics.count == 1) + #expect(model.metrics[0].title == "Gemini Models") + #expect(model.metrics[0].percent == 5) + #expect(model.metrics[0].percentLabel == "5% used") } } diff --git a/Tests/CodexBarTests/MenuCardClaudeSwapAccountTests.swift b/Tests/CodexBarTests/MenuCardClaudeSwapAccountTests.swift new file mode 100644 index 0000000000..eec9a75fb0 --- /dev/null +++ b/Tests/CodexBarTests/MenuCardClaudeSwapAccountTests.swift @@ -0,0 +1,113 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +/// Menu-model coverage for claude-swap account cards: the provider-neutral +/// projection renders as a regular Claude usage card with session/weekly +/// windows, account identity, and Hide Personal Info redaction. +struct MenuCardClaudeSwapAccountTests { + private func makeModel( + hidePersonalInfo: Bool, + planOverride: String? = nil, + additionalRateWindows: [NamedRateWindow] = []) throws -> UsageMenuCardView.Model + { + let now = Date(timeIntervalSince1970: 1_782_000_000) + let metadata = try #require(ProviderDefaults.metadata[.claude]) + let list = ClaudeSwapAccountList( + activeAccountNumber: 2, + accounts: [ + ClaudeSwapAccountRow( + number: 2, + email: "personal@example.com", + isActive: true, + usageStatus: .ok, + fiveHour: ClaudeSwapUsageWindow(usedPercent: 25, resetsAt: now.addingTimeInterval(3600)), + sevenDay: ClaudeSwapUsageWindow(usedPercent: 60, resetsAt: now.addingTimeInterval(86400)), + scoped: [ + ClaudeSwapScopedUsageWindow( + name: "Fable", + usedPercent: 80, + resetsAt: now.addingTimeInterval(86400)), + ]), + ]) + let account = try #require(ClaudeSwapAccountProjection.accountSnapshots(from: list, now: now).first) + let baseSnapshot = try #require(account.snapshot) + let snapshot = baseSnapshot.with( + extraRateWindows: (baseSnapshot.extraRateWindows ?? []) + additionalRateWindows) + + return UsageMenuCardView.Model.make(.init( + provider: .claude, + metadata: metadata, + snapshot: snapshot, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: account.displayLabel, plan: nil), + planOverride: planOverride, + isRefreshing: false, + lastError: account.error, + usageBarsShowUsed: true, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: false, + hidePersonalInfo: hidePersonalInfo, + now: now)) + } + + @Test + func `claude swap action overrides adapter login method`() throws { + let model = try self.makeModel(hidePersonalInfo: false, planOverride: "Switch Account...") + + #expect(model.planText == "Switch Account...") + } + + @Test + func `claude swap account snapshot renders session and weekly metrics with identity`() throws { + let model = try self.makeModel(hidePersonalInfo: false) + + #expect(model.email == "personal@example.com") + let primary = try #require(model.metrics.first(where: { $0.id == "primary" })) + #expect(primary.percent == 25) + let secondary = try #require(model.metrics.first(where: { $0.id == "secondary" })) + #expect(secondary.percent == 60) + let scoped = try #require(model.metrics.first(where: { $0.id == "claude-weekly-scoped-fable" })) + #expect(scoped.title == "Fable only") + #expect(scoped.percent == 80) + #expect(scoped.detailLeftText == "6% in reserve") + #expect(scoped.pacePercent != nil) + } + + @Test + func `claude nonweekly extra window does not render pace detail`() throws { + let now = Date(timeIntervalSince1970: 1_782_000_000) + let model = try self.makeModel( + hidePersonalInfo: false, + additionalRateWindows: [ + NamedRateWindow( + id: "claude-nonweekly-extra", + title: "Nonweekly extra", + window: RateWindow( + usedPercent: 80, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(4 * 60 * 60), + resetDescription: nil)), + ]) + + let extra = try #require(model.metrics.first(where: { $0.id == "claude-nonweekly-extra" })) + #expect(extra.detailLeftText == nil) + #expect(extra.detailRightText == nil) + #expect(extra.pacePercent == nil) + } + + @Test + func `claude swap account card respects hide personal info`() throws { + let model = try self.makeModel(hidePersonalInfo: true) + + #expect(!model.email.contains("personal@example.com")) + #expect(!model.email.contains("example.com")) + } +} diff --git a/Tests/CodexBarTests/MenuCardCostComparisonTests.swift b/Tests/CodexBarTests/MenuCardCostComparisonTests.swift new file mode 100644 index 0000000000..48ce6c996d --- /dev/null +++ b/Tests/CodexBarTests/MenuCardCostComparisonTests.swift @@ -0,0 +1,113 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +struct MenuCardCostComparisonTests { + @Test + func `cost section adds shorter periods from the same history snapshot`() throws { + let snapshot = CostUsageTokenSnapshot( + sessionTokens: 400, + sessionCostUSD: 4, + last30DaysTokens: 1000, + last30DaysCostUSD: 10, + historyDays: 90, + daily: [ + Self.entry(day: "2026-06-01", cost: 1, tokens: 100), + Self.entry(day: "2026-06-25", cost: 2, tokens: 200), + Self.entry(day: "2026-07-01", cost: 4, tokens: 400), + ], + updatedAt: Self.localNoon(year: 2026, month: 7, day: 1)) + + let section = try #require(UsageMenuCardView.Model.tokenUsageSection( + provider: .claude, + enabled: true, + comparisonPeriodsEnabled: true, + snapshot: snapshot, + error: nil)) + + #expect(section.comparisonLines == [ + "Last 7 days: $6.00 · 600 tokens", + "Last 30 days: $6.00 · 600 tokens", + ]) + } + + @Test + func `comparison periods remain opt in`() throws { + let snapshot = CostUsageTokenSnapshot( + sessionTokens: 1, + sessionCostUSD: 1, + last30DaysTokens: 1, + last30DaysCostUSD: 1, + historyDays: 90, + daily: [], + updatedAt: Date()) + + let section = try #require(UsageMenuCardView.Model.tokenUsageSection( + provider: .claude, + enabled: true, + comparisonPeriodsEnabled: false, + snapshot: snapshot, + error: nil)) + #expect(section.comparisonLines.isEmpty) + } + + @Test + func `inline dashboard shows enabled comparison periods`() throws { + let now = Date(timeIntervalSince1970: 1_783_123_200) + let snapshot = CostUsageTokenSnapshot( + sessionTokens: 400, + sessionCostUSD: 4, + last30DaysTokens: 1000, + last30DaysCostUSD: 10, + historyDays: 90, + daily: [ + Self.entry(day: "2026-06-01", cost: 1, tokens: 100), + Self.entry(day: "2026-06-25", cost: 2, tokens: 200), + Self.entry(day: "2026-07-01", cost: 4, tokens: 400), + ], + updatedAt: now) + let metadata = try #require(ProviderDefaults.metadata[.codex]) + + let model = UsageMenuCardView.Model.make(.init( + provider: .codex, + metadata: metadata, + snapshot: nil, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: snapshot, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: true, + costComparisonPeriodsEnabled: true, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + + #expect(model.inlineUsageDashboard?.detailLines.prefix(2) == [ + "Last 7 days: $4.00 · 400 tokens", + "Last 30 days: $6.00 · 600 tokens", + ]) + } + + private static func entry(day: String, cost: Double, tokens: Int) -> CostUsageDailyReport.Entry { + CostUsageDailyReport.Entry( + date: day, + inputTokens: nil, + outputTokens: nil, + totalTokens: tokens, + costUSD: cost, + modelsUsed: nil, + modelBreakdowns: nil) + } + + private static func localNoon(year: Int, month: Int, day: Int) -> Date { + Calendar.current.date(from: DateComponents(year: year, month: month, day: day, hour: 12))! + } +} diff --git a/Tests/CodexBarTests/MenuCardCostHintTests.swift b/Tests/CodexBarTests/MenuCardCostHintTests.swift index 086e9c6336..e275a68405 100644 --- a/Tests/CodexBarTests/MenuCardCostHintTests.swift +++ b/Tests/CodexBarTests/MenuCardCostHintTests.swift @@ -93,4 +93,68 @@ struct MenuCardCostHintTests { #expect(model.tokenUsage?.monthLine.hasPrefix("Today: ") == true) } + + @Test + func `metadata free Mistral day uses billing label only for a valid bucket`() throws { + let formatter = ISO8601DateFormatter() + let now = try #require(formatter.date(from: "2026-07-16T12:00:00Z")) + let billingDay = try #require(formatter.date(from: "2026-07-10T00:00:00Z")) + let metadata = try #require(ProviderDefaults.metadata[.mistral]) + let makeModel: (CostUsageTokenSnapshot) -> UsageMenuCardView.Model = { snapshot in + UsageMenuCardView.Model.make(.init( + provider: .mistral, + metadata: metadata, + snapshot: nil, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: snapshot, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: true, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + } + let valid = CostUsageTokenSnapshot( + sessionTokens: 10, + sessionCostUSD: 1, + last30DaysTokens: 10, + last30DaysCostUSD: 1, + currencyCode: "EUR", + historyDays: 1, + daily: [.init( + date: "2026-07-10", + inputTokens: 10, + outputTokens: 0, + totalTokens: 10, + costUSD: 1, + modelsUsed: nil, + modelBreakdowns: nil)], + updatedAt: billingDay) + let invalid = CostUsageTokenSnapshot( + sessionTokens: nil, + sessionCostUSD: nil, + last30DaysTokens: nil, + last30DaysCostUSD: nil, + currencyCode: "EUR", + historyDays: 1, + daily: [.init( + date: "not-a-day", + inputTokens: nil, + outputTokens: nil, + totalTokens: nil, + costUSD: nil, + modelsUsed: nil, + modelBreakdowns: nil)], + updatedAt: now) + + #expect(makeModel(valid).tokenUsage?.monthLine.hasPrefix("Latest billing day: ") == true) + #expect(makeModel(invalid).tokenUsage?.monthLine.hasPrefix("Today: ") == true) + } } diff --git a/Tests/CodexBarTests/MenuCardDeepSeekTests.swift b/Tests/CodexBarTests/MenuCardDeepSeekTests.swift index 3a17f9b107..ecda9be11b 100644 --- a/Tests/CodexBarTests/MenuCardDeepSeekTests.swift +++ b/Tests/CodexBarTests/MenuCardDeepSeekTests.swift @@ -25,7 +25,11 @@ struct MenuCardDeepSeekTests { updatedAt: now) } - private static func makeSnapshot(now: Date, usageSummary: DeepSeekUsageSummary? = nil) -> UsageSnapshot { + private static func makeSnapshot( + now: Date, + usageSummary: DeepSeekUsageSummary? = nil, + detailedUsageState: DeepSeekDetailedUsageState? = nil) -> UsageSnapshot + { DeepSeekUsageSnapshot( isAvailable: true, currency: "USD", @@ -33,6 +37,7 @@ struct MenuCardDeepSeekTests { grantedBalance: 0, toppedUpBalance: 9.32, usageSummary: usageSummary, + detailedUsageState: detailedUsageState, updatedAt: now) .toUsageSnapshot() } @@ -85,7 +90,7 @@ struct MenuCardDeepSeekTests { } @Test - func `model hides optional deepseek usage when extras disabled`() throws { + func `model hides deepseek usage when extras are disabled despite cost summary enabled`() throws { let now = Date() let metadata = try #require(ProviderDefaults.metadata[.deepseek]) let snapshot = Self.makeSnapshot(now: now, usageSummary: Self.sampleDeepSeekSummary(now: now)) @@ -105,7 +110,7 @@ struct MenuCardDeepSeekTests { lastError: nil, usageBarsShowUsed: false, resetTimeDisplayStyle: .countdown, - tokenCostUsageEnabled: false, + tokenCostUsageEnabled: true, showOptionalCreditsAndExtraUsage: false, hidePersonalInfo: false, now: now)) @@ -115,7 +120,7 @@ struct MenuCardDeepSeekTests { } @Test - func `model shows optional deepseek usage when extras enabled`() throws { + func `model shows deepseek usage when cost summary and extras are enabled`() throws { let now = Date() let metadata = try #require(ProviderDefaults.metadata[.deepseek]) let snapshot = Self.makeSnapshot(now: now, usageSummary: Self.sampleDeepSeekSummary(now: now)) @@ -135,12 +140,204 @@ struct MenuCardDeepSeekTests { lastError: nil, usageBarsShowUsed: false, resetTimeDisplayStyle: .countdown, - tokenCostUsageEnabled: false, + tokenCostUsageEnabled: true, showOptionalCreditsAndExtraUsage: true, hidePersonalInfo: false, now: now)) - #expect(model.inlineUsageDashboard?.accessibilityLabel == "DeepSeek 30 day token usage trend") + #expect(model.inlineUsageDashboard?.accessibilityLabel == "DeepSeek this month token usage trend") #expect(model.usageNotes.contains { $0.contains("Today:") }) } + + @Test + func `model explains unavailable deepseek usage when cost summary is enabled`() throws { + let now = Date() + let metadata = try #require(ProviderDefaults.metadata[.deepseek]) + let snapshot = Self.makeSnapshot(now: now) + + let model = UsageMenuCardView.Model.make(.init( + provider: .deepseek, + metadata: metadata, + snapshot: snapshot, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: true, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + + #expect(model.inlineUsageDashboard == nil) + #expect(model.usageNotes == ["Detailed usage unavailable."]) + } + + @Test + func `model shows balance without stale deepseek usage while refreshing`() throws { + let now = Date() + let metadata = try #require(ProviderDefaults.metadata[.deepseek]) + let snapshot = Self.makeSnapshot(now: now, usageSummary: Self.sampleDeepSeekSummary(now: now)) + + let model = UsageMenuCardView.Model.make(.init( + provider: .deepseek, + metadata: metadata, + snapshot: snapshot, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: true, + lastError: nil, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: true, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + + #expect(model.inlineUsageDashboard == nil) + #expect(model.usageNotes.isEmpty) + let balance = try #require(model.metrics.first) + #expect(balance.title == "Balance") + #expect(balance.statusText == "$9.32 (Paid: $9.32 / Granted: $0.00)") + } + + @Test + func `model explains that detailed usage needs a platform session`() throws { + let now = Date() + let metadata = try #require(ProviderDefaults.metadata[.deepseek]) + let snapshot = Self.makeSnapshot(now: now, detailedUsageState: .webSessionRequired) + + let model = UsageMenuCardView.Model.make(.init( + provider: .deepseek, + metadata: metadata, + snapshot: snapshot, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: true, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + + #expect(model.inlineUsageDashboard == nil) + #expect(model.usageNotes == ["Sign in to DeepSeek Platform in Chrome for detailed usage."]) + } + + @Test + func `browser only sign in remains visible when cost summary is disabled`() throws { + let now = Date() + let metadata = try #require(ProviderDefaults.metadata[.deepseek]) + let snapshot = DeepSeekUsageSnapshot( + hasBalance: false, + isAvailable: false, + currency: "USD", + totalBalance: 0, + grantedBalance: 0, + toppedUpBalance: 0, + detailedUsageState: .webSessionRequired, + updatedAt: now) + .toUsageSnapshot() + + let model = UsageMenuCardView.Model.make(.init( + provider: .deepseek, + metadata: metadata, + snapshot: snapshot, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: false, + hidePersonalInfo: false, + now: now)) + + #expect(model.metrics.isEmpty) + #expect(model.usageNotes == ["Sign in to DeepSeek Platform in Chrome for detailed usage."]) + } + + @Test + func `model asks for a profile when multiple deepseek sessions are valid`() throws { + let now = Date() + let metadata = try #require(ProviderDefaults.metadata[.deepseek]) + let snapshot = Self.makeSnapshot(now: now, detailedUsageState: .profileSelectionRequired) + + let model = UsageMenuCardView.Model.make(.init( + provider: .deepseek, + metadata: metadata, + snapshot: snapshot, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: true, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + + #expect(model.inlineUsageDashboard == nil) + #expect(model.usageNotes == ["Select a DeepSeek Chrome profile in Settings."]) + } + + @Test + func `model hides deepseek usage when cost summary is disabled despite extras enabled`() throws { + let now = Date() + let metadata = try #require(ProviderDefaults.metadata[.deepseek]) + let snapshot = Self.makeSnapshot(now: now, usageSummary: Self.sampleDeepSeekSummary(now: now)) + + let model = UsageMenuCardView.Model.make(.init( + provider: .deepseek, + metadata: metadata, + snapshot: snapshot, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + + #expect(model.inlineUsageDashboard == nil) + #expect(model.usageNotes.isEmpty) + } } diff --git a/Tests/CodexBarTests/MenuCardHeightFingerprintTests.swift b/Tests/CodexBarTests/MenuCardHeightFingerprintTests.swift new file mode 100644 index 0000000000..7e2ec20c12 --- /dev/null +++ b/Tests/CodexBarTests/MenuCardHeightFingerprintTests.swift @@ -0,0 +1,92 @@ +import SwiftUI +import Testing +@testable import CodexBar + +struct MenuCardHeightFingerprintTests { + @Test + func `height fingerprint does not retain raw text fields`() { + let model = Self.model() + + let fingerprint = model.heightFingerprint(section: "card") + + #expect(!fingerprint.contains("very-secret@example.com")) + #expect(!fingerprint.contains("Secret Provider Name")) + #expect(!fingerprint.contains("Secret Metric")) + #expect(!fingerprint.contains("Secret note")) + } + + @Test + func `height fingerprint field distinguishes nil from empty string`() { + let nilField = UsageMenuCardView.Model.heightFingerprintField("storage", nil) + let emptyField = UsageMenuCardView.Model.heightFingerprintField("storage", "") + + #expect(nilField != emptyField) + } + + @Test + func `height fingerprint keeps cheap metric percent identity`() { + let left = Self.model(percent: 42, percentStyle: .left).heightFingerprint(section: "card") + let used = Self.model(percent: 42, percentStyle: .used).heightFingerprint(section: "card") + let changedPercent = Self.model(percent: 43, percentStyle: .left).heightFingerprint(section: "card") + + #expect(left != used) + #expect(left != changedPercent) + } + + @Test + func `height fingerprint tracks reset-credit inventory shape`() { + let one = Self.model(resetCredits: CodexResetCreditsPresentation( + text: "1 available", + items: [.init(expiryText: "Expires in 1d", compactExpiryText: "1d")])) + let two = Self.model(resetCredits: CodexResetCreditsPresentation( + text: "2 available", + items: [ + .init(expiryText: "Expires in 1d", compactExpiryText: "1d"), + .init(expiryText: "No expiry", compactExpiryText: "No expiry"), + ])) + + #expect(one.heightFingerprint(section: "card") != two.heightFingerprint(section: "card")) + } + + private static func model( + percent: Double = 42, + percentStyle: UsageMenuCardView.Model.PercentStyle = .left, + resetCredits: CodexResetCreditsPresentation? = nil) -> UsageMenuCardView.Model + { + UsageMenuCardView.Model( + provider: .codex, + providerName: "Secret Provider Name", + email: "very-secret@example.com", + subtitleText: "Signed in as very-secret@example.com", + subtitleStyle: .info, + planText: "Secret Plan", + metrics: [ + .init( + id: "primary", + title: "Secret Metric", + percent: percent, + percentStyle: percentStyle, + statusText: "Secret status", + resetText: nil, + detailText: nil, + detailLeftText: nil, + detailRightText: nil, + pacePercent: nil, + paceOnTop: true), + ], + usageNotes: ["Secret note"], + openAIAPIUsage: nil, + inlineUsageDashboard: nil, + creditsText: nil, + creditsRemaining: nil, + creditsProgressPercent: nil, + creditsScaleText: nil, + creditsHintText: nil, + creditsHintCopyText: nil, + codexResetCredits: resetCredits, + providerCost: nil, + tokenUsage: nil, + placeholder: nil, + progressColor: .blue) + } +} diff --git a/Tests/CodexBarTests/MenuCardModelCodexDegradedQuotaTests.swift b/Tests/CodexBarTests/MenuCardModelCodexDegradedQuotaTests.swift new file mode 100644 index 0000000000..0b43092371 --- /dev/null +++ b/Tests/CodexBarTests/MenuCardModelCodexDegradedQuotaTests.swift @@ -0,0 +1,265 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +struct MenuCardModelCodexDegradedQuotaTests { + @Test + func `codex local token usage hides remote quota unavailable error`() throws { + let now = Date(timeIntervalSince1970: 1_800_000_000) + let metadata = try #require(ProviderDefaults.metadata[.codex]) + let tokenSnapshot = CostUsageTokenSnapshot( + sessionTokens: 721_966, + sessionCostUSD: 1.081155, + last30DaysTokens: 824_405_060, + last30DaysCostUSD: 583.1287345, + daily: [ + .init( + date: "2026-06-05", + inputTokens: 710_217, + outputTokens: 11749, + totalTokens: 721_966, + costUSD: 1.081155, + modelsUsed: ["gpt-5.5"], + modelBreakdowns: nil), + ], + updatedAt: now) + + let model = UsageMenuCardView.Model.make(.init( + provider: .codex, + metadata: metadata, + snapshot: nil, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: tokenSnapshot, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: "Codex usage is temporarily unavailable. Try refreshing.", + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: true, + codexLocalSessionCostLedgerEnabled: true, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + + #expect(model.placeholder == nil) + #expect(model.subtitleStyle == .info) + #expect(model.subtitleText == "Not fetched yet") + #expect(model.usesStackedDetailLayout) + #expect(model.tokenUsage?.sessionLine.contains("$1.08") == true) + #expect(model.tokenUsage?.sessionLine.contains("tokens") == true) + #expect(model.tokenUsage?.monthLine.contains("$583.13") == true) + #expect(model.tokenUsage?.monthLine.contains("tokens") == true) + } + + @Test + func `codex managed token usage keeps remote quota unavailable error visible`() throws { + let error = "Codex usage is temporarily unavailable. Try refreshing." + let model = try self.makeModel( + tokenCostUsageEnabled: true, + codexLocalSessionCostLedgerEnabled: false, + lastError: error) + + #expect(model.subtitleStyle == .error) + #expect(model.subtitleText == error) + #expect(model.tokenUsage != nil) + } + + @Test + func `codex remote quota unavailable error stays visible when token usage is hidden`() throws { + let now = Date(timeIntervalSince1970: 1_800_000_000) + let metadata = try #require(ProviderDefaults.metadata[.codex]) + let tokenSnapshot = CostUsageTokenSnapshot( + sessionTokens: 721_966, + sessionCostUSD: 1.081155, + last30DaysTokens: 824_405_060, + last30DaysCostUSD: 583.1287345, + daily: [], + updatedAt: now) + let error = "Codex usage is temporarily unavailable. Try refreshing." + + let model = UsageMenuCardView.Model.make(.init( + provider: .codex, + metadata: metadata, + snapshot: nil, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: tokenSnapshot, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: error, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + + #expect(model.placeholder == nil) + #expect(model.subtitleStyle == .error) + #expect(model.subtitleText == error) + #expect(model.tokenUsage == nil) + } + + @Test + func `codex local token usage preserves limits unavailable placeholder`() throws { + let now = Date(timeIntervalSince1970: 1_800_000_000) + let metadata = try #require(ProviderDefaults.metadata[.codex]) + let tokenSnapshot = CostUsageTokenSnapshot( + sessionTokens: 721_966, + sessionCostUSD: 1.081155, + last30DaysTokens: 824_405_060, + last30DaysCostUSD: 583.1287345, + daily: [], + updatedAt: now) + + let model = UsageMenuCardView.Model.make(.init( + provider: .codex, + metadata: metadata, + snapshot: nil, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: tokenSnapshot, + tokenError: nil, + account: AccountInfo(email: "user@example.com", plan: "Pro"), + isRefreshing: false, + lastError: UsageError.noRateLimitsFound.errorDescription, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: true, + codexLocalSessionCostLedgerEnabled: true, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + + #expect(model.placeholder == "Limits not available") + #expect(model.subtitleStyle == .info) + #expect(model.tokenUsage != nil) + #expect(model.usesStackedDetailLayout) + } + + @Test + func `codex local token usage preserves sign-in guidance`() throws { + let model = try self.makeModel( + tokenCostUsageEnabled: true, + lastError: "Codex CLI is not signed in. Run `codex login --device-auth`, then refresh.") + + #expect(model.subtitleStyle == .error) + #expect(model.subtitleText.contains("codex login")) + #expect(model.tokenUsage != nil) + #expect(model.usesStackedDetailLayout) + } + + @Test + func `codex local token usage hides mapped remote transport error`() throws { + let now = Date(timeIntervalSince1970: 1_800_000_000) + let metadata = try #require(ProviderDefaults.metadata[.codex]) + let tokenSnapshot = CostUsageTokenSnapshot( + sessionTokens: 721_966, + sessionCostUSD: 1.081155, + last30DaysTokens: 824_405_060, + last30DaysCostUSD: 583.1287345, + daily: [], + updatedAt: now) + let error = try #require(CodexUIErrorMapper.userFacingMessage("Codex connection failed: timed out.")) + + let model = UsageMenuCardView.Model.make(.init( + provider: .codex, + metadata: metadata, + snapshot: nil, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: tokenSnapshot, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: error, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: true, + codexLocalSessionCostLedgerEnabled: true, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + + #expect(model.placeholder == nil) + #expect(model.subtitleStyle == .info) + #expect(model.subtitleText == "Not fetched yet") + #expect(model.tokenUsage?.sessionLine.contains("$1.08") == true) + } + + @Test + func `credits select stacked detail layout without quota metrics`() { + let model = UsageMenuCardView.Model( + provider: .codex, + providerName: "Codex", + email: "user@example.com", + subtitleText: "Not fetched yet", + subtitleStyle: .info, + planText: nil, + metrics: [], + usageNotes: [], + openAIAPIUsage: nil, + inlineUsageDashboard: nil, + creditsText: "$12.34 remaining", + creditsRemaining: 12.34, + creditsProgressPercent: nil, + creditsScaleText: nil, + creditsHintText: nil, + creditsHintCopyText: nil, + providerCost: nil, + tokenUsage: nil, + placeholder: "No usage yet", + progressColor: .blue) + + #expect(model.usesStackedDetailLayout) + } + + private func makeModel( + tokenCostUsageEnabled: Bool, + codexLocalSessionCostLedgerEnabled: Bool = true, + lastError: String?) throws -> UsageMenuCardView.Model + { + let now = Date(timeIntervalSince1970: 1_800_000_000) + let metadata = try #require(ProviderDefaults.metadata[.codex]) + let tokenSnapshot = CostUsageTokenSnapshot( + sessionTokens: 721_966, + sessionCostUSD: 1.081155, + last30DaysTokens: 824_405_060, + last30DaysCostUSD: 583.1287345, + daily: [], + updatedAt: now) + + return UsageMenuCardView.Model.make(.init( + provider: .codex, + metadata: metadata, + snapshot: nil, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: tokenSnapshot, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: lastError, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: tokenCostUsageEnabled, + codexLocalSessionCostLedgerEnabled: codexLocalSessionCostLedgerEnabled, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + } +} diff --git a/Tests/CodexBarTests/MenuCardModelCodexProjectionTests.swift b/Tests/CodexBarTests/MenuCardModelCodexProjectionTests.swift index cd6e59419a..19d5070175 100644 --- a/Tests/CodexBarTests/MenuCardModelCodexProjectionTests.swift +++ b/Tests/CodexBarTests/MenuCardModelCodexProjectionTests.swift @@ -127,14 +127,15 @@ struct MenuCardModelCodexProjectionTests { now: now)) let weekly = try #require(model.metrics.first { $0.id == "secondary" }) - #expect(weekly.warningMarkerPercents == [20.0, 40.0, 60.0, 80.0]) + #expect(weekly.warningMarkerPercents.isEmpty) + #expect(weekly.workdayMarkerPercents == [20.0, 40.0, 60.0, 80.0]) let session = try #require(model.metrics.first { $0.id == "primary" }) #expect(session.warningMarkerPercents.isEmpty) } @Test - func `codex weekly lane workday markers merge with quota warning markers`() throws { + func `codex weekly lane keeps workday and quota warning markers separate`() throws { let now = Date(timeIntervalSince1970: 1_800_000_000) let metadata = try #require(ProviderDefaults.metadata[.codex]) let identity = ProviderIdentitySnapshot( @@ -193,7 +194,8 @@ struct MenuCardModelCodexProjectionTests { now: now)) let weekly = try #require(model.metrics.first { $0.id == "secondary" }) - #expect(weekly.warningMarkerPercents == [20.0, 40.0, 50.0, 60.0, 80.0]) + #expect(weekly.warningMarkerPercents == [50.0]) + #expect(weekly.workdayMarkerPercents == [20.0, 40.0, 60.0, 80.0]) } @Test @@ -256,7 +258,8 @@ struct MenuCardModelCodexProjectionTests { now: now)) let weekly = try #require(model.metrics.first { $0.id == "secondary" }) - #expect(weekly.warningMarkerPercents == [20.0, 40.0, 60.0, 80.0]) + #expect(weekly.warningMarkerPercents.isEmpty) + #expect(weekly.workdayMarkerPercents == [20.0, 40.0, 60.0, 80.0]) } @Test @@ -632,9 +635,9 @@ struct MenuCardModelCodexProjectionTests { id: "codex-spark", title: "Codex Spark 5-hour", window: RateWindow( - usedPercent: 30, + usedPercent: 80, windowMinutes: 300, - resetsAt: now.addingTimeInterval(60 * 60), + resetsAt: now.addingTimeInterval(2 * 60 * 60), resetDescription: nil)), NamedRateWindow( id: "codex-spark-weekly", @@ -683,14 +686,18 @@ struct MenuCardModelCodexProjectionTests { let spark = try #require(model.metrics.first { $0.id == "codex-spark" }) #expect(spark.title == "Codex Spark 5-hour") - #expect(spark.percent == 70) - #expect(spark.percentLabel == "70% left") + #expect(spark.percent == 20) + #expect(spark.percentLabel == "20% left") #expect(spark.resetText != nil) + #expect(spark.detailLeftText == "20% in deficit") + #expect(spark.detailRightText == "Projected empty in 45m") let sparkWeekly = try #require(model.metrics.first { $0.id == "codex-spark-weekly" }) #expect(sparkWeekly.title == "Codex Spark Weekly") #expect(sparkWeekly.percent == 0) #expect(sparkWeekly.percentLabel == "0% left") #expect(sparkWeekly.resetText != nil) + #expect(sparkWeekly.detailLeftText == nil) + #expect(sparkWeekly.detailRightText == nil) // Spark trails the core session/weekly lanes rather than replacing them. let sparkIndex = try #require(model.metrics.firstIndex { $0.id == "codex-spark" }) let sparkWeeklyIndex = try #require(model.metrics.firstIndex { $0.id == "codex-spark-weekly" }) @@ -750,9 +757,11 @@ struct MenuCardModelCodexProjectionTests { #expect(model.creditsText == nil) } +} +struct MenuCardModelCodexSparkVisibilityTests { @Test - func `hides codex spark extra metric when showOptionalCreditsAndExtraUsage is false`() throws { + func `codex spark visibility hides only spark metrics`() throws { let now = Date(timeIntervalSince1970: 1_800_000_000) let metadata = try #require(ProviderDefaults.metadata[.codex]) let identity = ProviderIdentitySnapshot( @@ -789,6 +798,14 @@ struct MenuCardModelCodexProjectionTests { windowMinutes: 10080, resetsAt: now.addingTimeInterval(6 * 24 * 60 * 60), resetDescription: nil)), + NamedRateWindow( + id: "codex-other-limit", + title: "Other Codex limit", + window: RateWindow( + usedPercent: 25, + windowMinutes: 1440, + resetsAt: now.addingTimeInterval(12 * 60 * 60), + resetDescription: nil)), ], updatedAt: now, identity: identity) @@ -797,7 +814,7 @@ struct MenuCardModelCodexProjectionTests { context: CodexConsumerProjection.Context( snapshot: snapshot, rawUsageError: nil, - liveCredits: nil, + liveCredits: CreditsSnapshot(remaining: 12, events: [], updatedAt: now), rawCreditsError: nil, liveDashboard: nil, rawDashboardError: nil, @@ -810,7 +827,7 @@ struct MenuCardModelCodexProjectionTests { metadata: metadata, snapshot: snapshot, codexProjection: projection, - credits: nil, + credits: CreditsSnapshot(remaining: 12, events: [], updatedAt: now), creditsError: nil, dashboard: nil, dashboardError: nil, @@ -822,7 +839,8 @@ struct MenuCardModelCodexProjectionTests { usageBarsShowUsed: false, resetTimeDisplayStyle: .countdown, tokenCostUsageEnabled: false, - showOptionalCreditsAndExtraUsage: false, + showOptionalCreditsAndExtraUsage: true, + codexSparkUsageVisible: false, hidePersonalInfo: false, now: now)) @@ -830,5 +848,34 @@ struct MenuCardModelCodexProjectionTests { #expect(!model.metrics.contains { $0.id == "codex-spark-weekly" }) #expect(model.metrics.contains { $0.id == "primary" }) #expect(model.metrics.contains { $0.id == "secondary" }) + #expect(model.metrics.contains { $0.id == "codex-other-limit" }) + #expect(model.creditsText != nil) + + let globalOffModel = UsageMenuCardView.Model.make(.init( + provider: .codex, + metadata: metadata, + snapshot: snapshot, + codexProjection: projection, + credits: CreditsSnapshot(remaining: 12, events: [], updatedAt: now), + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: "user@example.com", plan: "Pro"), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: false, + codexSparkUsageVisible: true, + hidePersonalInfo: false, + now: now)) + + #expect(!globalOffModel.metrics.contains { $0.id == "codex-spark" }) + #expect(!globalOffModel.metrics.contains { $0.id == "codex-spark-weekly" }) + #expect(!globalOffModel.metrics.contains { $0.id == "codex-other-limit" }) + #expect(globalOffModel.creditsText == nil) } } diff --git a/Tests/CodexBarTests/MenuCardModelTests.swift b/Tests/CodexBarTests/MenuCardModelTests.swift index 6e64710af2..e41890e11e 100644 --- a/Tests/CodexBarTests/MenuCardModelTests.swift +++ b/Tests/CodexBarTests/MenuCardModelTests.swift @@ -57,44 +57,71 @@ struct OverviewMenuCardVisibilityTests { #expect(model.placeholder == "Limits not available") #expect(!model.isOverviewErrorOnly) } + + @Test + func `claude subscription-only quota keeps local cost content`() throws { + let metadata = try #require(ProviderDefaults.metadata[.claude]) + let now = Date(timeIntervalSince1970: 1_800_000_000) + let tokenSnapshot = CostUsageTokenSnapshot( + sessionTokens: 4200, + sessionCostUSD: 1.25, + last30DaysTokens: 42000, + last30DaysCostUSD: 12.50, + daily: [], + updatedAt: now) + let quotaError = ClaudeStatusProbeError.parseFailed( + ClaudeStatusProbe.subscriptionQuotaUnavailableDescription).localizedDescription + let model = UsageMenuCardView.Model.make(.init( + provider: .claude, + metadata: metadata, + snapshot: nil, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: tokenSnapshot, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: quotaError, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: true, + tokenCostMenuSectionEnabled: true, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + + #expect(model.placeholder == "Limits not available") + #expect(model.subtitleStyle == .info) + #expect(model.tokenUsage != nil) + #expect(model.metrics.isEmpty) + #expect(!model.isOverviewErrorOnly) + } } struct ProviderInlineDashboardModelTests { @Test - func `claude admin api usage gets inline dashboard`() throws { - let now = Date(timeIntervalSince1970: 1_700_179_200) - let metadata = try #require(ProviderDefaults.metadata[.claude]) - let usage = ClaudeAdminAPIUsageSnapshot( - daily: [ - ClaudeAdminAPIUsageSnapshot.DailyBucket( - day: "2023-11-14", - startTime: now, - endTime: now.addingTimeInterval(86400), - costUSD: 1.25, - inputTokens: 1000, - cacheCreationInputTokens: 400, - cacheReadInputTokens: 300, - outputTokens: 250, - totalTokens: 1950, - costItems: [ - ClaudeAdminAPIUsageSnapshot.CostBreakdown(name: "Claude Sonnet Usage", costUSD: 1.25), - ], - models: [ - ClaudeAdminAPIUsageSnapshot.ModelBreakdown( - name: "claude-sonnet-4-20250514", - inputTokens: 1000, - cacheCreationInputTokens: 400, - cacheReadInputTokens: 300, - outputTokens: 250, - totalTokens: 1950), - ]), - ], + func `kimi model orders rate limit before weekly quota and shows pace`() throws { + let now = Date(timeIntervalSince1970: 1_800_000_000) + let metadata = try #require(ProviderDefaults.metadata[.kimi]) + let snapshot = UsageSnapshot( + primary: RateWindow( + usedPercent: 18.3, + windowMinutes: KimiProviderDescriptor.weeklyWindowMinutes, + resetsAt: now.addingTimeInterval(4 * 24 * 60 * 60), + resetDescription: "375/2048 requests"), + secondary: RateWindow( + usedPercent: 9.5, + windowMinutes: KimiProviderDescriptor.sessionWindowMinutes, + resetsAt: now.addingTimeInterval(4 * 60 * 60), + resetDescription: "Rate: 19/200 per 5 hours"), updatedAt: now) let model = UsageMenuCardView.Model.make(.init( - provider: .claude, + provider: .kimi, metadata: metadata, - snapshot: usage.toUsageSnapshot(), + snapshot: snapshot, credits: nil, creditsError: nil, dashboard: nil, @@ -111,13 +138,11 @@ struct ProviderInlineDashboardModelTests { hidePersonalInfo: false, now: now)) - #expect(model.metrics.isEmpty) - #expect(model.inlineUsageDashboard?.kpis.first?.value == "$1.25") - #expect(model.inlineUsageDashboard?.points.first?.accessibilityValue == "2023-11-14: $1.25") - #expect(model.inlineUsageDashboard?.detailLines - .contains { $0.hasPrefix("30d:") && $0.contains("tokens") } == true) - #expect(model.inlineUsageDashboard?.detailLines.contains("Top model: claude-sonnet-4-20250514") == true) - #expect(model.planText == "Admin API") + #expect(model.metrics.map(\.id) == ["secondary", "primary"]) + #expect(model.metrics.map(\.title) == ["Rate Limit", "Weekly"]) + #expect(model.metrics.map(\.detailLeftText) == ["11% in reserve", "25% in reserve"]) + #expect(model.metrics.map(\.detailRightText) == ["Lasts until reset", "Lasts until reset"]) + #expect(model.metrics.allSatisfy { $0.pacePercent != nil }) } @Test @@ -281,6 +306,7 @@ struct ProviderInlineDashboardModelTests { usageBarsShowUsed: false, resetTimeDisplayStyle: .countdown, tokenCostUsageEnabled: false, + tokenCostInlineDashboardEnabled: true, showOptionalCreditsAndExtraUsage: true, hidePersonalInfo: false, now: now)) @@ -292,6 +318,9 @@ struct ProviderInlineDashboardModelTests { @Test func `mistral billing usage can show cost card summary`() throws { + let formatter = ISO8601DateFormatter() + let monthStart = try #require(formatter.date(from: "2023-11-01T00:00:00Z")) + let monthEnd = try #require(formatter.date(from: "2023-11-30T23:59:59Z")) let now = Date(timeIntervalSince1970: 1_700_179_200) let metadata = try #require(ProviderDefaults.metadata[.mistral]) let snapshot = MistralUsageSnapshot( @@ -318,8 +347,8 @@ struct ProviderInlineDashboardModelTests { outputTokens: 50), ]), ], - startDate: nil, - endDate: nil, + startDate: monthStart, + endDate: monthEnd, updatedAt: now) let model = UsageMenuCardView.Model.make(.init( @@ -531,7 +560,7 @@ struct FactoryMenuCardModelTests { struct MiniMaxMenuCardModelTests { @Test - func `minimax service metrics use quota card copy`() throws { + func `minimax service metrics use codex aligned quota copy`() throws { let now = Date() let minimax = MiniMaxUsageSnapshot( planName: "Max", @@ -587,10 +616,10 @@ struct MiniMaxMenuCardModelTests { #expect(used.metrics.first?.title == "Text Generation") #expect(used.metrics.first?.detailLeftText == "Usage: 2 / 10") - #expect(used.metrics.first?.detailRightText == "Used 20%") - #expect(used.metrics.first?.detailText == "10:00-15:00(UTC+8)") + #expect(used.metrics.first?.detailRightText == nil) + #expect(used.metrics.first?.detailText == nil) #expect(used.metrics.first?.percent == 20) - #expect(used.metrics.first?.cardStyle == true) + #expect(used.metrics.first?.cardStyle == false) } @Test @@ -661,28 +690,46 @@ struct MiniMaxMenuCardModelTests { #expect(model.metrics[0].title == "Text Generation · Today") #expect(model.metrics[1].title == "Text Generation · Weekly") } -} -struct ClaudeMenuCardCostTests { @Test - func `claude extra usage labels monthly denominator as cap`() throws { + func `minimax token plan model shows weekly quota and points balance`() throws { let now = Date() - let metadata = try #require(ProviderDefaults.metadata[.claude]) - let snapshot = UsageSnapshot( - primary: RateWindow(usedPercent: 0, windowMinutes: nil, resetsAt: nil, resetDescription: nil), - secondary: nil, - tertiary: nil, - providerCost: ProviderCostSnapshot( - used: 5, - limit: 20, - currencyCode: "USD", - period: "Monthly cap", - updatedAt: now), + let minimax = MiniMaxUsageSnapshot( + planName: "Token Plan · TokenPlanPlus-年度会员", + availablePrompts: nil, + currentPrompts: nil, + remainingPrompts: nil, + windowMinutes: nil, + usedPercent: nil, + resetsAt: nil, updatedAt: now, - identity: nil) + services: [ + MiniMaxServiceUsage( + serviceType: "text-generation", + windowType: "5 hours", + timeRange: "10:00-15:00(UTC+8)", + usage: 4, + limit: 100, + percent: 4, + resetsAt: now.addingTimeInterval(4 * 3600), + resetDescription: "Resets in 4 hours"), + MiniMaxServiceUsage( + serviceType: "text-generation", + windowType: "Weekly", + timeRange: "06/01 00:00 - 06/08 00:00(UTC+8)", + usage: 1, + limit: 100, + percent: 1, + resetsAt: now.addingTimeInterval(6 * 24 * 3600), + resetDescription: "Resets in 6 days"), + ], + pointsBalance: 14000, + subscriptionRenewsAt: Date(timeIntervalSince1970: 1_810_569_600)) + let snapshot = minimax.toUsageSnapshot() + let metadata = try #require(ProviderDefaults.metadata[.minimax]) let model = UsageMenuCardView.Model.make(.init( - provider: .claude, + provider: .minimax, metadata: metadata, snapshot: snapshot, credits: nil, @@ -694,14 +741,27 @@ struct ClaudeMenuCardCostTests { account: AccountInfo(email: nil, plan: nil), isRefreshing: false, lastError: nil, - usageBarsShowUsed: false, + usageBarsShowUsed: true, resetTimeDisplayStyle: .countdown, tokenCostUsageEnabled: false, showOptionalCreditsAndExtraUsage: true, hidePersonalInfo: false, now: now)) - #expect(model.providerCost?.spendLine == "Monthly cap: $5.00 / $20.00") + #expect(model.planText == "Plus") + #expect(model.metrics[0].title == "Text Generation · 5h") + #expect(model.metrics[1].title == "Text Generation · Weekly") + #expect(model.metrics[0].detailLeftText == "Usage: 4 / 100") + #expect(model.metrics[1].detailLeftText == "Usage: 1 / 100") + #expect(model.metrics[0].detailRightText == nil) + #expect(model.metrics[1].detailRightText == nil) + #expect(model.metrics[0].detailText == nil) + #expect(model.metrics[1].detailText == nil) + #expect(model.metrics[0].cardStyle == false) + #expect(model.metrics[1].cardStyle == false) + #expect(model.providerCost?.title == "Credits") + #expect(model.providerCost?.spendLine == "Balance: 14000") + #expect(model.usageNotes == [String(format: L("Renews: %@"), minimaxRenewDate(1_810_569_600))]) } } @@ -828,66 +888,6 @@ struct MenuCardModelTests { #expect(model.planText == "Max") } - @Test - func `claude model includes routines bar when present`() throws { - let now = Date() - let identity = ProviderIdentitySnapshot( - providerID: .claude, - accountEmail: nil, - accountOrganization: nil, - loginMethod: "Max") - let snapshot = UsageSnapshot( - primary: RateWindow( - usedPercent: 2, - windowMinutes: nil, - resetsAt: now.addingTimeInterval(3600), - resetDescription: nil), - secondary: RateWindow( - usedPercent: 8, - windowMinutes: 10080, - resetsAt: now.addingTimeInterval(7200), - resetDescription: nil), - tertiary: RateWindow( - usedPercent: 16, - windowMinutes: 10080, - resetsAt: now.addingTimeInterval(7800), - resetDescription: nil), - extraRateWindows: [ - NamedRateWindow( - id: "claude-routines", - title: "Daily Routines", - window: RateWindow( - usedPercent: 7, - windowMinutes: 10080, - resetsAt: now.addingTimeInterval(9200), - resetDescription: nil)), - ], - updatedAt: now, - identity: identity) - let metadata = try #require(ProviderDefaults.metadata[.claude]) - let model = UsageMenuCardView.Model.make(.init( - provider: .claude, - metadata: metadata, - snapshot: snapshot, - credits: nil, - creditsError: nil, - dashboard: nil, - dashboardError: nil, - tokenSnapshot: nil, - tokenError: nil, - account: AccountInfo(email: "codex@example.com", plan: "plus"), - isRefreshing: false, - lastError: nil, - usageBarsShowUsed: false, - resetTimeDisplayStyle: .countdown, - tokenCostUsageEnabled: false, - showOptionalCreditsAndExtraUsage: true, - hidePersonalInfo: false, - now: now)) - - #expect(model.metrics.map(\.title) == ["Session", "Weekly", "Sonnet", "Daily Routines"]) - } - @Test func `shows error subtitle when present`() throws { let metadata = try #require(ProviderDefaults.metadata[.codex]) @@ -954,7 +954,7 @@ struct MenuCardModelTests { #expect(model.tokenUsage?.monthLine.contains("456") == true) #expect(model.tokenUsage?.monthLine.contains("tokens") == true) - #expect(model.tokenUsage?.hintLine == "Estimated from local Codex logs for the selected account.") + #expect(model.tokenUsage?.hintLine == "Estimated from token usage · not a subscription bill") } @Test @@ -1187,7 +1187,7 @@ struct MenuCardModelTests { hidePersonalInfo: true, now: now)) - #expect(model.email == "Hidden") + #expect(model.email.isEmpty) #expect(model.subtitleText.contains("codex@example.com") == false) #expect(model.creditsHintCopyText?.isEmpty == true) #expect(model.creditsHintText?.contains("codex@example.com") == false) @@ -1472,50 +1472,4 @@ struct MenuCardModelTests { #expect(primary.resetText == nil) #expect(primary.detailText == "10/100 credits") } - - @Test - func `mistral model surfaces monthly cost as primary detail text`() throws { - let now = Date() - let resetsAt = now.addingTimeInterval(3 * 24 * 60 * 60) - let identity = ProviderIdentitySnapshot( - providerID: .mistral, - accountEmail: nil, - accountOrganization: nil, - loginMethod: nil) - let snapshot = UsageSnapshot( - primary: RateWindow( - usedPercent: 0, - windowMinutes: nil, - resetsAt: resetsAt, - resetDescription: "€1.2345 this month"), - secondary: nil, - tertiary: nil, - updatedAt: now, - identity: identity) - let metadata = try #require(ProviderDefaults.metadata[.mistral]) - - let model = UsageMenuCardView.Model.make(.init( - provider: .mistral, - metadata: metadata, - snapshot: snapshot, - credits: nil, - creditsError: nil, - dashboard: nil, - dashboardError: nil, - tokenSnapshot: nil, - tokenError: nil, - account: AccountInfo(email: nil, plan: nil), - isRefreshing: false, - lastError: nil, - usageBarsShowUsed: true, - resetTimeDisplayStyle: .countdown, - tokenCostUsageEnabled: false, - showOptionalCreditsAndExtraUsage: true, - hidePersonalInfo: false, - now: now)) - - let primary = try #require(model.metrics.first) - #expect(primary.detailText == "€1.2345 this month") - #expect(primary.resetText?.hasPrefix("Resets") == true) - } } diff --git a/Tests/CodexBarTests/MenuCardNeuralWattTests.swift b/Tests/CodexBarTests/MenuCardNeuralWattTests.swift new file mode 100644 index 0000000000..f017182d13 --- /dev/null +++ b/Tests/CodexBarTests/MenuCardNeuralWattTests.swift @@ -0,0 +1,107 @@ +import Foundation +import Testing +@testable import CodexBar +@testable import CodexBarCore + +struct MenuCardNeuralWattTests { + @Test + func `model shows prepaid balance as pay as you go`() throws { + let now = Date(timeIntervalSince1970: 1_800_000_000) + let snapshot = NeuralWattUsageSnapshot( + creditsRemainingUSD: 51.00, + totalCreditsUSD: 77.04, + creditsUsedUSD: 26.04, + accountingMethod: "energy", + currentMonthCostUSD: 12.34, + currentMonthEnergyKWh: 0.25, + subscription: nil, + keyAllowance: nil, + rateLimitTier: "standard", + updatedAt: now) + .toUsageSnapshot() + let metadata = try #require(ProviderDefaults.metadata[.neuralwatt]) + + let model = UsageMenuCardView.Model.make(.init( + provider: .neuralwatt, + metadata: metadata, + snapshot: snapshot, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + + #expect(model.metrics.isEmpty) + let prepaid = try #require(model.providerCost) + #expect(prepaid.title == "Pay-as-you-go") + #expect(prepaid.spendLine.replacingOccurrences(of: "\u{00A0}", with: "") == "Balance: $51.00") + #expect(model.creditsText == nil) + } + + @Test + func `model shows subscription quota and separate prepaid balance`() throws { + let now = Date(timeIntervalSince1970: 1_800_000_000) + let subscription = NeuralWattSubscription( + plan: "pro", + status: "active", + billingInterval: "month", + currentPeriodStart: now.addingTimeInterval(-10 * 24 * 60 * 60), + currentPeriodEnd: now.addingTimeInterval(20 * 24 * 60 * 60), + autoRenew: true, + kwhIncluded: 10, + kwhUsed: 2.5, + kwhRemaining: 7.5, + inOverage: false) + let snapshot = NeuralWattUsageSnapshot( + creditsRemainingUSD: 0, + totalCreditsUSD: 0, + creditsUsedUSD: 0, + accountingMethod: "energy", + currentMonthCostUSD: nil, + currentMonthEnergyKWh: nil, + subscription: subscription, + keyAllowance: nil, + rateLimitTier: "standard", + updatedAt: now) + .toUsageSnapshot() + let metadata = try #require(ProviderDefaults.metadata[.neuralwatt]) + + let model = UsageMenuCardView.Model.make(.init( + provider: .neuralwatt, + metadata: metadata, + snapshot: snapshot, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: true, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + + let primary = try #require(model.metrics.first) + #expect(primary.title == "Subscription") + #expect(primary.percent == 25) + #expect(primary.detailText == "2.50 / 10 kWh") + #expect(primary.statusText == nil) + #expect(primary.resetText == "Resets in 20d") + #expect(model.providerCost?.spendLine.replacingOccurrences(of: "\u{00A0}", with: "") == "Balance: $0.00") + } +} diff --git a/Tests/CodexBarTests/MenuCardOverrideIsolationTests.swift b/Tests/CodexBarTests/MenuCardOverrideIsolationTests.swift new file mode 100644 index 0000000000..e2ea1fb5e2 --- /dev/null +++ b/Tests/CodexBarTests/MenuCardOverrideIsolationTests.swift @@ -0,0 +1,299 @@ +import AppKit +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +@MainActor +struct MenuCardOverrideIsolationTests { + @Test + func `explicit selected token account adopts legacy unscoped history`() throws { + let store = UsageStorePlanUtilizationTests.makeStore() + store.settings.addTokenAccount(provider: .claude, label: "Alice", token: "fixture") + let account = try #require(store.settings.selectedTokenAccount(for: .claude)) + let accountKey = try #require( + UsageStore._planUtilizationTokenAccountKeyForTesting(provider: .claude, account: account)) + let legacyHistory = [planSeries(name: .weekly, windowMinutes: 10080, entries: [ + planEntry(at: Date(timeIntervalSince1970: 1_700_000_000), usedPercent: 20), + ])] + store.planUtilizationHistory[.claude] = PlanUtilizationHistoryBuckets(unscoped: legacyHistory) + let originalRevision = store.planUtilizationHistoryRevision + + let selection = store.planUtilizationHistorySelection(for: .claude, account: account) + + #expect(selection.accountKey == accountKey) + #expect(selection.histories == legacyHistory) + #expect(store.planUtilizationHistory[.claude]?.unscoped.isEmpty == true) + #expect(store.planUtilizationHistoryRevision == originalRevision + 1) + } + + @Test + func `nil snapshot account card does not inherit ambient Claude costs`() throws { + let suite = "MenuCardOverrideIsolationTests-\(UUID().uuidString)" + 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.costUsageEnabled = true + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + store._setTokenSnapshotForTesting( + CostUsageTokenSnapshot( + sessionTokens: 123, + sessionCostUSD: 0.12, + last30DaysTokens: 456, + last30DaysCostUSD: 1.23, + daily: [], + updatedAt: Date()), + provider: .claude) + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: .system) + + let model = try #require(controller.menuCardModel( + for: .claude, + errorOverride: "Token expired", + forceOverrideCard: true, + accountOverride: AccountInfo(email: "account@example.com", plan: nil))) + + #expect(model.tokenUsage == nil) + #expect(model.email == "account@example.com") + } + + @Test + func `account card without its own error does not inherit the ambient Claude error`() throws { + let suite = "MenuCardOverrideIsolationTests-\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + let settings = SettingsStore( + userDefaults: defaults, + configStore: testConfigStore(suiteName: suite), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + store._setErrorForTesting("Claude OAuth credentials unavailable", provider: .claude) + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: .system) + let accountSnapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 25, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + secondary: nil, + updatedAt: Date(), + identity: ProviderIdentitySnapshot( + providerID: .claude, + accountEmail: "account@example.com", + accountOrganization: nil, + loginMethod: "claude-swap")) + + let model = try #require(controller.menuCardModel( + for: .claude, + snapshotOverride: accountSnapshot, + accountOverride: AccountInfo(email: "account@example.com", plan: nil))) + + #expect(model.subtitleStyle != .error) + #expect(!model.subtitleText.contains("Claude OAuth credentials unavailable")) + + let liveModel = try #require(controller.menuCardModel(for: .claude)) + #expect(liveModel.subtitleStyle == .error) + #expect(liveModel.subtitleText == "Claude OAuth credentials unavailable") + } + + @Test + func `stacked token account card uses its own session equivalent history`() throws { + let store = UsageStorePlanUtilizationTests.makeStore() + store.settings.addTokenAccount(provider: .claude, label: "Alice", token: "fixture") + store.settings.addTokenAccount(provider: .claude, label: "Bob", token: "fixture") + let accounts = store.settings.tokenAccounts(for: .claude) + let alice = try #require(accounts.first) + let bob = try #require(accounts.last) + let aliceKey = try #require( + UsageStore._planUtilizationTokenAccountKeyForTesting(provider: .claude, account: alice)) + let bobKey = try #require( + UsageStore._planUtilizationTokenAccountKeyForTesting(provider: .claude, account: bob)) + store.settings.setActiveTokenAccountIndex(0, for: .claude) + + let now = Date() + let currentSessionReset = now.addingTimeInterval(2 * 3600) + store.planUtilizationHistory[.claude] = PlanUtilizationHistoryBuckets(accounts: [ + aliceKey: Self.sessionEquivalentHistory( + burnPerWindow: 20, + currentSessionReset: currentSessionReset), + bobKey: Self.sessionEquivalentHistory( + burnPerWindow: 5, + currentSessionReset: currentSessionReset), + ]) + store.planUtilizationHistoryRevision = 1 + + let snapshot = UsageSnapshot( + primary: RateWindow( + usedPercent: 20, + windowMinutes: 300, + resetsAt: currentSessionReset, + resetDescription: nil), + secondary: RateWindow( + usedPercent: 60, + windowMinutes: 10080, + resetsAt: now.addingTimeInterval(2 * 24 * 3600), + resetDescription: nil), + updatedAt: now, + identity: ProviderIdentitySnapshot( + providerID: .claude, + accountEmail: "bob@example.com", + accountOrganization: nil, + loginMethod: "max")) + let controller = StatusItemController( + store: store, + settings: store.settings, + account: AccountInfo(email: nil, plan: nil), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: .system) + + let model = try #require(controller.tokenAccountMenuCardModel( + for: .claude, + accountSnapshot: TokenAccountUsageSnapshot( + account: bob, + snapshot: snapshot, + error: nil, + sourceLabel: nil, + cacheKey: "bob"))) + let weeklyMetric = try #require(model.metrics.first { $0.id == "secondary" }) + let leftText = try #require(weeklyMetric.sessionEquivalentDetail?.leftText) + + #expect(leftText == "Est. 8 session quotas left") + #expect(leftText != "Est. 2 session quotas left") + } + + @Test + func `failed stacked token account card keeps its configured label`() throws { + let suite = "MenuCardOverrideIsolationTests-\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + let settings = SettingsStore( + userDefaults: defaults, + configStore: testConfigStore(suiteName: suite), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + 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) + let account = ProviderTokenAccount( + id: UUID(), + label: "Rejected group", + token: "fixture", + addedAt: 0, + lastUsed: nil) + let accountSnapshot = TokenAccountUsageSnapshot( + account: account, + snapshot: nil, + error: "sub2api rejected the API key.", + sourceLabel: nil, + cacheKey: "fixture-cache") + + let model = try #require(controller.tokenAccountMenuCardModel( + for: .sub2api, + accountSnapshot: accountSnapshot)) + + #expect(model.email == "Rejected group") + #expect(model.subtitleStyle == .error) + } + + @Test + func `successful stacked token account card prefers fetched identity over configured label`() throws { + let suite = "MenuCardOverrideIsolationTests-\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + let settings = SettingsStore( + userDefaults: defaults, + configStore: testConfigStore(suiteName: suite), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + 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) + let account = ProviderTokenAccount( + id: UUID(), + label: "Configured group", + token: "fixture", + addedAt: 0, + lastUsed: nil) + let usage = UsageSnapshot( + primary: RateWindow(usedPercent: 25, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + secondary: nil, + updatedAt: Date(), + identity: ProviderIdentitySnapshot( + providerID: .sub2api, + accountEmail: "fetched@example.com", + accountOrganization: nil, + loginMethod: nil)) + let accountSnapshot = TokenAccountUsageSnapshot( + account: account, + snapshot: usage, + error: nil, + sourceLabel: "api", + cacheKey: "fixture-cache") + + let model = try #require(controller.tokenAccountMenuCardModel( + for: .sub2api, + accountSnapshot: accountSnapshot)) + + #expect(model.email == "fetched@example.com") + } + + private static func sessionEquivalentHistory( + burnPerWindow: Double, + currentSessionReset: Date) -> [PlanUtilizationSeriesHistory] + { + let duration: TimeInterval = 5 * 3600 + let start = currentSessionReset.addingTimeInterval(-4 * duration) + let weeklyReset = currentSessionReset.addingTimeInterval(6 * 24 * 3600) + var sessionEntries: [PlanUtilizationHistoryEntry] = [] + var weeklyEntries: [PlanUtilizationHistoryEntry] = [] + var weeklyUsed = 0.0 + + for index in 0..<3 { + let windowStart = start.addingTimeInterval(Double(index) * duration) + let reset = windowStart.addingTimeInterval(duration) + sessionEntries.append(planEntry( + at: windowStart.addingTimeInterval(30 * 60), + usedPercent: 20, + resetsAt: reset)) + sessionEntries.append(planEntry( + at: reset.addingTimeInterval(-30 * 60), + usedPercent: 100, + resetsAt: reset)) + weeklyEntries.append(planEntry(at: windowStart, usedPercent: weeklyUsed, resetsAt: weeklyReset)) + weeklyUsed += burnPerWindow + weeklyEntries.append(planEntry(at: reset, usedPercent: weeklyUsed, resetsAt: weeklyReset)) + } + + return [ + planSeries(name: .session, windowMinutes: 300, entries: sessionEntries), + planSeries(name: .weekly, windowMinutes: 10080, entries: weeklyEntries), + ] + } +} diff --git a/Tests/CodexBarTests/MenuCardProviderRegressionTests.swift b/Tests/CodexBarTests/MenuCardProviderRegressionTests.swift index 08bcc94eeb..8b2b0dcb21 100644 --- a/Tests/CodexBarTests/MenuCardProviderRegressionTests.swift +++ b/Tests/CodexBarTests/MenuCardProviderRegressionTests.swift @@ -5,11 +5,42 @@ import Testing @testable import CodexBar struct MenuCardProviderRegressionTests { + @Test + func `menu card keeps positive sub percent usage visible`() { + let metric = UsageMenuCardView.Model.Metric( + id: "sub-percent", + title: "Monthly", + percent: 0.1, + percentStyle: .used, + resetText: nil, + detailText: nil, + detailLeftText: nil, + detailRightText: nil, + pacePercent: nil, + paceOnTop: false) + + #expect(metric.percentLabel == "<1% used") + } + @Test func `elevenlabs progress color stays visible in light menus`() { #expect(UsageMenuCardView.Model.progressColor(for: .elevenlabs) == Color(nsColor: .labelColor)) } + @Test + func `command code progress color uses its contrasting brand accent`() { + let branding = ProviderDescriptorRegistry.descriptor(for: .commandcode).branding.color + let expected = ProviderColor(hex: 0xA04DFD) + + #expect(branding == expected) + #expect(UsageMenuCardView.Model.progressColor(for: .commandcode) == Color( + red: expected.red, + green: expected.green, + blue: expected.blue)) + #expect(Self.contrastRatio(expected, againstLuminance: 0) >= 3) + #expect(Self.contrastRatio(expected, againstLuminance: 1) >= 3) + } + @Test func `open router model shows daily and weekly key spend`() throws { let now = Date() @@ -49,6 +80,103 @@ struct MenuCardProviderRegressionTests { #expect(model.usageNotes == ["Today: $0.12 · This week: $0.74"]) } + private static func contrastRatio(_ color: ProviderColor, againstLuminance background: Double) -> Double { + let components = [color.red, color.green, color.blue].map { component in + component <= 0.04045 + ? component / 12.92 + : pow((component + 0.055) / 1.055, 2.4) + } + let foreground = 0.2126 * components[0] + 0.7152 * components[1] + 0.0722 * components[2] + return (max(foreground, background) + 0.05) / (min(foreground, background) + 0.05) + } + + @Test + func `ollama api key model explains browser session quota requirement`() throws { + let now = Date() + let metadata = try #require(ProviderDefaults.metadata[.ollama]) + let snapshot = OllamaAPIUsageSnapshot(modelCount: 3, updatedAt: now).toUsageSnapshot() + + let model = UsageMenuCardView.Model.make(.init( + provider: .ollama, + metadata: metadata, + snapshot: snapshot, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + sourceLabel: "api", + hidePersonalInfo: false, + now: now)) + + #expect(model.metrics.isEmpty) + #expect(model.placeholder == nil) + #expect(model.planText == "API key") + #expect(model.usageNotes == [ + "API key verified. Cloud quotas need browser cookies. Sign in to Ollama.", + ]) + } + + @Test + func `wayfinder model shows gateway routing savings and latency`() throws { + let now = Date() + let metadata = try #require(ProviderDefaults.metadata[.wayfinder]) + let usage = WayfinderUsageSnapshot( + gatewayStatus: "ok", + offline: false, + dryRun: true, + missingKeys: [], + modelCount: 2, + requests: 14, + tokens: 1028, + realized: 0.003558, + baseline: 0.009252, + saved: 0.005694, + savedPct: 61.5, + priced: true, + routes: [ + .init(name: "local", requests: 10, saved: 0.005694, tokens: 662), + .init(name: "cloud", requests: 4, saved: 0, tokens: 366), + ], + avgDecisionMs: 0.0804, + updatedAt: now) + + let model = UsageMenuCardView.Model.make(.init( + provider: .wayfinder, + metadata: metadata, + snapshot: usage.toUsageSnapshot(), + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + + #expect(model.usageNotes == [ + "Gateway: ok · 2 models · dry run", + "Routed: local: 10 · cloud: 4", + "Saved: <$0.01 · 61.5% vs highest-cost route", + "Avg decision: 0.1 ms", + ]) + } + @Test func `copilot over quota usage keeps used percentage detail`() throws { let now = Date() diff --git a/Tests/CodexBarTests/MenuCardQuotaWarningMarkerTests.swift b/Tests/CodexBarTests/MenuCardQuotaWarningMarkerTests.swift index 58c3c28a21..744f6fa7c0 100644 --- a/Tests/CodexBarTests/MenuCardQuotaWarningMarkerTests.swift +++ b/Tests/CodexBarTests/MenuCardQuotaWarningMarkerTests.swift @@ -5,16 +5,96 @@ import Testing struct MenuCardQuotaWarningMarkerTests { @Test - func `quota warning marker geometry is inset and hairline`() { + func `progress fill matches rounded edge labels`() { + #expect(UsageProgressBar.renderedFillPercent(0.4) == 0) + #expect(UsageProgressBar.renderedFillPercent(0.6) == 0.6) + #expect(UsageProgressBar.renderedFillPercent(99.4) == 99.4) + #expect(UsageProgressBar.renderedFillPercent(99.6) == 100) + } + + @Test + func `quota warning marker geometry matches pace stripe edges`() { let rect = UsageProgressBar.warningMarkerRect( x: 50, size: CGSize(width: 100, height: 6), scale: 2) + let stripe = UsageProgressBar.warningMarkerStripeRect( + rect, + scale: 2) - #expect(rect.width == 1) - #expect(rect.height < 6) - #expect(rect.minY > 0) + #expect(rect.width == 5) + #expect(rect.height == 6) + #expect(rect.minY == 0) + #expect(rect.maxY == 6) #expect(abs(rect.midX - 50) <= 0.5) + #expect(stripe.width == 1) + #expect(stripe.height == rect.height) + #expect(abs(stripe.midX - rect.midX) <= 0.001) + #expect(stripe.minX > rect.minX) + #expect(stripe.maxX < rect.maxX) + } + + @Test + func `quota warning marker geometry stays centered across display scales`() { + let scales: [CGFloat] = [1, 2, 3] + + for scale in scales { + let rect = UsageProgressBar.warningMarkerRect( + x: 33, + size: CGSize(width: 100, height: 6), + scale: scale) + let stripe = UsageProgressBar.warningMarkerStripeRect( + rect, + scale: scale) + + #expect(rect.minY == 0) + #expect(rect.height == 6) + #expect(rect.width == 5) + #expect(stripe.width == 1) + #expect(stripe.height == rect.height) + #expect(abs(stripe.midX - rect.midX) <= 1 / scale) + #expect(stripe.minX > rect.minX) + #expect(stripe.maxX < rect.maxX) + } + } + + @Test + func `workday boundary is a subtle lower tick`() { + let rect = UsageProgressBar.workdayMarkerRect( + x: 50, + size: CGSize(width: 100, height: 6), + scale: 2) + + #expect(rect.width == 0.5) + #expect(rect.height == 3) + #expect(rect.minY == 3) + #expect(abs(rect.midX - 50) <= 0.5) + } + + @Test + func `quota warning wins when marker kinds overlap`() { + let markers = UsageProgressBar.resolvedMarkers( + warningPercents: [50, 80], + workdayPercents: [20, 50, 60]) + + #expect(markers == [ + .init(percent: 20, kind: .workdayBoundary), + .init(percent: 50, kind: .quotaWarning), + .init(percent: 60, kind: .workdayBoundary), + .init(percent: 80, kind: .quotaWarning), + ]) + } + + @Test + func `marker resolver removes edges duplicates and invalid values`() { + let markers = UsageProgressBar.resolvedMarkers( + warningPercents: [-10, 0, 50, 50, 100, 120], + workdayPercents: [Double.nan, 25, 25]) + + #expect(markers == [ + .init(percent: 25, kind: .workdayBoundary), + .init(percent: 50, kind: .quotaWarning), + ]) } @Test diff --git a/Tests/CodexBarTests/MenuCardRefreshTests.swift b/Tests/CodexBarTests/MenuCardRefreshTests.swift new file mode 100644 index 0000000000..3b57fa212c --- /dev/null +++ b/Tests/CodexBarTests/MenuCardRefreshTests.swift @@ -0,0 +1,74 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +struct MenuCardRefreshTests { + private static func makeModel( + provider: UsageProvider, + snapshot: UsageSnapshot, + isRefreshing: Bool, + now: Date) throws -> UsageMenuCardView.Model + { + let metadata = try #require(ProviderDefaults.metadata[provider]) + return UsageMenuCardView.Model.make(.init( + provider: provider, + metadata: metadata, + snapshot: snapshot, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: isRefreshing, + lastError: nil, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + } + + @Test + func `background refresh keeps quota timing current`() throws { + let updatedAt = Date(timeIntervalSince1970: 1_800_000_000) + for provider in [UsageProvider.claude, .codex] { + let snapshot = UsageSnapshot( + primary: RateWindow( + usedPercent: 21, + windowMinutes: 300, + resetsAt: updatedAt.addingTimeInterval(4 * 60 * 60 + 40 * 60), + resetDescription: nil), + secondary: nil, + updatedAt: updatedAt, + identity: ProviderIdentitySnapshot( + providerID: provider, + accountEmail: nil, + accountOrganization: nil, + loginMethod: "Pro")) + let completedModel = try Self.makeModel( + provider: provider, + snapshot: snapshot, + isRefreshing: false, + now: updatedAt) + let refreshingModel = try Self.makeModel( + provider: provider, + snapshot: snapshot, + isRefreshing: true, + now: updatedAt.addingTimeInterval(10 * 60)) + + let completedMetric = try #require(completedModel.metrics.first) + let refreshingMetric = try #require(refreshingModel.metrics.first) + #expect(refreshingModel.subtitleText == "Refreshing…") + #expect(refreshingMetric.percentLabel == completedMetric.percentLabel) + #expect(completedMetric.resetText == "Resets in 4h 40m") + #expect(refreshingMetric.resetText == "Resets in 4h 30m") + #expect(refreshingMetric.detailLeftText != completedMetric.detailLeftText) + #expect(refreshingMetric.detailRightText != completedMetric.detailRightText) + #expect(refreshingMetric.pacePercent != completedMetric.pacePercent) + } + } +} diff --git a/Tests/CodexBarTests/MenuCardSubtitleTests.swift b/Tests/CodexBarTests/MenuCardSubtitleTests.swift index 8407c1c10d..6cda8e0d51 100644 --- a/Tests/CodexBarTests/MenuCardSubtitleTests.swift +++ b/Tests/CodexBarTests/MenuCardSubtitleTests.swift @@ -46,4 +46,49 @@ struct MenuCardSubtitleTests { #expect(model.subtitleText == UsageFormatter.updatedString(from: updatedAt, now: now)) } + + @Test + func `subtitle shows refreshing while cached snapshot remains visible`() throws { + let updatedAt = Date(timeIntervalSinceReferenceDate: 0) + let now = updatedAt.addingTimeInterval(5 * 3600) + let snapshot = UsageSnapshot( + primary: RateWindow( + usedPercent: 22, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(3000), + resetDescription: nil), + secondary: nil, + tertiary: nil, + updatedAt: updatedAt, + identity: ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: "codex@example.com", + accountOrganization: nil, + loginMethod: "Plus Plan")) + let metadata = try #require(ProviderDefaults.metadata[.codex]) + + let model = UsageMenuCardView.Model.make(.init( + provider: .codex, + metadata: metadata, + snapshot: snapshot, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: "codex@example.com", plan: "Plus Plan"), + isRefreshing: true, + lastError: nil, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + + #expect(model.subtitleText == "Refreshing…") + #expect(model.subtitleStyle == .loading) + #expect(!model.metrics.isEmpty) + } } diff --git a/Tests/CodexBarTests/MenuCardTestDateFormatting.swift b/Tests/CodexBarTests/MenuCardTestDateFormatting.swift new file mode 100644 index 0000000000..4f94f865d1 --- /dev/null +++ b/Tests/CodexBarTests/MenuCardTestDateFormatting.swift @@ -0,0 +1,9 @@ +import Foundation + +func minimaxRenewDate(_ timestamp: TimeInterval) -> String { + let formatter = DateFormatter() + formatter.locale = .current + formatter.timeZone = TimeZone(identifier: "Asia/Shanghai") + formatter.setLocalizedDateFormatFromTemplate("MMM d, yyyy") + return formatter.string(from: Date(timeIntervalSince1970: timestamp)) +} diff --git a/Tests/CodexBarTests/MenuCardViewRecyclingTests.swift b/Tests/CodexBarTests/MenuCardViewRecyclingTests.swift new file mode 100644 index 0000000000..9b068301b8 --- /dev/null +++ b/Tests/CodexBarTests/MenuCardViewRecyclingTests.swift @@ -0,0 +1,755 @@ +import AppKit +import CodexBarCore +import SwiftUI +import Testing +@testable import CodexBar + +@MainActor +private final class RecordingMenuHighlightView: NSView, MenuCardHighlighting { + private(set) var isHighlighted = false + + func setHighlighted(_ highlighted: Bool) { + self.isHighlighted = highlighted + } +} + +extension StatusMenuTests { + private func makeRecyclingController(settings: SettingsStore) -> StatusItemController { + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + return StatusItemController( + store: store, + settings: settings, + account: UsageFetcher().loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + } + + private func cardViewIdentities(in menu: NSMenu) -> [String: ObjectIdentifier] { + var identities: [String: ObjectIdentifier] = [:] + for item in menu.items { + guard let id = item.representedObject as? String else { continue } + guard let view = item.view, view is any MenuCardMeasuring else { continue } + identities[id] = ObjectIdentifier(view) + } + return identities + } + + @Test + func `menu card enabled state follows interaction affordances`() { + let previousRendering = StatusItemController.menuCardRenderingEnabled + defer { StatusItemController.menuCardRenderingEnabled = previousRendering } + + for renderingEnabled in [false, true] { + StatusItemController.menuCardRenderingEnabled = renderingEnabled + let settings = self.makeSettings() + settings.statusChecksEnabled = false + let controller = self.makeRecyclingController(settings: settings) + defer { controller.releaseStatusItemsForTesting() } + + let informational = controller.makeMenuCardItem(Text("Info"), id: "info", width: 300) + let embedded = controller.makeMenuCardItem( + Text("Embedded"), + id: "embedded", + width: 300, + containsInteractiveControls: true) + let clickable = controller.makeMenuCardItem(Text("Click"), id: "click", width: 300, onClick: {}) + let submenu = controller.makeMenuCardItem( + Text("Submenu"), + id: "submenu", + width: 300, + submenu: NSMenu()) + + #expect(!informational.isEnabled) + #expect(embedded.isEnabled == renderingEnabled) + #expect(clickable.isEnabled) + #expect(submenu.isEnabled) + } + } + + @Test + func `embedded controls stay enabled without highlighting the card`() { + StatusItemController.setMenuRefreshEnabledForTesting(false) + let previousRendering = StatusItemController.menuCardRenderingEnabled + StatusItemController.menuCardRenderingEnabled = true + defer { StatusItemController.menuCardRenderingEnabled = previousRendering } + + let settings = self.makeSettings() + settings.statusChecksEnabled = false + let controller = self.makeRecyclingController(settings: settings) + defer { controller.releaseStatusItemsForTesting() } + + let menu = NSMenu() + let item = controller.makeMenuCardItem( + Text("Embedded"), + id: "embedded", + width: 300, + containsInteractiveControls: true) + menu.addItem(item) + + controller.menu(menu, willHighlight: item) + + #expect(item.isEnabled) + #expect(controller.highlightedMenuItems[ObjectIdentifier(menu)] == nil) + guard let hosting = item.view as? MenuCardItemHostingView> + else { + Issue.record("expected a card hosting view") + return + } + #expect(!hosting.allowsMenuHighlight) + #expect(!hosting.highlightState.isHighlighted) + } + + @Test + func `merged menu width uses widest provider action set`() { + let settings = self.makeSettings() + settings.statusChecksEnabled = false + let controller = self.makeRecyclingController(settings: settings) + defer { controller.releaseStatusItemsForTesting() } + + let narrow = [ + MenuDescriptor.Section(entries: [ + .action("Usage Dashboard", .dashboard), + ]), + ] + let wide = [ + MenuDescriptor.Section(entries: [ + .action(String(repeating: "W", count: 60), .dashboard), + ]), + ] + + let narrowWidth = controller.measuredMenuCardWidth(for: [narrow]) + let stableWidth = controller.measuredMenuCardWidth(for: [narrow, wide]) + + #expect(narrowWidth == StatusItemController.menuCardBaseWidth) + #expect(stableWidth > narrowWidth) + #expect(controller.measuredMenuCardWidth(for: [wide, narrow]) == stableWidth) + } + + @Test + func `menu width normalization includes usage history submenu row`() { + let previousRendering = StatusItemController.menuCardRenderingEnabled + StatusItemController.menuCardRenderingEnabled = true + defer { StatusItemController.menuCardRenderingEnabled = previousRendering } + + let settings = self.makeSettings() + settings.statusChecksEnabled = false + let controller = self.makeRecyclingController(settings: settings) + defer { controller.releaseStatusItemsForTesting() } + + let menu = NSMenu() + let usageHistoryItem = controller.makeMenuCardItem( + Text("Plan Usage"), + id: "usageHistorySubmenu", + width: StatusItemController.menuCardBaseWidth) + menu.addItem(usageHistoryItem) + menu.addItem(NSMenuItem( + title: String(repeating: "W", count: 60), + action: nil, + keyEquivalent: "")) + + let expectedWidth = controller.renderedMenuWidth(for: menu) + #expect(expectedWidth > StatusItemController.menuCardBaseWidth) + + controller.refreshMenuCardHeights(in: menu) + + #expect(abs((usageHistoryItem.view?.frame.width ?? 0) - expectedWidth) <= 0.5) + } + + @Test + func `rendered menu width keeps tracked window width after AppKit shrink`() { + let width = StatusItemController.resolvedRenderedMenuWidth( + menuWidth: 310, + trackedWindowWidth: 356) + + #expect(width == 356) + #expect(StatusItemController.resolvedRenderedMenuWidth( + menuWidth: 310, + trackedWindowWidth: nil) == 310) + } + + @Test + func `data only repopulate reuses menu card hosting views`() { + StatusItemController.setMenuRefreshEnabledForTesting(false) + let previousRendering = StatusItemController.menuCardRenderingEnabled + StatusItemController.menuCardRenderingEnabled = true + defer { StatusItemController.menuCardRenderingEnabled = previousRendering } + + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + let registry = ProviderRegistry.shared + for provider in UsageProvider.allCases { + if let metadata = registry.metadata[provider] { + settings.setProviderEnabled(provider: provider, metadata: metadata, enabled: provider == .codex) + } + } + + let controller = self.makeRecyclingController(settings: settings) + defer { controller.releaseStatusItemsForTesting() } + + let menu = controller.makeMenu() + controller.populateMenu(menu, provider: .codex) + let firstPass = self.cardViewIdentities(in: menu) + #expect(!firstPass.isEmpty) + + controller.invalidateMenus(allowStaleContentDuringDataRefresh: true) + controller.populateMenu(menu, provider: .codex) + let secondPass = self.cardViewIdentities(in: menu) + + #expect(secondPass.keys.sorted() == firstPass.keys.sorted()) + for (id, identity) in firstPass { + #expect(secondPass[id] == identity, "card \(id) should reuse its hosting view") + } + #expect(controller.menuCardViewRecyclePool.isEmpty) + } + + @Test + func `merged data tick keeps row count and card views stable`() { + StatusItemController.setMenuRefreshEnabledForTesting(false) + let previousRendering = StatusItemController.menuCardRenderingEnabled + StatusItemController.menuCardRenderingEnabled = true + defer { StatusItemController.menuCardRenderingEnabled = previousRendering } + + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.mergedMenuLastSelectedWasOverview = false + let registry = ProviderRegistry.shared + let enabled: Set = [.codex, .claude] + for provider in UsageProvider.allCases { + if let metadata = registry.metadata[provider] { + settings.setProviderEnabled( + provider: provider, + metadata: metadata, + enabled: enabled.contains(provider)) + } + } + 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.selectedMenuProvider = .codex + let menu = controller.makeMenu() + controller.populateMenu(menu, provider: .codex) + let itemCountBefore = menu.items.count + let cardViewsBefore = self.cardViewIdentities(in: menu) + #expect(!cardViewsBefore.isEmpty) + + controller.invalidateMenus(allowStaleContentDuringDataRefresh: true) + controller.populateMenu(menu, provider: .codex) + + #expect(menu.items.count == itemCountBefore, "data-only repopulate should keep row count stable") + let cardViewsAfter = self.cardViewIdentities(in: menu) + for (id, identity) in cardViewsBefore { + #expect(cardViewsAfter[id] == identity, "card \(id) should reuse its hosting view") + } + } + + @Test + func `reconcile keeps matching edge rows when the middle differs`() { + StatusItemController.setMenuRefreshEnabledForTesting(false) + let previousRendering = StatusItemController.menuCardRenderingEnabled + StatusItemController.menuCardRenderingEnabled = true + defer { StatusItemController.menuCardRenderingEnabled = previousRendering } + + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + let controller = self.makeRecyclingController(settings: settings) + defer { controller.releaseStatusItemsForTesting() } + + func plainItem(_ title: String) -> NSMenuItem { + NSMenuItem(title: title, action: nil, keyEquivalent: "") + } + + let menu = NSMenu() + menu.addItem(controller.makeMenuCardItem(Text("card"), id: "menuCard", width: 300)) + menu.addItem(.separator()) + menu.addItem(plainItem("Old Provider Action")) + menu.addItem(plainItem("Old Provider Detail")) + menu.addItem(.separator()) + menu.addItem(plainItem("Settings")) + let cardItem = menu.items[0] + let cardView = cardItem.view + let settingsItem = menu.items[5] + + let shapes = controller.menuContentShapes(in: menu, fromIndex: 0) + controller.harvestRecyclableMenuCardViews(in: menu, fromIndex: 0, displacedSelection: nil) + defer { controller.clearMenuCardViewRecyclePool() } + + let scratch = NSMenu() + scratch.addItem(controller.makeMenuCardItem(Text("other provider card"), id: "menuCard", width: 300)) + scratch.addItem(.separator()) + scratch.addItem(plainItem("New Provider Action")) + scratch.addItem(.separator()) + scratch.addItem(plainItem("Settings")) + + controller.reconcileMenuContent(menu, fromIndex: 0, shapes: shapes, with: scratch) + + #expect(menu.items.count == 5) + #expect(menu.items[0] === cardItem, "card row should be updated in place") + #expect(menu.items[0].view === cardView, "card hosting view should be recycled in place") + #expect(menu.items[4] === settingsItem, "shared trailing row should be updated in place") + #expect(menu.items[2].title == "New Provider Action") + } + + @Test + func `cached provider content replaces native image rows and preserves switch back items`() { + let settings = self.makeSettings() + settings.statusChecksEnabled = false + let controller = self.makeRecyclingController(settings: settings) + defer { controller.releaseStatusItemsForTesting() } + + let outgoing = NSMenuItem(title: "Status Page", action: nil, keyEquivalent: "") + outgoing.image = NSImage(size: NSSize(width: 16, height: 16)) + let incoming = NSMenuItem(title: "Dashboard", action: nil, keyEquivalent: "") + incoming.image = NSImage(size: NSSize(width: 16, height: 16)) + let menu = NSMenu() + menu.addItem(outgoing) + + let displacedOutgoing = controller.replaceMenuContentKeepingRowsVisible( + menu, + fromIndex: 0, + with: [incoming]) + + #expect(menu.items.first === incoming) + #expect(displacedOutgoing.first === outgoing) + + let displacedIncoming = controller.replaceMenuContentKeepingRowsVisible( + menu, + fromIndex: 0, + with: displacedOutgoing) + + #expect(menu.items.first === outgoing) + #expect(displacedIncoming.first === incoming) + } + + @Test + func `cached provider content swap preserves both item sets for switch back`() { + let settings = self.makeSettings() + settings.statusChecksEnabled = false + let controller = self.makeRecyclingController(settings: settings) + defer { controller.releaseStatusItemsForTesting() } + + let switcher = NSMenuItem(title: "Switcher", action: nil, keyEquivalent: "") + let outgoing = [ + NSMenuItem(title: "Overview Card", action: nil, keyEquivalent: ""), + NSMenuItem.separator(), + NSMenuItem(title: "Overview Action", action: nil, keyEquivalent: ""), + ] + let incoming = [ + NSMenuItem(title: "Codex Card", action: nil, keyEquivalent: ""), + NSMenuItem.separator(), + NSMenuItem(title: "Codex Usage", action: nil, keyEquivalent: ""), + NSMenuItem(title: "Codex Settings", action: nil, keyEquivalent: ""), + ] + let menu = NSMenu() + menu.addItem(switcher) + outgoing.forEach(menu.addItem) + + let displacedOutgoing = controller.replaceMenuContentKeepingRowsVisible( + menu, + fromIndex: 1, + with: incoming) + + #expect(menu.items.first === switcher) + #expect(menu.items.dropFirst().map(\.title) == ["Codex Card", "", "Codex Usage", "Codex Settings"]) + #expect(Array(menu.items[1...3]).map(ObjectIdentifier.init) == outgoing.map(ObjectIdentifier.init)) + #expect(displacedOutgoing.map(ObjectIdentifier.init) == incoming.prefix(3).map(ObjectIdentifier.init)) + #expect(displacedOutgoing.map(\.title) == ["Overview Card", "", "Overview Action"]) + + let displacedIncoming = controller.replaceMenuContentKeepingRowsVisible( + menu, + fromIndex: 1, + with: displacedOutgoing) + + #expect(Array(menu.items.dropFirst()).map(ObjectIdentifier.init) == outgoing.map(ObjectIdentifier.init)) + #expect(displacedIncoming.map(ObjectIdentifier.init) == incoming.map(ObjectIdentifier.init)) + #expect(displacedIncoming.allSatisfy { $0.menu == nil }) + #expect(displacedIncoming.map(\.title) == ["Codex Card", "", "Codex Usage", "Codex Settings"]) + } + + @Test + func `reconcile preserves highlight on a retained custom action row`() { + let settings = self.makeSettings() + settings.statusChecksEnabled = false + let controller = self.makeRecyclingController(settings: settings) + defer { controller.releaseStatusItemsForTesting() } + + let menu = NSMenu() + let liveItem = NSMenuItem() + liveItem.isEnabled = true + liveItem.representedObject = "action" + liveItem.view = RecordingMenuHighlightView() + menu.addItem(liveItem) + controller.menu(menu, willHighlight: liveItem) + + let replacementView = RecordingMenuHighlightView() + let replacementItem = NSMenuItem() + replacementItem.isEnabled = true + replacementItem.representedObject = "action" + replacementItem.view = replacementView + let scratch = NSMenu() + scratch.addItem(replacementItem) + + let shapes = controller.menuContentShapes(in: menu, fromIndex: 0) + controller.reconcileMenuContent(menu, fromIndex: 0, shapes: shapes, with: scratch) + + #expect(menu.items[0] === liveItem) + #expect(liveItem.view === replacementView) + #expect(replacementView.isHighlighted) + #expect(controller.highlightedMenuItems[ObjectIdentifier(menu)] === liveItem) + } + + @Test + func `reconcile restores highlight on a retained recycled card`() { + StatusItemController.setMenuRefreshEnabledForTesting(false) + let previousRendering = StatusItemController.menuCardRenderingEnabled + StatusItemController.menuCardRenderingEnabled = true + defer { StatusItemController.menuCardRenderingEnabled = previousRendering } + + let settings = self.makeSettings() + settings.statusChecksEnabled = false + let controller = self.makeRecyclingController(settings: settings) + defer { controller.releaseStatusItemsForTesting() } + + let menu = NSMenu() + let liveItem = controller.makeMenuCardItem(Text("before"), id: "menuCard", width: 300, onClick: {}) + menu.addItem(liveItem) + controller.menu(menu, willHighlight: liveItem) + guard let hosting = liveItem.view as? MenuCardItemHostingView> + else { + Issue.record("expected a card hosting view") + return + } + + let shapes = controller.menuContentShapes(in: menu, fromIndex: 0) + controller.harvestRecyclableMenuCardViews( + in: menu, + fromIndex: 0, + displacedSelection: nil, + preserveHighlightedItem: true) + defer { controller.clearMenuCardViewRecyclePool() } + #expect(!hosting.highlightState.isHighlighted) + #expect(controller.highlightedMenuItems[ObjectIdentifier(menu)] === liveItem) + + let scratch = NSMenu() + scratch.addItem(controller.makeMenuCardItem(Text("after"), id: "menuCard", width: 300, onClick: {})) + controller.reconcileMenuContent(menu, fromIndex: 0, shapes: shapes, with: scratch) + + #expect(menu.items[0] === liveItem) + #expect(liveItem.view === hosting) + #expect(hosting.highlightState.isHighlighted) + #expect(controller.highlightedMenuItems[ObjectIdentifier(menu)] === liveItem) + } + + @Test + func `reconcile clears highlight when a retained card becomes disabled`() { + StatusItemController.setMenuRefreshEnabledForTesting(false) + let previousRendering = StatusItemController.menuCardRenderingEnabled + StatusItemController.menuCardRenderingEnabled = true + defer { StatusItemController.menuCardRenderingEnabled = previousRendering } + + let settings = self.makeSettings() + settings.statusChecksEnabled = false + let controller = self.makeRecyclingController(settings: settings) + defer { controller.releaseStatusItemsForTesting() } + + let menu = NSMenu() + let liveItem = controller.makeMenuCardItem(Text("before"), id: "menuCard", width: 300, onClick: {}) + menu.addItem(liveItem) + controller.menu(menu, willHighlight: liveItem) + guard let liveView = liveItem.view as? MenuCardItemHostingView> + else { + Issue.record("expected a card hosting view") + return + } + #expect(liveView.highlightState.isHighlighted) + #expect(controller.highlightedMenuItems[ObjectIdentifier(menu)] === liveItem) + + let shapes = controller.menuContentShapes(in: menu, fromIndex: 0) + let scratch = NSMenu() + scratch.addItem(controller.makeMenuCardItem(Text("after"), id: "menuCard", width: 300)) + controller.reconcileMenuContent(menu, fromIndex: 0, shapes: shapes, with: scratch) + + #expect(menu.items[0] === liveItem) + #expect(!liveItem.isEnabled) + #expect(controller.highlightedMenuItems[ObjectIdentifier(menu)] == nil) + guard let rebuiltView = liveItem.view as? MenuCardItemHostingView> + else { + Issue.record("expected the rebuilt card hosting view") + return + } + #expect(!rebuiltView.highlightState.isHighlighted) + } + + @Test + func `harvesting consumes only the displaced selection cache entry`() { + StatusItemController.setMenuRefreshEnabledForTesting(false) + let previousRendering = StatusItemController.menuCardRenderingEnabled + StatusItemController.menuCardRenderingEnabled = true + defer { StatusItemController.menuCardRenderingEnabled = previousRendering } + + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + let controller = self.makeRecyclingController(settings: settings) + defer { controller.releaseStatusItemsForTesting() } + + let menu = NSMenu() + let item = controller.makeMenuCardItem(Text("card"), id: "menuCard", width: 300, onClick: {}) + menu.addItem(item) + + let entry = CachedMergedSwitcherMenuContent( + requiredMenuContentVersion: 0, + menuWidth: 300, + codexAccountDisplay: nil, + tokenAccountDisplay: nil, + localizationSignature: "", + items: []) + controller.mergedSwitcherContentCaches[ObjectIdentifier(menu)] = [ + .overview: entry, + .provider(.codex): entry, + ] + controller.harvestRecyclableMenuCardViews( + in: menu, + fromIndex: 0, + displacedSelection: .provider(.codex)) + defer { controller.clearMenuCardViewRecyclePool() } + + #expect(controller.menuCardViewRecyclePool.count == 1) + #expect(item.view == nil) + let remaining = controller.mergedSwitcherContentCaches[ObjectIdentifier(menu)] + #expect(remaining?[.provider(.codex)] == nil) + #expect(remaining?[.overview] != nil) + } + + @Test + func `harvesting consumes displaced cache when card rendering is disabled`() { + let previousRendering = StatusItemController.menuCardRenderingEnabled + StatusItemController.menuCardRenderingEnabled = false + defer { StatusItemController.menuCardRenderingEnabled = previousRendering } + + let settings = self.makeSettings() + settings.statusChecksEnabled = false + let controller = self.makeRecyclingController(settings: settings) + defer { controller.releaseStatusItemsForTesting() } + + let menu = NSMenu() + let entry = CachedMergedSwitcherMenuContent( + requiredMenuContentVersion: 0, + menuWidth: 300, + codexAccountDisplay: nil, + tokenAccountDisplay: nil, + localizationSignature: "", + items: []) + controller.mergedSwitcherContentCaches[ObjectIdentifier(menu)] = [ + .overview: entry, + .provider(.codex): entry, + ] + + controller.harvestRecyclableMenuCardViews( + in: menu, + fromIndex: 0, + displacedSelection: .provider(.codex)) + + let remaining = controller.mergedSwitcherContentCaches[ObjectIdentifier(menu)] + #expect(remaining?[.provider(.codex)] == nil) + #expect(remaining?[.overview] != nil) + } + + @Test + func `type compatible leftover is adopted across card identifiers`() { + StatusItemController.setMenuRefreshEnabledForTesting(false) + let previousRendering = StatusItemController.menuCardRenderingEnabled + StatusItemController.menuCardRenderingEnabled = true + defer { StatusItemController.menuCardRenderingEnabled = previousRendering } + + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + let controller = self.makeRecyclingController(settings: settings) + defer { controller.releaseStatusItemsForTesting() } + + let menu = NSMenu() + let original = controller.makeMenuCardItem(Text("codex usage"), id: "menuCard-0", width: 300) + menu.addItem(original) + let originalView = original.view + + controller.harvestRecyclableMenuCardViews(in: menu, fromIndex: 0, displacedSelection: nil) + defer { controller.clearMenuCardViewRecyclePool() } + let switched = controller.makeMenuCardItem(Text("claude usage"), id: "menuCard", width: 300) + + #expect(switched.view === originalView) + #expect(controller.menuCardViewRecyclePool.isEmpty) + } + + @Test + func `recycled card keeps its hosting view and highlight state`() { + StatusItemController.setMenuRefreshEnabledForTesting(false) + let previousRendering = StatusItemController.menuCardRenderingEnabled + StatusItemController.menuCardRenderingEnabled = true + defer { StatusItemController.menuCardRenderingEnabled = previousRendering } + + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + let controller = self.makeRecyclingController(settings: settings) + defer { controller.releaseStatusItemsForTesting() } + + let menu = NSMenu() + let original = controller.makeMenuCardItem(Text("before"), id: "menuCard", width: 300) + menu.addItem(original) + guard let originalView = original.view as? MenuCardItemHostingView> + else { + Issue.record("expected a card hosting view") + return + } + + controller.harvestRecyclableMenuCardViews(in: menu, fromIndex: 0, displacedSelection: nil) + defer { controller.clearMenuCardViewRecyclePool() } + let rebuilt = controller.makeMenuCardItem(Text("after"), id: "menuCard", width: 300) + + #expect(rebuilt.view === originalView) + guard let rebuiltView = rebuilt.view as? MenuCardItemHostingView> + else { + Issue.record("expected the recycled hosting view") + return + } + #expect(rebuiltView.highlightState === originalView.highlightState) + rebuiltView.setHighlighted(true) + #expect(rebuiltView.highlightState.isHighlighted) + rebuiltView.setHighlighted(false) + } + + @Test + func `recycled card clears button role when click action is removed`() { + let highlightState = MenuCardHighlightState() + let hosting = MenuCardItemHostingView( + rootView: Text("clickable"), + highlightState: highlightState, + allowsMenuHighlight: true, + onClick: {}) + + #expect(hosting.accessibilityRole() == .button) + + hosting.prepareForReuse( + rootView: Text("informational"), + allowsMenuHighlight: false, + onClick: nil) + + #expect(hosting.accessibilityRole() == .group) + } + + @Test + func `harvesting a highlighted card clears its highlight and tracking entry`() { + StatusItemController.setMenuRefreshEnabledForTesting(false) + let previousRendering = StatusItemController.menuCardRenderingEnabled + StatusItemController.menuCardRenderingEnabled = true + defer { StatusItemController.menuCardRenderingEnabled = previousRendering } + + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + let controller = self.makeRecyclingController(settings: settings) + defer { controller.releaseStatusItemsForTesting() } + + let menu = NSMenu() + let item = controller.makeMenuCardItem(Text("card"), id: "menuCard", width: 300, onClick: {}) + menu.addItem(item) + controller.menu(menu, willHighlight: item) + guard let hosting = item.view as? MenuCardItemHostingView> + else { + Issue.record("expected a card hosting view") + return + } + #expect(hosting.highlightState.isHighlighted) + #expect(controller.highlightedMenuItems[ObjectIdentifier(menu)] === item) + + controller.harvestRecyclableMenuCardViews(in: menu, fromIndex: 0, displacedSelection: nil) + defer { controller.clearMenuCardViewRecyclePool() } + + #expect(!hosting.highlightState.isHighlighted) + #expect(controller.highlightedMenuItems[ObjectIdentifier(menu)] == nil) + + let rebuilt = controller.makeMenuCardItem(Text("rebuilt"), id: "menuCard", width: 300, onClick: {}) + #expect(rebuilt.view === hosting) + #expect(!hosting.highlightState.isHighlighted) + } + + @Test + func `same id with different content type builds a fresh view`() { + StatusItemController.setMenuRefreshEnabledForTesting(false) + let previousRendering = StatusItemController.menuCardRenderingEnabled + StatusItemController.menuCardRenderingEnabled = true + defer { StatusItemController.menuCardRenderingEnabled = previousRendering } + + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + let controller = self.makeRecyclingController(settings: settings) + defer { controller.releaseStatusItemsForTesting() } + + let menu = NSMenu() + let original = controller.makeMenuCardItem(Text("text card"), id: "menuCard", width: 300) + menu.addItem(original) + let originalView = original.view + + controller.harvestRecyclableMenuCardViews(in: menu, fromIndex: 0, displacedSelection: nil) + defer { controller.clearMenuCardViewRecyclePool() } + let rebuilt = controller.makeMenuCardItem(Image(systemName: "clock"), id: "menuCard", width: 300) + + #expect(rebuilt.view != nil) + #expect(rebuilt.view !== originalView) + // The incompatible pool entry is consumed rather than left behind. + #expect(controller.menuCardViewRecyclePool.isEmpty) + } + + @Test + func `gpu selection highlight bypasses swiftui highlight state`() { + StatusItemController.setMenuRefreshEnabledForTesting(false) + let previousRendering = StatusItemController.menuCardRenderingEnabled + StatusItemController.menuCardRenderingEnabled = true + defer { StatusItemController.menuCardRenderingEnabled = previousRendering } + + let settings = self.makeSettings() + settings.statusChecksEnabled = false + let controller = self.makeRecyclingController(settings: settings) + defer { controller.releaseStatusItemsForTesting() } + + let menu = NSMenu() + let item = controller.makeMenuCardItem( + Text("Overview row"), + id: "overview-gpu", + width: 300, + submenu: NSMenu(), + usesGPUSelection: true, + onClick: {}) + menu.addItem(item) + + guard let gpuView = item.view as? GPUSelectionHostingView + else { + Issue.record("expected a GPU selection hosting view") + return + } + + // The menu highlights the AppKit row, but the hosted SwiftUI highlight state must stay false + // so selection never re-invalidates the SwiftUI graph. + controller.menu(menu, willHighlight: item) + #expect(gpuView.isHighlightedForTesting) + #expect(!gpuView.swiftUIHighlightStateIsHighlightedForTesting) + + controller.menu(menu, willHighlight: nil) + #expect(!gpuView.isHighlightedForTesting) + #expect(!gpuView.swiftUIHighlightStateIsHighlightedForTesting) + } +} diff --git a/Tests/CodexBarTests/MenuCardWorkdayPaceTests.swift b/Tests/CodexBarTests/MenuCardWorkdayPaceTests.swift new file mode 100644 index 0000000000..119dc713e1 --- /dev/null +++ b/Tests/CodexBarTests/MenuCardWorkdayPaceTests.swift @@ -0,0 +1,79 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +struct MenuCardWorkdayPaceTests { + @Test + func `codex weekly lane hides exhausted pace before first configured workday`() throws { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = .current + let now = try #require(calendar.date(from: DateComponents( + calendar: calendar, + timeZone: calendar.timeZone, + year: 2026, + month: 6, + day: 7, + hour: 12))) + let resetsAt = try #require(calendar.date(from: DateComponents( + calendar: calendar, + timeZone: calendar.timeZone, + year: 2026, + month: 6, + day: 14))) + let metadata = try #require(ProviderDefaults.metadata[.codex]) + let identity = ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: "user@example.com", + accountOrganization: nil, + loginMethod: "Pro") + let snapshot = UsageSnapshot( + primary: nil, + secondary: RateWindow( + usedPercent: 100, + windowMinutes: 10080, + resetsAt: resetsAt, + resetDescription: nil), + tertiary: nil, + updatedAt: now, + identity: identity) + let projection = CodexConsumerProjection.make( + surface: .liveCard, + context: CodexConsumerProjection.Context( + snapshot: snapshot, + rawUsageError: nil, + liveCredits: nil, + rawCreditsError: nil, + liveDashboard: nil, + rawDashboardError: nil, + dashboardAttachmentAuthorized: false, + dashboardRequiresLogin: false, + now: now)) + + let model = UsageMenuCardView.Model.make(.init( + provider: .codex, + metadata: metadata, + snapshot: snapshot, + codexProjection: projection, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: "user@example.com", plan: "Pro"), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + workDaysPerWeek: 5, + now: now)) + + let weekly = try #require(model.metrics.first { $0.id == "secondary" }) + #expect(weekly.detailLeftText == nil) + #expect(weekly.detailRightText == nil) + } +} diff --git a/Tests/CodexBarTests/MenuDescriptorAntigravityTests.swift b/Tests/CodexBarTests/MenuDescriptorAntigravityTests.swift index 153425060c..8660018b2a 100644 --- a/Tests/CodexBarTests/MenuDescriptorAntigravityTests.swift +++ b/Tests/CodexBarTests/MenuDescriptorAntigravityTests.swift @@ -5,6 +5,52 @@ import Testing @MainActor struct MenuDescriptorAntigravityTests { + @Test + func `antigravity identity only snapshot shows limits unavailable`() throws { + let suite = "MenuDescriptorAntigravityTests-unavailable" + 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 + + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings) + let snapshot = UsageSnapshot( + primary: nil, + secondary: nil, + updatedAt: Date(), + identity: ProviderIdentitySnapshot( + providerID: .antigravity, + accountEmail: "user@example.com", + accountOrganization: nil, + loginMethod: "Paid")) + store._setSnapshotForTesting(snapshot, provider: .antigravity) + + let descriptor = MenuDescriptor.build( + provider: .antigravity, + store: store, + settings: settings, + account: AccountInfo(email: nil, plan: nil), + updateReady: false, + includeContextualActions: false) + + let lines = descriptor.sections + .flatMap(\.entries) + .compactMap { entry -> String? in + guard case let .text(text, _) = entry else { return nil } + return text + } + + #expect(lines.contains("Limits not available")) + } + @Test func `antigravity menu does not add unavailable notes for missing families`() throws { let suite = "MenuDescriptorAntigravityTests-missing-gemini" @@ -56,5 +102,57 @@ struct MenuDescriptorAntigravityTests { #expect(!lines.contains("Gemini Pro unavailable.")) #expect(!lines.contains("Gemini Flash unavailable.")) + #expect(!lines.contains("Limits not available")) + } + + @Test + func `antigravity descriptor does not render session pace for weekly primary window`() throws { + let suite = "MenuDescriptorAntigravityTests-weekly-primary-pace" + 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 + + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings) + let snapshot = UsageSnapshot( + primary: RateWindow( + usedPercent: 80, + windowMinutes: 10080, + resetsAt: Date().addingTimeInterval(2 * 24 * 3600), + resetDescription: nil), + secondary: nil, + tertiary: nil, + updatedAt: Date(), + identity: ProviderIdentitySnapshot( + providerID: .antigravity, + accountEmail: nil, + accountOrganization: nil, + loginMethod: "Pro")) + store._setSnapshotForTesting(snapshot, provider: .antigravity) + + let descriptor = MenuDescriptor.build( + provider: .antigravity, + store: store, + settings: settings, + account: AccountInfo(email: nil, plan: nil), + updateReady: false, + includeContextualActions: false) + + let lines = descriptor.sections + .flatMap(\.entries) + .compactMap { entry -> String? in + guard case let .text(text, _) = entry else { return nil } + return text + } + + #expect(!lines.contains { $0.hasPrefix("Pace:") }) } } diff --git a/Tests/CodexBarTests/MenuDescriptorCodexManagedFallbackTests.swift b/Tests/CodexBarTests/MenuDescriptorCodexManagedFallbackTests.swift index 218bb0abcd..811215ac99 100644 --- a/Tests/CodexBarTests/MenuDescriptorCodexManagedFallbackTests.swift +++ b/Tests/CodexBarTests/MenuDescriptorCodexManagedFallbackTests.swift @@ -24,7 +24,6 @@ struct MenuDescriptorCodexManagedFallbackTests { minimaxCookieStore: InMemoryMiniMaxCookieStore(), minimaxAPITokenStore: InMemoryMiniMaxAPITokenStore(), kimiTokenStore: InMemoryKimiTokenStore(), - kimiK2TokenStore: InMemoryKimiK2TokenStore(), augmentCookieStore: InMemoryCookieHeaderStore(), ampCookieStore: InMemoryCookieHeaderStore(), copilotTokenStore: InMemoryCopilotTokenStore(), @@ -101,7 +100,6 @@ struct MenuDescriptorCodexManagedFallbackTests { minimaxCookieStore: InMemoryMiniMaxCookieStore(), minimaxAPITokenStore: InMemoryMiniMaxAPITokenStore(), kimiTokenStore: InMemoryKimiTokenStore(), - kimiK2TokenStore: InMemoryKimiK2TokenStore(), augmentCookieStore: InMemoryCookieHeaderStore(), ampCookieStore: InMemoryCookieHeaderStore(), copilotTokenStore: InMemoryCopilotTokenStore(), diff --git a/Tests/CodexBarTests/MenuDescriptorOpenAIAPITests.swift b/Tests/CodexBarTests/MenuDescriptorOpenAIAPITests.swift index d45b96429e..e7954bf1f1 100644 --- a/Tests/CodexBarTests/MenuDescriptorOpenAIAPITests.swift +++ b/Tests/CodexBarTests/MenuDescriptorOpenAIAPITests.swift @@ -22,13 +22,15 @@ struct MenuDescriptorOpenAIAPITests { fetcher: UsageFetcher(environment: [:]), browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) - let now = Date(timeIntervalSince1970: 1_700_179_200) + let now = try Self.localNoon(year: 2023, month: 11, day: 17) + let firstDay = try Self.localNoon(year: 2023, month: 11, day: 13) + let secondDay = try Self.localNoon(year: 2023, month: 11, day: 14) let usage = OpenAIAPIUsageSnapshot( daily: [ OpenAIAPIUsageSnapshot.DailyBucket( day: "2023-11-13", - startTime: now.addingTimeInterval(-86400), - endTime: now, + startTime: firstDay, + endTime: firstDay.addingTimeInterval(86400), costUSD: 5, requests: 8, inputTokens: 100, @@ -47,8 +49,8 @@ struct MenuDescriptorOpenAIAPITests { ]), OpenAIAPIUsageSnapshot.DailyBucket( day: "2023-11-14", - startTime: now, - endTime: now.addingTimeInterval(86400), + startTime: secondDay, + endTime: secondDay.addingTimeInterval(86400), costUSD: 12.5, requests: 40, inputTokens: 1000, @@ -83,10 +85,14 @@ struct MenuDescriptorOpenAIAPITests { return text } - #expect(lines.contains("Today: $12.50 · 1.5K tokens")) + #expect(lines.contains("Today: $0.00 · 0 tokens")) #expect(lines.contains("7d: $17.50 · 48 requests")) #expect(lines.contains("30d: $17.50 · 48 requests")) #expect(lines.contains("Top model: gpt-5.2-codex")) #expect(!lines.contains("No usage yet")) } + + private static func localNoon(year: Int, month: Int, day: Int) throws -> Date { + try #require(Calendar.current.date(from: DateComponents(year: year, month: month, day: day, hour: 12))) + } } diff --git a/Tests/CodexBarTests/MenuDescriptorPoeTests.swift b/Tests/CodexBarTests/MenuDescriptorPoeTests.swift new file mode 100644 index 0000000000..ed8f202b96 --- /dev/null +++ b/Tests/CodexBarTests/MenuDescriptorPoeTests.swift @@ -0,0 +1,139 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +@MainActor +struct MenuDescriptorPoeTests { + @Test + func `poe balance renders as balance text not plan label`() throws { + let suite = "MenuDescriptorPoeTests-balance" + 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 + + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings) + let snapshot = UsageSnapshot( + primary: nil, + secondary: nil, + tertiary: nil, + providerCost: nil, + updatedAt: Date(), + identity: ProviderIdentitySnapshot( + providerID: .poe, + accountEmail: nil, + accountOrganization: nil, + loginMethod: "Balance: 1,500 points")) + store._setSnapshotForTesting(snapshot, provider: .poe) + + let descriptor = MenuDescriptor.build( + provider: .poe, + store: store, + settings: settings, + account: AccountInfo(email: nil, plan: nil), + updateReady: false, + includeContextualActions: false) + + let textLines = descriptor.sections + .flatMap(\.entries) + .compactMap { entry -> String? in + guard case let .text(text, _) = entry else { return nil } + return text + } + + #expect(textLines.contains(where: { $0.contains("Balance: 1,500 points") })) + #expect(!textLines.contains(where: { $0.contains("Plan: Balance:") })) + } + + @Test + func `poe usage history renders today week month and top breakdown`() throws { + let suite = "MenuDescriptorPoeTests-history" + 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 + + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings) + + let now = Date() + let calendar = Calendar.current + // Fixed calendar-day fixtures keep this stable around midnight. + let today = calendar.date(bySettingHour: 12, minute: 0, second: 0, of: now) ?? now + let yesterday = calendar.date(byAdding: .day, value: -1, to: today) ?? now.addingTimeInterval(-86400) + let history = PoeUsageHistorySnapshot( + entries: [ + .init( + id: "a", + createdAt: today, + model: "GPT-4o", + usageType: "chat", + points: 100, + costUSD: nil), + .init( + id: "b", + createdAt: yesterday, + model: "Claude-3.7-Sonnet", + usageType: "chat", + points: 200, + costUSD: nil), + ], + daily: [ + .init(day: "2026-05-30", points: 200, requests: 1, costUSD: nil), + .init(day: "2026-05-31", points: 100, requests: 1, costUSD: nil), + ], + updatedAt: now) + + let snapshot = UsageSnapshot( + primary: nil, + secondary: nil, + tertiary: nil, + providerCost: nil, + poeUsage: history, + updatedAt: now, + identity: ProviderIdentitySnapshot( + providerID: .poe, + accountEmail: nil, + accountOrganization: nil, + loginMethod: "Balance: 300 points")) + store._setSnapshotForTesting(snapshot, provider: .poe) + + let descriptor = MenuDescriptor.build( + provider: .poe, + store: store, + settings: settings, + account: AccountInfo(email: nil, plan: nil), + updateReady: false, + includeContextualActions: false) + + let textLines = descriptor.sections + .flatMap(\.entries) + .compactMap { entry -> String? in + guard case let .text(text, _) = entry else { return nil } + return text + } + + #expect(textLines.contains(where: { $0.contains("Today: 100 points") })) + #expect(textLines.contains(where: { $0.contains("7d: 300 points") })) + #expect(textLines.contains(where: { $0.contains("30d: 300 points") })) + #expect(textLines.contains(where: { $0.contains("Top model: Claude-3.7-Sonnet") })) + #expect(textLines.contains(where: { $0.contains("Usage mix: chat: 300 points") })) + #expect(textLines.contains(where: { $0.contains("Recent activity:") })) + } +} diff --git a/Tests/CodexBarTests/MenuDescriptorSakanaTests.swift b/Tests/CodexBarTests/MenuDescriptorSakanaTests.swift new file mode 100644 index 0000000000..62a9304ea4 --- /dev/null +++ b/Tests/CodexBarTests/MenuDescriptorSakanaTests.swift @@ -0,0 +1,128 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +@MainActor +struct MenuDescriptorSakanaTests { + @Test + func `sakana pay as you go rows render when optional usage is enabled`() throws { + let lines = try Self.menuLines(showOptionalUsage: true) + + #expect(lines.contains("Balance: $12.34")) + #expect(lines.contains("Usage: $5.67")) + } + + @Test + func `sakana pay as you go rows are hidden when optional usage is disabled`() throws { + // Regression for the render-path staleness gap: toggling "Show optional credits and extra + // usage" off only rebuilds the menu, it does not immediately refetch, so a + // previously-populated sakanaPayAsYouGo lingers in the cached snapshot. The rows must be + // gated on the setting, not only on the presence of the (possibly stale) snapshot field. + let lines = try Self.menuLines(showOptionalUsage: false) + + #expect(!lines.contains(where: { $0.hasPrefix("Balance:") })) + #expect(!lines.contains(where: { $0.hasPrefix("Usage:") })) + // The required quota windows must still render regardless of the optional-usage setting. + #expect(lines.contains(where: { $0.hasPrefix("5-hour") })) + #expect(lines.contains(where: { $0.hasPrefix("Weekly") })) + } + + private static func menuLines(showOptionalUsage: Bool) throws -> [String] { + let suite = "MenuDescriptorSakanaTests-\(showOptionalUsage)" + 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.showOptionalCreditsAndExtraUsage = showOptionalUsage + + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings) + let snapshot = SakanaUsageSnapshot( + planName: "Standard", + priceLabel: "$20/mo", + fiveHour: .init(usedPercent: 10, resetsAt: nil), + weekly: .init(usedPercent: 20, resetsAt: nil), + payAsYouGo: SakanaPayAsYouGoSnapshot( + creditBalance: 12.34, + periodUsageTotal: 5.67, + periodLabel: "Jun 02, 2026 - Jul 01, 2026")) + store._setSnapshotForTesting(snapshot.toUsageSnapshot(), provider: .sakana) + + let descriptor = MenuDescriptor.build( + provider: .sakana, + store: store, + settings: settings, + account: AccountInfo(email: nil, plan: nil), + updateReady: false, + includeContextualActions: false) + return descriptor.sections + .flatMap(\.entries) + .compactMap { entry -> String? in + guard case let .text(text, _) = entry else { return nil } + return text + } + } +} + +struct SakanaMenuCardModelTests { + @Test + func `pay as you go renders in the live menu card`() throws { + let model = try Self.model(showOptionalUsage: true) + + #expect(model.providerCost?.title == "Extra usage") + #expect(model.providerCost?.spendLine == "Balance: $12.34") + #expect(model.providerCost?.percentLine == "Usage: $5.67") + #expect(model.providerCost?.percentUsed == nil) + } + + @Test + func `pay as you go hides immediately when optional usage is disabled`() throws { + let model = try Self.model(showOptionalUsage: false) + + #expect(model.providerCost == nil) + #expect(model.metrics.map(\.title) == ["5-hour", "Weekly"]) + } + + private static func model(showOptionalUsage: Bool) throws -> UsageMenuCardView.Model { + let now = Date(timeIntervalSince1970: 0) + let snapshot = SakanaUsageSnapshot( + planName: "Standard", + priceLabel: "$20/mo", + fiveHour: .init(usedPercent: 10, resetsAt: nil), + weekly: .init(usedPercent: 20, resetsAt: nil), + payAsYouGo: SakanaPayAsYouGoSnapshot( + creditBalance: 12.34, + periodUsageTotal: 5.67, + periodLabel: "Jun 02, 2026 - Jul 01, 2026"), + updatedAt: now) + let metadata = try #require(ProviderDefaults.metadata[.sakana]) + + return UsageMenuCardView.Model.make(.init( + provider: .sakana, + metadata: metadata, + snapshot: snapshot.toUsageSnapshot(), + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: true, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: showOptionalUsage, + hidePersonalInfo: false, + now: now)) + } +} diff --git a/Tests/CodexBarTests/MenuDescriptorSub2APITests.swift b/Tests/CodexBarTests/MenuDescriptorSub2APITests.swift new file mode 100644 index 0000000000..7ef1df9f62 --- /dev/null +++ b/Tests/CodexBarTests/MenuDescriptorSub2APITests.swift @@ -0,0 +1,69 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +@MainActor +struct MenuDescriptorSub2APITests { + @Test + func `subscription labels and per key totals reach descriptor output`() throws { + let suite = "MenuDescriptorSub2APITests-usage" + 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 + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings) + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 10, windowMinutes: 1440, resetsAt: nil, resetDescription: "$1 / $10"), + secondary: RateWindow( + usedPercent: 20, + windowMinutes: 10080, + resetsAt: nil, + resetDescription: "$2 / $10"), + tertiary: RateWindow( + usedPercent: 30, + windowMinutes: 43200, + resetsAt: nil, + resetDescription: "$3 / $10"), + sub2APIUsage: Sub2APIUsageDetails( + kind: .subscription, + balance: 42.5, + unit: "USD", + today: .init(requests: 4, totalTokens: 1200, actualCostUSD: 1.25), + total: .init(requests: 40, totalTokens: 12000, actualCostUSD: 25)), + updatedAt: Date(timeIntervalSince1970: 1), + identity: ProviderIdentitySnapshot( + providerID: .sub2api, + accountEmail: nil, + accountOrganization: "Enterprise", + loginMethod: "Enterprise")) + store._setSnapshotForTesting(snapshot, provider: .sub2api) + + let descriptor = MenuDescriptor.build( + provider: .sub2api, + store: store, + settings: settings, + account: AccountInfo(email: nil, plan: nil), + updateReady: false, + includeContextualActions: false) + let lines = descriptor.sections.flatMap(\.entries).compactMap { entry -> String? in + guard case let .text(text, _) = entry else { return nil } + return text + } + + #expect(lines.contains(where: { $0.hasPrefix("Daily quota:") })) + #expect(lines.contains(where: { $0.hasPrefix("Weekly quota:") })) + #expect(lines.contains(where: { $0.hasPrefix("Monthly quota:") })) + #expect(lines.contains("Balance: $42.50")) + #expect(lines.contains("Today: 4 requests · 1.2K tokens · $1.25")) + #expect(lines.contains("Total: 40 requests · 12K tokens · $25.00")) + #expect(lines.contains("Plan: Enterprise")) + } +} diff --git a/Tests/CodexBarTests/MenuOpenRefreshPlanTests.swift b/Tests/CodexBarTests/MenuOpenRefreshPlanTests.swift new file mode 100644 index 0000000000..baa49b9d82 --- /dev/null +++ b/Tests/CodexBarTests/MenuOpenRefreshPlanTests.swift @@ -0,0 +1,62 @@ +import CodexBarCore +import Testing +@testable import CodexBar + +struct MenuOpenRefreshPlanTests { + @Test + func `refresh all selects every enabled provider concurrently`() { + let plan = MenuOpenRefreshPlan.resolve(.init( + refreshAllOnOpen: true, + enabledProviders: [.codex, .claude, .factory], + visibleProviders: [.codex], + refreshingProviders: [], + staleProviders: [], + missingProviders: [])) + + #expect(plan.providers == [.codex, .claude, .factory]) + #expect(plan.scheduling == .concurrent) + #expect(plan.refreshCodexDashboard) + } + + @Test + func `refresh all skips dashboard refresh when codex is disabled`() { + let plan = MenuOpenRefreshPlan.resolve(.init( + refreshAllOnOpen: true, + enabledProviders: [.claude, .factory], + visibleProviders: [.claude], + refreshingProviders: [], + staleProviders: [], + missingProviders: [])) + + #expect(plan.providers == [.claude, .factory]) + #expect(!plan.refreshCodexDashboard) + } + + @Test + func `ordinary refresh selects only visible enabled retries sequentially`() { + let plan = MenuOpenRefreshPlan.resolve(.init( + refreshAllOnOpen: false, + enabledProviders: [.codex, .claude, .factory], + visibleProviders: [.factory, .codex, .claude, .cursor], + refreshingProviders: [.factory], + staleProviders: [.codex], + missingProviders: [.claude, .cursor])) + + #expect(plan.providers == [.factory, .codex, .claude]) + #expect(plan.scheduling == .sequential) + #expect(!plan.refreshCodexDashboard) + } + + @Test + func `ordinary refresh skips fresh providers`() { + let plan = MenuOpenRefreshPlan.resolve(.init( + refreshAllOnOpen: false, + enabledProviders: [.codex], + visibleProviders: [.codex], + refreshingProviders: [], + staleProviders: [], + missingProviders: [])) + + #expect(plan.providers.isEmpty) + } +} diff --git a/Tests/CodexBarTests/MenuSessionCoordinatorTests.swift b/Tests/CodexBarTests/MenuSessionCoordinatorTests.swift new file mode 100644 index 0000000000..a4ec48fb6d --- /dev/null +++ b/Tests/CodexBarTests/MenuSessionCoordinatorTests.swift @@ -0,0 +1,159 @@ +import Testing +@testable import CodexBar + +struct MenuSessionCoordinatorTests { + @Test + func `invalidation records data structural and required generations independently`() { + var coordinator = MenuSessionCoordinator() + + coordinator.invalidate(allowsStaleContent: false, requiresRebuild: true) + #expect(coordinator.contentVersion == 1) + #expect(coordinator.latestStructuralContentVersion == 1) + #expect(coordinator.latestRequiredRebuildVersion == 1) + #expect(coordinator.latestDataOnlyContentVersion == 0) + + coordinator.invalidate(allowsStaleContent: true, requiresRebuild: false) + #expect(coordinator.contentVersion == 2) + #expect(coordinator.latestDataOnlyContentVersion == 2) + #expect(coordinator.latestStructuralContentVersion == 1) + #expect(coordinator.latestRequiredRebuildVersion == 1) + + coordinator.invalidate(allowsStaleContent: false, requiresRebuild: false) + #expect(coordinator.contentVersion == 3) + #expect(coordinator.latestStructuralContentVersion == 3) + #expect(coordinator.latestRequiredRebuildVersion == 1) + } + + @Test + func `closed preparation distinguishes no work deferred work and required work`() { + var coordinator = MenuSessionCoordinator() + let menu = "menu" + + #expect(coordinator.closedPreparationPlan(for: [menu]) == .nonDeferred) + + coordinator.invalidate(allowsStaleContent: true, requiresRebuild: false) + #expect(coordinator.closedPreparationPlan(for: [menu]) == .none) + + coordinator.invalidate(allowsStaleContent: false, requiresRebuild: true) + #expect(coordinator.closedPreparationPlan(for: [menu]) == .required(version: 2)) + + coordinator.markFresh(menu) + #expect(coordinator.closedPreparationPlan(for: [menu]) == .nonDeferred) + } + + @Test + func `stale content survives only a data generation after latest structural render`() { + var coordinator = MenuSessionCoordinator() + let menu = "menu" + + coordinator.invalidate(allowsStaleContent: false, requiresRebuild: true) + coordinator.markFresh(menu) + coordinator.invalidate(allowsStaleContent: true, requiresRebuild: false) + #expect(coordinator.canPreserveStaleContent(for: menu)) + + coordinator.invalidate(allowsStaleContent: false, requiresRebuild: false) + coordinator.invalidate(allowsStaleContent: true, requiresRebuild: false) + #expect(!coordinator.canPreserveStaleContent(for: menu)) + } + + @Test + func `removing menu clears all menu scoped lifecycle state`() { + var coordinator = MenuSessionCoordinator() + let menu = "menu" + + coordinator.markFresh(menu) + coordinator.deferUntilNextOpen(menu) + coordinator.deferParentRebuild(menu) + _ = coordinator.beginTrackingSession(menu) + _ = coordinator.armViewportRestore(menu) + coordinator.removeMenu(menu) + + #expect(coordinator.renderedVersion(for: menu) == nil) + #expect(!coordinator.isDeferredUntilNextOpen(menu)) + #expect(!coordinator.isParentRebuildDeferred(menu)) + #expect(coordinator.menuInteractionGeneration(for: menu) == nil) + #expect(coordinator.pendingViewportRestores.isEmpty) + } + + @Test + func `reopening a persistent menu replaces its tracking session token`() { + var coordinator = MenuSessionCoordinator() + + let closedSession = coordinator.beginTrackingSession("menu") + coordinator.endTrackingSession("menu") + let reopenedSession = coordinator.beginTrackingSession("menu") + + #expect(closedSession != reopenedSession) + #expect(!coordinator.isCurrentMenuInteraction(closedSession, for: "menu")) + #expect(coordinator.isCurrentMenuInteraction(reopenedSession, for: "menu")) + } + + @Test + func `menu interaction token advances within one tracking session`() throws { + var coordinator = MenuSessionCoordinator() + let initial = coordinator.beginTrackingSession("menu") + + let advanced = coordinator.advanceMenuInteraction(for: "menu") + let replacement = try #require(advanced) + + #expect(!coordinator.isCurrentMenuInteraction(initial, for: "menu")) + #expect(coordinator.isCurrentMenuInteraction(replacement, for: "menu")) + } + + @Test + func `replacement viewport restore token rejects stale completion`() { + var coordinator = MenuSessionCoordinator() + + let stale = coordinator.armViewportRestore("menu") + let current = coordinator.armViewportRestore("menu") + + #expect(!coordinator.isCurrentViewportRestore(stale, for: "menu")) + let staleConsumed = coordinator.consumeViewportRestore("menu", generation: stale) + #expect(!staleConsumed) + #expect(coordinator.isCurrentViewportRestore(current, for: "menu")) + let currentConsumed = coordinator.consumeViewportRestore("menu", generation: current) + #expect(currentConsumed) + #expect(coordinator.pendingViewportRestores.isEmpty) + } +} + +struct MenuRebuildRequestRegistryTests { + @Test + func `replacement request invalidates prior token without affecting other menus`() { + var registry = MenuRebuildRequestRegistry() + + let first = registry.replaceRequest(for: "parent") + let child = registry.replaceRequest(for: "child") + let replacement = registry.replaceRequest(for: "parent") + + #expect(!registry.isCurrent(first, for: "parent")) + #expect(registry.isCurrent(replacement, for: "parent")) + #expect(registry.isCurrent(child, for: "child")) + } + + @Test + func `stale completion cannot clear replacement request`() { + var registry = MenuRebuildRequestRegistry() + let stale = registry.replaceRequest(for: "menu") + let current = registry.replaceRequest(for: "menu") + + let staleDidFinish = registry.finish(stale, for: "menu") + #expect(!staleDidFinish) + #expect(registry.isCurrent(current, for: "menu")) + let currentDidFinish = registry.finish(current, for: "menu") + #expect(currentDidFinish) + #expect(!registry.isCurrent(current, for: "menu")) + } + + @Test + func `cancelling all requests keeps future tokens distinct`() { + var registry = MenuRebuildRequestRegistry() + let cancelled = registry.replaceRequest(for: "menu") + + registry.cancelAll() + let replacement = registry.replaceRequest(for: "menu") + + #expect(cancelled != replacement) + #expect(registry.isCurrent(replacement, for: "menu")) + } +} diff --git a/Tests/CodexBarTests/MiMoFirefoxSessionCookieImporterTests.swift b/Tests/CodexBarTests/MiMoFirefoxSessionCookieImporterTests.swift new file mode 100644 index 0000000000..968150251c --- /dev/null +++ b/Tests/CodexBarTests/MiMoFirefoxSessionCookieImporterTests.swift @@ -0,0 +1,259 @@ +import Foundation +import Testing +@testable import CodexBarCore + +#if os(macOS) +struct MiMoFirefoxSessionCookieImporterTests { + @Test + func `rejects mismatched decoded size`() throws { + let json = #"{"cookies":[]}"# + var data = self.mozillaLZ4LiteralFile(json) + var mismatchedSize = UInt32(json.utf8.count + 1).littleEndian + withUnsafeBytes(of: &mismatchedSize) { data.replaceSubrange(8..<12, with: $0) } + + do { + _ = try MiMoFirefoxSessionCookieImporter.decodeSessionRestoreData(data) + Issue.record("Expected mismatched Firefox session restore size to fail") + } catch let error as MiMoFirefoxSessionCookieImporter.ImportError { + guard case .invalidData = error else { + Issue.record("Unexpected Firefox session restore error: \(error)") + return + } + } + } + + @Test + func `too small decoded size falls back to valid backup`() throws { + let (temp, profile, backups) = try self.makeFirefoxProfile() + defer { try? FileManager.default.removeItem(at: temp) } + + let currentJSON = #"{"cookies":[]}"# + var current = self.mozillaLZ4LiteralFile(currentJSON) + var tooSmallSize = UInt32(currentJSON.utf8.count - 1).littleEndian + withUnsafeBytes(of: &tooSmallSize) { current.replaceSubrange(8..<12, with: $0) } + try current.write(to: backups.appendingPathComponent("recovery.jsonlz4")) + let backupJSON = #"{"cookies":[{"host":".xiaomimimo.com","name":"userId","value":"backup-user"}]}"# + try self.mozillaLZ4LiteralFile(backupJSON) + .write(to: backups.appendingPathComponent("recovery.baklz4")) + + let outcome = MiMoFirefoxSessionCookieImporter.load(profileDirectory: profile) + + guard case let .loaded(records) = outcome else { + Issue.record("Expected malformed current state to fall back to the valid backup") + return + } + #expect(records.map(\.value) == ["backup-user"]) + } + + @Test + func `canonical large payload bypasses raw size prefix trap`() throws { + let padding = String(repeating: "x", count: 65520) + let json = #"{"cookies":[],"padding":"\#(padding)"}"# + + let decoded = try MiMoFirefoxSessionCookieImporter.decodeSessionRestoreData( + self.mozillaLZ4LiteralFile(json)) + + #expect(decoded == Data(json.utf8)) + } + + @Test + func `reads only top level cookies`() throws { + let data = Data(#"{"nested":{"cookies":[{"host":".xiaomimimo.com","name":"userId","value":"stale"}]}}"#.utf8) + + let records = try MiMoFirefoxSessionCookieImporter.cookieRecords(fromJSONData: data) + + #expect(records.isEmpty) + } + + @Test + func `rejects isolated cookie contexts and wrong attribute types`() throws { + let data = Data(#""" + {"cookies":[ + {"host":".platform.xiaomimimo.com","name":"api-platform_serviceToken", + "value":"clean-token","originAttributes":{ + "userContextId":0,"privateBrowsingId":0, + "firstPartyDomain":"","geckoViewSessionContextId":"","partitionKey":"" + }}, + {"host":".xiaomimimo.com","name":"userId","value":"clean-user","originAttributes":"","isPartitioned":false}, + {"host":".xiaomimimo.com","name":"userId","value":"container-user","originAttributes":{"userContextId":2}}, + {"host":".xiaomimimo.com","name":"userId","value":"private-user","originAttributes":{"privateBrowsingId":1}}, + {"host":".platform.xiaomimimo.com","name":"api-platform_ph","value":"partitioned","isPartitioned":true}, + {"host":".platform.xiaomimimo.com","name":"api-platform_ph","value":"numeric-partition","isPartitioned":0}, + {"host":".platform.xiaomimimo.com","name":"api-platform_ph", + "value":"boolean-context","originAttributes":{"userContextId":false}}, + {"host":".platform.xiaomimimo.com","name":"api-platform_ph", + "value":"floating-context","originAttributes":{"privateBrowsingId":0.0}}, + {"host":".platform.xiaomimimo.com","name":"api-platform_ph", + "value":"unknown-context","originAttributes":{"futureIsolationKey":"value"}} + ]} + """#.utf8) + + let records = try MiMoFirefoxSessionCookieImporter.cookieRecords(fromJSONData: data) + + #expect(Set(records.map(\.value)) == Set(["clean-token", "clean-user"])) + } + + @Test + func `cookie count is bounded before filtering`() throws { + let data = Data(#""" + {"cookies":[ + {"host":"example.com","name":"irrelevant","value":"one"}, + {"host":"example.com","name":"irrelevant","value":"two"} + ]} + """#.utf8) + + do { + _ = try MiMoFirefoxSessionCookieImporter.cookieRecords(fromJSONData: data, maxRecords: 1) + Issue.record("Expected Firefox session restore cookie count to be bounded") + } catch let error as MiMoFirefoxSessionCookieImporter.ImportError { + guard case .resourceLimit = error else { + Issue.record("Unexpected Firefox session restore error: \(error)") + return + } + } + } + + @Test + func `mixed cookie array is malformed after applying count bound`() throws { + let oversized = Data(#"{"cookies":[1,2]}"#.utf8) + let mixed = Data(#"{"cookies":[{"host":"example.com"},1]}"#.utf8) + + do { + _ = try MiMoFirefoxSessionCookieImporter.cookieRecords(fromJSONData: oversized, maxRecords: 1) + Issue.record("Expected mixed Firefox session restore cookie count to be bounded") + } catch let error as MiMoFirefoxSessionCookieImporter.ImportError { + guard case .resourceLimit = error else { + Issue.record("Unexpected Firefox session restore error: \(error)") + return + } + } + + do { + _ = try MiMoFirefoxSessionCookieImporter.cookieRecords(fromJSONData: mixed, maxRecords: 2) + Issue.record("Expected mixed Firefox session restore cookies to be malformed") + } catch let error as MiMoFirefoxSessionCookieImporter.ImportError { + guard case .invalidData = error else { + Issue.record("Unexpected Firefox session restore error: \(error)") + return + } + } + } + + @Test + func `input limit stops before older backup`() throws { + let (temp, profile, backups) = try self.makeFirefoxProfile() + defer { try? FileManager.default.removeItem(at: temp) } + + try Data(repeating: 0x41, count: 5).write(to: backups.appendingPathComponent("recovery.jsonlz4")) + try self.mozillaLZ4LiteralFile(#"{"cookies":[]}"#) + .write(to: backups.appendingPathComponent("recovery.baklz4")) + + let outcome = MiMoFirefoxSessionCookieImporter.load( + profileDirectory: profile, + limits: .init(inputBytes: 4, outputBytes: 1024, cookieRecords: 10)) + + guard case .resourceLimited(.inputBytes) = outcome else { + Issue.record("Expected the current Firefox input limit to stop backup recovery") + return + } + } + + @Test + func `output limit stops before older backup`() throws { + let (temp, profile, backups) = try self.makeFirefoxProfile() + defer { try? FileManager.default.removeItem(at: temp) } + + var oversized = Data([0x6D, 0x6F, 0x7A, 0x4C, 0x7A, 0x34, 0x30, 0x00]) + var declaredSize = UInt32(129 * 1024 * 1024).littleEndian + withUnsafeBytes(of: &declaredSize) { oversized.append(contentsOf: $0) } + try oversized.write(to: backups.appendingPathComponent("recovery.jsonlz4")) + try self.mozillaLZ4LiteralFile(#"{"cookies":[]}"#) + .write(to: backups.appendingPathComponent("recovery.baklz4")) + + let outcome = MiMoFirefoxSessionCookieImporter.load(profileDirectory: profile) + + guard case .resourceLimited(.outputBytes) = outcome else { + Issue.record("Expected the current Firefox output limit to stop backup recovery") + return + } + } + + @Test + func `cookie limit stops before older backup`() throws { + let (temp, profile, backups) = try self.makeFirefoxProfile() + defer { try? FileManager.default.removeItem(at: temp) } + + try self.mozillaLZ4LiteralFile(#"{"cookies":[1,2]}"#) + .write(to: backups.appendingPathComponent("recovery.jsonlz4")) + try self.mozillaLZ4LiteralFile(#"{"cookies":[]}"#) + .write(to: backups.appendingPathComponent("recovery.baklz4")) + + let outcome = MiMoFirefoxSessionCookieImporter.load( + profileDirectory: profile, + limits: .init(inputBytes: 1024, outputBytes: 1024, cookieRecords: 1)) + + guard case .resourceLimited(.cookieRecords) = outcome else { + Issue.record("Expected the current Firefox cookie limit to stop backup recovery") + return + } + } + + @Test + func `candidates follow deterministic firefox order with newest upgrade only`() { + let profile = URL(fileURLWithPath: "/tmp/firefox/profile", isDirectory: true) + let backups = profile.appendingPathComponent("sessionstore-backups", isDirectory: true) + let upgrades = [ + backups.appendingPathComponent("upgrade.jsonlz4-20250101000000"), + backups.appendingPathComponent("unrelated.jsonlz4"), + backups.appendingPathComponent("upgrade.jsonlz4-20260101000000"), + ] + + let candidates = MiMoFirefoxSessionCookieImporter.orderedSessionRestoreFileCandidates( + profileDirectory: profile, + upgradeFiles: upgrades) + + #expect(candidates.map(\.lastPathComponent) == [ + "sessionstore.jsonlz4", + "recovery.jsonlz4", + "recovery.baklz4", + "previous.jsonlz4", + "upgrade.jsonlz4-20260101000000", + ]) + } + + private func mozillaLZ4LiteralFile(_ json: String) -> Data { + var data = Data([0x6D, 0x6F, 0x7A, 0x4C, 0x7A, 0x34, 0x30, 0x00]) + var decodedSize = UInt32(json.utf8.count).littleEndian + withUnsafeBytes(of: &decodedSize) { data.append(contentsOf: $0) } + data.append(self.lz4LiteralBlock(Data(json.utf8))) + return data + } + + private func makeFirefoxProfile() throws -> (temp: URL, profile: URL, backups: URL) { + let temp = FileManager.default.temporaryDirectory + .appendingPathComponent("mimo-firefox-limit-\(UUID().uuidString)", isDirectory: true) + let profile = temp.appendingPathComponent("default-release", isDirectory: true) + let backups = profile.appendingPathComponent("sessionstore-backups", isDirectory: true) + try FileManager.default.createDirectory(at: backups, withIntermediateDirectories: true) + return (temp, profile, backups) + } + + private func lz4LiteralBlock(_ payload: Data) -> Data { + var output = Data() + let literalCount = payload.count + if literalCount < 15 { + output.append(UInt8(literalCount << 4)) + } else { + output.append(0xF0) + var remaining = literalCount - 15 + while remaining >= 255 { + output.append(255) + remaining -= 255 + } + output.append(UInt8(remaining)) + } + output.append(payload) + return output + } +} +#endif diff --git a/Tests/CodexBarTests/MiMoLocalUsageFallbackTests.swift b/Tests/CodexBarTests/MiMoLocalUsageFallbackTests.swift new file mode 100644 index 0000000000..96ee23d81c --- /dev/null +++ b/Tests/CodexBarTests/MiMoLocalUsageFallbackTests.swift @@ -0,0 +1,192 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct MiMoLocalUsageFallbackTests { + @Test + func `returns nil when cache file is missing`() { + let snap = MiMoLocalUsageFallback.snapshot( + cachePath: "/nonexistent/path/that/should/never/exist.json", + now: Date()) + #expect(snap == nil) + } + + @Test + func `returns nil when cache file is malformed JSON`() throws { + let dir = FileManager.default.temporaryDirectory + .appendingPathComponent("mimo-fallback-test-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: dir) } + let file = dir.appendingPathComponent("malformed.json") + try "{not json".write(to: file, atomically: true, encoding: .utf8) + + let snap = MiMoLocalUsageFallback.snapshot(cachePath: file.path, now: Date()) + #expect(snap == nil) + } + + @Test + func `returns nil when cache schema is incomplete`() throws { + let dir = FileManager.default.temporaryDirectory + .appendingPathComponent("mimo-fallback-test-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: dir) } + let file = dir.appendingPathComponent("incomplete.json") + try Data("{}".utf8).write(to: file) + + let snap = MiMoLocalUsageFallback.snapshot(cachePath: file.path, now: Date()) + #expect(snap == nil) + } + + @Test + func `parses all token buckets without fabricating a quota window`() throws { + let dir = FileManager.default.temporaryDirectory + .appendingPathComponent("mimo-fallback-test-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: dir) } + let file = dir.appendingPathComponent("usage.json") + let updatedAt = "2026-06-03T05:04:03.123456+00:00" + let payload: [String: Any] = [ + "updated_at": updatedAt, + "sessions_scanned": 1296, + "windows": [ + "today": ["input": 1500, "output": 500, "cache_read": 0, "cache_create": 250, "messages": 3], + "week": [ + "input": 30000, + "output": 10000, + "cache_read": 60000, + "cache_create": 10000, + "messages": 25, + ], + "all_time": [ + "input": 3_600_000, + "output": 1_100_000, + "cache_read": 16_100_000, + "cache_create": 2_000_000, + "messages": 1315, + ], + ], + ] + try JSONSerialization.data(withJSONObject: payload).write(to: file) + + let snap = try #require(MiMoLocalUsageFallback.snapshot(cachePath: file.path, now: Date())) + + // planCode packs today/week/total/sessions in one row. + let plan = try #require(snap.planCode) + #expect(plan.contains("today")) + #expect(plan.contains("week")) + #expect(plan.contains("total")) + #expect(plan.contains("1296 sessions")) + #expect(plan.contains("110.0k week")) + #expect(plan.contains("22.8M total")) + #expect(snap.tokenUsed == 0) + #expect(snap.tokenLimit == 0) + #expect(snap.tokenPercent == 0) + let usage = snap.toUsageSnapshot(includeBalance: false) + #expect(usage.primary == nil) + #expect(usage.mimoUsage == nil) + + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + #expect(snap.updatedAt == formatter.date(from: updatedAt)) + } + + @Test + func `idle week keeps local accounting in the plan summary`() throws { + let dir = FileManager.default.temporaryDirectory + .appendingPathComponent("mimo-fallback-test-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: dir) } + let file = dir.appendingPathComponent("idle.json") + let payload: [String: Any] = [ + "sessions_scanned": 100, + "windows": [ + "today": ["input": 0, "output": 0, "cache_read": 0], + "week": ["input": 0, "output": 0, "cache_read": 0], + "all_time": ["input": 500_000, "output": 250_000, "cache_read": 1_250_000], + ], + ] + try JSONSerialization.data(withJSONObject: payload).write(to: file) + + let snap = try #require(MiMoLocalUsageFallback.snapshot(cachePath: file.path, now: Date())) + #expect(snap.tokenUsed == 0) + #expect(snap.tokenLimit == 0) + #expect(snap.tokenPercent == 0) + #expect(snap.toUsageSnapshot(includeBalance: false).mimoUsage == nil) + let plan = try #require(snap.planCode) + #expect(plan.hasPrefix("Local")) + #expect(!plan.contains("today")) + #expect(!plan.contains("week")) + #expect(plan.contains("total")) + #expect(plan.contains("100 sessions")) + } + + @Test + func `stale summary preserves compact casing through usage projection`() throws { + let now = try #require(ISO8601DateFormatter().date(from: "2026-07-07T10:00:00Z")) + let snap = try self.makeSnapshot(updatedAt: "2026-06-03T10:00:00.000000+00:00", now: now) + let plan = try #require(snap.planCode) + + #expect(plan == "Local · 1.5k total · 42 sessions · stale 34d") + #expect(snap.toUsageSnapshot(includeBalance: false).loginMethod(for: .mimo) == plan) + } + + @Test + func `stale boundary is exclusive and future timestamps stay fresh`() throws { + let now = try #require(ISO8601DateFormatter().date(from: "2026-07-07T10:00:00Z")) + let base = "Local · 1.5k total · 42 sessions" + let cases = [ + ("2026-07-06T22:00:00.000000+00:00", base), + ("2026-07-06T21:59:59.000000+00:00", "\(base) · stale 12h"), + ("2026-07-07T10:01:00.000000+00:00", base), + ] + + for (updatedAt, expectedPlan) in cases { + let snap = try self.makeSnapshot(updatedAt: updatedAt, now: now) + #expect(snap.planCode == expectedPlan) + } + } + + @Test + func `missing or invalid timestamp uses stale file modification date`() throws { + let now = try #require(ISO8601DateFormatter().date(from: "2026-07-07T10:00:00Z")) + let oldModificationDate = now.addingTimeInterval(-2 * 24 * 60 * 60) + + for updatedAt: String? in [nil, "not-a-timestamp"] { + let snap = try self.makeSnapshot( + updatedAt: updatedAt, + fileModificationDate: oldModificationDate, + now: now) + #expect(snap.planCode == "Local · 1.5k total · 42 sessions · stale 2d") + #expect(snap.updatedAt == oldModificationDate) + } + } + + private func makeSnapshot( + updatedAt: String?, + fileModificationDate: Date? = nil, + now: Date) throws -> MiMoUsageSnapshot + { + let dir = FileManager.default.temporaryDirectory + .appendingPathComponent("mimo-fallback-test-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: dir) } + let file = dir.appendingPathComponent("usage.json") + var payload: [String: Any] = [ + "sessions_scanned": 42, + "windows": [ + "today": ["input": 0, "output": 0, "cache_read": 0], + "week": ["input": 0, "output": 0, "cache_read": 0], + "all_time": ["input": 1000, "output": 500, "cache_read": 0], + ], + ] + if let updatedAt { + payload["updated_at"] = updatedAt + } + try JSONSerialization.data(withJSONObject: payload).write(to: file) + if let fileModificationDate { + try FileManager.default.setAttributes([.modificationDate: fileModificationDate], ofItemAtPath: file.path) + } + + return try #require(MiMoLocalUsageFallback.snapshot(cachePath: file.path, now: now)) + } +} diff --git a/Tests/CodexBarTests/MiMoProviderTests.swift b/Tests/CodexBarTests/MiMoProviderTests.swift index 411ed124b6..8e21bb4fd1 100644 --- a/Tests/CodexBarTests/MiMoProviderTests.swift +++ b/Tests/CodexBarTests/MiMoProviderTests.swift @@ -160,7 +160,7 @@ struct MiMoProviderTests { } @Test - func `usage snapshot exposes balance through identity plan text`() { + func `usage snapshot exposes balance without duplicating identity`() { let snapshot = MiMoUsageSnapshot( balance: 25.51, currency: "USD", @@ -170,7 +170,24 @@ struct MiMoProviderTests { #expect(usage.primary == nil) #expect(usage.secondary == nil) - #expect(usage.loginMethod(for: .mimo) == "Balance: $25.51") + #expect(usage.mimoUsage?.balanceDetail == "$25.51") + #expect(usage.loginMethod(for: .mimo) == nil) + } + + @Test + func `usage snapshot exposes paid and granted balance components`() { + let snapshot = MiMoUsageSnapshot( + balance: 25.51, + currency: "USD", + cashBalance: 20, + giftBalance: 5.51, + updatedAt: Date(timeIntervalSince1970: 1_742_771_200)) + + let usage = snapshot.toUsageSnapshot() + + #expect(usage.primary == nil) + #expect(usage.mimoUsage?.balanceDetail == "$25.51 (Paid: $20.00 / Granted: $5.51)") + #expect(usage.loginMethod(for: .mimo) == nil) } @Test @@ -193,9 +210,62 @@ struct MiMoProviderTests { #expect(abs((usage.primary?.usedPercent ?? .nan) - 5.05) < 0.0001) #expect(usage.primary?.resetDescription == "10,100,158 / 200,000,000 Credits") #expect(usage.primary?.resetsAt == resetDate) + #expect(usage.secondary == nil) + #expect(usage.mimoUsage?.balanceDetail == "$25.51") #expect(usage.loginMethod(for: .mimo) == "Standard") } + @Test + func `menu card preserves compact local summary casing`() throws { + let now = Date(timeIntervalSince1970: 1_742_771_200) + let summary = "Local · 1.5k total · 42 sessions · stale 34d" + let snapshot = MiMoUsageSnapshot( + balance: 0, + currency: "", + planCode: summary, + updatedAt: now) + .toUsageSnapshot(includeBalance: false) + let metadata = try #require(ProviderDefaults.metadata[.mimo]) + + let model = Self.makeMenuCardModel(snapshot: snapshot, metadata: metadata, now: now) + + #expect(model.planText == summary) + #expect(model.metrics.isEmpty) + } + + @Test + func `menu card shows balance as status text with and without token plan`() throws { + let now = Date(timeIntervalSince1970: 1_742_771_200) + let metadata = try #require(ProviderDefaults.metadata[.mimo]) + let balanceOnly = MiMoUsageSnapshot( + balance: 25.51, + currency: "USD", + cashBalance: 20, + giftBalance: 5.51, + updatedAt: now) + .toUsageSnapshot() + let withPlan = MiMoUsageSnapshot( + balance: 25.51, + currency: "USD", + cashBalance: 20, + giftBalance: 5.51, + planCode: "standard", + tokenUsed: 10, + tokenLimit: 100, + tokenPercent: 0.1, + updatedAt: now) + .toUsageSnapshot() + + let balanceModel = Self.makeMenuCardModel(snapshot: balanceOnly, metadata: metadata, now: now) + let planModel = Self.makeMenuCardModel(snapshot: withPlan, metadata: metadata, now: now) + + #expect(balanceModel.metrics.first?.title == "Balance") + #expect(balanceModel.metrics.first?.statusText == "$25.51 (Paid: $20.00 / Granted: $5.51)") + #expect(planModel.metrics.first?.title == "Credits") + #expect(planModel.metrics.last?.title == "Balance") + #expect(planModel.metrics.last?.statusText == "$25.51 (Paid: $20.00 / Granted: $5.51)") + } + @Test func `usage snapshot falls back to balance when no token plan`() { let snapshot = MiMoUsageSnapshot( @@ -212,7 +282,58 @@ struct MiMoProviderTests { let usage = snapshot.toUsageSnapshot() #expect(usage.primary == nil) - #expect(usage.loginMethod(for: .mimo) == "Balance: $0.00") + #expect(usage.mimoUsage?.balanceDetail == "$0.00") + #expect(usage.loginMethod(for: .mimo) == nil) + } + + @Test + func `usage snapshot persists mimo balance details`() throws { + let usage = MiMoUsageSnapshot( + balance: 25.51, + currency: "USD", + cashBalance: 20, + giftBalance: 5.51, + updatedAt: Date(timeIntervalSince1970: 1_742_771_200)) + .toUsageSnapshot() + + let decoded = try JSONDecoder().decode(UsageSnapshot.self, from: JSONEncoder().encode(usage)) + + #expect(decoded.primary == nil) + #expect(decoded.mimoUsage?.balanceDetail == "$25.51 (Paid: $20.00 / Granted: $5.51)") + } + + @Test + func `balance does not participate in icon or switcher quota percentages`() { + let balanceOnly = MiMoUsageSnapshot( + balance: 25.51, + currency: "USD", + updatedAt: Date()) + .toUsageSnapshot() + let withPlan = MiMoUsageSnapshot( + balance: 25.51, + currency: "USD", + planCode: "standard", + tokenUsed: 10, + tokenLimit: 100, + tokenPercent: 0.1, + updatedAt: Date()) + .toUsageSnapshot() + + let balanceIcon = IconRemainingResolver.resolvedRemaining(snapshot: balanceOnly, style: .mimo) + let planIcon = IconRemainingResolver.resolvedRemaining(snapshot: withPlan, style: .mimo) + + #expect(balanceIcon.primary == nil) + #expect(balanceIcon.secondary == nil) + #expect(StatusItemController.switcherWeeklyMetricPercent( + for: .mimo, + snapshot: balanceOnly, + showUsed: false) == nil) + #expect(planIcon.primary == 90) + #expect(planIcon.secondary == nil) + #expect(StatusItemController.switcherWeeklyMetricPercent( + for: .mimo, + snapshot: withPlan, + showUsed: false) == 90) } @Test @@ -235,9 +356,60 @@ struct MiMoProviderTests { #expect(snapshot.balance == 25.51) #expect(snapshot.currency == "USD") + #expect(snapshot.cashBalance == nil) + #expect(snapshot.giftBalance == nil) #expect(snapshot.updatedAt == now) } + @Test + func `parses paid and granted balance fields when available`() throws { + let now = Date(timeIntervalSince1970: 1_742_771_200) + let json = """ + { + "code": 0, + "message": "", + "data": { + "balance": "50.00", + "frozenBalance": null, + "currency": "USD", + "overdraftLimit": null, + "remainingOverdraftLimit": null, + "giftBalance": "20.00", + "cashBalance": "30.00" + } + } + """ + + let snapshot = try MiMoUsageFetcher.parseUsageSnapshot(from: Data(json.utf8), now: now) + + #expect(snapshot.balance == 50) + #expect(snapshot.cashBalance == 30) + #expect(snapshot.giftBalance == 20) + #expect(snapshot.currency == "USD") + } + + @Test + func `ignores malformed optional balance components`() throws { + let json = """ + { + "code": 0, + "message": "", + "data": { + "balance": "25.51", + "currency": "USD", + "giftBalance": "", + "cashBalance": "unknown" + } + } + """ + + let snapshot = try MiMoUsageFetcher.parseUsageSnapshot(from: Data(json.utf8)) + + #expect(snapshot.balance == 25.51) + #expect(snapshot.cashBalance == nil) + #expect(snapshot.giftBalance == nil) + } + @Test func `parses token plan detail payload`() throws { let json = """ @@ -292,7 +464,7 @@ struct MiMoProviderTests { func `combined snapshot merges balance and token plan`() throws { let now = Date(timeIntervalSince1970: 1_742_771_200) let balanceJSON = """ - {"code":0,"message":"","data":{"balance":"25.51","currency":"USD"}} + {"code":0,"message":"","data":{"balance":"25.51","currency":"USD","cashBalance":"20","giftBalance":"5.51"}} """ let detailJSON = """ {"code":0,"message":"","data":{"planCode":"standard","currentPeriodEnd":"2026-05-04 23:59:59","expired":false}} @@ -325,6 +497,8 @@ struct MiMoProviderTests { #expect(snapshot.balance == 25.51) #expect(snapshot.currency == "USD") + #expect(snapshot.cashBalance == 20) + #expect(snapshot.giftBalance == 5.51) #expect(snapshot.planCode == "standard") #expect(snapshot.tokenUsed == 10_100_158) #expect(snapshot.tokenLimit == 200_000_000) @@ -375,14 +549,90 @@ struct MiMoProviderTests { #expect(requestedPaths.contains("/api/v1/balance")) } + @Test + func `required balance failure cancels optional mimo requests promptly`() async throws { + let optionalStarted = MiMoOptionalRequestGate() + let transport = ProviderHTTPTransportStub { request in + let path = try #require(request.url?.path) + if path.hasSuffix("/balance") { + await optionalStarted.wait() + throw URLError(.userAuthenticationRequired) + } + + await optionalStarted.open() + try await Task.sleep(for: .seconds(5)) + let (response, data) = try Self.makeResponse(url: #require(request.url), body: "{}") + return (data, response) + } + + let startedAt = ContinuousClock.now + do { + _ = try await MiMoUsageFetcher.fetchUsage( + cookieHeader: "userId=123; api-platform_serviceToken=svc-token", + environment: ["MIMO_API_URL": "https://mimo.test/api/v1"], + session: transport) + Issue.record("Expected required balance request to fail") + } catch let error as URLError { + #expect(error.code == .userAuthenticationRequired) + } + let elapsed = startedAt.duration(to: .now) + + #expect(elapsed < .seconds(1), "Required failure was delayed by optional requests: \(elapsed)") + } + + @Test + func `fetch usage treats auth redirect as login required`() async throws { + let transport = ProviderHTTPTransportStub { request in + let url = try #require(request.url) + let (response, data) = Self.makeResponse(url: url, body: "", statusCode: 302) + return (data, response) + } + + do { + _ = try await MiMoUsageFetcher.fetchUsage( + cookieHeader: "userId=123; api-platform_serviceToken=expired-token", + environment: ["MIMO_API_URL": "https://mimo.test/api/v1"], + session: transport) + Issue.record("Expected MiMo auth redirect to require login") + } catch MiMoUsageError.loginRequired { + // Expected. + } + } +} + +private actor MiMoOptionalRequestGate { + private var isOpen = false + private var continuations: [CheckedContinuation] = [] + + func wait() async { + guard !self.isOpen else { return } + await withCheckedContinuation { continuation in + self.continuations.append(continuation) + } + } + + func open() { + self.isOpen = true + let continuations = self.continuations + self.continuations.removeAll() + for continuation in continuations { + continuation.resume() + } + } +} + +extension MiMoProviderTests { @Test @MainActor func `provider detail plan row formats mimo as balance`() { CodexBarLocalizationOverride.$appLanguage.withValue("en") { - let row = ProviderDetailView.planRow(provider: .mimo, planText: "Balance: $25.51") + let legacyBalance = ProviderDetailView.planRow(provider: .mimo, planText: "Balance: $25.51") + let tokenPlan = ProviderDetailView.planRow(provider: .mimo, planText: "Standard") - #expect(row?.label == "Balance") - #expect(row?.value == "$25.51") + #expect(legacyBalance?.label == "Balance") + #expect(legacyBalance?.value == "$25.51") + #expect(tokenPlan?.label == "Plan") + #expect(tokenPlan?.value == "Standard") } } @@ -423,6 +673,56 @@ struct MiMoProviderTests { #expect(lines.contains("Balance: $25.51")) #expect(!lines.contains("Balance: Balance: $25.51")) + if provider == .mimo { + #expect(!lines.contains(where: { $0.hasPrefix("Balance: 100%") })) + } + } + + @Test + @MainActor + func `menu descriptor renders mimo token detail without reset date`() throws { + let suite = "MiMoProviderTests-menu-token-detail" + 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 + + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings) + let snapshot = MiMoUsageSnapshot( + balance: 25.51, + currency: "USD", + planCode: "standard", + tokenUsed: 10, + tokenLimit: 100, + tokenPercent: 0.1, + updatedAt: Date(timeIntervalSince1970: 1_742_771_200)) + .toUsageSnapshot() + store._setSnapshotForTesting(snapshot, provider: .mimo) + + let descriptor = MenuDescriptor.build( + provider: .mimo, + store: store, + settings: settings, + account: AccountInfo(email: nil, plan: nil), + updateReady: false, + includeContextualActions: false) + let lines = descriptor.sections + .flatMap(\.entries) + .compactMap { entry -> String? in + guard case let .text(text, _) = entry else { return nil } + return text + } + + #expect(lines.contains("10 / 100 Credits")) + #expect(!lines.contains("Resets 10 / 100 Credits")) } @Test @@ -446,6 +746,103 @@ struct MiMoProviderTests { #expect(available == false) } + @Test + func `mimo local strategy works when web cookies are disabled or invalid`() async throws { + let dir = FileManager.default.temporaryDirectory + .appendingPathComponent("mimo-local-strategy-test-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: dir) } + let file = dir.appendingPathComponent("usage.json") + let payload: [String: Any] = [ + "sessions_scanned": 2, + "windows": [ + "today": ["input": 100, "output": 50, "cache_read": 0, "cache_create": 0], + "week": ["input": 100, "output": 50, "cache_read": 0, "cache_create": 0], + "all_time": ["input": 100, "output": 50, "cache_read": 0, "cache_create": 0], + ], + ] + try JSONSerialization.data(withJSONObject: payload).write(to: file) + + let settings = [ + ProviderSettingsSnapshot.make(mimo: .init(cookieSource: .off, manualCookieHeader: nil)), + ProviderSettingsSnapshot.make( + mimo: .init(cookieSource: .manual, manualCookieHeader: "Cookie: userId=123")), + ] + + for setting in settings { + let context = self.makeContext( + settings: setting, + environment: ["MIMO_LOCAL_USAGE_PATH": file.path]) + let outcome = await MiMoProviderDescriptor.descriptor.fetchPlan.fetchOutcome( + context: context, + provider: .mimo) + + switch outcome.result { + case let .success(result): + #expect(result.sourceLabel == "local") + #expect(result.strategyID == "mimo.local") + #expect(result.usage.primary == nil) + #expect(result.usage.mimoUsage == nil) + #expect(result.usage.loginMethod(for: .mimo) == "Local · 150 today · 150 week · 150 total · 2 sessions") + case let .failure(error): + Issue.record("Expected local MiMo fallback, got \(error)") + } + } + } + + @Test + func `mimo malformed local cache stays available and reports its cache error`() async throws { + let dir = FileManager.default.temporaryDirectory + .appendingPathComponent("mimo-invalid-local-strategy-test-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: dir) } + let file = dir.appendingPathComponent("usage.json") + try Data("{}".utf8).write(to: file) + + let context = self.makeContext(environment: ["MIMO_LOCAL_USAGE_PATH": file.path]) + let strategy = MiMoLocalFetchStrategy() + + #expect(await strategy.isAvailable(context)) + await #expect(throws: MiMoLocalUsageError.self) { + try await strategy.fetch(context) + } + } + + @Test + func `mimo explicit web mode does not use local fallback`() async throws { + let dir = FileManager.default.temporaryDirectory + .appendingPathComponent("mimo-web-mode-test-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: dir) } + let file = dir.appendingPathComponent("usage.json") + let payload: [String: Any] = [ + "updated_at": "2026-06-03T05:04:03+00:00", + "sessions_scanned": 1, + "windows": [ + "today": ["input": 100, "output": 50, "cache_read": 0, "cache_create": 0], + "week": ["input": 100, "output": 50, "cache_read": 0, "cache_create": 0], + "all_time": ["input": 100, "output": 50, "cache_read": 0, "cache_create": 0], + ], + ] + try JSONSerialization.data(withJSONObject: payload).write(to: file) + + let context = self.makeContext( + sourceMode: .web, + settings: ProviderSettingsSnapshot.make( + mimo: .init(cookieSource: .off, manualCookieHeader: nil)), + environment: ["MIMO_LOCAL_USAGE_PATH": file.path]) + let outcome = await MiMoProviderDescriptor.descriptor.fetchPlan.fetchOutcome( + context: context, + provider: .mimo) + + switch outcome.result { + case let .success(result): + Issue.record("Expected explicit web mode to reject local fallback, got \(result.strategyID)") + case .failure: + break + } + } + @Test func `mimo manual mode does not report available from cached browser session`() async { KeychainCacheStore.setTestStoreForTesting(true) @@ -488,6 +885,29 @@ struct MiMoProviderTests { } } + @Test + func `mimo cookie importer surfaces safari access denial`() throws { + let detection = BrowserDetection( + homeDirectory: "/tmp/codexbar-mimo-browser-test", + cacheTTL: 0, + fileExists: { _ in false }, + directoryContents: { _ in nil }) + + do { + _ = try MiMoCookieImporter.importSessions( + browserDetection: detection, + loadRecords: { browser, _, _ in + throw BrowserCookieError.accessDenied( + browser: browser, + details: "Grant CodexBar Full Disk Access to read Safari cookies.") + }) + Issue.record("Expected Safari access denial") + } catch let error as MiMoSettingsError { + #expect(error.localizedDescription.contains("Full Disk Access")) + #expect(error.localizedDescription.contains("Safari")) + } + } + @Test func `mimo web strategy retries imported sessions after decode failure`() async throws { KeychainCacheStore.setTestStoreForTesting(true) @@ -498,14 +918,13 @@ struct MiMoProviderTests { URLProtocol.unregisterClass(MiMoStubURLProtocol.self) } MiMoStubURLProtocol.handler = nil - MiMoCookieImporter.importSessionsOverrideForTesting = nil CookieHeaderCache.clear(provider: .mimo) } CookieHeaderCache.clear(provider: .mimo) CookieHeaderCache.store(provider: .mimo, cookieHeader: "invalid", sourceLabel: "invalid") - MiMoCookieImporter.importSessionsOverrideForTesting = { _, _ in + try await MiMoCookieImporter.withImportSessionsOverrideForTesting { _, _ in [ .init( cookieHeader: "api-platform_serviceToken=expired-token; userId=111", @@ -514,48 +933,114 @@ struct MiMoProviderTests { cookieHeader: "api-platform_serviceToken=valid-token; userId=222", sourceLabel: "Active Chrome"), ] - } + } operation: { + let lock = NSLock() + var requestedCookies: [String] = [] + MiMoStubURLProtocol.handler = { request in + guard let url = request.url else { throw URLError(.badURL) } + let cookie = request.value(forHTTPHeaderField: "Cookie") ?? "" + lock.withLock { + requestedCookies.append(cookie) + } - let lock = NSLock() - var requestedCookies: [String] = [] - MiMoStubURLProtocol.handler = { request in - guard let url = request.url else { throw URLError(.badURL) } - let cookie = request.value(forHTTPHeaderField: "Cookie") ?? "" - lock.withLock { - requestedCookies.append(cookie) - } + if cookie.contains("expired-token") { + let response = HTTPURLResponse( + url: url, + statusCode: 200, + httpVersion: "HTTP/1.1", + headerFields: ["Content-Type": "text/html"])! + return (response, Data("login".utf8)) + } - if cookie.contains("expired-token") { - let response = HTTPURLResponse( - url: url, - statusCode: 200, - httpVersion: "HTTP/1.1", - headerFields: ["Content-Type": "text/html"])! - return (response, Data("login".utf8)) + let body = """ + { + "code": 0, + "message": "", + "data": { + "balance": "25.51", + "currency": "USD" + } + } + """ + return Self.makeResponse(url: url, body: body) } - let body = """ - { - "code": 0, - "message": "", - "data": { - "balance": "25.51", - "currency": "USD" - } + let strategy = MiMoWebFetchStrategy() + let result = try await strategy + .fetch(self.makeContext(environment: ["MIMO_API_URL": "https://mimo.test/api/v1"])) + + #expect(requestedCookies.count == 6) + #expect(requestedCookies.contains(where: { $0.contains("expired-token") })) + #expect(requestedCookies.contains(where: { $0.contains("valid-token") })) + #expect(result.usage.mimoUsage?.balanceDetail == "$25.51") + #expect(CookieHeaderCache.load(provider: .mimo)?.sourceLabel == "Active Chrome") + } + } + + @Test + func `mimo web strategy retries safari after stale chrome auth redirect`() async throws { + KeychainCacheStore.setTestStoreForTesting(true) + defer { KeychainCacheStore.setTestStoreForTesting(false) } + let registered = URLProtocol.registerClass(MiMoStubURLProtocol.self) + defer { + if registered { + URLProtocol.unregisterClass(MiMoStubURLProtocol.self) } - """ - return Self.makeResponse(url: url, body: body) + MiMoStubURLProtocol.handler = nil + CookieHeaderCache.clear(provider: .mimo) } - let strategy = MiMoWebFetchStrategy() - let result = try await strategy - .fetch(self.makeContext(environment: ["MIMO_API_URL": "https://mimo.test/api/v1"])) + CookieHeaderCache.clear(provider: .mimo) + CookieHeaderCache.store( + provider: .mimo, + cookieHeader: "api-platform_serviceToken=stale-chrome-token; userId=111", + sourceLabel: "Chrome") + + try await MiMoCookieImporter.withImportSessionsOverrideForTesting { _, _ in + [ + .init( + cookieHeader: "api-platform_serviceToken=stale-chrome-token; userId=111", + sourceLabel: "Chrome"), + .init( + cookieHeader: "api-platform_serviceToken=valid-safari-token; userId=222", + sourceLabel: "Safari"), + ] + } operation: { + let lock = NSLock() + var requestedCookies: [String] = [] + MiMoStubURLProtocol.handler = { request in + guard let url = request.url else { throw URLError(.badURL) } + let cookie = request.value(forHTTPHeaderField: "Cookie") ?? "" + lock.withLock { + requestedCookies.append(cookie) + } - #expect(requestedCookies.count == 6) - #expect(requestedCookies.contains(where: { $0.contains("expired-token") })) - #expect(requestedCookies.contains(where: { $0.contains("valid-token") })) - #expect(result.usage.loginMethod(for: .mimo) == "Balance: $25.51") - #expect(CookieHeaderCache.load(provider: .mimo)?.sourceLabel == "Active Chrome") + if cookie.contains("stale-chrome-token") { + return Self.makeResponse(url: url, body: "", statusCode: 302) + } + + let body = """ + { + "code": 0, + "message": "", + "data": { + "balance": "25.51", + "currency": "USD" + } + } + """ + return Self.makeResponse(url: url, body: body) + } + + let strategy = MiMoWebFetchStrategy() + let result = try await strategy + .fetch(self.makeContext(environment: ["MIMO_API_URL": "https://mimo.test/api/v1"])) + + #expect(requestedCookies.contains(where: { $0.contains("stale-chrome-token") })) + #expect(requestedCookies.contains(where: { $0.contains("valid-safari-token") })) + #expect(result.usage.mimoUsage?.balanceDetail == "$25.51") + #expect(CookieHeaderCache.load(provider: .mimo)?.sourceLabel == "Safari") + } } #if os(macOS) @@ -603,8 +1088,441 @@ struct MiMoProviderTests { #expect(sessions.first?.sourceLabel == "Chrome Default") #expect(sessions.first?.cookieHeader == "api-platform_serviceToken=token; userId=123") } + + @Test + func `mimo importer recovers firefox session restore cookies`() throws { + let (temp, profile, backups) = try self.makeFirefoxSessionRestoreProfile(prefix: "mimo-firefox-session") + defer { try? FileManager.default.removeItem(at: temp) } + + let json = """ + { + "cookies": [ + { + "host": ".platform.xiaomimimo.com", + "path": "/", + "name": "api-platform_serviceToken", + "value": "svc-token", + "secure": false, + "httponly": false + }, + { + "host": ".xiaomimimo.com", + "path": "/", + "name": "userId", + "value": "1863175063", + "secure": false, + "httponly": false + }, + { + "host": ".platform.xiaomimimo.com", + "path": "/", + "name": "api-platform_ph", + "value": "ph-token", + "secure": false, + "httponly": false + } + ] + } + """ + try self.mozillaLZ4LiteralFile(json).write(to: backups.appendingPathComponent("recovery.jsonlz4")) + + let records = MiMoFirefoxSessionCookieImporter.records(profileDirectory: profile) + let store = self.makeFirefoxCookieStore(profileDirectory: profile) + let sessions = MiMoCookieImporter.sessionInfos(from: [ + BrowserCookieStoreRecords(store: store, records: records), + ]) + + #expect(sessions.map(\.cookieHeader) == [ + "api-platform_ph=ph-token; api-platform_serviceToken=svc-token; userId=1863175063", + ]) + } + + @Test + func `current partial firefox state does not resurrect backup credentials`() throws { + let (temp, profile, backups) = try self.makeFirefoxSessionRestoreProfile(prefix: "mimo-firefox-backup") + defer { try? FileManager.default.removeItem(at: temp) } + + let partial = """ + {"cookies":[ + {"host":".platform.xiaomimimo.com","path":"/","name":"api-platform_ph","value":"ph-token"} + ]} + """ + let complete = """ + {"cookies":[ + {"host":".platform.xiaomimimo.com","path":"/","name":"api-platform_serviceToken","value":"svc-token"}, + {"host":".xiaomimimo.com","path":"/","name":"userId","value":"1863175063"} + ]} + """ + try self.mozillaLZ4LiteralFile(partial).write(to: backups.appendingPathComponent("recovery.jsonlz4")) + try self.mozillaLZ4LiteralFile(complete).write(to: backups.appendingPathComponent("recovery.baklz4")) + + let records = MiMoFirefoxSessionCookieImporter.records(profileDirectory: profile) + let store = self.makeFirefoxCookieStore(profileDirectory: profile) + let sessions = MiMoCookieImporter.sessionInfos(from: [ + BrowserCookieStoreRecords(store: store, records: records), + ]) + + #expect(records.map(\.name) == ["api-platform_ph"]) + #expect(sessions.isEmpty) + } + + @Test + func `malformed current firefox state falls back to recovery backup`() throws { + let (temp, profile, backups) = try self.makeFirefoxSessionRestoreProfile(prefix: "mimo-firefox-corrupt") + defer { try? FileManager.default.removeItem(at: temp) } + + try Data("not-jsonlz4".utf8).write(to: backups.appendingPathComponent("recovery.jsonlz4")) + let complete = """ + {"cookies":[ + {"host":".platform.xiaomimimo.com","path":"/","name":"api-platform_serviceToken","value":"svc-token"}, + {"host":".xiaomimimo.com","path":"/","name":"userId","value":"1863175063"} + ]} + """ + try self.mozillaLZ4LiteralFile(complete).write(to: backups.appendingPathComponent("recovery.baklz4")) + + let records = MiMoFirefoxSessionCookieImporter.records(profileDirectory: profile) + + #expect(Set(records.map(\.value)) == Set(["svc-token", "1863175063"])) + } + + @Test + func `partial firefox state does not merge persisted and stale backup credentials`() throws { + let (temp, profile, backups) = try self.makeFirefoxSessionRestoreProfile(prefix: "mimo-firefox-persisted") + defer { try? FileManager.default.removeItem(at: temp) } + + let recovery = """ + {"cookies":[ + {"host":".platform.xiaomimimo.com","path":"/","name":"api-platform_ph","value":"ph-token"} + ]} + """ + let backup = """ + {"cookies":[ + {"host":".platform.xiaomimimo.com","path":"/","name":"api-platform_serviceToken","value":"old-token"}, + {"host":".xiaomimimo.com","path":"/","name":"userId","value":"old-user"} + ]} + """ + try self.mozillaLZ4LiteralFile(recovery).write(to: backups.appendingPathComponent("recovery.jsonlz4")) + try self.mozillaLZ4LiteralFile(backup).write(to: backups.appendingPathComponent("recovery.baklz4")) + + let store = self.makeFirefoxCookieStore(profileDirectory: profile) + let persisted = BrowserCookieStoreRecords(store: store, records: [ + BrowserCookieRecord( + domain: "platform.xiaomimimo.com", + name: "api-platform_serviceToken", + path: "/", + value: "current-token", + expires: Date(timeIntervalSince1970: 1_912_064_978), + isSecure: false, + isHTTPOnly: false), + BrowserCookieRecord( + domain: "xiaomimimo.com", + name: "userId", + path: "/", + value: "current-user", + expires: Date(timeIntervalSince1970: 1_912_064_978), + isSecure: false, + isHTTPOnly: false), + ]) + let resolved = MiMoCookieImporter.recordsIncludingFirefoxSessionCookies( + from: [persisted], + browser: .firefox, + stores: [store]) + + #expect(Set(resolved.first?.records.map(\.value) ?? []) == Set(["current-token", "current-user"])) + #expect(MiMoCookieImporter.sessionInfos(from: resolved).map(\.cookieHeader) == [ + "api-platform_serviceToken=current-token; userId=current-user", + ]) + } + + @Test + func `resource limited firefox state preserves persisted credentials`() throws { + let (temp, profile, backups) = try self.makeFirefoxSessionRestoreProfile(prefix: "mimo-firefox-persisted-limit") + defer { try? FileManager.default.removeItem(at: temp) } + + var oversized = Data([0x6D, 0x6F, 0x7A, 0x4C, 0x7A, 0x34, 0x30, 0x00]) + var declaredSize = UInt32(129 * 1024 * 1024).littleEndian + withUnsafeBytes(of: &declaredSize) { oversized.append(contentsOf: $0) } + try oversized.write(to: backups.appendingPathComponent("recovery.jsonlz4")) + let staleBackup = """ + {"cookies":[ + {"host":".platform.xiaomimimo.com","name":"api-platform_serviceToken","value":"old-token"}, + {"host":".xiaomimimo.com","name":"userId","value":"old-user"} + ]} + """ + try self.mozillaLZ4LiteralFile(staleBackup) + .write(to: backups.appendingPathComponent("recovery.baklz4")) + + let store = self.makeFirefoxCookieStore(profileDirectory: profile) + let persisted = BrowserCookieStoreRecords(store: store, records: [ + BrowserCookieRecord( + domain: "platform.xiaomimimo.com", + name: "api-platform_serviceToken", + path: "/", + value: "current-token", + expires: nil, + isSecure: true, + isHTTPOnly: true), + BrowserCookieRecord( + domain: "xiaomimimo.com", + name: "userId", + path: "/", + value: "current-user", + expires: nil, + isSecure: true, + isHTTPOnly: false), + ]) + + let resolved = MiMoCookieImporter.recordsIncludingFirefoxSessionCookies( + from: [persisted], + browser: .firefox, + stores: [store]) + + #expect(Set(resolved.first?.records.map(\.value) ?? []) == Set(["current-token", "current-user"])) + } + + @Test + func `mimo importer recovers session cookies when firefox query returns no rows`() throws { + let (temp, profile, backups) = try self.makeFirefoxSessionRestoreProfile(prefix: "mimo-firefox-empty-store") + defer { try? FileManager.default.removeItem(at: temp) } + + let json = """ + { + "cookies": [ + { + "host": ".platform.xiaomimimo.com", + "path": "/", + "name": "api-platform_serviceToken", + "value": "svc-token" + }, + {"host": ".xiaomimimo.com", "path": "/", "name": "userId", "value": "1863175063"} + ] + } + """ + try self.mozillaLZ4LiteralFile(json).write(to: backups.appendingPathComponent("recovery.jsonlz4")) + + let store = self.makeFirefoxCookieStore( + profileDirectory: profile, + profileID: "opaque-firefox-profile") + let resolved = MiMoCookieImporter.recordsIncludingFirefoxSessionCookies( + from: [], + browser: .firefox, + stores: [store]) + + #expect(resolved.count == 1) + #expect(resolved.first?.store.profile.id == "opaque-firefox-profile") + #expect(MiMoCookieImporter.sessionInfos(from: resolved).map(\.cookieHeader) == [ + "api-platform_serviceToken=svc-token; userId=1863175063", + ]) + } + + @Test + func `mimo import path checks firefox stores after an empty domain query`() throws { + let (temp, profile, backups) = try self.makeFirefoxSessionRestoreProfile(prefix: "mimo-firefox-import") + defer { try? FileManager.default.removeItem(at: temp) } + let store = self.makeFirefoxCookieStore(profileDirectory: profile) + let profilesRoot = profile.deletingLastPathComponent() + let json = """ + { + "cookies": [ + { + "host": ".platform.xiaomimimo.com", + "path": "/", + "name": "api-platform_serviceToken", + "value": "svc-token" + }, + {"host": ".xiaomimimo.com", "path": "/", "name": "userId", "value": "1863175063"} + ] + } + """ + try self.mozillaLZ4LiteralFile(json).write(to: backups.appendingPathComponent("recovery.jsonlz4")) + + let firefoxAppPath = "/Applications/\(Browser.firefox.appBundleName).app" + let detection = BrowserDetection( + homeDirectory: temp.path, + cacheTTL: 0, + now: Date.init, + fileExists: { path in + path == firefoxAppPath || path == profilesRoot.path || path == store.databaseURL?.path + }, + directoryContents: { path in + path == profilesRoot.path ? [profile.lastPathComponent] : nil + }, + applicationURLs: { _ in [] }, + profileAccessIssue: { _ in nil }) + var queriedFirefoxStores = false + let sessions = try MiMoCookieImporter.importSessions( + browserDetection: detection, + loadRecords: { _, _, _ in [] }, + loadStores: { browser in + guard browser == .firefox else { return [] } + queriedFirefoxStores = true + return [store] + }) + + #expect(queriedFirefoxStores) + #expect(sessions.map(\.cookieHeader) == [ + "api-platform_serviceToken=svc-token; userId=1863175063", + ]) + } + + @Test + func `complete firefox session state replaces persisted cookies`() throws { + let (temp, profile, backups) = try self.makeFirefoxSessionRestoreProfile(prefix: "mimo-firefox-merge") + defer { try? FileManager.default.removeItem(at: temp) } + + let json = """ + { + "cookies": [ + { + "host": ".platform.xiaomimimo.com", + "path": "/", + "name": "api-platform_serviceToken", + "value": "svc-token" + }, + {"host": ".xiaomimimo.com", "path": "/", "name": "userId", "value": "1863175063"} + ] + } + """ + try self.mozillaLZ4LiteralFile(json).write(to: backups.appendingPathComponent("recovery.jsonlz4")) + + let store = self.makeFirefoxCookieStore(profileDirectory: profile) + let persisted = BrowserCookieStoreRecords(store: store, records: [ + BrowserCookieRecord( + domain: "platform.xiaomimimo.com", + name: "cookie-preferences", + path: "/", + value: "xxx", + expires: Date(timeIntervalSince1970: 1_812_064_978), + isSecure: false, + isHTTPOnly: false), + BrowserCookieRecord( + domain: "platform.xiaomimimo.com", + name: "api-platform_serviceToken", + path: "/", + value: "stale-token", + expires: Date(timeIntervalSince1970: 1_912_064_978), + isSecure: false, + isHTTPOnly: false), + BrowserCookieRecord( + domain: "xiaomimimo.com", + name: "userId", + path: "/", + value: "stale-user", + expires: Date(timeIntervalSince1970: 1_912_064_978), + isSecure: false, + isHTTPOnly: false), + ]) + + let resolved = MiMoCookieImporter.recordsIncludingFirefoxSessionCookies( + from: [persisted], + browser: .firefox, + stores: [store]) + let sessions = MiMoCookieImporter.sessionInfos(from: resolved) + + #expect(Set(resolved.first?.records.map(\.value) ?? []) == Set(["svc-token", "1863175063"])) + #expect(sessions.map(\.cookieHeader) == ["api-platform_serviceToken=svc-token; userId=1863175063"]) + } + + @Test + func `firefox session restore input is size bounded`() throws { + let file = FileManager.default.temporaryDirectory + .appendingPathComponent("mimo-firefox-oversized-\(UUID().uuidString).jsonlz4") + defer { try? FileManager.default.removeItem(at: file) } + try Data(repeating: 0x41, count: 5).write(to: file) + + do { + _ = try MiMoFirefoxSessionCookieImporter.readData(from: file, maxBytes: 4) + Issue.record("Expected oversized Firefox session restore input to fail") + } catch let error as MiMoFirefoxSessionCookieImporter.ImportError { + guard case .resourceLimit(.inputBytes) = error else { + Issue.record("Unexpected Firefox session restore error: \(error)") + return + } + } + } + + @Test + func `firefox session restore decompression is size bounded`() throws { + var data = Data([0x6D, 0x6F, 0x7A, 0x4C, 0x7A, 0x34, 0x30, 0x00]) + data.append(contentsOf: [0x1F, 0x41, 0x01, 0x00, 0x14]) + + do { + _ = try MiMoFirefoxSessionCookieImporter.decodeSessionRestoreData(data, maxOutputBytes: 32) + Issue.record("Expected oversized Firefox session restore output to fail") + } catch let error as MiMoFirefoxSessionCookieImporter.ImportError { + guard case .resourceLimit(.outputBytes) = error else { + Issue.record("Unexpected Firefox session restore error: \(error)") + return + } + } + } + + @Test + func `firefox session restore accepts decoded size prefix`() throws { + let json = #"{"cookies":[]}"# + var data = Data([0x6D, 0x6F, 0x7A, 0x4C, 0x7A, 0x34, 0x30, 0x00]) + var decodedSize = UInt32(json.utf8.count).littleEndian + withUnsafeBytes(of: &decodedSize) { data.append(contentsOf: $0) } + data.append(self.lz4LiteralBlock(Data(json.utf8))) + + let decoded = try MiMoFirefoxSessionCookieImporter.decodeSessionRestoreData(data) + + #expect(decoded == Data(json.utf8)) + } + + private func makeFirefoxSessionRestoreProfile(prefix: String) throws -> ( + temp: URL, + profile: URL, + backups: URL) + { + let temp = FileManager.default.temporaryDirectory + .appendingPathComponent("\(prefix)-\(UUID().uuidString)", isDirectory: true) + let profile = temp + .appendingPathComponent("Library/Application Support/Firefox/Profiles/n757crxy.default-release-1") + let backups = profile.appendingPathComponent("sessionstore-backups", isDirectory: true) + try FileManager.default.createDirectory(at: backups, withIntermediateDirectories: true) + return (temp: temp, profile: profile, backups: backups) + } + + private func makeFirefoxCookieStore( + profileDirectory: URL, + profileID: String? = nil) -> BrowserCookieStore + { + BrowserCookieStore( + browser: .firefox, + profile: BrowserProfile(id: profileID ?? profileDirectory.path, name: profileDirectory.lastPathComponent), + kind: .primary, + label: "Firefox \(profileDirectory.lastPathComponent)", + databaseURL: profileDirectory.appendingPathComponent("cookies.sqlite")) + } #endif + private func mozillaLZ4LiteralFile(_ json: String) -> Data { + var data = Data([0x6D, 0x6F, 0x7A, 0x4C, 0x7A, 0x34, 0x30, 0x00]) + var decodedSize = UInt32(json.utf8.count).littleEndian + withUnsafeBytes(of: &decodedSize) { data.append(contentsOf: $0) } + data.append(self.lz4LiteralBlock(Data(json.utf8))) + return data + } + + private func lz4LiteralBlock(_ payload: Data) -> Data { + var output = Data() + let literalCount = payload.count + if literalCount < 15 { + output.append(UInt8(literalCount << 4)) + } else { + output.append(0xF0) + var remaining = literalCount - 15 + while remaining >= 255 { + output.append(255) + remaining -= 255 + } + output.append(UInt8(remaining)) + } + output.append(payload) + return output + } + private static func makeResponse( url: URL, body: String, @@ -618,6 +1536,32 @@ struct MiMoProviderTests { return (response, Data(body.utf8)) } + private static func makeMenuCardModel( + snapshot: UsageSnapshot, + metadata: ProviderMetadata, + now: Date) -> UsageMenuCardView.Model + { + UsageMenuCardView.Model.make(.init( + provider: .mimo, + metadata: metadata, + snapshot: snapshot, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + } + private func makeBalanceSnapshot(provider: UsageProvider) -> UsageSnapshot { let updatedAt = Date(timeIntervalSince1970: 1_742_771_200) switch provider { @@ -648,13 +1592,14 @@ struct MiMoProviderTests { } private func makeContext( + sourceMode: ProviderSourceMode = .auto, settings: ProviderSettingsSnapshot? = nil, environment: [String: String] = [:]) -> ProviderFetchContext { let browserDetection = BrowserDetection(cacheTTL: 0) return ProviderFetchContext( runtime: .app, - sourceMode: .auto, + sourceMode: sourceMode, includeCredits: false, webTimeout: 1, webDebugDumpHTML: false, @@ -686,7 +1631,11 @@ struct MiMoProviderTests { } final class MiMoStubURLProtocol: URLProtocol { - nonisolated(unsafe) static var handler: ((URLRequest) throws -> (HTTPURLResponse, Data))? + private static let _handlerBox = LockIsolated<((URLRequest) throws -> (HTTPURLResponse, Data))?>(nil) + static var handler: ((URLRequest) throws -> (HTTPURLResponse, Data))? { + get { Self._handlerBox.value } + set { Self._handlerBox.setValue(newValue) } + } override static func canInit(with request: URLRequest) -> Bool { request.url?.host == "mimo.test" diff --git a/Tests/CodexBarTests/MiMoUsageScriptTests.swift b/Tests/CodexBarTests/MiMoUsageScriptTests.swift new file mode 100644 index 0000000000..27be4f4257 --- /dev/null +++ b/Tests/CodexBarTests/MiMoUsageScriptTests.swift @@ -0,0 +1,195 @@ +import Foundation +import Testing + +struct MiMoUsageScriptTests { + @Test + func `script keeps final cumulative streaming usage`() throws { + let rows = [ + self.assistantRow(outputTokens: 10), + self.assistantRow(outputTokens: 40), + self.assistantRow(outputTokens: 90), + ] + let allTime = try self.runScript(files: ["session.jsonl": rows]) + + self.assertUsage( + allTime, + expected: .init(input: 120, cacheCreate: 10, cacheRead: 5, output: 90, messages: 1)) + } + + @Test + func `script keeps final cumulative streaming usage without session id`() throws { + let rows = [ + self.assistantRow(outputTokens: 10, sessionID: nil), + self.assistantRow(outputTokens: 40, sessionID: nil), + self.assistantRow(outputTokens: 90, sessionID: nil), + ] + let allTime = try self.runScript(files: ["session.jsonl": rows]) + + self.assertUsage( + allTime, + expected: .init(input: 120, cacheCreate: 10, cacheRead: 5, output: 90, messages: 1)) + } + + @Test + func `script keeps final cumulative usage without request id`() throws { + let rows = [ + self.assistantRow(outputTokens: 10, requestID: nil), + self.assistantRow(outputTokens: 40, requestID: nil), + self.assistantRow(outputTokens: 90, requestID: nil), + ] + let allTime = try self.runScript(files: ["session.jsonl": rows]) + + self.assertUsage( + allTime, + expected: .init(input: 120, cacheCreate: 10, cacheRead: 5, output: 90, messages: 1)) + } + + @Test + func `script counts rows without session identity conservatively`() throws { + let rows = [ + self.assistantRow(outputTokens: 10, sessionID: nil, requestID: nil), + self.assistantRow(outputTokens: 40, sessionID: nil, requestID: nil), + self.assistantRow(outputTokens: 90, sessionID: nil, requestID: nil), + ] + let allTime = try self.runScript(files: ["session.jsonl": rows]) + + self.assertUsage( + allTime, + expected: .init(input: 360, cacheCreate: 30, cacheRead: 15, output: 140, messages: 3)) + } + + @Test + func `script keeps distinct requests sharing a message id`() throws { + let rows = [ + self.assistantRow(outputTokens: 40, requestID: "req_one"), + self.assistantRow(outputTokens: 90, requestID: "req_two"), + ] + let allTime = try self.runScript(files: ["session.jsonl": rows]) + + self.assertUsage( + allTime, + expected: .init(input: 240, cacheCreate: 20, cacheRead: 10, output: 130, messages: 2)) + } + + @Test + func `script deduplicates copied rows from the same session`() throws { + let rows = [self.assistantRow(outputTokens: 90)] + let allTime = try self.runScript(files: [ + "session.jsonl": rows, + "session-copy.jsonl": rows, + ]) + + self.assertUsage( + allTime, + expected: .init(input: 120, cacheCreate: 10, cacheRead: 5, output: 90, messages: 1)) + } + + @Test + func `script deduplicates copied requests from different sessions`() throws { + let allTime = try self.runScript(files: [ + "session-a.jsonl": [self.assistantRow(outputTokens: 90, sessionID: "session_a")], + "session-b.jsonl": [self.assistantRow(outputTokens: 90, sessionID: "session_b")], + ]) + + self.assertUsage( + allTime, + expected: .init(input: 120, cacheCreate: 10, cacheRead: 5, output: 90, messages: 1)) + } + + private func runScript(files: [String: [[String: Any]]]) throws -> [String: Any] { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("mimo-usage-script-\(UUID().uuidString)") + defer { try? FileManager.default.removeItem(at: root) } + + let mimoHome = root.appendingPathComponent("mimo") + let projects = mimoHome + .appendingPathComponent(".claude") + .appendingPathComponent("projects") + .appendingPathComponent("project-a") + try FileManager.default.createDirectory(at: projects, withIntermediateDirectories: true) + + for (name, rows) in files { + let session = projects.appendingPathComponent(name) + let jsonl = try rows + .map { try JSONSerialization.data(withJSONObject: $0) } + .map { try #require(String(bytes: $0, encoding: .utf8)) } + .joined(separator: "\n") + try jsonl.write(to: session, atomically: true, encoding: .utf8) + } + + let cache = root.appendingPathComponent("usage.json") + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/env") + process.arguments = ["python3", self.scriptURL.path, "--update"] + process.environment = ProcessInfo.processInfo.environment.merging([ + "MIMO_CLAUDE_HOME": mimoHome.path, + "MIMO_LOCAL_USAGE_PATH": cache.path, + ]) { _, new in new } + let stderr = Pipe() + process.standardError = stderr + + try process.run() + process.waitUntilExit() + + let errorText = try #require(String( + bytes: stderr.fileHandleForReading.readDataToEndOfFile(), + encoding: .utf8)) + #expect(process.terminationStatus == 0, Comment(rawValue: errorText)) + + let payload = try #require( + JSONSerialization.jsonObject(with: Data(contentsOf: cache)) as? [String: Any]) + let windows = try #require(payload["windows"] as? [String: Any]) + return try #require(windows["all_time"] as? [String: Any]) + } + + private func assertUsage(_ allTime: [String: Any], expected: UsageExpectation) { + #expect(allTime["input"] as? Int == expected.input) + #expect(allTime["cache_create"] as? Int == expected.cacheCreate) + #expect(allTime["cache_read"] as? Int == expected.cacheRead) + #expect(allTime["output"] as? Int == expected.output) + #expect(allTime["messages"] as? Int == expected.messages) + } + + private var scriptURL: URL { + URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + .appendingPathComponent("Scripts/mimo-usage.py") + } + + private func assistantRow( + outputTokens: Int, + sessionID: String? = "session_stream", + requestID: String? = "req_stream") -> [String: Any] + { + var row: [String: Any] = [ + "type": "assistant", + "timestamp": ISO8601DateFormatter().string(from: Date()), + "message": [ + "id": "msg_stream", + "usage": [ + "input_tokens": 120, + "cache_creation_input_tokens": 10, + "cache_read_input_tokens": 5, + "output_tokens": outputTokens, + ], + ], + ] + if let sessionID { + row["sessionId"] = sessionID + } + if let requestID { + row["requestId"] = requestID + } + return row + } + + private struct UsageExpectation { + let input: Int + let cacheCreate: Int + let cacheRead: Int + let output: Int + let messages: Int + } +} diff --git a/Tests/CodexBarTests/MiniMaxAPITokenFetchTests.swift b/Tests/CodexBarTests/MiniMaxAPITokenFetchTests.swift index f1a69d37b0..32d2922d91 100644 --- a/Tests/CodexBarTests/MiniMaxAPITokenFetchTests.swift +++ b/Tests/CodexBarTests/MiniMaxAPITokenFetchTests.swift @@ -49,9 +49,16 @@ struct MiniMaxAPITokenFetchTests { session: Self.makeSession()) #expect(snapshot.planName == "Max") - #expect(MiniMaxAPITokenStubURLProtocol.requests.count == 2) - #expect(MiniMaxAPITokenStubURLProtocol.requests.first?.url?.host == "api.minimax.io") - #expect(MiniMaxAPITokenStubURLProtocol.requests.last?.url?.host == "api.minimaxi.com") + #expect(MiniMaxAPITokenStubURLProtocol.requests.map { $0.url?.host } == [ + "api.minimax.io", + "api.minimax.io", + "api.minimaxi.com", + ]) + #expect(MiniMaxAPITokenStubURLProtocol.requests.map { $0.url?.path } == [ + "/v1/token_plan/remains", + "/v1/api/openplatform/coding_plan/remains", + "/v1/token_plan/remains", + ]) } @Test @@ -83,9 +90,49 @@ struct MiniMaxAPITokenFetchTests { session: Self.makeSession()) } - #expect(MiniMaxAPITokenStubURLProtocol.requests.count == 2) - #expect(MiniMaxAPITokenStubURLProtocol.requests.first?.url?.host == "api.minimax.io") - #expect(MiniMaxAPITokenStubURLProtocol.requests.last?.url?.host == "api.minimaxi.com") + #expect(MiniMaxAPITokenStubURLProtocol.requests.map { $0.url?.host } == [ + "api.minimax.io", + "api.minimax.io", + "api.minimaxi.com", + "api.minimaxi.com", + ]) + #expect(MiniMaxAPITokenStubURLProtocol.requests.map { $0.url?.path } == [ + "/v1/token_plan/remains", + "/v1/api/openplatform/coding_plan/remains", + "/v1/token_plan/remains", + "/v1/api/openplatform/coding_plan/remains", + ]) + } + + @Test + func `explicit china region preserves structured invalid credentials across legacy fallback`() async throws { + defer { + MiniMaxAPITokenStubURLProtocol.handler = nil + MiniMaxAPITokenStubURLProtocol.requests = [] + } + MiniMaxAPITokenStubURLProtocol.requests = [] + + MiniMaxAPITokenStubURLProtocol.handler = { request in + guard let url = request.url else { throw URLError(.badURL) } + if url.path == "/v1/token_plan/remains" { + return Self.makeResponse( + url: url, + body: #"{"base_resp":{"status_code":1004,"status_msg":"invalid api key"}}"#) + } + return Self.makeResponse(url: url, body: "{}", statusCode: 404) + } + + await #expect(throws: MiniMaxUsageError.invalidCredentials) { + _ = try await MiniMaxUsageFetcher.fetchUsage( + apiToken: "sk-cp-test", + region: .chinaMainland, + session: Self.makeSession()) + } + + #expect(MiniMaxAPITokenStubURLProtocol.requests.map { $0.url?.path } == [ + "/v1/token_plan/remains", + "/v1/api/openplatform/coding_plan/remains", + ]) } @Test @@ -154,7 +201,12 @@ struct MiniMaxAPITokenFetchTests { } final class MiniMaxAPITokenStubURLProtocol: URLProtocol { - nonisolated(unsafe) static var handler: ((URLRequest) throws -> (HTTPURLResponse, Data))? + private static let _handlerBox = LockIsolated<((URLRequest) throws -> (HTTPURLResponse, Data))?>(nil) + static var handler: ((URLRequest) throws -> (HTTPURLResponse, Data))? { + get { Self._handlerBox.value } + set { Self._handlerBox.setValue(newValue) } + } + nonisolated(unsafe) static var requests: [URLRequest] = [] override static func canInit(with request: URLRequest) -> Bool { diff --git a/Tests/CodexBarTests/MiniMaxCurrentTokenPlanResponseTests.swift b/Tests/CodexBarTests/MiniMaxCurrentTokenPlanResponseTests.swift new file mode 100644 index 0000000000..8a1d1c9999 --- /dev/null +++ b/Tests/CodexBarTests/MiniMaxCurrentTokenPlanResponseTests.swift @@ -0,0 +1,218 @@ +import Foundation +import Testing +@testable import CodexBar +@testable import CodexBarCore + +struct MiniMaxCurrentTokenPlanResponseTests { + @Test + func `coarse html plan name does not replace remains api plan name`() { + let remainsSnapshot = MiniMaxUsageSnapshot( + planName: "Token Plan Pro", + availablePrompts: nil, + currentPrompts: nil, + remainingPrompts: nil, + windowMinutes: nil, + usedPercent: nil, + resetsAt: nil, + updatedAt: Date()) + + let enriched = remainsSnapshot.withPlanNameIfMissing("Plus") + + #expect(enriched.planName == "Token Plan Pro") + } + + @Test + func `parses token plan boosted weekly lane with permille spelling`() throws { + let now = Date(timeIntervalSince1970: 1_782_050_596) + + let snapshot = try MiniMaxUsageParser.parseCodingPlanRemains( + data: Data(Self.currentTokenPlanRemainsJSON.utf8), + now: now) + let services = try #require(snapshot.services) + + #expect(services.map(\.windowType) == ["5 hours", "Weekly"]) + #expect(services[0].usage == 0) + #expect(services[0].limit == 100) + #expect(services[0].percent == 0) + #expect(services[1].usage == 45) + #expect(services[1].limit == 150) + #expect(services[1].percent == 30) + #expect(snapshot.toUsageSnapshot().primary?.usedPercent == 0) + #expect(snapshot.toUsageSnapshot().secondary?.usedPercent == 30) + } + + @Test + func `web usage fetch enriches parsed html without service quota data from remains api`() async throws { + let now = Date(timeIntervalSince1970: 1_780_282_340) + let transport = ProviderHTTPTransportStub { request in + let url = try #require(request.url) + if url.path.contains("coding-plan") { + return Self.httpResponse( + url: url, + body: "
    Coding Plan Plus available usage 1000 prompts 5 hours
    ", + contentType: "text/html") + } + #expect(url.host == "platform.minimaxi.com") + #expect(url.path == "/v1/api/openplatform/coding_plan/remains") + return Self.httpResponse(url: url, body: Self.percentBasedRemainsJSON, contentType: "application/json") + } + + let snapshot = try await MiniMaxUsageFetcher.fetchUsage( + cookieHeader: "HERTZ-SESSION=abc", + region: .chinaMainland, + environment: [:], + includeBillingHistory: false, + session: transport, + now: now) + let requests = await transport.requests() + + #expect(snapshot.services?.count == 2) + #expect(snapshot.planName == "Plus") + #expect(snapshot.toUsageSnapshot().primary?.usedPercent == 4) + #expect(requests.map { $0.url?.host } == [ + "platform.minimaxi.com", + "platform.minimaxi.com", + ]) + } + + @Test + func `web usage fetch preserves auth failure from parseable html remains fallback`() async throws { + let transport = ProviderHTTPTransportStub { request in + let url = try #require(request.url) + if url.path.contains("coding-plan") { + return Self.httpResponse( + url: url, + body: "
    Coding Plan Plus available usage 1000 prompts / 5 hours
    ", + contentType: "text/html") + } + return Self.httpResponse( + url: url, + body: #"{"base_resp":{"status_code":1004,"status_msg":"cookie is missing, log in again"}}"#, + contentType: "application/json") + } + + await #expect(throws: MiniMaxUsageError.invalidCredentials) { + try await MiniMaxUsageFetcher.fetchUsage( + cookieHeader: "HERTZ-SESSION=expired", + region: .chinaMainland, + environment: [:], + includeBillingHistory: false, + session: transport) + } + let requests = await transport.requests() + #expect(requests.map { $0.url?.path } == [ + "/user-center/payment/coding-plan", + "/v1/api/openplatform/coding_plan/remains", + ]) + } + + @Test + func `web usage fetch preserves cancellation from parseable html remains fallback`() async throws { + let transport = ProviderHTTPTransportStub { request in + let url = try #require(request.url) + if url.path.contains("coding-plan") { + return Self.httpResponse( + url: url, + body: "
    Coding Plan Plus available usage 1000 prompts / 5 hours
    ", + contentType: "text/html") + } + throw CancellationError() + } + + await #expect(throws: CancellationError.self) { + try await MiniMaxUsageFetcher.fetchUsage( + cookieHeader: "HERTZ-SESSION=abc", + region: .chinaMainland, + environment: [:], + includeBillingHistory: false, + session: transport) + } + let requests = await transport.requests() + #expect(requests.map { $0.url?.path } == [ + "/user-center/payment/coding-plan", + "/v1/api/openplatform/coding_plan/remains", + ]) + } + + private static let currentTokenPlanRemainsJSON = """ + { + "model_remains": [ + { + "start_time": 1782043200000, + "end_time": 1782057600000, + "remains_time": 7003536, + "current_interval_total_count": 0, + "current_interval_usage_count": 0, + "model_name": "general", + "current_weekly_total_count": 0, + "current_weekly_usage_count": 0, + "weekly_start_time": 1781452800000, + "weekly_end_time": 1782057600000, + "weekly_remains_time": 7003536, + "current_interval_status": 1, + "current_interval_remaining_percent": 100, + "current_weekly_status": 1, + "current_weekly_remaining_percent": 70, + "weekly_boost_permille": 1500 + }, + { + "start_time": 1781971200000, + "end_time": 1782057600000, + "remains_time": 7003536, + "current_interval_total_count": 0, + "current_interval_usage_count": 0, + "model_name": "video", + "current_weekly_total_count": 0, + "current_weekly_usage_count": 0, + "weekly_start_time": 1781452800000, + "weekly_end_time": 1782057600000, + "weekly_remains_time": 7003536, + "current_interval_status": 3, + "current_interval_remaining_percent": 100, + "current_weekly_status": 3, + "current_weekly_remaining_percent": 100 + } + ], + "base_resp": { "status_code": 0, "status_msg": "success" } + } + """ + + private static let percentBasedRemainsJSON = """ + { + "model_remains": [ + { + "start_time": 1780279200000, + "end_time": 1780297200000, + "remains_time": 16659830, + "current_interval_total_count": 0, + "current_interval_usage_count": 0, + "model_name": "general", + "current_weekly_total_count": 0, + "current_weekly_usage_count": 0, + "weekly_start_time": 1780243200000, + "weekly_end_time": 1780848000000, + "weekly_remains_time": 567459830, + "current_interval_status": 1, + "current_interval_remaining_percent": 96, + "current_weekly_status": 1, + "current_weekly_remaining_percent": 99 + } + ], + "base_resp": { "status_code": 0, "status_msg": "success" } + } + """ + + private static func httpResponse( + url: URL, + body: String, + statusCode: Int = 200, + contentType: String) -> (Data, URLResponse) + { + let response = HTTPURLResponse( + url: url, + statusCode: statusCode, + httpVersion: "HTTP/1.1", + headerFields: ["Content-Type": contentType])! + return (Data(body.utf8), response) + } +} diff --git a/Tests/CodexBarTests/MiniMaxMenuCardModelPlanTests.swift b/Tests/CodexBarTests/MiniMaxMenuCardModelPlanTests.swift index 9689625905..11a80863d4 100644 --- a/Tests/CodexBarTests/MiniMaxMenuCardModelPlanTests.swift +++ b/Tests/CodexBarTests/MiniMaxMenuCardModelPlanTests.swift @@ -98,4 +98,191 @@ struct MiniMaxMenuCardModelPlanTests { #expect(model.planText == nil) } + + @Test + func `minimax quota rows include configured warning markers`() throws { + let now = Date() + let minimax = MiniMaxUsageSnapshot( + planName: "TokenPlanPlus-年度会员", + availablePrompts: nil, + currentPrompts: nil, + remainingPrompts: nil, + windowMinutes: nil, + usedPercent: nil, + resetsAt: nil, + updatedAt: now, + services: [ + MiniMaxServiceUsage( + serviceType: "general", + windowType: "5 hours", + timeRange: "15:00-20:00(UTC+8)", + usage: 31, + limit: 100, + percent: 31, + resetsAt: now.addingTimeInterval(3600), + resetDescription: "Resets in 1 hour"), + MiniMaxServiceUsage( + serviceType: "general", + windowType: "Weekly", + timeRange: "06/01 00:00 - 06/08 00:00(UTC+8)", + usage: 4, + limit: 100, + percent: 4, + resetsAt: now.addingTimeInterval(6 * 24 * 3600), + resetDescription: "Resets in 6 days"), + ]) + let metadata = try #require(ProviderDefaults.metadata[.minimax]) + let model = UsageMenuCardView.Model.make(.init( + provider: .minimax, + metadata: metadata, + snapshot: minimax.toUsageSnapshot(), + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: true, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + quotaWarningThresholds: [.session: [50, 20], .weekly: [50, 20]], + workDaysPerWeek: 5, + now: now)) + + #expect(model.metrics.map(\.warningMarkerPercents) == [[50, 80], [50, 80]]) + #expect(model.metrics.map(\.workdayMarkerPercents) == [[], [20, 40, 60, 80]]) + } + + @Test + func `minimax quota rows use canonical general first order`() throws { + let now = Date() + let minimax = MiniMaxUsageSnapshot( + planName: "TokenPlanMax-年度会员", + availablePrompts: nil, + currentPrompts: nil, + remainingPrompts: nil, + windowMinutes: nil, + usedPercent: nil, + resetsAt: nil, + updatedAt: now, + services: [ + MiniMaxServiceUsage( + serviceType: "video", + windowType: "Today", + timeRange: "06/01 00:00 - 06/02 00:00(UTC+8)", + usage: 70, + limit: 100, + percent: 70, + resetsAt: now.addingTimeInterval(3600), + resetDescription: "Resets in 1 hour"), + MiniMaxServiceUsage( + serviceType: "general", + windowType: "5 hours", + timeRange: "15:00-20:00(UTC+8)", + usage: 4, + limit: 100, + percent: 4, + resetsAt: now.addingTimeInterval(3600), + resetDescription: "Resets in 1 hour"), + MiniMaxServiceUsage( + serviceType: "general", + windowType: "Weekly", + timeRange: "06/01 00:00 - 06/08 00:00(UTC+8)", + usage: 1, + limit: 100, + percent: 1, + resetsAt: now.addingTimeInterval(6 * 24 * 3600), + resetDescription: "Resets in 6 days"), + ]) + let metadata = try #require(ProviderDefaults.metadata[.minimax]) + let model = UsageMenuCardView.Model.make(.init( + provider: .minimax, + metadata: metadata, + snapshot: minimax.toUsageSnapshot(), + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: true, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + + #expect(model.metrics.map(\.title) == ["General · 5h", "General · Weekly", "Video"]) + #expect(model.metrics.map(\.percent) == [4, 1, 70]) + } + + @Test + func `minimax unlimited quota rows omit usage copy and warning markers`() throws { + let now = Date() + let minimax = MiniMaxUsageSnapshot( + planName: "Plus", + availablePrompts: nil, + currentPrompts: nil, + remainingPrompts: nil, + windowMinutes: nil, + usedPercent: nil, + resetsAt: nil, + updatedAt: now, + services: [ + MiniMaxServiceUsage( + serviceType: "general", + windowType: "5 hours", + timeRange: "15:00-20:00(UTC+8)", + usage: 2, + limit: 200, + percent: 2, + resetsAt: now.addingTimeInterval(3600), + resetDescription: "Resets in 1 hour"), + MiniMaxServiceUsage( + serviceType: "general", + windowType: "Weekly", + timeRange: "06/01 00:00 - 06/08 00:00(UTC+8)", + usage: 0, + limit: 0, + percent: 0, + isUnlimited: true, + resetsAt: nil, + resetDescription: "Unlimited"), + ]) + let metadata = try #require(ProviderDefaults.metadata[.minimax]) + let model = UsageMenuCardView.Model.make(.init( + provider: .minimax, + metadata: metadata, + snapshot: minimax.toUsageSnapshot(), + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: true, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + quotaWarningThresholds: [.session: [50, 20], .weekly: [50, 20]], + now: now)) + + #expect(model.metrics.count == 2) + #expect(model.metrics[1].title == "General · Weekly") + #expect(model.metrics[1].statusText == "∞ Unlimited") + #expect(model.metrics[1].detailLeftText == nil) + #expect(model.metrics[1].warningMarkerPercents == []) + } } diff --git a/Tests/CodexBarTests/MiniMaxProviderTests.swift b/Tests/CodexBarTests/MiniMaxProviderTests.swift index 1313c24e9d..03dd11c874 100644 --- a/Tests/CodexBarTests/MiniMaxProviderTests.swift +++ b/Tests/CodexBarTests/MiniMaxProviderTests.swift @@ -25,6 +25,165 @@ struct MiniMaxAPISettingsReaderTests { } } +struct MiniMaxEndpointOverrideSettingsTests { + @Test + func `strict endpoint overrides reject non MiniMax hosts`() { + let env = [ + MiniMaxSettingsReader.requireProviderEndpointOverridesKey: "true", + MiniMaxSettingsReader.hostKey: "https://attacker.example", + MiniMaxSettingsReader.codingPlanURLKey: "https://attacker.example/coding-plan", + MiniMaxSettingsReader.remainsURLKey: "https://attacker.example/remains", + MiniMaxSettingsReader.billingHistoryURLKey: "https://attacker.example/account/amount", + ] + + #expect(MiniMaxSettingsReader.hostOverride(environment: env) == nil) + #expect(MiniMaxSettingsReader.codingPlanURL(environment: env) == nil) + #expect(MiniMaxSettingsReader.remainsURL(environment: env) == nil) + #expect(MiniMaxSettingsReader.billingHistoryURL(environment: env) == nil) + } + + @Test + func `endpoint overrides reject encoded host delimiters before suffix matching`() { + let encodedSlash = "https://attacker.example%2f.platform.minimax.io" + let doubleEncodedSlash = "https://attacker.example%252f.platform.minimax.io" + let env = [ + MiniMaxSettingsReader.hostKey: encodedSlash, + MiniMaxSettingsReader.codingPlanURLKey: "\(encodedSlash)/coding-plan", + MiniMaxSettingsReader.remainsURLKey: "\(encodedSlash)/remains", + MiniMaxSettingsReader.billingHistoryURLKey: "\(encodedSlash)/account/amount", + ] + + #expect(MiniMaxSettingsReader.hostOverride(environment: env) == nil) + #expect(MiniMaxSettingsReader.codingPlanURL(environment: env) == nil) + #expect(MiniMaxSettingsReader.remainsURL(environment: env) == nil) + #expect(MiniMaxSettingsReader.billingHistoryURL(environment: env) == nil) + #expect(MiniMaxSettingsReader.hostOverride(environment: [ + MiniMaxSettingsReader.hostKey: doubleEncodedSlash, + ]) == nil) + } + + @Test + func `endpoint overrides reject whitespace and control characters in hosts`() { + for host in ["https://bad host", "https://bad%20host", "https://bad%09host"] { + #expect(MiniMaxSettingsReader.hostOverride(environment: [ + MiniMaxSettingsReader.hostKey: host, + ]) == nil) + #expect(MiniMaxSettingsReader.remainsURL(environment: [ + MiniMaxSettingsReader.remainsURLKey: "\(host)/remains", + ]) == nil) + } + } + + @Test + func `endpoint overrides require https and no userinfo`() { + #expect(MiniMaxSettingsReader.hostOverride(environment: [ + MiniMaxSettingsReader.hostKey: "http://platform.minimax.io", + ]) == nil) + #expect(MiniMaxSettingsReader.remainsURL(environment: [ + MiniMaxSettingsReader.remainsURLKey: "https://user:pass@platform.minimax.io/remains", + ]) == nil) + #expect(MiniMaxSettingsReader.rejectedEndpointOverrideKey(environment: [ + MiniMaxSettingsReader.hostKey: ":443", + ]) == MiniMaxSettingsReader.hostKey) + } + + @Test + func `endpoint overrides allow MiniMax and custom https hosts`() { + let env = [ + MiniMaxSettingsReader.hostKey: "platform.minimaxi.com", + MiniMaxSettingsReader.remainsURLKey: "https://platform.minimax.io/custom/remains", + ] + + #expect(MiniMaxSettingsReader.hostOverride(environment: env) == "platform.minimaxi.com") + #expect(MiniMaxSettingsReader.remainsURL(environment: env)?.host == "platform.minimax.io") + + let customEnv = [ + MiniMaxSettingsReader.hostKey: "proxy.example.test", + MiniMaxSettingsReader.remainsURLKey: "https://proxy.example.test/custom/remains", + ] + #expect(MiniMaxSettingsReader.hostOverride(environment: customEnv) == "proxy.example.test") + #expect(MiniMaxSettingsReader.remainsURL(environment: customEnv)?.host == "proxy.example.test") + #expect(MiniMaxSettingsReader.rejectedEndpointOverrideKey(environment: customEnv) == nil) + } + + @Test + func `host endpoint overrides preserve explicit port`() { + let env = [MiniMaxSettingsReader.hostKey: "proxy.example.test:8443"] + + #expect(MiniMaxSettingsReader.hostOverride(environment: env) == "proxy.example.test:8443") + #expect( + MiniMaxUsageFetcher.resolveCodingPlanURL(region: .global, environment: env).absoluteString == + "https://proxy.example.test:8443/user-center/payment/coding-plan?cycle_type=3") + #expect( + MiniMaxUsageFetcher.resolveRemainsURL(region: .global, environment: env).absoluteString == + "https://proxy.example.test:8443/v1/api/openplatform/coding_plan/remains") + } + + @Test + func `subscription metadata accepts host names beginning with http`() throws { + let url = try MiniMaxSubscriptionMetadataFetcher.resolveComboURL( + region: .global, + environment: [MiniMaxSettingsReader.hostKey: "https://http-proxy.example.test"]) + + #expect(url.host == "http-proxy.example.test") + #expect(url.scheme == "https") + } + + @Test + func `scheme less endpoint preserves colon in path`() { + let env = [MiniMaxSettingsReader.remainsURLKey: "proxy.example.test/api:v1"] + + #expect( + MiniMaxSettingsReader.remainsURL(environment: env)?.absoluteString == + "https://proxy.example.test/api:v1") + } + + @Test + func `custom https endpoints allow bracketed IPv6 literals`() { + let env = [ + MiniMaxSettingsReader.hostKey: "[::1]:8443", + MiniMaxSettingsReader.remainsURLKey: "https://[::1]:8443/remains", + ] + + #expect(MiniMaxSettingsReader.hostOverride(environment: env) == "[::1]:8443") + #expect(MiniMaxSettingsReader.remainsURL(environment: env)?.host == "::1") + #expect(MiniMaxSettingsReader.rejectedEndpointOverrideKey(environment: env) == nil) + } + + @Test + func `strict provider endpoint mode rejects custom hosts`() { + let env = [ + MiniMaxSettingsReader.requireProviderEndpointOverridesKey: "true", + MiniMaxSettingsReader.hostKey: "proxy.example.test", + MiniMaxSettingsReader.remainsURLKey: "https://proxy.example.test/custom/remains", + ] + + #expect(MiniMaxSettingsReader.hostOverride(environment: env) == nil) + #expect(MiniMaxSettingsReader.remainsURL(environment: env) == nil) + #expect(MiniMaxSettingsReader.rejectedEndpointOverrideKey(environment: env) == MiniMaxSettingsReader.hostKey) + } + + @Test + func `custom https compatibility mode still rejects http and userinfo`() { + #expect(MiniMaxSettingsReader.hostOverride(environment: [ + MiniMaxSettingsReader.hostKey: "http://proxy.example.test", + ]) == nil) + #expect(MiniMaxSettingsReader.rejectedEndpointOverrideKey(environment: [ + MiniMaxSettingsReader.remainsURLKey: "https://user:pass@proxy.example.test/remains", + ]) == MiniMaxSettingsReader.remainsURLKey) + } + + @Test + func `explicit endpoint override rejects invalid scheme before network`() async { + await #expect(throws: ProviderEndpointOverrideError.minimax(MiniMaxSettingsReader.codingPlanURLKey)) { + _ = try await MiniMaxUsageFetcher.fetchUsage( + cookieHeader: "session=abc123", + environment: [MiniMaxSettingsReader.codingPlanURLKey: "http://platform.minimax.io/coding-plan"], + includeBillingHistory: false) + } + } +} + struct MiniMaxProviderStrategyTests { private struct StubClaudeFetcher: ClaudeUsageFetching { func loadLatestUsage(model _: String) async throws -> ClaudeUsageSnapshot { @@ -124,6 +283,18 @@ struct MiniMaxCookieHeaderTests { #expect(override?.authorizationToken == "token-abc") #expect(override?.groupID == "98765") } + + @Test + func `extracts group ID from combo curl header and cookie`() { + let raw = """ + curl 'https://www.minimaxi.com/v1/api/openplatform/charge/combo/cycle_audio_resource_package' \ + -b 'foo=bar; minimax_group_id_v2=2013894056999916075' \ + -H 'x-group-id: 2013894056999916075' + """ + let override = MiniMaxCookieHeader.override(from: raw) + #expect(override?.cookieHeader == "foo=bar; minimax_group_id_v2=2013894056999916075") + #expect(override?.groupID == "2013894056999916075") + } } struct MiniMaxUsageParserTests { @@ -1009,6 +1180,24 @@ struct MiniMaxAPIRegionTests { #expect(codingPlan.query == "cycle_type=3") } + @Test + func `resolves web remains fallback hosts`() { + let global = MiniMaxUsageFetcher.resolveRemainsURLs(region: .global, environment: [:]) + let china = MiniMaxUsageFetcher.resolveRemainsURLs(region: .chinaMainland, environment: [:]) + + #expect(global.map(\.host).contains("platform.minimax.io")) + #expect(global.map(\.host).contains("www.minimax.io")) + #expect(china.map(\.host).contains("platform.minimaxi.com")) + #expect(china.map(\.host).contains("www.minimaxi.com")) + } + + @Test + func `resolves official token plan remains URL`() { + let url = MiniMaxUsageFetcher.resolveTokenPlanRemainsURL(region: .chinaMainland) + #expect(url.host == "api.minimaxi.com") + #expect(url.path == "/v1/token_plan/remains") + } + @Test func `host override wins for remains and coding plan`() { let env = [MiniMaxSettingsReader.hostKey: "api.minimaxi.com"] diff --git a/Tests/CodexBarTests/MiniMaxTokenPlanChangeTests.swift b/Tests/CodexBarTests/MiniMaxTokenPlanChangeTests.swift new file mode 100644 index 0000000000..b7a0045722 --- /dev/null +++ b/Tests/CodexBarTests/MiniMaxTokenPlanChangeTests.swift @@ -0,0 +1,791 @@ +import Foundation +import Testing +@testable import CodexBar +@testable import CodexBarCore + +struct MiniMaxTokenPlanChangeTests { + @Test + func `parses percent based general token plan remains`() throws { + let now = Date(timeIntervalSince1970: 1_780_282_340) + + let snapshot = try MiniMaxUsageParser.parseCodingPlanRemains( + data: Data(Self.percentBasedRemainsJSON.utf8), + now: now) + let services = try #require(snapshot.services) + + #expect(snapshot.availablePrompts == nil) + #expect(snapshot.currentPrompts == nil) + #expect(snapshot.remainingPrompts == nil) + #expect(snapshot.usedPercent == 4) + #expect(services.count == 2) + #expect(services[0].serviceType == "general") + #expect(services[0].displayName == "General") + #expect(services[0].windowType == "5 hours") + #expect(services[0].usage == 4) + #expect(services[0].limit == 100) + #expect(services[0].percent == 4) + #expect(services[1].windowType == "Weekly") + #expect(services[1].usage == 1) + #expect(services[1].limit == 100) + #expect(services[1].percent == 1) + + let usage = snapshot.toUsageSnapshot() + #expect(usage.primary?.usedPercent == 4) + #expect(usage.primary?.windowMinutes == 300) + #expect(usage.secondary?.usedPercent == 1) + #expect(usage.secondary?.windowMinutes == 10080) + } + + @Test + func `zero count fields do not suppress percent based quota windows`() throws { + let now = Date(timeIntervalSince1970: 1_780_282_340) + let json = """ + { + "base_resp": { "status_code": "0" }, + "data": { + "current_subscribe_title": "Token Plan · TokenPlanPlus-年度会员", + "points_balance": "14000", + "model_remains": [ + { + "model_name": "general", + "current_interval_total_count": 0, + "current_interval_usage_count": 0, + "current_interval_remaining_percent": "96", + "start_time": 1780279200000, + "end_time": 1780297200000, + "current_weekly_total_count": 0, + "current_weekly_usage_count": 0, + "current_weekly_remaining_percent": "99", + "weekly_start_time": 1780243200000, + "weekly_end_time": 1780848000000 + } + ] + } + } + """ + + let snapshot = try MiniMaxUsageParser.parseCodingPlanRemains(data: Data(json.utf8), now: now) + + #expect(snapshot.planName == "Token Plan · TokenPlanPlus-年度会员") + #expect(snapshot.pointsBalance == 14000) + #expect(snapshot.services?.count == 2) + #expect(snapshot.toUsageSnapshot().providerCost?.used == 14000) + } + + @Test + func `video first token plan still uses general quota as primary and weekly secondary`() throws { + let now = Date(timeIntervalSince1970: 1_780_282_340) + let json = """ + { + "base_resp": { "status_code": "0" }, + "model_remains": [ + { + "model_name": "video", + "current_interval_total_count": 100, + "current_interval_usage_count": 70, + "current_interval_remaining_percent": 30, + "start_time": 1780243200000, + "end_time": 1780329600000 + }, + { + "model_name": "general", + "current_interval_total_count": 0, + "current_interval_usage_count": 0, + "current_interval_remaining_percent": 96, + "start_time": 1780279200000, + "end_time": 1780297200000, + "current_weekly_total_count": 0, + "current_weekly_usage_count": 0, + "current_weekly_remaining_percent": 99, + "weekly_start_time": 1780243200000, + "weekly_end_time": 1780848000000 + } + ] + } + """ + + let snapshot = try MiniMaxUsageParser.parseCodingPlanRemains(data: Data(json.utf8), now: now) + let usage = snapshot.toUsageSnapshot() + + #expect(snapshot.services?.map(\.serviceType) == ["video", "general", "general"]) + #expect(usage.primary?.usedPercent == 4) + #expect(usage.primary?.windowMinutes == 300) + #expect(usage.secondary?.usedPercent == 1) + #expect(usage.secondary?.windowMinutes == 10080) + #expect(usage.tertiary?.usedPercent == 70) + } + + @Test + func `plus token plan omits unavailable video quota lane`() throws { + let now = Date(timeIntervalSince1970: 1_780_282_340) + let json = """ + { + "base_resp": { "status_code": 0, "status_msg": "success" }, + "model_remains": [ + { + "start_time": 1780279200000, + "end_time": 1780297200000, + "remains_time": 16659830, + "current_interval_total_count": 0, + "current_interval_usage_count": 0, + "model_name": "general", + "current_weekly_total_count": 0, + "current_weekly_usage_count": 0, + "weekly_start_time": 1780243200000, + "weekly_end_time": 1780848000000, + "weekly_remains_time": 567459830, + "current_interval_status": 1, + "current_interval_remaining_percent": 96, + "current_weekly_status": 1, + "current_weekly_remaining_percent": 99 + }, + { + "start_time": 1780243200000, + "end_time": 1780329600000, + "remains_time": 49059830, + "current_interval_total_count": 0, + "current_interval_usage_count": 0, + "model_name": "video", + "current_weekly_total_count": 0, + "current_weekly_usage_count": 0, + "weekly_start_time": 1780243200000, + "weekly_end_time": 1780848000000, + "weekly_remains_time": 567459830, + "current_interval_status": 3, + "current_interval_remaining_percent": 100, + "current_weekly_status": 3, + "current_weekly_remaining_percent": 100 + } + ] + } + """ + + let snapshot = try MiniMaxUsageParser.parseCodingPlanRemains(data: Data(json.utf8), now: now) + let services = try #require(snapshot.services) + + #expect(snapshot.planName == "Plus") + #expect(snapshot.toUsageSnapshot().identity?.loginMethod == "Plus") + #expect(services.map(\.serviceType) == ["general", "general"]) + #expect(services.map(\.displayName) == ["General", "General"]) + #expect(snapshot.toUsageSnapshot().primary?.usedPercent == 4) + #expect(snapshot.toUsageSnapshot().secondary?.usedPercent == 1) + #expect(snapshot.toUsageSnapshot().tertiary == nil) + } + + @Test + func `plus token plan renders boosted interval and unlimited weekly lane`() throws { + let now = Date(timeIntervalSince1970: 1_780_347_620) + let json = """ + { + "model_remains": [ + { + "start_time": 1780347600000, + "end_time": 1780365600000, + "remains_time": 4650822, + "current_interval_total_count": 0, + "current_interval_usage_count": 0, + "model_name": "general", + "current_weekly_total_count": 0, + "current_weekly_usage_count": 0, + "weekly_start_time": 1780243200000, + "weekly_end_time": 1780848000000, + "weekly_remains_time": 487050822, + "current_interval_status": 1, + "current_interval_remaining_percent": 99, + "current_weekly_status": 3, + "current_weekly_remaining_percent": 100, + "interval_boost_permill": 2000, + "weekly_boost_permill": 2000 + }, + { + "start_time": 1780329600000, + "end_time": 1780416000000, + "remains_time": 55050822, + "current_interval_total_count": 0, + "current_interval_usage_count": 0, + "model_name": "video", + "current_weekly_total_count": 0, + "current_weekly_usage_count": 0, + "weekly_start_time": 1780243200000, + "weekly_end_time": 1780848000000, + "weekly_remains_time": 487050822, + "current_interval_status": 3, + "current_interval_remaining_percent": 100, + "current_weekly_status": 3, + "current_weekly_remaining_percent": 100 + } + ], + "base_resp": { "status_code": 0, "status_msg": "success" } + } + """ + + let snapshot = try MiniMaxUsageParser.parseCodingPlanRemains(data: Data(json.utf8), now: now) + let services = try #require(snapshot.services) + + #expect(services.count == 2) + #expect(services[0].serviceType == "general") + #expect(services[0].displayName == "General") + #expect(services[0].windowType == "5 hours") + #expect(services[0].usage == 2) + #expect(services[0].limit == 200) + #expect(services[0].percent == 1) + #expect(services[0].isUnlimited == false) + #expect(services[1].serviceType == "general") + #expect(services[1].displayName == "General") + #expect(services[1].windowType == "Weekly") + #expect(services[1].usage == 0) + #expect(services[1].limit == 0) + #expect(services[1].percent == 0) + #expect(services[1].isUnlimited) + #expect(snapshot.toUsageSnapshot().primary?.usedPercent == 1) + #expect(snapshot.toUsageSnapshot().secondary?.resetDescription == "Unlimited") + } + + @Test + func `web usage fetch falls back to www remains host after platform parse failure`() async throws { + let now = Date(timeIntervalSince1970: 1_780_282_340) + let transport = ProviderHTTPTransportStub { request in + let url = try #require(request.url) + if url.path.contains("coding-plan") { + return Self.httpResponse( + url: url, + body: "
    Coding Plan
    ", + contentType: "text/html") + } + if url.host == "platform.minimaxi.com", url.path.contains("coding_plan/remains") { + return Self.httpResponse(url: url, body: "not json", contentType: "application/json") + } + #expect(url.host == "www.minimaxi.com") + #expect(url.path == "/v1/api/openplatform/coding_plan/remains") + return Self.httpResponse(url: url, body: Self.percentBasedRemainsJSON, contentType: "application/json") + } + + let snapshot = try await MiniMaxUsageFetcher.fetchUsage( + cookieHeader: "HERTZ-SESSION=abc", + region: .chinaMainland, + environment: [:], + includeBillingHistory: false, + session: transport, + now: now) + let requests = await transport.requests() + + #expect(snapshot.toUsageSnapshot().primary?.usedPercent == 4) + #expect(requests.contains { + $0.url?.host == "platform.minimaxi.com" && $0.url?.path.contains("remains") == true + }) + #expect(requests.contains { + $0.url?.host == "www.minimaxi.com" && $0.url?.path.contains("remains") == true + }) + } + + @Test + func `web usage fetch falls back to www remains host after platform transport failure`() async throws { + let now = Date(timeIntervalSince1970: 1_780_282_340) + let transport = ProviderHTTPTransportStub { request in + let url = try #require(request.url) + if url.path.contains("coding-plan") { + return Self.httpResponse( + url: url, + body: "
    Coding Plan
    ", + contentType: "text/html") + } + if url.host == "platform.minimaxi.com", url.path.contains("coding_plan/remains") { + throw URLError(.timedOut) + } + #expect(url.host == "www.minimaxi.com") + #expect(url.path == "/v1/api/openplatform/coding_plan/remains") + return Self.httpResponse(url: url, body: Self.percentBasedRemainsJSON, contentType: "application/json") + } + + let snapshot = try await MiniMaxUsageFetcher.fetchUsage( + cookieHeader: "HERTZ-SESSION=abc", + region: .chinaMainland, + environment: [:], + includeBillingHistory: false, + session: transport, + now: now) + let requests = await transport.requests() + + #expect(snapshot.toUsageSnapshot().primary?.usedPercent == 4) + #expect(requests.map { $0.url?.host } == [ + "platform.minimaxi.com", + "platform.minimaxi.com", + "www.minimaxi.com", + ]) + } + + @Test + func `web usage fetch preserves coding plan json auth failure`() async throws { + let transport = ProviderHTTPTransportStub { request in + let url = try #require(request.url) + #expect(url.path.contains("coding-plan")) + return Self.httpResponse( + url: url, + body: #"{"base_resp":{"status_code":1004,"status_msg":"cookie is missing, log in again"}}"#, + contentType: "application/json") + } + + await #expect(throws: MiniMaxUsageError.invalidCredentials) { + try await MiniMaxUsageFetcher.fetchUsage( + cookieHeader: "HERTZ-SESSION=expired", + region: .chinaMainland, + environment: [:], + includeBillingHistory: false, + session: transport) + } + let requests = await transport.requests() + #expect(requests.count == 1) + } + + @Test + func `web usage fetch preserves remains json auth failure`() async throws { + let transport = ProviderHTTPTransportStub { request in + let url = try #require(request.url) + if url.path.contains("coding-plan") { + return Self.httpResponse( + url: url, + body: "
    Coding Plan
    ", + contentType: "text/html") + } + #expect(url.path.contains("coding_plan/remains")) + return Self.httpResponse( + url: url, + body: #"{"base_resp":{"status_code":1004,"status_msg":"cookie is missing, log in again"}}"#, + contentType: "application/json") + } + + await #expect(throws: MiniMaxUsageError.invalidCredentials) { + try await MiniMaxUsageFetcher.fetchUsage( + cookieHeader: "HERTZ-SESSION=expired", + region: .chinaMainland, + environment: [:], + includeBillingHistory: false, + session: transport) + } + let requests = await transport.requests() + #expect(requests.map { $0.url?.path } == [ + "/user-center/payment/coding-plan", + "/v1/api/openplatform/coding_plan/remains", + ]) + } + + @Test + func `api token fetch uses official token plan remains endpoint`() async throws { + let now = Date(timeIntervalSince1970: 1_780_282_340) + let transport = ProviderHTTPTransportStub { request in + let url = try #require(request.url) + #expect(url.host == "api.minimaxi.com") + #expect(url.path == "/v1/token_plan/remains") + #expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer sk-cp-test") + return Self.httpResponse(url: url, body: Self.percentBasedRemainsJSON, contentType: "application/json") + } + + let snapshot = try await MiniMaxUsageFetcher.fetchUsage( + apiToken: "sk-cp-test", + region: .chinaMainland, + now: now, + session: transport) + + #expect(snapshot.toUsageSnapshot().primary?.usedPercent == 4) + } + + @Test + func `api token fetch falls back to legacy coding plan endpoint after official auth failure`() async throws { + let now = Date(timeIntervalSince1970: 1_780_282_340) + let transport = ProviderHTTPTransportStub { request in + let url = try #require(request.url) + #expect(url.host == "api.minimaxi.com") + #expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer sk-standard-test") + if url.path == "/v1/token_plan/remains" { + return Self.httpResponse(url: url, body: "{}", statusCode: 401, contentType: "application/json") + } + #expect(url.path == "/v1/api/openplatform/coding_plan/remains") + return Self.httpResponse(url: url, body: Self.percentBasedRemainsJSON, contentType: "application/json") + } + + let snapshot = try await MiniMaxUsageFetcher.fetchUsage( + apiToken: "sk-standard-test", + region: .chinaMainland, + now: now, + session: transport) + let requests = await transport.requests() + + #expect(snapshot.toUsageSnapshot().primary?.usedPercent == 4) + #expect(requests.map { $0.url?.path } == [ + "/v1/token_plan/remains", + "/v1/api/openplatform/coding_plan/remains", + ]) + } + + @Test + func `global api token fetch preserves structured credential failure across legacy error`() async throws { + let now = Date(timeIntervalSince1970: 1_780_282_340) + let transport = ProviderHTTPTransportStub { request in + let url = try #require(request.url) + #expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer sk-cp-test") + + switch (url.host, url.path) { + case ("api.minimax.io", "/v1/token_plan/remains"): + return Self.httpResponse( + url: url, + body: #"{"base_resp":{"status_code":1001,"status_msg":"invalid api key"}}"#, + contentType: "application/json") + case ("api.minimax.io", "/v1/api/openplatform/coding_plan/remains"): + return Self.httpResponse( + url: url, + body: #"{"error":"legacy endpoint unavailable"}"#, + statusCode: 404, + contentType: "application/json") + case ("api.minimaxi.com", "/v1/token_plan/remains"): + return Self.httpResponse( + url: url, + body: Self.percentBasedRemainsJSON, + contentType: "application/json") + default: + Issue.record("Unexpected MiniMax API request: \(url.absoluteString)") + return Self.httpResponse(url: url, body: "{}", statusCode: 500, contentType: "application/json") + } + } + + let snapshot = try await MiniMaxUsageFetcher.fetchUsage( + apiToken: "sk-cp-test", + region: .global, + now: now, + session: transport) + let requests = await transport.requests() + + #expect(snapshot.toUsageSnapshot().primary?.usedPercent == 4) + #expect(requests.map { $0.url?.host } == [ + "api.minimax.io", + "api.minimax.io", + "api.minimaxi.com", + ]) + #expect(requests.map { $0.url?.path } == [ + "/v1/token_plan/remains", + "/v1/api/openplatform/coding_plan/remains", + "/v1/token_plan/remains", + ]) + } + + @Test + func `api token fetch falls back to legacy coding plan endpoint after official parse failure`() async throws { + let now = Date(timeIntervalSince1970: 1_780_282_340) + let transport = ProviderHTTPTransportStub { request in + let url = try #require(request.url) + #expect(url.host == "api.minimaxi.com") + #expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer sk-standard-test") + if url.path == "/v1/token_plan/remains" { + return Self.httpResponse(url: url, body: "{}", contentType: "application/json") + } + #expect(url.path == "/v1/api/openplatform/coding_plan/remains") + return Self.httpResponse(url: url, body: Self.percentBasedRemainsJSON, contentType: "application/json") + } + + let snapshot = try await MiniMaxUsageFetcher.fetchUsage( + apiToken: "sk-standard-test", + region: .chinaMainland, + now: now, + session: transport) + let requests = await transport.requests() + + #expect(snapshot.toUsageSnapshot().primary?.usedPercent == 4) + #expect(requests.map { $0.url?.path } == [ + "/v1/token_plan/remains", + "/v1/api/openplatform/coding_plan/remains", + ]) + } + + @Test + func `api token fetch falls back to legacy coding plan endpoint after official transport failure`() async throws { + let now = Date(timeIntervalSince1970: 1_780_282_340) + let transport = ProviderHTTPTransportStub { request in + let url = try #require(request.url) + #expect(url.host == "api.minimaxi.com") + #expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer sk-standard-test") + if url.path == "/v1/token_plan/remains" { + throw URLError(.timedOut) + } + #expect(url.path == "/v1/api/openplatform/coding_plan/remains") + return Self.httpResponse(url: url, body: Self.percentBasedRemainsJSON, contentType: "application/json") + } + + let snapshot = try await MiniMaxUsageFetcher.fetchUsage( + apiToken: "sk-standard-test", + region: .chinaMainland, + now: now, + session: transport) + let requests = await transport.requests() + + #expect(snapshot.toUsageSnapshot().primary?.usedPercent == 4) + #expect(requests.map { $0.url?.path } == [ + "/v1/token_plan/remains", + "/v1/api/openplatform/coding_plan/remains", + ]) + } + + @Test + func `api token fetch rejects after official and legacy endpoint auth failures`() async throws { + let transport = ProviderHTTPTransportStub { request in + let url = try #require(request.url) + #expect(url.host == "api.minimaxi.com") + #expect([ + "/v1/token_plan/remains", + "/v1/api/openplatform/coding_plan/remains", + ].contains(url.path)) + return Self.httpResponse(url: url, body: "{}", statusCode: 401, contentType: "application/json") + } + + await #expect(throws: MiniMaxUsageError.invalidCredentials) { + try await MiniMaxUsageFetcher.fetchUsage( + apiToken: "sk-standard-test", + region: .chinaMainland, + session: transport) + } + let requests = await transport.requests() + + #expect(requests.map { $0.url?.path } == [ + "/v1/token_plan/remains", + "/v1/api/openplatform/coding_plan/remains", + ]) + } + + @Test + func `combo metadata parser extracts token plan subscription label`() throws { + let metadata = try MiniMaxSubscriptionMetadataFetcher.parse(data: Data(Self.comboMetadataJSON.utf8)) + #expect(metadata.planName == "TokenPlanMax-年度会员") + #expect(metadata.subscriptionExpiresAt == Date(timeIntervalSince1970: 1_810_656_000)) + #expect(metadata.subscriptionRenewsAt == Date(timeIntervalSince1970: 1_810_569_600)) + } + + @Test + func `combo metadata parser prefers current subscription over package catalog`() throws { + let json = """ + { + "base_resp": { "status_code": 0, "status_msg": "success" }, + "data": { + "current_subscribe": { + "current_subscribe_title": "TokenPlanUltra-年度会员" + }, + "packages": [ + { "resource_package_name": "TokenPlanPlus" }, + { "resource_package_name": "TokenPlanMax" }, + { "resource_package_name": "TokenPlanUltra" } + ] + } + } + """ + + let metadata = try MiniMaxSubscriptionMetadataFetcher.parse(data: Data(json.utf8)) + + #expect(metadata.planName == "TokenPlanUltra-年度会员") + } + + @Test + func `web usage fetch merges combo subscription metadata`() async throws { + let now = Date(timeIntervalSince1970: 1_780_282_340) + let transport = ProviderHTTPTransportStub { request in + let url = try #require(request.url) + if url.path.contains("coding-plan") { + return Self.httpResponse( + url: url, + body: "
    Coding Plan
    ", + contentType: "text/html") + } + if url.path.contains("coding_plan/remains") { + return Self.httpResponse(url: url, body: Self.percentBasedRemainsJSON, contentType: "application/json") + } + #expect(url.host == "www.minimaxi.com") + #expect(url.path == "/v1/api/openplatform/charge/combo/cycle_audio_resource_package") + #expect(url.query?.contains("biz_line=2") == true) + #expect(request.value(forHTTPHeaderField: "x-group-id") == "2013894056999916075") + #expect(request.value(forHTTPHeaderField: "origin") == "https://platform.minimaxi.com") + return Self.httpResponse(url: url, body: Self.comboMetadataJSON, contentType: "application/json") + } + + let snapshot = try await MiniMaxUsageFetcher.fetchUsage( + cookieHeader: "_token=abc; minimax_group_id_v2=2013894056999916075", + groupID: "2013894056999916075", + region: .chinaMainland, + environment: [:], + includeBillingHistory: false, + session: transport, + now: now) + let requests = await transport.requests() + + #expect(snapshot.planName == "TokenPlanMax-年度会员") + #expect(snapshot.subscriptionExpiresAt == Date(timeIntervalSince1970: 1_810_656_000)) + #expect(snapshot.subscriptionRenewsAt == Date(timeIntervalSince1970: 1_810_569_600)) + #expect(snapshot.toUsageSnapshot().subscriptionExpiresAt == Date(timeIntervalSince1970: 1_810_656_000)) + #expect(snapshot.toUsageSnapshot().subscriptionRenewsAt == Date(timeIntervalSince1970: 1_810_569_600)) + #expect(snapshot.toUsageSnapshot().primary?.usedPercent == 4) + #expect(requests.contains { $0.url?.path.contains("cycle_audio_resource_package") == true }) + } + + @Test + func `combo metadata rejects non https host override before sending credentials`() async throws { + let transport = ProviderHTTPTransportStub { request in + Issue.record("Unexpected request to \(request.url?.absoluteString ?? "")") + return Self.httpResponse( + url: URL(string: "https://unused.example")!, + body: "{}", + contentType: "application/json") + } + + await #expect(throws: ProviderEndpointOverrideError.minimax(MiniMaxSettingsReader.hostKey)) { + try await MiniMaxSubscriptionMetadataFetcher.fetch( + cookieHeader: "_token=secret", + groupID: "2013894056999916075", + region: .chinaMainland, + environment: [MiniMaxSettingsReader.hostKey: "http://metadata.test"], + transport: transport) + } + + let requests = await transport.requests() + #expect(requests.isEmpty) + } + + @Test + func `combo metadata rejects malformed host override before sending credentials`() async throws { + let transport = ProviderHTTPTransportStub { request in + Issue.record("Unexpected request to \(request.url?.absoluteString ?? "")") + return Self.httpResponse( + url: URL(string: "https://unused.example")!, + body: "{}", + contentType: "application/json") + } + + await #expect(throws: ProviderEndpointOverrideError.minimax(MiniMaxSettingsReader.hostKey)) { + try await MiniMaxSubscriptionMetadataFetcher.fetch( + cookieHeader: "_token=secret", + groupID: "2013894056999916075", + region: .chinaMainland, + environment: [MiniMaxSettingsReader.hostKey: "bad host"], + transport: transport) + } + + let requests = await transport.requests() + #expect(requests.isEmpty) + } + + @Test + func `web usage fetch preserves combo metadata cancellation`() async throws { + let now = Date(timeIntervalSince1970: 1_780_282_340) + let transport = ProviderHTTPTransportStub { request in + let url = try #require(request.url) + if url.path.contains("coding-plan") { + return Self.httpResponse( + url: url, + body: "
    Coding Plan
    ", + contentType: "text/html") + } + if url.path.contains("coding_plan/remains") { + return Self.httpResponse(url: url, body: Self.percentBasedRemainsJSON, contentType: "application/json") + } + throw CancellationError() + } + + await #expect(throws: CancellationError.self) { + try await MiniMaxUsageFetcher.fetchUsage( + cookieHeader: "_token=abc", + groupID: "2013894056999916075", + region: .chinaMainland, + environment: [:], + includeBillingHistory: false, + session: transport, + now: now) + } + } + + @Test + func `combo metadata failure does not block quota rendering`() async throws { + let now = Date(timeIntervalSince1970: 1_780_282_340) + let transport = ProviderHTTPTransportStub { request in + let url = try #require(request.url) + if url.path.contains("coding-plan") { + return Self.httpResponse( + url: url, + body: "
    Coding Plan
    ", + contentType: "text/html") + } + if url.path.contains("coding_plan/remains") { + return Self.httpResponse(url: url, body: Self.percentBasedRemainsJSON, contentType: "application/json") + } + return Self.httpResponse( + url: url, + body: #"{"base_resp":{"status_code":1004,"status_msg":"cookie is missing, log in again"}}"#, + contentType: "application/json") + } + + let snapshot = try await MiniMaxUsageFetcher.fetchUsage( + cookieHeader: "_token=abc", + groupID: "2013894056999916075", + region: .chinaMainland, + environment: [:], + includeBillingHistory: false, + session: transport, + now: now) + + #expect(snapshot.planName == nil) + #expect(snapshot.toUsageSnapshot().primary?.usedPercent == 4) + } + + private static let comboMetadataJSON = """ + { + "base_resp": { "status_code": 0, "status_msg": "success" }, + "data": { + "current_subscribe": { + "current_subscribe_title": "TokenPlanMax-年度会员", + "current_subscribe_end_time": "05/19/2027", + "renewal_date": "05/18/2027", + "current_subscribe_end_time_ts": 1810656000000, + "renewal_trigger_time_ts": 1810569600000 + }, + "packages": [ + { + "resource_package_name": "TokenPlanMax", + "display_name": "Token Plan · TokenPlanMax-年度会员" + } + ] + } + } + """ + + private static let percentBasedRemainsJSON = """ + { + "model_remains": [ + { + "start_time": 1780279200000, + "end_time": 1780297200000, + "remains_time": 16659830, + "current_interval_total_count": 0, + "current_interval_usage_count": 0, + "model_name": "general", + "current_weekly_total_count": 0, + "current_weekly_usage_count": 0, + "weekly_start_time": 1780243200000, + "weekly_end_time": 1780848000000, + "weekly_remains_time": 567459830, + "current_interval_status": 1, + "current_interval_remaining_percent": 96, + "current_weekly_status": 1, + "current_weekly_remaining_percent": 99 + } + ], + "base_resp": { "status_code": 0, "status_msg": "success" } + } + """ + + private static func httpResponse( + url: URL, + body: String, + statusCode: Int = 200, + contentType: String) -> (Data, URLResponse) + { + let response = HTTPURLResponse( + url: url, + statusCode: statusCode, + httpVersion: "HTTP/1.1", + headerFields: ["Content-Type": contentType])! + return (Data(body.utf8), response) + } +} diff --git a/Tests/CodexBarTests/MistralMenuCardModelTests.swift b/Tests/CodexBarTests/MistralMenuCardModelTests.swift new file mode 100644 index 0000000000..5726839e69 --- /dev/null +++ b/Tests/CodexBarTests/MistralMenuCardModelTests.swift @@ -0,0 +1,168 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +struct MistralMenuCardModelTests { + @Test + func `mistral credit balance renders like deepseek balance`() throws { + let now = Date() + let credits = MistralCreditsSnapshot( + walletAmount: 0, + creditNotesAmount: 0, + ongoingUsageBalance: 0, + currency: "USD") + let snapshot = MistralUsageSnapshot( + totalCost: 0, + currency: "USD", + currencySymbol: "$", + totalInputTokens: 0, + totalOutputTokens: 0, + totalCachedTokens: 0, + modelCount: 0, + credits: credits, + startDate: nil, + endDate: nil, + updatedAt: now) + .toUsageSnapshot() + let metadata = try #require(ProviderDefaults.metadata[.mistral]) + + let model = UsageMenuCardView.Model.make(.init( + provider: .mistral, + metadata: metadata, + snapshot: snapshot, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + + let primary = try #require(model.metrics.first) + #expect(primary.title == "Balance") + #expect(primary.statusText == "$0.00") + #expect(primary.resetText == nil) + #expect(primary.detailText == nil) + } + + @Test + func `mistral credit balance renders separately from primary percent lane`() throws { + let now = Date() + let credits = MistralCreditsSnapshot( + walletAmount: 10, + creditNotesAmount: 2.5, + ongoingUsageBalance: 0, + currency: "USD") + let usage = MistralUsageSnapshot( + totalCost: 0, + currency: "USD", + currencySymbol: "$", + totalInputTokens: 0, + totalOutputTokens: 0, + totalCachedTokens: 0, + modelCount: 0, + credits: credits, + startDate: nil, + endDate: nil, + updatedAt: now) + .toUsageSnapshot() + let snapshot = UsageSnapshot( + primary: RateWindow( + usedPercent: 73, + windowMinutes: nil, + resetsAt: now.addingTimeInterval(3 * 24 * 60 * 60), + resetDescription: "API spend this month"), + secondary: nil, + tertiary: nil, + mistralUsage: usage.mistralUsage, + updatedAt: now, + identity: usage.identity) + let metadata = try #require(ProviderDefaults.metadata[.mistral]) + + let model = UsageMenuCardView.Model.make(.init( + provider: .mistral, + metadata: metadata, + snapshot: snapshot, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + + let primary = try #require(model.metrics.first) + #expect(primary.id == "mistral-balance") + #expect(primary.statusText == "$12.50") + #expect(primary.detailText == nil) + #expect(primary.resetText == nil) + + let percentMetric = try #require(model.metrics.dropFirst().first) + #expect(percentMetric.id == "primary") + #expect(percentMetric.percent == 27) + #expect(percentMetric.detailText == "API spend this month") + } + + @Test + func `mistral model surfaces monthly cost as primary detail text`() throws { + let now = Date() + let resetsAt = now.addingTimeInterval(3 * 24 * 60 * 60) + let identity = ProviderIdentitySnapshot( + providerID: .mistral, + accountEmail: nil, + accountOrganization: nil, + loginMethod: nil) + let snapshot = UsageSnapshot( + primary: RateWindow( + usedPercent: 0, + windowMinutes: nil, + resetsAt: resetsAt, + resetDescription: "€1.2345 this month"), + secondary: nil, + tertiary: nil, + updatedAt: now, + identity: identity) + let metadata = try #require(ProviderDefaults.metadata[.mistral]) + + let model = UsageMenuCardView.Model.make(.init( + provider: .mistral, + metadata: metadata, + snapshot: snapshot, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: true, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + + let primary = try #require(model.metrics.first) + #expect(primary.detailText == "€1.2345 this month") + #expect(primary.resetText?.hasPrefix("Resets") == true) + } +} diff --git a/Tests/CodexBarTests/MistralUsageParserTests.swift b/Tests/CodexBarTests/MistralUsageParserTests.swift index b380175365..1ea4478260 100644 --- a/Tests/CodexBarTests/MistralUsageParserTests.swift +++ b/Tests/CodexBarTests/MistralUsageParserTests.swift @@ -47,6 +47,101 @@ struct MistralUsageParserTests { #expect(snapshot.totalCost > 0) } + @Test(arguments: ["NaN", "Infinity", "1e308"]) + func `ignores prices that produce nonfinite costs`(price: String) async throws { + let json = """ + { + "completion": { + "models": { + "mistral-small": { + "input": [{ + "billing_metric": "tokens", + "billing_group": "input", + "timestamp": "2026-07-04", + "value": 2 + }] + } + } + }, + "prices": [{ + "billing_metric": "tokens", + "billing_group": "input", + "price": "\(price)" + }] + } + """ + let transport = ProviderHTTPTransportHandler { request in + #expect(request.url?.path == "/api/billing/v2/usage") + #expect(request.value(forHTTPHeaderField: "Cookie") == "ory_session_test=abc") + let requestURL = try #require(request.url) + let response = try #require(HTTPURLResponse( + url: requestURL, + statusCode: 200, + httpVersion: nil, + headerFields: nil)) + return (Data(json.utf8), response) + } + + let snapshot = try await MistralUsageFetcher.fetchUsage( + cookieHeader: "ory_session_test=abc", + csrfToken: nil, + transport: transport) + + #expect(snapshot.totalCost == 0) + #expect(snapshot.totalCost.isFinite) + #expect(snapshot.daily.first?.cost == 0) + #expect(snapshot.daily.first?.models.first?.cost == 0) + } + + @Test + func `keeps cost totals finite when individually valid costs overflow their sum`() throws { + let json = """ + { + "completion": { + "models": { + "mistral-small": { + "input": [ + { + "billing_metric": "tokens", + "billing_group": "input", + "timestamp": "2026-07-04", + "value": 1 + }, + { + "billing_metric": "tokens", + "billing_group": "input", + "timestamp": "2026-07-04", + "value": 1 + } + ] + }, + "mistral-large": { + "input": [{ + "billing_metric": "tokens", + "billing_group": "input", + "timestamp": "2026-07-04", + "value": 1 + }] + } + } + }, + "prices": [{ + "billing_metric": "tokens", + "billing_group": "input", + "price": "1e308" + }] + } + """ + + let snapshot = try MistralUsageFetcher.parseResponse(data: Data(json.utf8), updatedAt: Date()) + + #expect(snapshot.totalCost == 1e308) + #expect(snapshot.totalCost.isFinite) + #expect(snapshot.daily.first?.cost == 1e308) + #expect(snapshot.daily.first?.models.count == 2) + #expect(snapshot.daily.first?.models.allSatisfy { $0.cost == 1e308 } == true) + } + @Test func `parses empty response with no usage`() throws { let data = try #require(Self.emptyResponseJSON.data(using: .utf8)) @@ -59,6 +154,107 @@ struct MistralUsageParserTests { #expect(snapshot.currency == "EUR") } + @Test(arguments: ["{}", #"{"currency":" ","currency_symbol":" "}"#]) + func `missing currency stays explicitly unknown`(json: String) throws { + let snapshot = try MistralUsageFetcher.parseResponse(data: Data(json.utf8), updatedAt: Date()) + + #expect(snapshot.currency == "XXX") + #expect(snapshot.currencySymbol == "¤") + #expect(snapshot.toCostUsageTokenSnapshot().currencyCode == "XXX") + } + + @Test + func `parses credits response`() throws { + let json = """ + { + "wallet_amount": 12.5, + "credit_notes_amount": 2.25, + "ongoing_usage_balance": 1.5, + "currency": "USD", + "minimum_credits_purchase": 10, + "maximum_credits_purchase": 1000 + } + """ + + let credits = try MistralUsageFetcher.parseCredits(data: Data(json.utf8)) + + #expect(credits.walletAmount == 12.5) + #expect(credits.creditNotesAmount == 2.25) + #expect(credits.ongoingUsageBalance == 1.5) + #expect(credits.currency == "USD") + #expect(credits.availableAmount == 13.25) + #expect(credits.formattedAvailableAmount == "$13.25") + } + + @Test + func `credits available amount floors after ongoing usage`() { + let credits = MistralCreditsSnapshot( + walletAmount: 1, + creditNotesAmount: 0.5, + ongoingUsageBalance: 3, + currency: "USD") + + #expect(credits.availableAmount == 0) + #expect(credits.formattedAvailableAmount == "$0.00") + } + + @Test + func `rejects credit amounts whose sum overflows`() throws { + let json = """ + { + "wallet_amount": 1e308, + "credit_notes_amount": 1e308, + "ongoing_usage_balance": 0, + "currency": "USD" + } + """ + + #expect(throws: MistralUsageError.self) { + try MistralUsageFetcher.parseCredits(data: Data(json.utf8)) + } + + let credits = MistralCreditsSnapshot( + walletAmount: 1e308, + creditNotesAmount: 1e308, + ongoingUsageBalance: 0, + currency: "USD") + #expect(credits.availableAmount == 0) + #expect(credits.formattedAvailableAmount == "$0.00") + } + + @Test + func `fetches credits from dashboard endpoint with existing web session`() async throws { + let json = """ + { + "wallet_amount": 3, + "credit_notes_amount": 4, + "ongoing_usage_balance": 0, + "currency": "EUR" + } + """ + let transport = ProviderHTTPTransportHandler { request in + #expect(request.url?.absoluteString == "https://admin.mistral.ai/api/billing/credits") + #expect(request.value(forHTTPHeaderField: "Cookie") == "ory_session_test=abc; csrftoken=csrf") + #expect(request.value(forHTTPHeaderField: "X-CSRFTOKEN") == "csrf") + #expect(request.value(forHTTPHeaderField: "Referer") == "https://admin.mistral.ai/organization/billing") + let requestURL = try #require(request.url) + let response = try #require(HTTPURLResponse( + url: requestURL, + statusCode: 200, + httpVersion: nil, + headerFields: nil)) + return (Data(json.utf8), response) + } + + let credits = try await MistralUsageFetcher.fetchCredits( + cookieHeader: "ory_session_test=abc; csrftoken=csrf", + csrfToken: "csrf", + transport: transport) + + #expect(credits.availableAmount == 7) + #expect(credits.formattedAvailableAmount == "€7.00") + } + @Test func `daily spend keeps non token Mistral units out of token totals`() throws { let json = """ @@ -149,6 +345,33 @@ struct MistralUsageSnapshotConversionTests { #expect(usage.providerCost == nil) } + @Test + func `converts credits into balance data without replacing api spend or primary percent`() { + let credits = MistralCreditsSnapshot( + walletAmount: 10, + creditNotesAmount: 2.5, + ongoingUsageBalance: 1, + currency: "USD") + let snapshot = MistralUsageSnapshot( + totalCost: 1.2345, + currency: "USD", + currencySymbol: "$", + totalInputTokens: 10000, + totalOutputTokens: 5000, + totalCachedTokens: 0, + modelCount: 2, + credits: credits, + startDate: nil, + endDate: Date(), + updatedAt: Date()) + + let usage = snapshot.toUsageSnapshot() + #expect(usage.primary == nil) + #expect(usage.identity?.loginMethod == "API spend: $1.2345 this month") + #expect(usage.mistralUsage?.credits == credits) + #expect(usage.mistralUsage?.credits?.formattedAvailableAmount == "$11.50") + } + @Test func `converts zero cost into zero spend text`() { let snapshot = MistralUsageSnapshot( @@ -169,8 +392,8 @@ struct MistralUsageSnapshotConversionTests { } @Test - func `converts billing usage into cost token snapshot`() { - let now = Date(timeIntervalSince1970: 1_700_179_200) + func `requested one day trims rows totals and latest session to observed UTC day`() throws { + let now = try #require(ISO8601DateFormatter().date(from: "2023-11-15T12:00:00Z")) let snapshot = MistralUsageSnapshot( totalCost: 1.75, currency: "eur", @@ -215,21 +438,303 @@ struct MistralUsageSnapshotConversionTests { let cost = snapshot.toCostUsageTokenSnapshot(historyDays: 1) #expect(cost.currencyCode == "EUR") - #expect(cost.historyLabel == "This month") - #expect(cost.historyDays == 2) + #expect(cost.historyLabel == nil) + #expect(cost.historyDays == 1) #expect(cost.sessionCostUSD == 0.25) #expect(cost.sessionTokens == 330) - #expect(cost.last30DaysCostUSD == 1.75) - #expect(cost.last30DaysTokens == 500) - #expect(cost.daily.count == 2) - #expect(cost.daily.last?.modelsUsed == ["mistral-small"]) + #expect(cost.last30DaysCostUSD == 0.25) + #expect(cost.last30DaysTokens == 330) + #expect(cost.daily.map(\.date) == ["2023-11-15"]) + #expect(cost.daily.first?.modelsUsed == ["mistral-small"]) + } + + @Test + func `sparse daily usage reports inclusive covered day span`() throws { + let updatedAt = try #require(ISO8601DateFormatter().date(from: "2026-07-16T12:00:00Z")) + let snapshot = Self.coverageSnapshot( + dailyDays: ["2026-07-01", "2026-07-16"], + updatedAt: updatedAt) + + #expect(snapshot.toCostUsageTokenSnapshot().historyDays == 16) + let sevenDays = snapshot.toCostUsageTokenSnapshot(historyDays: 7) + #expect(sevenDays.historyDays == 1) + #expect(sevenDays.daily.map(\.date) == ["2026-07-16"]) + #expect(sevenDays.last30DaysCostUSD == 1) + #expect(sevenDays.last30DaysTokens == 1) + } + + @Test + func `metadata free coverage ends on latest valid billing bucket`() throws { + let formatter = ISO8601DateFormatter() + let updatedAt = try #require(formatter.date(from: "2026-07-16T12:00:00Z")) + let fetchDay = try #require(formatter.date(from: "2026-07-16T00:00:00Z")) + let latestBucket = try #require(formatter.date(from: "2026-07-15T00:00:00Z")) + let snapshot = Self.coverageSnapshot( + dailyDays: ["2026-07-14", "2026-07-15"], + updatedAt: updatedAt) + + let cost = snapshot.toCostUsageTokenSnapshot() + #expect(cost.historyDays == 2) + #expect(cost.updatedAt == latestBucket) + #expect(cost.last30DaysCostUSD == 2) + #expect(cost.last30DaysTokens == 2) + + let empty = Self.coverageSnapshot(dailyDays: [], updatedAt: updatedAt) + .toCostUsageTokenSnapshot() + #expect(empty.historyDays == 1) + #expect(!empty.historyCoverageIsEstablished) + #expect(empty.updatedAt == fetchDay) + #expect(empty.last30DaysCostUSD == nil) + #expect(empty.last30DaysTokens == nil) + + let invalid = MistralUsageSnapshot( + totalCost: 1, + currency: "EUR", + currencySymbol: "€", + totalInputTokens: 1, + totalOutputTokens: 0, + totalCachedTokens: 0, + modelCount: 1, + daily: [Self.bucket(day: "not-a-day")], + startDate: nil, + endDate: nil, + updatedAt: updatedAt) + .toCostUsageTokenSnapshot() + #expect(invalid.historyDays == 1) + #expect(!invalid.historyCoverageIsEstablished) + #expect(invalid.updatedAt == fetchDay) + #expect(invalid.last30DaysCostUSD == nil) + #expect(invalid.last30DaysTokens == nil) + + let outsideWindow = Self.coverageSnapshot( + dailyDays: ["2026-07-01"], + updatedAt: updatedAt) + .toCostUsageTokenSnapshot(historyDays: 7) + #expect(!outsideWindow.historyCoverageIsEstablished) + #expect(outsideWindow.daily.isEmpty) + #expect(outsideWindow.last30DaysCostUSD == nil) + #expect(outsideWindow.last30DaysTokens == nil) + } + + @Test + func `metadata coverage uses UTC dates and stops at earlier boundary`() throws { + let formatter = ISO8601DateFormatter() + let start = try #require(formatter.date(from: "2026-07-01T23:59:59Z")) + let monthEnd = try #require(formatter.date(from: "2026-07-31T23:59:59Z")) + let updatedAt = try #require(formatter.date(from: "2026-07-16T00:00:01Z")) + let secondDay = try #require(formatter.date(from: "2026-07-02T00:00:01Z")) + let longRangeStart = try #require(formatter.date(from: "2020-01-01T00:00:00Z")) + + let currentMonth = Self.coverageSnapshot( + dailyDays: ["2026-07-16"], + startDate: start, + endDate: monthEnd, + updatedAt: updatedAt) + let endedRange = Self.coverageSnapshot( + dailyDays: ["2026-07-01", "2026-07-02"], + startDate: start, + endDate: secondDay, + updatedAt: updatedAt) + let longRange = Self.coverageSnapshot( + dailyDays: [], + startDate: longRangeStart, + endDate: monthEnd, + updatedAt: updatedAt) + + #expect(currentMonth.toCostUsageTokenSnapshot().historyDays == 16) + #expect(currentMonth.toCostUsageTokenSnapshot().historyLabel == "This month") + let endedCost = endedRange.toCostUsageTokenSnapshot() + #expect(endedCost.historyDays == 2) + #expect(endedCost.historyLabel == nil) + #expect(endedCost.updatedAt == formatter.date(from: "2026-07-02T00:00:00Z")) + #expect(endedRange.toUsageSnapshot().updatedAt == updatedAt) + #expect(longRange.toCostUsageTokenSnapshot(historyDays: 900).historyDays == 365) + } + + @Test + func `metadata preserves empty covered days while excluding rows before requested window`() throws { + let formatter = ISO8601DateFormatter() + let start = try #require(formatter.date(from: "2026-07-01T00:00:00Z")) + let end = try #require(formatter.date(from: "2026-07-31T23:59:59Z")) + let updatedAt = try #require(formatter.date(from: "2026-07-16T12:00:00Z")) + let snapshot = Self.coverageSnapshot( + dailyDays: ["2026-07-01", "2026-07-10", "2026-07-16"], + startDate: start, + endDate: end, + updatedAt: updatedAt) + + let cost = snapshot.toCostUsageTokenSnapshot(historyDays: 7) + #expect(cost.historyDays == 7) + #expect(cost.historyLabel == nil) + #expect(cost.daily.map(\.date) == ["2026-07-10", "2026-07-16"]) + #expect(cost.last30DaysCostUSD == 2) + #expect(cost.last30DaysTokens == 2) + } + + @Test + func `empty current month still reports metadata coverage`() throws { + let formatter = ISO8601DateFormatter() + let start = try #require(formatter.date(from: "2026-07-01T00:00:00Z")) + let end = try #require(formatter.date(from: "2026-07-31T23:59:59Z")) + let updatedAt = try #require(formatter.date(from: "2026-07-02T12:00:00Z")) + let snapshot = Self.coverageSnapshot( + dailyDays: [], + startDate: start, + endDate: end, + updatedAt: updatedAt) + + #expect(snapshot.toCostUsageTokenSnapshot().historyDays == 2) + #expect(snapshot.toCostUsageTokenSnapshot().historyLabel == "This month") + } + + @Test(arguments: [ + "not-a-day", + "2026-07-01junk", + "2026-07-01", + "2026-02-30", + " 2026-07-01", + ]) + func `invalid coverage provenance fails closed after requested clamp`(day: String) { + let snapshot = Self.coverageSnapshot( + dailyDays: [day], + updatedAt: Date()) + + #expect(snapshot.toCostUsageTokenSnapshot(historyDays: 900).historyDays == 1) + } + + @Test + func `malformed nonzero row keeps requested window unavailable`() throws { + let updatedAt = try #require(ISO8601DateFormatter().date(from: "2026-07-16T12:00:00Z")) + let snapshot = Self.coverageSnapshot( + dailyDays: ["2026-07-01junk", "2026-07-16"], + updatedAt: updatedAt) + + let cost = snapshot.toCostUsageTokenSnapshot(historyDays: 1) + #expect(cost.historyDays == 1) + #expect(cost.daily.map(\.date) == ["2026-07-01junk", "2026-07-16"]) + #expect(cost.daily.allSatisfy { $0.costUSD == nil && $0.totalTokens == nil }) + #expect(cost.sessionCostUSD == nil) + #expect(cost.sessionTokens == nil) + #expect(cost.last30DaysCostUSD == nil) + #expect(cost.last30DaysTokens == nil) } @Test - func `clamps negative billing adjustments in cost token snapshot`() { + func `negative aggregate token counter fails closed despite equal signed net`() { + let snapshot = Self.tokenValidationSnapshot( + totalInputTokens: -5, + totalOutputTokens: 15, + daily: [Self.tokenBucket(day: "2026-07-16", inputTokens: 10)]) + + Self.expectTokenDataUnavailable(snapshot.toCostUsageTokenSnapshot()) + } + + @Test + func `negative daily token counter fails closed despite equal signed net`() { + let snapshot = Self.tokenValidationSnapshot( + totalInputTokens: 10, + daily: [Self.tokenBucket(day: "2026-07-16", inputTokens: 15, cachedTokens: -5)]) + + Self.expectTokenDataUnavailable(snapshot.toCostUsageTokenSnapshot()) + } + + @Test + func `negative model token counter fails closed despite equal signed net`() { + let snapshot = Self.tokenValidationSnapshot( + totalInputTokens: 10, + daily: [ + Self.tokenBucket( + day: "2026-07-16", + inputTokens: 10, + modelInputTokens: 15, + modelOutputTokens: -5), + ]) + + Self.expectTokenDataUnavailable(snapshot.toCostUsageTokenSnapshot()) + } + + @Test + func `zero and positive token counters remain complete`() { + let snapshot = Self.tokenValidationSnapshot( + totalInputTokens: 4, + totalCachedTokens: 2, + totalOutputTokens: 4, + daily: [ + Self.tokenBucket(day: "2026-07-15", inputTokens: 0), + Self.tokenBucket(day: "2026-07-16", inputTokens: 4, cachedTokens: 2, outputTokens: 4), + ]) + + let cost = snapshot.toCostUsageTokenSnapshot() + #expect(cost.last30DaysTokens == 10) + #expect(cost.sessionTokens == 10) + #expect(cost.daily.map(\.totalTokens) == [0, 10]) + #expect(cost.daily.map { $0.modelBreakdowns?.first?.totalTokens } == [0, 10]) + #expect(cost.last30DaysCostUSD == 2) + } + + @Test + func `negative excluded cost bucket cannot prove selected empty window is zero`() throws { + let updatedAt = try #require(ISO8601DateFormatter().date(from: "2026-07-16T12:00:00Z")) + let snapshot = Self.costValidationSnapshot( + totalCost: 10, + daily: [ + Self.costBucket(day: "2026-07-14", cost: -5), + Self.costBucket(day: "2026-07-15", cost: 15), + ], + updatedAt: updatedAt) + + let cost = snapshot.toCostUsageTokenSnapshot(historyDays: 1) + #expect(cost.daily.isEmpty) + #expect(!cost.historyCoverageIsEstablished) + #expect(cost.last30DaysCostUSD == nil) + #expect(cost.sessionCostUSD == nil) + #expect(cost.last30DaysTokens == nil) + } + + @Test + func `negative model cost invalidates cost proof while preserving valid tokens`() { + let snapshot = Self.costValidationSnapshot( + totalCost: 1, + totalInputTokens: 10, + daily: [ + Self.costBucket( + day: "2026-07-16", + cost: 1, + modelCosts: [-1, 2], + tokens: 10), + ]) + + let cost = snapshot.toCostUsageTokenSnapshot() + #expect(cost.last30DaysCostUSD == nil) + #expect(cost.sessionCostUSD == nil) + #expect(cost.daily.first?.costUSD == nil) + #expect(cost.daily.first?.modelBreakdowns?.allSatisfy { $0.costUSD == nil } == true) + #expect(cost.last30DaysTokens == 10) + #expect(cost.sessionTokens == 10) + } + + @Test + func `zero and positive costs remain complete`() { + let snapshot = Self.costValidationSnapshot( + totalCost: 2, + daily: [ + Self.costBucket(day: "2026-07-15", cost: 0), + Self.costBucket(day: "2026-07-16", cost: 2), + ]) + + let cost = snapshot.toCostUsageTokenSnapshot() + #expect(cost.last30DaysCostUSD == 2) + #expect(cost.sessionCostUSD == 2) + #expect(cost.daily.map(\.costUSD) == [0, 2]) + #expect(cost.daily.map { $0.modelBreakdowns?.first?.costUSD } == [0, 2]) + #expect(cost.last30DaysTokens == 0) + } + + @Test + func `negative billing adjustment fails closed in cost token snapshot`() { let now = Date(timeIntervalSince1970: 1_700_179_200) let snapshot = MistralUsageSnapshot( - totalCost: -2, + totalCost: -1.5, currency: "EUR", currencySymbol: "€", totalInputTokens: 100, @@ -257,14 +762,17 @@ struct MistralUsageSnapshotConversionTests { updatedAt: now) let cost = snapshot.toCostUsageTokenSnapshot() - #expect(cost.sessionCostUSD == 0) - #expect(cost.last30DaysCostUSD == 0) - #expect(cost.daily.first?.costUSD == 0) - #expect(cost.daily.first?.modelBreakdowns?.first?.costUSD == 0) + #expect(cost.sessionCostUSD == nil) + #expect(cost.last30DaysCostUSD == nil) + #expect(cost.daily.first?.costUSD == nil) + #expect(cost.daily.first?.modelBreakdowns?.first?.costUSD == nil) + #expect(cost.last30DaysTokens == 125) + #expect(cost.sessionTokens == 125) + #expect(snapshot.toUsageSnapshot().identity?.loginMethod == "API spend: €0.0000 this month") } @Test - func `preserves net monthly cost when billing includes credits`() { + func `credit adjusted window fails closed without changing primary monthly spend`() { let now = Date(timeIntervalSince1970: 1_700_179_200) let snapshot = MistralUsageSnapshot( totalCost: 8, @@ -295,9 +803,141 @@ struct MistralUsageSnapshotConversionTests { updatedAt: now) let cost = snapshot.toCostUsageTokenSnapshot() - #expect(cost.last30DaysCostUSD == 8) - #expect(cost.sessionCostUSD == 0) - #expect(cost.daily.map(\.costUSD) == [10, 0]) + #expect(cost.last30DaysCostUSD == nil) + #expect(cost.sessionCostUSD == nil) + #expect(cost.daily.map(\.costUSD) == [nil, nil]) + #expect(snapshot.toUsageSnapshot().identity?.loginMethod == "API spend: €8.0000 this month") + } + + private static func bucket(day: String) -> MistralDailyUsageBucket { + MistralDailyUsageBucket( + day: day, + cost: 1, + inputTokens: 1, + cachedTokens: 0, + outputTokens: 0, + models: []) + } + + private static func tokenBucket( + day: String, + inputTokens: Int, + cachedTokens: Int = 0, + outputTokens: Int = 0, + modelInputTokens: Int? = nil, + modelCachedTokens: Int? = nil, + modelOutputTokens: Int? = nil) -> MistralDailyUsageBucket + { + MistralDailyUsageBucket( + day: day, + cost: 1, + inputTokens: inputTokens, + cachedTokens: cachedTokens, + outputTokens: outputTokens, + models: [ + .init( + name: "test-model", + cost: 1, + inputTokens: modelInputTokens ?? inputTokens, + cachedTokens: modelCachedTokens ?? cachedTokens, + outputTokens: modelOutputTokens ?? outputTokens), + ]) + } + + private static func costBucket( + day: String, + cost: Double, + modelCosts: [Double]? = nil, + tokens: Int = 0) -> MistralDailyUsageBucket + { + let costs = modelCosts ?? [cost] + return MistralDailyUsageBucket( + day: day, + cost: cost, + inputTokens: tokens, + cachedTokens: 0, + outputTokens: 0, + models: costs.enumerated().map { index, modelCost in + .init( + name: "test-model-\(index)", + cost: modelCost, + inputTokens: index == 0 ? tokens : 0, + cachedTokens: 0, + outputTokens: 0) + }) + } + + private static func costValidationSnapshot( + totalCost: Double, + totalInputTokens: Int = 0, + daily: [MistralDailyUsageBucket], + updatedAt: Date = Date(timeIntervalSince1970: 1_784_179_200)) -> MistralUsageSnapshot + { + MistralUsageSnapshot( + totalCost: totalCost, + currency: "EUR", + currencySymbol: "€", + totalInputTokens: totalInputTokens, + totalOutputTokens: 0, + totalCachedTokens: 0, + modelCount: daily.flatMap(\.models).count, + daily: daily, + startDate: nil, + endDate: nil, + updatedAt: updatedAt) + } + + private static func tokenValidationSnapshot( + totalInputTokens: Int, + totalCachedTokens: Int = 0, + totalOutputTokens: Int = 0, + daily: [MistralDailyUsageBucket]) -> MistralUsageSnapshot + { + MistralUsageSnapshot( + totalCost: Double(daily.count), + currency: "EUR", + currencySymbol: "€", + totalInputTokens: totalInputTokens, + totalOutputTokens: totalOutputTokens, + totalCachedTokens: totalCachedTokens, + modelCount: 1, + daily: daily, + startDate: nil, + endDate: nil, + updatedAt: Date(timeIntervalSince1970: 1_784_179_200)) + } + + private static func expectTokenDataUnavailable(_ snapshot: CostUsageTokenSnapshot) { + #expect(snapshot.last30DaysTokens == nil) + #expect(snapshot.sessionTokens == nil) + #expect(snapshot.daily.allSatisfy { + $0.inputTokens == nil + && $0.cacheReadTokens == nil + && $0.outputTokens == nil + && $0.totalTokens == nil + && $0.modelBreakdowns?.allSatisfy { $0.totalTokens == nil } == true + }) + #expect(snapshot.last30DaysCostUSD == 1) + } + + private static func coverageSnapshot( + dailyDays: [String], + startDate: Date? = nil, + endDate: Date? = nil, + updatedAt: Date) -> MistralUsageSnapshot + { + MistralUsageSnapshot( + totalCost: Double(dailyDays.count), + currency: "EUR", + currencySymbol: "€", + totalInputTokens: dailyDays.count, + totalOutputTokens: 0, + totalCachedTokens: 0, + modelCount: dailyDays.isEmpty ? 0 : 1, + daily: dailyDays.map(self.bucket(day:)), + startDate: startDate, + endDate: endDate, + updatedAt: updatedAt) } } diff --git a/Tests/CodexBarTests/MistralVibeUsageTests.swift b/Tests/CodexBarTests/MistralVibeUsageTests.swift new file mode 100644 index 0000000000..87e78dbcf8 --- /dev/null +++ b/Tests/CodexBarTests/MistralVibeUsageTests.swift @@ -0,0 +1,357 @@ +import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif +import Testing +@testable import CodexBarCore + +private final class MistralRequestCapture: @unchecked Sendable { + private let lock = NSLock() + private var storedRequest: URLRequest? + + var request: URLRequest? { + self.lock.withLock { self.storedRequest } + } + + func record(_ request: URLRequest) { + self.lock.withLock { self.storedRequest = request } + } +} + +private final class MistralRequestPathLog: @unchecked Sendable { + private let lock = NSLock() + private var storedPaths: [String] = [] + + var paths: [String] { + self.lock.withLock { self.storedPaths } + } + + func record(_ request: URLRequest) { + let host = request.url?.host ?? "" + let path = request.url?.path ?? "" + self.lock.withLock { + self.storedPaths.append("\(host)\(path)") + } + } +} + +private final class MistralCookieHeaderLog: @unchecked Sendable { + private let lock = NSLock() + private var storedHeaders: [String] = [] + + var headers: [String] { + self.lock.withLock { self.storedHeaders } + } + + func record(_ request: URLRequest) { + self.lock.withLock { + self.storedHeaders.append(request.value(forHTTPHeaderField: "Cookie") ?? "") + } + } +} + +struct MistralVibeUsageTests { + #if os(macOS) + @Test + func `cookie importer uses only accepted Mistral domains`() { + #expect(Set(MistralCookieImporter.cookieDomains) == [ + "mistral.ai", + "admin.mistral.ai", + "auth.mistral.ai", + "console.mistral.ai", + ]) + + let referenceDate = Date(timeIntervalSince1970: 1_700_000_000) + let query = MistralCookieImporter.cookieQuery(referenceDate: referenceDate) + #expect(query.domains == MistralCookieImporter.cookieDomains) + #expect(query.includeExpired == false) + #expect(query.referenceDate == referenceDate) + guard case .exact = query.domainMatch else { + Issue.record("Expected exact Mistral cookie-domain matching") + return + } + } + + @Test + func `tries later browser sessions after invalid credentials`() async throws { + let headerLog = MistralCookieHeaderLog() + let usageData = Data(Self.billingUsageResponseJSON.utf8) + let sessions = try [ + Self.session(cookieName: "ory_session_chrome", value: "stale", sourceLabel: "Chrome"), + Self.session(cookieName: "ory_session_firefox", value: "stale", sourceLabel: "Firefox"), + Self.session(cookieName: "ory_session_safari", value: "valid", sourceLabel: "Safari"), + ] + let transport = ProviderHTTPTransportHandler { request in + guard let url = request.url else { throw URLError(.badURL) } + if url.host == "admin.mistral.ai", url.path == "/api/billing/v2/usage" { + headerLog.record(request) + let cookieHeader = request.value(forHTTPHeaderField: "Cookie") ?? "" + let statusCode = cookieHeader.contains("ory_session_safari=valid") ? 200 : 401 + return try (usageData, Self.response(url: url, statusCode: statusCode)) + } + return try (Data(), Self.response(url: url, statusCode: 404)) + } + + let (_, session) = try await MistralWebFetchStrategy.fetchUsageFromSessions( + sessions, + timeout: 2, + transport: transport) + + #expect(session.sourceLabel == "Safari") + #expect(headerLog.headers == [ + "ory_session_chrome=stale", + "ory_session_firefox=stale", + "ory_session_safari=valid", + ]) + } + #endif + + @Test + func `parses subscription percentage and reset`() throws { + let data = Data(Self.responseJSON(usagePercentage: 2.8141356666666666).utf8) + + let result = try MistralUsageFetcher.parseVibeUsage(data: data) + + #expect(result.usagePercentage == 2.8141356666666666) + #expect(result.resetAt == ISO8601DateFormatter().date(from: "2026-07-01T00:00:00Z")) + } + + @Test + func `rejects subscription percentages outside rate window range`() { + let data = Data(Self.responseJSON(usagePercentage: 101).utf8) + + #expect(throws: MistralUsageError.self) { + try MistralUsageFetcher.parseVibeUsage(data: data) + } + } + + @Test + func `subscription request sends only csrf cookie`() async throws { + let capture = MistralRequestCapture() + let data = Data(Self.responseJSON(usagePercentage: 12.5).utf8) + let transport = ProviderHTTPTransportHandler { request in + capture.record(request) + guard let url = request.url, + let response = HTTPURLResponse( + url: url, + statusCode: 200, + httpVersion: nil, + headerFields: nil) + else { + throw URLError(.badURL) + } + return (data, response) + } + + let result = try await MistralUsageFetcher.fetchVibeUsage( + csrfToken: " csrf-value ", + timeout: 2, + transport: transport) + let request = try #require(capture.request) + + #expect(result.usagePercentage == 12.5) + #expect(request.url?.host == "console.mistral.ai") + #expect(request.timeoutInterval == 2) + #expect(request.httpShouldHandleCookies == false) + #expect(request.value(forHTTPHeaderField: "Cookie") == "csrftoken=csrf-value") + #expect(request.value(forHTTPHeaderField: "X-CSRFToken") == "csrf-value") + #expect(request.allHTTPHeaderFields?.values.contains { $0.contains("ory_session") } != true) + } + + @Test + func `rejects csrf values that could add cookies or headers`() { + #expect(throws: MistralUsageError.self) { + try MistralUsageFetcher.vibeCookieHeader(csrfToken: "csrf; ory_session_secret=leak") + } + #expect(throws: MistralUsageError.self) { + try MistralUsageFetcher.vibeCookieHeader(csrfToken: "csrf\r\nX-Leak: value") + } + } + + @Test + func `optional subscription request propagates in flight cancellation`() async throws { + let started = AsyncStream.makeStream(of: Void.self) + let transport = ProviderHTTPTransportHandler { _ in + started.continuation.yield(()) + try await Task.sleep(for: .seconds(30)) + throw URLError(.timedOut) + } + let task = Task { + try await MistralWebFetchStrategy.fetchOptionalVibeUsage( + csrfToken: "csrf-value", + timeout: 30, + transport: transport) + } + + var iterator = started.stream.makeAsyncIterator() + _ = await iterator.next() + task.cancel() + + await #expect(throws: CancellationError.self) { + try await task.value + } + started.continuation.finish() + } + + @Test + func `optional subscription request ignores ordinary endpoint failures`() async throws { + let transport = ProviderHTTPTransportHandler { _ in + throw URLError(.cannotConnectToHost) + } + + let result = try await MistralWebFetchStrategy.fetchOptionalVibeUsage( + csrfToken: "csrf-value", + timeout: 2, + transport: transport) + + #expect(result == nil) + } + + @Test + func `combined fetch preserves monthly plan when optional credits time out`() async throws { + let requestLog = MistralRequestPathLog() + let usageData = Data(Self.billingUsageResponseJSON.utf8) + let vibeData = Data(Self.responseJSON(usagePercentage: 37).utf8) + let transport = ProviderHTTPTransportHandler { request in + requestLog.record(request) + guard let url = request.url else { throw URLError(.badURL) } + if url.host == "admin.mistral.ai", url.path == "/api/billing/v2/usage" { + let response = try Self.response(url: url, statusCode: 200) + return (usageData, response) + } + if url.host == "console.mistral.ai" { + let response = try Self.response(url: url, statusCode: 200) + return (vibeData, response) + } + if url.host == "admin.mistral.ai", url.path == "/api/billing/credits" { + try await Task.sleep(for: .milliseconds(25)) + throw URLError(.timedOut) + } + throw URLError(.badURL) + } + + let snapshot = try await MistralWebFetchStrategy.fetchUsageWithVibe( + cookieHeader: "ory_session_test=abc; csrftoken=csrf", + csrfToken: "csrf", + timeout: 1, + transport: transport) + + let monthlyPlan = snapshot.extraRateWindows?.first { $0.id == "mistral-monthly-plan" } + #expect(monthlyPlan?.window.usedPercent == 37) + #expect(snapshot.mistralUsage?.credits == nil) + #expect(requestLog.paths == [ + "admin.mistral.ai/api/billing/v2/usage", + "console.mistral.ai/api-ui/trpc/billing.vibeUsage", + "admin.mistral.ai/api/billing/credits", + ]) + } + + @Test + func `monthly plan window preserves existing extras`() { + let existing = NamedRateWindow( + id: "existing", + title: "Existing", + window: RateWindow(usedPercent: 5, windowMinutes: nil, resetsAt: nil, resetDescription: nil)) + let usage = UsageSnapshot( + primary: nil, + secondary: nil, + extraRateWindows: [existing], + updatedAt: Date()) + + let updated = MistralWebFetchStrategy.attachVibeWindow( + to: usage, + vibeResult: .init(usagePercentage: 25, resetAt: nil)) + + #expect(updated.extraRateWindows?.map(\.id) == ["existing", "mistral-monthly-plan"]) + #expect(updated.extraRateWindows?.last?.window.usedPercent == 25) + } + + // MARK: - consoleCookieHeader allowlist + + @Test + func `console cookie header contains only csrf when no admin header`() { + let cookie = MistralUsageFetcher.consoleCookieHeader(csrfToken: "tok", adminCookieHeader: nil) + #expect(cookie == "csrftoken=tok") + } + + @Test + func `console cookie header forwards ory session alongside csrf`() { + let admin = "csrftoken=tok; ory_session_coolcurranf83m3srkfl=sess123; other_admin=secret" + let cookie = MistralUsageFetcher.consoleCookieHeader(csrfToken: "tok", adminCookieHeader: admin) + #expect(cookie == "csrftoken=tok; ory_session_coolcurranf83m3srkfl=sess123") + } + + @Test + func `console cookie header excludes non-session admin cookies`() { + let admin = "csrftoken=tok; session_token=other; admin_secret=x" + let cookie = MistralUsageFetcher.consoleCookieHeader(csrfToken: "tok", adminCookieHeader: admin) + #expect(cookie == "csrftoken=tok") + #expect(!cookie.contains("admin_secret")) + #expect(!cookie.contains("session_token")) + } + + @Test + func `console cookie header forwards multiple ory session cookies`() { + let admin = "ory_session_a=val1; ory_session_b=val2; unrelated=drop" + let cookie = MistralUsageFetcher.consoleCookieHeader(csrfToken: "tok", adminCookieHeader: admin) + #expect(cookie.contains("csrftoken=tok")) + #expect(cookie.contains("ory_session_a=val1")) + #expect(cookie.contains("ory_session_b=val2")) + #expect(!cookie.contains("unrelated")) + } + + private static func responseJSON(usagePercentage: Double) -> String { + """ + [{"result":{"data":{"json":{ + "usage_percentage":\(usagePercentage), + "quota_changed_this_month":false, + "payg_enabled":false, + "reset_at":"2026-07-01T00:00:00Z" + }}}}] + """ + } + + #if os(macOS) + private static func session(cookieName: String, value: String, sourceLabel: String) throws + -> MistralCookieImporter.SessionInfo + { + let cookie = try #require(HTTPCookie(properties: [ + .domain: "admin.mistral.ai", + .path: "/", + .name: cookieName, + .value: value, + ])) + return MistralCookieImporter.SessionInfo(cookies: [cookie], sourceLabel: sourceLabel) + } + #endif + + private static var billingUsageResponseJSON: String { + """ + { + "completion": {"models": {}}, + "ocr": {"models": {}}, + "connectors": {"models": {}}, + "libraries_api": {"pages": {"models": {}}, "tokens": {"models": {}}}, + "fine_tuning": {"training": {}, "storage": {}}, + "audio": {"models": {}}, + "vibe_usage": 0.0, + "date": "2026-02-01T00:00:00Z", + "previous_month": "2026-01", + "next_month": "2026-03", + "start_date": "2026-02-01T00:00:00Z", + "end_date": "2026-02-28T23:59:59.999Z", + "currency": "USD", + "currency_symbol": "$", + "prices": [] + } + """ + } + + private static func response(url: URL, statusCode: Int) throws -> HTTPURLResponse { + try #require(HTTPURLResponse( + url: url, + statusCode: statusCode, + httpVersion: nil, + headerFields: nil)) + } +} diff --git a/Tests/CodexBarTests/ModelsDevPricingTests.swift b/Tests/CodexBarTests/ModelsDevPricingTests.swift index 3a4d1807df..a0da2f6370 100644 --- a/Tests/CodexBarTests/ModelsDevPricingTests.swift +++ b/Tests/CodexBarTests/ModelsDevPricingTests.swift @@ -37,18 +37,18 @@ struct ModelsDevPricingTests { } @Test - func `supports provider scoped alias normalization`() throws { + func `supports provider scoped model normalization`() throws { let catalog = try Self.fixtureCatalog() let anthropic = try #require(catalog.pricing( providerID: "anthropic", - modelID: "anthropic.us-east-1.claude-sonnet-4-6-v1:0")) + modelID: "us.anthropic.claude-sonnet-4-6")) let vertex = try #require(catalog.pricing( providerID: "google-vertex-anthropic", modelID: "claude-sonnet-4-6")) #expect(anthropic.normalizedModelID == "claude-sonnet-4-6") - #expect(vertex.normalizedModelID == "claude-sonnet-4-6@default") + #expect(vertex.normalizedModelID == "claude-sonnet-4-6") #expect(vertex.pricing.inputCostPerToken == 3.1 / 1_000_000.0) } @@ -150,9 +150,310 @@ struct ModelsDevPricingTests { #expect(lookup.pricing.inputCostPerToken == 0.15 / 1_000_000.0) } +} + +extension ModelsDevPricingTests { + @Test + func `unknown model refresh makes newly published pricing available`() async throws { + let root = try Self.cacheRoot() + let now = Date(timeIntervalSince1970: 10000) + try ModelsDevCache.save( + catalog: Self.fixtureCatalog(), + fetchedAt: now.addingTimeInterval(-901), + cacheRoot: root) + let refreshed = Data(""" + { + "openai": { + "id": "openai", + "models": { + "gpt-new": { "id": "gpt-new", "cost": { "input": 2, "output": 8 } } + } + }, + "anthropic": { + "id": "anthropic", + "models": { + "claude-new": { "id": "claude-new", "cost": { "input": 3, "output": 15 } } + } + } + } + """.utf8) + let transport = TrackingTransport(result: .success((refreshed, Self.response(status: 200)))) + let client = ModelsDevClient(transport: transport) + + let outcome = await ModelsDevPricingPipeline.refreshForUnknownModelsIfNeeded( + providerID: "openai", + modelIDs: ["gpt-new"], + now: now, + cacheRoot: root, + client: client) + #expect(outcome == .pricingAvailable) + #expect(transport.calls == 1) + #expect(ModelsDevPricingPipeline.lookup( + providerID: "openai", + modelID: "gpt-new", + cacheRoot: root) != nil) + } + + @Test + func `unknown model refresh is bounded per provider cache`() async throws { + let root = try Self.cacheRoot() + let now = Date(timeIntervalSince1970: 20000) + try ModelsDevCache.save( + catalog: Self.fixtureCatalog(), + fetchedAt: now.addingTimeInterval(-901), + cacheRoot: root) + let transport = try TrackingTransport(result: .success(( + JSONEncoder().encode(Self.fixtureCatalog()), + Self.response(status: 200)))) + let client = ModelsDevClient(transport: transport) + + let first = await ModelsDevPricingPipeline.refreshForUnknownModelsIfNeeded( + providerID: "openai", + modelIDs: ["still-unknown"], + now: now, + cacheRoot: root, + client: client) + let second = await ModelsDevPricingPipeline.refreshForUnknownModelsIfNeeded( + providerID: "openai", + modelIDs: ["another-unknown-model"], + now: now.addingTimeInterval(60), + cacheRoot: root, + client: client) + + #expect(first == .unavailable) + #expect(second == .unavailable) + #expect(transport.calls == 1) + } + + @Test + func `known requested model does not mask an unresolved unknown model`() async throws { + let root = try Self.cacheRoot() + let now = Date(timeIntervalSince1970: 25000) + let catalog = try Self.catalog(""" + { + "openai": { + "id": "openai", + "models": { + "already-priced": { "id": "already-priced", "cost": { "input": 1, "output": 2 } } + } + }, + "anthropic": { + "id": "anthropic", + "models": { + "catalog-anchor": { "id": "catalog-anchor", "cost": { "input": 3, "output": 4 } } + } + } + } + """) + ModelsDevCache.save( + catalog: catalog, + fetchedAt: now.addingTimeInterval(-901), + cacheRoot: root) + let transport = try TrackingTransport(result: .success(( + JSONEncoder().encode(catalog), + Self.response(status: 200)))) + + let outcome = await ModelsDevPricingPipeline.refreshForUnknownModelsIfNeeded( + providerID: "openai", + modelIDs: ["already-priced", "still-unknown"], + now: now, + cacheRoot: root, + client: ModelsDevClient(transport: transport)) + + #expect(outcome == .unavailable) + #expect(transport.calls == 1) + } + + @Test + func `pricing added by a completed background refresh requests a rescan`() async throws { + let root = try Self.cacheRoot() + let now = Date(timeIntervalSince1970: 30000) + let refreshed = Data(""" + { + "openai": { + "id": "openai", + "models": { "gpt-new": { "id": "gpt-new", "cost": { "input": 2, "output": 8 } } } + }, + "anthropic": { + "id": "anthropic", + "models": { "claude-new": { "id": "claude-new", "cost": { "input": 3, "output": 15 } } } + } + } + """.utf8) + let refreshedCatalog = try JSONDecoder().decode(ModelsDevCatalog.self, from: refreshed) + ModelsDevCache.save(catalog: refreshedCatalog, fetchedAt: now, cacheRoot: root) + let transport = TrackingTransport(result: .failure(MockError.failed)) + + let outcome = await ModelsDevPricingPipeline.refreshForUnknownModelsIfNeeded( + providerID: "openai", + modelIDs: ["gpt-new"], + now: now, + cacheRoot: root, + client: ModelsDevClient(transport: transport)) + + #expect(outcome == .pricingAvailable) + #expect(transport.calls == 0) + } + + @Test + func `ttl and unknown model refreshes share one download`() async throws { + let root = try Self.cacheRoot() + let now = Date(timeIntervalSince1970: 40000) + try ModelsDevCache.save( + catalog: Self.fixtureCatalog(), + fetchedAt: now.addingTimeInterval(-ModelsDevCache.ttlSeconds - 1), + cacheRoot: root) + let transport = try TrackingTransport( + result: .success((JSONEncoder().encode(Self.fixtureCatalog()), Self.response(status: 200))), + delayNanoseconds: 100_000_000) + let client = ModelsDevClient(transport: transport) + + async let ttl: Void = ModelsDevPricingPipeline.refreshIfNeeded( + now: now, + cacheRoot: root, + client: client) + async let unknown = ModelsDevPricingPipeline.refreshForUnknownModelsIfNeeded( + providerID: "openai", + modelIDs: ["still-unknown"], + now: now, + cacheRoot: root, + client: client) + _ = await (ttl, unknown) + + #expect(transport.calls == 1) + } + + @Test + func `completed ttl refresh bounds a following unknown model refresh`() async throws { + let root = try Self.cacheRoot() + let now = Date(timeIntervalSince1970: 45000) + try ModelsDevCache.save( + catalog: Self.fixtureCatalog(), + fetchedAt: now.addingTimeInterval(-ModelsDevCache.ttlSeconds - 1), + cacheRoot: root) + let transport = try TrackingTransport(result: .success(( + JSONEncoder().encode(Self.fixtureCatalog()), + Self.response(status: 200)))) + let client = ModelsDevClient(transport: transport) + + await ModelsDevPricingPipeline.refreshIfNeeded( + now: now, + cacheRoot: root, + client: client) + let outcome = await ModelsDevPricingPipeline.refreshForUnknownModelsIfNeeded( + providerID: "openai", + modelIDs: ["still-unknown"], + now: now, + cacheRoot: root, + client: client) + + #expect(outcome == .unavailable) + #expect(transport.calls == 1) + } + + @Test + func `failed ttl refresh bounds a following unknown model refresh within cooldown`() async throws { + let root = try Self.cacheRoot() + let now = Date(timeIntervalSince1970: 46000) + try ModelsDevCache.save( + catalog: Self.fixtureCatalog(), + fetchedAt: now.addingTimeInterval(-ModelsDevCache.ttlSeconds - 1), + cacheRoot: root) + let transport = TrackingTransport(result: .failure(MockError.failed)) + let client = ModelsDevClient(transport: transport) + + await ModelsDevPricingPipeline.refreshIfNeeded( + now: now, + cacheRoot: root, + client: client) + let outcome = await ModelsDevPricingPipeline.refreshForUnknownModelsIfNeeded( + providerID: "openai", + modelIDs: ["still-unknown"], + now: now, + cacheRoot: root, + client: client) + + #expect(outcome == .unavailable) + #expect(transport.calls == 1) + } + + @Test + func `failed unknown model refresh bounds a following ttl refresh within cooldown`() async throws { + let root = try Self.cacheRoot() + let now = Date(timeIntervalSince1970: 47000) + try ModelsDevCache.save( + catalog: Self.fixtureCatalog(), + fetchedAt: now.addingTimeInterval(-ModelsDevCache.ttlSeconds - 1), + cacheRoot: root) + let transport = TrackingTransport(result: .failure(MockError.failed)) + let client = ModelsDevClient(transport: transport) + + let outcome = await ModelsDevPricingPipeline.refreshForUnknownModelsIfNeeded( + providerID: "openai", + modelIDs: ["still-unknown"], + now: now, + cacheRoot: root, + client: client) + await ModelsDevPricingPipeline.refreshIfNeeded( + now: now, + cacheRoot: root, + client: client) + + #expect(outcome == .unavailable) + #expect(transport.calls == 1) + } @Test - func `refresh preserves cache when fetched catalog drops cached model`() async throws { + func `ttl refresh rechecks cache freshness after coordination`() async throws { + let root = try Self.cacheRoot() + let now = Date(timeIntervalSince1970: 48000) + try ModelsDevCache.save( + catalog: Self.fixtureCatalog(), + fetchedAt: now.addingTimeInterval(-ModelsDevCache.ttlSeconds - 1), + cacheRoot: root) + #expect(ModelsDevCache.load(now: now, cacheRoot: root).isStale) + + try ModelsDevCache.save(catalog: Self.fixtureCatalog(), fetchedAt: now, cacheRoot: root) + let transport = TrackingTransport(result: .failure(MockError.failed)) + let cacheIsCurrent = await ModelsDevPricingPipeline.refreshStaleCache( + now: now, + cacheRoot: root, + client: ModelsDevClient(transport: transport)) + + #expect(cacheIsCurrent) + #expect(transport.calls == 0) + } + + @Test + func `failed cache save does not report pricing available`() async { + let root = URL(fileURLWithPath: "/dev/null", isDirectory: true) + let now = Date(timeIntervalSince1970: 50000) + let refreshed = Data(""" + { + "openai": { + "id": "openai", + "models": { "gpt-new": { "id": "gpt-new", "cost": { "input": 2, "output": 8 } } } + }, + "anthropic": { + "id": "anthropic", + "models": { "claude-new": { "id": "claude-new", "cost": { "input": 3, "output": 15 } } } + } + } + """.utf8) + + let outcome = await ModelsDevPricingPipeline.refreshForUnknownModelsIfNeeded( + providerID: "openai", + modelIDs: ["gpt-new"], + now: now, + cacheRoot: root, + client: ModelsDevClient(transport: MockTransport( + result: .success((refreshed, Self.response(status: 200)))))) + + #expect(outcome == .unavailable) + } + + @Test + func `refresh accepts model churn and preserves removed pricing as fallback`() async throws { let root = try Self.cacheRoot() let old = Date(timeIntervalSince1970: 1) try ModelsDevCache.save(catalog: Self.fixtureCatalog(), fetchedAt: old, cacheRoot: root) @@ -165,6 +466,10 @@ struct ModelsDevPricingTests { "shared-model": { "id": "shared-model", "cost": { "input": 99, "output": 99 } + }, + "provider-a-new": { + "id": "provider-a-new", + "cost": { "input": 7, "output": 8 } } } }, @@ -180,8 +485,8 @@ struct ModelsDevPricingTests { "google-vertex-anthropic": { "id": "google-vertex-anthropic", "models": { - "claude-sonnet-4-6@default": { - "id": "claude-sonnet-4-6@default", + "claude-sonnet-4-6": { + "id": "claude-sonnet-4-6", "cost": { "input": 99, "output": 99 } } } @@ -194,12 +499,154 @@ struct ModelsDevPricingTests { client: ModelsDevClient(transport: MockTransport( result: .success((partialCatalog, Self.response(status: 200)))))) - let lookup = try #require(ModelsDevPricingPipeline.lookup( + let oldLookup = try #require(ModelsDevPricingPipeline.lookup( providerID: "openai", modelID: "gpt-4o-mini", cacheRoot: root)) + let newLookup = try #require(ModelsDevPricingPipeline.lookup( + providerID: "openai", + modelID: "provider-a-new", + cacheRoot: root)) + let updatedLookup = try #require(ModelsDevPricingPipeline.lookup( + providerID: "openai", + modelID: "shared-model", + cacheRoot: root)) - #expect(lookup.pricing.inputCostPerToken == 0.15 / 1_000_000.0) + #expect(oldLookup.pricing.inputCostPerToken == 0.15 / 1_000_000.0) + #expect(newLookup.pricing.inputCostPerToken == 7 / 1_000_000.0) + #expect(updatedLookup.pricing.inputCostPerToken == 99 / 1_000_000.0) + } + + @Test + func `accumulated fallback models do not freeze later refreshes`() async throws { + let root = try Self.cacheRoot() + let old = Date(timeIntervalSince1970: 1) + let cachedCatalog = try Self.catalog(""" + { + "openai": { + "id": "openai", + "models": { + "provider-a-old": { "id": "provider-a-old", "cost": { "input": 1, "output": 2 } } + } + }, + "anthropic": { + "id": "anthropic", + "models": { + "provider-b-old": { "id": "provider-b-old", "cost": { "input": 3, "output": 4 } } + } + }, + "stale-a": { + "id": "stale-a", + "models": { + "model-a": { "id": "model-a", "cost": { "input": 5, "output": 6 } } + } + }, + "stale-b": { + "id": "stale-b", + "models": { + "model-b": { "id": "model-b", "cost": { "input": 7, "output": 8 } } + } + }, + "stale-c": { + "id": "stale-c", + "models": { + "model-c": { "id": "model-c", "cost": { "input": 9, "output": 10 } } + } + } + } + """) + ModelsDevCache.save(catalog: cachedCatalog, fetchedAt: old, cacheRoot: root) + + let fetchedCatalog = Data(""" + { + "openai": { + "id": "openai", + "models": { + "provider-a-new": { "id": "provider-a-new", "cost": { "input": 11, "output": 12 } } + } + }, + "anthropic": { + "id": "anthropic", + "models": { + "provider-b-new": { "id": "provider-b-new", "cost": { "input": 13, "output": 14 } } + } + } + } + """.utf8) + await ModelsDevPricingPipeline.refreshIfNeeded( + now: Date(timeIntervalSince1970: 1 + ModelsDevCache.ttlSeconds + 1), + cacheRoot: root, + client: ModelsDevClient(transport: MockTransport( + result: .success((fetchedCatalog, Self.response(status: 200)))))) + + let newLookup = try #require(ModelsDevPricingPipeline.lookup( + providerID: "openai", + modelID: "provider-a-new", + cacheRoot: root)) + let fallbackLookup = try #require(ModelsDevPricingPipeline.lookup( + providerID: "stale-a", + modelID: "model-a", + cacheRoot: root)) + + #expect(newLookup.pricing.inputCostPerToken == 11 / 1_000_000.0) + #expect(fallbackLookup.pricing.inputCostPerToken == 5 / 1_000_000.0) + } + + @Test + func `historical fallback does not overwrite a refreshed model that reuses its map key`() async throws { + let root = try Self.cacheRoot() + let old = Date(timeIntervalSince1970: 1) + let cachedCatalog = try Self.catalog(""" + { + "openai": { + "id": "openai", + "models": { + "rolling": { "id": "provider-a-old", "cost": { "input": 1, "output": 2 } } + } + }, + "anthropic": { + "id": "anthropic", + "models": { + "provider-b-anchor": { "id": "provider-b-anchor", "cost": { "input": 3, "output": 4 } } + } + } + } + """) + ModelsDevCache.save(catalog: cachedCatalog, fetchedAt: old, cacheRoot: root) + + let fetchedCatalog = Data(""" + { + "openai": { + "id": "openai", + "models": { + "rolling": { "id": "provider-a-new", "cost": { "input": 99, "output": 100 } } + } + }, + "anthropic": { + "id": "anthropic", + "models": { + "provider-b-anchor": { "id": "provider-b-anchor", "cost": { "input": 3, "output": 4 } } + } + } + } + """.utf8) + await ModelsDevPricingPipeline.refreshIfNeeded( + now: Date(timeIntervalSince1970: 1 + ModelsDevCache.ttlSeconds + 1), + cacheRoot: root, + client: ModelsDevClient(transport: MockTransport( + result: .success((fetchedCatalog, Self.response(status: 200)))))) + + let freshLookup = try #require(ModelsDevPricingPipeline.lookup( + providerID: "openai", + modelID: "provider-a-new", + cacheRoot: root)) + let fallbackLookup = try #require(ModelsDevPricingPipeline.lookup( + providerID: "openai", + modelID: "provider-a-old", + cacheRoot: root)) + + #expect(freshLookup.pricing.inputCostPerToken == 99 / 1_000_000.0) + #expect(fallbackLookup.pricing.inputCostPerToken == 1 / 1_000_000.0) } @Test @@ -213,7 +660,7 @@ struct ModelsDevPricingTests { "openai": { "id": "openai", "models": { - "gpt-4o-mini-renamed": { + "renamed-model-key": { "id": "gpt-4o-mini", "cost": { "input": 99, "output": 99 } }, @@ -239,8 +686,8 @@ struct ModelsDevPricingTests { "google-vertex-anthropic": { "id": "google-vertex-anthropic", "models": { - "claude-sonnet-4-6-renamed": { - "id": "claude-sonnet-4-6@default", + "renamed-vertex-key": { + "id": "claude-sonnet-4-6", "cost": { "input": 99, "output": 99 } } } @@ -299,8 +746,8 @@ struct ModelsDevPricingTests { "google-vertex-anthropic": { "id": "google-vertex-anthropic", "models": { - "claude-sonnet-4-6@default": { - "id": "claude-sonnet-4-6@default", + "claude-sonnet-4-6": { + "id": "claude-sonnet-4-6", "cost": { "input": 99, "output": 99 } } } @@ -317,15 +764,51 @@ struct ModelsDevPricingTests { providerID: "openai", modelID: "gpt-4o-mini", cacheRoot: root)) + let updatedLookup = try #require(ModelsDevPricingPipeline.lookup( + providerID: "openai", + modelID: "shared-model", + cacheRoot: root)) #expect(lookup.pricing.inputCostPerToken == 0.15 / 1_000_000.0) + #expect(updatedLookup.pricing.inputCostPerToken == 99 / 1_000_000.0) } @Test func `refresh updates cache when fetched catalog canonicalizes alias model id`() async throws { let root = try Self.cacheRoot() let old = Date(timeIntervalSince1970: 1) - try ModelsDevCache.save(catalog: Self.fixtureCatalog(), fetchedAt: old, cacheRoot: root) + let cachedCatalog = try Self.catalog(""" + { + "openai": { + "id": "openai", + "models": { + "gpt-4o-mini": { + "id": "gpt-4o-mini", + "cost": { "input": 0.15, "output": 0.6 } + } + } + }, + "anthropic": { + "id": "anthropic", + "models": { + "claude-sonnet-4-6": { + "id": "claude-sonnet-4-6", + "cost": { "input": 3, "output": 15 } + } + } + }, + "google-vertex-anthropic": { + "id": "google-vertex-anthropic", + "models": { + "snapshot-model@20250101": { + "id": "snapshot-model@20250101", + "cost": { "input": 3.1, "output": 15.1 } + } + } + } + } + """) + ModelsDevCache.save(catalog: cachedCatalog, fetchedAt: old, cacheRoot: root) let canonicalizedCatalog = Data(""" { @@ -358,8 +841,8 @@ struct ModelsDevPricingTests { "google-vertex-anthropic": { "id": "google-vertex-anthropic", "models": { - "claude-sonnet-4-6": { - "id": "claude-sonnet-4-6", + "snapshot-model-20250101": { + "id": "snapshot-model-20250101", "cost": { "input": 99, "output": 99 } } } @@ -372,21 +855,97 @@ struct ModelsDevPricingTests { client: ModelsDevClient(transport: MockTransport( result: .success((canonicalizedCatalog, Self.response(status: 200)))))) - let defaultLookup = try #require(ModelsDevPricingPipeline.lookup( + let aliasLookup = try #require(ModelsDevPricingPipeline.lookup( providerID: "google-vertex-anthropic", - modelID: "claude-sonnet-4-6@default", + modelID: "snapshot-model@20250101", cacheRoot: root)) - let baseLookup = try #require(ModelsDevPricingPipeline.lookup( + let canonicalLookup = try #require(ModelsDevPricingPipeline.lookup( providerID: "google-vertex-anthropic", - modelID: "claude-sonnet-4-6", + modelID: "snapshot-model-20250101", cacheRoot: root)) - #expect(defaultLookup.pricing.inputCostPerToken == 99 / 1_000_000.0) - #expect(baseLookup.pricing.inputCostPerToken == 99 / 1_000_000.0) + #expect(aliasLookup.pricing.inputCostPerToken == 99 / 1_000_000.0) + #expect(canonicalLookup.pricing.inputCostPerToken == 99 / 1_000_000.0) + } + + @Test + func `fallback merge treats default alias as the canonical base model`() throws { + let cachedCatalog = try Self.catalog(""" + { + "anthropic": { + "id": "anthropic", + "models": { + "base-model@default": { + "id": "base-model@default", + "cost": { "input": 3, "output": 15 } + } + } + } + } + """) + let refreshedCatalog = try Self.catalog(""" + { + "anthropic": { + "id": "anthropic", + "models": { + "base-model": { + "id": "base-model", + "cost": { "input": 99, "output": 100 } + } + } + } + } + """) + + let merged = refreshedCatalog.mergingFallbackPricing(from: cachedCatalog) + let aliasLookup = try #require(merged.pricing( + providerID: "anthropic", + modelID: "base-model@default")) + + #expect(aliasLookup.pricing.inputCostPerToken == 99 / 1_000_000.0) + #expect(merged.providers["anthropic"]?.models.count == 1) } @Test - func `refresh preserves cache when fetched catalog only has different pinned snapshot`() async throws { + func `fallback merge treats provider version alias as the canonical base model`() throws { + let cachedCatalog = try Self.catalog(""" + { + "openai": { + "id": "openai", + "models": { + "openai/base-model-v1:0": { + "id": "openai/base-model-v1:0", + "cost": { "input": 3, "output": 15 } + } + } + } + } + """) + let refreshedCatalog = try Self.catalog(""" + { + "openai": { + "id": "openai", + "models": { + "base-model": { + "id": "base-model", + "cost": { "input": 99, "output": 100 } + } + } + } + } + """) + + let merged = refreshedCatalog.mergingFallbackPricing(from: cachedCatalog) + let aliasLookup = try #require(merged.pricing( + providerID: "openai", + modelID: "openai/base-model-v1:0")) + + #expect(aliasLookup.pricing.inputCostPerToken == 99 / 1_000_000.0) + #expect(merged.providers["openai"]?.models.count == 1) + } + + @Test + func `refresh keeps historical pinned pricing while accepting a new snapshot`() async throws { let root = try Self.cacheRoot() let old = Date(timeIntervalSince1970: 1) let cachedCatalog = try Self.catalog(""" @@ -394,8 +953,8 @@ struct ModelsDevPricingTests { "google-vertex-anthropic": { "id": "google-vertex-anthropic", "models": { - "claude-sonnet-4@20250101": { - "id": "claude-sonnet-4@20250101", + "snapshot-model@20250101": { + "id": "snapshot-model@20250101", "cost": { "input": 3, "output": 15 } } } @@ -406,11 +965,29 @@ struct ModelsDevPricingTests { let fetchedCatalog = Data(""" { + "openai": { + "id": "openai", + "models": { + "provider-a-anchor": { + "id": "provider-a-anchor", + "cost": { "input": 1, "output": 2 } + } + } + }, + "anthropic": { + "id": "anthropic", + "models": { + "provider-b-anchor": { + "id": "provider-b-anchor", + "cost": { "input": 3, "output": 4 } + } + } + }, "google-vertex-anthropic": { "id": "google-vertex-anthropic", "models": { - "claude-sonnet-4@20250201": { - "id": "claude-sonnet-4@20250201", + "snapshot-model@20250201": { + "id": "snapshot-model@20250201", "cost": { "input": 99, "output": 99 } } } @@ -423,12 +1000,114 @@ struct ModelsDevPricingTests { client: ModelsDevClient(transport: MockTransport( result: .success((fetchedCatalog, Self.response(status: 200)))))) - let lookup = try #require(ModelsDevPricingPipeline.lookup( + let oldLookup = try #require(ModelsDevPricingPipeline.lookup( + providerID: "google-vertex-anthropic", + modelID: "snapshot-model@20250101", + cacheRoot: root)) + let newLookup = try #require(ModelsDevPricingPipeline.lookup( providerID: "google-vertex-anthropic", - modelID: "claude-sonnet-4@20250101", + modelID: "snapshot-model@20250201", cacheRoot: root)) + #expect(oldLookup.pricing.inputCostPerToken == 3 / 1_000_000.0) + #expect(newLookup.pricing.inputCostPerToken == 99 / 1_000_000.0) + } + + @Test + func `refresh preserves dated snapshot when fetched catalog only keeps base model`() async throws { + let root = try Self.cacheRoot() + let old = Date(timeIntervalSince1970: 1) + let cachedCatalog = try Self.catalog(""" + { + "openai": { + "id": "openai", + "models": { + "historical-map-key": { + "id": "snapshot-model-2025-01-01", + "cost": { "input": 3, "output": 15 } + } + } + }, + "anthropic": { + "id": "anthropic", + "models": { + "provider-b-anchor": { + "id": "provider-b-anchor", + "cost": { "input": 3, "output": 4 } + } + } + } + } + """) + ModelsDevCache.save(catalog: cachedCatalog, fetchedAt: old, cacheRoot: root) + + let fetchedCatalog = Data(""" + { + "openai": { + "id": "openai", + "models": { + "snapshot-model": { + "id": "snapshot-model", + "cost": { "input": 99, "output": 99 } + } + } + }, + "anthropic": { + "id": "anthropic", + "models": { + "provider-b-anchor": { + "id": "provider-b-anchor", + "cost": { "input": 3, "output": 4 } + } + } + } + } + """.utf8) + await ModelsDevPricingPipeline.refreshIfNeeded( + now: Date(timeIntervalSince1970: 1 + ModelsDevCache.ttlSeconds + 1), + cacheRoot: root, + client: ModelsDevClient(transport: MockTransport( + result: .success((fetchedCatalog, Self.response(status: 200)))))) + + let snapshotLookup = try #require(ModelsDevPricingPipeline.lookup( + providerID: "openai", + modelID: "snapshot-model-2025-01-01", + cacheRoot: root)) + let baseLookup = try #require(ModelsDevPricingPipeline.lookup( + providerID: "openai", + modelID: "snapshot-model", + cacheRoot: root)) + + #expect(snapshotLookup.pricing.inputCostPerToken == 3 / 1_000_000.0) + #expect(baseLookup.pricing.inputCostPerToken == 99 / 1_000_000.0) + } + + @Test + func `compact snapshot alias prefers snapshot pricing over base pricing`() throws { + let catalog = try Self.catalog(""" + { + "openai": { + "id": "openai", + "models": { + "snapshot-model": { + "id": "snapshot-model", + "cost": { "input": 99, "output": 100 } + }, + "snapshot-model-20250101": { + "id": "snapshot-model-20250101", + "cost": { "input": 3, "output": 15 } + } + } + } + } + """) + + let lookup = try #require(catalog.pricing( + providerID: "openai", + modelID: "snapshot-model@20250101")) + #expect(lookup.pricing.inputCostPerToken == 3 / 1_000_000.0) + #expect(lookup.normalizedModelID == "snapshot-model-20250101") } @Test @@ -444,8 +1123,8 @@ struct ModelsDevPricingTests { "id": "gpt-4o-mini", "cost": { "input": 0.15, "output": 0.6 } }, - "unpriced-preview": { - "id": "unpriced-preview" + "unpriced-model": { + "id": "unpriced-model" } } } @@ -463,6 +1142,15 @@ struct ModelsDevPricingTests { "cost": { "input": 99, "output": 99 } } } + }, + "anthropic": { + "id": "anthropic", + "models": { + "provider-b-anchor": { + "id": "provider-b-anchor", + "cost": { "input": 3, "output": 4 } + } + } } } """.utf8) @@ -508,6 +1196,72 @@ struct ModelsDevPricingTests { #expect(load.error == .invalidJSON) } + @Test + func `serves decoded catalog from memo while the file is unchanged`() throws { + let root = try Self.cacheRoot() + try ModelsDevCache.save(catalog: Self.fixtureCatalog(), fetchedAt: Date(), cacheRoot: root) + let url = ModelsDevCache.cacheFileURL(cacheRoot: root) + + // Pin a whole-second modification date so the memo key (which compares modification dates) round-trips + // deterministically through the filesystem. + let pinnedDate = Date(timeIntervalSince1970: 1_700_000_000) + try FileManager.default.setAttributes([.modificationDate: pinnedDate], ofItemAtPath: url.path) + + // Prime the in-memory memo with a successful decode. + let primed = ModelsDevCache.load(cacheRoot: root) + let cachedArtifact = try #require(primed.artifact) + + // Corrupt the file contents while preserving its size and modification date, so the on-disk identity + // the memo keys on is unchanged. A re-decode would now fail; a memo hit returns the cached artifact. + let size = try #require( + try (FileManager.default.attributesOfItem(atPath: url.path)[.size]) as? NSNumber).intValue + try Data(repeating: 0, count: size).write(to: url) + try FileManager.default.setAttributes([.modificationDate: pinnedDate], ofItemAtPath: url.path) + + let reloaded = ModelsDevCache.load(cacheRoot: root) + + #expect(reloaded.error == nil) + #expect(reloaded.artifact == cachedArtifact) + } + + @Test + func `saving a new catalog invalidates the memo`() throws { + let root = try Self.cacheRoot() + try ModelsDevCache.save(catalog: Self.fixtureCatalog(), fetchedAt: Date(), cacheRoot: root) + #expect(ModelsDevCache.load(cacheRoot: root).artifact?.catalog.providers["openai"] != nil) + + // Overwriting the cache must drop the memo so the next load reflects the freshly written catalog. + ModelsDevCache.save(catalog: ModelsDevCatalog(providers: [:]), fetchedAt: Date(), cacheRoot: root) + let reloaded = ModelsDevCache.load(cacheRoot: root) + + #expect(reloaded.error == nil) + #expect(reloaded.artifact?.catalog.providers.isEmpty == true) + } + + @Test + func `serves a failed load from memo while the file is unchanged`() throws { + let root = try Self.cacheRoot() + let url = ModelsDevCache.cacheFileURL(cacheRoot: root) + try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true) + let validData = try Self.encodedArtifactData() + + // Write invalid JSON of the same size as a valid encoding, with a pinned modification date, then prime + // the memo with the resulting failure. + let pinnedDate = Date(timeIntervalSince1970: 1_700_000_000) + try Data(repeating: 0x7B, count: validData.count).write(to: url) + try FileManager.default.setAttributes([.modificationDate: pinnedDate], ofItemAtPath: url.path) + #expect(ModelsDevCache.load(cacheRoot: root).error == .invalidJSON) + + // Replace the bytes with a valid encoding of identical size + modification date. A re-read would now + // succeed, so a returned failure proves the unchanged-identity file was not read and decoded again. + try validData.write(to: url) + try FileManager.default.setAttributes([.modificationDate: pinnedDate], ofItemAtPath: url.path) + let reloaded = ModelsDevCache.load(cacheRoot: root) + + #expect(reloaded.error == .invalidJSON) + #expect(reloaded.artifact == nil) + } + @Test func `client fetches with mock transport`() async throws { let data = try Self.fixtureData() @@ -515,7 +1269,7 @@ struct ModelsDevPricingTests { let catalog = try await client.fetchCatalog() - #expect(catalog.providers["google-vertex-anthropic"]?.models["claude-sonnet-4-6@default"]?.cost?.input == 3.1) + #expect(catalog.providers["google-vertex-anthropic"]?.models["claude-sonnet-4-6"]?.cost?.input == 3.1) } @Test @@ -545,6 +1299,17 @@ struct ModelsDevPricingTests { try JSONDecoder().decode(ModelsDevCatalog.self, from: self.fixtureData()) } + /// A valid `ModelsDevCacheArtifact` encoding, written the same way `ModelsDevCache.save` writes the file. + private static func encodedArtifactData() throws -> Data { + let artifact = try ModelsDevCacheArtifact( + version: ModelsDevCache.artifactVersion, + fetchedAt: Date(timeIntervalSince1970: 0), + catalog: self.fixtureCatalog()) + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + return try encoder.encode(artifact) + } + private static func catalog(_ json: String) throws -> ModelsDevCatalog { try JSONDecoder().decode(ModelsDevCatalog.self, from: Data(json.utf8)) } @@ -578,15 +1343,25 @@ private struct MockTransport: ModelsDevHTTPTransport { } private final class TrackingTransport: ModelsDevHTTPTransport, @unchecked Sendable { - private(set) var calls = 0 + private let lock = NSLock() + private var callCount = 0 let result: Result<(Data, URLResponse), Error> + let delayNanoseconds: UInt64 - init(result: Result<(Data, URLResponse), Error>) { + var calls: Int { + self.lock.withLock { self.callCount } + } + + init(result: Result<(Data, URLResponse), Error>, delayNanoseconds: UInt64 = 0) { self.result = result + self.delayNanoseconds = delayNanoseconds } func data(for _: URLRequest) async throws -> (Data, URLResponse) { - self.calls += 1 + self.lock.withLock { self.callCount += 1 } + if self.delayNanoseconds > 0 { + try await Task.sleep(nanoseconds: self.delayNanoseconds) + } return try self.result.get() } } diff --git a/Tests/CodexBarTests/MoonshotUsageFetcherTests.swift b/Tests/CodexBarTests/MoonshotUsageFetcherTests.swift index 7d69af2af9..fe42a7c8ce 100644 --- a/Tests/CodexBarTests/MoonshotUsageFetcherTests.swift +++ b/Tests/CodexBarTests/MoonshotUsageFetcherTests.swift @@ -189,7 +189,11 @@ struct MoonshotUsageFetcherTests { final class MoonshotStubURLProtocol: URLProtocol { nonisolated(unsafe) static var requests: [URLRequest] = [] - nonisolated(unsafe) static var handler: (@Sendable (URLRequest) throws -> (HTTPURLResponse, Data))? + private static let _handlerBox = LockIsolated<(@Sendable (URLRequest) throws -> (HTTPURLResponse, Data))?>(nil) + static var handler: (@Sendable (URLRequest) throws -> (HTTPURLResponse, Data))? { + get { Self._handlerBox.value } + set { Self._handlerBox.setValue(newValue) } + } override static func canInit(with _: URLRequest) -> Bool { true diff --git a/Tests/CodexBarTests/NeuralWattUsageFetcherTests.swift b/Tests/CodexBarTests/NeuralWattUsageFetcherTests.swift new file mode 100644 index 0000000000..e5a935baca --- /dev/null +++ b/Tests/CodexBarTests/NeuralWattUsageFetcherTests.swift @@ -0,0 +1,534 @@ +import Foundation +import Testing +@testable import CodexBarCore + +@Suite(.serialized) +struct NeuralWattUsageFetcherTests { + @Test + func `parses quota response into usage snapshot`() throws { + let body = #""" + { + "snapshot_at": "2026-04-16T18:30:00Z", + "balance": { + "credits_remaining_usd": 32.6774, + "total_credits_usd": 52.34, + "credits_used_usd": 19.6626, + "accounting_method": "energy" + }, + "usage": { + "lifetime": { + "cost_usd": 243.9145, + "requests": 37801, + "tokens": 1235477176, + "energy_kwh": 15.6009 + }, + "current_month": { + "cost_usd": 160.1463, + "requests": 23902, + "tokens": 1116658995, + "energy_kwh": 9.7278 + } + }, + "limits": { + "overage_limit_usd": null, + "rate_limit_tier": "standard" + }, + "subscription": { + "plan": "standard", + "status": "active", + "billing_interval": "month", + "current_period_start": "2026-04-11T05:05:25Z", + "current_period_end": "2026-05-11T05:05:25Z", + "auto_renew": true, + "kwh_included": 20.0, + "kwh_used": 13.9023, + "kwh_remaining": 6.0977, + "in_overage": false + }, + "key": { + "name": "my-production-key", + "allowance": { + "limit_usd": 50.0, + "period": "monthly", + "spent_usd": 12.5, + "remaining_usd": 37.5, + "blocked": false + } + } + } + """# + + let snapshot = try NeuralWattUsageFetcher._parseSnapshotForTesting( + Data(body.utf8), + updatedAt: Date(timeIntervalSince1970: 1)) + let usage = snapshot.toUsageSnapshot() + + #expect(snapshot.totalCreditsUSD == 52.34) + #expect(snapshot.creditsUsedUSD == 19.6626) + let expectedCreditPercent = 19.6626 / 52.34 * 100 + #expect(abs(snapshot.creditUsedPercent - expectedCreditPercent) < 1e-6) + #expect(snapshot.keyAllowanceUsedPercent == 25.0) + #expect(snapshot.currentMonthCostUSD == 160.1463) + let expectedSubscriptionPercent = 13.9023 / 20 * 100 + let primaryPercent = usage.primary?.usedPercent + #expect(primaryPercent.map { abs($0 - expectedSubscriptionPercent) < 1e-6 } == true) + #expect(usage.primary?.resetDescription == "13.90 / 20 kWh") + #expect(usage.primary?.resetsAt == snapshot.subscription?.currentPeriodEnd) + #expect(usage.subscriptionRenewsAt == snapshot.subscription?.currentPeriodEnd) + #expect(usage.providerCost?.used == 32.6774) + #expect(usage.providerCost?.period == "Neuralwatt prepaid balance") + #expect(usage.loginMethod(for: .neuralwatt) == "Standard plan") + #expect(usage.extraRateWindows?.count == 1) + #expect(usage.extraRateWindows?.contains { $0.id == "current-month-spend" } == false) + let allowanceWindow = usage.extraRateWindows?.first { $0.id == "key-allowance" } + #expect(allowanceWindow?.title == "Key Monthly") + } + + @Test + func `parses response with null subscription using accounting method`() throws { + let body = #""" + { + "snapshot_at": "2026-04-16T18:30:00Z", + "balance": { + "credits_remaining_usd": 4.5, + "total_credits_usd": 5.0, + "credits_used_usd": 0.5, + "accounting_method": "energy" + }, + "usage": { + "lifetime": {"cost_usd": 0.5, "requests": 10, "tokens": 1000, "energy_kwh": 0.01}, + "current_month": {"cost_usd": 0.5, "requests": 10, "tokens": 1000, "energy_kwh": 0.01} + }, + "limits": {"overage_limit_usd": null, "rate_limit_tier": "free"}, + "subscription": null, + "key": {"name": "trial", "allowance": null} + } + """# + + let snapshot = try NeuralWattUsageFetcher._parseSnapshotForTesting( + Data(body.utf8), + updatedAt: Date(timeIntervalSince1970: 100)) + let usage = snapshot.toUsageSnapshot() + + #expect(snapshot.creditUsedPercent == 10) + #expect(snapshot.subscription == nil) + #expect(snapshot.keyAllowanceUsedPercent == nil) + #expect(usage.primary == nil) + #expect(usage.providerCost?.used == 4.5) + #expect(usage.subscriptionRenewsAt == nil) + #expect(usage.loginMethod(for: .neuralwatt) == "Energy") + // No resettable extra quota windows when there is no per-key allowance. + #expect(usage.extraRateWindows == nil) + } + + @Test + func `parses response with missing credits used derived from remaining`() throws { + let body = #""" + { + "balance": { + "credits_remaining_usd": 30.0, + "total_credits_usd": 100.0, + "accounting_method": "energy" + }, + "usage": {"lifetime": {}, "current_month": {}}, + "limits": {}, + "subscription": null, + "key": {"name": "x", "allowance": null} + } + """# + + let snapshot = try NeuralWattUsageFetcher._parseSnapshotForTesting( + Data(body.utf8), + updatedAt: Date(timeIntervalSince1970: 2)) + // credits_used_usd missing but derived as 100 - 30 = 70. + #expect(snapshot.effectiveUsedCredits == 70) + #expect(snapshot.creditUsedPercent == 70) + } + + @Test + func `keeps known zero prepaid balance separate from subscription quota`() throws { + let body = #""" + { + "balance": { + "credits_remaining_usd": 0.0, + "total_credits_usd": 0.0, + "accounting_method": "energy" + }, + "usage": {"lifetime": {}, "current_month": {}}, + "limits": {}, + "subscription": null, + "key": {"name": "x", "allowance": null} + } + """# + + let snapshot = try NeuralWattUsageFetcher._parseSnapshotForTesting( + Data(body.utf8), + updatedAt: Date(timeIntervalSince1970: 2)) + let usage = snapshot.toUsageSnapshot() + + #expect(snapshot.effectiveRemainingCredits == 0) + #expect(snapshot.effectiveTotalCredits == nil) + #expect(snapshot.creditUsedPercent == 100) + #expect(usage.primary == nil) + #expect(usage.providerCost?.used == 0) + #expect(usage.providerCost?.period == "Neuralwatt prepaid balance") + } + + @Test + func `zero prepaid balance does not exhaust active subscription`() throws { + let body = #""" + { + "balance": { + "credits_remaining_usd": 0.0, + "total_credits_usd": 0.0, + "accounting_method": "energy" + }, + "usage": {"lifetime": {}, "current_month": {}}, + "limits": {}, + "subscription": { + "plan": "pro_energy", + "status": "active", + "current_period_start": "2026-04-01T00:00:00Z", + "current_period_end": "2026-05-01T00:00:00Z", + "kwh_included": 10.0, + "kwh_used": 2.5, + "kwh_remaining": 7.5 + }, + "key": {"name": "subscriber", "allowance": null} + } + """# + + let usage = try NeuralWattUsageFetcher._parseSnapshotForTesting( + Data(body.utf8), + updatedAt: Date(timeIntervalSince1970: 4)) + .toUsageSnapshot() + + #expect(usage.primary?.usedPercent == 25) + #expect(usage.primary?.resetDescription == "2.50 / 10 kWh") + #expect(usage.providerCost?.used == 0) + #expect(usage.loginMethod(for: .neuralwatt) == "Pro Energy plan") + } + + @Test + func `non renewing subscription keeps period end without renewal date`() throws { + let body = #""" + { + "balance": {"credits_remaining_usd": 1.0}, + "subscription": { + "plan": "standard", + "status": "active", + "current_period_end": "2026-05-01T00:00:00Z", + "auto_renew": false, + "kwh_included": 10.0, + "kwh_used": 4.0, + "kwh_remaining": 6.0 + }, + "key": {"name": "subscriber", "allowance": null} + } + """# + + let usage = try NeuralWattUsageFetcher._parseSnapshotForTesting( + Data(body.utf8), + updatedAt: Date(timeIntervalSince1970: 6)) + .toUsageSnapshot() + + #expect(usage.primary?.resetsAt != nil) + #expect(usage.subscriptionRenewsAt == nil) + } + + @Test + func `blocked key allowance is exhausted without numeric limit`() throws { + let body = #""" + { + "balance": {"credits_remaining_usd": 3.0}, + "subscription": null, + "key": {"name": "blocked", "allowance": {"blocked": true, "period": "monthly"}} + } + """# + + let usage = try NeuralWattUsageFetcher._parseSnapshotForTesting( + Data(body.utf8), + updatedAt: Date(timeIntervalSince1970: 5)) + .toUsageSnapshot() + + #expect(usage.extraRateWindows?.first?.window.usedPercent == 100) + } + + @Test + func `parses fractional subscription dates`() throws { + let body = #""" + { + "balance": { + "credits_remaining_usd": 8.0, + "total_credits_usd": 10.0, + "credits_used_usd": 2.0, + "accounting_method": "energy" + }, + "usage": {"lifetime": {}, "current_month": {}}, + "limits": {}, + "subscription": { + "plan": "standard", + "status": "active", + "current_period_start": "2026-04-11T05:05:25.123Z", + "current_period_end": "2026-05-11T05:05:25.456Z" + }, + "key": {"name": "x", "allowance": null} + } + """# + + let snapshot = try NeuralWattUsageFetcher._parseSnapshotForTesting( + Data(body.utf8), + updatedAt: Date(timeIntervalSince1970: 3)) + + #expect(snapshot.subscription?.currentPeriodEnd != nil) + #expect(snapshot.creditUsedPercent == 20) + } + + @Test + func `rejects malformed successful response without balance`() throws { + let body = #"{"error":"temporarily unavailable"}"# + + do { + _ = try NeuralWattUsageFetcher._parseSnapshotForTesting( + Data(body.utf8), + updatedAt: Date(timeIntervalSince1970: 4)) + Issue.record("Expected NeuralWattUsageError.parseFailed") + } catch let error as NeuralWattUsageError { + guard case let .parseFailed(message) = error else { + Issue.record("Expected parseFailed, got \(error)") + return + } + #expect(message.contains("balance")) + } + } + + @Test + func `fetch usage rejects blank API key before request`() async throws { + do { + _ = try await NeuralWattUsageFetcher.fetchUsage( + apiKey: " ", + environment: [NeuralWattSettingsReader.apiURLEnvironmentKey: "https://api.neuralwatt.test"]) + Issue.record("Expected NeuralWattUsageError.missingCredentials") + } catch let error as NeuralWattUsageError { + guard case .missingCredentials = error else { + Issue.record("Expected missingCredentials, got \(error)") + return + } + } + } + + @Test + func `fetch rejects endpoint override before sending API key`() async throws { + let transport = ProviderHTTPTransportHandler { _ in + Issue.record("Endpoint override validation must happen before the request") + throw URLError(.badURL) + } + + await #expect(throws: NeuralWattSettingsError.invalidEndpointOverride( + NeuralWattSettingsReader.apiURLEnvironmentKey)) + { + _ = try await NeuralWattUsageFetcher.fetchUsage( + apiKey: "sk-test", + environment: [NeuralWattSettingsReader.apiURLEnvironmentKey: "https://user@example.com"], + transport: transport) + } + } + + @Test + func `fetch preserves transport cancellation`() async throws { + let transport = ProviderHTTPTransportHandler { _ in + throw CancellationError() + } + + do { + _ = try await NeuralWattUsageFetcher.fetchUsage( + apiKey: "sk-test", + environment: [:], + transport: transport) + Issue.record("Expected CancellationError") + } catch is CancellationError { + // Expected: refresh cancellation must not become a provider error. + } catch { + Issue.record("Expected CancellationError, got \(error)") + } + } + + @Test + func `unauthorized fetch throws missing credentials`() async throws { + let registered = URLProtocol.registerClass(NeuralWattStubURLProtocol.self) + defer { + if registered { + URLProtocol.unregisterClass(NeuralWattStubURLProtocol.self) + } + NeuralWattStubURLProtocol.handler = nil + } + + NeuralWattStubURLProtocol.handler = { request in + guard let url = request.url else { throw URLError(.badURL) } + return Self.makeResponse(url: url, body: #"{"detail":"bad key"}"#, statusCode: 401) + } + + do { + _ = try await NeuralWattUsageFetcher.fetchUsage( + apiKey: "sk-test", + environment: [NeuralWattSettingsReader.apiURLEnvironmentKey: "https://api.neuralwatt.test"], + retryPolicy: .disabled) + Issue.record("Expected NeuralWattUsageError.missingCredentials") + } catch let error as NeuralWattUsageError { + guard case .missingCredentials = error else { + Issue.record("Expected missingCredentials, got \(error)") + return + } + } + } + + @Test + func `fetch usage sends bearer authorization header`() async throws { + let registered = URLProtocol.registerClass(NeuralWattStubURLProtocol.self) + defer { + if registered { + URLProtocol.unregisterClass(NeuralWattStubURLProtocol.self) + } + NeuralWattStubURLProtocol.handler = nil + } + + NeuralWattStubURLProtocol.handler = { request in + guard let url = request.url else { throw URLError(.badURL) } + #expect(url.path == "/v1/quota") + #expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer sk-test") + #expect(request.timeoutInterval == 15) + + let body = #""" + { + "balance": {"credits_remaining_usd": 5.0, "total_credits_usd": 10.0, + "credits_used_usd": 5.0, "accounting_method": "energy"}, + "usage": {"lifetime": {}, "current_month": {}}, + "limits": {}, "subscription": null, "key": {"name": "k", "allowance": null} + } + """# + return Self.makeResponse(url: url, body: body, statusCode: 200) + } + + let usage = try await NeuralWattUsageFetcher.fetchUsage( + apiKey: " sk-test ", + environment: [NeuralWattSettingsReader.apiURLEnvironmentKey: "https://api.neuralwatt.test"]) + + #expect(usage.creditUsedPercent == 50) + } + + @Test + func `non success fetch throws generic HTTP error`() async throws { + let registered = URLProtocol.registerClass(NeuralWattStubURLProtocol.self) + defer { + if registered { + URLProtocol.unregisterClass(NeuralWattStubURLProtocol.self) + } + NeuralWattStubURLProtocol.handler = nil + } + + NeuralWattStubURLProtocol.handler = { request in + guard let url = request.url else { throw URLError(.badURL) } + return Self.makeResponse(url: url, body: #"{"detail":"bad key"}"#, statusCode: 500) + } + + do { + _ = try await NeuralWattUsageFetcher.fetchUsage( + apiKey: "sk-test", + environment: [NeuralWattSettingsReader.apiURLEnvironmentKey: "https://api.neuralwatt.test"], + retryPolicy: .disabled) + Issue.record("Expected NeuralWattUsageError.apiError") + } catch let error as NeuralWattUsageError { + guard case let .apiError(message) = error else { + Issue.record("Expected apiError, got \(error)") + return + } + #expect(message == "HTTP 500") + } + } + + @Test + func `fetch retries transient quota failure`() async throws { + let body = #""" + { + "balance": {"credits_remaining_usd": 5.0}, + "subscription": null, + "key": {"name": "retry", "allowance": null} + } + """# + let transport = NeuralWattSequenceTransport(statusCodes: [503, 200], body: Data(body.utf8)) + let retryPolicy = ProviderHTTPRetryPolicy(maxRetries: 1, baseDelaySeconds: 0, maxDelaySeconds: 0) + + let usage = try await NeuralWattUsageFetcher.fetchUsage( + apiKey: "sk-test", + environment: [NeuralWattSettingsReader.apiURLEnvironmentKey: "https://api.neuralwatt.test"], + transport: transport, + retryPolicy: retryPolicy) + + #expect(usage.effectiveRemainingCredits == 5) + #expect(await transport.requestCount == 2) + } + + private static func makeResponse( + url: URL, + body: String, + statusCode: Int = 200) -> (HTTPURLResponse, Data) + { + let response = HTTPURLResponse( + url: url, + statusCode: statusCode, + httpVersion: "HTTP/1.1", + headerFields: ["Content-Type": "application/json"])! + return (response, Data(body.utf8)) + } +} + +private actor NeuralWattSequenceTransport: ProviderHTTPTransport { + private var statusCodes: [Int] + private let body: Data + private(set) var requestCount = 0 + + init(statusCodes: [Int], body: Data) { + self.statusCodes = statusCodes + self.body = body + } + + func data(for request: URLRequest) async throws -> (Data, URLResponse) { + self.requestCount += 1 + let statusCode = self.statusCodes.isEmpty ? 200 : self.statusCodes.removeFirst() + let response = HTTPURLResponse( + url: request.url!, + statusCode: statusCode, + httpVersion: "HTTP/1.1", + headerFields: ["Content-Type": "application/json"])! + return (self.body, response) + } +} + +final class NeuralWattStubURLProtocol: URLProtocol { + nonisolated(unsafe) static var handler: ((URLRequest) throws -> (HTTPURLResponse, Data))? + + override static func canInit(with request: URLRequest) -> Bool { + request.url?.host == "api.neuralwatt.test" + } + + override static func canonicalRequest(for request: URLRequest) -> URLRequest { + request + } + + override func startLoading() { + guard let handler = Self.handler else { + self.client?.urlProtocol(self, didFailWithError: URLError(.badServerResponse)) + return + } + do { + let (response, data) = try handler(self.request) + self.client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + self.client?.urlProtocol(self, didLoad: data) + self.client?.urlProtocolDidFinishLoading(self) + } catch { + self.client?.urlProtocol(self, didFailWithError: error) + } + } + + override func stopLoading() {} +} diff --git a/Tests/CodexBarTests/OllamaUIErrorMapperTests.swift b/Tests/CodexBarTests/OllamaUIErrorMapperTests.swift new file mode 100644 index 0000000000..bd02b52efc --- /dev/null +++ b/Tests/CodexBarTests/OllamaUIErrorMapperTests.swift @@ -0,0 +1,42 @@ +import CodexBarCore +import Testing +@testable import CodexBar + +struct OllamaUIErrorMapperTests { + @Test + func `maps Safari cookie access error to localized hint`() { + let message = OllamaUIErrorMapper.userFacingMessage( + OllamaUsageError.safariCookieAccessDenied.localizedDescription, + localize: { key in "localized:\(key)" }) + + #expect(message == "localized:ollama_safari_cookie_access_hint") + } + + @Test + func `maps Brave decryption denial with browser name`() { + let message = OllamaUIErrorMapper.userFacingMessage( + OllamaUsageError.browserCookieDecryptionDenied("Brave").localizedDescription, + localize: { key in + key == "ollama_browser_cookie_decryption_denied" ? "%@ localized denial" : key + }) + + #expect(message == "Brave localized denial") + } + + @Test + func `maps disabled Keychain access with browser name`() { + let message = OllamaUIErrorMapper.userFacingMessage( + OllamaUsageError.browserCookieDecryptionDisabled("Brave").localizedDescription, + localize: { key in + key == "ollama_browser_cookie_decryption_disabled" ? "%@ localized disabled" : key + }) + + #expect(message == "Brave localized disabled") + } + + @Test + func `preserves generic Ollama errors`() { + let raw = OllamaUsageError.noSessionCookie.localizedDescription + #expect(OllamaUIErrorMapper.userFacingMessage(raw, localize: { $0 }) == raw) + } +} diff --git a/Tests/CodexBarTests/OllamaUsageFetcherRetryMappingTests.swift b/Tests/CodexBarTests/OllamaUsageFetcherRetryMappingTests.swift index cb42bccce0..776d92d3f3 100644 --- a/Tests/CodexBarTests/OllamaUsageFetcherRetryMappingTests.swift +++ b/Tests/CodexBarTests/OllamaUsageFetcherRetryMappingTests.swift @@ -85,14 +85,169 @@ struct OllamaUsageFetcherRetryMappingTests { } @Test - func `api fetch sends bearer token and rejects unauthorized key`() async throws { - let url = try #require(URL(string: "https://ollama.com/api/tags")) + func `automatic web fetch reuses validated cached cookie without browser import`() async throws { + let cached = CookieHeaderCache.Entry( + cookieHeader: "session=cached", + storedAt: Date(timeIntervalSince1970: 100), + sourceLabel: "Chrome") + var events: [String] = [] + + let snapshot = try await OllamaStatusFetchStrategy.fetchAutomatic( + cached: cached, + fetchCached: { entry in + events.append("cache:\(entry.sourceLabel)") + return Self.makeSnapshot(sessionUsedPercent: 12) + }, + fetchBrowser: { + events.append("browser") + return OllamaUsageFetcher.ResolvedCookieFetch( + snapshot: Self.makeSnapshot(sessionUsedPercent: 99), + cookieHeader: "session=browser", + sourceLabel: "Browser") + }, + clearCached: { _ in events.append("clear") }, + storeResolved: { _ in events.append("store") }) + + #expect(snapshot.sessionUsedPercent == 12) + #expect(events == ["cache:Chrome"]) + } + + @Test + func `automatic web fetch keeps cached cookie on offline failure`() async { + let cached = CookieHeaderCache.Entry( + cookieHeader: "session=cached", + storedAt: Date(timeIntervalSince1970: 100), + sourceLabel: "Chrome") + var events: [String] = [] + + await #expect(throws: URLError.self) { + _ = try await OllamaStatusFetchStrategy.fetchAutomatic( + cached: cached, + fetchCached: { _ in throw URLError(.notConnectedToInternet) }, + fetchBrowser: { + events.append("browser") + return OllamaUsageFetcher.ResolvedCookieFetch( + snapshot: Self.makeSnapshot(sessionUsedPercent: 99), + cookieHeader: "session=browser", + sourceLabel: "Browser") + }, + clearCached: { _ in events.append("clear") }, + storeResolved: { _ in events.append("store") }) + } + + #expect(events.isEmpty) + } + + @Test + func `automatic web fetch replaces cached cookie only after authentication failure`() async throws { + let cached = CookieHeaderCache.Entry( + cookieHeader: "session=expired", + storedAt: Date(timeIntervalSince1970: 100), + sourceLabel: "Chrome") + var events: [String] = [] + + let snapshot = try await OllamaStatusFetchStrategy.fetchAutomatic( + cached: cached, + fetchCached: { _ in + events.append("cache") + throw OllamaUsageError.invalidCredentials + }, + fetchBrowser: { + events.append("browser") + return OllamaUsageFetcher.ResolvedCookieFetch( + snapshot: Self.makeSnapshot(sessionUsedPercent: 34), + cookieHeader: "session=fresh", + sourceLabel: "Brave") + }, + clearCached: { entry in events.append("clear:\(entry.sourceLabel)") }, + storeResolved: { resolved in events.append("store:\(resolved.sourceLabel)") }) + + #expect(snapshot.sessionUsedPercent == 34) + #expect(events == ["cache", "clear:Chrome", "browser", "store:Brave"]) + } + + @Test + func `cached cookie invalidation excludes network and parse errors`() { + #expect(OllamaStatusFetchStrategy.shouldInvalidateCachedCookie( + after: OllamaUsageError.invalidCredentials)) + #expect(OllamaStatusFetchStrategy.shouldInvalidateCachedCookie( + after: OllamaUsageError.notLoggedIn)) + #expect(!OllamaStatusFetchStrategy.shouldInvalidateCachedCookie( + after: URLError(.notConnectedToInternet))) + #expect(!OllamaStatusFetchStrategy.shouldInvalidateCachedCookie( + after: OllamaUsageError.parseFailed("changed page"))) + } + + @Test + func `cached session missing usage tries another browser without clearing cache`() async throws { + let cached = CookieHeaderCache.Entry( + cookieHeader: "session=cached", + storedAt: Date(timeIntervalSince1970: 100), + sourceLabel: "Chrome") + var events: [String] = [] + + let snapshot = try await OllamaStatusFetchStrategy.fetchAutomatic( + cached: cached, + fetchCached: { _ in + events.append("cache") + throw OllamaUsageError.parseFailed("Missing Ollama usage data.") + }, + fetchBrowser: { + events.append("browser") + return OllamaUsageFetcher.ResolvedCookieFetch( + snapshot: Self.makeSnapshot(sessionUsedPercent: 56), + cookieHeader: "session=browser", + sourceLabel: "Brave") + }, + clearCached: { _ in events.append("clear") }, + storeResolved: { resolved in events.append("store:\(resolved.sourceLabel)") }) + + #expect(snapshot.sessionUsedPercent == 56) + #expect(events == ["cache", "browser", "store:Brave"]) + } + + @Test + func `failed browser fallback preserves cached missing usage error`() async { + let cached = CookieHeaderCache.Entry( + cookieHeader: "session=cached", + storedAt: Date(timeIntervalSince1970: 100), + sourceLabel: "Chrome") + var events: [String] = [] + + do { + _ = try await OllamaStatusFetchStrategy.fetchAutomatic( + cached: cached, + fetchCached: { _ in + events.append("cache") + throw OllamaUsageError.parseFailed("Missing Ollama usage data.") + }, + fetchBrowser: { + events.append("browser") + throw OllamaUsageError.noSessionCookie + }, + clearCached: { _ in events.append("clear") }, + storeResolved: { _ in events.append("store") }) + Issue.record("Expected cached parse failure") + } catch let OllamaUsageError.parseFailed(message) { + #expect(message == "Missing Ollama usage data.") + } catch { + Issue.record("Expected cached parse failure, got \(error)") + } + + #expect(events == ["cache", "browser"]) + } + + @Test(arguments: [401, 403]) + func `api fetch sends bearer token and rejects unauthorized key`(statusCode: Int) async throws { + let url = try #require(URL(string: "https://ollama.com/api/web_search")) let transport = ProviderHTTPTransportHandler { request in #expect(request.url == url) + #expect(request.httpMethod == "POST") + #expect(request.httpBody == Data(#"{"query":""}"#.utf8)) #expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer ollama-test") let response = HTTPURLResponse( url: url, - statusCode: 401, + statusCode: statusCode, httpVersion: "HTTP/1.1", headerFields: nil)! return (Data("{}".utf8), response) @@ -106,11 +261,233 @@ struct OllamaUsageFetcherRetryMappingTests { Issue.record("Expected apiUnauthorized, got \(error)") return } + #expect(error.localizedDescription == "Ollama API key is invalid or revoked.") } catch { Issue.record("Expected OllamaUsageError.apiUnauthorized, got \(error)") } } + @Test + func `authorized validation continues to model catalog`() async throws { + let validationURL = try #require(URL(string: "https://ollama.test/api/web_search")) + let tagsURL = try #require(URL(string: "https://ollama.test/api/tags")) + let transport = ProviderHTTPTransportHandler { request in + let statusCode: Int + let data: Data + switch request.url { + case validationURL: + statusCode = 400 + data = Data(#"{"error":"query is required"}"#.utf8) + case tagsURL: + statusCode = 200 + data = Data(#"{"models":[{}]}"#.utf8) + default: + Issue.record("Unexpected Ollama API URL") + statusCode = 500 + data = Data() + } + let response = HTTPURLResponse( + url: request.url!, + statusCode: statusCode, + httpVersion: "HTTP/1.1", + headerFields: nil)! + return (data, response) + } + + let snapshot = try await OllamaAPIUsageFetcher.fetchUsage( + apiKey: "ollama-test", + tagsURL: tagsURL, + validationURL: validationURL, + transport: transport) + + #expect(snapshot.modelCount == 1) + } + + @Test(arguments: [401, 403]) + func `authorized validation still rejects unauthorized model catalog`(statusCode: Int) async throws { + let validationURL = try #require(URL(string: "https://ollama.test/api/web_search")) + let tagsURL = try #require(URL(string: "https://ollama.test/api/tags")) + let transport = ProviderHTTPTransportHandler { request in + let responseStatus = request.url == validationURL ? 400 : statusCode + let response = HTTPURLResponse( + url: request.url!, + statusCode: responseStatus, + httpVersion: "HTTP/1.1", + headerFields: nil)! + return (Data("{}".utf8), response) + } + + do { + _ = try await OllamaAPIUsageFetcher.fetchUsage( + apiKey: "ollama-test", + tagsURL: tagsURL, + validationURL: validationURL, + transport: transport) + Issue.record("Expected unauthorized model catalog error") + } catch let error as OllamaUsageError { + guard case .apiUnauthorized = error else { + Issue.record("Expected apiUnauthorized, got \(error)") + return + } + } catch { + Issue.record("Expected OllamaUsageError.apiUnauthorized, got \(error)") + } + } + + @Test + func `custom catalog derives validation on the same origin`() async throws { + let tagsURL = try #require(URL(string: "https://private.example/prefix/api/tags")) + let validationURL = try #require(URL(string: "https://private.example/prefix/api/web_search")) + let transport = ProviderHTTPTransportHandler { request in + let statusCode: Int + let data: Data + switch request.url { + case validationURL: + statusCode = 400 + data = Data(#"{"error":"query is required"}"#.utf8) + case tagsURL: + statusCode = 200 + data = Data(#"{"models":[{}]}"#.utf8) + default: + Issue.record("Unexpected Ollama API URL") + statusCode = 500 + data = Data() + } + #expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer private-key") + let response = HTTPURLResponse( + url: request.url!, + statusCode: statusCode, + httpVersion: "HTTP/1.1", + headerFields: nil)! + return (data, response) + } + + let snapshot = try await OllamaAPIUsageFetcher.fetchUsage( + apiKey: "private-key", + tagsURL: tagsURL, + transport: transport) + + #expect(snapshot.modelCount == 1) + } + + @Test + func `cross origin validation endpoint is rejected before sending credentials`() async throws { + let tagsURL = try #require(URL(string: "https://private.example/api/tags")) + let validationURL = try #require(URL(string: "https://ollama.com/api/web_search")) + let transport = ProviderHTTPTransportHandler { _ in + Issue.record("Cross-origin endpoints must fail before transport") + throw URLError(.badURL) + } + + do { + _ = try await OllamaAPIUsageFetcher.fetchUsage( + apiKey: "private-key", + tagsURL: tagsURL, + validationURL: validationURL, + transport: transport) + Issue.record("Expected a same-origin validation error") + } catch let error as OllamaUsageError { + guard case let .networkError(message) = error else { + Issue.record("Expected networkError, got \(error)") + return + } + #expect(message == "Ollama key validation and model catalog endpoints must share an origin.") + } catch { + Issue.record("Expected OllamaUsageError.networkError, got \(error)") + } + } + + @Test + func `non loopback HTTP catalog is rejected before sending credentials`() async throws { + let tagsURL = try #require(URL(string: "http://private.example/api/tags")) + let transport = ProviderHTTPTransportHandler { _ in + Issue.record("Insecure non-loopback endpoints must fail before transport") + throw URLError(.badURL) + } + + do { + _ = try await OllamaAPIUsageFetcher.fetchUsage( + apiKey: "private-key", + tagsURL: tagsURL, + transport: transport) + Issue.record("Expected an insecure endpoint error") + } catch let error as OllamaUsageError { + guard case let .networkError(message) = error else { + Issue.record("Expected networkError, got \(error)") + return + } + #expect(message == "Ollama API endpoints must use HTTPS or loopback HTTP.") + } catch { + Issue.record("Expected OllamaUsageError.networkError, got \(error)") + } + } + + @Test + func `api validation preserves cancellation`() async { + let transport = ProviderHTTPTransportHandler { _ in + throw CancellationError() + } + + await #expect(throws: CancellationError.self) { + _ = try await OllamaAPIUsageFetcher.fetchUsage(apiKey: "ollama-test", transport: transport) + } + } + + @Test + func `model catalog fetch preserves URL cancellation`() async throws { + let validationURL = try #require(URL(string: "https://ollama.test/api/web_search")) + let tagsURL = try #require(URL(string: "https://ollama.test/api/tags")) + let transport = ProviderHTTPTransportHandler { request in + guard request.url == validationURL else { throw URLError(.cancelled) } + let response = HTTPURLResponse( + url: validationURL, + statusCode: 400, + httpVersion: "HTTP/1.1", + headerFields: nil)! + return (Data(#"{"error":"query is required"}"#.utf8), response) + } + + await #expect(throws: CancellationError.self) { + _ = try await OllamaAPIUsageFetcher.fetchUsage( + apiKey: "ollama-test", + tagsURL: tagsURL, + validationURL: validationURL, + transport: transport) + } + } + + @Test + func `unproven validation status fails closed`() async throws { + let validationURL = try #require(URL(string: "https://ollama.test/api/web_search")) + let tagsURL = try #require(URL(string: "https://ollama.test/api/tags")) + let transport = ProviderHTTPTransportHandler { request in + #expect(request.url == validationURL) + let response = HTTPURLResponse( + url: validationURL, + statusCode: 422, + httpVersion: "HTTP/1.1", + headerFields: nil)! + return (Data(#"{"error":"unprocessable"}"#.utf8), response) + } + + do { + _ = try await OllamaAPIUsageFetcher.fetchUsage( + apiKey: "ollama-test", + tagsURL: tagsURL, + validationURL: validationURL, + transport: transport) + Issue.record("Expected an HTTP 422 network error") + } catch let error as OllamaUsageError { + guard case let .networkError(message) = error else { + Issue.record("Expected networkError, got \(error)") + return + } + #expect(message == "HTTP 422") + } catch { + Issue.record("Expected OllamaUsageError.networkError, got \(error)") + } + } + @Test func `missing usage shape surfaces public parse failed message`() async { defer { OllamaRetryMappingStubURLProtocol.handler = nil } @@ -121,13 +498,7 @@ struct OllamaUsageFetcherRetryMappingTests { return Self.makeResponse(url: url, body: body, statusCode: 200) } - let fetcher = OllamaUsageFetcher( - browserDetection: BrowserDetection(cacheTTL: 0), - makeURLSession: { delegate in - let config = URLSessionConfiguration.ephemeral - config.protocolClasses = [OllamaRetryMappingStubURLProtocol.self] - return URLSession(configuration: config, delegate: delegate, delegateQueue: nil) - }) + let fetcher = self.makeCookieFetcher() do { _ = try await fetcher.fetch( cookieHeaderOverride: "session=test-cookie", @@ -144,6 +515,194 @@ struct OllamaUsageFetcherRetryMappingTests { } } + @Test + func `workos sign in landing surfaces invalid credentials before parsing`() async throws { + defer { OllamaRetryMappingStubURLProtocol.handler = nil } + + let landingURL = try #require(URL( + string: "https://signin.ollama.com/?client_id=test&authorization_session_id=expired")) + OllamaRetryMappingStubURLProtocol.handler = { request in + #expect(request.url == URL(string: "https://ollama.com/settings")) + let body = "Sign in to Ollama" + return Self.makeResponse(url: landingURL, body: body, statusCode: 200) + } + + let fetcher = self.makeCookieFetcher() + do { + _ = try await fetcher.fetch( + cookieHeaderOverride: "session=expired-cookie", + manualCookieMode: true) + Issue.record("Expected OllamaUsageError.invalidCredentials") + } catch let error as OllamaUsageError { + guard case .invalidCredentials = error else { + Issue.record("Expected invalidCredentials, got \(error)") + return + } + } catch { + Issue.record("Expected OllamaUsageError.invalidCredentials, got \(error)") + } + } + + @Test + func `workos sign in service failure remains a network error`() async throws { + defer { OllamaRetryMappingStubURLProtocol.handler = nil } + + let landingURL = try #require(URL(string: "https://signin.ollama.com/")) + OllamaRetryMappingStubURLProtocol.handler = { _ in + Self.makeResponse(url: landingURL, body: "Service unavailable", statusCode: 503) + } + + let fetcher = self.makeCookieFetcher() + do { + _ = try await fetcher.fetch( + cookieHeaderOverride: "session=expired-cookie", + manualCookieMode: true) + Issue.record("Expected OllamaUsageError.networkError") + } catch let error as OllamaUsageError { + guard case let .networkError(message) = error else { + Issue.record("Expected networkError, got \(error)") + return + } + #expect(message == "HTTP 503") + } catch { + Issue.record("Expected OllamaUsageError.networkError, got \(error)") + } + } + + @Test + func `temporary session is finished after a failed request`() async throws { + defer { OllamaRetryMappingStubURLProtocol.handler = nil } + + let landingURL = try #require(URL(string: "https://ollama.com/settings")) + OllamaRetryMappingStubURLProtocol.handler = { _ in + Self.makeResponse(url: landingURL, body: "Service unavailable", statusCode: 503) + } + let recorder = OllamaSessionFinishRecorder() + let fetcher = self.makeCookieFetcher(finishURLSession: { session in + recorder.record(session) + session.finishTasksAndInvalidate() + }) + + do { + _ = try await fetcher.fetch( + cookieHeaderOverride: "session=expired-cookie", + manualCookieMode: true) + Issue.record("Expected OllamaUsageError.networkError") + } catch is OllamaUsageError { + #expect(recorder.count == 1) + } + } + + @Test + func `temporary session is finished after a successful request`() async throws { + defer { OllamaRetryMappingStubURLProtocol.handler = nil } + + OllamaRetryMappingStubURLProtocol.handler = { request in + let url = try #require(request.url) + let body = """ +
    + Session usage + 1.2% used + Weekly usage + 3.4% used +
    + """ + return Self.makeResponse(url: url, body: body, statusCode: 200) + } + let recorder = OllamaSessionFinishRecorder() + let fetcher = self.makeCookieFetcher(finishURLSession: { session in + recorder.record(session) + session.finishTasksAndInvalidate() + }) + + _ = try await fetcher.fetch( + cookieHeaderOverride: "session=test-cookie", + manualCookieMode: true) + + #expect(recorder.count == 1) + } + + @Test + func `token account header survives the manual strategy boundary`() { + let header = "__Secure-session=my-cookie:session=abc" + let context = self.makeContext( + sourceMode: .auto, + settings: ProviderSettingsSnapshot.make( + ollama: .init(cookieSource: .manual, manualCookieHeader: header))) + + #expect(OllamaStatusFetchStrategy.manualCookieHeader(from: context) == header) + } + + @Test + func `token account session value reaches outgoing cookie header`() async throws { + defer { OllamaRetryMappingStubURLProtocol.handler = nil } + + let account = ProviderTokenAccount( + id: UUID(), + label: "Primary", + token: "account-token", + addedAt: 0, + lastUsed: nil) + let settings = ProviderCookieSettingsResolver.resolve( + provider: .ollama, + configuredSource: .auto, + configuredHeader: nil, + selectedAccount: account) + OllamaRetryMappingStubURLProtocol.handler = { request in + #expect(request.value(forHTTPHeaderField: "Cookie") == "__Secure-session=account-token") + let url = try #require(request.url) + let body = """ +
    + Session usage + 1.2% used + Weekly usage + 3.4% used +
    + """ + return Self.makeResponse(url: url, body: body, statusCode: 200) + } + + let fetcher = self.makeCookieFetcher() + _ = try await fetcher.fetch( + cookieHeaderOverride: settings.manualCookieHeader, + manualCookieMode: true) + } + + @Test + func `temporary session is finished after a transport failure`() async { + defer { OllamaRetryMappingStubURLProtocol.handler = nil } + + OllamaRetryMappingStubURLProtocol.handler = { _ in + throw URLError(.notConnectedToInternet) + } + let recorder = OllamaSessionFinishRecorder() + let fetcher = self.makeCookieFetcher(finishURLSession: { session in + recorder.record(session) + session.finishTasksAndInvalidate() + }) + + await #expect(throws: URLError.self) { + _ = try await fetcher.fetch( + cookieHeaderOverride: "session=test-cookie", + manualCookieMode: true) + } + #expect(recorder.count == 1) + } + + private func makeCookieFetcher( + finishURLSession: @escaping @Sendable (URLSession) -> Void = { $0.finishTasksAndInvalidate() }) + -> OllamaUsageFetcher + { + OllamaUsageFetcher( + browserDetection: BrowserDetection(cacheTTL: 0), + makeURLSession: { delegate in + let config = URLSessionConfiguration.ephemeral + config.protocolClasses = [OllamaRetryMappingStubURLProtocol.self] + return URLSession(configuration: config, delegate: delegate, delegateQueue: nil) + }, + finishURLSession: finishURLSession) + } + private static func makeResponse( url: URL, body: String, @@ -156,10 +715,40 @@ struct OllamaUsageFetcherRetryMappingTests { headerFields: ["Content-Type": "text/html"])! return (response, Data(body.utf8)) } + + private static func makeSnapshot(sessionUsedPercent: Double) -> OllamaUsageSnapshot { + OllamaUsageSnapshot( + planName: nil, + accountEmail: nil, + sessionUsedPercent: sessionUsedPercent, + weeklyUsedPercent: nil, + sessionResetsAt: nil, + weeklyResetsAt: nil, + updatedAt: Date(timeIntervalSince1970: 200)) + } +} + +private final class OllamaSessionFinishRecorder: @unchecked Sendable { + private let lock = NSLock() + private var sessions: [URLSession] = [] + + var count: Int { + self.lock.withLock { self.sessions.count } + } + + func record(_ session: URLSession) { + self.lock.withLock { + self.sessions.append(session) + } + } } final class OllamaRetryMappingStubURLProtocol: URLProtocol { - nonisolated(unsafe) static var handler: ((URLRequest) throws -> (HTTPURLResponse, Data))? + private static let _handlerBox = LockIsolated<((URLRequest) throws -> (HTTPURLResponse, Data))?>(nil) + static var handler: ((URLRequest) throws -> (HTTPURLResponse, Data))? { + get { Self._handlerBox.value } + set { Self._handlerBox.setValue(newValue) } + } override static func canInit(with request: URLRequest) -> Bool { guard let host = request.url?.host?.lowercased() else { return false } diff --git a/Tests/CodexBarTests/OllamaUsageFetcherTests.swift b/Tests/CodexBarTests/OllamaUsageFetcherTests.swift index e7dde89fff..4d968f15db 100644 --- a/Tests/CodexBarTests/OllamaUsageFetcherTests.swift +++ b/Tests/CodexBarTests/OllamaUsageFetcherTests.swift @@ -1,8 +1,19 @@ import Foundation import Testing @testable import CodexBarCore +#if os(macOS) +import SweetCookieKit +#endif +@Suite(.serialized) struct OllamaUsageFetcherTests { + @Test + func `session authentication errors point to current recovery page`() { + #expect(OllamaUsageError.notLoggedIn.errorDescription?.contains("https://ollama.com/signin") == true) + #expect(OllamaUsageError.invalidCredentials.errorDescription?.contains("https://ollama.com/signin") == true) + #expect(OllamaUsageError.noSessionCookie.errorDescription?.contains("https://ollama.com/signin") == true) + } + @Test func `attaches cookie for ollama hosts`() { #expect(OllamaUsageFetcher.shouldAttachCookie(to: URL(string: "https://ollama.com/settings"))) @@ -17,6 +28,34 @@ 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 `recognizes current ollama sign in redirects`() { + #expect(OllamaUsageFetcher.isSignInRedirect(URL(string: "https://ollama.com/signin"))) + #expect(OllamaUsageFetcher.isSignInRedirect(URL( + string: "https://api.workos.com/user_management/authorize?client_id=test"))) + #expect(OllamaUsageFetcher.isSignInRedirect(URL( + string: "https://auth.workos.com/user_management/authorize?client_id=test"))) + // The real unauthenticated chain lands on the WorkOS-hosted Ollama sign-in + // page on the `signin.ollama.com` subdomain (verified live); that terminal + // landing must also classify as a sign-in redirect. + #expect(OllamaUsageFetcher.isSignInRedirect(URL( + string: "https://signin.ollama.com/?client_id=test&authorization_session_id=x"))) + #expect(!OllamaUsageFetcher.isSignInRedirect(URL(string: "https://ollama.com/settings"))) + #expect(!OllamaUsageFetcher.isSignInRedirect(URL(string: "https://api.workos.com/other"))) + #expect(!OllamaUsageFetcher.isSignInRedirect(URL(string: "http://ollama.com/signin"))) + #expect(!OllamaUsageFetcher.isSignInRedirect(URL( + string: "http://auth.workos.com/user_management/authorize?client_id=test"))) + #expect(!OllamaUsageFetcher.isSignInRedirect(URL( + string: "https://example.com/user_management/authorize?client_id=test"))) + } + @Test func `manual mode without valid header throws no session cookie`() { do { @@ -69,6 +108,220 @@ struct OllamaUsageFetcherTests { #expect(resolved?.contains("__Secure-session=abc") == true) } + @Test + func `raw ollama token account becomes a secure session cookie`() throws { + let account = ProviderTokenAccount( + id: UUID(), + label: "Primary", + token: "account-token", + addedAt: 0, + lastUsed: nil) + let settings = ProviderCookieSettingsResolver.resolve( + provider: .ollama, + configuredSource: .auto, + configuredHeader: nil, + selectedAccount: account) + + #expect(settings.cookieSource == .manual) + #expect(settings.manualCookieHeader == "__Secure-session=account-token") + let resolved = try OllamaUsageFetcher.resolveManualCookieHeader( + override: settings.manualCookieHeader, + manualCookieMode: true) + #expect(resolved == "__Secure-session=account-token") + } + + @Test + func `padded ollama token account becomes a secure session cookie`() throws { + let account = ProviderTokenAccount( + id: UUID(), + label: "Primary", + token: " \n opaque-session== \t", + addedAt: 0, + lastUsed: nil) + let settings = ProviderCookieSettingsResolver.resolve( + provider: .ollama, + configuredSource: .auto, + configuredHeader: nil, + selectedAccount: account) + + #expect(settings.manualCookieHeader == "__Secure-session=opaque-session==") + let resolved = try OllamaUsageFetcher.resolveManualCookieHeader( + override: settings.manualCookieHeader, + manualCookieMode: true) + #expect(resolved == "__Secure-session=opaque-session==") + } + + @Test + func `empty ollama token account does not synthesize a session cookie`() { + let header = normalizedOllamaTokenAccountHeader( + " \n\t ", + defaultCookieName: "__Secure-session") + + #expect(header.isEmpty) + } + + @Test + func `ollama token account preserves unrecognized multi cookie header`() { + let header = "theme=dark; locale=en" + let normalized = normalizedOllamaTokenAccountHeader( + header, + defaultCookieName: "__Secure-session") + + #expect(normalized == header) + } + + @Test + func `ollama token account normalizes explicit cookie header`() throws { + let header = "Cookie: __Secure-session=opaque-session==" + let normalized = normalizedOllamaTokenAccountHeader( + header, + defaultCookieName: "__Secure-session") + + #expect(normalized == "__Secure-session=opaque-session==") + let resolved = try OllamaUsageFetcher.resolveManualCookieHeader( + override: normalized, + manualCookieMode: true) + #expect(resolved == "__Secure-session=opaque-session==") + } + + @Test(arguments: ["opaque-cookie:value", "prefixCOOKIE:value"]) + func `cookie marker inside ollama session value is not treated as a header`(token: String) { + let normalized = TokenAccountSupportCatalog.normalizedCookieHeader( + for: .ollama, + token: token) + + #expect(normalized == "__Secure-session=\(token)") + } + + @Test + func `lowercase secure session cookie name is canonicalized`() { + let normalized = TokenAccountSupportCatalog.normalizedCookieHeader( + for: .ollama, + token: "__secure-session=abc") + + #expect(normalized == "__Secure-session=abc") + } + + @Test + func `unknown single cookie shape is treated as an opaque session value`() { + let normalized = TokenAccountSupportCatalog.normalizedCookieHeader( + for: .ollama, + token: "foo=bar") + + #expect(normalized == "__Secure-session=foo=bar") + } + + @Test + func `embedded cookie marker in session value is preserved as value data`() { + let token = "my-cookie:session=abc" + let normalized = TokenAccountSupportCatalog.normalizedCookieHeader( + for: .ollama, + token: token) + + let expected = "__Secure-session=\(token)" + #expect(normalized == expected) + let resolved = try? OllamaUsageFetcher.resolveManualCookieHeader( + override: normalized, + manualCookieMode: true) + #expect(resolved == expected) + } + + @Test(arguments: ["abc123", "opaque-session=="]) + func `cookie prefixed bare value becomes a secure session cookie`(value: String) throws { + let normalized = TokenAccountSupportCatalog.normalizedCookieHeader( + for: .ollama, + token: "Cookie: \(value)") + let expected = "__Secure-session=\(value)" + + #expect(normalized == expected) + #expect(try OllamaUsageFetcher.resolveManualCookieHeader( + override: normalized, + manualCookieMode: true) == expected) + } + + @Test(arguments: [ + "curl https://ollama.com -H 'Cookie: __Secure-session=abc'", + "curl https://ollama.com -H Cookie:__Secure-session=abc", + "curl https://ollama.com --cookie '__Secure-session=abc'", + "curl https://ollama.com -b'__Secure-session=abc'", + ]) + func `ollama token account retains supported curl cookie forms`(token: String) throws { + let normalized = TokenAccountSupportCatalog.normalizedCookieHeader( + for: .ollama, + token: token) + + #expect(normalized == "__Secure-session=abc") + #expect(try OllamaUsageFetcher.resolveManualCookieHeader( + override: normalized, + manualCookieMode: true) == "__Secure-session=abc") + } + + @Test + func `mixed ollama header canonicalizes default cookie regardless of order`() { + let normalized = TokenAccountSupportCatalog.normalizedCookieHeader( + for: .ollama, + token: "wos-session=old; __secure-session=current") + + #expect(normalized == "wos-session=old; __Secure-session=current") + } + + @Test + func `ollama token account rejects multiline opaque values`() { + let normalized = TokenAccountSupportCatalog.normalizedCookieHeader( + for: .ollama, + token: "abc\r\nX-Test: injected") + + #expect(normalized.isEmpty) + } + + @Test + func `ollama token account preserves secure session cookie header`() { + let header = "__Secure-session=opaque-session==" + let account = ProviderTokenAccount( + id: UUID(), + label: "Primary", + token: header, + addedAt: 0, + lastUsed: nil) + let settings = ProviderCookieSettingsResolver.resolve( + provider: .ollama, + configuredSource: .auto, + configuredHeader: nil, + selectedAccount: account) + + #expect(settings.manualCookieHeader == header) + } + + @Test + func `ollama token account preserves another recognized cookie header`() throws { + let header = "wos-session=account-token" + let account = ProviderTokenAccount( + id: UUID(), + label: "Primary", + token: header, + addedAt: 0, + lastUsed: nil) + let settings = ProviderCookieSettingsResolver.resolve( + provider: .ollama, + configuredSource: .auto, + configuredHeader: nil, + selectedAccount: account) + + #expect(settings.manualCookieHeader == header) + let resolved = try OllamaUsageFetcher.resolveManualCookieHeader( + override: settings.manualCookieHeader, + manualCookieMode: true) + #expect(resolved == header) + } + + @Test + func `manual mode accepts workos session cookie header`() throws { + let resolved = try OllamaUsageFetcher.resolveManualCookieHeader( + override: "wos-session=abc; theme=dark", + manualCookieMode: true) + #expect(resolved?.contains("wos-session=abc") == true) + } + @Test func `retry policy retries only for auth errors`() { #expect(OllamaUsageFetcher.shouldRetryWithNextCookieCandidate(after: OllamaUsageError.invalidCredentials)) @@ -89,6 +342,207 @@ struct OllamaUsageFetcherTests { #expect(OllamaCookieImporter.defaultAllowFallbackBrowsers) } + @Test + func `cookie access errors map only unambiguous recovery paths`() { + let safari = OllamaCookieImporter.accessError(from: BrowserCookieError.accessDenied( + browser: .safari, + details: "Enable Full Disk Access.")) + guard case .safariCookieAccessDenied = safari else { + Issue.record("Expected Safari Full Disk Access error") + return + } + + let brave = OllamaCookieImporter.accessError(from: BrowserCookieError.accessDenied( + browser: .brave, + details: "macOS Keychain denied access.")) + guard case let .browserCookieDecryptionDenied(browserName) = brave else { + Issue.record("Expected Brave Keychain denial") + return + } + #expect(browserName == "Brave") + + let ambiguous = OllamaCookieImporter.accessError(from: BrowserCookieError.loadFailed( + browser: .brave, + details: "SQLite failed")) + #expect(ambiguous == nil) + } + + @Test + func `multi browser import skips safari access error after chrome was read`() { + BrowserCookieAccessGate.resetForTesting() + defer { BrowserCookieAccessGate.resetForTesting() } + var attemptedBrowsers: [Browser] = [] + + do { + _ = try OllamaCookieImporter.importSessions( + preferredSources: [.chrome], + allowFallbackBrowsers: true, + loadFallbackSources: { _ in [.safari] }, + loadSessions: { browser, _ in + attemptedBrowsers.append(browser) + if browser == .safari { + throw BrowserCookieError.accessDenied( + browser: .safari, + details: "Full Disk Access denied") + } + return [] + }) + Issue.record("Expected OllamaUsageError.noSessionCookie") + } catch OllamaUsageError.noSessionCookie { + #expect(attemptedBrowsers == [.chrome, .safari]) + } catch { + Issue.record("Expected OllamaUsageError.noSessionCookie, got \(error)") + } + } + + @Test + func `automatic fallback skips safari access error when preferred browser is unavailable`() { + BrowserCookieAccessGate.resetForTesting() + defer { BrowserCookieAccessGate.resetForTesting() } + var attemptedBrowsers: [Browser] = [] + + do { + _ = try OllamaCookieImporter.importSessions( + preferredSources: [], + allowFallbackBrowsers: true, + loadFallbackSources: { _ in [.safari] }, + loadSessions: { browser, _ in + attemptedBrowsers.append(browser) + throw BrowserCookieError.accessDenied( + browser: browser, + details: "Full Disk Access denied") + }) + Issue.record("Expected OllamaUsageError.noSessionCookie") + } catch OllamaUsageError.noSessionCookie { + #expect(attemptedBrowsers == [.safari]) + } catch { + Issue.record("Expected OllamaUsageError.noSessionCookie, got \(error)") + } + } + + @Test + func `automatic fallback keeps non safari access error after safari denial`() { + BrowserCookieAccessGate.resetForTesting() + defer { BrowserCookieAccessGate.resetForTesting() } + + do { + _ = try OllamaCookieImporter.importSessions( + preferredSources: [.chrome], + allowFallbackBrowsers: true, + loadFallbackSources: { _ in [.safari, .brave] }, + loadSessions: { browser, _ in + if browser == .chrome { + return [] + } + throw BrowserCookieError.accessDenied( + browser: browser, + details: "Access denied") + }) + Issue.record("Expected Brave Keychain denial") + } catch let OllamaUsageError.browserCookieDecryptionDenied(browserName) { + #expect(browserName == "Brave") + } catch { + Issue.record("Expected Brave Keychain denial, got \(error)") + } + } + + @Test + func `fallback browser gates stay lazy when chrome has a session`() throws { + var loadedFallbackSources = false + let sessions = try OllamaCookieImporter.importSessions( + preferredSources: [.chrome], + allowFallbackBrowsers: true, + loadFallbackSources: { _ in + loadedFallbackSources = true + return [.safari] + }, + loadSessions: { browser, _ in + #expect(browser == .chrome) + return [OllamaCookieImporter.SessionInfo( + cookies: [Self.makeCookie(name: "session", value: "auth")], + sourceLabel: "Chrome Profile")] + }) + + #expect(sessions.map(\.sourceLabel) == ["Chrome Profile"]) + #expect(!loadedFallbackSources) + } + + @Test + func `explicit safari import surfaces safari access error`() { + BrowserCookieAccessGate.resetForTesting() + defer { BrowserCookieAccessGate.resetForTesting() } + + do { + _ = try OllamaCookieImporter.importSessions( + preferredSources: [.safari], + allowFallbackBrowsers: false, + loadFallbackSources: { _ in [] }, + loadSessions: { browser, _ in + throw BrowserCookieError.accessDenied( + browser: browser, + details: "Full Disk Access denied") + }) + Issue.record("Expected Safari Full Disk Access error") + } catch OllamaUsageError.safariCookieAccessDenied { + // expected + } catch { + Issue.record("Expected Safari Full Disk Access error, got \(error)") + } + } + + @Test + func `cookie cooldown maps only the browser that was denied`() { + BrowserCookieAccessGate.resetForTesting() + defer { BrowserCookieAccessGate.resetForTesting() } + let now = Date(timeIntervalSince1970: 1000) + + KeychainAccessGate.withTaskOverrideForTesting(false) { + BrowserCookieAccessGate.recordDenied(for: .brave, now: now) + + let brave = OllamaCookieImporter.suppressedAccessError( + for: .brave, + now: now.addingTimeInterval(1)) + guard case let .browserCookieDecryptionDenied(browserName) = brave else { + Issue.record("Expected stored Brave Keychain denial") + return + } + #expect(browserName == "Brave") + #expect(OllamaCookieImporter.suppressedAccessError( + for: .chrome, + now: now.addingTimeInterval(1)) == nil) + } + } + + @Test + func `disabled Keychain access maps to browser recovery hint`() { + KeychainAccessGate.withTaskOverrideForTesting(true) { + let error = OllamaCookieImporter.suppressedAccessError(for: .brave) + guard case let .browserCookieDecryptionDisabled(browserName) = error else { + Issue.record("Expected disabled Brave Keychain error") + return + } + #expect(browserName == "Brave") + } + } + + @Test + func `manual refresh bypasses browser denial cooldown`() async { + await BrowserCookieAccessGate.withDeniedBrowsersForTesting([.brave]) { + KeychainAccessGate.withTaskOverrideForTesting(false) { + BrowserCookieAccessGate.withExplicitRetry { + ProviderInteractionContext.$current.withValue(.userInitiated) { + var accessError: OllamaUsageError? + let shouldAttempt = OllamaCookieImporter.shouldAttemptCookieSource( + .brave, + accessError: &accessError) + #expect(shouldAttempt) + #expect(accessError == nil) + } + } + } + } + } + @Test func `cookie selector skips session like noise and finds recognized cookie`() throws { let first = OllamaCookieImporter.SessionInfo( @@ -143,6 +597,16 @@ struct OllamaUsageFetcherTests { #expect(selected.sourceLabel == "Profile D") } + @Test + func `cookie selector accepts workos session cookie`() throws { + let candidate = OllamaCookieImporter.SessionInfo( + cookies: [Self.makeCookie(name: "wos-session", value: "auth")], + sourceLabel: "WorkOS Profile") + + let selected = try OllamaCookieImporter.selectSessionInfo(from: [candidate]) + #expect(selected.sourceLabel == "WorkOS Profile") + } + @Test func `cookie selector keeps recognized candidates in order`() throws { let first = OllamaCookieImporter.SessionInfo( diff --git a/Tests/CodexBarTests/OneConsoleJSONTests.swift b/Tests/CodexBarTests/OneConsoleJSONTests.swift new file mode 100644 index 0000000000..008e9b7e4f --- /dev/null +++ b/Tests/CodexBarTests/OneConsoleJSONTests.swift @@ -0,0 +1,65 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct OneConsoleJSONTests { + @Test + func `string lookup honors caller key priority across the full tree`() { + let value: [String: Any] = [ + "token": "generic-token", + "data": [ + "secToken": "preferred-sec-token", + ], + ] + + let result = OneConsoleJSON.findFirstString( + forKeys: ["secToken", "token"], + in: value) + + #expect(result == "preferred-sec-token") + } + + @Test + func `lookup skips invalid values before a nested valid value`() { + let value: [String: Any] = [ + "count": "not-a-number", + "data": [ + "count": "42", + ], + ] + + #expect(OneConsoleJSON.findFirstInt(forKeys: ["count"], in: value) == 42) + } + + @Test + func `array lookup preserves key priority and skips invalid values`() { + let value: [String: Any] = [ + "fallback": [1], + "preferred": "not-an-array", + "data": [ + "preferred": [2, 3], + ], + ] + + let result = OneConsoleJSON.findFirstArray( + forKeys: ["preferred", "fallback"], + in: value) as? [Int] + + #expect(result == [2, 3]) + } + + @Test + func `date only string round trips`() throws { + let date = try #require(OneConsoleJSON.date("2026-07-28")) + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.dateFormat = "yyyy-MM-dd" + + #expect(formatter.string(from: date) == "2026-07-28") + } + + @Test + func `numeric zero date is treated as missing`() { + #expect(OneConsoleJSON.date(0) == nil) + } +} diff --git a/Tests/CodexBarTests/OpenAIAPICreditBalanceTests.swift b/Tests/CodexBarTests/OpenAIAPICreditBalanceTests.swift index 88860dd5f6..a3948c4712 100644 --- a/Tests/CodexBarTests/OpenAIAPICreditBalanceTests.swift +++ b/Tests/CodexBarTests/OpenAIAPICreditBalanceTests.swift @@ -95,6 +95,32 @@ struct OpenAIAPICreditBalanceTests { #expect(usage.identity?.loginMethod == "API balance: $60.00") } + @Test + func `maps unauthorized legacy balance to admin key guidance`() async { + let transport = ProviderHTTPTransportStub { request in + let url = try #require(request.url) + let response = HTTPURLResponse( + url: url, + statusCode: 401, + httpVersion: nil, + headerFields: nil)! + return (Data("{}".utf8), response) + } + + do { + _ = try await OpenAIAPICreditBalanceFetcher.fetchBalance( + apiKey: "sk-test", + session: transport) + Issue.record("Expected credential rejection") + } catch let error as OpenAIAPICreditBalanceError { + #expect(error == .unauthorized) + #expect(error.errorDescription?.contains("organization Admin API key") == true) + #expect(error.errorDescription?.contains("service-account keys") == true) + } catch { + Issue.record("Expected OpenAIAPICreditBalanceError, got \(error)") + } + } + @Test func `falls back to legacy billing when admin usage rejects credentials`() async throws { let strategy = OpenAIAPIBalanceFetchStrategy( diff --git a/Tests/CodexBarTests/OpenAIAPIMenuCardModelTests.swift b/Tests/CodexBarTests/OpenAIAPIMenuCardModelTests.swift index b13eabe477..5d235db6fe 100644 --- a/Tests/CodexBarTests/OpenAIAPIMenuCardModelTests.swift +++ b/Tests/CodexBarTests/OpenAIAPIMenuCardModelTests.swift @@ -6,14 +6,15 @@ import Testing struct OpenAIAPIMenuCardModelTests { @Test func `admin usage model shows summaries and spend without fake quota bars`() throws { - let now = Date(timeIntervalSince1970: 1_700_179_200) + let now = try Self.localNoon(year: 2023, month: 11, day: 17) + let bucketDay = try Self.localNoon(year: 2023, month: 11, day: 14) let metadata = try #require(ProviderDefaults.metadata[.openai]) let apiUsage = OpenAIAPIUsageSnapshot( daily: [ OpenAIAPIUsageSnapshot.DailyBucket( day: "2023-11-14", - startTime: now, - endTime: now.addingTimeInterval(86400), + startTime: bucketDay, + endTime: bucketDay.addingTimeInterval(86400), costUSD: 12.5, requests: 40, inputTokens: 1000, @@ -51,19 +52,20 @@ struct OpenAIAPIMenuCardModelTests { usageBarsShowUsed: false, resetTimeDisplayStyle: .countdown, tokenCostUsageEnabled: false, + tokenCostInlineDashboardEnabled: true, showOptionalCreditsAndExtraUsage: true, hidePersonalInfo: false, now: now)) #expect(model.metrics.isEmpty) #expect(model.openAIAPIUsage != nil) - #expect(model.inlineUsageDashboard?.kpis.first?.value == "$12.50") + #expect(model.inlineUsageDashboard?.kpis.first?.value == "$0.00") #expect(model.inlineUsageDashboard?.kpis.last?.title == "Requests") #expect(model.inlineUsageDashboard?.kpis.last?.value == "40") #expect(model.inlineUsageDashboard?.points.count == 1) #expect(model.inlineUsageDashboard?.detailLines.contains("30d requests: 40 requests") == true) #expect(model.providerCost == nil) - #expect(model.usageNotes.contains { $0.contains("Today: $12.50") }) + #expect(model.usageNotes.contains { $0.contains("Today: $0.00") }) #expect(model.usageNotes.contains("Top model: gpt-5.2")) #expect(model.creditsText == nil) #expect(model.planText == "Admin API") @@ -119,14 +121,15 @@ struct OpenAIAPIMenuCardModelTests { @Test func `admin usage model can show cost card summary`() throws { - let now = Date(timeIntervalSince1970: 1_700_179_200) + let now = try Self.localNoon(year: 2023, month: 11, day: 17) + let bucketDay = try Self.localNoon(year: 2023, month: 11, day: 14) let metadata = try #require(ProviderDefaults.metadata[.openai]) let apiUsage = OpenAIAPIUsageSnapshot( daily: [ OpenAIAPIUsageSnapshot.DailyBucket( day: "2023-11-14", - startTime: now, - endTime: now.addingTimeInterval(86400), + startTime: bucketDay, + endTime: bucketDay.addingTimeInterval(86400), costUSD: 12.5, requests: 40, inputTokens: 1000, @@ -159,8 +162,12 @@ struct OpenAIAPIMenuCardModelTests { now: now)) #expect(ProviderDescriptorRegistry.descriptor(for: .openai).tokenCost.supportsTokenCost) - #expect(model.tokenUsage?.sessionLine == "Today: $12.50 · 1.5K tokens") + #expect(model.tokenUsage?.sessionLine == "Today: $0.00 · 0 tokens") #expect(model.tokenUsage?.monthLine == "Last 30 days: $12.50 · 1.5K tokens") #expect(model.tokenUsage?.hintLine == "Reported by OpenAI Admin API organization usage.") } + + private static func localNoon(year: Int, month: Int, day: Int) throws -> Date { + try #require(Calendar.current.date(from: DateComponents(year: year, month: month, day: day, hour: 12))) + } } diff --git a/Tests/CodexBarTests/OpenAIAPIStatusMenuTests.swift b/Tests/CodexBarTests/OpenAIAPIStatusMenuTests.swift index 002d2bd9cb..86ed7c165a 100644 --- a/Tests/CodexBarTests/OpenAIAPIStatusMenuTests.swift +++ b/Tests/CodexBarTests/OpenAIAPIStatusMenuTests.swift @@ -4,6 +4,109 @@ import Testing @testable import CodexBar extension StatusMenuTests { + @Test + func `open AI API primary dashboard ignores optional cost summary toggle`() throws { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.selectedMenuProvider = .openai + settings.costUsageEnabled = false + + let metadata = try #require(ProviderRegistry.shared.metadata[.openai]) + settings.setProviderEnabled(provider: .openai, metadata: metadata, enabled: true) + + let fetcher = UsageFetcher() + let store = UsageStore( + fetcher: fetcher, + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings) + let now = Date(timeIntervalSince1970: 1_700_000_000) + let usage = OpenAIAPIUsageSnapshot( + daily: [ + OpenAIAPIUsageSnapshot.DailyBucket( + day: "2023-11-14", + startTime: now, + endTime: now.addingTimeInterval(86400), + costUSD: 9, + requests: 12, + inputTokens: 100, + cachedInputTokens: 0, + outputTokens: 50, + totalTokens: 150, + lineItems: [], + models: []), + ], + updatedAt: now) + store._setSnapshotForTesting(usage.toUsageSnapshot(), provider: .openai) + + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + + let model = try #require(controller.menuCardModel(for: .openai)) + #expect(model.inlineUsageDashboard != nil) + #expect(model.tokenUsage == nil) + } + + @Test + func `open AI API usage submenu ignores optional local cost preferences`() throws { + self.disableMenuCardsForTesting() + + for style in CostSummaryDisplayStyle.allCases { + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.selectedMenuProvider = .openai + settings.costUsageEnabled = false + settings.costSummaryDisplayStyle = style + + let registry = ProviderRegistry.shared + let metadata = try #require(registry.metadata[.openai]) + settings.setProviderEnabled(provider: .openai, metadata: metadata, enabled: true) + + let fetcher = UsageFetcher() + let store = UsageStore( + fetcher: fetcher, + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings) + let now = Date(timeIntervalSince1970: 1_700_000_000) + let usage = OpenAIAPIUsageSnapshot( + daily: [ + OpenAIAPIUsageSnapshot.DailyBucket( + day: "2023-11-14", + startTime: now, + endTime: now.addingTimeInterval(86400), + costUSD: 9, + requests: 12, + inputTokens: 100, + cachedInputTokens: 0, + outputTokens: 50, + totalTokens: 150, + lineItems: [], + models: []), + ], + updatedAt: now) + store._setSnapshotForTesting(usage.toUsageSnapshot(), provider: .openai) + + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + + #expect(controller.makeOpenAIAPIUsageSubmenu(provider: .openai) != nil) + } + } + @Test func `open AI API usage submenu ignores stale token snapshot without current admin usage`() throws { self.disableMenuCardsForTesting() @@ -50,4 +153,73 @@ extension StatusMenuTests { #expect(controller.makeOpenAIAPIUsageSubmenu(provider: .openai) == nil) } + + @Test + func `mistral native billing submenus ignore optional local cost preferences`() throws { + StatusItemController.menuCardRenderingEnabled = true + StatusItemController.setMenuRefreshEnabledForTesting(false) + defer { self.disableMenuCardsForTesting() } + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.selectedMenuProvider = .mistral + settings.costUsageEnabled = false + settings.costSummaryDisplayStyle = .inlineSummary + + let metadata = try #require(ProviderRegistry.shared.metadata[.mistral]) + settings.setProviderEnabled(provider: .mistral, metadata: metadata, enabled: true) + + let fetcher = UsageFetcher() + let store = UsageStore( + fetcher: fetcher, + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings) + let now = Date(timeIntervalSince1970: 1_700_179_200) + let usage = MistralUsageSnapshot( + totalCost: 1.5, + currency: "EUR", + currencySymbol: "€", + totalInputTokens: 100, + totalOutputTokens: 50, + totalCachedTokens: 0, + modelCount: 1, + daily: [ + MistralDailyUsageBucket( + day: "2023-11-14", + cost: 1.5, + inputTokens: 100, + cachedTokens: 0, + outputTokens: 50, + models: []), + ], + startDate: nil, + endDate: nil, + updatedAt: now) + store._setSnapshotForTesting(usage.toUsageSnapshot(), provider: .mistral) + + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + + let model = try #require(controller.menuCardModel(for: .mistral)) + #expect(model.inlineUsageDashboard != nil) + #expect(model.tokenUsage == nil) + #expect(controller.makeOverviewRowSubmenu(provider: .mistral, model: model, width: 320) != nil) + + let menu = controller.makeMenu(for: .mistral) + controller.menuWillOpen(menu) + let usageItem = menu.items.first { ($0.representedObject as? String) == "menuCardUsage" } + #expect(usageItem?.submenu != nil) + + settings.costUsageEnabled = true + settings.costSummaryDisplayStyle = .both + let costsEnabledModel = try #require(controller.menuCardModel(for: .mistral)) + #expect(costsEnabledModel.inlineUsageDashboard != nil) + #expect(costsEnabledModel.tokenUsage == nil) + } } diff --git a/Tests/CodexBarTests/OpenAIAPIUsageFetcherTests.swift b/Tests/CodexBarTests/OpenAIAPIUsageFetcherTests.swift index df6f513b6f..eb3527efb6 100644 --- a/Tests/CodexBarTests/OpenAIAPIUsageFetcherTests.swift +++ b/Tests/CodexBarTests/OpenAIAPIUsageFetcherTests.swift @@ -111,6 +111,38 @@ struct OpenAIAPIUsageFetcherTests { #expect(snapshot.topModels.first?.totalTokens == 1800) } + @Test(arguments: ["NaN", "Infinity", "-Infinity", "1e309", "-1e309"]) + func `rejects nonfinite cost strings`(value: String) { + let costs = """ + { + "data": [{ + "start_time": 1700000000, + "end_time": 1700086400, + "results": [{ "amount": { "value": "\(value)", "currency": "usd" } }] + }], + "has_more": false, + "next_page": null + } + """ + let completions = #"{"data":[],"has_more":false,"next_page":null}"# + + do { + _ = try OpenAIAPIUsageFetcher._parseSnapshotForTesting( + costs: Data(costs.utf8), + completions: Data(completions.utf8), + now: Date(timeIntervalSince1970: 1_700_179_200)) + Issue.record("Expected a costs parse failure.") + } catch let error as OpenAIAPIUsageError { + guard case let .parseFailed(endpoint, _) = error else { + Issue.record("Expected a costs parse failure, got \(error).") + return + } + #expect(endpoint == "costs") + } catch { + Issue.record("Expected OpenAIAPIUsageError, got \(error).") + } + } + @Test func `admin usage fetch pages long history within endpoint bucket limit`() async throws { let now = Date(timeIntervalSince1970: 1_700_179_200) @@ -190,6 +222,127 @@ struct OpenAIAPIUsageFetcherTests { #expect(groupBys == ["line_item", "model"]) } + @Test + func `admin usage follows costs and completions pagination cursors`() async throws { + let now = Date(timeIntervalSince1970: 1_700_179_200) + let transport = OpenAIAdminUsagePaginationScript() + + let snapshot = try await OpenAIAPIUsageFetcher.fetchUsage( + apiKey: "sk-test", + projectID: "proj_abc", + costsURL: #require(URL(string: "https://api.openai.test/v1/organization/costs")), + completionsURL: #require(URL(string: "https://api.openai.test/v1/organization/usage/completions")), + session: transport, + now: now, + historyDays: 1) + + let requests = await transport.requests() + let costsRequests = requests.filter { $0.url?.path.contains("/organization/costs") == true } + let completionRequests = requests.filter { $0.url?.path.contains("/usage/completions") == true } + + #expect(snapshot.daily.count == 1) + #expect(snapshot.latestDay.costUSD == 4.0) + #expect(snapshot.latestDay.requests == 3) + #expect(snapshot.latestDay.totalTokens == 45) + #expect(costsRequests.count == 2) + #expect(completionRequests.count == 2) + #expect(Self.queryValue("page", in: costsRequests[0]) == nil) + #expect(Self.queryValue("page", in: costsRequests[1]) == "costs_page_2") + #expect(Self.queryValue("page", in: completionRequests[0]) == nil) + #expect(Self.queryValue("page", in: completionRequests[1]) == "completions_page_2") + #expect(requests.allSatisfy { Self.queryValue("project_ids", in: $0) == "proj_abc" }) + } + + @Test + func `admin usage rejects repeated pagination cursor`() async throws { + let transport = OpenAIAdminUsageRepeatingCursorScript() + + await #expect(throws: OpenAIAPIUsageError.parseFailed( + endpoint: "costs", + message: "Pagination cursor repeated.")) + { + try await OpenAIAPIUsageFetcher.fetchUsage( + apiKey: "sk-test", + costsURL: #require(URL(string: "https://api.openai.test/v1/organization/costs")), + completionsURL: #require(URL(string: "https://api.openai.test/v1/organization/usage/completions")), + session: transport, + now: Date(timeIntervalSince1970: 1_700_179_200), + historyDays: 1) + } + } + + @Test + func `admin usage rejects missing pagination cursor`() async throws { + let transport = OpenAIAdminUsageMissingCursorScript() + + await #expect(throws: OpenAIAPIUsageError.parseFailed( + endpoint: "costs", + message: "Pagination cursor missing.")) + { + try await OpenAIAPIUsageFetcher.fetchUsage( + apiKey: "sk-test", + costsURL: #require(URL(string: "https://api.openai.test/v1/organization/costs")), + completionsURL: #require(URL(string: "https://api.openai.test/v1/organization/usage/completions")), + session: transport, + now: Date(timeIntervalSince1970: 1_700_179_200), + historyDays: 1) + } + } + + @Test + func `admin usage rejects page without costs data`() async throws { + let transport = OpenAIAdminUsageMalformedPageScript( + costs: #"{"object":"page","has_more":false,"next_page":null}"#, + completions: #"{"object":"page","data":[],"has_more":false,"next_page":null}"#) + + do { + _ = try await OpenAIAPIUsageFetcher.fetchUsage( + apiKey: "sk-test", + costsURL: #require(URL(string: "https://api.openai.test/v1/organization/costs")), + completionsURL: #require(URL(string: "https://api.openai.test/v1/organization/usage/completions")), + session: transport, + now: Date(timeIntervalSince1970: 1_700_179_200), + historyDays: 1) + Issue.record("Expected costs parse failure.") + } catch let error as OpenAIAPIUsageError { + guard case let .parseFailed(endpoint, message) = error else { + Issue.record("Expected parse failure, got \(error).") + return + } + #expect(endpoint == "costs") + #expect(message.contains("data")) + } catch { + Issue.record("Expected OpenAIAPIUsageError, got \(error).") + } + } + + @Test + func `admin usage rejects page without completions pagination state`() async throws { + let transport = OpenAIAdminUsageMalformedPageScript( + costs: #"{"object":"page","data":[],"has_more":false,"next_page":null}"#, + completions: #"{"object":"page","data":[],"next_page":null}"#) + + do { + _ = try await OpenAIAPIUsageFetcher.fetchUsage( + apiKey: "sk-test", + costsURL: #require(URL(string: "https://api.openai.test/v1/organization/costs")), + completionsURL: #require(URL(string: "https://api.openai.test/v1/organization/usage/completions")), + session: transport, + now: Date(timeIntervalSince1970: 1_700_179_200), + historyDays: 1) + Issue.record("Expected completions parse failure.") + } catch let error as OpenAIAPIUsageError { + guard case let .parseFailed(endpoint, message) = error else { + Issue.record("Expected parse failure, got \(error).") + return + } + #expect(endpoint == "completions") + #expect(message.contains("missing")) + } catch { + Issue.record("Expected OpenAIAPIUsageError, got \(error).") + } + } + @Test func `admin usage retries transient completions failure once`() async throws { let now = Date(timeIntervalSince1970: 1_700_179_200) @@ -264,14 +417,16 @@ struct OpenAIAPIUsageFetcherTests { } @Test - func `maps project scoped admin usage to cost token snapshot`() { - let now = Date(timeIntervalSince1970: 1_700_179_200) + func `maps project scoped admin usage to cost token snapshot`() throws { + let now = try Self.localNoon(year: 2023, month: 11, day: 17) + let firstDay = try Self.localNoon(year: 2023, month: 11, day: 13) + let secondDay = try Self.localNoon(year: 2023, month: 11, day: 14) let apiUsage = OpenAIAPIUsageSnapshot( daily: [ OpenAIAPIUsageSnapshot.DailyBucket( day: "2023-11-13", - startTime: now.addingTimeInterval(-86400), - endTime: now, + startTime: firstDay, + endTime: firstDay.addingTimeInterval(86400), costUSD: 2.25, requests: 3, inputTokens: 300, @@ -290,8 +445,8 @@ struct OpenAIAPIUsageFetcherTests { ]), OpenAIAPIUsageSnapshot.DailyBucket( day: "2023-11-14", - startTime: now, - endTime: now.addingTimeInterval(86400), + startTime: secondDay, + endTime: secondDay.addingTimeInterval(86400), costUSD: 8.5, requests: 42, inputTokens: 1000, @@ -321,9 +476,11 @@ struct OpenAIAPIUsageFetcherTests { #expect(usage.identity?.accountOrganization == "Project: proj_abc") #expect(snapshot.historyDays == 7) #expect(snapshot.currencyCode == "USD") - #expect(snapshot.sessionCostUSD == 8.5) - #expect(snapshot.sessionTokens == 1250) - #expect(snapshot.sessionRequests == 42) + #expect(apiUsage.currentDay.costUSD == 0) + #expect(apiUsage.currentDay.totalTokens == 0) + #expect(snapshot.sessionCostUSD == 0) + #expect(snapshot.sessionTokens == 0) + #expect(snapshot.sessionRequests == 0) #expect(snapshot.last30DaysCostUSD == 10.75) #expect(snapshot.last30DaysTokens == 1750) #expect(snapshot.last30DaysRequests == 45) @@ -333,6 +490,200 @@ struct OpenAIAPIUsageFetcherTests { #expect(snapshot.daily[1].modelBreakdowns?.first?.requestCount == 42) #expect(snapshot.daily[1].modelBreakdowns?.first?.modelName == "gpt-5.2-codex") } + + private static func queryValue(_ name: String, in request: URLRequest) -> String? { + guard let url = request.url, + let components = URLComponents(url: url, resolvingAgainstBaseURL: false) + else { return nil } + return components.queryItems?.first(where: { $0.name == name })?.value + } + + private static func localNoon(year: Int, month: Int, day: Int) throws -> Date { + try #require(Calendar.current.date(from: DateComponents(year: year, month: month, day: day, hour: 12))) + } +} + +private actor OpenAIAdminUsagePaginationScript: ProviderHTTPTransport { + private var recordedRequests: [URLRequest] = [] + + func requests() -> [URLRequest] { + self.recordedRequests + } + + func data(for request: URLRequest) throws -> (Data, URLResponse) { + self.recordedRequests.append(request) + let url = request.url ?? URL(string: "https://api.openai.test")! + let page = Self.queryValue("page", in: url) + let body: String = if url.path.contains("/organization/costs") { + page == "costs_page_2" ? Self.costsPage2 : Self.costsPage1 + } else if url.path.contains("/usage/completions") { + page == "completions_page_2" ? Self.completionsPage2 : Self.completionsPage1 + } else { + #"{"object":"page","data":[],"has_more":false,"next_page":null}"# + } + return (Data(body.utf8), HTTPURLResponse( + url: url, + statusCode: 200, + httpVersion: "HTTP/1.1", + headerFields: nil)!) + } + + private static func queryValue(_ name: String, in url: URL) -> String? { + URLComponents(url: url, resolvingAgainstBaseURL: false)? + .queryItems? + .first(where: { $0.name == name })? + .value + } + + private static let costsPage1 = """ + { + "object": "page", + "data": [ + { + "object": "bucket", + "start_time": 1700000000, + "end_time": 1700086400, + "results": [ + { + "object": "organization.costs.result", + "amount": { "value": 1.25, "currency": "usd" }, + "line_item": "Text tokens" + } + ] + } + ], + "has_more": true, + "next_page": "costs_page_2" + } + """ + + private static let costsPage2 = """ + { + "object": "page", + "data": [ + { + "object": "bucket", + "start_time": 1700000000, + "end_time": 1700086400, + "results": [ + { + "object": "organization.costs.result", + "amount": { "value": 2.75, "currency": "usd" }, + "line_item": "Web search tool calls" + } + ] + } + ], + "has_more": false, + "next_page": null + } + """ + + private static let completionsPage1 = """ + { + "object": "page", + "data": [ + { + "object": "bucket", + "start_time": 1700000000, + "end_time": 1700086400, + "results": [ + { + "object": "organization.usage.completions.result", + "input_tokens": 10, + "output_tokens": 5, + "num_model_requests": 1, + "model": "gpt-5.2" + } + ] + } + ], + "has_more": true, + "next_page": "completions_page_2" + } + """ + + private static let completionsPage2 = """ + { + "object": "page", + "data": [ + { + "object": "bucket", + "start_time": 1700000000, + "end_time": 1700086400, + "results": [ + { + "object": "organization.usage.completions.result", + "input_tokens": 20, + "output_tokens": 10, + "num_model_requests": 2, + "model": "gpt-5.2" + } + ] + } + ], + "has_more": false, + "next_page": null + } + """ +} + +private actor OpenAIAdminUsageRepeatingCursorScript: ProviderHTTPTransport { + func data(for request: URLRequest) throws -> (Data, URLResponse) { + let url = request.url ?? URL(string: "https://api.openai.test")! + let body = """ + { + "object": "page", + "data": [], + "has_more": true, + "next_page": "same_page" + } + """ + return (Data(body.utf8), HTTPURLResponse( + url: url, + statusCode: 200, + httpVersion: "HTTP/1.1", + headerFields: nil)!) + } +} + +private actor OpenAIAdminUsageMissingCursorScript: ProviderHTTPTransport { + func data(for request: URLRequest) throws -> (Data, URLResponse) { + let url = request.url ?? URL(string: "https://api.openai.test")! + let body = """ + { + "object": "page", + "data": [], + "has_more": true, + "next_page": null + } + """ + return (Data(body.utf8), HTTPURLResponse( + url: url, + statusCode: 200, + httpVersion: "HTTP/1.1", + headerFields: nil)!) + } +} + +private actor OpenAIAdminUsageMalformedPageScript: ProviderHTTPTransport { + private let costs: String + private let completions: String + + init(costs: String, completions: String) { + self.costs = costs + self.completions = completions + } + + func data(for request: URLRequest) throws -> (Data, URLResponse) { + let url = request.url ?? URL(string: "https://api.openai.test")! + let body = url.path.contains("/usage/completions") ? self.completions : self.costs + return (Data(body.utf8), HTTPURLResponse( + url: url, + statusCode: 200, + httpVersion: "HTTP/1.1", + headerFields: nil)!) + } } private actor OpenAIAdminUsageRetryScript: ProviderHTTPTransport { diff --git a/Tests/CodexBarTests/OpenAIDashboardBrowserCookieImporterTests.swift b/Tests/CodexBarTests/OpenAIDashboardBrowserCookieImporterTests.swift index 357d49f91d..f581f01e55 100644 --- a/Tests/CodexBarTests/OpenAIDashboardBrowserCookieImporterTests.swift +++ b/Tests/CodexBarTests/OpenAIDashboardBrowserCookieImporterTests.swift @@ -2,7 +2,346 @@ import Foundation import Testing @testable import CodexBarCore +private final class CookieCallbackHarness: @unchecked Sendable { + private let lock = NSLock() + private var callback: (@Sendable () -> Void)? + + func capture(_ callback: @escaping @Sendable () -> Void) { + self.lock.withLock { self.callback = callback } + } + + func finish() { + let callback = self.lock.withLock { + let callback = self.callback + self.callback = nil + return callback + } + callback?() + } +} + +private final class CookieCallbackFlag: @unchecked Sendable { + private let lock = NSLock() + private var storedValue = false + + var value: Bool { + self.lock.withLock { self.storedValue } + } + + func set() { + self.lock.withLock { self.storedValue = true } + } +} + +private final class CookieOperationLog: @unchecked Sendable { + private let lock = NSLock() + private var entries: [String] = [] + + var snapshot: [String] { + self.lock.withLock { self.entries } + } + + func append(_ entry: String) { + self.lock.withLock { self.entries.append(entry) } + } +} + +private final class CookieTimeoutProbe: @unchecked Sendable { + private let lock = NSLock() + private var storedFiredAt: Date? + + var firedAt: Date? { + self.lock.withLock { self.storedFiredAt } + } + + func record() { + self.lock.withLock { + if self.storedFiredAt == nil { + self.storedFiredAt = Date() + } + } + } +} + struct OpenAIDashboardBrowserCookieImporterTests { + @Test + func `profile denial names exact running component`() { + let hint = OpenAIDashboardBrowserCookieImporter.browserProfileAccessHint( + for: .chrome, + issue: .accessDenied, + processName: "CodexBarCLI", + executablePath: "/Applications/CodexBar.app/Contents/Helpers/CodexBarCLI") + + #expect(hint.contains("macOS denied Chrome profile access")) + #expect(hint.contains("CodexBarCLI (/Applications/CodexBar.app/Contents/Helpers/CodexBarCLI)")) + #expect(hint.contains("Full Disk Access")) + } + + @Test + func `profile denial names app bundle for menu refresh`() { + let hint = OpenAIDashboardBrowserCookieImporter.browserProfileAccessHint( + for: .chrome, + issue: .accessDenied, + processName: "CodexBar", + executablePath: "/Applications/CodexBar.app/Contents/MacOS/CodexBar") + + #expect(hint.contains("CodexBar.app (/Applications/CodexBar.app)")) + } + + @Test + func `browser cookie timeout remains distinct from permission denial`() { + let error = OpenAIDashboardBrowserCookieImporter.browserCookieLoadTimeoutError( + for: .chrome, + processName: "CodexBarCLI", + executablePath: "/Applications/CodexBar.app/Contents/Helpers/CodexBarCLI") + + if case .browserCookieLoadTimedOut = error { + // Expected: a shared deadline does not prove macOS denied access. + } else { + Issue.record("Expected browser cookie load timeout") + } + #expect(error.localizedDescription.contains("Chrome did not finish before the web timeout")) + #expect(!error.localizedDescription.contains("access denied")) + #expect(error.localizedDescription.contains("CodexBarCLI")) + #expect(error.localizedDescription.contains("Keychain prompt")) + #expect(error.localizedDescription.contains("Full Disk Access")) + } + + @Test + func `shared deadline clamps each local timeout to remaining budget`() throws { + let start = Date(timeIntervalSinceReferenceDate: 1000) + let deadline = start.addingTimeInterval(30) + + let remaining = try OpenAIDashboardBrowserCookieImporter.remainingTimeout( + until: deadline, + cappedAt: 10, + now: start.addingTimeInterval(27)) + + #expect(remaining == 3) + } + + @Test + func `shared deadline preserves smaller local timeout`() throws { + let start = Date(timeIntervalSinceReferenceDate: 1000) + let deadline = start.addingTimeInterval(30) + + let remaining = try OpenAIDashboardBrowserCookieImporter.remainingTimeout( + until: deadline, + cappedAt: 10, + now: start.addingTimeInterval(5)) + + #expect(remaining == 10) + } + + @Test + func `expired shared deadline throws structured timeout`() { + let deadline = Date(timeIntervalSinceReferenceDate: 1000) + + do { + _ = try OpenAIDashboardBrowserCookieImporter.remainingTimeout( + until: deadline, + now: deadline) + Issue.record("Expected deadline timeout") + } catch let error as URLError { + #expect(error.code == .timedOut) + } catch { + Issue.record("Unexpected error: \(error)") + } + } + + @Test + func `blocking browser cookie load cannot exceed shared deadline`() async throws { + let start = Date() + let timeoutProbe = CookieTimeoutProbe() + + do { + _ = try await OpenAIDashboardBrowserCookieImporter.runBoundedCookieLoad( + deadline: start.addingTimeInterval(0.05), + timeoutObserver: timeoutProbe.record) + { + Thread.sleep(forTimeInterval: 0.5) + return true + } + Issue.record("Expected cookie load timeout") + } catch let error as URLError { + #expect(error.code == .timedOut) + } catch { + Issue.record("Unexpected error: \(error)") + } + let firedAt = try #require(timeoutProbe.firedAt) + #expect(firedAt.timeIntervalSince(start) < 0.3) + } + + @Test + func `timeout observer stays silent when operation wins`() async throws { + let timeoutProbe = CookieTimeoutProbe() + + let value = try await OpenAIDashboardBrowserCookieImporter.runBoundedCookieLoad( + deadline: Date().addingTimeInterval(0.05), + timeoutObserver: timeoutProbe.record) + { + true + } + try await Task.sleep(for: .milliseconds(100)) + + #expect(value) + #expect(timeoutProbe.firedAt == nil) + } + + @Test + func `bounded cookie loads preserve explicit retry context`() async throws { + BrowserCookieAccessGate.resetForTesting() + defer { BrowserCookieAccessGate.resetForTesting() } + let start = Date() + + for deadline in [nil, Date().addingTimeInterval(1)] { + BrowserCookieAccessGate.resetForTesting() + BrowserCookieAccessGate.recordDenied(for: .arc, now: start) + + let allowed = try await BrowserCookieAccessGate.withExplicitRetry { + try await ProviderInteractionContext.$current.withValue(.userInitiated) { + try await OpenAIDashboardBrowserCookieImporter.runBoundedCookieLoad(deadline: deadline) { + KeychainAccessGate.withTaskOverrideForTesting(false) { + ProviderInteractionContext.current == .userInitiated && + BrowserCookieAccessGate.shouldAttempt(.arc, now: start.addingTimeInterval(1)) + } + } + } + } + #expect(allowed) + } + } + + @Test + func `timed out cookie cache work stays ordered before retry`() async throws { + let log = CookieOperationLog() + let firstOperationStarted = DispatchSemaphore(value: 0) + let allowFirstOperationToFinish = DispatchSemaphore(value: 0) + + do { + _ = try await OpenAIDashboardBrowserCookieImporter.runBoundedCookieCacheOperation( + deadline: Date().addingTimeInterval(0.05)) + { + log.append("first-start") + firstOperationStarted.signal() + _ = allowFirstOperationToFinish.wait(timeout: .now() + 5) + log.append("first-end") + return true + } + Issue.record("Expected first cache operation timeout") + } catch let error as URLError { + #expect(error.code == .timedOut) + } catch { + Issue.record("Unexpected error: \(error)") + } + let firstOperationStartResult = await withCheckedContinuation { continuation in + DispatchQueue.global(qos: .userInitiated).async { + continuation.resume(returning: firstOperationStarted.wait(timeout: .now() + 5)) + } + } + #expect(firstOperationStartResult == .success) + do { + _ = try await OpenAIDashboardBrowserCookieImporter.runBoundedCookieCacheOperation( + deadline: Date().addingTimeInterval(0.05)) + { + log.append("second") + return true + } + Issue.record("Expected retry to wait behind first cache operation") + } catch let error as URLError { + #expect(error.code == .timedOut) + } catch { + Issue.record("Unexpected error: \(error)") + } + allowFirstOperationToFinish.signal() + + _ = try await OpenAIDashboardBrowserCookieImporter.runBoundedCookieCacheOperation( + deadline: Date().addingTimeInterval(1)) { true } + #expect(log.snapshot == ["first-start", "first-end", "second"]) + } + + @Test @MainActor + func `slow callback times out before completion`() async throws { + let start = Date() + let timeoutProbe = CookieTimeoutProbe() + + do { + try await OpenAIDashboardBrowserCookieImporter.runBoundedCallback( + deadline: start.addingTimeInterval(0.05), + timeoutObserver: timeoutProbe.record) + { completion in + DispatchQueue.global(qos: .userInitiated).asyncAfter(deadline: .now() + 0.5) { + completion() + } + } + Issue.record("Expected callback timeout") + } catch let error as URLError { + #expect(error.code == .timedOut) + } catch { + Issue.record("Unexpected error: \(error)") + } + let firedAt = try #require(timeoutProbe.firedAt) + #expect(firedAt.timeIntervalSince(start) < 0.3) + } + + @Test @MainActor + func `slow value callback times out before completion`() async throws { + let start = Date() + let timeoutProbe = CookieTimeoutProbe() + + do { + let _: [String] = try await OpenAIDashboardBrowserCookieImporter.runBoundedValueCallback( + deadline: start.addingTimeInterval(0.05), + timeoutObserver: timeoutProbe.record) + { completion in + DispatchQueue.global(qos: .userInitiated).asyncAfter(deadline: .now() + 0.5) { + completion([]) + } + } + Issue.record("Expected value callback timeout") + } catch let error as URLError { + #expect(error.code == .timedOut) + } catch { + Issue.record("Unexpected error: \(error)") + } + let firedAt = try #require(timeoutProbe.firedAt) + #expect(firedAt.timeIntervalSince(start) < 0.3) + } + + @Test @MainActor + func `retry waits for timed out cookie store mutation`() async throws { + let keyOwner = NSObject() + let key = ObjectIdentifier(keyOwner) + let first = CookieCallbackHarness() + + do { + try await OpenAIDashboardBrowserCookieImporter.runSerializedCallback( + key: key, + deadline: Date().addingTimeInterval(0.05), + start: first.capture) + Issue.record("Expected first mutation timeout") + } catch let error as URLError { + #expect(error.code == .timedOut) + } + + let secondStarted = CookieCallbackFlag() + let second = Task { @MainActor in + try await OpenAIDashboardBrowserCookieImporter.runSerializedCallback( + key: key, + deadline: Date().addingTimeInterval(1)) + { completion in + secondStarted.set() + completion() + } + } + + try await Task.sleep(for: .milliseconds(50)) + #expect(!secondStarted.value) + first.finish() + try await second.value + #expect(secondStarted.value) + } + @Test func `mismatch error mentions source label`() { let err = OpenAIDashboardBrowserCookieImporter.ImportError.noMatchingAccount( @@ -17,7 +356,14 @@ struct OpenAIDashboardBrowserCookieImporterTests { @Test func `timed out persistent validation keeps verified session`() { + let failure = OpenAIDashboardBrowserCookieImporter.persistentValidationFailure(URLError(.timedOut)) #expect(OpenAIDashboardBrowserCookieImporter.shouldTrustVerifiedSession( + afterPersistFailure: failure)) + } + + @Test + func `raw cookie mutation timeout is not trusted`() { + #expect(!OpenAIDashboardBrowserCookieImporter.shouldTrustVerifiedSession( afterPersistFailure: URLError(.timedOut))) } diff --git a/Tests/CodexBarTests/OpenAIDashboardFetcherCreditsWaitTests.swift b/Tests/CodexBarTests/OpenAIDashboardFetcherCreditsWaitTests.swift index e75da4dc5b..9ad31c9fd2 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")) @@ -249,6 +268,7 @@ struct OpenAIDashboardFetcherCreditsWaitTests { #expect(request.value(forHTTPHeaderField: "Cookie") == "a=b") #expect(request.value(forHTTPHeaderField: "Accept") == "application/json") #expect(request.value(forHTTPHeaderField: "Accept-Language") == "en-US,en;q=0.9") + #expect(request.cachePolicy == .reloadIgnoringLocalCacheData) } @Test @@ -260,6 +280,22 @@ struct OpenAIDashboardFetcherCreditsWaitTests { #expect(request.value(forHTTPHeaderField: "Cookie") == "a=b") #expect(request.value(forHTTPHeaderField: "Accept") == "application/json") #expect(request.value(forHTTPHeaderField: "Accept-Language") == "en-US,en;q=0.9") + #expect(request.cachePolicy == .reloadIgnoringLocalCacheData) + } + + @Test + func `dashboard api requests accept shared deadline timeout clamps`() throws { + let url = try #require(URL(string: "https://chatgpt.com/backend-api/me")) + let usageRequest = OpenAIDashboardFetcher.dashboardUsageAPIRequest( + cookieHeader: "a=b", + timeout: 1.25) + let identityRequest = OpenAIDashboardFetcher.dashboardIdentityAPIRequest( + url: url, + cookieHeader: "a=b", + timeout: 0.75) + + #expect(usageRequest.timeoutInterval == 1.25) + #expect(identityRequest.timeoutInterval == 0.75) } @Test diff --git a/Tests/CodexBarTests/OpenAIDashboardModelsTests.swift b/Tests/CodexBarTests/OpenAIDashboardModelsTests.swift index b6547e14fe..037d4c8cbe 100644 --- a/Tests/CodexBarTests/OpenAIDashboardModelsTests.swift +++ b/Tests/CodexBarTests/OpenAIDashboardModelsTests.swift @@ -3,6 +3,16 @@ import Foundation import Testing struct OpenAIDashboardModelsTests { + private static let utcCalendar: Calendar = { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(secondsFromGMT: 0)! + return calendar + }() + + private static func utcDate(year: Int, month: Int, day: Int) -> Date { + self.utcCalendar.date(from: DateComponents(year: year, month: month, day: day, hour: 12))! + } + @Test func `removes skill usage services from usage breakdown`() { let breakdown = [ @@ -91,4 +101,117 @@ struct OpenAIDashboardModelsTests { totalCreditsUsed: 4), ]) } + + @Test + func `recent credit totals use calendar days and exclude future rows`() { + let summary = OpenAIDashboardDailyBreakdown.recentUsageSummary( + from: [ + .init(day: "2026-05-31", services: [], totalCreditsUsed: 100), + .init(day: "2026-06-01", services: [], totalCreditsUsed: 1), + .init(day: "2026-06-29", services: [], totalCreditsUsed: 2), + .init(day: "2026-06-30", services: [], totalCreditsUsed: 3), + .init(day: "2026-07-01", services: [], totalCreditsUsed: 200), + ], + historyDays: 30, + now: Self.utcDate(year: 2026, month: 6, day: 30), + calendar: Self.utcCalendar) + + #expect(summary.historyDays == 30) + #expect(summary.todayCredits == 3) + #expect(summary.totalCredits == 6) + #expect(summary.daily.map(\.day) == ["2026-06-01", "2026-06-29", "2026-06-30"]) + } + + @Test + func `recent credit totals preserve gaps and sanitize invalid values`() throws { + let summary = OpenAIDashboardDailyBreakdown.recentUsageSummary( + from: [ + .init( + day: "2026-06-20", + services: [ + .init(service: "CLI", creditsUsed: 4), + .init(service: "bad", creditsUsed: .nan), + .init(service: "negative", creditsUsed: -2), + ], + totalCreditsUsed: 999), + .init(day: "2026-06-31", services: [], totalCreditsUsed: 9), + .init(day: "2026-06-30", services: [], totalCreditsUsed: .infinity), + ], + now: Self.utcDate(year: 2026, month: 6, day: 30), + calendar: Self.utcCalendar) + + #expect(summary.todayCredits == 0) + #expect(summary.totalCredits == 4) + let day = try #require(summary.daily.first) + #expect(day.day == "2026-06-20") + #expect(day.totalCreditsUsed == 4) + #expect(day.services.map(\.service) == ["CLI"]) + } + + @Test + func `recent credit totals report zero when history has no row for today`() { + let summary = OpenAIDashboardDailyBreakdown.recentUsageSummary( + from: [ + .init(day: "2026-06-29", services: [], totalCreditsUsed: 4), + ], + now: Self.utcDate(year: 2026, month: 6, day: 30), + calendar: Self.utcCalendar) + + #expect(summary.todayCredits == 0) + #expect(summary.totalCredits == 4) + #expect(summary.daily.map(\.day) == ["2026-06-29"]) + } + + @Test + func `recent credit totals fail closed on overflow`() { + let summary = OpenAIDashboardDailyBreakdown.recentUsageSummary( + from: [ + .init( + day: "2026-06-30", + services: [ + .init(service: "CLI", creditsUsed: Double.greatestFiniteMagnitude), + .init(service: "Desktop App", creditsUsed: Double.greatestFiniteMagnitude), + ], + totalCreditsUsed: 1), + ], + now: Self.utcDate(year: 2026, month: 6, day: 30), + calendar: Self.utcCalendar) + + #expect(summary.daily.isEmpty) + #expect(summary.todayCredits == nil) + #expect(summary.totalCredits == nil) + } + + @Test + func `recent credit totals respect the selected timezone`() throws { + var pacific = Calendar(identifier: .gregorian) + pacific.timeZone = try #require(TimeZone(identifier: "America/Los_Angeles")) + let now = try #require(ISO8601DateFormatter().date(from: "2026-07-01T06:30:00Z")) + + let summary = OpenAIDashboardDailyBreakdown.recentUsageSummary( + from: [ + .init(day: "2026-06-30", services: [], totalCreditsUsed: 7), + .init(day: "2026-07-01", services: [], totalCreditsUsed: 11), + ], + now: now, + calendar: pacific) + + #expect(summary.todayCredits == 7) + #expect(summary.totalCredits == 7) + #expect(summary.daily.map(\.day) == ["2026-06-30"]) + } + + @Test + func `recent credit totals keep Gregorian dashboard keys with a non Gregorian system calendar`() throws { + var buddhist = Calendar(identifier: .buddhist) + buddhist.timeZone = try #require(TimeZone(secondsFromGMT: 0)) + + let summary = OpenAIDashboardDailyBreakdown.recentUsageSummary( + from: [.init(day: "2026-06-30", services: [], totalCreditsUsed: 7)], + now: Self.utcDate(year: 2026, month: 6, day: 30), + calendar: buddhist) + + #expect(summary.todayCredits == 7) + #expect(summary.totalCredits == 7) + } } diff --git a/Tests/CodexBarTests/OpenAIDashboardNavigationDelegateTests.swift b/Tests/CodexBarTests/OpenAIDashboardNavigationDelegateTests.swift index 0ee8e0e70c..37db0483a6 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/OpenAIDashboardWebViewCacheTests.swift b/Tests/CodexBarTests/OpenAIDashboardWebViewCacheTests.swift index fb88a4103a..dce9826433 100644 --- a/Tests/CodexBarTests/OpenAIDashboardWebViewCacheTests.swift +++ b/Tests/CodexBarTests/OpenAIDashboardWebViewCacheTests.swift @@ -19,6 +19,34 @@ struct OpenAIDashboardWebViewCacheTests { // MARK: - Data Store Identity Tests + @Test + func `navigation retry uses only remaining shared deadline`() throws { + let start = Date(timeIntervalSinceReferenceDate: 1000) + let deadline = start.addingTimeInterval(10) + + let remaining = try OpenAIDashboardWebViewCache.remainingNavigationTimeout( + until: deadline, + now: start.addingTimeInterval(9.75)) + + #expect(remaining == 0.25) + } + + @Test + func `navigation retry refuses expired shared deadline`() { + let deadline = Date(timeIntervalSinceReferenceDate: 1000) + + do { + _ = try OpenAIDashboardWebViewCache.remainingNavigationTimeout( + until: deadline, + now: deadline) + Issue.record("Expected deadline timeout") + } catch let error as URLError { + #expect(error.code == .timedOut) + } catch { + Issue.record("Unexpected error: \(error)") + } + } + @Test func `WKWebsiteDataStore should return same instance for same email`() { if self.shouldSkipOnCI() { return } @@ -38,6 +66,43 @@ struct OpenAIDashboardWebViewCacheTests { OpenAIDashboardWebsiteDataStore.clearCacheForTesting() } + @Test + func `same email profile homes use distinct website data stores`() { + OpenAIDashboardWebsiteDataStore.clearCacheForTesting() + defer { OpenAIDashboardWebsiteDataStore.clearCacheForTesting() } + + let profileA = CookieHeaderCache.Scope.profileHome("/tmp/codex-profile-a") + let profileB = CookieHeaderCache.Scope.profileHome("/tmp/codex-profile-b") + let storeA = OpenAIDashboardWebsiteDataStore.store( + forAccountEmail: "shared@example.com", + scope: profileA) + let storeAAgain = OpenAIDashboardWebsiteDataStore.store( + forAccountEmail: "SHARED@example.com", + scope: profileA) + let storeB = OpenAIDashboardWebsiteDataStore.store( + forAccountEmail: "shared@example.com", + scope: profileB) + let liveStore = OpenAIDashboardWebsiteDataStore.store(forAccountEmail: "shared@example.com") + + #expect(storeA === storeAAgain) + #expect(storeA !== storeB) + #expect(storeA !== liveStore) + #expect(storeB !== liveStore) + #expect(storeA.identifier != storeB.identifier) + #expect(storeA.identifier != liveStore.identifier) + #expect(storeB.identifier != liveStore.identifier) + } + + @Test + func `live website data store preserves legacy email identifier`() { + OpenAIDashboardWebsiteDataStore.clearCacheForTesting() + defer { OpenAIDashboardWebsiteDataStore.clearCacheForTesting() } + + let store = OpenAIDashboardWebsiteDataStore.store(forAccountEmail: " SHARED@EXAMPLE.COM ") + + #expect(store.identifier?.uuidString == "CC61BD27-6855-439F-9D11-F470B7977B90") + } + // MARK: - WebView Reuse Tests @Test @@ -206,6 +271,66 @@ struct OpenAIDashboardWebViewCacheTests { cache.clearAllForTesting() } + @Test + func `Idle prune is scheduled without future cache activity`() async throws { + if self.shouldSkipOnCI() { return } + let cache = OpenAIDashboardWebViewCache(idleTimeout: 0.2) + let store = WKWebsiteDataStore.nonPersistent() + let url = try #require(URL(string: "about:blank")) + + var lease: OpenAIDashboardWebViewLease? = try await cache.acquire( + websiteDataStore: store, + usageURL: url, + logger: nil) + lease?.release() + lease = nil + + #expect(cache.hasCachedEntry(for: store), "WebView should remain cached right after release") + + let deadline = Date().addingTimeInterval(5) + while cache.hasCachedEntry(for: store), Date() < deadline { + try? await Task.sleep(for: .milliseconds(100)) + } + + #expect( + !cache.hasCachedEntry(for: store), + "Expected the scheduled idle prune to evict the WebView without any further cache activity") + + cache.clearAllForTesting() + } + + @Test + func `Later release does not postpone an older idle entry`() async throws { + if self.shouldSkipOnCI() { return } + let cache = OpenAIDashboardWebViewCache(idleTimeout: 5) + let firstStore = WKWebsiteDataStore.nonPersistent() + let secondStore = WKWebsiteDataStore.nonPersistent() + let url = try #require(URL(string: "about:blank")) + + let firstLease = try await cache.acquire( + websiteDataStore: firstStore, + usageURL: url, + logger: nil) + firstLease.release() + let firstDeadline = try #require(cache.idlePruneDeadlineForTesting) + + try await Task.sleep(for: .milliseconds(50)) + + let secondLease = try await cache.acquire( + websiteDataStore: secondStore, + usageURL: url, + logger: nil) + secondLease.release() + let rescheduledDeadline = try #require(cache.idlePruneDeadlineForTesting) + + #expect( + abs(rescheduledDeadline.timeIntervalSince(firstDeadline)) < 0.001, + "A later release should keep the prune scheduled for the oldest idle entry") + #expect(cache.hasCachedEntry(for: firstStore)) + #expect(cache.hasCachedEntry(for: secondStore), "A later release should keep its own idle window") + cache.clearAllForTesting() + } + @Test func `Reused page reset clears one shot scraper globals`() async throws { if self.shouldSkipOnCI() { return } @@ -313,6 +438,64 @@ struct OpenAIDashboardWebViewCacheTests { #expect(!cache.hasCachedEntry(for: store2), "Second store should be evicted") } + @Test + func `Evict idle removes idle WebViews without interrupting busy WebViews`() { + if self.shouldSkipOnCI() { return } + let cache = OpenAIDashboardWebViewCache() + let idleStore = WKWebsiteDataStore.nonPersistent() + let busyStore = WKWebsiteDataStore.nonPersistent() + + cache.cacheEntryForTesting(websiteDataStore: idleStore) + cache.cacheEntryForTesting(websiteDataStore: busyStore, isBusy: true) + + cache.evictIdle() + + #expect(!cache.hasCachedEntry(for: idleStore), "Idle WebView should be evicted") + #expect(cache.hasCachedEntry(for: busyStore), "Busy WebView should remain cached") + #expect(cache.entryCount == 1, "Only the busy entry should remain") + + cache.clearAllForTesting() + } + + @Test + func `Memory pressure monitor evicts idle shared WebViews without interrupting busy WebViews`() { + if self.shouldSkipOnCI() { return } + let cache = OpenAIDashboardWebViewCache.shared + cache.clearAllForTesting() + defer { cache.clearAllForTesting() } + + let idleStore = WKWebsiteDataStore.nonPersistent() + let busyStore = WKWebsiteDataStore.nonPersistent() + + cache.cacheEntryForTesting(websiteDataStore: idleStore) + cache.cacheEntryForTesting(websiteDataStore: busyStore, isBusy: true) + + #expect(cache.entryCount == 2, "Should have one idle entry and one busy entry before pressure") + + let monitor = MemoryPressureMonitor() + monitor.handleMemoryPressureForTesting(isWarning: true, isCritical: false) + + #expect(!cache.hasCachedEntry(for: idleStore), "Memory pressure should evict the idle shared WebView") + #expect(cache.hasCachedEntry(for: busyStore), "Memory pressure should not interrupt a busy shared WebView") + #expect(cache.entryCount == 1, "Only the busy shared entry should remain") + } + + @Test + func `Memory pressure malloc relief runs off the main thread`() async { + let probe = MemoryPressureThreadProbe() + let monitor = MemoryPressureMonitor(releaseFreeMallocPages: { + probe.recordCurrentThread() + }) + + monitor.handleMemoryPressureForTesting(isWarning: true, isCritical: false) + + let completed = await Task.detached { + probe.wait(timeout: .now() + 2) + }.value + #expect(completed) + #expect(probe.wasMainThread == false) + } + // MARK: - Busy WebView Tests @Test @@ -422,3 +605,24 @@ struct OpenAIDashboardWebViewCacheTests { OpenAIDashboardWebsiteDataStore.clearCacheForTesting() } } + +private final class MemoryPressureThreadProbe: @unchecked Sendable { + private let lock = NSLock() + private let semaphore = DispatchSemaphore(value: 0) + private var recordedMainThread: Bool? + + var wasMainThread: Bool? { + self.lock.withLock { self.recordedMainThread } + } + + func recordCurrentThread() { + self.lock.withLock { + self.recordedMainThread = Thread.isMainThread + } + self.semaphore.signal() + } + + func wait(timeout: DispatchTime) -> Bool { + self.semaphore.wait(timeout: timeout) == .success + } +} diff --git a/Tests/CodexBarTests/OpenAIWebAccountSwitchTests.swift b/Tests/CodexBarTests/OpenAIWebAccountSwitchTests.swift index b3a29b8392..1bab839623 100644 --- a/Tests/CodexBarTests/OpenAIWebAccountSwitchTests.swift +++ b/Tests/CodexBarTests/OpenAIWebAccountSwitchTests.swift @@ -61,4 +61,36 @@ struct OpenAIWebAccountSwitchTests { store.handleOpenAIWebTargetEmailChangeIfNeeded(targetEmail: "a@example.com") #expect(store.openAIDashboard == dash) } + + @Test + func `clears dashboard when profile source changes with the same email`() { + let settings = SettingsStore( + configStore: testConfigStore(suiteName: "OpenAIWebAccountSwitchTests-profile-source"), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + let store = UsageStore( + fetcher: UsageFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings) + + store.handleOpenAIWebTargetEmailChangeIfNeeded( + targetEmail: "shared@example.com", + targetScope: .profileHome("/tmp/codex-profile-a")) + store.openAIDashboard = OpenAIDashboardSnapshot( + signedInEmail: "shared@example.com", + codeReviewRemainingPercent: 100, + creditEvents: [], + dailyBreakdown: [], + usageBreakdown: [], + creditsPurchaseURL: nil, + updatedAt: Date()) + + store.handleOpenAIWebTargetEmailChangeIfNeeded( + targetEmail: "shared@example.com", + targetScope: .profileHome("/tmp/codex-profile-b")) + + #expect(store.openAIDashboard == nil) + #expect(store.openAIWebAccountDidChange) + #expect(store.openAIDashboardRequiresLogin) + } } diff --git a/Tests/CodexBarTests/OpenAIWebRefreshGateTests.swift b/Tests/CodexBarTests/OpenAIWebRefreshGateTests.swift index eead8bb841..e9a016db5d 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/OpenCodeGoLocalUsageReaderTests.swift b/Tests/CodexBarTests/OpenCodeGoLocalUsageReaderTests.swift index ca060b0a33..61eff060e1 100644 --- a/Tests/CodexBarTests/OpenCodeGoLocalUsageReaderTests.swift +++ b/Tests/CodexBarTests/OpenCodeGoLocalUsageReaderTests.swift @@ -37,6 +37,46 @@ struct OpenCodeGoLocalUsageReaderTests { #expect(snapshot.monthlyResetInSec == 1_626_796) } + @Test + func `builds daily cost history buckets within the requested window`() throws { + let env = try Self.makeEnvironment() + defer { try? FileManager.default.removeItem(at: env.root) } + + try Self.writeAuth(to: env.authURL) + try Self.createDatabase(at: env.databaseURL) + // Expected keys below use the same device-local calendar convention as production. + try Self.insertMessage( + databaseURL: env.databaseURL, + createdMs: Self.ms("2026-03-06T12:00:00.000Z"), + cost: 3.0) + try Self.insertMessage( + databaseURL: env.databaseURL, + createdMs: Self.ms("2026-03-06T13:00:00.000Z"), + cost: 1.5) + try Self.insertMessage( + databaseURL: env.databaseURL, + createdMs: Self.ms("2026-03-05T12:00:00.000Z"), + cost: 6.0) + try Self.insertMessage( + databaseURL: env.databaseURL, + createdMs: Self.ms("2026-01-01T12:00:00.000Z"), + cost: 100.0) + + let reader = OpenCodeGoLocalUsageReader(authURL: env.authURL, databaseURL: env.databaseURL) + let now = Date(timeIntervalSince1970: TimeInterval(Self.ms("2026-03-06T15:00:00.000Z")) / 1000) + let snapshot = try reader.fetch(now: now, historyDays: 30) + + let previousDayKey = CostUsageScanner.CostUsageDayRange.dayKey( + from: Date(timeIntervalSince1970: TimeInterval(Self.ms("2026-03-05T12:00:00.000Z")) / 1000)) + let currentDayKey = CostUsageScanner.CostUsageDayRange.dayKey( + from: Date(timeIntervalSince1970: TimeInterval(Self.ms("2026-03-06T12:00:00.000Z")) / 1000)) + #expect(snapshot.daily.map(\.date) == [previousDayKey, currentDayKey]) + #expect(snapshot.daily.first?.costUSD == 6.0) + #expect(snapshot.daily.first?.requestCount == 1) + #expect(snapshot.daily.last?.costUSD == 4.5) + #expect(snapshot.daily.last?.requestCount == 2) + } + @Test func `auth without history falls through to web strategy`() throws { let env = try Self.makeEnvironment() @@ -118,7 +158,7 @@ struct OpenCodeGoLocalUsageReaderTests { } @Test - func `does not double count step finish parts when message has cost`() throws { + func `uses message cost while counting step finish requests`() throws { let env = try Self.makeEnvironment() defer { try? FileManager.default.removeItem(at: env.root) } @@ -132,7 +172,12 @@ struct OpenCodeGoLocalUsageReaderTests { databaseURL: env.databaseURL, messageID: messageID, createdMs: Self.ms("2026-03-06T11:00:00.000Z"), - cost: 3.0) + cost: 1.0) + try Self.insertStepFinishPart( + databaseURL: env.databaseURL, + messageID: messageID, + createdMs: Self.ms("2026-03-06T11:05:00.000Z"), + cost: 2.0) let reader = OpenCodeGoLocalUsageReader(authURL: env.authURL, databaseURL: env.databaseURL) let snapshot = try reader.fetch(now: Date(timeIntervalSince1970: 1_772_798_400)) @@ -140,6 +185,46 @@ struct OpenCodeGoLocalUsageReaderTests { #expect(snapshot.rollingUsagePercent == 25) #expect(snapshot.weeklyUsagePercent == 10) #expect(snapshot.monthlyUsagePercent == 5) + #expect(snapshot.daily.first?.costUSD == 3.0) + #expect(snapshot.daily.first?.requestCount == 2) + } + + @Test + func `daily request count buckets step finish parts by their timestamps`() throws { + let env = try Self.makeEnvironment() + defer { try? FileManager.default.removeItem(at: env.root) } + + try Self.writeAuth(to: env.authURL) + try Self.createDatabase(at: env.databaseURL) + let anchor = Date(timeIntervalSince1970: TimeInterval(Self.ms("2026-03-06T15:00:00.000Z")) / 1000) + let dayStart = Calendar.current.startOfDay(for: anchor) + let now = dayStart.addingTimeInterval(6 * 60 * 60) + let beforeMidnight = dayStart.addingTimeInterval(-60) + let afterMidnight = dayStart.addingTimeInterval(60) + // One assistant turn can make provider requests on opposite sides of local midnight. + let messageID = try Self.insertMessage( + databaseURL: env.databaseURL, + createdMs: Self.ms(beforeMidnight), + cost: nil) + try Self.insertStepFinishPart( + databaseURL: env.databaseURL, + messageID: messageID, + createdMs: Self.ms(beforeMidnight), + cost: 1.0) + try Self.insertStepFinishPart( + databaseURL: env.databaseURL, + messageID: messageID, + createdMs: Self.ms(afterMidnight), + cost: 2.0) + + let reader = OpenCodeGoLocalUsageReader(authURL: env.authURL, databaseURL: env.databaseURL) + let snapshot = try reader.fetch(now: now, historyDays: 30) + + #expect(snapshot.daily.count == 2) + #expect(snapshot.daily.first?.costUSD == 1.0) + #expect(snapshot.daily.first?.requestCount == 1) + #expect(snapshot.daily.last?.costUSD == 2.0) + #expect(snapshot.daily.last?.requestCount == 1) } @Test @@ -288,6 +373,10 @@ struct OpenCodeGoLocalUsageReaderTests { return Int64((formatter.date(from: iso)?.timeIntervalSince1970 ?? 0) * 1000) } + private static func ms(_ date: Date) -> Int64 { + Int64(date.timeIntervalSince1970 * 1000) + } + private enum SQLiteTestError: Error { case open case prepare diff --git a/Tests/CodexBarTests/OpenCodeGoMenuCardModelTests.swift b/Tests/CodexBarTests/OpenCodeGoMenuCardModelTests.swift index a73d650b9c..c211d03899 100644 --- a/Tests/CodexBarTests/OpenCodeGoMenuCardModelTests.swift +++ b/Tests/CodexBarTests/OpenCodeGoMenuCardModelTests.swift @@ -4,6 +4,60 @@ import Testing @testable import CodexBar struct OpenCodeGoMenuCardModelTests { + @Test + func `monthly quota shows deficit and run out details`() throws { + let now = Date(timeIntervalSince1970: 10_368_000) // 1970-05-01T00:00:00Z + let reset = now.addingTimeInterval(6 * 24 * 3600) + let monthlyMinutes = 30 * 24 * 60 + let snapshot = UsageSnapshot( + primary: RateWindow( + usedPercent: 25, + windowMinutes: 5 * 60, + resetsAt: now.addingTimeInterval(2 * 3600), + resetDescription: nil), + secondary: RateWindow( + usedPercent: 40, + windowMinutes: 7 * 24 * 60, + resetsAt: now.addingTimeInterval(4 * 24 * 3600), + resetDescription: nil), + tertiary: RateWindow( + usedPercent: 90, + windowMinutes: monthlyMinutes, + resetsAt: reset, + resetDescription: nil), + updatedAt: now, + identity: nil) + let metadata = try #require(ProviderDefaults.metadata[.opencodego]) + + let model = UsageMenuCardView.Model.make(.init( + provider: .opencodego, + metadata: metadata, + snapshot: snapshot, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + + #expect(model.metrics.map(\.title) == ["5-hour", "Weekly", "Monthly"]) + let monthly = try #require(model.metrics.first { $0.id == "tertiary" }) + #expect(monthly.percentLabel == "10% left") + #expect(monthly.detailLeftText == "10% in deficit") + #expect(monthly.detailRightText == "Runs out in 2d 16h") + #expect(monthly.pacePercent == 20) + #expect(monthly.paceOnTop == false) + } + @Test func `zen balance renders as optional balance`() throws { let now = Date() @@ -48,7 +102,7 @@ struct OpenCodeGoMenuCardModelTests { } @Test - func `zen balance hides when optional usage is disabled`() throws { + func `required zen balance renders when optional usage is disabled`() throws { let now = Date() let snapshot = UsageSnapshot( primary: nil, @@ -64,6 +118,47 @@ struct OpenCodeGoMenuCardModelTests { identity: nil) let metadata = try #require(ProviderDefaults.metadata[.opencodego]) + let model = UsageMenuCardView.Model.make(.init( + provider: .opencodego, + metadata: metadata, + snapshot: snapshot, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: true, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: false, + hidePersonalInfo: false, + now: now)) + + #expect(model.providerCost?.title == "Zen balance") + #expect(model.providerCost?.spendLine == "Balance: $98.76") + } + + @Test + func `subscription zen balance hides when optional usage is disabled`() throws { + let now = Date() + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 12, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + secondary: RateWindow(usedPercent: 34, windowMinutes: 10080, resetsAt: nil, resetDescription: nil), + tertiary: nil, + providerCost: ProviderCostSnapshot( + used: 98.76, + limit: 0, + currencyCode: "USD", + period: "Zen balance", + updatedAt: now), + updatedAt: now, + identity: nil) + let metadata = try #require(ProviderDefaults.metadata[.opencodego]) + let model = UsageMenuCardView.Model.make(.init( provider: .opencodego, metadata: metadata, @@ -86,4 +181,117 @@ struct OpenCodeGoMenuCardModelTests { #expect(model.providerCost == nil) } + + @Test + func `inline dashboard falls back to inline chart when cost row is unavailable`() throws { + // "Inline only" cost display style: tokenCostMenuSectionEnabled is false (no Cost row), + // but tokenCostInlineDashboardEnabled is true. OpenCode Go should behave like + // Codex/Claude/Cursor here and still surface its cost history via the inline chart. + let now = Date() + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 12, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + secondary: RateWindow(usedPercent: 34, windowMinutes: 10080, resetsAt: nil, resetDescription: nil), + tertiary: nil, + updatedAt: now, + identity: nil) + let metadata = try #require(ProviderDefaults.metadata[.opencodego]) + let tokenSnapshot = CostUsageTokenSnapshot( + sessionTokens: nil, + sessionCostUSD: 0.78, + last30DaysTokens: nil, + last30DaysCostUSD: 22.13, + daily: [ + CostUsageDailyReport.Entry( + date: "2026-07-17", + inputTokens: nil, + outputTokens: nil, + totalTokens: nil, + requestCount: 200, + costUSD: 0.78, + modelsUsed: nil, + modelBreakdowns: nil), + ], + updatedAt: now) + + let model = UsageMenuCardView.Model.make(.init( + provider: .opencodego, + metadata: metadata, + snapshot: snapshot, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: tokenSnapshot, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: true, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: true, + tokenCostInlineDashboardEnabled: true, + tokenCostMenuSectionEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + + #expect(model.tokenUsage == nil) + #expect(model.inlineUsageDashboard != nil) + } + + @Test + func `cost row takes precedence over inline chart when both are enabled`() throws { + // "Both" cost display style: matches Codex/Claude, which show the Cost row and the + // inline chart simultaneously rather than one suppressing the other. + let now = Date() + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 12, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + secondary: RateWindow(usedPercent: 34, windowMinutes: 10080, resetsAt: nil, resetDescription: nil), + tertiary: nil, + updatedAt: now, + identity: nil) + let metadata = try #require(ProviderDefaults.metadata[.opencodego]) + let tokenSnapshot = CostUsageTokenSnapshot( + sessionTokens: nil, + sessionCostUSD: 0.78, + last30DaysTokens: nil, + last30DaysCostUSD: 22.13, + daily: [ + CostUsageDailyReport.Entry( + date: "2026-07-17", + inputTokens: nil, + outputTokens: nil, + totalTokens: nil, + requestCount: 200, + costUSD: 0.78, + modelsUsed: nil, + modelBreakdowns: nil), + ], + updatedAt: now) + + let model = UsageMenuCardView.Model.make(.init( + provider: .opencodego, + metadata: metadata, + snapshot: snapshot, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: tokenSnapshot, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: true, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: true, + tokenCostInlineDashboardEnabled: true, + tokenCostMenuSectionEnabled: true, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + + #expect(model.tokenUsage != nil) + #expect(model.inlineUsageDashboard != nil) + } } diff --git a/Tests/CodexBarTests/OpenCodeGoProviderStrategyTests.swift b/Tests/CodexBarTests/OpenCodeGoProviderStrategyTests.swift index 4c4d4d3989..6d5c982c7e 100644 --- a/Tests/CodexBarTests/OpenCodeGoProviderStrategyTests.swift +++ b/Tests/CodexBarTests/OpenCodeGoProviderStrategyTests.swift @@ -17,9 +17,13 @@ struct OpenCodeGoProviderStrategyTests { } } - private func makeContext(sourceMode: ProviderSourceMode = .auto) -> ProviderFetchContext { - let env: [String: String] = [:] - return ProviderFetchContext( + private func makeContext( + sourceMode: ProviderSourceMode = .auto, + env: [String: String] = [:], + settings: ProviderSettingsSnapshot? = nil, + selectedTokenAccountID: UUID? = nil) -> ProviderFetchContext + { + ProviderFetchContext( runtime: .app, sourceMode: sourceMode, includeCredits: false, @@ -27,20 +31,81 @@ struct OpenCodeGoProviderStrategyTests { webDebugDumpHTML: false, verbose: false, env: env, - settings: nil, + settings: settings, fetcher: UsageFetcher(environment: env), claudeFetcher: StubClaudeFetcher(), - browserDetection: BrowserDetection(cacheTTL: 0)) + browserDetection: BrowserDetection(cacheTTL: 0), + selectedTokenAccountID: selectedTokenAccountID) } @Test - func `auto source prefers web before local fallback`() async { + func `unscoped auto source prefers local history before web fallback`() async { let descriptor = OpenCodeGoProviderDescriptor.makeDescriptor() let strategies = await descriptor.fetchPlan.pipeline.resolveStrategies(self.makeContext()) + #expect(strategies.map(\.id) == ["opencodego.local", "opencodego.web"]) + } + + @Test + func `auto source tries web before local for selected token accounts`() async { + let descriptor = OpenCodeGoProviderDescriptor.makeDescriptor() + let strategies = await descriptor.fetchPlan.pipeline.resolveStrategies( + self.makeContext(selectedTokenAccountID: UUID())) + + #expect(strategies.map(\.id) == ["opencodego.web", "opencodego.local"]) + } + + @Test + func `auto source tries web before local for manual cookies`() async { + let descriptor = OpenCodeGoProviderDescriptor.makeDescriptor() + let settings = ProviderSettingsSnapshot.make(opencodego: .init( + cookieSource: .manual, + manualCookieHeader: "auth=selected", + workspaceID: nil)) + let strategies = await descriptor.fetchPlan.pipeline.resolveStrategies( + self.makeContext(settings: settings)) + + #expect(strategies.map(\.id) == ["opencodego.web", "opencodego.local"]) + } + + @Test + func `auto source tries web before local for configured workspaces`() async { + let descriptor = OpenCodeGoProviderDescriptor.makeDescriptor() + let settings = ProviderSettingsSnapshot.make(opencodego: .init( + cookieSource: .auto, + manualCookieHeader: nil, + workspaceID: "wrk_team")) + let strategies = await descriptor.fetchPlan.pipeline.resolveStrategies( + self.makeContext(settings: settings)) + + #expect(strategies.map(\.id) == ["opencodego.web", "opencodego.local"]) + } + + @Test + func `auto source tries web before local for environment workspaces`() async { + let descriptor = OpenCodeGoProviderDescriptor.makeDescriptor() + let strategies = await descriptor.fetchPlan.pipeline.resolveStrategies( + self.makeContext(env: ["CODEXBAR_OPENCODEGO_WORKSPACE_ID": "wrk_env"])) + #expect(strategies.map(\.id) == ["opencodego.web", "opencodego.local"]) } + @Test + func `auto source treats blank workspace overrides as unscoped`() async { + let descriptor = OpenCodeGoProviderDescriptor.makeDescriptor() + let settings = ProviderSettingsSnapshot.make(opencodego: .init( + cookieSource: .auto, + manualCookieHeader: nil, + workspaceID: " \n ")) + let settingsStrategies = await descriptor.fetchPlan.pipeline.resolveStrategies( + self.makeContext(settings: settings)) + let environmentStrategies = await descriptor.fetchPlan.pipeline.resolveStrategies( + self.makeContext(env: ["CODEXBAR_OPENCODEGO_WORKSPACE_ID": " \t "])) + + #expect(settingsStrategies.map(\.id) == ["opencodego.local", "opencodego.web"]) + #expect(environmentStrategies.map(\.id) == ["opencodego.local", "opencodego.web"]) + } + @Test func `web source does not include local fallback`() async { let descriptor = OpenCodeGoProviderDescriptor.makeDescriptor() @@ -50,7 +115,22 @@ struct OpenCodeGoProviderStrategyTests { } @Test - func `web strategy falls back to local only for auth setup failures in auto mode`() { + func `local strategy falls through to web when local history is unavailable`() { + let strategy = OpenCodeGoLocalUsageFetchStrategy() + let context = self.makeContext() + + #expect(strategy.shouldFallback(on: OpenCodeGoLocalUsageError.notDetected, context: context)) + #expect(strategy.shouldFallback( + on: OpenCodeGoLocalUsageError.historyUnavailable("database not found"), + context: context)) + #expect(strategy.shouldFallback( + on: OpenCodeGoLocalUsageError.sqliteFailed("database is locked"), + context: context)) + #expect(!strategy.shouldFallback(on: OpenCodeGoUsageError.networkError("timeout"), context: context)) + } + + @Test + func `web strategy falls through only for auth setup failures in auto mode`() { let strategy = OpenCodeGoUsageFetchStrategy() let autoContext = self.makeContext() let webContext = self.makeContext(sourceMode: .web) diff --git a/Tests/CodexBarTests/OpenCodeGoTokenCostTests.swift b/Tests/CodexBarTests/OpenCodeGoTokenCostTests.swift new file mode 100644 index 0000000000..8b7eba0b5d --- /dev/null +++ b/Tests/CodexBarTests/OpenCodeGoTokenCostTests.swift @@ -0,0 +1,79 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +@MainActor +struct OpenCodeGoTokenCostTests { + @Test + func `token snapshot projection is nil when local daily history is empty`() { + let settings = Self.makeSettings() + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + + // Web-only source mode and machines without a readable local database leave + // `opencodegoUsage` present but `daily` empty. A dataless projection here would + // otherwise still surface a Cost row whose history submenu has nothing to render. + let emptySnapshot = OpenCodeGoUsageSnapshot( + hasMonthlyUsage: true, + rollingUsagePercent: 12, + weeklyUsagePercent: 57, + monthlyUsagePercent: 34, + rollingResetInSec: 3600, + weeklyResetInSec: 86400, + monthlyResetInSec: 864_000, + daily: [], + updatedAt: Date()) + + #expect(store.tokenSnapshot( + fromProviderSnapshot: emptySnapshot.toUsageSnapshot(), + provider: .opencodego) == nil) + } + + @Test + func `token snapshot projection is populated when local daily history exists`() { + let settings = Self.makeSettings() + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + + let populatedSnapshot = OpenCodeGoUsageSnapshot( + hasMonthlyUsage: true, + rollingUsagePercent: 12, + weeklyUsagePercent: 57, + monthlyUsagePercent: 34, + rollingResetInSec: 3600, + weeklyResetInSec: 86400, + monthlyResetInSec: 864_000, + daily: [ + CostUsageDailyReport.Entry( + date: "2025-12-23", + inputTokens: nil, + outputTokens: nil, + totalTokens: nil, + requestCount: 5, + costUSD: 1.23, + modelsUsed: nil, + modelBreakdowns: nil), + ], + updatedAt: Date()) + + let tokenSnapshot = store.tokenSnapshot( + fromProviderSnapshot: populatedSnapshot.toUsageSnapshot(), + provider: .opencodego) + #expect(tokenSnapshot?.daily.isEmpty == false) + #expect(tokenSnapshot?.last30DaysCostUSD == 1.23) + } + + private static func makeSettings() -> SettingsStore { + let suite = "OpenCodeGoTokenCostTests-\(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.providerDetectionCompleted = true + return settings + } +} diff --git a/Tests/CodexBarTests/OpenCodeGoUsageFetcherErrorTests.swift b/Tests/CodexBarTests/OpenCodeGoUsageFetcherErrorTests.swift index 131bfddd1f..15b9ed0079 100644 --- a/Tests/CodexBarTests/OpenCodeGoUsageFetcherErrorTests.swift +++ b/Tests/CodexBarTests/OpenCodeGoUsageFetcherErrorTests.swift @@ -2,6 +2,45 @@ import Foundation import Testing @testable import CodexBarCore +private final class OpenCodeGoRequestRecorder: @unchecked Sendable { + private let lock = NSLock() + private var storage: [Value] = [] + + func append(_ value: Value) { + self.lock.lock() + defer { self.lock.unlock() } + self.storage.append(value) + } + + var values: [Value] { + self.lock.lock() + defer { self.lock.unlock() } + return self.storage + } +} + +private final class OpenCodeGoContinuationBox: @unchecked Sendable { + private let lock = NSLock() + private var continuation: CheckedContinuation? + + func wait(onReady: @Sendable () -> Void) async -> Value { + await withCheckedContinuation { continuation in + self.lock.lock() + self.continuation = continuation + self.lock.unlock() + onReady() + } + } + + func resume(returning value: Value) { + self.lock.lock() + let continuation = self.continuation + self.continuation = nil + self.lock.unlock() + continuation?.resume(returning: value) + } +} + @Suite(.serialized) struct OpenCodeGoUsageFetcherErrorTests { @Test @@ -79,10 +118,10 @@ struct OpenCodeGoUsageFetcherErrorTests { OpenCodeGoStubURLProtocol.handler = nil } - var methods: [String] = [] + let requests = OpenCodeGoRequestRecorder() OpenCodeGoStubURLProtocol.handler = { request in guard let url = request.url else { throw URLError(.badURL) } - methods.append(request.httpMethod ?? "GET") + requests.append("\(request.httpMethod ?? "GET") \(url.path)") let workspaceServerID = "def39973159c7f0483d8793a822b8dbb10d067e12c65455fcb4608459ba0234f" if url.query?.contains(workspaceServerID) == true, @@ -120,12 +159,17 @@ struct OpenCodeGoUsageFetcherErrorTests { let snapshot = try await OpenCodeGoUsageFetcher.fetchUsage( cookieHeader: "auth=test", timeout: 2, + includeZenBalance: false, session: self.makeSession()) #expect(snapshot.rollingUsagePercent == 22) #expect(snapshot.weeklyUsagePercent == 44) #expect(snapshot.monthlyUsagePercent == 55) - #expect(methods == ["GET", "POST", "GET", "GET"]) + #expect(requests.values == [ + "GET /_server", + "POST /_server", + "GET /workspace/wrk_TEST123/go", + ]) } @Test @@ -134,7 +178,7 @@ struct OpenCodeGoUsageFetcherErrorTests { OpenCodeGoStubURLProtocol.handler = nil } - var methods: [String] = [] + let methods = OpenCodeGoRequestRecorder() OpenCodeGoStubURLProtocol.handler = { request in guard let url = request.url else { throw URLError(.badURL) } methods.append(request.httpMethod ?? "GET") @@ -166,7 +210,7 @@ struct OpenCodeGoUsageFetcherErrorTests { } } - #expect(methods == ["GET"]) + #expect(methods.values == ["GET"]) } @Test @@ -175,7 +219,7 @@ struct OpenCodeGoUsageFetcherErrorTests { OpenCodeGoStubURLProtocol.handler = nil } - var methods: [String] = [] + let methods = OpenCodeGoRequestRecorder() OpenCodeGoStubURLProtocol.handler = { request in guard let url = request.url else { throw URLError(.badURL) } methods.append(request.httpMethod ?? "GET") @@ -202,7 +246,201 @@ struct OpenCodeGoUsageFetcherErrorTests { } } - #expect(methods == ["GET"]) + #expect(methods.values == ["GET", "GET", "GET"]) + } + + @Test + func `zen only account waits for balance beyond optional grace`() async throws { + defer { + OpenCodeGoStubURLProtocol.handler = nil + } + + let observedPaths = OpenCodeGoRequestRecorder() + OpenCodeGoStubURLProtocol.handler = { request in + guard let url = request.url else { throw URLError(.badURL) } + observedPaths.append(url.path) + if url.path == "/workspace/wrk_TEST123" { + Thread.sleep(forTimeInterval: 0.4) + return Self.makeResponse( + url: url, + body: #"

    現在の残高 $42.50

    "#, + statusCode: 200, + contentType: "text/html") + } + return Self.makeResponse( + url: url, + body: "opencodeNo Go subscription usage", + statusCode: 200, + contentType: "text/html") + } + + let snapshot = try await OpenCodeGoUsageFetcher.fetchUsage( + cookieHeader: "auth=test", + timeout: 2, + workspaceIDOverride: "wrk_TEST123", + session: self.makeSession()) + let usage = snapshot.toUsageSnapshot() + + #expect(snapshot.isBalanceOnly) + #expect(snapshot.zenBalanceUSD == 42.5) + #expect(usage.primary == nil) + #expect(usage.secondary == nil) + #expect(usage.providerCost?.used == 42.5) + #expect(usage.providerCost?.period == "Zen balance") + #expect(observedPaths.values.count == 2) + #expect(Set(observedPaths.values) == ["/workspace/wrk_TEST123/go", "/workspace/wrk_TEST123"]) + } + + @Test + func `zen only account fetches required balance when optional usage is disabled`() async throws { + defer { + OpenCodeGoStubURLProtocol.handler = nil + } + + let observedPaths = OpenCodeGoRequestRecorder() + OpenCodeGoStubURLProtocol.handler = { request in + guard let url = request.url else { throw URLError(.badURL) } + observedPaths.append(url.path) + if url.path == "/workspace/wrk_TEST123" { + return Self.makeResponse( + url: url, + body: #"

    Current balance $23.75

    "#, + statusCode: 200, + contentType: "text/html") + } + return Self.makeResponse( + url: url, + body: "opencodeNo Go subscription usage", + statusCode: 200, + contentType: "text/html") + } + + let snapshot = try await OpenCodeGoUsageFetcher.fetchUsage( + cookieHeader: "auth=test", + timeout: 2, + workspaceIDOverride: "wrk_TEST123", + includeZenBalance: false, + session: self.makeSession()) + + #expect(snapshot.isBalanceOnly) + #expect(snapshot.zenBalanceUSD == 23.75) + #expect(observedPaths.values == ["/workspace/wrk_TEST123/go", "/workspace/wrk_TEST123"]) + } + + @Test + func `zen only account propagates invalid credentials from required balance fetch`() async throws { + defer { + OpenCodeGoStubURLProtocol.handler = nil + } + + OpenCodeGoStubURLProtocol.handler = { request in + guard let url = request.url else { throw URLError(.badURL) } + if url.path == "/workspace/wrk_TEST123" { + return Self.makeResponse( + url: url, + body: "Unauthorized", + statusCode: 401, + contentType: "text/plain") + } + return Self.makeResponse( + url: url, + body: "opencodeNo Go subscription usage", + statusCode: 200, + contentType: "text/html") + } + + do { + _ = try await OpenCodeGoUsageFetcher.fetchUsage( + cookieHeader: "auth=stale", + timeout: 2, + workspaceIDOverride: "wrk_TEST123", + session: self.makeSession()) + Issue.record("Expected invalid credentials to propagate.") + } catch OpenCodeGoUsageError.invalidCredentials { + // Expected. + } catch { + Issue.record("Expected invalidCredentials, got: \(error)") + } + } + + @Test + func `zen only account falls back after final subscription parse failure`() async throws { + defer { + OpenCodeGoStubURLProtocol.handler = nil + } + + var rootTimeout: TimeInterval? + OpenCodeGoStubURLProtocol.handler = { request in + guard let url = request.url else { throw URLError(.badURL) } + if url.path == "/workspace/wrk_TEST123" { + rootTimeout = request.timeoutInterval + return Self.makeResponse( + url: url, + body: #"

    Current balance $17.25

    "#, + statusCode: 200, + contentType: "text/html") + } + return Self.makeResponse( + url: url, + body: #""#, + statusCode: 200, + contentType: "text/html") + } + + let snapshot = try await OpenCodeGoUsageFetcher.fetchUsage( + cookieHeader: "auth=test", + timeout: 12, + workspaceIDOverride: "wrk_TEST123", + session: self.makeSession()) + let usage = snapshot.toUsageSnapshot() + + #expect(snapshot.isBalanceOnly) + #expect(snapshot.zenBalanceUSD == 17.25) + #expect(usage.primary == nil) + #expect(usage.secondary == nil) + #expect(usage.providerCost?.used == 17.25) + #expect(rootTimeout == 12) + } + + @Test + func `zen only fallback promptly cancels when balance task ignores cancellation`() async throws { + let balanceStarted = AsyncStream.makeStream(of: Void.self) + let balanceContinuation = OpenCodeGoContinuationBox() + let balanceTask = Task { + await balanceContinuation.wait { + balanceStarted.continuation.yield(()) + } + } + let fallbackTask = Task { + try await OpenCodeGoUsageFetcher.requiredZenBalanceFallback( + from: balanceTask, + for: .parseFailed("Missing usage fields."), + request: OpenCodeGoUsageFetcher.ZenBalanceRequest( + workspaceID: "wrk_TEST123", + cookieHeader: "auth=test", + timeout: 2, + session: self.makeSession()), + now: Date()) + } + + var iterator = balanceStarted.stream.makeAsyncIterator() + _ = await iterator.next() + let start = ContinuousClock.now + fallbackTask.cancel() + + do { + _ = try await fallbackTask.value + Issue.record("Expected cancellation to propagate.") + } catch is CancellationError { + // Expected. + } catch { + Issue.record("Expected CancellationError, got: \(error)") + } + + #expect(start.duration(to: .now) < .milliseconds(500)) + #expect(balanceTask.isCancelled) + balanceContinuation.resume(returning: 42.5) + #expect(try await balanceTask.value == 42.5) } @Test @@ -211,7 +449,7 @@ struct OpenCodeGoUsageFetcherErrorTests { OpenCodeGoStubURLProtocol.handler = nil } - var observedPaths: [String] = [] + let observedPaths = OpenCodeGoRequestRecorder() OpenCodeGoStubURLProtocol.handler = { request in guard let url = request.url else { throw URLError(.badURL) } observedPaths.append(url.path) @@ -232,7 +470,12 @@ struct OpenCodeGoUsageFetcherErrorTests { workspaceIDOverride: "https://opencode.ai/workspace/wrk_URL123/billing", session: self.makeSession()) - #expect(observedPaths == ["/workspace/wrk_URL123/go", "/workspace/wrk_URL123"]) + #expect(observedPaths.values.count == 3) + #expect(Set(observedPaths.values) == [ + "/workspace/wrk_URL123/go", + "/workspace/wrk_URL123", + "/_server", + ]) } @Test @@ -271,6 +514,58 @@ struct OpenCodeGoUsageFetcherErrorTests { #expect(snapshot.toUsageSnapshot().providerCost?.period == "Zen balance") } + @Test + func `fetcher falls back to billing server when workspace page omits balance`() async throws { + defer { + OpenCodeGoStubURLProtocol.handler = nil + } + + let observedRequests = OpenCodeGoRequestRecorder() + OpenCodeGoStubURLProtocol.handler = { request in + guard let url = request.url else { throw URLError(.badURL) } + observedRequests.append(request) + if url.path == "/workspace/wrk_TEST123" { + return Self.makeResponse( + url: url, + body: "Workspace dashboard without hydrated billing data", + statusCode: 200, + contentType: "text/html") + } + if url.path == "/_server" { + return Self.makeResponse( + url: url, + body: #"$R[0]={customerID:"cus_test",balance:$R[1]=9876000000,reload:!1}"#, + statusCode: 200, + contentType: "text/javascript") + } + return Self.makeResponse( + url: url, + body: Self.goUsagePageHTML( + workspaceID: "wrk_TEST123", + rolling: UsageWindow(percent: 17, resetInSec: 600), + weekly: UsageWindow(percent: 75, resetInSec: 7200), + monthly: nil), + statusCode: 200, + contentType: "text/html") + } + + let snapshot = try await OpenCodeGoUsageFetcher.fetchUsage( + cookieHeader: "auth=test", + timeout: 2, + workspaceIDOverride: "wrk_TEST123", + session: self.makeSession()) + + #expect(snapshot.zenBalanceUSD == 98.76) + let billingRequest = try #require(observedRequests.values.first { $0.url?.path == "/_server" }) + let billingURL = try #require(billingRequest.url) + let components = try #require(URLComponents(url: billingURL, resolvingAgainstBaseURL: false)) + let query = Dictionary(uniqueKeysWithValues: (components.queryItems ?? []).map { ($0.name, $0.value ?? "") }) + #expect(query["id"] == "c83b78a614689c38ebee981f9b39a8b377716db85c1fd7dbab604adc02d3313d") + #expect(query["args"] == #"["wrk_TEST123"]"#) + #expect(billingRequest.value(forHTTPHeaderField: "Cookie") == "auth=test") + #expect(billingRequest.value(forHTTPHeaderField: "Referer") == "https://opencode.ai/workspace/wrk_TEST123") + } + @Test func `optional zen balance helper uses normalized cookie and workspace override`() async throws { defer { @@ -331,7 +626,7 @@ struct OpenCodeGoUsageFetcherErrorTests { #expect(snapshot.rollingUsagePercent == 17) #expect(snapshot.zenBalanceUSD == nil) - #expect(rootTimeout == 5) + #expect(rootTimeout == 60) } @Test @@ -549,7 +844,11 @@ struct OpenCodeGoUsageFetcherErrorTests { } final class OpenCodeGoStubURLProtocol: URLProtocol { - nonisolated(unsafe) static var handler: ((URLRequest) throws -> (HTTPURLResponse, Data))? + private static let _handlerBox = LockIsolated<((URLRequest) throws -> (HTTPURLResponse, Data))?>(nil) + static var handler: ((URLRequest) throws -> (HTTPURLResponse, Data))? { + get { Self._handlerBox.value } + set { Self._handlerBox.setValue(newValue) } + } override static func canInit(with request: URLRequest) -> Bool { request.url?.host == "opencode.ai" diff --git a/Tests/CodexBarTests/OpenCodeGoUsageParserTests.swift b/Tests/CodexBarTests/OpenCodeGoUsageParserTests.swift index e113747475..6b5b6269fa 100644 --- a/Tests/CodexBarTests/OpenCodeGoUsageParserTests.swift +++ b/Tests/CodexBarTests/OpenCodeGoUsageParserTests.swift @@ -59,6 +59,30 @@ struct OpenCodeGoUsageParserTests { #expect(OpenCodeGoUsageFetcher.parseZenBalance(text: text) == 1042.75) } + @Test + func `parses scaled zen balance from billing server response`() { + let text = + #";0x00000120;((self.$R=self.$R||{})["server-fn:test"]=[],"# + + #"($R=>$R[0]=$R[1]={customerID:"cus_test",balance:$R[2]=2375000000,reload:!1})"# + + #"($R["server-fn:test"]))"# + + #expect(OpenCodeGoZenBalanceParser.parseBillingServerResponse(text: text) == 23.75) + } + + @Test + func `billing server parser ignores unrelated balance metadata`() { + let text = #"$R[0]={balanceEnabled:!0,balanceUpdatedAt:1800000000}"# + + #expect(OpenCodeGoZenBalanceParser.parseBillingServerResponse(text: text) == nil) + } + + @Test + func `billing server parser ignores balance when billing is disabled`() { + let text = #"$R[0]={customerID:null,balance:0,reload:!1}"# + + #expect(OpenCodeGoZenBalanceParser.parseBillingServerResponse(text: text) == nil) + } + @Test func `zen balance parser ignores metadata before amount`() throws { let payload: [String: Any] = [ @@ -100,6 +124,77 @@ struct OpenCodeGoUsageParserTests { #expect(snapshot.monthlyResetInSec == monthlyResetInSec) } + @Test + func `parses rolling only usage from seroval response`() throws { + let text = + "$R[16]($R[30],$R[41]={rollingUsage:$R[42]={status:\"ok\",resetInSec:5944,usagePercent:17}});" + let now = Date(timeIntervalSince1970: 0) + + let snapshot = try OpenCodeGoUsageFetcher.parseSubscription(text: text, now: now) + let usage = snapshot.toUsageSnapshot() + + #expect(snapshot.rollingUsagePercent == 17) + #expect(snapshot.rollingResetInSec == 5944) + #expect(snapshot.hasWeeklyUsage == false) + #expect(usage.primary?.usedPercent == 17) + #expect(usage.secondary == nil) + #expect(usage.tertiary == nil) + } + + @Test + func `parses rolling only usage from JSON response`() throws { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let payload: [String: Any] = [ + "usage": [ + "rollingUsage": [ + "usagePercent": 25, + "resetInSec": 600, + ], + ], + ] + let data = try JSONSerialization.data(withJSONObject: payload) + let text = String(data: data, encoding: .utf8) ?? "" + + let snapshot = try OpenCodeGoUsageFetcher.parseSubscription(text: text, now: now) + let usage = snapshot.toUsageSnapshot() + + #expect(snapshot.rollingUsagePercent == 25) + #expect(snapshot.rollingResetInSec == 600) + #expect(snapshot.hasWeeklyUsage == false) + #expect(usage.primary?.usedPercent == 25) + #expect(usage.secondary == nil) + #expect(usage.tertiary == nil) + } + + @Test + func `recovers weekly usage from nested JSON window`() throws { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let payload: [String: Any] = [ + "usage": [ + "rollingUsage": [ + "usagePercent": 25, + "resetInSec": 600, + ], + "weeklyUsage": [ + "window": [ + "usagePercent": 75, + "resetInSec": 7200, + ], + ], + ], + ] + let data = try JSONSerialization.data(withJSONObject: payload) + let text = String(data: data, encoding: .utf8) ?? "" + + let snapshot = try OpenCodeGoUsageFetcher.parseSubscription(text: text, now: now) + let usage = snapshot.toUsageSnapshot() + + #expect(snapshot.hasWeeklyUsage == true) + #expect(snapshot.weeklyUsagePercent == 75) + #expect(snapshot.weeklyResetInSec == 7200) + #expect(usage.secondary?.usedPercent == 75) + } + @Test func `parses subscription from JSON with reset at and ratio percentages`() throws { let now = Date(timeIntervalSince1970: 1_700_000_000) @@ -137,6 +232,25 @@ struct OpenCodeGoUsageParserTests { #expect(snapshot.monthlyResetInSec == 86400) } + @Test(arguments: ["1e309", "1e308"]) + func `ignores reset timestamps outside integer range`(resetAt: String) throws { + let text = """ + { + "rollingUsage": { "usagePercent": 17, "resetAt": "\(resetAt)" }, + "weeklyUsage": { "usagePercent": 75, "resetInSec": 7200 } + } + """ + + let snapshot = try OpenCodeGoUsageFetcher.parseSubscription( + text: text, + now: Date(timeIntervalSince1970: 1_700_000_000)) + + #expect(snapshot.rollingUsagePercent == 17) + #expect(snapshot.weeklyUsagePercent == 75) + #expect(snapshot.rollingResetInSec == 0) + #expect(snapshot.weeklyResetInSec == 7200) + } + @Test func `computes usage percent from totals and treats monthly as optional`() throws { let now = Date(timeIntervalSince1970: 1_700_000_000) @@ -237,7 +351,7 @@ struct OpenCodeGoUsageParserTests { } @Test - func `candidate fallback does not fabricate weekly from non weekly windows`() throws { + func `candidate fallback preserves missing weekly window`() throws { let now = Date(timeIntervalSince1970: 1_700_000_000) let payload: [String: Any] = [ "windows": [ @@ -256,9 +370,15 @@ struct OpenCodeGoUsageParserTests { let data = try JSONSerialization.data(withJSONObject: payload) let text = String(data: data, encoding: .utf8) ?? "" - #expect(throws: OpenCodeGoUsageError.self) { - _ = try OpenCodeGoUsageFetcher.parseSubscription(text: text, now: now) - } + let snapshot = try OpenCodeGoUsageFetcher.parseSubscription(text: text, now: now) + let usage = snapshot.toUsageSnapshot() + + #expect(snapshot.rollingUsagePercent == 15) + #expect(snapshot.hasWeeklyUsage == false) + #expect(snapshot.hasMonthlyUsage == true) + #expect(snapshot.monthlyUsagePercent == 30) + #expect(usage.secondary == nil) + #expect(usage.tertiary?.usedPercent == 30) } @Test diff --git a/Tests/CodexBarTests/OpenCodeGoWebOverlayTests.swift b/Tests/CodexBarTests/OpenCodeGoWebOverlayTests.swift new file mode 100644 index 0000000000..817ee64e67 --- /dev/null +++ b/Tests/CodexBarTests/OpenCodeGoWebOverlayTests.swift @@ -0,0 +1,322 @@ +import Foundation +import Testing +@testable import CodexBarCore + +@Suite(.serialized) +struct OpenCodeGoWebOverlayTests { + private static let updatedAt = Date(timeIntervalSince1970: 1_784_836_525) + private static let renewsAt = Date(timeIntervalSince1970: 1_786_550_400) + + private final class Recorder: @unchecked Sendable { + private let lock = NSLock() + private var storage: [Value] = [] + + func append(_ value: Value) { + self.lock.lock() + defer { self.lock.unlock() } + self.storage.append(value) + } + + var values: [Value] { + self.lock.lock() + defer { self.lock.unlock() } + return self.storage + } + } + + private struct StubClaudeFetcher: ClaudeUsageFetching { + func loadLatestUsage(model _: String) async throws -> ClaudeUsageSnapshot { + throw ClaudeUsageError.parseFailed("stub") + } + + func debugRawProbe(model _: String) async -> String { + "stub" + } + + func detectVersion() -> String? { + nil + } + } + + private static func dailyEntry() -> CostUsageDailyReport.Entry { + CostUsageDailyReport.Entry( + date: "2026-07-20", + inputTokens: nil, + outputTokens: nil, + totalTokens: nil, + requestCount: 748, + costUSD: 11.52, + modelsUsed: nil, + modelBreakdowns: nil) + } + + /// Mirrors the mis-anchored local estimate: earliest local row far before the real billing + /// cycle, so the monthly window sums more than the $60 plan limit and clamps to 100%. + private static func localEstimate(zenBalanceUSD: Double? = nil) -> OpenCodeGoUsageSnapshot { + OpenCodeGoUsageSnapshot( + hasMonthlyUsage: true, + rollingUsagePercent: 0, + weeklyUsagePercent: 49.4, + monthlyUsagePercent: 100, + rollingResetInSec: 18000, + weeklyResetInSec: 266_400, + monthlyResetInSec: 266_400, + zenBalanceUSD: zenBalanceUSD, + daily: [self.dailyEntry()], + updatedAt: self.updatedAt) + } + + private static func webUsage(zenBalanceUSD: Double? = nil) -> OpenCodeGoUsageSnapshot { + OpenCodeGoUsageSnapshot( + hasMonthlyUsage: true, + rollingUsagePercent: 0, + weeklyUsagePercent: 52, + monthlyUsagePercent: 64, + rollingResetInSec: 18000, + weeklyResetInSec: 266_400, + monthlyResetInSec: 1_539_000, + zenBalanceUSD: zenBalanceUSD, + renewsAt: self.renewsAt, + updatedAt: self.updatedAt.addingTimeInterval(2)) + } + + private func makeContext( + includeOptionalUsage: Bool = true, + settings: ProviderSettingsSnapshot? = nil) -> ProviderFetchContext + { + ProviderFetchContext( + runtime: .app, + sourceMode: .auto, + includeCredits: false, + includeOptionalUsage: includeOptionalUsage, + webTimeout: 1, + webDebugDumpHTML: false, + verbose: false, + env: [:], + settings: settings, + fetcher: UsageFetcher(environment: [:]), + claudeFetcher: StubClaudeFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0)) + } + + private func makeManualCookieSettings() -> ProviderSettingsSnapshot { + ProviderSettingsSnapshot.make(opencodego: .init( + cookieSource: .manual, + manualCookieHeader: "auth=test", + workspaceID: nil)) + } + + @Test + func `overlay replaces estimated windows with server values and keeps local daily`() { + let merged = Self.localEstimate().applyingWebUsage(Self.webUsage(zenBalanceUSD: 42.5)) + + #expect(merged.rollingUsagePercent == 0) + #expect(merged.weeklyUsagePercent == 52) + #expect(merged.monthlyUsagePercent == 64) + #expect(merged.monthlyResetInSec == 1_539_000) + #expect(merged.hasWeeklyUsage) + #expect(merged.hasMonthlyUsage) + #expect(merged.zenBalanceUSD == 42.5) + #expect(merged.renewsAt == Self.renewsAt) + #expect(merged.daily.count == 1) + #expect(merged.daily.first?.costUSD == 11.52) + #expect(merged.updatedAt == Self.updatedAt) + #expect(!merged.isBalanceOnly) + } + + @Test + func `overlay keeps local zen balance when web usage has none`() { + let merged = Self.localEstimate(zenBalanceUSD: 7.25).applyingWebUsage(Self.webUsage()) + + #expect(merged.zenBalanceUSD == 7.25) + #expect(merged.monthlyUsagePercent == 64) + } + + @Test + func `overlay keeps local renewal date when web usage has none`() { + let local = Self.localEstimate() + let merged = local.applyingWebUsage(Self.webUsage()) + + #expect(merged.renewsAt == Self.renewsAt) + let webWithoutRenewal = OpenCodeGoUsageSnapshot( + hasMonthlyUsage: true, + rollingUsagePercent: 1, + weeklyUsagePercent: 2, + monthlyUsagePercent: 3, + rollingResetInSec: 1, + weeklyResetInSec: 2, + monthlyResetInSec: 3, + renewsAt: nil, + updatedAt: Self.updatedAt) + #expect(local.applyingWebUsage(webWithoutRenewal).renewsAt == nil) + } + + @Test + func `balance only web response keeps local windows and adopts balance`() { + let web = OpenCodeGoUsageSnapshot.zenBalanceOnly(balanceUSD: 42.5, updatedAt: Self.updatedAt) + let merged = Self.localEstimate().applyingWebUsage(web) + + #expect(merged.monthlyUsagePercent == 100) + #expect(merged.monthlyResetInSec == 266_400) + #expect(merged.zenBalanceUSD == 42.5) + #expect(merged.daily.count == 1) + #expect(!merged.isBalanceOnly) + } + + @Test + func `overlaid snapshot projects server monthly window into usage snapshot`() { + let merged = Self.localEstimate().applyingWebUsage(Self.webUsage()) + let usage = merged.toUsageSnapshot() + + #expect(usage.primary?.usedPercent == 0) + #expect(usage.secondary?.usedPercent == 52) + #expect(usage.tertiary?.usedPercent == 64) + #expect(usage.tertiary?.resetsAt == Self.updatedAt.addingTimeInterval(1_539_000)) + #expect(usage.opencodegoUsage?.daily.count == 1) + #expect(usage.extraRateWindows?.contains { $0.id == "renewal" } == true) + } + + @Test + func `local strategy overlays authoritative web usage when a cookie is configured`() async throws { + let observedCookies = Recorder() + let strategy = OpenCodeGoLocalUsageFetchStrategy( + localSnapshotLoader: { _ in Self.localEstimate() }, + webUsageOverlayFetcher: { _, cookieHeader in + observedCookies.append(cookieHeader) + return Self.webUsage(zenBalanceUSD: 42.5) + }) + + let result = try await strategy.fetch(self.makeContext(settings: self.makeManualCookieSettings())) + + #expect(result.sourceLabel == "local+web") + #expect(observedCookies.values == ["auth=test"]) + #expect(result.usage.tertiary?.usedPercent == 64) + #expect(result.usage.secondary?.usedPercent == 52) + #expect(result.usage.opencodegoUsage?.daily.count == 1) + #expect(result.usage.providerCost?.used == 42.5) + } + + @Test + func `local strategy keeps local estimate when web overlay is unavailable`() async throws { + let strategy = OpenCodeGoLocalUsageFetchStrategy( + localSnapshotLoader: { _ in Self.localEstimate() }, + webUsageOverlayFetcher: { _, _ in nil }) + + let result = try await strategy.fetch(self.makeContext( + includeOptionalUsage: false, + settings: self.makeManualCookieSettings())) + + #expect(result.sourceLabel == "local") + #expect(result.usage.tertiary?.usedPercent == 100) + } + + @Test + func `local strategy does not consult web usage when cookies are disabled`() async throws { + let webCalls = Recorder() + let strategy = OpenCodeGoLocalUsageFetchStrategy( + localSnapshotLoader: { _ in Self.localEstimate() }, + webUsageOverlayFetcher: { _, cookieHeader in + webCalls.append(cookieHeader) + return Self.webUsage() + }) + let settings = ProviderSettingsSnapshot.make(opencodego: .init( + cookieSource: .off, + manualCookieHeader: nil, + workspaceID: nil)) + + let result = try await strategy.fetch(self.makeContext(settings: settings)) + + #expect(webCalls.values.isEmpty) + #expect(result.sourceLabel == "local") + #expect(result.usage.tertiary?.usedPercent == 100) + } + + @Test + func `local strategy propagates cancellation from the web overlay`() async { + let strategy = OpenCodeGoLocalUsageFetchStrategy( + localSnapshotLoader: { _ in Self.localEstimate() }, + webUsageOverlayFetcher: { _, _ in throw CancellationError() }) + + await #expect(throws: CancellationError.self) { + try await strategy.fetch(self.makeContext(settings: self.makeManualCookieSettings())) + } + } + + @Test + func `local strategy propagates url session cancellation from the web overlay`() async { + let strategy = OpenCodeGoLocalUsageFetchStrategy( + localSnapshotLoader: { _ in Self.localEstimate() }, + webUsageOverlayFetcher: { _, _ in throw URLError(.cancelled) }) + + await #expect(throws: CancellationError.self) { + try await strategy.fetch(self.makeContext(settings: self.makeManualCookieSettings())) + } + } + + #if os(macOS) + @Test + func `local strategy evicts cached cookie after authentication failure`() async throws { + try await self.withCachedCookie { + let strategy = OpenCodeGoLocalUsageFetchStrategy( + localSnapshotLoader: { _ in Self.localEstimate() }, + webUsageOverlayFetcher: { _, _ in throw OpenCodeGoUsageError.invalidCredentials }) + + let result = try await strategy.fetch(self.makeContext(includeOptionalUsage: false)) + + #expect(result.sourceLabel == "local") + #expect(result.usage.tertiary?.usedPercent == 100) + #expect(CookieHeaderCache.load(provider: .opencodego) == nil) + } + } + + @Test + func `local strategy retains cached cookie after transport failure`() async throws { + try await self.withCachedCookie { + let strategy = OpenCodeGoLocalUsageFetchStrategy( + localSnapshotLoader: { _ in Self.localEstimate() }, + webUsageOverlayFetcher: { _, _ in throw URLError(.timedOut) }) + + let result = try await strategy.fetch(self.makeContext(includeOptionalUsage: false)) + + #expect(result.sourceLabel == "local") + #expect(result.usage.tertiary?.usedPercent == 100) + #expect(CookieHeaderCache.load(provider: .opencodego)?.cookieHeader == "auth=cached-session") + } + } + + @Test + func `local strategy reuses valid cached cookie`() async throws { + try await self.withCachedCookie { + let observedCookies = Recorder() + let strategy = OpenCodeGoLocalUsageFetchStrategy( + localSnapshotLoader: { _ in Self.localEstimate() }, + webUsageOverlayFetcher: { _, cookieHeader in + observedCookies.append(cookieHeader) + return Self.webUsage() + }) + + let result = try await strategy.fetch(self.makeContext(includeOptionalUsage: false)) + + #expect(result.sourceLabel == "local+web") + #expect(result.usage.tertiary?.usedPercent == 64) + #expect(observedCookies.values == ["auth=cached-session"]) + #expect(CookieHeaderCache.load(provider: .opencodego)?.cookieHeader == "auth=cached-session") + } + } + + private func withCachedCookie(_ operation: () async throws -> T) async rethrows -> T { + let service = "com.steipete.codexbar.tests.opencodego-overlay.\(UUID().uuidString)" + return try await KeychainCacheStore.withServiceOverrideForTesting(service) { + try await KeychainCacheStore.withImplicitTestStoreForTesting { + CookieHeaderCache.resetDisplayCacheForTesting() + defer { CookieHeaderCache.resetDisplayCacheForTesting() } + CookieHeaderCache.store( + provider: .opencodego, + cookieHeader: "auth=cached-session", + sourceLabel: "Chrome") + return try await operation() + } + } + } + #endif +} diff --git a/Tests/CodexBarTests/OpenCodeUsageFetcherErrorTests.swift b/Tests/CodexBarTests/OpenCodeUsageFetcherErrorTests.swift index c934d35205..f6bdde3ef3 100644 --- a/Tests/CodexBarTests/OpenCodeUsageFetcherErrorTests.swift +++ b/Tests/CodexBarTests/OpenCodeUsageFetcherErrorTests.swift @@ -279,7 +279,11 @@ struct OpenCodeUsageFetcherErrorTests { } final class OpenCodeStubURLProtocol: URLProtocol { - nonisolated(unsafe) static var handler: ((URLRequest) throws -> (HTTPURLResponse, Data))? + private static let _handlerBox = LockIsolated<((URLRequest) throws -> (HTTPURLResponse, Data))?>(nil) + static var handler: ((URLRequest) throws -> (HTTPURLResponse, Data))? { + get { Self._handlerBox.value } + set { Self._handlerBox.setValue(newValue) } + } override static func canInit(with request: URLRequest) -> Bool { request.url?.host == "opencode.ai" diff --git a/Tests/CodexBarTests/OpenCodeUsageParserTests.swift b/Tests/CodexBarTests/OpenCodeUsageParserTests.swift index ab7359cb19..291fc8734b 100644 --- a/Tests/CodexBarTests/OpenCodeUsageParserTests.swift +++ b/Tests/CodexBarTests/OpenCodeUsageParserTests.swift @@ -53,6 +53,25 @@ struct OpenCodeUsageParserTests { #expect(snapshot.weeklyResetInSec == 7200) } + @Test(arguments: ["1e309", "1e308"]) + func `ignores reset timestamps outside integer range`(resetAt: String) throws { + let text = """ + { + "rollingUsage": { "usagePercent": 17, "resetAt": "\(resetAt)" }, + "weeklyUsage": { "usagePercent": 75, "resetInSec": 7200 } + } + """ + + let snapshot = try OpenCodeUsageFetcher.parseSubscription( + text: text, + now: Date(timeIntervalSince1970: 1_700_000_000)) + + #expect(snapshot.rollingUsagePercent == 17) + #expect(snapshot.weeklyUsagePercent == 75) + #expect(snapshot.rollingResetInSec == 0) + #expect(snapshot.weeklyResetInSec == 7200) + } + @Test func `parses subscription from candidate windows`() throws { let now = Date(timeIntervalSince1970: 1_700_000_000) diff --git a/Tests/CodexBarTests/OpenRouterMultiAccountTests.swift b/Tests/CodexBarTests/OpenRouterMultiAccountTests.swift new file mode 100644 index 0000000000..402b2306b2 --- /dev/null +++ b/Tests/CodexBarTests/OpenRouterMultiAccountTests.swift @@ -0,0 +1,249 @@ +import CodexBarCore +import Commander +import Foundation +import Testing +@testable import CodexBar +@testable import CodexBarCLI + +private actor OpenRouterAccountFetchRecorder { + struct Request: Sendable { + let accountID: UUID? + let accountValue: String? + } + + private(set) var requests: [Request] = [] + + func record(context: ProviderFetchContext) { + self.requests.append(Request( + accountID: context.selectedTokenAccountID, + accountValue: context.env[OpenRouterSettingsReader.envKey])) + } +} + +private struct OpenRouterAccountFetchStrategy: ProviderFetchStrategy { + let recorder: OpenRouterAccountFetchRecorder + + let id = "openrouter-account-test" + let kind: ProviderFetchKind = .apiToken + + func isAvailable(_: ProviderFetchContext) async -> Bool { + true + } + + func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult { + await self.recorder.record(context: context) + let accountValue = context.env[OpenRouterSettingsReader.envKey] + let totalUsage = accountValue == "test-key" ? 10.0 : 40.0 + let usage = OpenRouterUsageSnapshot( + totalCredits: 100, + totalUsage: totalUsage, + balance: 100 - totalUsage, + usedPercent: totalUsage, + keyDataFetched: true, + keyLimit: 100, + keyUsage: totalUsage, + rateLimit: nil, + updatedAt: Date(timeIntervalSince1970: totalUsage)) + .toUsageSnapshot() + return self.makeResult(usage: usage, sourceLabel: self.id) + } + + func shouldFallback(on _: any Error, context _: ProviderFetchContext) -> Bool { + false + } +} + +@MainActor +@Suite(.serialized) +struct OpenRouterMultiAccountTests { + @Test + func `catalog entry exposes OpenRouter accounts in provider settings`() throws { + let support = try #require(TokenAccountSupportCatalog.support(for: .openrouter)) + #expect(support.title == "API keys") + #expect(support.subtitle == "Store multiple OpenRouter API keys.") + #expect(support.placeholder == "sk-or-v1-...") + #expect(!support.requiresManualCookieSource) + #expect(support.cookieName == nil) + guard case let .environment(key) = support.injection else { + Issue.record("Expected OpenRouter token accounts to use environment injection") + return + } + #expect(key == OpenRouterSettingsReader.envKey) + + let settings = Self.makeSettings(suite: "OpenRouterMultiAccountTests-settings") + let store = try Self.makeStore(settings: settings) + let descriptor = try #require( + ProvidersPane(settings: settings, store: store)._test_tokenAccountDescriptor(for: .openrouter)) + #expect(descriptor.provider == .openrouter) + #expect(descriptor.title == support.title) + #expect(descriptor.isVisible?() == true) + } + + @Test + func `two OpenRouter accounts fetch with isolated keys and caches`() async throws { + let settings = Self.makeSettings(suite: "OpenRouterMultiAccountTests-fetch") + settings.openRouterAPIToken = "decoy-token" + settings.addTokenAccount(provider: .openrouter, label: "Personal", token: "test-key") + settings.addTokenAccount(provider: .openrouter, label: "Work", token: "test-auth-token") + let accounts = settings.tokenAccounts(for: .openrouter) + let recorder = OpenRouterAccountFetchRecorder() + let store = try Self.makeStore(settings: settings, recorder: recorder) + + await store.refreshTokenAccounts(provider: .openrouter, accounts: accounts) + + let requests = await recorder.requests + #expect(Set(requests.compactMap(\.accountValue)) == ["test-key", "test-auth-token"]) + #expect(Set(requests.compactMap(\.accountID)) == Set(accounts.map(\.id))) + #expect(!requests.contains { + $0.accountValue == "decoy-token" || $0.accountValue == "test-token-placeholder" + }) + + let snapshots = try #require(store.accountSnapshots[.openrouter]) + #expect(snapshots.map(\.account.id) == accounts.map(\.id)) + #expect(snapshots.map { $0.snapshot?.accountEmail(for: .openrouter) } == ["Personal", "Work"]) + #expect(snapshots.map(\.snapshot?.openRouterUsage?.balance) == [90, 60]) + #expect(Set(snapshots.map(\.cacheKey)).count == 2) + + settings.setActiveTokenAccountIndex(0, for: .openrouter) + store.activateCachedTokenAccountSnapshot(provider: .openrouter, accountID: accounts[0].id) + #expect(store.snapshot(for: .openrouter)?.openRouterUsage?.balance == 90) + settings.setActiveTokenAccountIndex(1, for: .openrouter) + store.activateCachedTokenAccountSnapshot(provider: .openrouter, accountID: accounts[1].id) + #expect(store.snapshot(for: .openrouter)?.openRouterUsage?.balance == 60) + } + + @Test + func `OpenRouter menu projection supports stacked and segmented layouts`() async throws { + let settings = Self.makeSettings(suite: "OpenRouterMultiAccountTests-menu") + settings.addTokenAccount(provider: .openrouter, label: "Personal", token: "test-key") + settings.addTokenAccount(provider: .openrouter, label: "Work", token: "test-auth-token") + let accounts = settings.tokenAccounts(for: .openrouter) + let recorder = OpenRouterAccountFetchRecorder() + let store = try Self.makeStore(settings: settings, recorder: recorder) + await store.refreshTokenAccounts(provider: .openrouter, accounts: accounts) + + let fetcher = UsageFetcher(environment: [:]) + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: testStatusBar()) + defer { controller.releaseStatusItemsForTesting() } + + settings.multiAccountMenuLayout = .stacked + let stacked = try #require(controller.tokenAccountMenuDisplay(for: .openrouter)) + #expect(stacked.layout == .stacked) + #expect(stacked.accounts.map(\.label) == ["Personal", "Work"]) + #expect(stacked.snapshots.map(\.account.id) == accounts.map(\.id)) + let cardModels = stacked.snapshots.compactMap { + controller.tokenAccountMenuCardModel(for: .openrouter, accountSnapshot: $0) + } + #expect(cardModels.map(\.provider) == [.openrouter, .openrouter]) + #expect(cardModels.map(\.email) == ["Personal", "Work"]) + + settings.multiAccountMenuLayout = .segmented + let segmented = try #require(controller.tokenAccountMenuDisplay(for: .openrouter)) + #expect(segmented.layout == .segmented) + #expect(segmented.activeIndex == 1) + #expect(segmented.snapshots.isEmpty) + } + + @Test + func `OpenRouter CLI routes selected and all accounts`() throws { + let accounts = [ + Self.account(label: "Personal", token: "test-key", seed: 1), + Self.account(label: "Work", token: "test-auth-token", seed: 2), + ] + let config = CodexBarConfig(providers: [ + ProviderConfig( + id: .openrouter, + apiKey: "decoy-token", + tokenAccounts: ProviderTokenAccountData(version: 1, accounts: accounts, activeIndex: 0)), + ]) + let parser = CommandParser(signature: CodexBarCLI._usageSignatureForTesting()) + let selectedValues = try parser.parse(arguments: [ + "--provider", "openrouter", + "--account", "Work", + ]) + let allValues = try parser.parse(arguments: [ + "--provider", "openrouter", + "--all-accounts", + ]) + + let selectedContext = try TokenAccountCLIContext( + selection: TokenAccountCLISelection( + label: selectedValues.options["account"]?.last, + index: nil, + allAccounts: false), + config: config, + verbose: false, + baseEnvironment: [OpenRouterSettingsReader.envKey: "test-token-placeholder"]) + let selected = try selectedContext.resolvedAccounts(for: .openrouter) + #expect(selected.map(\.label) == ["Work"]) + #expect(selectedContext.environment( + base: [OpenRouterSettingsReader.envKey: "test-token-placeholder"], + provider: .openrouter, + account: selected[0])[OpenRouterSettingsReader.envKey] == "test-auth-token") + + let allContext = try TokenAccountCLIContext( + selection: TokenAccountCLISelection( + label: nil, + index: nil, + allAccounts: allValues.flags.contains("allAccounts")), + config: config, + verbose: false, + baseEnvironment: [OpenRouterSettingsReader.envKey: "test-token-placeholder"]) + let all = try allContext.resolvedAccounts(for: .openrouter) + #expect(all.map(\.label) == ["Personal", "Work"]) + #expect(all.map { + allContext.environment(base: [:], provider: .openrouter, account: $0)[OpenRouterSettingsReader.envKey] + } == ["test-key", "test-auth-token"]) + } + + private static func makeSettings(suite: String) -> SettingsStore { + testSettingsStore( + suiteName: "\(suite)-\(UUID().uuidString)", + tokenAccountStore: InMemoryTokenAccountStore()) + } + + private static func makeStore( + settings: SettingsStore, + recorder: OpenRouterAccountFetchRecorder? = nil) throws -> UsageStore + { + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + startupBehavior: .testing, + environmentBase: [OpenRouterSettingsReader.envKey: "test-token-placeholder"]) + guard let recorder else { return store } + let baseSpec = try #require(store.providerSpecs[.openrouter]) + let baseDescriptor = baseSpec.descriptor + let strategy = OpenRouterAccountFetchStrategy(recorder: recorder) + store.providerSpecs[.openrouter] = ProviderSpec( + style: baseSpec.style, + isEnabled: { true }, + descriptor: ProviderDescriptor( + id: .openrouter, + metadata: baseDescriptor.metadata, + branding: baseDescriptor.branding, + tokenCost: baseDescriptor.tokenCost, + fetchPlan: ProviderFetchPlan( + sourceModes: [.auto, .api], + pipeline: ProviderFetchPipeline { _ in [strategy] }), + cli: baseDescriptor.cli), + makeFetchContext: baseSpec.makeFetchContext) + return store + } + + private static func account(label: String, token: String, seed: UInt8) -> ProviderTokenAccount { + ProviderTokenAccount( + id: UUID(uuid: (seed, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, seed)), + label: label, + token: token, + addedAt: TimeInterval(seed), + lastUsed: nil) + } +} diff --git a/Tests/CodexBarTests/OpenRouterUsageStatsTests.swift b/Tests/CodexBarTests/OpenRouterUsageStatsTests.swift index c811414986..be61f68bda 100644 --- a/Tests/CodexBarTests/OpenRouterUsageStatsTests.swift +++ b/Tests/CodexBarTests/OpenRouterUsageStatsTests.swift @@ -201,6 +201,27 @@ struct OpenRouterUsageStatsTests { #expect(usage.keyQuotaStatus == .unavailable) } + @Test + func `key enrichment timeout does not wait for operation that ignores cancellation`() async throws { + let startedAt = ContinuousClock.now + + let fetched = try await OpenRouterUsageFetcher._boundedKeyFetchForTesting( + timeout: .milliseconds(20)) + { + await withCheckedContinuation { continuation in + DispatchQueue.global().asyncAfter(deadline: .now() + 0.5) { + continuation.resume() + } + } + } + + let elapsed = startedAt.duration(to: .now) + #expect(!fetched) + #expect(elapsed < .milliseconds(300)) + + try await Task.sleep(for: .milliseconds(550)) + } + @Test func `usage snapshot round trip persists open router usage metadata`() throws { let openRouter = OpenRouterUsageSnapshot( @@ -244,7 +265,11 @@ struct OpenRouterUsageStatsTests { } final class OpenRouterStubURLProtocol: URLProtocol { - nonisolated(unsafe) static var handler: ((URLRequest) throws -> (HTTPURLResponse, Data))? + private static let _handlerBox = LockIsolated<((URLRequest) throws -> (HTTPURLResponse, Data))?>(nil) + static var handler: ((URLRequest) throws -> (HTTPURLResponse, Data))? { + get { Self._handlerBox.value } + set { Self._handlerBox.setValue(newValue) } + } override static func canInit(with request: URLRequest) -> Bool { request.url?.host == "openrouter.test" diff --git a/Tests/CodexBarTests/PathBuilderTests.swift b/Tests/CodexBarTests/PathBuilderTests.swift index 7ac329e0cd..6c989256bc 100644 --- a/Tests/CodexBarTests/PathBuilderTests.swift +++ b/Tests/CodexBarTests/PathBuilderTests.swift @@ -44,6 +44,29 @@ struct PathBuilderTests { #expect(async == sync) } + @Test + func `login shell cache retries after timed out nil capture`() async { + let capture = LoginShellPathCaptureStub([ + nil, + ["/login/bin", "/usr/bin"], + ]) + + let cache = LoginShellPathCache { _, _ in capture.next() } + let firstResult: [String]? = await withCheckedContinuation { continuation in + cache.captureOnce(shell: "/unused", timeout: 0.01) { result in + continuation.resume(returning: result) + } + } + + #expect(firstResult == nil) + #expect(cache.current == nil) + + let recovered = cache.currentOrCapture(shell: "/unused", timeout: 2.0) + #expect(recovered == ["/login/bin", "/usr/bin"]) + #expect(cache.current == ["/login/bin", "/usr/bin"]) + #expect(capture.callCount == 2) + } + @Test func `shell runner drains noisy stdout and stderr`() throws { let script = """ @@ -73,7 +96,7 @@ struct PathBuilderTests { let escapedMarker = Self.shellSingleQuoted(marker) let script = """ ( - trap '' TERM + trap '' HUP TERM touch \(escapedMarker) while :; do sleep 1; done ) & @@ -134,6 +157,99 @@ struct PathBuilderTests { #expect(resolved == "/env/bin/codex") } + @Test + func `resolves codex from bundled ChatGPT app`() { + let appPath = "/Applications/ChatGPT.app/Contents/Resources/codex" + let fm = MockFileManager(executables: [appPath]) + + let resolved = BinaryLocator.resolveCodexBinary( + env: ["PATH": "/missing/bin"], + loginPATH: nil, + commandV: { _, _, _, _ in nil }, + aliasResolver: { _, _, _, _, _ in nil }, + launchCandidateFilter: { _, _ in true }, + fileManager: fm, + home: "/Users/test") + + #expect(resolved == appPath) + } + + @Test + func `resolves codex from user bundled ChatGPT app`() { + let appPath = "/Users/test/Applications/ChatGPT.app/Contents/Resources/codex" + let fm = MockFileManager(executables: [appPath]) + + let resolved = BinaryLocator.resolveCodexBinary( + env: ["PATH": "/missing/bin"], + loginPATH: nil, + commandV: { _, _, _, _ in nil }, + aliasResolver: { _, _, _, _, _ in nil }, + launchCandidateFilter: { _, _ in true }, + fileManager: fm, + home: "/Users/test") + + #expect(resolved == appPath) + } + + @Test + func `prefers bundled ChatGPT app over legacy Codex app within one scope`() { + let chatGPTPath = "/Applications/ChatGPT.app/Contents/Resources/codex" + let codexPath = "/Applications/Codex.app/Contents/Resources/codex" + let fm = MockFileManager(executables: [chatGPTPath, codexPath]) + + let resolved = BinaryLocator.resolveCodexBinary( + env: ["PATH": "/missing/bin"], + loginPATH: nil, + commandV: { _, _, _, _ in nil }, + aliasResolver: { _, _, _, _, _ in nil }, + launchCandidateFilter: { _, _ in true }, + fileManager: fm, + home: "/Users/test") + + #expect(resolved == chatGPTPath) + } + + @Test + func `preserves user app precedence over system ChatGPT app`() { + let userCodexPath = "/Users/test/Applications/Codex.app/Contents/Resources/codex" + let systemChatGPTPath = "/Applications/ChatGPT.app/Contents/Resources/codex" + let fm = MockFileManager(executables: [userCodexPath, systemChatGPTPath]) + + let resolved = BinaryLocator.resolveCodexBinary( + env: ["PATH": "/missing/bin"], + loginPATH: nil, + commandV: { _, _, _, _ in nil }, + aliasResolver: { _, _, _, _, _ in nil }, + launchCandidateFilter: { _, _ in true }, + fileManager: fm, + home: "/Users/test") + + #expect(resolved == userCodexPath) + } + + @Test + func `skips blocked ChatGPT app and falls back to legacy Codex app`() { + let chatGPTPath = "/Users/test/Applications/ChatGPT.app/Contents/Resources/codex" + let codexPath = "/Users/test/Applications/Codex.app/Contents/Resources/codex" + let fm = MockFileManager(executables: [chatGPTPath, codexPath]) + var checked: [String] = [] + + let resolved = BinaryLocator.resolveCodexBinary( + env: ["PATH": "/missing/bin"], + loginPATH: nil, + commandV: { _, _, _, _ in nil }, + aliasResolver: { _, _, _, _, _ in nil }, + launchCandidateFilter: { path, _ in + checked.append(path) + return path != chatGPTPath + }, + fileManager: fm, + home: "/Users/test") + + #expect(resolved == codexPath) + #expect(checked == [chatGPTPath, codexPath]) + } + @Test func `skips blocked codex path and falls back to signed app binary`() { let blockedPath = "/usr/local/bin/codex" @@ -209,7 +325,8 @@ struct PathBuilderTests { path: "/Applications/Codex.app/Contents/Resources/codex", fileManager: MockFileManager(executables: []), hasExtendedAttribute: { _, name in name == "com.apple.quarantine" }, - spctlAssessment: { _ in "accepted\nsource=Notarized Developer ID" }, + spctlAssessment: { _ in .init(output: "accepted\nsource=Notarized Developer ID", exitStatus: 0) }, + appSignatureIsTrusted: { _ in true }, isMachOExecutable: { _ in true }) #expect(allowed) @@ -224,8 +341,162 @@ struct PathBuilderTests { hasExtendedAttribute: { _, name in name == "com.apple.malware" }, spctlAssessment: { _ in assessed = true - return "accepted\nsource=Notarized Developer ID" + return .init(output: "accepted\nsource=Notarized Developer ID", exitStatus: 0) + }, + appSignatureIsTrusted: { _ in true }, + isMachOExecutable: { _ in true }) + + #expect(!allowed) + #expect(!assessed) + } + + @Test + func `Codex launch preflight validates containing app bundle`() { + let executable = "/Applications/ChatGPT.app/Contents/Resources/codex" + let bundle = "/Applications/ChatGPT.app" + var assessedPaths: [String] = [] + + let allowed = CodexLaunchPreflight.isLaunchCandidateAllowed( + path: executable, + fileManager: MockFileManager(executables: []), + hasExtendedAttribute: { path, name in + path == bundle && name == "com.apple.quarantine" + }, + spctlAssessment: { path in + assessedPaths.append(path) + return .init(output: "\(path): accepted\nsource=Notarized Developer ID", exitStatus: 0) + }, + appSignatureIsTrusted: { path in path == bundle }, + isMachOExecutable: { path in path == executable }) + + #expect(allowed) + #expect(assessedPaths == [bundle]) + } + + @Test + func `Codex launch preflight blocks unexpected app signing identity`() { + let executable = "/Users/test/Applications/ChatGPT.app/Contents/Resources/codex" + let bundle = "/Users/test/Applications/ChatGPT.app" + var assessed = false + + let allowed = CodexLaunchPreflight.isLaunchCandidateAllowed( + path: executable, + fileManager: MockFileManager(executables: []), + hasExtendedAttribute: { _, _ in false }, + spctlAssessment: { _ in + assessed = true + return .init(output: "accepted", exitStatus: 0) }, + appSignatureIsTrusted: { path in + #expect(path == bundle) + return false + }, + isMachOExecutable: { path in path == executable }) + + #expect(!allowed) + #expect(!assessed) + } + + @Test + func `Codex launch preflight blocks rejected containing app bundle`() { + let executable = "/Users/test/Applications/ChatGPT.app/Contents/Resources/codex" + let bundle = "/Users/test/Applications/ChatGPT.app" + + let allowed = CodexLaunchPreflight.isLaunchCandidateAllowed( + path: executable, + fileManager: MockFileManager(executables: []), + hasExtendedAttribute: { _, _ in false }, + spctlAssessment: { path in + #expect(path == bundle) + return .init(output: "\(path): rejected\nsource=no usable signature", exitStatus: 3) + }, + appSignatureIsTrusted: { _ in true }, + isMachOExecutable: { path in path == executable }) + + #expect(!allowed) + } + + @Test + func `Codex launch preflight requires successful app bundle assessment`() { + let executable = "/Users/test/Applications/ChatGPT.app/Contents/Resources/codex" + let bundle = "/Users/test/Applications/ChatGPT.app" + + let allowed = CodexLaunchPreflight.isLaunchCandidateAllowed( + path: executable, + fileManager: MockFileManager(executables: []), + hasExtendedAttribute: { _, _ in false }, + spctlAssessment: { path in + #expect(path == bundle) + return .init(output: "\(path): accepted", exitStatus: 1) + }, + appSignatureIsTrusted: { _ in true }, + isMachOExecutable: { path in path == executable }) + + #expect(!allowed) + } + + @Test + func `Codex launch preflight rejects indeterminate app assessment`() { + let executable = "/Users/test/Applications/ChatGPT.app/Contents/Resources/codex" + let bundle = "/Users/test/Applications/ChatGPT.app" + + let allowed = CodexLaunchPreflight.isLaunchCandidateAllowed( + path: executable, + fileManager: MockFileManager(executables: []), + hasExtendedAttribute: { _, _ in false }, + spctlAssessment: { path in + #expect(path == bundle) + return .init(output: "internal code signing error", exitStatus: 0) + }, + appSignatureIsTrusted: { _ in true }, + isMachOExecutable: { path in path == executable }) + + #expect(!allowed) + } + + @Test + func `Codex launch preflight fails closed when app bundle cannot be assessed`() { + let executable = "/Users/test/Applications/ChatGPT.app/Contents/Resources/codex" + let bundle = "/Users/test/Applications/ChatGPT.app" + + let allowed = CodexLaunchPreflight.isLaunchCandidateAllowed( + path: executable, + fileManager: MockFileManager(executables: []), + hasExtendedAttribute: { path, name in + path == bundle && name == "com.apple.quarantine" + }, + spctlAssessment: { path in + #expect(path == bundle) + return nil + }, + appSignatureIsTrusted: { _ in true }, + isMachOExecutable: { _ in false }) + + #expect(!allowed) + } + + @Test + func `Codex launch preflight blocks app bundled executable symlink escaping the bundle`() throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + let bundle = root.appendingPathComponent("ChatGPT.app") + let resources = bundle.appendingPathComponent("Contents/Resources") + let executable = resources.appendingPathComponent("codex") + let escapedTarget = root.appendingPathComponent("outside-codex") + try FileManager.default.createDirectory(at: resources, withIntermediateDirectories: true) + try Data().write(to: escapedTarget) + try FileManager.default.createSymbolicLink(at: executable, withDestinationURL: escapedTarget) + defer { try? FileManager.default.removeItem(at: root) } + var assessed = false + + let allowed = CodexLaunchPreflight.isLaunchCandidateAllowed( + path: executable.path, + fileManager: FileManager.default, + hasExtendedAttribute: { _, _ in false }, + spctlAssessment: { _ in + assessed = true + return .init(output: "accepted", exitStatus: 0) + }, + appSignatureIsTrusted: { _ in true }, isMachOExecutable: { _ in true }) #expect(!allowed) @@ -239,6 +510,7 @@ struct PathBuilderTests { fileManager: MockFileManager(executables: []), hasExtendedAttribute: { _, name in name == "com.apple.quarantine" }, spctlAssessment: { _ in nil }, + appSignatureIsTrusted: { _ in true }, isMachOExecutable: { _ in false }) #expect(!allowed) @@ -250,7 +522,8 @@ struct PathBuilderTests { path: "/Applications/Codex.app/Contents/Resources/codex", fileManager: MockFileManager(executables: []), hasExtendedAttribute: { _, _ in false }, - spctlAssessment: { _ in "rejected\nCSSMERR_TP_CERT_REVOKED" }, + spctlAssessment: { _ in .init(output: "rejected\nCSSMERR_TP_CERT_REVOKED", exitStatus: 3) }, + appSignatureIsTrusted: { _ in true }, isMachOExecutable: { _ in true }) #expect(!allowed) @@ -262,7 +535,8 @@ struct PathBuilderTests { path: "/opt/homebrew/bin/codex", fileManager: MockFileManager(executables: []), hasExtendedAttribute: { _, _ in false }, - spctlAssessment: { _ in "rejected\nsource=no usable signature" }, + spctlAssessment: { _ in .init(output: "rejected\nsource=no usable signature", exitStatus: 3) }, + appSignatureIsTrusted: { _ in true }, isMachOExecutable: { _ in true }) #expect(!allowed) @@ -274,7 +548,12 @@ struct PathBuilderTests { path: "/opt/homebrew/bin/codex", fileManager: MockFileManager(executables: []), hasExtendedAttribute: { _, name in name == "com.apple.quarantine" }, - spctlAssessment: { path in "\(path): rejected (the code is valid but does not seem to be an app)" }, + spctlAssessment: { path in + .init( + output: "\(path): rejected (the code is valid but does not seem to be an app)", + exitStatus: 3) + }, + appSignatureIsTrusted: { _ in true }, isMachOExecutable: { _ in true }) #expect(allowed) @@ -287,11 +566,14 @@ struct PathBuilderTests { fileManager: MockFileManager(executables: []), hasExtendedAttribute: { _, name in name == "com.apple.quarantine" }, spctlAssessment: { _ in - """ - rejected (the code is valid but does not seem to be an app) - CSSMERR_TP_CERT_REVOKED - """ + .init( + output: """ + rejected (the code is valid but does not seem to be an app) + CSSMERR_TP_CERT_REVOKED + """, + exitStatus: 3) }, + appSignatureIsTrusted: { _ in true }, isMachOExecutable: { _ in true }) #expect(!allowed) @@ -303,7 +585,10 @@ struct PathBuilderTests { path: "/tmp/code is valid but does not seem to be an app/codex", fileManager: MockFileManager(executables: []), hasExtendedAttribute: { _, name in name == "com.apple.quarantine" }, - spctlAssessment: { path in "\(path): rejected\nsource=no usable signature" }, + spctlAssessment: { path in + .init(output: "\(path): rejected\nsource=no usable signature", exitStatus: 3) + }, + appSignatureIsTrusted: { _ in true }, isMachOExecutable: { _ in true }) #expect(!allowed) @@ -315,7 +600,10 @@ struct PathBuilderTests { path: "/tmp/x: code is valid but does not seem to be an app/codex", fileManager: MockFileManager(executables: []), hasExtendedAttribute: { _, name in name == "com.apple.quarantine" }, - spctlAssessment: { path in "\(path): rejected\nsource=no usable signature" }, + spctlAssessment: { path in + .init(output: "\(path): rejected\nsource=no usable signature", exitStatus: 3) + }, + appSignatureIsTrusted: { _ in true }, isMachOExecutable: { _ in true }) #expect(!allowed) @@ -328,12 +616,15 @@ struct PathBuilderTests { fileManager: MockFileManager(executables: []), hasExtendedAttribute: { _, name in name == "com.apple.quarantine" }, spctlAssessment: { path in - """ - \(path): accepted - source=revoked quarantine marker - origin=malware test fixture - """ + .init( + output: """ + \(path): accepted + source=revoked quarantine marker + origin=malware test fixture + """, + exitStatus: 0) }, + appSignatureIsTrusted: { _ in true }, isMachOExecutable: { _ in true }) #expect(allowed) @@ -628,6 +919,29 @@ struct PathBuilderTests { } } +private final class LoginShellPathCaptureStub: @unchecked Sendable { + private let lock = NSLock() + private var results: [[String]?] + private var callCountStorage = 0 + + var callCount: Int { + self.lock.lock() + defer { self.lock.unlock() } + return self.callCountStorage + } + + init(_ results: [[String]?]) { + self.results = results + } + + func next() -> [String]? { + self.lock.lock() + defer { self.lock.unlock() } + self.callCountStorage += 1 + return self.results.isEmpty ? nil : self.results.removeFirst() + } +} + private final class MockFileManager: FileManager { private let executables: Set diff --git a/Tests/CodexBarTests/PerplexityProviderTests.swift b/Tests/CodexBarTests/PerplexityProviderTests.swift index b81a468447..cff202d06e 100644 --- a/Tests/CodexBarTests/PerplexityProviderTests.swift +++ b/Tests/CodexBarTests/PerplexityProviderTests.swift @@ -145,33 +145,32 @@ struct PerplexityProviderTests { func `environment token does not populate browser cookie cache`() async throws { try await self.withIsolatedCacheStore { PerplexityCookieImporter.invalidateImportSessionCache() - PerplexityCookieImporter.importSessionsOverrideForTesting = nil - PerplexityCookieImporter.importSessionOverrideForTesting = { _, _ in - throw PerplexityCookieImportError.noCookies - } defer { - PerplexityCookieImporter.importSessionsOverrideForTesting = nil - PerplexityCookieImporter.importSessionOverrideForTesting = nil PerplexityCookieImporter.invalidateImportSessionCache() } - let strategy = PerplexityWebFetchStrategy() - let settings = ProviderSettingsSnapshot.make( - perplexity: ProviderSettingsSnapshot.PerplexityProviderSettings( - cookieSource: .auto, - manualCookieHeader: nil)) - let context = self.makeContext( - settings: settings, - env: ["PERPLEXITY_COOKIE": "authjs.session-token=env-token"]) - let fetchOverride: @Sendable (String, String, Date) async throws -> PerplexityUsageSnapshot = { _, _, _ in - self.stubSnapshot() - } + try await PerplexityCookieImporter.withImportSessionOverrideForTesting { _, _ in + throw PerplexityCookieImportError.noCookies + } operation: { + let strategy = PerplexityWebFetchStrategy() + let settings = ProviderSettingsSnapshot.make( + perplexity: ProviderSettingsSnapshot.PerplexityProviderSettings( + cookieSource: .auto, + manualCookieHeader: nil)) + let context = self.makeContext( + settings: settings, + env: ["PERPLEXITY_COOKIE": "authjs.session-token=env-token"]) + let fetchOverride: @Sendable (String, String, Date) async throws + -> PerplexityUsageSnapshot = { _, _, _ in + self.stubSnapshot() + } - _ = try await PerplexityUsageFetcher.$fetchCreditsOverride.withValue(fetchOverride, operation: { - try await strategy.fetch(context) - }) + _ = try await PerplexityUsageFetcher.$fetchCreditsOverride.withValue(fetchOverride, operation: { + try await strategy.fetch(context) + }) - #expect(CookieHeaderCache.load(provider: .perplexity) == nil) + #expect(CookieHeaderCache.load(provider: .perplexity) == nil) + } } } @@ -200,43 +199,41 @@ struct PerplexityProviderTests { func `bare environment token falls back to auth JS cookie name`() async throws { try await self.withIsolatedCacheStore { PerplexityCookieImporter.invalidateImportSessionCache() - PerplexityCookieImporter.importSessionsOverrideForTesting = nil - PerplexityCookieImporter.importSessionOverrideForTesting = { _, _ in - throw PerplexityCookieImportError.noCookies - } defer { - PerplexityCookieImporter.importSessionsOverrideForTesting = nil - PerplexityCookieImporter.importSessionOverrideForTesting = nil PerplexityCookieImporter.invalidateImportSessionCache() } - let attemptedCookieNames = LockedArray() - let strategy = PerplexityWebFetchStrategy() - let settings = ProviderSettingsSnapshot.make( - perplexity: ProviderSettingsSnapshot.PerplexityProviderSettings( - cookieSource: .auto, - manualCookieHeader: nil)) - let context = self.makeContext( - settings: settings, - env: ["PERPLEXITY_SESSION_TOKEN": "env-token"]) - let fetchOverride: @Sendable (String, String, Date) async throws - -> PerplexityUsageSnapshot = { token, cookieName, _ in - #expect(token == "env-token") - attemptedCookieNames.append(cookieName) - if cookieName == "authjs.session-token" { - return self.stubSnapshot() + try await PerplexityCookieImporter.withImportSessionOverrideForTesting { _, _ in + throw PerplexityCookieImportError.noCookies + } operation: { + let attemptedCookieNames = LockedArray() + let strategy = PerplexityWebFetchStrategy() + let settings = ProviderSettingsSnapshot.make( + perplexity: ProviderSettingsSnapshot.PerplexityProviderSettings( + cookieSource: .auto, + manualCookieHeader: nil)) + let context = self.makeContext( + settings: settings, + env: ["PERPLEXITY_SESSION_TOKEN": "env-token"]) + let fetchOverride: @Sendable (String, String, Date) async throws + -> PerplexityUsageSnapshot = { token, cookieName, _ in + #expect(token == "env-token") + attemptedCookieNames.append(cookieName) + if cookieName == "authjs.session-token" { + return self.stubSnapshot() + } + throw PerplexityAPIError.invalidToken } - throw PerplexityAPIError.invalidToken - } - _ = try await PerplexityUsageFetcher.$fetchCreditsOverride.withValue(fetchOverride, operation: { - try await strategy.fetch(context) - }) + _ = try await PerplexityUsageFetcher.$fetchCreditsOverride.withValue(fetchOverride, operation: { + try await strategy.fetch(context) + }) - #expect(attemptedCookieNames.snapshot() == [ - "__Secure-authjs.session-token", - "authjs.session-token", - ]) + #expect(attemptedCookieNames.snapshot() == [ + "__Secure-authjs.session-token", + "authjs.session-token", + ]) + } } } @@ -244,8 +241,11 @@ struct PerplexityProviderTests { func `valid environment cookie wins after invalid browser session`() async throws { try await self.withIsolatedCacheStore { PerplexityCookieImporter.invalidateImportSessionCache() - PerplexityCookieImporter.importSessionsOverrideForTesting = nil - PerplexityCookieImporter.importSessionOverrideForTesting = { _, _ in + defer { + PerplexityCookieImporter.invalidateImportSessionCache() + } + + try await PerplexityCookieImporter.withImportSessionOverrideForTesting { _, _ in let cookie = try #require(HTTPCookie(properties: [ .domain: "www.perplexity.ai", .path: "/", @@ -254,40 +254,35 @@ struct PerplexityProviderTests { .secure: "TRUE", ])) return PerplexityCookieImporter.SessionInfo(cookies: [cookie], sourceLabel: "Chrome") - } - defer { - PerplexityCookieImporter.importSessionsOverrideForTesting = nil - PerplexityCookieImporter.importSessionOverrideForTesting = nil - PerplexityCookieImporter.invalidateImportSessionCache() - } - - let attemptedTokens = LockedArray() - let strategy = PerplexityWebFetchStrategy() - let settings = ProviderSettingsSnapshot.make( - perplexity: ProviderSettingsSnapshot.PerplexityProviderSettings( - cookieSource: .auto, - manualCookieHeader: nil)) - let context = self.makeContext( - settings: settings, - env: ["PERPLEXITY_COOKIE": "authjs.session-token=env-token"]) - let fetchOverride: @Sendable (String, String, Date) async throws - -> PerplexityUsageSnapshot = { token, _, _ in - attemptedTokens.append(token) - if token == "browser-token" { + } operation: { + let attemptedTokens = LockedArray() + let strategy = PerplexityWebFetchStrategy() + let settings = ProviderSettingsSnapshot.make( + perplexity: ProviderSettingsSnapshot.PerplexityProviderSettings( + cookieSource: .auto, + manualCookieHeader: nil)) + let context = self.makeContext( + settings: settings, + env: ["PERPLEXITY_COOKIE": "authjs.session-token=env-token"]) + let fetchOverride: @Sendable (String, String, Date) async throws + -> PerplexityUsageSnapshot = { token, _, _ in + attemptedTokens.append(token) + if token == "browser-token" { + throw PerplexityAPIError.invalidToken + } + if token == "env-token" { + return self.stubSnapshot() + } + Issue.record("Unexpected token \(token)") throw PerplexityAPIError.invalidToken } - if token == "env-token" { - return self.stubSnapshot() - } - Issue.record("Unexpected token \(token)") - throw PerplexityAPIError.invalidToken - } - _ = try await PerplexityUsageFetcher.$fetchCreditsOverride.withValue(fetchOverride, operation: { - try await strategy.fetch(context) - }) + _ = try await PerplexityUsageFetcher.$fetchCreditsOverride.withValue(fetchOverride, operation: { + try await strategy.fetch(context) + }) - #expect(attemptedTokens.snapshot() == ["browser-token", "env-token"]) + #expect(attemptedTokens.snapshot() == ["browser-token", "env-token"]) + } } } @@ -295,8 +290,11 @@ struct PerplexityProviderTests { func `later browser session wins after earlier imported session fails auth`() async throws { try await self.withIsolatedCacheStore { PerplexityCookieImporter.invalidateImportSessionCache() - PerplexityCookieImporter.importSessionOverrideForTesting = nil - PerplexityCookieImporter.importSessionsOverrideForTesting = { _, _ in + defer { + PerplexityCookieImporter.invalidateImportSessionCache() + } + + try await PerplexityCookieImporter.withImportSessionsOverrideForTesting { _, _ in let staleCookie = try #require(HTTPCookie(properties: [ .domain: "www.perplexity.ai", .path: "/", @@ -315,38 +313,33 @@ struct PerplexityProviderTests { PerplexityCookieImporter.SessionInfo(cookies: [staleCookie], sourceLabel: "Chrome"), PerplexityCookieImporter.SessionInfo(cookies: [liveCookie], sourceLabel: "Safari"), ] - } - defer { - PerplexityCookieImporter.importSessionsOverrideForTesting = nil - PerplexityCookieImporter.importSessionOverrideForTesting = nil - PerplexityCookieImporter.invalidateImportSessionCache() - } - - let attemptedTokens = LockedArray() - let strategy = PerplexityWebFetchStrategy() - let settings = ProviderSettingsSnapshot.make( - perplexity: ProviderSettingsSnapshot.PerplexityProviderSettings( - cookieSource: .auto, - manualCookieHeader: nil)) - let context = self.makeContext(settings: settings) - let fetchOverride: @Sendable (String, String, Date) async throws - -> PerplexityUsageSnapshot = { token, _, _ in - attemptedTokens.append(token) - if token == "stale-browser-token" { + } operation: { + let attemptedTokens = LockedArray() + let strategy = PerplexityWebFetchStrategy() + let settings = ProviderSettingsSnapshot.make( + perplexity: ProviderSettingsSnapshot.PerplexityProviderSettings( + cookieSource: .auto, + manualCookieHeader: nil)) + let context = self.makeContext(settings: settings) + let fetchOverride: @Sendable (String, String, Date) async throws + -> PerplexityUsageSnapshot = { token, _, _ in + attemptedTokens.append(token) + if token == "stale-browser-token" { + throw PerplexityAPIError.invalidToken + } + if token == "live-browser-token" { + return self.stubSnapshot() + } + Issue.record("Unexpected token \(token)") throw PerplexityAPIError.invalidToken } - if token == "live-browser-token" { - return self.stubSnapshot() - } - Issue.record("Unexpected token \(token)") - throw PerplexityAPIError.invalidToken - } - _ = try await PerplexityUsageFetcher.$fetchCreditsOverride.withValue(fetchOverride, operation: { - try await strategy.fetch(context) - }) + _ = try await PerplexityUsageFetcher.$fetchCreditsOverride.withValue(fetchOverride, operation: { + try await strategy.fetch(context) + }) - #expect(attemptedTokens.snapshot() == ["stale-browser-token", "live-browser-token"]) + #expect(attemptedTokens.snapshot() == ["stale-browser-token", "live-browser-token"]) + } } } @@ -355,8 +348,11 @@ struct PerplexityProviderTests { try await self.withIsolatedCacheStore { let importCount = LockedCounter() PerplexityCookieImporter.invalidateImportSessionCache() - PerplexityCookieImporter.importSessionsOverrideForTesting = nil - PerplexityCookieImporter.importSessionOverrideForTesting = { _, _ in + defer { + PerplexityCookieImporter.invalidateImportSessionCache() + } + + try await PerplexityCookieImporter.withImportSessionOverrideForTesting { _, _ in importCount.increment() let cookie = try #require(HTTPCookie(properties: [ .domain: "www.perplexity.ai", @@ -366,32 +362,27 @@ struct PerplexityProviderTests { .secure: "TRUE", ])) return PerplexityCookieImporter.SessionInfo(cookies: [cookie], sourceLabel: "Chrome") - } - defer { - PerplexityCookieImporter.importSessionsOverrideForTesting = nil - PerplexityCookieImporter.importSessionOverrideForTesting = nil - PerplexityCookieImporter.invalidateImportSessionCache() - } - - let strategy = PerplexityWebFetchStrategy() - let settings = ProviderSettingsSnapshot.make( - perplexity: ProviderSettingsSnapshot.PerplexityProviderSettings( - cookieSource: .auto, - manualCookieHeader: nil)) - let context = self.makeContext(settings: settings) - let fetchOverride: @Sendable (String, String, Date) async throws - -> PerplexityUsageSnapshot = { token, _, _ in - #expect(token == "browser-token") - return self.stubSnapshot() - } + } operation: { + let strategy = PerplexityWebFetchStrategy() + let settings = ProviderSettingsSnapshot.make( + perplexity: ProviderSettingsSnapshot.PerplexityProviderSettings( + cookieSource: .auto, + manualCookieHeader: nil)) + let context = self.makeContext(settings: settings) + let fetchOverride: @Sendable (String, String, Date) async throws + -> PerplexityUsageSnapshot = { token, _, _ in + #expect(token == "browser-token") + return self.stubSnapshot() + } - #expect(await strategy.isAvailable(context)) + #expect(await strategy.isAvailable(context)) - _ = try await PerplexityUsageFetcher.$fetchCreditsOverride.withValue(fetchOverride, operation: { - try await strategy.fetch(context) - }) + _ = try await PerplexityUsageFetcher.$fetchCreditsOverride.withValue(fetchOverride, operation: { + try await strategy.fetch(context) + }) - #expect(importCount.snapshot() == 1) + #expect(importCount.snapshot() == 1) + } } } } diff --git a/Tests/CodexBarTests/PersistentRefreshAccessibilityTests.swift b/Tests/CodexBarTests/PersistentRefreshAccessibilityTests.swift new file mode 100644 index 0000000000..67a61504e8 --- /dev/null +++ b/Tests/CodexBarTests/PersistentRefreshAccessibilityTests.swift @@ -0,0 +1,31 @@ +import AppKit +import Testing +@testable import CodexBar + +@Suite(.serialized) +@MainActor +struct PersistentRefreshAccessibilityTests { + @Test + func `disabled refresh row rejects accessibility press`() { + var pressCount = 0 + let view = PersistentRefreshMenuView( + title: "Refresh", + systemImageName: "arrow.clockwise", + shortcutText: "⌘ R", + onClick: { pressCount += 1 }) + + #expect(view.isAccessibilityEnabled()) + #expect(view.accessibilityPerformPress()) + #expect(pressCount == 1) + + view.setEnabled(false) + #expect(!view.isAccessibilityEnabled()) + #expect(!view.accessibilityPerformPress()) + #expect(pressCount == 1) + + view.setEnabled(true) + #expect(view.isAccessibilityEnabled()) + #expect(view.accessibilityPerformPress()) + #expect(pressCount == 2) + } +} diff --git a/Tests/CodexBarTests/PiSessionCostScannerTests.swift b/Tests/CodexBarTests/PiSessionCostScannerTests.swift index 1a063ce78f..677b807fbd 100644 --- a/Tests/CodexBarTests/PiSessionCostScannerTests.swift +++ b/Tests/CodexBarTests/PiSessionCostScannerTests.swift @@ -91,6 +91,166 @@ struct PiSessionCostScannerTests { #expect(claudeReport.data.first?.modelBreakdowns?.map(\.modelName) == ["claude-sonnet-4-6"]) } + @Test + func `scanner merges omp sessions with pi sessions`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 7, day: 17) + func session(_ id: String) -> [String: Any] { + ["type": "session", "id": id, "timestamp": env.isoString(for: day)] + } + func assistant(input: Int, output: Int) -> [String: Any] { + [ + "type": "message", + "timestamp": env.isoString(for: day), + "message": [ + "role": "assistant", + "api": "openai-codex-responses", + "provider": "openai-codex", + "model": "gpt-5.4", + "usage": [ + "input": input, + "output": output, + "cacheRead": 0, + "cacheWrite": 0, + "totalTokens": input + output, + ], + ], + ] + } + + _ = try env.writePiSessionFile( + relativePath: "2026-07-17T10-00-00-000Z_pi.jsonl", + contents: env.jsonl([session("pi-session"), assistant(input: 10, output: 5)])) + let ompSessionsRoot = env.root.appendingPathComponent("omp-sessions", isDirectory: true) + let ompSession = ompSessionsRoot.appendingPathComponent( + "nested/2026-07-17T11-00-00-000Z_omp.jsonl", + isDirectory: false) + try FileManager.default.createDirectory( + at: ompSession.deletingLastPathComponent(), + withIntermediateDirectories: true) + try env.jsonl([session("omp-session"), assistant(input: 20, output: 10)]) + .write(to: ompSession, atomically: true, encoding: .utf8) + + let report = PiSessionCostScanner.loadDailyReport( + provider: .codex, + since: day, + until: day, + now: day, + options: PiSessionCostScanner.Options( + piSessionsRoot: env.piSessionsRoot, + ompSessionsRoot: ompSessionsRoot, + cacheRoot: env.cacheRoot, + refreshMinIntervalSeconds: 0)) + + #expect(report.data.count == 1) + #expect(report.data.first?.totalTokens == 45) + #expect(report.data.first?.inputTokens == 30) + #expect(report.data.first?.outputTokens == 15) + } + + @Test + func `pi codex cache reads are billed once and use the true context size`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 4, day: 4) + let assistant: [String: Any] = [ + "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": 180_000, + "cacheRead": 60000, + "output": 0, + "totalTokens": 240_000, + ], + ], + ] + + _ = try env.writePiSessionFile( + relativePath: "2026-04-04T10-00-00-000Z_cache-read.jsonl", + contents: env.jsonl([assistant])) + + let report = PiSessionCostScanner.loadDailyReport( + provider: .codex, + since: day, + until: day, + now: day, + options: PiSessionCostScanner.Options( + piSessionsRoot: env.piSessionsRoot, + cacheRoot: env.cacheRoot, + refreshMinIntervalSeconds: 0)) + let expectedCost = CostUsagePricing.codexCostUSD( + model: "gpt-5.4", + inputTokens: 240_000, + cachedInputTokens: 60000, + outputTokens: 0) + + #expect(report.data.count == 1) + #expect(report.data.first?.inputTokens == 180_000) + #expect(report.data.first?.cacheReadTokens == 60000) + #expect(report.data.first?.totalTokens == 240_000) + #expect(abs((report.data.first?.costUSD ?? 0) - (expectedCost ?? 0)) < 0.000001) + } + + @Test + func `pi scanner keeps ambiguous claude errors priced`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 6, day: 9) + let claudeEntry: [String: Any] = [ + "type": "message", + "timestamp": env.isoString(for: day), + "message": [ + "role": "assistant", + "provider": "anthropic", + "model": "claude-fable-5", + "stopReason": "error", + "errorMessage": "An unknown error occurred", + "timestamp": Int(day.timeIntervalSince1970 * 1000), + "usage": [ + "input": 100, + "output": 0, + "cacheRead": 20, + "cacheWrite": 10, + "totalTokens": 130, + ], + ], + ] + + _ = try env.writePiSessionFile( + relativePath: "2026-06-09T10-00-00-000Z_refusal.jsonl", + contents: env.jsonl([claudeEntry])) + + let report = PiSessionCostScanner.loadDailyReport( + provider: .claude, + since: day, + until: day, + now: day, + options: PiSessionCostScanner.Options( + piSessionsRoot: env.piSessionsRoot, + cacheRoot: env.cacheRoot, + refreshMinIntervalSeconds: 0)) + + #expect(report.data.count == 1) + #expect(report.data.first?.totalTokens == 130) + let expectedCost = CostUsagePricing.claudeCostUSD( + model: "claude-fable-5", + inputTokens: 100, + cacheReadInputTokens: 20, + cacheCreationInputTokens: 10, + outputTokens: 0) + + #expect(abs((report.data.first?.costUSD ?? 0) - (expectedCost ?? 0)) < 0.000001) + } + @Test func `pi scanner uses model change fallback and assistant timestamp day`() throws { let env = try CostUsageTestEnvironment() @@ -425,7 +585,7 @@ struct PiSessionCostScannerTests { defer { env.cleanup() } let day = try env.makeLocalNoon(year: 2026, month: 5, day: 9) - let model = "claude-sonnet-4-6" + let model = "claude-sonnet-4-5" let firstAssistant: [String: Any] = [ "type": "message", "timestamp": env.isoString(for: day), @@ -494,75 +654,172 @@ struct PiSessionCostScannerTests { } @Test - func `pi scanner ignores v1 cache missing usage sample counts`() throws { + func `pi scanner ignores v3 cache with stale codex cached input pricing`() throws { let env = try CostUsageTestEnvironment() defer { env.cleanup() } let day = try env.makeLocalNoon(year: 2026, month: 5, day: 10) - let model = "claude-sonnet-4-6" - let firstAssistant: [String: Any] = [ + let model = "gpt-5.4" + let assistant: [String: Any] = [ "type": "message", "timestamp": env.isoString(for: day), "message": [ "role": "assistant", - "provider": "anthropic", + "provider": "openai-codex", "model": model, "timestamp": Int(day.timeIntervalSince1970 * 1000), "usage": [ - "input": 150_000, + "input": 180_000, + "cacheRead": 60000, "output": 0, - "totalTokens": 150_000, + "totalTokens": 240_000, ], ], ] - let secondAssistant: [String: Any] = [ + + let fileURL = try env.writePiSessionFile( + relativePath: "2026-05-10T10-00-00-000Z_cache-read.jsonl", + contents: env.jsonl([assistant])) + let attrs = try FileManager.default.attributesOfItem(atPath: fileURL.path) + let mtime = try #require(attrs[.modificationDate] as? Date) + let size = try #require((attrs[.size] as? NSNumber)?.int64Value) + + let expectedCost = CostUsagePricing.codexCostUSD( + model: model, + inputTokens: 240_000, + cachedInputTokens: 60000, + outputTokens: 0) ?? 0 + let staleCost = CostUsagePricing.codexCostUSD( + model: model, + inputTokens: 180_000, + cachedInputTokens: 60000, + outputTokens: 0, + modelsDevCacheRoot: env.cacheRoot) ?? 0 + let stalePacked = PiPackedUsage( + inputTokens: 180_000, + cacheReadTokens: 60000, + outputTokens: 0, + totalTokens: 240_000, + costNanos: Int64((staleCost * 1_000_000_000).rounded()), + costSampleCount: 1, + usageSampleCount: 1) + let dayKey = "2026-05-10" + let contributions = [ + UsageProvider.codex.rawValue: [ + dayKey: [ + model: stalePacked, + ], + ], + ] + let oldFileUsage = PiSessionFileUsage( + mtimeUnixMs: Int64(mtime.timeIntervalSince1970 * 1000), + size: size, + parsedBytes: size, + lastModelContext: nil, + contributions: contributions) + var oldCache = PiSessionCostCache(version: 3) + oldCache.lastScanUnixMs = Int64(day.timeIntervalSince1970 * 1000) + oldCache.scanSinceKey = dayKey + oldCache.scanUntilKey = dayKey + oldCache.daysByProvider = contributions + oldCache.files = [fileURL.path: oldFileUsage] + let oldCacheURL = env.cacheRoot + .appendingPathComponent("cost-usage", isDirectory: true) + .appendingPathComponent("pi-sessions-v3.json", isDirectory: false) + try FileManager.default.createDirectory( + at: oldCacheURL.deletingLastPathComponent(), + withIntermediateDirectories: true) + try JSONEncoder().encode(oldCache).write(to: oldCacheURL) + + let report = PiSessionCostScanner.loadDailyReport( + provider: .codex, + since: day, + until: day, + now: day, + options: PiSessionCostScanner.Options( + piSessionsRoot: env.piSessionsRoot, + cacheRoot: env.cacheRoot, + refreshMinIntervalSeconds: 3600)) + + #expect(report.data.count == 1) + #expect(report.data.first?.totalTokens == 240_000) + #expect(abs((report.data.first?.costUSD ?? 0) - expectedCost) < 0.000001) + #expect(abs((report.data.first?.costUSD ?? 0) - staleCost) > 0.000001) + + let newCacheURL = PiSessionCostCacheIO.cacheFileURL(cacheRoot: env.cacheRoot) + #expect(FileManager.default.fileExists(atPath: newCacheURL.path)) + let newCache = PiSessionCostCacheIO.load(cacheRoot: env.cacheRoot) + let rebuilt = newCache.daysByProvider[UsageProvider.codex.rawValue]?[dayKey]?[model] + #expect(newCacheURL.lastPathComponent == "pi-sessions-v8.json") + #expect(newCache.version == 8) + #expect(rebuilt?.usageSampleCount == 1) + #expect(rebuilt?.costSampleCount == 1) + #expect(rebuilt?.costNanos == Int64((expectedCost * 1_000_000_000).rounded())) + } + + @Test + func `pi scanner ignores v4 cache with stale gpt56 cache write pricing`() throws { + // v4 stored complete costNanos before cache-write rates existed; v7 must reprice. + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 7, day: 10) + let model = "gpt-5.6-sol" + let assistant: [String: Any] = [ "type": "message", - "timestamp": env.isoString(for: day.addingTimeInterval(1)), + "timestamp": env.isoString(for: day), "message": [ "role": "assistant", - "provider": "anthropic", + "provider": "openai-codex", "model": model, - "timestamp": Int(day.addingTimeInterval(1).timeIntervalSince1970 * 1000), + "timestamp": Int(day.timeIntervalSince1970 * 1000), "usage": [ - "input": 150_000, - "output": 0, - "totalTokens": 150_000, + "input": 70, + "cacheRead": 10, + "cacheWrite": 20, + "output": 5, + "totalTokens": 105, ], ], ] let fileURL = try env.writePiSessionFile( - relativePath: "2026-05-10T10-00-00-000Z_threshold.jsonl", - contents: env.jsonl([firstAssistant, secondAssistant])) + relativePath: "2026-07-10T10-00-00-000Z_cache-write.jsonl", + contents: env.jsonl([assistant])) let attrs = try FileManager.default.attributesOfItem(atPath: fileURL.path) let mtime = try #require(attrs[.modificationDate] as? Date) let size = try #require((attrs[.size] as? NSNumber)?.int64Value) - let requestCost = CostUsagePricing.claudeCostUSD( + let expectedCost = CostUsagePricing.codexCostUSD( model: model, - inputTokens: 150_000, - cacheReadInputTokens: 0, - cacheCreationInputTokens: 0, - outputTokens: 0, + inputTokens: 100, + cachedInputTokens: 10, + outputTokens: 5, + cacheWriteInputTokens: 20, modelsDevCacheRoot: env.cacheRoot) ?? 0 - let aggregateCost = CostUsagePricing.claudeCostUSD( + // Stale: writes folded into uncached input at 1× (pre-v5 behavior). + let staleCost = CostUsagePricing.codexCostUSD( model: model, - inputTokens: 300_000, - cacheReadInputTokens: 0, - cacheCreationInputTokens: 0, - outputTokens: 0, + inputTokens: 100, + cachedInputTokens: 10, + outputTokens: 5, modelsDevCacheRoot: env.cacheRoot) ?? 0 - let aggregatePacked = PiPackedUsage( - inputTokens: 300_000, - totalTokens: 300_000, - costNanos: Int64((aggregateCost * 1_000_000_000).rounded()), - costSampleCount: 2, - usageSampleCount: nil) - let dayKey = "2026-05-10" + #expect(abs(expectedCost - staleCost) > 0.000001) + + let stalePacked = PiPackedUsage( + inputTokens: 70, + cacheReadTokens: 10, + cacheWriteTokens: 20, + outputTokens: 5, + totalTokens: 105, + costNanos: Int64((staleCost * 1_000_000_000).rounded()), + costSampleCount: 1, + usageSampleCount: 1) + let dayKey = "2026-07-10" let contributions = [ - UsageProvider.claude.rawValue: [ + UsageProvider.codex.rawValue: [ dayKey: [ - model: aggregatePacked, + model: stalePacked, ], ], ] @@ -572,7 +829,7 @@ struct PiSessionCostScannerTests { parsedBytes: size, lastModelContext: nil, contributions: contributions) - var oldCache = PiSessionCostCache(version: 1) + var oldCache = PiSessionCostCache(version: 4) oldCache.lastScanUnixMs = Int64(day.timeIntervalSince1970 * 1000) oldCache.scanSinceKey = dayKey oldCache.scanUntilKey = dayKey @@ -580,14 +837,14 @@ struct PiSessionCostScannerTests { oldCache.files = [fileURL.path: oldFileUsage] let oldCacheURL = env.cacheRoot .appendingPathComponent("cost-usage", isDirectory: true) - .appendingPathComponent("pi-sessions-v1.json", isDirectory: false) + .appendingPathComponent("pi-sessions-v4.json", isDirectory: false) try FileManager.default.createDirectory( at: oldCacheURL.deletingLastPathComponent(), withIntermediateDirectories: true) try JSONEncoder().encode(oldCache).write(to: oldCacheURL) let report = PiSessionCostScanner.loadDailyReport( - provider: .claude, + provider: .codex, since: day, until: day, now: day, @@ -596,17 +853,387 @@ struct PiSessionCostScannerTests { cacheRoot: env.cacheRoot, refreshMinIntervalSeconds: 3600)) - let expectedCost = requestCost * 2 #expect(report.data.count == 1) - #expect(report.data.first?.totalTokens == 300_000) #expect(abs((report.data.first?.costUSD ?? 0) - expectedCost) < 0.000001) - #expect(abs((report.data.first?.costUSD ?? 0) - aggregateCost) > 0.000001) + #expect(abs((report.data.first?.costUSD ?? 0) - staleCost) > 0.000001) let newCache = PiSessionCostCacheIO.load(cacheRoot: env.cacheRoot) - let rebuilt = newCache.daysByProvider[UsageProvider.claude.rawValue]?[dayKey]?[model] - #expect(newCache.version == 2) - #expect(rebuilt?.usageSampleCount == 2) - #expect(rebuilt?.costSampleCount == 2) + let rebuilt = newCache.daysByProvider[UsageProvider.codex.rawValue]?[dayKey]?[model] + #expect(newCache.version == 8) + #expect(rebuilt?.costNanos == Int64((expectedCost * 1_000_000_000).rounded())) + } +} + +extension PiSessionCostScannerTests { + @Test + func `scanner counts duplicate pi and omp session ids once`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 7, day: 17) + let session: [String: Any] = [ + "type": "session", + "id": "shared-session", + "timestamp": env.isoString(for: day), + ] + func assistant(id: String, input: Int, output: Int) -> [String: Any] { + [ + "type": "message", + "id": id, + "timestamp": env.isoString(for: day), + "message": [ + "role": "assistant", + "provider": "openai-codex", + "model": "gpt-5.4", + "usage": [ + "input": input, + "output": output, + "totalTokens": input + output, + ], + ], + ] + } + + let initial = try env.jsonl([session, assistant(id: "shared-turn", input: 10, output: 5)]) + let piSession = try env.writePiSessionFile( + relativePath: "2026-07-17T10-00-00-000Z_shared.jsonl", + contents: initial) + let ompSessionsRoot = env.root.appendingPathComponent("omp-sessions", isDirectory: true) + let ompSession = ompSessionsRoot.appendingPathComponent( + "nested/2026-07-17T10-00-00-000Z_shared.jsonl", + isDirectory: false) + try FileManager.default.createDirectory( + at: ompSession.deletingLastPathComponent(), + withIntermediateDirectories: true) + try initial.write(to: ompSession, atomically: true, encoding: .utf8) + + let options = PiSessionCostScanner.Options( + piSessionsRoot: env.piSessionsRoot, + ompSessionsRoot: ompSessionsRoot, + cacheRoot: env.cacheRoot, + refreshMinIntervalSeconds: 0) + let first = PiSessionCostScanner.loadDailyReport( + provider: .codex, + since: day, + until: day, + now: day, + options: options) + #expect(first.data.first?.totalTokens == 15) + + let piHandle = try FileHandle(forWritingTo: piSession) + try piHandle.seekToEnd() + try piHandle.write(contentsOf: Data(env.jsonl([assistant(id: "pi-turn", input: 7, output: 3)]).utf8)) + try piHandle.close() + let ompHandle = try FileHandle(forWritingTo: ompSession) + try ompHandle.seekToEnd() + try ompHandle.write(contentsOf: Data(env.jsonl([assistant(id: "omp-turn", input: 20, output: 10)]).utf8)) + try ompHandle.close() + + let second = PiSessionCostScanner.loadDailyReport( + provider: .codex, + since: day, + until: day, + now: day.addingTimeInterval(1), + options: options) + #expect(second.data.first?.totalTokens == 55) + #expect(second.data.first?.inputTokens == 37) + #expect(second.data.first?.outputTokens == 18) + + let cache = PiSessionCostCacheIO.load(cacheRoot: env.cacheRoot) + #expect(cache.files.values.count == 2) + #expect(cache.files.values.allSatisfy { $0.sessionID == "shared-session" }) + #expect(cache.files.values.flatMap(\.entryUsages.keys).count == 4) + } + + @Test + func `pi scanner reprices unchanged files when catalog rates change`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 7, day: 10) + let model = "gpt-5.6-sol" + func assistant(at timestamp: Date) -> [String: Any] { + [ + "type": "message", + "timestamp": env.isoString(for: timestamp), + "message": [ + "role": "assistant", + "provider": "openai-codex", + "model": model, + "timestamp": Int(timestamp.timeIntervalSince1970 * 1000), + "usage": [ + "input": 150_000, + "output": 0, + "totalTokens": 150_000, + ], + ], + ] + } + _ = try env.writePiSessionFile( + relativePath: "2026-07-10T10-00-00-000Z_catalog-change.jsonl", + contents: env.jsonl([ + assistant(at: day.addingTimeInterval(-1)), + assistant(at: day), + ])) + + let firstCatalog = try Self.modelsDevCatalog(inputCostPerMillion: 4) + #expect(ModelsDevCache.save(catalog: firstCatalog, fetchedAt: day, cacheRoot: env.cacheRoot)) + let options = PiSessionCostScanner.Options( + piSessionsRoot: env.piSessionsRoot, + cacheRoot: env.cacheRoot, + refreshMinIntervalSeconds: 3600) + let firstReport = PiSessionCostScanner.loadDailyReport( + provider: .codex, + since: day, + until: day, + now: day, + options: options) + let firstCache = PiSessionCostCacheIO.load(cacheRoot: env.cacheRoot) + let firstPricingKey = try #require(firstCache.pricingKey) + #expect(firstReport.data.first?.totalTokens == 300_000) + #expect(abs((firstReport.data.first?.costUSD ?? 0) - 1.2) < 0.0000001) + + let secondCatalog = try Self.modelsDevCatalog(inputCostPerMillion: 8) + #expect(ModelsDevCache.save( + catalog: secondCatalog, + fetchedAt: day.addingTimeInterval(1), + cacheRoot: env.cacheRoot)) + #expect(PiSessionCostScanner.loadCachedDailyReport( + provider: .codex, + since: day, + until: day, + now: day.addingTimeInterval(1), + cacheRoot: env.cacheRoot) == nil) + + let secondReport = PiSessionCostScanner.loadDailyReport( + provider: .codex, + since: day, + until: day, + now: day.addingTimeInterval(2), + options: options) + let secondCache = PiSessionCostCacheIO.load(cacheRoot: env.cacheRoot) + #expect(secondCache.pricingKey != firstPricingKey) + // Each 150K message stays below the 272K threshold. The 300K daily aggregate must be the + // sum of two short-context costs, proving the pricing change triggered a full-file reparse. + #expect(secondReport.data.first?.totalTokens == 300_000) + #expect(abs((secondReport.data.first?.costUSD ?? 0) - 2.4) < 0.0000001) + } + + @Test + func `pi scanner reprices unchanged claude files when anthropic rates change`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 7, day: 10) + let model = "claude-fable-5" + func assistant(at timestamp: Date) -> [String: Any] { + [ + "type": "message", + "timestamp": env.isoString(for: timestamp), + "message": [ + "role": "assistant", + "provider": "anthropic", + "model": model, + "timestamp": Int(timestamp.timeIntervalSince1970 * 1000), + "usage": [ + "input": 150_000, + "output": 0, + "totalTokens": 150_000, + ], + ], + ] + } + _ = try env.writePiSessionFile( + relativePath: "2026-07-10T10-00-00-000Z_anthropic-catalog-change.jsonl", + contents: env.jsonl([ + assistant(at: day.addingTimeInterval(-1)), + assistant(at: day), + ])) + + let firstCatalog = try Self.anthropicModelsDevCatalog(inputCostPerMillion: 4) + #expect(ModelsDevCache.save(catalog: firstCatalog, fetchedAt: day, cacheRoot: env.cacheRoot)) + let options = PiSessionCostScanner.Options( + piSessionsRoot: env.piSessionsRoot, + cacheRoot: env.cacheRoot, + refreshMinIntervalSeconds: 3600) + let firstReport = PiSessionCostScanner.loadDailyReport( + provider: .claude, + since: day, + until: day, + now: day, + options: options) + let firstCache = PiSessionCostCacheIO.load(cacheRoot: env.cacheRoot) + let firstPricingKey = try #require(firstCache.pricingKey) + #expect(firstReport.data.first?.totalTokens == 300_000) + #expect(abs((firstReport.data.first?.costUSD ?? 0) - 1.2) < 0.0000001) + + let secondCatalog = try Self.anthropicModelsDevCatalog(inputCostPerMillion: 8) + #expect(ModelsDevCache.save( + catalog: secondCatalog, + fetchedAt: day.addingTimeInterval(1), + cacheRoot: env.cacheRoot)) + #expect(PiSessionCostScanner.loadCachedDailyReport( + provider: .claude, + since: day, + until: day, + now: day.addingTimeInterval(1), + cacheRoot: env.cacheRoot) == nil) + + let secondReport = PiSessionCostScanner.loadDailyReport( + provider: .claude, + since: day, + until: day, + now: day.addingTimeInterval(2), + options: options) + let secondCache = PiSessionCostCacheIO.load(cacheRoot: env.cacheRoot) + #expect(secondCache.pricingKey != firstPricingKey) + #expect(secondReport.data.first?.totalTokens == 300_000) + #expect(abs((secondReport.data.first?.costUSD ?? 0) - 2.4) < 0.0000001) + } + + @Test + func `pi pricing key ignores catalog fetch time when rates are unchanged`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 7, day: 10) + let catalog = try Self.modelsDevCatalog(inputCostPerMillion: 4) + #expect(ModelsDevCache.save(catalog: catalog, fetchedAt: day, cacheRoot: env.cacheRoot)) + let assistant: [String: Any] = [ + "type": "message", + "timestamp": env.isoString(for: day), + "message": [ + "role": "assistant", + "provider": "openai-codex", + "model": "gpt-5.6-sol", + "timestamp": Int(day.timeIntervalSince1970 * 1000), + "usage": ["input": 100, "output": 0, "totalTokens": 100], + ], + ] + _ = try env.writePiSessionFile( + relativePath: "2026-07-10T10-00-00-000Z_catalog-fetch-time.jsonl", + contents: env.jsonl([assistant])) + let options = PiSessionCostScanner.Options( + piSessionsRoot: env.piSessionsRoot, + cacheRoot: env.cacheRoot, + refreshMinIntervalSeconds: 3600) + _ = PiSessionCostScanner.loadDailyReport( + provider: .codex, + since: day, + until: day, + now: day, + options: options) + let firstCache = PiSessionCostCacheIO.load(cacheRoot: env.cacheRoot) + + #expect(ModelsDevCache.save( + catalog: catalog, + fetchedAt: day.addingTimeInterval(1), + cacheRoot: env.cacheRoot)) + _ = PiSessionCostScanner.loadDailyReport( + provider: .codex, + since: day, + until: day, + now: day.addingTimeInterval(2), + options: options) + let secondCache = PiSessionCostCacheIO.load(cacheRoot: env.cacheRoot) + + #expect(secondCache.pricingKey == firstCache.pricingKey) + #expect(secondCache.lastScanUnixMs == firstCache.lastScanUnixMs) + } + + @Test + func `pi pricing key ignores unrelated providers and non pricing context metadata`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 7, day: 10) + let firstCatalog = try Self.modelsDevCatalog(""" + { + "openai": { + "id": "openai", + "models": { + "gpt-5.6-sol": { + "id": "gpt-5.6-sol", + "limit": { "context": 1000000 }, + "cost": { "input": 4, "output": 30 } + } + } + }, + "google": { + "id": "google", + "models": { + "gemini-test": { + "id": "gemini-test", + "cost": { "input": 1, "output": 2 } + } + } + } + } + """) + #expect(ModelsDevCache.save(catalog: firstCatalog, fetchedAt: day, cacheRoot: env.cacheRoot)) + let assistant: [String: Any] = [ + "type": "message", + "timestamp": env.isoString(for: day), + "message": [ + "role": "assistant", + "provider": "openai-codex", + "model": "gpt-5.6-sol", + "timestamp": Int(day.timeIntervalSince1970 * 1000), + "usage": ["input": 100, "output": 0, "totalTokens": 100], + ], + ] + _ = try env.writePiSessionFile( + relativePath: "2026-07-10T10-00-00-000Z_catalog-metadata.jsonl", + contents: env.jsonl([assistant])) + let options = PiSessionCostScanner.Options( + piSessionsRoot: env.piSessionsRoot, + cacheRoot: env.cacheRoot, + refreshMinIntervalSeconds: 3600) + _ = PiSessionCostScanner.loadDailyReport( + provider: .codex, + since: day, + until: day, + now: day, + options: options) + let firstCache = PiSessionCostCacheIO.load(cacheRoot: env.cacheRoot) + + let metadataOnlyChange = try Self.modelsDevCatalog(""" + { + "openai": { + "id": "openai", + "models": { + "gpt-5.6-sol": { + "id": "gpt-5.6-sol", + "limit": { "context": 2000000 }, + "cost": { "input": 4, "output": 30 } + } + } + }, + "google": { + "id": "google", + "models": { + "gemini-test": { + "id": "gemini-test", + "cost": { "input": 99, "output": 199 } + } + } + } + } + """) + #expect(ModelsDevCache.save( + catalog: metadataOnlyChange, + fetchedAt: day.addingTimeInterval(1), + cacheRoot: env.cacheRoot)) + _ = PiSessionCostScanner.loadDailyReport( + provider: .codex, + since: day, + until: day, + now: day.addingTimeInterval(2), + options: options) + let secondCache = PiSessionCostCacheIO.load(cacheRoot: env.cacheRoot) + + #expect(secondCache.pricingKey == firstCache.pricingKey) + #expect(secondCache.lastScanUnixMs == firstCache.lastScanUnixMs) } @Test @@ -673,4 +1300,52 @@ struct PiSessionCostScannerTests { #expect(expandedReport.data.map(\.date) == ["2026-04-02", "2026-04-08"]) #expect(expandedReport.summary?.totalTokens == 45) } + + private static func modelsDevCatalog(inputCostPerMillion: Double) throws -> ModelsDevCatalog { + let json = """ + { + "openai": { + "id": "openai", + "models": { + "gpt-5.6-sol": { + "id": "gpt-5.6-sol", + "cost": { + "input": \(inputCostPerMillion), + "output": 30, + "cache_read": 0.5, + "cache_write": 6.25 + } + } + } + } + } + """ + return try self.modelsDevCatalog(json) + } + + private static func anthropicModelsDevCatalog(inputCostPerMillion: Double) throws -> ModelsDevCatalog { + let json = """ + { + "anthropic": { + "id": "anthropic", + "models": { + "claude-fable-5": { + "id": "claude-fable-5", + "cost": { + "input": \(inputCostPerMillion), + "output": 15, + "cache_read": 0.3, + "cache_write": 3.75 + } + } + } + } + } + """ + return try self.modelsDevCatalog(json) + } + + private static func modelsDevCatalog(_ json: String) throws -> ModelsDevCatalog { + try JSONDecoder().decode(ModelsDevCatalog.self, from: Data(json.utf8)) + } } diff --git a/Tests/CodexBarTests/PlanUtilizationHistoryChartMenuViewTests.swift b/Tests/CodexBarTests/PlanUtilizationHistoryChartMenuViewTests.swift new file mode 100644 index 0000000000..dd86189a39 --- /dev/null +++ b/Tests/CodexBarTests/PlanUtilizationHistoryChartMenuViewTests.swift @@ -0,0 +1,88 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +struct PlanUtilizationHistoryChartMenuViewTests { + @Test + func `merged entries preserve first occurrence order while removing duplicates`() { + let first = PlanUtilizationHistoryEntry( + capturedAt: Date(timeIntervalSince1970: 100), + usedPercent: 10, + resetsAt: Date(timeIntervalSince1970: 200)) + let second = PlanUtilizationHistoryEntry( + capturedAt: Date(timeIntervalSince1970: 300), + usedPercent: 20, + resetsAt: nil) + + let merged = PlanUtilizationHistoryChartMenuView.mergedEntries([ + first, + second, + first, + second, + ]) + + #expect(merged == [first, second]) + } + + @Test + func `generic primary weekly window keeps weekly history visible`() { + let history = PlanUtilizationSeriesHistory( + name: .weekly, + windowMinutes: 10080, + entries: [ + PlanUtilizationHistoryEntry( + capturedAt: Date(timeIntervalSince1970: 1_700_000_000), + usedPercent: 42, + resetsAt: nil), + ]) + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 42, windowMinutes: 10080, resetsAt: nil, resetDescription: nil), + secondary: nil, + updatedAt: Date(timeIntervalSince1970: 1_700_000_000)) + + let model = PlanUtilizationHistoryChartMenuView._modelSnapshotForTesting( + histories: [history], + provider: .zai, + snapshot: snapshot) + + #expect(model.visibleSeries == ["weekly:10080"]) + #expect(model.selectedSeries == "weekly:10080") + } + + @Test + func `generic unknown weekly extra window does not filter saved history`() { + let history = PlanUtilizationSeriesHistory( + name: .weekly, + windowMinutes: 10080, + entries: [ + PlanUtilizationHistoryEntry( + capturedAt: Date(timeIntervalSince1970: 1_700_000_000), + usedPercent: 42, + resetsAt: nil), + ]) + let snapshot = UsageSnapshot( + primary: nil, + secondary: nil, + extraRateWindows: [ + NamedRateWindow( + id: "weekly-reset-only", + title: "Weekly reset", + window: RateWindow( + usedPercent: 0, + windowMinutes: 10080, + resetsAt: Date(timeIntervalSince1970: 1_700_003_600), + resetDescription: nil), + usageKnown: false), + ], + updatedAt: Date(timeIntervalSince1970: 1_700_000_000)) + + let model = PlanUtilizationHistoryChartMenuView._modelSnapshotForTesting( + histories: [history], + provider: .zed, + snapshot: snapshot) + + #expect(model.visibleSeries == ["weekly:10080"]) + #expect(model.selectedSeries == "weekly:10080") + } +} diff --git a/Tests/CodexBarTests/PoeCurrentDayPresentationTests.swift b/Tests/CodexBarTests/PoeCurrentDayPresentationTests.swift new file mode 100644 index 0000000000..33ae1c108a --- /dev/null +++ b/Tests/CodexBarTests/PoeCurrentDayPresentationTests.swift @@ -0,0 +1,36 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +struct PoeCurrentDayPresentationTests { + @Test + func `Poe notes and dashboard do not label stale usage as Today`() throws { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = try #require(TimeZone(identifier: "Europe/London")) + let now = try #require(ISO8601DateFormatter().date(from: "2026-06-23T12:00:00Z")) + let yesterday = try #require(ISO8601DateFormatter().date(from: "2026-06-22T12:00:00Z")) + let usage = PoeUsageHistorySnapshot( + entries: [ + .init( + id: "stale", + createdAt: yesterday, + model: "GPT-4o", + usageType: "chat", + points: 100, + costUSD: 0.10), + ], + daily: [ + .init(day: "2026-06-22", points: 100, requests: 1, costUSD: 0.10), + ], + updatedAt: now) + + let notes = UsageMenuCardView.Model.poeUsageNotes(usage, now: now, calendar: calendar) + let dashboard = UsageMenuCardView.Model.poeInlineDashboard(usage, now: now, calendar: calendar) + + #expect(notes.first == "Today: 0 points · 0 requests") + #expect(dashboard.kpis.first?.title == "Today") + #expect(dashboard.kpis.first?.value == "0 points") + #expect(!dashboard.detailLines.contains(where: { $0.hasPrefix("Today USD:") })) + } +} diff --git a/Tests/CodexBarTests/PoeProviderDescriptorTests.swift b/Tests/CodexBarTests/PoeProviderDescriptorTests.swift new file mode 100644 index 0000000000..7d7e629a6a --- /dev/null +++ b/Tests/CodexBarTests/PoeProviderDescriptorTests.swift @@ -0,0 +1,12 @@ +import CodexBarCore +import Testing + +struct PoeProviderDescriptorTests { + @Test + func `Poe uses the official brand color and icon`() { + let branding = PoeProviderDescriptor.descriptor.branding + + #expect(branding.iconResourceName == "ProviderIcon-poe") + #expect(branding.color == ProviderColor(red: 93 / 255, green: 92 / 255, blue: 222 / 255)) + } +} diff --git a/Tests/CodexBarTests/PoeSettingsReaderTests.swift b/Tests/CodexBarTests/PoeSettingsReaderTests.swift new file mode 100644 index 0000000000..efc3f8095e --- /dev/null +++ b/Tests/CodexBarTests/PoeSettingsReaderTests.swift @@ -0,0 +1,11 @@ +import CodexBarCore +import Foundation +import Testing + +struct PoeSettingsReaderTests { + @Test + func `api key trims quotes`() { + let env = [PoeSettingsReader.apiKeyEnvironmentKey: " 'poe-key' "] + #expect(PoeSettingsReader.apiKey(environment: env) == "poe-key") + } +} diff --git a/Tests/CodexBarTests/PoeUsageFetcherTests.swift b/Tests/CodexBarTests/PoeUsageFetcherTests.swift new file mode 100644 index 0000000000..840541d95b --- /dev/null +++ b/Tests/CodexBarTests/PoeUsageFetcherTests.swift @@ -0,0 +1,251 @@ +import Foundation +import Testing +@testable import CodexBarCore + +@Suite(.serialized) +struct PoeUsageFetcherTests { + @Test + func `parse snapshot extracts current point balance`() throws { + let json = #"{"current_point_balance": 1500}"# + let data = Data(json.utf8) + let snapshot = try PoeUsageFetcher._parseSnapshotForTesting(data) + #expect(snapshot.currentPointBalance == 1500) + } + + @Test + func `parse snapshot accepts string-encoded balance`() throws { + let json = #"{"current_point_balance": "2500"}"# + let snapshot = try PoeUsageFetcher._parseSnapshotForTesting(Data(json.utf8)) + #expect(snapshot.currentPointBalance == 2500) + } + + @Test + func `parse snapshot returns nil balance when absent`() throws { + let json = #"{}"# + let snapshot = try PoeUsageFetcher._parseSnapshotForTesting(Data(json.utf8)) + #expect(snapshot.currentPointBalance == nil) + } + + @Test + func `parse snapshot throws on malformed JSON`() { + #expect { + _ = try PoeUsageFetcher._parseSnapshotForTesting(Data("not-json".utf8)) + } throws: { error in + guard case PoeUsageError.parseFailed = error else { return false } + return true + } + } + + @Test + func `snapshot maps balance to identity loginMethod only, not RateWindow`() { + let snapshot = PoeUsageSnapshot( + currentPointBalance: 500, + updatedAt: Date()) + + let unified = snapshot.toUsageSnapshot() + // No rate windows — balance is not a usage percentage + #expect(unified.primary == nil) + #expect(unified.secondary == nil) + #expect(unified.tertiary == nil) + // Balance lives in identity.loginMethod as "Balance: X points" + #expect(unified.identity?.providerID == .poe) + #expect(unified.identity?.loginMethod == "Balance: 500 points") + } + + @Test + func `snapshot hides balance when balance is absent`() { + let snapshot = PoeUsageSnapshot( + currentPointBalance: nil, + updatedAt: Date()) + + let unified = snapshot.toUsageSnapshot() + #expect(unified.primary == nil) + #expect(unified.identity?.loginMethod == nil) + } + + @Test + func `missing credentials fetch call throws missing credentials`() async { + do { + _ = try await PoeUsageFetcher.fetchUsage(apiKey: " ") + Issue.record("Expected missingCredentials error") + } catch let error as PoeUsageError { + guard case .missingCredentials = error else { + Issue.record("Expected .missingCredentials but got \(error)") + return + } + } catch { + Issue.record("Unexpected error: \(error)") + } + } + + @Test + func `compact number formats thousands with no decimals`() { + #expect(PoeUsageSnapshot.compactNumber(1500) == "1,500") + #expect(PoeUsageSnapshot.compactNumber(999) == "999") + #expect(PoeUsageSnapshot.compactNumber(10000) == "10,000") + } + + @Test + func `history page parser extracts entries and cursor`() throws { + let json = """ + { + "data": [ + { + "query_id": "a1", + "creation_time": 1717000000000000, + "bot_name": "GPT-4o", + "usage_type": "API", + "cost_points": 12.5, + "cost_usd": "0.03" + }, + { + "query_id": "a2", + "creation_time": 1717003600, + "bot_name": "Claude Sonnet", + "usage_type": "Chat", + "cost_points": "8", + "usd": "0.02" + } + ], + "next_cursor": "cursor-2" + } + """ + + let parsed = try PoeUsageFetcher._parseHistoryPageForTesting(Data(json.utf8)) + #expect(parsed.entries.count == 2) + #expect(parsed.entries[0].model == "GPT-4o") + #expect(parsed.entries[0].points == 12.5) + #expect(parsed.entries[0].id == "a1") + #expect(parsed.entries[1].costUSD == 0.02) + #expect(parsed.nextCursor == "cursor-2") + } + + @Test + func `history parser derives cursor from has_more and last query id`() throws { + let json = """ + { + "has_more": true, + "data": [ + { + "query_id": "q-1", + "creation_time": 1717000000, + "bot_name": "GPT-4o", + "usage_type": "API", + "cost_points": 3 + }, + { + "query_id": "q-2", + "creation_time": 1717003600, + "bot_name": "Claude Sonnet", + "usage_type": "Chat", + "cost_points": 9 + } + ] + } + """ + + let parsed = try PoeUsageFetcher._parseHistoryPageForTesting(Data(json.utf8)) + #expect(parsed.entries.count == 2) + #expect(parsed.nextCursor == "q-2") + } + + @Test + func `history daily aggregation groups by utc day`() { + let entries = [ + PoeUsageHistorySnapshot.Entry( + id: "1", + createdAt: Date(timeIntervalSince1970: 1_717_000_000), + model: "GPT-4o", + usageType: "inference", + points: 10, + costUSD: 0.02), + PoeUsageHistorySnapshot.Entry( + id: "2", + createdAt: Date(timeIntervalSince1970: 1_717_000_100), + model: "GPT-4o", + usageType: "inference", + points: 5, + costUSD: 0.01), + ] + + let daily = PoeUsageFetcher._buildDailyBucketsForTesting(entries: entries) + #expect(daily.count == 1) + #expect(daily[0].requests == 2) + #expect(daily[0].points == 15) + #expect(daily[0].costUSD == 0.03) + } + + @Test + func `fetch usage returns balance when points history endpoint fails`() async throws { + let transport = ProviderHTTPTransportStub { request in + let url = try #require(request.url) + let path = url.path + let response: (Data, Int) + if path.contains("current_balance") { + response = (Data(#"{"current_point_balance": 1500}"#.utf8), 200) + } else if path.contains("points_history") { + // Simulate a 500 from the optional history endpoint. + response = (Data("server error".utf8), 500) + } else { + Issue.record("Unexpected request: \(url.absoluteString)") + return Self.httpResponse(url: nil, status: 0) + } + return Self.httpResponse(url: nil, status: response.1, body: response.0) + } + + let snapshot = try await PoeUsageFetcher._fetchUsage( + apiKey: "test-key", + transport: transport) + + #expect(snapshot.currentPointBalance == 1500) + // History should be nil, not propagate the failure. + #expect(snapshot.history == nil) + } + + @Test + func `fetch usage surfaces history snapshot when history endpoint succeeds`() async throws { + let transport = ProviderHTTPTransportStub { request in + let url = try #require(request.url) + let path = url.path + if path.contains("current_balance") { + return Self.httpResponse( + url: nil, + status: 200, + body: Data(#"{"current_point_balance": 2500}"#.utf8)) + } + if path.contains("points_history") { + return Self.httpResponse( + url: nil, + status: 200, + body: Data(""" + {"data":[],"next_cursor":null} + """.utf8)) + } + Issue.record("Unexpected request: \(url.absoluteString)") + return Self.httpResponse(url: nil, status: 0) + } + + let snapshot = try await PoeUsageFetcher._fetchUsage( + apiKey: "test-key", + transport: transport) + + #expect(snapshot.currentPointBalance == 2500) + // Empty history page still produces a non-nil snapshot with empty buckets. + #expect(snapshot.history == nil) + } +} + +extension PoeUsageFetcherTests { + fileprivate static func httpResponse( + url: URL?, + status: Int, + body: Data = Data()) -> (Data, URLResponse) + { + let response = HTTPURLResponse( + url: url ?? URL(string: "https://example.invalid")!, + statusCode: status, + httpVersion: "HTTP/1.1", + headerFields: nil) ?? HTTPURLResponse() + return (body, response) + } +} diff --git a/Tests/CodexBarTests/PoeUsageHistorySnapshotTests.swift b/Tests/CodexBarTests/PoeUsageHistorySnapshotTests.swift new file mode 100644 index 0000000000..b7a0f70d2d --- /dev/null +++ b/Tests/CodexBarTests/PoeUsageHistorySnapshotTests.swift @@ -0,0 +1,486 @@ +import CodexBarCore +import Foundation +import Testing + +struct PoeUsageHistorySnapshotTests { + // MARK: - summary(days:) + + @Test + func `summary over empty daily returns zeroed summary with nil cost`() { + let snapshot = PoeUsageHistorySnapshot( + entries: [], + daily: [], + updatedAt: Date()) + + let summary = snapshot.summary(days: 7) + + #expect(summary.points == 0) + #expect(summary.requests == 0) + #expect(summary.costUSD == nil) + } + + @Test + func `summary over single day reports that day's points and requests`() { + let snapshot = PoeUsageHistorySnapshot( + entries: [], + daily: [PoeUsageHistorySnapshot.DailyBucket( + day: "2026-05-31", + points: 250, + requests: 3, + costUSD: 0.05)], + updatedAt: Date()) + + let summary = snapshot.summary(days: 1) + + #expect(summary.points == 250) + #expect(summary.requests == 3) + #expect(summary.costUSD == 0.05) + } + + @Test + func `summary over seven days uses the last seven daily buckets`() { + let daily: [PoeUsageHistorySnapshot.DailyBucket] = (1...10).map { offset in + PoeUsageHistorySnapshot.DailyBucket( + day: String(format: "2026-05-%02d", offset), + points: Double(offset * 10), + requests: offset, + costUSD: nil) + } + let snapshot = PoeUsageHistorySnapshot( + entries: [], + daily: daily, + updatedAt: Date()) + + // Last 7 buckets: offsets 4..10 → 40+50+60+70+80+90+100 = 490 + let summary = snapshot.summary(days: 7) + + #expect(summary.points == 490) + #expect(summary.requests == 4 + 5 + 6 + 7 + 8 + 9 + 10) + #expect(summary.costUSD == nil) + } + + @Test + func `summary clamps zero and negative day counts up to one`() { + let snapshot = PoeUsageHistorySnapshot( + entries: [], + daily: [ + PoeUsageHistorySnapshot.DailyBucket(day: "2026-05-29", points: 100, requests: 1, costUSD: nil), + PoeUsageHistorySnapshot.DailyBucket(day: "2026-05-30", points: 200, requests: 2, costUSD: nil), + PoeUsageHistorySnapshot.DailyBucket(day: "2026-05-31", points: 300, requests: 3, costUSD: nil), + ], + updatedAt: Date()) + + #expect(snapshot.summary(days: 0).points == 300) // last bucket only + #expect(snapshot.summary(days: 0).requests == 3) + #expect(snapshot.summary(days: -5).points == 300) // clamped up to 1 + } + + @Test + func `summary ignores daily buckets beyond the requested window`() { + let snapshot = PoeUsageHistorySnapshot( + entries: [], + daily: (1...30).map { offset in + PoeUsageHistorySnapshot.DailyBucket( + day: String(format: "2026-04-%02d", offset), + points: 1, + requests: 1, + costUSD: nil) + }, + updatedAt: Date()) + + let last30 = snapshot.summary(days: 30) + let last7 = snapshot.summary(days: 7) + + #expect(last30.points == 30) + #expect(last7.points == 7) + } + + @Test + func `summary reports nil cost when every daily bucket has nil cost`() { + let snapshot = PoeUsageHistorySnapshot( + entries: [], + daily: (1...3).map { offset in + PoeUsageHistorySnapshot.DailyBucket( + day: "2026-05-\(28 + offset)", + points: 50, + requests: 1, + costUSD: nil) + }, + updatedAt: Date()) + + #expect(snapshot.summary(days: 7).costUSD == nil) + } + + @Test + func `summary sums only the non-nil cost buckets and keeps the rest invisible`() throws { + let snapshot = PoeUsageHistorySnapshot( + entries: [], + daily: [ + PoeUsageHistorySnapshot.DailyBucket(day: "2026-05-29", points: 100, requests: 1, costUSD: nil), + PoeUsageHistorySnapshot.DailyBucket(day: "2026-05-30", points: 200, requests: 1, costUSD: 0.10), + PoeUsageHistorySnapshot.DailyBucket(day: "2026-05-31", points: 300, requests: 1, costUSD: 0.20), + ], + updatedAt: Date()) + + // Skips the nil bucket, sums 0.10 + 0.20 (allow IEEE-754 round-trip) + let cost = try #require(snapshot.summary(days: 7).costUSD) + #expect(abs(cost - 0.30) < 1e-9) + } + + // MARK: - latestDay / last7Days / last30Days shortcuts + + @Test + func `latest day, last 7 and last 30 days agree with summary by day count`() { + let daily: [PoeUsageHistorySnapshot.DailyBucket] = (1...40).map { offset in + PoeUsageHistorySnapshot.DailyBucket( + day: String(format: "2026-04-%02d", offset), + points: Double(offset), + requests: 1, + costUSD: nil) + } + let snapshot = PoeUsageHistorySnapshot( + entries: [], + daily: daily, + updatedAt: Date()) + + #expect(snapshot.latestDay == snapshot.summary(days: 1)) + #expect(snapshot.last7Days == snapshot.summary(days: 7)) + #expect(snapshot.last30Days == snapshot.summary(days: 30)) + } + + @Test + func `current day does not reuse a stale latest bucket`() throws { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = try #require(TimeZone(identifier: "Europe/London")) + let now = try #require(ISO8601DateFormatter().date(from: "2026-06-23T12:00:00Z")) + let yesterday = try #require(ISO8601DateFormatter().date(from: "2026-06-22T12:00:00Z")) + let snapshot = PoeUsageHistorySnapshot( + entries: [ + self.makeEntry( + id: "stale", + createdAt: yesterday, + model: "GPT-4o", + usageType: "chat", + points: 100, + costUSD: 0.10), + ], + daily: [ + PoeUsageHistorySnapshot.DailyBucket( + day: "2026-06-22", + points: 100, + requests: 1, + costUSD: 0.10), + ], + updatedAt: now) + + #expect(snapshot.latestDay.points == 100) + #expect(snapshot.currentDay(now: now, calendar: calendar).points == 0) + #expect(snapshot.currentDay(now: now, calendar: calendar).requests == 0) + #expect(snapshot.currentDay(now: now, calendar: calendar).costUSD == nil) + } + + @Test + func `current day filters raw entries across a UTC bucket boundary`() throws { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = try #require(TimeZone(identifier: "America/Los_Angeles")) + let now = try #require(ISO8601DateFormatter().date(from: "2026-06-23T01:00:00Z")) + let localToday = try #require(ISO8601DateFormatter().date(from: "2026-06-22T20:00:00Z")) + let localYesterday = try #require(ISO8601DateFormatter().date(from: "2026-06-22T06:00:00Z")) + let snapshot = PoeUsageHistorySnapshot( + entries: [ + self.makeEntry( + id: "today", + createdAt: localToday, + model: "GPT-4o", + usageType: "chat", + points: 80, + costUSD: 0.08), + self.makeEntry( + id: "yesterday", + createdAt: localYesterday, + model: "Claude", + usageType: "chat", + points: 20, + costUSD: 0.02), + ], + daily: [ + PoeUsageHistorySnapshot.DailyBucket( + day: "2026-06-22", + points: 100, + requests: 2, + costUSD: 0.10), + ], + updatedAt: now) + + let current = snapshot.currentDay(now: now, calendar: calendar) + #expect(current.points == 80) + #expect(current.requests == 1) + #expect(current.costUSD == 0.08) + } + + // MARK: - topModels / topModel + + @Test + func `top models is empty when entries is empty`() { + let snapshot = PoeUsageHistorySnapshot( + entries: [], + daily: [], + updatedAt: Date()) + + #expect(snapshot.topModels.isEmpty) + #expect(snapshot.topModel == nil) + } + + @Test + func `top models groups by model and sums points and requests`() { + let entries = [ + self.makeEntry(id: "1", model: "GPT-4o", usageType: "chat", points: 10, costUSD: 0.01), + self.makeEntry(id: "2", model: "GPT-4o", usageType: "chat", points: 5, costUSD: 0.01), + self.makeEntry(id: "3", model: "Claude-3.7", usageType: "chat", points: 20, costUSD: 0.02), + ] + let snapshot = PoeUsageHistorySnapshot( + entries: entries, + daily: [], + updatedAt: Date()) + + let top = snapshot.topModels + + #expect(top.count == 2) + #expect(top[0].name == "Claude-3.7") + #expect(top[0].points == 20) + #expect(top[0].requests == 1) + #expect(top[1].name == "GPT-4o") + #expect(top[1].points == 15) + #expect(top[1].requests == 2) + } + + @Test + func `top models breaks ties by name ascending`() { + let entries = [ + self.makeEntry(id: "1", model: "Z-Model", usageType: "chat", points: 10, costUSD: nil), + self.makeEntry(id: "2", model: "A-Model", usageType: "chat", points: 10, costUSD: nil), + self.makeEntry(id: "3", model: "M-Model", usageType: "chat", points: 10, costUSD: nil), + ] + let snapshot = PoeUsageHistorySnapshot( + entries: entries, + daily: [], + updatedAt: Date()) + + let top = snapshot.topModels + #expect(top.map(\.name) == ["A-Model", "M-Model", "Z-Model"]) + } + + @Test + func `top models falls back to unknown for empty or whitespace model strings`() { + let entries = [ + self.makeEntry(id: "1", model: "GPT-4o", usageType: "chat", points: 5, costUSD: nil), + self.makeEntry(id: "2", model: "", usageType: "chat", points: 5, costUSD: nil), + self.makeEntry(id: "3", model: " ", usageType: "chat", points: 5, costUSD: nil), + ] + let snapshot = PoeUsageHistorySnapshot( + entries: entries, + daily: [], + updatedAt: Date()) + + let top = snapshot.topModels + let names = top.map(\.name) + #expect(names.contains("unknown")) + #expect(names.contains("GPT-4o")) + } + + @Test + func `top models omits cost when no entry reported cost`() { + let entries = [ + self.makeEntry(id: "1", model: "GPT-4o", usageType: "chat", points: 5, costUSD: nil), + ] + let snapshot = PoeUsageHistorySnapshot( + entries: entries, + daily: [], + updatedAt: Date()) + + #expect(snapshot.topModels.first?.costUSD == nil) + } + + @Test + func `top models sums cost across entries for the same model`() { + let entries = [ + self.makeEntry(id: "1", model: "GPT-4o", usageType: "chat", points: 5, costUSD: 0.01), + self.makeEntry(id: "2", model: "GPT-4o", usageType: "chat", points: 5, costUSD: 0.02), + ] + let snapshot = PoeUsageHistorySnapshot( + entries: entries, + daily: [], + updatedAt: Date()) + + #expect(snapshot.topModels.first?.costUSD == 0.03) + } + + // MARK: - topUsageTypes / topUsageType + + @Test + func `top usage types groups by usage type independent of model`() { + let entries = [ + self.makeEntry(id: "1", model: "GPT-4o", usageType: "chat", points: 5, costUSD: nil), + self.makeEntry(id: "2", model: "Claude", usageType: "chat", points: 10, costUSD: nil), + self.makeEntry(id: "3", model: "GPT-4o", usageType: "api", points: 8, costUSD: nil), + ] + let snapshot = PoeUsageHistorySnapshot( + entries: entries, + daily: [], + updatedAt: Date()) + + let top = snapshot.topUsageTypes + #expect(top.map(\.name) == ["chat", "api"]) + #expect(top[0].points == 15) + #expect(top[0].requests == 2) + } + + @Test + func `top usage type is the first entry in top usage types`() { + let entries = [ + self.makeEntry(id: "1", model: "GPT-4o", usageType: "chat", points: 5, costUSD: nil), + self.makeEntry(id: "2", model: "GPT-4o", usageType: "api", points: 10, costUSD: nil), + ] + let snapshot = PoeUsageHistorySnapshot( + entries: entries, + daily: [], + updatedAt: Date()) + + #expect(snapshot.topUsageType == "api") + #expect(snapshot.topUsageType == snapshot.topUsageTypes.first?.name) + } + + @Test + func `top usage type is nil for empty entries`() { + let snapshot = PoeUsageHistorySnapshot( + entries: [], + daily: [], + updatedAt: Date()) + + #expect(snapshot.topUsageType == nil) + } + + // MARK: - recentEntries(limit:) + + @Test + func `recent entries returns up to the requested limit, newest first`() { + let now = Date(timeIntervalSince1970: 1_717_000_000) + let entries = (0..<5).map { offset in + self.makeEntry( + id: "\(offset)", + createdAt: now.addingTimeInterval(TimeInterval(offset * 60)), + model: "GPT-4o", + usageType: "chat", + points: 1, + costUSD: nil) + } + // entries are passed in order they came back; init should sort + let snapshot = PoeUsageHistorySnapshot( + entries: entries, + daily: [], + updatedAt: now) + + let recent = snapshot.recentEntries(limit: 3) + + #expect(recent.count == 3) + // Newest three (offsets 4, 3, 2) should be first + #expect(recent[0].id == "4") + #expect(recent[1].id == "3") + #expect(recent[2].id == "2") + } + + @Test + func `recent entries clamps non-positive limit up to one`() { + let now = Date(timeIntervalSince1970: 1_717_000_000) + let entries = (0..<3).map { offset in + self.makeEntry( + id: "\(offset)", + createdAt: now.addingTimeInterval(TimeInterval(offset * 60)), + model: "GPT-4o", + usageType: "chat", + points: 1, + costUSD: nil) + } + let snapshot = PoeUsageHistorySnapshot( + entries: entries, + daily: [], + updatedAt: now) + + #expect(snapshot.recentEntries(limit: 0).count == 1) + #expect(snapshot.recentEntries(limit: -3).count == 1) + #expect(snapshot.recentEntries(limit: 0).first?.id == "2") + } + + @Test + func `recent entries returns everything when limit exceeds entries count`() { + let now = Date(timeIntervalSince1970: 1_717_000_000) + let entries = [ + self.makeEntry(id: "1", createdAt: now, model: "A", usageType: "t", points: 1, costUSD: nil), + self.makeEntry( + id: "2", + createdAt: now.addingTimeInterval(60), + model: "A", + usageType: "t", + points: 1, + costUSD: nil), + ] + let snapshot = PoeUsageHistorySnapshot( + entries: entries, + daily: [], + updatedAt: now) + + let recent = snapshot.recentEntries(limit: 10) + #expect(recent.count == 2) + } + + // MARK: - Init sorting invariants + + @Test + func `init sorts entries ascending by created at and daily ascending by day string`() { + let now = Date(timeIntervalSince1970: 1_717_000_000) + let entries = [ + self.makeEntry( + id: "newer", + createdAt: now.addingTimeInterval(120), + model: "A", + usageType: "t", + points: 1, + costUSD: nil), + self.makeEntry(id: "older", createdAt: now, model: "A", usageType: "t", points: 1, costUSD: nil), + ] + let daily = [ + PoeUsageHistorySnapshot.DailyBucket(day: "2026-05-31", points: 1, requests: 1, costUSD: nil), + PoeUsageHistorySnapshot.DailyBucket(day: "2026-05-30", points: 1, requests: 1, costUSD: nil), + ] + let snapshot = PoeUsageHistorySnapshot( + entries: entries, + daily: daily, + updatedAt: now) + + // Public init sorts entries ASC by createdAt, daily ASC by day + // (consumers wanting newest-first should use recentEntries(limit:)) + #expect(snapshot.entries.first?.id == "older") + #expect(snapshot.entries.last?.id == "newer") + #expect(snapshot.daily.first?.day == "2026-05-30") + #expect(snapshot.daily.last?.day == "2026-05-31") + } + + // MARK: - Helpers + + private func makeEntry( + id: String, + createdAt: Date = Date(timeIntervalSince1970: 1_717_000_000), + model: String, + usageType: String, + points: Double, + costUSD: Double?) -> PoeUsageHistorySnapshot.Entry + { + PoeUsageHistorySnapshot.Entry( + id: id, + createdAt: createdAt, + model: model, + usageType: usageType, + points: points, + costUSD: costUSD) + } +} diff --git a/Tests/CodexBarTests/PopupLocalizationTests.swift b/Tests/CodexBarTests/PopupLocalizationTests.swift index 8389645f91..e555d36584 100644 --- a/Tests/CodexBarTests/PopupLocalizationTests.swift +++ b/Tests/CodexBarTests/PopupLocalizationTests.swift @@ -89,7 +89,8 @@ struct PopupLocalizationTests { #expect(dashboard.kpis.map(\.title) == ["餘額", "今天", "週", "月"]) #expect(dashboard.points.map(\.label) == ["今天", "週", "月"]) - #expect(dashboard.detailLines.contains("速率限制: 100 / 10s")) + #expect(dashboard.detailLines.contains("速率限制:100 / 10s")) + #expect(dashboard.detailLines.contains("金鑰剩餘額度:$15.00")) } } @@ -123,6 +124,15 @@ struct PopupLocalizationTests { } } + @Test + func `settings labels use selected localization`() { + CodexBarLocalizationOverride.$appLanguage.withValue("zh-Hant") { + #expect(KiroMenuBarDisplayMode.hidden.label == "隱藏") + #expect(KiroMenuBarDisplayMode.creditsLeft.label == "剩餘額度") + #expect(L("(System)") == "(系統)") + } + } + @Test func `provider organization entries preserve provider supplied text`() throws { let settings = try Self.makeSettingsStore(suite: "PopupLocalizationTests-organizations") diff --git a/Tests/CodexBarTests/PredictivePaceWarningTests.swift b/Tests/CodexBarTests/PredictivePaceWarningTests.swift new file mode 100644 index 0000000000..6fb9bb1ffe --- /dev/null +++ b/Tests/CodexBarTests/PredictivePaceWarningTests.swift @@ -0,0 +1,721 @@ +import Foundation +import Testing +@testable import CodexBar +@testable import CodexBarCore + +@MainActor +struct PredictivePaceWarningTests { + @MainActor + final class NotifierSpy: SessionQuotaNotifying { + struct PredictivePost { + let event: PredictivePaceWarningEvent + let provider: UsageProvider + let soundEnabled: Bool + let onScreenAlertEnabled: Bool + let now: Date + } + + private(set) var quotaWarningPosts: [( + event: QuotaWarningEvent, + provider: UsageProvider, + soundEnabled: Bool, + onScreenAlertEnabled: Bool)] = [] + private(set) var predictivePosts: [PredictivePost] = [] + + func post(transition _: SessionQuotaTransition, provider _: UsageProvider, badge _: NSNumber?) {} + + func postQuotaWarning( + event: QuotaWarningEvent, + provider: UsageProvider, + soundEnabled: Bool, + onScreenAlertEnabled: Bool) + { + self.quotaWarningPosts.append(( + event: event, + provider: provider, + soundEnabled: soundEnabled, + onScreenAlertEnabled: onScreenAlertEnabled)) + } + + func postPredictivePaceWarning( + event: PredictivePaceWarningEvent, + provider: UsageProvider, + soundEnabled: Bool, + onScreenAlertEnabled: Bool, + now: Date) + { + self.predictivePosts.append(PredictivePost( + event: event, + provider: provider, + soundEnabled: soundEnabled, + onScreenAlertEnabled: onScreenAlertEnabled, + now: now)) + } + } + + @Test + func `predictive pace warnings default off and persist when enabled`() throws { + let suite = "PredictivePaceWarningTests-default-\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + let settings = self.makeSettings(suiteName: suite, clear: false) + + #expect(settings.predictivePaceWarningNotificationsEnabled == false) + #expect(defaults.object(forKey: "predictivePaceWarningNotificationsEnabled") == nil) + + settings.predictivePaceWarningNotificationsEnabled = true + + #expect(defaults.bool(forKey: "predictivePaceWarningNotificationsEnabled") == true) + #expect(self.makeSettings(suiteName: suite, clear: false).predictivePaceWarningNotificationsEnabled == true) + } + + @Test + func `predictive pace preference refreshes background work only when it changes`() { + let settings = self.makeSettings(suiteName: "PredictivePaceWarningTests-settings-revision") + let initialRevision = settings.backgroundWorkSettingsRevision + + settings.predictivePaceWarningNotificationsEnabled = true + #expect(settings.backgroundWorkSettingsRevision == initialRevision + 1) + + settings.predictivePaceWarningNotificationsEnabled = true + #expect(settings.backgroundWorkSettingsRevision == initialRevision + 1) + + settings.predictivePaceWarningNotificationsEnabled = false + #expect(settings.backgroundWorkSettingsRevision == initialRevision + 2) + } + + @Test + func `predictive only settings expose delivery controls without threshold editors`() { + let disabled = QuotaWarningSettingsVisibility( + thresholdWarningsEnabled: false, + predictiveWarningsEnabled: false) + #expect(!disabled.showsThresholdControls) + #expect(!disabled.showsDeliveryControls) + + let predictiveOnly = QuotaWarningSettingsVisibility( + thresholdWarningsEnabled: false, + predictiveWarningsEnabled: true) + #expect(!predictiveOnly.showsThresholdControls) + #expect(predictiveOnly.showsDeliveryControls) + + let thresholdWarnings = QuotaWarningSettingsVisibility( + thresholdWarningsEnabled: true, + predictiveWarningsEnabled: false) + #expect(thresholdWarnings.showsThresholdControls) + #expect(thresholdWarnings.showsDeliveryControls) + } + + @Test + func `trigger only accepts at risk pace with positive eta and confident probability`() { + #expect(PredictivePaceWarningNotificationLogic.shouldNotify(pace: self.pace( + willLastToReset: false, + etaSeconds: 60, + runOutProbability: nil))) + #expect(PredictivePaceWarningNotificationLogic.shouldNotify(pace: self.pace( + willLastToReset: false, + etaSeconds: 60, + runOutProbability: 0.5))) + #expect(!PredictivePaceWarningNotificationLogic.shouldNotify(pace: self.pace( + willLastToReset: true, + etaSeconds: 60, + runOutProbability: nil))) + #expect(!PredictivePaceWarningNotificationLogic.shouldNotify(pace: self.pace( + willLastToReset: false, + etaSeconds: nil, + runOutProbability: nil))) + #expect(!PredictivePaceWarningNotificationLogic.shouldNotify(pace: self.pace( + willLastToReset: false, + etaSeconds: 0, + runOutProbability: nil))) + #expect(!PredictivePaceWarningNotificationLogic.shouldNotify(pace: self.pace( + willLastToReset: false, + etaSeconds: 60, + runOutProbability: 0.49))) + } + + @Test + func `state machine suppresses repeats until authoritative recovery`() { + let key = PredictivePaceWarningStateKey( + provider: .claude, + accountDiscriminator: "email:person@example.com", + window: .session, + resetWindow: self.resetWindow(minutes: 300, resetsAt: 1_780_000_000)) + var notifiedKeys: Set = [] + + #expect(PredictivePaceWarningNotificationLogic.recordObservation( + key: key, + pace: self.pace(willLastToReset: false, etaSeconds: 60), + notifiedKeys: ¬ifiedKeys)) + #expect(!PredictivePaceWarningNotificationLogic.recordObservation( + key: key, + pace: self.pace(willLastToReset: false, etaSeconds: 60), + notifiedKeys: ¬ifiedKeys)) + #expect(!PredictivePaceWarningNotificationLogic.recordObservation( + key: key, + pace: self.pace(willLastToReset: false, etaSeconds: 60, runOutProbability: 0.2), + notifiedKeys: ¬ifiedKeys)) + #expect(notifiedKeys.contains(key)) + + #expect(!PredictivePaceWarningNotificationLogic.recordObservation( + key: key, + pace: self.pace(willLastToReset: true, etaSeconds: nil), + notifiedKeys: ¬ifiedKeys)) + #expect(!notifiedKeys.contains(key)) + #expect(PredictivePaceWarningNotificationLogic.recordObservation( + key: key, + pace: self.pace(willLastToReset: false, etaSeconds: 60), + notifiedKeys: ¬ifiedKeys)) + } + + @Test + func `new reset window identity is independent and prunes expired sibling key`() { + var notifiedKeys: Set = [] + let oldKey = PredictivePaceWarningStateKey( + provider: .claude, + accountDiscriminator: "email:person@example.com", + window: .weekly, + resetWindow: self.resetWindow(minutes: 10080, resetsAt: 1_780_000_000)) + let newKey = PredictivePaceWarningStateKey( + provider: .claude, + accountDiscriminator: "email:person@example.com", + window: .weekly, + resetWindow: self.resetWindow(minutes: 10080, resetsAt: 1_780_604_800)) + + #expect(PredictivePaceWarningNotificationLogic.recordObservation( + key: oldKey, + pace: self.pace(willLastToReset: false, etaSeconds: 60), + notifiedKeys: ¬ifiedKeys)) + PredictivePaceWarningNotificationLogic.reconcileSiblingWindowKeys( + activeKey: newKey, + notifiedKeys: ¬ifiedKeys) + #expect(!notifiedKeys.contains(oldKey)) + #expect(PredictivePaceWarningNotificationLogic.recordObservation( + key: newKey, + pace: self.pace(willLastToReset: false, etaSeconds: 60), + notifiedKeys: ¬ifiedKeys)) + } + + @Test + func `provider account and window risk episodes are isolated`() { + let keys = [ + PredictivePaceWarningStateKey( + provider: .claude, + accountDiscriminator: "account-a", + window: .session, + resetWindow: self.resetWindow(minutes: 300, resetsAt: 1_780_000_000)), + PredictivePaceWarningStateKey( + provider: .claude, + accountDiscriminator: "account-a", + window: .weekly, + resetWindow: self.resetWindow(minutes: 10080, resetsAt: 1_780_000_000)), + PredictivePaceWarningStateKey( + provider: .claude, + accountDiscriminator: "account-b", + window: .session, + resetWindow: self.resetWindow(minutes: 300, resetsAt: 1_780_000_000)), + PredictivePaceWarningStateKey( + provider: .codex, + accountDiscriminator: "account-a", + window: .session, + resetWindow: self.resetWindow(minutes: 300, resetsAt: 1_780_000_000)), + ] + var notifiedKeys: Set = [] + + for key in keys { + #expect(PredictivePaceWarningNotificationLogic.recordObservation( + key: key, + pace: self.pace(willLastToReset: false, etaSeconds: 60), + notifiedKeys: ¬ifiedKeys)) + } + for key in keys { + #expect(!PredictivePaceWarningNotificationLogic.recordObservation( + key: key, + pace: self.pace(willLastToReset: false, etaSeconds: 60), + notifiedKeys: ¬ifiedKeys)) + } + #expect(notifiedKeys == Set(keys)) + } + + @Test + func `reset time jitter follows the same risk episode without repeating`() { + let firstKey = PredictivePaceWarningStateKey( + provider: .codex, + accountDiscriminator: "account-a", + window: .session, + resetWindow: self.resetWindow(minutes: 300, resetsAt: 1_780_000_000)) + let correctedKey = PredictivePaceWarningStateKey( + provider: .codex, + accountDiscriminator: "account-a", + window: .session, + resetWindow: self.resetWindow(minutes: 300, resetsAt: 1_780_000_120)) + var notifiedKeys: Set = [] + + #expect(PredictivePaceWarningNotificationLogic.recordObservation( + key: firstKey, + pace: self.pace(willLastToReset: false, etaSeconds: 60), + notifiedKeys: ¬ifiedKeys)) + PredictivePaceWarningNotificationLogic.reconcileSiblingWindowKeys( + activeKey: correctedKey, + notifiedKeys: ¬ifiedKeys) + #expect(!PredictivePaceWarningNotificationLogic.recordObservation( + key: correctedKey, + pace: self.pace(willLastToReset: false, etaSeconds: 60), + notifiedKeys: ¬ifiedKeys)) + #expect(notifiedKeys == Set([correctedKey])) + + PredictivePaceWarningNotificationLogic.reconcileSiblingWindowKeys( + activeKey: correctedKey, + notifiedKeys: ¬ifiedKeys) + #expect(!PredictivePaceWarningNotificationLogic.recordObservation( + key: correctedKey, + pace: self.pace(willLastToReset: true, etaSeconds: nil), + notifiedKeys: ¬ifiedKeys)) + #expect(notifiedKeys.isEmpty) + } + + @Test + func `store posts once for Claude session and weekly risk then re-arms after recovery`() { + let now = Date(timeIntervalSince1970: 1_780_000_000) + let settings = self.makeSettings(suiteName: "PredictivePaceWarningTests-claude-store") + settings.predictivePaceWarningNotificationsEnabled = true + settings.quotaWarningSoundEnabled = false + settings.quotaWarningOnScreenAlertEnabled = true + let notifier = NotifierSpy() + let store = self.makeStore(settings: settings, notifier: notifier) + + let atRisk = self.snapshot( + now: now, + sessionUsed: 80, + weeklyUsed: 90, + accountEmail: "person@example.com") + store.handlePredictivePaceWarningTransitions(provider: .claude, snapshot: atRisk) + store.handlePredictivePaceWarningTransitions(provider: .claude, snapshot: atRisk) + + #expect(notifier.predictivePosts.map(\.event.window) == [.session, .weekly]) + #expect(notifier.predictivePosts.allSatisfy { $0.provider == .claude }) + #expect(notifier.predictivePosts.allSatisfy { $0.soundEnabled == false }) + #expect(notifier.predictivePosts.allSatisfy { $0.onScreenAlertEnabled == true }) + #expect(notifier.predictivePosts.allSatisfy { $0.event.accountDisplayName == "person@example.com" }) + + let jitteredAtRisk = self.snapshot( + now: now.addingTimeInterval(120), + sessionUsed: 80, + weeklyUsed: 90, + accountEmail: "person@example.com") + store.handlePredictivePaceWarningTransitions(provider: .claude, snapshot: jitteredAtRisk) + #expect(notifier.predictivePosts.map(\.event.window) == [.session, .weekly]) + + let recovered = self.snapshot( + now: now, + sessionUsed: 20, + weeklyUsed: 20, + accountEmail: "person@example.com") + store.handlePredictivePaceWarningTransitions(provider: .claude, snapshot: recovered) + store.handlePredictivePaceWarningTransitions(provider: .claude, snapshot: atRisk) + + #expect(notifier.predictivePosts.map(\.event.window) == [.session, .weekly, .session, .weekly]) + } + + @Test + func `missing incomplete and failed observations preserve warned state`() async { + let now = Date(timeIntervalSince1970: 1_780_000_000) + let settings = self.makeSettings(suiteName: "PredictivePaceWarningTests-preserve-state") + settings.predictivePaceWarningNotificationsEnabled = true + let notifier = NotifierSpy() + let store = self.makeStore(settings: settings, notifier: notifier) + let atRisk = self.snapshot( + now: now, + sessionUsed: 80, + weeklyUsed: 90, + accountEmail: "person@example.com") + + store.handlePredictivePaceWarningTransitions(provider: .claude, snapshot: atRisk) + #expect(notifier.predictivePosts.map(\.event.window) == [.session, .weekly]) + + let incomplete = UsageSnapshot( + primary: nil, + secondary: nil, + updatedAt: now, + identity: ProviderIdentitySnapshot( + providerID: .claude, + accountEmail: "person@example.com", + accountOrganization: nil, + loginMethod: nil)) + store.handlePredictivePaceWarningTransitions(provider: .claude, snapshot: incomplete) + + let missingIdentity = self.snapshot( + now: now, + sessionUsed: 80, + weeklyUsed: 90, + accountEmail: nil) + store.handlePredictivePaceWarningTransitions(provider: .claude, snapshot: missingIdentity) + + await store.applySelectedOutcome( + ProviderFetchOutcome( + result: .failure(ProviderFetchError.noAvailableStrategy(.claude)), + attempts: []), + provider: .claude, + account: nil, + fallbackSnapshot: atRisk) + + store.handlePredictivePaceWarningTransitions(provider: .claude, snapshot: atRisk) + #expect(notifier.predictivePosts.map(\.event.window) == [.session, .weekly]) + } + + @Test + func `new store starts with memory only warning state`() { + let now = Date(timeIntervalSince1970: 1_780_000_000) + let settings = self.makeSettings(suiteName: "PredictivePaceWarningTests-memory-only") + settings.predictivePaceWarningNotificationsEnabled = true + let snapshot = self.snapshot( + now: now, + sessionUsed: 80, + weeklyUsed: 20, + accountEmail: "person@example.com") + let firstNotifier = NotifierSpy() + let firstStore = self.makeStore(settings: settings, notifier: firstNotifier) + firstStore.handlePredictivePaceWarningTransitions(provider: .claude, snapshot: snapshot) + firstStore.handlePredictivePaceWarningTransitions(provider: .claude, snapshot: snapshot) + #expect(firstNotifier.predictivePosts.count == 1) + + let secondNotifier = NotifierSpy() + let secondStore = self.makeStore(settings: settings, notifier: secondNotifier) + secondStore.handlePredictivePaceWarningTransitions(provider: .claude, snapshot: snapshot) + #expect(secondNotifier.predictivePosts.count == 1) + } + + @Test + func `store posts for Codex session and weekly risk`() { + let now = Date(timeIntervalSince1970: 1_780_000_000) + let settings = self.makeSettings(suiteName: "PredictivePaceWarningTests-codex-store") + settings.predictivePaceWarningNotificationsEnabled = true + let notifier = NotifierSpy() + let store = self.makeStore(settings: settings, notifier: notifier) + + let atRisk = self.snapshot( + now: now, + sessionUsed: 80, + weeklyUsed: 90, + accountEmail: "codex@example.com", + provider: .codex) + store.handlePredictivePaceWarningTransitions(provider: .codex, snapshot: atRisk) + store.handlePredictivePaceWarningTransitions(provider: .codex, snapshot: atRisk) + + #expect(notifier.predictivePosts.map(\.event.window) == [.session, .weekly]) + #expect(notifier.predictivePosts.allSatisfy { $0.provider == .codex }) + #expect(notifier.predictivePosts.allSatisfy { $0.event.accountDisplayName == "codex@example.com" }) + } + + @Test + func `store isolates risk episodes by account`() { + let now = Date(timeIntervalSince1970: 1_780_000_000) + let settings = self.makeSettings(suiteName: "PredictivePaceWarningTests-account-isolation") + settings.predictivePaceWarningNotificationsEnabled = true + let notifier = NotifierSpy() + let store = self.makeStore(settings: settings, notifier: notifier) + + let firstAccount = self.snapshot( + now: now, + sessionUsed: 80, + weeklyUsed: 20, + accountEmail: "first@example.com") + let secondAccount = self.snapshot( + now: now, + sessionUsed: 80, + weeklyUsed: 20, + accountEmail: "second@example.com") + + store.handlePredictivePaceWarningTransitions(provider: .claude, snapshot: firstAccount) + store.handlePredictivePaceWarningTransitions(provider: .claude, snapshot: firstAccount) + store.handlePredictivePaceWarningTransitions(provider: .claude, snapshot: secondAccount) + store.handlePredictivePaceWarningTransitions(provider: .claude, snapshot: firstAccount) + + #expect(notifier.predictivePosts.map(\.event.window) == [.session, .session]) + #expect(notifier.predictivePosts.map(\.event.accountDisplayName) == [ + "first@example.com", + "second@example.com", + ]) + } + + @Test + func `stable Claude account identity spans OAuth and CLI observations`() { + let now = Date(timeIntervalSince1970: 1_780_000_000) + let settings = self.makeSettings(suiteName: "PredictivePaceWarningTests-claude-active-account") + settings.predictivePaceWarningNotificationsEnabled = true + let notifier = NotifierSpy() + let store = self.makeStore(settings: settings, notifier: notifier) + let firstAccount = UsageStore.warningClaudeAccountDiscriminator( + strategyKind: .oauth, + observation: .stable(identity: "account-a")) + let secondAccount = UsageStore.warningClaudeAccountDiscriminator( + strategyKind: .cli, + observation: .stable(identity: "account-b")) + #expect(UsageStore.warningClaudeAccountDiscriminator( + strategyKind: .oauth, + observation: .stable(identity: nil)) == nil) + #expect(UsageStore.warningClaudeAccountDiscriminator( + strategyKind: .cli, + observation: .changed) == nil) + #expect(UsageStore.warningClaudeAccountDiscriminator( + strategyKind: .web, + observation: .stable(identity: "account-a")) == nil) + #expect(UsageStore.warningClaudeAccountDiscriminator( + strategyKind: .apiToken, + observation: .stable(identity: "account-a")) == nil) + let noEmailRisk = self.snapshot( + now: now, + sessionUsed: 80, + weeklyUsed: 20, + accountEmail: nil) + let emailRisk = self.snapshot( + now: now, + sessionUsed: 80, + weeklyUsed: 20, + accountEmail: "person@example.com") + let emailRecovery = self.snapshot( + now: now, + sessionUsed: 20, + weeklyUsed: 20, + accountEmail: "person@example.com") + + store.handlePredictivePaceWarningTransitions( + provider: .claude, + snapshot: noEmailRisk, + accountDiscriminatorOverride: firstAccount) + store.handlePredictivePaceWarningTransitions( + provider: .claude, + snapshot: emailRisk, + accountDiscriminatorOverride: firstAccount) + store.handlePredictivePaceWarningTransitions( + provider: .claude, + snapshot: emailRecovery, + accountDiscriminatorOverride: firstAccount) + store.handlePredictivePaceWarningTransitions( + provider: .claude, + snapshot: noEmailRisk, + accountDiscriminatorOverride: firstAccount) + store.handlePredictivePaceWarningTransitions( + provider: .claude, + snapshot: noEmailRisk, + accountDiscriminatorOverride: secondAccount) + + #expect(notifier.predictivePosts.map(\.event.window) == [.session, .session, .session]) + #expect(notifier.predictivePosts.allSatisfy { $0.provider == .claude }) + } + + @Test + func `Claude OAuth owner keeps no email warnings account scoped when active metadata is missing`() { + let owner = String(repeating: "a", count: 64) + + #expect(UsageStore.warningClaudeAccountDiscriminator( + strategyKind: .oauth, + observation: .stable(identity: nil), + oauthHistoryOwnerIdentifier: owner) == "claude-oauth-owner:\(owner)") + #expect(UsageStore.warningClaudeAccountDiscriminator( + strategyKind: .oauth, + observation: .changed, + oauthHistoryOwnerIdentifier: " \(owner.uppercased()) ") == "claude-oauth-owner:\(owner)") + #expect(UsageStore.warningClaudeAccountDiscriminator( + strategyKind: .cli, + observation: .stable(identity: nil), + oauthHistoryOwnerIdentifier: owner) == nil) + #expect(UsageStore.warningClaudeAccountDiscriminator( + strategyKind: .web, + observation: .stable(identity: nil), + oauthHistoryOwnerIdentifier: owner) == nil) + } + + @Test + func `selected Claude account identity is stable across OAuth owner changes`() async throws { + let now = Date(timeIntervalSince1970: 1_780_000_000) + let settings = self.makeSettings(suiteName: "PredictivePaceWarningTests-selected-claude-account") + settings.predictivePaceWarningNotificationsEnabled = true + let firstAccount = try ProviderTokenAccount( + id: #require(UUID(uuidString: "00000000-0000-0000-0000-000000000001")), + label: "First", + token: "token", + addedAt: 0, + lastUsed: nil) + let secondAccount = try ProviderTokenAccount( + id: #require(UUID(uuidString: "00000000-0000-0000-0000-000000000002")), + label: "Second", + token: "token", + addedAt: 0, + lastUsed: nil) + let notifier = NotifierSpy() + let store = self.makeStore(settings: settings, notifier: notifier) + let snapshot = self.snapshot( + now: now, + sessionUsed: 80, + weeklyUsed: 20, + accountEmail: nil) + + await store.applySelectedOutcome( + self.claudeOAuthOutcome(snapshot: snapshot, ownerIdentifier: "owner-a"), + provider: .claude, + account: firstAccount, + fallbackSnapshot: nil) + await store.applySelectedOutcome( + self.claudeOAuthOutcome(snapshot: snapshot, ownerIdentifier: "owner-b"), + provider: .claude, + account: firstAccount, + fallbackSnapshot: nil) + await store.applySelectedOutcome( + self.claudeOAuthOutcome(snapshot: snapshot, ownerIdentifier: "owner-b"), + provider: .claude, + account: secondAccount, + fallbackSnapshot: nil) + + #expect(notifier.predictivePosts.map(\.event.window) == [.session, .session]) + #expect(notifier.predictivePosts.allSatisfy { $0.provider == .claude }) + #expect(notifier.predictivePosts.map(\.event.accountDisplayName) == ["First", "Second"]) + } + + @Test + func `store keeps identity out of copy when personal info is hidden`() throws { + let now = Date(timeIntervalSince1970: 1_780_000_000) + let settings = self.makeSettings(suiteName: "PredictivePaceWarningTests-hidden-info") + settings.predictivePaceWarningNotificationsEnabled = true + settings.hidePersonalInfo = true + let notifier = NotifierSpy() + let store = self.makeStore(settings: settings, notifier: notifier) + + store.handlePredictivePaceWarningTransitions( + provider: .claude, + snapshot: self.snapshot( + now: now, + sessionUsed: 80, + weeklyUsed: 20, + accountEmail: "person@example.com")) + + #expect(notifier.predictivePosts.first?.event.accountDisplayName == nil) + let copy = try PredictivePaceWarningNotificationLogic.notificationCopy( + providerName: "Claude", + event: #require(notifier.predictivePosts.first?.event), + now: now) + #expect(!copy.body.contains("person@example.com")) + } + + @Test + func `store ignores providers outside accepted scope`() { + let now = Date(timeIntervalSince1970: 1_780_000_000) + let settings = self.makeSettings(suiteName: "PredictivePaceWarningTests-scope") + settings.predictivePaceWarningNotificationsEnabled = true + let notifier = NotifierSpy() + let store = self.makeStore(settings: settings, notifier: notifier) + + store.handlePredictivePaceWarningTransitions( + provider: .zai, + snapshot: self.snapshot(now: now, sessionUsed: 80, weeklyUsed: 90, accountEmail: "person@example.com")) + + #expect(notifier.predictivePosts.isEmpty) + } + + @Test + func `store ignores unsupported tertiary windows`() { + let now = Date(timeIntervalSince1970: 1_780_000_000) + let settings = self.makeSettings(suiteName: "PredictivePaceWarningTests-window-scope") + settings.predictivePaceWarningNotificationsEnabled = true + let notifier = NotifierSpy() + let store = self.makeStore(settings: settings, notifier: notifier) + let snapshot = UsageSnapshot( + primary: nil, + secondary: nil, + tertiary: RateWindow( + usedPercent: 90, + windowMinutes: 30 * 24 * 60, + resetsAt: now.addingTimeInterval(2 * 24 * 3600), + resetDescription: nil), + updatedAt: now, + identity: ProviderIdentitySnapshot( + providerID: .claude, + accountEmail: "person@example.com", + accountOrganization: nil, + loginMethod: nil)) + + store.handlePredictivePaceWarningTransitions(provider: .claude, snapshot: snapshot) + + #expect(notifier.predictivePosts.isEmpty) + } + + private func makeSettings(suiteName: String, clear: Bool = true) -> SettingsStore { + let defaults = UserDefaults(suiteName: suiteName)! + if clear { + defaults.removePersistentDomain(forName: suiteName) + } + return SettingsStore( + userDefaults: defaults, + configStore: testConfigStore(suiteName: suiteName), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore(), + tokenAccountStore: InMemoryTokenAccountStore()) + } + + private func makeStore(settings: SettingsStore, notifier: NotifierSpy) -> UsageStore { + UsageStore( + fetcher: UsageFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + sessionQuotaNotifier: notifier) + } + + private func claudeOAuthOutcome(snapshot: UsageSnapshot, ownerIdentifier: String) -> ProviderFetchOutcome { + ProviderFetchOutcome( + result: .success(ProviderFetchResult( + usage: snapshot, + credits: nil, + dashboard: nil, + sourceLabel: "oauth", + strategyID: "claude-oauth", + strategyKind: .oauth, + claudeOAuthHistoryOwnerIdentifier: ownerIdentifier)), + attempts: []) + } + + private func snapshot( + now: Date, + sessionUsed: Double, + weeklyUsed: Double, + accountEmail: String?, + provider: UsageProvider = .claude) -> UsageSnapshot + { + UsageSnapshot( + primary: RateWindow( + usedPercent: sessionUsed, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(2 * 3600), + resetDescription: nil), + secondary: RateWindow( + usedPercent: weeklyUsed, + windowMinutes: 7 * 24 * 60, + resetsAt: now.addingTimeInterval(2 * 24 * 3600), + resetDescription: nil), + updatedAt: now, + identity: ProviderIdentitySnapshot( + providerID: provider, + accountEmail: accountEmail, + accountOrganization: nil, + loginMethod: nil)) + } + + private func pace( + willLastToReset: Bool, + etaSeconds: TimeInterval?, + runOutProbability: Double? = nil) -> UsagePace + { + UsagePace( + stage: willLastToReset ? .onTrack : .ahead, + deltaPercent: willLastToReset ? 0 : 20, + expectedUsedPercent: 50, + actualUsedPercent: willLastToReset ? 40 : 70, + etaSeconds: etaSeconds, + willLastToReset: willLastToReset, + runOutProbability: runOutProbability) + } + + private func resetWindow(minutes: Int?, resetsAt: TimeInterval) -> PredictivePaceWarningResetWindow { + PredictivePaceWarningResetWindow( + windowMinutes: minutes, + resetsAt: Date(timeIntervalSince1970: resetsAt)) + } +} diff --git a/Tests/CodexBarTests/PreferencesPaneSmokeTests.swift b/Tests/CodexBarTests/PreferencesPaneSmokeTests.swift index a7b19b7bd6..2033e38ef5 100644 --- a/Tests/CodexBarTests/PreferencesPaneSmokeTests.swift +++ b/Tests/CodexBarTests/PreferencesPaneSmokeTests.swift @@ -11,12 +11,16 @@ struct PreferencesPaneSmokeTests { let settings = Self.makeSettingsStore(suite: "PreferencesPaneSmokeTests-default") let store = Self.makeUsageStore(settings: settings) - _ = GeneralPane(settings: settings, store: store).body - _ = DisplayPane(settings: settings, store: store).body - _ = AdvancedPane(settings: settings).body + _ = GeneralPane(settings: settings).body + _ = NotificationsPane(settings: settings).body + _ = MenuBarPane(settings: settings, store: store).body + _ = MenuPane(settings: settings, store: store).body + _ = AdvancedPane(settings: settings, store: store).body + _ = HooksPane(settings: settings).body _ = ProvidersPane(settings: settings, store: store).body _ = DebugPane(settings: settings, store: store).body _ = AboutPane(updater: DisabledUpdaterController()).body + _ = SettingsSidebarView(settings: settings, store: store, selection: .constant(.general)).body settings.debugDisableKeychainAccess = false } @@ -25,33 +29,426 @@ struct PreferencesPaneSmokeTests { func `builds preference panes with toggled settings`() { let settings = Self.makeSettingsStore(suite: "PreferencesPaneSmokeTests-toggled") settings.menuBarShowsBrandIconWithPercent = true + settings.menuBarHighContrastOnInactiveDisplays = true settings.menuBarShowsHighestUsage = true settings.multiAccountMenuLayout = .stacked settings.hidePersonalInfo = true settings.resetTimesShowAbsolute = true + settings.costUsageEnabled = true + settings.costComparisonPeriodsEnabled = true settings.debugDisableKeychainAccess = true settings.claudeOAuthKeychainPromptMode = .always settings.refreshFrequency = .manual + settings.quotaWarningNotificationsEnabled = true let store = Self.makeUsageStore(settings: settings) store._setErrorForTesting("Example error", provider: .codex) - _ = GeneralPane(settings: settings, store: store).body - _ = DisplayPane(settings: settings, store: store).body - _ = AdvancedPane(settings: settings).body - _ = ProvidersPane(settings: settings, store: store).body + _ = GeneralPane(settings: settings).body + _ = NotificationsPane(settings: settings).body + _ = MenuBarPane(settings: settings, store: store).body + _ = MenuPane(settings: settings, store: store).body + _ = AdvancedPane(settings: settings, store: store).body + _ = ProvidersPane(provider: .claude, settings: settings, store: store).body _ = DebugPane(settings: settings, store: store).body _ = AboutPane(updater: DisabledUpdaterController()).body + _ = SettingsSidebarView(settings: settings, store: store, selection: .constant(.provider(.codex))).body } @Test - func `overview provider limit text formats numeric limit as object argument`() { - let text = DisplayPane.overviewProviderLimitText(limit: 3) + func `general menu options cover persisted settings`() { + let previousLanguage = UserDefaults.standard.object(forKey: "appLanguage") + let previousAppleLanguages = UserDefaults.standard.object(forKey: "AppleLanguages") + defer { + if let previousLanguage { + UserDefaults.standard.set(previousLanguage, forKey: "appLanguage") + } else { + UserDefaults.standard.removeObject(forKey: "appLanguage") + } + if let previousAppleLanguages { + UserDefaults.standard.set(previousAppleLanguages, forKey: "AppleLanguages") + } else { + UserDefaults.standard.removeObject(forKey: "AppleLanguages") + } + } + + #expect(GeneralSettingsMenuOptions.languages == AppLanguage.allCases.map(\.rawValue)) + #expect(GeneralSettingsMenuOptions.refreshFrequencies == RefreshFrequency.allCases) + #expect(GeneralSettingsMenuOptions.terminalApps(selected: .terminal) { _ in nil } == [.terminal]) + #expect(GeneralSettingsMenuOptions.terminalApps(selected: .iTerm) { _ in nil } == [.terminal, .iTerm]) + + let suite = "PreferencesPaneSmokeTests-general-menu-persistence" + let settings = Self.makeSettingsStore(suite: suite) + settings.appLanguage = "ja" + settings.terminalApp = .iTerm + settings.refreshFrequency = .fiveMinutes + + let reloaded = Self.makeSettingsStore(suite: suite, reset: false) + #expect(reloaded.appLanguage == "ja") + #expect(reloaded.terminalApp == .iTerm) + #expect(reloaded.refreshFrequency == .fiveMinutes) + } + + @Test + func `menu bar and menu options cover persisted settings`() { + #expect(MenuBarSettingsMenuOptions.displayModes == MenuBarDisplayMode.allCases) + #expect(MenuBarSettingsMenuOptions.iconStyles == MenuBarIconStyle.allCases) + #expect(MenuBarSettingsMenuOptions.switcherRows == SwitcherRowsOption.allCases) + #expect(MenuSettingsMenuOptions.weeklyProgressWorkDays == [nil, 4, 5, 7]) + #expect(MenuSettingsMenuOptions.weeklyProgressWorkDaysLabel(nil) == L("Automatic")) + #expect(MenuSettingsMenuOptions.multiAccountLayouts == MultiAccountMenuLayout.allCases) + #expect(MenuSettingsMenuOptions.usageBarsFill == UsageBarsFillOption.allCases) + #expect(MenuSettingsMenuOptions.resetTimes == ResetTimesOption.allCases) + #expect(MenuSettingsMenuOptions.costSummaries == CostSummaryOption.allCases) + #expect(NotificationsSettingsMenuOptions.confettiCelebrations == ConfettiCelebrationOption.allCases) + + let suite = "PreferencesPaneSmokeTests-display-menu-persistence" + let settings = Self.makeSettingsStore(suite: suite) + settings.menuBarDisplayMode = .resetTime + settings.weeklyProgressWorkDays = 7 + settings.multiAccountMenuLayout = .stacked + settings.costSummaryDisplayStyle = .costSubmenu - #expect(text.contains("3")) + let reloaded = Self.makeSettingsStore(suite: suite, reset: false) + #expect(reloaded.menuBarDisplayMode == .resetTime) + #expect(reloaded.weeklyProgressWorkDays == 7) + #expect(reloaded.multiAccountMenuLayout == .stacked) + #expect(reloaded.costSummaryDisplayStyle == .costSubmenu) + } + + @Test + func `overview provider limit text shows the configured maximum`() { + let text = MenuBarPane.overviewProviderLimitText() + + #expect(text.contains("6")) #expect(!text.contains("%@")) } + @Test + func `inactive display contrast is available only for icon and percent`() { + #expect(!MenuBarPane.inactiveDisplayContrastAvailable(for: .critters)) + #expect(!MenuBarPane.inactiveDisplayContrastAvailable(for: .bars)) + #expect(MenuBarPane.inactiveDisplayContrastAvailable(for: .iconAndPercent)) + } + + /// This fork defaults usage colors on; upstream defaults them off. + @Test + func `usage colors default on and persist`() { + let suite = "PreferencesPaneSmokeTests-usage-colors" + let settings = Self.makeSettingsStore(suite: suite) + + #expect(settings.menuBarUsageColorsEnabled) + + settings.menuBarUsageColorsEnabled = false + #expect(!Self.makeSettingsStore(suite: suite, reset: false).menuBarUsageColorsEnabled) + } + + @Test + func `menu bar icon style maps existing booleans`() { + let settings = Self.makeSettingsStore(suite: "PreferencesPaneSmokeTests-menu-bar-icon-style") + + settings.menuBarShowsBrandIconWithPercent = false + settings.menuBarHidesCritters = false + #expect(settings.menuBarIconStyle == .critters) + + settings.menuBarHidesCritters = true + #expect(settings.menuBarIconStyle == .bars) + + settings.menuBarShowsBrandIconWithPercent = true + #expect(settings.menuBarIconStyle == .iconAndPercent) + + settings.menuBarHidesCritters = true + settings.menuBarIconStyle = .iconAndPercent + #expect(settings.menuBarShowsBrandIconWithPercent) + #expect(settings.menuBarHidesCritters) + + settings.menuBarIconStyle = .critters + #expect(!settings.menuBarShowsBrandIconWithPercent) + #expect(!settings.menuBarHidesCritters) + + settings.menuBarIconStyle = .bars + #expect(!settings.menuBarShowsBrandIconWithPercent) + #expect(settings.menuBarHidesCritters) + } + + @Test + func `confetti celebration option maps all boolean combinations`() { + let settings = Self.makeSettingsStore(suite: "PreferencesPaneSmokeTests-confetti-celebration") + + for option in ConfettiCelebrationOption.allCases { + settings.confettiCelebrationOption = option + #expect(settings.confettiCelebrationOption == option) + #expect(settings.confettiOnSessionLimitResetsEnabled == (option == .session || option == .both)) + #expect(settings.confettiOnWeeklyLimitResetsEnabled == (option == .weekly || option == .both)) + } + } + + @Test + func `cost summary option disables without losing style`() { + let settings = Self.makeSettingsStore(suite: "PreferencesPaneSmokeTests-cost-summary-option") + + settings.costSummaryOption = .costSubmenu + #expect(settings.costUsageEnabled) + #expect(settings.costSummaryDisplayStyle == .costSubmenu) + + settings.costSummaryOption = .off + #expect(!settings.costUsageEnabled) + #expect(settings.costSummaryDisplayStyle == .costSubmenu) + #expect(settings.costSummaryOption == .off) + + settings.costUsageEnabled = true + #expect(settings.costSummaryOption == .costSubmenu) + + settings.costSummaryOption = .inlineSummary + #expect(settings.costUsageEnabled) + #expect(settings.costSummaryDisplayStyle == .inlineSummary) + + settings.costSummaryOption = .both + #expect(settings.costUsageEnabled) + #expect(settings.costSummaryDisplayStyle == .both) + } + + @Test + func `cost history days editor builds with clamped settings binding`() { + let settings = Self.makeSettingsStore(suite: "PreferencesPaneSmokeTests-cost-history-days") + + settings.costUsageHistoryDays = 999 + #expect(settings.costUsageHistoryDays == 365) + #expect(CostHistoryDaysEditor.title(days: 365).contains("365")) + #expect(!CostHistoryDaysEditor.title(days: 365).contains("%d")) + + _ = CostHistoryDaysEditor(settings: settings).body + } + + @Test + func `quota warning compact threshold text filters and persists typed values`() { + let suite = "PreferencesPaneSmokeTests-quota-warning-threshold-editor" + let settings = Self.makeSettingsStore(suite: suite) + + #expect(QuotaWarningThresholdEditorText.filteredIntegerText("9a8b7") == "98") + #expect(QuotaWarningThresholdEditorText.resolvedThresholds(upperText: "", lowerText: "12") == [50, 12]) + + let typedThresholds = QuotaWarningThresholdEditorText.resolvedThresholds(upperText: "75", lowerText: "15") + settings.setQuotaWarningThresholds(.session, thresholds: typedThresholds) + + #expect(settings.quotaWarningThresholds(.session) == [75, 15]) + let reloaded = Self.makeSettingsStore(suite: suite, reset: false) + #expect(reloaded.quotaWarningThresholds(.session) == [75, 15]) + } + + @Test + func `quota warning compact draft preserves untouched threshold lists`() { + var singleThreshold = QuotaWarningThresholdEditorText.Draft(thresholds: [50]) + var severalThresholds = QuotaWarningThresholdEditorText.Draft(thresholds: [80, 50, 20]) + + #expect(singleThreshold.takeResolvedThresholds() == nil) + #expect(severalThresholds.takeResolvedThresholds() == nil) + #expect(singleThreshold.isDirty == false) + #expect(severalThresholds.isDirty == false) + } + + @Test + func `quota warning compact draft commits only changed text`() { + var draft = QuotaWarningThresholdEditorText.Draft(thresholds: [80, 50, 20]) + + draft.setText("80", for: .upper) + #expect(draft.isDirty == false) + + draft.setText("7a5", for: .upper) + #expect(draft.isDirty == true) + #expect(draft.takeResolvedThresholds() == [75, 50]) + #expect(draft.isDirty == false) + #expect(draft.text(for: .upper) == "75") + #expect(draft.text(for: .lower) == "50") + } + + @Test + func `quota warning compact draft treats reverted text as unchanged`() { + var draft = QuotaWarningThresholdEditorText.Draft(thresholds: [80, 50, 20]) + + draft.setText("79", for: .upper) + #expect(draft.isDirty == true) + + draft.setText("80", for: .upper) + #expect(draft.isDirty == false) + #expect(draft.takeResolvedThresholds() == nil) + } + + @Test + func `quota warning compact window toggle keeps thresholds while disabled`() { + let settings = Self.makeSettingsStore(suite: "PreferencesPaneSmokeTests-quota-warning-disabled-window") + + settings.setQuotaWarningThresholds(.weekly, thresholds: [80, 30]) + settings.setQuotaWarningWindowEnabled(.weekly, enabled: false) + + #expect(settings.quotaWarningWindowEnabled(.weekly) == false) + #expect(settings.quotaWarningThresholds(.weekly) == [80, 30]) + + settings.setQuotaWarningWindowEnabled(.weekly, enabled: true) + + #expect(settings.quotaWarningWindowEnabled(.weekly) == true) + #expect(settings.quotaWarningThresholds(.weekly) == [80, 30]) + } + + @Test + func `quota warning compact rows build with semantic threshold labels`() { + let settings = Self.makeSettingsStore(suite: "PreferencesPaneSmokeTests-quota-warning-semantic-labels") + settings.quotaWarningNotificationsEnabled = true + + CodexBarLocalizationOverride.$appLanguage.withValue("ru") { + #expect(L("quota_warning_global") == "Глобально") + #expect(L("quota_warning_warning") == "Предупреждение") + #expect(L("quota_warning_critical") == "Критично") + + _ = GlobalQuotaWarningSettingsView(settings: settings).body + } + } + + @Test + func `provider quota warning inherited summary keeps additional active thresholds visible`() { + CodexBarLocalizationOverride.$appLanguage.withValue("en") { + let thresholdText = ProviderQuotaWarningSettingsView.thresholdText([80, 50, 20], enabled: true) + + #expect(thresholdText == "Warning 80%, Critical 50%, 20%") + #expect(String(format: L("quota_warning_inherited"), thresholdText) + == "Inherited: Warning 80%, Critical 50%, 20%") + } + } + + @Test + func `provider quota warning rows build for global custom and off states`() { + let settings = Self.makeSettingsStore(suite: "PreferencesPaneSmokeTests-provider-quota-warning-rows") + settings.quotaWarningNotificationsEnabled = true + settings.setQuotaWarningThresholds(.session, thresholds: [50, 20]) + settings.setQuotaWarningThresholds(.weekly, thresholds: [80, 40]) + + _ = ProviderQuotaWarningSettingsView(provider: .codex, settings: settings).body + + settings.setQuotaWarningOverride(provider: .codex, window: .session, thresholds: [70, 30], enabled: true) + settings.setQuotaWarningOverride(provider: .codex, window: .weekly, thresholds: [60, 10], enabled: false) + + _ = ProviderQuotaWarningSettingsView(provider: .codex, settings: settings).body + + #expect(settings.hasQuotaWarningOverride(provider: .codex, window: .session)) + #expect(settings.hasQuotaWarningOverride(provider: .codex, window: .weekly)) + #expect(settings.quotaWarningEnabled(provider: .codex, window: .session)) + #expect(!settings.quotaWarningEnabled(provider: .codex, window: .weekly)) + #expect(settings.resolvedQuotaWarningThresholds(provider: .codex, window: .weekly) == [60, 10]) + } + + @Test + func `provider quota warning controls follow notification and marker visibility`() { + let settings = Self.makeSettingsStore(suite: "PreferencesPaneSmokeTests-provider-quota-warning-disabled") + settings.quotaWarningNotificationsEnabled = true + settings.quotaWarningMarkersVisible = true + settings.setQuotaWarningOverride(provider: .codex, window: .session, thresholds: [70, 30], enabled: true) + settings.setQuotaWarningOverride(provider: .codex, window: .weekly, thresholds: [60, 10], enabled: false) + + let view = ProviderQuotaWarningSettingsView(provider: .codex, settings: settings) + let inheritedView = ProviderQuotaWarningSettingsView(provider: .claude, settings: settings) + #expect(view.controlsEnabled) + #expect(view.overrideMode(for: .session) == .custom) + #expect(view.overrideMode(for: .weekly) == .off) + #expect(inheritedView.overrideMode(for: .session) == .global) + #expect(inheritedView.overrideMode(for: .weekly) == .global) + + CodexBarLocalizationOverride.$appLanguage.withValue("en") { + #expect(view.footerText == "Uses the global quota warning settings unless a window is customized here.") + } + + settings.quotaWarningNotificationsEnabled = false + + #expect(view.controlsEnabled) + #expect(inheritedView.controlsEnabled) + #expect(view.overrideMode(for: .session) == .custom) + #expect(view.overrideMode(for: .weekly) == .off) + #expect(inheritedView.overrideMode(for: .session) == .global) + #expect(inheritedView.overrideMode(for: .weekly) == .global) + #expect(settings.explicitQuotaWarningThresholds(provider: .codex, window: .session) == [70, 30]) + #expect(settings.explicitQuotaWarningThresholds(provider: .codex, window: .weekly) == [60, 10]) + + CodexBarLocalizationOverride.$appLanguage.withValue("en") { + #expect(view.footerText == "Quota warning notifications are disabled globally. " + + "These settings still control usage-bar markers.") + } + + settings.quotaWarningMarkersVisible = false + settings.predictivePaceWarningNotificationsEnabled = true + + #expect(!view.controlsEnabled) + #expect(!inheritedView.controlsEnabled) + + CodexBarLocalizationOverride.$appLanguage.withValue("en") { + #expect(view.footerText == "Quota warning notifications and usage-bar markers are disabled. " + + "Enable either to edit these saved settings.") + } + + settings.quotaWarningNotificationsEnabled = true + + #expect(view.controlsEnabled) + #expect(inheritedView.controlsEnabled) + #expect(view.overrideMode(for: .session) == .custom) + #expect(view.overrideMode(for: .weekly) == .off) + #expect(inheritedView.overrideMode(for: .session) == .global) + #expect(inheritedView.overrideMode(for: .weekly) == .global) + + CodexBarLocalizationOverride.$appLanguage.withValue("en") { + #expect(view.footerText == "Uses the global quota warning settings unless a window is customized here.") + } + } + + @Test + func `provider quota warning mode binding applies global custom and off transitions`() { + let settings = Self.makeSettingsStore(suite: "PreferencesPaneSmokeTests-provider-quota-warning-mode-binding") + settings.quotaWarningNotificationsEnabled = true + settings.setQuotaWarningWindowEnabled(.session, enabled: true) + settings.setQuotaWarningThresholds(.session, thresholds: [50, 20]) + + let view = ProviderQuotaWarningSettingsView(provider: .codex, settings: settings) + let mode = view.overrideModeBinding(for: .session) + + #expect(mode.wrappedValue == .global) + + mode.wrappedValue = .custom + #expect(mode.wrappedValue == .custom) + #expect(settings.hasQuotaWarningOverride(provider: .codex, window: .session)) + #expect(settings.quotaWarningEnabled(provider: .codex, window: .session)) + #expect(settings.providerConfig(for: .codex)?.quotaWarnings?.session?.thresholds == nil) + #expect(settings.resolvedQuotaWarningThresholds(provider: .codex, window: .session) == [50, 20]) + #expect(view.shouldCommitThresholdEditorOnDisappear(for: .session)) + + settings.setQuotaWarningThresholds(provider: .codex, window: .session, thresholds: [70, 30]) + mode.wrappedValue = .off + #expect(mode.wrappedValue == .off) + #expect(settings.hasQuotaWarningOverride(provider: .codex, window: .session)) + #expect(!settings.quotaWarningEnabled(provider: .codex, window: .session)) + #expect(settings.resolvedQuotaWarningThresholds(provider: .codex, window: .session) == [70, 30]) + #expect(view.shouldCommitThresholdEditorOnDisappear(for: .session)) + + mode.wrappedValue = .custom + #expect(mode.wrappedValue == .custom) + #expect(settings.quotaWarningEnabled(provider: .codex, window: .session)) + #expect(settings.explicitQuotaWarningThresholds(provider: .codex, window: .session) == [70, 30]) + #expect(settings.resolvedQuotaWarningThresholds(provider: .codex, window: .session) == [70, 30]) + + mode.wrappedValue = .global + #expect(mode.wrappedValue == .global) + #expect(!settings.hasQuotaWarningOverride(provider: .codex, window: .session)) + #expect(settings.quotaWarningEnabled(provider: .codex, window: .session)) + #expect(settings.resolvedQuotaWarningThresholds(provider: .codex, window: .session) == [50, 20]) + #expect(!view.shouldCommitThresholdEditorOnDisappear(for: .session)) + + mode.wrappedValue = .custom + #expect(settings.providerConfig(for: .codex)?.quotaWarnings?.session?.thresholds == nil) + + mode.wrappedValue = .off + let disabledInheritedConfig = settings.providerConfig(for: .codex)?.quotaWarnings?.session + #expect(disabledInheritedConfig?.enabled == false) + #expect(disabledInheritedConfig?.thresholds == nil) + #expect(settings.resolvedQuotaWarningThresholds(provider: .codex, window: .session) == [50, 20]) + #expect(view.shouldCommitThresholdEditorOnDisappear(for: .session)) + } + @Test func `language preference updates global localization resolver`() { let previousLanguage = UserDefaults.standard.object(forKey: "appLanguage") @@ -74,15 +471,102 @@ struct PreferencesPaneSmokeTests { settings.appLanguage = "zh-Hans" #expect(UserDefaults.standard.string(forKey: "appLanguage") == "zh-Hans") - #expect(L("tab_general") == "通用") - #expect(L("quota_warning_notifications_title") == "配额预警通知") - #expect(L("show_provider_storage_usage_title") == "显示提供商存储用量") + CodexBarLocalizationOverride.$appLanguage.withValue("zh-Hans") { + #expect(L("tab_general") == "通用") + #expect(L("threshold_warnings_title") == "阈值预警") + #expect(L("show_provider_storage_usage_title") == "显示提供商存储用量") + } + + settings.appLanguage = "ja" + + #expect(UserDefaults.standard.string(forKey: "appLanguage") == "ja") + CodexBarLocalizationOverride.$appLanguage.withValue("ja") { + #expect(L("language_title") == "言語") + #expect(L("start_at_login_title") == "ログイン時に起動") + #expect(L("quit_app") == "CodexBar を終了") + } + + settings.appLanguage = "id" + + #expect(UserDefaults.standard.string(forKey: "appLanguage") == "id") + CodexBarLocalizationOverride.$appLanguage.withValue("id") { + #expect(L("language_title") == "Bahasa") + #expect(L("start_at_login_title") == "Mulai saat Login") + #expect(L("quit_app") == "Keluar CodexBar") + } + } + + @Test + func `language preference clears stale app level AppleLanguages override`() { + let previousLanguage = UserDefaults.standard.object(forKey: "appLanguage") + let previousAppleLanguages = UserDefaults.standard.object(forKey: "AppleLanguages") + defer { + if let previousLanguage { + UserDefaults.standard.set(previousLanguage, forKey: "appLanguage") + } else { + UserDefaults.standard.removeObject(forKey: "appLanguage") + } + if let previousAppleLanguages { + UserDefaults.standard.set(previousAppleLanguages, forKey: "AppleLanguages") + } else { + UserDefaults.standard.removeObject(forKey: "AppleLanguages") + } + } + + let staleOverride = ["zz-StaleLanguageOverride"] + UserDefaults.standard.set(staleOverride, forKey: "AppleLanguages") + + let settings = Self.makeSettingsStore(suite: "PreferencesPaneSmokeTests-language-system") + settings.appLanguage = "ko" + + #expect(UserDefaults.standard.string(forKey: "appLanguage") == "ko") + #expect(UserDefaults.standard.object(forKey: "AppleLanguages") as? [String] != staleOverride) + + settings.appLanguage = "" + + #expect(UserDefaults.standard.object(forKey: "appLanguage") == nil) + #expect(UserDefaults.standard.object(forKey: "AppleLanguages") as? [String] != staleOverride) } - private static func makeSettingsStore(suite: String) -> SettingsStore { + @Test + func `german app language resolves localized labels`() { + let settings = Self.makeSettingsStore(suite: "PreferencesPaneSmokeTests-language-de") + settings.appLanguage = "de" + + #expect(UserDefaults.standard.string(forKey: "appLanguage") == "de") + CodexBarLocalizationOverride.$appLanguage.withValue("de") { + #expect(L("tab_general") == "Allgemein") + #expect(L("language_title") == "Sprache") + #expect(L("quit_app") == "CodexBar beenden") + #expect(L("display_mode_reset_time") == "Zurücksetzungszeit") + #expect(L("display_mode_reset_time_desc").contains("↻ 15:56")) + #expect(L("vertex_ai_login_instructions").contains("\n\n1. Öffnen Sie Terminal")) + #expect(!L("vertex_ai_login_instructions").contains("\\n")) + } + } + + @Test + func `italian language preference resolves italian strings`() { + let settings = Self.makeSettingsStore(suite: "PreferencesPaneSmokeTests-language-italian") + settings.appLanguage = "it" + + #expect(UserDefaults.standard.string(forKey: "appLanguage") == "it") + CodexBarLocalizationOverride.$appLanguage.withValue("it") { + #expect(L("language_title") == "Lingua") + #expect(L("section_system") == "Sistema") + #expect(L("language_italian") == "Italiano") + #expect(L("tab_menu_bar") == "Barra menu") + #expect(L("tab_advanced") == "Avanzate") + #expect(L("quit_app") == "Esci da CodexBar") + } + } + + private static func makeSettingsStore(suite: String, reset: Bool = true) -> SettingsStore { let defaults = UserDefaults(suiteName: suite)! - defaults.removePersistentDomain(forName: suite) - let configStore = testConfigStore(suiteName: suite) + if reset { + defaults.removePersistentDomain(forName: suite) + } + let configStore = testConfigStore(suiteName: suite, reset: reset) return SettingsStore( userDefaults: defaults, @@ -97,7 +581,6 @@ struct PreferencesPaneSmokeTests { minimaxCookieStore: InMemoryMiniMaxCookieStore(), minimaxAPITokenStore: InMemoryMiniMaxAPITokenStore(), kimiTokenStore: InMemoryKimiTokenStore(), - kimiK2TokenStore: InMemoryKimiK2TokenStore(), augmentCookieStore: InMemoryCookieHeaderStore(), ampCookieStore: InMemoryCookieHeaderStore(), copilotTokenStore: InMemoryCopilotTokenStore(), diff --git a/Tests/CodexBarTests/PreferencesSelectionTests.swift b/Tests/CodexBarTests/PreferencesSelectionTests.swift new file mode 100644 index 0000000000..f02b44c9f6 --- /dev/null +++ b/Tests/CodexBarTests/PreferencesSelectionTests.swift @@ -0,0 +1,46 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +@MainActor +struct PreferencesSelectionTests { + @Test + func `pane persistence tokens round-trip`() { + let panes: [SettingsPane] = [ + .general, + .usageSpend, + .notifications, + .menuBar, + .menu, + .advanced, + .about, + .debug, + .provider(.claude), + ] + for pane in panes { + #expect(SettingsPane(persistenceToken: pane.persistenceToken) == pane) + } + #expect(SettingsPane(persistenceToken: "provider:definitely-not-a-provider") == nil) + #expect(SettingsPane(persistenceToken: "") == nil) + } + + @Test + func `legacy display token restores the menu bar pane`() { + #expect(SettingsPane(persistenceToken: "display") == .menuBar) + } + + @Test + func `selection restores persisted pane and saves changes`() throws { + let suite = "PreferencesSelectionTests-\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suite)) + defer { defaults.removePersistentDomain(forName: suite) } + + #expect(PreferencesSelection(userDefaults: defaults).pane == .general) + + let selection = PreferencesSelection(userDefaults: defaults) + selection.pane = .provider(.codex) + #expect(defaults.string(forKey: PreferencesSelection.paneDefaultsKey) == "provider:codex") + #expect(PreferencesSelection(userDefaults: defaults).pane == .provider(.codex)) + } +} diff --git a/Tests/CodexBarTests/ProviderConfigEnvironmentTests.swift b/Tests/CodexBarTests/ProviderConfigEnvironmentTests.swift index c2bf3a36ef..ae13927d66 100644 --- a/Tests/CodexBarTests/ProviderConfigEnvironmentTests.swift +++ b/Tests/CodexBarTests/ProviderConfigEnvironmentTests.swift @@ -2,6 +2,18 @@ import CodexBarCore import Testing struct ProviderConfigEnvironmentTests { + @Test + func `applies API key override for amp`() { + let config = ProviderConfig(id: .amp, apiKey: "sgamp-config") + let env = ProviderConfigEnvironment.applyAPIKeyOverride( + base: [:], + provider: .amp, + config: config) + + #expect(env[AmpSettingsReader.apiTokenKey] == "sgamp-config") + #expect(ProviderTokenResolver.ampToken(environment: env) == "sgamp-config") + } + @Test func `applies API key override for zai`() { let config = ProviderConfig(id: .zai, apiKey: "z-token") @@ -11,6 +23,8 @@ struct ProviderConfigEnvironmentTests { config: config) #expect(env[ZaiSettingsReader.apiTokenKey] == "z-token") + #expect(env[ZaiSettingsReader.bigModelOrganizationKey] == nil) + #expect(env[ZaiSettingsReader.bigModelProjectKey] == nil) } @Test @@ -51,6 +65,223 @@ struct ProviderConfigEnvironmentTests { #expect(ProviderTokenResolver.doubaoToken(environment: env) == "db-token") } + @Test + func `preserves doubao ark API key when environment secret key is present`() { + let config = ProviderConfig(id: .doubao, apiKey: "ark-config") + let env = ProviderConfigEnvironment.applyAPIKeyOverride( + base: [ + DoubaoSettingsReader.secretAccessKeyEnvironmentKeys[0]: "sk-env", + ], + provider: .doubao, + config: config) + + #expect(env[DoubaoSettingsReader.apiKeyEnvironmentKeys[0]] == "ark-config") + #expect(env[DoubaoSettingsReader.accessKeyIDEnvironmentKeys[0]] == nil) + #expect(env[DoubaoSettingsReader.secretAccessKeyEnvironmentKeys[0]] == nil) + #expect(DoubaoSettingsReader.codingPlanCredentials(environment: env) == nil) + #expect(ProviderTokenResolver.doubaoToken(environment: env) == "ark-config") + } + + @Test + func `preserves doubao ark API key when config secret key is present`() { + let config = ProviderConfig( + id: .doubao, + apiKey: "ark-config", + secretKey: "sk-config") + let env = ProviderConfigEnvironment.applyAPIKeyOverride( + base: [:], + provider: .doubao, + config: config) + + #expect(env[DoubaoSettingsReader.apiKeyEnvironmentKeys[0]] == "ark-config") + #expect(env[DoubaoSettingsReader.accessKeyIDEnvironmentKeys[0]] == nil) + #expect(env[DoubaoSettingsReader.secretAccessKeyEnvironmentKeys[0]] == nil) + #expect(DoubaoSettingsReader.codingPlanCredentials(environment: env) == nil) + #expect(ProviderTokenResolver.doubaoToken(environment: env) == "ark-config") + } + + @Test + func `doubao ark API key config overrides environment coding plan credentials`() { + let config = ProviderConfig(id: .doubao, apiKey: "ark-config") + let env = ProviderConfigEnvironment.applyAPIKeyOverride( + base: [ + DoubaoSettingsReader.accessKeyIDEnvironmentKeys[0]: "AKLT-env", + DoubaoSettingsReader.secretAccessKeyEnvironmentKeys[0]: "sk-env", + DoubaoSettingsReader.regionEnvironmentKeys[0]: "cn-shanghai", + ], + provider: .doubao, + config: config) + + #expect(env[DoubaoSettingsReader.apiKeyEnvironmentKeys[0]] == "ark-config") + #expect(env[DoubaoSettingsReader.accessKeyIDEnvironmentKeys[0]] == nil) + #expect(env[DoubaoSettingsReader.secretAccessKeyEnvironmentKeys[0]] == nil) + #expect(DoubaoSettingsReader.codingPlanCredentials(environment: env) == nil) + #expect(ProviderTokenResolver.doubaoToken(environment: env) == "ark-config") + } + + @Test + func `reads doubao volcengine secret key alias`() { + let env = [ + DoubaoSettingsReader.accessKeyIDEnvironmentKeys[1]: "AKLT-env", + "VOLCENGINE_SECRET_KEY": "sk-env", + ] + + #expect(DoubaoSettingsReader.secretAccessKeyEnvironmentKeys.contains("VOLCENGINE_SECRET_KEY")) + #expect(DoubaoSettingsReader.secretAccessKey(environment: env) == "sk-env") + #expect(DoubaoSettingsReader.codingPlanCredentials(environment: env)?.accessKeyID == "AKLT-env") + #expect(DoubaoSettingsReader.codingPlanCredentials(environment: env)?.secretAccessKey == "sk-env") + } + + @Test + func `reads doubao volc sdk credential aliases`() { + let env = [ + "VOLC_ACCESSKEY": "AKLT-volc", + "VOLC_SECRETKEY": "sk-volc", + "VOLC_REGION": "cn-shanghai", + ] + + #expect(DoubaoSettingsReader.accessKeyIDEnvironmentKeys.contains("VOLC_ACCESSKEY")) + #expect(DoubaoSettingsReader.secretAccessKeyEnvironmentKeys.contains("VOLC_SECRETKEY")) + #expect(DoubaoSettingsReader.regionEnvironmentKeys.contains("VOLC_REGION")) + #expect(DoubaoSettingsReader.codingPlanCredentials(environment: env)?.accessKeyID == "AKLT-volc") + #expect(DoubaoSettingsReader.codingPlanCredentials(environment: env)?.secretAccessKey == "sk-volc") + #expect(DoubaoSettingsReader.codingPlanCredentials(environment: env)?.region == "cn-shanghai") + } + + @Test + func `does not project incomplete doubao access key as ark API key`() { + let config = ProviderConfig(id: .doubao, apiKey: "AKLT-config") + let env = ProviderConfigEnvironment.applyAPIKeyOverride( + base: [:], + provider: .doubao, + config: config) + + #expect(env[DoubaoSettingsReader.accessKeyIDEnvironmentKeys[0]] == nil) + #expect(env[DoubaoSettingsReader.apiKeyEnvironmentKeys[0]] == nil) + #expect(DoubaoSettingsReader.codingPlanCredentials(environment: env) == nil) + #expect(ProviderTokenResolver.doubaoToken(environment: env) == nil) + } + + @Test + func `keeps base doubao ark API key when config access key lacks secret`() { + let config = ProviderConfig(id: .doubao, apiKey: "AKLT-config") + let env = ProviderConfigEnvironment.applyAPIKeyOverride( + base: [ + DoubaoSettingsReader.apiKeyEnvironmentKeys[0]: "ark-env", + ], + provider: .doubao, + config: config) + + #expect(env[DoubaoSettingsReader.accessKeyIDEnvironmentKeys[0]] == nil) + #expect(env[DoubaoSettingsReader.apiKeyEnvironmentKeys[0]] == "ark-env") + #expect(DoubaoSettingsReader.codingPlanCredentials(environment: env) == nil) + #expect(ProviderTokenResolver.doubaoToken(environment: env) == "ark-env") + } + + @Test + func `applies volcengine access key override for doubao coding plan`() { + let config = ProviderConfig( + id: .doubao, + apiKey: "AKLT-config", + secretKey: "sk-config", + region: "cn-shanghai") + let env = ProviderConfigEnvironment.applyAPIKeyOverride( + base: [:], + provider: .doubao, + config: config) + + #expect(env[DoubaoSettingsReader.accessKeyIDEnvironmentKeys[0]] == "AKLT-config") + #expect(env[DoubaoSettingsReader.secretAccessKeyEnvironmentKeys[0]] == "sk-config") + #expect(env[DoubaoSettingsReader.regionEnvironmentKeys[0]] == "cn-shanghai") + #expect(DoubaoSettingsReader.codingPlanCredentials(environment: env)?.accessKeyID == "AKLT-config") + #expect(DoubaoSettingsReader.codingPlanCredentials(environment: env)?.secretAccessKey == "sk-config") + #expect(DoubaoSettingsReader.codingPlanCredentials(environment: env)?.region == "cn-shanghai") + } + + @Test + func `merges doubao config access key with environment secret key`() { + let config = ProviderConfig( + id: .doubao, + apiKey: "AKLT-config") + let env = ProviderConfigEnvironment.applyAPIKeyOverride( + base: [ + DoubaoSettingsReader.secretAccessKeyEnvironmentKeys[0]: "sk-env", + DoubaoSettingsReader.regionEnvironmentKeys[2]: "cn-shanghai", + ], + provider: .doubao, + config: config) + + #expect(env[DoubaoSettingsReader.accessKeyIDEnvironmentKeys[0]] == "AKLT-config") + #expect(env[DoubaoSettingsReader.secretAccessKeyEnvironmentKeys[0]] == "sk-env") + #expect(env[DoubaoSettingsReader.regionEnvironmentKeys[0]] == "cn-shanghai") + #expect(env[DoubaoSettingsReader.apiKeyEnvironmentKeys[0]] == nil) + #expect(DoubaoSettingsReader.codingPlanCredentials(environment: env)?.accessKeyID == "AKLT-config") + #expect(DoubaoSettingsReader.codingPlanCredentials(environment: env)?.secretAccessKey == "sk-env") + #expect(DoubaoSettingsReader.codingPlanCredentials(environment: env)?.region == "cn-shanghai") + } + + @Test + func `merges doubao environment access key with config secret key`() { + let config = ProviderConfig( + id: .doubao, + secretKey: "sk-config") + let env = ProviderConfigEnvironment.applyAPIKeyOverride( + base: [ + DoubaoSettingsReader.accessKeyIDEnvironmentKeys[0]: "AKLT-env", + DoubaoSettingsReader.regionEnvironmentKeys[1]: "cn-beijing", + ], + provider: .doubao, + config: config) + + #expect(env[DoubaoSettingsReader.accessKeyIDEnvironmentKeys[0]] == "AKLT-env") + #expect(env[DoubaoSettingsReader.secretAccessKeyEnvironmentKeys[0]] == "sk-config") + #expect(env[DoubaoSettingsReader.regionEnvironmentKeys[0]] == "cn-beijing") + #expect(DoubaoSettingsReader.codingPlanCredentials(environment: env)?.accessKeyID == "AKLT-env") + #expect(DoubaoSettingsReader.codingPlanCredentials(environment: env)?.secretAccessKey == "sk-config") + #expect(DoubaoSettingsReader.codingPlanCredentials(environment: env)?.region == "cn-beijing") + } + + @Test + func `applies cookie header override for sakana`() { + let config = ProviderConfig(id: .sakana, cookieHeader: "Cookie: session=abc") + let env = ProviderConfigEnvironment.applyProviderConfigOverrides( + base: [:], + provider: .sakana, + config: config) + + #expect(env[SakanaSettingsReader.cookieHeaderKey] == "Cookie: session=abc") + #expect(SakanaSettingsReader.cookieHeader(environment: env) == "session=abc") + } + + @Test + func `applies cookie header override for longcat`() { + let config = ProviderConfig( + id: .longcat, + cookieHeader: "Cookie: passport_token=abc; uid=42", + cookieSource: .manual) + let env = ProviderConfigEnvironment.applyProviderConfigOverrides( + base: [:], + provider: .longcat, + config: config) + + #expect(env[LongCatSettingsReader.cookieHeaderKey] == "Cookie: passport_token=abc; uid=42") + #expect(LongCatSettingsReader.cookieHeader(environment: env) == "Cookie: passport_token=abc; uid=42") + } + + @Test + func `does not expose stored longcat cookie outside manual mode`() { + for source in [ProviderCookieSource.auto, .off] { + let config = ProviderConfig(id: .longcat, cookieHeader: "stale=1", cookieSource: source) + let env = ProviderConfigEnvironment.applyProviderConfigOverrides( + base: [:], + provider: .longcat, + config: config) + + #expect(env[LongCatSettingsReader.cookieHeaderKey] == nil) + } + } + + @Test func `applies API key override for moonshot`() { let config = ProviderConfig(id: .moonshot, apiKey: "moon-token") let env = ProviderConfigEnvironment.applyAPIKeyOverride( @@ -65,6 +296,25 @@ struct ProviderConfigEnvironmentTests { #expect(env[key] == "moon-token") } + @Test + func `applies Kimi API key and base URL config overrides`() throws { + let config = ProviderConfig( + id: .kimi, + apiKey: "kimi-api-token", + enterpriseHost: "https://proxy.example.com/kimi") + let env = ProviderConfigEnvironment.applyProviderConfigOverrides( + base: [:], + provider: .kimi, + config: config) + + #expect(env["KIMI_CODE_API_KEY"] == "kimi-api-token") + #expect(env["KIMI_API_KEY"] == nil) + #expect(env[KimiSettingsReader.codeAPIBaseURLEnvironmentKey] == "https://proxy.example.com/kimi") + #expect(ProviderTokenResolver.kimiAPIToken(environment: env) == "kimi-api-token") + #expect(try KimiSettingsReader.codeAPIBaseURL(environment: env).absoluteString == + "https://proxy.example.com/kimi") + } + @Test func `applies API key override for elevenlabs`() { let config = ProviderConfig(id: .elevenlabs, apiKey: "xi-token") @@ -77,6 +327,19 @@ struct ProviderConfigEnvironmentTests { #expect(ProviderTokenResolver.elevenLabsToken(environment: env) == "xi-token") } + @Test + func `applies API key override for NeuralWatt`() { + let config = ProviderConfig(id: .neuralwatt, apiKey: "sk-neuralwatt-config") + let env = ProviderConfigEnvironment.applyAPIKeyOverride( + base: [:], + provider: .neuralwatt, + config: config) + + #expect(env[NeuralWattSettingsReader.apiKeyEnvironmentKey] == "sk-neuralwatt-config") + #expect(ProviderTokenResolver.neuralWattToken(environment: env) == "sk-neuralwatt-config") + #expect(ProviderConfigEnvironment.supportsAPIKeyOverride(for: .neuralwatt)) + } + @Test func `applies API key override for groq`() { let config = ProviderConfig(id: .groq, apiKey: "gsk-token") @@ -105,6 +368,22 @@ struct ProviderConfigEnvironmentTests { #expect(ProviderTokenResolver.llmProxyToken(environment: env) == "proxy-token") } + @Test + func `applies LiteLLM config overrides`() { + let config = ProviderConfig( + id: .litellm, + apiKey: "litellm-token", + enterpriseHost: "https://litellm.example.com/v1") + let env = ProviderConfigEnvironment.applyProviderConfigOverrides( + base: [:], + provider: .litellm, + config: config) + + #expect(env[LiteLLMSettingsReader.apiKeyEnvironmentKey] == "litellm-token") + #expect(env[LiteLLMSettingsReader.baseURLEnvironmentKey] == "https://litellm.example.com/v1") + #expect(ProviderTokenResolver.liteLLMToken(environment: env) == "litellm-token") + } + @Test func `openai config override uses preferred admin key environment`() { let config = ProviderConfig(id: .openai, apiKey: "config-openai-token") @@ -336,6 +615,41 @@ struct ProviderConfigEnvironmentTests { #expect(ProviderTokenResolver.deepseekToken(environment: env) == nil) } + @Test + func `projects the legacy DeepSeek Platform token and stable profile identifier`() { + let config = ProviderConfig( + id: .deepseek, + apiKey: "legacy-api-key", + cookieHeader: "browser-platform-token", + deepseekProfileID: "/profiles/Profile 2", + deepseekProfileScope: "account-id") + let env = ProviderConfigEnvironment.applyProviderConfigOverrides( + base: [:], + provider: .deepseek, + config: config) + + #expect(env[DeepSeekSettingsReader.apiKeyEnvironmentKey] == nil) + #expect(env[DeepSeekSettingsReader.platformTokenEnvironmentKey] == "browser-platform-token") + #expect(env[DeepSeekSettingsReader.profileIDEnvironmentKey] == "chrome:Profile 2") + #expect(env[DeepSeekSettingsReader.profileScopeEnvironmentKey] == "account-id") + } + + @Test + func `normalization preserves a legacy DeepSeek browser token and canonicalizes the profile path`() throws { + let config = CodexBarConfig(providers: [ + ProviderConfig( + id: .deepseek, + cookieHeader: "browser-platform-token", + deepseekProfileID: "/profiles/Profile 2", + deepseekProfileScope: " account-id "), + ]).normalized() + let deepseek = try #require(config.providerConfig(for: .deepseek)) + + #expect(deepseek.cookieHeader == "browser-platform-token") + #expect(deepseek.deepseekProfileID == "chrome:Profile 2") + #expect(deepseek.deepseekProfileScope == "account-id") + } + @Test func `applies API key override for kilo`() { let config = ProviderConfig(id: .kilo, apiKey: "kilo-token") @@ -348,6 +662,31 @@ struct ProviderConfigEnvironmentTests { #expect(ProviderTokenResolver.kiloToken(environment: env, authFileURL: nil) == "kilo-token") } + @Test + func `applies API key override for factory`() { + let config = ProviderConfig(id: .factory, apiKey: "fk-config-token") + let env = ProviderConfigEnvironment.applyAPIKeyOverride( + base: [:], + provider: .factory, + config: config) + + #expect(env[FactorySettingsReader.apiTokenKey] == "fk-config-token") + #expect(FactorySettingsReader.apiKey(environment: env) == "fk-config-token") + #expect(ProviderConfigEnvironment.supportsAPIKeyOverride(for: .factory)) + } + + @Test + func `factory config api key wins over existing FACTORY_API_KEY`() { + let config = ProviderConfig(id: .factory, apiKey: "fk-config-token") + let env = ProviderConfigEnvironment.applyAPIKeyOverride( + base: [FactorySettingsReader.apiTokenKey: "fk-env-token"], + provider: .factory, + config: config) + + #expect(env[FactorySettingsReader.apiTokenKey] == "fk-config-token") + #expect(FactorySettingsReader.apiKey(environment: env) == "fk-config-token") + } + @Test func `open router config override wins over environment token`() { let config = ProviderConfig(id: .openrouter, apiKey: "config-token") @@ -448,4 +787,21 @@ struct ProviderConfigEnvironmentTests { #expect(env[ZaiSettingsReader.apiTokenKey] == "existing") } + + @Test + func `applies API key override for poe`() { + let config = ProviderConfig(id: .poe, apiKey: "poe-token") + let env = ProviderConfigEnvironment.applyAPIKeyOverride( + base: [:], + provider: .poe, + config: config) + + #expect(env[PoeSettingsReader.apiKeyEnvironmentKey] == "poe-token") + #expect(ProviderTokenResolver.poeToken(environment: env) == "poe-token") + } + + @Test + func `poe supports API key override`() { + #expect(ProviderConfigEnvironment.supportsAPIKeyOverride(for: .poe) == true) + } } diff --git a/Tests/CodexBarTests/ProviderCookieSettingsResolverTests.swift b/Tests/CodexBarTests/ProviderCookieSettingsResolverTests.swift new file mode 100644 index 0000000000..5d88ebf17c --- /dev/null +++ b/Tests/CodexBarTests/ProviderCookieSettingsResolverTests.swift @@ -0,0 +1,91 @@ +import CodexBarCore +import Foundation +import Testing + +struct ProviderCookieSettingsResolverTests { + @Test + func `shared cookie settings preserve Alibaba token plan defaults`() { + let settings = ProviderSettingsSnapshot.AlibabaTokenPlanProviderSettings() + + #expect(settings.cookieSource == .auto) + #expect(settings.manualCookieHeader == nil) + } + + @Test + func `provider cookie settings remain distinct nominal types`() { + let cursor = ProviderSettingsSnapshot.CursorProviderSettings( + cookieSource: .auto, + manualCookieHeader: nil) + let factory = ProviderSettingsSnapshot.FactoryProviderSettings( + cookieSource: .auto, + manualCookieHeader: nil) + + #expect(Self.providerName(cursor) == "cursor") + #expect(Self.providerName(factory) == "factory") + } + + @Test + func `selected cookie account overrides configured credentials`() { + let settings = ProviderCookieSettingsResolver.resolve( + provider: .manus, + configuredSource: .auto, + configuredHeader: "session_id=config", + selectedAccount: Self.account(token: "account")) + + #expect(settings.cookieSource == .manual) + #expect(settings.manualCookieHeader == "session_id=account") + } + + @Test + func `configured credentials remain when no account is selected`() { + let settings = ProviderCookieSettingsResolver.resolve( + provider: .cursor, + configuredSource: .manual, + configuredHeader: "Cookie: session=config", + selectedAccount: nil) + + #expect(settings.cookieSource == .manual) + #expect(settings.manualCookieHeader == "Cookie: session=config") + } + + @Test + func `environment token accounts do not become cookie credentials`() { + let settings = ProviderCookieSettingsResolver.resolve( + provider: .zai, + configuredSource: .off, + configuredHeader: nil, + selectedAccount: Self.account(token: "api-token")) + + #expect(settings.cookieSource == .off) + #expect(settings.manualCookieHeader == nil) + } + + @Test + func `providers without token account support ignore selected account`() { + let settings = ProviderCookieSettingsResolver.resolve( + provider: .mimo, + configuredSource: .auto, + configuredHeader: "configured=true", + selectedAccount: Self.account(token: "account=true")) + + #expect(settings.cookieSource == .auto) + #expect(settings.manualCookieHeader == "configured=true") + } + + private static func account(token: String) -> ProviderTokenAccount { + ProviderTokenAccount( + id: UUID(), + label: "Test", + token: token, + addedAt: 0, + lastUsed: nil) + } + + private static func providerName(_: ProviderSettingsSnapshot.CursorProviderSettings) -> String { + "cursor" + } + + private static func providerName(_: ProviderSettingsSnapshot.FactoryProviderSettings) -> String { + "factory" + } +} diff --git a/Tests/CodexBarTests/ProviderDetectionPolicyTests.swift b/Tests/CodexBarTests/ProviderDetectionPolicyTests.swift new file mode 100644 index 0000000000..a3ad4216ac --- /dev/null +++ b/Tests/CodexBarTests/ProviderDetectionPolicyTests.swift @@ -0,0 +1,44 @@ +import Testing +@testable import CodexBar +@testable import CodexBarCore + +struct ProviderDetectionPolicyTests { + @Test + func `fresh install detects Codex and Claude Desktop without unconfigured Gemini`() { + let enabled = ProviderDetectionPolicy.enabledProviders(signals: .init( + codexCLIInstalled: true, + claudeCLIInstalled: false, + claudeDesktopInstalled: true, + geminiCLIInstalled: true, + geminiConfigured: false, + antigravityAvailable: false)) + + #expect(enabled == [.codex, .claude]) + } + + @Test + func `configured Gemini CLI is detected`() { + let enabled = ProviderDetectionPolicy.enabledProviders(signals: .init( + codexCLIInstalled: false, + claudeCLIInstalled: false, + claudeDesktopInstalled: false, + geminiCLIInstalled: true, + geminiConfigured: true, + antigravityAvailable: false)) + + #expect(enabled == [.gemini]) + } + + @Test + func `Codex remains the fallback when no provider source is available`() { + let enabled = ProviderDetectionPolicy.enabledProviders(signals: .init( + codexCLIInstalled: false, + claudeCLIInstalled: false, + claudeDesktopInstalled: false, + geminiCLIInstalled: false, + geminiConfigured: false, + antigravityAvailable: false)) + + #expect(enabled == [.codex]) + } +} diff --git a/Tests/CodexBarTests/ProviderDiagnosticExportTests.swift b/Tests/CodexBarTests/ProviderDiagnosticExportTests.swift index 71065bdffe..5209e3596f 100644 --- a/Tests/CodexBarTests/ProviderDiagnosticExportTests.swift +++ b/Tests/CodexBarTests/ProviderDiagnosticExportTests.swift @@ -35,7 +35,10 @@ struct ProviderDiagnosticExportTests { #expect(json.contains("\"provider\"")) #expect(json.contains("\"openai\"")) + #expect(json.contains("\"platform\"")) #expect(json.contains("\"auth\"")) + #expect(json.contains("\"dataConfidence\"")) + #expect(json.contains("\"unknown\"")) #expect(json.contains("\"hasResetDescription\"")) #expect(!json.contains("sk-cp-")) #expect(!json.contains("sk-api-")) @@ -45,6 +48,204 @@ struct ProviderDiagnosticExportTests { #expect(!json.contains("localizedDescription")) } + @Test + func `diagnostic export decodes legacy schema without platform metadata`() throws { + let export = ProviderDiagnosticExport( + timestamp: Date(timeIntervalSince1970: 1_700_000_000), + provider: "openai", + displayName: "OpenAI", + source: "api", + sourceMode: "auto", + auth: ProviderDiagnosticAuthSummary(configured: true, modes: ["api"]), + usage: nil, + fetchAttempts: [], + error: nil, + settings: ProviderDiagnosticSettingsSummary(sourceMode: .auto), + details: nil) + var object = try #require( + try JSONSerialization.jsonObject(with: Data(self.json(export).utf8)) as? [String: Any]) + object.removeValue(forKey: "platform") + object.removeValue(forKey: "appVersion") + + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + let decoded = try decoder.decode( + ProviderDiagnosticExport.self, + from: JSONSerialization.data(withJSONObject: object)) + + #expect(decoded.platform == ProviderDiagnosticPlatform.current) + #expect(decoded.appVersion == nil) + } + + @Test + func `usage snapshot defaults legacy payloads to unknown confidence without reencoding unknown`() throws { + let json = """ + { + "primary": { + "usedPercent": 42, + "windowMinutes": 300, + "hasResetDescription": false + }, + "secondary": null, + "tertiary": null, + "updatedAt": "2023-11-14T22:13:20Z" + } + """ + + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + let snapshot = try decoder.decode(UsageSnapshot.self, from: Data(json.utf8)) + #expect(snapshot.dataConfidence == .unknown) + + let encoded = try self.json(snapshot) + #expect(!encoded.contains("dataConfidence")) + } + + @Test + func `usage snapshot preserves explicit confidence through Codable`() throws { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let snapshot = UsageSnapshot( + primary: RateWindow( + usedPercent: 12, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(18000), + resetDescription: nil), + secondary: nil, + updatedAt: now, + dataConfidence: .exact) + + let encoded = try self.json(snapshot) + #expect(encoded.contains("\"dataConfidence\" : \"exact\"")) + + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + let decoded = try decoder.decode(UsageSnapshot.self, from: Data(encoded.utf8)) + #expect(decoded.dataConfidence == .exact) + } + + @Test + func `usage snapshot treats future confidence values as unknown`() throws { + let json = """ + { + "primary": null, + "secondary": null, + "tertiary": null, + "updatedAt": "2023-11-14T22:13:20Z", + "dataConfidence": "future" + } + """ + + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + let snapshot = try decoder.decode(UsageSnapshot.self, from: Data(json.utf8)) + + #expect(snapshot.dataConfidence == .unknown) + #expect(try !self.json(snapshot).contains("dataConfidence")) + } + + @Test + func `diagnostic usage summary includes confidence`() { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let summary = ProviderDiagnosticUsageSummary(from: UsageSnapshot( + primary: RateWindow( + usedPercent: 12, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(18000), + resetDescription: nil), + secondary: nil, + updatedAt: now, + dataConfidence: .exact)) + + #expect(summary.dataConfidence == "exact") + } + + @Test + func `diagnostic usage summary defaults legacy payloads to unknown confidence`() throws { + let json = """ + { + "updatedAt": "2023-11-14T22:13:20Z", + "windows": [], + "extraWindowCount": 0, + "providerCostPresent": false, + "providerSpecificData": [] + } + """ + + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + let summary = try decoder.decode( + ProviderDiagnosticUsageSummary.self, + from: Data(json.utf8)) + + #expect(summary.dataConfidence == "unknown") + #expect(try self.json(summary).contains("\"dataConfidence\" : \"unknown\"")) + } + + @Test + func `unwired provider diagnostics remain unknown confidence`() { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let snapshot = MiniMaxUsageSnapshot( + planName: "Max", + availablePrompts: 1000, + currentPrompts: 250, + remainingPrompts: 750, + windowMinutes: 300, + usedPercent: 25, + resetsAt: now.addingTimeInterval(18000), + updatedAt: now) + + let usage = snapshot.toUsageSnapshot() + let summary = ProviderDiagnosticUsageSummary(from: usage) + + #expect(usage.dataConfidence == .unknown) + #expect(summary.dataConfidence == "unknown") + #expect(summary.windows.first?.usedPercent == 25) + } + + @Test + func `diagnostic export marks named windows with unknown usage`() throws { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let summary = ProviderDiagnosticUsageSummary(from: UsageSnapshot( + primary: nil, + secondary: nil, + extraRateWindows: [ + NamedRateWindow( + id: "nebula-window", + title: "Nebula Window", + window: RateWindow( + usedPercent: 100, + windowMinutes: nil, + resetsAt: now.addingTimeInterval(3600), + resetDescription: nil), + usageKnown: false), + ], + updatedAt: now)) + + let json = try self.json(summary) + let object = try #require( + JSONSerialization.jsonObject(with: Data(json.utf8)) as? [String: Any]) + let windows = try #require(object["windows"] as? [[String: Any]]) + + #expect(windows.first?["usageKnown"] as? Bool == false) + } + + @Test + func `diagnostic rate window defaults legacy payloads to known usage`() throws { + let json = """ + { + "label": "Legacy Window", + "usedPercent": 42, + "hasResetDescription": false + } + """ + + let window = try JSONDecoder().decode( + ProviderDiagnosticRateWindow.self, + from: Data(json.utf8)) + + #expect(window.usageKnown) + } + @Test func `raw error text never appears in encoded JSON`() throws { let export = ProviderDiagnosticExport( @@ -100,6 +301,31 @@ struct ProviderDiagnosticExportTests { #expect(diagParse.category == "parse") } + @Test + func `diagnostic error maps Alibaba invalid endpoint override to configuration`() { + let error = ProviderEndpointOverrideError.alibabaCodingPlan("ALIBABA_CODING_PLAN_QUOTA_URL") + let diag = ProviderDiagnosticError(from: error, authConfigured: true) + + #expect(diag.category == "configuration") + #expect(diag.safeDescription == "Configuration issue - check provider source and settings") + } + + @Test + func `endpoint override fetch attempt stays in configuration category`() { + let error = ProviderEndpointOverrideError.minimax("MINIMAX_HOST") + let attempt = ProviderFetchAttempt( + strategyID: "minimax.web", + kind: .web, + wasAvailable: true, + errorDescription: error.localizedDescription) + + let diagError = ProviderDiagnosticError(from: error, authConfigured: true) + let diagAttempt = ProviderDiagnosticFetchAttempt(from: attempt) + + #expect(diagError.category == "configuration") + #expect(diagAttempt.errorCategory == "configuration") + } + @Test func `no available strategy maps missing auth to auth category`() { let error = ProviderFetchError.noAvailableStrategy(.minimax) @@ -115,7 +341,7 @@ struct ProviderDiagnosticExportTests { result: .failure(ProviderFetchError.noAvailableStrategy(.antigravity)), attempts: [ ProviderFetchAttempt( - strategyID: "antigravity.local", + strategyID: "antigravity.ide-local", kind: .localProbe, wasAvailable: true, errorDescription: "unauthenticated local probe"), @@ -192,26 +418,78 @@ struct ProviderDiagnosticExportTests { func `service usage maps from MiniMaxServiceUsage correctly`() throws { let now = Date(timeIntervalSince1970: 1_700_000_000) let service = MiniMaxServiceUsage( - serviceType: "Text Generation", - windowType: "5 hours", - timeRange: "10:00-15:00(UTC+8)", - usage: 750, - limit: 1000, - percent: 75, + serviceType: "General", + windowType: "Weekly", + timeRange: "Jun 15-Jun 22", + usage: 6, + limit: 150, + percent: 4, resetsAt: now.addingTimeInterval(18000), - resetDescription: "5 hours") + resetDescription: "Weekly") let diagService = MiniMaxDiagnosticServiceUsage(from: service) - #expect(diagService.displayName == "Text Generation") - #expect(diagService.percent == 75) - #expect(diagService.windowType == "5 hours") + #expect(diagService.displayName == "General") + #expect(diagService.percent == 4) + #expect(diagService.usage == 6) + #expect(diagService.limit == 150) + #expect(diagService.remaining == 144) + #expect(diagService.isUnlimited == false) + #expect(diagService.windowType == "Weekly") #expect(diagService.hasResetDescription == true) let json = try self.json(diagService) #expect(json.contains("hasResetDescription")) + #expect(json.contains(#""usage" : 6"#)) + #expect(json.contains(#""limit" : 150"#)) + #expect(json.contains(#""remaining" : 144"#)) #expect(!json.contains("resetDescription")) } + @Test + func `unlimited MiniMax diagnostic omits remaining quota`() throws { + let service = MiniMaxServiceUsage( + serviceType: "General", + windowType: "Weekly", + timeRange: "", + usage: 0, + limit: 0, + percent: 0, + isUnlimited: true, + resetsAt: nil, + resetDescription: "Unlimited") + + let diagnostic = MiniMaxDiagnosticServiceUsage(from: service) + #expect(diagnostic.isUnlimited) + #expect(diagnostic.remaining == nil) + + let json = try self.json(diagnostic) + #expect(!json.contains("remaining")) + } + + @Test + func `legacy MiniMax service diagnostic decodes without quota values`() throws { + let data = Data(#""" + { + "displayName": "General", + "percent": 4, + "windowType": "Weekly", + "resetsAt": null, + "hasResetDescription": true + } + """#.utf8) + + let diagnostic = try JSONDecoder().decode(MiniMaxDiagnosticServiceUsage.self, from: data) + + #expect(diagnostic.displayName == "General") + #expect(diagnostic.percent == 4) + #expect(diagnostic.usage == 0) + #expect(diagnostic.limit == 0) + #expect(diagnostic.remaining == nil) + #expect(!diagnostic.isUnlimited) + #expect(diagnostic.windowType == "Weekly") + #expect(diagnostic.hasResetDescription) + } + @Test func `builder creates generic safe diagnostic with error on failure`() { let outcome = ProviderFetchOutcome( @@ -230,9 +508,12 @@ struct ProviderDiagnosticExportTests { outcome: outcome, sourceMode: .auto, settings: nil, - auth: ProviderDiagnosticAuthSummary(configured: true, modes: ["apiToken"]))) + auth: ProviderDiagnosticAuthSummary(configured: true, modes: ["apiToken"]), + appVersion: "9.8.7")) #expect(diag.provider == "minimax") + #expect(diag.platform == ProviderDiagnosticPlatform.current) + #expect(diag.appVersion == "9.8.7") #expect(diag.source == "failed") #expect(diag.auth.configured == true) #expect(diag.usage == nil) diff --git a/Tests/CodexBarTests/ProviderEndpointOverrideSecurityTests.swift b/Tests/CodexBarTests/ProviderEndpointOverrideSecurityTests.swift new file mode 100644 index 0000000000..aec2741f4c --- /dev/null +++ b/Tests/CodexBarTests/ProviderEndpointOverrideSecurityTests.swift @@ -0,0 +1,232 @@ +import CodexBarCore +import Foundation +import Testing + +struct ProviderEndpointOverrideSecurityTests { + @Test + func `sibling endpoint overrides allow bracketed IPv6 literals`() throws { + let endpoint = "https://[::1]:8443/v1" + + try OpenRouterSettingsReader.validateEndpointOverrides( + environment: ["OPENROUTER_API_URL": endpoint]) + #expect(OpenRouterSettingsReader.apiURL( + environment: ["OPENROUTER_API_URL": endpoint]).absoluteString == endpoint) + + try CodebuffSettingsReader.validateEndpointOverrides( + environment: ["CODEBUFF_API_URL": endpoint]) + #expect(CodebuffSettingsReader.apiURL( + environment: ["CODEBUFF_API_URL": endpoint]).absoluteString == endpoint) + + try GroqSettingsReader.validateEndpointOverrides( + environment: [GroqSettingsReader.apiURLEnvironmentKey: endpoint]) + #expect(GroqSettingsReader.apiURL( + environment: [GroqSettingsReader.apiURLEnvironmentKey: endpoint]).absoluteString == endpoint) + + try ElevenLabsSettingsReader.validateEndpointOverrides( + environment: [ElevenLabsSettingsReader.apiURLEnvironmentKey: endpoint]) + #expect(ElevenLabsSettingsReader.apiURL( + environment: [ElevenLabsSettingsReader.apiURLEnvironmentKey: endpoint]).absoluteString == endpoint) + } + + @Test + func `sibling endpoint overrides reject userinfo and encoded host delimiters`() { + let userInfoURL = "https://user:pass@proxy.test/v1" + let malformedHostURLs = [ + "https://proxy.test%2f.attacker.test/v1", + "https://bad host/v1", + "https://bad%20host/v1", + "https://bad%09host/v1", + ] + + #expect(OpenRouterSettingsReader.apiURL( + environment: ["OPENROUTER_API_URL": userInfoURL]).host == "openrouter.ai") + for malformedHostURL in malformedHostURLs { + #expect(throws: OpenRouterSettingsError.invalidEndpointOverride("OPENROUTER_API_URL")) { + try OpenRouterSettingsReader.validateEndpointOverrides( + environment: ["OPENROUTER_API_URL": malformedHostURL]) + } + } + + #expect(CodebuffSettingsReader.apiURL( + environment: ["CODEBUFF_API_URL": userInfoURL]).host == "www.codebuff.com") + for malformedHostURL in malformedHostURLs { + #expect(throws: CodebuffSettingsError.invalidEndpointOverride("CODEBUFF_API_URL")) { + try CodebuffSettingsReader.validateEndpointOverrides( + environment: ["CODEBUFF_API_URL": malformedHostURL]) + } + } + + #expect(GroqSettingsReader.apiURL( + environment: [GroqSettingsReader.apiURLEnvironmentKey: userInfoURL]).host == "api.groq.com") + for malformedHostURL in malformedHostURLs { + #expect(throws: GroqSettingsError.invalidEndpointOverride(GroqSettingsReader.apiURLEnvironmentKey)) { + try GroqSettingsReader.validateEndpointOverrides( + environment: [GroqSettingsReader.apiURLEnvironmentKey: malformedHostURL]) + } + } + + #expect(ElevenLabsSettingsReader.apiURL( + environment: [ElevenLabsSettingsReader.apiURLEnvironmentKey: userInfoURL]).host == "api.elevenlabs.io") + for malformedHostURL in malformedHostURLs { + #expect(throws: ElevenLabsSettingsError.invalidEndpointOverride( + ElevenLabsSettingsReader.apiURLEnvironmentKey)) + { + try ElevenLabsSettingsReader.validateEndpointOverrides( + environment: [ElevenLabsSettingsReader.apiURLEnvironmentKey: malformedHostURL]) + } + } + } + + @Test + func `credentialed fetchers reject insecure overrides before sending requests`() async { + let insecureURL = "http://attacker.test/v1" + + do { + _ = try await OpenRouterUsageFetcher.fetchUsage( + apiKey: "openrouter-test", + environment: ["OPENROUTER_API_URL": insecureURL]) + Issue.record("Expected OpenRouterSettingsError.invalidEndpointOverride") + } catch { + #expect(error as? OpenRouterSettingsError == .invalidEndpointOverride("OPENROUTER_API_URL")) + } + + do { + _ = try await CodebuffUsageFetcher.fetchUsage( + apiKey: "codebuff-test", + environment: ["CODEBUFF_API_URL": insecureURL]) + Issue.record("Expected CodebuffSettingsError.invalidEndpointOverride") + } catch { + #expect(error as? CodebuffSettingsError == .invalidEndpointOverride("CODEBUFF_API_URL")) + } + + do { + _ = try await GroqUsageFetcher.fetchUsage( + apiKey: "groq-test", + environment: [GroqSettingsReader.apiURLEnvironmentKey: insecureURL]) + Issue.record("Expected GroqSettingsError.invalidEndpointOverride") + } catch { + #expect(error as? GroqSettingsError == .invalidEndpointOverride(GroqSettingsReader.apiURLEnvironmentKey)) + } + + do { + _ = try await ElevenLabsUsageFetcher.fetchUsage( + apiKey: "elevenlabs-test", + environment: [ElevenLabsSettingsReader.apiURLEnvironmentKey: insecureURL]) + Issue.record("Expected ElevenLabsSettingsError.invalidEndpointOverride") + } catch { + #expect(error as? ElevenLabsSettingsError == .invalidEndpointOverride( + ElevenLabsSettingsReader.apiURLEnvironmentKey)) + } + } + + @Test + func `OpenRouter endpoint override must be HTTPS or a bare host`() throws { + let httpsURL = OpenRouterSettingsReader.apiURL( + environment: ["OPENROUTER_API_URL": "https://router.test/v1"]) + #expect(httpsURL.absoluteString == "https://router.test/v1") + + let bareURL = OpenRouterSettingsReader.apiURL(environment: ["OPENROUTER_API_URL": "router.test/v1"]) + #expect(bareURL.absoluteString == "https://router.test/v1") + + let hostPortURL = OpenRouterSettingsReader.apiURL( + environment: ["OPENROUTER_API_URL": "localhost:8080/v1"]) + #expect(hostPortURL.absoluteString == "https://localhost:8080/v1") + + let httpURL = OpenRouterSettingsReader.apiURL( + environment: ["OPENROUTER_API_URL": "http://attacker.test/v1"]) + #expect(httpURL.absoluteString == "https://openrouter.ai/api/v1") + + do { + try OpenRouterSettingsReader.validateEndpointOverrides( + environment: ["OPENROUTER_API_URL": "http://attacker.test/v1"]) + Issue.record("Expected OpenRouterSettingsError.invalidEndpointOverride") + } catch OpenRouterSettingsError.invalidEndpointOverride("OPENROUTER_API_URL") { + // Expected. + } catch { + Issue.record("Expected OpenRouterSettingsError.invalidEndpointOverride, got \(error)") + } + } + + @Test + func `Codebuff endpoint override must be HTTPS or a bare host`() throws { + let httpsURL = CodebuffSettingsReader.apiURL(environment: ["CODEBUFF_API_URL": "https://codebuff.test"]) + #expect(httpsURL.absoluteString == "https://codebuff.test") + + let bareURL = CodebuffSettingsReader.apiURL(environment: ["CODEBUFF_API_URL": "codebuff.test"]) + #expect(bareURL.absoluteString == "https://codebuff.test") + + let hostPortURL = CodebuffSettingsReader.apiURL(environment: ["CODEBUFF_API_URL": "localhost:8080"]) + #expect(hostPortURL.absoluteString == "https://localhost:8080") + + let httpURL = CodebuffSettingsReader.apiURL(environment: ["CODEBUFF_API_URL": "http://attacker.test"]) + #expect(httpURL.absoluteString == "https://www.codebuff.com") + + do { + try CodebuffSettingsReader.validateEndpointOverrides( + environment: ["CODEBUFF_API_URL": "http://attacker.test"]) + Issue.record("Expected CodebuffSettingsError.invalidEndpointOverride") + } catch CodebuffSettingsError.invalidEndpointOverride("CODEBUFF_API_URL") { + // Expected. + } catch { + Issue.record("Expected CodebuffSettingsError.invalidEndpointOverride, got \(error)") + } + } + + @Test + func `Groq endpoint override must be HTTPS or a bare host`() throws { + let httpsURL = GroqSettingsReader.apiURL( + environment: [GroqSettingsReader.apiURLEnvironmentKey: "https://groq.test/v1"]) + #expect(httpsURL.absoluteString == "https://groq.test/v1") + + let bareURL = GroqSettingsReader.apiURL( + environment: [GroqSettingsReader.apiURLEnvironmentKey: "groq.test/v1"]) + #expect(bareURL.absoluteString == "https://groq.test/v1") + + let hostPortURL = GroqSettingsReader.apiURL( + environment: [GroqSettingsReader.apiURLEnvironmentKey: "localhost:8080/v1"]) + #expect(hostPortURL.absoluteString == "https://localhost:8080/v1") + + let httpURL = GroqSettingsReader.apiURL( + environment: [GroqSettingsReader.apiURLEnvironmentKey: "http://attacker.test/v1"]) + #expect(httpURL.absoluteString == "https://api.groq.com/v1") + + do { + try GroqSettingsReader.validateEndpointOverrides( + environment: [GroqSettingsReader.apiURLEnvironmentKey: "http://attacker.test/v1"]) + Issue.record("Expected GroqSettingsError.invalidEndpointOverride") + } catch GroqSettingsError.invalidEndpointOverride(GroqSettingsReader.apiURLEnvironmentKey) { + // Expected. + } catch { + Issue.record("Expected GroqSettingsError.invalidEndpointOverride, got \(error)") + } + } + + @Test + func `ElevenLabs endpoint override must be HTTPS or a bare host`() throws { + let httpsURL = ElevenLabsSettingsReader.apiURL( + environment: [ElevenLabsSettingsReader.apiURLEnvironmentKey: "https://eleven.test"]) + #expect(httpsURL.absoluteString == "https://eleven.test") + + let bareURL = ElevenLabsSettingsReader.apiURL( + environment: [ElevenLabsSettingsReader.apiURLEnvironmentKey: "eleven.test"]) + #expect(bareURL.absoluteString == "https://eleven.test") + + let hostPortURL = ElevenLabsSettingsReader.apiURL( + environment: [ElevenLabsSettingsReader.apiURLEnvironmentKey: "localhost:8080"]) + #expect(hostPortURL.absoluteString == "https://localhost:8080") + + let httpURL = ElevenLabsSettingsReader.apiURL( + environment: [ElevenLabsSettingsReader.apiURLEnvironmentKey: "http://attacker.test"]) + #expect(httpURL.absoluteString == "https://api.elevenlabs.io") + + do { + try ElevenLabsSettingsReader.validateEndpointOverrides( + environment: [ElevenLabsSettingsReader.apiURLEnvironmentKey: "http://attacker.test"]) + Issue.record("Expected ElevenLabsSettingsError.invalidEndpointOverride") + } catch ElevenLabsSettingsError.invalidEndpointOverride(ElevenLabsSettingsReader.apiURLEnvironmentKey) { + // Expected. + } catch { + Issue.record("Expected ElevenLabsSettingsError.invalidEndpointOverride, got \(error)") + } + } +} diff --git a/Tests/CodexBarTests/ProviderEnvironmentResolverTests.swift b/Tests/CodexBarTests/ProviderEnvironmentResolverTests.swift new file mode 100644 index 0000000000..604257aea4 --- /dev/null +++ b/Tests/CodexBarTests/ProviderEnvironmentResolverTests.swift @@ -0,0 +1,103 @@ +import CodexBarCore +import Foundation +import Testing + +struct ProviderEnvironmentResolverTests { + @Test + func `selected API account overrides saved and ambient credentials`() { + let account = Self.account(token: "account-token") + let environment = ProviderEnvironmentResolver.resolve( + base: [ZaiSettingsReader.apiTokenKey: "ambient-token"], + provider: .zai, + config: ProviderConfig(id: .zai, apiKey: "saved-token"), + selectedAccount: account) + + #expect(environment[ZaiSettingsReader.apiTokenKey] == "account-token") + } + + @Test + func `NeuralWatt selected API account overrides saved and ambient credentials`() { + let account = Self.account(token: "sk-neuralwatt-account") + let environment = ProviderEnvironmentResolver.resolve( + base: [NeuralWattSettingsReader.apiKeyEnvironmentKey: "ambient-token"], + provider: .neuralwatt, + config: ProviderConfig(id: .neuralwatt, apiKey: "saved-token"), + selectedAccount: account) + + #expect(environment[NeuralWattSettingsReader.apiKeyEnvironmentKey] == "sk-neuralwatt-account") + } + + @Test + func `OpenAI account removes project scoping from saved config`() { + let account = Self.account(token: "sk-admin-account") + let environment = ProviderEnvironmentResolver.resolve( + base: [ + OpenAIAPISettingsReader.adminAPIKeyEnvironmentKey: "ambient-token", + OpenAIAPISettingsReader.projectIDEnvironmentKey: "ambient-project", + ], + provider: .openai, + config: ProviderConfig( + id: .openai, + apiKey: "saved-token", + workspaceID: "saved-project"), + selectedAccount: account) + + #expect(environment[OpenAIAPISettingsReader.adminAPIKeyEnvironmentKey] == "sk-admin-account") + #expect(environment[OpenAIAPISettingsReader.projectIDEnvironmentKey] == nil) + } + + @Test + func `Claude session account removes API and OAuth credentials`() { + let environment = ProviderEnvironmentResolver.resolve( + base: [ + ClaudeAdminAPISettingsReader.alternateAdminAPIKeyEnvironmentKey: "ambient-admin", + ClaudeOAuthCredentialsStore.environmentTokenKey: "ambient-oauth", + ], + provider: .claude, + config: ProviderConfig(id: .claude, apiKey: "saved-admin"), + selectedAccount: Self.account(token: "sk-ant-session-account")) + + for key in ClaudeAdminAPISettingsReader.apiKeyEnvironmentKeys { + #expect(environment[key] == nil) + } + #expect(environment[ClaudeOAuthCredentialsStore.environmentTokenKey] == nil) + } + + @Test + func `Claude OAuth account replaces incompatible credentials`() { + let environment = ProviderEnvironmentResolver.resolve( + base: [ + ClaudeAdminAPISettingsReader.alternateAdminAPIKeyEnvironmentKey: "ambient-admin", + ClaudeOAuthCredentialsStore.environmentTokenKey: "ambient-oauth", + ], + provider: .claude, + config: ProviderConfig(id: .claude, apiKey: "saved-admin"), + selectedAccount: Self.account(token: "Bearer sk-ant-oat-account")) + + for key in ClaudeAdminAPISettingsReader.apiKeyEnvironmentKeys { + #expect(environment[key] == nil) + } + #expect(environment[ClaudeOAuthCredentialsStore.environmentTokenKey] == "sk-ant-oat-account") + } + + @Test + func `cookie account leaves unrelated provider environment intact`() { + let base = ["FOO": "bar"] + let environment = ProviderEnvironmentResolver.resolve( + base: base, + provider: .cursor, + config: ProviderConfig(id: .cursor), + selectedAccount: Self.account(token: "session=account")) + + #expect(environment == base) + } + + private static func account(token: String) -> ProviderTokenAccount { + ProviderTokenAccount( + id: UUID(), + label: "Test", + token: token, + addedAt: 0, + lastUsed: nil) + } +} diff --git a/Tests/CodexBarTests/ProviderHTTPClientTests.swift b/Tests/CodexBarTests/ProviderHTTPClientTests.swift index c07b43e068..7a1c2d5fea 100644 --- a/Tests/CodexBarTests/ProviderHTTPClientTests.swift +++ b/Tests/CodexBarTests/ProviderHTTPClientTests.swift @@ -129,6 +129,71 @@ struct ProviderHTTPClientTests { #expect(response.statusCode == 403) #expect(await script.requestCount() == 1) } + + @Test + func `redirect guard blocks cross origin redirects`() throws { + var redirectRequest = try URLRequest(url: #require(URL(string: "https://attacker.example/capture"))) + redirectRequest.setValue("[REDACTED]", forHTTPHeaderField: "Cookie") + redirectRequest.setValue("[REDACTED]", forHTTPHeaderField: "x-api-key") + + let guarded = ProviderHTTPRedirectGuardDelegate.guardedRedirectRequest( + originalURL: URL(string: "https://provider.example/usage"), + redirectRequest: redirectRequest) + + #expect(guarded == nil) + } + + @Test + func `redirect guard blocks non HTTPS redirects`() throws { + var redirectRequest = try URLRequest(url: #require(URL(string: "http://provider.example/capture"))) + redirectRequest.setValue("[REDACTED]", forHTTPHeaderField: "Cookie") + + let guarded = ProviderHTTPRedirectGuardDelegate.guardedRedirectRequest( + originalURL: URL(string: "https://provider.example/usage"), + redirectRequest: redirectRequest) + + #expect(guarded == nil) + } + + @Test + func `redirect guard blocks redirects without an original URL`() throws { + let redirectRequest = try URLRequest(url: #require(URL(string: "https://provider.example/usage/next"))) + + let guarded = ProviderHTTPRedirectGuardDelegate.guardedRedirectRequest( + originalURL: nil, + redirectRequest: redirectRequest) + + #expect(guarded == nil) + } + + @Test + func `redirect guard blocks port changes`() throws { + let redirectRequest = try URLRequest(url: #require(URL(string: "https://provider.example:8443/usage"))) + + let guarded = ProviderHTTPRedirectGuardDelegate.guardedRedirectRequest( + originalURL: URL(string: "https://provider.example/usage"), + redirectRequest: redirectRequest) + + #expect(guarded == nil) + } + + @Test + func `redirect guard preserves same origin HTTPS requests`() throws { + var redirectRequest = try URLRequest(url: #require(URL(string: "https://provider.example/usage/next"))) + redirectRequest.setValue("[REDACTED]", forHTTPHeaderField: "Cookie") + redirectRequest.setValue("[REDACTED]", forHTTPHeaderField: "Authorization") + redirectRequest.setValue("[REDACTED]", forHTTPHeaderField: "x-api-key") + redirectRequest.setValue("application/json", forHTTPHeaderField: "Accept") + + let guarded = try #require(ProviderHTTPRedirectGuardDelegate.guardedRedirectRequest( + originalURL: URL(string: "https://provider.example/usage"), + redirectRequest: redirectRequest)) + + #expect(guarded.value(forHTTPHeaderField: "Cookie") == "[REDACTED]") + #expect(guarded.value(forHTTPHeaderField: "Authorization") == "[REDACTED]") + #expect(guarded.value(forHTTPHeaderField: "x-api-key") == "[REDACTED]") + #expect(guarded.value(forHTTPHeaderField: "Accept") == "application/json") + } } extension ProviderHTTPRetryPolicy { @@ -177,7 +242,12 @@ private actor ScriptedHTTPTransport: ProviderHTTPTransport { } final class StubURLProtocol: URLProtocol { - nonisolated(unsafe) static var handler: ((URLRequest) throws -> (Data, URLResponse))? + private static let _handlerBox = LockIsolated<((URLRequest) throws -> (Data, URLResponse))?>(nil) + static var handler: ((URLRequest) throws -> (Data, URLResponse))? { + get { Self._handlerBox.value } + set { Self._handlerBox.setValue(newValue) } + } + nonisolated(unsafe) static var requests: [URLRequest] = [] override static func canInit(with request: URLRequest) -> Bool { diff --git a/Tests/CodexBarTests/ProviderIconResourcesTests.swift b/Tests/CodexBarTests/ProviderIconResourcesTests.swift index 53bcbf11f5..064e4c33a9 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 { @@ -12,6 +14,7 @@ struct ProviderIconResourcesTests { let slugs = [ "codex", "claude", + "clinepass", "zai", "minimax", "cursor", @@ -22,15 +25,26 @@ struct ProviderIconResourcesTests { "antigravity", "factory", "copilot", + "devin", "crof", "commandcode", "t3chat", "kimi", + "longcat", "bedrock", "elevenlabs", "groq", "llmproxy", + "litellm", "deepgram", + "ollama", + "clawrouter", + "sub2api", + "wayfinder", + "zenmux", + "aiand", + "zoommate", + "xai", ] for slug in slugs { let url = resources.appending(path: "ProviderIcon-\(slug).svg") @@ -53,6 +67,82 @@ struct ProviderIconResourcesTests { #expect(groq != grok) } + @Test + func `grok and xai provider icons are distinct`() throws { + let root = try Self.repoRoot() + let resources = root.appending(path: "Sources/CodexBar/Resources", directoryHint: .isDirectory) + let grok = try String(contentsOf: resources.appending(path: "ProviderIcon-grok.svg"), encoding: .utf8) + let xai = try String(contentsOf: resources.appending(path: "ProviderIcon-xai.svg"), encoding: .utf8) + + #expect(grok != xai) + } + + @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) + } + + @Test + func `ollama provider icon uses template rendering`() throws { + ProviderBrandIcon.resetCacheForTesting() + defer { ProviderBrandIcon.resetCacheForTesting() } + + let image = try #require(ProviderBrandIcon.image(for: .ollama)) + + #expect(image.size == NSSize(width: 16, height: 16)) + #expect(image.isTemplate) + + let bitmap = try #require(NSBitmapImageRep( + bitmapDataPlanes: nil, + pixelsWide: 16, + pixelsHigh: 16, + bitsPerSample: 8, + samplesPerPixel: 4, + hasAlpha: true, + isPlanar: false, + colorSpaceName: .deviceRGB, + bytesPerRow: 0, + bitsPerPixel: 0)) + let context = try #require(NSGraphicsContext(bitmapImageRep: bitmap)) + NSGraphicsContext.saveGraphicsState() + NSGraphicsContext.current = context + context.cgContext.clear(CGRect(x: 0, y: 0, width: 16, height: 16)) + image.draw(in: NSRect(x: 0, y: 0, width: 16, height: 16)) + NSGraphicsContext.restoreGraphicsState() + + var visiblePixels = 0 + for y in 0.. 0 + { + visiblePixels += 1 + } + } + #expect(visiblePixels > 40) + #expect(visiblePixels < 240) + } + + @Test + func `registered providers resolve bundled brand icons`() { + ProviderBrandIcon.resetCacheForTesting() + defer { ProviderBrandIcon.resetCacheForTesting() } + + for provider in UsageProvider.allCases { + let descriptor = ProviderDescriptorRegistry.descriptor(for: provider) + #expect( + ProviderBrandIcon.image(for: provider) != nil, + "Missing icon resource \(descriptor.branding.iconResourceName).svg for \(provider.rawValue)") + } + } + private static func repoRoot() throws -> URL { var dir = URL(filePath: #filePath).deletingLastPathComponent() for _ in 0..<12 { diff --git a/Tests/CodexBarTests/ProviderMetadataStatusLinkTests.swift b/Tests/CodexBarTests/ProviderMetadataStatusLinkTests.swift index 3ca12b2336..909306581d 100644 --- a/Tests/CodexBarTests/ProviderMetadataStatusLinkTests.swift +++ b/Tests/CodexBarTests/ProviderMetadataStatusLinkTests.swift @@ -12,13 +12,4 @@ struct ProviderMetadataStatusLinkTests { "Expected \(provider.rawValue) statusLinkURL to be \(expected)") } } - - @Test - func `kimi K2 metadata does not present legacy endpoint as official`() throws { - let meta = try #require(ProviderDefaults.metadata[.kimik2]) - - #expect(meta.displayName == "Kimi K2 (unofficial)") - #expect(meta.toggleTitle == "Show unofficial Kimi K2 usage") - #expect(meta.dashboardURL == nil) - } } diff --git a/Tests/CodexBarTests/ProviderPaceCapabilityTests.swift b/Tests/CodexBarTests/ProviderPaceCapabilityTests.swift new file mode 100644 index 0000000000..a743e20c9e --- /dev/null +++ b/Tests/CodexBarTests/ProviderPaceCapabilityTests.swift @@ -0,0 +1,119 @@ +import CodexBarCore +import Foundation +import Testing + +struct ProviderPaceCapabilityTests { + private static let weeklyWindowMinutes = 7 * 24 * 60 + private static let monthlyWindowSentinelMinutes = 30 * 24 * 60 + + @Test + func `descriptor pace capabilities match the supported provider mapping`() { + let now = Date(timeIntervalSince1970: 1_750_000_000) + let fixtures: [RateWindow] = [ + Self.window(minutes: nil, resetsAt: nil), + Self.window(minutes: nil, resetsAt: now.addingTimeInterval(4 * 24 * 60 * 60)), + Self.window(minutes: nil, resetsAt: now.addingTimeInterval(8 * 24 * 60 * 60)), + Self.window(minutes: nil, resetsAt: now.addingTimeInterval(30 * 24 * 60 * 60)), + Self.window(minutes: 60, resetsAt: now.addingTimeInterval(30 * 60)), + Self.window(minutes: Self.weeklyWindowMinutes, resetsAt: nil), + Self.window(minutes: Self.weeklyWindowMinutes, resetsAt: now.addingTimeInterval(4 * 24 * 60 * 60)), + Self.window(minutes: Self.weeklyWindowMinutes, resetsAt: now.addingTimeInterval(8 * 24 * 60 * 60)), + Self.window(minutes: Self.monthlyWindowSentinelMinutes, resetsAt: nil), + Self.window( + minutes: Self.monthlyWindowSentinelMinutes, + resetsAt: now.addingTimeInterval(20 * 24 * 60 * 60)), + Self.window(minutes: 0, resetsAt: now.addingTimeInterval(60)), + Self.window(minutes: Self.weeklyWindowMinutes, resetsAt: now.addingTimeInterval(-60)), + ] + + for provider in UsageProvider.allCases { + let capability = ProviderDescriptorRegistry.descriptor(for: provider).pace + for window in fixtures { + let actualResetWindowPace = capability.supportsResetWindowPace(window: window, now: now) + let expectedResetWindowPace = Self.expectedSupportsResetWindowPace( + provider: provider, + window: window, + now: now) + #expect( + actualResetWindowPace == expectedResetWindowPace, + "Reset-window pace changed for \(provider.rawValue), window=\(String(describing: window)).") + + let actualMonthlyInference = capability.usesInferredMonthlyDuration(window: window) + let legacyMonthlyInference = Self.legacyUsesInferredMonthlyDuration( + provider: provider, + window: window) + #expect( + actualMonthlyInference == legacyMonthlyInference, + "Monthly inference changed for \(provider.rawValue), window=\(String(describing: window)).") + } + } + } + + @Test + func `amp monthly pace is limited to subscription windows`() { + let now = Date(timeIntervalSince1970: 1_750_000_000) + let capability = AmpProviderDescriptor.descriptor.pace + let freeTier = Self.window( + minutes: 24 * 60, + resetsAt: now.addingTimeInterval(12 * 60 * 60)) + let subscription = Self.window( + minutes: Self.monthlyWindowSentinelMinutes, + resetsAt: now.addingTimeInterval(20 * 24 * 60 * 60)) + + #expect(!capability.supportsResetWindowPace(window: freeTier, now: now)) + #expect(!capability.usesInferredMonthlyDuration(window: freeTier)) + #expect(capability.supportsResetWindowPace(window: subscription, now: now)) + #expect(capability.usesInferredMonthlyDuration(window: subscription)) + } + + private static func window(minutes: Int?, resetsAt: Date?) -> RateWindow { + RateWindow( + usedPercent: 50, + windowMinutes: minutes, + resetsAt: resetsAt, + resetDescription: nil) + } + + /// Expected provider-specific reset-window behavior, including newly declared capabilities. + private static func expectedSupportsResetWindowPace( + provider: UsageProvider, + window: RateWindow, + now: Date) -> Bool + { + switch provider { + case .copilot: + return window.resetsAt != nil + case .cursor: + return window.windowMinutes != nil + case .grok: + guard GrokProviderDescriptor.primaryLabel(window: window, now: now) == "Weekly", + let resetsAt = window.resetsAt + else { return false } + let windowMinutes = window.windowMinutes ?? self.weeklyWindowMinutes + let timeUntilReset = resetsAt.timeIntervalSince(now) + return windowMinutes > 0 + && timeUntilReset > 0 + && timeUntilReset <= TimeInterval(windowMinutes) * 60 + case .kimi: + return window.windowMinutes == self.weeklyWindowMinutes + case .alibaba, .alibabatokenplan, .amp, .doubao, .opencodego: + return window.windowMinutes == self.monthlyWindowSentinelMinutes + default: + return false + } + } + + private static func legacyUsesInferredMonthlyDuration( + provider: UsageProvider, + window: RateWindow) -> Bool + { + switch provider { + case .copilot: + window.windowMinutes == nil + case .alibaba, .alibabatokenplan, .amp, .doubao, .opencodego: + window.windowMinutes == self.monthlyWindowSentinelMinutes + default: + false + } + } +} diff --git a/Tests/CodexBarTests/ProviderPlanLineParsingTests.swift b/Tests/CodexBarTests/ProviderPlanLineParsingTests.swift new file mode 100644 index 0000000000..11ef471b7c --- /dev/null +++ b/Tests/CodexBarTests/ProviderPlanLineParsingTests.swift @@ -0,0 +1,72 @@ +import Testing +@testable import CodexBarCore + +struct ProviderPlanLineParsingTests { + @Test + func `Claude plan matching does not bridge usage lines`() { + let usageText = """ + Skills, subagents, plugins, and MCP servers + Noattributiondatayet·accumulatesasyouuseClaude + + dtoday·wtoweek + + Usagecredits + Usagecreditsareoff·/usage-creditstoturnthemon + """ + + let identity = ClaudeStatusProbe.parseIdentity(usageText: usageText, statusText: nil) + + #expect(identity.loginMethod == nil) + } + + @Test + func `Claude plan matching keeps single line phrases`() { + let identity = ClaudeStatusProbe.parseIdentity( + usageText: nil, + statusText: "Sonnet 4.6 · Claude Max · you@example.com") + + #expect(identity.loginMethod == "Max") + } + + @Test + func `Kiro legacy plan matching does not bridge lines`() throws { + let output = """ + | + KIRO FREE + ████████████████████████████████████████████████████ 25% + (12.50 of 50 covered in plan), resets on 01/15 + """ + + let snapshot = try KiroStatusProbe().parse(output: output) + + #expect(snapshot.planName == "Kiro") + } + + @Test + func `Kiro estimated usage plan matching does not bridge lines`() throws { + let output = """ + Estimated Usage | resets on 2026-06-01 | + KIRO FREE + ████████████████████████████████████████████████████ 25% + (12.50 of 50 covered in plan), resets on 01/15 + """ + + let snapshot = try KiroStatusProbe().parse(output: output) + + #expect(snapshot.planName == "Kiro") + } + + @Test + func `Kiro labeled plan matching does not bridge lines`() throws { + let output = """ + Plan: + Q Developer Pro + ████████████████████████████████████████████████████ 25% + (12.50 of 50 covered in plan), resets on 01/15 + """ + + let snapshot = try KiroStatusProbe().parse(output: output) + + #expect(snapshot.planName == "Kiro") + } +} diff --git a/Tests/CodexBarTests/ProviderQuotaFixtureContractTests.swift b/Tests/CodexBarTests/ProviderQuotaFixtureContractTests.swift new file mode 100644 index 0000000000..2eda0ad14d --- /dev/null +++ b/Tests/CodexBarTests/ProviderQuotaFixtureContractTests.swift @@ -0,0 +1,79 @@ +import Foundation +import Testing +@testable import CodexBar +@testable import CodexBarCore + +struct ProviderQuotaFixtureContractTests { + @Test + func `MiniMax fixture preserves quota windows and plan`() throws { + let data = try Self.fixtureData(provider: "MiniMax", name: "token-plan-normal", fileExtension: "json") + let snapshot = try MiniMaxUsageParser.parseCodingPlanRemains( + data: data, + now: Date(timeIntervalSince1970: 1_780_282_340)) + let usage = snapshot.toUsageSnapshot() + + #expect(usage.identity(for: .minimax)?.loginMethod == "Token Plan Plus") + #expect(usage.primary?.usedPercent == 4) + #expect(usage.primary?.windowMinutes == 300) + #expect(usage.primary?.resetsAt == Date(timeIntervalSince1970: 1_780_297_200)) + #expect(usage.secondary?.usedPercent == 1) + #expect(usage.secondary?.windowMinutes == 10080) + #expect(usage.secondary?.resetsAt == Date(timeIntervalSince1970: 1_780_848_000)) + } + + @Test + func `MiniMax fixture keeps windows when reset timestamps are absent`() throws { + let data = try Self.fixtureData( + provider: "MiniMax", + name: "token-plan-missing-reset", + fileExtension: "json") + let snapshot = try MiniMaxUsageParser.parseCodingPlanRemains( + data: data, + now: Date(timeIntervalSince1970: 1_780_282_340)) + let usage = snapshot.toUsageSnapshot() + + #expect(usage.identity(for: .minimax)?.loginMethod == "Token Plan Plus") + #expect(usage.primary?.usedPercent == 25) + #expect(usage.primary?.resetsAt == nil) + #expect(usage.secondary?.usedPercent == 40) + #expect(usage.secondary?.windowMinutes == 10080) + #expect(usage.secondary?.resetsAt == nil) + } + + @Test + func `OpenAI fixture preserves quota windows and plan`() throws { + let data = try Self.fixtureData(provider: "OpenAI", name: "pro-normal", fileExtension: "html") + let body = try #require(String(data: data, encoding: .utf8)) + let limits = OpenAIDashboardParser.parseRateLimits(bodyText: body) + + #expect(OpenAIDashboardParser.parsePlanFromHTML(html: body) == "Pro 5x") + #expect(limits.primary?.usedPercent == 28) + #expect(limits.primary?.windowMinutes == 300) + #expect(limits.primary?.resetDescription?.localizedCaseInsensitiveContains("resets") == true) + #expect(limits.secondary?.usedPercent == 59) + #expect(limits.secondary?.windowMinutes == 10080) + #expect(limits.secondary?.resetDescription?.localizedCaseInsensitiveContains("resets") == true) + } + + @Test + func `Claude fixture preserves quota windows and plan`() throws { + let data = try Self.fixtureData(provider: "Claude", name: "weekly-limit", fileExtension: "json") + let snapshot = try #require(ClaudeUsageFetcher.parse(json: data)) + + #expect(snapshot.loginMethod == "Claude Max") + #expect(snapshot.primary.usedPercent == 7) + #expect(snapshot.primary.windowMinutes == 300) + #expect(snapshot.primary.resetDescription?.contains("Europe/Vienna") == true) + #expect(snapshot.secondary?.usedPercent == 21) + #expect(snapshot.secondary?.windowMinutes == 10080) + #expect(snapshot.secondary?.resetDescription?.contains("Europe/Vienna") == true) + } + + private static func fixtureData(provider: String, name: String, fileExtension: String) throws -> Data { + let url = try #require(Bundle.module.url( + forResource: name, + withExtension: fileExtension, + subdirectory: "Fixtures/Providers/\(provider)")) + return try Data(contentsOf: url) + } +} diff --git a/Tests/CodexBarTests/ProviderRefreshCoordinatorTests.swift b/Tests/CodexBarTests/ProviderRefreshCoordinatorTests.swift new file mode 100644 index 0000000000..5e03ee1a10 --- /dev/null +++ b/Tests/CodexBarTests/ProviderRefreshCoordinatorTests.swift @@ -0,0 +1,160 @@ +import Testing +@testable import CodexBar + +@MainActor +struct ProviderRefreshCoordinatorTests { + @Test + func `replacement cancels and orders predecessor while advancing current generation`() async { + let coordinator = ProviderRefreshCoordinator() + let first = coordinator.beginReplacingRequest(for: "codex") + let firstTask = Task { + while !Task.isCancelled { + await Task.yield() + } + } + first.state.install(task: firstTask) + + let second = coordinator.beginReplacingRequest(for: "codex") + + #expect(firstTask.isCancelled) + #expect(second.predecessorStates.count == 1) + #expect(second.predecessorStates[0] === first.state) + #expect(!coordinator.isCurrent(first.generation, for: "codex")) + #expect(coordinator.isCurrent(second.generation, for: "codex")) + await firstTask.value + } + + @Test + func `invalidation cancels work without dropping waiter completion`() async { + let coordinator = ProviderRefreshCoordinator() + let request = coordinator.beginReplacingRequest(for: "codex") + let gate = ProviderRefreshCoordinatorGate() + let task = Task { + await gate.wait() + } + request.state.install(task: task) + let waiter = Task { + await coordinator.wait(for: "codex", state: request.state) + } + await Task.yield() + + coordinator.invalidateRequests(for: "codex") + + #expect(task.isCancelled) + #expect(!coordinator.isCurrent(request.generation, for: "codex")) + #expect(coordinator.coalescingState(for: "codex") == nil) + + await gate.resume() + await task.value + coordinator.complete(request.state, for: "codex", retryRequired: false) + #expect(await waiter.value == .completed) + } + + @Test + func `coalescing returns latest request independently per key`() { + let coordinator = ProviderRefreshCoordinator() + let firstCodex = coordinator.beginReplacingRequest(for: "codex") + let claude = coordinator.beginReplacingRequest(for: "claude") + let latestCodex = coordinator.beginReplacingRequest(for: "codex") + + #expect(coordinator.coalescingState(for: "codex") === latestCodex.state) + #expect(coordinator.coalescingState(for: "claude") === claude.state) + #expect(coordinator.coalescingState(for: "codex") !== firstCodex.state) + } + + @Test + func `canceling one of two waiters keeps shared task alive`() async { + let coordinator = ProviderRefreshCoordinator() + let request = coordinator.beginReplacingRequest(for: "codex") + let gate = ProviderRefreshCoordinatorGate() + let task = Task { + await gate.wait() + } + request.state.install(task: task) + + let owner = Task { + await coordinator.wait(for: "codex", state: request.state) + } + let shared = Task { + await coordinator.wait(for: "codex", state: request.state) + } + await Task.yield() + owner.cancel() + await Task.yield() + + #expect(!task.isCancelled) + + await gate.resume() + coordinator.complete(request.state, for: "codex", retryRequired: false) + _ = await owner.value + _ = await shared.value + } + + @Test + func `wait result exposes retry without leaking task state`() async { + let coordinator = ProviderRefreshCoordinator() + let request = coordinator.beginReplacingRequest(for: "codex") + let task = Task {} + request.state.install(task: task) + coordinator.complete(request.state, for: "codex", retryRequired: true) + + let result = await coordinator.wait(for: "codex", state: request.state) + + #expect(result == .retryRequired) + } + + @Test + func `completed request is not offered for coalescing before deferred removal`() { + let coordinator = ProviderRefreshCoordinator() + let request = coordinator.beginReplacingRequest(for: "codex") + let task = Task {} + request.state.install(task: task) + + coordinator.complete(request.state, for: "codex", retryRequired: false) + + #expect(coordinator.coalescingState(for: "codex") == nil) + } + + @Test + func `completion removal and activity counts are key scoped`() async { + let coordinator = ProviderRefreshCoordinator() + let codex = coordinator.beginReplacingRequest(for: "codex") + let claude = coordinator.beginReplacingRequest(for: "claude") + let codexTask = Task {} + let claudeTask = Task {} + codex.state.install(task: codexTask) + claude.state.install(task: claudeTask) + + #expect(coordinator.beginActivity(for: "codex")) + #expect(!coordinator.beginActivity(for: "codex")) + #expect(coordinator.beginActivity(for: "claude")) + #expect(!coordinator.endActivity(for: "codex")) + #expect(coordinator.endActivity(for: "codex")) + #expect(coordinator.endActivity(for: "claude")) + + coordinator.complete(codex.state, for: "codex", retryRequired: true) + coordinator.complete(claude.state, for: "claude", retryRequired: false) + await codexTask.value + await claudeTask.value + await Task.yield() + await Task.yield() + + #expect(coordinator.coalescingState(for: "codex") == nil) + #expect(coordinator.coalescingState(for: "claude") == nil) + } +} + +private actor ProviderRefreshCoordinatorGate { + private var continuation: CheckedContinuation? + + func wait() async { + await withCheckedContinuation { continuation in + self.continuation = continuation + } + } + + func resume() { + self.continuation?.resume() + self.continuation = nil + } +} diff --git a/Tests/CodexBarTests/ProviderRefreshRequestContextTests.swift b/Tests/CodexBarTests/ProviderRefreshRequestContextTests.swift new file mode 100644 index 0000000000..6f691a21bd --- /dev/null +++ b/Tests/CodexBarTests/ProviderRefreshRequestContextTests.swift @@ -0,0 +1,24 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct ProviderRefreshRequestContextTests { + @Test + func `new request stays bound for the full awaited operation`() async throws { + #expect(ProviderRefreshRequestContext.id == nil) + + let firstRequestID = try await ProviderRefreshRequestContext.withNewRequest { + let requestID = try #require(ProviderRefreshRequestContext.id) + await Task.yield() + #expect(ProviderRefreshRequestContext.id == requestID) + return requestID + } + + let secondRequestID = await ProviderRefreshRequestContext.withNewRequest { + ProviderRefreshRequestContext.id + } + #expect(secondRequestID != nil) + #expect(secondRequestID != firstRequestID) + #expect(ProviderRefreshRequestContext.id == nil) + } +} diff --git a/Tests/CodexBarTests/ProviderRegistryTests.swift b/Tests/CodexBarTests/ProviderRegistryTests.swift index 7206bda851..e92d5a5941 100644 --- a/Tests/CodexBarTests/ProviderRegistryTests.swift +++ b/Tests/CodexBarTests/ProviderRegistryTests.swift @@ -1,5 +1,6 @@ import CodexBarCore import Testing +@testable import CodexBar struct ProviderRegistryTests { @Test @@ -17,6 +18,21 @@ struct ProviderRegistryTests { #expect(ids == secondPass, "ProviderDescriptorRegistry order changed between reads.") } + @Test + func `implementation registry is complete and deterministic`() { + let implementations = ProviderImplementationRegistry.all + let ids = implementations.map(\.id) + + #expect(!implementations.isEmpty, "ProviderImplementationRegistry must not be empty.") + #expect(Set(ids).count == ids.count, "ProviderImplementationRegistry contains duplicate IDs.") + + let missing = Set(UsageProvider.allCases).subtracting(ids) + #expect(missing.isEmpty, "Missing implementations for providers: \(missing).") + + let secondPass = ProviderImplementationRegistry.all.map(\.id) + #expect(ids == secondPass, "ProviderImplementationRegistry order changed between reads.") + } + @Test func `minimax sorts after zai in registry`() { let ids = ProviderDescriptorRegistry.all.map(\.id) @@ -29,4 +45,36 @@ struct ProviderRegistryTests { #expect(zaiIndex < minimaxIndex) } + + @Test + func `provider confetti palettes are complete and branded`() { + for descriptor in ProviderDescriptorRegistry.all { + let palette = descriptor.branding.confettiPalette + #expect( + (2...3).contains(palette.count), + "Invalid confetti palette for \(descriptor.id.rawValue).") + let hasDistinctColors = palette.first.map { first in + palette.dropFirst().contains { $0 != first } + } ?? false + #expect( + hasDistinctColors, + "Confetti palette for \(descriptor.id.rawValue) must contain distinct colors.") + } + + #expect(ClaudeProviderDescriptor.descriptor.branding.confettiPalette == [ + ProviderColor(hex: 0xD97757), + ProviderColor(hex: 0xF0EEE6), + ProviderColor(hex: 0x141413), + ]) + #expect(CodexProviderDescriptor.descriptor.branding.confettiPalette == [ + ProviderColor(hex: 0x736BD4), + ProviderColor(hex: 0x97A9F7), + ProviderColor(hex: 0xCFD4F7), + ]) + #expect(OpenAIAPIProviderDescriptor.descriptor.branding.confettiPalette == [ + ProviderColor(hex: 0x000000), + ProviderColor(hex: 0x808080), + ProviderColor(hex: 0xFFFFFF), + ]) + } } diff --git a/Tests/CodexBarTests/ProviderSettingsDescriptorTests.swift b/Tests/CodexBarTests/ProviderSettingsDescriptorTests.swift index 43ae2fe0e4..89c1cd752d 100644 --- a/Tests/CodexBarTests/ProviderSettingsDescriptorTests.swift +++ b/Tests/CodexBarTests/ProviderSettingsDescriptorTests.swift @@ -1,11 +1,30 @@ -import CodexBarCore import Foundation import SwiftUI import Testing @testable import CodexBar +@testable import CodexBarCore @MainActor +@Suite(.serialized) struct ProviderSettingsDescriptorTests { + @Test + func `provider settings refresh enables explicit browser retry`() async { + var observedInteraction: ProviderInteraction? + var browserRetryAllowed = false + + await KeychainAccessGate.withTaskOverrideForTesting(false) { + await BrowserCookieAccessGate.withDeniedBrowsersForTesting([.chrome]) { + await ProviderSettingsRefreshInteraction.perform { + observedInteraction = ProviderInteractionContext.current + browserRetryAllowed = BrowserCookieAccessGate.shouldAttempt(.chrome) + } + } + } + + #expect(observedInteraction == .userInitiated) + #expect(browserRetryAllowed) + } + @Test func `toggle I ds are unique across providers`() throws { let fixture = try self.makeSettingsFixture(suite: "ProviderSettingsDescriptorTests-unique") @@ -51,15 +70,220 @@ struct ProviderSettingsDescriptorTests { } @Test - func `codex exposes usage and cookie pickers`() throws { - let fixture = try self.makeSettingsFixture(suite: "ProviderSettingsDescriptorTests-codex") - let context = fixture.settingsContext(provider: .codex) + func `open code cookie refresh commits replacement through user initiated gate`() async throws { + let fixture = try self.makeSettingsFixture(suite: "ProviderSettingsDescriptorTests-opencode-refresh") + let context = fixture.settingsContext(provider: .opencode) + let picker = try #require(OpenCodeProviderImplementation().settingsPickers(context: context).first) + let action = try #require(picker.trailingActions.first) + let service = "com.steipete.codexbar.tests.settings-cookie-refresh.\(UUID().uuidString)" + var observedInteraction: ProviderInteraction? - let pickers = CodexProviderImplementation().settingsPickers(context: context) - let toggles = CodexProviderImplementation().settingsToggles(context: context) - #expect(pickers.contains(where: { $0.id == "codex-usage-source" })) - #expect(pickers.contains(where: { $0.id == "codex-cookie-source" })) - #expect(toggles.contains(where: { $0.id == "codex-historical-tracking" })) + #expect(action.title == "Refresh") + #expect(action.isVisible?() == true) + + await KeychainCacheStore.withServiceOverrideForTesting(service) { + await KeychainCacheStore.withImplicitTestStoreForTesting { + CookieHeaderCache.store( + provider: .opencode, + cookieHeader: "old-test-cookie", + sourceLabel: "Test old") + fixture.store._test_providerRefreshOverride = { provider in + observedInteraction = ProviderInteractionContext.current + CookieHeaderCache.store( + provider: provider, + cookieHeader: "new-test-cookie", + sourceLabel: "Test new") + fixture.store.snapshots[provider] = UsageSnapshot( + primary: nil, + secondary: nil, + updatedAt: Date()) + fixture.store.lastSourceLabels[provider] = "web" + } + defer { fixture.store._test_providerRefreshOverride = nil } + + await action.perform() + + #expect(observedInteraction == .userInitiated) + #expect(CookieHeaderCache.load(provider: .opencode)?.cookieHeader == "new-test-cookie") + #expect(picker.trailingText?()?.contains("Test new") == true) + } + } + } + + @Test + func `ollama automatic cookie source exposes validated refresh action`() throws { + let fixture = try self.makeSettingsFixture(suite: "ProviderSettingsDescriptorTests-ollama-refresh") + let context = fixture.settingsContext(provider: .ollama) + let pickers = OllamaProviderImplementation().settingsPickers(context: context) + let picker = try #require(pickers.first { $0.id == "ollama-cookie-source" }) + let action = try #require(picker.trailingActions.first) + + #expect(action.id == "ollama-reimport-cookie") + #expect(action.title == "Refresh") + #expect(action.isVisible?() == true) + + fixture.settings.ollamaCookieSource = .manual + #expect(action.isVisible?() == false) + #expect(picker.trailingText?() == nil) + + fixture.settings.ollamaCookieSource = .auto + fixture.settings.ollamaUsageDataSource = .api + #expect(action.isVisible?() == false) + #expect(picker.trailingText?() == nil) + } + + @Test + func `open code go cookie refresh rejects local fallback cookie`() async throws { + let fixture = try self.makeSettingsFixture(suite: "ProviderSettingsDescriptorTests-opencodego-validation") + let context = fixture.settingsContext(provider: .opencodego) + let picker = try #require(OpenCodeGoProviderImplementation().settingsPickers(context: context).first) + let action = try #require(picker.trailingActions.first) + let service = "com.steipete.codexbar.tests.settings-cookie-validation.\(UUID().uuidString)" + + await KeychainCacheStore.withServiceOverrideForTesting(service) { + await KeychainCacheStore.withImplicitTestStoreForTesting { + CookieHeaderCache.store( + provider: .opencodego, + cookieHeader: "old-test-cookie", + sourceLabel: "Test old") + fixture.store._test_providerRefreshOverride = { provider in + CookieHeaderCache.store( + provider: provider, + cookieHeader: "invalid-test-cookie", + sourceLabel: "Test invalid") + fixture.store.snapshots[provider] = UsageSnapshot( + primary: nil, + secondary: nil, + updatedAt: Date()) + fixture.store.lastSourceLabels[provider] = "local" + } + defer { fixture.store._test_providerRefreshOverride = nil } + + await action.perform() + + #expect(CookieHeaderCache.load(provider: .opencodego)?.cookieHeader == "old-test-cookie") + #expect(picker.trailingText?() == L("Failed")) + } + } + } + + @Test + func `open code cookie refresh rejects missing validation snapshot`() async throws { + let fixture = try self.makeSettingsFixture(suite: "ProviderSettingsDescriptorTests-opencode-validation") + let context = fixture.settingsContext(provider: .opencode) + let picker = try #require(OpenCodeProviderImplementation().settingsPickers(context: context).first) + let action = try #require(picker.trailingActions.first) + let service = "com.steipete.codexbar.tests.settings-cookie-missing-snapshot.\(UUID().uuidString)" + fixture.store.snapshots[.opencode] = UsageSnapshot( + primary: nil, + secondary: nil, + updatedAt: Date()) + fixture.store.lastSourceLabels[.opencode] = "web" + + await KeychainCacheStore.withServiceOverrideForTesting(service) { + await KeychainCacheStore.withImplicitTestStoreForTesting { + CookieHeaderCache.store( + provider: .opencode, + cookieHeader: "old-test-cookie", + sourceLabel: "Test old") + fixture.store._test_providerRefreshOverride = { provider in + CookieHeaderCache.store( + provider: provider, + cookieHeader: "unvalidated-test-cookie", + sourceLabel: "Test unvalidated") + fixture.store.snapshots.removeValue(forKey: provider) + } + defer { fixture.store._test_providerRefreshOverride = nil } + + await action.perform() + + #expect(CookieHeaderCache.load(provider: .opencode)?.cookieHeader == "old-test-cookie") + #expect(picker.trailingText?() == L("Failed")) + } + } + } + + @Test + func `open code cookie refresh respects denial cooldown and preserves cookie`() async throws { + let fixture = try self.makeSettingsFixture(suite: "ProviderSettingsDescriptorTests-opencode-cooldown") + let context = fixture.settingsContext(provider: .opencode) + let picker = try #require(OpenCodeProviderImplementation().settingsPickers(context: context).first) + let action = try #require(picker.trailingActions.first) + let service = "com.steipete.codexbar.tests.settings-cookie-cooldown.\(UUID().uuidString)" + var cooldownRespected = false + + BrowserCookieAccessGate.resetForTesting() + BrowserCookieAccessGate.recordDenied(for: .chrome) + defer { BrowserCookieAccessGate.resetForTesting() } + + await KeychainCacheStore.withServiceOverrideForTesting(service) { + await KeychainCacheStore.withImplicitTestStoreForTesting { + CookieHeaderCache.store( + provider: .opencode, + cookieHeader: "old-test-cookie", + sourceLabel: "Test old") + fixture.store._test_providerRefreshOverride = { _ in + cooldownRespected = !BrowserCookieAccessGate.shouldAttempt(.chrome) + } + defer { fixture.store._test_providerRefreshOverride = nil } + + await action.perform() + + #expect(cooldownRespected) + #expect(CookieHeaderCache.load(provider: .opencode)?.cookieHeader == "old-test-cookie") + #expect(picker.trailingText?() == L("Failed")) + } + } + } + + @Test + func `antigravity usage source picker clarifies local ide and agy`() throws { + let fixture = try self.makeSettingsFixture(suite: "ProviderSettingsDescriptorTests-antigravity-source") + let context = fixture.settingsContext(provider: .antigravity) + + let pickers = AntigravityProviderImplementation().settingsPickers(context: context) + let usagePicker = try #require(pickers.first(where: { $0.id == "antigravity-usage-source" })) + + #expect(usagePicker.options.map(\.title) == ["Auto", "Google OAuth", "Local API / agy CLI"]) + #expect(usagePicker.subtitle == + "Auto tries Antigravity app, agy CLI, then IDE; OAuth follows for selected or signed-in accounts.") + } + + @Test + func `antigravity exhausted five hour and weekly priority names both surfaces and persists across reopen`() throws { + let suite = "ProviderSettingsDescriptorTests-antigravity-ranking" + let fixture = try self.makeSettingsFixture(suite: suite) + let context = fixture.settingsContext(provider: .antigravity) + + let toggles = AntigravityProviderImplementation().settingsToggles(context: context) + let toggle = try #require(toggles.first { $0.id == "antigravity-prioritize-exhausted-quotas" }) + + #expect(toggle.title == "Prioritize exhausted quotas") + #expect(toggle.subtitle == + "Optional. In Automatic mode, let exhausted five-hour or weekly lanes outrank still-usable model " + + "families. Applies to the menu bar and Overview ranking.") + #expect(toggle.binding.wrappedValue == false) + #expect(fixture.settings.providerConfig(for: .antigravity)?.antigravityPrioritizeExhaustedQuotas == nil) + + toggle.binding.wrappedValue = true + + #expect(fixture.settings.antigravityPrioritizeExhaustedQuotas) + #expect(fixture.settings.providerConfig(for: .antigravity)?.antigravityPrioritizeExhaustedQuotas == true) + + let reopened = try SettingsStore( + userDefaults: #require(UserDefaults(suiteName: suite)), + configStore: testConfigStore(suiteName: suite, reset: false), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + #expect(reopened.antigravityPrioritizeExhaustedQuotas) + + reopened.antigravityPrioritizeExhaustedQuotas = false + let reopenedAfterDisabling = try SettingsStore( + userDefaults: #require(UserDefaults(suiteName: suite)), + configStore: testConfigStore(suiteName: suite, reset: false), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + #expect(reopenedAfterDisabling.antigravityPrioritizeExhaustedQuotas == false) } @Test @@ -88,7 +312,8 @@ struct ProviderSettingsDescriptorTests { let context = fixture.settingsContext(provider: .claude) let pickers = ClaudeProviderImplementation().settingsPickers(context: context) - #expect(pickers.contains(where: { $0.id == "claude-usage-source" })) + let usagePicker = try #require(pickers.first(where: { $0.id == "claude-usage-source" })) + #expect(usagePicker.placement == .connection) #expect(pickers.contains(where: { $0.id == "claude-cookie-source" })) let toggles = ClaudeProviderImplementation().settingsToggles(context: context) #expect(!toggles.contains(where: { $0.id == "claude-peak-hours" })) @@ -101,16 +326,76 @@ struct ProviderSettingsDescriptorTests { } @Test - func `claude prompt policy picker hidden when experimental reader selected`() throws { + func `claude daily routines toggle follows global optional usage setting`() throws { + let fixture = try self.makeSettingsFixture(suite: "ProviderSettingsDescriptorTests-claude-routines") + let context = fixture.settingsContext(provider: .claude) + let toggles = ClaudeProviderImplementation().settingsToggles(context: context) + let routinesToggle = try #require(toggles.first { + $0.id == "claude-daily-routines-usage-visible" + }) + + #expect(routinesToggle.binding.wrappedValue) + #expect(routinesToggle.isEnabled?() == true) + + routinesToggle.binding.wrappedValue = false + #expect(fixture.settings.claudeDailyRoutinesUsageVisible == false) + + fixture.settings.showOptionalCreditsAndExtraUsage = false + #expect(routinesToggle.isEnabled?() == false) + } + + @Test + func `claude single swap account toggle persists and follows integration visibility`() throws { + let fixture = try self.makeSettingsFixture(suite: "ProviderSettingsDescriptorTests-claude-swap-single") + let context = fixture.settingsContext(provider: .claude) + let toggles = ClaudeProviderImplementation().settingsToggles(context: context) + let singleAccountToggle = try #require(toggles.first { + $0.id == "claude-swap-show-single-account" + }) + + #expect(singleAccountToggle.binding.wrappedValue == false) + #expect(singleAccountToggle.isVisible?() == false) + + fixture.settings.claudeSwapEnabled = true + #expect(singleAccountToggle.isVisible?() == true) + singleAccountToggle.binding.wrappedValue = true + + #expect(fixture.settings.claudeSwapShowSingleAccount) + #expect(fixture.settings.configSnapshot.providerConfig(for: .claude)?.claudeSwapShowSingleAccount == true) + } + + @Test + func `claude prompt policy picker remains visible for prompt free toggle`() throws { let fixture = try self.makeSettingsFixture( - suite: "ProviderSettingsDescriptorTests-claude-prompt-hidden-experimental") + suite: "ProviderSettingsDescriptorTests-claude-prompt-visible-prompt-free") fixture.settings.debugDisableKeychainAccess = false - fixture.settings.claudeOAuthKeychainReadStrategy = .securityCLIExperimental + fixture.settings.claudeOAuthPromptFreeCredentialsEnabled = true let context = fixture.settingsContext(provider: .claude) let pickers = ClaudeProviderImplementation().settingsPickers(context: context) let keychainPicker = try #require(pickers.first(where: { $0.id == "claude-keychain-prompt-policy" })) - #expect(keychainPicker.isVisible?() == false) + #expect(keychainPicker.isVisible?() ?? true) + #expect(keychainPicker.binding.wrappedValue == ClaudeOAuthKeychainPromptMode.never.rawValue) + } + + @Test + func `claude avoid keychain prompts toggle is disabled when global keychain disabled`() throws { + let fixture = try self.makeSettingsFixture(suite: "ProviderSettingsDescriptorTests-claude-prompt-free-disabled") + fixture.settings.debugDisableKeychainAccess = true + fixture.settings.claudeOAuthPromptFreeCredentialsEnabled = true + let context = fixture.settingsContext(provider: .claude) + + let toggles = ClaudeProviderImplementation().settingsToggles(context: context) + let promptFreeToggle = try #require(toggles.first(where: { $0.id == "claude-oauth-prompt-free-credentials" })) + #expect(promptFreeToggle.isEnabled?() == false) + #expect(promptFreeToggle.binding.wrappedValue == true) + + promptFreeToggle.binding.wrappedValue = false + #expect(fixture.settings.claudeOAuthPromptFreeCredentialsEnabled == true) + + fixture.settings.debugDisableKeychainAccess = false + #expect(promptFreeToggle.isEnabled?() == true) + #expect(promptFreeToggle.binding.wrappedValue == true) } @Test @@ -153,6 +438,82 @@ struct ProviderSettingsDescriptorTests { #expect(fields.contains(where: { $0.id == "kilo-api-key" })) } + @Test + func `copilot budget secondary picker appears before cookie picker`() throws { + let fixture = try self.makeSettingsFixture(suite: "ProviderSettingsDescriptorTests-copilot-budget-pickers") + fixture.settings.copilotBudgetExtrasEnabled = true + let context = fixture.settingsContext(provider: .copilot) + + let pickers = CopilotProviderImplementation().settingsPickers(context: context) + + #expect(pickers.map(\.id) == ["copilot-icon-secondary-window", "copilot-budget-cookie-source"]) + #expect(pickers.first?.title == "Menu bar secondary metric") + #expect(pickers.first?.placement == .menuBar) + #expect(pickers.last?.placement == .connection) + } + + @Test + func `kiro menu bar display picker uses the menu bar placement`() throws { + let fixture = try self.makeSettingsFixture(suite: "ProviderSettingsDescriptorTests-kiro-placement") + let context = fixture.settingsContext(provider: .kiro) + + let pickers = KiroProviderImplementation().settingsPickers(context: context) + let picker = try #require(pickers.first(where: { $0.id == "kiroMenuBarDisplay" })) + + #expect(picker.placement == .menuBar) + } + + @Test + func `copilot manual cookie field is labelled and refreshable`() throws { + let fixture = try self.makeSettingsFixture(suite: "ProviderSettingsDescriptorTests-copilot-budget-field") + fixture.settings.copilotBudgetExtrasEnabled = true + fixture.settings.copilotBudgetCookieSource = .manual + let context = fixture.settingsContext(provider: .copilot) + + let fields = CopilotProviderImplementation().settingsFields(context: context) + let field = try #require(fields.first { $0.id == "copilot-budget-cookie-header" }) + + #expect(field.title == "Manual GitHub Cookie header") + #expect(field.subtitle.contains("Treat this value like a password")) + #expect(field.actions.map(\.id) == ["refresh-copilot-budget-cookie"]) + } + + @Test + func `kimi exposes usage source picker plus api and cookie fields`() throws { + let fixture = try self.makeSettingsFixture(suite: "ProviderSettingsDescriptorTests-kimi") + let context = fixture.settingsContext(provider: .kimi) + + let implementation = KimiProviderImplementation() + let pickers = implementation.settingsPickers(context: context) + let fields = implementation.settingsFields(context: context) + + let usagePicker = try #require(pickers.first(where: { $0.id == "kimi-usage-source" })) + #expect(usagePicker.options.map(\.id) == ["auto", "api", "web"]) + #expect(usagePicker.subtitle == + "Auto tries your configured API key, then a signed-in Kimi Code CLI credential, then browser cookies.") + #expect(usagePicker.placement == .connection) + #expect(usagePicker.trailingText?() == nil) + fixture.store.lastSourceLabels[.kimi] = "Kimi Code CLI" + #expect(usagePicker.trailingText?() == "Kimi Code CLI") + #expect(pickers.contains(where: { $0.id == "kimi-cookie-source" })) + #expect(fields.contains(where: { $0.id == "kimi-api-key" })) + #expect(fields.contains(where: { $0.id == "kimi-cookie" })) + } + + @Test + func `kimi presentation follows selected source label`() throws { + let fixture = try self.makeSettingsFixture(suite: "ProviderSettingsDescriptorTests-kimi-presentation") + fixture.settings.kimiUsageDataSource = .api + let metadata = try #require(ProviderDescriptorRegistry.metadata[.kimi]) + let context = fixture.presentationContext(provider: .kimi, metadata: metadata) + + let detailLine = KimiProviderImplementation() + .presentation(context: context) + .detailLine(context) + + #expect(detailLine == "api") + } + @Test func `deepgram exposes api key and project id fields`() throws { let fixture = try self.makeSettingsFixture(suite: "ProviderSettingsDescriptorTests-deepgram") @@ -181,7 +542,39 @@ struct ProviderSettingsDescriptorTests { #expect(detailLine == fixture.store.sourceLabel(for: .alibaba)) } +} + +extension ProviderSettingsDescriptorTests { + @Test + func `zoommate presentation surfaces web rather than an undetected version`() throws { + let fixture = try self.makeSettingsFixture(suite: "ProviderSettingsDescriptorTests-zoommate-presentation") + let metadata = try #require(ProviderDescriptorRegistry.metadata[.zoommate]) + let context = fixture.presentationContext(provider: .zoommate, metadata: metadata) + + let detailLine = ZoomMateProviderImplementation() + .presentation(context: context) + .detailLine(context) + + // Web-cookie provider with versionDetector: nil — must not fall back to "zoommate not detected". + #expect(detailLine == "web") + } + + @Test + func `devin presentation follows store source label`() throws { + let fixture = try self.makeSettingsFixture(suite: "ProviderSettingsDescriptorTests-devin-presentation") + fixture.store.lastSourceLabels[.devin] = "web" + let metadata = try #require(ProviderDescriptorRegistry.metadata[.devin]) + let context = fixture.presentationContext(provider: .devin, metadata: metadata) + + let detailLine = DevinProviderImplementation() + .presentation(context: context) + .detailLine(context) + + #expect(detailLine == "web") + } +} +extension ProviderSettingsDescriptorTests { @Test func `alibaba token plan settings expose cookie controls`() throws { let fixture = try self.makeSettingsFixture(suite: "ProviderSettingsDescriptorTests-alibaba-token-plan-settings") @@ -190,13 +583,529 @@ struct ProviderSettingsDescriptorTests { let implementation = AlibabaTokenPlanProviderImplementation() let pickers = implementation.settingsPickers(context: context) let fields = implementation.settingsFields(context: context) + let regionPicker = try #require(pickers.first(where: { $0.id == "alibaba-token-plan-region" })) #expect(pickers.contains(where: { $0.id == "alibaba-token-plan-cookie-source" })) + #expect(Set(regionPicker.options.map(\.id)) == ["intl", "cn", "intl-personal", "cn-personal"]) #expect(fields.contains(where: { $0.id == "alibaba-token-plan-cookie" })) #expect(fields.first?.actions.contains(where: { $0.id == "alibaba-token-plan-open-dashboard" }) == true) } - private func makeSettingsFixture(suite: String) throws -> ProviderSettingsFixture { + @Test + func `deepseek profile picker contains only validated profiles and persists selection`() throws { + let apiKey = "test-deepseek-api-key" + let fixture = try self.makeSettingsFixture( + suite: "ProviderSettingsDescriptorTests-deepseek-profiles", + environmentBase: [DeepSeekSettingsReader.apiKeyEnvironmentKey: apiKey]) + fixture.store.snapshots[.deepseek] = UsageSnapshot( + primary: nil, + secondary: nil, + deepseekPlatformProfiles: [ + DeepSeekPlatformProfile(id: "chrome:Default", name: "Chrome — Personal"), + DeepSeekPlatformProfile(id: "chrome:Profile 2", name: "Chrome — Work"), + ], + updatedAt: Date()) + let context = fixture.settingsContext(provider: .deepseek) + + let picker = try #require(DeepSeekProviderImplementation().settingsPickers(context: context).first) + #expect(picker.options.map(\.id) == ["", "chrome:Default", "chrome:Profile 2"]) + #expect(picker.binding.wrappedValue.isEmpty) + let configRevision = fixture.settings.configRevision + let backgroundWorkRevision = fixture.settings.backgroundWorkSettingsRevision + let providerConfigRevision = fixture.settings.providerConfigRevision(for: .deepseek) + let snapshot = fixture.store.snapshots[.deepseek] + + picker.binding.wrappedValue = "chrome:Profile 2" + #expect(fixture.settings.deepseekProfileID(apiKey: apiKey) == "chrome:Profile 2") + #expect(fixture.settings.providerConfig(for: .deepseek)?.sanitizedDeepSeekProfileID == "chrome:Profile 2") + let expectedScope = try #require(DeepSeekSettingsReader.profileScope( + selectedTokenAccountID: nil, + apiKey: apiKey)) + #expect(fixture.settings.providerConfig(for: .deepseek)?.sanitizedDeepSeekProfileScope == expectedScope) + #expect(fixture.settings.configRevision == configRevision) + #expect(fixture.settings.backgroundWorkSettingsRevision == backgroundWorkRevision) + #expect(fixture.settings.providerConfigRevision(for: .deepseek) == providerConfigRevision + 1) + #expect(fixture.store.snapshots[.deepseek]?.updatedAt == snapshot?.updatedAt) + #expect(fixture.store.snapshots[.deepseek]?.deepseekPlatformProfiles.map(\.id) == [ + "chrome:Default", + "chrome:Profile 2", + ]) + } + + @Test + func `deepseek browser only profile selection persists without an API key`() async throws { + let fixture = try self.makeSettingsFixture( + suite: "ProviderSettingsDescriptorTests-deepseek-browser-only-profile") + fixture.store.snapshots[.deepseek] = UsageSnapshot( + primary: RateWindow( + usedPercent: 0, + windowMinutes: nil, + resetsAt: nil, + resetDescription: "$8.06 (Paid: $8.06 / Granted: $0.00)"), + secondary: nil, + deepseekPlatformProfiles: [ + DeepSeekPlatformProfile(id: "chrome:Default", name: "Chrome — Personal"), + DeepSeekPlatformProfile(id: "chrome:Profile 2", name: "Chrome — Work"), + ], + updatedAt: Date()) + + let picker = try #require(DeepSeekProviderImplementation() + .settingsPickers(context: fixture.settingsContext(provider: .deepseek)).first) + picker.binding.wrappedValue = "chrome:Profile 2" + + #expect(fixture.settings.deepseekProfileID(apiKey: nil) == "chrome:Profile 2") + #expect(fixture.settings.providerConfig(for: .deepseek)?.sanitizedDeepSeekProfileScope != nil) + #expect(fixture.store.deepseekProfileTransitionSnapshot?.primary?.resetDescription == "Refreshing") + + await fixture.store.applySelectedOutcome( + ProviderFetchOutcome( + result: .failure(DeepSeekUsageError.networkError("offline")), + attempts: []), + provider: .deepseek, + account: nil, + fallbackSnapshot: nil) + + #expect(fixture.store.deepseekProfileTransitionSnapshot?.primary?.resetDescription == "Unavailable") + #expect(fixture.store.deepseekProfileTransitionSnapshot?.primary?.resetDescription?.contains("$8.06") == false) + } + + @Test + func `deepseek profile picker stays visible while switching profiles`() throws { + let fixture = try self.makeSettingsFixture(suite: "ProviderSettingsDescriptorTests-deepseek-profile-switch") + let snapshot = UsageSnapshot( + primary: nil, + secondary: nil, + deepseekPlatformProfiles: [ + DeepSeekPlatformProfile(id: "chrome:Default", name: "Chrome — Personal"), + DeepSeekPlatformProfile(id: "chrome:Profile 2", name: "Chrome — Work"), + ], + updatedAt: Date()) + fixture.store.lastKnownResetSnapshots[.deepseek] = snapshot + fixture.store.snapshots.removeValue(forKey: .deepseek) + fixture.store.refreshingProviders.insert(.deepseek) + let context = fixture.settingsContext(provider: .deepseek) + + let picker = try #require(DeepSeekProviderImplementation().settingsPickers(context: context).first) + #expect(picker.options.map(\.id) == ["", "chrome:Default", "chrome:Profile 2"]) + #expect(picker.binding.wrappedValue.isEmpty) + #expect(picker.dynamicSubtitle?() == "Refreshing") + #expect(!(picker.isEnabled?() ?? true)) + } + + @Test + func `deepseek browser profile cancellation does not leave refreshing behind`() async throws { + let fixture = try self.makeSettingsFixture( + suite: "ProviderSettingsDescriptorTests-deepseek-browser-cancelled-transition") + fixture.store.snapshots[.deepseek] = UsageSnapshot( + primary: RateWindow( + usedPercent: 0, + windowMinutes: nil, + resetsAt: nil, + resetDescription: "$8.06"), + secondary: nil, + updatedAt: Date()) + fixture.store.beginDeepSeekProfileTransition(preservingBalance: false) + + await fixture.store.applySelectedOutcome( + ProviderFetchOutcome(result: .failure(CancellationError()), attempts: []), + provider: .deepseek, + account: nil, + fallbackSnapshot: nil) + + #expect(fixture.store.deepseekProfileTransitionSnapshot?.primary?.resetDescription == "Unavailable") + } + + @Test + func `deepseek settings keeps balance while live snapshot is switching`() throws { + let fixture = try self.makeSettingsFixture(suite: "ProviderSettingsDescriptorTests-deepseek-balance-switch") + fixture.store.lastKnownResetSnapshots[.deepseek] = UsageSnapshot( + primary: RateWindow( + usedPercent: 0, + windowMinutes: nil, + resetsAt: nil, + resetDescription: "$9.32 (Paid: $9.32 / Granted: $0.00)"), + secondary: nil, + updatedAt: Date()) + fixture.store.snapshots.removeValue(forKey: .deepseek) + fixture.store.refreshingProviders.insert(.deepseek) + + let model = ProvidersPane(settings: fixture.settings, store: fixture.store) + ._test_menuCardModel(for: .deepseek) + + let balance = try #require(model.metrics.first) + #expect(balance.title == "Balance") + #expect(balance.statusText == "$9.32 (Paid: $9.32 / Granted: $0.00)") + #expect(model.usageNotes.isEmpty) + #expect(model.inlineUsageDashboard == nil) + #expect(model.placeholder == nil) + } + + @Test + func `deepseek profile transition survives selected api token cache invalidation`() throws { + let fixture = try self.makeSettingsFixture(suite: "ProviderSettingsDescriptorTests-deepseek-token-transition") + fixture.settings.addTokenAccount(provider: .deepseek, label: "cv", token: "test-token") + let snapshot = UsageSnapshot( + primary: RateWindow( + usedPercent: 0, + windowMinutes: nil, + resetsAt: nil, + resetDescription: "$8.06 (Paid: $8.06 / Granted: $0.00)"), + secondary: nil, + deepseekUsage: DeepSeekUsageSummary( + todayTokens: 100, + currentMonthTokens: 100, + todayCost: 0.1, + currentMonthCost: 0.1, + requestCount: 1, + currentMonthRequestCount: 1, + topModel: "deepseek-chat", + categoryBreakdown: [], + daily: [], + currency: "USD", + updatedAt: Date()), + deepseekDetailedUsageState: .available, + deepseekPlatformProfiles: [ + DeepSeekPlatformProfile(id: "chrome:Default", name: "Chrome — Personal"), + DeepSeekPlatformProfile(id: "chrome:Profile 2", name: "Chrome — Work"), + ], + updatedAt: Date()) + fixture.store.snapshots[.deepseek] = snapshot + fixture.store.lastKnownResetSnapshots[.deepseek] = snapshot + let context = fixture.settingsContext(provider: .deepseek) + let picker = try #require(DeepSeekProviderImplementation().settingsPickers(context: context).first) + + picker.binding.wrappedValue = "chrome:Profile 2" + fixture.store.refreshingProviders.insert(.deepseek) + fixture.store.reconcileSelectedTokenAccountSnapshotBeforeRefresh( + provider: .deepseek, + accounts: fixture.settings.tokenAccounts(for: .deepseek)) + + #expect(fixture.store.snapshots[.deepseek] == nil) + #expect(fixture.store.lastKnownResetSnapshots[.deepseek] == nil) + let model = ProvidersPane(settings: fixture.settings, store: fixture.store) + ._test_menuCardModel(for: .deepseek) + #expect(model.metrics.first?.statusText == "$8.06 (Paid: $8.06 / Granted: $0.00)") + #expect(model.inlineUsageDashboard == nil) + #expect(model.usageNotes.isEmpty) + #expect(!ProvidersPane(settings: fixture.settings, store: fixture.store) + ._test_providerSubtitle(.deepseek).contains("usage not fetched yet")) + let transitionPicker = try #require(DeepSeekProviderImplementation().settingsPickers(context: context).first) + #expect(transitionPicker.options.map(\.id) == ["chrome:Default", "chrome:Profile 2"]) + #expect(!(transitionPicker.isEnabled?() ?? true)) + + fixture.store.refreshingProviders.remove(.deepseek) + #expect(fixture.store.presentationSnapshot(for: .deepseek)?.primary != nil) + #expect(fixture.store.presentationSnapshot(for: .deepseek)?.deepseekUsage == nil) + + fixture.store.clearDeepSeekProfileTransition() + #expect(fixture.store.presentationSnapshot(for: .deepseek) == nil) + } + + @Test + func `deepseek selected account success clears its profile transition`() async throws { + let fixture = try self.makeSettingsFixture(suite: "ProviderSettingsDescriptorTests-deepseek-transition-success") + fixture.store.snapshots[.deepseek] = UsageSnapshot( + primary: RateWindow( + usedPercent: 0, + windowMinutes: nil, + resetsAt: nil, + resetDescription: "$8.06"), + secondary: nil, + updatedAt: Date()) + fixture.store.beginDeepSeekProfileTransition() + let refreshed = UsageSnapshot( + primary: RateWindow( + usedPercent: 0, + windowMinutes: nil, + resetsAt: nil, + resetDescription: "$7.50"), + secondary: nil, + updatedAt: Date()) + + await fixture.store.applySelectedOutcome( + ProviderFetchOutcome( + result: .success(ProviderFetchResult( + usage: refreshed, + credits: nil, + dashboard: nil, + sourceLabel: "api", + strategyID: "deepseek.api", + strategyKind: .apiToken)), + attempts: []), + provider: .deepseek, + account: nil, + fallbackSnapshot: nil) + + #expect(fixture.store.deepseekProfileTransitionSnapshot == nil) + #expect(fixture.store.presentationSnapshot(for: .deepseek)?.primary?.resetDescription == "$7.50") + } + + @Test + func `deepseek timeout keeps the validated profile catalog with the refreshed balance`() async throws { + let fixture = try self.makeSettingsFixture(suite: "ProviderSettingsDescriptorTests-deepseek-timeout-catalog") + fixture.store.snapshots[.deepseek] = UsageSnapshot( + primary: RateWindow( + usedPercent: 0, + windowMinutes: nil, + resetsAt: nil, + resetDescription: "$8.06"), + secondary: nil, + deepseekDetailedUsageState: .available, + deepseekPlatformProfiles: [ + DeepSeekPlatformProfile(id: "chrome:Default", name: "Chrome — Personal"), + DeepSeekPlatformProfile(id: "chrome:Profile 2", name: "Chrome — Work"), + ], + updatedAt: Date()) + fixture.store.beginDeepSeekProfileTransition() + let refreshedBalance = UsageSnapshot( + primary: RateWindow( + usedPercent: 0, + windowMinutes: nil, + resetsAt: nil, + resetDescription: "$7.50"), + secondary: nil, + deepseekDetailedUsageState: .unavailable, + deepseekPlatformProfiles: [], + updatedAt: Date()) + + await fixture.store.applySelectedOutcome( + ProviderFetchOutcome( + result: .success(ProviderFetchResult( + usage: refreshedBalance, + credits: nil, + dashboard: nil, + sourceLabel: "api", + strategyID: "deepseek.api", + strategyKind: .apiToken)), + attempts: []), + provider: .deepseek, + account: nil, + fallbackSnapshot: nil) + + #expect(fixture.store.deepseekProfileTransitionSnapshot == nil) + #expect(fixture.store.snapshots[.deepseek]?.primary?.resetDescription == "$7.50") + #expect(fixture.store.snapshots[.deepseek]?.deepseekPlatformProfiles.map(\.id) == [ + "chrome:Default", + "chrome:Profile 2", + ]) + let picker = try #require(DeepSeekProviderImplementation() + .settingsPickers(context: fixture.settingsContext(provider: .deepseek)).first) + #expect(picker.options.map(\.id) == ["", "chrome:Default", "chrome:Profile 2"]) + } + + @Test + func `deepseek selected account failure preserves its balance only transition`() async throws { + let fixture = try self.makeSettingsFixture(suite: "ProviderSettingsDescriptorTests-deepseek-transition-failure") + fixture.store.snapshots[.deepseek] = UsageSnapshot( + primary: RateWindow( + usedPercent: 0, + windowMinutes: nil, + resetsAt: nil, + resetDescription: "$8.06"), + secondary: nil, + deepseekUsage: DeepSeekUsageSummary( + todayTokens: 100, + currentMonthTokens: 100, + todayCost: nil, + currentMonthCost: nil, + requestCount: 1, + currentMonthRequestCount: 1, + topModel: nil, + categoryBreakdown: [], + daily: [], + currency: "USD", + updatedAt: Date()), + deepseekDetailedUsageState: .available, + updatedAt: Date()) + fixture.store.beginDeepSeekProfileTransition() + + await fixture.store.applySelectedOutcome( + ProviderFetchOutcome( + result: .failure(DeepSeekUsageError.apiError("offline")), + attempts: []), + provider: .deepseek, + account: nil, + fallbackSnapshot: nil) + + #expect(fixture.store.deepseekProfileTransitionSnapshot != nil) + #expect(fixture.store.presentationSnapshot(for: .deepseek)?.primary?.resetDescription == "$8.06") + #expect(fixture.store.presentationSnapshot(for: .deepseek)?.deepseekUsage == nil) + } + + @Test + func `disabling deepseek clears a failed profile transition`() async throws { + let fixture = try self.makeSettingsFixture(suite: "ProviderSettingsDescriptorTests-deepseek-disable-transition") + fixture.store.snapshots[.deepseek] = UsageSnapshot( + primary: RateWindow( + usedPercent: 0, + windowMinutes: nil, + resetsAt: nil, + resetDescription: "$8.06"), + secondary: nil, + updatedAt: Date()) + fixture.store.beginDeepSeekProfileTransition() + await fixture.store.applySelectedOutcome( + ProviderFetchOutcome( + result: .failure(DeepSeekUsageError.apiError("offline")), + attempts: []), + provider: .deepseek, + account: nil, + fallbackSnapshot: nil) + #expect(fixture.store.presentationSnapshot(for: .deepseek) != nil) + + fixture.store.clearDisabledProviderState(enabledProviders: []) + + #expect(fixture.store.deepseekProfileTransitionSnapshot == nil) + #expect(fixture.store.presentationSnapshot(for: .deepseek) == nil) + } + + @Test + func `deepseek requires explicit replacement when the stored profile expires`() throws { + let apiKey = "test-deepseek-api-key" + let fixture = try self.makeSettingsFixture( + suite: "ProviderSettingsDescriptorTests-deepseek-expired-selection", + environmentBase: [DeepSeekSettingsReader.apiKeyEnvironmentKey: apiKey]) + fixture.settings.setDeepSeekProfileID("chrome:Default", apiKey: apiKey) + fixture.store.snapshots[.deepseek] = UsageSnapshot( + primary: nil, + secondary: nil, + deepseekDetailedUsageState: .profileSelectionRequired, + deepseekPlatformProfiles: [ + DeepSeekPlatformProfile(id: "chrome:Profile 2", name: "Chrome — Work"), + ], + updatedAt: Date()) + + let picker = try #require(DeepSeekProviderImplementation() + .settingsPickers(context: fixture.settingsContext(provider: .deepseek)).first) + #expect(picker.options.map(\.id) == ["", "chrome:Profile 2"]) + #expect(picker.binding.wrappedValue.isEmpty) + } + + @Test + func `deepseek profile transition does not cross api token account selection`() throws { + let fixture = try self.makeSettingsFixture(suite: "ProviderSettingsDescriptorTests-deepseek-account-transition") + fixture.settings.addTokenAccount(provider: .deepseek, label: "Personal", token: "token-1") + fixture.settings.addTokenAccount(provider: .deepseek, label: "Work", token: "token-2") + let workAccount = try #require(fixture.settings.selectedTokenAccount(for: .deepseek)) + fixture.store.snapshots[.deepseek] = UsageSnapshot( + primary: RateWindow( + usedPercent: 0, + windowMinutes: nil, + resetsAt: nil, + resetDescription: "$8.06 Work"), + secondary: nil, + updatedAt: Date()) + fixture.settings.setDeepSeekProfileID("chrome:Profile 2", apiKey: workAccount.token) + fixture.store.beginDeepSeekProfileTransition() + + fixture.settings.setActiveTokenAccountIndex(0, for: .deepseek) + let personalAccount = try #require(fixture.settings.selectedTokenAccount(for: .deepseek)) + #expect(personalAccount.id != workAccount.id) + fixture.store.reconcileSelectedTokenAccountSnapshotBeforeRefresh( + provider: .deepseek, + accounts: fixture.settings.tokenAccounts(for: .deepseek)) + + #expect(fixture.settings.deepseekProfileID(apiKey: personalAccount.token).isEmpty) + #expect(fixture.store.deepseekProfileTransitionSnapshot != nil) + #expect(fixture.store.presentationSnapshot(for: .deepseek) == nil) + } + + @Test + func `replacing a deepseek key in the same account clears its profile selection`() throws { + let fixture = try self.makeSettingsFixture(suite: "ProviderSettingsDescriptorTests-deepseek-replaced-key") + fixture.settings.addTokenAccount(provider: .deepseek, label: "Account", token: "old-key") + let account = try #require(fixture.settings.selectedTokenAccount(for: .deepseek)) + fixture.settings.setDeepSeekProfileID("chrome:Default", apiKey: account.token) + #expect(fixture.settings.deepseekProfileID(apiKey: account.token) == "chrome:Default") + + fixture.settings.updateTokenAccount( + provider: .deepseek, + accountID: account.id, + token: "new-key") + + #expect(fixture.settings.deepseekProfileID(apiKey: "new-key").isEmpty) + #expect(fixture.settings.providerConfig(for: .deepseek)?.sanitizedDeepSeekProfileID == "chrome:Default") + } + + @Test + func `deepseek detailed usage requires cost extras and active api token account`() throws { + let fixture = try self.makeSettingsFixture(suite: "ProviderSettingsDescriptorTests-deepseek-account-usage") + fixture.settings.showOptionalCreditsAndExtraUsage = true + fixture.settings.costSummaryOption = .inlineSummary + #expect(fixture.settings.costSummaryShowsInlineDashboard(for: .deepseek)) + fixture.settings.addTokenAccount(provider: .deepseek, label: "Personal", token: "token-1") + fixture.settings.addTokenAccount(provider: .deepseek, label: "Work", token: "token-2") + let accounts = fixture.settings.tokenAccounts(for: .deepseek) + let active = try #require(fixture.settings.selectedTokenAccount(for: .deepseek)) + let inactive = try #require(accounts.first(where: { $0.id != active.id })) + + #expect(ProviderTokenAccountSelection.shouldIncludeOptionalUsage( + provider: .deepseek, + settings: fixture.settings, + override: TokenAccountOverride(provider: .deepseek, account: active))) + #expect(!ProviderTokenAccountSelection.shouldIncludeOptionalUsage( + provider: .deepseek, + settings: fixture.settings, + override: TokenAccountOverride(provider: .deepseek, account: inactive))) + fixture.settings.costSummaryOption = .costSubmenu + #expect(fixture.settings.costSummaryShowsInlineDashboard(for: .deepseek)) + #expect(ProviderTokenAccountSelection.shouldIncludeOptionalUsage( + provider: .deepseek, + settings: fixture.settings, + override: TokenAccountOverride(provider: .deepseek, account: active))) + fixture.settings.costSummaryOption = .both + #expect(fixture.settings.costSummaryShowsInlineDashboard(for: .deepseek)) + fixture.settings.costSummaryOption = .off + #expect(!fixture.settings.costSummaryShowsInlineDashboard(for: .deepseek)) + #expect(!ProviderTokenAccountSelection.shouldIncludeOptionalUsage( + provider: .deepseek, + settings: fixture.settings, + override: TokenAccountOverride(provider: .deepseek, account: active))) + fixture.settings.showOptionalCreditsAndExtraUsage = false + #expect(!ProviderTokenAccountSelection.shouldIncludeOptionalUsage( + provider: .codex, + settings: fixture.settings, + override: nil)) + fixture.settings.costSummaryOption = .inlineSummary + #expect(!ProviderTokenAccountSelection.shouldIncludeOptionalUsage( + provider: .deepseek, + settings: fixture.settings, + override: TokenAccountOverride(provider: .deepseek, account: active))) + } + + @Test + func `provider settings labels an empty transition as refreshing`() { + #expect(ProviderMetricsInlineView.placeholderText( + isEnabled: true, + isRefreshing: true, + modelPlaceholder: nil) == "Refreshing") + #expect(ProviderMetricsInlineView.placeholderText( + isEnabled: true, + isRefreshing: false, + modelPlaceholder: nil) == "No usage yet") + } + + @Test + func `deepseek hides profile picker when only one validated profile remains`() throws { + let fixture = try self.makeSettingsFixture(suite: "ProviderSettingsDescriptorTests-deepseek-single-profile") + fixture.store.snapshots[.deepseek] = UsageSnapshot( + primary: nil, + secondary: nil, + deepseekPlatformProfiles: [ + DeepSeekPlatformProfile(id: "chrome:Default", name: "Chrome — Personal"), + ], + updatedAt: Date()) + let context = fixture.settingsContext(provider: .deepseek) + + #expect(DeepSeekProviderImplementation().settingsPickers(context: context).isEmpty) + } +} + +extension ProviderSettingsDescriptorTests { + private func makeSettingsFixture( + suite: String, + environmentBase: [String: String] = [:]) throws -> ProviderSettingsFixture + { let defaults = try #require(UserDefaults(suiteName: suite)) defaults.removePersistentDomain(forName: suite) let settings = SettingsStore( @@ -207,7 +1116,8 @@ struct ProviderSettingsDescriptorTests { let store = UsageStore( fetcher: UsageFetcher(environment: [:]), browserDetection: BrowserDetection(cacheTTL: 0), - settings: settings) + settings: settings, + environmentBase: environmentBase) return ProviderSettingsFixture(settings: settings, store: store) } diff --git a/Tests/CodexBarTests/ProviderStorageFootprintTests.swift b/Tests/CodexBarTests/ProviderStorageFootprintTests.swift index 0a903e95e1..78898609b1 100644 --- a/Tests/CodexBarTests/ProviderStorageFootprintTests.swift +++ b/Tests/CodexBarTests/ProviderStorageFootprintTests.swift @@ -1,10 +1,28 @@ import AppKit import CodexBarCore import Foundation +import Observation import Testing @testable import CodexBar struct ProviderStorageFootprintTests { + private final class ObservationFlag: @unchecked Sendable { + private let lock = NSLock() + private var value = false + + func set() { + self.lock.lock() + self.value = true + self.lock.unlock() + } + + func get() -> Bool { + self.lock.lock() + defer { self.lock.unlock() } + return self.value + } + } + @Test func `scanner sums nested regular files and skips symlink targets`() throws { let root = try Self.makeTemporaryDirectory() @@ -90,6 +108,27 @@ struct ProviderStorageFootprintTests { #expect(paths.first == expected) } + @Test + func `cursor path catalog includes application data and caches`() { + let home = FileManager.default.homeDirectoryForCurrentUser + let paths = ProviderStoragePathCatalog.candidatePaths(for: .cursor, environment: [:]) + + #expect(paths == [ + home.appendingPathComponent("Library/Application Support/Cursor", isDirectory: true).path, + home.appendingPathComponent( + "Library/Application Support/Caches/cursor-updater", + isDirectory: true).path, + home.appendingPathComponent(".cursor", isDirectory: true).path, + home.appendingPathComponent("Library/Caches/Cursor", isDirectory: true).path, + home.appendingPathComponent("Library/Caches/com.todesktop.230313mzl4w4u92", isDirectory: true).path, + home.appendingPathComponent("Library/Caches/com.todesktop.230313mzl4w4u92.ShipIt", isDirectory: true).path, + home.appendingPathComponent("Library/Caches/cursor-compile-cache", isDirectory: true).path, + home.appendingPathComponent( + "Library/HTTPStorages/com.todesktop.230313mzl4w4u92", + isDirectory: true).path, + ]) + } + @Test func `claude recommendations use documented cleanup categories`() { let root = "/Users/test/.claude" @@ -272,15 +311,6 @@ struct ProviderStorageFootprintTests { #expect(detailView.copyablePaths.contains("\(root)/file-history")) } - @Test - @MainActor - func `storage path copy button writes exact path to pasteboard`() { - let path = "/Users/test/.claude/projects/example" - StoragePathCopyButton.copyToPasteboard(path) - - #expect(NSPasteboard.general.string(forType: .string) == path) - } - @Test @MainActor func `manual storage refresh updates deleted provider data`() async throws { @@ -321,6 +351,55 @@ struct ProviderStorageFootprintTests { #expect(store.storageFootprintText(for: .codex) == "No local data found") } + @Test + @MainActor + func `repeated identical storage refresh does not republish observable footprints`() async throws { + let home = try Self.makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: home) } + + let codexHome = home.appendingPathComponent(".codex", isDirectory: true) + let sessions = codexHome.appendingPathComponent("sessions", isDirectory: true) + try FileManager.default.createDirectory(at: sessions, withIntermediateDirectories: true) + try Data(repeating: 1, count: 32).write(to: sessions.appendingPathComponent("session.jsonl")) + + let suite = "ProviderStorageFootprintTests-identity-\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + let settings = SettingsStore( + userDefaults: defaults, + configStore: testConfigStore(suiteName: suite), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + if let codexMetadata = ProviderDefaults.metadata[.codex] { + settings.setProviderEnabled(provider: .codex, metadata: codexMetadata, enabled: true) + } + let store = UsageStore( + fetcher: UsageFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + environmentBase: ["CODEX_HOME": codexHome.path]) + settings.providerStorageFootprintsEnabled = true + store.managedCodexAccountsForStorageOverride = [] + + await store.refreshStorageFootprintsNow(for: [.codex]) + #expect(store.storageFootprint(for: .codex)?.totalBytes == 32) + + // A second scan over identical on-disk data must not re-assign the observable property. + // Storage scans run on every menu open and every ~5 min; an unconditional re-publish wakes + // the controller's `menuObservationToken` -> `invalidateMenus` path for no value change. + let didRepublish = ObservationFlag() + withObservationTracking { + _ = store.providerStorageFootprints + } onChange: { + didRepublish.set() + } + await store.refreshStorageFootprintsNow(for: [.codex]) + try? await Task.sleep(for: .milliseconds(50)) + + #expect(didRepublish.get() == false) + #expect(store.storageFootprint(for: .codex)?.totalBytes == 32) + } + @Test @MainActor func `storage refresh is opt in and clears stale footprints when disabled`() async throws { @@ -401,6 +480,69 @@ struct ProviderStorageFootprintTests { #expect(store.storageRefreshGeneration == 41) } + @Test + @MainActor + func `scheduled storage refresh notices managed Codex home changes`() async throws { + let home = try Self.makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: home) } + + let ambientHome = home.appendingPathComponent("ambient", isDirectory: true) + let firstManagedHome = home.appendingPathComponent("managed-a", isDirectory: true) + let secondManagedHome = home.appendingPathComponent("managed-b", isDirectory: true) + try FileManager.default.createDirectory(at: ambientHome, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: firstManagedHome, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: secondManagedHome, withIntermediateDirectories: true) + try Data(repeating: 1, count: 16).write(to: firstManagedHome.appendingPathComponent("session.jsonl")) + try Data(repeating: 2, count: 32).write(to: secondManagedHome.appendingPathComponent("session.jsonl")) + + let suite = "ProviderStorageFootprintTests-managed-refresh-\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + let settings = SettingsStore( + userDefaults: defaults, + configStore: testConfigStore(suiteName: suite), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + if let codexMetadata = ProviderDefaults.metadata[.codex] { + settings.setProviderEnabled(provider: .codex, metadata: codexMetadata, enabled: true) + } + let store = UsageStore( + fetcher: UsageFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + environmentBase: ["CODEX_HOME": ambientHome.path]) + settings.providerStorageFootprintsEnabled = true + store.managedCodexAccountsForStorageOverride = [ + Self.managedCodexAccount(homePath: firstManagedHome.path), + ] + + store.scheduleStorageFootprintRefresh(for: [.codex]) + for _ in 0..<100 where store.isStorageRefreshInFlight { + try await Task.sleep(for: .milliseconds(10)) + } + #expect(store.storageFootprint(for: .codex)?.totalBytes == 16) + + store.managedCodexAccountsForStorageOverride = [ + Self.managedCodexAccount(homePath: secondManagedHome.path), + ] + store.scheduleStorageFootprintRefresh(for: [.codex]) + for _ in 0..<100 where store.isStorageRefreshInFlight { + try await Task.sleep(for: .milliseconds(10)) + } + + #expect(store.storageFootprint(for: .codex)?.totalBytes == 32) + } + + private static func managedCodexAccount(homePath: String) -> ManagedCodexAccount { + ManagedCodexAccount( + id: UUID(), + email: "storage@example.com", + managedHomePath: homePath, + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: nil) + } + private static func makeTemporaryDirectory() throws -> URL { let url = FileManager.default.temporaryDirectory .appendingPathComponent("ProviderStorageFootprintTests-\(UUID().uuidString)", isDirectory: true) diff --git a/Tests/CodexBarTests/ProviderSwitcherEventPeekGateTests.swift b/Tests/CodexBarTests/ProviderSwitcherEventPeekGateTests.swift new file mode 100644 index 0000000000..27cc01113c --- /dev/null +++ b/Tests/CodexBarTests/ProviderSwitcherEventPeekGateTests.swift @@ -0,0 +1,149 @@ +import AppKit +import CoreGraphics +import Testing +@testable import CodexBar + +@MainActor +struct ProviderSwitcherEventPeekGateTests { + @Test + func `first check always peeks`() { + let gate = ProviderSwitcherEventPeekGate(eventTypes: [.keyDown], counterProvider: { _ in 7 }) + #expect(gate.shouldPeek()) + } + + @Test + func `unchanged counters skip the peek`() { + let gate = ProviderSwitcherEventPeekGate(eventTypes: [.keyDown, .leftMouseDown], counterProvider: { _ in 7 }) + #expect(gate.shouldPeek()) + gate.observeQueueEmpty(afterFindingEvent: false) + #expect(gate.shouldPeek()) + gate.observeQueueEmpty(afterFindingEvent: false) + #expect(!gate.shouldPeek()) + #expect(!gate.shouldPeek()) + } + + @Test + func `any advanced counter re-enables the peek`() { + var keyDownCount: UInt32 = 1 + let gate = ProviderSwitcherEventPeekGate( + eventTypes: [.keyDown, .leftMouseDown], + counterProvider: { type in type == .keyDown ? keyDownCount : 3 }) + #expect(gate.shouldPeek()) + gate.observeQueueEmpty(afterFindingEvent: false) + #expect(gate.shouldPeek()) + gate.observeQueueEmpty(afterFindingEvent: false) + #expect(!gate.shouldPeek()) + keyDownCount += 1 + #expect(gate.shouldPeek()) + gate.observeQueueEmpty(afterFindingEvent: false) + #expect(gate.shouldPeek()) + gate.observeQueueEmpty(afterFindingEvent: false) + #expect(!gate.shouldPeek()) + } + + @Test + func `counter change keeps one follow up peek for AppKit queue delivery`() { + var keyDownCount: UInt32 = 1 + let gate = ProviderSwitcherEventPeekGate( + eventTypes: [.keyDown], + counterProvider: { _ in keyDownCount }) + #expect(gate.shouldPeek()) + gate.observeQueueEmpty(afterFindingEvent: false) + #expect(gate.shouldPeek()) + gate.observeQueueEmpty(afterFindingEvent: false) + #expect(!gate.shouldPeek()) + + keyDownCount += 1 + #expect(gate.shouldPeek()) + gate.observeQueueEmpty(afterFindingEvent: false) + #expect(gate.shouldPeek()) + gate.observeQueueEmpty(afterFindingEvent: false) + #expect(!gate.shouldPeek()) + } + + @Test + func `queued unhandled event burst keeps peeking until the queue is empty`() throws { + var eventCount: UInt32 = 1 + let gate = ProviderSwitcherEventPeekGate( + eventTypes: [.keyUp], + counterProvider: { _ in eventCount }) + #expect(gate.shouldPeek()) + gate.observeQueueEmpty(afterFindingEvent: false) + #expect(gate.shouldPeek()) + gate.observeQueueEmpty(afterFindingEvent: false) + #expect(!gate.shouldPeek()) + + eventCount += 3 + #expect(gate.shouldPeek()) + try gate.observe(Self.keyEvent(type: .keyUp, keyCode: 124)) + #expect(gate.shouldPeek()) + try gate.observe(Self.keyEvent(type: .keyUp, keyCode: 124)) + #expect(gate.shouldPeek()) + try gate.observe(Self.keyEvent(type: .keyUp, keyCode: 124)) + #expect(gate.shouldPeek()) + + gate.observeQueueEmpty(afterFindingEvent: false) + #expect(gate.shouldPeek()) + gate.observeQueueEmpty(afterFindingEvent: false) + #expect(!gate.shouldPeek()) + } + + @Test + func `handled event keeps peeking for delayed sibling from same counter snapshot`() throws { + var eventCount: UInt32 = 1 + let gate = ProviderSwitcherEventPeekGate( + eventTypes: [.keyUp], + counterProvider: { _ in eventCount }) + #expect(gate.shouldPeek()) + gate.observeQueueEmpty(afterFindingEvent: false) + #expect(gate.shouldPeek()) + gate.observeQueueEmpty(afterFindingEvent: false) + #expect(!gate.shouldPeek()) + + eventCount += 2 + #expect(gate.shouldPeek()) + try gate.observe(Self.keyEvent(type: .keyUp, keyCode: 124)) + gate.observeQueueEmpty(afterFindingEvent: true) + + #expect(gate.shouldPeek()) + try gate.observe(Self.keyEvent(type: .keyUp, keyCode: 124)) + gate.observeQueueEmpty(afterFindingEvent: true) + + #expect(gate.shouldPeek()) + gate.observeQueueEmpty(afterFindingEvent: false) + #expect(!gate.shouldPeek()) + } + + @Test + func `held key keeps peeking for uncounted autorepeat events`() throws { + let gate = ProviderSwitcherEventPeekGate(eventTypes: [.keyDown, .keyUp], counterProvider: { _ in 7 }) + #expect(gate.shouldPeek()) + gate.observeQueueEmpty(afterFindingEvent: false) + #expect(gate.shouldPeek()) + gate.observeQueueEmpty(afterFindingEvent: false) + #expect(!gate.shouldPeek()) + + try gate.observe(Self.keyEvent(type: .keyDown, keyCode: 124)) + #expect(gate.shouldPeek()) + #expect(gate.shouldPeek()) + + try gate.observe(Self.keyEvent(type: .keyUp, keyCode: 124)) + #expect(gate.shouldPeek()) + gate.observeQueueEmpty(afterFindingEvent: false) + #expect(!gate.shouldPeek()) + } + + private static func keyEvent(type: NSEvent.EventType, keyCode: UInt16) throws -> NSEvent { + try #require(NSEvent.keyEvent( + with: type, + location: .zero, + modifierFlags: [], + timestamp: 0, + windowNumber: 0, + context: nil, + characters: "", + charactersIgnoringModifiers: "", + isARepeat: false, + keyCode: keyCode)) + } +} diff --git a/Tests/CodexBarTests/ProviderTokenResolverTests.swift b/Tests/CodexBarTests/ProviderTokenResolverTests.swift index 9477d0c312..e7887cd795 100644 --- a/Tests/CodexBarTests/ProviderTokenResolverTests.swift +++ b/Tests/CodexBarTests/ProviderTokenResolverTests.swift @@ -135,6 +135,14 @@ struct ProviderTokenResolverTests { #expect(resolution == nil) } + @Test + func `poe resolution uses manual api key`() { + let env = [PoeSettingsReader.apiKeyEnvironmentKey: "manual-key"] + let resolution = ProviderTokenResolver.poeResolution(environment: env) + #expect(resolution?.token == "manual-key") + #expect(resolution?.source == .environment) + } + private func makeCodebuffCredentialsFile(contents: String) throws -> URL { let directory = FileManager.default.temporaryDirectory .appendingPathComponent(UUID().uuidString, isDirectory: true) diff --git a/Tests/CodexBarTests/ProviderVersionDetectionGatingTests.swift b/Tests/CodexBarTests/ProviderVersionDetectionGatingTests.swift new file mode 100644 index 0000000000..45cdd95c92 --- /dev/null +++ b/Tests/CodexBarTests/ProviderVersionDetectionGatingTests.swift @@ -0,0 +1,28 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +@Suite("Provider version detection gating") +@MainActor +struct ProviderVersionDetectionGatingTests { + @Test + func `disabled providers are excluded from version probes`() { + let implementations = UsageStore.versionDetectionImplementations(enabled: [.codex, .claude]) + let ids = Set(implementations.map(\.id)) + #expect(ids == [.codex, .claude]) + #expect(!ids.contains(.antigravity)) + } + + @Test + func `empty enabled set probes nothing`() { + #expect(UsageStore.versionDetectionImplementations(enabled: []).isEmpty) + } + + @Test + func `enabling a provider includes it in version probes`() { + let ids = Set(UsageStore.versionDetectionImplementations( + enabled: [.codex, .antigravity]).map(\.id)) + #expect(ids.contains(.antigravity)) + } +} diff --git a/Tests/CodexBarTests/ProviderVersionDetectorTests.swift b/Tests/CodexBarTests/ProviderVersionDetectorTests.swift index 331948debe..41118de108 100644 --- a/Tests/CodexBarTests/ProviderVersionDetectorTests.swift +++ b/Tests/CodexBarTests/ProviderVersionDetectorTests.swift @@ -1,6 +1,12 @@ import XCTest @testable import CodexBarCore +#if canImport(Darwin) +import Darwin +#else +import Glibc +#endif + final class ProviderVersionDetectorTests: XCTestCase { func test_run_returnsFirstLineForSuccessfulCommand() { let version = ProviderVersionDetector.run( @@ -22,4 +28,415 @@ final class ProviderVersionDetectorTests: XCTestCase { XCTAssertNil(version) XCTAssertLessThan(duration, 2.0) } + + func test_run_returnsOutputWhenDetachedChildKeepsPipeOpen() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("codexbar-version-drain-\(UUID().uuidString)", isDirectory: true) + let childPIDFile = root.appendingPathComponent("child.pid") + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + defer { + if let text = try? String(contentsOf: childPIDFile, encoding: .utf8), + let childPID = pid_t(text.trimmingCharacters(in: .whitespacesAndNewlines)) + { + _ = kill(childPID, SIGKILL) + } + } + + var environment = ProcessInfo.processInfo.environment + environment["CODEXBAR_TEST_CHILD_PID_FILE"] = childPIDFile.path + let script = """ + (trap '' HUP; sleep 5) & + child=$! + printf '%s' "$child" > "$CODEXBAR_TEST_CHILD_PID_FILE" + printf 'grok 1.2.3\\n' + """ + + let start = Date() + let version = ProviderVersionDetector.run( + path: "/bin/sh", + args: ["-c", script], + timeout: 1.0, + environment: environment) + let duration = Date().timeIntervalSince(start) + + XCTAssertEqual(version, "grok 1.2.3") + XCTAssertLessThan(duration, 2.0) + let childPID = try XCTUnwrap( + pid_t(String(contentsOf: childPIDFile, encoding: .utf8) + .trimmingCharacters(in: .whitespacesAndNewlines))) + XCTAssertEqual(kill(childPID, 0), 0, "Descendant should still hold the inherited pipe open") + } + + override func setUp() { + super.setUp() + ProviderVersionDetector.resetHooksAndCache() + } + + override func tearDown() { + ProviderVersionDetector.resetHooksAndCache() + super.tearDown() + } + + private final class MockDetectorState { + var callCount = 0 + var runDelay: TimeInterval? + var runnerResult: TTYCommandRunner.Result? = .init( + text: "claude-code 2.1.70", + completion: .processExited(status: 0)) + let lock = NSLock() + + func increment() -> TTYCommandRunner.Result? { + self.lock.lock() + self.callCount += 1 + let delay = self.runDelay + let res = self.runnerResult + self.lock.unlock() + if let delay { + Thread.sleep(forTimeInterval: delay) + } + return res + } + + func setResult(text: String, completion: TTYCommandRunner.Result.Completion = .processExited(status: 0)) { + self.lock.lock() + self.runnerResult = .init(text: text, completion: completion) + self.lock.unlock() + } + } + + func test_claudeVersion_cachesSuccessfulResult() { + let state = MockDetectorState() + ProviderVersionDetector.whichHook = { _ in "/mock/bin/claude" } + ProviderVersionDetector.attributesHook = { _ in + [ + .modificationDate: Date(timeIntervalSince1970: 1000), + .size: NSNumber(value: 5000), + .systemFileNumber: NSNumber(value: 99), + ] + } + ProviderVersionDetector.runClaudeVersionHook = { _ in + state.increment() + } + + let first = ProviderVersionDetector.claudeVersion() + XCTAssertEqual(first, "claude-code 2.1.70") + XCTAssertEqual(state.callCount, 1) + + let second = ProviderVersionDetector.claudeVersion() + XCTAssertEqual(second, "claude-code 2.1.70") + XCTAssertEqual(state.callCount, 1) + } + + func test_claudeVersion_productionPathProof() { + let state = MockDetectorState() + var size = 5000 + ProviderVersionDetector.whichHook = { _ in "/mock/bin/claude" } + ProviderVersionDetector.attributesHook = { _ in + [ + .modificationDate: Date(timeIntervalSince1970: 1000), + .size: NSNumber(value: size), + .systemFileNumber: NSNumber(value: 99), + ] + } + ProviderVersionDetector.runClaudeVersionHook = { _ in + state.increment() + } + + let cold = ProviderVersionDetector.claudeVersion() + let warm = ProviderVersionDetector.claudeVersion() + size = 6000 + let afterFingerprintChange = ProviderVersionDetector.claudeVersion() + + print( + "ProviderVersionDetector proof: cold=\(cold ?? "nil") " + + "warm=\(warm ?? "nil") " + + "afterFingerprintChange=\(afterFingerprintChange ?? "nil") " + + "productionProbeCount=\(state.callCount)") + XCTAssertEqual(cold, "claude-code 2.1.70") + XCTAssertEqual(warm, "claude-code 2.1.70") + XCTAssertEqual(afterFingerprintChange, "claude-code 2.1.70") + XCTAssertEqual(state.callCount, 2) + } + + func test_claudeVersion_realExecutableProof() throws { + guard ProcessInfo.processInfo.environment["LIVE_CLAUDE_TTY"] == "1" else { + throw XCTSkip("Set LIVE_CLAUDE_TTY=1 to probe the installed Claude executable") + } + guard let path = TTYCommandRunner.which("claude") else { + throw XCTSkip("claude executable is not installed in PATH") + } + + let direct = try XCTUnwrap(ProviderVersionDetector.run(path: path, args: ["--version"])) + let cold = try XCTUnwrap(ProviderVersionDetector.claudeVersion()) + let warm = try XCTUnwrap(ProviderVersionDetector.claudeVersion()) + + print( + "Claude real executable proof: path=\(URL(fileURLWithPath: path).lastPathComponent) " + + "direct=\(direct) cold=\(cold) warm=\(warm)") + XCTAssertEqual(cold, direct) + XCTAssertEqual(warm, direct) + } + + func test_claudeVersion_coalescesConcurrentProbes() { + let state = MockDetectorState() + state.runDelay = 0.1 + ProviderVersionDetector.whichHook = { _ in "/mock/bin/claude" } + ProviderVersionDetector.attributesHook = { _ in + [ + .modificationDate: Date(timeIntervalSince1970: 1000), + .size: NSNumber(value: 5000), + .systemFileNumber: NSNumber(value: 99), + ] + } + ProviderVersionDetector.runClaudeVersionHook = { _ in + state.increment() + } + + let totalThreads = 20 + let semaphore = DispatchSemaphore(value: 0) + + for _ in 0...metricInlinePresentation(metric) == .status("Unavailable")) + } + + @Test + func `provider detail renders ordinary metric progress`() { + let metric = UsageMenuCardView.Model.Metric( + id: "fixture", + title: "Example quota", + percent: 50, + percentStyle: .left, + resetText: nil, + detailText: nil, + detailLeftText: nil, + detailRightText: nil, + pacePercent: nil, + paceOnTop: false) + + #expect(ProviderDetailView.metricInlinePresentation(metric) == .progress) + } + @Test func `opencode manual cookie source hides cached browser trailing text`() { let settings = Self.makeSettingsStore(suite: "ProvidersPaneCoverageTests-opencode-manual") @@ -297,6 +348,7 @@ struct ProvidersPaneCoverageTests { #expect(picker?.dynamicSubtitle?() == "Paste a Cookie header captured from the billing page.") #expect(picker?.trailingText?() == nil) + #expect(picker?.trailingActions.first?.isVisible?() == false) } @Test @@ -312,6 +364,7 @@ struct ProvidersPaneCoverageTests { #expect(picker?.dynamicSubtitle?() == "Paste a Cookie header captured from the billing page.") #expect(picker?.trailingText?() == nil) + #expect(picker?.trailingActions.first?.isVisible?() == false) } @Test @@ -378,7 +431,6 @@ struct ProvidersPaneCoverageTests { minimaxCookieStore: InMemoryMiniMaxCookieStore(), minimaxAPITokenStore: InMemoryMiniMaxAPITokenStore(), kimiTokenStore: InMemoryKimiTokenStore(), - kimiK2TokenStore: InMemoryKimiK2TokenStore(), augmentCookieStore: InMemoryCookieHeaderStore(), ampCookieStore: InMemoryCookieHeaderStore(), copilotTokenStore: InMemoryCopilotTokenStore(), diff --git a/Tests/CodexBarTests/QoderDashboardActionTests.swift b/Tests/CodexBarTests/QoderDashboardActionTests.swift new file mode 100644 index 0000000000..d0cad98ab6 --- /dev/null +++ b/Tests/CodexBarTests/QoderDashboardActionTests.swift @@ -0,0 +1,94 @@ +import CodexBarCore +import SwiftUI +import Testing +@testable import CodexBar + +@MainActor +@Suite(.serialized) +struct QoderDashboardActionTests { + private func makeSettings() -> SettingsStore { + let suite = "QoderDashboardActionTests-\(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.providerDetectionCompleted = true + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + return settings + } + + private func makeStore(settings: SettingsStore) -> UsageStore { + let fetcher = UsageFetcher() + return UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + } + + private func makeContext(settings: SettingsStore, store: UsageStore) -> ProviderSettingsContext { + ProviderSettingsContext( + provider: .qoder, + settings: settings, + store: store, + boolBinding: { keyPath in + Binding( + get: { settings[keyPath: keyPath] }, + set: { settings[keyPath: keyPath] = $0 }) + }, + stringBinding: { keyPath in + Binding( + get: { settings[keyPath: keyPath] }, + set: { settings[keyPath: keyPath] = $0 }) + }, + statusText: { _ in nil }, + setStatusText: { _, _ in }, + lastAppActiveRunAt: { _ in nil }, + setLastAppActiveRunAt: { _, _ in }, + requestConfirmation: { _ in }, + runLoginFlow: {}) + } + + @Test + func `qoder dashboard action follows current manual header`() { + let settings = self.makeSettings() + settings.qoderCookieSource = .manual + settings.qoderCookieHeader = "curl https://qoder.com.cn -H 'Cookie: sid=abc'" + let store = self.makeStore(settings: settings) + let context = self.makeContext(settings: settings, store: store) + let fields = QoderProviderImplementation().settingsFields(context: context) + let action = fields.first { $0.id == "qoder-cookie" }?.actions.first { $0.id == "qoder-open-usage" } + + #expect(action != nil) + #expect(QoderProviderImplementation.usageDashboardURL(settings: settings) == QoderWebSite.china.dashboardURL) + #expect(QoderProviderDescriptor.dashboardURL( + settings: settings.qoderSettingsSnapshot(tokenOverride: nil), + sourceLabel: "manual / qoder.com") == QoderWebSite.china.dashboardURL) + + settings.qoderCookieHeader = "curl https://qoder.com -H 'Host: qoder.com.cn' -H 'Cookie: sid=abc'" + #expect(QoderProviderImplementation.usageDashboardURL(settings: settings) == + QoderWebSite.international.dashboardURL) + + settings.qoderCookieHeader = "curl https://qoder.com -H 'Cookie: sid=abc'" + #expect(QoderProviderImplementation.usageDashboardURL(settings: settings) == + QoderWebSite.international.dashboardURL) + } + + @Test + func `qoder dashboard route trusts generated source label suffix only`() { + let automatic = ProviderSettingsSnapshot.QoderProviderSettings(cookieSource: .auto, manualCookieHeader: nil) + + #expect(QoderProviderDescriptor.dashboardURL( + settings: automatic, + sourceLabel: "Chrome Profile qoder.com.cn / qoder.com") == QoderWebSite.international.dashboardURL) + #expect(QoderProviderDescriptor.dashboardURL( + settings: automatic, + sourceLabel: "Chrome Profile qoder.com / qoder.com.cn") == QoderWebSite.china.dashboardURL) + #expect(QoderProviderDescriptor.dashboardURL( + settings: automatic, + sourceLabel: "Chrome Profile qoder.com.cn") == QoderWebSite.international.dashboardURL) + #expect(QoderProviderDescriptor.dashboardURL( + settings: automatic, + sourceLabel: "Chrome Profile / qoder.com.cn/extra") == QoderWebSite.international.dashboardURL) + } +} diff --git a/Tests/CodexBarTests/QoderProviderBehaviorTests.swift b/Tests/CodexBarTests/QoderProviderBehaviorTests.swift new file mode 100644 index 0000000000..08c369c59e --- /dev/null +++ b/Tests/CodexBarTests/QoderProviderBehaviorTests.swift @@ -0,0 +1,1055 @@ +import Foundation +import Testing +@testable import CodexBar +@testable import CodexBarCLI +@testable import CodexBarCore +#if os(macOS) +import SweetCookieKit +#endif + +struct QoderProviderBehaviorTests { + @MainActor + private final class SessionQuotaNotifierSpy: SessionQuotaNotifying { + private(set) var posts: [(transition: SessionQuotaTransition, provider: UsageProvider)] = [] + private(set) var quotaWarningPosts: [( + event: QuotaWarningEvent, + provider: UsageProvider, + soundEnabled: Bool, + onScreenAlertEnabled: Bool)] = [] + + func post(transition: SessionQuotaTransition, provider: UsageProvider, badge _: NSNumber?) { + self.posts.append((transition: transition, provider: provider)) + } + + func postQuotaWarning( + event: QuotaWarningEvent, + provider: UsageProvider, + soundEnabled: Bool, + onScreenAlertEnabled: Bool) + { + self.quotaWarningPosts.append(( + event: event, + provider: provider, + soundEnabled: soundEnabled, + onScreenAlertEnabled: onScreenAlertEnabled)) + } + } + + private struct StubClaudeFetcher: ClaudeUsageFetching { + func loadLatestUsage(model _: String) async throws -> ClaudeUsageSnapshot { + throw ClaudeUsageError.parseFailed("stub") + } + + func debugRawProbe(model _: String) async -> String { + "stub" + } + + func detectVersion() -> String? { + nil + } + } + + private final class Recorder: @unchecked Sendable { + private let lock = NSLock() + private var cookieHeaders: [String] = [] + private var skippedLabels: [Set] = [] + private var sites: [QoderWebSite] = [] + private var site: QoderWebSite? + + func appendCookieHeader(_ value: String) { + self.lock.withLock { + self.cookieHeaders.append(value) + } + } + + func appendSkippedLabels(_ value: Set) { + self.lock.withLock { + self.skippedLabels.append(value) + } + } + + func setSite(_ value: QoderWebSite) { + self.lock.withLock { + self.site = value + } + } + + func appendSite(_ value: QoderWebSite) { + self.lock.withLock { + self.sites.append(value) + self.site = value + } + } + + func cookieHeadersSnapshot() -> [String] { + self.lock.withLock { self.cookieHeaders } + } + + func skippedLabelsSnapshot() -> [Set] { + self.lock.withLock { self.skippedLabels } + } + + func siteSnapshot() -> QoderWebSite? { + self.lock.withLock { self.site } + } + + func sitesSnapshot() -> [QoderWebSite] { + self.lock.withLock { self.sites } + } + } + + @Test + func `token account selection forces manual cookie source in CLI settings snapshot`() throws { + let accounts = ProviderTokenAccountData( + version: 1, + accounts: [ + ProviderTokenAccount( + id: UUID(), + label: "Qoder", + token: "sid=qoder-account-token", + addedAt: 0, + lastUsed: nil), + ], + activeIndex: 0) + let config = CodexBarConfig(providers: [ + ProviderConfig( + id: .qoder, + cookieSource: .auto, + tokenAccounts: accounts), + ]) + let selection = TokenAccountCLISelection(label: nil, index: nil, allAccounts: false) + let tokenContext = try TokenAccountCLIContext(selection: selection, config: config, verbose: false) + let account = try #require(tokenContext.resolvedAccounts(for: .qoder).first) + let snapshot = try #require(tokenContext.settingsSnapshot(for: .qoder, account: account)) + let qoderSettings = try #require(snapshot.qoder) + + #expect(qoderSettings.cookieSource == .manual) + #expect(qoderSettings.manualCookieHeader == "sid=qoder-account-token") + } + + @Test + func `model shows credit total only as primary detail when reset date missing`() throws { + let now = Date() + let metadata = try #require(ProviderDefaults.metadata[.qoder]) + let snapshot = UsageSnapshot( + primary: RateWindow( + usedPercent: 25, + windowMinutes: nil, + resetsAt: nil, + resetDescription: "125 / 500 credits"), + secondary: nil, + tertiary: nil, + updatedAt: now, + identity: ProviderIdentitySnapshot( + providerID: .qoder, + accountEmail: nil, + accountOrganization: nil, + loginMethod: nil)) + + let model = UsageMenuCardView.Model.make(.init( + provider: .qoder, + metadata: metadata, + snapshot: snapshot, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + + let primary = try #require(model.metrics.first) + #expect(primary.resetText == nil) + #expect(primary.detailText == "125 / 500 credits") + #expect(model.creditsText == nil) + #expect(model.creditsHintText == nil) + } + + @Test + func `model shows reset countdown with credit detail`() throws { + let now = Date(timeIntervalSince1970: 1_719_206_400) + let snapshot = QoderUsageSnapshot( + usedCredits: 125, + totalCredits: 500, + remainingCredits: 375, + usagePercentage: 25, + unit: "credit", + resetsAt: now.addingTimeInterval(86400), + updatedAt: now).toUsageSnapshot() + let metadata = try #require(ProviderDefaults.metadata[.qoder]) + + let model = UsageMenuCardView.Model.make(.init( + provider: .qoder, + metadata: metadata, + snapshot: snapshot, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + + let primary = try #require(model.metrics.first) + #expect(primary.resetText != nil) + #expect(primary.detailText == "125 / 500 credits") + } + + @MainActor + @Test + func `standard menu shows credit total as detail instead of reset line`() throws { + let suite = "QoderProviderBehaviorTests-menu-detail" + 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.usageBarsShowUsed = false + + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings) + let snapshot = UsageSnapshot( + primary: RateWindow( + usedPercent: 25, + windowMinutes: nil, + resetsAt: nil, + resetDescription: "125 / 500 credits"), + secondary: nil, + tertiary: nil, + updatedAt: Date(), + identity: ProviderIdentitySnapshot( + providerID: .qoder, + accountEmail: nil, + accountOrganization: nil, + loginMethod: nil)) + store._setSnapshotForTesting(snapshot, provider: .qoder) + + let descriptor = MenuDescriptor.build( + provider: .qoder, + store: store, + settings: settings, + account: AccountInfo(email: nil, plan: nil), + updateReady: false, + includeContextualActions: false) + + let textLines = descriptor.sections + .flatMap(\.entries) + .compactMap { entry -> String? in + guard case let .text(text, _) = entry else { return nil } + return text + } + + #expect(textLines.contains("125 / 500 credits")) + #expect(!textLines.contains(where: { $0.contains("Resets 125 / 500 credits") })) + } +} + +struct QoderManualCookieRoutingTests { + @Test + func `manual cookie header can route to Qoder China site`() { + #expect(QoderWebFetchStrategy.site(forManualCookieHeader: "sid=abc") == .international) + #expect(QoderWebFetchStrategy.site(forManualCookieHeader: "sid=qoder.com.cn-looking-value") == .international) + #expect(QoderWebFetchStrategy + .site(forManualCookieHeader: "sid=abc; note=curl https://qoder.com.cn") == .international) + #expect(QoderWebFetchStrategy + .site(forManualCookieHeader: "sid=abc; redirect=https://example.com/curl") == .international) + #expect(QoderWebFetchStrategy.site(forManualCookieHeader: "sid=abc; Domain=.qoder.com.cn") == .china) + #expect(QoderWebFetchStrategy.site(forManualCookieHeader: "sid=abc; Domain=qoder.com.cn") == .china) + #expect(QoderWebFetchStrategy.site(forManualCookieHeader: "sid=abc; Domain=www.qoder.com.cn") == .china) + #expect(QoderWebFetchStrategy + .site(forManualCookieHeader: "curl https://qoder.com.cn -H 'Cookie: sid=abc'") == .china) + #expect(QoderWebFetchStrategy + .site(forManualCookieHeader: "HTTPS_PROXY=http://127.0.0.1:8080 curl https://qoder.com.cn") == .china) + #expect(QoderWebFetchStrategy + .site( + forManualCookieHeader: + "HTTPS_PROXY=http://127.0.0.1:8080 \\\ncurl https://qoder.com.cn -H 'Cookie: sid=abc'") == + .china) + #expect(QoderWebFetchStrategy + .site(forManualCookieHeader: "\\\ncurl https://qoder.com.cn -H 'Cookie: sid=abc'") == .china) + #expect(QoderWebFetchStrategy + .site(forManualCookieHeader: "\\\r\ncurl https://qoder.com -H 'Cookie: sid=abc'") == .international) + #expect(QoderWebFetchStrategy + .site( + forManualCookieHeader: + "curl https://qoder.com -H 'Origin: https://qoder.com' " + + "-H 'Referer: https://qoder.com/account/usage' -H 'Cookie: sid=abc'") == .international) + #expect(QoderWebFetchStrategy + .site( + forManualCookieHeader: + "curl https://qoder.com.cn -H 'Origin: https://qoder.com.cn' " + + "-H 'Referer: https://qoder.com.cn/account/usage' -H 'Cookie: sid=abc'") == .china) + #expect(QoderWebFetchStrategy + .site(forManualCookieHeader: "curl https://www.qoder.com.cn -H 'Cookie: sid=abc'") == .china) + #expect(QoderWebFetchStrategy + .site(forManualCookieHeader: "curl --url https://qoder.com.cn -H 'Cookie: sid=abc'") == .china) + #expect(QoderWebFetchStrategy + .site(forManualCookieHeader: "curl --url https://qoder.com --data 'x=1; Domain=qoder.com.cn'") == + .international) + #expect(QoderWebFetchStrategy + .site( + forManualCookieHeader: + "curl https://qoder.com --data 'GET /account/usage HTTP/1.1\nHost: qoder.com.cn'") == nil) + #expect(QoderWebFetchStrategy.site(forManualCookieHeader: "GET https://qoder.com.cn/account/usage") == .china) + #expect(QoderWebFetchStrategy.site(forManualCookieHeader: "GET /account/usage HTTP/1.1\nHost: qoder.com.cn") == + .china) + #expect(QoderWebFetchStrategy + .site(forManualCookieHeader: "GET /account/usage HTTP/1.1\nHost: www.qoder.com.cn") == + .china) + #expect(QoderWebFetchStrategy + .site(forManualCookieHeader: "GET /account/usage HTTP/1.1\nHost: qoder.com.cn:443") == + .china) + #expect(QoderWebFetchStrategy + .site(forManualCookieHeader: "GET /account/usage HTTP/1.1\nHost: qoder.com.cn:evil") == nil) + #expect(QoderWebFetchStrategy + .site(forManualCookieHeader: "GET /account/usage HTTP/1.1\nHost: qoder.com.cn:") == nil) + #expect(QoderWebFetchStrategy + .site(forManualCookieHeader: "GET /account/usage HTTP/1.1\nHost: qoder.com.cn:65536") == nil) + #expect(QoderWebFetchStrategy + .site(forManualCookieHeader: "GET /account/usage HTTP/1.1\nHost: qoder.com.cn:443:444") == nil) + #expect(QoderWebFetchStrategy.site(forManualCookieHeader: "GET /account/usage HTTP/1.1\nHost: qoder.com") == + .international) + #expect(QoderWebFetchStrategy + .site(forManualCookieHeader: "TRACE /account/usage HTTP/1.1\nHost: qoder.com.cn") == + nil) + #expect(QoderWebFetchStrategy.site(forManualCookieHeader: "CONNECT qoder.com.cn:443 HTTP/1.1") == nil) + #expect(QoderWebFetchStrategy.site(forManualCookieHeader: "BREW /account/usage HTTP/1.1\nHost: qoder.com.cn") == + nil) + #expect(QoderWebFetchStrategy + .site( + forManualCookieHeader: + "curl -H 'Referer: https://qoder.com.cn/account/usage' https://qoder.com -H 'Cookie: sid=abc'") == nil) + #expect(QoderWebFetchStrategy + .site( + forManualCookieHeader: + "curl --proxy-header 'X: https://qoder.com.cn' https://qoder.com -H 'Cookie: sid=abc'") == nil) + #expect(QoderWebFetchStrategy.site(forManualCookieHeader: "curl -X GET https://qoder.com.cn") == nil) + #expect(QoderWebFetchStrategy.site(forManualCookieHeader: "sudo curl https://qoder.com.cn") == nil) + #expect(QoderWebFetchStrategy.site(forManualCookieHeader: "sid=abc; curl https://qoder.com.cn") == nil) + #expect(QoderWebFetchStrategy + .site(forManualCookieHeader: "curl https://qoder.com/account https://qoder.com/profile") == nil) + #expect(QoderWebFetchStrategy + .site(forManualCookieHeader: "curl --url https://qoder.com/account https://qoder.com/profile") == nil) + #expect(QoderWebFetchStrategy + .site(forManualCookieHeader: "curl https://qoder.com https://qoder.com.cn -H 'Cookie: sid=abc'") == nil) + #expect(QoderWebFetchStrategy + .site(forManualCookieHeader: "curl https://example.com -H 'Cookie: sid=abc'") == nil) + #expect(QoderWebFetchStrategy + .site(forManualCookieHeader: "GET https://qoder.com/account/usage HTTP/1.1\nHost: qoder.com.cn") == nil) + #expect(QoderWebFetchStrategy + .site(forManualCookieHeader: "GET https://qoder.com/account/usage HTTP/1.1\nHost: example.com") == nil) + #expect(QoderWebFetchStrategy.site(forManualCookieHeader: "GET /account/usage HTTP/1.1\nHost: example.com") == + nil) + } + + @Test + func `manual curl Host headers must match authoritative Qoder target`() { + #expect(QoderWebFetchStrategy + .site( + forManualCookieHeader: + "curl https://qoder.com -H 'Host: qoder.com' -H 'Cookie: sid=abc'") == .international) + #expect(QoderWebFetchStrategy + .site( + forManualCookieHeader: + "curl https://qoder.com.cn -H 'Host: www.qoder.com.cn:443' -H 'Cookie: sid=abc'") == .china) + #expect(QoderWebFetchStrategy + .site( + forManualCookieHeader: + "curl https://qoder.com.cn -sH 'Host: qoder.com.cn' -H 'Cookie: sid=abc'") == .china) + #expect(QoderWebFetchStrategy + .site( + forManualCookieHeader: + "curl https://qoder.com.cn -fsSLHHost:qoder.com.cn -H 'Cookie: sid=abc'") == .china) + #expect(QoderWebFetchStrategy + .site( + forManualCookieHeader: + "curl https://qoder.com.cn -HHost:qoder.com.cn -H 'Cookie: sid=abc'") == .china) + #expect(QoderWebFetchStrategy + .site( + forManualCookieHeader: + "curl https://qoder.com.cn --header=Host:qoder.com.cn -H 'Cookie: sid=abc'") == .china) + #expect(QoderWebFetchStrategy + .site( + forManualCookieHeader: + "curl https://qoder.com.cn \\\n-H 'Host: qoder.com.cn' \\\r\n-H 'Cookie: sid=abc'") == .china) + + #expect(QoderWebFetchStrategy + .site( + forManualCookieHeader: + "curl https://qoder.com -H 'Host: qoder.com.cn' -H 'Cookie: sid=abc'") == nil) + #expect(QoderWebFetchStrategy + .site( + forManualCookieHeader: + "curl https://qoder.com.cn -H 'Host: qoder.com' -H 'Cookie: sid=abc'") == nil) + #expect(QoderWebFetchStrategy + .site( + forManualCookieHeader: + "curl https://qoder.com.cn -H 'Host: qoder.com.cn:evil' -H 'Cookie: sid=abc'") == nil) + #expect(QoderWebFetchStrategy + .site( + forManualCookieHeader: + "curl https://qoder.com.cn -H 'Host: qoder.com.cn' -H 'Host: qoder.com' -H 'Cookie: sid=abc'") == + nil) + #expect(QoderWebFetchStrategy + .site( + forManualCookieHeader: + "curl https://qoder.com -sH 'Host: qoder.com.cn' -H 'Cookie: sid=abc'") == nil) + #expect(QoderWebFetchStrategy + .site( + forManualCookieHeader: + "curl https://qoder.com -fsSLHHost:qoder.com.cn -H 'Cookie: sid=abc'") == nil) + #expect(QoderWebFetchStrategy + .site( + forManualCookieHeader: + "curl https://qoder.com -HHost:qoder.com.cn -H 'Cookie: sid=abc'") == nil) + #expect(QoderWebFetchStrategy + .site( + forManualCookieHeader: + "curl https://qoder.com.cn -XH 'Host: qoder.com.cn' -H 'Cookie: sid=abc'") == nil) + #expect(QoderWebFetchStrategy + .site( + forManualCookieHeader: + "curl https://qoder.com -H @headers.txt -H 'Cookie: sid=abc'") == nil) + #expect(QoderWebFetchStrategy + .site( + forManualCookieHeader: + "curl https://qoder.com --header @- -H 'Cookie: sid=abc'") == nil) + #expect(QoderWebFetchStrategy + .site( + forManualCookieHeader: + "curl https://qoder.com.cn -H 'Host:' -H 'Cookie: sid=abc'") == nil) + #expect(QoderWebFetchStrategy + .site( + forManualCookieHeader: + "curl https://qoder.com.cn -H 'Host;' -H 'Cookie: sid=abc'") == nil) + #expect(QoderWebFetchStrategy + .site( + forManualCookieHeader: + "curl https://qoder.com.cn --header=Host\\; -H 'Cookie: sid=abc'") == nil) + #expect(QoderWebFetchStrategy + .site( + forManualCookieHeader: + "curl https://qoder.com.cn -sHHost\\; -H 'Cookie: sid=abc'") == nil) + #expect(QoderWebFetchStrategy + .site( + forManualCookieHeader: + "curl https://qoder.com.cn -K qoder.curlrc -H 'Cookie: sid=abc'") == nil) + #expect(QoderWebFetchStrategy + .site( + forManualCookieHeader: + "curl https://qoder.com.cn --config qoder.curlrc -H 'Cookie: sid=abc'") == nil) + #expect(QoderWebFetchStrategy + .site( + forManualCookieHeader: + "curl https://qoder.com.cn --config=qoder.curlrc -H 'Cookie: sid=abc'") == nil) + #expect(QoderWebFetchStrategy + .site( + forManualCookieHeader: + "curl https://qoder.com --variable site=qoder.com.cn --expand-header 'Host: {{site}}' " + + "-H 'Cookie: sid=abc'") == nil) + #expect(QoderWebFetchStrategy + .site( + forManualCookieHeader: + "curl https://qoder.com --variable site=qoder.com.cn --expand-url 'https://{{site}}' " + + "-H 'Cookie: sid=abc'") == nil) + #expect(QoderWebFetchStrategy + .site( + forManualCookieHeader: + "curl https://qoder.com.cn --expand-config '{{config}}' -H 'Cookie: sid=abc'") == nil) + #expect(QoderWebFetchStrategy + .site( + forManualCookieHeader: + "curl https://qoder.com.cn ; echo -H 'Cookie: sid=global'") == nil) + #expect(QoderWebFetchStrategy + .site( + forManualCookieHeader: + "curl https://qoder.com.cn | cat -H 'Cookie: sid=global'") == nil) + #expect(QoderWebFetchStrategy + .site( + forManualCookieHeader: + "curl https://qoder.com.cn > headers.txt -H 'Cookie: sid=abc'") == nil) + #expect(QoderWebFetchStrategy + .site( + forManualCookieHeader: + "curl https://qoder.com && echo done -H 'Cookie: sid=abc'") == nil) + #expect(QoderWebFetchStrategy + .site( + forManualCookieHeader: + "curl 'https://qoder.com/account/usage?a=1&b=2;next=ok' -H 'Cookie: sid=abc'") == + .international) + #expect(QoderWebFetchStrategy + .site( + forManualCookieHeader: + "curl https://qoder.com -H 'X-Note: a;b|c&d=' -H 'Cookie: sid=abc'") == .international) + } + + @Test + func `manual curl rejects shell synthesis and injected controls`() { + #expect(QoderWebFetchStrategy + .site( + forManualCookieHeader: + "curl https://qoder.com --location-trusted -H 'Cookie: sid=abc'") == nil) + #expect(QoderWebFetchStrategy + .site( + forManualCookieHeader: + "curl https://qoder.com -A $'agent\r\nHost: qoder.com.cn' -H 'Cookie: sid=abc'") == nil) + #expect(QoderWebFetchStrategy + .site( + forManualCookieHeader: + "curl https://qoder.com --referer $'https://qoder.com\r\nHost: qoder.com.cn' " + + "-H 'Cookie: sid=abc'") == nil) + #expect(QoderWebFetchStrategy + .site( + forManualCookieHeader: + "curl https://qoder.com -A \\'$'agent\\r\\nHost: qoder.com.cn'\\' -H 'Cookie: sid=abc'") == nil) + #expect(QoderWebFetchStrategy + .site( + forManualCookieHeader: + "curl https://qoder.com -A \\'$'agent\r\nHost: qoder.com.cn'\\' -H 'Cookie: sid=abc'") == nil) + #expect(QoderWebFetchStrategy + .site( + forManualCookieHeader: + "curl https://qoder.com -H 'User-Agent: agent\\\nHost: qoder.com.cn' -H 'Cookie: sid=abc'") == nil) + #expect(QoderWebFetchStrategy + .site( + forManualCookieHeader: + "curl https://qoder.com -A $'agent\\r\\nHost: qoder.com.cn' -H 'Cookie: sid=abc'") == nil) + #expect(QoderWebFetchStrategy + .site( + forManualCookieHeader: + "curl https://qoder.com -A $(printf agent) -H 'Cookie: sid=abc'") == nil) + #expect(QoderWebFetchStrategy + .site( + forManualCookieHeader: + "curl https://qoder.com -A `printf agent` -H 'Cookie: sid=abc'") == nil) + #expect(QoderWebFetchStrategy + .site( + forManualCookieHeader: + "curl https://qoder.com -A $AGENT -H 'Cookie: sid=abc'") == nil) + #expect(QoderWebFetchStrategy + .site( + forManualCookieHeader: + "\"curl\" https://qoder.com.cn -A $AGENT -H 'Cookie: sid=abc'") == nil) + #expect(QoderWebFetchStrategy + .site( + forManualCookieHeader: + "'/usr/bin/curl' https://qoder.com.cn -A $AGENT -H 'Cookie: sid=abc'") == nil) + #expect(QoderWebFetchStrategy + .site( + forManualCookieHeader: + "\\curl https://qoder.com.cn -A $AGENT -H 'Cookie: sid=abc'") == nil) + #expect(QoderWebFetchStrategy + .site( + forManualCookieHeader: + "QODER_AGENT=$AGENT \\\ncurl https://qoder.com.cn -H 'Cookie: sid=abc'") == nil) + #expect(QoderWebFetchStrategy + .site( + forManualCookieHeader: + "curl https://qoder.com -H \"User-Agent: $AGENT\" -H 'Cookie: sid=abc'") == nil) + #expect(QoderWebFetchStrategy + .site( + forManualCookieHeader: + "curl https://qoder.com -A $\"agent\" -H 'Cookie: sid=abc'") == nil) + #expect(QoderWebFetchStrategy + .site( + forManualCookieHeader: + "curl https://qoder.com -H @<(printf 'Host: qoder.com.cn') -H 'Cookie: sid=abc'") == nil) + #expect(QoderWebFetchStrategy + .site( + forManualCookieHeader: + "curl https://qoder.com -A \\'literal\\' -H 'Cookie: sid=abc'") == .international) + #expect(QoderWebFetchStrategy + .site( + forManualCookieHeader: + "curl https://qoder.com -A \\\"literal\\\" -H 'Cookie: sid=abc'") == .international) + #expect(QoderWebFetchStrategy + .site( + forManualCookieHeader: + "curl https://qoder.com -A literal\\\\slash -H 'Cookie: sid=abc'") == .international) + } +} + +extension QoderProviderBehaviorTests { + #if os(macOS) + @Test + func `importer exact domain filter keeps China cookies out of global sessions`() { + let records = [ + Self.cookieRecord(domain: "qoder.com", name: "global", value: "1"), + Self.cookieRecord(domain: ".qoder.com.cn", name: "china", value: "1"), + Self.cookieRecord(domain: "www.qoder.com.cn", name: "china-www", value: "1"), + ] + + let filtered = QoderCookieImporter.records(records, for: .international) + + #expect(QoderCookieImporter.cookieQuery(for: .international).domainMatch == .exact) + #expect(filtered.map(\.name) == ["global"]) + } + + @Test + func `importer exact domain filter keeps global cookies out of China sessions`() { + let records = [ + Self.cookieRecord(domain: ".qoder.com", name: "global", value: "1"), + Self.cookieRecord(domain: "qoder.com.cn", name: "china", value: "1"), + Self.cookieRecord(domain: ".www.qoder.com.cn", name: "china-www", value: "1"), + ] + + let filtered = QoderCookieImporter.records(records, for: .china) + + #expect(QoderCookieImporter.cookieQuery(for: .china).domainMatch == .exact) + #expect(filtered.map(\.name) == ["china", "china-www"]) + } + #endif + + @Test + func `auto cookie fetch retries every imported candidate before succeeding`() async throws { + let candidates = [ + QoderResolvedCookie(cookieHeader: "sid=expired-one", sourceLabel: "Chrome Default / qoder.com"), + QoderResolvedCookie(cookieHeader: "sid=expired-two", sourceLabel: "Chrome Profile 2 / qoder.com.cn"), + QoderResolvedCookie(cookieHeader: "sid=valid", sourceLabel: "Chrome Profile 3 / qoder.com.cn"), + ] + let recorder = Recorder() + let strategy = QoderWebFetchStrategy( + usageLoader: { cookieHeader, _, _ in + recorder.appendCookieHeader(cookieHeader) + if cookieHeader != "sid=valid" { + throw QoderUsageError.invalidCredentials + } + return QoderUsageSnapshot( + usedCredits: 125, + totalCredits: 500, + remainingCredits: 375, + usagePercentage: 25, + unit: "credit") + }, + cookieResolver: { _, _, skippedLabels in + recorder.appendSkippedLabels(skippedLabels) + return candidates.first { !skippedLabels.contains($0.sourceLabel) } + }) + + let result = try await strategy.fetch(self.makeContext(settings: .make( + qoder: .init(cookieSource: .auto, manualCookieHeader: nil)))) + + #expect(recorder.cookieHeadersSnapshot() == ["sid=expired-one", "sid=expired-two", "sid=valid"]) + #expect(recorder.skippedLabelsSnapshot() == [ + Set(), + ["Chrome Default / qoder.com"], + ["Chrome Default / qoder.com", "Chrome Profile 2 / qoder.com.cn"], + ]) + #expect(result.sourceLabel == "Chrome Profile 3 / qoder.com.cn") + #expect(result.usage.primary?.resetDescription == "125 / 500 credits") + } + + @Test + func `auto cookie source label trusts authoritative suffix over browser label text`() async throws { + let recorder = Recorder() + let strategy = QoderWebFetchStrategy( + usageLoader: { _, site, _ in + recorder.appendSite(site) + return QoderUsageSnapshot( + usedCredits: 125, + totalCredits: 500, + remainingCredits: 375, + usagePercentage: 25, + unit: "credit") + }, + cookieResolver: { _, _, _ in + QoderResolvedCookie( + cookieHeader: "sid=global", + sourceLabel: "Chrome Profile qoder.com.cn / qoder.com") + }) + + let result = try await strategy.fetch(self.makeContext(settings: .make( + qoder: .init(cookieSource: .auto, manualCookieHeader: nil)))) + + #expect(recorder.sitesSnapshot() == [.international]) + #expect(result.sourceLabel == "Chrome Profile qoder.com.cn / qoder.com") + } + + @Test + func `auto cookie fetch retries freshly imported session after stale cache`() async throws { + let sourceLabel = "Chrome Default / qoder.com" + let recorder = Recorder() + let strategy = QoderWebFetchStrategy( + usageLoader: { cookieHeader, _, _ in + recorder.appendCookieHeader(cookieHeader) + if cookieHeader == "sid=expired-cache" { + throw QoderUsageError.invalidCredentials + } + return QoderUsageSnapshot( + usedCredits: 125, + totalCredits: 500, + remainingCredits: 375, + usagePercentage: 25, + unit: "credit") + }, + cookieResolver: { _, allowCached, skippedLabels in + recorder.appendSkippedLabels(skippedLabels) + if allowCached { + return QoderResolvedCookie( + cookieHeader: "sid=expired-cache", + sourceLabel: sourceLabel, + isFromCache: true) + } + return QoderResolvedCookie(cookieHeader: "sid=fresh", sourceLabel: sourceLabel) + }) + + let result = try await strategy.fetch(self.makeContext(settings: .make( + qoder: .init(cookieSource: .auto, manualCookieHeader: nil)))) + + #expect(recorder.cookieHeadersSnapshot() == ["sid=expired-cache", "sid=fresh"]) + #expect(recorder.skippedLabelsSnapshot() == [Set(), Set()]) + #expect(result.sourceLabel == sourceLabel) + } + + @Test + func `manual cookie fetch uses China endpoint when header identifies China site`() async throws { + let recorder = Recorder() + let strategy = QoderWebFetchStrategy( + usageLoader: { _, site, _ in + recorder.setSite(site) + return QoderUsageSnapshot( + usedCredits: 0, + totalCredits: 300, + remainingCredits: 300, + usagePercentage: 0, + unit: "credit") + }) + + let result = try await strategy.fetch(self.makeContext(settings: .make( + qoder: .init( + cookieSource: .manual, + manualCookieHeader: "curl https://qoder.com.cn -H 'Cookie: sid=china'")))) + + #expect(recorder.siteSnapshot() == .china) + #expect(result.sourceLabel == "manual / qoder.com.cn") + } + + @Test + func `manual cookie value that looks like China domain stays on global endpoint`() async throws { + let recorder = Recorder() + let strategy = QoderWebFetchStrategy( + usageLoader: { _, site, _ in + recorder.appendSite(site) + return QoderUsageSnapshot( + usedCredits: 0, + totalCredits: 300, + remainingCredits: 300, + usagePercentage: 0, + unit: "credit") + }) + + let result = try await strategy.fetch(self.makeContext(settings: .make( + qoder: .init( + cookieSource: .manual, + manualCookieHeader: "sid=qoder.com.cn-looking-value")))) + + #expect(recorder.sitesSnapshot() == [.international]) + #expect(result.sourceLabel == "manual / qoder.com") + } + + @Test + func `manual request-like cookie with ambiguous target fails before request`() async { + let recorder = Recorder() + let strategy = QoderWebFetchStrategy( + usageLoader: { _, site, _ in + recorder.appendSite(site) + return QoderUsageSnapshot( + usedCredits: 0, + totalCredits: 300, + remainingCredits: 300, + usagePercentage: 0, + unit: "credit") + }) + + await #expect(throws: QoderUsageError.invalidCredentials) { + try await strategy.fetch(self.makeContext(settings: .make( + qoder: .init( + cookieSource: .manual, + manualCookieHeader: "curl --proxy-header 'X: https://qoder.com.cn' https://qoder.com")))) + } + + #expect(recorder.sitesSnapshot().isEmpty) + } + + @Test + func `manual curl with appended command does not resolve cookie or send request`() async { + let recorder = Recorder() + let strategy = QoderWebFetchStrategy( + usageLoader: { cookieHeader, site, _ in + recorder.appendCookieHeader(cookieHeader) + recorder.appendSite(site) + return QoderUsageSnapshot( + usedCredits: 0, + totalCredits: 300, + remainingCredits: 300, + usagePercentage: 0, + unit: "credit") + }) + + await #expect(throws: QoderUsageError.invalidCredentials) { + try await strategy.fetch(self.makeContext(settings: .make( + qoder: .init( + cookieSource: .manual, + manualCookieHeader: "curl https://qoder.com.cn ; echo -H 'Cookie: sid=global'")))) + } + + #expect(recorder.cookieHeadersSnapshot().isEmpty) + #expect(recorder.sitesSnapshot().isEmpty) + } + + @Test + func `manual plain cookie fetch does not retry China after global auth failure`() async { + let recorder = Recorder() + let strategy = QoderWebFetchStrategy( + usageLoader: { _, site, _ in + recorder.appendSite(site) + throw QoderUsageError.invalidCredentials + }) + + await #expect(throws: QoderUsageError.invalidCredentials) { + try await strategy.fetch(self.makeContext(settings: .make( + qoder: .init( + cookieSource: .manual, + manualCookieHeader: "sid=plain-cookie")))) + } + + #expect(recorder.sitesSnapshot() == [.international]) + } + + @Test + func `manual plain cookie fetch does not retry China after global network failure`() async { + let recorder = Recorder() + let strategy = QoderWebFetchStrategy( + usageLoader: { _, site, _ in + recorder.appendSite(site) + throw QoderUsageError.networkError("timed out") + }) + + await #expect(throws: QoderUsageError.networkError("timed out")) { + try await strategy.fetch(self.makeContext(settings: .make( + qoder: .init( + cookieSource: .manual, + manualCookieHeader: "sid=plain-cookie")))) + } + + #expect(recorder.sitesSnapshot() == [.international]) + } + + @Test + func `auto cookie fetch preserves invalid credentials when fresh import is exhausted`() async { + let strategy = QoderWebFetchStrategy( + usageLoader: { _, _, _ in + throw QoderUsageError.invalidCredentials + }, + cookieResolver: { _, allowCached, _ in + if allowCached { + return QoderResolvedCookie( + cookieHeader: "sid=expired-cache", + sourceLabel: "Chrome Default / qoder.com", + isFromCache: true) + } + throw QoderUsageError.missingCredentials + }) + + await #expect(throws: QoderUsageError.invalidCredentials) { + try await strategy.fetch(self.makeContext(settings: .make( + qoder: .init( + cookieSource: .auto, + manualCookieHeader: nil)))) + } + } + + @Test + func `auto cookie fetch preserves terminal non auth error when fresh import is exhausted`() async { + let strategy = QoderWebFetchStrategy( + usageLoader: { _, _, _ in + throw QoderUsageError.networkError("global timed out") + }, + cookieResolver: { _, allowCached, _ in + if allowCached { + return QoderResolvedCookie( + cookieHeader: "sid=stale-cache", + sourceLabel: "Chrome Default / qoder.com", + isFromCache: true) + } + throw QoderUsageError.missingCredentials + }) + + await #expect(throws: QoderUsageError.networkError("global timed out")) { + try await strategy.fetch(self.makeContext(settings: .make( + qoder: .init( + cookieSource: .auto, + manualCookieHeader: nil)))) + } + } + + @Test + func `auto cookie fetch preserves terminal non auth error when later candidate also fails`() async { + let candidates = [ + QoderResolvedCookie(cookieHeader: "sid=global", sourceLabel: "Chrome Default / qoder.com"), + QoderResolvedCookie(cookieHeader: "sid=china", sourceLabel: "Chrome Default / qoder.com.cn"), + ] + let strategy = QoderWebFetchStrategy( + usageLoader: { cookieHeader, _, _ in + if cookieHeader == "sid=global" { + throw QoderUsageError.networkError("timed out") + } + throw QoderUsageError.apiError(503) + }, + cookieResolver: { _, _, skippedLabels in + candidates.first { !skippedLabels.contains($0.sourceLabel) } + }) + + await #expect(throws: QoderUsageError.apiError(503)) { + try await strategy.fetch(self.makeContext(settings: .make( + qoder: .init( + cookieSource: .auto, + manualCookieHeader: nil)))) + } + } + + @Test + func `auto cookie fetch preserves later non auth error after auth failure`() async { + let candidates = [ + QoderResolvedCookie(cookieHeader: "sid=global", sourceLabel: "Chrome Default / qoder.com"), + QoderResolvedCookie(cookieHeader: "sid=china", sourceLabel: "Chrome Default / qoder.com.cn"), + ] + let strategy = QoderWebFetchStrategy( + usageLoader: { cookieHeader, _, _ in + if cookieHeader == "sid=global" { + throw QoderUsageError.invalidCredentials + } + throw QoderUsageError.networkError("china timed out") + }, + cookieResolver: { _, _, skippedLabels in + candidates.first { !skippedLabels.contains($0.sourceLabel) } + }) + + await #expect(throws: QoderUsageError.networkError("china timed out")) { + try await strategy.fetch(self.makeContext(settings: .make( + qoder: .init( + cookieSource: .auto, + manualCookieHeader: nil)))) + } + } + + @Test + func `manual plain cookie fetch reports invalid credentials when every candidate is auth failure`() async { + let strategy = QoderWebFetchStrategy( + usageLoader: { _, _, _ in + throw QoderUsageError.invalidCredentials + }) + + await #expect(throws: QoderUsageError.invalidCredentials) { + try await strategy.fetch(self.makeContext(settings: .make( + qoder: .init( + cookieSource: .manual, + manualCookieHeader: "sid=plain-cookie")))) + } + } + + @Test + @MainActor + func `monthly credits keep nil cadence and do not emit quota notifications`() throws { + let suiteName = "QoderProviderBehaviorTests-quota-notifications" + let defaults = try #require(UserDefaults(suiteName: suiteName)) + defaults.removePersistentDomain(forName: suiteName) + let settings = SettingsStore( + userDefaults: defaults, + configStore: testConfigStore(suiteName: suiteName), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + settings.refreshFrequency = .manual + settings.statusChecksEnabled = false + settings.sessionQuotaNotificationsEnabled = true + settings.quotaWarningNotificationsEnabled = true + settings.quotaWarningThresholds = [50, 20] + + let notifier = SessionQuotaNotifierSpy() + let store = UsageStore( + fetcher: UsageFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + sessionQuotaNotifier: notifier) + let depletedSnapshot = QoderUsageSnapshot( + usedCredits: 500, + totalCredits: 500, + remainingCredits: 0, + usagePercentage: 100, + unit: "credit", + resetsAt: Date().addingTimeInterval(30 * 24 * 60 * 60)) + .toUsageSnapshot() + let restoredSnapshot = QoderUsageSnapshot( + usedCredits: 100, + totalCredits: 500, + remainingCredits: 400, + usagePercentage: 20, + unit: "credit", + resetsAt: Date().addingTimeInterval(30 * 24 * 60 * 60)) + .toUsageSnapshot() + let restoredPrimary = try #require(restoredSnapshot.primary) + + #expect(depletedSnapshot.primary?.windowMinutes == nil) + #expect(restoredPrimary.windowMinutes == nil) + #expect(store.weeklyPace(provider: .qoder, window: restoredPrimary, now: Date()) == nil) + + for snapshot in [depletedSnapshot, restoredSnapshot] { + store.handleSessionQuotaTransition(provider: .qoder, snapshot: snapshot) + store.handleQuotaWarningTransitions(provider: .qoder, snapshot: snapshot) + } + + #expect(notifier.posts.isEmpty) + #expect(notifier.quotaWarningPosts.isEmpty) + } + + private func makeContext(settings: ProviderSettingsSnapshot?) -> ProviderFetchContext { + let env: [String: String] = [:] + return ProviderFetchContext( + runtime: .app, + sourceMode: .auto, + includeCredits: false, + webTimeout: 1, + webDebugDumpHTML: false, + verbose: false, + env: env, + settings: settings, + fetcher: UsageFetcher(environment: env), + claudeFetcher: StubClaudeFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0)) + } + + #if os(macOS) + private static func cookieRecord(domain: String, name: String, value: String) -> BrowserCookieRecord { + BrowserCookieRecord( + domain: domain, + name: name, + path: "/", + value: value, + expires: Date(timeIntervalSince1970: 1_900_000_000), + isSecure: true, + isHTTPOnly: true) + } + #endif +} diff --git a/Tests/CodexBarTests/QoderProviderTests.swift b/Tests/CodexBarTests/QoderProviderTests.swift new file mode 100644 index 0000000000..f398c10b0f --- /dev/null +++ b/Tests/CodexBarTests/QoderProviderTests.swift @@ -0,0 +1,62 @@ +import CodexBarCore +import Testing +@testable import CodexBar + +struct QoderProviderTests { + @Test + func `descriptor metadata is correct`() { + let descriptor = ProviderDescriptorRegistry.descriptor(for: .qoder) + + #expect(descriptor.metadata.displayName == "Qoder") + #expect(descriptor.metadata.dashboardURL == QoderWebSite.international.dashboardURL.absoluteString) + #expect(QoderWebSite.international.dashboardURL.absoluteString == "https://qoder.com/account/usage") + #expect(QoderWebSite.china.dashboardURL.absoluteString == "https://qoder.com.cn/account/usage") + #expect(descriptor.metadata.cliName == "qoder") + #expect(descriptor.branding.iconResourceName == "ProviderIcon-qoder") + #expect(descriptor.branding.iconStyle == .qoder) + #expect(!descriptor.metadata.supportsCredits) + #if os(macOS) + #expect(descriptor.metadata.browserCookieOrder == [.chrome]) + #else + #expect(descriptor.metadata.browserCookieOrder == nil) + #endif + } + + @MainActor + @Test + func `implementation is registered`() { + #expect(ProviderCatalog.implementation(for: .qoder) != nil) + } + + @Test + func `dashboard URL follows manual header classifier`() { + let global = ProviderSettingsSnapshot.QoderProviderSettings( + cookieSource: .manual, + manualCookieHeader: "sid=abc") + let china = ProviderSettingsSnapshot.QoderProviderSettings( + cookieSource: .manual, + manualCookieHeader: "curl https://qoder.com.cn -H 'Cookie: sid=abc'") + let malformed = ProviderSettingsSnapshot.QoderProviderSettings( + cookieSource: .manual, + manualCookieHeader: "curl https://qoder.com -H 'Host: qoder.com.cn' -H 'Cookie: sid=abc'") + + #expect(QoderProviderDescriptor.dashboardURL(settings: global, sourceLabel: "manual / qoder.com.cn") == + QoderWebSite.international.dashboardURL) + #expect(QoderProviderDescriptor.dashboardURL(settings: china, sourceLabel: "manual / qoder.com") == + QoderWebSite.china.dashboardURL) + #expect(QoderProviderDescriptor.dashboardURL(settings: malformed, sourceLabel: "manual / qoder.com.cn") == + QoderWebSite.international.dashboardURL) + } + + @Test + func `dashboard URL follows resolved source labels outside manual mode`() { + let automatic = ProviderSettingsSnapshot.QoderProviderSettings(cookieSource: .auto, manualCookieHeader: nil) + + #expect(QoderProviderDescriptor.dashboardURL(settings: automatic, sourceLabel: "Chrome / qoder.com.cn") == + QoderWebSite.china.dashboardURL) + #expect(QoderProviderDescriptor.dashboardURL(settings: automatic, sourceLabel: "Chrome / qoder.com") == + QoderWebSite.international.dashboardURL) + #expect(QoderProviderDescriptor.dashboardURL(settings: automatic, sourceLabel: nil) == + QoderWebSite.international.dashboardURL) + } +} diff --git a/Tests/CodexBarTests/QoderUsageFetcherTests.swift b/Tests/CodexBarTests/QoderUsageFetcherTests.swift new file mode 100644 index 0000000000..5dde3bd56f --- /dev/null +++ b/Tests/CodexBarTests/QoderUsageFetcherTests.swift @@ -0,0 +1,299 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct QoderUsageFetcherTests { + @Test + func `parses documented member quota summary`() throws { + let snapshot = try QoderUsageFetcher.parseUsage(data: Data(Self.quotaJSON.utf8), now: Self.now) + let usage = snapshot.toUsageSnapshot() + + #expect(snapshot.usedCredits == 125) + #expect(snapshot.totalCredits == 500) + #expect(snapshot.remainingCredits == 375) + #expect(snapshot.usagePercentage == 25) + #expect(snapshot.unit == "credit") + #expect(snapshot.resetsAt == Self.resetDate) + #expect(usage.primary?.usedPercent == 25) + #expect(usage.primary?.resetsAt == Self.resetDate) + #expect(usage.primary?.windowMinutes == nil) + #expect(usage.primary?.resetDescription == "125 / 500 credits") + #expect(usage.identity?.providerID == .qoder) + #expect(usage.identity?.loginMethod == nil) + } + + @Test + func `parses legacy snake case quota summary`() throws { + let snapshot = try QoderUsageFetcher.parseUsage(data: Data(Self.legacyQuotaJSON.utf8), now: Self.now) + + #expect(snapshot.usedCredits == 125) + #expect(snapshot.totalCredits == 500) + #expect(snapshot.remainingCredits == 375) + #expect(snapshot.usagePercentage == 25) + #expect(snapshot.unit == "credit") + #expect(snapshot.resetsAt == Self.resetDate) + } + + @Test + func `parses numeric reset timestamp`() throws { + let json = Self.quotaJSON.replacing( + "\"2024-09-01T00:00:00Z\"", + with: "1725148800000") + let snapshot = try QoderUsageFetcher.parseUsage(data: Data(json.utf8), now: Self.now) + + #expect(snapshot.resetsAt == Self.resetDate) + } + + @Test + func `folds shared quota into displayed totals`() throws { + let snapshot = try QoderUsageFetcher.parseUsage( + data: Data(Self.sharedQuotaJSON.utf8), + now: Self.now) + let usage = snapshot.toUsageSnapshot() + + #expect(snapshot.usedCredits == 1700) + #expect(snapshot.totalCredits == 2500) + #expect(snapshot.remainingCredits == 800) + #expect(snapshot.usagePercentage == 68) + #expect(usage.primary?.usedPercent == 68) + #expect(usage.primary?.windowMinutes == nil) + #expect(usage.primary?.resetDescription == "1,700 / 2,500 credits") + #expect(usage.identity?.loginMethod == nil) + } + + @Test + func `zero total zero usage without percentage is exhausted`() throws { + let snapshot = try QoderUsageFetcher.parseUsage( + data: Data(Self.zeroTotalQuotaJSON.utf8), + now: Self.now) + let usage = snapshot.toUsageSnapshot() + + #expect(snapshot.usedCredits == 0) + #expect(snapshot.totalCredits == 0) + #expect(snapshot.remainingCredits == 0) + #expect(snapshot.usagePercentage == 100) + #expect(usage.primary?.usedPercent == 100) + #expect(usage.primary?.resetDescription == "0 / 0 credits") + } + + @Test + func `negative quota values are invalid`() { + #expect(throws: QoderUsageError.parseFailed("quota values must be nonnegative")) { + try QoderUsageFetcher.parseUsage( + data: Data(Self.zeroTotalQuotaJSON.replacing("\"usedValue\": 0", with: "\"usedValue\": -1").utf8), + now: Self.now) + } + #expect(throws: QoderUsageError.parseFailed("quota values must be nonnegative")) { + try QoderUsageFetcher.parseUsage( + data: Data(Self.zeroTotalQuotaJSON.replacing("\"limitValue\": 0", with: "\"limitValue\": -1").utf8), + now: Self.now) + } + #expect(throws: QoderUsageError.parseFailed("quota values must be nonnegative")) { + try QoderUsageFetcher.parseUsage( + data: Data(Self.zeroTotalQuotaJSON.replacing("\"remainingValue\": 0", with: "\"remainingValue\": -1") + .utf8), + now: Self.now) + } + } + + @Test + func `zero total with positive usage is invalid`() { + #expect(throws: QoderUsageError.parseFailed("zero total quota must have zero usage and remaining")) { + try QoderUsageFetcher.parseUsage( + data: Data(Self.zeroTotalQuotaJSON.replacing("\"usedValue\": 0", with: "\"usedValue\": 1").utf8), + now: Self.now) + } + #expect(throws: QoderUsageError.parseFailed("zero total quota must have zero usage and remaining")) { + try QoderUsageFetcher.parseUsage( + data: Data(Self.zeroTotalQuotaJSON.replacing("\"remainingValue\": 0", with: "\"remainingValue\": 1") + .utf8), + now: Self.now) + } + } + + @Test + func `fetch sends documented Qoder headers`() async throws { + let transport = ProviderHTTPTransportStub { request in + #expect(request.httpMethod == "GET") + #expect(request.timeoutInterval == 42) + #expect(request.url?.absoluteString == "https://qoder.com/api/v2/me/usages/big_model_credits") + #expect(request.value(forHTTPHeaderField: "Cookie") == "sid=abc") + #expect(request.value(forHTTPHeaderField: "Accept") == "application/json, text/plain, */*") + #expect(request.value(forHTTPHeaderField: "Accept-Language") == "en-US,en;q=0.9") + #expect(request.value(forHTTPHeaderField: "Origin") == "https://qoder.com") + #expect(request.value(forHTTPHeaderField: "Referer") == "https://qoder.com/account/usage") + #expect(request.value(forHTTPHeaderField: "X-Requested-With") == "XMLHttpRequest") + #expect(request.value(forHTTPHeaderField: "Bx-V") == "2.5.35") + return ( + Data(Self.quotaJSON.utf8), + HTTPURLResponse( + url: request.url!, + statusCode: 200, + httpVersion: nil, + headerFields: nil)!) + } + + let snapshot = try await QoderUsageFetcher.fetchUsage( + cookieHeader: "sid=abc", + transport: transport, + now: Self.now, + timeout: 42) + + #expect(snapshot.remainingCredits == 375) + } + + @Test + func `fetch can target Qoder China site`() async throws { + let transport = ProviderHTTPTransportStub { request in + #expect(request.url?.absoluteString == "https://qoder.com.cn/api/v2/me/usages/big_model_credits") + #expect(request.value(forHTTPHeaderField: "Origin") == "https://qoder.com.cn") + #expect(request.value(forHTTPHeaderField: "Referer") == "https://qoder.com.cn/account/usage") + return ( + Data(Self.quotaJSON.utf8), + HTTPURLResponse( + url: request.url!, + statusCode: 200, + httpVersion: nil, + headerFields: nil)!) + } + + let snapshot = try await QoderUsageFetcher.fetchUsage( + cookieHeader: "sid=abc", + site: .china, + transport: transport, + now: Self.now) + + #expect(snapshot.remainingCredits == 375) + } + + @Test + func `unauthorized response maps to invalid credentials`() async { + let transport = ProviderHTTPTransportStub { request in + ( + Data(), + HTTPURLResponse( + url: request.url!, + statusCode: 401, + httpVersion: nil, + headerFields: nil)!) + } + + await #expect(throws: QoderUsageError.invalidCredentials) { + try await QoderUsageFetcher.fetchUsage(cookieHeader: "sid=expired", transport: transport) + } + } + + @Test + func `invalid credentials message is domain neutral`() { + #expect(QoderUsageError.invalidCredentials + .localizedDescription == "Qoder session is invalid or expired. Please sign in to Qoder again.") + } + + @Test + func `task cancellation propagates`() async { + let transport = ProviderHTTPTransportStub { _ in + throw CancellationError() + } + + await #expect(throws: CancellationError.self) { + try await QoderUsageFetcher.fetchUsage(cookieHeader: "sid=cancelled", transport: transport) + } + } + + @Test + func `URL cancellation propagates as task cancellation`() async { + let transport = ProviderHTTPTransportStub { _ in + throw URLError(.cancelled) + } + + await #expect(throws: CancellationError.self) { + try await QoderUsageFetcher.fetchUsage(cookieHeader: "sid=cancelled", transport: transport) + } + } + + private static let now = Date(timeIntervalSince1970: 1_719_206_400) + private static let resetDate = Date(timeIntervalSince1970: 1_725_148_800) + + /// Fixture shape from steipete/CodexBar#1590 (camelCase browser response). + private static let quotaJSON = """ + { + "userId": "redacted", + "quotaKey": "big_model_credits", + "nextResetAt": "2024-09-01T00:00:00Z", + "status": "active", + "totalQuota": { + "quotaSummary": { + "usedValue": 125, + "limitValue": 500, + "remainingValue": 375, + "usagePercentage": 25, + "unit": "credit" + }, + "quotaDetail": [] + } + } + """ + + /// Fixture shape from steipete/CodexBar#1590 (snake_case browser response). + private static let legacyQuotaJSON = """ + { + "user_id": "redacted", + "quota_key": "big_model_credits", + "next_reset_at": "2024-09-01T00:00:00Z", + "status": "active", + "total_quota": { + "quota_summary": { + "used_value": 125, + "limit_value": 500, + "remaining_value": 375, + "usage_percentage": 25, + "unit": "credit" + }, + "quota_detail": [] + } + } + """ + + /// Team shared add-on credits are separate from totalQuota (plan + resource pack). + private static let sharedQuotaJSON = """ + { + "userId": "redacted", + "quotaKey": "big_model_credits", + "status": "active", + "totalQuota": { + "quotaSummary": { + "usedValue": 1500, + "limitValue": 1500, + "remainingValue": 0, + "usagePercentage": 100, + "unit": "credit" + } + }, + "sharedQuota": { + "quotaSummary": { + "usedValue": 200, + "limitValue": 1000, + "remainingValue": 800, + "usagePercentage": 20, + "unit": "credit" + } + } + } + """ + + private static let zeroTotalQuotaJSON = """ + { + "userId": "redacted", + "quotaKey": "big_model_credits", + "totalQuota": { + "quotaSummary": { + "usedValue": 0, + "limitValue": 0, + "remainingValue": 0, + "unit": "credit" + }, + "quotaDetail": [] + } + } + """ +} diff --git a/Tests/CodexBarTests/QuotaLowHookAccountScopingTests.swift b/Tests/CodexBarTests/QuotaLowHookAccountScopingTests.swift new file mode 100644 index 0000000000..30462a4ee0 --- /dev/null +++ b/Tests/CodexBarTests/QuotaLowHookAccountScopingTests.swift @@ -0,0 +1,101 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +@MainActor +struct QuotaLowHookAccountScopingTests { + @Test + func `quota_low crossing history is scoped per account`() { + // Same provider/window/lane, different account discriminators must not share + // history: one account's high usage must not overwrite or re-arm another's. + let accountA = UsageStore.QuotaWarningStateKey( + provider: .claude, window: .session, accountDiscriminator: "a@example.com") + let accountB = UsageStore.QuotaWarningStateKey( + provider: .claude, window: .session, accountDiscriminator: "b@example.com") + #expect(accountA != accountB) + + var usage: [UsageStore.QuotaWarningStateKey: Double] = [:] + usage[accountA] = 0.40 + usage[accountB] = 0.95 + // Account B's observation did not clobber account A's baseline. + #expect(usage[accountA] == 0.40) + #expect(usage[accountB] == 0.95) + } + + @Test + func `distinct windows and lanes stay independent for one account`() { + let session = UsageStore.QuotaWarningStateKey( + provider: .claude, window: .session, accountDiscriminator: "a@example.com") + let weekly = UsageStore.QuotaWarningStateKey( + provider: .claude, window: .weekly, accountDiscriminator: "a@example.com") + let scoped = UsageStore.QuotaWarningStateKey( + provider: .claude, + window: .weekly, + accountDiscriminator: "a@example.com", + windowID: "claude-weekly-scoped-fable") + #expect(Set([session, weekly, scoped]).count == 3) + } + + @Test + func `inactive hooks discard quota-low baselines`() { + let store = self.makeStore(suiteName: "QuotaLowHookAccountScopingTests-inactive") + let claude = UsageStore.QuotaWarningStateKey( + provider: .claude, window: .session, accountDiscriminator: "account") + let codex = UsageStore.QuotaWarningStateKey( + provider: .codex, window: .session, accountDiscriminator: "account") + store.quotaLowHookUsage = [claude: 0.4, codex: 0.5] + + store.clearQuotaLowHookUsage(provider: .claude) + + #expect(store.quotaLowHookUsage[claude] == nil) + #expect(store.quotaLowHookUsage[codex] == 0.5) + } + + @Test + func `configuration revision discards quota-low baselines`() { + let store = self.makeStore(suiteName: "QuotaLowHookAccountScopingTests-revision") + let key = UsageStore.QuotaWarningStateKey( + provider: .claude, window: .session, accountDiscriminator: "account") + store.resetQuotaLowHookUsageIfConfigurationChanged() + store.quotaLowHookUsage[key] = 0.4 + + store.settings.setHooksEnabled(true) + store.resetQuotaLowHookUsageIfConfigurationChanged() + + #expect(store.quotaLowHookUsage[key] == nil) + #expect(store.quotaLowHookConfigRevision == store.settings.configRevision) + } + + @Test + func `vanished extra lanes discard quota-low baselines`() { + let store = self.makeStore(suiteName: "QuotaLowHookAccountScopingTests-extra") + let retained = UsageStore.QuotaWarningStateKey( + provider: .claude, + window: .weekly, + accountDiscriminator: "account", + windowID: "retained") + let vanished = UsageStore.QuotaWarningStateKey( + provider: .claude, + window: .weekly, + accountDiscriminator: "account", + windowID: "vanished") + store.quotaLowHookUsage = [retained: 0.4, vanished: 0.5] + + store.pruneQuotaLowHookUsage( + provider: .claude, + accountDiscriminator: "account", + keepingExtraWindowIDs: ["retained"]) + + #expect(store.quotaLowHookUsage[retained] == 0.4) + #expect(store.quotaLowHookUsage[vanished] == nil) + } + + private func makeStore(suiteName: String) -> UsageStore { + UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: testSettingsStore(suiteName: suiteName), + environmentBase: [:]) + } +} diff --git a/Tests/CodexBarTests/QuotaWarningAlertPresentationStateTests.swift b/Tests/CodexBarTests/QuotaWarningAlertPresentationStateTests.swift new file mode 100644 index 0000000000..8164b0a182 --- /dev/null +++ b/Tests/CodexBarTests/QuotaWarningAlertPresentationStateTests.swift @@ -0,0 +1,27 @@ +import Testing +@testable import CodexBar + +struct QuotaWarningAlertPresentationStateTests { + @Test + func `replacement alert ignores stale dismissal`() { + var state = QuotaWarningAlertPresentationState() + let session = state.present(title: "Session quota low", message: "20% left") + let weekly = state.present(title: "Weekly quota low", message: "10% left") + + #expect(state.dismiss(generation: session.generation) == false) + #expect(state.current == weekly) + #expect(state.dismiss(generation: weekly.generation) == true) + #expect(state.current == nil) + } + + @Test + func `manual dismissal clears current alert`() { + var state = QuotaWarningAlertPresentationState() + let presentation = state.present(title: "Session quota low", message: "20% left") + #expect(state.current == presentation) + + state.dismiss() + + #expect(state.current == nil) + } +} diff --git a/Tests/CodexBarTests/QuotaWarningNotificationLogicTests.swift b/Tests/CodexBarTests/QuotaWarningNotificationLogicTests.swift index b0bd32c7f1..ba62c44176 100644 --- a/Tests/CodexBarTests/QuotaWarningNotificationLogicTests.swift +++ b/Tests/CodexBarTests/QuotaWarningNotificationLogicTests.swift @@ -46,6 +46,42 @@ struct QuotaWarningNotificationLogicTests { } } + @Test + func `quota warning copy uses the extra-window display label when provided`() { + Self.withAppLanguage("en") { + let copy = QuotaWarningNotificationLogic.notificationCopy( + providerName: "Claude", + window: .weekly, + threshold: 50, + currentRemaining: 45, + windowDisplayLabel: "Fable only") + + #expect(copy.title == "Claude Fable only quota low") + #expect(copy.body == "45% left. Reached your 50% Fable only warning threshold.") + } + } + + @Test + func `extra-window notification identifiers are independent`() { + let fable = QuotaWarningEvent( + window: .weekly, + threshold: 50, + currentRemaining: 45, + windowID: "claude-weekly-scoped-fable") + let routines = QuotaWarningEvent( + window: .weekly, + threshold: 50, + currentRemaining: 45, + windowID: "claude-routines") + + let ids = [fable, routines].map { + QuotaWarningNotificationLogic.notificationIDPrefix(provider: .claude, event: $0) + } + #expect(Set(ids).count == 2) + #expect(ids[0].contains("claude-weekly-scoped-fable")) + #expect(ids[1].contains("claude-routines")) + } + @Test func `quota warning copy follows Traditional Chinese app language`() { Self.withAppLanguage("zh-Hant") { diff --git a/Tests/CodexBarTests/QwenCloudProviderTests.swift b/Tests/CodexBarTests/QwenCloudProviderTests.swift new file mode 100644 index 0000000000..517588fa09 --- /dev/null +++ b/Tests/CodexBarTests/QwenCloudProviderTests.swift @@ -0,0 +1,823 @@ +import Foundation +import Testing +@testable import CodexBarCore + +private func qwenCloudFixture(_ name: String) throws -> Data { + try Data( + contentsOf: #require(Bundle.module.url( + forResource: name, + withExtension: "json", + subdirectory: "Fixtures/QwenCloud"))) +} + +struct QwenCloudSettingsReaderTests { + @Test + func `cookie reads from environment`() { + let cookie = QwenCloudSettingsReader.cookieHeader(environment: [ + QwenCloudSettingsReader.cookieHeaderKey: "\"login_aliyunid_ticket=ticket\"", + ]) + #expect(cookie == "login_aliyunid_ticket=ticket") + } + + @Test + func `quota URL infers HTTPS scheme`() { + let url = QwenCloudSettingsReader.quotaURL(environment: [ + QwenCloudSettingsReader.quotaURLKey: "quota.qwen-cloud.test/data/api.json", + ]) + + #expect(url?.scheme == "https") + #expect(url?.host == "quota.qwen-cloud.test") + } + + @Test + func `quota URL rejects non HTTPS schemes`() { + let httpURL = QwenCloudSettingsReader.quotaURL(environment: [ + QwenCloudSettingsReader.quotaURLKey: "http://quota.qwen-cloud.test/data/api.json", + ]) + + #expect(httpURL == nil) + } + + @Test + func `host override rejects non HTTPS schemes`() { + let httpHost = QwenCloudSettingsReader.hostOverride(environment: [ + QwenCloudSettingsReader.hostKey: "http://home.qwen-cloud.test", + ]) + let httpsHost = QwenCloudSettingsReader.hostOverride(environment: [ + QwenCloudSettingsReader.hostKey: "https://home.qwen-cloud.test", + ]) + + #expect(httpHost == nil) + #expect(httpsHost == "https://home.qwen-cloud.test") + } + + @Test + func `host override normalizes bare hosts to HTTPS`() { + let bareHost = QwenCloudSettingsReader.hostOverride(environment: [ + QwenCloudSettingsReader.hostKey: "home.qwen-cloud.test", + ]) + let bareHostWithPort = QwenCloudSettingsReader.hostOverride(environment: [ + QwenCloudSettingsReader.hostKey: "home.qwen-cloud.test:8443", + ]) + + #expect(bareHost == "https://home.qwen-cloud.test") + #expect(bareHostWithPort == "https://home.qwen-cloud.test:8443") + } + + @Test + func `bare host overrides build valid dashboard and quota URLs`() { + let environment = [QwenCloudSettingsReader.hostKey: "qwen-cloud.test"] + + let dashboard = QwenCloudUsageFetcher.dashboardURL(environment: environment) + #expect(dashboard.scheme == "https") + #expect(dashboard.host == "qwen-cloud.test") + #expect(dashboard.absoluteString.contains("/billing/subscription/token-plan-individual")) + + let quota = QwenCloudUsageFetcher.defaultQuotaURL(environment: environment) + #expect(quota.scheme == "https") + #expect(quota.host == "qwen-cloud.test") + #expect(quota.absoluteString.removingPercentEncoding?.contains("personal/api/v2/usage") == true) + } + + @Test + func `default quota URL targets qwen data gateway usage API`() { + let url = QwenCloudUsageFetcher.defaultQuotaURL + #expect(url.host == "cs-data.qwencloud.com") + #expect(url.absoluteString.removingPercentEncoding?.contains("personal/api/v2/usage") == true) + #expect(url.absoluteString.contains("sfm_bailian")) + } + + @Test + func `dashboard URL targets the individual token plan page`() { + let url = QwenCloudUsageFetcher.dashboardURL + #expect(url.host == "home.qwencloud.com") + #expect(url.absoluteString.contains("/billing/subscription/token-plan-individual")) + } +} + +struct QwenCloudUsageSnapshotTests { + @Test + func `provider labels current quota windows`() { + let metadata = QwenCloudProviderDescriptor.descriptor.metadata + + #expect(metadata.sessionLabel == "5-hour") + #expect(metadata.weeklyLabel == "Weekly") + #if os(macOS) + #expect(metadata.browserCookieOrder == [.chrome]) + #expect(QwenCloudWebFetchStrategy.browserOrder == [.chrome]) + #else + #expect(metadata.browserCookieOrder == nil) + #endif + } + + @Test + func `maps used and total quota to primary window`() { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let reset = Date(timeIntervalSince1970: 1_700_100_000) + let snapshot = QwenCloudUsageSnapshot( + planName: "Token Plan", + usedQuota: 250, + totalQuota: 1000, + remainingQuota: nil, + resetsAt: reset, + updatedAt: now) + + let usage = snapshot.toUsageSnapshot() + + #expect(usage.primary?.usedPercent == 25) + #expect(usage.primary?.resetsAt == reset) + #expect(usage.primary?.resetDescription == "250 / 1,000 credits used") + #expect(usage.loginMethod(for: .qwencloud) == "Token Plan") + } +} + +@Suite(.serialized) +struct QwenCloudUsageParsingTests { + @Test + func `parses current token plan 5 hour and weekly usage`() throws { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let innerJSON = """ + { + "code": 0, + "data": { + "per5HourPercentage": 0.03, + "per5HourResetTime": 1700003600000, + "per1WeekPercentage": 0.01, + "per1WeekResetTime": 1700086400000 + }, + "success": true + } + """ + let payload: [String: Any] = [ + "data": [ + "DataV2": [ + "data": innerJSON, + ], + ], + "httpStatusCode": 200, + ] + let data = try JSONSerialization.data(withJSONObject: payload) + + let snapshot = try QwenCloudUsageFetcher.parseUsageSnapshot(from: data, now: now) + let usage = snapshot.toUsageSnapshot() + + #expect(usage.primary?.usedPercent == 3) + #expect(usage.primary?.resetsAt == Date(timeIntervalSince1970: 1_700_003_600)) + #expect(usage.secondary?.usedPercent == 1) + #expect(usage.secondary?.resetsAt == Date(timeIntervalSince1970: 1_700_086_400)) + } + + @Test + func `parses nested equity list token plan payload`() throws { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let data = try qwenCloudFixture("nested_equity_list") + let snapshot = try QwenCloudUsageFetcher.parseUsageSnapshot(from: data, now: now) + + #expect(snapshot.totalQuota == 1000) + #expect(snapshot.remainingQuota == 875) + #expect(snapshot.usedQuota == 125) + #expect(snapshot.resetsAt == Date(timeIntervalSince1970: 1_701_000_000)) + #expect(snapshot.toUsageSnapshot().primary?.usedPercent == 12.5) + } + + @Test + func `parses flat subscription summary payload`() throws { + let data = try qwenCloudFixture("flat_subscription_summary") + let snapshot = try QwenCloudUsageFetcher.parseUsageSnapshot(from: data) + + #expect(snapshot.totalQuota == 2000) + #expect(snapshot.remainingQuota == 1500) + #expect(snapshot.usedQuota == 500) + } + + @Test + func `login payload maps to login required`() throws { + let data = try qwenCloudFixture("login_required") + #expect(throws: QwenCloudUsageError.loginRequired) { + try QwenCloudUsageFetcher.parseUsageSnapshot(from: data) + } + } + + @Test + func `forbidden payload maps to invalid credentials`() throws { + let data = try qwenCloudFixture("forbidden") + #expect(throws: QwenCloudUsageError.invalidCredentials) { + try QwenCloudUsageFetcher.parseUsageSnapshot(from: data) + } + } + + @Test + func `non json payload maps to parse failed`() { + #expect(throws: QwenCloudUsageError.parseFailed("Invalid JSON response")) { + try QwenCloudUsageFetcher.parseUsageSnapshot(from: Data("not-json".utf8)) + } + } + + /// Real-world response shape returned for an authenticated Qwen Cloud account + /// with no active individual token-plan subscription. Captured live against + /// `home.qwencloud.com` (requestId/Uid redacted) — the API returns HTTP 200 + /// with `TotalCount: 0` and zeroed quota fields rather than an error, so the + /// parser must not report a false subscription. Fixture: `Fixtures/QwenCloud/no_active_subscription.json`. + @Test + func `authenticated account with no active subscription reports no quota`() throws { + let data = try qwenCloudFixture("no_active_subscription") + let snapshot = try QwenCloudUsageFetcher.parseUsageSnapshot(from: data) + + // No subscription instance and zero total → no quota window to display. + #expect(snapshot.totalQuota == 0 || snapshot.totalQuota == nil) + #expect(snapshot.usedQuota == nil || snapshot.usedQuota == 0) + #expect(snapshot.remainingQuota == nil || snapshot.remainingQuota == 0) + // The primary rate window must not render a false "100% remaining" bar + // for a non-subscribed account. + #expect(snapshot.toUsageSnapshot().primary == nil) + } +} + +struct QwenCloudCookieHeaderTests { + @Test + func `builds URL scoped headers for API and dashboard`() throws { + let cookies = [ + self.cookie(name: "login_aliyunid_ticket", value: "ticket", domain: ".qwencloud.com"), + self.cookie(name: "login_current_pk", value: "account", domain: ".qwencloud.com"), + self.cookie(name: "modelstudio_only", value: "modelstudio", domain: "modelstudio.console.aliyun.com"), + ] + + let headers = try #require(QwenCloudCookieHeader.headers(from: cookies)) + + #expect(headers.apiCookieHeader.contains("login_aliyunid_ticket=ticket")) + #expect(headers.apiCookieHeader.contains("login_current_pk=account")) + #expect(!headers.apiCookieHeader.contains("modelstudio_only=modelstudio")) + } + + @Test + func `cached headers preserve URL scoping`() throws { + let headers = QwenCloudCookieHeaders( + apiCookieHeader: "login_aliyunid_ticket=ticket; api_only=api", + dashboardCookieHeader: "login_aliyunid_ticket=ticket; dashboard_only=dashboard") + + let cached = try #require(QwenCloudCookieHeaders(qwenCloudCachedHeader: headers.cacheQwenCloudCookieHeader())) + + #expect(cached.apiCookieHeader.contains("api_only=api")) + #expect(!cached.apiCookieHeader.contains("dashboard_only=dashboard")) + #expect(cached.dashboardCookieHeader.contains("dashboard_only=dashboard")) + } + + private func cookie( + name: String, + value: String, + domain: String, + path: String = "/", + expires: Date = Date(timeIntervalSinceNow: 3600)) -> HTTPCookie + { + HTTPCookie(properties: [ + .domain: domain, + .path: path, + .name: name, + .value: value, + .expires: expires, + .secure: true, + ])! + } +} + +@Suite(.serialized) +struct QwenCloudFetchTests { + @Test + func `fetches usage with dashboard sec token preflight`() async throws { + let usageAPI = "zeldaHttp.apikeyMgr./tokenplan/personal/api/v2/usage" + let subscriptionAPI = "zeldaHttp.apikeyMgr./tokenplan/personal/api/v2/subscription" + let quotaConfigAPI = "zeldaHttp.apikeyMgr./tokenplan/personal/api/v2/quota-config" + var requestedAPIs: [String] = [] + QwenCloudStubURLProtocol.handler = { request in + guard let url = request.url else { throw URLError(.badURL) } + + if url.host == "qwen-cloud.test", + url.path == "/billing/subscription/token-plan-individual", + request.httpMethod == "GET" + { + return Self.makeResponse( + url: url, + body: "", + statusCode: 200) + } + + if url.host == "qwen-cloud.test", request.httpMethod == "POST" { + let body = Self.requestBodyString(from: request) + let form = try #require(URLComponents(string: "?\(body)")) + let formValues = Dictionary(uniqueKeysWithValues: form.queryItems?.compactMap { item in + item.value.map { (item.name, $0) } + } ?? []) + #expect(formValues["sec_token"] == "qwen-html-token") + #expect(formValues["product"] == "sfm_bailian") + let paramsData = try #require(formValues["params"]?.data(using: .utf8)) + let params = try #require(JSONSerialization.jsonObject(with: paramsData) as? [String: Any]) + let api = try #require(params["Api"] as? String) + let data = try #require(params["Data"] as? [String: Any]) + let cornerstone = try #require(data["cornerstoneParam"] as? [String: Any]) + #expect(cornerstone["consoleSite"] as? String == "QWENCLOUD") + requestedAPIs.append(api) + + let json: String + switch api { + case usageAPI: + json = """ + { + "data": { + "per5HourPercentage": 0.03, + "per5HourResetTime": 1700003600000, + "per1WeekPercentage": 0.01, + "per1WeekResetTime": 1700086400000 + } + } + """ + case subscriptionAPI: + #expect(data["commodityCode"] as? String == "sfm_tokenplansolo_public_intl") + json = #"{"data":{"specCode":"standard","status":"VALID"}}"# + case quotaConfigAPI: + json = """ + { + "data": { + "lite": { "five_hour": 1000, "weekly": 10000 }, + "standard": { "five_hour": 5000, "weekly": 50000 }, + "pro": { "five_hour": 10000, "weekly": 100000 } + } + } + """ + default: + throw URLError(.unsupportedURL) + } + return Self.makeResponse(url: url, body: json, statusCode: 200) + } + + throw URLError(.unsupportedURL) + } + defer { + QwenCloudStubURLProtocol.handler = nil + } + + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [QwenCloudStubURLProtocol.self] + let session = URLSession(configuration: configuration) + let transport = ProviderHTTPClient(session: session) + let snapshot = try await QwenCloudUsageFetcher.fetchUsage( + apiCookieHeader: "login_aliyunid_ticket=ticket", + dashboardCookieHeader: "login_aliyunid_ticket=ticket", + environment: [QwenCloudSettingsReader.hostKey: "https://qwen-cloud.test"], + transport: transport) + + #expect(requestedAPIs == [usageAPI, subscriptionAPI, quotaConfigAPI]) + #expect(snapshot.planName == "Standard") + #expect(snapshot.toUsageSnapshot().primary?.usedPercent == 3) + #expect(snapshot.toUsageSnapshot().primary?.resetDescription == "150 / 5,000 credits used") + #expect(snapshot.toUsageSnapshot().secondary?.usedPercent == 1) + #expect(snapshot.toUsageSnapshot().secondary?.resetDescription == "500 / 50,000 credits used") + } + + @Test + func `login page csrf token maps to login required before API requests`() async throws { + let transport = ProviderHTTPTransportHandler { request in + guard let url = request.url else { throw URLError(.badURL) } + if url.path == "/billing/subscription/token-plan-individual" { + return Self.makeTransportResponse( + url: url, + body: """ + + Sign in + + + """, + statusCode: 200) + } + if url.path == "/tool/user/info.json" { + return Self.makeTransportResponse(url: url, body: "{}", statusCode: 200) + } + throw URLError(.unsupportedURL) + } + + await #expect(throws: QwenCloudUsageError.loginRequired) { + try await QwenCloudUsageFetcher.fetchUsage( + apiCookieHeader: "login_aliyunid_ticket=expired", + dashboardCookieHeader: "login_aliyunid_ticket=expired", + environment: [QwenCloudSettingsReader.hostKey: "https://qwen-cloud.test"], + transport: transport) + } + } + + @Test + func `dashboard timeout maps to network error when token fallbacks are empty`() async throws { + let transport = ProviderHTTPTransportHandler { request in + guard let url = request.url else { throw URLError(.badURL) } + if url.path == "/billing/subscription/token-plan-individual" { + throw URLError(.timedOut) + } + if url.path == "/tool/user/info.json" { + return Self.makeTransportResponse(url: url, body: "{}", statusCode: 200) + } + throw URLError(.unsupportedURL) + } + + let error = await #expect(throws: QwenCloudUsageError.self) { + try await QwenCloudUsageFetcher.fetchUsage( + apiCookieHeader: "login_aliyunid_ticket=ticket", + dashboardCookieHeader: "login_aliyunid_ticket=ticket", + environment: [QwenCloudSettingsReader.hostKey: "https://qwen-cloud.test"], + transport: transport) + } + guard case .networkError = error else { + Issue.record("Expected networkError, got \(String(describing: error))") + return + } + } + + @Test + func `dashboard server failure maps to network error when token fallbacks are empty`() async throws { + let transport = ProviderHTTPTransportHandler { request in + guard let url = request.url else { throw URLError(.badURL) } + if url.path == "/billing/subscription/token-plan-individual" { + return Self.makeTransportResponse(url: url, body: "Unavailable", statusCode: 503) + } + if url.path == "/tool/user/info.json" { + return Self.makeTransportResponse(url: url, body: "{}", statusCode: 200) + } + throw URLError(.unsupportedURL) + } + + let error = await #expect(throws: QwenCloudUsageError.self) { + try await QwenCloudUsageFetcher.fetchUsage( + apiCookieHeader: "login_aliyunid_ticket=ticket", + dashboardCookieHeader: "login_aliyunid_ticket=ticket", + environment: [QwenCloudSettingsReader.hostKey: "https://qwen-cloud.test"], + transport: transport) + } + guard case .networkError = error else { + Issue.record("Expected networkError, got \(String(describing: error))") + return + } + } + + @Test + func `dashboard failure still allows sec token cookie fallback`() async throws { + let dashboardURL = try #require(URL(string: "https://qwen-cloud.test/dashboard")) + let resolver = OneConsoleSECTokenResolver(configuration: .init( + dashboardURL: { _ in dashboardURL }, + userInfoPath: "/tool/user/info.json", + isLoginPage: { _ in false })) + let transport = ProviderHTTPTransportHandler { _ in + throw URLError(.timedOut) + } + + let resolved = try await resolver.resolve( + cookieHeader: "login_aliyunid_ticket=ticket; sec_token=cookie-token", + environment: [:], + transport: transport) + + #expect(resolved.value == "cookie-token") + #expect(resolved.source == .cookie) + } + + @Test + func `user info resolver preserves sec token key priority`() async throws { + let dashboardURL = try #require(URL(string: "https://qwen-cloud.test/dashboard")) + let resolver = OneConsoleSECTokenResolver(configuration: .init( + dashboardURL: { _ in dashboardURL }, + userInfoPath: "/tool/user/info.json", + isLoginPage: { _ in false })) + let transport = ProviderHTTPTransportHandler { request in + guard let url = request.url else { throw URLError(.badURL) } + if url.path == "/dashboard" { + return Self.makeTransportResponse(url: url, body: "", statusCode: 200) + } + if url.path == "/tool/user/info.json" { + return Self.makeTransportResponse( + url: url, + body: """ + { + "token": "generic-token", + "data": { + "secToken": "", + "nested": { "secToken": "preferred-sec-token" } + } + } + """, + statusCode: 200) + } + throw URLError(.unsupportedURL) + } + + let resolved = try await resolver.resolve( + cookieHeader: "login_aliyunid_ticket=ticket", + environment: [:], + transport: transport) + + #expect(resolved.value == "preferred-sec-token") + #expect(resolved.source == .userInfo) + } + + @Test + func `redirect routing strips cross origin credentials and blocks preserved bodies`() throws { + let apiURL = try #require(URL(string: "https://cs-data.qwencloud.com/data/api.json")) + let dashboardRedirect = try #require(URL(string: "https://home.qwencloud.com/redirected")) + let crossHostURL = try #require(URL(string: "https://signin.aliyun.com/login")) + let insecureURL = try #require(URL(string: "http://home.qwencloud.com/login")) + let untrustedPortURL = try #require(URL(string: "https://home.qwencloud.com:8443/login")) + let redirectResponse = try #require(HTTPURLResponse( + url: apiURL, + statusCode: 302, + httpVersion: "HTTP/1.1", + headerFields: ["Location": dashboardRedirect.absoluteString])) + let routing = OneConsoleCookieRouting( + apiURL: apiURL, + dashboardURL: dashboardRedirect, + apiCookieHeader: "api_cookie=value", + dashboardCookieHeader: "dashboard_cookie=value") + + var apiRequest = URLRequest(url: apiURL) + apiRequest.httpMethod = "POST" + apiRequest.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") + apiRequest.setValue("api_cookie=value", forHTTPHeaderField: "Cookie") + apiRequest.setValue("Bearer secret", forHTTPHeaderField: "Authorization") + apiRequest.setValue("Basic proxy-secret", forHTTPHeaderField: "Proxy-Authorization") + apiRequest.setValue("api-key-secret", forHTTPHeaderField: "x-api-key") + apiRequest.setValue("csrf-secret", forHTTPHeaderField: "x-csrf-token") + apiRequest.setValue("xsrf-secret", forHTTPHeaderField: "x-xsrf-token") + apiRequest.httpBody = Data("sec_token=secret".utf8) + + var crossHostRedirect = URLRequest(url: crossHostURL) + crossHostRedirect.httpMethod = "GET" + crossHostRedirect.setValue("old=value", forHTTPHeaderField: "Cookie") + crossHostRedirect.setValue("Bearer secret", forHTTPHeaderField: "Authorization") + crossHostRedirect.setValue("Basic proxy-secret", forHTTPHeaderField: "Proxy-Authorization") + crossHostRedirect.setValue("api-key-secret", forHTTPHeaderField: "x-api-key") + crossHostRedirect.setValue("csrf-secret", forHTTPHeaderField: "x-csrf-token") + crossHostRedirect.setValue("xsrf-secret", forHTTPHeaderField: "x-xsrf-token") + let routedCrossHost = try #require(routing.redirectedRequest( + forRedirectFrom: apiRequest, + response: redirectResponse, + to: crossHostRedirect)) + #expect(routedCrossHost.value(forHTTPHeaderField: "Cookie") == nil) + #expect(routedCrossHost.value(forHTTPHeaderField: "Authorization") == nil) + #expect(routedCrossHost.value(forHTTPHeaderField: "Proxy-Authorization") == nil) + #expect(routedCrossHost.value(forHTTPHeaderField: "x-api-key") == nil) + #expect(routedCrossHost.value(forHTTPHeaderField: "x-csrf-token") == nil) + #expect(routedCrossHost.value(forHTTPHeaderField: "x-xsrf-token") == nil) + #expect(routedCrossHost.httpBody == nil) + + var sameHostRedirect = URLRequest(url: dashboardRedirect) + sameHostRedirect.httpMethod = "GET" + sameHostRedirect.setValue("old=value", forHTTPHeaderField: "Cookie") + sameHostRedirect.setValue("csrf-secret", forHTTPHeaderField: "x-csrf-token") + let routedDashboard = try #require(routing.redirectedRequest( + forRedirectFrom: apiRequest, + response: redirectResponse, + to: sameHostRedirect)) + #expect(routedDashboard.value(forHTTPHeaderField: "Cookie") == "dashboard_cookie=value") + #expect(routedDashboard.value(forHTTPHeaderField: "x-csrf-token") == nil) + #expect(routedDashboard.httpBody == nil) + + for statusCode in [307, 308] { + let preservedRedirectResponse = try #require(HTTPURLResponse( + url: apiURL, + statusCode: statusCode, + httpVersion: "HTTP/1.1", + headerFields: ["Location": crossHostURL.absoluteString])) + + var apiRedirect = URLRequest(url: apiURL) + apiRedirect.httpMethod = "POST" + apiRedirect.httpBody = Data("sec_token=secret".utf8) + let routedAPI = try #require(routing.redirectedRequest( + forRedirectFrom: apiRequest, + response: preservedRedirectResponse, + to: apiRedirect)) + #expect(routedAPI.value(forHTTPHeaderField: "Cookie") == "api_cookie=value") + #expect(routedAPI.httpBody == Data("sec_token=secret".utf8)) + + var externalPreservedPOST = URLRequest(url: crossHostURL) + externalPreservedPOST.httpMethod = "POST" + externalPreservedPOST.httpBody = Data("sec_token=secret".utf8) + #expect(routing.redirectedRequest( + forRedirectFrom: apiRequest, + response: preservedRedirectResponse, + to: externalPreservedPOST) == nil) + + var dashboardPreservedPOST = URLRequest(url: dashboardRedirect) + dashboardPreservedPOST.httpMethod = "POST" + dashboardPreservedPOST.httpBody = Data("sec_token=secret".utf8) + #expect(routing.redirectedRequest( + forRedirectFrom: apiRequest, + response: preservedRedirectResponse, + to: dashboardPreservedPOST) == nil) + } + + var untrustedPortRedirect = URLRequest(url: untrustedPortURL) + untrustedPortRedirect.setValue("old=value", forHTTPHeaderField: "Cookie") + let routedUntrustedPort = try #require(routing.redirectedRequest( + forRedirectFrom: apiRequest, + response: redirectResponse, + to: untrustedPortRedirect)) + #expect(routedUntrustedPort.value(forHTTPHeaderField: "Cookie") == nil) + + let insecureRedirect = URLRequest(url: insecureURL) + #expect(routing.redirectedRequest( + forRedirectFrom: apiRequest, + response: redirectResponse, + to: insecureRedirect) == nil) + + let notModified = try #require(HTTPURLResponse( + url: apiURL, + statusCode: 304, + httpVersion: "HTTP/1.1", + headerFields: nil)) + #expect(routing.redirectedRequest( + forRedirectFrom: apiRequest, + response: notModified, + to: crossHostRedirect) == nil) + } + + private static func makeResponse(url: URL, body: String, statusCode: Int) -> (HTTPURLResponse, Data) { + let response = HTTPURLResponse( + url: url, + statusCode: statusCode, + httpVersion: "HTTP/1.1", + headerFields: ["Content-Type": "application/json"])! + return (response, Data(body.utf8)) + } + + private static func makeTransportResponse( + url: URL, + body: String, + statusCode: Int) -> (Data, URLResponse) + { + let (response, data) = self.makeResponse(url: url, body: body, statusCode: statusCode) + return (data, response) + } + + private static func requestBodyString(from request: URLRequest) -> String { + if let data = request.httpBody { + return String(data: data, encoding: .utf8) ?? "" + } + if let stream = request.httpBodyStream { + stream.open() + defer { + stream.close() + } + var data = Data() + var buffer = [UInt8](repeating: 0, count: 1024) + while stream.hasBytesAvailable { + let count = stream.read(&buffer, maxLength: buffer.count) + if count <= 0 { + break + } + data.append(buffer, count: count) + } + return String(data: data, encoding: .utf8) ?? "" + } + return "" + } +} + +struct QwenCloudCookieImportValidationTests { + #if os(macOS) + @Test + func `accepts passport ticket sessions`() { + let cookies = [ + self.cookie(name: "login_aliyunid_ticket", value: "ticket", domain: ".alibabacloud.com"), + ] + #expect(QwenCloudCookieImport.isAuthenticatedSession(cookies: cookies)) + } + + @Test + func `accepts qwen scoped sso sessions`() { + let cookies = [ + self.cookie(name: "qwen_sso_ticket", value: "sso-ticket", domain: ".qwencloud.com"), + ] + #expect(QwenCloudCookieImport.isAuthenticatedSession(cookies: cookies)) + } + + @Test + func `accepts current qwen cloud login tickets`() { + let cookies = [ + self.cookie(name: "login_qwencloud_ticket", value: "ticket", domain: ".qwencloud.com"), + ] + #expect(QwenCloudCookieImport.isAuthenticatedSession(cookies: cookies)) + } + + @Test + func `rejects locale and account cookies without a login ticket`() { + // A browser profile that merely visited qwencloud.com carries locale + // preferences, account-id markers, and CSRF cookies while logged out; + // none of them prove an authenticated session. + let cookies = [ + self.cookie(name: "locale_pref", value: "en-US", domain: ".qwencloud.com"), + self.cookie(name: "login_aliyunid_pk", value: "1234567890", domain: ".qwencloud.com"), + self.cookie(name: "login_current_pk", value: "1234567890", domain: ".home.qwencloud.com"), + self.cookie(name: "sec_token", value: "csrf-token", domain: ".home.qwencloud.com"), + ] + #expect(!QwenCloudCookieImport.isAuthenticatedSession(cookies: cookies)) + } + + @Test + func `rejects sessions without recognized cookies`() { + let cookies = [ + self.cookie(name: "unrelated", value: "x", domain: ".example.com"), + ] + #expect(!QwenCloudCookieImport.isAuthenticatedSession(cookies: cookies)) + } + + private func cookie( + name: String, + value: String, + domain: String, + path: String = "/", + expires: Date = Date(timeIntervalSinceNow: 3600)) -> HTTPCookie + { + HTTPCookie(properties: [ + .domain: domain, + .path: path, + .name: name, + .value: value, + .expires: expires, + .secure: true, + ])! + } + #endif +} + +final class QwenCloudStubURLProtocol: URLProtocol { + nonisolated(unsafe) static var handler: ((URLRequest) throws -> (HTTPURLResponse, Data))? + + override static func canInit(with request: URLRequest) -> Bool { + guard let host = request.url?.host else { return false } + return host == "home.qwencloud.com" || host == "qwen-cloud.test" + } + + override static func canonicalRequest(for request: URLRequest) -> URLRequest { + request + } + + override func startLoading() { + guard let handler = Self.handler else { + self.client?.urlProtocol(self, didFailWithError: URLError(.badServerResponse)) + return + } + + do { + let (response, data) = try handler(self.request) + self.client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + self.client?.urlProtocol(self, didLoad: data) + self.client?.urlProtocolDidFinishLoading(self) + } catch { + self.client?.urlProtocol(self, didFailWithError: error) + } + } + + override func stopLoading() {} +} + +/// Opt-in live smoke test against the real Qwen Cloud console. +/// +/// Disabled by default so CI / `make test` never touch the network or Keychain. +/// To run it against your own account: +/// 1. Copy a `Cookie:` header from `https://home.qwencloud.com/billing/subscription/token-plan-individual` +/// 2. Temporarily remove the `.disabled(...)` trait below (keep the guard) +/// 3. QWEN_CLOUD_LIVE_TEST=1 QWEN_CLOUD_COOKIE='login_aliyunid_ticket=...; ...' \ +/// swift test --filter QwenCloudLiveSmokeTests +@Suite(.serialized) +struct QwenCloudLiveSmokeTests { + @Test(.disabled("Set QWEN_CLOUD_LIVE_TEST=1 and QWEN_CLOUD_COOKIE to run live Qwen Cloud checks.")) + func `live token plan usage resolves`() async throws { + let environment = ProcessInfo.processInfo.environment + guard environment["QWEN_CLOUD_LIVE_TEST"] == "1" else { return } + guard let cookie = environment["QWEN_CLOUD_COOKIE"], + !cookie.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + else { + Issue.record("QWEN_CLOUD_COOKIE is not set; paste a Cookie header from the Qwen Cloud billing page.") + return + } + + let snapshot = try await QwenCloudUsageFetcher.fetchUsage( + apiCookieHeader: cookie, + environment: environment) + + func describe(_ value: (some Any)?) -> String { + value.map { "\($0)" } ?? "" + } + + print( + """ + [qwen-cloud-live] plan=\(describe(snapshot.planName)) \ + used=\(describe(snapshot.usedQuota)) \ + total=\(describe(snapshot.totalQuota)) \ + remaining=\(describe(snapshot.remainingQuota)) \ + resetsAt=\(describe(snapshot.resetsAt)) + """) + + // An authenticated account must not be treated as logged out. + #expect(snapshot.updatedAt > Date(timeIntervalSince1970: 0)) + // A subscribed account reports a total; a free/empty account may legitimately be nil. + if snapshot.totalQuota == nil { + print("[qwen-cloud-live] No active token-plan total reported (account may have no subscription).") + } else { + #expect((snapshot.totalQuota ?? 0) >= 0) + } + } +} diff --git a/Tests/CodexBarTests/RateWindowSyntheticPlaceholderTests.swift b/Tests/CodexBarTests/RateWindowSyntheticPlaceholderTests.swift new file mode 100644 index 0000000000..e2bfe04986 --- /dev/null +++ b/Tests/CodexBarTests/RateWindowSyntheticPlaceholderTests.swift @@ -0,0 +1,141 @@ +import Foundation +import Testing +@testable import CodexBarCore + +/// Coverage for `RateWindow.isSyntheticPlaceholder` — the boundary marker that lets lane classifiers +/// distinguish Claude web's `five_hour: null` placeholder from a real zero-usage session. Verifies the +/// marker is set at the web boundary, survives Codable (with backward compatibility), and survives the +/// reset backfill that previously defeated a shape-only heuristic. +struct RateWindowSyntheticPlaceholderTests { + @Test + func `synthetic placeholder flag round-trips through Codable`() throws { + let window = RateWindow( + usedPercent: 0, + windowMinutes: 300, + resetsAt: nil, + resetDescription: nil, + isSyntheticPlaceholder: true) + + let data = try JSONEncoder().encode(window) + let decoded = try JSONDecoder().decode(RateWindow.self, from: data) + + #expect(decoded.isSyntheticPlaceholder == true) + #expect(decoded.usedPercent == 0) + #expect(decoded.windowMinutes == 300) + } + + @Test + func `older payload without the flag decodes as not a placeholder`() throws { + // Cached payloads written before the flag existed have no `isSyntheticPlaceholder` key. + let json = #"{"usedPercent": 50, "windowMinutes": 300}"# + let decoded = try JSONDecoder().decode(RateWindow.self, from: Data(json.utf8)) + + #expect(decoded.isSyntheticPlaceholder == false) + #expect(decoded.usedPercent == 50) + #expect(decoded.windowMinutes == 300) + } + + @Test + func `a real window omits the placeholder flag when encoded`() throws { + let window = RateWindow(usedPercent: 50, windowMinutes: 300, resetsAt: nil, resetDescription: nil) + + let data = try JSONEncoder().encode(window) + let json = String(bytes: data, encoding: .utf8) ?? "" + + // The flag is only persisted when true, so real windows keep their prior on-disk shape. + #expect(json.contains("isSyntheticPlaceholder") == false) + } + + @Test + func `backfilling a reset preserves the synthetic placeholder flag`() { + // Regression: backfilling a still-future cached reset onto the placeholder (which has no reset) + // must NOT let it masquerade as a real session — the marker has to survive the backfill. + let now = Date(timeIntervalSince1970: 1_800_000_000) + let cached = RateWindow( + usedPercent: 12, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(3600), + resetDescription: "Resets in 1h") + let placeholder = RateWindow( + usedPercent: 0, + windowMinutes: 300, + resetsAt: nil, + resetDescription: nil, + isSyntheticPlaceholder: true) + + let result = placeholder.backfillingResetTime(from: cached, now: now) + + #expect(result.resetsAt == now.addingTimeInterval(3600)) + #expect(result.isSyntheticPlaceholder == true) + } + + @Test + func `web mapping flags the null five-hour session as a synthetic placeholder`() throws { + let json = """ + { + "five_hour": null, + "seven_day": { "utilization": 42, "resets_at": "2025-12-29T23:00:00.000Z" } + } + """ + let webData = try ClaudeWebAPIFetcher._parseUsageResponseForTesting(Data(json.utf8)) + let primary = ClaudeUsageFetcher.webPrimaryWindow(from: webData) + + #expect(primary.isSyntheticPlaceholder == true) + #expect(primary.usedPercent == 0) + #expect(primary.windowMinutes == 300) + #expect(primary.resetsAt == nil) + } + + @Test + func `web mapping keeps a real five-hour session unflagged`() throws { + let json = """ + { + "five_hour": { "utilization": 11, "resets_at": "2025-12-29T20:00:00.000Z" }, + "seven_day": { "utilization": 42, "resets_at": "2025-12-29T23:00:00.000Z" } + } + """ + let webData = try ClaudeWebAPIFetcher._parseUsageResponseForTesting(Data(json.utf8)) + let primary = ClaudeUsageFetcher.webPrimaryWindow(from: webData) + + #expect(primary.isSyntheticPlaceholder == false) + #expect(primary.usedPercent == 11) + } + + @Test + func `web mapping keeps fractional session and weekly utilization`() throws { + let json = """ + { + "five_hour": { "utilization": 45.5, "resets_at": "2025-12-29T20:00:00.000Z" }, + "seven_day": { "utilization": 12.25, "resets_at": "2025-12-29T23:00:00.000Z" } + } + """ + let webData = try ClaudeWebAPIFetcher._parseUsageResponseForTesting(Data(json.utf8)) + #expect(webData.sessionPercentUsed == 45.5) + #expect(webData.weeklyPercentUsed == 12.25) + #expect(webData.hasLiveSessionWindow == true) + + let primary = ClaudeUsageFetcher.webPrimaryWindow(from: webData) + #expect(primary.isSyntheticPlaceholder == false) + #expect(primary.usedPercent == 45.5) + #expect(primary.remainingPercent == 54.5) + } + + @Test + func `web mapping keeps a real zero-usage session that omits a reset`() throws { + // A reported `five_hour` object at 0% with no `resets_at` is a real idle session, not the + // `five_hour: null` placeholder. The flag keys off object presence (not percent/reset), so this + // must stay unflagged — otherwise the combined metric would hide a genuine empty session. + let json = """ + { + "five_hour": { "utilization": 0 }, + "seven_day": { "utilization": 42, "resets_at": "2025-12-29T23:00:00.000Z" } + } + """ + let webData = try ClaudeWebAPIFetcher._parseUsageResponseForTesting(Data(json.utf8)) + let primary = ClaudeUsageFetcher.webPrimaryWindow(from: webData) + + #expect(primary.isSyntheticPlaceholder == false) + #expect(primary.usedPercent == 0) + #expect(primary.resetsAt == nil) + } +} diff --git a/Tests/CodexBarTests/RefreshFailureHookStatusTests.swift b/Tests/CodexBarTests/RefreshFailureHookStatusTests.swift new file mode 100644 index 0000000000..7f71155bc8 --- /dev/null +++ b/Tests/CodexBarTests/RefreshFailureHookStatusTests.swift @@ -0,0 +1,33 @@ +import Foundation +import Testing +@testable import CodexBar + +struct RefreshFailureHookStatusTests { + @Test + func `maps URL errors to coarse categories`() { + let timeout = NSError(domain: NSURLErrorDomain, code: NSURLErrorTimedOut) + #expect(UsageStore.refreshFailureHookStatus(timeout) == "timeout") + + let offline = NSError(domain: NSURLErrorDomain, code: NSURLErrorNotConnectedToInternet) + #expect(UsageStore.refreshFailureHookStatus(offline) == "offline") + + let cancelled = NSError(domain: NSURLErrorDomain, code: NSURLErrorCancelled) + #expect(UsageStore.refreshFailureHookStatus(cancelled) == "cancelled") + + #expect(UsageStore.refreshFailureHookStatus(CancellationError()) == "cancelled") + } + + @Test + func `never forwards the raw error description`() { + // A provider error whose description embeds a response-body preview must not + // leak into the hook status. + let leaky = NSError( + domain: "ProviderHTTP", + code: 500, + userInfo: [NSLocalizedDescriptionKey: "HTTP 500: {\"error\":\"secret-token abc123\"}"]) + let status = UsageStore.refreshFailureHookStatus(leaky) + #expect(status == "error") + #expect(!status.contains("secret-token")) + #expect(!status.contains("500")) + } +} diff --git a/Tests/CodexBarTests/RequiredRefreshCoalescingTests.swift b/Tests/CodexBarTests/RequiredRefreshCoalescingTests.swift new file mode 100644 index 0000000000..f0694de8c1 --- /dev/null +++ b/Tests/CodexBarTests/RequiredRefreshCoalescingTests.swift @@ -0,0 +1,555 @@ +import Foundation +import Testing +@testable import CodexBar +@testable import CodexBarCore + +extension CodexBackgroundRefreshCoalescingTests { + @Test + func `required refresh requests during a pass share one follow-up`() async throws { + let settings = try self.makeSettingsStore( + suite: "CodexBackgroundRefreshCoalescingTests-required-refresh-follow-up") + settings.statusChecksEnabled = false + settings.costUsageEnabled = false + settings.openAIWebAccessEnabled = false + let store = self.makeStore(settings: settings) + let providerGate = BlockingRequiredProviderRefresh() + store._test_providerRefreshOverride = { _ in + await providerGate.run(interaction: ProviderInteractionContext.current) + } + store._test_codexCreditsLoaderOverride = { + CreditsSnapshot(remaining: 25, events: [], updatedAt: Date()) + } + defer { + store._test_providerRefreshOverride = nil + store._test_codexCreditsLoaderOverride = nil + } + + let firstRefresh = Task { @MainActor in + await store.refreshForSettingsChange() + } + let didStartFirstPass = await providerGate.waitUntilStarted(count: 1) + #expect(didStartFirstPass) + guard didStartFirstPass else { + firstRefresh.cancel() + store.cancelRequiredRefresh() + await providerGate.cancelAll() + await firstRefresh.value + return + } + + var laterRefreshes: [Task] = [] + for expectedGeneration in 2...4 { + let task = Task { @MainActor in + await store.refreshForSettingsChange() + } + laterRefreshes.append(task) + for _ in 0..<100 where store.requiredRefreshRequestGeneration < expectedGeneration { + await Task.yield() + } + #expect(store.requiredRefreshRequestGeneration == expectedGeneration) + } + #expect(await providerGate.startedCount() == 1) + + await providerGate.resumeNext() + let didStartFollowUp = await providerGate.waitUntilStarted(count: 2) + #expect(didStartFollowUp) + guard didStartFollowUp else { + firstRefresh.cancel() + laterRefreshes.forEach { $0.cancel() } + store.cancelRequiredRefresh() + await providerGate.cancelAll() + await firstRefresh.value + for task in laterRefreshes { + await task.value + } + return + } + #expect(store.requiredRefreshCompletedGeneration == 1) + + await providerGate.resumeNext() + await firstRefresh.value + for task in laterRefreshes { + await task.value + } + try await Task.sleep(for: .milliseconds(50)) + + #expect(await providerGate.startedCount() == 2) + #expect(await providerGate.recordedInteractions() == [.background, .background]) + #expect(store.requiredRefreshCompletedGeneration == 4) + #expect(store.requiredRefreshTask == nil) + #expect(store.pendingRequiredRefreshRequest == nil) + } + + @Test + func `forced dashboard refresh stops a queued stale scheduler before it starts`() async throws { + let settings = try self.makeSettingsStore( + suite: "CodexBackgroundRefreshCoalescingTests-forced-dashboard-prestart-cancellation") + settings.statusChecksEnabled = false + settings.costUsageEnabled = false + let managedAccount = try Self.installManagedAccount( + email: "managed@example.com", + settings: settings) + defer { try? FileManager.default.removeItem(atPath: managedAccount.managedHomePath) } + + let store = self.makeStore(settings: settings) + var allowNavigationTimeoutRetries: [Bool] = [] + store._test_openAIDashboardCookieImportOverride = { targetEmail, _, _, _, _ in + OpenAIDashboardBrowserCookieImporter.ImportResult( + sourceLabel: "Fixture", + cookieCount: 2, + signedInEmail: targetEmail, + matchesCodexEmail: true) + } + store._test_openAIDashboardLoaderOverride = { _, _, allowNavigationTimeoutRetry, _ in + allowNavigationTimeoutRetries.append(allowNavigationTimeoutRetry) + return OpenAIDashboardSnapshot( + signedInEmail: managedAccount.email, + codeReviewRemainingPercent: 95, + creditEvents: [], + dailyBreakdown: [], + usageBreakdown: [], + creditsPurchaseURL: nil, + creditsRemaining: 25, + accountPlan: "Pro", + updatedAt: Date()) + } + defer { + store._test_openAIDashboardCookieImportOverride = nil + store._test_openAIDashboardLoaderOverride = nil + } + + let currentGuard = store.freshCodexOpenAIWebRefreshGuard() + let backgroundGuard = CodexAccountScopedRefreshGuard( + source: currentGuard.source, + identity: currentGuard.identity, + accountKey: currentGuard.accountKey, + authFingerprint: "background-token-material") + let forcedGuard = CodexAccountScopedRefreshGuard( + source: currentGuard.source, + identity: currentGuard.identity, + accountKey: currentGuard.accountKey, + authFingerprint: "forced-token-material") + store.openAIWebAccountDidChange = true + + // Keep both calls on this MainActor turn so the forced request cancels the scheduler + // before its task body starts. + store.scheduleOpenAIDashboardRefreshIfNeeded(expectedGuard: backgroundGuard) + let backgroundTask = try #require(store.openAIDashboardBackgroundRefreshTask) + await store.refreshOpenAIDashboardIfNeeded( + force: true, + expectedGuard: forcedGuard, + bypassCoalescing: true, + allowCodexUsageBackfill: false) + await backgroundTask.value + + #expect(backgroundTask.isCancelled) + #expect(allowNavigationTimeoutRetries == [true]) + #expect(store.openAIDashboardBackgroundRefreshTask == nil) + #expect(store.openAIDashboardRefreshTask == nil) + } + + @Test + func `forced dashboard enrichment supersedes weaker background request`() async throws { + let settings = try self.makeSettingsStore( + suite: "CodexBackgroundRefreshCoalescingTests-forced-dashboard-supersedes-background") + settings.statusChecksEnabled = false + settings.costUsageEnabled = false + let managedAccount = try Self.installManagedAccount( + email: "managed@example.com", + settings: settings) + defer { try? FileManager.default.removeItem(atPath: managedAccount.managedHomePath) } + + let store = self.makeStore(settings: settings) + let dashboardLoader = BlockingManagedOpenAIDashboardLoader() + var allowNavigationTimeoutRetries: [Bool] = [] + var dashboardInteractions: [ProviderInteraction] = [] + store._test_providerRefreshOverride = { _ in } + store._test_codexCreditsLoaderOverride = { + CreditsSnapshot(remaining: 25, events: [], updatedAt: Date()) + } + store._test_openAIDashboardLoaderOverride = { _, _, allowNavigationTimeoutRetry, _ in + allowNavigationTimeoutRetries.append(allowNavigationTimeoutRetry) + dashboardInteractions.append(ProviderInteractionContext.current) + return try await dashboardLoader.awaitResult() + } + defer { + store._test_providerRefreshOverride = nil + store._test_codexCreditsLoaderOverride = nil + store._test_openAIDashboardLoaderOverride = nil + } + + store.scheduleOpenAIDashboardRefreshIfNeeded( + expectedGuard: store.freshCodexOpenAIWebRefreshGuard()) + let didStartBackground = await dashboardLoader.waitUntilStartedWithin(count: 1) + #expect(didStartBackground) + guard didStartBackground else { + store.invalidateOpenAIDashboardRefreshTask() + await dashboardLoader.cancelAll() + return + } + let staleDashboardTask = store.openAIDashboardRefreshTask + let backgroundTask = store.openAIDashboardBackgroundRefreshTask + + let forcedRefresh = Task { @MainActor in + await BrowserCookieAccessGate.withExplicitRetry { + await ProviderInteractionContext.$current.withValue(.userInitiated) { + await store.refresh(enrichmentMode: .forcedBackground) + await store.awaitForcedRefreshEnrichment() + } + } + } + let didStartForced = await dashboardLoader.waitUntilStartedWithin(count: 2) + #expect(didStartForced) + guard didStartForced else { + forcedRefresh.cancel() + store.cancelForcedRefreshEnrichment() + store.invalidateOpenAIDashboardRefreshTask() + await dashboardLoader.cancelAll() + await staleDashboardTask?.value + await backgroundTask?.value + await forcedRefresh.value + return + } + #expect(staleDashboardTask?.isCancelled == true) + #expect(backgroundTask?.isCancelled == true) + + await dashboardLoader.resumeNext(with: .failure(URLError(.timedOut))) + await staleDashboardTask?.value + await backgroundTask?.value + #expect(store.lastOpenAIDashboardError == nil) + + await dashboardLoader.resumeNext(with: .success(OpenAIDashboardSnapshot( + signedInEmail: managedAccount.email, + codeReviewRemainingPercent: 95, + creditEvents: [], + dailyBreakdown: [], + usageBreakdown: [], + creditsPurchaseURL: nil, + creditsRemaining: 25, + accountPlan: "Pro", + updatedAt: Date()))) + await forcedRefresh.value + + #expect(allowNavigationTimeoutRetries == [false, true]) + #expect(dashboardInteractions == [.background, .userInitiated]) + #expect(store.openAIDashboard?.creditsRemaining == 25) + #expect(store.lastOpenAIDashboardError == nil) + #expect(!store.hasForcedRefreshEnrichmentInFlight) + } + + @Test + func `account scoped refresh supersedes weaker background dashboard request`() async throws { + let settings = try self.makeSettingsStore( + suite: "CodexBackgroundRefreshCoalescingTests-account-dashboard-supersedes-background") + settings.statusChecksEnabled = false + settings.costUsageEnabled = false + let managedAccount = try Self.installManagedAccount( + email: "managed@example.com", + settings: settings) + defer { try? FileManager.default.removeItem(atPath: managedAccount.managedHomePath) } + + let store = self.makeStore(settings: settings) + let dashboardLoader = BlockingManagedOpenAIDashboardLoader() + var allowNavigationTimeoutRetries: [Bool] = [] + store._test_providerRefreshOverride = { _ in } + store._test_codexCreditsLoaderOverride = { + CreditsSnapshot(remaining: 25, events: [], updatedAt: Date()) + } + store._test_openAIDashboardLoaderOverride = { _, _, allowNavigationTimeoutRetry, _ in + allowNavigationTimeoutRetries.append(allowNavigationTimeoutRetry) + return try await dashboardLoader.awaitResult() + } + defer { + store._test_providerRefreshOverride = nil + store._test_codexCreditsLoaderOverride = nil + store._test_openAIDashboardLoaderOverride = nil + } + + store.scheduleOpenAIDashboardRefreshIfNeeded( + expectedGuard: store.freshCodexOpenAIWebRefreshGuard()) + let didStartBackground = await dashboardLoader.waitUntilStartedWithin(count: 1) + #expect(didStartBackground) + guard didStartBackground else { + store.invalidateOpenAIDashboardRefreshTask() + await dashboardLoader.cancelAll() + return + } + let staleDashboardTask = store.openAIDashboardRefreshTask + let backgroundTask = store.openAIDashboardBackgroundRefreshTask + + let accountRefresh = Task { @MainActor in + await BrowserCookieAccessGate.withExplicitRetry { + await ProviderInteractionContext.$current.withValue(.userInitiated) { + await store.refreshCodexAccountScopedState() + } + } + } + let didStartForced = await dashboardLoader.waitUntilStartedWithin(count: 2) + #expect(didStartForced) + guard didStartForced else { + accountRefresh.cancel() + store.invalidateOpenAIDashboardRefreshTask() + await dashboardLoader.cancelAll() + await staleDashboardTask?.value + await backgroundTask?.value + await accountRefresh.value + return + } + #expect(staleDashboardTask?.isCancelled == true) + #expect(backgroundTask?.isCancelled == true) + + await dashboardLoader.resumeNext(with: .failure(URLError(.timedOut))) + await staleDashboardTask?.value + await backgroundTask?.value + #expect(store.lastOpenAIDashboardError == nil) + + await dashboardLoader.resumeNext(with: .success(OpenAIDashboardSnapshot( + signedInEmail: managedAccount.email, + codeReviewRemainingPercent: 95, + creditEvents: [], + dailyBreakdown: [], + usageBreakdown: [], + creditsPurchaseURL: nil, + creditsRemaining: 25, + accountPlan: "Pro", + updatedAt: Date()))) + await accountRefresh.value + + #expect(allowNavigationTimeoutRetries == [false, true]) + #expect(store.openAIDashboard?.creditsRemaining == 25) + #expect(store.lastOpenAIDashboardError == nil) + } + + @Test + func `forced background refresh detaches stale dashboard before its tail`() async throws { + let settings = try self.makeSettingsStore( + suite: "CodexBackgroundRefreshCoalescingTests-forced-dashboard-detaches-account") + settings.statusChecksEnabled = false + settings.costUsageEnabled = true + let alphaAccount = try Self.installManagedAccount( + email: "alpha@example.com", + settings: settings) + defer { try? FileManager.default.removeItem(atPath: alphaAccount.managedHomePath) } + + let store = self.makeStore(settings: settings) + store.syncOpenAIWebState() + let alphaDashboard = OpenAIDashboardSnapshot( + signedInEmail: alphaAccount.email, + codeReviewRemainingPercent: 95, + creditEvents: [], + dailyBreakdown: [], + usageBreakdown: [], + creditsPurchaseURL: nil, + creditsRemaining: 25, + accountPlan: "Pro", + updatedAt: Date()) + store.openAIDashboard = alphaDashboard + store.openAIDashboardAttachmentAuthorized = true + store.lastOpenAIDashboardSnapshot = alphaDashboard + store.lastOpenAIDashboardAttachmentAuthorized = true + + let betaAccount = ManagedCodexAccount( + id: UUID(), + email: "beta@example.com", + managedHomePath: "/tmp/codexbar-managed-beta", + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1) + let tokenGate = BlockingForcedTokenRefresh() + store._test_providerRefreshOverride = { provider in + guard provider == .codex else { return } + settings._test_activeManagedCodexAccount = betaAccount + settings.codexActiveSource = .managedAccount(id: betaAccount.id) + } + store._test_codexCreditsLoaderOverride = { + CreditsSnapshot(remaining: 25, events: [], updatedAt: Date()) + } + store._test_tokenUsageRefreshOverride = { provider, force in + guard force else { return } + await tokenGate.run( + provider: provider, + force: force, + interaction: ProviderInteractionContext.current, + refreshPhase: ProviderRefreshContext.current, + browserRetryAllowed: false) + } + defer { + settings._test_activeManagedCodexAccount = nil + store._test_providerRefreshOverride = nil + store._test_codexCreditsLoaderOverride = nil + store._test_tokenUsageRefreshOverride = nil + } + + await store.refresh(enrichmentMode: .forcedBackground) + #expect(await tokenGate.waitUntilStarted(count: 1)) + + #expect(store.openAIDashboard == nil) + #expect(!store.openAIDashboardAttachmentAuthorized) + #expect(store.lastOpenAIDashboardSnapshot == nil) + #expect(!store.lastOpenAIDashboardAttachmentAuthorized) + #expect(store.openAIDashboardRequiresLogin) + #expect(store.hasForcedRefreshEnrichmentInFlight) + + let enrichmentTask = store.forcedRefreshEnrichmentTask + store.cancelForcedRefreshEnrichment() + await tokenGate.resumeNext() + await enrichmentTask?.value + } + + @Test + func `forced token tail excludes periodic token sequence`() async throws { + let settings = try self.makeSettingsStore( + suite: "CodexBackgroundRefreshCoalescingTests-forced-token-excludes-timer") + settings.statusChecksEnabled = false + settings.costUsageEnabled = true + settings.openAIWebAccessEnabled = false + let store = self.makeStore(settings: settings) + let tokenGate = BlockingForcedTokenRefresh() + store._test_providerRefreshOverride = { _ in } + store._test_codexCreditsLoaderOverride = { + CreditsSnapshot(remaining: 25, events: [], updatedAt: Date()) + } + store._test_tokenUsageRefreshOverride = { provider, force in + await tokenGate.run( + provider: provider, + force: force, + interaction: ProviderInteractionContext.current, + refreshPhase: ProviderRefreshContext.current, + browserRetryAllowed: false) + } + defer { + store._test_providerRefreshOverride = nil + store._test_codexCreditsLoaderOverride = nil + store._test_tokenUsageRefreshOverride = nil + } + + await store.refresh(enrichmentMode: .forcedBackground) + #expect(await tokenGate.waitUntilStarted(count: 1)) + + store.scheduleTokenRefreshForTesting() + try await Task.sleep(for: .milliseconds(100)) + #expect(await tokenGate.recordedCalls().count == 1) + + await tokenGate.resumeNext() + await store.awaitForcedRefreshEnrichment() + + let calls = await tokenGate.recordedCalls() + #expect(calls.count == 1) + #expect(calls.first?.force == true) + #expect(!store.hasForcedRefreshEnrichmentInFlight) + } + + @Test + func `forced enrichment excludes timer after token child completes`() async throws { + let settings = try self.makeSettingsStore( + suite: "CodexBackgroundRefreshCoalescingTests-forced-tail-excludes-token-timer") + settings.statusChecksEnabled = false + settings.costUsageEnabled = true + settings.openAIWebAccessEnabled = false + let managedAccount = try Self.installManagedAccount( + email: "managed@example.com", + settings: settings) + defer { try? FileManager.default.removeItem(atPath: managedAccount.managedHomePath) } + + let store = self.makeStore(settings: settings) + let tokenGate = BlockingForcedTokenRefresh() + let creditsGate = BlockingCreditsLoader() + store._test_providerRefreshOverride = { _ in } + store._test_codexCreditsLoaderOverride = { + try await creditsGate.awaitResult() + } + store._test_tokenUsageRefreshOverride = { provider, force in + await tokenGate.run( + provider: provider, + force: force, + interaction: ProviderInteractionContext.current, + refreshPhase: ProviderRefreshContext.current, + browserRetryAllowed: false) + } + defer { + store._test_providerRefreshOverride = nil + store._test_codexCreditsLoaderOverride = nil + store._test_tokenUsageRefreshOverride = nil + } + + await store.refresh(enrichmentMode: .forcedBackground) + #expect(await tokenGate.waitUntilStarted(count: 1)) + #expect(await creditsGate.waitUntilStartedWithin(count: 1)) + + await tokenGate.resumeNext() + for _ in 0..<100 where store.tokenRefreshSequenceTask != nil { + await Task.yield() + } + #expect(store.tokenRefreshSequenceTask == nil) + #expect(store.hasForcedRefreshEnrichmentInFlight) + + store.scheduleTokenRefreshForTesting() + try await Task.sleep(for: .milliseconds(100)) + #expect(store.tokenRefreshSequenceTask == nil) + #expect(await tokenGate.recordedCalls().count == 1) + + await creditsGate.resumeNext(with: .success(CreditsSnapshot( + remaining: 25, + events: [], + updatedAt: Date()))) + await store.awaitForcedRefreshEnrichment() + + #expect(!store.hasForcedRefreshEnrichmentInFlight) + } +} + +private actor BlockingRequiredProviderRefresh { + private var interactions: [ProviderInteraction] = [] + private var continuations: [(id: UUID, continuation: CheckedContinuation)] = [] + + func run(interaction: ProviderInteraction) async { + let id = UUID() + self.interactions.append(interaction) + await withTaskCancellationHandler { + await withCheckedContinuation { (continuation: CheckedContinuation) in + if Task.isCancelled { + continuation.resume() + } else { + self.continuations.append((id: id, continuation: continuation)) + } + } + } onCancel: { + Task { await self.cancel(id: id) } + } + } + + func waitUntilStarted(count: Int, timeout: Duration = .seconds(5)) async -> Bool { + let deadline = ContinuousClock.now + timeout + while self.interactions.count < count { + if ContinuousClock.now >= deadline { + return false + } + try? await Task.sleep(for: .milliseconds(10)) + } + return true + } + + func startedCount() -> Int { + self.interactions.count + } + + func recordedInteractions() -> [ProviderInteraction] { + self.interactions + } + + func resumeNext() { + guard !self.continuations.isEmpty else { return } + self.continuations.removeFirst().continuation.resume() + } + + func cancelAll() { + let continuations = self.continuations + self.continuations.removeAll() + continuations.forEach { $0.continuation.resume() } + } + + private func cancel(id: UUID) { + guard let index = self.continuations.firstIndex(where: { $0.id == id }) else { return } + self.continuations.remove(at: index).continuation.resume() + } +} diff --git a/Tests/CodexBarTests/ResetCountdownDayRolloverTests.swift b/Tests/CodexBarTests/ResetCountdownDayRolloverTests.swift new file mode 100644 index 0000000000..e0c9a796e3 --- /dev/null +++ b/Tests/CodexBarTests/ResetCountdownDayRolloverTests.swift @@ -0,0 +1,55 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct ResetCountdownDayRolloverTests { + private static let now = Date(timeIntervalSince1970: 1_700_000_000) + + private func at(hoursFromNow hours: Double) -> Date { + Self.now.addingTimeInterval(hours * 3600) + } + + @Test + func `Windsurf web reset at exactly 24h rolls over to a day`() { + // Was "Resets in 24h 0m"; the day form must be reachable at the 24h boundary. + #expect( + WindsurfGetPlanStatusResponse.formatResetDescription(self.at(hoursFromNow: 24), now: Self.now) + == "Resets in 1d 0h") + } + + @Test + func `Windsurf web reset above 24h shows day and hour`() { + #expect( + WindsurfGetPlanStatusResponse.formatResetDescription(self.at(hoursFromNow: 25), now: Self.now) + == "Resets in 1d 1h") + } + + @Test + func `Windsurf web reset below 24h stays in hours`() { + #expect( + WindsurfGetPlanStatusResponse.formatResetDescription(self.at(hoursFromNow: 23), now: Self.now) + == "Resets in 23h 0m") + } + + @Test + func `Windsurf cached reset at exactly 24h rolls over to a day`() { + #expect( + WindsurfCachedPlanInfo.formatResetDescription(self.at(hoursFromNow: 24), now: Self.now) + == "Resets in 1d 0h") + } + + @Test + func `Zed cycle at exactly 24h rolls over to a day`() { + // Was "Cycle ends in 24h 0m". + #expect( + ZedUsageSnapshot.formatResetDescription(self.at(hoursFromNow: 24), now: Self.now) + == "Cycle ends in 1d 0h") + } + + @Test + func `JetBrains reset at exactly 24h rolls over to a day`() { + #expect( + JetBrainsStatusSnapshot.formatResetDescription(self.at(hoursFromNow: 24), now: Self.now) + == "Resets in 1d 0h") + } +} diff --git a/Tests/CodexBarTests/ResetTimeBackfillTests.swift b/Tests/CodexBarTests/ResetTimeBackfillTests.swift index 7cdbf152fb..84dd73d3aa 100644 --- a/Tests/CodexBarTests/ResetTimeBackfillTests.swift +++ b/Tests/CodexBarTests/ResetTimeBackfillTests.swift @@ -28,6 +28,22 @@ final class ResetTimeBackfillTests: XCTestCase { XCTAssertEqual(result.nextRegenPercent, 4) } + func test_backfillsZeroWindowDurationFromCachedWindow() { + let now = Date(timeIntervalSince1970: 1_800_000_000) + let reset = now.addingTimeInterval(3600) + let cached = RateWindow( + usedPercent: 50, + windowMinutes: 300, + resetsAt: reset, + resetDescription: nil) + let fresh = RateWindow(usedPercent: 62, windowMinutes: 0, resetsAt: nil, resetDescription: nil) + + let result = fresh.backfillingResetTime(from: cached, now: now) + + XCTAssertEqual(result.windowMinutes, 300) + XCTAssertEqual(result.resetsAt, reset) + } + func test_skipsExpiredCachedReset() { let now = Date(timeIntervalSince1970: 1_800_000_000) let cached = RateWindow( @@ -76,6 +92,8 @@ final class ResetTimeBackfillTests: XCTestCase { secondary: nil, extraRateWindows: [extra], cursorRequests: CursorRequestUsage(used: 10, limit: 50), + subscriptionExpiresAt: reset.addingTimeInterval(86400), + subscriptionRenewsAt: reset.addingTimeInterval(43200), updatedAt: now, identity: identity) @@ -87,6 +105,8 @@ final class ResetTimeBackfillTests: XCTestCase { XCTAssertEqual(result.extraRateWindows?.first?.id, "overflow") XCTAssertEqual(result.extraRateWindows?.first?.window.nextRegenPercent, 2) XCTAssertEqual(result.cursorRequests?.used, 10) + XCTAssertEqual(result.subscriptionExpiresAt, reset.addingTimeInterval(86400)) + XCTAssertEqual(result.subscriptionRenewsAt, reset.addingTimeInterval(43200)) XCTAssertEqual(result.identity?.accountEmail, "peter@example.com") } @@ -119,4 +139,69 @@ final class ResetTimeBackfillTests: XCTestCase { XCTAssertNil(result.primary?.resetsAt) } + + func test_snapshotBackfillSkipsSameEmailWithDifferentStableAccountIDs() { + let now = Date(timeIntervalSince1970: 1_800_000_000) + let cached = UsageSnapshot( + primary: RateWindow( + usedPercent: 40, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(3600), + resetDescription: "Soon"), + secondary: nil, + updatedAt: now.addingTimeInterval(-300), + identity: ProviderIdentitySnapshot( + providerID: .cursor, + accountEmail: "shared@example.com", + accountOrganization: nil, + loginMethod: nil, + accountID: "account-a")) + let fresh = UsageSnapshot( + primary: RateWindow(usedPercent: 66, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: nil, + updatedAt: now, + identity: ProviderIdentitySnapshot( + providerID: .cursor, + accountEmail: "shared@example.com", + accountOrganization: nil, + loginMethod: nil, + accountID: "account-b")) + + let result = fresh.backfillingResetTimes(from: cached, now: now) + + XCTAssertNil(result.primary?.resetsAt) + } + + func test_snapshotBackfillKeepsOtherProviderResetWhenDescriptionChanges() { + let now = Date(timeIntervalSince1970: 1_800_000_000) + let reset = now.addingTimeInterval(3600) + let identity = ProviderIdentitySnapshot( + providerID: .claude, + accountEmail: "user@example.com", + accountOrganization: nil, + loginMethod: nil) + let cached = UsageSnapshot( + primary: RateWindow( + usedPercent: 40, + windowMinutes: 300, + resetsAt: reset, + resetDescription: "40 / 100 used"), + secondary: nil, + updatedAt: now.addingTimeInterval(-300), + identity: identity) + let fresh = UsageSnapshot( + primary: RateWindow( + usedPercent: 50, + windowMinutes: 300, + resetsAt: nil, + resetDescription: "50 / 100 used"), + secondary: nil, + updatedAt: now, + identity: identity) + + let result = fresh.backfillingResetTimes(from: cached, now: now) + + XCTAssertEqual(result.primary?.resetsAt, reset) + XCTAssertEqual(result.primary?.resetDescription, "50 / 100 used") + } } diff --git a/Tests/CodexBarTests/SakanaUsageFetcherTests.swift b/Tests/CodexBarTests/SakanaUsageFetcherTests.swift new file mode 100644 index 0000000000..9292635d83 --- /dev/null +++ b/Tests/CodexBarTests/SakanaUsageFetcherTests.swift @@ -0,0 +1,533 @@ +import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif +import Testing +@testable import CodexBarCore + +@Suite(.serialized) +struct SakanaUsageFetcherTests { + @Test + func `billing html maps five hour and weekly windows`() throws { + let now = Date(timeIntervalSince1970: 1_782_222_000) + let usage = try SakanaUsageFetcher.parseBillingHTML( + Self.billingHTML, + now: now).toUsageSnapshot() + + #expect(usage.primary?.usedPercent == 92) + #expect(usage.primary?.windowMinutes == 300) + #expect(usage.primary?.resetsAt == Self.date(year: 2026, month: 6, day: 23, hour: 14, minute: 53)) + #expect(usage.primary?.resetDescription == nil) + #expect(usage.secondary?.usedPercent == 32) + #expect(usage.secondary?.windowMinutes == 10080) + #expect(usage.secondary?.resetsAt == Self.date(year: 2026, month: 6, day: 29, hour: 0, minute: 0)) + #expect(usage.secondary?.resetDescription == nil) + #expect(usage.identity?.providerID == .sakana) + #expect(usage.identity?.loginMethod == "Standard $20/mo") + #expect(usage.updatedAt == now) + } + + @Test + func `fetch sends normalized cookie header to billing endpoint`() async throws { + let transport = SakanaScriptedTransport(statusCode: 200, body: Self.billingHTML) + + let snapshot = try await SakanaUsageFetcher.fetchUsage( + cookieHeader: "Cookie: session=abc; theme=dark", + session: transport, + now: Date(timeIntervalSince1970: 0)) + let requests = await transport.capturedRequestsSnapshot() + let request = requests.first { $0.url == "https://console.sakana.ai/billing" } + + #expect(snapshot.fiveHour?.usedPercent == 92) + #expect(request?.url == "https://console.sakana.ai/billing") + #expect(request?.method == "GET") + #expect(request?.cookie == "session=abc; theme=dark") + #expect(request?.acceptLanguage == "en-US,en;q=0.9") + } + + @Test + func `fetches pay as you go concurrently and merges the credit balance`() async throws { + let transport = SakanaScriptedTransport( + statusCode: 200, + body: Self.billingHTML, + overridesByURL: [ + "https://console.sakana.ai/billing?tab=payAsYouGo": (200, Self.payAsYouGoHTML), + ], + billingWaitsForPayAsYouGo: true) + + let snapshot = try await SakanaUsageFetcher.fetchUsage( + cookieHeader: "session=abc", + session: transport, + now: Date(timeIntervalSince1970: 0)) + let requests = await transport.capturedRequestsSnapshot() + + #expect(snapshot.fiveHour?.usedPercent == 92) + #expect(snapshot.payAsYouGo?.creditBalance == 12.34) + #expect(snapshot.payAsYouGo?.periodUsageTotal == 5.67) + #expect(snapshot.payAsYouGo?.periodLabel == "Jun 02, 2026 - Jul 01, 2026") + #expect(requests.count == 2) + let payAsYouGoRequest = requests.first { $0.url == "https://console.sakana.ai/billing?tab=payAsYouGo" } + #expect(payAsYouGoRequest?.cookie == "session=abc") + + let usage = snapshot.toUsageSnapshot() + #expect(usage.sakanaPayAsYouGo?.balanceDetail == "$12.34") + } + + @Test + func `quick pay as you go response can finish after primary within the shared budget`() async throws { + let transport = SakanaScriptedTransport( + statusCode: 200, + body: Self.billingHTML, + overridesByURL: [ + "https://console.sakana.ai/billing?tab=payAsYouGo": (200, Self.payAsYouGoHTML), + ], + payAsYouGoDelay: .milliseconds(20)) + + let snapshot = try await SakanaUsageFetcher.fetchUsage( + cookieHeader: "session=abc", + session: transport, + now: Date(timeIntervalSince1970: 0)) + + #expect(snapshot.fiveHour?.usedPercent == 92) + #expect(snapshot.payAsYouGo?.creditBalance == 12.34) + } + + @Test + func `fetch skips the pay as you go request entirely when optional usage is disabled`() async throws { + let transport = SakanaScriptedTransport( + statusCode: 200, + body: Self.billingHTML, + overridesByURL: [ + "https://console.sakana.ai/billing?tab=payAsYouGo": (200, Self.payAsYouGoHTML), + ]) + + let snapshot = try await SakanaUsageFetcher.fetchUsage( + cookieHeader: "session=abc", + session: transport, + now: Date(timeIntervalSince1970: 0), + includeOptionalUsage: false) + let requests = await transport.capturedRequestsSnapshot() + + #expect(snapshot.fiveHour?.usedPercent == 92) + #expect(snapshot.payAsYouGo == nil) + // Only the required subscription-quota request is made; disabling optional usage must not + // just discard the PAYG result, it must skip the network request entirely. + #expect(requests.count == 1) + #expect(requests.first?.url == "https://console.sakana.ai/billing") + } + + @Test + func `pay as you go bounded fetch does not wait for an operation that ignores cancellation`() async throws { + let startedAt = ContinuousClock.now + + let fetched = await SakanaUsageFetcher._boundedFetchPayAsYouGoForTesting(timeout: .milliseconds(20)) { + await withCheckedContinuation { continuation in + DispatchQueue.global().asyncAfter(deadline: .now() + 0.5) { + continuation.resume(returning: SakanaPayAsYouGoSnapshot(creditBalance: 9)) + } + } + } + + let elapsed = startedAt.duration(to: .now) + #expect(fetched == nil) + #expect(elapsed < .milliseconds(300)) + + try await Task.sleep(for: .milliseconds(550)) + } + + @Test + func `fetch tolerates a failing pay as you go request without failing the primary fetch`() async throws { + // Default response is a 500; only the primary billing URL is overridden to succeed, so the + // pay-as-you-go request (not present in the override map) falls through to that failure. + let transport = SakanaScriptedTransport( + statusCode: 500, + body: "boom", + overridesByURL: [ + "https://console.sakana.ai/billing": (200, Self.billingHTML), + ]) + + let snapshot = try await SakanaUsageFetcher.fetchUsage( + cookieHeader: "session=abc", + session: transport, + now: Date(timeIntervalSince1970: 0)) + + #expect(snapshot.fiveHour?.usedPercent == 92) + #expect(snapshot.payAsYouGo == nil) + } + + @Test + func `slow pay as you go request never delays the primary quota result`() async throws { + let transport = SakanaScriptedTransport( + statusCode: 200, + body: Self.billingHTML, + billingWaitsForPayAsYouGo: true, + payAsYouGoBlocksUntilCancelled: true) + let startedAt = ContinuousClock.now + + let snapshot = try await SakanaUsageFetcher.fetchUsage( + cookieHeader: "session=abc", + session: transport, + now: Date(timeIntervalSince1970: 0)) + + #expect(snapshot.fiveHour?.usedPercent == 92) + #expect(snapshot.payAsYouGo == nil) + #expect(startedAt.duration(to: .now) < .milliseconds(500)) + for _ in 0..<1000 where await !(transport.didCancelPayAsYouGo()) { + await Task.yield() + } + #expect(await transport.didCancelPayAsYouGo()) + } + + @Test + func `required fetch failure cancels the concurrent pay as you go request`() async throws { + let transport = SakanaScriptedTransport( + statusCode: 401, + body: "expired", + billingWaitsForPayAsYouGo: true, + payAsYouGoBlocksUntilCancelled: true) + + await #expect(throws: SakanaUsageError.loginRequired) { + _ = try await SakanaUsageFetcher.fetchUsage( + cookieHeader: "session=expired", + session: transport) + } + + for _ in 0..<1000 where await !(transport.didCancelPayAsYouGo()) { + await Task.yield() + } + #expect(await transport.didCancelPayAsYouGo()) + } + + @Test + func `fetch rejects cross origin login redirect`() async throws { + let transport = try SakanaScriptedTransport( + statusCode: 200, + body: Self.billingHTML, + responseURL: #require(URL(string: "https://auth.sakana.ai")?.appending(path: "login"))) + + await #expect(throws: SakanaUsageError.loginRequired) { + _ = try await SakanaUsageFetcher.fetchUsage( + cookieHeader: "session=expired", + session: transport) + } + } + + @Test + func `fetch classifies blocked login redirect as login required`() async throws { + let transport = try SakanaScriptedTransport( + statusCode: 302, + body: "", + headers: ["Location": #require(URL(string: "https://auth.sakana.ai/login")).absoluteString]) + + await #expect(throws: SakanaUsageError.loginRequired) { + _ = try await SakanaUsageFetcher.fetchUsage( + cookieHeader: "session=expired", + session: transport) + } + } + + @Test + func `fetch does not expose error response body`() async { + let transport = SakanaScriptedTransport(statusCode: 500, body: "private account response") + + await #expect(throws: SakanaUsageError.apiError(500)) { + _ = try await SakanaUsageFetcher.fetchUsage( + cookieHeader: "session=abc", + session: transport) + } + } + + @Test + func `missing usage windows throws parse error`() { + #expect(throws: SakanaUsageError.parseFailed("Usage limit windows were not found.")) { + _ = try SakanaUsageFetcher.parseBillingHTML("
    Billing
    ") + } + } + + @Test + func `out of range percentages are rejected`() { + let html = Self.billingHTML + .replacing("92% used", with: "101% used") + .replacing("32% used", with: "999% used") + + #expect(throws: SakanaUsageError.parseFailed("Invalid 5-hour usage percentage.")) { + _ = try SakanaUsageFetcher.parseBillingHTML(html) + } + } + + @Test + func `invalid primary percentage rejects otherwise valid weekly response`() { + let html = Self.billingHTML.replacing("92% used", with: "101% used") + + #expect(throws: SakanaUsageError.parseFailed("Invalid 5-hour usage percentage.")) { + _ = try SakanaUsageFetcher.parseBillingHTML(html) + } + } + + @Test + func `unparsed reset date does not become reset description`() throws { + let usage = try SakanaUsageFetcher.parseBillingHTML( + Self.billingHTML.replacing("June 23, 2026 at 2:53 PM", with: "soon-ish")).toUsageSnapshot() + + #expect(usage.primary?.usedPercent == 92) + #expect(usage.primary?.resetsAt == nil) + #expect(usage.primary?.resetDescription == nil) + } + + @Test + func `window without reset line still maps percent`() throws { + let html = Self.billingHTML.replacing( + "

    Resets on June 23, 2026 at 2:53 PM

    ", + with: "") + let usage = try SakanaUsageFetcher.parseBillingHTML(html).toUsageSnapshot() + + #expect(usage.primary?.usedPercent == 92) + #expect(usage.primary?.windowMinutes == 300) + #expect(usage.primary?.resetsAt == nil) + #expect(usage.primary?.resetDescription == nil) + #expect(usage.secondary?.usedPercent == 32) + #expect(usage.secondary?.resetsAt == Self.date(year: 2026, month: 6, day: 29, hour: 0, minute: 0)) + } + + @Test + func `missing window percent rejects response without reading next quota window`() { + let html = Self.billingHTML.replacing( + "

    92% used

    ", + with: "") + + #expect(throws: SakanaUsageError.parseFailed("Invalid 5-hour usage percentage.")) { + _ = try SakanaUsageFetcher.parseBillingHTML(html) + } + } + + @Test + func `reset date is parsed as UTC regardless of the device's local timezone`() throws { + // The console always server-renders "Resets on " in UTC (the client corrects it to + // the viewer's local time only after JS hydration, which this HTML-only fetcher never + // runs). Regression coverage for steipete/CodexBar#1826: force the process default far + // from UTC (UTC+14) so this fails if TimeZone.current ever leaks back into the parser -- + // on a UTC CI runner the pre-fix TimeZone.current code would coincidentally still produce + // the right answer, so this test would not have caught the original bug without the + // override. + let originalTimeZone = NSTimeZone.default + NSTimeZone.default = TimeZone(secondsFromGMT: 14 * 60 * 60)! + defer { NSTimeZone.default = originalTimeZone } + + let usage = try SakanaUsageFetcher.parseBillingHTML(Self.billingHTML).toUsageSnapshot() + + #expect(usage.primary?.resetsAt == Self.date(year: 2026, month: 6, day: 23, hour: 14, minute: 53)) + #expect(usage.primary?.resetsAt?.timeIntervalSince1970 == 1_782_226_380) + } + + @Test + func `pay as you go html maps credit balance usage total and date range label`() { + let usage = SakanaUsageFetcher.parsePayAsYouGoHTML(Self.payAsYouGoHTML) + + #expect(usage?.creditBalance == 12.34) + #expect(usage?.periodUsageTotal == 5.67) + #expect(usage?.periodLabel == "Jun 02, 2026 - Jul 01, 2026") + #expect(usage?.balanceDetail == "$12.34") + } + + @Test + func `pay as you go html without usage total still maps credit balance`() { + let html = Self.payAsYouGoHTML.replacing( + "Total: $5.67", + with: "") + + let usage = SakanaUsageFetcher.parsePayAsYouGoHTML(html) + + #expect(usage?.creditBalance == 12.34) + #expect(usage?.periodUsageTotal == nil) + } + + @Test + func `billing html without a pay as you go tab returns nil`() { + #expect(SakanaUsageFetcher.parsePayAsYouGoHTML(Self.billingHTML) == nil) + } + + @Test + func `sakana usage snapshot carries pay as you go through to the usage snapshot mapping`() { + let payAsYouGo = SakanaPayAsYouGoSnapshot(creditBalance: 9, periodUsageTotal: 1.5, periodLabel: "Last 30 days") + let snapshot = SakanaUsageSnapshot( + planName: "Standard", + priceLabel: "$20/mo", + fiveHour: .init(usedPercent: 10, resetsAt: nil), + weekly: .init(usedPercent: 20, resetsAt: nil), + payAsYouGo: payAsYouGo) + + let usage = snapshot.toUsageSnapshot() + + #expect(usage.sakanaPayAsYouGo?.creditBalance == 9) + #expect(usage.sakanaPayAsYouGo?.balanceDetail == "$9.00") + } + + private static func date(year: Int, month: Int, day: Int, hour: Int, minute: Int) -> Date? { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(identifier: "UTC")! + return calendar.date(from: DateComponents( + year: year, + month: month, + day: day, + hour: hour, + minute: minute)) + } + + /// Raw server response values are UTC; browser hydration localizes them afterward. + private static let billingHTML = """ +
    +
    Standard$20/mo
    +
    Usage limit
    +

    5-hour

    +

    Resets on June 23, 2026 at 2:53 PM

    + +

    92% used

    +

    Weekly

    +

    Resets on June 29, 2026 at 12:00 AM

    + +

    32% used

    +
    + """ + + /// Minimal reproduction of the "Pay as you go" tab, which the live console only server-renders + /// when the request includes `?tab=payAsYouGo`. The `` markers reproduce React's + /// hydration-boundary comments between separately interpolated JSX text nodes. + private static let payAsYouGoHTML = """ +
    +

    Credit balance

    + +

    $12.34

    + +

    Usage

    + Total: $5.67 +
    + """ +} + +private actor SakanaScriptedTransport: ProviderHTTPTransport { + struct CapturedRequest { + let url: String? + let method: String? + let cookie: String? + let acceptLanguage: String? + } + + private let statusCode: Int + private let body: String + private let responseURL: URL? + private let headers: [String: String] + /// Per-URL response overrides (keyed by the full request URL string), used to stub the + /// subscription-tab and pay-as-you-go-tab requests independently. Falls back to + /// `(statusCode, body)` for any URL not present here. + private let overridesByURL: [String: (statusCode: Int, body: String)] + private let billingWaitsForPayAsYouGo: Bool + private let payAsYouGoBlocksUntilCancelled: Bool + private let payAsYouGoDelay: Duration? + private var capturedRequests: [CapturedRequest] = [] + private var payAsYouGoStarted = false + private var payAsYouGoCompleted = false + private var payAsYouGoWasCancelled = false + private var payAsYouGoStartWaiters: [CheckedContinuation] = [] + private var payAsYouGoCompletionWaiters: [CheckedContinuation] = [] + + init( + statusCode: Int, + body: String, + responseURL: URL? = nil, + headers: [String: String] = [:], + overridesByURL: [String: (statusCode: Int, body: String)] = [:], + billingWaitsForPayAsYouGo: Bool = false, + payAsYouGoBlocksUntilCancelled: Bool = false, + payAsYouGoDelay: Duration? = nil) + { + self.statusCode = statusCode + self.body = body + self.responseURL = responseURL + self.headers = headers + self.overridesByURL = overridesByURL + self.billingWaitsForPayAsYouGo = billingWaitsForPayAsYouGo + self.payAsYouGoBlocksUntilCancelled = payAsYouGoBlocksUntilCancelled + self.payAsYouGoDelay = payAsYouGoDelay + } + + func lastCapturedRequest() -> CapturedRequest? { + self.capturedRequests.last + } + + func capturedRequestsSnapshot() -> [CapturedRequest] { + self.capturedRequests + } + + func didCancelPayAsYouGo() -> Bool { + self.payAsYouGoWasCancelled + } + + func data(for request: URLRequest) async throws -> (Data, URLResponse) { + let isPayAsYouGo = request.url?.query == "tab=payAsYouGo" + if isPayAsYouGo { + self.markPayAsYouGoStarted() + if let payAsYouGoDelay { + try await Task.sleep(for: payAsYouGoDelay) + } + if self.payAsYouGoBlocksUntilCancelled { + do { + try await Task.sleep(for: .seconds(30)) + } catch { + self.payAsYouGoWasCancelled = true + throw error + } + } + } else if self.billingWaitsForPayAsYouGo { + await self.waitForPayAsYouGoStart() + if !self.payAsYouGoBlocksUntilCancelled { + await self.waitForPayAsYouGoCompletion() + } + } + + self.capturedRequests.append(CapturedRequest( + url: request.url?.absoluteString, + method: request.httpMethod, + cookie: request.value(forHTTPHeaderField: "Cookie"), + acceptLanguage: request.value(forHTTPHeaderField: "Accept-Language"))) + + let override = request.url.flatMap { self.overridesByURL[$0.absoluteString] } + let (responseStatusCode, responseBody) = override ?? (self.statusCode, self.body) + let response = HTTPURLResponse( + url: self.responseURL ?? request.url!, + statusCode: responseStatusCode, + httpVersion: "HTTP/1.1", + headerFields: self.headers)! + if isPayAsYouGo { + self.markPayAsYouGoCompleted() + } + return (Data(responseBody.utf8), response) + } + + private func waitForPayAsYouGoStart() async { + guard !self.payAsYouGoStarted else { return } + await withCheckedContinuation { continuation in + self.payAsYouGoStartWaiters.append(continuation) + } + } + + private func waitForPayAsYouGoCompletion() async { + guard !self.payAsYouGoCompleted else { return } + await withCheckedContinuation { continuation in + self.payAsYouGoCompletionWaiters.append(continuation) + } + } + + private func markPayAsYouGoStarted() { + self.payAsYouGoStarted = true + let waiters = self.payAsYouGoStartWaiters + self.payAsYouGoStartWaiters.removeAll() + waiters.forEach { $0.resume() } + } + + private func markPayAsYouGoCompleted() { + self.payAsYouGoCompleted = true + let waiters = self.payAsYouGoCompletionWaiters + self.payAsYouGoCompletionWaiters.removeAll() + waiters.forEach { $0.resume() } + } +} diff --git a/Tests/CodexBarTests/SessionEquivalentForecastIdleTests.swift b/Tests/CodexBarTests/SessionEquivalentForecastIdleTests.swift new file mode 100644 index 0000000000..6dc3b6b920 --- /dev/null +++ b/Tests/CodexBarTests/SessionEquivalentForecastIdleTests.swift @@ -0,0 +1,71 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +extension SessionEquivalentForecastTests { + @MainActor + @Test + func `usage store preserves learned forecast while current session is idle`() throws { + let store = UsageStorePlanUtilizationTests.makeStore() + let fixture = Self.historyFixture(burns: [4, 8, 6, 10]) + store.planUtilizationHistory[.claude] = PlanUtilizationHistoryBuckets(unscoped: fixture.histories) + let now = fixture.currentSessionReset.addingTimeInterval(-3600) + let session = RateWindow( + usedPercent: 0, + windowMinutes: 300, + resetsAt: nil, + resetDescription: nil) + let weekly = RateWindow( + usedPercent: 60, + windowMinutes: 10080, + resetsAt: now.addingTimeInterval(2 * 24 * 3600), + resetDescription: nil) + + let forecast = try #require(store.sessionEquivalentForecast( + provider: .claude, + sessionWindow: session, + weeklyWindow: weekly, + now: now)) + + #expect(forecast.sampleCount == 4) + #expect(forecast.estimatedWindowsToExhaustWeekly > 0) + } + + @MainActor + @Test + func `idle forecast cache refreshes after a historical session completes`() throws { + let store = UsageStorePlanUtilizationTests.makeStore() + let fixture = Self.historyFixture(burns: [5, 5, 5]) + store.planUtilizationHistory[.claude] = PlanUtilizationHistoryBuckets(unscoped: fixture.histories) + store.planUtilizationHistoryRevision = 1 + let thirdReset = fixture.currentSessionReset.addingTimeInterval(-5 * 3600) + let beforeReset = thirdReset.addingTimeInterval(-61) + let afterReset = thirdReset.addingTimeInterval(61) + let session = RateWindow( + usedPercent: 0, + windowMinutes: 300, + resetsAt: nil, + resetDescription: nil) + let weekly = RateWindow( + usedPercent: 15, + windowMinutes: 10080, + resetsAt: afterReset.addingTimeInterval(2 * 24 * 3600), + resetDescription: nil) + + #expect(store.sessionEquivalentForecast( + provider: .claude, + sessionWindow: session, + weeklyWindow: weekly, + now: beforeReset) == nil) + #expect(store._sessionEquivalentHistoryScanCountForTesting == 1) + + let forecast = try #require(store.sessionEquivalentForecast( + provider: .claude, + sessionWindow: session, + weeklyWindow: weekly, + now: afterReset)) + #expect(forecast.sampleCount == 3) + #expect(store._sessionEquivalentHistoryScanCountForTesting == 2) + } +} diff --git a/Tests/CodexBarTests/SessionEquivalentForecastTests.swift b/Tests/CodexBarTests/SessionEquivalentForecastTests.swift new file mode 100644 index 0000000000..f2bcd14f09 --- /dev/null +++ b/Tests/CodexBarTests/SessionEquivalentForecastTests.swift @@ -0,0 +1,1597 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +struct SessionEquivalentForecastTests { + private static let weeklyReset = Date(timeIntervalSince1970: 2_000_000_000) + + @Test + func `uses the median of the latest seven completed active session windows`() throws { + let fixture = Self.historyFixture(burns: [5, 4, 8, 6, 10, 12, 14, 16]) + + let estimate = try #require(SessionEquivalentBurnEstimator.estimate( + histories: fixture.histories, + currentSessionResetsAt: fixture.currentSessionReset, + now: fixture.currentSessionReset.addingTimeInterval(-3600))) + + #expect(estimate.sampleCount == 7) + #expect(estimate.medianWeeklyPercentPerWindow == 10) + } + + @Test + func `normalizes aligned partial session observations to a full allowance`() throws { + let fixture = Self.alignedPartialHistoryFixture() + + let estimate = try #require(SessionEquivalentBurnEstimator.estimate( + histories: fixture.histories, + currentSessionResetsAt: fixture.currentSessionReset, + now: fixture.currentSessionReset.addingTimeInterval(-3600))) + + #expect(estimate.sampleCount == 3) + #expect(estimate.medianWeeklyPercentPerWindow == 10) + } + + @Test + func `rejects partial sessions whose weekly burn cannot be aligned`() { + let fixture = Self.historyFixture(samples: [ + (sessionUsedPercent: 20, weeklyBurnPercent: 2), + (sessionUsedPercent: 40, weeklyBurnPercent: 4), + (sessionUsedPercent: 60, weeklyBurnPercent: 6), + ]) + + #expect(SessionEquivalentBurnEstimator.estimate( + histories: fixture.histories, + currentSessionResetsAt: fixture.currentSessionReset, + now: fixture.currentSessionReset.addingTimeInterval(-3600)) == nil) + } + + @Test + func `uses eligible boundary samples when a closer sample follows the boundary`() throws { + let fixture = Self.straddledBoundaryHistoryFixture() + + let estimate = try #require(SessionEquivalentBurnEstimator.estimate( + histories: fixture.histories, + currentSessionResetsAt: fixture.currentSessionReset, + now: fixture.currentSessionReset.addingTimeInterval(-3600))) + + #expect(estimate.sampleCount == 3) + #expect(estimate.medianWeeklyPercentPerWindow == 10) + } + + @Test + func `requires three completed windows with measurable burn`() { + let fixture = Self.historyFixture(burns: [8, 12]) + + let estimate = SessionEquivalentBurnEstimator.estimate( + histories: fixture.histories, + currentSessionResetsAt: fixture.currentSessionReset, + now: fixture.currentSessionReset.addingTimeInterval(-3600)) + + #expect(estimate == nil) + } + + @Test + func `rejects zero burn and non finite division inputs`() { + let now = Date(timeIntervalSince1970: 1_900_000_000) + let session = RateWindow( + usedPercent: 20, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(3600), + resetDescription: nil) + let weekly = RateWindow( + usedPercent: 50, + windowMinutes: 10080, + resetsAt: now.addingTimeInterval(2 * 24 * 3600), + resetDescription: nil) + + #expect(SessionEquivalentForecast.make( + sessionWindow: session, + weeklyWindow: weekly, + burnEstimate: SessionEquivalentBurnEstimate( + medianWeeklyPercentPerWindow: 0, + sampleCount: 3), + now: now, + workDays: nil) == nil) + #expect(SessionEquivalentForecast.make( + sessionWindow: session, + weeklyWindow: weekly, + burnEstimate: SessionEquivalentBurnEstimate( + medianWeeklyPercentPerWindow: .infinity, + sampleCount: 3), + now: now, + workDays: nil) == nil) + } + + @Test + func `rejects synthetic Claude session placeholder with a future reset`() { + let now = Date(timeIntervalSince1970: 1_900_000_000) + let session = RateWindow( + usedPercent: 0, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(3600), + resetDescription: nil, + isSyntheticPlaceholder: true) + let weekly = RateWindow( + usedPercent: 50, + windowMinutes: 10080, + resetsAt: now.addingTimeInterval(2 * 24 * 3600), + resetDescription: nil) + + #expect(SessionEquivalentForecast.make( + sessionWindow: session, + weeklyWindow: weekly, + burnEstimate: SessionEquivalentBurnEstimate( + medianWeeklyPercentPerWindow: 10, + sampleCount: 3), + now: now, + workDays: nil) == nil) + } + + @Test + func `privacy redaction preserves session equivalent detail`() throws { + let detail = UsagePaceText.sessionEquivalentDetail(forecast: SessionEquivalentForecast( + estimatedWindowsToExhaustWeekly: 4, + windowsUntilReset: 9, + sampleCount: 7, + weeklyResetsAt: Self.weeklyReset, + weeklyUsedPercent: 60)) + let metric = UsageMenuCardView.Model.Metric( + id: "weekly", + title: "Weekly", + percent: 60, + percentStyle: .used, + resetText: nil, + detailText: nil, + detailLeftText: nil, + detailRightText: nil, + pacePercent: nil, + paceOnTop: false, + sessionEquivalentDetail: detail) + + let redacted = UsageMenuCardView.Model.redactedMetrics( + [metric], + provider: .claude, + hidePersonalInfo: true) + + let redactedDetail = try #require(redacted.first?.sessionEquivalentDetail) + #expect(redactedDetail.leftText == detail.leftText) + #expect(redactedDetail.rightText == detail.rightText) + } + + @Test + func `floors five hour windows at exact boundaries`() throws { + let now = Date(timeIntervalSince1970: 1_900_000_000) + let session = RateWindow( + usedPercent: 20, + windowMinutes: KimiProviderDescriptor.sessionWindowMinutes, + resetsAt: now.addingTimeInterval(3600), + resetDescription: nil) + let burn = SessionEquivalentBurnEstimate(medianWeeklyPercentPerWindow: 10, sampleCount: 3) + + let below = try #require(SessionEquivalentForecast.make( + sessionWindow: session, + weeklyWindow: RateWindow( + usedPercent: 60, + windowMinutes: 10080, + resetsAt: now.addingTimeInterval(10 * 5 * 3600 - 1), + resetDescription: nil), + burnEstimate: burn, + now: now, + workDays: nil)) + let exact = try #require(SessionEquivalentForecast.make( + sessionWindow: session, + weeklyWindow: RateWindow( + usedPercent: 60, + windowMinutes: 10080, + resetsAt: now.addingTimeInterval(10 * 5 * 3600), + resetDescription: nil), + burnEstimate: burn, + now: now, + workDays: nil)) + + #expect(below.windowsUntilReset == 9) + #expect(exact.windowsUntilReset == 10) + } + + @Test + func `work day setting excludes weekend capacity`() throws { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = try #require(TimeZone(secondsFromGMT: 0)) + let now = try #require(calendar.date(from: DateComponents( + calendar: calendar, + timeZone: calendar.timeZone, + year: 2026, + month: 7, + day: 17, + hour: 12))) + let reset = try #require(calendar.date(from: DateComponents( + calendar: calendar, + timeZone: calendar.timeZone, + year: 2026, + month: 7, + day: 20, + hour: 12))) + let session = RateWindow( + usedPercent: 20, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(3600), + resetDescription: nil) + let weekly = RateWindow( + usedPercent: 60, + windowMinutes: 10080, + resetsAt: reset, + resetDescription: nil) + let burn = SessionEquivalentBurnEstimate(medianWeeklyPercentPerWindow: 10, sampleCount: 3) + + let everyDay = try #require(SessionEquivalentForecast.make( + sessionWindow: session, + weeklyWindow: weekly, + burnEstimate: burn, + now: now, + workDays: nil, + calendar: calendar)) + let weekdays = try #require(SessionEquivalentForecast.make( + sessionWindow: session, + weeklyWindow: weekly, + burnEstimate: burn, + now: now, + workDays: 5, + calendar: calendar)) + + #expect(everyDay.windowsUntilReset == 14) + #expect(weekdays.windowsUntilReset == 4) + } + + @Test + func `formats session quota estimate and reset windows`() { + let early = SessionEquivalentForecast( + estimatedWindowsToExhaustWeekly: 4, + windowsUntilReset: 9, + sampleCount: 7, + weeklyResetsAt: Self.weeklyReset, + weeklyUsedPercent: 60) + let stranded = SessionEquivalentForecast( + estimatedWindowsToExhaustWeekly: 10, + windowsUntilReset: 9, + sampleCount: 7, + weeklyResetsAt: Self.weeklyReset, + weeklyUsedPercent: 20) + + let earlyText = UsagePaceText.sessionEquivalentDetail(forecast: early) + let strandedText = UsagePaceText.sessionEquivalentDetail(forecast: stranded) + + #expect(earlyText.leftText == "Est. 4 session quotas left") + #expect(earlyText.rightText == "9 windows until reset") + #expect(earlyText.accessibilityLabel == "Est. 4 session quotas left · 9 windows until reset") + #expect(strandedText.leftText == "Est. 10 session quotas left") + } + + @Test + func `formats quota estimates with up to one decimal and pluralizes units`() { + let equal = UsagePaceText.sessionEquivalentDetail(forecast: SessionEquivalentForecast( + estimatedWindowsToExhaustWeekly: 2, + windowsUntilReset: 2, + sampleCount: 7, + weeklyResetsAt: Self.weeklyReset, + weeklyUsedPercent: 80)) + let roundedSingular = UsagePaceText.sessionEquivalentDetail(forecast: SessionEquivalentForecast( + estimatedWindowsToExhaustWeekly: 1.04, + windowsUntilReset: 2, + sampleCount: 7, + weeklyResetsAt: Self.weeklyReset, + weeklyUsedPercent: 90)) + let close = UsagePaceText.sessionEquivalentDetail(forecast: SessionEquivalentForecast( + estimatedWindowsToExhaustWeekly: 8.6, + windowsUntilReset: 9, + sampleCount: 7, + weeklyResetsAt: Self.weeklyReset, + weeklyUsedPercent: 14)) + + #expect(equal.leftText == "Est. 2 session quotas left") + #expect(roundedSingular.leftText == "Est. 1 session quota left") + #expect(roundedSingular.rightText == "2 windows until reset") + #expect(close.leftText == "Est. 8.6 session quotas left") + #expect(close.rightText == "9 windows until reset") + } + + @Test + func `formats sub-one quota and singular reset window`() { + let detail = UsagePaceText.sessionEquivalentDetail(forecast: SessionEquivalentForecast( + estimatedWindowsToExhaustWeekly: 0.3, + windowsUntilReset: 1, + availableWindowsUntilReset: 1.8, + sampleCount: 7, + weeklyResetsAt: Self.weeklyReset, + weeklyUsedPercent: 95)) + + #expect(detail.leftText == "Est. 0.3 session quota left") + #expect(detail.rightText == "1 window until reset") + } + + @Test + func `reset tolerance compares actual distance across bucket boundaries`() throws { + let fixture = Self.historyFixture(burns: [4, 6, 8]) + let session = PlanUtilizationSeriesHistory( + name: .session, + windowMinutes: 300, + entries: fixture.histories[0].entries.enumerated().map { index, entry in + planEntry( + at: entry.capturedAt, + usedPercent: entry.usedPercent, + resetsAt: entry.resetsAt?.addingTimeInterval(index.isMultiple(of: 2) ? 59 : 61)) + }) + let weekly = PlanUtilizationSeriesHistory( + name: .weekly, + windowMinutes: 10080, + entries: fixture.histories[1].entries.enumerated().map { index, entry in + planEntry( + at: entry.capturedAt, + usedPercent: entry.usedPercent, + resetsAt: entry.resetsAt?.addingTimeInterval(index.isMultiple(of: 2) ? 59 : 61)) + }) + + let estimate = try #require(SessionEquivalentBurnEstimator.estimate( + histories: [session, weekly], + currentSessionResetsAt: fixture.currentSessionReset, + now: fixture.currentSessionReset.addingTimeInterval(-3600))) + + #expect(estimate.sampleCount == 3) + #expect(estimate.medianWeeklyPercentPerWindow == 6) + } + + @Test + func `rejects hostile dates percentages and unsorted history`() throws { + let now = Date(timeIntervalSince1970: 1_900_000_000) + let session = RateWindow( + usedPercent: 20, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(3600), + resetDescription: nil) + let burn = SessionEquivalentBurnEstimate(medianWeeklyPercentPerWindow: 10, sampleCount: 3) + let extremeDate = Date(timeIntervalSinceReferenceDate: 1e30) + + #expect(SessionEquivalentForecast.make( + sessionWindow: RateWindow( + usedPercent: 20, + windowMinutes: 300, + resetsAt: extremeDate, + resetDescription: nil), + weeklyWindow: RateWindow( + usedPercent: 50, + windowMinutes: 10080, + resetsAt: now.addingTimeInterval(24 * 3600), + resetDescription: nil), + burnEstimate: burn, + now: now, + workDays: nil) == nil) + #expect(SessionEquivalentForecast.make( + sessionWindow: session, + weeklyWindow: RateWindow( + usedPercent: -1, + windowMinutes: 10080, + resetsAt: now.addingTimeInterval(24 * 3600), + resetDescription: nil), + burnEstimate: burn, + now: now, + workDays: nil) == nil) + + let fixture = Self.historyFixture(burns: [4, 6, 8]) + let encodedSession = try JSONEncoder().encode(fixture.histories[0]) + var sessionJSON = try #require(JSONSerialization.jsonObject(with: encodedSession) as? [String: Any]) + let entriesJSON = try #require(sessionJSON["entries"] as? [[String: Any]]) + sessionJSON["entries"] = Array(entriesJSON.reversed()) + let shuffledData = try JSONSerialization.data(withJSONObject: sessionJSON) + let shuffledSession = try JSONDecoder().decode(PlanUtilizationSeriesHistory.self, from: shuffledData) + #expect((shuffledSession.entries.first?.capturedAt ?? .distantPast) + > (shuffledSession.entries.last?.capturedAt ?? .distantFuture)) + #expect(SessionEquivalentBurnEstimator.estimate( + histories: [shuffledSession, fixture.histories[1]], + currentSessionResetsAt: fixture.currentSessionReset, + now: fixture.currentSessionReset.addingTimeInterval(-3600)) == nil) + + let huge = UsagePaceText.sessionEquivalentDetail(forecast: SessionEquivalentForecast( + estimatedWindowsToExhaustWeekly: .greatestFiniteMagnitude, + windowsUntilReset: 2, + sampleCount: 7, + weeklyResetsAt: Self.weeklyReset, + weeklyUsedPercent: 1)) + #expect(huge.leftText == "Est. 1,000,000 session quotas left") + } + + @Test + func `does not replace unusable recent windows with older samples`() throws { + let fixture = Self.historyFixture(burns: [20, 2, 4, 6, 8, 10, 12, 14]) + let lastReset = fixture.currentSessionReset.addingTimeInterval(-5 * 3600) + let lastStart = lastReset.addingTimeInterval(-5 * 3600) + let weekly = fixture.histories[1] + let missingLatestBoundaries = PlanUtilizationSeriesHistory( + name: weekly.name, + windowMinutes: weekly.windowMinutes, + entries: weekly.entries.filter { $0.capturedAt != lastStart && $0.capturedAt != lastReset }) + + let estimate = try #require(SessionEquivalentBurnEstimator.estimate( + histories: [fixture.histories[0], missingLatestBoundaries], + currentSessionResetsAt: fixture.currentSessionReset, + now: fixture.currentSessionReset.addingTimeInterval(-3600))) + + #expect(estimate.sampleCount == 5) + #expect(estimate.medianWeeklyPercentPerWindow == 6) + } + + @Test + func `does not count a session whose reset is still in the future`() { + let fixture = Self.historyFixture(burns: [5, 5]) + let now = fixture.currentSessionReset.addingTimeInterval(-3600) + let futureReset = now.addingTimeInterval(30 * 60) + let futureStart = futureReset.addingTimeInterval(-5 * 3600) + let session = fixture.histories[0] + let weekly = fixture.histories[1] + let sessionEntries = (session.entries + [ + planEntry(at: futureStart.addingTimeInterval(3600), usedPercent: 80, resetsAt: futureReset), + ]).sorted { $0.capturedAt < $1.capturedAt } + let weeklyEntries = (weekly.entries + [ + planEntry(at: futureStart, usedPercent: 10, resetsAt: weekly.entries[0].resetsAt), + planEntry(at: futureReset, usedPercent: 15, resetsAt: weekly.entries[0].resetsAt), + ]).sorted { $0.capturedAt < $1.capturedAt } + + #expect(SessionEquivalentBurnEstimator.estimate( + histories: [ + planSeries(name: .session, windowMinutes: 300, entries: sessionEntries), + planSeries(name: .weekly, windowMinutes: 10080, entries: weeklyEntries), + ], + currentSessionResetsAt: nil, + now: now) == nil) + } + + @Test + func `provider metric shows estimate only on its matching weekly window`() throws { + let now = Date(timeIntervalSince1970: 1_900_000_000) + let weeklyReset = now.addingTimeInterval((7 * 60 + 37) * 60) + let snapshot = UsageSnapshot( + primary: RateWindow( + usedPercent: 20, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(3600), + resetDescription: nil), + secondary: RateWindow( + usedPercent: 95, + windowMinutes: 10080, + resetsAt: weeklyReset, + resetDescription: nil), + updatedAt: now) + let metadata = try #require(ProviderDefaults.metadata[.claude]) + let forecast = SessionEquivalentForecast( + estimatedWindowsToExhaustWeekly: 0.3, + windowsUntilReset: 1, + sampleCount: 7, + weeklyResetsAt: weeklyReset, + weeklyUsedPercent: 95) + + let model = UsageMenuCardView.Model.make(.init( + provider: .claude, + metadata: metadata, + snapshot: snapshot, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + weeklyPace: UsagePace( + stage: .onTrack, + deltaPercent: -0.4, + expectedUsedPercent: 95.4, + actualUsedPercent: 95, + etaSeconds: nil, + willLastToReset: true), + sessionEquivalentForecast: forecast, + now: now)) + + let sessionMetric = try #require(model.metrics.first { $0.id == "primary" }) + let weeklyMetric = try #require(model.metrics.first { $0.id == "secondary" }) + #expect(sessionMetric.sessionEquivalentDetail == nil) + #expect(weeklyMetric.percentLabel == "5% left") + #expect(weeklyMetric.detailLeftText == "On pace") + #expect(weeklyMetric.detailRightText == "Lasts until reset") + #expect(weeklyMetric.sessionEquivalentDetail?.leftText == "Est. 0.3 session quota left") + #expect(weeklyMetric.sessionEquivalentDetail?.rightText == "1 window until reset") + } + + @MainActor + @Test + func `Claude scoped weekly window cannot use all model history`() throws { + let store = UsageStorePlanUtilizationTests.makeStore() + let fixture = Self.historyFixture(burns: [4, 8, 6]) + store.planUtilizationHistory[.claude] = PlanUtilizationHistoryBuckets(unscoped: fixture.histories) + let now = fixture.currentSessionReset.addingTimeInterval(-3600) + let session = RateWindow( + usedPercent: 20, + windowMinutes: 300, + resetsAt: fixture.currentSessionReset, + resetDescription: nil) + let scopedOnly = UsageSnapshot( + primary: session, + secondary: nil, + extraRateWindows: [ + NamedRateWindow( + id: "claude-weekly-scoped-fable", + title: "Fable weekly", + window: RateWindow( + usedPercent: 60, + windowMinutes: 10080, + resetsAt: now.addingTimeInterval(2 * 24 * 3600), + resetDescription: nil)), + ], + updatedAt: now) + + #expect(store.sessionEquivalentWindows(provider: .claude, snapshot: scopedOnly) == nil) + + let allModelsWeekly = RateWindow( + usedPercent: 40, + windowMinutes: 10080, + resetsAt: now.addingTimeInterval(2 * 24 * 3600), + resetDescription: nil) + let complete = UsageSnapshot( + primary: session, + secondary: allModelsWeekly, + extraRateWindows: scopedOnly.extraRateWindows, + updatedAt: now) + let resolved = try #require(store.sessionEquivalentWindows(provider: .claude, snapshot: complete)) + #expect(resolved.weekly == allModelsWeekly) + #expect(resolved.weeklyWindowID == nil) + } + + @Test + func `named provider metric requires the selected weekly window identity`() { + let weekly = RateWindow( + usedPercent: 60, + windowMinutes: 10080, + resetsAt: Self.weeklyReset, + resetDescription: nil) + let forecast = SessionEquivalentForecast( + estimatedWindowsToExhaustWeekly: 4, + windowsUntilReset: 9, + sampleCount: 7, + weeklyResetsAt: Self.weeklyReset, + weeklyUsedPercent: 60, + weeklyWindowID: "antigravity-quota-summary-gemini-weekly") + + #expect(forecast.applies( + to: weekly, + windowID: "antigravity-quota-summary-gemini-weekly")) + #expect(!forecast.applies( + to: weekly, + windowID: "antigravity-quota-summary-3p-weekly")) + } + + @MainActor + @Test + func `usage store memoizes the history scan until revision changes`() { + let store = UsageStorePlanUtilizationTests.makeStore() + let fixture = Self.historyFixture(burns: [4, 8, 6, 10]) + store.planUtilizationHistory[.claude] = PlanUtilizationHistoryBuckets(unscoped: fixture.histories) + store.planUtilizationHistoryRevision = 1 + let now = fixture.currentSessionReset.addingTimeInterval(-3600) + let session = RateWindow( + usedPercent: 20, + windowMinutes: 300, + resetsAt: fixture.currentSessionReset, + resetDescription: nil) + let weekly = RateWindow( + usedPercent: 60, + windowMinutes: 10080, + resetsAt: now.addingTimeInterval(2 * 24 * 3600), + resetDescription: nil) + + #expect(store.sessionEquivalentForecast( + provider: .claude, + sessionWindow: session, + weeklyWindow: weekly, + now: now) != nil) + #expect(store.sessionEquivalentForecast( + provider: .claude, + sessionWindow: session, + weeklyWindow: weekly, + now: now) != nil) + #expect(store._sessionEquivalentHistoryScanCountForTesting == 1) + + store.planUtilizationHistoryRevision = 2 + #expect(store.sessionEquivalentForecast( + provider: .claude, + sessionWindow: session, + weeklyWindow: weekly, + now: now) != nil) + #expect(store._sessionEquivalentHistoryScanCountForTesting == 2) + } + + @MainActor + @Test + func `antigravity records session and weekly history without generic history opt in`() async { + let store = UsageStorePlanUtilizationTests.makeStore() + let now = Date(timeIntervalSince1970: 1_900_000_000) + let snapshot = UsageSnapshot( + primary: nil, + secondary: nil, + extraRateWindows: [ + NamedRateWindow( + id: "antigravity-quota-summary-gemini-session", + title: "Gemini session", + window: RateWindow( + usedPercent: 20, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(3600), + resetDescription: nil)), + NamedRateWindow( + id: "antigravity-quota-summary-gemini-weekly", + title: "Gemini weekly", + window: RateWindow( + usedPercent: 40, + windowMinutes: 10080, + resetsAt: now.addingTimeInterval(3 * 24 * 3600), + resetDescription: nil)), + ], + updatedAt: now) + + #expect(store.settings.historicalTrackingEnabled == false) + await store.recordPlanUtilizationHistorySample(provider: .antigravity, snapshot: snapshot, now: now) + + let histories = store.planUtilizationHistory(for: .antigravity) + #expect(findSeries(histories, name: .session, windowMinutes: 300)?.entries.last?.usedPercent == 20) + #expect(findSeries(histories, name: .weekly, windowMinutes: 10080)?.entries.last?.usedPercent == 40) + } + + @MainActor + @Test + func `antigravity forecast keeps a stable Gemini quota family`() { + let store = UsageStorePlanUtilizationTests.makeStore() + let now = Date(timeIntervalSince1970: 1_900_000_000) + let before = Self.antigravitySnapshot( + now: now, + geminiSession: 20, + geminiWeekly: 60, + thirdPartySession: 30, + thirdPartyWeekly: 50) + let after = Self.antigravitySnapshot( + now: now.addingTimeInterval(3600), + geminiSession: 25, + geminiWeekly: 61, + thirdPartySession: 35, + thirdPartyWeekly: 70) + + #expect(store.sessionEquivalentWindows(provider: .antigravity, snapshot: before)?.weekly.usedPercent == 60) + #expect(store.sessionEquivalentWindows(provider: .antigravity, snapshot: after)?.weekly.usedPercent == 61) + #expect(store.sessionEquivalentWindows(provider: .antigravity, snapshot: after)?.weeklyWindowID + == "antigravity-quota-summary-gemini-weekly") + #expect(store.sessionEquivalentWindows( + provider: .antigravity, + snapshot: Self.antigravitySnapshot( + now: now, + geminiSession: 20, + geminiWeekly: 60, + thirdPartySession: 30, + thirdPartyWeekly: 50, + geminiFamily: "gemini-pro")) == nil) + } +} + +extension SessionEquivalentForecastTests { + @MainActor + @Test + func `generic named weekly window preserves its rendering identity`() throws { + let store = UsageStorePlanUtilizationTests.makeStore() + let now = Date(timeIntervalSince1970: 1_900_000_000) + let snapshot = UsageSnapshot( + primary: nil, + secondary: nil, + extraRateWindows: [ + NamedRateWindow( + id: "zai-named-session", + title: "Session", + window: RateWindow( + usedPercent: 20, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(3600), + resetDescription: nil)), + NamedRateWindow( + id: "zai-named-weekly", + title: "Weekly", + window: RateWindow( + usedPercent: 40, + windowMinutes: 10080, + resetsAt: now.addingTimeInterval(3 * 24 * 3600), + resetDescription: nil)), + ], + updatedAt: now) + + let windows = try #require(store.sessionEquivalentWindows(provider: .zai, snapshot: snapshot)) + #expect(windows.weeklyWindowID == "zai-named-weekly") + } + + @MainActor + @Test + func `Kimi resolves its inverted primary and secondary windows by duration`() throws { + let store = UsageStorePlanUtilizationTests.makeStore() + let now = Date(timeIntervalSince1970: 1_900_000_000) + let weekly = RateWindow( + usedPercent: 40, + windowMinutes: KimiProviderDescriptor.weeklyWindowMinutes, + resetsAt: now.addingTimeInterval(3 * 24 * 60 * 60), + resetDescription: nil) + let session = RateWindow( + usedPercent: 20, + windowMinutes: KimiProviderDescriptor.sessionWindowMinutes, + resetsAt: now.addingTimeInterval(4 * 60 * 60), + resetDescription: nil) + let snapshot = UsageSnapshot( + primary: weekly, + secondary: session, + tertiary: nil, + extraRateWindows: [ + NamedRateWindow( + id: "kimi-code-7d", + title: "Code 7-day", + window: RateWindow( + usedPercent: 15, + windowMinutes: KimiProviderDescriptor.weeklyWindowMinutes, + resetsAt: now.addingTimeInterval(2 * 24 * 60 * 60), + resetDescription: nil)), + ], + updatedAt: now) + + let windows = try #require(store.sessionEquivalentWindows(provider: .kimi, snapshot: snapshot)) + #expect(windows.session.windowMinutes == KimiProviderDescriptor.sessionWindowMinutes) + #expect(windows.weekly.windowMinutes == KimiProviderDescriptor.weeklyWindowMinutes) + #expect(windows.weekly.usedPercent == 40) + + let forecast = try #require(SessionEquivalentForecast.make( + sessionWindow: windows.session, + weeklyWindow: windows.weekly, + burnEstimate: SessionEquivalentBurnEstimate( + medianWeeklyPercentPerWindow: 10, + sampleCount: 3), + now: now, + workDays: nil)) + #expect(forecast.estimatedWindowsToExhaustWeekly == 6) + #expect(forecast.windowsUntilReset == 14) + let detail = UsagePaceText.sessionEquivalentDetail(forecast: forecast) + #expect(detail.leftText == "Est. 6 session quotas left") + #expect(detail.rightText == "14 windows until reset") + } + + @MainActor + @Test + func `generic named windows require the same quota family`() async throws { + let store = UsageStorePlanUtilizationTests.makeStore() + store.settings.historicalTrackingEnabled = true + let now = Date(timeIntervalSince1970: 1_900_000_000) + let snapshot = UsageSnapshot( + primary: nil, + secondary: nil, + extraRateWindows: [ + NamedRateWindow( + id: "family-a-session", + title: "A session", + window: RateWindow( + usedPercent: 20, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(3600), + resetDescription: nil)), + NamedRateWindow( + id: "family-b-weekly", + title: "B weekly", + window: RateWindow( + usedPercent: 40, + windowMinutes: 10080, + resetsAt: now.addingTimeInterval(3 * 24 * 3600), + resetDescription: nil)), + ], + updatedAt: now) + + #expect(store.sessionEquivalentWindows(provider: .zai, snapshot: snapshot) == nil) + await store.recordPlanUtilizationHistorySample(provider: .zai, snapshot: snapshot, now: now) + let sessionWindow = try #require(snapshot.extraRateWindows?.first) + let changedWeekly = UsageSnapshot( + primary: nil, + secondary: nil, + extraRateWindows: [ + sessionWindow, + NamedRateWindow( + id: "family-c-weekly", + title: "C weekly", + window: RateWindow( + usedPercent: 50, + windowMinutes: 10080, + resetsAt: now.addingTimeInterval(3 * 24 * 3600), + resetDescription: nil)), + ], + updatedAt: now.addingTimeInterval(3600)) + await store.recordPlanUtilizationHistorySample( + provider: .zai, + snapshot: changedWeekly, + now: changedWeekly.updatedAt) + + let histories = store.planUtilizationHistory(for: .zai) + #expect(findSeries(histories, name: .session, windowMinutes: 300) == nil) + #expect(findSeries(histories, name: .weekly, windowMinutes: 10080)?.entries.map(\.usedPercent) == [40]) + } + + @MainActor + @Test + func `first complete generic pair clears unidentified session history`() async { + let store = UsageStorePlanUtilizationTests.makeStore() + store.settings.historicalTrackingEnabled = true + let now = Date(timeIntervalSince1970: 1_900_000_000) + let incomplete = UsageSnapshot( + primary: RateWindow( + usedPercent: 20, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(3600), + resetDescription: nil), + secondary: nil, + updatedAt: now) + await store.recordPlanUtilizationHistorySample(provider: .zai, snapshot: incomplete, now: now) + + let complete = UsageSnapshot( + primary: nil, + secondary: RateWindow( + usedPercent: 40, + windowMinutes: 10080, + resetsAt: now.addingTimeInterval(3 * 24 * 3600), + resetDescription: nil), + tertiary: RateWindow( + usedPercent: 30, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(2 * 3600), + resetDescription: nil), + updatedAt: now.addingTimeInterval(3600)) + await store.recordPlanUtilizationHistorySample(provider: .zai, snapshot: complete, now: complete.updatedAt) + + let histories = store.planUtilizationHistory(for: .zai) + #expect(findSeries(histories, name: .session, windowMinutes: 300)?.entries.map(\.usedPercent) == [30]) + #expect(findSeries(histories, name: .weekly, windowMinutes: 10080)?.entries.map(\.usedPercent) == [40]) + } + + @Test + func `generic pair identity parser rejects overflowing component lengths`() { + #expect(UsageStore.sessionEquivalentPairComponents(from: "\(Int.max)#x1#y") == nil) + } + + @MainActor + @Test + func `generic identity migration preserves existing weekly history`() async { + let store = UsageStorePlanUtilizationTests.makeStore() + store.settings.historicalTrackingEnabled = true + let now = Date(timeIntervalSince1970: 1_900_000_000) + store.planUtilizationHistory[.zai] = PlanUtilizationHistoryBuckets(unscoped: [ + planSeries( + name: .weekly, + windowMinutes: 10080, + entries: [ + planEntry(at: now.addingTimeInterval(-7200), usedPercent: 30), + planEntry(at: now.addingTimeInterval(-3600), usedPercent: 35), + ]), + ]) + let snapshot = UsageSnapshot( + primary: RateWindow( + usedPercent: 20, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(3600), + resetDescription: nil), + secondary: RateWindow( + usedPercent: 40, + windowMinutes: 10080, + resetsAt: now.addingTimeInterval(3 * 24 * 3600), + resetDescription: nil), + updatedAt: now) + + await store.recordPlanUtilizationHistorySample(provider: .zai, snapshot: snapshot, now: now) + + let histories = store.planUtilizationHistory(for: .zai) + #expect(findSeries(histories, name: .weekly, windowMinutes: 10080)?.entries.map(\.usedPercent) + == [30, 35, 40]) + #expect(findSeries(histories, name: .session, windowMinutes: 300)?.entries.map(\.usedPercent) == [20]) + #expect(store.sessionEquivalentHistoryIdentityMatches( + provider: .zai, + accountKey: nil, + historyIdentity: store.sessionEquivalentWindows(provider: .zai, snapshot: snapshot)?.historyIdentity)) + } + + @MainActor + @Test + func `generic history preserves session when weekly window identity changes`() async { + let store = UsageStorePlanUtilizationTests.makeStore() + store.settings.historicalTrackingEnabled = true + let now = Date(timeIntervalSince1970: 1_900_000_000) + + func snapshot(weeklySlot: Int, sessionUsed: Double, weeklyUsed: Double, at date: Date) -> UsageSnapshot { + let weekly = RateWindow( + usedPercent: weeklyUsed, + windowMinutes: 10080, + resetsAt: date.addingTimeInterval(3 * 24 * 3600), + resetDescription: nil) + return UsageSnapshot( + primary: RateWindow( + usedPercent: sessionUsed, + windowMinutes: 300, + resetsAt: date.addingTimeInterval(3600), + resetDescription: nil), + secondary: weeklySlot == 2 ? weekly : nil, + tertiary: weeklySlot == 3 ? weekly : nil, + updatedAt: date) + } + + let first = snapshot(weeklySlot: 2, sessionUsed: 20, weeklyUsed: 40, at: now) + let second = snapshot( + weeklySlot: 3, + sessionUsed: 30, + weeklyUsed: 50, + at: now.addingTimeInterval(3600)) + #expect(!store.sessionEquivalentHistoryIdentityMatches( + provider: .zai, + accountKey: nil, + historyIdentity: store.sessionEquivalentWindows(provider: .zai, snapshot: first)?.historyIdentity)) + + await store.recordPlanUtilizationHistorySample(provider: .zai, snapshot: first, now: first.updatedAt) + #expect(store.sessionEquivalentHistoryIdentityMatches( + provider: .zai, + accountKey: nil, + historyIdentity: store.sessionEquivalentWindows(provider: .zai, snapshot: first)?.historyIdentity)) + await store.recordPlanUtilizationHistorySample(provider: .zai, snapshot: second, now: second.updatedAt) + + let histories = store.planUtilizationHistory(for: .zai) + #expect(findSeries(histories, name: .session, windowMinutes: 300)?.entries.map(\.usedPercent) == [20, 30]) + #expect(findSeries(histories, name: .weekly, windowMinutes: 10080)?.entries.map(\.usedPercent) == [50]) + #expect(store.sessionEquivalentHistoryIdentityMatches( + provider: .zai, + accountKey: nil, + historyIdentity: store.sessionEquivalentWindows(provider: .zai, snapshot: second)?.historyIdentity)) + } + + @MainActor + @Test + func `generic history migration rejects a different legacy pair identity`() async { + let store = UsageStorePlanUtilizationTests.makeStore() + store.settings.historicalTrackingEnabled = true + let now = Date(timeIntervalSince1970: 1_900_000_000) + store.planUtilizationHistory[.zai] = PlanUtilizationHistoryBuckets(unscoped: [ + planSeries( + name: .session, + windowMinutes: 300, + entries: [planEntry(at: now.addingTimeInterval(-3600), usedPercent: 10)]), + planSeries( + name: .weekly, + windowMinutes: 10080, + entries: [planEntry(at: now.addingTimeInterval(-3600), usedPercent: 30)]), + ]) + store.settings.userDefaults.set( + ["zai|\(UsageStore.planUtilizationUnscopedPreferredKey)": "legacy-pair"], + forKey: UsageStore.legacySessionEquivalentHistoryIdentityDefaultsKey) + let snapshot = UsageSnapshot( + primary: RateWindow( + usedPercent: 20, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(3600), + resetDescription: nil), + secondary: RateWindow( + usedPercent: 40, + windowMinutes: 10080, + resetsAt: now.addingTimeInterval(3 * 24 * 3600), + resetDescription: nil), + updatedAt: now) + + await store.recordPlanUtilizationHistorySample(provider: .zai, snapshot: snapshot, now: now) + + let histories = store.planUtilizationHistory(for: .zai) + #expect(findSeries(histories, name: .session, windowMinutes: 300)?.entries.map(\.usedPercent) == [20]) + #expect(findSeries(histories, name: .weekly, windowMinutes: 10080)?.entries.map(\.usedPercent) == [40]) + } + + @MainActor + @Test + func `generic legacy identity protects history during an incomplete first refresh`() async throws { + let store = UsageStorePlanUtilizationTests.makeStore() + store.settings.historicalTrackingEnabled = true + let now = Date(timeIntervalSince1970: 1_900_000_000) + let complete = UsageSnapshot( + primary: RateWindow( + usedPercent: 20, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(3600), + resetDescription: nil), + secondary: RateWindow( + usedPercent: 40, + windowMinutes: 10080, + resetsAt: now.addingTimeInterval(3 * 24 * 3600), + resetDescription: nil), + updatedAt: now) + let identity = try #require(store.sessionEquivalentWindows( + provider: .zai, + snapshot: complete)?.historyIdentity) + store.planUtilizationHistory[.zai] = PlanUtilizationHistoryBuckets(unscoped: [ + planSeries( + name: .session, + windowMinutes: 300, + entries: [planEntry(at: now.addingTimeInterval(-3600), usedPercent: 10)]), + planSeries( + name: .weekly, + windowMinutes: 10080, + entries: [planEntry(at: now.addingTimeInterval(-3600), usedPercent: 30)]), + ]) + store.settings.userDefaults.set( + ["zai|\(UsageStore.planUtilizationUnscopedPreferredKey)": identity], + forKey: UsageStore.legacySessionEquivalentHistoryIdentityDefaultsKey) + #expect(store.sessionEquivalentHistoryIdentityMatches( + provider: .zai, + accountKey: nil, + historyIdentity: identity)) + let incomplete = UsageSnapshot( + primary: RateWindow( + usedPercent: 50, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(2 * 3600), + resetDescription: nil), + secondary: nil, + updatedAt: now.addingTimeInterval(3600)) + + await store.recordPlanUtilizationHistorySample( + provider: .zai, + snapshot: incomplete, + now: incomplete.updatedAt) + + let histories = store.planUtilizationHistory(for: .zai) + #expect(findSeries(histories, name: .session, windowMinutes: 300)?.entries.map(\.usedPercent) == [10]) + #expect(findSeries(histories, name: .weekly, windowMinutes: 10080)?.entries.map(\.usedPercent) == [30]) + #expect(store.planUtilizationHistory[.zai]? + .sessionEquivalentWindowPairIdentity(for: nil) == identity) + } + + @MainActor + @Test + func `generic history preserves weekly when session window identity changes`() async throws { + let store = UsageStorePlanUtilizationTests.makeStore() + store.settings.historicalTrackingEnabled = true + let now = Date(timeIntervalSince1970: 1_900_000_000) + + func snapshot(sessionSlot: Int, sessionUsed: Double, at date: Date) -> UsageSnapshot { + let session = RateWindow( + usedPercent: sessionUsed, + windowMinutes: 300, + resetsAt: date.addingTimeInterval(3600), + resetDescription: nil) + return UsageSnapshot( + primary: sessionSlot == 1 ? session : nil, + secondary: RateWindow( + usedPercent: 40, + windowMinutes: 10080, + resetsAt: date.addingTimeInterval(3 * 24 * 3600), + resetDescription: nil), + tertiary: sessionSlot == 3 ? session : nil, + updatedAt: date) + } + + let first = snapshot(sessionSlot: 1, sessionUsed: 20, at: now) + let second = snapshot( + sessionSlot: 3, + sessionUsed: 30, + at: now.addingTimeInterval(3600)) + await store.recordPlanUtilizationHistorySample(provider: .zai, snapshot: first, now: first.updatedAt) + await store.recordPlanUtilizationHistorySample(provider: .zai, snapshot: second, now: second.updatedAt) + + let histories = store.planUtilizationHistory(for: .zai) + #expect(findSeries(histories, name: .session, windowMinutes: 300)?.entries.map(\.usedPercent) == [30]) + #expect(findSeries(histories, name: .weekly, windowMinutes: 10080)?.entries.map(\.usedPercent) == [40, 40]) + let identity = try #require(store.sessionEquivalentWindows(provider: .zai, snapshot: second)?.historyIdentity) + #expect(store.sessionEquivalentHistoryIdentityMatches( + provider: .zai, + accountKey: nil, + historyIdentity: identity)) + } + + @MainActor + @Test + func `generic forecast rejects ambiguous session lanes while weekly history continues`() async throws { + let store = UsageStorePlanUtilizationTests.makeStore() + store.settings.historicalTrackingEnabled = true + let now = Date(timeIntervalSince1970: 1_900_000_000) + let session = RateWindow( + usedPercent: 20, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(3600), + resetDescription: nil) + let snapshot = UsageSnapshot( + primary: session, + secondary: RateWindow( + usedPercent: 40, + windowMinutes: 10080, + resetsAt: now.addingTimeInterval(3 * 24 * 3600), + resetDescription: nil), + tertiary: session, + updatedAt: now) + + #expect(store.sessionEquivalentWindows(provider: .zai, snapshot: snapshot) == nil) + + func exactSnapshot(usedPercent: Double, at date: Date) -> UsageSnapshot { + UsageSnapshot( + primary: RateWindow( + usedPercent: usedPercent, + windowMinutes: 300, + resetsAt: date.addingTimeInterval(3600), + resetDescription: nil), + secondary: snapshot.secondary, + updatedAt: date) + } + + let first = exactSnapshot(usedPercent: 10, at: now.addingTimeInterval(-3600)) + await store.recordPlanUtilizationHistorySample(provider: .zai, snapshot: first, now: first.updatedAt) + let firstIdentity = try #require(store.sessionEquivalentWindows( + provider: .zai, + snapshot: first)?.historyIdentity) + #expect(store.sessionEquivalentHistoryIdentityMatches( + provider: .zai, + accountKey: nil, + historyIdentity: firstIdentity)) + store.settings.userDefaults.set( + ["zai|\(UsageStore.planUtilizationUnscopedPreferredKey)": firstIdentity], + forKey: UsageStore.legacySessionEquivalentHistoryIdentityDefaultsKey) + + await store.recordPlanUtilizationHistorySample(provider: .zai, snapshot: snapshot, now: now) + #expect(store.sessionEquivalentHistoryIdentityMatches( + provider: .zai, + accountKey: nil, + historyIdentity: firstIdentity)) + let ambiguousHistories = store.planUtilizationHistory(for: .zai) + #expect(findSeries(ambiguousHistories, name: .session, windowMinutes: 300)?.entries.map(\.usedPercent) == [10]) + #expect(findSeries(ambiguousHistories, name: .weekly, windowMinutes: 10080)?.entries.map(\.usedPercent) + == [40, 40]) + + let incomplete = UsageSnapshot( + primary: RateWindow( + usedPercent: 25, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(2 * 3600), + resetDescription: nil), + secondary: nil, + updatedAt: now.addingTimeInterval(1800)) + await store.recordPlanUtilizationHistorySample( + provider: .zai, + snapshot: incomplete, + now: incomplete.updatedAt) + #expect(store.sessionEquivalentHistoryIdentityMatches( + provider: .zai, + accountKey: nil, + historyIdentity: firstIdentity)) + + let restored = exactSnapshot(usedPercent: 30, at: now.addingTimeInterval(3600)) + await store.recordPlanUtilizationHistorySample(provider: .zai, snapshot: restored, now: restored.updatedAt) + let histories = store.planUtilizationHistory(for: .zai) + #expect(findSeries(histories, name: .session, windowMinutes: 300)?.entries.map(\.usedPercent) == [10, 30]) + #expect(findSeries(histories, name: .weekly, windowMinutes: 10080)?.entries.map(\.usedPercent) + == [40, 40, 40]) + } + + @MainActor + @Test + func `generic weekly ambiguity preserves both sides of prior pair history`() async { + let store = UsageStorePlanUtilizationTests.makeStore() + store.settings.historicalTrackingEnabled = true + let now = Date(timeIntervalSince1970: 1_900_000_000) + let session = RateWindow( + usedPercent: 20, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(3600), + resetDescription: nil) + + func snapshot(weeklyValues: [Double], at date: Date) -> UsageSnapshot { + UsageSnapshot( + primary: session, + secondary: weeklyValues.first.map { + RateWindow( + usedPercent: $0, + windowMinutes: 10080, + resetsAt: date.addingTimeInterval(3 * 24 * 3600), + resetDescription: nil) + }, + tertiary: weeklyValues.dropFirst().first.map { + RateWindow( + usedPercent: $0, + windowMinutes: 10080, + resetsAt: date.addingTimeInterval(3 * 24 * 3600), + resetDescription: nil) + }, + updatedAt: date) + } + + let exact = snapshot(weeklyValues: [40], at: now) + await store.recordPlanUtilizationHistorySample(provider: .zai, snapshot: exact, now: exact.updatedAt) + + let ambiguous = snapshot(weeklyValues: [45, 60], at: now.addingTimeInterval(3600)) + await store.recordPlanUtilizationHistorySample( + provider: .zai, + snapshot: ambiguous, + now: ambiguous.updatedAt) + + let histories = store.planUtilizationHistory(for: .zai) + #expect(findSeries(histories, name: .session, windowMinutes: 300)?.entries.map(\.usedPercent) == [20]) + #expect(findSeries(histories, name: .weekly, windowMinutes: 10080)?.entries.map(\.usedPercent) == [40]) + } + + @MainActor + @Test + func `generic account adoption moves pair identity with unscoped history`() async throws { + let store = UsageStorePlanUtilizationTests.makeStore() + store.settings.historicalTrackingEnabled = true + let now = Date(timeIntervalSince1970: 1_900_000_000) + let snapshot = UsageSnapshot( + primary: RateWindow( + usedPercent: 20, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(3600), + resetDescription: nil), + secondary: RateWindow( + usedPercent: 40, + windowMinutes: 10080, + resetsAt: now.addingTimeInterval(3 * 24 * 3600), + resetDescription: nil), + updatedAt: now) + let identity = try #require(store.sessionEquivalentWindows( + provider: .zai, + snapshot: snapshot)?.historyIdentity) + var buckets = PlanUtilizationHistoryBuckets(unscoped: [ + planSeries( + name: .session, + windowMinutes: 300, + entries: [planEntry(at: now.addingTimeInterval(-3600), usedPercent: 10)]), + planSeries( + name: .weekly, + windowMinutes: 10080, + entries: [planEntry(at: now.addingTimeInterval(-3600), usedPercent: 30)]), + ]) + buckets.setSessionEquivalentWindowPairIdentity(identity, for: nil) + store.planUtilizationHistory[.zai] = buckets + let account = ProviderTokenAccount( + id: UUID(), + label: "Zai test", + token: "fixture", + addedAt: 0, + lastUsed: nil) + let accountKey = try #require(UsageStore._planUtilizationTokenAccountKeyForTesting( + provider: .zai, + account: account)) + + await store.recordPlanUtilizationHistorySample( + provider: .zai, + snapshot: snapshot, + account: account, + now: now) + + let migrated = try #require(store.planUtilizationHistory[.zai]) + #expect(migrated.unscoped.isEmpty) + #expect(migrated.sessionEquivalentWindowPairIdentity(for: nil) == nil) + #expect(migrated.sessionEquivalentWindowPairIdentity(for: accountKey) == identity) + let histories = migrated.histories(for: accountKey) + #expect(findSeries(histories, name: .session, windowMinutes: 300)?.entries.map(\.usedPercent) == [10, 20]) + #expect(findSeries(histories, name: .weekly, windowMinutes: 10080)?.entries.map(\.usedPercent) == [30, 40]) + } + + @MainActor + @Test + func `generic pair identity distinguishes delimiter bearing family names`() throws { + let store = UsageStorePlanUtilizationTests.makeStore() + let now = Date(timeIntervalSince1970: 1_900_000_000) + + func identity(sessionID: String, weeklyID: String) throws -> String { + let snapshot = UsageSnapshot( + primary: nil, + secondary: nil, + extraRateWindows: [ + NamedRateWindow( + id: sessionID, + title: "Session", + window: RateWindow( + usedPercent: 20, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(3600), + resetDescription: nil)), + NamedRateWindow( + id: weeklyID, + title: "Weekly", + window: RateWindow( + usedPercent: 40, + windowMinutes: 10080, + resetsAt: now.addingTimeInterval(3 * 24 * 3600), + resetDescription: nil)), + ], + updatedAt: now) + return try #require(store.sessionEquivalentWindows( + provider: .zai, + snapshot: snapshot)?.historyIdentity) + } + + let first = try identity( + sessionID: "a|weekly:named:b-session", + weeklyID: "a|weekly:named:b-weekly") + let second = try identity(sessionID: "a-session", weeklyID: "a-weekly") + #expect(first != second) + } + + @MainActor + @Test + func `generic incomplete refresh preserves established pair history`() async throws { + let store = UsageStorePlanUtilizationTests.makeStore() + store.settings.historicalTrackingEnabled = true + let now = Date(timeIntervalSince1970: 1_900_000_000) + let session = RateWindow( + usedPercent: 20, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(3600), + resetDescription: nil) + let complete = UsageSnapshot( + primary: session, + secondary: RateWindow( + usedPercent: 40, + windowMinutes: 10080, + resetsAt: now.addingTimeInterval(3 * 24 * 3600), + resetDescription: nil), + updatedAt: now) + let incomplete = UsageSnapshot( + primary: RateWindow( + usedPercent: 30, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(2 * 3600), + resetDescription: nil), + secondary: nil, + updatedAt: now.addingTimeInterval(3600)) + + await store.recordPlanUtilizationHistorySample(provider: .zai, snapshot: complete, now: complete.updatedAt) + let identity = try #require(store.sessionEquivalentWindows( + provider: .zai, + snapshot: complete)?.historyIdentity) + await store.recordPlanUtilizationHistorySample( + provider: .zai, + snapshot: incomplete, + now: incomplete.updatedAt) + + #expect(store.sessionEquivalentHistoryIdentityMatches( + provider: .zai, + accountKey: nil, + historyIdentity: identity)) + let histories = store.planUtilizationHistory(for: .zai) + #expect(findSeries(histories, name: .session, windowMinutes: 300)?.entries.map(\.usedPercent) == [20]) + #expect(findSeries(histories, name: .weekly, windowMinutes: 10080)?.entries.map(\.usedPercent) == [40]) + } +} + +extension SessionEquivalentForecastTests { + @MainActor + @Test + func `antigravity history skips refreshes without the pinned Gemini family`() async { + let store = UsageStorePlanUtilizationTests.makeStore() + let now = Date(timeIntervalSince1970: 1_900_000_000) + store.planUtilizationHistory[.antigravity] = PlanUtilizationHistoryBuckets(unscoped: [ + planSeries( + name: .weekly, + windowMinutes: 10080, + entries: [planEntry(at: now.addingTimeInterval(-3600), usedPercent: 99)]), + ]) + let complete = Self.antigravitySnapshot( + now: now, + geminiSession: 20, + geminiWeekly: 60, + thirdPartySession: 30, + thirdPartyWeekly: 50) + let thirdPartyOnly = UsageSnapshot( + primary: nil, + secondary: nil, + extraRateWindows: complete.extraRateWindows?.filter { $0.id.contains("3p") }, + updatedAt: now.addingTimeInterval(3600)) + + await store.recordPlanUtilizationHistorySample(provider: .antigravity, snapshot: complete, now: now) + await store.recordPlanUtilizationHistorySample( + provider: .antigravity, + snapshot: thirdPartyOnly, + now: thirdPartyOnly.updatedAt) + + let histories = store.planUtilizationHistory(for: .antigravity) + #expect(findSeries(histories, name: .session, windowMinutes: 300)?.entries.map(\.usedPercent) == [20]) + #expect(findSeries(histories, name: .weekly, windowMinutes: 10080)?.entries.map(\.usedPercent) == [60]) + } + + private static func antigravitySnapshot( + now: Date, + geminiSession: Double, + geminiWeekly: Double, + thirdPartySession: Double, + thirdPartyWeekly: Double, + geminiFamily: String = "gemini") -> UsageSnapshot + { + UsageSnapshot( + primary: nil, + secondary: nil, + extraRateWindows: [ + NamedRateWindow( + id: "antigravity-quota-summary-\(geminiFamily)-5h", + title: "Gemini 5-hour", + window: RateWindow( + usedPercent: geminiSession, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(3600), + resetDescription: nil)), + NamedRateWindow( + id: "antigravity-quota-summary-\(geminiFamily)-weekly", + title: "Gemini weekly", + window: RateWindow( + usedPercent: geminiWeekly, + windowMinutes: 10080, + resetsAt: now.addingTimeInterval(3 * 24 * 3600), + resetDescription: nil)), + NamedRateWindow( + id: "antigravity-quota-summary-3p-5h", + title: "Third party 5-hour", + window: RateWindow( + usedPercent: thirdPartySession, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(3600), + resetDescription: nil)), + NamedRateWindow( + id: "antigravity-quota-summary-3p-weekly", + title: "Third party weekly", + window: RateWindow( + usedPercent: thirdPartyWeekly, + windowMinutes: 10080, + resetsAt: now.addingTimeInterval(3 * 24 * 3600), + resetDescription: nil)), + ], + updatedAt: now) + } + + static func historyFixture(burns: [Double]) + -> (histories: [PlanUtilizationSeriesHistory], currentSessionReset: Date) + { + self.historyFixture(samples: burns.map { + (sessionUsedPercent: 100, weeklyBurnPercent: $0) + }) + } + + private static func historyFixture( + samples: [(sessionUsedPercent: Double, weeklyBurnPercent: Double)]) + -> (histories: [PlanUtilizationSeriesHistory], currentSessionReset: Date) + { + let start = Date(timeIntervalSince1970: 1_800_000_000) + let duration: TimeInterval = 5 * 3600 + let weeklyReset = start.addingTimeInterval(7 * 24 * 3600) + var sessionEntries: [PlanUtilizationHistoryEntry] = [] + var weeklyEntries: [PlanUtilizationHistoryEntry] = [] + var weeklyUsed = 0.0 + + for (index, sample) in samples.enumerated() { + let windowStart = start.addingTimeInterval(Double(index) * duration) + let reset = windowStart.addingTimeInterval(duration) + sessionEntries.append(planEntry( + at: windowStart.addingTimeInterval(30 * 60), + usedPercent: min(20, sample.sessionUsedPercent), + resetsAt: reset)) + sessionEntries.append(planEntry( + at: reset.addingTimeInterval(-30 * 60), + usedPercent: sample.sessionUsedPercent, + resetsAt: reset)) + weeklyEntries.append(planEntry(at: windowStart, usedPercent: weeklyUsed, resetsAt: weeklyReset)) + weeklyUsed += sample.weeklyBurnPercent + weeklyEntries.append(planEntry(at: reset, usedPercent: weeklyUsed, resetsAt: weeklyReset)) + } + + return ( + histories: [ + planSeries(name: .session, windowMinutes: 300, entries: sessionEntries), + planSeries(name: .weekly, windowMinutes: 10080, entries: weeklyEntries), + ], + currentSessionReset: start.addingTimeInterval(Double(samples.count + 1) * duration)) + } + + private static func alignedPartialHistoryFixture() + -> (histories: [PlanUtilizationSeriesHistory], currentSessionReset: Date) + { + let start = Date(timeIntervalSince1970: 1_800_000_000) + let duration: TimeInterval = 5 * 3600 + let weeklyReset = start.addingTimeInterval(7 * 24 * 3600) + let fullAllowanceBurn = 10.0 + var sessionEntries: [PlanUtilizationHistoryEntry] = [] + var weeklyEntries: [PlanUtilizationHistoryEntry] = [] + var weeklyUsed = 0.0 + + for (index, sessionUsedPercent) in [20.0, 40.0, 100.0].enumerated() { + let windowStart = start.addingTimeInterval(Double(index) * duration) + let reset = windowStart.addingTimeInterval(duration) + let firstSessionUsedPercent = sessionUsedPercent / 4 + let firstCapturedAt = windowStart.addingTimeInterval(30 * 60) + let lastCapturedAt = reset.addingTimeInterval(-30 * 60) + let firstWeeklyUsedPercent = weeklyUsed + fullAllowanceBurn * firstSessionUsedPercent / 100 + let lastWeeklyUsedPercent = weeklyUsed + fullAllowanceBurn * sessionUsedPercent / 100 + + sessionEntries.append(planEntry( + at: firstCapturedAt, + usedPercent: firstSessionUsedPercent, + resetsAt: reset)) + weeklyEntries.append(planEntry( + at: firstCapturedAt, + usedPercent: firstWeeklyUsedPercent, + resetsAt: weeklyReset)) + sessionEntries.append(planEntry( + at: lastCapturedAt, + usedPercent: sessionUsedPercent, + resetsAt: reset)) + weeklyEntries.append(planEntry( + at: lastCapturedAt, + usedPercent: lastWeeklyUsedPercent, + resetsAt: weeklyReset)) + weeklyUsed = lastWeeklyUsedPercent + } + + let histories = UsageStore._updatedPlanUtilizationHistoriesForTesting( + existingHistories: [], + samples: [ + planSeries(name: .session, windowMinutes: 300, entries: sessionEntries), + planSeries(name: .weekly, windowMinutes: 10080, entries: weeklyEntries), + ]) ?? [] + return ( + histories: histories, + currentSessionReset: start.addingTimeInterval(4 * duration)) + } + + private static func straddledBoundaryHistoryFixture() + -> (histories: [PlanUtilizationSeriesHistory], currentSessionReset: Date) + { + let start = Date(timeIntervalSince1970: 1_800_000_000) + let duration: TimeInterval = 5 * 3600 + let stride: TimeInterval = 6 * 3600 + let weeklyReset = start.addingTimeInterval(7 * 24 * 3600) + var sessionEntries: [PlanUtilizationHistoryEntry] = [] + var weeklyEntries: [PlanUtilizationHistoryEntry] = [] + var weeklyUsed = 0.0 + + for index in 0..<3 { + let windowStart = start.addingTimeInterval(Double(index) * stride) + let reset = windowStart.addingTimeInterval(duration) + sessionEntries.append(planEntry( + at: windowStart.addingTimeInterval(30 * 60), + usedPercent: 20, + resetsAt: reset)) + sessionEntries.append(planEntry( + at: reset.addingTimeInterval(-30 * 60), + usedPercent: 100, + resetsAt: reset)) + weeklyEntries.append(planEntry( + at: windowStart.addingTimeInterval(-90), + usedPercent: weeklyUsed, + resetsAt: weeklyReset)) + weeklyEntries.append(planEntry( + at: windowStart.addingTimeInterval(30), + usedPercent: weeklyUsed, + resetsAt: weeklyReset)) + weeklyUsed += 10 + weeklyEntries.append(planEntry( + at: reset.addingTimeInterval(-90), + usedPercent: weeklyUsed, + resetsAt: weeklyReset)) + weeklyEntries.append(planEntry( + at: reset.addingTimeInterval(30), + usedPercent: weeklyUsed, + resetsAt: weeklyReset)) + } + + return ( + histories: [ + planSeries(name: .session, windowMinutes: 300, entries: sessionEntries), + planSeries(name: .weekly, windowMinutes: 10080, entries: weeklyEntries), + ], + currentSessionReset: start.addingTimeInterval(3 * stride + duration)) + } +} diff --git a/Tests/CodexBarTests/SessionQuotaNotificationLogicTests.swift b/Tests/CodexBarTests/SessionQuotaNotificationLogicTests.swift index 2c43d31791..f0801616cf 100644 --- a/Tests/CodexBarTests/SessionQuotaNotificationLogicTests.swift +++ b/Tests/CodexBarTests/SessionQuotaNotificationLogicTests.swift @@ -1,3 +1,4 @@ +import Foundation import Testing @testable import CodexBar diff --git a/Tests/CodexBarTests/SettingsStoreAdditionalTests.swift b/Tests/CodexBarTests/SettingsStoreAdditionalTests.swift index 39f50263fd..a50a595e39 100644 --- a/Tests/CodexBarTests/SettingsStoreAdditionalTests.swift +++ b/Tests/CodexBarTests/SettingsStoreAdditionalTests.swift @@ -5,6 +5,55 @@ import Testing @MainActor struct SettingsStoreAdditionalTests { + @Test + @MainActor + func `antigravity two pool migration preserves released metric meaning`() { + let primaryDefaults = UserDefaults(suiteName: #function + ".primary")! + primaryDefaults.removePersistentDomain(forName: #function + ".primary") + primaryDefaults.set( + [UsageProvider.antigravity.rawValue: MenuBarMetricPreference.primary.rawValue], + forKey: "menuBarMetricPreferences") + + let primarySettings = SettingsStore(userDefaults: primaryDefaults) + + #expect(primarySettings.menuBarMetricPreference(for: .antigravity) == .secondary) + #expect(primaryDefaults.bool(forKey: "antigravityTwoPoolMetricPreferenceMigrated")) + + let secondaryDefaults = UserDefaults(suiteName: #function + ".secondary")! + secondaryDefaults.removePersistentDomain(forName: #function + ".secondary") + secondaryDefaults.set( + [UsageProvider.antigravity.rawValue: MenuBarMetricPreference.secondary.rawValue], + forKey: "menuBarMetricPreferences") + + let secondarySettings = SettingsStore(userDefaults: secondaryDefaults) + + #expect(secondarySettings.menuBarMetricPreference(for: .antigravity) == .primary) + + let reloadedSettings = SettingsStore(userDefaults: secondaryDefaults) + #expect(reloadedSettings.menuBarMetricPreference(for: .antigravity) == .primary) + + let tertiaryDefaults = UserDefaults(suiteName: #function + ".tertiary")! + tertiaryDefaults.removePersistentDomain(forName: #function + ".tertiary") + tertiaryDefaults.set( + [UsageProvider.antigravity.rawValue: MenuBarMetricPreference.tertiary.rawValue], + forKey: "menuBarMetricPreferences") + + let tertiarySettings = SettingsStore(userDefaults: tertiaryDefaults) + + #expect(tertiarySettings.menuBarMetricPreference(for: .antigravity) == .primary) + + let migratedDefaults = UserDefaults(suiteName: #function + ".migrated")! + migratedDefaults.removePersistentDomain(forName: #function + ".migrated") + migratedDefaults.set( + [UsageProvider.antigravity.rawValue: MenuBarMetricPreference.primary.rawValue], + forKey: "menuBarMetricPreferences") + migratedDefaults.set(true, forKey: "antigravityTwoPoolMetricPreferenceMigrated") + + let migratedSettings = SettingsStore(userDefaults: migratedDefaults) + + #expect(migratedSettings.menuBarMetricPreference(for: .antigravity) == .primary) + } + @Test func `menu bar metric preference handles zai and average`() { let settings = Self.makeSettingsStore(suite: "SettingsStoreAdditionalTests-metric") @@ -25,6 +74,21 @@ struct SettingsStoreAdditionalTests { settings.setMenuBarMetricPreference(.average, for: .codex) #expect(settings.menuBarMetricPreference(for: .codex) == .automatic) + settings.setMenuBarMetricPreference(.primaryAndSecondary, for: .codex) + #expect(settings.menuBarMetricPreference(for: .codex) == .primaryAndSecondary) + #expect(settings.menuBarMetricSupportsPrimaryAndSecondary(for: .codex)) + + settings.setMenuBarMetricPreference(.primaryAndSecondary, for: .claude) + #expect(settings.menuBarMetricPreference(for: .claude) == .primaryAndSecondary) + #expect(settings.menuBarMetricSupportsPrimaryAndSecondary(for: .claude)) + + settings.setMenuBarMetricPreference(.monthlyPlan, for: .codex) + #expect(settings.menuBarMetricPreference(for: .codex) == .automatic) + + settings.menuBarMetricPreferencesRaw[UsageProvider.codex.rawValue] = MenuBarMetricPreference.monthlyPlan + .rawValue + #expect(settings.menuBarMetricPreference(for: .codex) == .automatic) + settings.setMenuBarMetricPreference(.average, for: .gemini) #expect(settings.menuBarMetricPreference(for: .gemini) == .average) @@ -75,11 +139,22 @@ struct SettingsStoreAdditionalTests { #expect(settings.menuBarMetricPreference(for: .openrouter) == .automatic) } + @Test + func `menu bar metric preference restricts mistral to payg or monthly plan`() { + let settings = Self.makeSettingsStore(suite: "SettingsStoreAdditionalTests-mistral-metric") + + settings.setMenuBarMetricPreference(.monthlyPlan, for: .mistral) + #expect(settings.menuBarMetricPreference(for: .mistral) == .monthlyPlan) + + settings.setMenuBarMetricPreference(.secondary, for: .mistral) + #expect(settings.menuBarMetricPreference(for: .mistral) == .automatic) + } + @Test func `menu bar metric preference restricts text only balance providers to automatic`() { let settings = Self.makeSettingsStore(suite: "SettingsStoreAdditionalTests-text-only-metric") - for provider in [UsageProvider.deepseek, .mistral, .kimik2] { + for provider in [UsageProvider.deepseek, .poe] { settings.setMenuBarMetricPreference(.primary, for: provider) #expect(settings.menuBarMetricPreference(for: provider) == .automatic) @@ -153,7 +228,6 @@ struct SettingsStoreAdditionalTests { minimaxCookieStore: InMemoryMiniMaxCookieStore(), minimaxAPITokenStore: InMemoryMiniMaxAPITokenStore(), kimiTokenStore: InMemoryKimiTokenStore(), - kimiK2TokenStore: InMemoryKimiK2TokenStore(), augmentCookieStore: InMemoryCookieHeaderStore(), ampCookieStore: InMemoryCookieHeaderStore(), copilotTokenStore: InMemoryCopilotTokenStore(), diff --git a/Tests/CodexBarTests/SettingsStoreCoverageTests.swift b/Tests/CodexBarTests/SettingsStoreCoverageTests.swift index 44a3e3e403..3aa84a5b23 100644 --- a/Tests/CodexBarTests/SettingsStoreCoverageTests.swift +++ b/Tests/CodexBarTests/SettingsStoreCoverageTests.swift @@ -25,11 +25,17 @@ struct SettingsStoreCoverageTests { #expect(ordered.first == .zai) #expect(ordered.contains(.minimax)) + let configRevisionBeforeOrder = settings.configRevision + let backgroundRevisionBeforeOrder = settings.backgroundWorkSettingsRevision settings.moveProvider(fromOffsets: IndexSet(integer: 0), toOffset: 2) #expect(settings.orderedProviders() != ordered) + #expect(settings.configRevision == configRevisionBeforeOrder + 1) + #expect(settings.backgroundWorkSettingsRevision == backgroundRevisionBeforeOrder) let metadata = ProviderRegistry.shared.metadata + let backgroundRevisionBeforeEnablement = settings.backgroundWorkSettingsRevision try settings.setProviderEnabled(provider: .codex, metadata: #require(metadata[.codex]), enabled: true) + #expect(settings.backgroundWorkSettingsRevision == backgroundRevisionBeforeEnablement + 1) try settings.setProviderEnabled(provider: .claude, metadata: #require(metadata[.claude]), enabled: false) let enabled = settings.enabledProvidersOrdered(metadataByProvider: metadata) #expect(enabled.contains(.codex)) @@ -74,6 +80,72 @@ struct SettingsStoreCoverageTests { #expect(settings.resetTimeDisplayStyle == .absolute) } + @Test + func `minimax settings snapshot uses selected token account as manual cookie`() { + let settings = Self.makeSettingsStore(suiteName: "SettingsStoreCoverageTests-minimax-token-account") + settings.minimaxCookieSource = .auto + settings.minimaxCookieHeader = "HERTZ-SESSION=global" + settings.addTokenAccount(provider: .minimax, label: "account", token: "HERTZ-SESSION=selected") + + let snapshot = settings.minimaxSettingsSnapshot(tokenOverride: nil) + + #expect(snapshot.cookieSource == .manual) + #expect(snapshot.manualCookieHeader == "HERTZ-SESSION=selected") + } + + @Test + func `minimax settings snapshot falls back to global cookie without token accounts`() { + let settings = Self.makeSettingsStore(suiteName: "SettingsStoreCoverageTests-minimax-global-cookie") + settings.minimaxCookieSource = .auto + settings.minimaxCookieHeader = "HERTZ-SESSION=global" + + let snapshot = settings.minimaxSettingsSnapshot(tokenOverride: nil) + + #expect(snapshot.cookieSource == .auto) + #expect(snapshot.manualCookieHeader == "HERTZ-SESSION=global") + } + + @Test + func `copilot budget extras default off and persist in provider snapshot`() throws { + let suite = "SettingsStoreCoverageTests-copilot-budget-extras" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + let configStore = testConfigStore(suiteName: suite) + + let initial = Self.makeSettingsStore(userDefaults: defaults, configStore: configStore) + #expect(initial.copilotBudgetExtrasEnabled == false) + #expect(initial.copilotSettingsSnapshot(tokenOverride: nil).budgetExtrasEnabled == false) + + initial.copilotBudgetExtrasEnabled = true + + let reloaded = Self.makeSettingsStore(userDefaults: defaults, configStore: configStore) + #expect(reloaded.copilotBudgetExtrasEnabled) + #expect(reloaded.copilotSettingsSnapshot(tokenOverride: nil).budgetExtrasEnabled) + } + + @Test + func `agent sessions default off and persist explicit opt in`() throws { + let suite = "SettingsStoreCoverageTests-agent-sessions" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + let configStore = testConfigStore(suiteName: suite) + + let initial = Self.makeSettingsStore(userDefaults: defaults, configStore: configStore) + #expect(initial.agentSessionsEnabled == false) + #expect(initial.agentSessionLabelStyle == .project) + #expect(defaults.object(forKey: "agentSessionsEnabled") == nil) + #expect(defaults.object(forKey: "agentSessionLabelStyle") == nil) + + initial.agentSessionsEnabled = true + initial.agentSessionLabelStyle = .descriptiveAndProject + #expect(defaults.object(forKey: "agentSessionsEnabled") as? Bool == true) + #expect(defaults.string(forKey: "agentSessionLabelStyle") == "descriptiveAndProject") + + let reloaded = Self.makeSettingsStore(userDefaults: defaults, configStore: configStore) + #expect(reloaded.agentSessionsEnabled) + #expect(reloaded.agentSessionLabelStyle == .descriptiveAndProject) + } + @Test func `multi account menu layout persists and bridges legacy show all token accounts`() throws { let suite = "SettingsStoreCoverageTests-multi-account-layout" @@ -151,6 +223,31 @@ struct SettingsStoreCoverageTests { #expect(settings.tokenAccounts(for: .copilot).count == 2) } + @Test + func `zai token account update preserves team metadata`() throws { + let settings = Self.makeSettingsStore() + + settings.addTokenAccount( + provider: .zai, + label: "Team", + token: "token-1", + usageScope: "team", + organizationID: "org-team", + workspaceID: "proj-team") + + let original = try #require(settings.selectedTokenAccount(for: .zai)) + settings.updateTokenAccount( + provider: .zai, + accountID: original.id, + label: "Team Updated", + token: "token-2") + + let updated = try #require(settings.selectedTokenAccount(for: .zai)) + #expect(updated.usageScope == "team") + #expect(updated.organizationID == "org-team") + #expect(updated.workspaceID == "proj-team") + } + @Test func `copilot token accounts clear legacy api key fallback`() throws { let settings = Self.makeSettingsStore() @@ -170,6 +267,21 @@ struct SettingsStoreCoverageTests { #expect(settings.copilotSettingsSnapshot(tokenOverride: nil).apiToken == nil) } + @Test + func `copilot settings snapshot carries selected account identifier`() { + let settings = Self.makeSettingsStore() + settings.addTokenAccount( + provider: .copilot, + label: "octocat (Pro)", + token: "token-1", + externalIdentifier: "github:user:123") + + let snapshot = settings.copilotSettingsSnapshot(tokenOverride: nil) + + #expect(snapshot.apiToken == "token-1") + #expect(snapshot.selectedAccountExternalIdentifier == "github:user:123") + } + @Test func `copilot enterprise host persists in provider config`() throws { let suite = "SettingsStoreCoverageTests-copilot-enterprise-host" @@ -307,6 +419,62 @@ struct SettingsStoreCoverageTests { #expect(SettingsStore.hasAnyTokenCostUsageSources( env: ["CLAUDE_CONFIG_DIR": claudeRoot.path], fileManager: fileManager)) + + let metadataOnlyHome = fileManager.temporaryDirectory.appendingPathComponent( + "claude-desktop-metadata-\(UUID().uuidString)", + isDirectory: true) + let metadataFile = metadataOnlyHome + .appendingPathComponent("Library/Application Support/Claude/claude-code-sessions", isDirectory: true) + .appendingPathComponent("account-id/org-id/local_session.json", isDirectory: false) + try fileManager.createDirectory(at: metadataFile.deletingLastPathComponent(), withIntermediateDirectories: true) + try Data(#"{"cliSessionId":"desktop-cli-session"}"#.utf8).write(to: metadataFile) + + #expect(!SettingsStore.hasAnyTokenCostUsageSources( + env: [:], + fileManager: fileManager, + homeDirectory: metadataOnlyHome)) + + let desktopHome = fileManager.temporaryDirectory.appendingPathComponent( + "claude-desktop-\(UUID().uuidString)", + isDirectory: true) + let desktopProjects = desktopHome + .appendingPathComponent("Library", isDirectory: true) + .appendingPathComponent("Application Support", isDirectory: true) + .appendingPathComponent("Claude", isDirectory: true) + .appendingPathComponent("local-agent-mode-sessions", isDirectory: true) + .appendingPathComponent("workspace-id", isDirectory: true) + .appendingPathComponent("session-id", isDirectory: true) + .appendingPathComponent("local_agent", isDirectory: true) + .appendingPathComponent(".claude", isDirectory: true) + .appendingPathComponent("projects", isDirectory: true) + try fileManager.createDirectory(at: desktopProjects, withIntermediateDirectories: true) + let desktopFile = desktopProjects + .appendingPathComponent("project-a", isDirectory: true) + .appendingPathComponent("session-a.jsonl", isDirectory: false) + try fileManager.createDirectory(at: desktopFile.deletingLastPathComponent(), withIntermediateDirectories: true) + fileManager.createFile(atPath: desktopFile.path, contents: Data("{}".utf8)) + + #expect(SettingsStore.hasAnyTokenCostUsageSources( + env: [:], + fileManager: fileManager, + homeDirectory: desktopHome)) + + let desktopCodeHome = fileManager.temporaryDirectory.appendingPathComponent( + "claude-desktop-code-\(UUID().uuidString)", + isDirectory: true) + let desktopCodeFile = desktopCodeHome + .appendingPathComponent("Library/Application Support/Claude/claude-code-sessions", isDirectory: true) + .appendingPathComponent("account-id/org-id/.claude/projects/project-a", isDirectory: true) + .appendingPathComponent("session-a.jsonl", isDirectory: false) + try fileManager.createDirectory( + at: desktopCodeFile.deletingLastPathComponent(), + withIntermediateDirectories: true) + fileManager.createFile(atPath: desktopCodeFile.path, contents: Data("{}".utf8)) + + #expect(SettingsStore.hasAnyTokenCostUsageSources( + env: [:], + fileManager: fileManager, + homeDirectory: desktopCodeHome)) } @Test @@ -323,7 +491,6 @@ struct SettingsStoreCoverageTests { settings.ensureMiniMaxCookieLoaded() settings.ensureMiniMaxAPITokenLoaded() settings.ensureKimiAuthTokenLoaded() - settings.ensureKimiK2APITokenLoaded() settings.ensureAugmentCookieLoaded() settings.ensureAmpCookieLoaded() settings.ensureOllamaCookieLoaded() @@ -388,9 +555,9 @@ struct SettingsStoreCoverageTests { } @Test - func `claude keychain read strategy defaults to security CLI experimental`() { + func `claude keychain read strategy defaults to security framework`() { let settings = Self.makeSettingsStore() - #expect(settings.claudeOAuthKeychainReadStrategy == .securityCLIExperimental) + #expect(settings.claudeOAuthKeychainReadStrategy == .securityFramework) } @Test @@ -401,13 +568,56 @@ struct SettingsStoreCoverageTests { let configStore = testConfigStore(suiteName: suite) let first = Self.makeSettingsStore(userDefaults: defaults, configStore: configStore) - first.claudeOAuthKeychainReadStrategy = .securityCLIExperimental + first.claudeOAuthKeychainReadStrategy = .securityFramework #expect( defaults.string(forKey: "claudeOAuthKeychainReadStrategy") - == ClaudeOAuthKeychainReadStrategy.securityCLIExperimental.rawValue) + == ClaudeOAuthKeychainReadStrategy.securityFramework.rawValue) let second = Self.makeSettingsStore(userDefaults: defaults, configStore: configStore) - #expect(second.claudeOAuthKeychainReadStrategy == .securityCLIExperimental) + #expect(second.claudeOAuthKeychainReadStrategy == .securityFramework) + } + + @Test + func `claude legacy security CLI read strategy preserves no prompt intent`() throws { + let suite = "SettingsStoreCoverageTests-claude-keychain-read-strategy-migration" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + defaults.set( + ClaudeOAuthKeychainReadStrategy.securityCLIExperimental.rawValue, + forKey: "claudeOAuthKeychainReadStrategy") + let configStore = testConfigStore(suiteName: suite) + + let settings = Self.makeSettingsStore(userDefaults: defaults, configStore: configStore) + + #expect(settings.claudeOAuthKeychainReadStrategy == .securityFramework) + #expect(settings.claudeOAuthKeychainPromptMode == .never) + #expect(settings.claudeOAuthPromptFreeCredentialsEnabled) + #expect( + defaults.string(forKey: "claudeOAuthKeychainReadStrategy") + == ClaudeOAuthKeychainReadStrategy.securityFramework.rawValue) + #expect( + defaults.string(forKey: "claudeOAuthKeychainPromptMode") + == ClaudeOAuthKeychainPromptMode.never.rawValue) + } + + @Test + func `claude legacy security CLI migration preserves explicit prompt policy`() throws { + let suite = "SettingsStoreCoverageTests-claude-keychain-explicit-prompt-migration" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + defaults.set( + ClaudeOAuthKeychainReadStrategy.securityCLIExperimental.rawValue, + forKey: "claudeOAuthKeychainReadStrategy") + defaults.set( + ClaudeOAuthKeychainPromptMode.always.rawValue, + forKey: "claudeOAuthKeychainPromptMode") + let configStore = testConfigStore(suiteName: suite) + + let settings = Self.makeSettingsStore(userDefaults: defaults, configStore: configStore) + + #expect(settings.claudeOAuthKeychainReadStrategy == .securityFramework) + #expect(settings.claudeOAuthKeychainPromptMode == .always) + #expect(!settings.claudeOAuthPromptFreeCredentialsEnabled) } @Test @@ -423,15 +633,17 @@ struct SettingsStoreCoverageTests { } @Test - func `claude prompt free credentials toggle maps to read strategy`() { + func `claude prompt free credentials toggle maps to never prompt policy`() { let settings = Self.makeSettingsStore() - #expect(settings.claudeOAuthPromptFreeCredentialsEnabled == true) + #expect(settings.claudeOAuthPromptFreeCredentialsEnabled == false) - settings.claudeOAuthPromptFreeCredentialsEnabled = false + settings.claudeOAuthPromptFreeCredentialsEnabled = true #expect(settings.claudeOAuthKeychainReadStrategy == .securityFramework) + #expect(settings.claudeOAuthKeychainPromptMode == .never) - settings.claudeOAuthPromptFreeCredentialsEnabled = true - #expect(settings.claudeOAuthKeychainReadStrategy == .securityCLIExperimental) + settings.claudeOAuthPromptFreeCredentialsEnabled = false + #expect(settings.claudeOAuthKeychainReadStrategy == .securityFramework) + #expect(settings.claudeOAuthKeychainPromptMode == .onlyOnUserAction) } @Test @@ -485,6 +697,97 @@ struct SettingsStoreCoverageTests { #expect(settings.selectedTokenAccount(for: .antigravity)?.id == accounts.last?.id) } + @Test + func `removing last antigravity oauth account clears matching shared credentials`() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("antigravity-shared-removal-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + + let sharedStore = AntigravityOAuthCredentialsStore( + fileURL: root.appendingPathComponent("oauth_creds.json")) + let credentials = AntigravityOAuthCredentials( + accessToken: "shared-access", + refreshToken: "shared-refresh", + expiryDate: Date(timeIntervalSince1970: 1_700_000_000), + email: "user@example.com") + try sharedStore.save(credentials) + let settings = Self.makeSettingsStore( + suiteName: "SettingsStoreCoverageTests-antigravity-remove-shared", + antigravityOAuthCredentialsStore: sharedStore) + + settings.upsertAntigravityOAuthAccount(credentials) + let account = try #require(settings.selectedTokenAccount(for: .antigravity)) + settings.removeTokenAccount(provider: .antigravity, accountID: account.id) + + #expect(settings.tokenAccounts(for: .antigravity).isEmpty) + #expect(try sharedStore.load() == nil) + } + + @Test + func `removing antigravity oauth account preserves freshly reauthenticated credentials`() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("antigravity-shared-reauth-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + + let sharedStore = AntigravityOAuthCredentialsStore( + fileURL: root.appendingPathComponent("oauth_creds.json")) + let removed = AntigravityOAuthCredentials( + accessToken: "removed-access", + refreshToken: "removed-refresh", + expiryDate: Date(timeIntervalSince1970: 1_700_000_000), + email: "user@example.com") + let refreshed = AntigravityOAuthCredentials( + accessToken: "fresh-access", + refreshToken: "fresh-refresh", + expiryDate: Date(timeIntervalSince1970: 1_700_000_100), + email: "user@example.com") + try sharedStore.save(refreshed) + let settings = Self.makeSettingsStore( + suiteName: "SettingsStoreCoverageTests-antigravity-preserve-reauth", + antigravityOAuthCredentialsStore: sharedStore) + + settings.upsertAntigravityOAuthAccount(removed) + let account = try #require(settings.selectedTokenAccount(for: .antigravity)) + settings.removeTokenAccount(provider: .antigravity, accountID: account.id) + + #expect(try sharedStore.load() == refreshed) + } + + @Test + func `removing antigravity oauth account preserves different shared credentials`() async throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("antigravity-shared-preserve-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + + let sharedStore = AntigravityOAuthCredentialsStore( + fileURL: root.appendingPathComponent("oauth_creds.json")) + let removed = AntigravityOAuthCredentials( + accessToken: "removed-access", + refreshToken: "removed-refresh", + expiryDate: Date(timeIntervalSince1970: 1_700_000_000), + email: "removed@example.com") + let shared = AntigravityOAuthCredentials( + accessToken: "shared-access", + refreshToken: "shared-refresh", + expiryDate: Date(timeIntervalSince1970: 1_700_000_000), + email: "shared@example.com") + try sharedStore.save(shared) + let settings = Self.makeSettingsStore( + suiteName: "SettingsStoreCoverageTests-antigravity-preserve-shared", + antigravityOAuthCredentialsStore: sharedStore) + + settings.upsertAntigravityOAuthAccount(removed) + let account = try #require(settings.selectedTokenAccount(for: .antigravity)) + settings.removeTokenAccount(provider: .antigravity, accountID: account.id) + + #expect(settings.tokenAccounts(for: .antigravity).isEmpty) + try await Task.sleep(nanoseconds: 50_000_000) + #expect(try sharedStore.load() == shared) + } + @Test func `weekly progress work days defaults to nil and persists across store reload`() throws { let suite = "SettingsStoreCoverageTests-weekly-progress-work-days" @@ -517,17 +820,41 @@ struct SettingsStoreCoverageTests { #expect(reloaded4.weeklyProgressWorkDays == nil) } - private static func makeSettingsStore(suiteName: String = "SettingsStoreCoverageTests") -> SettingsStore { + @Test + func `preferred currency defaults to USD and persists an explicit selection`() throws { + let suite = "SettingsStoreCoverageTests-preferred-currency" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + let configStore = testConfigStore(suiteName: suite) + + let fresh = Self.makeSettingsStore(userDefaults: defaults, configStore: configStore) + #expect(fresh.preferredCurrencyCode == "USD") + + fresh.preferredCurrencyCode = "GBP" + let reloaded = Self.makeSettingsStore(userDefaults: defaults, configStore: configStore) + #expect(reloaded.preferredCurrencyCode == "GBP") + } + + private static func makeSettingsStore( + suiteName: String = "SettingsStoreCoverageTests", + antigravityOAuthCredentialsStore: AntigravityOAuthCredentialsStore = AntigravityOAuthCredentialsStore()) + -> SettingsStore + { let defaults = UserDefaults(suiteName: suiteName)! defaults.removePersistentDomain(forName: suiteName) defaults.set(false, forKey: "debugDisableKeychainAccess") let configStore = testConfigStore(suiteName: suiteName) - return Self.makeSettingsStore(userDefaults: defaults, configStore: configStore) + return Self.makeSettingsStore( + userDefaults: defaults, + configStore: configStore, + antigravityOAuthCredentialsStore: antigravityOAuthCredentialsStore) } private static func makeSettingsStore( userDefaults: UserDefaults, - configStore: CodexBarConfigStore) -> SettingsStore + configStore: CodexBarConfigStore, + antigravityOAuthCredentialsStore: AntigravityOAuthCredentialsStore = AntigravityOAuthCredentialsStore()) + -> SettingsStore { SettingsStore( userDefaults: userDefaults, @@ -542,10 +869,25 @@ struct SettingsStoreCoverageTests { minimaxCookieStore: InMemoryMiniMaxCookieStore(), minimaxAPITokenStore: InMemoryMiniMaxAPITokenStore(), kimiTokenStore: InMemoryKimiTokenStore(), - kimiK2TokenStore: InMemoryKimiK2TokenStore(), augmentCookieStore: InMemoryCookieHeaderStore(), ampCookieStore: InMemoryCookieHeaderStore(), copilotTokenStore: InMemoryCopilotTokenStore(), - tokenAccountStore: InMemoryTokenAccountStore()) + tokenAccountStore: InMemoryTokenAccountStore(), + antigravityOAuthCredentialsStore: antigravityOAuthCredentialsStore) + } + + private static func waitForSharedAntigravityCredentials( + in store: AntigravityOAuthCredentialsStore, + matches predicate: (AntigravityOAuthCredentials?) -> Bool) async throws + -> AntigravityOAuthCredentials? + { + for _ in 0..<100 { + let credentials = try store.load() + if predicate(credentials) { + return credentials + } + try await Task.sleep(nanoseconds: 10_000_000) + } + return try store.load() } } diff --git a/Tests/CodexBarTests/SettingsStoreRefreshDefaultTests.swift b/Tests/CodexBarTests/SettingsStoreRefreshDefaultTests.swift new file mode 100644 index 0000000000..bbd92c63e3 --- /dev/null +++ b/Tests/CodexBarTests/SettingsStoreRefreshDefaultTests.swift @@ -0,0 +1,243 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +@Suite(.serialized) +@MainActor +struct SettingsStoreRefreshDefaultTests { + enum PreviousLaunchMarker: CaseIterable, Sendable { + case providerDetection + case appGroupMigration + + func seed(_ defaults: UserDefaults) { + switch self { + case .providerDetection: + defaults.set(true, forKey: "providerDetectionCompleted") + case .appGroupMigration: + defaults.set(AppGroupSupport.migrationVersion, forKey: AppGroupSupport.migrationVersionKey) + } + } + } + + @Test + func `fresh install defaults to adaptive and persists the choice`() throws { + let suite = "SettingsStoreRefreshDefaultTests-fresh" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + let configStore = testConfigStore(suiteName: suite) + + let store = self.makeStore(defaults: defaults, configStore: configStore) + + #expect(store.refreshFrequency == .adaptive) + #expect(store.refreshFrequency.seconds == nil) + #expect(defaults.string(forKey: "refreshFrequency") == RefreshFrequency.adaptive.rawValue) + #expect(store.adaptiveActivityScanConsent == .undecided) + #expect(defaults.string(forKey: "adaptiveActivityScanConsent") == "undecided") + #expect(!store.shouldRequestAdaptiveActivityScanConsent) + + defaults.set(true, forKey: "providerDetectionCompleted") + let reloaded = self.makeStore(defaults: defaults, configStore: configStore) + #expect(reloaded.refreshFrequency == .adaptive) + #expect(reloaded.adaptiveActivityScanConsent == .undecided) + } + + @Test + func `unrecognized refresh frequency keeps the legacy fallback`() throws { + let suite = "SettingsStoreRefreshDefaultTests-invalid" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + defaults.set("legacyValue", forKey: "refreshFrequency") + + let store = self.makeStore( + defaults: defaults, + configStore: testConfigStore(suiteName: suite)) + + #expect(store.refreshFrequency == .fiveMinutes) + #expect(defaults.string(forKey: "refreshFrequency") == RefreshFrequency.fiveMinutes.rawValue) + } + + @Test(arguments: PreviousLaunchMarker.allCases) + func `legacy unset refresh keeps five minute fallback`(marker: PreviousLaunchMarker) throws { + let suite = "SettingsStoreRefreshDefaultTests-legacy-\(marker)" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + marker.seed(defaults) + + let store = self.makeStore( + defaults: defaults, + configStore: testConfigStore(suiteName: suite)) + + #expect(store.refreshFrequency == .fiveMinutes) + #expect(defaults.string(forKey: "refreshFrequency") == RefreshFrequency.fiveMinutes.rawValue) + } + + @Test + func `existing config without launch markers keeps five minute fallback`() throws { + let suite = "SettingsStoreRefreshDefaultTests-existing-config" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + let configStore = testConfigStore(suiteName: suite) + try configStore.save(CodexBarConfig.makeDefault()) + + let store = self.makeStore(defaults: defaults, configStore: configStore) + + #expect(store.refreshFrequency == .fiveMinutes) + #expect(defaults.string(forKey: "refreshFrequency") == RefreshFrequency.fiveMinutes.rawValue) + } + + @Test + func `non string refresh value keeps five minute fallback`() throws { + let suite = "SettingsStoreRefreshDefaultTests-non-string" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + defaults.set(17, forKey: "refreshFrequency") + + let store = self.makeStore( + defaults: defaults, + configStore: testConfigStore(suiteName: suite)) + + #expect(store.refreshFrequency == .fiveMinutes) + #expect(defaults.string(forKey: "refreshFrequency") == RefreshFrequency.fiveMinutes.rawValue) + } + + @Test + func `every valid stored refresh frequency remains authoritative`() throws { + let markers: [PreviousLaunchMarker?] = [nil, .providerDetection, .appGroupMigration] + for frequency in RefreshFrequency.allCases { + for marker in markers { + let suite = "SettingsStoreRefreshDefaultTests-valid-\(frequency.rawValue)-\(String(describing: marker))" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + defaults.set(frequency.rawValue, forKey: "refreshFrequency") + marker?.seed(defaults) + + let store = self.makeStore( + defaults: defaults, + configStore: testConfigStore(suiteName: suite)) + + #expect(store.refreshFrequency == frequency) + #expect(defaults.string(forKey: "refreshFrequency") == frequency.rawValue) + #expect(store.adaptiveActivityScanConsent == .undecided) + #expect(defaults.string(forKey: "adaptiveActivityScanConsent") == "undecided") + #expect(store.shouldRequestAdaptiveActivityScanConsent == (frequency == .adaptiveAgentAware)) + } + } + } + + @Test + func `adaptive activity consent is explicit and persists`() throws { + let suite = "SettingsStoreRefreshDefaultTests-consent" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + let configStore = testConfigStore(suiteName: suite) + let store = self.makeStore(defaults: defaults, configStore: configStore) + + store.refreshFrequency = .adaptiveAgentAware + #expect(!store.adaptiveActivityScanningEnabled) + #expect(store.shouldRequestAdaptiveActivityScanConsent) + + store.adaptiveActivityScanConsent = .allowed + #expect(store.adaptiveActivityScanningEnabled) + #expect(!store.shouldRequestAdaptiveActivityScanConsent) + #expect(defaults.string(forKey: "adaptiveActivityScanConsent") == "allowed") + + let reloaded = self.makeStore(defaults: defaults, configStore: configStore) + #expect(reloaded.adaptiveActivityScanConsent == .allowed) + #expect(reloaded.adaptiveActivityScanningEnabled) + + reloaded.adaptiveActivityScanConsent = .declined + #expect(!reloaded.adaptiveActivityScanningEnabled) + #expect(defaults.string(forKey: "adaptiveActivityScanConsent") == "declined") + } + + @Test + func `invalid consent fails closed and requests a decision`() throws { + let suite = "SettingsStoreRefreshDefaultTests-invalid-consent" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + defaults.set(RefreshFrequency.adaptiveAgentAware.rawValue, forKey: "refreshFrequency") + defaults.set("legacy", forKey: "adaptiveActivityScanConsent") + + let store = self.makeStore( + defaults: defaults, + configStore: testConfigStore(suiteName: suite)) + + #expect(store.adaptiveActivityScanConsent == .undecided) + #expect(!store.adaptiveActivityScanningEnabled) + #expect(store.shouldRequestAdaptiveActivityScanConsent) + #expect(defaults.string(forKey: "adaptiveActivityScanConsent") == "undecided") + } + + @Test + func `plain adaptive never requests consent or scans`() throws { + let suite = "SettingsStoreRefreshDefaultTests-existing-adaptive-consent" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + defaults.set(true, forKey: "providerDetectionCompleted") + defaults.set(RefreshFrequency.adaptive.rawValue, forKey: "refreshFrequency") + + let store = self.makeStore( + defaults: defaults, + configStore: testConfigStore(suiteName: suite)) + + #expect(store.refreshFrequency == .adaptive) + #expect(store.adaptiveActivityScanConsent == .undecided) + #expect(!store.adaptiveActivityScanningEnabled) + #expect(!store.shouldRequestAdaptiveActivityScanConsent) + + store.adaptiveActivityScanConsent = .allowed + #expect(!store.adaptiveActivityScanningEnabled) + } + + @Test + func `consent prompt is limited to agent aware adaptive`() throws { + let suite = "SettingsStoreRefreshDefaultTests-consent-prompt" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + let store = self.makeStore( + defaults: defaults, + configStore: testConfigStore(suiteName: suite)) + + #expect(!store.shouldRequestAdaptiveActivityScanConsent) + + store.refreshFrequency = .adaptiveAgentAware + #expect(store.shouldRequestAdaptiveActivityScanConsent) + + store.agentSessionsEnabled = true + #expect(store.shouldRequestAdaptiveActivityScanConsent) + + store.adaptiveActivityScanConsent = .allowed + #expect(store.adaptiveActivityScanningEnabled) + store.refreshFrequency = .adaptive + #expect(!store.adaptiveActivityScanningEnabled) + #expect(!store.shouldRequestAdaptiveActivityScanConsent) + } + + @Test + func `reselecting agent aware adaptive after decline requests consent again`() throws { + let suite = "SettingsStoreRefreshDefaultTests-consent-reselect" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + let store = self.makeStore( + defaults: defaults, + configStore: testConfigStore(suiteName: suite)) + + store.adaptiveActivityScanConsent = .declined + store.refreshFrequency = .adaptiveAgentAware + + #expect(store.adaptiveActivityScanConsent == .undecided) + #expect(store.shouldRequestAdaptiveActivityScanConsent) + } + + private func makeStore( + defaults: UserDefaults, + configStore: CodexBarConfigStore) -> SettingsStore + { + SettingsStore( + userDefaults: defaults, + configStore: configStore, + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + } +} diff --git a/Tests/CodexBarTests/SettingsStoreTests.swift b/Tests/CodexBarTests/SettingsStoreTests.swift index 7c3b70790d..509a4419af 100644 --- a/Tests/CodexBarTests/SettingsStoreTests.swift +++ b/Tests/CodexBarTests/SettingsStoreTests.swift @@ -25,11 +25,54 @@ struct SettingsStoreTests { } } + private final class BoolRecorder: @unchecked Sendable { + private let lock = NSLock() + private var values: [Bool] = [] + + func append(_ value: Bool) { + self.lock.lock() + self.values.append(value) + self.lock.unlock() + } + + func get() -> [Bool] { + self.lock.lock() + defer { self.lock.unlock() } + return self.values + } + } + + @Test + func `persists refresh frequency across instances`() throws { + let suite = "SettingsStoreTests-persist" + let defaultsA = try #require(UserDefaults(suiteName: suite)) + defaultsA.removePersistentDomain(forName: suite) + let configStore = testConfigStore(suiteName: suite) + let storeA = SettingsStore( + userDefaults: defaultsA, + configStore: configStore, + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + + storeA.refreshFrequency = .fifteenMinutes + + let defaultsB = try #require(UserDefaults(suiteName: suite)) + let storeB = SettingsStore( + userDefaults: defaultsB, + configStore: configStore, + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + + #expect(storeB.refreshFrequency == .fifteenMinutes) + #expect(storeB.refreshFrequency.seconds == 900) + } + @Test - func `default refresh frequency is five minutes`() throws { - let suite = "SettingsStoreTests-default" + func `preserves an explicit five minute selection under the adaptive default`() throws { + let suite = "SettingsStoreTests-explicit-five-minute" let defaults = try #require(UserDefaults(suiteName: suite)) defaults.removePersistentDomain(forName: suite) + defaults.set(RefreshFrequency.fiveMinutes.rawValue, forKey: "refreshFrequency") let configStore = testConfigStore(suiteName: suite) let store = SettingsStore( @@ -40,30 +83,57 @@ struct SettingsStoreTests { #expect(store.refreshFrequency == .fiveMinutes) #expect(store.refreshFrequency.seconds == 300) - #expect(defaults.string(forKey: "refreshFrequency") == RefreshFrequency.fiveMinutes.rawValue) } @Test - func `repairs unrecognized refresh frequency raw value`() throws { - let suite = "SettingsStoreTests-invalid-refresh" + func `refresh on open defaults off and persists`() throws { + let suite = "SettingsStoreTests-refresh-on-open" let defaults = try #require(UserDefaults(suiteName: suite)) defaults.removePersistentDomain(forName: suite) - defaults.set("legacyValue", forKey: "refreshFrequency") let configStore = testConfigStore(suiteName: suite) + let store = SettingsStore( + userDefaults: defaults, + configStore: configStore, + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + + #expect(store.refreshAllProvidersOnMenuOpen == false) + store.refreshAllProvidersOnMenuOpen = true + + let reloaded = SettingsStore( + userDefaults: defaults, + configStore: configStore, + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + #expect(reloaded.refreshAllProvidersOnMenuOpen == true) + } + @Test + func `exhausted reset time display defaults off and persists`() throws { + let suite = "SettingsStoreTests-exhausted-reset-time" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + let configStore = testConfigStore(suiteName: suite) let store = SettingsStore( userDefaults: defaults, configStore: configStore, zaiTokenStore: NoopZaiTokenStore(), syntheticTokenStore: NoopSyntheticTokenStore()) - #expect(store.refreshFrequency == .fiveMinutes) - #expect(defaults.string(forKey: "refreshFrequency") == RefreshFrequency.fiveMinutes.rawValue) + #expect(store.menuBarShowsResetTimeWhenExhausted == false) + store.menuBarShowsResetTimeWhenExhausted = true + + let reloaded = SettingsStore( + userDefaults: defaults, + configStore: configStore, + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + #expect(reloaded.menuBarShowsResetTimeWhenExhausted == true) } @Test - func `persists refresh frequency across instances`() throws { - let suite = "SettingsStoreTests-persist" + func `weekly confetti setting defaults off and persists`() throws { + let suite = "SettingsStoreTests-weekly-confetti" let defaultsA = try #require(UserDefaults(suiteName: suite)) defaultsA.removePersistentDomain(forName: suite) let configStore = testConfigStore(suiteName: suite) @@ -73,7 +143,8 @@ struct SettingsStoreTests { zaiTokenStore: NoopZaiTokenStore(), syntheticTokenStore: NoopSyntheticTokenStore()) - storeA.refreshFrequency = .fifteenMinutes + #expect(storeA.confettiOnWeeklyLimitResetsEnabled == false) + storeA.confettiOnWeeklyLimitResetsEnabled = true let defaultsB = try #require(UserDefaults(suiteName: suite)) let storeB = SettingsStore( @@ -82,13 +153,12 @@ struct SettingsStoreTests { zaiTokenStore: NoopZaiTokenStore(), syntheticTokenStore: NoopSyntheticTokenStore()) - #expect(storeB.refreshFrequency == .fifteenMinutes) - #expect(storeB.refreshFrequency.seconds == 900) + #expect(storeB.confettiOnWeeklyLimitResetsEnabled == true) } @Test - func `weekly confetti setting defaults off and persists`() throws { - let suite = "SettingsStoreTests-weekly-confetti" + func `session confetti setting defaults off and persists`() throws { + let suite = "SettingsStoreTests-session-confetti" let defaultsA = try #require(UserDefaults(suiteName: suite)) defaultsA.removePersistentDomain(forName: suite) let configStore = testConfigStore(suiteName: suite) @@ -98,8 +168,8 @@ struct SettingsStoreTests { zaiTokenStore: NoopZaiTokenStore(), syntheticTokenStore: NoopSyntheticTokenStore()) - #expect(storeA.confettiOnWeeklyLimitResetsEnabled == false) - storeA.confettiOnWeeklyLimitResetsEnabled = true + #expect(storeA.confettiOnSessionLimitResetsEnabled == false) + storeA.confettiOnSessionLimitResetsEnabled = true let defaultsB = try #require(UserDefaults(suiteName: suite)) let storeB = SettingsStore( @@ -108,7 +178,7 @@ struct SettingsStoreTests { zaiTokenStore: NoopZaiTokenStore(), syntheticTokenStore: NoopSyntheticTokenStore()) - #expect(storeB.confettiOnWeeklyLimitResetsEnabled == true) + #expect(storeB.confettiOnSessionLimitResetsEnabled == true) } @Test @@ -137,6 +207,55 @@ struct SettingsStoreTests { #expect(storeB.providerStorageFootprintsEnabled == true) } + @Test + func `providers sorted alphabetically defaults off and persists`() throws { + let suite = "SettingsStoreTests-providers-sorted-alpha" + let defaultsA = try #require(UserDefaults(suiteName: suite)) + defaultsA.removePersistentDomain(forName: suite) + let configStore = testConfigStore(suiteName: suite) + let storeA = SettingsStore( + userDefaults: defaultsA, + configStore: configStore, + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + + #expect(storeA.providersSortedAlphabetically == false) + storeA.providersSortedAlphabetically = true + + let defaultsB = try #require(UserDefaults(suiteName: suite)) + let storeB = SettingsStore( + userDefaults: defaultsB, + configStore: configStore, + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + + #expect(storeB.providersSortedAlphabetically == true) + } + + @Test + func `alphabetical provider order puts enabled first then sorts by name`() { + let metadata = ProviderDescriptorRegistry.metadata + let enabled: Set = [.cursor, .claude, .codex] + let ordered = CodexBarConfig.alphabeticalProviderOrder( + enablement: { enabled.contains($0) }) + + #expect(Set(ordered) == Set(UsageProvider.allCases)) + + let displayName: (UsageProvider) -> String = { metadata[$0]?.displayName ?? $0.rawValue } + let enabledPart = ordered.filter { enabled.contains($0) } + let disabledPart = ordered.filter { !enabled.contains($0) } + // Enabled providers occupy the top of the list, ahead of every disabled provider. + #expect(Array(ordered.prefix(enabled.count)) == enabledPart) + #expect(ordered == enabledPart + disabledPart) + let isSortedByName: ([UsageProvider]) -> Bool = { group in + group == group.sorted { + displayName($0).localizedCaseInsensitiveCompare(displayName($1)) == .orderedAscending + } + } + #expect(isSortedByName(enabledPart)) + #expect(isSortedByName(disabledPart)) + } + @Test func `provider changelog links setting defaults off and persists`() throws { let suite = "SettingsStoreTests-provider-changelog-links" @@ -163,6 +282,58 @@ struct SettingsStoreTests { #expect(storeB.providerChangelogLinksEnabled == true) } + @Test + func `hide critters setting defaults off and persists`() throws { + let suite = "SettingsStoreTests-hide-critters" + let defaultsA = try #require(UserDefaults(suiteName: suite)) + defaultsA.removePersistentDomain(forName: suite) + let configStore = testConfigStore(suiteName: suite) + let storeA = SettingsStore( + userDefaults: defaultsA, + configStore: configStore, + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + + #expect(storeA.menuBarHidesCritters == false) + #expect(defaultsA.bool(forKey: "menuBarHidesCritters") == false) + storeA.menuBarHidesCritters = true + + let defaultsB = try #require(UserDefaults(suiteName: suite)) + let storeB = SettingsStore( + userDefaults: defaultsB, + configStore: configStore, + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + + #expect(storeB.menuBarHidesCritters == true) + } + + @Test + func `inactive display contrast setting defaults off and persists`() throws { + let suite = "SettingsStoreTests-inactive-display-contrast" + let defaultsA = try #require(UserDefaults(suiteName: suite)) + defaultsA.removePersistentDomain(forName: suite) + let configStore = testConfigStore(suiteName: suite) + let storeA = SettingsStore( + userDefaults: defaultsA, + configStore: configStore, + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + + #expect(storeA.menuBarHighContrastOnInactiveDisplays == false) + #expect(defaultsA.bool(forKey: "menuBarHighContrastOnInactiveDisplays") == false) + storeA.menuBarHighContrastOnInactiveDisplays = true + + let defaultsB = try #require(UserDefaults(suiteName: suite)) + let storeB = SettingsStore( + userDefaults: defaultsB, + configStore: configStore, + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + + #expect(storeB.menuBarHighContrastOnInactiveDisplays == true) + } + @Test func `persists selected menu provider across instances`() throws { let suite = "SettingsStoreTests-selectedMenuProvider" @@ -223,8 +394,18 @@ struct SettingsStoreTests { zaiTokenStore: NoopZaiTokenStore(), syntheticTokenStore: NoopSyntheticTokenStore()) - storeA.mergedOverviewSelectedProviders = [.opencode, .codex, .opencode, .claude] - #expect(storeA.mergedOverviewSelectedProviders == [.opencode, .codex, .claude]) + storeA.mergedOverviewSelectedProviders = [ + .opencode, + .codex, + .opencode, + .claude, + .cursor, + .warp, + .gemini, + .grok, + ] + let expectedProviders: [UsageProvider] = [.opencode, .codex, .claude, .cursor, .warp, .gemini] + #expect(storeA.mergedOverviewSelectedProviders == expectedProviders) let defaultsB = try #require(UserDefaults(suiteName: suite)) let storeB = SettingsStore( @@ -233,7 +414,7 @@ struct SettingsStoreTests { zaiTokenStore: NoopZaiTokenStore(), syntheticTokenStore: NoopSyntheticTokenStore()) - #expect(storeB.mergedOverviewSelectedProviders == [.opencode, .codex, .claude]) + #expect(storeB.mergedOverviewSelectedProviders == expectedProviders) } @Test @@ -253,8 +434,8 @@ struct SettingsStoreTests { } @Test - func `resolved merged overview providers defaults to first three when selection empty`() throws { - let suite = "SettingsStoreTests-merged-overview-default-first-three" + func `resolved merged overview providers defaults to first six when selection empty`() throws { + let suite = "SettingsStoreTests-merged-overview-default-first-six" let defaults = try #require(UserDefaults(suiteName: suite)) defaults.removePersistentDomain(forName: suite) let configStore = testConfigStore(suiteName: suite) @@ -264,10 +445,10 @@ struct SettingsStoreTests { zaiTokenStore: NoopZaiTokenStore(), syntheticTokenStore: NoopSyntheticTokenStore()) - let activeProviders: [UsageProvider] = [.codex, .claude, .cursor, .opencode, .warp] + let activeProviders: [UsageProvider] = [.codex, .claude, .cursor, .opencode, .warp, .gemini, .grok] let resolved = store.resolvedMergedOverviewProviders(activeProviders: activeProviders) - #expect(resolved == [.codex, .claude, .cursor]) + #expect(resolved == [.codex, .claude, .cursor, .opencode, .warp, .gemini]) } @Test @@ -283,7 +464,7 @@ struct SettingsStoreTests { syntheticTokenStore: NoopSyntheticTokenStore()) store.mergedOverviewSelectedProviders = [] - let activeProviders: [UsageProvider] = [.codex, .claude, .cursor, .opencode, .warp] + let activeProviders: [UsageProvider] = [.codex, .claude, .cursor, .opencode, .warp, .gemini, .grok] let resolved = store.resolvedMergedOverviewProviders(activeProviders: activeProviders) #expect(resolved == []) @@ -302,7 +483,7 @@ struct SettingsStoreTests { syntheticTokenStore: NoopSyntheticTokenStore()) store.mergedOverviewSelectedProviders = [.opencode, .codex, .cursor] - let activeProviders: [UsageProvider] = [.codex, .claude, .cursor, .opencode] + let activeProviders: [UsageProvider] = [.codex, .claude, .cursor, .opencode, .warp, .gemini, .grok] let resolved = store.resolvedMergedOverviewProviders(activeProviders: activeProviders) #expect(resolved == [.codex, .cursor, .opencode]) @@ -321,7 +502,7 @@ struct SettingsStoreTests { syntheticTokenStore: NoopSyntheticTokenStore()) store.mergedOverviewSelectedProviders = [.codex, .claude, .opencode] - let activeProviders: [UsageProvider] = [.codex, .cursor, .gemini, .opencode] + let activeProviders: [UsageProvider] = [.codex, .cursor, .gemini, .opencode, .warp, .grok, .amp] let resolved = store.reconcileMergedOverviewSelectedProviders(activeProviders: activeProviders) @@ -330,8 +511,8 @@ struct SettingsStoreTests { } @Test - func `reconcile merged overview selection does not clobber stored preference when three or fewer`() throws { - let suite = "SettingsStoreTests-merged-overview-three-or-fewer" + func `reconcile merged overview selection does not clobber stored preference when six or fewer`() throws { + let suite = "SettingsStoreTests-merged-overview-six-or-fewer" let defaults = try #require(UserDefaults(suiteName: suite)) defaults.removePersistentDomain(forName: suite) let configStore = testConfigStore(suiteName: suite) @@ -351,10 +532,10 @@ struct SettingsStoreTests { } @Test - func `reconcile merged overview selection ignores stale subset without persisting auto fill when three or fewer`() + func `reconcile merged overview selection ignores stale subset without persisting auto fill when six or fewer`() throws { - let suite = "SettingsStoreTests-merged-overview-three-or-fewer-subset" + let suite = "SettingsStoreTests-merged-overview-six-or-fewer-subset" let defaults = try #require(UserDefaults(suiteName: suite)) defaults.removePersistentDomain(forName: suite) let configStore = testConfigStore(suiteName: suite) @@ -374,8 +555,8 @@ struct SettingsStoreTests { } @Test - func `merged overview selection allows deselecting providers when three or fewer`() throws { - let suite = "SettingsStoreTests-merged-overview-deselect-three-or-fewer" + func `merged overview selection allows deselecting providers when six or fewer`() throws { + let suite = "SettingsStoreTests-merged-overview-deselect-six-or-fewer" let defaults = try #require(UserDefaults(suiteName: suite)) defaults.removePersistentDomain(forName: suite) let configStore = testConfigStore(suiteName: suite) @@ -422,7 +603,7 @@ struct SettingsStoreTests { } @Test - func `merged overview selection allows deselecting providers when more than three active`() throws { + func `merged overview selection allows deselecting providers when more than six active`() throws { let suite = "SettingsStoreTests-merged-overview-deselect-subset" let defaults = try #require(UserDefaults(suiteName: suite)) defaults.removePersistentDomain(forName: suite) @@ -434,7 +615,7 @@ struct SettingsStoreTests { syntheticTokenStore: NoopSyntheticTokenStore()) store.mergedOverviewSelectedProviders = [.codex, .claude, .cursor] - let activeProviders: [UsageProvider] = [.codex, .claude, .cursor, .opencode] + let activeProviders: [UsageProvider] = [.codex, .claude, .cursor, .opencode, .warp, .gemini, .grok] _ = store.setMergedOverviewProviderSelection( provider: .cursor, @@ -446,7 +627,7 @@ struct SettingsStoreTests { } @Test - func `reconcile merged overview selection preserves stored subset when active drops to three or fewer`() throws { + func `reconcile merged overview selection preserves stored subset when active drops to six or fewer`() throws { let suite = "SettingsStoreTests-merged-overview-preserve-subset-across-drop" let defaults = try #require(UserDefaults(suiteName: suite)) defaults.removePersistentDomain(forName: suite) @@ -457,26 +638,27 @@ struct SettingsStoreTests { zaiTokenStore: NoopZaiTokenStore(), syntheticTokenStore: NoopSyntheticTokenStore()) - let activeProviders: [UsageProvider] = [.codex, .claude, .cursor, .opencode] + let activeProviders: [UsageProvider] = [.codex, .claude, .cursor, .opencode, .warp, .gemini, .grok] _ = store.setMergedOverviewProviderSelection( provider: .claude, isSelected: false, activeProviders: activeProviders) _ = store.setMergedOverviewProviderSelection( - provider: .opencode, + provider: .grok, isSelected: true, activeProviders: activeProviders) - #expect(store.mergedOverviewSelectedProviders == [.codex, .cursor, .opencode]) + let expectedSelection: [UsageProvider] = [.codex, .cursor, .opencode, .warp, .gemini, .grok] + #expect(store.mergedOverviewSelectedProviders == expectedSelection) let reducedActiveProviders: [UsageProvider] = [.codex, .claude, .cursor] let resolvedWhenReduced = store.reconcileMergedOverviewSelectedProviders( activeProviders: reducedActiveProviders) #expect(resolvedWhenReduced == [.codex, .claude, .cursor]) - #expect(store.mergedOverviewSelectedProviders == [.codex, .cursor, .opencode]) + #expect(store.mergedOverviewSelectedProviders == expectedSelection) let resolvedWhenRestored = store.resolvedMergedOverviewProviders(activeProviders: activeProviders) - #expect(resolvedWhenRestored == [.codex, .cursor, .opencode]) + #expect(resolvedWhenRestored == expectedSelection) } @Test @@ -491,18 +673,24 @@ struct SettingsStoreTests { zaiTokenStore: NoopZaiTokenStore(), syntheticTokenStore: NoopSyntheticTokenStore()) - let activeProviders: [UsageProvider] = [.codex, .claude, .cursor, .opencode] + let activeProviders: [UsageProvider] = [.codex, .claude, .cursor, .opencode, .warp, .gemini, .grok] _ = store.setMergedOverviewProviderSelection( provider: .codex, isSelected: false, activeProviders: activeProviders) - #expect(store.resolvedMergedOverviewProviders(activeProviders: activeProviders) == [.claude, .cursor]) + #expect(store.resolvedMergedOverviewProviders(activeProviders: activeProviders) == [ + .claude, + .cursor, + .opencode, + .warp, + .gemini, + ]) let resolvedWhenEmpty = store.reconcileMergedOverviewSelectedProviders(activeProviders: []) #expect(resolvedWhenEmpty == []) let resolvedAfterReenable = store.resolvedMergedOverviewProviders(activeProviders: activeProviders) - #expect(resolvedAfterReenable == [.codex, .claude, .cursor]) + #expect(resolvedAfterReenable == [.codex, .claude, .cursor, .opencode, .warp, .gemini]) } @Test @@ -560,14 +748,38 @@ struct SettingsStoreTests { #expect(store.quotaWarningWindowEnabled(.session) == true) #expect(store.quotaWarningWindowEnabled(.weekly) == true) #expect(store.quotaWarningSoundEnabled == true) + #expect(store.quotaWarningOnScreenAlertEnabled == false) #expect(store.quotaWarningMarkersVisible == true) #expect(defaults.array(forKey: "quotaWarningThresholds") as? [Int] == [50, 20]) #expect(defaults.object(forKey: "quotaWarningSessionEnabled") as? Bool == true) #expect(defaults.object(forKey: "quotaWarningWeeklyEnabled") as? Bool == true) #expect(defaults.bool(forKey: "quotaWarningSoundEnabled") == true) + #expect(defaults.object(forKey: "quotaWarningOnScreenAlertEnabled") as? Bool == false) #expect(defaults.object(forKey: "quotaWarningMarkersVisible") as? Bool == true) } + @Test + func `on-screen quota warning preference persists`() throws { + let suite = "SettingsStoreTests-quota-warning-on-screen-alert" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + let configStore = testConfigStore(suiteName: suite) + let store = SettingsStore( + userDefaults: defaults, + configStore: configStore, + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + + store.quotaWarningOnScreenAlertEnabled = true + + let reloaded = SettingsStore( + userDefaults: defaults, + configStore: configStore, + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + #expect(reloaded.quotaWarningOnScreenAlertEnabled == true) + } + @Test func `global quota warning windows persist independently`() throws { let suite = "SettingsStoreTests-quota-warning-window-enabled" @@ -626,14 +838,70 @@ struct SettingsStoreTests { store.quotaWarningThresholds = [50, 20] #expect(store.resolvedQuotaWarningThresholds(provider: .codex, window: .session) == [50, 20]) + #expect(store.explicitQuotaWarningThresholds(provider: .codex, window: .session) == nil) store.setQuotaWarningThresholds(provider: .codex, window: .session, thresholds: [10]) + #expect(store.explicitQuotaWarningThresholds(provider: .codex, window: .session) == [10]) #expect(store.resolvedQuotaWarningThresholds(provider: .codex, window: .session) == [10]) #expect(store.resolvedQuotaWarningThresholds(provider: .codex, window: .weekly) == [50, 20]) store.setQuotaWarningThresholds(provider: .codex, window: .session, thresholds: nil) + #expect(store.explicitQuotaWarningThresholds(provider: .codex, window: .session) == nil) + #expect(store.resolvedQuotaWarningThresholds(provider: .codex, window: .session) == [50, 20]) + } + + @Test + func `provider quota warning stale editor save does not restore cleared override`() throws { + let suite = "SettingsStoreTests-quota-warning-provider-cleared-override" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + let configStore = testConfigStore(suiteName: suite) + let store = SettingsStore( + userDefaults: defaults, + configStore: configStore, + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + store.quotaWarningThresholds = [50, 20] + store.setQuotaWarningOverride(provider: .codex, window: .session, thresholds: [70, 30], enabled: true) + let staleEditorThresholds = store.resolvedQuotaWarningThresholds(provider: .codex, window: .session) + + store.setQuotaWarningOverride(provider: .codex, window: .session, thresholds: nil, enabled: nil) + store.setQuotaWarningThresholdsIfOverridden( + provider: .codex, + window: .session, + thresholds: staleEditorThresholds) + + #expect(store.hasQuotaWarningOverride(provider: .codex, window: .session) == false) #expect(store.resolvedQuotaWarningThresholds(provider: .codex, window: .session) == [50, 20]) } + @Test + func `provider quota warning inherited thresholds stay inherited after no-op editor save`() throws { + let suite = "SettingsStoreTests-quota-warning-provider-inherited-thresholds" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + let configStore = testConfigStore(suiteName: suite) + let store = SettingsStore( + userDefaults: defaults, + configStore: configStore, + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + store.quotaWarningThresholds = [50, 20] + store.setQuotaWarningOverride(provider: .codex, window: .session, thresholds: nil, enabled: true) + + let resolvedEditorThresholds = store.resolvedQuotaWarningThresholds(provider: .codex, window: .session) + store.setQuotaWarningThresholdsIfOverridden( + provider: .codex, + window: .session, + thresholds: resolvedEditorThresholds) + + let sessionConfig = store.providerConfig(for: .codex)?.quotaWarnings?.session + #expect(sessionConfig?.enabled == true) + #expect(sessionConfig?.thresholds == nil) + + store.quotaWarningThresholds = [80, 40] + #expect(store.resolvedQuotaWarningThresholds(provider: .codex, window: .session) == [80, 40]) + } + @Test func `global quota warning thresholds resolve independently by window`() throws { let suite = "SettingsStoreTests-quota-warning-window-thresholds" @@ -820,6 +1088,61 @@ struct SettingsStoreTests { #expect(notifications.get() == 0) } + @Test + func `config notifications classify order and provider changes`() throws { + let suite = "SettingsStoreTests-config-change-impact" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + let store = SettingsStore( + userDefaults: defaults, + configStore: testConfigStore(suiteName: suite), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + let impacts = BoolRecorder() + let token = NotificationCenter.default.addObserver( + forName: .codexbarProviderConfigDidChange, + object: store, + queue: .main) + { notification in + impacts.append(notification.userInfo?["affectsBackgroundWork"] as? Bool ?? true) + } + defer { NotificationCenter.default.removeObserver(token) } + + store.setProviderOrder(Array(store.orderedProviders().reversed())) + store.codexUsageDataSource = .cli + + #expect(impacts.get() == [false, true]) + } + + @Test + func `external config ignores order-only changes for background work`() throws { + let suite = "SettingsStoreTests-external-config-impact" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + let store = SettingsStore( + userDefaults: defaults, + configStore: testConfigStore(suiteName: suite), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + + let initialConfigRevision = store.configRevision + let initialBackgroundRevision = store.backgroundWorkSettingsRevision + var reordered = store.configSnapshot + reordered.providers.reverse() + store.applyExternalConfig(reordered, reason: "order-only") + + #expect(store.configRevision == initialConfigRevision + 1) + #expect(store.backgroundWorkSettingsRevision == initialBackgroundRevision) + #expect(store.orderedProviders() == reordered.providers.map(\.id)) + + var changed = store.configSnapshot + let codexIndex = try #require(changed.providers.firstIndex(where: { $0.id == .codex })) + changed.providers[codexIndex].source = .cli + store.applyExternalConfig(changed, reason: "provider-source", affectsBackgroundWork: false) + + #expect(store.backgroundWorkSettingsRevision == initialBackgroundRevision + 1) + } + @Test func `persists zai API region across instances`() throws { let suite = "SettingsStoreTests-zai-region" @@ -1007,6 +1330,39 @@ struct SettingsStoreTests { #expect(store.openAIWebBatterySaverEnabled == false) } + @Test + func `codex spark usage visibility defaults on persists and refreshes only menus`() async throws { + let suite = "SettingsStoreTests-codex-spark-usage-visible" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + let configStore = testConfigStore(suiteName: suite) + let store = SettingsStore( + userDefaults: defaults, + configStore: configStore, + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + + #expect(store.codexSparkUsageVisible) + let backgroundRevision = store.backgroundWorkSettingsRevision + let menuDidChange = ObservationFlag() + withObservationTracking { + _ = store.menuObservationToken + } onChange: { + menuDidChange.set() + } + store.codexSparkUsageVisible = false + try? await Task.sleep(nanoseconds: 50_000_000) + #expect(store.backgroundWorkSettingsRevision == backgroundRevision) + #expect(menuDidChange.get()) + + let reloaded = SettingsStore( + userDefaults: defaults, + configStore: configStore, + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + #expect(reloaded.codexSparkUsageVisible == false) + } + @Test func `menu observation token updates on defaults change`() async throws { let suite = "SettingsStoreTests-observation-defaults" @@ -1034,6 +1390,61 @@ struct SettingsStoreTests { #expect(didChange.get() == true) } + @Test + func `menu observation token updates on cost summary display style changes`() async throws { + let suite = "SettingsStoreTests-observation-cost-summary-display-style" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + let configStore = testConfigStore(suiteName: suite) + + let store = SettingsStore( + userDefaults: defaults, + configStore: configStore, + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + + let didChange = ObservationFlag() + + withObservationTracking { + _ = store.menuObservationToken + } onChange: { + didChange.set() + } + + store.costSummaryDisplayStyle = .costSubmenu + try? await Task.sleep(nanoseconds: 50_000_000) + + #expect(didChange.get() == true) + } + + @Test + func `menu observation token ignores merged switcher selection churn`() async throws { + let suite = "SettingsStoreTests-observation-switcher-selection" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + let configStore = testConfigStore(suiteName: suite) + + let store = SettingsStore( + userDefaults: defaults, + configStore: configStore, + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + + let didChange = ObservationFlag() + + withObservationTracking { + _ = store.menuObservationToken + } onChange: { + didChange.set() + } + + store.selectedMenuProvider = .claude + store.mergedMenuLastSelectedWasOverview.toggle() + try? await Task.sleep(nanoseconds: 50_000_000) + + #expect(didChange.get() == false) + } + @Test func `menu observation token updates on per-window quota threshold changes`() async throws { let suite = "SettingsStoreTests-observation-quota-threshold-windows" @@ -1068,6 +1479,33 @@ struct SettingsStoreTests { await expectObservation(for: .weekly, thresholds: [80, 40]) } + @Test + func `quota warning threshold setters ignore unchanged values`() async throws { + let suite = "SettingsStoreTests-observation-quota-threshold-noop" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + let configStore = testConfigStore(suiteName: suite) + + let store = SettingsStore( + userDefaults: defaults, + configStore: configStore, + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + store.setQuotaWarningThresholds(.session, thresholds: [70, 30]) + + let didChange = ObservationFlag() + withObservationTracking { + _ = store.menuObservationToken + } onChange: { + didChange.set() + } + + store.setQuotaWarningThresholds(.session, thresholds: [70, 30]) + try await Task.sleep(nanoseconds: 50_000_000) + + #expect(didChange.get() == false) + } + @Test func `menu observation token updates on weekly progress work days changes`() async throws { let suite = "SettingsStoreTests-observation-weekly-progress-work-days" @@ -1245,4 +1683,105 @@ struct SettingsStoreTests { let metadata = try #require(ProviderDescriptorRegistry.metadata[.alibaba]) #expect(store.isProviderEnabled(provider: .alibaba, metadata: metadata)) } + + @Test + func `cost comparison periods default off and persist`() throws { + let suite = "SettingsStoreTests-cost-comparison-periods" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + let configStore = testConfigStore(suiteName: suite) + let storeA = SettingsStore( + userDefaults: defaults, + configStore: configStore, + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + + #expect(!storeA.costComparisonPeriodsEnabled) + storeA.costComparisonPeriodsEnabled = true + + let storeB = SettingsStore( + userDefaults: defaults, + configStore: configStore, + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + #expect(storeB.costComparisonPeriodsEnabled) + } + + @Test + func `cost summary display style defaults to both and persists`() throws { + let suite = "SettingsStoreTests-cost-summary-display-style" + let defaultsA = try #require(UserDefaults(suiteName: suite)) + defaultsA.removePersistentDomain(forName: suite) + let configStore = testConfigStore(suiteName: suite) + let storeA = SettingsStore( + userDefaults: defaultsA, + configStore: configStore, + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + + #expect(storeA.costSummaryDisplayStyle == .both) + + storeA.costSummaryDisplayStyle = .costSubmenu + + let defaultsB = try #require(UserDefaults(suiteName: suite)) + let storeB = SettingsStore( + userDefaults: defaultsB, + configStore: configStore, + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + + #expect(storeB.costSummaryDisplayStyle == .costSubmenu) + + storeB.costSummaryDisplayStyleRaw = "legacy-style" + #expect(storeB.costSummaryDisplayStyle == .both) + } + + @Test + func `missing cost summary display style preserves existing enabled cost summary`() throws { + let suite = "SettingsStoreTests-cost-summary-display-style-upgrade" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + defaults.set(true, forKey: "tokenCostUsageEnabled") + defaults.removeObject(forKey: "costSummaryDisplayStyle") + let configStore = testConfigStore(suiteName: suite) + + let store = SettingsStore( + userDefaults: defaults, + configStore: configStore, + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + + #expect(store.costSummaryDisplayStyle == .both) + #expect(defaults.string(forKey: "costSummaryDisplayStyle") == CostSummaryDisplayStyle.both.rawValue) + } + + @Test + func `enabling cost summary preserves both display style across relaunch`() throws { + let suite = "SettingsStoreTests-cost-summary-display-style-enable" + let defaultsA = try #require(UserDefaults(suiteName: suite)) + defaultsA.removePersistentDomain(forName: suite) + let configStore = testConfigStore(suiteName: suite) + let storeA = SettingsStore( + userDefaults: defaultsA, + configStore: configStore, + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + + #expect(storeA.costSummaryDisplayStyle == .both) + #expect(defaultsA.string(forKey: "costSummaryDisplayStyle") == nil) + + storeA.costUsageEnabled = true + + #expect(storeA.costSummaryDisplayStyle == .both) + #expect(defaultsA.string(forKey: "costSummaryDisplayStyle") == nil) + + let defaultsB = try #require(UserDefaults(suiteName: suite)) + let storeB = SettingsStore( + userDefaults: defaultsB, + configStore: configStore, + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + + #expect(storeB.costSummaryDisplayStyle == .both) + } } diff --git a/Tests/CodexBarTests/SettingsWindowAppearanceTests.swift b/Tests/CodexBarTests/SettingsWindowAppearanceTests.swift new file mode 100644 index 0000000000..67f47e0757 --- /dev/null +++ b/Tests/CodexBarTests/SettingsWindowAppearanceTests.swift @@ -0,0 +1,180 @@ +import AppKit +import SwiftUI +import Testing +@testable import CodexBar + +@MainActor +struct SettingsWindowAppearanceTests { + @Test + func `settings sidebar uses a fixed noncollapsible width`() { + #expect(SettingsPane.sidebarWidth == 260) + #expect(SettingsPane.windowMinWidth > SettingsPane.sidebarWidth) + #expect(SettingsPane.detailMaxWidth > SettingsPane.windowMinWidth - SettingsPane.sidebarWidth) + } + + @Test + func `settings window sizing repairs collapsed saved frames`() { + let window = NSWindow( + contentRect: NSRect(x: 120, y: 160, width: 180, height: 140), + styleMask: [.titled], + backing: .buffered, + defer: false) + let originalMaxY = window.frame.maxY + + SettingsWindowSizing.enforceMinimumSize(window) + + #expect(window.minSize.width == SettingsPane.windowMinWidth) + #expect(window.minSize.height >= SettingsPane.windowMinHeight) + #expect(window.frame.width >= window.minSize.width) + #expect(window.frame.height >= window.minSize.height) + #expect(abs(window.frame.maxY - originalMaxY) < 1) + } + + @Test + func `settings window sizing leaves valid frames alone`() { + let window = NSWindow( + contentRect: NSRect(x: 120, y: 160, width: SettingsPane.windowWidth, height: SettingsPane.windowHeight), + styleMask: [.titled], + backing: .buffered, + defer: false) + let originalFrame = window.frame + + SettingsWindowSizing.enforceMinimumSize(window) + + #expect(window.frame == originalFrame) + #expect(window.minSize.width == SettingsPane.windowMinWidth) + #expect(window.minSize.height >= SettingsPane.windowMinHeight) + } + + @Test + func `settings window sizing does not mutate content split views`() { + let window = NSWindow( + contentRect: NSRect(x: 120, y: 160, width: 180, height: 140), + styleMask: [.titled], + backing: .buffered, + defer: false) + let splitView = NSSplitView( + frame: NSRect(x: 0, y: 0, width: SettingsPane.windowWidth, height: SettingsPane.windowHeight)) + splitView.isVertical = true + let sidebar = NSView(frame: NSRect(x: 0, y: 0, width: 0, height: SettingsPane.windowHeight)) + let detail = NSView( + frame: NSRect(x: 0, y: 0, width: SettingsPane.windowWidth, height: SettingsPane.windowHeight)) + splitView.addSubview(sidebar) + splitView.addSubview(detail) + window.contentView = splitView + + SettingsWindowSizing.enforceMinimumSize(window) + + #expect(window.frame.width >= window.minSize.width) + #expect(sidebar.frame.width == 0) + } + + @Test + func `bridge pulses exact effective appearance then restores inheritance`() { + let application = NSApplication.shared + let effectiveAppearance = application.effectiveAppearance + let staleSource = NSView() + staleSource.appearance = NSAppearance(named: .aqua) + let resetCapture = ResetCapture() + let bridge = SettingsWindowAppearanceView { resetCapture.actions.append($0) } + + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 400, height: 300), + styleMask: [.titled], + backing: .buffered, + defer: false) + window.appearance = NSAppearance(named: .aqua) + window.appearanceSource = staleSource + window.contentView = bridge + + let pulseMatchesEffectiveAppearance = window.appearance === effectiveAppearance + let sourceIsApplication = (window.appearanceSource as AnyObject?) === application + #expect(pulseMatchesEffectiveAppearance) + #expect(sourceIsApplication) + #expect(resetCapture.actions.count == 1) + + resetCapture.actions[0]() + + #expect(window.appearance == nil) + #expect(window.viewsNeedDisplay) + } + + @Test + func `bridge updates window title without pulsing appearance on pane changes`() { + let resetCapture = ResetCapture() + let bridge = SettingsWindowAppearanceView { resetCapture.actions.append($0) } + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 400, height: 300), + styleMask: [.titled], + backing: .buffered, + defer: false) + window.contentView = bridge + resetCapture.actions.removeAll() + + bridge.refreshWindowAppearance(for: .light, windowTitle: "Display") + #expect(resetCapture.actions.count == 1) + + bridge.refreshWindowAppearance(for: .light, windowTitle: "General") + + #expect(window.title == "General") + #expect(resetCapture.actions.count == 1) + } + + @Test + func `settings window style remains resizable`() { + let bridge = SettingsWindowAppearanceView() + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 400, height: 300), + styleMask: [.titled], + backing: .buffered, + defer: false) + + window.contentView = bridge + + #expect(window.styleMask.contains(.resizable)) + } + + @Test + func `settings window extends content behind the titlebar for the edge-to-edge sidebar`() { + let bridge = SettingsWindowAppearanceView() + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 400, height: 300), + styleMask: [.titled], + backing: .buffered, + defer: false) + + window.contentView = bridge + + #expect(window.styleMask.contains(.fullSizeContentView)) + #expect(window.titlebarAppearsTransparent) + } + + @Test + func `repeated theme updates cannot leave an explicit appearance`() { + let resetCapture = ResetCapture() + let bridge = SettingsWindowAppearanceView { resetCapture.actions.append($0) } + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 400, height: 300), + styleMask: [.titled], + backing: .buffered, + defer: false) + window.contentView = bridge + + bridge.refreshWindowAppearance(for: .light) + bridge.refreshWindowAppearance(for: .light) + bridge.refreshWindowAppearance(for: .dark) + #expect(resetCapture.actions.count == 3) + for action in resetCapture.actions { + action() + } + + let sourceIsApplication = (window.appearanceSource as AnyObject?) === NSApplication.shared + #expect(window.appearance == nil) + #expect(sourceIsApplication) + } +} + +@MainActor +private final class ResetCapture { + var actions: [SettingsWindowAppearance.ResetAction] = [] +} diff --git a/Tests/CodexBarTests/SettingsWindowOpeningTests.swift b/Tests/CodexBarTests/SettingsWindowOpeningTests.swift new file mode 100644 index 0000000000..90aa007216 --- /dev/null +++ b/Tests/CodexBarTests/SettingsWindowOpeningTests.swift @@ -0,0 +1,40 @@ +import AppKit +import Testing +@testable import CodexBar + +@MainActor +struct SettingsWindowOpeningTests { + @Test + func `recreated keepalive shell is configured and missing relay invokes settings fallback`() { + let keepaliveShell = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 500, height: 500), + styleMask: [.titled], + backing: .buffered, + defer: false) + let configuratorView = KeepaliveWindowConfiguratorView(windowProvider: { _ in keepaliveShell }) + configuratorView.viewDidMoveToWindow() + + #expect(keepaliveShell.identifier?.rawValue == "CodexBarLifecycleKeepalive") + #expect(keepaliveShell.styleMask == [.borderless]) + #expect(keepaliveShell.alphaValue == 0) + #expect(keepaliveShell.frame.size == NSSize(width: 1, height: 1)) + + let settingsWindow = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 640, height: 480), + styleMask: [.titled], + backing: .buffered, + defer: false) + var presentedWindow: NSWindow? + let opener = SettingsWindowOpener( + notification: { false }, + appKit: { + presentedWindow = settingsWindow + return true + }) + + let outcome = opener.open(preferred: .notification) + + #expect(outcome == .fallback) + #expect(presentedWindow === settingsWindow) + } +} diff --git a/Tests/CodexBarTests/ShareStatsTests.swift b/Tests/CodexBarTests/ShareStatsTests.swift new file mode 100644 index 0000000000..adb0a24651 --- /dev/null +++ b/Tests/CodexBarTests/ShareStatsTests.swift @@ -0,0 +1,413 @@ +import AppKit +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +struct ShareStatsTests { + @Test + func `builder preserves native currencies and unavailable spend`() throws { + let subscriptionNames = try [ + "codex:one": #require(Self.subscriptionName(provider: .codex, rawName: "pro")), + "cursor": #require(Self.subscriptionName(provider: .cursor, rawName: "Cursor Pro")), + "claude": #require(Self.subscriptionName(provider: .claude, rawName: "Claude Max")), + ] + let payload = try #require(ShareStatsBuilder.make( + model: Self.dashboard, + subscriptionNames: subscriptionNames)) + + #expect(payload.days == 30) + #expect(payload.totalTokens == nil) + #expect(payload.currencies == [ + ShareStatsCurrencyPayload(currencyCode: "GBP", estimatedCost: 12, coveredDayCount: 10), + ShareStatsCurrencyPayload(currencyCode: "USD", estimatedCost: nil, coveredDayCount: 0), + ]) + #expect(payload.providers.map(\.providerName) == ["Claude", "Codex · #1", "Cursor"]) + #expect(payload.providers.map(\.subscriptionName) == ["Max", "Pro 20x", "Cursor Pro"]) + #expect(payload.providers.last?.estimatedCost == nil) + #expect(payload.topModels.map(\.modelName).prefix(2) == ["Claude", "GPT"]) + + let text = ShareStatsFormatting.text(payload) + #expect(text.contains("GBP: £12.00 estimated · coverage 10/30 days")) + #expect(text.contains("Claude · Max: 300 tokens · ~£12.00 est · 10/30 days")) + #expect(text.contains("USD: Spend unavailable · coverage 0/30 days")) + #expect(text.contains("Cursor · Cursor Pro: Spend unavailable")) + #expect(!text.contains("£12.00 +")) + } + + @Test + func `payload sanitizer excludes emails identifiers paths and prompts`() throws { + let model = Self.dashboard(models: [ + "gpt-5.4", + "person@example.com", + "/Users/peter/private/model", + "550e8400-e29b-41d4-a716-446655440000", + "summarize my secret project", + "abcdefabcdefabcdefabcdef", + "https://intranet.example/client-model-2", + "acme/private-model-v2", + "acme-private-model-v2", + "gpt-acme-private-model-v2", + ]) + var subscriptionNames = try [ + "claude": #require(Self.subscriptionName(provider: .claude, rawName: "Claude Max")), + ] + if let unsafeCodexName = Self.subscriptionName(provider: .codex, rawName: "person@example.com") { + subscriptionNames["codex:one"] = unsafeCodexName + } + if let unsafeCursorName = Self.subscriptionName(provider: .cursor, rawName: "/Users/peter/plan") { + subscriptionNames["cursor"] = unsafeCursorName + } + let payload = try #require(ShareStatsBuilder.make( + model: model, + subscriptionNames: subscriptionNames)) + let text = ShareStatsFormatting.text(payload) + + #expect(payload.topModels.map(\.modelName) == ["Claude", "GPT"]) + #expect(payload.topModels.last?.totalTokens == 400) + #expect(payload.topModels.last?.estimatedCost == 8) + #expect(payload.providers.map(\.subscriptionName) == ["Max", nil, nil]) + #expect(!text.contains("person@example.com")) + #expect(!text.contains("/Users/")) + #expect(!text.contains("550e8400")) + #expect(!text.contains("secret project")) + #expect(!text.contains("abcdefabcdef")) + #expect(!text.contains("intranet")) + #expect(!text.contains("acme")) + } + + @Test + func `subscription labels require a plan tier provider contract`() { + #expect(Self.subscriptionName(provider: .codex, rawName: "pro")?.displayName == "Pro 20x") + #expect(Self.subscriptionName(provider: .codex, rawName: "Plus Plan")?.displayName == "Plus") + #expect(Self.subscriptionName(provider: .cursor, rawName: "Cursor Pro")?.displayName == "Cursor Pro") + #expect(Self.subscriptionName(provider: .gemini, rawName: "Paid")?.displayName == "Paid") + #expect(Self.subscriptionName(provider: .copilot, rawName: "Business")?.displayName == "Business") + #expect(Self.subscriptionName(provider: .perplexity, rawName: "Max")?.displayName == "Max") + #expect(Self.subscriptionName(provider: .windsurf, rawName: "Teams")?.displayName == "Teams") + #expect(Self.subscriptionName(provider: .zed, rawName: "Zed Pro")?.displayName == "Zed Pro") + #expect(Self.subscriptionName(provider: .minimax, rawName: "MiniMax Star")?.displayName == "MiniMax Star") + #expect(Self.subscriptionName(provider: .synthetic, rawName: "Starter")?.displayName == "Starter") + #expect(Self.subscriptionName(provider: .openrouter, rawName: "Team") == nil) + #expect(Self.subscriptionName(provider: .claude, rawName: "name@example.com") == nil) + #expect(Self.subscriptionName(provider: .claude, rawName: "Alice Smith") == nil) + #expect(Self.subscriptionName(provider: .codex, rawName: "123456789") == nil) + #expect(Self.subscriptionName(provider: .cursor, rawName: "sk-live-example") == nil) + #expect(Self.subscriptionName(provider: .claude, rawName: "internal.example") == nil) + #expect(Self.subscriptionName(provider: .claude, rawName: "Max", accountOrganization: "Max") == nil) + } + + @Test + func `subscription label uses first plan bearing snapshot`() { + let unidentified = UsageSnapshot(primary: nil, secondary: nil, updatedAt: Self.date) + let fallback = Self.snapshot(provider: .codex, rawName: "pro") + + let name = ShareStatsSubscriptionName.first( + from: [unidentified, fallback], + provider: .codex) + #expect(name?.displayName == "Pro 20x") + } + + @Test + func `bedrock regional model identifiers map to public families`() { + #expect(ShareStatsSanitizer.modelName("us.amazon.nova-2-lite-v1:0") == "Amazon Nova") + #expect(ShareStatsSanitizer.modelName("global.anthropic.claude-sonnet-4-v1:0") == "Claude") + } + + @Test + func `overflowed model family totals stay unavailable`() throws { + let rows = [ + SpendDashboardModel.ModelRow( + rank: 1, + provider: .codex, + providerName: "Codex", + modelName: "gpt-5.4", + totalTokens: Int.max, + totalCost: Double.greatestFiniteMagnitude), + SpendDashboardModel.ModelRow( + rank: 2, + provider: .codex, + providerName: "Codex", + modelName: "gpt-5.4-mini", + totalTokens: 1, + totalCost: Double.greatestFiniteMagnitude), + SpendDashboardModel.ModelRow( + rank: 3, + provider: .codex, + providerName: "Codex", + modelName: "gpt-5.4-nano", + totalTokens: 5, + totalCost: 5), + ] + let group = SpendDashboardModel.CurrencyGroup( + currencyCode: "USD", + providers: [ + SpendDashboardModel.ProviderRow( + id: "codex", + rank: 1, + provider: .codex, + displayName: "Codex", + totalTokens: 1, + totalCost: nil, + coveredDayCount: 7), + ], + models: rows, + dailyPoints: [], + totalTokens: 1, + totalCost: nil, + coveredDayCount: 0, + chartDomain: Self.date...Self.date, + modelHistoryCompleteness: .complete) + let payload = try #require(ShareStatsBuilder.make( + model: SpendDashboardModel(requestedDays: 7, groups: [group]))) + + #expect(payload.topModels.isEmpty) + } + + @Test + func `empty dashboard has no share payload`() { + #expect(ShareStatsBuilder.make(model: SpendDashboardModel(requestedDays: 30, groups: [])) == nil) + } + + @Test + func `cost only models do not enter token usage rankings`() throws { + let model = SpendDashboardModel(requestedDays: 7, groups: [ + SpendDashboardModel.CurrencyGroup( + currencyCode: "USD", + providers: [ + SpendDashboardModel.ProviderRow( + id: "codex", + rank: 1, + provider: .codex, + displayName: "Codex", + totalTokens: 10, + totalCost: .nan, + coveredDayCount: 7), + ], + models: [ + SpendDashboardModel.ModelRow( + rank: 1, + provider: .codex, + providerName: "Codex", + modelName: "gpt-5.4", + totalTokens: 10, + totalCost: .infinity), + SpendDashboardModel.ModelRow( + rank: 2, + provider: .codex, + providerName: "Codex", + modelName: "gpt-5.4-mini", + totalTokens: nil, + totalCost: 2), + SpendDashboardModel.ModelRow( + rank: 3, + provider: .codex, + providerName: "Codex", + modelName: "gpt-5.4-nano", + totalTokens: nil, + totalCost: nil), + ], + dailyPoints: [], + totalTokens: 10, + totalCost: -.infinity, + coveredDayCount: 7, + chartDomain: Self.date...Self.date, + modelHistoryCompleteness: .complete), + ]) + let payload = try #require(ShareStatsBuilder.make(model: model)) + + #expect(payload.providers.first?.estimatedCost == nil) + #expect(payload.topModels.first?.totalTokens == 10) + #expect(payload.topModels.first?.estimatedCost == nil) + #expect(payload.topModels.count == 1) + #expect(payload.currencies.first?.estimatedCost == nil) + #expect(!ShareStatsFormatting.text(payload).lowercased().contains("nan")) + #expect(!ShareStatsFormatting.text(payload).lowercased().contains("inf")) + } + + @Test + func `partial model history does not enter shared rankings`() throws { + let group = SpendDashboardModel.CurrencyGroup( + currencyCode: "USD", + providers: [ + SpendDashboardModel.ProviderRow( + id: "codex", + rank: 1, + provider: .codex, + displayName: "Codex", + totalTokens: 10, + totalCost: 2, + coveredDayCount: 7), + ], + models: [ + SpendDashboardModel.ModelRow( + rank: 1, + provider: .codex, + providerName: "Codex", + modelName: "gpt-5.4", + totalTokens: 10, + totalCost: 2), + ], + dailyPoints: [], + totalTokens: nil, + totalCost: nil, + coveredDayCount: 7, + chartDomain: Self.date...Self.date, + modelHistoryCompleteness: .incomplete) + let payload = try #require(ShareStatsBuilder.make( + model: SpendDashboardModel(requestedDays: 7, groups: [group]))) + + #expect(payload.providers.count == 1) + #expect(payload.topModels.isEmpty) + } + + @Test @MainActor + func `renderer creates social card PNG`() throws { + let payload = try #require(ShareStatsBuilder.make(model: Self.dashboard)) + let data = try #require(ShareStatsRenderer.pngData(for: payload)) + + #expect(ShareStatsCardView.size == CGSize(width: 1200, height: 630)) + #expect(data.starts(with: [0x89, 0x50, 0x4E, 0x47])) + let bitmap = try #require(NSBitmapImageRep(data: data)) + #expect(bitmap.pixelsWide == 1200) + #expect(bitmap.pixelsHigh == 630) + var sampledRGB: Set = [] + for y in stride(from: 0, to: bitmap.pixelsHigh, by: 19) { + for x in stride(from: 0, to: bitmap.pixelsWide, by: 23) { + guard let color = bitmap.colorAt(x: x, y: y)?.usingColorSpace(.deviceRGB) else { continue } + let red = UInt32((color.redComponent * 255).rounded()) + let green = UInt32((color.greenComponent * 255).rounded()) + let blue = UInt32((color.blueComponent * 255).rounded()) + sampledRGB.insert((red << 16) | (green << 8) | blue) + if sampledRGB.count > 8 { + break + } + } + if sampledRGB.count > 8 { + break + } + } + #expect(sampledRGB.count > 1) + } + + @Test @MainActor + func `provider rows leave room for overflow summary`() { + #expect(ShareStatsCardView.providerDisplayLimit(for: 5) == 5) + #expect(ShareStatsCardView.providerDisplayLimit(for: 6) == 4) + #expect(ShareStatsCardView.providerDisplayLimit(for: 12) == 4) + } + + @Test @MainActor + func `model colors use provider identity instead of decorated account name`() throws { + let payload = try #require(ShareStatsBuilder.make(model: Self.dashboard)) + let codexModel = try #require(payload.topModels.first { $0.provider == .codex }) + + #expect(ShareStatsCardView.providerPaletteIndex(for: codexModel, providers: payload.providers) == 1) + } + + @Test + func `overall token total becomes unavailable on overflow`() { + #expect(ShareStatsBuilder.combinedTotalTokens([Int.max, 1]) == nil) + #expect(ShareStatsBuilder.combinedTotalTokens([10, nil]) == nil) + #expect(ShareStatsBuilder.combinedTotalTokens([10, 20]) == 30) + } + + private static let date = Date(timeIntervalSince1970: 1_783_382_400) + + private static func subscriptionName( + provider: UsageProvider, + rawName: String, + accountOrganization: String? = nil) -> ShareStatsSubscriptionName? + { + ShareStatsSubscriptionName.from( + snapshot: self.snapshot( + provider: provider, + rawName: rawName, + accountOrganization: accountOrganization), + provider: provider) + } + + private static func snapshot( + provider: UsageProvider, + rawName: String, + accountOrganization: String? = nil) -> UsageSnapshot + { + let identity = ProviderIdentitySnapshot( + providerID: provider, + accountEmail: nil, + accountOrganization: accountOrganization, + loginMethod: rawName) + return UsageSnapshot(primary: nil, secondary: nil, updatedAt: self.date, identity: identity) + } + + private static var dashboard: SpendDashboardModel { + self.dashboard(models: ["gpt-5.4"]) + } + + private static func dashboard(models: [String]) -> SpendDashboardModel { + SpendDashboardModel(requestedDays: 30, groups: [ + SpendDashboardModel.CurrencyGroup( + currencyCode: "GBP", + providers: [ + SpendDashboardModel.ProviderRow( + id: "claude", + rank: 1, + provider: .claude, + displayName: "Claude", + totalTokens: 300, + totalCost: 12, + coveredDayCount: 10), + ], + models: [ + SpendDashboardModel.ModelRow( + rank: 1, + provider: .claude, + providerName: "Claude", + modelName: "claude-sonnet-4", + totalTokens: 1000, + totalCost: 1), + ], + dailyPoints: [], + totalTokens: 300, + totalCost: 12, + coveredDayCount: 10, + chartDomain: self.date...self.date, + modelHistoryCompleteness: .complete), + SpendDashboardModel.CurrencyGroup( + currencyCode: "USD", + providers: [ + SpendDashboardModel.ProviderRow( + id: "codex:one", + rank: 1, + provider: .codex, + displayName: "Codex · #1", + totalTokens: 200, + totalCost: 4, + coveredDayCount: 30), + SpendDashboardModel.ProviderRow( + id: "cursor", + rank: 2, + provider: .cursor, + displayName: "Cursor", + totalTokens: nil, + totalCost: nil, + coveredDayCount: 0), + ], + models: models.enumerated().map { index, name in + SpendDashboardModel.ModelRow( + rank: index + 1, + provider: .codex, + providerName: "Codex", + modelName: name, + totalTokens: 200, + totalCost: 4) + }, + dailyPoints: [], + totalTokens: nil, + totalCost: nil, + coveredDayCount: 0, + chartDomain: self.date...self.date, + modelHistoryCompleteness: .complete), + ]) + } +} diff --git a/Tests/CodexBarTests/ShellCommandForegroundTests.swift b/Tests/CodexBarTests/ShellCommandForegroundTests.swift new file mode 100644 index 0000000000..691593dd37 --- /dev/null +++ b/Tests/CodexBarTests/ShellCommandForegroundTests.swift @@ -0,0 +1,15 @@ +import Darwin +import Testing +@testable import CodexBarCore + +@Suite(.serialized) +struct ShellCommandForegroundTests { + @Test + func `shell probe requests a detached session`() { + let flags = ShellCommandLocator.test_shellSpawnFlags + + #expect(flags & Int16(POSIX_SPAWN_SETSID) != 0) + #expect(flags & Int16(POSIX_SPAWN_CLOEXEC_DEFAULT) != 0) + #expect(flags & Int16(POSIX_SPAWN_SETPGROUP) == 0) + } +} diff --git a/Tests/CodexBarTests/ShellCommandLocatorProcessTests.swift b/Tests/CodexBarTests/ShellCommandLocatorProcessTests.swift new file mode 100644 index 0000000000..935f225599 --- /dev/null +++ b/Tests/CodexBarTests/ShellCommandLocatorProcessTests.swift @@ -0,0 +1,103 @@ +#if canImport(Darwin) +import Darwin +#else +import Glibc +#endif +import Foundation +import Testing +@testable import CodexBarCore + +struct ShellCommandLocatorProcessTests { + @Test + func `shell probe pipe descriptors close across unrelated execs`() throws { + let fds = try #require(ShellCommandLocator.test_makeCloseOnExecPipe()) + defer { + close(fds.read) + close(fds.write) + } + + for fd in [fds.read, fds.write] { + let flags = fcntl(fd, F_GETFD) + #expect(flags >= 0) + #expect(flags & FD_CLOEXEC != 0) + } + } + + @Test + func `shell runner terminates session escaped partial output holders after timeout`() throws { + let pidFile = FileManager.default.temporaryDirectory + .appendingPathComponent("codexbar-shell-runner-timeout-\(UUID().uuidString)") + .path + let stdoutPIDFile = "\(pidFile).stdout" + let stderrPIDFile = "\(pidFile).stderr" + let pidFiles = [stdoutPIDFile, stderrPIDFile] + defer { + for file in pidFiles { + if let pidText = try? String(contentsOfFile: file, encoding: .utf8) + .trimmingCharacters(in: .whitespacesAndNewlines), + let pid = pid_t(pidText) + { + kill(pid, SIGKILL) + } + try? FileManager.default.removeItem(atPath: file) + } + } + let script = """ + import os + import signal + import sys + import time + + for stream, suffix in ((1, ".stdout"), (2, ".stderr")): + child = os.fork() + if child == 0: + os.setsid() + signal.signal(signal.SIGHUP, signal.SIG_IGN) + signal.signal(signal.SIGTERM, signal.SIG_IGN) + os.close(2 if stream == 1 else 1) + with open(sys.argv[1] + suffix, "w") as handle: + handle.write(str(os.getpid())) + while True: + time.sleep(1) + + while not all(os.path.exists(sys.argv[1] + suffix) for suffix in (".stdout", ".stderr")): + time.sleep(0.01) + time.sleep(1000) + """ + + // Pre-warm the interpreter so cold-start latency on loaded CI runners cannot + // consume the probe timeout before the helper children write their PID files. + let warmup = Process() + warmup.executableURL = URL(fileURLWithPath: "/usr/bin/python3") + warmup.arguments = ["-c", "pass"] + try warmup.run() + warmup.waitUntilExit() + + let start = Date() + let data = ShellCommandLocator.test_runShellCommand( + shell: "/usr/bin/python3", + arguments: ["-c", script, pidFile], + timeout: 5.0) + let elapsed = Date().timeIntervalSince(start) + + // The PID files are written by detached grandchildren; give the filesystem a + // bounded grace period before reading so slow runners cannot race the writes. + let fileDeadline = Date().addingTimeInterval(10) + while Date() < fileDeadline, + !pidFiles.allSatisfy({ FileManager.default.fileExists(atPath: $0) }) + { + usleep(100_000) + } + let pids = try pidFiles.map { file in + let pidText = try String(contentsOfFile: file, encoding: .utf8) + .trimmingCharacters(in: .whitespacesAndNewlines) + return try #require(pid_t(pidText)) + } + + #expect(data == nil) + #expect(elapsed < 8.0, "Timed-out PATH probes should remain bounded") + for pid in pids { + #expect(kill(pid, 0) != 0) + } + } +} diff --git a/Tests/CodexBarTests/SpawnedProcessGroupTests.swift b/Tests/CodexBarTests/SpawnedProcessGroupTests.swift new file mode 100644 index 0000000000..7e63182309 --- /dev/null +++ b/Tests/CodexBarTests/SpawnedProcessGroupTests.swift @@ -0,0 +1,686 @@ +import Foundation +import Testing +@testable import CodexBarCore +#if canImport(Darwin) +import Darwin +#else +import Glibc +#endif + +struct SpawnedProcessGroupTests { + @Test + func `pipe cleanup preserves standard descriptors`() { + let descriptors = SpawnedProcessGroup.pipeDescriptorsToClose([0, 1, 2, 3, 4, 3]) + + #expect(descriptors == [3, 4]) + } + + #if canImport(Darwin) + @Test + func `Darwin device identifier preserves signed bit pattern`() { + #expect(SpawnedProcessGroup.darwinDeviceIdentifier(-805_306_367) == 3_489_660_929) + } + #endif + + @Test + func `musl close-from selects numeric descriptors at or above minimum`() throws { + let descriptors = try PosixSpawnFileActionsCloseFrom.descriptorsToClose(startingAt: 4) { path in + #expect(path == "/proc/self/fd") + return ["8", "cwd", "3", "4"] + } + + #expect(descriptors == [4, 8]) + } + + @Test + func `musl close-from fails when descriptor enumeration fails`() { + #expect(throws: PosixSpawnFileActionsCloseFrom.CloseFromError.self) { + try PosixSpawnFileActionsCloseFrom.descriptorsToClose(startingAt: 3) { _ in + throw CocoaError(.fileReadNoPermission) + } + } + } + + @Test + func `launch captures child output`() async throws { + let stdoutPipe = Pipe() + let stderrPipe = Pipe() + let stdoutCapture = ProcessPipeCapture(pipe: stdoutPipe) + let stderrCapture = ProcessPipeCapture(pipe: stderrPipe) + stdoutCapture.start() + stderrCapture.start() + + let process = try SpawnedProcessGroup.launch( + binary: "/bin/sh", + arguments: ["-c", "printf stdout-value; printf stderr-value >&2"], + environment: ProcessInfo.processInfo.environment, + stdoutPipe: stdoutPipe, + stderrPipe: stderrPipe) + while process.isRunning { + try await Task.sleep(for: .milliseconds(20)) + } + await process.terminateResidualProcesses() + await process.finish() + + async let stdout = stdoutCapture.finish(timeout: .seconds(1)) + async let stderr = stderrCapture.finish(timeout: .seconds(1)) + let output = await (stdout, stderr) + + #expect(process.terminationStatus == 0) + #expect(String(data: output.0, encoding: .utf8) == "stdout-value") + #expect(String(data: output.1, encoding: .utf8) == "stderr-value") + } + + @Test + func `launch clears the parent thread signal mask`() throws { + var blockedMask = sigset_t() + var previousMask = sigset_t() + sigemptyset(&blockedMask) + sigaddset(&blockedMask, SIGTERM) + try #require(pthread_sigmask(SIG_BLOCK, &blockedMask, &previousMask) == 0) + defer { pthread_sigmask(SIG_SETMASK, &previousMask, nil) } + + let stdoutPipe = Pipe() + let stderrPipe = Pipe() + let script = """ + import signal + import sys + + blocked = signal.pthread_sigmask(signal.SIG_BLOCK, set()) + sys.exit(1 if signal.SIGTERM in blocked else 0) + """ + let process = try SpawnedProcessGroup.launch( + binary: "/usr/bin/python3", + arguments: ["-c", script], + environment: ProcessInfo.processInfo.environment, + stdoutPipe: stdoutPipe, + stderrPipe: stderrPipe) + + while process.isRunning { + usleep(20000) + } + process.finishSynchronously() + + #expect(process.terminationStatus == 0) + } + + @Test + func `PTY launch clears the parent thread signal mask`() throws { + var blockedMask = sigset_t() + var previousMask = sigset_t() + sigemptyset(&blockedMask) + sigaddset(&blockedMask, SIGTERM) + try #require(pthread_sigmask(SIG_BLOCK, &blockedMask, &previousMask) == 0) + defer { pthread_sigmask(SIG_SETMASK, &previousMask, nil) } + + var primaryFD: Int32 = -1 + var secondaryFD: Int32 = -1 + try #require(openpty(&primaryFD, &secondaryFD, nil, nil, nil) == 0) + let primaryHandle = FileHandle(fileDescriptor: primaryFD, closeOnDealloc: true) + let secondaryHandle = FileHandle(fileDescriptor: secondaryFD, closeOnDealloc: true) + defer { + try? primaryHandle.close() + try? secondaryHandle.close() + } + + let script = """ + import signal + import sys + + blocked = signal.pthread_sigmask(signal.SIG_BLOCK, set()) + sys.exit(1 if signal.SIGTERM in blocked else 0) + """ + let process = try SpawnedProcessGroup.launchPTY( + binary: "/usr/bin/python3", + arguments: ["-c", script], + environment: ProcessInfo.processInfo.environment, + workingDirectory: nil, + fileDescriptors: (primary: primaryFD, secondary: secondaryFD)) + try? secondaryHandle.close() + + while process.isRunning { + usleep(20000) + } + process.finishSynchronously() + + #expect(process.terminationStatus == 0) + } + + @Test + func `launch closes unrelated parent descriptors`() async throws { + let sourceFD = open("/dev/null", O_RDONLY) + let inheritedFD = fcntl(sourceFD, F_DUPFD, 200) + close(sourceFD) + let resolvedFD = try #require(inheritedFD >= 200 ? inheritedFD : nil) + defer { close(resolvedFD) } + _ = fcntl(resolvedFD, F_SETFD, 0) + + #if canImport(Darwin) + let descriptorPath = "/dev/fd/\(resolvedFD)" + #else + let descriptorPath = "/proc/self/fd/\(resolvedFD)" + #endif + let stdoutPipe = Pipe() + let stderrPipe = Pipe() + let process = try SpawnedProcessGroup.launch( + binary: "/bin/sh", + arguments: ["-c", "test ! -e \(descriptorPath)"], + environment: ProcessInfo.processInfo.environment, + stdoutPipe: stdoutPipe, + stderrPipe: stderrPipe) + + while process.isRunning { + try await Task.sleep(for: .milliseconds(20)) + } + await process.terminateResidualProcesses() + await process.finish() + + #expect(process.terminationStatus == 0) + } + + @Test + func `termination waits for grace before killing escaped descendants`() async throws { + let childPIDFile = FileManager.default.temporaryDirectory + .appendingPathComponent("codexbar-process-group-\(UUID().uuidString).pid") + defer { try? FileManager.default.removeItem(at: childPIDFile) } + + let script = """ + import subprocess + import sys + import time + + child = subprocess.Popen( + [ + sys.executable, + "-c", + "import os,signal,sys,time; signal.signal(signal.SIGTERM, signal.SIG_IGN); " + "open(sys.argv[1], 'w').write(str(os.getpid())); time.sleep(30)", + sys.argv[1], + ], + start_new_session=True, + ) + time.sleep(30) + """ + let stdoutPipe = Pipe() + let stderrPipe = Pipe() + let process = try SpawnedProcessGroup.launch( + binary: "/usr/bin/python3", + arguments: ["-c", script, childPIDFile.path], + environment: ProcessInfo.processInfo.environment, + stdoutPipe: stdoutPipe, + stderrPipe: stderrPipe) + + var childPID: pid_t? + for _ in 0..<500 { + if let text = try? String(contentsOf: childPIDFile, encoding: .utf8), + let parsedPID = pid_t(text.trimmingCharacters(in: .whitespacesAndNewlines)) + { + childPID = parsedPID + break + } + try await Task.sleep(for: .milliseconds(20)) + } + guard let escapedPID = childPID else { + await process.terminate(grace: 0) + Issue.record("Timed out waiting for escaped child PID") + return + } + defer { _ = kill(escapedPID, SIGKILL) } + + let start = Date() + await process.terminate(grace: 0.3) + let elapsed = Date().timeIntervalSince(start) + + #expect(elapsed >= 0.25, "Termination should honor the grace period before SIGKILL") + #expect(kill(escapedPID, 0) == -1) + } + + @Test + func `termination kills reparented process group members after root exit`() async throws { + let childPIDFile = FileManager.default.temporaryDirectory + .appendingPathComponent("codexbar-process-group-member-\(UUID().uuidString).pid") + defer { try? FileManager.default.removeItem(at: childPIDFile) } + + let script = """ + import os + import signal + import sys + import time + + intermediate = os.fork() + if intermediate == 0: + child = os.fork() + if child > 0: + os._exit(0) + os.close(1) + os.close(2) + signal.signal(signal.SIGTERM, signal.SIG_IGN) + with open(sys.argv[1], "w") as handle: + handle.write(str(os.getpid())) + time.sleep(30) + os._exit(0) + + os.waitpid(intermediate, 0) + time.sleep(30) + """ + let stdoutPipe = Pipe() + let stderrPipe = Pipe() + let process = try SpawnedProcessGroup.launch( + binary: "/usr/bin/python3", + arguments: ["-c", script, childPIDFile.path], + environment: ProcessInfo.processInfo.environment, + stdoutPipe: stdoutPipe, + stderrPipe: stderrPipe) + + var childPID: pid_t? + for _ in 0..<100 { + if let text = try? String(contentsOf: childPIDFile, encoding: .utf8), + let parsedPID = pid_t(text.trimmingCharacters(in: .whitespacesAndNewlines)) + { + childPID = parsedPID + break + } + try await Task.sleep(for: .milliseconds(20)) + } + let reparentedPID = try #require(childPID) + defer { _ = kill(reparentedPID, SIGKILL) } + + await process.terminate(grace: 0.2) + + #expect(kill(reparentedPID, 0) == -1) + } + + @Test + func `termination gives reparented process group members grace`() async throws { + let childPIDFile = FileManager.default.temporaryDirectory + .appendingPathComponent("codexbar-process-group-grace-\(UUID().uuidString).pid") + let termReceivedFile = childPIDFile.appendingPathExtension("term") + let gracefulExitFile = childPIDFile.appendingPathExtension("graceful") + defer { + try? FileManager.default.removeItem(at: childPIDFile) + try? FileManager.default.removeItem(at: termReceivedFile) + try? FileManager.default.removeItem(at: gracefulExitFile) + } + + let script = """ + import os + import signal + import sys + import time + + intermediate = os.fork() + if intermediate == 0: + child = os.fork() + if child > 0: + os._exit(0) + os.close(1) + os.close(2) + def handle_term(_signal, _frame): + with open(sys.argv[2], "w") as handle: + handle.write("term") + time.sleep(0.1) + with open(sys.argv[3], "w") as handle: + handle.write("graceful") + os._exit(0) + signal.signal(signal.SIGTERM, handle_term) + signal.pthread_sigmask(signal.SIG_UNBLOCK, {signal.SIGTERM}) + with open(sys.argv[1], "w") as handle: + handle.write(str(os.getpid())) + time.sleep(30) + os._exit(0) + + os.waitpid(intermediate, 0) + time.sleep(30) + """ + let stdoutPipe = Pipe() + let stderrPipe = Pipe() + let process = try SpawnedProcessGroup.launch( + binary: "/usr/bin/python3", + arguments: ["-c", script, childPIDFile.path, termReceivedFile.path, gracefulExitFile.path], + environment: ProcessInfo.processInfo.environment, + stdoutPipe: stdoutPipe, + stderrPipe: stderrPipe) + + var childPID: pid_t? + for _ in 0..<100 { + if let text = try? String(contentsOf: childPIDFile, encoding: .utf8), + let parsedPID = pid_t(text.trimmingCharacters(in: .whitespacesAndNewlines)) + { + childPID = parsedPID + break + } + try await Task.sleep(for: .milliseconds(20)) + } + let reparentedPID = try #require(childPID) + defer { _ = kill(reparentedPID, SIGKILL) } + for _ in 0..<100 + where TTYProcessTreeTerminator.descendantPIDs(of: process.pid).contains(reparentedPID) + { + try await Task.sleep(for: .milliseconds(20)) + } + #expect(!TTYProcessTreeTerminator.descendantPIDs(of: process.pid).contains(reparentedPID)) + #expect(getpgid(reparentedPID) == process.processGroup) + + await process.terminate(grace: 0.3) + + #expect(FileManager.default.fileExists(atPath: termReceivedFile.path)) + #expect(FileManager.default.fileExists(atPath: gracefulExitFile.path)) + #expect(kill(reparentedPID, 0) == -1) + } + + @Test + func `residual termination cleans same group helpers spawned during SIGTERM`() async throws { + let readyFile = FileManager.default.temporaryDirectory + .appendingPathComponent("codexbar-process-group-term-\(UUID().uuidString).ready") + let childPIDFile = readyFile.appendingPathExtension("pid") + let heartbeatFile = readyFile.appendingPathExtension("heartbeat") + defer { + try? FileManager.default.removeItem(at: readyFile) + try? FileManager.default.removeItem(at: childPIDFile) + try? FileManager.default.removeItem(at: heartbeatFile) + } + + let script = """ + import os + import signal + import sys + import time + + def handle_term(_signal, _frame): + reader, writer = os.pipe() + child = os.fork() + if child == 0: + os.close(reader) + os.close(1) + os.close(2) + signal.signal(signal.SIGTERM, signal.SIG_IGN) + with open(sys.argv[2], "w") as handle: + handle.write(str(os.getpid())) + with open(sys.argv[3], "w") as heartbeat: + heartbeat.write("1") + heartbeat.flush() + os.write(writer, b"1") + os.close(writer) + counter = 1 + while True: + counter += 1 + heartbeat.seek(0) + heartbeat.write(str(counter)) + heartbeat.truncate() + heartbeat.flush() + time.sleep(0.02) + os.close(writer) + os.read(reader, 1) + os.close(reader) + os._exit(0) + + signal.signal(signal.SIGTERM, handle_term) + signal.pthread_sigmask(signal.SIG_UNBLOCK, {signal.SIGTERM}) + with open(sys.argv[1], "w") as handle: + handle.write("ready") + time.sleep(30) + """ + let stdoutPipe = Pipe() + let stderrPipe = Pipe() + let process = try SpawnedProcessGroup.launch( + binary: "/usr/bin/python3", + arguments: ["-c", script, readyFile.path, childPIDFile.path, heartbeatFile.path], + environment: ProcessInfo.processInfo.environment, + stdoutPipe: stdoutPipe, + stderrPipe: stderrPipe) + + for _ in 0..<100 where !FileManager.default.fileExists(atPath: readyFile.path) { + try await Task.sleep(for: .milliseconds(20)) + } + #expect(FileManager.default.fileExists(atPath: readyFile.path)) + + await process.terminateResidualProcesses(grace: 0.2) + await process.finish() + + var childPID: pid_t? + for _ in 0..<100 { + if let text = try? String(contentsOf: childPIDFile, encoding: .utf8), + let parsedPID = pid_t(text.trimmingCharacters(in: .whitespacesAndNewlines)) + { + childPID = parsedPID + break + } + try await Task.sleep(for: .milliseconds(20)) + } + let resolvedChildPID = try #require(childPID) + defer { _ = kill(resolvedChildPID, SIGKILL) } + let heartbeatAfterCleanup = try String(contentsOf: heartbeatFile, encoding: .utf8) + try await Task.sleep(for: .milliseconds(200)) + let heartbeatAfterSettle = try String(contentsOf: heartbeatFile, encoding: .utf8) + #expect(heartbeatAfterSettle == heartbeatAfterCleanup) + } + + @Test + func `normal exit cleans a session escaped output holder`() async throws { + let childPIDFile = FileManager.default.temporaryDirectory + .appendingPathComponent("codexbar-process-holder-\(UUID().uuidString).pid") + defer { try? FileManager.default.removeItem(at: childPIDFile) } + + let script = """ + import subprocess + import sys + + child = subprocess.Popen( + [ + sys.executable, + "-c", + "import signal,time; signal.signal(signal.SIGTERM, signal.SIG_IGN); time.sleep(30)", + ], + start_new_session=True, + ) + with open(sys.argv[1], "w") as handle: + handle.write(str(child.pid)) + print("parent complete", flush=True) + """ + let stdoutPipe = Pipe() + let stderrPipe = Pipe() + let process = try SpawnedProcessGroup.launch( + binary: "/usr/bin/python3", + arguments: ["-c", script, childPIDFile.path], + environment: ProcessInfo.processInfo.environment, + stdoutPipe: stdoutPipe, + stderrPipe: stderrPipe) + + while process.isRunning { + try await Task.sleep(for: .milliseconds(20)) + } + var childPID: pid_t? + for _ in 0..<100 { + if let text = try? String(contentsOf: childPIDFile, encoding: .utf8), + let parsedPID = pid_t(text.trimmingCharacters(in: .whitespacesAndNewlines)) + { + childPID = parsedPID + break + } + try await Task.sleep(for: .milliseconds(20)) + } + let resolvedChildPID = try #require(childPID) + defer { _ = kill(resolvedChildPID, SIGKILL) } + #expect(kill(resolvedChildPID, 0) == 0) + + await process.terminateResidualProcesses(grace: 0.2) + await process.finish() + + #expect(kill(resolvedChildPID, 0) == -1) + } + + @Test + func `normal exit cleans a same group helper without output pipes`() async throws { + let childPIDFile = FileManager.default.temporaryDirectory + .appendingPathComponent("codexbar-process-group-holder-\(UUID().uuidString).pid") + let heartbeatFile = childPIDFile.appendingPathExtension("heartbeat") + defer { + try? FileManager.default.removeItem(at: childPIDFile) + try? FileManager.default.removeItem(at: heartbeatFile) + } + + let script = """ + import os + import signal + import sys + import time + + child = os.fork() + if child == 0: + os.close(1) + os.close(2) + signal.signal(signal.SIGTERM, signal.SIG_IGN) + with open(sys.argv[1], "w") as handle: + handle.write(str(os.getpid())) + counter = 0 + with open(sys.argv[2], "w") as heartbeat: + while True: + counter += 1 + heartbeat.seek(0) + heartbeat.write(str(counter)) + heartbeat.truncate() + heartbeat.flush() + time.sleep(0.02) + os._exit(0) + """ + let stdoutPipe = Pipe() + let stderrPipe = Pipe() + let process = try SpawnedProcessGroup.launch( + binary: "/usr/bin/python3", + arguments: ["-c", script, childPIDFile.path, heartbeatFile.path], + environment: ProcessInfo.processInfo.environment, + stdoutPipe: stdoutPipe, + stderrPipe: stderrPipe) + + while process.isRunning { + try await Task.sleep(for: .milliseconds(20)) + } + var childPID: pid_t? + for _ in 0..<100 { + if let text = try? String(contentsOf: childPIDFile, encoding: .utf8), + let parsedPID = pid_t(text.trimmingCharacters(in: .whitespacesAndNewlines)) + { + childPID = parsedPID + break + } + try await Task.sleep(for: .milliseconds(20)) + } + let resolvedChildPID = try #require(childPID) + defer { _ = kill(resolvedChildPID, SIGKILL) } + #expect(getpgid(resolvedChildPID) == process.processGroup) + #expect(kill(resolvedChildPID, 0) == 0) + for _ in 0..<100 where !FileManager.default.fileExists(atPath: heartbeatFile.path) { + try await Task.sleep(for: .milliseconds(20)) + } + let heartbeatBefore = try String(contentsOf: heartbeatFile, encoding: .utf8) + var heartbeatWhileRunning = heartbeatBefore + for _ in 0..<100 where heartbeatWhileRunning == heartbeatBefore { + try await Task.sleep(for: .milliseconds(20)) + heartbeatWhileRunning = try String(contentsOf: heartbeatFile, encoding: .utf8) + } + #expect(heartbeatWhileRunning != heartbeatBefore) + + await process.terminateResidualProcesses(grace: 0.2) + await process.finish() + + try await Task.sleep(for: .milliseconds(100)) + let heartbeatAfterCleanup = try String(contentsOf: heartbeatFile, encoding: .utf8) + try await Task.sleep(for: .milliseconds(200)) + let heartbeatAfterSettle = try String(contentsOf: heartbeatFile, encoding: .utf8) + #expect(heartbeatAfterSettle == heartbeatAfterCleanup) + } + + @Test + func `normal exit cleanup catches helper spawned during SIGTERM`() async throws { + let readyFile = FileManager.default.temporaryDirectory + .appendingPathComponent("codexbar-process-group-post-exit-\(UUID().uuidString).ready") + let childPIDFile = readyFile.appendingPathExtension("pid") + let heartbeatFile = readyFile.appendingPathExtension("heartbeat") + defer { + try? FileManager.default.removeItem(at: readyFile) + try? FileManager.default.removeItem(at: childPIDFile) + try? FileManager.default.removeItem(at: heartbeatFile) + } + + let script = """ + import os + import signal + import sys + import time + + helper = os.fork() + if helper == 0: + os.close(1) + os.close(2) + def handle_term(_signal, _frame): + reader, writer = os.pipe() + child = os.fork() + if child == 0: + os.close(reader) + signal.signal(signal.SIGTERM, signal.SIG_IGN) + with open(sys.argv[2], "w") as handle: + handle.write(str(os.getpid())) + with open(sys.argv[3], "w") as heartbeat: + heartbeat.write("1") + heartbeat.flush() + os.write(writer, b"1") + os.close(writer) + counter = 1 + while True: + counter += 1 + heartbeat.seek(0) + heartbeat.write(str(counter)) + heartbeat.truncate() + heartbeat.flush() + time.sleep(0.02) + os.close(writer) + os.read(reader, 1) + os.close(reader) + os._exit(0) + + signal.signal(signal.SIGTERM, handle_term) + signal.pthread_sigmask(signal.SIG_UNBLOCK, {signal.SIGTERM}) + with open(sys.argv[1], "w") as handle: + handle.write("ready") + time.sleep(30) + os._exit(0) + + while not os.path.exists(sys.argv[1]): + time.sleep(0.01) + os._exit(0) + """ + let stdoutPipe = Pipe() + let stderrPipe = Pipe() + let process = try SpawnedProcessGroup.launch( + binary: "/usr/bin/python3", + arguments: ["-c", script, readyFile.path, childPIDFile.path, heartbeatFile.path], + environment: ProcessInfo.processInfo.environment, + stdoutPipe: stdoutPipe, + stderrPipe: stderrPipe) + + while process.isRunning { + try await Task.sleep(for: .milliseconds(20)) + } + #expect(FileManager.default.fileExists(atPath: readyFile.path)) + + await process.terminateResidualProcesses(grace: 0.2) + await process.finish() + + var childPID: pid_t? + for _ in 0..<100 { + if let text = try? String(contentsOf: childPIDFile, encoding: .utf8), + let parsedPID = pid_t(text.trimmingCharacters(in: .whitespacesAndNewlines)) + { + childPID = parsedPID + break + } + try await Task.sleep(for: .milliseconds(20)) + } + let resolvedChildPID = try #require(childPID) + defer { _ = kill(resolvedChildPID, SIGKILL) } + let heartbeatAfterCleanup = try String(contentsOf: heartbeatFile, encoding: .utf8) + try await Task.sleep(for: .milliseconds(200)) + let heartbeatAfterSettle = try String(contentsOf: heartbeatFile, encoding: .utf8) + #expect(heartbeatAfterSettle == heartbeatAfterCleanup) + } +} diff --git a/Tests/CodexBarTests/SpendDashboardClockRolloverTests.swift b/Tests/CodexBarTests/SpendDashboardClockRolloverTests.swift new file mode 100644 index 0000000000..406aff0610 --- /dev/null +++ b/Tests/CodexBarTests/SpendDashboardClockRolloverTests.swift @@ -0,0 +1,174 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +@MainActor +struct SpendDashboardClockRolloverTests { + @Test + func `reporting window advances and rescans source inputs`() async throws { + let loadedAt = try #require(ISO8601DateFormatter().date(from: "2026-07-16T12:00:00Z")) + let afterRollover = try #require(ISO8601DateFormatter().date(from: "2026-07-22T12:00:00Z")) + let loadCount = LockIsolated(0) + let clock = LockIsolated(loadedAt) + let configuration = Self.configuration + let initialInput = Self.input(day: "2026-07-15", cost: 4, updatedAt: loadedAt) + let rolloverInput = Self.input(day: "2026-07-22", cost: 6, updatedAt: afterRollover) + // Keep selectDays persistence out of UserDefaults.standard so later suites that + // construct SpendDashboardController with the default store still get the 30-day window. + let defaults = try Self.isolatedDefaults(suiteName: "SpendDashboardClockRolloverTests-window") + defer { defaults.removePersistentDomain(forName: "SpendDashboardClockRolloverTests-window") } + let controller = SpendDashboardController( + userDefaults: defaults, + requestBuilder: { mode in + SpendDashboardLoadRequest( + configuration: configuration, + capturedInputs: [], + unavailableSourceIDs: [], + codexRequests: [], + now: clock.value, + force: mode.forcesLoader) + }, + loader: { _ in + let count = loadCount.value + 1 + loadCount.setValue(count) + return SpendDashboardLoadResult( + inputs: [count == 1 ? initialInput : rolloverInput], + failedSourceIDs: []) + }, + nowProvider: { clock.value }) + + controller.update(configuration: configuration) + await Self.waitUntil { !controller.isRefreshing } + controller.selectDays(7) + #expect(controller.model.groups.first?.totalCost == 4) + let generation = controller.generation + + clock.setValue(afterRollover) + controller.refreshDateWindow() + await Self.waitUntil { !controller.isRefreshing } + + #expect(controller.generation == generation + 1) + #expect(loadCount.value == 2) + #expect(controller.model.groups.first?.totalCost == 6) + #expect(controller.model.groups.first?.dailyPoints.count == 1) + } + + @Test + func `rollover replaces an in flight load instead of dropping the rescan`() async throws { + let loadedAt = try #require(ISO8601DateFormatter().date(from: "2026-07-16T12:00:00Z")) + let afterRollover = try #require(ISO8601DateFormatter().date(from: "2026-07-22T12:00:00Z")) + let clock = LockIsolated(loadedAt) + let configuration = Self.configuration + let staleInput = Self.input(day: "2026-07-15", cost: 4, updatedAt: loadedAt) + let freshInput = Self.input(day: "2026-07-22", cost: 6, updatedAt: afterRollover) + let gate = SpendDashboardRolloverGate() + let defaults = try Self.isolatedDefaults(suiteName: "SpendDashboardClockRolloverTests-inflight") + defer { defaults.removePersistentDomain(forName: "SpendDashboardClockRolloverTests-inflight") } + let controller = SpendDashboardController( + userDefaults: defaults, + requestBuilder: { mode in + SpendDashboardLoadRequest( + configuration: configuration, + capturedInputs: [], + unavailableSourceIDs: [], + codexRequests: [], + now: clock.value, + force: mode.forcesLoader) + }, + loader: { request in + await gate.load(request) + }, + nowProvider: { clock.value }) + controller.update(configuration: configuration) + await Self.waitForPendingCount(1, gate: gate) + + clock.setValue(afterRollover) + controller.refreshDateWindow() + await Self.waitForPendingCount(2, gate: gate) + + await gate.resume(at: 0, result: .init(inputs: [staleInput], failedSourceIDs: [])) + await gate.resume(at: 1, result: .init(inputs: [freshInput], failedSourceIDs: [])) + await Self.waitUntil { !controller.isRefreshing } + + #expect(controller.generation == 2) + #expect(controller.model.groups.first?.totalCost == 6) + #expect(controller.model.groups.first?.dailyPoints.count == 1) + } + + private static let configuration = SpendDashboardConfiguration( + costUsageEnabled: true, + providerIDs: [UsageProvider.codex.rawValue], + codexAccountIdentities: ["rollover"]) + + private static func isolatedDefaults(suiteName: String) throws -> UserDefaults { + let defaults = try #require(UserDefaults(suiteName: suiteName)) + defaults.removePersistentDomain(forName: suiteName) + return defaults + } + + private static func input(day: String, cost: Double, updatedAt: Date) -> SpendDashboardModel.ProviderInput { + let entry = CostUsageDailyReport.Entry( + date: day, + inputTokens: nil, + outputTokens: nil, + totalTokens: 10, + costUSD: cost, + modelsUsed: nil, + modelBreakdowns: nil) + let snapshot = CostUsageTokenSnapshot( + sessionTokens: nil, + sessionCostUSD: nil, + last30DaysTokens: 10, + last30DaysCostUSD: cost, + daily: [entry], + updatedAt: updatedAt) + return SpendDashboardModel.ProviderInput( + provider: .codex, + displayName: "Codex", + snapshot: snapshot) + } + + private static func waitUntil(_ condition: @MainActor () -> Bool) async { + for _ in 0..<1000 { + if condition() { + return + } + await Task.yield() + } + Issue.record("Timed out waiting for controller state") + } + + private static func waitForPendingCount(_ count: Int, gate: SpendDashboardRolloverGate) async { + for _ in 0..<1000 { + if await gate.pendingCount == count { + return + } + await Task.yield() + } + Issue.record("Timed out waiting for \(count) pending loads") + } +} + +private actor SpendDashboardRolloverGate { + private struct Pending { + let continuation: CheckedContinuation + } + + private var pending: [Pending] = [] + + var pendingCount: Int { + self.pending.count + } + + func load(_ request: SpendDashboardLoadRequest) async -> SpendDashboardLoadResult { + _ = request + return await withCheckedContinuation { continuation in + self.pending.append(Pending(continuation: continuation)) + } + } + + func resume(at index: Int, result: SpendDashboardLoadResult) { + self.pending[index].continuation.resume(returning: result) + } +} diff --git a/Tests/CodexBarTests/SpendDashboardControllerTests.swift b/Tests/CodexBarTests/SpendDashboardControllerTests.swift new file mode 100644 index 0000000000..69aa8b4414 --- /dev/null +++ b/Tests/CodexBarTests/SpendDashboardControllerTests.swift @@ -0,0 +1,1264 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +@MainActor +@Suite(.serialized) +struct SpendDashboardControllerTests { + @Test + func `empty codex history loads as successful inactive source`() async { + let now = Date(timeIntervalSince1970: 1_784_179_200) + let recorder = SpendDashboardCodexLoadRecorder() + let account = CodexSpendScanRequest( + id: "inactive", + displayName: "Codex", + source: .profileHome(path: "/synthetic/codex-home"), + homePath: "/synthetic/codex-home", + authFingerprint: nil, + authFileWasReadable: false, + cacheIdentity: "inactive-cache") + let request = SpendDashboardLoadRequest( + configuration: Self.configuration(account: "inactive|inactive-cache"), + capturedInputs: [], + unavailableSourceIDs: [], + codexRequests: [account], + now: now, + force: false) + + let result = await SpendDashboardSource.load(request, codexSnapshotLoader: { context in + await recorder.record(context) + return CostUsageTokenSnapshot( + sessionTokens: nil, + sessionCostUSD: nil, + last30DaysTokens: 0, + last30DaysCostUSD: 0, + historyDays: context.historyDays, + daily: [], + updatedAt: context.now) + }) + let contexts = await recorder.contexts + + #expect(result.inputs.count == 1) + #expect(result.inputs.first?.id == "codex:inactive") + #expect(result.inputs.first?.snapshot.daily.isEmpty == true) + #expect(result.failedSourceIDs.isEmpty) + #expect(contexts.count == 1) + #expect(contexts.first?.account == account) + #expect(contexts.first?.cacheRoot.lastPathComponent == "inactive-cache") + #expect(contexts.first?.now == now) + #expect(contexts.first?.force == false) + #expect(contexts.first?.historyDays == 30) + #expect(contexts.first?.refreshPricingInBackground == false) + #expect(contexts.first?.includePiSessions == false) + } + + @Test + func `Codex auth rotation invalidates stale spend while retaining unrelated providers`() async throws { + let home = FileManager.default.temporaryDirectory + .appendingPathComponent( + "SpendDashboardControllerTests-auth-rotation-\(UUID().uuidString)", + isDirectory: true) + try FileManager.default.createDirectory(at: home, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: home) } + let authURL = CodexAuthFingerprint.authFileURL(homePath: home.path) + let originalAuth = Data("{\"profile\":\"owner-one\"}".utf8) + try originalAuth.write(to: authURL, options: .atomic) + let account = CodexSpendScanRequest( + id: "account", + displayName: "Codex", + source: .profileHome(path: home.path), + homePath: home.path, + authFingerprint: CodexAuthFingerprint.fingerprint(data: originalAuth), + authFileWasReadable: true, + cacheIdentity: "auth-rotation") + let gate = SpendDashboardCodexSnapshotGate() + let recorder = SpendDashboardLoadResultRecorder() + let configuration = SpendDashboardConfiguration( + costUsageEnabled: true, + providerIDs: [UsageProvider.codex.rawValue, UsageProvider.openai.rawValue], + codexAccountIdentities: ["account|auth-rotation"], + codexAccountDisplayNames: ["codex:account": "Codex"], + sourceOwnershipFingerprints: ["openai:stable"]) + let controller = SpendDashboardController( + requestBuilder: { mode in + SpendDashboardLoadRequest( + configuration: configuration, + capturedInputs: [Self.input(id: "openai", provider: .openai, cost: 2)], + unavailableSourceIDs: [], + codexRequests: [account], + now: Date(timeIntervalSince1970: 1_784_179_200), + force: mode.forcesLoader) + }, + loader: { request in + let result = await SpendDashboardSource.load(request, codexSnapshotLoader: { context in + await gate.load(context) + }) + await recorder.record(result) + return result + }) + + controller.update(configuration: configuration) + await Self.waitForCodexPendingCount(1, gate: gate) + await gate.resume(at: 0, snapshot: Self.input(cost: 6).snapshot) + await Self.waitUntil { !controller.isRefreshing } + #expect(controller.model.groups.first?.totalCost == 8) + + controller.refresh() + await Self.waitForCodexPendingCount(1, gate: gate) + let replacementAuth = Data("{\"profile\":\"owner-two\"}".utf8) + try replacementAuth.write(to: authURL, options: .atomic) + await gate.resume(at: 0, snapshot: Self.input(cost: 99).snapshot) + await Self.waitUntil { !controller.isRefreshing } + + let results = await recorder.results + #expect(results.last?.invalidatedSourceIDs == ["codex:account"]) + #expect(results.last?.failedSourceIDs == ["codex:account"]) + #expect(controller.failedSourceCount == 1) + #expect(controller.model.groups.first?.totalCost == 2) + #expect(Set(controller.model.groups.flatMap(\.providers).map(\.id)) == ["openai"]) + } + + @Test + func `replacement generation rejects stale completion`() async { + let gate = SpendDashboardLoaderGate() + let controller = Self.controller(gate: gate) + let firstConfiguration = Self.configuration(account: "first") + let secondConfiguration = Self.configuration(account: "second") + + controller.update(configuration: firstConfiguration) + await Self.waitForPendingCount(1, gate: gate) + controller.update(configuration: secondConfiguration) + await Self.waitForPendingCount(2, gate: gate) + + await gate.resume(at: 1, result: .init(inputs: [Self.input(cost: 2)], failedSourceIDs: [])) + await Self.waitUntil { !controller.isRefreshing } + #expect(controller.model.groups.first?.totalCost == 2) + #expect(controller.generation == 2) + + await gate.resume(at: 0, result: .init(inputs: [Self.input(cost: 1)], failedSourceIDs: [])) + await Task.yield() + #expect(controller.model.groups.first?.totalCost == 2) + } + + @Test + func `failed same configuration refresh retains last good model`() async { + let gate = SpendDashboardLoaderGate() + let controller = Self.controller(gate: gate) + let configuration = Self.configuration(account: "same") + + controller.update(configuration: configuration) + await Self.waitForPendingCount(1, gate: gate) + await gate.resume(at: 0, result: .init( + inputs: [ + Self.input(cost: 7), + Self.input(id: "claude", provider: .claude, cost: 3), + ], + failedSourceIDs: [])) + await Self.waitUntil { !controller.isRefreshing } + #expect(controller.model.groups.first?.totalCost == 10) + + controller.refresh() + await Self.waitForPendingCount(1, gate: gate) + await gate.resume(at: 0, result: .init(inputs: [Self.input(cost: 8)], failedSourceIDs: ["claude"])) + await Self.waitUntil { !controller.isRefreshing } + #expect(controller.model.groups.first?.totalCost == 11) + #expect(controller.model.groups.first?.providers.count == 2) + #expect(controller.failedSourceCount == 1) + } + + @Test + func `refresh retains only sources that actually failed`() async { + let gate = SpendDashboardLoaderGate() + let controller = Self.controller(gate: gate) + let configuration = Self.configuration(account: "same") + + controller.update(configuration: configuration) + await Self.waitForPendingCount(1, gate: gate) + await gate.resume(at: 0, result: .init( + inputs: [ + Self.input(cost: 7), + Self.input(id: "claude", provider: .claude, cost: 3), + Self.input(id: "openai", provider: .openai, cost: 2), + ], + failedSourceIDs: [])) + await Self.waitUntil { !controller.isRefreshing } + + controller.refresh() + await Self.waitForPendingCount(1, gate: gate) + await gate.resume(at: 0, result: .init( + inputs: [Self.input(cost: 8)], + failedSourceIDs: ["claude"])) + await Self.waitUntil { !controller.isRefreshing } + + let providerIDs = Set(controller.model.groups.flatMap(\.providers).map(\.id)) + #expect(providerIDs == ["codex", "claude"]) + #expect(controller.model.groups.first?.totalCost == 11) + } + + @Test + func `changed data revision retains failed source with same ownership`() async { + let gate = SpendDashboardLoaderGate() + let controller = Self.controller(gate: gate) + + controller.update(configuration: Self.configuration(account: "same", revision: "first")) + await Self.waitForPendingCount(1, gate: gate) + await gate.resume(at: 0, result: .init( + inputs: [ + Self.input(cost: 7), + Self.input(id: "claude", provider: .claude, cost: 3), + ], + failedSourceIDs: ["openai"])) + await Self.waitUntil { !controller.isRefreshing } + #expect(controller.failedSourceCount == 1) + + controller.update(configuration: Self.configuration(account: "same", revision: "second")) + await Self.waitForPendingCount(1, gate: gate) + #expect(controller.isRefreshing) + #expect(controller.model.groups.first?.totalCost == 10) + #expect(controller.failedSourceCount == 1) + await gate.resume(at: 0, result: .init( + inputs: [Self.input(cost: 8)], + failedSourceIDs: ["claude"])) + await Self.waitUntil { !controller.isRefreshing } + + #expect(controller.model.groups.first?.totalCost == 11) + #expect(Set(controller.model.groups.flatMap(\.providers).map(\.id)) == ["codex", "claude"]) + } + + @Test + func `snapshot spend replacement with unchanged metadata triggers reload`() async { + let gate = SpendDashboardLoaderGate() + let controller = Self.controller(gate: gate) + let firstInput = Self.input(provider: .claude, cost: 3) + let replacementInput = Self.input(provider: .claude, cost: 8) + let settings = testSettingsStore(suiteName: "SpendDashboardControllerTests-snapshot-replacement") + settings.costUsageEnabled = true + for provider in UsageProvider.allCases { + guard let metadata = ProviderRegistry.shared.metadata[provider] else { continue } + settings.setProviderEnabled(provider: provider, metadata: metadata, enabled: provider == .claude) + } + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + startupBehavior: .testing, + environmentBase: [:]) + + store._setTokenSnapshotForTesting(firstInput.snapshot, provider: .claude) + let firstConfiguration = SpendDashboardSource.configuration(settings: settings, store: store) + store._setTokenSnapshotForTesting(replacementInput.snapshot, provider: .claude) + let replacementConfiguration = SpendDashboardSource.configuration(settings: settings, store: store) + + #expect(firstInput.snapshot.daily.count == replacementInput.snapshot.daily.count) + #expect(firstInput.snapshot.updatedAt == replacementInput.snapshot.updatedAt) + #expect(firstInput.snapshot.historyDays == replacementInput.snapshot.historyDays) + #expect(firstConfiguration.providerIDs == [UsageProvider.claude.rawValue]) + #expect(firstConfiguration.sourceRevisions != replacementConfiguration.sourceRevisions) + + controller.update(configuration: firstConfiguration) + await Self.waitForPendingCount(1, gate: gate) + await gate.resume(at: 0, result: .init(inputs: [firstInput], failedSourceIDs: [])) + await Self.waitUntil { !controller.isRefreshing } + #expect(controller.model.groups.first?.totalCost == 3) + + controller.update(configuration: replacementConfiguration) + await Self.waitForPendingCount(1, gate: gate) + await gate.resume(at: 0, result: .init(inputs: [replacementInput], failedSourceIDs: [])) + await Self.waitUntil { !controller.isRefreshing } + + #expect(controller.generation == 2) + #expect(controller.model.groups.first?.totalCost == 8) + } + + @Test + func `identical successful republication reloads and clears retained failure warning`() async { + let settings = testSettingsStore( + suiteName: "SpendDashboardControllerTests-identical-republication") + settings.costUsageEnabled = true + for provider in UsageProvider.allCases { + guard let metadata = ProviderRegistry.shared.metadata[provider] else { continue } + settings.setProviderEnabled(provider: provider, metadata: metadata, enabled: provider == .claude) + } + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + startupBehavior: .testing, + environmentBase: [:]) + let snapshot = Self.input(id: "claude", provider: .claude, cost: 3).snapshot + store._setTokenSnapshotForTesting(snapshot, provider: .claude) + store._test_tokenUsageRefreshOverride = { _, _ in } + let controller = Self.dashboardController(settings: settings, store: store) + + let baselineConfiguration = SpendDashboardSource.configuration(settings: settings, store: store) + controller.update(configuration: baselineConfiguration) + await Self.waitUntil { !controller.isRefreshing } + #expect(controller.model.groups.first?.totalCost == 3) + + controller.refresh() + await Self.waitUntil { !controller.isRefreshing } + #expect(controller.model.groups.first?.totalCost == 3) + #expect(controller.failedSourceCount == 1) + + store._setTokenSnapshotForTesting(snapshot, provider: .claude) + let replacementConfiguration = SpendDashboardSource.configuration(settings: settings, store: store) + #expect(replacementConfiguration.sourceRevisions != baselineConfiguration.sourceRevisions) + controller.update(configuration: replacementConfiguration) + await Self.waitUntil { !controller.isRefreshing } + + #expect(controller.generation == 4) + #expect(controller.model.groups.first?.totalCost == 3) + #expect(controller.failedSourceCount == 0) + + let settledGeneration = controller.generation + controller.update(configuration: replacementConfiguration) + await Task.yield() + #expect(controller.generation == settledGeneration) + } + + @Test + func `capture request distinguishes confirmed empty provider from unavailable provider`() async { + let settings = testSettingsStore( + suiteName: "SpendDashboardControllerTests-confirmed-empty-capture") + settings.costUsageEnabled = true + for provider in UsageProvider.allCases { + guard let metadata = ProviderRegistry.shared.metadata[provider] else { continue } + settings.setProviderEnabled(provider: provider, metadata: metadata, enabled: provider == .claude) + } + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + startupBehavior: .testing, + environmentBase: [:]) + store.publishConfirmedEmptyTokenSnapshot(for: .claude) + + let request = await SpendDashboardSource.makeRequest( + settings: settings, + store: store, + mode: .captureOnly) + + #expect(request.capturedInputs.isEmpty) + #expect(request.unavailableSourceIDs.isEmpty) + #expect(request.confirmedEmptySourceIDs == [UsageProvider.claude.rawValue]) + } + + @Test + func `changed provider ownership drops only stale source and retains unchanged failures`() async { + let gate = SpendDashboardLoaderGate() + let controller = Self.controller(gate: gate) + let firstConfiguration = SpendDashboardConfiguration( + costUsageEnabled: true, + providerIDs: ["codex", "claude", "openai"], + codexAccountIdentities: ["same|cache"], + sourceOwnershipFingerprints: ["claude:owner-one", "openai:owner"], + sourceRevisions: ["first"]) + let replacementConfiguration = SpendDashboardConfiguration( + costUsageEnabled: true, + providerIDs: ["codex", "claude", "openai"], + codexAccountIdentities: ["same|cache"], + sourceOwnershipFingerprints: ["claude:owner-two", "openai:owner"], + sourceRevisions: ["second"]) + + controller.update(configuration: firstConfiguration) + await Self.waitForPendingCount(1, gate: gate) + await gate.resume(at: 0, result: .init( + inputs: [ + Self.input(cost: 7), + Self.input(id: "claude", provider: .claude, cost: 3), + Self.input(id: "openai", provider: .openai, cost: 2), + ], + failedSourceIDs: [])) + await Self.waitUntil { !controller.isRefreshing } + + controller.update(configuration: replacementConfiguration) + await Self.waitForPendingCount(1, gate: gate) + #expect(controller.isRefreshing) + #expect(controller.model.groups.first?.totalCost == 9) + #expect(Set(controller.model.groups.flatMap(\.providers).map(\.id)) == ["codex", "openai"]) + #expect(controller.failedSourceCount == 0) + await gate.resume(at: 0, result: .init( + inputs: [Self.input(cost: 8)], + failedSourceIDs: ["claude", "openai"])) + await Self.waitUntil { !controller.isRefreshing } + + #expect(controller.model.groups.first?.totalCost == 10) + #expect(Set(controller.model.groups.flatMap(\.providers).map(\.id)) == ["codex", "openai"]) + #expect(controller.failedSourceCount == 2) + } + + @Test + func `changed provider ownership requires a confirmed fresh store snapshot`() async { + let settings = testSettingsStore(suiteName: "SpendDashboardControllerTests-owner-freshness") + settings.costUsageEnabled = true + for provider in UsageProvider.allCases { + guard let metadata = ProviderRegistry.shared.metadata[provider] else { continue } + settings.setProviderEnabled(provider: provider, metadata: metadata, enabled: provider == .claude) + } + settings.updateProviderConfig(provider: .claude) { config in + config.enterpriseHost = "owner-one.invalid" + } + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + startupBehavior: .testing, + environmentBase: [:]) + store._setTokenSnapshotForTesting(Self.input(provider: .claude, cost: 3).snapshot, provider: .claude) + store._test_tokenUsageRefreshOverride = { _, _ in } + let controller = Self.dashboardController(settings: settings, store: store) + + let firstConfiguration = SpendDashboardSource.configuration(settings: settings, store: store) + controller.update(configuration: firstConfiguration) + await Self.waitUntil { !controller.isRefreshing } + #expect(controller.model.groups.first?.totalCost == 3) + + settings.updateProviderConfig(provider: .claude) { config in + config.enterpriseHost = "owner-two.invalid" + } + let replacementConfiguration = SpendDashboardSource.configuration(settings: settings, store: store) + #expect(firstConfiguration.sourceOwnershipFingerprints != replacementConfiguration.sourceOwnershipFingerprints) + + controller.update(configuration: replacementConfiguration) + #expect(controller.model.groups.isEmpty) + await Self.waitUntil { !controller.isRefreshing } + + #expect(controller.model.groups.isEmpty) + #expect(controller.failedSourceCount == 1) + #expect(store.tokenSnapshot(for: .claude)?.last30DaysCostUSD == 3) + + let reopenedController = Self.dashboardController(settings: settings, store: store) + reopenedController.update(configuration: replacementConfiguration) + await Self.waitUntil { !reopenedController.isRefreshing } + #expect(reopenedController.model.groups.isEmpty) + #expect(reopenedController.failedSourceCount == 1) + + let identicalSnapshot = store.tokenSnapshot(for: .claude) + store._test_tokenUsageRefreshOverride = { provider, _ in + guard provider == .claude, let identicalSnapshot else { return } + store._setTokenSnapshotForTesting(identicalSnapshot, provider: provider) + } + settings.updateProviderConfig(provider: .claude) { config in + config.enterpriseHost = "owner-three.invalid" + } + let thirdConfiguration = SpendDashboardSource.configuration(settings: settings, store: store) + reopenedController.update(configuration: thirdConfiguration) + await Self.waitUntil { !reopenedController.isRefreshing } + #expect(reopenedController.model.groups.first?.totalCost == 3) + #expect(reopenedController.failedSourceCount == 0) + } + + @Test + func `selected token account ownership ignores inactive edits and drops failed replacement`() async throws { + let settings = testSettingsStore(suiteName: "SpendDashboardControllerTests-token-account-owner") + settings.costUsageEnabled = true + for provider in UsageProvider.allCases { + guard let metadata = ProviderRegistry.shared.metadata[provider] else { continue } + settings.setProviderEnabled(provider: provider, metadata: metadata, enabled: provider == .mistral) + } + settings.addTokenAccount(provider: .mistral, label: "Primary", token: UUID().uuidString) + settings.addTokenAccount(provider: .mistral, label: "Backup", token: UUID().uuidString) + settings.setActiveTokenAccountIndex(0, for: .mistral) + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + startupBehavior: .testing, + environmentBase: [:]) + + let primaryConfiguration = SpendDashboardSource.configuration(settings: settings, store: store) + let accounts = settings.tokenAccounts(for: .mistral) + let backup = try #require(accounts.last) + settings.updateTokenAccount(provider: .mistral, accountID: backup.id, label: "Renamed backup") + let inactiveEditConfiguration = SpendDashboardSource.configuration(settings: settings, store: store) + #expect(primaryConfiguration.sourceOwnershipFingerprints == inactiveEditConfiguration + .sourceOwnershipFingerprints) + + settings.setActiveTokenAccountIndex(1, for: .mistral) + let selectedBackupConfiguration = SpendDashboardSource.configuration(settings: settings, store: store) + #expect(inactiveEditConfiguration.sourceOwnershipFingerprints != selectedBackupConfiguration + .sourceOwnershipFingerprints) + + store._setTokenSnapshotForTesting(Self.input(provider: .mistral, cost: 3).snapshot, provider: .mistral) + store._test_providerRefreshOverride = { _ in } + let controller = Self.dashboardController(settings: settings, store: store) + controller.update(configuration: selectedBackupConfiguration) + await Self.waitUntil { !controller.isRefreshing } + #expect(controller.model.groups.first?.totalCost == 3) + + let selectedBackup = try #require(settings.effectiveSelectedTokenAccount(for: .mistral)) + settings.updateTokenAccount( + provider: .mistral, + accountID: selectedBackup.id, + token: UUID().uuidString) + let replacementConfiguration = SpendDashboardSource.configuration(settings: settings, store: store) + #expect(selectedBackupConfiguration.sourceOwnershipFingerprints != replacementConfiguration + .sourceOwnershipFingerprints) + + controller.update(configuration: replacementConfiguration) + #expect(controller.model.groups.isEmpty) + await Self.waitUntil { !controller.isRefreshing } + + #expect(controller.model.groups.isEmpty) + #expect(controller.failedSourceCount == 1) + } + + @Test + func `ordinary force failure retains same owner last good snapshot with warning`() async { + let settings = testSettingsStore(suiteName: "SpendDashboardControllerTests-force-failure") + settings.costUsageEnabled = true + for provider in UsageProvider.allCases { + guard let metadata = ProviderRegistry.shared.metadata[provider] else { continue } + settings.setProviderEnabled(provider: provider, metadata: metadata, enabled: provider == .claude) + } + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + startupBehavior: .testing, + environmentBase: [:]) + store._setTokenSnapshotForTesting(Self.input(provider: .claude, cost: 4).snapshot, provider: .claude) + store._test_tokenUsageRefreshOverride = { _, _ in } + let controller = Self.dashboardController(settings: settings, store: store) + controller.update(configuration: SpendDashboardSource.configuration(settings: settings, store: store)) + await Self.waitUntil { !controller.isRefreshing } + #expect(controller.model.groups.first?.totalCost == 4) + + controller.refresh() + await Self.waitUntil { !controller.isRefreshing } + + #expect(controller.model.groups.first?.totalCost == 4) + #expect(controller.failedSourceCount == 1) + } + + @Test + func `history scope change drops stale spend when replacement refresh is unconfirmed`() async { + let settings = testSettingsStore(suiteName: "SpendDashboardControllerTests-history-scope") + settings.costUsageEnabled = true + for provider in UsageProvider.allCases { + guard let metadata = ProviderRegistry.shared.metadata[provider] else { continue } + settings.setProviderEnabled(provider: provider, metadata: metadata, enabled: provider == .claude) + } + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + startupBehavior: .testing, + environmentBase: [:]) + store._setTokenSnapshotForTesting(Self.input(provider: .claude, cost: 5).snapshot, provider: .claude) + store._test_tokenUsageRefreshOverride = { _, _ in } + let controller = Self.dashboardController(settings: settings, store: store) + let firstConfiguration = SpendDashboardSource.configuration(settings: settings, store: store) + controller.update(configuration: firstConfiguration) + await Self.waitUntil { !controller.isRefreshing } + #expect(controller.model.groups.first?.totalCost == 5) + + settings.costUsageHistoryDays = 7 + let replacementConfiguration = SpendDashboardSource.configuration(settings: settings, store: store) + #expect(firstConfiguration.sourceOwnershipFingerprints != replacementConfiguration.sourceOwnershipFingerprints) + #expect(store.tokenSnapshotForCurrentProviderConfig(for: .claude) == nil) + + controller.update(configuration: replacementConfiguration) + #expect(controller.model.groups.isEmpty) + await Self.waitUntil { !controller.isRefreshing } + #expect(controller.model.groups.isEmpty) + #expect(controller.failedSourceCount == 1) + } + + @Test + func `Vertex spend ownership includes Claude fallback enablement`() { + let settings = testSettingsStore(suiteName: "SpendDashboardControllerTests-vertex-scope") + settings.costUsageEnabled = true + for provider in UsageProvider.allCases { + guard let metadata = ProviderRegistry.shared.metadata[provider] else { continue } + settings.setProviderEnabled(provider: provider, metadata: metadata, enabled: provider == .vertexai) + } + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + startupBehavior: .testing, + environmentBase: [:]) + store._setTokenSnapshotForTesting(Self.input(provider: .vertexai, cost: 6).snapshot, provider: .vertexai) + let firstConfiguration = SpendDashboardSource.configuration(settings: settings, store: store) + let firstVertexOwnership = firstConfiguration.sourceOwnershipFingerprints.first { + $0.hasPrefix("vertexai:") + } + #expect(firstVertexOwnership != nil) + + if let claudeMetadata = ProviderRegistry.shared.metadata[.claude] { + settings.setProviderEnabled(provider: .claude, metadata: claudeMetadata, enabled: true) + } + let replacementConfiguration = SpendDashboardSource.configuration(settings: settings, store: store) + let replacementVertexOwnership = replacementConfiguration.sourceOwnershipFingerprints.first { + $0.hasPrefix("vertexai:") + } + + #expect(firstVertexOwnership != replacementVertexOwnership) + #expect(store.tokenSnapshotForCurrentProviderConfig(for: .vertexai) == nil) + } + + @Test + func `cost tracking disable and reenable cannot revive the prior snapshot`() async { + let settings = testSettingsStore(suiteName: "SpendDashboardControllerTests-cost-enable-epoch") + settings.costUsageEnabled = true + for provider in UsageProvider.allCases { + guard let metadata = ProviderRegistry.shared.metadata[provider] else { continue } + settings.setProviderEnabled(provider: provider, metadata: metadata, enabled: provider == .claude) + } + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + startupBehavior: .testing, + environmentBase: [:]) + store._setTokenSnapshotForTesting(Self.input(provider: .claude, cost: 5).snapshot, provider: .claude) + store._test_tokenUsageRefreshOverride = { _, _ in } + let controller = Self.dashboardController(settings: settings, store: store) + controller.update(configuration: SpendDashboardSource.configuration(settings: settings, store: store)) + await Self.waitUntil { !controller.isRefreshing } + #expect(controller.model.groups.first?.totalCost == 5) + + settings.costUsageEnabled = false + controller.update(configuration: SpendDashboardSource.configuration(settings: settings, store: store)) + #expect(controller.model.groups.isEmpty) + settings.costUsageEnabled = true + let reenabledConfiguration = SpendDashboardSource.configuration(settings: settings, store: store) + #expect(store.tokenSnapshotForCurrentProviderConfig(for: .claude) == nil) + controller.update(configuration: reenabledConfiguration) + await Self.waitUntil { !controller.isRefreshing } + #expect(controller.model.groups.isEmpty) + #expect(controller.failedSourceCount == 1) + + let reopenedController = Self.dashboardController(settings: settings, store: store) + reopenedController.update(configuration: reenabledConfiguration) + await Self.waitUntil { !reopenedController.isRefreshing } + #expect(reopenedController.model.groups.isEmpty) + #expect(reopenedController.failedSourceCount == 1) + } + + @Test + func `force refresh coalesces volatile revisions and finishes every provider`() async { + let controllerBox = SpendDashboardControllerBox() + let refreshRecorder = SpendDashboardRefreshRecorder() + let initialConfiguration = Self.configuration(account: "same", revision: "initial") + let firstProviderConfiguration = Self.configuration(account: "same", revision: "claude-fresh") + let controller = SpendDashboardController( + requestBuilder: { mode in + if mode == .forceRefresh { + await refreshRecorder.append(.claude) + controllerBox.controller?.update(configuration: firstProviderConfiguration) + await refreshRecorder.append(.openai) + } + return SpendDashboardLoadRequest( + configuration: firstProviderConfiguration, + capturedInputs: [ + Self.input(id: "claude", provider: .claude, cost: 3), + Self.input(id: "openai", provider: .openai, cost: 4), + ], + unavailableSourceIDs: [], + codexRequests: [], + now: Date(timeIntervalSince1970: 1_784_179_200), + force: mode.forcesLoader) + }, + loader: { request in + SpendDashboardLoadResult(inputs: request.capturedInputs, failedSourceIDs: []) + }) + controllerBox.controller = controller + + controller.update(configuration: initialConfiguration, force: true) + await Self.waitUntil { !controller.isRefreshing } + + #expect(await refreshRecorder.providers == [.claude, .openai]) + #expect(controller.configuration == firstProviderConfiguration) + #expect(controller.generation == 2) + #expect(controller.model.groups.first?.totalCost == 7) + #expect(Set(controller.model.groups.flatMap(\.providers).map(\.id)) == ["claude", "openai"]) + } + + @Test + func `force refresh reconciles loader drift through capture barrier without second loader`() async { + let gate = SpendDashboardLoaderGate() + let forceRecorder = SpendDashboardForceRecorder() + let initialConfiguration = Self.configuration(account: "same", revision: "initial") + let latestConfiguration = Self.configuration(account: "same", revision: "latest") + let controller = SpendDashboardController( + requestBuilder: { mode in + await forceRecorder.append(mode) + if mode == .forceRefresh { + return Self.request(configuration: initialConfiguration, force: true) + } + return SpendDashboardLoadRequest( + configuration: latestConfiguration, + capturedInputs: [Self.input(id: "claude", provider: .claude, cost: 2)], + unavailableSourceIDs: [], + codexRequests: [], + now: Date(timeIntervalSince1970: 1_784_179_200), + force: false) + }, + loader: { request in await gate.load(request) }) + + controller.update(configuration: initialConfiguration, force: true) + await Self.waitForPendingCount(1, gate: gate) + controller.update(configuration: latestConfiguration) + await gate.resume(at: 0, result: .init(inputs: [Self.input(cost: 1)], failedSourceIDs: [])) + await Self.waitUntil { !controller.isRefreshing } + + #expect(controller.configuration == latestConfiguration) + #expect(controller.generation == 2) + #expect(controller.model.groups.first?.totalCost == 2) + #expect(await forceRecorder.values == [.forceRefresh, .captureOnly]) + #expect(await gate.pendingCount == 0) + } + + @Test + func `disablement cancels pending work and clears safely`() async { + let gate = SpendDashboardLoaderGate() + let controller = Self.controller(gate: gate) + controller.update(configuration: Self.configuration(account: "enabled")) + await Self.waitForPendingCount(1, gate: gate) + + controller.update(configuration: SpendDashboardConfiguration( + costUsageEnabled: false, + providerIDs: [UsageProvider.codex.rawValue], + codexAccountIdentities: ["enabled"])) + #expect(!controller.isRefreshing) + #expect(controller.model.groups.isEmpty) + + await gate.resume(at: 0, result: .init(inputs: [Self.input(cost: 99)], failedSourceIDs: [])) + await Task.yield() + #expect(controller.model.groups.isEmpty) + } + + @Test + func `range selection persists only supported windows`() throws { + let suite = "SpendDashboardControllerTests-days" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + defer { defaults.removePersistentDomain(forName: suite) } + let controller = SpendDashboardController( + userDefaults: defaults, + requestBuilder: { mode in + Self.request( + configuration: Self.configuration(account: "unused"), + force: mode.forcesLoader) + }) + + #expect(controller.selectedDays == 30) + controller.selectDays(7) + #expect(controller.selectedDays == 7) + #expect(defaults.integer(forKey: "settingsSpendDashboardDays") == 7) + controller.selectDays(9) + #expect(controller.selectedDays == 30) + } + + private static let fixtureNow = Date(timeIntervalSince1970: 1_784_179_200) + + private static func dashboardController( + settings: SettingsStore, + store: UsageStore) -> SpendDashboardController + { + SpendDashboardController( + userDefaults: settings.userDefaults, + requestBuilder: { mode in + await SpendDashboardSource.makeRequest( + settings: settings, + store: store, + mode: mode, + now: Self.fixtureNow) + }) + } + + private static func controller(gate: SpendDashboardLoaderGate) -> SpendDashboardController { + let controllerBox = SpendDashboardControllerBox() + let captureStore = SpendDashboardCapturedInputStore() + let controller = SpendDashboardController( + requestBuilder: { mode in + let configuration = controllerBox.controller?.configuration + ?? Self.configuration(account: "pending") + return await SpendDashboardLoadRequest( + configuration: configuration, + capturedInputs: mode == .captureOnly ? captureStore.inputs : [], + unavailableSourceIDs: [], + codexRequests: [], + now: Date(timeIntervalSince1970: 1_784_179_200), + force: mode.forcesLoader) + }, + loader: { request in + let result = await gate.load(request) + await captureStore.replace(with: result.inputs) + return result + }) + controllerBox.controller = controller + return controller + } + + private static func request( + configuration: SpendDashboardConfiguration, + force: Bool) -> SpendDashboardLoadRequest + { + SpendDashboardLoadRequest( + configuration: configuration, + capturedInputs: [], + unavailableSourceIDs: [], + codexRequests: [], + now: Date(timeIntervalSince1970: 1_784_179_200), + force: force) + } + + private static func configuration( + account: String, + revision: String = "", + sourceOwnershipFingerprint: String = "") -> SpendDashboardConfiguration + { + SpendDashboardConfiguration( + costUsageEnabled: true, + providerIDs: [UsageProvider.codex.rawValue], + codexAccountIdentities: [account], + sourceOwnershipFingerprints: [sourceOwnershipFingerprint], + sourceRevisions: [revision]) + } + + private static func input( + id: String? = nil, + provider: UsageProvider = .codex, + cost: Double) -> SpendDashboardModel.ProviderInput + { + let entry = CostUsageDailyReport.Entry( + date: "2026-07-15", + inputTokens: nil, + outputTokens: nil, + totalTokens: 10, + costUSD: cost, + modelsUsed: nil, + modelBreakdowns: nil) + let snapshot = CostUsageTokenSnapshot( + sessionTokens: nil, + sessionCostUSD: nil, + last30DaysTokens: 10, + last30DaysCostUSD: cost, + currencyCode: "USD", + daily: [entry], + updatedAt: Date(timeIntervalSince1970: 1_784_179_200)) + return SpendDashboardModel.ProviderInput( + id: id, + provider: provider, + displayName: provider.rawValue, + snapshot: snapshot) + } + + private static func waitForPendingCount(_ count: Int, gate: SpendDashboardLoaderGate) async { + for _ in 0..<1000 { + if await gate.pendingCount == count { + return + } + await Task.yield() + } + Issue.record("Timed out waiting for \(count) pending loads") + } + + private static func waitForCodexPendingCount(_ count: Int, gate: SpendDashboardCodexSnapshotGate) async { + for _ in 0..<1000 { + if await gate.pendingCount == count { + return + } + await Task.yield() + } + Issue.record("Timed out waiting for \(count) pending Codex loads") + } + + private static func waitUntil(_ condition: @MainActor () -> Bool) async { + for _ in 0..<1000 { + if condition() { + return + } + await Task.yield() + } + Issue.record("Timed out waiting for controller state") + } +} + +@MainActor +struct SpendDashboardRequestTimeTests { + @Test + func `default request time resolves after provider refresh boundary`() async throws { + let (settings, store) = Self.store(suiteName: "SpendDashboardRequestTimeTests-capture") + let refreshFinished = LockIsolated(false) + store._test_tokenUsageRefreshOverride = { _, _ in + refreshFinished.setValue(true) + } + let afterMidnight = try #require(ISO8601DateFormatter().date(from: "2026-07-17T00:00:01Z")) + + let request = await SpendDashboardSource.makeRequest( + settings: settings, + store: store, + mode: .forceRefresh, + nowProvider: { + #expect(refreshFinished.value) + return afterMidnight + }) + + #expect(request.now == afterMidnight) + } + + @Test + func `explicit request time remains authoritative after refresh`() async throws { + let (settings, store) = Self.store(suiteName: "SpendDashboardRequestTimeTests-explicit") + store._test_tokenUsageRefreshOverride = { _, _ in } + let injected = try #require(ISO8601DateFormatter().date(from: "2026-07-16T23:59:59Z")) + + let request = await SpendDashboardSource.makeRequest( + settings: settings, + store: store, + mode: .forceRefresh, + now: injected, + nowProvider: { + Issue.record("Explicit request time must not read the default clock") + return Date.distantFuture + }) + + #expect(request.now == injected) + } + + private static func store(suiteName: String) -> (SettingsStore, UsageStore) { + let settings = testSettingsStore(suiteName: suiteName) + settings.costUsageEnabled = true + for provider in UsageProvider.allCases { + guard let metadata = ProviderRegistry.shared.metadata[provider] else { continue } + settings.setProviderEnabled(provider: provider, metadata: metadata, enabled: provider == .claude) + } + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + startupBehavior: .testing, + environmentBase: [:]) + return (settings, store) + } +} + +@MainActor +struct SpendDashboardControllerRevisionTests { + private struct CompletenessReloadCase { + let name: String + let snapshot: CostUsageTokenSnapshot + let expectedTokens: Int? + let expectedCost: Double? + let expectedCompleteness: SpendDashboardModel.ModelHistoryCompleteness + } + + @Test + func `snapshot revision includes every dashboard completeness metric`() { + let baseline = Self.completenessSnapshot() + let baselineRevision = Self.sourceRevision( + snapshot: baseline, + suiteName: "SpendDashboardControllerTests-completeness-revision-baseline") + let mutations: [(String, CostUsageTokenSnapshot)] = [ + ("history coverage", Self.completenessSnapshot(historyCoverageIsEstablished: false)), + ("last 30 day tokens", Self.completenessSnapshot(last30DaysTokens: 1)), + ("last 30 day cost", Self.completenessSnapshot(last30DaysCostUSD: 1)), + ("entry input tokens", Self.completenessSnapshot(entryInputTokens: 1)), + ("entry cache read tokens", Self.completenessSnapshot(entryCacheReadTokens: 1)), + ("entry cache creation tokens", Self.completenessSnapshot(entryCacheCreationTokens: 1)), + ("entry output tokens", Self.completenessSnapshot(entryOutputTokens: 1)), + ("entry request count", Self.completenessSnapshot(entryRequestCount: 1)), + ("breakdown request count", Self.completenessSnapshot(breakdownRequestCount: 1)), + ("breakdown standard cost", Self.completenessSnapshot(breakdownStandardCostUSD: 1)), + ("breakdown priority cost", Self.completenessSnapshot(breakdownPriorityCostUSD: 1)), + ("breakdown standard tokens", Self.completenessSnapshot(breakdownStandardTokens: 1)), + ("breakdown priority tokens", Self.completenessSnapshot(breakdownPriorityTokens: 1)), + ] + + for (index, mutation) in mutations.enumerated() { + let revision = Self.sourceRevision( + snapshot: mutation.1, + suiteName: "SpendDashboardControllerTests-completeness-revision-\(index)") + #expect(revision != baselineRevision, "\(mutation.0) must affect the snapshot revision") + } + } + + @Test + func `same timestamp completeness mutations reload with metric specific validity`() async { + let mutations: [CompletenessReloadCase] = [ + .init( + name: "last 30 day aggregates", + snapshot: Self.completenessSnapshot( + date: "malformed", + last30DaysTokens: 1, + last30DaysCostUSD: 1), + expectedTokens: nil, + expectedCost: nil, + expectedCompleteness: .incomplete), + .init( + name: "entry request count", + snapshot: Self.completenessSnapshot(date: "malformed", entryRequestCount: 1), + expectedTokens: 0, + expectedCost: 0, + expectedCompleteness: .complete), + .init( + name: "breakdown standard cost", + snapshot: Self.completenessSnapshot(date: "malformed", breakdownStandardCostUSD: 1), + expectedTokens: 0, + expectedCost: nil, + expectedCompleteness: .incomplete), + ] + + for (index, mutation) in mutations.enumerated() { + let baseline = Self.completenessSnapshot(date: "malformed") + let (settings, store) = Self.revisionStore( + suiteName: "SpendDashboardControllerTests-completeness-reload-\(index)") + let baselineConfiguration = Self.configuration(snapshot: baseline, settings: settings, store: store) + let replacementConfiguration = Self.configuration( + snapshot: mutation.snapshot, + settings: settings, + store: store) + let gate = SpendDashboardLoaderGate() + let controller = Self.controller(gate: gate) + + #expect( + baselineConfiguration.sourceRevisions != replacementConfiguration.sourceRevisions, + "\(mutation.name) must invalidate the dashboard request") + + controller.update(configuration: baselineConfiguration) + await Self.waitForPendingCount(1, gate: gate) + await gate.resume(at: 0, result: .init( + inputs: [Self.input(provider: .claude, snapshot: baseline)], + failedSourceIDs: [])) + await Self.waitUntil { !controller.isRefreshing } + #expect(controller.model.groups.first?.providers.first?.totalTokens == 0) + #expect(controller.model.groups.first?.modelHistoryCompleteness == .complete) + + controller.update(configuration: replacementConfiguration) + await Self.waitForPendingCount(1, gate: gate) + await gate.resume(at: 0, result: .init( + inputs: [Self.input(provider: .claude, snapshot: mutation.snapshot)], + failedSourceIDs: [])) + await Self.waitUntil { !controller.isRefreshing } + + #expect(controller.generation == 2, "\(mutation.name) must trigger a replacement load") + #expect(controller.model.groups.first?.providers.first?.totalTokens == mutation.expectedTokens) + #expect(controller.model.groups.first?.providers.first?.totalCost == mutation.expectedCost) + #expect( + controller.model.groups.first?.modelHistoryCompleteness == mutation.expectedCompleteness) + #expect(controller.model.groups.first?.dailyPoints.isEmpty == true) + } + } + + private static func controller(gate: SpendDashboardLoaderGate) -> SpendDashboardController { + let controllerBox = SpendDashboardControllerBox() + let controller = SpendDashboardController( + requestBuilder: { mode in + let configuration = controllerBox.controller?.configuration + ?? SpendDashboardConfiguration( + costUsageEnabled: false, + providerIDs: [], + codexAccountIdentities: []) + return SpendDashboardLoadRequest( + configuration: configuration, + capturedInputs: [], + unavailableSourceIDs: [], + codexRequests: [], + now: Date(timeIntervalSince1970: 1_784_179_200), + force: mode.forcesLoader) + }, + loader: { request in await gate.load(request) }) + controllerBox.controller = controller + return controller + } + + private static func revisionStore(suiteName: String) -> (SettingsStore, UsageStore) { + let settings = testSettingsStore(suiteName: suiteName) + settings.costUsageEnabled = true + for provider in UsageProvider.allCases { + guard let metadata = ProviderRegistry.shared.metadata[provider] else { continue } + settings.setProviderEnabled( + provider: provider, + metadata: metadata, + enabled: provider == .claude) + } + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + startupBehavior: .testing, + environmentBase: [:]) + return (settings, store) + } + + private static func configuration( + snapshot: CostUsageTokenSnapshot, + settings: SettingsStore, + store: UsageStore) -> SpendDashboardConfiguration + { + store._setTokenSnapshotForTesting(snapshot, provider: .claude) + return SpendDashboardSource.configuration(settings: settings, store: store) + } + + private static func sourceRevision( + snapshot: CostUsageTokenSnapshot, + suiteName: String) -> [String] + { + let (settings, store) = Self.revisionStore(suiteName: suiteName) + return Self.configuration(snapshot: snapshot, settings: settings, store: store).sourceRevisions + } + + private static func completenessSnapshot( + date: String = "2026-07-15", + historyCoverageIsEstablished: Bool = true, + last30DaysTokens: Int? = 0, + last30DaysCostUSD: Double? = 0, + entryInputTokens: Int? = nil, + entryCacheReadTokens: Int? = nil, + entryCacheCreationTokens: Int? = nil, + entryOutputTokens: Int? = nil, + entryRequestCount: Int? = nil, + breakdownRequestCount: Int? = nil, + breakdownStandardCostUSD: Double? = nil, + breakdownPriorityCostUSD: Double? = nil, + breakdownStandardTokens: Int? = nil, + breakdownPriorityTokens: Int? = nil) -> CostUsageTokenSnapshot + { + let breakdown = CostUsageDailyReport.ModelBreakdown( + modelName: "", + costUSD: 0, + totalTokens: 0, + requestCount: breakdownRequestCount, + standardCostUSD: breakdownStandardCostUSD, + priorityCostUSD: breakdownPriorityCostUSD, + standardTokens: breakdownStandardTokens, + priorityTokens: breakdownPriorityTokens) + let entry = CostUsageDailyReport.Entry( + date: date, + inputTokens: entryInputTokens, + outputTokens: entryOutputTokens, + cacheReadTokens: entryCacheReadTokens, + cacheCreationTokens: entryCacheCreationTokens, + totalTokens: 0, + requestCount: entryRequestCount, + costUSD: 0, + modelsUsed: nil, + modelBreakdowns: [breakdown]) + return CostUsageTokenSnapshot( + sessionTokens: nil, + sessionCostUSD: nil, + last30DaysTokens: last30DaysTokens, + last30DaysCostUSD: last30DaysCostUSD, + historyCoverageIsEstablished: historyCoverageIsEstablished, + daily: [entry], + updatedAt: Date(timeIntervalSince1970: 1_784_179_200)) + } + + private static func input( + provider: UsageProvider, + snapshot: CostUsageTokenSnapshot) -> SpendDashboardModel.ProviderInput + { + SpendDashboardModel.ProviderInput( + provider: provider, + displayName: provider.rawValue, + snapshot: snapshot) + } + + private static func waitForPendingCount(_ count: Int, gate: SpendDashboardLoaderGate) async { + for _ in 0..<1000 { + if await gate.pendingCount == count { + return + } + await Task.yield() + } + Issue.record("Timed out waiting for \(count) pending loads") + } + + private static func waitUntil(_ condition: @MainActor () -> Bool) async { + for _ in 0..<1000 { + if condition() { + return + } + await Task.yield() + } + Issue.record("Timed out waiting for controller state") + } +} + +@MainActor +private final class SpendDashboardControllerBox { + var controller: SpendDashboardController? +} + +private actor SpendDashboardRefreshRecorder { + private(set) var providers: [UsageProvider] = [] + + func append(_ provider: UsageProvider) { + self.providers.append(provider) + } +} + +private actor SpendDashboardForceRecorder { + private(set) var values: [SpendDashboardRequestBuildMode] = [] + + func append(_ mode: SpendDashboardRequestBuildMode) { + self.values.append(mode) + } +} + +private actor SpendDashboardCapturedInputStore { + private(set) var inputs: [SpendDashboardModel.ProviderInput] = [] + + func replace(with inputs: [SpendDashboardModel.ProviderInput]) { + self.inputs = inputs + } +} + +private actor SpendDashboardCodexLoadRecorder { + private(set) var contexts: [CodexSpendSnapshotLoadContext] = [] + + func record(_ context: CodexSpendSnapshotLoadContext) { + self.contexts.append(context) + } +} + +private actor SpendDashboardLoadResultRecorder { + private(set) var results: [SpendDashboardLoadResult] = [] + + func record(_ result: SpendDashboardLoadResult) { + self.results.append(result) + } +} + +private actor SpendDashboardCodexSnapshotGate { + private var continuations: [CheckedContinuation] = [] + + var pendingCount: Int { + self.continuations.count + } + + func load(_ context: CodexSpendSnapshotLoadContext) async -> CostUsageTokenSnapshot { + _ = context + return await withCheckedContinuation { continuation in + self.continuations.append(continuation) + } + } + + func resume(at index: Int, snapshot: CostUsageTokenSnapshot) { + self.continuations.remove(at: index).resume(returning: snapshot) + } +} + +private actor SpendDashboardLoaderGate { + private var continuations: [CheckedContinuation] = [] + + var pendingCount: Int { + self.continuations.count + } + + func load(_ request: SpendDashboardLoadRequest) async -> SpendDashboardLoadResult { + _ = request + return await withCheckedContinuation { continuation in + self.continuations.append(continuation) + } + } + + func resume(at index: Int, result: SpendDashboardLoadResult) { + self.continuations.remove(at: index).resume(returning: result) + } +} diff --git a/Tests/CodexBarTests/SpendDashboardDateTruthTests.swift b/Tests/CodexBarTests/SpendDashboardDateTruthTests.swift new file mode 100644 index 0000000000..ea29bd0f58 --- /dev/null +++ b/Tests/CodexBarTests/SpendDashboardDateTruthTests.swift @@ -0,0 +1,855 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +struct SpendDashboardDateTruthTests { + private struct MalformedMetricCase { + let name: String + let breakdown: CostUsageDailyReport.ModelBreakdown + let totalCost: Double? + let totalTokens: Int? + let modelHistory: SpendDashboardModel.ModelHistoryCompleteness + let chartCost: Double? + } + + @Test + func `Mistral UTC buckets map into Pacific dashboard days at midnight UTC`() throws { + var pacific = Calendar(identifier: .gregorian) + pacific.timeZone = try #require(TimeZone(identifier: "America/Los_Angeles")) + let now = try #require(ISO8601DateFormatter().date(from: "2026-07-02T00:01:00Z")) + let june30 = try #require(pacific.date(from: DateComponents(year: 2026, month: 6, day: 30))) + let july1 = try #require(pacific.date(from: DateComponents(year: 2026, month: 7, day: 1))) + let snapshot = Self.snapshot( + currency: "EUR", + entries: [ + Self.entry(day: "2026-07-01", cost: 1, tokens: 10), + Self.entry(day: "2026-07-02", cost: 2, tokens: 20), + ], + historyDays: 2, + updatedAt: now) + let group = try #require(SpendDashboardModel.build( + inputs: [.init(provider: .mistral, displayName: "Mistral", snapshot: snapshot)], + requestedDays: 7, + now: now, + calendar: pacific).groups.first) + + #expect(group.totalCost == 3) + #expect(group.totalTokens == 30) + #expect(group.coveredDayCount == 2) + #expect(group.dailyPoints.map(\.day) == [june30, july1]) + #expect(group.dailyPoints.map(\.cost) == [1, 2]) + } + + @Test + func `Mistral coverage end preserves UTC bucket day after Pacific midnight`() throws { + var pacific = Calendar(identifier: .gregorian) + pacific.timeZone = try #require(TimeZone(identifier: "America/Los_Angeles")) + let now = try #require(ISO8601DateFormatter().date(from: "2026-07-02T08:00:00Z")) + let mistral = SpendDashboardModel.ProviderInput( + provider: .mistral, + displayName: "Mistral", + snapshot: Self.snapshot( + currency: "USD", + entries: [], + historyDays: 2, + updatedAt: now)) + let local = SpendDashboardModel.ProviderInput( + provider: .claude, + displayName: "Claude", + snapshot: Self.snapshot( + currency: "USD", + entries: [Self.entry(day: "2026-07-02", cost: 1)], + historyDays: 1, + updatedAt: now)) + let group = try #require(SpendDashboardModel.build( + inputs: [mistral, local], + requestedDays: 7, + now: now, + calendar: pacific).groups.first) + + #expect(group.coveredDayCount == 0) + #expect(group.providers.first(where: { $0.provider == .mistral })?.coveredDayCount == 2) + #expect(group.providers.first(where: { $0.provider == .claude })?.coveredDayCount == 1) + } + + @Test + func `Mistral ended range stays on observed UTC days instead of publishing recent zeros`() throws { + let formatter = ISO8601DateFormatter() + let updatedAt = try #require(formatter.date(from: "2026-07-16T12:00:00Z")) + let startDate = try #require(formatter.date(from: "2026-07-01T00:00:00Z")) + let endDate = try #require(formatter.date(from: "2026-07-02T00:00:01Z")) + let usage = MistralUsageSnapshot( + totalCost: 3, + currency: "USD", + currencySymbol: "$", + totalInputTokens: 30, + totalOutputTokens: 0, + totalCachedTokens: 0, + modelCount: 1, + daily: [ + Self.mistralBucket(day: "2026-07-01", cost: 1, tokens: 10), + Self.mistralBucket(day: "2026-07-02", cost: 2, tokens: 20), + ], + startDate: startDate, + endDate: endDate, + updatedAt: updatedAt) + let snapshot = usage.toCostUsageTokenSnapshot(historyDays: 7) + + let earlierGroup = try #require(SpendDashboardModel.build( + inputs: [.init(provider: .mistral, displayName: "Mistral", snapshot: snapshot)], + requestedDays: 30, + now: updatedAt, + calendar: Self.calendar).groups.first) + #expect(earlierGroup.providers.first?.coveredDayCount == 2) + #expect(earlierGroup.providers.first?.totalCost == 3) + #expect(earlierGroup.dailyPoints.map(\.day) == [startDate, Self.calendar.startOfDay(for: endDate)]) + + let recentGroup = try #require(SpendDashboardModel.build( + inputs: [.init(provider: .mistral, displayName: "Mistral", snapshot: snapshot)], + requestedDays: 7, + now: updatedAt, + calendar: Self.calendar).groups.first) + #expect(recentGroup.providers.first?.coveredDayCount == 0) + #expect(recentGroup.providers.first?.totalCost == nil) + #expect(recentGroup.providers.first?.totalTokens == nil) + #expect(recentGroup.dailyPoints.isEmpty) + } + + @Test + func `metadata free Mistral coverage preserves stale valid billing buckets`() throws { + let formatter = ISO8601DateFormatter() + let updatedAt = try #require(formatter.date(from: "2026-07-16T12:00:00Z")) + let july14 = try #require(formatter.date(from: "2026-07-14T00:00:00Z")) + let july15 = try #require(formatter.date(from: "2026-07-15T00:00:00Z")) + let usage = MistralUsageSnapshot( + totalCost: 3, + currency: "EUR", + currencySymbol: "€", + totalInputTokens: 30, + totalOutputTokens: 0, + totalCachedTokens: 0, + modelCount: 1, + daily: [ + Self.mistralBucket(day: "2026-07-14", cost: 1, tokens: 10), + Self.mistralBucket(day: "2026-07-15", cost: 2, tokens: 20), + ], + startDate: nil, + endDate: nil, + updatedAt: updatedAt) + let snapshot = usage.toCostUsageTokenSnapshot() + let group = try #require(SpendDashboardModel.build( + inputs: [.init(provider: .mistral, displayName: "Mistral", snapshot: snapshot)], + requestedDays: 30, + now: updatedAt, + calendar: Self.calendar).groups.first) + + #expect(snapshot.updatedAt == july15) + #expect(group.coveredDayCount == 2) + #expect(group.totalCost == 3) + #expect(group.totalTokens == 30) + #expect(group.dailyPoints.map(\.day) == [july14, july15]) + #expect(group.dailyPoints.map(\.cost) == [1, 2]) + } + + @Test + func `Mistral without established coverage cannot publish a current zero day`() throws { + let snapshot = MistralUsageSnapshot( + totalCost: 0, + currency: "EUR", + currencySymbol: "€", + totalInputTokens: 0, + totalOutputTokens: 0, + totalCachedTokens: 0, + modelCount: 0, + daily: [], + startDate: nil, + endDate: nil, + updatedAt: Self.now) + .toCostUsageTokenSnapshot(historyDays: 7) + let group = try #require(SpendDashboardModel.build( + inputs: [.init(provider: .mistral, displayName: "Mistral", snapshot: snapshot)], + requestedDays: 7, + now: Self.now, + calendar: Self.calendar).groups.first) + + #expect(!snapshot.historyCoverageIsEstablished) + #expect(group.coveredDayCount == 0) + #expect(group.providers.first?.coveredDayCount == 0) + #expect(group.totalCost == nil) + #expect(group.totalTokens == nil) + #expect(group.dailyPoints.isEmpty) + } + + @Test + func `unknown currency spend cannot enter a known currency group`() throws { + let model = SpendDashboardModel.build( + inputs: [ + Self.input(id: "usd", provider: .claude, currency: "USD", cost: 2), + Self.input(id: "blank", provider: .mistral, currency: " ", cost: 100), + Self.input(id: "unknown", provider: .openai, currency: "XXX", cost: 200), + Self.input(id: "eur", provider: .codex, currency: "EUR", cost: 3), + ], + requestedDays: 30, + now: Self.now, + calendar: Self.calendar) + + #expect(model.groups.map(\.currencyCode) == ["EUR", "USD"]) + let eur = try #require(model.groups.first(where: { $0.currencyCode == "EUR" })) + let usd = try #require(model.groups.first(where: { $0.currencyCode == "USD" })) + #expect(eur.providers.map(\.id) == ["eur"]) + #expect(eur.totalCost == 3) + #expect(usd.providers.map(\.id) == ["usd"]) + #expect(usd.totalCost == 2) + } + + @Test + func `preferred currency combines convertible dashboard groups and preserves unavailable sources`() throws { + let eurRate = try #require(CurrencyExchange.shared.rate(for: "EUR")) + let model = SpendDashboardModel.build( + inputs: [ + Self.input(id: "usd", provider: .claude, currency: "USD", cost: 2), + Self.input(id: "eur", provider: .codex, currency: "EUR", cost: 3), + Self.input(id: "chf", provider: .mistral, currency: "CHF", cost: 5), + ], + requestedDays: 30, + now: Self.now, + calendar: Self.calendar, + preferredCurrencyCode: "USD") + + #expect(model.groups.map(\.currencyCode) == ["CHF", "USD"]) + let chf = try #require(model.groups.first(where: { $0.currencyCode == "CHF" })) + let usd = try #require(model.groups.first(where: { $0.currencyCode == "USD" })) + #expect(chf.totalCost == 5) + #expect(usd.providers.map(\.id).sorted() == ["eur", "usd"]) + #expect(abs((usd.totalCost ?? 0) - (2 + 3 / eurRate)) < 1e-9) + #expect(abs((usd.dailyPoints.map(\.cost).reduce(0, +)) - (2 + 3 / eurRate)) < 1e-9) + } + + @Test + func `date with a valid prefix and trailing junk fails closed`() throws { + let snapshot = Self.snapshot(currency: "USD", entries: [ + Self.entry(day: "2026-07-16junk", cost: 2, tokens: 20), + ]) + let group = try #require(SpendDashboardModel.build( + inputs: [.init(provider: .claude, displayName: "Claude", snapshot: snapshot)], + requestedDays: 7, + now: Self.now, + calendar: Self.calendar).groups.first) + + #expect(group.providers.first?.totalCost == nil) + #expect(group.providers.first?.totalTokens == nil) + #expect(group.modelHistoryCompleteness == .incomplete) + #expect(group.dailyPoints.isEmpty) + } + + @Test + func `malformed rows validate cost and tokens independently`() throws { + let cases: [MalformedMetricCase] = [ + .init( + name: "cost", + breakdown: .init(modelName: "spend", costUSD: 1, totalTokens: 0), + totalCost: nil, + totalTokens: 30, + modelHistory: .incomplete, + chartCost: nil), + .init( + name: "tokens", + breakdown: .init(modelName: "tokens", costUSD: 0, totalTokens: 1), + totalCost: 3, + totalTokens: nil, + modelHistory: .complete, + chartCost: 3), + .init( + name: "requests", + breakdown: .init(modelName: "requests", costUSD: 0, totalTokens: 0, requestCount: 1), + totalCost: 3, + totalTokens: 30, + modelHistory: .complete, + chartCost: 3), + ] + + for testCase in cases { + let snapshot = Self.snapshot(currency: "USD", entries: [ + Self.entry(day: "2026-07-16", cost: 3, tokens: 30), + Self.entryWithBreakdowns( + day: "malformed", + breakdowns: [testCase.breakdown]), + ]) + let group = try #require(SpendDashboardModel.build( + inputs: [.init(provider: .claude, displayName: "Claude", snapshot: snapshot)], + requestedDays: 7, + now: Self.now, + calendar: Self.calendar).groups.first) + + #expect(group.providers.first?.totalCost == testCase.totalCost, Comment(rawValue: testCase.name)) + #expect(group.providers.first?.totalTokens == testCase.totalTokens, Comment(rawValue: testCase.name)) + #expect(group.modelHistoryCompleteness == testCase.modelHistory, Comment(rawValue: testCase.name)) + #expect(group.models.map(\.totalCost) == (testCase.totalCost == nil ? [] : [3])) + #expect(group.dailyPoints.first?.cost == testCase.chartCost, Comment(rawValue: testCase.name)) + } + } + + @Test + func `omitted rows preserve independent metrics sources and currencies`() throws { + let omissions = [(day: "malformed", historyDays: 30), (day: "2026-07-15", historyDays: 1)] + + for omission in omissions { + let tokenInvalid = SpendDashboardModel.ProviderInput( + id: "token-invalid", + provider: .claude, + displayName: "Token invalid", + snapshot: Self.snapshot( + currency: "USD", + entries: [ + Self.entry(day: "2026-07-16", cost: 3, tokens: 30), + Self.entry(day: omission.day, cost: 0, tokens: nil, model: nil), + ], + historyDays: omission.historyDays)) + let costInvalid = SpendDashboardModel.ProviderInput( + id: "cost-invalid", + provider: .openai, + displayName: "Cost invalid", + snapshot: Self.snapshot( + currency: "CAD", + entries: [ + Self.entry(day: "2026-07-16", cost: 2, tokens: 20), + Self.entry(day: omission.day, cost: nil, tokens: 0, model: nil), + ], + historyDays: omission.historyDays)) + let groups = SpendDashboardModel.build( + inputs: [ + tokenInvalid, + Self.input(id: "healthy-usd", provider: .codex, currency: "USD", cost: 4), + costInvalid, + Self.input(id: "healthy-cad", provider: .mistral, currency: "CAD", cost: 5), + Self.input(id: "healthy-eur", provider: .bedrock, currency: "EUR", cost: 6), + ], + requestedDays: 7, + now: Self.now, + calendar: Self.calendar).groups + let cad = try #require(groups.first(where: { $0.currencyCode == "CAD" })) + let eur = try #require(groups.first(where: { $0.currencyCode == "EUR" })) + let usd = try #require(groups.first(where: { $0.currencyCode == "USD" })) + + #expect(usd.totalCost == 7) + #expect(usd.totalTokens == nil) + #expect(usd.modelHistoryCompleteness == .complete) + #expect(usd.models.map(\.totalCost) == [4, 3]) + #expect(usd.models.first(where: { $0.provider == .claude })?.totalTokens == nil) + #expect(usd.models.first(where: { $0.provider == .codex })?.totalTokens == 10) + #expect(usd.dailyPoints.map(\.sourceID) == ["healthy-usd", "token-invalid"]) + #expect(SpendDailyChartPresentation( + dailyPoints: usd.dailyPoints, + aggregateTotal: usd.totalCost).content == .chart) + + #expect(cad.totalCost == nil) + #expect(cad.totalTokens == 30) + #expect(cad.modelHistoryCompleteness == .incomplete) + #expect(cad.models.map(\.provider) == [.mistral]) + #expect(cad.models.map(\.totalCost) == [5]) + #expect(cad.dailyPoints.map(\.sourceID) == ["healthy-cad"]) + #expect(SpendDailyChartPresentation( + dailyPoints: cad.dailyPoints, + aggregateTotal: cad.totalCost).content == .chart) + + #expect(eur.totalCost == 6) + #expect(eur.totalTokens == 10) + #expect(eur.modelHistoryCompleteness == .complete) + #expect(eur.models.map(\.totalCost) == [6]) + #expect(eur.dailyPoints.map(\.sourceID) == ["healthy-eur"]) + } + } + + @Test + func `complete model costs survive invalid aggregate and per-model tokens`() throws { + let negative = SpendDashboardModel.ProviderInput( + id: "negative", + provider: .mistral, + displayName: "Negative", + snapshot: Self.snapshot(currency: "USD", entries: [ + Self.entryWithBreakdowns( + day: "2026-07-16", + totalCost: 5, + totalTokens: -1, + breakdowns: [.init(modelName: "negative", costUSD: 5, totalTokens: -1)]), + ])) + let overflow = SpendDashboardModel.ProviderInput( + id: "overflow", + provider: .claude, + displayName: "Overflow", + snapshot: Self.snapshot(currency: "USD", entries: [ + Self.entryWithBreakdowns( + day: "2026-07-16", + totalCost: 3, + totalTokens: .max, + breakdowns: [.init(modelName: "overflow", costUSD: 3, totalTokens: .max)]), + Self.entryWithBreakdowns( + day: "2026-07-15", + totalCost: 4, + totalTokens: .max, + breakdowns: [.init(modelName: "overflow", costUSD: 4, totalTokens: .max)]), + ])) + let mismatch = SpendDashboardModel.ProviderInput( + id: "mismatch", + provider: .openai, + displayName: "Mismatch", + snapshot: Self.snapshot(currency: "USD", entries: [ + Self.entryWithBreakdowns( + day: "2026-07-16", + totalCost: 6, + totalTokens: 60, + breakdowns: [.init(modelName: "mismatch", costUSD: 6, totalTokens: 10)]), + ])) + let valid = SpendDashboardModel.ProviderInput( + id: "valid", + provider: .codex, + displayName: "Valid", + snapshot: Self.snapshot(currency: "USD", entries: [ + Self.entryWithBreakdowns( + day: "2026-07-16", + totalCost: 2, + totalTokens: 2, + breakdowns: [.init(modelName: "valid", costUSD: 2, totalTokens: 2)]), + ])) + let group = try #require(SpendDashboardModel.build( + inputs: [negative, overflow, mismatch, valid], + requestedDays: 7, + now: Self.now, + calendar: Self.calendar).groups.first) + + #expect(group.totalCost == 20) + #expect(group.totalTokens == nil) + #expect(group.modelHistoryCompleteness == .complete) + #expect(group.models.map(\.modelName) == ["overflow", "mismatch", "negative", "valid"]) + #expect(group.models.map(\.totalCost) == [7, 6, 5, 2]) + #expect(group.models.map(\.totalTokens) == [nil, nil, nil, 2]) + #expect(Set(group.dailyPoints.map(\.sourceID)) == ["mismatch", "negative", "overflow", "valid"]) + } + + @Test + func `malformed source is omitted without hiding healthy currency peers`() throws { + let malformed = SpendDashboardModel.ProviderInput( + id: "malformed", + provider: .claude, + displayName: "Malformed", + snapshot: Self.snapshot(currency: "USD", entries: [ + Self.entry(day: "2026-07-16", cost: 3, tokens: 30), + Self.entry(day: "not-a-day", cost: 7, tokens: 70), + ])) + let healthyUSD = Self.input(id: "healthy-usd", provider: .codex, currency: "USD", cost: 4) + let healthyEUR = Self.input(id: "healthy-eur", provider: .openai, currency: "EUR", cost: 5) + let groups = SpendDashboardModel.build( + inputs: [malformed, healthyUSD, healthyEUR], + requestedDays: 7, + now: Self.now, + calendar: Self.calendar).groups + let eur = try #require(groups.first(where: { $0.currencyCode == "EUR" })) + let usd = try #require(groups.first(where: { $0.currencyCode == "USD" })) + + #expect(usd.providers.first(where: { $0.id == "malformed" })?.totalCost == nil) + #expect(usd.providers.first(where: { $0.id == "malformed" })?.totalTokens == nil) + #expect(usd.totalCost == nil) + #expect(usd.totalTokens == nil) + #expect(usd.modelHistoryCompleteness == .incomplete) + #expect(usd.models.map(\.provider) == [.codex]) + #expect(usd.models.map(\.totalCost) == [4]) + #expect(usd.dailyPoints.map(\.sourceID) == ["healthy-usd"]) + #expect(usd.dailyPoints.map(\.cost) == [4]) + #expect(eur.totalCost == 5) + #expect(eur.totalTokens == 10) + #expect(eur.modelHistoryCompleteness == .complete) + #expect(eur.dailyPoints.map(\.sourceID) == ["healthy-eur"]) + } + + @Test + func `coverage contradiction fails source closed across every aggregate`() throws { + let contradictions = [ + Self.entry(day: "2026-07-15", cost: 7, tokens: 70), + Self.entry(day: "2026-07-15", cost: nil, tokens: nil, model: nil), + ] + + for contradiction in contradictions { + let snapshot = Self.snapshot( + currency: "USD", + entries: [ + Self.entry(day: "2026-07-16", cost: 3, tokens: 30), + contradiction, + ], + historyDays: 1) + let group = try #require(SpendDashboardModel.build( + inputs: [.init(provider: .claude, displayName: "Claude", snapshot: snapshot)], + requestedDays: 7, + now: Self.now, + calendar: Self.calendar).groups.first) + + #expect(group.providers.first?.coveredDayCount == 1) + #expect(group.providers.first?.totalCost == nil) + #expect(group.providers.first?.totalTokens == nil) + #expect(group.totalCost == nil) + #expect(group.totalTokens == nil) + #expect(group.modelHistoryCompleteness == .incomplete) + #expect(group.models.isEmpty) + #expect(group.dailyPoints.isEmpty) + } + } + + @Test + func `entries inside declared coverage aggregate normally`() throws { + let snapshot = Self.snapshot( + currency: "USD", + entries: [ + Self.entry(day: "2026-07-15", cost: 7, tokens: 70), + Self.entry(day: "2026-07-16", cost: 3, tokens: 30), + ], + historyDays: 2) + let group = try #require(SpendDashboardModel.build( + inputs: [.init(provider: .claude, displayName: "Claude", snapshot: snapshot)], + requestedDays: 7, + now: Self.now, + calendar: Self.calendar).groups.first) + + #expect(group.totalCost == 10) + #expect(group.totalTokens == 100) + #expect(group.modelHistoryCompleteness == .complete) + #expect(group.models.map(\.totalCost) == [10]) + #expect(group.dailyPoints.map(\.cost) == [7, 3]) + } + + @Test + func `aggregate contradictions fail only the affected metric`() throws { + let entry = Self.entry(day: "2026-07-16", cost: 3, tokens: 30) + let costContradiction = Self.snapshot( + currency: "USD", + entries: [entry], + last30DaysTokens: 30, + last30DaysCostUSD: 10) + let tokenContradiction = Self.snapshot( + currency: "USD", + entries: [entry], + last30DaysTokens: 100, + last30DaysCostUSD: 3) + + let costGroup = try #require(SpendDashboardModel.build( + inputs: [.init(provider: .claude, displayName: "Claude", snapshot: costContradiction)], + requestedDays: 30, + now: Self.now, + calendar: Self.calendar).groups.first) + let tokenGroup = try #require(SpendDashboardModel.build( + inputs: [.init(provider: .claude, displayName: "Claude", snapshot: tokenContradiction)], + requestedDays: 30, + now: Self.now, + calendar: Self.calendar).groups.first) + + #expect(costGroup.totalCost == nil) + #expect(costGroup.totalTokens == 30) + #expect(costGroup.dailyPoints.isEmpty) + #expect(costGroup.modelHistoryCompleteness == .incomplete) + #expect(costGroup.models.isEmpty) + + #expect(tokenGroup.totalCost == 3) + #expect(tokenGroup.totalTokens == nil) + #expect(tokenGroup.dailyPoints.map(\.cost) == [3]) + #expect(tokenGroup.modelHistoryCompleteness == .complete) + #expect(tokenGroup.models.map(\.totalCost) == [3]) + #expect(tokenGroup.models.map(\.totalTokens) == [nil]) + } + + @Test + func `matching full history aggregates allow shorter selected window`() throws { + let snapshot = Self.snapshot( + currency: "USD", + entries: [ + Self.entry(day: "2026-07-06", cost: 7, tokens: 70), + Self.entry(day: "2026-07-16", cost: 3, tokens: 30), + ], + historyDays: 30, + last30DaysTokens: 100, + last30DaysCostUSD: 10) + let group = try #require(SpendDashboardModel.build( + inputs: [.init(provider: .claude, displayName: "Claude", snapshot: snapshot)], + requestedDays: 7, + now: Self.now, + calendar: Self.calendar).groups.first) + + #expect(group.totalCost == 3) + #expect(group.totalTokens == 30) + #expect(group.models.map(\.totalCost) == [3]) + #expect(group.models.map(\.totalTokens) == [30]) + #expect(group.dailyPoints.map(\.cost) == [3]) + } + + @Test + func `out of request usage and proven zero outside coverage are harmless`() throws { + let snapshot = Self.snapshot( + currency: "USD", + entries: [ + Self.entry(day: "2026-07-01", cost: 100, tokens: 1000), + Self.entry(day: "2026-07-15", cost: 0, tokens: 0, model: nil), + Self.entry(day: "2026-07-16", cost: 3, tokens: 30), + ], + historyDays: 1) + let group = try #require(SpendDashboardModel.build( + inputs: [.init(provider: .claude, displayName: "Claude", snapshot: snapshot)], + requestedDays: 7, + now: Self.now, + calendar: Self.calendar).groups.first) + + #expect(group.totalCost == 3) + #expect(group.totalTokens == 30) + #expect(group.modelHistoryCompleteness == .complete) + #expect(group.models.map(\.totalCost) == [3]) + #expect(group.dailyPoints.map(\.cost) == [3]) + } + + @Test + func `coverage contradiction omits only its source and currency`() throws { + let contradictory = SpendDashboardModel.ProviderInput( + id: "contradictory", + provider: .claude, + displayName: "Contradictory", + snapshot: Self.snapshot( + currency: "USD", + entries: [ + Self.entry(day: "2026-07-15", cost: 7, tokens: 70), + Self.entry(day: "2026-07-16", cost: 3, tokens: 30), + ], + historyDays: 1)) + let healthyUSD = Self.input(id: "healthy-usd", provider: .codex, currency: "USD", cost: 4) + let healthyEUR = Self.input(id: "healthy-eur", provider: .openai, currency: "EUR", cost: 5) + let groups = SpendDashboardModel.build( + inputs: [contradictory, healthyUSD, healthyEUR], + requestedDays: 7, + now: Self.now, + calendar: Self.calendar).groups + let eur = try #require(groups.first(where: { $0.currencyCode == "EUR" })) + let usd = try #require(groups.first(where: { $0.currencyCode == "USD" })) + + #expect(usd.totalCost == nil) + #expect(usd.modelHistoryCompleteness == .incomplete) + #expect(usd.models.map(\.provider) == [.codex]) + #expect(usd.models.map(\.totalCost) == [4]) + #expect(usd.dailyPoints.map(\.sourceID) == ["healthy-usd"]) + #expect(usd.dailyPoints.map(\.cost) == [4]) + #expect(eur.totalCost == 5) + #expect(eur.modelHistoryCompleteness == .complete) + #expect(eur.models.map(\.totalCost) == [5]) + #expect(eur.dailyPoints.map(\.sourceID) == ["healthy-eur"]) + } + + @Test + func `empty Mistral history with incomplete aggregates stays unavailable`() throws { + let snapshot = Self.mistralSnapshot(totalCost: 5, totalTokens: 50) + #expect(snapshot.last30DaysCostUSD == nil) + #expect(snapshot.last30DaysTokens == nil) + let group = try #require(SpendDashboardModel.build( + inputs: [.init(provider: .mistral, displayName: "Mistral", snapshot: snapshot)], + requestedDays: 7, + now: Self.now, + calendar: Self.calendar).groups.first) + + #expect(!snapshot.historyCoverageIsEstablished) + #expect(group.coveredDayCount == 0) + #expect(group.totalCost == nil) + #expect(group.totalTokens == nil) + #expect(group.modelHistoryCompleteness == .incomplete) + #expect(group.models.isEmpty) + } + + @Test + func `empty Mistral history with declared coverage preserves explicit zeros`() throws { + let snapshot = Self.mistralSnapshot(totalCost: 0, totalTokens: 0, establishesCoverage: true) + #expect(snapshot.historyCoverageIsEstablished) + #expect(snapshot.last30DaysCostUSD == 0) + #expect(snapshot.last30DaysTokens == 0) + let group = try #require(SpendDashboardModel.build( + inputs: [.init(provider: .mistral, displayName: "Mistral", snapshot: snapshot)], + requestedDays: 7, + now: Self.now, + calendar: Self.calendar).groups.first) + + #expect(group.totalCost == 0) + #expect(group.totalTokens == 0) + #expect(group.modelHistoryCompleteness == .complete) + #expect(group.models.isEmpty) + } + + @Test + func `malformed zero row cannot prove contradictory nonzero aggregates`() throws { + let snapshot = Self.snapshot( + currency: "USD", + entries: [Self.entry(day: "malformed", cost: 0, tokens: 0, model: nil)], + historyDays: 1, + last30DaysTokens: 1, + last30DaysCostUSD: 1) + let group = try #require(SpendDashboardModel.build( + inputs: [.init(provider: .claude, displayName: "Claude", snapshot: snapshot)], + requestedDays: 7, + now: Self.now, + calendar: Self.calendar).groups.first) + + #expect(group.totalCost == nil) + #expect(group.totalTokens == nil) + #expect(group.modelHistoryCompleteness == .incomplete) + #expect(group.dailyPoints.isEmpty) + } + + @Test + func `empty history metric proof stays independent and currency scoped`() throws { + let costOnly = SpendDashboardModel.ProviderInput( + id: "cost-only", + provider: .mistral, + displayName: "Cost only", + snapshot: Self.mistralSnapshot( + totalCost: 0, + totalTokens: 50, + currency: "USD", + establishesCoverage: true)) + let completeUSD = SpendDashboardModel.ProviderInput( + id: "complete-usd", + provider: .claude, + displayName: "Complete USD", + snapshot: Self.snapshot( + currency: "USD", + entries: [], + historyDays: 1, + last30DaysTokens: 0, + last30DaysCostUSD: 0)) + let tokenOnly = SpendDashboardModel.ProviderInput( + id: "token-only", + provider: .mistral, + displayName: "Token only", + snapshot: Self.mistralSnapshot( + totalCost: 5, + totalTokens: 0, + currency: "EUR", + establishesCoverage: true)) + let groups = SpendDashboardModel.build( + inputs: [costOnly, completeUSD, tokenOnly], + requestedDays: 7, + now: Self.now, + calendar: Self.calendar).groups + let eur = try #require(groups.first(where: { $0.currencyCode == "EUR" })) + let usd = try #require(groups.first(where: { $0.currencyCode == "USD" })) + + #expect(usd.totalCost == 0) + #expect(usd.totalTokens == nil) + #expect(usd.modelHistoryCompleteness == .complete) + #expect(eur.totalCost == nil) + #expect(eur.totalTokens == 0) + #expect(eur.modelHistoryCompleteness == .incomplete) + } + + private static func input( + id: String, + provider: UsageProvider, + currency: String, + cost: Double) -> SpendDashboardModel.ProviderInput + { + SpendDashboardModel.ProviderInput( + id: id, + provider: provider, + displayName: provider.rawValue, + snapshot: self.snapshot( + currency: currency, + entries: [self.entry(day: "2026-07-16", cost: cost)])) + } + + private static func snapshot( + currency: String, + entries: [CostUsageDailyReport.Entry], + historyDays: Int = 30, + last30DaysTokens: Int? = nil, + last30DaysCostUSD: Double? = nil, + updatedAt: Date = now) -> CostUsageTokenSnapshot + { + CostUsageTokenSnapshot( + sessionTokens: nil, + sessionCostUSD: nil, + last30DaysTokens: last30DaysTokens, + last30DaysCostUSD: last30DaysCostUSD, + currencyCode: currency, + historyDays: historyDays, + daily: entries, + updatedAt: updatedAt) + } + + private static func mistralSnapshot( + totalCost: Double, + totalTokens: Int, + currency: String = "USD", + establishesCoverage: Bool = false) -> CostUsageTokenSnapshot + { + MistralUsageSnapshot( + totalCost: totalCost, + currency: currency, + currencySymbol: currency, + totalInputTokens: totalTokens, + totalOutputTokens: 0, + totalCachedTokens: 0, + modelCount: 0, + daily: [], + startDate: establishesCoverage ? self.now : nil, + endDate: establishesCoverage ? self.now : nil, + updatedAt: self.now) + .toCostUsageTokenSnapshot(historyDays: 7) + } + + private static func entry( + day: String, + cost: Double?, + tokens: Int? = 10, + model: String? = "test-model") -> CostUsageDailyReport.Entry + { + CostUsageDailyReport.Entry( + date: day, + inputTokens: nil, + outputTokens: nil, + totalTokens: tokens, + costUSD: cost, + modelsUsed: nil, + modelBreakdowns: model.map { + [.init(modelName: $0, costUSD: cost, totalTokens: tokens)] + }) + } + + private static func entryWithBreakdowns( + day: String, + totalCost: Double = 0, + totalTokens: Int = 0, + breakdowns: [CostUsageDailyReport.ModelBreakdown]) -> CostUsageDailyReport.Entry + { + CostUsageDailyReport.Entry( + date: day, + inputTokens: nil, + outputTokens: nil, + totalTokens: totalTokens, + costUSD: totalCost, + modelsUsed: nil, + modelBreakdowns: breakdowns) + } + + private static func mistralBucket(day: String, cost: Double, tokens: Int) -> MistralDailyUsageBucket { + MistralDailyUsageBucket( + day: day, + cost: cost, + inputTokens: tokens, + cachedTokens: 0, + outputTokens: 0, + models: [ + .init( + name: "test-model", + cost: cost, + inputTokens: tokens, + cachedTokens: 0, + outputTokens: 0), + ]) + } + + private static let now = Date(timeIntervalSince1970: 1_784_179_200) + private static var calendar: Calendar { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(secondsFromGMT: 0)! + return calendar + } +} diff --git a/Tests/CodexBarTests/SpendDashboardForceStateMachineTests.swift b/Tests/CodexBarTests/SpendDashboardForceStateMachineTests.swift new file mode 100644 index 0000000000..e0c1f152c6 --- /dev/null +++ b/Tests/CodexBarTests/SpendDashboardForceStateMachineTests.swift @@ -0,0 +1,888 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +@MainActor +struct SpendDashboardForceStateMachineTests { + @Test + func `A forced failures dominate stale capture and retain only trusted old rows`() async { + let initial = Self.configuration(owner: "owner", revision: "R") + let latest = Self.configuration(owner: "owner", revision: "L") + let oldInputs = [ + Self.input(id: "claude", provider: .claude, cost: 3), + Self.input(id: "codex:a", provider: .codex, cost: 5), + ] + let failedIDs: Set = ["claude", "codex:a"] + let builder = SpendDashboardBuildScript([ + .init(mode: .refreshMissing, request: Self.request(initial, mode: .refreshMissing)), + .init( + mode: .forceRefresh, + request: Self.request( + initial, + mode: .forceRefresh, + codexAccount: true)), + .init( + mode: .captureOnly, + request: Self.request( + latest, + mode: .captureOnly, + inputs: [ + Self.input(id: "claude", provider: .claude, cost: 90), + Self.input(id: "codex:a", provider: .codex, cost: 90), + ])), + ]) + let loader = SpendDashboardStateLoaderGate() + let controller = SpendDashboardController( + requestBuilder: { mode in await builder.next(mode) }, + loader: { request in await loader.load(request) }) + + controller.update(configuration: initial) + await Self.waitForLoader(loader) + await loader.resume(SpendDashboardLoadResult(inputs: oldInputs, failedSourceIDs: [])) + await Self.waitUntil { !controller.isRefreshing } + + controller.refresh() + await Self.waitForLoader(loader) + controller.update(configuration: latest) + await loader.resume(SpendDashboardLoadResult(inputs: [], failedSourceIDs: failedIDs)) + await Self.waitUntil { !controller.isRefreshing } + + #expect(builder.modes == [.refreshMissing, .forceRefresh, .captureOnly]) + #expect(await loader.forces == [false, true]) + #expect(controller.failedSourceCount == 2) + #expect(controller.model.groups.first?.totalCost == 8) + } + + @Test + func `B capture drift wins for providers while forced Codex success carries`() async { + let initial = Self.configuration(owner: "owner", revision: "R") + let latest = Self.configuration(owner: "owner", revision: "L") + let builder = SpendDashboardBuildScript([ + .init( + mode: .forceRefresh, + request: Self.request( + initial, + mode: .forceRefresh, + confirmedEmptySourceIDs: ["claude"], + codexAccount: true)), + .init( + mode: .captureOnly, + request: Self.request( + latest, + mode: .captureOnly, + inputs: [Self.input(id: "claude", provider: .claude, cost: 7)])), + ]) + let loader = SpendDashboardStateLoaderGate() + let controller = SpendDashboardController( + requestBuilder: { mode in await builder.next(mode) }, + loader: { request in await loader.load(request) }) + + controller.update(configuration: initial, force: true) + await Self.waitForLoader(loader) + controller.update(configuration: latest) + await loader.resume(SpendDashboardLoadResult( + inputs: [Self.input(id: "codex:a", provider: .codex, cost: 5)], + failedSourceIDs: [])) + await Self.waitUntil { !controller.isRefreshing } + + #expect(builder.modes == [.forceRefresh, .captureOnly]) + #expect(await loader.forces == [true]) + #expect(controller.configuration == latest) + #expect(controller.model.groups.first?.totalCost == 12) + } + + @Test + func `C same owner barrier churn repeats capture only and preserves failures`() async { + let initial = Self.configuration(owner: "owner", revision: "R") + let first = Self.configuration(owner: "owner", revision: "L") + let second = Self.configuration(owner: "owner", revision: "M") + let third = Self.configuration(owner: "owner", revision: "N") + let latest = Self.configuration(owner: "owner", revision: "O") + let firstCaptureGate = SpendDashboardStateBuildGate() + let secondCaptureGate = SpendDashboardStateBuildGate() + let thirdCaptureGate = SpendDashboardStateBuildGate() + let builder = SpendDashboardBuildScript([ + .init(mode: .forceRefresh, request: Self.request(initial, mode: .forceRefresh)), + .init( + mode: .captureOnly, + request: Self.request( + first, + mode: .captureOnly, + confirmedEmptySourceIDs: ["claude"]), + gate: firstCaptureGate), + .init( + mode: .captureOnly, + request: Self.request( + second, + mode: .captureOnly, + unavailableSourceIDs: ["claude"]), + gate: secondCaptureGate), + .init( + mode: .captureOnly, + request: Self.request( + third, + mode: .captureOnly, + inputs: [Self.input(id: "claude", provider: .claude, cost: 3)]), + gate: thirdCaptureGate), + .init( + mode: .captureOnly, + request: Self.request( + latest, + mode: .captureOnly, + confirmedEmptySourceIDs: ["claude"])), + ]) + let loader = SpendDashboardStateLoaderGate() + let controller = SpendDashboardController( + requestBuilder: { mode in await builder.next(mode) }, + loader: { request in await loader.load(request) }) + + controller.update(configuration: initial, force: true) + await Self.waitForLoader(loader) + controller.update(configuration: first) + await loader.resume(SpendDashboardLoadResult(inputs: [], failedSourceIDs: ["openai"])) + await Self.waitForBuildGate(firstCaptureGate) + controller.update(configuration: second) + await firstCaptureGate.resume() + await Self.waitForBuildGate(secondCaptureGate) + controller.update(configuration: third) + await secondCaptureGate.resume() + await Self.waitForBuildGate(thirdCaptureGate) + controller.update(configuration: latest) + await thirdCaptureGate.resume() + await Self.waitUntil { !controller.isRefreshing } + + #expect(builder.modes == [ + .forceRefresh, + .captureOnly, + .captureOnly, + .captureOnly, + .captureOnly, + ]) + #expect(await loader.forces == [true]) + #expect(controller.configuration == latest) + #expect(controller.failedSourceCount == 1) + #expect(controller.model.groups.isEmpty) + } + + @Test + func `D mandatory barrier catches delayed observation without later reload`() async { + let initial = Self.configuration(owner: "owner", revision: "R") + let latest = Self.configuration(owner: "owner", revision: "L") + let builder = SpendDashboardBuildScript([ + .init(mode: .forceRefresh, request: Self.request(initial, mode: .forceRefresh)), + .init( + mode: .captureOnly, + request: Self.request( + latest, + mode: .captureOnly, + inputs: [Self.input(id: "claude", provider: .claude, cost: 7)])), + ]) + let loader = SpendDashboardStateLoaderGate() + let controller = SpendDashboardController( + requestBuilder: { mode in await builder.next(mode) }, + loader: { request in await loader.load(request) }) + + controller.update(configuration: initial, force: true) + await Self.waitForLoader(loader) + await loader.resume(SpendDashboardLoadResult( + inputs: [Self.input(id: "claude", provider: .claude, cost: 1)], + failedSourceIDs: [])) + await Self.waitUntil { !controller.isRefreshing } + + let settledGeneration = controller.generation + controller.update(configuration: latest) + await Task.yield() + + #expect(builder.modes == [.forceRefresh, .captureOnly]) + #expect(await loader.forces == [true]) + #expect(controller.configuration == latest) + #expect(controller.generation == settledGeneration) + #expect(controller.model.groups.first?.totalCost == 7) + } + + @Test + func `E owner change during barrier discards carry and forces new owner`() async { + let firstOwner = Self.configuration(owner: "owner-one", revision: "R") + let firstOwnerLatest = Self.configuration(owner: "owner-one", revision: "S") + let secondOwner = Self.configuration(owner: "owner-two", revision: "L") + let learnedEmptyGate = SpendDashboardStateBuildGate() + let oldBarrierGate = SpendDashboardStateBuildGate() + let builder = SpendDashboardBuildScript([ + .init( + mode: .forceRefresh, + request: Self.request(firstOwner, mode: .forceRefresh, codexAccount: true)), + .init( + mode: .captureOnly, + request: Self.request( + firstOwner, + mode: .captureOnly, + confirmedEmptySourceIDs: ["claude"]), + gate: learnedEmptyGate), + .init( + mode: .captureOnly, + request: Self.request(firstOwnerLatest, mode: .captureOnly), + gate: oldBarrierGate), + .init(mode: .forceRefresh, request: Self.request(secondOwner, mode: .forceRefresh)), + .init( + mode: .captureOnly, + request: Self.request( + secondOwner, + mode: .captureOnly, + inputs: [Self.input(id: "claude", provider: .claude, cost: 8)])), + ]) + let loader = SpendDashboardStateLoaderGate() + let controller = SpendDashboardController( + requestBuilder: { mode in await builder.next(mode) }, + loader: { request in await loader.load(request) }) + + controller.update(configuration: firstOwner, force: true) + await Self.waitForLoader(loader) + await loader.resume(SpendDashboardLoadResult( + inputs: [Self.input(id: "codex:a", provider: .codex, cost: 5)], + failedSourceIDs: [])) + await Self.waitForBuildGate(learnedEmptyGate) + controller.update(configuration: firstOwnerLatest) + await learnedEmptyGate.resume() + await Self.waitForBuildGate(oldBarrierGate) + + controller.update(configuration: secondOwner) + await Self.waitForLoader(loader) + await loader.resume(SpendDashboardLoadResult( + inputs: [Self.input(id: "claude", provider: .claude, cost: 7)], + failedSourceIDs: [])) + await Self.waitUntil { !controller.isRefreshing } + await oldBarrierGate.resume() + await Task.yield() + + #expect(builder.modes == [.forceRefresh, .captureOnly, .captureOnly, .forceRefresh, .captureOnly]) + #expect(await loader.forces == [true, true]) + #expect(controller.configuration == secondOwner) + #expect(controller.model.groups.first?.totalCost == 8) + #expect(controller.model.groups.flatMap(\.providers).allSatisfy { $0.id != "codex:a" }) + } + + @Test + func `F confirmed empty capture wins over forced provider success`() async { + let configuration = Self.configuration(owner: "owner", revision: "R") + let oldInput = Self.input(id: "claude", provider: .claude, cost: 4) + let builder = SpendDashboardBuildScript([ + .init(mode: .refreshMissing, request: Self.request(configuration, mode: .refreshMissing)), + .init(mode: .forceRefresh, request: Self.request(configuration, mode: .forceRefresh)), + .init( + mode: .captureOnly, + request: Self.request( + configuration, + mode: .captureOnly, + confirmedEmptySourceIDs: ["claude"])), + ]) + let loader = SpendDashboardStateLoaderGate() + let controller = SpendDashboardController( + requestBuilder: { mode in await builder.next(mode) }, + loader: { request in await loader.load(request) }) + + controller.update(configuration: configuration) + await Self.waitForLoader(loader) + await loader.resume(SpendDashboardLoadResult(inputs: [oldInput], failedSourceIDs: [])) + await Self.waitUntil { !controller.isRefreshing } + + controller.refresh() + await Self.waitForLoader(loader) + await loader.resume(SpendDashboardLoadResult( + inputs: [Self.input(id: "claude", provider: .claude, cost: 6)], + failedSourceIDs: [])) + await Self.waitUntil { !controller.isRefreshing } + + #expect(builder.modes == [.refreshMissing, .forceRefresh, .captureOnly]) + #expect(await loader.forces == [false, true]) + #expect(controller.model.groups.isEmpty) + #expect(controller.failedSourceCount == 0) + } + + @Test + func `G forced Codex invalidation suppresses stale capture and retained row`() async { + let configuration = Self.configuration(owner: "owner", revision: "R") + let codexInput = Self.input(id: "codex:a", provider: .codex, cost: 4) + let builder = SpendDashboardBuildScript([ + .init(mode: .refreshMissing, request: Self.request(configuration, mode: .refreshMissing)), + .init( + mode: .forceRefresh, + request: Self.request(configuration, mode: .forceRefresh, codexAccount: true)), + .init( + mode: .captureOnly, + request: Self.request( + configuration, + mode: .captureOnly, + inputs: [Self.input(id: "codex:a", provider: .codex, cost: 99)])), + ]) + let loader = SpendDashboardStateLoaderGate() + let controller = SpendDashboardController( + requestBuilder: { mode in await builder.next(mode) }, + loader: { request in await loader.load(request) }) + + controller.update(configuration: configuration) + await Self.waitForLoader(loader) + await loader.resume(SpendDashboardLoadResult(inputs: [codexInput], failedSourceIDs: [])) + await Self.waitUntil { !controller.isRefreshing } + + controller.refresh() + await Self.waitForLoader(loader) + await loader.resume(SpendDashboardLoadResult( + inputs: [], + failedSourceIDs: ["codex:a"], + invalidatedSourceIDs: ["codex:a"])) + await Self.waitUntil { !controller.isRefreshing } + + #expect(builder.modes == [.refreshMissing, .forceRefresh, .captureOnly]) + #expect(await loader.forces == [false, true]) + #expect(controller.model.groups.isEmpty) + #expect(controller.failedSourceCount == 1) + } + + @Test + func `H ordinary update uses refresh missing and one loader without barrier`() async { + let configuration = Self.configuration(owner: "owner", revision: "R") + let input = Self.input(id: "claude", provider: .claude, cost: 3) + let builder = SpendDashboardBuildScript([ + .init( + mode: .refreshMissing, + request: Self.request(configuration, mode: .refreshMissing, inputs: [input])), + ]) + let loader = SpendDashboardStateLoaderGate() + let controller = SpendDashboardController( + requestBuilder: { mode in await builder.next(mode) }, + loader: { request in await loader.load(request) }) + + controller.update(configuration: configuration) + await Self.waitForLoader(loader) + await loader.resume(SpendDashboardLoadResult(inputs: [input], failedSourceIDs: [])) + await Self.waitUntil { !controller.isRefreshing } + + #expect(builder.modes == [.refreshMissing]) + #expect(await loader.forces == [false]) + #expect(controller.generation == 1) + #expect(controller.model.groups.first?.totalCost == 3) + } + + @Test + func `I empty provider published during Codex scan clears retained spend without later reload`() async { + let initial = Self.configuration(owner: "owner", revision: "R") + let confirmedEmpty = Self.configuration(owner: "owner", revision: "E") + let oldProviderInput = Self.input(id: "claude", provider: .claude, cost: 4) + let codexInput = Self.input(id: "codex:a", provider: .codex, cost: 2) + let builder = SpendDashboardBuildScript([ + .init( + mode: .refreshMissing, + request: Self.request(initial, mode: .refreshMissing, inputs: [oldProviderInput])), + .init( + mode: .forceRefresh, + request: Self.request( + initial, + mode: .forceRefresh, + unavailableSourceIDs: ["claude"], + codexAccount: true)), + .init( + mode: .captureOnly, + request: Self.request( + confirmedEmpty, + mode: .captureOnly, + confirmedEmptySourceIDs: ["claude"])), + ]) + let codexGate = SpendDashboardStateCodexGate() + let loaderRecorder = SpendDashboardStateLoadRecorder() + let controller = SpendDashboardController( + requestBuilder: { mode in await builder.next(mode) }, + loader: { request in + await loaderRecorder.record(request) + return await SpendDashboardSource.load(request, codexSnapshotLoader: { _ in + await codexGate.load() + }) + }) + + controller.update(configuration: initial) + await Self.waitUntil { !controller.isRefreshing } + #expect(controller.model.groups.first?.totalCost == 4) + + controller.refresh() + await Self.waitForCodexGate(codexGate) + controller.update(configuration: confirmedEmpty) + await codexGate.resume(codexInput.snapshot) + await Self.waitUntil { !controller.isRefreshing } + + let settledGeneration = controller.generation + let settledLoadCount = await loaderRecorder.count + controller.update(configuration: confirmedEmpty) + await Task.yield() + + #expect(builder.modes == [.refreshMissing, .forceRefresh, .captureOnly]) + #expect(settledLoadCount == 2) + #expect(await loaderRecorder.count == settledLoadCount) + #expect(controller.generation == settledGeneration) + #expect(controller.configuration == confirmedEmpty) + #expect(controller.failedSourceCount == 1) + #expect(controller.model.groups.first?.totalCost == 2) + #expect(controller.model.groups.flatMap(\.providers).map(\.id) == ["codex:a"]) + } + + @Test + func `J forced empty survives unavailable capture churn without restoring old spend`() async { + let initial = Self.configuration(owner: "owner", revision: "R") + let unavailable = Self.configuration(owner: "owner", revision: "U") + let latest = Self.configuration(owner: "owner", revision: "M") + let oldProviderInput = Self.input(id: "claude", provider: .claude, cost: 4) + let codexInput = Self.input(id: "codex:a", provider: .codex, cost: 2) + let captureGate = SpendDashboardStateBuildGate() + let builder = SpendDashboardBuildScript([ + .init( + mode: .refreshMissing, + request: Self.request(initial, mode: .refreshMissing, inputs: [oldProviderInput])), + .init( + mode: .forceRefresh, + request: Self.request( + initial, + mode: .forceRefresh, + confirmedEmptySourceIDs: ["claude"], + codexAccount: true)), + .init( + mode: .captureOnly, + request: Self.request( + unavailable, + mode: .captureOnly, + unavailableSourceIDs: ["claude"]), + gate: captureGate), + .init( + mode: .captureOnly, + request: Self.request( + latest, + mode: .captureOnly, + unavailableSourceIDs: ["claude"])), + ]) + let codexGate = SpendDashboardStateCodexGate() + let loaderRecorder = SpendDashboardStateLoadRecorder() + let controller = SpendDashboardController( + requestBuilder: { mode in await builder.next(mode) }, + loader: { request in + await loaderRecorder.record(request) + return await SpendDashboardSource.load(request, codexSnapshotLoader: { _ in + await codexGate.load() + }) + }) + + controller.update(configuration: initial) + await Self.waitUntil { !controller.isRefreshing } + #expect(controller.model.groups.first?.totalCost == 4) + + controller.refresh() + await Self.waitForCodexGate(codexGate) + controller.update(configuration: unavailable) + await codexGate.resume(codexInput.snapshot) + await Self.waitForBuildGate(captureGate) + controller.update(configuration: latest) + await captureGate.resume() + await Self.waitUntil { !controller.isRefreshing } + + let settledGeneration = controller.generation + controller.update(configuration: latest) + await Task.yield() + + #expect(builder.modes == [.refreshMissing, .forceRefresh, .captureOnly, .captureOnly]) + #expect(await loaderRecorder.forces == [false, true]) + #expect(controller.generation == settledGeneration) + #expect(controller.configuration == latest) + #expect(controller.failedSourceCount == 1) + #expect(controller.model.groups.first?.totalCost == 2) + #expect(controller.model.groups.flatMap(\.providers).map(\.id) == ["codex:a"]) + } + + @Test + func `K learned empty survives superseded capture then unavailable barrier`() async { + let initial = Self.configuration(owner: "owner", revision: "R") + let confirmedEmpty = Self.configuration(owner: "owner", revision: "E") + let unavailable = Self.configuration(owner: "owner", revision: "U") + let oldProviderInput = Self.input(id: "claude", provider: .claude, cost: 4) + let forcedProviderInput = Self.input(id: "claude", provider: .claude, cost: 6) + let learnedEmptyGate = SpendDashboardStateBuildGate() + let builder = SpendDashboardBuildScript([ + .init(mode: .refreshMissing, request: Self.request(initial, mode: .refreshMissing)), + .init(mode: .forceRefresh, request: Self.request(initial, mode: .forceRefresh)), + .init( + mode: .captureOnly, + request: Self.request( + confirmedEmpty, + mode: .captureOnly, + confirmedEmptySourceIDs: ["claude"]), + gate: learnedEmptyGate), + .init( + mode: .captureOnly, + request: Self.request( + unavailable, + mode: .captureOnly, + unavailableSourceIDs: ["claude"])), + ]) + let loader = SpendDashboardStateLoaderGate() + let controller = SpendDashboardController( + requestBuilder: { mode in await builder.next(mode) }, + loader: { request in await loader.load(request) }) + + controller.update(configuration: initial) + await Self.waitForLoader(loader) + await loader.resume(SpendDashboardLoadResult(inputs: [oldProviderInput], failedSourceIDs: [])) + await Self.waitUntil { !controller.isRefreshing } + + controller.refresh() + await Self.waitForLoader(loader) + controller.update(configuration: confirmedEmpty) + await loader.resume(SpendDashboardLoadResult(inputs: [forcedProviderInput], failedSourceIDs: [])) + await Self.waitForBuildGate(learnedEmptyGate) + controller.update(configuration: unavailable) + await learnedEmptyGate.resume() + await Self.waitUntil { !controller.isRefreshing } + + let settledGeneration = controller.generation + controller.update(configuration: unavailable) + await Task.yield() + + #expect(builder.modes == [.refreshMissing, .forceRefresh, .captureOnly, .captureOnly]) + #expect(await loader.forces == [false, true]) + #expect(controller.generation == settledGeneration) + #expect(controller.configuration == unavailable) + #expect(controller.failedSourceCount == 1) + #expect(controller.model.groups.isEmpty) + } + + @Test + func `L fresh nonempty after empty survives later unavailable despite forced failure`() async { + let initial = Self.configuration(owner: "owner", revision: "R") + let confirmedEmpty = Self.configuration(owner: "owner", revision: "E") + let fresh = Self.configuration(owner: "owner", revision: "N") + let unavailable = Self.configuration(owner: "owner", revision: "U") + let oldProviderInput = Self.input(id: "claude", provider: .claude, cost: 4) + let freshProviderInput = Self.input(id: "claude", provider: .claude, cost: 7) + let emptyGate = SpendDashboardStateBuildGate() + let freshGate = SpendDashboardStateBuildGate() + let builder = SpendDashboardBuildScript([ + .init(mode: .refreshMissing, request: Self.request(initial, mode: .refreshMissing)), + .init(mode: .forceRefresh, request: Self.request(initial, mode: .forceRefresh)), + .init( + mode: .captureOnly, + request: Self.request( + confirmedEmpty, + mode: .captureOnly, + confirmedEmptySourceIDs: ["claude"]), + gate: emptyGate), + .init( + mode: .captureOnly, + request: Self.request( + fresh, + mode: .captureOnly, + inputs: [freshProviderInput]), + gate: freshGate), + .init( + mode: .captureOnly, + request: Self.request( + unavailable, + mode: .captureOnly, + unavailableSourceIDs: ["claude"])), + ]) + let loader = SpendDashboardStateLoaderGate() + let controller = SpendDashboardController( + requestBuilder: { mode in await builder.next(mode) }, + loader: { request in await loader.load(request) }) + + controller.update(configuration: initial) + await Self.waitForLoader(loader) + await loader.resume(SpendDashboardLoadResult(inputs: [oldProviderInput], failedSourceIDs: [])) + await Self.waitUntil { !controller.isRefreshing } + + controller.refresh() + await Self.waitForLoader(loader) + controller.update(configuration: confirmedEmpty) + await loader.resume(SpendDashboardLoadResult(inputs: [], failedSourceIDs: ["claude"])) + await Self.waitForBuildGate(emptyGate) + controller.update(configuration: fresh) + await emptyGate.resume() + await Self.waitForBuildGate(freshGate) + controller.update(configuration: unavailable) + await freshGate.resume() + await Self.waitUntil { !controller.isRefreshing } + + let settledGeneration = controller.generation + controller.update(configuration: unavailable) + await Task.yield() + + #expect(builder.modes == [ + .refreshMissing, + .forceRefresh, + .captureOnly, + .captureOnly, + .captureOnly, + ]) + #expect(await loader.forces == [false, true]) + #expect(controller.generation == settledGeneration) + #expect(controller.configuration == unavailable) + #expect(controller.failedSourceCount == 1) + #expect(controller.model.groups.first?.totalCost == 7) + #expect(controller.model.groups.flatMap(\.providers).map(\.id) == ["claude"]) + } + + @Test + func `M newer source publication supersedes failed force stale row`() async { + let initial = Self.configuration(owner: "owner", revision: "claude:snapshot:1:old") + let latest = Self.configuration(owner: "owner", revision: "claude:snapshot:2:fresh") + let oldProviderInput = Self.input(id: "claude", provider: .claude, cost: 4) + let freshProviderInput = Self.input(id: "claude", provider: .claude, cost: 7) + let builder = SpendDashboardBuildScript([ + .init( + mode: .refreshMissing, + request: Self.request(initial, mode: .refreshMissing, inputs: [oldProviderInput])), + .init(mode: .forceRefresh, request: Self.request(initial, mode: .forceRefresh)), + .init( + mode: .captureOnly, + request: Self.request(latest, mode: .captureOnly, inputs: [freshProviderInput])), + ]) + let loader = SpendDashboardStateLoaderGate() + let controller = SpendDashboardController( + requestBuilder: { mode in await builder.next(mode) }, + loader: { request in await loader.load(request) }) + + controller.update(configuration: initial) + await Self.waitForLoader(loader) + await loader.resume(SpendDashboardLoadResult(inputs: [oldProviderInput], failedSourceIDs: [])) + await Self.waitUntil { !controller.isRefreshing } + + controller.refresh() + await Self.waitForLoader(loader) + await loader.resume(SpendDashboardLoadResult(inputs: [], failedSourceIDs: ["claude"])) + await Self.waitUntil { !controller.isRefreshing } + + #expect(builder.modes == [.refreshMissing, .forceRefresh, .captureOnly]) + #expect(await loader.forces == [false, true]) + #expect(controller.configuration == latest) + #expect(controller.failedSourceCount == 1) + #expect(controller.model.groups.first?.totalCost == 7) + #expect(controller.model.groups.flatMap(\.providers).map(\.id) == ["claude"]) + } + + private static func configuration(owner: String, revision: String) -> SpendDashboardConfiguration { + SpendDashboardConfiguration( + costUsageEnabled: true, + providerIDs: [UsageProvider.codex.rawValue, UsageProvider.claude.rawValue], + codexAccountIdentities: ["a|\(owner)"], + sourceOwnershipFingerprints: ["claude:\(owner)"], + sourceRevisions: [revision]) + } + + private static func request( + _ configuration: SpendDashboardConfiguration, + mode: SpendDashboardRequestBuildMode, + inputs: [SpendDashboardModel.ProviderInput] = [], + unavailableSourceIDs: Set = [], + confirmedEmptySourceIDs: Set = [], + codexAccount: Bool = false) -> SpendDashboardLoadRequest + { + SpendDashboardLoadRequest( + configuration: configuration, + capturedInputs: inputs, + unavailableSourceIDs: unavailableSourceIDs, + confirmedEmptySourceIDs: confirmedEmptySourceIDs, + codexRequests: codexAccount ? [self.codexRequest()] : [], + now: Date(timeIntervalSince1970: 1_784_179_200), + force: mode.forcesLoader) + } + + private static func codexRequest() -> CodexSpendScanRequest { + CodexSpendScanRequest( + id: "a", + displayName: "Codex", + source: .profileHome(path: "/synthetic/codex-a"), + homePath: "/synthetic/codex-a", + authFingerprint: nil, + authFileWasReadable: false, + cacheIdentity: "synthetic-a") + } + + private static func input( + id: String, + provider: UsageProvider, + cost: Double) -> SpendDashboardModel.ProviderInput + { + let entry = CostUsageDailyReport.Entry( + date: "2026-07-15", + inputTokens: nil, + outputTokens: nil, + totalTokens: 10, + costUSD: cost, + modelsUsed: nil, + modelBreakdowns: nil) + let snapshot = CostUsageTokenSnapshot( + sessionTokens: nil, + sessionCostUSD: nil, + last30DaysTokens: 10, + last30DaysCostUSD: cost, + daily: [entry], + updatedAt: Date(timeIntervalSince1970: 1_784_179_200)) + return SpendDashboardModel.ProviderInput( + id: id, + provider: provider, + displayName: provider.rawValue, + modelProviderName: provider == .codex ? "Codex" : nil, + snapshot: snapshot) + } + + private static func waitForLoader(_ loader: SpendDashboardStateLoaderGate) async { + for _ in 0..<1000 { + if await loader.pendingCount == 1 { + return + } + await Task.yield() + } + Issue.record("Timed out waiting for dashboard loader") + } + + private static func waitForBuildGate(_ gate: SpendDashboardStateBuildGate) async { + for _ in 0..<1000 { + if await gate.isSuspended { + return + } + await Task.yield() + } + Issue.record("Timed out waiting for dashboard build gate") + } + + private static func waitForCodexGate(_ gate: SpendDashboardStateCodexGate) async { + for _ in 0..<1000 { + if await gate.isSuspended { + return + } + await Task.yield() + } + Issue.record("Timed out waiting for dashboard Codex gate") + } + + private static func waitUntil(_ condition: @MainActor () -> Bool) async { + for _ in 0..<1000 { + if condition() { + return + } + await Task.yield() + } + Issue.record("Timed out waiting for dashboard controller") + } +} + +@MainActor +private final class SpendDashboardBuildScript { + struct Step { + let mode: SpendDashboardRequestBuildMode + let request: SpendDashboardLoadRequest + let gate: SpendDashboardStateBuildGate? + + init( + mode: SpendDashboardRequestBuildMode, + request: SpendDashboardLoadRequest, + gate: SpendDashboardStateBuildGate? = nil) + { + self.mode = mode + self.request = request + self.gate = gate + } + } + + private var steps: [Step] + private(set) var modes: [SpendDashboardRequestBuildMode] = [] + + init(_ steps: [Step]) { + self.steps = steps + } + + func next(_ mode: SpendDashboardRequestBuildMode) async -> SpendDashboardLoadRequest { + guard !self.steps.isEmpty else { + Issue.record("Unexpected dashboard build mode: \(mode)") + return SpendDashboardLoadRequest( + configuration: SpendDashboardConfiguration( + costUsageEnabled: false, + providerIDs: [], + codexAccountIdentities: []), + capturedInputs: [], + unavailableSourceIDs: [], + codexRequests: [], + now: Date(timeIntervalSince1970: 1_784_179_200), + force: mode.forcesLoader) + } + let step = self.steps.removeFirst() + self.modes.append(mode) + #expect(mode == step.mode) + if let gate = step.gate { + await gate.suspend() + } + return step.request + } +} + +private actor SpendDashboardStateBuildGate { + private var continuation: CheckedContinuation? + + var isSuspended: Bool { + self.continuation != nil + } + + func suspend() async { + await withCheckedContinuation { continuation in + self.continuation = continuation + } + } + + func resume() { + self.continuation?.resume() + self.continuation = nil + } +} + +private actor SpendDashboardStateLoaderGate { + private var continuations: [CheckedContinuation] = [] + private(set) var forces: [Bool] = [] + + var pendingCount: Int { + self.continuations.count + } + + func load(_ request: SpendDashboardLoadRequest) async -> SpendDashboardLoadResult { + self.forces.append(request.force) + return await withCheckedContinuation { continuation in + self.continuations.append(continuation) + } + } + + func resume(_ result: SpendDashboardLoadResult) { + self.continuations.removeFirst().resume(returning: result) + } +} + +private actor SpendDashboardStateCodexGate { + private var continuation: CheckedContinuation? + + var isSuspended: Bool { + self.continuation != nil + } + + func load() async -> CostUsageTokenSnapshot { + await withCheckedContinuation { continuation in + self.continuation = continuation + } + } + + func resume(_ snapshot: CostUsageTokenSnapshot) { + self.continuation?.resume(returning: snapshot) + self.continuation = nil + } +} + +private actor SpendDashboardStateLoadRecorder { + private(set) var count = 0 + private(set) var forces: [Bool] = [] + + func record(_ request: SpendDashboardLoadRequest) { + self.count += 1 + self.forces.append(request.force) + } +} diff --git a/Tests/CodexBarTests/SpendDashboardModelTests.swift b/Tests/CodexBarTests/SpendDashboardModelTests.swift new file mode 100644 index 0000000000..9223e633ca --- /dev/null +++ b/Tests/CodexBarTests/SpendDashboardModelTests.swift @@ -0,0 +1,871 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +struct SpendDashboardModelTests { + @Test + func `count labels avoid plural agreement and localize numbers`() { + CodexBarLocalizationOverride.$appLanguage.withValue("en") { + #expect(spendDashboardRefreshFailureText(1) == "Refresh failures: 1") + #expect(spendDashboardRefreshFailureText(2) == "Refresh failures: 2") + #expect(spendDashboardCoverageText(covered: 3, requested: 7) == "Coverage: 3 / 7") + } + CodexBarLocalizationOverride.$appLanguage.withValue("de") { + #expect(spendDashboardRefreshFailureText(1234) == "Fehlgeschlagene Aktualisierungen: 1.234") + #expect(spendDashboardCoverageText(covered: 3, requested: 30) == "Abdeckung: 3 / 30") + } + CodexBarLocalizationOverride.$appLanguage.withValue("fa") { + #expect(codexBarLocalizedInteger(12) == "۱۲") + #expect(spendDashboardDayRangeText(7) == "۷ روز") + #expect(spendDashboardDayRangeText(30) == "۳۰ روز") + #expect(spendDashboardRankText(1234) == "#۱٬۲۳۴") + #expect(spendDashboardRefreshFailureText(2) == "\(L("Refresh failures")): ۲") + #expect(spendDashboardCoverageText(covered: 3, requested: 30) == "پوشش: ۳ / ۳۰") + } + } + + @Test + func `Codex account indices use app locale numerals`() throws { + let home = FileManager.default.temporaryDirectory + .appendingPathComponent("SpendDashboardModelTests-index-locale-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: home, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: home) } + let account = CodexVisibleAccount( + id: "locale-account", + email: "locale@example.com", + authFingerprint: nil, + storedAccountID: nil, + selectionSource: .profileHome(path: home.path), + isActive: true, + isLive: true, + canReauthenticate: true, + canRemove: true) + + let persian = CodexBarLocalizationOverride.$appLanguage.withValue("fa") { + SpendDashboardSource.codexRequest( + account: account, + homePath: home.path, + providerName: "Codex", + index: 1, + count: 2)?.displayName + } + let arabic = CodexBarLocalizationOverride.$appLanguage.withValue("ar") { + SpendDashboardSource.codexRequest( + account: account, + homePath: home.path, + providerName: "Codex", + index: 1, + count: 2)?.displayName + } + + #expect(persian == "Codex · #۲") + #expect(arabic == "Codex · #٢") + } + + @Test + func `dashboard source contract includes only cost capable descriptors`() { + let providers = Set(ProviderDescriptorRegistry.all + .filter(\.tokenCost.supportsTokenCost) + .map(\.id)) + #expect(providers == [.codex, .claude, .vertexai, .openai, .mistral, .bedrock, .cursor, .opencodego]) + } + + @Test + func `native currencies stay separate and rank only within their currency`() throws { + let model = SpendDashboardModel.build( + inputs: [ + Self.input(id: "usd-low", provider: .claude, currency: "usd", cost: 2), + Self.input(id: "eur", provider: .openai, currency: "EUR", cost: 100), + Self.input(id: "usd-high", provider: .codex, currency: "USD", cost: 8), + ], + requestedDays: 30, + now: Self.now, + calendar: Self.calendar) + + #expect(model.groups.map(\.currencyCode) == ["EUR", "USD"]) + let eur = try #require(model.groups.first) + #expect(eur.providers.map(\.id) == ["eur"]) + #expect(eur.providers.map(\.rank) == [1]) + #expect(eur.totalCost == 100) + #expect(eur.models.map(\.modelName) == ["test-model"]) + #expect(eur.models.map(\.totalCost) == [100]) + let usd = try #require(model.groups.last) + #expect(usd.providers.map(\.id) == ["usd-high", "usd-low"]) + #expect(usd.providers.map(\.rank) == [1, 2]) + #expect(usd.totalCost == 10) + #expect(usd.models.allSatisfy { $0.modelName == "test-model" }) + #expect(usd.models.compactMap(\.totalCost).reduce(0, +) == 10) + } + + @Test + func `windows anchor to injected now and report covered days honestly`() throws { + let snapshot = Self.snapshot( + currency: "USD", + entries: [ + Self.entry(day: "2026-07-16", cost: 1), + Self.entry(day: "2026-07-09", cost: 2), + Self.entry(day: "2026-07-08", cost: 4), + Self.entry(day: "2026-08-01", cost: 100), + ]) + let input = SpendDashboardModel.ProviderInput(provider: .claude, displayName: "Claude", snapshot: snapshot) + + let sevenDays = SpendDashboardModel.build( + inputs: [input], + requestedDays: 7, + now: Self.now, + calendar: Self.calendar) + let group = try #require(sevenDays.groups.first) + #expect(group.totalCost == 1) + #expect(group.coveredDayCount == 7) + #expect(group.providers.first?.coveredDayCount == 7) + + let thirtyDays = SpendDashboardModel.build( + inputs: [input], + requestedDays: 30, + now: Self.now, + calendar: Self.calendar) + #expect(thirtyDays.groups.first?.totalCost == 7) + #expect(thirtyDays.groups.first?.coveredDayCount == 30) + + let futureSnapshot = Self.snapshot( + currency: "USD", + entries: [Self.entry(day: "2026-07-16", cost: 1)], + updatedAt: Date(timeIntervalSince1970: 1_900_000_000)) + let futureModel = SpendDashboardModel.build( + inputs: [.init(provider: .claude, displayName: "Claude", snapshot: futureSnapshot)], + requestedDays: 30, + now: Self.now, + calendar: Self.calendar) + #expect(futureModel.groups.first?.coveredDayCount == 0) + + let shortSnapshot = Self.snapshot( + currency: "USD", + entries: [Self.entry(day: "2026-07-16", cost: 1)], + historyDays: 7) + let shortModel = SpendDashboardModel.build( + inputs: [.init(provider: .claude, displayName: "Claude", snapshot: shortSnapshot)], + requestedDays: 30, + now: Self.now, + calendar: Self.calendar) + #expect(shortModel.groups.first?.coveredDayCount == 7) + } + + @Test + func `chart domain uses the exact requested window despite sparse points`() throws { + let input = SpendDashboardModel.ProviderInput( + provider: .claude, + displayName: "Claude", + snapshot: Self.snapshot( + currency: "USD", + entries: [Self.entry(day: "2026-07-16", cost: 1)])) + let sevenDays = try #require(SpendDashboardModel.build( + inputs: [input], + requestedDays: 7, + now: Self.now, + calendar: Self.calendar).groups.first) + let thirtyDays = try #require(SpendDashboardModel.build( + inputs: [input], + requestedDays: 30, + now: Self.now, + calendar: Self.calendar).groups.first) + let anchor = Self.calendar.startOfDay(for: Self.now) + let sevenDayStart = try #require(Self.calendar.date(byAdding: .day, value: -6, to: anchor)) + let thirtyDayStart = try #require(Self.calendar.date(byAdding: .day, value: -29, to: anchor)) + let end = try #require(Self.calendar.date(byAdding: .day, value: 1, to: anchor)) + + #expect(sevenDays.dailyPoints.map(\.day) == [anchor]) + #expect(thirtyDays.dailyPoints.map(\.day) == [anchor]) + #expect(sevenDays.chartDomain == sevenDayStart...end) + #expect(thirtyDays.chartDomain == thirtyDayStart...end) + } + + @Test + func `currency coverage intersects disjoint provider windows`() throws { + let earlier = try SpendDashboardModel.ProviderInput( + id: "earlier", + provider: .claude, + displayName: "Earlier", + snapshot: Self.snapshot( + currency: "USD", + entries: [Self.entry(day: "2026-07-09", cost: 2)], + historyDays: 7, + updatedAt: #require(Self.calendar.date(byAdding: .day, value: -7, to: Self.now)))) + let later = SpendDashboardModel.ProviderInput( + id: "later", + provider: .codex, + displayName: "Later", + snapshot: Self.snapshot( + currency: "USD", + entries: [Self.entry(day: "2026-07-16", cost: 3)], + historyDays: 7)) + let group = try #require(SpendDashboardModel.build( + inputs: [earlier, later], + requestedDays: 30, + now: Self.now, + calendar: Self.calendar).groups.first) + + #expect(group.coveredDayCount == 0) + #expect(group.providers.allSatisfy { $0.coveredDayCount == 7 }) + #expect(group.totalCost == 5) + #expect(group.providers.map(\.id) == ["later", "earlier"]) + #expect(group.dailyPoints.map(\.sourceID) == ["earlier", "later"]) + } + + @Test + func `currency coverage counts only overlapping provider days`() throws { + let earlier = try SpendDashboardModel.ProviderInput( + id: "earlier", + provider: .claude, + displayName: "Earlier", + snapshot: Self.snapshot( + currency: "USD", + entries: [Self.entry(day: "2026-07-12", cost: 2)], + historyDays: 7, + updatedAt: #require(Self.calendar.date(byAdding: .day, value: -4, to: Self.now)))) + let later = SpendDashboardModel.ProviderInput( + id: "later", + provider: .codex, + displayName: "Later", + snapshot: Self.snapshot( + currency: "USD", + entries: [Self.entry(day: "2026-07-16", cost: 3)], + historyDays: 7)) + let group = try #require(SpendDashboardModel.build( + inputs: [earlier, later], + requestedDays: 30, + now: Self.now, + calendar: Self.calendar).groups.first) + + #expect(group.coveredDayCount == 3) + #expect(group.providers.allSatisfy { $0.coveredDayCount == 7 }) + #expect(group.totalCost == 5) + } + + @Test + func `uncovered same currency source keeps complete model rows without ranking them as complete`() throws { + let covered = Self.input(id: "covered", provider: .claude, currency: "USD", cost: 4) + let uncovered = SpendDashboardModel.ProviderInput( + id: "uncovered", + provider: .codex, + displayName: "Uncovered", + snapshot: Self.snapshot( + currency: "USD", + entries: [Self.entry(day: "2026-08-01", cost: 10)], + historyDays: 1, + updatedAt: Date(timeIntervalSince1970: 1_785_542_400))) // 2026-08-01 00:00:00 UTC + let group = try #require(SpendDashboardModel.build( + inputs: [covered, uncovered], + requestedDays: 7, + now: Self.now, + calendar: Self.calendar).groups.first) + + #expect(group.totalCost == nil) + #expect(group.totalTokens == nil) + #expect(group.modelHistoryCompleteness == .incomplete) + #expect(group.models.map(\.provider) == [.claude]) + #expect(group.models.map(\.modelName) == ["test-model"]) + #expect(group.models.map(\.totalCost) == [4]) + #expect(spendDashboardModelHistoryPresentation(group) == .partial) + } + + @Test + func `only uncovered source reports model breakdown unavailable`() throws { + let uncovered = SpendDashboardModel.ProviderInput( + provider: .claude, + displayName: "Claude", + snapshot: Self.snapshot( + currency: "USD", + entries: [Self.entry(day: "2026-08-01", cost: 10)], + historyDays: 1, + updatedAt: Date(timeIntervalSince1970: 1_785_542_400))) // 2026-08-01 00:00:00 UTC + let group = try #require(SpendDashboardModel.build( + inputs: [uncovered], + requestedDays: 7, + now: Self.now, + calendar: Self.calendar).groups.first) + + #expect(group.coveredDayCount == 0) + #expect(group.totalCost == nil) + #expect(group.totalTokens == nil) + #expect(group.modelHistoryCompleteness == .incomplete) + #expect(group.models.isEmpty) + #expect(spendDashboardModelHistoryPresentation(group) == .unavailable) + } + + @Test + func `uncovered source affects only its own currency model history`() throws { + let covered = Self.input(id: "covered", provider: .claude, currency: "USD", cost: 4) + let uncovered = SpendDashboardModel.ProviderInput( + id: "uncovered", + provider: .codex, + displayName: "Uncovered", + snapshot: Self.snapshot( + currency: "EUR", + entries: [Self.entry(day: "2026-08-01", cost: 10)], + historyDays: 1, + updatedAt: Date(timeIntervalSince1970: 1_785_542_400))) // 2026-08-01 00:00:00 UTC + let groups = SpendDashboardModel.build( + inputs: [covered, uncovered], + requestedDays: 7, + now: Self.now, + calendar: Self.calendar).groups + let eur = try #require(groups.first(where: { $0.currencyCode == "EUR" })) + let usd = try #require(groups.first(where: { $0.currencyCode == "USD" })) + + #expect(eur.modelHistoryCompleteness == .incomplete) + #expect(eur.models.isEmpty) + #expect(usd.modelHistoryCompleteness == .complete) + #expect(usd.models.map(\.totalCost) == [4]) + } + + @Test + func `ISO history stays Gregorian while preserving the injected timezone`() throws { + let timeZone = try #require(TimeZone(secondsFromGMT: 7 * 60 * 60)) + var gregorian = Calendar(identifier: .gregorian) + gregorian.timeZone = timeZone + let now = try #require(gregorian.date(from: DateComponents( + year: 2026, + month: 7, + day: 16, + hour: 12))) + var buddhist = Calendar(identifier: .buddhist) + buddhist.timeZone = timeZone + let snapshot = Self.snapshot( + currency: "USD", + entries: [Self.entry(day: "2026-07-16", cost: 4)], + updatedAt: now) + let group = try #require(SpendDashboardModel.build( + inputs: [.init(provider: .claude, displayName: "Claude", snapshot: snapshot)], + requestedDays: 7, + now: now, + calendar: buddhist).groups.first) + + #expect(group.totalCost == 4) + #expect(group.coveredDayCount == 7) + #expect(group.dailyPoints.map(\.day) == [gregorian.startOfDay(for: now)]) + } + + @Test + func `daily values aggregate once and produce deterministic nonoverlapping stacks`() throws { + let first = SpendDashboardModel.ProviderInput( + id: "a", + provider: .claude, + displayName: "Claude", + snapshot: Self.snapshot(currency: "USD", entries: [ + Self.entry(day: "2026-07-16", cost: 2), + Self.entry(day: "2026-07-16", cost: 3), + ])) + let second = SpendDashboardModel.ProviderInput( + id: "b", + provider: .codex, + displayName: "Codex", + snapshot: Self.snapshot(currency: "USD", entries: [Self.entry(day: "2026-07-16", cost: 4)])) + let group = try #require(SpendDashboardModel.build( + inputs: [second, first], + requestedDays: 7, + now: Self.now, + calendar: Self.calendar).groups.first) + + #expect(group.dailyPoints.map(\.sourceID) == ["a", "b"]) + #expect(group.dailyPoints.map(\.cost) == [5, 4]) + #expect(group.dailyPoints.map(\.stackStart) == [0, 5]) + #expect(group.dailyPoints.map(\.stackEnd) == [5, 9]) + } + + @Test + func `invalid costs and arithmetic overflow never become spend`() throws { + let invalid = SpendDashboardModel.ProviderInput( + id: "invalid", + provider: .claude, + displayName: "Claude", + snapshot: Self.snapshot(currency: "USD", entries: [ + Self.entry(day: "2026-07-16", cost: -.infinity, tokens: .max), + Self.entry(day: "2026-07-15", cost: -.nan, tokens: .max), + Self.entry(day: "2026-07-14", cost: -1), + Self.entry(day: "2026-06-31", cost: 99), + ])) + let hugeA = Self.input(id: "huge-a", provider: .codex, currency: "USD", cost: .greatestFiniteMagnitude) + let hugeB = Self.input(id: "huge-b", provider: .openai, currency: "USD", cost: .greatestFiniteMagnitude) + let group = try #require(SpendDashboardModel.build( + inputs: [invalid, hugeA, hugeB], + requestedDays: 30, + now: Self.now, + calendar: Self.calendar).groups.first) + + #expect(group.providers.first(where: { $0.id == "invalid" })?.totalCost == nil) + #expect(group.totalCost == nil) + #expect(group.totalTokens == nil) + #expect(group.dailyPoints.isEmpty) + } + + @Test + func `malformed date mixed with valid usage fails the source closed`() throws { + let snapshot = Self.snapshot(currency: "USD", entries: [ + Self.entry(day: "2026-07-16", cost: 4, tokens: 40), + Self.entry(day: "not-a-day", cost: 2, tokens: 20), + ]) + let group = try #require(SpendDashboardModel.build( + inputs: [.init(provider: .claude, displayName: "Claude", snapshot: snapshot)], + requestedDays: 7, + now: Self.now, + calendar: Self.calendar).groups.first) + + #expect(group.providers.first?.totalCost == nil) + #expect(group.providers.first?.totalTokens == nil) + #expect(group.totalCost == nil) + #expect(group.totalTokens == nil) + #expect(group.modelHistoryCompleteness == .incomplete) + #expect(group.models.isEmpty) + #expect(group.dailyPoints.isEmpty) + } + + @Test + func `malformed date only with unknown usage is unavailable not zero`() throws { + let snapshot = Self.snapshot(currency: "USD", entries: [ + Self.entry(day: "2026-02-30", cost: nil, tokens: nil, model: nil), + ]) + let group = try #require(SpendDashboardModel.build( + inputs: [.init(provider: .claude, displayName: "Claude", snapshot: snapshot)], + requestedDays: 7, + now: Self.now, + calendar: Self.calendar).groups.first) + + #expect(group.providers.first?.totalCost == nil) + #expect(group.providers.first?.totalTokens == nil) + #expect(group.totalCost == nil) + #expect(group.totalTokens == nil) + #expect(group.modelHistoryCompleteness == .incomplete) + #expect(group.models.isEmpty) + #expect(group.dailyPoints.isEmpty) + } + + @Test + func `explicit zero malformed date is ignored without affecting valid window rows`() throws { + let snapshot = Self.snapshot(currency: "USD", entries: [ + Self.entry(day: "malformed", cost: 0, tokens: 0, model: nil), + Self.entryWithBreakdowns( + day: "also-malformed", + totalCost: 0, + totalTokens: 0, + breakdowns: [.init(modelName: "zero", costUSD: 0, totalTokens: 0, requestCount: 0)]), + Self.entry(day: "2026-07-16", cost: 3, tokens: 30), + Self.entry(day: "2026-07-01", cost: 99, tokens: 990), + ]) + let group = try #require(SpendDashboardModel.build( + inputs: [.init(provider: .claude, displayName: "Claude", snapshot: snapshot)], + requestedDays: 7, + now: Self.now, + calendar: Self.calendar).groups.first) + + #expect(group.providers.first?.totalCost == 3) + #expect(group.providers.first?.totalTokens == 30) + #expect(group.totalCost == 3) + #expect(group.totalTokens == 30) + #expect(group.modelHistoryCompleteness == .complete) + #expect(group.models.map(\.totalCost) == [3]) + #expect(group.dailyPoints.map(\.cost) == [3]) + } + + @Test + func `mixed invalid entry metrics make source and group totals unavailable`() throws { + let inputs = [ + SpendDashboardModel.ProviderInput( + id: "missing", + provider: .claude, + displayName: "Missing", + snapshot: Self.snapshot(currency: "USD", entries: [ + Self.entry(day: "2026-07-16", cost: 1, tokens: 1), + Self.entry(day: "2026-07-15", cost: nil, tokens: nil), + ])), + SpendDashboardModel.ProviderInput( + id: "negative", + provider: .codex, + displayName: "Negative", + snapshot: Self.snapshot(currency: "USD", entries: [ + Self.entry(day: "2026-07-16", cost: 1, tokens: 1), + Self.entry(day: "2026-07-15", cost: -1, tokens: -1), + ])), + SpendDashboardModel.ProviderInput( + id: "nonfinite", + provider: .openai, + displayName: "Nonfinite", + snapshot: Self.snapshot(currency: "USD", entries: [ + Self.entry(day: "2026-07-16", cost: 1, tokens: 1), + Self.entry(day: "2026-07-15", cost: .infinity, tokens: 1), + ])), + SpendDashboardModel.ProviderInput( + id: "overflow", + provider: .mistral, + displayName: "Overflow", + snapshot: Self.snapshot(currency: "USD", entries: [ + Self.entry(day: "2026-07-16", cost: .greatestFiniteMagnitude, tokens: .max), + Self.entry(day: "2026-07-15", cost: .greatestFiniteMagnitude, tokens: .max), + ])), + ] + let group = try #require(SpendDashboardModel.build( + inputs: inputs, + requestedDays: 7, + now: Self.now, + calendar: Self.calendar).groups.first) + + #expect(group.providers.allSatisfy { $0.totalCost == nil }) + #expect(group.providers.first(where: { $0.id == "nonfinite" })?.totalTokens == 2) + #expect(group.providers.filter { $0.id != "nonfinite" }.allSatisfy { $0.totalTokens == nil }) + #expect(group.totalCost == nil) + #expect(group.totalTokens == nil) + } + + @Test + func `invalid model breakdowns make model history unavailable`() throws { + let snapshot = Self.snapshot(currency: "USD", entries: [ + Self.entryWithBreakdowns( + day: "2026-07-16", + breakdowns: [ + .init(modelName: "complete", costUSD: 2, totalTokens: 2), + .init(modelName: "missing", costUSD: 4, totalTokens: 4), + .init(modelName: "negative", costUSD: 4, totalTokens: 4), + .init(modelName: "overflow", costUSD: .greatestFiniteMagnitude, totalTokens: .max), + ]), + Self.entryWithBreakdowns( + day: "2026-07-15", + breakdowns: [ + .init(modelName: "complete", costUSD: 1, totalTokens: 1), + .init(modelName: "missing", costUSD: nil, totalTokens: nil), + .init(modelName: "negative", costUSD: -1, totalTokens: -1), + .init(modelName: "overflow", costUSD: .greatestFiniteMagnitude, totalTokens: .max), + ]), + ]) + let group = try #require(SpendDashboardModel.build( + inputs: [.init(provider: .claude, displayName: "Claude", snapshot: snapshot)], + requestedDays: 7, + now: Self.now, + calendar: Self.calendar).groups.first) + + #expect(group.modelHistoryCompleteness == .incomplete) + #expect(group.models.isEmpty) + } + + @Test + func `partial contributing model history is unavailable instead of a lower bound`() throws { + let snapshot = Self.snapshot(currency: "USD", entries: [ + Self.entry(day: "2026-07-16", cost: 4, tokens: 40, model: nil), + Self.entry(day: "2026-07-15", cost: 2, tokens: 20), + ]) + let group = try #require(SpendDashboardModel.build( + inputs: [.init(provider: .claude, displayName: "Claude", snapshot: snapshot)], + requestedDays: 7, + now: Self.now, + calendar: Self.calendar).groups.first) + + #expect(group.modelHistoryCompleteness == .incomplete) + #expect(group.models.isEmpty) + #expect(group.totalCost == 6) + } + + @Test + func `zero usage without a breakdown keeps model history complete`() throws { + let snapshot = Self.snapshot(currency: "USD", entries: [ + Self.entryWithBreakdowns(day: "2026-07-16", breakdowns: []), + Self.entry(day: "2026-07-15", cost: 2, tokens: 20), + ]) + let group = try #require(SpendDashboardModel.build( + inputs: [.init(provider: .claude, displayName: "Claude", snapshot: snapshot)], + requestedDays: 7, + now: Self.now, + calendar: Self.calendar).groups.first) + + #expect(group.modelHistoryCompleteness == .complete) + #expect(group.models.map(\.modelName) == ["test-model"]) + #expect(group.models.map(\.totalCost) == [2]) + } + + @Test + func `unknown usage without a breakdown makes model history unavailable`() throws { + let snapshot = Self.snapshot(currency: "USD", entries: [ + Self.entry(day: "2026-07-16", cost: nil, tokens: nil, model: nil), + Self.entry(day: "2026-07-15", cost: 2, tokens: 20), + ]) + let group = try #require(SpendDashboardModel.build( + inputs: [.init(provider: .claude, displayName: "Claude", snapshot: snapshot)], + requestedDays: 7, + now: Self.now, + calendar: Self.calendar).groups.first) + + #expect(group.modelHistoryCompleteness == .incomplete) + #expect(group.models.isEmpty) + } + + @Test + func `blank model names fail closed unless their usage is explicitly zero`() throws { + let incomplete = Self.snapshot(currency: "USD", entries: [Self.entryWithBreakdowns( + day: "2026-07-16", + totalCost: 3, + totalTokens: 30, + breakdowns: [ + .init(modelName: " \n ", costUSD: 2, totalTokens: 20), + .init(modelName: "named", costUSD: 1, totalTokens: 10), + ])]) + let complete = Self.snapshot(currency: "USD", entries: [Self.entryWithBreakdowns( + day: "2026-07-16", + totalCost: 1, + totalTokens: 10, + breakdowns: [ + .init(modelName: " \n ", costUSD: 0, totalTokens: 0), + .init(modelName: "named", costUSD: 1, totalTokens: 10), + ])]) + let incompleteGroup = try #require(SpendDashboardModel.build( + inputs: [.init(provider: .claude, displayName: "Claude", snapshot: incomplete)], + requestedDays: 7, + now: Self.now, + calendar: Self.calendar).groups.first) + let completeGroup = try #require(SpendDashboardModel.build( + inputs: [.init(provider: .claude, displayName: "Claude", snapshot: complete)], + requestedDays: 7, + now: Self.now, + calendar: Self.calendar).groups.first) + + #expect(incompleteGroup.modelHistoryCompleteness == .incomplete) + #expect(incompleteGroup.models.isEmpty) + #expect(completeGroup.modelHistoryCompleteness == .complete) + #expect(completeGroup.models.map(\.modelName) == ["named"]) + } + + @Test + func `partial named breakdown totals make model history unavailable`() throws { + let snapshot = Self.snapshot(currency: "USD", entries: [Self.entryWithBreakdowns( + day: "2026-07-16", + totalCost: 10, + totalTokens: 100, + breakdowns: [.init(modelName: "partial", costUSD: 4, totalTokens: 40)])]) + let group = try #require(SpendDashboardModel.build( + inputs: [.init(provider: .claude, displayName: "Claude", snapshot: snapshot)], + requestedDays: 7, + now: Self.now, + calendar: Self.calendar).groups.first) + + #expect(group.modelHistoryCompleteness == .incomplete) + #expect(group.models.isEmpty) + } + + @Test + func `incomplete duplicate day sources do not render partial chart stacks`() throws { + let missing = SpendDashboardModel.ProviderInput( + id: "missing", + provider: .claude, + displayName: "Missing", + snapshot: Self.snapshot(currency: "USD", entries: [ + Self.entry(day: "2026-07-16", cost: 2), + Self.entry(day: "2026-07-16", cost: nil), + ])) + let overflow = SpendDashboardModel.ProviderInput( + id: "overflow", + provider: .codex, + displayName: "Overflow", + snapshot: Self.snapshot(currency: "USD", entries: [ + Self.entry(day: "2026-07-16", cost: .greatestFiniteMagnitude), + Self.entry(day: "2026-07-16", cost: .greatestFiniteMagnitude), + ])) + let complete = Self.input(id: "complete", provider: .openai, currency: "USD", cost: 3) + let group = try #require(SpendDashboardModel.build( + inputs: [missing, overflow, complete], + requestedDays: 7, + now: Self.now, + calendar: Self.calendar).groups.first) + + #expect(group.dailyPoints.map(\.sourceID) == ["complete"]) + #expect(group.dailyPoints.map(\.cost) == [3]) + #expect(group.dailyPoints.map(\.stackStart) == [0]) + #expect(group.dailyPoints.map(\.stackEnd) == [3]) + } + + @Test + func `covered inactive sources contribute zero without hiding active totals`() throws { + let inactive = SpendDashboardModel.ProviderInput( + id: "inactive", + provider: .claude, + displayName: "Inactive", + snapshot: Self.snapshot(currency: "USD", entries: [ + Self.entry(day: "2026-07-16", cost: 0, tokens: 0, model: nil), + ])) + let active = Self.input(id: "active", provider: .codex, currency: "USD", cost: 10) + let group = try #require(SpendDashboardModel.build( + inputs: [inactive, active], + requestedDays: 7, + now: Self.now, + calendar: Self.calendar).groups.first) + + let inactiveRow = try #require(group.providers.first(where: { $0.id == "inactive" })) + #expect(inactiveRow.totalCost == 0) + #expect(inactiveRow.totalTokens == 0) + #expect(inactiveRow.coveredDayCount == 7) + #expect(group.totalCost == 10) + #expect(group.totalTokens == 10) + #expect(group.providers.map(\.id) == ["active", "inactive"]) + #expect(group.modelHistoryCompleteness == .complete) + #expect(group.models.map(\.totalCost) == [10]) + } + + @Test + func `unpriced history stays unavailable instead of becoming zero`() throws { + let snapshot = Self.snapshot( + currency: "CAD", + entries: [Self.entry(day: "2026-07-16", cost: nil, tokens: 12)]) + let model = SpendDashboardModel.build( + inputs: [.init(provider: .claude, displayName: "Claude", snapshot: snapshot)], + requestedDays: 7, + now: Self.now, + calendar: Self.calendar) + let group = try #require(model.groups.first) + + #expect(group.totalCost == nil) + #expect(group.totalTokens == 12) + #expect(group.providers.first?.totalCost == nil) + } + + @Test + func `Codex requests freeze source home auth and cache identity`() throws { + let id = try #require(UUID(uuidString: "AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE")) + let home = FileManager.default.temporaryDirectory + .appendingPathComponent("SpendDashboardModelTests-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: home, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: home) } + let account = CodexVisibleAccount( + id: "account", + email: "test@example.com", + authFingerprint: "ABC123", + storedAccountID: id, + selectionSource: .managedAccount(id: id), + isActive: true, + isLive: false, + canReauthenticate: true, + canRemove: true) + let request = try #require(SpendDashboardSource.codexRequest( + account: account, + homePath: home.path, + providerName: "Codex", + index: 1, + count: 2)) + + #expect(request.source == .managedAccount(id: id)) + #expect(request.homePath == home.path) + #expect(request.authFingerprint == "abc123") + #expect(!request.authFileWasReadable) + #expect(request.displayName == "Codex · #2") + #expect(request.cacheIdentity.count == 64) + #expect(SpendDashboardSource.scanDays == 30) + #expect(SpendDashboardSource.codexRequest( + account: account, + homePath: "relative/path", + providerName: "Codex", + index: 0, + count: 1) == nil) + #expect(SpendDashboardSource.codexRequest( + account: account, + homePath: home.appendingPathComponent("missing", isDirectory: true).path, + providerName: "Codex", + index: 0, + count: 1) == nil) + + let changed = CodexVisibleAccount( + id: account.id, + email: account.email, + authFingerprint: "different", + storedAccountID: id, + selectionSource: account.selectionSource, + isActive: account.isActive, + isLive: account.isLive, + canReauthenticate: account.canReauthenticate, + canRemove: account.canRemove) + let changedRequest = try #require(SpendDashboardSource.codexRequest( + account: changed, + homePath: request.homePath, + providerName: "Codex", + index: 1, + count: 2)) + #expect(changedRequest.cacheIdentity != request.cacheIdentity) + + let authData = Data("{\"tokens\":\"synthetic\"}".utf8) + try authData.write(to: CodexAuthFingerprint.authFileURL(homePath: home.path)) + let exact = try #require(SpendDashboardSource.codexRequest( + account: account, + homePath: home.path, + providerName: "Codex", + index: 0, + count: 1)) + #expect(exact.authFingerprint == CodexAuthFingerprint.fingerprint(data: authData)) + #expect(exact.authFileWasReadable) + #expect(exact.cacheIdentity != request.cacheIdentity) + } + + private static func input( + id: String, + provider: UsageProvider, + currency: String, + cost: Double) -> SpendDashboardModel.ProviderInput + { + SpendDashboardModel.ProviderInput( + id: id, + provider: provider, + displayName: provider.rawValue, + snapshot: self.snapshot(currency: currency, entries: [self.entry(day: "2026-07-16", cost: cost)])) + } + + private static func snapshot( + currency: String, + entries: [CostUsageDailyReport.Entry], + historyDays: Int = 30, + updatedAt: Date = now) -> CostUsageTokenSnapshot + { + CostUsageTokenSnapshot( + sessionTokens: nil, + sessionCostUSD: nil, + last30DaysTokens: nil, + last30DaysCostUSD: nil, + currencyCode: currency, + historyDays: historyDays, + daily: entries, + updatedAt: updatedAt) + } + + private static func entry( + day: String, + cost: Double?, + tokens: Int? = 10, + model: String? = "test-model") -> CostUsageDailyReport.Entry + { + CostUsageDailyReport.Entry( + date: day, + inputTokens: nil, + outputTokens: nil, + totalTokens: tokens, + costUSD: cost, + modelsUsed: nil, + modelBreakdowns: model.map { + [.init(modelName: $0, costUSD: cost, totalTokens: tokens)] + }) + } + + private static func entryWithBreakdowns( + day: String, + totalCost: Double = 0, + totalTokens: Int = 0, + breakdowns: [CostUsageDailyReport.ModelBreakdown]) -> CostUsageDailyReport.Entry + { + CostUsageDailyReport.Entry( + date: day, + inputTokens: nil, + outputTokens: nil, + totalTokens: totalTokens, + costUSD: totalCost, + modelsUsed: nil, + modelBreakdowns: breakdowns) + } + + private static let now = Date(timeIntervalSince1970: 1_784_179_200) // 2026-07-16 00:00:00 UTC + private static var calendar: Calendar { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(secondsFromGMT: 0)! + return calendar + } +} diff --git a/Tests/CodexBarTests/SpendDashboardSourceConcurrencyTests.swift b/Tests/CodexBarTests/SpendDashboardSourceConcurrencyTests.swift new file mode 100644 index 0000000000..fda8fb8878 --- /dev/null +++ b/Tests/CodexBarTests/SpendDashboardSourceConcurrencyTests.swift @@ -0,0 +1,733 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +@MainActor +struct SpendDashboardSourceConcurrencyTests { + @Test + func `Codex batch revalidates completed and failed accounts after later scans`() async throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("SpendDashboardSourceConcurrencyTests-auth-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + + let completed = try Self.makeAccount(id: "completed", root: root) + let failed = try Self.makeAccount(id: "failed", root: root) + let later = try Self.makeAccount(id: "later", root: root) + let completedSnapshot = Self.input(cost: 1).snapshot + let laterSnapshot = Self.input(cost: 2).snapshot + let gate = SpendDashboardCodexBatchGate() + let request = SpendDashboardLoadRequest( + configuration: SpendDashboardConfiguration( + costUsageEnabled: true, + providerIDs: [UsageProvider.codex.rawValue], + codexAccountIdentities: [completed, failed, later].map { "\($0.id)|\($0.cacheIdentity)" }), + capturedInputs: [], + unavailableSourceIDs: [], + codexRequests: [completed, failed, later], + now: Date(timeIntervalSince1970: 1_784_179_200), + force: true) + + let loadTask = Task { + await SpendDashboardSource.load(request, codexSnapshotLoader: { context in + switch context.account.id { + case completed.id: + completedSnapshot + case failed.id: + throw SpendDashboardSyntheticError.failed + default: + await gate.load() + } + }) + } + await Self.waitForCodexGate(gate) + let replacementAuth = Data("{\"profile\":\"replacement-owner\"}".utf8) + try replacementAuth.write( + to: CodexAuthFingerprint.authFileURL(homePath: completed.homePath), + options: .atomic) + try replacementAuth.write( + to: CodexAuthFingerprint.authFileURL(homePath: failed.homePath), + options: .atomic) + await gate.resume(snapshot: laterSnapshot) + + let result = await loadTask.value + #expect(result.inputs.map(\.id) == ["codex:later"]) + #expect(result.failedSourceIDs == ["codex:completed", "codex:failed"]) + #expect(result.invalidatedSourceIDs == ["codex:completed", "codex:failed"]) + } + + @Test + func `Codex ownership change retains failed unchanged sibling only`() async { + let gate = SpendDashboardResultBatchGate() + let initial = SpendDashboardConfiguration( + costUsageEnabled: true, + providerIDs: [UsageProvider.codex.rawValue], + codexAccountIdentities: ["a|owner-a", "b|owner-b"]) + let replacement = SpendDashboardConfiguration( + costUsageEnabled: true, + providerIDs: [UsageProvider.codex.rawValue], + codexAccountIdentities: ["a|owner-a-replacement", "b|owner-b"]) + let requestSequence = SpendDashboardRequestSequence([ + .init(configuration: initial), + .init(configuration: replacement), + ]) + let controller = SpendDashboardController( + requestBuilder: { mode in await requestSequence.next(mode: mode) }, + loader: { request in await gate.load(request) }) + + controller.update(configuration: initial) + await Self.waitForResultGate(gate) + await gate.resume(result: SpendDashboardLoadResult( + inputs: [ + Self.input(id: "codex:a", cost: 3), + Self.input(id: "codex:b", cost: 5), + ], + failedSourceIDs: [])) + await Self.waitUntil { !controller.isRefreshing } + #expect(controller.model.groups.first?.totalCost == 8) + + controller.update(configuration: replacement) + await Self.waitForResultGate(gate) + #expect(controller.model.groups.first?.totalCost == 5) + #expect(Set(controller.model.groups.flatMap(\.providers).map(\.id)) == ["codex:b"]) + await gate.resume(result: SpendDashboardLoadResult( + inputs: [], + failedSourceIDs: ["codex:a", "codex:b"])) + await Self.waitUntil { !controller.isRefreshing } + + #expect(controller.model.groups.first?.totalCost == 5) + #expect(Set(controller.model.groups.flatMap(\.providers).map(\.id)) == ["codex:b"]) + #expect(controller.failedSourceCount == 2) + } + + @Test + func `Codex removal relabels retained failed account from second to first`() async throws { + let gate = SpendDashboardResultBatchGate() + let requestGate = SpendDashboardProviderBatchGate() + let initialRequests = [ + Self.scanRequest(id: "a", displayName: "Codex · #1"), + Self.scanRequest(id: "b", displayName: "Codex · #2"), + Self.scanRequest(id: "c", displayName: "Codex · #3"), + ] + let replacementRequests = [ + Self.scanRequest(id: "b", displayName: "Codex · #1"), + Self.scanRequest(id: "c", displayName: "Codex · #2"), + ] + let initial = SpendDashboardConfiguration( + costUsageEnabled: true, + providerIDs: [UsageProvider.codex.rawValue], + codexAccountIdentities: ["a|owner-a", "b|owner-b", "c|owner-c"], + codexAccountDisplayNames: [ + "codex:a": "Codex · #1", + "codex:b": "Codex · #2", + "codex:c": "Codex · #3", + ]) + let replacement = SpendDashboardConfiguration( + costUsageEnabled: true, + providerIDs: [UsageProvider.codex.rawValue], + codexAccountIdentities: ["b|owner-b", "c|owner-c"], + codexAccountDisplayNames: [ + "codex:b": "Codex · #1", + "codex:c": "Codex · #2", + ]) + let requestSequence = SpendDashboardRequestSequence( + [ + .init(configuration: initial, codexRequests: initialRequests), + .init(configuration: replacement, codexRequests: replacementRequests), + ], + suspendAt: 1, + gate: requestGate) + let controller = SpendDashboardController( + requestBuilder: { mode in await requestSequence.next(mode: mode) }, + loader: { request in await gate.load(request) }) + + controller.update(configuration: initial) + await Self.waitForResultGate(gate) + await gate.resume(result: SpendDashboardLoadResult( + inputs: [ + Self.input(id: "codex:a", cost: 3, displayName: "Codex · #1"), + Self.input(id: "codex:b", cost: 5, displayName: "Codex · #2"), + Self.input(id: "codex:c", cost: 7, displayName: "Codex · #3"), + ], + failedSourceIDs: [])) + await Self.waitUntil { !controller.isRefreshing } + + controller.update(configuration: replacement) + let pendingRows = try #require(controller.model.groups.first?.providers) + #expect(Dictionary(uniqueKeysWithValues: pendingRows.map { ($0.id, $0.displayName) }) == [ + "codex:b": "Codex · #1", + "codex:c": "Codex · #2", + ]) + await Self.waitForProviderGate(requestGate) + #expect(await gate.pendingCount == 0) + await requestGate.resume() + await Self.waitForResultGate(gate) + await gate.resume(result: SpendDashboardLoadResult( + inputs: [Self.input(id: "codex:c", cost: 8, displayName: "Codex · #2")], + failedSourceIDs: ["codex:b"])) + await Self.waitUntil { !controller.isRefreshing } + + let finalRows = try #require(controller.model.groups.first?.providers) + #expect(Dictionary(uniqueKeysWithValues: finalRows.map { ($0.id, $0.displayName) }) == [ + "codex:b": "Codex · #1", + "codex:c": "Codex · #2", + ]) + #expect(finalRows.first { $0.id == "codex:b" }?.totalCost == 5) + #expect(controller.failedSourceCount == 1) + } + + @Test + func `request revision captured before coalesced update cannot publish stale inputs`() async { + let requestGate = SpendDashboardProviderBatchGate() + let recorder = SpendDashboardRequestRecorder() + let initial = SpendDashboardConfiguration( + costUsageEnabled: true, + providerIDs: [UsageProvider.claude.rawValue], + codexAccountIdentities: [], + sourceOwnershipFingerprints: ["claude:owner"], + sourceRevisions: ["R"]) + let replacement = SpendDashboardConfiguration( + costUsageEnabled: true, + providerIDs: [UsageProvider.claude.rawValue], + codexAccountIdentities: [], + sourceOwnershipFingerprints: ["claude:owner"], + sourceRevisions: ["R+1"]) + let requestSequence = SpendDashboardRequestSequence( + [ + .init(configuration: initial, capturedInputs: [Self.input(provider: .claude, cost: 1)]), + .init(configuration: replacement, capturedInputs: [Self.input(provider: .claude, cost: 2)]), + .init(configuration: replacement, capturedInputs: [Self.input(provider: .claude, cost: 2)]), + ], + suspendAt: 0, + gate: requestGate) + let controller = SpendDashboardController( + requestBuilder: { mode in await requestSequence.next(mode: mode) }, + loader: { request in + await recorder.record(request) + return SpendDashboardLoadResult(inputs: request.capturedInputs, failedSourceIDs: []) + }) + + controller.update(configuration: initial, force: true) + await Self.waitForProviderGate(requestGate) + controller.update(configuration: replacement) + await requestGate.resume() + await Self.waitUntil { !controller.isRefreshing } + + #expect(controller.configuration == replacement) + #expect(controller.generation == 2) + #expect(controller.model.groups.first?.totalCost == 2) + #expect(requestSequence.modes == [.forceRefresh, .captureOnly]) + #expect(await recorder.configurations == [initial]) + #expect(await recorder.forces == [true]) + } + + @Test + func `force adopts builder published revision without losing Codex scan intent`() async { + let recorder = SpendDashboardRequestRecorder() + let initial = SpendDashboardConfiguration( + costUsageEnabled: true, + providerIDs: [UsageProvider.codex.rawValue, UsageProvider.claude.rawValue], + codexAccountIdentities: ["account|owner"], + sourceOwnershipFingerprints: ["claude:owner"], + sourceRevisions: ["R"]) + let replacement = SpendDashboardConfiguration( + costUsageEnabled: true, + providerIDs: [UsageProvider.codex.rawValue, UsageProvider.claude.rawValue], + codexAccountIdentities: ["account|owner"], + sourceOwnershipFingerprints: ["claude:owner"], + sourceRevisions: ["R+1"]) + let requestSequence = SpendDashboardRequestSequence([ + .init(configuration: replacement, capturedInputs: [Self.input(provider: .claude, cost: 2)]), + .init(configuration: replacement, capturedInputs: [Self.input(provider: .claude, cost: 2)]), + ]) + let controller = SpendDashboardController( + requestBuilder: { mode in await requestSequence.next(mode: mode) }, + loader: { request in + await recorder.record(request) + return SpendDashboardLoadResult(inputs: request.capturedInputs, failedSourceIDs: []) + }) + + controller.update(configuration: initial, force: true) + await Self.waitUntil { !controller.isRefreshing } + + #expect(controller.configuration == replacement) + #expect(controller.generation == 2) + #expect(controller.model.groups.first?.totalCost == 2) + #expect(requestSequence.modes == [.forceRefresh, .captureOnly]) + #expect(await recorder.forces == [true]) + } + + @Test + func `forced builder owner mismatch reruns replacement builder and rejects cached request`() async { + let requestGate = SpendDashboardProviderBatchGate() + let recorder = SpendDashboardRequestRecorder() + let initial = SpendDashboardConfiguration( + costUsageEnabled: true, + providerIDs: [UsageProvider.codex.rawValue, UsageProvider.claude.rawValue], + codexAccountIdentities: ["account|owner"], + sourceOwnershipFingerprints: ["claude:owner-one"], + sourceRevisions: ["R"]) + let replacement = SpendDashboardConfiguration( + costUsageEnabled: true, + providerIDs: [UsageProvider.codex.rawValue, UsageProvider.claude.rawValue], + codexAccountIdentities: ["account|owner"], + sourceOwnershipFingerprints: ["claude:owner-two"], + sourceRevisions: ["R+1"]) + let cachedInput = Self.input(provider: .claude, cost: 1) + let freshInput = Self.input(provider: .claude, cost: 3) + let requestSequence = SpendDashboardRequestSequence( + [ + .init(configuration: replacement, capturedInputs: [cachedInput]), + .init(configuration: replacement, capturedInputs: [freshInput]), + .init(configuration: replacement, capturedInputs: [freshInput]), + ], + suspendAt: 1, + gate: requestGate) + let controller = SpendDashboardController( + requestBuilder: { mode in await requestSequence.next(mode: mode) }, + loader: { request in + await recorder.record(request) + return SpendDashboardLoadResult(inputs: request.capturedInputs, failedSourceIDs: []) + }) + + controller.update(configuration: initial, force: true) + await Self.waitForProviderGate(requestGate) + + #expect(controller.configuration == replacement) + #expect(controller.generation == 2) + #expect(controller.model.groups.isEmpty) + #expect(requestSequence.modes == [.forceRefresh, .forceRefresh]) + #expect(await recorder.configurations.isEmpty) + + await requestGate.resume() + await Self.waitUntil { !controller.isRefreshing } + + #expect(controller.configuration == replacement) + #expect(controller.generation == 3) + #expect(controller.model.groups.first?.totalCost == 3) + #expect(requestSequence.modes == [.forceRefresh, .forceRefresh, .captureOnly]) + #expect(await recorder.configurations == [replacement]) + #expect(await recorder.forces == [true]) + } + + @Test + func `ownership replacement while force builder is pending reruns builder and loader forced`() async { + let requestGate = SpendDashboardProviderBatchGate() + let recorder = SpendDashboardRequestRecorder() + let initial = SpendDashboardConfiguration( + costUsageEnabled: true, + providerIDs: [UsageProvider.codex.rawValue, UsageProvider.claude.rawValue], + codexAccountIdentities: ["account|owner"], + sourceOwnershipFingerprints: ["claude:owner-one"], + sourceRevisions: ["R"]) + let replacement = SpendDashboardConfiguration( + costUsageEnabled: true, + providerIDs: [UsageProvider.codex.rawValue, UsageProvider.claude.rawValue], + codexAccountIdentities: ["account|owner"], + sourceOwnershipFingerprints: ["claude:owner-two"], + sourceRevisions: ["R+1"]) + let replacementInput = Self.input(provider: .codex, cost: 4) + let requestSequence = SpendDashboardRequestSequence( + [ + .init(configuration: initial, capturedInputs: [Self.input(provider: .codex, cost: 1)]), + .init(configuration: replacement, capturedInputs: [replacementInput]), + .init(configuration: replacement, capturedInputs: [replacementInput]), + ], + suspendAt: 0, + gate: requestGate) + let controller = SpendDashboardController( + requestBuilder: { mode in await requestSequence.next(mode: mode) }, + loader: { request in + await recorder.record(request) + return SpendDashboardLoadResult(inputs: request.capturedInputs, failedSourceIDs: []) + }) + + controller.update(configuration: initial, force: true) + await Self.waitForProviderGate(requestGate) + controller.update(configuration: replacement) + await Self.waitUntil { !controller.isRefreshing } + await requestGate.resume() + await Task.yield() + + #expect(controller.configuration == replacement) + #expect(controller.generation == 3) + #expect(controller.model.groups.first?.totalCost == 4) + #expect(requestSequence.modes == [.forceRefresh, .forceRefresh, .captureOnly]) + #expect(await recorder.configurations == [replacement]) + #expect(await recorder.forces == [true]) + } + + @Test + func `ownership replacement after force builder completes reruns builder and loader forced`() async { + let loaderGate = SpendDashboardRecordedResultGate() + let initial = SpendDashboardConfiguration( + costUsageEnabled: true, + providerIDs: [UsageProvider.codex.rawValue, UsageProvider.claude.rawValue], + codexAccountIdentities: ["account|owner"], + sourceOwnershipFingerprints: ["claude:owner-one"], + sourceRevisions: ["R"]) + let replacement = SpendDashboardConfiguration( + costUsageEnabled: true, + providerIDs: [UsageProvider.codex.rawValue, UsageProvider.claude.rawValue], + codexAccountIdentities: ["account|owner"], + sourceOwnershipFingerprints: ["claude:owner-two"], + sourceRevisions: ["R+1"]) + let requestSequence = SpendDashboardRequestSequence([ + .init(configuration: initial), + .init(configuration: replacement), + .init( + configuration: replacement, + capturedInputs: [Self.input(provider: .codex, cost: 5)]), + ]) + let controller = SpendDashboardController( + requestBuilder: { mode in await requestSequence.next(mode: mode) }, + loader: { request in await loaderGate.load(request) }) + + controller.update(configuration: initial, force: true) + await Self.waitForRecordedResultGate(loaderGate, pendingCount: 1) + controller.update(configuration: replacement) + await Self.waitForRecordedResultGate(loaderGate, pendingCount: 2) + + #expect(requestSequence.modes == [.forceRefresh, .forceRefresh]) + #expect(await loaderGate.configurations == [initial, replacement]) + #expect(await loaderGate.forces == [true, true]) + + await loaderGate.resume( + at: 1, + result: SpendDashboardLoadResult( + inputs: [Self.input(provider: .codex, cost: 5)], + failedSourceIDs: [])) + await Self.waitUntil { !controller.isRefreshing } + await loaderGate.resume( + at: 0, + result: SpendDashboardLoadResult( + inputs: [Self.input(provider: .codex, cost: 99)], + failedSourceIDs: [])) + await Task.yield() + + #expect(controller.configuration == replacement) + #expect(controller.generation == 3) + #expect(controller.model.groups.first?.totalCost == 5) + } + + @Test + func `force request recaptures earlier provider after later refresh suspends`() async throws { + let settings = testSettingsStore(suiteName: "SpendDashboardSourceConcurrencyTests-force-recapture") + settings.costUsageEnabled = true + for provider in UsageProvider.allCases { + guard let metadata = ProviderRegistry.shared.metadata[provider] else { continue } + settings.setProviderEnabled( + provider: provider, + metadata: metadata, + enabled: provider == .claude || provider == .mistral) + } + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + startupBehavior: .testing, + environmentBase: [:]) + let providers = SpendDashboardSource.costCapableProviders(store: store) + #expect(providers == [.claude, .mistral]) + let firstProvider = UsageProvider.claude + let laterProvider = UsageProvider.mistral + store._setTokenSnapshotForTesting( + Self.input(provider: firstProvider, cost: 1).snapshot, + provider: firstProvider) + store._setTokenSnapshotForTesting( + Self.input(provider: laterProvider, cost: 2).snapshot, + provider: laterProvider) + + let gate = SpendDashboardProviderBatchGate() + store._test_tokenUsageRefreshOverride = { provider, _ in + #expect(provider == firstProvider) + store._setTokenSnapshotForTesting( + Self.input(provider: provider, cost: 10).snapshot, + provider: provider) + } + store._test_providerRefreshOverride = { provider in + #expect(provider == laterProvider) + await gate.suspend() + store._setTokenSnapshotForTesting( + Self.input(provider: provider, cost: 20).snapshot, + provider: provider) + } + + let requestTask = Task { @MainActor in + await SpendDashboardSource.makeRequest(settings: settings, store: store, mode: .forceRefresh) + } + await Self.waitForProviderGate(gate) + store._setTokenSnapshotForTesting( + Self.input(provider: firstProvider, cost: 11).snapshot, + provider: firstProvider) + await gate.resume() + + let request = await requestTask.value + let firstInput = try #require(request.capturedInputs.first { $0.provider == firstProvider }) + let laterInput = try #require(request.capturedInputs.first { $0.provider == laterProvider }) + #expect(firstInput.snapshot.last30DaysCostUSD == 11) + #expect(laterInput.snapshot.last30DaysCostUSD == 20) + #expect(request.unavailableSourceIDs.isEmpty) + #expect(request.configuration == SpendDashboardSource.configuration(settings: settings, store: store)) + } + + private static func makeAccount(id: String, root: URL) throws -> CodexSpendScanRequest { + let home = root.appendingPathComponent(id, isDirectory: true) + try FileManager.default.createDirectory(at: home, withIntermediateDirectories: true) + let auth = Data("{\"profile\":\"\(id)-owner\"}".utf8) + try auth.write(to: CodexAuthFingerprint.authFileURL(homePath: home.path), options: .atomic) + return CodexSpendScanRequest( + id: id, + displayName: "Codex · \(id)", + source: .profileHome(path: home.path), + homePath: home.path, + authFingerprint: CodexAuthFingerprint.fingerprint(data: auth), + authFileWasReadable: true, + cacheIdentity: "\(id)-cache") + } + + private static func scanRequest(id: String, displayName: String) -> CodexSpendScanRequest { + CodexSpendScanRequest( + id: id, + displayName: displayName, + source: .profileHome(path: "/synthetic/\(id)"), + homePath: "/synthetic/\(id)", + authFingerprint: nil, + authFileWasReadable: false, + cacheIdentity: "\(id)-cache") + } + + private static func input( + id: String? = nil, + provider: UsageProvider = .codex, + cost: Double, + displayName: String? = nil) -> SpendDashboardModel.ProviderInput + { + let entry = CostUsageDailyReport.Entry( + date: "2026-07-15", + inputTokens: nil, + outputTokens: nil, + totalTokens: 10, + costUSD: cost, + modelsUsed: nil, + modelBreakdowns: nil) + let snapshot = CostUsageTokenSnapshot( + sessionTokens: nil, + sessionCostUSD: nil, + last30DaysTokens: 10, + last30DaysCostUSD: cost, + daily: [entry], + updatedAt: Date(timeIntervalSince1970: 1_784_179_200)) + return SpendDashboardModel.ProviderInput( + id: id, + provider: provider, + displayName: displayName ?? provider.rawValue, + modelProviderName: provider == .codex ? "Codex" : nil, + snapshot: snapshot) + } + + private static func waitForCodexGate(_ gate: SpendDashboardCodexBatchGate) async { + for _ in 0..<1000 { + if await gate.isSuspended { + return + } + await Task.yield() + } + Issue.record("Timed out waiting for pending Codex load") + } + + private static func waitForProviderGate(_ gate: SpendDashboardProviderBatchGate) async { + for _ in 0..<1000 { + if await gate.isSuspended { + return + } + await Task.yield() + } + Issue.record("Timed out waiting for pending provider refresh") + } + + private static func waitForResultGate(_ gate: SpendDashboardResultBatchGate) async { + for _ in 0..<1000 { + if await gate.pendingCount == 1 { + return + } + await Task.yield() + } + Issue.record("Timed out waiting for pending dashboard load") + } + + private static func waitForRecordedResultGate( + _ gate: SpendDashboardRecordedResultGate, + pendingCount: Int) async + { + for _ in 0..<1000 { + if await gate.pendingCount == pendingCount { + return + } + await Task.yield() + } + Issue.record("Timed out waiting for \(pendingCount) recorded dashboard loads") + } + + private static func waitUntil(_ condition: @MainActor () -> Bool) async { + for _ in 0..<1000 { + if condition() { + return + } + await Task.yield() + } + Issue.record("Timed out waiting for dashboard state") + } +} + +private enum SpendDashboardSyntheticError: Error { + case failed +} + +@MainActor +private final class SpendDashboardRequestSequence { + struct Item { + let configuration: SpendDashboardConfiguration + let capturedInputs: [SpendDashboardModel.ProviderInput] + let codexRequests: [CodexSpendScanRequest] + + init( + configuration: SpendDashboardConfiguration, + capturedInputs: [SpendDashboardModel.ProviderInput] = [], + codexRequests: [CodexSpendScanRequest] = []) + { + self.configuration = configuration + self.capturedInputs = capturedInputs + self.codexRequests = codexRequests + } + } + + private var items: [Item] + private let suspendAt: Int? + private let gate: SpendDashboardProviderBatchGate? + private var index = 0 + private(set) var modes: [SpendDashboardRequestBuildMode] = [] + + init( + _ items: [Item], + suspendAt: Int? = nil, + gate: SpendDashboardProviderBatchGate? = nil) + { + self.items = items + self.suspendAt = suspendAt + self.gate = gate + } + + func next(mode: SpendDashboardRequestBuildMode) async -> SpendDashboardLoadRequest { + let item = self.items.removeFirst() + let index = self.index + self.index += 1 + self.modes.append(mode) + if index == self.suspendAt { + await self.gate?.suspend() + } + return SpendDashboardLoadRequest( + configuration: item.configuration, + capturedInputs: item.capturedInputs, + unavailableSourceIDs: [], + codexRequests: item.codexRequests, + now: Date(timeIntervalSince1970: 1_784_179_200), + force: mode.forcesLoader) + } +} + +private actor SpendDashboardRequestRecorder { + private(set) var configurations: [SpendDashboardConfiguration] = [] + private(set) var forces: [Bool] = [] + + func record(_ request: SpendDashboardLoadRequest) { + self.configurations.append(request.configuration) + self.forces.append(request.force) + } +} + +private actor SpendDashboardRecordedResultGate { + private var requests: [SpendDashboardLoadRequest] = [] + private var continuations: [CheckedContinuation] = [] + + var pendingCount: Int { + self.continuations.count + } + + var configurations: [SpendDashboardConfiguration] { + self.requests.map(\.configuration) + } + + var forces: [Bool] { + self.requests.map(\.force) + } + + func load(_ request: SpendDashboardLoadRequest) async -> SpendDashboardLoadResult { + self.requests.append(request) + return await withCheckedContinuation { continuation in + self.continuations.append(continuation) + } + } + + func resume(at index: Int, result: SpendDashboardLoadResult) { + self.continuations.remove(at: index).resume(returning: result) + } +} + +private actor SpendDashboardCodexBatchGate { + private var continuation: CheckedContinuation? + + var isSuspended: Bool { + self.continuation != nil + } + + func load() async -> CostUsageTokenSnapshot { + await withCheckedContinuation { continuation in + self.continuation = continuation + } + } + + func resume(snapshot: CostUsageTokenSnapshot) { + self.continuation?.resume(returning: snapshot) + self.continuation = nil + } +} + +private actor SpendDashboardProviderBatchGate { + private var continuation: CheckedContinuation? + + var isSuspended: Bool { + self.continuation != nil + } + + func suspend() async { + await withCheckedContinuation { continuation in + self.continuation = continuation + } + } + + func resume() { + self.continuation?.resume() + self.continuation = nil + } +} + +private actor SpendDashboardResultBatchGate { + private var continuations: [CheckedContinuation] = [] + + var pendingCount: Int { + self.continuations.count + } + + func load(_ request: SpendDashboardLoadRequest) async -> SpendDashboardLoadResult { + _ = request + return await withCheckedContinuation { continuation in + self.continuations.append(continuation) + } + } + + func resume(result: SpendDashboardLoadResult) { + self.continuations.removeFirst().resume(returning: result) + } +} diff --git a/Tests/CodexBarTests/SpendDashboardTokenProvenanceTests.swift b/Tests/CodexBarTests/SpendDashboardTokenProvenanceTests.swift new file mode 100644 index 0000000000..6fc55887a4 --- /dev/null +++ b/Tests/CodexBarTests/SpendDashboardTokenProvenanceTests.swift @@ -0,0 +1,455 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +@MainActor +@Suite(.serialized) +struct SpendDashboardTokenProvenanceTests { + @Test + func `direct token scan rejects stale config completion`() async { + let (settings, store) = Self.makeStore(provider: .bedrock) + settings.updateProviderConfig(provider: .bedrock) { $0.region = "us-east-1" } + let gate = SpendDashboardProvenanceGate() + store._test_tokenUsageSnapshotLoaderOverride = { _, _, _, _, _ in + let call = await gate.enter() + return Self.tokenSnapshot(cost: call == 1 ? 1 : 2) + } + + let refresh = Task { @MainActor in + await store.refreshTokenUsageNow(for: .bedrock, force: true) + } + await gate.waitForCalls(1) + settings.updateProviderConfig(provider: .bedrock) { $0.region = "us-west-2" } + await gate.releaseFirst() + await refresh.value + await gate.waitForCalls(2) + await Self.waitUntil { + store.tokenSnapshotForCurrentProviderConfig(for: .bedrock)?.snapshot.last30DaysCostUSD == 2 + } + + #expect(store.tokenSnapshotForCurrentProviderConfig(for: .bedrock)?.snapshot.last30DaysCostUSD == 2) + #expect(store.tokenSnapshotPublicationRevision(for: .bedrock) == 1) + } + + @Test + func `direct token scan rejects completion across disable and reenable epoch`() async { + let (settings, store) = Self.makeStore(provider: .bedrock) + let gate = SpendDashboardProvenanceGate() + store._test_tokenUsageSnapshotLoaderOverride = { _, _, _, _, _ in + let call = await gate.enter() + return Self.tokenSnapshot(cost: call == 1 ? 1 : 2) + } + + let refresh = Task { @MainActor in + await store.refreshTokenUsageNow(for: .bedrock, force: true) + } + await gate.waitForCalls(1) + settings.costUsageEnabled = false + settings.costUsageEnabled = true + await gate.releaseFirst() + await refresh.value + await gate.waitForCalls(2) + await Self.waitUntil { + store.tokenSnapshotForCurrentProviderConfig(for: .bedrock)?.snapshot.last30DaysCostUSD == 2 + } + + #expect(store.tokenSnapshotForCurrentProviderConfig(for: .bedrock)?.snapshot.last30DaysCostUSD == 2) + #expect(store.tokenSnapshotPublicationRevision(for: .bedrock) == 1) + } + + @Test + func `direct token scan refreshes changed provider config within ttl`() async { + let (settings, store) = Self.makeStore(provider: .bedrock) + settings.updateProviderConfig(provider: .bedrock) { $0.region = "us-east-1" } + var loadCount = 0 + store._test_tokenUsageSnapshotLoaderOverride = { _, _, _, _, _ in + loadCount += 1 + return Self.tokenSnapshot(cost: Double(loadCount)) + } + + await store.refreshTokenUsageNow(for: .bedrock, force: true) + #expect(store.tokenSnapshotForCurrentProviderConfig(for: .bedrock)?.snapshot.last30DaysCostUSD == 1) + + settings.updateProviderConfig(provider: .bedrock) { $0.region = "us-west-2" } + await store.refreshTokenUsageNow(for: .bedrock, force: false) + + #expect(loadCount == 2) + #expect(store.tokenSnapshotForCurrentProviderConfig(for: .bedrock)?.snapshot.last30DaysCostUSD == 2) + #expect(store.tokenSnapshotPublicationRevision(for: .bedrock) == 2) + } + + @Test + func `provider derived snapshot rejects completion from old history scope`() async { + let (settings, store) = Self.makeStore(provider: .mistral) + let gate = SpendDashboardProvenanceGate() + store._test_providerFetchOutcomeOverride = { _ in + _ = await gate.enter() + return Self.mistralOutcome(cost: 4) + } + + let refresh = Task { @MainActor in + await store.refreshProvider(.mistral) + } + await gate.waitForCalls(1) + settings.costUsageHistoryDays = 7 + await gate.releaseFirst() + await refresh.value + + #expect(store.tokenSnapshotForCurrentProviderConfig(for: .mistral) == nil) + #expect(store.tokenSnapshotPublicationRevision(for: .mistral) == 0) + } + + @Test + func `cached token account activation does not prove a forced refresh`() async throws { + let now = Date(timeIntervalSince1970: 1_784_179_200) + let (settings, store) = Self.makeStore(provider: .mistral) + settings.addTokenAccount(provider: .mistral, label: "Fixture", token: "fixture") + let account = try #require(settings.effectiveSelectedTokenAccount(for: .mistral)) + let usage = Self.mistralUsage(cost: 3) + store.accountSnapshots[.mistral] = [TokenAccountUsageSnapshot( + account: account, + snapshot: usage, + error: nil, + sourceLabel: "fixture-cache", + cacheKey: store.tokenAccountSnapshotCacheKey(provider: .mistral, account: account))] + store.tokenErrors[.mistral] = "stale account cost error" + _ = store.tokenFailureGates[.mistral]?.shouldSurfaceError(onFailureWithPriorData: false) + store.activateCachedTokenAccountSnapshot(provider: .mistral, accountID: account.id) + let baselineRevision = store.tokenSnapshotPublicationRevision(for: .mistral) + #expect(store.tokenSnapshot(for: .mistral)?.last30DaysCostUSD == 3) + #expect(store.tokenSnapshotPublicationForCurrentProviderConfig(for: .mistral)?.snapshot?.last30DaysCostUSD == 3) + #expect(store.tokenError(for: .mistral) == nil) + #expect(store.tokenFailureGates[.mistral]?.streak == 0) + + store.activateCachedTokenAccountSnapshot(provider: .mistral, accountID: account.id) + #expect(store.tokenSnapshotPublicationRevision(for: .mistral) == baselineRevision) + store._test_providerRefreshOverride = { _ in } + let controller = SpendDashboardController( + userDefaults: settings.userDefaults, + requestBuilder: { mode in + await SpendDashboardSource.makeRequest( + settings: settings, + store: store, + mode: mode, + now: now) + }, + nowProvider: { now }) + controller.update(configuration: SpendDashboardSource.configuration(settings: settings, store: store)) + await Self.waitUntil { !controller.isRefreshing } + #expect(controller.model.groups.first?.totalCost == 3) + + controller.refresh() + await Self.waitUntil { !controller.isRefreshing } + + #expect(controller.model.groups.first?.totalCost == 3) + #expect(controller.failedSourceCount == 1) + #expect(store.tokenSnapshotPublicationRevision(for: .mistral) == baselineRevision) + } + + @Test + func `forced successful empty publication removes prior spend without warning`() async { + let now = Date(timeIntervalSince1970: 1_784_203_200) + let (settings, store) = Self.makeStore(provider: .bedrock) + var loadCount = 0 + store._test_tokenUsageSnapshotLoaderOverride = { _, _, _, _, _ in + loadCount += 1 + return loadCount == 1 ? Self.tokenSnapshot(cost: 4) : Self.emptyTokenSnapshot() + } + await store.refreshTokenUsageNow(for: .bedrock, force: true) + let controller = SpendDashboardController( + userDefaults: settings.userDefaults, + requestBuilder: { mode in + await SpendDashboardSource.makeRequest( + settings: settings, + store: store, + mode: mode, + now: now) + }, + nowProvider: { now }) + controller.update(configuration: SpendDashboardSource.configuration(settings: settings, store: store)) + await Self.waitUntil { !controller.isRefreshing } + #expect(controller.model.groups.first?.totalCost == 4) + + controller.refresh() + await Self.waitUntil { !controller.isRefreshing } + + #expect(loadCount == 2) + #expect(controller.model.groups.isEmpty) + #expect(controller.failedSourceCount == 0) + #expect(store.tokenSnapshot(for: .bedrock) == nil) + let publication = store.tokenSnapshotPublicationForCurrentProviderConfig(for: .bedrock) + #expect(publication?.snapshot == nil) + #expect(publication?.publicationRevision == 2) + } + + @Test + func `first open accepts current empty publication without redundant refresh`() async { + let now = Date(timeIntervalSince1970: 1_784_179_200) + let (settings, store) = Self.makeStore(provider: .bedrock) + var loadCount = 0 + store._test_tokenUsageSnapshotLoaderOverride = { _, _, _, _, _ in + loadCount += 1 + return Self.emptyTokenSnapshot() + } + await store.refreshTokenUsageNow(for: .bedrock, force: true) + let publicationRevision = store.tokenSnapshotPublicationRevision(for: .bedrock) + let controller = SpendDashboardController( + userDefaults: settings.userDefaults, + requestBuilder: { mode in + await SpendDashboardSource.makeRequest( + settings: settings, + store: store, + mode: mode, + now: now) + }, + nowProvider: { now }) + + controller.update(configuration: SpendDashboardSource.configuration(settings: settings, store: store)) + await Self.waitUntil { !controller.isRefreshing } + + #expect(loadCount == 1) + #expect(controller.model.groups.isEmpty) + #expect(controller.failedSourceCount == 0) + #expect(store.tokenSnapshotPublicationRevision(for: .bedrock) == publicationRevision) + } + + @Test + func `provider success without cost projection confirms empty publication`() async { + let (_, store) = Self.makeStore(provider: .mistral) + let outcome = Self.mistralOutcomeWithoutCostProjection() + + await store.applySelectedOutcome( + outcome, + provider: .mistral, + account: nil, + fallbackSnapshot: nil) + + let publication = store.tokenSnapshotPublicationForCurrentProviderConfig(for: .mistral) + #expect(publication?.snapshot == nil) + #expect(publication?.publicationRevision == 1) + #expect(store.tokenSnapshot(for: .mistral) == nil) + } + + @Test + func `legacy token refresh preserves current confirmed empty provider publication`() async { + let (_, store) = Self.makeStore(provider: .mistral) + await store.applySelectedOutcome( + Self.mistralOutcomeWithoutCostProjection(), + provider: .mistral, + account: nil, + fallbackSnapshot: nil) + let publicationRevision = store.tokenSnapshotPublicationRevision(for: .mistral) + + await store.refreshTokenUsage(.mistral, force: true) + + let publication = store.tokenSnapshotPublicationForCurrentProviderConfig(for: .mistral) + #expect(publication?.snapshot == nil) + #expect(publication?.publicationRevision == publicationRevision) + #expect(store.tokenError(for: .mistral) == nil) + } + + @Test + func `multi account provider success publishes current token provenance`() async throws { + let (settings, store) = Self.makeStore(provider: .mistral) + settings.addTokenAccount(provider: .mistral, label: "Fixture", token: "fixture") + let account = try #require(settings.effectiveSelectedTokenAccount(for: .mistral)) + store.tokenErrors[.mistral] = "stale account cost error" + _ = store.tokenFailureGates[.mistral]?.shouldSurfaceError(onFailureWithPriorData: false) + + await store.applySelectedOutcome( + Self.mistralOutcome(cost: 7), + provider: .mistral, + account: account, + fallbackSnapshot: nil) + + #expect(store.tokenSnapshotForCurrentProviderConfig(for: .mistral)?.snapshot.last30DaysCostUSD == 7) + #expect(store.tokenSnapshotPublicationRevision(for: .mistral) == 1) + #expect(store.tokenError(for: .mistral) == nil) + #expect(store.tokenFailureGates[.mistral]?.streak == 0) + } + + @Test + func `legacy token refresh cannot stamp raw provider snapshot without provenance`() async { + let (_, store) = Self.makeStore(provider: .mistral) + store._setSnapshotForTesting(Self.mistralUsage(cost: 8), provider: .mistral) + + await store.refreshTokenUsage(.mistral, force: true) + + #expect(store.snapshot(for: .mistral) != nil) + #expect(store.tokenSnapshotForCurrentProviderConfig(for: .mistral) == nil) + #expect(store.tokenSnapshotPublicationRevision(for: .mistral) == 0) + } + + @Test + func `widget does not project raw provider cost without current provenance`() async throws { + let (_, store) = Self.makeStore(provider: .mistral) + store._setSnapshotForTesting(Self.mistralUsage(cost: 8), provider: .mistral) + var savedSnapshots: [WidgetSnapshot] = [] + store._test_widgetSnapshotSaveOverride = { savedSnapshots.append($0) } + + store.persistWidgetSnapshot(reason: "provenance-test") + await store.widgetSnapshotPersistTask?.value + + let entry = try #require(savedSnapshots.last?.entries.first { $0.provider == .mistral }) + #expect(entry.tokenUsage == nil) + #expect(entry.dailyUsage.isEmpty) + } + + @Test + func `token publication counter remains monotonic across clear and identical republish`() { + let (_, store) = Self.makeStore(provider: .claude) + let snapshot = Self.tokenSnapshot(cost: 9) + store._setTokenSnapshotForTesting(snapshot, provider: .claude) + let firstRevision = store.tokenSnapshotPublicationRevision(for: .claude) + + store._setTokenSnapshotForTesting(nil, provider: .claude) + store._setTokenSnapshotForTesting(snapshot, provider: .claude) + + #expect(store.tokenSnapshotPublicationRevision(for: .claude) > firstRevision) + #expect(store.tokenSnapshotForCurrentProviderConfig(for: .claude)?.snapshot == snapshot) + } + + private static func makeStore(provider: UsageProvider) -> (SettingsStore, UsageStore) { + let settings = testSettingsStore(suiteName: "SpendDashboardTokenProvenanceTests-\(provider.rawValue)") + settings.costUsageEnabled = true + for candidate in UsageProvider.allCases { + guard let metadata = ProviderRegistry.shared.metadata[candidate] else { continue } + settings.setProviderEnabled(provider: candidate, metadata: metadata, enabled: candidate == provider) + } + if provider == .bedrock { + settings.updateProviderConfig(provider: .bedrock) { config in + config.awsAuthMode = BedrockAuthMode.profile.rawValue + config.awsProfile = "fixture" + } + } + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + startupBehavior: .testing, + environmentBase: [:]) + return (settings, store) + } + + private static func tokenSnapshot(cost: Double) -> CostUsageTokenSnapshot { + CostUsageTokenSnapshot( + sessionTokens: 10, + sessionCostUSD: cost, + last30DaysTokens: 10, + last30DaysCostUSD: cost, + currencyCode: "USD", + daily: [CostUsageDailyReport.Entry( + date: "2026-07-16", + inputTokens: 4, + outputTokens: 6, + totalTokens: 10, + costUSD: cost, + modelsUsed: nil, + modelBreakdowns: nil)], + updatedAt: Date(timeIntervalSince1970: 1_784_203_200)) + } + + private static func emptyTokenSnapshot() -> CostUsageTokenSnapshot { + CostUsageTokenSnapshot( + sessionTokens: nil, + sessionCostUSD: nil, + last30DaysTokens: 0, + last30DaysCostUSD: 0, + daily: [], + updatedAt: Date(timeIntervalSince1970: 1_784_179_200)) + } + + private static func mistralUsage(cost: Double) -> UsageSnapshot { + MistralUsageSnapshot( + totalCost: cost, + currency: "USD", + currencySymbol: "$", + totalInputTokens: 4, + totalOutputTokens: 6, + totalCachedTokens: 0, + modelCount: 1, + daily: [MistralDailyUsageBucket( + day: "2026-07-16", + cost: cost, + inputTokens: 4, + cachedTokens: 0, + outputTokens: 6, + models: [])], + startDate: nil, + endDate: nil, + updatedAt: Date(timeIntervalSince1970: 1_784_179_200)) + .toUsageSnapshot() + } + + private static func mistralOutcome(cost: Double) -> ProviderFetchOutcome { + ProviderFetchOutcome( + result: .success(ProviderFetchResult( + usage: self.mistralUsage(cost: cost), + credits: nil, + dashboard: nil, + sourceLabel: "fixture", + strategyID: "fixture", + strategyKind: .apiToken)), + attempts: []) + } + + private static func mistralOutcomeWithoutCostProjection() -> ProviderFetchOutcome { + ProviderFetchOutcome( + result: .success(ProviderFetchResult( + usage: UsageSnapshot(primary: nil, secondary: nil, updatedAt: Date()), + credits: nil, + dashboard: nil, + sourceLabel: "fixture", + strategyID: "fixture", + strategyKind: .apiToken)), + attempts: []) + } + + private static func waitUntil(_ condition: @MainActor () -> Bool) async { + for _ in 0..<1000 { + if condition() { + return + } + await Task.yield() + } + Issue.record("Timed out waiting for provenance state") + } +} + +private actor SpendDashboardProvenanceGate { + private var callCount = 0 + private var firstReleased = false + private var releaseContinuations: [CheckedContinuation] = [] + private var callWaiters: [(count: Int, continuation: CheckedContinuation)] = [] + + func enter() async -> Int { + self.callCount += 1 + let call = self.callCount + let ready = self.callWaiters.filter { self.callCount >= $0.count } + self.callWaiters.removeAll { self.callCount >= $0.count } + ready.forEach { $0.continuation.resume() } + if call == 1, !self.firstReleased { + await withCheckedContinuation { continuation in + self.releaseContinuations.append(continuation) + } + } + return call + } + + func waitForCalls(_ count: Int) async { + if self.callCount >= count { + return + } + await withCheckedContinuation { continuation in + self.callWaiters.append((count, continuation)) + } + } + + func releaseFirst() { + self.firstReleased = true + let continuations = self.releaseContinuations + self.releaseContinuations.removeAll() + continuations.forEach { $0.resume() } + } +} diff --git a/Tests/CodexBarTests/StatusItemAnimationCodexCreditsTests.swift b/Tests/CodexBarTests/StatusItemAnimationCodexCreditsTests.swift index eafebe544b..25dc34b444 100644 --- a/Tests/CodexBarTests/StatusItemAnimationCodexCreditsTests.swift +++ b/Tests/CodexBarTests/StatusItemAnimationCodexCreditsTests.swift @@ -6,20 +6,9 @@ import Testing @Suite(.serialized) @MainActor struct StatusItemAnimationCodexCreditsTests { - private func makeStatusBarForTesting() -> NSStatusBar { - let env = ProcessInfo.processInfo.environment - if env["GITHUB_ACTIONS"] == "true" || env["CI"] == "true" { - return .system - } - return NSStatusBar() - } - @Test func `codex icon keeps credits only rendering when usage is missing`() { - let settings = SettingsStore( - configStore: testConfigStore(suiteName: "StatusItemAnimationTests-credits-only-icon"), - zaiTokenStore: NoopZaiTokenStore(), - syntheticTokenStore: NoopSyntheticTokenStore()) + let settings = testSettingsStore(suiteName: "StatusItemAnimationTests-credits-only-icon") settings.statusChecksEnabled = false settings.refreshFrequency = .manual settings.mergeIcons = false @@ -41,7 +30,8 @@ struct StatusItemAnimationCodexCreditsTests { account: fetcher.loadAccountInfo(), updater: DisabledUpdaterController(), preferencesSelection: PreferencesSelection(), - statusBar: self.makeStatusBarForTesting()) + statusBar: testStatusBar()) + defer { controller.releaseStatusItemsForTesting() } controller.applyIcon(for: .codex, phase: nil) diff --git a/Tests/CodexBarTests/StatusItemAnimationSignatureTests.swift b/Tests/CodexBarTests/StatusItemAnimationSignatureTests.swift index fc064a9d34..a5986037d7 100644 --- a/Tests/CodexBarTests/StatusItemAnimationSignatureTests.swift +++ b/Tests/CodexBarTests/StatusItemAnimationSignatureTests.swift @@ -4,25 +4,12 @@ import Testing @testable import CodexBar @MainActor +@Suite(.serialized) struct StatusItemAnimationSignatureTests { - private func makeStatusBarForTesting() -> NSStatusBar { - let env = ProcessInfo.processInfo.environment - if env["GITHUB_ACTIONS"] == "true" || env["CI"] == "true" { - return .system - } - return NSStatusBar() - } - @Test - func `merged render signature changes when unified icon style changes`() throws { + func `merged render signature changes when unified icon style changes`() { let suite = "StatusItemAnimationSignatureTests-merged-style-signature" - let defaults = try #require(UserDefaults(suiteName: suite)) - defaults.removePersistentDomain(forName: suite) - let settings = SettingsStore( - userDefaults: defaults, - configStore: testConfigStore(suiteName: suite), - zaiTokenStore: NoopZaiTokenStore(), - syntheticTokenStore: NoopSyntheticTokenStore()) + let settings = testSettingsStore(suiteName: suite) settings.statusChecksEnabled = false settings.refreshFrequency = .manual settings.mergeIcons = true @@ -46,7 +33,8 @@ struct StatusItemAnimationSignatureTests { account: fetcher.loadAccountInfo(), updater: DisabledUpdaterController(), preferencesSelection: PreferencesSelection(), - statusBar: self.makeStatusBarForTesting()) + statusBar: testStatusBar()) + defer { controller.releaseStatusItemsForTesting() } store._setSnapshotForTesting( UsageSnapshot( @@ -77,16 +65,730 @@ struct StatusItemAnimationSignatureTests { #expect(codexSignature?.contains("style=codex") == true) } + @Test + func `merged antigravity icon resolves quota summary with provider style`() throws { + let suite = "StatusItemAnimationSignatureTests-merged-antigravity-provider-style" + let settings = testSettingsStore(suiteName: suite) + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.selectedMenuProvider = .antigravity + settings.menuBarShowsBrandIconWithPercent = false + settings.usageBarsShowUsed = false + settings.syntheticAPIToken = "synthetic-test-token" + + let registry = ProviderRegistry.shared + if let antigravityMeta = registry.metadata[.antigravity] { + settings.setProviderEnabled(provider: .antigravity, metadata: antigravityMeta, 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: testStatusBar()) + defer { controller.releaseStatusItemsForTesting() } + + store._setSnapshotForTesting( + UsageSnapshot( + primary: RateWindow(usedPercent: 99, windowMinutes: 10080, resetsAt: nil, resetDescription: nil), + secondary: RateWindow(usedPercent: 16, windowMinutes: 10080, resetsAt: nil, resetDescription: nil), + tertiary: nil, + extraRateWindows: [ + NamedRateWindow( + id: "antigravity-quota-summary-gemini-5h", + title: "Gemini Session", + window: RateWindow(usedPercent: 1, windowMinutes: 300, resetsAt: nil, resetDescription: nil)), + NamedRateWindow( + id: "antigravity-quota-summary-gemini-weekly", + title: "Gemini Weekly", + window: RateWindow( + usedPercent: 99, + windowMinutes: 10080, + resetsAt: nil, + resetDescription: nil)), + NamedRateWindow( + id: "antigravity-quota-summary-3p-5h", + title: "Claude + GPT Session", + window: RateWindow(usedPercent: 2, windowMinutes: 300, resetsAt: nil, resetDescription: nil)), + NamedRateWindow( + id: "antigravity-quota-summary-3p-weekly", + title: "Claude + GPT Weekly", + window: RateWindow( + usedPercent: 16, + windowMinutes: 10080, + resetsAt: nil, + resetDescription: nil)), + ], + updatedAt: Date()), + provider: .antigravity) + + #expect(store.iconStyle == .combined) + #expect(controller.primaryProviderForUnifiedIcon() == .antigravity) + + controller.applyIcon(phase: nil) + let signature = try #require(controller.lastAppliedMergedIconRenderSignature) + + #expect(signature.contains("provider=antigravity")) + #expect(signature.contains("style=combined")) + #expect(signature.contains("primary=98.000")) + #expect(signature.contains("weekly=1.000")) + } + + @Test + func `merged mistral icon uses monthly plan metric when selected`() throws { + let suite = "StatusItemAnimationSignatureTests-merged-mistral-monthly-plan" + let settings = testSettingsStore(suiteName: suite) + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.selectedMenuProvider = .mistral + settings.menuBarShowsBrandIconWithPercent = false + settings.usageBarsShowUsed = true + settings.syntheticAPIToken = "synthetic-test-token" + settings.setMenuBarMetricPreference(.monthlyPlan, for: .mistral) + + let registry = ProviderRegistry.shared + if let mistralMeta = registry.metadata[.mistral] { + settings.setProviderEnabled(provider: .mistral, metadata: mistralMeta, 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: testStatusBar()) + defer { controller.releaseStatusItemsForTesting() } + + store._setSnapshotForTesting( + UsageSnapshot( + primary: nil, + secondary: nil, + extraRateWindows: [ + NamedRateWindow( + id: "mistral-monthly-plan", + title: "Monthly Plan", + window: RateWindow(usedPercent: 42, windowMinutes: nil, resetsAt: nil, resetDescription: nil)), + ], + updatedAt: Date()), + provider: .mistral) + + #expect(store.iconStyle == .combined) + #expect(controller.primaryProviderForUnifiedIcon() == .mistral) + + controller.applyIcon(phase: nil) + let signature = try #require(controller.lastAppliedMergedIconRenderSignature) + + #expect(signature.contains("provider=mistral")) + #expect(signature.contains("primary=42.000")) + #expect(signature.contains("weekly=nil")) + } + + @Test + func `mistral pay as you go icon ignores balance primary percent`() { + let suite = "StatusItemAnimationSignatureTests-mistral-payg-balance-percent" + let settings = testSettingsStore(suiteName: suite) + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.usageBarsShowUsed = true + settings.setMenuBarMetricPreference(.automatic, for: .mistral) + + 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: testStatusBar()) + defer { controller.releaseStatusItemsForTesting() } + + let snapshot = UsageSnapshot( + primary: RateWindow( + usedPercent: 0, + windowMinutes: nil, + resetsAt: nil, + resetDescription: "$12.50"), + secondary: nil, + updatedAt: Date()) + + let percents = controller.resolvedMenuBarIconPercents( + provider: .mistral, + snapshot: snapshot, + style: .mistral, + showUsed: true) + + #expect(percents?.primary == nil) + #expect(percents?.secondary == nil) + } + + @Test + func `merged brand percent reapplies title when cached render is skipped`() throws { + let suite = "StatusItemAnimationSignatureTests-merged-brand-percent-title-restore" + let settings = testSettingsStore(suiteName: suite) + 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: testStatusBar()) + defer { controller.releaseStatusItemsForTesting() } + + 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 icon only content repairs stale title when cached render is skipped`() throws { + let suite = "StatusItemAnimationSignatureTests-merged-icon-only-title-restore" + let settings = testSettingsStore(suiteName: suite) + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.selectedMenuProvider = .codex + settings.menuBarShowsBrandIconWithPercent = false + + let registry = ProviderRegistry.shared + if let codexMeta = registry.metadata[.codex] { + settings.setProviderEnabled(provider: .codex, metadata: codexMeta, enabled: true) + } + if let claudeMeta = registry.metadata[.claude] { + settings.setProviderEnabled(provider: .claude, metadata: claudeMeta, enabled: 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: testStatusBar()) + defer { controller.releaseStatusItemsForTesting() } + + store._setSnapshotForTesting( + UsageSnapshot( + primary: RateWindow(usedPercent: 23, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: nil, + updatedAt: Date()), + provider: .codex) + + controller.applyIcon(phase: nil) + let button = try #require(controller.statusItem.button) + button.title = " stale" + button.imagePosition = .imageLeft + + let skipped = controller.applyIcon(phase: nil) + + #expect(skipped) + #expect(button.title.isEmpty) + #expect(button.imagePosition == .imageOnly) + } + + @Test + func `inactive display contrast embeds the brand and restores standard content when disabled`() throws { + let suite = "StatusItemAnimationSignatureTests-inactive-display-contrast" + let settings = testSettingsStore(suiteName: suite) + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.selectedMenuProvider = .codex + settings.menuBarShowsBrandIconWithPercent = true + settings.menuBarHighContrastOnInactiveDisplays = true + settings.menuBarDisplayMode = .percent + settings.usageBarsShowUsed = false + + let registry = ProviderRegistry.shared + if let codexMeta = registry.metadata[.codex] { + settings.setProviderEnabled(provider: .codex, metadata: codexMeta, 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: testStatusBar()) + defer { controller.releaseStatusItemsForTesting() } + + 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.image == nil) + #expect(button.imagePosition == .noImage) + #expect(button.attributedTitle.string == "\u{FFFC}\(expectedTitle)") + #expect(button.attributedTitle.attribute(.attachment, at: 0, effectiveRange: nil) is NSTextAttachment) + + #expect(controller.prepareButtonForImageOnlyCacheHit(button)) + #expect(button.image == nil) + #expect(button.imagePosition == .noImage) + #expect(button.attributedTitle.string == "\u{FFFC}\(expectedTitle)") + #expect(button.attributedTitle.attribute(.attachment, at: 0, effectiveRange: nil) is NSTextAttachment) + + button.attributedTitle = NSAttributedString() + #expect(!controller.prepareButtonForImageOnlyCacheHit(button)) + + controller.applyIcon(phase: nil) + #expect(button.attributedTitle.string == "\u{FFFC}\(expectedTitle)") + #expect(button.attributedTitle.attribute(.attachment, at: 0, effectiveRange: nil) is NSTextAttachment) + + settings.menuBarIconStyle = .critters + let critterSkipped = controller.applyIcon(phase: nil) + + #expect(!critterSkipped) + #expect(button.image != nil) + #expect(button.title.isEmpty) + #expect(button.imagePosition == .imageOnly) + #expect(button.attributedTitle.length == 0) + + settings.menuBarIconStyle = .iconAndPercent + controller.applyIcon(phase: nil) + + #expect(button.image == nil) + #expect(button.imagePosition == .noImage) + #expect(button.attributedTitle.string == "\u{FFFC}\(expectedTitle)") + #expect(button.attributedTitle.attribute(.attachment, at: 0, effectiveRange: nil) is NSTextAttachment) + + settings.menuBarHighContrastOnInactiveDisplays = false + let skipped = controller.applyIcon(phase: nil) + + #expect(!skipped) + #expect(button.image != nil) + #expect(button.title == expectedTitle) + #expect(button.imagePosition == .imageLeft) + #expect(button.attributedTitle.attribute(.attachment, at: 0, effectiveRange: nil) == nil) + } + + @Test + func `merged icon render defers while merged menu is tracking`() async throws { + let suite = "StatusItemAnimationSignatureTests-merged-icon-defers-during-tracking" + let settings = testSettingsStore(suiteName: suite) + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.selectedMenuProvider = .codex + settings.menuBarShowsBrandIconWithPercent = false + settings.syntheticAPIToken = "synthetic-test-token" + + 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 || provider == .synthetic) + } + + 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: testStatusBar()) + defer { controller.releaseStatusItemsForTesting() } + controller.menuRefreshEnabledOverrideForTesting = true + + func snapshot(usedPercent: Double) -> UsageSnapshot { + UsageSnapshot( + primary: RateWindow( + usedPercent: usedPercent, + windowMinutes: nil, + resetsAt: nil, + resetDescription: nil), + secondary: nil, + updatedAt: Date()) + } + + store._setSnapshotForTesting(snapshot(usedPercent: 20), provider: .codex) + controller.updateIcons() + #expect(controller.animationDriver == nil) + controller.applyIcon(phase: nil) + let initialSignature = try #require(controller.lastAppliedMergedIconRenderSignature) + + let menu = controller.makeMenu() + controller.mergedMenu = menu + controller.statusItem.menu = menu + controller.menuWillOpen(menu) + #expect(controller.isMergedMenuOpen) + + store._setSnapshotForTesting(nil, provider: .codex) + controller.updateIcons() + #expect(controller.animationDriver != nil) + #expect(controller.deferredMergedIconRenderAfterTracking) + + store._setSnapshotForTesting(snapshot(usedPercent: 80), provider: .codex) + controller.updateIcons() + #expect(controller.animationDriver == nil) + #expect(controller.deferredMergedIconRenderAfterTracking) + #expect(controller.lastAppliedMergedIconRenderSignature == initialSignature) + + controller.startQuotaWarningFlash(provider: .codex) + #expect(controller.lastAppliedMergedIconRenderSignature?.contains("warningFlash=1") == true) + + let quotaWarningTask = controller.quotaWarningFlashTasks[.codex] + controller.clearExpiredQuotaWarningFlash(provider: .codex, now: .distantFuture) + quotaWarningTask?.cancel() + #expect(controller.lastAppliedMergedIconRenderSignature?.contains("warningFlash=0") == true) + + controller.menuDidClose(menu) + + #expect(!controller.deferredMergedIconRenderAfterTracking) + #expect(controller.lastAppliedMergedIconRenderSignature?.contains("warningFlash=0") == true) + + controller.menuWillOpen(menu) + settings.selectedMenuProvider = .synthetic + #expect(controller.primaryProviderForUnifiedIcon() == .synthetic) + #expect(controller.lastAppliedMergedIconRenderSignature?.contains("provider=codex") == true) + + controller.startQuotaWarningFlash(provider: .codex) + let switchedProviderWarningTask = controller.quotaWarningFlashTasks[.codex] + #expect(controller.lastAppliedMergedIconRenderSignature?.contains("provider=synthetic") == true) + controller.clearExpiredQuotaWarningFlash(provider: .codex, now: .distantFuture) + switchedProviderWarningTask?.cancel() + controller.menuDidClose(menu) + + settings.selectedMenuProvider = .codex + for _ in 0..<10 where controller.primaryProviderForUnifiedIcon() != .codex { + await Task.yield() + } + + controller.menuWillOpen(menu) + store._setSnapshotForTesting(nil, provider: .codex) + controller.updateAnimationState() + controller.applyIcon(phase: controller.animationPhase) + #expect(controller.animationDriver != nil) + #expect(controller.deferredMergedIconRenderAfterTracking) + + controller.animationDriver?.stop() + controller.animationDriver = nil + controller.animationPhase = 0 + controller.menuDidClose(menu) + + #expect(controller.animationDriver == nil) + #expect(controller.lastAppliedMergedIconRenderSignature?.contains("primary=nil") == true) + } + + @Test + func `merged fallback provider follows enabled provider order`() { + let suite = "StatusItemAnimationSignatureTests-merged-provider-order" + let settings = testSettingsStore(suiteName: suite) + 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: testStatusBar()) + defer { controller.releaseStatusItemsForTesting() } + + 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 status indicator follows rendered provider`() throws { + let suite = "StatusItemAnimationSignatureTests-merged-status-provider-scope" + let settings = testSettingsStore(suiteName: suite) + settings.statusChecksEnabled = true + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.selectedMenuProvider = .codex + settings.menuBarShowsBrandIconWithPercent = false + + let registry = ProviderRegistry.shared + let codexMeta = try #require(registry.metadata[.codex]) + let claudeMeta = try #require(registry.metadata[.claude]) + settings.setProviderEnabled(provider: .codex, metadata: codexMeta, enabled: true) + settings.setProviderEnabled(provider: .claude, metadata: claudeMeta, 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: testStatusBar()) + defer { controller.releaseStatusItemsForTesting() } + + 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: .claude) + store.statuses[.claude] = ProviderStatus( + indicator: .major, + description: "Claude status issue", + updatedAt: Date(timeIntervalSince1970: 20)) + + controller.applyIcon(phase: nil) + + #expect(controller.primaryProviderForUnifiedIcon() == .codex) + #expect(controller.lastAppliedMergedIconRenderSignature?.contains("provider=codex") == true) + #expect(controller.lastAppliedMergedIconRenderSignature?.contains("status=none") == true) + + settings.selectedMenuProvider = .claude + controller.applyIcon(phase: nil) + + #expect(controller.primaryProviderForUnifiedIcon() == .claude) + #expect(controller.lastAppliedMergedIconRenderSignature?.contains("provider=claude") == true) + #expect(controller.lastAppliedMergedIconRenderSignature?.contains("status=major") == true) + } + + @Test + func `highest usage icon ranks only overview providers`() throws { + let suite = "StatusItemAnimationSignatureTests-highest-usage-overview-subset" + let settings = testSettingsStore(suiteName: suite) + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.menuBarShowsHighestUsage = true + + let registry = ProviderRegistry.shared + let codexMeta = try #require(registry.metadata[.codex]) + let claudeMeta = try #require(registry.metadata[.claude]) + settings.setProviderEnabled(provider: .codex, metadata: codexMeta, enabled: true) + settings.setProviderEnabled(provider: .claude, metadata: claudeMeta, enabled: true) + + let fetcher = UsageFetcher() + let store = UsageStore( + fetcher: fetcher, + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings) + settings.setMergedOverviewProviderSelection( + provider: .claude, + isSelected: false, + activeProviders: store.enabledProvidersForDisplay()) + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: testStatusBar()) + defer { controller.releaseStatusItemsForTesting() } + + store._setSnapshotForTesting( + UsageSnapshot( + primary: RateWindow(usedPercent: 25, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: nil, + updatedAt: Date()), + provider: .codex) + store._setSnapshotForTesting( + UsageSnapshot( + primary: RateWindow(usedPercent: 80, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: nil, + updatedAt: Date()), + provider: .claude) + + #expect(store.providerWithHighestUsage()?.provider == .claude) + #expect(controller.primaryProviderForUnifiedIcon() == .codex) + } + + @Test(arguments: [nil, 100.0] as [Double?]) + func `highest usage icon keeps nonempty overview authoritative when unrankable`( + overviewUsedPercent: Double?) throws + { + let suite = "StatusItemAnimationSignatureTests-highest-usage-overview-fallback" + let settings = testSettingsStore(suiteName: suite) + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.menuBarShowsHighestUsage = true + settings.selectedMenuProvider = .claude + settings.mergedMenuLastSelectedWasOverview = false + + let registry = ProviderRegistry.shared + let codexMeta = try #require(registry.metadata[.codex]) + let claudeMeta = try #require(registry.metadata[.claude]) + settings.setProviderEnabled(provider: .codex, metadata: codexMeta, enabled: true) + settings.setProviderEnabled(provider: .claude, metadata: claudeMeta, enabled: true) + + let fetcher = UsageFetcher() + let store = UsageStore( + fetcher: fetcher, + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings) + settings.setMergedOverviewProviderSelection( + provider: .claude, + isSelected: false, + activeProviders: store.enabledProvidersForDisplay()) + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: testStatusBar()) + defer { controller.releaseStatusItemsForTesting() } + + if let overviewUsedPercent { + store._setSnapshotForTesting( + UsageSnapshot( + primary: RateWindow( + usedPercent: overviewUsedPercent, + windowMinutes: nil, + resetsAt: nil, + resetDescription: nil), + secondary: nil, + updatedAt: Date()), + provider: .codex) + } + store._setSnapshotForTesting( + UsageSnapshot( + primary: RateWindow(usedPercent: 80, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: nil, + updatedAt: Date()), + provider: .claude) + + #expect(store.providerWithHighestUsage(candidateProviders: [.codex]) == nil) + #expect(controller.primaryProviderForUnifiedIcon() == .codex) + } + + @Test + func `highest usage icon allows broad fallback for explicit empty overview`() throws { + let suite = "StatusItemAnimationSignatureTests-highest-usage-empty-overview" + let settings = testSettingsStore(suiteName: suite) + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.menuBarShowsHighestUsage = true + settings.selectedMenuProvider = .claude + + let registry = ProviderRegistry.shared + let codexMeta = try #require(registry.metadata[.codex]) + let claudeMeta = try #require(registry.metadata[.claude]) + settings.setProviderEnabled(provider: .codex, metadata: codexMeta, enabled: true) + settings.setProviderEnabled(provider: .claude, metadata: claudeMeta, enabled: true) + + let fetcher = UsageFetcher() + let store = UsageStore( + fetcher: fetcher, + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings) + let activeProviders = store.enabledProvidersForDisplay() + settings.setMergedOverviewProviderSelection( + provider: .codex, + isSelected: false, + activeProviders: activeProviders) + settings.setMergedOverviewProviderSelection( + provider: .claude, + isSelected: false, + activeProviders: activeProviders) + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: testStatusBar()) + defer { controller.releaseStatusItemsForTesting() } + + #expect(settings.resolvedMergedOverviewProviders(activeProviders: store.enabledProvidersForDisplay()) == []) + #expect(controller.primaryProviderForUnifiedIcon() == .claude) + } + @Test func `merged icon follows overview provider order when first overview provider is loading`() { let suite = "StatusItemAnimationSignatureTests-merged-overview-provider-order" - let defaults = UserDefaults(suiteName: suite) - defaults?.removePersistentDomain(forName: suite) - let settings = SettingsStore( - userDefaults: defaults ?? .standard, - configStore: testConfigStore(suiteName: "StatusItemAnimationSignatureTests-merged-overview-provider-order"), - zaiTokenStore: NoopZaiTokenStore(), - syntheticTokenStore: NoopSyntheticTokenStore()) + let settings = testSettingsStore(suiteName: suite) settings.statusChecksEnabled = false settings.refreshFrequency = .manual settings.mergeIcons = true @@ -114,7 +816,8 @@ struct StatusItemAnimationSignatureTests { account: fetcher.loadAccountInfo(), updater: DisabledUpdaterController(), preferencesSelection: PreferencesSelection(), - statusBar: self.makeStatusBarForTesting()) + statusBar: testStatusBar()) + defer { controller.releaseStatusItemsForTesting() } let snapshot = UsageSnapshot( primary: RateWindow(usedPercent: 50, windowMinutes: nil, resetsAt: nil, resetDescription: nil), @@ -138,13 +841,7 @@ struct StatusItemAnimationSignatureTests { @Test func `split provider icon skips unchanged render signature`() throws { let suite = "StatusItemAnimationSignatureTests-split-provider-signature" - let defaults = try #require(UserDefaults(suiteName: suite)) - defaults.removePersistentDomain(forName: suite) - let settings = SettingsStore( - userDefaults: defaults, - configStore: testConfigStore(suiteName: suite), - zaiTokenStore: NoopZaiTokenStore(), - syntheticTokenStore: NoopSyntheticTokenStore()) + let settings = testSettingsStore(suiteName: suite) settings.statusChecksEnabled = false settings.refreshFrequency = .manual settings.mergeIcons = false @@ -162,7 +859,8 @@ struct StatusItemAnimationSignatureTests { account: fetcher.loadAccountInfo(), updater: DisabledUpdaterController(), preferencesSelection: PreferencesSelection(), - statusBar: self.makeStatusBarForTesting()) + statusBar: testStatusBar()) + defer { controller.releaseStatusItemsForTesting() } store._setSnapshotForTesting( UsageSnapshot( @@ -172,7 +870,13 @@ struct StatusItemAnimationSignatureTests { provider: .codex) #expect(controller.applyIcon(for: .codex, phase: nil) == false) + let button = try #require(controller.statusItems[.codex]?.button) + button.title = " stale" + button.imagePosition = .imageLeft + #expect(controller.applyIcon(for: .codex, phase: nil) == true) + #expect(button.title.isEmpty) + #expect(button.imagePosition == .imageOnly) store._setSnapshotForTesting( UsageSnapshot( diff --git a/Tests/CodexBarTests/StatusItemAnimationTests.swift b/Tests/CodexBarTests/StatusItemAnimationTests.swift index cca5e433c4..6d00d898df 100644 --- a/Tests/CodexBarTests/StatusItemAnimationTests.swift +++ b/Tests/CodexBarTests/StatusItemAnimationTests.swift @@ -26,6 +26,40 @@ struct StatusItemAnimationTests { .system } + @Test + func `known unavailable limits stop loading animation`() { + let settings = SettingsStore( + configStore: testConfigStore(suiteName: "StatusItemAnimationTests-known-unavailable"), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + + let registry = ProviderRegistry.shared + if let claudeMeta = registry.metadata[.claude] { + settings.setProviderEnabled(provider: .claude, metadata: claudeMeta, 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()) + defer { controller.releaseStatusItemsForTesting() } + + store._setSnapshotForTesting(nil, provider: .claude) + store._setErrorForTesting(nil, provider: .claude) + #expect(controller.shouldAnimate(provider: .claude)) + + store._setKnownLimitsAvailabilityForTesting(.unavailable, provider: .claude) + #expect(!controller.shouldAnimate(provider: .claude)) + } + @Test func `merged icon loading animation tracks selected provider only`() { let settings = SettingsStore( @@ -420,6 +454,47 @@ struct StatusItemAnimationTests { #expect(window?.usedPercent == 42) } + @Test + func `combined codex menu bar metric window uses most constrained visible lane`() { + let settings = SettingsStore( + configStore: testConfigStore(suiteName: "StatusItemAnimationTests-codex-combined-window"), + zaiTokenStore: NoopZaiTokenStore()) + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.selectedMenuProvider = .codex + settings.setMenuBarMetricPreference(.primaryAndSecondary, for: .codex) + + let registry = ProviderRegistry.shared + if let codexMeta = registry.metadata[.codex] { + settings.setProviderEnabled(provider: .codex, metadata: codexMeta, 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()) + defer { controller.releaseStatusItemsForTesting() } + + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 12, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + secondary: RateWindow(usedPercent: 91, windowMinutes: 7 * 24 * 60, resetsAt: nil, resetDescription: nil), + updatedAt: Date()) + + store._setSnapshotForTesting(snapshot, provider: .codex) + store._setErrorForTesting(nil, provider: .codex) + + let window = controller.menuBarMetricWindow(for: .codex, snapshot: snapshot) + + #expect(window?.usedPercent == 91) + #expect(window?.windowMinutes == 7 * 24 * 60) + } + @Test func `menu bar percent automatic prefers rate limit for kimi`() { let settings = SettingsStore( @@ -894,6 +969,47 @@ struct StatusItemAnimationTests { #expect(both == "40% · +16%") } + @Test + func `menu bar display text formats codex combined percent lanes`() { + let sessionWindow = RateWindow(usedPercent: 7, windowMinutes: 300, resetsAt: nil, resetDescription: nil) + let weeklyWindow = RateWindow(usedPercent: 18, windowMinutes: 10080, resetsAt: nil, resetDescription: nil) + + let remaining = MenuBarDisplayText.combinedSessionWeeklyPercentText( + sessionWindow: sessionWindow, + weeklyWindow: weeklyWindow, + showUsed: false) + let used = MenuBarDisplayText.combinedSessionWeeklyPercentText( + sessionWindow: sessionWindow, + weeklyWindow: weeklyWindow, + showUsed: true) + let weeklyOnly = MenuBarDisplayText.combinedSessionWeeklyPercentText( + sessionWindow: nil, + weeklyWindow: weeklyWindow, + showUsed: false) + let nineHour = MenuBarDisplayText.combinedSessionWeeklyPercentText( + sessionWindow: RateWindow( + usedPercent: 7, + windowMinutes: 540, + resetsAt: nil, + resetDescription: nil), + weeklyWindow: weeklyWindow, + showUsed: false) + let unknownSessionDuration = MenuBarDisplayText.combinedSessionWeeklyPercentText( + sessionWindow: RateWindow( + usedPercent: 7, + windowMinutes: nil, + resetsAt: nil, + resetDescription: nil), + weeklyWindow: weeklyWindow, + showUsed: false) + + #expect(remaining == "5h 93% · W 82%") + #expect(used == "5h 7% · W 18%") + #expect(weeklyOnly == "W 82%") + #expect(nineHour == "9h 93% · W 82%") + #expect(unknownSessionDuration == "S 93% · W 82%") + } + @Test func `menu bar display text falls back to percent when pace unavailable`() { let percentWindow = RateWindow(usedPercent: 40, windowMinutes: nil, resetsAt: nil, resetDescription: nil) @@ -907,8 +1023,7 @@ struct StatusItemAnimationTests { percentWindow: percentWindow, showUsed: true) - #expect(pace == nil) - // "Both" mode falls back to percent-only when pace is unavailable + #expect(pace == "40%") #expect(both == "40%") } @@ -927,11 +1042,544 @@ struct StatusItemAnimationTests { pace: nil, showUsed: true) - #expect(pace == nil) - // "Both" mode falls back to percent-only when pace is unavailable + #expect(pace == "40%") #expect(both == "40%") } + @Test + func `claude primary menu bar metric computes pace from selected session window`() { + let settings = SettingsStore( + configStore: testConfigStore(suiteName: "StatusItemAnimationTests-claude-primary-pace"), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.selectedMenuProvider = .claude + settings.menuBarDisplayMode = .both + settings.usageBarsShowUsed = false + settings.setMenuBarMetricPreference(.primary, for: .claude) + + let registry = ProviderRegistry.shared + if let claudeMeta = registry.metadata[.claude] { + settings.setProviderEnabled(provider: .claude, metadata: claudeMeta, 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()) + defer { controller.releaseStatusItemsForTesting() } + + let now = Date() + let snapshot = UsageSnapshot( + primary: RateWindow( + usedPercent: 80, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(4 * 60 * 60), + resetDescription: nil), + secondary: RateWindow( + usedPercent: 20, + windowMinutes: 7 * 24 * 60, + resetsAt: now.addingTimeInterval(24 * 60 * 60), + resetDescription: nil), + updatedAt: now) + store._setSnapshotForTesting(snapshot, provider: .claude) + store._setErrorForTesting(nil, provider: .claude) + + let displayText = controller.menuBarDisplayText(for: .claude, snapshot: snapshot) + + #expect(displayText == "20% · +60%") + } + + @Test + func `claude combined menu bar metric shows session and weekly lanes`() { + let settings = SettingsStore( + configStore: testConfigStore(suiteName: "StatusItemAnimationTests-claude-combined"), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.selectedMenuProvider = .claude + settings.menuBarDisplayMode = .percent + settings.usageBarsShowUsed = true + settings.setMenuBarMetricPreference(.primaryAndSecondary, for: .claude) + #expect(settings.menuBarMetricPreference(for: .claude) == .primaryAndSecondary) + + let registry = ProviderRegistry.shared + if let claudeMeta = registry.metadata[.claude] { + settings.setProviderEnabled(provider: .claude, metadata: claudeMeta, 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()) + defer { controller.releaseStatusItemsForTesting() } + + let now = Date() + let snapshot = UsageSnapshot( + primary: RateWindow( + usedPercent: 12, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(4 * 60 * 60), + resetDescription: nil), + secondary: RateWindow( + usedPercent: 45, + windowMinutes: 7 * 24 * 60, + resetsAt: now.addingTimeInterval(24 * 60 * 60), + resetDescription: nil), + updatedAt: now) + store._setSnapshotForTesting(snapshot, provider: .claude) + store._setErrorForTesting(nil, provider: .claude) + + let displayText = controller.menuBarDisplayText(for: .claude, snapshot: snapshot) + + #expect(displayText == "5h 12% · W 45%") + } + + @Test + func `claude combined menu bar metric shows weekly only when session lane is absent`() { + // Mirrors the Claude OAuth path where `five_hour` is missing: the mapper parks the 7-day + // window in BOTH `primary` and `secondary`. The combined metric must not relabel the + // weekly window as a session lane (e.g. "168h 42% · W 42%") — it should show weekly only. + let settings = SettingsStore( + configStore: testConfigStore(suiteName: "StatusItemAnimationTests-claude-combined-no-session"), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.selectedMenuProvider = .claude + settings.menuBarDisplayMode = .percent + settings.usageBarsShowUsed = true + settings.setMenuBarMetricPreference(.primaryAndSecondary, for: .claude) + + let registry = ProviderRegistry.shared + if let claudeMeta = registry.metadata[.claude] { + settings.setProviderEnabled(provider: .claude, metadata: claudeMeta, 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()) + defer { controller.releaseStatusItemsForTesting() } + + let now = Date() + let weekly = RateWindow( + usedPercent: 42, + windowMinutes: 7 * 24 * 60, + resetsAt: now.addingTimeInterval(24 * 60 * 60), + resetDescription: nil) + let snapshot = UsageSnapshot(primary: weekly, secondary: weekly, updatedAt: now) + store._setSnapshotForTesting(snapshot, provider: .claude) + store._setErrorForTesting(nil, provider: .claude) + + let displayText = controller.menuBarDisplayText(for: .claude, snapshot: snapshot) + + #expect(displayText == "W 42%") + } + + @Test + func `claude combined menu bar metric paces the weekly lane in both mode`() { + let settings = SettingsStore( + configStore: testConfigStore(suiteName: "StatusItemAnimationTests-claude-combined-pace"), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.selectedMenuProvider = .claude + settings.menuBarDisplayMode = .both + settings.menuBarShowsResetTimeWhenExhausted = false + settings.usageBarsShowUsed = false + settings.setMenuBarMetricPreference(.primaryAndSecondary, for: .claude) + + let registry = ProviderRegistry.shared + if let claudeMeta = registry.metadata[.claude] { + settings.setProviderEnabled(provider: .claude, metadata: claudeMeta, 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()) + defer { controller.releaseStatusItemsForTesting() } + + let now = Date() + // Session lane is fully consumed, so its own pace is nil; the weekly lane still has room. + // The combined metric must pace the weekly lane, so a pace component must appear even though + // the displayed percent comes from the session lane (here fully consumed). + let snapshot = UsageSnapshot( + primary: RateWindow( + usedPercent: 100, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(4 * 60 * 60), + resetDescription: nil), + secondary: RateWindow( + usedPercent: 50, + windowMinutes: 7 * 24 * 60, + resetsAt: now.addingTimeInterval(60 * 60), + resetDescription: nil), + updatedAt: now) + store._setSnapshotForTesting(snapshot, provider: .claude) + store._setErrorForTesting(nil, provider: .claude) + + let displayText = controller.menuBarDisplayText(for: .claude, snapshot: snapshot) + + // "0% · ±N%": percent from the session lane (here exhausted), pace from the weekly lane. + #expect(displayText?.hasPrefix("0% · ") == true) + } + + @Test + func `claude combined menu bar metric pairs session usage with weekly pace`() { + // Regression: in pace/both modes the combined metric must pair the SESSION usage with the + // WEEKLY pace. Previously the usage component came from the most-constrained lane, so when the + // weekly lane was busier than the session lane it showed weekly usage + weekly pace. + let settings = SettingsStore( + configStore: testConfigStore(suiteName: "StatusItemAnimationTests-claude-combined-session-pace"), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.selectedMenuProvider = .claude + settings.menuBarDisplayMode = .both + settings.usageBarsShowUsed = true + settings.setMenuBarMetricPreference(.primaryAndSecondary, for: .claude) + + let registry = ProviderRegistry.shared + if let claudeMeta = registry.metadata[.claude] { + settings.setProviderEnabled(provider: .claude, metadata: claudeMeta, 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()) + defer { controller.releaseStatusItemsForTesting() } + + let now = Date() + // Weekly lane (45%) is busier than the session lane (12%). The usage component must still be the + // session lane, while the pace is computed on the weekly lane (mostly elapsed → pace present). + let snapshot = UsageSnapshot( + primary: RateWindow( + usedPercent: 12, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(4 * 60 * 60), + resetDescription: nil), + secondary: RateWindow( + usedPercent: 45, + windowMinutes: 7 * 24 * 60, + resetsAt: now.addingTimeInterval(60 * 60), + resetDescription: nil), + updatedAt: now) + store._setSnapshotForTesting(snapshot, provider: .claude) + store._setErrorForTesting(nil, provider: .claude) + + let displayText = controller.menuBarDisplayText(for: .claude, snapshot: snapshot) + + // Usage is the session lane (12% used), not the most-constrained weekly lane (45%). + #expect(displayText?.hasPrefix("12% · ") == true) + #expect(displayText?.hasPrefix("45%") == false) + } + + @Test + func `codex combined menu bar metric pairs session usage with weekly pace`() { + // The combined metric is shared with Codex, which resolves its lanes through the consumer + // projection. The session usage must headline the pace/both readout there too — not the busier + // weekly lane that drives the icon/bar. + let settings = SettingsStore( + configStore: testConfigStore(suiteName: "StatusItemAnimationTests-codex-combined-session-pace"), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.selectedMenuProvider = .codex + settings.menuBarDisplayMode = .both + settings.usageBarsShowUsed = true + settings.setMenuBarMetricPreference(.primaryAndSecondary, for: .codex) + + let registry = ProviderRegistry.shared + if let codexMeta = registry.metadata[.codex] { + settings.setProviderEnabled(provider: .codex, metadata: codexMeta, 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()) + defer { controller.releaseStatusItemsForTesting() } + + let now = Date() + // Weekly lane (91%) is busier than the session lane (12%), but neither is exhausted. The usage + // component must be the session lane while the pace is computed on the weekly lane. + let snapshot = UsageSnapshot( + primary: RateWindow( + usedPercent: 12, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(4 * 60 * 60), + resetDescription: nil), + secondary: RateWindow( + usedPercent: 91, + windowMinutes: 7 * 24 * 60, + resetsAt: now.addingTimeInterval(60 * 60), + resetDescription: nil), + updatedAt: now) + store._setSnapshotForTesting(snapshot, provider: .codex) + store._setErrorForTesting(nil, provider: .codex) + + let displayText = controller.menuBarDisplayText(for: .codex, snapshot: snapshot) + + #expect(displayText?.hasPrefix("12% · ") == true) + #expect(displayText?.hasPrefix("91%") == false) + } + + @Test + func `claude combined menu bar metric surfaces an exhausted weekly lane in both mode`() { + // When the weekly lane is exhausted it is the binding cap and has no pace, so the combined metric + // must surface it instead of a roomy session number that would hide the spent weekly limit. + let settings = SettingsStore( + configStore: testConfigStore(suiteName: "StatusItemAnimationTests-claude-combined-weekly-exhausted"), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.selectedMenuProvider = .claude + settings.menuBarDisplayMode = .both + settings.menuBarShowsResetTimeWhenExhausted = false + settings.usageBarsShowUsed = false + settings.setMenuBarMetricPreference(.primaryAndSecondary, for: .claude) + + let registry = ProviderRegistry.shared + if let claudeMeta = registry.metadata[.claude] { + settings.setProviderEnabled(provider: .claude, metadata: claudeMeta, 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()) + defer { controller.releaseStatusItemsForTesting() } + + let now = Date() + // Session lane has room (88% remaining); weekly lane is fully consumed (0% remaining). + let snapshot = UsageSnapshot( + primary: RateWindow( + usedPercent: 12, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(4 * 60 * 60), + resetDescription: nil), + secondary: RateWindow( + usedPercent: 100, + windowMinutes: 7 * 24 * 60, + resetsAt: now.addingTimeInterval(60 * 60), + resetDescription: nil), + updatedAt: now) + store._setSnapshotForTesting(snapshot, provider: .claude) + store._setErrorForTesting(nil, provider: .claude) + + let displayText = controller.menuBarDisplayText(for: .claude, snapshot: snapshot) + + // Shows the exhausted weekly lane (0% remaining), not the roomy session lane (88%). + #expect(displayText == "0%") + #expect(displayText?.hasPrefix("88%") == false) + } + + @Test + func `claude combined menu bar metric falls back to weekly lane in both mode when session absent`() { + // Five_hour OAuth fallback: the mapper parks the 7-day window in both primary and secondary, so no + // session lane exists. The pace/both usage component must land on the weekly lane, not collapse to + // nil. + let settings = SettingsStore( + configStore: testConfigStore(suiteName: "StatusItemAnimationTests-claude-combined-no-session-both"), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.selectedMenuProvider = .claude + settings.menuBarDisplayMode = .both + settings.usageBarsShowUsed = true + settings.setMenuBarMetricPreference(.primaryAndSecondary, for: .claude) + + let registry = ProviderRegistry.shared + if let claudeMeta = registry.metadata[.claude] { + settings.setProviderEnabled(provider: .claude, metadata: claudeMeta, 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()) + defer { controller.releaseStatusItemsForTesting() } + + let now = Date() + let weekly = RateWindow( + usedPercent: 42, + windowMinutes: 7 * 24 * 60, + resetsAt: now.addingTimeInterval(60 * 60), + resetDescription: nil) + let snapshot = UsageSnapshot(primary: weekly, secondary: weekly, updatedAt: now) + store._setSnapshotForTesting(snapshot, provider: .claude) + store._setErrorForTesting(nil, provider: .claude) + + let displayText = controller.menuBarDisplayText(for: .claude, snapshot: snapshot) + + // Usage lands on the weekly lane (42% used) rather than collapsing to nil. + #expect(displayText?.hasPrefix("42%") == true) + } + + @Test + func `claude combined menu bar metric shows spend limit for a spend-limit-only account`() { + // A Claude account that only exposes an enterprise/extra-usage spend limit has no real + // session/weekly lanes (here a 0% 5h placeholder + a spend limit). With Session + Weekly selected, + // it must surface the spend-limit usage, not the meaningless "5h 0%" placeholder lane. + let settings = SettingsStore( + configStore: testConfigStore(suiteName: "StatusItemAnimationTests-claude-combined-spend-limit"), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.selectedMenuProvider = .claude + settings.menuBarDisplayMode = .percent + settings.usageBarsShowUsed = true + settings.setMenuBarMetricPreference(.primaryAndSecondary, for: .claude) + + let registry = ProviderRegistry.shared + if let claudeMeta = registry.metadata[.claude] { + settings.setProviderEnabled(provider: .claude, metadata: claudeMeta, 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()) + defer { controller.releaseStatusItemsForTesting() } + + let now = Date() + let snapshot = UsageSnapshot( + primary: RateWindow( + usedPercent: 0, + windowMinutes: 300, + resetsAt: nil, + resetDescription: nil, + isSyntheticPlaceholder: true), + secondary: nil, + providerCost: ProviderCostSnapshot( + used: 45, + limit: 100, + currencyCode: "USD", + period: "Spend limit", + updatedAt: now), + updatedAt: now) + store._setSnapshotForTesting(snapshot, provider: .claude) + store._setErrorForTesting(nil, provider: .claude) + + let displayText = controller.menuBarDisplayText(for: .claude, snapshot: snapshot) + + // Spend-limit usage (45% of the cap), not the "5h 0%" placeholder lane. + #expect(displayText == "45%") + #expect(displayText?.contains("5h") == false) + } + + @Test + func `codex menu bar pace does not fall back to session when weekly projection is unavailable`() { + let settings = SettingsStore( + configStore: testConfigStore(suiteName: "StatusItemAnimationTests-codex-no-weekly-pace"), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.selectedMenuProvider = .codex + settings.menuBarDisplayMode = .both + settings.usageBarsShowUsed = false + settings.setMenuBarMetricPreference(.primary, for: .codex) + + let registry = ProviderRegistry.shared + if let codexMeta = registry.metadata[.codex] { + settings.setProviderEnabled(provider: .codex, metadata: codexMeta, 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()) + defer { controller.releaseStatusItemsForTesting() } + + let now = Date() + let snapshot = UsageSnapshot( + primary: RateWindow( + usedPercent: 80, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(4 * 60 * 60), + resetDescription: nil), + secondary: nil, + updatedAt: now) + store._setSnapshotForTesting(snapshot, provider: .codex) + store._setErrorForTesting(nil, provider: .codex) + + let displayText = controller.menuBarDisplayText(for: .codex, snapshot: snapshot) + + #expect(displayText == "20%") + } + @Test func `menu bar display text uses credits when codex weekly is exhausted`() { let settings = SettingsStore( @@ -1103,28 +1751,4 @@ struct StatusItemAnimationTests { #expect(baselineAlpha < 0.01) #expect(outputAlpha > 0.01) } - - @Test - func `menu bar time window settings round trip`() { - // These settings persist to UserDefaults.standard; clear leftovers so the defaults check is meaningful. - UserDefaults.standard.removeObject(forKey: "menuBarPercentTimeWindow") - UserDefaults.standard.removeObject(forKey: "menuBarPaceTimeWindow") - - let settings = SettingsStore( - configStore: testConfigStore(suiteName: "StatusItemAnimationTests-timewindow"), - zaiTokenStore: NoopZaiTokenStore()) - - // Backward-compatible defaults: percent tracks the session, pace tracks the week. - #expect(settings.menuBarPercentTimeWindow == .session) - #expect(settings.menuBarPaceTimeWindow == .weekly) - - settings.menuBarPercentTimeWindow = .weekly - settings.menuBarPaceTimeWindow = .session - - #expect(settings.menuBarPercentTimeWindow == .weekly) - #expect(settings.menuBarPaceTimeWindow == .session) - - #expect(settings.userDefaults.string(forKey: "menuBarPercentTimeWindow") == "weekly") - #expect(settings.userDefaults.string(forKey: "menuBarPaceTimeWindow") == "session") - } } diff --git a/Tests/CodexBarTests/StatusItemBalanceDisplayTests.swift b/Tests/CodexBarTests/StatusItemBalanceDisplayTests.swift index 1f88a7230a..b55c718785 100644 --- a/Tests/CodexBarTests/StatusItemBalanceDisplayTests.swift +++ b/Tests/CodexBarTests/StatusItemBalanceDisplayTests.swift @@ -6,14 +6,6 @@ import Testing @Suite(.serialized) @MainActor struct StatusItemBalanceDisplayTests { - private func makeStatusBarForTesting() -> NSStatusBar { - let env = ProcessInfo.processInfo.environment - if env["GITHUB_ACTIONS"] == "true" || env["CI"] == "true" { - return .system - } - return NSStatusBar() - } - @Test func `menu bar display text uses open router balance`() { let settings = self.makeSettings( @@ -21,6 +13,7 @@ struct StatusItemBalanceDisplayTests { provider: .openrouter) settings.setMenuBarMetricPreference(.automatic, for: .openrouter) let (store, controller) = self.makeStoreAndController(settings: settings) + defer { controller.releaseStatusItemsForTesting() } let snapshot = Self.openRouterSnapshot() store._setSnapshotForTesting(snapshot, provider: .openrouter) @@ -31,6 +24,129 @@ struct StatusItemBalanceDisplayTests { #expect(displayText == "$12.34") } + @Test + func `reset time mode preserves automatic open router balance`() { + let settings = self.makeSettings( + suiteName: "StatusItemBalanceDisplayTests-openrouter-reset-time", + provider: .openrouter) + settings.menuBarDisplayMode = .resetTime + settings.setMenuBarMetricPreference(.automatic, for: .openrouter) + let (store, controller) = self.makeStoreAndController(settings: settings) + defer { controller.releaseStatusItemsForTesting() } + let snapshot = Self.openRouterSnapshot() + + store._setSnapshotForTesting(snapshot, provider: .openrouter) + store._setErrorForTesting(nil, provider: .openrouter) + + let displayText = controller.menuBarDisplayText(for: .openrouter, snapshot: snapshot) + + #expect(displayText == "$12.34") + } + + @Test + func `menu bar display text uses zen balance when open code has no subscription`() { + let settings = self.makeSettings( + suiteName: "StatusItemBalanceDisplayTests-opencodego-zen-only", + provider: .opencodego) + let (store, controller) = self.makeStoreAndController(settings: settings) + defer { controller.releaseStatusItemsForTesting() } + let snapshot = UsageSnapshot( + primary: nil, + secondary: nil, + providerCost: ProviderCostSnapshot( + used: 23.75, + limit: 0, + currencyCode: "USD", + period: "Zen balance", + updatedAt: Date()), + updatedAt: Date()) + + store._setSnapshotForTesting(snapshot, provider: .opencodego) + store._setErrorForTesting(nil, provider: .opencodego) + + let displayText = controller.menuBarDisplayText(for: .opencodego, snapshot: snapshot) + + #expect(displayText == "$23.75") + } + + @Test + func `menu bar display text uses negative zen balance when open code is in deficit`() { + let settings = self.makeSettings( + suiteName: "StatusItemBalanceDisplayTests-opencodego-zen-deficit", + provider: .opencodego) + let (store, controller) = self.makeStoreAndController(settings: settings) + defer { controller.releaseStatusItemsForTesting() } + let snapshot = UsageSnapshot( + primary: nil, + secondary: nil, + providerCost: ProviderCostSnapshot( + used: -4.25, + limit: 0, + currencyCode: "USD", + period: "Zen balance", + updatedAt: Date()), + updatedAt: Date()) + + store._setSnapshotForTesting(snapshot, provider: .opencodego) + store._setErrorForTesting(nil, provider: .opencodego) + + let displayText = controller.menuBarDisplayText(for: .opencodego, snapshot: snapshot) + + #expect(displayText == "-$4.25") + } + + @Test + func `menu bar display text keeps open code subscription percentage`() { + let settings = self.makeSettings( + suiteName: "StatusItemBalanceDisplayTests-opencodego-subscription", + provider: .opencodego) + let (store, controller) = self.makeStoreAndController(settings: settings) + defer { controller.releaseStatusItemsForTesting() } + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 12, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + secondary: RateWindow(usedPercent: 34, windowMinutes: 10080, resetsAt: nil, resetDescription: nil), + providerCost: ProviderCostSnapshot( + used: 23.75, + limit: 0, + currencyCode: "USD", + period: "Zen balance", + updatedAt: Date()), + updatedAt: Date()) + + store._setSnapshotForTesting(snapshot, provider: .opencodego) + store._setErrorForTesting(nil, provider: .opencodego) + + let displayText = controller.menuBarDisplayText(for: .opencodego, snapshot: snapshot) + + #expect(displayText == "12%") + } + + @Test + func `reset time mode preserves balance when provider has no quota window`() { + let settings = self.makeSettings( + suiteName: "StatusItemBalanceDisplayTests-moonshot-reset-time", + provider: .moonshot) + settings.menuBarDisplayMode = .resetTime + let (store, controller) = self.makeStoreAndController(settings: settings) + defer { controller.releaseStatusItemsForTesting() } + let snapshot = UsageSnapshot( + primary: nil, + secondary: nil, + updatedAt: Date(), + identity: ProviderIdentitySnapshot( + providerID: .moonshot, + accountEmail: nil, + accountOrganization: nil, + loginMethod: "Balance: $49.58 · $0.42 in deficit")) + + store._setSnapshotForTesting(snapshot, provider: .moonshot) + store._setErrorForTesting(nil, provider: .moonshot) + + let displayText = controller.menuBarDisplayText(for: .moonshot, snapshot: snapshot) + + #expect(displayText == "$49.58") + } + @Test func `menu bar display text respects open router primary metric preference`() { let settings = self.makeSettings( @@ -38,6 +154,7 @@ struct StatusItemBalanceDisplayTests { provider: .openrouter) settings.setMenuBarMetricPreference(.primary, for: .openrouter) let (store, controller) = self.makeStoreAndController(settings: settings) + defer { controller.releaseStatusItemsForTesting() } let snapshot = Self.openRouterSnapshot() store._setSnapshotForTesting(snapshot, provider: .openrouter) @@ -48,12 +165,40 @@ struct StatusItemBalanceDisplayTests { #expect(displayText == "25%") } + @Test + func `menu bar display text skips exhausted cursor api subquota when total remains usable`() { + let settings = self.makeSettings( + suiteName: "StatusItemBalanceDisplayTests-cursor-exhausted-api", + provider: .cursor) + settings.usageBarsShowUsed = false + settings.setMenuBarMetricPreference(.automatic, for: .cursor) + let (store, controller) = self.makeStoreAndController(settings: settings) + defer { controller.releaseStatusItemsForTesting() } + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 67, windowMinutes: 30 * 24 * 60, resetsAt: nil, resetDescription: "Total"), + secondary: RateWindow( + usedPercent: 34, + windowMinutes: 30 * 24 * 60, + resetsAt: nil, + resetDescription: "Auto"), + tertiary: RateWindow(usedPercent: 100, windowMinutes: 30 * 24 * 60, resetsAt: nil, resetDescription: "API"), + updatedAt: Date()) + + store._setSnapshotForTesting(snapshot, provider: .cursor) + store._setErrorForTesting(nil, provider: .cursor) + + let displayText = controller.menuBarDisplayText(for: .cursor, snapshot: snapshot) + + #expect(displayText == "33%") + } + @Test func `menu bar display text uses deepseek balance`() { let settings = self.makeSettings( suiteName: "StatusItemBalanceDisplayTests-deepseek-balance", provider: .deepseek) let (store, controller) = self.makeStoreAndController(settings: settings) + defer { controller.releaseStatusItemsForTesting() } let snapshot = UsageSnapshot( primary: RateWindow( usedPercent: 0, @@ -71,12 +216,160 @@ struct StatusItemBalanceDisplayTests { #expect(displayText == "$9.32") } + @Test + func `menu bar display text uses DeepInfra available balance`() { + let settings = self.makeSettings( + suiteName: "StatusItemBalanceDisplayTests-deepinfra-balance", + provider: .deepinfra) + let (store, controller) = self.makeStoreAndController(settings: settings) + defer { controller.releaseStatusItemsForTesting() } + let snapshot = DeepInfraUsageSnapshot( + availableBalanceUSD: 12.34, + amountOwedUSD: 0, + currentMonthCostUSD: 1.25, + recentCostUSD: 1.25, + spendingLimitUSD: nil, + suspended: false, + suspendReason: nil, + updatedAt: Date()) + .toUsageSnapshot() + + store._setSnapshotForTesting(snapshot, provider: .deepinfra) + store._setErrorForTesting(nil, provider: .deepinfra) + + #expect(controller.menuBarDisplayText(for: .deepinfra, snapshot: snapshot) == "$12.34") + } + + @Test + func `DeepInfra card shows balance text without an inferred percentage bar`() throws { + let now = Date() + let snapshot = DeepInfraUsageSnapshot( + availableBalanceUSD: 95.81, + amountOwedUSD: 0, + currentMonthCostUSD: 3.94, + recentCostUSD: 3.94, + spendingLimitUSD: nil, + suspended: false, + suspendReason: nil, + updatedAt: now) + .toUsageSnapshot() + let metadata = try #require(ProviderDefaults.metadata[.deepinfra]) + + let model = UsageMenuCardView.Model.make(.init( + provider: .deepinfra, + metadata: metadata, + snapshot: snapshot, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + + let balance = try #require(model.metrics.first) + #expect(balance.title == "Balance") + #expect(balance.statusText == "$95.81 available · $3.94 spent this month") + #expect(balance.detailText == nil) + #expect(balance.resetText == nil) + } + + @Test + func `menu bar display text marks DeepInfra amount owed`() { + let snapshot = DeepInfraUsageSnapshot( + availableBalanceUSD: 0, + amountOwedUSD: 2.75, + currentMonthCostUSD: 3, + recentCostUSD: 3, + spendingLimitUSD: nil, + suspended: false, + suspendReason: nil, + updatedAt: Date()) + .toUsageSnapshot() + + #expect(StatusItemController.deepInfraBalanceDisplayText(snapshot: snapshot) == "-$2.75") + } + + @Test + func `menu bar display text keeps DeepInfra balance when suspended`() { + let snapshot = DeepInfraUsageSnapshot( + availableBalanceUSD: 4, + amountOwedUSD: 0, + currentMonthCostUSD: 3, + recentCostUSD: 3, + spendingLimitUSD: nil, + suspended: true, + suspendReason: "Payment review", + updatedAt: Date()) + .toUsageSnapshot() + + #expect(StatusItemController.deepInfraBalanceDisplayText(snapshot: snapshot) == "$4.00") + } + + @Test + func `menu bar display text uses mimo balance without token plan`() { + let settings = self.makeSettings( + suiteName: "StatusItemBalanceDisplayTests-mimo-balance", + provider: .mimo) + let (store, controller) = self.makeStoreAndController(settings: settings) + defer { controller.releaseStatusItemsForTesting() } + let snapshot = MiMoUsageSnapshot( + balance: 25.51, + currency: "USD", + cashBalance: 20, + giftBalance: 5.51, + updatedAt: Date()) + .toUsageSnapshot() + + store._setSnapshotForTesting(snapshot, provider: .mimo) + store._setErrorForTesting(nil, provider: .mimo) + + let displayText = controller.menuBarDisplayText(for: .mimo, snapshot: snapshot) + + #expect(displayText == "$25.51") + } + + @Test + func `menu bar display text uses selected mimo balance with token plan`() { + let settings = self.makeSettings( + suiteName: "StatusItemBalanceDisplayTests-mimo-token-plan", + provider: .mimo) + settings.setMenuBarMetricPreference(.secondary, for: .mimo) + let (store, controller) = self.makeStoreAndController(settings: settings) + defer { controller.releaseStatusItemsForTesting() } + let snapshot = MiMoUsageSnapshot( + balance: 25.51, + currency: "USD", + planCode: "standard", + tokenUsed: 10, + tokenLimit: 100, + tokenPercent: 0.1, + updatedAt: Date()) + .toUsageSnapshot() + + store._setSnapshotForTesting(snapshot, provider: .mimo) + store._setErrorForTesting(nil, provider: .mimo) + + let displayText = controller.menuBarDisplayText(for: .mimo, snapshot: snapshot) + + #expect(displayText == "$25.51") + } + @Test func `menu bar display text uses moonshot balance`() { let settings = self.makeSettings( suiteName: "StatusItemBalanceDisplayTests-moonshot-balance", provider: .moonshot) let (store, controller) = self.makeStoreAndController(settings: settings) + defer { controller.releaseStatusItemsForTesting() } let snapshot = UsageSnapshot( primary: nil, secondary: nil, @@ -102,6 +395,7 @@ struct StatusItemBalanceDisplayTests { suiteName: "StatusItemBalanceDisplayTests-mistral-spend", provider: .mistral) let (store, controller) = self.makeStoreAndController(settings: settings) + defer { controller.releaseStatusItemsForTesting() } let snapshot = MistralUsageSnapshot( totalCost: 1.2345, currency: "EUR", @@ -125,25 +419,73 @@ struct StatusItemBalanceDisplayTests { } @Test - func `menu bar display text uses kimi k2 api key credits`() { + func `menu bar display text uses mistral monthly plan when selected`() { let settings = self.makeSettings( - suiteName: "StatusItemBalanceDisplayTests-kimik2-credits", - provider: .kimik2) + suiteName: "StatusItemBalanceDisplayTests-mistral-monthly-plan", + provider: .mistral) + settings.setMenuBarMetricPreference(.monthlyPlan, for: .mistral) let (store, controller) = self.makeStoreAndController(settings: settings) - let snapshot = KimiK2UsageSummary( - consumed: 75, - remaining: 1234.5, - averageTokens: nil, - updatedAt: Date()).toUsageSnapshot() + defer { controller.releaseStatusItemsForTesting() } + let snapshot = MistralUsageSnapshot( + totalCost: 1.2345, + currency: "EUR", + currencySymbol: "€", + totalInputTokens: 10000, + totalOutputTokens: 5000, + totalCachedTokens: 0, + modelCount: 2, + startDate: nil, + endDate: nil, + updatedAt: Date()) + .toUsageSnapshot() + .with(extraRateWindows: [ + NamedRateWindow( + id: "mistral-monthly-plan", + title: "Monthly Plan", + window: RateWindow( + usedPercent: 42, + windowMinutes: nil, + resetsAt: nil, + resetDescription: nil)), + ]) - store._setSnapshotForTesting(snapshot, provider: .kimik2) - store._setErrorForTesting(nil, provider: .kimik2) + store._setSnapshotForTesting(snapshot, provider: .mistral) + store._setErrorForTesting(nil, provider: .mistral) - let displayText = controller.menuBarDisplayText(for: .kimik2, snapshot: snapshot) + let displayText = controller.menuBarDisplayText(for: .mistral, snapshot: snapshot) - #expect(snapshot.primary == nil) - #expect(snapshot.identity?.loginMethod == "Credits: 1234.5 left") - #expect(displayText == "1234.5") + #expect(snapshot.identity?.loginMethod == "API spend: €1.2345 this month") + #expect(displayText == "42%") + } + + @Test + func `menu bar display text falls back to mistral spend when monthly plan is missing`() { + let settings = self.makeSettings( + suiteName: "StatusItemBalanceDisplayTests-mistral-monthly-plan-missing", + provider: .mistral) + settings.setMenuBarMetricPreference(.monthlyPlan, for: .mistral) + let (store, controller) = self.makeStoreAndController(settings: settings) + defer { controller.releaseStatusItemsForTesting() } + let snapshot = MistralUsageSnapshot( + totalCost: 1.2345, + currency: "EUR", + currencySymbol: "€", + totalInputTokens: 10000, + totalOutputTokens: 5000, + totalCachedTokens: 0, + modelCount: 2, + startDate: nil, + endDate: nil, + updatedAt: Date()) + .toUsageSnapshot() + + store._setSnapshotForTesting(snapshot, provider: .mistral) + store._setErrorForTesting(nil, provider: .mistral) + + let displayText = controller.menuBarDisplayText(for: .mistral, snapshot: snapshot) + + #expect(snapshot.identity?.loginMethod == "API spend: €1.2345 this month") + #expect(displayText == "€1.2345") } @Test @@ -153,6 +495,7 @@ struct StatusItemBalanceDisplayTests { provider: .kiro) settings.kiroMenuBarDisplayMode = .automatic let (store, controller) = self.makeStoreAndController(settings: settings) + defer { controller.releaseStatusItemsForTesting() } let snapshot = Self.kiroSnapshot() store._setSnapshotForTesting(snapshot, provider: .kiro) @@ -170,6 +513,7 @@ struct StatusItemBalanceDisplayTests { provider: .kiro) settings.kiroMenuBarDisplayMode = .creditsAndPercent let (store, controller) = self.makeStoreAndController(settings: settings) + defer { controller.releaseStatusItemsForTesting() } let snapshot = Self.kiroSnapshot() store._setSnapshotForTesting(snapshot, provider: .kiro) @@ -187,6 +531,7 @@ struct StatusItemBalanceDisplayTests { provider: .kiro) settings.kiroMenuBarDisplayMode = .hidden let (store, controller) = self.makeStoreAndController(settings: settings) + defer { controller.releaseStatusItemsForTesting() } let snapshot = Self.kiroSnapshot() store._setSnapshotForTesting(snapshot, provider: .kiro) @@ -204,6 +549,7 @@ struct StatusItemBalanceDisplayTests { provider: .kiro) settings.kiroMenuBarDisplayMode = .usedAndTotal let (store, controller) = self.makeStoreAndController(settings: settings) + defer { controller.releaseStatusItemsForTesting() } let snapshot = Self.kiroSnapshot() store._setSnapshotForTesting(snapshot, provider: .kiro) @@ -221,6 +567,7 @@ struct StatusItemBalanceDisplayTests { provider: .kiro) settings.kiroMenuBarDisplayMode = .overageCreditsWhenExhausted let (store, controller) = self.makeStoreAndController(settings: settings) + defer { controller.releaseStatusItemsForTesting() } let snapshot = Self.exhaustedKiroSnapshot() store._setSnapshotForTesting(snapshot, provider: .kiro) @@ -238,6 +585,7 @@ struct StatusItemBalanceDisplayTests { provider: .kiro) settings.kiroMenuBarDisplayMode = .overageCostWhenExhausted let (store, controller) = self.makeStoreAndController(settings: settings) + defer { controller.releaseStatusItemsForTesting() } let snapshot = Self.exhaustedKiroSnapshot() store._setSnapshotForTesting(snapshot, provider: .kiro) @@ -255,6 +603,7 @@ struct StatusItemBalanceDisplayTests { provider: .kiro) settings.kiroMenuBarDisplayMode = .overageCreditsAndCostWhenExhausted let (store, controller) = self.makeStoreAndController(settings: settings) + defer { controller.releaseStatusItemsForTesting() } let snapshot = Self.exhaustedKiroSnapshot() store._setSnapshotForTesting(snapshot, provider: .kiro) @@ -272,6 +621,7 @@ struct StatusItemBalanceDisplayTests { provider: .kiro) settings.kiroMenuBarDisplayMode = .overageCreditsAndCostWhenExhausted let (store, controller) = self.makeStoreAndController(settings: settings) + defer { controller.releaseStatusItemsForTesting() } let snapshot = Self.kiroSnapshot() store._setSnapshotForTesting(snapshot, provider: .kiro) @@ -289,6 +639,7 @@ struct StatusItemBalanceDisplayTests { provider: .kiro) settings.kiroMenuBarDisplayMode = .overageCreditsAndCostWhenExhausted let (store, controller) = self.makeStoreAndController(settings: settings) + defer { controller.releaseStatusItemsForTesting() } let snapshot = Self.exhaustedKiroSnapshot(overagesStatus: "Disabled") store._setSnapshotForTesting(snapshot, provider: .kiro) @@ -307,6 +658,7 @@ struct StatusItemBalanceDisplayTests { settings.kiroMenuBarDisplayMode = .automatic settings.usageBarsShowUsed = false let (store, controller) = self.makeStoreAndController(settings: settings) + defer { controller.releaseStatusItemsForTesting() } let snapshot = KiroUsageSnapshot( planName: "Q Developer Pro", creditsUsed: 0, @@ -327,7 +679,7 @@ struct StatusItemBalanceDisplayTests { } @Test - func `mistral primary window is nil even when billing end date is set`() { + func `mistral primary window is nil without credits even when billing end date is set`() { let endDate = Date(timeIntervalSinceNow: 3600) let snapshot = MistralUsageSnapshot( totalCost: 0.5, @@ -341,7 +693,7 @@ struct StatusItemBalanceDisplayTests { endDate: endDate, updatedAt: Date()).toUsageSnapshot() - // Mistral doesn't expose a reset time — primary is always nil. + // Billing end date alone is not a quota window; credits are what populate primary. #expect(snapshot.primary == nil) } @@ -353,11 +705,40 @@ struct StatusItemBalanceDisplayTests { #expect(StatusItemController.buttonTitle("", hasImage: true).isEmpty) } + @Test + func `debug button title stays visible with or without a usage value`() { + #expect(StatusItemController.buttonTitle(nil, hasImage: true, isDebugApp: true) == " D") + #expect(StatusItemController.buttonTitle("42%", hasImage: true, isDebugApp: true) == " 42% D") + #expect(StatusItemController.buttonTitle("42%", hasImage: false, isDebugApp: true) == "42% D") + } + + @Test + func `high contrast button title embeds image and metric in attributed content`() throws { + let image = NSImage(size: NSSize(width: 18, height: 18)) + image.isTemplate = true + + let title = StatusItemController.highContrastButtonTitle(image: image, title: " 42%") + + #expect(title.string == "\u{FFFC} 42%") + let attachment = try #require(title.attribute(.attachment, at: 0, effectiveRange: nil) as? NSTextAttachment) + #expect(attachment.image === image) + #expect(attachment.bounds.width == 18) + #expect(attachment.bounds.height == 18) + #expect(title.attribute(.font, at: 1, effectiveRange: nil) is NSFont) + #expect(title.attribute(.foregroundColor, at: 1, effectiveRange: nil) as? NSColor == .labelColor) + } + + @Test + func `debug bundle identity updates status item accessibility`() { + #expect(StatusItemController.isDebugApp(bundleIdentifier: "com.steipete.codexbar.debug")) + #expect(!StatusItemController.isDebugApp(bundleIdentifier: "com.steipete.codexbar")) + #expect(!StatusItemController.isDebugApp(bundleIdentifier: nil)) + #expect(StatusItemController.statusItemAccessibilityTitle(isDebugApp: true) == "CodexBar Debug") + #expect(StatusItemController.statusItemAccessibilityTitle(isDebugApp: false) == "CodexBar") + } + private func makeSettings(suiteName: String, provider: UsageProvider) -> SettingsStore { - let settings = SettingsStore( - configStore: testConfigStore(suiteName: suiteName), - zaiTokenStore: NoopZaiTokenStore(), - syntheticTokenStore: NoopSyntheticTokenStore()) + let settings = testSettingsStore(suiteName: suiteName) settings.statusChecksEnabled = false settings.refreshFrequency = .manual settings.mergeIcons = true @@ -381,7 +762,7 @@ struct StatusItemBalanceDisplayTests { account: fetcher.loadAccountInfo(), updater: DisabledUpdaterController(), preferencesSelection: PreferencesSelection(), - statusBar: self.makeStatusBarForTesting()) + statusBar: testStatusBar()) return (store, controller) } diff --git a/Tests/CodexBarTests/StatusItemCombinedMetricPlaceholderTests.swift b/Tests/CodexBarTests/StatusItemCombinedMetricPlaceholderTests.swift new file mode 100644 index 0000000000..ec37f8c130 --- /dev/null +++ b/Tests/CodexBarTests/StatusItemCombinedMetricPlaceholderTests.swift @@ -0,0 +1,184 @@ +import AppKit +import CodexBarCore +import Testing +@testable import CodexBar + +/// Regression coverage for the combined "Session + Weekly" menu-bar metric ignoring Claude web's +/// synthetic `five_hour: null` session placeholder. Claude web parses an account with no live session +/// (but a real `seven_day` lane) into a 0% 5-hour `primary` with no reset signal; the combined metric +/// must drop that placeholder so the readout falls back to the weekly lane instead of rendering a +/// non-existent `5h 0%`/`5h 100%` session. A genuine, freshly reset session (which still carries a +/// `resetsAt`) must survive the filter. +@MainActor +@Suite(.serialized) +struct StatusItemCombinedMetricPlaceholderTests { + private func makeStatusBarForTesting() -> NSStatusBar { + // Use the real system status bar in tests. Standalone NSStatusBar instances have caused + // AppKit teardown crashes under swiftpm-testing-helper. + .system + } + + /// Builds a Claude-only controller with the combined Session + Weekly metric selected. + private func makeClaudeCombinedController( + suiteName: String, + displayMode: MenuBarDisplayMode, + showUsed: Bool) -> (controller: StatusItemController, store: UsageStore) + { + let settings = SettingsStore( + configStore: testConfigStore(suiteName: suiteName), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.selectedMenuProvider = .claude + settings.menuBarDisplayMode = displayMode + settings.usageBarsShowUsed = showUsed + settings.setMenuBarMetricPreference(.primaryAndSecondary, for: .claude) + + let registry = ProviderRegistry.shared + if let claudeMeta = registry.metadata[.claude] { + settings.setProviderEnabled(provider: .claude, metadata: claudeMeta, 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()) + return (controller, store) + } + + @Test + func `combined metric ignores the claude web null-session placeholder in percent mode`() { + let (controller, store) = self.makeClaudeCombinedController( + suiteName: "StatusItemCombinedMetricPlaceholderTests-percent", + displayMode: .percent, + showUsed: true) + defer { controller.releaseStatusItemsForTesting() } + + let now = Date() + // `primary` is the synthetic placeholder Claude web emits for `five_hour: null`: a 0% 5h window + // flagged `isSyntheticPlaceholder`. `secondary` is the real weekly lane. + let snapshot = UsageSnapshot( + primary: RateWindow( + usedPercent: 0, + windowMinutes: 300, + resetsAt: nil, + resetDescription: nil, + isSyntheticPlaceholder: true), + secondary: RateWindow( + usedPercent: 42, + windowMinutes: 7 * 24 * 60, + resetsAt: now.addingTimeInterval(24 * 60 * 60), + resetDescription: nil), + updatedAt: now) + store._setSnapshotForTesting(snapshot, provider: .claude) + store._setErrorForTesting(nil, provider: .claude) + + let displayText = controller.menuBarDisplayText(for: .claude, snapshot: snapshot) + + // Weekly lane only — the placeholder session is dropped (no "5h" component). + #expect(displayText == "W 42%") + #expect(displayText?.contains("5h") == false) + } + + @Test + func `combined metric ignores the claude web null-session placeholder in both mode`() { + let (controller, store) = self.makeClaudeCombinedController( + suiteName: "StatusItemCombinedMetricPlaceholderTests-both", + displayMode: .both, + showUsed: false) + defer { controller.releaseStatusItemsForTesting() } + + let now = Date() + let snapshot = UsageSnapshot( + primary: RateWindow( + usedPercent: 0, + windowMinutes: 300, + resetsAt: nil, + resetDescription: nil, + isSyntheticPlaceholder: true), + secondary: RateWindow( + usedPercent: 42, + windowMinutes: 7 * 24 * 60, + resetsAt: now.addingTimeInterval(60 * 60), + resetDescription: nil), + updatedAt: now) + store._setSnapshotForTesting(snapshot, provider: .claude) + store._setErrorForTesting(nil, provider: .claude) + + let displayText = controller.menuBarDisplayText(for: .claude, snapshot: snapshot) + + // Percent comes from the weekly lane (58% remaining), not the placeholder session's 100%. + #expect(displayText?.hasPrefix("58%") == true) + #expect(displayText?.hasPrefix("100%") == false) + // The placeholder session lane never surfaces, so no "5h" label appears. + #expect(displayText?.contains("5h") == false) + } + + @Test + func `combined metric keeps a real freshly reset claude session lane`() { + let (controller, store) = self.makeClaudeCombinedController( + suiteName: "StatusItemCombinedMetricPlaceholderTests-fresh", + displayMode: .percent, + showUsed: true) + defer { controller.releaseStatusItemsForTesting() } + + let now = Date() + // A real, freshly reset session: 0% used but with a concrete reset time — unlike the placeholder. + let snapshot = UsageSnapshot( + primary: RateWindow( + usedPercent: 0, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(4 * 60 * 60), + resetDescription: nil), + secondary: RateWindow( + usedPercent: 42, + windowMinutes: 7 * 24 * 60, + resetsAt: now.addingTimeInterval(24 * 60 * 60), + resetDescription: nil), + updatedAt: now) + store._setSnapshotForTesting(snapshot, provider: .claude) + store._setErrorForTesting(nil, provider: .claude) + + let displayText = controller.menuBarDisplayText(for: .claude, snapshot: snapshot) + + // The real session lane survives the filter, so both lanes render. + #expect(displayText == "5h 0% · W 42%") + } + + @Test + func `combined metric keeps an unflagged zero-usage session sharing the placeholder shape`() { + // Precision guard: a real empty session can share the placeholder's exact RateWindow shape + // (0% used, 5h cadence, no reset) — e.g. the Claude CLI scrape, where session reset text can be + // absent. Because the drop keys on the explicit `isSyntheticPlaceholder` marker (set only at the + // Claude web boundary) rather than the shape, this unflagged session must be kept, not dropped. + let (controller, store) = self.makeClaudeCombinedController( + suiteName: "StatusItemCombinedMetricPlaceholderTests-unflagged-shape", + displayMode: .percent, + showUsed: true) + defer { controller.releaseStatusItemsForTesting() } + + let now = Date() + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 0, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + secondary: RateWindow( + usedPercent: 42, + windowMinutes: 7 * 24 * 60, + resetsAt: now.addingTimeInterval(24 * 60 * 60), + resetDescription: nil), + updatedAt: now) + store._setSnapshotForTesting(snapshot, provider: .claude) + store._setErrorForTesting(nil, provider: .claude) + + let displayText = controller.menuBarDisplayText(for: .claude, snapshot: snapshot) + + // Unflagged session is real, so it renders despite matching the placeholder shape. + #expect(displayText == "5h 0% · W 42%") + } +} diff --git a/Tests/CodexBarTests/StatusItemControllerMenuTests.swift b/Tests/CodexBarTests/StatusItemControllerMenuTests.swift index 9e3f2d3de8..f11d7fbfc0 100644 --- a/Tests/CodexBarTests/StatusItemControllerMenuTests.swift +++ b/Tests/CodexBarTests/StatusItemControllerMenuTests.swift @@ -29,6 +29,7 @@ struct StatusItemControllerMenuTests { primary: RateWindow?, secondary: RateWindow?, tertiary: RateWindow? = nil, + extraRateWindows: [NamedRateWindow]? = nil, providerCost: ProviderCostSnapshot? = nil) -> UsageSnapshot { @@ -36,10 +37,169 @@ struct StatusItemControllerMenuTests { primary: primary, secondary: secondary, tertiary: tertiary, + extraRateWindows: extraRateWindows, providerCost: providerCost, updatedAt: Date()) } + @Test + func `switcher prefers weekly allowance over primary session allowance`() { + let session = RateWindow( + usedPercent: 20, + windowMinutes: 5 * 60, + resetsAt: nil, + resetDescription: nil) + let weekly = RateWindow( + usedPercent: 65, + windowMinutes: 7 * 24 * 60, + resetsAt: nil, + resetDescription: nil) + let snapshot = self.makeSnapshot(primary: session, secondary: weekly) + + let percent = StatusItemController.switcherWeeklyMetricPercent( + for: .claude, + snapshot: snapshot, + showUsed: false) + + #expect(percent == 35) + } + + @Test + func `switcher uses most constrained named weekly allowance`() { + let session = RateWindow( + usedPercent: 10, + windowMinutes: 5 * 60, + resetsAt: nil, + resetDescription: nil) + let snapshot = self.makeSnapshot( + primary: session, + secondary: nil, + extraRateWindows: [ + NamedRateWindow( + id: "antigravity-quota-summary-gemini-weekly", + title: "Gemini weekly", + window: RateWindow( + usedPercent: 40, + windowMinutes: 7 * 24 * 60, + resetsAt: nil, + resetDescription: nil)), + NamedRateWindow( + id: "antigravity-quota-summary-claude-weekly", + title: "Claude/GPT weekly", + window: RateWindow( + usedPercent: 75, + windowMinutes: 7 * 24 * 60, + resetsAt: nil, + resetDescription: nil)), + ]) + + let percent = StatusItemController.switcherWeeklyMetricPercent( + for: .antigravity, + snapshot: snapshot, + showUsed: false) + + #expect(percent == 25) + } + + @Test + func `claude switcher ignores exhausted scoped weekly carve outs`() { + let session = RateWindow( + usedPercent: 77, + windowMinutes: 5 * 60, + resetsAt: nil, + resetDescription: nil) + let weekly = RateWindow( + usedPercent: 61, + windowMinutes: 7 * 24 * 60, + resetsAt: nil, + resetDescription: nil) + let sonnet = RateWindow( + usedPercent: 100, + windowMinutes: 7 * 24 * 60, + resetsAt: nil, + resetDescription: nil) + let snapshot = self.makeSnapshot( + primary: session, + secondary: weekly, + tertiary: sonnet, + extraRateWindows: [ + NamedRateWindow( + id: "claude-weekly-scoped-fable", + title: "Fable only", + window: RateWindow( + usedPercent: 100, + windowMinutes: 7 * 24 * 60, + resetsAt: nil, + resetDescription: nil)), + NamedRateWindow( + id: "claude-routines", + title: "Daily Routines", + window: RateWindow( + usedPercent: 100, + windowMinutes: 7 * 24 * 60, + resetsAt: nil, + resetDescription: nil)), + ]) + + let percent = StatusItemController.switcherWeeklyMetricPercent( + for: .claude, + snapshot: snapshot, + showUsed: false) + + #expect(percent == 39) + } + + @Test + func `claude switcher keeps account weekly even when scoped carve out remains`() { + let session = RateWindow( + usedPercent: 20, + windowMinutes: 5 * 60, + resetsAt: nil, + resetDescription: nil) + let weekly = RateWindow( + usedPercent: 40, + windowMinutes: 7 * 24 * 60, + resetsAt: nil, + resetDescription: nil) + let snapshot = self.makeSnapshot( + primary: session, + secondary: weekly, + extraRateWindows: [ + NamedRateWindow( + id: "claude-weekly-scoped-fable", + title: "Fable only", + window: RateWindow( + usedPercent: 85, + windowMinutes: 7 * 24 * 60, + resetsAt: nil, + resetDescription: nil)), + ]) + + let percent = StatusItemController.switcherWeeklyMetricPercent( + for: .claude, + snapshot: snapshot, + showUsed: false) + + #expect(percent == 60) + } + + @Test + func `switcher preserves provider quota when no weekly allowance exists`() { + let monthly = RateWindow( + usedPercent: 28, + windowMinutes: nil, + resetsAt: nil, + resetDescription: nil) + let snapshot = self.makeSnapshot(primary: monthly, secondary: nil) + + let percent = StatusItemController.switcherWeeklyMetricPercent( + for: .copilot, + snapshot: snapshot, + showUsed: false) + + #expect(percent == 72) + } + @Test func `cursor switcher falls back to on demand budget when plan exhausted and showing remaining`() { let primary = RateWindow(usedPercent: 100, windowMinutes: nil, resetsAt: nil, resetDescription: nil) @@ -116,6 +276,48 @@ struct StatusItemControllerMenuTests { #expect(percent == 76) } + @Test + func `mistral switcher uses monthly plan metric when selected`() { + let snapshot = UsageSnapshot( + primary: nil, + secondary: nil, + extraRateWindows: [ + NamedRateWindow( + id: "mistral-monthly-plan", + title: "Monthly Plan", + window: RateWindow(usedPercent: 42, windowMinutes: nil, resetsAt: nil, resetDescription: nil)), + ], + updatedAt: Date()) + + let percent = StatusItemController.switcherWeeklyMetricPercent( + for: .mistral, + snapshot: snapshot, + showUsed: true, + preference: .monthlyPlan) + + #expect(percent == 42) + } + + @Test + func `mistral switcher ignores pay as you go balance primary`() { + let snapshot = UsageSnapshot( + primary: RateWindow( + usedPercent: 0, + windowMinutes: nil, + resetsAt: nil, + resetDescription: "$12.50"), + secondary: nil, + updatedAt: Date()) + + let percent = StatusItemController.switcherWeeklyMetricPercent( + for: .mistral, + snapshot: snapshot, + showUsed: true, + preference: .automatic) + + #expect(percent == nil) + } + @Test @MainActor func `menu card width stays at base width when menu accessories are present`() { @@ -131,6 +333,102 @@ struct StatusItemControllerMenuTests { #expect(ceil(submenuMenu.size.width) < 310) } + // MARK: - Status component allowlist + + @Test + @MainActor + func `status component allowlist passes through unfiltered for providers without one`() { + let components = [ + ProviderStatusComponent(id: "1", name: "API", indicator: .none, status: "operational"), + ProviderStatusComponent(id: "2", name: "Web App", indicator: .none, status: "operational"), + ] + + let filtered = StatusItemController.filterStatusComponents(components, for: .claude) + + #expect(filtered.map(\.name) == ["API", "Web App"]) + } + + @Test + @MainActor + func `status component allowlist keeps only named components and groups for zoommate`() { + let components = [ + ProviderStatusComponent( + id: "g1", + name: "Zoom Meetings", + indicator: .none, + status: "operational", + children: [ + ProviderStatusComponent( + id: "c1", + name: "Zoom Whiteboard", + indicator: .none, + status: "operational"), + ]), + ProviderStatusComponent(id: "2", name: "ZoomMate", indicator: .none, status: "operational"), + ProviderStatusComponent(id: "3", name: "My Notes", indicator: .none, status: "operational"), + ProviderStatusComponent( + id: "g2", + name: "Zoom Workflows", + indicator: .none, + status: "operational", + children: [ + ProviderStatusComponent( + id: "c2", + name: "Zoom AIC", + indicator: .none, + status: "operational"), + ]), + ProviderStatusComponent(id: "4", name: "Zoom Phone - US", indicator: .none, status: "operational"), + ProviderStatusComponent(id: "5", name: "Zoom Rooms", indicator: .none, status: "operational"), + ] + + let filtered = StatusItemController.filterStatusComponents(components, for: .zoommate) + + #expect(filtered.map(\.name) == ["Zoom Meetings", "ZoomMate", "My Notes", "Zoom Workflows"]) + // Allowlisted groups keep their existing full child list; the allowlist filters at the + // top level only, it does not additionally prune group children. + #expect(filtered.first { $0.name == "Zoom Meetings" }?.children.map(\.name) == ["Zoom Whiteboard"]) + } + + @Test + @MainActor + func `status component allowlist tolerates any subset of named components being absent`() { + // Only two of the four allowlisted names are present; the other two are silently omitted + // without error, and unrelated components remain excluded as usual. + let components = [ + ProviderStatusComponent(id: "2", name: "ZoomMate", indicator: .none, status: "operational"), + ProviderStatusComponent(id: "4", name: "Zoom Phone - US", indicator: .none, status: "operational"), + ProviderStatusComponent(id: "3", name: "My Notes", indicator: .minor, status: "degraded_performance"), + ] + + let filtered = StatusItemController.filterStatusComponents(components, for: .zoommate) + + #expect(filtered.map(\.name) == ["ZoomMate", "My Notes"]) + } + + @Test + @MainActor + func `status component allowlist returns empty list when all named components are absent`() { + // Zoom's status page has been fully restructured and none of the four allowlisted names + // remain; the filter must degrade to an empty list rather than crash, so the existing + // "no components" gate (which shows only the website link) takes over. + let components = [ + ProviderStatusComponent(id: "4", name: "Zoom Phone - US", indicator: .none, status: "operational"), + ProviderStatusComponent(id: "5", name: "Zoom Rooms", indicator: .none, status: "operational"), + ] + + let filtered = StatusItemController.filterStatusComponents(components, for: .zoommate) + + #expect(filtered.isEmpty) + } + + @Test + @MainActor + func `status component allowlist on an empty component list stays empty`() { + let filtered = StatusItemController.filterStatusComponents([], for: .zoommate) + #expect(filtered.isEmpty) + } + @Test @MainActor func `update menu action installs prepared update instead of checking again`() throws { diff --git a/Tests/CodexBarTests/StatusItemControllerShutdownTests.swift b/Tests/CodexBarTests/StatusItemControllerShutdownTests.swift index 73a427366b..7f4745ef81 100644 --- a/Tests/CodexBarTests/StatusItemControllerShutdownTests.swift +++ b/Tests/CodexBarTests/StatusItemControllerShutdownTests.swift @@ -27,12 +27,18 @@ struct StatusItemControllerShutdownTests { settings.setProviderEnabled(provider: .claude, metadata: claudeMetadata, enabled: true) } - let fetcher = UsageFetcher() - let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + let environment = Self.isolatedEnvironment() + let fetcher = UsageFetcher(environment: environment) + let store = UsageStore( + fetcher: fetcher, + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + startupBehavior: .testing, + environmentBase: environment) let controller = StatusItemController( store: store, settings: settings, - account: fetcher.loadAccountInfo(), + account: AccountInfo(email: nil, plan: nil), updater: DisabledUpdaterController(), preferencesSelection: PreferencesSelection(), statusBar: .system) @@ -41,9 +47,14 @@ struct StatusItemControllerShutdownTests { controller.menuWillOpen(menu) let key = ObjectIdentifier(menu) controller.menuRefreshTasks[key] = Task { try? await Task.sleep(for: .seconds(30)) } + controller.menuReadinessSignatures[key] = "readiness" + controller.menuIdentitySignatures[key] = "identity" + controller.nativeHighlightDeferredMenuRebuilds[key] = .init(provider: .codex) + controller.pendingMenuBaselineResyncs.insert(key) #expect(controller.openMenus[key] === menu) - #expect(controller.statusItem.menu != nil) + #expect(controller.mergedMenu != nil) + #expect(controller.statusItem.menu === controller.mergedMenu) controller.prepareForAppShutdown() controller.prepareForAppShutdown() @@ -51,6 +62,10 @@ struct StatusItemControllerShutdownTests { #expect(controller.hasPreparedForAppShutdown) #expect(controller.openMenus.isEmpty) #expect(controller.menuRefreshTasks.isEmpty) + #expect(controller.menuReadinessSignatures.isEmpty) + #expect(controller.menuIdentitySignatures.isEmpty) + #expect(controller.nativeHighlightDeferredMenuRebuilds.isEmpty) + #expect(controller.pendingMenuBaselineResyncs.isEmpty) #expect(controller.providerSwitcherShortcutEventMonitor == nil) #expect(controller.statusItem.menu == nil) #expect(controller.statusItems.isEmpty) @@ -58,14 +73,230 @@ struct StatusItemControllerShutdownTests { #expect(controller.mergedMenu == nil) } + @Test + func `status menu quit defers shutdown until menu tracking can unwind`() { + let controller = self.makeController() + defer { + StatusItemController.menuCardRenderingEnabled = !SettingsStore.isRunningTests + StatusItemController.resetMenuRefreshEnabledForTesting() + } + let menu = controller.makeMenu() + controller.menuWillOpen(menu) + let key = ObjectIdentifier(menu) + + var scheduledTermination: (@MainActor () -> Void)? + var didTerminate = false + controller.scheduleQuitTermination = { operation in + scheduledTermination = operation + } + controller.terminateApplicationForQuit = { + didTerminate = true + } + + controller.quit() + + #expect(scheduledTermination != nil) + #expect(!controller.hasPreparedForAppShutdown) + #expect(!didTerminate) + #expect(controller.openMenus[key] === menu) + + scheduledTermination?() + + #expect(controller.hasPreparedForAppShutdown) + #expect(controller.openMenus.isEmpty) + #expect(controller.statusItem.menu == nil) + #expect(didTerminate) + } + + @Test + func `app shutdown cancels forced enrichment`() async { + let controller = self.makeController() + defer { + StatusItemController.menuCardRenderingEnabled = !SettingsStore.isRunningTests + StatusItemController.resetMenuRefreshEnabledForTesting() + } + controller.settings.statusChecksEnabled = false + controller.settings.costUsageEnabled = true + controller.settings.openAIWebAccessEnabled = false + controller.settings.codexCookieSource = .off + let tokenTail = CancellationAwareTokenTail() + + controller.store._test_providerRefreshOverride = { _ in } + controller.store._test_codexCreditsLoaderOverride = { + CreditsSnapshot(remaining: 25, events: [], updatedAt: Date()) + } + controller.store._test_tokenUsageRefreshOverride = { _, _ in + await tokenTail.run() + } + defer { + controller.store._test_providerRefreshOverride = nil + controller.store._test_codexCreditsLoaderOverride = nil + controller.store._test_tokenUsageRefreshOverride = nil + } + + controller.refreshNow() + let didStartTokenTail = await tokenTail.waitUntilStarted() + #expect(didStartTokenTail) + guard didStartTokenTail else { + controller.prepareForAppShutdown() + return + } + await controller.manualRefreshTasks[.global]?.value + let enrichmentTask = controller.store.forcedRefreshEnrichmentTask + let requiredRefresh = Task { @MainActor in + await controller.store.refreshForSettingsChange() + } + for _ in 0..<100 where controller.store.requiredRefreshTask == nil { + await Task.yield() + } + + #expect(controller.store.hasForcedRefreshEnrichmentInFlight) + #expect(controller.store.requiredRefreshTask != nil) + controller.prepareForAppShutdown() + await enrichmentTask?.value + await requiredRefresh.value + + #expect(await tokenTail.wasCancelled()) + #expect(!controller.store.hasForcedRefreshEnrichmentInFlight) + #expect(controller.store.forcedRefreshEnrichmentTask == nil) + #expect(controller.store.pendingForcedRefreshEnrichmentTask == nil) + #expect(controller.store.requiredRefreshTask == nil) + #expect(controller.store.pendingRequiredRefreshRequest == nil) + #expect(controller.store.openAIDashboardRefreshTask == nil) + #expect(controller.store.tokenRefreshInFlight.isEmpty) + } + + @Test + func `app shutdown cancels active and pending forced enrichment without promotion`() async { + let controller = self.makeController() + defer { + StatusItemController.menuCardRenderingEnabled = !SettingsStore.isRunningTests + StatusItemController.resetMenuRefreshEnabledForTesting() + } + controller.settings.statusChecksEnabled = false + controller.settings.costUsageEnabled = true + controller.settings.openAIWebAccessEnabled = false + controller.settings.codexCookieSource = .off + let tokenTail = CancellationAwareTokenTail() + + controller.store._test_providerRefreshOverride = { _ in } + controller.store._test_codexCreditsLoaderOverride = { + CreditsSnapshot(remaining: 25, events: [], updatedAt: Date()) + } + controller.store._test_tokenUsageRefreshOverride = { _, _ in + await tokenTail.run() + } + defer { + controller.store._test_providerRefreshOverride = nil + controller.store._test_codexCreditsLoaderOverride = nil + controller.store._test_tokenUsageRefreshOverride = nil + } + + controller.refreshNow() + let didStartTokenTail = await tokenTail.waitUntilStarted(count: 1) + #expect(didStartTokenTail) + guard didStartTokenTail else { + controller.prepareForAppShutdown() + return + } + await controller.manualRefreshTasks[.global]?.value + + await controller.store.refresh(enrichmentMode: .forcedBackground) + let activeTask = controller.store.forcedRefreshEnrichmentTask + let pendingTask = controller.store.pendingForcedRefreshEnrichmentTask + #expect(activeTask != nil) + #expect(pendingTask != nil) + + controller.prepareForAppShutdown() + await activeTask?.value + await pendingTask?.value + + #expect(await tokenTail.startedCount() == 1) + #expect(await tokenTail.cancelledCount() == 1) + #expect(pendingTask?.isCancelled == true) + #expect(!controller.store.hasForcedRefreshEnrichmentInFlight) + #expect(controller.store.forcedRefreshEnrichmentTask == nil) + #expect(controller.store.pendingForcedRefreshEnrichmentTask == nil) + } + + private func makeController() -> StatusItemController { + StatusItemController.menuCardRenderingEnabled = false + StatusItemController.setMenuRefreshEnabledForTesting(true) + + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + if let codexMetadata = ProviderRegistry.shared.metadata[.codex] { + settings.setProviderEnabled(provider: .codex, metadata: codexMetadata, enabled: true) + } + + let environment = Self.isolatedEnvironment() + let fetcher = UsageFetcher(environment: environment) + let store = UsageStore( + fetcher: fetcher, + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + startupBehavior: .testing, + environmentBase: environment) + return StatusItemController( + store: store, + settings: settings, + account: AccountInfo(email: nil, plan: nil), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: .system) + } + private func makeSettings() -> SettingsStore { - let suite = "StatusItemControllerShutdownTests-\(UUID().uuidString)" - let defaults = UserDefaults(suiteName: suite)! - defaults.removePersistentDomain(forName: suite) - return SettingsStore( - userDefaults: defaults, - configStore: testConfigStore(suiteName: suite), - zaiTokenStore: NoopZaiTokenStore(), - syntheticTokenStore: NoopSyntheticTokenStore()) + testSettingsStore(suiteName: "StatusItemControllerShutdownTests") + } + + private static func isolatedEnvironment() -> [String: String] { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("codexbar-tests", isDirectory: true) + .appendingPathComponent(UUID().uuidString, isDirectory: true) + return [ + "HOME": root.path, + "CODEX_HOME": root.appendingPathComponent(".codex", isDirectory: true).path, + "XDG_CONFIG_HOME": root.appendingPathComponent(".config", isDirectory: true).path, + ] + } +} + +private actor CancellationAwareTokenTail { + private var started = 0 + private var cancelled = 0 + + func run() async { + self.started += 1 + do { + try await Task.sleep(for: .seconds(30)) + } catch is CancellationError { + self.cancelled += 1 + } catch {} + } + + func waitUntilStarted(count: Int = 1, timeout: Duration = .seconds(5)) async -> Bool { + let deadline = ContinuousClock.now + timeout + while self.started < count { + if ContinuousClock.now >= deadline { + return false + } + try? await Task.sleep(for: .milliseconds(10)) + } + return true + } + + func wasCancelled() -> Bool { + self.cancelled > 0 + } + + func startedCount() -> Int { + self.started + } + + func cancelledCount() -> Int { + self.cancelled } } diff --git a/Tests/CodexBarTests/StatusItemControllerSplitLifecycleTests.swift b/Tests/CodexBarTests/StatusItemControllerSplitLifecycleTests.swift index 86d247d9d5..6722c413d5 100644 --- a/Tests/CodexBarTests/StatusItemControllerSplitLifecycleTests.swift +++ b/Tests/CodexBarTests/StatusItemControllerSplitLifecycleTests.swift @@ -65,6 +65,42 @@ struct StatusItemControllerSplitLifecycleTests { return (settings, controller) } + @Test + func `provider config notifications relay background work impact between settings stores`() { + self.disableMenuCardsForTesting() + let sourceSettings = self.makeSettings() + let controllerSettings = self.makeSettings() + controllerSettings.statusChecksEnabled = false + controllerSettings.refreshFrequency = .manual + + let fetcher = UsageFetcher() + let store = UsageStore( + fetcher: fetcher, + browserDetection: BrowserDetection(cacheTTL: 0), + settings: controllerSettings) + let controller = StatusItemController( + store: store, + settings: controllerSettings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting(), + observeProviderConfigNotifications: true) + defer { controller.releaseStatusItemsForTesting() } + + let initialBackgroundRevision = controllerSettings.backgroundWorkSettingsRevision + let reorderedProviders = Array(sourceSettings.orderedProviders().reversed()) + sourceSettings.setProviderOrder(reorderedProviders) + + #expect(controllerSettings.orderedProviders() == reorderedProviders) + #expect(controllerSettings.backgroundWorkSettingsRevision == initialBackgroundRevision) + + sourceSettings.codexUsageDataSource = .cli + + #expect(controllerSettings.codexUsageDataSource == .cli) + #expect(controllerSettings.backgroundWorkSettingsRevision == initialBackgroundRevision + 1) + } + @Test func `merged mode removes split provider status items`() throws { let (settings, controller) = try self.makeSplitController() @@ -72,12 +108,71 @@ struct StatusItemControllerSplitLifecycleTests { #expect(controller.statusItems[.codex] != nil) #expect(controller.statusItems[.claude] != nil) + #expect(controller.expectedVisibleStatusItemAutosaveNames == ["codexbar-codex", "codexbar-claude"]) settings.mergeIcons = true controller.handleProviderConfigChange(reason: "test") #expect(controller.statusItem.isVisible == true) #expect(controller.statusItems.isEmpty) + #expect(controller.expectedVisibleStatusItemAutosaveNames == ["codexbar-merged"]) + } + + @Test + func `removing split provider status items clears all menu lifecycle state`() throws { + let (settings, controller) = try self.makeSplitController() + defer { controller.releaseStatusItemsForTesting() } + + let menus = try [UsageProvider.codex, .claude].map { provider in + try #require(controller.providerMenus[provider]) + } + let keys = menus.map(ObjectIdentifier.init) + for (menu, key) in zip(menus, keys) { + controller.menuProviders[key] = .codex + controller.menuReadinessSignatures[key] = "readiness" + controller.menuIdentitySignatures[key] = "identity" + controller.menuSession.markFresh(key) + controller.menuSession.deferUntilNextOpen(key) + controller.menuSession.deferParentRebuild(key) + controller.openMenus[key] = menu + controller.menuRefreshTasks[key] = Task { + try? await Task.sleep(for: .seconds(60)) + } + controller.closedMenuRebuildTasks[key] = Task { + try? await Task.sleep(for: .seconds(60)) + } + controller.openMenuRebuildTasks[key] = Task { + try? await Task.sleep(for: .seconds(60)) + } + _ = controller.closedMenuRebuildRequests.replaceRequest(for: key) + _ = controller.openMenuRebuildRequests.replaceRequest(for: key) + controller.openMenuRebuildsClosingHostedSubviewMenus.insert(key) + controller.highlightedMenuItems[key] = NSMenuItem(title: "Highlighted", action: nil, keyEquivalent: "") + controller.nativeHighlightDeferredMenuRebuilds[key] = .init(provider: .codex) + controller.pendingMenuBaselineResyncs.insert(key) + } + + settings.mergeIcons = true + controller.handleProviderConfigChange(reason: "test") + + for key in keys { + #expect(controller.menuProviders[key] == nil) + #expect(controller.menuReadinessSignatures[key] == nil) + #expect(controller.menuIdentitySignatures[key] == nil) + #expect(controller.menuSession.renderedVersion(for: key) == nil) + #expect(!controller.menuSession.isDeferredUntilNextOpen(key)) + #expect(!controller.menuSession.isParentRebuildDeferred(key)) + #expect(controller.openMenus[key] == nil) + #expect(controller.menuRefreshTasks[key] == nil) + #expect(controller.closedMenuRebuildTasks[key] == nil) + #expect(controller.openMenuRebuildTasks[key] == nil) + #expect(controller.closedMenuRebuildRequests.tokens[key] == nil) + #expect(controller.openMenuRebuildRequests.tokens[key] == nil) + #expect(!controller.openMenuRebuildsClosingHostedSubviewMenus.contains(key)) + #expect(controller.highlightedMenuItems[key] == nil) + #expect(controller.nativeHighlightDeferredMenuRebuilds[key] == nil) + #expect(!controller.pendingMenuBaselineResyncs.contains(key)) + } } @Test @@ -98,22 +193,237 @@ 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") #expect(controller.statusItem.button?.accessibilityTitle() == "CodexBar") #expect(codexButton.accessibilityTitle() == "CodexBar") #expect(claudeButton.accessibilityTitle() == "CodexBar") + #expect(controller.statusItem.button?.toolTip == nil) + #expect(codexButton.toolTip == nil) + #expect(claudeButton.toolTip == nil) + } + + @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 @@ -148,6 +458,26 @@ struct StatusItemControllerSplitLifecycleTests { #expect(defaults.object(forKey: "NSStatusItem VisibleCC Item-2") != nil) } + @Test + func `status item visibility default distinguishes enabled disabled and unset`() throws { + let suite = "StatusItemControllerSplitLifecycleTests-visibility-default-\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + defaults.set(true, forKey: "NSStatusItem VisibleCC codexbar-merged") + defaults.set(false, forKey: "NSStatusItem VisibleCC codexbar-claude") + defer { defaults.removePersistentDomain(forName: suite) } + + #expect(MenuBarStatusItemDefaultsRepair.visibilityDefault( + defaults: defaults, + autosaveName: "codexbar-merged") == true) + #expect(MenuBarStatusItemDefaultsRepair.visibilityDefault( + defaults: defaults, + autosaveName: "codexbar-claude") == false) + #expect(MenuBarStatusItemDefaultsRepair.visibilityDefault( + defaults: defaults, + autosaveName: "codexbar-codex") == nil) + } + @Test func `non destructive visibility refresh preserves split provider status items`() throws { let (_, controller) = try self.makeSplitController() @@ -164,7 +494,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 +512,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 +544,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 +562,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/StatusItemExtraUsageMetricTests.swift b/Tests/CodexBarTests/StatusItemExtraUsageMetricTests.swift index 3ef14df719..28edeb6be4 100644 --- a/Tests/CodexBarTests/StatusItemExtraUsageMetricTests.swift +++ b/Tests/CodexBarTests/StatusItemExtraUsageMetricTests.swift @@ -6,17 +6,10 @@ import Testing @Suite(.serialized) @MainActor struct StatusItemExtraUsageMetricTests { - private func makeStatusBarForTesting() -> NSStatusBar { - let env = ProcessInfo.processInfo.environment - if env["GITHUB_ACTIONS"] == "true" || env["CI"] == "true" { - return .system - } - return NSStatusBar() - } - @Test func `menu bar extra usage preference uses cursor on demand budget`() { let (store, controller) = self.makeCursorController(suiteName: "StatusItemExtraUsageMetricTests-budget") + defer { controller.releaseStatusItemsForTesting() } let snapshot = UsageSnapshot( primary: RateWindow(usedPercent: 10, windowMinutes: nil, resetsAt: nil, resetDescription: nil), secondary: RateWindow(usedPercent: 20, windowMinutes: nil, resetsAt: nil, resetDescription: nil), @@ -41,6 +34,7 @@ struct StatusItemExtraUsageMetricTests { let (store, controller) = self.makeController( suiteName: "StatusItemExtraUsageMetricTests-missing-budget", provider: .cursor) + defer { controller.releaseStatusItemsForTesting() } let snapshot = UsageSnapshot( primary: RateWindow(usedPercent: 10, windowMinutes: nil, resetsAt: nil, resetDescription: nil), secondary: RateWindow(usedPercent: 72, windowMinutes: nil, resetsAt: nil, resetDescription: nil), @@ -57,10 +51,11 @@ struct StatusItemExtraUsageMetricTests { } @Test - func `menu bar extra usage preference shows currency spend text for cursor when provider cost exists`() { + func `menu bar extra usage preference honors percent used display for cursor`() { let (store, controller) = self.makeController( suiteName: "StatusItemExtraUsageMetricTests-cursor-spend-text", provider: .cursor) + defer { controller.releaseStatusItemsForTesting() } let snapshot = UsageSnapshot( primary: RateWindow(usedPercent: 10, windowMinutes: nil, resetsAt: nil, resetDescription: nil), secondary: RateWindow(usedPercent: 20, windowMinutes: nil, resetsAt: nil, resetDescription: nil), @@ -77,14 +72,93 @@ struct StatusItemExtraUsageMetricTests { let displayText = controller.menuBarDisplayText(for: .cursor, snapshot: snapshot) + #expect(displayText == "12%") + } + + @Test + func `menu bar extra usage preference honors percent remaining display for cursor`() { + let (store, controller) = self.makeController( + suiteName: "StatusItemExtraUsageMetricTests-cursor-remaining-text", + provider: .cursor) + defer { controller.releaseStatusItemsForTesting() } + controller.settings.usageBarsShowUsed = false + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 10, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: RateWindow(usedPercent: 20, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + tertiary: RateWindow(usedPercent: 72, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + providerCost: ProviderCostSnapshot( + used: 12.34, + limit: 100, + currencyCode: "USD", + updatedAt: Date()), + updatedAt: Date()) + + store._setSnapshotForTesting(snapshot, provider: .cursor) + store._setErrorForTesting(nil, provider: .cursor) + + let displayText = controller.menuBarDisplayText(for: .cursor, snapshot: snapshot) + + #expect(displayText == "88%") + } + + @Test + func `menu bar extra usage preference keeps cursor currency fallback in pace mode`() { + let (store, controller) = self.makeController( + suiteName: "StatusItemExtraUsageMetricTests-cursor-pace-spend-text", + provider: .cursor) + defer { controller.releaseStatusItemsForTesting() } + controller.settings.menuBarDisplayMode = .pace + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 42, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + secondary: nil, + tertiary: nil, + providerCost: ProviderCostSnapshot( + used: 12.34, + limit: 100, + currencyCode: "USD", + updatedAt: Date()), + updatedAt: Date()) + + store._setSnapshotForTesting(snapshot, provider: .cursor) + store._setErrorForTesting(nil, provider: .cursor) + + let displayText = controller.menuBarDisplayText(for: .cursor, snapshot: snapshot) + #expect(displayText == "$12.34") } @Test - func `menu bar extra usage preference shows currency spend text for claude when provider cost exists`() { + func `menu bar extra usage preference uses percent in combined mode`() { + let (store, controller) = self.makeController( + suiteName: "StatusItemExtraUsageMetricTests-cursor-combined-text", + provider: .cursor) + defer { controller.releaseStatusItemsForTesting() } + controller.settings.menuBarDisplayMode = .both + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 10, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: nil, + tertiary: nil, + providerCost: ProviderCostSnapshot( + used: 56, + limit: 100, + currencyCode: "USD", + updatedAt: Date()), + updatedAt: Date()) + + store._setSnapshotForTesting(snapshot, provider: .cursor) + store._setErrorForTesting(nil, provider: .cursor) + + let displayText = controller.menuBarDisplayText(for: .cursor, snapshot: snapshot) + + #expect(displayText == "56%") + } + + @Test + func `menu bar extra usage preference preserves claude currency display`() { let (store, controller) = self.makeController( suiteName: "StatusItemExtraUsageMetricTests-claude-spend-text", provider: .claude) + defer { controller.releaseStatusItemsForTesting() } let snapshot = UsageSnapshot( primary: RateWindow(usedPercent: 42, windowMinutes: 300, resetsAt: nil, resetDescription: nil), secondary: nil, @@ -110,6 +184,7 @@ struct StatusItemExtraUsageMetricTests { let (store, controller) = self.makeController( suiteName: "StatusItemExtraUsageMetricTests-fallback-percent", provider: .cursor) + defer { controller.releaseStatusItemsForTesting() } let snapshot = UsageSnapshot( primary: RateWindow(usedPercent: 10, windowMinutes: nil, resetsAt: nil, resetDescription: nil), secondary: RateWindow(usedPercent: 72, windowMinutes: nil, resetsAt: nil, resetDescription: nil), @@ -125,19 +200,53 @@ struct StatusItemExtraUsageMetricTests { #expect(displayText == "72%") } + @Test + func `reset time mode uses extra usage reset instead of spend`() { + let resetsAt = Date().addingTimeInterval(2 * 24 * 3600) + let (store, controller) = self.makeController( + suiteName: "StatusItemExtraUsageMetricTests-reset-time", + provider: .cursor, + displayMode: .resetTime, + resetTimesShowAbsolute: true) + defer { controller.releaseStatusItemsForTesting() } + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 10, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: nil, + tertiary: nil, + providerCost: ProviderCostSnapshot( + used: 12.34, + limit: 100, + currencyCode: "USD", + period: "Monthly", + resetsAt: resetsAt, + updatedAt: Date()), + updatedAt: Date()) + + store._setSnapshotForTesting(snapshot, provider: .cursor) + store._setErrorForTesting(nil, provider: .cursor) + + let displayText = controller.menuBarDisplayText(for: .cursor, snapshot: snapshot) + + #expect(displayText == "↻ \(UsageFormatter.resetDescription(from: resetsAt))") + } + private func makeCursorController(suiteName: String) -> (UsageStore, StatusItemController) { self.makeController(suiteName: suiteName, provider: .cursor) } - private func makeController(suiteName: String, provider: UsageProvider) -> (UsageStore, StatusItemController) { - let settings = SettingsStore( - configStore: testConfigStore(suiteName: suiteName), - zaiTokenStore: NoopZaiTokenStore()) + private func makeController( + suiteName: String, + provider: UsageProvider, + displayMode: MenuBarDisplayMode = .percent, + resetTimesShowAbsolute: Bool = false) -> (UsageStore, StatusItemController) + { + let settings = testSettingsStore(suiteName: suiteName) settings.statusChecksEnabled = false settings.refreshFrequency = .manual settings.mergeIcons = true settings.selectedMenuProvider = provider - settings.menuBarDisplayMode = .percent + settings.menuBarDisplayMode = displayMode + settings.resetTimesShowAbsolute = resetTimesShowAbsolute settings.usageBarsShowUsed = true settings.setMenuBarMetricPreference(.extraUsage, for: provider) @@ -154,7 +263,7 @@ struct StatusItemExtraUsageMetricTests { account: fetcher.loadAccountInfo(), updater: DisabledUpdaterController(), preferencesSelection: PreferencesSelection(), - statusBar: self.makeStatusBarForTesting()) + statusBar: testStatusBar()) return (store, controller) } } diff --git a/Tests/CodexBarTests/StatusItemIconObservationSignatureTests.swift b/Tests/CodexBarTests/StatusItemIconObservationSignatureTests.swift index 6660c264ee..02b23dedcc 100644 --- a/Tests/CodexBarTests/StatusItemIconObservationSignatureTests.swift +++ b/Tests/CodexBarTests/StatusItemIconObservationSignatureTests.swift @@ -6,20 +6,33 @@ import Testing @MainActor @Suite(.serialized) struct StatusItemIconObservationSignatureTests { - private func makeController(suiteName: String) -> (SettingsStore, UsageStore, StatusItemController) { - let settings = SettingsStore( - configStore: testConfigStore(suiteName: suiteName), - zaiTokenStore: NoopZaiTokenStore(), - syntheticTokenStore: NoopSyntheticTokenStore()) + private func makeController( + suiteName: String, + menuBarLayout: MenuBarLayout? = nil) + -> (SettingsStore, UsageStore, StatusItemController) + { + let settings = testSettingsStore(suiteName: suiteName) settings.statusChecksEnabled = true settings.refreshFrequency = .manual + settings.usageBarsShowUsed = false + settings.showOptionalCreditsAndExtraUsage = true settings.menuBarShowsBrandIconWithPercent = false + settings.menuBarShowsHighestUsage = false settings.mergeIcons = true + settings.mergedMenuLastSelectedWasOverview = false + settings.selectedMenuProvider = .codex + if let menuBarLayout { + settings.menuBarShowsBrandIconWithPercent = true + settings.setMenuBarLayout(menuBarLayout, for: nil) + } let registry = ProviderRegistry.shared if let codexMeta = registry.metadata[.codex] { settings.setProviderEnabled(provider: .codex, metadata: codexMeta, enabled: true) } + if let claudeMeta = registry.metadata[.claude] { + settings.setProviderEnabled(provider: .claude, metadata: claudeMeta, enabled: false) + } let fetcher = UsageFetcher() let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) @@ -55,6 +68,212 @@ struct StatusItemIconObservationSignatureTests { #expect(controller.storeIconObservationSignature() == baseline) } + @Test + func `custom menu bar layout preserves accessibility without a hover tooltip`() throws { + let (_, _, controller) = self.makeController( + suiteName: "StatusItemIconObservationSignatureTests-custom-layout-tooltip", + menuBarLayout: MenuBarLayout(lines: [[.icon, .providerName]])) + defer { controller.releaseStatusItemsForTesting() } + + let button = try #require(controller.statusItem.button) + #expect(button.accessibilityTitle()?.isEmpty == false) + #expect(button.toolTip == nil) + } + + @Test + func `store icon observation signature ignores non visual snapshot churn`() { + let (_, store, controller) = self.makeController( + suiteName: "StatusItemIconObservationSignatureTests-snapshot-metadata") + defer { controller.releaseStatusItemsForTesting() } + + let baseline = controller.storeIconObservationSignature() + #expect(!baseline.contains("icon@example.com")) + + store._setSnapshotForTesting( + Self.makeSnapshot( + provider: .codex, + email: "rotated-account@example.com", + updatedAt: Date(timeIntervalSince1970: 200)), + provider: .codex) + + let signature = controller.storeIconObservationSignature() + + #expect(signature == baseline) + #expect(!signature.contains("rotated-account@example.com")) + } + + @Test + func `custom account label changes the store icon observation signature`() { + let (_, store, controller) = self.makeController( + suiteName: "StatusItemIconObservationSignatureTests-custom-account-label", + menuBarLayout: MenuBarLayout(lines: [[.accountLabel]])) + defer { controller.releaseStatusItemsForTesting() } + + let baseline = controller.storeIconObservationSignature() + #expect(!baseline.contains("icon@example.com")) + + store._setSnapshotForTesting( + Self.makeSnapshot( + provider: .codex, + email: "rotated-account@example.com", + updatedAt: Date(timeIntervalSince1970: 200)), + provider: .codex) + + let signature = controller.storeIconObservationSignature() + + #expect(signature != baseline) + #expect(!signature.contains("rotated-account@example.com")) + } + + @Test + func `hidden custom account label ignores account changes`() { + let (settings, store, controller) = self.makeController( + suiteName: "StatusItemIconObservationSignatureTests-hidden-custom-account-label", + menuBarLayout: MenuBarLayout(lines: [[.accountLabel]])) + defer { controller.releaseStatusItemsForTesting() } + settings.hidePersonalInfo = true + let baseline = controller.storeIconObservationSignature() + + store._setSnapshotForTesting( + Self.makeSnapshot( + provider: .codex, + email: "rotated-account@example.com", + updatedAt: Date(timeIntervalSince1970: 200)), + provider: .codex) + + let signature = controller.storeIconObservationSignature() + + #expect(signature == baseline) + #expect(!signature.contains("rotated-account@example.com")) + } + + @Test + func `merged store icon observation signature ignores non primary snapshot churn`() throws { + let (settings, store, controller) = self.makeController( + suiteName: "StatusItemIconObservationSignatureTests-merged-secondary-snapshot") + defer { controller.releaseStatusItemsForTesting() } + + let registry = ProviderRegistry.shared + let claudeMetadata = try #require(registry.metadata[.claude]) + settings.setProviderEnabled(provider: .claude, metadata: claudeMetadata, enabled: true) + settings.selectedMenuProvider = .codex + store._setSnapshotForTesting( + Self.makeSnapshot(provider: .claude, email: "claude@example.com"), + provider: .claude) + let baseline = controller.storeIconObservationSignature() + + store._setSnapshotForTesting( + Self.makeSnapshot( + provider: .claude, + email: "changed@example.com", + primaryUsedPercent: 99, + secondaryUsedPercent: 88, + updatedAt: Date(timeIntervalSince1970: 300)), + provider: .claude) + + #expect(controller.storeIconObservationSignature() == baseline) + } + + @Test + func `store icon observation signature changes when icon percentages change`() { + let (_, store, controller) = self.makeController( + suiteName: "StatusItemIconObservationSignatureTests-percent-change") + defer { controller.releaseStatusItemsForTesting() } + + let baseline = controller.storeIconObservationSignature() + + store._setSnapshotForTesting( + Self.makeSnapshot( + provider: .codex, + email: "icon@example.com", + primaryUsedPercent: 42, + secondaryUsedPercent: 63), + provider: .codex) + + #expect(controller.storeIconObservationSignature() != baseline) + } + + @Test + func `store icon observation signature tracks selected copilot budget`() throws { + let (settings, store, controller) = self.makeController( + suiteName: "StatusItemIconObservationSignatureTests-copilot-budget") + defer { controller.releaseStatusItemsForTesting() } + + let registry = ProviderRegistry.shared + let codexMetadata = try #require(registry.metadata[.codex]) + let copilotMetadata = try #require(registry.metadata[.copilot]) + settings.setProviderEnabled(provider: .codex, metadata: codexMetadata, enabled: false) + settings.setProviderEnabled(provider: .copilot, metadata: copilotMetadata, enabled: true) + settings.selectedMenuProvider = .copilot + settings.copilotBudgetExtrasEnabled = true + settings.copilotIconSecondaryWindowID = "copilot-budget-agent" + + store._setSnapshotForTesting( + Self.makeCopilotSnapshot(budgetUsedPercent: 25), + provider: .copilot) + let baseline = controller.storeIconObservationSignature() + + store._setSnapshotForTesting( + Self.makeCopilotSnapshot(budgetUsedPercent: 75), + provider: .copilot) + + #expect(controller.storeIconObservationSignature() != baseline) + } + + @Test + func `store icon observation signature changes when credit fallback changes`() { + let (_, store, controller) = self.makeController( + suiteName: "StatusItemIconObservationSignatureTests-credit-fallback") + defer { controller.releaseStatusItemsForTesting() } + + store._setSnapshotForTesting( + Self.makeSnapshot( + provider: .codex, + email: "icon@example.com", + primaryUsedPercent: 100, + secondaryUsedPercent: 20), + provider: .codex) + store.credits = CreditsSnapshot(remaining: 80, events: [], updatedAt: Date(timeIntervalSince1970: 100)) + let baseline = controller.storeIconObservationSignature() + + store.credits = CreditsSnapshot(remaining: 42, events: [], updatedAt: Date(timeIntervalSince1970: 200)) + + #expect(controller.storeIconObservationSignature() != baseline) + } + + @Test + func `store icon observation signature ignores unused credit balance`() { + let (_, store, controller) = self.makeController( + suiteName: "StatusItemIconObservationSignatureTests-unused-credits") + defer { controller.releaseStatusItemsForTesting() } + + store.credits = CreditsSnapshot(remaining: 80, events: [], updatedAt: Date(timeIntervalSince1970: 100)) + let baseline = controller.storeIconObservationSignature() + + store.credits = CreditsSnapshot(remaining: 42, events: [], updatedAt: Date(timeIntervalSince1970: 200)) + + #expect(controller.storeIconObservationSignature() == baseline) + } + + @Test + func `merged store icon observation signature ignores non primary status changes`() throws { + let (settings, store, controller) = self.makeController( + suiteName: "StatusItemIconObservationSignatureTests-merged-secondary-status") + defer { controller.releaseStatusItemsForTesting() } + + let registry = ProviderRegistry.shared + let claudeMetadata = try #require(registry.metadata[.claude]) + settings.setProviderEnabled(provider: .claude, metadata: claudeMetadata, enabled: true) + let baseline = controller.storeIconObservationSignature() + + store.statuses[.claude] = ProviderStatus( + indicator: .major, + description: "Claude status issue", + updatedAt: Date(timeIntervalSince1970: 20)) + + #expect(controller.storeIconObservationSignature() == baseline) + } + @Test func `store icon observation signature changes when status indicator changes`() { let (_, store, controller) = self.makeController( @@ -75,15 +294,226 @@ struct StatusItemIconObservationSignatureTests { #expect(controller.storeIconObservationSignature() != baseline) } - private static func makeSnapshot(provider: UsageProvider, email: String) -> UsageSnapshot { + @Test + func `store icon observation signature changes when hide critters toggles`() { + let (settings, _, controller) = self.makeController( + suiteName: "StatusItemIconObservationSignatureTests-hide-critters") + defer { controller.releaseStatusItemsForTesting() } + + settings.menuBarHidesCritters = false + let baseline = controller.storeIconObservationSignature() + + settings.menuBarHidesCritters = true + + #expect(controller.storeIconObservationSignature() != baseline) + } + + @Test(arguments: [MenuBarLayoutToken.costToday, .cost30d]) + func `custom cost token changes the store icon observation signature`(layoutElement: MenuBarLayoutToken) { + let (_, store, controller) = self.makeController( + suiteName: "StatusItemIconObservationSignatureTests-custom-cost-\(layoutElement)", + menuBarLayout: MenuBarLayout(lines: [[layoutElement]])) + defer { controller.releaseStatusItemsForTesting() } + + let baseline = controller.storeIconObservationSignature() + + store._setTokenSnapshotForTesting( + Self.makeTokenSnapshot(todayCost: 1.25, last30DaysCost: 12.50), + provider: .codex) + + #expect(controller.storeIconObservationSignature() != baseline) + } + + @Test + func `custom cost layout ignores token fields it does not render`() { + let (_, store, controller) = self.makeController( + suiteName: "StatusItemIconObservationSignatureTests-custom-cost-irrelevant", + menuBarLayout: MenuBarLayout(lines: [[.cost30d]])) + defer { controller.releaseStatusItemsForTesting() } + + store._setTokenSnapshotForTesting( + Self.makeTokenSnapshot(todayCost: 1.25, last30DaysCost: 12.50, sessionTokens: 100), + provider: .codex) + let baseline = controller.storeIconObservationSignature() + + store._setTokenSnapshotForTesting( + Self.makeTokenSnapshot(todayCost: 9.99, last30DaysCost: 12.50, sessionTokens: 999), + provider: .codex) + + #expect(controller.storeIconObservationSignature() == baseline) + } + + @Test + func `token cost publication enters the icon refresh path without a usage change`() async { + let (_, store, controller) = self.makeController( + suiteName: "StatusItemIconObservationSignatureTests-custom-cost-title", + menuBarLayout: MenuBarLayout(lines: [[.cost30d]])) + defer { controller.releaseStatusItemsForTesting() } + controller.updateIcons() + let baseline = controller.lastObservedStoreIconWorkSignature + let usageUpdatedAt = store.snapshot(for: .codex)?.updatedAt + let usagePrimaryPercent = store.snapshot(for: .codex)?.primary?.usedPercent + + store._setTokenSnapshotForTesting( + Self.makeTokenSnapshot(todayCost: 1.25, last30DaysCost: 12.50), + provider: .codex) + + for _ in 0..<100 where controller.lastObservedStoreIconWorkSignature == baseline { + await Task.yield() + try? await Task.sleep(for: .milliseconds(10)) + } + #expect(store.snapshot(for: .codex)?.updatedAt == usageUpdatedAt) + #expect(store.snapshot(for: .codex)?.primary?.usedPercent == usagePrimaryPercent) + #expect(controller.lastObservedStoreIconWorkSignature != baseline) + #expect( + controller.menuBarLayoutCostStrings(provider: .codex).last30Days == + UsageFormatter.currencyString(12.50, currencyCode: "USD")) + } + + @Test + func `display settings persist cached widget snapshot`() async { + let (settings, store, controller) = self.makeController( + suiteName: "StatusItemIconObservationSignatureTests-widget-display") + defer { controller.releaseStatusItemsForTesting() } + + var widgetSnapshots: [WidgetSnapshot] = [] + store._test_widgetSnapshotSaveOverride = { widgetSnapshots.append($0) } + defer { store._test_widgetSnapshotSaveOverride = nil } + + settings.usageBarsShowUsed = true + try? await Task.sleep(nanoseconds: 50_000_000) + await store.widgetSnapshotPersistTask?.value + + #expect(widgetSnapshots.last?.usageBarsShowUsed == true) + #expect(widgetSnapshots.last?.entries.contains(where: { $0.provider == .codex }) == true) + } + + @Test + func `config only settings do not persist cached widget snapshot`() async { + let (settings, store, controller) = self.makeController( + suiteName: "StatusItemIconObservationSignatureTests-widget-config-only") + defer { controller.releaseStatusItemsForTesting() } + + var widgetSnapshots: [WidgetSnapshot] = [] + store._test_widgetSnapshotSaveOverride = { widgetSnapshots.append($0) } + defer { store._test_widgetSnapshotSaveOverride = nil } + + settings.zaiAPIToken = "test-token" + try? await Task.sleep(nanoseconds: 100_000_000) + await store.widgetSnapshotPersistTask?.value + + #expect(widgetSnapshots.isEmpty) + } + + @Test + func `updateIcons reuses a precomputed store icon signature instead of recomputing it`() { + let (_, _, controller) = self.makeController( + suiteName: "StatusItemIconObservationSignatureTests-precomputed-reuse") + defer { controller.releaseStatusItemsForTesting() } + + let precomputed = "precomputed-store-icon-signature-sentinel" + controller.updateIcons(precomputedStoreIconSignature: precomputed) + + // A supplied signature must be stored verbatim; if updateIcons recomputed it, the gate would + // never equal the sentinel value. + #expect(controller.lastObservedStoreIconWorkSignature == precomputed) + } + + @Test + func `updateIcons recomputes the store icon signature when none is provided`() { + let (_, _, controller) = self.makeController( + suiteName: "StatusItemIconObservationSignatureTests-recompute-default") + defer { controller.releaseStatusItemsForTesting() } + + controller.updateIcons() + + #expect(controller.lastObservedStoreIconWorkSignature == controller.storeIconObservationSignature()) + } + + private static func makeSnapshot( + provider: UsageProvider, + email: String, + primaryUsedPercent: Double = 10, + secondaryUsedPercent: Double = 20, + updatedAt: Date = Date(timeIntervalSince1970: 100)) + -> UsageSnapshot + { UsageSnapshot( - primary: RateWindow(usedPercent: 10, windowMinutes: 300, resetsAt: nil, resetDescription: nil), - secondary: RateWindow(usedPercent: 20, windowMinutes: 10080, resetsAt: nil, resetDescription: nil), - updatedAt: Date(timeIntervalSince1970: 100), + primary: RateWindow( + usedPercent: primaryUsedPercent, + windowMinutes: 300, + resetsAt: nil, + resetDescription: nil), + secondary: RateWindow( + usedPercent: secondaryUsedPercent, + windowMinutes: 10080, + resetsAt: nil, + resetDescription: nil), + updatedAt: updatedAt, identity: ProviderIdentitySnapshot( providerID: provider, accountEmail: email, accountOrganization: nil, loginMethod: "plus")) } + + private static func makeCopilotSnapshot(budgetUsedPercent: Double) -> UsageSnapshot { + UsageSnapshot( + primary: RateWindow( + usedPercent: 10, + windowMinutes: nil, + resetsAt: nil, + resetDescription: nil), + secondary: RateWindow( + usedPercent: 20, + windowMinutes: nil, + resetsAt: nil, + resetDescription: nil), + extraRateWindows: [ + NamedRateWindow( + id: "copilot-budget-agent", + title: "Budget - Copilot Agent Premium Requests", + window: RateWindow( + usedPercent: budgetUsedPercent, + windowMinutes: nil, + resetsAt: nil, + resetDescription: nil)), + ], + updatedAt: Date(timeIntervalSince1970: 100), + identity: ProviderIdentitySnapshot( + providerID: .copilot, + accountEmail: "copilot@example.com", + accountOrganization: nil, + loginMethod: "individual")) + } + + private static func makeTokenSnapshot( + todayCost: Double, + last30DaysCost: Double, + sessionTokens: Int? = nil, + now: Date = .init()) + -> CostUsageTokenSnapshot + { + let formatter = DateFormatter() + formatter.calendar = .current + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.timeZone = .current + formatter.dateFormat = "yyyy-MM-dd" + return CostUsageTokenSnapshot( + sessionTokens: sessionTokens, + sessionCostUSD: nil, + last30DaysTokens: nil, + last30DaysCostUSD: last30DaysCost, + daily: [ + CostUsageDailyReport.Entry( + date: formatter.string(from: now), + inputTokens: nil, + outputTokens: nil, + totalTokens: nil, + costUSD: todayCost, + modelsUsed: nil, + modelBreakdowns: nil), + ], + updatedAt: now) + } } diff --git a/Tests/CodexBarTests/StatusItemPurchaseURLTests.swift b/Tests/CodexBarTests/StatusItemPurchaseURLTests.swift index 58d126e376..1f2069e7f4 100644 --- a/Tests/CodexBarTests/StatusItemPurchaseURLTests.swift +++ b/Tests/CodexBarTests/StatusItemPurchaseURLTests.swift @@ -1,3 +1,4 @@ +import CodexBarCore import Foundation import Testing @testable import CodexBar @@ -46,4 +47,17 @@ struct StatusItemPurchaseURLTests { == nil) #expect(StatusItemController.sanitizedCreditsPurchaseURL("not a url") == nil) } + + @Test + @MainActor + func `scoped purchase window requires an account email`() { + let scope = CookieHeaderCache.Scope.profileHome("/tmp/codex-profile") + + #expect(!OpenAICreditsPurchaseWindowController.canOpenPurchaseWindow(accountEmail: nil, cacheScope: scope)) + #expect(!OpenAICreditsPurchaseWindowController.canOpenPurchaseWindow(accountEmail: " ", cacheScope: scope)) + #expect(OpenAICreditsPurchaseWindowController.canOpenPurchaseWindow( + accountEmail: " owner@example.com ", + cacheScope: scope)) + #expect(OpenAICreditsPurchaseWindowController.canOpenPurchaseWindow(accountEmail: nil, cacheScope: nil)) + } } diff --git a/Tests/CodexBarTests/StatusItemReuseRegressionTests.swift b/Tests/CodexBarTests/StatusItemReuseRegressionTests.swift new file mode 100644 index 0000000000..c96531b38e --- /dev/null +++ b/Tests/CodexBarTests/StatusItemReuseRegressionTests.swift @@ -0,0 +1,75 @@ +import AppKit +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +@MainActor +@Suite(.serialized) +struct StatusItemReuseRegressionTests { + @Test + func `usage update during vending reuses the provider status item`() throws { + let suite = "StatusItemReuseRegressionTests-\(UUID().uuidString)" + let settings = testSettingsStore(suiteName: suite) + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.providerDetectionCompleted = true + settings.menuBarShowsBrandIconWithPercent = true + settings.menuBarDisplayMode = .percent + + 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) + } + + 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: testStatusBar()) + defer { controller.releaseStatusItemsForTesting() } + + let initialItem = try #require(controller.statusItems[.codex]) + controller.statusItems.removeValue(forKey: .codex) + controller.statusBar.removeStatusItem(initialItem) + + var itemSeenByUpdate: NSStatusItem? + let vendedItem = controller._test_vendStatusItem(for: .codex) { _ in + store._setSnapshotForTesting( + UsageSnapshot( + primary: RateWindow( + usedPercent: 23, + windowMinutes: 300, + resetsAt: nil, + resetDescription: nil), + secondary: nil, + updatedAt: Date()), + provider: .codex) + controller.updateIcons() + itemSeenByUpdate = controller.statusItems[.codex] + } + defer { + if let itemSeenByUpdate, itemSeenByUpdate !== vendedItem { + controller.statusBar.removeStatusItem(itemSeenByUpdate) + } + } + + let updatedItem = try #require(itemSeenByUpdate) + #expect(updatedItem === vendedItem) + #expect(controller.statusItems.count == 1) + #expect(controller.statusItems[.codex] === vendedItem) + #expect(vendedItem.button?.title.contains("77%") == true) + } +} diff --git a/Tests/CodexBarTests/StatusMenuAppearanceTests.swift b/Tests/CodexBarTests/StatusMenuAppearanceTests.swift new file mode 100644 index 0000000000..4cf96a17f8 --- /dev/null +++ b/Tests/CodexBarTests/StatusMenuAppearanceTests.swift @@ -0,0 +1,60 @@ +import AppKit +import Testing +@testable import CodexBar + +@MainActor +struct StatusMenuAppearanceTests { + private final class AppearanceTrackingMenu: NSMenu { + var appearanceAssignmentCount = 0 + + override var appearance: NSAppearance? { + didSet { + self.appearanceAssignmentCount += 1 + } + } + } + + @Test + func `pin uses the exact application effective appearance`() { + let menu = NSMenu() + let effectiveAppearance = NSApplication.shared.effectiveAppearance + + StatusMenuAppearance.pin(menu) + + #expect(menu.appearance === effectiveAppearance) + } + + @Test + func `pin reassigns an appearance even when its name is unchanged`() throws { + let menu = AppearanceTrackingMenu() + let appearance = try #require(NSAppearance(named: .aqua)) + menu.appearance = appearance + let assignmentsBeforePin = menu.appearanceAssignmentCount + + StatusMenuAppearance.pin(menu, to: appearance) + + #expect(menu.appearance === appearance) + #expect(menu.appearanceAssignmentCount == assignmentsBeforePin + 1) + } + + @Test + func `submenus inherit each refreshed root appearance`() throws { + let menu = NSMenu() + let submenu = NSMenu() + let item = NSMenuItem(title: "Details", action: nil, keyEquivalent: "") + item.submenu = submenu + menu.addItem(item) + + let lightAppearance = try #require(NSAppearance(named: .aqua)) + StatusMenuAppearance.pin(menu, to: lightAppearance) + #expect(menu.appearance === lightAppearance) + #expect(submenu.appearance == nil) + #expect(submenu.effectiveAppearance.bestMatch(from: [.aqua, .darkAqua]) == .aqua) + + let darkAppearance = try #require(NSAppearance(named: .darkAqua)) + StatusMenuAppearance.pin(menu, to: darkAppearance) + #expect(menu.appearance === darkAppearance) + #expect(submenu.appearance == nil) + #expect(submenu.effectiveAppearance.bestMatch(from: [.aqua, .darkAqua]) == .darkAqua) + } +} diff --git a/Tests/CodexBarTests/StatusMenuClosedPreparationTests.swift b/Tests/CodexBarTests/StatusMenuClosedPreparationTests.swift new file mode 100644 index 0000000000..62628b01ef --- /dev/null +++ b/Tests/CodexBarTests/StatusMenuClosedPreparationTests.swift @@ -0,0 +1,307 @@ +import AppKit +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +@MainActor +private final class ClosedMenuManualRefreshGate { + private var continuation: CheckedContinuation? + private var isOpen = false + + func wait() async { + if self.isOpen { + self.isOpen = false + return + } + await withCheckedContinuation { continuation in + self.continuation = continuation + } + } + + func resume() { + if let continuation = self.continuation { + continuation.resume() + self.continuation = nil + } else { + self.isOpen = true + } + } +} + +extension StatusMenuTests { + @Test + func `stale data refresh suppresses icon attached closed menu preparation`() async { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + let registry = ProviderRegistry.shared + for provider in UsageProvider.allCases { + if let metadata = registry.metadata[provider] { + settings.setProviderEnabled(provider: provider, metadata: metadata, enabled: provider == .codex) + } + } + + 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 + for _ in 0..<20 { + await Task.yield() + } + let menu = controller.makeMenu() + // Simulate a closed menu that was attached by an icon update but has never been opened. + controller.fallbackMenu = menu + controller.statusItem.menu = menu + let key = ObjectIdentifier(menu) + + controller.invalidateMenus(allowStaleContentDuringDataRefresh: true) + controller.prepareAttachedClosedMenusIfNeeded() + for _ in 0..<40 { + await Task.yield() + } + + #expect(controller.openMenus.isEmpty) + #expect(controller.menuVersions[key] == nil) + + controller.menuWillOpen(menu) + defer { controller.menuDidClose(menu) } + + #expect(controller.menuVersions[key] == controller.menuContentVersion) + } + + @Test + func `stale refresh completion requeues required closed menu preparation blocked by refresh`() async { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + let registry = ProviderRegistry.shared + for provider in UsageProvider.allCases { + if let metadata = registry.metadata[provider] { + settings.setProviderEnabled(provider: provider, metadata: metadata, enabled: provider == .codex) + } + } + + 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 + for _ in 0..<20 { + await Task.yield() + } + let menu = controller.makeMenu() + controller.fallbackMenu = menu + controller.statusItem.menu = menu + + controller.populateMenu(menu, provider: nil) + controller.markMenuFresh(menu) + let key = ObjectIdentifier(menu) + let openedVersion = controller.menuVersions[key] + + controller.invalidateMenus() + let requiredVersion = controller.latestRequiredMenuRebuildVersion + store.isRefreshing = true + for _ in 0..<40 where controller.closedMenuRebuildTasks[key] != nil { + await Task.yield() + } + + #expect(requiredVersion > (openedVersion ?? -1)) + #expect(controller.closedMenuRebuildTasks[key] == nil) + #expect(controller.menuVersions[key] == openedVersion) + + store.isRefreshing = false + controller.fallbackMenu = menu + controller.statusItem.menu = menu + controller.invalidateMenus(allowStaleContentDuringDataRefresh: true) + for _ in 0..<40 where controller.menuVersions[key] == openedVersion { + await Task.yield() + } + + #expect(controller.openMenus.isEmpty) + #expect(controller.menuVersions[key] == controller.menuContentVersion) + } + + @Test + func `manual refresh completion requeues required closed menu preparation`() async throws { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + let registry = ProviderRegistry.shared + for provider in UsageProvider.allCases { + if let metadata = registry.metadata[provider] { + settings.setProviderEnabled(provider: provider, metadata: metadata, enabled: provider == .codex) + } + } + + 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.fallbackMenu = menu + controller.statusItem.menu = menu + controller.populateMenu(menu, provider: nil) + controller.markMenuFresh(menu) + let key = ObjectIdentifier(menu) + let initialVersion = controller.menuVersions[key] + + let gate = ClosedMenuManualRefreshGate() + controller._test_manualRefreshOperation = { await gate.wait() } + defer { + gate.resume() + controller._test_manualRefreshOperation = nil + } + controller.refreshNow() + let task = try #require(controller.manualRefreshTasks[.global]) + + controller.invalidateMenus() + for _ in 0..<40 { + await Task.yield() + } + + #expect(controller.menuVersions[key] == initialVersion) + + gate.resume() + await task.value + for _ in 0..<40 where controller.menuVersions[key] == initialVersion { + await Task.yield() + } + + #expect(controller.openMenus.isEmpty) + #expect(controller.menuVersions[key] == controller.menuContentVersion) + } + + @Test + func `closed menu prewarm waits for other menu tracking to end`() 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() } + StatusItemController.setClosedMenuPreparationDelayForTesting(.milliseconds(50)) + defer { StatusItemController.resetClosedMenuPreparationDelayForTesting() } + + controller.menuRefreshEnabledOverrideForTesting = true + let closedMenu = controller.makeMenu(for: .claude) + controller.providerMenus[.claude] = closedMenu + controller.populateMenu(closedMenu, provider: .claude) + controller.markMenuFresh(closedMenu) + let closedKey = ObjectIdentifier(closedMenu) + let closedVersion = controller.menuVersions[closedKey] + + controller.invalidateMenus() + controller.rebuildClosedMenuIfNeeded(closedMenu) + #expect(controller.closedMenuRebuildTasks[closedKey] != nil) + + let visibleMenu = controller.makeMenu(for: .codex) + controller.menuWillOpen(visibleMenu) + try? await Task.sleep(for: .milliseconds(80)) + for _ in 0..<20 where controller.closedMenuRebuildTasks[closedKey] != nil { + await Task.yield() + } + + #expect(controller.menuVersions[closedKey] == closedVersion) + #expect(controller.openMenus[ObjectIdentifier(visibleMenu)] != nil) + + controller.menuDidClose(visibleMenu) + try? await Task.sleep(for: .milliseconds(80)) + for _ in 0..<20 where controller.menuVersions[closedKey] == closedVersion { + await Task.yield() + } + + #expect(controller.openMenus.isEmpty) + #expect(controller.menuVersions[closedKey] == controller.menuContentVersion) + } + + @Test + func `data refresh while persistent menu is open rebuilds on close`() async { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + let registry = ProviderRegistry.shared + for provider in UsageProvider.allCases { + if let metadata = registry.metadata[provider] { + settings.setProviderEnabled(provider: provider, metadata: metadata, enabled: provider == .codex) + } + } + + 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.fallbackMenu = menu + controller.statusItem.menu = menu + + controller.menuWillOpen(menu) + let key = ObjectIdentifier(menu) + let openedVersion = controller.menuVersions[key] + + controller.invalidateMenus(allowStaleContentDuringDataRefresh: true) + #expect(controller.menuContentVersion != openedVersion) + #expect(controller.menuVersions[key] == openedVersion) + + controller.menuDidClose(menu) + for _ in 0..<40 where controller.menuVersions[key] == openedVersion { + await Task.yield() + } + + #expect(controller.openMenus.isEmpty) + #expect(controller.menuVersions[key] == controller.menuContentVersion) + #expect(controller.menuVersions[key] != openedVersion) + } +} diff --git a/Tests/CodexBarTests/StatusMenuCodexCostHistoryRefreshTests.swift b/Tests/CodexBarTests/StatusMenuCodexCostHistoryRefreshTests.swift new file mode 100644 index 0000000000..58c3caef30 --- /dev/null +++ b/Tests/CodexBarTests/StatusMenuCodexCostHistoryRefreshTests.swift @@ -0,0 +1,274 @@ +import AppKit +import CodexBarCore +import Testing +@testable import CodexBar + +@MainActor +@Suite(.serialized) +struct StatusMenuCodexCostHistoryRefreshTests { + @Test + func `codex cost history preserves identity when hidden project nested data changes`() throws { + try self.assertCodexCostHistoryPreservesIdentity( + mutate: { snapshot in + var projects = snapshot.projects + guard projects.count > 5 else { return snapshot } + projects[5] = Self.makeCodexProject( + index: 5, + sourceCount: 1, + nestedDailyCost: 99.0) + return Self.copySnapshot(snapshot, projects: projects) + }) + } + + @Test + func `codex cost history preserves identity when visible project nested data changes`() throws { + try self.assertCodexCostHistoryPreservesIdentity( + mutate: { snapshot in + var projects = snapshot.projects + guard !projects.isEmpty else { return snapshot } + projects[0] = Self.makeCodexProject( + index: 0, + sourceCount: 1, + nestedDailyCost: 99.0) + return Self.copySnapshot(snapshot, projects: projects) + }) + } + + @Test + func `codex cost history rebuilds when daily cost changes`() throws { + try self.assertCodexCostHistoryRebuilds( + mutate: { snapshot in + Self.copySnapshot(snapshot, dailyCost: 9.87) + }) + } + + @Test + func `hydrated codex cost history stores the same fingerprint as refresh`() throws { + let previousMenuCardRendering = StatusItemController.menuCardRenderingEnabled + StatusItemController.menuCardRenderingEnabled = true + defer { StatusItemController.menuCardRenderingEnabled = previousMenuCardRendering } + + let settings = Self.makeSettings() + settings.costUsageEnabled = true + Self.enableOnly(settings, provider: .codex) + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + store._setTokenSnapshotForTesting(Self.makeCodexCostSnapshot(), provider: .codex) + + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: .system) + defer { controller.releaseStatusItemsForTesting() } + + let width = StatusItemController.menuCardBaseWidth + let submenu = controller.makeHostedSubviewPlaceholderMenu( + chartID: StatusItemController.costHistoryChartID, + provider: .codex, + width: width) + controller.menuWillOpen(submenu) + + let stored = try #require(controller._storedHostedSubviewRenderSignatureForTesting(menu: submenu)) + let recomputed = try #require(controller._hostedSubviewRenderSignatureForTesting(menu: submenu, width: width)) + #expect(stored == recomputed) + + controller.refreshHostedSubviewMenu(submenu) + #expect(controller._storedHostedSubviewRenderSignatureForTesting(menu: submenu) == recomputed) + } + + private func assertCodexCostHistoryPreservesIdentity( + mutate: (CostUsageTokenSnapshot) -> CostUsageTokenSnapshot) throws + { + let previousMenuCardRendering = StatusItemController.menuCardRenderingEnabled + StatusItemController.menuCardRenderingEnabled = true + defer { StatusItemController.menuCardRenderingEnabled = previousMenuCardRendering } + + let settings = Self.makeSettings() + settings.costUsageEnabled = true + Self.enableOnly(settings, provider: .codex) + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + store._setTokenSnapshotForTesting(Self.makeCodexCostSnapshot(), provider: .codex) + + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: .system) + defer { controller.releaseStatusItemsForTesting() } + + let submenu = controller.makeHostedSubviewPlaceholderMenu( + chartID: StatusItemController.costHistoryChartID, + provider: .codex, + width: StatusItemController.menuCardBaseWidth) + controller.menuWillOpen(submenu) + + let hydratedView = try #require(submenu.items.first?.view) + store._setTokenSnapshotForTesting(mutate(Self.makeCodexCostSnapshot()), provider: .codex) + controller.refreshHostedSubviewMenu(submenu) + + #expect(submenu.items.first?.view === hydratedView) + } + + private func assertCodexCostHistoryRebuilds( + mutate: (CostUsageTokenSnapshot) -> CostUsageTokenSnapshot) throws + { + let previousMenuCardRendering = StatusItemController.menuCardRenderingEnabled + StatusItemController.menuCardRenderingEnabled = true + defer { StatusItemController.menuCardRenderingEnabled = previousMenuCardRendering } + + let settings = Self.makeSettings() + settings.costUsageEnabled = true + Self.enableOnly(settings, provider: .codex) + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + store._setTokenSnapshotForTesting(Self.makeCodexCostSnapshot(), provider: .codex) + + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: .system) + defer { controller.releaseStatusItemsForTesting() } + + let submenu = controller.makeHostedSubviewPlaceholderMenu( + chartID: StatusItemController.costHistoryChartID, + provider: .codex, + width: StatusItemController.menuCardBaseWidth) + controller.menuWillOpen(submenu) + + let hydratedView = try #require(submenu.items.first?.view) + store._setTokenSnapshotForTesting(mutate(Self.makeCodexCostSnapshot()), provider: .codex) + controller.refreshHostedSubviewMenu(submenu) + + #expect(submenu.items.first?.view !== hydratedView) + } + + private static func makeSettings() -> SettingsStore { + let suite = "StatusMenuCodexCostHistoryRefreshTests-\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suite)! + defaults.removePersistentDomain(forName: suite) + return SettingsStore( + userDefaults: defaults, + configStore: testConfigStore(suiteName: suite), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + } + + private static func enableOnly(_ settings: SettingsStore, provider enabledProvider: UsageProvider) { + 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 == enabledProvider) + } + } + + private static func makeCodexCostSnapshot( + dailyCost: Double = 1.23, + projectCount: Int = 6) -> CostUsageTokenSnapshot + { + CostUsageTokenSnapshot( + sessionTokens: 123, + sessionCostUSD: 0.12, + last30DaysTokens: 123, + last30DaysCostUSD: dailyCost, + daily: [ + CostUsageDailyReport.Entry( + date: "2025-12-23", + inputTokens: nil, + outputTokens: nil, + totalTokens: 123, + costUSD: dailyCost, + modelsUsed: nil, + modelBreakdowns: nil), + ], + projects: (0.. CostUsageTokenSnapshot + { + let daily = snapshot.daily.map { entry in + CostUsageDailyReport.Entry( + date: entry.date, + inputTokens: entry.inputTokens, + outputTokens: entry.outputTokens, + totalTokens: entry.totalTokens, + costUSD: dailyCost ?? entry.costUSD, + modelsUsed: entry.modelsUsed, + modelBreakdowns: entry.modelBreakdowns) + } + return CostUsageTokenSnapshot( + sessionTokens: snapshot.sessionTokens, + sessionCostUSD: snapshot.sessionCostUSD, + last30DaysTokens: snapshot.last30DaysTokens, + last30DaysCostUSD: dailyCost ?? snapshot.last30DaysCostUSD, + currencyCode: snapshot.currencyCode, + historyDays: snapshot.historyDays, + historyLabel: snapshot.historyLabel, + daily: daily, + projects: projects ?? snapshot.projects, + updatedAt: snapshot.updatedAt) + } + + private static func makeCodexProject( + index: Int, + sourceCount: Int, + nestedDailyCost: Double = 0.01) -> CostUsageProjectBreakdown + { + let nestedDaily = [ + CostUsageDailyReport.Entry( + date: "2025-12-23", + inputTokens: 1, + outputTokens: 1, + totalTokens: 10, + costUSD: nestedDailyCost, + modelsUsed: ["nested"], + modelBreakdowns: [ + CostUsageDailyReport.ModelBreakdown( + modelName: "nested-model", + costUSD: nestedDailyCost, + totalTokens: 10), + ]), + ] + return CostUsageProjectBreakdown( + name: "Project-\(index)", + path: "/tmp/project-\(index)", + totalTokens: 100 + index, + totalCostUSD: 1.0 + Double(index), + daily: nestedDaily, + modelBreakdowns: [ + CostUsageDailyReport.ModelBreakdown( + modelName: "project-model", + costUSD: nestedDailyCost, + totalTokens: 10), + ], + sources: (0.. UsageSnapshot diff --git a/Tests/CodexBarTests/StatusMenuCostMenuCardTests.swift b/Tests/CodexBarTests/StatusMenuCostMenuCardTests.swift index b1f73389ad..0f35371dfa 100644 --- a/Tests/CodexBarTests/StatusMenuCostMenuCardTests.swift +++ b/Tests/CodexBarTests/StatusMenuCostMenuCardTests.swift @@ -1,3 +1,6 @@ +import AppKit +import CodexBarCore +import SwiftUI import Testing @testable import CodexBar @@ -5,7 +8,7 @@ import Testing @Suite(.serialized) struct StatusMenuCostMenuCardTests { @Test - func `cost menu fallback keeps visible details in attributed title`() { + func `cost menu omits detail text beside a history submenu`() { let tokenUsage = UsageMenuCardView.Model.TokenUsageSection( sessionLine: "Today: $74.83 - 87M tokens", monthLine: "Last 30 days: $4,279.64 - 5.7B tokens", @@ -13,17 +16,48 @@ struct StatusMenuCostMenuCardTests { errorLine: "Cost refresh failed.", errorCopyText: nil) - let visibleLines = StatusItemController.costMenuVisibleDetailLines(tokenUsage: tokenUsage) + let visibleLines = StatusItemController.costMenuVisibleDetailLines( + provider: .codex, + tokenUsage: tokenUsage, + hasSubmenu: true) + #expect(visibleLines == []) + #expect(StatusItemController.costMenuVisibleDetailLines( + provider: .claude, + tokenUsage: tokenUsage, + hasSubmenu: true) == []) + + let fallbackTitle = StatusItemController.costMenuFallbackAttributedTitle( + title: "Cost", + visibleDetailLines: visibleLines) + #expect(fallbackTitle.string == "Cost") + } + + @Test + func `cost menu preserves summary lines without history submenu`() { + let tokenUsage = UsageMenuCardView.Model.TokenUsageSection( + sessionLine: "Today: $74.83 - 87M tokens", + monthLine: "Last 30 days: $4,279.64 - 5.7B tokens", + hintLine: "Costs are estimated from local usage.", + errorLine: "Cost refresh failed.", + errorCopyText: nil) + + let visibleLines = StatusItemController.costMenuVisibleDetailLines( + provider: .codex, + tokenUsage: tokenUsage, + hasSubmenu: false) #expect(visibleLines == [ "Today: $74.83 - 87M tokens", "Last 30 days: $4,279.64 - 5.7B tokens", + "Costs are estimated from local usage.", "Cost refresh failed.", ]) - let fallbackTitle = StatusItemController.costMenuFallbackAttributedTitle(visibleDetailLines: visibleLines) - #expect(fallbackTitle.string.contains("Cost")) + let fallbackTitle = StatusItemController.costMenuFallbackAttributedTitle( + title: "Cost", + visibleDetailLines: visibleLines) #expect(fallbackTitle.string.contains("Today: $74.83 - 87M tokens")) #expect(fallbackTitle.string.contains("Last 30 days: $4,279.64 - 5.7B tokens")) + #expect(fallbackTitle.string.contains("Costs are estimated from local usage.")) #expect(fallbackTitle.string.contains("Cost refresh failed.")) } @@ -36,11 +70,139 @@ struct StatusMenuCostMenuCardTests { errorLine: "Cost refresh failed.", errorCopyText: nil) - #expect(StatusItemController.costMenuTooltipLines(tokenUsage: tokenUsage) == [ + #expect(StatusItemController.costMenuTooltipLines(provider: .codex, tokenUsage: tokenUsage) == [ "Today: $1.00", "Last 30 days: $9.00", "Costs are estimated from local usage.", "Cost refresh failed.", ]) + #expect(StatusItemController.costMenuTooltipLines(provider: .claude, tokenUsage: tokenUsage) == [ + "Today: $1.00", + "Last 30 days: $9.00", + "Costs are estimated from local usage.", + "Cost refresh failed.", + ]) + } + + @Test + func `cost menu with history submenu omits native tooltip`() { + let settings = self.makeSettings() + 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() } + + let tokenUsage = UsageMenuCardView.Model.TokenUsageSection( + sessionLine: "Today: $1.00", + monthLine: "Last 30 days: $9.00", + hintLine: "Costs are estimated from local usage.", + errorLine: nil, + errorCopyText: nil) + let submenu = NSMenu() + + let item = controller.makeCostMenuCardItem( + model: self.makeModel(tokenUsage: tokenUsage), + submenu: submenu, + width: StatusItemController.menuCardBaseWidth) + + #expect(item.submenu === submenu) + #expect(item.toolTip == nil) + } + + @Test + func `rendered cost menu keeps long dynamic details inside fixed row width`() throws { + let previousRendering = StatusItemController.menuCardRenderingEnabled + StatusItemController.menuCardRenderingEnabled = true + defer { StatusItemController.menuCardRenderingEnabled = previousRendering } + + let settings = self.makeSettings() + 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() } + + let width = StatusItemController.menuCardBaseWidth + let tokenUsage = UsageMenuCardView.Model.TokenUsageSection( + sessionLine: "Today: $227.42 - 267M tokens - " + String(repeating: "wide ", count: 20), + monthLine: "Last 30 days: $52,431.09 - 77B tokens - " + String(repeating: "wide ", count: 20), + hintLine: "Costs are estimated from local usage.", + errorLine: nil, + errorCopyText: nil) + let model = self.makeModel(tokenUsage: tokenUsage) + + // No history submenu — detail lines are visible and must be clipped to the row width. + let item = controller.makeCostMenuCardItem( + model: model, + submenu: nil, + width: width) + let view = try #require(item.view) + + #expect(view is any MenuCardMeasuring) + #expect(abs(view.frame.width - width) <= 0.5) + #expect(item.title == "Cost") + #expect(item.toolTip?.contains("$52,431.09") == true) + #expect(item.submenu == nil) + } + + @Test + func `cost menu title stays consistent across providers`() { + #expect(StatusItemController.costMenuTitleForProvider(.codex) == "Cost") + #expect(StatusItemController.costMenuTitleForProvider(.claude) == "Cost") + #expect(StatusItemController.costMenuTitleForProvider(.mistral) == "Cost") + } + + private func makeSettings() -> SettingsStore { + let suite = "StatusMenuCostMenuCardTests-\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suite)! + defaults.removePersistentDomain(forName: suite) + return SettingsStore( + userDefaults: defaults, + configStore: testConfigStore(suiteName: suite), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + } + + private func makeModel( + tokenUsage: UsageMenuCardView.Model.TokenUsageSection) -> UsageMenuCardView.Model + { + UsageMenuCardView.Model( + provider: .codex, + providerName: "Codex", + email: "user@example.com", + subtitleText: "Updated now", + subtitleStyle: .info, + planText: "Pro", + metrics: [], + usageNotes: [], + openAIAPIUsage: nil, + inlineUsageDashboard: nil, + creditsText: nil, + creditsRemaining: nil, + creditsProgressPercent: nil, + creditsScaleText: nil, + creditsHintText: nil, + creditsHintCopyText: nil, + providerCost: nil, + tokenUsage: tokenUsage, + placeholder: nil, + progressColor: .blue) } } diff --git a/Tests/CodexBarTests/StatusMenuCostSummaryDisplayStyleTests.swift b/Tests/CodexBarTests/StatusMenuCostSummaryDisplayStyleTests.swift new file mode 100644 index 0000000000..7ecaa1164d --- /dev/null +++ b/Tests/CodexBarTests/StatusMenuCostSummaryDisplayStyleTests.swift @@ -0,0 +1,76 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +@MainActor +extension StatusMenuTests { + @Test + func `cost summary display style controls codex menu presentation`() throws { + self.disableMenuCardsForTesting() + + for style in CostSummaryDisplayStyle.allCases { + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.selectedMenuProvider = .codex + settings.costUsageEnabled = true + settings.costSummaryDisplayStyle = style + + 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) + } + + let fetcher = UsageFetcher() + let store = UsageStore( + fetcher: fetcher, + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings) + store._setTokenSnapshotForTesting( + CostUsageTokenSnapshot( + sessionTokens: 85_000_000, + sessionCostUSD: 91.63, + last30DaysTokens: 1_100_000_000, + last30DaysCostUSD: 1001.27, + daily: [ + CostUsageDailyReport.Entry( + date: "2026-06-23", + inputTokens: nil, + outputTokens: nil, + totalTokens: 85_000_000, + costUSD: 91.63, + modelsUsed: ["fictional-test-model"], + modelBreakdowns: nil), + ], + updatedAt: Date()), + provider: .codex) + + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + + let model = try #require(controller.menuCardModel(for: .codex)) + let providerDetailModel = ProvidersPane(settings: settings, store: store) + ._test_menuCardModel(for: .codex) + let menu = controller.makeMenu() + controller.menuWillOpen(menu) + + let ids = menu.items.compactMap { $0.representedObject as? String } + #expect((model.inlineUsageDashboard != nil) == style.showsInlineSummary) + #expect((model.tokenUsage != nil) == style.showsCostSubmenu) + #expect(providerDetailModel.tokenUsage != nil) + #expect(ids.contains("menuCardCost") == style.showsCostSubmenu) + } + } +} diff --git a/Tests/CodexBarTests/StatusMenuHeightCacheTests.swift b/Tests/CodexBarTests/StatusMenuHeightCacheTests.swift new file mode 100644 index 0000000000..18f27e501d --- /dev/null +++ b/Tests/CodexBarTests/StatusMenuHeightCacheTests.swift @@ -0,0 +1,302 @@ +import CodexBarCore +import Foundation +import SwiftUI +import Testing +@testable import CodexBar + +extension StatusMenuTests { + @Test + func `menu card sizing uses displayed hosting view`() throws { + let previousMenuCardRendering = StatusItemController.menuCardRenderingEnabled + StatusItemController.menuCardRenderingEnabled = true + defer { + StatusItemController.menuCardRenderingEnabled = previousMenuCardRendering + } + + let controller = self.makeHeightCacheController() + defer { controller.releaseStatusItemsForTesting() } + + let counter = MenuCardRepresentableCounter() + let item = controller.makeMenuCardItem( + CountingMenuCardRepresentable(counter: counter), + id: "countingCard-\(UUID().uuidString)", + width: 320, + heightCacheScope: "counting", + heightCacheFingerprint: "counting-\(UUID().uuidString)") + let view = try #require(item.view) + + view.layoutSubtreeIfNeeded() + + #expect(counter.makeViewCount == 1) + } + + @Test + func `menu card height cache is reused for stable card content`() { + let previousMenuCardRendering = StatusItemController.menuCardRenderingEnabled + StatusItemController.menuCardRenderingEnabled = true + defer { + StatusItemController.menuCardRenderingEnabled = previousMenuCardRendering + } + + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + 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) + } + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + let controller = StatusItemController( + store: store, + settings: settings, + account: AccountInfo(email: nil, plan: nil), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + + let menu = controller.makeMenu() + controller.populateMenu(menu, provider: .codex) + let firstKeys = Set(controller.menuCardHeightCache.keys) + + #expect(!firstKeys.isEmpty) + + controller.populateMenu(menu, provider: .codex) + #expect(Set(controller.menuCardHeightCache.keys) == firstKeys) + + controller.invalidateMenus() + #expect(Set(controller.menuCardHeightCache.keys) == firstKeys) + } + + @Test + func `standard menu width cache is reused for stable action rows`() { + let controller = self.makeHeightCacheController() + defer { controller.releaseStatusItemsForTesting() } + + let menu = controller.makeMenu() + controller.populateMenu(menu, provider: .codex) + let firstCache = controller.measuredStandardMenuWidthCache + + #expect(!firstCache.isEmpty) + #expect(firstCache.keys.allSatisfy { + $0.contains("font=\(StatusItemController.menuCardHeightTextScaleToken())") + }) + + controller.populateMenu(menu, provider: .codex) + #expect(controller.measuredStandardMenuWidthCache == firstCache) + } + + @Test + func `fingerprinted menu card height cache survives content version invalidation`() { + let controller = self.makeHeightCacheController() + defer { controller.releaseStatusItemsForTesting() } + + var measureCount = 0 + let first = controller.cachedMenuCardHeight( + for: "menuCard", + scope: UsageProvider.codex.rawValue, + width: 320, + fingerprint: "content:stable") + { + measureCount += 1 + return 42 + } + + controller.invalidateMenus() + + let second = controller.cachedMenuCardHeight( + for: "menuCard", + scope: UsageProvider.codex.rawValue, + width: 320, + fingerprint: "content:stable") + { + measureCount += 1 + return 99 + } + + #expect(first == 42) + #expect(second == 42) + #expect(measureCount == 1) + } + + @Test + func `fingerprinted menu card height cache remeasures when content changes`() { + let controller = self.makeHeightCacheController() + defer { controller.releaseStatusItemsForTesting() } + + var measureCount = 0 + let first = controller.cachedMenuCardHeight( + for: "menuCard", + scope: UsageProvider.codex.rawValue, + width: 320, + fingerprint: "content:a") + { + measureCount += 1 + return 42 + } + let second = controller.cachedMenuCardHeight( + for: "menuCard", + scope: UsageProvider.codex.rawValue, + width: 320, + fingerprint: "content:b") + { + measureCount += 1 + return 99 + } + + #expect(first == 42) + #expect(second == 99) + #expect(measureCount == 2) + } + + @Test + func `unfingerprinted menu card height cache remains content version scoped`() { + let controller = self.makeHeightCacheController() + defer { controller.releaseStatusItemsForTesting() } + + var measureCount = 0 + let first = controller.cachedMenuCardHeight( + for: "menuCard", + scope: UsageProvider.codex.rawValue, + width: 320) + { + measureCount += 1 + return 42 + } + + controller.invalidateMenus() + + let second = controller.cachedMenuCardHeight( + for: "menuCard", + scope: UsageProvider.codex.rawValue, + width: 320) + { + measureCount += 1 + return 99 + } + + #expect(first == 42) + #expect(second == 99) + #expect(measureCount == 2) + } + + @Test + func `menu invalidation prunes old version scoped height cache entries`() { + let controller = self.makeHeightCacheController() + defer { controller.releaseStatusItemsForTesting() } + + _ = controller.cachedMenuCardHeight( + for: "versioned", + scope: UsageProvider.codex.rawValue, + width: 320) + { + 42 + } + _ = controller.cachedMenuCardHeight( + for: "fingerprinted", + scope: UsageProvider.codex.rawValue, + width: 320, + fingerprint: "content:stable") + { + 99 + } + + controller.invalidateMenus() + + #expect(controller.menuCardHeightCache.keys.allSatisfy { !$0.fingerprint.hasPrefix("version:") }) + #expect(controller.menuCardHeightCache.keys.contains { $0.fingerprint == "content:stable" }) + } + + @Test + func `menu card height cache scopes same row ids by provider`() { + let previousMenuCardRendering = StatusItemController.menuCardRenderingEnabled + StatusItemController.menuCardRenderingEnabled = true + defer { + StatusItemController.menuCardRenderingEnabled = previousMenuCardRendering + } + + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + 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 || provider == .claude) + } + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + store._setSnapshotForTesting( + UsageSnapshot( + primary: RateWindow( + usedPercent: 12, + windowMinutes: 300, + resetsAt: nil, + resetDescription: nil), + secondary: nil, + tertiary: nil, + updatedAt: Date(), + identity: ProviderIdentitySnapshot( + providerID: .claude, + accountEmail: "claude@example.com", + accountOrganization: nil, + loginMethod: "Claude Pro")), + provider: .claude) + let controller = StatusItemController( + store: store, + settings: settings, + account: AccountInfo(email: nil, plan: nil), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + + let menu = controller.makeMenu() + controller.populateMenu(menu, provider: .codex) + controller.populateMenu(menu, provider: .claude) + + let scopes = Set(controller.menuCardHeightCache.keys.map(\.scope)) + #expect(scopes.contains(UsageProvider.codex.rawValue)) + #expect(scopes.contains(UsageProvider.claude.rawValue)) + } + + private func makeHeightCacheController() -> StatusItemController { + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + return StatusItemController( + store: store, + settings: settings, + account: AccountInfo(email: nil, plan: nil), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + } +} + +@MainActor +private final class MenuCardRepresentableCounter { + var makeViewCount = 0 +} + +private struct CountingMenuCardRepresentable: NSViewRepresentable { + let counter: MenuCardRepresentableCounter + + func makeNSView(context: Context) -> NSTextField { + self.counter.makeViewCount += 1 + return NSTextField(labelWithString: "Counted") + } + + func updateNSView(_ nsView: NSTextField, context: Context) { + _ = nsView + _ = context + } +} diff --git a/Tests/CodexBarTests/StatusMenuHighlightTests.swift b/Tests/CodexBarTests/StatusMenuHighlightTests.swift index 96fccbb561..a2d7a3bd3c 100644 --- a/Tests/CodexBarTests/StatusMenuHighlightTests.swift +++ b/Tests/CodexBarTests/StatusMenuHighlightTests.swift @@ -18,6 +18,7 @@ extension StatusMenuTests { self.disableMenuCardsForTesting() let settings = self.makeSettings() let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + store._cancelPlanUtilizationHistoryLoadForTesting() let controller = StatusItemController( store: store, settings: settings, @@ -52,4 +53,502 @@ extension StatusMenuTests { #expect(secondView.states == [true]) #expect(thirdView.states.isEmpty) } + + @Test + func `native highlight preserves coalesced baseline resync until pointer leaves native rows`() async { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + store._cancelPlanUtilizationHistoryLoadForTesting() + 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) + defer { controller.menuDidClose(menu) } + let key = ObjectIdentifier(menu) + controller.cancelMenuWork(key) + controller.openMenus[key] = menu + let planUsage = NSMenuItem(title: "Plan Usage", action: nil, keyEquivalent: "") + planUsage.isEnabled = true + let cost = NSMenuItem(title: "Cost", action: nil, keyEquivalent: "") + cost.isEnabled = true + menu.addItem(planUsage) + menu.addItem(cost) + + controller.menu(menu, willHighlight: planUsage) + #expect(controller.highlightedMenuItems[key] === planUsage) + #expect(controller.isNativeMenuItemHighlighted(in: menu)) + controller.lastMenuAdjunctReadinessSignature = "stale-baseline" + controller.menuSession.invalidate(allowsStaleContent: false, requiresRebuild: true) + + var rebuildCount = 0 + controller._test_openMenuRebuildObserver = { _ in rebuildCount += 1 } + defer { controller._test_openMenuRebuildObserver = nil } + controller.scheduleOpenMenuRebuildIfStillVisible( + menu, + provider: .codex, + resyncReadinessBaselineAfterRebuild: true) + controller.scheduleOpenMenuRebuildIfStillVisible(menu, provider: .codex) + for _ in 0..<20 where controller.nativeHighlightDeferredMenuRebuilds[key] == nil { + await Task.yield() + } + + #expect(rebuildCount == 0) + #expect(controller.nativeHighlightDeferredMenuRebuilds[key] != nil) + #expect(controller.pendingMenuBaselineResyncs.contains(key)) + #expect(controller.menuNeedsRefresh(menu)) + + controller.menu(menu, willHighlight: cost) + for _ in 0..<20 { + await Task.yield() + } + #expect(rebuildCount == 0) + #expect(controller.nativeHighlightDeferredMenuRebuilds[key] != nil) + + controller.menu(menu, willHighlight: nil) + for _ in 0..<20 where rebuildCount == 0 { + await Task.yield() + } + + #expect(rebuildCount == 1) + #expect(controller.nativeHighlightDeferredMenuRebuilds[key] == nil) + #expect(!controller.pendingMenuBaselineResyncs.contains(key)) + #expect(!controller.menuNeedsRefresh(menu)) + #expect(controller.lastMenuAdjunctReadinessSignature == controller.menuAdjunctReadinessSignature()) + } + + @Test + func `native highlight preserves explicit rebuild even when menu is already fresh`() async { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + store._cancelPlanUtilizationHistoryLoadForTesting() + 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.populateMenu(menu, provider: .codex) + controller.markMenuFresh(menu) + let key = ObjectIdentifier(menu) + controller.openMenus[key] = menu + defer { controller.menuDidClose(menu) } + + let nativeItem = NSMenuItem(title: "Plan Usage", action: nil, keyEquivalent: "") + nativeItem.isEnabled = true + menu.addItem(nativeItem) + controller.menu(menu, willHighlight: nativeItem) + #expect(!controller.menuNeedsRefresh(menu)) + + var rebuildCount = 0 + controller._test_openMenuRebuildObserver = { _ in rebuildCount += 1 } + defer { controller._test_openMenuRebuildObserver = nil } + controller.scheduleOpenMenuRebuildIfStillVisible(menu, provider: .claude) + for _ in 0..<20 where controller.nativeHighlightDeferredMenuRebuilds[key] == nil { + await Task.yield() + } + + #expect(rebuildCount == 0) + #expect(controller.nativeHighlightDeferredMenuRebuilds[key]?.provider == .claude) + #expect(!controller.menuNeedsRefresh(menu)) + + controller.menu(menu, willHighlight: nil) + for _ in 0..<20 where rebuildCount == 0 { + await Task.yield() + } + + #expect(rebuildCount == 1) + #expect(controller.nativeHighlightDeferredMenuRebuilds[key] == nil) + #expect(!controller.menuNeedsRefresh(menu)) + } + + @Test + func `hosted submenu close resumes deferred explicit rebuild on fresh parent`() async { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + store._cancelPlanUtilizationHistoryLoadForTesting() + let controller = StatusItemController( + store: store, + settings: settings, + account: UsageFetcher().loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + controller.menuRefreshEnabledOverrideForTesting = true + + let parent = controller.makeMenu() + controller.populateMenu(parent, provider: .codex) + controller.markMenuFresh(parent) + let parentKey = ObjectIdentifier(parent) + controller.openMenus[parentKey] = parent + defer { controller.menuDidClose(parent) } + + let nativeItem = NSMenuItem(title: "Plan Usage", action: nil, keyEquivalent: "") + nativeItem.isEnabled = true + parent.addItem(nativeItem) + controller.menu(parent, willHighlight: nativeItem) + + var rebuildCount = 0 + controller._test_openMenuRebuildObserver = { menu in + if menu === parent { + rebuildCount += 1 + } + } + defer { controller._test_openMenuRebuildObserver = nil } + controller.scheduleOpenMenuRebuildIfStillVisible(parent, provider: .claude) + for _ in 0..<20 where controller.nativeHighlightDeferredMenuRebuilds[parentKey] == nil { + await Task.yield() + } + + #expect(rebuildCount == 0) + #expect(controller.nativeHighlightDeferredMenuRebuilds[parentKey]?.provider == .claude) + #expect(!controller.menuNeedsRefresh(parent)) + + let submenu = controller.makeHostedSubviewPlaceholderMenu( + chartID: StatusItemController.costHistoryChartID, + provider: .codex) + let submenuKey = ObjectIdentifier(submenu) + controller.openMenus[submenuKey] = submenu + controller.menu(parent, willHighlight: nil) + for _ in 0..<20 { + await Task.yield() + } + + #expect(rebuildCount == 0) + #expect(controller.nativeHighlightDeferredMenuRebuilds[parentKey]?.provider == .claude) + #expect(!controller.menuNeedsRefresh(parent)) + + controller.menuDidClose(submenu) + for _ in 0..<40 where rebuildCount == 0 { + await Task.yield() + } + + #expect(controller.openMenus[submenuKey] == nil) + #expect(rebuildCount == 1) + #expect(controller.nativeHighlightDeferredMenuRebuilds[parentKey] == nil) + #expect(!controller.menuNeedsRefresh(parent)) + } + + @Test + func `hosted submenu close keeps explicit rebuild ahead of dirty parent refresh`() async { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + store._cancelPlanUtilizationHistoryLoadForTesting() + let controller = StatusItemController( + store: store, + settings: settings, + account: UsageFetcher().loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + controller.menuRefreshEnabledOverrideForTesting = true + + let parent = controller.makeMenu() + controller.populateMenu(parent, provider: .codex) + controller.markMenuFresh(parent) + let parentKey = ObjectIdentifier(parent) + controller.openMenus[parentKey] = parent + defer { controller.menuDidClose(parent) } + + let nativeItem = NSMenuItem(title: "Plan Usage", action: nil, keyEquivalent: "") + nativeItem.isEnabled = true + parent.addItem(nativeItem) + controller.menu(parent, willHighlight: nativeItem) + + var rebuildCount = 0 + controller._test_openMenuRebuildObserver = { menu in + if menu === parent { + rebuildCount += 1 + } + } + defer { controller._test_openMenuRebuildObserver = nil } + controller.scheduleOpenMenuRebuildIfStillVisible(parent, provider: .claude) + for _ in 0..<20 where controller.nativeHighlightDeferredMenuRebuilds[parentKey] == nil { + await Task.yield() + } + + let submenu = controller.makeHostedSubviewPlaceholderMenu( + chartID: StatusItemController.costHistoryChartID, + provider: .codex) + controller.openMenus[ObjectIdentifier(submenu)] = submenu + controller.menuSession.invalidate(allowsStaleContent: false, requiresRebuild: true) + #expect(controller.menuNeedsRefresh(parent)) + + controller.menuDidClose(submenu) + for _ in 0..<40 { + await Task.yield() + } + + #expect(rebuildCount == 0) + #expect(controller.nativeHighlightDeferredMenuRebuilds[parentKey]?.provider == .claude) + #expect(controller.menuNeedsRefresh(parent)) + + controller.menu(parent, willHighlight: nil) + for _ in 0..<40 where rebuildCount == 0 { + await Task.yield() + } + + #expect(rebuildCount == 1) + #expect(controller.nativeHighlightDeferredMenuRebuilds[parentKey] == nil) + #expect(!controller.menuNeedsRefresh(parent)) + } + + @Test + func `hosted submenu close preserves pending parent baseline resync`() async { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + store._cancelPlanUtilizationHistoryLoadForTesting() + let controller = StatusItemController( + store: store, + settings: settings, + account: UsageFetcher().loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + controller.menuRefreshEnabledOverrideForTesting = true + + let parent = controller.makeMenu() + controller.populateMenu(parent, provider: .codex) + controller.markMenuFresh(parent) + let parentKey = ObjectIdentifier(parent) + controller.openMenus[parentKey] = parent + defer { controller.menuDidClose(parent) } + + let submenu = controller.makeHostedSubviewPlaceholderMenu( + chartID: StatusItemController.costHistoryChartID, + provider: .codex) + let submenuKey = ObjectIdentifier(submenu) + controller.openMenus[submenuKey] = submenu + + controller.lastMenuAdjunctReadinessSignature = "stale-baseline" + controller.menuSession.invalidate(allowsStaleContent: false, requiresRebuild: true) + var rebuildCount = 0 + controller._test_openMenuRebuildObserver = { menu in + if menu === parent { + rebuildCount += 1 + } + } + defer { controller._test_openMenuRebuildObserver = nil } + + controller.scheduleOpenMenuRebuildIfStillVisible( + parent, + provider: .codex, + resyncReadinessBaselineAfterRebuild: true) + for _ in 0..<20 where controller.openMenuRebuildTasks[parentKey] != nil { + await Task.yield() + } + + #expect(rebuildCount == 0) + #expect(controller.pendingMenuBaselineResyncs.contains(parentKey)) + #expect(controller.menuNeedsRefresh(parent)) + + controller.menuDidClose(submenu) + for _ in 0..<40 where rebuildCount == 0 { + await Task.yield() + } + + #expect(controller.openMenus[submenuKey] == nil) + #expect(rebuildCount == 1) + #expect(!controller.pendingMenuBaselineResyncs.contains(parentKey)) + #expect(!controller.menuNeedsRefresh(parent)) + #expect(controller.lastMenuAdjunctReadinessSignature == controller.menuAdjunctReadinessSignature()) + } + + @Test + func `menu close clears native highlight deferral and pending baseline resync`() async { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + store._cancelPlanUtilizationHistoryLoadForTesting() + let controller = StatusItemController( + store: store, + settings: settings, + account: UsageFetcher().loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + + let menu = controller.makeMenu() + let key = ObjectIdentifier(menu) + controller.openMenus[key] = menu + let nativeItem = NSMenuItem(title: "Settings", action: nil, keyEquivalent: "") + nativeItem.isEnabled = true + menu.addItem(nativeItem) + controller.menu(menu, willHighlight: nativeItem) + controller.menuSession.invalidate(allowsStaleContent: false, requiresRebuild: true) + + var rebuildCount = 0 + controller._test_openMenuRebuildObserver = { _ in rebuildCount += 1 } + defer { controller._test_openMenuRebuildObserver = nil } + controller.scheduleOpenMenuRebuildIfStillVisible( + menu, + provider: .codex, + resyncReadinessBaselineAfterRebuild: true) + for _ in 0..<20 where controller.nativeHighlightDeferredMenuRebuilds[key] == nil { + await Task.yield() + } + + #expect(controller.nativeHighlightDeferredMenuRebuilds[key] != nil) + #expect(controller.pendingMenuBaselineResyncs.contains(key)) + controller.menuDidClose(menu) + for _ in 0..<10 { + await Task.yield() + } + + #expect(rebuildCount == 0) + #expect(controller.openMenus[key] == nil) + #expect(controller.highlightedMenuItems[key] == nil) + #expect(controller.nativeHighlightDeferredMenuRebuilds[key] == nil) + #expect(!controller.pendingMenuBaselineResyncs.contains(key)) + #expect(controller.openMenuRebuildTasks[key] == nil) + #expect(controller.openMenuRebuildRequests.tokens[key] == nil) + } + + @Test + func `hosted native highlight defers signature changing refresh until pointer leaves`() async throws { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = true + settings.refreshFrequency = .manual + settings.mergeIcons = false + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + store._cancelPlanUtilizationHistoryLoadForTesting() + let controller = StatusItemController( + store: store, + settings: settings, + account: UsageFetcher().loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + controller.menuRefreshEnabledOverrideForTesting = true + + let submenu = NSMenu() + #expect(controller.appendStatusComponentsItem( + to: submenu, + provider: .codex, + width: StatusItemController.menuCardBaseWidth)) + let key = ObjectIdentifier(submenu) + controller.openMenus[key] = submenu + defer { controller.menuDidClose(submenu) } + let originalLink = try #require(submenu.items.last) + #expect(originalLink.title == L("Open Status Page")) + #expect(originalLink.view == nil) + #expect(originalLink.isEnabled) + controller.menu(submenu, willHighlight: originalLink) + + store.statusComponents[.codex] = [ + ProviderStatusComponent( + id: "api", + name: "API", + indicator: .none, + status: "operational"), + ] + var rebuildCount = 0 + controller._test_openMenuRebuildObserver = { menu in + if menu === submenu { + rebuildCount += 1 + } + } + defer { controller._test_openMenuRebuildObserver = nil } + + controller.refreshOpenMenusAllowingParentRebuild() + for _ in 0..<20 where controller.nativeHighlightDeferredMenuRebuilds[key] == nil { + await Task.yield() + } + + #expect(rebuildCount == 0) + #expect(controller.nativeHighlightDeferredMenuRebuilds[key] != nil) + #expect(submenu.items.count == 1) + #expect(submenu.items.first === originalLink) + + controller.menu(submenu, willHighlight: nil) + for _ in 0..<20 where rebuildCount == 0 { + await Task.yield() + } + + #expect(rebuildCount == 1) + #expect(controller.nativeHighlightDeferredMenuRebuilds[key] == nil) + #expect(submenu.items.count == 3) + #expect(submenu.items.last !== originalLink) + #expect(submenu.items.last?.title == L("Open Status Page")) + } + + @Test + func `custom highlight does not defer open menu rebuild`() { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + store._cancelPlanUtilizationHistoryLoadForTesting() + 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) + defer { controller.menuDidClose(menu) } + let key = ObjectIdentifier(menu) + controller.cancelMenuWork(key) + controller.openMenus[key] = menu + let customItem = NSMenuItem() + customItem.view = HighlightProbeView() + customItem.isEnabled = true + menu.addItem(customItem) + controller.menu(menu, willHighlight: customItem) + controller.menuSession.invalidate(allowsStaleContent: false, requiresRebuild: true) + + var rebuildCount = 0 + controller._test_openMenuRebuildObserver = { _ in rebuildCount += 1 } + defer { controller._test_openMenuRebuildObserver = nil } + controller.rebuildOpenMenuIfStillVisible(menu, provider: .codex) + + #expect(rebuildCount == 1) + #expect(controller.nativeHighlightDeferredMenuRebuilds[key] == nil) + #expect(!controller.menuNeedsRefresh(menu)) + } } diff --git a/Tests/CodexBarTests/StatusMenuHostedSubmenuRefreshTests.swift b/Tests/CodexBarTests/StatusMenuHostedSubmenuRefreshTests.swift index 845c2ab6cf..b20fa9e22f 100644 --- a/Tests/CodexBarTests/StatusMenuHostedSubmenuRefreshTests.swift +++ b/Tests/CodexBarTests/StatusMenuHostedSubmenuRefreshTests.swift @@ -7,14 +7,147 @@ import Testing @Suite(.serialized) struct StatusMenuHostedSubmenuRefreshTests { @Test - func `open parent menu defers data rebuild until next open`() throws { + func `claude swap completion changes open menu readiness`() { + let settings = Self.makeSettings() + settings.setProviderEnabled( + provider: .claude, + metadata: ProviderDescriptorRegistry.descriptor(for: .claude).metadata, + 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: .system) + defer { controller.releaseStatusItemsForTesting() } + + let before = controller.menuAdjunctReadinessSignature() + store.claudeSwapRevision &+= 1 + + #expect(controller.menuAdjunctReadinessSignature() != before) + } + + @Test + func `status components change open menu readiness`() { + let settings = Self.makeSettings() + settings.statusChecksEnabled = true + settings.setProviderEnabled( + provider: .claude, + metadata: ProviderDescriptorRegistry.descriptor(for: .claude).metadata, + 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: .system) + defer { controller.releaseStatusItemsForTesting() } + + let before = controller.menuAdjunctReadinessSignature() + store.statusComponents[.claude] = [ + ProviderStatusComponent( + id: "api", + name: "API", + indicator: .none, + status: "operational"), + ] + + #expect(controller.menuAdjunctReadinessSignature() != before) + } + + @Test + func `project source changes open menu readiness`() { + let settings = Self.makeSettings() + settings.costUsageEnabled = true + Self.enableOnly(settings, provider: .codex) + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + store._setTokenSnapshotForTesting(Self.makeTokenSnapshot(projectSourcePath: "/tmp/main"), provider: .codex) + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: .system) + defer { controller.releaseStatusItemsForTesting() } + + let before = controller.menuAdjunctReadinessSignature() + store._setTokenSnapshotForTesting(Self.makeTokenSnapshot(projectSourcePath: "/tmp/worktree"), provider: .codex) + + #expect(controller.menuAdjunctReadinessSignature() != before) + } + + @Test + func `status submenu link stays scoped to its provider`() throws { + let settings = Self.makeSettings() + settings.statusChecksEnabled = 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: .system) + defer { controller.releaseStatusItemsForTesting() } + + let submenu = NSMenu() + #expect(controller.appendStatusComponentsItem( + to: submenu, + provider: .claude, + width: StatusItemController.menuCardBaseWidth)) + #expect(controller.hydrateHostedSubviewMenuIfNeeded(submenu)) + + let link = try #require(submenu.items.last) + #expect(link.action == #selector(StatusItemController.openStatusPageFromMenuItem(_:))) + #expect(link.identifier?.rawValue == UsageProvider.claude.rawValue) + #expect(link.target === controller) + } + + @Test + func `storage native row preserves its plain menu title`() throws { + let settings = Self.makeSettings() + settings.providerStorageFootprintsEnabled = true + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + Self.seedStorageFootprint(in: store) + + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: .system) + defer { controller.releaseStatusItemsForTesting() } + + let menu = NSMenu() + #expect(controller.addStorageMenuCardSection( + to: menu, + provider: .claude, + width: StatusItemController.menuCardBaseWidth)) + let item = try #require(menu.items.first) + #expect(item.title.hasPrefix(L("Storage"))) + #expect(item.title == item.attributedTitle?.string) + #expect(item.view == nil) + #expect(item.isEnabled) + #expect(item.submenu != nil) + } + + @Test + 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() @@ -23,6 +156,7 @@ struct StatusMenuHostedSubmenuRefreshTests { settings.mergeIcons = true settings.selectedMenuProvider = .claude settings.costUsageEnabled = true + settings.costSummaryDisplayStyle = .both Self.enableOnlyClaude(settings) let fetcher = UsageFetcher() @@ -37,6 +171,7 @@ struct StatusMenuHostedSubmenuRefreshTests { preferencesSelection: PreferencesSelection(), statusBar: .system) defer { controller.releaseStatusItemsForTesting() } + controller.menuRefreshEnabledOverrideForTesting = false let menu = controller.makeMenu() controller.menuWillOpen(menu) @@ -46,34 +181,353 @@ struct StatusMenuHostedSubmenuRefreshTests { let costItem = try #require(menu.items.first { ($0.representedObject as? String) == "menuCardCost" }) #expect(costItem.view == nil) + #expect(costItem.title == StatusItemController.costMenuTitleForProvider(.claude)) + #expect(costItem.isEnabled) let submenu = try #require(costItem.submenu) - let submenuAction = try #require(costItem.action) - #expect(NSStringFromSelector(submenuAction) == "submenuAction:") - #expect((costItem.target as? NSMenu) === submenu) #expect(submenu.items.first?.representedObject as? String == StatusItemController.costHistoryChartID) #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) + } + + @Test + func `hosted chart items size to the displayed view without a throwaway controller`() throws { + try self.assertHostedChartItemHeightMatchesRefresh( + chartID: StatusItemController.costHistoryChartID, + provider: .claude, + seed: Self.seedClaudeSnapshots) + { controller, submenu, width in + controller.appendCostHistoryChartItem(to: submenu, provider: .claude, width: width) + } + try self.assertHostedChartItemHeightMatchesRefresh( + chartID: StatusItemController.usageHistoryChartID, + provider: .claude, + seed: Self.seedPlanUtilizationHistory) + { controller, submenu, width in + controller.appendUsageHistoryChartItem(to: submenu, provider: .claude, width: width) + } + try self.assertHostedChartItemHeightMatchesRefresh( + chartID: StatusItemController.storageBreakdownID, + provider: .claude, + seed: Self.seedStorageFootprint) + { controller, submenu, width in + controller.appendStorageBreakdownItem(to: submenu, provider: .claude, width: width) + } + } + + @Test + func `zai chart render signature follows time range boundaries`() throws { + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd HH:mm" + formatter.locale = Locale(identifier: "en_US_POSIX") + let beforeMidnight = try #require(formatter.date(from: "2026-01-01 23:30")) + let afterMidnight = try #require(formatter.date(from: "2026-01-02 00:30")) + let modelUsage = ZaiModelUsageData( + xTime: ["2026-01-01 23:00"], + modelDataList: [ + ZaiModelDataItem(modelName: "glm-4.5", tokensUsage: [100]), + ]) + + let before = StatusItemController.zaiHourlyUsageRenderSignature( + modelUsage: modelUsage, + now: beforeMidnight) + let after = StatusItemController.zaiHourlyUsageRenderSignature( + modelUsage: modelUsage, + now: afterMidnight) + + #expect(before != after) + } + + @Test + func `utilization chart invalidates when active account changes`() throws { + let previousMenuCardRendering = StatusItemController.menuCardRenderingEnabled + StatusItemController.menuCardRenderingEnabled = true + defer { StatusItemController.menuCardRenderingEnabled = previousMenuCardRendering } + + let settings = Self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + Self.enableOnlyClaude(settings) + settings.addTokenAccount(provider: .claude, label: "Alice", token: "alice-token") + settings.addTokenAccount(provider: .claude, label: "Bob", token: "bob-token") + let accounts = settings.tokenAccounts(for: .claude) + let alice = try #require(accounts.first) + let bob = try #require(accounts.last) + let aliceKey = try #require( + UsageStore._planUtilizationTokenAccountKeyForTesting(provider: .claude, account: alice)) + let bobKey = try #require( + UsageStore._planUtilizationTokenAccountKeyForTesting(provider: .claude, account: bob)) + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + Self.seedClaudeSnapshots(in: store) + store.planUtilizationHistory[.claude] = PlanUtilizationHistoryBuckets(accounts: [ + aliceKey: [Self.makePlanHistory(usedPercent: 20)], + bobKey: [Self.makePlanHistory(usedPercent: 50)], + ]) + settings.setActiveTokenAccountIndex(0, for: .claude) + + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: .system) + defer { controller.releaseStatusItemsForTesting() } + + let submenu = controller.makeHostedSubviewPlaceholderMenu( + chartID: StatusItemController.usageHistoryChartID, + provider: .claude, + width: StatusItemController.menuCardBaseWidth) + controller.menuWillOpen(submenu) + let aliceView = try #require(submenu.items.first?.view) + + settings.setActiveTokenAccountIndex(1, for: .claude) + controller.refreshHostedSubviewMenu(submenu) + + let bobView = try #require(submenu.items.first?.view) + #expect(bobView !== aliceView) + } + + private func assertHostedChartItemHeightMatchesRefresh( + chartID: String, + provider: UsageProvider, + seed: (UsageStore) -> Void, + append: (StatusItemController, NSMenu, CGFloat) -> Bool) throws + { + let previousMenuCardRendering = StatusItemController.menuCardRenderingEnabled + StatusItemController.menuCardRenderingEnabled = true + defer { StatusItemController.menuCardRenderingEnabled = previousMenuCardRendering } + + let settings = Self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + 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() } + + let width = StatusItemController.menuCardBaseWidth + let submenu = NSMenu() + submenu.minimumWidth = width + #expect(append(controller, submenu, width)) + + let item = try #require(submenu.items.first) + let view = try #require(item.view) + let heightFromAppend = view.frame.height + // The height the append path assigns must match the authoritative re-measure pass; otherwise + // dropping the throwaway NSHostingController would have changed sizing behavior. + controller.refreshHostedSubviewHeights(in: submenu) + #expect(view.frame.height == heightFromAppend) + #expect(heightFromAppend > 1) + } + + 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") + let hydratedView = hydratedItem.view + let inflatedHeight = hydratedView.map { view -> CGFloat in + let inflatedHeight = view.frame.height + 100 + if chartID == StatusItemController.zaiHourlyUsageChartID { + view.frame.size.height = inflatedHeight + } + return inflatedHeight + } + + 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") + #expect(refreshedItem.view === hydratedView) + if chartID == StatusItemController.zaiHourlyUsageChartID { + #expect(refreshedItem.view?.frame.height != inflatedHeight) + } + + if chartID == StatusItemController.costHistoryChartID, provider == .claude { + store._setTokenSnapshotForTesting(Self.makeTokenSnapshot(dailyCost: 2.34), provider: .claude) + controller.refreshHostedSubviewMenu(submenu) + + let changedItem = try #require(submenu.items.first) + #expect(changedItem.view != nil) + #expect(changedItem.view !== hydratedView) + } + } + private static func makeSettings() -> SettingsStore { let suite = "StatusMenuHostedSubmenuRefreshTests-\(UUID().uuidString)" let defaults = UserDefaults(suiteName: suite)! @@ -86,12 +540,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,21 +563,138 @@ 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: [ + self.makePlanHistory(usedPercent: 24), + ]) + } + + private static func makePlanHistory(usedPercent: Double) -> PlanUtilizationSeriesHistory { + PlanUtilizationSeriesHistory( + name: .session, + windowMinutes: 300, + entries: [ + PlanUtilizationHistoryEntry( + capturedAt: Date(timeIntervalSince1970: 1_700_000_000), + usedPercent: usedPercent, + 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( + dailyCost: Double = 1.23, + projectSourcePath: String? = nil) -> CostUsageTokenSnapshot + { + let projects = projectSourcePath.map { sourcePath in + [ + CostUsageProjectBreakdown( + name: "Project", + path: "/tmp/main", + totalTokens: 123, + totalCostUSD: dailyCost, + daily: [], + modelBreakdowns: nil, + sources: [ + CostUsageProjectSourceBreakdown( + name: "Source", + path: sourcePath, + totalTokens: 123, + totalCostUSD: dailyCost, + daily: [], + modelBreakdowns: nil), + ]), + ] + } ?? [] + return CostUsageTokenSnapshot( sessionTokens: 123, sessionCostUSD: 0.12, last30DaysTokens: 123, - last30DaysCostUSD: 1.23, + last30DaysCostUSD: dailyCost, daily: [ CostUsageDailyReport.Entry( date: "2025-12-23", inputTokens: nil, outputTokens: nil, totalTokens: 123, - costUSD: 1.23, + costUSD: dailyCost, modelsUsed: nil, modelBreakdowns: nil), ], - updatedAt: Date()), provider: .claude) + projects: projects, + updatedAt: Date()) } } diff --git a/Tests/CodexBarTests/StatusMenuInstantOpenTests.swift b/Tests/CodexBarTests/StatusMenuInstantOpenTests.swift new file mode 100644 index 0000000000..17e4e5c785 --- /dev/null +++ b/Tests/CodexBarTests/StatusMenuInstantOpenTests.swift @@ -0,0 +1,1702 @@ +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.enableOnlyCodexForInstantOpenTesting(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 `non codex menu refresh all also defers codex dashboard refresh`() async { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + settings.refreshAllProvidersOnMenuOpen = true + // Enable several providers; only the available ones land in background work. The + // assertion below compares against that resolved set, so it stays robust regardless. + self.enableProvidersForInstantOpenTesting([.codex, .claude, .factory], settings: settings) + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + // Give every enabled provider a fresh, non-stale snapshot so the ONLY reason to refresh on + // open is the new setting — not a stale/missing retry (which is the pre-existing behavior). + let now = Date() + for provider in store.enabledProvidersForBackgroundWork() { + store._setSnapshotForTesting( + UsageSnapshot( + primary: RateWindow( + usedPercent: 20, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(1800), + resetDescription: nil), + secondary: nil, + updatedAt: now), + provider: provider) + } + var refreshedProviders: Set = [] + var refreshInteractions: [ProviderInteraction] = [] + store._test_providerRefreshOverride = { provider in + refreshedProviders.insert(provider) + refreshInteractions.append(ProviderInteractionContext.current) + } + 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() } + StatusItemController.setMenuOpenRefreshDelayForTesting(.zero) + defer { StatusItemController.resetMenuOpenRefreshDelayForTesting() } + + let expectedProviders = Set(store.enabledProvidersForBackgroundWork()) + let menu = controller.makeMenu(for: .claude) + controller.menuWillOpen(menu) + defer { controller.menuDidClose(menu) } + + for _ in 0..<80 where refreshedProviders != expectedProviders { + await Task.yield() + } + + // Every enabled provider is refreshed on open even though all snapshots were fresh. + #expect(refreshedProviders == expectedProviders && expectedProviders.contains(.codex)) + #expect(!refreshInteractions.isEmpty) + #expect(refreshInteractions.allSatisfy { $0 == .background }) + #expect(controller.deferredOpenAIDashboardRefreshReason != nil) + } + + @Test + func `menu open leaves fresh provider untouched when refresh-all-on-open is disabled`() async { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + settings.refreshAllProvidersOnMenuOpen = false + self.enableOnlyCodexForInstantOpenTesting(settings) + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + var providerRefreshCount = 0 + store._test_providerRefreshOverride = { _ in + 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 the delayed open-refresh task actually fire; with the setting off and fresh data, + // it must still skip the refresh (today's stale/missing-only behavior). + StatusItemController.setMenuOpenRefreshDelayForTesting(.zero) + defer { StatusItemController.resetMenuOpenRefreshDelayForTesting() } + + let menu = controller.makeMenu() + controller.menuWillOpen(menu) + defer { controller.menuDidClose(menu) } + + for _ in 0..<40 { + await Task.yield() + } + + #expect(providerRefreshCount == 0) + } + + @Test + func `delayed open refresh does not rebuild fresh menu after unrelated data invalidation`() async { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + self.enableOnlyCodexForInstantOpenTesting(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 + StatusItemController.setMenuOpenRefreshDelayForTesting(.milliseconds(50)) + defer { StatusItemController.resetMenuOpenRefreshDelayForTesting() } + + let menu = controller.makeMenu() + controller.menuWillOpen(menu) + defer { controller.menuDidClose(menu) } + + let key = ObjectIdentifier(menu) + let openedVersion = controller.menuVersions[key] + var rebuildCount = 0 + controller._test_openMenuRebuildObserver = { _ in + rebuildCount += 1 + } + defer { controller._test_openMenuRebuildObserver = nil } + + // Models first-open storage-footprint publication: menu content becomes stale, but no + // displayed provider was missing or failed when this menu opened. + controller.invalidateMenus(allowStaleContentDuringDataRefresh: true) + try? await Task.sleep(for: .milliseconds(150)) + + #expect(controller.menuContentVersion != openedVersion) + #expect(controller.menuVersions[key] == openedVersion) + #expect(rebuildCount == 0) + } + + @Test + func `menu open with missing data refreshes asynchronously while tracking`() async { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + self.enableOnlyCodexForInstantOpenTesting(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 + store._setSnapshotForTesting( + UsageSnapshot( + primary: RateWindow( + usedPercent: 25, + windowMinutes: 300, + resetsAt: Date().addingTimeInterval(3600), + resetDescription: nil), + secondary: nil, + updatedAt: Date()), + provider: .codex) + } + 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() } + StatusItemController.setMenuOpenRefreshDelayForTesting(.zero) + defer { StatusItemController.resetMenuOpenRefreshDelayForTesting() } + + let menu = controller.makeMenu() + controller.menuWillOpen(menu) + #expect(controller.deferredMenuInteractionRefreshPending) + + for _ in 0..<40 where providerRefreshCount == 0 { + await Task.yield() + } + + #expect(providerRefreshCount == 1) + #expect(refreshInteractions == [.background]) + for _ in 0..<40 where controller.deferredMenuInteractionRefreshPending { + await Task.yield() + } + #expect(!controller.deferredMenuInteractionRefreshPending) + controller.menuDidClose(menu) + #expect(!controller.deferredMenuInteractionRefreshPending) + } + + @Test + func `menu open renders cached data immediately after data only invalidation`() async { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + self.enableOnlyCodexForInstantOpenTesting(settings) + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + var providerRefreshCount = 0 + store._test_providerRefreshOverride = { provider in + guard provider == .codex else { return } + 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() } + + 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 cachedItemCount = menu.items.count + let cachedVersion = controller.menuVersions[key] + controller.lastMenuAdjunctReadinessSignature = "stale-baseline" + + controller.invalidateMenus(allowStaleContentDuringDataRefresh: true) + let dataOnlyVersion = controller.menuContentVersion + var asyncRebuilds = 0 + controller._test_openMenuRebuildObserver = { _ in + asyncRebuilds += 1 + } + defer { controller._test_openMenuRebuildObserver = nil } + + controller.menuWillOpen(menu) + + #expect(cachedVersion != dataOnlyVersion) + #expect(menu.items.count == cachedItemCount) + #expect(controller.menuVersions[key] == cachedVersion) + #expect(asyncRebuilds == 0) + #expect(!controller.deferredMenuInteractionRefreshPending) + + for _ in 0..<40 where asyncRebuilds == 0 { + await Task.yield() + } + + #expect(asyncRebuilds == 1) + #expect(controller.menuVersions[key] == controller.menuContentVersion) + #expect(!controller.didMenuAdjunctReadinessChange()) + controller.menuDidClose(menu) + for _ in 0..<40 { + await Task.yield() + } + #expect(providerRefreshCount == 0) + } + + @Test + func `closing before cached menu rebuild keeps next open stale`() async { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + self.enableOnlyCodexForInstantOpenTesting(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 cachedVersion = controller.menuVersions[key] + controller.invalidateMenus(allowStaleContentDuringDataRefresh: true) + + var rebuildGateEntries = 0 + var rebuildGate: CheckedContinuation? + controller._test_openMenuRefreshYieldOverride = { + rebuildGateEntries += 1 + await withCheckedContinuation { continuation in + rebuildGate = continuation + } + } + defer { + rebuildGate?.resume() + controller._test_openMenuRefreshYieldOverride = nil + } + + controller.menuWillOpen(menu) + for _ in 0..<40 where rebuildGateEntries == 0 { + await Task.yield() + } + + #expect(rebuildGateEntries == 1) + #expect(controller.menuVersions[key] == cachedVersion) + controller.menuDidClose(menu) + #expect(controller.menuNeedsRefresh(menu)) + + rebuildGate?.resume() + rebuildGate = nil + controller._test_openMenuRefreshYieldOverride = nil + for _ in 0..<20 { + await Task.yield() + } + + controller.menuWillOpen(menu) + for _ in 0..<40 where controller.menuNeedsRefresh(menu) { + await Task.yield() + } + + #expect(!controller.menuNeedsRefresh(menu)) + controller.menuDidClose(menu) + } + + @Test + func `menu open rebuilds synchronously after provider identity changes`() { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + self.enableOnlyCodexForInstantOpenTesting(settings) + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + store._setSnapshotForTesting( + self.instantOpenSnapshot(email: "old@example.com"), + provider: .codex) + 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 cachedVersion = controller.menuVersions[ObjectIdentifier(menu)] + + store._setSnapshotForTesting( + self.instantOpenSnapshot(email: "new@example.com"), + provider: .codex) + controller.invalidateMenus(allowStaleContentDuringDataRefresh: true) + controller.menuWillOpen(menu) + defer { controller.menuDidClose(menu) } + + #expect(controller.menuVersions[ObjectIdentifier(menu)] != cachedVersion) + #expect(controller.menuVersions[ObjectIdentifier(menu)] == controller.menuContentVersion) + } + + @Test + func `overview menu rebuilds synchronously after secondary provider identity changes`() { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.mergedMenuLastSelectedWasOverview = true + self.enableProvidersForInstantOpenTesting([.codex, .claude], settings: settings) + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + store._setSnapshotForTesting( + self.instantOpenSnapshot(email: "codex@example.com"), + provider: .codex) + store._setSnapshotForTesting( + self.instantOpenSnapshot(email: "old@example.com", provider: .claude), + provider: .claude) + let controller = StatusItemController( + store: store, + settings: settings, + account: UsageFetcher().loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + controller.menuRefreshEnabledOverrideForTesting = true + controller.selectedMenuProvider = .codex + + let menu = controller.makeMenu() + controller.mergedMenu = menu + controller.statusItem.menu = menu + controller.populateMenu(menu, provider: nil) + controller.markMenuFresh(menu) + let cachedVersion = controller.menuVersions[ObjectIdentifier(menu)] + + store._setSnapshotForTesting( + self.instantOpenSnapshot(email: "new@example.com", provider: .claude), + provider: .claude) + controller.invalidateMenus(allowStaleContentDuringDataRefresh: true) + controller.menuWillOpen(menu) + defer { controller.menuDidClose(menu) } + + #expect(controller.menuVersions[ObjectIdentifier(menu)] != cachedVersion) + #expect(controller.menuVersions[ObjectIdentifier(menu)] == controller.menuContentVersion) + } + + @Test + func `stacked Codex menu rebuilds synchronously after secondary account identity changes`() { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.multiAccountMenuLayout = .stacked + self.enableOnlyCodexForInstantOpenTesting(settings) + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + store._setSnapshotForTesting( + self.instantOpenSnapshot(email: "selected@example.com"), + provider: .codex) + let selectedAccount = CodexVisibleAccount( + id: "selected", + email: "selected@example.com", + workspaceLabel: nil, + workspaceAccountID: nil, + authFingerprint: nil, + storedAccountID: nil, + selectionSource: .liveSystem, + isActive: true, + isLive: true, + canReauthenticate: false, + canRemove: false) + let secondaryAccount = CodexVisibleAccount( + id: "secondary", + email: "secondary@example.com", + workspaceLabel: nil, + workspaceAccountID: nil, + authFingerprint: nil, + storedAccountID: nil, + selectionSource: .liveSystem, + isActive: false, + isLive: false, + canReauthenticate: false, + canRemove: false) + store.codexAccountSnapshots = [ + CodexAccountUsageSnapshot( + account: selectedAccount, + snapshot: self.instantOpenSnapshot(email: "selected@example.com"), + error: nil, + sourceLabel: "test"), + CodexAccountUsageSnapshot( + account: secondaryAccount, + snapshot: self.instantOpenSnapshot(email: "old@example.com"), + error: nil, + sourceLabel: "test"), + ] + 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 cachedVersion = controller.menuVersions[ObjectIdentifier(menu)] + + store.codexAccountSnapshots[1] = CodexAccountUsageSnapshot( + account: secondaryAccount, + snapshot: self.instantOpenSnapshot(email: "new@example.com"), + error: nil, + sourceLabel: "test") + controller.invalidateMenus(allowStaleContentDuringDataRefresh: true) + controller.menuWillOpen(menu) + defer { controller.menuDidClose(menu) } + + #expect(controller.menuVersions[ObjectIdentifier(menu)] != cachedVersion) + #expect(controller.menuVersions[ObjectIdentifier(menu)] == controller.menuContentVersion) + } + + @Test + func `cache preserving structural invalidation rebuilds synchronously on open`() { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + self.enableOnlyCodexForInstantOpenTesting(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 cachedVersion = controller.menuVersions[key] + + controller.preservingMergedSwitcherContentCachesDuringInvalidation { + controller.invalidateMenus() + } + #expect(controller.menuVersions[key] == cachedVersion) + #expect(controller.menuContentVersion != controller.latestDataOnlyMenuContentVersion) + + controller.menuWillOpen(menu) + defer { controller.menuDidClose(menu) } + + #expect(controller.menuVersions[key] == controller.menuContentVersion) + #expect(controller.openMenuRebuildTasks[key] == nil) + } + + @Test + func `data invalidation after cache preserving structural invalidation still rebuilds synchronously`() { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + self.enableOnlyCodexForInstantOpenTesting(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 cachedVersion = controller.menuVersions[key] + + controller.preservingMergedSwitcherContentCachesDuringInvalidation { + controller.invalidateMenus() + } + let structuralVersion = controller.menuContentVersion + controller.invalidateMenus(allowStaleContentDuringDataRefresh: true) + + #expect(controller.menuVersions[key] == cachedVersion) + #expect(controller.latestStructuralMenuContentVersion == structuralVersion) + #expect(controller.menuContentVersion == controller.latestDataOnlyMenuContentVersion) + + controller.menuWillOpen(menu) + defer { controller.menuDidClose(menu) } + + #expect(controller.menuVersions[key] == controller.menuContentVersion) + #expect(controller.openMenuRebuildTasks[key] == nil) + } + + @Test + func `menu open does not overlap provider specific refresh`() async { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + self.enableOnlyCodexForInstantOpenTesting(settings) + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + store._setSnapshotForTesting(nil, provider: .codex) + let refreshGate = BlockingInstantOpenProviderRefresh() + store._test_providerRefreshOverride = { provider in + guard provider == .codex else { return } + await refreshGate.run() + store._setSnapshotForTesting(self.instantOpenSnapshot(email: "refreshed@example.com"), provider: .codex) + } + defer { store._test_providerRefreshOverride = nil } + let existingRefreshTask = Task { + await store.refreshProvider(.codex) + } + await refreshGate.waitUntilStarted(count: 1) + + let controller = StatusItemController( + store: store, + settings: settings, + account: UsageFetcher().loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + controller.menuRefreshEnabledOverrideForTesting = true + StatusItemController.setMenuOpenRefreshDelayForTesting(.zero) + defer { StatusItemController.resetMenuOpenRefreshDelayForTesting() } + + let menu = controller.makeMenu() + controller.menuWillOpen(menu) + defer { controller.menuDidClose(menu) } + for _ in 0..<40 { + await Task.yield() + } + + #expect(await refreshGate.startCount == 1) + #expect(store.refreshingProviders.contains(.codex)) + await refreshGate.releaseFirst() + await existingRefreshTask.value + for _ in 0..<40 where controller.deferredMenuInteractionRefreshPending { + await Task.yield() + } + #expect(!store.isRefreshing) + #expect(!controller.deferredMenuInteractionRefreshPending) + } + + @Test + func `cached menu rebuilds after active provider refresh completes`() async { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + self.enableOnlyCodexForInstantOpenTesting(settings) + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + store._setSnapshotForTesting(nil, provider: .codex) + let refreshGate = BlockingInstantOpenProviderRefresh() + store._test_providerRefreshOverride = { provider in + guard provider == .codex else { return } + await refreshGate.run() + store._setSnapshotForTesting(self.instantOpenSnapshot(email: "refreshed@example.com"), provider: .codex) + } + 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() } + controller.menuRefreshEnabledOverrideForTesting = true + StatusItemController.setMenuOpenRefreshDelayForTesting(.zero) + defer { StatusItemController.resetMenuOpenRefreshDelayForTesting() } + + let menu = controller.makeMenu() + controller.mergedMenu = menu + controller.statusItem.menu = menu + controller.populateMenu(menu, provider: nil) + controller.markMenuFresh(menu) + controller.invalidateMenus(allowStaleContentDuringDataRefresh: true) + + let existingRefreshTask = Task { + await store.refreshProvider(.codex) + } + await refreshGate.waitUntilStarted(count: 1) + controller.menuWillOpen(menu) + defer { controller.menuDidClose(menu) } + #expect(controller.menuNeedsRefresh(menu)) + + await refreshGate.releaseFirst() + await existingRefreshTask.value + for _ in 0..<80 where controller.menuNeedsRefresh(menu) { + await Task.yield() + } + + #expect(await refreshGate.startCount == 1) + #expect(!controller.menuNeedsRefresh(menu)) + } + + @Test + func `menu rebuilds after displayed provider completes while another provider refreshes`() async { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + self.enableProvidersForInstantOpenTesting([.claude, .codex], settings: settings) + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + store._setSnapshotForTesting( + self.instantOpenSnapshot(email: "old@example.com"), + provider: .codex) + store.refreshingProviders = [.claude] + defer { store.refreshingProviders = [] } + let controller = StatusItemController( + store: store, + settings: settings, + account: UsageFetcher().loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + controller.menuRefreshEnabledOverrideForTesting = true + StatusItemController.setMenuOpenRefreshDelayForTesting(.zero) + defer { StatusItemController.resetMenuOpenRefreshDelayForTesting() } + + let menu = controller.makeMenu(for: .codex) + controller.populateMenu(menu, provider: .codex) + controller.markMenuFresh(menu) + store._setSnapshotForTesting( + self.instantOpenSnapshot(email: "new@example.com"), + provider: .codex) + controller.invalidateMenus(allowStaleContentDuringDataRefresh: true) + + controller.menuWillOpen(menu) + defer { controller.menuDidClose(menu) } + for _ in 0..<80 where controller.menuNeedsRefresh(menu) { + await Task.yield() + } + + #expect(!controller.menuNeedsRefresh(menu)) + } + + @Test + func `user refresh supersedes background provider refresh`() async { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + self.enableOnlyCodexForInstantOpenTesting(settings) + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + let refreshGate = BlockingInstantOpenProviderRefresh() + var refreshInteractions: [ProviderInteraction] = [] + store._test_providerRefreshOverride = { provider in + guard provider == .codex else { return } + refreshInteractions.append(ProviderInteractionContext.current) + await refreshGate.run() + } + defer { store._test_providerRefreshOverride = nil } + + let backgroundRefreshTask = Task { + await ProviderInteractionContext.$current.withValue(.background) { + await store.refreshProvider(.codex, coalesceIfRefreshing: true) + } + } + await refreshGate.waitUntilStarted(count: 1) + + let userRefreshTask = Task { + await ProviderInteractionContext.$current.withValue(.userInitiated) { + await store.refresh() + } + } + for _ in 0..<40 { + await Task.yield() + } + #expect(await refreshGate.startCount == 1) + + await refreshGate.releaseFirst() + await refreshGate.waitUntilStarted(count: 2) + await userRefreshTask.value + await backgroundRefreshTask.value + #expect(await refreshGate.startCount == 2) + #expect(refreshInteractions == [.background, .userInitiated]) + } + + @Test + func `settings refresh supersedes background provider refresh without becoming user initiated`() async { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + self.enableOnlyCodexForInstantOpenTesting(settings) + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + let refreshGate = BlockingInstantOpenProviderRefresh() + var refreshInteractions: [ProviderInteraction] = [] + store._test_providerRefreshOverride = { provider in + guard provider == .codex else { return } + refreshInteractions.append(ProviderInteractionContext.current) + await refreshGate.run() + } + defer { store._test_providerRefreshOverride = nil } + + let backgroundRefreshTask = Task { + await store.refreshProvider(.codex, coalesceIfRefreshing: true) + } + await refreshGate.waitUntilStarted(count: 1) + + let settingsRefreshTask = Task { + await store.refreshForSettingsChange() + } + for _ in 0..<40 { + await Task.yield() + } + #expect(await refreshGate.startCount == 1) + + await refreshGate.releaseFirst() + await refreshGate.waitUntilStarted(count: 2) + await settingsRefreshTask.value + await backgroundRefreshTask.value + #expect(await refreshGate.startCount == 2) + #expect(refreshInteractions == [.background, .background]) + } + + @Test + func `superseded provider refresh drains before newer result`() async throws { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + self.enableProvidersForInstantOpenTesting([.claude], settings: settings) + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + let refreshes = OrderedInstantOpenProviderRefresh() + let baseSpec = try #require(store.providerSpecs[.claude]) + let baseDescriptor = baseSpec.descriptor + let strategy = InstantOpenProviderFetchStrategy { + await refreshes.awaitSnapshot() + } + store.providerSpecs[.claude] = ProviderSpec( + style: baseSpec.style, + isEnabled: baseSpec.isEnabled, + descriptor: ProviderDescriptor( + id: .claude, + metadata: baseDescriptor.metadata, + branding: baseDescriptor.branding, + tokenCost: baseDescriptor.tokenCost, + fetchPlan: ProviderFetchPlan( + sourceModes: [.auto, .cli, .oauth], + pipeline: ProviderFetchPipeline { _ in [strategy] }), + cli: baseDescriptor.cli), + makeFetchContext: baseSpec.makeFetchContext) + + let olderTask = Task { + await store.refreshProvider(.claude) + } + await refreshes.waitUntilStarted(count: 1) + let newerTask = Task { + await store.refreshProvider(.claude) + } + for _ in 0..<40 { + await Task.yield() + } + #expect(await refreshes.startCount == 1) + + await refreshes.resume( + call: 1, + snapshot: self.instantOpenSnapshot( + email: "old@example.com", + provider: .claude, + percent: 10)) + await refreshes.waitUntilStarted(count: 2) + await refreshes.resume( + call: 2, + snapshot: self.instantOpenSnapshot( + email: "new@example.com", + provider: .claude, + percent: 80)) + await newerTask.value + await olderTask.value + + #expect(store.snapshot(for: .claude)?.primary?.usedPercent == 80) + #expect(store.snapshot(for: .claude)?.accountEmail(for: .claude) == "new@example.com") + } + + @Test + func `superseded provider refresh cannot overwrite manually changed token`() async throws { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + self.enableProvidersForInstantOpenTesting([.stepfun], settings: settings) + settings.stepfunToken = "initial-token" + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + let refreshes = OrderedInstantOpenProviderMutation() + let baseSpec = try #require(store.providerSpecs[.stepfun]) + let baseDescriptor = baseSpec.descriptor + let strategy = InstantOpenProviderMutationFetchStrategy(mutations: refreshes) + store.providerSpecs[.stepfun] = ProviderSpec( + style: baseSpec.style, + isEnabled: baseSpec.isEnabled, + descriptor: ProviderDescriptor( + id: .stepfun, + metadata: baseDescriptor.metadata, + branding: baseDescriptor.branding, + tokenCost: baseDescriptor.tokenCost, + fetchPlan: ProviderFetchPlan( + sourceModes: [.auto, .web], + pipeline: ProviderFetchPipeline { _ in [strategy] }), + cli: baseDescriptor.cli), + makeFetchContext: baseSpec.makeFetchContext) + + let olderTask = Task { + await store.refreshProvider(.stepfun) + } + await refreshes.waitUntilStarted(count: 1) + let newerTask = Task { + await store.refreshProvider(.stepfun) + } + for _ in 0..<40 { + await Task.yield() + } + #expect(await refreshes.startCount == 1) + + settings.stepfunToken = "user-token" + await refreshes.resume(call: 1, token: "old-token") + await refreshes.waitUntilStarted(count: 2) + #expect(settings.stepfunToken == "user-token") + await refreshes.resume(call: 2, token: "new-token") + await newerTask.value + await olderTask.value + + #expect(settings.stepfunToken == "new-token") + } + + @Test + func `superseded provider refresh preserves rotated token when credential is unchanged`() async throws { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + self.enableProvidersForInstantOpenTesting([.stepfun], settings: settings) + settings.stepfunToken = "initial-token" + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + let refreshes = OrderedInstantOpenProviderMutation() + let baseSpec = try #require(store.providerSpecs[.stepfun]) + let baseDescriptor = baseSpec.descriptor + let strategy = InstantOpenProviderMutationFetchStrategy(mutations: refreshes) + store.providerSpecs[.stepfun] = ProviderSpec( + style: baseSpec.style, + isEnabled: baseSpec.isEnabled, + descriptor: ProviderDescriptor( + id: .stepfun, + metadata: baseDescriptor.metadata, + branding: baseDescriptor.branding, + tokenCost: baseDescriptor.tokenCost, + fetchPlan: ProviderFetchPlan( + sourceModes: [.auto, .web], + pipeline: ProviderFetchPipeline { _ in [strategy] }), + cli: baseDescriptor.cli), + makeFetchContext: baseSpec.makeFetchContext) + + let olderTask = Task { + await store.refreshProvider(.stepfun) + } + await refreshes.waitUntilStarted(count: 1) + let newerTask = Task { + await store.refreshProvider(.stepfun) + } + + await refreshes.resume(call: 1, token: "rotated-token") + await refreshes.waitUntilStarted(count: 2) + #expect(settings.stepfunToken == "rotated-token") + await refreshes.resume(call: 2, token: "newer-token") + await newerTask.value + await olderTask.value + + #expect(settings.stepfunToken == "newer-token") + } + + @Test + func `canceling provider refresh cancels its owned probe task`() async { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + self.enableOnlyCodexForInstantOpenTesting(settings) + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + let refreshGate = BlockingInstantOpenProviderRefresh() + var refreshWasCancelled = false + store._test_providerRefreshOverride = { provider in + guard provider == .codex else { return } + await refreshGate.run() + refreshWasCancelled = Task.isCancelled + } + defer { store._test_providerRefreshOverride = nil } + + let refreshTask = Task { + await store.refreshProvider(.codex) + } + await refreshGate.waitUntilStarted(count: 1) + refreshTask.cancel() + await refreshGate.releaseFirst() + await refreshTask.value + + #expect(refreshWasCancelled) + } + + @Test + func `canceling refresh owner keeps shared provider probe alive`() async { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + self.enableOnlyCodexForInstantOpenTesting(settings) + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + let refreshGate = BlockingInstantOpenProviderRefresh() + var refreshWasCancelled = false + store._test_providerRefreshOverride = { provider in + guard provider == .codex else { return } + await refreshGate.run() + refreshWasCancelled = Task.isCancelled + } + defer { store._test_providerRefreshOverride = nil } + + let ownerTask = Task { + await store.refreshProvider(.codex) + } + await refreshGate.waitUntilStarted(count: 1) + let sharedWaiterTask = Task { + await store.refreshProvider(.codex, coalesceIfRefreshing: true) + } + for _ in 0..<40 { + await Task.yield() + } + + ownerTask.cancel() + await refreshGate.releaseFirst() + await ownerTask.value + await sharedWaiterTask.value + + #expect(!refreshWasCancelled) + #expect(await refreshGate.startCount == 1) + } + + @Test + func `background refresh retries canceled provider probe with cached data`() async { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + self.enableOnlyCodexForInstantOpenTesting(settings) + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + store._setSnapshotForTesting( + self.instantOpenSnapshot(email: "cached@example.com"), + provider: .codex) + let refreshGate = BlockingInstantOpenProviderRefresh() + store._test_providerRefreshOverride = { provider in + guard provider == .codex else { return } + await refreshGate.run() + } + defer { store._test_providerRefreshOverride = nil } + + let ownerTask = Task { + await store.refreshProvider(.codex) + } + await refreshGate.waitUntilStarted(count: 1) + ownerTask.cancel() + let backgroundTask = Task { + await store.refreshProvider(.codex, coalesceIfRefreshing: true) + } + for _ in 0..<40 { + await Task.yield() + } + await refreshGate.releaseFirst() + await ownerTask.value + await backgroundTask.value + + #expect(await refreshGate.startCount == 2) + } + + @Test + func `menu open refresh only retries the displayed provider`() async { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + self.enableProvidersForInstantOpenTesting([.claude, .codex], settings: settings) + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + store._setSnapshotForTesting(nil, provider: .codex) + store.refreshingProviders.insert(.claude) + defer { store.refreshingProviders.remove(.claude) } + var refreshedProviders: [UsageProvider] = [] + store._test_providerRefreshOverride = { provider in + refreshedProviders.append(provider) + } + 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() } + controller.menuRefreshEnabledOverrideForTesting = true + StatusItemController.setMenuOpenRefreshDelayForTesting(.zero) + defer { StatusItemController.resetMenuOpenRefreshDelayForTesting() } + + let menu = controller.makeMenu() + controller.menuProviders[ObjectIdentifier(menu)] = .codex + controller.menuWillOpen(menu) + defer { controller.menuDidClose(menu) } + for _ in 0..<40 where refreshedProviders.isEmpty { + await Task.yield() + } + + #expect(refreshedProviders == [.codex]) + } + + @Test + func `opening fresh split menu preserves another provider deferred retry`() async { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + self.enableProvidersForInstantOpenTesting([.claude, .codex], settings: settings) + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + store._setSnapshotForTesting(nil, provider: .codex) + store._setSnapshotForTesting( + self.instantOpenSnapshot(email: "claude@example.com", provider: .claude), + provider: .claude) + let controller = StatusItemController( + store: store, + settings: settings, + account: UsageFetcher().loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + controller.menuRefreshEnabledOverrideForTesting = true + StatusItemController.setMenuOpenRefreshDelayForTesting(.seconds(60)) + StatusItemController.setDeferredMenuInteractionRefreshDelayForTesting(.seconds(60)) + defer { + StatusItemController.resetMenuOpenRefreshDelayForTesting() + StatusItemController.resetDeferredMenuInteractionRefreshDelayForTesting() + } + + let codexMenu = controller.makeMenu(for: .codex) + controller.menuWillOpen(codexMenu) + controller.menuDidClose(codexMenu) + #expect(controller.deferredMenuInteractionRefreshProviders == [.codex]) + + StatusItemController.setMenuOpenRefreshDelayForTesting(.zero) + let claudeMenu = controller.makeMenu(for: .claude) + controller.menuWillOpen(claudeMenu) + defer { controller.menuDidClose(claudeMenu) } + for _ in 0..<40 { + await Task.yield() + } + + #expect(controller.deferredMenuInteractionRefreshProviders == [.codex]) + #expect(controller.deferredMenuInteractionRefreshPending) + } + + @Test + func `overview defers only providers that need retry`() { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.mergedMenuLastSelectedWasOverview = true + self.enableProvidersForInstantOpenTesting([.claude, .codex], settings: settings) + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + store._setSnapshotForTesting(nil, provider: .codex) + store._setSnapshotForTesting( + self.instantOpenSnapshot(email: "claude@example.com", provider: .claude), + provider: .claude) + let controller = StatusItemController( + store: store, + settings: settings, + account: UsageFetcher().loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + controller.menuRefreshEnabledOverrideForTesting = true + StatusItemController.setMenuOpenRefreshDelayForTesting(.seconds(60)) + defer { StatusItemController.resetMenuOpenRefreshDelayForTesting() } + + let menu = controller.makeMenu() + controller.menuWillOpen(menu) + defer { controller.menuDidClose(menu) } + + #expect(controller.deferredMenuInteractionRefreshProviders == [.codex]) + } + + @Test + func `closing overview menu stops before refreshing another provider`() async { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.mergedMenuLastSelectedWasOverview = true + self.enableProvidersForInstantOpenTesting([.codex, .openai], settings: settings) + settings.updateProviderConfig(provider: .openai) { config in + config.apiKey = "test-openai-key" + } + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + store._setSnapshotForTesting(nil, provider: .codex) + store._setSnapshotForTesting(nil, provider: .openai) + let refreshGate = BlockingInstantOpenProviderRefresh() + store._test_providerRefreshOverride = { _ in + await refreshGate.run() + } + 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() } + controller.menuRefreshEnabledOverrideForTesting = true + StatusItemController.setMenuOpenRefreshDelayForTesting(.zero) + defer { StatusItemController.resetMenuOpenRefreshDelayForTesting() } + StatusItemController.setDeferredMenuInteractionRefreshDelayForTesting(.seconds(60)) + defer { StatusItemController.resetDeferredMenuInteractionRefreshDelayForTesting() } + + let menu = controller.makeMenu() + controller.menuWillOpen(menu) + await refreshGate.waitUntilStarted(count: 1) + controller.menuDidClose(menu) + await refreshGate.releaseFirst() + for _ in 0..<80 { + await Task.yield() + } + + #expect(await refreshGate.startCount == 1) + #expect(controller.deferredMenuInteractionRefreshPending) + } + + @Test + func `closing menu during missing data refresh preserves deferred retry`() async { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + self.enableOnlyCodexForInstantOpenTesting(settings) + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + store._setSnapshotForTesting(nil, provider: .codex) + let refreshGate = BlockingInstantOpenProviderRefresh() + var refreshInteractions: [ProviderInteraction] = [] + store._test_providerRefreshOverride = { provider in + guard provider == .codex else { return } + refreshInteractions.append(ProviderInteractionContext.current) + await refreshGate.run() + } + 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() } + controller.menuRefreshEnabledOverrideForTesting = true + StatusItemController.setDeferredMenuInteractionRefreshDelayForTesting(.zero) + defer { StatusItemController.resetDeferredMenuInteractionRefreshDelayForTesting() } + StatusItemController.setMenuOpenRefreshDelayForTesting(.zero) + defer { StatusItemController.resetMenuOpenRefreshDelayForTesting() } + + let menu = controller.makeMenu() + controller.menuWillOpen(menu) + await refreshGate.waitUntilStarted(count: 1) + #expect(controller.deferredMenuInteractionRefreshPending) + #expect(store.refreshingProviders.contains(.codex)) + + let periodicRefreshTask = Task { + await store.refresh() + } + for _ in 0..<40 { + await Task.yield() + } + #expect(await refreshGate.startCount == 1) + + controller.menuDidClose(menu) + #expect(controller.deferredMenuInteractionRefreshPending) + await refreshGate.releaseFirst() + await periodicRefreshTask.value + for _ in 0..<40 where store.isRefreshing { + await Task.yield() + } + for _ in 0..<40 { + await Task.yield() + } + #expect(controller.deferredMenuInteractionRefreshPending) + + controller.scheduleDeferredMenuInteractionRefreshIfNeeded(delay: .zero) + await refreshGate.waitUntilStarted(count: 2) + for _ in 0..<40 where controller.deferredMenuInteractionRefreshPending { + await Task.yield() + } + + #expect(await refreshGate.startCount == 2) + #expect(refreshInteractions == [.background, .background]) + #expect(!controller.deferredMenuInteractionRefreshPending) + } + + @Test + func `closing menu during successful missing data refresh clears deferred retry`() async { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + self.enableOnlyCodexForInstantOpenTesting(settings) + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + store._setSnapshotForTesting(nil, provider: .codex) + let refreshGate = BlockingInstantOpenProviderRefresh() + store._test_providerRefreshOverride = { provider in + guard provider == .codex else { return } + await refreshGate.run() + store._setSnapshotForTesting( + UsageSnapshot( + primary: RateWindow( + usedPercent: 25, + windowMinutes: 300, + resetsAt: Date().addingTimeInterval(3600), + resetDescription: nil), + secondary: nil, + updatedAt: Date()), + provider: .codex) + } + 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() } + controller.menuRefreshEnabledOverrideForTesting = true + StatusItemController.setDeferredMenuInteractionRefreshDelayForTesting(.zero) + defer { StatusItemController.resetDeferredMenuInteractionRefreshDelayForTesting() } + StatusItemController.setMenuOpenRefreshDelayForTesting(.zero) + defer { StatusItemController.resetMenuOpenRefreshDelayForTesting() } + var deferredRefreshCount = 0 + controller.onDeferredMenuInteractionRefreshForTesting = { + deferredRefreshCount += 1 + } + + let menu = controller.makeMenu() + controller.menuWillOpen(menu) + await refreshGate.waitUntilStarted(count: 1) + controller.menuDidClose(menu) + for _ in 0..<80 { + await Task.yield() + } + #expect(deferredRefreshCount == 0) + await refreshGate.releaseFirst() + + for _ in 0..<80 where controller.deferredMenuInteractionRefreshPending { + await Task.yield() + } + for _ in 0..<40 { + await Task.yield() + } + + #expect(await refreshGate.startCount == 1) + #expect(deferredRefreshCount == 0) + #expect(!controller.deferredMenuInteractionRefreshPending) + } + + private func enableOnlyCodexForInstantOpenTesting(_ settings: SettingsStore) { + self.enableProvidersForInstantOpenTesting([.codex], settings: settings) + } + + private func instantOpenSnapshot( + email: String, + provider: UsageProvider = .codex, + percent: Double = 25) -> UsageSnapshot + { + UsageSnapshot( + primary: RateWindow( + usedPercent: percent, + windowMinutes: 300, + resetsAt: Date().addingTimeInterval(3600), + resetDescription: nil), + secondary: nil, + updatedAt: Date(), + identity: ProviderIdentitySnapshot( + providerID: provider, + accountEmail: email, + accountOrganization: nil, + loginMethod: "ChatGPT")) + } + + private func enableProvidersForInstantOpenTesting( + _ enabledProviders: Set, + 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: enabledProviders.contains(provider)) + } + } +} + +private struct InstantOpenProviderFetchStrategy: ProviderFetchStrategy { + let loader: @Sendable () async -> UsageSnapshot + + var id: String { + "instant-open-provider-refresh-test" + } + + var kind: ProviderFetchKind { + .cli + } + + func isAvailable(_: ProviderFetchContext) async -> Bool { + true + } + + func fetch(_: ProviderFetchContext) async throws -> ProviderFetchResult { + let usage = await self.loader() + return self.makeResult(usage: usage, sourceLabel: self.id) + } + + func shouldFallback(on _: Error, context _: ProviderFetchContext) -> Bool { + false + } +} + +private struct InstantOpenProviderMutationFetchStrategy: ProviderFetchStrategy { + let mutations: OrderedInstantOpenProviderMutation + + let id = "instant-open-provider-mutation-test" + let kind: ProviderFetchKind = .web + + func isAvailable(_: ProviderFetchContext) async -> Bool { + true + } + + func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult { + let token = await self.mutations.awaitToken() + await context.providerManualTokenUpdater?(.stepfun, token) + let usage = UsageSnapshot( + primary: RateWindow( + usedPercent: 25, + windowMinutes: 300, + resetsAt: Date().addingTimeInterval(3600), + resetDescription: nil), + secondary: nil, + updatedAt: Date()) + return self.makeResult(usage: usage, sourceLabel: self.id) + } + + func shouldFallback(on _: Error, context _: ProviderFetchContext) -> Bool { + false + } +} + +private actor OrderedInstantOpenProviderRefresh { + private var started = 0 + private var startWaiters: [(count: Int, continuation: CheckedContinuation)] = [] + private var continuations: [Int: CheckedContinuation] = [:] + + var startCount: Int { + self.started + } + + func awaitSnapshot() async -> UsageSnapshot { + self.started += 1 + let call = self.started + self.resumeReadyStartWaiters() + return await withCheckedContinuation { continuation in + self.continuations[call] = continuation + } + } + + func waitUntilStarted(count: Int) async { + if self.started >= count { return } + await withCheckedContinuation { continuation in + self.startWaiters.append((count: count, continuation: continuation)) + } + } + + func resume(call: Int, snapshot: UsageSnapshot) { + self.continuations.removeValue(forKey: call)?.resume(returning: snapshot) + } + + private func resumeReadyStartWaiters() { + var remaining: [(count: Int, continuation: CheckedContinuation)] = [] + for waiter in self.startWaiters { + if self.started >= waiter.count { + waiter.continuation.resume() + } else { + remaining.append(waiter) + } + } + self.startWaiters = remaining + } +} + +private actor OrderedInstantOpenProviderMutation { + private var started = 0 + private var startWaiters: [(count: Int, continuation: CheckedContinuation)] = [] + private var continuations: [Int: CheckedContinuation] = [:] + + var startCount: Int { + self.started + } + + func awaitToken() async -> String { + self.started += 1 + let call = self.started + self.resumeReadyStartWaiters() + return await withCheckedContinuation { continuation in + self.continuations[call] = continuation + } + } + + func waitUntilStarted(count: Int) async { + if self.started >= count { return } + await withCheckedContinuation { continuation in + self.startWaiters.append((count: count, continuation: continuation)) + } + } + + func resume(call: Int, token: String) { + self.continuations.removeValue(forKey: call)?.resume(returning: token) + } + + private func resumeReadyStartWaiters() { + var remaining: [(count: Int, continuation: CheckedContinuation)] = [] + for waiter in self.startWaiters { + if self.started >= waiter.count { + waiter.continuation.resume() + } else { + remaining.append(waiter) + } + } + self.startWaiters = remaining + } +} + +private actor BlockingInstantOpenProviderRefresh { + private var started = 0 + private var startWaiters: [(count: Int, continuation: CheckedContinuation)] = [] + private var firstReleaseWaiters: [CheckedContinuation] = [] + private var firstReleased = false + + var startCount: Int { + self.started + } + + func run() async { + self.started += 1 + self.resumeReadyStartWaiters() + guard self.started == 1, !self.firstReleased else { return } + await withCheckedContinuation { continuation in + self.firstReleaseWaiters.append(continuation) + } + } + + func waitUntilStarted(count: Int) async { + if self.started >= count { return } + await withCheckedContinuation { continuation in + self.startWaiters.append((count: count, continuation: continuation)) + } + } + + func releaseFirst() { + self.firstReleased = true + let waiters = self.firstReleaseWaiters + self.firstReleaseWaiters.removeAll() + waiters.forEach { $0.resume() } + } + + private func resumeReadyStartWaiters() { + var remaining: [(count: Int, continuation: CheckedContinuation)] = [] + for waiter in self.startWaiters { + if self.started >= waiter.count { + waiter.continuation.resume() + } else { + remaining.append(waiter) + } + } + self.startWaiters = remaining + } +} diff --git a/Tests/CodexBarTests/StatusMenuLocalizationRefreshTests.swift b/Tests/CodexBarTests/StatusMenuLocalizationRefreshTests.swift index 4a78b364c9..02160ca60e 100644 --- a/Tests/CodexBarTests/StatusMenuLocalizationRefreshTests.swift +++ b/Tests/CodexBarTests/StatusMenuLocalizationRefreshTests.swift @@ -31,6 +31,7 @@ struct StatusMenuLocalizationRefreshTests { settings.switcherShowsIcons = false settings.selectedMenuProvider = .codex settings.costUsageEnabled = true + settings.costSummaryDisplayStyle = .both let registry = ProviderRegistry.shared for provider in UsageProvider.allCases { @@ -71,11 +72,11 @@ struct StatusMenuLocalizationRefreshTests { controller.menuWillOpen(menu) } controller.openMenus[ObjectIdentifier(menu)] = menu - StatusItemController.setMenuRefreshEnabledForTesting(true) - defer { StatusItemController.resetMenuRefreshEnabledForTesting() } + controller.menuRefreshEnabledOverrideForTesting = true #expect(Self.switcherButtons(in: menu).first?.title == "Resumen") - #expect(menu.items.first(where: { $0.representedObject as? String == "menuCardCost" })?.title == "Coste") + let initialCostTitle = menu.items.first(where: { $0.representedObject as? String == "menuCardCost" })?.title + #expect(initialCostTitle == "Coste") let initialSwitcher = menu.items.first?.view as? ProviderSwitcherView let initialSwitcherID = initialSwitcher.map(ObjectIdentifier.init) @@ -98,7 +99,8 @@ struct StatusMenuLocalizationRefreshTests { #expect(rebuildCount == 1) let updatedSwitcher = menu.items.first?.view as? ProviderSwitcherView #expect(Self.switcherButtons(in: menu).first?.title == "Overview") - #expect(menu.items.first(where: { $0.representedObject as? String == "menuCardCost" })?.title == "Cost") + let updatedCostTitle = menu.items.first(where: { $0.representedObject as? String == "menuCardCost" })?.title + #expect(updatedCostTitle == "Cost") if let initialSwitcherID, let updatedSwitcher { #expect(initialSwitcherID != ObjectIdentifier(updatedSwitcher)) } diff --git a/Tests/CodexBarTests/StatusMenuMergedOverviewRefreshTests.swift b/Tests/CodexBarTests/StatusMenuMergedOverviewRefreshTests.swift new file mode 100644 index 0000000000..ebd136a071 --- /dev/null +++ b/Tests/CodexBarTests/StatusMenuMergedOverviewRefreshTests.swift @@ -0,0 +1,94 @@ +import AppKit +import CodexBarCore +import Testing +@testable import CodexBar + +@MainActor +@Suite(.serialized) +struct StatusMenuMergedOverviewRefreshTests { + @Test + func `overview stays busy for an omitted provider refresh`() async throws { + let settings = self.makeSettings() + settings.refreshFrequency = .manual + settings.mergeIcons = true + let activeProviders: [UsageProvider] = [.claude, .codex, .cursor, .opencode] + self.enableOnly(Set(activeProviders), settings: settings) + settings.setMergedOverviewProviderSelection( + provider: .opencode, + isSelected: false, + activeProviders: activeProviders) + settings.mergedMenuLastSelectedWasOverview = true + + let controller = self.makeController(settings: settings) + let menu = try #require(controller.makeMenu() as? StatusItemMenu) + controller.mergedMenu = menu + controller.menuWillOpen(menu) + defer { controller.menuDidClose(menu) } + + let visibleProviders = settings.resolvedMergedOverviewProviders( + activeProviders: controller.store.enabledProvidersForDisplay()) + #expect(!visibleProviders.contains(.opencode)) + + controller.store.refreshingProviders.insert(.opencode) + controller.updatePersistentRefreshItemsEnabled() + #expect(controller.isRefreshActionInFlight(for: menu)) + let refreshItem = try #require(menu.items.first { $0.title == "Refresh" }) + #expect(controller.isPersistentRefreshItem(refreshItem)) + #expect(!refreshItem.isEnabled) + + var requestCount = 0 + controller._test_manualRefreshOperation = { requestCount += 1 } + controller.performPersistentRefreshAction(in: ObjectIdentifier(menu)) + #expect(try menu.performKeyEquivalent(with: self.keyEvent("r", keyCode: 15))) + for _ in 0..<20 { + await Task.yield() + } + + #expect(requestCount == 0) + #expect(controller.manualRefreshTasks.isEmpty) + } + + private func makeSettings() -> SettingsStore { + let suite = "StatusMenuMergedOverviewRefreshTests-\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suite)! + defaults.removePersistentDomain(forName: suite) + return SettingsStore( + userDefaults: defaults, + configStore: testConfigStore(suiteName: suite), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + } + + private func makeController(settings: SettingsStore) -> StatusItemController { + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + return StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: .system) + } + + private func enableOnly(_ providers: Set, settings: SettingsStore) { + for provider in UsageProvider.allCases { + guard let metadata = ProviderRegistry.shared.metadata[provider] else { continue } + settings.setProviderEnabled(provider: provider, metadata: metadata, enabled: providers.contains(provider)) + } + } + + private func keyEvent(_ characters: String, keyCode: UInt16) throws -> NSEvent { + try #require(NSEvent.keyEvent( + with: .keyDown, + location: .zero, + modifierFlags: [.command], + timestamp: 0, + windowNumber: 0, + context: nil, + characters: characters, + charactersIgnoringModifiers: characters, + isARepeat: false, + keyCode: keyCode)) + } +} diff --git a/Tests/CodexBarTests/StatusMenuNativeSectionSpacingTests.swift b/Tests/CodexBarTests/StatusMenuNativeSectionSpacingTests.swift new file mode 100644 index 0000000000..c604c07dd6 --- /dev/null +++ b/Tests/CodexBarTests/StatusMenuNativeSectionSpacingTests.swift @@ -0,0 +1,261 @@ +import AppKit +import CodexBarCore +import Testing +@testable import CodexBar + +@MainActor +@Suite(.serialized) +struct StatusMenuNativeSectionSpacingTests { + @Test + func `buy credits stays available without an error only credits section`() { + let previousRendering = StatusItemController.menuCardRenderingEnabled + StatusItemController.menuCardRenderingEnabled = true + defer { StatusItemController.menuCardRenderingEnabled = previousRendering } + + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.selectedMenuProvider = .codex + settings.showOptionalCreditsAndExtraUsage = true + self.enableOnlyCodex(settings) + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + store.lastCreditsError = UsageError.noRateLimitsFound.errorDescription + store.lastOpenAIDashboardError = + "No matching OpenAI web session found. Sign in to chatgpt.com, then refresh OpenAI cookies." + let event = CreditEvent(date: Date(), service: "CLI", creditsUsed: 1) + let breakdown = OpenAIDashboardSnapshot.makeDailyBreakdown(from: [event], maxDays: 30) + store.openAIDashboard = OpenAIDashboardSnapshot( + signedInEmail: "user@example.com", + codeReviewRemainingPercent: 100, + creditEvents: [event], + dailyBreakdown: breakdown, + usageBreakdown: breakdown, + creditsPurchaseURL: nil, + updatedAt: Date()) + store.openAIDashboardAttachmentAuthorized = true + store.openAIDashboardRequiresLogin = false + + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: .system) + defer { controller.releaseStatusItemsForTesting() } + + let menu = controller.makeMenu(for: .codex) + controller.menuWillOpen(menu) + + #expect(menu.items.contains { ($0.representedObject as? String) == "menuCardCredits" } == false) + #expect(menu.items.contains { $0.title == "Buy Credits..." }) + #expect(menu.items.contains { item in + item.submenu?.items.contains { ($0.representedObject as? String) == "creditsHistoryChart" } == true + }) + + settings.showOptionalCreditsAndExtraUsage = false + let hiddenMenu = controller.makeMenu(for: .codex) + controller.menuWillOpen(hiddenMenu) + #expect(hiddenMenu.items.contains { $0.title == "Buy Credits..." } == false) + #expect(hiddenMenu.items.contains { item in + item.submenu?.items.contains { ($0.representedObject as? String) == "creditsHistoryChart" } == true + } == false) + } + + @Test + func `usage history cost and storage stay together without adjacent separators`() throws { + let previousRendering = StatusItemController.menuCardRenderingEnabled + StatusItemController.menuCardRenderingEnabled = true + defer { StatusItemController.menuCardRenderingEnabled = previousRendering } + + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.selectedMenuProvider = .codex + settings.costUsageEnabled = true + settings.costSummaryDisplayStyle = .both + settings.providerStorageFootprintsEnabled = true + self.enableOnlyCodex(settings) + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + let storageRoot = "/Users/test/.codex" + store.providerStorageFootprints[.codex] = ProviderStorageFootprint( + provider: .codex, + totalBytes: 1024, + paths: [storageRoot], + missingPaths: [], + unreadablePaths: [], + components: [.init(path: storageRoot, totalBytes: 1024)], + updatedAt: Date()) + store.credits = CreditsSnapshot(remaining: 100, events: [], updatedAt: Date()) + store.openAIDashboard = OpenAIDashboardSnapshot( + signedInEmail: "user@example.com", + codeReviewRemainingPercent: 100, + creditEvents: [], + dailyBreakdown: [], + usageBreakdown: [], + creditsPurchaseURL: nil, + updatedAt: Date()) + store.openAIDashboardAttachmentAuthorized = true + store.openAIDashboardRequiresLogin = false + store._setTokenSnapshotForTesting(CostUsageTokenSnapshot( + sessionTokens: 123, + sessionCostUSD: 0.12, + last30DaysTokens: 123, + last30DaysCostUSD: 1.23, + daily: [ + CostUsageDailyReport.Entry( + date: "2025-12-23", + inputTokens: nil, + outputTokens: nil, + totalTokens: 123, + costUSD: 1.23, + modelsUsed: nil, + modelBreakdowns: nil), + ], + updatedAt: Date()), provider: .codex) + + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: .system) + defer { controller.releaseStatusItemsForTesting() } + + let menu = controller.makeMenu(for: .codex) + controller.menuWillOpen(menu) + let usageHistoryIndex = try #require(menu.items.firstIndex { + ($0.representedObject as? String) == "usageHistorySubmenu" + }) + let storageIndex = try #require(menu.items.firstIndex { + ($0.representedObject as? String) == "menuCardStorage" + }) + let creditsIndex = try #require(menu.items.firstIndex { + ($0.representedObject as? String) == "menuCardCredits" + }) + let costIndex = try #require(menu.items.firstIndex { + ($0.representedObject as? String) == "menuCardCost" + }) + #expect(creditsIndex < usageHistoryIndex) + #expect(usageHistoryIndex < costIndex) + #expect(costIndex < storageIndex) + #expect(menu.items[usageHistoryIndex].title == "Plan Usage") + #expect(menu.items[storageIndex].view == nil) + #expect(menu.items[storageIndex].title.hasPrefix("Storage")) + #expect(menu.items[storageIndex].title.contains("1 KB")) + #expect(menu.items[storageIndex + 1].isSeparatorItem) + #expect(!zip(menu.items, menu.items.dropFirst()).contains { first, second in + first.isSeparatorItem && second.isSeparatorItem + }) + } + + @Test + func `opencodego cost history hangs off the cost row not the usage pane`() throws { + let previousRendering = StatusItemController.menuCardRenderingEnabled + StatusItemController.menuCardRenderingEnabled = true + defer { StatusItemController.menuCardRenderingEnabled = previousRendering } + + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.selectedMenuProvider = .opencodego + settings.costUsageEnabled = true + settings.costSummaryDisplayStyle = .both + self.enableOnlyOpenCodeGo(settings) + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + let opencodegoSnapshot = OpenCodeGoUsageSnapshot( + hasMonthlyUsage: true, + rollingUsagePercent: 12, + weeklyUsagePercent: 57, + monthlyUsagePercent: 34, + rollingResetInSec: 3600, + weeklyResetInSec: 86400, + monthlyResetInSec: 864_000, + daily: [ + CostUsageDailyReport.Entry( + date: "2025-12-23", + inputTokens: nil, + outputTokens: nil, + totalTokens: nil, + requestCount: 5, + costUSD: 1.23, + modelsUsed: nil, + modelBreakdowns: nil), + ], + updatedAt: Date()) + let opencodegoUsageSnapshot = opencodegoSnapshot.toUsageSnapshot() + store._setSnapshotForTesting(opencodegoUsageSnapshot, provider: .opencodego) + // A completed refresh also caches the projected token snapshot (UsageStore+Refresh.swift); + // populate it here so `openAIWebContext.hasCostHistory` matches real post-refresh state. + store._setTokenSnapshotForTesting( + store.tokenSnapshot(fromProviderSnapshot: opencodegoUsageSnapshot, provider: .opencodego), + provider: .opencodego) + + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: .system) + defer { controller.releaseStatusItemsForTesting() } + + let menu = controller.makeMenu(for: .opencodego) + controller.menuWillOpen(menu) + + let usageIndex = try #require(menu.items.firstIndex { + ($0.representedObject as? String) == "menuCardUsage" + }) + let usageHistoryIndex = try #require(menu.items.firstIndex { + ($0.representedObject as? String) == "usageHistorySubmenu" + }) + let costIndex = try #require(menu.items.firstIndex { + ($0.representedObject as? String) == "menuCardCost" + }) + + // The rate-limit bars pane keeps its own submenu-free row; the cost history chart hangs + // off the dedicated "Cost" row instead, matching Codex/Claude's structure. + #expect(menu.items[usageIndex].submenu == nil) + #expect(menu.items[usageHistoryIndex].title == "Plan Usage") + #expect(usageIndex < usageHistoryIndex) + #expect(usageHistoryIndex < costIndex) + #expect(menu.items[costIndex].submenu != nil) + } + + private func makeSettings() -> SettingsStore { + let suite = "StatusMenuNativeSectionSpacingTests-\(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.providerDetectionCompleted = true + return settings + } + + private func enableOnlyCodex(_ settings: SettingsStore) { + for provider in UsageProvider.allCases { + guard let metadata = ProviderRegistry.shared.metadata[provider] else { continue } + settings.setProviderEnabled(provider: provider, metadata: metadata, enabled: provider == .codex) + } + } + + private func enableOnlyOpenCodeGo(_ settings: SettingsStore) { + for provider in UsageProvider.allCases { + guard let metadata = ProviderRegistry.shared.metadata[provider] else { continue } + settings.setProviderEnabled(provider: provider, metadata: metadata, enabled: provider == .opencodego) + } + } +} diff --git a/Tests/CodexBarTests/StatusMenuOpenRefreshTests.swift b/Tests/CodexBarTests/StatusMenuOpenRefreshTests.swift index aeec4c1c8d..fc9a378c7a 100644 --- a/Tests/CodexBarTests/StatusMenuOpenRefreshTests.swift +++ b/Tests/CodexBarTests/StatusMenuOpenRefreshTests.swift @@ -1,3 +1,4 @@ +import AppKit import CodexBarCore import Foundation import Testing @@ -26,8 +27,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,14 +63,1205 @@ extension StatusMenuTests { } @Test - func `explicit store actions refresh a visible open menu`() async { + func `closed merged menu defers rebuild until next open instead of pre-warming`() 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 + for _ in 0..<20 { + await Task.yield() + } + + controller.populateMenu(menu, provider: nil) + controller.markMenuFresh(menu) + let key = ObjectIdentifier(menu) + for _ in 0..<40 { + await Task.yield() + } + controller.populateMenu(menu, provider: nil) + controller.markMenuFresh(menu) + controller.cancelAllClosedMenuRebuilds() + controller.closedMenusDeferredUntilNextOpen.removeAll(keepingCapacity: false) + let openedVersion = controller.menuVersions[key] + + // Background data-refresh tick (stale allowed): closed prep is skipped entirely, so + // the closed merged menu must not be pre-warmed or marked deferred. + controller.invalidateMenus(allowStaleContentDuringDataRefresh: true) + for _ in 0..<40 { + await Task.yield() + } + #expect(controller.openMenus.isEmpty) + #expect(controller.menuContentVersion != openedVersion) + #expect(controller.menuVersions[key] == openedVersion) + #expect(!controller.closedMenusDeferredUntilNextOpen.contains(key)) + + // A required (non-stale) invalidation must also leave the closed merged menu deferred. + controller.invalidateMenus() + for _ in 0..<40 { + await Task.yield() + } + #expect(controller.menuVersions[key] == openedVersion) + #expect(controller.closedMenusDeferredUntilNextOpen.contains(key)) + + // The deferred merged menu is repopulated synchronously on the next open. + controller.menuWillOpen(menu) + defer { controller.menuDidClose(menu) } + #expect(controller.menuVersions[key] == controller.menuContentVersion) + #expect(!controller.closedMenusDeferredUntilNextOpen.contains(key)) + } + + @Test + func `data refresh invalidation does not rebuild closed non merged attached menu`() 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() + // Use a non-merged attached menu: stale data-refresh invalidations should not pre-warm any + // closed attached menu, while required invalidations still may prepare non-merged menus. + controller.fallbackMenu = menu + controller.statusItem.menu = menu + + controller.populateMenu(menu, provider: nil) + controller.markMenuFresh(menu) + let key = ObjectIdentifier(menu) + for _ in 0..<40 { + await Task.yield() + } + controller.populateMenu(menu, provider: nil) + controller.markMenuFresh(menu) + controller.cancelAllClosedMenuRebuilds() + let openedVersion = controller.menuVersions[key] + + controller.invalidateMenus(allowStaleContentDuringDataRefresh: true) + for _ in 0..<40 { + await Task.yield() + } + + #expect(controller.openMenus.isEmpty) + #expect(controller.menuContentVersion != openedVersion) + #expect(controller.menuVersions[key] == openedVersion) + + controller.menuWillOpen(menu) + defer { controller.menuDidClose(menu) } + + for _ in 0..<40 where controller.menuVersions[key] != controller.menuContentVersion { + await Task.yield() + } + #expect(controller.menuVersions[key] == controller.menuContentVersion) + } + + @Test + func `required non merged closed menu preparation survives later data refresh invalidation`() 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() + // Use a non-merged attached menu so this covers the delayed closed-menu rebuild path. Merged + // menus are intentionally deferred until next open on current main (#1274). + controller.fallbackMenu = menu + controller.statusItem.menu = menu + + controller.populateMenu(menu, provider: nil) + controller.markMenuFresh(menu) + let key = ObjectIdentifier(menu) + let openedVersion = controller.menuVersions[key] + + controller.invalidateMenus() + let requiredVersion = controller.latestRequiredMenuRebuildVersion + controller.invalidateMenus(allowStaleContentDuringDataRefresh: true) + for _ in 0..<40 where controller.menuVersions[key] == openedVersion { + await Task.yield() + } + + #expect(controller.openMenus.isEmpty) + #expect(requiredVersion > (openedVersion ?? -1)) + #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() + // Use a non-merged attached menu: the merged menu is intentionally never pre-warmed while + // closed (#1274), so the in-flight-refresh prep machinery is exercised via the fallback menu. + controller.fallbackMenu = 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 + controller.invalidateMenus() + for _ in 0..<40 { + await Task.yield() + } + + #expect(controller.menuVersions[key] == openedVersion) + + store.isRefreshing = false + 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 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 = 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() + // Use a non-merged attached menu: the merged menu is intentionally never pre-warmed while + // closed (#1274), so the in-flight-refresh prep machinery is exercised via the fallback menu. + controller.fallbackMenu = menu + controller.statusItem.menu = menu + + controller.populateMenu(menu, provider: nil) + controller.markMenuFresh(menu) + let key = ObjectIdentifier(menu) + let openedVersion = controller.menuVersions[key] + + store.tokenRefreshInFlight.insert(.codex) + controller.invalidateMenus() + for _ in 0..<40 { + await Task.yield() + } + + #expect(controller.menuVersions[key] == openedVersion) + + store.tokenRefreshInFlight.remove(.codex) + 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 menu rebuild cleanup runs when weak menu disappears`() 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() } + + 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 `merged menu close defers stale rebuild until next open`() 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) + controller.menuWillOpen(menu) + + let key = ObjectIdentifier(menu) + let openedVersion = controller.menuVersions[key] + controller.invalidateMenus(refreshOpenMenus: false) + #expect(controller.menuNeedsRefresh(menu)) + + controller.menuDidClose(menu) + await self.waitUntilClosedMenuRebuildRemainsDeferred(controller, key: key, openedVersion: openedVersion) + + #expect(controller.closedMenuRebuildTasks[key] == nil) + #expect(controller.menuVersions[key] == openedVersion) + + controller.menuWillOpen(menu) + #expect(controller.menuVersions[key] == controller.menuContentVersion) + } + + @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( + 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.isRefreshing = true + defer { store.isRefreshing = false } + 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 `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 defer visible parent 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 + + let openedVersion = controller.menuVersions[key] + var rebuildCount = 0 + controller._test_openMenuRebuildObserver = { _ in + rebuildCount += 1 + } + defer { controller._test_openMenuRebuildObserver = nil } + + controller.refreshOpenMenusAfterExplicitStoreAction() + for _ in 0..<20 { + await Task.yield() + } + + #expect(controller.menuContentVersion != openedVersion) + #expect(rebuildCount == 0) + #expect(controller.menuVersions[key] == openedVersion) + #expect(controller.parentMenuRebuildsDeferredDuringTracking.contains(key)) + } + + @Test + func `repeated explicit store actions keep parent rebuild deferred`() 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 { + await Task.yield() + } + + #expect(rebuildCount == 0) + #expect(controller.menuVersions[key] != controller.menuContentVersion) + #expect(controller.parentMenuRebuildsDeferredDuringTracking.contains(key)) + } + + @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) + #expect(!controller.parentMenuRebuildsDeferredDuringTracking.contains(menuKey)) + } + + @Test + func `hosted submenu close waits for active refresh before rebuilding parent`() 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 } + + store.isRefreshing = true + controller.refreshOpenMenusAfterExplicitStoreAction() + controller.menuDidClose(submenu) + for _ in 0..<20 { + await Task.yield() + } + + #expect(controller.openMenus[submenuKey] == nil) + #expect(rebuildCount == 0) + #expect(controller.menuVersions[menuKey] == openedVersion) + #expect(controller.parentMenuRebuildPendingAfterHostedSubviewClose) + + store.isRefreshing = false + controller.handleObservedStoreMenuChange() + for _ in 0..<20 where rebuildCount == 0 { + await Task.yield() + } + + #expect(rebuildCount == 1) + #expect(controller.menuVersions[menuKey] == controller.menuContentVersion) + #expect(!controller.parentMenuRebuildPendingAfterHostedSubviewClose) + #expect(!controller.parentMenuRebuildsDeferredDuringTracking.contains(menuKey)) + } + + @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 rootRebuildCount = 0 + controller._test_openMenuRebuildObserver = { rebuiltMenu in + guard rebuiltMenu === menu else { return } + rootRebuildCount += 1 + } + defer { controller._test_openMenuRebuildObserver = nil } + + controller.deferSwitcherMenuRebuildIfStillVisible(menu, provider: .codex) + controller.refreshOpenMenuIfStillVisible(menu, provider: .codex) + + for _ in 0..<20 where rootRebuildCount == 0 { + await Task.yield() + } + + #expect(controller.openMenus[submenuKey] == nil) + #expect(rootRebuildCount == 1) + #expect(controller.menuVersions[menuKey] == controller.menuContentVersion) + } + + @Test + func `rapid switcher rebuild requests coalesce before populating 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 menuKey = ObjectIdentifier(menu) + controller.openMenus[menuKey] = menu + controller.menuRefreshEnabledOverrideForTesting = true + controller._test_providerSwitcherMenuRebuildDebounceNanoseconds = 0 + defer { controller._test_providerSwitcherMenuRebuildDebounceNanoseconds = nil } + + var rebuildCount = 0 + controller._test_openMenuRebuildObserver = { _ in + rebuildCount += 1 + } + defer { controller._test_openMenuRebuildObserver = nil } + var refreshGateEntries = 0 + var pendingRefreshGates: [CheckedContinuation] = [] + func resumePendingRefreshGates() { + let gates = pendingRefreshGates + pendingRefreshGates.removeAll(keepingCapacity: true) + for gate in gates { + gate.resume() + } + } + controller._test_openMenuRefreshYieldOverride = { + refreshGateEntries += 1 + await withCheckedContinuation { continuation in + pendingRefreshGates.append(continuation) + } + } + defer { + resumePendingRefreshGates() + controller._test_openMenuRefreshYieldOverride = nil + } + + controller.deferSwitcherMenuRebuildIfStillVisible(menu, provider: .codex) + for _ in 0..<20 where refreshGateEntries == 0 { + await Task.yield() + } + #expect(refreshGateEntries == 1) + #expect(rebuildCount == 0) + + controller.deferSwitcherMenuRebuildIfStillVisible(menu, provider: .codex) + resumePendingRefreshGates() + for _ in 0..<20 where refreshGateEntries < 2 { + await Task.yield() + } + #expect(refreshGateEntries == 2) + #expect(rebuildCount == 0) + resumePendingRefreshGates() + + for _ in 0..<20 where rebuildCount == 0 { + await Task.yield() + } + + #expect(rebuildCount == 1) + for _ in 0..<20 { + await Task.yield() + } + #expect(rebuildCount == 1) + } + + @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, @@ -84,33 +1275,95 @@ 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 - controller._test_openMenuRebuildObserver = { _ in - rebuildCount += 1 - } - defer { controller._test_openMenuRebuildObserver = nil } + let openedVersion = try #require(controller.menuVersions[key]) + #expect(self.menuItem(in: menu, id: "menuCardCredits") == nil) - controller.refreshOpenMenusAfterExplicitStoreAction() - for _ in 0..<20 where rebuildCount == 0 { - await Task.yield() - } + 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(rebuildCount == 1) - #expect(controller.menuVersions[key] != 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 `repeated explicit store actions coalesce to one open menu rebuild`() async { + 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 + settings.costSummaryDisplayStyle = .both + self.enableOnlyCodex(settings) let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) let controller = StatusItemController( @@ -126,36 +1379,45 @@ extension StatusMenuTests { controller.menuWillOpen(menu) let key = ObjectIdentifier(menu) controller.openMenus[key] = menu - StatusItemController.setMenuRefreshEnabledForTesting(true) - defer { StatusItemController.resetMenuRefreshEnabledForTesting() } + controller.menuRefreshEnabledOverrideForTesting = true - var rebuildCount = 0 - controller._test_openMenuRebuildObserver = { _ in - rebuildCount += 1 - } - defer { controller._test_openMenuRebuildObserver = nil } + let openedVersion = try #require(controller.menuVersions[key]) + #expect(self.menuItem(in: menu, id: "menuCardCost") == nil) - controller.refreshOpenMenusAfterExplicitStoreAction() - controller.refreshOpenMenusAfterExplicitStoreAction() - controller.refreshOpenMenusAfterExplicitStoreAction() + store._setTokenSnapshotForTesting(self.makeCodexTokenCostSnapshot(), provider: .codex) - for _ in 0..<20 where rebuildCount == 0 { - await Task.yield() - } + await self.waitUntilOpenMenuStaysStale(controller, key: key, after: openedVersion) - #expect(rebuildCount == 1) + #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 `plain open menu refresh preserves pending switcher hosted submenu cleanup`() async { + 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 + settings.costSummaryDisplayStyle = .both + 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, @@ -167,32 +1429,315 @@ extension StatusMenuTests { let menu = controller.makeMenu() controller.menuWillOpen(menu) - let menuKey = ObjectIdentifier(menu) - controller.openMenus[menuKey] = menu + let key = ObjectIdentifier(menu) + controller.openMenus[key] = menu + controller.menuRefreshEnabledOverrideForTesting = true - let submenu = controller.makeHostedSubviewPlaceholderMenu( - chartID: StatusItemController.usageBreakdownChartID, + 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) - let submenuKey = ObjectIdentifier(submenu) - controller.openMenus[submenuKey] = submenu - StatusItemController.setMenuRefreshEnabledForTesting(true) - defer { StatusItemController.resetMenuRefreshEnabledForTesting() } - var rebuildCount = 0 - controller._test_openMenuRebuildObserver = { _ in - rebuildCount += 1 + 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) } - defer { controller._test_openMenuRebuildObserver = nil } + } - controller.deferSwitcherMenuRebuildIfStillVisible(menu, provider: .codex) - controller.refreshOpenMenuIfStillVisible(menu, provider: .codex) + private func menuItem(in menu: NSMenu, id: String) -> NSMenuItem? { + menu.items.first { ($0.representedObject as? String) == id } + } - for _ in 0..<20 where rebuildCount == 0 { + private func waitUntilMenuVersionChanges( + _ controller: StatusItemController, + from version: Int?) async + { + for _ in 0..<20 where controller.menuContentVersion == version { await Task.yield() } + } - #expect(controller.openMenus[submenuKey] == nil) - #expect(rebuildCount == 1) - #expect(controller.menuVersions[menuKey] == controller.menuContentVersion) + 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 waitUntilClosedMenuRebuildRemainsDeferred( + _ controller: StatusItemController, + key: ObjectIdentifier, + openedVersion: Int?) async + { + for _ in 0..<40 + where controller.closedMenuRebuildTasks[key] != nil || + controller.menuVersions[key] != openedVersion + { + await Task.yield() + } + } + + 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")) + } + + /// The recent-interaction signal that `AdaptiveRefreshPolicy` reads has exactly one production + /// entry point: `StatusItemController.menuWillOpen(_:)` calling `store.noteMenuOpened()`. Every + /// other adaptive-refresh test drives `UsageStore` directly, so none of them would catch that + /// wiring line being deleted — this test drives the real menu-open path instead. + @Test + func `menuWillOpen records the menu-open signal on the store`() { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + #expect(store.lastMenuOpenAt == nil) + + 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) + + #expect(store.lastMenuOpenAt != nil) + } +} + +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/StatusMenuOverviewClickTests.swift b/Tests/CodexBarTests/StatusMenuOverviewClickTests.swift new file mode 100644 index 0000000000..d8e2ee856c --- /dev/null +++ b/Tests/CodexBarTests/StatusMenuOverviewClickTests.swift @@ -0,0 +1,300 @@ +import AppKit +import CodexBarCore +import SwiftUI +import Testing +@testable import CodexBar + +@MainActor +@Suite(.serialized) +struct StatusMenuOverviewClickTests { + @Test + func `routes runtime click without gesture recognizer`() { + var clicked = false + let view = MenuCardItemHostingView( + rootView: Text("Overview row"), + highlightState: MenuCardHighlightState(), + allowsMenuHighlight: true, + onClick: { clicked = true }) + view.frame = NSRect(x: 0, y: 0, width: 320, height: 44) + #expect(view._test_simulateRuntimeClick()) + #expect(clicked) + } + + @Test + func `routes gpu selection runtime click without gesture recognizer`() { + var clicked = false + let wrapped = MenuCardSectionContainerView( + highlightState: MenuCardHighlightState(), + showsSubmenuIndicator: false, + submenuIndicatorAlignment: .trailing, + submenuIndicatorTopPadding: 0, + refreshMonitor: nil) + { + Text("Overview GPU row") + } + let view = GPUSelectionHostingView( + rootView: wrapped, + allowsMenuHighlight: true, + onClick: { clicked = true }) + view.frame = NSRect(x: 0, y: 0, width: 320, height: 44) + #expect(view._test_simulateRuntimeClick()) + #expect(clicked) + } + + @Test + func `gpu tracking activates only for mouseUp inside row`() { + let wrapped = MenuCardSectionContainerView( + highlightState: MenuCardHighlightState(), + showsSubmenuIndicator: false, + submenuIndicatorAlignment: .trailing, + submenuIndicatorTopPadding: 0, + refreshMonitor: nil) + { + Text("Overview GPU row") + } + let view = GPUSelectionHostingView( + rootView: wrapped, + allowsMenuHighlight: true, + onClick: {}) + view.frame = NSRect(x: 0, y: 0, width: 320, height: 44) + let events = Self.mouseClick(at: NSPoint(x: 160, y: 22)) + + #expect(view._test_primaryPressDecision(for: events.down) == nil) + #expect(view._test_primaryPressDecision(for: events.up) == true) + } + + @Test + func `gpu tracking cancels when release leaves row`() { + let wrapped = MenuCardSectionContainerView( + highlightState: MenuCardHighlightState(), + showsSubmenuIndicator: true, + submenuIndicatorAlignment: .trailing, + submenuIndicatorTopPadding: 0, + refreshMonitor: nil) + { + Text("Overview GPU row") + } + let view = GPUSelectionHostingView( + rootView: wrapped, + allowsMenuHighlight: true, + onClick: {}) + view.frame = NSRect(x: 0, y: 0, width: 320, height: 44) + let outsideUp = Self.mouseClick(at: NSPoint(x: 340, y: 22)).up + + #expect(view._test_primaryPressDecision(for: outsideUp) == false) + } + + @Test + func `gpu tracking yields an outside drag to native submenu tracking`() { + let wrapped = MenuCardSectionContainerView( + highlightState: MenuCardHighlightState(), + showsSubmenuIndicator: true, + submenuIndicatorAlignment: .trailing, + submenuIndicatorTopPadding: 0, + refreshMonitor: nil) + { + Text("Overview GPU row") + } + let view = GPUSelectionHostingView( + rootView: wrapped, + allowsMenuHighlight: true, + onClick: {}) + view.frame = NSRect(x: 0, y: 0, width: 320, height: 44) + + let insideDrag = Self.mouseDrag(at: NSPoint(x: 160, y: 22)) + let outsideDrag = Self.mouseDrag(at: NSPoint(x: 340, y: 22)) + #expect(!view._test_primaryPressShouldYieldToMenu(for: insideDrag)) + #expect(view._test_primaryPressShouldYieldToMenu(for: outsideDrag)) + } + + @Test + func `hitTest preserves button targets in standard hosting view`() { + let view = MenuCardItemHostingView( + rootView: Text("Overview row"), + highlightState: MenuCardHighlightState(), + allowsMenuHighlight: true, + onClick: {}) + view.frame = NSRect(x: 0, y: 0, width: 320, height: 44) + let button = NSButton(frame: NSRect(x: 10, y: 10, width: 50, height: 20)) + view.addSubview(button) + + let hit = view.hitTest(NSPoint(x: 15, y: 15)) + #expect(hit !== view) + #expect(hit === button || hit?.isDescendant(of: button) == true) + } + + @Test + func `hitTest preserves button targets in gpu selection hosting view`() { + let wrapped = MenuCardSectionContainerView( + highlightState: MenuCardHighlightState(), + showsSubmenuIndicator: false, + submenuIndicatorAlignment: .trailing, + submenuIndicatorTopPadding: 0, + refreshMonitor: nil) + { + Text("Overview GPU row") + } + let view = GPUSelectionHostingView( + rootView: wrapped, + allowsMenuHighlight: true, + onClick: {}) + view.frame = NSRect(x: 0, y: 0, width: 320, height: 44) + let button = NSButton(frame: NSRect(x: 10, y: 10, width: 50, height: 20)) + view.addSubview(button) + + let hit = view.hitTest(NSPoint(x: 15, y: 15)) + #expect(hit !== view) + #expect(hit === button || hit?.isDescendant(of: button) == true) + } + + @Test + func `gpu hosting preserves nested SwiftUI button target`() { + let interactiveRegionStore = MenuCardInteractiveRegionStore() + let content = Button("Copy") {} + .frame(width: 80, height: 30) + .menuCardInteractiveControl() + .frame(width: 320, height: 44, alignment: .trailing) + let wrapped = MenuCardSectionContainerView( + highlightState: MenuCardHighlightState(), + showsSubmenuIndicator: false, + submenuIndicatorAlignment: .trailing, + submenuIndicatorTopPadding: 0, + refreshMonitor: nil, + interactiveRegionStore: interactiveRegionStore) + { + content + } + let view = GPUSelectionHostingView( + rootView: wrapped, + allowsMenuHighlight: true, + containsInteractiveControls: true, + interactiveRegionStore: interactiveRegionStore, + onClick: {}) + view.frame = NSRect(x: 0, y: 0, width: 320, height: 51) + Self.settleWindowlessLayout(view) + let buttonPoint = NSPoint(x: 280, y: 39) + + #expect(view._test_hitsHostedInteractiveControl(at: buttonPoint)) + #expect(!view._test_hitsHostedInteractiveControl(at: NSPoint(x: 280, y: 8))) + #expect(view.hitTest(buttonPoint) !== view) + #expect(!view._test_simulateRuntimeClick(at: buttonPoint)) + } + + @Test + func `standard hosting forwards nested SwiftUI control events without invoking row`() { + var rowClicked = false + let interactiveRegionStore = MenuCardInteractiveRegionStore() + let content = Button("Copy") {} + .frame(width: 80, height: 30) + .menuCardInteractiveControl() + .frame(width: 320, height: 44, alignment: .trailing) + let wrapped = MenuCardSectionContainerView( + highlightState: MenuCardHighlightState(), + showsSubmenuIndicator: false, + submenuIndicatorAlignment: .trailing, + submenuIndicatorTopPadding: 0, + refreshMonitor: nil, + interactiveRegionStore: interactiveRegionStore) + { + content + } + let view = MenuCardItemHostingView( + rootView: wrapped, + highlightState: MenuCardHighlightState(), + allowsMenuHighlight: true, + containsInteractiveControls: true, + interactiveRegionStore: interactiveRegionStore, + onClick: { rowClicked = true }) + view.frame = NSRect(x: 0, y: 0, width: 320, height: 51) + Self.settleWindowlessLayout(view) + let buttonPoint = NSPoint(x: 280, y: 39) + + #expect(view._test_hitsHostedInteractiveControl(at: buttonPoint)) + #expect(!view._test_hitsHostedInteractiveControl(at: NSPoint(x: 280, y: 8))) + let events = Self.mouseClick(at: buttonPoint) + view.mouseDown(with: events.down) + view.mouseUp(with: events.up) + let forwarded = view._test_forwardedHostedControlEvents + #expect(forwarded.mouseDown) + #expect(forwarded.mouseUp) + #expect(!rowClicked) + } + + @Test + func `hidden SwiftUI button region keeps row clickable`() { + var rowClicked = false + let interactiveRegionStore = MenuCardInteractiveRegionStore() + let content = Button("Hidden copy") {} + .frame(width: 80, height: 30) + .menuCardInteractiveControl(isEnabled: false) + .frame(width: 320, height: 44, alignment: .trailing) + let wrapped = MenuCardSectionContainerView( + highlightState: MenuCardHighlightState(), + showsSubmenuIndicator: false, + submenuIndicatorAlignment: .trailing, + submenuIndicatorTopPadding: 0, + refreshMonitor: nil, + interactiveRegionStore: interactiveRegionStore) + { + content + } + let view = MenuCardItemHostingView( + rootView: wrapped, + highlightState: MenuCardHighlightState(), + allowsMenuHighlight: true, + containsInteractiveControls: true, + interactiveRegionStore: interactiveRegionStore, + onClick: { rowClicked = true }) + view.frame = NSRect(x: 0, y: 0, width: 320, height: 44) + Self.settleWindowlessLayout(view) + let buttonPoint = NSPoint(x: 280, y: 22) + + #expect(!view._test_hitsHostedInteractiveControl(at: buttonPoint)) + #expect(view._test_simulateRuntimeClick(at: buttonPoint)) + #expect(rowClicked) + } + + private static func settleWindowlessLayout(_ view: NSView) { + view.needsLayout = true + view.layoutSubtreeIfNeeded() + RunLoop.current.run(until: Date().addingTimeInterval(0.02)) + view.layoutSubtreeIfNeeded() + } + + private static func mouseClick(at point: NSPoint) -> (down: NSEvent, up: NSEvent) { + let down = NSEvent.mouseEvent( + with: .leftMouseDown, + location: point, + modifierFlags: [], + timestamp: 0, + windowNumber: 0, + context: nil, + eventNumber: 1, + clickCount: 1, + pressure: 1)! + let up = NSEvent.mouseEvent( + with: .leftMouseUp, + location: point, + modifierFlags: [], + timestamp: 0, + windowNumber: 0, + context: nil, + eventNumber: 2, + clickCount: 1, + pressure: 0)! + return (down, up) + } + + private static func mouseDrag(at point: NSPoint) -> NSEvent { + NSEvent.mouseEvent( + with: .leftMouseDragged, + location: point, + modifierFlags: [], + timestamp: 0, + windowNumber: 0, + context: nil, + eventNumber: 3, + clickCount: 1, + pressure: 1)! + } +} diff --git a/Tests/CodexBarTests/StatusMenuOverviewScrollTests.swift b/Tests/CodexBarTests/StatusMenuOverviewScrollTests.swift new file mode 100644 index 0000000000..2a4b7b2fde --- /dev/null +++ b/Tests/CodexBarTests/StatusMenuOverviewScrollTests.swift @@ -0,0 +1,203 @@ +import AppKit +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +@MainActor +struct StatusMenuOverviewScrollTests { + private func makeController(suiteName: String) -> StatusItemController { + _ = NSApplication.shared + let settings = SettingsStore( + configStore: testConfigStore(suiteName: suiteName), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + let fetcher = UsageFetcher() + let store = UsageStore( + fetcher: fetcher, + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings) + return StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: .system) + } + + private func makeOverviewMenu() -> NSMenu { + let menu = NSMenu() + for provider in ["claude", "codex"] { + let item = NSMenuItem() + item.representedObject = "\(StatusItemController.overviewRowIdentifierPrefix)\(provider)" + item.isEnabled = true + menu.addItem(item) + } + return menu + } + + private func makeScrollEvent(deltaY: Double, precise: Bool) -> NSEvent? { + guard let cgEvent = CGEvent( + scrollWheelEvent2Source: nil, + units: precise ? .pixel : .line, + wheelCount: 1, + wheel1: Int32(deltaY), + wheel2: 0, + wheel3: 0) + else { return nil } + return NSEvent(cgEvent: cgEvent) + } + + @Test + func `coarse wheel steps move highlight and respect direction`() throws { + let controller = self.makeController(suiteName: "OverviewScroll-Direction") + defer { controller.releaseStatusItemsForTesting() } + let menu = self.makeOverviewMenu() + + var steps: [OverviewScrollStep] = [] + controller.overviewScrollNavigationHandlerForTesting = { steps.append($0) } + + let scrollUp = try #require(self.makeScrollEvent(deltaY: 1, precise: false)) + #expect(controller.handleOverviewScrollWheel(scrollUp, menu: menu)) + #expect(steps == [.up]) + + steps = [] + let scrollDown = try #require(self.makeScrollEvent(deltaY: -1, precise: false)) + #expect(controller.handleOverviewScrollWheel(scrollDown, menu: menu)) + #expect(steps == [.down]) + } + + @Test + func `navigation targets only overview rows`() { + let controller = self.makeController(suiteName: "OverviewScroll-Targets") + defer { controller.releaseStatusItemsForTesting() } + let menu = self.makeOverviewMenu() + let refresh = NSMenuItem(title: "Refresh", action: nil, keyEquivalent: "") + refresh.isEnabled = true + menu.addItem(refresh) + let rows = Array(menu.items.prefix(2)) + + #expect(controller.overviewScrollTargetItem(in: menu, step: .down) === rows[0]) + #expect(controller.overviewScrollTargetItem(in: menu, step: .up) === rows[1]) + + controller.highlightedMenuItems[ObjectIdentifier(menu)] = rows[0] + #expect(controller.overviewScrollTargetItem(in: menu, step: .down) === rows[1]) + #expect(controller.overviewScrollTargetItem(in: menu, step: .up) === rows[0]) + + controller.highlightedMenuItems[ObjectIdentifier(menu)] = rows[1] + #expect(controller.overviewScrollTargetItem(in: menu, step: .down) === rows[1]) + #expect(controller.overviewScrollTargetItem(in: menu, step: .up) === rows[0]) + + controller.highlightedMenuItems[ObjectIdentifier(menu)] = refresh + #expect(controller.overviewScrollTargetItem(in: menu, step: .down) === rows[0]) + } + + @Test + func `precise trackpad scrolling is passed through to native menu scrolling`() throws { + let controller = self.makeController(suiteName: "OverviewScroll-Precise") + defer { controller.releaseStatusItemsForTesting() } + let menu = self.makeOverviewMenu() + + var steps: [OverviewScrollStep] = [] + controller.overviewScrollNavigationHandlerForTesting = { steps.append($0) } + + let scroll = try #require(self.makeScrollEvent(deltaY: 30, precise: true)) + #expect(!controller.handleOverviewScrollWheel(scroll, menu: menu)) + #expect(steps.isEmpty) + } + + @Test + func `precise trackpad scrolling clears wheel accumulation`() throws { + let controller = self.makeController(suiteName: "OverviewScroll-PreciseReset") + defer { controller.releaseStatusItemsForTesting() } + let menu = self.makeOverviewMenu() + + var steps: [OverviewScrollStep] = [] + controller.overviewScrollNavigationHandlerForTesting = { steps.append($0) } + + controller.overviewScrollAccumulatedDelta = 0.5 + let scroll = try #require(self.makeScrollEvent(deltaY: 30, precise: true)) + #expect(!controller.handleOverviewScrollWheel(scroll, menu: menu)) + #expect(steps.isEmpty) + #expect(controller.overviewScrollAccumulatedDelta == 0) + } + + @Test + func `coarse wheel lines step immediately`() throws { + let controller = self.makeController(suiteName: "OverviewScroll-Wheel") + defer { controller.releaseStatusItemsForTesting() } + let menu = self.makeOverviewMenu() + + var steps: [OverviewScrollStep] = [] + controller.overviewScrollNavigationHandlerForTesting = { steps.append($0) } + + let wheelNotch = try #require(self.makeScrollEvent(deltaY: -1, precise: false)) + #expect(controller.handleOverviewScrollWheel(wheelNotch, menu: menu)) + #expect(steps == [.down]) + } + + @Test + func `fast flick is capped per event`() throws { + let controller = self.makeController(suiteName: "OverviewScroll-Cap") + defer { controller.releaseStatusItemsForTesting() } + let menu = self.makeOverviewMenu() + + var steps: [OverviewScrollStep] = [] + controller.overviewScrollNavigationHandlerForTesting = { steps.append($0) } + + let flick = try #require(self.makeScrollEvent(deltaY: 500, precise: false)) + #expect(controller.handleOverviewScrollWheel(flick, menu: menu)) + #expect(steps == [.up, .up, .up]) + } + + @Test + func `precise flick is passed through instead of being capped into highlight jumps`() throws { + let controller = self.makeController(suiteName: "OverviewScroll-PreciseFlick") + defer { controller.releaseStatusItemsForTesting() } + let menu = self.makeOverviewMenu() + + var steps: [OverviewScrollStep] = [] + controller.overviewScrollNavigationHandlerForTesting = { steps.append($0) } + + let flick = try #require(self.makeScrollEvent(deltaY: 500, precise: true)) + #expect(!controller.handleOverviewScrollWheel(flick, menu: menu)) + #expect(steps.isEmpty) + } + + @Test + func `open submenu suspends scroll navigation`() throws { + let controller = self.makeController(suiteName: "OverviewScroll-Submenu") + defer { controller.releaseStatusItemsForTesting() } + let menu = self.makeOverviewMenu() + let submenu = NSMenu() + controller.openMenus[ObjectIdentifier(menu)] = menu + controller.openMenus[ObjectIdentifier(submenu)] = submenu + defer { controller.openMenus.removeAll() } + + var steps: [OverviewScrollStep] = [] + controller.overviewScrollNavigationHandlerForTesting = { steps.append($0) } + + let scroll = try #require(self.makeScrollEvent(deltaY: 1, precise: false)) + #expect(!controller.handleOverviewScrollWheel(scroll, menu: menu)) + #expect(steps.isEmpty) + } + + @Test + func `menus without overview rows ignore scrolling`() throws { + let controller = self.makeController(suiteName: "OverviewScroll-NonOverview") + defer { controller.releaseStatusItemsForTesting() } + let menu = NSMenu() + menu.addItem(NSMenuItem(title: "Refresh", action: nil, keyEquivalent: "")) + + var steps: [OverviewScrollStep] = [] + controller.overviewScrollNavigationHandlerForTesting = { steps.append($0) } + + let scroll = try #require(self.makeScrollEvent(deltaY: 1, precise: false)) + #expect(!controller.handleOverviewScrollWheel(scroll, menu: menu)) + #expect(steps.isEmpty) + #expect(!menu.items.isEmpty) + } +} diff --git a/Tests/CodexBarTests/StatusMenuOverviewSubmenuTests.swift b/Tests/CodexBarTests/StatusMenuOverviewSubmenuTests.swift index 6b90c164e6..b1834e0d45 100644 --- a/Tests/CodexBarTests/StatusMenuOverviewSubmenuTests.swift +++ b/Tests/CodexBarTests/StatusMenuOverviewSubmenuTests.swift @@ -13,6 +13,8 @@ extension StatusMenuTests { settings.mergeIcons = true settings.selectedMenuProvider = .openai settings.mergedMenuLastSelectedWasOverview = true + settings.costUsageEnabled = true + settings.costSummaryDisplayStyle = .both let registry = ProviderRegistry.shared for provider in UsageProvider.allCases { @@ -61,4 +63,280 @@ extension StatusMenuTests { ($0.representedObject as? String) == StatusItemController.costHistoryChartID } == true) } + + @Test + func `overview row shows plan usage not cost history for opencodego`() throws { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.selectedMenuProvider = .opencodego + settings.mergedMenuLastSelectedWasOverview = true + settings.costUsageEnabled = true + // Deliberately NOT `.costSubmenu`/`.both`: opencodego has real rate-limit bars (unlike + // mistral), so its Overview row must fall through to Plan Usage here rather than + // unconditionally preferring cost history the way mistral's Overview row does. + settings.costSummaryDisplayStyle = .inlineSummary + + let registry = ProviderRegistry.shared + for provider in UsageProvider.allCases { + guard let metadata = registry.metadata[provider] else { continue } + let shouldEnable = provider == .opencodego || provider == .codex + settings.setProviderEnabled(provider: provider, metadata: metadata, enabled: shouldEnable) + } + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + let opencodegoSnapshot = OpenCodeGoUsageSnapshot( + hasMonthlyUsage: true, + rollingUsagePercent: 12, + weeklyUsagePercent: 57, + monthlyUsagePercent: 34, + rollingResetInSec: 3600, + weeklyResetInSec: 86400, + monthlyResetInSec: 864_000, + daily: [ + CostUsageDailyReport.Entry( + date: "2025-12-23", + inputTokens: nil, + outputTokens: nil, + totalTokens: nil, + requestCount: 5, + costUSD: 1.23, + modelsUsed: nil, + modelBreakdowns: nil), + ], + updatedAt: Date()) + store._setSnapshotForTesting(opencodegoSnapshot.toUsageSnapshot(), provider: .opencodego) + + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + + let menu = controller.makeMenu() + controller.menuWillOpen(menu) + + let opencodegoRow = try #require(menu.items.first { + ($0.representedObject as? String) == "overviewRow-opencodego" + }) + #expect(opencodegoRow.submenu?.items.contains { + ($0.representedObject as? String) == StatusItemController.usageHistoryChartID + } == true) + #expect(opencodegoRow.submenu?.items.contains { + ($0.representedObject as? String) == StatusItemController.costHistoryChartID + } == false) + } + + @Test + func `overview row submenu action does not switch provider detail`() throws { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.selectedMenuProvider = .claude + settings.mergedMenuLastSelectedWasOverview = true + + let registry = ProviderRegistry.shared + for provider in UsageProvider.allCases { + guard let metadata = registry.metadata[provider] else { continue } + let shouldEnable = provider == .zai || provider == .claude + settings.setProviderEnabled(provider: provider, metadata: metadata, enabled: shouldEnable) + } + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + let now = Date(timeIntervalSince1970: 1_700_000_000) + let usage = ZaiUsageSnapshot( + tokenLimit: nil, + timeLimit: ZaiLimitEntry( + type: .timeLimit, + unit: .minutes, + number: 1, + usage: 100, + currentValue: 50, + remaining: 50, + percentage: 50, + usageDetails: [ZaiUsageDetail(modelCode: "glm-4.5", usage: 512)], + nextResetTime: now.addingTimeInterval(3600)), + planName: "Pro", + updatedAt: now) + store._setSnapshotForTesting(usage.toUsageSnapshot(), provider: .zai) + + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + + let menu = controller.makeMenu() + controller.menuWillOpen(menu) + + let zaiRow = try #require(menu.items.first { + ($0.representedObject as? String) == "overviewRow-zai" + }) + #expect(zaiRow.submenu != nil) + + let action = try #require(zaiRow.action) + let target = try #require(zaiRow.target as? StatusItemController) + _ = target.perform(action, with: zaiRow) + + #expect(settings.mergedMenuLastSelectedWasOverview) + #expect(settings.selectedMenuProvider == .claude) + #expect(menu.items.contains { + ($0.representedObject as? String) == "overviewRow-zai" + }) + } + + @Test + func `selecting overview row defers provider detail rebuild`() async throws { + let previousMenuCardRendering = StatusItemController.menuCardRenderingEnabled + let previousMenuRefresh = StatusItemController.menuRefreshEnabled + StatusItemController.menuCardRenderingEnabled = false + StatusItemController.setMenuRefreshEnabledForTesting(true) + defer { + StatusItemController.menuCardRenderingEnabled = previousMenuCardRendering + StatusItemController.setMenuRefreshEnabledForTesting(previousMenuRefresh) + } + + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.selectedMenuProvider = .codex + settings.mergedMenuLastSelectedWasOverview = true + + let registry = ProviderRegistry.shared + for provider in UsageProvider.allCases { + guard let metadata = registry.metadata[provider] else { continue } + let shouldEnable = provider == .codex || provider == .cursor + settings.setProviderEnabled(provider: provider, metadata: metadata, enabled: shouldEnable) + } + + 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()) + defer { controller.releaseStatusItemsForTesting() } + + let menu = controller.makeMenu() + controller.menuWillOpen(menu) + defer { controller.menuDidClose(menu) } + + let cursorRow = try #require(menu.items.first { + ($0.representedObject as? String) == "overviewRow-cursor" + }) + var rebuildCount = 0 + controller._test_openMenuRebuildObserver = { _ in + rebuildCount += 1 + } + defer { controller._test_openMenuRebuildObserver = nil } + + let action = try #require(cursorRow.action) + let target = try #require(cursorRow.target as? StatusItemController) + _ = target.perform(action, with: cursorRow) + + #expect(settings.mergedMenuLastSelectedWasOverview == false) + #expect(settings.selectedMenuProvider == .cursor) + #expect(rebuildCount == 0) + #expect(menu.items.contains { + ($0.representedObject as? String)?.hasPrefix("overviewRow-") == true + }) + + for _ in 0..<100 where rebuildCount == 0 { + await Task.yield() + try? await Task.sleep(for: .milliseconds(10)) + } + + let representedIDs = menu.items.compactMap { $0.representedObject as? String } + let switcherButtons = (menu.items.first?.view as? ProviderSwitcherView)?.subviews + .compactMap { $0 as? NSButton } ?? [] + #expect(rebuildCount == 1) + #expect(representedIDs.contains("menuCard")) + #expect(representedIDs.contains(where: { $0.hasPrefix("overviewRow-") }) == false) + #expect(switcherButtons.first(where: { $0.state == .on })?.tag == 2) + } + + @Test + func `overview row action close renders selected provider on next open`() async throws { + let previousMenuCardRendering = StatusItemController.menuCardRenderingEnabled + let previousMenuRefresh = StatusItemController.menuRefreshEnabled + StatusItemController.menuCardRenderingEnabled = false + StatusItemController.setMenuRefreshEnabledForTesting(true) + defer { + StatusItemController.menuCardRenderingEnabled = previousMenuCardRendering + StatusItemController.setMenuRefreshEnabledForTesting(previousMenuRefresh) + } + + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.selectedMenuProvider = .codex + settings.mergedMenuLastSelectedWasOverview = true + + let registry = ProviderRegistry.shared + for provider in UsageProvider.allCases { + guard let metadata = registry.metadata[provider] else { continue } + let shouldEnable = provider == .codex || provider == .cursor + settings.setProviderEnabled(provider: provider, metadata: metadata, enabled: shouldEnable) + } + + 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()) + defer { controller.releaseStatusItemsForTesting() } + + let menu = controller.makeMenu() + controller.menuWillOpen(menu) + + let cursorRow = try #require(menu.items.first { + ($0.representedObject as? String) == "overviewRow-cursor" + }) + var rebuildCount = 0 + controller._test_openMenuRebuildObserver = { _ in + rebuildCount += 1 + } + defer { controller._test_openMenuRebuildObserver = nil } + + let action = try #require(cursorRow.action) + let target = try #require(cursorRow.target as? StatusItemController) + _ = target.perform(action, with: cursorRow) + controller.menuDidClose(menu) + + await Task.yield() + await Task.yield() + #expect(rebuildCount == 0) + #expect(settings.selectedMenuProvider == .cursor) + + controller.menuWillOpen(menu) + defer { controller.menuDidClose(menu) } + + let representedIDs = menu.items.compactMap { $0.representedObject as? String } + let switcherButtons = (menu.items.first?.view as? ProviderSwitcherView)?.subviews + .compactMap { $0 as? NSButton } ?? [] + #expect(representedIDs.contains("menuCard")) + #expect(representedIDs.contains(where: { $0.hasPrefix("overviewRow-") }) == false) + #expect(switcherButtons.first(where: { $0.state == .on })?.tag == 2) + } } diff --git a/Tests/CodexBarTests/StatusMenuPersistentRefreshTests.swift b/Tests/CodexBarTests/StatusMenuPersistentRefreshTests.swift index dc52083750..69aa18a658 100644 --- a/Tests/CodexBarTests/StatusMenuPersistentRefreshTests.swift +++ b/Tests/CodexBarTests/StatusMenuPersistentRefreshTests.swift @@ -1,16 +1,24 @@ import AppKit import CodexBarCore +import SwiftUI import Testing @testable import CodexBar private final class RefreshShortcutRecorder: StatusItemMenuPersistentActionDelegate { var refreshCount = 0 + var refreshMenuIDs: [ObjectIdentifier] = [] + var refreshMenuInteractionGenerations: [Int] = [] var settingsCount = 0 var quitCount = 0 var navigationDirections: [StatusItemMenuProviderNavigationDirection] = [] - func performPersistentRefreshAction() { + func performPersistentRefreshAction( + in menuID: ObjectIdentifier, + menuInteractionGeneration: Int) + { self.refreshCount += 1 + self.refreshMenuIDs.append(menuID) + self.refreshMenuInteractionGenerations.append(menuInteractionGeneration) } func performPersistentSettingsAction() { @@ -38,38 +46,117 @@ private final class UpdateReadyUpdater: UpdaterProviding { func installUpdate() {} } +@MainActor +private final class ManualRefreshGate { + private var continuation: CheckedContinuation? + private var isOpen = false + + func wait() async { + if self.isOpen { + self.isOpen = false + return + } + await withCheckedContinuation { continuation in + self.continuation = continuation + } + } + + func resume() { + if let continuation = self.continuation { + continuation.resume() + self.continuation = nil + } else { + self.isOpen = true + } + } + + func waitUntilSignaled(timeout: Duration = .seconds(5)) async -> Bool { + let deadline = ContinuousClock.now + timeout + while !self.isOpen { + if ContinuousClock.now >= deadline { + return false + } + try? await Task.sleep(for: .milliseconds(10)) + } + self.isOpen = false + return true + } +} + +enum BlockingEnrichmentStage: Sendable { + case credits + case dashboard +} + @MainActor @Suite(.serialized) struct StatusMenuPersistentRefreshTests { private func makeSettings() -> SettingsStore { - let suite = "StatusMenuPersistentRefreshTests-\(UUID().uuidString)" - let defaults = UserDefaults(suiteName: suite)! - defaults.removePersistentDomain(forName: suite) - let configStore = testConfigStore(suiteName: suite) - return SettingsStore( - userDefaults: defaults, - configStore: configStore, - zaiTokenStore: NoopZaiTokenStore(), - syntheticTokenStore: NoopSyntheticTokenStore()) + testSettingsStore(suiteName: "StatusMenuPersistentRefreshTests") } private func makeController( settings: SettingsStore, - updater: UpdaterProviding = DisabledUpdaterController()) -> StatusItemController + updater: UpdaterProviding = DisabledUpdaterController(), + account: AccountInfo? = nil) -> StatusItemController { - let fetcher = UsageFetcher() - let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + let environment = Self.isolatedEnvironment() + let fetcher = UsageFetcher(environment: environment) + let store = UsageStore( + fetcher: fetcher, + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + startupBehavior: .testing, + environmentBase: environment) + if let account { + store.accountInfoCache[.codex] = UsageStore.AccountInfoCacheEntry( + account: account, + configRevision: settings.configRevision, + expiresAt: .distantFuture) + } return StatusItemController( store: store, settings: settings, - account: fetcher.loadAccountInfo(), + account: account ?? AccountInfo(email: nil, plan: nil), updater: updater, preferencesSelection: PreferencesSelection(), statusBar: .system) } + private static func isolatedEnvironment() -> [String: String] { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("codexbar-tests", isDirectory: true) + .appendingPathComponent(UUID().uuidString, isDirectory: true) + return [ + "HOME": root.path, + "CODEX_HOME": root.appendingPathComponent(".codex", isDirectory: true).path, + "XDG_CONFIG_HOME": root.appendingPathComponent(".config", isDirectory: true).path, + ] + } + + private func enableOnly(_ providers: Set, settings: SettingsStore) { + for provider in UsageProvider.allCases { + guard let metadata = ProviderRegistry.shared.metadata[provider] else { continue } + settings.setProviderEnabled(provider: provider, metadata: metadata, enabled: providers.contains(provider)) + } + } + + private static func makeTokenSnapshot() -> CostUsageTokenSnapshot { + CostUsageTokenSnapshot( + sessionTokens: 123, + sessionCostUSD: 0.12, + last30DaysTokens: 456, + last30DaysCostUSD: 1.23, + daily: [], + updatedAt: Date()) + } + @Test - func `refresh menu item is view backed so mouse activation keeps the menu open`() throws { + func `refresh row is custom and appears above settings`() throws { + let previousRendering = StatusItemController.menuCardRenderingEnabled + StatusItemController.menuCardRenderingEnabled = true + defer { StatusItemController.menuCardRenderingEnabled = previousRendering } + let settings = self.makeSettings() settings.refreshFrequency = .manual settings.mergeIcons = false @@ -80,15 +167,62 @@ struct StatusMenuPersistentRefreshTests { controller.menuWillOpen(menu) let refreshItem = try #require(menu.items.first { $0.title == "Refresh" }) + let settingsItem = try #require(menu.items.first { $0.title == "Settings..." }) + let refreshIndex = try #require(menu.items.firstIndex(where: { $0 === refreshItem })) + let settingsIndex = try #require(menu.items.firstIndex(where: { $0 === settingsItem })) + #expect(refreshItem.action == nil) #expect(refreshItem.target == nil) - #expect(refreshItem.view != nil) - #expect(refreshItem.keyEquivalent == "r") - #expect(refreshItem.keyEquivalentModifierMask == [.command]) + let refreshView = try #require(refreshItem.view) + #expect(refreshView is any MenuCardHighlighting) + #expect(refreshView.fittingSize.height > 0) + #expect(controller.isPersistentRefreshItem(refreshItem)) + #expect(refreshItem.keyEquivalent.isEmpty) + #expect(refreshItem.keyEquivalentModifierMask.isEmpty) + #expect(refreshIndex < settingsIndex) + } + + @Test + func `persistent refresh installs tracking monitor and handles command R without native shortcut`() async throws { + let settings = self.makeSettings() + settings.refreshFrequency = .manual + settings.mergeIcons = false + + let controller = self.makeController(settings: settings) + controller.menuRefreshEnabledOverrideForTesting = true + let menu = controller.makeMenu(for: .codex) + controller.menuWillOpen(menu) + defer { controller.menuDidClose(menu) } + + let refreshItem = try #require(menu.items.first { $0.title == "Refresh" }) + #expect(controller.isPersistentRefreshItem(refreshItem)) + #expect(controller.providerSwitcherShortcutEventMonitor != nil) + #expect(controller.providerSwitcherShortcutMenuID == ObjectIdentifier(menu)) + #expect(refreshItem.keyEquivalent.isEmpty) + + let gate = ManualRefreshGate() + controller._test_manualRefreshOperation = { await gate.wait() } + #expect(try controller.handleMenuTrackingShortcutEvent(self.keyEvent("r", keyCode: 15), menu: menu)) + for _ in 0..<20 where controller.manualRefreshTasks[.provider(.codex)] == nil { + await Task.yield() + } + + let task = try #require(controller.manualRefreshTasks[.provider(.codex)]) + #expect(!refreshItem.isEnabled) + + gate.resume() + await task.value + + #expect(controller.manualRefreshTasks[.provider(.codex)] == nil) + #expect(refreshItem.isEnabled) } @Test - func `meta menu actions use the same stable row implementation`() throws { + func `only refresh uses a custom row while standard actions stay native`() throws { + let previousRendering = StatusItemController.menuCardRenderingEnabled + StatusItemController.menuCardRenderingEnabled = true + defer { StatusItemController.menuCardRenderingEnabled = previousRendering } + let settings = self.makeSettings() settings.refreshFrequency = .manual settings.mergeIcons = false @@ -97,72 +231,797 @@ struct StatusMenuPersistentRefreshTests { let menu = controller.makeMenu(for: .codex) controller.menuWillOpen(menu) - for title in ["Update ready, restart now?", "Refresh", "Settings...", "About CodexBar", "Quit"] { + let updateItem = try #require(menu.items.first { $0.title == "Update ready, restart now?" }) + let refreshItem = try #require(menu.items.first { $0.title == "Refresh" }) + #expect(MenuDescriptor.MenuAction.installUpdate.systemImageName == "arrow.down.circle") + #expect(MenuDescriptor.MenuAction.dashboard.systemImageName == "chart.xyaxis.line") + #expect(updateItem.image != nil) + #expect(refreshItem.view is any MenuCardHighlighting) + #expect(refreshItem.action == nil) + #expect(controller.isPersistentRefreshItem(refreshItem)) + #expect(refreshItem.keyEquivalent.isEmpty) + #expect(refreshItem.keyEquivalentModifierMask.isEmpty) + + #expect(updateItem.view == nil) + #expect(updateItem.action != nil) + #expect(updateItem.target === controller) + + for (title, key) in [("Settings...", ","), ("About CodexBar", ""), ("Quit", "q")] { let item = try #require(menu.items.first { $0.title == title }) - #expect(item.view is PersistentMenuActionItemView) - #expect(item.view?.frame.height == PersistentMenuActionItemView.rowHeight) - if title == "Refresh" { - #expect(item.action == nil) - #expect(item.target == nil) - } else { - #expect(item.action != nil) - #expect(item.target === controller) + #expect(item.view == nil) + #expect(item.action != nil) + #expect(item.target === controller) + #expect(item.keyEquivalent == key) + if !key.isEmpty { + #expect(item.keyEquivalentModifierMask == [.command]) } } } @Test - func `refresh menu item view keeps fixed metrics while highlighted`() { - let views = [ - PersistentMenuActionItemView( - title: "Refresh", - systemImageName: "arrow.clockwise", - shortcutText: "⌘R", - width: 320, - onClick: {}), - PersistentMenuActionItemView( - title: "Settings...", - systemImageName: "gearshape", - shortcutText: "⌘,", - width: 320, - onClick: {}), - PersistentMenuActionItemView( - title: "About CodexBar", - systemImageName: "info.circle", - shortcutText: nil, - width: 320, - onClick: {}), - PersistentMenuActionItemView( - title: "Quit", - systemImageName: nil, - shortcutText: nil, - width: 320, - onClick: {}), - ] + func `persistent refresh row reflects scoped global and manual refresh state`() throws { + let previousRendering = StatusItemController.menuCardRenderingEnabled + StatusItemController.menuCardRenderingEnabled = true + defer { StatusItemController.menuCardRenderingEnabled = previousRendering } + + let settings = self.makeSettings() + settings.refreshFrequency = .manual + settings.mergeIcons = false + + let controller = self.makeController(settings: settings) + let menu = controller.makeMenu(for: .codex) + controller.menuWillOpen(menu) + + let refreshItem = try #require(menu.items.first { $0.title == "Refresh" }) + #expect(controller.isPersistentRefreshItem(refreshItem)) + #expect(controller.persistentRefreshItems.allObjects.contains { $0 === refreshItem }) + #expect(refreshItem.isEnabled) + + controller.store.refreshingProviders.insert(.claude) + controller.updatePersistentRefreshItemsEnabled() + #expect(refreshItem.isEnabled) + + controller.store.refreshingProviders.insert(.codex) + controller.updatePersistentRefreshItemsEnabled() + #expect(!refreshItem.isEnabled) + + controller.store.refreshingProviders.removeAll() + controller.store.isRefreshing = true + controller.updatePersistentRefreshItemsEnabled() + #expect(!refreshItem.isEnabled) + + controller.store.isRefreshing = false + + // A manual refresh scoped to another provider must not grey out this provider's row. + controller.manualRefreshTasks[.provider(.claude)] = Task {} + controller.updatePersistentRefreshItemsEnabled() + #expect(refreshItem.isEnabled) + + // This provider's own manual refresh does disable its row. + controller.manualRefreshTasks[.provider(.codex)] = Task {} + controller.updatePersistentRefreshItemsEnabled() + #expect(!refreshItem.isEnabled) + + controller.manualRefreshTasks[.provider(.codex)] = nil + controller.manualRefreshTasks[.provider(.claude)] = nil + controller.updatePersistentRefreshItemsEnabled() + #expect(refreshItem.isEnabled) + + // An all-providers refresh greys every row. + controller.manualRefreshTasks[.global] = Task {} + controller.updatePersistentRefreshItemsEnabled() + #expect(!refreshItem.isEnabled) + + controller.manualRefreshTasks[.global] = nil + controller.updatePersistentRefreshItemsEnabled() + #expect(refreshItem.isEnabled) + + refreshItem.representedObject = "notRefresh" + controller.manualRefreshTasks[.global] = Task {} + controller.updatePersistentRefreshItemsEnabled() + #expect(refreshItem.isEnabled) + #expect(!controller.persistentRefreshItems.allObjects.contains { $0 === refreshItem }) + controller.manualRefreshTasks[.global] = nil + } + + @Test + func `refresh monitor follows refresh success and failure`() { + let settings = self.makeSettings() + let controller = self.makeController(settings: settings) + let monitor = controller.menuCardRefreshMonitor + let fallback = MenuCardLiveSubtitle(text: "Fallback", style: .info) + + #expect(monitor.subtitle(for: .codex, fallback: fallback).style == .info) + + controller.store.refreshingProviders.insert(.codex) + #expect(monitor.subtitle(for: .codex, fallback: fallback).style == .loading) + + controller.store.refreshingProviders.remove(.codex) + monitor.beginManualRefresh(frozenModels: [:], provider: nil) + #expect(monitor.subtitle(for: .codex, fallback: fallback).style == .info) + controller.store.refreshingProviders.insert(.codex) + #expect(monitor.subtitle(for: .codex, fallback: fallback).style == .loading) + monitor.endManualRefresh() + controller.store.refreshingProviders.remove(.codex) + + let now = Date() + controller.store.snapshots[.codex] = UsageSnapshot( + primary: RateWindow( + usedPercent: 10, + windowMinutes: nil, + resetsAt: now.addingTimeInterval(3600), + resetDescription: nil), + secondary: nil, + updatedAt: now) + let success = monitor.subtitle(for: .codex, fallback: fallback) + #expect(success.style == .info) + #expect(success.text == UsageFormatter.updatedString(from: now, now: Date())) + + controller.store.errors[.codex] = "Refresh failed" + let failure = monitor.subtitle(for: .codex, fallback: fallback) + #expect(failure.style == .error) + #expect(failure.text == "Refresh failed") + + monitor.beginManualRefresh(frozenModels: [:], provider: nil) + #expect(monitor.subtitle(for: .codex, fallback: fallback).style == .error) + controller.store.refreshingProviders.insert(.codex) + #expect(monitor.subtitle(for: .codex, fallback: fallback).style == .loading) + controller.store.refreshingProviders.remove(.codex) + } + + @Test + func `scoped refresh monitor leaves unrelated providers unchanged`() throws { + let settings = self.makeSettings() + let controller = self.makeController(settings: settings) + let monitor = controller.menuCardRefreshMonitor + let codexModel = try #require(controller.menuCardModel(for: .codex)) + let fallback = MenuCardLiveSubtitle(text: "Claude idle", style: .info) + let expectedClaude = monitor.subtitle(for: .claude, fallback: fallback) + + monitor.beginManualRefresh(frozenModels: [.codex: codexModel], provider: .codex) + defer { monitor.endManualRefresh(for: .codex) } + + #expect(monitor.isManualRefreshInFlight(for: .codex)) + #expect(!monitor.isManualRefreshInFlight(for: .claude)) + #expect(monitor.subtitle(for: .codex, fallback: fallback).style == .loading) + let actualClaude = monitor.subtitle(for: .claude, fallback: fallback) + #expect(actualClaude.text == expectedClaude.text) + #expect(actualClaude.style == expectedClaude.style) + } + + @Test + func `refresh monitor updates compatible usage values after manual refresh completes`() throws { + let settings = self.makeSettings() + let controller = self.makeController(settings: settings) + let now = Date() + controller.store.snapshots[.claude] = UsageSnapshot( + primary: RateWindow( + usedPercent: 10, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(3600), + resetDescription: nil), + secondary: RateWindow( + usedPercent: 20, + windowMinutes: 10080, + resetsAt: now.addingTimeInterval(7200), + resetDescription: nil), + updatedAt: now) + let fallback = try #require(controller.menuCardModel(for: .claude)) + controller.menuCardRefreshMonitor.beginManualRefresh(frozenModels: [:], provider: .claude) + + controller.store.snapshots[.claude] = UsageSnapshot( + primary: RateWindow( + usedPercent: 65, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(3600), + resetDescription: nil), + secondary: RateWindow( + usedPercent: 75, + windowMinutes: 10080, + resetsAt: now.addingTimeInterval(7200), + resetDescription: nil), + updatedAt: now.addingTimeInterval(1)) + + let inFlight = controller.menuCardRefreshMonitor.model(for: .claude, fallback: fallback) + #expect(inFlight.metrics.map(\.percent) == fallback.metrics.map(\.percent)) + + controller.menuCardRefreshMonitor.endManualRefresh(for: .claude) + let refreshed = controller.menuCardRefreshMonitor.model(for: .claude, fallback: fallback) + let expected = try #require(controller.menuCardModel(for: .claude)) + + #expect(refreshed.metrics.map(\.percent) == expected.metrics.map(\.percent)) + #expect(refreshed.metrics.map(\.percent) != fallback.metrics.map(\.percent)) + } + + @Test + func `manual refresh keeps frozen quota even if menu rebuilds before completion`() throws { + let settings = self.makeSettings() + let controller = self.makeController(settings: settings) + let now = Date() + for provider in [UsageProvider.claude, .codex] { + controller.store.snapshots[provider] = UsageSnapshot( + primary: RateWindow( + usedPercent: 21, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(3600), + resetDescription: nil), + secondary: nil, + updatedAt: now) + let frozen = try #require(controller.menuCardModel(for: provider)) + controller.menuCardRefreshMonitor.beginManualRefresh(frozenModels: [provider: frozen]) + controller.store.refreshingProviders.insert(provider) + + controller.store.snapshots[provider] = UsageSnapshot( + primary: RateWindow( + usedPercent: 18, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(3600), + resetDescription: nil), + secondary: nil, + updatedAt: now.addingTimeInterval(1)) + let rebuiltFallback = try #require(controller.menuCardModel(for: provider)) + let inFlight = controller.menuCardRefreshMonitor.model(for: provider, fallback: rebuiltFallback) + + #expect(frozen.metrics.first?.percentLabel == "79% left") + #expect(rebuiltFallback.metrics.first?.percentLabel == "82% left") + #expect(inFlight.metrics.first?.percentLabel == "79% left") - for view in views { - self.assertStableMetrics(view) + controller.menuCardRefreshMonitor.endManualRefresh() + controller.store.refreshingProviders.remove(provider) + let completed = controller.menuCardRefreshMonitor.model(for: provider, fallback: frozen) + #expect(completed.metrics.first?.percentLabel == "82% left") } } - private func assertStableMetrics(_ view: PersistentMenuActionItemView) { - #expect(view.frame.height == PersistentMenuActionItemView.rowHeight) - #expect(view.intrinsicContentSize.height == PersistentMenuActionItemView.rowHeight) - #expect(view.fittingSize.height == PersistentMenuActionItemView.rowHeight) + @Test + func `manual refresh uses fallback when frozen quota layout is incompatible`() throws { + let settings = self.makeSettings() + let controller = self.makeController(settings: settings) + let now = Date() + controller.store.snapshots[.claude] = UsageSnapshot( + primary: RateWindow( + usedPercent: 21, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(3600), + resetDescription: nil), + secondary: nil, + updatedAt: now) + let frozen = try #require(controller.menuCardModel(for: .claude)) + controller.menuCardRefreshMonitor.beginManualRefresh(frozenModels: [.claude: frozen]) + controller.store.refreshingProviders.insert(.claude) + defer { controller.store.refreshingProviders.remove(.claude) } + + controller.store.snapshots[.claude] = UsageSnapshot( + primary: RateWindow( + usedPercent: 18, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(3600), + resetDescription: nil), + secondary: RateWindow( + usedPercent: 12, + windowMinutes: 10080, + resetsAt: now.addingTimeInterval(7 * 24 * 60 * 60), + resetDescription: nil), + updatedAt: now.addingTimeInterval(1)) + let rebuiltFallback = try #require(controller.menuCardModel(for: .claude)) + let inFlight = controller.menuCardRefreshMonitor.model(for: .claude, fallback: rebuiltFallback) + + #expect(frozen.metrics.count == 1) + #expect(rebuiltFallback.metrics.count == 2) + #expect(inFlight.metrics.count == 2) + #expect(inFlight.metrics.map(\.id) == rebuiltFallback.metrics.map(\.id)) + } + + @Test + func `manual refresh preserves frozen quota when supplemental metric remains`() throws { + let settings = self.makeSettings() + let controller = self.makeController( + settings: settings, + account: AccountInfo(email: "test@example.com", plan: "pro")) + let now = Date() + controller.store.openAIDashboard = OpenAIDashboardSnapshot( + signedInEmail: "test@example.com", + codeReviewRemainingPercent: 88, + creditEvents: [], + dailyBreakdown: [], + usageBreakdown: [], + creditsPurchaseURL: nil, + updatedAt: now) + controller.store.openAIDashboardAttachmentAuthorized = true + controller.store.openAIDashboardRequiresLogin = false + controller.store.snapshots[.codex] = UsageSnapshot( + primary: RateWindow( + usedPercent: 21, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(3600), + resetDescription: nil), + secondary: RateWindow( + usedPercent: 12, + windowMinutes: 10080, + resetsAt: now.addingTimeInterval(7 * 24 * 60 * 60), + resetDescription: nil), + updatedAt: now) + let frozen = try #require(controller.menuCardModel(for: .codex)) + controller.menuCardRefreshMonitor.beginManualRefresh(frozenModels: [.codex: frozen]) + controller.store.refreshingProviders.insert(.codex) + defer { controller.store.refreshingProviders.remove(.codex) } + + controller.store.snapshots[.codex] = UsageSnapshot( + primary: nil, + secondary: nil, + updatedAt: now.addingTimeInterval(1)) + let fallback = try #require(controller.menuCardModel(for: .codex)) + let inFlight = controller.menuCardRefreshMonitor.model(for: .codex, fallback: fallback) + + #expect(frozen.metrics.count == 3) + #expect(fallback.metrics.map(\.id) == ["code-review"]) + #expect(inFlight.metrics.map(\.id) == frozen.metrics.map(\.id)) + #expect(inFlight.metrics.first?.percentLabel == "79% left") + } + + @Test + func `manual refresh uses fallback when empty quota gains credit content`() throws { + let settings = self.makeSettings() + let controller = self.makeController( + settings: settings, + account: AccountInfo(email: "test@example.com", plan: "pro")) + let now = Date() + controller.store.snapshots[.codex] = UsageSnapshot( + primary: RateWindow( + usedPercent: 21, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(3600), + resetDescription: nil), + secondary: nil, + updatedAt: now) + let frozen = try #require(controller.menuCardModel(for: .codex)) + controller.menuCardRefreshMonitor.beginManualRefresh(frozenModels: [.codex: frozen]) + controller.store.refreshingProviders.insert(.codex) + defer { controller.store.refreshingProviders.remove(.codex) } + + controller.store.snapshots[.codex] = UsageSnapshot( + primary: nil, + secondary: nil, + updatedAt: now.addingTimeInterval(1)) + controller.store.credits = CreditsSnapshot( + remaining: 42, + events: [], + updatedAt: now.addingTimeInterval(1)) + let fallback = try #require(controller.menuCardModel(for: .codex)) + let inFlight = controller.menuCardRefreshMonitor.model(for: .codex, fallback: fallback) + + #expect(frozen.metrics.count == 1) + #expect(fallback.metrics.isEmpty) + #expect(fallback.creditsText != nil) + #expect(inFlight.metrics.isEmpty) + #expect(inFlight.creditsText == fallback.creditsText) + } +} + +extension StatusMenuPersistentRefreshTests { + @Test + func `manual refresh uses fallback when empty quota gains a placeholder`() throws { + let settings = self.makeSettings() + let controller = self.makeController(settings: settings) + let now = Date() + controller.store.snapshots[.claude] = UsageSnapshot( + primary: RateWindow( + usedPercent: 21, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(3600), + resetDescription: nil), + secondary: nil, + updatedAt: now) + let frozen = try #require(controller.menuCardModel(for: .claude)) + controller.menuCardRefreshMonitor.beginManualRefresh(frozenModels: [.claude: frozen]) + + controller.store.snapshots.removeValue(forKey: .claude) + let fallback = try #require(controller.menuCardModel(for: .claude)) + controller.store.refreshingProviders.insert(.claude) + defer { controller.store.refreshingProviders.remove(.claude) } + let inFlight = controller.menuCardRefreshMonitor.model(for: .claude, fallback: fallback) + + #expect(frozen.metrics.count == 1) + #expect(fallback.metrics.isEmpty) + #expect(fallback.placeholder != nil) + #expect(inFlight.metrics.isEmpty) + #expect(inFlight.placeholder == fallback.placeholder) + } + + @Test + func `refresh monitor updates single line credit balances`() throws { + let settings = self.makeSettings() + let controller = self.makeController( + settings: settings, + account: AccountInfo(email: "test@example.com", plan: "pro")) + let now = Date() + controller.store.snapshots[.codex] = UsageSnapshot( + primary: RateWindow( + usedPercent: 10, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(3600), + resetDescription: nil), + secondary: nil, + updatedAt: now) + controller.store.credits = CreditsSnapshot(remaining: 80, events: [], updatedAt: now) + let fallback = try #require(controller.menuCardModel(for: .codex)) + + controller.store.credits = CreditsSnapshot( + remaining: 42, + events: [], + updatedAt: now.addingTimeInterval(1)) + let refreshed = controller.menuCardRefreshMonitor.model(for: .codex, fallback: fallback) + + #expect(refreshed.creditsRemaining == 42) + #expect(refreshed.creditsText != fallback.creditsText) + } + + @Test + func `refresh monitor preserves multiline workspace credit text`() throws { + let settings = self.makeSettings() + let controller = self.makeController(settings: settings) + controller.store.snapshots[.amp] = UsageSnapshot( + primary: nil, + secondary: nil, + ampUsage: AmpUsageDetails( + individualCredits: 12, + workspaceBalances: [AmpWorkspaceBalance(name: "Team", remaining: 7)]), + updatedAt: Date()) + let fallback = try #require(controller.menuCardModel(for: .amp)) + + controller.store.snapshots[.amp] = UsageSnapshot( + primary: nil, + secondary: nil, + ampUsage: AmpUsageDetails( + individualCredits: 10, + workspaceBalances: [AmpWorkspaceBalance(name: "Team", remaining: 3)]), + updatedAt: Date()) + let refreshed = controller.menuCardRefreshMonitor.model(for: .amp, fallback: fallback) + + #expect(refreshed.creditsText == fallback.creditsText) + } + + @Test + func `refresh monitor preserves tracked layout when refresh adds usage sections`() throws { + let settings = self.makeSettings() + let controller = self.makeController(settings: settings) + let fallback = try #require(controller.menuCardModel(for: .claude)) + #expect(fallback.metrics.isEmpty) + + controller.store.snapshots[.claude] = UsageSnapshot( + primary: RateWindow( + usedPercent: 25, + windowMinutes: 300, + resetsAt: Date().addingTimeInterval(3600), + resetDescription: nil), + secondary: nil, + updatedAt: Date()) + + let refreshed = controller.menuCardRefreshMonitor.model(for: .claude, fallback: fallback) + + #expect(refreshed.metrics.isEmpty) + #expect(refreshed.placeholder == fallback.placeholder) + } + + @Test + func `refresh monitor preserves tracked layout when token error appears`() throws { + let settings = self.makeSettings() + settings.costUsageEnabled = true + settings.costSummaryDisplayStyle = .both + let controller = self.makeController(settings: settings) + controller.store._setTokenSnapshotForTesting(Self.makeTokenSnapshot(), provider: .claude) + let fallback = try #require(controller.menuCardModel(for: .claude)) + #expect(fallback.tokenUsage?.errorLine == nil) + + controller.store._setTokenErrorForTesting("New token usage error", provider: .claude) + let refreshed = controller.menuCardRefreshMonitor.model(for: .claude, fallback: fallback) + + #expect(refreshed.tokenUsage?.errorLine == nil) + } + + @Test + func `refresh monitor preserves tracked layout when token error text changes`() throws { + let settings = self.makeSettings() + settings.costUsageEnabled = true + settings.costSummaryDisplayStyle = .both + let controller = self.makeController(settings: settings) + controller.store._setTokenSnapshotForTesting(Self.makeTokenSnapshot(), provider: .claude) + controller.store._setTokenErrorForTesting("Old token usage error", provider: .claude) + let fallback = try #require(controller.menuCardModel(for: .claude)) + + controller.store._setTokenErrorForTesting( + "A longer replacement error that could occupy more lines", + provider: .claude) + let refreshed = controller.menuCardRefreshMonitor.model(for: .claude, fallback: fallback) + + #expect(refreshed.tokenUsage?.errorLine == "Old token usage error") + } + + @Test + func `live subtitle preserves canonical model error filtering`() throws { + let settings = self.makeSettings() + let controller = self.makeController( + settings: settings, + account: AccountInfo(email: "test@example.com", plan: "pro")) + controller.store.errors[.codex] = UsageError.noRateLimitsFound.errorDescription + let model = try #require(controller.menuCardModel(for: .codex)) + let fallback = MenuCardLiveSubtitle(text: "Fallback", style: .error) + + let liveSubtitle = controller.menuCardRefreshMonitor.subtitle(for: .codex, fallback: fallback) + + #expect(liveSubtitle.text == model.subtitleText) + #expect(liveSubtitle.style == model.subtitleStyle) + #expect(liveSubtitle.text != UsageError.noRateLimitsFound.errorDescription) + #expect(liveSubtitle.style != .error) + } + + @Test + func `override cards keep their own subtitle`() throws { + let settings = self.makeSettings() + let controller = self.makeController(settings: settings) + let liveModel = try #require(controller.menuCardModel(for: .codex)) + let overrideModel = try #require(controller.menuCardModel( + for: .codex, + errorOverride: "Account unavailable", + forceOverrideCard: true)) + + #expect(liveModel.usesLiveSubtitle) + #expect(!overrideModel.usesLiveSubtitle) + #expect(overrideModel.subtitleText == "Account unavailable") + } + + @Test + func `live failure keeps the measured card height`() throws { + let settings = self.makeSettings() + let controller = self.makeController(settings: settings) + + func fittingHeight(for model: UsageMenuCardView.Model) -> CGFloat { + NSHostingView(rootView: UsageMenuCardView(model: model, width: 320) + .environment(\.menuCardRefreshMonitor, controller.menuCardRefreshMonitor)) + .fittingSize.height + } + + let idleModel = try #require(controller.menuCardModel(for: .codex)) + let idleHeight = fittingHeight(for: idleModel) + controller.store.errors[.codex] = "Short error" + let failureHeight = fittingHeight(for: idleModel) + + #expect(failureHeight == idleHeight) + + let errorModel = try #require(controller.menuCardModel(for: .codex)) + let errorHeight = fittingHeight(for: errorModel) + controller.store.errors[.codex] = + "Refresh failed with a much longer replacement message that must not resize the tracked menu" + let replacementErrorHeight = fittingHeight(for: errorModel) + controller.menuCardRefreshMonitor.beginManualRefresh(frozenModels: [:], provider: nil) + let retryHeight = fittingHeight(for: errorModel) + + #expect(replacementErrorHeight == errorHeight) + let fallback = MenuCardLiveSubtitle(text: errorModel.subtitleText, style: errorModel.subtitleStyle) + #expect(controller.menuCardRefreshMonitor.subtitle(for: .codex, fallback: fallback).style == .error) + controller.store.refreshingProviders.insert(.codex) + #expect(controller.menuCardRefreshMonitor.subtitle(for: .codex, fallback: fallback).style == .loading) + controller.store.refreshingProviders.remove(.codex) + #expect(retryHeight == errorHeight) + } + + @Test + func `manual refresh is suppressed after shutdown preparation`() { + let settings = self.makeSettings() + let controller = self.makeController(settings: settings) + var requestCount = 0 + controller._test_manualRefreshOperation = { + requestCount += 1 + } + + controller.prepareForAppShutdown() + controller.refreshNow() + + #expect(requestCount == 0) + #expect(controller.manualRefreshTasks.isEmpty) + #expect(!controller.menuCardRefreshMonitor.isManualRefreshInFlight) + } + + @Test + func `repeated manual refresh clicks share one lifecycle`() async throws { + let settings = self.makeSettings() + settings.refreshFrequency = .manual + settings.mergeIcons = false + + let controller = self.makeController(settings: settings) + + let gate = ManualRefreshGate() + var requestCount = 0 + controller._test_manualRefreshOperation = { + requestCount += 1 + await gate.wait() + } + + controller.refreshNow() + let task = try #require(controller.manualRefreshTasks[.global]) + controller.refreshNow() + controller.refreshNow() + await Task.yield() - view.setFrameSize(NSSize(width: 360, height: 44)) - #expect(view.frame.width == 360) - #expect(view.frame.height == PersistentMenuActionItemView.rowHeight) + #expect(requestCount == 1) + #expect(controller.menuCardRefreshMonitor.isManualRefreshInFlight) - view.setHighlighted(true) - #expect(view.frame.height == PersistentMenuActionItemView.rowHeight) - #expect(view.intrinsicContentSize.height == PersistentMenuActionItemView.rowHeight) - #expect(view.fittingSize.height == PersistentMenuActionItemView.rowHeight) + gate.resume() + await task.value - view.setHighlighted(false) - #expect(view.frame.height == PersistentMenuActionItemView.rowHeight) - #expect(view.intrinsicContentSize.height == PersistentMenuActionItemView.rowHeight) - #expect(view.fittingSize.height == PersistentMenuActionItemView.rowHeight) + #expect(controller.manualRefreshTasks[.global] == nil) + #expect(!controller.menuCardRefreshMonitor.isManualRefreshInFlight) + } + + @Test + func `provider menu persistent refresh row and command R refresh only that provider`() async throws { + let settings = self.makeSettings() + settings.refreshFrequency = .manual + settings.mergeIcons = false + self.enableOnly([.claude, .codex], settings: settings) + + let controller = self.makeController(settings: settings) + let menu = try #require(controller.makeMenu(for: .claude) as? StatusItemMenu) + let codexMenu = try #require(controller.makeMenu(for: .codex) as? StatusItemMenu) + controller.menuWillOpen(menu) + controller.menuWillOpen(codexMenu) + defer { + controller.menuDidClose(codexMenu) + controller.menuDidClose(menu) + } + + let mouseGate = ManualRefreshGate() + controller._test_manualRefreshOperation = { await mouseGate.wait() } + let refreshItem = try #require(menu.items.first { $0.title == "Refresh" }) + #expect(controller.isPersistentRefreshItem(refreshItem)) + let refreshView = try #require(refreshItem.view as? PersistentRefreshMenuView) + #expect(refreshView.accessibilityPerformPress()) + for _ in 0..<20 where controller.manualRefreshTasks[.provider(.claude)] == nil { + await Task.yield() + } + let mouseTask = try #require(controller.manualRefreshTasks[.provider(.claude)]) + + // Refreshing Claude greys the Claude row but must leave the Codex row available. + #expect(controller.isRefreshActionInFlight(for: menu)) + #expect(!controller.isRefreshActionInFlight(for: codexMenu)) + + mouseGate.resume() + await mouseTask.value + + let keyboardGate = ManualRefreshGate() + controller._test_manualRefreshOperation = { await keyboardGate.wait() } + #expect(try menu.performKeyEquivalent(with: self.keyEvent("r", keyCode: 15))) + for _ in 0..<20 where controller.manualRefreshTasks[.provider(.claude)] == nil { + await Task.yield() + } + let keyboardTask = try #require(controller.manualRefreshTasks[.provider(.claude)]) + keyboardGate.resume() + await keyboardTask.value + } + + @Test + func `provider menu does not replace matching scoped refresh`() async throws { + let settings = self.makeSettings() + settings.refreshFrequency = .manual + settings.mergeIcons = false + self.enableOnly([.claude, .codex], settings: settings) + + let controller = self.makeController(settings: settings) + let menu = try #require(controller.makeMenu(for: .claude) as? StatusItemMenu) + controller.menuWillOpen(menu) + defer { controller.menuDidClose(menu) } + + controller.store.refreshingProviders.insert(.claude) + var requestCount = 0 + controller._test_manualRefreshOperation = { requestCount += 1 } + + let refreshItem = try #require(menu.items.first { $0.title == "Refresh" }) + #expect(controller.isPersistentRefreshItem(refreshItem)) + controller.performPersistentRefreshAction(in: ObjectIdentifier(menu)) + #expect(try menu.performKeyEquivalent(with: self.keyEvent("r", keyCode: 15))) + for _ in 0..<20 { + await Task.yield() + } + + #expect(requestCount == 0) + #expect(controller.manualRefreshTasks.isEmpty) + } + + @Test + func `merged overview refreshes globally while selected provider stays scoped`() async throws { + let settings = self.makeSettings() + settings.refreshFrequency = .manual + settings.mergeIcons = true + self.enableOnly([.claude, .codex], settings: settings) + settings.mergedMenuLastSelectedWasOverview = true + + let controller = self.makeController(settings: settings) + let menu = try #require(controller.makeMenu() as? StatusItemMenu) + controller.mergedMenu = menu + controller.menuWillOpen(menu) + defer { controller.menuDidClose(menu) } + + let overviewGate = ManualRefreshGate() + controller._test_manualRefreshOperation = { await overviewGate.wait() } + let refreshItem = try #require(menu.items.first { $0.title == "Refresh" }) + #expect(controller.isPersistentRefreshItem(refreshItem)) + controller.performPersistentRefreshAction(in: ObjectIdentifier(menu)) + for _ in 0..<20 where controller.manualRefreshTasks[.global] == nil { + await Task.yield() + } + let overviewTask = try #require(controller.manualRefreshTasks[.global]) + overviewGate.resume() + await overviewTask.value + + settings.mergedMenuLastSelectedWasOverview = false + controller.selectedMenuProvider = .claude + let providerGate = ManualRefreshGate() + controller._test_manualRefreshOperation = { await providerGate.wait() } + #expect(try menu.performKeyEquivalent(with: self.keyEvent("r", keyCode: 15))) + for _ in 0..<20 where controller.manualRefreshTasks[.provider(.claude)] == nil { + await Task.yield() + } + let providerTask = try #require(controller.manualRefreshTasks[.provider(.claude)]) + providerGate.resume() + await providerTask.value + } + + @Test + func `provider scoped refresh updates status and widget snapshot`() async { + let settings = self.makeSettings() + settings.refreshFrequency = .manual + settings.statusChecksEnabled = true + self.enableOnly([.synthetic], settings: settings) + + let controller = self.makeController(settings: settings) + controller.store._test_providerRefreshOverride = { _ in } + controller.store._test_providerStatusFetchOverride = { provider in + #expect(provider == .synthetic) + return ProviderStatus(indicator: .none, description: "Operational", updatedAt: Date()) + } + var savedSnapshots = 0 + controller.store._test_widgetSnapshotSaveOverride = { _ in + savedSnapshots += 1 + } + + await controller.performStoreRefresh( + for: .synthetic, + refreshOpenMenusWhenComplete: false, + interaction: .userInitiated) + _ = await controller.store.widgetSnapshotPersistTask?.result + + #expect(controller.store.statuses[.synthetic]?.description == "Operational") + #expect(savedSnapshots == 1) + } + + @Test + func `failed manual refresh returns persistent item to enabled and surfaces error`() async throws { + let settings = self.makeSettings() + settings.refreshFrequency = .manual + settings.mergeIcons = false + + let controller = self.makeController(settings: settings) + let menu = controller.makeMenu(for: .codex) + controller.menuWillOpen(menu) + let refreshItem = try #require(menu.items.first { $0.title == "Refresh" }) + let gate = ManualRefreshGate() + + controller._test_manualRefreshOperation = { + await gate.wait() + controller.store.errors[.codex] = "Refresh failed" + } + + controller.refreshNow() + let task = try #require(controller.manualRefreshTasks[.global]) + #expect(!refreshItem.isEnabled) + + gate.resume() + await task.value + + #expect(controller.manualRefreshTasks[.global] == nil) + #expect(refreshItem.isEnabled) + let fallback = MenuCardLiveSubtitle(text: "Fallback", style: .info) + #expect(controller.menuCardRefreshMonitor.subtitle(for: .codex, fallback: fallback).style == .error) } @Test @@ -170,16 +1029,21 @@ struct StatusMenuPersistentRefreshTests { let menu = StatusItemMenu() let recorder = RefreshShortcutRecorder() menu.persistentActionDelegate = recorder + menu.menuInteractionGeneration = 42 #expect(try menu.performKeyEquivalent(with: self.keyEvent("r", keyCode: 15)) == true) #expect(try menu.performKeyEquivalent(with: self.keyEvent(",", keyCode: 43)) == true) #expect(try menu.performKeyEquivalent(with: self.keyEvent("q", keyCode: 12)) == true) #expect(recorder.refreshCount == 1) + #expect(recorder.refreshMenuIDs == [ObjectIdentifier(menu)]) + #expect(recorder.refreshMenuInteractionGenerations == [42]) #expect(recorder.settingsCount == 1) #expect(recorder.quitCount == 1) } +} +extension StatusMenuPersistentRefreshTests { private func keyEvent(_ characters: String, keyCode: UInt16) throws -> NSEvent { try #require(NSEvent.keyEvent( with: .keyDown, @@ -193,4 +1057,519 @@ struct StatusMenuPersistentRefreshTests { isARepeat: false, keyCode: keyCode)) } + + @Test + func `refresh row metrics match tuned native-style values`() { + let metrics = PersistentRefreshRowMetrics.defaults + #expect(metrics.rowHeight == 24) + #expect(metrics.selectionHorizontalInset == 5) + #expect(metrics.selectionVerticalInset == 0) + #expect(metrics.selectionCornerRadius == 7) + #expect(metrics.leadingPadding == 15) + #expect(metrics.trailingPadding == 8) + #expect(metrics.iconWidth == 16) + #expect(metrics.iconSymbolPointSize == 16) + #expect(metrics.iconSymbolWeight == .regular) + #expect(metrics.iconTitleSpacing == 4.5) + #expect(metrics.shortcutFontSize == 13) + #expect(metrics.shortcutXOffset == -9.5) + #expect(metrics.shortcutYOffset == 0) + } + + @Test + func `refresh shortcut display has stable native-style column`() throws { + let previousRendering = StatusItemController.menuCardRenderingEnabled + StatusItemController.menuCardRenderingEnabled = true + defer { StatusItemController.menuCardRenderingEnabled = previousRendering } + + let settings = self.makeSettings() + settings.refreshFrequency = .manual + settings.mergeIcons = false + + let controller = self.makeController(settings: settings) + let menu = controller.makeMenu(for: .codex) + controller.menuWillOpen(menu) + + let refreshItem = try #require(menu.items.first { $0.title == "Refresh" }) + let refreshView = try #require(refreshItem.view as? PersistentRefreshMenuView) + refreshView.applySize(width: 320, height: PersistentRefreshRowMetrics.defaults.rowHeight) + refreshView.layoutSubtreeIfNeeded() + + let shortcutField = try #require( + refreshView.subviews.compactMap { $0 as? NSTextField }.first { $0.stringValue == "⌘ R" }) + #expect(shortcutField.alignment == .left) + #expect(shortcutField.lineBreakMode == .byClipping) + #expect(shortcutField.frame.width >= 40) + let shortcutFont = try #require(shortcutField.font) + #expect(abs(shortcutFont.pointSize - PersistentRefreshRowMetrics.defaults.shortcutFontSize) < 0.001) + + let iconView = try #require(refreshView.subviews.compactMap { $0 as? NSImageView }.first) + let titleField = try #require( + refreshView.subviews.compactMap { $0 as? NSTextField }.first { $0.stringValue == "Refresh" }) + #expect(iconView.frame.minX == PersistentRefreshRowMetrics.defaults.leadingPadding) + #expect(titleField.frame.minX == PersistentRefreshRowMetrics.defaults.leadingPadding + + PersistentRefreshRowMetrics.defaults.iconWidth + + PersistentRefreshRowMetrics.defaults.iconTitleSpacing) + #expect(iconView.frame.width == PersistentRefreshRowMetrics.defaults.iconWidth) + #expect(iconView.frame.height == PersistentRefreshRowMetrics.defaults.iconWidth) + } + + @Test + func `refresh row width follows final rendered menu width`() { + let settings = self.makeSettings() + let controller = self.makeController(settings: settings) + defer { controller.releaseStatusItemsForTesting() } + + let metrics = PersistentRefreshRowMetrics.defaults + let refreshView = PersistentRefreshMenuView( + title: "Refresh", + systemImageName: "arrow.clockwise", + shortcutText: "⌘ R") + refreshView.applySize(width: StatusItemController.menuCardBaseWidth, height: metrics.rowHeight) + refreshView.frame.origin.x = 4 + + let refreshItem = NSMenuItem() + refreshItem.title = "Refresh" + refreshItem.view = refreshView + + let wideNativeItem = NSMenuItem( + title: String(repeating: "W", count: 60), + action: nil, + keyEquivalent: "") + let menu = NSMenu() + menu.addItem(refreshItem) + menu.addItem(wideNativeItem) + + let expectedWidth = controller.renderedMenuWidth(for: menu) + #expect(expectedWidth > StatusItemController.menuCardBaseWidth) + + controller.refreshMenuCardHeights(in: menu) + + #expect(abs(refreshView.frame.width - expectedWidth) <= 0.5) + #expect(refreshView.frame.origin == .zero) + #expect(refreshView.frame.height == metrics.rowHeight) + } +} + +extension StatusMenuPersistentRefreshTests { + @Test + func `global manual refresh only marks active provider cards as refreshing`() { + let settings = self.makeSettings() + let controller = self.makeController(settings: settings) + let monitor = controller.menuCardRefreshMonitor + let fallback = MenuCardLiveSubtitle(text: "Idle", style: .info) + + monitor.beginManualRefresh(frozenModels: [:], provider: nil) + defer { monitor.endManualRefresh() } + + controller.store.refreshingProviders.insert(.claude) + #expect(monitor.isManualRefreshInFlight) + #expect(!monitor.isManualRefreshInFlight(for: .codex)) + #expect(monitor.isManualRefreshInFlight(for: .claude)) + #expect(monitor.subtitle(for: .codex, fallback: fallback).style == .info) + #expect(monitor.subtitle(for: .claude, fallback: fallback).style == .loading) + + controller.store.refreshingProviders.remove(.claude) + #expect(monitor.isManualRefreshInFlight) + #expect(!monitor.isManualRefreshInFlight(for: .claude)) + #expect(monitor.subtitle(for: .claude, fallback: fallback).style == .info) + } + + @Test + func `completed provider cards stop refreshing while another provider is still running`() async { + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.costUsageEnabled = false + settings.openAIWebAccessEnabled = false + settings.codexCookieSource = .off + self.enableOnly([.claude, .codex], settings: settings) + let controller = self.makeController(settings: settings) + let claudeStarted = ManualRefreshGate() + let releaseClaude = ManualRefreshGate() + let monitor = controller.menuCardRefreshMonitor + let fallback = MenuCardLiveSubtitle(text: "Idle", style: .info) + + controller.store._test_providerRefreshOverride = { provider in + guard provider == .claude else { return } + claudeStarted.resume() + await releaseClaude.wait() + } + defer { controller.store._test_providerRefreshOverride = nil } + + controller.refreshNow() + await claudeStarted.wait() + for _ in 0..<20 where controller.store.refreshingProviders != [.claude] { + await Task.yield() + } + + #expect(controller.store.refreshingProviders == [.claude]) + #expect(!monitor.isManualRefreshInFlight(for: .codex)) + #expect(monitor.isManualRefreshInFlight(for: .claude)) + #expect(monitor.subtitle(for: .codex, fallback: fallback).style == .info) + #expect(monitor.subtitle(for: .claude, fallback: fallback).style == .loading) + + releaseClaude.resume() + await controller.manualRefreshTasks[.global]?.value + } + + @Test + func `token-cost tail does not keep completed provider card refreshing`() async { + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.costUsageEnabled = true + settings.openAIWebAccessEnabled = false + settings.codexCookieSource = .off + self.enableOnly([.codex], settings: settings) + let controller = self.makeController(settings: settings) + let tokenRefreshStarted = ManualRefreshGate() + let releaseTokenRefresh = ManualRefreshGate() + defer { releaseTokenRefresh.resume() } + let monitor = controller.menuCardRefreshMonitor + let fallback = MenuCardLiveSubtitle(text: "Idle", style: .info) + let menu = controller.makeMenu() + var tokenRefreshWasForced: Bool? + + controller.store._test_providerRefreshOverride = { _ in } + controller.store._test_codexCreditsLoaderOverride = { + CreditsSnapshot(remaining: 25, events: [], updatedAt: Date()) + } + controller.store._test_tokenUsageRefreshOverride = { _, force in + tokenRefreshWasForced = force + tokenRefreshStarted.resume() + await releaseTokenRefresh.wait() + } + defer { + controller.store._test_providerRefreshOverride = nil + controller.store._test_codexCreditsLoaderOverride = nil + controller.store._test_tokenUsageRefreshOverride = nil + } + + controller.refreshNow() + let manualTask = controller.manualRefreshTasks[.global] + #expect(await tokenRefreshStarted.waitUntilSignaled()) + let manualCompletion = RefreshCompletionProbe() + let manualCompletionTask = Task { + await manualTask?.value + await manualCompletion.markCompleted() + } + #expect(await manualCompletion.waitUntilCompleted()) + let tailTask = controller.store.forcedRefreshEnrichmentTask + + #expect(!controller.store.isRefreshing) + #expect(controller.store.hasForcedRefreshEnrichmentInFlight) + #expect(controller.store.refreshingProviders.isEmpty) + #expect(tokenRefreshWasForced == true) + #expect(controller.isRefreshActionInFlight(for: menu)) + #expect(!monitor.isManualRefreshInFlight) + #expect(!monitor.isManualRefreshInFlight(for: .codex)) + #expect(monitor.subtitle(for: .codex, fallback: fallback).style == .info) + + releaseTokenRefresh.resume() + await manualCompletionTask.value + await tailTask?.value + #expect(!controller.store.hasForcedRefreshEnrichmentInFlight) + #expect(!controller.isRefreshActionInFlight(for: menu)) + } + + @Test + func `credit tail does not keep completed provider card refreshing`() async { + await self.verifyCompletedProviderCardStopsRefreshing(whileBlocking: .credits) + } + + @Test + func `dashboard tail does not keep completed provider card refreshing`() async { + await self.verifyCompletedProviderCardStopsRefreshing(whileBlocking: .dashboard) + } + + private func verifyCompletedProviderCardStopsRefreshing(whileBlocking stage: BlockingEnrichmentStage) async { + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.costUsageEnabled = false + settings.openAIWebAccessEnabled = stage == .dashboard + settings.codexCookieSource = stage == .dashboard ? .auto : .off + settings._test_liveSystemCodexAccount = ObservedSystemCodexAccount( + email: "fixture@example.com", + codexHomePath: "/Users/test/.codex", + observedAt: Date(), + identity: .emailOnly(normalizedEmail: "fixture@example.com")) + settings.codexActiveSource = .liveSystem + self.enableOnly([.codex], settings: settings) + let account = AccountInfo(email: "fixture@example.com", plan: "pro") + let controller = self.makeController(settings: settings, account: account) + controller.store.accountInfoCache[.codex] = UsageStore.AccountInfoCacheEntry( + account: account, + configRevision: settings.configRevision, + expiresAt: .distantFuture) + let stageStarted = ManualRefreshGate() + let releaseStage = ManualRefreshGate() + defer { + releaseStage.resume() + controller.prepareForAppShutdown() + settings._test_liveSystemCodexAccount = nil + } + let monitor = controller.menuCardRefreshMonitor + let fallback = MenuCardLiveSubtitle(text: "Idle", style: .info) + let menu = controller.makeMenu() + var creditsLoaderCalls = 0 + var dashboardLoaderCalls = 0 + + controller.store._test_providerRefreshOverride = { _ in } + controller.store._test_codexCreditsLoaderOverride = { + creditsLoaderCalls += 1 + if stage == .credits, creditsLoaderCalls == 1 { + stageStarted.resume() + await releaseStage.wait() + } + return CreditsSnapshot(remaining: 25, events: [], updatedAt: Date()) + } + controller.store._test_openAIDashboardLoaderOverride = { _, _, _, _ in + dashboardLoaderCalls += 1 + if stage == .dashboard, dashboardLoaderCalls == 1 { + stageStarted.resume() + await releaseStage.wait() + } + return OpenAIDashboardSnapshot( + signedInEmail: account.email, + codeReviewRemainingPercent: 95, + creditEvents: [], + dailyBreakdown: [], + usageBreakdown: [], + creditsPurchaseURL: nil, + creditsRemaining: 25, + accountPlan: "Pro", + updatedAt: Date()) + } + defer { + controller.store._test_providerRefreshOverride = nil + controller.store._test_codexCreditsLoaderOverride = nil + controller.store._test_openAIDashboardLoaderOverride = nil + } + + controller.refreshNow() + let manualTask = controller.manualRefreshTasks[.global] + let didStartStage = await stageStarted.waitUntilSignaled() + #expect(didStartStage) + guard didStartStage else { return } + let manualCompletion = RefreshCompletionProbe() + let manualCompletionTask = Task { + await manualTask?.value + await manualCompletion.markCompleted() + } + let didCompleteManualRefresh = await manualCompletion.waitUntilCompleted() + #expect(didCompleteManualRefresh) + guard didCompleteManualRefresh else { return } + let tailTask = controller.store.forcedRefreshEnrichmentTask + + #expect(!controller.store.isRefreshing) + #expect(controller.store.hasForcedRefreshEnrichmentInFlight) + #expect(controller.store.refreshingProviders.isEmpty) + #expect(controller.isRefreshActionInFlight(for: menu)) + #expect(!monitor.isManualRefreshInFlight) + #expect(!monitor.isManualRefreshInFlight(for: .codex)) + #expect(monitor.subtitle(for: .codex, fallback: fallback).style == .info) + + releaseStage.resume() + await manualCompletionTask.value + let tailCompletion = RefreshCompletionProbe() + let tailCompletionTask = Task { + await tailTask?.value + await tailCompletion.markCompleted() + } + let didCompleteTail = await tailCompletion.waitUntilCompleted() + #expect(didCompleteTail) + guard didCompleteTail else { return } + await tailCompletionTask.value + + #expect(creditsLoaderCalls >= 1) + #expect(dashboardLoaderCalls == (stage == .dashboard ? 1 : 0)) + #expect(!controller.store.hasForcedRefreshEnrichmentInFlight) + #expect(!controller.isRefreshActionInFlight(for: menu)) + } + + @Test + func `provider scoped refresh waits for global forced enrichment`() async { + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.costUsageEnabled = true + settings.openAIWebAccessEnabled = false + settings.codexCookieSource = .off + self.enableOnly([.codex], settings: settings) + let controller = self.makeController(settings: settings) + let tokenRefreshStarted = ManualRefreshGate() + let releaseTokenRefresh = ManualRefreshGate() + let scopedWaitStarted = ManualRefreshGate() + defer { releaseTokenRefresh.resume() } + var providerRefreshCalls = 0 + var tokenRefreshCalls = 0 + + controller.store._test_providerRefreshOverride = { _ in + providerRefreshCalls += 1 + } + controller.store._test_codexCreditsLoaderOverride = { + CreditsSnapshot(remaining: 25, events: [], updatedAt: Date()) + } + controller.store._test_tokenUsageRefreshOverride = { _, _ in + tokenRefreshCalls += 1 + if tokenRefreshCalls == 1 { + tokenRefreshStarted.resume() + await releaseTokenRefresh.wait() + } + } + controller.store._test_forcedRefreshEnrichmentWaitObserver = { + scopedWaitStarted.resume() + } + defer { + controller.store._test_providerRefreshOverride = nil + controller.store._test_codexCreditsLoaderOverride = nil + controller.store._test_tokenUsageRefreshOverride = nil + controller.store._test_forcedRefreshEnrichmentWaitObserver = nil + } + + controller.refreshNow() + let globalTask = controller.manualRefreshTasks[.global] + #expect(await tokenRefreshStarted.waitUntilSignaled()) + let globalCompletion = RefreshCompletionProbe() + let globalCompletionTask = Task { + await globalTask?.value + await globalCompletion.markCompleted() + } + #expect(await globalCompletion.waitUntilCompleted()) + #expect(providerRefreshCalls == 1) + + let scopedTask = Task { @MainActor in + await controller.performStoreRefresh( + for: .codex, + refreshOpenMenusWhenComplete: false, + interaction: .userInitiated) + } + #expect(await scopedWaitStarted.waitUntilSignaled()) + #expect(providerRefreshCalls == 1) + + releaseTokenRefresh.resume() + await globalCompletionTask.value + await scopedTask.value + #expect(providerRefreshCalls == 2) + #expect(tokenRefreshCalls == 2) + #expect(!controller.store.hasForcedRefreshEnrichmentInFlight) + } + + @Test + func `concurrent manual refreshes keep each provider's frozen card`() throws { + let settings = self.makeSettings() + let controller = self.makeController(settings: settings) + let monitor = controller.menuCardRefreshMonitor + let now = Date() + + func quotaSnapshot(usedPercent: Double, updatedAt: Date) -> UsageSnapshot { + UsageSnapshot( + primary: RateWindow( + usedPercent: usedPercent, + windowMinutes: 300, + resetsAt: updatedAt.addingTimeInterval(3600), + resetDescription: nil), + secondary: nil, + updatedAt: updatedAt) + } + + // The snapshot helper supplies every enabled provider even for a provider-scoped refresh. + controller.store.snapshots[.claude] = quotaSnapshot(usedPercent: 21, updatedAt: now) + controller.store.snapshots[.codex] = quotaSnapshot(usedPercent: 15, updatedAt: now) + let claudeFrozen = try #require(controller.menuCardModel(for: .claude)) + let codexBeforeItsRefresh = try #require(controller.menuCardModel(for: .codex)) + monitor.beginManualRefresh( + frozenModels: [.claude: claudeFrozen, .codex: codexBeforeItsRefresh], + provider: .claude) + + // Each provider must freeze the card visible when its own refresh starts. + controller.store.snapshots[.claude] = quotaSnapshot(usedPercent: 65, updatedAt: now.addingTimeInterval(1)) + controller.store.snapshots[.codex] = quotaSnapshot(usedPercent: 42, updatedAt: now.addingTimeInterval(1)) + let claudeMidRefresh = try #require(controller.menuCardModel(for: .claude)) + let codexFrozen = try #require(controller.menuCardModel(for: .codex)) + monitor.beginManualRefresh( + frozenModels: [.claude: claudeMidRefresh, .codex: codexFrozen], + provider: .codex) + + let shownClaude = monitor.model(for: .claude, fallback: claudeMidRefresh) + let shownCodex = monitor.model(for: .codex, fallback: codexFrozen) + #expect(shownClaude.metrics.first?.percentLabel == "79% left") + #expect(shownCodex.metrics.first?.percentLabel == "58% left") + + // Ending Codex leaves Claude frozen; ending Claude clears it. + monitor.endManualRefresh(for: .codex) + #expect(monitor.isManualRefreshInFlight(for: .claude)) + #expect(!monitor.isManualRefreshInFlight(for: .codex)) + monitor.endManualRefresh(for: .claude) + #expect(!monitor.isManualRefreshInFlight(for: .claude)) + } + + @Test + func `refreshing one provider does not block refreshing another`() async throws { + let settings = self.makeSettings() + settings.refreshFrequency = .manual + settings.mergeIcons = false + self.enableOnly([.claude, .codex], settings: settings) + + let controller = self.makeController(settings: settings) + let codexMenu = try #require(controller.makeMenu(for: .codex) as? StatusItemMenu) + controller.menuWillOpen(codexMenu) + defer { controller.menuDidClose(codexMenu) } + + // Simulate a Claude manual refresh already in flight. + controller.manualRefreshTasks[.provider(.claude)] = Task {} + defer { + controller.manualRefreshTasks[.provider(.claude)]?.cancel() + controller.manualRefreshTasks[.provider(.claude)] = nil + } + + let gate = ManualRefreshGate() + controller._test_manualRefreshOperation = { await gate.wait() } + + // A Codex refresh must still start rather than being blocked by the in-flight Claude one. + controller.performPersistentRefreshAction(in: ObjectIdentifier(codexMenu)) + for _ in 0..<20 where controller.manualRefreshTasks[.provider(.codex)] == nil { + await Task.yield() + } + let codexTask = try #require(controller.manualRefreshTasks[.provider(.codex)]) + #expect(controller.isRefreshActionInFlight(for: codexMenu)) + + gate.resume() + await codexTask.value + } + + @Test + func `overview stays busy through a provider refresh tail and blocks a global refresh`() async throws { + let settings = self.makeSettings() + settings.refreshFrequency = .manual + settings.mergeIcons = true + self.enableOnly([.claude, .codex], settings: settings) + settings.mergedMenuLastSelectedWasOverview = true + + let controller = self.makeController(settings: settings) + let menu = try #require(controller.makeMenu() as? StatusItemMenu) + controller.mergedMenu = menu + controller.menuWillOpen(menu) + defer { controller.menuDidClose(menu) } + + // A per-provider Claude refresh whose task outlives the store's `refreshingProviders` window + // (the status/token/credits tail runs after the provider is removed from that set). + controller.manualRefreshTasks[.provider(.claude)] = Task {} + defer { + controller.manualRefreshTasks[.provider(.claude)]?.cancel() + controller.manualRefreshTasks[.provider(.claude)] = nil + } + + // Overview stands for every provider, so its row stays greyed even with `refreshingProviders` empty. + #expect(controller.store.refreshingProviders.isEmpty) + #expect(controller.isRefreshActionInFlight(for: menu)) + + // And a global overview refresh must not start on top of the in-flight provider refresh. + var requestCount = 0 + controller._test_manualRefreshOperation = { requestCount += 1 } + controller.performPersistentRefreshAction(in: ObjectIdentifier(menu)) + for _ in 0..<20 { + await Task.yield() + } + #expect(requestCount == 0) + #expect(controller.manualRefreshTasks[.global] == nil) + } } diff --git a/Tests/CodexBarTests/StatusMenuReadinessBaselineTests.swift b/Tests/CodexBarTests/StatusMenuReadinessBaselineTests.swift new file mode 100644 index 0000000000..f13add449f --- /dev/null +++ b/Tests/CodexBarTests/StatusMenuReadinessBaselineTests.swift @@ -0,0 +1,645 @@ +import AppKit +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +extension StatusMenuTests { + @Test + func `reopening root menu resyncs readiness baseline so reverted store data still refreshes`() { + // Regression for the readiness-signature optimization (#1351): the baseline is no longer + // recomputed on every store change while menus are closed, so it must be re-anchored when a + // root menu opens. Otherwise a closed-then-reopened menu built from new data, followed by an + // open-menu change that reverts to the *previous* baseline value, would be treated as + // "unchanged" and skip the rebuild, leaving the visible menu showing stale content. + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + settings.costUsageEnabled = true + self.enableOnlyCodexForReadinessBaseline(settings) + + let snapshotA = self.makeReadinessBaselineTokenSnapshot( + sessionTokens: 111, + sessionCostUSD: 1.11, + last30DaysTokens: 1111, + last30DaysCostUSD: 11.11, + updatedAt: Date(timeIntervalSince1970: 100)) + let snapshotB = self.makeReadinessBaselineTokenSnapshot( + sessionTokens: 222, + sessionCostUSD: 2.22, + last30DaysTokens: 2222, + last30DaysCostUSD: 22.22, + updatedAt: Date(timeIntervalSince1970: 200)) + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + store._setTokenSnapshotForTesting(snapshotA, provider: .codex) + 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() + + // Root open anchors the baseline to snapshot A. Normalize via an explicit comparison so the + // assertion below is independent of whatever the controller's initial baseline happened to be. + controller.menuWillOpen(menu) + _ = controller.didMenuAdjunctReadinessChange() + controller.menuDidClose(menu) + + // Closed store change to B: the optimization intentionally skips recomputing the baseline here. + store._setTokenSnapshotForTesting(snapshotB, provider: .codex) + + // Reopening the root menu rebuilds from B and must re-anchor the baseline to B. + controller.menuWillOpen(menu) + controller.menuDidClose(menu) + + // Reverting to A (the value the *first* baseline held) must still register as a change. + store._setTokenSnapshotForTesting(snapshotA, provider: .codex) + #expect(controller.didMenuAdjunctReadinessChange()) + } + + @Test + func `root open during in flight refresh preserves stale content and does not resync baseline`() { + // When `refreshMenuForOpenIfNeeded` keeps existing menu content during an in-flight provider + // refresh, the readiness baseline must not be re-anchored to live store data. Otherwise the + // refresh-completion store mutation would compare equal against the prematurely resynced baseline + // and skip the rebuild, leaving stale content visible (#1351). + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + settings.costUsageEnabled = true + self.enableOnlyCodexForReadinessBaseline(settings) + + let snapshotA = self.makeReadinessBaselineTokenSnapshot( + sessionTokens: 111, + sessionCostUSD: 1.11, + last30DaysTokens: 1111, + last30DaysCostUSD: 11.11, + updatedAt: Date(timeIntervalSince1970: 100)) + let snapshotB = self.makeReadinessBaselineTokenSnapshot( + sessionTokens: 222, + sessionCostUSD: 2.22, + last30DaysTokens: 2222, + last30DaysCostUSD: 22.22, + updatedAt: Date(timeIntervalSince1970: 200)) + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + store._setTokenSnapshotForTesting(snapshotA, provider: .codex) + 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.populateMenu(menu, provider: .codex) + controller.markMenuFresh(menu) + let key = ObjectIdentifier(menu) + let openedVersion = controller.menuVersions[key] + + store.isRefreshing = true + store._setTokenSnapshotForTesting(snapshotB, provider: .codex) + controller.invalidateMenus(allowStaleContentDuringDataRefresh: true) + + #expect(controller.menuContentVersion != openedVersion) + #expect(controller.menuVersions[key] == openedVersion) + + controller.menuWillOpen(menu) + defer { controller.menuDidClose(menu) } + + // Stale content was preserved: the menu is still behind the current content version. + #expect(controller.menuNeedsRefresh(menu)) + + store.isRefreshing = false + // Refresh completion must still register as a readiness change so the open menu can rebuild. + #expect(controller.didMenuAdjunctReadinessChange()) + } + + @Test + func `native merged menu preparation during in flight refresh preserves stale menu freshness`() throws { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.costUsageEnabled = true + self.enableOnlyCodexForReadinessBaseline(settings) + if let claudeMetadata = ProviderRegistry.shared.metadata[.claude] { + settings.setProviderEnabled(provider: .claude, metadata: claudeMetadata, enabled: true) + } + + let snapshotA = self.makeReadinessBaselineTokenSnapshot( + sessionTokens: 111, + sessionCostUSD: 1.11, + last30DaysTokens: 1111, + last30DaysCostUSD: 11.11, + updatedAt: Date(timeIntervalSince1970: 100)) + let snapshotB = self.makeReadinessBaselineTokenSnapshot( + sessionTokens: 222, + sessionCostUSD: 2.22, + last30DaysTokens: 2222, + last30DaysCostUSD: 22.22, + updatedAt: Date(timeIntervalSince1970: 200)) + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + store._setTokenSnapshotForTesting(snapshotA, provider: .codex) + 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 = try #require(controller.statusItem.menu) + #expect(menu === controller.mergedMenu) + controller.menuNeedsUpdate(menu) + let key = ObjectIdentifier(menu) + let preparedVersion = controller.menuVersions[key] + + store.isRefreshing = true + store._setTokenSnapshotForTesting(snapshotB, provider: .codex) + controller.invalidateMenus(allowStaleContentDuringDataRefresh: true) + + controller.menuNeedsUpdate(menu) + + #expect(controller.menuVersions[key] == preparedVersion) + #expect(controller.menuNeedsRefresh(menu)) + } + + @Test + func `root open before deferred store observation rebuilds and refreshes matching observer`() { + // Store observation invalidates menus from a deferred main-actor task. If a closed menu opens after + // live data changes but before that task runs, it must rebuild from live data and let the matching + // observer invalidate any coalesced non-readiness menu state without losing the readiness baseline. + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + settings.costUsageEnabled = true + self.enableOnlyCodexForReadinessBaseline(settings) + + let snapshotA = self.makeReadinessBaselineTokenSnapshot( + sessionTokens: 111, + sessionCostUSD: 1.11, + last30DaysTokens: 1111, + last30DaysCostUSD: 11.11, + updatedAt: Date(timeIntervalSince1970: 100)) + let snapshotB = self.makeReadinessBaselineTokenSnapshot( + sessionTokens: 222, + sessionCostUSD: 2.22, + last30DaysTokens: 2222, + last30DaysCostUSD: 22.22, + updatedAt: Date(timeIntervalSince1970: 200)) + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + store._setTokenSnapshotForTesting(snapshotA, provider: .codex) + 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.populateMenu(menu, provider: .codex) + controller.markMenuFresh(menu) + controller.resyncMenuAdjunctReadinessBaseline() + + // Simulate the live store mutation being visible before the observation task has invalidated menus. + store._setTokenSnapshotForTesting(snapshotB, provider: .codex) + controller.menuWillOpen(menu) + defer { controller.menuDidClose(menu) } + + let key = ObjectIdentifier(menu) + let versionAfterOpen = controller.menuContentVersion + let menuVersionAfterOpen = controller.menuVersions[key] + #expect(!controller.menuNeedsRefresh(menu)) + + controller.handleObservedStoreMenuChange() + + #expect(controller.menuContentVersion != versionAfterOpen) + #expect(controller.menuVersions[key] == menuVersionAfterOpen) + #expect(controller.menuNeedsRefresh(menu)) + #expect(!controller.didMenuAdjunctReadinessChange()) + } + + @Test + func `root open before deferred store observation during refresh leaves observer pending`() { + // The pre-observer root-open repair must not bypass the in-flight refresh stale-content path. + // While data is refreshing, the deferred observer should still invalidate the open menu and defer + // parent rebuild instead of marking an intermediate snapshot fresh. + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + settings.costUsageEnabled = true + self.enableOnlyCodexForReadinessBaseline(settings) + + let snapshotA = self.makeReadinessBaselineTokenSnapshot( + sessionTokens: 111, + sessionCostUSD: 1.11, + last30DaysTokens: 1111, + last30DaysCostUSD: 11.11, + updatedAt: Date(timeIntervalSince1970: 100)) + let snapshotB = self.makeReadinessBaselineTokenSnapshot( + sessionTokens: 222, + sessionCostUSD: 2.22, + last30DaysTokens: 2222, + last30DaysCostUSD: 22.22, + updatedAt: Date(timeIntervalSince1970: 200)) + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + store._setTokenSnapshotForTesting(snapshotA, provider: .codex) + 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.populateMenu(menu, provider: .codex) + controller.markMenuFresh(menu) + controller.resyncMenuAdjunctReadinessBaseline() + + store.isRefreshing = true + store._setTokenSnapshotForTesting(snapshotB, provider: .codex) + controller.menuWillOpen(menu) + defer { controller.menuDidClose(menu) } + + let versionAfterOpen = controller.menuContentVersion + #expect(!controller.menuNeedsRefresh(menu)) + + controller.handleObservedStoreMenuChange() + + #expect(controller.menuContentVersion != versionAfterOpen) + #expect(controller.menuNeedsRefresh(menu)) + #expect(!controller.didMenuAdjunctReadinessChange()) + } + + @Test + func `fresh newer-version root open during unrelated refresh still reanchors baseline`() { + // An in-flight refresh elsewhere must not block re-anchoring when this menu was already rebuilt for + // a newer menuContentVersion. Otherwise the stale baseline can still hide a later reverted update. + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + settings.costUsageEnabled = true + self.enableOnlyCodexForReadinessBaseline(settings) + + let snapshotA = self.makeReadinessBaselineTokenSnapshot( + sessionTokens: 111, + sessionCostUSD: 1.11, + last30DaysTokens: 1111, + last30DaysCostUSD: 11.11, + updatedAt: Date(timeIntervalSince1970: 100)) + let snapshotB = self.makeReadinessBaselineTokenSnapshot( + sessionTokens: 222, + sessionCostUSD: 2.22, + last30DaysTokens: 2222, + last30DaysCostUSD: 22.22, + updatedAt: Date(timeIntervalSince1970: 200)) + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + store._setTokenSnapshotForTesting(snapshotA, provider: .codex) + 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.populateMenu(menu, provider: .codex) + controller.markMenuFresh(menu) + controller.resyncMenuAdjunctReadinessBaseline() + + store._setTokenSnapshotForTesting(snapshotB, provider: .codex) + controller.invalidateMenus() + controller.populateMenu(menu, provider: .codex) + controller.markMenuFresh(menu) + + store.isRefreshing = true + controller.menuWillOpen(menu) + defer { controller.menuDidClose(menu) } + + store._setTokenSnapshotForTesting(snapshotA, provider: .codex) + #expect(controller.didMenuAdjunctReadinessChange()) + } + + @Test + func `equal-signature root open advances baseline version before next pre-observer change`() { + // A root open whose signature still equals the baseline can nevertheless confirm that the visible + // menu is fresh for a newer menuContentVersion. Record that version so a later live-data change before + // its deferred observer does not look like already-rendered data. + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + settings.costUsageEnabled = true + self.enableOnlyCodexForReadinessBaseline(settings) + + let snapshotA = self.makeReadinessBaselineTokenSnapshot( + sessionTokens: 111, + sessionCostUSD: 1.11, + last30DaysTokens: 1111, + last30DaysCostUSD: 11.11, + updatedAt: Date(timeIntervalSince1970: 100)) + let snapshotB = self.makeReadinessBaselineTokenSnapshot( + sessionTokens: 222, + sessionCostUSD: 2.22, + last30DaysTokens: 2222, + last30DaysCostUSD: 22.22, + updatedAt: Date(timeIntervalSince1970: 200)) + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + store._setTokenSnapshotForTesting(snapshotA, provider: .codex) + 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(for: .codex) + controller.providerMenus[.codex] = menu + controller.populateMenu(menu, provider: .codex) + controller.markMenuFresh(menu) + controller.resyncMenuAdjunctReadinessBaseline() + + controller.invalidateMenus() + controller.populateMenu(menu, provider: .codex) + controller.markMenuFresh(menu) + controller.menuWillOpen(menu) + controller.menuDidClose(menu) + + let versionBeforeChange = controller.menuContentVersion + store._setTokenSnapshotForTesting(snapshotB, provider: .codex) + controller.menuWillOpen(menu) + defer { controller.menuDidClose(menu) } + + #expect(controller.menuContentVersion != versionBeforeChange) + #expect(!controller.menuNeedsRefresh(menu)) + #expect(!controller.didMenuAdjunctReadinessChange()) + } + + @Test + func `newer-version root open rebuilds when rendered signature is older than live data`() { + // A menu can be fresh for the current menuContentVersion while still having rendered an older + // readiness signature than the current live store. Root open must rebuild in that pre-observer gap. + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + settings.costUsageEnabled = true + self.enableOnlyCodexForReadinessBaseline(settings) + + let snapshotA = self.makeReadinessBaselineTokenSnapshot( + sessionTokens: 111, + sessionCostUSD: 1.11, + last30DaysTokens: 1111, + last30DaysCostUSD: 11.11, + updatedAt: Date(timeIntervalSince1970: 100)) + let snapshotB = self.makeReadinessBaselineTokenSnapshot( + sessionTokens: 222, + sessionCostUSD: 2.22, + last30DaysTokens: 2222, + last30DaysCostUSD: 22.22, + updatedAt: Date(timeIntervalSince1970: 200)) + let snapshotC = self.makeReadinessBaselineTokenSnapshot( + sessionTokens: 333, + sessionCostUSD: 3.33, + last30DaysTokens: 3333, + last30DaysCostUSD: 33.33, + updatedAt: Date(timeIntervalSince1970: 300)) + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + store._setTokenSnapshotForTesting(snapshotA, provider: .codex) + 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(for: .codex) + controller.providerMenus[.codex] = menu + controller.populateMenu(menu, provider: .codex) + controller.markMenuFresh(menu) + controller.resyncMenuAdjunctReadinessBaseline() + + store._setTokenSnapshotForTesting(snapshotB, provider: .codex) + controller.invalidateMenus() + controller.populateMenu(menu, provider: .codex) + controller.markMenuFresh(menu) + controller.menuWillOpen(menu) + controller.menuDidClose(menu) + + let versionBeforeChange = controller.menuContentVersion + store._setTokenSnapshotForTesting(snapshotC, provider: .codex) + controller.menuWillOpen(menu) + defer { controller.menuDidClose(menu) } + + #expect(controller.menuContentVersion != versionBeforeChange) + #expect(!controller.menuNeedsRefresh(menu)) + #expect(!controller.didMenuAdjunctReadinessChange()) + } + + @Test + func `equal-signature root open rebuilds when rendered signature reverted before observer`() { + // A closed provider menu can be rebuilt from B while the readiness baseline remains A. If live data + // reverts to A before the deferred observer runs, root open must still repair the B-rendered menu. + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + settings.costUsageEnabled = true + self.enableOnlyCodexForReadinessBaseline(settings) + + let snapshotA = self.makeReadinessBaselineTokenSnapshot( + sessionTokens: 111, + sessionCostUSD: 1.11, + last30DaysTokens: 1111, + last30DaysCostUSD: 11.11, + updatedAt: Date(timeIntervalSince1970: 100)) + let snapshotB = self.makeReadinessBaselineTokenSnapshot( + sessionTokens: 222, + sessionCostUSD: 2.22, + last30DaysTokens: 2222, + last30DaysCostUSD: 22.22, + updatedAt: Date(timeIntervalSince1970: 200)) + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + store._setTokenSnapshotForTesting(snapshotA, provider: .codex) + 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(for: .codex) + controller.providerMenus[.codex] = menu + let key = ObjectIdentifier(menu) + controller.populateMenu(menu, provider: .codex) + controller.markMenuFresh(menu) + controller.resyncMenuAdjunctReadinessBaseline() + + store._setTokenSnapshotForTesting(snapshotB, provider: .codex) + controller.invalidateMenus() + controller.populateMenu(menu, provider: .codex) + controller.markMenuFresh(menu) + + let renderedBSignature = controller.menuReadinessSignatures[key] + store._setTokenSnapshotForTesting(snapshotA, provider: .codex) + let versionBeforeOpen = controller.menuContentVersion + controller.menuWillOpen(menu) + defer { controller.menuDidClose(menu) } + + #expect(controller.menuContentVersion != versionBeforeOpen) + #expect(controller.menuReadinessSignatures[key] != renderedBSignature) + #expect(controller.menuReadinessSignatures[key] == controller.menuAdjunctReadinessSignature()) + #expect(!controller.didMenuAdjunctReadinessChange()) + } + + @Test + func `provider root open before deferred store observation leaves sibling provider menu stale`() { + // The readiness signature is global across enabled providers. In split-icon mode, opening one + // provider's menu must not consume a pending global observation while leaving sibling menus marked + // fresh even though their provider data changed. + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + settings.costUsageEnabled = true + self.enableProvidersForReadinessBaseline(settings, providers: [.claude, .codex]) + + let snapshotA = self.makeReadinessBaselineTokenSnapshot( + sessionTokens: 111, + sessionCostUSD: 1.11, + last30DaysTokens: 1111, + last30DaysCostUSD: 11.11, + updatedAt: Date(timeIntervalSince1970: 100)) + let snapshotB = self.makeReadinessBaselineTokenSnapshot( + sessionTokens: 222, + sessionCostUSD: 2.22, + last30DaysTokens: 2222, + last30DaysCostUSD: 22.22, + updatedAt: Date(timeIntervalSince1970: 200)) + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + store._setTokenSnapshotForTesting(snapshotA, provider: .claude) + store._setTokenSnapshotForTesting(snapshotA, provider: .codex) + let controller = StatusItemController( + store: store, + settings: settings, + account: UsageFetcher().loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + controller.menuRefreshEnabledOverrideForTesting = true + + let claudeMenu = controller.makeMenu(for: .claude) + controller.populateMenu(claudeMenu, provider: .claude) + controller.markMenuFresh(claudeMenu) + let codexMenu = controller.makeMenu(for: .codex) + controller.populateMenu(codexMenu, provider: .codex) + controller.markMenuFresh(codexMenu) + controller.resyncMenuAdjunctReadinessBaseline() + + store._setTokenSnapshotForTesting(snapshotB, provider: .claude) + controller.menuWillOpen(codexMenu) + defer { controller.menuDidClose(codexMenu) } + + let versionAfterOpen = controller.menuContentVersion + #expect(!controller.menuNeedsRefresh(codexMenu)) + #expect(controller.menuNeedsRefresh(claudeMenu)) + + controller.handleObservedStoreMenuChange() + + #expect(controller.menuContentVersion != versionAfterOpen) + #expect(controller.menuNeedsRefresh(codexMenu)) + #expect(controller.menuNeedsRefresh(claudeMenu)) + #expect(!controller.didMenuAdjunctReadinessChange()) + } + + private func enableOnlyCodexForReadinessBaseline(_ settings: SettingsStore) { + self.enableProvidersForReadinessBaseline(settings, providers: [.codex]) + } + + private func enableProvidersForReadinessBaseline(_ settings: SettingsStore, providers: Set) { + let registry = ProviderRegistry.shared + for provider in UsageProvider.allCases { + guard let metadata = registry.metadata[provider] else { continue } + settings.setProviderEnabled(provider: provider, metadata: metadata, enabled: providers.contains(provider)) + } + } + + private func makeReadinessBaselineTokenSnapshot( + sessionTokens: Int, + sessionCostUSD: Double, + last30DaysTokens: Int, + last30DaysCostUSD: Double, + updatedAt: 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) + } +} diff --git a/Tests/CodexBarTests/StatusMenuScopedCodexRefreshTests.swift b/Tests/CodexBarTests/StatusMenuScopedCodexRefreshTests.swift new file mode 100644 index 0000000000..532ec780cd --- /dev/null +++ b/Tests/CodexBarTests/StatusMenuScopedCodexRefreshTests.swift @@ -0,0 +1,79 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +@MainActor +@Suite(.serialized) +struct StatusMenuScopedCodexRefreshTests { + @Test + func `scoped refresh reconciles usage after dashboard login expires`() async { + let settings = self.makeSettings() + settings.refreshFrequency = .manual + settings.statusChecksEnabled = false + settings.openAIWebAccessEnabled = true + settings.codexCookieSource = .auto + self.enableOnlyCodex(settings) + + let account = AccountInfo(email: "test@example.com", plan: "pro") + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + store.accountInfoCache[.codex] = UsageStore.AccountInfoCacheEntry( + account: account, + configRevision: settings.configRevision, + expiresAt: .distantFuture) + let controller = StatusItemController( + store: store, + settings: settings, + account: account, + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: .system) + + var providerRefreshes = 0 + store._test_providerRefreshOverride = { provider in + #expect(provider == .codex) + providerRefreshes += 1 + } + store._test_tokenUsageRefreshOverride = { _, _ in } + store._test_codexCreditsLoaderOverride = { + CreditsSnapshot(remaining: 25, events: [], updatedAt: Date()) + } + store._test_openAIDashboardLoaderOverride = { _, _, _, _ in + throw OpenAIDashboardFetcher.FetchError.loginRequired + } + store._test_openAIDashboardCookieImportOverride = { targetEmail, _, _, _, _ in + OpenAIDashboardBrowserCookieImporter.ImportResult( + sourceLabel: "Chrome", + cookieCount: 2, + signedInEmail: targetEmail, + matchesCodexEmail: true) + } + + await controller.performStoreRefresh( + for: .codex, + refreshOpenMenusWhenComplete: false, + interaction: .userInitiated) + + #expect(store.openAIDashboardRequiresLogin) + #expect(providerRefreshes == 2) + } + + private func makeSettings() -> SettingsStore { + let suite = "StatusMenuScopedCodexRefreshTests-\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suite)! + defaults.removePersistentDomain(forName: suite) + return SettingsStore( + userDefaults: defaults, + configStore: testConfigStore(suiteName: suite), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + } + + private func enableOnlyCodex(_ settings: SettingsStore) { + for provider in UsageProvider.allCases { + guard let metadata = ProviderRegistry.shared.metadata[provider] else { continue } + settings.setProviderEnabled(provider: provider, metadata: metadata, enabled: provider == .codex) + } + } +} diff --git a/Tests/CodexBarTests/StatusMenuSwitcherClickTests.swift b/Tests/CodexBarTests/StatusMenuSwitcherClickTests.swift index 9fe93b78d1..b7adc2f0d8 100644 --- a/Tests/CodexBarTests/StatusMenuSwitcherClickTests.swift +++ b/Tests/CodexBarTests/StatusMenuSwitcherClickTests.swift @@ -129,6 +129,241 @@ struct StatusMenuSwitcherClickTests { #expect(settings.selectedMenuProvider == .codex) } + @Test + func `merged switcher commits selection on matching mouse up`() throws { + var selections: [ProviderSwitcherSelection] = [] + let switcher = ProviderSwitcherView( + providers: [.codex, .claude], + selected: .provider(.codex), + includesOverview: true, + width: 320, + showsIcons: false, + iconProvider: { _ in NSImage() }, + weeklyRemainingProvider: { _ in nil }, + onSelect: { selections.append($0) }) + + #expect(switcher._test_simulateMouseDown(buttonTag: 0)) + #expect(selections.isEmpty) + let mouseUp = try #require(switcher._test_mouseUpEvent(buttonTag: 0)) + #expect(switcher.handleMenuTrackingMouseUp(mouseUp)) + #expect(selections == [.overview]) + } + + @Test + func `menu tracking routes switcher pointer sequence before AppKit menu dispatch`() throws { + var selected: ProviderSwitcherSelection? + let switcher = ProviderSwitcherView( + providers: [.codex, .claude], + selected: .provider(.codex), + includesOverview: true, + width: 320, + showsIcons: false, + iconProvider: { _ in NSImage() }, + weeklyRemainingProvider: { _ in nil }, + onSelect: { selected = $0 }) + let menu = StatusItemMenu() + let item = NSMenuItem() + item.view = switcher + item.isEnabled = false + menu.addItem(item) + + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + let fetcher = UsageFetcher() + let controller = StatusItemController( + store: UsageStore( + fetcher: fetcher, + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings), + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + + let mouseDown = try #require(switcher._test_mouseDownEvent(buttonTag: 0)) + let mouseUp = try #require(switcher._test_mouseUpEvent(buttonTag: 0)) + #expect(controller.handleProviderSwitcherTrackingEvent(mouseDown, menu: menu)) + #expect(selected == nil) + #expect(controller.handleProviderSwitcherTrackingEvent(mouseUp, menu: menu)) + #expect(selected == .overview) + } + + @Test + func `merged switcher runtime click defers icon rendering until after event handling`() async throws { + let previousMenuCardRendering = StatusItemController.menuCardRenderingEnabled + let previousMenuRefresh = StatusItemController.menuRefreshEnabled + StatusItemController.menuCardRenderingEnabled = false + StatusItemController.setMenuRefreshEnabledForTesting(false) + defer { + StatusItemController.menuCardRenderingEnabled = previousMenuCardRendering + StatusItemController.setMenuRefreshEnabledForTesting(previousMenuRefresh) + } + + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.selectedMenuProvider = .codex + settings.mergedMenuLastSelectedWasOverview = false + + let registry = ProviderRegistry.shared + for provider in UsageProvider.allCases { + guard let metadata = registry.metadata[provider] else { continue } + let shouldEnable = provider == .codex || provider == .claude + settings.setProviderEnabled(provider: provider, metadata: metadata, enabled: shouldEnable) + } + + 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()) + defer { controller.releaseStatusItemsForTesting() } + + controller.applyIcon(phase: nil) + #expect(controller.lastAppliedMergedIconRenderSignature?.contains("provider=codex") == true) + + let menu = controller.makeMenu() + controller.menuWillOpen(menu) + + let switcher = try #require(menu.items.first?.view as? ProviderSwitcherView) + #expect(switcher._test_simulateRuntimeClick(buttonTag: 2)) + + #expect(settings.selectedMenuProvider == .claude) + #expect(controller.lastAppliedMergedIconRenderSignature?.contains("provider=codex") == true) + await controller.providerSelectionUIRefreshTask?.value + #expect(controller.lastAppliedMergedIconRenderSignature?.contains("provider=claude") == true) + } + + @Test + func `merged switcher click marks menu stale before deferred rebuild`() throws { + let previousMenuCardRendering = StatusItemController.menuCardRenderingEnabled + let previousMenuRefresh = StatusItemController.menuRefreshEnabled + StatusItemController.menuCardRenderingEnabled = false + StatusItemController.setMenuRefreshEnabledForTesting(true) + defer { + StatusItemController.menuCardRenderingEnabled = previousMenuCardRendering + StatusItemController.setMenuRefreshEnabledForTesting(previousMenuRefresh) + } + + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.selectedMenuProvider = .codex + settings.mergedMenuLastSelectedWasOverview = false + + let registry = ProviderRegistry.shared + for provider in UsageProvider.allCases { + guard let metadata = registry.metadata[provider] else { continue } + let shouldEnable = provider == .codex || provider == .claude + settings.setProviderEnabled(provider: provider, metadata: metadata, enabled: shouldEnable) + } + + 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()) + defer { controller.releaseStatusItemsForTesting() } + + controller._test_providerSwitcherMenuRebuildDebounceNanoseconds = UInt64.max + defer { controller._test_providerSwitcherMenuRebuildDebounceNanoseconds = nil } + + let menu = controller.makeMenu() + controller.menuWillOpen(menu) + let key = ObjectIdentifier(menu) + #expect(controller.menuVersions[key] == controller.menuContentVersion) + + let openedVersion = controller.menuContentVersion + let switcher = try #require(menu.items.first?.view as? ProviderSwitcherView) + #expect(switcher._test_simulateRuntimeClick(buttonTag: 2)) + + #expect(settings.selectedMenuProvider == .claude) + #expect(controller.menuContentVersion != openedVersion) + #expect(controller.menuVersions[key] != controller.menuContentVersion) + + controller.menuDidClose(menu) + #expect(controller.menuVersions[key] != controller.menuContentVersion) + #expect(controller.openMenuRebuildTasks[key] == nil) + } + + @Test + func `merged switcher runtime click updates loading animation state after event handling`() async throws { + let previousMenuCardRendering = StatusItemController.menuCardRenderingEnabled + let previousMenuRefresh = StatusItemController.menuRefreshEnabled + StatusItemController.menuCardRenderingEnabled = false + StatusItemController.setMenuRefreshEnabledForTesting(false) + defer { + StatusItemController.menuCardRenderingEnabled = previousMenuCardRendering + StatusItemController.setMenuRefreshEnabledForTesting(previousMenuRefresh) + } + + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.selectedMenuProvider = .codex + settings.mergedMenuLastSelectedWasOverview = false + + let registry = ProviderRegistry.shared + for provider in UsageProvider.allCases { + guard let metadata = registry.metadata[provider] else { continue } + let shouldEnable = provider == .codex || provider == .claude + settings.setProviderEnabled(provider: provider, metadata: metadata, enabled: shouldEnable) + } + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + store._setSnapshotForTesting( + UsageSnapshot( + primary: RateWindow(usedPercent: 25, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: nil, + updatedAt: Date()), + provider: .codex) + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + + controller.applyIcon(phase: nil) + #expect(controller.needsMenuBarIconAnimation() == false) + #expect(controller.animationDriver == nil) + + let menu = controller.makeMenu() + controller.menuWillOpen(menu) + + let switcher = try #require(menu.items.first?.view as? ProviderSwitcherView) + #expect(switcher._test_simulateRuntimeClick(buttonTag: 2)) + #expect(settings.selectedMenuProvider == .claude) + await controller.providerSelectionUIRefreshTask?.value + #expect(controller.needsMenuBarIconAnimation() == true) + #expect(controller.animationDriver != nil) + #expect(controller.lastAppliedMergedIconRenderSignature?.contains("provider=claude") == true) + + #expect(switcher._test_simulateRuntimeClick(buttonTag: 1)) + #expect(settings.selectedMenuProvider == .codex) + await controller.providerSelectionUIRefreshTask?.value + #expect(controller.needsMenuBarIconAnimation() == false) + #expect(controller.animationDriver == nil) + #expect(controller.lastAppliedMergedIconRenderSignature?.contains("provider=codex") == true) + } + @Test func `merged switcher switches provider while overview chart submenu is open`() async throws { let previousMenuCardRendering = StatusItemController.menuCardRenderingEnabled @@ -146,6 +381,8 @@ struct StatusMenuSwitcherClickTests { settings.mergeIcons = true settings.selectedMenuProvider = .openai settings.mergedMenuLastSelectedWasOverview = true + settings.costUsageEnabled = true + settings.costSummaryDisplayStyle = .both let registry = ProviderRegistry.shared for provider in UsageProvider.allCases { @@ -259,20 +496,37 @@ struct StatusMenuSwitcherClickTests { updater: DisabledUpdaterController(), preferencesSelection: PreferencesSelection(), statusBar: self.makeStatusBarForTesting()) + controller.menuRefreshEnabledOverrideForTesting = true + defer { controller.releaseStatusItemsForTesting() } let menu = try #require(controller.makeMenu() as? StatusItemMenu) controller.menuWillOpen(menu) #expect(menu.items.first?.view is ProviderSwitcherView) + store.tokenRefreshInFlight.insert(.codex) + defer { store.tokenRefreshInFlight.remove(.codex) } + var rebuildCount = 0 + controller._test_openMenuRebuildObserver = { _ in + rebuildCount += 1 + } + defer { controller._test_openMenuRebuildObserver = nil } #expect(try menu.performKeyEquivalent(with: Self.arrowKeyEvent(keyCode: 124)) == true) - await Task.yield() + for _ in 0..<100 where rebuildCount == 0 { + await Task.yield() + try? await Task.sleep(for: .milliseconds(5)) + } #expect(settings.mergedMenuLastSelectedWasOverview == false) #expect(settings.selectedMenuProvider == .claude) + #expect(rebuildCount == 1) #expect(try menu.performKeyEquivalent(with: Self.arrowKeyEvent(keyCode: 123)) == true) - await Task.yield() + for _ in 0..<100 where rebuildCount == 1 { + await Task.yield() + try? await Task.sleep(for: .milliseconds(5)) + } #expect(settings.mergedMenuLastSelectedWasOverview == false) #expect(settings.selectedMenuProvider == .codex) + #expect(rebuildCount == 2) } @Test @@ -495,7 +749,7 @@ struct StatusMenuSwitcherClickTests { } @Test - func `switcher quota indicator disappears when remaining becomes unavailable`() throws { + func `switcher keeps stable height when remaining becomes unavailable`() throws { var grokRemaining: Double? = 50 let noQuotaView = ProviderSwitcherView( providers: [.claude, .grok], @@ -527,7 +781,7 @@ struct StatusMenuSwitcherClickTests { #expect(view._test_quotaIndicatorFillRatios().count == 2) let noQuotaHeight = try #require(noQuotaView._test_buttonFittingSizes().last?.height) let quotaHeight = try #require(view._test_buttonFittingSizes().last?.height) - #expect(quotaHeight > noQuotaHeight) + #expect(quotaHeight == noQuotaHeight) grokRemaining = nil view.updateQuotaIndicators() @@ -538,7 +792,7 @@ struct StatusMenuSwitcherClickTests { } @Test - func `text only switcher quota bars reserve title space`() throws { + func `text only switcher keeps stable height with quota bars`() throws { let providers: [UsageProvider] = [.claude, .grok] let textOnlyWithoutQuota = ProviderSwitcherView( providers: providers, @@ -561,7 +815,7 @@ struct StatusMenuSwitcherClickTests { let withoutQuotaHeight = try #require(textOnlyWithoutQuota._test_buttonFittingSizes().first?.height) let withQuotaHeight = try #require(textOnlyWithQuota._test_buttonFittingSizes().first?.height) - #expect(withQuotaHeight > withoutQuotaHeight) + #expect(withQuotaHeight == withoutQuotaHeight) } @Test @@ -643,13 +897,21 @@ struct StatusMenuSwitcherClickTests { view.layoutSubtreeIfNeeded() // All buttons must stay within switcher bounds (no vertical overflow). - for frame in view._test_buttonFrames() { + let buttonFrames = view._test_buttonFrames() + let contentFrames = view._test_buttonContentFrames() + let trackFrames = view._test_quotaIndicatorTrackFrames() + for (frame, contentFrame) in zip(buttonFrames, contentFrames) { #expect(frame.minY >= 0) #expect(frame.maxY <= view.bounds.maxY) + #expect(abs((contentFrame?.midY ?? -1) - frame.height / 2) <= 0.5) + } + for (buttonFrame, trackFrame) in zip(buttonFrames.dropFirst(), trackFrames) { + #expect(trackFrame.minY >= buttonFrame.minY) + #expect(trackFrame.maxY <= buttonFrame.maxY) } #expect(view._test_rowCount() == 4) - #expect(view._test_rowHeight() == 44) - #expect(view.bounds.height == 188) + #expect(view._test_rowHeight() == 39) + #expect(view.bounds.height == 168) } } diff --git a/Tests/CodexBarTests/StatusMenuSwitcherLayoutTests.swift b/Tests/CodexBarTests/StatusMenuSwitcherLayoutTests.swift new file mode 100644 index 0000000000..1e2883c4a5 --- /dev/null +++ b/Tests/CodexBarTests/StatusMenuSwitcherLayoutTests.swift @@ -0,0 +1,129 @@ +import AppKit +import Testing +@testable import CodexBar + +@MainActor +@Suite(.serialized) +struct StatusMenuSwitcherLayoutTests { + @Test + func `overview switcher segment matches provider segment height when quota bars are present`() throws { + let view = ProviderSwitcherView( + providers: [.claude, .grok, .cursor], + selected: .overview, + includesOverview: true, + width: 300, + showsIcons: true, + iconProvider: { _ in NSImage(size: NSSize(width: 16, height: 16)) }, + weeklyRemainingProvider: { _ in 50 }, + onSelect: { _ in }) + view.updateConstraintsForSubtreeIfNeeded() + view.layoutSubtreeIfNeeded() + + let frames = view._test_buttonFrames() + #expect(frames.count == 4) + let overviewFrame = try #require(frames.first) + + for frame in frames.dropFirst() { + #expect(frame.height == overviewFrame.height) + #expect(frame.minY == overviewFrame.minY) + #expect(frame.maxY == overviewFrame.maxY) + } + + #expect(view._test_rowHeight() == 36) + } + + @Test + func `quota bars do not offset inline switcher content`() throws { + let view = ProviderSwitcherView( + providers: [.codex, .devin], + selected: .provider(.codex), + includesOverview: true, + width: 300, + showsIcons: true, + iconProvider: { _ in NSImage(size: NSSize(width: 16, height: 16)) }, + weeklyRemainingProvider: { provider in + provider == .devin ? 50 : nil + }, + onSelect: { _ in }) + view.updateConstraintsForSubtreeIfNeeded() + view.layoutSubtreeIfNeeded() + + let buttonFrames = view._test_buttonFrames() + let contentFrames = view._test_buttonContentFrames() + let trackFrames = view._test_quotaIndicatorTrackFrames() + #expect(buttonFrames.count == 3) + #expect(contentFrames.count == 3) + #expect(trackFrames.count == 1) + #expect(view._test_rowHeight() == 30) + + let overviewFrame = try #require(buttonFrames.first) + for (buttonFrame, contentFrame) in zip(buttonFrames, contentFrames) { + let contentFrame = try #require(contentFrame) + #expect(buttonFrame.minY == overviewFrame.minY) + #expect(buttonFrame.maxY == overviewFrame.maxY) + #expect(abs(contentFrame.midY - buttonFrame.height / 2) < 0.01) + } + + let devinButtonFrame = try #require(buttonFrames.last) + let devinTrackFrame = try #require(trackFrames.first) + #expect(devinButtonFrame.height == 30) + #expect(devinTrackFrame.minY >= devinButtonFrame.minY) + #expect(devinTrackFrame.maxY <= devinButtonFrame.maxY) + } + + @Test + func `integrated quota indicator selects its provider`() { + let view = ProviderSwitcherView( + providers: [.codex, .devin], + selected: .provider(.codex), + includesOverview: true, + width: 300, + showsIcons: true, + iconProvider: { _ in NSImage(size: NSSize(width: 16, height: 16)) }, + weeklyRemainingProvider: { $0 == .devin ? 50 : nil }, + onSelect: { _ in }) + + #expect(view._test_simulateRuntimeClickOnQuotaIndicator(buttonTag: 2)) + } + + @Test + func `localized inline switcher titles fit without losing equal sizing`() throws { + try CodexBarLocalizationOverride.$appLanguage.withValue("tr") { + for width in stride(from: CGFloat(280), through: CGFloat(330), by: 1) { + let view = ProviderSwitcherView( + providers: [.codex, .devin], + selected: .overview, + includesOverview: true, + width: width, + showsIcons: true, + iconProvider: { _ in NSImage(size: NSSize(width: 16, height: 16)) }, + weeklyRemainingProvider: { _ in nil }, + onSelect: { _ in }) + view.updateConstraintsForSubtreeIfNeeded() + view.layoutSubtreeIfNeeded() + + let frames = view._test_buttonFrames() + let desiredWidths = view._test_buttonDesiredWidths() + #expect(frames.count == 3) + #expect(desiredWidths.count == frames.count) + let firstWidth = try #require(frames.first?.width) + + for (frame, desiredWidth) in zip(frames, desiredWidths) { + #expect(frame.width == firstWidth) + let minimalInsetAllowedWidth = floor((width - 12 - 2) / 3) + let evenMinimalInsetAllowedWidth = minimalInsetAllowedWidth + .truncatingRemainder(dividingBy: 2) == 0 + ? minimalInsetAllowedWidth + : minimalInsetAllowedWidth - 1 + let roundedDesiredWidth = ceil(desiredWidth) + let evenDesiredWidth = roundedDesiredWidth.truncatingRemainder(dividingBy: 2) == 0 + ? roundedDesiredWidth + : roundedDesiredWidth + 1 + if evenMinimalInsetAllowedWidth >= evenDesiredWidth { + #expect(frame.width >= desiredWidth) + } + } + } + } + } +} diff --git a/Tests/CodexBarTests/StatusMenuSwitcherRefreshTests.swift b/Tests/CodexBarTests/StatusMenuSwitcherRefreshTests.swift index 4d71f58e1e..060574b92b 100644 --- a/Tests/CodexBarTests/StatusMenuSwitcherRefreshTests.swift +++ b/Tests/CodexBarTests/StatusMenuSwitcherRefreshTests.swift @@ -3,9 +3,69 @@ import CodexBarCore import Testing @testable import CodexBar +@MainActor +private final class SwitcherRefreshManualGate { + private var continuation: CheckedContinuation? + private var isOpen = false + + func wait() async { + if self.isOpen { + self.isOpen = false + return + } + await withCheckedContinuation { continuation in + self.continuation = continuation + } + } + + func resume() { + if let continuation = self.continuation { + continuation.resume() + self.continuation = nil + } else { + self.isOpen = true + } + } +} + @MainActor @Suite(.serialized) struct StatusMenuSwitcherRefreshTests { + @Test + func `native switcher action preserves off tab switches after button state toggles`() { + var selections: [ProviderSwitcherSelection] = [] + let switcher = ProviderSwitcherView( + providers: [.codex, .claude], + selected: .provider(.codex), + includesOverview: false, + width: 310, + showsIcons: false, + iconProvider: { _ in NSImage() }, + weeklyRemainingProvider: { _ in nil }, + onSelect: { selections.append($0) }) + + #expect(switcher._test_simulateNativeAction(buttonTag: 1, state: .on)) + #expect(selections == [.provider(.claude)]) + } + + @Test + func `native switcher action restores active tab after native toggle`() { + var selections: [ProviderSwitcherSelection] = [] + let switcher = ProviderSwitcherView( + providers: [.codex, .claude], + selected: .provider(.codex), + includesOverview: false, + width: 310, + showsIcons: false, + iconProvider: { _ in NSImage() }, + weeklyRemainingProvider: { _ in nil }, + onSelect: { selections.append($0) }) + + #expect(switcher._test_simulateNativeAction(buttonTag: 0, state: .off)) + #expect(selections.isEmpty) + #expect(Self.switcherButtons(in: switcher).first { $0.tag == 0 }?.state == .on) + } + @Test func `merged provider switch rebuilds stale width switcher rows`() async throws { let previousMenuCardRendering = StatusItemController.menuCardRenderingEnabled @@ -73,6 +133,618 @@ struct StatusMenuSwitcherRefreshTests { #expect(Self.switcherButtons(in: menu).first { $0.tag == nextProviderButton.tag }?.state == .on) } + @Test + func `selected provider tab click does not rebuild open menu`() async throws { + let previousMenuCardRendering = StatusItemController.menuCardRenderingEnabled + StatusItemController.menuCardRenderingEnabled = false + StatusItemController.setMenuRefreshEnabledForTesting(true) + defer { + StatusItemController.menuCardRenderingEnabled = previousMenuCardRendering + StatusItemController.resetMenuRefreshEnabledForTesting() + } + + let settings = Self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.selectedMenuProvider = .codex + settings.openAIWebAccessEnabled = true + Self.enableCodexAndClaude(settings) + Self.disableOverview(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() } + + let menu = controller.makeMenu() + controller.menuWillOpen(menu) + let switcher = try #require(menu.items.first?.view as? ProviderSwitcherView) + let selectedButton = try #require(Self.switcherButtons(in: menu).first { $0.state == .on }) + + var rebuildCount = 0 + controller._test_openMenuRebuildObserver = { _ in + rebuildCount += 1 + } + defer { controller._test_openMenuRebuildObserver = nil } + + #expect(switcher._test_simulateRuntimeClick(buttonTag: selectedButton.tag)) + try? await Task.sleep(for: .milliseconds(40)) + + #expect(rebuildCount == 0) + #expect(Self.switcherButtons(in: menu).first { $0.tag == selectedButton.tag }?.state == .on) + } + + @Test + func `merged provider switch updates live tab rows in place`() async throws { + let previousMenuCardRendering = StatusItemController.menuCardRenderingEnabled + StatusItemController.menuCardRenderingEnabled = false + StatusItemController.setMenuRefreshEnabledForTesting(true) + defer { + StatusItemController.menuCardRenderingEnabled = previousMenuCardRendering + StatusItemController.resetMenuRefreshEnabledForTesting() + } + + let settings = Self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.selectedMenuProvider = .codex + Self.enableCodexAndClaude(settings) + Self.disableOverview(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() } + + let menu = controller.makeMenu() + controller.menuWillOpen(menu) + let contentStartIndex = controller.providerSwitcherContentStartIndex(in: menu) + #expect(menu.items.indices.contains(contentStartIndex)) + let originalContentID = ObjectIdentifier(menu.items[contentStartIndex]) + let selectedButton = try #require(Self.switcherButtons(in: menu).first { $0.state == .on }) + let alternateButton = try #require(Self.switcherButtons(in: menu).first { $0.state == .off }) + + var rebuildCount = 0 + controller._test_openMenuRebuildObserver = { _ in + rebuildCount += 1 + } + defer { controller._test_openMenuRebuildObserver = nil } + + // Provider switches now reconcile matching rows in place instead of parking and + // restoring distinct item sets per tab: the same NSMenuItem objects carry each + // tab's freshly built content, so AppKit never relayouts the open menu per insert. + let initialSwitcher = try #require(menu.items.first?.view as? ProviderSwitcherView) + #expect(initialSwitcher._test_simulateRuntimeClick(buttonTag: alternateButton.tag)) + await Self.waitForRebuildCount(1, rebuildCount: { rebuildCount }) + #expect(menu.items.indices.contains(contentStartIndex)) + #expect(ObjectIdentifier(menu.items[contentStartIndex]) == originalContentID) + + let alternateSwitcher = try #require(menu.items.first?.view as? ProviderSwitcherView) + #expect(alternateSwitcher._test_simulateRuntimeClick(buttonTag: selectedButton.tag)) + await Self.waitForRebuildCount(2, rebuildCount: { rebuildCount }) + #expect(menu.items.indices.contains(contentStartIndex)) + #expect(ObjectIdentifier(menu.items[contentStartIndex]) == originalContentID) + + let restoredSwitcher = try #require(menu.items.first?.view as? ProviderSwitcherView) + #expect(restoredSwitcher._test_simulateRuntimeClick(buttonTag: alternateButton.tag)) + await Self.waitForRebuildCount(3, rebuildCount: { rebuildCount }) + #expect(menu.items.indices.contains(contentStartIndex)) + #expect(ObjectIdentifier(menu.items[contentStartIndex]) == originalContentID) + + controller.invalidateMenus() + #expect(controller.mergedSwitcherContentCaches.isEmpty) + } + + @Test + func `smart provider switch resizes persistent refresh row to rendered menu width`() 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() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.selectedMenuProvider = .codex + Self.enableCodexAndClaude(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() } + + let menu = controller.makeMenu() + controller.menuWillOpen(menu) + menu.minimumWidth = 420 + + let descriptor = controller.makeMenuDescriptor(provider: .claude, includeContextualActions: true) + controller.updateMenuContentPreservingSwitcher( + menu, + context: StatusItemController.MenuUpdateContext( + provider: .claude, + currentProvider: .claude, + switcherSelection: .provider(.claude), + menuWidth: StatusItemController.menuCardBaseWidth, + codexAccountDisplay: nil, + tokenAccountDisplay: nil, + openAIContext: StatusItemController.OpenAIWebContext( + hasUsageBreakdown: false, + hasCreditsHistory: false, + hasCostHistory: false, + canShowBuyCredits: false, + hasOpenAIWebMenuItems: false), + descriptor: descriptor)) + + let expectedWidth = controller.renderedMenuWidth(for: menu) + #expect(expectedWidth == 420) + let refreshView = try #require(menu.items.first { $0.title == "Refresh" }?.view as? PersistentRefreshMenuView) + #expect(abs(refreshView.frame.width - expectedWidth) <= 0.5) + } + + @Test + func `manual refresh keeps codex quota visible after switching away and back`() async throws { + let previousMenuCardRendering = StatusItemController.menuCardRenderingEnabled + StatusItemController.menuCardRenderingEnabled = false + StatusItemController.setMenuRefreshEnabledForTesting(true) + defer { + StatusItemController.menuCardRenderingEnabled = previousMenuCardRendering + StatusItemController.resetMenuRefreshEnabledForTesting() + } + + let settings = Self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.selectedMenuProvider = .codex + Self.enableCodexAndClaude(settings) + Self.disableOverview(settings) + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + let now = Date(timeIntervalSince1970: 1_800_000_000) + store._setSnapshotForTesting(Self.quotaSnapshot(usedPercent: 21, updatedAt: now), provider: .codex) + store._setSnapshotForTesting(Self.quotaSnapshot(usedPercent: 44, updatedAt: now), provider: .claude) + let event = CreditEvent(date: now, service: "CLI", creditsUsed: 1) + let breakdown = OpenAIDashboardSnapshot.makeDailyBreakdown(from: [event], maxDays: 30) + store.openAIDashboard = OpenAIDashboardSnapshot( + signedInEmail: "test@example.com", + codeReviewRemainingPercent: nil, + creditEvents: [event], + dailyBreakdown: breakdown, + usageBreakdown: breakdown, + creditsPurchaseURL: nil, + updatedAt: now) + store.openAIDashboardAttachmentAuthorized = true + store.openAIDashboardRequiresLogin = false + let controller = StatusItemController( + store: store, + settings: settings, + account: AccountInfo(email: "test@example.com", plan: "pro"), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: .system) + defer { + controller.manualRefreshTasks.values.forEach { $0.cancel() } + controller.releaseStatusItemsForTesting() + } + + let gate = SwitcherRefreshManualGate() + controller._test_manualRefreshOperation = { + await gate.wait() + } + defer { controller._test_manualRefreshOperation = nil } + + let menu = controller.makeMenu() + controller.menuWillOpen(menu) + let selectedButton = try #require(Self.switcherButtons(in: menu).first { $0.state == .on }) + let alternateButton = try #require(Self.switcherButtons(in: menu).first { $0.state == .off }) + + controller.refreshNow() + #expect(controller.menuCardRefreshMonitor.isManualRefreshInFlight) + store.refreshingProviders.insert(.codex) + + store._setSnapshotForTesting( + UsageSnapshot(primary: nil, secondary: nil, updatedAt: now.addingTimeInterval(1)), + provider: .codex) + + var rebuildCount = 0 + controller._test_openMenuRebuildObserver = { _ in + rebuildCount += 1 + } + defer { controller._test_openMenuRebuildObserver = nil } + + let initialSwitcher = try #require(menu.items.first?.view as? ProviderSwitcherView) + #expect(initialSwitcher._test_simulateRuntimeClick(buttonTag: alternateButton.tag)) + await Self.waitForRebuildCount(1, rebuildCount: { rebuildCount }) + + let alternateSwitcher = try #require(menu.items.first?.view as? ProviderSwitcherView) + #expect(alternateSwitcher._test_simulateRuntimeClick(buttonTag: selectedButton.tag)) + await Self.waitForRebuildCount(2, rebuildCount: { rebuildCount }) + #expect(settings.selectedMenuProvider == .codex) + + let usageItem = menu.items.first { ($0.representedObject as? String) == "menuCardUsage" } + #expect(usageItem != nil) + #expect(menu.items.contains { ($0.representedObject as? String) == "menuCardHeader" } == false) + + let emptyFallback = try #require(controller.menuCardModel(for: .codex)) + let inFlight = controller.menuCardRefreshMonitor.model(for: .codex, fallback: emptyFallback) + let subtitle = controller.menuCardRefreshMonitor.subtitle( + for: .codex, + fallback: MenuCardLiveSubtitle(text: emptyFallback.subtitleText, style: emptyFallback.subtitleStyle)) + + #expect(emptyFallback.metrics.isEmpty) + #expect(subtitle.text == "Refreshing…") + #expect(inFlight.metrics.first?.percentLabel == "79% left") + + gate.resume() + store.refreshingProviders.remove(.codex) + await controller.manualRefreshTasks[.global]?.value + #expect(!controller.menuCardRefreshMonitor.isManualRefreshInFlight) + let completed = controller.menuCardRefreshMonitor.model(for: .codex, fallback: emptyFallback) + #expect(completed.metrics.isEmpty) + } + + @Test + func `completed refresh re-enables cached item when switching back`() async throws { + let previousMenuCardRendering = StatusItemController.menuCardRenderingEnabled + StatusItemController.menuCardRenderingEnabled = false + StatusItemController.setMenuRefreshEnabledForTesting(true) + defer { + StatusItemController.menuCardRenderingEnabled = previousMenuCardRendering + StatusItemController.resetMenuRefreshEnabledForTesting() + } + + let settings = Self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.selectedMenuProvider = .codex + Self.enableCodexAndClaude(settings) + Self.disableOverview(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.manualRefreshTasks.values.forEach { $0.cancel() } + controller.releaseStatusItemsForTesting() + } + + let gate = SwitcherRefreshManualGate() + controller._test_manualRefreshOperation = { await gate.wait() } + defer { controller._test_manualRefreshOperation = nil } + + let menu = controller.makeMenu() + controller.menuWillOpen(menu) + let selectedButton = try #require(Self.switcherButtons(in: menu).first { $0.state == .on }) + let alternateButton = try #require(Self.switcherButtons(in: menu).first { $0.state == .off }) + let initialRefreshItem = try #require(menu.items.first { $0.title == "Refresh" }) + + controller.refreshNow() + let refreshTask = try #require(controller.manualRefreshTasks[.global]) + #expect(!initialRefreshItem.isEnabled) + + var rebuildCount = 0 + controller._test_openMenuRebuildObserver = { _ in rebuildCount += 1 } + defer { controller._test_openMenuRebuildObserver = nil } + + let initialSwitcher = try #require(menu.items.first?.view as? ProviderSwitcherView) + #expect(initialSwitcher._test_simulateRuntimeClick(buttonTag: alternateButton.tag)) + await Self.waitForRebuildCount(1, rebuildCount: { rebuildCount }) + + gate.resume() + await refreshTask.value + #expect(controller.manualRefreshTasks[.global] == nil) + + let alternateSwitcher = try #require(menu.items.first?.view as? ProviderSwitcherView) + #expect(alternateSwitcher._test_simulateRuntimeClick(buttonTag: selectedButton.tag)) + await Self.waitForRebuildCount(2, rebuildCount: { rebuildCount }) + + let restoredRefreshItem = try #require(menu.items.first { $0.title == "Refresh" }) + #expect(restoredRefreshItem.isEnabled) + } + + @Test + func `full cached reattachment resynchronizes detached refresh item`() throws { + let previousMenuCardRendering = StatusItemController.menuCardRenderingEnabled + StatusItemController.menuCardRenderingEnabled = false + defer { StatusItemController.menuCardRenderingEnabled = previousMenuCardRendering } + + let settings = Self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.selectedMenuProvider = .codex + Self.enableCodexAndClaude(settings) + Self.disableOverview(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.manualRefreshTasks.values.forEach { $0.cancel() } + controller.releaseStatusItemsForTesting() + } + + let menu = controller.makeMenu() + controller.menuWillOpen(menu) + let cache = try #require( + controller.mergedSwitcherContentCaches[ObjectIdentifier(menu)]?[.provider(.codex)]) + let refreshItem = try #require(cache.items.first { $0.title == "Refresh" }) + + controller.manualRefreshTasks[.global] = Task {} + controller.updatePersistentRefreshItemsEnabled() + #expect(!refreshItem.isEnabled) + + menu.removeAllItems() + #expect(refreshItem.menu == nil) + controller.manualRefreshTasks[.global] = nil + controller.updatePersistentRefreshItemsEnabled() + #expect(!refreshItem.isEnabled) + + #expect(controller.addCachedMergedSwitcherContent( + for: .provider(.codex), + to: menu, + menuWidth: cache.menuWidth, + codexAccountDisplay: cache.codexAccountDisplay, + tokenAccountDisplay: cache.tokenAccountDisplay)) + #expect(refreshItem.menu === menu) + #expect(refreshItem.isEnabled) + } + + @Test + func `a provider manual refresh only greys its own tab`() { + let settings = Self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.selectedMenuProvider = .codex + Self.enableCodexAndClaude(settings) + Self.disableOverview(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.manualRefreshTasks.values.forEach { $0.cancel() } + controller.manualRefreshTasks.removeAll() + controller.releaseStatusItemsForTesting() + } + + let menu = controller.makeMenu() + controller.mergedMenu = menu + controller.menuWillOpen(menu) + defer { controller.menuDidClose(menu) } + + // A manual refresh of Claude must leave the Codex tab's Refresh row enabled. + controller.manualRefreshTasks[.provider(.claude)] = Task {} + #expect(!controller.isRefreshActionInFlight(for: menu)) + + // Switching to the Claude tab reflects Claude's own in-flight refresh. + settings.selectedMenuProvider = .claude + #expect(controller.isRefreshActionInFlight(for: menu)) + + // An all-providers refresh busies every tab regardless of the selected provider. + settings.selectedMenuProvider = .codex + controller.manualRefreshTasks[.provider(.claude)] = nil + controller.manualRefreshTasks[.global] = Task {} + #expect(controller.isRefreshActionInFlight(for: menu)) + } + + @Test + func `native image menu rows are replaced during reconciliation`() { + let settings = Self.makeSettings() + 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() } + + let menu = NSMenu() + let liveItem = Self.nativeImageItem(title: "Status Page") + menu.addItem(liveItem) + let shapes = controller.menuContentShapes(in: menu, fromIndex: 0) + + let scratch = NSMenu() + let freshItem = Self.nativeImageItem(title: "Status Page") + scratch.addItem(freshItem) + + controller.reconcileMenuContent(menu, fromIndex: 0, shapes: shapes, with: scratch) + + #expect(menu.items.count == 1) + #expect(ObjectIdentifier(menu.items[0]) == ObjectIdentifier(freshItem)) + #expect(ObjectIdentifier(menu.items[0]) != ObjectIdentifier(liveItem)) + } + + @Test + func `native image submenu rows reconcile in place`() { + let settings = Self.makeSettings() + 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() } + + let menu = NSMenu() + let liveItem = Self.nativeImageItem(title: "System Account") + liveItem.submenu = NSMenu(title: "System Account") + menu.addItem(liveItem) + let shapes = controller.menuContentShapes(in: menu, fromIndex: 0) + + let scratch = NSMenu() + let freshItem = Self.nativeImageItem(title: "System Account") + freshItem.submenu = NSMenu(title: "System Account") + scratch.addItem(freshItem) + + controller.reconcileMenuContent(menu, fromIndex: 0, shapes: shapes, with: scratch) + + #expect(menu.items.count == 1) + #expect(ObjectIdentifier(menu.items[0]) == ObjectIdentifier(liveItem)) + #expect(ObjectIdentifier(menu.items[0]) != ObjectIdentifier(freshItem)) + } + + @Test + func `provider switch does not cache stale rows after required invalidation`() async throws { + let previousMenuCardRendering = StatusItemController.menuCardRenderingEnabled + StatusItemController.menuCardRenderingEnabled = false + StatusItemController.setMenuRefreshEnabledForTesting(true) + defer { + StatusItemController.menuCardRenderingEnabled = previousMenuCardRendering + StatusItemController.resetMenuRefreshEnabledForTesting() + } + + let settings = Self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.selectedMenuProvider = .codex + Self.enableCodexAndClaude(settings) + Self.disableOverview(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() } + + let menu = controller.makeMenu() + controller.menuWillOpen(menu) + let contentStartIndex = controller.providerSwitcherContentStartIndex(in: menu) + #expect(menu.items.indices.contains(contentStartIndex)) + let selectedButton = try #require(Self.switcherButtons(in: menu).first { $0.state == .on }) + let alternateButton = try #require(Self.switcherButtons(in: menu).first { $0.state == .off }) + + var rebuildCount = 0 + controller._test_openMenuRebuildObserver = { _ in + rebuildCount += 1 + } + defer { controller._test_openMenuRebuildObserver = nil } + + controller.invalidateMenus() + #expect(controller.mergedSwitcherContentCaches.isEmpty) + let initialSwitcher = try #require(menu.items.first?.view as? ProviderSwitcherView) + #expect(initialSwitcher._test_simulateRuntimeClick(buttonTag: alternateButton.tag)) + await Self.waitForRebuildCount(1, rebuildCount: { rebuildCount }) + + let alternateSwitcher = try #require(menu.items.first?.view as? ProviderSwitcherView) + #expect(alternateSwitcher._test_simulateRuntimeClick(buttonTag: selectedButton.tag)) + await Self.waitForRebuildCount(2, rebuildCount: { rebuildCount }) + + // Rows are reconciled in place, so freshness is guaranteed by rebuilding content + // from current data rather than by minting new items: the live menu must be marked + // fresh and no cached entry may predate the required invalidation. (In-place item + // identity itself is covered deterministically in MenuCardViewRecyclingTests; here + // async gate state may legitimately route a populate through the full rebuild.) + #expect(menu.items.indices.contains(contentStartIndex)) + let menuKey = ObjectIdentifier(menu) + #expect(controller.menuVersions[menuKey] == controller.menuContentVersion) + for entry in controller.mergedSwitcherContentCaches[menuKey]?.values ?? [:].values { + #expect(entry.requiredMenuContentVersion >= controller.latestRequiredMenuRebuildVersion) + } + } + + @Test + func `tab switch does not replace quota indicator constraints`() { + let switcher = ProviderSwitcherView( + providers: [.codex, .claude], + selected: .provider(.codex), + includesOverview: false, + width: 310, + showsIcons: false, + iconProvider: { _ in NSImage() }, + weeklyRemainingProvider: { _ in 75.0 }, + onSelect: { _ in }) + + let initialConstraints = switcher._test_quotaIndicatorConstraintIdentifiers() + #expect(initialConstraints.count == 2, "both providers should have quota indicators") + + switcher.updateQuotaIndicators() + + let afterFirstCall = switcher._test_quotaIndicatorConstraintIdentifiers() + #expect(afterFirstCall == initialConstraints, "same ratio: constraints must not be replaced") + } + + @Test + func `quota indicator constraints are replaced when ratio changes`() { + var currentRemaining = 75.0 + let switcher = ProviderSwitcherView( + providers: [.codex, .claude], + selected: .provider(.codex), + includesOverview: false, + width: 310, + showsIcons: false, + iconProvider: { _ in NSImage() }, + weeklyRemainingProvider: { _ in currentRemaining }, + onSelect: { _ in }) + + let initialConstraints = switcher._test_quotaIndicatorConstraintIdentifiers() + #expect(initialConstraints.count == 2) + + currentRemaining = 40.0 + switcher.updateQuotaIndicators() + + let afterDataChange = switcher._test_quotaIndicatorConstraintIdentifiers() + #expect(afterDataChange != initialConstraints, "changed ratio: constraints should be replaced") + } + private static func makeSettings() -> SettingsStore { let suite = "StatusMenuSwitcherRefreshTests-\(UUID().uuidString)" let defaults = UserDefaults(suiteName: suite)! @@ -94,10 +766,53 @@ struct StatusMenuSwitcherRefreshTests { } } + private static func disableOverview(_ settings: SettingsStore) { + let activeProviders: [UsageProvider] = [.codex, .claude] + _ = settings.setMergedOverviewProviderSelection( + provider: .codex, + isSelected: false, + activeProviders: activeProviders) + _ = settings.setMergedOverviewProviderSelection( + provider: .claude, + isSelected: false, + activeProviders: activeProviders) + } + + private static func quotaSnapshot(usedPercent: Double, updatedAt: Date) -> UsageSnapshot { + UsageSnapshot( + primary: RateWindow( + usedPercent: usedPercent, + windowMinutes: 300, + resetsAt: updatedAt.addingTimeInterval(3600), + resetDescription: nil), + secondary: nil, + updatedAt: updatedAt) + } + + private static func waitForRebuildCount( + _ expectedCount: Int, + rebuildCount: () -> Int) async + { + for _ in 0..<100 where rebuildCount() < expectedCount { + await Task.yield() + try? await Task.sleep(for: .milliseconds(10)) + } + } + private static func switcherButtons(in menu: NSMenu) -> [NSButton] { guard let switcherView = menu.items.first?.view as? ProviderSwitcherView else { return [] } - return switcherView.subviews + return self.switcherButtons(in: switcherView) + } + + private static func switcherButtons(in switcherView: ProviderSwitcherView) -> [NSButton] { + switcherView.subviews .compactMap { $0 as? NSButton } .sorted { $0.tag < $1.tag } } + + private static func nativeImageItem(title: String) -> NSMenuItem { + let item = NSMenuItem(title: title, action: nil, keyEquivalent: "") + item.image = NSImage(size: NSSize(width: 16, height: 16)) + return item + } } diff --git a/Tests/CodexBarTests/StatusMenuSwitcherTrackingTests.swift b/Tests/CodexBarTests/StatusMenuSwitcherTrackingTests.swift new file mode 100644 index 0000000000..d93630f812 --- /dev/null +++ b/Tests/CodexBarTests/StatusMenuSwitcherTrackingTests.swift @@ -0,0 +1,209 @@ +import AppKit +import CodexBarCore +import Testing +@testable import CodexBar + +@MainActor +@Suite(.serialized) +struct StatusMenuSwitcherTrackingTests { + @Test + func `switcher rebuild scheduler runs during menu tracking exactly once`() { + var runCount = 0 + ProviderSwitcherTrackingRunLoopScheduler.schedule { + runCount += 1 + } + + CFRunLoopRunInMode( + CFRunLoopMode(RunLoop.Mode.eventTracking.rawValue as CFString), + 0.1, + true) + #expect(runCount == 1) + + CFRunLoopRunInMode(.defaultMode, 0.1, true) + #expect(runCount == 1) + } + + @Test + func `pointer switch defers structural menu rebuild until mouse up`() async throws { + let previousMenuCardRendering = StatusItemController.menuCardRenderingEnabled + let previousMenuRefresh = StatusItemController.menuRefreshEnabled + StatusItemController.menuCardRenderingEnabled = false + StatusItemController.setMenuRefreshEnabledForTesting(true) + defer { + StatusItemController.menuCardRenderingEnabled = previousMenuCardRendering + StatusItemController.setMenuRefreshEnabledForTesting(previousMenuRefresh) + } + + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.selectedMenuProvider = .codex + settings.mergedMenuLastSelectedWasOverview = false + + 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 || provider == .claude) + } + + 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, + menuRefreshEnabled: false) + defer { controller.releaseStatusItemsForTesting() } + + let menu = controller.makeMenu() + controller.menuWillOpen(menu) + controller.menuRefreshEnabledOverrideForTesting = true + controller.openMenus[ObjectIdentifier(menu)] = menu + let switcher = try #require(menu.items.first?.view as? ProviderSwitcherView) + let mouseDown = try #require(switcher._test_mouseDownEvent(buttonTag: 2)) + let mouseUp = try #require(switcher._test_mouseUpEvent(buttonTag: 2)) + + var rebuildCount = 0 + controller._test_openMenuRebuildObserver = { _ in + rebuildCount += 1 + } + defer { controller._test_openMenuRebuildObserver = nil } + + #expect(controller.handleProviderSwitcherTrackingEvent(mouseDown, menu: menu)) + #expect(settings.selectedMenuProvider == .codex) + #expect(controller.providerSwitcherPointerInteractionMenuID == ObjectIdentifier(menu)) + #expect(controller.pendingProviderSwitcherPointerRebuild == nil) + + for _ in 0..<20 { + await Task.yield() + } + #expect(rebuildCount == 0) + + #expect(controller.handleProviderSwitcherTrackingEvent(mouseUp, menu: menu)) + #expect(settings.selectedMenuProvider == .claude) + for _ in 0..<100 where rebuildCount == 0 { + await Task.yield() + try? await Task.sleep(for: .milliseconds(5)) + } + #expect(rebuildCount == 1) + #expect(controller.providerSwitcherPointerInteractionMenuID == nil) + #expect(controller.pendingProviderSwitcherPointerRebuild == nil) + } + + @Test + func `pointer switch cancels when mouse up leaves pressed segment`() throws { + let previousMenuCardRendering = StatusItemController.menuCardRenderingEnabled + let previousMenuRefresh = StatusItemController.menuRefreshEnabled + StatusItemController.menuCardRenderingEnabled = false + StatusItemController.setMenuRefreshEnabledForTesting(true) + defer { + StatusItemController.menuCardRenderingEnabled = previousMenuCardRendering + StatusItemController.setMenuRefreshEnabledForTesting(previousMenuRefresh) + } + + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.selectedMenuProvider = .codex + settings.mergedMenuLastSelectedWasOverview = false + + 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 || provider == .claude) + } + + 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, + menuRefreshEnabled: false) + defer { controller.releaseStatusItemsForTesting() } + + let menu = controller.makeMenu() + controller.menuWillOpen(menu) + controller.menuRefreshEnabledOverrideForTesting = true + controller.openMenus[ObjectIdentifier(menu)] = menu + let switcher = try #require(menu.items.first?.view as? ProviderSwitcherView) + let mouseDown = try #require(switcher._test_mouseDownEvent(buttonTag: 2)) + let mouseUpElsewhere = try #require(switcher._test_mouseUpEvent(buttonTag: 1)) + + var rebuildCount = 0 + controller._test_openMenuRebuildObserver = { _ in + rebuildCount += 1 + } + defer { controller._test_openMenuRebuildObserver = nil } + + #expect(controller.handleProviderSwitcherTrackingEvent(mouseDown, menu: menu)) + #expect(controller.handleProviderSwitcherTrackingEvent(mouseUpElsewhere, menu: menu)) + #expect(settings.selectedMenuProvider == .codex) + #expect(rebuildCount == 0) + #expect(controller.providerSwitcherPointerInteractionMenuID == nil) + #expect(controller.pendingProviderSwitcherPointerRebuild == nil) + } + + @Test + func `unrelated mouse up remains available to normal menu items`() throws { + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + + let fetcher = UsageFetcher() + let controller = StatusItemController( + store: UsageStore( + fetcher: fetcher, + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings), + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: .system) + defer { controller.releaseStatusItemsForTesting() } + + let menu = NSMenu() + let switcher = ProviderSwitcherView( + providers: [.codex, .claude], + selected: .provider(.codex), + includesOverview: true, + width: 320, + showsIcons: false, + iconProvider: { _ in NSImage() }, + weeklyRemainingProvider: { _ in nil }, + onSelect: { _ in }) + let item = NSMenuItem() + item.view = switcher + menu.addItem(item) + let unrelatedMouseUp = try #require(switcher._test_mouseUpEvent(buttonTag: 1)) + + #expect(!controller.handleProviderSwitcherTrackingEvent(unrelatedMouseUp, menu: menu)) + } + + private func makeSettings() -> SettingsStore { + let suite = "StatusMenuSwitcherTrackingTests-\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suite)! + defaults.removePersistentDomain(forName: suite) + return SettingsStore( + userDefaults: defaults, + configStore: testConfigStore(suiteName: suite), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + } +} diff --git a/Tests/CodexBarTests/StatusMenuTests.swift b/Tests/CodexBarTests/StatusMenuTests.swift index cd4e5e9130..a324d5414c 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() @@ -101,6 +102,46 @@ struct StatusMenuTests { #expect(controller.dashboardURL(for: .alibaba) == AlibabaCodingPlanAPIRegion.chinaMainland.dashboardURL) } + @Test + func `zai dashboard action follows selected region`() { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + + 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()) + + settings.zaiAPIRegion = .global + #expect(controller.dashboardURL(for: .zai) == ZaiAPIRegion.global.dashboardURL) + #expect( + controller.dashboardURL(for: .zai)?.absoluteString == + "https://z.ai/manage-apikey/coding-plan/personal/my-plan") + #expect( + controller.dashboardURL( + for: .zai, + environment: [ZaiSettingsReader.apiHostKey: "open.bigmodel.cn"]) == + ZaiAPIRegion.bigmodelCN.dashboardURL) + + settings.zaiAPIRegion = .bigmodelCN + #expect(controller.dashboardURL(for: .zai) == ZaiAPIRegion.bigmodelCN.dashboardURL) + #expect(controller.dashboardURL(for: .zai)?.absoluteString == "https://bigmodel.cn/coding-plan/personal/usage") + + settings.addTokenAccount(provider: .zai, label: "Team", token: "team-token", usageScope: "team") + #expect(controller.dashboardURL(for: .zai) == ZaiAPIRegion.bigmodelCN.teamDashboardURL) + #expect( + controller.dashboardURL(for: .zai)?.absoluteString == + "https://bigmodel.cn/coding-plan/team/usage-stats") + } + @Test func `opencode go dashboard action follows configured workspace`() { self.disableMenuCardsForTesting() @@ -433,7 +474,7 @@ struct StatusMenuTests { } let menu = controller.makeMenu() controller.menuWillOpen(menu) - StatusItemController.setMenuRefreshEnabledForTesting(false) + controller.menuRefreshEnabledOverrideForTesting = false try? await Task.sleep(for: .milliseconds(180)) } @@ -553,8 +594,7 @@ struct StatusMenuTests { let menu = controller.makeMenu() controller.menuWillOpen(menu) controller.openMenus[ObjectIdentifier(menu)] = menu - StatusItemController.setMenuRefreshEnabledForTesting(true) - defer { StatusItemController.resetMenuRefreshEnabledForTesting() } + controller.menuRefreshEnabledOverrideForTesting = true let initialSwitcher = menu.items.first?.view as? ProviderSwitcherView #expect(initialSwitcher != nil) @@ -562,12 +602,19 @@ struct StatusMenuTests { settings.usageBarsShowUsed = true controller.handleProviderConfigChange(reason: "usageBarsShowUsed") - for _ in 0..<20 - where initialSwitcherID == (menu.items.first?.view as? ProviderSwitcherView).map(ObjectIdentifier.init) - { + for _ in 0..<20 { await Task.yield() } + #expect(controller.parentMenuRebuildsDeferredDuringTracking.contains(ObjectIdentifier(menu))) + if let initialSwitcherID, let currentSwitcher = menu.items.first?.view as? ProviderSwitcherView { + #expect(initialSwitcherID == ObjectIdentifier(currentSwitcher)) + } + + controller.menuDidClose(menu) + controller.menuWillOpen(menu) + defer { controller.menuDidClose(menu) } + let updatedSwitcher = menu.items.first?.view as? ProviderSwitcherView #expect(updatedSwitcher != nil) if let initialSwitcherID, let updatedSwitcher { @@ -696,8 +743,7 @@ struct StatusMenuTests { let menu = controller.makeMenu() controller.menuWillOpen(menu) controller.openMenus[ObjectIdentifier(menu)] = menu - StatusItemController.setMenuRefreshEnabledForTesting(true) - defer { StatusItemController.resetMenuRefreshEnabledForTesting() } + controller.menuRefreshEnabledOverrideForTesting = true let initialButtons = self.switcherButtons(in: menu) #expect(initialButtons.count == activeProviders.count) @@ -715,7 +761,7 @@ struct StatusMenuTests { } @Test - func `overview tab omits contextual provider actions`() { + func `overview tab omits contextual provider actions`() throws { self.disableMenuCardsForTesting() let settings = self.makeSettings() settings.statusChecksEnabled = false @@ -754,10 +800,11 @@ struct StatusMenuTests { #expect(titles.contains("About CodexBar")) #expect(titles.contains("Quit")) - let refreshItem = menu.items.first { $0.title == "Refresh" } - #expect(refreshItem != nil) - #expect(refreshItem?.keyEquivalent == "r") - #expect(refreshItem?.keyEquivalentModifierMask == [.command]) + let refreshItem = try #require(menu.items.first { $0.title == "Refresh" }) + #expect(controller.isPersistentRefreshItem(refreshItem)) + #expect(refreshItem.view is PersistentRefreshMenuView) + #expect(refreshItem.keyEquivalent.isEmpty) + #expect(refreshItem.keyEquivalentModifierMask.isEmpty) let settingsItem = menu.items.first { $0.title == "Settings..." } #expect(settingsItem != nil) @@ -820,7 +867,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 +906,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 +929,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 +939,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 @@ -1169,12 +1214,13 @@ extension StatusMenuTests { controller.menuWillOpen(menu) let usageItem = menu.items.first { ($0.representedObject as? String) == "menuCardUsage" } let creditsItem = menu.items.first { ($0.representedObject as? String) == "menuCardCredits" } + let creditsHistoryItem = menu.items.first { item in + item.submenu?.items.contains { ($0.representedObject as? String) == "creditsHistoryChart" } == true + } #expect( usageItem?.submenu?.items .contains { ($0.representedObject as? String) == "usageBreakdownChart" } == true) - #expect( - creditsItem?.submenu?.items - .contains { ($0.representedObject as? String) == "creditsHistoryChart" } == true) + #expect(creditsItem == nil && creditsHistoryItem != nil) } @Test @@ -1185,6 +1231,8 @@ extension StatusMenuTests { settings.refreshFrequency = .manual settings.mergeIcons = false settings.selectedMenuProvider = .openai + settings.costUsageEnabled = true + settings.costSummaryDisplayStyle = .both let registry = ProviderRegistry.shared let metadata = try #require(registry.metadata[.openai]) @@ -1239,17 +1287,13 @@ extension StatusMenuTests { settings.mergeIcons = true settings.selectedMenuProvider = .codex settings.costUsageEnabled = true + settings.costSummaryDisplayStyle = .both let registry = ProviderRegistry.shared - if let codexMeta = registry.metadata[.codex] { - settings.setProviderEnabled(provider: .codex, metadata: codexMeta, enabled: true) - } - if let claudeMeta = registry.metadata[.claude] { - settings.setProviderEnabled(provider: .claude, metadata: claudeMeta, enabled: false) - } - if let geminiMeta = registry.metadata[.gemini] { - settings.setProviderEnabled(provider: .gemini, metadata: geminiMeta, enabled: false) - } + let metadata = registry.metadata + try settings.setProviderEnabled(provider: .codex, metadata: #require(metadata[.codex]), enabled: true) + try settings.setProviderEnabled(provider: .claude, metadata: #require(metadata[.claude]), enabled: false) + try settings.setProviderEnabled(provider: .gemini, metadata: #require(metadata[.gemini]), enabled: false) let fetcher = UsageFetcher() let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) @@ -1368,6 +1412,7 @@ extension StatusMenuTests { settings.mergeIcons = true settings.selectedMenuProvider = .claude settings.costUsageEnabled = true + settings.costSummaryDisplayStyle = .both settings.claudeWebExtrasEnabled = true let registry = ProviderRegistry.shared @@ -1443,6 +1488,7 @@ extension StatusMenuTests { settings.mergeIcons = true settings.selectedMenuProvider = .vertexai settings.costUsageEnabled = true + settings.costSummaryDisplayStyle = .both let registry = ProviderRegistry.shared if let vertexMeta = registry.metadata[.vertexai] { @@ -1493,7 +1539,7 @@ extension StatusMenuTests { extension StatusMenuTests { @Test - func `overview tab renders overview rows for all active providers when three or fewer`() { + func `overview tab renders overview rows for six active providers`() { self.disableMenuCardsForTesting() let settings = self.makeSettings() settings.statusChecksEnabled = false @@ -1502,11 +1548,14 @@ extension StatusMenuTests { settings.selectedMenuProvider = .claude settings.mergedMenuLastSelectedWasOverview = true + let enabledProviders: Set = [.codex, .claude, .cursor, .opencode, .warp, .gemini] let registry = ProviderRegistry.shared for provider in UsageProvider.allCases { guard let metadata = registry.metadata[provider] else { continue } - let shouldEnable = provider == .codex || provider == .claude || provider == .cursor - settings.setProviderEnabled(provider: provider, metadata: metadata, enabled: shouldEnable) + settings.setProviderEnabled( + provider: provider, + metadata: metadata, + enabled: enabledProviders.contains(provider)) } let fetcher = UsageFetcher() @@ -1521,18 +1570,14 @@ extension StatusMenuTests { let menu = controller.makeMenu() controller.menuWillOpen(menu) - let ids = self.representedIDs(in: menu) let overviewRows = ids.filter { $0.hasPrefix("overviewRow-") } - #expect(overviewRows.count == 3) - #expect(overviewRows.contains("overviewRow-codex")) - #expect(overviewRows.contains("overviewRow-claude")) - #expect(overviewRows.contains("overviewRow-cursor")) - #expect(ids.contains("menuCard") == false) + #expect(Set(overviewRows) == Set(enabledProviders.map { "overviewRow-\($0.rawValue)" })) + #expect(menu.items.count(where: \.isSeparatorItem) == overviewRows.count + 1) } @Test - func `overview tab honors stored subset when three or fewer`() { + func `overview tab honors stored subset when within the provider limit`() { self.disableMenuCardsForTesting() let settings = self.makeSettings() settings.statusChecksEnabled = false @@ -1584,15 +1629,14 @@ extension StatusMenuTests { settings.selectedMenuProvider = .codex settings.mergedMenuLastSelectedWasOverview = true settings.mergedOverviewSelectedProviders = [] - + let enabledProviders: Set = [.codex, .claude, .cursor, .opencode, .warp, .gemini, .grok] let registry = ProviderRegistry.shared for provider in UsageProvider.allCases { guard let metadata = registry.metadata[provider] else { continue } - let shouldEnable = provider == .codex || - provider == .claude || - provider == .cursor || - provider == .opencode - settings.setProviderEnabled(provider: provider, metadata: metadata, enabled: shouldEnable) + settings.setProviderEnabled( + provider: provider, + metadata: metadata, + enabled: enabledProviders.contains(provider)) } let fetcher = UsageFetcher() @@ -1658,50 +1702,4 @@ extension StatusMenuTests { #expect(claudeRow.action != nil) #expect(claudeRow.target is StatusItemController) } - - @Test - func `selecting overview row switches to provider detail`() throws { - self.disableMenuCardsForTesting() - let settings = self.makeSettings() - settings.statusChecksEnabled = false - settings.refreshFrequency = .manual - settings.mergeIcons = true - settings.selectedMenuProvider = .codex - settings.mergedMenuLastSelectedWasOverview = true - - let registry = ProviderRegistry.shared - for provider in UsageProvider.allCases { - guard let metadata = registry.metadata[provider] else { continue } - let shouldEnable = provider == .codex || provider == .claude - settings.setProviderEnabled(provider: provider, metadata: metadata, enabled: shouldEnable) - } - - 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 menu = controller.makeMenu() - controller.menuWillOpen(menu) - - let claudeRow = try #require(menu.items.first { - ($0.representedObject as? String) == "overviewRow-claude" - }) - let action = try #require(claudeRow.action) - let target = try #require(claudeRow.target as? StatusItemController) - _ = target.perform(action, with: claudeRow) - - #expect(settings.mergedMenuLastSelectedWasOverview == false) - #expect(settings.selectedMenuProvider == .claude) - - let ids = self.representedIDs(in: menu) - #expect(ids.contains("menuCard")) - #expect(ids.contains(where: { $0.hasPrefix("overviewRow-") }) == false) - #expect(self.switcherButtons(in: menu).first(where: { $0.state == .on })?.tag == 2) - } } diff --git a/Tests/CodexBarTests/StatusMenuTokenAccountSwitcherTests.swift b/Tests/CodexBarTests/StatusMenuTokenAccountSwitcherTests.swift index 49053cc937..8d88ac1a38 100644 --- a/Tests/CodexBarTests/StatusMenuTokenAccountSwitcherTests.swift +++ b/Tests/CodexBarTests/StatusMenuTokenAccountSwitcherTests.swift @@ -11,24 +11,9 @@ final class StatusMenuTokenAccountSwitcherTests: XCTestCase { StatusItemController.setMenuRefreshEnabledForTesting(false) } - private func makeStatusBarForTesting() -> NSStatusBar { - let env = ProcessInfo.processInfo.environment - if env["GITHUB_ACTIONS"] == "true" || env["CI"] == "true" { - return .system - } - return NSStatusBar() - } - private func makeSettings() -> SettingsStore { - let suite = "StatusMenuTokenAccountSwitcherTests-\(UUID().uuidString)" - let defaults = UserDefaults(suiteName: suite)! - defaults.removePersistentDomain(forName: suite) - let configStore = testConfigStore(suiteName: suite) - let settings = SettingsStore( - userDefaults: defaults, - configStore: configStore, - zaiTokenStore: NoopZaiTokenStore(), - syntheticTokenStore: NoopSyntheticTokenStore(), + let settings = testSettingsStore( + suiteName: "StatusMenuTokenAccountSwitcherTests", tokenAccountStore: InMemoryTokenAccountStore()) settings.providerDetectionCompleted = true return settings @@ -57,6 +42,35 @@ final class StatusMenuTokenAccountSwitcherTests: XCTestCase { } } + private func installRotatingProvider( + on store: UsageStore, + provider: UsageProvider, + rotatedToken: String) + { + let baseSpec = store.providerSpecs[provider]! + let baseDescriptor = baseSpec.descriptor + let snapshot = self.snapshot(percent: 37) + let descriptor = ProviderDescriptor( + id: provider, + metadata: baseDescriptor.metadata, + branding: baseDescriptor.branding, + tokenCost: baseDescriptor.tokenCost, + fetchPlan: ProviderFetchPlan( + sourceModes: baseDescriptor.fetchPlan.sourceModes, + pipeline: ProviderFetchPipeline { _ in [ + RotatingTokenAccountFetchStrategy( + provider: provider, + rotatedToken: rotatedToken, + snapshot: snapshot), + ] }), + cli: baseDescriptor.cli) + store.providerSpecs[provider] = ProviderSpec( + style: baseSpec.style, + isEnabled: baseSpec.isEnabled, + descriptor: descriptor, + makeFetchContext: baseSpec.makeFetchContext) + } + private static func makeClaudeProviderSpec( baseSpec: ProviderSpec, loader: @escaping @Sendable () async throws -> UsageSnapshot) -> ProviderSpec @@ -116,7 +130,7 @@ final class StatusMenuTokenAccountSwitcherTests: XCTestCase { account: fetcher.loadAccountInfo(), updater: DisabledUpdaterController(), preferencesSelection: PreferencesSelection(), - statusBar: self.makeStatusBarForTesting()) + statusBar: testStatusBar()) defer { controller.releaseStatusItemsForTesting() } let refreshTask = Task { @MainActor in @@ -131,10 +145,15 @@ final class StatusMenuTokenAccountSwitcherTests: XCTestCase { let switcher = try XCTUnwrap(menu.items.compactMap { $0.view as? TokenAccountSwitcherView }.first) let selectionTask = try XCTUnwrap(switcher._test_select(index: 1)) - await blocker.waitUntilStarted(count: 2) XCTAssertEqual(settings.tokenAccountsData(for: .claude)?.clampedActiveIndex(), 1) + for _ in 0..<40 { + await Task.yield() + } + let startedBeforeDrain = await blocker.startedCallCount() + XCTAssertEqual(startedBeforeDrain, 1) await blocker.resumeAll(with: .success(self.snapshot(percent: 17))) + await blocker.waitUntilStarted(count: 2) await selectionTask.value await refreshTask.value let startedCallCount = await blocker.startedCallCount() @@ -160,7 +179,7 @@ final class StatusMenuTokenAccountSwitcherTests: XCTestCase { account: fetcher.loadAccountInfo(), updater: DisabledUpdaterController(), preferencesSelection: PreferencesSelection(), - statusBar: self.makeStatusBarForTesting()) + statusBar: testStatusBar()) defer { controller.releaseStatusItemsForTesting() } let menu = controller.makeMenu(for: .copilot) @@ -189,7 +208,8 @@ final class StatusMenuTokenAccountSwitcherTests: XCTestCase { account: account, snapshot: self.snapshot(percent: Double(10 + index)), error: nil, - sourceLabel: "test") + sourceLabel: "test", + cacheKey: store.tokenAccountSnapshotCacheKey(provider: .copilot, account: account)) } let controller = StatusItemController( store: store, @@ -197,7 +217,7 @@ final class StatusMenuTokenAccountSwitcherTests: XCTestCase { account: fetcher.loadAccountInfo(), updater: DisabledUpdaterController(), preferencesSelection: PreferencesSelection(), - statusBar: self.makeStatusBarForTesting()) + statusBar: testStatusBar()) defer { controller.releaseStatusItemsForTesting() } let menu = controller.makeMenu(for: .copilot) @@ -265,14 +285,16 @@ final class StatusMenuTokenAccountSwitcherTests: XCTestCase { account: account, snapshot: self.snapshot(percent: Double(70 + index)), error: nil, - sourceLabel: "stale") + sourceLabel: "stale", + cacheKey: store.tokenAccountSnapshotCacheKey(provider: .copilot, account: account)) } let currentSnapshots = accounts.enumerated().map { index, account in TokenAccountUsageSnapshot( account: account, snapshot: self.snapshot(percent: Double(10 + index)), error: nil, - sourceLabel: "current") + sourceLabel: "current", + cacheKey: store.tokenAccountSnapshotCacheKey(provider: .copilot, account: account)) } store.accountSnapshots[.copilot] = staleSnapshots + currentSnapshots let controller = StatusItemController( @@ -281,7 +303,7 @@ final class StatusMenuTokenAccountSwitcherTests: XCTestCase { account: fetcher.loadAccountInfo(), updater: DisabledUpdaterController(), preferencesSelection: PreferencesSelection(), - statusBar: self.makeStatusBarForTesting()) + statusBar: testStatusBar()) defer { controller.releaseStatusItemsForTesting() } let menu = controller.makeMenu(for: .copilot) @@ -293,6 +315,205 @@ final class StatusMenuTokenAccountSwitcherTests: XCTestCase { ["menuCard-0", "menuCard-1", "menuCard-2", "menuCard-3", "menuCard-4", "menuCard-5"]) } + func test_multiAccountStackedLayoutRejectsSnapshotsAfterCredentialOrBaseURLChanges() throws { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.multiAccountMenuLayout = .stacked + self.enableOnly(.sub2api, settings) + settings.updateProviderConfig(provider: .sub2api) { config in + config.enterpriseHost = "https://first.example.test" + } + settings.addTokenAccount(provider: .sub2api, label: "Primary", token: "p1") + settings.addTokenAccount(provider: .sub2api, label: "Secondary", token: "p2") + let originalAccounts = settings.tokenAccounts(for: .sub2api) + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + store.accountSnapshots[.sub2api] = originalAccounts.map { account in + TokenAccountUsageSnapshot( + account: account, + snapshot: self.snapshot(), + error: nil, + sourceLabel: "fixture", + cacheKey: store.tokenAccountSnapshotCacheKey(provider: .sub2api, account: account)) + } + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: testStatusBar()) + defer { controller.releaseStatusItemsForTesting() } + + XCTAssertEqual(try XCTUnwrap(controller.tokenAccountMenuDisplay(for: .sub2api)).snapshots.count, 2) + + settings.updateTokenAccount( + provider: .sub2api, + accountID: originalAccounts[0].id, + token: "rotated-p1") + XCTAssertEqual( + try XCTUnwrap(controller.tokenAccountMenuDisplay(for: .sub2api)).snapshots.map(\.account.id), + [originalAccounts[1].id]) + + settings.updateProviderConfig(provider: .sub2api) { config in + config.enterpriseHost = "https://second.example.test" + } + XCTAssertTrue(try XCTUnwrap(controller.tokenAccountMenuDisplay(for: .sub2api)).snapshots.isEmpty) + } + + func test_multiAccountStackedCancellationCannotRestoreCredentialStaleSnapshots() async { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.multiAccountMenuLayout = .stacked + self.enableOnlyClaude(settings) + settings.addTokenAccount(provider: .claude, label: "Primary", token: "p1") + settings.addTokenAccount(provider: .claude, label: "Secondary", token: "p2") + let originalAccounts = settings.tokenAccounts(for: .claude) + + let store = UsageStore( + fetcher: UsageFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings) + store.accountSnapshots[.claude] = originalAccounts.map { account in + TokenAccountUsageSnapshot( + account: account, + snapshot: self.snapshot(), + error: nil, + sourceLabel: "fixture", + cacheKey: store.tokenAccountSnapshotCacheKey(provider: .claude, account: account)) + } + settings.updateTokenAccount( + provider: .claude, + accountID: originalAccounts[0].id, + token: "rotated-p1") + let blocker = BlockingTokenAccountFetchStrategy() + self.installBlockingClaudeProvider(on: store, blocker: blocker) + + let refreshTask = Task { @MainActor in + await store.refreshProvider(.claude) + } + await blocker.waitUntilStarted(count: 2) + await blocker.resumeAll(with: .failure(CancellationError())) + await refreshTask.value + + XCTAssertEqual(store.accountSnapshots[.claude]?.map(\.account.id), [originalAccounts[1].id]) + } + + func test_validTokenAccountSnapshotsHandlesDuplicateAccountIDsWithoutTrapping() { + let settings = self.makeSettings() + self.enableOnlyClaude(settings) + let id = UUID() + let first = ProviderTokenAccount( + id: id, + label: "First", + token: "f1", + addedAt: 1, + lastUsed: nil) + let duplicate = ProviderTokenAccount( + id: id, + label: "Duplicate", + token: "d1", + addedAt: 2, + lastUsed: nil) + let store = UsageStore( + fetcher: UsageFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings) + store.accountSnapshots[.claude] = [ + TokenAccountUsageSnapshot( + account: first, + snapshot: self.snapshot(), + error: nil, + sourceLabel: "fixture", + cacheKey: store.tokenAccountSnapshotCacheKey(provider: .claude, account: first)), + ] + + XCTAssertTrue(store.validTokenAccountSnapshots(provider: .claude, accounts: [first, duplicate]).isEmpty) + } + + func test_duplicateAccountIDsRejectCrossCredentialPublication() async { + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + self.enableOnlyClaude(settings) + settings.addTokenAccount(provider: .claude, label: "First", token: "f1") + settings.addTokenAccount(provider: .claude, label: "Second", token: "s1") + let accounts = settings.tokenAccounts(for: .claude) + let duplicate = ProviderTokenAccount( + id: accounts[0].id, + label: accounts[1].label, + token: accounts[1].token, + addedAt: accounts[1].addedAt, + lastUsed: accounts[1].lastUsed) + settings.updateProviderConfig(provider: .claude) { config in + config.tokenAccounts = ProviderTokenAccountData( + version: 1, + accounts: [accounts[0], duplicate], + activeIndex: 1) + } + let store = UsageStore( + fetcher: UsageFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings) + store._test_providerFetchOutcomeOverride = { _ in + ProviderFetchOutcome( + result: .success(ProviderFetchResult( + usage: self.snapshot(percent: 66), + credits: nil, + dashboard: nil, + sourceLabel: "fixture", + strategyID: "fixture", + strategyKind: .apiToken)), + attempts: []) + } + + await store.refreshProvider(.claude) + + XCTAssertNil(store.snapshot(for: .claude)) + XCTAssertNil(store.accountSnapshots[.claude]) + } + + func test_authorizedTokenRotationPublishesAndCachesUnderTheRotatedCredential() async { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.multiAccountMenuLayout = .segmented + self.enableOnly(.antigravity, settings) + settings.addTokenAccount(provider: .antigravity, label: "Primary", token: "p1") + settings.addTokenAccount(provider: .antigravity, label: "Secondary", token: "p2") + settings.setActiveTokenAccountIndex(0, for: .antigravity) + + let store = UsageStore( + fetcher: UsageFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings) + self.installRotatingProvider(on: store, provider: .antigravity, rotatedToken: "n1") + + await store.refreshProvider(.antigravity) + let accountsAfterPrimaryRefresh = settings.tokenAccounts(for: .antigravity) + XCTAssertEqual(accountsAfterPrimaryRefresh[0].token, "n1") + XCTAssertEqual(store.snapshot(for: .antigravity)?.primary?.usedPercent, 37) + XCTAssertEqual( + store.accountSnapshots[.antigravity]?.first?.cacheKey, + store.tokenAccountSnapshotCacheKey(provider: .antigravity, account: accountsAfterPrimaryRefresh[0])) + + settings.setActiveTokenAccountIndex(1, for: .antigravity) + await store.refreshProvider(.antigravity) + settings.setActiveTokenAccountIndex(0, for: .antigravity) + store.activateCachedTokenAccountSnapshot( + provider: .antigravity, + accountID: accountsAfterPrimaryRefresh[0].id) + + XCTAssertEqual(store.snapshot(for: .antigravity)?.primary?.usedPercent, 37) + XCTAssertEqual(store.accountSnapshots[.antigravity]?.count, 2) + } + func test_tokenAccountSwitchDefersOpenMenuRebuildUntilAfterSwitcherAction() async throws { self.disableMenuCardsForTesting() StatusItemController.setMenuRefreshEnabledForTesting(true) @@ -326,7 +547,7 @@ final class StatusMenuTokenAccountSwitcherTests: XCTestCase { account: fetcher.loadAccountInfo(), updater: DisabledUpdaterController(), preferencesSelection: PreferencesSelection(), - statusBar: self.makeStatusBarForTesting()) + statusBar: testStatusBar()) defer { controller.releaseStatusItemsForTesting() } let menu = controller.makeMenu() @@ -350,9 +571,391 @@ final class StatusMenuTokenAccountSwitcherTests: XCTestCase { await blocker.waitUntilStarted(count: 1) await blocker.resumeAll(with: .success(self.snapshot(percent: 17))) await selectionTask.value + for _ in 0..<20 where rebuildCount < 2 { + await Task.yield() + } + XCTAssertEqual(rebuildCount, 2) + } + + func test_tokenAccountSwitchUsesSelectedAccountCacheWhileRefreshIsInFlight() async throws { + self.disableMenuCardsForTesting() + StatusItemController.setMenuRefreshEnabledForTesting(true) + defer { StatusItemController.setMenuRefreshEnabledForTesting(false) } + + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + settings.multiAccountMenuLayout = .segmented + self.enableOnlyClaude(settings) + settings.addTokenAccount(provider: .claude, label: "Primary", token: "Bearer sk-ant-oat-primary") + settings.addTokenAccount(provider: .claude, label: "Secondary", token: "Bearer sk-ant-oat-secondary") + settings.setActiveTokenAccountIndex(0, for: .claude) + let accounts = settings.tokenAccounts(for: .claude) + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + store.snapshots[.claude] = self.snapshot(percent: 11) + store.lastKnownResetSnapshots[.claude] = self.snapshot(percent: 11) + store.errors[.claude] = "primary-error" + store.lastSourceLabels[.claude] = "primary-cache" + store.accountSnapshots[.claude] = [ + TokenAccountUsageSnapshot( + account: accounts[0], + snapshot: self.snapshot(percent: 11), + error: nil, + sourceLabel: "primary-cache", + cacheKey: store.tokenAccountSnapshotCacheKey(provider: .claude, account: accounts[0])), + TokenAccountUsageSnapshot( + account: accounts[1], + snapshot: self.snapshot(percent: 72), + error: nil, + sourceLabel: "secondary-cache", + cacheKey: store.tokenAccountSnapshotCacheKey(provider: .claude, account: accounts[1])), + ] + let blocker = BlockingTokenAccountFetchStrategy() + self.installBlockingClaudeProvider(on: store, blocker: blocker) + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: testStatusBar()) + defer { controller.releaseStatusItemsForTesting() } + + let menu = controller.makeMenu(for: .claude) + controller.menuWillOpen(menu) + let switcher = try XCTUnwrap(menu.items.compactMap { $0.view as? TokenAccountSwitcherView }.first) + + let selectionTask = try XCTUnwrap(switcher._test_select(index: 1)) + + XCTAssertEqual(store.snapshot(for: .claude)?.primary?.usedPercent, 72) + XCTAssertEqual(store.lastKnownResetSnapshots[.claude]?.primary?.usedPercent, 72) + XCTAssertNil(store.errors[.claude]) + XCTAssertEqual(store.sourceLabel(for: .claude), "secondary-cache") + + await blocker.waitUntilStarted(count: 1) + await blocker.resumeAll(with: .success(self.snapshot(percent: 45))) + await selectionTask.value + } + + func test_tokenAccountSwitchClearsPreviousAccountIdentityUntilSelectedRefreshCompletes() async throws { + self.disableMenuCardsForTesting() + + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + settings.multiAccountMenuLayout = .segmented + self.enableOnlyClaude(settings) + settings.addTokenAccount(provider: .claude, label: "Primary", token: "Bearer sk-ant-oat-primary") + settings.addTokenAccount(provider: .claude, label: "Secondary", token: "Bearer sk-ant-oat-secondary") + settings.setActiveTokenAccountIndex(0, for: .claude) + let accounts = settings.tokenAccounts(for: .claude) + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + store.snapshots[.claude] = self.snapshot(percent: 11) + store.lastKnownResetSnapshots[.claude] = self.snapshot(percent: 11) + store.errors[.claude] = "primary-error" + store.lastSourceLabels[.claude] = "primary-cache" + store.accountSnapshots[.claude] = [ + TokenAccountUsageSnapshot( + account: accounts[0], + snapshot: self.snapshot(percent: 11), + error: nil, + sourceLabel: "primary-cache", + cacheKey: store.tokenAccountSnapshotCacheKey(provider: .claude, account: accounts[0])), + ] + let blocker = BlockingTokenAccountFetchStrategy() + self.installBlockingClaudeProvider(on: store, blocker: blocker) + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: testStatusBar()) + defer { controller.releaseStatusItemsForTesting() } + + let menu = controller.makeMenu(for: .claude) + controller.menuWillOpen(menu) + let switcher = try XCTUnwrap(menu.items.compactMap { $0.view as? TokenAccountSwitcherView }.first) + + let selectionTask = try XCTUnwrap(switcher._test_select(index: 1)) + + XCTAssertNil(store.snapshot(for: .claude)) + XCTAssertNil(store.snapshot(for: .claude)?.identity(for: .claude)) + let pausedModel = try XCTUnwrap(controller.menuCardModel(for: .claude)) + XCTAssertTrue(pausedModel.email.isEmpty) + XCTAssertTrue(pausedModel.metrics.isEmpty) + XCTAssertNil(store.lastKnownResetSnapshots[.claude]) + XCTAssertNil(store.errors[.claude]) + XCTAssertNil(store.lastSourceLabels[.claude]) + + await blocker.waitUntilStarted(count: 1) + await blocker.resumeAll(with: .success(self.snapshot(percent: 45))) + await selectionTask.value + + XCTAssertEqual(store.snapshot(for: .claude)?.primary?.usedPercent, 45) + XCTAssertEqual( + store.accountSnapshots[.claude]?.first(where: { $0.account.id == accounts[1].id })? + .snapshot?.primary?.usedPercent, + 45) + } + + func test_segmentedRefreshPreservesValidAccountCacheAndInvalidatesCredentialChanges() async { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.multiAccountMenuLayout = .segmented + self.enableOnlyClaude(settings) + settings.addTokenAccount(provider: .claude, label: "Primary", token: "p1") + settings.addTokenAccount(provider: .claude, label: "Secondary", token: "p2") + settings.setActiveTokenAccountIndex(0, for: .claude) + + let store = UsageStore( + fetcher: UsageFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings) + store._test_providerFetchOutcomeOverride = { _ in + let percent = settings.selectedTokenAccount(for: .claude)?.label == "Primary" ? 11.0 : 72.0 + return ProviderFetchOutcome( + result: .success(ProviderFetchResult( + usage: self.snapshot(percent: percent), + credits: nil, + dashboard: nil, + sourceLabel: "fixture", + strategyID: "fixture", + strategyKind: .apiToken)), + attempts: []) + } + + await store.refreshProvider(.claude) + let originalAccounts = settings.tokenAccounts(for: .claude) + XCTAssertEqual(store.snapshot(for: .claude)?.primary?.usedPercent, 11) + XCTAssertEqual(store.accountSnapshots[.claude]?.count, 1) + + settings.setActiveTokenAccountIndex(1, for: .claude) + store.activateCachedTokenAccountSnapshot(provider: .claude, accountID: originalAccounts[1].id) + XCTAssertNil(store.snapshot(for: .claude)) + await store.refreshProvider(.claude) + XCTAssertEqual(store.snapshot(for: .claude)?.primary?.usedPercent, 72) + XCTAssertEqual(store.accountSnapshots[.claude]?.count, 2) + + settings.setActiveTokenAccountIndex(0, for: .claude) + store.activateCachedTokenAccountSnapshot(provider: .claude, accountID: originalAccounts[0].id) + XCTAssertEqual(store.snapshot(for: .claude)?.primary?.usedPercent, 11) + + settings.updateTokenAccount( + provider: .claude, + accountID: originalAccounts[0].id, + token: "rotated-p1") + store.activateCachedTokenAccountSnapshot(provider: .claude, accountID: originalAccounts[0].id) + XCTAssertNil(store.snapshot(for: .claude)) + XCTAssertEqual(store.accountSnapshots[.claude]?.map(\.account.id), [originalAccounts[1].id]) + + settings.removeTokenAccount(provider: .claude, accountID: originalAccounts[1].id) + store.pruneTokenAccountSnapshots(provider: .claude, accounts: settings.tokenAccounts(for: .claude)) + XCTAssertNil(store.accountSnapshots[.claude]) } } +extension StatusMenuTokenAccountSwitcherTests { + func test_segmentedRefreshClearsLiveSnapshotWhenCredentialChangesAndReplacementFails() async { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.multiAccountMenuLayout = .segmented + self.enableOnlyClaude(settings) + settings.addTokenAccount(provider: .claude, label: "Primary", token: "p1") + let account = try? XCTUnwrap(settings.selectedTokenAccount(for: .claude)) + + let store = UsageStore( + fetcher: UsageFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings) + store._test_providerFetchOutcomeOverride = { _ in + ProviderFetchOutcome( + result: .success(ProviderFetchResult( + usage: self.snapshot(percent: 45), + credits: nil, + dashboard: nil, + sourceLabel: "fixture", + strategyID: "fixture", + strategyKind: .apiToken)), + attempts: []) + } + await store.refreshProvider(.claude) + XCTAssertEqual(store.snapshot(for: .claude)?.primary?.usedPercent, 45) + XCTAssertEqual(store.sourceLabel(for: .claude), "fixture") + + if let account { + settings.updateTokenAccount(provider: .claude, accountID: account.id, token: "rotated-p1") + } + store._test_providerFetchOutcomeOverride = { _ in + ProviderFetchOutcome(result: .failure(StatusMenuTokenAccountTestError.rejected), attempts: []) + } + await store.refreshProvider(.claude) + + XCTAssertNil(store.snapshot(for: .claude)) + XCTAssertNil(store.lastSourceLabels[.claude]) + XCTAssertNil(store.lastKnownResetSnapshots[.claude]) + XCTAssertNil(store.accountSnapshots[.claude]) + } + + func test_segmentedRefreshClearsLiveSnapshotWhenBaseURLChangesAndReplacementFails() async { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.multiAccountMenuLayout = .segmented + self.enableOnly(.sub2api, settings) + settings.updateProviderConfig(provider: .sub2api) { config in + config.enterpriseHost = "https://first.example.test" + } + settings.addTokenAccount(provider: .sub2api, label: "Primary", token: "p1") + + let store = UsageStore( + fetcher: UsageFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings) + store._test_providerFetchOutcomeOverride = { _ in + ProviderFetchOutcome( + result: .success(ProviderFetchResult( + usage: self.snapshot(percent: 45), + credits: nil, + dashboard: nil, + sourceLabel: "fixture", + strategyID: "fixture", + strategyKind: .apiToken)), + attempts: []) + } + await store.refreshProvider(.sub2api) + XCTAssertEqual(store.snapshot(for: .sub2api)?.primary?.usedPercent, 45) + + settings.updateProviderConfig(provider: .sub2api) { config in + config.enterpriseHost = "https://second.example.test" + } + store._test_providerFetchOutcomeOverride = { _ in + ProviderFetchOutcome(result: .failure(StatusMenuTokenAccountTestError.rejected), attempts: []) + } + await store.refreshProvider(.sub2api) + + XCTAssertNil(store.snapshot(for: .sub2api)) + XCTAssertNil(store.lastSourceLabels[.sub2api]) + XCTAssertNil(store.lastKnownResetSnapshots[.sub2api]) + XCTAssertNil(store.accountSnapshots[.sub2api]) + } + + func test_segmentedRefreshClearsLiveSnapshotWhenLastAccountIsRemovedAndFallbackFails() async { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.multiAccountMenuLayout = .segmented + self.enableOnlyClaude(settings) + settings.addTokenAccount(provider: .claude, label: "Primary", token: "p1") + let accountID = settings.selectedTokenAccount(for: .claude)?.id + + let store = UsageStore( + fetcher: UsageFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings) + store._test_providerFetchOutcomeOverride = { _ in + ProviderFetchOutcome( + result: .success(ProviderFetchResult( + usage: self.snapshot(percent: 45), + credits: nil, + dashboard: nil, + sourceLabel: "fixture", + strategyID: "fixture", + strategyKind: .apiToken)), + attempts: []) + } + await store.refreshProvider(.claude) + XCTAssertEqual(store.snapshot(for: .claude)?.primary?.usedPercent, 45) + + if let accountID { + settings.removeTokenAccount(provider: .claude, accountID: accountID) + } + store._test_providerFetchOutcomeOverride = { _ in + ProviderFetchOutcome(result: .failure(StatusMenuTokenAccountTestError.rejected), attempts: []) + } + await store.refreshProvider(.claude) + + XCTAssertNil(store.snapshot(for: .claude)) + XCTAssertNil(store.lastSourceLabels[.claude]) + XCTAssertNil(store.lastKnownResetSnapshots[.claude]) + XCTAssertNil(store.accountSnapshots[.claude]) + } + + func test_segmentedRefreshClearsTokenAccountErrorWhenFailedAccountIsRemovedAndFallbackCancels() async { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.multiAccountMenuLayout = .segmented + self.enableOnlyClaude(settings) + settings.addTokenAccount(provider: .claude, label: "Primary", token: "p1") + let accountID = settings.selectedTokenAccount(for: .claude)?.id + + let store = UsageStore( + fetcher: UsageFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings) + store._test_providerFetchOutcomeOverride = { _ in + ProviderFetchOutcome(result: .failure(StatusMenuTokenAccountTestError.rejected), attempts: []) + } + await store.refreshProvider(.claude) + XCTAssertNotNil(store.userFacingError(for: .claude)) + XCTAssertTrue(store.tokenAccountLiveStateProviders.contains(.claude)) + + if let accountID { + settings.removeTokenAccount(provider: .claude, accountID: accountID) + } + store._test_providerFetchOutcomeOverride = { _ in + ProviderFetchOutcome(result: .failure(CancellationError()), attempts: []) + } + await store.refreshProvider(.claude) + + XCTAssertNil(store.snapshot(for: .claude)) + XCTAssertNil(store.userFacingError(for: .claude)) + XCTAssertNil(store.knownLimitsAvailabilityByProvider[.claude]) + XCTAssertFalse(store.tokenAccountLiveStateProviders.contains(.claude)) + } + + func test_segmentedRefreshPreservesAmbientSnapshotWithoutTokenAccountOwnership() async { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.multiAccountMenuLayout = .segmented + self.enableOnlyClaude(settings) + + let store = UsageStore( + fetcher: UsageFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings) + store._setSnapshotForTesting(self.snapshot(percent: 45), provider: .claude) + store._test_providerFetchOutcomeOverride = { _ in + ProviderFetchOutcome(result: .failure(CancellationError()), attempts: []) + } + + await store.refreshProvider(.claude) + + XCTAssertEqual(store.snapshot(for: .claude)?.primary?.usedPercent, 45) + XCTAssertFalse(store.tokenAccountLiveStateProviders.contains(.claude)) + } +} + +private enum StatusMenuTokenAccountTestError: Error { + case rejected +} + private struct StatusMenuTokenAccountFetchStrategy: ProviderFetchStrategy { let loader: @Sendable () async throws -> UsageSnapshot @@ -378,6 +981,42 @@ private struct StatusMenuTokenAccountFetchStrategy: ProviderFetchStrategy { } } +private struct RotatingTokenAccountFetchStrategy: ProviderFetchStrategy { + let provider: UsageProvider + let rotatedToken: String + let snapshot: UsageSnapshot + + var id: String { + "rotating-token-account-test" + } + + var kind: ProviderFetchKind { + .apiToken + } + + func isAvailable(_: ProviderFetchContext) async -> Bool { + true + } + + func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult { + guard let accountID = context.selectedTokenAccountID, + let updater = context.tokenAccountTokenUpdater + else { + throw RotatingTokenAccountTestError.missingUpdater + } + await updater(self.provider, accountID, self.rotatedToken) + return self.makeResult(usage: self.snapshot, sourceLabel: "rotating-token-account-test") + } + + func shouldFallback(on _: Error, context _: ProviderFetchContext) -> Bool { + false + } +} + +private enum RotatingTokenAccountTestError: Error { + case missingUpdater +} + private actor BlockingTokenAccountFetchStrategy { private var waiters: [CheckedContinuation, Never>] = [] private var startedWaiters: [(count: Int, continuation: CheckedContinuation)] = [] @@ -399,7 +1038,9 @@ private actor BlockingTokenAccountFetchStrategy { } func waitUntilStarted(count: Int) async { - if self.startedCount >= count { return } + if self.startedCount >= count { + return + } await withCheckedContinuation { continuation in self.startedWaiters.append((count: count, continuation: continuation)) } diff --git a/Tests/CodexBarTests/StatusMenuUsageDisplayTests.swift b/Tests/CodexBarTests/StatusMenuUsageDisplayTests.swift new file mode 100644 index 0000000000..6cac6a03d9 --- /dev/null +++ b/Tests/CodexBarTests/StatusMenuUsageDisplayTests.swift @@ -0,0 +1,140 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +extension StatusMenuTests { + @Test + func `overview card model follows usage display preference`() throws { + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + 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() } + + settings.usageBarsShowUsed = false + let remainingMetric = try #require(controller.menuCardModel(for: .codex)?.metrics.first { $0.id == "primary" }) + #expect(remainingMetric.percent == 78) + #expect(remainingMetric.percentStyle.rawValue == "left") + + settings.usageBarsShowUsed = true + let usedMetric = try #require(controller.menuCardModel(for: .codex)?.metrics.first { $0.id == "primary" }) + #expect(usedMetric.percent == 22) + #expect(usedMetric.percentStyle.rawValue == "used") + } + + @Test + func `status menu card follows claude daily routines visibility`() throws { + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + let now = Date() + store._setSnapshotForTesting( + UsageSnapshot( + primary: RateWindow( + usedPercent: 22, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(1800), + resetDescription: nil), + secondary: nil, + tertiary: nil, + extraRateWindows: [ + NamedRateWindow( + id: "claude-weekly-scoped-fable", + title: "Fable only", + window: RateWindow( + usedPercent: 30, + windowMinutes: 10080, + resetsAt: now.addingTimeInterval(3600), + resetDescription: nil)), + NamedRateWindow( + id: "claude-routines", + title: "Daily Routines", + window: RateWindow( + usedPercent: 40, + windowMinutes: 10080, + resetsAt: now.addingTimeInterval(7200), + resetDescription: nil)), + ], + updatedAt: now), + provider: .claude) + let controller = StatusItemController( + store: store, + settings: settings, + account: UsageFetcher().loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + + #expect(controller.menuCardModel(for: .claude)?.metrics.contains { $0.id == "claude-routines" } == true) + + settings.claudeDailyRoutinesUsageVisible = false + let hiddenModel = try #require(controller.menuCardModel(for: .claude)) + #expect(!hiddenModel.metrics.contains { $0.id == "claude-routines" }) + #expect(hiddenModel.metrics.contains { $0.id == "claude-weekly-scoped-fable" }) + } + + @Test + func `status menu card follows codex spark visibility`() throws { + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + let now = Date() + store._setSnapshotForTesting( + UsageSnapshot( + primary: RateWindow( + usedPercent: 22, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(1800), + resetDescription: nil), + secondary: nil, + tertiary: nil, + extraRateWindows: [ + NamedRateWindow( + id: CodexAdditionalRateLimitMapper.sparkWindowID, + title: "Codex Spark 5-hour", + window: RateWindow( + usedPercent: 40, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(1800), + resetDescription: nil)), + NamedRateWindow( + id: "codex-other-limit", + title: "Other Codex limit", + window: RateWindow( + usedPercent: 30, + windowMinutes: 1440, + resetsAt: now.addingTimeInterval(3600), + resetDescription: nil)), + ], + updatedAt: now), + provider: .codex) + let controller = StatusItemController( + store: store, + settings: settings, + account: UsageFetcher().loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + + #expect(controller.menuCardModel(for: .codex)?.metrics.contains { + $0.id == CodexAdditionalRateLimitMapper.sparkWindowID + } == true) + + settings.codexSparkUsageVisible = false + let hiddenModel = try #require(controller.menuCardModel(for: .codex)) + #expect(!hiddenModel.metrics.contains { $0.id == CodexAdditionalRateLimitMapper.sparkWindowID }) + #expect(hiddenModel.metrics.contains { $0.id == "codex-other-limit" }) + } +} diff --git a/Tests/CodexBarTests/StatusMenuViewportRestoreTests.swift b/Tests/CodexBarTests/StatusMenuViewportRestoreTests.swift new file mode 100644 index 0000000000..f746179b4e --- /dev/null +++ b/Tests/CodexBarTests/StatusMenuViewportRestoreTests.swift @@ -0,0 +1,1538 @@ +import AppKit +import CodexBarCore +import Testing +@testable import CodexBar + +@MainActor +private final class ViewportRefreshGate { + private var continuation: CheckedContinuation? + private var isOpen = false + + func wait() async { + if self.isOpen { + self.isOpen = false + return + } + await withCheckedContinuation { continuation in + self.continuation = continuation + } + } + + func resume() { + if let continuation = self.continuation { + continuation.resume() + self.continuation = nil + } else { + self.isOpen = true + } + } +} + +private final class FlippedViewportDocumentView: NSView { + override var isFlipped: Bool { + true + } +} + +@MainActor +@Suite(.serialized) +struct StatusMenuViewportRestoreTests { + private func makeSettings() -> SettingsStore { + testSettingsStore(suiteName: "StatusMenuViewportRestoreTests") + } + + private func makeController(settings: SettingsStore) -> StatusItemController { + let environment = Self.isolatedEnvironment() + let fetcher = UsageFetcher(environment: environment) + let store = UsageStore( + fetcher: fetcher, + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + startupBehavior: .testing, + environmentBase: environment) + return StatusItemController( + store: store, + settings: settings, + account: AccountInfo(email: nil, plan: nil), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: .system) + } + + private static func isolatedEnvironment() -> [String: String] { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("codexbar-tests", isDirectory: true) + .appendingPathComponent(UUID().uuidString, isDirectory: true) + return [ + "HOME": root.path, + "CODEX_HOME": root.appendingPathComponent(".codex", isDirectory: true).path, + "XDG_CONFIG_HOME": root.appendingPathComponent(".config", isDirectory: true).path, + ] + } + + @Test + func `viewport top offset is nil when the menu content fits the clip`() { + #expect(StatusItemController.menuViewportTopOffset( + documentIsFlipped: true, + documentHeight: 500, + clipHeight: 500, + currentOffset: 0) == nil) + #expect(StatusItemController.menuViewportTopOffset( + documentIsFlipped: true, + documentHeight: 400, + clipHeight: 500, + currentOffset: 0) == nil) + #expect(StatusItemController.menuViewportTopOffset( + documentIsFlipped: true, + documentHeight: 400, + clipHeight: 0, + currentOffset: 0) == nil) + } + + @Test + func `viewport top offset is nil when the viewport already shows the top`() { + #expect(StatusItemController.menuViewportTopOffset( + documentIsFlipped: true, + documentHeight: 1700, + clipHeight: 950, + currentOffset: 0) == nil) + #expect(StatusItemController.menuViewportTopOffset( + documentIsFlipped: false, + documentHeight: 1700, + clipHeight: 950, + currentOffset: 750) == nil) + } + + @Test + func `viewport top offset targets the content top for a scrolled menu`() { + #expect(StatusItemController.menuViewportTopOffset( + documentIsFlipped: true, + documentHeight: 1700, + clipHeight: 950, + currentOffset: 750) == 0) + #expect(StatusItemController.menuViewportTopOffset( + documentIsFlipped: false, + documentHeight: 1700, + clipHeight: 950, + currentOffset: 0) == 750) + } +} + +extension StatusMenuViewportRestoreTests { + @Test + func `settled viewport geometry distinguishes layout from movement`() { + let document = NSView() + let clipView = NSClipView() + let initial = MenuViewportGeometry( + documentID: ObjectIdentifier(document), + clipID: ObjectIdentifier(clipView), + documentSize: CGSize(width: 200, height: 600), + documentIsFlipped: false, + clipSize: CGSize(width: 200, height: 100), + clipOrigin: CGPoint(x: 0, y: 200)) + + #expect(StatusItemController.menuViewportGeometryTransition( + from: initial, + to: MenuViewportGeometry( + documentID: ObjectIdentifier(document), + clipID: initial.clipID, + documentSize: initial.documentSize, + documentIsFlipped: false, + clipSize: initial.clipSize, + clipOrigin: CGPoint(x: 0, y: 200.5))) == .unchanged) + #expect(StatusItemController.menuViewportGeometryTransition( + from: initial, + to: MenuViewportGeometry( + documentID: ObjectIdentifier(document), + clipID: initial.clipID, + documentSize: initial.documentSize, + documentIsFlipped: false, + clipSize: initial.clipSize, + clipOrigin: CGPoint(x: 0, y: 210))) == .movement) + #expect(StatusItemController.menuViewportGeometryTransition( + from: initial, + to: MenuViewportGeometry( + documentID: ObjectIdentifier(document), + clipID: initial.clipID, + documentSize: CGSize(width: 200, height: 500), + documentIsFlipped: false, + clipSize: initial.clipSize, + clipOrigin: .zero)) == .layout) + } + + @Test + func `viewport movement tracker settles layout then accumulates fractional scrolling`() { + let scrollView = NSScrollView(frame: NSRect(x: 0, y: 0, width: 200, height: 100)) + let documentView = NSView(frame: NSRect(x: 0, y: 0, width: 200, height: 500)) + scrollView.documentView = documentView + scrollView.contentView.scroll(to: NSPoint(x: 0, y: 100)) + let originalBoundsNotifications = scrollView.contentView.postsBoundsChangedNotifications + let originalClipFrameNotifications = scrollView.contentView.postsFrameChangedNotifications + let originalDocumentFrameNotifications = documentView.postsFrameChangedNotifications + + let tracker = ManualRefreshViewportMovementTracker(scrollView: scrollView) + #expect(scrollView.contentView.postsBoundsChangedNotifications) + #expect(scrollView.contentView.postsFrameChangedNotifications) + #expect(documentView.postsFrameChangedNotifications) + + // AppKit can publish the origin reset before exposing the new document height. The + // coalesced sample must see the settled geometry and classify the batch as layout. + scrollView.contentView.scroll(to: .zero) + NotificationCenter.default.post( + name: NSView.boundsDidChangeNotification, + object: scrollView.contentView) + documentView.frame.size.height = 600 + tracker.settlePendingGeometryChanges() + #expect(!tracker.observedMovement) + + for offset in [0.4, 0.8] { + scrollView.contentView.scroll(to: NSPoint(x: 0, y: offset)) + tracker.settlePendingGeometryChanges() + #expect(!tracker.observedMovement) + } + scrollView.contentView.scroll(to: NSPoint(x: 0, y: 1.2)) + tracker.settlePendingGeometryChanges() + #expect(tracker.observedMovement) + + tracker.stop() + #expect(scrollView.contentView.postsBoundsChangedNotifications == originalBoundsNotifications) + #expect(scrollView.contentView.postsFrameChangedNotifications == originalClipFrameNotifications) + #expect(documentView.postsFrameChangedNotifications == originalDocumentFrameNotifications) + } + + @Test + func `refresh completion waits for settled AppKit geometry before rebasing`() { + let scrollView = NSScrollView(frame: NSRect(x: 0, y: 0, width: 200, height: 100)) + let documentView = NSView(frame: NSRect(x: 0, y: 0, width: 200, height: 500)) + scrollView.documentView = documentView + scrollView.contentView.scroll(to: NSPoint(x: 0, y: 100)) + + let tracker = ManualRefreshViewportMovementTracker(scrollView: scrollView) + defer { tracker.stop() } + + // macOS 27 can publish this reset while the document still reports its old height. + scrollView.contentView.scroll(to: .zero) + NotificationCenter.default.post( + name: NSView.boundsDidChangeNotification, + object: scrollView.contentView) + var completionRan = false + tracker.afterPendingGeometrySettles { + tracker.rebaseAfterRefreshLayout() + completionRan = true + } + #expect(!completionRan) + + documentView.frame.size.height = 600 + self.runLoop(mode: CFRunLoopMode(RunLoop.Mode.eventTracking.rawValue as CFString)) + + #expect(completionRan) + #expect(!tracker.observedMovement) + } + + @Test + func `viewport tracker absorbs a delayed origin correction after layout settles`() { + let scrollView = NSScrollView(frame: NSRect(x: 0, y: 0, width: 200, height: 100)) + let documentView = FlippedViewportDocumentView(frame: NSRect(x: 0, y: 0, width: 200, height: 500)) + scrollView.documentView = documentView + scrollView.contentView.scroll(to: NSPoint(x: 0, y: 100)) + + let tracker = ManualRefreshViewportMovementTracker(scrollView: scrollView) + defer { tracker.stop() } + + documentView.frame.size.height = 600 + self.runLoop(mode: CFRunLoopMode(RunLoop.Mode.eventTracking.rawValue as CFString)) + #expect(!tracker.observedMovement) + + // AppKit may correct the origin on the following pass, after geometry already settled. + scrollView.contentView.scroll(to: .zero) + self.runLoop(mode: CFRunLoopMode(RunLoop.Mode.eventTracking.rawValue as CFString)) + #expect(!tracker.observedMovement) + + // A further stable-geometry edge tick is user movement and remains sticky. + scrollView.contentView.scroll(to: NSPoint(x: 0, y: 20)) + self.runLoop(mode: CFRunLoopMode(RunLoop.Mode.eventTracking.rawValue as CFString)) + #expect(tracker.observedMovement) + } + + @Test + func `viewport observer records move away and return within one settled batch`() { + let scrollView = NSScrollView(frame: NSRect(x: 0, y: 0, width: 200, height: 100)) + let documentView = NSView(frame: NSRect(x: 0, y: 0, width: 200, height: 500)) + scrollView.documentView = documentView + scrollView.contentView.scroll(to: NSPoint(x: 0, y: 100)) + + let tracker = ManualRefreshViewportMovementTracker(scrollView: scrollView) + defer { tracker.stop() } + + for offset in [120.0, 100.0] { + scrollView.contentView.scroll(to: NSPoint(x: 0, y: offset)) + NotificationCenter.default.post( + name: NSView.boundsDidChangeNotification, + object: scrollView.contentView) + } + self.runLoop(mode: CFRunLoopMode(RunLoop.Mode.eventTracking.rawValue as CFString)) + + #expect(tracker.observedMovement) + } + + @Test + func `stale completion preserves movement owned by a newer refresh`() { + let menu = NSMenu() + let key = ObjectIdentifier(menu) + let newerScrollView = NSScrollView(frame: NSRect(x: 0, y: 0, width: 200, height: 100)) + newerScrollView.documentView = NSView(frame: NSRect(x: 0, y: 0, width: 200, height: 500)) + newerScrollView.contentView.scroll(to: NSPoint(x: 0, y: 100)) + let staleScrollView = NSScrollView(frame: newerScrollView.frame) + staleScrollView.documentView = NSView(frame: NSRect(x: 0, y: 0, width: 200, height: 500)) + + let state = ManualRefreshViewportRestoreState() + defer { state.stopAllMovementTracking() } + state.startMovementTracking(for: key, generation: 2, scrollView: newerScrollView) + newerScrollView.contentView.scroll(to: NSPoint(x: 0, y: 120)) + self.runLoop(mode: CFRunLoopMode(RunLoop.Mode.eventTracking.rawValue as CFString)) + #expect(state.observedMovement(for: key, generation: 2)) + + var completionCount = 0 + state.prepareForCompletedRefreshLayout( + for: key, + generation: 1, + scrollView: staleScrollView) + { + completionCount += 1 + } + + #expect(completionCount == 1) + #expect(state.observedMovement(for: key, generation: 2)) + } + + @Test + func `manual refresh restores originating dirty menu without rebuilding tracked parent`() async throws { + let settings = self.makeSettings() + settings.refreshFrequency = .manual + settings.mergeIcons = false + + let controller = self.makeController(settings: settings) + defer { controller.releaseStatusItemsForTesting() } + controller.menuRefreshEnabledOverrideForTesting = true + let menu = controller.makeMenu(for: .codex) + controller.menuWillOpen(menu) + defer { controller.menuDidClose(menu) } + let menuID = ObjectIdentifier(menu) + + var restoredMenus: [ObjectIdentifier] = [] + var rebuildCount = 0 + let gate = ViewportRefreshGate() + controller._test_menuViewportRestoreObserver = { restoredMenus.append(ObjectIdentifier($0)) } + controller._test_openMenuRebuildObserver = { rebuiltMenu in + if rebuiltMenu === menu { + rebuildCount += 1 + } + } + controller._test_manualRefreshOperation = { + await gate.wait() + controller.refreshOpenMenusAfterExplicitStoreAction() + } + #expect(try controller.handleMenuTrackingShortcutEvent(self.keyEvent("r", keyCode: 15), menu: menu)) + for _ in 0..<20 where controller.manualRefreshTasks[.provider(.codex)] == nil { + await Task.yield() + } + let task = try #require(controller.manualRefreshTasks[.provider(.codex)]) + gate.resume() + await task.value + + #expect(controller.menuNeedsRefresh(menu)) + #expect(controller.menuSession.isParentRebuildDeferred(menuID)) + #expect(rebuildCount == 0) + + self.runLoop(mode: CFRunLoopMode(RunLoop.Mode.eventTracking.rawValue as CFString)) + + #expect(restoredMenus == [menuID]) + self.runLoop(mode: .defaultMode) + #expect(restoredMenus == [menuID]) + #expect(rebuildCount == 0) + #expect(controller.menuNeedsRefresh(menu)) + #expect(controller.menuSession.isParentRebuildDeferred(menuID)) + #expect(controller.menuSession.pendingViewportRestores.isEmpty) + } + + @Test + func `completed manual refresh clears its request when the menu stayed clean`() async throws { + let settings = self.makeSettings() + settings.refreshFrequency = .manual + settings.mergeIcons = false + + let controller = self.makeController(settings: settings) + defer { controller.releaseStatusItemsForTesting() } + controller.menuRefreshEnabledOverrideForTesting = true + let menu = controller.makeMenu(for: .codex) + controller.menuWillOpen(menu) + defer { controller.menuDidClose(menu) } + controller.markMenuFresh(menu) + + var scheduledCount = 0 + controller._test_menuViewportRestoreScheduler = { _ in scheduledCount += 1 } + controller._test_manualRefreshOperation = {} + controller.refreshMenuProviderNow(in: menu) + for _ in 0..<20 where controller.manualRefreshTasks[.provider(.codex)] == nil { + await Task.yield() + } + let task = try #require(controller.manualRefreshTasks[.provider(.codex)]) + await task.value + + #expect(scheduledCount == 0) + #expect(controller.menuSession.pendingViewportRestores.isEmpty) + } + + @Test + func `viewport becoming attachable during refresh schedules one restore`() async throws { + let settings = self.makeSettings() + settings.refreshFrequency = .manual + settings.mergeIcons = false + + let controller = self.makeController(settings: settings) + defer { controller.releaseStatusItemsForTesting() } + controller.menuRefreshEnabledOverrideForTesting = true + let menu = controller.makeMenu(for: .codex) + controller.menuWillOpen(menu) + #expect(StatusItemController.attachedMenuScrollView(in: menu) == nil) + + let gate = ViewportRefreshGate() + var scheduled: [@MainActor () -> Void] = [] + var restoreCount = 0 + controller._test_menuViewportRestoreScheduler = { scheduled.append($0) } + controller._test_menuViewportRestoreObserver = { _ in restoreCount += 1 } + controller._test_manualRefreshOperation = { + await gate.wait() + controller.refreshOpenMenusAfterExplicitStoreAction() + } + controller.refreshMenuProviderNow(in: menu) + for _ in 0..<20 where controller.manualRefreshTasks[.provider(.codex)] == nil { + await Task.yield() + } + let task = try #require(controller.manualRefreshTasks[.provider(.codex)]) + _ = self.attachScrollableViewport(to: menu) + + gate.resume() + await task.value + + #expect(scheduled.count == 1) + scheduled.removeFirst()() + #expect(restoreCount == 1) + #expect(controller.menuSession.pendingViewportRestores.isEmpty) + } + + @Test + func `provider refresh restores only its originating open menu`() async throws { + let settings = self.makeSettings() + settings.refreshFrequency = .manual + settings.mergeIcons = false + + let controller = self.makeController(settings: settings) + defer { controller.releaseStatusItemsForTesting() } + controller.menuRefreshEnabledOverrideForTesting = true + let claudeMenu = controller.makeMenu(for: .claude) + let codexMenu = controller.makeMenu(for: .codex) + controller.providerMenus[.claude] = claudeMenu + controller.providerMenus[.codex] = codexMenu + controller.menuWillOpen(claudeMenu) + controller.menuWillOpen(codexMenu) + defer { + controller.menuDidClose(codexMenu) + controller.menuDidClose(claudeMenu) + } + + var scheduled: [@MainActor () -> Void] = [] + var restoredMenus: [ObjectIdentifier] = [] + controller._test_menuViewportRestoreScheduler = { scheduled.append($0) } + controller._test_menuViewportRestoreObserver = { restoredMenus.append(ObjectIdentifier($0)) } + controller._test_manualRefreshOperation = { + controller.refreshOpenMenusAfterExplicitStoreAction() + } + controller.refreshMenuProviderNow(in: claudeMenu) + for _ in 0..<20 where controller.manualRefreshTasks[.provider(.claude)] == nil { + await Task.yield() + } + let task = try #require(controller.manualRefreshTasks[.provider(.claude)]) + await task.value + + #expect(scheduled.count == 1) + scheduled.removeFirst()() + + #expect(restoredMenus == [ObjectIdentifier(claudeMenu)]) + #expect(controller.menuNeedsRefresh(codexMenu)) + #expect(controller.menuSession.pendingViewportRestores.isEmpty) + } + + @Test + func `closing and reopening during refresh cannot transfer restore to new tracking session`() async throws { + let settings = self.makeSettings() + settings.refreshFrequency = .manual + settings.mergeIcons = false + + let controller = self.makeController(settings: settings) + defer { controller.releaseStatusItemsForTesting() } + controller.menuRefreshEnabledOverrideForTesting = true + let menu = controller.makeMenu(for: .codex) + controller.menuWillOpen(menu) + defer { controller.menuDidClose(menu) } + + let gate = ViewportRefreshGate() + var scheduledCount = 0 + var restoreCount = 0 + controller._test_menuViewportRestoreScheduler = { _ in scheduledCount += 1 } + controller._test_menuViewportRestoreObserver = { _ in restoreCount += 1 } + controller._test_manualRefreshOperation = { + await gate.wait() + controller.refreshOpenMenusAfterExplicitStoreAction() + } + controller.refreshMenuProviderNow(in: menu) + for _ in 0..<20 where controller.manualRefreshTasks[.provider(.codex)] == nil { + await Task.yield() + } + let task = try #require(controller.manualRefreshTasks[.provider(.codex)]) + #expect(!controller.menuSession.pendingViewportRestores.isEmpty) + + controller.menuDidClose(menu) + controller.menuWillOpen(menu) + #expect(controller.menuSession.pendingViewportRestores.isEmpty) + + gate.resume() + await task.value + + #expect(scheduledCount == 0) + #expect(restoreCount == 0) + #expect(controller.menuSession.pendingViewportRestores.isEmpty) + } + + @Test + func `closing and reopening after completion invalidates scheduled restore`() async throws { + let settings = self.makeSettings() + settings.refreshFrequency = .manual + settings.mergeIcons = false + + let controller = self.makeController(settings: settings) + defer { controller.releaseStatusItemsForTesting() } + controller.menuRefreshEnabledOverrideForTesting = true + let menu = controller.makeMenu(for: .codex) + controller.menuWillOpen(menu) + + var scheduled: [@MainActor () -> Void] = [] + var restoreCount = 0 + controller._test_menuViewportRestoreScheduler = { scheduled.append($0) } + controller._test_menuViewportRestoreObserver = { _ in restoreCount += 1 } + controller._test_manualRefreshOperation = { + controller.refreshOpenMenusAfterExplicitStoreAction() + } + controller.refreshMenuProviderNow(in: menu) + for _ in 0..<20 where controller.manualRefreshTasks[.provider(.codex)] == nil { + await Task.yield() + } + let task = try #require(controller.manualRefreshTasks[.provider(.codex)]) + await task.value + #expect(scheduled.count == 1) + + controller.menuDidClose(menu) + controller.menuWillOpen(menu) + defer { controller.menuDidClose(menu) } + scheduled.removeFirst()() + + #expect(restoreCount == 0) + #expect(controller.menuSession.pendingViewportRestores.isEmpty) + } + + @Test + func `open hosted submenu blocks parent viewport restore`() async throws { + let settings = self.makeSettings() + settings.refreshFrequency = .manual + settings.mergeIcons = false + + let controller = self.makeController(settings: settings) + defer { controller.releaseStatusItemsForTesting() } + controller.menuRefreshEnabledOverrideForTesting = true + let menu = controller.makeMenu(for: .codex) + controller.menuWillOpen(menu) + let submenu = controller.makeHostedSubviewPlaceholderMenu( + chartID: StatusItemController.usageBreakdownChartID) + controller.openMenus[ObjectIdentifier(submenu)] = submenu + + var scheduled: [@MainActor () -> Void] = [] + var restoreCount = 0 + var rebuildCount = 0 + controller._test_menuViewportRestoreScheduler = { scheduled.append($0) } + controller._test_menuViewportRestoreObserver = { _ in restoreCount += 1 } + controller._test_openMenuRebuildObserver = { rebuiltMenu in + if rebuiltMenu === menu { + rebuildCount += 1 + } + } + controller._test_manualRefreshOperation = { + controller.refreshOpenMenusAfterExplicitStoreAction() + } + controller.refreshMenuProviderNow(in: menu) + for _ in 0..<20 where controller.manualRefreshTasks[.provider(.codex)] == nil { + await Task.yield() + } + let task = try #require(controller.manualRefreshTasks[.provider(.codex)]) + await task.value + + #expect(scheduled.isEmpty) + #expect(restoreCount == 0) + #expect(!controller.menuSession.pendingViewportRestores.isEmpty) + #expect(controller.manualRefreshViewportRestoreState.deferredUntilRebuild.count == 1) + + controller.menuDidClose(submenu) + for _ in 0..<20 where scheduled.isEmpty { + await Task.yield() + } + #expect(rebuildCount == 1) + #expect(scheduled.count == 1) + scheduled.removeFirst()() + + #expect(restoreCount == 1) + #expect(controller.menuSession.pendingViewportRestores.isEmpty) + #expect(controller.manualRefreshViewportRestoreState.deferredUntilRebuild.isEmpty) + } + + @Test + func `hosted submenu opening before delivery invalidates parent viewport restore`() async throws { + let settings = self.makeSettings() + settings.refreshFrequency = .manual + settings.mergeIcons = false + + let controller = self.makeController(settings: settings) + defer { controller.releaseStatusItemsForTesting() } + controller.menuRefreshEnabledOverrideForTesting = true + let menu = controller.makeMenu(for: .codex) + controller.menuWillOpen(menu) + + var scheduled: [@MainActor () -> Void] = [] + var restoreCount = 0 + var rebuildCount = 0 + controller._test_menuViewportRestoreScheduler = { scheduled.append($0) } + controller._test_menuViewportRestoreObserver = { _ in restoreCount += 1 } + controller._test_openMenuRebuildObserver = { rebuiltMenu in + if rebuiltMenu === menu { + rebuildCount += 1 + } + } + controller._test_manualRefreshOperation = { + controller.refreshOpenMenusAfterExplicitStoreAction() + } + controller.refreshMenuProviderNow(in: menu) + for _ in 0..<20 where controller.manualRefreshTasks[.provider(.codex)] == nil { + await Task.yield() + } + let task = try #require(controller.manualRefreshTasks[.provider(.codex)]) + await task.value + #expect(scheduled.count == 1) + + let submenu = controller.makeHostedSubviewPlaceholderMenu( + chartID: StatusItemController.usageBreakdownChartID) + controller.openMenus[ObjectIdentifier(submenu)] = submenu + scheduled.removeFirst()() + + #expect(restoreCount == 0) + #expect(!controller.menuSession.pendingViewportRestores.isEmpty) + #expect(controller.manualRefreshViewportRestoreState.deferredUntilRebuild.count == 1) + + controller.menuDidClose(submenu) + for _ in 0..<20 where scheduled.isEmpty { + await Task.yield() + } + #expect(rebuildCount == 1) + #expect(scheduled.count == 1) + scheduled.removeFirst()() + + #expect(restoreCount == 1) + #expect(controller.menuSession.pendingViewportRestores.isEmpty) + #expect(controller.manualRefreshViewportRestoreState.deferredUntilRebuild.isEmpty) + } + + @Test + func `fresh parent does not defer old restore when hosted submenu opens before delivery`() async throws { + let settings = self.makeSettings() + settings.refreshFrequency = .manual + settings.mergeIcons = false + + let controller = self.makeController(settings: settings) + defer { controller.releaseStatusItemsForTesting() } + controller.menuRefreshEnabledOverrideForTesting = true + let menu = controller.makeMenu(for: .codex) + controller.menuWillOpen(menu) + + var scheduled: [@MainActor () -> Void] = [] + var restoreCount = 0 + controller._test_menuViewportRestoreScheduler = { scheduled.append($0) } + controller._test_menuViewportRestoreObserver = { _ in restoreCount += 1 } + controller._test_manualRefreshOperation = { + controller.refreshOpenMenusAfterExplicitStoreAction() + } + controller.refreshMenuProviderNow(in: menu) + for _ in 0..<20 where controller.manualRefreshTasks[.provider(.codex)] == nil { + await Task.yield() + } + let task = try #require(controller.manualRefreshTasks[.provider(.codex)]) + await task.value + #expect(scheduled.count == 1) + + controller.rebuildOpenMenuIfStillVisible(menu, provider: .codex) + #expect(!controller.menuNeedsRefresh(menu)) + controller.parentMenuRebuildPendingAfterHostedSubviewClose = true + let submenu = controller.makeHostedSubviewPlaceholderMenu( + chartID: StatusItemController.usageBreakdownChartID) + controller.openMenus[ObjectIdentifier(submenu)] = submenu + scheduled.removeFirst()() + + #expect(restoreCount == 0) + #expect(controller.menuSession.pendingViewportRestores.isEmpty) + #expect(controller.manualRefreshViewportRestoreState.deferredUntilRebuild.isEmpty) + } + + @Test + func `native highlight blocks parent viewport restore`() async throws { + let settings = self.makeSettings() + settings.refreshFrequency = .manual + settings.mergeIcons = false + + let controller = self.makeController(settings: settings) + defer { controller.releaseStatusItemsForTesting() } + controller.menuRefreshEnabledOverrideForTesting = true + let menu = controller.makeMenu(for: .codex) + controller.menuWillOpen(menu) + let settingsItem = try #require(menu.items.first { $0.title == "Settings..." }) + controller.menu(menu, willHighlight: settingsItem) + + var scheduledCount = 0 + var restoreCount = 0 + controller._test_menuViewportRestoreScheduler = { _ in scheduledCount += 1 } + controller._test_menuViewportRestoreObserver = { _ in restoreCount += 1 } + controller._test_manualRefreshOperation = { + controller.refreshOpenMenusAfterExplicitStoreAction() + } + controller.refreshMenuProviderNow(in: menu) + for _ in 0..<20 where controller.manualRefreshTasks[.provider(.codex)] == nil { + await Task.yield() + } + let task = try #require(controller.manualRefreshTasks[.provider(.codex)]) + await task.value + + #expect(scheduledCount == 0) + #expect(restoreCount == 0) + #expect(controller.menuSession.pendingViewportRestores.isEmpty) + } + + @Test + func `native highlight before delivery invalidates parent viewport restore`() async throws { + let settings = self.makeSettings() + settings.refreshFrequency = .manual + settings.mergeIcons = false + + let controller = self.makeController(settings: settings) + defer { controller.releaseStatusItemsForTesting() } + controller.menuRefreshEnabledOverrideForTesting = true + let menu = controller.makeMenu(for: .codex) + controller.menuWillOpen(menu) + + var scheduled: [@MainActor () -> Void] = [] + var restoreCount = 0 + controller._test_menuViewportRestoreScheduler = { scheduled.append($0) } + controller._test_menuViewportRestoreObserver = { _ in restoreCount += 1 } + controller._test_manualRefreshOperation = { + controller.refreshOpenMenusAfterExplicitStoreAction() + } + controller.refreshMenuProviderNow(in: menu) + for _ in 0..<20 where controller.manualRefreshTasks[.provider(.codex)] == nil { + await Task.yield() + } + let task = try #require(controller.manualRefreshTasks[.provider(.codex)]) + await task.value + #expect(scheduled.count == 1) + + let settingsItem = try #require(menu.items.first { $0.title == "Settings..." }) + controller.menu(menu, willHighlight: settingsItem) + scheduled.removeFirst()() + + #expect(restoreCount == 0) + #expect(controller.menuSession.pendingViewportRestores.isEmpty) + } + + @Test + func `custom highlight blocks parent viewport restore`() async throws { + let settings = self.makeSettings() + settings.refreshFrequency = .manual + settings.mergeIcons = false + + let controller = self.makeController(settings: settings) + defer { controller.releaseStatusItemsForTesting() } + controller.menuRefreshEnabledOverrideForTesting = true + let menu = controller.makeMenu(for: .codex) + controller.menuWillOpen(menu) + let overviewItem = NSMenuItem() + overviewItem.view = NSView() + overviewItem.isEnabled = true + menu.addItem(overviewItem) + controller.menu(menu, willHighlight: overviewItem) + + var scheduledCount = 0 + var restoreCount = 0 + controller._test_menuViewportRestoreScheduler = { _ in scheduledCount += 1 } + controller._test_menuViewportRestoreObserver = { _ in restoreCount += 1 } + controller._test_manualRefreshOperation = { + controller.menuSession.invalidate(allowsStaleContent: false, requiresRebuild: true) + } + controller.refreshMenuProviderNow(in: menu) + for _ in 0..<20 where controller.manualRefreshTasks[.provider(.codex)] == nil { + await Task.yield() + } + let task = try #require(controller.manualRefreshTasks[.provider(.codex)]) + await task.value + + #expect(scheduledCount == 0) + #expect(restoreCount == 0) + #expect(controller.menuSession.pendingViewportRestores.isEmpty) + } + + @Test + func `custom highlight before delivery invalidates parent viewport restore`() async throws { + let settings = self.makeSettings() + settings.refreshFrequency = .manual + settings.mergeIcons = false + + let controller = self.makeController(settings: settings) + defer { controller.releaseStatusItemsForTesting() } + controller.menuRefreshEnabledOverrideForTesting = true + let menu = controller.makeMenu(for: .codex) + controller.menuWillOpen(menu) + + var scheduled: [@MainActor () -> Void] = [] + var restoreCount = 0 + controller._test_menuViewportRestoreScheduler = { scheduled.append($0) } + controller._test_menuViewportRestoreObserver = { _ in restoreCount += 1 } + controller._test_manualRefreshOperation = { + controller.refreshOpenMenusAfterExplicitStoreAction() + } + controller.refreshMenuProviderNow(in: menu) + for _ in 0..<20 where controller.manualRefreshTasks[.provider(.codex)] == nil { + await Task.yield() + } + let task = try #require(controller.manualRefreshTasks[.provider(.codex)]) + await task.value + #expect(scheduled.count == 1) + + let overviewItem = NSMenuItem() + overviewItem.view = NSView() + overviewItem.isEnabled = true + menu.addItem(overviewItem) + controller.menu(menu, willHighlight: overviewItem) + scheduled.removeFirst()() + + #expect(restoreCount == 0) + #expect(controller.menuSession.pendingViewportRestores.isEmpty) + } + + @Test + func `refresh row highlight clears while its action is in flight`() async throws { + let settings = self.makeSettings() + settings.refreshFrequency = .manual + settings.mergeIcons = false + + let controller = self.makeController(settings: settings) + defer { controller.releaseStatusItemsForTesting() } + controller.menuRefreshEnabledOverrideForTesting = true + let menu = controller.makeMenu(for: .codex) + controller.menuWillOpen(menu) + let refreshItem = try #require(menu.items.first(where: controller.isPersistentRefreshItem)) + let refreshView = try #require(refreshItem.view as? PersistentRefreshMenuView) + controller.menu(menu, willHighlight: refreshItem) + + var scheduled: [@MainActor () -> Void] = [] + var restoreCount = 0 + let gate = ViewportRefreshGate() + controller._test_menuViewportRestoreScheduler = { scheduled.append($0) } + controller._test_menuViewportRestoreObserver = { _ in restoreCount += 1 } + controller._test_manualRefreshOperation = { + await gate.wait() + controller.refreshOpenMenusAfterExplicitStoreAction() + } + + #expect(refreshView.accessibilityPerformPress()) + for _ in 0..<20 where controller.manualRefreshTasks[.provider(.codex)] == nil { + await Task.yield() + } + let task = try #require(controller.manualRefreshTasks[.provider(.codex)]) + #expect(controller.highlightedMenuItems[ObjectIdentifier(menu)] == nil) + #expect(!refreshItem.isEnabled) + + gate.resume() + await task.value + #expect(refreshItem.isEnabled) + #expect(scheduled.count == 1) + + scheduled.removeFirst()() + #expect(restoreCount == 1) + #expect(controller.menuSession.pendingViewportRestores.isEmpty) + } + + @Test + func `clip movement during refresh invalidates parent viewport restore without a wheel event`() async throws { + let settings = self.makeSettings() + settings.refreshFrequency = .manual + settings.mergeIcons = false + + let controller = self.makeController(settings: settings) + defer { controller.releaseStatusItemsForTesting() } + controller.menuRefreshEnabledOverrideForTesting = true + let menu = controller.makeMenu(for: .codex) + controller.menuWillOpen(menu) + let scrollView = self.attachScrollableViewport(to: menu) + + let gate = ViewportRefreshGate() + var scheduledCount = 0 + controller._test_menuViewportRestoreScheduler = { _ in scheduledCount += 1 } + controller._test_manualRefreshOperation = { + await gate.wait() + controller.refreshOpenMenusAfterExplicitStoreAction() + } + controller.refreshMenuProviderNow(in: menu) + for _ in 0..<20 where controller.manualRefreshTasks[.provider(.codex)] == nil { + await Task.yield() + } + let task = try #require(controller.manualRefreshTasks[.provider(.codex)]) + + scrollView.contentView.scroll(to: NSPoint(x: 0, y: 120)) + gate.resume() + await task.value + self.runLoop(mode: CFRunLoopMode(RunLoop.Mode.eventTracking.rawValue as CFString)) + + #expect(scheduledCount == 0) + #expect(controller.menuSession.pendingViewportRestores.isEmpty) + } + + @Test + func `scroll during refresh invalidates parent viewport restore`() async throws { + let settings = self.makeSettings() + settings.refreshFrequency = .manual + settings.mergeIcons = false + + let controller = self.makeController(settings: settings) + defer { controller.releaseStatusItemsForTesting() } + controller.menuRefreshEnabledOverrideForTesting = true + let menu = controller.makeMenu(for: .codex) + controller.menuWillOpen(menu) + + let gate = ViewportRefreshGate() + var scheduledCount = 0 + controller._test_menuViewportRestoreScheduler = { _ in scheduledCount += 1 } + controller._test_manualRefreshOperation = { + await gate.wait() + controller.refreshOpenMenusAfterExplicitStoreAction() + } + controller.refreshMenuProviderNow(in: menu) + for _ in 0..<20 where controller.manualRefreshTasks[.provider(.codex)] == nil { + await Task.yield() + } + let task = try #require(controller.manualRefreshTasks[.provider(.codex)]) + let initialGeneration = try #require(controller.menuSession + .menuInteractionGeneration(for: ObjectIdentifier(menu))) + + let scroll = try self.scrollEvent() + #expect(!controller.handleMenuTrackingShortcutEvent(scroll, menu: menu)) + #expect(controller.menuSession.menuInteractionGeneration(for: ObjectIdentifier(menu)) == initialGeneration + 1) + + gate.resume() + await task.value + + #expect(scheduledCount == 0) + #expect(controller.menuSession.pendingViewportRestores.isEmpty) + } + + @Test + func `clip movement before delivery invalidates parent viewport restore without a wheel event`() async throws { + let settings = self.makeSettings() + settings.refreshFrequency = .manual + settings.mergeIcons = false + + let controller = self.makeController(settings: settings) + defer { controller.releaseStatusItemsForTesting() } + controller.menuRefreshEnabledOverrideForTesting = true + let menu = controller.makeMenu(for: .codex) + controller.menuWillOpen(menu) + let scrollView = self.attachScrollableViewport(to: menu) + + var scheduled: [@MainActor () -> Void] = [] + var restoreCount = 0 + controller._test_menuViewportRestoreScheduler = { scheduled.append($0) } + controller._test_menuViewportRestoreObserver = { _ in restoreCount += 1 } + controller._test_manualRefreshOperation = { + controller.refreshOpenMenusAfterExplicitStoreAction() + } + controller.refreshMenuProviderNow(in: menu) + for _ in 0..<20 where controller.manualRefreshTasks[.provider(.codex)] == nil { + await Task.yield() + } + let task = try #require(controller.manualRefreshTasks[.provider(.codex)]) + await task.value + #expect(scheduled.count == 1) + + scrollView.contentView.scroll(to: NSPoint(x: 0, y: 120)) + scheduled.removeFirst()() + self.runLoop(mode: CFRunLoopMode(RunLoop.Mode.eventTracking.rawValue as CFString)) + + #expect(restoreCount == 0) + #expect(controller.menuSession.pendingViewportRestores.isEmpty) + } + + @Test + func `scroll before delivery invalidates parent viewport restore`() async throws { + let settings = self.makeSettings() + settings.refreshFrequency = .manual + settings.mergeIcons = false + + let controller = self.makeController(settings: settings) + defer { controller.releaseStatusItemsForTesting() } + controller.menuRefreshEnabledOverrideForTesting = true + let menu = controller.makeMenu(for: .codex) + controller.menuWillOpen(menu) + + var scheduled: [@MainActor () -> Void] = [] + var restoreCount = 0 + controller._test_menuViewportRestoreScheduler = { scheduled.append($0) } + controller._test_menuViewportRestoreObserver = { _ in restoreCount += 1 } + controller._test_manualRefreshOperation = { + controller.refreshOpenMenusAfterExplicitStoreAction() + } + controller.refreshMenuProviderNow(in: menu) + for _ in 0..<20 where controller.manualRefreshTasks[.provider(.codex)] == nil { + await Task.yield() + } + let task = try #require(controller.manualRefreshTasks[.provider(.codex)]) + await task.value + #expect(scheduled.count == 1) + + let scroll = try self.scrollEvent() + #expect(!controller.handleMenuTrackingShortcutEvent(scroll, menu: menu)) + scheduled.removeFirst()() + + #expect(restoreCount == 0) + #expect(controller.menuSession.pendingViewportRestores.isEmpty) + } + + @Test + func `non-manual invalidation never schedules a viewport restore`() { + let settings = self.makeSettings() + settings.refreshFrequency = .manual + settings.mergeIcons = false + + let controller = self.makeController(settings: settings) + defer { controller.releaseStatusItemsForTesting() } + controller.menuRefreshEnabledOverrideForTesting = true + let menu = controller.makeMenu(for: .codex) + controller.menuWillOpen(menu) + defer { controller.menuDidClose(menu) } + + var scheduledCount = 0 + controller._test_menuViewportRestoreScheduler = { _ in scheduledCount += 1 } + controller.refreshOpenMenusAfterExplicitStoreAction() + + #expect(controller.menuNeedsRefresh(menu)) + #expect(scheduledCount == 0) + #expect(controller.menuSession.pendingViewportRestores.isEmpty) + } + + @Test + func `closed origin cannot transfer restore to another open menu before task starts`() async throws { + let settings = self.makeSettings() + settings.refreshFrequency = .manual + settings.mergeIcons = false + + let controller = self.makeController(settings: settings) + defer { controller.releaseStatusItemsForTesting() } + controller.menuRefreshEnabledOverrideForTesting = true + let claudeMenu = controller.makeMenu(for: .claude) + let codexMenu = controller.makeMenu(for: .codex) + controller.providerMenus[.claude] = claudeMenu + controller.providerMenus[.codex] = codexMenu + controller.menuWillOpen(claudeMenu) + + var scheduledCount = 0 + var restoreCount = 0 + let gate = ViewportRefreshGate() + controller._test_menuViewportRestoreScheduler = { _ in scheduledCount += 1 } + controller._test_menuViewportRestoreObserver = { _ in restoreCount += 1 } + controller._test_manualRefreshOperation = { + await gate.wait() + controller.refreshOpenMenusAfterExplicitStoreAction() + } + + controller.performPersistentRefreshAction(in: ObjectIdentifier(claudeMenu)) + controller.menuDidClose(claudeMenu) + controller.menuWillOpen(codexMenu) + for _ in 0..<20 where controller.manualRefreshTasks[.provider(.claude)] == nil { + await Task.yield() + } + let task = try #require(controller.manualRefreshTasks[.provider(.claude)]) + gate.resume() + await task.value + + #expect(scheduledCount == 0) + #expect(restoreCount == 0) + #expect(controller.menuSession.pendingViewportRestores.isEmpty) + } + + @Test + func `queued refresh cannot arm restore for a reopened persistent menu`() async throws { + let settings = self.makeSettings() + settings.refreshFrequency = .manual + settings.mergeIcons = false + + let controller = self.makeController(settings: settings) + defer { controller.releaseStatusItemsForTesting() } + controller.menuRefreshEnabledOverrideForTesting = true + let menu = try #require(controller.makeMenu(for: .codex) as? StatusItemMenu) + controller.providerMenus[.codex] = menu + controller.menuWillOpen(menu) + let closedSession = try #require(menu.menuInteractionGeneration) + + let gate = ViewportRefreshGate() + var scheduledCount = 0 + var restoreCount = 0 + controller._test_menuViewportRestoreScheduler = { _ in scheduledCount += 1 } + controller._test_menuViewportRestoreObserver = { _ in restoreCount += 1 } + controller._test_manualRefreshOperation = { + await gate.wait() + controller.refreshOpenMenusAfterExplicitStoreAction() + } + + menu.requestPersistentRefreshAction() + controller.menuDidClose(menu) + controller.menuWillOpen(menu) + defer { controller.menuDidClose(menu) } + let reopenedSession = try #require(menu.menuInteractionGeneration) + #expect(reopenedSession != closedSession) + + for _ in 0..<20 where controller.manualRefreshTasks[.provider(.codex)] == nil { + await Task.yield() + } + let task = try #require(controller.manualRefreshTasks[.provider(.codex)]) + #expect(controller.menuSession.pendingViewportRestores.isEmpty) + + gate.resume() + await task.value + + #expect(scheduledCount == 0) + #expect(restoreCount == 0) + #expect(controller.menuSession.pendingViewportRestores.isEmpty) + } +} + +extension StatusMenuViewportRestoreTests { + @Test + func `open non-hosted child menu blocks global viewport restore`() async throws { + let settings = self.makeSettings() + settings.refreshFrequency = .manual + settings.mergeIcons = false + + let controller = self.makeController(settings: settings) + defer { controller.releaseStatusItemsForTesting() } + controller.menuRefreshEnabledOverrideForTesting = true + let rootMenu = controller.makeMenu(for: .codex) + controller.menuWillOpen(rootMenu) + let submenu = NSMenu() + let submenuItem = NSMenuItem(title: "Submenu", action: nil, keyEquivalent: "") + submenuItem.submenu = submenu + rootMenu.addItem(submenuItem) + controller.menuWillOpen(submenu) + defer { + controller.menuDidClose(submenu) + controller.menuDidClose(rootMenu) + } + + var scheduledCount = 0 + var restoreCount = 0 + controller._test_menuViewportRestoreScheduler = { _ in scheduledCount += 1 } + controller._test_menuViewportRestoreObserver = { _ in restoreCount += 1 } + controller._test_manualRefreshOperation = { + controller.refreshOpenMenusAfterExplicitStoreAction() + } + controller.refreshNow() + for _ in 0..<20 where controller.manualRefreshTasks[.global] == nil { + await Task.yield() + } + let task = try #require(controller.manualRefreshTasks[.global]) + await task.value + + #expect(scheduledCount == 0) + #expect(restoreCount == 0) + #expect(controller.menuSession.pendingViewportRestores.isEmpty) + } + + @Test + func `opening and closing non-hosted child during refresh invalidates parent restore`() async throws { + let settings = self.makeSettings() + settings.refreshFrequency = .manual + settings.mergeIcons = false + + let controller = self.makeController(settings: settings) + defer { controller.releaseStatusItemsForTesting() } + controller.menuRefreshEnabledOverrideForTesting = true + let rootMenu = controller.makeMenu(for: .codex) + controller.menuWillOpen(rootMenu) + defer { controller.menuDidClose(rootMenu) } + let rootID = ObjectIdentifier(rootMenu) + + let gate = ViewportRefreshGate() + var scheduledCount = 0 + var restoreCount = 0 + controller._test_menuViewportRestoreScheduler = { _ in scheduledCount += 1 } + controller._test_menuViewportRestoreObserver = { _ in restoreCount += 1 } + controller._test_manualRefreshOperation = { + await gate.wait() + controller.refreshOpenMenusAfterExplicitStoreAction() + } + controller.refreshMenuProviderNow(in: rootMenu) + for _ in 0..<20 where controller.manualRefreshTasks[.provider(.codex)] == nil { + await Task.yield() + } + let task = try #require(controller.manualRefreshTasks[.provider(.codex)]) + let refreshInteraction = try #require(controller.menuSession.menuInteractionGeneration(for: rootID)) + + let submenu = NSMenu() + let submenuItem = NSMenuItem(title: "Submenu", action: nil, keyEquivalent: "") + submenuItem.submenu = submenu + rootMenu.addItem(submenuItem) + controller.menuWillOpen(submenu) + #expect(controller.menuSession.menuInteractionGeneration(for: rootID) != refreshInteraction) + controller.menuDidClose(submenu) + + gate.resume() + await task.value + + #expect(scheduledCount == 0) + #expect(restoreCount == 0) + #expect(controller.menuSession.pendingViewportRestores.isEmpty) + } + + @Test + func `non-hosted child opening before delivery invalidates parent restore`() async throws { + let settings = self.makeSettings() + settings.refreshFrequency = .manual + settings.mergeIcons = false + + let controller = self.makeController(settings: settings) + defer { controller.releaseStatusItemsForTesting() } + controller.menuRefreshEnabledOverrideForTesting = true + let rootMenu = controller.makeMenu(for: .codex) + controller.menuWillOpen(rootMenu) + defer { controller.menuDidClose(rootMenu) } + + var scheduled: [@MainActor () -> Void] = [] + var restoreCount = 0 + controller._test_menuViewportRestoreScheduler = { scheduled.append($0) } + controller._test_menuViewportRestoreObserver = { _ in restoreCount += 1 } + controller._test_manualRefreshOperation = { + controller.refreshOpenMenusAfterExplicitStoreAction() + } + controller.refreshMenuProviderNow(in: rootMenu) + for _ in 0..<20 where controller.manualRefreshTasks[.provider(.codex)] == nil { + await Task.yield() + } + let task = try #require(controller.manualRefreshTasks[.provider(.codex)]) + await task.value + #expect(scheduled.count == 1) + + let submenu = NSMenu() + let submenuItem = NSMenuItem(title: "Submenu", action: nil, keyEquivalent: "") + submenuItem.submenu = submenu + rootMenu.addItem(submenuItem) + controller.menuWillOpen(submenu) + defer { controller.menuDidClose(submenu) } + scheduled.removeFirst()() + + #expect(restoreCount == 0) + #expect(controller.menuSession.pendingViewportRestores.isEmpty) + } + + @Test + func `merged selection change discards originating refresh restore`() async throws { + let settings = self.makeSettings() + settings.refreshFrequency = .manual + settings.mergeIcons = true + self.enableOnly([.claude, .codex], settings: settings) + settings.selectedMenuProvider = .claude + settings.mergedMenuLastSelectedWasOverview = false + + let controller = self.makeController(settings: settings) + defer { controller.releaseStatusItemsForTesting() } + controller.menuRefreshEnabledOverrideForTesting = true + let menu = controller.makeMenu() + controller.mergedMenu = menu + controller.menuWillOpen(menu) + + let gate = ViewportRefreshGate() + var scheduledCount = 0 + controller._test_menuViewportRestoreScheduler = { _ in scheduledCount += 1 } + controller._test_manualRefreshOperation = { + await gate.wait() + controller.refreshOpenMenusAfterExplicitStoreAction() + } + controller.refreshMenuProviderNow(in: menu) + for _ in 0..<20 where controller.manualRefreshTasks[.provider(.claude)] == nil { + await Task.yield() + } + let task = try #require(controller.manualRefreshTasks[.provider(.claude)]) + + settings.selectedMenuProvider = .codex + gate.resume() + await task.value + + #expect(scheduledCount == 0) + #expect(controller.menuSession.pendingViewportRestores.isEmpty) + } + + @Test + func `merged selection ABA discards originating refresh restore`() async throws { + let settings = self.makeSettings() + settings.refreshFrequency = .manual + settings.mergeIcons = true + self.enableOnly([.claude, .codex], settings: settings) + settings.mergedOverviewSelectedProviders = [] + settings.selectedMenuProvider = .claude + settings.mergedMenuLastSelectedWasOverview = false + + let controller = self.makeController(settings: settings) + defer { controller.releaseStatusItemsForTesting() } + controller.menuRefreshEnabledOverrideForTesting = true + controller._test_providerSwitcherMenuRebuildDebounceNanoseconds = UInt64.max + let menu = controller.makeMenu() + controller.mergedMenu = menu + controller.menuWillOpen(menu) + defer { controller.menuDidClose(menu) } + + let gate = ViewportRefreshGate() + var scheduled: [@MainActor () -> Void] = [] + var restoreCount = 0 + controller._test_menuViewportRestoreScheduler = { scheduled.append($0) } + controller._test_menuViewportRestoreObserver = { _ in restoreCount += 1 } + controller._test_manualRefreshOperation = { + await gate.wait() + controller.refreshOpenMenusAfterExplicitStoreAction() + } + controller.refreshMenuProviderNow(in: menu) + for _ in 0..<20 where controller.manualRefreshTasks[.provider(.claude)] == nil { + await Task.yield() + } + let task = try #require(controller.manualRefreshTasks[.provider(.claude)]) + + gate.resume() + await task.value + #expect(scheduled.count == 1) + + let initialGeneration = try #require(controller.menuSession + .menuInteractionGeneration(for: ObjectIdentifier(menu))) + controller.selectOverviewProvider(.codex, menu: menu) + controller.selectOverviewProvider(.claude, menu: menu) + #expect(settings.selectedMenuProvider == .claude) + #expect(controller.menuSession.menuInteractionGeneration(for: ObjectIdentifier(menu)) == initialGeneration + 2) + + scheduled.removeFirst()() + + #expect(restoreCount == 0) + #expect(controller.menuSession.pendingViewportRestores.isEmpty) + } + + @Test + func `queued refresh captures menu interaction before its task starts`() async throws { + let settings = self.makeSettings() + settings.refreshFrequency = .manual + settings.mergeIcons = true + self.enableOnly([.claude, .codex], settings: settings) + settings.mergedOverviewSelectedProviders = [] + settings.selectedMenuProvider = .claude + settings.mergedMenuLastSelectedWasOverview = false + + let controller = self.makeController(settings: settings) + defer { controller.releaseStatusItemsForTesting() } + controller.menuRefreshEnabledOverrideForTesting = true + controller._test_providerSwitcherMenuRebuildDebounceNanoseconds = UInt64.max + let menu = try #require(controller.makeMenu() as? StatusItemMenu) + controller.mergedMenu = menu + controller.menuWillOpen(menu) + defer { controller.menuDidClose(menu) } + + let gate = ViewportRefreshGate() + var scheduledCount = 0 + controller._test_menuViewportRestoreScheduler = { _ in scheduledCount += 1 } + controller._test_manualRefreshOperation = { + await gate.wait() + controller.refreshOpenMenusAfterExplicitStoreAction() + } + + menu.requestPersistentRefreshAction() + let actionGeneration = try #require(controller.menuSession + .menuInteractionGeneration(for: ObjectIdentifier(menu))) + controller.selectOverviewProvider(.codex, menu: menu) + controller.selectOverviewProvider(.claude, menu: menu) + #expect(controller.menuSession.menuInteractionGeneration(for: ObjectIdentifier(menu)) == actionGeneration + 2) + + for _ in 0..<20 where controller.manualRefreshTasks[.provider(.claude)] == nil { + await Task.yield() + } + let task = try #require(controller.manualRefreshTasks[.provider(.claude)]) + #expect(controller.menuSession.pendingViewportRestores.isEmpty) + + gate.resume() + await task.value + + #expect(scheduledCount == 0) + #expect(controller.menuSession.pendingViewportRestores.isEmpty) + } + + @Test + func `account selection ABA discards originating refresh restore`() async throws { + let settings = self.makeSettings() + settings.refreshFrequency = .manual + settings.mergeIcons = false + settings.multiAccountMenuLayout = .segmented + settings.statusChecksEnabled = false + self.enableOnly([.copilot], settings: settings) + settings.addTokenAccount(provider: .copilot, label: "Primary", token: "a") + settings.addTokenAccount(provider: .copilot, label: "Secondary", token: "b") + settings.setActiveTokenAccountIndex(0, for: .copilot) + + let controller = self.makeController(settings: settings) + defer { controller.releaseStatusItemsForTesting() } + controller.menuRefreshEnabledOverrideForTesting = true + controller._test_providerSwitcherMenuRebuildDebounceNanoseconds = UInt64.max + let menu = controller.makeMenu(for: .copilot) + controller.menuWillOpen(menu) + defer { controller.menuDidClose(menu) } + let switcher = try #require(menu.items.compactMap { $0.view as? TokenAccountSwitcherView }.first) + + let gate = ViewportRefreshGate() + var scheduledCount = 0 + controller._test_menuViewportRestoreScheduler = { _ in scheduledCount += 1 } + controller._test_manualRefreshOperation = { + await gate.wait() + controller.refreshOpenMenusAfterExplicitStoreAction() + } + controller.refreshMenuProviderNow(in: menu) + for _ in 0..<20 where controller.manualRefreshTasks[.provider(.copilot)] == nil { + await Task.yield() + } + let task = try #require(controller.manualRefreshTasks[.provider(.copilot)]) + let initialGeneration = try #require(controller.menuSession + .menuInteractionGeneration(for: ObjectIdentifier(menu))) + + let secondaryRefresh = try #require(switcher._test_select(index: 1)) + secondaryRefresh.cancel() + let primaryRefresh = try #require(switcher._test_select(index: 0)) + primaryRefresh.cancel() + #expect(settings.tokenAccountsData(for: .copilot)?.clampedActiveIndex() == 0) + #expect(controller.menuSession.menuInteractionGeneration(for: ObjectIdentifier(menu)) == initialGeneration + 2) + + gate.resume() + await task.value + + #expect(scheduledCount == 0) + #expect(controller.menuSession.pendingViewportRestores.isEmpty) + } + + @Test + func `cancelled manual refresh clears restore request without scheduling`() async throws { + let settings = self.makeSettings() + settings.refreshFrequency = .manual + settings.mergeIcons = false + + let controller = self.makeController(settings: settings) + defer { controller.releaseStatusItemsForTesting() } + controller.menuRefreshEnabledOverrideForTesting = true + let menu = controller.makeMenu(for: .codex) + controller.menuWillOpen(menu) + + let gate = ViewportRefreshGate() + var scheduledCount = 0 + controller._test_menuViewportRestoreScheduler = { _ in scheduledCount += 1 } + controller._test_manualRefreshOperation = { await gate.wait() } + controller.refreshMenuProviderNow(in: menu) + for _ in 0..<20 where controller.manualRefreshTasks[.provider(.codex)] == nil { + await Task.yield() + } + let task = try #require(controller.manualRefreshTasks[.provider(.codex)]) + #expect(!controller.menuSession.pendingViewportRestores.isEmpty) + + task.cancel() + gate.resume() + await task.value + + #expect(scheduledCount == 0) + #expect(controller.menuSession.pendingViewportRestores.isEmpty) + } + + @Test + func `viewport restore is a safe no-op without an attached menu window`() { + let settings = self.makeSettings() + settings.refreshFrequency = .manual + settings.mergeIcons = false + + let controller = self.makeController(settings: settings) + defer { controller.releaseStatusItemsForTesting() } + let menu = controller.makeMenu(for: .codex) + controller.menuWillOpen(menu) + defer { controller.menuDidClose(menu) } + + // Menu items exist but no view is hosted in a menu window, so the private + // scroll view cannot be resolved and the restore must bail out quietly. + #expect(StatusItemController.attachedMenuScrollView(in: menu) == nil) + controller.restoreMenuViewportToTop(menu) + } + + private func keyEvent(_ characters: String, keyCode: UInt16) throws -> NSEvent { + try #require(NSEvent.keyEvent( + with: .keyDown, + location: .zero, + modifierFlags: [.command], + timestamp: 0, + windowNumber: 0, + context: nil, + characters: characters, + charactersIgnoringModifiers: characters, + isARepeat: false, + keyCode: keyCode)) + } + + private func scrollEvent() throws -> NSEvent { + let event = CGEvent( + scrollWheelEvent2Source: nil, + units: .pixel, + wheelCount: 1, + wheel1: 30, + wheel2: 0, + wheel3: 0) + return try #require(event.flatMap(NSEvent.init(cgEvent:))) + } + + private func attachScrollableViewport(to menu: NSMenu) -> NSScrollView { + let scrollView = NSScrollView(frame: NSRect(x: 0, y: 0, width: 200, height: 100)) + let documentView = NSView(frame: NSRect(x: 0, y: 0, width: 200, height: 500)) + let hostedItemView = NSView(frame: NSRect(x: 0, y: 0, width: 200, height: 20)) + let item = NSMenuItem() + item.view = hostedItemView + menu.addItem(item) + scrollView.documentView = documentView + documentView.addSubview(hostedItemView) + scrollView.contentView.scroll(to: NSPoint(x: 0, y: 100)) + #expect(StatusItemController.attachedMenuScrollView(in: menu) === scrollView) + return scrollView + } + + private func enableOnly(_ providers: Set, settings: SettingsStore) { + for provider in UsageProvider.allCases { + guard let metadata = ProviderRegistry.shared.metadata[provider] else { continue } + settings.setProviderEnabled(provider: provider, metadata: metadata, enabled: providers.contains(provider)) + } + } + + private func runLoop(mode: CFRunLoopMode) { + CFRunLoopRunInMode(mode, 0.1, true) + } +} diff --git a/Tests/CodexBarTests/StatusProbeTests.swift b/Tests/CodexBarTests/StatusProbeTests.swift index dde1026a4b..3411ef975a 100644 --- a/Tests/CodexBarTests/StatusProbeTests.swift +++ b/Tests/CodexBarTests/StatusProbeTests.swift @@ -66,6 +66,37 @@ struct StatusProbeTests { #expect(snap.weeklyPercentLeft == 25) } + @Test + func `parse codex monthly credit limit`() throws { + let now = try #require( + Calendar(identifier: .gregorian).date(from: DateComponents( + timeZone: TimeZone.current, + year: 2026, + month: 6, + day: 23, + hour: 12, + minute: 0))) + let sample = """ + Model: codex-status-fixture + Monthly credit limit: [██████████████████░░] 92% left (resets 08:00 on 1 Jul) + 7,761 of 100,000 credits used + """ + + let snap = try CodexStatusProbe.parse(text: sample, now: now) + + #expect(snap.codexCreditLimit?.limit == 100_000) + #expect(snap.codexCreditLimit?.used == 7761) + #expect(snap.codexCreditLimit?.remaining == 92239) + #expect(snap.codexCreditLimit?.remainingPercent == 92) + #expect(snap.codexCreditLimit?.resetsAt == Calendar(identifier: .gregorian).date(from: DateComponents( + timeZone: TimeZone.current, + year: 2026, + month: 7, + day: 1, + hour: 8, + minute: 0))) + } + @Test func `parse claude status`() throws { let sample = """ @@ -439,7 +470,7 @@ struct StatusProbeTests { do { _ = try ClaudeStatusProbe.parse(text: sample) #expect(Bool(false), "Parsing should fail for auth error") - } catch let ClaudeStatusProbeError.parseFailed(message) { + } catch let ClaudeStatusProbeError.authenticationFailed(message) { let lower = message.lowercased() #expect(lower.contains("token")) #expect(lower.contains("login")) @@ -448,6 +479,32 @@ struct StatusProbeTests { } } + @Test + func `classifies Claude login failures separately from parser failures`() { + let failures = [ + (type: "error", message: "OAuth account information not found in config"), + (type: "error", message: "Your account does not have access to Claude Code. Please run /login"), + (type: "error", message: "API Error: 401"), + (type: "permission_error", message: "API Error: 403"), + (type: "error", message: "Claude CLI token expired. Run `claude login` to refresh."), + ] + + for failure in failures { + let sample = """ + Error: Failed to load usage data: \ + {"error":{"type":"\(failure.type)","message":"\(failure.message)"}} + """ + do { + _ = try ClaudeStatusProbe.parse(text: sample) + Issue.record("Expected authentication failure for: \(failure.message)") + } catch ClaudeStatusProbeError.authenticationFailed { + continue + } catch { + Issue.record("Unexpected error for \(failure.message): \(error)") + } + } + } + @Test func `surfaces claude rate limited compact usage error`() { let sample = """ @@ -533,6 +590,13 @@ struct StatusProbeTests { let lower = message.lowercased() #expect(lower.contains("subscription")) #expect(!lower.contains("still loading")) + #expect(ClaudeStatusProbe.isSubscriptionQuotaUnavailableDescription(message)) + + let errorDescription = ClaudeStatusProbeError.parseFailed(message).localizedDescription + #expect(UsageLimitsAvailability.resolve( + provider: .claude, + snapshot: nil, + lastErrorDescription: errorDescription) == .unavailable) } catch { #expect(Bool(false), "Unexpected error: \(error)") } @@ -592,17 +656,40 @@ struct StatusProbeTests { } @Test - func `parses claude reset time only`() throws { - let now = Date(timeIntervalSince1970: 1_733_690_000) - let parsed = ClaudeStatusProbe.parseResetDate(from: "Resets 12:59pm (Europe/Helsinki)", now: now) - let tz = try #require(TimeZone(identifier: "Europe/Helsinki")) + func `uses the five hour window to resolve stale claude reset times`() throws { var calendar = Calendar(identifier: .gregorian) - calendar.timeZone = tz - var expected = try #require(calendar.date(bySettingHour: 12, minute: 59, second: 0, of: now)) - if expected < now { - expected = try #require(calendar.date(byAdding: .day, value: 1, to: expected)) + calendar.timeZone = try #require(TimeZone(identifier: "UTC")) + let cases: [(now: DateComponents, text: String, expected: DateComponents)] = [ + ( + DateComponents(year: 2026, month: 7, day: 9, hour: 15), + "Resets 3pm (UTC)", + DateComponents(year: 2026, month: 7, day: 9, hour: 15)), + ( + DateComponents(year: 2026, month: 7, day: 9, hour: 15, minute: 5), + "Resets 3pm (UTC)", + DateComponents(year: 2026, month: 7, day: 9, hour: 15, minute: 0)), + ( + DateComponents(year: 2026, month: 7, day: 9, hour: 20), + "Resets 3pm (UTC)", + DateComponents(year: 2026, month: 7, day: 9, hour: 15)), + ( + DateComponents(year: 2026, month: 7, day: 9, hour: 23, minute: 59), + "Resets 12:01am (UTC)", + DateComponents(year: 2026, month: 7, day: 10, hour: 0, minute: 1)), + ( + DateComponents(year: 2026, month: 7, day: 10, hour: 0, minute: 1), + "Resets 11:59pm (UTC)", + DateComponents(year: 2026, month: 7, day: 9, hour: 23, minute: 59)), + ] + + for item in cases { + let now = try #require(calendar.date(from: item.now)) + let parsed = ClaudeStatusProbe.parseResetDate( + from: item.text, + now: now, + expectedWindow: 5 * 60 * 60) + #expect(parsed == calendar.date(from: item.expected), "Failed session-window resolution: \(item.text)") } - #expect(parsed == expected) } @Test @@ -621,6 +708,89 @@ struct StatusProbeTests { #expect(parsed == expected) } + @Test + func `uses the weekly window to resolve stale claude reset dates`() throws { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = try #require(TimeZone(identifier: "UTC")) + let cases: [(now: DateComponents, text: String, expected: DateComponents)] = [ + ( + DateComponents(year: 2026, month: 12, day: 31, hour: 23), + "Resets Jan 2, 3:15am (UTC)", + DateComponents(year: 2027, month: 1, day: 2, hour: 3, minute: 15)), + ( + DateComponents(year: 2026, month: 12, day: 31, hour: 23), + "Resets Jan 2, 3am (UTC)", + DateComponents(year: 2027, month: 1, day: 2, hour: 3, minute: 0)), + ( + DateComponents(year: 2027, month: 1, day: 1, hour: 0, minute: 5), + "Resets Dec 31, 11:59pm (UTC)", + DateComponents(year: 2026, month: 12, day: 31, hour: 23, minute: 59)), + ( + DateComponents(year: 2027, month: 1, day: 1, hour: 0, minute: 5), + "Resets Dec 31, 11pm (UTC)", + DateComponents(year: 2026, month: 12, day: 31, hour: 23, minute: 0)), + ( + DateComponents(year: 2026, month: 7, day: 9, hour: 15, minute: 5), + "Resets Jul 9, 3:00pm (UTC)", + DateComponents(year: 2026, month: 7, day: 9, hour: 15, minute: 0)), + ] + + for item in cases { + let now = try #require(calendar.date(from: item.now)) + let parsed = ClaudeStatusProbe.parseResetDate( + from: item.text, + now: now, + expectedWindow: 7 * 24 * 60 * 60) + #expect(parsed == calendar.date(from: item.expected), "Failed weekly-window resolution: \(item.text)") + } + } + + @Test + func `public claude reset parser remains forward looking`() throws { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = try #require(TimeZone(identifier: "UTC")) + let cases: [(now: DateComponents, text: String, expected: DateComponents)] = [ + ( + DateComponents(year: 2026, month: 7, day: 9, hour: 8), + "Resets 9pm (UTC)", + DateComponents(year: 2026, month: 7, day: 9, hour: 21)), + ( + DateComponents(year: 2026, month: 7, day: 9, hour: 20), + "Resets 1pm (UTC)", + DateComponents(year: 2026, month: 7, day: 10, hour: 13)), + ( + DateComponents(year: 2026, month: 7, day: 9, hour: 12), + "Resets Jul 1, 9am (UTC)", + DateComponents(year: 2027, month: 7, day: 1, hour: 9)), + ] + + for item in cases { + let now = try #require(calendar.date(from: item.now)) + let parsed = ClaudeStatusProbe.parseResetDate(from: item.text, now: now) + #expect(parsed == calendar.date(from: item.expected), "Failed future resolution: \(item.text)") + } + } + + @Test + func `stale same day claude reset renders resets now`() throws { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = try #require(TimeZone(identifier: "UTC")) + let now = try #require(calendar.date(from: DateComponents( + year: 2026, month: 7, day: 9, hour: 15, minute: 5, second: 0))) + let resetText = "Resets Jul 9, 3:00pm (UTC)" + let resetDate = try #require(ClaudeStatusProbe.parseResetDate( + from: resetText, + now: now, + expectedWindow: 5 * 60 * 60)) + let window = RateWindow( + usedPercent: 73, + windowMinutes: 5 * 60, + resetsAt: resetDate, + resetDescription: resetText) + + #expect(UsageFormatter.resetLine(for: window, style: .countdown, now: now) == "Resets now") + } + @Test func `parses claude reset with dot separated time`() throws { let now = Date(timeIntervalSince1970: 1_733_690_000) @@ -637,10 +807,8 @@ struct StatusProbeTests { let parsedTimeOnly = ClaudeStatusProbe.parseResetDate(from: "Resets 1pm (UTC)", now: now) var calendar = Calendar(identifier: .gregorian) calendar.timeZone = try #require(TimeZone(identifier: "UTC")) - var expected = try #require(calendar.date(bySettingHour: 13, minute: 0, second: 0, of: now)) - if expected < now { - expected = try #require(calendar.date(byAdding: .day, value: 1, to: expected)) - } + let sameDay = try #require(calendar.date(bySettingHour: 13, minute: 0, second: 0, of: now)) + let expected = try #require(calendar.date(byAdding: .day, value: 1, to: sameDay)) #expect(parsedTimeOnly == expected) let parsedDateTime = ClaudeStatusProbe.parseResetDate(from: "Resets Dec 9, 9am", now: now) @@ -698,3 +866,22 @@ struct StatusProbeTests { } } } + +struct ClaudeUsageErrorClassificationTests { + @Test + func `ignores authentication words outside the usage error`() { + let sample = """ + Hook warning: forbidden command skipped + Error: Failed to load usage data: Session quota fields were unavailable + """ + + do { + _ = try ClaudeStatusProbe.parse(text: sample) + Issue.record("Expected parser failure") + } catch ClaudeStatusProbeError.parseFailed { + // Expected: unrelated hook output must not turn a transient parse failure into auth loss. + } catch { + Issue.record("Unexpected error: \(error)") + } + } +} diff --git a/Tests/CodexBarTests/StatuspageSummaryTests.swift b/Tests/CodexBarTests/StatuspageSummaryTests.swift new file mode 100644 index 0000000000..dd49f55638 --- /dev/null +++ b/Tests/CodexBarTests/StatuspageSummaryTests.swift @@ -0,0 +1,208 @@ +import Foundation +import Testing +@testable import CodexBar +@testable import CodexBarCore + +@MainActor +struct StatuspageSummaryTests { + @Test + func `parse statuspage status decodes overall indicator`() throws { + let data = Data(#""" + { + "page": {"updated_at": "2026-06-18T19:41:22Z"}, + "status": {"indicator": "minor", "description": "Partial System Degradation"} + } + """#.utf8) + + let status = try UsageStore.parseStatuspageStatus(data: data) + #expect(status.indicator == .minor) + #expect(status.description == "Partial System Degradation") + #expect(status.updatedAt != nil) + } + + @Test + func `parse statuspage components maps and sorts leaf rows`() throws { + // Mirrors components.json, which includes unlisted rows such as FedRAMP. + let data = Data(#""" + { + "components": [ + {"id": "c-cli", "name": "CLI", "status": "operational", "position": 2}, + {"id": "c-api", "name": "Codex API", "status": "major_outage", "position": 1}, + {"id": "c-fed", "name": "FedRAMP", "status": "degraded_performance", "position": 25} + ] + } + """#.utf8) + + let components = try UsageStore.parseStatuspageComponents(data: data) + + #expect(components.map(\.name) == ["Codex API", "CLI", "FedRAMP"]) + #expect(components.map(\.indicator) == [.critical, .none, .minor]) + #expect(components.allSatisfy { !$0.isGroup }) + #expect(components.last?.status == "degraded_performance") + #expect(components.last?.statusLabel == L("status_degraded")) + } + + @Test + func `parse statuspage components nests children under their group`() throws { + let data = Data(#""" + { + "components": [ + {"id": "g1", "name": "API", "status": "degraded_performance", "group": true, "position": 0}, + {"id": "c-resp", "name": "Responses", "status": "operational", "group_id": "g1", "position": 2}, + {"id": "c-chat", "name": "Chat Completions", "status": "major_outage", "group_id": "g1", "position": 1}, + {"id": "c-cli", "name": "CLI", "status": "operational", "position": 3} + ] + } + """#.utf8) + + let components = try UsageStore.parseStatuspageComponents(data: data) + + // Top level: the group followed by the ungrouped leaf. Children are not promoted. + #expect(components.map(\.name) == ["API", "CLI"]) + + let group = try #require(components.first) + #expect(group.isGroup) + #expect(group.indicator == .minor) // group's own status (degraded) + // Children appear inside the group, sorted by position. + #expect(group.children.map(\.name) == ["Chat Completions", "Responses"]) + #expect(group.children.map(\.indicator) == [.critical, .none]) + + #expect(components[1].isGroup == false) + } + + @Test + func `parse statuspage components tolerates missing components`() throws { + let components = try UsageStore.parseStatuspageComponents(data: Data("{}".utf8)) + #expect(components.isEmpty) + } + + @Test + func `parse statuspage components drops blank names`() throws { + let data = Data(#""" + { + "components": [ + {"id": "blank", "name": " ", "status": "operational", "position": 1}, + {"id": "api", "name": " API ", "status": "operational", "position": 2} + ] + } + """#.utf8) + + let components = try UsageStore.parseStatuspageComponents(data: data) + + #expect(components.map(\.name) == ["API"]) + } + + @Test + func `fetchStatusSummary returns status with empty components when component feed fails`() async throws { + let summaryJSON = Data(#""" + { + "page": {"updated_at": "2026-06-18T19:41:22Z"}, + "status": {"indicator": "minor", "description": "Partial Outage"} + } + """#.utf8) + + let stub = ProviderHTTPTransportStub { request in + guard let path = request.url?.path else { throw URLError(.badURL) } + if path.hasSuffix("summary.json") { + let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)! + return (summaryJSON, response) + } + throw URLError(.notConnectedToInternet) + } + + let baseURL = try #require(URL(string: "https://status.example.test")) + let result = try await UsageStore.fetchStatusSummary(from: baseURL, transport: stub) + + #expect(result.status.indicator == .minor) + #expect(result.status.description == "Partial Outage") + #expect(result.components == nil) + } + + @Test + func `fetchStatusSummary overlays description and updatedAt when incident io succeeds`() async throws { + let proxyJSON = Data(#""" + { + "summary": { + "affected_components": [{"component_id": "c-api", "status": "degraded_performance"}], + "structure": {"items": [ + {"component": {"component_id": "c-api", "name": "API", "hidden": false}} + ]} + } + } + """#.utf8) + + let statusJSON = Data(#""" + { + "page": {"updated_at": "2026-06-20T10:00:00Z"}, + "status": {"indicator": "minor", "description": "Elevated error rates"} + } + """#.utf8) + + let stub = ProviderHTTPTransportStub { request in + guard let url = request.url else { throw URLError(.badURL) } + let ok = HTTPURLResponse(url: url, statusCode: 200, httpVersion: nil, headerFields: nil)! + if url.path.contains("/proxy/") { return (proxyJSON, ok) } + if url.path.hasSuffix("status.json") { return (statusJSON, ok) } + throw URLError(.notConnectedToInternet) + } + + let baseURL = try #require(URL(string: "https://status.example.test")) + let result = try await UsageStore.fetchStatusSummary(from: baseURL, transport: stub) + + #expect(result.status.indicator == .minor) + #expect(result.status.description == "Elevated error rates") + #expect(result.status.updatedAt != nil) + #expect(result.components?.map(\.name) == ["API"]) + } + + @Test + func `parse incident io summary builds groups with aggregated status`() throws { + // Shaped like status.openai.com/proxy/status.openai.com. + let data = Data(#""" + { + "summary": { + "affected_components": [ + {"component_id": "c-fed", "status": "degraded_performance"}, + {"component_id": "c-top", "status": "full_outage"} + ], + "structure": { + "items": [ + {"group": {"id": "g-codex", "name": "Codex", "hidden": false, "components": [ + {"component_id": "c-cli", "name": "CLI", "hidden": false}, + {"component_id": "c-web", "name": "Codex Web", "hidden": false}, + {"component_id": "c-secret", "name": "Hidden", "hidden": true} + ]}}, + {"group": {"id": "g-fed", "name": "FedRAMP", "hidden": false, "components": [ + {"component_id": "c-fed", "name": "FedRAMP", "hidden": false} + ]}}, + {"component": {"component_id": "c-top", "name": "Standalone", "hidden": false}} + ] + } + } + } + """#.utf8) + + let result = try UsageStore.parseIncidentIOSummary(data: data) + + #expect(result.components.map(\.name) == ["Codex", "FedRAMP", "Standalone"]) + + let codex = result.components[0] + #expect(codex.isGroup) + #expect(codex.indicator == .none) // all children operational + #expect(codex.children.map(\.name) == ["CLI", "Codex Web"]) // hidden child dropped + + let fedramp = result.components[1] + #expect(fedramp.isGroup) + #expect(fedramp.indicator == .minor) // aggregates the degraded child + #expect(fedramp.status == "degraded_performance") + #expect(fedramp.statusLabel == L("status_degraded")) + + #expect(result.components[2].isGroup == false) // standalone component + #expect(result.components[2].indicator == .critical) + #expect(result.components[2].status == "full_outage") + #expect(result.components[2].statusLabel == L("status_major_outage")) + + // Overall page status reflects the worst leaf (standalone full outage). + #expect(result.status.indicator == .critical) + } +} diff --git a/Tests/CodexBarTests/StepFunUsageFetcherTests.swift b/Tests/CodexBarTests/StepFunUsageFetcherTests.swift index 2c1ee8503a..888eb856b9 100644 --- a/Tests/CodexBarTests/StepFunUsageFetcherTests.swift +++ b/Tests/CodexBarTests/StepFunUsageFetcherTests.swift @@ -206,6 +206,264 @@ struct StepFunUsageFetcherParsingTests { // 100% remaining → 0% used (integer 1 parsed as 1.0) #expect(usage.secondary?.usedPercent == 0.0) } + + // MARK: - Credit-plan parsing + + @Test + func `parses credit-plan response and maps credit as primary window`() throws { + // Real StepFun Mini-plan response: plan_family=2 with credit data. + // The 5h/weekly rate fields are 0 (no rate-limit window for credit plans). + let json = """ + { + "status": 1, + "five_hour_usage_left_rate": 0, + "five_hour_usage_reset_time": "0", + "weekly_usage_left_rate": 0, + "weekly_usage_reset_time": "0", + "plan_family": 2, + "plan_credit_rate_limit": { + "subscription_credit_left_rate": 0.9641096, + "subscription_credit_reset_time": "1786288293", + "topup_credit_left_rate": 0, + "credit_buckets": [ + { + "type": 1, + "credit_total": "400000000", + "credit_residual": "385643853", + "expire_at": "1792416128", + "next_reset_at": "1786288293" + } + ] + } + } + """ + let data = Data(json.utf8) + let snapshot = try StepFunUsageFetcher._parseSnapshotForTesting(data) + + #expect(snapshot.isCreditPlan == true) + #expect(snapshot.creditLeftRate ?? 0 > 0.96) + let usage = snapshot.toUsageSnapshot() + + // Credit balance → primary window: ~3.6% used (1 - 0.9641) + let primaryUsed = usage.primary?.usedPercent ?? -1 + #expect(primaryUsed > 3.5 && primaryUsed < 3.7) + + // No secondary window for credit plans. + #expect(usage.secondary == nil) + } + + @Test + func `does not treat rate-window plan as credit plan`() throws { + // plan_family absent → classic rate-window plan, unchanged behavior. + let json = """ + { + "status": 1, + "five_hour_usage_left_rate": 0.8, + "weekly_usage_left_rate": 0.6, + "five_hour_usage_reset_time": "1746000000", + "weekly_usage_reset_time": "1746500000" + } + """ + let data = Data(json.utf8) + let snapshot = try StepFunUsageFetcher._parseSnapshotForTesting(data) + + #expect(snapshot.isCreditPlan == false) + let usage = snapshot.toUsageSnapshot() + #expect(usage.primary?.usedPercent ?? 0 > 19.9 && usage.primary?.usedPercent ?? 0 < 20.1) + #expect(usage.secondary?.usedPercent ?? 0 > 39.9 && usage.secondary?.usedPercent ?? 0 < 40.1) + } + + @Test + func `uses credit buckets when explicit rate is absent`() throws { + // No subscription_credit_left_rate, but buckets provide residual/total. + let json = """ + { + "status": 1, + "five_hour_usage_left_rate": 0, + "five_hour_usage_reset_time": "0", + "weekly_usage_left_rate": 0, + "weekly_usage_reset_time": "0", + "plan_family": 2, + "plan_credit_rate_limit": { + "subscription_credit_reset_time": "1786288293", + "credit_buckets": [ + { + "credit_total": "1000", + "credit_residual": "750", + "expire_at": "1792416128", + "next_reset_at": "1786288293" + } + ] + } + } + """ + let data = Data(json.utf8) + let snapshot = try StepFunUsageFetcher._parseSnapshotForTesting(data) + + #expect(snapshot.isCreditPlan == true) + // 750/1000 = 0.75 remaining + #expect(snapshot.creditLeftRate ?? 0 > 0.749 && snapshot.creditLeftRate ?? 0 < 0.751) + let usage = snapshot.toUsageSnapshot() + // 25% used + #expect(usage.primary?.usedPercent ?? -1 > 24.9 && usage.primary?.usedPercent ?? -1 < 25.1) + } + + @Test + func `live rolling windows win over a credit-family id`() throws { + // Robustness across the 2026-06-18 Coding Plan → Token Plan split: the + // grandfathered Coding Plan meters live 5h/weekly windows. A live window must + // route to the rolling-window renderer even if the same payload also reports + // plan_family=2, so a stale/changed family id can never send a windowed plan + // to the credit renderer (which would drop the real windows and show a bogus + // credit balance). + let json = """ + { + "status": 1, + "five_hour_usage_left_rate": 0.8, + "five_hour_usage_reset_time": "1746000000", + "weekly_usage_left_rate": 0.6, + "weekly_usage_reset_time": "1746500000", + "plan_family": 2, + "plan_credit_rate_limit": { "subscription_credit_left_rate": 1, "credit_buckets": [] } + } + """ + let snapshot = try StepFunUsageFetcher._parseSnapshotForTesting(Data(json.utf8)) + + #expect(snapshot.isCreditPlan == false) + let usage = snapshot.toUsageSnapshot() + #expect(usage.primary?.windowMinutes == 300) + #expect(usage.secondary?.windowMinutes == 10080) + } + + @Test + func `falls back to the credit-family id only when the payload is otherwise ambiguous`() throws { + // A brand-new plan with neither a live window nor a credit pool yet leaves + // plan_family as the only signal: 2 == Token Plan (Credit family), else Coding. + let creditFamily = """ + {"status":1,"five_hour_usage_left_rate":0,"five_hour_usage_reset_time":"0",\ + "weekly_usage_left_rate":0,"weekly_usage_reset_time":"0","plan_family":2} + """ + let windowFamily = """ + {"status":1,"five_hour_usage_left_rate":0,"five_hour_usage_reset_time":"0",\ + "weekly_usage_left_rate":0,"weekly_usage_reset_time":"0","plan_family":1} + """ + #expect(try StepFunUsageFetcher._parseSnapshotForTesting(Data(creditFamily.utf8)).isCreditPlan == true) + #expect(try StepFunUsageFetcher._parseSnapshotForTesting(Data(windowFamily.utf8)).isCreditPlan == false) + } + + @Test + func `classifies exhausted zero-credit pool without a family id`() throws { + let json = """ + { + "status": 1, + "five_hour_usage_left_rate": 0, + "five_hour_usage_reset_time": "0", + "weekly_usage_left_rate": 0, + "weekly_usage_reset_time": "0", + "plan_credit_rate_limit": { + "subscription_credit_left_rate": 0, + "subscription_credit_reset_time": "1786288293" + } + } + """ + let snapshot = try StepFunUsageFetcher._parseSnapshotForTesting(Data(json.utf8)) + + #expect(snapshot.isCreditPlan == true) + #expect(snapshot.creditLeftRate == 0) + let usage = snapshot.toUsageSnapshot() + #expect(usage.primary?.usedPercent == 100) + #expect(usage.secondary == nil) + } + + @Test + func `classifies zero-credit pool when only top-up field is present`() throws { + let json = """ + { + "status": 1, + "five_hour_usage_left_rate": 0, + "five_hour_usage_reset_time": "0", + "weekly_usage_left_rate": 0, + "weekly_usage_reset_time": "0", + "plan_credit_rate_limit": { + "topup_credit_left_rate": 0 + } + } + """ + let snapshot = try StepFunUsageFetcher._parseSnapshotForTesting(Data(json.utf8)) + + #expect(snapshot.isCreditPlan == true) + #expect(snapshot.creditLeftRate == 0) + let usage = snapshot.toUsageSnapshot() + #expect(usage.primary?.usedPercent == 100) + #expect(usage.secondary == nil) + } + + @Test + func `weights mixed subscription and top-up credit buckets`() throws { + let json = """ + { + "status": 1, + "plan_family": 2, + "plan_credit_rate_limit": { + "subscription_credit_left_rate": 0.8, + "topup_credit_left_rate": 0.5, + "credit_buckets": [ + { "credit_total": "100", "credit_residual": "80" }, + { "credit_total": "300", "credit_residual": "150" } + ] + } + } + """ + let snapshot = try StepFunUsageFetcher._parseSnapshotForTesting(Data(json.utf8)) + + // The independent rates sum to 1.3, but the weighted balance is + // (80 + 150) / (100 + 300) = 0.575 remaining, or 42.5% used. + #expect(snapshot.creditLeftRate == 0.575) + #expect(abs((snapshot.toUsageSnapshot().primary?.usedPercent ?? 0) - 42.5) < 0.0001) + } + + @Test + func `falls back to subscription rate for incomplete credit buckets`() throws { + let json = """ + { + "status": 1, + "plan_family": 2, + "plan_credit_rate_limit": { + "subscription_credit_left_rate": 0.6, + "topup_credit_left_rate": 0.4, + "credit_buckets": [ + { "credit_total": "100" } + ] + } + } + """ + let snapshot = try StepFunUsageFetcher._parseSnapshotForTesting(Data(json.utf8)) + + #expect(snapshot.creditLeftRate == 0.6) + #expect(snapshot.toUsageSnapshot().primary?.usedPercent == 40) + } + + @Test + func `credit plan does not throw when rate fields are missing`() throws { + // A credit-plan response might omit the rate-window fields entirely. + let json = """ + { + "status": 1, + "plan_family": 2, + "plan_credit_rate_limit": { + "subscription_credit_left_rate": 0.5, + "subscription_credit_reset_time": "1786288293" + } + } + """ + let data = Data(json.utf8) + let snapshot = try StepFunUsageFetcher._parseSnapshotForTesting(data) + + #expect(snapshot.isCreditPlan == true) + let usage = snapshot.toUsageSnapshot() + #expect(usage.primary?.usedPercent == 50.0) + #expect(usage.secondary == nil) + } } struct StepFunTokenNormalizerTests { @@ -530,6 +788,59 @@ struct StepFunTokenRefreshTests { } } + @Test + func `password login matches web ID to registered device`() async throws { + let registeredDeviceID = "registered-device" + let registeredJWT = try Self.jwt(deviceID: registeredDeviceID) + let anonymousPair = "anon-access...\(registeredJWT)" + + try await self.withStubProtocol { _ in + StepFunStubURLProtocol.handler = { request in + let path = request.url?.path ?? "" + if path.isEmpty || path == "/" { + return Self.jsonResponse( + for: request, + body: "{}", + headers: ["Set-Cookie": "INGRESSCOOKIE=ingress-cookie; Path=/"]) + } + + if path.contains("RegisterDevice") { + return Self.jsonResponse( + for: request, + body: """ + { + "accessToken": {"raw": "anon-access"}, + "refreshToken": {"raw": "\(registeredJWT)"} + } + """) + } + + if path.contains("SignInByPassword") { + #expect(request.value(forHTTPHeaderField: "oasis-webid") == registeredDeviceID) + #expect(request.value(forHTTPHeaderField: "Cookie") == + "Oasis-Token=\(anonymousPair); " + + "Oasis-Webid=\(registeredDeviceID); " + + "INGRESSCOOKIE=ingress-cookie") + return Self.jsonResponse( + for: request, + body: """ + { + "accessToken": {"raw": "login-access"}, + "refreshToken": {"raw": "login-refresh"} + } + """) + } + + return Self.jsonResponse(for: request, statusCode: 404, body: #"{"error":"unexpected"}"#) + } + + let authenticatedPair = try await StepFunUsageFetcher.login( + username: "user@example.com", + password: "pw") + #expect(authenticatedPair == "login-access...login-refresh") + } + } + @Test func `post refresh non auth usage failure is not rewritten as auth guidance`() async throws { try await self.withStubProtocol { recorder in @@ -710,6 +1021,15 @@ struct StepFunTokenRefreshTests { """) } + private static func jwt(deviceID: String) throws -> String { + let payload = try JSONSerialization.data(withJSONObject: ["device_id": deviceID]) + let encodedPayload = payload.base64EncodedString() + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") + return "header.\(encodedPayload).signature" + } + private static func jsonResponse( for request: URLRequest, statusCode: Int = 200, @@ -769,7 +1089,11 @@ private final class StepFunRequestRecorder: @unchecked Sendable { } private final class StepFunStubURLProtocol: URLProtocol { - nonisolated(unsafe) static var handler: (@Sendable (URLRequest) throws -> (HTTPURLResponse, Data))? + private static let _handlerBox = LockIsolated<(@Sendable (URLRequest) throws -> (HTTPURLResponse, Data))?>(nil) + static var handler: (@Sendable (URLRequest) throws -> (HTTPURLResponse, Data))? { + get { Self._handlerBox.value } + set { Self._handlerBox.setValue(newValue) } + } override static func canInit(with request: URLRequest) -> Bool { request.url?.host == "platform.stepfun.com" diff --git a/Tests/CodexBarTests/StorageBreakdownSegmentTests.swift b/Tests/CodexBarTests/StorageBreakdownSegmentTests.swift new file mode 100644 index 0000000000..3198b53a64 --- /dev/null +++ b/Tests/CodexBarTests/StorageBreakdownSegmentTests.swift @@ -0,0 +1,112 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +struct StorageBreakdownSegmentTests { + @Test @MainActor + func `folds overflow into eighth segment without losing paths`() { + let components = (1...10).map { index in + ProviderStorageFootprint.Component(path: "/tmp/item-\(index)", totalBytes: Int64(index)) + } + let view = StorageBreakdownMenuView( + footprint: Self.footprint(components: components), + width: 310) + + #expect(view._segmentNamesForTesting == [ + "item-1", "item-2", "item-3", "item-4", "item-5", "item-6", "item-7", "Other (3 items)", + ]) + #expect(view._segmentBytesForTesting == [1, 2, 3, 4, 5, 6, 7, 27]) + #expect(view._overflowNamesForTesting == ["item-8", "item-9", "item-10"]) + #expect(view._overflowExpansionHeightForTesting == 68) + #expect(view.copyablePaths == components.map(\.path)) + } + + @Test @MainActor + func `segment widths fill bar and keep tiny values visible`() { + let components = [ + ProviderStorageFootprint.Component(path: "/tmp/large", totalBytes: 1_000_000), + ProviderStorageFootprint.Component(path: "/tmp/tiny", totalBytes: 1), + ProviderStorageFootprint.Component(path: "/tmp/zero", totalBytes: 0), + ] + let view = StorageBreakdownMenuView( + footprint: Self.footprint(components: components), + width: 310) + let widths = view._segmentWidthsForTesting(barWidth: 100) + + #expect(widths.count == 3) + #expect(abs(widths.reduce(0, +) - 100) < 0.001) + #expect(widths.allSatisfy { $0 >= 2 }) + } + + @Test @MainActor + func `narrow bar divides width without overflow`() { + let components = (1...8).map { index in + ProviderStorageFootprint.Component(path: "/tmp/item-\(index)", totalBytes: 1) + } + let view = StorageBreakdownMenuView( + footprint: Self.footprint(components: components), + width: 310) + let widths = view._segmentWidthsForTesting(barWidth: 8) + + #expect(widths == Array(repeating: 1, count: 8)) + #expect(widths.reduce(0, +) == 8) + } + + @Test @MainActor + func `zero byte components evenly fill bar`() { + let components = (1...4).map { index in + ProviderStorageFootprint.Component(path: "/tmp/item-\(index)", totalBytes: 0) + } + let view = StorageBreakdownMenuView( + footprint: Self.footprint(components: components), + width: 310) + + #expect(view._segmentWidthsForTesting(barWidth: 100) == [25, 25, 25, 25]) + } + + @Test @MainActor + func `negative component sizes clamp to zero`() { + let components = [ + ProviderStorageFootprint.Component(path: "/tmp/negative", totalBytes: -10), + ProviderStorageFootprint.Component(path: "/tmp/positive", totalBytes: 10), + ] + let view = StorageBreakdownMenuView( + footprint: Self.footprint(components: components), + width: 310) + + #expect(view._segmentBytesForTesting == [0, 10]) + #expect(view._segmentWidthsForTesting(barWidth: 100).allSatisfy { $0 >= 2 }) + } + + @Test @MainActor + func `extreme component sizes still fill exactly one bar`() { + let components = [ + ProviderStorageFootprint.Component(path: "/tmp/first", totalBytes: .max), + ProviderStorageFootprint.Component(path: "/tmp/second", totalBytes: .max), + ] + let view = StorageBreakdownMenuView( + footprint: Self.footprint(components: components), + width: 310) + let widths = view._segmentWidthsForTesting(barWidth: 100) + + #expect(abs(widths.reduce(0, +) - 100) < 0.001) + #expect(widths == [50, 50]) + } + + private static func footprint( + components: [ProviderStorageFootprint.Component]) -> ProviderStorageFootprint + { + ProviderStorageFootprint( + provider: .claude, + totalBytes: components.reduce(Int64(0)) { partial, component in + let (sum, overflowed) = partial.addingReportingOverflow(max(component.totalBytes, 0)) + return overflowed ? .max : sum + }, + paths: components.map(\.path), + missingPaths: [], + unreadablePaths: [], + components: components, + updatedAt: Date(timeIntervalSince1970: 0)) + } +} diff --git a/Tests/CodexBarTests/StubURLProtocolHandlerConcurrencyTests.swift b/Tests/CodexBarTests/StubURLProtocolHandlerConcurrencyTests.swift new file mode 100644 index 0000000000..ce64d0deb5 --- /dev/null +++ b/Tests/CodexBarTests/StubURLProtocolHandlerConcurrencyTests.swift @@ -0,0 +1,37 @@ +import Foundation +import Testing + +/// Regression test for the `URLProtocol` test-stub `handler` data race fixed by backing each +/// stub's handler with a `LockIsolated` box. The stubs store their per-test handler in a static +/// that URLSession reads on a background thread while the test assigns it from another — a data +/// race under ThreadSanitizer. This hammers the real representative stub from +/// `ProviderHTTPClientTests` so the test covers the production declaration rather than a copy. +/// +/// Opt-in: it hammers a static thousands of times, so it is gated behind `CODEXBAR_TSAN_STRESS` and +/// run in isolation via `CODEXBAR_TSAN_STRESS=1 swift test --sanitize=thread --filter +/// StubURLProtocolHandlerConcurrencyTests`, never in the normal parallel suite. +@Suite(.serialized) +struct StubURLProtocolHandlerConcurrencyTests { + @Test(.enabled(if: ProcessInfo.processInfo.environment["CODEXBAR_TSAN_STRESS"] == "1")) + func `concurrent stub handler writes and reads are race-free`() { + let iterations = 5000 + let lanes = 4 + let group = DispatchGroup() + let queue = DispatchQueue(label: "stub-handler.concurrency", attributes: .concurrent) + for lane in 0.. Date { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = .current + return try #require(calendar.date(from: DateComponents( + calendar: calendar, + timeZone: calendar.timeZone, + year: year, + month: month, + day: day, + hour: 12))) + } +} diff --git a/Tests/CodexBarTests/SubprocessRunnerTests.swift b/Tests/CodexBarTests/SubprocessRunnerTests.swift index 9767b32216..4eb9184479 100644 --- a/Tests/CodexBarTests/SubprocessRunnerTests.swift +++ b/Tests/CodexBarTests/SubprocessRunnerTests.swift @@ -2,6 +2,12 @@ import Foundation import Testing @testable import CodexBarCore +#if canImport(Darwin) +import Darwin +#else +import Glibc +#endif + struct SubprocessRunnerTests { @Test func `reads large stdout without deadlock`() async throws { @@ -9,13 +15,153 @@ struct SubprocessRunnerTests { binary: "/usr/bin/python3", arguments: ["-c", "print('x' * 1_000_000)"], environment: ProcessInfo.processInfo.environment, - timeout: 5, + timeout: 15, label: "python large stdout") #expect(result.stdout.count >= 1_000_000) #expect(result.stderr.isEmpty) } + @Test + func `bounds oversized stdout while continuing to drain`() async throws { + let result = try await SubprocessRunner.run( + binary: "/usr/bin/python3", + arguments: ["-c", "print('x' * 2_000_000)"], + environment: ProcessInfo.processInfo.environment, + timeout: 5, + label: "python oversized stdout") + + #expect(result.stdout.utf8.count == ProcessPipeCapture.defaultMaxBytes) + #expect(result.stderr.isEmpty) + } + + @Test + func `rejects oversized output when strict limit is configured`() async throws { + do { + _ = try await SubprocessRunner.run( + binary: "/usr/bin/python3", + arguments: ["-c", "print('x' * 10_000)"], + environment: ProcessInfo.processInfo.environment, + timeout: 5, + maxOutputBytes: 1024, + label: "python strict output limit") + Issue.record("Expected strict output limit failure") + } catch let error as SubprocessRunnerError { + guard case let .outputTooLarge(label) = error else { + Issue.record("Expected outputTooLarge, got \(error)") + return + } + #expect(label == "python strict output limit") + } catch { + Issue.record("Expected SubprocessRunnerError, got \(error)") + } + } + + @Test + func `preserves captured prefix when limit splits three byte scalar`() async throws { + let asciiCount = ProcessPipeCapture.defaultMaxBytes - 1 + let script = "import sys; sys.stdout.buffer.write(b'x' * \(asciiCount) + bytes([0xe2, 0x82, 0xac]) + b'tail')" + let result = try await SubprocessRunner.run( + binary: "/usr/bin/python3", + arguments: ["-c", script], + environment: ProcessInfo.processInfo.environment, + timeout: 5, + label: "python split utf8 stdout") + + #expect(result.stdout.count == ProcessPipeCapture.defaultMaxBytes) + #expect(result.stdout.first == "x") + #expect(result.stdout.last == "\u{FFFD}") + #expect(result.stdout.utf8.count == ProcessPipeCapture.defaultMaxBytes + 2) + } + + @Test + func `bounds simultaneous oversized stdout and stderr while draining`() async throws { + let script = """ + import sys + chunk = 2048 + for _ in range(2000): + sys.stdout.write('o' * chunk) + sys.stdout.flush() + sys.stderr.write('e' * chunk) + sys.stderr.flush() + """ + let result = try await SubprocessRunner.run( + binary: "/usr/bin/python3", + arguments: ["-c", script], + environment: ProcessInfo.processInfo.environment, + timeout: 10, + label: "python simultaneous oversized output") + + #expect(result.stdout.utf8.count == ProcessPipeCapture.defaultMaxBytes) + #expect(result.stderr.utf8.count == ProcessPipeCapture.defaultMaxBytes) + } + + @Test + func `bounds oversized stderr on failure`() async throws { + do { + _ = try await SubprocessRunner.run( + binary: "/usr/bin/python3", + arguments: ["-c", "import sys; sys.stderr.write('e' * 2_000_000); sys.exit(7)"], + environment: ProcessInfo.processInfo.environment, + timeout: 5, + label: "python oversized stderr") + Issue.record("Expected non-zero exit") + } catch let error as SubprocessRunnerError { + guard case let .nonZeroExit(code, stderr) = error else { + Issue.record("Expected non-zero exit, got \(error)") + return + } + #expect(code == 7) + #expect(stderr.utf8.count == ProcessPipeCapture.defaultMaxBytes) + } catch { + Issue.record("Expected SubprocessRunnerError, got \(error)") + } + } + + @Test + func `returns partial output when detached child keeps pipes open`() async throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("codexbar-subprocess-drain-\(UUID().uuidString)", isDirectory: true) + let childPIDFile = root.appendingPathComponent("child.pid") + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + defer { + if let text = try? String(contentsOf: childPIDFile, encoding: .utf8), + let childPID = pid_t(text.trimmingCharacters(in: .whitespacesAndNewlines)) + { + _ = kill(childPID, SIGKILL) + } + } + + var environment = ProcessInfo.processInfo.environment + environment["CODEXBAR_TEST_CHILD_PID_FILE"] = childPIDFile.path + let script = """ + import os + import subprocess + import sys + + child = subprocess.Popen( + [sys.executable, "-c", "import time; time.sleep(5)"], + start_new_session=True, + ) + with open(os.environ["CODEXBAR_TEST_CHILD_PID_FILE"], "w") as handle: + handle.write(str(child.pid)) + print("parent-output", flush=True) + """ + + let start = Date() + let result = try await SubprocessRunner.run( + binary: "/usr/bin/python3", + arguments: ["-c", script], + environment: environment, + timeout: 5, + label: "detached-output-holder") + let elapsed = Date().timeIntervalSince(start) + + #expect(result.stdout.contains("parent-output")) + #expect(elapsed < 3, "Output drain should not wait for the detached child, took \(elapsed)s") + } + /// Regression test for #474: a hung subprocess must be killed and throw `.timedOut` /// instead of blocking indefinitely. /// @@ -48,6 +194,51 @@ struct SubprocessRunnerTests { #expect(elapsed < 3, "Timeout should fire in ~1s, not wait for process to exit naturally") } + @Test + func `timeout kills descendants that escape the process group`() async throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("codexbar-subprocess-tree-\(UUID().uuidString)", isDirectory: true) + let childPIDFile = root.appendingPathComponent("child.pid") + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + + var environment = ProcessInfo.processInfo.environment + environment["CODEXBAR_TEST_CHILD_PID_FILE"] = childPIDFile.path + let script = """ + import os + import subprocess + import sys + import time + + child = subprocess.Popen( + [sys.executable, "-c", "import time; time.sleep(30)"], + start_new_session=True, + ) + with open(os.environ["CODEXBAR_TEST_CHILD_PID_FILE"], "w") as handle: + handle.write(str(child.pid)) + time.sleep(30) + """ + + await #expect(throws: SubprocessRunnerError.self) { + try await SubprocessRunner.run( + binary: "/usr/bin/python3", + arguments: ["-c", script], + environment: environment, + timeout: 0.5, + label: "escaped-descendant") + } + + let text = try String(contentsOf: childPIDFile, encoding: .utf8) + let childPID = try #require(pid_t(text.trimmingCharacters(in: .whitespacesAndNewlines))) + defer { _ = kill(childPID, SIGKILL) } + + let deadline = Date().addingTimeInterval(1) + while kill(childPID, 0) == 0, Date() < deadline { + try await Task.sleep(for: .milliseconds(20)) + } + #expect(kill(childPID, 0) == -1) + } + /// Multiple concurrent hung subprocesses must all time out independently, proving that /// one blocked subprocess does not starve the timeout mechanism of others. /// This is the core scenario that caused the original permanent-refresh-stall bug. diff --git a/Tests/CodexBarTests/TTYCommandRunnerTests.swift b/Tests/CodexBarTests/TTYCommandRunnerTests.swift index d390406905..eb3f4b760d 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 @@ -23,51 +25,84 @@ struct TTYCommandRunnerEnvTests { @Test func `shutdown fence drains tracked TTY processes`() { - TTYCommandRunner._test_resetTrackedProcesses() - defer { TTYCommandRunner._test_resetTrackedProcesses() } + TTYCommandRunner.withIsolatedActiveProcessRegistryForTesting { + TTYCommandRunner._test_resetTrackedProcesses() + defer { TTYCommandRunner._test_resetTrackedProcesses() } - #expect(TTYCommandRunner._test_registerTrackedProcess(pid: 1001, binary: "codex")) - #expect(TTYCommandRunner._test_trackedProcessCount() == 1) + #expect(TTYCommandRunner._test_registerTrackedProcess(pid: 1001, binary: "codex")) + #expect(TTYCommandRunner._test_trackedProcessCount() == 1) - let drained = TTYCommandRunner._test_drainTrackedProcessesForShutdown() - #expect(drained.count == 1) - #expect(drained[0].pid == 1001) - #expect(TTYCommandRunner._test_trackedProcessCount() == 0) + let drained = TTYCommandRunner._test_drainTrackedProcessesForShutdown() + #expect(drained.count == 1) + #expect(drained[0].pid == 1001) + #expect(TTYCommandRunner._test_trackedProcessCount() == 0) + } } @Test func `cached CLI sessions share shutdown tracking`() { - TTYCommandRunner._test_resetTrackedProcesses() - defer { TTYCommandRunner._test_resetTrackedProcesses() } + TTYCommandRunner.withIsolatedActiveProcessRegistryForTesting { + TTYCommandRunner._test_resetTrackedProcesses() + defer { TTYCommandRunner._test_resetTrackedProcesses() } - #expect(TTYCommandRunner.registerActiveProcessForAppShutdown(pid: 3001, binary: "codex")) - TTYCommandRunner.updateActiveProcessGroupForAppShutdown(pid: 3001, processGroup: 3001) - #expect(TTYCommandRunner._test_trackedProcessCount() == 1) + #expect(TTYCommandRunner.registerActiveProcessForAppShutdown(pid: 3001, binary: "codex")) + TTYCommandRunner.updateActiveProcessGroupForAppShutdown(pid: 3001, processGroup: 3001) + #expect(TTYCommandRunner._test_trackedProcessCount() == 1) - TTYCommandRunner.unregisterActiveProcessForAppShutdown(pid: 3001) - #expect(TTYCommandRunner._test_trackedProcessCount() == 0) + TTYCommandRunner.unregisterActiveProcessForAppShutdown(pid: 3001) + #expect(TTYCommandRunner._test_trackedProcessCount() == 0) + } } @Test func `tracked process helpers ignore invalid PID`() { - TTYCommandRunner._test_resetTrackedProcesses() - defer { TTYCommandRunner._test_resetTrackedProcesses() } + TTYCommandRunner.withIsolatedActiveProcessRegistryForTesting { + TTYCommandRunner._test_resetTrackedProcesses() + defer { TTYCommandRunner._test_resetTrackedProcesses() } - TTYCommandRunner._test_trackProcess(pid: 0, binary: "codex", processGroup: nil) - #expect(TTYCommandRunner._test_trackedProcessCount() == 0) + TTYCommandRunner._test_trackProcess(pid: 0, binary: "codex", processGroup: nil) + #expect(TTYCommandRunner._test_trackedProcessCount() == 0) + } } @Test func `shutdown fence rejects new registrations`() { - TTYCommandRunner._test_resetTrackedProcesses() - defer { TTYCommandRunner._test_resetTrackedProcesses() } + TTYCommandRunner.withIsolatedActiveProcessRegistryForTesting { + TTYCommandRunner._test_resetTrackedProcesses() + defer { TTYCommandRunner._test_resetTrackedProcesses() } - #expect(TTYCommandRunner._test_registerTrackedProcess(pid: 2001, binary: "codex")) - let drained = TTYCommandRunner._test_drainTrackedProcessesForShutdown() - #expect(drained.count == 1) + #expect(TTYCommandRunner._test_registerTrackedProcess(pid: 2001, binary: "codex")) + let drained = TTYCommandRunner._test_drainTrackedProcessesForShutdown() + #expect(drained.count == 1) - #expect(TTYCommandRunner._test_registerTrackedProcess(pid: 2002, binary: "codex") == false) - #expect(TTYCommandRunner._test_trackedProcessCount() == 0) + #expect(TTYCommandRunner._test_registerTrackedProcess(pid: 2002, binary: "codex") == false) + #expect(TTYCommandRunner._test_trackedProcessCount() == 0) + } + } + + @Test + func `shutdown waits for launch cleanup before draining`() { + TTYCommandRunner.withIsolatedActiveProcessRegistryForTesting { + TTYCommandRunner._test_resetTrackedProcesses() + defer { TTYCommandRunner._test_resetTrackedProcesses() } + + #expect(TTYCommandRunner._test_beginTrackedProcessLaunch()) + let fenceSet = DispatchSemaphore(value: 0) + let completed = DispatchSemaphore(value: 0) + let drain = TTYCommandRunner._test_makeDrainTrackedProcessesForShutdownOperation { + fenceSet.signal() + } + Thread.detachNewThread { + _ = drain() + completed.signal() + } + + #expect(fenceSet.wait(timeout: .now() + 1) == .success) + #expect(completed.wait(timeout: .now() + 0.05) == .timedOut) + #expect(!TTYCommandRunner._test_registerTrackedProcess(pid: 2002, binary: "codex")) + TTYCommandRunner._test_endTrackedProcessLaunch() + #expect(completed.wait(timeout: .now() + 1) == .success) + } } @Test @@ -202,7 +237,14 @@ 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, + stopOnSubstrings: [dir.path], + returnOnEmptyProcessExit: true)) let clean = result.text.replacingOccurrences(of: "\r", with: "") #expect(clean.contains(dir.path)) } @@ -214,7 +256,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 +270,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 +289,7 @@ struct TTYCommandRunnerEnvTests { binary: fakeClaude.path, send: "", options: .init( - timeout: 3, + timeout: Self.harnessPTYTimeout, baseEnvironment: env, stopOnSubstrings: ["deep-link-disabled"], useClaudeProbeWorkingDirectory: true)) @@ -327,7 +369,9 @@ struct TTYCommandRunnerEnvTests { TTYCommandRunner.drainRemainingOutput( until: Date().addingTimeInterval(1), readChunk: { - if reads.isEmpty { return .closed } + if reads.isEmpty { + return .closed + } return reads.removeFirst() }, processChunk: { data in @@ -355,7 +399,9 @@ struct TTYCommandRunnerEnvTests { until: Date().addingTimeInterval(1), readChunk: { readCount += 1 - if reads.isEmpty { return .closed } + if reads.isEmpty { + return .closed + } return reads.removeFirst() }, processChunk: { data in @@ -383,6 +429,61 @@ struct TTYCommandRunnerEnvTests { #expect(readCount == 1) } + @Test + func `deadline drain preserves timeout while collecting late output`() throws { + let fm = FileManager.default + let dir = fm.temporaryDirectory.appendingPathComponent("codexbar-tty-\(UUID().uuidString)", isDirectory: true) + try fm.createDirectory(at: dir, withIntermediateDirectories: true) + defer { try? fm.removeItem(at: dir) } + + let scriptURL = dir.appendingPathComponent("late-output.sh") + let script = """ + #!/bin/sh + /bin/sleep 0.12 + printf 'https://claude.ai/oauth/authorize?test=late\\n' + """ + try script.write(to: scriptURL, atomically: true, encoding: .utf8) + try fm.setAttributes([.posixPermissions: 0o755], ofItemAtPath: scriptURL.path) + + let runner = TTYCommandRunner() + let result = try TTYCommandRunner.withPostDeadlineDrainDurationOverrideForTesting(10) { + try runner.run( + binary: scriptURL.path, + send: "", + options: .init(timeout: 0.01, initialDelay: 0, settleAfterStop: 0.5)) + } + + #expect(result.completion == .deadlineExceeded) + #expect(result.text.contains("https://claude.ai/oauth/authorize?test=late")) + } + + @Test + func `PTY closure keeps waiting for child exit before deadline`() throws { + let fm = FileManager.default + let dir = fm.temporaryDirectory.appendingPathComponent("codexbar-tty-\(UUID().uuidString)", isDirectory: true) + try fm.createDirectory(at: dir, withIntermediateDirectories: true) + defer { try? fm.removeItem(at: dir) } + + let scriptURL = dir.appendingPathComponent("close-pty-exit.sh") + let script = """ + #!/bin/sh + exec /dev/null 2>/dev/null + /bin/sleep 2 + exit 0 + """ + try script.write(to: scriptURL, atomically: true, encoding: .utf8) + try fm.setAttributes([.posixPermissions: 0o755], ofItemAtPath: scriptURL.path) + + let runner = TTYCommandRunner() + let result = try runner.run( + binary: scriptURL.path, + send: "", + options: .init(timeout: 10, initialDelay: 0, returnOnEmptyProcessExit: true)) + + #expect(result.completion == .processExited(status: 0)) + #expect(result.text.isEmpty) + } + @Test func `interrupted drain reads are treated as retryable`() { let result = TTYCommandRunner.drainReadResult(for: Data(), terminalRead: -1, errno: EINTR) diff --git a/Tests/CodexBarTests/TTYIntegrationTests.swift b/Tests/CodexBarTests/TTYIntegrationTests.swift index dca1536fa4..dc3ea5e86e 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) @@ -74,8 +74,13 @@ struct TTYIntegrationTests { @Test func `claude pty usage stops on subscription notice`() async throws { - let cli = try Self.makeSubscriptionNoticeClaudeCLI() - defer { Task { await ClaudeCLISession.shared.reset() } } + let logURL = FileManager.default.temporaryDirectory + .appendingPathComponent("CodexBarTTYTests-\(UUID().uuidString).log") + let cli = try Self.makeSubscriptionNoticeClaudeCLI(logURL: logURL) + defer { + try? FileManager.default.removeItem(at: logURL) + Task { await ClaudeCLISession.shared.reset() } + } do { try await ClaudeCLISession.withIsolatedSessionForTesting { @@ -87,6 +92,10 @@ struct TTYIntegrationTests { } catch { #expect(Bool(false), "Unexpected error: \(error)") } + + let commands = try String(contentsOf: logURL, encoding: .utf8) + #expect(commands.contains("/usage")) + #expect(!commands.contains("/status")) } private static func makeSlowUsageClaudeCLI() throws -> URL { @@ -101,7 +110,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' @@ -117,7 +126,7 @@ struct TTYIntegrationTests { return url } - private static func makeSubscriptionNoticeClaudeCLI() throws -> URL { + private static func makeSubscriptionNoticeClaudeCLI(logURL: URL) throws -> URL { let dir = FileManager.default.temporaryDirectory .appendingPathComponent("CodexBarTTYTests-\(UUID().uuidString)", isDirectory: true) try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) @@ -125,6 +134,7 @@ struct TTYIntegrationTests { let script = """ #!/bin/sh while IFS= read -r line; do + printf '%s\\n' "$line" >> '\(logURL.path)' case "$line" in *"/usage"*) printf '%s\\n' 'You are currently using your subscription to power your Claude Code usage' diff --git a/Tests/CodexBarTests/TailscaleSessionTests.swift b/Tests/CodexBarTests/TailscaleSessionTests.swift new file mode 100644 index 0000000000..7cb4eba3e7 --- /dev/null +++ b/Tests/CodexBarTests/TailscaleSessionTests.swift @@ -0,0 +1,135 @@ +import CodexBarCore +import Foundation +import Testing + +struct TailscaleSessionTests { + @Test + func `online mac and linux peers become hosts`() throws { + let url = try AgentSessionParserTests.fixtureURL("agent-sessions-tailscale", extension: "json") + let hosts = try TailscaleStatusParser.hosts( + from: Data(contentsOf: url), + excludingLocalHost: "local-mac") + + #expect(hosts == ["clawmac", "linuxbox"]) + } + + @Test + func `binary candidates prefer the CLI wrapper over the app binary`() throws { + // A GUI-launched app inherits a minimal PATH that omits the CLI locations. + let candidates = RemoteSessionFetcher.tailscaleBinaryCandidates(path: "/usr/bin:/bin") + + // The standard wrapper locations are still probed, ahead of the app binary… + #expect(candidates.contains("/usr/local/bin/tailscale")) + #expect(candidates.contains("/opt/homebrew/bin/tailscale")) + // …and the dual-mode app binary is the last resort. + #expect(candidates.last == "/Applications/Tailscale.app/Contents/MacOS/Tailscale") + #expect(try #require(candidates.firstIndex(of: "/usr/local/bin/tailscale")) < candidates.count - 1) + } + + @Test + func `binary candidates keep PATH entries first and dedupe well-known dirs`() { + let candidates = RemoteSessionFetcher.tailscaleBinaryCandidates(path: "/opt/homebrew/bin:/usr/bin") + + #expect(candidates.first == "/opt/homebrew/bin/tailscale") + #expect(candidates.count(where: { $0 == "/opt/homebrew/bin/tailscale" }) == 1) + } + + @Test + func `cli environment injects a shell marker for the app-binary fallback`() { + // Without a marker the dual-mode binary launches the GUI instead of the CLI. + let env = RemoteSessionFetcher.tailscaleCLIEnvironment(from: ["PATH": "/usr/bin"]) + + #expect(env["SHLVL"] == "1") + } + + @Test + func `cli environment preserves an existing terminal context`() { + // Already CLI-safe: leave TERM alone and don't fabricate a SHLVL… + let withTerm = RemoteSessionFetcher.tailscaleCLIEnvironment(from: ["TERM": "xterm-256color"]) + #expect(withTerm["SHLVL"] == nil) + + // …and never clobber a caller-provided SHLVL. + let withShlvl = RemoteSessionFetcher.tailscaleCLIEnvironment(from: ["SHLVL": "3"]) + #expect(withShlvl["SHLVL"] == "3") + } + + @Test + func `discovery falls through to the next candidate when the first fails`() async { + // First candidate exists but is a wrong/broken tailscale variant: its status output isn't valid + // Tailscale JSON. Discovery must try the next candidate rather than returning no hosts. + let validStatus = Data(#""" + {"Version":"1.0","Self":{"HostName":"local-mac"}, + "Peer":{"n":{"Online":true,"OS":"linux","DNSName":"linuxbox.tail.ts.net."}}} + """#.utf8) + var probed: [String] = [] + let hosts = await RemoteSessionFetcher.firstDiscoveredHosts( + candidates: ["/first/tailscale", "/second/tailscale"], + localHost: "local-mac") + { binary in + probed.append(binary) + return binary == "/first/tailscale" ? Data(#"{"Version":"1.0"}"#.utf8) : validStatus + } + + #expect(hosts == ["linuxbox"]) + #expect(probed == ["/first/tailscale", "/second/tailscale"]) // fell through, in order + } + + @Test + func `discovery falls through when an earlier candidate needs login`() async { + let inactiveStatus = Data(#"{"Version":"1.0","BackendState":"NeedsLogin","Peer":null}"#.utf8) + let runningStatus = Data(#""" + {"Version":"1.0","BackendState":"Running","Self":{"HostName":"local-mac"}, + "Peer":{"n":{"Online":true,"OS":"linux","DNSName":"linuxbox.tail.ts.net."}}} + """#.utf8) + var probed: [String] = [] + let hosts = await RemoteSessionFetcher.firstDiscoveredHosts( + candidates: ["/inactive/tailscale", "/running/tailscale"], + localHost: "local-mac") + { binary in + probed.append(binary) + return binary == "/inactive/tailscale" ? inactiveStatus : runningStatus + } + + #expect(hosts == ["linuxbox"]) + #expect(probed == ["/inactive/tailscale", "/running/tailscale"]) + } + + @Test + func `discovery returns empty when no candidate yields a valid status`() async { + let hosts = await RemoteSessionFetcher.firstDiscoveredHosts( + candidates: ["/a/tailscale", "/b/tailscale"], + localHost: nil) { _ in Data("nope".utf8) } + + #expect(hosts.isEmpty) + } + + @Test + func `parseHosts distinguishes invalid output from an empty tailnet`() { + // Non-status output -> nil so the caller falls through to the next candidate… + #expect(TailscaleStatusParser.parseHosts(from: Data("not json".utf8)) == nil) + #expect(TailscaleStatusParser.parseHosts(from: Data("Tailscale help text".utf8)) == nil) + #expect(TailscaleStatusParser.parseHosts(from: Data(#"{"Version":"1.0"}"#.utf8)) == nil) + #expect(TailscaleStatusParser.parseHosts(from: Data(#"{"Self":null}"#.utf8)) == nil) + #expect(TailscaleStatusParser.parseHosts(from: Data(#"{"Peer":"error"}"#.utf8)) == nil) + // …a valid status with no eligible peers -> [] (a real answer, stop probing). + let empty = TailscaleStatusParser.parseHosts( + from: Data(#"{"Version":"1.0","BackendState":"Running","Self":{},"Peer":null}"#.utf8)) + #expect(empty == []) + #expect(TailscaleStatusParser.parseHosts( + from: Data(#"{"Version":"1.0","BackendState":"NeedsLogin","Peer":null}"#.utf8)) == nil) + } + + @Test + func `ssh destinations reject options whitespace and controls`() { + let hosts = RemoteSessionFetcher.sanitizedHosts([ + "user@clawmac", + "USER@CLAWMAC", + "-oProxyCommand=touch /tmp/unsafe", + "host with-space", + "host\nother", + "linuxbox", + ]) + + #expect(hosts == ["user@clawmac", "linuxbox"]) + } +} diff --git a/Tests/CodexBarTests/TerminalAppTests.swift b/Tests/CodexBarTests/TerminalAppTests.swift new file mode 100644 index 0000000000..e8ad626c64 --- /dev/null +++ b/Tests/CodexBarTests/TerminalAppTests.swift @@ -0,0 +1,130 @@ +import AppKit +import Foundation +import Testing +@testable import CodexBar + +@Suite("TerminalApp") +struct TerminalAppTests { + @Test + @MainActor + func `default is terminal`() throws { + let suite = "TerminalAppTests-\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + let store = SettingsStore( + userDefaults: defaults, + configStore: testConfigStore(suiteName: suite), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + #expect(store.terminalApp == .terminal) + } + + @Test + @MainActor + func `setting terminal app persists it`() throws { + let suite = "TerminalAppTests-\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + let store = SettingsStore( + userDefaults: defaults, + configStore: testConfigStore(suiteName: suite), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + store.terminalApp = .iTerm + #expect(store.terminalApp == .iTerm) + #expect(defaults.string(forKey: "terminalApp") == "iTerm") + } + + @Test + @MainActor + func `invalid stored value falls back to terminal`() throws { + let suite = "TerminalAppTests-\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.set("nonexistent", forKey: "terminalApp") + let store = SettingsStore( + userDefaults: defaults, + configStore: testConfigStore(suiteName: suite), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + #expect(store.terminalApp == .terminal) + } + + @Test + func `only two cases exist`() { + #expect(TerminalApp.allCases.count == 2) + } + + @Test + func `installed terminals always include Terminal and detected alternatives`() { + let iTermURL = URL(fileURLWithPath: "/Applications/iTerm.app") + let installed = TerminalApp.installed { bundleIdentifier in + bundleIdentifier == TerminalApp.iTerm.bundleIdentifier ? iTermURL : nil + } + + #expect(installed == [.terminal, .iTerm]) + #expect(TerminalApp.installed { _ in nil } == [.terminal]) + } + + @Test + func `picker options preserve an unavailable persisted selection`() { + #expect(TerminalApp.pickerOptions(selected: .terminal) { _ in nil } == [.terminal]) + #expect(TerminalApp.pickerOptions(selected: .iTerm) { _ in nil } == [.terminal, .iTerm]) + } + + @Test + @MainActor + func `picker icon has compact intrinsic size`() { + let source = NSImage(size: NSSize(width: 128, height: 64)) + + let icon = TerminalApp.pickerIcon(from: source) + + #expect(icon.size == NSSize(width: 16, height: 16)) + } + + @Test + @MainActor + func `zero size picker icon remains compact`() { + let icon = TerminalApp.pickerIcon(from: NSImage(size: .zero)) + + #expect(icon.size == NSSize(width: 16, height: 16)) + } + + @Test + func `all cases have unique bundle identifiers`() { + let ids = TerminalApp.allCases.map(\.bundleIdentifier) + #expect(Set(ids).count == TerminalApp.allCases.count) + } + + @Test + func `all cases have non-empty labels`() { + for app in TerminalApp.allCases { + #expect(!app.label.isEmpty) + } + } + + @Test + func `round-trip all cases through raw value`() { + for app in TerminalApp.allCases { + #expect(TerminalApp(rawValue: app.rawValue) == app) + } + } + + @Test + func `escapes commands embedded in AppleScript strings`() { + let escaped = TerminalApp.escapeForAppleScript(#"echo "C:\tmp""#) + + #expect(escaped == #"echo \"C:\\tmp\""#) + } + + @Test + func `builds terminal-specific launch scripts`() { + let command = #"echo "hello""# + let terminalScript = TerminalApp.terminal.appleScript(command: command) + let iTermScript = TerminalApp.iTerm.appleScript(command: command) + + #expect(terminalScript.contains(#"tell application "Terminal""#)) + #expect(terminalScript.contains(#"do script "echo \"hello\"""#)) + #expect(iTermScript.contains(#"tell application "iTerm""#)) + #expect(iTermScript.contains(#"write text "echo \"hello\"""#)) + } +} diff --git a/Tests/CodexBarTests/TestProcessCleanup.swift b/Tests/CodexBarTests/TestProcessCleanup.swift index dbc3a1b767..af9d6f0b0c 100644 --- a/Tests/CodexBarTests/TestProcessCleanup.swift +++ b/Tests/CodexBarTests/TestProcessCleanup.swift @@ -7,12 +7,19 @@ import Glibc #endif enum TestProcessCleanup { + static let codexTestStubCommandRegex = [ + #"codex-(stub|fallback-stub|plan-only-stub|credits-only-stub|hung-stub)-"#, + #"[[:xdigit:]]{8}-[[:xdigit:]]{4}-[[:xdigit:]]{4}-[[:xdigit:]]{4}-[[:xdigit:]]{12}"#, + #"[[:space:]]+app-server([[:space:]]|$)"#, + ].joined() + static func register() { atexit(_testProcessCleanupAtExit) } - fileprivate static func terminateLeakedCodexAppServers() { - let pids = Self.pids(matchingFullCommandRegex: "codex.*app-server") + fileprivate static func terminateLeakedCodexTestStubs() { + // Never target an installed Codex process. These UUID-named executables are created only by this test target. + let pids = Self.pids(matchingFullCommandRegex: Self.codexTestStubCommandRegex) .filter { $0 > 0 && $0 != getpid() } guard !pids.isEmpty else { return } @@ -65,5 +72,5 @@ private let _registerTestProcessCleanup: Void = TestProcessCleanup.register() @_cdecl("codexbar_test_cleanup_atexit") private func _testProcessCleanupAtExit() { - TestProcessCleanup.terminateLeakedCodexAppServers() + TestProcessCleanup.terminateLeakedCodexTestStubs() } diff --git a/Tests/CodexBarTests/TestProcessCleanupTests.swift b/Tests/CodexBarTests/TestProcessCleanupTests.swift new file mode 100644 index 0000000000..5fed8db627 --- /dev/null +++ b/Tests/CodexBarTests/TestProcessCleanupTests.swift @@ -0,0 +1,37 @@ +import Foundation +import Testing + +struct TestProcessCleanupTests { + @Test + func `cleanup pattern matches only CodexBar test stub app servers`() throws { + let regex = try NSRegularExpression(pattern: TestProcessCleanup.codexTestStubCommandRegex) + let testStubCommands = [ + "/usr/bin/python3 -S /tmp/codex-stub-01234567-89AB-CDEF-0123-456789ABCDEF app-server", + "/bin/sh /tmp/codex-fallback-stub-01234567-89AB-CDEF-0123-456789ABCDEF app-server", + "/bin/sh /tmp/codex-plan-only-stub-01234567-89AB-CDEF-0123-456789ABCDEF app-server", + "/bin/sh /tmp/codex-credits-only-stub-01234567-89AB-CDEF-0123-456789ABCDEF app-server", + "/bin/sh /tmp/codex-hung-stub-01234567-89AB-CDEF-0123-456789ABCDEF app-server", + ] + let userCommands = [ + "/Applications/Codex.app/Contents/Resources/codex app-server --listen stdio://", + "/opt/homebrew/bin/codex app-server", + "node /Users/test/node_modules/.bin/codex app-server --listen stdio://", + "/tmp/codex-stub-cache app-server", + "/tmp/codex-stub-01234567-89AB-CDEF-0123 app-server", + "/tmp/codex-stub-01234567-89AB-CDEF-0123-456789ABCDEF app-server-helper", + ] + + for command in testStubCommands { + #expect(Self.matches(regex, command)) + } + for command in userCommands { + #expect(!Self.matches(regex, command)) + } + } + + private static func matches(_ regex: NSRegularExpression, _ command: String) -> Bool { + regex.firstMatch( + in: command, + range: NSRange(command.startIndex..., in: command)) != nil + } +} diff --git a/Tests/CodexBarTests/TestStores.swift b/Tests/CodexBarTests/TestStores.swift index 185248593e..bfce8d16c0 100644 --- a/Tests/CodexBarTests/TestStores.swift +++ b/Tests/CodexBarTests/TestStores.swift @@ -69,22 +69,6 @@ final class InMemoryKimiTokenStore: KimiTokenStoring, @unchecked Sendable { } } -final class InMemoryKimiK2TokenStore: KimiK2TokenStoring, @unchecked Sendable { - var value: String? - - init(value: String? = nil) { - self.value = value - } - - func loadToken() throws -> String? { - self.value - } - - func storeToken(_ token: String?) throws { - self.value = token - } -} - final class InMemoryCopilotTokenStore: CopilotTokenStoring, @unchecked Sendable { var value: String? @@ -136,7 +120,51 @@ func testConfigStore(suiteName: String, reset: Bool = true) -> CodexBarConfigSto return CodexBarConfigStore(fileURL: url) } +@MainActor +func testSettingsStore( + suiteName: String, + tokenAccountStore: any ProviderTokenAccountStoring = InMemoryTokenAccountStore(), + config: CodexBarConfig? = nil) -> SettingsStore +{ + let isolatedSuiteName = "\(suiteName)-\(UUID().uuidString)" + guard let defaults = UserDefaults(suiteName: isolatedSuiteName) else { + preconditionFailure("Could not create test defaults suite") + } + defaults.removePersistentDomain(forName: isolatedSuiteName) + let configStore = testConfigStore(suiteName: isolatedSuiteName) + if let config { + do { + try configStore.save(config) + } catch { + preconditionFailure("Could not save test config: \(error)") + } + } + return SettingsStore( + userDefaults: defaults, + configStore: configStore, + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore(), + codexCookieStore: InMemoryCookieHeaderStore(), + claudeCookieStore: InMemoryCookieHeaderStore(), + cursorCookieStore: InMemoryCookieHeaderStore(), + opencodeCookieStore: InMemoryCookieHeaderStore(), + factoryCookieStore: InMemoryCookieHeaderStore(), + minimaxCookieStore: InMemoryMiniMaxCookieStore(), + minimaxAPITokenStore: InMemoryMiniMaxAPITokenStore(), + kimiTokenStore: InMemoryKimiTokenStore(), + augmentCookieStore: InMemoryCookieHeaderStore(), + ampCookieStore: InMemoryCookieHeaderStore(), + copilotTokenStore: InMemoryCopilotTokenStore(), + tokenAccountStore: tokenAccountStore) +} + #if os(macOS) +@MainActor +func testStatusBar() -> NSStatusBar { + // Standalone NSStatusBar instances can crash during swiftpm-testing-helper teardown. + .system +} + @MainActor @discardableResult func withStatusItemControllerForTesting( diff --git a/Tests/CodexBarTests/TokenAccountEnvironmentPrecedenceTests.swift b/Tests/CodexBarTests/TokenAccountEnvironmentPrecedenceTests.swift index 14dfe6392c..56e42dca25 100644 --- a/Tests/CodexBarTests/TokenAccountEnvironmentPrecedenceTests.swift +++ b/Tests/CodexBarTests/TokenAccountEnvironmentPrecedenceTests.swift @@ -4,6 +4,141 @@ import Testing @testable import CodexBar @testable import CodexBarCLI +@Suite(.serialized) +struct AlibabaTokenPlanRegionSelectionTests { + @Test @MainActor + func `fresh app settings default to International`() { + let settings = testSettingsStore(suiteName: "AlibabaTokenPlanRegionSelectionTests-fresh") + + #expect(settings.alibabaTokenPlanAPIRegion == .international) + } + + @Test @MainActor + func `legacy app settings without region remain China mainland`() { + var config = CodexBarConfig.makeDefault() + config.setProviderConfig(ProviderConfig(id: .alibabatokenplan, region: nil)) + let settings = testSettingsStore( + suiteName: "AlibabaTokenPlanRegionSelectionTests-legacy", + config: config) + + #expect(settings.alibabaTokenPlanAPIRegion == .chinaMainland) + } + + @Test @MainActor + func `app settings trim configured region`() { + var config = CodexBarConfig.makeDefault() + config.setProviderConfig(ProviderConfig(id: .alibabatokenplan, region: " intl ")) + let settings = testSettingsStore( + suiteName: "AlibabaTokenPlanRegionSelectionTests-trimmed", + config: config) + + #expect(settings.alibabaTokenPlanAPIRegion == .international) + } + + @Test + func `CLI honors explicit region and keeps legacy config on China mainland`() throws { + let selection = TokenAccountCLISelection(label: nil, index: nil, allAccounts: false) + let internationalContext = try TokenAccountCLIContext( + selection: selection, + config: CodexBarConfig(providers: [ + ProviderConfig(id: .alibabatokenplan, region: AlibabaTokenPlanAPIRegion.international.rawValue), + ]), + verbose: false) + let legacyContext = try TokenAccountCLIContext( + selection: selection, + config: CodexBarConfig(providers: [ + ProviderConfig(id: .alibabatokenplan, region: nil), + ]), + verbose: false) + + #expect(internationalContext.settingsSnapshot(for: .alibabatokenplan, account: nil)? + .alibabaTokenPlan?.apiRegion == .international) + #expect(legacyContext.settingsSnapshot(for: .alibabatokenplan, account: nil)? + .alibabaTokenPlan?.apiRegion == .chinaMainland) + } +} + +@Suite(.serialized) +struct ZaiTokenAccountEnvironmentPrecedenceTests { + @Test + func `zai CLI settings snapshot defaults to personal without account scope`() throws { + let config = CodexBarConfig(providers: [ + ProviderConfig(id: .zai), + ]) + let selection = TokenAccountCLISelection(label: nil, index: nil, allAccounts: false) + let tokenContext = try TokenAccountCLIContext( + selection: selection, + config: config, + verbose: false, + baseEnvironment: [ + ZaiSettingsReader.bigModelOrganizationKey: " org-env ", + ZaiSettingsReader.bigModelProjectKey: " proj-env ", + ]) + + let snapshot = try #require(tokenContext.settingsSnapshot(for: .zai, account: nil)?.zai) + + #expect(snapshot.usageScope == .personal) + #expect(snapshot.teamContext == nil) + } + + @Test + func `zai CLI settings snapshot uses selected team account scope`() throws { + let account = ProviderTokenAccount( + id: UUID(), + label: "Team", + token: "account-token", + addedAt: 0, + lastUsed: nil, + usageScope: " team ", + organizationID: " org-account ", + workspaceID: " proj-account ") + let config = CodexBarConfig(providers: [ + ProviderConfig(id: .zai), + ]) + let tokenContext = try TokenAccountCLIContext( + selection: TokenAccountCLISelection(label: nil, index: nil, allAccounts: false), + config: config, + verbose: false, + baseEnvironment: [ + ZaiSettingsReader.bigModelOrganizationKey: "org-env", + ZaiSettingsReader.bigModelProjectKey: "proj-env", + ]) + + let snapshot = try #require(tokenContext.settingsSnapshot(for: .zai, account: account)?.zai) + + #expect(snapshot.usageScope == .team) + #expect(snapshot.teamContext?.organizationID == "org-account") + #expect(snapshot.teamContext?.projectID == "proj-account") + } + + @Test + func `zai CLI personal account scope clears inherited team context`() throws { + let account = ProviderTokenAccount( + id: UUID(), + label: "Personal", + token: "account-token", + addedAt: 0, + lastUsed: nil, + usageScope: "personal") + let config = CodexBarConfig(providers: [ + ProviderConfig(id: .zai), + ]) + let tokenContext = try TokenAccountCLIContext( + selection: TokenAccountCLISelection(label: nil, index: nil, allAccounts: false), + config: config, + verbose: false, + baseEnvironment: [ + ZaiSettingsReader.bigModelOrganizationKey: "org-env", + ZaiSettingsReader.bigModelProjectKey: "proj-env", + ]) + + let snapshot = try #require(tokenContext.settingsSnapshot(for: .zai, account: account)?.zai) + + #expect(snapshot.usageScope == .personal) + #expect(snapshot.teamContext == nil) + } +} + @Suite(.serialized) @MainActor struct TokenAccountEnvironmentPrecedenceTests { @@ -107,6 +242,45 @@ struct TokenAccountEnvironmentPrecedenceTests { #expect(ollamaSettings.manualCookieHeader == "session=account-token") } + @Test + func `command code config cookie is carried into CLI settings snapshot`() throws { + let config = CodexBarConfig(providers: [ + ProviderConfig( + id: .commandcode, + cookieHeader: "better-auth.session_token=manual-token", + cookieSource: .manual), + ]) + let selection = TokenAccountCLISelection(label: nil, index: nil, allAccounts: false) + let tokenContext = try TokenAccountCLIContext(selection: selection, config: config, verbose: false) + let snapshot = try #require(tokenContext.settingsSnapshot(for: .commandcode, account: nil)) + let commandCodeSettings = try #require(snapshot.commandcode) + + #expect(commandCodeSettings.cookieSource == .manual) + #expect(commandCodeSettings.manualCookieHeader == "better-auth.session_token=manual-token") + } + + @Test + func `app snapshot override resolves cookie account without mutating stored selection`() throws { + let settings = Self.makeSettingsStore(suite: "TokenAccountEnvironmentPrecedenceTests-cookie-override-app") + settings.cursorCookieSource = .auto + settings.cursorCookieHeader = "configured=true" + let account = ProviderTokenAccount( + id: UUID(), + label: "Override", + token: "account=true", + addedAt: 0, + lastUsed: nil) + + let snapshot = ProviderRegistry.makeSettingsSnapshot( + settings: settings, + tokenOverride: TokenAccountOverride(provider: .cursor, account: account)) + let cursorSettings = try #require(snapshot.cursor) + + #expect(cursorSettings.cookieSource == .manual) + #expect(cursorSettings.manualCookieHeader == "account=true") + #expect(settings.tokenAccounts(for: .cursor).isEmpty) + } + @Test func `stepfun CLI snapshot reads manual token from region field`() throws { let config = CodexBarConfig( @@ -415,15 +589,21 @@ struct TokenAccountEnvironmentPrecedenceTests { } @Test - func `codex all accounts selection exposes visible managed accounts and scopes CLI homes`() throws { + func `codex all accounts selection exposes configured accounts and scopes CLI homes`() throws { let root = FileManager.default.temporaryDirectory .appendingPathComponent("codex-cli-all-accounts-\(UUID().uuidString)", isDirectory: true) let ambientHome = root.appendingPathComponent("ambient", isDirectory: true) let firstHome = root.appendingPathComponent("first", isDirectory: true) let secondHome = root.appendingPathComponent("second", isDirectory: true) + let profileHome = root.appendingPathComponent("profile", isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } try FileManager.default.createDirectory(at: ambientHome, withIntermediateDirectories: true) try FileManager.default.createDirectory(at: firstHome, withIntermediateDirectories: true) try FileManager.default.createDirectory(at: secondHome, withIntermediateDirectories: true) + try Self.writeCodexAuthFile( + homeURL: profileHome, + email: "profile@example.com", + accountID: "acct_profile") let storeURL = root.appendingPathComponent("managed-codex-accounts.json") let firstID = UUID() let secondID = UUID() @@ -447,7 +627,10 @@ struct TokenAccountEnvironmentPrecedenceTests { ]) try FileManagedCodexAccountStore(fileURL: storeURL).storeAccounts(accounts) let config = CodexBarConfig(providers: [ - ProviderConfig(id: .codex, codexActiveSource: .managedAccount(id: secondID)), + ProviderConfig( + id: .codex, + codexActiveSource: .managedAccount(id: secondID), + codexProfileHomePaths: [profileHome.path]), ]) let context = try TokenAccountCLIContext( selection: TokenAccountCLISelection(label: nil, index: nil, allAccounts: true), @@ -459,10 +642,12 @@ struct TokenAccountEnvironmentPrecedenceTests { let projection = context.visibleCodexAccounts() #expect(projection.visibleAccounts.map(\.menuDisplayName) == [ "first@example.com — Team", + "profile@example.com", "second@example.com", ]) #expect(projection.visibleAccounts.map(\.selectionSource) == [ .managedAccount(id: firstID), + .profileHome(path: profileHome.path), .managedAccount(id: secondID), ]) #expect(projection.visibleAccounts.first { $0.email == "second@example.com" }?.isActive == true) @@ -474,6 +659,18 @@ struct TokenAccountEnvironmentPrecedenceTests { codexActiveSourceOverride: .managedAccount(id: firstID)) #expect(firstEnv["CODEX_HOME"] == firstHome.path) + let profileEnv = context.environment( + base: ["CODEX_HOME": ambientHome.path], + provider: .codex, + account: nil, + codexActiveSourceOverride: .profileHome(path: profileHome.path)) + #expect(profileEnv["CODEX_HOME"] == profileHome.path) + #expect(context.settingsSnapshot( + for: .codex, + account: nil, + codexActiveSourceOverride: .profileHome(path: profileHome.path))?.codex?.openAIWebCacheScope + == .profileHome(profileHome.path)) + let liveEnv = context.environment( base: ["CODEX_HOME": ambientHome.path], provider: .codex, @@ -499,6 +696,38 @@ struct TokenAccountEnvironmentPrecedenceTests { #expect(identity.accountOrganization == "Team") } + @Test + func `codex CLI ignores relative profile homes`() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("codex-cli-relative-profile-\(UUID().uuidString)", isDirectory: true) + let ambientHome = root.appendingPathComponent("ambient", isDirectory: true) + let managedStoreURL = root.appendingPathComponent("managed-codex-accounts.json") + defer { try? FileManager.default.removeItem(at: root) } + try FileManager.default.createDirectory(at: ambientHome, withIntermediateDirectories: true) + + let config = CodexBarConfig(providers: [ + ProviderConfig( + id: .codex, + codexActiveSource: .profileHome(path: "relative-codex-home"), + codexProfileHomePaths: ["relative-codex-home"]), + ]) + let context = try TokenAccountCLIContext( + selection: TokenAccountCLISelection(label: nil, index: nil, allAccounts: false), + config: config, + verbose: false, + baseEnvironment: ["CODEX_HOME": ambientHome.path], + managedCodexAccountStoreURL: managedStoreURL) + + let environment = context.environment( + base: ["CODEX_HOME": ambientHome.path], + provider: .codex, + account: nil, + codexActiveSourceOverride: .profileHome(path: "relative-codex-home")) + + #expect(context.visibleCodexAccounts().visibleAccounts.isEmpty) + #expect(environment["CODEX_HOME"] == ambientHome.path) + } + @Test func `claude ambient explicit CLI source remains CLI in CLI`() throws { let config = CodexBarConfig(providers: [ProviderConfig(id: .claude)]) @@ -824,7 +1053,6 @@ extension TokenAccountEnvironmentPrecedenceTests { minimaxCookieStore: InMemoryMiniMaxCookieStore(), minimaxAPITokenStore: InMemoryMiniMaxAPITokenStore(), kimiTokenStore: InMemoryKimiTokenStore(), - kimiK2TokenStore: InMemoryKimiK2TokenStore(), augmentCookieStore: InMemoryCookieHeaderStore(), ampCookieStore: InMemoryCookieHeaderStore(), copilotTokenStore: InMemoryCopilotTokenStore(), @@ -860,6 +1088,20 @@ extension TokenAccountEnvironmentPrecedenceTests { return environment["CODEX_HOME"] } + fileprivate static func writeCodexAuthFile(homeURL: URL, email: String, accountID: String) throws { + try FileManager.default.createDirectory(at: homeURL, withIntermediateDirectories: true) + let auth: [String: Any] = [ + "tokens": [ + "accessToken": "access-token", + "refreshToken": "refresh-token", + "idToken": self.fakeJWT(email: email, plan: "pro", accountId: accountID), + "account_id": accountID, + ], + ] + let data = try JSONSerialization.data(withJSONObject: auth) + try data.write(to: homeURL.appendingPathComponent("auth.json")) + } + fileprivate static func knownOwnerMultiset( _ owners: [CodexDashboardKnownOwnerCandidate]) -> [CodexDashboardKnownOwnerCandidate: Int] { @@ -974,6 +1216,8 @@ extension TokenAccountEnvironmentPrecedenceTests { rateLimit: nil, updatedAt: now), cursorRequests: CursorRequestUsage(used: 7, limit: 70), + subscriptionExpiresAt: reset.addingTimeInterval(86400), + subscriptionRenewsAt: reset.addingTimeInterval(43200), updatedAt: now, identity: identity) } @@ -993,6 +1237,8 @@ extension TokenAccountEnvironmentPrecedenceTests { #expect(after.openRouterUsage?.rateLimit?.requests == before.openRouterUsage?.rateLimit?.requests) #expect(after.cursorRequests?.used == before.cursorRequests?.used) #expect(after.cursorRequests?.limit == before.cursorRequests?.limit) + #expect(after.subscriptionExpiresAt == before.subscriptionExpiresAt) + #expect(after.subscriptionRenewsAt == before.subscriptionRenewsAt) #expect(after.updatedAt == before.updatedAt) } } diff --git a/Tests/CodexBarTests/TokenAccountStoreTests.swift b/Tests/CodexBarTests/TokenAccountStoreTests.swift index 9a252219fa..c83f190dec 100644 --- a/Tests/CodexBarTests/TokenAccountStoreTests.swift +++ b/Tests/CodexBarTests/TokenAccountStoreTests.swift @@ -38,13 +38,19 @@ func `FileTokenAccountStore round trip`() throws { label: "user@example.com", token: "test-token", addedAt: now, - lastUsed: nil) + lastUsed: nil, + usageScope: "team", + organizationID: "org-test", + workspaceID: "proj-test") let data = ProviderTokenAccountData(version: 1, accounts: [account], activeIndex: 0) let store = FileTokenAccountStore(fileURL: fileURL) - try store.storeAccounts([.claude: data]) + try store.storeAccounts([.zai: data]) let loaded = try store.loadAccounts() - #expect(loaded[.claude]?.accounts.count == 1) - #expect(loaded[.claude]?.accounts[0].label == "user@example.com") + #expect(loaded[.zai]?.accounts.count == 1) + #expect(loaded[.zai]?.accounts[0].label == "user@example.com") + #expect(loaded[.zai]?.accounts[0].usageScope == "team") + #expect(loaded[.zai]?.accounts[0].organizationID == "org-test") + #expect(loaded[.zai]?.accounts[0].workspaceID == "proj-test") } diff --git a/Tests/CodexBarTests/UsageBreakdownChartMenuViewTests.swift b/Tests/CodexBarTests/UsageBreakdownChartMenuViewTests.swift new file mode 100644 index 0000000000..99325f09fb --- /dev/null +++ b/Tests/CodexBarTests/UsageBreakdownChartMenuViewTests.swift @@ -0,0 +1,30 @@ +import Testing +@testable import CodexBar + +@Suite("Usage breakdown chart menu") +@MainActor +struct UsageBreakdownChartMenuViewTests { + @Test + func `valid totals remain visible when service rows are absent`() { + #expect( + UsageBreakdownChartMenuView.presentationState( + hasSummary: true, + hasChartPoints: false) == .totalsOnly) + } + + @Test + func `service rows select the chart presentation`() { + #expect( + UsageBreakdownChartMenuView.presentationState( + hasSummary: true, + hasChartPoints: true) == .chart) + } + + @Test + func `missing totals and service rows select the empty presentation`() { + #expect( + UsageBreakdownChartMenuView.presentationState( + hasSummary: false, + hasChartPoints: false) == .empty) + } +} diff --git a/Tests/CodexBarTests/UsageChartScaleTests.swift b/Tests/CodexBarTests/UsageChartScaleTests.swift new file mode 100644 index 0000000000..9d144a8cac --- /dev/null +++ b/Tests/CodexBarTests/UsageChartScaleTests.swift @@ -0,0 +1,23 @@ +import CodexBarCore +import Testing + +struct UsageChartScaleTests { + @Test + func `sub dollar maximum fills the chart`() { + let scale = UsageChartScale(values: [0.10, 0.25, 0.50]) + + #expect(scale.maximum == 0.50) + #expect(scale.fraction(for: 0.50) == 1) + #expect(scale.fraction(for: 0.25) == 0.5) + } + + @Test + func `scale ignores invalid and nonpositive values`() { + let scale = UsageChartScale(values: [.nan, .infinity, -10, 0, 4]) + + #expect(scale.maximum == 4) + #expect(scale.fraction(for: .nan) == 0) + #expect(scale.fraction(for: -1) == 0) + #expect(scale.fraction(for: 8) == 1) + } +} diff --git a/Tests/CodexBarTests/UsageColorLevelTests.swift b/Tests/CodexBarTests/UsageColorLevelTests.swift deleted file mode 100644 index 125e282496..0000000000 --- a/Tests/CodexBarTests/UsageColorLevelTests.swift +++ /dev/null @@ -1,45 +0,0 @@ -import AppKit -import Testing -@testable import CodexBar - -struct UsageColorLevelTests { - private func redComponent(_ color: NSColor?) -> CGFloat? { - guard let resolved = color?.usingColorSpace(.sRGB) else { return nil } - var r: CGFloat = 0 - resolved.getRed(&r, green: nil, blue: nil, alpha: nil) - return r - } - - @Test - func nilUsageReturnsNoTint() { - #expect(UsageColorLevel.tintColor(for: nil) == nil) - } - - @Test - func highUsageIsSystemRed() { - #expect(UsageColorLevel.tintColor(for: 90) == .systemRed) - #expect(UsageColorLevel.tintColor(for: 100) == .systemRed) - // Values above 100 are clamped and still red. - #expect(UsageColorLevel.tintColor(for: 250) == .systemRed) - } - - @Test - func rednessIncreasesWithUsage() throws { - let low = try #require(self.redComponent(UsageColorLevel.tintColor(for: 10))) - let mid = try #require(self.redComponent(UsageColorLevel.tintColor(for: 80))) - let high = try #require(self.redComponent(UsageColorLevel.tintColor(for: 95))) - #expect(low < mid) - #expect(mid <= high) - } - - @Test - func lowUsageIsGreenDominant() throws { - let color = try #require(UsageColorLevel.tintColor(for: 0)?.usingColorSpace(.sRGB)) - var r: CGFloat = 0 - var g: CGFloat = 0 - var b: CGFloat = 0 - color.getRed(&r, green: &g, blue: &b, alpha: nil) - #expect(g > r) - #expect(g > b) - } -} diff --git a/Tests/CodexBarTests/UsageFormatterTests.swift b/Tests/CodexBarTests/UsageFormatterTests.swift index 873591d41f..c12d70e3de 100644 --- a/Tests/CodexBarTests/UsageFormatterTests.swift +++ b/Tests/CodexBarTests/UsageFormatterTests.swift @@ -10,12 +10,23 @@ struct UsageFormatterTests { "Resets %@", "Resets in %@", "Resets now", + "reset_tomorrow_format", "Updated %@", + "Updated relative %@", + "Updated absolute %@", "Updated %@h ago", "Updated %@m ago", "Updated just now", "usage_percent_suffix_left", "usage_percent_suffix_used", + "byte_unit_byte", + "byte_unit_bytes", + "byte_unit_kilobyte", + "byte_unit_kilobytes", + "byte_unit_megabyte", + "byte_unit_megabytes", + "byte_unit_gigabyte", + "byte_unit_gigabytes", ] @Test @@ -34,10 +45,34 @@ struct UsageFormatterTests { #expect(line == "75% used") } + @Test + func `positive sub percent usage stays visible`() { + #expect(UsageFormatter.percentString(-1) == "0%") + #expect(UsageFormatter.percentString(0) == "0%") + #expect(UsageFormatter.percentString(0.1) == "<1%") + #expect(UsageFormatter.percentString(0.96) == "<1%") + #expect(UsageFormatter.percentString(1) == "1%") + #expect(UsageFormatter.percentString(101) == "100%") + #expect(UsageFormatter.usageLine(remaining: 99.9, used: 0.1, showUsed: true) == "<1% used") + // Values in (0.5, 1) round up to "1%" under %.0f, so the old post-format + // "0%" -> "<1%" replacement missed them. percentText must show "<1%" + // across the whole sub-1% range, matching percentString above. + #expect(UsageFormatter.usageLine(remaining: 99.4, used: 0.6, showUsed: true) == "<1% used") + #expect(UsageFormatter.usageLine(remaining: 99.25, used: 0.75, showUsed: true) == "<1% used") + #expect(UsageFormatter.usageLine(remaining: 0.75, used: 99.25, showUsed: false) == "<1% left") + + let usedWindow = RateWindow(usedPercent: 0.1, windowMinutes: nil, resetsAt: nil, resetDescription: nil) + let leftWindow = RateWindow(usedPercent: 99.9, windowMinutes: nil, resetsAt: nil, resetDescription: nil) + #expect(MenuBarDisplayText.percentText(window: usedWindow, showUsed: true) == "<1%") + #expect(MenuBarDisplayText.percentText(window: leftWindow, showUsed: false) == "<1%") + } + @Test func `usage line respects injected localization provider`() { UsageFormatter.setLocalizationProvider { key in switch key { + case "%.0f%% %@": "%2$@ %1$.0f%%" + case "<1%% %@": "%1$@ <1%%" case "usage_percent_suffix_left": "剩余" case "usage_percent_suffix_used": "已使用" default: key @@ -45,8 +80,10 @@ struct UsageFormatterTests { } defer { UsageFormatter.clearLocalizationProvider() } - #expect(UsageFormatter.usageLine(remaining: 22, used: 78, showUsed: false) == "22% 剩余") - #expect(UsageFormatter.usageLine(remaining: 22, used: 78, showUsed: true) == "78% 已使用") + #expect(UsageFormatter.usageLine(remaining: 22, used: 78, showUsed: false) == "剩余 22%") + #expect(UsageFormatter.usageLine(remaining: 22, used: 78, showUsed: true) == "已使用 78%") + #expect(UsageFormatter.usageLine(remaining: 0.75, used: 99.25, showUsed: false) == "剩余 <1%") + #expect(UsageFormatter.usageLine(remaining: 99.4, used: 0.6, showUsed: true) == "已使用 <1%") } @Test @@ -69,7 +106,7 @@ struct UsageFormatterTests { func `injected zh Hans locale applies app language formatting`() { UsageFormatter.setLocalizationProvider { key in switch key { - case "Updated %@": + case "Updated absolute %@": "更新于 %@" default: key @@ -88,6 +125,30 @@ struct UsageFormatterTests { #expect(output.hasPrefix("更新于 ")) } + @Test + func `injected zh Hant relative updated string can place updated after relative time`() { + UsageFormatter.setLocalizationProvider { key in + switch key { + case "Updated relative %@": + "%@已更新" + default: + key + } + } + UsageFormatter.setLocaleProvider { Locale(identifier: "zh-Hant") } + defer { + UsageFormatter.clearLocalizationProvider() + UsageFormatter.clearLocaleProvider() + } + + let now = Date(timeIntervalSince1970: 1_710_048_000) + let old = now.addingTimeInterval(-(5 * 3600)) + let output = UsageFormatter.updatedString(from: old, now: now) + + #expect(output.hasSuffix("已更新")) + #expect(!output.hasPrefix("已更新")) + } + @Test func `clearing locale provider returns to stable default behavior`() { UsageFormatter.clearLocalizationProvider() @@ -105,6 +166,29 @@ struct UsageFormatterTests { #expect(restored == baseline) } + @Test + func `tomorrow reset description uses localized format`() throws { + UsageFormatter.setLocalizationProvider { key in + key == "reset_tomorrow_format" ? "明日 %@" : key + } + UsageFormatter.setLocaleProvider { Locale(identifier: "ja_JP") } + defer { + UsageFormatter.clearLocalizationProvider() + UsageFormatter.clearLocaleProvider() + } + + let calendar = Calendar.current + let today = calendar.startOfDay(for: Date(timeIntervalSince1970: 1_750_000_000)) + let now = try #require(calendar.date(byAdding: .hour, value: 12, to: today)) + let tomorrow = try #require(calendar.date(byAdding: .day, value: 1, to: today)) + let reset = try #require(calendar.date(byAdding: .minute, value: 10 * 60 + 50, to: tomorrow)) + + let output = UsageFormatter.resetDescription(from: reset, now: now) + #expect(output.hasPrefix("明日 ")) + #expect(!output.contains("tomorrow")) + #expect(!output.contains("%@")) + } + @Test func `relative updated recent`() { let now = Date() @@ -139,12 +223,40 @@ struct UsageFormatterTests { } @Test - func `reset countdown days and hours`() { + func `reset countdown caps days with hours at two units`() { let now = Date(timeIntervalSince1970: 1_000_000) - let reset = now.addingTimeInterval((26 * 3600) + 10) + let reset = now.addingTimeInterval((26 * 3600) + (1 * 60)) #expect(UsageFormatter.resetCountdownDescription(from: reset, now: now) == "in 1d 2h") } + @Test + func `reset countdown days and exact hours`() { + let now = Date(timeIntervalSince1970: 1_000_000) + let reset = now.addingTimeInterval(26 * 3600) + #expect(UsageFormatter.resetCountdownDescription(from: reset, now: now) == "in 1d 2h") + } + + @Test + func `reset countdown days and minutes without whole hours`() { + let now = Date(timeIntervalSince1970: 1_000_000) + let reset = now.addingTimeInterval((24 * 3600) + (5 * 60)) + #expect(UsageFormatter.resetCountdownDescription(from: reset, now: now) == "in 1d 5m") + } + + @Test + func `reset countdown exact days`() { + let now = Date(timeIntervalSince1970: 1_000_000) + let reset = now.addingTimeInterval(2 * 24 * 3600) + #expect(UsageFormatter.resetCountdownDescription(from: reset, now: now) == "in 2d") + } + + @Test + func `reset countdown rounds the last minute into a day`() { + let now = Date(timeIntervalSince1970: 1_000_000) + let reset = now.addingTimeInterval((24 * 3600) - 59) + #expect(UsageFormatter.resetCountdownDescription(from: reset, now: now) == "in 1d") + } + @Test func `reset countdown exact hour`() { let now = Date(timeIntervalSince1970: 1_000_000) @@ -188,6 +300,7 @@ struct UsageFormatterTests { #expect(UsageFormatter.modelDisplayName("Claude Opus 4.5 2025 1101") == "Claude Opus 4.5") #expect(UsageFormatter.modelDisplayName("claude-sonnet-4-5") == "claude-sonnet-4-5") #expect(UsageFormatter.modelDisplayName("gpt-5.3-codex-spark") == "gpt-5.3-codex-spark") + #expect(UsageFormatter.modelDisplayName("unknown") == "Unknown model") } @Test @@ -206,6 +319,13 @@ struct UsageFormatterTests { #expect(UsageFormatter.modelCostDetail("custom-model", costUSD: nil, totalTokens: 987) == "987") } + @Test + func `token count string formats small values without grouping`() { + #expect(UsageFormatter.tokenCountString(0) == "0") + #expect(UsageFormatter.tokenCountString(987) == "987") + #expect(UsageFormatter.tokenCountString(-42) == "-42") + } + @Test func `clean plan maps O auth to ollama`() { #expect(UsageFormatter.cleanPlanName("oauth") == "Ollama") @@ -262,6 +382,16 @@ struct UsageFormatterTests { #expect(result == "$0.00") } + @Test(arguments: [ + (0.0, "$0"), + (0.50, "$0.50"), + (12.56, "$13"), + (1515.0, "$1,515"), + ]) + func `compact currency keeps cents only below one unit`(value: Double, expected: String) { + #expect(UsageFormatter.compactCurrencyString(value, currencyCode: "USD") == expected) + } + @Test func `currency string handles non USD currencies`() { // FormatStyle handles all currencies with proper symbols @@ -320,6 +450,78 @@ struct UsageFormatterTests { #expect(UsageFormatter.byteCountString(10 * 1024) == "10 KB") #expect(UsageFormatter.byteCountString(5 * 1024 * 1024) == "5 MB") #expect(UsageFormatter.byteCountString(Int64(1536 * 1024 * 1024)) == "1.5 GB") + #expect(UsageFormatter.byteCountString(.min) == "-8589934592 GB") + } + + @Test + func `long byte count string localizes units and handles boundaries`() { + UsageFormatter.clearLocalizationProvider() + #expect(UsageFormatter.byteCountStringLong(1024 * 1024) == "1 megabyte") + + UsageFormatter.setLocalizationProvider { "[\($0)]" } + defer { UsageFormatter.clearLocalizationProvider() } + + #expect(UsageFormatter.byteCountStringLong(1) == "1 [byte_unit_byte]") + #expect(UsageFormatter.byteCountStringLong(2) == "2 [byte_unit_bytes]") + #expect(UsageFormatter.byteCountStringLong(1536) == "1.5 [byte_unit_kilobytes]") + #expect(UsageFormatter.byteCountStringLong(1024 * 1024) == "1 [byte_unit_megabyte]") + #expect(UsageFormatter.byteCountStringLong(1024 * 1024 + 1) == "1.0 [byte_unit_megabyte]") + #expect(UsageFormatter.byteCountStringLong(.min) == "-8589934592 [byte_unit_gigabytes]") + } + + @Test + func `currency exchange converts rates and formats correctly`() { + let exchange = CurrencyExchange.shared + let epsilon = 1e-9 + // USD → USD is identity + #expect(abs((exchange.convert(usdAmount: 10.0, to: "USD") ?? 0) - 10.0) < epsilon) + + // Cross-currency conversion via USD pivot + let gbpRate = exchange.rate(for: "GBP") ?? 0.79 + let eurRate = exchange.rate(for: "EUR") ?? 0.92 + #expect(abs((exchange.convert(usdAmount: 10.0, to: "GBP") ?? 0) - 10.0 * gbpRate) < epsilon) + #expect(abs((exchange.convert(usdAmount: 10.0, to: "EUR") ?? 0) - 10.0 * eurRate) < epsilon) + + // Cross-currency: GBP → EUR + let gbpToEur = exchange.convert(amount: 10.0, from: "GBP", to: "EUR") + let expectedGbpToEur = 10.0 / gbpRate * eurRate + #expect(abs((gbpToEur ?? 0) - expectedGbpToEur) < epsilon) + + // GBP → USD cross-currency + let gbpToUsd = exchange.convert(amount: 10.0, from: "GBP", to: "USD") + #expect(abs((gbpToUsd ?? 0) - 10.0 / gbpRate) < epsilon) + + // Formatting + let gbpFormatted = UsageFormatter.convertedCostString(10.0, targetCurrency: "GBP") + #expect(gbpFormatted.contains("£")) + + let usdFormatted = UsageFormatter.convertedCostString(10.0, targetCurrency: "USD") + #expect(usdFormatted == "$10.00") + + // Smart conversion with preferred currency + let autoResult = UsageFormatter.convertedCostString(10.0, preferredCurrency: "auto", providerCurrency: "GBP") + #expect(autoResult.contains("£")) + + let explicitCNY = UsageFormatter.convertedCostString(10.0, preferredCurrency: "CNY", providerCurrency: "USD") + #expect(explicitCNY.contains("¥")) + + #expect(exchange.convert(amount: 10.0, from: "CHF", to: "USD") == nil) + let unavailable = UsageFormatter.convertedCostString( + 10.0, + preferredCurrency: "USD", + providerCurrency: "CHF") + #expect(unavailable.contains("CHF")) + #expect(!unavailable.contains("$")) + } + + @Test + func `live exchange rates require an explicit non USD currency`() { + #expect(!CurrencyExchange.requiresLiveRates(preferredCurrencyCode: "USD")) + #expect(!CurrencyExchange.requiresLiveRates(preferredCurrencyCode: " usd ")) + #expect(!CurrencyExchange.requiresLiveRates(preferredCurrencyCode: "auto")) + #expect(!CurrencyExchange.requiresLiveRates(preferredCurrencyCode: "CHF")) + #expect(CurrencyExchange.requiresLiveRates(preferredCurrencyCode: "GBP")) + #expect(CurrencyExchange.requiresLiveRates(preferredCurrencyCode: " eur ")) } @Test diff --git a/Tests/CodexBarTests/UsageMenuCardLayoutTests.swift b/Tests/CodexBarTests/UsageMenuCardLayoutTests.swift new file mode 100644 index 0000000000..b5bf156881 --- /dev/null +++ b/Tests/CodexBarTests/UsageMenuCardLayoutTests.swift @@ -0,0 +1,121 @@ +import AppKit +import CodexBarCore +import SwiftUI +import Testing +@testable import CodexBar + +@MainActor +struct UsageMenuCardLayoutTests { + private static let heightTolerance: CGFloat = 1 + + @Test + func `overview groups provider content without section dividers`() { + #expect(OverviewMenuCardRowView.showsSectionDividers == false) + } + + @Test + func `header only menu card keeps comfortable padding`() { + let model = Self.model() + 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 > 0) + #expect(abs(cardSize.height - headerSize.height) < Self.heightTolerance) + } + + @Test + func `full provider card matches overview height`() { + let model = Self.model(metrics: [ + UsageMenuCardView.Model.Metric( + id: "session", + title: "Session", + percent: 37, + percentStyle: .left, + resetText: "Resets in 41m", + detailText: nil, + detailLeftText: "24% in reserve", + detailRightText: "Lasts until reset", + pacePercent: nil, + paceOnTop: true), + ]) + let width: CGFloat = 296 + + let fullCardSize = NSHostingController(rootView: UsageMenuCardView(model: model, width: width)) + .sizeThatFits(in: CGSize(width: width, height: .greatestFiniteMagnitude)) + let overviewStyleSize = NSHostingController(rootView: UsageMenuCardHeaderAndUsageSectionView( + model: model, + layoutModel: model, + bottomPadding: UsageMenuCardLayout.sectionBottomPadding, + width: width)) + .sizeThatFits(in: CGSize(width: width, height: .greatestFiniteMagnitude)) + + #expect(UsageMenuCardLayout.postHeaderDividerContentSpacing == 16) + #expect(UsageMenuCardLayout.headerOnlyVerticalPadding == 6) + #expect(UsageMenuCardLayout.sectionTopPadding == 6) + #expect(UsageMenuCardLayout.sectionBottomPadding == 6) + + #expect(abs(fullCardSize.height - overviewStyleSize.height) < Self.heightTolerance) + } + + @Test + func `detail card keeps compact divider gap without usage section`() { + let metricsModel = Self.model(metrics: [ + UsageMenuCardView.Model.Metric( + id: "session", + title: "Session", + percent: 37, + percentStyle: .left, + resetText: "Resets in 41m", + detailText: nil, + detailLeftText: "24% in reserve", + detailRightText: "Lasts until reset", + pacePercent: nil, + paceOnTop: true), + ]) + + #expect(UsageMenuCardView.dividerBottomPadding(for: metricsModel) == + UsageMenuCardLayout.postHeaderDividerContentSpacing) + #expect(UsageMenuCardView.dividerBottomPadding(for: Self.model(creditsText: "$12.34 remaining")) == + UsageMenuCardLayout.sectionBottomPadding) + #expect(UsageMenuCardView.dividerBottomPadding(for: Self.model(usageNotes: ["Waiting for data"])) == + UsageMenuCardLayout.sectionBottomPadding) + #expect(UsageMenuCardView.dividerBottomPadding(for: Self.model(placeholder: "No usage yet")) == + UsageMenuCardLayout.sectionBottomPadding) + } + + private static func model( + metrics: [UsageMenuCardView.Model.Metric] = [], + usageNotes: [String] = [], + creditsText: String? = nil, + placeholder: String? = nil) -> UsageMenuCardView.Model + { + UsageMenuCardView.Model( + provider: .codex, + providerName: "Codex", + email: "steipete@gmail.com", + subtitleText: "Not fetched yet", + subtitleStyle: .info, + planText: "Pro 20x", + metrics: metrics, + usageNotes: usageNotes, + openAIAPIUsage: nil, + inlineUsageDashboard: nil, + creditsText: creditsText, + creditsRemaining: nil, + creditsProgressPercent: nil, + creditsScaleText: nil, + creditsHintText: nil, + creditsHintCopyText: nil, + providerCost: nil, + tokenUsage: nil, + placeholder: placeholder, + progressColor: .blue) + } +} diff --git a/Tests/CodexBarTests/UsagePaceTests.swift b/Tests/CodexBarTests/UsagePaceTests.swift index 0de92b0986..a5b3f78b2f 100644 --- a/Tests/CodexBarTests/UsagePaceTests.swift +++ b/Tests/CodexBarTests/UsagePaceTests.swift @@ -42,6 +42,36 @@ struct UsagePaceTests { #expect(pace.etaSeconds == nil) #expect(pace.runOutProbability == nil) #expect(pace.stage == .farBehind) + #expect(abs((pace.speedMultiplierToReset ?? 0) - 14.25) < 0.01) + } + + @Test + func `weekly pace speed headroom uses remaining burn capacity`() throws { + let now = Date(timeIntervalSince1970: 0) + let window = RateWindow( + usedPercent: 70, + windowMinutes: 10080, + resetsAt: now.addingTimeInterval(0.7 * 24 * 3600), + resetDescription: nil) + + let pace = try #require(UsagePace.weekly(window: window, now: now)) + + #expect(abs(pace.expectedUsedPercent - 90) < 0.01) + #expect(pace.willLastToReset) + #expect(abs((pace.speedMultiplierToReset ?? 0) - 3.857) < 0.01) + } + + @Test + func `historical pace speed headroom uses projected remaining usage`() { + let pace = UsagePace.historical( + expectedUsedPercent: 45, + actualUsedPercent: 20, + etaSeconds: nil, + willLastToReset: true, + runOutProbability: 0, + projectedRemainingUsage: 20) + + #expect(pace.speedMultiplierToReset == 4) } @Test @@ -76,6 +106,431 @@ struct UsagePaceTests { #expect(pace == nil) } + // MARK: - Workday-aware pace + + @Test + func `workday aware pace shows on track for five day user on friday`() throws { + // Window: Sun Jun 7 00:00 → Sun Jun 14 00:00 (7 days). + // "now" is Friday Jun 12 18:00 → elapsed = 5.75 days. + // 7-day linear: expected ≈ 82.1%, actual = 100% → ~18% deficit. + // 5-day workday: Mon-Thu plus 18 hours Friday → expected = 95%. + let calendar = Self.utcCalendar + + // Reset on Sunday Jun 14 00:00 + var resetComponents = DateComponents() + resetComponents.calendar = calendar + resetComponents.timeZone = calendar.timeZone + resetComponents.year = 2026 + resetComponents.month = 6 + resetComponents.day = 14 // Sunday + resetComponents.hour = 0 + resetComponents.minute = 0 + let resetsAt = try #require(calendar.date(from: resetComponents)) + + // "now" is Friday Jun 12 18:00 (30 hours before reset) + let now = resetsAt.addingTimeInterval(-30 * 3600) + + let window = RateWindow( + usedPercent: 100, + windowMinutes: 10080, + resetsAt: resetsAt, + resetDescription: nil) + + let pace7 = try #require(UsagePace.weekly(window: window, now: now, workDays: nil)) + let pace5 = try #require(UsagePace.weekly( + window: window, + now: now, + workDays: 5, + calendar: calendar)) + + // 7-day linear: expected ≈ 82%, actual = 100% → ~18% deficit + #expect(pace7.deltaPercent > 15) + + // 5-day workday: expected = 95%, so 100% actual remains within the on-pace threshold. + #expect(abs(pace5.expectedUsedPercent - 95) < 0.01) + #expect(abs(pace5.deltaPercent) <= 5) + } + + @Test + func `workday aware pace shows on track midweek`() throws { + // Window: Sun Jun 7 00:00 → Sun Jun 14 00:00. + // "now" is Thu Jun 11 00:00 → 3 full workdays (Mon-Wed) elapsed of 5. + // 5-day model: expected ≈ 60%. + let calendar = Self.utcCalendar + + // Reset on Sunday Jun 14 00:00 + var resetComponents = DateComponents() + resetComponents.calendar = calendar + resetComponents.timeZone = calendar.timeZone + resetComponents.year = 2026 + resetComponents.month = 6 + resetComponents.day = 14 // Sunday + resetComponents.hour = 0 + resetComponents.minute = 0 + let resetsAt = try #require(calendar.date(from: resetComponents)) + + // Thu Jun 11 00:00 (3 days before reset). + let now = resetsAt.addingTimeInterval(-72 * 3600) + + let window = RateWindow( + usedPercent: 60, + windowMinutes: 10080, + resetsAt: resetsAt, + resetDescription: nil) + + let pace5 = try #require(UsagePace.weekly( + window: window, + now: now, + workDays: 5, + calendar: calendar)) + + // 3 full workdays elapsed out of 5 → expected ≈ 60% + #expect(abs(pace5.expectedUsedPercent - 60) < 0.01) + #expect(abs(pace5.deltaPercent) < 0.01) + } + + @Test + func `workday aware exhausted quota does not last through weekend`() throws { + let calendar = Self.utcCalendar + + let resetsAt = try #require(calendar.date(from: DateComponents( + calendar: calendar, + timeZone: calendar.timeZone, + year: 2026, + month: 6, + day: 14))) + let now = try #require(calendar.date(from: DateComponents( + calendar: calendar, + timeZone: calendar.timeZone, + year: 2026, + month: 6, + day: 13, + hour: 12))) + let window = RateWindow( + usedPercent: 100, + windowMinutes: 10080, + resetsAt: resetsAt, + resetDescription: nil) + + let pace = try #require(UsagePace.weekly( + window: window, + now: now, + workDays: 5, + calendar: calendar)) + + #expect(pace.willLastToReset == false) + #expect(pace.etaSeconds == 0) + } + + @Test + func `workday aware eta excludes non workday elapsed time`() throws { + let calendar = Self.utcCalendar + + let resetsAt = try #require(calendar.date(from: DateComponents( + calendar: calendar, + timeZone: calendar.timeZone, + year: 2026, + month: 6, + day: 14))) + let now = try #require(calendar.date(from: DateComponents( + calendar: calendar, + timeZone: calendar.timeZone, + year: 2026, + month: 6, + day: 8, + hour: 12))) + let window = RateWindow( + usedPercent: 20, + windowMinutes: 10080, + resetsAt: resetsAt, + resetDescription: nil) + + let pace = try #require(UsagePace.weekly( + window: window, + now: now, + workDays: 5, + calendar: calendar)) + + #expect(pace.willLastToReset == false) + #expect(abs((pace.etaSeconds ?? 0) - (48 * 3600)) < 1) + } + + @Test + func `workday aware eta maps work time across a weekend`() throws { + let calendar = Self.utcCalendar + let resetsAt = try Self.date( + year: 2026, + month: 6, + day: 17, + hour: 0, + calendar: calendar) + let now = try Self.date( + year: 2026, + month: 6, + day: 12, + hour: 12, + calendar: calendar) + let window = RateWindow( + usedPercent: 60, + windowMinutes: 10080, + resetsAt: resetsAt, + resetDescription: nil) + + let pace = try #require(UsagePace.weekly( + window: window, + now: now, + workDays: 5, + calendar: calendar)) + + // 40 work hours remain at the observed rate: 12 hours Friday, all Monday, then 4 hours Tuesday. + #expect(pace.willLastToReset == false) + #expect(abs((pace.etaSeconds ?? 0) - (88 * 3600)) < 1) + } + + @Test + func `workday aware pace stays flat on non workdays`() throws { + let calendar = Self.utcCalendar + let resetsAt = try Self.date( + year: 2026, + month: 6, + day: 17, + hour: 0, + calendar: calendar) + let saturday = try Self.date( + year: 2026, + month: 6, + day: 13, + hour: 12, + calendar: calendar) + let sunday = try Self.date( + year: 2026, + month: 6, + day: 14, + hour: 12, + calendar: calendar) + let window = RateWindow( + usedPercent: 60, + windowMinutes: 10080, + resetsAt: resetsAt, + resetDescription: nil) + + let saturdayPace = try #require(UsagePace.weekly( + window: window, + now: saturday, + workDays: 5, + calendar: calendar)) + let sundayPace = try #require(UsagePace.weekly( + window: window, + now: sunday, + workDays: 5, + calendar: calendar)) + + #expect(abs(saturdayPace.expectedUsedPercent - 60) < 0.01) + #expect(sundayPace.expectedUsedPercent == saturdayPace.expectedUsedPercent) + } + + @Test + func `zero usage becomes safe only after the first configured workday begins`() throws { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = try #require(TimeZone(identifier: "America/Los_Angeles")) + let resetsAt = try Self.date( + year: 2026, + month: 6, + day: 14, + hour: 0, + calendar: calendar) + let firstWorkday = try Self.date( + year: 2026, + month: 6, + day: 8, + hour: 0, + calendar: calendar) + let window = RateWindow( + usedPercent: 0, + windowMinutes: 10080, + resetsAt: resetsAt, + resetDescription: nil) + + let before = try #require(UsagePace.weekly( + window: window, + now: firstWorkday.addingTimeInterval(-1), + workDays: 5, + calendar: calendar)) + let boundary = try #require(UsagePace.weekly( + window: window, + now: firstWorkday, + workDays: 5, + calendar: calendar)) + let after = try #require(UsagePace.weekly( + window: window, + now: firstWorkday.addingTimeInterval(3600), + workDays: 5, + calendar: calendar)) + + #expect(before.expectedUsedPercent == 0) + #expect(before.willLastToReset == false) + #expect(boundary.expectedUsedPercent == 0) + #expect(boundary.willLastToReset == false) + #expect(after.expectedUsedPercent > 0) + #expect(after.willLastToReset == true) + } + + @Test + func `workday aware pace does not declare zero usage safe before first workday`() throws { + let calendar = Self.utcCalendar + + let resetsAt = try #require(calendar.date(from: DateComponents( + calendar: calendar, + timeZone: calendar.timeZone, + year: 2026, + month: 6, + day: 14))) + let now = try #require(calendar.date(from: DateComponents( + calendar: calendar, + timeZone: calendar.timeZone, + year: 2026, + month: 6, + day: 7, + hour: 12))) + let window = RateWindow( + usedPercent: 0, + windowMinutes: 10080, + resetsAt: resetsAt, + resetDescription: nil) + + let pace = try #require(UsagePace.weekly( + window: window, + now: now, + workDays: 5, + calendar: calendar)) + + #expect(pace.expectedUsedPercent == 0) + #expect(pace.willLastToReset == false) + #expect(pace.etaSeconds == nil) + } + + @Test + func `workday aware exhausted quota stays exhausted before first workday`() throws { + let calendar = Self.utcCalendar + + let resetsAt = try #require(calendar.date(from: DateComponents( + calendar: calendar, + timeZone: calendar.timeZone, + year: 2026, + month: 6, + day: 14))) + let now = try #require(calendar.date(from: DateComponents( + calendar: calendar, + timeZone: calendar.timeZone, + year: 2026, + month: 6, + day: 7, + hour: 12))) + let window = RateWindow( + usedPercent: 100, + windowMinutes: 10080, + resetsAt: resetsAt, + resetDescription: nil) + + let pace = try #require(UsagePace.weekly( + window: window, + now: now, + workDays: 5, + calendar: calendar)) + + #expect(pace.willLastToReset == false) + #expect(pace.etaSeconds == 0) + } + + @Test + func `workday aware pace splits a non midnight reset at local day boundaries`() throws { + let calendar = Self.utcCalendar + + let resetsAt = try #require(calendar.date(from: DateComponents( + calendar: calendar, + timeZone: calendar.timeZone, + year: 2026, + month: 6, + day: 14, + hour: 20))) + let now = try #require(calendar.date(from: DateComponents( + calendar: calendar, + timeZone: calendar.timeZone, + year: 2026, + month: 6, + day: 8, + hour: 12))) + let window = RateWindow( + usedPercent: 10, + windowMinutes: 10080, + resetsAt: resetsAt, + resetDescription: nil) + + let pace = try #require(UsagePace.weekly( + window: window, + now: now, + workDays: 5, + calendar: calendar)) + + // The weekly window starts Sunday at 20:00. Monday 00:00-12:00 is 12 of + // the week's 120 work hours, so it must contribute 10% despite the reset offset. + #expect(abs(pace.expectedUsedPercent - 10) < 0.01) + #expect(abs(pace.deltaPercent) < 0.01) + } + + @Test + func `workday aware pace falls back to linear when workDays is nil or 7`() throws { + let now = Date(timeIntervalSince1970: 0) + let window = RateWindow( + usedPercent: 50, + windowMinutes: 10080, + resetsAt: now.addingTimeInterval(4 * 24 * 3600), + resetDescription: nil) + + let paceNil = try #require(UsagePace.weekly(window: window, now: now, workDays: nil)) + let pace7 = try #require(UsagePace.weekly(window: window, now: now, workDays: 7)) + let paceDefault = try #require(UsagePace.weekly(window: window, now: now)) + + // All should produce identical expected values (linear) + #expect(abs(paceNil.expectedUsedPercent - paceDefault.expectedUsedPercent) < 0.01) + #expect(abs(pace7.expectedUsedPercent - paceDefault.expectedUsedPercent) < 0.01) + } + + @Test + func `workdays off linear weekly pace keeps deficit sign`() throws { + let now = Date(timeIntervalSince1970: 0) + let window = RateWindow( + usedPercent: 88, + windowMinutes: 10080, + resetsAt: now.addingTimeInterval(2 * 24 * 3600 + 19 * 3600), + resetDescription: nil) + + let pace = try #require(UsagePace.weekly(window: window, now: now, workDays: nil)) + + #expect(abs(pace.expectedUsedPercent - (101.0 / 168.0 * 100.0)) < 0.01) + #expect(pace.deltaPercent > 25) + #expect(pace.stage == .farAhead) + #expect(pace.willLastToReset == false) + } + + @Test + func `workday aware pace ignores non weekly windows`() throws { + let now = Date(timeIntervalSince1970: 0) + // 300-minute session window — workDays should have no effect + let window = RateWindow( + usedPercent: 50, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(2 * 3600), + resetDescription: nil) + + let paceNoWork = try #require( + UsagePace.weekly(window: window, now: now, defaultWindowMinutes: 300, workDays: nil)) + let paceWork5 = try #require( + UsagePace.weekly(window: window, now: now, defaultWindowMinutes: 300, workDays: 5)) + + #expect(abs(paceNoWork.expectedUsedPercent - paceWork5.expectedUsedPercent) < 0.01) + } + @Test func `session pace computes delta and eta for five hour window`() { let now = Date(timeIntervalSince1970: 0) @@ -94,4 +549,44 @@ struct UsagePaceTests { #expect(pace.stage == .behind) #expect(pace.willLastToReset == true) } + + @Test + func `one work day falls back to linear pace`() throws { + let now = Date(timeIntervalSince1970: 0) + let window = RateWindow( + usedPercent: 50, + windowMinutes: 10080, + resetsAt: now.addingTimeInterval(4 * 24 * 3600), + resetDescription: nil) + + let paceOne = try #require(UsagePace.weekly(window: window, now: now, workDays: 1)) + let paceNil = try #require(UsagePace.weekly(window: window, now: now)) + + // workDays == 1 should fall back to linear pace, identical to workDays: nil + #expect(abs(paceOne.expectedUsedPercent - paceNil.expectedUsedPercent) < 0.01) + } + + private static var utcCalendar: Calendar { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(secondsFromGMT: 0)! + return calendar + } + + private static func date( + year: Int, + month: Int, + day: Int, + hour: Int, + minute: Int = 0, + calendar: Calendar) throws -> Date + { + try #require(calendar.date(from: DateComponents( + calendar: calendar, + timeZone: calendar.timeZone, + year: year, + month: month, + day: day, + hour: hour, + minute: minute))) + } } diff --git a/Tests/CodexBarTests/UsagePaceTextTests.swift b/Tests/CodexBarTests/UsagePaceTextTests.swift index 2fc4cc1ef2..a703689995 100644 --- a/Tests/CodexBarTests/UsagePaceTextTests.swift +++ b/Tests/CodexBarTests/UsagePaceTextTests.swift @@ -15,7 +15,16 @@ struct UsagePaceTextTests { "Projected empty in %@", "Runs out now", "Runs out in %@", + "1.5× headroom", "≈ %d%% run-out risk", + "%@ left", + "session quota", + "session quotas", + "session_quota_estimate_value_format", + "≈%d full 5h windows of weekly left · %d windows until reset", + "Weekly cannot run out before reset at this pace", + "Weekly can run out ≈%d windows early", + "Estimated: %@", "%@ · %@", ] @@ -29,12 +38,28 @@ struct UsagePaceTextTests { resetDescription: nil) let pace = try #require(UsagePace.weekly(window: window, now: now)) - let detail = UsagePaceText.weeklyDetail(pace: pace, now: now) + let detail = UsagePaceText.weeklyDetail(provider: .codex, pace: pace, now: now) #expect(detail.leftLabel == "7% in deficit") #expect(detail.rightLabel == "Runs out in 3d") } + @Test + func `weekly pace detail treats rounded zero delta as on pace`() { + let now = Date(timeIntervalSince1970: 0) + let pace = UsagePace( + stage: .slightlyBehind, + deltaPercent: -0.4, + expectedUsedPercent: 50.4, + actualUsedPercent: 50, + etaSeconds: nil, + willLastToReset: true) + + let detail = UsagePaceText.weeklyDetail(provider: .codex, pace: pace, now: now) + + #expect(detail.leftLabel == "On pace") + } + @Test func `weekly pace detail reports lasts until reset`() throws { let now = Date(timeIntervalSince1970: 0) @@ -45,10 +70,10 @@ struct UsagePaceTextTests { resetDescription: nil) let pace = try #require(UsagePace.weekly(window: window, now: now)) - let detail = UsagePaceText.weeklyDetail(pace: pace, now: now) + let detail = UsagePaceText.weeklyDetail(provider: .codex, pace: pace, now: now) #expect(detail.leftLabel == "33% in reserve") - #expect(detail.rightLabel == "Lasts until reset") + #expect(detail.rightLabel == "Lasts until reset · 1.5× headroom") } @Test @@ -61,11 +86,75 @@ struct UsagePaceTextTests { resetDescription: nil) let pace = try #require(UsagePace.weekly(window: window, now: now)) - let summary = UsagePaceText.weeklySummary(pace: pace, now: now) + let summary = UsagePaceText.weeklySummary(provider: .codex, pace: pace, now: now) #expect(summary == "Pace: 7% in deficit · Runs out in 3d") } + @Test + func `weekly pace detail reports capped speed headroom when under pace`() throws { + let now = Date(timeIntervalSince1970: 0) + let window = RateWindow( + usedPercent: 20, + windowMinutes: 10080, + resetsAt: now.addingTimeInterval(3 * 24 * 3600), + resetDescription: nil) + let pace = try #require(UsagePace.weekly(window: window, now: now)) + + let detail = UsagePaceText.weeklyDetail(provider: .codex, pace: pace, now: now) + + #expect(detail.leftLabel == "37% in reserve") + #expect(detail.rightLabel == "Lasts until reset · 1.5× headroom") + } + + @Test + func `weekly pace detail limits headroom hint to Codex`() throws { + let now = Date(timeIntervalSince1970: 0) + let window = RateWindow( + usedPercent: 20, + windowMinutes: 10080, + resetsAt: now.addingTimeInterval(3 * 24 * 3600), + resetDescription: nil) + let pace = try #require(UsagePace.weekly(window: window, now: now)) + + let detail = UsagePaceText.weeklyDetail(provider: .claude, pace: pace, now: now) + + #expect(detail.rightLabel == "Lasts until reset") + } + + @Test + func `weekly pace detail reports remaining headroom late in window`() throws { + let now = Date(timeIntervalSince1970: 0) + let window = RateWindow( + usedPercent: 70, + windowMinutes: 10080, + resetsAt: now.addingTimeInterval(0.7 * 24 * 3600), + resetDescription: nil) + let pace = try #require(UsagePace.weekly(window: window, now: now)) + + let detail = UsagePaceText.weeklyDetail(provider: .codex, pace: pace, now: now) + + #expect(detail.leftLabel == "20% in reserve") + #expect(detail.rightLabel == "Lasts until reset · 1.5× headroom") + } + + @Test + func `reported weekly state renders deficit and run out headline`() throws { + let now = Date(timeIntervalSince1970: 0) + let window = RateWindow( + usedPercent: 88, + windowMinutes: 10080, + resetsAt: now.addingTimeInterval((2 * 24 + 19) * 3600), + resetDescription: nil) + let pace = try #require(UsagePace.weekly(window: window, now: now, workDays: nil)) + + let detail = UsagePaceText.weeklyDetail(provider: .codex, pace: pace, now: now) + + #expect(detail.leftLabel == "28% in deficit") + #expect(detail.rightLabel == "Runs out in 13h 47m") + #expect(detail.rightLabel?.contains("Lasts until reset") == false) + } + @Test func `weekly pace detail formats rounded risk when available`() { let now = Date(timeIntervalSince1970: 0) @@ -78,11 +167,64 @@ struct UsagePaceTextTests { willLastToReset: false, runOutProbability: 0.683) - let detail = UsagePaceText.weeklyDetail(pace: pace, now: now) + let detail = UsagePaceText.weeklyDetail(provider: .codex, pace: pace, now: now) #expect(detail.rightLabel == "Runs out in 2d · ≈ 70% run-out risk") } + @Test + func `weekly pace detail does not combine lasts until reset with run out risk`() { + let now = Date(timeIntervalSince1970: 0) + let pace = UsagePace( + stage: .slightlyBehind, + deltaPercent: -9, + expectedUsedPercent: 21, + actualUsedPercent: 12, + etaSeconds: nil, + willLastToReset: true, + runOutProbability: 0.45) + + let detail = UsagePaceText.weeklyDetail(provider: .codex, pace: pace, now: now) + + #expect(detail.leftLabel == "9% in reserve") + #expect(detail.rightLabel == "≈ 45% run-out risk") + } + + @Test + func `weekly pace detail keeps lasts until reset only when rounded risk is zero`() { + let now = Date(timeIntervalSince1970: 0) + let pace = UsagePace( + stage: .farBehind, + deltaPercent: -30, + expectedUsedPercent: 40, + actualUsedPercent: 10, + etaSeconds: nil, + willLastToReset: true, + runOutProbability: 0.02, + speedMultiplierToReset: 4) + + let detail = UsagePaceText.weeklyDetail(provider: .codex, pace: pace, now: now) + + #expect(detail.rightLabel == "Lasts until reset · 1.5× headroom · ≈ 0% run-out risk") + } + + @Test + func `weekly pace detail prefers risk over lasts until reset when rounded risk is material`() { + let now = Date(timeIntervalSince1970: 0) + let pace = UsagePace( + stage: .slightlyBehind, + deltaPercent: -9, + expectedUsedPercent: 21, + actualUsedPercent: 12, + etaSeconds: nil, + willLastToReset: true, + runOutProbability: 0.03) + + let detail = UsagePaceText.weeklyDetail(provider: .codex, pace: pace, now: now) + + #expect(detail.rightLabel == "≈ 5% run-out risk") + } + // MARK: - Session pace (5-hour window) @Test @@ -105,7 +247,7 @@ struct UsagePaceTextTests { } @Test - func `session pace detail reports lasts until reset`() { + func `Claude session pace does not show Codex headroom`() { let now = Date(timeIntervalSince1970: 0) // 300-minute window, 2h remaining => 3h elapsed // expected = 60%, actual = 10% => far behind (in reserve) @@ -122,6 +264,20 @@ struct UsagePaceTextTests { #expect(detail?.rightLabel == "Lasts until reset") } + @Test + func `Codex session pace shows conservative headroom`() { + let now = Date(timeIntervalSince1970: 0) + let window = RateWindow( + usedPercent: 10, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(2 * 3600), + resetDescription: nil) + + let detail = UsagePaceText.sessionDetail(provider: .codex, window: window, now: now) + + #expect(detail?.rightLabel == "Lasts until reset · 1.5× headroom") + } + @Test func `session pace summary formats single line text`() { let now = Date(timeIntervalSince1970: 0) @@ -151,6 +307,35 @@ struct UsagePaceTextTests { #expect(detail?.rightLabel == "Projected empty in 45m") } + @Test + func `session pace detail supports Antigravity five hour window`() { + let now = Date(timeIntervalSince1970: 0) + let window = RateWindow( + usedPercent: 80, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(2 * 3600), + resetDescription: nil) + + let detail = UsagePaceText.sessionDetail(provider: .antigravity, window: window, now: now) + + #expect(detail?.leftLabel == "20% in deficit") + #expect(detail?.rightLabel == "Projected empty in 45m") + } + + @Test + func `session pace detail hides Antigravity weekly window`() { + let now = Date(timeIntervalSince1970: 0) + let window = RateWindow( + usedPercent: 80, + windowMinutes: 10080, + resetsAt: now.addingTimeInterval(2 * 24 * 3600), + resetDescription: nil) + + let detail = UsagePaceText.sessionDetail(provider: .antigravity, window: window, now: now) + + #expect(detail == nil) + } + @Test func `session pace detail hides Ollama window without explicit duration`() { let now = Date(timeIntervalSince1970: 0) @@ -215,6 +400,29 @@ struct UsagePaceTextTests { } } + @Test + func `session quota estimate template localizes CJK number unit spacing`() throws { + let root = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + let expectations = [ + (language: "zh-Hans", unit: "会话额度", expected: "0.3会话额度"), + (language: "zh-Hant", unit: "工作階段額度", expected: "0.3工作階段額度"), + (language: "ja", unit: "セッション枠", expected: "0.3セッション枠"), + (language: "ko", unit: "세션 할당량", expected: "0.3 세션 할당량"), + ] + + for expectation in expectations { + let url = root.appendingPathComponent( + "Sources/CodexBar/Resources/\(expectation.language).lproj/Localizable.strings") + let table = try Self.readStringsTable(at: url) + let template = try #require(table["session_quota_estimate_value_format"]) + let arguments: [CVarArg] = ["0.3", expectation.unit] + #expect(String(format: template, arguments: arguments) == expectation.expected) + } + } + private static func readStringsTable(at url: URL) throws -> [String: String] { guard let dict = NSDictionary(contentsOf: url) as? [String: String] else { throw NSError( diff --git a/Tests/CodexBarTests/UsagePercentTests.swift b/Tests/CodexBarTests/UsagePercentTests.swift new file mode 100644 index 0000000000..4dec18669b --- /dev/null +++ b/Tests/CodexBarTests/UsagePercentTests.swift @@ -0,0 +1,27 @@ +import Testing +@testable import CodexBarCore + +struct UsagePercentTests { + @Test + func `display normalization preserves boundaries and small percentages`() { + #expect(UsagePercent(raw: 0).displayClamped == 0) + #expect(UsagePercent(raw: 0.25).displayClamped == 0.25) + #expect(UsagePercent(raw: 100).displayClamped == 100) + } + + @Test + func `display normalization clamps overage while preserving the raw percentage`() { + let percent = UsagePercent(used: 150, limit: 100) + + #expect(percent.raw == 150) + #expect(percent.displayClamped == 100) + } + + @Test + func `display normalization guards negative usage`() { + let percent = UsagePercent(used: -1, limit: 100) + + #expect(percent.raw == -1) + #expect(percent.displayClamped == 0) + } +} diff --git a/Tests/CodexBarTests/UsageStoreAccountQuotaWarningTests.swift b/Tests/CodexBarTests/UsageStoreAccountQuotaWarningTests.swift new file mode 100644 index 0000000000..b85a06b749 --- /dev/null +++ b/Tests/CodexBarTests/UsageStoreAccountQuotaWarningTests.swift @@ -0,0 +1,144 @@ +import Foundation +import Testing +@testable import CodexBar +@testable import CodexBarCore + +@MainActor +struct UsageStoreAccountQuotaWarningTests { + @MainActor + private final class NotifierSpy: SessionQuotaNotifying { + private(set) var quotaWarnings: [QuotaWarningEvent] = [] + + func post(transition _: SessionQuotaTransition, provider _: UsageProvider, badge _: NSNumber?) {} + + func postQuotaWarning( + event: QuotaWarningEvent, + provider _: UsageProvider, + soundEnabled _: Bool, + onScreenAlertEnabled _: Bool) + { + self.quotaWarnings.append(event) + } + } + + @Test + func `ordinary refresh keeps selected token account warning episodes independent`() async { + let settings = testSettingsStore( + suiteName: "UsageStoreAccountQuotaWarningTests-selected-account", + tokenAccountStore: InMemoryTokenAccountStore()) + settings.refreshFrequency = .manual + settings.statusChecksEnabled = false + settings.quotaWarningNotificationsEnabled = true + settings.quotaWarningThresholds = [50] + settings.setQuotaWarningWindowEnabled(.session, enabled: true) + settings.setQuotaWarningWindowEnabled(.weekly, enabled: false) + settings.sessionQuotaNotificationsEnabled = false + settings.predictivePaceWarningNotificationsEnabled = false + settings.addTokenAccount(provider: .deepseek, label: "First", token: "fixture") + settings.addTokenAccount(provider: .deepseek, label: "Second", token: "fixture") + #expect(settings.tokenAccounts(for: .deepseek).map(\.label) == ["First", "Second"]) + + let notifier = NotifierSpy() + let store = UsageStore( + fetcher: UsageFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + sessionQuotaNotifier: notifier, + startupBehavior: .testing) + let sequence = [ + (accountIndex: 0, usedPercent: 40.0), + (accountIndex: 1, usedPercent: 40.0), + (accountIndex: 0, usedPercent: 55.0), + (accountIndex: 1, usedPercent: 30.0), + (accountIndex: 0, usedPercent: 55.0), + (accountIndex: 1, usedPercent: 55.0), + ] + + for (step, observation) in sequence.enumerated() { + settings.setActiveTokenAccountIndex(observation.accountIndex, for: .deepseek) + let outcome = Self.outcome( + usedPercent: observation.usedPercent, + updatedAt: Date(timeIntervalSince1970: 1_780_000_000 + Double(step))) + store._test_providerFetchOutcomeOverride = { _ in outcome } + await store.refreshProvider(.deepseek, allowDisabled: true) + } + + #expect(notifier.quotaWarnings.map(\.accountDisplayName) == ["First", "Second"]) + #expect(notifier.quotaWarnings.allSatisfy { $0.threshold == 50 }) + } + + @Test + func `selected outcome keeps token account warning episodes independent`() async throws { + let settings = testSettingsStore( + suiteName: "UsageStoreAccountQuotaWarningTests-selected-outcome", + tokenAccountStore: InMemoryTokenAccountStore()) + settings.quotaWarningNotificationsEnabled = true + settings.quotaWarningThresholds = [50] + settings.setQuotaWarningWindowEnabled(.session, enabled: true) + settings.setQuotaWarningWindowEnabled(.weekly, enabled: false) + settings.sessionQuotaNotificationsEnabled = false + settings.predictivePaceWarningNotificationsEnabled = false + + let accounts = try [ + ProviderTokenAccount( + id: #require(UUID(uuidString: "00000000-0000-0000-0000-000000000001")), + label: "First", + token: "fixture", + addedAt: 0, + lastUsed: nil), + ProviderTokenAccount( + id: #require(UUID(uuidString: "00000000-0000-0000-0000-000000000002")), + label: "Second", + token: "fixture", + addedAt: 0, + lastUsed: nil), + ] + let notifier = NotifierSpy() + let store = UsageStore( + fetcher: UsageFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + sessionQuotaNotifier: notifier, + startupBehavior: .testing) + let sequence = [ + (accountIndex: 0, usedPercent: 40.0), + (accountIndex: 1, usedPercent: 40.0), + (accountIndex: 0, usedPercent: 55.0), + (accountIndex: 1, usedPercent: 30.0), + (accountIndex: 0, usedPercent: 55.0), + (accountIndex: 1, usedPercent: 55.0), + ] + + for (step, observation) in sequence.enumerated() { + await store.applySelectedOutcome( + Self.outcome( + usedPercent: observation.usedPercent, + updatedAt: Date(timeIntervalSince1970: 1_780_000_000 + Double(step))), + provider: .deepseek, + account: accounts[observation.accountIndex], + fallbackSnapshot: nil) + } + + #expect(notifier.quotaWarnings.map(\.accountDisplayName) == ["First", "Second"]) + #expect(notifier.quotaWarnings.allSatisfy { $0.threshold == 50 }) + } + + private static func outcome(usedPercent: Double, updatedAt: Date) -> ProviderFetchOutcome { + ProviderFetchOutcome( + result: .success(ProviderFetchResult( + usage: UsageSnapshot( + primary: RateWindow( + usedPercent: usedPercent, + windowMinutes: 300, + resetsAt: nil, + resetDescription: nil), + secondary: nil, + updatedAt: updatedAt), + credits: nil, + dashboard: nil, + sourceLabel: "fixture", + strategyID: "fixture.api-token", + strategyKind: .apiToken)), + attempts: []) + } +} diff --git a/Tests/CodexBarTests/UsageStoreCachedTokenHydrationTests.swift b/Tests/CodexBarTests/UsageStoreCachedTokenHydrationTests.swift new file mode 100644 index 0000000000..0237fe4542 --- /dev/null +++ b/Tests/CodexBarTests/UsageStoreCachedTokenHydrationTests.swift @@ -0,0 +1,331 @@ +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) + } + + @Test + func `fresh cached hydration suppresses the redundant startup token refresh`() async throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let now = Date() + try Self.writeCodexSessionFile( + homeRoot: env.codexHomeRoot, + env: env, + day: now, + filename: "cached.jsonl", + tokens: 42) + + let options = CostUsageScanner.Options( + codexSessionsRoot: env.codexSessionsRoot, + cacheRoot: env.cacheRoot) + _ = try await CostUsageFetcher.loadTokenSnapshot( + provider: .codex, + now: now, + 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: [:]) + var tokenRefreshCount = 0 + store._test_tokenUsageRefreshOverride = { _, _ in tokenRefreshCount += 1 } + + store.hydrateCachedTokenSnapshots(now: now) + for _ in 0..<100 where store.tokenSnapshot(for: .codex) == nil { + try await Task.sleep(for: .milliseconds(10)) + } + + await store.refreshTokenUsageNow(for: .codex, force: false) + + #expect(store.tokenSnapshot(for: .codex)?.sessionTokens == 42) + #expect(store.tokenLastAttemptAt(for: .codex).map { abs($0.timeIntervalSince(now)) < 0.001 } == true) + #expect(tokenRefreshCount == 0) + } + + @Test + func `stale cached hydration still allows the startup token refresh`() async throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let now = Date() + try Self.writeCodexSessionFile( + homeRoot: env.codexHomeRoot, + env: env, + day: now, + filename: "cached.jsonl", + tokens: 42) + + let options = CostUsageScanner.Options( + codexSessionsRoot: env.codexSessionsRoot, + cacheRoot: env.cacheRoot) + _ = try await CostUsageFetcher.loadTokenSnapshot( + provider: .codex, + now: now, + historyDays: 1, + scannerOptions: options) + var cache = CostUsageCacheIO.load(provider: .codex, cacheRoot: env.cacheRoot) + cache.lastScanUnixMs = Int64(now.addingTimeInterval(-2 * 60 * 60).timeIntervalSince1970 * 1000) + CostUsageCacheIO.save(provider: .codex, cache: cache, cacheRoot: env.cacheRoot) + + let settings = Self.makeCodexOnlySettings(historyDays: 1) + let store = UsageStore( + fetcher: UsageFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0), + costUsageFetcher: CostUsageFetcher(scannerOptions: options), + settings: settings, + startupBehavior: .testing, + environmentBase: [:]) + var tokenRefreshCount = 0 + store._test_tokenUsageRefreshOverride = { _, _ in tokenRefreshCount += 1 } + + store.hydrateCachedTokenSnapshots(now: now) + for _ in 0..<100 where store.tokenSnapshot(for: .codex) == nil { + try await Task.sleep(for: .milliseconds(10)) + } + + await store.refreshTokenUsageNow(for: .codex, force: false) + + #expect(store.tokenSnapshot(for: .codex)?.sessionTokens == 42) + #expect(store.tokenLastAttemptAt(for: .codex) != nil) + #expect(tokenRefreshCount == 1) + } + + @Test + func `confirmed empty publication wins over in flight cached codex hydration`() async { + let settings = Self.makeCodexOnlySettings(historyDays: 1) + let store = UsageStore( + fetcher: UsageFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + startupBehavior: .testing, + environmentBase: [:]) + let gate = CachedTokenHydrationGate() + store._test_cachedCodexTokenSnapshotLoaderOverride = { _, _, _ in + await gate.enter() + return (Self.cachedTokenSnapshot(), Date()) + } + + let hydration = store.hydrateCachedTokenSnapshots() + await gate.waitForStart() + store.publishConfirmedEmptyTokenSnapshot(for: .codex) + let confirmedEmptyRevision = store.tokenSnapshotPublicationRevision(for: .codex) + await gate.release() + await hydration?.value + + let publication = store.tokenSnapshotPublicationForCurrentProviderConfig(for: .codex) + #expect(hydration != nil) + #expect(publication?.snapshot == nil) + #expect(publication?.publicationRevision == confirmedEmptyRevision) + #expect(store.tokenSnapshot(for: .codex) == nil) + #expect(store.tokenLastAttemptAt(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 = .fiveMinutes + 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 cachedTokenSnapshot() -> CostUsageTokenSnapshot { + CostUsageTokenSnapshot( + sessionTokens: 42, + sessionCostUSD: 1, + last30DaysTokens: 42, + last30DaysCostUSD: 1, + daily: [], + updatedAt: Date()) + } + + 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 actor CachedTokenHydrationGate { + private var started = false + private var released = false + private var startWaiters: [CheckedContinuation] = [] + private var releaseWaiters: [CheckedContinuation] = [] + + func enter() async { + self.started = true + let waiters = self.startWaiters + self.startWaiters.removeAll() + waiters.forEach { $0.resume() } + guard !self.released else { return } + await withCheckedContinuation { continuation in + self.releaseWaiters.append(continuation) + } + } + + func waitForStart() async { + guard !self.started else { return } + await withCheckedContinuation { continuation in + self.startWaiters.append(continuation) + } + } + + func release() { + self.released = true + let waiters = self.releaseWaiters + self.releaseWaiters.removeAll() + waiters.forEach { $0.resume() } + } +} diff --git a/Tests/CodexBarTests/UsageStoreCoverageTests.swift b/Tests/CodexBarTests/UsageStoreCoverageTests.swift index f771e0bc8a..85af8b03c9 100644 --- a/Tests/CodexBarTests/UsageStoreCoverageTests.swift +++ b/Tests/CodexBarTests/UsageStoreCoverageTests.swift @@ -1,10 +1,28 @@ -import CodexBarCore import Foundation +import Observation import Testing @testable import CodexBar +@testable import CodexBarCore @MainActor struct UsageStoreCoverageTests { + private final class ObservationFlag: @unchecked Sendable { + private let lock = NSLock() + private var value = false + + func set() { + self.lock.lock() + self.value = true + self.lock.unlock() + } + + func get() -> Bool { + self.lock.lock() + defer { self.lock.unlock() } + return self.value + } + } + @Test func `provider with highest usage and icon style`() throws { let settings = Self.makeSettingsStore(suite: "UsageStoreCoverageTests-highest") @@ -48,6 +66,116 @@ struct UsageStoreCoverageTests { #expect(store.isStale) } + @Test + func `cursor credential fingerprint is stable and does not expose the cookie`() { + let cookie = "fixture=a" + let fingerprint = CookieHeaderCache.credentialFingerprint(cookie) + + #expect(fingerprint == CookieHeaderCache.credentialFingerprint(" \(cookie) ")) + #expect(fingerprint != CookieHeaderCache.credentialFingerprint("fixture=b")) + #expect(!fingerprint.contains("fixture=a")) + } + + @Test + func `cursor manual cost refresh rejects an empty cookie without falling back`() async throws { + let settings = Self.makeSettingsStore(suite: "UsageStoreCoverageTests-cursor-manual-cost") + settings.costUsageEnabled = true + settings.cursorCookieSource = .manual + settings.cursorCookieHeader = " " + let metadata = try #require(ProviderRegistry.shared.metadata[.cursor]) + settings.setProviderEnabled(provider: .cursor, metadata: metadata, enabled: true) + let store = Self.makeUsageStore(settings: settings) + let invoked = ObservationFlag() + store._test_tokenUsageSnapshotLoaderOverride = { _, _, now, _, _ in + invoked.set() + return CostUsageTokenSnapshot( + sessionTokens: nil, + sessionCostUSD: nil, + last30DaysTokens: nil, + last30DaysCostUSD: nil, + meteredCostUSD: 1, + daily: [], + updatedAt: now) + } + + await store.refreshTokenUsage(.cursor, force: true) + + #expect(!invoked.get()) + #expect(store.tokenSnapshot(for: .cursor) == nil) + #expect(store.tokenError(for: .cursor)?.contains("non-empty Manual cookie header") == true) + #expect(store.tokenSnapshotScopeSignature(for: .cursor).contains("manual:missing")) + } + + @Test + func `cursor metered-only cost refresh publishes the snapshot`() async throws { + let settings = Self.makeSettingsStore(suite: "UsageStoreCoverageTests-cursor-metered-only") + settings.costUsageEnabled = true + settings.cursorCookieSource = .manual + settings.cursorCookieHeader = "fixture=cursor" + let metadata = try #require(ProviderRegistry.shared.metadata[.cursor]) + settings.setProviderEnabled(provider: .cursor, metadata: metadata, enabled: true) + let store = Self.makeUsageStore(settings: settings) + store._test_tokenUsageSnapshotLoaderOverride = { _, _, now, _, _ in + CostUsageTokenSnapshot( + sessionTokens: nil, + sessionCostUSD: nil, + last30DaysTokens: nil, + last30DaysCostUSD: nil, + meteredCostUSD: 1.25, + daily: [], + updatedAt: now) + } + + await store.refreshTokenUsage(.cursor, force: true) + + #expect(store.tokenSnapshot(for: .cursor)?.meteredCostUSD == 1.25) + #expect(store.tokenError(for: .cursor) == nil) + } + + @Test + func `cursor auto credential resolution cannot relax a changed history window`() throws { + let settings = Self.makeSettingsStore(suite: "UsageStoreCoverageTests-cursor-history-race") + settings.costUsageEnabled = true + settings.costUsageHistoryDays = 30 + settings.cursorCookieSource = .auto + let metadata = try #require(ProviderRegistry.shared.metadata[.cursor]) + settings.setProviderEnabled(provider: .cursor, metadata: metadata, enabled: true) + let store = Self.makeUsageStore(settings: settings) + let cookie = "fixture=resolved" + let fingerprint = CookieHeaderCache.credentialFingerprint(cookie) + let generation = CookieHeaderCache.beginDisplayReadGenerationForTesting(provider: .cursor) + let previousEntry = CookieHeaderCache.currentDisplayEntryForTesting(provider: .cursor) + _ = CookieHeaderCache.commitDisplaySnapshotIfCurrentForTesting( + provider: .cursor, + entry: CookieHeaderCache.Entry( + cookieHeader: cookie, + storedAt: Date(), + sourceLabel: "test"), + generation: generation) + defer { + _ = CookieHeaderCache.commitDisplaySnapshotIfCurrentForTesting( + provider: .cursor, + entry: previousEntry, + generation: generation) + } + + let initialSignature = store.cursorCostScopeSignature( + historyDays: 30, + source: .auto, + credentialFingerprint: "unresolved") + let revision = store.providerPublicationRevision(for: .cursor) + let providerConfigRevision = settings.providerConfigRevision(for: .cursor) + settings.costUsageHistoryDays = 7 + + #expect(!store.tokenRefreshPublicationIsCurrent( + provider: .cursor, + publicationRevision: revision, + providerConfigRevision: providerConfigRevision, + historyDays: 30, + costScopeSignature: initialSignature, + fetchedCredentialScopeFingerprint: fingerprint)) + } + @Test func `source label adds open AI web`() { let settings = Self.makeSettingsStore(suite: "UsageStoreCoverageTests-source") @@ -70,6 +198,98 @@ struct UsageStoreCoverageTests { #expect(label.contains("openai-web")) } + @Test + func `amp balances are rendered in provider cards`() { + let settings = Self.makeSettingsStore(suite: "UsageStoreCoverageTests-amp-credits") + let store = Self.makeUsageStore(settings: settings) + let now = Date() + + store._setSnapshotForTesting( + UsageSnapshot( + primary: RateWindow( + usedPercent: 51.4, + windowMinutes: 1440, + resetsAt: now.addingTimeInterval(12 * 3600), + resetDescription: nil), + secondary: nil, + ampUsage: AmpUsageDetails( + individualCredits: 25.64, + workspaceBalances: [AmpWorkspaceBalance(name: "billing@example.test", remaining: 10.22)]), + updatedAt: now), + provider: .amp) + let model = ProvidersPane(settings: settings, store: store)._test_menuCardModel(for: .amp) + + #expect(model.metrics.map(\.title) == ["Amp Free"]) + #expect(model.metrics.allSatisfy { $0.pacePercent == nil }) + #expect(model.creditsText == "Individual credits: $25.64\nWorkspace billing@example.test: $10.22") + #expect(model.creditsRemaining == nil) + + settings.hidePersonalInfo = true + let redactedModel = ProvidersPane(settings: settings, store: store)._test_menuCardModel(for: .amp) + #expect(redactedModel.creditsText == "Individual credits: $25.64\nWorkspace: $10.22") + } + + @Test + func `amp subscription pools use their own labels`() { + let settings = Self.makeSettingsStore(suite: "UsageStoreCoverageTests-amp-subscription") + let store = Self.makeUsageStore(settings: settings) + let now = Date() + + store._setSnapshotForTesting( + UsageSnapshot( + primary: RateWindow( + usedPercent: 3, + windowMinutes: ProviderPaceCapability.monthlyWindowSentinelMinutes, + resetsAt: now.addingTimeInterval(29 * 24 * 60 * 60), + resetDescription: "renews in 29 days"), + secondary: RateWindow( + usedPercent: 0, + windowMinutes: ProviderPaceCapability.monthlyWindowSentinelMinutes, + resetsAt: now.addingTimeInterval(29 * 24 * 60 * 60), + resetDescription: "renews in 29 days"), + ampUsage: AmpUsageDetails( + individualCredits: nil, + workspaceBalances: [], + subscriptionPlan: "Megawatt"), + updatedAt: now), + provider: .amp) + + let model = ProvidersPane(settings: settings, store: store)._test_menuCardModel(for: .amp) + + #expect(model.metrics.map(\.title) == ["Other usage", "Orb usage"]) + #expect(model.planText == "Megawatt") + } + + @Test + func `account info caches codex auth parsing until config revision changes`() throws { + let settings = Self.makeSettingsStore(suite: "UsageStoreCoverageTests-account-info-cache") + let home = FileManager.default.temporaryDirectory.appendingPathComponent( + "usage-store-account-info-\(UUID().uuidString)", + isDirectory: true) + defer { try? FileManager.default.removeItem(at: home) } + + try Self.writeCodexAuthFile(homeURL: home, email: "first@example.com", plan: "plus") + let env = ["CODEX_HOME": home.path] + settings._test_codexReconciliationEnvironment = env + defer { settings._test_codexReconciliationEnvironment = nil } + let store = UsageStore( + fetcher: UsageFetcher(environment: env), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + startupBehavior: .testing, + environmentBase: env) + + let first = store.accountInfo(for: .codex) + try Self.writeCodexAuthFile(homeURL: home, email: "second@example.com", plan: "pro") + let cached = store.accountInfo(for: .codex) + settings.configRevision &+= 1 + let refreshed = store.accountInfo(for: .codex) + + #expect(first.email == "first@example.com") + #expect(cached.email == "first@example.com") + #expect(refreshed.email == "second@example.com") + } + @Test func `source label uses configured kilo source`() { let settings = Self.makeSettingsStore(suite: "UsageStoreCoverageTests-kilo-source") @@ -79,6 +299,41 @@ struct UsageStoreCoverageTests { #expect(store.sourceLabel(for: .kilo) == "api") } + @Test + func `clearing copilot budget extras syncs reset baseline`() { + let settings = Self.makeSettingsStore(suite: "UsageStoreCoverageTests-copilot-budget-clear") + let store = Self.makeUsageStore(settings: settings) + let live = Self.makeCopilotSnapshot(usedPercent: 20, extraRateWindows: [Self.makeCopilotBudgetWindow()]) + let resetBaseline = Self.makeCopilotSnapshot(usedPercent: 10, extraRateWindows: nil) + store._setSnapshotForTesting(live, provider: .copilot) + store.lastKnownResetSnapshots[.copilot] = resetBaseline + + store.clearCopilotBudgetExtras() + + #expect(store.snapshot(for: .copilot)?.extraRateWindows == nil) + #expect(store.lastKnownResetSnapshots[.copilot]?.extraRateWindows == nil) + #expect(store.lastKnownResetSnapshots[.copilot]?.primary?.usedPercent == 20) + } + + @Test + func `clearing copilot budget extras also clears stale reset baseline`() { + let settings = Self.makeSettingsStore(suite: "UsageStoreCoverageTests-copilot-budget-reset-clear") + let store = Self.makeUsageStore(settings: settings) + let live = Self.makeCopilotSnapshot(usedPercent: 20, extraRateWindows: nil) + let resetBaseline = Self.makeCopilotSnapshot( + usedPercent: 10, + extraRateWindows: [Self.makeCopilotBudgetWindow()]) + store._setSnapshotForTesting(live, provider: .copilot) + store.lastKnownResetSnapshots[.copilot] = resetBaseline + + store.clearCopilotBudgetExtras() + + #expect(store.snapshot(for: .copilot)?.extraRateWindows == nil) + #expect(store.snapshot(for: .copilot)?.primary?.usedPercent == 20) + #expect(store.lastKnownResetSnapshots[.copilot]?.extraRateWindows == nil) + #expect(store.lastKnownResetSnapshots[.copilot]?.primary?.usedPercent == 10) + } + @Test func `permission prompt errors are detected for notifications`() { let errors: [LocalizedTestError] = [ @@ -169,6 +424,9 @@ struct UsageStoreCoverageTests { store._setSnapshotForTesting(staleSnapshot, provider: .claude) store._setErrorForTesting("stale", provider: .claude) store.statuses[.claude] = ProviderStatus(indicator: .major, description: "Outage", updatedAt: Date()) + store.statusComponents[.claude] = [ + ProviderStatusComponent(id: "api", name: "API", indicator: .major, status: "major_outage"), + ] #expect(store.enabledProviders() == [.codex]) @@ -177,6 +435,7 @@ struct UsageStoreCoverageTests { #expect(store.snapshot(for: .claude) == nil) #expect(store.errors[.claude] == nil) #expect(store.statuses[.claude] == nil) + #expect(store.statusComponents(for: .claude).isEmpty) } @Test @@ -267,6 +526,29 @@ struct UsageStoreCoverageTests { #expect(store.userFacingError(for: .synthetic) == SyntheticSettingsError.missingToken.errorDescription) #expect(store.unavailableMessage(for: .synthetic) == SyntheticSettingsError.missingToken.errorDescription) } +} + +extension UsageStoreCoverageTests { + @Test + func `sub2api unavailable message identifies the missing setting`() throws { + let settings = Self.makeSettingsStore(suite: "UsageStoreCoverageTests-sub2api-unavailable-message") + settings.refreshFrequency = .manual + settings.statusChecksEnabled = false + + let metadata = ProviderRegistry.shared.metadata + for provider in UsageProvider.allCases { + try settings.setProviderEnabled( + provider: provider, + metadata: #require(metadata[provider]), + enabled: provider == .sub2api) + } + + let store = Self.makeUsageStore(settings: settings) + #expect(store.unavailableMessage(for: .sub2api) == Sub2APIUsageError.missingCredentials.errorDescription) + + settings.sub2APIAPIKey = "group-key" + #expect(store.unavailableMessage(for: .sub2api) == Sub2APIUsageError.missingBaseURL.errorDescription) + } @Test func `refresh clears enabled but unavailable cached state`() async throws { @@ -294,7 +576,12 @@ struct UsageStoreCoverageTests { store._setSnapshotForTesting(cachedSnapshot, provider: .synthetic) let account = ProviderTokenAccount(id: UUID(), label: "Account", token: "token", addedAt: 0, lastUsed: nil) store.accountSnapshots[.synthetic] = [ - TokenAccountUsageSnapshot(account: account, snapshot: cachedSnapshot, error: nil, sourceLabel: "api"), + TokenAccountUsageSnapshot( + account: account, + snapshot: cachedSnapshot, + error: nil, + sourceLabel: "api", + cacheKey: store.tokenAccountSnapshotCacheKey(provider: .synthetic, account: account)), ] store._setTokenSnapshotForTesting( CostUsageTokenSnapshot( @@ -338,6 +625,9 @@ struct UsageStoreCoverageTests { let store = Self.makeUsageStore(settings: settings) store._setErrorForTesting("stale", provider: .synthetic) store.statuses[.synthetic] = ProviderStatus(indicator: .major, description: "Outage", updatedAt: Date()) + store.statusComponents[.synthetic] = [ + ProviderStatusComponent(id: "api", name: "API", indicator: .major, status: "major_outage"), + ] store.tokenErrors[.synthetic] = "token stale" #expect(store.enabledProvidersForDisplay() == [.synthetic]) @@ -349,21 +639,27 @@ struct UsageStoreCoverageTests { #expect(store.errors[.synthetic] == nil) #expect(store.tokenErrors[.synthetic] == nil) #expect(store.statuses[.synthetic] == nil) + #expect(store.statusComponents(for: .synthetic).isEmpty) #expect(store.enabledProvidersForBackgroundWork().isEmpty) } @Test func `widget snapshot projects provider derived token usage`() async throws { let settings = Self.makeSettingsStore(suite: "UsageStoreCoverageTests-widget-provider-cost") + settings.costUsageEnabled = true let store = Self.makeUsageStore(settings: settings) + let formatter = ISO8601DateFormatter() + let updatedAt = try #require(formatter.date(from: "2026-05-26T12:00:00Z")) + let startDate = try #require(formatter.date(from: "2026-05-01T00:00:00Z")) + let endDate = try #require(formatter.date(from: "2026-05-31T23:59:59Z")) let day = MistralDailyUsageBucket( day: "2026-05-26", - cost: 1.2, + cost: 9, inputTokens: 10, cachedTokens: 0, outputTokens: 5, models: []) - store._setSnapshotForTesting(MistralUsageSnapshot( + let providerSnapshot = MistralUsageSnapshot( totalCost: 9, currency: "eur", currencySymbol: "€", @@ -372,9 +668,14 @@ struct UsageStoreCoverageTests { totalCachedTokens: 0, modelCount: 1, daily: [day], - startDate: nil, - endDate: nil, - updatedAt: Date()).toUsageSnapshot(), provider: .mistral) + startDate: startDate, + endDate: endDate, + updatedAt: updatedAt).toUsageSnapshot() + store._setSnapshotForTesting(providerSnapshot, provider: .mistral) + let tokenSnapshot = try #require(store.tokenSnapshot( + fromProviderSnapshot: providerSnapshot, + provider: .mistral)) + store._setTokenSnapshotForTesting(tokenSnapshot, provider: .mistral) var widgetSnapshots: [WidgetSnapshot] = [] store._test_widgetSnapshotSaveOverride = { widgetSnapshots.append($0) } @@ -471,6 +772,228 @@ struct UsageStoreCoverageTests { NSError(domain: NSCocoaErrorDomain, code: 0))) } + @Test + func `background work settings observation ignores menu provider selection churn`() async throws { + let settings = Self.makeSettingsStore(suite: "UsageStoreCoverageTests-switcher-selection-observation") + settings.refreshFrequency = .manual + settings.mergeIcons = true + try Self.enableOnly(.codex, settings: settings) + + let store = Self.makeUsageStore(settings: settings) + let didChange = ObservationFlag() + + withObservationTracking { + _ = store.backgroundWorkSettingsObservationToken + } onChange: { + didChange.set() + } + + settings.selectedMenuProvider = .codex + try? await Task.sleep(nanoseconds: 50_000_000) + #expect(didChange.get() == false) + + let refreshDidChange = ObservationFlag() + withObservationTracking { + _ = store.backgroundWorkSettingsObservationToken + } onChange: { + refreshDidChange.set() + } + + settings.refreshFrequency = .oneMinute + try? await Task.sleep(nanoseconds: 50_000_000) + #expect(refreshDidChange.get() == true) + } + + @Test + func `background work settings observation ignores display only settings churn`() async throws { + let settings = Self.makeSettingsStore(suite: "UsageStoreCoverageTests-display-only-observation") + settings.refreshFrequency = .manual + settings.statusChecksEnabled = false + settings.mergeIcons = false + settings.randomBlinkEnabled = false + settings.usageBarsShowUsed = false + settings.showOptionalCreditsAndExtraUsage = false + try Self.enableOnly(.codex, settings: settings) + + let store = Self.makeUsageStore(settings: settings) + let didChange = ObservationFlag() + + withObservationTracking { + _ = store.backgroundWorkSettingsObservationToken + } onChange: { + didChange.set() + } + + settings.usageBarsShowUsed = true + settings.mergeIcons = true + settings.randomBlinkEnabled = true + settings.codexSparkUsageVisible.toggle() + settings.debugLoadingPattern = .pulse + settings.setProviderOrder(Array(settings.orderedProviders().reversed())) + try? await Task.sleep(nanoseconds: 50_000_000) + #expect(didChange.get() == false) + + let refreshDidChange = ObservationFlag() + withObservationTracking { + _ = store.backgroundWorkSettingsObservationToken + } onChange: { + refreshDidChange.set() + } + + settings.statusChecksEnabled = true + try? await Task.sleep(nanoseconds: 50_000_000) + #expect(refreshDidChange.get() == true) + + let layoutDidChange = ObservationFlag() + withObservationTracking { + _ = store.backgroundWorkSettingsObservationToken + } onChange: { + layoutDidChange.set() + } + + settings.multiAccountMenuLayout = .stacked + try? await Task.sleep(nanoseconds: 50_000_000) + #expect(layoutDidChange.get() == true) + + let optionalUsageDidChange = ObservationFlag() + withObservationTracking { + _ = store.backgroundWorkSettingsObservationToken + } onChange: { + optionalUsageDidChange.set() + } + + settings.showOptionalCreditsAndExtraUsage = true + try? await Task.sleep(nanoseconds: 50_000_000) + #expect(optionalUsageDidChange.get() == true) + } + + @Test + func `display only settings do not invoke provider refresh while background work is active`() async throws { + let settings = Self.makeSettingsStore(suite: "UsageStoreCoverageTests-display-only-no-provider-refresh") + settings.refreshFrequency = .manual + settings.statusChecksEnabled = false + settings.mergeIcons = false + settings.randomBlinkEnabled = false + settings.usageBarsShowUsed = false + try Self.enableOnly(.codex, settings: settings) + + let store = Self.makeUsageStore(settings: settings) + var refreshedProviders: [UsageProvider] = [] + store._test_providerRefreshOverride = { refreshedProviders.append($0) } + defer { store._test_providerRefreshOverride = nil } + + func observeBackgroundSettingsForTest() { + withObservationTracking { + _ = store.backgroundWorkSettingsObservationToken + } onChange: { + Task { @MainActor in + await store.refreshForSettingsChange() + } + } + } + + observeBackgroundSettingsForTest() + + settings.usageBarsShowUsed = true + settings.mergeIcons = true + settings.randomBlinkEnabled = true + settings.codexSparkUsageVisible.toggle() + settings.debugLoadingPattern = .pulse + settings.setProviderOrder(Array(settings.orderedProviders().reversed())) + try? await Task.sleep(nanoseconds: 50_000_000) + #expect(refreshedProviders.isEmpty) + + settings.codexUsageDataSource = .cli + for _ in 0..<20 where !refreshedProviders.contains(.codex) { + try? await Task.sleep(nanoseconds: 25_000_000) + } + #expect(refreshedProviders.contains(.codex)) + } + + @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(), @@ -494,7 +1017,6 @@ struct UsageStoreCoverageTests { minimaxCookieStore: InMemoryMiniMaxCookieStore(), minimaxAPITokenStore: InMemoryMiniMaxAPITokenStore(), kimiTokenStore: InMemoryKimiTokenStore(), - kimiK2TokenStore: InMemoryKimiK2TokenStore(), augmentCookieStore: InMemoryCookieHeaderStore(), ampCookieStore: InMemoryCookieHeaderStore(), copilotTokenStore: InMemoryCopilotTokenStore(), @@ -510,6 +1032,101 @@ struct UsageStoreCoverageTests { settings: settings, environmentBase: [:]) } + + private static func writeCodexAuthFile(homeURL: URL, email: String, plan: String) throws { + try FileManager.default.createDirectory(at: homeURL, withIntermediateDirectories: true) + let auth = try [ + "tokens": [ + "accessToken": "access-token", + "refreshToken": "refresh-token", + "idToken": Self.fakeCodexJWT(email: email, plan: plan), + ], + ] + let data = try JSONSerialization.data(withJSONObject: auth) + try data.write(to: homeURL.appendingPathComponent("auth.json"), options: .atomic) + } + + private static func fakeCodexJWT(email: String, plan: String) throws -> String { + let header = try JSONSerialization.data(withJSONObject: ["alg": "none"]) + let payload = try JSONSerialization.data(withJSONObject: [ + "email": email, + "chatgpt_plan_type": plan, + "https://api.openai.com/auth": [ + "chatgpt_plan_type": plan, + ], + ]) + return "\(Self.base64URL(header)).\(Self.base64URL(payload))." + } + + private static func base64URL(_ data: Data) -> String { + data.base64EncodedString() + .replacingOccurrences(of: "=", with: "") + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + } + + private static func makeCopilotSnapshot( + usedPercent: Double, + extraRateWindows: [NamedRateWindow]?) -> UsageSnapshot + { + UsageSnapshot( + primary: RateWindow(usedPercent: usedPercent, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: nil, + extraRateWindows: extraRateWindows, + updatedAt: Date(timeIntervalSince1970: 1_780_358_400)) + } + + private static func makeCopilotBudgetWindow() -> NamedRateWindow { + NamedRateWindow( + id: "copilot-budget-test", + title: "Budget - Copilot", + window: RateWindow(usedPercent: 50, windowMinutes: nil, resetsAt: nil, resetDescription: nil)) + } + + 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/Tests/CodexBarTests/UsageStoreDisabledProviderCleanupTests.swift b/Tests/CodexBarTests/UsageStoreDisabledProviderCleanupTests.swift new file mode 100644 index 0000000000..01a4a77473 --- /dev/null +++ b/Tests/CodexBarTests/UsageStoreDisabledProviderCleanupTests.swift @@ -0,0 +1,769 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +@MainActor +struct UsageStoreDisabledProviderCleanupTests { + @Test + func `disabled cleanup rejects stale provider publication after re-enable`() async throws { + let settings = Self.makeSettingsStore(suite: "UsageStoreDisabledProviderCleanupTests-provider-race") + settings.refreshFrequency = .manual + settings.statusChecksEnabled = false + try Self.setOnlyProvider(.amp, enabled: true, settings: settings) + let store = Self.makeUsageStore(settings: settings) + let gate = CleanupAsyncGate() + let stale = Self.usageSnapshot(usedPercent: 71) + store._test_providerFetchOutcomeOverride = { _ in + await gate.suspend() + return Self.providerOutcome(snapshot: stale) + } + + let staleTask = Task { await store.refreshProvider(.amp) } + await gate.waitUntilStarted() + try Self.setProvider(.amp, enabled: false, settings: settings) + store.clearDisabledProviderState(enabledProviders: []) + try Self.setProvider(.amp, enabled: true, settings: settings) + await gate.resume() + await staleTask.value + + #expect(store.snapshot(for: .amp) == nil) + + let fresh = Self.usageSnapshot(usedPercent: 19) + store._test_providerFetchOutcomeOverride = { _ in Self.providerOutcome(snapshot: fresh) } + await store.refreshProvider(.amp) + #expect(store.snapshot(for: .amp)?.primary?.usedPercent == 19) + } + + @Test + func `quick provider toggle rejects stale publication before cleanup runs`() async throws { + let settings = Self.makeSettingsStore(suite: "UsageStoreDisabledProviderCleanupTests-provider-config-race") + settings.refreshFrequency = .manual + settings.statusChecksEnabled = false + try Self.setOnlyProvider(.amp, enabled: true, settings: settings) + let store = Self.makeUsageStore(settings: settings) + let gate = CleanupAsyncGate() + store._test_providerFetchOutcomeOverride = { _ in + await gate.suspend() + return Self.providerOutcome(snapshot: Self.usageSnapshot(usedPercent: 71)) + } + + let staleTask = Task { await store.refreshProvider(.amp) } + await gate.waitUntilStarted() + try Self.setProvider(.amp, enabled: false, settings: settings) + try Self.setProvider(.amp, enabled: true, settings: settings) + await gate.resume() + await staleTask.value + + #expect(store.snapshot(for: .amp) == nil) + } + + @Test + func `provider order change preserves in-flight publication`() async throws { + let settings = Self.makeSettingsStore(suite: "UsageStoreDisabledProviderCleanupTests-provider-order") + settings.refreshFrequency = .manual + settings.statusChecksEnabled = false + try Self.setOnlyProvider(.amp, enabled: true, settings: settings) + let store = Self.makeUsageStore(settings: settings) + let gate = CleanupAsyncGate() + store._test_providerFetchOutcomeOverride = { _ in + await gate.suspend() + return Self.providerOutcome(snapshot: Self.usageSnapshot(usedPercent: 43)) + } + + let refreshTask = Task { await store.refreshProvider(.amp) } + await gate.waitUntilStarted() + settings.setProviderOrder(Array(settings.orderedProviders().reversed())) + await gate.resume() + await refreshTask.value + + #expect(store.snapshot(for: .amp)?.primary?.usedPercent == 43) + } + + @Test + func `provider config round trip rejects stale publication before cleanup runs`() async throws { + let settings = Self.makeSettingsStore(suite: "UsageStoreDisabledProviderCleanupTests-provider-config") + settings.refreshFrequency = .manual + settings.statusChecksEnabled = false + try Self.setOnlyProvider(.amp, enabled: true, settings: settings) + settings.updateProviderConfig(provider: .amp) { $0.source = .auto } + let store = Self.makeUsageStore(settings: settings) + let gate = CleanupAsyncGate() + store._test_providerFetchOutcomeOverride = { _ in + await gate.suspend() + return Self.providerOutcome(snapshot: Self.usageSnapshot(usedPercent: 71)) + } + + let staleTask = Task { await store.refreshProvider(.amp) } + await gate.waitUntilStarted() + settings.updateProviderConfig(provider: .amp) { $0.source = .api } + settings.updateProviderConfig(provider: .amp) { $0.source = .auto } + await gate.resume() + await staleTask.value + + #expect(store.snapshot(for: .amp) == nil) + } + + @Test + func `base URL change rejects suspended token account result and cache`() async throws { + let settings = Self.makeSettingsStore(suite: "UsageStoreDisabledProviderCleanupTests-token-base-url") + settings.refreshFrequency = .manual + settings.statusChecksEnabled = false + try Self.setOnlyProvider(.sub2api, enabled: true, settings: settings) + settings.updateProviderConfig(provider: .sub2api) { config in + config.enterpriseHost = "https://first.example.test" + } + settings.addTokenAccount(provider: .sub2api, label: "Primary", token: "k1") + let store = Self.makeUsageStore(settings: settings) + let gate = CleanupAsyncGate() + store._test_providerFetchOutcomeOverride = { _ in + await gate.suspend() + return Self.providerOutcome(snapshot: Self.usageSnapshot(usedPercent: 71)) + } + + let staleTask = Task { await store.refreshProvider(.sub2api) } + await gate.waitUntilStarted() + settings.updateProviderConfig(provider: .sub2api) { config in + config.enterpriseHost = "https://second.example.test" + } + await gate.resume() + await staleTask.value + + #expect(store.snapshot(for: .sub2api) == nil) + #expect(store.accountSnapshots[.sub2api] == nil) + } + + @Test + func `disabled cleanup preserves explicit allow-disabled refresh`() async throws { + let settings = Self.makeSettingsStore(suite: "UsageStoreDisabledProviderCleanupTests-allow-disabled") + settings.refreshFrequency = .manual + settings.statusChecksEnabled = false + try Self.setOnlyProvider(.amp, enabled: false, settings: settings) + let store = Self.makeUsageStore(settings: settings) + let gate = CleanupAsyncGate() + store._test_providerFetchOutcomeOverride = { _ in + await gate.suspend() + return Self.providerOutcome(snapshot: Self.usageSnapshot(usedPercent: 27)) + } + + let refreshTask = Task { await store.refreshProvider(.amp, allowDisabled: true) } + await gate.waitUntilStarted() + store.clearDisabledProviderState(enabledProviders: []) + await gate.resume() + await refreshTask.value + + #expect(store.snapshot(for: .amp)?.primary?.usedPercent == 27) + + store.clearDisabledProviderState(enabledProviders: []) + #expect(store.snapshot(for: .amp) == nil) + } + + @Test + func `disabled cleanup rejects stale status success after re-enable`() async throws { + let settings = Self.makeSettingsStore(suite: "UsageStoreDisabledProviderCleanupTests-status-success") + settings.refreshFrequency = .manual + settings.statusChecksEnabled = true + try Self.setOnlyProvider(.codex, enabled: true, settings: settings) + let store = Self.makeUsageStore(settings: settings) + let gate = CleanupAsyncGate() + store._test_providerStatusFetchOverride = { _ in + await gate.suspend() + return ProviderStatus(indicator: .major, description: "stale", updatedAt: Date()) + } + + let staleTask = Task { await store.refreshProviderStatus(.codex) } + await gate.waitUntilStarted() + try Self.setProvider(.codex, enabled: false, settings: settings) + try Self.setProvider(.codex, enabled: true, settings: settings) + await gate.resume() + await staleTask.value + + #expect(store.statuses[.codex] == nil) + + store._test_providerStatusFetchOverride = { _ in + ProviderStatus(indicator: .none, description: "fresh", updatedAt: Date()) + } + await store.refreshProviderStatus(.codex) + #expect(store.statuses[.codex]?.description == "fresh") + } + + @Test + func `disabled cleanup rejects stale status failure after re-enable`() async throws { + let settings = Self.makeSettingsStore(suite: "UsageStoreDisabledProviderCleanupTests-status-failure") + settings.refreshFrequency = .manual + settings.statusChecksEnabled = true + try Self.setOnlyProvider(.codex, enabled: true, settings: settings) + let store = Self.makeUsageStore(settings: settings) + let gate = CleanupAsyncGate() + store._test_providerStatusFetchOverride = { _ in + await gate.suspend() + throw CleanupTestError.failed + } + + let staleTask = Task { await store.refreshProviderStatus(.codex) } + await gate.waitUntilStarted() + try Self.setProvider(.codex, enabled: false, settings: settings) + store.clearDisabledProviderState(enabledProviders: []) + try Self.setProvider(.codex, enabled: true, settings: settings) + await gate.resume() + await staleTask.value + + #expect(store.statuses[.codex] == nil) + } + + @Test + func `disabled cleanup rejects stale token result after re-enable`() async throws { + let settings = Self.makeSettingsStore(suite: "UsageStoreDisabledProviderCleanupTests-token-race") + settings.refreshFrequency = .manual + settings.statusChecksEnabled = false + settings.costUsageEnabled = true + try Self.setOnlyProvider(.codex, enabled: true, settings: settings) + let store = Self.makeUsageStore(settings: settings) + let gate = CleanupAsyncGate() + var loadCount = 0 + store._test_tokenUsageSnapshotLoaderOverride = { _, _, _, _, historyDays in + loadCount += 1 + if loadCount == 1 { + await gate.suspend() + return Self.tokenSnapshot(tokens: 710, historyDays: historyDays) + } + return Self.tokenSnapshot(tokens: 190, historyDays: historyDays) + } + + let staleTask = Task { await store.refreshTokenUsage(.codex, force: true) } + await gate.waitUntilStarted() + try Self.setProvider(.codex, enabled: false, settings: settings) + store.clearDisabledProviderState(enabledProviders: []) + try Self.setProvider(.codex, enabled: true, settings: settings) + await gate.resume() + await staleTask.value + for _ in 0..<100 where store.tokenSnapshot(for: .codex) == nil { + try await Task.sleep(for: .milliseconds(10)) + } + + #expect(store.tokenSnapshot(for: .codex)?.sessionTokens == 190) + #expect(loadCount == 2) + } + + @Test + func `disabled cleanup replaces stale token failure with fresh retry`() async throws { + let settings = Self.makeSettingsStore(suite: "UsageStoreDisabledProviderCleanupTests-token-failure") + settings.refreshFrequency = .manual + settings.statusChecksEnabled = false + settings.costUsageEnabled = true + try Self.setOnlyProvider(.codex, enabled: true, settings: settings) + let store = Self.makeUsageStore(settings: settings) + let gate = CleanupAsyncGate() + var loadCount = 0 + store._test_tokenUsageSnapshotLoaderOverride = { _, _, _, _, historyDays in + loadCount += 1 + if loadCount == 1 { + await gate.suspend() + throw CleanupTestError.failed + } + return Self.tokenSnapshot(tokens: 190, historyDays: historyDays) + } + + let staleTask = Task { await store.refreshTokenUsage(.codex, force: true) } + await gate.waitUntilStarted() + try Self.setProvider(.codex, enabled: false, settings: settings) + store.clearDisabledProviderState(enabledProviders: []) + try Self.setProvider(.codex, enabled: true, settings: settings) + await gate.resume() + await staleTask.value + for _ in 0..<100 where store.tokenSnapshot(for: .codex) == nil { + try await Task.sleep(for: .milliseconds(10)) + } + + #expect(store.tokenSnapshot(for: .codex)?.sessionTokens == 190) + #expect(store.tokenError(for: .codex) == nil) + #expect(loadCount == 2) + } + + @Test + func `disabled token completion preserves retry through active sequence`() async throws { + let settings = Self.makeSettingsStore(suite: "UsageStoreDisabledProviderCleanupTests-token-sequence") + settings.refreshFrequency = .manual + settings.statusChecksEnabled = false + settings.costUsageEnabled = true + try Self.setOnlyProvider(.codex, enabled: true, settings: settings) + try Self.setProvider(.claude, enabled: true, settings: settings) + let store = Self.makeUsageStore(settings: settings) + let codexGate = CleanupAsyncGate() + let claudeGate = CleanupAsyncGate() + var codexLoads = 0 + var claudeLoads = 0 + store._test_tokenUsageSnapshotLoaderOverride = { provider, _, _, _, historyDays in + switch provider { + case .codex: + codexLoads += 1 + if codexLoads == 1 { + await codexGate.suspend() + return Self.tokenSnapshot(tokens: 710, historyDays: historyDays) + } + return Self.tokenSnapshot(tokens: 190, historyDays: historyDays) + case .claude: + claudeLoads += 1 + if claudeLoads == 1 { + await claudeGate.suspend() + } + return Self.tokenSnapshot(tokens: 50, historyDays: historyDays) + default: + return Self.tokenSnapshot(tokens: 1, historyDays: historyDays) + } + } + + store.scheduleTokenRefreshForTesting() + await codexGate.waitUntilStarted() + try Self.setProvider(.codex, enabled: false, settings: settings) + store.clearDisabledProviderState(enabledProviders: [.claude]) + await codexGate.resume() + + await claudeGate.waitUntilStarted() + try Self.setProvider(.codex, enabled: true, settings: settings) + store.scheduleTokenRefreshForTesting() + await claudeGate.resume() + + for _ in 0..<200 + where store.tokenSnapshot(for: .codex)?.sessionTokens != 190 || claudeLoads != 2 + { + try await Task.sleep(for: .milliseconds(10)) + } + + #expect(store.tokenSnapshot(for: .codex)?.sessionTokens == 190) + #expect(codexLoads == 2) + #expect(claudeLoads == 1) + } + + @Test + func `token configuration change rejects stale result`() async throws { + let settings = Self.makeSettingsStore(suite: "UsageStoreDisabledProviderCleanupTests-token-scope") + settings.refreshFrequency = .manual + settings.statusChecksEnabled = false + settings.costUsageEnabled = true + settings.costUsageHistoryDays = 30 + try Self.setOnlyProvider(.codex, enabled: true, settings: settings) + let store = Self.makeUsageStore(settings: settings) + let gate = CleanupAsyncGate() + var loadCount = 0 + store._test_tokenUsageSnapshotLoaderOverride = { _, _, _, _, historyDays in + loadCount += 1 + if loadCount == 1 { + await gate.suspend() + return Self.tokenSnapshot(tokens: 710, historyDays: historyDays) + } + return Self.tokenSnapshot(tokens: 190, historyDays: historyDays) + } + + let staleTask = Task { await store.refreshTokenUsage(.codex, force: true) } + await gate.waitUntilStarted() + settings.costUsageHistoryDays = 7 + await gate.resume() + await staleTask.value + for _ in 0..<100 where store.tokenSnapshot(for: .codex) == nil { + try await Task.sleep(for: .milliseconds(10)) + } + + #expect(store.tokenSnapshot(for: .codex)?.sessionTokens == 190) + #expect(loadCount == 2) + } + + @Test + func `cached token hydration rejects disable re-enable completion`() async throws { + let settings = Self.makeSettingsStore(suite: "UsageStoreDisabledProviderCleanupTests-token-cache") + settings.refreshFrequency = .manual + settings.statusChecksEnabled = false + settings.costUsageEnabled = true + try Self.setOnlyProvider(.codex, enabled: true, settings: settings) + let store = Self.makeUsageStore(settings: settings) + let gate = CleanupAsyncGate() + store._test_cachedCodexTokenSnapshotLoaderOverride = { now, _, historyDays in + await gate.suspend() + return ( + snapshot: Self.tokenSnapshot(tokens: 710, historyDays: historyDays, updatedAt: now), + lastRefreshAt: now) + } + + store.hydrateCachedTokenSnapshots() + await gate.waitUntilStarted() + try Self.setProvider(.codex, enabled: false, settings: settings) + try Self.setProvider(.codex, enabled: true, settings: settings) + await gate.resume() + for _ in 0..<10 { + await Task.yield() + } + + #expect(store.tokenSnapshot(for: .codex) == nil) + #expect(store.tokenLastAttemptAt(for: .codex) == nil) + } + + @Test + func `disabled provider cleanup clears derived reset scope and warning state`() throws { + let settings = Self.makeSettingsStore(suite: "UsageStoreDisabledProviderCleanupTests-derived") + settings.refreshFrequency = .manual + settings.statusChecksEnabled = false + + let metadata = ProviderRegistry.shared.metadata + for provider in UsageProvider.allCases { + try settings.setProviderEnabled( + provider: provider, + metadata: #require(metadata[provider]), + enabled: false) + } + try settings.setProviderEnabled(provider: .codex, metadata: #require(metadata[.codex]), enabled: true) + + let store = Self.makeUsageStore(settings: settings) + let staleSnapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 40, windowMinutes: 300, resetsAt: Date(), resetDescription: nil), + secondary: nil, + updatedAt: Date()) + let retainedSnapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 12, windowMinutes: 300, resetsAt: Date(), resetDescription: nil), + secondary: nil, + updatedAt: Date()) + + store._setSnapshotForTesting(staleSnapshot, provider: .kilo) + store.lastKnownResetSnapshots[.kilo] = staleSnapshot + store.lastKnownResetSnapshots[.codex] = retainedSnapshot + store.kiloScopeSnapshots = [ + KiloScopeSnapshot( + id: KiloUsageScope.personal.scopeIdentifier, + scope: .personal, + snapshot: staleSnapshot, + errorMessage: nil, + sourceLabel: "personal"), + KiloScopeSnapshot( + id: "org-stale", + scope: .organization(id: "org-stale", name: "Stale Org"), + snapshot: staleSnapshot, + errorMessage: nil, + sourceLabel: "org"), + ] + store.providerStorageFootprints[.kilo] = ProviderStorageFootprint( + provider: .kilo, + totalBytes: 42, + paths: ["/tmp/kilo"], + missingPaths: [], + unreadablePaths: [], + components: [], + updatedAt: Date()) + store.quotaWarningState[ + UsageStore.QuotaWarningStateKey(provider: .kilo, window: .session, accountDiscriminator: nil), + ] = + UsageStore.QuotaWarningState(lastRemaining: 20, firedThresholds: [50], source: .primary) + store.quotaWarningState[ + UsageStore.QuotaWarningStateKey(provider: .codex, window: .session, accountDiscriminator: nil), + ] = + UsageStore.QuotaWarningState(lastRemaining: 80, firedThresholds: [20], source: .primary) + store.predictivePaceWarningNotifiedKeys = [ + PredictivePaceWarningStateKey( + provider: .kilo, + accountDiscriminator: "kilo", + window: .session, + resetWindow: PredictivePaceWarningResetWindow(windowMinutes: 300, resetsAt: Date())), + PredictivePaceWarningStateKey( + provider: .codex, + accountDiscriminator: "codex", + window: .session, + resetWindow: PredictivePaceWarningResetWindow(windowMinutes: 300, resetsAt: Date())), + ] + store.lastTokenFetchAt[.kilo] = Date() + store.lastTokenFetchScope[.kilo] = "stale" + + store.clearDisabledProviderState(enabledProviders: Set(store.enabledProvidersForDisplay())) + + #expect(store.snapshot(for: .kilo) == nil) + #expect(store.lastKnownResetSnapshots[.kilo] == nil) + #expect(store.kiloScopeSnapshots.isEmpty) + #expect(store.providerStorageFootprints[.kilo] == nil) + #expect(store.quotaWarningState[ + UsageStore.QuotaWarningStateKey(provider: .kilo, window: .session, accountDiscriminator: nil), + ] == nil) + #expect(store.predictivePaceWarningNotifiedKeys.allSatisfy { $0.provider != .kilo }) + #expect(store.lastTokenFetchAt[.kilo] == nil) + #expect(store.lastTokenFetchScope[.kilo] == nil) + + #expect(store.lastKnownResetSnapshots[.codex]?.primary?.usedPercent == 12) + #expect(store.quotaWarningState[ + UsageStore.QuotaWarningStateKey(provider: .codex, window: .session, accountDiscriminator: nil), + ] != nil) + #expect(store.predictivePaceWarningNotifiedKeys.contains { $0.provider == .codex }) + } + + @Test + func `disabled Codex cleanup clears account snapshots and publication guard`() throws { + let settings = Self.makeSettingsStore(suite: "UsageStoreDisabledProviderCleanupTests-codex") + settings.refreshFrequency = .manual + settings.statusChecksEnabled = false + + let metadata = ProviderRegistry.shared.metadata + for provider in UsageProvider.allCases { + try settings.setProviderEnabled( + provider: provider, + metadata: #require(metadata[provider]), + enabled: false) + } + try settings.setProviderEnabled(provider: .claude, metadata: #require(metadata[.claude]), enabled: true) + + let store = Self.makeUsageStore(settings: settings) + let account = CodexVisibleAccount( + id: "stale@example.com", + email: "stale@example.com", + storedAccountID: nil, + selectionSource: .liveSystem, + isActive: true, + isLive: true, + canReauthenticate: true, + canRemove: true) + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 33, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: nil, + updatedAt: Date()) + store._setSnapshotForTesting(snapshot, provider: .codex) + store.lastKnownResetSnapshots[.codex] = snapshot + store.codexAccountSnapshots = [ + CodexAccountUsageSnapshot(account: account, snapshot: snapshot, error: nil, sourceLabel: "stale"), + ] + store.lastCodexUsagePublicationGuard = CodexAccountScopedRefreshGuard( + source: .liveSystem, + identity: .emailOnly(normalizedEmail: "stale@example.com"), + accountKey: "stale@example.com", + authFingerprint: "stale-fingerprint") + store.lastCodexAccountScopedRefreshGuard = store.lastCodexUsagePublicationGuard + + store.clearDisabledProviderState(enabledProviders: Set(store.enabledProvidersForDisplay())) + + #expect(store.snapshot(for: .codex) == nil) + #expect(store.lastKnownResetSnapshots[.codex] == nil) + #expect(store.codexAccountSnapshots.isEmpty) + #expect(store.lastCodexUsagePublicationGuard == nil) + #expect(store.lastCodexAccountScopedRefreshGuard != nil) + } + + @Test + func `disabled Claude cleanup clears swap runtime without touching settings`() throws { + let settings = Self.makeSettingsStore(suite: "UsageStoreDisabledProviderCleanupTests-claude-swap") + settings.refreshFrequency = .manual + settings.statusChecksEnabled = false + settings.claudeSwapEnabled = true + settings.claudeSwapExecutablePath = "/tmp/cswap-fixture" + + let metadata = ProviderRegistry.shared.metadata + for provider in UsageProvider.allCases { + try settings.setProviderEnabled( + provider: provider, + metadata: #require(metadata[provider]), + enabled: false) + } + try settings.setProviderEnabled(provider: .codex, metadata: #require(metadata[.codex]), enabled: true) + + let store = Self.makeUsageStore(settings: settings) + store.claudeSwapAccountSnapshots = [ + ProviderAccountUsageSnapshot( + id: ProviderAccountIdentity(source: ClaudeSwapAccountProjection.sourceName, opaqueID: "1"), + provider: .claude, + displayLabel: "account@example.com", + isActive: false, + snapshot: nil, + error: "Token expired", + sourceLabel: ClaudeSwapAccountProjection.sourceLabel), + ] + store.claudeSwapLastRefreshAt = Date() + store.claudeSwapLastError = "stale" + + store.clearDisabledProviderState(enabledProviders: Set(store.enabledProvidersForDisplay())) + + #expect(store.claudeSwapAccountSnapshots.isEmpty) + #expect(store.claudeSwapLastRefreshAt == nil) + #expect(store.claudeSwapLastError == nil) + #expect(settings.claudeSwapEnabled) + #expect(settings.claudeSwapExecutablePath == "/tmp/cswap-fixture") + } + + @Test + func `unavailable provider cleanup clears derived reset and scope state`() throws { + let settings = Self.makeSettingsStore(suite: "UsageStoreDisabledProviderCleanupTests-unavailable") + settings.refreshFrequency = .manual + settings.statusChecksEnabled = false + + let metadata = ProviderRegistry.shared.metadata + for provider in UsageProvider.allCases { + try settings.setProviderEnabled( + provider: provider, + metadata: #require(metadata[provider]), + enabled: false) + } + try settings.setProviderEnabled(provider: .kilo, metadata: #require(metadata[.kilo]), enabled: true) + + let store = Self.makeUsageStore(settings: settings) + let staleSnapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 55, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: nil, + updatedAt: Date()) + store._setSnapshotForTesting(staleSnapshot, provider: .kilo) + store.lastKnownResetSnapshots[.kilo] = staleSnapshot + store.kiloScopeSnapshots = [ + KiloScopeSnapshot( + id: KiloUsageScope.personal.scopeIdentifier, + scope: .personal, + snapshot: staleSnapshot, + errorMessage: nil, + sourceLabel: "personal"), + KiloScopeSnapshot( + id: "org-stale", + scope: .organization(id: "org-stale", name: "Stale Org"), + snapshot: staleSnapshot, + errorMessage: nil, + sourceLabel: "org"), + ] + + store.clearUnavailableProviderState( + displayEnabledProviders: [.kilo], + availableProviders: []) + + #expect(store.snapshot(for: .kilo) == nil) + #expect(store.lastKnownResetSnapshots[.kilo] == nil) + #expect(store.kiloScopeSnapshots.isEmpty) + } + + 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(), + augmentCookieStore: InMemoryCookieHeaderStore(), + ampCookieStore: InMemoryCookieHeaderStore(), + copilotTokenStore: InMemoryCopilotTokenStore(), + tokenAccountStore: InMemoryTokenAccountStore()) + settings.providerDetectionCompleted = true + return settings + } + + private static func makeUsageStore(settings: SettingsStore) -> UsageStore { + UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + environmentBase: [:]) + } + + private static func setOnlyProvider( + _ provider: UsageProvider, + enabled: Bool, + settings: SettingsStore) throws + { + let metadata = ProviderRegistry.shared.metadata + for candidate in UsageProvider.allCases { + try settings.setProviderEnabled( + provider: candidate, + metadata: #require(metadata[candidate]), + enabled: candidate == provider && enabled) + } + } + + private static func setProvider( + _ provider: UsageProvider, + enabled: Bool, + settings: SettingsStore) throws + { + try settings.setProviderEnabled( + provider: provider, + metadata: #require(ProviderRegistry.shared.metadata[provider]), + enabled: enabled) + } + + private static func usageSnapshot(usedPercent: Double) -> UsageSnapshot { + UsageSnapshot( + primary: RateWindow( + usedPercent: usedPercent, + windowMinutes: 300, + resetsAt: nil, + resetDescription: nil), + secondary: nil, + updatedAt: Date()) + } + + private static func providerOutcome(snapshot: UsageSnapshot) -> ProviderFetchOutcome { + ProviderFetchOutcome( + result: .success(ProviderFetchResult( + usage: snapshot, + credits: nil, + dashboard: nil, + sourceLabel: "fixture", + strategyID: "fixture", + strategyKind: .cli)), + attempts: []) + } + + private static func tokenSnapshot( + tokens: Int, + historyDays: Int, + updatedAt: Date = Date()) -> CostUsageTokenSnapshot + { + CostUsageTokenSnapshot( + sessionTokens: tokens, + sessionCostUSD: 1, + last30DaysTokens: tokens, + last30DaysCostUSD: 1, + historyDays: historyDays, + daily: [ + CostUsageDailyReport.Entry( + date: "2026-07-11", + inputTokens: tokens, + outputTokens: 0, + totalTokens: tokens, + costUSD: 1, + modelsUsed: [], + modelBreakdowns: nil), + ], + updatedAt: updatedAt) + } +} + +private enum CleanupTestError: LocalizedError { + case failed + + var errorDescription: String? { + "fixture failure" + } +} + +private actor CleanupAsyncGate { + private var started = false + private var startWaiters: [CheckedContinuation] = [] + private var releaseContinuation: CheckedContinuation? + + func suspend() async { + self.started = true + for waiter in self.startWaiters { + waiter.resume() + } + self.startWaiters.removeAll() + await withCheckedContinuation { continuation in + self.releaseContinuation = continuation + } + } + + func waitUntilStarted() async { + guard !self.started else { return } + await withCheckedContinuation { continuation in + self.startWaiters.append(continuation) + } + } + + func resume() { + self.releaseContinuation?.resume() + self.releaseContinuation = nil + } +} diff --git a/Tests/CodexBarTests/UsageStoreHighestUsageTests.swift b/Tests/CodexBarTests/UsageStoreHighestUsageTests.swift index 49a353234c..09005f135b 100644 --- a/Tests/CodexBarTests/UsageStoreHighestUsageTests.swift +++ b/Tests/CodexBarTests/UsageStoreHighestUsageTests.swift @@ -40,6 +40,11 @@ struct UsageStoreHighestUsageTests { let highest = store.providerWithHighestUsage() #expect(highest?.provider == .claude) #expect(highest?.usedPercent == 60) + + let overviewHighest = store.providerWithHighestUsage(candidateProviders: [.codex]) + #expect(overviewHighest?.provider == .codex) + #expect(overviewHighest?.usedPercent == 25) + #expect(store.providerWithHighestUsage(candidateProviders: []) == nil) } @Test @@ -80,7 +85,7 @@ struct UsageStoreHighestUsageTests { } @Test - func `automatic metric uses secondary for kimi when ranking highest usage`() { + func `automatic metric uses rate limit for kimi when ranking highest usage`() { let settings = SettingsStore( configStore: testConfigStore(suiteName: "UsageStoreHighestUsageTests-kimi-automatic"), zaiTokenStore: NoopZaiTokenStore(), @@ -106,7 +111,7 @@ struct UsageStoreHighestUsageTests { updatedAt: Date()) let kimiSnapshot = UsageSnapshot( primary: RateWindow(usedPercent: 90, windowMinutes: nil, resetsAt: nil, resetDescription: nil), - secondary: RateWindow(usedPercent: 20, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: RateWindow(usedPercent: 20, windowMinutes: 300, resetsAt: nil, resetDescription: nil), updatedAt: Date()) store._setSnapshotForTesting(codexSnapshot, provider: .codex) @@ -118,7 +123,51 @@ struct UsageStoreHighestUsageTests { } @Test - func `automatic metric uses antigravity tertiary when leading lanes are missing`() { + func `automatic metric keeps partially exhausted kimi eligible for highest usage`() { + let settings = SettingsStore( + configStore: testConfigStore(suiteName: "UsageStoreHighestUsageTests-kimi-partially-exhausted"), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + settings.refreshFrequency = .manual + settings.statusChecksEnabled = false + settings.setMenuBarMetricPreference(.automatic, for: .kimi) + + let registry = ProviderRegistry.shared + if let codexMeta = registry.metadata[.codex] { + settings.setProviderEnabled(provider: .codex, metadata: codexMeta, enabled: true) + } + if let kimiMeta = registry.metadata[.kimi] { + settings.setProviderEnabled(provider: .kimi, metadata: kimiMeta, enabled: true) + } + + let store = UsageStore( + fetcher: UsageFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings) + store._setSnapshotForTesting( + UsageSnapshot( + primary: RateWindow(usedPercent: 70, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: nil, + updatedAt: Date()), + provider: .codex) + store._setSnapshotForTesting( + UsageSnapshot( + primary: RateWindow(usedPercent: 100, windowMinutes: nil, resetsAt: nil, resetDescription: "Weekly"), + secondary: RateWindow( + usedPercent: 20, + windowMinutes: 300, + resetsAt: nil, + resetDescription: "5-hour"), + updatedAt: Date()), + provider: .kimi) + + let highest = store.providerWithHighestUsage() + #expect(highest?.provider == .kimi) + #expect(highest?.usedPercent == 100) + } + + @Test + func `automatic metric ignores antigravity tertiary when compact icon has no quota summary`() { let settings = SettingsStore( configStore: testConfigStore(suiteName: "UsageStoreHighestUsageTests-antigravity-tertiary"), zaiTokenStore: NoopZaiTokenStore(), @@ -152,8 +201,341 @@ struct UsageStoreHighestUsageTests { store._setSnapshotForTesting(antigravitySnapshot, provider: .antigravity) let highest = store.providerWithHighestUsage() + #expect(highest?.provider == .codex) + #expect(highest?.usedPercent == 70) + } + + @Test + func `automatic metric ignores unclassified antigravity compact fallback until exhausted priority is enabled`() + throws + { + let settings = SettingsStore( + configStore: testConfigStore(suiteName: "UsageStoreHighestUsageTests-antigravity-unclassified"), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + settings.refreshFrequency = .manual + settings.statusChecksEnabled = false + settings.setMenuBarMetricPreference(.automatic, for: .antigravity) + + let registry = ProviderRegistry.shared + if let codexMeta = registry.metadata[.codex] { + settings.setProviderEnabled(provider: .codex, metadata: codexMeta, enabled: true) + } + if let antigravityMeta = registry.metadata[.antigravity] { + settings.setProviderEnabled(provider: .antigravity, metadata: antigravityMeta, enabled: true) + } + + let store = UsageStore( + fetcher: UsageFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings) + let codexSnapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 50, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: nil, + updatedAt: Date()) + let antigravitySnapshot = try AntigravityStatusSnapshot( + modelQuotas: [ + AntigravityModelQuota( + label: "Experimental Model", + modelId: "MODEL_PLACEHOLDER_NEW", + remainingFraction: 0.36, + resetTime: nil, + resetDescription: nil), + ], + accountEmail: nil, + accountPlan: nil, + source: .local) + .toUsageSnapshot() + + store._setSnapshotForTesting(codexSnapshot, provider: .codex) + store._setSnapshotForTesting(antigravitySnapshot, provider: .antigravity) + + let highest = store.providerWithHighestUsage() + #expect(highest?.provider == .codex) + #expect(highest?.usedPercent == 50) + + settings.antigravityPrioritizeExhaustedQuotas = true + let optInHighest = store.providerWithHighestUsage() + #expect(optInHighest?.provider == .antigravity) + #expect(optInHighest?.usedPercent == 64) + } + + @Test + func `automatic metric ignores legacy antigravity family lanes without quota summary`() { + let settings = SettingsStore( + configStore: testConfigStore(suiteName: "UsageStoreHighestUsageTests-antigravity-constrained-gemini"), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + settings.refreshFrequency = .manual + settings.statusChecksEnabled = false + settings.setMenuBarMetricPreference(.automatic, for: .antigravity) + + let registry = ProviderRegistry.shared + if let codexMeta = registry.metadata[.codex] { + settings.setProviderEnabled(provider: .codex, metadata: codexMeta, enabled: true) + } + if let antigravityMeta = registry.metadata[.antigravity] { + settings.setProviderEnabled(provider: .antigravity, metadata: antigravityMeta, enabled: true) + } + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + + let codexSnapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 70, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: nil, + updatedAt: Date()) + let antigravitySnapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 0, windowMinutes: nil, resetsAt: nil, resetDescription: "Claude"), + secondary: RateWindow(usedPercent: 100, windowMinutes: nil, resetsAt: nil, resetDescription: "Gemini Pro"), + tertiary: RateWindow(usedPercent: 40, windowMinutes: nil, resetsAt: nil, resetDescription: "Gemini Flash"), + updatedAt: Date()) + + store._setSnapshotForTesting(codexSnapshot, provider: .codex) + store._setSnapshotForTesting(antigravitySnapshot, provider: .antigravity) + + let highest = store.providerWithHighestUsage() + #expect(highest?.provider == .codex) + #expect(highest?.usedPercent == 70) + } +} + +extension UsageStoreHighestUsageTests { + @Test + func `antigravity automatic ranking keeps usable first until exhausted priority is enabled`() { + let settings = SettingsStore( + configStore: testConfigStore(suiteName: "UsageStoreHighestUsageTests-antigravity-all-summary"), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + settings.refreshFrequency = .manual + settings.statusChecksEnabled = false + settings.setMenuBarMetricPreference(.automatic, for: .antigravity) + + let registry = ProviderRegistry.shared + if let codexMeta = registry.metadata[.codex] { + settings.setProviderEnabled(provider: .codex, metadata: codexMeta, enabled: true) + } + if let antigravityMeta = registry.metadata[.antigravity] { + settings.setProviderEnabled(provider: .antigravity, metadata: antigravityMeta, enabled: true) + } + + let store = UsageStore( + fetcher: UsageFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings) + store._setSnapshotForTesting( + UsageSnapshot( + primary: RateWindow(usedPercent: 80, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: nil, + updatedAt: Date()), + provider: .codex) + let antigravity = self.antigravityQuotaSummarySnapshot( + geminiSessionUsed: 10, + geminiWeeklyUsed: 20, + otherSessionUsed: 95, + otherWeeklyUsed: 90) + let unknownCadence = NamedRateWindow( + id: "antigravity-quota-summary-future-daily", + title: "Future daily lane", + window: RateWindow( + usedPercent: 99, + windowMinutes: 24 * 60, + resetsAt: nil, + resetDescription: nil)) + store._setSnapshotForTesting( + antigravity.with(extraRateWindows: (antigravity.extraRateWindows ?? []) + [unknownCadence]), + provider: .antigravity) + + var highest = store.providerWithHighestUsage() + #expect(highest?.provider == .antigravity) + #expect(highest?.usedPercent == 95) + + store._setSnapshotForTesting( + self.antigravityQuotaSummarySnapshot( + geminiSessionUsed: 95, + geminiWeeklyUsed: 20, + otherSessionUsed: 10, + otherWeeklyUsed: 10), + provider: .antigravity) + highest = store.providerWithHighestUsage() + #expect(highest?.provider == .antigravity) + #expect(highest?.usedPercent == 95) + + store._setSnapshotForTesting( + self.antigravityQuotaSummarySnapshot( + geminiSessionUsed: 100, + geminiWeeklyUsed: 100, + otherSessionUsed: 50, + otherWeeklyUsed: 50), + provider: .antigravity) + highest = store.providerWithHighestUsage() + #expect(highest?.provider == .codex) + #expect(highest?.usedPercent == 80) + + settings.antigravityPrioritizeExhaustedQuotas = true + highest = store.providerWithHighestUsage() #expect(highest?.provider == .antigravity) - #expect(highest?.usedPercent == 85) + #expect(highest?.usedPercent == 100) + } + + @Test + func `opt in automatic metric excludes antigravity only when every summary family is blocked`() { + let settings = SettingsStore( + configStore: testConfigStore(suiteName: "UsageStoreHighestUsageTests-antigravity-summary-usable"), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + settings.refreshFrequency = .manual + settings.statusChecksEnabled = false + settings.setMenuBarMetricPreference(.automatic, for: .antigravity) + settings.antigravityPrioritizeExhaustedQuotas = true + + let registry = ProviderRegistry.shared + if let codexMeta = registry.metadata[.codex] { + settings.setProviderEnabled(provider: .codex, metadata: codexMeta, enabled: true) + } + if let antigravityMeta = registry.metadata[.antigravity] { + settings.setProviderEnabled(provider: .antigravity, metadata: antigravityMeta, enabled: true) + } + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + + let codexSnapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 80, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: nil, + updatedAt: Date()) + let antigravitySnapshot = self.antigravityQuotaSummarySnapshot( + geminiSessionUsed: 100, + geminiWeeklyUsed: 40, + otherSessionUsed: 100, + otherWeeklyUsed: 100) + + store._setSnapshotForTesting(codexSnapshot, provider: .codex) + store._setSnapshotForTesting(antigravitySnapshot, provider: .antigravity) + + let highest = store.providerWithHighestUsage() + #expect(highest?.provider == .codex) + #expect(highest?.usedPercent == 80) + + let unsupportedRow = NamedRateWindow( + id: "antigravity-quota-summary-future-daily", + title: "Future daily lane", + window: RateWindow( + usedPercent: 100, + windowMinutes: 1440, + resetsAt: nil, + resetDescription: nil)) + store._setSnapshotForTesting( + antigravitySnapshot.with( + extraRateWindows: (antigravitySnapshot.extraRateWindows ?? []) + [unsupportedRow]), + provider: .antigravity) + + let failOpenHighest = store.providerWithHighestUsage() + #expect(failOpenHighest?.provider == .antigravity) + #expect(failOpenHighest?.usedPercent == 100) + } + + @Test + func `automatic metric ignores antigravity legacy detail rows`() { + let settings = SettingsStore( + configStore: testConfigStore(suiteName: "UsageStoreHighestUsageTests-antigravity-fallback-detail"), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + settings.refreshFrequency = .manual + settings.statusChecksEnabled = false + settings.setMenuBarMetricPreference(.automatic, for: .antigravity) + + let registry = ProviderRegistry.shared + if let codexMeta = registry.metadata[.codex] { + settings.setProviderEnabled(provider: .codex, metadata: codexMeta, enabled: true) + } + if let antigravityMeta = registry.metadata[.antigravity] { + settings.setProviderEnabled(provider: .antigravity, metadata: antigravityMeta, enabled: true) + } + + let store = UsageStore( + fetcher: UsageFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings) + store._setSnapshotForTesting( + UsageSnapshot( + primary: RateWindow(usedPercent: 80, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: nil, + updatedAt: Date()), + provider: .codex) + store._setSnapshotForTesting( + UsageSnapshot( + primary: nil, + secondary: nil, + tertiary: nil, + extraRateWindows: [ + NamedRateWindow( + id: "antigravity-compact-fallback-model-a", + title: "Model A", + window: RateWindow( + usedPercent: 100, + windowMinutes: nil, + resetsAt: nil, + resetDescription: nil)), + NamedRateWindow( + id: "model-b", + title: "Model B", + window: RateWindow( + usedPercent: 50, + windowMinutes: nil, + resetsAt: nil, + resetDescription: nil)), + ], + updatedAt: Date()), + provider: .antigravity) + + let highest = store.providerWithHighestUsage() + #expect(highest?.provider == .codex) + #expect(highest?.usedPercent == 80) + } + + @Test + func `automatic metric skips antigravity with no quota lanes`() { + let settings = SettingsStore( + configStore: testConfigStore(suiteName: "UsageStoreHighestUsageTests-antigravity-empty"), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + settings.refreshFrequency = .manual + settings.statusChecksEnabled = false + settings.setMenuBarMetricPreference(.automatic, for: .antigravity) + + let registry = ProviderRegistry.shared + if let codexMeta = registry.metadata[.codex] { + settings.setProviderEnabled(provider: .codex, metadata: codexMeta, enabled: true) + } + if let antigravityMeta = registry.metadata[.antigravity] { + settings.setProviderEnabled(provider: .antigravity, metadata: antigravityMeta, enabled: true) + } + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + + let codexSnapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 80, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: nil, + updatedAt: Date()) + let antigravitySnapshot = UsageSnapshot( + primary: nil, + secondary: nil, + tertiary: nil, + updatedAt: Date(), + identity: ProviderIdentitySnapshot( + providerID: .antigravity, + accountEmail: "user@example.com", + accountOrganization: nil, + loginMethod: "Pro")) + + store._setSnapshotForTesting(codexSnapshot, provider: .codex) + store._setSnapshotForTesting(antigravitySnapshot, provider: .antigravity) + + let highest = store.providerWithHighestUsage() + #expect(highest?.provider == .codex) + #expect(highest?.usedPercent == 80) } @Test @@ -582,4 +964,104 @@ struct UsageStoreHighestUsageTests { #expect(highest?.provider == .cursor) #expect(highest?.usedPercent == 100) } + + private func antigravityQuotaSummarySnapshot( + geminiSessionUsed: Double, + geminiWeeklyUsed: Double, + otherSessionUsed: Double, + otherWeeklyUsed: Double) -> UsageSnapshot + { + UsageSnapshot( + primary: nil, + secondary: nil, + tertiary: nil, + extraRateWindows: [ + NamedRateWindow( + id: "antigravity-quota-summary-gemini-5h", + title: "Gemini Session", + window: RateWindow( + usedPercent: geminiSessionUsed, + windowMinutes: 5 * 60, + resetsAt: nil, + resetDescription: nil)), + NamedRateWindow( + id: "antigravity-quota-summary-gemini-weekly", + title: "Gemini Weekly", + window: RateWindow( + usedPercent: geminiWeeklyUsed, + windowMinutes: 7 * 24 * 60, + resetsAt: nil, + resetDescription: nil)), + NamedRateWindow( + id: "antigravity-quota-summary-3p-5h", + title: "Claude + GPT Session", + window: RateWindow( + usedPercent: otherSessionUsed, + windowMinutes: 5 * 60, + resetsAt: nil, + resetDescription: nil)), + NamedRateWindow( + id: "antigravity-quota-summary-3p-weekly", + title: "Claude + GPT Weekly", + window: RateWindow( + usedPercent: otherWeeklyUsed, + windowMinutes: 7 * 24 * 60, + resetsAt: nil, + resetDescription: nil)), + ], + updatedAt: Date()) + } +} + +extension UsageStoreHighestUsageTests { + @Test + func `explicit antigravity metric remains authoritative for highest usage`() { + let settings = SettingsStore( + configStore: testConfigStore(suiteName: "UsageStoreHighestUsageTests-antigravity-explicit"), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + settings.refreshFrequency = .manual + settings.statusChecksEnabled = false + settings.setMenuBarMetricPreference(.secondary, for: .antigravity) + + let registry = ProviderRegistry.shared + if let codexMeta = registry.metadata[.codex] { + settings.setProviderEnabled(provider: .codex, metadata: codexMeta, enabled: true) + } + if let antigravityMeta = registry.metadata[.antigravity] { + settings.setProviderEnabled(provider: .antigravity, metadata: antigravityMeta, enabled: true) + } + + let store = UsageStore( + fetcher: UsageFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings) + store._setSnapshotForTesting( + UsageSnapshot( + primary: RateWindow(usedPercent: 80, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: nil, + updatedAt: Date()), + provider: .codex) + let antigravity = self.antigravityQuotaSummarySnapshot( + geminiSessionUsed: 10, + geminiWeeklyUsed: 20, + otherSessionUsed: 95, + otherWeeklyUsed: 90) + .with( + primary: RateWindow(usedPercent: 10, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: RateWindow(usedPercent: 95, windowMinutes: nil, resetsAt: nil, resetDescription: nil)) + store._setSnapshotForTesting(antigravity, provider: .antigravity) + + var highest = store.providerWithHighestUsage() + #expect(highest?.provider == .antigravity) + #expect(highest?.usedPercent == 95) + + store._setSnapshotForTesting( + antigravity.with( + primary: antigravity.primary, + secondary: RateWindow(usedPercent: 100, windowMinutes: nil, resetsAt: nil, resetDescription: nil)), + provider: .antigravity) + highest = store.providerWithHighestUsage() + #expect(highest?.provider == .codex) + } } diff --git a/Tests/CodexBarTests/UsageStoreManualTokenRefreshTests.swift b/Tests/CodexBarTests/UsageStoreManualTokenRefreshTests.swift index 014ce6716f..eb28ffd4fd 100644 --- a/Tests/CodexBarTests/UsageStoreManualTokenRefreshTests.swift +++ b/Tests/CodexBarTests/UsageStoreManualTokenRefreshTests.swift @@ -9,6 +9,7 @@ private actor TokenRefreshGate { private var released = false private var startWaiters: [CheckedContinuation] = [] private var releaseWaiters: [CheckedContinuation] = [] + private var finishWaiters: [CheckedContinuation] = [] private(set) var calls: [(provider: UsageProvider, force: Bool)] = [] func start(provider: UsageProvider, force: Bool) { @@ -42,11 +43,21 @@ private actor TokenRefreshGate { func finish() { self.didFinish = true + let waiters = self.finishWaiters + self.finishWaiters.removeAll() + waiters.forEach { $0.resume() } } func hasFinished() -> Bool { self.didFinish } + + func waitForFinish() async { + if self.didFinish { return } + await withCheckedContinuation { continuation in + self.finishWaiters.append(continuation) + } + } } private actor CompletionFlag { @@ -67,6 +78,17 @@ private actor TokenRefreshRecorder { func record(provider: UsageProvider, force: Bool) { self.calls.append((provider, force)) } + + func waitForCallCount(_ count: Int, timeout: Duration = .seconds(5)) async -> Bool { + let deadline = ContinuousClock.now + timeout + while self.calls.count < count { + if ContinuousClock.now >= deadline { + return false + } + try? await Task.sleep(for: .milliseconds(10)) + } + return true + } } @MainActor @@ -148,6 +170,119 @@ struct UsageStoreManualTokenRefreshTests { #expect(await recorder.calls.map(\.force) == [false, true]) } + @Test + func `scoped manual refresh drains scheduled token-cost refresh before forced pass`() async { + let store = Self.makeStore() + let scheduledGate = TokenRefreshGate() + let forcedGate = TokenRefreshGate() + let recorder = TokenRefreshRecorder() + let completion = CompletionFlag() + store._test_providerRefreshOverride = { _ in } + store._test_tokenUsageRefreshOverride = { provider, force in + await recorder.record(provider: provider, force: force) + if force { + await forcedGate.start(provider: provider, force: force) + await forcedGate.waitForRelease() + await forcedGate.finish() + } else { + await scheduledGate.start(provider: provider, force: force) + await scheduledGate.waitForRelease() + await scheduledGate.finish() + } + } + + await store.refresh(forceTokenUsage: false) + await scheduledGate.waitForStart() + + let task = Task { @MainActor in + await store.refreshTokenUsageNow(for: .codex, force: true) + await completion.markCompleted() + } + + try? await Task.sleep(for: .milliseconds(50)) + #expect(await completion.isCompleted() == false) + + await scheduledGate.release() + await forcedGate.waitForStart() + #expect(await completion.isCompleted() == false) + + await forcedGate.release() + await task.value + + #expect(await completion.isCompleted()) + #expect(await scheduledGate.hasFinished()) + #expect(await forcedGate.hasFinished()) + #expect(await recorder.calls.map(\.provider) == [.codex, .codex]) + #expect(await recorder.calls.map(\.force) == [false, true]) + } + + @Test + func `scoped manual refresh leaves unrelated scheduled token-cost refresh running`() async { + let store = Self.makeStore(enabledProviders: [.claude, .codex]) + let scheduledGate = TokenRefreshGate() + let forcedGate = TokenRefreshGate() + let recorder = TokenRefreshRecorder() + let completion = CompletionFlag() + store._test_providerRefreshOverride = { _ in } + store._test_tokenUsageRefreshOverride = { provider, force in + await recorder.record(provider: provider, force: force) + if force { + await forcedGate.start(provider: provider, force: force) + await forcedGate.waitForRelease() + await forcedGate.finish() + } else { + await scheduledGate.start(provider: provider, force: force) + await scheduledGate.waitForRelease() + await scheduledGate.finish() + } + } + + await store.refresh(forceTokenUsage: false) + await scheduledGate.waitForStart() + + let task = Task { @MainActor in + await store.refreshTokenUsageNow(for: .claude, force: true) + await completion.markCompleted() + } + + await forcedGate.waitForStart() + #expect(await scheduledGate.hasFinished() == false) + #expect(await completion.isCompleted() == false) + #expect(await recorder.calls.map(\.provider) == [.codex, .claude]) + #expect(await recorder.calls.map(\.force) == [false, true]) + + await forcedGate.release() + await task.value + #expect(await completion.isCompleted()) + #expect(await scheduledGate.hasFinished() == false) + + await scheduledGate.release() + await scheduledGate.waitForFinish() + } + + @Test + func `scoped manual refresh preserves an unrelated token sequence before it starts`() async { + let store = Self.makeStore(enabledProviders: [.claude, .codex]) + let recorder = TokenRefreshRecorder() + store._test_tokenUsageRefreshOverride = { provider, force in + await recorder.record(provider: provider, force: force) + } + + // Do not yield between installing the scheduled slot and starting the scoped refresh. This + // exercises the window before the scheduled task receives its first MainActor turn. + store.scheduleTokenRefreshForTesting() + await store.refreshTokenUsageNow(for: .claude, force: true) + + let recordedBothRefreshes = await recorder.waitForCallCount(2) + #expect(recordedBothRefreshes) + let scheduledTask = store.tokenRefreshSequenceTask + await scheduledTask?.value + + let calls = await recorder.calls + #expect(calls.contains { $0.provider == .codex && !$0.force }) + #expect(calls.contains { $0.provider == .claude && $0.force }) + } + @Test func `regular refresh schedules token-cost refresh without waiting`() async { let store = Self.makeStore() @@ -172,15 +307,39 @@ struct UsageStoreManualTokenRefreshTests { } } - private static func makeStore() -> UsageStore { - let suite = "UsageStoreManualTokenRefreshTests-\(UUID().uuidString)" - let defaults = UserDefaults(suiteName: suite)! - defaults.removePersistentDomain(forName: suite) - let settings = SettingsStore( - userDefaults: defaults, - configStore: testConfigStore(suiteName: suite), - zaiTokenStore: NoopZaiTokenStore(), - syntheticTokenStore: NoopSyntheticTokenStore()) + @Test + func `forced background refresh bypasses a fresh token cache`() async { + let store = Self.makeStore() + let recorder = TokenRefreshRecorder() + store._test_providerRefreshOverride = { _ in } + store._test_codexCreditsLoaderOverride = { + CreditsSnapshot(remaining: 25, events: [], updatedAt: Date()) + } + store._test_tokenUsageRefreshOverride = { provider, force in + await recorder.record(provider: provider, force: force) + } + defer { + store._test_providerRefreshOverride = nil + store._test_codexCreditsLoaderOverride = nil + store._test_tokenUsageRefreshOverride = nil + } + + await store.refresh(forceTokenUsage: false) + let didRecordScheduledRefresh = await recorder.waitForCallCount(1) + #expect(didRecordScheduledRefresh) + guard didRecordScheduledRefresh else { + store.cancelForcedRefreshEnrichment() + return + } + await store.refresh(enrichmentMode: .forcedBackground) + await store.awaitForcedRefreshEnrichment() + + #expect(await recorder.calls.map(\.provider) == [.codex, .codex]) + #expect(await recorder.calls.map(\.force) == [false, true]) + } + + private static func makeStore(enabledProviders: Set = [.codex]) -> UsageStore { + let settings = testSettingsStore(suiteName: "UsageStoreManualTokenRefreshTests") settings.refreshFrequency = .manual settings.statusChecksEnabled = false settings.costUsageEnabled = true @@ -191,14 +350,25 @@ struct UsageStoreManualTokenRefreshTests { 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) + settings.setProviderEnabled( + provider: provider, + metadata: metadata, + enabled: enabledProviders.contains(provider)) } + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("codexbar-tests", isDirectory: true) + .appendingPathComponent(UUID().uuidString, isDirectory: true) + let environment = [ + "HOME": root.path, + "CODEX_HOME": root.appendingPathComponent(".codex", isDirectory: true).path, + "XDG_CONFIG_HOME": root.appendingPathComponent(".config", isDirectory: true).path, + ] return UsageStore( - fetcher: UsageFetcher(), + fetcher: UsageFetcher(environment: environment), browserDetection: BrowserDetection(cacheTTL: 0), settings: settings, startupBehavior: .testing, - environmentBase: [:]) + environmentBase: environment) } } diff --git a/Tests/CodexBarTests/UsageStoreNeuralWattAccountRefreshTests.swift b/Tests/CodexBarTests/UsageStoreNeuralWattAccountRefreshTests.swift new file mode 100644 index 0000000000..84ee6ab66a --- /dev/null +++ b/Tests/CodexBarTests/UsageStoreNeuralWattAccountRefreshTests.swift @@ -0,0 +1,121 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +private actor NeuralWattAccountRefreshRecorder { + private(set) var dates: [Date] = [] + private var waiters: [(count: Int, continuation: CheckedContinuation)] = [] + + func record() { + self.dates.append(Date()) + let ready = self.waiters.filter { self.dates.count >= $0.count } + self.waiters.removeAll { self.dates.count >= $0.count } + ready.forEach { $0.continuation.resume() } + } + + func waitForCount(_ count: Int) async { + if self.dates.count >= count { return } + await withCheckedContinuation { continuation in + self.waiters.append((count, continuation)) + } + } +} + +private struct NeuralWattAccountRefreshStrategy: ProviderFetchStrategy { + let recorder: NeuralWattAccountRefreshRecorder + + let id = "neuralwatt-account-refresh-test" + let kind: ProviderFetchKind = .apiToken + + func isAvailable(_: ProviderFetchContext) async -> Bool { + true + } + + func fetch(_: ProviderFetchContext) async throws -> ProviderFetchResult { + await self.recorder.record() + let snapshot = UsageSnapshot( + primary: RateWindow( + usedPercent: 25, + windowMinutes: nil, + resetsAt: nil, + resetDescription: nil), + secondary: nil, + updatedAt: Date()) + return self.makeResult(usage: snapshot, sourceLabel: self.id) + } + + func shouldFallback(on _: Error, context _: ProviderFetchContext) -> Bool { + false + } +} + +@MainActor +@Suite(.serialized) +struct UsageStoreNeuralWattAccountRefreshTests { + @Test + func `multi-account refresh respects Neuralwatt quota rate limit`() async throws { + let recorder = NeuralWattAccountRefreshRecorder() + let store = try Self.makeStore(recorder: recorder) + let accounts = Self.addAccounts(to: store, count: 2) + + await store.refreshTokenAccounts(provider: .neuralwatt, accounts: accounts) + + let dates = await recorder.dates + #expect(dates.count == 2) + #expect(dates[1].timeIntervalSince(dates[0]) >= 0.95) + } + + private static func makeStore(recorder: NeuralWattAccountRefreshRecorder) throws -> UsageStore { + let settings = testSettingsStore( + suiteName: "UsageStoreNeuralWattAccountRefreshTests-\(UUID().uuidString)", + tokenAccountStore: InMemoryTokenAccountStore()) + settings.providerDetectionCompleted = true + settings.refreshFrequency = .manual + settings.statusChecksEnabled = false + let store = UsageStore( + fetcher: UsageFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + startupBehavior: .testing, + environmentBase: [:]) + let baseSpec = try #require(store.providerSpecs[.neuralwatt]) + let baseDescriptor = baseSpec.descriptor + let strategy = NeuralWattAccountRefreshStrategy(recorder: recorder) + store.providerSpecs[.neuralwatt] = ProviderSpec( + style: baseSpec.style, + isEnabled: { true }, + descriptor: ProviderDescriptor( + id: .neuralwatt, + metadata: baseDescriptor.metadata, + branding: baseDescriptor.branding, + tokenCost: baseDescriptor.tokenCost, + fetchPlan: ProviderFetchPlan( + sourceModes: [.auto, .api], + pipeline: ProviderFetchPipeline { _ in [strategy] }), + cli: baseDescriptor.cli), + makeFetchContext: baseSpec.makeFetchContext) + return store + } + + private static func addAccounts(to store: UsageStore, count: Int) -> [ProviderTokenAccount] { + for index in 0.. UsageSnapshot { + UsageSnapshot( + primary: RateWindow( + usedPercent: 25, + windowMinutes: nil, + resetsAt: nil, + resetDescription: nil), + secondary: nil, + updatedAt: Date()) + } +} diff --git a/Tests/CodexBarTests/UsageStorePlanUtilizationAsyncLoadTests.swift b/Tests/CodexBarTests/UsageStorePlanUtilizationAsyncLoadTests.swift new file mode 100644 index 0000000000..99e2c58978 --- /dev/null +++ b/Tests/CodexBarTests/UsageStorePlanUtilizationAsyncLoadTests.swift @@ -0,0 +1,417 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +/// Tests for the startup async plan-utilization history load. +/// +/// The decode of the persisted `PlanUtilizationHistoryStore` is moved off the +/// startup main thread because a mature two-year history can take ~150 ms to +/// parse. These tests pin the contract: +/// - `UsageStore.init` returns before disk I/O completes +/// - the load publishes exactly once after the gate releases +/// - sync menu accessors return the empty stub (no migration, no persistence +/// enqueue) while the load is in flight +/// - mutation paths wait for the load before touching the dictionary so a +/// startup refresh cannot overwrite real disk history with empty stubs +struct UsageStorePlanUtilizationAsyncLoadTests { + @MainActor + @Test + func `testing startup without an injected history store skips disk loading`() { + let suiteName = "UsageStorePlanUtilizationAsyncLoad-default-test-\(UUID().uuidString)" + let settings = Self.makeSettings(suiteName: suiteName) + defer { UserDefaults().removePersistentDomain(forName: suiteName) } + + let store = UsageStore( + fetcher: UsageFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + startupBehavior: .testing) + + #expect(store.planUtilizationHistoryLoadTask == nil) + #expect(store.planUtilizationHistoryLoaded == true) + #expect(store.planUtilizationHistory.isEmpty) + #expect(store.planUtilizationHistoryStore.directoryURL == nil) + } + + @MainActor + @Test + func `testing startup without an explicit gate skips background load`() { + let suiteName = "UsageStorePlanUtilizationAsyncLoad-testing-\(UUID().uuidString)" + let historyStore = testPlanUtilizationHistoryStore(suiteName: suiteName, reset: true) + historyStore.save([.codex: PlanUtilizationHistoryBuckets( + preferredAccountKey: nil, + unscoped: [planSeries( + name: .session, + windowMinutes: 300, + entries: [planEntry(at: Date(timeIntervalSince1970: 1_700_000_000), usedPercent: 42)])], + accounts: [:])]) + let settings = Self.makeSettings(suiteName: suiteName) + defer { UserDefaults().removePersistentDomain(forName: suiteName) } + + let store = UsageStore( + fetcher: UsageFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + planUtilizationHistoryStore: historyStore, + startupBehavior: .testing) + + #expect(store.planUtilizationHistoryLoadTask == nil) + #expect(store.planUtilizationHistoryLoaded) + #expect(store.planUtilizationHistory.isEmpty) + } + + @MainActor + @Test + func `init returns before disk load completes`() { + let suiteName = "UsageStorePlanUtilizationAsyncLoad-init-\(UUID().uuidString)" + let gate = PlanUtilizationHistoryLoadGate() + let historyStore = testPlanUtilizationHistoryStore(suiteName: suiteName, reset: false) + let settings = Self.makeSettings(suiteName: suiteName) + defer { UserDefaults().removePersistentDomain(forName: suiteName) } + _ = settings // silence unused + let store = UsageStore( + fetcher: UsageFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + planUtilizationHistoryStore: historyStore, + startupBehavior: .testing, + planUtilizationHistoryLoadGateForTesting: gate) + defer { store._cancelPlanUtilizationHistoryLoadForTesting() } + + // The gate is still closed, so the background load has not run. + #expect(store.planUtilizationHistory.isEmpty) + #expect(store.planUtilizationHistoryLoaded == false) + #expect(gate.isOpen == false) + } + + @MainActor + @Test + func `gate release publishes loaded history and bumps revision once`() async { + let suiteName = "UsageStorePlanUtilizationAsyncLoad-release-\(UUID().uuidString)" + let gate = PlanUtilizationHistoryLoadGate() + let historyStore = testPlanUtilizationHistoryStore(suiteName: suiteName, reset: true) + let codexSeries = planSeries( + name: .session, + windowMinutes: 300, + entries: [planEntry(at: Date(timeIntervalSince1970: 1_700_000_000), usedPercent: 42)]) + let buckets = PlanUtilizationHistoryBuckets( + preferredAccountKey: nil, + unscoped: [codexSeries], + accounts: [:]) + historyStore.save([.codex: buckets]) + let settings = Self.makeSettings(suiteName: suiteName) + defer { UserDefaults().removePersistentDomain(forName: suiteName) } + _ = settings + let store = UsageStore( + fetcher: UsageFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + planUtilizationHistoryStore: historyStore, + startupBehavior: .testing, + planUtilizationHistoryLoadGateForTesting: gate) + defer { store._cancelPlanUtilizationHistoryLoadForTesting() } + + let revisionBeforeOpen = store.planUtilizationHistoryRevision + gate.open() + await store._waitForPlanUtilizationHistoryLoadForTesting() + + #expect(store.planUtilizationHistoryLoaded == true) + #expect(store.planUtilizationHistory[.codex]?.unscoped.first?.name == .session) + // Revision must increment by exactly one when the load completes. + #expect(store.planUtilizationHistoryRevision == revisionBeforeOpen + 1) + } + + @MainActor + @Test + func `sync menu accessor returns empty stub while loading`() { + let suiteName = "UsageStorePlanUtilizationAsyncLoad-menuGate-\(UUID().uuidString)" + let gate = PlanUtilizationHistoryLoadGate() + let historyStore = testPlanUtilizationHistoryStore(suiteName: suiteName, reset: true) + // Pre-populate disk so a loaded store would return real history. + let series = planSeries( + name: .weekly, + windowMinutes: 10080, + entries: [planEntry(at: Date(timeIntervalSince1970: 1_700_000_000), usedPercent: 88)]) + historyStore.save([.claude: PlanUtilizationHistoryBuckets( + preferredAccountKey: nil, + unscoped: [series], + accounts: [:])]) + let settings = Self.makeSettings(suiteName: suiteName) + defer { UserDefaults().removePersistentDomain(forName: suiteName) } + _ = settings + let store = UsageStore( + fetcher: UsageFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + planUtilizationHistoryStore: historyStore, + startupBehavior: .testing, + planUtilizationHistoryLoadGateForTesting: gate) + defer { store._cancelPlanUtilizationHistoryLoadForTesting() } + + let selection = store.planUtilizationHistorySelection(for: .claude) + #expect(selection.accountKey == nil) + #expect(selection.histories.isEmpty) + #expect(store.planUtilizationHistory[.claude]?.preferredAccountKey == nil) + } + + @MainActor + @Test + func `empty directory loads to empty dictionary without error`() async { + let suiteName = "UsageStorePlanUtilizationAsyncLoad-empty-\(UUID().uuidString)" + let gate = PlanUtilizationHistoryLoadGate() + let historyStore = testPlanUtilizationHistoryStore(suiteName: suiteName, reset: true) + let settings = Self.makeSettings(suiteName: suiteName) + defer { UserDefaults().removePersistentDomain(forName: suiteName) } + _ = settings + let store = UsageStore( + fetcher: UsageFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + planUtilizationHistoryStore: historyStore, + startupBehavior: .testing, + planUtilizationHistoryLoadGateForTesting: gate) + defer { store._cancelPlanUtilizationHistoryLoadForTesting() } + + gate.open() + await store._waitForPlanUtilizationHistoryLoadForTesting() + + #expect(store.planUtilizationHistory.isEmpty) + #expect(store.planUtilizationHistoryLoaded == true) + } + + @MainActor + @Test + func `corrupt file loads best-effort empty`() async throws { + let suiteName = "UsageStorePlanUtilizationAsyncLoad-corrupt-\(UUID().uuidString)" + let gate = PlanUtilizationHistoryLoadGate() + let historyStore = testPlanUtilizationHistoryStore(suiteName: suiteName, reset: true) + // Write a file that does not parse as the expected schema. + let directoryURL = try #require(historyStore.directoryURL) + try? FileManager.default.createDirectory(at: directoryURL, withIntermediateDirectories: true) + let badURL = directoryURL.appendingPathComponent("codex.json") + try? Data("{not valid json".utf8).write(to: badURL) + let settings = Self.makeSettings(suiteName: suiteName) + defer { UserDefaults().removePersistentDomain(forName: suiteName) } + _ = settings + let store = UsageStore( + fetcher: UsageFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + planUtilizationHistoryStore: historyStore, + startupBehavior: .testing, + planUtilizationHistoryLoadGateForTesting: gate) + defer { store._cancelPlanUtilizationHistoryLoadForTesting() } + + gate.open() + await store._waitForPlanUtilizationHistoryLoadForTesting() + + // Best-effort empty: no panic, no providers populated, loaded flag set. + #expect(store.planUtilizationHistory.isEmpty) + #expect(store.planUtilizationHistoryLoaded == true) + } + + @MainActor + @Test + func `multi-provider multi-account ownership preserved after load`() async { + let suiteName = "UsageStorePlanUtilizationAsyncLoad-multi-\(UUID().uuidString)" + let gate = PlanUtilizationHistoryLoadGate() + let historyStore = testPlanUtilizationHistoryStore(suiteName: suiteName, reset: true) + let codexSession = planSeries( + name: .session, + windowMinutes: 300, + entries: [planEntry(at: Date(timeIntervalSince1970: 1_700_000_000), usedPercent: 31)]) + let claudeWeekly = planSeries( + name: .weekly, + windowMinutes: 10080, + entries: [planEntry(at: Date(timeIntervalSince1970: 1_700_000_001), usedPercent: 65)]) + let accountKey = "hashed-account-key" + let buckets = PlanUtilizationHistoryBuckets( + preferredAccountKey: accountKey, + unscoped: [], + accounts: [accountKey: [codexSession, claudeWeekly]]) + historyStore.save([.codex: buckets, .claude: buckets]) + let settings = Self.makeSettings(suiteName: suiteName) + defer { UserDefaults().removePersistentDomain(forName: suiteName) } + _ = settings + let store = UsageStore( + fetcher: UsageFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + planUtilizationHistoryStore: historyStore, + startupBehavior: .testing, + planUtilizationHistoryLoadGateForTesting: gate) + defer { store._cancelPlanUtilizationHistoryLoadForTesting() } + + gate.open() + await store._waitForPlanUtilizationHistoryLoadForTesting() + + #expect(store.planUtilizationHistory[.codex]?.accounts[accountKey]?.count == 2) + #expect(store.planUtilizationHistory[.claude]?.accounts[accountKey]?.count == 2) + #expect(store.planUtilizationHistory[.codex]?.preferredAccountKey == accountKey) + } + + @MainActor + @Test + func `record waits for disk load then merges and persists history`() async { + let suiteName = "UsageStorePlanUtilizationAsyncLoad-record-\(UUID().uuidString)" + let gate = PlanUtilizationHistoryLoadGate() + let historyStore = testPlanUtilizationHistoryStore(suiteName: suiteName, reset: true) + let oldCapture = Date(timeIntervalSince1970: 1_700_000_000) + let newCapture = oldCapture.addingTimeInterval(3700) + historyStore.save([.claude: PlanUtilizationHistoryBuckets( + preferredAccountKey: nil, + unscoped: [planSeries( + name: .session, + windowMinutes: 300, + entries: [planEntry(at: oldCapture, usedPercent: 20)])], + accounts: [:])]) + let settings = Self.makeSettings(suiteName: suiteName) + defer { UserDefaults().removePersistentDomain(forName: suiteName) } + let store = UsageStore( + fetcher: UsageFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + planUtilizationHistoryStore: historyStore, + startupBehavior: .testing, + planUtilizationHistoryLoadGateForTesting: gate) + let snapshot = UsageSnapshot( + primary: RateWindow( + usedPercent: 42, + windowMinutes: 300, + resetsAt: nil, + resetDescription: nil), + secondary: nil, + updatedAt: newCapture, + identity: nil) + + var recordStarted = false + var recordCompleted = false + let recordTask = Task { @MainActor in + recordStarted = true + await store.recordPlanUtilizationHistorySample( + provider: .claude, + snapshot: snapshot, + now: newCapture) + recordCompleted = true + } + for _ in 0..<1000 where !recordStarted { + await Task.yield() + } + #expect(recordStarted) + #expect(!recordCompleted) + #expect(store.planUtilizationHistory.isEmpty) + + gate.open() + await store._waitForPlanUtilizationHistoryLoadForTesting() + await recordTask.value + + let inMemory = findSeries( + store.planUtilizationHistory[.claude]?.unscoped ?? [], + name: .session, + windowMinutes: 300) + var persisted: PlanUtilizationSeriesHistory? + for _ in 0..<100 { + persisted = findSeries( + historyStore.load()[.claude]?.unscoped ?? [], + name: .session, + windowMinutes: 300) + if persisted == inMemory { break } + try? await Task.sleep(for: .milliseconds(10)) + } + #expect(inMemory?.entries.map(\.capturedAt) == [oldCapture, newCapture]) + #expect(inMemory?.entries.map(\.usedPercent) == [20, 42]) + #expect(persisted == inMemory) + } + + @MainActor + @Test + func `init work is independent of history size`() throws { + // With a closed load gate, UsageStore.init must return even when the + // persisted history would dominate startup time at production scale. + // The closed gate decouples the assertion from wall-clock variance; + // we verify the init returned before the load completed, not the + // decode duration itself. + let suiteName = "UsageStorePlanUtilizationAsyncLoad-perf-\(UUID().uuidString)" + let gate = PlanUtilizationHistoryLoadGate() + let historyStore = testPlanUtilizationHistoryStore(suiteName: suiteName, reset: true) + // Write a multi-megabyte synthetic payload so a real load would block. + let directoryURL = try #require(historyStore.directoryURL) + try? FileManager.default.createDirectory(at: directoryURL, withIntermediateDirectories: true) + let bigURL = directoryURL.appendingPathComponent("codex.json") + let payload = Self.makeSyntheticHistoryPayload(entriesPerProvider: 50000) + try? payload.write(to: bigURL) + let settings = Self.makeSettings(suiteName: suiteName) + defer { UserDefaults().removePersistentDomain(forName: suiteName) } + _ = settings + + let store = UsageStore( + fetcher: UsageFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + planUtilizationHistoryStore: historyStore, + startupBehavior: .testing, + planUtilizationHistoryLoadGateForTesting: gate) + defer { store._cancelPlanUtilizationHistoryLoadForTesting() } + + // Init returned without waiting on the disk load. + #expect(store.planUtilizationHistoryLoaded == false) + #expect(gate.isOpen == false) + } + + @MainActor + @Test + func `cancel before load wait is registered still drains the task`() async throws { + // Cancel immediately after init, intentionally without yielding. The + // cancellation state must remain visible when the load task later + // reaches `wait()`; otherwise the wakeup can be lost and the task leaks. + let suiteName = "UsageStorePlanUtilizationAsyncLoad-cancel-\(UUID().uuidString)" + let gate = PlanUtilizationHistoryLoadGate() + let historyStore = testPlanUtilizationHistoryStore(suiteName: suiteName, reset: true) + let settings = Self.makeSettings(suiteName: suiteName) + defer { UserDefaults().removePersistentDomain(forName: suiteName) } + _ = settings + let store = UsageStore( + fetcher: UsageFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + planUtilizationHistoryStore: historyStore, + startupBehavior: .testing, + planUtilizationHistoryLoadGateForTesting: gate) + + let loadTask = try #require(store.planUtilizationHistoryLoadTask) + store._cancelPlanUtilizationHistoryLoadForTesting() + await loadTask.value + + #expect(gate.isCancelled == true) + #expect(store.planUtilizationHistoryLoaded == true) + #expect(store.planUtilizationHistory.isEmpty) + gate.open() + #expect(gate.isOpen == false) + } + + // MARK: - Helpers + + @MainActor + private static func makeSettings(suiteName: String) -> SettingsStore { + let defaults = UserDefaults(suiteName: suiteName) ?? UserDefaults.standard + defaults.removePersistentDomain(forName: suiteName) + return SettingsStore( + userDefaults: defaults, + configStore: testConfigStore(suiteName: suiteName), + tokenAccountStore: InMemoryTokenAccountStore()) + } + + private static func makeSyntheticHistoryPayload(entriesPerProvider: Int) -> Data { + // A non-decodable but valid JSON shape keeps the test independent of + // the schema version while still forcing the JSON decoder to do real + // work when the load runs. + var entries: [String] = [] + entries.reserveCapacity(entriesPerProvider) + for index in 0.. UsageSnapshot { + UsageSnapshot( + primary: RateWindow( + usedPercent: usedPercent, + windowMinutes: 5 * 60, + resetsAt: nil, + resetDescription: nil, + isSyntheticPlaceholder: isPlaceholder), + secondary: RateWindow( + usedPercent: 20, + windowMinutes: 7 * 24 * 60, + resetsAt: nil, + resetDescription: nil), + updatedAt: updatedAt, + identity: ProviderIdentitySnapshot( + providerID: .claude, + accountEmail: accountLabel, + accountOrganization: nil, + loginMethod: "web")) + } + + let start = Date(timeIntervalSince1970: 1_780_000_000) + let before = snapshot(usedPercent: 65, isPlaceholder: false, updatedAt: start) + let placeholder = snapshot( + usedPercent: 0, + isPlaceholder: true, + updatedAt: start.addingTimeInterval(60 * 60)) + let genuineReset = snapshot( + usedPercent: 0, + isPlaceholder: false, + updatedAt: start.addingTimeInterval(2 * 60 * 60)) + + await store.recordPlanUtilizationHistorySample(provider: .claude, snapshot: before, now: before.updatedAt) + await store.recordPlanUtilizationHistorySample( + provider: .claude, + snapshot: placeholder, + now: placeholder.updatedAt) + #expect(recorder.events.isEmpty) + + await store.recordPlanUtilizationHistorySample( + provider: .claude, + snapshot: genuineReset, + now: genuineReset.updatedAt) + #expect(recorder.events.count == 1) + #expect(recorder.events.first?.usedPercent == 0) + } + + @MainActor + @Test + func `legacy session detector state preserves first reset after upgrade`() async throws { + let store = Self.makeStore() + let accountLabel = "session-reset-upgrade@example.com" + let recorder = SessionLimitResetEventRecorder(provider: .claude, accountLabel: accountLabel) + defer { recorder.invalidate() } + + let before = UsageSnapshot( + primary: RateWindow(usedPercent: 65, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + secondary: RateWindow(usedPercent: 20, windowMinutes: 10080, resetsAt: nil, resetDescription: nil), + updatedAt: Date(timeIntervalSince1970: 1_700_000_000), + identity: ProviderIdentitySnapshot( + providerID: .claude, + accountEmail: accountLabel, + accountOrganization: nil, + loginMethod: "max")) + let after = UsageSnapshot( + primary: RateWindow(usedPercent: 0, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + secondary: RateWindow(usedPercent: 20, windowMinutes: 10080, resetsAt: nil, resetDescription: nil), + updatedAt: Date(timeIntervalSince1970: 1_700_003_600), + identity: ProviderIdentitySnapshot( + providerID: .claude, + accountEmail: accountLabel, + accountOrganization: nil, + loginMethod: "max")) + + await store.recordPlanUtilizationHistorySample(provider: .claude, snapshot: before, now: before.updatedAt) + let detectorKey = try #require(store.sessionLimitResetDetectorStates.keys.first) + store.sessionLimitResetDetectorStates[detectorKey] = UsageStore.LimitResetDetectorState( + wasAboveThreshold: true, + lastObservedAt: before.updatedAt, + sourceRawValue: nil) + + await store.recordPlanUtilizationHistorySample(provider: .claude, snapshot: after, now: after.updatedAt) + + #expect(recorder.events.count == 1) + } + + @MainActor + @Test + func `codex session celebration follows semantic secondary session lane`() async throws { + let store = Self.makeStore() + let accountLabel = "codex-session-secondary@example.com" + let ownerKey = try #require(CodexLimitResetOwnerKey( + identity: .providerAccount(id: "fixture-codex-session-secondary"), + accountEmail: accountLabel)) + let recorder = SessionLimitResetEventRecorder(provider: .codex, accountLabel: accountLabel) + defer { recorder.invalidate() } + + let before = UsageSnapshot( + primary: RateWindow(usedPercent: 20, windowMinutes: 10080, resetsAt: nil, resetDescription: nil), + secondary: RateWindow(usedPercent: 65, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + updatedAt: Date(timeIntervalSince1970: 1_700_000_000), + identity: ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: accountLabel, + accountOrganization: nil, + loginMethod: "plus")) + let after = UsageSnapshot( + primary: RateWindow(usedPercent: 20, windowMinutes: 10080, resetsAt: nil, resetDescription: nil), + secondary: RateWindow(usedPercent: 0, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + updatedAt: Date(timeIntervalSince1970: 1_700_003_600), + identity: ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: accountLabel, + accountOrganization: nil, + loginMethod: "plus")) + + await store.recordPlanUtilizationHistorySample( + provider: .codex, + snapshot: before, + codexLimitResetOwnerKey: ownerKey, + now: before.updatedAt) + await store.recordPlanUtilizationHistorySample( + provider: .codex, + snapshot: after, + codexLimitResetOwnerKey: ownerKey, + now: after.updatedAt) + + #expect(recorder.events.count == 1) + #expect(recorder.events.first?.usedPercent == 0) + } + + @MainActor + @Test + func `codex session celebration ignores transient zero when reset boundary is unchanged`() async throws { + let store = Self.makeStore() + let accountLabel = "codex-session-transient-zero@example.com" + let ownerKey = try #require(CodexLimitResetOwnerKey( + identity: .providerAccount(id: "fixture-codex-session-transient-zero"), + accountEmail: accountLabel)) + let recorder = SessionLimitResetEventRecorder(provider: .codex, accountLabel: accountLabel) + defer { recorder.invalidate() } + + let firstDate = Date(timeIntervalSince1970: 1_700_000_000) + let sessionReset = firstDate.addingTimeInterval(5 * 3600) + let weeklyReset = firstDate.addingTimeInterval(3 * 24 * 3600) + + func snapshot(sessionUsed: Double, sessionReset: Date, updatedAt: Date) -> UsageSnapshot { + UsageSnapshot( + primary: RateWindow( + usedPercent: sessionUsed, + windowMinutes: 300, + resetsAt: sessionReset, + resetDescription: nil), + secondary: RateWindow( + usedPercent: 100, + windowMinutes: 10080, + resetsAt: weeklyReset, + resetDescription: nil), + updatedAt: updatedAt, + identity: ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: accountLabel, + accountOrganization: nil, + loginMethod: "pro")) + } + + let before = snapshot( + sessionUsed: 67, + sessionReset: sessionReset, + updatedAt: firstDate) + let regressedBoundaryHigh = snapshot( + sessionUsed: 68, + sessionReset: sessionReset.addingTimeInterval(-3600), + updatedAt: firstDate.addingTimeInterval(60)) + let transientZero = snapshot( + sessionUsed: 0, + sessionReset: sessionReset, + updatedAt: firstDate.addingTimeInterval(120)) + let realReset = snapshot( + sessionUsed: 0, + sessionReset: sessionReset.addingTimeInterval(5 * 3600), + updatedAt: firstDate.addingTimeInterval(180)) + + await store.recordPlanUtilizationHistorySample( + provider: .codex, + snapshot: before, + codexLimitResetOwnerKey: ownerKey, + now: before.updatedAt) + await store.recordPlanUtilizationHistorySample( + provider: .codex, + snapshot: regressedBoundaryHigh, + codexLimitResetOwnerKey: ownerKey, + now: regressedBoundaryHigh.updatedAt) + await store.recordPlanUtilizationHistorySample( + provider: .codex, + snapshot: transientZero, + codexLimitResetOwnerKey: ownerKey, + now: transientZero.updatedAt) + #expect(recorder.events.isEmpty) + + await store.recordPlanUtilizationHistorySample( + provider: .codex, + snapshot: realReset, + codexLimitResetOwnerKey: ownerKey, + now: realReset.updatedAt) + + #expect(recorder.events.count == 1) + #expect(recorder.events.first?.usedPercent == 0) + } + + @MainActor + @Test + func `codex weekly celebration ignores transient zero when reset boundary is unchanged`() async throws { + let store = Self.makeStore() + let accountLabel = "codex-weekly-transient-zero@example.com" + let ownerKey = try #require(CodexLimitResetOwnerKey( + identity: .providerAccount(id: "fixture-codex-weekly-transient-zero"), + accountEmail: accountLabel)) + let recorder = WeeklyLimitResetEventRecorder(provider: .codex, accountLabel: accountLabel) + defer { recorder.invalidate() } + + let firstDate = Date(timeIntervalSince1970: 1_700_000_000) + let sessionReset = firstDate.addingTimeInterval(5 * 3600) + let weeklyReset = firstDate.addingTimeInterval(3 * 24 * 3600) + + func snapshot(weeklyUsed: Double, weeklyReset: Date, updatedAt: Date) -> UsageSnapshot { + UsageSnapshot( + primary: RateWindow( + usedPercent: 14, + windowMinutes: 300, + resetsAt: sessionReset, + resetDescription: nil), + secondary: RateWindow( + usedPercent: weeklyUsed, + windowMinutes: 10080, + resetsAt: weeklyReset, + resetDescription: nil), + updatedAt: updatedAt, + identity: ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: accountLabel, + accountOrganization: nil, + loginMethod: "pro")) + } + + let before = snapshot( + weeklyUsed: 86, + weeklyReset: weeklyReset, + updatedAt: firstDate) + let transientZero = snapshot( + weeklyUsed: 0, + weeklyReset: weeklyReset, + updatedAt: firstDate.addingTimeInterval(120)) + let realReset = snapshot( + weeklyUsed: 0, + weeklyReset: weeklyReset.addingTimeInterval(7 * 24 * 3600), + updatedAt: firstDate.addingTimeInterval(180)) + + await store.recordPlanUtilizationHistorySample( + provider: .codex, + snapshot: before, + codexLimitResetOwnerKey: ownerKey, + now: before.updatedAt) + await store.recordPlanUtilizationHistorySample( + provider: .codex, + snapshot: transientZero, + codexLimitResetOwnerKey: ownerKey, + now: transientZero.updatedAt) + #expect(recorder.events.isEmpty) + + await store.recordPlanUtilizationHistorySample( + provider: .codex, + snapshot: realReset, + codexLimitResetOwnerKey: ownerKey, + now: realReset.updatedAt) + + #expect(recorder.events.count == 1) + #expect(recorder.events.first?.usedPercent == 0) + } + + @MainActor + @Test + func `codex weekly celebration ignores missing reset boundaries`() async throws { + let store = Self.makeStore() + let accountLabel = "codex-weekly-missing-boundary@example.com" + let ownerKey = try #require(CodexLimitResetOwnerKey( + identity: .providerAccount(id: "fixture-codex-weekly-missing-boundary"), + accountEmail: accountLabel)) + let recorder = WeeklyLimitResetEventRecorder(provider: .codex, accountLabel: accountLabel) + defer { recorder.invalidate() } + + let firstDate = Date(timeIntervalSince1970: 1_700_800_000) + let sessionReset = firstDate.addingTimeInterval(5 * 3600) + let weeklyReset = firstDate.addingTimeInterval(3 * 24 * 3600) + + func snapshot(weeklyUsed: Double, weeklyReset: Date?, updatedAt: Date) -> UsageSnapshot { + UsageSnapshot( + primary: RateWindow( + usedPercent: 14, + windowMinutes: 300, + resetsAt: sessionReset, + resetDescription: nil), + secondary: RateWindow( + usedPercent: weeklyUsed, + windowMinutes: 10080, + resetsAt: weeklyReset, + resetDescription: nil), + updatedAt: updatedAt, + identity: ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: accountLabel, + accountOrganization: nil, + loginMethod: "pro")) + } + + let before = snapshot(weeklyUsed: 86, weeklyReset: nil, updatedAt: firstDate) + let transientZero = snapshot( + weeklyUsed: 0, + weeklyReset: nil, + updatedAt: firstDate.addingTimeInterval(120)) + + await store.recordPlanUtilizationHistorySample( + provider: .codex, + snapshot: before, + codexLimitResetOwnerKey: ownerKey, + now: before.updatedAt) + await store.recordPlanUtilizationHistorySample( + provider: .codex, + snapshot: transientZero, + codexLimitResetOwnerKey: ownerKey, + now: transientZero.updatedAt) + #expect(recorder.events.isEmpty) + + let establishedBoundary = snapshot( + weeklyUsed: 72, + weeklyReset: weeklyReset, + updatedAt: firstDate.addingTimeInterval(240)) + let realReset = snapshot( + weeklyUsed: 0, + weeklyReset: weeklyReset.addingTimeInterval(7 * 24 * 3600), + updatedAt: firstDate.addingTimeInterval(360)) + + await store.recordPlanUtilizationHistorySample( + provider: .codex, + snapshot: establishedBoundary, + codexLimitResetOwnerKey: ownerKey, + now: establishedBoundary.updatedAt) + await store.recordPlanUtilizationHistorySample( + provider: .codex, + snapshot: realReset, + codexLimitResetOwnerKey: ownerKey, + now: realReset.updatedAt) + + #expect(recorder.events.count == 1) + #expect(recorder.events.first?.usedPercent == 0) + } + + @MainActor + @Test + func `codex weekly celebration preserves a known boundary across missing metadata`() async throws { + let store = Self.makeStore() + let accountLabel = "codex-weekly-intermittent-boundary@example.com" + let ownerKey = try #require(CodexLimitResetOwnerKey( + identity: .providerAccount(id: "fixture-codex-weekly-intermittent-boundary"), + accountEmail: accountLabel)) + let recorder = WeeklyLimitResetEventRecorder(provider: .codex, accountLabel: accountLabel) + defer { recorder.invalidate() } + + let firstDate = Date(timeIntervalSince1970: 1_700_900_000) + let weeklyReset = firstDate.addingTimeInterval(3 * 24 * 3600) + + func snapshot(weeklyUsed: Double, weeklyReset: Date?, updatedAt: Date) -> UsageSnapshot { + UsageSnapshot( + primary: RateWindow( + usedPercent: 14, + windowMinutes: 300, + resetsAt: firstDate.addingTimeInterval(5 * 3600), + resetDescription: nil), + secondary: RateWindow( + usedPercent: weeklyUsed, + windowMinutes: 10080, + resetsAt: weeklyReset, + resetDescription: nil), + updatedAt: updatedAt, + identity: ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: accountLabel, + accountOrganization: nil, + loginMethod: "pro")) + } + + let before = snapshot(weeklyUsed: 86, weeklyReset: weeklyReset, updatedAt: firstDate) + let missingMetadata = snapshot( + weeklyUsed: 84, + weeklyReset: nil, + updatedAt: firstDate.addingTimeInterval(60)) + let realReset = snapshot( + weeklyUsed: 0, + weeklyReset: weeklyReset.addingTimeInterval(7 * 24 * 3600), + updatedAt: firstDate.addingTimeInterval(120)) + + await store.recordPlanUtilizationHistorySample( + provider: .codex, + snapshot: before, + codexLimitResetOwnerKey: ownerKey, + now: before.updatedAt) + await store.recordPlanUtilizationHistorySample( + provider: .codex, + snapshot: missingMetadata, + codexLimitResetOwnerKey: ownerKey, + now: missingMetadata.updatedAt) + await store.recordPlanUtilizationHistorySample( + provider: .codex, + snapshot: realReset, + codexLimitResetOwnerKey: ownerKey, + now: realReset.updatedAt) + + #expect(recorder.events.count == 1) + #expect(recorder.events.first?.usedPercent == 0) + } + + @MainActor + @Test + func `codex session celebration ignores missing reset boundary after a known boundary`() async throws { + let store = Self.makeStore() + let accountLabel = "codex-session-missing-boundary@example.com" + let ownerKey = try #require(CodexLimitResetOwnerKey( + identity: .providerAccount(id: "fixture-codex-session-missing-boundary"), + accountEmail: accountLabel)) + let recorder = SessionLimitResetEventRecorder(provider: .codex, accountLabel: accountLabel) + defer { recorder.invalidate() } + + let firstDate = Date(timeIntervalSince1970: 1_700_000_000) + let sessionReset = firstDate.addingTimeInterval(5 * 3600) + let weeklyReset = firstDate.addingTimeInterval(3 * 24 * 3600) + + func snapshot(sessionUsed: Double, sessionReset: Date?, updatedAt: Date) -> UsageSnapshot { + UsageSnapshot( + primary: RateWindow( + usedPercent: sessionUsed, + windowMinutes: 300, + resetsAt: sessionReset, + resetDescription: nil), + secondary: RateWindow( + usedPercent: 100, + windowMinutes: 10080, + resetsAt: weeklyReset, + resetDescription: nil), + updatedAt: updatedAt, + identity: ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: accountLabel, + accountOrganization: nil, + loginMethod: "pro")) + } + + let before = snapshot( + sessionUsed: 67, + sessionReset: sessionReset, + updatedAt: firstDate) + let missingBoundaryHigh = snapshot( + sessionUsed: 68, + sessionReset: nil, + updatedAt: firstDate.addingTimeInterval(60)) + let missingBoundary = snapshot( + sessionUsed: 0, + sessionReset: nil, + updatedAt: firstDate.addingTimeInterval(120)) + let realReset = snapshot( + sessionUsed: 0, + sessionReset: sessionReset.addingTimeInterval(5 * 3600), + updatedAt: firstDate.addingTimeInterval(180)) + + await store.recordPlanUtilizationHistorySample( + provider: .codex, + snapshot: before, + codexLimitResetOwnerKey: ownerKey, + now: before.updatedAt) + await store.recordPlanUtilizationHistorySample( + provider: .codex, + snapshot: missingBoundaryHigh, + codexLimitResetOwnerKey: ownerKey, + now: missingBoundaryHigh.updatedAt) + await store.recordPlanUtilizationHistorySample( + provider: .codex, + snapshot: missingBoundary, + codexLimitResetOwnerKey: ownerKey, + now: missingBoundary.updatedAt) + #expect(recorder.events.isEmpty) + + await store.recordPlanUtilizationHistorySample( + provider: .codex, + snapshot: realReset, + codexLimitResetOwnerKey: ownerKey, + now: realReset.updatedAt) + + #expect(recorder.events.count == 1) + #expect(recorder.events.first?.usedPercent == 0) + } + + @MainActor + @Test + func `codex weekly celebration ignores low usage with an unchanged boundary`() async throws { + let store = Self.makeStore() + let accountLabel = "codex-weekly-unchanged-boundary@example.com" + let ownerKey = try #require(CodexLimitResetOwnerKey( + identity: .providerAccount(id: "fixture-codex-weekly-unchanged-boundary"), + accountEmail: accountLabel)) + let recorder = WeeklyLimitResetEventRecorder(provider: .codex, accountLabel: accountLabel) + defer { recorder.invalidate() } + + let firstDate = Date(timeIntervalSince1970: 1_701_000_000) + let weeklyReset = firstDate.addingTimeInterval(3 * 24 * 3600) + let before = codexWeeklySnapshot( + accountLabel: accountLabel, + usedPercent: 86, + resetsAt: weeklyReset, + updatedAt: firstDate) + let transientLow = codexWeeklySnapshot( + accountLabel: accountLabel, + usedPercent: 0, + resetsAt: weeklyReset, + updatedAt: firstDate.addingTimeInterval(60)) + + await store.recordPlanUtilizationHistorySample( + provider: .codex, + snapshot: before, + codexLimitResetOwnerKey: ownerKey, + now: before.updatedAt) + await store.recordPlanUtilizationHistorySample( + provider: .codex, + snapshot: transientLow, + codexLimitResetOwnerKey: ownerKey, + now: transientLow.updatedAt) + + #expect(recorder.events.isEmpty) + } + + @MainActor + @Test + func `codex weekly celebration requires both reset boundaries`() async throws { + let store = Self.makeStore() + let accountLabel = "codex-weekly-requires-boundaries@example.com" + let recorder = WeeklyLimitResetEventRecorder(provider: .codex, accountLabel: accountLabel) + defer { recorder.invalidate() } + + let firstDate = Date(timeIntervalSince1970: 1_702_000_000) + let weeklyReset = firstDate.addingTimeInterval(3 * 24 * 3600) + let missingPreviousOwner = try #require(CodexLimitResetOwnerKey( + identity: .providerAccount(id: "fixture-codex-weekly-missing-previous"), + accountEmail: accountLabel)) + let missingCurrentOwner = try #require(CodexLimitResetOwnerKey( + identity: .providerAccount(id: "fixture-codex-weekly-missing-current"), + accountEmail: accountLabel)) + let missingPreviousHigh = codexWeeklySnapshot( + accountLabel: accountLabel, + usedPercent: 86, + resetsAt: nil, + updatedAt: firstDate) + let boundaryAppearedLow = codexWeeklySnapshot( + accountLabel: accountLabel, + usedPercent: 0, + resetsAt: weeklyReset, + updatedAt: firstDate.addingTimeInterval(60)) + let knownBoundaryHigh = codexWeeklySnapshot( + accountLabel: accountLabel, + usedPercent: 86, + resetsAt: weeklyReset, + updatedAt: firstDate.addingTimeInterval(120)) + let missingCurrentLow = codexWeeklySnapshot( + accountLabel: accountLabel, + usedPercent: 0, + resetsAt: nil, + updatedAt: firstDate.addingTimeInterval(180)) + + await store.recordPlanUtilizationHistorySample( + provider: .codex, + snapshot: missingPreviousHigh, + codexLimitResetOwnerKey: missingPreviousOwner, + now: missingPreviousHigh.updatedAt) + await store.recordPlanUtilizationHistorySample( + provider: .codex, + snapshot: boundaryAppearedLow, + codexLimitResetOwnerKey: missingPreviousOwner, + now: boundaryAppearedLow.updatedAt) + await store.recordPlanUtilizationHistorySample( + provider: .codex, + snapshot: knownBoundaryHigh, + codexLimitResetOwnerKey: missingCurrentOwner, + now: knownBoundaryHigh.updatedAt) + await store.recordPlanUtilizationHistorySample( + provider: .codex, + snapshot: missingCurrentLow, + codexLimitResetOwnerKey: missingCurrentOwner, + now: missingCurrentLow.updatedAt) + + #expect(recorder.events.isEmpty) + } + + @MainActor + @Test + func `codex weekly celebration posts once for an advanced boundary`() async throws { + let store = Self.makeStore() + let accountLabel = "codex-weekly-advanced-boundary@example.com" + let ownerKey = try #require(CodexLimitResetOwnerKey( + identity: .providerAccount(id: "fixture-codex-weekly-advanced-boundary"), + accountEmail: accountLabel)) + let recorder = WeeklyLimitResetEventRecorder(provider: .codex, accountLabel: accountLabel) + defer { recorder.invalidate() } + + let firstDate = Date(timeIntervalSince1970: 1_703_000_000) + let weeklyReset = firstDate.addingTimeInterval(3 * 24 * 3600) + let nextWeeklyReset = weeklyReset.addingTimeInterval(7 * 24 * 3600) + let before = codexWeeklySnapshot( + accountLabel: accountLabel, + usedPercent: 86, + resetsAt: weeklyReset, + updatedAt: firstDate) + let reset = codexWeeklySnapshot( + accountLabel: accountLabel, + usedPercent: 0, + resetsAt: nextWeeklyReset, + updatedAt: firstDate.addingTimeInterval(60)) + let repeatedLow = codexWeeklySnapshot( + accountLabel: accountLabel, + usedPercent: 0, + resetsAt: nextWeeklyReset, + updatedAt: firstDate.addingTimeInterval(120)) + + for snapshot in [before, reset, repeatedLow] { + await store.recordPlanUtilizationHistorySample( + provider: .codex, + snapshot: snapshot, + codexLimitResetOwnerKey: ownerKey, + now: snapshot.updatedAt) + } + + #expect(recorder.events.count == 1) + #expect(recorder.events.first?.usedPercent == 0) + } + + @MainActor + @Test + func `codex weekly detector isolates same email workspaces`() async throws { + let store = Self.makeStore() + let accountLabel = "codex-shared-email@example.com" + let ownerA = try #require(CodexLimitResetOwnerKey( + identity: .providerAccount(id: "fixture-codex-workspace-a"), + accountEmail: accountLabel)) + let ownerB = try #require(CodexLimitResetOwnerKey( + identity: .providerAccount(id: "fixture-codex-workspace-b"), + accountEmail: accountLabel)) + let recorder = WeeklyLimitResetEventRecorder(provider: .codex, accountLabel: accountLabel) + defer { recorder.invalidate() } + + let firstDate = Date(timeIntervalSince1970: 1_703_500_000) + let weeklyReset = firstDate.addingTimeInterval(3 * 24 * 3600) + let nextWeeklyReset = weeklyReset.addingTimeInterval(7 * 24 * 3600) + let workspaceAHigh = codexWeeklySnapshot( + accountLabel: accountLabel, + usedPercent: 86, + resetsAt: weeklyReset, + updatedAt: firstDate) + let workspaceBLow = codexWeeklySnapshot( + accountLabel: accountLabel, + usedPercent: 0, + resetsAt: nextWeeklyReset, + updatedAt: firstDate.addingTimeInterval(60)) + let workspaceAReset = codexWeeklySnapshot( + accountLabel: accountLabel, + usedPercent: 0, + resetsAt: nextWeeklyReset, + updatedAt: firstDate.addingTimeInterval(120)) + + await store.recordPlanUtilizationHistorySample( + provider: .codex, + snapshot: workspaceAHigh, + codexLimitResetOwnerKey: ownerA, + now: workspaceAHigh.updatedAt) + await store.recordPlanUtilizationHistorySample( + provider: .codex, + snapshot: workspaceBLow, + codexLimitResetOwnerKey: ownerB, + now: workspaceBLow.updatedAt) + + #expect(recorder.events.isEmpty) + #expect(store.weeklyLimitResetDetectorStates.count == 2) + + await store.recordPlanUtilizationHistorySample( + provider: .codex, + snapshot: workspaceAReset, + codexLimitResetOwnerKey: ownerA, + now: workspaceAReset.updatedAt) + + #expect(recorder.events.count == 1) + #expect(recorder.events.first?.usedPercent == 0) + } + + @MainActor + @Test + func `codex weekly detector isolates members of the same workspace`() async throws { + let store = Self.makeStore() + let firstEmail = "first-workspace-member@example.com" + let secondEmail = "second-workspace-member@example.com" + let ownerA = try #require(CodexLimitResetOwnerKey( + identity: .providerAccount(id: "fixture-codex-shared-workspace"), + accountEmail: firstEmail)) + let ownerB = try #require(CodexLimitResetOwnerKey( + identity: .providerAccount(id: "fixture-codex-shared-workspace"), + accountEmail: secondEmail)) + let recorder = WeeklyLimitResetEventRecorder(provider: .codex, accountLabel: secondEmail) + defer { recorder.invalidate() } + + let firstDate = Date(timeIntervalSince1970: 1_703_700_000) + let weeklyReset = firstDate.addingTimeInterval(3 * 24 * 3600) + let nextWeeklyReset = weeklyReset.addingTimeInterval(7 * 24 * 3600) + let firstMemberHigh = codexWeeklySnapshot( + accountLabel: firstEmail, + usedPercent: 86, + resetsAt: weeklyReset, + updatedAt: firstDate) + let secondMemberLow = codexWeeklySnapshot( + accountLabel: secondEmail, + usedPercent: 0, + resetsAt: nextWeeklyReset, + updatedAt: firstDate.addingTimeInterval(60)) + + await store.recordPlanUtilizationHistorySample( + provider: .codex, + snapshot: firstMemberHigh, + codexLimitResetOwnerKey: ownerA, + now: firstMemberHigh.updatedAt) + await store.recordPlanUtilizationHistorySample( + provider: .codex, + snapshot: secondMemberLow, + codexLimitResetOwnerKey: ownerB, + now: secondMemberLow.updatedAt) + + #expect(ownerA != ownerB) + #expect(store.weeklyLimitResetDetectorStates.count == 2) + #expect(recorder.events.isEmpty) + } + + @MainActor + @Test + func `codex weekly celebration preserves baseline across a regressed boundary`() async throws { + let store = Self.makeStore() + let accountLabel = "codex-weekly-regressed-boundary@example.com" + let ownerKey = try #require(CodexLimitResetOwnerKey( + identity: .providerAccount(id: "fixture-codex-weekly-regressed-boundary"), + accountEmail: accountLabel)) + let recorder = WeeklyLimitResetEventRecorder(provider: .codex, accountLabel: accountLabel) + defer { recorder.invalidate() } + + let firstDate = Date(timeIntervalSince1970: 1_704_000_000) + let weeklyReset = firstDate.addingTimeInterval(3 * 24 * 3600) + let before = codexWeeklySnapshot( + accountLabel: accountLabel, + usedPercent: 86, + resetsAt: weeklyReset, + updatedAt: firstDate) + let regressedHigh = codexWeeklySnapshot( + accountLabel: accountLabel, + usedPercent: 87, + resetsAt: weeklyReset.addingTimeInterval(-24 * 3600), + updatedAt: firstDate.addingTimeInterval(60)) + let transientLow = codexWeeklySnapshot( + accountLabel: accountLabel, + usedPercent: 0, + resetsAt: weeklyReset, + updatedAt: firstDate.addingTimeInterval(120)) + let realReset = codexWeeklySnapshot( + accountLabel: accountLabel, + usedPercent: 0, + resetsAt: weeklyReset.addingTimeInterval(7 * 24 * 3600), + updatedAt: firstDate.addingTimeInterval(180)) + + for snapshot in [before, regressedHigh, transientLow] { + await store.recordPlanUtilizationHistorySample( + provider: .codex, + snapshot: snapshot, + codexLimitResetOwnerKey: ownerKey, + now: snapshot.updatedAt) + } + #expect(recorder.events.isEmpty) + + await store.recordPlanUtilizationHistorySample( + provider: .codex, + snapshot: realReset, + codexLimitResetOwnerKey: ownerKey, + now: realReset.updatedAt) + + #expect(recorder.events.count == 1) + #expect(recorder.events.first?.usedPercent == 0) + } + + @MainActor + @Test + func `weekly quota celebration posts when weekly usage resets to zero`() async { + let store = Self.makeStore() + let accountLabel = "reset-zero@example.com" + let recorder = WeeklyLimitResetEventRecorder(provider: .claude, accountLabel: accountLabel) + defer { recorder.invalidate() } + + let before = UsageSnapshot( + primary: RateWindow(usedPercent: 10, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + secondary: RateWindow(usedPercent: 99, windowMinutes: 10080, resetsAt: nil, resetDescription: nil), + updatedAt: Date(timeIntervalSince1970: 1_700_000_000), + identity: ProviderIdentitySnapshot( + providerID: .claude, + accountEmail: accountLabel, + accountOrganization: nil, + loginMethod: "max")) + let after = UsageSnapshot( + primary: RateWindow(usedPercent: 10, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + secondary: RateWindow(usedPercent: 0, windowMinutes: 10080, resetsAt: nil, resetDescription: nil), + updatedAt: Date(timeIntervalSince1970: 1_700_003_600), + identity: ProviderIdentitySnapshot( + providerID: .claude, + accountEmail: accountLabel, + accountOrganization: nil, + loginMethod: "max")) + + await store.recordPlanUtilizationHistorySample(provider: .claude, snapshot: before, now: before.updatedAt) + await store.recordPlanUtilizationHistorySample(provider: .claude, snapshot: after, now: after.updatedAt) + + let events = recorder.events + #expect(events.count == 1) + #expect(events[0].provider == .claude) + #expect(events[0].accountLabel == accountLabel) + #expect(events[0].usedPercent == 0) + } + + @MainActor + @Test + func `weekly quota celebration posts when reset lands mid hour without history split`() async { + let store = Self.makeStore() + let accountLabel = "mid-hour-reset@example.com" + let recorder = WeeklyLimitResetEventRecorder(provider: .claude, accountLabel: accountLabel) + defer { recorder.invalidate() } + + let before = UsageSnapshot( + primary: RateWindow(usedPercent: 10, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + secondary: RateWindow( + usedPercent: 40, + windowMinutes: 10080, + resetsAt: Date(timeIntervalSince1970: 1_700_100_000), + resetDescription: nil), + updatedAt: Date(timeIntervalSince1970: 1_700_000_000), + identity: ProviderIdentitySnapshot( + providerID: .claude, + accountEmail: accountLabel, + accountOrganization: nil, + loginMethod: "max")) + let after = UsageSnapshot( + primary: RateWindow(usedPercent: 10, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + secondary: RateWindow( + usedPercent: 0, + windowMinutes: 10080, + resetsAt: Date(timeIntervalSince1970: 1_700_100_030), + resetDescription: nil), + updatedAt: Date(timeIntervalSince1970: 1_700_001_800), + identity: ProviderIdentitySnapshot( + providerID: .claude, + accountEmail: accountLabel, + accountOrganization: nil, + loginMethod: "max")) + + await store.recordPlanUtilizationHistorySample(provider: .claude, snapshot: before, now: before.updatedAt) + await store.recordPlanUtilizationHistorySample(provider: .claude, snapshot: after, now: after.updatedAt) + + let histories = store.planUtilizationHistory(for: .claude) + #expect(findSeries(histories, name: .weekly, windowMinutes: 10080)?.entries.count == 1) + #expect(findSeries(histories, name: .weekly, windowMinutes: 10080)?.entries.last?.usedPercent == 40) + let events = recorder.events + #expect(events.count == 1) + #expect(events[0].usedPercent == 0) + } + + @MainActor + @Test + func `weekly quota celebration ignores first seen reset sample`() async { + let store = Self.makeStore() + let accountLabel = "first-seen-reset@example.com" + let recorder = WeeklyLimitResetEventRecorder(provider: .claude, accountLabel: accountLabel) + defer { recorder.invalidate() } + + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 10, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + secondary: RateWindow(usedPercent: 0, windowMinutes: 10080, resetsAt: nil, resetDescription: nil), + updatedAt: Date(timeIntervalSince1970: 1_700_000_000), + identity: ProviderIdentitySnapshot( + providerID: .claude, + accountEmail: accountLabel, + accountOrganization: nil, + loginMethod: "max")) + + await store.recordPlanUtilizationHistorySample(provider: .claude, snapshot: snapshot, now: snapshot.updatedAt) + + #expect(recorder.events.isEmpty) + } + + @MainActor + @Test + func `antigravity weekly celebration samples stable named bucket maximum`() async { + let store = Self.makeStore() + let recorder = WeeklyLimitResetEventRecorder(provider: .antigravity, accountLabel: nil) + defer { recorder.invalidate() } + + func snapshot( + primary: RateWindow, + secondary: RateWindow, + geminiWeeklyUsed: Double, + thirdPartyWeeklyUsed: Double, + updatedAt: Date) -> UsageSnapshot + { + UsageSnapshot( + primary: primary, + secondary: secondary, + tertiary: nil, + extraRateWindows: [ + NamedRateWindow( + id: "antigravity-quota-summary-gemini-weekly", + title: "Gemini Models Weekly Limit", + window: RateWindow( + usedPercent: geminiWeeklyUsed, + windowMinutes: 10080, + resetsAt: nil, + resetDescription: nil)), + NamedRateWindow( + id: "antigravity-quota-summary-3p-weekly", + title: "Claude and GPT models Weekly Limit", + window: RateWindow( + usedPercent: thirdPartyWeeklyUsed, + windowMinutes: 10080, + resetsAt: nil, + resetDescription: nil)), + ], + updatedAt: updatedAt) + } + + let firstDate = Date(timeIntervalSince1970: 1_700_000_000) + let before = snapshot( + primary: RateWindow( + usedPercent: 80, + windowMinutes: 10080, + resetsAt: nil, + resetDescription: nil), + secondary: RateWindow( + usedPercent: 20, + windowMinutes: 300, + resetsAt: nil, + resetDescription: nil), + geminiWeeklyUsed: 80, + thirdPartyWeeklyUsed: 0, + updatedAt: firstDate) + let representativeChanged = snapshot( + primary: RateWindow( + usedPercent: 20, + windowMinutes: 300, + resetsAt: nil, + resetDescription: nil), + secondary: RateWindow( + usedPercent: 0, + windowMinutes: 10080, + resetsAt: nil, + resetDescription: nil), + geminiWeeklyUsed: 0, + thirdPartyWeeklyUsed: 80, + updatedAt: firstDate.addingTimeInterval(3600)) + let reset = snapshot( + primary: RateWindow( + usedPercent: 20, + windowMinutes: 300, + resetsAt: nil, + resetDescription: nil), + secondary: RateWindow( + usedPercent: 0, + windowMinutes: 10080, + resetsAt: nil, + resetDescription: nil), + geminiWeeklyUsed: 0, + thirdPartyWeeklyUsed: 0, + updatedAt: firstDate.addingTimeInterval(7200)) + + await store.recordPlanUtilizationHistorySample( + provider: .antigravity, + snapshot: before, + now: before.updatedAt) + await store.recordPlanUtilizationHistorySample( + provider: .antigravity, + snapshot: representativeChanged, + now: representativeChanged.updatedAt) + #expect(recorder.events.isEmpty) + + await store.recordPlanUtilizationHistorySample( + provider: .antigravity, + snapshot: reset, + now: reset.updatedAt) + #expect(recorder.events.count == 1) + #expect(recorder.events.first?.usedPercent == 0) + } + + @MainActor + @Test + func `antigravity session celebration follows stable quota summary source`() async { + let store = Self.makeStore() + let recorder = SessionLimitResetEventRecorder(provider: .antigravity, accountLabel: nil) + defer { recorder.invalidate() } + + func summarySnapshot(geminiUsed: Double, thirdPartyUsed: Double, updatedAt: Date) -> UsageSnapshot { + UsageSnapshot( + primary: RateWindow( + usedPercent: 0, + windowMinutes: 300, + resetsAt: nil, + resetDescription: nil), + secondary: nil, + extraRateWindows: [ + NamedRateWindow( + id: "antigravity-quota-summary-gemini-session", + title: "Gemini Models", + window: RateWindow( + usedPercent: geminiUsed, + windowMinutes: 300, + resetsAt: nil, + resetDescription: nil)), + NamedRateWindow( + id: "antigravity-quota-summary-3p-session", + title: "Claude and GPT models", + window: RateWindow( + usedPercent: thirdPartyUsed, + windowMinutes: 300, + resetsAt: nil, + resetDescription: nil)), + ], + updatedAt: updatedAt) + } + + let firstDate = Date(timeIntervalSince1970: 1_700_000_000) + let legacy = UsageSnapshot( + primary: RateWindow(usedPercent: 90, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + secondary: nil, + updatedAt: firstDate) + let sourceChanged = summarySnapshot( + geminiUsed: 0, + thirdPartyUsed: 0, + updatedAt: firstDate.addingTimeInterval(3600)) + let representativeChanged = summarySnapshot( + geminiUsed: 80, + thirdPartyUsed: 20, + updatedAt: firstDate.addingTimeInterval(7200)) + let reset = summarySnapshot( + geminiUsed: 0, + thirdPartyUsed: 0, + updatedAt: firstDate.addingTimeInterval(10800)) + + await store.recordPlanUtilizationHistorySample( + provider: .antigravity, + snapshot: legacy, + now: legacy.updatedAt) + await store.recordPlanUtilizationHistorySample( + provider: .antigravity, + snapshot: sourceChanged, + now: sourceChanged.updatedAt) + #expect(recorder.events.isEmpty) + + await store.recordPlanUtilizationHistorySample( + provider: .antigravity, + snapshot: representativeChanged, + now: representativeChanged.updatedAt) + await store.recordPlanUtilizationHistorySample( + provider: .antigravity, + snapshot: reset, + now: reset.updatedAt) + #expect(recorder.events.count == 1) + #expect(recorder.events.first?.usedPercent == 0) + } + + @MainActor + @Test + func `weekly quota celebration fires once across repeated low samples`() async { + let store = Self.makeStore() + let accountLabel = "repeated-low@example.com" + let recorder = WeeklyLimitResetEventRecorder(provider: .claude, accountLabel: accountLabel) + defer { recorder.invalidate() } + + let before = UsageSnapshot( + primary: RateWindow(usedPercent: 10, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + secondary: RateWindow(usedPercent: 60, windowMinutes: 10080, resetsAt: nil, resetDescription: nil), + updatedAt: Date(timeIntervalSince1970: 1_700_000_000), + identity: ProviderIdentitySnapshot( + providerID: .claude, + accountEmail: accountLabel, + accountOrganization: nil, + loginMethod: "max")) + let firstLow = UsageSnapshot( + primary: RateWindow(usedPercent: 10, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + secondary: RateWindow(usedPercent: 1, windowMinutes: 10080, resetsAt: nil, resetDescription: nil), + updatedAt: Date(timeIntervalSince1970: 1_700_001_800), + identity: ProviderIdentitySnapshot( + providerID: .claude, + accountEmail: accountLabel, + accountOrganization: nil, + loginMethod: "max")) + let secondLow = UsageSnapshot( + primary: RateWindow(usedPercent: 10, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + secondary: RateWindow(usedPercent: 0, windowMinutes: 10080, resetsAt: nil, resetDescription: nil), + updatedAt: Date(timeIntervalSince1970: 1_700_002_100), + identity: ProviderIdentitySnapshot( + providerID: .claude, + accountEmail: accountLabel, + accountOrganization: nil, + loginMethod: "max")) + + await store.recordPlanUtilizationHistorySample(provider: .claude, snapshot: before, now: before.updatedAt) + await store.recordPlanUtilizationHistorySample(provider: .claude, snapshot: firstLow, now: firstLow.updatedAt) + await store.recordPlanUtilizationHistorySample(provider: .claude, snapshot: secondLow, now: secondLow.updatedAt) + + let events = recorder.events + #expect(events.count == 1) + #expect(events[0].usedPercent == 1) + } + + @MainActor + @Test + func `weekly quota celebration posts for generic provider weekly lane`() async { + let store = Self.makeStore() + let accountLabel = "zai-reset-org" + let recorder = WeeklyLimitResetEventRecorder(provider: .zai, accountLabel: accountLabel) + defer { recorder.invalidate() } + + let before = UsageSnapshot( + primary: RateWindow(usedPercent: 92, windowMinutes: 10080, resetsAt: nil, resetDescription: nil), + secondary: RateWindow(usedPercent: 15, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + updatedAt: Date(timeIntervalSince1970: 1_700_000_000), + identity: ProviderIdentitySnapshot( + providerID: .zai, + accountEmail: nil, + accountOrganization: accountLabel, + loginMethod: "pro")) + let after = UsageSnapshot( + primary: RateWindow(usedPercent: 0, windowMinutes: 10080, resetsAt: nil, resetDescription: nil), + secondary: RateWindow(usedPercent: 15, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + updatedAt: Date(timeIntervalSince1970: 1_700_003_600), + identity: ProviderIdentitySnapshot( + providerID: .zai, + accountEmail: nil, + accountOrganization: accountLabel, + loginMethod: "pro")) + + await store.recordPlanUtilizationHistorySample(provider: .zai, snapshot: before, now: before.updatedAt) + await store.recordPlanUtilizationHistorySample(provider: .zai, snapshot: after, now: after.updatedAt) + + let events = recorder.events + #expect(events.count == 1) + #expect(events[0].provider == .zai) + #expect(events[0].accountLabel == accountLabel) + #expect(events[0].usedPercent == 0) + } + + @MainActor + @Test + func `session quota celebration uses copilot secondary fallback without history sample`() async { + let store = Self.makeStore() + let accountLabel = "copilot-session-reset@example.com" + let recorder = SessionLimitResetEventRecorder(provider: .copilot, accountLabel: accountLabel) + defer { recorder.invalidate() } + + let before = UsageSnapshot( + primary: nil, + secondary: RateWindow(usedPercent: 88, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + updatedAt: Date(timeIntervalSince1970: 1_700_000_000), + identity: ProviderIdentitySnapshot( + providerID: .copilot, + accountEmail: accountLabel, + accountOrganization: nil, + loginMethod: "github")) + let after = UsageSnapshot( + primary: nil, + secondary: RateWindow(usedPercent: 0, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + updatedAt: Date(timeIntervalSince1970: 1_700_003_600), + identity: ProviderIdentitySnapshot( + providerID: .copilot, + accountEmail: accountLabel, + accountOrganization: nil, + loginMethod: "github")) + + await store.recordPlanUtilizationHistorySample(provider: .copilot, snapshot: before, now: before.updatedAt) + await store.recordPlanUtilizationHistorySample(provider: .copilot, snapshot: after, now: after.updatedAt) + + let events = recorder.events + #expect(events.count == 1) + #expect(events[0].provider == .copilot) + #expect(events[0].accountLabel == accountLabel) + #expect(events[0].usedPercent == 0) + #expect(store.planUtilizationHistory(for: .copilot).isEmpty) + } + + @MainActor + @Test + func `session quota celebration uses generic provider canonical primary without history sample`() async { + let store = Self.makeStore() + let accountLabel = "zai-session-reset-org" + let recorder = SessionLimitResetEventRecorder(provider: .zai, accountLabel: accountLabel) + defer { recorder.invalidate() } + + func snapshot(usedPercent: Double, updatedAt: Date) -> UsageSnapshot { + UsageSnapshot( + primary: RateWindow( + usedPercent: usedPercent, + windowMinutes: 300, + resetsAt: nil, + resetDescription: nil), + secondary: nil, + updatedAt: updatedAt, + identity: ProviderIdentitySnapshot( + providerID: .zai, + accountEmail: nil, + accountOrganization: accountLabel, + loginMethod: "pro")) + } + + let before = snapshot(usedPercent: 88, updatedAt: Date(timeIntervalSince1970: 1_700_000_000)) + let after = snapshot(usedPercent: 0, updatedAt: Date(timeIntervalSince1970: 1_700_003_600)) + + await store.recordPlanUtilizationHistorySample(provider: .zai, snapshot: before, now: before.updatedAt) + await store.recordPlanUtilizationHistorySample(provider: .zai, snapshot: after, now: after.updatedAt) + + #expect(recorder.events.count == 1) + #expect(recorder.events.first?.usedPercent == 0) + #expect(store.planUtilizationHistory(for: .zai).isEmpty) + } + + @MainActor + @Test + func `session quota celebration ignores unknown duration credit pool`() async { + let store = Self.makeStore() + let accountLabel = "elevenlabs-monthly-reset@example.com" + let recorder = SessionLimitResetEventRecorder(provider: .elevenlabs, accountLabel: accountLabel) + defer { recorder.invalidate() } + + func snapshot(usedPercent: Double, updatedAt: Date) -> UsageSnapshot { + UsageSnapshot( + primary: RateWindow( + usedPercent: usedPercent, + windowMinutes: nil, + resetsAt: nil, + resetDescription: "Monthly credits"), + secondary: nil, + updatedAt: updatedAt, + identity: ProviderIdentitySnapshot( + providerID: .elevenlabs, + accountEmail: accountLabel, + accountOrganization: nil, + loginMethod: "api-key")) + } + + let before = snapshot(usedPercent: 88, updatedAt: Date(timeIntervalSince1970: 1_700_000_000)) + let after = snapshot(usedPercent: 0, updatedAt: Date(timeIntervalSince1970: 1_700_003_600)) + + await store.recordPlanUtilizationHistorySample(provider: .elevenlabs, snapshot: before, now: before.updatedAt) + await store.recordPlanUtilizationHistorySample(provider: .elevenlabs, snapshot: after, now: after.updatedAt) + + #expect(recorder.events.isEmpty) + #expect(store.sessionLimitResetDetectorStates.isEmpty) + } + + @MainActor + @Test + func `session quota celebration uses zai semantic tertiary session lane`() async { + let store = Self.makeStore() + let accountLabel = "zai-semantic-session-org" + let recorder = SessionLimitResetEventRecorder(provider: .zai, accountLabel: accountLabel) + defer { recorder.invalidate() } + + func snapshot(sessionUsed: Double, updatedAt: Date) -> UsageSnapshot { + UsageSnapshot( + primary: RateWindow( + usedPercent: 30, + windowMinutes: 10080, + resetsAt: nil, + resetDescription: nil), + secondary: RateWindow( + usedPercent: 40, + windowMinutes: 43200, + resetsAt: nil, + resetDescription: "Monthly"), + tertiary: RateWindow( + usedPercent: sessionUsed, + windowMinutes: 300, + resetsAt: nil, + resetDescription: nil), + updatedAt: updatedAt, + identity: ProviderIdentitySnapshot( + providerID: .zai, + accountEmail: nil, + accountOrganization: accountLabel, + loginMethod: "pro")) + } + + let before = snapshot(sessionUsed: 88, updatedAt: Date(timeIntervalSince1970: 1_700_000_000)) + let after = snapshot(sessionUsed: 0, updatedAt: Date(timeIntervalSince1970: 1_700_003_600)) + + await store.recordPlanUtilizationHistorySample(provider: .zai, snapshot: before, now: before.updatedAt) + await store.recordPlanUtilizationHistorySample(provider: .zai, snapshot: after, now: after.updatedAt) + + #expect(recorder.events.count == 1) + #expect(recorder.events.first?.usedPercent == 0) + #expect(store.sessionLimitResetDetectorStates.values.first?.sourceRawValue == "zaiTertiary") + } + + @MainActor + @Test + func `session quota celebration keeps account baselines isolated`() async { + let store = Self.makeStore() + let accountLabel = "session-reset-b@example.com" + let recorder = SessionLimitResetEventRecorder(provider: .claude, accountLabel: accountLabel) + defer { recorder.invalidate() } + + func snapshot(account: String, usedPercent: Double, updatedAt: Date) -> UsageSnapshot { + UsageSnapshot( + primary: RateWindow( + usedPercent: usedPercent, + windowMinutes: 300, + resetsAt: nil, + resetDescription: nil), + secondary: RateWindow( + usedPercent: 20, + windowMinutes: 10080, + resetsAt: nil, + resetDescription: nil), + updatedAt: updatedAt, + identity: ProviderIdentitySnapshot( + providerID: .claude, + accountEmail: account, + accountOrganization: nil, + loginMethod: "max")) + } + + let firstDate = Date(timeIntervalSince1970: 1_700_000_000) + let accountAHigh = snapshot(account: "session-reset-a@example.com", usedPercent: 80, updatedAt: firstDate) + let accountBLow = snapshot(account: accountLabel, usedPercent: 0, updatedAt: firstDate.addingTimeInterval(3600)) + let accountBHigh = snapshot( + account: accountLabel, + usedPercent: 80, + updatedAt: firstDate.addingTimeInterval(7200)) + let accountBReset = snapshot( + account: accountLabel, + usedPercent: 0, + updatedAt: firstDate.addingTimeInterval(10800)) + + await store.recordPlanUtilizationHistorySample( + provider: .claude, + snapshot: accountAHigh, + now: accountAHigh.updatedAt) + await store.recordPlanUtilizationHistorySample( + provider: .claude, + snapshot: accountBLow, + now: accountBLow.updatedAt) + #expect(recorder.events.isEmpty) + + await store.recordPlanUtilizationHistorySample( + provider: .claude, + snapshot: accountBHigh, + now: accountBHigh.updatedAt) + await store.recordPlanUtilizationHistorySample( + provider: .claude, + snapshot: accountBReset, + now: accountBReset.updatedAt) + #expect(recorder.events.count == 1) + } + + @MainActor + @Test + func `session quota celebration ignores command code subscription enrichment failure`() async { + let store = Self.makeStore() + let recorder = SessionLimitResetEventRecorder(provider: .commandcode, accountLabel: nil) + defer { recorder.invalidate() } + + func snapshot(usedPercent: Double, enrichmentUnavailable: Bool, updatedAt: Date) -> UsageSnapshot { + UsageSnapshot( + primary: RateWindow( + usedPercent: usedPercent, + windowMinutes: 300, + resetsAt: nil, + resetDescription: nil), + secondary: nil, + commandCodeSubscriptionEnrichmentUnavailable: enrichmentUnavailable, + updatedAt: updatedAt) + } + + let firstDate = Date(timeIntervalSince1970: 1_700_000_000) + let before = snapshot(usedPercent: 80, enrichmentUnavailable: false, updatedAt: firstDate) + let failedEnrichment = snapshot( + usedPercent: 0, + enrichmentUnavailable: true, + updatedAt: firstDate.addingTimeInterval(3600)) + let validReset = snapshot( + usedPercent: 0, + enrichmentUnavailable: false, + updatedAt: firstDate.addingTimeInterval(7200)) + + await store.recordPlanUtilizationHistorySample(provider: .commandcode, snapshot: before, now: before.updatedAt) + await store.recordPlanUtilizationHistorySample( + provider: .commandcode, + snapshot: failedEnrichment, + now: failedEnrichment.updatedAt) + #expect(recorder.events.isEmpty) + + await store.recordPlanUtilizationHistorySample( + provider: .commandcode, + snapshot: validReset, + now: validReset.updatedAt) + #expect(recorder.events.count == 1) + } + + @MainActor + @Test + func `session quota celebration does not infer arbitrary secondary session lane`() async { + let store = Self.makeStore() + let recorder = SessionLimitResetEventRecorder(provider: .zai, accountLabel: nil) + defer { recorder.invalidate() } + + let before = UsageSnapshot( + primary: RateWindow(usedPercent: 30, windowMinutes: 10080, resetsAt: nil, resetDescription: nil), + secondary: RateWindow(usedPercent: 88, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + updatedAt: Date(timeIntervalSince1970: 1_700_000_000)) + let after = UsageSnapshot( + primary: RateWindow(usedPercent: 30, windowMinutes: 10080, resetsAt: nil, resetDescription: nil), + secondary: RateWindow(usedPercent: 0, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + updatedAt: Date(timeIntervalSince1970: 1_700_003_600)) + + await store.recordPlanUtilizationHistorySample(provider: .zai, snapshot: before, now: before.updatedAt) + await store.recordPlanUtilizationHistorySample(provider: .zai, snapshot: after, now: after.updatedAt) + + #expect(recorder.events.isEmpty) + } +} + +private func codexWeeklySnapshot( + accountLabel: String, + usedPercent: Double, + resetsAt: Date?, + updatedAt: Date) -> UsageSnapshot +{ + UsageSnapshot( + primary: RateWindow( + usedPercent: usedPercent, + windowMinutes: 10080, + resetsAt: resetsAt, + resetDescription: nil), + secondary: RateWindow( + usedPercent: 14, + windowMinutes: 300, + resetsAt: nil, + resetDescription: nil), + updatedAt: updatedAt, + identity: ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: accountLabel, + accountOrganization: nil, + loginMethod: "test")) +} + +final class SessionLimitResetEventRecorder: @unchecked Sendable { + struct Event { + let provider: UsageProvider + let accountLabel: String? + let usedPercent: Double + } + + private let provider: UsageProvider + private let accountLabel: String? + private let lock = NSLock() + private var observedEvents: [Event] = [] + private var token: NSObjectProtocol? + + init(provider: UsageProvider, accountLabel: String?) { + self.provider = provider + self.accountLabel = accountLabel + self.token = NotificationCenter.default.addObserver( + forName: .codexbarSessionLimitReset, + object: nil, + queue: nil) + { [weak self] notification in + guard let self, + let event = notification.object as? SessionLimitResetEvent + else { + return + } + + let recorded = MainActor.assumeIsolated { () -> Event? in + guard event.provider == self.provider, + event.accountLabel == self.accountLabel + else { + return nil + } + return Event( + provider: event.provider, + accountLabel: event.accountLabel, + usedPercent: event.usedPercent) + } + guard let recorded else { return } + + self.lock.lock() + self.observedEvents.append(recorded) + self.lock.unlock() + } + } + + var events: [Event] { + self.lock.lock() + defer { self.lock.unlock() } + return self.observedEvents + } + + func invalidate() { + guard let token else { return } + NotificationCenter.default.removeObserver(token) + self.token = nil + } + + deinit { + self.invalidate() + } +} + +final class WeeklyLimitResetEventRecorder: @unchecked Sendable { + struct Event { + let provider: UsageProvider + let accountLabel: String? + let usedPercent: Double + } + + private let provider: UsageProvider + private let accountLabel: String? + private let lock = NSLock() + private var observedEvents: [Event] = [] + private var token: NSObjectProtocol? + + init(provider: UsageProvider, accountLabel: String?) { + self.provider = provider + self.accountLabel = accountLabel + self.token = NotificationCenter.default.addObserver( + forName: .codexbarWeeklyLimitReset, + object: nil, + queue: nil) + { [weak self] notification in + guard let self, + let event = notification.object as? WeeklyLimitResetEvent + else { + return + } + + let recorded = MainActor.assumeIsolated { () -> Event? in + guard event.provider == self.provider, + event.accountLabel == self.accountLabel + else { + return nil + } + return Event( + provider: event.provider, + accountLabel: event.accountLabel, + usedPercent: event.usedPercent) + } + guard let recorded else { return } + + self.lock.lock() + self.observedEvents.append(recorded) + self.lock.unlock() + } + } + + var events: [Event] { + self.lock.lock() + defer { self.lock.unlock() } + return self.observedEvents + } + + var count: Int { + self.lock.lock() + defer { self.lock.unlock() } + return self.observedEvents.count + } + + func invalidate() { + guard let token else { return } + NotificationCenter.default.removeObserver(token) + self.token = nil + } + + deinit { + self.invalidate() + } +} diff --git a/Tests/CodexBarTests/UsageStorePlanUtilizationClaudeIdentityBoundaryTests.swift b/Tests/CodexBarTests/UsageStorePlanUtilizationClaudeIdentityBoundaryTests.swift new file mode 100644 index 0000000000..f321f45b33 --- /dev/null +++ b/Tests/CodexBarTests/UsageStorePlanUtilizationClaudeIdentityBoundaryTests.swift @@ -0,0 +1,305 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +struct UsageStorePlanUtilizationClaudeIdentityBoundaryTests { + @MainActor + @Test + func `claude history without identity falls back to last resolved account`() async { + let store = UsageStorePlanUtilizationTests.makeStore() + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 10, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + secondary: RateWindow(usedPercent: 20, windowMinutes: 10080, resetsAt: nil, resetDescription: nil), + updatedAt: Date(), + identity: ProviderIdentitySnapshot( + providerID: .claude, + accountEmail: "alice@example.com", + accountOrganization: nil, + loginMethod: "max")) + store._setSnapshotForTesting(snapshot, provider: .claude) + + await store.recordPlanUtilizationHistorySample( + provider: .claude, + snapshot: snapshot, + now: Date(timeIntervalSince1970: 1_700_000_000)) + + let identitylessSnapshot = UsageSnapshot( + primary: snapshot.primary, + secondary: snapshot.secondary, + updatedAt: snapshot.updatedAt) + store._setSnapshotForTesting(identitylessSnapshot, provider: .claude) + + let history = store.planUtilizationHistory(for: .claude) + #expect(findSeries(history, name: .session, windowMinutes: 300)?.entries.last?.usedPercent == 10) + #expect(findSeries(history, name: .weekly, windowMinutes: 10080)?.entries.last?.usedPercent == 20) + } + + @MainActor + @Test + func `established account accepts same owner after access token rotation`() async throws { + let store = UsageStorePlanUtilizationTests.makeStore() + let owner = String(repeating: "a", count: 64) + let accountIdentity = UsageStore._activeClaudeAccountIdentityForTesting("uuid-A") + let start = Date(timeIntervalSince1970: 1_700_000_000) + + await store.recordPlanUtilizationHistorySample( + provider: .claude, + snapshot: self.snapshot(usedPercent: 30), + claudeOAuthPersistentRefHash: "account-a-ref", + claudeOAuthHistoryOwnerIdentifier: owner, + claudeOAuthActiveAccountObservation: .stable(identity: accountIdentity), + isClaudeOAuthSample: true, + now: start) + await store.recordPlanUtilizationHistorySample( + provider: .claude, + snapshot: self.snapshot(usedPercent: 30), + claudeOAuthPersistentRefHash: "account-a-ref", + claudeOAuthHistoryOwnerIdentifier: owner, + claudeOAuthActiveAccountObservation: .stable(identity: accountIdentity), + isClaudeOAuthSample: true, + now: start.addingTimeInterval(30 * 60)) + await store.recordPlanUtilizationHistorySample( + provider: .claude, + snapshot: self.snapshot(usedPercent: 50), + claudeOAuthHistoryOwnerIdentifier: owner, + claudeOAuthKeychainCredentialMismatch: true, + claudeOAuthActiveAccountObservation: .stable(identity: accountIdentity), + isClaudeOAuthSample: true, + now: start.addingTimeInterval(2 * 60 * 60)) + + let key = try #require( + UsageStore._claudeOAuthPlanUtilizationAccountKeyForTesting(historyOwnerIdentifier: owner)) + let buckets = try #require(store.planUtilizationHistory[.claude]) + #expect(findSeries(buckets.accounts[key] ?? [], name: .session, windowMinutes: 300)? + .entries.map(\.usedPercent) == [30, 50]) + } + + @MainActor + @Test + func `first sighting without keychain match is quarantined`() async { + let store = UsageStorePlanUtilizationTests.makeStore() + let snapshot = UsageSnapshot( + primary: RateWindow( + usedPercent: 90, + windowMinutes: 300, + resetsAt: nil, + resetDescription: nil), + secondary: nil, + updatedAt: Date()) + + await store.recordPlanUtilizationHistorySample( + provider: .claude, + snapshot: snapshot, + claudeOAuthHistoryOwnerIdentifier: String(repeating: "s", count: 64), + claudeOAuthKeychainCredentialMismatch: true, + claudeOAuthActiveAccountObservation: .stable( + identity: UsageStore._activeClaudeAccountIdentityForTesting("uuid-current")), + isClaudeOAuthSample: true) + + #expect(UsageStore.loadClaudeOAuthAccountUuidMap(from: store.settings.userDefaults).isEmpty) + #expect(store.planUtilizationHistory[.claude] == nil) + } + + @MainActor + @Test + func `file backed owner records history when keychain comparison is unavailable`() async throws { + let store = UsageStorePlanUtilizationTests.makeStore() + let owner = String(repeating: "e", count: 64) + let key = try #require( + UsageStore._claudeOAuthPlanUtilizationAccountKeyForTesting(historyOwnerIdentifier: owner)) + + await store.recordPlanUtilizationHistorySample( + provider: .claude, + snapshot: self.snapshot(usedPercent: 90), + claudeOAuthHistoryOwnerIdentifier: owner, + claudeOAuthKeychainCredentialUnavailable: true, + claudeOAuthActiveAccountObservation: .stable( + identity: UsageStore._activeClaudeAccountIdentityForTesting("uuid-current")), + isClaudeOAuthSample: true) + + #expect(UsageStore.loadClaudeOAuthAccountUuidMap(from: store.settings.userDefaults).isEmpty) + #expect(UsageStore.loadClaudeOAuthAccountBindingCandidateMap( + from: store.settings.userDefaults).isEmpty) + let buckets = try #require(store.planUtilizationHistory[.claude]) + #expect(findSeries(buckets.accounts[key] ?? [], name: .session, windowMinutes: 300)? + .entries.map(\.usedPercent) == [90]) + } + + @MainActor + @Test + func `absent keychain still quarantines an owner bound to another account`() async { + let store = UsageStorePlanUtilizationTests.makeStore() + let owner = String(repeating: "f", count: 64) + store.persistClaudeOAuthAccountUuidMap([ + owner: UsageStore._activeClaudeAccountIdentityForTesting("uuid-A"), + ]) + + await store.recordPlanUtilizationHistorySample( + provider: .claude, + snapshot: self.snapshot(usedPercent: 90), + claudeOAuthHistoryOwnerIdentifier: owner, + claudeOAuthKeychainCredentialAbsent: true, + claudeOAuthActiveAccountObservation: .stable( + identity: UsageStore._activeClaudeAccountIdentityForTesting("uuid-B")), + isClaudeOAuthSample: true) + + #expect(store.planUtilizationHistory[.claude] == nil) + } + + @MainActor + @Test + func `absent keychain records an unbound file owner`() async throws { + let store = UsageStorePlanUtilizationTests.makeStore() + let owner = String(repeating: "b", count: 64) + let key = try #require( + UsageStore._claudeOAuthPlanUtilizationAccountKeyForTesting(historyOwnerIdentifier: owner)) + + await store.recordPlanUtilizationHistorySample( + provider: .claude, + snapshot: self.snapshot(usedPercent: 80), + claudeOAuthHistoryOwnerIdentifier: owner, + claudeOAuthKeychainCredentialAbsent: true, + claudeOAuthActiveAccountObservation: .stable( + identity: UsageStore._activeClaudeAccountIdentityForTesting("uuid-current")), + isClaudeOAuthSample: true) + + let buckets = try #require(store.planUtilizationHistory[.claude]) + #expect(findSeries(buckets.accounts[key] ?? [], name: .session, windowMinutes: 300)? + .entries.map(\.usedPercent) == [80]) + } + + @MainActor + @Test + func `account change during identity capture cannot bind or write history`() async { + let store = UsageStorePlanUtilizationTests.makeStore() + let snapshot = UsageSnapshot( + primary: RateWindow( + usedPercent: 90, + windowMinutes: 300, + resetsAt: nil, + resetDescription: nil), + secondary: nil, + updatedAt: Date()) + + await store.recordPlanUtilizationHistorySample( + provider: .claude, + snapshot: snapshot, + claudeOAuthPersistentRefHash: "account-a-ref", + claudeOAuthHistoryOwnerIdentifier: String(repeating: "r", count: 64), + claudeOAuthActiveAccountObservation: .changed, + isClaudeOAuthSample: true) + + #expect(UsageStore.loadClaudeOAuthAccountUuidMap(from: store.settings.userDefaults).isEmpty) + #expect(store.planUtilizationHistory[.claude] == nil) + } + + @MainActor + @Test + func `missing active account identity preserves owner scoped history`() async throws { + let store = UsageStorePlanUtilizationTests.makeStore() + let owner = String(repeating: "c", count: 64) + let key = try #require( + UsageStore._claudeOAuthPlanUtilizationAccountKeyForTesting(historyOwnerIdentifier: owner)) + let snapshot = UsageSnapshot( + primary: RateWindow( + usedPercent: 35, + windowMinutes: 300, + resetsAt: nil, + resetDescription: nil), + secondary: nil, + updatedAt: Date()) + + await UsageStore.withActiveClaudeAccountUuidForTesting(nil) { + await store.recordPlanUtilizationHistorySample( + provider: .claude, + snapshot: snapshot, + claudeOAuthHistoryOwnerIdentifier: owner, + claudeOAuthActiveAccountObservation: .stable(identity: nil), + isClaudeOAuthSample: true) + } + + let buckets = try #require(store.planUtilizationHistory[.claude]) + #expect(findSeries(buckets.accounts[key] ?? [], name: .session, windowMinutes: 300)? + .entries.map(\.usedPercent) == [35]) + } + + @MainActor + @Test + func `explicit oauth credential ignores Claude Code account identity`() async throws { + let store = UsageStorePlanUtilizationTests.makeStore() + let owner = String(repeating: "d", count: 64) + let key = try #require( + UsageStore._claudeOAuthPlanUtilizationAccountKeyForTesting(historyOwnerIdentifier: owner)) + let snapshot = UsageSnapshot( + primary: RateWindow( + usedPercent: 45, + windowMinutes: 300, + resetsAt: nil, + resetDescription: nil), + secondary: nil, + updatedAt: Date()) + + await UsageStore.withActiveClaudeAccountUuidForTesting("claude-code-account") { + await store.recordPlanUtilizationHistorySample( + provider: .claude, + snapshot: snapshot, + claudeOAuthHistoryOwnerIdentifier: owner, + claudeOAuthActiveAccountObservation: .changed, + isClaudeOAuthSample: true) + } + + let buckets = try #require(store.planUtilizationHistory[.claude]) + #expect(findSeries(buckets.accounts[key] ?? [], name: .session, windowMinutes: 300)? + .entries.map(\.usedPercent) == [45]) + #expect(UsageStore.loadClaudeOAuthAccountUuidMap(from: store.settings.userDefaults).isEmpty) + } + + @Test + func `claude oauth history scope requires full auth fingerprint stability`() { + let stablePersistentRefHash = UsageStore._stableClaudeKeychainPersistentRefHashForTesting( + beforeFetchFingerprintToken: "stable-fingerprint", + afterFetchFingerprintToken: "stable-fingerprint", + beforeFetchPersistentRefHash: "stable-ref", + afterFetchPersistentRefHash: "stable-ref") + let changedFingerprintPersistentRefHash = UsageStore._stableClaudeKeychainPersistentRefHashForTesting( + beforeFetchFingerprintToken: "before-fingerprint", + afterFetchFingerprintToken: "after-fingerprint", + beforeFetchPersistentRefHash: "stable-ref", + afterFetchPersistentRefHash: "stable-ref") + + #expect(stablePersistentRefHash == "stable-ref") + #expect(changedFingerprintPersistentRefHash == nil) + } + + @Test + func `credential change around account read invalidates the observation`() { + let identityA = UsageStore._activeClaudeAccountIdentityForTesting("uuid-A") + let identityB = UsageStore._activeClaudeAccountIdentityForTesting("uuid-B") + let stable = UsageStore._claudeOAuthActiveAccountObservationForTesting( + identityBeforeFetch: identityB, + identityAfterFetch: identityB) + let changed = UsageStore._claudeOAuthActiveAccountObservationForTesting( + identityBeforeFetch: identityA, + identityAfterFetch: identityB) + let unstable = UsageStore._claudeOAuthActiveAccountObservationForTesting( + identityBeforeFetch: identityB, + identityAfterFetch: identityB, + beforeFetchWasStable: false) + + #expect(stable == .stable(identity: identityB)) + #expect(changed == .changed) + #expect(unstable == .changed) + } + + private func snapshot(usedPercent: Double) -> UsageSnapshot { + UsageSnapshot( + primary: RateWindow( + usedPercent: usedPercent, + windowMinutes: 300, + resetsAt: nil, + resetDescription: nil), + secondary: nil, + updatedAt: Date()) + } +} diff --git a/Tests/CodexBarTests/UsageStorePlanUtilizationClaudeIdentityTests.swift b/Tests/CodexBarTests/UsageStorePlanUtilizationClaudeIdentityTests.swift index 2ccc89a236..4c42e29ae4 100644 --- a/Tests/CodexBarTests/UsageStorePlanUtilizationClaudeIdentityTests.swift +++ b/Tests/CodexBarTests/UsageStorePlanUtilizationClaudeIdentityTests.swift @@ -101,9 +101,10 @@ struct UsageStorePlanUtilizationClaudeIdentityTests { @MainActor @Test - func `claude history without identity falls back to last resolved account`() async { + func `claude oauth credential owner separates switched account history`() async throws { let store = UsageStorePlanUtilizationTests.makeStore() - let snapshot = UsageSnapshot( + let accountBOwner = self.oauthOwnerIdentifier("b") + let accountASnapshot = UsageSnapshot( primary: RateWindow(usedPercent: 10, windowMinutes: 300, resetsAt: nil, resetDescription: nil), secondary: RateWindow(usedPercent: 20, windowMinutes: 10080, resetsAt: nil, resetDescription: nil), updatedAt: Date(), @@ -112,22 +113,636 @@ struct UsageStorePlanUtilizationClaudeIdentityTests { accountEmail: "alice@example.com", accountOrganization: nil, loginMethod: "max")) + let accountAKey = try #require( + UsageStore._planUtilizationAccountKeyForTesting(provider: .claude, snapshot: accountASnapshot)) + await store.recordPlanUtilizationHistorySample( + provider: .claude, + snapshot: accountASnapshot, + now: Date(timeIntervalSince1970: 1_700_000_000)) + + let accountBSnapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 70, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + secondary: RateWindow(usedPercent: 80, windowMinutes: 10080, resetsAt: nil, resetDescription: nil), + updatedAt: Date()) + let accountBKey = try #require( + UsageStore._claudeOAuthPlanUtilizationAccountKeyForTesting( + historyOwnerIdentifier: accountBOwner)) + await store.recordPlanUtilizationHistorySample( + provider: .claude, + snapshot: accountBSnapshot, + claudeOAuthPersistentRefHash: "account-b-ref", + claudeOAuthHistoryOwnerIdentifier: accountBOwner, + isClaudeOAuthSample: true, + now: Date(timeIntervalSince1970: 1_700_007_200)) + store._setSnapshotForTesting(accountBSnapshot, provider: .claude) + + let selectedHistory = store.planUtilizationHistory(for: .claude) + let buckets = try #require(store.planUtilizationHistory[.claude]) + let accountAHistory = try #require(buckets.accounts[accountAKey]) + let accountBHistory = try #require(buckets.accounts[accountBKey]) + + #expect(buckets.preferredAccountKey == accountBKey) + #expect(buckets.unscoped.isEmpty) + #expect(findSeries(accountAHistory, name: .session, windowMinutes: 300)?.entries.last?.usedPercent == 10) + #expect(findSeries(accountAHistory, name: .weekly, windowMinutes: 10080)?.entries.last?.usedPercent == 20) + #expect(findSeries(accountBHistory, name: .session, windowMinutes: 300)?.entries.last?.usedPercent == 70) + #expect(findSeries(accountBHistory, name: .weekly, windowMinutes: 10080)?.entries.last?.usedPercent == 80) + #expect(findSeries(selectedHistory, name: .session, windowMinutes: 300)?.entries.last?.usedPercent == 70) + } + + @MainActor + @Test + func `claude oauth credential owner wins over configured token account`() async throws { + let store = UsageStorePlanUtilizationTests.makeStore() + let oauthOwner = self.oauthOwnerIdentifier("a") + store.settings.addTokenAccount(provider: .claude, label: "Unrelated", token: "unrelated-token") + store.settings.setActiveTokenAccountIndex(0, for: .claude) + let selectedAccount = try #require(store.settings.selectedTokenAccount(for: .claude)) + let tokenAccountKey = try #require( + UsageStore._planUtilizationTokenAccountKeyForTesting(provider: .claude, account: selectedAccount)) + let oauthAccountKey = try #require( + UsageStore._claudeOAuthPlanUtilizationAccountKeyForTesting( + historyOwnerIdentifier: oauthOwner)) + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 45, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + secondary: nil, + updatedAt: Date()) + + await store.recordPlanUtilizationHistorySample( + provider: .claude, + snapshot: snapshot, + claudeOAuthPersistentRefHash: "oauth-ref", + claudeOAuthHistoryOwnerIdentifier: oauthOwner, + isClaudeOAuthSample: true, + now: Date(timeIntervalSince1970: 1_700_000_000)) store._setSnapshotForTesting(snapshot, provider: .claude) + let selection = store.planUtilizationHistorySelection(for: .claude) + let buckets = try #require(store.planUtilizationHistory[.claude]) + #expect(buckets.preferredAccountKey == oauthAccountKey) + #expect(buckets.accounts[tokenAccountKey] == nil) + #expect(findSeries(buckets.accounts[oauthAccountKey] ?? [], name: .session, windowMinutes: 300)? + .entries.map(\.usedPercent) == [45]) + #expect(selection.accountKey == oauthAccountKey) + #expect(findSeries(selection.histories, name: .session, windowMinutes: 300)? + .entries.map(\.usedPercent) == [45]) + } + + @MainActor + @Test + func `claude oauth without credential ownership is not persisted`() async throws { + let store = UsageStorePlanUtilizationTests.makeStore() + store.settings.addTokenAccount(provider: .claude, label: "Unrelated", token: "unrelated-token") + store.settings.setActiveTokenAccountIndex(0, for: .claude) + let selectedAccount = try #require(store.settings.selectedTokenAccount(for: .claude)) + let tokenAccountKey = try #require( + UsageStore._planUtilizationTokenAccountKeyForTesting(provider: .claude, account: selectedAccount)) + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 55, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + secondary: nil, + updatedAt: Date()) + await store.recordPlanUtilizationHistorySample( provider: .claude, snapshot: snapshot, + claudeOAuthPersistentRefHash: "row-only-ref", + isClaudeOAuthSample: true, now: Date(timeIntervalSince1970: 1_700_000_000)) + store._setSnapshotForTesting(snapshot, provider: .claude) - let identitylessSnapshot = UsageSnapshot( - primary: snapshot.primary, - secondary: snapshot.secondary, - updatedAt: snapshot.updatedAt) - store._setSnapshotForTesting(identitylessSnapshot, provider: .claude) + let selection = store.planUtilizationHistorySelection(for: .claude) + let buckets = try #require(store.planUtilizationHistory[.claude]) + #expect(buckets.unscoped.isEmpty) + #expect(buckets.accounts.isEmpty) + #expect(selection.accountKey == tokenAccountKey) + #expect(selection.histories.isEmpty) + } - let history = store.planUtilizationHistory(for: .claude) - #expect(findSeries(history, name: .session, windowMinutes: 300)?.entries.last?.usedPercent == 10) - #expect(findSeries(history, name: .weekly, windowMinutes: 10080)?.entries.last?.usedPercent == 20) + @MainActor + @Test + func `coalesced claude oauth sample still switches preferred account`() async throws { + let store = UsageStorePlanUtilizationTests.makeStore() + let accountAOwner = self.oauthOwnerIdentifier("a") + let accountBOwner = self.oauthOwnerIdentifier("b") + let accountAKey = try #require( + UsageStore._claudeOAuthPlanUtilizationAccountKeyForTesting( + historyOwnerIdentifier: accountAOwner)) + let accountBKey = try #require( + UsageStore._claudeOAuthPlanUtilizationAccountKeyForTesting( + historyOwnerIdentifier: accountBOwner)) + let hourStart = Date(timeIntervalSince1970: 1_700_000_000) + + await store.recordPlanUtilizationHistorySample( + provider: .claude, + snapshot: self.identitylessClaudeSnapshot(usedPercent: 70), + claudeOAuthPersistentRefHash: "account-a-ref", + claudeOAuthHistoryOwnerIdentifier: accountAOwner, + isClaudeOAuthSample: true, + now: hourStart) + await store.recordPlanUtilizationHistorySample( + provider: .claude, + snapshot: self.identitylessClaudeSnapshot(usedPercent: 40), + claudeOAuthPersistentRefHash: "account-b-ref", + claudeOAuthHistoryOwnerIdentifier: accountBOwner, + isClaudeOAuthSample: true, + now: hourStart.addingTimeInterval(5 * 60)) + await store.recordPlanUtilizationHistorySample( + provider: .claude, + snapshot: self.identitylessClaudeSnapshot(usedPercent: 60), + claudeOAuthPersistentRefHash: "account-a-ref", + claudeOAuthHistoryOwnerIdentifier: accountAOwner, + isClaudeOAuthSample: true, + now: hourStart.addingTimeInterval(10 * 60)) + + let buckets = try #require(store.planUtilizationHistory[.claude]) + let selection = store.planUtilizationHistorySelection(for: .claude) + #expect(buckets.preferredAccountKey == accountAKey) + #expect(findSeries(buckets.accounts[accountAKey] ?? [], name: .session, windowMinutes: 300)? + .entries.map(\.usedPercent) == [70]) + #expect(findSeries(buckets.accounts[accountBKey] ?? [], name: .session, windowMinutes: 300)? + .entries.map(\.usedPercent) == [40]) + #expect(selection.accountKey == accountAKey) + #expect(findSeries(selection.histories, name: .session, windowMinutes: 300)? + .entries.map(\.usedPercent) == [70]) + } + + @MainActor + @Test + func `same dir account switch quarantines stale background credential`() async throws { + let store = UsageStorePlanUtilizationTests.makeStore() + let ownerA = self.oauthOwnerIdentifier("a") + let ownerB = self.oauthOwnerIdentifier("b") + let keyA = try #require( + UsageStore._claudeOAuthPlanUtilizationAccountKeyForTesting(historyOwnerIdentifier: ownerA)) + let keyB = try #require( + UsageStore._claudeOAuthPlanUtilizationAccountKeyForTesting(historyOwnerIdentifier: ownerB)) + let hourStart = Date(timeIntervalSince1970: 1_700_000_000) + + // 1) Active account A (~/.claude.json = uuid-A). Two stable exact-Keychain observations bind owner_A + // to A; the short confirmation interval does not add a second history point. + await UsageStore.withActiveClaudeAccountUuidForTesting("uuid-A") { + await ProviderInteractionContext.$current.withValue(.userInitiated) { + await store.recordPlanUtilizationHistorySample( + provider: .claude, + snapshot: self.identitylessClaudeSnapshot(usedPercent: 30), + claudeOAuthPersistentRefHash: "account-a-ref", + claudeOAuthHistoryOwnerIdentifier: ownerA, + claudeOAuthActiveAccountObservation: .stable( + identity: UsageStore._activeClaudeAccountIdentityForTesting("uuid-A")), + isClaudeOAuthSample: true, + now: hourStart) + await store.recordPlanUtilizationHistorySample( + provider: .claude, + snapshot: self.identitylessClaudeSnapshot(usedPercent: 30), + claudeOAuthPersistentRefHash: "account-a-ref", + claudeOAuthHistoryOwnerIdentifier: ownerA, + claudeOAuthActiveAccountObservation: .stable( + identity: UsageStore._activeClaudeAccountIdentityForTesting("uuid-A")), + isClaudeOAuthSample: true, + now: hourStart.addingTimeInterval(30 * 60)) + } + } + + do { + let buckets = try #require(store.planUtilizationHistory[.claude]) + #expect(findSeries(buckets.accounts[keyA] ?? [], name: .session, windowMinutes: 300)? + .entries.map(\.usedPercent) == [30]) + #expect(buckets.unscoped.isEmpty) + } + + // 2) `/login` switched the active account to B (~/.claude.json = uuid-B), but the gated BACKGROUND + // poll still serves the STALE owner_A credential. Prompt-free Keychain comparison is unavailable, + // but the existing owner_A -> uuid-A binding detects the mismatch, so this must be quarantined: + // no new sample lands. + await UsageStore.withActiveClaudeAccountUuidForTesting("uuid-B") { + await ProviderInteractionContext.$current.withValue(.background) { + await store.recordPlanUtilizationHistorySample( + provider: .claude, + snapshot: self.identitylessClaudeSnapshot(usedPercent: 90), + claudeOAuthPersistentRefHash: nil, + claudeOAuthHistoryOwnerIdentifier: ownerA, + claudeOAuthKeychainCredentialUnavailable: true, + claudeOAuthActiveAccountObservation: .stable( + identity: UsageStore._activeClaudeAccountIdentityForTesting("uuid-B")), + isClaudeOAuthSample: true, + now: hourStart.addingTimeInterval(2 * 60 * 60)) + } + } + + do { + let buckets = try #require(store.planUtilizationHistory[.claude]) + // key_A history is UNCHANGED (the stale sample was dropped, not appended). + #expect(findSeries(buckets.accounts[keyA] ?? [], name: .session, windowMinutes: 300)? + .entries.map(\.usedPercent) == [30]) + // Critically, the quarantined sample did NOT leak into the shared unscoped bucket. This is the + // assertion that catches the naive-nil bug (nil accountKey writes to `unscoped`, not nowhere). + #expect(buckets.unscoped.isEmpty) + #expect(buckets.accounts[keyB] == nil) + } + + // 3) A USER-INITIATED refresh yields account B's real credential (owner_B), with exact current-Keychain + // match evidence. Recovery: bind owner_B -> uuid-B, write to key_B, and leave key_A untouched. + await UsageStore.withActiveClaudeAccountUuidForTesting("uuid-B") { + await ProviderInteractionContext.$current.withValue(.userInitiated) { + await store.recordPlanUtilizationHistorySample( + provider: .claude, + snapshot: self.identitylessClaudeSnapshot(usedPercent: 75), + claudeOAuthPersistentRefHash: "account-b-ref", + claudeOAuthHistoryOwnerIdentifier: ownerB, + claudeOAuthActiveAccountObservation: .stable( + identity: UsageStore._activeClaudeAccountIdentityForTesting("uuid-B")), + isClaudeOAuthSample: true, + now: hourStart.addingTimeInterval(3 * 60 * 60)) + } + } + + let buckets = try #require(store.planUtilizationHistory[.claude]) + #expect(findSeries(buckets.accounts[keyB] ?? [], name: .session, windowMinutes: 300)? + .entries.map(\.usedPercent) == [75]) + #expect(findSeries(buckets.accounts[keyA] ?? [], name: .session, windowMinutes: 300)? + .entries.map(\.usedPercent) == [30]) + #expect(buckets.unscoped.isEmpty) + } + + @MainActor + @Test + func `exact keychain match arms account map on background poll`() async throws { + let store = UsageStorePlanUtilizationTests.makeStore() + let ownerA = self.oauthOwnerIdentifier("a") + let ownerB = self.oauthOwnerIdentifier("b") + let keyA = try #require( + UsageStore._claudeOAuthPlanUtilizationAccountKeyForTesting(historyOwnerIdentifier: ownerA)) + let keyB = try #require( + UsageStore._claudeOAuthPlanUtilizationAccountKeyForTesting(historyOwnerIdentifier: ownerB)) + let hourStart = Date(timeIntervalSince1970: 1_700_000_000) + + // 1) First run after upgrading: the map is empty. A background poll serves owner_A while + // ~/.claude.json reports uuid-A, and exact current-Keychain evidence corroborates the credential. + // The first observation stages a candidate; a later identical observation confirms the binding. + await UsageStore.withActiveClaudeAccountUuidForTesting("uuid-A") { + await ProviderInteractionContext.$current.withValue(.background) { + await store.recordPlanUtilizationHistorySample( + provider: .claude, + snapshot: self.identitylessClaudeSnapshot(usedPercent: 30), + claudeOAuthPersistentRefHash: "account-a-ref", + claudeOAuthHistoryOwnerIdentifier: ownerA, + claudeOAuthActiveAccountObservation: .stable( + identity: UsageStore._activeClaudeAccountIdentityForTesting("uuid-A")), + isClaudeOAuthSample: true, + now: hourStart) + } + } + + let accountAIdentity = UsageStore._activeClaudeAccountIdentityForTesting("uuid-A") + let accountBIdentity = UsageStore._activeClaudeAccountIdentityForTesting("uuid-B") + #expect(UsageStore.loadClaudeOAuthAccountUuidMap(from: store.settings.userDefaults)[ownerA] == nil) + #expect(UsageStore.loadClaudeOAuthAccountBindingCandidateMap( + from: store.settings.userDefaults)[ownerA]?.identity == accountAIdentity) + + await UsageStore.withActiveClaudeAccountUuidForTesting("uuid-A") { + await ProviderInteractionContext.$current.withValue(.background) { + await store.recordPlanUtilizationHistorySample( + provider: .claude, + snapshot: self.identitylessClaudeSnapshot(usedPercent: 30), + claudeOAuthPersistentRefHash: "account-a-ref", + claudeOAuthHistoryOwnerIdentifier: ownerA, + claudeOAuthActiveAccountObservation: .stable(identity: accountAIdentity), + isClaudeOAuthSample: true, + now: hourStart.addingTimeInterval(30 * 60)) + } + } + #expect(UsageStore.loadClaudeOAuthAccountUuidMap( + from: store.settings.userDefaults)[ownerA] == accountAIdentity) + + // 2) `/login` switched to account B (~/.claude.json = uuid-B), but the gated BACKGROUND poll still + // serves the stale owner_A credential. It no longer matches the current Keychain item, and the + // existing owner_A -> uuid-A binding detects the UUID mismatch, so the sample is quarantined. + await UsageStore.withActiveClaudeAccountUuidForTesting("uuid-B") { + await ProviderInteractionContext.$current.withValue(.background) { + await store.recordPlanUtilizationHistorySample( + provider: .claude, + snapshot: self.identitylessClaudeSnapshot(usedPercent: 90), + claudeOAuthPersistentRefHash: nil, + claudeOAuthHistoryOwnerIdentifier: ownerA, + claudeOAuthKeychainCredentialMismatch: true, + claudeOAuthActiveAccountObservation: .stable( + identity: UsageStore._activeClaudeAccountIdentityForTesting("uuid-B")), + isClaudeOAuthSample: true, + now: hourStart.addingTimeInterval(2 * 60 * 60)) + } + } + + do { + let mapAfterBackground = UsageStore.loadClaudeOAuthAccountUuidMap(from: store.settings.userDefaults) + #expect(mapAfterBackground[ownerA] == accountAIdentity) + let buckets = try #require(store.planUtilizationHistory[.claude]) + #expect(findSeries(buckets.accounts[keyA] ?? [], name: .session, windowMinutes: 300)? + .entries.map(\.usedPercent) == [30]) + #expect(buckets.unscoped.isEmpty) + } + + // 3) A background poll now yields account B's real credential (owner_B) with exact current-Keychain + // match evidence. That evidence, not the interaction label, binds owner_B -> uuid-B and writes key_B. + await UsageStore.withActiveClaudeAccountUuidForTesting("uuid-B") { + await ProviderInteractionContext.$current.withValue(.background) { + await store.recordPlanUtilizationHistorySample( + provider: .claude, + snapshot: self.identitylessClaudeSnapshot(usedPercent: 75), + claudeOAuthPersistentRefHash: "account-b-ref", + claudeOAuthHistoryOwnerIdentifier: ownerB, + claudeOAuthActiveAccountObservation: .stable( + identity: UsageStore._activeClaudeAccountIdentityForTesting("uuid-B")), + isClaudeOAuthSample: true, + now: hourStart.addingTimeInterval(3 * 60 * 60)) + } + } + + #expect(UsageStore.loadClaudeOAuthAccountUuidMap(from: store.settings.userDefaults)[ownerB] == nil) + await UsageStore.withActiveClaudeAccountUuidForTesting("uuid-B") { + await ProviderInteractionContext.$current.withValue(.background) { + await store.recordPlanUtilizationHistorySample( + provider: .claude, + snapshot: self.identitylessClaudeSnapshot(usedPercent: 75), + claudeOAuthPersistentRefHash: "account-b-ref", + claudeOAuthHistoryOwnerIdentifier: ownerB, + claudeOAuthActiveAccountObservation: .stable(identity: accountBIdentity), + isClaudeOAuthSample: true, + now: hourStart.addingTimeInterval(3 * 60 * 60 + 30 * 60)) + } + } + + let mapAfterRecovery = UsageStore.loadClaudeOAuthAccountUuidMap(from: store.settings.userDefaults) + #expect(mapAfterRecovery[ownerB] == accountBIdentity) + #expect(mapAfterRecovery[ownerA] == accountAIdentity) + let buckets = try #require(store.planUtilizationHistory[.claude]) + #expect(findSeries(buckets.accounts[keyB] ?? [], name: .session, windowMinutes: 300)? + .entries.map(\.usedPercent) == [75]) + #expect(buckets.unscoped.isEmpty) + } + + @MainActor + @Test + func `coalesced claude oauth sample without owner cannot switch preferred account`() async throws { + let store = UsageStorePlanUtilizationTests.makeStore() + let scopedOwner = self.oauthOwnerIdentifier("c") + let scopedAccountKey = try #require( + UsageStore._claudeOAuthPlanUtilizationAccountKeyForTesting( + historyOwnerIdentifier: scopedOwner)) + let hourStart = Date(timeIntervalSince1970: 1_700_000_000) + + await store.recordPlanUtilizationHistorySample( + provider: .claude, + snapshot: self.identitylessClaudeSnapshot(usedPercent: 70), + isClaudeOAuthSample: true, + now: hourStart) + await store.recordPlanUtilizationHistorySample( + provider: .claude, + snapshot: self.identitylessClaudeSnapshot(usedPercent: 40), + claudeOAuthPersistentRefHash: "scoped-ref", + claudeOAuthHistoryOwnerIdentifier: scopedOwner, + isClaudeOAuthSample: true, + now: hourStart.addingTimeInterval(5 * 60)) + await store.recordPlanUtilizationHistorySample( + provider: .claude, + snapshot: self.identitylessClaudeSnapshot(usedPercent: 60), + isClaudeOAuthSample: true, + now: hourStart.addingTimeInterval(10 * 60)) + + let buckets = try #require(store.planUtilizationHistory[.claude]) + let selection = store.planUtilizationHistorySelection(for: .claude) + #expect(buckets.preferredAccountKey == scopedAccountKey) + #expect(buckets.unscoped.isEmpty) + #expect(findSeries(buckets.accounts[scopedAccountKey] ?? [], name: .session, windowMinutes: 300)? + .entries.map(\.usedPercent) == [40]) + #expect(selection.accountKey == scopedAccountKey) + #expect(findSeries(selection.histories, name: .session, windowMinutes: 300)? + .entries.map(\.usedPercent) == [40]) + } + + @MainActor + @Test + func `reloaded scoped claude oauth preference wins over configured token account`() throws { + let oauthOwner = self.oauthOwnerIdentifier("a") + let oauthAccountKey = try #require( + UsageStore._claudeOAuthPlanUtilizationAccountKeyForTesting( + historyOwnerIdentifier: oauthOwner)) + let oauthHistory = planSeries(name: .session, windowMinutes: 300, entries: [ + planEntry(at: Date(timeIntervalSince1970: 1_700_000_000), usedPercent: 45), + ]) + let store = self.makeReloadedStoreWithConfiguredTokenAccount( + buckets: PlanUtilizationHistoryBuckets( + preferredAccountKey: oauthAccountKey, + accounts: [oauthAccountKey: [oauthHistory]])) + + #expect(store.lastSourceLabels[.claude] == nil) + #expect(store.settings.selectedTokenAccount(for: .claude) != nil) + + let selection = store.planUtilizationHistorySelection(for: .claude) + + #expect(selection.accountKey == oauthAccountKey) + #expect(selection.histories == [oauthHistory]) + #expect(store.planUtilizationHistory[.claude]?.preferredAccountKey == oauthAccountKey) + } + + @MainActor + @Test + func `reloaded unscoped claude oauth preference wins over configured token account`() { + let oauthHistory = planSeries(name: .session, windowMinutes: 300, entries: [ + planEntry(at: Date(timeIntervalSince1970: 1_700_000_000), usedPercent: 55), + ]) + let store = self.makeReloadedStoreWithConfiguredTokenAccount( + buckets: PlanUtilizationHistoryBuckets( + preferredAccountKey: "__unscoped__", + unscoped: [oauthHistory])) + + #expect(store.lastSourceLabels[.claude] == nil) + #expect(store.settings.selectedTokenAccount(for: .claude) != nil) + + let selection = store.planUtilizationHistorySelection(for: .claude) + + #expect(selection.accountKey == nil) + #expect(selection.histories == [oauthHistory]) + #expect(store.planUtilizationHistory[.claude]?.preferredAccountKey == "__unscoped__") + } + + @MainActor + @Test + func `later token account sample supersedes claude oauth preference`() async throws { + let store = UsageStorePlanUtilizationTests.makeStore() + let oauthOwner = self.oauthOwnerIdentifier("a") + store.settings.addTokenAccount(provider: .claude, label: "Selected", token: "selected-token") + store.settings.setActiveTokenAccountIndex(0, for: .claude) + let selectedAccount = try #require(store.settings.selectedTokenAccount(for: .claude)) + let tokenAccountKey = try #require( + UsageStore._planUtilizationTokenAccountKeyForTesting(provider: .claude, account: selectedAccount)) + let oauthAccountKey = try #require( + UsageStore._claudeOAuthPlanUtilizationAccountKeyForTesting( + historyOwnerIdentifier: oauthOwner)) + let oauthSnapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 45, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + secondary: nil, + updatedAt: Date()) + await store.recordPlanUtilizationHistorySample( + provider: .claude, + snapshot: oauthSnapshot, + claudeOAuthPersistentRefHash: "oauth-ref", + claudeOAuthHistoryOwnerIdentifier: oauthOwner, + isClaudeOAuthSample: true, + now: Date(timeIntervalSince1970: 1_700_000_000)) + + let tokenSnapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 20, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + secondary: nil, + updatedAt: Date()) + await store.recordPlanUtilizationHistorySample( + provider: .claude, + snapshot: tokenSnapshot, + account: selectedAccount, + now: Date(timeIntervalSince1970: 1_700_007_200)) + + let buckets = try #require(store.planUtilizationHistory[.claude]) + let selection = store.planUtilizationHistorySelection(for: .claude) + #expect(buckets.preferredAccountKey == tokenAccountKey) + #expect(findSeries(buckets.accounts[oauthAccountKey] ?? [], name: .session, windowMinutes: 300)? + .entries.map(\.usedPercent) == [45]) + #expect(selection.accountKey == tokenAccountKey) + #expect(findSeries(selection.histories, name: .session, windowMinutes: 300)? + .entries.map(\.usedPercent) == [20]) + } + + @MainActor + @Test + func `first claude oauth owner quarantines legacy unscoped history`() async throws { + let store = UsageStorePlanUtilizationTests.makeStore() + let currentOwner = self.oauthOwnerIdentifier("c") + let legacy = planSeries(name: .session, windowMinutes: 300, entries: [ + planEntry(at: Date(timeIntervalSince1970: 1_700_000_000), usedPercent: 25), + ]) + store.planUtilizationHistory[.claude] = PlanUtilizationHistoryBuckets(unscoped: [legacy]) + + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 60, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + secondary: nil, + updatedAt: Date()) + let accountKey = try #require( + UsageStore._claudeOAuthPlanUtilizationAccountKeyForTesting( + historyOwnerIdentifier: currentOwner)) + await store.recordPlanUtilizationHistorySample( + provider: .claude, + snapshot: snapshot, + claudeOAuthPersistentRefHash: "current-ref", + claudeOAuthHistoryOwnerIdentifier: currentOwner, + isClaudeOAuthSample: true, + now: Date(timeIntervalSince1970: 1_700_007_200)) + + let buckets = try #require(store.planUtilizationHistory[.claude]) + let scoped = try #require(buckets.accounts[accountKey]) + #expect(buckets.unscoped == [legacy]) + #expect(findSeries(scoped, name: .session, windowMinutes: 300)?.entries.map(\.usedPercent) == [60]) + #expect(buckets.preferredAccountKey == accountKey) + } + + @MainActor + @Test + func `provenance-less claude oauth credentials stay isolated across restart`() async throws { + let store = UsageStorePlanUtilizationTests.makeStore() + let accountAOwner = self.oauthOwnerIdentifier("a") + let accountBOwner = self.oauthOwnerIdentifier("b") + let accountAKey = try #require(UsageStore._claudeOAuthPlanUtilizationAccountKeyForTesting( + historyOwnerIdentifier: accountAOwner)) + let accountBKey = try #require(UsageStore._claudeOAuthPlanUtilizationAccountKeyForTesting( + historyOwnerIdentifier: accountBOwner)) + + await UsageStore.withActiveClaudeAccountUuidForTesting(nil) { + await store.recordPlanUtilizationHistorySample( + provider: .claude, + snapshot: self.identitylessClaudeSnapshot(usedPercent: 10), + claudeOAuthHistoryOwnerIdentifier: accountAOwner, + isClaudeOAuthSample: true, + now: Date(timeIntervalSince1970: 1_700_000_000)) + await store.recordPlanUtilizationHistorySample( + provider: .claude, + snapshot: self.identitylessClaudeSnapshot(usedPercent: 75), + claudeOAuthHistoryOwnerIdentifier: accountBOwner, + isClaudeOAuthSample: true, + now: Date(timeIntervalSince1970: 1_700_007_200)) + } + + let buckets = try #require(store.planUtilizationHistory[.claude]) + #expect(buckets.unscoped.isEmpty) + #expect(findSeries(buckets.accounts[accountAKey] ?? [], name: .session, windowMinutes: 300)? + .entries.map(\.usedPercent) == [10]) + #expect(findSeries(buckets.accounts[accountBKey] ?? [], name: .session, windowMinutes: 300)? + .entries.map(\.usedPercent) == [75]) + + let reloaded = self.makeReloadedStoreWithConfiguredTokenAccount(buckets: buckets) + let selection = reloaded.planUtilizationHistorySelection(for: .claude) + #expect(selection.accountKey == accountBKey) + #expect(findSeries(selection.histories, name: .session, windowMinutes: 300)? + .entries.map(\.usedPercent) == [75]) + #expect(reloaded.planUtilizationHistory[.claude]?.accounts[accountAKey] != nil) + } + + @MainActor + @Test + func `same keychain reference credential replacement stays isolated across restart`() async throws { + let store = UsageStorePlanUtilizationTests.makeStore() + let originalOwner = self.oauthOwnerIdentifier("c") + let replacementOwner = self.oauthOwnerIdentifier("d") + let originalKey = try #require(UsageStore._claudeOAuthPlanUtilizationAccountKeyForTesting( + historyOwnerIdentifier: originalOwner, + persistentRefHash: "same-row-ref")) + let replacementKey = try #require(UsageStore._claudeOAuthPlanUtilizationAccountKeyForTesting( + historyOwnerIdentifier: replacementOwner, + persistentRefHash: "same-row-ref")) + + await store.recordPlanUtilizationHistorySample( + provider: .claude, + snapshot: self.identitylessClaudeSnapshot(usedPercent: 25), + claudeOAuthPersistentRefHash: "same-row-ref", + claudeOAuthHistoryOwnerIdentifier: originalOwner, + isClaudeOAuthSample: true, + now: Date(timeIntervalSince1970: 1_700_000_000)) + await store.recordPlanUtilizationHistorySample( + provider: .claude, + snapshot: self.identitylessClaudeSnapshot(usedPercent: 80), + claudeOAuthPersistentRefHash: "same-row-ref", + claudeOAuthHistoryOwnerIdentifier: replacementOwner, + isClaudeOAuthSample: true, + now: Date(timeIntervalSince1970: 1_700_007_200)) + + let buckets = try #require(store.planUtilizationHistory[.claude]) + #expect(originalKey != replacementKey) + #expect(findSeries(buckets.accounts[originalKey] ?? [], name: .session, windowMinutes: 300)? + .entries.map(\.usedPercent) == [25]) + #expect(findSeries(buckets.accounts[replacementKey] ?? [], name: .session, windowMinutes: 300)? + .entries.map(\.usedPercent) == [80]) + + let reloaded = self.makeReloadedStoreWithConfiguredTokenAccount(buckets: buckets) + let selection = reloaded.planUtilizationHistorySelection(for: .claude) + #expect(selection.accountKey == replacementKey) + #expect(findSeries(selection.histories, name: .session, windowMinutes: 300)? + .entries.map(\.usedPercent) == [80]) + #expect(reloaded.planUtilizationHistory[.claude]?.accounts[originalKey] != nil) + } + + @Test + func `claude oauth history key is stable for one credential owner`() throws { + let owner = self.oauthOwnerIdentifier("a") + let first = try #require( + UsageStore._claudeOAuthPlanUtilizationAccountKeyForTesting(historyOwnerIdentifier: owner)) + let refreshed = try #require( + UsageStore._claudeOAuthPlanUtilizationAccountKeyForTesting( + historyOwnerIdentifier: " \(owner.uppercased()) ")) + let switched = try #require( + UsageStore._claudeOAuthPlanUtilizationAccountKeyForTesting( + historyOwnerIdentifier: self.oauthOwnerIdentifier("b"))) + + #expect(first == refreshed) + #expect(first != switched) + #expect(first != owner) + #expect(first.hasPrefix("__claude_oauth__:")) + #expect(first.dropFirst("__claude_oauth__:".count).count == 64) } @Test @@ -241,4 +856,50 @@ struct UsageStorePlanUtilizationClaudeIdentityTests { #expect(buckets.accounts[accountKey] == [legacyWeekly]) #expect(buckets.preferredAccountKey == accountKey) } + + private func oauthOwnerIdentifier(_ character: Character) -> String { + String(repeating: String(character), count: 64) + } + + private func identitylessClaudeSnapshot(usedPercent: Double) -> UsageSnapshot { + UsageSnapshot( + primary: RateWindow( + usedPercent: usedPercent, + windowMinutes: 300, + resetsAt: nil, + resetDescription: nil), + secondary: nil, + updatedAt: Date()) + } + + @MainActor + private func makeReloadedStoreWithConfiguredTokenAccount( + buckets: PlanUtilizationHistoryBuckets) -> UsageStore + { + let suiteName = "UsageStorePlanUtilizationClaudeReload-\(UUID().uuidString)" + guard let defaults = UserDefaults(suiteName: suiteName) else { + fatalError("Failed to create isolated UserDefaults suite for tests") + } + defaults.removePersistentDomain(forName: suiteName) + let historyStore = testPlanUtilizationHistoryStore(suiteName: suiteName) + historyStore.save([.claude: buckets]) + let settings = SettingsStore( + userDefaults: defaults, + configStore: testConfigStore(suiteName: suiteName), + tokenAccountStore: InMemoryTokenAccountStore()) + settings.addTokenAccount(provider: .claude, label: "Unrelated", token: "unrelated-token") + settings.setActiveTokenAccountIndex(0, for: .claude) + let store = UsageStore( + fetcher: UsageFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + planUtilizationHistoryStore: historyStore, + startupBehavior: .testing) + // Cancel the background decode and apply the disk-loaded buckets + // synchronously so callers can immediately query without racing the + // utility-priority load task. + store._cancelPlanUtilizationHistoryLoadForTesting() + store.planUtilizationHistory = store.planUtilizationHistoryStore.load() + return store + } } diff --git a/Tests/CodexBarTests/UsageStorePlanUtilizationClaudeWeeklyDedupTests.swift b/Tests/CodexBarTests/UsageStorePlanUtilizationClaudeWeeklyDedupTests.swift new file mode 100644 index 0000000000..ef83556cf3 --- /dev/null +++ b/Tests/CodexBarTests/UsageStorePlanUtilizationClaudeWeeklyDedupTests.swift @@ -0,0 +1,325 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +extension UsageStorePlanUtilizationTests { + @MainActor + @Test + func `Claude weekly celebration ignores a stale high and duplicate low after reset`() async throws { + let store = Self.makeStore() + let accountLabel = "claude-weekly-dedup-account" + let recorder = ClaudeWeeklyResetEventRecorder(accountLabel: accountLabel) + defer { recorder.invalidate() } + + let start = Date(timeIntervalSince1970: 1_784_174_200) + let boundary = start.addingTimeInterval(4 * 24 * 60 * 60) + let snapshots = [ + claudeWeeklyDedupSnapshot( + accountLabel: accountLabel, + usedPercent: 73, + resetsAt: boundary, + updatedAt: start), + claudeWeeklyDedupSnapshot( + accountLabel: accountLabel, + usedPercent: 0, + resetsAt: boundary, + updatedAt: start.addingTimeInterval(10)), + claudeWeeklyDedupSnapshot( + accountLabel: accountLabel, + usedPercent: 73, + resetsAt: boundary, + updatedAt: start.addingTimeInterval(20)), + claudeWeeklyDedupSnapshot( + accountLabel: accountLabel, + usedPercent: 0, + resetsAt: boundary, + updatedAt: start.addingTimeInterval(25)), + ] + + for snapshot in snapshots { + await store.recordPlanUtilizationHistorySample( + provider: .claude, + snapshot: snapshot, + now: snapshot.updatedAt) + } + + #expect(recorder.count == 1) + let state = try #require(store.weeklyLimitResetDetectorStates.values.first) + #expect(state.wasAboveThreshold == false) + #expect(state.recoveryAboveThresholdCount == 0) + } + + @MainActor + @Test + func `Claude weekly recovery confirmation persists and later permits a new reset`() async throws { + let firstStore = Self.makeStore() + let accountLabel = "claude-weekly-persisted-dedup-account" + let recorder = ClaudeWeeklyResetEventRecorder(accountLabel: accountLabel) + defer { recorder.invalidate() } + + let start = Date(timeIntervalSince1970: 1_784_200_000) + let boundary = start.addingTimeInterval(4 * 24 * 60 * 60) + let firstHigh = claudeWeeklyDedupSnapshot( + accountLabel: accountLabel, + usedPercent: 65, + resetsAt: boundary, + updatedAt: start) + let firstReset = claudeWeeklyDedupSnapshot( + accountLabel: accountLabel, + usedPercent: 0, + resetsAt: boundary, + updatedAt: start.addingTimeInterval(10)) + + for snapshot in [firstHigh, firstReset] { + await firstStore.recordPlanUtilizationHistorySample( + provider: .claude, + snapshot: snapshot, + now: snapshot.updatedAt) + } + #expect(recorder.count == 1) + + let persistedStates = UsageStore.loadWeeklyLimitResetDetectorStates( + from: firstStore.settings.userDefaults) + let restartedStore = Self.makeStore() + restartedStore.weeklyLimitResetDetectorStates = persistedStates + + let delayedStaleHigh = claudeWeeklyDedupSnapshot( + accountLabel: accountLabel, + usedPercent: 65, + resetsAt: boundary, + updatedAt: firstReset.updatedAt.addingTimeInterval(30 * 60)) + let duplicateLow = claudeWeeklyDedupSnapshot( + accountLabel: accountLabel, + usedPercent: 0, + resetsAt: boundary, + updatedAt: firstReset.updatedAt.addingTimeInterval(31 * 60)) + + for snapshot in [delayedStaleHigh, duplicateLow] { + await restartedStore.recordPlanUtilizationHistorySample( + provider: .claude, + snapshot: snapshot, + now: snapshot.updatedAt) + } + #expect(recorder.count == 1) + var state = try #require(restartedStore.weeklyLimitResetDetectorStates.values.first) + #expect(state.wasAboveThreshold == false) + #expect(state.recoveryAboveThresholdCount == 0) + + let firstRecoveryHigh = claudeWeeklyDedupSnapshot( + accountLabel: accountLabel, + usedPercent: 40, + resetsAt: boundary, + updatedAt: firstReset.updatedAt.addingTimeInterval(60 * 60)) + await restartedStore.recordPlanUtilizationHistorySample( + provider: .claude, + snapshot: firstRecoveryHigh, + now: firstRecoveryHigh.updatedAt) + #expect(recorder.count == 1) + state = try #require(restartedStore.weeklyLimitResetDetectorStates.values.first) + #expect(state.wasAboveThreshold == false) + #expect(state.recoveryAboveThresholdCount == 1) + + let recoveryStates = UsageStore.loadWeeklyLimitResetDetectorStates( + from: restartedStore.settings.userDefaults) + let secondRestartedStore = Self.makeStore() + secondRestartedStore.weeklyLimitResetDetectorStates = recoveryStates + + let secondRecoveryHigh = claudeWeeklyDedupSnapshot( + accountLabel: accountLabel, + usedPercent: 45, + resetsAt: boundary, + updatedAt: firstReset.updatedAt.addingTimeInterval(65 * 60)) + let laterReset = claudeWeeklyDedupSnapshot( + accountLabel: accountLabel, + usedPercent: 0, + resetsAt: boundary, + updatedAt: firstReset.updatedAt.addingTimeInterval(70 * 60)) + + for snapshot in [secondRecoveryHigh, laterReset] { + await secondRestartedStore.recordPlanUtilizationHistorySample( + provider: .claude, + snapshot: snapshot, + now: snapshot.updatedAt) + } + #expect(recorder.count == 2) + } + + @MainActor + @Test + func `Claude weekly recovery confirmation is isolated by account`() async { + let store = Self.makeStore() + let firstAccount = "claude-weekly-dedup-account-a" + let secondAccount = "claude-weekly-dedup-account-b" + let firstRecorder = ClaudeWeeklyResetEventRecorder(accountLabel: firstAccount) + let secondRecorder = ClaudeWeeklyResetEventRecorder(accountLabel: secondAccount) + defer { + firstRecorder.invalidate() + secondRecorder.invalidate() + } + + let start = Date(timeIntervalSince1970: 1_784_300_000) + let boundary = start.addingTimeInterval(4 * 24 * 60 * 60) + let snapshots = [ + claudeWeeklyDedupSnapshot( + accountLabel: firstAccount, + usedPercent: 65, + resetsAt: boundary, + updatedAt: start), + claudeWeeklyDedupSnapshot( + accountLabel: firstAccount, + usedPercent: 0, + resetsAt: boundary, + updatedAt: start.addingTimeInterval(1)), + claudeWeeklyDedupSnapshot( + accountLabel: firstAccount, + usedPercent: 65, + resetsAt: boundary, + updatedAt: start.addingTimeInterval(30 * 60)), + claudeWeeklyDedupSnapshot( + accountLabel: secondAccount, + usedPercent: 60, + resetsAt: boundary, + updatedAt: start.addingTimeInterval(31 * 60)), + claudeWeeklyDedupSnapshot( + accountLabel: secondAccount, + usedPercent: 0, + resetsAt: boundary, + updatedAt: start.addingTimeInterval(32 * 60)), + claudeWeeklyDedupSnapshot( + accountLabel: firstAccount, + usedPercent: 0, + resetsAt: boundary, + updatedAt: start.addingTimeInterval(33 * 60)), + ] + + for snapshot in snapshots { + await store.recordPlanUtilizationHistorySample( + provider: .claude, + snapshot: snapshot, + now: snapshot.updatedAt) + } + + #expect(firstRecorder.count == 1) + #expect(secondRecorder.count == 1) + #expect(store.weeklyLimitResetDetectorStates.count == 2) + #expect(store.weeklyLimitResetDetectorStates.values.allSatisfy { !$0.wasAboveThreshold }) + #expect(store.weeklyLimitResetDetectorStates.values.allSatisfy { + $0.recoveryAboveThresholdCount == 0 + }) + } + + @Test + func `legacy reset detector state decodes without recovery state`() throws { + let suiteName = "ClaudeWeeklyResetDedupLegacy-\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suiteName)) + defer { defaults.removePersistentDomain(forName: suiteName) } + + let data = Data( + #"{"claude:legacy":{"wasAboveThreshold":true,"lastObservedAt":0}}"#.utf8) + defaults.set(data, forKey: "legacyWeeklyResetStates") + + let states = UsageStore.loadLimitResetDetectorStates( + from: defaults, + defaultsKey: "legacyWeeklyResetStates", + logName: "weekly") + + let state = try #require(states["claude:legacy"]) + #expect(state.wasAboveThreshold) + #expect(state.recoveryAboveThresholdCount == nil) + #expect(!state.pendingLowConfirmation) + } + + @Test + func `legacy Claude weekly low state migrates into recovery confirmation`() throws { + let suiteName = "ClaudeWeeklyResetDedupLowMigration-\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suiteName)) + defer { defaults.removePersistentDomain(forName: suiteName) } + + let data = Data( + """ + { + "claude:legacy-low": {"wasAboveThreshold":false,"lastObservedAt":0}, + "codex:legacy-low": {"wasAboveThreshold":false,"lastObservedAt":0} + } + """.utf8) + defaults.set(data, forKey: "weeklyLimitResetDetectorStates") + + let states = UsageStore.loadWeeklyLimitResetDetectorStates(from: defaults) + + #expect(states["claude:legacy-low"]?.recoveryAboveThresholdCount == 0) + #expect(states["codex:legacy-low"]?.recoveryAboveThresholdCount == nil) + } +} + +private func claudeWeeklyDedupSnapshot( + accountLabel: String, + usedPercent: Double, + resetsAt: Date, + updatedAt: Date) -> UsageSnapshot +{ + UsageSnapshot( + primary: RateWindow( + usedPercent: 14, + windowMinutes: 300, + resetsAt: updatedAt.addingTimeInterval(5 * 60 * 60), + resetDescription: nil), + secondary: RateWindow( + usedPercent: usedPercent, + windowMinutes: 7 * 24 * 60, + resetsAt: resetsAt, + resetDescription: nil), + updatedAt: updatedAt, + identity: ProviderIdentitySnapshot( + providerID: .claude, + accountEmail: accountLabel, + accountOrganization: nil, + loginMethod: "web")) +} + +private final class ClaudeWeeklyResetEventRecorder: @unchecked Sendable { + private let accountLabel: String + private let lock = NSLock() + private var eventCount = 0 + private var observer: NSObjectProtocol? + + init(accountLabel: String) { + self.accountLabel = accountLabel + self.observer = NotificationCenter.default.addObserver( + forName: .codexbarWeeklyLimitReset, + object: nil, + queue: nil) + { [weak self] notification in + guard let self, + let event = notification.object as? WeeklyLimitResetEvent + else { + return + } + + let matches = MainActor.assumeIsolated { + event.provider == .claude && event.accountLabel == self.accountLabel + } + guard matches else { return } + + self.lock.lock() + self.eventCount += 1 + self.lock.unlock() + } + } + + var count: Int { + self.lock.lock() + defer { self.lock.unlock() } + return self.eventCount + } + + func invalidate() { + guard let observer else { return } + NotificationCenter.default.removeObserver(observer) + self.observer = nil + } + + deinit { + self.invalidate() + } +} diff --git a/Tests/CodexBarTests/UsageStorePlanUtilizationCodexResetOwnershipTests.swift b/Tests/CodexBarTests/UsageStorePlanUtilizationCodexResetOwnershipTests.swift new file mode 100644 index 0000000000..73da85bec2 --- /dev/null +++ b/Tests/CodexBarTests/UsageStorePlanUtilizationCodexResetOwnershipTests.swift @@ -0,0 +1,153 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +extension UsageStorePlanUtilizationTests { + @MainActor + @Test + func `codex weekly reset detector does not derive an owner for default refreshes`() async { + let store = Self.makeStore() + let email = "shared-default@example.com" + let observedAt = Date(timeIntervalSince1970: 1_700_050_000) + defer { store.settings._test_liveSystemCodexAccount = nil } + + store.settings._test_liveSystemCodexAccount = ObservedSystemCodexAccount( + email: email, + authFingerprint: "fingerprint-a", + codexHomePath: "/tmp/codex-a", + observedAt: observedAt) + await store.recordPlanUtilizationHistorySample( + provider: .codex, + snapshot: Self.codexWeeklySnapshot(email: email, plan: "plus", observedAt: observedAt), + now: observedAt) + + store.settings._test_liveSystemCodexAccount = ObservedSystemCodexAccount( + email: email, + authFingerprint: "fingerprint-b", + codexHomePath: "/tmp/codex-b", + observedAt: observedAt.addingTimeInterval(60)) + await store.recordPlanUtilizationHistorySample( + provider: .codex, + snapshot: Self.codexWeeklySnapshot( + email: email, + plan: "plus", + observedAt: observedAt.addingTimeInterval(60)), + now: observedAt.addingTimeInterval(60)) + + #expect(store.weeklyLimitResetDetectorStates.isEmpty) + } + + @MainActor + @Test + func `codex weekly reset detector separates workspace accounts and ignores plan changes`() async throws { + let store = Self.makeStore() + let email = "shared-workspace@example.com" + let ownerA = try #require(CodexLimitResetOwnerKey( + identity: .providerAccount(id: "account-a"), + accountEmail: email)) + let ownerB = try #require(CodexLimitResetOwnerKey( + identity: .providerAccount(id: "account-b"), + accountEmail: email)) + let observedAt = Date(timeIntervalSince1970: 1_700_000_000) + + await store.recordPlanUtilizationHistorySample( + provider: .codex, + snapshot: Self.codexWeeklySnapshot(email: email, plan: "plus", observedAt: observedAt), + codexLimitResetOwnerKey: ownerA, + now: observedAt) + await store.recordPlanUtilizationHistorySample( + provider: .codex, + snapshot: Self.codexWeeklySnapshot( + email: email, + plan: "pro", + observedAt: observedAt.addingTimeInterval(60)), + codexLimitResetOwnerKey: ownerA, + now: observedAt.addingTimeInterval(60)) + await store.recordPlanUtilizationHistorySample( + provider: .codex, + snapshot: Self.codexWeeklySnapshot( + email: email, + plan: "plus", + observedAt: observedAt.addingTimeInterval(120)), + codexLimitResetOwnerKey: ownerB, + now: observedAt.addingTimeInterval(120)) + + #expect(store.weeklyLimitResetDetectorStates.count == 2) + } + + @MainActor + @Test + func `codex weekly reset detector fails closed without workspace ids`() async { + let store = Self.makeStore() + let email = "shared-auth@example.com" + let observedAt = Date(timeIntervalSince1970: 1_700_100_000) + + #expect(CodexLimitResetOwnerKey(identity: .emailOnly(normalizedEmail: email), accountEmail: email) == nil) + + await store.recordPlanUtilizationHistorySample( + provider: .codex, + snapshot: Self.codexWeeklySnapshot(email: email, plan: "plus", observedAt: observedAt), + now: observedAt) + await store.recordPlanUtilizationHistorySample( + provider: .codex, + snapshot: Self.codexWeeklySnapshot( + email: email, + plan: "plus", + observedAt: observedAt.addingTimeInterval(60)), + now: observedAt.addingTimeInterval(60)) + + #expect(store.weeklyLimitResetDetectorStates.isEmpty) + } + + @MainActor + @Test + func `codex weekly reset detector keeps workspace ownership across token refreshes`() async throws { + let store = Self.makeStore() + let email = "managed-refresh@example.com" + let observedAt = Date(timeIntervalSince1970: 1_700_200_000) + let ownerKey = try #require(CodexLimitResetOwnerKey( + identity: .providerAccount(id: "managed-workspace"), + accountEmail: email)) + + await store.recordPlanUtilizationHistorySample( + provider: .codex, + snapshot: Self.codexWeeklySnapshot(email: email, plan: "plus", observedAt: observedAt), + codexLimitResetOwnerKey: ownerKey, + now: observedAt) + await store.recordPlanUtilizationHistorySample( + provider: .codex, + snapshot: Self.codexWeeklySnapshot( + email: email, + plan: "plus", + observedAt: observedAt.addingTimeInterval(60)), + codexLimitResetOwnerKey: ownerKey, + now: observedAt.addingTimeInterval(60)) + + #expect(store.weeklyLimitResetDetectorStates.count == 1) + } + + private static func codexWeeklySnapshot( + email: String, + plan: String, + observedAt: Date) -> UsageSnapshot + { + UsageSnapshot( + primary: RateWindow( + usedPercent: 10, + windowMinutes: 300, + resetsAt: observedAt.addingTimeInterval(5 * 3600), + resetDescription: nil), + secondary: RateWindow( + usedPercent: 80, + windowMinutes: 10080, + resetsAt: observedAt.addingTimeInterval(3 * 24 * 3600), + resetDescription: nil), + updatedAt: observedAt, + identity: ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: email, + accountOrganization: nil, + loginMethod: plan)) + } +} diff --git a/Tests/CodexBarTests/UsageStorePlanUtilizationResetConfirmationTests.swift b/Tests/CodexBarTests/UsageStorePlanUtilizationResetConfirmationTests.swift new file mode 100644 index 0000000000..736546604a --- /dev/null +++ b/Tests/CodexBarTests/UsageStorePlanUtilizationResetConfirmationTests.swift @@ -0,0 +1,205 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +extension UsageStorePlanUtilizationTests { + @MainActor + @Test + func `identity-less Claude reset celebrations require a second low sample`() async throws { + let store = Self.makeStore() + let sessionRecorder = SessionLimitResetEventRecorder(provider: .claude, accountLabel: nil) + let weeklyRecorder = WeeklyLimitResetEventRecorder(provider: .claude, accountLabel: nil) + defer { + sessionRecorder.invalidate() + weeklyRecorder.invalidate() + } + + let firstDate = Date(timeIntervalSince1970: 1_780_000_000) + let firstSessionBoundary = firstDate.addingTimeInterval(60 * 60) + let firstWeeklyBoundary = firstDate.addingTimeInterval(3 * 24 * 60 * 60) + + func snapshot( + sessionUsed: Double, + weeklyUsed: Double, + sessionBoundary: Date, + weeklyBoundary: Date, + updatedAt: Date) -> UsageSnapshot + { + UsageSnapshot( + primary: RateWindow( + usedPercent: sessionUsed, + windowMinutes: 300, + resetsAt: sessionBoundary, + resetDescription: nil), + secondary: RateWindow( + usedPercent: weeklyUsed, + windowMinutes: 10080, + resetsAt: weeklyBoundary, + resetDescription: nil), + updatedAt: updatedAt) + } + + let before = snapshot( + sessionUsed: 30, + weeklyUsed: 40, + sessionBoundary: firstSessionBoundary, + weeklyBoundary: firstWeeklyBoundary, + updatedAt: firstDate) + let apparentReset = snapshot( + sessionUsed: 0, + weeklyUsed: 0, + sessionBoundary: firstSessionBoundary.addingTimeInterval(5 * 60 * 60), + weeklyBoundary: firstWeeklyBoundary.addingTimeInterval(7 * 24 * 60 * 60), + updatedAt: firstDate.addingTimeInterval(60)) + let recovered = try snapshot( + sessionUsed: 31, + weeklyUsed: 41, + sessionBoundary: #require(apparentReset.primary?.resetsAt), + weeklyBoundary: #require(apparentReset.secondary?.resetsAt), + updatedAt: firstDate.addingTimeInterval(120)) + + for current in [before, apparentReset, recovered] { + await store.recordPlanUtilizationHistorySample( + provider: .claude, + snapshot: current, + now: current.updatedAt) + } + #expect(sessionRecorder.events.isEmpty) + #expect(weeklyRecorder.events.isEmpty) + + let reset = try snapshot( + sessionUsed: 0, + weeklyUsed: 0, + sessionBoundary: #require(recovered.primary?.resetsAt).addingTimeInterval(5 * 60 * 60), + weeklyBoundary: #require(recovered.secondary?.resetsAt).addingTimeInterval(7 * 24 * 60 * 60), + updatedAt: firstDate.addingTimeInterval(180)) + let confirmedReset = try snapshot( + sessionUsed: 1, + weeklyUsed: 1, + sessionBoundary: #require(reset.primary?.resetsAt), + weeklyBoundary: #require(reset.secondary?.resetsAt), + updatedAt: firstDate.addingTimeInterval(240)) + + await store.recordPlanUtilizationHistorySample(provider: .claude, snapshot: reset, now: reset.updatedAt) + #expect(sessionRecorder.events.isEmpty) + #expect(weeklyRecorder.events.isEmpty) + + await store.recordPlanUtilizationHistorySample( + provider: .claude, + snapshot: confirmedReset, + now: confirmedReset.updatedAt) + #expect(sessionRecorder.events.count == 1) + #expect(weeklyRecorder.events.count == 1) + + let repeatedLow = try snapshot( + sessionUsed: 0.5, + weeklyUsed: 0.5, + sessionBoundary: #require(confirmedReset.primary?.resetsAt), + weeklyBoundary: #require(confirmedReset.secondary?.resetsAt), + updatedAt: firstDate.addingTimeInterval(300)) + await store.recordPlanUtilizationHistorySample( + provider: .claude, + snapshot: repeatedLow, + now: repeatedLow.updatedAt) + #expect(sessionRecorder.events.count == 1) + #expect(weeklyRecorder.events.count == 1) + } + + @MainActor + @Test + func `identity-less confirmation and identified weekly dedup compose`() async { + let identitylessStore = Self.makeStore() + let identitylessSessionRecorder = SessionLimitResetEventRecorder(provider: .claude, accountLabel: nil) + let identitylessWeeklyRecorder = WeeklyLimitResetEventRecorder(provider: .claude, accountLabel: nil) + let identifiedStore = Self.makeStore() + let identifiedAccount = "claude-composed-reset-account" + let identifiedWeeklyRecorder = WeeklyLimitResetEventRecorder( + provider: .claude, + accountLabel: identifiedAccount) + defer { + identitylessSessionRecorder.invalidate() + identitylessWeeklyRecorder.invalidate() + identifiedWeeklyRecorder.invalidate() + } + + let start = Date(timeIntervalSince1970: 1_784_500_000) + let firstSessionBoundary = start.addingTimeInterval(5 * 60 * 60) + let resetSessionBoundary = firstSessionBoundary.addingTimeInterval(5 * 60 * 60) + let weeklyBoundary = start.addingTimeInterval(4 * 24 * 60 * 60) + + func snapshot( + accountLabel: String?, + sessionUsed: Double, + weeklyUsed: Double, + sessionBoundary: Date, + updatedAt: Date) -> UsageSnapshot + { + UsageSnapshot( + primary: RateWindow( + usedPercent: sessionUsed, + windowMinutes: 300, + resetsAt: sessionBoundary, + resetDescription: nil), + secondary: RateWindow( + usedPercent: weeklyUsed, + windowMinutes: 10080, + resetsAt: weeklyBoundary, + resetDescription: nil), + updatedAt: updatedAt, + identity: accountLabel.map { + ProviderIdentitySnapshot( + providerID: .claude, + accountEmail: $0, + accountOrganization: nil, + loginMethod: "test") + }) + } + + let identitylessSnapshots = [ + snapshot( + accountLabel: nil, + sessionUsed: 3, + weeklyUsed: 3, + sessionBoundary: firstSessionBoundary, + updatedAt: start), + snapshot( + accountLabel: nil, + sessionUsed: 0, + weeklyUsed: 0, + sessionBoundary: resetSessionBoundary, + updatedAt: start.addingTimeInterval(60)), + snapshot( + accountLabel: nil, + sessionUsed: 3, + weeklyUsed: 3, + sessionBoundary: resetSessionBoundary, + updatedAt: start.addingTimeInterval(120)), + ] + for current in identitylessSnapshots { + await identitylessStore.recordPlanUtilizationHistorySample( + provider: .claude, + snapshot: current, + now: current.updatedAt) + } + + #expect(identitylessSessionRecorder.events.isEmpty) + #expect(identitylessWeeklyRecorder.events.isEmpty) + + let identifiedWeeklyUsage = [73.0, 0.0, 73.0, 0.0] + for (index, weeklyUsed) in identifiedWeeklyUsage.enumerated() { + let current = snapshot( + accountLabel: identifiedAccount, + sessionUsed: 50, + weeklyUsed: weeklyUsed, + sessionBoundary: firstSessionBoundary, + updatedAt: start.addingTimeInterval(TimeInterval(300 + index * 60))) + await identifiedStore.recordPlanUtilizationHistorySample( + provider: .claude, + snapshot: current, + now: current.updatedAt) + } + + #expect(identifiedWeeklyRecorder.events.count == 1) + } +} diff --git a/Tests/CodexBarTests/UsageStorePlanUtilizationTests.swift b/Tests/CodexBarTests/UsageStorePlanUtilizationTests.swift index aa3207f6dd..0a6db50ab9 100644 --- a/Tests/CodexBarTests/UsageStorePlanUtilizationTests.swift +++ b/Tests/CodexBarTests/UsageStorePlanUtilizationTests.swift @@ -198,6 +198,36 @@ struct UsageStorePlanUtilizationTests { #expect(model.selectedSeries == "session:300") } + @MainActor + @Test + func `opencodego history tabs include the monthly series`() { + let histories = [ + planSeries(name: .session, windowMinutes: 300, entries: [ + planEntry(at: Date(timeIntervalSince1970: 1_700_000_000), usedPercent: 12), + ]), + planSeries(name: .weekly, windowMinutes: 10080, entries: [ + planEntry(at: Date(timeIntervalSince1970: 1_700_086_400), usedPercent: 57), + ]), + planSeries(name: .monthly, windowMinutes: 43200, entries: [ + planEntry(at: Date(timeIntervalSince1970: 1_700_086_400), usedPercent: 34), + ]), + ] + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 12, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + secondary: RateWindow(usedPercent: 57, windowMinutes: 10080, resetsAt: nil, resetDescription: nil), + tertiary: RateWindow(usedPercent: 34, windowMinutes: 43200, resetsAt: nil, resetDescription: nil), + updatedAt: Date(timeIntervalSince1970: 1_700_086_400), + identity: nil) + + let model = PlanUtilizationHistoryChartMenuView._modelSnapshotForTesting( + histories: histories, + provider: .opencodego, + snapshot: snapshot) + + #expect(model.visibleSeries == ["session:300", "weekly:10080", "monthly:43200"]) + #expect(model.selectedSeries == "session:300") + } + @MainActor @Test func `session chart uses native reset boundaries and fills missing windows`() throws { @@ -539,6 +569,44 @@ struct UsageStorePlanUtilizationTests { #expect(bobHistory == [bobWeekly]) } + @MainActor + @Test + func `cursor automatic history ignores dormant saved token account`() async throws { + let store = Self.makeStore() + store.settings.historicalTrackingEnabled = true + store.settings.addTokenAccount( + provider: .cursor, + label: "Dormant manual account", + token: "fixture") + let dormantAccount = try #require(store.settings.selectedTokenAccount(for: .cursor)) + let dormantAccountKey = try #require( + UsageStore._planUtilizationTokenAccountKeyForTesting( + provider: .cursor, + account: dormantAccount)) + store.settings.cursorCookieSource = .auto + + let browserSnapshot = Self.makeSnapshot(provider: .cursor, email: "browser@example.com") + let browserAccountKey = try #require( + UsageStore._planUtilizationAccountKeyForTesting( + provider: .cursor, + snapshot: browserSnapshot)) + store._setSnapshotForTesting(browserSnapshot, provider: .cursor) + + await store.recordPlanUtilizationHistorySample( + provider: .cursor, + snapshot: browserSnapshot, + now: Date(timeIntervalSince1970: 1_700_000_000)) + + let histories = store.planUtilizationHistory(for: .cursor) + let buckets = try #require(store.planUtilizationHistory[.cursor]) + #expect(store.settings.selectedTokenAccount(for: .cursor)?.id == dormantAccount.id) + #expect(store.settings.effectiveSelectedTokenAccount(for: .cursor) == nil) + #expect(buckets.preferredAccountKey == browserAccountKey) + #expect(buckets.accounts[dormantAccountKey] == nil) + #expect(histories == buckets.accounts[browserAccountKey]) + #expect(!histories.isEmpty) + } + @MainActor @Test func `plan utilization menu hides while refreshing without current snapshot`() throws { @@ -736,190 +804,215 @@ struct UsageStorePlanUtilizationTests { @MainActor @Test - func `weekly quota celebration posts when weekly usage resets to zero`() async { + func `opencodego plan history is always supported like codex and claude`() { let store = Self.makeStore() - let accountLabel = "reset-zero@example.com" - let recorder = WeeklyLimitResetEventRecorder(provider: .claude, accountLabel: accountLabel) - defer { recorder.invalidate() } + #expect(store.settings.historicalTrackingEnabled == false) + #expect(store.supportsPlanUtilizationHistory(for: .opencodego)) + } - let before = UsageSnapshot( - primary: RateWindow(usedPercent: 10, windowMinutes: 300, resetsAt: nil, resetDescription: nil), - secondary: RateWindow(usedPercent: 99, windowMinutes: 10080, resetsAt: nil, resetDescription: nil), - updatedAt: Date(timeIntervalSince1970: 1_700_000_000), - identity: ProviderIdentitySnapshot( - providerID: .claude, - accountEmail: accountLabel, - accountOrganization: nil, - loginMethod: "max")) - let after = UsageSnapshot( - primary: RateWindow(usedPercent: 10, windowMinutes: 300, resetsAt: nil, resetDescription: nil), - secondary: RateWindow(usedPercent: 0, windowMinutes: 10080, resetsAt: nil, resetDescription: nil), - updatedAt: Date(timeIntervalSince1970: 1_700_003_600), + @MainActor + @Test + func `record plan history stores opencodego monthly series`() async { + let store = Self.makeStore() + // historicalTrackingEnabled defaults to false; opencodego must still record, like codex/claude. + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 12, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + secondary: RateWindow(usedPercent: 57, windowMinutes: 10080, resetsAt: nil, resetDescription: nil), + tertiary: RateWindow(usedPercent: 34, windowMinutes: 43200, resetsAt: nil, resetDescription: nil), + updatedAt: Date(), identity: ProviderIdentitySnapshot( - providerID: .claude, - accountEmail: accountLabel, + providerID: .opencodego, + accountEmail: nil, accountOrganization: nil, - loginMethod: "max")) + loginMethod: nil)) + store._setSnapshotForTesting(snapshot, provider: .opencodego) - await store.recordPlanUtilizationHistorySample(provider: .claude, snapshot: before, now: before.updatedAt) - await store.recordPlanUtilizationHistorySample(provider: .claude, snapshot: after, now: after.updatedAt) + await store.recordPlanUtilizationHistorySample( + provider: .opencodego, + snapshot: snapshot, + now: Date(timeIntervalSince1970: 1_700_000_000)) - let events = recorder.events - #expect(events.count == 1) - #expect(events[0].provider == .claude) - #expect(events[0].accountLabel == accountLabel) - #expect(events[0].usedPercent == 0) + let histories = store.planUtilizationHistory(for: .opencodego) + #expect(findSeries(histories, name: .session, windowMinutes: 300)?.entries.last?.usedPercent == 12) + #expect(findSeries(histories, name: .weekly, windowMinutes: 10080)?.entries.last?.usedPercent == 57) + #expect(findSeries(histories, name: .monthly, windowMinutes: 43200)?.entries.last?.usedPercent == 34) } @MainActor @Test - func `weekly quota celebration posts when reset lands mid hour without history split`() async { + func `generic provider weekly lane is persisted to provider history json`() async throws { let store = Self.makeStore() - let accountLabel = "mid-hour-reset@example.com" - let recorder = WeeklyLimitResetEventRecorder(provider: .claude, accountLabel: accountLabel) - defer { recorder.invalidate() } - + store.settings.historicalTrackingEnabled = true + let accountLabel = "zai-history-org" + let firstDate = Date(timeIntervalSince1970: 1_700_000_000) let before = UsageSnapshot( - primary: RateWindow(usedPercent: 10, windowMinutes: 300, resetsAt: nil, resetDescription: nil), - secondary: RateWindow( - usedPercent: 40, - windowMinutes: 10080, - resetsAt: Date(timeIntervalSince1970: 1_700_100_000), - resetDescription: nil), - updatedAt: Date(timeIntervalSince1970: 1_700_000_000), + primary: RateWindow(usedPercent: 42, windowMinutes: 10080, resetsAt: nil, resetDescription: nil), + secondary: RateWindow(usedPercent: 15, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + updatedAt: firstDate, identity: ProviderIdentitySnapshot( - providerID: .claude, - accountEmail: accountLabel, - accountOrganization: nil, - loginMethod: "max")) + providerID: .zai, + accountEmail: nil, + accountOrganization: accountLabel, + loginMethod: "pro")) let after = UsageSnapshot( - primary: RateWindow(usedPercent: 10, windowMinutes: 300, resetsAt: nil, resetDescription: nil), - secondary: RateWindow( - usedPercent: 0, - windowMinutes: 10080, - resetsAt: Date(timeIntervalSince1970: 1_700_100_030), - resetDescription: nil), - updatedAt: Date(timeIntervalSince1970: 1_700_001_800), + primary: RateWindow(usedPercent: 58, windowMinutes: 10080, resetsAt: nil, resetDescription: nil), + secondary: RateWindow(usedPercent: 25, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + updatedAt: firstDate.addingTimeInterval(3600), identity: ProviderIdentitySnapshot( - providerID: .claude, - accountEmail: accountLabel, - accountOrganization: nil, - loginMethod: "max")) + providerID: .zai, + accountEmail: nil, + accountOrganization: accountLabel, + loginMethod: "pro")) - await store.recordPlanUtilizationHistorySample(provider: .claude, snapshot: before, now: before.updatedAt) - await store.recordPlanUtilizationHistorySample(provider: .claude, snapshot: after, now: after.updatedAt) + await store.recordPlanUtilizationHistorySample(provider: .zai, snapshot: before, now: before.updatedAt) + await store.recordPlanUtilizationHistorySample(provider: .zai, snapshot: after, now: after.updatedAt) - let histories = store.planUtilizationHistory(for: .claude) - #expect(findSeries(histories, name: .weekly, windowMinutes: 10080)?.entries.count == 1) - #expect(findSeries(histories, name: .weekly, windowMinutes: 10080)?.entries.last?.usedPercent == 40) - let events = recorder.events - #expect(events.count == 1) - #expect(events[0].usedPercent == 0) + let histories = store.planUtilizationHistory(for: .zai) + #expect(findSeries(histories, name: .weekly, windowMinutes: 10080)?.entries.map(\.usedPercent) == [42, 58]) + #expect(findSeries(histories, name: .session, windowMinutes: 300)?.entries.map(\.usedPercent) == [15, 25]) + + let providerURL = try #require(store.planUtilizationHistoryStore.directoryURL? + .appendingPathComponent("zai.json", isDirectory: false)) + var persistedBuckets: PlanUtilizationHistoryBuckets? + for _ in 0..<20 { + persistedBuckets = store.planUtilizationHistoryStore.load()[.zai] + let weeklyEntries = persistedBuckets?.histories(for: persistedBuckets?.preferredAccountKey) + .first { $0.name == .weekly }?.entries.count + if weeklyEntries == 2 { + break + } + try await Task.sleep(nanoseconds: 50_000_000) + } + #expect(FileManager.default.fileExists(atPath: providerURL.path)) + let persisted = try #require(persistedBuckets) + #expect(persisted.histories(for: persisted.preferredAccountKey) + .first { $0.name == .weekly }?.entries.map(\.usedPercent) == [42, 58]) } @MainActor @Test - func `weekly quota celebration ignores first seen reset sample`() async { + func `generic history opt in controls recording while saved history stays visible`() async throws { let store = Self.makeStore() - let accountLabel = "first-seen-reset@example.com" - let recorder = WeeklyLimitResetEventRecorder(provider: .claude, accountLabel: accountLabel) - defer { recorder.invalidate() } + let firstDate = Date(timeIntervalSince1970: 1_700_000_000) + let before = UsageSnapshot( + primary: RateWindow(usedPercent: 42, windowMinutes: 10080, resetsAt: nil, resetDescription: nil), + secondary: nil, + updatedAt: firstDate) + store._setSnapshotForTesting(before, provider: .zai) + let providerURL = try #require(store.planUtilizationHistoryStore.directoryURL? + .appendingPathComponent("zai.json", isDirectory: false)) + + #expect(store.settings.historicalTrackingEnabled == false) + #expect(store.supportsPlanUtilizationHistory(for: .zai) == false) + await store.recordPlanUtilizationHistorySample(provider: .zai, snapshot: before, now: before.updatedAt) + #expect(store.planUtilizationHistory(for: .zai).isEmpty) + #expect(FileManager.default.fileExists(atPath: providerURL.path) == false) - let snapshot = UsageSnapshot( - primary: RateWindow(usedPercent: 10, windowMinutes: 300, resetsAt: nil, resetDescription: nil), - secondary: RateWindow(usedPercent: 0, windowMinutes: 10080, resetsAt: nil, resetDescription: nil), - updatedAt: Date(timeIntervalSince1970: 1_700_000_000), - identity: ProviderIdentitySnapshot( - providerID: .claude, - accountEmail: accountLabel, - accountOrganization: nil, - loginMethod: "max")) + store.settings.historicalTrackingEnabled = true + #expect(store.supportsPlanUtilizationHistory(for: .zai)) + await store.recordPlanUtilizationHistorySample(provider: .zai, snapshot: before, now: before.updatedAt) + #expect(findSeries(store.planUtilizationHistory(for: .zai), name: .weekly, windowMinutes: 10080)? + .entries.map(\.usedPercent) == [42]) - await store.recordPlanUtilizationHistorySample(provider: .claude, snapshot: snapshot, now: snapshot.updatedAt) + store.settings.historicalTrackingEnabled = false + #expect(store.supportsPlanUtilizationHistory(for: .zai)) + let after = UsageSnapshot( + primary: RateWindow(usedPercent: 58, windowMinutes: 10080, resetsAt: nil, resetDescription: nil), + secondary: nil, + updatedAt: firstDate.addingTimeInterval(3600)) + await store.recordPlanUtilizationHistorySample(provider: .zai, snapshot: after, now: after.updatedAt) + #expect(findSeries(store.planUtilizationHistory(for: .zai), name: .weekly, windowMinutes: 10080)? + .entries.map(\.usedPercent) == [42]) - #expect(recorder.events.isEmpty) + for _ in 0..<20 where !FileManager.default.fileExists(atPath: providerURL.path) { + try await Task.sleep(nanoseconds: 50_000_000) + } + #expect(FileManager.default.fileExists(atPath: providerURL.path)) } @MainActor @Test - func `weekly quota celebration fires once across repeated low samples`() async { + func `generic provider persists weekly extra window`() async { let store = Self.makeStore() - let accountLabel = "repeated-low@example.com" - let recorder = WeeklyLimitResetEventRecorder(provider: .claude, accountLabel: accountLabel) - defer { recorder.invalidate() } - - let before = UsageSnapshot( - primary: RateWindow(usedPercent: 10, windowMinutes: 300, resetsAt: nil, resetDescription: nil), - secondary: RateWindow(usedPercent: 60, windowMinutes: 10080, resetsAt: nil, resetDescription: nil), - updatedAt: Date(timeIntervalSince1970: 1_700_000_000), - identity: ProviderIdentitySnapshot( - providerID: .claude, - accountEmail: accountLabel, - accountOrganization: nil, - loginMethod: "max")) - let firstLow = UsageSnapshot( - primary: RateWindow(usedPercent: 10, windowMinutes: 300, resetsAt: nil, resetDescription: nil), - secondary: RateWindow(usedPercent: 1, windowMinutes: 10080, resetsAt: nil, resetDescription: nil), - updatedAt: Date(timeIntervalSince1970: 1_700_001_800), - identity: ProviderIdentitySnapshot( - providerID: .claude, - accountEmail: accountLabel, - accountOrganization: nil, - loginMethod: "max")) - let secondLow = UsageSnapshot( - primary: RateWindow(usedPercent: 10, windowMinutes: 300, resetsAt: nil, resetDescription: nil), - secondary: RateWindow(usedPercent: 0, windowMinutes: 10080, resetsAt: nil, resetDescription: nil), - updatedAt: Date(timeIntervalSince1970: 1_700_002_100), - identity: ProviderIdentitySnapshot( - providerID: .claude, - accountEmail: accountLabel, - accountOrganization: nil, - loginMethod: "max")) + store.settings.historicalTrackingEnabled = true + let now = Date(timeIntervalSince1970: 1_700_000_000) + let snapshot = UsageSnapshot( + primary: nil, + secondary: nil, + extraRateWindows: [ + NamedRateWindow( + id: "weekly-budget", + title: "Weekly budget", + window: RateWindow( + usedPercent: 42, + windowMinutes: 10080, + resetsAt: nil, + resetDescription: nil)), + ], + updatedAt: now) - await store.recordPlanUtilizationHistorySample(provider: .claude, snapshot: before, now: before.updatedAt) - await store.recordPlanUtilizationHistorySample(provider: .claude, snapshot: firstLow, now: firstLow.updatedAt) - await store.recordPlanUtilizationHistorySample(provider: .claude, snapshot: secondLow, now: secondLow.updatedAt) + await store.recordPlanUtilizationHistorySample(provider: .zai, snapshot: snapshot, now: now) - let events = recorder.events - #expect(events.count == 1) - #expect(events[0].usedPercent == 1) + #expect(findSeries(store.planUtilizationHistory(for: .zai), name: .weekly, windowMinutes: 10080)? + .entries.map(\.usedPercent) == [42]) } @MainActor @Test - func `weekly quota celebration posts for generic provider weekly lane`() async { + func `generic provider ignores unknown weekly extra window`() async { let store = Self.makeStore() - let accountLabel = "zai-reset-org" - let recorder = WeeklyLimitResetEventRecorder(provider: .zai, accountLabel: accountLabel) - defer { recorder.invalidate() } + store.settings.historicalTrackingEnabled = true + let now = Date(timeIntervalSince1970: 1_700_000_000) + let snapshot = UsageSnapshot( + primary: nil, + secondary: nil, + extraRateWindows: [ + NamedRateWindow( + id: "weekly-reset-only", + title: "Weekly reset", + window: RateWindow( + usedPercent: 0, + windowMinutes: 10080, + resetsAt: now.addingTimeInterval(3600), + resetDescription: nil), + usageKnown: false), + ], + updatedAt: now) - let before = UsageSnapshot( - primary: RateWindow(usedPercent: 92, windowMinutes: 10080, resetsAt: nil, resetDescription: nil), - secondary: RateWindow(usedPercent: 15, windowMinutes: 300, resetsAt: nil, resetDescription: nil), - updatedAt: Date(timeIntervalSince1970: 1_700_000_000), - identity: ProviderIdentitySnapshot( - providerID: .zai, - accountEmail: nil, - accountOrganization: accountLabel, - loginMethod: "pro")) - let after = UsageSnapshot( - primary: RateWindow(usedPercent: 0, windowMinutes: 10080, resetsAt: nil, resetDescription: nil), - secondary: RateWindow(usedPercent: 15, windowMinutes: 300, resetsAt: nil, resetDescription: nil), - updatedAt: Date(timeIntervalSince1970: 1_700_003_600), - identity: ProviderIdentitySnapshot( - providerID: .zai, - accountEmail: nil, - accountOrganization: accountLabel, - loginMethod: "pro")) + await store.recordPlanUtilizationHistorySample(provider: .zed, snapshot: snapshot, now: now) - await store.recordPlanUtilizationHistorySample(provider: .zai, snapshot: before, now: before.updatedAt) - await store.recordPlanUtilizationHistorySample(provider: .zai, snapshot: after, now: after.updatedAt) + #expect(store.planUtilizationHistory(for: .zed).isEmpty) + } + + @MainActor + @Test + func `generic provider prefers standard weekly window over extra window`() async { + let store = Self.makeStore() + store.settings.historicalTrackingEnabled = true + let now = Date(timeIntervalSince1970: 1_700_000_000) + let snapshot = UsageSnapshot( + primary: nil, + secondary: RateWindow( + usedPercent: 42, + windowMinutes: 10080, + resetsAt: nil, + resetDescription: nil), + extraRateWindows: [ + NamedRateWindow( + id: "extra-weekly-budget", + title: "Extra weekly budget", + window: RateWindow( + usedPercent: 84, + windowMinutes: 10080, + resetsAt: nil, + resetDescription: nil)), + ], + updatedAt: now) + + await store.recordPlanUtilizationHistorySample(provider: .factory, snapshot: snapshot, now: now) - let events = recorder.events - #expect(events.count == 1) - #expect(events[0].provider == .zai) - #expect(events[0].accountLabel == accountLabel) - #expect(events[0].usedPercent == 0) + #expect(findSeries(store.planUtilizationHistory(for: .factory), name: .weekly, windowMinutes: 10080)? + .entries.map(\.usedPercent) == [42]) } @MainActor @@ -1049,7 +1142,7 @@ struct UsageStorePlanUtilizationTests { .appendingPathComponent("com.steipete.codexbar", isDirectory: true) .appendingPathComponent("history", isDirectory: true) let store = PlanUtilizationHistoryStore(directoryURL: directoryURL) - let buckets = PlanUtilizationHistoryBuckets( + var buckets = PlanUtilizationHistoryBuckets( preferredAccountKey: "alice", unscoped: [ planSeries(name: .session, windowMinutes: 300, entries: [ @@ -1066,12 +1159,29 @@ struct UsageStorePlanUtilizationTests { ]), ], ]) + buckets.setSessionEquivalentWindowPairIdentity("session:standard|weekly:standard", for: "alice") store.save([.codex: buckets]) let loaded = store.load() #expect(loaded == [.codex: buckets]) } + + @Test + func `store persists an invalidated pair identity without histories`() { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + let directoryURL = root + .appendingPathComponent("com.steipete.codexbar", isDirectory: true) + .appendingPathComponent("history", isDirectory: true) + let store = PlanUtilizationHistoryStore(directoryURL: directoryURL) + var buckets = PlanUtilizationHistoryBuckets() + buckets.invalidateSessionEquivalentWindowPairIdentity(for: nil) + + store.save([.zai: buckets]) + + #expect(store.load() == [.zai: buckets]) + } } extension UsageStorePlanUtilizationTests { @@ -1126,6 +1236,10 @@ extension UsageStorePlanUtilizationTests { startupBehavior: .testing) isolatedSettings._test_managedCodexAccountStoreURL = managedStoreURL isolatedSettings.codexActiveSource = .liveSystem + // Cancel the background plan-utilization decode so it cannot race the + // explicit empty assignment below. Production paths still load on the + // utility queue; this only short-circuits the test setup. + store._cancelPlanUtilizationHistoryLoadForTesting() store.planUtilizationHistory = [:] return store } @@ -1158,6 +1272,29 @@ extension UsageStorePlanUtilizationTests { } } +extension UsageStorePlanUtilizationTests { + @MainActor + @Test + func `global refresh tail does not keep completed provider plan card loading`() { + let store = Self.makeStore() + store._setSnapshotForTesting(nil, provider: .claude) + store.isRefreshing = true + store.refreshingProviders.insert(.claude) + + #expect(store.shouldShowRefreshingMenuCard(for: .claude)) + #expect(store.shouldShowRefreshingMenuCardIndicator(for: .claude)) + #expect(store.shouldHidePlanUtilizationMenuItem(for: .claude)) + + store.refreshingProviders.remove(.claude) + + #expect(store.isRefreshing) + #expect(store.refreshingProviders.isEmpty) + #expect(!store.shouldShowRefreshingMenuCard(for: .claude)) + #expect(!store.shouldShowRefreshingMenuCardIndicator(for: .claude)) + #expect(!store.shouldHidePlanUtilizationMenuItem(for: .claude)) + } +} + func planEntry(at capturedAt: Date, usedPercent: Double, resetsAt: Date? = nil) -> PlanUtilizationHistoryEntry { PlanUtilizationHistoryEntry(capturedAt: capturedAt, usedPercent: usedPercent, resetsAt: resetsAt) } @@ -1178,75 +1315,6 @@ func findSeries( histories.first { $0.name == name && $0.windowMinutes == windowMinutes } } -private final class WeeklyLimitResetEventRecorder: @unchecked Sendable { - struct Event { - let provider: UsageProvider - let accountLabel: String? - let usedPercent: Double - } - - private let provider: UsageProvider - private let accountLabel: String? - private let lock = NSLock() - private var observedEvents: [Event] = [] - private var token: NSObjectProtocol? - - init(provider: UsageProvider, accountLabel: String?) { - self.provider = provider - self.accountLabel = accountLabel - self.token = NotificationCenter.default.addObserver( - forName: .codexbarWeeklyLimitReset, - object: nil, - queue: nil) - { [weak self] notification in - guard let self, - let event = notification.object as? WeeklyLimitResetEvent - else { - return - } - - let recorded = MainActor.assumeIsolated { () -> Event? in - guard event.provider == self.provider, - event.accountLabel == self.accountLabel - else { - return nil - } - return Event( - provider: event.provider, - accountLabel: event.accountLabel, - usedPercent: event.usedPercent) - } - guard let recorded else { return } - - self.lock.lock() - self.observedEvents.append(recorded) - self.lock.unlock() - } - } - - var events: [Event] { - self.lock.lock() - defer { self.lock.unlock() } - return self.observedEvents - } - - var count: Int { - self.lock.lock() - defer { self.lock.unlock() } - return self.observedEvents.count - } - - func invalidate() { - guard let token else { return } - NotificationCenter.default.removeObserver(token) - self.token = nil - } - - deinit { - self.invalidate() - } -} - func formattedBoundary(_ date: Date) -> String { let formatter = DateFormatter() formatter.locale = Locale(identifier: "en_US_POSIX") diff --git a/Tests/CodexBarTests/UsageStoreResetBoundaryRefreshTests.swift b/Tests/CodexBarTests/UsageStoreResetBoundaryRefreshTests.swift new file mode 100644 index 0000000000..1e11a59493 --- /dev/null +++ b/Tests/CodexBarTests/UsageStoreResetBoundaryRefreshTests.swift @@ -0,0 +1,316 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +struct UsageStoreResetBoundaryRefreshTests { + @Test + func `schedules refresh at reset boundary before normal poll`() { + let now = Date(timeIntervalSince1970: 1000) + let resetsAt = now.addingTimeInterval(10 * 60) + let snapshot = Self.snapshot(updatedAt: now, primaryResetsAt: resetsAt) + + let refreshAt = UsageStore.nextResetBoundaryRefreshDate( + snapshots: [.codex: snapshot], + normalRefreshInterval: 30 * 60, + now: now) + + #expect(refreshAt == resetsAt.addingTimeInterval(UsageStore.resetBoundaryRefreshGraceSeconds)) + } + + @Test + func `schedules prompt refresh when reset boundary already passed`() { + let now = Date(timeIntervalSince1970: 2000) + let resetsAt = now.addingTimeInterval(-3 * 60) + let snapshot = Self.snapshot( + updatedAt: resetsAt.addingTimeInterval(-60), + primaryResetsAt: resetsAt) + + let refreshAt = UsageStore.nextResetBoundaryRefreshDate( + snapshots: [.codex: snapshot], + normalRefreshInterval: 30 * 60, + now: now) + + #expect(refreshAt == now.addingTimeInterval(UsageStore.resetBoundaryRefreshMinimumDelaySeconds)) + } + + @Test + func `suppresses repeated prompt refresh after attempted boundary`() { + let now = Date(timeIntervalSince1970: 2500) + let resetsAt = now.addingTimeInterval(-3 * 60) + let boundaryRefreshAt = resetsAt.addingTimeInterval(UsageStore.resetBoundaryRefreshGraceSeconds) + let snapshot = Self.snapshot( + updatedAt: resetsAt.addingTimeInterval(-60), + primaryResetsAt: resetsAt) + + let refreshAt = UsageStore.nextResetBoundaryRefreshDate( + snapshots: [.codex: snapshot], + normalRefreshInterval: 30 * 60, + attemptedBoundaryRefreshes: [boundaryRefreshAt], + now: now) + + #expect(refreshAt == nil) + } + + @Test + func `in flight boundary refresh remains retryable`() { + let now = Date(timeIntervalSince1970: 2750) + let resetsAt = now.addingTimeInterval(-3 * 60) + let boundaryRefreshAt = resetsAt.addingTimeInterval(UsageStore.resetBoundaryRefreshGraceSeconds) + let snapshot = Self.snapshot( + updatedAt: resetsAt.addingTimeInterval(-60), + primaryResetsAt: resetsAt) + + #expect(UsageStore.shouldRecordResetBoundaryAttempt(isRefreshing: true) == false) + #expect(UsageStore.shouldRecordResetBoundaryAttempt(isRefreshing: false) == true) + + let refreshAt = UsageStore.nextResetBoundaryRefreshDate( + snapshots: [.codex: snapshot], + normalRefreshInterval: 30 * 60, + attemptedBoundaryRefreshes: [], + now: now) + + #expect(refreshAt == now.addingTimeInterval(UsageStore.resetBoundaryRefreshMinimumDelaySeconds)) + + let suppressedAfterRecordedAttempt = UsageStore.nextResetBoundaryRefreshDate( + snapshots: [.codex: snapshot], + normalRefreshInterval: 30 * 60, + attemptedBoundaryRefreshes: [boundaryRefreshAt], + now: now) + + #expect(suppressedAfterRecordedAttempt == nil) + } + + @Test + @MainActor + func `in flight boundary refresh clears fired schedule marker`() async { + let now = Date(timeIntervalSince1970: 2800) + let resetsAt = now.addingTimeInterval(-3 * 60) + let boundaryRefreshAt = resetsAt.addingTimeInterval(UsageStore.resetBoundaryRefreshGraceSeconds) + let retryAt = now.addingTimeInterval(UsageStore.resetBoundaryRefreshMinimumDelaySeconds) + let snapshot = Self.snapshot( + updatedAt: resetsAt.addingTimeInterval(-60), + primaryResetsAt: resetsAt) + let settings = testSettingsStore(suiteName: "UsageStoreResetBoundaryRefreshTests-inflight-marker") + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + startupBehavior: .testing) + store.snapshots[.codex] = snapshot + store.isRefreshing = true + store.scheduledResetBoundaryRefreshAt = retryAt + + await store.runResetBoundaryRefresh(boundaryRefreshAt: boundaryRefreshAt) + + #expect(store.scheduledResetBoundaryRefreshAt == nil) + #expect(store.attemptedResetBoundaryRefreshes.isEmpty) + + store.isRefreshing = false + store.scheduleResetBoundaryRefreshIfNeeded(normalRefreshInterval: 30 * 60, now: now) + defer { store.cancelResetBoundaryRefresh() } + + #expect(store.scheduledResetBoundaryRefreshAt == retryAt) + } + + @Test + @MainActor + func `boundary refresh records its attempt before waiting for forced enrichment`() async { + let settings = testSettingsStore(suiteName: "UsageStoreResetBoundaryRefreshTests-waits-for-tail") + settings.statusChecksEnabled = false + settings.costUsageEnabled = true + settings.openAIWebAccessEnabled = false + settings.codexCookieSource = .off + for provider in UsageProvider.allCases { + guard let metadata = ProviderRegistry.shared.metadata[provider] else { continue } + settings.setProviderEnabled(provider: provider, metadata: metadata, enabled: provider == .codex) + } + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + startupBehavior: .testing) + let tokenGate = BlockingForcedTokenRefresh() + var providerRefreshes = 0 + var didObserveWait = false + store._test_providerRefreshOverride = { _ in + providerRefreshes += 1 + } + store._test_codexCreditsLoaderOverride = { + CreditsSnapshot(remaining: 25, events: [], updatedAt: Date()) + } + store._test_tokenUsageRefreshOverride = { provider, force in + guard force else { return } + await tokenGate.run( + provider: provider, + force: force, + interaction: ProviderInteractionContext.current, + refreshPhase: ProviderRefreshContext.current, + browserRetryAllowed: false) + } + store._test_forcedRefreshEnrichmentWaitObserver = { + didObserveWait = true + } + defer { + store._test_providerRefreshOverride = nil + store._test_codexCreditsLoaderOverride = nil + store._test_tokenUsageRefreshOverride = nil + store._test_forcedRefreshEnrichmentWaitObserver = nil + } + + await store.refresh(enrichmentMode: .forcedBackground) + #expect(await tokenGate.waitUntilStarted(count: 1)) + settings.costUsageEnabled = false + + let boundaryRefreshAt = Date(timeIntervalSince1970: 12345) + let boundaryTask = Task { @MainActor in + await store.runResetBoundaryRefresh(boundaryRefreshAt: boundaryRefreshAt) + } + for _ in 0..<100 where !didObserveWait { + await Task.yield() + } + + #expect(didObserveWait) + #expect(providerRefreshes == 1) + #expect(store.attemptedResetBoundaryRefreshes.contains(boundaryRefreshAt)) + + await tokenGate.resumeNext() + await boundaryTask.value + + #expect(providerRefreshes == 2) + #expect(store.attemptedResetBoundaryRefreshes.contains(boundaryRefreshAt)) + #expect(!store.hasForcedRefreshEnrichmentInFlight) + } + + @Test + @MainActor + func `boundary refresh does not reschedule unchanged stale snapshot`() async { + let settings = testSettingsStore(suiteName: "UsageStoreResetBoundaryRefreshTests-no-duplicate") + settings.refreshFrequency = .oneMinute + settings.statusChecksEnabled = false + settings.costUsageEnabled = false + settings.openAIWebAccessEnabled = false + settings.codexCookieSource = .off + for provider in UsageProvider.allCases { + guard let metadata = ProviderRegistry.shared.metadata[provider] else { continue } + settings.setProviderEnabled(provider: provider, metadata: metadata, enabled: provider == .codex) + } + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + startupBehavior: .testing) + let now = Date() + let boundaryRefreshAt = now.addingTimeInterval(1) + store.snapshots[.codex] = Self.snapshot( + updatedAt: now.addingTimeInterval(-60), + primaryResetsAt: boundaryRefreshAt.addingTimeInterval(-UsageStore.resetBoundaryRefreshGraceSeconds)) + var providerRefreshes = 0 + store._test_providerRefreshOverride = { _ in + providerRefreshes += 1 + } + defer { + store._test_providerRefreshOverride = nil + store.cancelResetBoundaryRefresh() + } + + await store.runResetBoundaryRefresh(boundaryRefreshAt: boundaryRefreshAt) + + #expect(providerRefreshes == 1) + #expect(store.attemptedResetBoundaryRefreshes.contains(boundaryRefreshAt)) + #expect(store.resetBoundaryRefreshTask == nil) + #expect(store.scheduledResetBoundaryRefreshAt == nil) + } + + @Test + func `ignores reset boundary after normal poll`() { + let now = Date(timeIntervalSince1970: 3000) + let resetsAt = now.addingTimeInterval(40 * 60) + let snapshot = Self.snapshot(updatedAt: now, primaryResetsAt: resetsAt) + + let refreshAt = UsageStore.nextResetBoundaryRefreshDate( + snapshots: [.codex: snapshot], + normalRefreshInterval: 30 * 60, + now: now) + + #expect(refreshAt == nil) + } + + @Test + func `ignores already refreshed reset boundary`() { + let now = Date(timeIntervalSince1970: 4000) + let resetsAt = now.addingTimeInterval(-3 * 60) + let snapshot = Self.snapshot(updatedAt: now, primaryResetsAt: resetsAt) + + let refreshAt = UsageStore.nextResetBoundaryRefreshDate( + snapshots: [.codex: snapshot], + normalRefreshInterval: 30 * 60, + now: now) + + #expect(refreshAt == nil) + } + + @Test + func `uses earliest boundary across secondary and extra windows`() { + let now = Date(timeIntervalSince1970: 5000) + let secondaryResetsAt = now.addingTimeInterval(8 * 60) + let extraResetsAt = now.addingTimeInterval(4 * 60) + let snapshot = UsageSnapshot( + primary: RateWindow( + usedPercent: 10, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(20 * 60), + resetDescription: nil), + secondary: RateWindow( + usedPercent: 80, + windowMinutes: 10080, + resetsAt: secondaryResetsAt, + resetDescription: nil), + tertiary: nil, + extraRateWindows: [ + NamedRateWindow( + id: "extra", + title: "Extra", + window: RateWindow( + usedPercent: 50, + windowMinutes: 60, + resetsAt: extraResetsAt, + resetDescription: nil)), + ], + updatedAt: now) + + let refreshAt = UsageStore.nextResetBoundaryRefreshDate( + snapshots: [.codex: snapshot], + normalRefreshInterval: 30 * 60, + now: now) + + #expect(refreshAt == extraResetsAt.addingTimeInterval(UsageStore.resetBoundaryRefreshGraceSeconds)) + } + + @Test + func `manual refresh cadence does not schedule boundary refresh`() { + let now = Date(timeIntervalSince1970: 6000) + let snapshot = Self.snapshot( + updatedAt: now, + primaryResetsAt: now.addingTimeInterval(10 * 60)) + + let refreshAt = UsageStore.nextResetBoundaryRefreshDate( + snapshots: [.codex: snapshot], + normalRefreshInterval: nil, + now: now) + + #expect(refreshAt == nil) + } + + private static func snapshot(updatedAt: Date, primaryResetsAt: Date) -> UsageSnapshot { + UsageSnapshot( + primary: RateWindow( + usedPercent: 100, + windowMinutes: 300, + resetsAt: primaryResetsAt, + resetDescription: nil), + secondary: nil, + tertiary: nil, + updatedAt: updatedAt) + } +} diff --git a/Tests/CodexBarTests/UsageStoreSessionQuotaTransitionTests.swift b/Tests/CodexBarTests/UsageStoreSessionQuotaTransitionTests.swift index 1798b8eb4f..8d4d21e010 100644 --- a/Tests/CodexBarTests/UsageStoreSessionQuotaTransitionTests.swift +++ b/Tests/CodexBarTests/UsageStoreSessionQuotaTransitionTests.swift @@ -21,14 +21,24 @@ struct UsageStoreSessionQuotaTransitionTests { private(set) var quotaWarningPosts: [( event: QuotaWarningEvent, provider: UsageProvider, - soundEnabled: Bool)] = [] + soundEnabled: Bool, + onScreenAlertEnabled: Bool)] = [] func post(transition: SessionQuotaTransition, provider: UsageProvider, badge _: NSNumber?) { self.posts.append((transition: transition, provider: provider)) } - func postQuotaWarning(event: QuotaWarningEvent, provider: UsageProvider, soundEnabled: Bool) { - self.quotaWarningPosts.append((event: event, provider: provider, soundEnabled: soundEnabled)) + func postQuotaWarning( + event: QuotaWarningEvent, + provider: UsageProvider, + soundEnabled: Bool, + onScreenAlertEnabled: Bool) + { + self.quotaWarningPosts.append(( + event: event, + provider: provider, + soundEnabled: soundEnabled, + onScreenAlertEnabled: onScreenAlertEnabled)) } } @@ -159,6 +169,45 @@ struct UsageStoreSessionQuotaTransitionTests { #expect(notifier.quotaWarningPosts.isEmpty) } + @Test + func `mimo balance and monthly credits do not emit quota notifications`() { + let settings = self.makeSettings(suiteName: "UsageStoreSessionQuotaTransitionTests-mimo-balance") + settings.refreshFrequency = .manual + settings.statusChecksEnabled = false + settings.sessionQuotaNotificationsEnabled = true + settings.quotaWarningNotificationsEnabled = true + settings.quotaWarningThresholds = [50, 20] + + let notifier = SessionQuotaNotifierSpy() + let store = UsageStore( + fetcher: UsageFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + sessionQuotaNotifier: notifier) + let balanceSnapshot = MiMoUsageSnapshot( + balance: 0, + currency: "USD", + updatedAt: Date()) + .toUsageSnapshot() + let tokenPlanSnapshot = MiMoUsageSnapshot( + balance: 25.51, + currency: "USD", + planCode: "standard", + tokenUsed: 100, + tokenLimit: 100, + tokenPercent: 1, + updatedAt: Date()) + .toUsageSnapshot() + + for snapshot in [balanceSnapshot, tokenPlanSnapshot] { + store.handleSessionQuotaTransition(provider: .mimo, snapshot: snapshot) + store.handleQuotaWarningTransitions(provider: .mimo, snapshot: snapshot) + } + + #expect(notifier.posts.isEmpty) + #expect(notifier.quotaWarningPosts.isEmpty) + } + @Test func `claude five hour primary still emits session quota notifications`() { let settings = self.makeSettings(suiteName: "UsageStoreSessionQuotaTransitionTests-claude-session") @@ -188,6 +237,80 @@ struct UsageStoreSessionQuotaTransitionTests { #expect(notifier.posts.map(\.provider) == [.claude]) } + @Test + func `antigravity session notification uses quota summary duration instead of family representative`() { + let settings = self.makeSettings(suiteName: "UsageStoreSessionQuotaTransitionTests-antigravity-session") + settings.refreshFrequency = .manual + settings.statusChecksEnabled = false + settings.sessionQuotaNotificationsEnabled = true + + let notifier = SessionQuotaNotifierSpy() + let store = UsageStore( + fetcher: UsageFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + sessionQuotaNotifier: notifier) + + store.handleSessionQuotaTransition( + provider: .antigravity, + snapshot: self.antigravityQuotaSummarySnapshot(sessionUsed: 20, weeklyUsed: 100)) + store.handleSessionQuotaTransition( + provider: .antigravity, + snapshot: self.antigravityQuotaSummarySnapshot(sessionUsed: 100, weeklyUsed: 100)) + + #expect(notifier.posts.map(\.provider) == [.antigravity]) + #expect(notifier.posts.map(\.transition) == [.depleted]) + } + + @Test + func `antigravity preserves session notifications for durationless legacy family lanes`() { + let settings = self.makeSettings(suiteName: "UsageStoreSessionQuotaTransitionTests-antigravity-legacy-session") + settings.refreshFrequency = .manual + settings.statusChecksEnabled = false + settings.sessionQuotaNotificationsEnabled = true + + let notifier = SessionQuotaNotifierSpy() + let store = UsageStore( + fetcher: UsageFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + sessionQuotaNotifier: notifier) + + store.handleSessionQuotaTransition( + provider: .antigravity, + snapshot: self.antigravityLegacySnapshot(geminiUsed: 20, claudeUsed: 20)) + store.handleSessionQuotaTransition( + provider: .antigravity, + snapshot: self.antigravityLegacySnapshot(geminiUsed: 20, claudeUsed: 100)) + + #expect(notifier.posts.map(\.provider) == [.antigravity]) + #expect(notifier.posts.map(\.transition) == [.depleted]) + } + + @Test + func `antigravity snapshot mode change resets session notification baseline`() { + let settings = self.makeSettings(suiteName: "UsageStoreSessionQuotaTransitionTests-antigravity-mode-change") + settings.refreshFrequency = .manual + settings.statusChecksEnabled = false + settings.sessionQuotaNotificationsEnabled = true + + let notifier = SessionQuotaNotifierSpy() + let store = UsageStore( + fetcher: UsageFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + sessionQuotaNotifier: notifier) + + store.handleSessionQuotaTransition( + provider: .antigravity, + snapshot: self.antigravityQuotaSummarySnapshot(sessionUsed: 20, weeklyUsed: 20)) + store.handleSessionQuotaTransition( + provider: .antigravity, + snapshot: self.antigravityLegacySnapshot(geminiUsed: 100, claudeUsed: 100)) + + #expect(notifier.posts.isEmpty) + } + @Test func `quota warning disabled does not post`() { let settings = self.makeSettings(suiteName: "UsageStoreSessionQuotaTransitionTests-warning-disabled") @@ -217,6 +340,7 @@ struct UsageStoreSessionQuotaTransitionTests { settings.refreshFrequency = .manual settings.statusChecksEnabled = false settings.quotaWarningNotificationsEnabled = true + settings.quotaWarningOnScreenAlertEnabled = true settings.quotaWarningThresholds = [50, 20] settings.setQuotaWarningWindowEnabled(.session, enabled: true) settings.setQuotaWarningWindowEnabled(.weekly, enabled: true) @@ -266,6 +390,7 @@ struct UsageStoreSessionQuotaTransitionTests { #expect(notifier.quotaWarningPosts.first?.event.window == .session) #expect(notifier.quotaWarningPosts.first?.event.threshold == 50) #expect(notifier.quotaWarningPosts.first?.event.accountDisplayName == "person@example.com") + #expect(notifier.quotaWarningPosts.first?.onScreenAlertEnabled == true) } @Test @@ -510,6 +635,157 @@ struct UsageStoreSessionQuotaTransitionTests { #expect(notifier.quotaWarningPosts.map(\.event.window) == [.weekly]) } + @Test + func `minimax quota warning posts for session and weekly windows`() { + let settings = self.makeSettings(suiteName: "UsageStoreSessionQuotaTransitionTests-warning-minimax") + settings.refreshFrequency = .manual + settings.statusChecksEnabled = false + settings.quotaWarningNotificationsEnabled = true + settings.quotaWarningThresholds = [50, 20] + settings.setQuotaWarningWindowEnabled(.session, enabled: true) + settings.setQuotaWarningWindowEnabled(.weekly, enabled: true) + + let notifier = SessionQuotaNotifierSpy() + let store = UsageStore( + fetcher: UsageFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + sessionQuotaNotifier: notifier) + + store.handleQuotaWarningTransitions( + provider: .minimax, + snapshot: self.minimaxSnapshot(sessionUsed: 40, weeklyUsed: 40)) + store.handleQuotaWarningTransitions( + provider: .minimax, + snapshot: self.minimaxSnapshot(sessionUsed: 55, weeklyUsed: 55)) + + #expect(notifier.quotaWarningPosts.map(\.provider) == [.minimax, .minimax]) + #expect(notifier.quotaWarningPosts.map(\.event.window) == [.session, .weekly]) + #expect(notifier.quotaWarningPosts.map(\.event.threshold) == [50, 50]) + } + + @Test + func `amp subscription quota warnings use pool labels`() { + let settings = self.makeSettings(suiteName: "UsageStoreSessionQuotaTransitionTests-warning-amp") + settings.refreshFrequency = .manual + settings.statusChecksEnabled = false + settings.quotaWarningNotificationsEnabled = true + settings.quotaWarningThresholds = [50] + settings.setQuotaWarningWindowEnabled(.session, enabled: true) + settings.setQuotaWarningWindowEnabled(.weekly, enabled: true) + + let notifier = SessionQuotaNotifierSpy() + let store = UsageStore( + fetcher: UsageFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + sessionQuotaNotifier: notifier) + + func snapshot(used: Double) -> UsageSnapshot { + UsageSnapshot( + primary: RateWindow(usedPercent: used, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: RateWindow(usedPercent: used, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + ampUsage: AmpUsageDetails( + individualCredits: nil, + workspaceBalances: [], + subscriptionPlan: "Megawatt"), + updatedAt: Date()) + } + store.handleQuotaWarningTransitions(provider: .amp, snapshot: snapshot(used: 40)) + store.handleQuotaWarningTransitions(provider: .amp, snapshot: snapshot(used: 55)) + + #expect(notifier.quotaWarningPosts.map(\.event.windowDisplayLabel) == ["Other usage", "Orb usage"]) + } + + @Test + func `antigravity quota warnings use named session and weekly durations`() { + let settings = self.makeSettings(suiteName: "UsageStoreSessionQuotaTransitionTests-warning-antigravity") + settings.refreshFrequency = .manual + settings.statusChecksEnabled = false + settings.quotaWarningNotificationsEnabled = true + settings.quotaWarningThresholds = [50] + settings.setQuotaWarningWindowEnabled(.session, enabled: true) + settings.setQuotaWarningWindowEnabled(.weekly, enabled: true) + + let notifier = SessionQuotaNotifierSpy() + let store = UsageStore( + fetcher: UsageFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + sessionQuotaNotifier: notifier) + + store.handleQuotaWarningTransitions( + provider: .antigravity, + snapshot: self.antigravityQuotaSummarySnapshot(sessionUsed: 40, weeklyUsed: 40)) + store.handleQuotaWarningTransitions( + provider: .antigravity, + snapshot: self.antigravityQuotaSummarySnapshot(sessionUsed: 60, weeklyUsed: 60)) + + #expect(notifier.quotaWarningPosts.map(\.provider) == [.antigravity, .antigravity]) + #expect(notifier.quotaWarningPosts.map(\.event.window) == [.session, .weekly]) + #expect(notifier.quotaWarningPosts.map(\.event.threshold) == [50, 50]) + } + + @Test + func `antigravity legacy quota warnings do not infer weekly from family slots`() { + let settings = self.makeSettings(suiteName: "UsageStoreSessionQuotaTransitionTests-warning-antigravity-legacy") + settings.refreshFrequency = .manual + settings.statusChecksEnabled = false + settings.quotaWarningNotificationsEnabled = true + settings.quotaWarningThresholds = [50] + settings.setQuotaWarningWindowEnabled(.session, enabled: true) + settings.setQuotaWarningWindowEnabled(.weekly, enabled: true) + + let notifier = SessionQuotaNotifierSpy() + let store = UsageStore( + fetcher: UsageFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + sessionQuotaNotifier: notifier) + + store.handleQuotaWarningTransitions( + provider: .antigravity, + snapshot: self.antigravityLegacySnapshot(geminiUsed: 40, claudeUsed: 40)) + store.handleQuotaWarningTransitions( + provider: .antigravity, + snapshot: self.antigravityLegacySnapshot(geminiUsed: 60, claudeUsed: 60)) + + #expect(notifier.quotaWarningPosts.map(\.event.window) == [.session]) + } + + @Test + func `antigravity quota warning mode change resets warning baseline`() { + let settings = self.makeSettings(suiteName: "UsageStoreSessionQuotaTransitionTests-warning-antigravity-mode") + settings.refreshFrequency = .manual + settings.statusChecksEnabled = false + settings.quotaWarningNotificationsEnabled = true + settings.quotaWarningThresholds = [50] + settings.setQuotaWarningWindowEnabled(.session, enabled: true) + settings.setQuotaWarningWindowEnabled(.weekly, enabled: true) + + let notifier = SessionQuotaNotifierSpy() + let store = UsageStore( + fetcher: UsageFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + sessionQuotaNotifier: notifier) + + store.handleQuotaWarningTransitions( + provider: .antigravity, + snapshot: self.antigravityQuotaSummarySnapshot(sessionUsed: 20, weeklyUsed: 20)) + store.handleQuotaWarningTransitions( + provider: .antigravity, + snapshot: self.antigravityLegacySnapshot(geminiUsed: 80, claudeUsed: 40)) + + #expect(notifier.quotaWarningPosts.isEmpty) + let key = UsageStore.QuotaWarningStateKey( + provider: .antigravity, + window: .session, + accountDiscriminator: nil) + #expect(store.quotaWarningState[key]?.lastRemaining == 20) + #expect(store.quotaWarningState[key]?.source == .antigravityLegacy) + } + @Test func `disabling quota warning window clears fired state`() { let settings = self @@ -548,6 +824,179 @@ struct UsageStoreSessionQuotaTransitionTests { updatedAt: Date())) #expect(notifier.quotaWarningPosts.count == 1) - #expect(store.quotaWarningState[UsageStore.QuotaWarningStateKey(provider: .codex, window: .session)] == nil) + #expect(store.quotaWarningState[ + UsageStore.QuotaWarningStateKey(provider: .codex, window: .session, accountDiscriminator: nil), + ] == nil) + } + + private func minimaxSnapshot(sessionUsed: Double, weeklyUsed: Double) -> UsageSnapshot { + let now = Date() + return MiniMaxUsageSnapshot( + planName: "Plus", + availablePrompts: nil, + currentPrompts: nil, + remainingPrompts: nil, + windowMinutes: nil, + usedPercent: nil, + resetsAt: nil, + updatedAt: now, + services: [ + MiniMaxServiceUsage( + serviceType: "text-generation", + windowType: "5 hours", + timeRange: "15:00-20:00(UTC+8)", + usage: Int(sessionUsed), + limit: 100, + percent: sessionUsed, + resetsAt: now.addingTimeInterval(3600), + resetDescription: "Resets in 1 hour"), + MiniMaxServiceUsage( + serviceType: "text-generation", + windowType: "Weekly", + timeRange: "06/01 00:00 - 06/08 00:00(UTC+8)", + usage: Int(weeklyUsed), + limit: 100, + percent: weeklyUsed, + resetsAt: now.addingTimeInterval(6 * 24 * 3600), + resetDescription: "Resets in 6 days"), + ]).toUsageSnapshot() + } + + private func antigravityQuotaSummarySnapshot(sessionUsed: Double, weeklyUsed: Double) -> UsageSnapshot { + UsageSnapshot( + primary: RateWindow( + usedPercent: weeklyUsed, + windowMinutes: 7 * 24 * 60, + resetsAt: nil, + resetDescription: nil), + secondary: RateWindow( + usedPercent: sessionUsed, + windowMinutes: 5 * 60, + resetsAt: nil, + resetDescription: nil), + tertiary: nil, + extraRateWindows: [ + NamedRateWindow( + id: "antigravity-quota-summary-gemini-5h", + title: "Gemini Models Five Hour Limit", + window: RateWindow( + usedPercent: sessionUsed, + windowMinutes: 5 * 60, + resetsAt: nil, + resetDescription: nil)), + NamedRateWindow( + id: "antigravity-quota-summary-gemini-weekly", + title: "Gemini Models Weekly Limit", + window: RateWindow( + usedPercent: weeklyUsed, + windowMinutes: 7 * 24 * 60, + resetsAt: nil, + resetDescription: nil)), + ], + updatedAt: Date()) + } + + private func antigravityLegacySnapshot(geminiUsed: Double, claudeUsed: Double) -> UsageSnapshot { + UsageSnapshot( + primary: RateWindow( + usedPercent: geminiUsed, + windowMinutes: nil, + resetsAt: nil, + resetDescription: nil), + secondary: RateWindow( + usedPercent: claudeUsed, + windowMinutes: nil, + resetsAt: nil, + resetDescription: nil), + updatedAt: Date()) + } +} + +@MainActor +struct CrofQuotaNotificationTests { + private func makeSettings(suiteName: String) -> SettingsStore { + let defaults = UserDefaults(suiteName: suiteName)! + defaults.removePersistentDomain(forName: suiteName) + return SettingsStore( + userDefaults: defaults, + configStore: testConfigStore(suiteName: suiteName), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + } + + @Test + func `crof credits-only balance depletion and top-up do not emit quota notifications`() { + let settings = self.makeSettings(suiteName: "UsageStoreSessionQuotaTransitionTests-crof-balance") + settings.refreshFrequency = .manual + settings.statusChecksEnabled = false + settings.sessionQuotaNotificationsEnabled = true + settings.quotaWarningNotificationsEnabled = true + settings.quotaWarningThresholds = [50, 20] + + let notifier = UsageStoreSessionQuotaTransitionTests.SessionQuotaNotifierSpy() + let store = UsageStore( + fetcher: UsageFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + sessionQuotaNotifier: notifier) + + let funded = CrofUsageSnapshot(credits: 9.0441, updatedAt: Date()).toUsageSnapshot() + let depleted = CrofUsageSnapshot(credits: 0, updatedAt: Date()).toUsageSnapshot() + let toppedUp = CrofUsageSnapshot(credits: 5, updatedAt: Date()).toUsageSnapshot() + + #expect(funded.secondary == nil) + #expect(funded.primary?.windowMinutes == nil) + #expect(depleted.primary?.usedPercent == 100) + + for snapshot in [funded, depleted, toppedUp] { + store.handleSessionQuotaTransition(provider: .crof, snapshot: snapshot) + store.handleQuotaWarningTransitions(provider: .crof, snapshot: snapshot) + } + + #expect(notifier.posts.isEmpty) + #expect(notifier.quotaWarningPosts.isEmpty) + } + + @Test + func `crof quota-backed session window still emits session quota notifications`() { + let settings = self.makeSettings(suiteName: "UsageStoreSessionQuotaTransitionTests-crof-quota") + settings.refreshFrequency = .manual + settings.statusChecksEnabled = false + settings.sessionQuotaNotificationsEnabled = true + + let notifier = UsageStoreSessionQuotaTransitionTests.SessionQuotaNotifierSpy() + let store = UsageStore( + fetcher: UsageFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + sessionQuotaNotifier: notifier) + + // Quota-backed Crof shape: request-quota primary + credits secondary. + let credits = RateWindow( + usedPercent: 0, + windowMinutes: nil, + resetsAt: nil, + resetDescription: "$10.00") + let baseline = UsageSnapshot( + primary: RateWindow( + usedPercent: 20, + windowMinutes: 5 * 60, + resetsAt: nil, + resetDescription: "800 requests left"), + secondary: credits, + updatedAt: Date()) + store.handleSessionQuotaTransition(provider: .crof, snapshot: baseline) + + let depleted = UsageSnapshot( + primary: RateWindow( + usedPercent: 100, + windowMinutes: 5 * 60, + resetsAt: nil, + resetDescription: "0 requests left"), + secondary: credits, + updatedAt: Date()) + store.handleSessionQuotaTransition(provider: .crof, snapshot: depleted) + + #expect(notifier.posts.map(\.provider) == [.crof]) } } diff --git a/Tests/CodexBarTests/UsageStoreTimeoutTests.swift b/Tests/CodexBarTests/UsageStoreTimeoutTests.swift new file mode 100644 index 0000000000..0b3a8e60de --- /dev/null +++ b/Tests/CodexBarTests/UsageStoreTimeoutTests.swift @@ -0,0 +1,61 @@ +import Foundation +import Testing +@testable import CodexBar + +struct UsageStoreTimeoutTests { + private final class ProbeGate: @unchecked Sendable { + private let lock = NSLock() + private var continuation: CheckedContinuation? + private var released = false + + func wait() async { + await withCheckedContinuation { continuation in + let shouldResume = self.lock.withLock { + guard !self.released else { return true } + self.continuation = continuation + return false + } + if shouldResume { + continuation.resume() + } + } + } + + func release() { + let continuation = self.lock.withLock { + self.released = true + let continuation = self.continuation + self.continuation = nil + return continuation + } + continuation?.resume() + } + + var isReleased: Bool { + self.lock.withLock { self.released } + } + } + + @Test + func `timeout does not wait for a cancellation ignoring probe`() async { + let gate = ProbeGate() + defer { gate.release() } + + let result = await UsageStore.runWithTimeout(seconds: 0.03) { + await gate.wait() + return "late result" + } + + #expect(result == "Probe timed out after 0s") + #expect(!gate.isReleased) + } + + @Test + func `completed probe wins timeout race`() async { + let result = await UsageStore.runWithTimeout(seconds: 10) { + "probe result" + } + + #expect(result == "probe result") + } +} diff --git a/Tests/CodexBarTests/UsageStoreTokenRefreshCadenceTests.swift b/Tests/CodexBarTests/UsageStoreTokenRefreshCadenceTests.swift new file mode 100644 index 0000000000..2de7cd1374 --- /dev/null +++ b/Tests/CodexBarTests/UsageStoreTokenRefreshCadenceTests.swift @@ -0,0 +1,30 @@ +import Foundation +import Testing +@testable import CodexBar + +@MainActor +struct UsageStoreTokenRefreshCadenceTests { + @Test(arguments: [ + (RefreshFrequency.oneMinute, 300.0), + (.twoMinutes, 300.0), + (.fiveMinutes, 300.0), + (.fifteenMinutes, 900.0), + (.thirtyMinutes, 1800.0), + ]) + func `fixed refresh frequencies derive a widget-safe token TTL`( + frequency: RefreshFrequency, + expectedSeconds: TimeInterval) + { + #expect(UsageStore.tokenFetchTTL(for: frequency) == expectedSeconds) + } + + @Test(arguments: [RefreshFrequency.adaptive, .adaptiveAgentAware]) + func `adaptive refresh frequencies use the policy nominal interval`(frequency: RefreshFrequency) { + #expect(UsageStore.tokenFetchTTL(for: frequency) == AdaptiveRefreshPolicy.nominalIntervalForHeuristics) + } + + @Test + func `manual refresh disables the automatic token cadence`() { + #expect(UsageStore.tokenFetchTTL(for: .manual) == nil) + } +} diff --git a/Tests/CodexBarTests/UsageStoreTokenRetryPolicyTests.swift b/Tests/CodexBarTests/UsageStoreTokenRetryPolicyTests.swift new file mode 100644 index 0000000000..0648eaac41 --- /dev/null +++ b/Tests/CodexBarTests/UsageStoreTokenRetryPolicyTests.swift @@ -0,0 +1,12 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +struct UsageStoreTokenRetryPolicyTests { + @Test + func `timed out token scans keep the fetch TTL while fast failures retry early`() { + #expect(!UsageStore.tokenFetchFailureAllowsEarlyRetry(CostUsageError.timedOut(seconds: 600))) + #expect(UsageStore.tokenFetchFailureAllowsEarlyRetry(CocoaError(.fileReadNoSuchFile))) + } +} diff --git a/Tests/CodexBarTests/UsageStoreWidgetSnapshotAccountTests.swift b/Tests/CodexBarTests/UsageStoreWidgetSnapshotAccountTests.swift new file mode 100644 index 0000000000..6b25694f72 --- /dev/null +++ b/Tests/CodexBarTests/UsageStoreWidgetSnapshotAccountTests.swift @@ -0,0 +1,322 @@ +import Foundation +import Testing +@testable import CodexBar +@testable import CodexBarCore + +@MainActor +struct UsageStoreWidgetSnapshotAccountTests { + @Test + func `legacy ownerless Claude quota is not preserved after upgrade`() async throws { + let suite = "UsageStoreWidgetSnapshotTests-claude-legacy-ownerless-drops-quota" + 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 + + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings) + let quotaUpdatedAt = Date(timeIntervalSince1970: 1_800_000_000) + let tokenUpdatedAt = quotaUpdatedAt.addingTimeInterval(60) + let quota = RateWindow( + usedPercent: 28, + windowMinutes: 300, + resetsAt: quotaUpdatedAt.addingTimeInterval(3600), + resetDescription: nil) + store.lastQueuedWidgetSnapshot = WidgetSnapshot( + entries: [ + WidgetSnapshot.ProviderEntry( + provider: .claude, + updatedAt: quotaUpdatedAt, + primary: quota, + secondary: nil, + tertiary: nil, + usageRows: [ + WidgetSnapshot.WidgetUsageRowSnapshot( + id: "primary", + title: "Session", + percentLeft: 72), + ], + creditsRemaining: nil, + codeReviewRemainingPercent: nil, + tokenUsage: nil, + dailyUsage: []), + ], + enabledProviders: [.claude], + generatedAt: quotaUpdatedAt) + store._setTokenSnapshotForTesting( + CostUsageTokenSnapshot( + sessionTokens: 4300, + sessionCostUSD: 1.50, + last30DaysTokens: 43000, + last30DaysCostUSD: 13.50, + daily: [], + updatedAt: tokenUpdatedAt), + provider: .claude) + + var widgetSnapshots: [WidgetSnapshot] = [] + store._test_widgetSnapshotSaveOverride = { widgetSnapshots.append($0) } + defer { store._test_widgetSnapshotSaveOverride = nil } + + store.persistWidgetSnapshot(reason: "claude-legacy-ownerless-drops-quota-test") + await store.widgetSnapshotPersistTask?.value + + let entry = try #require(widgetSnapshots.last?.entries.first { $0.provider == .claude }) + #expect(entry.primary == nil) + #expect(entry.usageRows?.isEmpty == true) + #expect(entry.quotaOwnerKey == nil) + #expect(entry.tokenUsage?.sessionTokens == 4300) + } + + @Test + func `widget snapshot does not preserve Claude quota across OAuth profiles`() async throws { + let suiteA = "UsageStoreWidgetSnapshotTests-claude-oauth-profile-a" + let defaultsA = try #require(UserDefaults(suiteName: suiteA)) + defaultsA.removePersistentDomain(forName: suiteA) + let settingsA = SettingsStore( + userDefaults: defaultsA, + configStore: testConfigStore(suiteName: suiteA), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + settingsA.statusChecksEnabled = false + + let environmentA = ["CLAUDE_CONFIG_DIR": "/tmp/codexbar-widget-profile-a"] + let storeA = UsageStore( + fetcher: UsageFetcher(environment: environmentA), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settingsA, + environmentBase: environmentA) + let quotaUpdatedAt = Date(timeIntervalSince1970: 1_800_000_000) + let tokenUpdatedAt = quotaUpdatedAt.addingTimeInterval(60) + let quota = RateWindow( + usedPercent: 28, + windowMinutes: 300, + resetsAt: quotaUpdatedAt.addingTimeInterval(3600), + resetDescription: nil) + storeA._setSnapshotForTesting( + UsageSnapshot(primary: quota, secondary: nil, updatedAt: quotaUpdatedAt), + provider: .claude) + + var profileASnapshots: [WidgetSnapshot] = [] + storeA._test_widgetSnapshotSaveOverride = { profileASnapshots.append($0) } + defer { storeA._test_widgetSnapshotSaveOverride = nil } + ClaudeOAuthCredentialsStore.withEnvironmentCredentialsURLForTesting { + storeA.persistWidgetSnapshot(reason: "claude-oauth-profile-a-primes-quota-test") + } + await storeA.widgetSnapshotPersistTask?.value + let profileAEntry = try #require(profileASnapshots.last?.entries.first { $0.provider == .claude }) + let profileAOwner = try #require(profileAEntry.quotaOwnerKey) + + let suiteB = "UsageStoreWidgetSnapshotTests-claude-oauth-profile-b" + let defaultsB = try #require(UserDefaults(suiteName: suiteB)) + defaultsB.removePersistentDomain(forName: suiteB) + let settingsB = SettingsStore( + userDefaults: defaultsB, + configStore: testConfigStore(suiteName: suiteB), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + settingsB.statusChecksEnabled = false + + let environmentB = ["CLAUDE_CONFIG_DIR": "/tmp/codexbar-widget-profile-b"] + let profileBOwner = ClaudeOAuthCredentialsStore.withEnvironmentCredentialsURLForTesting { + ClaudeOAuthCredentialsStore.credentialsProfileIdentifier(environment: environmentB) + } + #expect(profileAOwner != profileBOwner) + + let storeB = UsageStore( + fetcher: UsageFetcher(environment: environmentB), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settingsB, + environmentBase: environmentB) + storeB.lastQueuedWidgetSnapshot = WidgetSnapshot( + entries: [profileAEntry], + enabledProviders: [.claude], + generatedAt: quotaUpdatedAt) + storeB._setTokenSnapshotForTesting( + CostUsageTokenSnapshot( + sessionTokens: 4300, + sessionCostUSD: 1.50, + last30DaysTokens: 43000, + last30DaysCostUSD: 13.50, + daily: [], + updatedAt: tokenUpdatedAt), + provider: .claude) + + var profileBSnapshots: [WidgetSnapshot] = [] + storeB._test_widgetSnapshotSaveOverride = { profileBSnapshots.append($0) } + defer { storeB._test_widgetSnapshotSaveOverride = nil } + ClaudeOAuthCredentialsStore.withEnvironmentCredentialsURLForTesting { + storeB.persistWidgetSnapshot(reason: "claude-oauth-profile-change-drops-quota-test") + } + await storeB.widgetSnapshotPersistTask?.value + + let entry = try #require(profileBSnapshots.last?.entries.first { $0.provider == .claude }) + #expect(entry.primary == nil) + #expect(entry.usageRows?.isEmpty == true) + #expect(entry.quotaOwnerKey == nil) + #expect(entry.tokenUsage?.sessionTokens == 4300) + } + + @Test + func `widget snapshot does not preserve Claude quota after selected account changes`() async throws { + let suite = "UsageStoreWidgetSnapshotTests-claude-account-change-drops-quota" + 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.addTokenAccount(provider: .claude, label: "Primary", token: "primary-token") + settings.addTokenAccount(provider: .claude, label: "Secondary", token: "secondary-token") + settings.setActiveTokenAccountIndex(0, for: .claude) + + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings) + let accounts = settings.tokenAccounts(for: .claude) + let primaryAccount = try #require(accounts.first) + let quotaOwnerKey = store.tokenAccountSnapshotCacheKey(provider: .claude, account: primaryAccount) + let quotaUpdatedAt = Date(timeIntervalSince1970: 1_800_000_000) + let tokenUpdatedAt = quotaUpdatedAt.addingTimeInterval(60) + let quota = RateWindow( + usedPercent: 28, + windowMinutes: 300, + resetsAt: quotaUpdatedAt.addingTimeInterval(3600), + resetDescription: nil) + store.lastQueuedWidgetSnapshot = WidgetSnapshot( + entries: [ + WidgetSnapshot.ProviderEntry( + provider: .claude, + updatedAt: quotaUpdatedAt, + primary: quota, + secondary: nil, + tertiary: nil, + usageRows: [ + WidgetSnapshot.WidgetUsageRowSnapshot( + id: "primary", + title: "Session", + percentLeft: 72), + ], + creditsRemaining: nil, + codeReviewRemainingPercent: nil, + tokenUsage: nil, + dailyUsage: [], + quotaOwnerKey: quotaOwnerKey), + ], + enabledProviders: [.claude], + generatedAt: quotaUpdatedAt) + + settings.setActiveTokenAccountIndex(1, for: .claude) + store._setTokenSnapshotForTesting( + CostUsageTokenSnapshot( + sessionTokens: 4300, + sessionCostUSD: 1.50, + last30DaysTokens: 43000, + last30DaysCostUSD: 13.50, + daily: [], + updatedAt: tokenUpdatedAt), + provider: .claude) + + var widgetSnapshots: [WidgetSnapshot] = [] + store._test_widgetSnapshotSaveOverride = { widgetSnapshots.append($0) } + defer { store._test_widgetSnapshotSaveOverride = nil } + + store.persistWidgetSnapshot(reason: "claude-account-change-drops-quota-test") + await store.widgetSnapshotPersistTask?.value + + let entry = try #require(widgetSnapshots.last?.entries.first { $0.provider == .claude }) + #expect(entry.primary == nil) + #expect(entry.usageRows?.isEmpty == true) + #expect(entry.quotaOwnerKey == nil) + #expect(entry.tokenUsage?.sessionTokens == 4300) + } + + @Test + func `stacked Claude account success re-enables quota preservation`() async throws { + let suite = "UsageStoreWidgetSnapshotTests-claude-stacked-success-unblocks-quota" + 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.multiAccountMenuLayout = .stacked + settings.addTokenAccount(provider: .claude, label: "Primary", token: "primary-token") + settings.addTokenAccount(provider: .claude, label: "Secondary", token: "secondary-token") + settings.setActiveTokenAccountIndex(0, for: .claude) + + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings) + let account = try #require(settings.effectiveSelectedTokenAccount(for: .claude)) + let quotaUpdatedAt = Date(timeIntervalSince1970: 1_800_000_000) + let tokenUpdatedAt = quotaUpdatedAt.addingTimeInterval(60) + let quota = RateWindow( + usedPercent: 28, + windowMinutes: 300, + resetsAt: quotaUpdatedAt.addingTimeInterval(3600), + resetDescription: nil) + let outcome = ProviderFetchOutcome( + result: .success(ProviderFetchResult( + usage: UsageSnapshot( + primary: quota, + secondary: nil, + updatedAt: quotaUpdatedAt), + credits: nil, + dashboard: nil, + sourceLabel: "fixture", + strategyID: "fixture.api-token", + strategyKind: .apiToken)), + attempts: []) + + store.widgetUsagePreservationBlockedProviders.insert(.claude) + await store.applySelectedOutcome( + outcome, + provider: .claude, + account: account, + fallbackSnapshot: nil) + #expect(!store.widgetUsagePreservationBlockedProviders.contains(.claude)) + + var widgetSnapshots: [WidgetSnapshot] = [] + store._test_widgetSnapshotSaveOverride = { widgetSnapshots.append($0) } + defer { store._test_widgetSnapshotSaveOverride = nil } + + store.persistWidgetSnapshot(reason: "claude-stacked-success-primes-quota-test") + await store.widgetSnapshotPersistTask?.value + let freshEntry = try #require(widgetSnapshots.last?.entries.first { $0.provider == .claude }) + #expect(freshEntry.primary == quota) + #expect(freshEntry.quotaOwnerKey != nil) + + store.snapshots.removeValue(forKey: .claude) + store._setTokenSnapshotForTesting( + CostUsageTokenSnapshot( + sessionTokens: 4300, + sessionCostUSD: 1.50, + last30DaysTokens: 43000, + last30DaysCostUSD: 13.50, + daily: [], + updatedAt: tokenUpdatedAt), + provider: .claude) + store.persistWidgetSnapshot(reason: "claude-stacked-success-preserves-quota-test") + await store.widgetSnapshotPersistTask?.value + + let preservedEntry = try #require(widgetSnapshots.last?.entries.first { $0.provider == .claude }) + #expect(preservedEntry.primary == quota) + #expect(preservedEntry.quotaOwnerKey == freshEntry.quotaOwnerKey) + #expect(preservedEntry.tokenUsage?.sessionTokens == 4300) + } +} diff --git a/Tests/CodexBarTests/UsageStoreWidgetSnapshotTests.swift b/Tests/CodexBarTests/UsageStoreWidgetSnapshotTests.swift index dcc7978003..d7bb0566a2 100644 --- a/Tests/CodexBarTests/UsageStoreWidgetSnapshotTests.swift +++ b/Tests/CodexBarTests/UsageStoreWidgetSnapshotTests.swift @@ -6,8 +6,55 @@ import Testing @MainActor struct UsageStoreWidgetSnapshotTests { @Test - func `widget snapshot includes antigravity tertiary usage row`() async throws { - let suite = "UsageStoreWidgetSnapshotTests-antigravity-tertiary" + func `widget snapshot preserves raw Codex windows for timeline projection`() async throws { + let suite = "UsageStoreWidgetSnapshotTests-codex-weekly-cap" + 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 + + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings) + let now = Date() + store._setSnapshotForTesting( + UsageSnapshot( + primary: RateWindow( + usedPercent: 1, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(1800), + resetDescription: nil), + secondary: RateWindow( + usedPercent: 100, + windowMinutes: 10080, + resetsAt: now.addingTimeInterval(3600), + resetDescription: nil), + updatedAt: now.addingTimeInterval(-7200)), + provider: .codex) + + var widgetSnapshots: [WidgetSnapshot] = [] + store._test_widgetSnapshotSaveOverride = { widgetSnapshots.append($0) } + defer { store._test_widgetSnapshotSaveOverride = nil } + + store.persistWidgetSnapshot(reason: "codex-weekly-cap-test") + await store.widgetSnapshotPersistTask?.value + + let entry = try #require(widgetSnapshots.last?.entries.first { $0.provider == .codex }) + #expect(entry.usageRows?.map(\.id) == ["session", "weekly"]) + #expect(entry.usageRows?.compactMap(\.percentLeft) == [99, 0]) + #expect(entry.usageRows?.first?.window?.usedPercent == 1) + #expect(entry.usageRows?.last?.window?.resetsAt == now.addingTimeInterval(3600)) + } + + @Test + func `widget snapshot includes Kimi subscription quota rows`() async throws { + let suite = "UsageStoreWidgetSnapshotTests-kimi-subscription-rows" let defaults = try #require(UserDefaults(suiteName: suite)) defaults.removePersistentDomain(forName: suite) @@ -18,6 +65,76 @@ struct UsageStoreWidgetSnapshotTests { syntheticTokenStore: NoopSyntheticTokenStore()) settings.statusChecksEnabled = false + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings) + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 25, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: RateWindow(usedPercent: 50, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + tertiary: nil, + extraRateWindows: [ + NamedRateWindow( + id: "kimi-code-7d", + title: "Code 7-day", + window: RateWindow( + usedPercent: 10, + windowMinutes: 7 * 24 * 60, + resetsAt: nil, + resetDescription: nil)), + NamedRateWindow( + id: "kimi-future-quota", + title: "Future quota", + window: RateWindow( + usedPercent: 5, + windowMinutes: nil, + resetsAt: nil, + resetDescription: nil)), + NamedRateWindow( + id: "kimi-monthly", + title: "Monthly", + window: RateWindow( + usedPercent: 75, + windowMinutes: nil, + resetsAt: nil, + resetDescription: nil)), + ], + updatedAt: Date(), + identity: ProviderIdentitySnapshot( + providerID: .kimi, + accountEmail: nil, + accountOrganization: nil, + loginMethod: nil)) + store._setSnapshotForTesting(snapshot, provider: .kimi) + + var widgetSnapshots: [WidgetSnapshot] = [] + store._test_widgetSnapshotSaveOverride = { widgetSnapshots.append($0) } + defer { store._test_widgetSnapshotSaveOverride = nil } + + store.persistWidgetSnapshot(reason: "kimi-subscription-rows-test") + await store.widgetSnapshotPersistTask?.value + + let entry = try #require(widgetSnapshots.last?.entries.first { $0.provider == .kimi }) + // Widgets preserve persisted lane order; menu-only presentation may reorder these lanes. + #expect(entry.usageRows?.map(\.id) == ["primary", "secondary", "kimi-monthly", "kimi-code-7d"]) + #expect(entry.usageRows?.map(\.title) == ["Weekly", "Rate Limit", "Monthly", "Code 7-day"]) + #expect(entry.usageRows?.compactMap(\.percentLeft) == [75, 50, 25, 90]) + } + + @Test + func `widget snapshot includes antigravity grouped usage rows`() async throws { + let suite = "UsageStoreWidgetSnapshotTests-antigravity-grouped" + 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.usageBarsShowUsed = true + let store = UsageStore( fetcher: UsageFetcher(environment: [:]), browserDetection: BrowserDetection(cacheTTL: 0), @@ -39,12 +156,597 @@ struct UsageStoreWidgetSnapshotTests { store._test_widgetSnapshotSaveOverride = { widgetSnapshots.append($0) } defer { store._test_widgetSnapshotSaveOverride = nil } - store.persistWidgetSnapshot(reason: "antigravity-tertiary-test") + store.persistWidgetSnapshot(reason: "antigravity-grouped-test") + await store.widgetSnapshotPersistTask?.value + + let entry = try #require(widgetSnapshots.last?.entries.first { $0.provider == .antigravity }) + #expect(widgetSnapshots.last?.usageBarsShowUsed == true) + #expect(entry.usageRows?.map(\.id) == ["primary", "secondary"]) + #expect(entry.usageRows?.map(\.title) == ["Gemini Models", "Claude and GPT"]) + #expect(entry.usageRows?.compactMap(\.percentLeft) == [90, 80]) + } + + @Test + func `widget snapshot includes antigravity quota summary rows`() async throws { + let suite = "UsageStoreWidgetSnapshotTests-antigravity-quota-summary" + 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 + + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings) + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 27, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + secondary: nil, + tertiary: nil, + extraRateWindows: [ + NamedRateWindow( + id: "antigravity-quota-summary-gemini-5h", + title: "Gemini Models Five Hour Limit", + window: RateWindow(usedPercent: 9, windowMinutes: 300, resetsAt: nil, resetDescription: nil)), + NamedRateWindow( + id: "antigravity-quota-summary-gemini-weekly", + title: "Gemini Models Weekly Limit", + window: RateWindow(usedPercent: 18, windowMinutes: 10080, resetsAt: nil, resetDescription: nil)), + NamedRateWindow( + id: "antigravity-quota-summary-3p-5h", + title: "Claude and GPT models Five Hour Limit", + window: RateWindow(usedPercent: 27, windowMinutes: 300, resetsAt: nil, resetDescription: nil)), + NamedRateWindow( + id: "antigravity-quota-summary-3p-weekly", + title: "Claude and GPT models Weekly Limit", + window: RateWindow(usedPercent: 36, windowMinutes: 10080, resetsAt: nil, resetDescription: nil)), + ], + updatedAt: Date(), + identity: ProviderIdentitySnapshot( + providerID: .antigravity, + accountEmail: nil, + accountOrganization: nil, + loginMethod: "Pro")) + + store._setSnapshotForTesting(snapshot, provider: .antigravity) + + var widgetSnapshots: [WidgetSnapshot] = [] + store._test_widgetSnapshotSaveOverride = { widgetSnapshots.append($0) } + defer { store._test_widgetSnapshotSaveOverride = nil } + + store.persistWidgetSnapshot(reason: "antigravity-quota-summary-test") + await store.widgetSnapshotPersistTask?.value + + let entry = try #require(widgetSnapshots.last?.entries.first { $0.provider == .antigravity }) + #expect(entry.usageRows?.map(\.title) == [ + "Gemini Models Five Hour Limit", + "Gemini Models Weekly Limit", + "Claude and GPT models Five Hour Limit", + "Claude and GPT models Weekly Limit", + ]) + #expect(entry.usageRows?.compactMap(\.percentLeft) == [91, 82, 73, 64]) + } + + @Test + func `widget snapshot labels antigravity compact fallback with model name`() async throws { + let suite = "UsageStoreWidgetSnapshotTests-antigravity-compact-fallback" + 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 + + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings) + let snapshot = try AntigravityStatusSnapshot( + modelQuotas: [ + AntigravityModelQuota( + label: "Experimental Model", + modelId: "MODEL_PLACEHOLDER_NEW", + remainingFraction: 0.36, + resetTime: nil, + resetDescription: nil), + ], + accountEmail: nil, + accountPlan: nil, + source: .local) + .toUsageSnapshot() + store._setSnapshotForTesting(snapshot, provider: .antigravity) + + var widgetSnapshots: [WidgetSnapshot] = [] + store._test_widgetSnapshotSaveOverride = { widgetSnapshots.append($0) } + defer { store._test_widgetSnapshotSaveOverride = nil } + + store.persistWidgetSnapshot(reason: "antigravity-compact-fallback-test") await store.widgetSnapshotPersistTask?.value let entry = try #require(widgetSnapshots.last?.entries.first { $0.provider == .antigravity }) - #expect(entry.usageRows?.map(\.id) == ["primary", "secondary", "tertiary"]) - #expect(entry.usageRows?.map(\.title) == ["Claude", "Gemini Pro", "Gemini Flash"]) - #expect(entry.usageRows?.compactMap(\.percentLeft) == [90, 80, 70]) + #expect(entry.primary == nil) + #expect(entry.usageRows?.map(\.id) == ["antigravity-compact-fallback-MODEL_PLACEHOLDER_NEW"]) + #expect(entry.usageRows?.map(\.title) == ["Experimental Model"]) + #expect(entry.usageRows?.compactMap(\.percentLeft) == [36]) + } + + @Test + func `widget snapshot excludes mimo balance from quota rows`() async throws { + let suite = "UsageStoreWidgetSnapshotTests-mimo-balance" + 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 + + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings) + let snapshot = MiMoUsageSnapshot( + balance: 25.51, + currency: "USD", + updatedAt: Date()) + .toUsageSnapshot() + store._setSnapshotForTesting(snapshot, provider: .mimo) + + var widgetSnapshots: [WidgetSnapshot] = [] + store._test_widgetSnapshotSaveOverride = { widgetSnapshots.append($0) } + defer { store._test_widgetSnapshotSaveOverride = nil } + + store.persistWidgetSnapshot(reason: "mimo-balance-test") + await store.widgetSnapshotPersistTask?.value + + let entry = try #require(widgetSnapshots.last?.entries.first { $0.provider == .mimo }) + #expect(entry.primary == nil) + #expect(entry.secondary == nil) + #expect(entry.usageRows?.isEmpty == true) + } + + @Test + func `widget snapshot keeps Claude local cost without quota data`() async throws { + let suite = "UsageStoreWidgetSnapshotTests-claude-local-cost-only" + 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 + + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings) + let updatedAt = Date(timeIntervalSince1970: 1_800_000_000) + store._setTokenSnapshotForTesting( + CostUsageTokenSnapshot( + sessionTokens: 4200, + sessionCostUSD: 1.25, + last30DaysTokens: 42000, + last30DaysCostUSD: 12.50, + daily: [], + updatedAt: updatedAt), + provider: .claude) + store._setSnapshotForTesting( + UsageSnapshot( + primary: RateWindow(usedPercent: 30, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + secondary: nil, + updatedAt: updatedAt, + identity: ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: nil, + accountOrganization: nil, + loginMethod: nil)), + provider: .codex) + + var widgetSnapshots: [WidgetSnapshot] = [] + store._test_widgetSnapshotSaveOverride = { widgetSnapshots.append($0) } + defer { store._test_widgetSnapshotSaveOverride = nil } + + store.persistWidgetSnapshot(reason: "claude-local-cost-only-test") + await store.widgetSnapshotPersistTask?.value + + let entry = try #require(widgetSnapshots.last?.entries.first { $0.provider == .claude }) + #expect(entry.updatedAt == updatedAt) + #expect(entry.primary == nil) + #expect(entry.secondary == nil) + #expect(entry.usageRows?.isEmpty == true) + #expect(entry.tokenUsage?.sessionTokens == 4200) + #expect(entry.tokenUsage?.last30DaysTokens == 42000) + } + + @Test + func `widget snapshot preserves prior Claude quota rows during token only refresh`() async throws { + let suite = "UsageStoreWidgetSnapshotTests-claude-token-only-preserves-quota" + 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 + + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings) + let quotaUpdatedAt = Date(timeIntervalSince1970: 1_800_000_000) + let tokenUpdatedAt = quotaUpdatedAt.addingTimeInterval(60) + let primary = RateWindow( + usedPercent: 28, + windowMinutes: 300, + resetsAt: quotaUpdatedAt.addingTimeInterval(3600), + resetDescription: nil) + let secondary = RateWindow( + usedPercent: 12, + windowMinutes: 10080, + resetsAt: quotaUpdatedAt.addingTimeInterval(86400), + resetDescription: nil) + store._setSnapshotForTesting( + UsageSnapshot( + primary: primary, + secondary: secondary, + updatedAt: quotaUpdatedAt), + provider: .claude) + + var widgetSnapshots: [WidgetSnapshot] = [] + store._test_widgetSnapshotSaveOverride = { widgetSnapshots.append($0) } + defer { store._test_widgetSnapshotSaveOverride = nil } + + store.persistWidgetSnapshot(reason: "claude-pre-token-preserves-quota-test") + await store.widgetSnapshotPersistTask?.value + + let preTokenEntry = try #require(widgetSnapshots.last?.entries.first { $0.provider == .claude }) + #expect(preTokenEntry.updatedAt == quotaUpdatedAt) + #expect(preTokenEntry.primary == primary) + #expect(preTokenEntry.secondary == secondary) + #expect(preTokenEntry.usageRows?.map(\.id) == ["primary", "secondary"]) + #expect(preTokenEntry.tokenUsage == nil) + let quotaOwnerKey = try #require(preTokenEntry.quotaOwnerKey) + + store.snapshots.removeValue(forKey: .claude) + store._setTokenSnapshotForTesting( + CostUsageTokenSnapshot( + sessionTokens: 4300, + sessionCostUSD: 1.50, + last30DaysTokens: 43000, + last30DaysCostUSD: 13.50, + daily: [], + updatedAt: tokenUpdatedAt), + provider: .claude) + store.persistWidgetSnapshot(reason: "claude-token-only-preserves-quota-test") + await store.widgetSnapshotPersistTask?.value + + let entry = try #require(widgetSnapshots.last?.entries.first { $0.provider == .claude }) + #expect(entry.updatedAt == quotaUpdatedAt) + #expect(entry.primary == primary) + #expect(entry.secondary == secondary) + #expect(entry.usageRows?.map(\.id) == ["primary", "secondary"]) + #expect(entry.usageRows?.compactMap(\.percentLeft) == [72, 88]) + #expect(entry.tokenUsage?.updatedAt == tokenUpdatedAt) + #expect(entry.tokenUsage?.sessionTokens == 4300) + + store.lastQueuedWidgetSnapshot = WidgetSnapshot( + entries: [ + WidgetSnapshot.ProviderEntry( + provider: .claude, + updatedAt: quotaUpdatedAt, + primary: primary, + secondary: secondary, + tertiary: nil, + usageRows: [ + WidgetSnapshot.WidgetUsageRowSnapshot( + id: "primary", + title: "Session", + percentLeft: 72), + WidgetSnapshot.WidgetUsageRowSnapshot( + id: "secondary", + title: "Weekly", + percentLeft: 88), + ], + creditsRemaining: nil, + codeReviewRemainingPercent: nil, + tokenUsage: nil, + dailyUsage: [], + quotaOwnerKey: quotaOwnerKey), + ], + enabledProviders: [.claude], + generatedAt: quotaUpdatedAt) + store.widgetUsagePreservationBlockedProviders.insert(.claude) + + store.persistWidgetSnapshot(reason: "claude-token-only-credential-change-test") + await store.widgetSnapshotPersistTask?.value + + let blockedEntry = try #require(widgetSnapshots.last?.entries.first { $0.provider == .claude }) + #expect(blockedEntry.updatedAt == tokenUpdatedAt) + #expect(blockedEntry.primary == nil) + #expect(blockedEntry.secondary == nil) + #expect(blockedEntry.usageRows?.isEmpty == true) + + let placeholder = RateWindow( + usedPercent: 0, + windowMinutes: 300, + resetsAt: nil, + resetDescription: nil, + isSyntheticPlaceholder: true) + store.widgetUsagePreservationBlockedProviders.remove(.claude) + store.lastQueuedWidgetSnapshot = WidgetSnapshot( + entries: [ + WidgetSnapshot.ProviderEntry( + provider: .claude, + updatedAt: quotaUpdatedAt, + primary: placeholder, + secondary: nil, + tertiary: nil, + usageRows: [ + WidgetSnapshot.WidgetUsageRowSnapshot( + id: "primary", + title: "Session", + percentLeft: 100), + ], + creditsRemaining: nil, + codeReviewRemainingPercent: nil, + tokenUsage: nil, + dailyUsage: [], + quotaOwnerKey: quotaOwnerKey), + ], + enabledProviders: [.claude], + generatedAt: quotaUpdatedAt) + + store.persistWidgetSnapshot(reason: "claude-token-only-drops-placeholder-test") + await store.widgetSnapshotPersistTask?.value + + let filteredEntry = try #require(widgetSnapshots.last?.entries.first { $0.provider == .claude }) + #expect(filteredEntry.updatedAt == tokenUpdatedAt) + #expect(filteredEntry.primary == nil) + #expect(filteredEntry.usageRows?.isEmpty == true) + } + + @Test + func `widget snapshot uses Claude enterprise spend limit instead of placeholder quota`() async throws { + let suite = "UsageStoreWidgetSnapshotTests-claude-enterprise-spend-limit" + 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 + + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings) + let updatedAt = Date(timeIntervalSince1970: 1_800_000_000) + store._setSnapshotForTesting( + UsageSnapshot( + primary: RateWindow( + usedPercent: 0, + windowMinutes: 300, + resetsAt: nil, + resetDescription: nil, + isSyntheticPlaceholder: true), + secondary: nil, + providerCost: ProviderCostSnapshot( + used: 25545.63, + limit: 30000, + currencyCode: "USD", + period: "Monthly cap", + updatedAt: updatedAt), + updatedAt: updatedAt, + identity: ProviderIdentitySnapshot( + providerID: .claude, + accountEmail: nil, + accountOrganization: nil, + loginMethod: nil)), + provider: .claude) + + var widgetSnapshots: [WidgetSnapshot] = [] + store._test_widgetSnapshotSaveOverride = { widgetSnapshots.append($0) } + defer { store._test_widgetSnapshotSaveOverride = nil } + + store.persistWidgetSnapshot(reason: "claude-enterprise-spend-limit-test") + await store.widgetSnapshotPersistTask?.value + + let entry = try #require(widgetSnapshots.last?.entries.first { $0.provider == .claude }) + let row = try #require(entry.usageRows?.first) + #expect(entry.usageRows?.count == 1) + #expect(row.id == "extraUsage") + #expect(row.title == "Monthly cap") + #expect(abs((row.percentLeft ?? 0) - 14.8479) < 0.0001) + #expect(row.window?.isSyntheticPlaceholder == false) + } + + @Test(arguments: [true, false]) + func `widget snapshot respects extra usage visibility for Devin`(_ showsExtraUsage: Bool) async throws { + let suite = "UsageStoreWidgetSnapshotTests-devin-extra-usage-\(showsExtraUsage)" + 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.showOptionalCreditsAndExtraUsage = showsExtraUsage + + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings) + let updatedAt = Date(timeIntervalSince1970: 1_800_000_000) + store._setSnapshotForTesting( + UsageSnapshot( + primary: nil, + secondary: nil, + providerCost: ProviderCostSnapshot( + used: 48, + limit: 0, + currencyCode: "USD", + period: "Extra usage balance", + updatedAt: updatedAt), + updatedAt: updatedAt, + identity: ProviderIdentitySnapshot( + providerID: .devin, + accountEmail: nil, + accountOrganization: nil, + loginMethod: nil)), + provider: .devin) + + var widgetSnapshots: [WidgetSnapshot] = [] + store._test_widgetSnapshotSaveOverride = { widgetSnapshots.append($0) } + defer { store._test_widgetSnapshotSaveOverride = nil } + + store.persistWidgetSnapshot(reason: "devin-extra-usage-visibility-test") + await store.widgetSnapshotPersistTask?.value + + let entry = try #require(widgetSnapshots.last?.entries.first { $0.provider == .devin }) + #expect((entry.providerCost != nil) == showsExtraUsage) + } + + @Test + func `widget snapshot carries token usage age separately from entry freshness`() async throws { + let suite = "UsageStoreWidgetSnapshotTests-token-usage-age" + 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 + + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings) + let entryUpdatedAt = Date(timeIntervalSince1970: 1_800_000_000) + let tokenUpdatedAt = entryUpdatedAt.addingTimeInterval(-45 * 60) + store._setSnapshotForTesting( + UsageSnapshot( + primary: RateWindow(usedPercent: 30, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + secondary: nil, + updatedAt: entryUpdatedAt, + identity: ProviderIdentitySnapshot( + providerID: .claude, + accountEmail: nil, + accountOrganization: nil, + loginMethod: nil)), + provider: .claude) + store._setTokenSnapshotForTesting( + CostUsageTokenSnapshot( + sessionTokens: 4200, + sessionCostUSD: 1.25, + last30DaysTokens: 42000, + last30DaysCostUSD: 12.50, + daily: [], + updatedAt: tokenUpdatedAt), + provider: .claude) + + var widgetSnapshots: [WidgetSnapshot] = [] + store._test_widgetSnapshotSaveOverride = { widgetSnapshots.append($0) } + defer { store._test_widgetSnapshotSaveOverride = nil } + + store.persistWidgetSnapshot(reason: "token-usage-age-test") + await store.widgetSnapshotPersistTask?.value + + let entry = try #require(widgetSnapshots.last?.entries.first { $0.provider == .claude }) + #expect(entry.updatedAt == entryUpdatedAt) + #expect(entry.tokenUsage?.updatedAt == tokenUpdatedAt) + #expect(entry.tokenUsage?.isStale(comparedTo: entry.updatedAt) == true) + } + + @Test + func `widget snapshot labels legacy Cursor request quota as Requests`() async throws { + let suite = "UsageStoreWidgetSnapshotTests-cursor-requests-label" + 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 + + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings) + store._setSnapshotForTesting( + UsageSnapshot( + primary: RateWindow(usedPercent: 40, windowMinutes: 43200, resetsAt: nil, resetDescription: nil), + secondary: nil, + tertiary: nil, + cursorRequests: CursorRequestUsage(used: 200, limit: 500), + updatedAt: Date()), + provider: .cursor) + + var widgetSnapshots: [WidgetSnapshot] = [] + store._test_widgetSnapshotSaveOverride = { widgetSnapshots.append($0) } + defer { store._test_widgetSnapshotSaveOverride = nil } + + store.persistWidgetSnapshot(reason: "cursor-requests-label-test") + await store.widgetSnapshotPersistTask?.value + + let entry = try #require(widgetSnapshots.last?.entries.first { $0.provider == .cursor }) + #expect(entry.usageRows?.map(\.id) == ["primary"]) + #expect(entry.usageRows?.map(\.title) == ["Requests"]) + } + + @Test + func `widget snapshot keeps Cursor Total label for token based plans`() async throws { + let suite = "UsageStoreWidgetSnapshotTests-cursor-total-label" + 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 + + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings) + store._setSnapshotForTesting( + UsageSnapshot( + primary: RateWindow(usedPercent: 10, windowMinutes: 43200, resetsAt: nil, resetDescription: nil), + secondary: RateWindow(usedPercent: 20, windowMinutes: 43200, resetsAt: nil, resetDescription: nil), + tertiary: RateWindow(usedPercent: 0, windowMinutes: 43200, resetsAt: nil, resetDescription: nil), + updatedAt: Date()), + provider: .cursor) + + var widgetSnapshots: [WidgetSnapshot] = [] + store._test_widgetSnapshotSaveOverride = { widgetSnapshots.append($0) } + defer { store._test_widgetSnapshotSaveOverride = nil } + + store.persistWidgetSnapshot(reason: "cursor-total-label-test") + await store.widgetSnapshotPersistTask?.value + + let entry = try #require(widgetSnapshots.last?.entries.first { $0.provider == .cursor }) + #expect(entry.usageRows?.map(\.title) == ["Total", "Auto", "API"]) } } diff --git a/Tests/CodexBarTests/UserFacingLocalizationCoverageTests.swift b/Tests/CodexBarTests/UserFacingLocalizationCoverageTests.swift index b6545f0743..e4c9e3e37f 100644 --- a/Tests/CodexBarTests/UserFacingLocalizationCoverageTests.swift +++ b/Tests/CodexBarTests/UserFacingLocalizationCoverageTests.swift @@ -1,5 +1,6 @@ import Foundation import Testing +@testable import CodexBar struct UserFacingLocalizationCoverageTests { @Test @@ -32,6 +33,11 @@ struct UserFacingLocalizationCoverageTests { ".value(\"Utilization Start\"", ".value(\"Utilization End\"", ], + "Sources/CodexBar/Providers/JetBrains/JetBrainsLoginFlow.swift": [ + " \"Install a JetBrains IDE with AI Assistant enabled, then refresh CodexBar.\",", + " \"Alternatively, set a custom path in Settings.\",", + "title: \"No JetBrains IDE detected\"", + ], "Sources/CodexBar/PreferencesCodexAccountsSection.swift": [ "?? \"No system account\"", "return \"Adding Account…\"", @@ -55,6 +61,9 @@ struct UserFacingLocalizationCoverageTests { "Sources/CodexBar/PreferencesProviderErrorView.swift": [ ".help(\"Copy error\")", ], + "Sources/CodexBar/PreferencesSpendDashboardPane.swift": [ + "Text(\"Model breakdown unavailable\")", + ], "Sources/CodexBar/PreferencesProviderSettingsRows.swift": [ "Text(self.title)", "Text(self.toggle.title)", @@ -77,13 +86,9 @@ struct UserFacingLocalizationCoverageTests { "Text(\"No organizations loaded. Click Refresh after setting your API key.\")", "Button(\"Refresh organizations\")", ], - "Sources/CodexBar/PreferencesProviderSidebarView.swift": [ - ".help(\"Drag to reorder\")", + "Sources/CodexBar/PreferencesSidebar.swift": [ "\"Disabled —", - ".accessibilityLabel(\"Reorder\")", - ], - "Sources/CodexBar/StatusItemController+UsageHistoryMenu.swift": [ - "Text(\"Subscription Utilization\")", + ".accessibilityLabel(\"Sort", ], "Sources/CodexBar/StatusItemController+CostMenuCard.swift": [ "static let costMenuTitle", @@ -109,4 +114,60 @@ struct UserFacingLocalizationCoverageTests { violations.isEmpty, "Raw user-facing localization markers remain:\n\(violations.joined(separator: "\n"))") } + + @Test + func `spend dashboard model breakdown state stays precise and localized`() throws { + let root = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + let source = try String( + contentsOf: root.appendingPathComponent("Sources/CodexBar/PreferencesSpendDashboardPane.swift"), + encoding: .utf8) + + #expect(source.contains(#"Text(L("Model breakdown unavailable"))"#)) + #expect(source.contains(#"Text(L("No model-level history"))"#)) + } + + @Test + func `spend dashboard chart keeps validated points when aggregate total is unavailable`() { + let start = Date(timeIntervalSince1970: 1_783_036_800) + let points = [ + SpendDashboardModel.DailyPoint( + sourceID: "healthy-claude", + provider: .claude, + providerName: "Claude", + day: start, + cost: 2, + stackStart: 0, + stackEnd: 2), + SpendDashboardModel.DailyPoint( + sourceID: "healthy-openai-1", + provider: .openai, + providerName: "OpenAI", + day: start, + cost: 3, + stackStart: 2, + stackEnd: 5), + SpendDashboardModel.DailyPoint( + sourceID: "healthy-openai-2", + provider: .openai, + providerName: "OpenAI", + day: start.addingTimeInterval(86400), + cost: 4, + stackStart: 0, + stackEnd: 4), + ] + + let partial = SpendDailyChartPresentation(dailyPoints: points, aggregateTotal: nil) + #expect(partial.content == .chart) + #expect(partial.series.map(\.name) == ["Claude", "OpenAI"]) + #expect(partial.dayCount == 2) + CodexBarLocalizationOverride.$appLanguage.withValue("en") { + #expect(partial.accessibilityValue == "2 days of usage data across 2 services") + } + + #expect(SpendDailyChartPresentation(dailyPoints: [], aggregateTotal: nil).content == .unavailable) + #expect(SpendDailyChartPresentation(dailyPoints: [], aggregateTotal: 0).content == .chart) + } } diff --git a/Tests/CodexBarTests/WayfinderProviderTests.swift b/Tests/CodexBarTests/WayfinderProviderTests.swift new file mode 100644 index 0000000000..ea7be695bc --- /dev/null +++ b/Tests/CodexBarTests/WayfinderProviderTests.swift @@ -0,0 +1,38 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +struct WayfinderProviderTests { + @Test + @MainActor + func `descriptor and implementation are registered`() throws { + let descriptor = ProviderDescriptorRegistry.descriptor(for: .wayfinder) + #expect(descriptor.metadata.displayName == "Wayfinder") + #expect(descriptor.metadata.cliName == "wayfinder") + #expect(descriptor.cli.aliases.contains("wayfinder-router")) + #expect(!descriptor.metadata.defaultEnabled) + #expect(descriptor.branding.iconResourceName == "ProviderIcon-wayfinder") + + let implementation = try #require(ProviderImplementationRegistry.implementation(for: .wayfinder)) + #expect(implementation.id == .wayfinder) + } + + @Test + @MainActor + func `dashboard follows saved gateway instead of the descriptor default`() throws { + let suite = "WayfinderProviderTests-\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suite)) + defer { defaults.removePersistentDomain(forName: suite) } + let settings = SettingsStore( + userDefaults: defaults, + configStore: testConfigStore(suiteName: suite), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + settings.wayfinderGatewayURL = "http://localhost:9191/wayfinder" + + #expect(WayfinderProviderImplementation.dashboardURL( + settings: settings, + environment: [:]).absoluteString == "http://localhost:9191/wayfinder/router") + } +} diff --git a/Tests/CodexBarTests/WidgetSnapshotTests.swift b/Tests/CodexBarTests/WidgetSnapshotTests.swift index d431e7eb6b..8ccf40f138 100644 --- a/Tests/CodexBarTests/WidgetSnapshotTests.swift +++ b/Tests/CodexBarTests/WidgetSnapshotTests.swift @@ -1,8 +1,29 @@ import Foundation import Testing +@testable import CodexBar @testable import CodexBarCore struct WidgetSnapshotTests { + @Test + func `Codex widget labels disclose API estimates`() { + let snapshot = CostUsageTokenSnapshot( + sessionTokens: 1200, + sessionCostUSD: 1.25, + last30DaysTokens: 9000, + last30DaysCostUSD: 9.99, + historyDays: 30, + daily: [], + updatedAt: Date(timeIntervalSince1970: 0)) + + let codex = UsageStore.widgetTokenUsageSummary(from: snapshot, provider: .codex) + let claude = UsageStore.widgetTokenUsageSummary(from: snapshot, provider: .claude) + + #expect(codex?.sessionLabel == "Today API est. · not billed") + #expect(codex?.last30DaysLabel == "30d API est. · not billed") + #expect(claude?.sessionLabel == "Today") + #expect(claude?.last30DaysLabel == "30d") + } + @Test func `widget snapshot round trip`() throws { let entry = WidgetSnapshot.ProviderEntry( @@ -27,11 +48,13 @@ struct WidgetSnapshotTests { last30DaysLabel: "This month"), dailyUsage: [ WidgetSnapshot.DailyUsagePoint(dayKey: "2025-12-20", totalTokens: 1200, costUSD: 12.3), - ]) + ], + quotaOwnerKey: "claude-account-cache-key") let snapshot = WidgetSnapshot( entries: [entry], enabledProviders: [.codex, .claude], + usageBarsShowUsed: true, generatedAt: Date()) let encoder = JSONEncoder() @@ -49,7 +72,9 @@ struct WidgetSnapshotTests { #expect(decoded.entries.first?.tokenUsage?.sessionLabel == "Latest billing day") #expect(decoded.entries.first?.tokenUsage?.last30DaysLabel == "This month") #expect(decoded.entries.first?.usageRows?.map(\.id) == ["session", "weekly"]) + #expect(decoded.entries.first?.quotaOwnerKey == "claude-account-cache-key") #expect(decoded.enabledProviders == [.codex, .claude]) + #expect(decoded.usageBarsShowUsed) } @Test @@ -165,7 +190,9 @@ struct WidgetSnapshotTests { #expect(decoded.entries.count == 1) #expect(decoded.entries.first?.usageRows == nil) + #expect(decoded.entries.first?.quotaOwnerKey == nil) #expect(decoded.entries.first?.secondary?.usedPercent == 25) + #expect(!decoded.usageBarsShowUsed) } @Test @@ -203,4 +230,50 @@ struct WidgetSnapshotTests { #expect(decoded.entries.first?.tokenUsage?.last30DaysLabel == "30d") #expect(decoded.enabledProviders == [.codex]) } + + @Test + func `token usage summary round trips updatedAt and tolerates legacy payloads`() throws { + let updatedAt = Date(timeIntervalSince1970: 1_760_000_000) + let summary = WidgetSnapshot.TokenUsageSummary( + sessionCostUSD: 1.5, + sessionTokens: 100, + last30DaysCostUSD: 30, + last30DaysTokens: 2000, + updatedAt: updatedAt) + + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + + let decoded = try decoder.decode( + WidgetSnapshot.TokenUsageSummary.self, + from: encoder.encode(summary)) + #expect(decoded.updatedAt == updatedAt) + + let legacy = try decoder.decode( + WidgetSnapshot.TokenUsageSummary.self, + from: Data(#"{"sessionCostUSD": 1.5, "sessionTokens": 100}"#.utf8)) + #expect(legacy.updatedAt == nil) + } + + @Test + func `token usage staleness discloses only meaningful lag`() { + let entryUpdatedAt = Date() + + func summary(updatedAt: Date?) -> WidgetSnapshot.TokenUsageSummary { + WidgetSnapshot.TokenUsageSummary( + sessionCostUSD: nil, + sessionTokens: nil, + last30DaysCostUSD: nil, + last30DaysTokens: nil, + updatedAt: updatedAt) + } + + #expect(!summary(updatedAt: entryUpdatedAt.addingTimeInterval(-5 * 60)) + .isStale(comparedTo: entryUpdatedAt)) + #expect(summary(updatedAt: entryUpdatedAt.addingTimeInterval(-61 * 60)) + .isStale(comparedTo: entryUpdatedAt)) + #expect(!summary(updatedAt: nil).isStale(comparedTo: entryUpdatedAt)) + } } diff --git a/Tests/CodexBarTests/WindsurfDevinSessionImporterTests.swift b/Tests/CodexBarTests/WindsurfDevinSessionImporterTests.swift index 6d9a6ed724..3579ba4fd9 100644 --- a/Tests/CodexBarTests/WindsurfDevinSessionImporterTests.swift +++ b/Tests/CodexBarTests/WindsurfDevinSessionImporterTests.swift @@ -1,4 +1,5 @@ import Foundation +import SweetCookieKit import Testing @testable import CodexBarCore @@ -11,6 +12,14 @@ struct WindsurfDevinSessionImporterTests { #expect(!WindsurfDevinSessionImporter.fallbackBrowsersExcluding([.chrome, .edge]).contains(.edge)) } + @Test + func `reads Devin app storage before legacy Windsurf origin`() { + #expect(WindsurfDevinSessionImporter.localStorageOrigins.map(\.absoluteString) == [ + "https://app.devin.ai", + "https://windsurf.com", + ]) + } + @Test func `decodes quoted local storage strings`() { #expect(WindsurfDevinSessionImporter @@ -36,6 +45,82 @@ struct WindsurfDevinSessionImporterTests { #expect(session?.sourceLabel == "Chrome Default") } + @Test + func `keeps partial app origin separate from complete legacy origin`() throws { + let appOrigin = try #require(URL(string: "https://app.devin.ai")) + let legacyOrigin = try #require(URL(string: "https://windsurf.com")) + + let snapshots = WindsurfDevinSessionImporter.localStorageSnapshots(from: [ + ( + origin: appOrigin, + entries: [ + Self.entry(origin: appOrigin, key: "devin_session_token", value: "app-session"), + Self.entry(origin: appOrigin, key: "devin_auth1_token", value: "app-auth1"), + ]), + ( + origin: legacyOrigin, + entries: [ + Self.entry(origin: legacyOrigin, key: "devin_session_token", value: "legacy-session"), + Self.entry(origin: legacyOrigin, key: "devin_auth1_token", value: "legacy-auth1"), + Self.entry(origin: legacyOrigin, key: "devin_account_id", value: "legacy-account"), + Self.entry(origin: legacyOrigin, key: "devin_primary_org_id", value: "legacy-org"), + ]), + ]) + + #expect(snapshots == [ + WindsurfDevinSessionImporter.LocalStorageSnapshot( + storage: [ + "devin_session_token": "legacy-session", + "devin_auth1_token": "legacy-auth1", + "devin_account_id": "legacy-account", + "devin_primary_org_id": "legacy-org", + ], + sourceSuffix: "windsurf.com"), + ]) + } + + @Test + func `keeps text entry fallback after structured origin snapshots`() throws { + let appOrigin = try #require(URL(string: "https://app.devin.ai")) + + let snapshots = WindsurfDevinSessionImporter.localStorageSnapshots( + from: [ + ( + origin: appOrigin, + entries: [ + Self.entry(origin: appOrigin, key: "devin_session_token", value: "stale-app-session"), + Self.entry(origin: appOrigin, key: "devin_auth1_token", value: "stale-app-auth1"), + Self.entry(origin: appOrigin, key: "devin_account_id", value: "stale-app-account"), + Self.entry(origin: appOrigin, key: "devin_primary_org_id", value: "stale-app-org"), + ]), + ], + textEntries: [ + Self.textEntry(key: "devin_session_token", value: "legacy-text-session"), + Self.textEntry(key: "devin_auth1_token", value: "legacy-text-auth1"), + Self.textEntry(key: "devin_account_id", value: "legacy-text-account"), + Self.textEntry(key: "devin_primary_org_id", value: "legacy-text-org"), + ]) + + #expect(snapshots == [ + WindsurfDevinSessionImporter.LocalStorageSnapshot( + storage: [ + "devin_session_token": "stale-app-session", + "devin_auth1_token": "stale-app-auth1", + "devin_account_id": "stale-app-account", + "devin_primary_org_id": "stale-app-org", + ], + sourceSuffix: "app.devin.ai"), + WindsurfDevinSessionImporter.LocalStorageSnapshot( + storage: [ + "devin_session_token": "legacy-text-session", + "devin_auth1_token": "legacy-text-auth1", + "devin_account_id": "legacy-text-account", + "devin_primary_org_id": "legacy-text-org", + ], + sourceSuffix: nil), + ]) + } + @Test func `deduplicates repeated session tokens while preserving first source`() { let sessions = [ @@ -69,4 +154,16 @@ struct WindsurfDevinSessionImporterTests { #expect(deduplicated[0].session.sessionToken == "devin-session-token$abc") #expect(deduplicated[1].session.sessionToken == "devin-session-token$def") } + + private static func entry(origin: URL, key: String, value: String) -> ChromiumLocalStorageEntry { + ChromiumLocalStorageEntry( + origin: origin.absoluteString, + key: key, + value: value, + rawValueLength: value.utf8.count) + } + + private static func textEntry(key: String, value: String) -> ChromiumLevelDBTextEntry { + ChromiumLevelDBTextEntry(key: key, value: value) + } } diff --git a/Tests/CodexBarTests/WindsurfWebFetcherTests.swift b/Tests/CodexBarTests/WindsurfWebFetcherTests.swift index c6f3e571ac..487804e168 100644 --- a/Tests/CodexBarTests/WindsurfWebFetcherTests.swift +++ b/Tests/CodexBarTests/WindsurfWebFetcherTests.swift @@ -1,9 +1,18 @@ import Foundation +import SweetCookieKit import Testing @testable import CodexBarCore @Suite(.serialized) struct WindsurfWebFetcherTests { + @Test + func `missing session guidance names current and legacy origins`() { + let message = WindsurfWebFetcherError.noSessionData.errorDescription + + #expect(message?.contains("app.devin.ai") == true) + #expect(message?.contains("windsurf.com") == true) + } + private struct ResponseFixture { let planName: String let dailyRemaining: Int @@ -19,6 +28,23 @@ struct WindsurfWebFetcherTests { return URLSession(configuration: config) } + private func withWindsurfSessionOverrides( + importSessions: ((BrowserDetection, ((String) -> Void)?) -> [WindsurfDevinSessionImporter.SessionInfo])? = nil, + preferredSessions: ((BrowserDetection, ((String) -> Void)?) -> [WindsurfDevinSessionImporter.SessionInfo])? = + nil, + fallbackSessions: ((BrowserDetection, ((String) -> Void)?) -> [WindsurfDevinSessionImporter.SessionInfo])? = + nil, + operation: () async throws -> T) async rethrows -> T + { + try await WindsurfDevinSessionImporter.withImportSessionsOverrideForTesting(importSessions) { + try await WindsurfDevinSessionImporter.withImportPreferredSessionsOverrideForTesting(preferredSessions) { + try await WindsurfDevinSessionImporter.withImportFallbackSessionsOverrideForTesting(fallbackSessions) { + try await operation() + } + } + } + } + @Test func `manual devin session sends protobuf request and auth headers`() async throws { defer { @@ -84,31 +110,29 @@ struct WindsurfWebFetcherTests { @Test func `auto session import retries next profile after auth failure`() async throws { defer { - WindsurfDevinSessionImporter.importSessionsOverrideForTesting = nil - WindsurfDevinSessionImporter.importPreferredSessionsOverrideForTesting = nil - WindsurfDevinSessionImporter.importFallbackSessionsOverrideForTesting = nil WindsurfWebFetcherStubURLProtocol.requests = [] WindsurfWebFetcherStubURLProtocol.handler = nil } - WindsurfDevinSessionImporter.importPreferredSessionsOverrideForTesting = { _, _ in - [ - WindsurfDevinSessionImporter.SessionInfo( - session: WindsurfDevinSessionAuth( - sessionToken: "stale-token", - auth1Token: "stale-auth1", - accountID: "stale-account", - primaryOrgID: "stale-org"), - sourceLabel: "Chrome Default"), - WindsurfDevinSessionImporter.SessionInfo( - session: WindsurfDevinSessionAuth( - sessionToken: "fresh-token", - auth1Token: "fresh-auth1", - accountID: "fresh-account", - primaryOrgID: "fresh-org"), - sourceLabel: "Chrome Profile 1"), - ] - } + let preferredSessions: (BrowserDetection, ((String) -> Void)?) + -> [WindsurfDevinSessionImporter.SessionInfo] = { _, _ in + [ + WindsurfDevinSessionImporter.SessionInfo( + session: WindsurfDevinSessionAuth( + sessionToken: "stale-token", + auth1Token: "stale-auth1", + accountID: "stale-account", + primaryOrgID: "stale-org"), + sourceLabel: "Chrome Default"), + WindsurfDevinSessionImporter.SessionInfo( + session: WindsurfDevinSessionAuth( + sessionToken: "fresh-token", + auth1Token: "fresh-auth1", + accountID: "fresh-account", + primaryOrgID: "fresh-org"), + sourceLabel: "Chrome Profile 1"), + ] + } WindsurfWebFetcherStubURLProtocol.requests = [] WindsurfWebFetcherStubURLProtocol.handler = { request in @@ -137,50 +161,51 @@ struct WindsurfWebFetcherTests { statusCode: 200) } - let snapshot = try await WindsurfWebFetcher.fetchUsage( - browserDetection: BrowserDetection(cacheTTL: 0), - cookieSource: .auto, - timeout: 2, - session: self.makeSession()) + try await self.withWindsurfSessionOverrides(preferredSessions: preferredSessions) { + let snapshot = try await WindsurfWebFetcher.fetchUsage( + browserDetection: BrowserDetection(cacheTTL: 0), + cookieSource: .auto, + timeout: 2, + session: self.makeSession()) - #expect(WindsurfWebFetcherStubURLProtocol.requests.count == 2) - #expect(snapshot.identity?.loginMethod == "Teams") - #expect(snapshot.primary?.usedPercent == 25) - #expect(snapshot.secondary?.usedPercent == 10) + #expect(WindsurfWebFetcherStubURLProtocol.requests.count == 2) + #expect(snapshot.identity?.loginMethod == "Teams") + #expect(snapshot.primary?.usedPercent == 25) + #expect(snapshot.secondary?.usedPercent == 10) + } } @Test func `auto session import tries fallback browsers after preferred sessions fail`() async throws { defer { - WindsurfDevinSessionImporter.importSessionsOverrideForTesting = nil - WindsurfDevinSessionImporter.importPreferredSessionsOverrideForTesting = nil - WindsurfDevinSessionImporter.importFallbackSessionsOverrideForTesting = nil WindsurfWebFetcherStubURLProtocol.requests = [] WindsurfWebFetcherStubURLProtocol.handler = nil } - WindsurfDevinSessionImporter.importPreferredSessionsOverrideForTesting = { _, _ in - [ - WindsurfDevinSessionImporter.SessionInfo( - session: WindsurfDevinSessionAuth( - sessionToken: "stale-chrome-token", - auth1Token: "stale-auth1", - accountID: "stale-account", - primaryOrgID: "stale-org"), - sourceLabel: "Chrome Default"), - ] - } - WindsurfDevinSessionImporter.importFallbackSessionsOverrideForTesting = { _, _ in - [ - WindsurfDevinSessionImporter.SessionInfo( - session: WindsurfDevinSessionAuth( - sessionToken: "fresh-edge-token", - auth1Token: "fresh-auth1", - accountID: "fresh-account", - primaryOrgID: "fresh-org"), - sourceLabel: "Microsoft Edge Default"), - ] - } + let preferredSessions: (BrowserDetection, ((String) -> Void)?) + -> [WindsurfDevinSessionImporter.SessionInfo] = { _, _ in + [ + WindsurfDevinSessionImporter.SessionInfo( + session: WindsurfDevinSessionAuth( + sessionToken: "stale-chrome-token", + auth1Token: "stale-auth1", + accountID: "stale-account", + primaryOrgID: "stale-org"), + sourceLabel: "Chrome Default"), + ] + } + let fallbackSessions: (BrowserDetection, ((String) -> Void)?) + -> [WindsurfDevinSessionImporter.SessionInfo] = { _, _ in + [ + WindsurfDevinSessionImporter.SessionInfo( + session: WindsurfDevinSessionAuth( + sessionToken: "fresh-edge-token", + auth1Token: "fresh-auth1", + accountID: "fresh-account", + primaryOrgID: "fresh-org"), + sourceLabel: "Microsoft Edge Default"), + ] + } WindsurfWebFetcherStubURLProtocol.requests = [] WindsurfWebFetcherStubURLProtocol.handler = { request in @@ -209,51 +234,129 @@ struct WindsurfWebFetcherTests { statusCode: 200) } - let snapshot = try await WindsurfWebFetcher.fetchUsage( - browserDetection: BrowserDetection(cacheTTL: 0), - cookieSource: .auto, - timeout: 2, - session: self.makeSession()) + try await self.withWindsurfSessionOverrides( + preferredSessions: preferredSessions, + fallbackSessions: fallbackSessions) + { + let snapshot = try await WindsurfWebFetcher.fetchUsage( + browserDetection: BrowserDetection(cacheTTL: 0), + cookieSource: .auto, + timeout: 2, + session: self.makeSession()) - #expect(WindsurfWebFetcherStubURLProtocol.requests.count == 2) - #expect(snapshot.identity?.loginMethod == "Teams") - #expect(snapshot.primary?.usedPercent == 36) - #expect(snapshot.secondary?.usedPercent == 20) + #expect(WindsurfWebFetcherStubURLProtocol.requests.count == 2) + #expect(snapshot.identity?.loginMethod == "Teams") + #expect(snapshot.primary?.usedPercent == 36) + #expect(snapshot.secondary?.usedPercent == 20) + } } @Test - func `manual mode with empty session does not fall back to imported session`() async { + func `auto import uses complete legacy origin when app origin is partial`() async throws { defer { - WindsurfDevinSessionImporter.importSessionsOverrideForTesting = nil - WindsurfDevinSessionImporter.importPreferredSessionsOverrideForTesting = nil - WindsurfDevinSessionImporter.importFallbackSessionsOverrideForTesting = nil WindsurfWebFetcherStubURLProtocol.requests = [] WindsurfWebFetcherStubURLProtocol.handler = nil } - WindsurfDevinSessionImporter.importSessionsOverrideForTesting = { _, _ in - [ - WindsurfDevinSessionImporter.SessionInfo( - session: WindsurfDevinSessionAuth( - sessionToken: "auto-token", - auth1Token: "auto-auth1", - accountID: "auto-account", - primaryOrgID: "auto-org"), - sourceLabel: "Chrome Default"), - ] + let appOrigin = try #require(URL(string: "https://app.devin.ai")) + let legacyOrigin = try #require(URL(string: "https://windsurf.com")) + let snapshots = WindsurfDevinSessionImporter.localStorageSnapshots(from: [ + ( + origin: appOrigin, + entries: [ + Self.localStorageEntry(origin: appOrigin, key: "devin_session_token", value: "app-session"), + Self.localStorageEntry(origin: appOrigin, key: "devin_auth1_token", value: "app-auth1"), + ]), + ( + origin: legacyOrigin, + entries: [ + Self.localStorageEntry(origin: legacyOrigin, key: "devin_session_token", value: "legacy-session"), + Self.localStorageEntry(origin: legacyOrigin, key: "devin_auth1_token", value: "legacy-auth1"), + Self.localStorageEntry(origin: legacyOrigin, key: "devin_account_id", value: "legacy-account"), + Self.localStorageEntry(origin: legacyOrigin, key: "devin_primary_org_id", value: "legacy-org"), + ]), + ]) + + let sessionInfos = snapshots.compactMap { snapshot in + WindsurfDevinSessionImporter.session( + from: snapshot.storage, + sourceLabel: "Chrome Default (\(snapshot.sourceSuffix ?? "unknown"))") } WindsurfWebFetcherStubURLProtocol.requests = [] + WindsurfWebFetcherStubURLProtocol.handler = { request in + let url = try #require(request.url) + #expect(request.value(forHTTPHeaderField: "x-devin-session-token") == "legacy-session") + #expect(request.value(forHTTPHeaderField: "x-devin-auth1-token") == "legacy-auth1") + #expect(request.value(forHTTPHeaderField: "x-devin-account-id") == "legacy-account") + #expect(request.value(forHTTPHeaderField: "x-devin-primary-org-id") == "legacy-org") - await #expect { - _ = try await WindsurfWebFetcher.fetchUsage( - browserDetection: BrowserDetection(cacheTTL: 0), - cookieSource: .manual, - manualSessionInput: " \n", - timeout: 2, - session: self.makeSession()) - } throws: { error in - guard case let WindsurfWebFetcherError.invalidManualSession(message) = error else { return false } - return message == "empty input" + let body = try WindsurfPlanStatusProtoCodec.decodeRequest(Self.requestBodyData(from: request)) + #expect(body.authToken == "legacy-session") + + return Self.makeResponse( + url: url, + body: Self.makePlanStatusResponse(ResponseFixture( + planName: "Pro", + dailyRemaining: 70, + weeklyRemaining: 85, + planEndUnix: 1_777_888_000, + dailyResetUnix: 1_777_900_000, + weeklyResetUnix: 1_778_000_000)), + contentType: "application/proto", + statusCode: 200) + } + + try await self.withWindsurfSessionOverrides( + preferredSessions: { _, _ in sessionInfos }, + operation: { + let snapshot = try await WindsurfWebFetcher.fetchUsage( + browserDetection: BrowserDetection(cacheTTL: 0), + cookieSource: .auto, + timeout: 2, + session: self.makeSession()) + + #expect(snapshots.count == 1) + #expect(sessionInfos.map(\.sourceLabel) == ["Chrome Default (windsurf.com)"]) + #expect(WindsurfWebFetcherStubURLProtocol.requests.count == 1) + #expect(snapshot.identity?.loginMethod == "Pro") + #expect(snapshot.primary?.usedPercent == 30) + #expect(snapshot.secondary?.usedPercent == 15) + }) + } + + @Test + func `manual mode with empty session does not fall back to imported session`() async { + defer { + WindsurfWebFetcherStubURLProtocol.requests = [] + WindsurfWebFetcherStubURLProtocol.handler = nil + } + + let importedSessions: (BrowserDetection, ((String) -> Void)?) + -> [WindsurfDevinSessionImporter.SessionInfo] = { _, _ in + [ + WindsurfDevinSessionImporter.SessionInfo( + session: WindsurfDevinSessionAuth( + sessionToken: "auto-token", + auth1Token: "auto-auth1", + accountID: "auto-account", + primaryOrgID: "auto-org"), + sourceLabel: "Chrome Default"), + ] + } + WindsurfWebFetcherStubURLProtocol.requests = [] + + _ = await self.withWindsurfSessionOverrides(importSessions: importedSessions) { + await #expect { + _ = try await WindsurfWebFetcher.fetchUsage( + browserDetection: BrowserDetection(cacheTTL: 0), + cookieSource: .manual, + manualSessionInput: " \n", + timeout: 2, + session: self.makeSession()) + } throws: { error in + guard case let WindsurfWebFetcherError.invalidManualSession(message) = error else { return false } + return message == "empty input" + } } #expect(WindsurfWebFetcherStubURLProtocol.requests.isEmpty) } @@ -439,11 +542,23 @@ struct WindsurfWebFetcherTests { data.append(UInt8(remaining)) return data } + + private static func localStorageEntry(origin: URL, key: String, value: String) -> ChromiumLocalStorageEntry { + ChromiumLocalStorageEntry( + origin: origin.absoluteString, + key: key, + value: value, + rawValueLength: value.utf8.count) + } } final class WindsurfWebFetcherStubURLProtocol: URLProtocol { nonisolated(unsafe) static var requests: [URLRequest] = [] - nonisolated(unsafe) static var handler: (@Sendable (URLRequest) throws -> (HTTPURLResponse, Data))? + private static let _handlerBox = LockIsolated<(@Sendable (URLRequest) throws -> (HTTPURLResponse, Data))?>(nil) + static var handler: (@Sendable (URLRequest) throws -> (HTTPURLResponse, Data))? { + get { Self._handlerBox.value } + set { Self._handlerBox.setValue(newValue) } + } override static func canInit(with _: URLRequest) -> Bool { true diff --git a/Tests/CodexBarTests/XAIProviderTests.swift b/Tests/CodexBarTests/XAIProviderTests.swift new file mode 100644 index 0000000000..5ae03aa4bc --- /dev/null +++ b/Tests/CodexBarTests/XAIProviderTests.swift @@ -0,0 +1,570 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar +@testable import CodexBarCLI + +struct XAIProviderTests { + // MARK: - Settings reader + + @Test + func `settings reader trims whitespace and quotes`() { + #expect(XAISettingsReader.apiKey(environment: [ + XAISettingsReader.apiKeyEnvironmentKey: " 'fixture-management-key' ", + ]) == "fixture-management-key") + #expect(XAISettingsReader.apiKey(environment: [:]) == nil) + #expect(XAISettingsReader.apiKey(environment: [ + XAISettingsReader.apiKeyEnvironmentKey: " ", + ]) == nil) + #expect(XAISettingsReader.teamID(environment: [ + XAISettingsReader.teamIDEnvironmentKey: " \"team-1234\" ", + ]) == "team-1234") + #expect(XAISettingsReader.teamID(environment: [:]) == nil) + } + + // MARK: - Fetch: request shape + + @Test + func `balance and usage requests use documented endpoints and bearer auth`() async throws { + let now = Date(timeIntervalSince1970: 1_800_000_000) // 2027-01-15 08:00:00 UTC + let transport = ProviderHTTPTransportStub { request in + let url = try #require(request.url) + #expect(url.scheme == "https") + #expect(url.host == "management-api.x.ai") + #expect(url.user == nil) + #expect(url.password == nil) + #expect(url.query == nil) + #expect(url.fragment == nil) + #expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer fixture-management-key") + #expect(request.value(forHTTPHeaderField: "Accept") == "application/json") + switch url.path { + case "/v1/billing/teams/team-1234/prepaid/balance": + #expect(request.httpMethod == "GET") + return Self.response(url: url, body: Self.balanceFixture) + case "/v1/billing/teams/team-1234/usage": + #expect(request.httpMethod == "POST") + #expect(request.value(forHTTPHeaderField: "Content-Type") == "application/json") + let body = try #require(request.httpBody) + let payload = try #require( + try JSONSerialization.jsonObject(with: body) as? [String: Any]) + let analytics = try #require(payload["analyticsRequest"] as? [String: Any]) + let timeRange = try #require(analytics["timeRange"] as? [String: Any]) + #expect(timeRange["startTime"] as? String == "2026-12-17 00:00:00") + #expect(timeRange["endTime"] as? String == "2027-01-15 08:00:00") + #expect(timeRange["timezone"] as? String == "Etc/GMT") + #expect(analytics["timeUnit"] as? String == "TIME_UNIT_DAY") + let values = try #require(analytics["values"] as? [[String: Any]]) + #expect(values.count == 1) + #expect(values.first?["name"] as? String == "usd") + #expect(values.first?["aggregation"] as? String == "AGGREGATION_SUM") + #expect(analytics["groupBy"] as? [String] == []) + #expect(analytics["filters"] as? [String] == []) + return Self.response(url: url, body: Self.usageFixture) + default: + Issue.record("unexpected request path \(url.path)") + throw URLError(.badURL) + } + } + + let usage = try await XAIBillingFetcher.fetchUsage( + managementKey: "fixture-management-key", + teamID: "team-1234", + transport: transport, + now: now) + + #expect(await transport.requests().count == 2) + // Docs example: a $10 top-up shows total.val = "-1000" (string USD cents, + // inverted ledger), so the remaining balance is +$10.00. + #expect(usage.balanceUSD == 10.0) + #expect(!usage.limitReached) + #expect(usage.historyDays == 30) + #expect(usage.updatedAt == now) + // Two series contribute to the same days; sums are per-day and zeros stay dense. + #expect(usage.daily.map(\.day) == ["2027-01-13", "2027-01-14", "2027-01-15"]) + let costs = usage.daily.map(\.costUSD) + #expect(costs.count == 3) + #expect(abs(costs[0] - 1.25973725) < 1e-9) + #expect(costs[1] == 0.5) + #expect(costs[2] == 0) + } + + @Test + func `management key is only sent as a bearer header`() async throws { + let transport = Self.happyTransport() + + _ = try await XAIBillingFetcher.fetchUsage( + managementKey: "fixture-management-key", + teamID: "team-1234", + transport: transport) + + for request in await transport.requests() { + let url = try #require(request.url) + #expect(!url.absoluteString.contains("fixture-management-key")) + #expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer fixture-management-key") + } + } + + @Test + func `team id is encoded as a single path component`() async throws { + let transport = ProviderHTTPTransportStub { request in + let url = try #require(request.url) + #expect(!url.absoluteString.contains("team one")) + #expect(url.absoluteString.contains("team%20one")) + if url.path.hasSuffix("/prepaid/balance") { + return Self.response(url: url, body: Self.balanceFixture) + } + return Self.response(url: url, body: Self.usageFixture) + } + + _ = try await XAIBillingFetcher.fetchUsage( + managementKey: "fixture-management-key", + teamID: "team one", + transport: transport) + } + + @Test + func `team id with path separators is rejected`() async { + await #expect { + _ = try await XAIBillingFetcher.fetchUsage( + managementKey: "fixture-management-key", + teamID: "team/../other", + transport: Self.happyTransport()) + } throws: { error in + error as? XAIBillingError == .invalidTeamID + } + } + + // MARK: - Fetch: balance mapping + + @Test + func `positive ledger total maps to a negative remaining balance`() async throws { + let usage = try await Self.fetch(balanceBody: #"{"changes":[],"total":{"val":"2500"}}"#) + #expect(usage.balanceUSD == -25.0) + } + + @Test + func `zero total maps to a zero balance`() async throws { + let usage = try await Self.fetch(balanceBody: #"{"changes":[],"total":{"val":"0"}}"#) + #expect(usage.balanceUSD == 0) + } + + @Test + func `fractional cent strings convert exactly`() async throws { + let usage = try await Self.fetch(balanceBody: #"{"changes":[],"total":{"val":"-333"}}"#) + #expect(usage.balanceUSD == 3.33) + } + + @Test + func `malformed 200 balance is a parse error not a zero balance`() async { + for body in [ + "{}", + #"{"total":{}}"#, + #"{"total":{"val":""}}"#, + #"{"total":{"val":"n/a"}}"#, + #"{"total":{"val":"12abc"}}"#, + #"{"error":"forbidden"}"#, + ] { + await #expect { + _ = try await Self.fetch(balanceBody: body) + } throws: { error in + guard case .parseFailed = error as? XAIBillingError else { return false } + return true + } + } + } + + // MARK: - Fetch: usage mapping + + @Test + func `usage history failure preserves the balance`() async throws { + let transport = ProviderHTTPTransportStub { request in + let url = try #require(request.url) + if url.path.hasSuffix("/prepaid/balance") { + return Self.response(url: url, body: Self.balanceFixture) + } + return Self.response(url: url, body: #"{"error":"unavailable"}"#, statusCode: 500) + } + + let usage = try await XAIBillingFetcher.fetchUsage( + managementKey: "fixture-management-key", + teamID: "team-1234", + transport: transport) + + #expect(usage.balanceUSD == 10.0) + #expect(usage.daily.isEmpty) + #expect(!usage.limitReached) + } + + @Test + func `usage auth failure is not hidden by the balance success`() async { + let transport = ProviderHTTPTransportStub { request in + let url = try #require(request.url) + if url.path.hasSuffix("/prepaid/balance") { + return Self.response(url: url, body: Self.balanceFixture) + } + return Self.response(url: url, body: #"{"error":"unauthorized"}"#, statusCode: 401) + } + + await #expect { + _ = try await XAIBillingFetcher.fetchUsage( + managementKey: "fixture-management-key", + teamID: "team-1234", + transport: transport) + } throws: { error in + error as? XAIBillingError == .authenticationRejected + } + } + + @Test + func `limitReached marks the history partial`() async throws { + let body = Self.usageFixture.replacingOccurrences( + of: #""limitReached": false"#, + with: #""limitReached": true"#) + let usage = try await Self.fetch(usageBody: body) + #expect(usage.limitReached) + } + + @Test + func `malformed usage payload degrades to balance only`() async throws { + let usage = try await Self.fetch(usageBody: #"{"object":"list"}"#) + #expect(usage.balanceUSD == 10.0) + #expect(usage.daily.isEmpty) + } + + // MARK: - Fetch: errors + + @Test + func `missing or whitespace credential fails clearly`() async { + await #expect { + _ = try await XAIBillingFetcher.fetchUsage( + managementKey: " ", + teamID: "team-1234", + transport: Self.happyTransport()) + } throws: { error in + error as? XAIBillingError == .notConfigured + } + #expect(XAIBillingError.notConfigured.errorDescription?.contains("XAI_MANAGEMENT_API_KEY") == true) + } + + @Test + func `missing team id fails with actionable guidance`() async { + await #expect { + _ = try await XAIBillingFetcher.fetchUsage( + managementKey: "fixture-management-key", + teamID: " ", + transport: Self.happyTransport()) + } throws: { error in + error as? XAIBillingError == .missingTeamID + } + #expect(XAIBillingError.missingTeamID.errorDescription?.contains("XAI_TEAM_ID") == true) + } + + @Test + func `401 maps to management key guidance`() async { + await #expect { + _ = try await Self.fetch(balanceBody: #"{"error":"unauthorized"}"#, balanceStatus: 401) + } throws: { error in + error as? XAIBillingError == .authenticationRejected + } + #expect(XAIBillingError.authenticationRejected.errorDescription?.contains("Management") == true) + } + + @Test + func `403 maps to management key guidance`() async { + await #expect { + _ = try await Self.fetch(balanceBody: #"{"error":"forbidden"}"#, balanceStatus: 403) + } throws: { error in + error as? XAIBillingError == .authenticationRejected + } + } + + @Test + func `404 maps to team id guidance`() async { + await #expect { + _ = try await Self.fetch(balanceBody: #"{"error":"not found"}"#, balanceStatus: 404) + } throws: { error in + error as? XAIBillingError == .teamNotFound + } + #expect(XAIBillingError.teamNotFound.errorDescription?.contains("team") == true) + } + + @Test + func `429 is surfaced as rate limiting`() async { + await #expect { + _ = try await Self.fetch(balanceBody: #"{"error":"slow down"}"#, balanceStatus: 429) + } throws: { error in + error as? XAIBillingError == .rateLimited + } + } + + @Test + func `unexpected status is reported with its code`() async { + await #expect { + _ = try await Self.fetch(balanceBody: #"{"error":"boom"}"#, balanceStatus: 503) + } throws: { error in + error as? XAIBillingError == .apiError(503) + } + } + + // MARK: - Snapshot projections + + @Test + func `usage snapshot projects balance and history into shared models`() throws { + let now = Date(timeIntervalSince1970: 1_800_000_000) // 2027-01-15 UTC + let usage = XAIUsageSnapshot( + balanceUSD: 7.36, + daily: [ + .init(day: "2027-01-15", costUSD: 0.25), + .init(day: "2027-01-14", costUSD: 1.5), + ], + updatedAt: now) + let snapshot = usage.toUsageSnapshot() + + #expect(snapshot.primary == nil) + #expect(snapshot.secondary == nil) + #expect(snapshot.providerCost?.used == 7.36) + #expect(snapshot.providerCost?.limit == 0) + #expect(snapshot.providerCost?.currencyCode == "USD") + #expect(snapshot.providerCost?.period == "Prepaid credits") + #expect(snapshot.xaiUsage == usage) + #expect(snapshot.identity?.providerID == .xai) + #expect(snapshot.identity?.loginMethod == "Management API") + #expect(snapshot.dataConfidence == .exact) + + let token = try #require(usage.costHistorySnapshot()) + // Ascending day order; a nil costUSD would silently drop the day from the chart. + #expect(token.daily.map(\.date) == ["2027-01-14", "2027-01-15"]) + #expect(token.daily.compactMap(\.costUSD) == [1.5, 0.25]) + #expect(token.sessionCostUSD == 0.25) + #expect(token.last30DaysCostUSD == 1.75) + #expect(token.currencyCode == "USD") + #expect(token.historyDays == 30) + #expect(token.sessionTokens == nil) + #expect(token.last30DaysTokens == nil) + } + + @Test + func `limitReached lowers confidence and labels the history partial`() throws { + let usage = XAIUsageSnapshot( + balanceUSD: 1, + daily: [.init(day: "2027-01-15", costUSD: 0.5)], + limitReached: true, + updatedAt: Date(timeIntervalSince1970: 1_800_000_000)) + + #expect(usage.toUsageSnapshot().dataConfidence == .estimated) + let token = try #require(usage.costHistorySnapshot()) + #expect(token.historyLabel == "Last 30 days (partial)") + } + + @Test + func `empty history yields no cost history snapshot`() { + let usage = XAIUsageSnapshot( + balanceUSD: 1, + daily: [], + updatedAt: Date(timeIntervalSince1970: 1_800_000_000)) + #expect(usage.costHistorySnapshot() == nil) + #expect(usage.toUsageSnapshot().providerCost?.used == 1) + } + + @Test + func `snapshot round trips through Codable with xai usage preserved`() throws { + let now = Date(timeIntervalSince1970: 1_800_000_000) + let usage = XAIUsageSnapshot( + balanceUSD: 7.36, + daily: [.init(day: "2027-01-15", costUSD: 0.25)], + updatedAt: now) + let encoded = try JSONEncoder().encode(usage.toUsageSnapshot()) + let decoded = try JSONDecoder().decode(UsageSnapshot.self, from: encoded) + #expect(decoded.xaiUsage == usage) + #expect(decoded.providerCost?.used == 7.36) + } + + // MARK: - Registration and wiring + + @Test @MainActor + func `descriptor and app registry include xai`() throws { + let descriptor = ProviderDescriptorRegistry.descriptor(for: .xai) + #expect(descriptor.metadata.displayName == "xAI") + #expect(descriptor.metadata.cliName == "xai") + #expect(descriptor.metadata.defaultEnabled == false) + #expect(!descriptor.metadata.supportsCredits) + #expect(!descriptor.tokenCost.supportsTokenCost) + #expect(descriptor.fetchPlan.sourceModes == [.auto, .api]) + #expect(descriptor.cli.aliases.isEmpty) + + let implementation = try #require(ProviderImplementationRegistry.implementation(for: .xai)) + #expect(implementation is XAIProviderImplementation) + } + + @Test + func `config API key and team ID project into the fetch environment`() { + let env = ProviderConfigEnvironment.applyAPIKeyOverride( + base: [ + XAISettingsReader.apiKeyEnvironmentKey: "environment-key", + XAISettingsReader.teamIDEnvironmentKey: "environment-team", + ], + provider: .xai, + config: ProviderConfig(id: .xai, apiKey: "config-key", workspaceID: "config-team")) + + #expect(XAISettingsReader.apiKey(environment: env) == "config-key") + #expect(XAISettingsReader.teamID(environment: env) == "config-team") + #expect(ProviderConfigEnvironment.supportsAPIKeyOverride(for: .xai)) + } + + @Test @MainActor + func `menu card renders the prepaid balance line`() { + let now = Date(timeIntervalSince1970: 1_800_000_000) + let usage = XAIUsageSnapshot( + balanceUSD: 7.36, + daily: [.init(day: "2027-01-15", costUSD: 0.25)], + updatedAt: now) + let model = UsageMenuCardView.Model.make(.init( + provider: .xai, + metadata: XAIProviderDescriptor.descriptor.metadata, + snapshot: usage.toUsageSnapshot(), + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: true, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + + #expect(model.metrics.isEmpty) + #expect(model.creditsText == nil) + #expect(model.providerCost?.title == "Credits") + #expect(model.providerCost?.spendLine == "Balance: $7.36") + #expect(model.providerCost?.percentUsed == nil) + #expect(model.providerCost?.percentLine == nil) + } + + @Test + func `CLI text renders the prepaid balance instead of the generic cost fallback`() { + let usage = XAIUsageSnapshot( + balanceUSD: 7.36, + daily: [ + .init(day: "2027-01-14", costUSD: 1.5), + .init(day: "2027-01-15", costUSD: 0.25), + ], + updatedAt: Date(timeIntervalSince1970: 1_800_000_000)) + let text = CLIRenderer.renderText( + provider: .xai, + snapshot: usage.toUsageSnapshot(), + credits: nil, + context: RenderContext( + header: "xAI (api)", + status: nil, + useColor: false, + resetStyle: .countdown)) + + #expect(text.contains("Balance: $7.36")) + #expect(text.contains("Last 30 days: $1.75")) + // The generic no-window fallback would print "Cost: 7.4 / 0.0", which + // presents the balance as a spend against a zero budget. + #expect(!text.contains("Cost:")) + // `plan.capitalized` would mangle the login method into "Management Api". + #expect(text.contains("Plan: Management API")) + } + + // MARK: - Helpers + + private static func fetch( + balanceBody: String = balanceFixture, + balanceStatus: Int = 200, + usageBody: String = usageFixture, + usageStatus: Int = 200, + now: Date = Date(timeIntervalSince1970: 1_800_000_000)) async throws -> XAIUsageSnapshot + { + let transport = ProviderHTTPTransportStub { request in + let url = try #require(request.url) + if url.path.hasSuffix("/prepaid/balance") { + return Self.response(url: url, body: balanceBody, statusCode: balanceStatus) + } + return Self.response(url: url, body: usageBody, statusCode: usageStatus) + } + return try await XAIBillingFetcher.fetchUsage( + managementKey: "fixture-management-key", + teamID: "team-1234", + transport: transport, + now: now) + } + + private static func happyTransport() -> ProviderHTTPTransportStub { + ProviderHTTPTransportStub { request in + let url = try #require(request.url) + if url.path.hasSuffix("/prepaid/balance") { + return Self.response(url: url, body: Self.balanceFixture) + } + return Self.response(url: url, body: Self.usageFixture) + } + } + + /// Shape from the documented balance example: a $10 top-up (PURCHASE) recorded + /// as "-1000" cents, with the running total in the same inverted-ledger unit. + static let balanceFixture = #""" + { + "changes": [ + { + "teamId": "team-1234", + "changeOrigin": "PURCHASE", + "topupStatus": "SUCCEEDED", + "amount": { "val": "-1000" }, + "invoiceId": "fixture-invoice-id", + "invoiceNumber": "000-000-000-001", + "createTime": "2026-12-24T15:28:02.308840Z", + "paymentProcessor": { "kind": "STRIPE" } + } + ], + "total": { "val": "-1000" } + } + """# + + /// Shape from the documented usage example: dense daily buckets per series, + /// numeric USD values, `limitReached` cardinality marker. + static let usageFixture = #""" + { + "timeSeries": [ + { + "group": ["Chat grok-4-fixture"], + "groupLabels": ["Chat grok-4-fixture"], + "dataPoints": [ + { "timestamp": "2027-01-13T00:00:00Z", "values": [0.75973725] }, + { "timestamp": "2027-01-14T00:00:00Z", "values": [0.5] }, + { "timestamp": "2027-01-15T00:00:00Z", "values": [0] } + ] + }, + { + "group": ["Live search"], + "groupLabels": ["Live search"], + "dataPoints": [ + { "timestamp": "2027-01-13T00:00:00Z", "values": [0.5] }, + { "timestamp": "2027-01-14T00:00:00Z", "values": [0] }, + { "timestamp": "2027-01-15T00:00:00Z", "values": [0] } + ] + } + ], + "limitReached": false + } + """# + + static func response( + url: URL, + body: String, + statusCode: Int = 200) -> (Data, URLResponse) + { + let response = HTTPURLResponse( + url: url, + statusCode: statusCode, + httpVersion: "HTTP/1.1", + headerFields: ["Content-Type": "application/json"])! + return (Data(body.utf8), response) + } +} diff --git a/Tests/CodexBarTests/ZaiProviderTests.swift b/Tests/CodexBarTests/ZaiProviderTests.swift index 64c1bdbfa7..ea46854179 100644 --- a/Tests/CodexBarTests/ZaiProviderTests.swift +++ b/Tests/CodexBarTests/ZaiProviderTests.swift @@ -27,6 +27,30 @@ struct ZaiSettingsReaderTests { .quotaURL(environment: [ZaiSettingsReader.quotaURLKey: "open.bigmodel.cn/api/coding"]) #expect(url?.absoluteString == "https://open.bigmodel.cn/api/coding") } + + @Test + func `endpoint override validation accepts HTTPS and bare hosts`() throws { + try ZaiSettingsReader.validateEndpointOverrides(environment: [ + ZaiSettingsReader.quotaURLKey: "https://open.bigmodel.cn/api/coding", + ]) + try ZaiSettingsReader.validateEndpointOverrides(environment: [ + ZaiSettingsReader.apiHostKey: "open.bigmodel.cn", + ]) + } + + @Test + func `endpoint override validation rejects insecure URLs`() { + #expect(throws: ZaiSettingsError.invalidEndpointOverride(ZaiSettingsReader.quotaURLKey)) { + try ZaiSettingsReader.validateEndpointOverrides(environment: [ + ZaiSettingsReader.quotaURLKey: "http://attacker.test/quota", + ]) + } + #expect(throws: ZaiSettingsError.invalidEndpointOverride(ZaiSettingsReader.apiHostKey)) { + try ZaiSettingsReader.validateEndpointOverrides(environment: [ + ZaiSettingsReader.apiHostKey: "http://attacker.test", + ]) + } + } } struct ZaiUsageSnapshotTests { @@ -286,6 +310,20 @@ struct ZaiUsageParsingTests { } } + @Test + func `failed response without message reports the API code`() { + let json = """ + { "code": 1001, "success": false } + """ + + #expect { + _ = try ZaiUsageFetcher.parseUsageSnapshot(from: Data(json.utf8)) + } throws: { error in + guard case let ZaiUsageError.apiError(message) = error else { return false } + return message == "Z.ai quota API returned code 1001" + } + } + @Test func `success without data returns parse failed`() { let json = """ @@ -363,6 +401,340 @@ struct ZaiUsageParsingTests { #expect(snapshot.tokenLimit?.windowMinutes == 300) #expect(snapshot.timeLimit?.usage == 100) } + + @Test + func `parses BigModel CN quota response without message`() throws { + let json = """ + { + "code": 200, + "data": { + "limits": [ + { + "type": "TIME_LIMIT", + "unit": 5, + "number": 1, + "usage": 1000, + "currentValue": 147, + "remaining": 853, + "percentage": 14, + "nextResetTime": 1784706344993, + "usageDetails": [ + { "modelCode": "search-prime", "usage": 84 }, + { "modelCode": "web-reader", "usage": 41 }, + { "modelCode": "zread", "usage": 8 } + ] + }, + { + "type": "TOKENS_LIMIT", + "unit": 3, + "number": 5, + "percentage": 8, + "nextResetTime": 1783049703178 + }, + { + "type": "TOKENS_LIMIT", + "unit": 6, + "number": 1, + "percentage": 7, + "nextResetTime": 1783496744998 + } + ], + "level": "pro" + }, + "success": true + } + """ + + let snapshot = try ZaiUsageFetcher.parseUsageSnapshot(from: Data(json.utf8)) + let usage = snapshot.toUsageSnapshot() + + #expect(usage.primary?.usedPercent == 7) + #expect(usage.secondary?.usedPercent == 14.7) + #expect(usage.tertiary?.usedPercent == 8) + } +} + +struct ZaiBigModelTeamScopeTests { + @Test + func `team scope appends type 2 and sends BigModel project headers`() async throws { + let transport = ProviderHTTPTransportStub { request in + let json = """ + { + "code": 200, + "msg": "操作成功", + "data": { + "level": "pro", + "limits": [ + { + "type": "TIME_LIMIT", + "unit": 5, + "number": 1, + "usage": 1000, + "currentValue": 224, + "remaining": 776, + "percentage": 22, + "nextResetTime": 1777575229998, + "usageDetails": [] + }, + { + "type": "TOKENS_LIMIT", + "unit": 3, + "number": 5, + "percentage": 25, + "nextResetTime": 1775020168897 + }, + { + "type": "TOKENS_LIMIT", + "unit": 6, + "number": 1, + "percentage": 9, + "nextResetTime": 1775588029998 + } + ] + }, + "success": true + } + """ + return ( + Data(json.utf8), + HTTPURLResponse( + url: request.url!, + statusCode: 200, + httpVersion: nil, + headerFields: nil)!) + } + + let snapshot = try await ZaiUsageFetcher.fetchUsage( + apiKey: "zai-test-token", + region: .bigmodelCN, + usageScope: .team, + teamContext: ZaiBigModelTeamContext( + organizationID: "org-test", + projectID: "proj-test"), + environment: [:], + transport: transport) + + let requests = await transport.requests() + let request = try #require(requests.first) + + #expect(request.url?.absoluteString == "https://open.bigmodel.cn/api/monitor/usage/quota/limit?type=2") + #expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer zai-test-token") + #expect(request.value(forHTTPHeaderField: "Bigmodel-Organization") == "org-test") + #expect(request.value(forHTTPHeaderField: "Bigmodel-Project") == "proj-test") + #expect(snapshot.tokenLimit?.unit == .weeks) + #expect(snapshot.sessionTokenLimit?.unit == .hours) + #expect(snapshot.timeLimit?.usage == 1000) + } + + @Test + func `personal scope keeps existing quota URL and omits team headers`() async throws { + let transport = ProviderHTTPTransportStub { request in + let json = """ + { + "code": 200, + "msg": "Operation successful", + "data": { + "limits": [ + { + "type": "TOKENS_LIMIT", + "unit": 3, + "number": 5, + "percentage": 34, + "nextResetTime": 1768507567547 + } + ] + }, + "success": true + } + """ + return ( + Data(json.utf8), + HTTPURLResponse( + url: request.url!, + statusCode: 200, + httpVersion: nil, + headerFields: nil)!) + } + + _ = try await ZaiUsageFetcher.fetchUsage( + apiKey: "zai-test-token", + region: .bigmodelCN, + usageScope: .personal, + teamContext: ZaiBigModelTeamContext( + organizationID: "org-test", + projectID: "proj-test"), + environment: [:], + transport: transport) + + let requests = await transport.requests() + let request = try #require(requests.first) + + #expect(request.url?.absoluteString == "https://open.bigmodel.cn/api/monitor/usage/quota/limit") + #expect(request.value(forHTTPHeaderField: "Bigmodel-Organization") == nil) + #expect(request.value(forHTTPHeaderField: "Bigmodel-Project") == nil) + } + + @Test + func `team scope requires complete BigModel context`() async { + let transport = ProviderHTTPTransportStub { request in + ( + Data(), + HTTPURLResponse( + url: request.url!, + statusCode: 200, + httpVersion: nil, + headerFields: nil)!) + } + + await self.expectMissingTeamContext { + _ = try await ZaiUsageFetcher.fetchUsage( + apiKey: "zai-test-token", + region: .bigmodelCN, + usageScope: .team, + teamContext: nil, + environment: [:], + transport: transport) + } + + let requests = await transport.requests() + #expect(requests.isEmpty) + + await self.expectMissingTeamContext { + _ = try await ZaiUsageFetcher.fetchUsage( + apiKey: "zai-test-token", + region: .bigmodelCN, + usageScope: .team, + teamContext: nil, + environment: [ZaiSettingsReader.bigModelOrganizationKey: "org-only"], + transport: transport) + } + + await self.expectMissingTeamContext { + _ = try await ZaiUsageFetcher.fetchUsage( + apiKey: "zai-test-token", + region: .bigmodelCN, + usageScope: .team, + teamContext: nil, + environment: [ZaiSettingsReader.bigModelProjectKey: "proj-only"], + transport: transport) + } + } + + @Test + func `team model usage appends type 3 and sends BigModel project headers`() async throws { + let transport = ProviderHTTPTransportStub { request in + let json = """ + { + "code": 200, + "msg": "success", + "success": true, + "data": { + "x_time": ["2026-06-21 08:00"], + "modelDataList": [ + { "modelName": "glm-4.6", "tokensUsage": [100] } + ] + } + } + """ + return ( + Data(json.utf8), + HTTPURLResponse( + url: request.url!, + statusCode: 200, + httpVersion: nil, + headerFields: nil)!) + } + + let usage = try await ZaiUsageFetcher.fetchModelUsage( + apiKey: "zai-test-token", + region: .bigmodelCN, + usageScope: .team, + teamContext: ZaiBigModelTeamContext( + organizationID: "org-test", + projectID: "proj-test"), + environment: [:], + transport: transport) + + let requests = await transport.requests() + let request = try #require(requests.first) + let requestURL = try #require(request.url) + let components = try #require(URLComponents(url: requestURL, resolvingAgainstBaseURL: false)) + + #expect(components.path == "/api/monitor/usage/model-usage") + #expect(components.queryItems?.first { $0.name == "type" }?.value == "3") + #expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer zai-test-token") + #expect(request.value(forHTTPHeaderField: "Bigmodel-Organization") == "org-test") + #expect(request.value(forHTTPHeaderField: "Bigmodel-Project") == "proj-test") + #expect(usage.modelNames == ["glm-4.6"]) + } + + @Test + func `team quota rejects insecure override before sending credentials`() async throws { + let transport = ProviderHTTPTransportStub { request in + Issue.record("Unexpected z.ai team quota request to \(request.url?.absoluteString ?? "")") + throw URLError(.badURL) + } + + await #expect(throws: ZaiSettingsError.invalidEndpointOverride(ZaiSettingsReader.quotaURLKey)) { + try await ZaiUsageFetcher.fetchUsage( + apiKey: "zai-test-token", + region: .bigmodelCN, + usageScope: .team, + teamContext: ZaiBigModelTeamContext( + organizationID: "org-test", + projectID: "proj-test"), + environment: [ZaiSettingsReader.quotaURLKey: "http://attacker.test/quota"], + transport: transport) + } + + let requests = await transport.requests() + #expect(requests.isEmpty) + } + + @Test + func `team model usage rejects insecure API host before sending credentials`() async throws { + let transport = ProviderHTTPTransportStub { request in + Issue.record("Unexpected z.ai team model usage request to \(request.url?.absoluteString ?? "")") + throw URLError(.badURL) + } + + await #expect(throws: ZaiSettingsError.invalidEndpointOverride(ZaiSettingsReader.apiHostKey)) { + try await ZaiUsageFetcher.fetchModelUsage( + apiKey: "zai-test-token", + region: .bigmodelCN, + usageScope: .team, + teamContext: ZaiBigModelTeamContext( + organizationID: "org-test", + projectID: "proj-test"), + environment: [ZaiSettingsReader.apiHostKey: "http://attacker.test"], + transport: transport) + } + + let requests = await transport.requests() + #expect(requests.isEmpty) + } + + private func expectMissingTeamContext(_ operation: () async throws -> Void) async { + do { + try await operation() + Issue.record("Expected z.ai missing team context error.") + } catch ZaiUsageError.missingTeamContext { + // Expected. + } catch { + Issue.record("Expected z.ai missing team context error, got \(error).") + } + } + + @Test + func `team context can be resolved from environment`() { + let env = [ + ZaiSettingsReader.bigModelOrganizationKey: " org-env ", + ZaiSettingsReader.bigModelProjectKey: " proj-env ", + ] + + #expect(ZaiBigModelTeamContext(environment: env)?.organizationID == "org-env") + #expect(ZaiBigModelTeamContext(environment: env)?.projectID == "proj-env") + } } struct ZaiHourlyUsageTests { @@ -586,6 +958,19 @@ struct ZaiThreeLimitTests { } struct ZaiAPIRegionTests { + @Test + func `dashboard URLs follow selected region`() { + #expect( + ZaiAPIRegion.global.dashboardURL.absoluteString == + "https://z.ai/manage-apikey/coding-plan/personal/my-plan") + #expect( + ZaiAPIRegion.bigmodelCN.dashboardURL.absoluteString == + "https://bigmodel.cn/coding-plan/personal/usage") + #expect( + ZaiProviderDescriptor.descriptor.metadata.dashboardURL == + ZaiAPIRegion.global.dashboardURL.absoluteString) + } + @Test func `defaults to global endpoint`() { let url = ZaiUsageFetcher.resolveQuotaURL(region: .global, environment: [:]) @@ -611,4 +996,26 @@ struct ZaiAPIRegionTests { let url = ZaiUsageFetcher.resolveQuotaURL(region: .global, environment: env) #expect(url.absoluteString == "https://open.bigmodel.cn/api/monitor/usage/quota/limit") } + + @Test + func `dashboard follows known endpoint overrides`() { + let china = ZaiUsageFetcher.resolveDashboardURL( + region: .global, + environment: [ZaiSettingsReader.apiHostKey: "open.bigmodel.cn"]) + #expect(china == ZaiAPIRegion.bigmodelCN.dashboardURL) + + let global = ZaiUsageFetcher.resolveDashboardURL( + region: .bigmodelCN, + environment: [ZaiSettingsReader.apiHostKey: "api.z.ai"]) + #expect(global == ZaiAPIRegion.global.dashboardURL) + } + + @Test + func `dashboard keeps selected region for custom endpoint override`() { + let dashboard = ZaiUsageFetcher.resolveDashboardURL( + region: .bigmodelCN, + environment: [ZaiSettingsReader.apiHostKey: "zai.internal.example"]) + + #expect(dashboard == ZaiAPIRegion.bigmodelCN.dashboardURL) + } } diff --git a/Tests/CodexBarTests/ZaiTokenAccountEnvironmentTests.swift b/Tests/CodexBarTests/ZaiTokenAccountEnvironmentTests.swift new file mode 100644 index 0000000000..48c4239270 --- /dev/null +++ b/Tests/CodexBarTests/ZaiTokenAccountEnvironmentTests.swift @@ -0,0 +1,165 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +@MainActor +@Suite(.serialized) +struct ZaiTokenAccountEnvironmentTests { + @Test + func `zai selected team account injects team scope environment`() { + let settings = Self.makeSettingsStore(suite: "ZaiTokenAccountEnvironmentTests-team-app") + settings.addTokenAccount( + provider: .zai, + label: "Team", + token: "account-token", + usageScope: "team", + organizationID: " org-account ", + workspaceID: " proj-account ") + + let env = ProviderRegistry.makeEnvironment( + base: [ + ZaiSettingsReader.bigModelOrganizationKey: "org-env", + ZaiSettingsReader.bigModelProjectKey: "proj-env", + ], + provider: .zai, + settings: settings, + tokenOverride: nil) + + #expect(env[ZaiSettingsReader.apiTokenKey] == "account-token") + #expect(env[ZaiSettingsReader.bigModelOrganizationKey] == "org-account") + #expect(env[ZaiSettingsReader.bigModelProjectKey] == "proj-account") + } + + @Test + func `zai selected personal account clears inherited team environment`() { + let settings = Self.makeSettingsStore(suite: "ZaiTokenAccountEnvironmentTests-personal-app") + settings.addTokenAccount( + provider: .zai, + label: "Personal", + token: "account-token", + usageScope: "personal") + + let env = ProviderRegistry.makeEnvironment( + base: [ + ZaiSettingsReader.bigModelOrganizationKey: "org-env", + ZaiSettingsReader.bigModelProjectKey: "proj-env", + ], + provider: .zai, + settings: settings, + tokenOverride: nil) + + #expect(env[ZaiSettingsReader.apiTokenKey] == "account-token") + #expect(env[ZaiSettingsReader.bigModelOrganizationKey] == nil) + #expect(env[ZaiSettingsReader.bigModelProjectKey] == nil) + } + + @Test + func `zai account switched back to personal clears stored team context`() throws { + let settings = Self.makeSettingsStore(suite: "ZaiTokenAccountEnvironmentTests-team-to-personal") + settings.addTokenAccount( + provider: .zai, + label: "Team", + token: "account-token", + usageScope: "team", + organizationID: "org-account", + workspaceID: "proj-account") + let account = try #require(settings.selectedTokenAccount(for: .zai)) + + settings.updateTokenAccount( + provider: .zai, + accountID: account.id, + usageScope: .some("personal"), + organizationID: .some(nil), + workspaceID: .some(nil)) + + let updated = try #require(settings.selectedTokenAccount(for: .zai)) + #expect(updated.usageScope == "personal") + #expect(updated.organizationID == nil) + #expect(updated.workspaceID == nil) + } + + @Test + func `zai selected team account overrides app settings snapshot`() { + let settings = Self.makeSettingsStore(suite: "ZaiTokenAccountEnvironmentTests-team-snapshot") + settings.addTokenAccount( + provider: .zai, + label: "Team", + token: "account-token", + usageScope: "team", + organizationID: " org-account ", + workspaceID: " proj-account ") + + let snapshot = ProviderRegistry.makeSettingsSnapshot(settings: settings, tokenOverride: nil).zai + + #expect(snapshot?.usageScope == .team) + #expect(snapshot?.teamContext?.organizationID == "org-account") + #expect(snapshot?.teamContext?.projectID == "proj-account") + } + + @Test + func `zai explicit team account does not inherit provider team context`() { + let settings = Self.makeSettingsStore(suite: "ZaiTokenAccountEnvironmentTests-empty-team-snapshot") + settings.addTokenAccount( + provider: .zai, + label: "Team", + token: "account-token", + usageScope: "team") + + let snapshot = ProviderRegistry.makeSettingsSnapshot(settings: settings, tokenOverride: nil).zai + + #expect(snapshot?.usageScope == .team) + #expect(snapshot?.teamContext == nil) + } + + @Test + func `zai token account usage scope and project id round trip through JSON`() throws { + let json = """ + { + "id": "00000000-0000-0000-0000-000000000001", + "label": "Team", + "token": "account-token", + "addedAt": 0, + "lastUsed": null, + "usageScope": "team", + "organizationId": "org-team", + "workspaceID": "proj-team" + } + """ + let account = try JSONDecoder().decode(ProviderTokenAccount.self, from: Data(json.utf8)) + let encoded = try JSONSerialization.jsonObject(with: JSONEncoder().encode(account)) as? [String: Any] + + #expect(account.sanitizedUsageScope == "team") + #expect(account.sanitizedOrganizationID == "org-team") + #expect(account.sanitizedWorkspaceID == "proj-team") + #expect(encoded?["usageScope"] as? String == "team") + #expect(encoded?["organizationId"] as? String == "org-team") + #expect(encoded?["workspaceID"] as? String == "proj-team") + } +} + +extension ZaiTokenAccountEnvironmentTests { + fileprivate static func makeSettingsStore(suite: String) -> SettingsStore { + let defaults = UserDefaults(suiteName: suite)! + defaults.removePersistentDomain(forName: suite) + let configStore = testConfigStore(suiteName: suite) + + return SettingsStore( + userDefaults: defaults, + configStore: configStore, + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore(), + codexCookieStore: InMemoryCookieHeaderStore(), + claudeCookieStore: InMemoryCookieHeaderStore(), + cursorCookieStore: InMemoryCookieHeaderStore(), + opencodeCookieStore: InMemoryCookieHeaderStore(), + factoryCookieStore: InMemoryCookieHeaderStore(), + minimaxCookieStore: InMemoryMiniMaxCookieStore(), + minimaxAPITokenStore: InMemoryMiniMaxAPITokenStore(), + kimiTokenStore: InMemoryKimiTokenStore(), + augmentCookieStore: InMemoryCookieHeaderStore(), + ampCookieStore: InMemoryCookieHeaderStore(), + copilotTokenStore: InMemoryCopilotTokenStore(), + tokenAccountStore: InMemoryTokenAccountStore()) + } +} diff --git a/Tests/CodexBarTests/ZedStatusProbeTests.swift b/Tests/CodexBarTests/ZedStatusProbeTests.swift new file mode 100644 index 0000000000..148faef701 --- /dev/null +++ b/Tests/CodexBarTests/ZedStatusProbeTests.swift @@ -0,0 +1,314 @@ +import CodexBarCore +import Foundation +import Testing + +struct ZedStatusProbeTests { + private struct StubCredentialsReader: ZedCredentialsReading { + let credentials: ZedCredentials? + + func loadCredentials(serviceURL _: String) throws -> ZedCredentials? { + self.credentials + } + } + + private static let subscriptionPeriod = """ + "subscription_period": { + "started_at": "2026-05-13T00:00:00.000Z", + "ended_at": "2026-06-13T00:00:00.000Z" + } + """ + + private static func fixture(plan: String, used: Int, limit: String, overdue: Bool = false) -> Data { + Data( + """ + { + "user": { + "id": 4242, + "github_login": "octocat", + "name": "The Octocat" + }, + "feature_flags": [], + "plan": { + "plan_v3": "\(plan)", + \(self.subscriptionPeriod), + "usage": { + "edit_predictions": { + "used": \(used), + "limit": \(limit) + } + }, + "has_overdue_invoices": \(overdue) + } + } + """.utf8) + } + + private static func httpResponse(data: Data, statusCode: Int) -> (Data, URLResponse) { + let response = HTTPURLResponse( + url: URL(string: "https://cloud.zed.dev/client/users/me")!, + statusCode: statusCode, + httpVersion: nil, + headerFields: nil)! + return (data, response) + } + + @Test + func `decodes free plan with limited edit predictions`() throws { + let response = try ZedStatusProbe.parseResponse(Self.fixture(plan: "zed_free", used: 12, limit: "50")) + #expect(response.plan.planV3 == "zed_free") + #expect(response.plan.usage.editPredictions.used == 12) + #expect(response.plan.usage.editPredictions.limit == .limited(50)) + #expect(response.user.githubLogin == "octocat") + } + + @Test + func `decodes pro plan with unlimited edit predictions`() throws { + let response = try ZedStatusProbe.parseResponse(Self.fixture(plan: "zed_pro", used: 0, limit: "\"unlimited\"")) + #expect(response.plan.planV3 == "zed_pro") + #expect(response.plan.usage.editPredictions.limit == .unlimited) + } + + @Test + func `decodes pro trial student and business plans`() throws { + let trial = try ZedStatusProbe.parseResponse(Self.fixture( + plan: "zed_pro_trial", + used: 3, + limit: "\"unlimited\"")) + let student = try ZedStatusProbe.parseResponse(Self.fixture(plan: "zed_student", used: 1, limit: "25")) + let business = try ZedStatusProbe.parseResponse(Self.fixture( + plan: "zed_business", + used: 0, + limit: "\"unlimited\"")) + + #expect(trial.plan.planV3 == "zed_pro_trial") + #expect(student.plan.planV3 == "zed_student") + #expect(business.plan.planV3 == "zed_business") + } + + @Test + func `maps free plan to usage snapshot`() throws { + let response = try ZedStatusProbe.parseResponse(Self.fixture(plan: "zed_free", used: 10, limit: "20")) + let snapshot = ZedUsageSnapshot(response: response).toUsageSnapshot() + + #expect(snapshot.identity?.loginMethod == "Zed Free") + #expect(snapshot.identity?.accountEmail == "octocat") + #expect(snapshot.primary?.resetDescription == "10 / 20 predictions") + #expect(snapshot.primary?.usedPercent == 50) + #expect(snapshot.secondary?.resetsAt != nil) + #expect(snapshot.extraRateWindows == nil) + } + + @Test + func `maps pro plan with unlimited edit predictions`() throws { + let response = try ZedStatusProbe.parseResponse(Self.fixture(plan: "zed_pro", used: 0, limit: "\"unlimited\"")) + let snapshot = ZedUsageSnapshot(response: response).toUsageSnapshot() + + #expect(snapshot.identity?.loginMethod == "Zed Pro") + #expect(snapshot.primary?.resetDescription == "Unlimited") + #expect(snapshot.extraRateWindows == nil) + } + + @Test + func `maps overdue invoices warning window`() throws { + let response = try ZedStatusProbe.parseResponse( + Self.fixture(plan: "zed_pro", used: 0, limit: "\"unlimited\"", overdue: true)) + let snapshot = ZedUsageSnapshot(response: response).toUsageSnapshot() + + #expect(snapshot.extraRateWindows?.contains(where: { $0.id == "zed.overdue-invoices" }) == true) + } + + @Test + func `reads credentials url from settings`() { + let settings = ZedClientSettings( + credentialsURL: "https://preview.zed.dev", + serverURL: "https://zed.dev") + #expect(settings.keychainServiceURL == "https://preview.zed.dev") + + let fallback = ZedClientSettings(credentialsURL: nil, serverURL: "https://custom.zed.dev") + #expect(fallback.keychainServiceURL == "https://custom.zed.dev") + + let defaultSettings = ZedClientSettings(credentialsURL: nil, serverURL: nil) + #expect(defaultSettings.keychainServiceURL == ZedStatusProbe.defaultKeychainServiceURL) + } + + @Test + func `uses documented zed settings path`() { + let expected = FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent(".config/zed/settings.json") + #expect(ZedStatusProbe.defaultSettingsURL == expected) + } + + @Test + func `loads client settings from json`() throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("CodexBar-ZedSettings-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + + let settingsURL = directory.appendingPathComponent("settings.json") + try Data( + """ + { + "credentials_url": "zed-preview-key", + "server_url": "https://staging.zed.dev" + } + """.utf8) + .write(to: settingsURL) + + let settings = try #require(ZedClientSettings.load(from: settingsURL)) + #expect(settings.credentialsURL == "zed-preview-key") + #expect(settings.serverURL == "https://staging.zed.dev") + } + + @Test + func `maps server url independently from keychain identifier`() { + let production = ZedClientSettings(credentialsURL: "zed-preview-key", serverURL: "https://zed.dev") + let staging = ZedClientSettings(credentialsURL: nil, serverURL: "https://staging.zed.dev") + let localhost = ZedClientSettings(credentialsURL: nil, serverURL: "http://localhost:3000") + let custom = ZedClientSettings(credentialsURL: nil, serverURL: "https://zed.example.com") + let untrustedOverride = ZedClientSettings( + credentialsURL: "https://zed.dev", + serverURL: "https://zed.example.com") + let invalid = ZedClientSettings(credentialsURL: nil, serverURL: "file:///tmp/zed") + + #expect(production.keychainServiceURL == "zed-preview-key") + #expect(production.cloudAPIURL?.absoluteString == "https://cloud.zed.dev/client/users/me") + #expect(staging.cloudAPIURL?.absoluteString == "https://cloud.zed.dev/client/users/me") + #expect(localhost.cloudAPIURL == nil) + #expect(custom.cloudAPIURL?.absoluteString == "https://zed.example.com/client/users/me") + #expect(untrustedOverride.cloudAPIURL == nil) + #expect(invalid.cloudAPIURL == nil) + } + + @Test + func `display plan names normalize zed enums`() { + #expect(ZedUsageSnapshot.displayPlanName("zed_pro") == "Zed Pro") + #expect(ZedUsageSnapshot.displayPlanName("zed_pro_trial") == "Zed Pro Trial") + #expect(ZedUsageSnapshot.displayPlanName("zed_student") == "Zed Student") + } + + @Test + func `fetch uses authorization header from keychain credentials`() async throws { + let transport = ProviderHTTPTransportStub { request in + #expect(request.url?.absoluteString == "https://cloud.zed.dev/client/users/me") + #expect(request.value(forHTTPHeaderField: "Authorization") == "4242 test-token") + return Self.httpResponse( + data: Self.fixture(plan: "zed_pro", used: 0, limit: "\"unlimited\""), + statusCode: 200) + } + + let probe = ZedStatusProbe( + credentialsReader: StubCredentialsReader( + credentials: ZedCredentials(userID: "4242", accessToken: "test-token")), + transport: transport, + settingsLoader: { ZedClientSettings(credentialsURL: nil, serverURL: nil) }) + + let snapshot = try await probe.fetch() + #expect(snapshot.response.plan.planV3 == "zed_pro") + } + + @Test + func `fetch sends credentials only to configured server`() async throws { + let transport = ProviderHTTPTransportStub { request in + #expect(request.url?.absoluteString == "https://zed.example.com/client/users/me") + #expect(request.value(forHTTPHeaderField: "Authorization") == "4242 custom-token") + return Self.httpResponse( + data: Self.fixture(plan: "zed_pro", used: 0, limit: "\"unlimited\""), + statusCode: 200) + } + let probe = ZedStatusProbe( + credentialsReader: StubCredentialsReader( + credentials: ZedCredentials(userID: "4242", accessToken: "custom-token")), + transport: transport, + settingsLoader: { + ZedClientSettings( + credentialsURL: "https://zed.example.com", + serverURL: "https://zed.example.com") + }) + + _ = try await probe.fetch() + } + + @Test + func `fetch rejects invalid server before reading credentials`() async { + let probe = ZedStatusProbe( + credentialsReader: StubCredentialsReader( + credentials: ZedCredentials(userID: "4242", accessToken: "must-not-send")), + transport: ProviderHTTPTransportStub { _ in + Issue.record("Should not send credentials to an invalid server URL") + return Self.httpResponse(data: Data(), statusCode: 500) + }, + settingsLoader: { + ZedClientSettings(credentialsURL: "custom-keychain-id", serverURL: "file:///tmp/zed") + }) + + await #expect(throws: ZedStatusProbeError.invalidServerURL("file:///tmp/zed")) { + _ = try await probe.fetch() + } + } + + @Test + func `fetch rejects cross-origin credential override`() async { + let probe = ZedStatusProbe( + credentialsReader: StubCredentialsReader( + credentials: ZedCredentials(userID: "4242", accessToken: "must-not-send")), + transport: ProviderHTTPTransportStub { _ in + Issue.record("Should not send credentials to an untrusted custom server") + return Self.httpResponse(data: Data(), statusCode: 500) + }, + settingsLoader: { + ZedClientSettings( + credentialsURL: "https://zed.dev", + serverURL: "https://attacker.example.com") + }) + + await #expect(throws: ZedStatusProbeError.untrustedServerConfiguration) { + _ = try await probe.fetch() + } + } + + @Test + func `fetch surfaces not signed in when keychain is empty`() async { + let probe = ZedStatusProbe( + credentialsReader: StubCredentialsReader(credentials: nil), + transport: ProviderHTTPTransportStub { _ in + Issue.record("Should not call cloud API without credentials") + return Self.httpResponse(data: Data(), statusCode: 500) + }, + settingsLoader: { nil }) + + await #expect(throws: ZedStatusProbeError.notSignedIn) { + _ = try await probe.fetch() + } + } + + @Test + func `fetch surfaces unauthorized responses`() async { + let probe = ZedStatusProbe( + credentialsReader: StubCredentialsReader( + credentials: ZedCredentials(userID: "1", accessToken: "bad")), + transport: ProviderHTTPTransportStub { _ in + Self.httpResponse(data: Data("{}".utf8), statusCode: 401) + }, + settingsLoader: { nil }) + + await #expect(throws: ZedStatusProbeError.unauthorized) { + _ = try await probe.fetch() + } + } + + @Test + func `fetch preserves transport cancellation`() async { + let probe = ZedStatusProbe( + credentialsReader: StubCredentialsReader( + credentials: ZedCredentials(userID: "1", accessToken: "cancelled")), + transport: ProviderHTTPTransportStub { _ in + throw URLError(.cancelled) + }, + settingsLoader: { nil }) + + await #expect(throws: CancellationError.self) { + _ = try await probe.fetch() + } + } +} diff --git a/Tests/CodexBarTests/ZenMuxProviderTests.swift b/Tests/CodexBarTests/ZenMuxProviderTests.swift new file mode 100644 index 0000000000..f7ca2692e8 --- /dev/null +++ b/Tests/CodexBarTests/ZenMuxProviderTests.swift @@ -0,0 +1,364 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +struct ZenMuxProviderTests { + @Test + func `subscription and balance map to quota windows and USD PAYG`() async throws { + let now = Date(timeIntervalSince1970: 1_800_000_000) + let transport = ProviderHTTPTransportStub { request in + let url = try #require(request.url) + #expect(request.httpMethod == "GET") + #expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer management-key") + #expect(request.value(forHTTPHeaderField: "Accept") == "application/json") + #expect(url.scheme == "https") + #expect(url.host == "zenmux.ai") + #expect(url.port == nil) + #expect(url.user == nil) + #expect(url.password == nil) + #expect(url.query == nil) + #expect(url.fragment == nil) + switch url.path { + case "/api/v1/management/subscription/detail": + #expect(url.absoluteString == "https://zenmux.ai/api/v1/management/subscription/detail") + return Self.response(url: url, body: Self.subscriptionFixture) + case "/api/v1/management/payg/balance": + #expect(url.absoluteString == "https://zenmux.ai/api/v1/management/payg/balance") + return Self.response( + url: url, + body: Self.balanceFixture) + default: + throw URLError(.badURL) + } + } + + let result = try await ZenMuxUsageFetcher.fetchUsage( + "management-key", + includePaygBalance: true, + transport: transport, + now: now) + let usage = result.usage.toUsageSnapshot(paygBalanceUSD: result.paygBalanceUSD) + + #expect(abs((usage.primary?.usedPercent ?? 0) - 7.15) < 0.0001) + #expect(usage.primary?.windowMinutes == 300) + #expect(usage.primary?.resetDescription == "57.20 / 800 flows") + #expect(abs((usage.secondary?.usedPercent ?? 0) - 6.73) < 0.0001) + #expect(usage.secondary?.windowMinutes == 10080) + #expect(usage.secondary?.resetDescription == "416.11 / 6182 flows") + #expect(usage.loginMethod(for: .zenmux) == "Ultra plan") + #expect(usage.subscriptionRenewsAt == nil) + #expect(usage.subscriptionExpiresAt == Self.date("2026-04-12T08:26:56.000Z")) + #expect(usage.providerCost?.used == 482.74) + #expect(usage.providerCost?.currencyCode == "USD") + #expect(usage.providerCost?.period == "ZenMux PAYG balance") + #expect(result.paygBalanceUSD == 482.74) + } + + @Test + func `unhealthy account status is included in identity`() async throws { + let body = Self.subscriptionFixture.replacingOccurrences( + of: #""account_status": "healthy""#, + with: #""account_status": "monitored""#) + let transport = ProviderHTTPTransportStub { request in + let url = try #require(request.url) + return Self.response(url: url, body: body) + } + + let result = try await ZenMuxUsageFetcher.fetchUsage( + "management-key", + includePaygBalance: false, + transport: transport) + + #expect(result.usage.toUsageSnapshot().loginMethod(for: .zenmux) == "Ultra plan · Monitored") + #expect(result.paygBalanceUSD == nil) + } + + @Test + func `balance failure does not discard subscription usage`() async throws { + let transport = ProviderHTTPTransportStub { request in + let url = try #require(request.url) + if url.path.hasSuffix("/subscription/detail") { + return Self.response(url: url, body: Self.subscriptionFixture) + } + return Self.response(url: url, body: #"{"error":"unavailable"}"#, statusCode: 500) + } + + let result = try await ZenMuxUsageFetcher.fetchUsage( + "management-key", + includePaygBalance: true, + transport: transport) + + #expect(abs((result.usage.toUsageSnapshot().primary?.usedPercent ?? 0) - 7.15) < 0.0001) + #expect(result.paygBalanceUSD == nil) + } + + @Test + func `balance auth failure is not hidden`() async { + let transport = ProviderHTTPTransportStub { request in + let url = try #require(request.url) + if url.path.hasSuffix("/subscription/detail") { + return Self.response(url: url, body: Self.subscriptionFixture) + } + return Self.response(url: url, body: #"{"error":"unauthorized"}"#, statusCode: 401) + } + + await #expect { + _ = try await ZenMuxUsageFetcher.fetchUsage( + "management-key", + includePaygBalance: true, + transport: transport) + } throws: { error in + error as? ZenMuxUsageError == .authenticationRejected + } + } + + @Test + func `balance cancellation is preserved`() async { + let transport = ProviderHTTPTransportStub { request in + let url = try #require(request.url) + if url.path.hasSuffix("/subscription/detail") { + return Self.response(url: url, body: Self.subscriptionFixture) + } + throw URLError(.cancelled) + } + + await #expect { + _ = try await ZenMuxUsageFetcher.fetchUsage( + "management-key", + includePaygBalance: true, + transport: transport) + } throws: { error in + error is CancellationError + } + } + + @Test + func `missing and invalid credentials fail clearly`() async { + await #expect { + _ = try await ZenMuxUsageFetcher.fetchUsage( + " ", + includePaygBalance: false) + } throws: { error in + error as? ZenMuxUsageError == .notConfigured + } + + let transport = ProviderHTTPTransportStub { request in + let url = try #require(request.url) + return Self.response(url: url, body: #"{"error":"unauthorized"}"#, statusCode: 403) + } + await #expect { + _ = try await ZenMuxUsageFetcher.fetchUsage( + "wrong-key", + includePaygBalance: false, + transport: transport) + } throws: { error in + error as? ZenMuxUsageError == .authenticationRejected + } + } + + @Test + func `malformed subscription payload fails parsing`() async { + let transport = ProviderHTTPTransportStub { request in + let url = try #require(request.url) + return Self.response(url: url, body: #"{"success":true,"data":{"plan":{}}}"#) + } + + await #expect { + _ = try await ZenMuxUsageFetcher.fetchUsage( + "management-key", + includePaygBalance: false, + transport: transport) + } throws: { error in + guard case .parseFailed = error as? ZenMuxUsageError else { return false } + return true + } + } + + @Test + func `non USD PAYG balance is ignored without discarding quota usage`() async throws { + let nonUSDBalance = Self.balanceFixture.replacingOccurrences( + of: #""currency": "usd""#, + with: #""currency": "eur""#) + let transport = ProviderHTTPTransportStub { request in + let url = try #require(request.url) + return Self.response( + url: url, + body: url.path.hasSuffix("/payg/balance") ? nonUSDBalance : Self.subscriptionFixture) + } + + let result = try await ZenMuxUsageFetcher.fetchUsage( + "management-key", + includePaygBalance: true, + transport: transport) + + #expect(result.paygBalanceUSD == nil) + #expect(abs((result.usage.toUsageSnapshot().primary?.usedPercent ?? 0) - 7.15) < 0.0001) + } + + @Test + func `negative overdue PAYG balance remains visible`() async throws { + let overdueBalance = Self.balanceFixture.replacingOccurrences( + of: #""total_credits": 482.74"#, + with: #""total_credits": -12.34"#) + let transport = ProviderHTTPTransportStub { request in + let url = try #require(request.url) + return Self.response( + url: url, + body: url.path.hasSuffix("/payg/balance") ? overdueBalance : Self.subscriptionFixture) + } + + let result = try await ZenMuxUsageFetcher.fetchUsage( + "management-key", + includePaygBalance: true, + transport: transport) + let snapshot = result.usage.toUsageSnapshot(paygBalanceUSD: result.paygBalanceUSD) + + #expect(result.paygBalanceUSD == -12.34) + #expect(snapshot.providerCost?.used == -12.34) + } + + @Test + func `settings reader trims quotes`() { + #expect(ZenMuxSettingsReader.managementAPIKey(environment: [ + ZenMuxSettingsReader.managementAPIKeyEnvironmentKey: " 'management-key' ", + ]) == "management-key") + #expect(ZenMuxSettingsReader.managementAPIKey(environment: [:]) == nil) + } + + @Test @MainActor + func `descriptor and app registry include ZenMux`() throws { + let descriptor = ProviderDescriptorRegistry.descriptor(for: .zenmux) + #expect(descriptor.metadata.displayName == "ZenMux") + #expect(descriptor.metadata.defaultEnabled == false) + #expect(!descriptor.metadata.supportsCredits) + #expect(descriptor.fetchPlan.sourceModes == [.auto, .api]) + + let implementation = try #require(ProviderImplementationRegistry.implementation(for: .zenmux)) + #expect(implementation is ZenMuxProviderImplementation) + } + + @Test @MainActor + func `menu card uses compact flow expiry and USD PAYG labels`() async throws { + let now = try #require(Self.date("2026-03-24T07:35:09.000Z")) + let expiresAt = try #require(Self.date("2026-04-12T08:26:56.000Z")) + let expiryFormatter = DateFormatter() + expiryFormatter.locale = .current + expiryFormatter.timeZone = .current + expiryFormatter.setLocalizedDateFormatFromTemplate("MMM d, yyyy") + let transport = ProviderHTTPTransportStub { request in + let url = try #require(request.url) + return Self.response( + url: url, + body: url.path.hasSuffix("/payg/balance") ? Self.balanceFixture : Self.subscriptionFixture) + } + let result = try await ZenMuxUsageFetcher.fetchUsage( + "management-key", + includePaygBalance: true, + transport: transport, + now: now) + let snapshot = result.usage.toUsageSnapshot(paygBalanceUSD: result.paygBalanceUSD) + let model = UsageMenuCardView.Model.make(.init( + provider: .zenmux, + metadata: ZenMuxProviderDescriptor.descriptor.metadata, + snapshot: snapshot, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: true, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + + let primary = try #require(model.metrics.first { $0.id == "primary" }) + let secondary = try #require(model.metrics.first { $0.id == "secondary" }) + #expect(primary.detailLeftText == "57.20 / 800 flows") + #expect(primary.detailRightText == nil) + #expect(primary.resetText == "Resets in 1h") + #expect(secondary.detailLeftText == "416.11 / 6182 flows") + #expect(secondary.detailRightText == nil) + #expect(model.usageNotes == ["Plan expires: \(expiryFormatter.string(from: expiresAt))"]) + #expect(model.creditsText == nil) + #expect(model.providerCost?.title == "Pay-as-you-go") + #expect(model.providerCost?.spendLine == "Balance: $482.74") + } + + private static let subscriptionFixture = #""" + { + "success": true, + "data": { + "plan": { + "tier": "ultra", + "amount_usd": 200, + "interval": "month", + "expires_at": "2026-04-12T08:26:56.000Z" + }, + "currency": "usd", + "base_usd_per_flow": 0.03283, + "effective_usd_per_flow": 0.03283, + "account_status": "healthy", + "quota_5_hour": { + "usage_percentage": 0.0715, + "resets_at": "2026-03-24T08:35:09.000Z", + "max_flows": 800, + "used_flows": 57.2, + "remaining_flows": 742.8, + "used_value_usd": 1.88, + "max_value_usd": 26.27 + }, + "quota_7_day": { + "usage_percentage": 0.0673, + "resets_at": "2026-03-26T02:15:05.000Z", + "max_flows": 6182, + "used_flows": 416.11, + "remaining_flows": 5765.89, + "used_value_usd": 13.66, + "max_value_usd": 202.99 + }, + "quota_monthly": { + "max_flows": 34560, + "max_value_usd": 1134.33 + } + } + } + """# + + private static let balanceFixture = #""" + { + "success": true, + "data": { + "currency": "usd", + "total_credits": 482.74, + "top_up_credits": 35, + "bonus_credits": 447.74 + } + } + """# + + private static func response( + url: URL, + body: String, + statusCode: Int = 200) -> (Data, URLResponse) + { + let response = HTTPURLResponse( + url: url, + statusCode: statusCode, + httpVersion: "HTTP/1.1", + headerFields: ["Content-Type": "application/json"])! + return (Data(body.utf8), response) + } + + private static func date(_ raw: String) -> Date? { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return formatter.date(from: raw) + } +} diff --git a/Tests/CodexBarTests/ZoomMateCookieCacheTests.swift b/Tests/CodexBarTests/ZoomMateCookieCacheTests.swift new file mode 100644 index 0000000000..545f4a3c62 --- /dev/null +++ b/Tests/CodexBarTests/ZoomMateCookieCacheTests.swift @@ -0,0 +1,360 @@ +import Foundation +import Testing +@testable import CodexBarCore + +/// Covers the `.auto` cookie-cache handoff: a validated browser session is persisted through +/// `CookieHeaderCache`, and later resolutions (background refreshes, the bundled CLI) run from the +/// cached header without rereading the browser. Modeled on `PerplexityCookieCacheTests`. +@Suite(.serialized) +struct ZoomMateCookieCacheTests { + private static let cachedHeader = "_zm_ssid=fake-session-value; cf_clearance=fake-clearance-value" + private static let cachedHeaders = ZoomMateCookieHeaders(headersByHost: [ + "ai.zoom.us": cachedHeader, + "zoommate.zoom.us": cachedHeader, + ]) + private static let cachedStorage = cachedHeaders.encodedForStorage() ?? "" + + private static func sharedCookieHeaders(_ header: String) -> ZoomMateCookieHeaders { + ZoomMateCookieHeaders(headersByHost: [ + "ai.zoom.us": header, + "zoommate.zoom.us": header, + ]) + } + + /// Minimal unsigned JWT carrying only a far-future `exp` claim, so minted tokens are cacheable. + private static func makeJWT(exp: Int = 9_999_999_999) -> String { + func b64url(_ text: String) -> String { + Data(text.utf8).base64EncodedString() + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") + } + return "\(b64url("{\"alg\":\"none\"}")).\(b64url("{\"exp\":\(exp)}")).sig" + } + + private static func mintResponseStub( + nak: String, + email: String? = nil, + expectedCookieHeader: String? = nil) -> ProviderHTTPTransportStub + { + ProviderHTTPTransportStub { request in + if let expectedCookieHeader { + #expect(request.value(forHTTPHeaderField: "Cookie") == expectedCookieHeader) + } + let profile = email.map { ", \"user_profile\": {\"email\": \"\($0)\"}" } ?? "" + let body = "{\"success\": true, \"data\": {\"nak\": \"\(nak)\"\(profile)}}" + let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)! + return (Data(body.utf8), response) + } + } + + #if os(macOS) + @Test + func `auto mode reuses the cached cookie header without a browser read`() async throws { + KeychainCacheStore.setTestStoreForTesting(true) + defer { + CookieHeaderCache.clear(provider: .zoommate) + KeychainCacheStore.setTestStoreForTesting(false) + } + CookieHeaderCache.store( + provider: .zoommate, + cookieHeader: Self.cachedStorage, + sourceLabel: "Chrome (Test)") + + let jwt = Self.makeJWT() + let stub = Self.mintResponseStub( + nak: jwt, + email: "fake.user@example.com", + expectedCookieHeader: Self.cachedHeader) + let fetcher = ZoomMateUsageFetcher(browserDetection: BrowserDetection(cacheTTL: 0)) + + let context = try await fetcher.resolveRequestContext( + manualCaptureOverride: nil, + timeout: 1, + logger: nil, + cache: ZoomMateBearerTokenCache(), + transport: stub) + + #expect(context.authorization == "Bearer \(jwt)") + #expect(context.cookieHeaders == Self.cachedHeaders) + #expect(context.accountEmail == "fake.user@example.com") + #expect(context.cacheKey == ZoomMateBearerTokenCache.key(forCookieHeaders: Self.cachedHeaders)) + #expect(await stub.requests().count == 1) // the mint only — no browser import happened + } + + @Test + func `resolution without cache falls back to the browser import path`() async throws { + KeychainCacheStore.setTestStoreForTesting(true) + defer { + CookieHeaderCache.clear(provider: .zoommate) + KeychainCacheStore.setTestStoreForTesting(false) + } + CookieHeaderCache.store( + provider: .zoommate, + cookieHeader: Self.cachedStorage, + sourceLabel: "Chrome (Test)") + + let stub = ProviderHTTPTransportStub { request in + Issue.record("Unexpected network request: \(request.url?.absoluteString ?? "nil")") + let response = HTTPURLResponse(url: request.url!, statusCode: 500, httpVersion: nil, headerFields: nil)! + return (Data(), response) + } + let fetcher = ZoomMateUsageFetcher(browserDetection: BrowserDetection(cacheTTL: 0)) + + // The dead-session retry disallows the cache; under the test runner the browser cookie + // store is suppressed, so the fallback surfaces `noSession` without any network traffic. + await #expect { + _ = try await fetcher.resolveRequestContext( + manualCaptureOverride: nil, + allowCachedCookieHeader: false, + timeout: 1, + logger: nil, + cache: ZoomMateBearerTokenCache(), + transport: stub) + } throws: { error in + guard case ZoomMateUsageError.noSession = error else { return false } + return true + } + // Skipping the cache must not mutate it; clearing is the strategy's explicit decision. + #expect(CookieHeaderCache.load(provider: .zoommate)?.cookieHeader == Self.cachedStorage) + } + + @Test + func `rejected cached session surfaces invalidCredentials and leaves the entry intact`() async throws { + KeychainCacheStore.setTestStoreForTesting(true) + defer { + CookieHeaderCache.clear(provider: .zoommate) + KeychainCacheStore.setTestStoreForTesting(false) + } + CookieHeaderCache.store( + provider: .zoommate, + cookieHeader: Self.cachedStorage, + sourceLabel: "Chrome (Test)") + + let stub = ProviderHTTPTransportStub { request in + let response = HTTPURLResponse(url: request.url!, statusCode: 401, httpVersion: nil, headerFields: nil)! + return (Data("{}".utf8), response) + } + let fetcher = ZoomMateUsageFetcher(browserDetection: BrowserDetection(cacheTTL: 0)) + + await #expect { + _ = try await fetcher.resolveRequestContext( + manualCaptureOverride: nil, + timeout: 1, + logger: nil, + cache: ZoomMateBearerTokenCache(), + transport: stub) + } throws: { error in + guard case ZoomMateUsageError.invalidCredentials = error else { return false } + return true + } + // The fetcher never clears the cache itself — the strategy clears and retries once with a + // fresh import, so a transient mis-clear can't wipe a concurrently refreshed entry. + #expect(CookieHeaderCache.load(provider: .zoommate) != nil) + } + + @Test + func `validated browser session is persisted through the cookie cache`() async throws { + KeychainCacheStore.setTestStoreForTesting(true) + defer { + CookieHeaderCache.clear(provider: .zoommate) + KeychainCacheStore.setTestStoreForTesting(false) + } + + let nak = Self.makeJWT() + let stub = Self.mintResponseStub(nak: nak, expectedCookieHeader: Self.cachedHeader) + + let context = try await ZoomMateUsageFetcher.requestContext( + forCookieHeaders: Self.cachedHeaders, + persistingValidatedHeaderAs: "Chrome (Test)", + cache: ZoomMateBearerTokenCache(), + timeout: 1, + transport: stub, + logger: nil) + + let cached = try #require(CookieHeaderCache.load(provider: .zoommate)) + #expect(cached.cookieHeader == Self.cachedStorage) + #expect(cached.sourceLabel == "Chrome (Test)") + // Only the cookie header is persisted — the minted bearer stays in memory. + #expect(!cached.cookieHeader.contains(nak)) + #expect(context.authorization == "Bearer \(nak)") + } + + @Test + func `auto mode continues past a rejected Chrome profile`() async throws { + KeychainCacheStore.setTestStoreForTesting(true) + defer { + CookieHeaderCache.clear(provider: .zoommate) + KeychainCacheStore.setTestStoreForTesting(false) + } + + let rejectedHeader = "_zm_ssid=fake-rejected-session" + let validHeader = "_zm_ssid=fake-valid-session" + let jwt = Self.makeJWT() + let sessions = [ + ZoomMateCookieImporter.SessionInfo( + cookieHeaders: Self.sharedCookieHeaders(rejectedHeader), + sourceLabel: "Chrome Profile 1"), + ZoomMateCookieImporter.SessionInfo( + cookieHeaders: Self.sharedCookieHeaders(validHeader), + sourceLabel: "Chrome Profile 2"), + ] + let stub = ProviderHTTPTransportStub { request in + let cookieHeader = request.value(forHTTPHeaderField: "Cookie") + if cookieHeader == rejectedHeader { + let response = HTTPURLResponse( + url: request.url!, + statusCode: 401, + httpVersion: nil, + headerFields: nil)! + return (Data("{}".utf8), response) + } + + #expect(cookieHeader == validHeader) + let body = "{\"success\": true, \"data\": {\"nak\": \"\(jwt)\"}}" + let response = HTTPURLResponse( + url: request.url!, + statusCode: 200, + httpVersion: nil, + headerFields: nil)! + return (Data(body.utf8), response) + } + + let context = try await ZoomMateUsageFetcher.requestContext( + forCookieSessions: sessions, + cache: ZoomMateBearerTokenCache(), + timeout: 1, + transport: stub, + logger: nil) + + #expect(context.authorization == "Bearer \(jwt)") + #expect(context.cookieHeaders == Self.sharedCookieHeaders(validHeader)) + #expect(await stub.requests().count == 2) + let cached = try #require(CookieHeaderCache.load(provider: .zoommate)) + #expect(cached.cookieHeader == Self.sharedCookieHeaders(validHeader).encodedForStorage()) + #expect(cached.sourceLabel == "Chrome Profile 2") + } + + @Test + func `auto mode does not hide a parse failure behind another Chrome profile`() async throws { + KeychainCacheStore.setTestStoreForTesting(true) + defer { + CookieHeaderCache.clear(provider: .zoommate) + KeychainCacheStore.setTestStoreForTesting(false) + } + + let sessions = [ + ZoomMateCookieImporter.SessionInfo( + cookieHeaders: Self.sharedCookieHeaders("_zm_ssid=fake-malformed-response-session"), + sourceLabel: "Chrome Profile 1"), + ZoomMateCookieImporter.SessionInfo( + cookieHeaders: Self.sharedCookieHeaders("_zm_ssid=fake-unused-session"), + sourceLabel: "Chrome Profile 2"), + ] + let stub = ProviderHTTPTransportStub { request in + let response = HTTPURLResponse( + url: request.url!, + statusCode: 200, + httpVersion: nil, + headerFields: nil)! + return (Data("{\"success\": true, \"data\": {}}".utf8), response) + } + + await #expect { + _ = try await ZoomMateUsageFetcher.requestContext( + forCookieSessions: sessions, + cache: ZoomMateBearerTokenCache(), + timeout: 1, + transport: stub, + logger: nil) + } throws: { error in + guard case ZoomMateUsageError.parseFailed = error else { return false } + return true + } + #expect(await stub.requests().count == 1) + #expect(CookieHeaderCache.load(provider: .zoommate) == nil) + } + + @Test + func `failed mint persists nothing`() async throws { + KeychainCacheStore.setTestStoreForTesting(true) + defer { + CookieHeaderCache.clear(provider: .zoommate) + KeychainCacheStore.setTestStoreForTesting(false) + } + + let stub = ProviderHTTPTransportStub { request in + let response = HTTPURLResponse(url: request.url!, statusCode: 401, httpVersion: nil, headerFields: nil)! + return (Data("{}".utf8), response) + } + + await #expect { + _ = try await ZoomMateUsageFetcher.requestContext( + forCookieHeaders: Self.cachedHeaders, + persistingValidatedHeaderAs: "Chrome (Test)", + cache: ZoomMateBearerTokenCache(), + timeout: 1, + transport: stub, + logger: nil) + } throws: { error in + guard case ZoomMateUsageError.invalidCredentials = error else { return false } + return true + } + #expect(CookieHeaderCache.load(provider: .zoommate) == nil) + } + + @Test + func `already cached header is not re-persisted`() async throws { + KeychainCacheStore.setTestStoreForTesting(true) + defer { + CookieHeaderCache.clear(provider: .zoommate) + KeychainCacheStore.setTestStoreForTesting(false) + } + + let stub = Self.mintResponseStub(nak: Self.makeJWT()) + _ = try await ZoomMateUsageFetcher.requestContext( + forCookieHeaders: Self.cachedHeaders, + persistingValidatedHeaderAs: nil, + cache: ZoomMateBearerTokenCache(), + timeout: 1, + transport: stub, + logger: nil) + + #expect(CookieHeaderCache.load(provider: .zoommate) == nil) + } + + @Test + func `manual capture mode neither reads nor writes the cookie cache`() async throws { + KeychainCacheStore.setTestStoreForTesting(true) + defer { + CookieHeaderCache.clear(provider: .zoommate) + KeychainCacheStore.setTestStoreForTesting(false) + } + CookieHeaderCache.store( + provider: .zoommate, + cookieHeader: Self.cachedStorage, + sourceLabel: "Chrome (Test)") + + let curl = "curl 'https://ai.zoom.us/ai-computer/api/v1/credits/status' " + + "-H 'authorization: Bearer fake-manual-token' -H 'cookie: session=fake-manual-cookie'" + let stub = ProviderHTTPTransportStub { request in + Issue.record("Unexpected network request: \(request.url?.absoluteString ?? "nil")") + let response = HTTPURLResponse(url: request.url!, statusCode: 500, httpVersion: nil, headerFields: nil)! + return (Data(), response) + } + let fetcher = ZoomMateUsageFetcher(browserDetection: BrowserDetection(cacheTTL: 0)) + + let context = try await fetcher.resolveRequestContext( + manualCaptureOverride: curl, + timeout: 1, + logger: nil, + cache: ZoomMateBearerTokenCache(), + transport: stub) + + #expect(context.authorization == "Bearer fake-manual-token") + #expect(context.cookieHeaders.header(forHost: "ai.zoom.us") == "session=fake-manual-cookie") + #expect(context.cookieHeaders.header(forHost: "zoommate.zoom.us") == nil) + #expect(CookieHeaderCache.load(provider: .zoommate)?.cookieHeader == Self.cachedStorage) + } + #endif +} diff --git a/Tests/CodexBarTests/ZoomMateCreditsHistoryFetcherTests.swift b/Tests/CodexBarTests/ZoomMateCreditsHistoryFetcherTests.swift new file mode 100644 index 0000000000..76dd28358a --- /dev/null +++ b/Tests/CodexBarTests/ZoomMateCreditsHistoryFetcherTests.swift @@ -0,0 +1,550 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct ZoomMateCreditsHistoryFetcherTests { + // Every payload below is generated from synthetic IDs, titles, costs, and timestamps. + private static let now = Date(timeIntervalSince1970: 1_782_800_000) + private static let startTime = Self.now.addingTimeInterval(-30 * 24 * 3600) + + private static func page(records: String, total: Int) -> String { + """ + { "data": { "records": [\(records)], "total": \(total) }, "status_code": 200, "error_message": null } + """ + } + + private static func record( + id: String, + title: String, + cost: Double, + time: String, + isRunning: Bool = false, + isDeleted: Bool = false) -> String + { + """ + {"session_id": "\(id)", "title": "\(title)", "cost": \(cost), "time": "\(time)", + "is_running": \(isRunning), "is_deleted": \(isDeleted)} + """ + } + + @Test + func `decodes a single page fully within the limit`() async throws { + let body = Self.page( + records: [ + Self.record(id: "s1", title: "Task A", cost: 5, time: "2026-06-30T10:00:00Z"), + Self.record(id: "s2", title: "Task B", cost: 3, time: "2026-06-29T10:00:00Z"), + ].joined(separator: ","), + total: 2) + + let stub = ProviderHTTPTransportStub { request in + #expect(request.url?.scheme == "https") + #expect(request.url?.host == "ai.zoom.us") + #expect(request.url?.path == "/ai-computer/api/v1/credits/history") + #expect(request.url?.query?.contains("app_id=demo_app") == true) + #expect(request.url?.query?.contains("page=0") == true) + #expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer fake-token") + #expect(request.value(forHTTPHeaderField: "Origin") == "https://zoommate.zoom.us") + #expect(request.value(forHTTPHeaderField: "Referer") == "https://zoommate.zoom.us") + let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)! + return (Data(body.utf8), response) + } + + let context = ZoomMateUsageFetcher.RequestContext( + authorization: "Bearer fake-token", + headers: ["Origin": "https://attacker.example", "Referer": "https://attacker.example/path"]) + let snapshot = try await ZoomMateCreditsHistoryFetcher.fetch( + context: context, + startTime: Self.startTime, + endTime: Self.now, + now: Self.now, + transport: stub) + + #expect(snapshot.records.count == 2) + let requestCount = await stub.requests().count + #expect(requestCount == 1) + } + + @Test + func `history failover sends only the cookie header scoped to each host`() async throws { + let stub = ProviderHTTPTransportStub { request in + let statusCode = request.url?.host == "ai.zoom.us" ? 503 : 200 + if request.url?.host == "ai.zoom.us" { + #expect(request.value(forHTTPHeaderField: "Cookie") == "parent=fake; ai-only=fake") + } else { + #expect(request.url?.host == "zoommate.zoom.us") + #expect(request.value(forHTTPHeaderField: "Cookie") == "parent=fake; mate-only=fake") + } + let body = Self.page(records: "", total: 0) + let response = HTTPURLResponse( + url: request.url!, + statusCode: statusCode, + httpVersion: nil, + headerFields: nil)! + return (statusCode == 200 ? Data(body.utf8) : Data(), response) + } + let context = ZoomMateUsageFetcher.RequestContext( + authorization: "Bearer fake-token", + cookieHeaders: ZoomMateCookieHeaders(headersByHost: [ + "ai.zoom.us": "parent=fake; ai-only=fake", + "zoommate.zoom.us": "parent=fake; mate-only=fake", + ])) + + let snapshot = try await ZoomMateCreditsHistoryFetcher.fetch( + context: context, + startTime: Self.startTime, + endTime: Self.now, + now: Self.now, + transport: stub) + + #expect(snapshot.records.isEmpty) + #expect(await stub.requests().count == 2) + } + + @Test + func `paginates across multiple pages until total is satisfied`() async throws { + let stub = ProviderHTTPTransportStub { request in + let query = request.url?.query ?? "" + let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)! + if query.contains("page=0") { + let body = Self.page( + records: (0..<50).map { + Self.record(id: "s\($0)", title: "Task \($0)", cost: 1, time: "2026-06-30T10:00:00Z") + }.joined(separator: ","), + total: 55) + return (Data(body.utf8), response) + } + #expect(query.contains("page=1")) + let body = Self.page( + records: (50..<55).map { + Self.record(id: "s\($0)", title: "Task \($0)", cost: 1, time: "2026-06-29T10:00:00Z") + }.joined(separator: ","), + total: 55) + return (Data(body.utf8), response) + } + + let context = ZoomMateUsageFetcher.RequestContext(authorization: "Bearer fake-token") + let snapshot = try await ZoomMateCreditsHistoryFetcher.fetch( + context: context, + startTime: Self.startTime, + endTime: Self.now, + now: Self.now, + transport: stub) + + #expect(snapshot.records.count == 55) + let requestCount = await stub.requests().count + #expect(requestCount == 2) + } + + @Test + func `stops pagination early when a page returns no records`() async throws { + let stub = ProviderHTTPTransportStub { request in + let body = Self.page(records: "", total: 1000) + let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)! + return (Data(body.utf8), response) + } + + let context = ZoomMateUsageFetcher.RequestContext(authorization: "Bearer fake-token") + let snapshot = try await ZoomMateCreditsHistoryFetcher.fetch( + context: context, + startTime: Self.startTime, + endTime: Self.now, + now: Self.now, + transport: stub) + + #expect(snapshot.records.isEmpty) + let requestCount = await stub.requests().count + #expect(requestCount == 1) + } + + @Test + func `stops pagination early when a page is entirely older than startTime`() async throws { + // `total: 1000` implies many more pages exist, but every record on page 0 is already + // older than `startTime` — the defensive date-boundary stop (design.md D2) should break + // before requesting page 1, regardless of what `total`/`maxPages` would otherwise allow. + let staleTime = Self.startTime.addingTimeInterval(-24 * 3600) // 1 day before the window. + let stub = ProviderHTTPTransportStub { request in + #expect(request.url?.query?.contains("page=0") == true) + let body = Self.page( + records: (0..<50).map { + Self.record( + id: "s\($0)", + title: "Stale \($0)", + cost: 1, + time: ISO8601DateFormatter().string(from: staleTime)) + }.joined(separator: ","), + total: 1000) + let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)! + return (Data(body.utf8), response) + } + + let context = ZoomMateUsageFetcher.RequestContext(authorization: "Bearer fake-token") + let snapshot = try await ZoomMateCreditsHistoryFetcher.fetch( + context: context, + startTime: Self.startTime, + endTime: Self.now, + now: Self.now, + transport: stub) + + #expect(snapshot.records.count == 50) + let requestCount = await stub.requests().count + #expect(requestCount == 1) + } + + @Test + func `unauthorized response maps to invalidCredentials`() async throws { + let stub = ProviderHTTPTransportStub { request in + let response = HTTPURLResponse(url: request.url!, statusCode: 401, httpVersion: nil, headerFields: nil)! + return (Data("{\"detail\": \"unauthorized\"}".utf8), response) + } + + let context = ZoomMateUsageFetcher.RequestContext(authorization: "Bearer fake-token") + await #expect { + _ = try await ZoomMateCreditsHistoryFetcher.fetch( + context: context, + startTime: Self.startTime, + endTime: Self.now, + now: Self.now, + transport: stub) + } throws: { error in + guard case ZoomMateUsageError.invalidCredentials = error else { return false } + return true + } + } + + @Test + func `other server error maps to apiError`() async throws { + let stub = ProviderHTTPTransportStub { request in + let response = HTTPURLResponse(url: request.url!, statusCode: 500, httpVersion: nil, headerFields: nil)! + return (Data("boom".utf8), response) + } + + let context = ZoomMateUsageFetcher.RequestContext(authorization: "Bearer fake-token") + await #expect { + _ = try await ZoomMateCreditsHistoryFetcher.fetch( + context: context, + startTime: Self.startTime, + endTime: Self.now, + now: Self.now, + transport: stub) + } throws: { error in + guard case ZoomMateUsageError.apiError = error else { return false } + return true + } + } + + @Test + func `malformed body surfaces parseFailed`() async throws { + let stub = ProviderHTTPTransportStub { request in + let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)! + return (Data("{\"unexpected\": true}".utf8), response) + } + + let context = ZoomMateUsageFetcher.RequestContext(authorization: "Bearer fake-token") + await #expect { + _ = try await ZoomMateCreditsHistoryFetcher.fetch( + context: context, + startTime: Self.startTime, + endTime: Self.now, + now: Self.now, + transport: stub) + } throws: { error in + guard case ZoomMateUsageError.parseFailed = error else { return false } + return true + } + } + + // MARK: - Daily aggregation + + @Test + func `daily breakdown sums cost per calendar day and sorts ascending`() { + let records: [ZoomMateCreditHistoryRecord] = [ + ZoomMateCreditHistoryRecord( + sessionID: "s1", + title: "A", + cost: 5, + time: "2026-06-30T10:00:00Z", + isRunning: false, + isDeleted: false), + ZoomMateCreditHistoryRecord( + sessionID: "s2", + title: "B", + cost: 3, + time: "2026-06-30T20:00:00Z", + isRunning: false, + isDeleted: false), + ZoomMateCreditHistoryRecord( + sessionID: "s3", + title: "C", + cost: 2, + time: "2026-06-29T10:00:00Z", + isRunning: false, + isDeleted: false), + ] + let snapshot = ZoomMateCreditsHistorySnapshot(records: records, updatedAt: Self.now) + let breakdown = snapshot.dailyBreakdown(calendar: Self.utcCalendar, now: Self.now) + + #expect(breakdown.count == 2) + #expect(breakdown[0].day == "2026-06-29") + #expect(breakdown[0].totalCreditsUsed == 2) + #expect(breakdown[1].day == "2026-06-30") + #expect(breakdown[1].totalCreditsUsed == 8) + } + + @Test + func `daily breakdown excludes deleted records`() { + let records: [ZoomMateCreditHistoryRecord] = [ + ZoomMateCreditHistoryRecord( + sessionID: "s1", + title: "A", + cost: 5, + time: "2026-06-30T10:00:00Z", + isRunning: false, + isDeleted: false), + ZoomMateCreditHistoryRecord( + sessionID: "s2", + title: "B (deleted)", + cost: 100, + time: "2026-06-30T11:00:00Z", + isRunning: false, + isDeleted: true), + ] + let snapshot = ZoomMateCreditsHistorySnapshot(records: records, updatedAt: Self.now) + let breakdown = snapshot.dailyBreakdown(calendar: Self.utcCalendar, now: Self.now) + + #expect(breakdown.count == 1) + #expect(breakdown[0].totalCreditsUsed == 5) + } + + @Test + func `daily breakdown includes running sessions`() { + let records: [ZoomMateCreditHistoryRecord] = [ + ZoomMateCreditHistoryRecord( + sessionID: "s1", + title: "Still running", + cost: 1.5, + time: "2026-06-30T10:00:00Z", + isRunning: true, + isDeleted: false), + ] + let snapshot = ZoomMateCreditsHistorySnapshot(records: records, updatedAt: Self.now) + let breakdown = snapshot.dailyBreakdown(calendar: Self.utcCalendar, now: Self.now) + + #expect(breakdown.count == 1) + #expect(breakdown[0].totalCreditsUsed == 1.5) + } + + @Test + func `daily breakdown skips records with unparseable time or negative cost`() { + let records: [ZoomMateCreditHistoryRecord] = [ + ZoomMateCreditHistoryRecord( + sessionID: "s1", + title: "Bad time", + cost: 5, + time: "not-a-date", + isRunning: false, + isDeleted: false), + ZoomMateCreditHistoryRecord( + sessionID: "s2", + title: "Negative cost", + cost: -1, + time: "2026-06-30T10:00:00Z", + isRunning: false, + isDeleted: false), + ZoomMateCreditHistoryRecord( + sessionID: "s3", + title: "Missing time", + cost: 2, + time: nil, + isRunning: false, + isDeleted: false), + ZoomMateCreditHistoryRecord( + sessionID: "s4", + title: "Missing cost", + cost: nil, + time: "2026-06-30T10:00:00Z", + isRunning: false, + isDeleted: false), + ] + let snapshot = ZoomMateCreditsHistorySnapshot(records: records, updatedAt: Self.now) + let breakdown = snapshot.dailyBreakdown(calendar: Self.utcCalendar, now: Self.now) + + #expect(breakdown.isEmpty) + } + + @Test + func `daily breakdown returns empty for no records`() { + let snapshot = ZoomMateCreditsHistorySnapshot(records: [], updatedAt: Self.now) + #expect(snapshot.dailyBreakdown(calendar: Self.utcCalendar, now: Self.now).isEmpty) + } + + @Test + func `daily breakdown excludes records older than the trailing 30-day window`() throws { + // Fixed `now`; one record just inside the 30-day window, one just outside it. + let fixedNow = try #require(Self.utcCalendar.date(from: DateComponents(year: 2026, month: 7, day: 4, hour: 12))) + let withinWindow = "2026-06-05T10:00:00Z" // 29 days before `now` -> included. + let outsideWindow = "2026-06-03T10:00:00Z" // 31 days before `now` -> excluded. + let records: [ZoomMateCreditHistoryRecord] = [ + ZoomMateCreditHistoryRecord( + sessionID: "s1", + title: "Recent", + cost: 5, + time: withinWindow, + isRunning: false, + isDeleted: false), + ZoomMateCreditHistoryRecord( + sessionID: "s2", + title: "Stale", + cost: 100, + time: outsideWindow, + isRunning: false, + isDeleted: false), + ] + let snapshot = ZoomMateCreditsHistorySnapshot(records: records, updatedAt: fixedNow) + let breakdown = snapshot.dailyBreakdown(calendar: Self.utcCalendar, now: fixedNow) + + #expect(breakdown.count == 1) + #expect(breakdown[0].day == "2026-06-05") + #expect(breakdown[0].totalCreditsUsed == 5) + } + + private static var utcCalendar: Calendar { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(identifier: "UTC")! + return calendar + } + + // MARK: - Pacing verdict + + @Test + func `pacing verdict reports onTrack when usage matches elapsed cycle fraction`() throws { + // Cycle: 100,000s long; now is 50,000s in (50% elapsed); used = 50% of budget. + let cycleStart = Self.now.addingTimeInterval(-50000) + let cycleEnd = Self.now.addingTimeInterval(50000) + let status = ZoomMateCreditStatus( + budgetCap: 1000, + usedCredit: 500, + remainingCredit: 500, + overageCredit: 0, + allowOverage: false, + cycleStartDate: Int64(cycleStart.timeIntervalSince1970 * 1000), + cycleEndDate: Int64(cycleEnd.timeIntervalSince1970 * 1000), + isQuotaAvailable: true, + isUnlimited: false) + + let pace = try #require(status.pacingVerdict(now: Self.now)) + #expect(pace.stage == .onTrack) + } + + @Test + func `pacing verdict reports behind when usage is well below elapsed cycle fraction`() throws { + let cycleStart = Self.now.addingTimeInterval(-50000) + let cycleEnd = Self.now.addingTimeInterval(50000) + let status = ZoomMateCreditStatus( + budgetCap: 1000, + usedCredit: 100, + remainingCredit: 900, + overageCredit: 0, + allowOverage: false, + cycleStartDate: Int64(cycleStart.timeIntervalSince1970 * 1000), + cycleEndDate: Int64(cycleEnd.timeIntervalSince1970 * 1000), + isQuotaAvailable: true, + isUnlimited: false) + + let pace = try #require(status.pacingVerdict(now: Self.now)) + #expect(pace.stage == .behind || pace.stage == .farBehind || pace.stage == .slightlyBehind) + #expect(pace.deltaPercent < 0) + } + + @Test + func `pacing verdict reports ahead when usage is well above elapsed cycle fraction`() throws { + let cycleStart = Self.now.addingTimeInterval(-50000) + let cycleEnd = Self.now.addingTimeInterval(50000) + let status = ZoomMateCreditStatus( + budgetCap: 1000, + usedCredit: 900, + remainingCredit: 100, + overageCredit: 0, + allowOverage: false, + cycleStartDate: Int64(cycleStart.timeIntervalSince1970 * 1000), + cycleEndDate: Int64(cycleEnd.timeIntervalSince1970 * 1000), + isQuotaAvailable: true, + isUnlimited: false) + + let pace = try #require(status.pacingVerdict(now: Self.now)) + #expect(pace.stage == .ahead || pace.stage == .farAhead || pace.stage == .slightlyAhead) + #expect(pace.deltaPercent > 0) + } + + @Test + func `pacing verdict is nil for unlimited plans`() { + let status = ZoomMateCreditStatus( + budgetCap: 1000, + usedCredit: 500, + remainingCredit: 500, + overageCredit: 0, + allowOverage: false, + cycleStartDate: Int64(Self.now.addingTimeInterval(-50000).timeIntervalSince1970 * 1000), + cycleEndDate: Int64(Self.now.addingTimeInterval(50000).timeIntervalSince1970 * 1000), + isQuotaAvailable: true, + isUnlimited: true) + + #expect(status.pacingVerdict(now: Self.now) == nil) + } + + @Test + func `pacing verdict is nil when cycle dates are missing`() { + let status = ZoomMateCreditStatus( + budgetCap: 1000, + usedCredit: 500, + remainingCredit: 500, + overageCredit: 0, + allowOverage: false, + cycleStartDate: nil, + cycleEndDate: nil, + isQuotaAvailable: true, + isUnlimited: false) + + #expect(status.pacingVerdict(now: Self.now) == nil) + } + + @Test + func `pacing verdict is nil when budget cap is zero`() { + let status = ZoomMateCreditStatus( + budgetCap: 0, + usedCredit: 0, + remainingCredit: 0, + overageCredit: 0, + allowOverage: false, + cycleStartDate: Int64(Self.now.addingTimeInterval(-50000).timeIntervalSince1970 * 1000), + cycleEndDate: Int64(Self.now.addingTimeInterval(50000).timeIntervalSince1970 * 1000), + isQuotaAvailable: false, + isUnlimited: false) + + #expect(status.pacingVerdict(now: Self.now) == nil) + } + + @Test + func `ZoomMateCreditsHistorySnapshot pacingVerdict delegates to its attached creditStatus`() { + let cycleStart = Self.now.addingTimeInterval(-50000) + let cycleEnd = Self.now.addingTimeInterval(50000) + let status = ZoomMateCreditStatus( + budgetCap: 1000, + usedCredit: 500, + remainingCredit: 500, + overageCredit: 0, + allowOverage: false, + cycleStartDate: Int64(cycleStart.timeIntervalSince1970 * 1000), + cycleEndDate: Int64(cycleEnd.timeIntervalSince1970 * 1000), + isQuotaAvailable: true, + isUnlimited: false) + let snapshot = ZoomMateCreditsHistorySnapshot(records: [], creditStatus: status, updatedAt: Self.now) + + #expect(snapshot.pacingVerdict(now: Self.now)?.stage == .onTrack) + } + + @Test + func `ZoomMateCreditsHistorySnapshot pacingVerdict is nil without an attached creditStatus`() { + let snapshot = ZoomMateCreditsHistorySnapshot(records: [], updatedAt: Self.now) + #expect(snapshot.pacingVerdict(now: Self.now) == nil) + } +} diff --git a/Tests/CodexBarTests/ZoomMateUsageFetcherTests.swift b/Tests/CodexBarTests/ZoomMateUsageFetcherTests.swift new file mode 100644 index 0000000000..0946707f2c --- /dev/null +++ b/Tests/CodexBarTests/ZoomMateUsageFetcherTests.swift @@ -0,0 +1,874 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct ZoomMateUsageFetcherTests { + private final class MessageRecorder: @unchecked Sendable { + private var messages: [String] = [] + private let lock = NSLock() + + func append(_ message: String) { + self.lock.lock() + defer { self.lock.unlock() } + self.messages.append(message) + } + + func output() -> String { + self.lock.lock() + defer { self.lock.unlock() } + return self.messages.joined(separator: "\n") + } + } + + private struct StubClaudeFetcher: ClaudeUsageFetching { + func loadLatestUsage(model _: String) async throws -> ClaudeUsageSnapshot { + throw ClaudeUsageError.parseFailed("stub") + } + + func debugRawProbe(model _: String) async -> String { + "stub" + } + + func detectVersion() -> String? { + nil + } + } + + private static let now = Date(timeIntervalSince1970: 1_782_800_000) + + private static func sharedCookieHeaders(_ header: String) -> ZoomMateCookieHeaders { + ZoomMateCookieHeaders(headersByHost: [ + "ai.zoom.us": header, + "zoommate.zoom.us": header, + ]) + } + + /// Fully synthetic payload matching the first-party web client's decoded response shape. + private static let sampleResponse = """ + { "data": { "credit_status": { + "budget_cap": 12345.0, "used_credit": 678.0, "remaining_credit": 11667.0, + "overage_credit": 0.0, "allow_overage": false, + "cycle_start_date": 1893456000000, "cycle_end_date": 1896134399000, + "is_quota_available": true, "is_unlimited": false } }, + "status_code": 200, "error_message": null } + """ + + @Test + func `decodes credit status from sample JSON`() throws { + let data = Data(Self.sampleResponse.utf8) + struct Envelope: Decodable { + struct DataBox: Decodable { + let creditStatus: ZoomMateCreditStatus + private enum CodingKeys: String, CodingKey { case creditStatus = "credit_status" } + } + + let data: DataBox + } + let envelope = try JSONDecoder().decode(Envelope.self, from: data) + let status = envelope.data.creditStatus + + #expect(status.budgetCap == 12345) + #expect(status.usedCredit == 678) + #expect(status.remainingCredit == 11667) + #expect(status.isUnlimited == false) + #expect(status.cycleEndDate == 1_896_134_399_000) + } + + @Test + func `maps normal credit usage to primary window`() { + let status = ZoomMateCreditStatus( + budgetCap: 35000, + usedCredit: 942, + remainingCredit: 34058, + overageCredit: 0, + allowOverage: false, + cycleStartDate: 1_782_777_600_000, + cycleEndDate: 1_785_455_999_000, + isQuotaAvailable: true, + isUnlimited: false) + let snapshot = ZoomMateUsageSnapshot(creditStatus: status, updatedAt: Self.now).toUsageSnapshot() + + #expect(snapshot.primary != nil) + #expect(abs((snapshot.primary?.usedPercent ?? 0) - 2.691_428_57) < 0.001) + #expect(snapshot.primary?.resetsAt?.timeIntervalSince1970 == Double(1_785_455_999_000) / 1000) + #expect(snapshot.primary?.resetDescription == "Credits") + #expect(snapshot.secondary == nil) + #expect(snapshot.identity?.providerID == .zoommate) + #expect(snapshot.identity?.accountEmail == nil) + } + + @Test + func `unlimited plan reports zero percent and no reset`() { + let status = ZoomMateCreditStatus( + budgetCap: 35000, + usedCredit: 942, + remainingCredit: 34058, + overageCredit: 0, + allowOverage: false, + cycleStartDate: 1_782_777_600_000, + cycleEndDate: 1_785_455_999_000, + isQuotaAvailable: true, + isUnlimited: true) + let snapshot = ZoomMateUsageSnapshot(creditStatus: status, updatedAt: Self.now).toUsageSnapshot() + + #expect(snapshot.primary?.usedPercent == 0) + #expect(snapshot.primary?.resetsAt == nil) + } + + @Test + func `zero budget cap avoids divide by zero`() { + let status = ZoomMateCreditStatus( + budgetCap: 0, + usedCredit: 0, + remainingCredit: 0, + overageCredit: 0, + allowOverage: false, + cycleStartDate: nil, + cycleEndDate: nil, + isQuotaAvailable: false, + isUnlimited: false) + let snapshot = ZoomMateUsageSnapshot(creditStatus: status, updatedAt: Self.now).toUsageSnapshot() + + #expect(snapshot.primary?.usedPercent == 0) + #expect(snapshot.primary?.resetsAt == nil) + } + + @Test + func `fetch sends authorization and decodes credit status`() async throws { + let stub = ProviderHTTPTransportStub { request in + #expect(request.url?.scheme == "https") + #expect(request.url?.host == "ai.zoom.us") + #expect(request.url?.path == "/ai-computer/api/v1/credits/status") + #expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer fake-token") + #expect(request.value(forHTTPHeaderField: "Origin") == "https://zoommate.zoom.us") + #expect(request.value(forHTTPHeaderField: "Referer") == "https://zoommate.zoom.us") + let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)! + return (Data(Self.sampleResponse.utf8), response) + } + + let context = ZoomMateUsageFetcher.RequestContext( + authorization: "Bearer fake-token", + headers: ["Origin": "https://attacker.example", "Referer": "https://attacker.example/path"]) + let snapshot = try await ZoomMateUsageFetcher.fetchCreditsStatus( + context: context, + now: Self.now, + transport: stub) + + #expect(snapshot.creditStatus.usedCredit == 678) + } + + @Test + func `unauthorized response is invalid credentials`() async throws { + let stub = ProviderHTTPTransportStub { request in + let response = HTTPURLResponse(url: request.url!, statusCode: 401, httpVersion: nil, headerFields: nil)! + return (Data("{\"detail\": \"Missing Authorization header\"}".utf8), response) + } + + let context = ZoomMateUsageFetcher.RequestContext(authorization: "Bearer fake-token") + await #expect { + _ = try await ZoomMateUsageFetcher.fetchCreditsStatus(context: context, now: Self.now, transport: stub) + } throws: { error in + guard case ZoomMateUsageError.invalidCredentials = error else { return false } + return true + } + } + + @Test + func `other server error is apiError`() async throws { + let stub = ProviderHTTPTransportStub { request in + let response = HTTPURLResponse(url: request.url!, statusCode: 500, httpVersion: nil, headerFields: nil)! + return (Data("boom".utf8), response) + } + + let context = ZoomMateUsageFetcher.RequestContext(authorization: "Bearer fake-token") + await #expect { + _ = try await ZoomMateUsageFetcher.fetchCreditsStatus(context: context, now: Self.now, transport: stub) + } throws: { error in + guard case ZoomMateUsageError.apiError = error else { return false } + return true + } + } + + @Test + func `malformed 200 body surfaces parseFailed`() async throws { + let stub = ProviderHTTPTransportStub { request in + let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)! + return (Data("{\"unexpected\": true}".utf8), response) + } + + let context = ZoomMateUsageFetcher.RequestContext(authorization: "Bearer fake-token") + await #expect { + _ = try await ZoomMateUsageFetcher.fetchCreditsStatus(context: context, now: Self.now, transport: stub) + } throws: { error in + guard case ZoomMateUsageError.parseFailed = error else { return false } + return true + } + } + + @Test + func `manual curl capture extracts authorization and cookie`() throws { + let curl = """ + curl 'https://ai.zoom.us/ai-computer/api/v1/credits/status' \\ + -H 'authorization: Bearer fake-manual-token' \\ + -H 'cookie: session=fake-cookie-value' \\ + -H 'origin: https://zoommate.zoom.us' \\ + -H 'referer: https://zoommate.zoom.us/' + """ + + let context = try #require(ZoomMateUsageFetcher.requestContext(from: curl)) + #expect(context.authorization == "Bearer fake-manual-token") + #expect(context.cookieHeaders.header(forHost: "ai.zoom.us") == "session=fake-cookie-value") + #expect(context.cookieHeaders.header(forHost: "zoommate.zoom.us") == nil) + #expect(context.preferredHost == "ai.zoom.us") + #expect(context.headers["Origin"] == nil) + #expect(context.headers["Referer"] == nil) + } + + @Test + func `manual curl capture rejects nonofficial and malformed targets`() { + let captures = [ + "curl 'http://ai.zoom.us/ai-computer/api/v1/credits/status' -H 'authorization: Bearer fake'", + "curl 'https://marketing.zoom.us/ai-computer/api/v1/credits/status' -H 'authorization: Bearer fake'", + "curl 'https://zoom.us.attacker.com/ai-computer/api/v1/credits/status' -H 'authorization: Bearer fake'", + "curl 'https://example.com/ai-computer/api/v1/credits/status' -H 'authorization: Bearer fake'", + "curl 'https://ai.zoom.us/ai-computer/api/v1/credits/history' -H 'authorization: Bearer fake'", + "curl 'https://ai.zoom.us:444/ai-computer/api/v1/credits/status' -H 'authorization: Bearer fake'", + "curl --location 'https://ai.zoom.us/ai-computer/api/v1/credits/status' " + + "-H 'authorization: Bearer fake'", + ] + + for capture in captures { + #expect(ZoomMateUsageFetcher.requestContext(from: capture) == nil) + } + } + + @Test + func `manual curl capture accepts either interchangeable first-party host`() throws { + let capture = "curl 'https://zoommate.zoom.us/ai-computer/api/v1/credits/status' " + + "-H 'authorization: Bearer fake-manual-token' -H 'cookie: mate-only=fake'" + + let context = try #require(ZoomMateUsageFetcher.requestContext(from: capture)) + #expect(context.authorization == "Bearer fake-manual-token") + #expect(context.cookieHeaders.header(forHost: "ai.zoom.us") == nil) + #expect(context.cookieHeaders.header(forHost: "zoommate.zoom.us") == "mate-only=fake") + #expect(context.preferredHost == "zoommate.zoom.us") + } + + @Test + func `manual ai capture never sends its cookie to zoommate during failover`() async throws { + let capture = "curl 'https://ai.zoom.us/ai-computer/api/v1/credits/status' " + + "-H 'authorization: Bearer fake-manual-token' -H 'cookie: ai-only=fake'" + let context = try #require(ZoomMateUsageFetcher.requestContext(from: capture)) + let stub = ProviderHTTPTransportStub { request in + let statusCode = request.url?.host == "ai.zoom.us" ? 503 : 200 + if request.url?.host == "ai.zoom.us" { + #expect(request.value(forHTTPHeaderField: "Cookie") == "ai-only=fake") + } else { + #expect(request.url?.host == "zoommate.zoom.us") + #expect(request.value(forHTTPHeaderField: "Cookie") == nil) + } + let response = HTTPURLResponse( + url: request.url!, + statusCode: statusCode, + httpVersion: nil, + headerFields: nil)! + return (statusCode == 200 ? Data(Self.sampleResponse.utf8) : Data(), response) + } + + _ = try await ZoomMateUsageFetcher.fetchCreditsStatus(context: context, now: Self.now, transport: stub) + #expect(await stub.requests().count == 2) + } + + @Test + func `manual zoommate capture starts on its host and drops its cookie during failover`() async throws { + let capture = "curl 'https://zoommate.zoom.us/ai-computer/api/v1/credits/status' " + + "-H 'authorization: Bearer fake-manual-token' -H 'cookie: mate-only=fake'" + let context = try #require(ZoomMateUsageFetcher.requestContext(from: capture)) + let stub = ProviderHTTPTransportStub { request in + let statusCode = request.url?.host == "zoommate.zoom.us" ? 503 : 200 + if request.url?.host == "zoommate.zoom.us" { + #expect(request.value(forHTTPHeaderField: "Cookie") == "mate-only=fake") + } else { + #expect(request.url?.host == "ai.zoom.us") + #expect(request.value(forHTTPHeaderField: "Cookie") == nil) + } + let response = HTTPURLResponse( + url: request.url!, + statusCode: statusCode, + httpVersion: nil, + headerFields: nil)! + return (statusCode == 200 ? Data(Self.sampleResponse.utf8) : Data(), response) + } + + _ = try await ZoomMateUsageFetcher.fetchCreditsStatus(context: context, now: Self.now, transport: stub) + #expect(await stub.requests().count == 2) + } + + @Test + func `credits status fails over to the alternate host on a non-auth failure`() async throws { + let stub = ProviderHTTPTransportStub { request in + if request.url?.host == "ai.zoom.us" { + let response = HTTPURLResponse( + url: request.url!, + statusCode: 503, + httpVersion: nil, + headerFields: nil)! + return (Data(), response) + } + #expect(request.url?.host == "zoommate.zoom.us") + #expect(request.url?.path == "/ai-computer/api/v1/credits/status") + let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)! + return (Data(Self.sampleResponse.utf8), response) + } + + let context = ZoomMateUsageFetcher.RequestContext( + authorization: "Bearer fake-token", + cookieHeaders: ZoomMateCookieHeaders(headersByHost: [ + "ai.zoom.us": "parent=fake; ai-only=fake", + "zoommate.zoom.us": "parent=fake; mate-only=fake", + ])) + let snapshot = try await ZoomMateUsageFetcher.fetchCreditsStatus( + context: context, + now: Self.now, + transport: stub) + + #expect(snapshot.creditStatus.usedCredit == 678) + #expect(await stub.requests().count == 2) + let requests = await stub.requests() + #expect(requests[0].value(forHTTPHeaderField: "Cookie") == "parent=fake; ai-only=fake") + #expect(requests[1].value(forHTTPHeaderField: "Cookie") == "parent=fake; mate-only=fake") + } + + @Test + func `auth rejection does not fail over to the alternate host`() async throws { + let stub = ProviderHTTPTransportStub { request in + let response = HTTPURLResponse(url: request.url!, statusCode: 401, httpVersion: nil, headerFields: nil)! + return (Data("{}".utf8), response) + } + + let context = ZoomMateUsageFetcher.RequestContext(authorization: "Bearer fake-token") + await #expect { + _ = try await ZoomMateUsageFetcher.fetchCreditsStatus(context: context, now: Self.now, transport: stub) + } throws: { error in + guard case ZoomMateUsageError.invalidCredentials = error else { return false } + return true + } + #expect(await stub.requests().count == 1) + } + + @Test + func `parse failure does not fail over to the alternate host`() async throws { + let stub = ProviderHTTPTransportStub { request in + let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)! + return (Data("{\"unexpected\": true}".utf8), response) + } + + let context = ZoomMateUsageFetcher.RequestContext(authorization: "Bearer fake-token") + await #expect { + _ = try await ZoomMateUsageFetcher.fetchCreditsStatus(context: context, now: Self.now, transport: stub) + } throws: { error in + guard case ZoomMateUsageError.parseFailed = error else { return false } + return true + } + #expect(await stub.requests().count == 1) + } + + @Test + func `mint fails over to the alternate host on a non-auth failure`() async throws { + let stub = ProviderHTTPTransportStub { request in + if request.url?.host == "ai.zoom.us" { + #expect(request.value(forHTTPHeaderField: "Cookie") == "parent=fake; ai-only=fake") + let response = HTTPURLResponse( + url: request.url!, + statusCode: 500, + httpVersion: nil, + headerFields: nil)! + return (Data(), response) + } + #expect(request.url?.host == "zoommate.zoom.us") + #expect(request.url?.path == "/ai-computer/api/v1/login") + #expect(request.value(forHTTPHeaderField: "Cookie") == "parent=fake; mate-only=fake") + let body = "{\"success\": true, \"data\": {\"nak\": \"fake-minted-jwt\"}}" + let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)! + return (Data(body.utf8), response) + } + + let minted = try await ZoomMateUsageFetcher.mintBearerToken( + cookieHeaders: ZoomMateCookieHeaders(headersByHost: [ + "ai.zoom.us": "parent=fake; ai-only=fake", + "zoommate.zoom.us": "parent=fake; mate-only=fake", + ]), + transport: stub) + + #expect(minted.bearerToken == "fake-minted-jwt") + #expect(await stub.requests().count == 2) + } + + @Test + func `host failover preserves cancellation without trying the alternate host`() async { + var attemptedHosts: [String] = [] + + do { + let _: String = try await ZoomMateUsageFetcher.withAPIHostFailover { host in + attemptedHosts.append(host) + throw CancellationError() + } + Issue.record("Expected cancellation") + } catch { + #expect(error is CancellationError) + } + + #expect(attemptedHosts == ["ai.zoom.us"]) + } + + @Test + func `curl capture without authorization header yields nil context`() { + let curl = """ + curl 'https://ai.zoom.us/ai-computer/api/v1/credits/status' \\ + -H 'cookie: session=fake-cookie-value' + """ + + #expect(ZoomMateUsageFetcher.requestContext(from: curl) == nil) + } + + @Test + func `manual strategy remains available so malformed captures surface an honest error`() async { + let curl = "curl 'https://ai.zoom.us/ai-computer/api/v1/credits/status' " + + "-H 'authorization: Bearer fake-manual-token'" + let settings = ProviderSettingsSnapshot.make( + zoommate: ProviderSettingsSnapshot.ZoomMateProviderSettings( + cookieSource: .manual, + manualCookieHeader: curl)) + + #expect(await ZoomMateWebFetchStrategy().isAvailable(Self.makeContext(settings: settings))) + + let emptySettings = ProviderSettingsSnapshot.make( + zoommate: ProviderSettingsSnapshot.ZoomMateProviderSettings( + cookieSource: .manual, + manualCookieHeader: nil)) + #expect(await ZoomMateWebFetchStrategy().isAvailable(Self.makeContext(settings: emptySettings))) + } + + @Test + func `manual mode with an empty or malformed capture returns noCapture`() async { + let fetcher = ZoomMateUsageFetcher(browserDetection: BrowserDetection(cacheTTL: 0)) + + for capture in ["", "curl 'https://example.com' -H 'authorization: Bearer fake'"] { + await #expect { + _ = try await fetcher.resolveRequestContext( + manualCaptureOverride: capture, + timeout: 1, + logger: nil) + } throws: { error in + guard case ZoomMateUsageError.noCapture = error else { return false } + return true + } + } + } + + @Test + func `auto strategy is available on macOS regardless of a stored manual capture`() async { + let settings = ProviderSettingsSnapshot.make( + zoommate: ProviderSettingsSnapshot.ZoomMateProviderSettings( + cookieSource: .auto, + manualCookieHeader: nil)) + + #if os(macOS) + #expect(await ZoomMateWebFetchStrategy().isAvailable(Self.makeContext(settings: settings))) + #else + #expect(await ZoomMateWebFetchStrategy().isAvailable(Self.makeContext(settings: settings)) == false) + #endif + } + + @Test + func `strategy is unavailable when cookie source is off`() async { + let settings = ProviderSettingsSnapshot.make( + zoommate: ProviderSettingsSnapshot.ZoomMateProviderSettings( + cookieSource: .off, + manualCookieHeader: nil)) + + #expect(await ZoomMateWebFetchStrategy().isAvailable(Self.makeContext(settings: settings)) == false) + } + + @Test + func `mintBearerToken sends cookie and decodes nak from login bootstrap response`() async throws { + let stub = ProviderHTTPTransportStub { request in + #expect(request.url?.host == "ai.zoom.us") + #expect(request.url?.path == "/ai-computer/api/v1/login") + #expect(request.url?.query?.contains("continue=") == true) + #expect(request.value(forHTTPHeaderField: "Cookie") == "session=fake-cookie-value") + let body = """ + {"success": true, "data": {"nak": "fake-minted-jwt"}} + """ + let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)! + return (Data(body.utf8), response) + } + + let minted = try await ZoomMateUsageFetcher.mintBearerToken( + cookieHeaders: Self.sharedCookieHeaders("session=fake-cookie-value"), + transport: stub) + + #expect(minted.bearerToken == "fake-minted-jwt") + #expect(minted.accountEmail == nil) + } + + @Test + func `mintBearerToken extracts email from user_profile when present`() async throws { + let stub = ProviderHTTPTransportStub { request in + let body = """ + {"success": true, "data": {"nak": "fake-minted-jwt", "user_profile": { + "user_id": "fake-user-id", "email": "fake.user@example.com", "display_name": "Fake User" + }}} + """ + let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)! + return (Data(body.utf8), response) + } + + let minted = try await ZoomMateUsageFetcher.mintBearerToken( + cookieHeaders: Self.sharedCookieHeaders("session=fake-cookie-value"), + transport: stub) + + #expect(minted.bearerToken == "fake-minted-jwt") + #expect(minted.accountEmail == "fake.user@example.com") + } + + @Test + func `mintBearerToken tolerates missing user_profile without throwing`() async throws { + let stub = ProviderHTTPTransportStub { request in + let body = """ + {"success": true, "data": {"nak": "fake-minted-jwt"}} + """ + let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)! + return (Data(body.utf8), response) + } + + let minted = try await ZoomMateUsageFetcher.mintBearerToken( + cookieHeaders: Self.sharedCookieHeaders("session=fake-cookie-value"), + transport: stub) + + #expect(minted.bearerToken == "fake-minted-jwt") + #expect(minted.accountEmail == nil) + } + + @Test + func `mintBearerToken tolerates user_profile with missing email without throwing`() async throws { + let stub = ProviderHTTPTransportStub { request in + let body = """ + {"success": true, "data": {"nak": "fake-minted-jwt", "user_profile": { + "user_id": "fake-user-id", "display_name": "Fake User" + }}} + """ + let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)! + return (Data(body.utf8), response) + } + + let minted = try await ZoomMateUsageFetcher.mintBearerToken( + cookieHeaders: Self.sharedCookieHeaders("session=fake-cookie-value"), + transport: stub) + + #expect(minted.bearerToken == "fake-minted-jwt") + #expect(minted.accountEmail == nil) + } + + @Test + func `toUsageSnapshot populates identity accountEmail and loginMethod when email is known`() { + let status = ZoomMateCreditStatus( + budgetCap: 35000, + usedCredit: 942, + remainingCredit: 34058, + overageCredit: 0, + allowOverage: false, + cycleStartDate: 1_782_777_600_000, + cycleEndDate: 1_785_455_999_000, + isQuotaAvailable: true, + isUnlimited: false) + let snapshot = ZoomMateUsageSnapshot(creditStatus: status, updatedAt: Self.now) + .toUsageSnapshot(accountEmail: "fake.user@example.com") + + #expect(snapshot.identity?.accountEmail == "fake.user@example.com") + #expect(snapshot.identity?.loginMethod == "Cookie") + } + + @Test + func `mintBearerToken maps unauthorized to invalidCredentials`() async throws { + let stub = ProviderHTTPTransportStub { request in + let response = HTTPURLResponse(url: request.url!, statusCode: 401, httpVersion: nil, headerFields: nil)! + return (Data("{}".utf8), response) + } + + await #expect { + _ = try await ZoomMateUsageFetcher.mintBearerToken( + cookieHeaders: Self.sharedCookieHeaders("session=expired"), + transport: stub) + } throws: { error in + guard case ZoomMateUsageError.invalidCredentials = error else { return false } + return true + } + } + + @Test + func `mintBearerToken surfaces parseFailed when nak is missing`() async throws { + let stub = ProviderHTTPTransportStub { request in + let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)! + return (Data("{\"success\": true, \"data\": {}}".utf8), response) + } + + await #expect { + _ = try await ZoomMateUsageFetcher.mintBearerToken( + cookieHeaders: Self.sharedCookieHeaders("session=fake"), + transport: stub) + } throws: { error in + guard case ZoomMateUsageError.parseFailed = error else { return false } + return true + } + } + + @Test + func `descriptor dashboard URL points to the credit usage pane`() { + #expect( + ZoomMateProviderDescriptor.descriptor.metadata.dashboardURL == + "https://zoommate.zoom.us/#/?settings=credit-usage") + } + + #if os(macOS) + @Test + func `descriptor limits automatic cookie import to Chrome`() throws { + let order = try #require(ZoomMateProviderDescriptor.descriptor.metadata.browserCookieOrder) + #expect(order == [.chrome]) + } + #endif + + @Test + func `credential errors describe distinct recovery actions`() { + #expect(ZoomMateUsageError.noCapture.localizedDescription.contains("ai.zoom.us")) + #expect(ZoomMateUsageError.noSession.localizedDescription.contains("Chrome")) + #expect(ZoomMateUsageError.invalidCredentials.localizedDescription.contains("rejected")) + } + + @Test + func `verbose logs omit captured cookies and bearer tokens`() async throws { + let cookieMarker = "COOKIE_SECRET_MARKER" + let tokenMarker = "TOKEN_SECRET_MARKER" + let nakMarker = "NAK_SECRET_MARKER" + let curl = """ + curl 'https://ai.zoom.us/ai-computer/api/v1/credits/status' \ + -H 'authorization: Bearer \(tokenMarker)' \ + -H 'cookie: session=\(cookieMarker)' + """ + let stub = ProviderHTTPTransportStub { request in + let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)! + return (Data(Self.sampleResponse.utf8), response) + } + let fetcher = ZoomMateUsageFetcher(browserDetection: BrowserDetection(cacheTTL: 0)) + let messages = MessageRecorder() + + _ = try await fetcher.fetch( + manualCaptureOverride: curl, + logger: { messages.append($0) }, + transport: stub) + + let output = messages.output() + #expect(!output.contains(cookieMarker)) + #expect(!output.contains(tokenMarker)) + #expect(output.contains("Forwarding captured headers: Cookie")) + + let mintStub = ProviderHTTPTransportStub { request in + let body = "{\"success\": true, \"data\": {\"nak\": \"\(nakMarker)\"}}" + let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)! + return (Data(body.utf8), response) + } + _ = try await ZoomMateUsageFetcher.cachedOrMintedToken( + cookieHeaders: Self.sharedCookieHeaders("session=\(cookieMarker)"), + cache: ZoomMateBearerTokenCache(), + timeout: 1, + transport: mintStub, + logger: { messages.append($0) }) + + let mintOutput = messages.output() + #expect(!mintOutput.contains(cookieMarker)) + #expect(!mintOutput.contains(nakMarker)) + } + + // MARK: - Bearer token expiry + in-memory cache + + /// Minimal unsigned JWT carrying only an `exp` claim, for cache-expiry tests. + private static func makeJWT(exp: Int) -> String { + func b64url(_ text: String) -> String { + Data(text.utf8).base64EncodedString() + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") + } + return "\(b64url("{\"alg\":\"none\"}")).\(b64url("{\"exp\":\(exp)}")).sig" + } + + @Test + func `expiry decodes exp claim from a bearer JWT and ignores non-JWT tokens`() { + let jwt = Self.makeJWT(exp: 1_782_800_000) + #expect(ZoomMateUsageFetcher.expiry(fromJWT: jwt) == Date(timeIntervalSince1970: 1_782_800_000)) + // Tolerates an already-prefixed "Bearer " value. + #expect(ZoomMateUsageFetcher.expiry(fromJWT: "Bearer \(jwt)") == Date(timeIntervalSince1970: 1_782_800_000)) + // Opaque (non-JWT) tokens are undatable → nil (caller must not cache them). + #expect(ZoomMateUsageFetcher.expiry(fromJWT: "opaque-token") == nil) + } + + @Test + func `cachedOrMintedToken reuses an in-date token instead of re-minting`() async throws { + let jwt = Self.makeJWT(exp: 9_999_999_999) + let stub = ProviderHTTPTransportStub { request in + let body = "{\"success\": true, \"data\": {\"nak\": \"\(jwt)\"}}" + let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)! + return (Data(body.utf8), response) + } + let cache = ZoomMateBearerTokenCache() + + let first = try await ZoomMateUsageFetcher.cachedOrMintedToken( + cookieHeaders: Self.sharedCookieHeaders("session=abc"), + cache: cache, + timeout: 1, + transport: stub, + logger: nil) + let second = try await ZoomMateUsageFetcher.cachedOrMintedToken( + cookieHeaders: Self.sharedCookieHeaders("session=abc"), + cache: cache, + timeout: 1, + transport: stub, + logger: nil) + + #expect(first.bearerToken == jwt) + #expect(second.bearerToken == jwt) + #expect(await stub.requests().count == 1) // minted once, reused once + } + + @Test + func `cachedOrMintedToken re-mints a token without a decodable expiry`() async throws { + let stub = ProviderHTTPTransportStub { request in + let body = "{\"success\": true, \"data\": {\"nak\": \"opaque-not-a-jwt\"}}" + let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)! + return (Data(body.utf8), response) + } + let cache = ZoomMateBearerTokenCache() + + _ = try await ZoomMateUsageFetcher.cachedOrMintedToken( + cookieHeaders: Self.sharedCookieHeaders("session=abc"), + cache: cache, + timeout: 1, + transport: stub, + logger: nil) + _ = try await ZoomMateUsageFetcher.cachedOrMintedToken( + cookieHeaders: Self.sharedCookieHeaders("session=abc"), + cache: cache, + timeout: 1, + transport: stub, + logger: nil) + + #expect(await stub.requests().count == 2) // undatable token is never cached + } + + @Test + func `cache serves an in-date entry but withholds one inside the refresh-skew window`() async { + let cache = ZoomMateBearerTokenCache() + let key = ZoomMateBearerTokenCache.key(forCookieHeaders: Self.sharedCookieHeaders("session=abc")) + let now = Date(timeIntervalSince1970: 1_000_000_000) + // Expiry comfortably beyond the 60s skew → served. + await cache.store( + ZoomMateBearerTokenCache.Entry( + token: "t", + accountEmail: nil, + expiry: now.addingTimeInterval(600)), + forKey: key) + #expect(await cache.validEntry(forKey: key, now: now) != nil) + + // Re-store with an expiry only 30s out (inside the 60s skew) → withheld and evicted. + await cache.store( + ZoomMateBearerTokenCache.Entry( + token: "t", + accountEmail: nil, + expiry: now.addingTimeInterval(30)), + forKey: key) + #expect(await cache.validEntry(forKey: key, now: now) == nil) + // Eviction is durable: a later lookup still misses. + #expect(await cache.validEntry(forKey: key, now: now) == nil) + } + + @Test + func `invalidate evicts a cached token so the next call re-mints`() async throws { + let jwt = Self.makeJWT(exp: 9_999_999_999) + let stub = ProviderHTTPTransportStub { request in + let body = "{\"success\": true, \"data\": {\"nak\": \"\(jwt)\"}}" + let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)! + return (Data(body.utf8), response) + } + let cache = ZoomMateBearerTokenCache() + let key = ZoomMateBearerTokenCache.key(forCookieHeaders: Self.sharedCookieHeaders("session=abc")) + + _ = try await ZoomMateUsageFetcher.cachedOrMintedToken( + cookieHeaders: Self.sharedCookieHeaders("session=abc"), + cache: cache, + timeout: 1, + transport: stub, + logger: nil) + await cache.invalidate(forKey: key) + _ = try await ZoomMateUsageFetcher.cachedOrMintedToken( + cookieHeaders: Self.sharedCookieHeaders("session=abc"), + cache: cache, + timeout: 1, + transport: stub, + logger: nil) + + #expect(await stub.requests().count == 2) + } + + #if os(macOS) + @Test + func `automatic import partitions parent and host-only cookies per destination`() throws { + func cookie(domain: String, name: String) throws -> HTTPCookie { + try #require(HTTPCookie(properties: [ + .domain: domain, + .path: "/", + .name: name, + .value: "fake", + .secure: "TRUE", + ])) + } + + let headers = try ZoomMateCookieImporter.cookieHeaders(from: [ + cookie(domain: ".zoom.us", name: "parent"), + cookie(domain: "zoom.us", name: "parent-host-only"), + cookie(domain: "ai.zoom.us", name: "ai-only"), + cookie(domain: "zoommate.zoom.us", name: "mate-only"), + cookie(domain: "marketing.zoom.us", name: "marketing-only"), + ]) + + #expect(headers.header(forHost: "ai.zoom.us") == "parent=fake; ai-only=fake") + #expect(headers.header(forHost: "zoommate.zoom.us") == "parent=fake; mate-only=fake") + } + + @Test + func `cookie scope filter follows RFC 6265 host-only and domain matching`() { + #expect(ZoomMateCookieImporter.isSendable(cookieDomain: "ai.zoom.us", toHost: "ai.zoom.us")) + #expect(!ZoomMateCookieImporter.isSendable(cookieDomain: "ai.zoom.us", toHost: "zoommate.zoom.us")) + #expect(ZoomMateCookieImporter.isSendable(cookieDomain: ".zoom.us", toHost: "ai.zoom.us")) + #expect(ZoomMateCookieImporter.isSendable(cookieDomain: ".zoom.us", toHost: "zoommate.zoom.us")) + #expect(!ZoomMateCookieImporter.isSendable(cookieDomain: "zoom.us", toHost: "ai.zoom.us")) + #expect(!ZoomMateCookieImporter.isSendable(cookieDomain: "marketing.zoom.us", toHost: "ai.zoom.us")) + #expect(!ZoomMateCookieImporter.isSendable(cookieDomain: "zoom.us.attacker.com", toHost: "ai.zoom.us")) + #expect(!ZoomMateCookieImporter.isSendable(cookieDomain: "", toHost: "ai.zoom.us")) + } + #endif + + private static func makeContext(settings: ProviderSettingsSnapshot) -> ProviderFetchContext { + ProviderFetchContext( + runtime: .app, + sourceMode: .auto, + includeCredits: true, + webTimeout: 1, + webDebugDumpHTML: false, + verbose: false, + env: [:], + settings: settings, + fetcher: UsageFetcher(environment: [:]), + claudeFetcher: StubClaudeFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0)) + } +} diff --git a/TestsLinux/AbacusUsageSnapshotLinuxTests.swift b/TestsLinux/AbacusUsageSnapshotLinuxTests.swift new file mode 100644 index 0000000000..390afd6764 --- /dev/null +++ b/TestsLinux/AbacusUsageSnapshotLinuxTests.swift @@ -0,0 +1,24 @@ +#if os(Linux) +import Foundation +import Testing +@testable import CodexBarCore + +struct AbacusUsageSnapshotLinuxTests { + @Test + func `in-range credit usage maps to its percent`() { + let snapshot = AbacusUsageSnapshot(creditsUsed: 250, creditsTotal: 1000) + let usage = snapshot.toUsageSnapshot() + #expect(abs((usage.primary?.usedPercent ?? 0) - 25) < 0.01) + } + + @Test + func `credit overage clamps used percent to 100`() { + // A usage-based plan in overage (or a shrunk grant) reports used > total. + // The percent must cap at 100 like every sibling credit provider, instead + // of flowing 150 into RateWindow.usedPercent (which does not clamp). + let snapshot = AbacusUsageSnapshot(creditsUsed: 15000, creditsTotal: 10000) + let usage = snapshot.toUsageSnapshot() + #expect(usage.primary?.usedPercent == 100) + } +} +#endif diff --git a/TestsLinux/AlibabaTokenPlanLinuxTests.swift b/TestsLinux/AlibabaTokenPlanLinuxTests.swift new file mode 100644 index 0000000000..b39915de44 --- /dev/null +++ b/TestsLinux/AlibabaTokenPlanLinuxTests.swift @@ -0,0 +1,30 @@ +#if os(Linux) +import Foundation +import Testing +@testable import CodexBarCLI +@testable import CodexBarCore + +struct AlibabaTokenPlanLinuxTests { + @Test + func `manual cookie source does not require macOS web support`() { + // The Alibaba/Qwen Token Plan fetch is plain URLSession + cookies, so a manually + // configured cookie header must be usable off macOS (matches qoder/commandcode). + #expect(!CodexBarCLI.sourceModeRequiresWebSupport( + .web, + provider: .alibabatokenplan, + settings: ProviderSettingsSnapshot.make( + alibabaTokenPlan: .init( + cookieSource: .manual, + manualCookieHeader: "login_qwencloud_ticket=t")))) + } + + @Test + func `auto cookie source still requires web support off macOS`() { + #expect(CodexBarCLI.sourceModeRequiresWebSupport( + .web, + provider: .alibabatokenplan, + settings: ProviderSettingsSnapshot.make( + alibabaTokenPlan: .init(cookieSource: .auto, manualCookieHeader: nil)))) + } +} +#endif diff --git a/TestsLinux/AntigravityCLIStrategyLinuxTests.swift b/TestsLinux/AntigravityCLIStrategyLinuxTests.swift new file mode 100644 index 0000000000..fcc8195e52 --- /dev/null +++ b/TestsLinux/AntigravityCLIStrategyLinuxTests.swift @@ -0,0 +1,86 @@ +import Foundation +import Testing +@testable import CodexBarCore + +#if os(Linux) +struct AntigravityCLIStrategyLinuxTests { + @Test + func `cli local strategy is available with HTTP fallback`() async throws { + let binaryURL = FileManager.default.temporaryDirectory + .appendingPathComponent("codexbar-antigravity-\(UUID().uuidString)") + try Data("#!/bin/sh\n".utf8).write(to: binaryURL) + try FileManager.default.setAttributes( + [.posixPermissions: 0o755], + ofItemAtPath: binaryURL.path) + defer { try? FileManager.default.removeItem(at: binaryURL) } + + let context = ProviderFetchContext( + runtime: .cli, + sourceMode: .cli, + includeCredits: false, + webTimeout: 1, + webDebugDumpHTML: false, + verbose: false, + env: ["ANTIGRAVITY_CLI_PATH": binaryURL.path], + settings: nil, + fetcher: UsageFetcher(environment: [:]), + claudeFetcher: StubClaudeFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0)) + let isAvailable = await AntigravityCLIHTTPSFetchStrategy().isAvailable(context) + + #expect(isAvailable) + } + + @Test + func `cli local endpoints include Linux HTTP fallback`() { + #expect( + AntigravityStatusProbe.cliEndpoints(ports: [55624]) == [ + AntigravityStatusProbe.AntigravityConnectionEndpoint( + scheme: "https", + port: 55624, + csrfToken: "", + source: .cliHTTPS), + AntigravityStatusProbe.AntigravityConnectionEndpoint( + scheme: "http", + port: 55624, + csrfToken: "", + source: .cliHTTPS), + ]) + } + + @Test + func `language server endpoints include Linux HTTP fallback`() { + #expect( + AntigravityStatusProbe.connectionCandidates( + listeningPorts: [64440], + languageServerCSRFToken: "language-token", + extensionServerPort: nil, + extensionServerCSRFToken: nil) == [ + AntigravityStatusProbe.AntigravityConnectionEndpoint( + scheme: "https", + port: 64440, + csrfToken: "language-token", + source: .languageServer), + AntigravityStatusProbe.AntigravityConnectionEndpoint( + scheme: "http", + port: 64440, + csrfToken: "language-token", + source: .languageServer), + ]) + } + + private struct StubClaudeFetcher: ClaudeUsageFetching { + func loadLatestUsage(model _: String) async throws -> ClaudeUsageSnapshot { + throw ClaudeUsageError.parseFailed("stub") + } + + func debugRawProbe(model _: String) async -> String { + "stub" + } + + func detectVersion() -> String? { + nil + } + } +} +#endif diff --git a/TestsLinux/AntigravityProcessLauncherLinuxTests.swift b/TestsLinux/AntigravityProcessLauncherLinuxTests.swift new file mode 100644 index 0000000000..0f1fc575b3 --- /dev/null +++ b/TestsLinux/AntigravityProcessLauncherLinuxTests.swift @@ -0,0 +1,72 @@ +#if canImport(Glibc) || canImport(Musl) +import Foundation +#if canImport(Glibc) +import Glibc +#elseif canImport(Musl) +import Musl +#endif +import Testing +@testable import CodexBarCore + +struct AntigravityProcessLauncherLinuxTests { + @Test + func `pty launcher uses home and closes unrelated descriptors`() throws { + let tempDirectory = FileManager.default.temporaryDirectory + .appendingPathComponent("antigravity-spawn-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: tempDirectory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: tempDirectory) } + + let inheritedSourceFD = open("/dev/null", O_RDONLY) + guard inheritedSourceFD >= 0 else { + Issue.record("Failed to open descriptor fixture") + return + } + defer { close(inheritedSourceFD) } + let inheritedFD = fcntl(inheritedSourceFD, F_DUPFD, 200) + guard inheritedFD >= 200 else { + Issue.record("Failed to duplicate descriptor fixture") + return + } + defer { close(inheritedFD) } + + let outputURL = tempDirectory.appendingPathComponent("result.txt") + let scriptURL = tempDirectory.appendingPathComponent("probe.sh") + let script = """ + #!/bin/sh + pwd > \(outputURL.path) + if [ -e /proc/self/fd/\(inheritedFD) ]; then + echo inherited >> \(outputURL.path) + else + echo closed >> \(outputURL.path) + fi + """ + // Direct writes close the executable before spawn; atomic replacement can race with exec on overlay + // filesystems. + try Data(script.utf8).write(to: scriptURL) + #expect(chmod(scriptURL.path, 0o700) == 0) + + let handle = try AntigravityPTYProcessLauncher().launch(binary: scriptURL.path) + defer { + handle.killRoot() + handle.terminateTree(signal: SIGKILL, knownDescendants: []) + handle.closePTY() + } + + for _ in 0..<200 { + if FileManager.default.fileExists(atPath: outputURL.path), + let output = try? String(contentsOf: outputURL, encoding: .utf8) + { + let lines = output + .split(separator: "\n") + .map(String.init) + if lines.count >= 2, output.hasSuffix("\n") { break } + } + Thread.sleep(forTimeInterval: 0.01) + } + let lines = try String(contentsOf: outputURL, encoding: .utf8) + .split(separator: "\n") + .map(String.init) + #expect(lines == [NSHomeDirectory(), "closed"]) + } +} +#endif diff --git a/TestsLinux/AzureEndpointOverrideSecurityTests.swift b/TestsLinux/AzureEndpointOverrideSecurityTests.swift new file mode 100644 index 0000000000..4ba9684257 --- /dev/null +++ b/TestsLinux/AzureEndpointOverrideSecurityTests.swift @@ -0,0 +1,71 @@ +import CodexBarCore +import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif +import Testing + +@Suite +struct AzureEndpointOverrideSecurityTests { + @Test + func azureOpenAIEndpointOverrideMustBeHTTPSOrBareHost() throws { + let httpsURL = try #require(AzureOpenAISettingsReader.endpointURL(from: "https://proxy.example.com/base")) + #expect(httpsURL.absoluteString == "https://proxy.example.com/base") + + let bareURL = try #require(AzureOpenAISettingsReader.endpointURL(from: "resource.openai.azure.com")) + #expect(bareURL.absoluteString == "https://resource.openai.azure.com") + + let hostPortURL = try #require(AzureOpenAISettingsReader.endpointURL(from: "localhost:8443/openai")) + #expect(hostPortURL.absoluteString == "https://localhost:8443/openai") + + let trimmedURL = try #require(AzureOpenAISettingsReader.endpointURL(from: " https://trimmed.example.com/base ")) + #expect(trimmedURL.absoluteString == "https://trimmed.example.com/base") + + #expect(AzureOpenAISettingsReader.endpointURL(from: "http://attacker.test") == nil) + #expect(AzureOpenAISettingsReader.endpointURL(from: "https://user:pass@proxy.example.com") == nil) + #expect(AzureOpenAISettingsReader.endpointURL(from: "https://proxy.example.com%2f.attacker.test") == nil) + + #expect(throws: AzureOpenAISettingsError.invalidEndpointOverride( + AzureOpenAISettingsReader.endpointEnvironmentKey)) + { + try AzureOpenAISettingsReader.validateEndpointOverrides(environment: [ + AzureOpenAISettingsReader.endpointEnvironmentKey: "http://attacker.test", + ]) + } + } + + @Test + func azureOpenAIHTTPOverrideIsRejectedBeforeAPIKeyRequest() async throws { + let endpoint = try #require(URL(string: "http://127.0.0.1:31337")) + let transport = CapturingTransport { request in + Issue.record("Azure OpenAI should reject insecure endpoint overrides before sending api-key headers") + #expect(request.value(forHTTPHeaderField: "api-key") == nil) + throw CapturingTransportError.unexpectedRequest + } + + do { + _ = try await AzureOpenAIUsageFetcher.fetchUsage( + apiKey: "AZURE_CANARY_KEY", + endpoint: endpoint, + deploymentName: "canary-deployment", + transport: transport, + updatedAt: Date(timeIntervalSince1970: 1_800_000_000)) + Issue.record("Expected AzureOpenAIUsageError.invalidEndpointOverride") + } catch { + #expect(error as? AzureOpenAIUsageError == .invalidEndpointOverride( + AzureOpenAISettingsReader.endpointEnvironmentKey)) + } + } +} + +private enum CapturingTransportError: Error { + case unexpectedRequest +} + +private struct CapturingTransport: ProviderHTTPTransport { + let handler: @Sendable (URLRequest) async throws -> (Data, URLResponse) + + func data(for request: URLRequest) async throws -> (Data, URLResponse) { + try await self.handler(request) + } +} diff --git a/TestsLinux/CLICardsClaudeSwapTests.swift b/TestsLinux/CLICardsClaudeSwapTests.swift new file mode 100644 index 0000000000..71b22030d4 --- /dev/null +++ b/TestsLinux/CLICardsClaudeSwapTests.swift @@ -0,0 +1,419 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBarCLI + +struct CLICardsClaudeSwapTests { + private actor InvocationCounter { + private(set) var value = 0 + + func increment() { + self.value += 1 + } + } + + private struct AdapterError: LocalizedError, Sendable { + let text: String + var errorDescription: String? { + self.text + } + } + + private func ambientOutput(failed: Bool = false) -> UsageCommandOutput { + var output = UsageCommandOutput() + output.cards = [CLICardModel( + provider: .claude, + title: "Ambient Claude", + sourceLabel: "oauth", + planBadge: "Max", + accountLine: "ambient@example.com", + infoLines: [], + metrics: [CLICardMetric(label: "Session", remainingPercent: 50, resetText: nil)], + extraLines: [], + statusLine: nil)] + if failed { + output.cardFailures = [CLICardFailure(provider: .claude, accountLabel: nil, message: "ambient failed")] + output.exitCode = .failure + } + return output + } + + private func renderOptions(status: ProviderStatusPayload? = nil) -> CLIClaudeSwapCardsRenderOptions { + CLIClaudeSwapCardsRenderOptions( + status: status, + useColor: false, + resetStyle: .countdown, + weeklyWorkDays: nil, + now: Date(timeIntervalSince1970: 1_700_000_000)) + } + + private func row( + number: Int, + active: Bool = false, + status: ClaudeSwapUsageStatus = .ok, + email: String? = nil, + hasUsage: Bool = true) -> ClaudeSwapAccountRow + { + ClaudeSwapAccountRow( + number: number, + email: email ?? "account-\(number)@example.com", + isActive: active, + usageStatus: status, + fiveHour: hasUsage ? ClaudeSwapUsageWindow(usedPercent: Double(number * 10), resetsAt: nil) : nil, + sevenDay: nil) + } + + @Test + func `configured executable path strips surrounding quotes`() { + for rawPath in [" \"/tmp/cswap\" ", " '/tmp/cswap' "] { + let config = ProviderConfig(id: .claude, claudeSwapExecutablePath: rawPath) + #expect(CLIClaudeSwapCards.executablePath(from: config) == "/tmp/cswap") + } + #expect(CLIClaudeSwapCards.executablePath(from: nil).isEmpty) + } + + @Test + func `single account config is backward compatible and round trips opt in`() throws { + let legacyData = Data(#"{"id":"claude"}"#.utf8) + let legacy = try JSONDecoder().decode(ProviderConfig.self, from: legacyData) + #expect(legacy.claudeSwapShowSingleAccount != true) + + let enabled = ProviderConfig(id: .claude, claudeSwapShowSingleAccount: true) + let encoded = try JSONEncoder().encode(enabled) + let decoded = try JSONDecoder().decode(ProviderConfig.self, from: encoded) + #expect(decoded.claudeSwapShowSingleAccount == true) + } + + @Test + func `eligibility preserves explicit account and source intent`() { + let eligibleSourceModes: [ProviderSourceMode?] = [nil, .auto] + for sourceMode in eligibleSourceModes { + #expect(CLIClaudeSwapCards.isEligible( + provider: .claude, + integrationEnabled: true, + hasExplicitAccountSelection: false, + sourceModeOverride: sourceMode)) + } + + for sourceMode in [ProviderSourceMode.web, .cli, .oauth, .api] { + #expect(!CLIClaudeSwapCards.isEligible( + provider: .claude, + integrationEnabled: true, + hasExplicitAccountSelection: false, + sourceModeOverride: sourceMode)) + } + + #expect(!CLIClaudeSwapCards.isEligible( + provider: .claude, + integrationEnabled: false, + hasExplicitAccountSelection: false, + sourceModeOverride: nil)) + #expect(!CLIClaudeSwapCards.isEligible( + provider: .claude, + integrationEnabled: true, + hasExplicitAccountSelection: true, + sourceModeOverride: nil)) + #expect(!CLIClaudeSwapCards.isEligible( + provider: .codex, + integrationEnabled: true, + hasExplicitAccountSelection: false, + sourceModeOverride: nil)) + } + + @Test + func `bypass does not invoke the adapter when single account cards are enabled`() async { + let counter = InvocationCounter() + let ambient = self.ambientOutput() + let output = await CLIClaudeSwapCards.fetch( + eligible: false, + executablePath: "/unused/cswap", + showSingleAccount: true, + renderOptions: self.renderOptions(), + ambientFetch: { ambient }, + accountListReader: { _ in + await counter.increment() + return ClaudeSwapAccountList(activeAccountNumber: nil, accounts: []) + }) + + #expect(await counter.value == 0) + #expect(output.cards == ambient.cards) + } + + @Test + func `zero and one account lists retain ambient output`() async { + let ambientCounter = InvocationCounter() + let ambient = self.ambientOutput() + for accounts in [[], [self.row(number: 1)]] { + let output = await CLIClaudeSwapCards.fetch( + eligible: true, + executablePath: "/fake/cswap", + renderOptions: self.renderOptions(), + ambientFetch: { + await ambientCounter.increment() + return ambient + }, + accountListReader: { _ in + ClaudeSwapAccountList(activeAccountNumber: nil, accounts: accounts) + }) + #expect(output.cards == ambient.cards) + #expect(output.cardFailures.isEmpty) + } + #expect(await ambientCounter.value == 2) + } + + @Test + func `single account option renders sentinel account instead of ambient output`() async { + let ambientCounter = InvocationCounter() + let output = await CLIClaudeSwapCards.fetch( + eligible: true, + executablePath: "/fake/cswap", + showSingleAccount: true, + renderOptions: self.renderOptions(), + ambientFetch: { + await ambientCounter.increment() + return self.ambientOutput(failed: true) + }, + accountListReader: { _ in + ClaudeSwapAccountList(activeAccountNumber: 1, accounts: [ + self.row( + number: 1, + active: true, + status: .tokenExpired, + email: "single@example.com", + hasUsage: false), + ]) + }) + + #expect(await ambientCounter.value == 0) + #expect(output.exitCode == .success) + #expect(output.cards.count == 1) + #expect(output.cards.first?.accountLine == "single@example.com") + #expect(output.cards.first?.isActive == true) + #expect(output.cards.first?.accountProblem == + "Token expired. Switch to this account in claude-swap to refresh it.") + } + + @Test + func `multi account list skips ambient output and renders in active slot order`() async { + let adapterCounter = InvocationCounter() + let ambientCounter = InvocationCounter() + let ambient = self.ambientOutput(failed: true) + let list = ClaudeSwapAccountList(activeAccountNumber: 2, accounts: [ + self.row(number: 3), + self.row(number: 2, active: true), + self.row(number: 1), + ]) + let status = ProviderStatusPayload( + indicator: .minor, + description: "Degraded performance", + updatedAt: Date(timeIntervalSince1970: 0), + url: "https://status.example.com") + + let output = await CLIClaudeSwapCards.fetch( + eligible: true, + executablePath: "/fake/cswap", + renderOptions: self.renderOptions(status: status), + ambientFetch: { + await ambientCounter.increment() + return ambient + }, + accountListReader: { _ in + await adapterCounter.increment() + return list + }) + + #expect(await adapterCounter.value == 1) + #expect(await ambientCounter.value == 0) + #expect(output.cards.map(\.accountLine) == [ + "account-2@example.com", + "account-1@example.com", + "account-3@example.com", + ]) + #expect(output.cards.map(\.isActive) == [true, false, false]) + #expect(output.cards.allSatisfy { $0.sourceLabel == "claude-swap" && $0.planBadge == nil }) + #expect(output.cards.allSatisfy { $0.statusLine == "Status: Partial outage – Degraded performance" }) + #expect(output.cardFailures.isEmpty) + #expect(output.exitCode == .success) + } + + @Test + func `all sentinel rows remain successful metrics less cards`() async { + let statuses: [ClaudeSwapUsageStatus] = [ + .apiKey, + .tokenExpired, + .keychainUnavailable, + .noCredentials, + .unavailable, + .unknown("future_status"), + .ok, + ] + let rows = statuses.enumerated().map { index, status in + self.row(number: index + 1, status: status, hasUsage: false) + } + let output = await CLIClaudeSwapCards.fetch( + eligible: true, + executablePath: "/fake/cswap", + renderOptions: self.renderOptions(), + ambientFetch: { self.ambientOutput(failed: true) }, + accountListReader: { _ in + ClaudeSwapAccountList(activeAccountNumber: nil, accounts: rows) + }) + + #expect(output.exitCode == .success) + #expect(output.cards.count == statuses.count) + #expect(output.cards.allSatisfy { $0.metrics.isEmpty && !$0.isActive }) + #expect(output.cards.map(\.accountProblem) == [ + "API-key account; subscription usage is unavailable.", + "Token expired. Switch to this account in claude-swap to refresh it.", + "claude-swap could not read the active account's Keychain entry.", + "No stored credentials for this account slot.", + "Usage fetch failed.", + "Unrecognized claude-swap status: future_status", + "No usage windows reported.", + ]) + } + + @Test + func `active sentinel account remains active and metrics less in full and brief cards`() async { + let problem = "Usage fetch failed." + let output = await CLIClaudeSwapCards.fetch( + eligible: true, + executablePath: "/fake/cswap", + renderOptions: self.renderOptions(), + ambientFetch: { self.ambientOutput(failed: true) }, + accountListReader: { _ in + ClaudeSwapAccountList(activeAccountNumber: 1, accounts: [ + self.row( + number: 1, + active: true, + status: .unavailable, + email: "active@example.com", + hasUsage: false), + self.row(number: 2), + ]) + }) + + #expect(output.exitCode == .success) + #expect(output.cardFailures.isEmpty) + #expect(output.cards.count == 2) + let activeCard = output.cards.first + #expect(activeCard?.accountLine == "active@example.com") + #expect(activeCard?.isActive == true) + #expect(activeCard?.accountProblem == problem) + #expect(activeCard?.metrics.isEmpty == true) + + let rows = CLICardsBriefRenderer.makeRows(cards: activeCard.map { [$0] } ?? []) + #expect(rows.count == 1) + #expect(rows.first?.accountLabel == "active@example.com") + #expect(rows.first?.isActive == true) + #expect(rows.first?.accountProblem == problem) + #expect(rows.first?.metricLabel == nil) + #expect(rows.first?.usedPercent == nil) + } + + @Test + func `blank executable path preserves ambient output and fails distinctly`() async { + let ambient = self.ambientOutput() + let output = await CLIClaudeSwapCards.fetch( + eligible: true, + executablePath: " ", + renderOptions: self.renderOptions(), + ambientFetch: { ambient }) + + #expect(output.cards == ambient.cards) + #expect(output.exitCode != .success) + #expect(output.cardFailures == [CLICardFailure( + provider: .claude, + accountLabel: "claude-swap", + message: "No claude-swap executable path is configured.")]) + } + + @Test + func `adapter failures follow ambient failures and are bounded and sanitized`() async { + let raw = "\u{1B}]0;owned\u{07}reader\r\nfailed\u{1B}[31m" + String(repeating: "x", count: 700) + let output = await CLIClaudeSwapCards.fetch( + eligible: true, + executablePath: " ", + renderOptions: self.renderOptions(), + ambientFetch: { self.ambientOutput(failed: true) }, + accountListReader: { _ in throw AdapterError(text: raw) }) + + #expect(output.exitCode != .success) + #expect(output.cards.first?.title == "Ambient Claude") + #expect(output.cardFailures.map(\.accountLabel) == [nil, "claude-swap"]) + let diagnostic = output.cardFailures.last?.message ?? "" + #expect(diagnostic.contains("reader failed")) + #expect(!diagnostic.contains("\u{1B}")) + #expect(diagnostic.unicodeScalars.count == CLIClaudeSwapText.diagnosticScalarLimit) + } + + @Test + func `fake executable receives only one read only list command`() async throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("cards-claude-swap-tests-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let executable = directory.appendingPathComponent("cswap") + let invocationMarker = directory.appendingPathComponent("invoked", isDirectory: true) + let duplicateMarker = directory.appendingPathComponent("duplicate") + let script = """ + #!/bin/sh + mkdir '\(invocationMarker.path)' || { + touch '\(duplicateMarker.path)' + exit 2 + } + [ "$#" -eq 2 ] || exit 2 + [ "$1" = "--list" ] || exit 2 + [ "$2" = "--json" ] || exit 2 + cat <<'JSON' + {"schemaVersion":1,"activeAccountNumber":2,"accounts":[ + {"number":1,"email":"one@example.com","active":false,"usageStatus":"api_key"}, + {"number":2,"email":"two@example.com","active":true,"usageStatus":"unavailable"} + ]} + JSON + """ + try script.write(to: executable, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: executable.path) + + let output = await CLIClaudeSwapCards.fetch( + eligible: true, + executablePath: executable.path, + renderOptions: self.renderOptions(), + ambientFetch: { self.ambientOutput() }) + + #expect(output.exitCode == .success) + #expect(output.cardFailures.isEmpty) + #expect(output.cards.count == 2) + #expect(FileManager.default.fileExists(atPath: invocationMarker.path)) + #expect(!FileManager.default.fileExists(atPath: duplicateMarker.path)) + } + + @Test + func `cancellation drains the adapter child and preserves ambient output`() async { + let cancellationCount = InvocationCounter() + let ambient = self.ambientOutput() + let task = Task { + await CLIClaudeSwapCards.fetch( + eligible: true, + executablePath: "/fake/cswap", + renderOptions: self.renderOptions(), + ambientFetch: { ambient }, + accountListReader: { _ in + do { + try await Task.sleep(for: .seconds(30)) + return ClaudeSwapAccountList(activeAccountNumber: nil, accounts: []) + } catch { + await cancellationCount.increment() + throw error + } + }) + } + await Task.yield() + task.cancel() + let output = await task.value + + #expect(await cancellationCount.value == 1) + #expect(output.cards == ambient.cards) + #expect(output.cardFailures.last?.accountLabel == "claude-swap") + #expect(output.exitCode != .success) + } +} diff --git a/TestsLinux/CLICardsRendererTests.swift b/TestsLinux/CLICardsRendererTests.swift new file mode 100644 index 0000000000..9ce3b54343 --- /dev/null +++ b/TestsLinux/CLICardsRendererTests.swift @@ -0,0 +1,701 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBarCLI + +struct CLICardsRendererTests { + @Test + func `computes column count from terminal width`() { + #expect(CLICardsRenderer.columnCount(terminalWidth: 80) == 2) + #expect(CLICardsRenderer.columnCount(terminalWidth: 120) == 3) + #expect(CLICardsRenderer.columnCount(terminalWidth: 160) == 4) + #expect(CLICardsRenderer.columnCount(terminalWidth: 30) == 1) + } + + @Test + func `renders single codex card without color`() { + let identity = ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: "user@example.com", + accountOrganization: nil, + loginMethod: "pro") + let snapshot = UsageSnapshot( + primary: .init(usedPercent: 12, windowMinutes: 300, resetsAt: nil, resetDescription: "today at 3:00 PM"), + secondary: .init(usedPercent: 25, windowMinutes: 10080, resetsAt: nil, resetDescription: "Fri at 9:00 AM"), + tertiary: nil, + updatedAt: Date(timeIntervalSince1970: 0), + identity: identity) + let card = CLICardsRenderer.makeCard(CLICardBuildInput( + provider: .codex, + snapshot: snapshot, + credits: CreditsSnapshot(remaining: 42, events: [], updatedAt: Date()), + source: "oauth", + status: nil, + notes: [], + useColor: false, + resetStyle: .absolute, + weeklyWorkDays: nil, + now: Date())) + + let output = CLICardsRenderer.render(cards: [card], failures: [], terminalWidth: 80, useColor: false) + + #expect(output.contains("Codex")) + #expect(output.contains("[oauth]")) + #expect(output.contains("PLAN Pro 20x")) + #expect(output.contains("Session")) + #expect(output.contains("88% left")) + #expect(output.contains("[ ")) + #expect(output.contains("━")) + #expect(output.contains("Credits:")) + #expect(output.contains("42 left")) + #expect(output.contains("@ user@example.com")) + #expect(output.contains("╰")) + } + + @Test + func `card includes account line`() { + let identity = ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: "user@example.com", + accountOrganization: nil, + loginMethod: "pro") + let snapshot = UsageSnapshot( + primary: .init(usedPercent: 12, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + secondary: nil, + tertiary: nil, + updatedAt: Date(timeIntervalSince1970: 0), + identity: identity) + let card = CLICardsRenderer.makeCard(CLICardBuildInput( + provider: .codex, + snapshot: snapshot, + credits: nil, + source: "cli", + status: nil, + notes: [], + useColor: false, + resetStyle: .absolute, + weeklyWorkDays: nil, + now: Date())) + + let lines = CLICardsRenderer.renderCard(card, width: 48, useColor: false) + let joined = lines.joined(separator: "\n") + + #expect(joined.contains("@ user@example.com")) + #expect(joined.contains("Session")) + #expect(!joined.contains("Plan: Pro 20x")) + } + + @Test + func `renders two card grid at fixed width`() { + let codex = CLICardModel( + provider: .codex, + title: "Codex", + sourceLabel: "oauth", + planBadge: "Pro", + accountLine: nil, + infoLines: [], + metrics: [CLICardMetric(label: "Session", remainingPercent: 88, resetText: nil)], + extraLines: [], + statusLine: nil) + let claude = CLICardModel( + provider: .claude, + title: "Claude", + sourceLabel: "web", + planBadge: "Max", + accountLine: nil, + infoLines: [], + metrics: [CLICardMetric(label: "Session", remainingPercent: 50, resetText: nil)], + extraLines: [], + statusLine: nil) + + let output = CLICardsRenderer.render(cards: [codex, claude], failures: [], terminalWidth: 120, useColor: false) + + #expect(output.contains("Codex")) + #expect(output.contains("Claude")) + #expect(output.contains("88% left")) + #expect(output.contains("50% left")) + #expect(output.components(separatedBy: "╰").count >= 3) + } + + @Test + func `renders failure footer without cards`() { + let failures = [ + CLICardFailure(provider: .cursor, accountLabel: nil, message: "not configured"), + ] + let output = CLICardsRenderer.render(cards: [], failures: failures, terminalWidth: 80, useColor: false) + + #expect(output.contains("Failed providers:")) + #expect(output.contains("Cursor: not configured")) + } + + @Test + func `appends failure footer after successful cards`() { + let card = CLICardModel( + provider: .codex, + title: "Codex", + sourceLabel: "oauth", + planBadge: nil, + accountLine: nil, + infoLines: [], + metrics: [CLICardMetric(label: "Session", remainingPercent: 88, resetText: nil)], + extraLines: [], + statusLine: nil) + let failures = [ + CLICardFailure(provider: .grok, accountLabel: nil, message: "timeout"), + ] + + let output = CLICardsRenderer.render(cards: [card], failures: failures, terminalWidth: 80, useColor: false) + + #expect(output.contains("88% left")) + #expect(output.contains("Failed providers:")) + #expect(output.contains("Grok: timeout")) + } + + @Test + func `brief mode renders usage table`() { + let card = CLICardModel( + provider: .claude, + title: "Claude", + sourceLabel: "web", + planBadge: "Max", + accountLine: nil, + infoLines: [], + metrics: [CLICardMetric(label: "Session", remainingPercent: 2, resetText: "⏳ Resets in 1h 49m")], + extraLines: [], + statusLine: nil) + let rows = CLICardsBriefRenderer.makeRows(cards: [card]) + let output = CLICardsBriefRenderer.render( + rows: rows, + failures: [], + terminalWidth: 80, + useColor: false, + now: Date(timeIntervalSince1970: 0)) + + #expect(output.contains("codexbar • AI Usage & Limits")) + #expect(output.contains("Provider")) + #expect(output.contains("Claude")) + #expect(output.contains("web")) + #expect(output.contains("Max")) + #expect(output.contains("98%")) + #expect(output.contains("█")) + #expect(output.contains("1h 49m")) + #expect(output.contains("⚠ Warnings:")) + let tableLine = output.split(separator: "\n").first { $0.hasPrefix("┌") } ?? "" + #expect(tableLine.count >= 50) + #expect(tableLine.count <= 72) + } + + @Test + func `synthetic quota lanes do not replace real brief usage`() { + let snapshot = UsageSnapshot( + primary: .init( + usedPercent: 0, + windowMinutes: 300, + resetsAt: nil, + resetDescription: nil, + isSyntheticPlaceholder: true), + secondary: .init( + usedPercent: 20, + windowMinutes: 10080, + resetsAt: nil, + resetDescription: nil), + tertiary: nil, + updatedAt: Date(timeIntervalSince1970: 0)) + let card = CLICardsRenderer.makeCard(CLICardBuildInput( + provider: .claude, + snapshot: snapshot, + credits: nil, + source: "oauth", + status: nil, + notes: [], + useColor: false, + resetStyle: .countdown, + weeklyWorkDays: nil, + now: Date(timeIntervalSince1970: 0))) + let rows = CLICardsBriefRenderer.makeRows(cards: [card]) + + #expect(card.metrics.map(\.label) == ["Weekly"]) + #expect(rows.first?.usedPercent == 20) + } + + @Test + func `brief reset summary wraps to terminal width`() { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let card = CLICardModel( + provider: .alibabatokenplan, + title: "Alibaba Token Plan", + sourceLabel: "web", + planBadge: "International", + accountLine: nil, + infoLines: [], + metrics: [CLICardMetric( + label: "Monthly budget", + remainingPercent: 50, + resetText: "⏳ Resets July 30 at 11:59 PM", + resetAt: now.addingTimeInterval(3600))], + extraLines: [], + statusLine: nil) + + let output = CLICardsBriefRenderer.render( + rows: CLICardsBriefRenderer.makeRows(cards: [card]), + failures: [], + terminalWidth: 40, + useColor: false, + now: now) + let lines = output.split(separator: "\n", omittingEmptySubsequences: false) + + #expect(output.contains("Next reset: Alibaba Token Plan")) + #expect(lines.allSatisfy { $0.count <= 40 }) + } + + @Test + func `detail backed quota descriptions are not rendered as resets`() { + let snapshot = UsageSnapshot( + primary: .init( + usedPercent: 25, + windowMinutes: nil, + resetsAt: nil, + resetDescription: "25/100 credits"), + secondary: nil, + tertiary: nil, + updatedAt: Date(timeIntervalSince1970: 0)) + let card = CLICardsRenderer.makeCard(CLICardBuildInput( + provider: .kilo, + snapshot: snapshot, + credits: nil, + source: "api", + status: nil, + notes: [], + useColor: false, + resetStyle: .countdown, + weeklyWorkDays: nil, + now: Date(timeIntervalSince1970: 0))) + + #expect(card.metrics.first?.resetText == nil) + #expect(card.metrics.first?.detailText == "25/100 credits") + + let output = CLICardsBriefRenderer.render( + rows: CLICardsBriefRenderer.makeRows(cards: [card]), + failures: [], + terminalWidth: 80, + useColor: false, + now: Date(timeIntervalSince1970: 0)) + #expect(!output.contains("Next reset")) + #expect(!output.contains("Reset 25/100 credits")) + } + + @Test + func `card metrics honor reset display style`() { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let snapshot = UsageSnapshot( + primary: .init( + usedPercent: 25, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(3600), + resetDescription: nil), + secondary: nil, + tertiary: nil, + updatedAt: now) + let countdown = CLICardsRenderer.makeCard(CLICardBuildInput( + provider: .codex, + snapshot: snapshot, + credits: nil, + source: "oauth", + status: nil, + notes: [], + useColor: false, + resetStyle: .countdown, + weeklyWorkDays: nil, + now: now)) + let absolute = CLICardsRenderer.makeCard(CLICardBuildInput( + provider: .codex, + snapshot: snapshot, + credits: nil, + source: "oauth", + status: nil, + notes: [], + useColor: false, + resetStyle: .absolute, + weeklyWorkDays: nil, + now: now)) + + #expect(countdown.metrics.first?.resetText != absolute.metrics.first?.resetText) + #expect(countdown.metrics.first?.resetText?.contains("in 1h") == true) + #expect(absolute.metrics.first?.resetAt == now.addingTimeInterval(3600)) + } + + @Test + func `long detail rows stay within card width`() { + let card = CLICardModel( + provider: .clawrouter, + title: "ClawRouter", + sourceLabel: "api", + planBadge: nil, + accountLine: nil, + infoLines: ["Workspace: " + String(repeating: "long-name-", count: 12)], + metrics: [], + extraLines: [], + statusLine: nil) + + let lines = CLICardsRenderer.renderCard(card, width: 38, useColor: true, enhanced: true) + #expect(lines.allSatisfy { TextParsing.stripANSICodes($0).count == 38 }) + } + + @Test + func `brief warnings name the actual quota metric`() { + let card = CLICardModel( + provider: .openrouter, + title: "OpenRouter", + sourceLabel: "api", + planBadge: nil, + accountLine: nil, + infoLines: [], + metrics: [CLICardMetric(label: "Spend", remainingPercent: 10, resetText: nil)], + extraLines: [], + statusLine: nil) + + let output = CLICardsBriefRenderer.render( + rows: CLICardsBriefRenderer.makeRows(cards: [card]), + failures: [], + terminalWidth: 80, + useColor: false, + now: Date(timeIntervalSince1970: 0)) + + #expect(output.contains("OpenRouter Spend: 90% used")) + #expect(!output.contains("session limit")) + } + + @Test + func `brief rows preserve account identity`() { + let cards = [ + CLICardModel( + provider: .codex, + title: "Codex", + sourceLabel: "oauth", + planBadge: "Pro", + accountLine: "one@x.dev", + infoLines: [], + metrics: [CLICardMetric(label: "Session", remainingPercent: 80, resetText: nil)], + extraLines: [], + statusLine: nil), + CLICardModel( + provider: .codex, + title: "Codex", + sourceLabel: "oauth", + planBadge: "Pro", + accountLine: "two@x.dev", + infoLines: [], + metrics: [CLICardMetric(label: "Session", remainingPercent: 60, resetText: nil)], + extraLines: [], + statusLine: nil), + ] + + let output = CLICardsBriefRenderer.render( + rows: CLICardsBriefRenderer.makeRows(cards: cards), + failures: [], + terminalWidth: 80, + useColor: false, + now: Date(timeIntervalSince1970: 0)) + + #expect(output.contains("one@x.dev")) + #expect(output.contains("two@x.dev")) + } + + @Test + func `brief warnings wrap to terminal width`() { + let cards = ["OpenRouter", "Antigravity", "CommandCode"].map { title in + CLICardModel( + provider: .openrouter, + title: title, + sourceLabel: "api", + planBadge: nil, + accountLine: nil, + infoLines: [], + metrics: [CLICardMetric(label: "Monthly budget", remainingPercent: 5, resetText: nil)], + extraLines: [], + statusLine: nil) + } + + let output = CLICardsBriefRenderer.render( + rows: CLICardsBriefRenderer.makeRows(cards: cards), + failures: [], + terminalWidth: 40, + useColor: false, + now: Date(timeIntervalSince1970: 0)) + let warningLines = output.split(separator: "\n").filter { + $0.contains("Warnings:") || $0.contains("% used") + } + + #expect(warningLines.count > 1) + #expect(warningLines.allSatisfy { $0.count <= 40 }) + } + + @Test + func `brief summary ignores unparseable reset labels and fits narrow terminals`() { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let rows = CLICardsBriefRenderer.makeRows(cards: [ + CLICardModel( + provider: .kilo, + title: "Kilo", + sourceLabel: "api", + planBadge: nil, + accountLine: nil, + infoLines: [], + metrics: [CLICardMetric(label: "Credits", remainingPercent: 75, resetText: "Reset Unlimited")], + extraLines: [], + statusLine: nil), + CLICardModel( + provider: .codex, + title: "Codex", + sourceLabel: "oauth", + planBadge: "Pro", + accountLine: nil, + infoLines: [], + metrics: [CLICardMetric( + label: "Session", + remainingPercent: 50, + resetText: "⏳ Resets in 5h", + resetAt: now.addingTimeInterval(5 * 3600))], + extraLines: [], + statusLine: nil), + ]) + + let output = CLICardsBriefRenderer.render( + rows: rows, + failures: [], + terminalWidth: 40, + useColor: false, + now: now) + let lines = output.split(separator: "\n", omittingEmptySubsequences: false) + + #expect(output.contains("Next reset: Codex in 5h")) + #expect(!output.contains("Next reset: Kilo")) + #expect(lines.allSatisfy { $0.count <= 40 }) + } + + @Test + func `enhanced brief mode fills bars from used percentage`() { + let rows = CLICardsBriefRenderer.makeRows(cards: [ + CLICardModel( + provider: .codex, + title: "Unused", + sourceLabel: "oauth", + planBadge: nil, + accountLine: nil, + infoLines: [], + metrics: [CLICardMetric(label: "Session", remainingPercent: 100, resetText: nil)], + extraLines: [], + statusLine: nil), + CLICardModel( + provider: .openrouter, + title: "Exhausted", + sourceLabel: "api", + planBadge: nil, + accountLine: nil, + infoLines: [], + metrics: [CLICardMetric(label: "Session", remainingPercent: 0, resetText: nil)], + extraLines: [], + statusLine: nil), + ]) + let output = CLICardsBriefRenderer.render( + rows: rows, + failures: [], + terminalWidth: 80, + useColor: true, + enhanced: true, + now: Date(timeIntervalSince1970: 0)) + let plainLines = TextParsing.stripANSICodes(output).split(separator: "\n") + let unusedLine = String(plainLines.first { $0.contains("Unused") } ?? "") + let exhaustedLine = String(plainLines.first { $0.contains("Exhausted") } ?? "") + + #expect(unusedLine.contains("0%")) + #expect(unusedLine.filter { $0 == "█" }.isEmpty) + #expect(exhaustedLine.contains("100%")) + #expect(exhaustedLine.filter { $0 == "░" }.isEmpty) + } + + @Test + func `standard brief mode fills bars from used percentage`() { + let rows = CLICardsBriefRenderer.makeRows(cards: [ + CLICardModel( + provider: .codex, + title: "Unused", + sourceLabel: "oauth", + planBadge: nil, + accountLine: nil, + infoLines: [], + metrics: [CLICardMetric(label: "Session", remainingPercent: 100, resetText: nil)], + extraLines: [], + statusLine: nil), + CLICardModel( + provider: .openrouter, + title: "Exhausted", + sourceLabel: "api", + planBadge: nil, + accountLine: nil, + infoLines: [], + metrics: [CLICardMetric(label: "Session", remainingPercent: 0, resetText: nil)], + extraLines: [], + statusLine: nil), + ]) + let output = CLICardsBriefRenderer.render( + rows: rows, + failures: [], + terminalWidth: 80, + useColor: false, + enhanced: false, + now: Date(timeIntervalSince1970: 0)) + let plainLines = output.split(separator: "\n") + let unusedLine = String(plainLines.first { $0.contains("Unused") } ?? "") + let exhaustedLine = String(plainLines.first { $0.contains("Exhausted") } ?? "") + + #expect(unusedLine.contains("0%")) + #expect(unusedLine.filter { $0 == "█" }.isEmpty) + #expect(exhaustedLine.contains("100%")) + #expect(exhaustedLine.filter { $0 == "░" }.isEmpty) + } + + @Test + func `standard card grid shows empty remaining bar at exhaustion`() { + let card = CLICardModel( + provider: .openrouter, + title: "Exhausted", + sourceLabel: "api", + planBadge: nil, + accountLine: nil, + infoLines: [], + metrics: [CLICardMetric(label: "Session", remainingPercent: 0, resetText: nil)], + extraLines: [], + statusLine: nil) + let lines = CLICardsRenderer.renderCard(card, width: 48, useColor: false, enhanced: false) + let barLine = String(lines.first { $0.contains("[ ") && $0.contains("]") } ?? "") + #expect(barLine.filter { $0 == "━" }.isEmpty) + } + + @Test + func `enhanced card grid shows empty remaining bar at exhaustion`() { + let card = CLICardModel( + provider: .openrouter, + title: "Exhausted", + sourceLabel: "api", + planBadge: nil, + accountLine: nil, + infoLines: [], + metrics: [CLICardMetric(label: "Session", remainingPercent: 0, resetText: nil)], + extraLines: [], + statusLine: nil) + let lines = CLICardsRenderer.renderCard(card, width: 48, useColor: true, enhanced: true) + let plainBarLine = TextParsing.stripANSICodes( + String(lines.first { $0.contains("[ ") && $0.contains("]") } ?? "")) + #expect(plainBarLine.filter { !$0.isWhitespace && $0 != "│" && $0 != "[" && $0 != "]" }.isEmpty) + } + + @Test + func `enhanced mode uses truecolor gradient bars`() { + let card = CLICardModel( + provider: .codex, + title: "Codex", + sourceLabel: "oauth", + planBadge: "Pro", + accountLine: nil, + infoLines: [], + metrics: [CLICardMetric(label: "Session", remainingPercent: 50, resetText: nil)], + extraLines: [], + statusLine: nil) + let output = CLICardsRenderer.render( + cards: [card], + failures: [], + terminalWidth: 80, + useColor: true, + enhanced: true) + #expect(output.contains("38;2;")) + #expect(output.contains("48;2;")) + #expect(output.contains("[ ")) + } + + @Test + func `claude swap active account renders without inferred plan`() { + let snapshot = UsageSnapshot( + primary: .init(usedPercent: 25, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + secondary: nil, + tertiary: nil, + updatedAt: Date(timeIntervalSince1970: 0), + identity: ProviderIdentitySnapshot( + providerID: .claude, + accountEmail: "active@example.com", + accountOrganization: nil, + loginMethod: "claude-swap")) + let account = ProviderAccountUsageSnapshot( + id: ProviderAccountIdentity(source: "claude-swap", opaqueID: "2"), + provider: .claude, + displayLabel: "active@example.com", + isActive: true, + snapshot: snapshot, + error: nil, + sourceLabel: "claude-swap") + let card = CLICardsRenderer.makeClaudeSwapCard( + account: account, + renderOptions: CLIClaudeSwapCardsRenderOptions( + status: nil, + useColor: false, + resetStyle: .countdown, + weeklyWorkDays: nil, + now: Date(timeIntervalSince1970: 0))) + let full = CLICardsRenderer.renderCard(card, width: 38, useColor: false).joined(separator: "\n") + let brief = CLICardsBriefRenderer.render( + rows: CLICardsBriefRenderer.makeRows(cards: [card]), + failures: [], + terminalWidth: 40, + useColor: false, + now: Date(timeIntervalSince1970: 0)) + + #expect(card.planBadge == nil) + #expect(full.contains("@ active@example.com [active]")) + #expect(!full.contains("PLAN Claude-Swap")) + #expect(brief.contains("[active]")) + #expect(!brief.contains("Claude-Swap")) + #expect(full.split(separator: "\n").allSatisfy { $0.count == 38 }) + #expect(brief.split(separator: "\n", omittingEmptySubsequences: false).allSatisfy { $0.count <= 40 }) + } + + @Test + func `claude swap sentinel text survives full and brief projections`() { + let account = ProviderAccountUsageSnapshot( + id: ProviderAccountIdentity(source: "claude-swap", opaqueID: "7"), + provider: .claude, + displayLabel: "bad\u{1B}[31m\r\n" + String(repeating: "x", count: 300), + isActive: true, + snapshot: nil, + error: "API-key account; subscription usage is unavailable.", + sourceLabel: "claude-swap") + let card = CLICardsRenderer.makeClaudeSwapCard( + account: account, + renderOptions: CLIClaudeSwapCardsRenderOptions( + status: nil, + useColor: false, + resetStyle: .countdown, + weeklyWorkDays: nil, + now: Date(timeIntervalSince1970: 0))) + let full = CLICardsRenderer.renderCard(card, width: 42, useColor: false).joined(separator: "\n") + let brief = CLICardsBriefRenderer.render( + rows: CLICardsBriefRenderer.makeRows(cards: [card]), + failures: [], + terminalWidth: 80, + useColor: false, + now: Date(timeIntervalSince1970: 0)) + let briefRow = brief.split(separator: "\n").first { $0.contains("API-key") } ?? "" + + #expect(card.accountLine?.unicodeScalars.count == CLIClaudeSwapText.labelScalarLimit) + #expect(card.accountLine?.contains("\u{1B}") == false) + #expect(card.accountLine?.contains("\n") == false) + #expect(card.isActive) + #expect(full.contains("[active]")) + #expect(full.contains("API-key account;")) + #expect(full.contains("subscription usage")) + #expect(full.contains("unavailable.")) + #expect(brief.contains("Claude [active]")) + #expect(brief.contains("API-key account")) + #expect(briefRow.hasSuffix(" — │")) + #expect(card.metrics.isEmpty) + } +} diff --git a/TestsLinux/CLIGuardDecisionTests.swift b/TestsLinux/CLIGuardDecisionTests.swift new file mode 100644 index 0000000000..7cf84db139 --- /dev/null +++ b/TestsLinux/CLIGuardDecisionTests.swift @@ -0,0 +1,132 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBarCLI + +struct CLIGuardDecisionTests { + @Test + func `ample headroom is ok and exits zero`() { + let result = CodexBarCLI.evaluateGuard( + outcome: .available(74), + minimumRemainingPercent: 10, + failOpen: false) + #expect(result.decision == .ok) + #expect(result.exitCode == 0) + } + + @Test + func `insufficient headroom is blocked and exits one`() { + let result = CodexBarCLI.evaluateGuard( + outcome: .available(5), + minimumRemainingPercent: 10, + failOpen: false) + #expect(result.decision == .blocked) + #expect(result.exitCode == 1) + } + + @Test + func `fetch failure exits unavailable by default`() { + let result = CodexBarCLI.evaluateGuard( + outcome: .unavailable(.fetchFailed), + minimumRemainingPercent: 10, + failOpen: false) + #expect(result.decision == .unknown) + #expect(result.exitCode == 69) + #expect(result.unavailableReason == .fetchFailed) + } + + @Test + func `unknown remaining with fail-open exits zero`() { + let result = CodexBarCLI.evaluateGuard( + outcome: .unavailable(.fetchFailed), + minimumRemainingPercent: 10, + failOpen: true) + #expect(result.decision == .unknown) + #expect(result.exitCode == 0) + } + + @Test + func `remaining exactly equal to need is ok`() { + let result = CodexBarCLI.evaluateGuard( + outcome: .available(10), + minimumRemainingPercent: 10, + failOpen: false) + #expect(result.decision == .ok) + #expect(result.exitCode == 0) + } + + @Test + func `unknown provider is rejected`() { + let result = CodexBarCLI.guardProvider(rawOverride: "definitely-not-a-provider") + guard case let .failure(error) = result else { + Issue.record("Expected unknown provider to be rejected") + return + } + #expect(error.localizedDescription == "unknown provider 'definitely-not-a-provider'.") + } + + @Test + func `missing provider is rejected`() { + let result = CodexBarCLI.guardProvider(rawOverride: nil) + guard case let .failure(error) = result else { + Issue.record("Expected missing provider to be rejected") + return + } + #expect(error.localizedDescription == "guard requires --provider .") + } + + @Test + func `timeout rejects values that could overflow duration`() { + let result = CodexBarCLI.guardTimeout(raw: "1e100") + guard case .failure = result else { + Issue.record("Expected enormous timeout to be rejected") + return + } + } + + @Test + func `fetch timeout is reported as unavailable`() async { + let result = await CodexBarCLI.runGuardFetch(timeout: 0.01) { + try? await Task.sleep(for: .seconds(30)) + return .available(100) + } + guard case .unavailable(.timeout) = result else { + Issue.record("Expected guard fetch to time out") + return + } + } + + // MARK: - Window headroom (synthetic-placeholder filtering) + + private func window(usedPercent: Double, synthetic: Bool) -> RateWindow { + RateWindow( + usedPercent: usedPercent, + windowMinutes: 300, + resetsAt: nil, + resetDescription: nil, + isSyntheticPlaceholder: synthetic) + } + + @Test + func `real window reports remaining headroom`() { + let remaining = CodexBarCLI.guardRemainingHeadroom(for: self.window(usedPercent: 30, synthetic: false)) + #expect(remaining == 70) + } + + @Test + func `synthetic placeholder window is treated as unknown`() { + let remaining = CodexBarCLI.guardRemainingHeadroom(for: self.window(usedPercent: 0, synthetic: true)) + #expect(remaining == nil) + } + + @Test + func `absent window is unknown`() { + #expect(CodexBarCLI.guardRemainingHeadroom(for: nil) == nil) + } + + @Test + func `fully used real window has zero headroom`() { + let remaining = CodexBarCLI.guardRemainingHeadroom(for: self.window(usedPercent: 100, synthetic: false)) + #expect(remaining == 0) + } +} diff --git a/TestsLinux/CLITerminalCapabilitiesTests.swift b/TestsLinux/CLITerminalCapabilitiesTests.swift new file mode 100644 index 0000000000..a8d01a6839 --- /dev/null +++ b/TestsLinux/CLITerminalCapabilitiesTests.swift @@ -0,0 +1,49 @@ +import Foundation +import Testing +@testable import CodexBarCLI + +struct CLITerminalCapabilitiesTests { + @Test + func `detects kitty graphics backend`() { + let env = ["KITTY_WINDOW_ID": "1", "TERM": "xterm-kitty"] + #expect(CLITerminalCapabilities.detect(environment: env) == .kittyGraphics) + #expect(CLITerminalCapabilities.supportsEnhancedCards(useColor: true, environment: env)) + } + + @Test + func `detects ghostty backend`() { + let env = ["GHOSTTY_RESOURCES_DIR": "/usr/share/ghostty", "TERM": "xterm-ghostty"] + #expect(CLITerminalCapabilities.detect(environment: env) == .kittyGraphics) + } + + @Test + func `detects truecolor without graphics env`() { + let env = ["COLORTERM": "truecolor", "TERM": "alacritty"] + #expect(CLITerminalCapabilities.detect(environment: env) == .truecolor) + } + + @Test + func `respects forced enhanced env override`() { + let env = ["TERM": "dumb", "CODEXBAR_CARDS_ENHANCED": "1"] + #expect(CLITerminalCapabilities.supportsEnhancedCards(useColor: true, environment: env)) + } + + @Test + func `defaults cards to standard on plain ansi terminals`() { + let env = ["TERM": "xterm-256color"] + #expect(!CLITerminalCapabilities.supportsEnhancedCards(useColor: true, environment: env)) + #expect(!CLITerminalCapabilities.supportsEnhancedCards(useColor: false, environment: env)) + } + + @Test + func `defaults cards to enhanced on truecolor terminals`() { + let env = ["TERM": "xterm-256color", "COLORTERM": "truecolor"] + #expect(CLITerminalCapabilities.supportsEnhancedCards(useColor: true, environment: env)) + } + + @Test + func `respects forced enhanced opt out`() { + let env = ["TERM": "xterm-256color", "CODEXBAR_CARDS_ENHANCED": "0"] + #expect(!CLITerminalCapabilities.supportsEnhancedCards(useColor: true, environment: env)) + } +} diff --git a/TestsLinux/CLITimeZoneBootstrapTests.swift b/TestsLinux/CLITimeZoneBootstrapTests.swift new file mode 100644 index 0000000000..966d313e5e --- /dev/null +++ b/TestsLinux/CLITimeZoneBootstrapTests.swift @@ -0,0 +1,82 @@ +import Foundation +import Testing +@testable import CodexBarCLI + +struct CLITimeZoneBootstrapTests { + @Test + func `derives IANA identifier from resolved zoneinfo path`() { + #expect(CodexBarCLI.linuxTimeZoneBootstrapIdentifier( + currentValue: nil, + localTimeReadable: true, + resolvedLocalTimePath: "/nix/store/hash-tzdata/share/zoneinfo/America/New_York") + == "America/New_York") + } + + @Test(arguments: [ + ("/usr/share/zoneinfo/Europe/Berlin", "Europe/Berlin"), + ("/usr/share/zoneinfo/posix/Australia/Sydney", "Australia/Sydney"), + ("/usr/share/zoneinfo/right/Etc/UTC", "Etc/UTC"), + ]) + func `normalizes conventional zoneinfo paths`(resolvedPath: String, expectedIdentifier: String) { + #expect(CodexBarCLI.linuxTimeZoneIdentifier(from: resolvedPath) == expectedIdentifier) + } + + @Test + func `does not bootstrap an unrecognized localtime path`() { + #expect(CodexBarCLI.linuxTimeZoneBootstrapIdentifier( + currentValue: nil, + localTimeReadable: true, + resolvedLocalTimePath: "/etc/localtime") == nil) + } + + @Test(arguments: ["Asia/Kolkata", "", ":/custom/zoneinfo"]) + func `preserves caller timezone`(currentValue: String) { + #expect(CodexBarCLI.linuxTimeZoneBootstrapIdentifier( + currentValue: currentValue, + localTimeReadable: true, + resolvedLocalTimePath: "/nix/store/hash-tzdata/share/zoneinfo/Asia/Kolkata") == nil) + } + + @Test + func `does not set an unreadable localtime file`() { + #expect(CodexBarCLI.linuxTimeZoneBootstrapIdentifier( + currentValue: nil, + localTimeReadable: false, + resolvedLocalTimePath: "/nix/store/hash-tzdata/share/zoneinfo/Asia/Kolkata") == nil) + } + + @Test(arguments: [ + "/nix/store/hash-tzdata/share/zoneinfo/", + "/nix/store/hash-tzdata/share/zoneinfo/../UTC", + "/var/lib/timezone/Asia/Kolkata", + ]) + func `rejects invalid or unrelated resolved paths`(resolvedPath: String) { + #expect(CodexBarCLI.linuxTimeZoneIdentifier(from: resolvedPath) == nil) + } + + @Test + func `rejects invalid CoreFoundation timezone data`() { + #expect(!CodexBarCLI.primeCoreFoundationTimeZone( + identifier: "Etc/CodexBarInvalid", + filePath: "/dev/null")) + } + + #if os(Linux) + @Test + func `primes the legacy formatter bridge with system timezone data`() throws { + let resolvedPath = URL(fileURLWithPath: "/etc/localtime").resolvingSymlinksInPath().path + guard let identifier = CodexBarCLI.linuxTimeZoneIdentifier(from: resolvedPath) else { return } + + #expect(CodexBarCLI.primeCoreFoundationTimeZone( + identifier: identifier, + filePath: "/etc/localtime")) + + let timeZone = try #require(TimeZone(identifier: identifier)) + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.timeZone = timeZone + formatter.dateFormat = "yyyy-MM-dd HH:mm:ss" + #expect(!formatter.string(from: Date(timeIntervalSince1970: 0)).isEmpty) + } + #endif +} diff --git a/TestsLinux/ClaudeOAuthCredentialHistoryLinuxTests.swift b/TestsLinux/ClaudeOAuthCredentialHistoryLinuxTests.swift new file mode 100644 index 0000000000..bd25fed22d --- /dev/null +++ b/TestsLinux/ClaudeOAuthCredentialHistoryLinuxTests.swift @@ -0,0 +1,27 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct ClaudeOAuthCredentialHistoryLinuxTests { + @Test + func historyOwnerIdentifierUsesSwiftCrypto() throws { + let first = ClaudeOAuthCredentials( + accessToken: "access-token-a", + refreshToken: "refresh-token-a", + expiresAt: Date(timeIntervalSinceNow: 3600), + scopes: ["user:profile"], + rateLimitTier: nil) + let second = ClaudeOAuthCredentials( + accessToken: "access-token-b", + refreshToken: "refresh-token-b", + expiresAt: Date(timeIntervalSinceNow: 3600), + scopes: ["user:profile"], + rateLimitTier: nil) + + let firstIdentifier = try #require(first.historyOwnerIdentifier) + let secondIdentifier = try #require(second.historyOwnerIdentifier) + #expect(firstIdentifier.count == 64) + #expect(firstIdentifier != secondIdentifier) + #expect(firstIdentifier.allSatisfy { $0.isHexDigit }) + } +} diff --git a/TestsLinux/ClaudeOAuthDelegatedRefreshLinuxTests.swift b/TestsLinux/ClaudeOAuthDelegatedRefreshLinuxTests.swift new file mode 100644 index 0000000000..21ca1b3707 --- /dev/null +++ b/TestsLinux/ClaudeOAuthDelegatedRefreshLinuxTests.swift @@ -0,0 +1,169 @@ +import Foundation +import Testing +@testable import CodexBarCore + +@Suite(.serialized) +struct ClaudeOAuthDelegatedRefreshLinuxTests { + private actor Counter { + private var value = 0 + + func increment() { + self.value += 1 + } + + func current() -> Int { + self.value + } + } + + private actor VersionDetectionCapture { + private var value: Bool? + + func record(_ value: Bool) { + self.value = value + } + + func current() -> Bool? { + self.value + } + } + + @Test + func cliOAuthSkipsVersionDetectionWhileAppPreservesIt() async throws { + #expect(try await self.detectsClaudeVersion(runtime: .cli) == false) + #expect(try await self.detectsClaudeVersion(runtime: .app) == true) + } + + @Test + func cliOAuthDoesNotDelegateRefreshEvenForUserAction() async { + let result = await self.runDelegatedRefresh( + runtime: .cli, + interaction: .userInitiated, + promptMode: .always) + + #expect(result.attempts == 0) + #expect(result.message.contains("CodexBar CLI does not launch Claude")) + } + + @Test + func appOAuthPreservesUserInitiatedDelegatedRefresh() async { + let result = await self.runDelegatedRefresh( + runtime: .app, + interaction: .userInitiated, + promptMode: .onlyOnUserAction) + + #expect(result.attempts == 1) + #expect(result.message.contains("still unavailable after delegated Claude CLI refresh")) + } + + @Test + func appOAuthBackgroundRespectsPlatformKeychainPromptPolicy() async { + let result = await self.runDelegatedRefresh( + runtime: .app, + interaction: .background, + promptMode: .onlyOnUserAction) + + #expect(result.attempts == 0) + #expect(result.message.contains("background repair is suppressed")) + #expect(result.message.contains("Click Refresh in the CodexBar menu")) + #expect(!result.message.contains("Open the CodexBar menu or")) + } + + private func runDelegatedRefresh( + runtime: ProviderRuntime, + interaction: ProviderInteraction, + promptMode: ClaudeOAuthKeychainPromptMode) async -> (attempts: Int, message: String) + { + let counter = Counter() + let fetcher = ClaudeUsageFetcher( + browserDetection: BrowserDetection(cacheTTL: 0), + environment: [:], + runtime: runtime, + dataSource: .oauth) + let credentialsOverride: @Sendable ( + [String: String], + Bool, + Bool) async throws -> ClaudeOAuthCredentials = { _, _, _ in + throw ClaudeOAuthCredentialsError.refreshDelegatedToClaudeCLI + } + let delegatedOverride: @Sendable ( + Date, + TimeInterval, + [String: String]) async -> ClaudeOAuthDelegatedRefreshCoordinator.Outcome = { _, _, _ in + await counter.increment() + return .attemptedSucceeded + } + + do { + _ = try await ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(promptMode) { + try await ProviderInteractionContext.$current.withValue(interaction) { + try await ClaudeUsageFetcher.$loadOAuthCredentialsOverride + .withValue(credentialsOverride) { + try await ClaudeUsageFetcher.$delegatedRefreshAttemptOverride + .withValue(delegatedOverride) { + try await fetcher.loadLatestUsage(model: "sonnet") + } + } + } + } + Issue.record("Expected delegated-refresh path to fail with mocked stale credentials") + return (await counter.current(), "") + } catch let error as ClaudeUsageError { + guard case let .oauthFailed(message) = error else { + Issue.record("Expected ClaudeUsageError.oauthFailed, got \(error)") + return (await counter.current(), "") + } + return (await counter.current(), message) + } catch { + Issue.record("Expected ClaudeUsageError, got \(error)") + return (await counter.current(), "") + } + } + + private func detectsClaudeVersion(runtime: ProviderRuntime) async throws -> Bool { + let capture = VersionDetectionCapture() + let fetcher = ClaudeUsageFetcher( + browserDetection: BrowserDetection(cacheTTL: 0), + environment: [:], + runtime: runtime, + dataSource: .oauth) + let credentialsOverride: @Sendable ( + [String: String], + Bool, + Bool) async throws -> ClaudeOAuthCredentials = { _, _, _ in + ClaudeOAuthCredentials( + accessToken: "access-token", + refreshToken: nil, + expiresAt: Date(timeIntervalSinceNow: 3600), + scopes: ["user:profile"], + rateLimitTier: nil) + } + let fetchOverride: @Sendable (String, Bool) async throws -> OAuthUsageResponse = { + _, detectClaudeVersion in + await capture.record(detectClaudeVersion) + return try Self.makeOAuthUsageResponse() + } + + _ = try await ClaudeUsageFetcher.$loadOAuthCredentialsOverride.withValue(credentialsOverride) { + try await ClaudeUsageFetcher.$fetchOAuthUsageOverride.withValue(fetchOverride) { + try await fetcher.loadLatestUsage(model: "sonnet") + } + } + + guard let value = await capture.current() else { + Issue.record("Expected OAuth fetch to report its version-detection policy") + return false + } + return value + } + + private static func makeOAuthUsageResponse() throws -> OAuthUsageResponse { + let json = """ + { + "five_hour": { "utilization": 7, "resets_at": "2025-12-23T16:00:00.000Z" }, + "seven_day": { "utilization": 21, "resets_at": "2025-12-29T23:00:00.000Z" } + } + """ + return try ClaudeOAuthUsageFetcher._decodeUsageResponseForTesting(Data(json.utf8)) + } +} diff --git a/TestsLinux/ClaudeOAuthUsageRateLimitGateLinuxTests.swift b/TestsLinux/ClaudeOAuthUsageRateLimitGateLinuxTests.swift new file mode 100644 index 0000000000..cc5be5899f --- /dev/null +++ b/TestsLinux/ClaudeOAuthUsageRateLimitGateLinuxTests.swift @@ -0,0 +1,29 @@ +#if os(Linux) +import Foundation +import Testing +@testable import CodexBarCore + +struct ClaudeOAuthUsageRateLimitGateLinuxTests { + @Test + func `rate limit gate isolates tokens without storing raw credentials`() { + ClaudeOAuthUsageRateLimitGate.resetForTesting() + defer { ClaudeOAuthUsageRateLimitGate.resetForTesting() } + + let now = Date(timeIntervalSince1970: 1_700_000_000) + let tokenA = "linux-account-a" + let tokenB = "linux-account-b" + let keyA = ClaudeOAuthUsageRateLimitGate.storageKeyForTesting(accessToken: tokenA) + let keyB = ClaudeOAuthUsageRateLimitGate.storageKeyForTesting(accessToken: tokenB) + + ClaudeOAuthUsageRateLimitGate.recordRateLimit( + accessToken: tokenA, + retryAfter: now.addingTimeInterval(120), + now: now) + + #expect(keyA != keyB) + #expect(!keyA.contains(tokenA)) + #expect(ClaudeOAuthUsageRateLimitGate.currentBlockedUntil(accessToken: tokenA, now: now) != nil) + #expect(ClaudeOAuthUsageRateLimitGate.currentBlockedUntil(accessToken: tokenB, now: now) == nil) + } +} +#endif diff --git a/TestsLinux/ClinePassProviderLinuxTests.swift b/TestsLinux/ClinePassProviderLinuxTests.swift new file mode 100644 index 0000000000..53b982ba83 --- /dev/null +++ b/TestsLinux/ClinePassProviderLinuxTests.swift @@ -0,0 +1,223 @@ +import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif +import Testing +@testable import CodexBarCLI +@testable import CodexBarCore + +struct ClinePassProviderLinuxTests { + @Test + func `parses all rate windows`() throws { + let body = #""" + { + "data": { + "limits": [ + { + "type": "five_hour", + "percentUsed": 12.5, + "resetsAt": "2026-07-16T10:20:30Z" + }, + { + "type": "weekly", + "percentUsed": 34, + "resetsAt": "2026-07-20T00:00:00Z" + }, + { + "type": "monthly", + "percentUsed": 56.75, + "resetsAt": "2026-08-01T00:00:00Z" + } + ] + }, + "success": true + } + """# + let updatedAt = Date(timeIntervalSince1970: 123) + + let snapshot = try ClinePassUsageFetcher._parseSnapshotForTesting( + Data(body.utf8), + updatedAt: updatedAt) + let usage = snapshot.toUsageSnapshot() + + #expect(usage.primary?.usedPercent == 12.5) + #expect(usage.primary?.windowMinutes == 5 * 60) + #expect(usage.primary?.resetsAt == Self.date("2026-07-16T10:20:30Z")) + #expect(usage.secondary?.usedPercent == 34) + #expect(usage.secondary?.windowMinutes == 7 * 24 * 60) + #expect(usage.secondary?.resetsAt == Self.date("2026-07-20T00:00:00Z")) + #expect(usage.tertiary?.usedPercent == 56.75) + #expect(usage.tertiary?.windowMinutes == 30 * 24 * 60) + #expect(usage.tertiary?.resetsAt == Self.date("2026-08-01T00:00:00Z")) + #expect(usage.updatedAt == updatedAt) + #expect(usage.identity?.providerID == .clinepass) + #expect(usage.identity?.loginMethod == "API key") + } + + @Test + func `leaves missing rate windows nil`() throws { + let body = #""" + { + "data": { + "limits": [ + { + "type": "weekly", + "percentUsed": 40 + } + ] + }, + "success": true + } + """# + + let snapshot = try ClinePassUsageFetcher._parseSnapshotForTesting(Data(body.utf8)) + + #expect(snapshot.primary == nil) + #expect(snapshot.secondary?.usedPercent == 40) + #expect(snapshot.secondary?.resetsAt == nil) + #expect(snapshot.tertiary == nil) + } + + @Test + func `rejects malformed payload`() { + let body = #""" + { + "data": { + "limits": [ + { + "type": "weekly", + "percentUsed": "forty" + } + ] + }, + "success": true + } + """# + + #expect { + _ = try ClinePassUsageFetcher._parseSnapshotForTesting(Data(body.utf8)) + } throws: { error in + guard case ClinePassUsageError.parseFailed = error else { return false } + return true + } + } + + @Test + func `reads both environment keys and config override`() { + #expect(ClinePassSettingsReader.apiKey(environment: [ + ClinePassSettingsReader.apiKeyEnvironmentKey: " primary ", + ClinePassSettingsReader.alternateAPIKeyEnvironmentKey: "alternate", + ]) == "primary") + #expect(ClinePassSettingsReader.apiKey(environment: [ + ClinePassSettingsReader.alternateAPIKeyEnvironmentKey: " alternate ", + ]) == "alternate") + + let config = ProviderConfig(id: .clinepass, apiKey: "config-key") + let environment = ProviderConfigEnvironment.applyAPIKeyOverride( + base: [:], + provider: .clinepass, + config: config) + + #expect(environment[ClinePassSettingsReader.apiKeyEnvironmentKey] == "config-key") + #expect(ClinePassSettingsReader.apiKey(environment: environment) == "config-key") + #expect(ProviderConfigEnvironment.supportsAPIKeyOverride(for: .clinepass)) + } + + @Test + func `registers descriptor and CLI selection`() throws { + let descriptor = ProviderDescriptorRegistry.descriptor(for: .clinepass) + let selection = try #require(ProviderSelection(argument: "clinepass")) + + #expect(descriptor.metadata.displayName == "ClinePass") + #expect(descriptor.cli.name == "clinepass") + #expect(descriptor.fetchPlan.sourceModes == [.auto, .api]) + #expect(ProviderDescriptorRegistry.cliNameMap["clinepass"] == .clinepass) + #expect(selection.asList == [.clinepass]) + #expect(ProviderHelp.list.split(separator: "|").contains("clinepass")) + } + + @Test + func `fetches usage with bearer authentication`() async throws { + let body = #""" + { + "data": { + "limits": [ + { + "type": "five_hour", + "percentUsed": 25 + } + ] + }, + "success": true + } + """# + let transport = ProviderHTTPTransportHandler { request in + #expect(request.url?.absoluteString == "https://api.cline.bot/api/v1/users/me/plan/usage-limits") + #expect(request.httpMethod == "GET") + #expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer test-key") + #expect(request.value(forHTTPHeaderField: "Accept") == "application/json") + #expect(request.timeoutInterval == 15) + return try Self.response(for: request, body: body, statusCode: 200) + } + + let snapshot = try await ClinePassUsageFetcher._fetchUsage( + apiKey: " test-key ", + transport: transport, + now: Date(timeIntervalSince1970: 456)) + + #expect(snapshot.primary?.usedPercent == 25) + #expect(snapshot.updatedAt == Date(timeIntervalSince1970: 456)) + } + + @Test + func `requires authentication and rejects unauthorized response`() async { + let unusedTransport = ProviderHTTPTransportHandler { _ in + Issue.record("Transport should not be called without an API key") + throw URLError(.userAuthenticationRequired) + } + + do { + _ = try await ClinePassUsageFetcher._fetchUsage(apiKey: " ", transport: unusedTransport) + Issue.record("Expected missing credentials") + } catch let error as ClinePassUsageError { + #expect(error == .missingCredentials) + } catch { + Issue.record("Unexpected error: \(error)") + } + + let unauthorizedTransport = ProviderHTTPTransportHandler { request in + try Self.response(for: request, body: "{}", statusCode: 401) + } + do { + _ = try await ClinePassUsageFetcher._fetchUsage( + apiKey: "test-key", + transport: unauthorizedTransport) + Issue.record("Expected unauthorized error") + } catch let error as ClinePassUsageError { + #expect(error == .unauthorized) + } catch { + Issue.record("Unexpected error: \(error)") + } + } + + private static func date(_ raw: String) -> Date? { + ISO8601DateFormatter().date(from: raw) + } + + private static func response( + for request: URLRequest, + body: String, + statusCode: Int) throws -> (Data, URLResponse) + { + guard let url = request.url, + let response = HTTPURLResponse( + url: url, + statusCode: statusCode, + httpVersion: "HTTP/1.1", + headerFields: ["Content-Type": "application/json"]) + else { + throw URLError(.badServerResponse) + } + return (Data(body.utf8), response) + } +} diff --git a/TestsLinux/CodexBarLoggingPerformanceTests.swift b/TestsLinux/CodexBarLoggingPerformanceTests.swift new file mode 100644 index 0000000000..1b2e202b9e --- /dev/null +++ b/TestsLinux/CodexBarLoggingPerformanceTests.swift @@ -0,0 +1,82 @@ +import Logging +import Testing +@testable import CodexBarCore + +@Suite(.serialized) +struct CodexBarLoggingPerformanceTests { + @Test + func `filtered log messages are not evaluated`() { + let probe = LogEvaluationProbe() + let logger = CodexBarLogger(minimumLevel: .info) { _, message, _ in + probe.loggedMessages.append(message) + } + + logger.debug(probe.expensiveMessage()) + + #expect(probe.evaluations == 0) + #expect(probe.loggedMessages.isEmpty) + + logger.info(probe.expensiveMessage()) + + #expect(probe.evaluations == 1) + #expect(probe.loggedMessages == ["evaluated"]) + } + + @Test + func `disabled file logging does not format metadata`() { + let sink = FileLogSink() + var handler = FileLogHandler(label: "test", sink: sink) + let probe = LogEvaluationProbe() + handler[metadataKey: "expensive"] = .stringConvertible(ExpensiveMetadataValue { + probe.evaluations += 1 + }) + + handler.log(event: LogEvent( + level: .info, + message: "hello", + metadata: nil, + source: "test", + file: #filePath, + function: #function, + line: #line)) + + #expect(probe.evaluations == 0) + } + + @Test + func `redactor leaves ordinary log lines unchanged`() { + let line = "CodexBar starting version=1.2.3 build=456" + + #expect(LogRedactor.redact(line) == line) + } + + @Test + func `redactor still redacts sensitive log lines`() { + let line = "Authorization: Bearer secret-token\nContact: user@example.com" + let redacted = LogRedactor.redact(line) + + #expect(redacted.contains("secret-token") == false) + #expect(redacted.contains("user@example.com") == false) + #expect(redacted.contains("Authorization: ")) + #expect(redacted.contains("")) + } +} + +private final class LogEvaluationProbe: @unchecked Sendable { + var evaluations = 0 + var loggedMessages: [String] = [] + + func expensiveMessage() -> String { + self.evaluations += 1 + return "evaluated" + } +} + +private struct ExpensiveMetadataValue: CustomStringConvertible, Sendable { + let onRender: @Sendable () -> Void + + var description: String { + self.onRender() + return "rendered" + } +} diff --git a/TestsLinux/CodexOAuthCredentialsStoreLinuxTests.swift b/TestsLinux/CodexOAuthCredentialsStoreLinuxTests.swift new file mode 100644 index 0000000000..01853c49f1 --- /dev/null +++ b/TestsLinux/CodexOAuthCredentialsStoreLinuxTests.swift @@ -0,0 +1,31 @@ +import Foundation +import Testing +@testable import CodexBarCore + +@Suite +struct CodexOAuthCredentialsStoreLinuxTests { + @Test + func saveKeepsAuthJSONPrivate() throws { + #if os(macOS) || os(Linux) + let codexHome = FileManager.default.temporaryDirectory + .appendingPathComponent("codex-oauth-permissions-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: codexHome) } + + let credentials = CodexOAuthCredentials( + accessToken: "access-token", + refreshToken: "refresh-token", + idToken: "id-token", + accountId: "account-123", + lastRefresh: Date()) + + try CodexOAuthCredentialsStore.save(credentials, env: ["CODEX_HOME": codexHome.path]) + + let authURL = codexHome.appendingPathComponent("auth.json") + let attributes = try FileManager.default.attributesOfItem(atPath: authURL.path) + let permissions = try #require(attributes[.posixPermissions] as? NSNumber) + #expect(permissions.intValue & 0o777 == 0o600) + #else + #expect(Bool(true)) + #endif + } +} diff --git a/TestsLinux/CostUsageScanExecutorLinuxTests.swift b/TestsLinux/CostUsageScanExecutorLinuxTests.swift new file mode 100644 index 0000000000..e286e535c1 --- /dev/null +++ b/TestsLinux/CostUsageScanExecutorLinuxTests.swift @@ -0,0 +1,29 @@ +import CodexBarCore +import Foundation +import Testing + +@Suite +struct CostUsageScanExecutorLinuxTests { + @Test + func returnsWorkValue() async throws { + let value = try await CostUsageScanExecutor.run { _ in 42 } + #expect(value == 42) + } + + @Test + func cancelledTaskThrowsCancellationError() async { + let task = Task { + try await CostUsageScanExecutor.run { checkCancellation in + while true { + try checkCancellation() + Thread.sleep(forTimeInterval: 0.005) + } + } + } + task.cancel() + + await #expect(throws: CancellationError.self) { + try await task.value + } + } +} diff --git a/TestsLinux/CrofCreditsOnlyLinuxTests.swift b/TestsLinux/CrofCreditsOnlyLinuxTests.swift new file mode 100644 index 0000000000..f447dda461 --- /dev/null +++ b/TestsLinux/CrofCreditsOnlyLinuxTests.swift @@ -0,0 +1,63 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct CrofCreditsOnlyLinuxTests { + @Test + func `credits-only API payload maps balance as primary without request quota`() throws { + let json = """ + { + "credits":9.0441, + "requests_plan":null, + "usable_requests":null, + "usage":{ + "deepseek-v4-flash":{ + "cached_tokens":0, + "input_tokens":23, + "output_tokens":132, + "total_tokens":155 + } + } + } + """ + + let snapshot = try CrofUsageFetcher._parseSnapshotForTesting(Data(json.utf8)) + let usage = snapshot.toUsageSnapshot() + + #expect(snapshot.credits == 9.0441) + #expect(snapshot.requestsPlan == nil) + #expect(snapshot.usableRequests == nil) + #expect(usage.primary?.usedPercent == 0) + #expect(usage.primary?.windowMinutes == nil) + #expect(usage.primary?.resetsAt == nil) + #expect(usage.primary?.resetDescription == "$9.04") + #expect(usage.secondary == nil) + #expect(CrofProviderDescriptor.primaryLabel(snapshot: usage) == "Credits") + } + + @Test + func `zero credit balance is exhausted without inventing a reset window`() { + let usage = CrofUsageSnapshot(credits: 0).toUsageSnapshot() + + #expect(usage.primary?.usedPercent == 100) + #expect(usage.primary?.windowMinutes == nil) + #expect(usage.primary?.resetsAt == nil) + #expect(usage.primary?.resetDescription == "$0.00") + #expect(usage.secondary == nil) + #expect(CrofProviderDescriptor.primaryLabel(snapshot: usage) == "Credits") + } + + @Test + func `optional request quota still preferred when present`() { + let usage = CrofUsageSnapshot( + credits: 10, + requestsPlan: 1000, + usableRequests: 998, + updatedAt: Date(timeIntervalSince1970: 1_777_800_000)).toUsageSnapshot() + + #expect(usage.primary?.windowMinutes == 1440) + #expect(usage.primary?.resetDescription == "998 requests left") + #expect(usage.secondary?.resetDescription == "$10.00") + #expect(CrofProviderDescriptor.primaryLabel(snapshot: usage) != "Credits") + } +} diff --git a/TestsLinux/CursorLinuxTests.swift b/TestsLinux/CursorLinuxTests.swift new file mode 100644 index 0000000000..f78acb1ba5 --- /dev/null +++ b/TestsLinux/CursorLinuxTests.swift @@ -0,0 +1,66 @@ +#if os(Linux) +import Foundation +import Testing +@testable import CodexBarCLI +@testable import CodexBarCore + +struct CursorLinuxTests { + @Test + func `Cursor database path honors absolute XDG config home`() { + let path = CursorAppAuthStore.resolveDefaultDBPath( + home: "/home/test", + environment: ["XDG_CONFIG_HOME": "/custom/config"]) + #expect(path == "/custom/config/Cursor/User/globalStorage/state.vscdb") + } + + @Test + func `Cursor database path falls back to dot config`() { + let path = CursorAppAuthStore.resolveDefaultDBPath( + home: "/home/test", + environment: [:]) + #expect(path == "/home/test/.config/Cursor/User/globalStorage/state.vscdb") + } + + @Test + func `Cursor database path rejects relative XDG config home`() { + let path = CursorAppAuthStore.resolveDefaultDBPath( + home: "/home/test", + environment: ["XDG_CONFIG_HOME": "relative/config"]) + #expect(path == "/home/test/.config/Cursor/User/globalStorage/state.vscdb") + } + + @Test + func `Cursor automatic source does not require macOS web support`() { + #expect(!CodexBarCLI.sourceModeRequiresWebSupport( + .auto, + provider: .cursor, + settings: ProviderSettingsSnapshot.make( + cursor: .init(cookieSource: .auto, manualCookieHeader: nil)))) + } + + @Test + func `Cursor descriptor accepts explicit web source`() { + #expect(CursorProviderDescriptor.descriptor.fetchPlan.sourceModes.contains(.web)) + } + + @Test + func `Cursor manual cookie does not require macOS web support`() { + #expect(!CodexBarCLI.sourceModeRequiresWebSupport( + .web, + provider: .cursor, + settings: ProviderSettingsSnapshot.make( + cursor: .init( + cookieSource: .manual, + manualCookieHeader: "WorkosCursorSessionToken=test")))) + } + + @Test + func `disabled Cursor web source still requires macOS web support`() { + #expect(CodexBarCLI.sourceModeRequiresWebSupport( + .web, + provider: .cursor, + settings: ProviderSettingsSnapshot.make( + cursor: .init(cookieSource: .off, manualCookieHeader: nil)))) + } +} +#endif diff --git a/TestsLinux/ElevenLabsUsageSnapshotLinuxTests.swift b/TestsLinux/ElevenLabsUsageSnapshotLinuxTests.swift new file mode 100644 index 0000000000..b0ab203440 --- /dev/null +++ b/TestsLinux/ElevenLabsUsageSnapshotLinuxTests.swift @@ -0,0 +1,53 @@ +#if os(Linux) +import Foundation +import Testing +@testable import CodexBarCore + +struct ElevenLabsUsageSnapshotLinuxTests { + private func snapshot( + characterCount: Int, + characterLimit: Int, + voiceSlotsUsed: Int? = nil, + voiceLimit: Int? = nil) -> ElevenLabsUsageSnapshot + { + ElevenLabsUsageSnapshot( + tier: "creator", + characterCount: characterCount, + characterLimit: characterLimit, + voiceSlotsUsed: voiceSlotsUsed, + professionalVoiceSlotsUsed: nil, + voiceLimit: voiceLimit, + professionalVoiceLimit: nil, + currentOverage: nil, + status: "active", + resetsAt: nil, + updatedAt: Date(timeIntervalSince1970: 0)) + } + + @Test + func `in-range character usage maps to its percent`() { + let usage = self.snapshot(characterCount: 25000, characterLimit: 100_000).toUsageSnapshot() + #expect(abs((usage.primary?.usedPercent ?? 0) - 25) < 0.01) + } + + @Test + func `character overage clamps used percent to 100`() { + // ElevenLabs models overage explicitly (currentOverage), so characterCount > characterLimit + // is a real state. The percent must cap at 100 like sibling credit providers instead of + // flowing 150 into RateWindow.usedPercent (which does not clamp). + let usage = self.snapshot(characterCount: 150_000, characterLimit: 100_000).toUsageSnapshot() + #expect(usage.primary?.usedPercent == 100) + } + + @Test + func `voice slot overage clamps used percent to 100`() { + let usage = self.snapshot( + characterCount: 0, + characterLimit: 100_000, + voiceSlotsUsed: 12, + voiceLimit: 10).toUsageSnapshot() + let voice = usage.extraRateWindows?.first { $0.id == "voice-slots" } + #expect(voice?.window.usedPercent == 100) + } +} +#endif diff --git a/TestsLinux/HookDispatchTests.swift b/TestsLinux/HookDispatchTests.swift new file mode 100644 index 0000000000..4626594bc2 --- /dev/null +++ b/TestsLinux/HookDispatchTests.swift @@ -0,0 +1,141 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct HookDispatchTests { + private func event( + _ type: HookEventType = .quotaReached, + provider: String = "codex", + usagePercent: Double? = 0.95, + window: String? = "session") -> HookEvent + { + HookEvent( + event: type, + provider: provider, + window: window, + usagePercent: usagePercent, + resetAt: Date(timeIntervalSince1970: 1_700_000_000), + timestamp: Date(timeIntervalSince1970: 1_700_000_100)) + } + + @Test + func `invalid timeout threshold and provider fail closed`() { + let event = self.event(.quotaLow, provider: "codex", usagePercent: 0.95) + #expect(!HookRule( + event: .quotaLow, + threshold: 1.1, + executable: "/bin/echo").matches(event)) + #expect(!HookRule( + event: .quotaLow, + threshold: 0, + executable: "/bin/echo").matches(event)) + #expect(!HookRule( + event: .quotaLow, + provider: "unknown", + executable: "/bin/echo").matches(event)) + #expect(!HookRule( + event: .quotaLow, + executable: "/bin/echo", + timeoutSeconds: 0).matches(event)) + #expect(!HookRule( + event: .quotaReached, + executable: "/bin/echo", + arguments: Array(repeating: "x", count: HookRule.maximumArgumentCount + 1)).matches(event)) + let tooManyRules = HooksConfig( + enabled: true, + events: Array( + repeating: HookRule(event: .quotaReached, executable: "/bin/echo"), + count: HooksConfig.maximumRuleCount + 1)) + #expect(tooManyRules.matchingRules(for: event).isEmpty) + } + + @Test + func `runner writes the complete JSON payload to stdin`() async throws { + let original = self.event(.quotaReached, provider: "claude", usagePercent: 0.42, window: "session") + let result = try await HookRunner.run( + rule: HookRule(event: .quotaReached, executable: "/bin/cat"), + event: original) + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + let decoded = try decoder.decode(HookEvent.self, from: Data(result.stdout.utf8)) + + #expect(decoded == original) + #expect(result.stdout == + "{\"event\":\"quota_reached\",\"provider\":\"claude\",\"resetAt\":\"2023-11-14T22:13:20Z\"," + + "\"timestamp\":\"2023-11-14T22:15:00Z\",\"usagePercent\":0.42,\"window\":\"session\"}") + } + + @Test + func `runner rejects payloads above the pipe-safe limit`() async { + let oversized = HookEvent( + event: .quotaReached, + provider: "codex", + account: String(repeating: "x", count: HookRunner.maximumPayloadBytes), + timestamp: Date()) + + await #expect(throws: HookRunnerError.self) { + try await HookRunner.run( + rule: HookRule(event: .quotaReached, executable: "/bin/cat"), + event: oversized) + } + } + + @Test + func `runner preserves whitespace and empty argument boundaries`() async throws { + let rule = HookRule( + event: .quotaReached, + executable: "/usr/bin/printf", + arguments: ["<%s>|<%s>|<%s>", "quota reached", "", "tail"]) + let result = try await HookRunner.run(rule: rule, event: self.event()) + + #expect(result.stdout == "|<>|") + } + + @Test + func `dispatch coalesces repeated refresh failures`() async throws { + let output = FileManager.default.temporaryDirectory + .appendingPathComponent("codexbar-hook-rate-limit-\(UUID().uuidString).json") + defer { try? FileManager.default.removeItem(at: output) } + let event = self.event(.refreshFailed, usagePercent: nil, window: nil) + let config = HooksConfig(enabled: true, events: [ + HookRule(event: .refreshFailed, executable: "/usr/bin/tee", arguments: ["-a", output.path]), + ]) + let limiter = HookRateLimiter(window: 600) + + await HookRunner.dispatch(event: event, config: config, rateLimiter: limiter) + await HookRunner.dispatch(event: event, config: config, rateLimiter: limiter) + let contents = try String(contentsOf: output, encoding: .utf8) + + #expect(contents.components(separatedBy: "\"event\":\"refresh_failed\"").count - 1 == 1) + } + + @Test + func `dispatch contains one rule failure and continues`() async { + let output = FileManager.default.temporaryDirectory + .appendingPathComponent("codexbar-hook-failure-\(UUID().uuidString).json") + defer { try? FileManager.default.removeItem(at: output) } + let event = self.event() + let config = HooksConfig(enabled: true, events: [ + HookRule(event: .quotaReached, executable: "/nonexistent/codexbar-hook"), + HookRule(event: .quotaReached, executable: "/usr/bin/tee", arguments: [output.path]), + ]) + + await HookRunner.dispatch(event: event, config: config, rateLimiter: HookRateLimiter()) + + #expect(FileManager.default.fileExists(atPath: output.path)) + } + + @Test + func `disabled dispatch never invokes a rule`() async { + let output = FileManager.default.temporaryDirectory + .appendingPathComponent("codexbar-hook-disabled-\(UUID().uuidString).json") + defer { try? FileManager.default.removeItem(at: output) } + let config = HooksConfig(enabled: false, events: [ + HookRule(event: .quotaReached, executable: "/usr/bin/tee", arguments: [output.path]), + ]) + + await HookRunner.dispatch(event: self.event(), config: config, rateLimiter: HookRateLimiter()) + + #expect(!FileManager.default.fileExists(atPath: output.path)) + } +} diff --git a/TestsLinux/HooksTests.swift b/TestsLinux/HooksTests.swift new file mode 100644 index 0000000000..739bfb7eaf --- /dev/null +++ b/TestsLinux/HooksTests.swift @@ -0,0 +1,193 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct HooksTests { + private func event( + _ type: HookEventType = .quotaReached, + provider: String = "codex", + usagePercent: Double? = 0.95, + account: String? = nil, + window: String? = "session") -> HookEvent + { + HookEvent( + event: type, + provider: provider, + account: account, + window: window, + usagePercent: usagePercent, + resetAt: Date(timeIntervalSince1970: 1_700_000_000), + timestamp: Date(timeIntervalSince1970: 1_700_000_100)) + } + + // MARK: - Matching + + @Test + func `rule matches on event and provider`() { + let rule = HookRule(event: .quotaReached, provider: "codex", executable: "/bin/echo") + #expect(rule.matches(self.event(.quotaReached, provider: "codex"))) + #expect(!rule.matches(self.event(.quotaReached, provider: "claude"))) + #expect(!rule.matches(self.event(.quotaLow, provider: "codex"))) + } + + @Test + func `nil provider matches any provider`() { + let rule = HookRule(event: .quotaReached, provider: nil, executable: "/bin/echo") + #expect(rule.matches(self.event(.quotaReached, provider: "codex"))) + #expect(rule.matches(self.event(.quotaReached, provider: "claude"))) + } + + @Test + func `quotaLow threshold gates on usage percent`() { + let rule = HookRule(event: .quotaLow, threshold: 0.90, executable: "/bin/echo") + #expect(rule.matches(self.event(.quotaLow, usagePercent: 0.92))) + #expect(rule.matches(self.event(.quotaLow, usagePercent: 0.90))) + #expect(!rule.matches(self.event(.quotaLow, usagePercent: 0.80))) + #expect(!rule.matches(self.event(.quotaLow, usagePercent: nil))) + } + + @Test + func `disabled rule and relative path never match`() { + let disabled = HookRule(enabled: false, event: .quotaReached, executable: "/bin/echo") + #expect(!disabled.matches(self.event())) + + let relative = HookRule(event: .quotaReached, executable: "my-command") + #expect(!relative.matches(self.event())) + } + + @Test + func `disabled config yields no matching rules`() { + let rule = HookRule(event: .quotaReached, executable: "/bin/echo") + let enabled = HooksConfig(enabled: true, events: [rule]) + let disabled = HooksConfig(enabled: false, events: [rule]) + #expect(enabled.matchingRules(for: self.event()).count == 1) + #expect(disabled.matchingRules(for: self.event()).isEmpty) + } + + // MARK: - quota_low threshold crossing + + @Test + func `quotaLow rule fires only when its own threshold is crossed upward`() { + let rule = HookRule(event: .quotaLow, threshold: 0.90, executable: "/bin/echo") + // Notification thresholds (50/20 remaining => 0.50/0.80 usage) would not fire + // a 0.90 rule; the rule's own threshold must drive it. + let fallback = [0.50, 0.80] + + // Crossing 0.90 upward fires it. + #expect(!QuotaLowHookThreshold.crossedRules( + [rule], previousUsage: 0.85, currentUsage: 0.95, fallbackThresholds: fallback).isEmpty) + // Already above, no new crossing. + #expect(QuotaLowHookThreshold.crossedRules( + [rule], previousUsage: 0.92, currentUsage: 0.97, fallbackThresholds: fallback).isEmpty) + // Below threshold, no fire even though notification thresholds were crossed. + #expect(QuotaLowHookThreshold.crossedRules( + [rule], previousUsage: 0.40, currentUsage: 0.85, fallbackThresholds: fallback).isEmpty) + } + + @Test + func `thresholdless quotaLow rule falls back to notification thresholds`() { + let rule = HookRule(event: .quotaLow, threshold: nil, executable: "/bin/echo") + let fallback = [0.50, 0.80] + #expect(!QuotaLowHookThreshold.crossedRules( + [rule], previousUsage: 0.40, currentUsage: 0.55, fallbackThresholds: fallback).isEmpty) + #expect(QuotaLowHookThreshold.crossedRules( + [rule], previousUsage: 0.55, currentUsage: 0.60, fallbackThresholds: fallback).isEmpty) + } + + @Test + func `only the crossed rule is selected among several`() { + let low = HookRule(id: "low", event: .quotaLow, threshold: 0.50, executable: "/bin/echo") + let high = HookRule(id: "high", event: .quotaLow, threshold: 0.90, executable: "/bin/echo") + // Usage rises past 0.90; 0.50 already fired earlier so must not re-fire. + let crossed = QuotaLowHookThreshold.crossedRules( + [low, high], previousUsage: 0.85, currentUsage: 0.95, fallbackThresholds: []) + #expect(crossed.map(\.id) == ["high"]) + } + + // MARK: - Payload + + @Test + func `environment variables include set fields and omit nil`() { + let env = self.event(.quotaLow, usagePercent: 0.5, account: nil, window: "weekly") + .environmentVariables() + #expect(env["CODEXBAR_EVENT"] == "quota_low") + #expect(env["CODEXBAR_PROVIDER"] == "codex") + #expect(env["CODEXBAR_WINDOW"] == "weekly") + #expect(env["CODEXBAR_USAGE_PERCENT"] == "0.5") + #expect(env["CODEXBAR_RESET_AT"] == "2023-11-14T22:13:20Z") + #expect(env["CODEXBAR_TIMESTAMP"] != nil) + #expect(env["CODEXBAR_ACCOUNT"] == nil) + #expect(env["CODEXBAR_STATUS"] == nil) + } + + @Test + func `json payload round-trips`() throws { + let original = self.event(.quotaReached, provider: "claude", usagePercent: 0.42, window: "session") + let data = try original.jsonPayload() + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + let decoded = try decoder.decode(HookEvent.self, from: data) + #expect(decoded.event == .quotaReached) + #expect(decoded.provider == "claude") + #expect(decoded.usagePercent == 0.42) + #expect(decoded.window == "session") + } + + // MARK: - Rate limiter + + @Test + func `rate limiter suppresses same key within window`() async { + let limiter = HookRateLimiter(window: 600) + let base = Date(timeIntervalSince1970: 1_000_000) + #expect(await limiter.allow(self.event(), now: base)) + #expect(await !limiter.allow(self.event(), now: base.addingTimeInterval(300))) + #expect(await limiter.allow(self.event(), now: base.addingTimeInterval(601))) + } + + @Test + func `rate limiter treats distinct keys independently`() async { + let limiter = HookRateLimiter(window: 600) + let now = Date(timeIntervalSince1970: 1_000_000) + #expect(await limiter.allow(self.event(provider: "codex"), now: now)) + #expect(await limiter.allow(self.event(provider: "claude"), now: now)) + } + + // MARK: - Runner + + @Test + func `only storm-prone events are rate limited`() { + #expect(HookEventType.refreshFailed.isRateLimited) + #expect(HookEventType.providerUnavailable.isRateLimited) + #expect(!HookEventType.quotaLow.isRateLimited) + #expect(!HookEventType.quotaReached.isRateLimited) + #expect(!HookEventType.quotaReset.isRateLimited) + #expect(!HookEventType.providerRecovered.isRateLimited) + } + + @Test + func `runner executes command and passes event environment`() async throws { + // /usr/bin/env prints the environment; assert our injected vars reach the child. + let rule = HookRule(event: .quotaReached, executable: "/usr/bin/env") + let result = try await HookRunner.run(rule: rule, event: self.event()) + #expect(result.stdout.contains("CODEXBAR_EVENT=quota_reached")) + #expect(result.stdout.contains("CODEXBAR_PROVIDER=codex")) + } + + @Test + func `runner does not forward secrets from the base environment`() async throws { + let rule = HookRule(event: .quotaReached, executable: "/usr/bin/env") + let base = ["PATH": "/usr/bin:/bin", "UNRELATED_VARIABLE": "sensitive-fixture"] + let result = try await HookRunner.run(rule: rule, event: self.event(), baseEnvironment: base) + #expect(result.stdout.contains("PATH=/usr/bin:/bin")) // allowlisted variable forwarded + #expect(!result.stdout.contains("sensitive-fixture")) // non-allowlisted value dropped + #expect(!result.stdout.contains("UNRELATED_VARIABLE")) + } + + @Test + func `runner throws on missing executable`() async { + let rule = HookRule(event: .quotaReached, executable: "/nonexistent/codexbar-hook") + await #expect(throws: SubprocessRunnerError.self) { + try await HookRunner.run(rule: rule, event: self.event()) + } + } +} diff --git a/TestsLinux/LLMProxyResetLinuxTests.swift b/TestsLinux/LLMProxyResetLinuxTests.swift new file mode 100644 index 0000000000..0307eee93b --- /dev/null +++ b/TestsLinux/LLMProxyResetLinuxTests.swift @@ -0,0 +1,53 @@ +#if os(Linux) +import CodexBarCore +import Foundation +import Testing + +struct LLMProxyResetLinuxTests { + // 2023-11-14T22:13:20Z — the snapshot time treated as "now". + private static let now = Date(timeIntervalSince1970: 1_700_000_000) + + private func nextReset(resetTimes: [String]) throws -> Date? { + let groups = resetTimes + .map { "{ \"remaining_percent\": 50, \"reset_time\": \"\($0)\" }" } + .joined(separator: ", ") + let json = "{ \"providers\": { \"p\": { \"quota_groups\": [ \(groups) ] } } }" + return try LLMProxyUsageFetcher + ._parseSnapshotForTesting(Data(json.utf8), updatedAt: Self.now) + .nextResetAt + } + + private func date(_ year: Int, _ month: Int, _ day: Int) throws -> Date { + try #require(DateComponents( + calendar: Calendar(identifier: .gregorian), + timeZone: TimeZone(secondsFromGMT: 0), + year: year, month: month, day: day, hour: 0, minute: 0, second: 0).date) + } + + @Test + func `next reset skips already-elapsed reset times`() throws { + // A past reset (stale until the API refreshes) must not be chosen over the soonest upcoming one. + let reset = try self.nextReset(resetTimes: [ + "2023-11-01T00:00:00Z", // past (before now) + "2023-11-20T00:00:00Z", // soonest future + "2023-12-25T00:00:00Z", // later future + ]) + #expect(try abs(#require(reset).timeIntervalSince(self.date(2023, 11, 20))) < 0.001) + } + + @Test + func `all-past reset times yield no next reset`() throws { + let reset = try self.nextReset(resetTimes: [ + "2023-11-01T00:00:00Z", + "2023-10-15T00:00:00Z", + ]) + #expect(reset == nil) + } + + @Test + func `future reset time is preserved`() throws { + let reset = try self.nextReset(resetTimes: ["2023-11-20T00:00:00Z"]) + #expect(try abs(#require(reset).timeIntervalSince(self.date(2023, 11, 20))) < 0.001) + } +} +#endif diff --git a/TestsLinux/MiniMaxLinuxTests.swift b/TestsLinux/MiniMaxLinuxTests.swift new file mode 100644 index 0000000000..610258cc09 --- /dev/null +++ b/TestsLinux/MiniMaxLinuxTests.swift @@ -0,0 +1,74 @@ +#if os(Linux) +import Foundation +import Testing +@testable import CodexBarCLI +@testable import CodexBarCore + +struct MiniMaxLinuxTests { + @Test + func `coding plan API key does not require macOS web support`() { + // A coding-plan key resolves to the plain HTTPS + Bearer API strategy, so it must be + // usable off macOS (matches the Factory/Kimi credential exemptions). + #expect(!CodexBarCLI.sourceModeRequiresWebSupport( + .auto, + provider: .minimax, + environment: [MiniMaxAPISettingsReader.codingPlanAPITokenKey: "sk-cp-test"])) + } + + @Test + func `standard API key still requires web support because Auto resolves to the coding plan page`() { + // `MiniMaxAPIFetchStrategy` refuses standard `sk-api-` keys, so Auto falls back to the + // Coding Plan web strategy. Exempting it here would only produce `noAvailableStrategy`. + #expect(CodexBarCLI.sourceModeRequiresWebSupport( + .auto, + provider: .minimax, + environment: [MiniMaxAPISettingsReader.apiTokenKey: "sk-api-test"])) + } + + @Test + func `auto without an API key still requires web support off macOS`() { + // Without a credential the only remaining Auto path is the web/cookie one, which + // genuinely needs macOS — the gate must still fire. + #expect(CodexBarCLI.sourceModeRequiresWebSupport( + .auto, + provider: .minimax, + environment: [:])) + } + + @Test + func `explicit web source still requires web support even with an API key`() { + // The exemption is scoped to Auto; asking for the web source explicitly must not + // be silently redirected to the API path. + #expect(CodexBarCLI.sourceModeRequiresWebSupport( + .web, + provider: .minimax, + environment: [MiniMaxAPISettingsReader.codingPlanAPITokenKey: "sk-cp-test"])) + } + + @Test + func `exempted coding plan key actually resolves a linux capable strategy`() async { + // Gate agreement is not enough: the Auto plan must contain a strategy that can run + // without the macOS web path, otherwise the exemption ends in `noAvailableStrategy`. + let env = [MiniMaxAPISettingsReader.codingPlanAPITokenKey: "sk-cp-test"] + let browserDetection = BrowserDetection(cacheTTL: 0) + let context = ProviderFetchContext( + runtime: .cli, + sourceMode: .auto, + includeCredits: false, + webTimeout: 1, + webDebugDumpHTML: false, + verbose: false, + env: env, + settings: ProviderSettingsSnapshot.make(), + fetcher: UsageFetcher(environment: env), + claudeFetcher: ClaudeUsageFetcher(browserDetection: browserDetection), + browserDetection: browserDetection) + let strategies = await ProviderDescriptorRegistry + .descriptor(for: .minimax) + .fetchPlan + .pipeline + .resolveStrategies(context) + #expect(strategies.contains { $0.id == "minimax.api" }) + } +} +#endif diff --git a/TestsLinux/OpenCodeGoLinuxTests.swift b/TestsLinux/OpenCodeGoLinuxTests.swift new file mode 100644 index 0000000000..dd85a8d50f --- /dev/null +++ b/TestsLinux/OpenCodeGoLinuxTests.swift @@ -0,0 +1,74 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBarCLI + +#if canImport(SQLite3) +import SQLite3 +#elseif canImport(CSQLite3) +import CSQLite3 +#endif + +#if canImport(SQLite3) || canImport(CSQLite3) +@Suite +struct OpenCodeGoLinuxTests { + @Test + func autoSourceDoesNotRequireWebSupport() { + #expect(!CodexBarCLI.sourceModeRequiresWebSupport(.auto, provider: .opencodego)) + #expect(CodexBarCLI.sourceModeRequiresWebSupport(.web, provider: .opencodego)) + } + + @Test + func commandCodeManualCookieDoesNotRequireMacOSWebSupport() { + let settings = ProviderSettingsSnapshot.make( + commandcode: .init(cookieSource: .manual, manualCookieHeader: "session=manual")) + + #expect(!CodexBarCLI.sourceModeRequiresWebSupport( + .auto, + provider: .commandcode, + settings: settings)) + #expect(!CodexBarCLI.sourceModeRequiresWebSupport( + .web, + provider: .commandcode, + settings: settings)) + } + + @Test + func localReaderLoadsOpenCodeDatabase() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("OpenCodeGoLinuxTests-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + + let databaseURL = root.appendingPathComponent("opencode.db") + let authURL = root.appendingPathComponent("auth.json") + let now = Date(timeIntervalSince1970: 1_800_000_000) + let createdMs = Int64((now.timeIntervalSince1970 - 60) * 1000) + try Self.createDatabase(at: databaseURL, createdMs: createdMs) + + let snapshot = try OpenCodeGoLocalUsageReader(authURL: authURL, databaseURL: databaseURL).fetch(now: now) + + #expect(snapshot.rollingUsagePercent == 50) + #expect(snapshot.weeklyUsagePercent == 20) + #expect(snapshot.monthlyUsagePercent == 10) + } + + private static func createDatabase(at url: URL, createdMs: Int64) throws { + var database: OpaquePointer? + guard sqlite3_open(url.path, &database) == SQLITE_OK else { + sqlite3_close(database) + throw OpenCodeGoLocalUsageError.sqliteFailed("open failed") + } + defer { sqlite3_close(database) } + + let data = "{\"time\":{\"created\":\(createdMs)},\"cost\":6,\"providerID\":\"opencode-go\",\"role\":\"assistant\"}" + let sql = """ + CREATE TABLE message (id TEXT PRIMARY KEY, time_created INTEGER NOT NULL, data TEXT NOT NULL); + INSERT INTO message (id, time_created, data) VALUES ('message-1', \(createdMs), '\(data)'); + """ + guard sqlite3_exec(database, sql, nil, nil, nil) == SQLITE_OK else { + throw OpenCodeGoLocalUsageError.sqliteFailed("fixture creation failed") + } + } +} +#endif diff --git a/TestsLinux/OpenCodeGoPercentUnitLinuxTests.swift b/TestsLinux/OpenCodeGoPercentUnitLinuxTests.swift new file mode 100644 index 0000000000..907e306681 --- /dev/null +++ b/TestsLinux/OpenCodeGoPercentUnitLinuxTests.swift @@ -0,0 +1,41 @@ +#if os(Linux) +import Foundation +import Testing +@testable import CodexBarCore + +struct OpenCodeGoPercentUnitLinuxTests { + private func rollingPercent(used: Int, limit: Int) throws -> Double { + let payload: [String: Any] = [ + "usage": ["rollingUsage": ["used": used, "limit": limit, "resetInSec": 600]], + ] + let text = try String(data: JSONSerialization.data(withJSONObject: payload), encoding: .utf8) ?? "" + return try OpenCodeGoUsageFetcher.parseSubscription(text: text, now: Date(timeIntervalSince1970: 0)) + .rollingUsagePercent + } + + @Test + func `sub-one-percent computed usage is not rescaled to 100`() throws { + // 1/100 = 1% used. The direct-fraction heuristic (<= 1 => * 100) must NOT touch a + // computed used/limit percent, which is already 0...100 — else 1.0 becomes 100. + #expect(try abs(self.rollingPercent(used: 1, limit: 100) - 1) < 0.0001) + // 1/200 = 0.5% used (was wrongly rescaled to 50). + #expect(try abs(self.rollingPercent(used: 1, limit: 200) - 0.5) < 0.0001) + } + + @Test + func `normal computed usage is unchanged`() throws { + #expect(try abs(self.rollingPercent(used: 25, limit: 100) - 25) < 0.0001) + } + + @Test + func `direct fractional percent is still scaled to percent`() throws { + // A direct percent field given as a 0...1 fraction keeps the existing behavior. + let payload: [String: Any] = [ + "usage": ["rollingUsage": ["usagePercent": 0.25, "resetInSec": 600]], + ] + let text = try String(data: JSONSerialization.data(withJSONObject: payload), encoding: .utf8) ?? "" + let snapshot = try OpenCodeGoUsageFetcher.parseSubscription(text: text, now: Date(timeIntervalSince1970: 0)) + #expect(snapshot.rollingUsagePercent == 25) + } +} +#endif diff --git a/TestsLinux/OpenCodePercentUnitLinuxTests.swift b/TestsLinux/OpenCodePercentUnitLinuxTests.swift new file mode 100644 index 0000000000..1e7e959272 --- /dev/null +++ b/TestsLinux/OpenCodePercentUnitLinuxTests.swift @@ -0,0 +1,44 @@ +#if os(Linux) +import Foundation +import Testing +@testable import CodexBarCore + +struct OpenCodePercentUnitLinuxTests { + private func rollingPercent(used: Int, limit: Int) throws -> Double { + let payload: [String: Any] = [ + "rollingUsage": ["used": used, "limit": limit, "resetInSec": 600], + "weeklyUsage": ["used": used, "limit": limit, "resetInSec": 3600], + ] + let text = try String(data: JSONSerialization.data(withJSONObject: payload), encoding: .utf8) ?? "" + return try OpenCodeUsageFetcher.parseSubscription(text: text, now: Date(timeIntervalSince1970: 0)) + .rollingUsagePercent + } + + @Test + func `sub-one-percent computed usage is not rescaled to 100`() throws { + // 1/100 = 1% used. The direct-fraction heuristic (<= 1 => * 100) must NOT touch a + // computed used/limit percent, which is already 0...100 — else 1.0 becomes 100. + #expect(try abs(self.rollingPercent(used: 1, limit: 100) - 1) < 0.0001) + // 1/200 = 0.5% used (was wrongly rescaled to 50). + #expect(try abs(self.rollingPercent(used: 1, limit: 200) - 0.5) < 0.0001) + } + + @Test + func `normal computed usage is unchanged`() throws { + #expect(try abs(self.rollingPercent(used: 25, limit: 100) - 25) < 0.0001) + } + + @Test + func `direct fractional percent is still scaled to percent`() throws { + // A direct percent field given as a 0...1 fraction keeps the existing behavior. + let payload: [String: Any] = [ + "rollingUsage": ["usagePercent": 0.25, "resetInSec": 600], + "weeklyUsage": ["usagePercent": 0.5, "resetInSec": 3600], + ] + let text = try String(data: JSONSerialization.data(withJSONObject: payload), encoding: .utf8) ?? "" + let snapshot = try OpenCodeUsageFetcher.parseSubscription(text: text, now: Date(timeIntervalSince1970: 0)) + #expect(snapshot.rollingUsagePercent == 25) + #expect(snapshot.weeklyUsagePercent == 50) + } +} +#endif diff --git a/TestsLinux/PlatformGatingTests.swift b/TestsLinux/PlatformGatingTests.swift index 61bb5bcf25..9586b69699 100644 --- a/TestsLinux/PlatformGatingTests.swift +++ b/TestsLinux/PlatformGatingTests.swift @@ -1,8 +1,152 @@ -import CodexBarCore +import Foundation import Testing +@testable import CodexBarCLI +@testable import CodexBarCore -@Suite +@Suite(.serialized) struct PlatformGatingTests { + @Test + func `shell probe requests a detached Linux session`() { + #if os(Linux) + #expect(ShellCommandLocator.test_shellSpawnFlags == 0x80) + #else + #expect(Bool(true)) + #endif + } + + @Test + func ampAutoSource_doesNotRequireWebSupport() { + #expect(!CodexBarCLI.sourceModeRequiresWebSupport(.auto, provider: .amp)) + } + + @Test + func claudeAutoSource_allowsPlannerToFallBackToCLI() { + #expect(!CodexBarCLI.sourceModeRequiresWebSupport(.auto, provider: .claude)) + #expect(CodexBarCLI.sourceModeRequiresWebSupport(.web, provider: .claude)) + } + + @Test + func claudeAutoPipeline_skipsUnsupportedWebAndUsesCLI() async throws { + #if os(Linux) + let binaryURL = try Self.makeClaudeCLI(loggedIn: true) + defer { try? FileManager.default.removeItem(at: binaryURL) } + let context = self.makeClaudeAutoContext(env: ["CLAUDE_CLI_PATH": binaryURL.path]) + let cliFetchOverride: ClaudeStatusProbe.FetchOverride = { _, _, _ in + Self.makeClaudeStatus() + } + let outcome = await ClaudeCLIResolver.withResolvedBinaryPathOverrideForTesting(binaryURL.path) { + await ClaudeCLIAuthStatusProbe.withResultOverrideForTesting(true) { + await ClaudeStatusProbe.withFetchOverrideForTesting(cliFetchOverride) { + await ClaudeProviderDescriptor.makeDescriptor().fetchPlan.fetchOutcome( + context: context, + provider: .claude) + } + } + } + let result = try outcome.result.get() + + #expect(result.strategyID == "claude.cli") + #expect(outcome.attempts.map(\.strategyID) == ["claude.web", "claude.cli"]) + #expect(outcome.attempts.map(\.wasAvailable) == [false, true]) + #else + #expect(Bool(true)) + #endif + } + + @Test + func claudeAutoPipeline_withoutCLIReportsNoAvailableStrategy() async { + #if os(Linux) + let context = self.makeClaudeAutoContext() + let outcome = await ClaudeCLIResolver.withResolvedBinaryPathOverrideForTesting( + "/definitely/missing/claude") + { + await ClaudeProviderDescriptor.makeDescriptor().fetchPlan.fetchOutcome( + context: context, + provider: .claude) + } + + switch outcome.result { + case .success: + Issue.record("Expected Claude auto without a CLI to report no available strategy") + case let .failure(error): + guard let fetchError = error as? ProviderFetchError else { + Issue.record("Expected ProviderFetchError, got \(error)") + return + } + switch fetchError { + case let .noAvailableStrategy(provider): + #expect(provider == .claude) + } + } + #expect(outcome.attempts.map(\.strategyID) == ["claude.web", "claude.cli"]) + #expect(outcome.attempts.map(\.wasAvailable) == [false, false]) + #else + #expect(Bool(true)) + #endif + } + + @Test(arguments: [ProviderSourceMode.auto, .cli]) + func `Claude CLI runtime skips logged out interactive fallback`(sourceMode: ProviderSourceMode) async throws { + #if os(Linux) + let invocationLog = FileManager.default.temporaryDirectory + .appendingPathComponent("claude-cli-runtime-invocations-\(UUID().uuidString).log") + let binaryURL = try Self.makeClaudeCLI(loggedIn: false, invocationLog: invocationLog) + defer { + try? FileManager.default.removeItem(at: binaryURL) + try? FileManager.default.removeItem(at: invocationLog) + } + let context = self.makeClaudeContext( + sourceMode: sourceMode, + env: ["CLAUDE_CLI_PATH": binaryURL.path]) + let cliFetchOverride: ClaudeStatusProbe.FetchOverride = { _, _, _ in + Issue.record("Logged-out Claude CLI reached the interactive usage probe") + return Self.makeClaudeStatus() + } + + let outcome = await ClaudeCLIAuthStatusProbe.withTimeoutOverrideForTesting(20) { + await ClaudeStatusProbe.withFetchOverrideForTesting(cliFetchOverride) { + await ClaudeProviderDescriptor.makeDescriptor().fetchPlan.fetchOutcome( + context: context, + provider: .claude) + } + } + + switch outcome.result { + case .success: + Issue.record("Expected logged-out Claude CLI to report no available strategy") + case let .failure(error): + guard let fetchError = error as? ProviderFetchError else { + Issue.record("Expected ProviderFetchError, got \(error)") + return + } + switch fetchError { + case let .noAvailableStrategy(provider): + #expect(provider == .claude) + } + } + let expectedStrategyIDs = sourceMode == .auto ? ["claude.web", "claude.cli"] : ["claude.cli"] + #expect(outcome.attempts.map(\.strategyID) == expectedStrategyIDs) + #expect(outcome.attempts.allSatisfy { !$0.wasAvailable }) + let invocations = try String(contentsOf: invocationLog, encoding: .utf8) + #expect(invocations == "auth status --json\n") + #else + #expect(Bool(true)) + #endif + } + + @Test + func claudeOAuthUsageDoesNotDetectCLIVersion() { + #expect(!CodexBarCLI.shouldDetectVersion( + provider: .claude, + result: self.makeResult(kind: .oauth))) + #expect(CodexBarCLI.shouldDetectVersion( + provider: .claude, + result: self.makeResult(kind: .cli))) + #expect(CodexBarCLI.shouldDetectVersion( + provider: .codex, + result: self.makeResult(kind: .oauth))) + } + @Test func claudeWebFetcher_isNotSupportedOnLinux() async { #if os(Linux) @@ -43,4 +187,78 @@ struct PlatformGatingTests { #expect(Bool(true)) #endif } + private func makeClaudeAutoContext(env: [String: String] = [:]) -> ProviderFetchContext { + self.makeClaudeContext(sourceMode: .auto, env: env) + } + + private func makeClaudeContext( + sourceMode: ProviderSourceMode, + env: [String: String] = [:]) -> ProviderFetchContext + { + let browserDetection = BrowserDetection(cacheTTL: 0) + let usageDataSource: ClaudeUsageDataSource = sourceMode == .cli ? .cli : .auto + return ProviderFetchContext( + runtime: .cli, + sourceMode: sourceMode, + includeCredits: false, + webTimeout: 1, + webDebugDumpHTML: false, + verbose: false, + env: env, + settings: ProviderSettingsSnapshot.make(claude: .init( + usageDataSource: usageDataSource, + webExtrasEnabled: false, + cookieSource: .auto, + manualCookieHeader: nil)), + fetcher: UsageFetcher(environment: env), + claudeFetcher: ClaudeUsageFetcher(browserDetection: browserDetection), + browserDetection: browserDetection) + } + + private static func makeClaudeCLI(loggedIn: Bool, invocationLog: URL? = nil) throws -> URL { + if let invocationLog { + try Data().write(to: invocationLog) + } + let binaryURL = FileManager.default.temporaryDirectory + .appendingPathComponent("claude-cli-runtime-\(UUID().uuidString)") + let recordInvocation = invocationLog.map { "printf '%s\\n' \"$*\" >> '\($0.path)'" } ?? "" + let loggedInJSON = loggedIn ? "true" : "false" + let script = """ + #!/bin/sh + \(recordInvocation) + if [ "$1" = "auth" ] && [ "$2" = "status" ]; then + printf '%s\\n' '{"loggedIn":\(loggedInJSON)}' + fi + """ + try Data(script.utf8).write(to: binaryURL) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: binaryURL.path) + return binaryURL + } + + private static func makeClaudeStatus() -> ClaudeStatusSnapshot { + ClaudeStatusSnapshot( + sessionPercentLeft: 80, + weeklyPercentLeft: nil, + opusPercentLeft: nil, + accountEmail: nil, + accountOrganization: nil, + loginMethod: nil, + primaryResetDescription: nil, + secondaryResetDescription: nil, + opusResetDescription: nil, + rawText: "stub") + } + + private func makeResult(kind: ProviderFetchKind) -> ProviderFetchResult { + ProviderFetchResult( + usage: UsageSnapshot( + primary: nil, + secondary: nil, + updatedAt: Date(timeIntervalSince1970: 0)), + credits: nil, + dashboard: nil, + sourceLabel: "test", + strategyID: "test", + strategyKind: kind) + } } diff --git a/TestsLinux/ProcNetTCPListeningPortParserLinuxTests.swift b/TestsLinux/ProcNetTCPListeningPortParserLinuxTests.swift new file mode 100644 index 0000000000..82016dc1a3 --- /dev/null +++ b/TestsLinux/ProcNetTCPListeningPortParserLinuxTests.swift @@ -0,0 +1,111 @@ +import Foundation +import Testing +@testable import CodexBarCore + +/// Tests for the `/proc//net/tcp` listening-port parser used on Linux as a +/// fallback for Antigravity CLI port detection when `lsof` is unavailable. +struct ProcNetTCPListeningPortParserLinuxTests { + /// Two loopback LISTEN sockets (inodes 111111, 222222) and one established + /// connection (inode 333333, st 01) that must be ignored. + private static let sample = """ + sl local_address rem_address st tx_queue rx_queue tr tm->when retrnsmt uid timeout inode + 0: 0100007F:1F90 00000000:0000 0A 00000000:00000000 00:00000000 00000000 1000 0 111111 1 0000000000000000 100 0 0 10 0 + 1: 0100007F:C000 00000000:0000 0A 00000000:00000000 00:00000000 00000000 1000 0 222222 1 0000000000000000 100 0 0 10 0 + 2: 0100007F:1F91 0100007F:E1F0 01 00000000:00000000 00:00000000 00000000 1000 0 333333 1 0000000000000000 100 0 0 10 0 + """ + + @Test + func `returns listening ports for owned socket inodes`() { + let ports = ProcNetTCPListeningPortParser.listeningPorts( + Self.sample, socketInodes: ["111111", "222222"]) + #expect(ports == [8080, 49152]) + } + + @Test + func `parses tcp6 and deduplicates ports across tables`() { + let tcp6 = """ + sl local_address rem_address st tx_queue rx_queue tr tm->when retrnsmt uid timeout inode + 0: 00000000000000000000000000000000:C000 00000000000000000000000000000000:0000 0A 00000000:00000000 00:00000000 00000000 1000 0 222222 + """ + let tcpPorts = ProcNetTCPListeningPortParser.listeningPorts( + Self.sample, socketInodes: ["222222"]) + let tcp6Ports = ProcNetTCPListeningPortParser.listeningPorts( + tcp6, socketInodes: ["222222"]) + #expect(tcpPorts.union(tcp6Ports) == [49152]) + } + + @Test + func `ignores malformed and out of range ports`() { + let malformed = """ + sl local_address rem_address st tx_queue rx_queue tr tm->when retrnsmt uid timeout inode + 0: 0100007F:NOTHEX 00000000:0000 0A 00000000:00000000 00:00000000 00000000 1000 0 111111 + 1: 0100007F:10000 00000000:0000 0A 00000000:00000000 00:00000000 00000000 1000 0 111111 + 2: missing-columns + """ + #expect(ProcNetTCPListeningPortParser.listeningPorts( + malformed, socketInodes: ["111111"]).isEmpty) + #expect(ProcNetTCPListeningPortParser.listeningPorts( + "header only", socketInodes: ["111111"]).isEmpty) + } + + @Test + func `accepts a headerless proc row`() { + let row = Self.sample.split(separator: "\n")[1] + #expect(ProcNetTCPListeningPortParser.listeningPorts( + String(row), socketInodes: ["111111"]) == [8080]) + } + + @Test + func `ignores listening sockets owned by other processes`() { + let ports = ProcNetTCPListeningPortParser.listeningPorts( + Self.sample, socketInodes: ["999999"]) + #expect(ports.isEmpty) + } + + @Test + func `ignores non listening sockets`() { + // inode 333333 is an established (st 01) socket, not LISTEN. + let ports = ProcNetTCPListeningPortParser.listeningPorts( + Self.sample, socketInodes: ["333333"]) + #expect(ports.isEmpty) + } + + @Test + func `parses socket inode from FD symlink destination`() { + #expect(ProcNetTCPListeningPortParser.socketInode(fromLink: "socket:[12345]") == "12345") + #expect(ProcNetTCPListeningPortParser.socketInode(fromLink: "/dev/pts/0") == nil) + #expect(ProcNetTCPListeningPortParser.socketInode(fromLink: "anon_inode:[eventpoll]") == nil) + #expect(ProcNetTCPListeningPortParser.socketInode(fromLink: "socket:[]") == nil) + } + + @Test + func `reads process scoped TCP tables`() throws { + let fileManager = FileManager.default + let procRoot = fileManager.temporaryDirectory + .appendingPathComponent("codexbar-proc-\(UUID().uuidString)") + let processRoot = procRoot.appendingPathComponent("42") + let fdDirectory = processRoot.appendingPathComponent("fd") + let netDirectory = processRoot.appendingPathComponent("net") + let callerNetDirectory = procRoot.appendingPathComponent("net") + try fileManager.createDirectory(at: fdDirectory, withIntermediateDirectories: true) + try fileManager.createDirectory(at: netDirectory, withIntermediateDirectories: true) + try fileManager.createDirectory(at: callerNetDirectory, withIntermediateDirectories: true) + defer { try? fileManager.removeItem(at: procRoot) } + + try fileManager.createSymbolicLink( + atPath: fdDirectory.appendingPathComponent("7").path, + withDestinationPath: "socket:[111111]") + try Self.sample.write( + to: netDirectory.appendingPathComponent("tcp"), + atomically: true, + encoding: .utf8) + try Self.sample.replacingOccurrences(of: ":1F90", with: ":C001").write( + to: callerNetDirectory.appendingPathComponent("tcp"), + atomically: true, + encoding: .utf8) + + #expect(AntigravityStatusProbe.procListeningPorts( + pid: 42, + procRoot: procRoot.path) == [8080]) + } +} diff --git a/TestsLinux/ProcessPipeCaptureLinuxTests.swift b/TestsLinux/ProcessPipeCaptureLinuxTests.swift new file mode 100644 index 0000000000..19181b5d0b --- /dev/null +++ b/TestsLinux/ProcessPipeCaptureLinuxTests.swift @@ -0,0 +1,206 @@ +import Foundation +#if os(Linux) +import Glibc +#endif +import Testing +@testable import CodexBarCore + +#if os(Linux) +@Suite(.serialized) +struct ProcessPipeCaptureLinuxTests { + private static let emfileChildEnvironmentKey = "CODEXBAR_PROCESS_PIPE_EMFILE_CHILD" + + @Test + func `blocked onData callback does not block capture close`() throws { + let callbackStarted = DispatchSemaphore(value: 0) + let releaseCallback = DispatchSemaphore(value: 0) + let captureFinished = DispatchSemaphore(value: 0) + let pipe = Pipe() + let capture = ProcessPipeCapture(pipe: pipe, onData: { + callbackStarted.signal() + releaseCallback.wait() + }) + capture.start() + + try pipe.fileHandleForWriting.write(contentsOf: Data("hello".utf8)) + #expect(callbackStarted.wait(timeout: .now() + 1) == .success) + + DispatchQueue.global().async { + _ = capture.finishSynchronously(timeout: 0.05) + captureFinished.signal() + } + let finishResult = captureFinished.wait(timeout: .now() + 0.5) + releaseCallback.signal() + #expect(finishResult == .success) + if finishResult != .success { + _ = captureFinished.wait(timeout: .now() + 1) + } + try pipe.fileHandleForWriting.close() + } + + @Test + func `continuous output does not defeat the capture timeout`() throws { + let writerStarted = DispatchSemaphore(value: 0) + let stopWriter = DispatchSemaphore(value: 0) + let writerFinished = DispatchSemaphore(value: 0) + let pipe = Pipe() + let writerDescriptor = pipe.fileHandleForWriting.fileDescriptor + let writerFlags = Glibc.fcntl(writerDescriptor, F_GETFL) + #expect(writerFlags >= 0) + #expect(Glibc.fcntl(writerDescriptor, F_SETFL, writerFlags | O_NONBLOCK) == 0) + + let capture = ProcessPipeCapture(pipe: pipe, maxBytes: 1024) + capture.start() + DispatchQueue.global().async { + var blockedSignals = sigset_t() + var previousSignals = sigset_t() + Glibc.sigemptyset(&blockedSignals) + Glibc.sigaddset(&blockedSignals, SIGPIPE) + _ = Glibc.pthread_sigmask(SIG_BLOCK, &blockedSignals, &previousSignals) + defer { + var pendingSignals = sigset_t() + if Glibc.sigpending(&pendingSignals) == 0, Glibc.sigismember(&pendingSignals, SIGPIPE) == 1 { + var noWait = timespec(tv_sec: 0, tv_nsec: 0) + _ = Glibc.sigtimedwait(&blockedSignals, nil, &noWait) + } + _ = Glibc.pthread_sigmask(SIG_SETMASK, &previousSignals, nil) + } + + var bytes = [UInt8](repeating: 0x41, count: 16 * 1024) + while stopWriter.wait(timeout: .now()) == .timedOut { + let count = bytes.withUnsafeMutableBytes { buffer in + Glibc.write(writerDescriptor, buffer.baseAddress, buffer.count) + } + if count < 0, errno == EPIPE { + break + } + if count > 0 { + writerStarted.signal() + } + } + writerFinished.signal() + } + #expect(writerStarted.wait(timeout: .now() + 1) == .success) + + let startedAt = ContinuousClock.now + _ = capture.finishSynchronously(timeout: 0.01) + let elapsed = startedAt.duration(to: .now) + stopWriter.signal() + + #expect(elapsed < .milliseconds(500)) + #expect(writerFinished.wait(timeout: .now() + 1) == .success) + try pipe.fileHandleForWriting.close() + } + + @Test + func `Linux descriptor setup failure closes the read end immediately`() throws { + let pipe = Pipe() + let readFileDescriptor = pipe.fileHandleForReading.fileDescriptor + let capture = ProcessPipeCapture(pipe: pipe) + capture.start(linuxDescriptorSetup: { descriptor in + errno = EMFILE + return descriptor < 0 + }) + + let startedAt = ContinuousClock.now + let data = capture.finishSynchronously(timeout: 5) + let elapsed = startedAt.duration(to: .now) + + #expect(data.isEmpty) + #expect(elapsed < .milliseconds(500)) + #expect(Glibc.fcntl(readFileDescriptor, F_GETFD) == -1) + #expect(errno == EBADF) + try pipe.fileHandleForWriting.close() + } + + @Test + func `capture starts while the process is at EMFILE`() throws { + if ProcessInfo.processInfo.environment[Self.emfileChildEnvironmentKey] == "1" { + try Self.runEMFILEChildScenario() + return + } + + let process = Process() + let testExecutable = try FileManager.default.destinationOfSymbolicLink(atPath: "/proc/self/exe") + process.executableURL = URL(fileURLWithPath: testExecutable) + process.arguments = ["--filter", "ProcessPipeCaptureLinuxTests", "--testing-library", "swift-testing"] + var environment = ProcessInfo.processInfo.environment + environment[Self.emfileChildEnvironmentKey] = "1" + process.environment = environment + try process.run() + process.waitUntilExit() + + #expect(process.terminationReason == .exit) + #expect(process.terminationStatus == 0) + } + + @Test + func `ProcessPipeCapture releases its pipe read end after capture`() throws { + let initialFDs = try countOpenFDs() + for _ in 0..<100 { + let proc = Process() + proc.executableURL = URL(fileURLWithPath: "/bin/echo") + proc.arguments = ["hello"] + let out = Pipe() + proc.standardOutput = out + proc.standardError = FileHandle.nullDevice + + let capture = ProcessPipeCapture(pipe: out) + capture.start() + try proc.run() + try out.fileHandleForWriting.close() + proc.waitUntilExit() + let data = capture.finishSynchronously(timeout: 0.25) + #expect(String(decoding: data, as: UTF8.self) == "hello\n") + } + let finalFDs = try countOpenFDs() + + // Allow a small tolerance for unrelated fd churn, but ensure we are + // not leaking pipe read ends (which would show as ~100 extra fds). + #expect(finalFDs - initialFDs <= 15) + } + + private static func runEMFILEChildScenario() throws { + var originalLimit = rlimit() + let noFileResource = Int32(RLIMIT_NOFILE.rawValue) + #expect(Glibc.getrlimit(noFileResource, &originalLimit) == 0) + + let pipe = Pipe() + let capture = ProcessPipeCapture(pipe: pipe) + let highestOpenFileDescriptor = try FileManager.default.contentsOfDirectory(atPath: "/proc/self/fd") + .compactMap(Int.init) + .max() ?? 32 + var constrainedLimit = originalLimit + constrainedLimit.rlim_cur = min(originalLimit.rlim_cur, rlim_t(highestOpenFileDescriptor + 32)) + #expect(Glibc.setrlimit(noFileResource, &constrainedLimit) == 0) + + var heldFileDescriptors: [Int32] = [] + defer { + for descriptor in heldFileDescriptors { + Glibc.close(descriptor) + } + _ = Glibc.setrlimit(noFileResource, &originalLimit) + } + while true { + let descriptor = Glibc.dup(STDIN_FILENO) + if descriptor < 0 { + #expect(errno == EMFILE) + break + } + heldFileDescriptors.append(descriptor) + } + + capture.start() + try pipe.fileHandleForWriting.write(contentsOf: Data("hello".utf8)) + try pipe.fileHandleForWriting.close() + let data = capture.finishSynchronously(timeout: 1) + #expect(String(decoding: data, as: UTF8.self) == "hello") + #expect(capture.reachedEOF) + } +} + +private func countOpenFDs() throws -> Int { + let entries = try FileManager.default.contentsOfDirectory(atPath: "/proc/self/fd") + return entries.count +} +#endif diff --git a/TestsLinux/ProviderEndpointOverrideSecurityLinuxTests.swift b/TestsLinux/ProviderEndpointOverrideSecurityLinuxTests.swift new file mode 100644 index 0000000000..1a482a5c66 --- /dev/null +++ b/TestsLinux/ProviderEndpointOverrideSecurityLinuxTests.swift @@ -0,0 +1,262 @@ +@testable import CodexBarCore +import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif +import Testing + +@Suite +struct ProviderEndpointOverrideSecurityLinuxTests { + @Test + func mimoInvalidEndpointOverrideDoesNotFallbackToLocalCache() { + #expect(MiMoWebFetchStrategy.shouldFallbackToLocal( + error: MiMoSettingsError.invalidEndpointOverride(MiMoSettingsReader.apiURLKey)) == false) + #expect(MiMoWebFetchStrategy.shouldFallbackToLocal(error: MiMoSettingsError.missingCookie()) == true) + #expect(MiMoWebFetchStrategy.shouldFallbackToLocal(error: MiMoSettingsError.invalidCookie) == true) + } + + @Test + func deepgramRejectsInsecureOverrideBeforeSendingToken() async { + let transport = FailingTransport() + do { + _ = try await DeepgramUsageFetcher.fetchUsage( + apiKey: "dg-test-token", + environment: [DeepgramUsageFetcher.apiURLKey: "http://attacker.test/v1"], + transport: transport) + Issue.record("Expected DeepgramUsageError.invalidEndpointOverride") + } catch DeepgramUsageError.invalidEndpointOverride(DeepgramUsageFetcher.apiURLKey) { + // Expected. + } catch { + Issue.record("Expected DeepgramUsageError.invalidEndpointOverride, got \(error)") + } + } + + @Test + func zaiRejectsInsecureQuotaOverrideBeforeSendingToken() async { + do { + _ = try await ZaiUsageFetcher.fetchUsage( + apiKey: "zai-test-token", + environment: [ZaiSettingsReader.quotaURLKey: "http://attacker.test/quota"]) + Issue.record("Expected ZaiSettingsError.invalidEndpointOverride") + } catch ZaiSettingsError.invalidEndpointOverride(ZaiSettingsReader.quotaURLKey) { + // Expected. + } catch { + Issue.record("Expected ZaiSettingsError.invalidEndpointOverride, got \(error)") + } + } + + @Test + func zaiRejectsInsecureAPIHostOverrideWhenQuotaURLIsAbsent() { + #expect(throws: ZaiSettingsError.invalidEndpointOverride(ZaiSettingsReader.apiHostKey)) { + try ZaiSettingsReader.validateEndpointOverrides( + environment: [ZaiSettingsReader.apiHostKey: "http://attacker.test"]) + } + } + + @Test + func zaiQuotaResolutionIgnoresInvalidLowerPriorityAPIHost() throws { + let environment = [ + ZaiSettingsReader.quotaURLKey: "https://zai-proxy.test/quota", + ZaiSettingsReader.apiHostKey: "http://attacker.test", + ] + + try ZaiSettingsReader.validateQuotaEndpointOverride(environment: environment) + #expect(ZaiUsageFetcher.resolveQuotaURL(region: .global, environment: environment).absoluteString == + "https://zai-proxy.test/quota") + } + + @Test + func zaiCombinedFetchRejectsInvalidAPIHostBeforeQuotaRequest() async { + let environment = [ + ZaiSettingsReader.quotaURLKey: "https://127.0.0.1:31337/quota", + ZaiSettingsReader.apiHostKey: "http://127.0.0.1:31337", + ] + + await #expect(throws: ZaiSettingsError.invalidEndpointOverride(ZaiSettingsReader.apiHostKey)) { + try await ZaiUsageFetcher.fetchUsageWithModelUsage( + apiKey: "ZAI_CANARY_KEY", + environment: environment) + } + } + + @Test + func zaiModelUsageRejectsInsecureAPIHostOverride() async { + do { + _ = try await ZaiUsageFetcher.fetchModelUsage( + apiKey: "zai-test-token", + environment: [ZaiSettingsReader.apiHostKey: "http://attacker.test"]) + Issue.record("Expected ZaiSettingsError.invalidEndpointOverride") + } catch ZaiSettingsError.invalidEndpointOverride(ZaiSettingsReader.apiHostKey) { + // Expected. + } catch { + Issue.record("Expected ZaiSettingsError.invalidEndpointOverride, got \(error)") + } + } + + @Test + func mimoRejectsInsecureOverrideBeforeSendingCookie() async { + let transport = FailingTransport() + do { + _ = try await MiMoUsageFetcher.fetchUsage( + cookieHeader: "api-platform_serviceToken=session-token; userId=user-1", + environment: [MiMoSettingsReader.apiURLKey: "http://attacker.test/api/v1"], + session: transport) + Issue.record("Expected MiMoSettingsError.invalidEndpointOverride") + } catch MiMoSettingsError.invalidEndpointOverride(MiMoSettingsReader.apiURLKey) { + // Expected. + } catch { + Issue.record("Expected MiMoSettingsError.invalidEndpointOverride, got \(error)") + } + } + + @Test + func affectedProviderOverridesAcceptHTTPSAndBareHosts() throws { + try DeepgramUsageFetcher.validateEndpointOverrides(environment: [ + DeepgramUsageFetcher.apiURLKey: "deepgram-proxy.test/v1", + ]) + try ZaiSettingsReader + .validateEndpointOverrides(environment: [ZaiSettingsReader.quotaURLKey: "https://zai-proxy.test/quota"]) + try ZaiSettingsReader.validateEndpointOverrides(environment: [ZaiSettingsReader.apiHostKey: "localhost:9443"]) + try MiMoSettingsReader + .validateEndpointOverrides(environment: [MiMoSettingsReader.apiURLKey: "mimo-proxy.test/api/v1"]) + + #expect(ZaiSettingsReader.quotaURL(environment: [ZaiSettingsReader.quotaURLKey: "zai-proxy.test/quota"])? + .absoluteString == "https://zai-proxy.test/quota") + #expect(MiMoSettingsReader.apiURL(environment: [MiMoSettingsReader.apiURLKey: "mimo-proxy.test/api/v1"]) + .absoluteString == "https://mimo-proxy.test/api/v1") + } + + // MARK: - LiteLLM / LLM Proxy + + // Both send their API key to the configured base URL as a bearer token. HTTPS works everywhere; + // HTTP is limited to loopback and explicitly private-network destinations. + + @Test + func liteLLMRejectsRemoteHTTPBaseURLBeforeSendingKey() { + for endpoint in Self.publicHTTPEndpoints { + #expect(LiteLLMSettingsReader.baseURL( + environment: [LiteLLMSettingsReader.baseURLEnvironmentKey: endpoint]) == nil) + } + } + + @Test + func liteLLMRejectsBaseURLWithEmbeddedCredentials() { + #expect(LiteLLMSettingsReader.baseURL( + environment: [LiteLLMSettingsReader.baseURLEnvironmentKey: "https://user@attacker.test"]) == nil) + } + + @Test + func liteLLMAcceptsHTTPSAndPrivateNetworkHTTPBaseURLs() { + #expect(LiteLLMSettingsReader.baseURL( + environment: [LiteLLMSettingsReader.baseURLEnvironmentKey: "https://litellm.example.com"])? + .absoluteString == "https://litellm.example.com") + for endpoint in Self.privateHTTPEndpoints { + #expect(LiteLLMSettingsReader.baseURL( + environment: [LiteLLMSettingsReader.baseURLEnvironmentKey: endpoint])?.absoluteString == endpoint) + } + } + + @Test + func llmProxyRejectsRemoteHTTPBaseURLBeforeSendingKey() { + for endpoint in Self.publicHTTPEndpoints { + #expect(LLMProxySettingsReader.baseURL( + environment: [LLMProxySettingsReader.baseURLEnvironmentKey: endpoint]) == nil) + } + } + + @Test + func llmProxyRejectsBaseURLWithEmbeddedCredentials() { + #expect(LLMProxySettingsReader.baseURL( + environment: [LLMProxySettingsReader.baseURLEnvironmentKey: "https://user@attacker.test"]) == nil) + } + + @Test + func rejectedBaseURLStaysConfiguredSoTheErrorCanSurface() { + // A rejected override must not read as "never configured": the strategy stays available so + // the fetch path can report invalidEndpointOverride instead of the provider going missing. + let liteLLM = [LiteLLMSettingsReader.baseURLEnvironmentKey: "http://attacker.test"] + #expect(LiteLLMSettingsReader.baseURL(environment: liteLLM) == nil) + #expect(LiteLLMSettingsReader.hasBaseURLOverride(environment: liteLLM)) + #expect(!LiteLLMSettingsReader.hasBaseURLOverride(environment: [:])) + + let llmProxy = [LLMProxySettingsReader.baseURLEnvironmentKey: "http://attacker.test"] + #expect(LLMProxySettingsReader.baseURL(environment: llmProxy) == nil) + #expect(LLMProxySettingsReader.hasBaseURLOverride(environment: llmProxy)) + #expect(!LLMProxySettingsReader.hasBaseURLOverride(environment: [:])) + } + + @Test + func rejectedOverrideErrorNamesTheSettingAndTheRule() { + // The message has to tell the user which key to fix and what shape is accepted. + let liteLLM = LiteLLMUsageError + .invalidEndpointOverride(LiteLLMSettingsReader.baseURLEnvironmentKey).errorDescription ?? "" + #expect(liteLLM.contains("LITELLM_BASE_URL")) + #expect(liteLLM.contains("HTTPS")) + #expect(liteLLM.contains("private-network")) + #expect(liteLLM.contains(".local")) + + let llmProxy = LLMProxyUsageError + .invalidEndpointOverride(LLMProxySettingsReader.baseURLEnvironmentKey).errorDescription ?? "" + #expect(llmProxy.contains("LLM_PROXY_BASE_URL")) + #expect(llmProxy.contains("HTTPS")) + #expect(llmProxy.contains("private-network")) + #expect(llmProxy.contains(".local")) + } + + @Test + func llmProxyAcceptsHTTPSAndPrivateNetworkHTTPBaseURLs() { + #expect(LLMProxySettingsReader.baseURL( + environment: [LLMProxySettingsReader.baseURLEnvironmentKey: "https://proxy.example.com"])? + .absoluteString == "https://proxy.example.com") + for endpoint in Self.privateHTTPEndpoints { + #expect(LLMProxySettingsReader.baseURL( + environment: [LLMProxySettingsReader.baseURLEnvironmentKey: endpoint])?.absoluteString == endpoint) + } + } + + @Test + func sharedLoopbackOnlyValidatorStillRejectsPrivateNetworkHTTP() { + let validator = ProviderEndpointOverrideValidator() + #expect(validator.validatedURLAllowingLoopbackHTTP("http://127.0.0.1:4000") != nil) + #expect(validator.validatedURLAllowingLoopbackHTTP("http://192.168.1.10:4000") == nil) + #expect(validator.validatedURLAllowingLoopbackHTTP("http://[fd00::1]:4000") == nil) + #expect(validator.validatedURLAllowingLoopbackHTTP("http://proxy.local:4000") == nil) + } + + private static let privateHTTPEndpoints = [ + "http://localhost:4000", + "http://127.0.0.1:4000", + "http://[::1]:4000", + "http://10.255.255.255:4000", + "http://172.16.0.1:4000", + "http://172.31.255.255:4000", + "http://192.168.1.10:4000", + "http://169.254.10.20:4000", + "http://[fc00::1]:4000", + "http://[fdff:ffff::1]:4000", + "http://[fe80::1]:4000", + "http://[febf:ffff::1]:4000", + "http://proxy.local:4000", + "http://proxy.local.:4000", + ] + + private static let publicHTTPEndpoints = [ + "http://attacker.test:4000", + "http://8.8.8.8:4000", + "http://172.15.255.255:4000", + "http://172.32.0.0:4000", + "http://169.253.255.255:4000", + "http://192.169.0.1:4000", + "http://[2606:4700:4700::1111]:4000", + "http://[fec0::1]:4000", + ] +} + +private struct FailingTransport: ProviderHTTPTransport { + func data(for request: URLRequest) async throws -> (Data, URLResponse) { + Issue + .record( + "Endpoint override validation should fail before any request is sent to \(request.url?.absoluteString ?? "")") + throw URLError(.badURL) + } +} diff --git a/TestsLinux/ResetCountdownDayRolloverLinuxTests.swift b/TestsLinux/ResetCountdownDayRolloverLinuxTests.swift new file mode 100644 index 0000000000..474ca208d5 --- /dev/null +++ b/TestsLinux/ResetCountdownDayRolloverLinuxTests.swift @@ -0,0 +1,57 @@ +#if os(Linux) +import Foundation +import Testing +@testable import CodexBarCore + +struct ResetCountdownDayRolloverLinuxTests { + private static let now = Date(timeIntervalSince1970: 1_700_000_000) + + private func at(hoursFromNow hours: Double) -> Date { + Self.now.addingTimeInterval(hours * 3600) + } + + @Test + func `Windsurf web reset at exactly 24h rolls over to a day`() { + // Was "Resets in 24h 0m"; the day form must be reachable at the 24h boundary. + #expect( + WindsurfGetPlanStatusResponse.formatResetDescription(self.at(hoursFromNow: 24), now: Self.now) + == "Resets in 1d 0h") + } + + @Test + func `Windsurf web reset above 24h shows day and hour`() { + #expect( + WindsurfGetPlanStatusResponse.formatResetDescription(self.at(hoursFromNow: 25), now: Self.now) + == "Resets in 1d 1h") + } + + @Test + func `Windsurf web reset below 24h stays in hours`() { + #expect( + WindsurfGetPlanStatusResponse.formatResetDescription(self.at(hoursFromNow: 23), now: Self.now) + == "Resets in 23h 0m") + } + + @Test + func `Windsurf cached reset at exactly 24h rolls over to a day`() { + #expect( + WindsurfCachedPlanInfo.formatResetDescription(self.at(hoursFromNow: 24), now: Self.now) + == "Resets in 1d 0h") + } + + @Test + func `Zed cycle at exactly 24h rolls over to a day`() { + // Was "Cycle ends in 24h 0m". + #expect( + ZedUsageSnapshot.formatResetDescription(self.at(hoursFromNow: 24), now: Self.now) + == "Cycle ends in 1d 0h") + } + + @Test + func `JetBrains reset at exactly 24h rolls over to a day`() { + #expect( + JetBrainsStatusSnapshot.formatResetDescription(self.at(hoursFromNow: 24), now: Self.now) + == "Resets in 1d 0h") + } +} +#endif diff --git a/TestsLinux/ShellCommandSessionLinuxTests.swift b/TestsLinux/ShellCommandSessionLinuxTests.swift new file mode 100644 index 0000000000..10c755c396 --- /dev/null +++ b/TestsLinux/ShellCommandSessionLinuxTests.swift @@ -0,0 +1,22 @@ +import Foundation +import Testing +@testable import CodexBarCore + +#if os(Linux) +@Suite(.serialized) +struct ShellCommandSessionLinuxTests { + @Test + func `shell probe launches as a detached session leader`() throws { + let output = ShellCommandLocator.test_runShellCommand( + shell: "/bin/sh", + arguments: ["-c", "printf '%s ' \"$$\"; ps -o sid= -p \"$$\""], + timeout: 5) + let text = try #require(output.flatMap { String(data: $0, encoding: .utf8) }) + let identifiers = text.split(whereSeparator: \.isWhitespace).compactMap { Int32($0) } + + #expect(identifiers.count == 2) + guard identifiers.count == 2 else { return } + #expect(identifiers[0] == identifiers[1]) + } +} +#endif diff --git a/TestsLinux/UsageFormatterLinuxTests.swift b/TestsLinux/UsageFormatterLinuxTests.swift new file mode 100644 index 0000000000..22facb0392 --- /dev/null +++ b/TestsLinux/UsageFormatterLinuxTests.swift @@ -0,0 +1,15 @@ +import CodexBarCore +import Testing + +@Suite(.serialized) +struct UsageFormatterLinuxTests { + @Test + func `rate-window formatting uses the standalone English fallback`() { + UsageFormatter.clearLocalizationProvider() + UsageFormatter.clearLocaleProvider() + + #expect(UsageFormatter.usageLine(remaining: 25, used: 75, showUsed: false) == "25% left") + #expect(UsageFormatter.usageLine(remaining: 25, used: 75, showUsed: true) == "75% used") + #expect(UsageFormatter.usageLine(remaining: 0.75, used: 99.25, showUsed: false) == "<1% left") + } +} diff --git a/TestsLinux/WayfinderProviderLinuxTests.swift b/TestsLinux/WayfinderProviderLinuxTests.swift new file mode 100644 index 0000000000..d0bd8ccfd7 --- /dev/null +++ b/TestsLinux/WayfinderProviderLinuxTests.swift @@ -0,0 +1,447 @@ +import CodexBarCore +import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif +import Testing +@testable import CodexBarCLI + +/// Fixtures below were captured verbatim from a locally running Wayfinder gateway +/// (`wayfinder-router serve`, two-tier priced config) after routing real traffic. +struct WayfinderProviderLinuxTests { + @Test + func `assembles a snapshot from live gateway payloads`() throws { + let snapshot = try Self.makeSnapshot() + + #expect(snapshot.gatewayStatus == "ok") + #expect(!snapshot.offline) + #expect(!snapshot.dryRun) + #expect(snapshot.missingKeys.isEmpty) + #expect(snapshot.modelCount == 2) + #expect(snapshot.requests == 14) + #expect(snapshot.tokens == 1028) + #expect(snapshot.priced) + #expect(snapshot.saved == 0.005694) + #expect(snapshot.savedPct == 61.5) + #expect(snapshot.routes.map(\.name) == ["local", "cloud"]) + #expect(snapshot.routes.first { $0.name == "local" }?.requests == 10) + #expect(snapshot.routes.first { $0.name == "cloud" }?.requests == 4) + #expect(snapshot.statusLabel == "Local gateway") + #expect(snapshot.gatewaySummary == "ok · 2 models") + #expect(snapshot.displayLines == [ + "Gateway: ok · 2 models", + "Routed: local: 10 · cloud: 4", + "Saved: <$0.01 · 61.5% vs highest-cost route", + "Avg decision: 0.1 ms", + ]) + + let avgMs = try #require(snapshot.avgDecisionMs) + #expect(abs(avgMs - 0.0804) < 0.001) + } + + @Test + func `maps the snapshot onto the shared usage snapshot`() throws { + let usage = try Self.makeSnapshot().toUsageSnapshot() + + #expect(usage.primary == nil) + #expect(usage.secondary == nil) + #expect(usage.providerCost == nil) + #expect(usage.identity?.providerID == .wayfinder) + #expect(usage.identity?.accountEmail == nil) + #expect(usage.identity?.accountOrganization == "2 models · local gateway") + #expect(usage.identity?.loginMethod == "Local gateway") + #expect(usage.dataConfidence == .exact) + } + + @Test + func `degraded health reports the missing key count`() throws { + let snapshot = try Self.makeSnapshot(healthData: Self.healthDegraded) + #expect(snapshot.gatewayStatus == "degraded") + #expect(snapshot.missingKeys == ["cloud"]) + #expect(snapshot.statusLabel == "Degraded — 1 key missing") + } + + @Test + func `empty savings suppress the routed and saved summaries`() throws { + let snapshot = try Self.makeSnapshot(savingsData: Self.savingsZeros) + #expect(snapshot.requests == 0) + #expect(snapshot.routedSummary == nil) + #expect(snapshot.savedSummary == nil) + #expect(snapshot.toUsageSnapshot().providerCost == nil) + } + + @Test + func `unpriced savings never render dollars`() throws { + let snapshot = try Self.makeSnapshot(savingsData: Self.savingsUnpriced) + #expect(!snapshot.priced) + #expect(snapshot.savedSummary == "40% vs highest-cost route") + #expect(snapshot.toUsageSnapshot().providerCost == nil) + } + + @Test + func `sub-cent priced savings render below one cent`() throws { + let snapshot = try Self.makeSnapshot() + #expect(snapshot.routedSummary == "local: 10 · cloud: 4") + #expect(snapshot.savedSummary == "<$0.01 · 61.5% vs highest-cost route") + #expect(snapshot.avgDecisionSummary == "0.1 ms") + } + + @Test + func `metrics parsing is best effort`() throws { + #expect(WayfinderUsageFetcher._averageDecisionMillisecondsForTesting("") == nil) + #expect(WayfinderUsageFetcher._averageDecisionMillisecondsForTesting("garbage\nlines\n") == nil) + #expect(WayfinderUsageFetcher._averageDecisionMillisecondsForTesting( + "wayfinder_router_decision_latency_seconds_sum 1.5\n") == nil) + #expect(WayfinderUsageFetcher._averageDecisionMillisecondsForTesting( + "wayfinder_router_decision_latency_seconds_sum 1.5\n" + + "wayfinder_router_decision_latency_seconds_count 0\n") == nil) + + let labeled = WayfinderUsageFetcher._averageDecisionMillisecondsForTesting( + "wayfinder_router_decision_latency_seconds_sum{route=\"all\"} 2.0\n" + + "wayfinder_router_decision_latency_seconds_count{route=\"all\"} 4\n") + #expect(labeled == 500) + + let snapshot = try Self.makeSnapshot(metricsText: nil) + #expect(snapshot.avgDecisionMs == nil) + #expect(snapshot.avgDecisionSummary == nil) + } + + @Test + func `endpoint URLs preserve prefixes and trailing slashes`() throws { + func endpoint(_ base: String, _ path: String) throws -> String { + try WayfinderUsageFetcher._endpointURLForTesting( + baseURL: #require(URL(string: base)), + path: path).absoluteString + } + #expect(try endpoint("http://127.0.0.1:8088", "healthz") == "http://127.0.0.1:8088/healthz") + #expect(try endpoint("http://127.0.0.1:8088/", "healthz") == "http://127.0.0.1:8088/healthz") + #expect(try endpoint("https://wayfinder.example.com/wf", "v1/savings") == + "https://wayfinder.example.com/wf/v1/savings") + } + + @Test + func `gateway URL override allows loopback HTTP and rejects remote HTTP`() throws { + let key = WayfinderSettingsReader.baseURLEnvironmentKey + + try WayfinderSettingsReader.validateEndpointOverride(environment: [key: "http://127.0.0.1:9090"]) + try WayfinderSettingsReader.validateEndpointOverride(environment: [key: "http://localhost:8088"]) + try WayfinderSettingsReader.validateEndpointOverride(environment: [key: "https://wayfinder.example.com"]) + #expect(WayfinderSettingsReader.baseURL(environment: [key: "http://127.0.0.1:9090"]).absoluteString == + "http://127.0.0.1:9090") + + #expect(throws: WayfinderSettingsError.invalidEndpointOverride(key)) { + try WayfinderSettingsReader.validateEndpointOverride(environment: [key: "http://192.168.1.5:8088"]) + } + #expect(throws: WayfinderSettingsError.invalidEndpointOverride(key)) { + try WayfinderSettingsReader.validateEndpointOverride(environment: [key: "http://user@127.0.0.1:8088"]) + } + #expect(WayfinderSettingsReader.baseURL(environment: [key: "http://attacker.test"]) == + WayfinderSettingsReader.defaultBaseURL) + #expect(WayfinderSettingsReader.baseURL(environment: [:]) == WayfinderSettingsReader.defaultBaseURL) + } + + @Test + func `dashboard URL follows the configured gateway and preserves its prefix`() { + let key = WayfinderSettingsReader.baseURLEnvironmentKey + + #expect(WayfinderSettingsReader.dashboardURL(environment: [:]).absoluteString == + "http://127.0.0.1:8088/router") + #expect(WayfinderSettingsReader.dashboardURL( + environment: [key: "http://localhost:9191/wayfinder/"]).absoluteString == + "http://localhost:9191/wayfinder/router") + } + + @Test + func `config projects the gateway URL into the fetch environment`() { + let config = ProviderConfig(id: .wayfinder, enterpriseHost: "http://localhost:9099") + let environment = ProviderConfigEnvironment.applyProviderConfigOverrides( + base: [:], + provider: .wayfinder, + config: config) + + #expect(environment[WayfinderSettingsReader.baseURLEnvironmentKey] == "http://localhost:9099") + #expect(WayfinderSettingsReader.baseURL(environment: environment).absoluteString == "http://localhost:9099") + } + + @Test + func `descriptor is registered`() { + let descriptor = ProviderDescriptorRegistry.descriptor(for: .wayfinder) + #expect(descriptor.metadata.displayName == "Wayfinder") + #expect(descriptor.metadata.cliName == "wayfinder") + #expect(descriptor.cli.aliases.contains("wayfinder-router")) + #expect(!descriptor.metadata.defaultEnabled) + } + + @Test + func `usage snapshot preserves Wayfinder detail when cached`() throws { + let snapshot = try Self.makeSnapshot() + let encoded = try JSONEncoder().encode(snapshot.toUsageSnapshot()) + let decoded = try JSONDecoder().decode(UsageSnapshot.self, from: encoded) + + #expect(decoded.wayfinderUsage == snapshot) + #expect(decoded.identity?.providerID == .wayfinder) + } + + @Test + func `fetch polls only the documented read-only endpoints`() async throws { + let log = RequestLog() + let transport = ProviderHTTPTransportHandler { request in + let url = try #require(request.url) + log.append(url) + #expect(request.httpMethod == "GET") + let body: Data = switch url.path { + case "/healthz": Self.healthOK + case "/router/models": Self.models + case "/v1/savings": Self.savings30d + case "/metrics": Data(Self.metricsText.utf8) + default: Data() + } + let response = try #require(HTTPURLResponse( + url: url, + statusCode: 200, + httpVersion: nil, + headerFields: nil)) + return (body, response) + } + + let snapshot = try await WayfinderUsageFetcher.fetchUsage( + baseURL: #require(URL(string: "http://127.0.0.1:8088")), + transport: transport) + + #expect(snapshot.requests == 14) + #expect(log.paths() == ["/healthz", "/router/models", "/v1/savings", "/metrics"]) + #expect(log.queries().contains("period=30d")) + } + + @Test + func `fetch maps HTTP failures to actionable errors`() async throws { + let failing = ProviderHTTPTransportHandler { _ in + throw URLError(.cannotConnectToHost) + } + await #expect(throws: WayfinderUsageError.gatewayUnreachable) { + _ = try await WayfinderUsageFetcher.fetchUsage( + baseURL: #require(URL(string: "http://127.0.0.1:8088")), + transport: failing) + } + + let serverError = ProviderHTTPTransportHandler { request in + let url = try #require(request.url) + let response = try #require(HTTPURLResponse( + url: url, + statusCode: 500, + httpVersion: nil, + headerFields: nil)) + return (Data(), response) + } + await #expect(throws: WayfinderUsageError.apiError(500)) { + _ = try await WayfinderUsageFetcher.fetchUsage( + baseURL: #require(URL(string: "http://127.0.0.1:8088")), + transport: serverError) + } + } + + @Test + func `required request cancellation remains cancellation`() async throws { + for error in [CancellationError() as any Error, URLError(.cancelled) as any Error] { + let cancelling = ProviderHTTPTransportHandler { _ in throw error } + await #expect(throws: CancellationError.self) { + _ = try await WayfinderUsageFetcher.fetchUsage( + baseURL: #require(URL(string: "http://127.0.0.1:8088")), + transport: cancelling) + } + } + } + + @Test + func `optional metrics cancellation remains cancellation`() async throws { + let cancelling = ProviderHTTPTransportHandler { request in + let url = try #require(request.url) + if url.path == "/metrics" { + throw CancellationError() + } + let body: Data = switch url.path { + case "/healthz": Self.healthOK + case "/router/models": Self.models + case "/v1/savings": Self.savings30d + default: Data() + } + let response = try #require(HTTPURLResponse( + url: url, + statusCode: 200, + httpVersion: nil, + headerFields: nil)) + return (body, response) + } + + await #expect(throws: CancellationError.self) { + _ = try await WayfinderUsageFetcher.fetchUsage( + baseURL: #require(URL(string: "http://127.0.0.1:8088")), + transport: cancelling) + } + } + + @Test + func `fetch rejects responses from a different origin`() async throws { + let redirecting = ProviderHTTPTransportHandler { _ in + let elsewhere = try #require(URL(string: "http://attacker.test/healthz")) + let response = try #require(HTTPURLResponse( + url: elsewhere, + statusCode: 200, + httpVersion: nil, + headerFields: nil)) + return (Self.healthOK, response) + } + await #expect(throws: WayfinderUsageError.unexpectedRedirect) { + _ = try await WayfinderUsageFetcher.fetchUsage( + baseURL: #require(URL(string: "http://127.0.0.1:8088")), + transport: redirecting) + } + } + + @Test + func `text CLI renders gateway health routed split savings and latency`() throws { + let output = try CLIRenderer.renderText( + provider: .wayfinder, + snapshot: Self.makeSnapshot().toUsageSnapshot(), + credits: nil, + context: RenderContext( + header: "Wayfinder (api)", + status: nil, + useColor: false, + resetStyle: .countdown)) + + #expect(output.contains("Gateway: ok · 2 models")) + #expect(output.contains("Routed: local: 10 · cloud: 4")) + #expect(output.contains("Saved: <$0.01 · 61.5% vs highest-cost route")) + #expect(output.contains("Avg decision: 0.1 ms")) + #expect(!output.contains("Cost:")) + } + + @Test + func `routed summary reflects request counts regardless of configured model order`() throws { + // The heavier-traffic route ("primary-tier") is configured SECOND in /router/models, + // and the lighter one ("secondary-tier") FIRST — proving nothing in the summary is + // derived from array position (the gateway's config order is not a semantic signal). + let reorderedModels = Data(""" + {"models":[{"name":"secondary-tier","endpoint":"http://127.0.0.1:9102/v1",\ + "model":"stand-in-large","api_key_env":"RIG_CLOUD_KEY","key_ok":true},\ + {"name":"primary-tier","endpoint":"http://127.0.0.1:9101/v1","model":"stand-in-small",\ + "api_key_env":null,"key_ok":true}],"dry_run":false} + """.utf8) + let snapshot = try Self.makeSnapshot(modelsData: reorderedModels) + + #expect(snapshot.routedSummary == "local: 10 · cloud: 4") + #expect(snapshot.routes.first?.name == "local") + } + + @Test + func `routed summary uses the gateway's own route names, not a hardcoded local or cloud label`() throws { + // Route names are whatever the user named their endpoints in the Wayfinder config — + // there is no "local"/"cloud" semantic anywhere in the gateway's JSON. + let customNamedSavings = Data(""" + {"period_days":30,"unit":"usd","priced":true,"requests":14,"estimated_requests":0,\ + "tokens":1028,"realized":0.003558,"baseline":0.009252,"saved":0.005694,"saved_pct":61.5,\ + "by_route":{"groq-8b":{"requests":10,"realized":0.000264,"baseline":0.005958,\ + "saved":0.005694,"tokens":662},"openai-o1":{"requests":4,"realized":0.003294,\ + "baseline":0.003294,"saved":0.0,"tokens":366}},"by_key":{},\ + "price_table_version":"a3db80fd9a78"} + """.utf8) + let snapshot = try Self.makeSnapshot(savingsData: customNamedSavings) + let summary = try #require(snapshot.routedSummary) + + #expect(summary == "groq-8b: 10 · openai-o1: 4") + #expect(!summary.contains("local")) + #expect(!summary.contains("cloud")) + } + + // MARK: - Helpers + + private static func makeSnapshot( + healthData: Data = Self.healthOK, + modelsData: Data = Self.models, + savingsData: Data = Self.savings30d, + metricsText: String? = Self.metricsText) throws -> WayfinderUsageSnapshot + { + try WayfinderUsageFetcher._makeSnapshotForTesting( + healthData: healthData, + modelsData: modelsData, + savingsData: savingsData, + metricsText: metricsText, + updatedAt: Date(timeIntervalSince1970: 1)) + } + + private final class RequestLog: @unchecked Sendable { + private let lock = NSLock() + private var urls: [URL] = [] + + func append(_ url: URL) { + self.lock.lock() + defer { self.lock.unlock() } + self.urls.append(url) + } + + func paths() -> [String] { + self.lock.lock() + defer { self.lock.unlock() } + return self.urls.map(\.path) + } + + func queries() -> [String] { + self.lock.lock() + defer { self.lock.unlock() } + return self.urls.compactMap(\.query) + } + } + + // MARK: - Fixtures (captured from a live gateway) + + private static let healthOK = Data(""" + {"status":"ok","models":["cloud","local"],"offline":false} + """.utf8) + + private static let healthDegraded = Data(""" + {"status":"degraded","models":["cloud","local"],"offline":false,"missing_keys":["cloud"]} + """.utf8) + + private static let models = Data(""" + {"models":[{"name":"local","endpoint":"http://127.0.0.1:9101/v1","model":"stand-in-small",\ + "api_key_env":null,"key_ok":true},{"name":"cloud","endpoint":"http://127.0.0.1:9102/v1",\ + "model":"stand-in-large","api_key_env":"RIG_CLOUD_KEY","key_ok":true}],"dry_run":false} + """.utf8) + + private static let savings30d = Data(""" + {"period_days":30,"unit":"usd","priced":true,"requests":14,"estimated_requests":0,\ + "tokens":1028,"realized":0.003558,"baseline":0.009252,"saved":0.005694,"saved_pct":61.5,\ + "by_route":{"cloud":{"requests":4,"realized":0.003294,"baseline":0.003294,"saved":0.0,\ + "tokens":366},"local":{"requests":10,"realized":0.000264,"baseline":0.005958,\ + "saved":0.005694,"tokens":662}},"by_key":{},"price_table_version":"a3db80fd9a78"} + """.utf8) + + private static let savingsZeros = Data(""" + {"period_days":30,"unit":"usd","priced":true,"requests":0,"estimated_requests":0,\ + "tokens":0,"realized":0.0,"baseline":0.0,"saved":0.0,"saved_pct":0.0,"by_route":{},\ + "by_key":{},"price_table_version":"a3db80fd9a78"} + """.utf8) + + private static let savingsUnpriced = Data(""" + {"period_days":30,"unit":"relative","priced":false,"requests":5,"estimated_requests":0,\ + "tokens":420,"realized":1.8,"baseline":3.0,"saved":1.2,"saved_pct":40.0,\ + "by_route":{"local":{"requests":4,"realized":0.8,"baseline":2.0,"saved":1.2,"tokens":320},\ + "cloud":{"requests":1,"realized":1.0,"baseline":1.0,"saved":0.0,"tokens":100}},\ + "by_key":{},"price_table_version":"a3db80fd9a78"} + """.utf8) + + private static let metricsText = """ + # HELP wayfinder_router_requests_total Routed requests by model and mode. + # TYPE wayfinder_router_requests_total counter + wayfinder_router_requests_total{model="local",mode="scored"} 10 + wayfinder_router_requests_total{model="cloud",mode="scored"} 4 + # HELP wayfinder_router_decision_latency_seconds Time to score a prompt and pick a model (no model call). + # TYPE wayfinder_router_decision_latency_seconds histogram + wayfinder_router_decision_latency_seconds_bucket{le="0.0001"} 13 + wayfinder_router_decision_latency_seconds_bucket{le="0.00025"} 14 + wayfinder_router_decision_latency_seconds_bucket{le="+Inf"} 14 + wayfinder_router_decision_latency_seconds_sum 0.00112602 + wayfinder_router_decision_latency_seconds_count 14 + """ +} diff --git a/TestsLinux/ZaiUsedPercentLinuxTests.swift b/TestsLinux/ZaiUsedPercentLinuxTests.swift new file mode 100644 index 0000000000..481a475a31 --- /dev/null +++ b/TestsLinux/ZaiUsedPercentLinuxTests.swift @@ -0,0 +1,52 @@ +#if os(Linux) +import CodexBarCore +import Foundation +import Testing + +struct ZaiUsedPercentLinuxTests { + /// usage == nil forces computedUsedPercent to return nil, exercising the raw-percentage fallback. + private func fallbackUsedPercent(_ percentage: Double) -> Double { + ZaiLimitEntry( + type: .tokensLimit, + unit: .hours, + number: 5, + usage: nil, + currentValue: nil, + remaining: nil, + percentage: percentage, + usageDetails: [], + nextResetTime: nil).usedPercent + } + + @Test + func `raw percentage fallback clamps above 100`() { + #expect(self.fallbackUsedPercent(150) == 100) + } + + @Test + func `raw percentage fallback clamps below 0`() { + #expect(self.fallbackUsedPercent(-5) == 0) + } + + @Test + func `raw percentage fallback preserves an in-range value`() { + #expect(self.fallbackUsedPercent(42) == 42) + } + + @Test + func `computed path takes precedence and ignores the raw percentage`() { + // usage(limit)=100, currentValue(used)=25 -> computed 25%, so the raw 999 must not leak. + let entry = ZaiLimitEntry( + type: .tokensLimit, + unit: .hours, + number: 5, + usage: 100, + currentValue: 25, + remaining: nil, + percentage: 999, + usageDetails: [], + nextResetTime: nil) + #expect(entry.usedPercent == 25) + } +} +#endif diff --git a/WidgetExtension/CodexBarWidgetExtension.xcodeproj/project.pbxproj b/WidgetExtension/CodexBarWidgetExtension.xcodeproj/project.pbxproj index 83a8c7f525..ae91927b36 100644 --- a/WidgetExtension/CodexBarWidgetExtension.xcodeproj/project.pbxproj +++ b/WidgetExtension/CodexBarWidgetExtension.xcodeproj/project.pbxproj @@ -9,18 +9,23 @@ /* Begin PBXBuildFile section */ 0972D036B563954337344F35 /* CodexBarWidgetBundle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50E5C7D39315A8DA5DC9D18A /* CodexBarWidgetBundle.swift */; }; 49DB3749D8E8748409CDC4FE /* CodexBarWidgetProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 549C61629C144C190B18EAD9 /* CodexBarWidgetProvider.swift */; }; + 6AE8A91F50B5CE058EC3F7C3 /* BurnDownWidgetViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = A57F0B3D6EEDEFD75EE3F3A0 /* BurnDownWidgetViews.swift */; }; 6F12082A467310EEDD1F3439 /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E430B27E4F28973A5E77EA3F /* WidgetKit.framework */; }; + 795FB218DC9B1C0909B4D202 /* CombinedBurnDownWidgetViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1AFF48072AC06EBE060E3AFE /* CombinedBurnDownWidgetViews.swift */; }; 7A3A654DC5A5B85C9C41EA02 /* CodexBarCore in Frameworks */ = {isa = PBXBuildFile; productRef = 140C60DAC1DE9A8AE19E58FE /* CodexBarCore */; }; 7F0E34471853E41206F690FB /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 02F15F392AF9D502F0503F8F /* SwiftUI.framework */; }; 882A41814588292DD631F525 /* CodexBarWidgetViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84672F595D2C0B83323E2C54 /* CodexBarWidgetViews.swift */; }; + D1B3B06F03A2F05251AC0B42 /* BurnDownWidgetProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = B99BBE05D3817388F9649AD0 /* BurnDownWidgetProvider.swift */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ 02F15F392AF9D502F0503F8F /* SwiftUI.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SwiftUI.framework; path = System/Library/Frameworks/SwiftUI.framework; sourceTree = SDKROOT; }; + 1AFF48072AC06EBE060E3AFE /* CombinedBurnDownWidgetViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CombinedBurnDownWidgetViews.swift; sourceTree = ""; }; 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; }; + A57F0B3D6EEDEFD75EE3F3A0 /* BurnDownWidgetViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BurnDownWidgetViews.swift; sourceTree = ""; }; + B99BBE05D3817388F9649AD0 /* BurnDownWidgetProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BurnDownWidgetProvider.swift; sourceTree = ""; }; 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; }; /* End PBXFileReference section */ @@ -42,7 +47,6 @@ 4FAD1E2FCD6C4AC65D308ABC /* Packages */ = { isa = PBXGroup; children = ( - 9FA0A78FB7CA1D877E7BA54B /* codexbar */, ); name = Packages; sourceTree = ""; @@ -60,9 +64,12 @@ B37422CB8DFAAFC8B3B8C1B6 /* CodexBarWidget */ = { isa = PBXGroup; children = ( + B99BBE05D3817388F9649AD0 /* BurnDownWidgetProvider.swift */, + A57F0B3D6EEDEFD75EE3F3A0 /* BurnDownWidgetViews.swift */, 50E5C7D39315A8DA5DC9D18A /* CodexBarWidgetBundle.swift */, 549C61629C144C190B18EAD9 /* CodexBarWidgetProvider.swift */, 84672F595D2C0B83323E2C54 /* CodexBarWidgetViews.swift */, + 1AFF48072AC06EBE060E3AFE /* CombinedBurnDownWidgetViews.swift */, ); name = CodexBarWidget; path = ../Sources/CodexBarWidget; @@ -145,9 +152,12 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + D1B3B06F03A2F05251AC0B42 /* BurnDownWidgetProvider.swift in Sources */, + 6AE8A91F50B5CE058EC3F7C3 /* BurnDownWidgetViews.swift in Sources */, 0972D036B563954337344F35 /* CodexBarWidgetBundle.swift in Sources */, 49DB3749D8E8748409CDC4FE /* CodexBarWidgetProvider.swift in Sources */, 882A41814588292DD631F525 /* CodexBarWidgetViews.swift in Sources */, + 795FB218DC9B1C0909B4D202 /* CombinedBurnDownWidgetViews.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/WidgetExtension/CodexBarWidgetExtension.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/WidgetExtension/CodexBarWidgetExtension.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 0000000000..37ef8e0663 --- /dev/null +++ b/WidgetExtension/CodexBarWidgetExtension.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,77 @@ +{ + "originHash" : "2c34a752ff5b5315e5bae001ccb84dbfa3f7ee7793a23b8862dbc8d096d771ba", + "pins" : [ + { + "identity" : "commander", + "kind" : "remoteSourceControl", + "location" : "https://github.com/steipete/Commander", + "state" : { + "revision" : "b9fb00564aa3229deeb48090801fec2c185951f4", + "version" : "0.2.3" + } + }, + { + "identity" : "keyboardshortcuts", + "kind" : "remoteSourceControl", + "location" : "https://github.com/sindresorhus/KeyboardShortcuts", + "state" : { + "revision" : "1aef85578fdd4f9eaeeb8d53b7b4fc31bf08fe27", + "version" : "2.4.0" + } + }, + { + "identity" : "sparkle", + "kind" : "remoteSourceControl", + "location" : "https://github.com/sparkle-project/Sparkle", + "state" : { + "revision" : "b6496a74a087257ef5e6da1c5b29a447a60f5bd7", + "version" : "2.9.4" + } + }, + { + "identity" : "sweetcookiekit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/steipete/SweetCookieKit", + "state" : { + "revision" : "21bedea672a3e63ccad24d744051e76cdf0462dd", + "version" : "0.4.1" + } + }, + { + "identity" : "swift-asn1", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-asn1.git", + "state" : { + "revision" : "a9a5efd40eaf558a2bcd48d64b1d1646be686008", + "version" : "1.7.1" + } + }, + { + "identity" : "swift-crypto", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-crypto.git", + "state" : { + "revision" : "95ba0316a9b733e92bb6b071255ff46263bbe7dc", + "version" : "3.15.1" + } + }, + { + "identity" : "swift-log", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-log", + "state" : { + "revision" : "a878e7f8f46cfc0e1125e565b5c08e7d5272dc9a", + "version" : "1.14.0" + } + }, + { + "identity" : "vortex", + "kind" : "remoteSourceControl", + "location" : "https://github.com/zats/Vortex", + "state" : { + "revision" : "ef5392088d4aeb255c4eee83157dbdafcd31bf07" + } + } + ], + "version" : 3 +} diff --git a/appcast.xml b/appcast.xml index c0df2ebba3..ccf78e45bf 100644 --- a/appcast.xml +++ b/appcast.xml @@ -3,97 +3,122 @@ CodexBar - 0.31.0 - Thu, 28 May 2026 23:11:46 +0100 + 0.46.0 + Wed, 29 Jul 2026 05:17:14 -0700 https://raw.githubusercontent.com/steipete/CodexBar/main/appcast.xml - 73 - 0.31.0 + 110 + 0.46.0 14.0 - CodexBar 0.31.0 -

    Changed

    + CodexBar 0.46.0 +

    Added

      -
    • Docs: update the Homebrew install command to use the official codexbar cask now that it supports Intel Macs (#1189). Thanks @SSakutaro!
    • -
    • Tests: document and audit that routine validation must not trigger macOS Keychain prompts.
    • -
    • Localization: localize popup panels and provider settings UI across supported languages (#1181). Thanks @jack24254029!
    • -
    • Localization: complete Brazilian Portuguese coverage so pt-BR no longer falls back to English for new UI strings (#1188). Thanks @ManuzimFerreira!
    • +
    • Qwen Cloud: new provider for Individual Token Plans with 5-hour and weekly rolling windows (#2361). Thanks @umutkeltek, and @Yach0 for the API investigation!
    • +
    • ZoomMate: new provider with credits, session history, and pacing, using host-scoped cookie routing (#2344). Thanks @weddle!
    • +
    • Alibaba: Personal/Solo Token Plan variants for mainland (Bailian) and international (Model Studio) accounts (#2487). Thanks @LeoLin990405 and @halilertekin for the investigations!
    • +
    • Claude: show prepaid credit balance in cost surfaces, using only cached or manually configured web sessions (#2443). Thanks @Zihao-Qi!
    • +
    • Claude: setting to hide the Daily Routines row (#2358, fixes #2353). Thanks @Zihao-Qi and @tavlean!
    • +
    • Codex: local Workspaces indexing foundation for per-workspace usage attribution (#2456). Thanks @AmrMohamad!
    • +
    • Menu: fractional session quota estimates with a condensed weekly forecast row (#2357). Thanks @Zihao-Qi!
    -

    Added

    +

    Changed

      -
    • AWS Bedrock: support resolving usage and cost-history credentials from a named AWS profile via the AWS CLI (#1190). Thanks @oleksandr-soldatov!
    • -
    • Codex: show Codex Spark model-specific usage as an optional extra quota lane (#1195, fixes #1177). Thanks @LeoLin990405!
    • -
    • Localization: add Swedish as a selectable app language (#1186). Thanks @yeager!
    • +
    • CLI: config dump now redacts stored credentials by default; --show-secrets restores raw output (#2410, fixes #2400). Thanks @Yuxin-Qiao!

    Fixed

      -
    • 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!
    • -
    • Claude: hide the obsolete Design quota lane now that Claude Design shares the main Claude usage limit (#1197).
    • -
    • Menu bar: coalesce visible-menu rebuilds and reduce hover highlight work so the dropdown stays responsive on macOS 26.5 (#1196).
    • +
    • Keychain: disabling Keychain access no longer breaks Cursor and Claude refresh — cookie caches fall back to memory only, and background Claude checks cannot prompt (#2426, fixes #2408 and #2425). Thanks @gmkbenjamin!
    • +
    • Claude: keep the switcher bar on the account Weekly quota instead of exhausted model carve-outs (#2424, fixes #2423). Thanks @gmkbenjamin!
    • +
    • Claude: profile-scoped credential caching so multiple Claude profiles cannot reuse each other's cached credentials, with safe legacy migration (#2484, part of #2380). Thanks @ProspectOre!
    • +
    • Codex: bound cost scans on giant session corpora with resumable parsing — huge rollouts no longer pin a CPU core and still count fully toward cost history (#2452). Thanks @D4ilyHub!
    • +
    • Menu bar: center stacked two-line custom layouts vertically (#2347, fixes #2345). Thanks @kiranmagic7, and @lg for the measured report!
    • +
    • Menu bar: stale --hook-event launches from other CodexBar installations no longer create duplicate menu bar items (#2416). Thanks @uclort!
    • +
    • Widgets: prevent a WidgetKit reload loop that caused sustained chronod disk writes near quota resets (#2371). Thanks @Yuxin-Qiao and @cskeleton!
    • +
    • Widgets: remove an unintended dark background overlay (#2354). Thanks @jarvisluk!
    • +
    • Widgets: Claude enterprise spend-cap accounts now persist their extra-usage row instead of synthetic Session/Weekly rows (#2478). Thanks @ChenZiHong-Gavin!
    • +
    • Claude: hide the Daily Routines row entirely when Anthropic returns a null routines payload (#2450). Thanks @urda!
    • +
    • Claude: show model-scoped weekly rows above Daily Routines (#2461, fixes #2460). Thanks @Eimerrrrr!
    • +
    • Claude: tolerate garbled "all models" captures so duplicated weekly rows no longer appear (#2434). Thanks @guhyun9454!
    • +
    • Amp: parse subscription plans (Megawatt) into proper percentage windows instead of a misleading cookie error (#2438, fixes #2435). Thanks @tylergibbs1 and @diegomrv!
    • +
    • Grok: explicit cookie-refresh imports browser cookies and caches validated sessions for background reuse (#2458). Thanks @olddonkey!
    • +
    • Kimi: reliable weekly and API-derived window durations now feed pace and forecasts (#2433). Thanks @harjothkhara!
    • +
    • Chutes: render quota counts as detail text instead of misreading them as reset schedules (#2402, fixes #2399). Thanks @kiranmagic7!
    • +
    • Alibaba/Qwen: allow Token Plan usage on Linux with a manual cookie (#2356). Thanks @OfficialAbhinavSingh!
    • +
    • LongCat: automatic cookie import falls back to Firefox after Chrome (#2462, fixes #2463). Thanks @akshayprabhu200!
    • +
    • Hooks: preserve configured hooks across config saves (#2436, fixes #2432). Thanks @kiranmagic7!
    • +
    • Menu: prioritize exhausted windows for automatic display while preserving the Antigravity preference (#2352). Thanks @Yuxin-Qiao!
    • +
    • Menu bar: refresh custom Account labels after account changes (#2362). Thanks @kiranmagic7!
    • +
    • Usage: keep the learned full-session estimate visible while the session window is idle (#2336). Thanks @Zihao-Qi!
    • +
    • Resets: show the day form at exactly 24 hours in countdowns (#2343). Thanks @OfficialAbhinavSingh!
    • +
    • z.ai: clamp the raw-percentage fallback to 0–100 (#2342). Thanks @OfficialAbhinavSingh!
    • +
    • LLMProxy: skip already-elapsed reset times when picking the next reset (#2335). Thanks @OfficialAbhinavSingh!
    • +
    • Ollama: reuse validated browser sessions across refreshes, and skip inaccessible Safari cookies during automatic
    +

    fallback while preserving explicit Safari permission guidance (#2404). Thanks @hxy91819!

    View full changelog

    ]]>
    - +
    - 0.30.1 - Thu, 28 May 2026 07:56:49 +0100 + 0.45.2 + Sun, 19 Jul 2026 18:23:42 +0100 https://raw.githubusercontent.com/steipete/CodexBar/main/appcast.xml - 72 - 0.30.1 + 109 + 0.45.2 14.0 - CodexBar 0.30.1 -

    Changed

    -
      -
    • CLI: make codexbar diagnose use a generic safe provider diagnostic export for all providers, with MiniMax details attached only as provider-specific metadata.
    • -
    + CodexBar 0.45.2

    Fixed

      -
    • Settings: add trailing breathing room to provider-sidebar controls (#1183). Thanks @Yuxin-Qiao!
    • -
    • Claude: treat OAuth usage HTTP 429s as rate limits, preserve cached credentials, and back off background retries while still allowing manual refresh (#1179). Thanks @LeoLin990405!
    • -
    • Menu bar: stop repeated display-change status-item recreation from corrupting Control Center or confusing menu bar managers (#1176, fixes #1175). Thanks @diazdesandi!
    • +
    • Refresh: prevent macOS 14 launch crashes caused by TaskLocal task-allocation corruption (#2341, fixes #2319 and #2326). Thanks @lzylzylzy130 and @jorgesancha!
    • +
    • Menu bar: render custom-layout provider icons at the native size and tint them for light and dark menu bars (#2334). Thanks @elpinguinofrio!
    • +
    • Menu: fix switcher “Weekly progress” to prefer weekly quota windows, with provider-specific fallback when unavailable (#2327). Thanks @Anneo22!
    • +
    • Command Code: improve progress-bar contrast in dark mode (#2333). Thanks @Baksalyar!
    • +
    • Widgets: keep cost rows on one line with large token counts (#2337). Thanks @zhulijin1991!
    • +
    • OpenCode/OpenCode Go: preserve computed sub-1% usage percentages instead of rescaling them as direct fractions (#2331). Thanks @OfficialAbhinavSingh!
    • +
    • OpenCode Go: prefer local usage for unscoped Auto refreshes while keeping account- and workspace-scoped requests web-first (#2316). Thanks @kiranmagic7!

    View full changelog

    ]]>
    - +
    - 0.30.0 - Wed, 27 May 2026 07:04:18 +0100 + 0.45.1 + Sun, 19 Jul 2026 08:59:53 +0100 https://raw.githubusercontent.com/steipete/CodexBar/main/appcast.xml - 71 - 0.30.0 + 108 + 0.45.1 14.0 - CodexBar 0.30.0 + CodexBar 0.45.1

    Added

      -
    • MiniMax: add a redacted diagnostic CLI export for safe issue reports (#1128). Thanks @Yuxin-Qiao!
    • -
    • Antigravity: show the complete per-model quota breakdown alongside the existing summary lanes (#1139). Thanks @guhyun9454!
    • -
    • Widget: show tertiary usage rows for providers that expose a third quota lane (#1160). Thanks @LeoLin990405!
    • -
    • DeepSeek: show optional web-session usage and cost summaries alongside the balance card (#1166). Thanks @Yuxin-Qiao!
    • -
    • OpenAI: scope Admin API usage to the configured project and keep token accounts from inheriting stale project filters (#1168). Thanks @mstallone!
    • +
    • Claude: show per-model weekly claude-swap usage windows from schema-v1 account listings (#2310). Thanks @AlexGodard!
    • +
    • Claude: allow an opt-in claude-swap card when only one account is available (#2280). Thanks @possibilities!
    • +
    • OpenCode Go: add daily local cost and plan-usage history (#2296). Thanks @kentoku24!
    • +
    • Overview: raise the merged provider limit from three to six (#2314). Thanks @BobbyWang0120!
    • +
    +

    Changed

    +
      +
    • Menu bar: remove status-item hover tooltips to match macOS menu extras, keeping VoiceOver titles (#2315). Thanks @BobbyWang0120!
    • +
    • Codex: simplify cost labels to "Cost" and move the reported-versus-estimated explanation into Cost settings, keeping a short per-value estimate note (#2313). Thanks @Zihao-Qi!

    Fixed

      -
    • App shutdown: detach status items, close tracked menus, and cancel menu tasks before quit so Dock autohide stays responsive on macOS 26.5 (#1174). Thanks @jskoiz!
    • -
    • Widgets: package the macOS widget as a real Xcode app-extension target so WidgetKit descriptors load on macOS 26.5 (#1095). Thanks @jamesjlopez!
    • -
    • Menu: render quota-warning markers as subtle inset ticks instead of full-height bars (#1149).
    • -
    • Codex: show sign-in guidance when the Codex CLI is logged out instead of reporting a temporary usage outage (#1171, fixes #1170). Thanks @jskoiz!
    • -
    • Menu bar: clear stale hidden macOS status-item visibility defaults once before creating CodexBar items (#1169).
    • -
    • StepFun: refresh expired Oasis tokens and persist recovered manual sessions. Thanks @LeoLin990405!
    • -
    • Release: prevent manual CLI artifact builds from publishing or clobbering release assets (#1154). Thanks @jskoiz!
    • -
    • Cost history: route OpenAI and Mistral API spend through the shared cost-history cards, including OpenAI request counts (#1163). Thanks @LeoLin990405!
    • -
    • Menu: keep provider switcher Cmd-number and arrow shortcuts working while the open menu is tracking events (#1157, fixes #1156 and #1144). Thanks @anirudhvee!
    • -
    • Codex: prevent fork token replay from overcounting corrected cumulative session totals (#1164). Thanks @xx205!
    • -
    • Alibaba Token Plan: update usage refreshes to the Bailian subscription-summary endpoint (#1142). Thanks @YanxinXue!
    • -
    • Ollama: show pace projections for documented 5-hour session and 7-day weekly usage windows (#1136). Thanks @bdamokos!
    • -
    • Localization: polish Simplified Chinese wording and add notification strings (#1165). Thanks @fanfanci!
    • -
    • Localization: improve Traditional Chinese wording and localize notification copy (#1158). Thanks @jack24254029!
    • -
    • Localization: improve Simplified Chinese visible menu, dashboard, and usage labels (#1145). Thanks @Yuxin-Qiao!
    • +
    • StepFun: fix password login web ID derivation so the header and cookie match the anonymous token (#2312). Thanks @Zihao-Qi!
    • +
    • Menu bar: refresh custom cost tokens when token-cost data changes (#2305). Thanks @Zihao-Qi!
    • +
    • Menu bar: refresh custom reset tokens at their displayed time boundaries (#2303). Thanks @Zihao-Qi!
    • +
    • Usage: normalize session-equivalent forecasts against aligned partial-session samples so extrapolated weekly burn is not overstated (#2301). Thanks @Zihao-Qi!
    • +
    • Usage: align current/latest and historical cost/token metrics by period (#2295). Thanks @RoshanMhatre!
    • +
    • Codex: exclude parent-copied prefixes from compact subagent usage when the fork boundary matches the parent snapshot (#2285). Thanks @hhh2210!
    • +
    • Usage & Spend: fix black share-card PNG exports while keeping rendering compatible with Intel Macs (#2292). Thanks @Chipagosfinest!
    • +
    • Usage & Spend: keep complete model rows visible when another same-currency source has incomplete history (#2308). Thanks @Chipagosfinest!
    • +
    • ElevenLabs: clamp character and voice-slot usage percentages at 100% during overage (#2293). Thanks @OfficialAbhinavSingh!
    • +
    +

    Internal

    +
      +
    • Serialize the Claude CLI platform-gating cases to prevent nondeterministic Linux CI failures (#2311). Thanks @Chipagosfinest!

    View full changelog

    ]]>
    - +
    0.14.0 diff --git a/bin/install-codexbar-cli.sh b/bin/install-codexbar-cli.sh index 50fb953377..742a974e2f 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/codexbar.png b/codexbar.png deleted file mode 100644 index feb52be248..0000000000 Binary files a/codexbar.png and /dev/null differ diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 4a687f4230..544092eb93 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -16,7 +16,7 @@ read_when: # Full build, package, and launch (recommended) ./Scripts/compile_and_run.sh -# Also run swift test before packaging/relaunching +# Also run the sharded test suite before packaging/relaunching ./Scripts/compile_and_run.sh --test # Just build and package (no tests) @@ -63,7 +63,8 @@ browser-cookie web path. The web path reuses cached cookies when possible and im the cache is missing or rejected. ### Refresh Frequency -- Default: Every 5 minutes (configurable in Preferences → General) +- Fresh-install default: Adaptive, between 2 and 30 minutes (configurable in Preferences → General). Existing installs + without a stored cadence retain the legacy 5-minute fallback. - Minimum: 1 minute - Cookie import happens automatically when cached cookies need refresh @@ -78,32 +79,46 @@ If automatic import fails: ## Project Structure +Key source, test, and packaging paths (not exhaustive): + ``` CodexBar/ ├── Sources/CodexBar/ # Main app (SwiftUI + AppKit) -│ ├── CodexBarApp.swift # App entry point -│ ├── StatusItemController.swift # Menu bar icon -│ ├── UsageStore.swift # Usage data management -│ ├── SettingsStore.swift # User preferences -│ ├── Providers/ # Provider-specific code -│ │ ├── Augment/ # Augment Code integration -│ │ ├── Claude/ # Anthropic Claude -│ │ ├── Codex/ # OpenAI Codex -│ │ └── ... -│ └── KeychainMigration.swift # One-time keychain migration -├── Sources/CodexBarCore/ # Shared business logic -├── Tests/CodexBarTests/ # XCTest suite +│ ├── CodexbarApp.swift # App entry point +│ ├── StatusItemController*.swift # Menu bar icon, menu rendering, and actions +│ ├── UsageStore*.swift # Usage refresh, caching, widgets, and history +│ ├── SettingsStore*.swift # User preferences and config persistence +│ ├── Providers/ # App-side provider settings/runtime glue +│ └── Resources/ # Assets and localized strings +├── Sources/CodexBarCore/ # Shared business logic used by app, CLI, and widgets +│ ├── Config/ # Config file model, reader, writer, and validation +│ ├── Providers/ # Provider descriptors, fetchers, parsers, and status probes +│ ├── OpenAIWeb/ # OpenAI dashboard integration helpers +│ ├── WebKit/ # Web session helpers +│ └── Vendored/ # Embedded support code +├── Sources/CodexBarCLI/ # Bundled codexbar command-line tool +├── Sources/CodexBarWidget/ # WidgetKit support +├── WidgetExtension/ # Xcode wrapper for the packaged widget extension +├── Tests/CodexBarTests/ # macOS app/core test suite (XCTest + Swift Testing) +├── TestsLinux/ # Linux-specific CLI/core test coverage └── Scripts/ # Build and packaging scripts ``` ## Common Tasks ### Add a New Provider -1. Add a `UsageProvider` case in `Sources/CodexBarCore/Providers/Providers.swift` -2. Add core descriptor/fetcher wiring under `Sources/CodexBarCore/Providers/YourProvider/` -3. Add app-side implementation under `Sources/CodexBar/Providers/YourProvider/` -4. Register the implementation in `ProviderImplementationRegistry` -5. Add icon assets such as `Resources/ProviderIcon-yourprovider.svg` +See the canonical [provider authoring guide](provider.md#adding-a-new-provider-current-flow) for the complete flow. + +1. Add the provider identity to `Sources/CodexBarCore/Providers/Providers.swift`. +2. Add the descriptor and the fetcher, parser, settings-reader, or status-probe pieces the provider needs under + `Sources/CodexBarCore/Providers/YourProvider/`. +3. Register the descriptor from `Sources/CodexBarCore/Providers/ProviderDescriptor.swift`. +4. Add an app-side `ProviderImplementation` under `Sources/CodexBar/Providers/YourProvider/`; implementations can use + protocol defaults when no custom UI or macOS integration is needed. +5. Add the provider's exhaustive switch case to + `Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift`. +6. Add icon assets under `Sources/CodexBar/Resources/`. +7. Add focused tests under `Tests/CodexBarTests/` and, for CLI/core behavior that must run on Linux, `TestsLinux/`. ### Debug Cookie Issues 1. Enable Debug → Logging → "Enable file logging" or raise verbosity in the app settings. @@ -114,7 +129,7 @@ CodexBar/ ### Run Tests Only ```bash -swift test +make test ``` ### Format Code @@ -128,7 +143,7 @@ swiftlint --strict ### Local Development Build ```bash ./Scripts/package_app.sh -# Creates: CodexBar.app (Developer ID by default; set CODEXBAR_SIGNING=adhoc for ad-hoc signing) +# Creates: CodexBar.app with ad-hoc signing by default ``` ### Release Build (Notarized) @@ -166,6 +181,22 @@ log show --predicate 'category == "keychain-migration"' --last 5m 3. Check Preferences → Providers → Augment → Cookie source is "Automatic" 4. Enable debug logging and check Console.app +### Main-Thread Hangs + +Debug builds start the hang watchdog automatically. To diagnose a release build, +enable it explicitly and restart CodexBar: + +```bash +defaults write com.steipete.codexbar debugMainThreadHangWatchdog -bool true +``` + +Hangs are written to the app log. Hangs over two seconds also request a process +sample under `~/Library/Logs/CodexBar/`. Disable the release opt-in with: + +```bash +defaults delete com.steipete.codexbar debugMainThreadHangWatchdog +``` + ## Architecture Notes ### Menu Bar App Pattern diff --git a/docs/DEVELOPMENT_SETUP.md b/docs/DEVELOPMENT_SETUP.md index 48343efe95..ba7b47bc07 100644 --- a/docs/DEVELOPMENT_SETUP.md +++ b/docs/DEVELOPMENT_SETUP.md @@ -99,11 +99,16 @@ The build script creates `CodexBar.app` in the project root. Old numbered builds This script: 1. Kills existing CodexBar instances 2. Runs `swift build` (release mode) -3. Runs `swift test` (all tests) +3. Runs the sharded full test suite when `--test` is passed 4. Packages the app with `./Scripts/package_app.sh` 5. Launches `CodexBar.app` 6. Verifies it stays running +Launching an unbundled `CodexBar` executable, including SwiftPM builds using `.build` or a custom scratch path, disables +Keychain access for that process to avoid repeated password prompts. Use the packaged `CodexBar.app` when local +validation needs browser cookies or stored credentials; packaged app bundles keep their normal Keychain behavior +regardless of signing mode. + When the script falls back to ad-hoc signing, it preserves CodexBar-owned keychain state by default. That means you may still see keychain prompts for existing CodexBar cache entries, but allowing those prompts keeps the cached browser/OAuth state available across normal rebuilds. @@ -121,7 +126,7 @@ swift build -c release ### Run Tests Only ```bash -swift test +make test ``` ### Debug Build diff --git a/docs/FORK_QUICK_START.md b/docs/FORK_QUICK_START.md index 23cdb9c4e8..9168d37536 100644 --- a/docs/FORK_QUICK_START.md +++ b/docs/FORK_QUICK_START.md @@ -41,7 +41,7 @@ read_when: swift build # Run tests -swift test +make test # Format code swiftformat Sources Tests @@ -128,13 +128,13 @@ git push origin feature/my-feature ### Testing Changes ```bash # Run all tests -swift test +make test # Run specific test swift test --filter AugmentTests # Build and test together -./Scripts/compile_and_run.sh +./Scripts/compile_and_run.sh --test ``` ### Updating Documentation diff --git a/docs/FORK_SETUP.md b/docs/FORK_SETUP.md index d2b732a2ee..a40b64daa0 100644 --- a/docs/FORK_SETUP.md +++ b/docs/FORK_SETUP.md @@ -255,7 +255,7 @@ git cherry-pick git diff upstream/main # 4. Test -swift test +make test # 5. Push to your fork git push origin upstream-pr/fix-cursor-bonus diff --git a/docs/KEYCHAIN_FIX.md b/docs/KEYCHAIN_FIX.md index 3c96f05efc..1642e36d37 100644 --- a/docs/KEYCHAIN_FIX.md +++ b/docs/KEYCHAIN_FIX.md @@ -95,9 +95,11 @@ This is OS/keychain ACL behavior, not a `ThisDeviceOnly` migration issue. `Advanced -> Disable Keychain access` sets `debugDisableKeychainAccess` and flips `KeychainAccessGate.isDisabled`. Effects: -- Blocks keychain reads/writes in legacy stores. -- Disables keychain-backed cookie auto-import paths. -- Forces cookie source resolution to manual/off where applicable. +- Blocks keychain reads/writes in legacy stores and Claude CLI keychain bootstrap. +- Disables Chromium cookie auto-import paths that require Safe Storage keychain decryption (Safari/Firefox remain eligible). +- Keeps an in-process memory fallback only for `KeychainCacheStore` cookie session caches so Cursor (and other cookie providers) can still reconcile sessions without Keychain persistence. OAuth credential cache entries are never retained by this fallback. +- Clears that in-process fallback whenever Keychain access is toggled, so disabled-mode cookies cannot resurface after re-enabling Keychain. +- Allows Claude Auto **background** CLI when Keychain access is disabled only after a successful user-initiated CLI refresh establishes availability for the current app process. Background Auto never launches `claude auth status --json`; before foreground establishment it falls through without starting any Claude child process. When Keychain remains enabled, background Auto also requires prompt mode **Always**. ## Verification diff --git a/docs/RELEASING.md b/docs/RELEASING.md index 5c245d3a6f..e4b08bd581 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -73,12 +73,12 @@ Uploads not handled automatically—commit/publish appcast + zip to the feed loc CodexBar ships a Homebrew **Cask** in `../homebrew-tap`. When installed via Homebrew, CodexBar disables Sparkle and the app must be updated via `brew`. -After publishing the GitHub release, `.github/workflows/release-cli.yml` builds the CLI tarballs, uploads `CodexBarCLI-v-{macos-arm64,macos-x86_64,linux-aarch64,linux-x86_64}.tar.gz` plus checksums, then dispatches the Homebrew tap update for both the CLI formula and app cask. If the final dispatch is rate-limited, the tarballs and app zip may still be present; rerun or manually update the tap formula/cask from the published assets. +After publishing the GitHub release, `.github/workflows/release-cli.yml` builds the macOS, glibc Linux, and static musl Linux CLI tarballs for arm64 and x86_64, uploads them plus checksums, then dispatches the Homebrew tap update for both the CLI formula and app cask. Homebrew continues to use the glibc Linux assets. If the final dispatch is rate-limited, the tarballs and app zip may still be present; rerun or manually update the tap formula/cask from the published assets. ## Checklist (quick) - [ ] Read both this file and `~/Projects/agent-scripts/docs/RELEASING-MAC.md`; resolve any conflicts toward CodexBar’s specifics. - [ ] Update versions (scripts/Info.plist, CHANGELOG, About text) — changelog top section must be finalized; release script pulls notes from it automatically. -- [ ] `swiftformat`, `swiftlint`, `swift test` (zero warnings/errors) +- [ ] `swiftformat`, `swiftlint`, `make test` (zero warnings/errors) - [ ] `./Scripts/build_icon.sh` if icon changed - [ ] `./Scripts/sign-and-notarize.sh` - [ ] Generate Sparkle appcast via `Scripts/release.sh` or `Scripts/make_appcast.sh`; use `SPARKLE_PRIVATE_KEY_FILE` only if overriding Keychain signing. diff --git a/docs/agent-sessions-design.md b/docs/agent-sessions-design.md new file mode 100644 index 0000000000..c74d770023 --- /dev/null +++ b/docs/agent-sessions-design.md @@ -0,0 +1,98 @@ +# Agent Sessions (prototype) + +Track live Codex + Claude Code agent sessions — local Mac first, other Macs on the tailnet second — and surface them in the CodexBar menu with click-to-focus of the owning terminal window. + +## Why in CodexBar + +CodexBar already parses `~/.claude/projects` JSONL (cost scanner) and ships a bundled CLI on macOS + Linux. Sessions reuse both: the local scanner feeds the menu UI, and the same scanner exposed as `codexbar sessions --json` is what remote Macs run over SSH. No daemon, no new app. + +## Data model (CodexBarCore) + +```swift +public struct AgentSession: Codable, Sendable, Identifiable { + public enum Provider: String, Codable, Sendable { case codex, claude } + public enum Source: String, Codable, Sendable { case cli, desktopApp, ide, unknown } + public enum State: String, Codable, Sendable { case active, idle } + + public var id: String // session UUID when resolvable, else "pid:" + public var provider: Provider + public var source: Source + public var state: State + public var pid: Int32? // nil for file-only (e.g. Codex desktop) sessions + public var cwd: String? + public var projectName: String? // last path component of cwd + public var startedAt: Date? + public var lastActivityAt: Date? // transcript mtime + public var transcriptPath: String? + public var host: String // local hostname, or remote host label +} +``` + +`active` = last activity ≤ 120 s ago. `idle` = live process (or recent file) with older activity. Constants live in one `SessionScanConfig` struct (activeWindow 120 s, fileOnlyWindow 30 min) so thresholds are tunable/testable. + +## Local scanner (CodexBarCore, no new deps) + +`LocalAgentSessionScanner` combines two signals: + +1. **Process scan** — parse `ps -axo pid=,ppid=,lstart=,command=`. + - Claude: command basename `claude` (skip obvious non-agent helpers). Source: path contains `Application Support/Claude/claude-code` → `.desktopApp`, else `.cli`. Deduplicate the wrapper/child pair (desktop spawns `disclaimer` parent + `claude` child with same argv; keep the child). + - Codex: basename `codex` with no `app-server` argument → `.cli` (TUI or `exec`). `codex app-server` marks the desktop app as present but is not itself a session. + - cwd per pid via one batched `lsof -a -d cwd -Fn -p ` call (parse `p`/`n` records). Failure → cwd nil, session still listed. +2. **Transcript correlation** + - Claude: cwd → `~/.claude/projects//` (escape: every non-alphanumeric ASCII → `-`), newest `*.jsonl` by mtime → session id (filename UUID), lastActivityAt (mtime). Also reuse `ClaudeDesktopProjectsLocator` roots so desktop local-agent-mode sessions resolve. + - Codex: enumerate `~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl` for today + yesterday (`$CODEX_HOME` respected). Read only the first line (`session_meta`: `session_id`, `cwd`, `originator`, `source`). File with mtime ≤ fileOnlyWindow and no matching live pid → file-only session, source from `originator` (`codex_exec`/`exec` → `.cli`; ide-ish originators → `.ide`; desktop → `.desktopApp`). Live `codex` pids match to rollouts by cwd (newest wins); unmatched live pid still listed with nil transcript. + - Never read more than the first line of any JSONL; never load whole transcripts. + +Scanner is `Sendable`, pure functions where possible; ps/lsof output parsing lives in dedicated parser types fed by strings so tests use fixtures. + +## CLI (CodexBarCLI) + +- `codexbar sessions` — table; `--json` — `[AgentSession]` (stable field names above; ISO-8601 dates). +- `codexbar sessions focus ` — macOS only: focus the session's terminal window (see Focus). Exit 1 if id unknown, 2 if focus failed. +- Follows existing `CLI*Command.swift` conventions. Works on Linux for listing (ps/proc paths guarded), focus is Darwin-only. + +## Remote hosts (CodexBarCore + app) + +`RemoteSessionFetcher`: + +- Host list = manual entries (settings, ssh destinations like `steipete@clawmac`) ∪ automatic Tailscale discovery (no-op when tailscale is absent): run `tailscale status --json` (PATH, then `/Applications/Tailscale.app/Contents/MacOS/Tailscale`), take online peers with `"OS": "macOS"|"linux"`, use first `DNSName` label as host. Local host excluded. +- Fetch per host (parallel, 5 s budget): `ssh -o BatchMode=yes -o ConnectTimeout=3 sh -lc 'codexbar sessions --json'` with fallback to the bundled app CLI path (resolve the canonical bundled location from `Scripts/package_app.sh` and hardcode it as fallback: `… || sessions --json`). Host errors are non-fatal: host shown as unreachable, others still render. +- Remote focus: fire-and-forget `ssh sh -lc 'codexbar sessions focus '`. +- Refresh: local scan every 30 s while the status item exists (cheap), remote every 60 s and immediately on menu open; both skipped when the feature is off. Reuse existing refresh loop plumbing rather than new timers if it fits. + +## Menu UI (CodexBar app) + +- New menu section **Agent Sessions (N)** (N = total, all hosts) above the settings/footer area, built through the existing `MenuDescriptor`-style seam so it's testable headless. +- Local sessions first, then one group per remote host (`clawmac — 2`, unreachable hosts greyed with a tooltip). Row: state dot (● active / ○ idle), provider glyph, `projectName — provider · source · 12m`. +- Click local row → `SessionWindowFocuser`. Click remote row → remote focus ssh call. +- Settings: "Sessions" group — a single enable toggle (default on) plus a manual hosts text field (comma-separated); Tailscale discovery is always on while the feature is enabled. Persist in `SettingsStore` like neighboring prefs. + +## Focus (macOS, app + CLI shared in Core or app-adjacent target) + +`SessionWindowFocuser`: + +1. pid → walk ppid chain to the nearest ancestor whose `NSRunningApplication.bundleIdentifier` is a known terminal/editor host: Ghostty, iTerm2, Apple Terminal, Warp, WezTerm, kitty, Alacritty, VS Code, Cursor, Zed, Claude desktop (`com.anthropic.claudefordesktop`). Fallback: the app owning the pid. +2. Activate the app, then AX (`AXUIElementCreateApplication` → `AXWindows`): raise the window whose title contains projectName or the cwd tail; fallback to frontmost window of that app. Requires Accessibility permission — call `AXIsProcessTrustedWithOptions` with prompt on first use; degrade gracefully (activate app only) when untrusted. +3. File-only sessions (no pid): Claude desktop → activate Claude.app; Codex desktop → activate Codex.app; otherwise no-op with log. + +tmux pane / terminal-tab precision is out of scope for the prototype. + +## Tests (Tests/CodexBarTests) + +Fixture-driven, no live processes, no Keychain/AX: + +- ps output parser: desktop `disclaimer`+`claude` dedupe, codex vs `codex app-server`, weird argv. +- lsof `-Fn` parser. +- Claude cwd escaping → project dir mapping; newest-jsonl selection (temp dirs). +- Codex rollout first-line parse → AgentSession (fixture JSONL), file-only window cutoff. +- Tailscale status JSON → host list (fixture; offline/iOS peers excluded). +- Sessions JSON round-trip (CLI output schema stability). +- Menu section descriptor: counts, grouping, unreachable-host rendering. + +## Non-goals (prototype) + +Claude.ai chat sessions; Codex cloud tasks; historical session browsing/analytics; "waiting on permission" state; tmux pane/tab focus; Bonjour/mDNS; persistent remote daemon or push transport; widget changes. No new SPM dependencies. + +## Proof + +`make check` clean; `make test` (or focused `swift test --filter` covering the new tests) green; `swift run CodexBarCLI sessions --json` produces plausible output on this Mac. diff --git a/docs/aiand.md b/docs/aiand.md new file mode 100644 index 0000000000..6660a94528 --- /dev/null +++ b/docs/aiand.md @@ -0,0 +1,84 @@ +--- +summary: "ai& provider: API key setup and 30-day spend summed from the request logs API." +read_when: + - Configuring ai& usage + - Debugging ai& request-log fetches +--- + +# ai& Provider + +CodexBar reads organization spend from ai&'s documented request-log API. ai& (aiand.com) is an OpenAI/Anthropic-compatible +inference gateway that can back Claude Code, Codex CLI, and opencode (all three have dedicated integration guides in +the ai& docs). + +## Authentication + +Create an API key in the [ai& console](https://console.aiand.com) (Settings → API Keys → Create). Keys use the `sk-` +prefix and are shown once at creation time. Add the key in CodexBar Settings → Providers → ai&. + +You can also set the environment variable: + +```bash +export AIAND_API_KEY="..." +``` + +Or configure it through the CLI: + +```bash +printf '%s' "$AIAND_API_KEY" | codexbar config set-api-key --provider aiand --stdin +``` + +## Data Source + +CodexBar requests: + +- `GET https://api.aiand.com/logs?range=30days&limit=100`, following `next_after`/`next_after_id` cursor pagination + (both cursors are always passed together, as the docs require) for up to 10 pages per refresh. + +Spend is the sum of each log row's `cost` field, parsed as decimal strings — never floating point — in the +organization's billing currency (`currency` per row: USD or JPY). Requests use `Authorization: Bearer `. +CodexBar does not read ai& browser cookies, console sessions, or inference prompts; only per-request cost/currency +metadata from the log rows is used. + +Why the log endpoint: as of 2026-07-17 the documented `cost_usd` field is missing from live +`GET /analytics/summary` responses (the endpoint returns only request/token counts and a token timeseries), and +`/analytics/metrics` has no cost series either. `/logs` matches its documentation exactly and is the only public +endpoint that reports cost, so CodexBar sums spend from it. + +## Display + +The menu shows the last 30 days of organization spend, in the organization's billing currency, as an API-spend row. +ai& bills prepaid credits with no quota windows, so no session or weekly meters are shown. The remaining credit balance +is only available in the ai& console; the public API does not expose it. + +Notes: + +- The billing currency is read from the log rows themselves — CodexBar never assumes a currency. If the organization + made no requests in the window there are no rows and no currency source, so no spend row is shown at all. +- ai& retains request logs for 30 days, which is exactly the summed window. +- CodexBar reads at most 10 pages (1,000 requests) per refresh. If the organization has more requests in the window, + the row is labeled "Last 30 days (partial)" and covers only the newest 1,000 requests — there is no silent + truncation. +- If log rows ever disagree on currency, only rows matching the newest row's currency are summed. +- API keys are organization-scoped: every key in the same organization reports the same org-wide spend. + +## CLI Usage + +```bash +codexbar usage --provider aiand +``` + +`ai&` and `ai-and` also work as provider aliases. + +## Troubleshooting + +- A `401` means ai& rejected the API key; create a new key in the console (keys are shown only once). +- A `402` means the organization is out of prepaid credits; top up at console.aiand.com. +- A `429` means the per-minute rate limit was hit; CodexBar retries on the next refresh cycle. +- A "(partial)" period label means the 10-page cap was hit; the total covers the newest 1,000 requests only. + +## Sources + +- [Request Logs](https://docs.aiand.com/analytics/logs/) +- [Authentication](https://docs.aiand.com/authentication/) +- [Credits & Top-Up](https://docs.aiand.com/billing/credits/) diff --git a/docs/alibaba-coding-plan.md b/docs/alibaba-coding-plan.md index 480204803a..834e62e385 100644 --- a/docs/alibaba-coding-plan.md +++ b/docs/alibaba-coding-plan.md @@ -42,7 +42,9 @@ When the RPC endpoint returns `ConsoleNeedLogin`, CodexBar treats that as a cons - Override host base: `ALIBABA_CODING_PLAN_HOST` - Example: `ALIBABA_CODING_PLAN_HOST=modelstudio.console.alibabacloud.com` - Override full quota URL: `ALIBABA_CODING_PLAN_QUOTA_URL` - - Example: `ALIBABA_CODING_PLAN_QUOTA_URL=https://example.com/data/api.json?action=...` + - Example: `ALIBABA_CODING_PLAN_QUOTA_URL=https://modelstudio.console.alibabacloud.com/data/api.json?action=...` +- Security policy: endpoint overrides are only accepted when they use `https://`, omit userinfo, and do not contain encoded host delimiters. Custom HTTPS proxy/test domains continue to work for compatibility, but `http://` endpoints are rejected so cookies and API credentials are not sent in cleartext. +- Strict provider-host mode: set `ALIBABA_CODING_PLAN_REQUIRE_PROVIDER_ENDPOINT_OVERRIDES=true` to additionally reject custom proxy/test domains and only accept the known Alibaba Coding Plan console and RPC hosts. ## Request headers - `Authorization: Bearer ` diff --git a/docs/alibaba-token-plan.md b/docs/alibaba-token-plan.md index 1d8884605e..5f3a8145a0 100644 --- a/docs/alibaba-token-plan.md +++ b/docs/alibaba-token-plan.md @@ -1,5 +1,5 @@ --- -summary: "Alibaba Token Plan provider notes: Bailian cookie auth, subscription summary endpoint, and setup." +summary: "Alibaba Token Plan provider notes: Team and Personal/Solo variants, cookie auth, and setup." read_when: - Adding or modifying the Alibaba Token Plan provider - Debugging Alibaba Token Plan cookie import or subscription summary fetching @@ -8,11 +8,12 @@ read_when: # Alibaba Token Plan Provider -The Alibaba Token Plan provider tracks Bailian token-plan credits from the Alibaba Cloud console. +The Alibaba Token Plan provider tracks Team credits and Personal/Solo rolling-window usage from the Alibaba Cloud console. ## Features - **Token-plan usage display**: Shows used, total, and remaining token-plan credits when Bailian returns quota totals. +- **Personal/Solo windows**: Shows 5-hour and 7-day usage, reset times, and tier-specific quota totals. - **Cookie-based auth**: Uses browser cookies or a pasted `Cookie:` header. - **Expiry awareness**: Shows the nearest token-plan expiration date as the reset time when the subscription summary includes it. @@ -20,27 +21,32 @@ The Alibaba Token Plan provider tracks Bailian token-plan credits from the Aliba 1. Open **Settings -> Providers** 2. Enable **Alibaba Token Plan** -3. Leave **Cookie source** on **Auto** (recommended) +3. Choose the matching **Gateway region** Team or Personal/Solo variant +4. Leave **Cookie source** on **Auto** (recommended) ### Manual cookie import (optional) -1. Open `https://bailian.console.aliyun.com/cn-beijing?tab=plan#/efm/subscription/token-plan` -2. Copy a `Cookie:` header from your browser's Network tab +1. Open the Token Plan page using **Open Token Plan** in settings +2. Copy a `Cookie:` header from the quota request in your browser's Network tab. For Personal/Solo, use the + `.../tokenplan/personal/api/v2/usage` request so the header is scoped to the quota host. 3. Paste it into **Alibaba Token Plan -> Cookie source -> Manual** ## How it works -- Fetches `POST https://bailian.console.aliyun.com/data/api.json?action=GetSubscriptionSummary&product=BssOpenAPI-V3&_tag=` -- Sends form-encoded fields for `product=BssOpenAPI-V3`, `action=GetSubscriptionSummary`, `region=cn-beijing`, and `params={"ProductCode":"sfm_tokenplanteams_dp_cn"}` -- Uses Alibaba/Bailian login cookies, with `sec_token` added when it can be resolved from the dashboard page -- Parses `TotalValue`, `TotalSurplusValue`, `TotalCount`, and `NearestExpireDate` from the subscription summary response +- Team variants fetch `GetSubscriptionSummary` from the selected international or mainland console and parse the + credit pool without probing Personal endpoints. +- Personal/Solo variants fetch `usage`, `subscription`, and `quota-config` from the rolling-window API. International + uses `bailian-singapore-cs.alibabacloud.com`; mainland uses `bailian-cs.console.aliyun.com`. +- Browser cookies are rebuilt independently for the dashboard and quota hosts. Personal/Solo requests use the + quota-host cookie header and support cookie-only auth without requiring `sec_token`. +- Config region values are `intl` and `cn` for Team, or `intl-personal` and `cn-personal` for Personal/Solo. - Supports `ALIBABA_TOKEN_PLAN_HOST` and `ALIBABA_TOKEN_PLAN_QUOTA_URL` for testing endpoint overrides ## Limitations - Alibaba Token Plan currently supports the Bailian web-cookie path only - API-key auth, token cost summaries, and automatic status polling are not supported -- The default endpoint is the China mainland Bailian token-plan subscription summary +- Live provider auth is not exercised by the test suite; endpoint changes rely on reporter confirmation after release ## Troubleshooting diff --git a/docs/amp.md b/docs/amp.md index 9856abf1d7..dacdf04c69 100644 --- a/docs/amp.md +++ b/docs/amp.md @@ -1,5 +1,5 @@ --- -summary: "Amp provider notes: settings scrape, cookie auth, and free-tier usage." +summary: "Amp provider notes: CLI usage, web fallback, cookie auth, and credits." read_when: - Adding or modifying the Amp provider - Debugging Amp cookie import or settings parsing @@ -8,19 +8,28 @@ read_when: # Amp Provider -The Amp provider tracks your Amp Free usage by scraping the Amp settings page with browser cookies. +The Amp provider tracks Amp Free usage plus individual and workspace credits. It prefers the local Amp CLI, then an Amp +access token, and finally browser cookies. ## Features - **Amp Free meter**: Shows how much daily free usage remains. - **Time-to-full reset**: “Resets in …” indicates when free usage replenishes to full. -- **Browser cookie auth**: No API keys needed. +- **Individual credits**: Shows the remaining paid credit balance when Amp reports one. +- **Workspace credits**: Shows each workspace's remaining paid credit balance separately. +- **CLI-first fetch**: Uses `amp usage` when the Amp CLI is installed and signed in. +- **Access token support**: Uses `AMP_API_KEY` or the access token saved in CodexBar settings. +- **Browser cookie fallback**: Reads the legacy settings-page payload when the CLI and access token are unavailable. ## Setup 1. Open **Settings → Providers** 2. Enable **Amp** -3. Leave **Cookie source** on **Auto** (recommended) +3. Install and sign in to the Amp CLI, add an Amp access token, or leave **Cookie source** on **Auto** for web fallback + +### Access token (optional) + +Create an access token in Amp settings, then paste it into **Amp → Access token** or set `AMP_API_KEY`. ### Manual cookie import (optional) @@ -30,10 +39,16 @@ The Amp provider tracks your Amp Free usage by scraping the Amp settings page wi ## How it works -- Fetches `https://ampcode.com/settings` -- Parses the embedded `freeTierUsage` payload +- Runs `amp usage` first in automatic mode +- Calls `POST https://ampcode.com/api/internal?userDisplayBalanceInfo` with an Amp access token +- Falls back to the settings page with browser cookies +- Parses the same usage display format returned to the CLI - Computes time-to-full from the hourly replenishment rate +### “Amp access token is invalid or expired” + +Create a new access token in Amp settings, update `AMP_API_KEY` or CodexBar settings, then refresh. + ## Troubleshooting ### “No Amp session cookie found” diff --git a/docs/antigravity.md b/docs/antigravity.md index 5ba4978da8..5ad0dd43d9 100644 --- a/docs/antigravity.md +++ b/docs/antigravity.md @@ -9,7 +9,38 @@ read_when: # Antigravity provider -Antigravity supports local IDE probing and Google OAuth-backed remote usage. The OAuth path can store multiple Google accounts through the shared token-account switcher. +For Google individual, AI Pro, and Ultra accounts blocked by the June 2026 Gemini CLI OAuth +shutdown, Antigravity is the replacement path for Gemini quota tracking in CodexBar. Launch +the Antigravity app or run `agy`, sign in, then refresh. See `docs/gemini.md` for the Gemini +provider migration notes. CodexBar offers the handoff only after an observed Google migration +signal and never enables or falls back to Antigravity automatically. + +Antigravity supports four usage data sources: + +1. The Antigravity 2.0 app's local `language_server` (preferred when the app is open). +2. The `agy` CLI's embedded HTTPS localhost server (preferred over the IDE because it exposes richer quota data). +3. The Antigravity IDE extension `language_server` (used after `agy` CLI because current IDE local payloads only expose session/model quota data). +4. Google OAuth-backed remote usage (explicit OAuth mode, and the account-scoped fallback used for multi-account switching). The OAuth path can store multiple Google accounts through the shared token-account switcher. + +The local and CLI paths both prefer Antigravity's internal `RetrieveUserQuotaSummary` quota payload and may fall back to +`GetUserStatus`, then `GetCommandModelConfigs`; CodexBar never scrapes the desktop UI or the `agy` TUI. + +As of Antigravity 2.x, the Antigravity app and `agy` CLI payloads can be richer than Google OAuth and IDE payloads. +`RetrieveUserQuotaSummary` exposes the same two groups shown by Antigravity's Model Quota UI: + +- `Gemini Models`: weekly limit and five-hour limit. +- `Claude and GPT models`: weekly limit and five-hour limit. + +Older local payloads may only include raw Claude, GPT-OSS, Gemini tiers, account plan, and session reset timestamps. +Current Antigravity IDE local endpoints return `GetUserStatus`, `GetAvailableModels`, and `GetCascadeModelConfigData` +with five-hour/session reset data, but not the app/CLI `RetrieveUserQuotaSummary` weekly/session grouping. OAuth +payloads can be less complete and may only prove model availability. Treat `auto` as the authoritative user-facing mode: +it accepts the first account-matching source in Antigravity app -> `agy` CLI -> Antigravity IDE order, and adds OAuth +when CodexBar has a selected/injected Google account or an existing shared credentials file. An all-100% +`fetchAvailableModels` payload is only accepted after `retrieveUserQuota` echoes bucket fractions; this can be an +availability-style fallback rather than the full Antigravity quota summary. +When OAuth identifies the account but quota endpoints deny access, CodexBar shows `Limits not available` instead of an +empty quota card. ## OAuth account switching @@ -17,7 +48,13 @@ Antigravity supports local IDE probing and Google OAuth-backed remote usage. The - A successful login writes the latest shared credentials to `~/.codexbar/antigravity/oauth_creds.json` and upserts a token-account entry for the Google account. - Each token-account entry stores serialized `AntigravityOAuthCredentials` and is injected into remote fetches through `ANTIGRAVITY_OAUTH_CREDENTIALS_JSON`. - When a token account is selected, the OAuth fetcher uses that account before falling back to the shared credentials file. -- The menu action is labeled `Add Account...`; switching between saved accounts uses the existing segmented/stacked token-account menu UI. + In `auto` mode the ambient Antigravity app, `agy` CLI, and IDE probes still run first, but a snapshot whose account + does not match the selected account is rejected so the pipeline falls through to the account-scoped OAuth fetch (see + `AntigravitySelectedAccountGuard`). If no account is selected/injected, `auto` includes OAuth only when the legacy + shared credentials file already exists. Explicit `cli`/`oauth` source modes stay authoritative and are not re-checked. +- Removing the last saved token account that matches `~/.codexbar/antigravity/oauth_creds.json` deletes that shared file, + so a removed CodexBar account does not silently continue refreshing through the legacy shared cache. +- The menu action is labeled `Add Account...`; switching between saved accounts scopes Google OAuth fetches. ## Remote OAuth data sources @@ -25,37 +62,117 @@ Antigravity supports local IDE probing and Google OAuth-backed remote usage. The - `POST https://cloudcode-pa.googleapis.com/v1internal:onboardUser` - `POST https://cloudcode-pa.googleapis.com/v1internal:fetchAvailableModels` - `POST https://cloudcode-pa.googleapis.com/v1internal:retrieveUserQuota` - -## Local data sources + fallback order +- `POST https://cloudcode-pa.googleapis.com/v1internal:retrieveUserQuotaSummary` (available, but current observed OAuth + responses are model-bucket shaped rather than Antigravity 2.0's two quota groups) ## Data sources + fallback order -1) **Process detection** +### 1) Antigravity app local probe + +When the Antigravity 2.0 app is running: + +1. **Process detection** - Command: `ps -ax -o pid=,command=`. - - Match process name: `language_server_macos` plus Antigravity markers: - - `--app_data_dir antigravity` OR path contains `/antigravity/`. + - The app local strategy scopes detection to the **Antigravity app** language server only + (`AntigravityStatusProbe(processScope: .appOnly)`). It deliberately does **not** + attach to an IDE or `agy` CLI process: a lower-information IDE payload should not mask + `agy`'s richer quota summary, and a stale or still-initializing `agy` can accept the + connection before it is ready. `agy` is owned exclusively by the CLI HTTPS source below, + which waits for real API readiness. The probe still classifies all kinds + (`processInfo(scope: .ideAndCLI)` is used by `isRunning()` for status reporting): + - the **Antigravity app** language server: process names such as `language_server`, `language_server_macos`, + `language_server_macos_arm`, or `language-server` plus + Antigravity markers (`--app_data_dir antigravity`, an Antigravity app bundle path, + or a path containing `/antigravity/`); or + - the **IDE** language server: the Antigravity IDE extension language server, usually under + `Antigravity IDE.app/.../extensions/antigravity/bin/` with `--app_data_dir antigravity-ide`; or + - the **CLI**: an `antigravity-cli` / `antigravity_cli` path segment, or the + `agy` binary (path-anchored so unrelated arguments/binaries do not match). + - CodexBar collects all valid local app language-server candidates and probes each reachable one. If multiple + app processes are open, it prefers the richer quota-summary snapshot over the legacy `GetUserStatus` + two-pool fallback. - Extract CLI flags: - - `--csrf_token ` (required). - - `--extension_server_port ` (HTTP fallback). - -2) **Port discovery** - - Command: `lsof -nP -iTCP -sTCP:LISTEN -p `. + - `--csrf_token `. Requirement depends on the match kind: + - **App/IDE** matches still require it - a tokenless desktop language-server match is + skipped so a later valid server can be found, otherwise `missingCSRFToken` + is reported (unchanged behavior). + - **CLI** matches accept an empty token, because the CLI's language server + exposes no `--csrf_token` flag and requires none. + - `--extension_server_port ` (HTTP fallback; app/IDE only). + - `--extension_server_csrf_token ` (preferred HTTP fallback token when present). + +2. **Port discovery** + - Command: `lsof -nP -iTCP -sTCP:LISTEN -a -p `. - All listening ports are probed. -3) **Connect port probe (HTTPS)** +3. **Connect port probe (HTTPS)** - `POST https://127.0.0.1:/exa.language_server_pb.LanguageServerService/GetUnleashData` - Headers: - `X-Codeium-Csrf-Token: ` - `Connect-Protocol-Version: 1` - First 200 OK response selects the connect port. -4) **Quota fetch** +4. **Quota fetch** - Primary: + - `POST https://127.0.0.1:/exa.language_server_pb.LanguageServerService/RetrieveUserQuotaSummary` + - Fallback 1: - `POST https://127.0.0.1:/exa.language_server_pb.LanguageServerService/GetUserStatus` - - Fallback: + - Fallback 2: - `POST https://127.0.0.1:/exa.language_server_pb.LanguageServerService/GetCommandModelConfigs` - If HTTPS fails, retry over HTTP on `extension_server_port`. +### 2) `agy` CLI HTTPS source + +When source mode is `auto` or `cli` and the desktop local probe fails, CodexBar resolves `agy` via: + +- `ANTIGRAVITY_CLI_PATH` +- `PATH` / login-shell path lookup +- Well-known paths: + - `~/.local/bin/agy` + - `/opt/homebrew/bin/agy` + - `/usr/local/bin/agy` + +CodexBar launches `agy` in a PTY because the CLI exposes its quota server only while the interactive process is alive. +The implementation still does **not** scrape terminal output; it only keeps the process alive, drains discarded PTY +rendering, discovers listening ports with `lsof`, and probes the local HTTPS server: + +- First: `POST https://127.0.0.1:/exa.language_server_pb.LanguageServerService/RetrieveUserQuotaSummary` +- Fallback 1: `POST https://127.0.0.1:/exa.language_server_pb.LanguageServerService/GetUserStatus` +- Fallback 2: `POST https://127.0.0.1:/exa.language_server_pb.LanguageServerService/GetCommandModelConfigs` + +The fallback can return quota without the account email or plan fields from `GetUserStatus`. + +Differences from the desktop local probe: + +- The CLI HTTPS endpoint does **not** require `X-Codeium-Csrf-Token`. +- Before a one-shot CLI invocation launches `agy`, CodexBar spends at most two seconds looking for an already-running, + same-user `agy` at the selected binary path and reuses its tokenless local HTTPS endpoint when it returns parseable + usage for the selected account. Long-lived app/server refreshes keep using CodexBar's managed session, and + CodexBar-owned pids are excluded from external reuse so probe/idle lifecycle accounting stays balanced. +- Readiness is endpoint-based: CodexBar retries until one of the quota endpoints parses, because fresh `agy` + processes can bind a port before the quota service is initialized. +- App runtime uses a bounded warm session: `agy` is kept alive briefly after a refresh, then stopped on idle. CLI runtime + tears it down immediately after the one-shot fetch. +- Repeated endpoint failures force a relaunch instead of reusing a wedged process forever. +- CodexBar records the launched pid + executable identity and conservatively reaps only its own matching stale `agy` + process on the next launch. It never blind-kills a user-launched `agy`. + +### 3) Antigravity IDE local probe + +When the Antigravity 2.0 app and `agy` CLI are unavailable, CodexBar probes Antigravity IDE language servers with +`AntigravityStatusProbe(processScope: .ideOnly)`. Current observed IDE payloads return model-level/session quota data +through `GetUserStatus`, `GetAvailableModels`, and `GetCascadeModelConfigData`; `RetrieveUserQuotaSummary` returns 404 +from the IDE local server. This means the IDE fallback can show session bars, but should not be expected to provide the +weekly limit shown by Antigravity 2.0. + +### 4) OAuth remote fallback + +When source mode is `auto`, OAuth is used after app, `agy` CLI, and IDE paths fail if CodexBar has a selected/injected +Google account or an existing shared credentials file. The app, `agy` CLI, and IDE probes still run first, but in +`auto` mode their snapshots are accepted only when the reported account matches the selected account; otherwise the +pipeline falls through to this account-scoped OAuth fetch. When source mode is `oauth`, only OAuth is used and the +shared OAuth file can still be used as a fallback credential source. + ## Request body (summary) - Minimal metadata payload: - `ideName: antigravity` @@ -64,14 +181,27 @@ Antigravity supports local IDE probing and Google OAuth-backed remote usage. The - `ideVersion: unknown` ## Parsing and model mapping -- Source fields: +- Preferred source fields: + - `response.groups[].displayName` + - `response.groups[].buckets[].bucketId` + - `response.groups[].buckets[].displayName` + - `response.groups[].buckets[].remaining.remainingFraction` + - `response.groups[].buckets[].description` +- Legacy source fields: - `userStatus.cascadeModelConfigData.clientModelConfigs[].quotaInfo.remainingFraction` - `userStatus.cascadeModelConfigData.clientModelConfigs[].quotaInfo.resetTime` -- Mapping priority: - 1) Claude (label contains `claude` but not `thinking`) - 2) Gemini Pro Low (label contains `pro` + `low`) - 3) Gemini Flash (label contains `gemini` + `flash`) - 4) Fallback: lowest remaining percent +- Preferred quota summary UI: + - Render `Gemini Session`, `Gemini Weekly`, `Claude + GPT Session`, and `Claude + GPT Weekly` as named windows. + - Keep Antigravity's bucket description as reset prose; infer `windowMinutes` from the bucket ID/display name. + - Use the most constrained known bucket as the compact/menu-bar metric. +- Legacy user-facing quota groups: + - `Gemini` groups Gemini Pro and Gemini Flash text models. + - `Claude + GPT` groups Claude text models and GPT/GPT-OSS text models. +- Representative selection: + - Hidden model rows such as Lite, autocomplete, and image variants do not drive summary bars. + - For each group, CodexBar uses the lowest remaining known quota row and preserves that row's reset metadata. + - Rows with reset metadata but no remaining fraction stay visible as unavailable reset context only when their group + has no known usage row. - `resetTime` parsing: - ISO-8601 preferred; numeric epoch seconds as fallback. - Identity: @@ -80,14 +210,22 @@ Antigravity supports local IDE probing and Google OAuth-backed remote usage. The ## UI mapping - Provider metadata: - Display: `Antigravity` - - Labels: `Claude` (primary), `Gemini Pro` (secondary), `Gemini Flash` (tertiary) + - Labels: `Gemini` (primary), `Claude + GPT` (secondary) - Status badge: Google Workspace incidents for the Gemini product. +- Antigravity exposes many model rows, but current local payloads show them collapsing into two real usage pools: + Gemini and Claude/GPT. Detailed usage should not list every raw Gemini tier unless a future source exposes a genuinely + distinct unknown or consumed quota window. +- Some Antigravity local/CLI model config entries include reset metadata but omit `remainingFraction`. Those windows stay + in `extraRateWindows` for reset context and are marked with `usageKnown: false`; clients should not render their + `usedPercent` as a real exhausted quota. ## Constraints - Internal protocol; fields may change. -- Requires `lsof` for port detection. -- Local HTTPS uses a self-signed cert; the probe allows insecure TLS. +- Requires `lsof` for local/CLI port detection. +- Local HTTPS uses a self-signed cert; the probe allows insecure TLS only for loopback hosts. ## Key files +- `Sources/CodexBarCore/Providers/Antigravity/AntigravityCLISession.swift` +- `Sources/CodexBarCore/Providers/Antigravity/AntigravityProviderDescriptor.swift` - `Sources/CodexBarCore/Providers/Antigravity/AntigravityStatusProbe.swift` - `Sources/CodexBar/Providers/Antigravity/AntigravityProviderImplementation.swift` diff --git a/docs/architecture.md b/docs/architecture.md index 0162b3e395..f3dc337bde 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -12,8 +12,6 @@ read_when: - `Sources/CodexBar`: state + UI (UsageStore, SettingsStore, StatusItemController, menus, icon rendering). - `Sources/CodexBarWidget`: WidgetKit extension wired to the shared snapshot. - `Sources/CodexBarCLI`: bundled CLI for `codexbar` usage/status output. -- `Sources/CodexBarMacros`: SwiftSyntax macros for provider registration. -- `Sources/CodexBarMacroSupport`: shared macro support used by app/core/CLI targets. - `Sources/CodexBarClaudeWatchdog`: helper process for stable Claude CLI PTY sessions. - `Sources/CodexBarClaudeWebProbe`: CLI helper to diagnose Claude web fetches. diff --git a/docs/azure-openai.md b/docs/azure-openai.md new file mode 100644 index 0000000000..9a469d4a5d --- /dev/null +++ b/docs/azure-openai.md @@ -0,0 +1,106 @@ +--- +summary: "Azure OpenAI provider: API key, endpoint, and deployment validation probe." +read_when: + - Debugging Azure OpenAI provider setup + - Updating Azure OpenAI endpoint or deployment validation + - Explaining Azure OpenAI environment variables +--- + +# Azure OpenAI provider + +CodexBar's Azure OpenAI provider validates that a configured deployment is reachable. It does not read Azure spend, +quota history, or token usage history. + +## Authentication + +Azure OpenAI requires three values: + +1. API key +2. Resource endpoint +3. Deployment name + +Settings -> Providers -> Azure OpenAI stores those values in the shared CodexBar config. The same values can also be +provided with environment variables: + +```bash +export AZURE_OPENAI_API_KEY="..." +export AZURE_OPENAI_ENDPOINT="https://resource.openai.azure.com" +export AZURE_OPENAI_DEPLOYMENT_NAME="chat-prod" +``` + +You can store the API key through the CLI: + +```bash +printf '%s' "$AZURE_OPENAI_API_KEY" | codexbar config set-api-key --provider azure-openai --stdin +``` + +The endpoint and deployment are stored as `enterpriseHost` and `workspaceID` in the `azureopenai` provider config: + +```json +{ + "id": "azureopenai", + "apiKey": "", + "enterpriseHost": "https://resource.openai.azure.com", + "workspaceID": "chat-prod" +} +``` + +## Data source + +CodexBar sends a minimal chat-completions request to validate the deployment: + +```http +POST https://resource.openai.azure.com/openai/deployments//chat/completions?api-version=2024-10-21 +api-key: +Accept: application/json +Content-Type: application/json +``` + +For dated API versions, the request body contains one `ping` message and `max_tokens: 1`. A successful response is +parsed only for the returned `model` field so the menu can show deployment detail. + +Set `AZURE_OPENAI_API_VERSION` to override the API version. When it is set to `v1`, CodexBar uses Azure's +OpenAI-compatible v1 path, includes the deployment name as the request `model`, and uses +`max_completion_tokens: 1`: + +```http +POST https://resource.openai.azure.com/openai/v1/chat/completions +``` + +## Endpoint handling + +`AZURE_OPENAI_ENDPOINT` and the configured endpoint field must be HTTPS URLs, or bare hosts that can be normalized to +HTTPS. CodexBar rejects explicit `http://` endpoints, user info, and encoded host-delimiter tricks before attaching the +`api-key` header. + +Endpoint paths are preserved. CodexBar avoids duplicating a trailing `/openai` for dated API versions or a trailing +`/openai/v1` for the v1 API when building the validation URL. + +Each refresh with complete, valid configuration sends this real inference request and can consume billable input and +output tokens for the configured deployment. + +## Display + +- Settings shows the provider's static `api` label before a fetch. After a successful fetch, Settings' Source row and + the CLI report `deployment`. +- The menu shows the Azure OpenAI resource host as organization context. +- The primary detail line shows `Deployment: ` and includes `Model: ` when the validation response returns + one. +- The menu bar usage meter does not show spend, quota, or reset history because the provider only performs deployment + validation. + +## CLI usage + +```bash +codexbar usage --provider azure-openai +codexbar usage --provider azureopenai +codexbar usage --provider aoai +``` + +## Key files + +- `Sources/CodexBarCore/Providers/AzureOpenAI/AzureOpenAIProviderDescriptor.swift` +- `Sources/CodexBarCore/Providers/AzureOpenAI/AzureOpenAISettingsReader.swift` +- `Sources/CodexBarCore/Providers/AzureOpenAI/AzureOpenAIUsageFetcher.swift` +- `Sources/CodexBar/Providers/AzureOpenAI/AzureOpenAIProviderImplementation.swift` +- `Tests/CodexBarTests/AzureOpenAIUsageFetcherTests.swift` diff --git a/docs/bedrock.md b/docs/bedrock.md index 6cb6d7fa42..aed954d40d 100644 --- a/docs/bedrock.md +++ b/docs/bedrock.md @@ -1,14 +1,15 @@ --- -summary: "AWS Bedrock provider: Cost Explorer credentials, budget tracking, and usage display." +summary: "AWS Bedrock provider: Cost Explorer spend, CloudWatch Claude activity, credentials, and budgets." read_when: - Setting up AWS Bedrock usage tracking - - Debugging Bedrock Cost Explorer fetches - - Updating Bedrock credentials, region, or budget handling + - Debugging Bedrock Cost Explorer or CloudWatch fetches + - Updating Bedrock credentials, region, budget, or activity handling --- # AWS Bedrock provider -CodexBar reads AWS Cost Explorer for Bedrock spend and can compare the current month against an optional budget. +CodexBar reads AWS Cost Explorer for Bedrock spend and can compare the current month against an optional budget. When +permitted, it also reads CloudWatch for rolling 14-day Claude token and request totals in the configured region. ## Authentication @@ -57,19 +58,25 @@ export AWS_CLI_PATH="/opt/homebrew/bin/aws" # optional override ``` The AWS identity (from either mode) must have permission to call Cost Explorer APIs, including `ce:GetCostAndUsage`. +Grant `cloudwatch:GetMetricData` to add the optional Claude activity totals. Without that permission, cost and budget +tracking continue unchanged. ## Data source - Service: AWS Cost Explorer. - Region: `AWS_REGION` or `AWS_DEFAULT_REGION`, defaulting to `us-east-1`. - Usage: current-month Bedrock spend and historical daily cost buckets. +- Claude activity: rolling 14-day input tokens, output tokens, and requests from the configured region's `AWS/Bedrock` + CloudWatch metrics. Other model families are excluded. - Budget: `CODEXBAR_BEDROCK_BUDGET`, when set to a positive dollar amount. -- Test override: `CODEXBAR_BEDROCK_API_URL` replaces the Cost Explorer endpoint. +- Test override: `CODEXBAR_BEDROCK_API_URL` replaces the Cost Explorer endpoint; use HTTPS or loopback HTTP. +- Test override: `CODEXBAR_BEDROCK_CLOUDWATCH_API_URL` replaces the CloudWatch endpoint; use HTTPS or loopback HTTP. ## Display - Shows month-to-date Bedrock spend. - Shows budget progress when a budget is configured. +- Shows rolling 14-day Claude tokens and requests when CloudWatch access is available. - Reuses the shared inline dashboard for daily cost history when enough buckets are available. ## CLI @@ -86,6 +93,8 @@ codexbar --provider bedrock --format json --pretty - Confirm the credentials are visible to CodexBar. - Confirm the AWS account has Cost Explorer enabled. - Confirm the IAM principal can call `ce:GetCostAndUsage`. +- To include Claude token/request totals, confirm the principal can call `cloudwatch:GetMetricData` in the configured + region. - If using temporary credentials, include `AWS_SESSION_TOKEN`. - In profile mode, confirm the AWS CLI is installed (or set `AWS_CLI_PATH`) and that the profile name is correct. diff --git a/docs/chutes.md b/docs/chutes.md new file mode 100644 index 0000000000..f5e504f230 --- /dev/null +++ b/docs/chutes.md @@ -0,0 +1,59 @@ +--- +summary: "Chutes provider: API key setup, subscription usage, and quota windows." +read_when: + - Configuring Chutes usage + - Debugging Chutes subscription or quota requests +--- + +# Chutes Provider + +CodexBar reads subscription and quota usage from Chutes' management API with a manually configured API key. + +## Service context + +Chutes' [terms are governed by the laws of Nevis, Saint Kitts and Nevis](https://chutes.ai/terms), and its +[decentralized backend uses independent miners](https://chutes.ai/docs/miner-resources/overview). Its pricing surface +has changed over time and should be treated as historically unstable; check the [current pricing page](https://chutes.ai/pricing) +before relying on a plan or rate. + +## Authentication + +Create a Chutes API key using the [official authentication guide](https://chutes.ai/docs/getting-started/authentication), then add it in CodexBar Settings → Providers → Chutes. + +You can also set the environment variable: + +```bash +export CHUTES_API_KEY="cpk_..." +``` + +Or configure it through the CLI: + +```bash +printf '%s' "$CHUTES_API_KEY" | codexbar config set-api-key --provider chutes --stdin +``` + +## Data Source + +CodexBar requests: + +- `GET https://api.chutes.ai/users/me/subscription_usage` +- `GET https://api.chutes.ai/users/me/quotas` when subscription data does not contain every usage window +- `GET https://api.chutes.ai/users/me/quota_usage/{chute_id}` for quota details when available + +All requests use `Authorization: Bearer cpk_...`. Subscription usage is required; quota-detail requests are best-effort. + +## Display + +The provider prefers the rolling four-hour window as the primary meter and monthly subscription usage as the secondary meter. Accounts without a subscription can still show available pay-as-you-go quota data. + +## CLI Usage + +```bash +codexbar --provider chutes +``` + +## Troubleshooting + +- Confirm the key can read `https://api.chutes.ai/users/me/subscription_usage`. +- A `401` or `403` means Chutes rejected the key. +- `CHUTES_API_URL` can override the management API base URL, but CodexBar accepts HTTPS endpoints only. diff --git a/docs/claude-comparison-since-0.18.0beta2.md b/docs/claude-comparison-since-0.18.0beta2.md index 6d7f665bc5..dece49f234 100644 --- a/docs/claude-comparison-since-0.18.0beta2.md +++ b/docs/claude-comparison-since-0.18.0beta2.md @@ -1,3 +1,11 @@ +--- +summary: "Claude fetch behavior comparison covering OAuth, web, CLI, and Keychain prompt changes." +read_when: + - Reviewing Claude fetch regressions since 0.18.0 beta 2 + - Changing Claude OAuth, web, CLI, or Keychain prompt behavior + - Comparing old and current Claude credential flows +--- + # Claude Fetch Comparison (`7b79b2d` vs `HEAD`) This document compares Claude data fetching behavior between: diff --git a/docs/claude-multi-account-and-status-items.md b/docs/claude-multi-account-and-status-items.md new file mode 100644 index 0000000000..151be1335f --- /dev/null +++ b/docs/claude-multi-account-and-status-items.md @@ -0,0 +1,186 @@ +--- +summary: "Accepted design for Claude subscription accounts and per-account menu bar items." +read_when: + - Reviewing Claude multi-account support + - Designing per-account status items + - Evaluating claude-swap integration +--- + +# Claude multi-account and status item decision + +Status: **Phase 1 account display implemented; Phase 2 explicit account activation accepted.** + +Related: [#1756](https://github.com/steipete/CodexBar/issues/1756), +[#1268](https://github.com/steipete/CodexBar/issues/1268), and the bounded Claude sign-in repair in +[#1811](https://github.com/steipete/CodexBar/pull/1811). + +## Accepted direction + +1. Use an opt-in `claude-swap` adapter as the first Claude subscription multi-account source. +2. Normalize its results behind a provider-neutral account snapshot before adding any status item UI. +3. Make per-account status items opt-in, replace the provider item for that provider, cap selection at four, and keep + them mutually exclusive with Merge Icons. +4. Allow an explicit click on an inactive account card to invoke exactly `cswap --switch-to --json`. Keep + automatic switching and session launching out of scope. + +This solves the durable OAuth refresh problem without making CodexBar a second credential vault. It also avoids a +Claude-only status item implementation that would need to be redesigned for Codex and other providers. + +![Proposed multi-account settings and status items](screenshots/claude-multi-account-status-items-proposal.svg) + +## Current architecture and gap + +CodexBar has three account concepts today: + +- The ambient Claude OAuth credential is routed from CodexBar's cache, Claude Code's credentials file, or Claude + Code's Keychain item. It represents one active credential. Claude Code-owned expired credentials delegate refresh + back to the CLI; CodexBar-owned cached credentials can refresh directly. +- `ProviderTokenAccount` stores a label and one token plus optional provider metadata. It has no refresh token or + expiry model. Claude entries therefore work for session cookies, Admin API keys, or short-lived OAuth access tokens, + but they are not durable multi-subscription OAuth sessions. +- `TokenAccountUsageSnapshot` and `CodexAccountUsageSnapshot` separately project multi-account usage into menus. + Status items remain provider-scoped: `StatusItemIdentity` has only `merged` and `provider`, and + `statusItems` is keyed by `UsageProvider`. + +The recently merged [#1800](https://github.com/steipete/CodexBar/pull/1800) scopes Claude OAuth history to the routed +Keychain identity. [#1776](https://github.com/steipete/CodexBar/pull/1776) prevents CLI-runtime usage refreshes from +delegating credential repair to Claude Code, while app and user-initiated repair remain available. Both changes improve +single-active-account correctness; neither discovers or displays multiple subscriptions. + +The closed [#1707](https://github.com/steipete/CodexBar/pull/1707) should not be revived. It coupled account discovery, +credential resolution, provider routing, menu rendering, and animation across a large patch while broadening +Keychain and prompt behavior. The safer seam is a credential-free usage adapter first. + +## Source options + +| Option | Credential ownership | Durability | Risk | Recommendation | +| --- | --- | --- | --- | --- | +| First-party OAuth account vault | CodexBar | High | New login, refresh, storage, revocation, migration, and security surface | Defer | +| Bounded `claude-swap` adapter | `claude-swap` | High | External executable and schema dependency | **Phase 1–2** | +| Discover Claude Code Keychain entries | Claude Code / ambiguous | Unknown | Undocumented enumeration; prompt and identity hazards | Reject | +| Existing token accounts | CodexBar config | Low for OAuth | Access token expires without refresh metadata | Keep for current cookie/API-key uses | + +As of [`claude-swap` v0.18.0](https://github.com/realiti4/claude-swap/releases/tag/v0.18.0), +`cswap --list --json` still returns a versioned object with `schemaVersion: 1`, an active account number, account slots, +redaction-sensitive email labels, 5-hour and 7-day usage percentages, optional model-scoped weekly windows, and reset +timestamps. Handled failures return an error object and non-zero exit. Direct switching returns the same versioned +envelope. CodexBar does not need +`--token-status`, credential files, Keychain access, or raw OAuth values for display or explicit activation. + +## Phase 1 adapter contract + +- Disabled by default. User chooses an executable path and enables “Read accounts from claude-swap.” +- Execute exactly the argument array `cswap --list --json`. Never invoke a shell or accept config-defined passthrough + arguments. +- Require `schemaVersion == 1`; reject unknown versions and partial top-level shapes. +- Bound runtime and stdout, terminate on timeout, and retain the last successful snapshot with a stale marker. +- Parse only slot number, active state, usage status, 5-hour/7-day percentages, optional `usage.scoped` display names + and percentages, and reset timestamps. Ignore malformed or unknown scoped rows without discarding valid account-wide + windows. +- Treat email as display-only sensitive data. Never log or persist it. Respect Hide Personal Info. +- Use the source-issued numeric slot for identity (`claude-swap:`), not email or credential-derived values. +- CodexBar never reads `claude-swap` storage, Claude Code storage, environment credentials, or Keychain entries. The + subprocess remains solely responsible for its own credential access. The adapter copies only allow-listed + usage/identity fields into its model and never logs or persists raw stdout. +- Never run `auto`, `run`, `--switch`, `--switch-to`, `--add-account`, export, import, purge, or any other command in + Phase 1. +- Isolate adapter failure from ambient Claude usage. Users without `claude-swap` see no behavior change. + +The executable is an optional external dependency, not a bundled component. Preferences should show detected version, +last refresh, adapter errors, and a link to the upstream project; CodexBar should not install or update it. + +## Phase 2 explicit activation contract + +- Only an explicit click on an inactive, actionable account card can start a switch. +- Derive the numeric slot from the already validated account snapshot and execute exactly + `cswap --switch-to --json`; never accept free-form arguments or invoke a shell. +- Serialize switches, validate `schemaVersion == 1` and the returned target slot, and bound captured output. +- Once launched, let the external credential transaction reach its natural exit without forced timeout or + cancellation. If the adapter setting changes, hide its UI state and discard its result when the original + configuration is no longer current. +- Refresh ambient Claude usage and the adapter account list after completion. Show switch errors independently from + list-refresh errors and preserve the last successful usage snapshots. +- Keep expired, missing, unknown, and Keychain-inaccessible credential slots non-actionable. Never auto-switch, launch + sessions, add/import/export/purge accounts, or mutate credentials directly. + +## Provider-neutral account model + +Introduce one projection used by menus and status items rather than teaching status item code about Claude OAuth: + +```swift +struct ProviderAccountUsageSnapshot: Identifiable { + let id: ProviderAccountIdentity + let provider: UsageProvider + let displayLabel: String + let isActive: Bool + let canActivate: Bool + let snapshot: UsageSnapshot? + let error: String? + let sourceLabel: String? +} + +struct ProviderAccountIdentity: Hashable { + let source: String + let opaqueID: String +} +``` + +Adapters own identity conversion. UI receives a user alias or privacy-safe ordinal when personal information is hidden. +No provider may fill identity, plan, or usage fields using another provider's data. + +Existing `TokenAccountUsageSnapshot` and `CodexAccountUsageSnapshot` can migrate behind this projection in small, +separately reviewed steps. Their credential and refresh logic stays source-specific. + +## Per-account status item behavior + +Proposed setting under each provider's Accounts section: + +- `One provider icon` (default; current behavior) +- `Selected account icons`, with up to four account checkboxes + +Selecting account icons replaces that provider's aggregate item; it does not add duplicates. Account items use a +stable `StatusItemIdentity.account(provider:source:opaqueID:)`, preserve existing provider autosave names, and open the +provider menu focused on that account. A short user alias or ordinal badge distinguishes otherwise identical provider +icons. Hide Personal Info replaces labels with `Account 1`, `Account 2`, and so on. + +Merge Icons continues to mean exactly one status item. Account-icon controls are disabled while it is enabled, with a +button to turn Merge Icons off. Existing users and status item positions remain unchanged until they opt in. + +The alternative proposed in the #1268 discussion is a per-account toggle that adds selected account items, leaves +unselected accounts under the provider item, and coexists with Merge Icons. That is more granular, but it creates +duplicate provider/account items, makes “Merge Icons” no longer mean one item, and multiplies autosave and recovery +states. The replacement mode above is the recommendation; if maintainers prefer the additive mode, grouping and Merge +Icons semantics must be decided before implementation. + +## UI proof + +The mock above shows the recommended mode and its Merge Icons conflict. It is intentionally a decision artifact, not +an implementation screenshot. The following packaged synthetic-account proof verifies the bounded current behavior: +the account action is now named “Sign in with Claude Code…” and no longer claims it will add a durable CodexBar account. +No real credential, browser session, or provider call was used. + +![Packaged synthetic Claude sign-in proof](screenshots/claude-sign-in-synthetic-proof.png) + +## Accepted decisions + +1. The optional external `claude-swap` dependency is accepted for exact `cswap --list --json` execution and explicit + `cswap --switch-to --json` activation. +2. Automatic switching, account add/import/export/purge, and session launching stay out of scope. +3. Provider-neutral account snapshots land before any per-account status item work. +4. Per-account status items are capped at four and mutually exclusive with Merge Icons. +5. Status item labels use aliases or privacy-safe ordinals, never email identity. + +Any further change to these decisions requires a new product/auth review before implementation because it changes +storage, status item migration, process authority, or the credential boundary. + +## Implementation and validation sequence + +1. Add fixtures for schema v1, error payloads, unknown versions, invalid percentages/timestamps, output limits, and + process timeout. Use a fake executable only. +2. Add the opt-in adapter and provider-neutral projection. Verify no credential reads and no impact on ambient Claude. +3. Add settings-state and menu-model tests. Keep AppKit status item creation out of headless tests. +4. Add status item identity/migration tests, then implement account items behind the opt-in setting. +5. Add exact-argv, strict switch-result, serialization, and refresh tests using a fake executable only. +6. Run focused tests, `make check`, `make test`, packaged synthetic proof, and macOS UI proof with redacted fixtures. + +No credential import, automatic switching, session launching, or compatibility shim is part of this proposal. diff --git a/docs/claude.md b/docs/claude.md index ecafe17865..8e90142c62 100644 --- a/docs/claude.md +++ b/docs/claude.md @@ -65,9 +65,12 @@ Admin API key setup: - CodexBar OAuth cache when available. - File fallback: `~/.claude/.credentials.json`. - Claude CLI Keychain bootstrap/repair fallback: `Claude Code-credentials`. +- On Claude Code 2.1.x, `Claude Code-credentials` may contain only MCP server OAuth state (`mcpOAuth`) with no `claudeAiOauth`. CodexBar treats that as an OAuth configuration error, does not run background delegated `claude /status` refresh, and surfaces re-auth guidance. Use Web or CLI usage source, or restore a valid Claude OAuth keychain entry. See #1844. - Requires `user:profile` scope (CLI tokens with only `user:inference` cannot call usage). -- Endpoint: +- Endpoints: - `GET https://api.anthropic.com/api/oauth/usage` + - `GET https://api.anthropic.com/api/oauth/profile` → account identity used to verify that optional Web enrichment + belongs to the same Claude account. - Headers: - `Authorization: Bearer ` - `anthropic-beta: oauth-2025-04-20` @@ -75,12 +78,18 @@ Admin API key setup: - `five_hour` → session window. - `seven_day` → weekly window; also becomes the primary fallback when `five_hour` is absent or has no utilization. - `seven_day_sonnet` / `seven_day_opus` → model-specific weekly window. + - `limits[].weekly_scoped` → model-specific weekly windows; generic `All models` scopes stay in the main weekly row. - `seven_day_routines` / `seven_day_cowork` → Daily Routines extra window. - Claude Design/Omelette keys are ignored because Claude Design shares the main Claude usage limit. - `extra_usage` → Extra usage cost (monthly spend/limit). -- Successful OAuth login enables Claude and selects OAuth as the usage source. +- Preferences → Providers → Claude → Show Daily Routines usage hides only the Daily Routines row in menus and the + provider preview. The global optional credits and extra usage setting is its master switch. The Claude-specific + setting does not change fetching, history, notifications, widgets, hooks, model-scoped weekly limits, or CLI output. +- Successful OAuth login enables Claude and preserves the selected usage source. With the default Auto source, OAuth + remains preferred when readable, while CLI/Web fallback stays available when OAuth credentials are not usable. - Plan inference: `subscriptionType` is preferred when present; `rate_limit_tier` falls back to - Max/Pro/Team/Enterprise. + Max/Pro/Team/Enterprise. When a Max `rate_limit_tier` carries a usage multiplier + (`default_claude_max_5x` / `default_claude_max_20x`), it is surfaced in the label as "Max 5x" / "Max 20x". ## Web API (cookies) - Preferences → Providers → Claude → Cookie source (Automatic or Manual). @@ -104,18 +113,70 @@ Admin API key setup: - `GET https://claude.ai/api/organizations` → org UUID. - `GET https://claude.ai/api/organizations/{orgId}/usage` → session/weekly/opus. - `GET https://claude.ai/api/organizations/{orgId}/overage_spend_limit` → Extra usage spend/limit. + - `GET https://claude.ai/api/organizations/{orgId}/prepaid/credits` → remaining Usage credits balance. - `GET https://claude.ai/api/account` → email + plan hints. - Outputs: - Session + weekly + model-specific percent used. - Daily Routines extra window when returned by the usage API. - Extra usage spend/limit (if enabled). + - Remaining Usage credits balance (if enabled). - Account email + inferred plan. +## claude-swap accounts (opt-in) + +The accepted multi-account design in +[claude-multi-account-and-status-items.md](claude-multi-account-and-status-items.md). + +- Setup: Preferences → Providers → Claude → "Read accounts from claude-swap", then set the path to the + [`cswap`](https://github.com/realiti4/claude-swap) executable (for example `~/.local/bin/cswap`). +- Behavior: on each Claude refresh, CodexBar runs `cswap --list --json` independently of the ambient Claude fetch (no + shell, fixed arguments, bounded runtime and output), requires `schemaVersion == 1`, and parses only slot number, + active state, usage status, email (display only), the 5-hour/7-day windows, and optional display-only model-scoped + weekly windows from `usage.scoped`. +- Display: when claude-swap reports more than one account, the Claude menu and `codexbar cards` show one card per + account (active account first, then numeric slot) instead of ambient/token-account Claude cards. To use this + presentation with one account, enable “Show account card when only one account is available” or set + `claudeSwapShowSingleAccount: true` on the Claude provider in the resolved config file (normally + `~/.config/codexbar/config.json`; legacy installs may use `~/.codexbar/config.json`). The option defaults off, + zero accounts still use the ambient presentation, and account identity is `claude-swap:`, never the display + email. +- Terminal scope: this automatic precedence is cards-only and works on every supported CLI platform. An explicit + Claude provider or `--source auto` remains eligible, while `--account`, `--account-index`, `--all-accounts`, and + explicit non-auto source flags bypass the adapter. `codexbar usage` and `codexbar serve` are unchanged. +- Isolation: CodexBar never reads claude-swap or Claude Code credential storage for this feature; the + subprocess handles its own credential access. In the app, adapter failures keep the last successful accounts as + stale data, surface the error in provider settings, and never affect the ambient Claude usage card. In terminal + cards, a list failure retains the current ambient output, adds a distinct `Claude (claude-swap)` footer entry, and + exits non-zero. +- Sentinel statuses (`token_expired`, `api_key`, `keychain_unavailable`, `no_credentials`, + `unavailable`, and unknown future values) render as per-account notes instead of usage bars in both full and brief + cards. Active rows are marked `[active]`; no claude-swap row infers a plan badge. +- Switching: an inactive account with usable source credentials shows “Switch Account…”. Clicking it runs exactly + `cswap --switch-to --json`, validates the versioned result and requested slot, then refreshes both ambient + Claude usage and every claude-swap account card. Switches are serialized; no automatic switching occurs. +- Expired, missing, unknown, or Keychain-inaccessible credentials stay non-actionable. A failed switch remains visible + on that account without discarding its last successful usage. A running Claude Code process can take up to the + claude-swap Keychain cache interval to observe the new account. +- Multiple claude-swap accounts—and a single account when explicitly enabled—take precedence over Claude + token-account presentation (stacked cards and the segmented switcher). + +Packaged synthetic proof (fake `cswap` executable, no real accounts or credentials): + +![Stacked claude-swap account cards](screenshots/claude-swap-accounts-synthetic-proof.png) + +Model-scoped weekly-window proof (synthetic data, no real accounts or credentials): + +| Before | After | +| --- | --- | +| ![claude-swap card before scoped windows](screenshots/claude-swap-scoped-before.png) | ![claude-swap card with a Fable scoped weekly window](screenshots/claude-swap-scoped-after.png) | + ## CLI PTY (fallback) - Runs `claude` in a PTY session (`ClaudeCLISession`). - Default behavior: exit after each probe; Debug → "Keep CLI sessions alive" keeps it running between probes. - Probe working directory: `~/Library/Application Support/CodexBar/ClaudeProbe` with local Claude settings that disable deep-link URL handler registration during headless probes. +- After transient probes exit, CodexBar removes Claude Code `.jsonl` session artifacts for that dedicated + `ClaudeProbe` project directory so background `/usage` polling does not clutter the user's Claude project history. - Command flow: 1) Start CLI with `--allowed-tools ""` (no tools). 2) Auto-respond to first-run prompts (trust files, workspace, telemetry). @@ -126,6 +187,9 @@ Admin API key setup: - Extracts percent left/used and reset text near those headers. - Parses `Account:` and `Org:` lines when present. - Surfaces CLI errors (e.g. token expired) directly. + - Some Education and organization-managed subscriptions return only a subscription notice, with no numeric + session or weekly quota fields. CodexBar reports those limits as unavailable, keeps local cost/token history + visible, and never derives quota percentages from spend or token totals. ## Cost usage (local log scan) - Source roots: @@ -133,19 +197,27 @@ Admin API key setup: - `$CLAUDE_CONFIG_DIR` (comma-separated), each root uses `/projects`. - Fallback roots: - `~/.config/claude/projects` - - `~/.claude/projects` - - Supported pi sessions: + - `~/.claude/projects` (Claude Code and current Claude Desktop Code/Cowork CLI sessions) + - Additional embedded Claude Desktop project stores, when present: + - `~/Library/Application Support/Claude/local-agent-mode-sessions/**/.claude/projects` + - `~/Library/Application Support/Claude/claude-code-sessions/**/.claude/projects` + - Current Claude Desktop metadata under `claude-code-sessions` points to shared CLI session JSONL by + `cliSessionId`; metadata-only directories are not treated as usage sources. + - Supported pi-compatible sessions: - `~/.pi/agent/sessions/**/*.jsonl` -- Files: `**/*.jsonl` under the native project roots plus supported pi session files. + - `~/.omp/agent/sessions/**/*.jsonl` +- Files: `**/*.jsonl` under the native project roots, discovered Claude Desktop project roots, + plus supported pi-compatible session files. - Parsing: - Native Claude logs parse lines with `type: "assistant"` and `message.usage`. - Uses per-model token counts (input, cache read/create, output). - Deduplicates streaming chunks by `message.id + requestId` (usage is cumulative per chunk). - - pi sessions attribute `anthropic` assistant usage to Claude and bucket it by assistant-turn timestamp, so a single pi - session can contribute to multiple models/days. + - pi and OMP sessions attribute `anthropic` assistant usage to Claude and bucket it by assistant-turn timestamp, so a + single pi-compatible session can contribute to multiple models/days. + - Matching assistant entry IDs within the same session are counted once across roots; distinct turns are retained. - Cache: - Native + merged provider cache: `~/Library/Caches/CodexBar/cost-usage/claude-v2.json` - - pi session cache: `~/Library/Caches/CodexBar/cost-usage/pi-sessions-v1.json` + - pi-compatible session cache: `~/Library/Caches/CodexBar/cost-usage/pi-sessions-v7.json` ## Key files - OAuth: `Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/*` diff --git a/docs/clawrouter.md b/docs/clawrouter.md new file mode 100644 index 0000000000..a65a73055c --- /dev/null +++ b/docs/clawrouter.md @@ -0,0 +1,51 @@ +--- +summary: "ClawRouter setup for monthly budget, spend, and routed-provider usage." +read_when: + - Configuring ClawRouter usage tracking + - Debugging ClawRouter budget or provider breakdown display + - Explaining ClawRouter API key and base URL settings +--- + +# ClawRouter + +CodexBar reads the policy attached to a ClawRouter API key. The menu card shows its monthly budget, spend, requests, +tokens, and provider breakdown. Provider rows come directly from ClawRouter, so the integration works with any routed +model provider configured there; CodexBar does not need a separate provider plugin for each route. + +## Setup + +Create a ClawRouter key with access to the routes you want, then store it in CodexBar: + +```bash +printf '%s' "$CLAWROUTER_API_KEY" | codexbar config set-api-key --provider clawrouter --stdin +``` + +You can also paste the key in CodexBar Settings → Providers → ClawRouter. The hosted service is used by default: + +```text +https://clawrouter.openclaw.ai +``` + +For another deployment, set the Base URL in Settings or use `CLAWROUTER_BASE_URL`. The value may point to the service +root or `/v1`; CodexBar normalizes both to `/v1/usage`. Overrides must be HTTPS URLs or bare hosts normalized to HTTPS. + +## Display + +- Monthly budget meter and reset date when the policy has a budget. +- This-month spend against the configured limit. +- Request count and total token usage. +- Up to five routed-provider rows, ordered by spend and request count. +- Unmetered policy status and spend when no monthly limit is configured. + +ClawRouter usage is policy-wide. If one key can route OpenAI, Anthropic, Google, OpenRouter, or non-model services, the +same CodexBar card aggregates them and lists the provider identifiers returned by `/v1/usage`. + +## Environment variables + +| Variable | Description | +| --- | --- | +| `CLAWROUTER_API_KEY` | ClawRouter API key. | +| `CLAWROUTER_BASE_URL` | Optional HTTPS service root or `/v1` URL. | + +CodexBar sends the key only to the validated ClawRouter endpoint. `/v1/usage` returns accounting metadata; CodexBar +never receives routed prompts or model responses. diff --git a/docs/cli-configuration.md b/docs/cli-configuration.md index cdd0608263..414af4b9f2 100644 --- a/docs/cli-configuration.md +++ b/docs/cli-configuration.md @@ -8,7 +8,9 @@ read_when: # CLI configuration -`codexbar config` edits the same `~/.codexbar/config.json` file used by the app's Settings → Providers pane. +`codexbar config` edits the same resolved config file used by the app's Settings → Providers pane. +New installs use `~/.config/codexbar/config.json`; absolute `XDG_CONFIG_HOME` paths and `CODEXBAR_CONFIG` are +supported, and existing `~/.codexbar/config.json` installs keep using the legacy file when no XDG config exists. The CLI writes the file with `0600` permissions. ## Providers @@ -56,15 +58,30 @@ printf '%s' "$DEEPGRAM_API_KEY" | codexbar config set-api-key --provider deepgra printf '%s' "$GROQ_API_KEY" | codexbar config set-api-key --provider groq --stdin printf '%s' "$LLM_PROXY_API_KEY" | codexbar config set-api-key --provider llmproxy --stdin printf '%s' "$Z_AI_API_KEY" | codexbar config set-api-key --provider zai --stdin +printf '%s' "$XAI_MANAGEMENT_API_KEY" | codexbar config set-api-key --provider xai --stdin ``` +For a z.ai team account: + +```bash +printf '%s' "$Z_AI_API_KEY" | codexbar config set-api-key --provider zai --stdin \ + --label Team \ + --usage-scope team \ + --organization-id org_... \ + --workspace-id proj_... +``` + +Use single-line BigModel organization/project IDs; see [z.ai](zai.md). + Only providers that consume config-backed API keys accept this command. Admin API providers may require a key with -organization/usage permissions, not a normal inference key. Browser/OAuth providers such as Grok use their own provider -sessions instead of an xAI API key for CodexBar's billing view, so enable them with +organization/usage permissions, not a normal inference key. The `xai` provider reads xAI developer-platform billing +with a Management key plus a team ID (set `workspaceID` in the provider entry, `XAI_TEAM_ID`, or the app settings +pane); inference API keys are not accepted. The separate Grok provider tracks consumer Grok/SuperGrok subscriptions +through its own browser/CLI session and takes no API key, so enable it with `codexbar config enable --provider grok`. LLM Proxy also needs a base URL. Use `LLM_PROXY_BASE_URL` for CLI runs, or add `"enterpriseHost"` to the provider entry -in `~/.codexbar/config.json`. +in the CodexBar config file. ## Isolated config files diff --git a/docs/cli.md b/docs/cli.md index c9a0101524..ad834cb7b2 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -20,7 +20,8 @@ Use it when you need usage numbers in scripts, CI, or dashboards without UI. - Homebrew formula (Linux today): `brew install steipete/tap/codexbar`. - Download release tarballs from GitHub Releases: - macOS: `CodexBarCLI-v-macos-arm64.tar.gz`, `CodexBarCLI-v-macos-x86_64.tar.gz` - - Linux: `CodexBarCLI-v-linux-aarch64.tar.gz`, `CodexBarCLI-v-linux-x86_64.tar.gz` + - Linux (glibc): `CodexBarCLI-v-linux-aarch64.tar.gz`, `CodexBarCLI-v-linux-x86_64.tar.gz` + - Linux (static musl): `CodexBarCLI-v-linux-musl-aarch64.tar.gz`, `CodexBarCLI-v-linux-musl-x86_64.tar.gz` - Extract and run `./codexbar` (symlink) or `./CodexBarCLI`. ``` @@ -35,26 +36,72 @@ tar -xzf CodexBarCLI-v0.17.0-macos-x86_64.tar.gz - Dependencies: Swift 6.2+, Commander package (`https://github.com/steipete/Commander`). ## Configuration -CodexBar reads `~/.codexbar/config.json` for provider settings, secrets, and ordering. +CodexBar reads the resolved config file for provider settings, secrets, and ordering. New installs use +`~/.config/codexbar/config.json`; absolute `XDG_CONFIG_HOME` paths and `CODEXBAR_CONFIG` are supported, and existing +`~/.codexbar/config.json` installs keep using the legacy file when no XDG config exists. See `docs/configuration.md` for the schema. ## Command - `codexbar` defaults to the `usage` command. - `--format text|json` (default: text). -- `codexbar cost` prints local token cost usage for Claude + Codex without web/CLI access. +- `codexbar cost` prints token cost usage for Claude, Codex, and Cursor. + - Claude and Codex are scanned from local session logs without web/CLI access. + - Cursor is fetched from the cookie-authenticated cursor.com dashboard API (macOS only; see `docs/cursor.md`) and honors the configured cookie source: a non-empty Manual header is required and forwarded, while Off fails explicitly instead of silently omitting Cursor. - `--format text|json` (default: text). - `--refresh` ignores cached scans. -- `codexbar serve` starts a foreground localhost-only HTTP server for usage and cost JSON. +- `codexbar cards` prints a one-shot usage snapshot as a responsive terminal card grid. + - Reuses the same provider, source, account, credits, and status flags as `codexbar usage`. + - Account lines and plan badges are included in the card grid by default. + - `--brief` renders a compact table (Provider / Usage / Reset) instead of the card grid. + - Stdout is always rendered text; `--json-output` only affects stderr logs (no JSON card payload). + - Failed providers are summarized in a footer (not rendered as error cards). + - When the opt-in Claude claude-swap integration returns two or more accounts—or one account with + `claudeSwapShowSingleAccount` enabled—cards renders every account in active-first/slot order instead of the + ambient or token-account Claude cards. This applies on macOS and Linux, including an explicit + `--provider claude`; `--source auto` remains eligible. + - `--account`, `--account-index`, `--all-accounts`, and explicit non-auto source flags preserve their requested + ambient behavior and do not invoke claude-swap. Zero-account lists always retain ambient Claude output; + one-account lists do so unless `claudeSwapShowSingleAccount` is enabled. + - claude-swap sentinel accounts remain successful cards with their problem text and no fabricated usage metrics. + A list adapter, parser, or timeout failure retains useful ambient Claude output, adds a distinct + `Claude (claude-swap)` failure footer entry, and makes the command exit non-zero. + - This precedence is cards-only: `codexbar usage` and `codexbar serve` keep their existing output cardinality. + - Honors `$COLUMNS` for layout; falls back to 80 columns. Use `--no-color` for plain output. + - Kitty, Ghostty, WezTerm, and other truecolor terminals auto-enable enhanced gradients/outlines. + - Force enhanced mode elsewhere with `CODEXBAR_CARDS_ENHANCED=1`. + - Exit code is non-zero when any provider fetch fails. +- `codexbar serve` starts a foreground HTTP server for usage and cost JSON plus a token-gated dashboard snapshot. + - `--host ` accepts `localhost` or an IPv4 address and defaults to `127.0.0.1`; `localhost` is normalized to `127.0.0.1`. Binding a non-loopback host requires a dashboard token **and** `--allow-plain-http` (see `docs/dashboard-api.md` for the threat model). - `--port ` defaults to `8080`. - `--refresh-interval ` defaults to `60` and controls the in-memory response cache TTL. - - 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=`. + - `--request-timeout ` defaults to `30` and bounds each request before returning `504 Gateway Timeout`; use `0` to keep waiting indefinitely. + - `--dashboard-token ` sets the static bearer token for `GET /dashboard/v1/snapshot`. Prefer the `CODEXBAR_DASHBOARD_TOKEN` environment variable (it wins over the flag; a flag value leaks via `ps`). Empty or whitespace-only tokens are startup errors. Without a token the snapshot route fails closed with `401`. + - On a **non-loopback** host the token gates **all data routes** — `/usage`, `/cost`, and `/dashboard/v1/snapshot` all require `Authorization: Bearer YOUR_TOKEN`, so account data is never exposed to the network unauthenticated. `/health` is always open. On the default loopback bind, `/usage` and `/cost` stay unauthenticated. + - `--allow-plain-http` is the explicit acknowledgment that the bearer token crosses the network **in cleartext on every request** when serving on a non-loopback host. `serve` refuses to start on a non-loopback host without it. + - Provider config is reloaded for each usage/cost request; cache entries are keyed by the loaded config so provider toggles and source changes do not require restarting `serve`. + - Transient refresh failures fall back to the last good response for up to ten refresh intervals (minimum five minutes) so polling clients do not flicker between data and errors; disabled when `--refresh-interval 0`. + - The default loopback bind rejects non-loopback `Host` headers; a configured non-loopback `--host` additionally accepts its own name. No CORS, TLS, or daemon mode. + - Endpoints: `GET /health`, `GET /usage`, `GET /usage?provider=`, `GET /cost`, `GET /cost?provider=`, `GET /dashboard/v1/snapshot`. + - `GET /dashboard/v1/snapshot` requires `Authorization: Bearer YOUR_TOKEN`; responses (and all `401`s) carry `Cache-Control: no-store`. The token is never accepted via query string. See `docs/dashboard-api.md` for the payload contract. + - `GET /health` returns `{"status":"ok"}` plus a `version` field with the running build (e.g. `"0.37.2"`) when resolvable; clients can compare it against `codexbar --version` to detect a `serve` process still running an older binary after an update. - Codex usage responses include every visible Codex account, matching the menu bar switcher. - `codexbar cache clear` clears local CodexBar caches. - `--cookies` removes cached browser-cookie headers from the CodexBar Keychain cache. - `--cookies --provider ` removes browser-cookie cache entries for that provider, including managed Codex account scopes. - `--cost` removes local cost-usage scan caches. - `--all` clears both cookies and cost caches. `--provider` is cookie-only and cannot be combined with `--cost` or `--all`. +- `codexbar cookie refresh` ignores the provider's current cookie caches while importing a replacement through its web strategy. A failed or interrupted import leaves existing cookies intact. + - Choose exactly one of `--provider ` or `--all`; provider support comes from shared browser-cookie metadata rather than a fixed CLI list. + - Prompt-capable Chromium imports require `--allow-keychain-prompt`. Without it, the command fails before cache mutation with an interactive-retry hint. + - A six-hour Keychain-denial cooldown is bypassed only by that explicit acknowledgment flag. Output never includes cookie values. + - Providers configured for Manual or Off cookie sources are skipped. +- `codexbar guard --provider ` gates automation on one provider's remaining quota. + - `--min-remaining ` sets the inclusive threshold (default: `10`; valid range: `0...100`). + - `--window session|weekly` selects the primary/session window or secondary/weekly window (default: `session`). + - `--timeout ` bounds the complete fetch (range: `0...86400`; default: `60`; `0` disables this guard-level deadline while provider-specific timeouts still apply). + - `--json` emits the provider, window, remaining quota, threshold, decision, unavailable reason, and exit code; add `--pretty` for formatted JSON. + - Stable guard exit codes: `0` means safe, `1` means below threshold, `64` (`EX_USAGE`) means invalid arguments, and `69` (`EX_UNAVAILABLE`) means the quota could not be checked or the selected window is unavailable. `--fail-open` changes only unavailable results from `69` to `0`; JSON still reports `decision: "unknown"` and the reason. + - Guard fetches are read-only and use background interaction policy, matching `codexbar usage`; they never request interactive Keychain access. - `--provider ` (default: enabled providers in config; falls back to defaults when missing). - Provider IDs live in the config file (see `docs/configuration.md`). - With three or more providers enabled, the default stays scoped to enabled providers; use `--provider all` to query @@ -66,28 +113,36 @@ See `docs/configuration.md` for the schema. - `--antigravity-plan-debug` (debug: print Antigravity planInfo fields to stderr). - `--source ` (default: `auto`). - `auto`: provider-specific fallback order from `docs/providers.md`. - - `web` (macOS only): web-only where that provider exposes an explicit web source; no CLI/API fallback. + - `web`: web-only where that provider exposes an explicit web source; no CLI/API fallback. Browser import is macOS-only, while supported providers can use configured manual cookies on Linux. - `cli`: CLI/local-helper source where the provider exposes one (for example Codex RPC/PTy, Claude PTY, Kilo CLI fallback, Kiro CLI, local probes). - `oauth`: OAuth-backed source where supported (Codex, Claude, Vertex AI). - - `api`: API-key/token flow when the provider supports it (OpenAI, Claude Admin API, z.ai, Gemini, Alibaba, Copilot, Kilo, Kimi K2, MiniMax, Ollama, Warp, OpenRouter, ElevenLabs, Deepgram, Synthetic, DeepSeek, Moonshot, Doubao, Codebuff, Crof, Venice, AWS Bedrock). + - `api`: API-key/token flow when the provider supports it (OpenAI, Claude Admin API, z.ai, Gemini, Alibaba, Copilot, Kilo, Kimi, MiniMax, Ollama, Warp, OpenRouter, ElevenLabs, Deepgram, Synthetic, DeepSeek, DeepInfra, Moonshot, Doubao, Codebuff, Crof, Venice, AWS Bedrock). - Output `source` reflects the strategy actually used (`openai-web`, `web`, `oauth`, `api`, `local`, `cli`, or provider CLI label). - Codex web: OpenAI web dashboard (usage limits, credits remaining, code review remaining, usage breakdown). - `--web-timeout ` (default: 60) - `--web-debug-dump-html` (writes HTML snapshots to `/tmp` when data is missing) - - Claude web: claude.ai API (session + weekly usage, plus account metadata when available). - - Command Code web: commandcode.ai browser session cookies for monthly credit usage. + - Claude web: claude.ai API (session + weekly usage, account metadata, and prepaid Usage credits balance when + available). + - Command Code web: commandcode.ai browser session cookies on macOS, or a configured manual cookie on Linux, for monthly credit usage. + - OpenCode Go auto: local SQLite usage on macOS and Linux, with optional manual-cookie web enrichment. - Kilo auto: app.kilo.ai API first, then CLI auth fallback (`~/.local/share/kilo/auth.json`) on missing/unauthorized API credentials. - - Linux: web-backed `auto`/`web` modes are not supported; CLI prints an error and exits non-zero for providers that require browser/WebKit access. + - Linux: browser-backed `auto`/`web` modes are not supported; local sources and configured manual-cookie paths remain available where documented. - Global flags: `-h/--help`, `-V/--version`, `-v/--verbose`, `--no-color`, `--log-level `, `--json-output`, `--json-only`. - `--json-output`: JSONL logs on stderr (machine-readable). - `--json-only`: suppress non-JSON output; errors become JSON payloads. -- `codexbar config validate` checks `~/.codexbar/config.json` for invalid fields. +- `codexbar config validate` checks the resolved config file for invalid fields. - `--format text|json`, `--pretty`, and `--json-only` are supported. - Warnings keep exit code 0; errors exit non-zero. - `codexbar config dump` prints the normalized config JSON. +- `codexbar hooks list` shows the local hook configuration; `--format json` and `--pretty` are supported. +- `codexbar hooks enable|disable` changes the explicit top-level opt-in switch in the local config file. +- `codexbar hooks test --provider ` invokes matching enabled rules with a representative event. Hook + commands run directly without a shell and receive `CODEXBAR_*` variables plus JSON on stdin. `--format json` and + `--json-only` return structured per-rule results. See + `docs/configuration.md#external-event-hooks` for the event, payload, timeout, and security contract. ### Token accounts -The CLI reads multi-account tokens from `~/.codexbar/config.json` (same file as the app). +The CLI reads multi-account tokens from the same resolved config file as the app. - Select a specific account: `--account